Skip to main content

gam_solve/
sensitivity.rs

1//! ONE sensitivity operator (#935): every "how does the fit move?"
2//! question is the same solve.
3//!
4//! At a penalized optimum the stationarity condition `g(β̂; t) = 0` makes
5//! every sensitivity of the fit one object — the factored fitted curvature
6//! applied to a perturbation of the score:
7//!
8//! ```text
9//!   ∂β̂/∂t = −H⁻¹ · ∂g/∂t
10//! ```
11//!
12//! for ANY perturbation channel `t`: smoothing parameters (the REML outer
13//! gradient), case weights (ALO / leave-one-out / Cook's distance),
14//! responses (data attribution). One identity, read off in whichever
15//! direction a diagnostic needs it.
16//!
17//! Before this, the tree computed `H⁻¹·` in independent dialects with
18//! independent factorizations — an
19//! `ift_dbeta_drho_from_solver` solve-closure and a separate coned variant
20//! (evidence.rs), and the projected pseudo-inverse of the rank-deficient
21//! LAML kernel (unified.rs) — so each site had to answer on its own the
22//! question that actually causes bugs: **which inverse is "H⁻¹"?** The
23//! large-scale fix 0dc469bd and the #901 layer-2 investigation are both
24//! incidents of two sites answering differently.
25//!
26//! [`FitSensitivity`] is the single answer. It is built once at the optimum
27//! from whichever factored form the solver already has — a faer Cholesky
28//! factor, a raw lower-triangular (arrow-Schur) factor, or the projected
29//! pseudo-inverse `U · M⁻¹ · Uᵀ` (the #752/#901 intrinsic-quotient
30//! convention) — and every consumer asks it, never a factor directly.
31//! Consumers therefore cannot disagree about the inverse, and every
32//! batching/cone improvement made inside [`FitSensitivity::apply_multi`] is
33//! inherited by all of them at once.
34//!
35//! The channels, each a one-line restatement of the identity above:
36//!
37//! - [`mode_response`](FitSensitivity::mode_response) — `−H⁻¹ ∂g/∂t`, the
38//!   REML outer gradient's `∂β̂/∂ρ` (evidence `ift_dbeta_drho`).
39//! - [`mode_response_coned`](FitSensitivity::mode_response_coned) — the same
40//!   response confined to its cone of influence (#779); the lazy/local form
41//!   the smoothing-correction IFT uses.
42//! - `leverage_block` — `H⁻¹Xᵀ`, whose
43//!   column `i` is at once ALO's per-row solve and the case/response channel.
44//! - `case_deletion` — dfbetas + Cook's
45//!   distance, the leave-one-out channel, one scaled column of `H⁻¹Xᵀ` each.
46//!
47//! What is deliberately NOT folded in: the matrix-free `hop.solve_multi`
48//! (PCG/GPU), the constrained kernel `K_T = K_S − K_S Aᵀ(A K_S Aᵀ)⁻¹A K_S`,
49//! and `alo.rs`'s zero-copy `StableSolver` loop. Those are distinct inverse
50//! *representations*, not duplicate spellings of the same factored inverse —
51//! routing them through here would regress performance and couple unrelated
52//! concerns rather than remove the bug class.
53
54use ndarray::{Array1, Array2, ArrayView2};
55
56use gam_linalg::faer_ndarray::FaerCholeskyFactor;
57
58/// The fitted curvature in whichever factored form the solver produced —
59/// the SINGLE place that knows how to invert it.
60pub enum FittedInverse<'a> {
61    /// Cholesky factor of the (stabilized) penalized Hessian: the
62    /// full-rank convention (PIRLS / ALO path).
63    FaerCholesky(&'a FaerCholeskyFactor),
64    /// Raw lower-triangular Cholesky factor `L` with `H = L·Lᵀ` (the
65    /// arrow-Schur reduced factor in evidence.rs).
66    LowerTriangular(&'a Array2<f64>),
67    /// Projected (pseudo-)inverse `U · M⁻¹ · Uᵀ` over a column basis `U`
68    /// (p × r) with reduced inverse `M⁻¹` (r × r) — the rank-deficient
69    /// LAML convention (#752/0dc469bd/#901): the inverse acts on
70    /// range(U) and annihilates its complement.
71    Projected {
72        basis: &'a Array2<f64>,
73        reduced_inverse: &'a Array2<f64>,
74    },
75}
76
77/// The one sensitivity operator built at the optimum. See module docs.
78pub struct FitSensitivity<'a> {
79    inverse: FittedInverse<'a>,
80    dim: usize,
81}
82
83impl<'a> FitSensitivity<'a> {
84    pub fn from_faer_cholesky(factor: &'a FaerCholeskyFactor, dim: usize) -> Self {
85        Self {
86            inverse: FittedInverse::FaerCholesky(factor),
87            dim,
88        }
89    }
90
91    pub fn from_projected(basis: &'a Array2<f64>, reduced_inverse: &'a Array2<f64>) -> Self {
92        let dim = basis.nrows();
93        Self {
94            inverse: FittedInverse::Projected {
95                basis,
96                reduced_inverse,
97            },
98            dim,
99        }
100    }
101
102    /// Coefficient dimension `p` the operator acts on.
103    pub fn dim(&self) -> usize {
104        self.dim
105    }
106
107    /// `H⁻¹ · rhs` (pseudo-inverse action for the projected variant).
108    pub fn apply(&self, rhs: &Array1<f64>) -> Array1<f64> {
109        assert_eq!(rhs.len(), self.dim, "FitSensitivity rhs dimension");
110        match &self.inverse {
111            FittedInverse::FaerCholesky(factor) => factor.solvevec(rhs),
112            FittedInverse::LowerTriangular(factor) => {
113                gam_linalg::triangular::cholesky_solve_vector(factor.view(), rhs.view())
114            }
115            FittedInverse::Projected {
116                basis,
117                reduced_inverse,
118            } => {
119                // `U · (M⁻¹ · (Uᵀ · a))` via faer SIMD contractions — the
120                // single spelling of the projected (rank-deficient LAML)
121                // inverse, shared with `PenaltySubspaceTrace`.
122                let proj = gam_linalg::faer_ndarray::fast_atv(basis, rhs);
123                let reduced = reduced_inverse.dot(&proj);
124                gam_linalg::faer_ndarray::fast_av(basis, &reduced)
125            }
126        }
127    }
128
129    /// `H⁻¹ · RHS` for a (p × m) block of right-hand sides — the batched
130    /// form every multi-channel consumer should use (outer ρ-pair solves,
131    /// ALO's `H⁻¹Xᵀ` leverage block) so the factor is traversed once per
132    /// block instead of once per column.
133    pub fn apply_multi(&self, rhs: ArrayView2<'_, f64>) -> Array2<f64> {
134        assert_eq!(rhs.nrows(), self.dim, "FitSensitivity RHS dimension");
135        match &self.inverse {
136            FittedInverse::FaerCholesky(factor) => {
137                let mut out = rhs.to_owned();
138                factor.solve_mat_in_place(&mut out);
139                out
140            }
141            FittedInverse::LowerTriangular(factor) => {
142                gam_linalg::triangular::cholesky_solve_matrix(*factor, rhs)
143            }
144            FittedInverse::Projected {
145                basis,
146                reduced_inverse,
147            } => {
148                let reduced = gam_linalg::faer_ndarray::fast_atb(basis, &rhs.to_owned());
149                gam_linalg::faer_ndarray::fast_ab(basis, &reduced_inverse.dot(&reduced))
150            }
151        }
152    }
153
154    /// The IFT mode response `∂β̂/∂t = −H⁻¹ · ∂g/∂t` for a (p × m) block
155    /// of score perturbations — THE object of #935.
156    ///
157    /// Returns `None` if any solved entry is non-finite (the factored
158    /// curvature was unusable for this channel); callers must not
159    /// substitute an approximation, matching the contract of the deleted
160    /// `ift_dbeta_drho_from_solver`.
161    pub fn mode_response(&self, dg_dt: ArrayView2<'_, f64>) -> Option<Array2<f64>> {
162        if dg_dt.nrows() != self.dim {
163            return None;
164        }
165        let mut out = self.apply_multi(dg_dt);
166        if out.iter().any(|value| !value.is_finite()) {
167            return None;
168        }
169        out.mapv_inplace(|value| -value);
170        Some(out)
171    }
172
173    /// Cone-of-influence mode response (#779), the lazy/local form of
174    /// [`Self::mode_response`]. Each perturbation column `∂g/∂t_a` is
175    /// structurally supported only within `col_supports[a]`, so its response
176    /// `−H⁻¹ ∂g/∂t_a` is exactly zero outside the coupling component of
177    /// `hessian` containing that support. Columns whose support is empty (a
178    /// structurally inactive channel) are skipped with no solve; the active
179    /// columns are solved as ONE batched block through [`Self::apply_multi`]
180    /// — strictly better than the per-column BLAS-2 loop this replaces — and
181    /// each result confined to its cone. On a fully coupled `hessian` every
182    /// cone is the whole space and the result equals [`Self::mode_response`]
183    /// bit-for-bit.
184    ///
185    /// `hessian` must be the same curvature this operator inverts; a
186    /// dimension mismatch (or any non-finite solved entry) returns `None`
187    /// rather than silently substituting an approximation.
188    pub fn mode_response_coned(
189        &self,
190        hessian: ArrayView2<'_, f64>,
191        dg_dt: ArrayView2<'_, f64>,
192        col_supports: &[std::ops::Range<usize>],
193    ) -> Option<Array2<f64>> {
194        let p = self.dim;
195        let r = dg_dt.ncols();
196        if dg_dt.nrows() != p
197            || hessian.nrows() != p
198            || hessian.ncols() != p
199            || col_supports.len() != r
200        {
201            return None;
202        }
203        let labels = crate::evidence::coupling_components(hessian);
204        if labels.len() != p {
205            return None;
206        }
207
208        // Active columns + their cones; structurally inactive columns (empty
209        // support → empty cone) contribute an identically-zero sensitivity
210        // and are skipped entirely (no solve).
211        let mut active: Vec<(usize, Vec<usize>)> = Vec::new();
212        for a in 0..r {
213            let sr = &col_supports[a];
214            let support: Vec<usize> = (sr.start..sr.end)
215                .filter(|idx| *idx < p)
216                .filter(|idx| dg_dt[[*idx, a]] != 0.0)
217                .collect();
218            let cone = crate::evidence::cone_of_influence(&labels, &support);
219            if !cone.is_empty() {
220                active.push((a, cone));
221            }
222        }
223
224        let mut out = Array2::<f64>::zeros((p, r));
225        if active.is_empty() {
226            return Some(out);
227        }
228        // One batched solve over only the active columns.
229        let mut rhs = Array2::<f64>::zeros((p, active.len()));
230        for (j, (a, _)) in active.iter().enumerate() {
231            rhs.column_mut(j).assign(&dg_dt.column(*a));
232        }
233        let solved = self.apply_multi(rhs.view());
234        if solved.iter().any(|value| !value.is_finite()) {
235            return None;
236        }
237        for (j, (a, cone)) in active.iter().enumerate() {
238            for &row in cone {
239                out[[row, *a]] = -solved[[row, j]];
240            }
241        }
242        Some(out)
243    }
244
245}
246
247/// Exact (Gaussian) / one-step (GLM) case-deletion influence produced by
248/// `FitSensitivity::case_deletion`. See that method for the identities.
249pub struct CaseDeletionInfluence {
250    /// `dfbeta[[i, j]]` = change in coefficient `j` when observation `i` is
251    /// left out, `β̂_j − β̂₍ᵢ₎_j`.
252    pub dfbeta: Array2<f64>,
253    /// Leverage (hat value) `h_ii = w_i x_iᵀ H⁻¹ x_i` per observation.
254    pub leverage: Array1<f64>,
255    /// Cook's distance per observation.
256    pub cooks_distance: Array1<f64>,
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use faer::Side;
263    use gam_linalg::faer_ndarray::FaerCholesky;
264    use ndarray::array;
265
266    #[test]
267    fn mode_response_refuses_non_finite_channels() {
268        let h = array![[2.0, 0.0], [0.0, 1.0]];
269        let faer = h.cholesky(Side::Lower).expect("SPD factor");
270        let s = FitSensitivity::from_faer_cholesky(&faer, 2);
271        let bad = array![[1.0], [f64::NAN]];
272        assert!(s.mode_response(bad.view()).is_none());
273        let wrong_dim = array![[1.0], [0.0], [0.0]];
274        assert!(s.mode_response(wrong_dim.view()).is_none());
275    }
276}