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}