Skip to main content

gam_terms/smooth/
structure_analysis.rs

1use crate::basis::{
2    BSplineIdentifiability, CenterStrategy, ConstantCurvatureIdentifiability,
3    MaternIdentifiability, MeasureJetIdentifiability, SpatialIdentifiability,
4    SphericalSplineIdentifiability,
5};
6
7use super::{
8    ByVarKind, FactorSmoothFlavour, SmoothBasisSpec, SmoothTermSpec, TensorBSplineIdentifiability,
9};
10
11use std::collections::BTreeSet;
12
13fn smooth_basis_feature_cols(basis: &SmoothBasisSpec) -> Vec<usize> {
14    match basis {
15        SmoothBasisSpec::ByVariable { inner, by_col, .. }
16        | SmoothBasisSpec::FactorSumToZero { inner, by_col, .. } => {
17            let mut cols = smooth_basis_feature_cols(inner);
18            cols.push(*by_col);
19            cols.sort_unstable();
20            cols.dedup();
21            cols
22        }
23        SmoothBasisSpec::BySmooth { smooth, .. } => smooth_basis_feature_cols(smooth),
24        SmoothBasisSpec::BSpline1D { feature_col, .. } => vec![*feature_col],
25        SmoothBasisSpec::ThinPlate { feature_cols, .. }
26        | SmoothBasisSpec::Sphere { feature_cols, .. }
27        | SmoothBasisSpec::ConstantCurvature { feature_cols, .. }
28        | SmoothBasisSpec::Matern { feature_cols, .. }
29        | SmoothBasisSpec::MeasureJet { feature_cols, .. }
30        | SmoothBasisSpec::Duchon { feature_cols, .. }
31        | SmoothBasisSpec::Pca { feature_cols, .. }
32        | SmoothBasisSpec::TensorBSpline { feature_cols, .. } => feature_cols.clone(),
33        SmoothBasisSpec::FactorSmooth { spec } => {
34            let mut cols = spec.continuous_cols.clone();
35            cols.push(spec.group_col);
36            cols.sort_unstable();
37            cols.dedup();
38            cols
39        }
40    }
41}
42
43pub fn smooth_term_feature_cols(term: &SmoothTermSpec) -> Vec<usize> {
44    smooth_basis_feature_cols(&term.basis)
45}
46
47fn smooth_basis_family_rank(term: &SmoothTermSpec) -> u8 {
48    match &term.basis {
49        SmoothBasisSpec::ByVariable { inner, .. }
50        | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
51            smooth_basis_family_rank(&SmoothTermSpec {
52                frozen_parametric_residualization: None,
53                name: term.name.clone(),
54                basis: (**inner).clone(),
55                shape: term.shape,
56                joint_null_rotation: None,
57            })
58        }
59        SmoothBasisSpec::BSpline1D { .. } => 0,
60        SmoothBasisSpec::TensorBSpline { .. } => 1,
61        SmoothBasisSpec::ThinPlate { .. } => 2,
62        SmoothBasisSpec::Sphere { .. } => 3,
63        SmoothBasisSpec::Matern { .. } => 4,
64        SmoothBasisSpec::Duchon { .. } => 5,
65        SmoothBasisSpec::Pca { .. } => 6,
66        SmoothBasisSpec::ConstantCurvature { .. } => 8,
67        SmoothBasisSpec::MeasureJet { .. } => 9,
68        SmoothBasisSpec::BySmooth { smooth, .. } => smooth_basis_family_rank(&SmoothTermSpec {
69            frozen_parametric_residualization: None,
70            name: term.name.clone(),
71            basis: (**smooth).clone(),
72            shape: term.shape,
73            joint_null_rotation: None,
74        }),
75        SmoothBasisSpec::FactorSmooth { .. } => 7,
76    }
77}
78
79pub fn smooth_has_frozen_identifiability(term: &SmoothTermSpec) -> bool {
80    match &term.basis {
81        SmoothBasisSpec::ByVariable { inner, .. }
82        | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
83            smooth_has_frozen_identifiability(&SmoothTermSpec {
84                frozen_parametric_residualization: None,
85                name: term.name.clone(),
86                basis: (**inner).clone(),
87                shape: term.shape,
88                joint_null_rotation: None,
89            })
90        }
91        SmoothBasisSpec::BSpline1D { spec, .. } => {
92            matches!(
93                spec.identifiability,
94                BSplineIdentifiability::FrozenTransform { .. }
95            )
96        }
97        SmoothBasisSpec::ThinPlate { spec, .. } => matches!(
98            spec.identifiability,
99            SpatialIdentifiability::FrozenTransform { .. }
100        ),
101        SmoothBasisSpec::Sphere { spec, .. } => {
102            matches!(spec.center_strategy, CenterStrategy::UserProvided(_))
103                || matches!(
104                    spec.identifiability,
105                    SphericalSplineIdentifiability::FrozenTransform { .. }
106                )
107        }
108        SmoothBasisSpec::ConstantCurvature { spec, .. } => {
109            matches!(spec.center_strategy, CenterStrategy::UserProvided(_))
110                || matches!(
111                    spec.identifiability,
112                    ConstantCurvatureIdentifiability::FrozenTransform { .. }
113                )
114        }
115        SmoothBasisSpec::MeasureJet { spec, .. } => {
116            matches!(spec.center_strategy, CenterStrategy::UserProvided(_))
117                || matches!(
118                    spec.identifiability,
119                    MeasureJetIdentifiability::FrozenTransform { .. }
120                )
121        }
122        SmoothBasisSpec::Matern { spec, .. } => matches!(
123            spec.identifiability,
124            MaternIdentifiability::FrozenTransform { .. }
125        ),
126        SmoothBasisSpec::BySmooth { by_kind, .. } => match by_kind {
127            ByVarKind::Factor { frozen_levels, .. } => frozen_levels.is_some(),
128            ByVarKind::Numeric { .. } => true,
129        },
130        SmoothBasisSpec::FactorSmooth { spec } => spec.group_frozen_levels.is_some(),
131        SmoothBasisSpec::Duchon { spec, .. } => matches!(
132            spec.identifiability,
133            SpatialIdentifiability::FrozenTransform { .. }
134        ),
135        SmoothBasisSpec::Pca {
136            centered,
137            center_mean,
138            pca_basis_path,
139            ..
140        } => !*centered || center_mean.is_some() || pca_basis_path.is_some(),
141        SmoothBasisSpec::TensorBSpline { spec, .. } => matches!(
142            spec.identifiability,
143            TensorBSplineIdentifiability::FrozenTransform { .. }
144        ),
145    }
146}
147
148fn compare_smooth_ownership_priority(
149    lhs_idx: usize,
150    lhs: &SmoothTermSpec,
151    rhs_idx: usize,
152    rhs: &SmoothTermSpec,
153) -> std::cmp::Ordering {
154    let lhs_cols = smooth_term_feature_cols(lhs);
155    let rhs_cols = smooth_term_feature_cols(rhs);
156    lhs_cols
157        .len()
158        .cmp(&rhs_cols.len())
159        .then_with(|| lhs_cols.cmp(&rhs_cols))
160        .then_with(|| smooth_basis_family_rank(lhs).cmp(&smooth_basis_family_rank(rhs)))
161        .then_with(|| lhs.name.cmp(&rhs.name))
162        .then(lhs_idx.cmp(&rhs_idx))
163}
164
165/// The `(by_col, level_bits)` row-gate of a factor-`by=` level smooth
166/// (`s(x, by=fac)`, treatment-contrast level), or `None` for any other smooth
167/// (including numeric-`by` scaling, which is NOT row-gated).
168///
169/// A level-gated smooth's design is zero on every row outside its level, so its
170/// columns are NOT in the column span of an un-gated (full-support) smooth on
171/// the same covariate. Ownership/orthogonalization must therefore skip it
172/// (otherwise the per-group deviation is residualized away to zero — #1276).
173fn factor_by_level_gate_of(term: &SmoothTermSpec) -> Option<(usize, u64)> {
174    match &term.basis {
175        SmoothBasisSpec::ByVariable {
176            by_col,
177            by: crate::smooth::ByVariableSpec::Level { value_bits, .. },
178            ..
179        } => Some((*by_col, *value_bits)),
180        _ => None,
181    }
182}
183
184/// The grouping column of a sum-to-zero factor *deviation* smooth
185/// (`s(g, x, bs="sz")`, lowered to either `FactorSmooth { Sz }` or the internal
186/// `FactorSumToZero`), or `None` for any other smooth.
187///
188/// An sz smooth's per-level design columns are sum-to-zero ACROSS the grouping
189/// factor at every covariate value (the last level's block is `-Σ` of the
190/// others, so each contrast column's across-group sum vanishes pointwise). They
191/// are therefore orthogonal to — never spanned by — any owner smooth that does
192/// not itself vary with that factor. Ownership/orthogonalization against such an
193/// owner (e.g. the shared `s(x)` in the canonical `s(x) + s(g, x, bs="sz")`)
194/// would residualize the genuine deviation away, collapsing every group's curve
195/// to a constant offset (#1605 — the same failure family as the factor-`by`
196/// level gate #1276). The sz block is self-identified by its own sum-to-zero
197/// contrast + penalty, so it needs no owner block.
198///
199/// This is specific to the `Sz` flavour: `Fs`/`Re` random-effect factor smooths
200/// carry a non-zero group mean that genuinely overlaps a shared `s(x)`, so their
201/// deliberate `s(x) + fs` residualization (#978) is preserved.
202fn factor_sum_to_zero_group_col(term: &SmoothTermSpec) -> Option<usize> {
203    match &term.basis {
204        SmoothBasisSpec::FactorSumToZero { by_col, .. } => Some(*by_col),
205        SmoothBasisSpec::FactorSmooth { spec }
206            if matches!(spec.flavour, FactorSmoothFlavour::Sz) =>
207        {
208            Some(spec.group_col)
209        }
210        _ => None,
211    }
212}
213
214fn smooth_is_owned_by_prior_term(owner: &SmoothTermSpec, target: &SmoothTermSpec) -> bool {
215    // A factor-`by=` level smooth is row-gated (zero off its level), so its
216    // columns lie outside the span of any owner that is not gated to the SAME
217    // (by_col, level): the un-gated population smooth `s(x)` does not span the
218    // group deviation `s(x, by=g==level)`. Residualizing the gated deviation
219    // against the population smooth collapses it to zero (#1276). Identifiability
220    // of the deviation comes from its own factor-level gate + penalty, handled
221    // by `factor_by_level_gate` in design construction — not from ownership.
222    if let Some(target_gate) = factor_by_level_gate_of(target) {
223        if factor_by_level_gate_of(owner) != Some(target_gate) {
224            return false;
225        }
226    }
227    // A sum-to-zero factor deviation smooth is orthogonal to any owner that does
228    // not vary with its grouping factor (its columns sum to zero across that
229    // factor). Such an owner cannot span it, so skip ownership — otherwise the
230    // deviation is residualized down to a per-group constant and the curve
231    // shape is lost (#1605).
232    if let Some(group_col) = factor_sum_to_zero_group_col(target) {
233        let owner_features = smooth_term_feature_cols(owner)
234            .into_iter()
235            .collect::<BTreeSet<_>>();
236        if !owner_features.contains(&group_col) {
237            return false;
238        }
239    }
240    let owner_features = smooth_term_feature_cols(owner)
241        .into_iter()
242        .collect::<BTreeSet<_>>();
243    let target_features = smooth_term_feature_cols(target)
244        .into_iter()
245        .collect::<BTreeSet<_>>();
246    owner_features.is_subset(&target_features)
247}
248
249/// Static (spec-only) description of the hierarchical smooth-ownership decomposition.
250///
251/// This is the single source of truth for the deterministic ownership policy that
252/// `apply_global_smooth_identifiability` uses during the fit: the processing order of
253/// smooth terms, the feature columns each term spans, the candidate lower-order owners of
254/// each term (nested/duplicate feature sets), and the basis-family rank used as a
255/// tie-breaker. The fit engine consumes this structure and additionally applies a numerical
256/// cross-residual overlap test on the realized design columns; the CLI structure-warning
257/// path consumes the same structure for diagnostic messages, so both paths agree on which
258/// smooths own which subspaces.
259pub struct SmoothStructureAnalysis {
260    /// Smooth-term indices sorted into ownership-processing order (lowest priority first):
261    /// lower-order / narrower smooths come first and own their subspaces.
262    pub ownership_order: Vec<usize>,
263    /// `term_feature_cols[idx]` are the sorted, deduplicated feature columns that smooth term
264    /// `idx` spans (indexed by the original smooth-term index, not by `ownership_order`).
265    pub term_feature_cols: Vec<Vec<usize>>,
266    /// `term_owners[idx]` are the indices of prior (in `ownership_order`) smooth terms whose
267    /// feature set is a subset of term `idx`'s feature set, i.e. candidate owners of `idx`.
268    /// The list is given in ownership-processing order.
269    pub term_owners: Vec<Vec<usize>>,
270}
271
272/// Compute the static hierarchical smooth-ownership decomposition from the smooth-term specs.
273///
274/// `smoothspecs` is the same slice that `apply_global_smooth_identifiability` receives.
275pub fn analyze_smooth_ownership(smoothspecs: &[SmoothTermSpec]) -> SmoothStructureAnalysis {
276    let term_feature_cols: Vec<Vec<usize>> =
277        smoothspecs.iter().map(smooth_term_feature_cols).collect();
278
279    let mut ownership_order: Vec<usize> = (0..smoothspecs.len()).collect();
280    ownership_order.sort_by(|&lhs, &rhs| {
281        compare_smooth_ownership_priority(lhs, &smoothspecs[lhs], rhs, &smoothspecs[rhs])
282    });
283
284    let mut term_owners = vec![Vec::<usize>::new(); smoothspecs.len()];
285    for (pos, &target_idx) in ownership_order.iter().enumerate() {
286        let target = &smoothspecs[target_idx];
287        term_owners[target_idx] = ownership_order[..pos]
288            .iter()
289            .copied()
290            .filter(|&owner_idx| smooth_is_owned_by_prior_term(&smoothspecs[owner_idx], target))
291            .collect();
292    }
293
294    SmoothStructureAnalysis {
295        ownership_order,
296        term_feature_cols,
297        term_owners,
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::basis::{BSplineBasisSpec, BSplineKnotSpec, OneDimensionalBoundary};
305    use crate::smooth::{BySmoothKind, ByVariableSpec, ShapeConstraint};
306
307    fn bspline(feature_col: usize) -> SmoothBasisSpec {
308        SmoothBasisSpec::BSpline1D {
309            feature_col,
310            spec: BSplineBasisSpec {
311                degree: 3,
312                penalty_order: 2,
313                knotspec: BSplineKnotSpec::Generate {
314                    data_range: (0.0, 1.0),
315                    num_internal_knots: 5,
316                },
317                double_penalty: false,
318                identifiability: BSplineIdentifiability::None,
319                boundary: OneDimensionalBoundary::Open,
320                boundary_conditions: Default::default(),
321            },
322        }
323    }
324
325    fn term(name: &str, basis: SmoothBasisSpec) -> SmoothTermSpec {
326        SmoothTermSpec {
327            frozen_parametric_residualization: None,
328            name: name.to_string(),
329            basis,
330            shape: ShapeConstraint::None,
331            joint_null_rotation: None,
332        }
333    }
334
335    fn level_by_term(
336        name: &str,
337        feature_col: usize,
338        by_col: usize,
339        level_bits: u64,
340    ) -> SmoothTermSpec {
341        term(
342            name,
343            SmoothBasisSpec::ByVariable {
344                inner: Box::new(bspline(feature_col)),
345                by_col,
346                kind: BySmoothKind::Level { level_bits },
347                by: ByVariableSpec::Level {
348                    value_bits: level_bits,
349                    label: name.to_string(),
350                },
351            },
352        )
353    }
354
355    #[test]
356    fn ungated_smooth_does_not_own_factor_by_level_smooth() {
357        let specs = vec![term("s(x)", bspline(0)), level_by_term("s(x):B", 0, 1, 42)];
358
359        let analysis = analyze_smooth_ownership(&specs);
360
361        assert_eq!(
362            analysis.term_owners[1],
363            Vec::<usize>::new(),
364            "ungated s(x) must not own the row-gated by-factor deviation smooth"
365        );
366    }
367
368    #[test]
369    fn same_factor_by_level_gate_keeps_normal_subset_ownership() {
370        let specs = vec![
371            level_by_term("s(x):B", 0, 2, 42),
372            term(
373                "te(x,z):B",
374                SmoothBasisSpec::ByVariable {
375                    inner: Box::new(SmoothBasisSpec::TensorBSpline {
376                        feature_cols: vec![0, 1],
377                        spec: Default::default(),
378                    }),
379                    by_col: 2,
380                    kind: BySmoothKind::Level { level_bits: 42 },
381                    by: ByVariableSpec::Level {
382                        value_bits: 42,
383                        label: "B".to_string(),
384                    },
385                },
386            ),
387        ];
388
389        let analysis = analyze_smooth_ownership(&specs);
390
391        assert_eq!(
392            analysis.term_owners[1],
393            vec![0],
394            "matching by-level gates may still use the ordinary nested smooth ownership rule"
395        );
396    }
397}