Skip to main content

gam_solve/pirls/
edf.rs

1use crate::estimate::EstimationError;
2use gam_linalg::faer_ndarray::{FaerSymmetricFactor, array2_to_matmut};
3use gam_linalg::matrix::SymmetricMatrix;
4use gam_linalg::utils::{StableSolver, array_is_finite};
5use gam_problem::Coefficients;
6use ndarray::{Array1, Array2};
7
8use super::{PirlsPenalty, PirlsWorkspace};
9
10/// Result of the stable penalized least squares solve
11#[derive(Clone)]
12pub struct StablePLSResult {
13    /// Solution vector beta
14    pub beta: Coefficients,
15    /// Final penalized Hessian matrix (sparse or dense depending on solve path)
16    pub penalized_hessian: SymmetricMatrix,
17    /// Effective degrees of freedom
18    pub edf: f64,
19    /// Residual standard deviation estimate.
20    ///
21    /// Contract: for Gaussian identity models this is the residual standard
22    /// deviation (sigma), not the residual variance/dispersion.
23    pub standard_deviation: f64,
24    /// Ridge added to ensure the SPD solve is well-posed.
25    pub ridge_used: f64,
26}
27
28/// EDF from an already-factorized dense regularized Hessian (dense path).
29///
30/// Mirrors `calculate_edfwithworkspace_with_penalty` but accepts the
31/// `FaerSymmetricFactor` that PLS already produced, eliminating the redundant
32/// second O(p³) factorization inside every PIRLS outer iteration.
33pub(super) fn calculate_edfwithworkspace_from_factor(
34    factor: &FaerSymmetricFactor,
35    penalty: &PirlsPenalty,
36    workspace: &mut PirlsWorkspace,
37) -> Result<f64, EstimationError> {
38    match penalty {
39        PirlsPenalty::Dense { e_transformed, .. } => {
40            let p = factor.n();
41            let r = e_transformed.nrows();
42            let mp = (p as f64 - r as f64).max(0.0);
43            if r == 0 {
44                return Ok(p as f64);
45            }
46            if workspace.final_aug_matrix.nrows() != p || workspace.final_aug_matrix.ncols() != r {
47                workspace.final_aug_matrix = Array2::zeros((p, r));
48            }
49            for j in 0..r {
50                for i in 0..p {
51                    workspace.final_aug_matrix[[i, j]] = e_transformed[[j, i]];
52                }
53            }
54            {
55                let mut rhsview = array2_to_matmut(&mut workspace.final_aug_matrix);
56                factor.solve_in_place(rhsview.as_mut());
57            }
58            if workspace.final_aug_matrix.nrows() == p
59                && workspace.final_aug_matrix.ncols() == r
60                && array_is_finite(&workspace.final_aug_matrix)
61            {
62                return Ok(edf_from_solution(p, r, mp, e_transformed, |i, j| {
63                    workspace.final_aug_matrix[(i, j)]
64                }));
65            }
66            Err(EstimationError::ModelIsIllConditioned {
67                condition_number: f64::INFINITY,
68            })
69        }
70        PirlsPenalty::Diagonal {
71            diag,
72            positive_indices,
73            ..
74        } => {
75            let p = factor.n();
76            let r = positive_indices.len();
77            let mp = (p as f64 - r as f64).max(0.0);
78            if r == 0 {
79                return Ok(p as f64);
80            }
81            if workspace.final_aug_matrix.nrows() != p || workspace.final_aug_matrix.ncols() != r {
82                workspace.final_aug_matrix = Array2::zeros((p, r));
83            } else {
84                workspace.final_aug_matrix.fill(0.0);
85            }
86            for (col, &idx) in positive_indices.iter().enumerate() {
87                workspace.final_aug_matrix[[idx, col]] = 1.0;
88            }
89            {
90                let mut rhsview = array2_to_matmut(&mut workspace.final_aug_matrix);
91                factor.solve_in_place(rhsview.as_mut());
92            }
93            let mut tr = 0.0;
94            for (col, &idx) in positive_indices.iter().enumerate() {
95                tr += diag[idx] * workspace.final_aug_matrix[[idx, col]];
96            }
97            Ok((p as f64 - tr).clamp(mp, p as f64))
98        }
99    }
100}
101
102/// EDF from an already-factorized sparse penalized Hessian (sparse path).
103///
104/// Mirrors `calculate_edf_with_penalty` but accepts the `SparseExactFactor`
105/// that PLS already produced, eliminating the redundant second sparse
106/// factorization inside every PIRLS outer iteration.
107///
108/// Only the `PirlsPenalty::Dense` variant is handled because the sparse-native
109/// path requires `PirlsPenalty::Dense` (enforced by the caller).
110pub(super) fn calculate_edf_from_sparse_factor(
111    factor: &gam_linalg::sparse_exact::SparseExactFactor,
112    penalty: &PirlsPenalty,
113) -> Result<f64, EstimationError> {
114    let PirlsPenalty::Dense { e_transformed, .. } = penalty else {
115        crate::bail_invalid_estim!("calculate_edf_from_sparse_factor requires PirlsPenalty::Dense");
116    };
117    // e_transformed has shape (r, p) — cols give the coefficient dimension p.
118    let p = e_transformed.ncols();
119    let r = e_transformed.nrows();
120    let mp = (p as f64 - r as f64).max(0.0);
121    if r == 0 {
122        return Ok(p as f64);
123    }
124    let rhs_arr = e_transformed.t().to_owned();
125    let sol = gam_linalg::sparse_exact::solve_sparse_spdmulti(factor, &rhs_arr).map_err(|_| {
126        EstimationError::ModelIsIllConditioned {
127            condition_number: f64::INFINITY,
128        }
129    })?;
130    if sol.nrows() == p && sol.ncols() == r && sol.iter().all(|v| v.is_finite()) {
131        return Ok(edf_from_solution(p, r, mp, e_transformed, |i, j| {
132            sol[[i, j]]
133        }));
134    }
135    Err(EstimationError::ModelIsIllConditioned {
136        condition_number: f64::INFINITY,
137    })
138}
139
140pub(super) fn calculate_edf(
141    penalized_hessian: &SymmetricMatrix,
142    e_transformed: &Array2<f64>,
143) -> Result<f64, EstimationError> {
144    let p = penalized_hessian.ncols();
145    let r = e_transformed.nrows();
146    let mp = (p as f64 - r as f64).max(0.0);
147    if r == 0 {
148        return Ok(p as f64);
149    }
150    let rhs_arr = e_transformed.t().to_owned();
151    // Use SymmetricMatrix::factorize() which dispatches to sparse Cholesky
152    // for sparse Hessians and dense Cholesky for dense ones.
153    let factor =
154        penalized_hessian
155            .factorize()
156            .map_err(|_| EstimationError::ModelIsIllConditioned {
157                condition_number: f64::INFINITY,
158            })?;
159    let sol = factor
160        .solvemulti(&rhs_arr)
161        .map_err(|_| EstimationError::ModelIsIllConditioned {
162            condition_number: f64::INFINITY,
163        })?;
164    if sol.nrows() == p && sol.ncols() == r && sol.iter().all(|v| v.is_finite()) {
165        return Ok(edf_from_solution(p, r, mp, e_transformed, |i, j| {
166            sol[[i, j]]
167        }));
168    }
169
170    Err(EstimationError::ModelIsIllConditioned {
171        condition_number: f64::INFINITY,
172    })
173}
174
175pub(super) fn calculate_edf_with_penalty(
176    penalized_hessian: &SymmetricMatrix,
177    penalty: &PirlsPenalty,
178) -> Result<f64, EstimationError> {
179    match penalty {
180        PirlsPenalty::Dense { e_transformed, .. } => {
181            calculate_edf(penalized_hessian, e_transformed)
182        }
183        PirlsPenalty::Diagonal {
184            diag,
185            positive_indices,
186            ..
187        } => calculate_edf_from_diagonal_penalty(penalized_hessian, diag, positive_indices),
188    }
189}
190
191pub(super) fn calculate_edfwithworkspace(
192    penalized_hessian: &Array2<f64>,
193    e_transformed: &Array2<f64>,
194    workspace: &mut PirlsWorkspace,
195) -> Result<f64, EstimationError> {
196    let p = penalized_hessian.ncols();
197    let r = e_transformed.nrows();
198    let mp = (p as f64 - r as f64).max(0.0);
199    if r == 0 {
200        return Ok(p as f64);
201    }
202    if workspace.final_aug_matrix.nrows() != p || workspace.final_aug_matrix.ncols() != r {
203        workspace.final_aug_matrix = Array2::zeros((p, r));
204    }
205    for j in 0..r {
206        for i in 0..p {
207            workspace.final_aug_matrix[[i, j]] = e_transformed[[j, i]];
208        }
209    }
210
211    let factor = StableSolver::new()
212        .factorize(penalized_hessian)
213        .map_err(|_| EstimationError::ModelIsIllConditioned {
214            condition_number: f64::INFINITY,
215        })?;
216    {
217        let mut rhsview = array2_to_matmut(&mut workspace.final_aug_matrix);
218        factor.solve_in_place(rhsview.as_mut());
219    }
220    if workspace.final_aug_matrix.nrows() == p
221        && workspace.final_aug_matrix.ncols() == r
222        && array_is_finite(&workspace.final_aug_matrix)
223    {
224        return Ok(edf_from_solution(p, r, mp, e_transformed, |i, j| {
225            workspace.final_aug_matrix[(i, j)]
226        }));
227    }
228
229    Err(EstimationError::ModelIsIllConditioned {
230        condition_number: f64::INFINITY,
231    })
232}
233
234pub(super) fn calculate_edfwithworkspace_with_penalty(
235    penalized_hessian: &Array2<f64>,
236    penalty: &PirlsPenalty,
237    workspace: &mut PirlsWorkspace,
238) -> Result<f64, EstimationError> {
239    match penalty {
240        PirlsPenalty::Dense { e_transformed, .. } => {
241            calculate_edfwithworkspace(penalized_hessian, e_transformed, workspace)
242        }
243        PirlsPenalty::Diagonal {
244            diag,
245            positive_indices,
246            ..
247        } => calculate_edfwithworkspace_from_diagonal_penalty(
248            penalized_hessian,
249            diag,
250            positive_indices,
251            workspace,
252        ),
253    }
254}
255
256pub(super) fn calculate_edf_from_diagonal_penalty(
257    penalized_hessian: &SymmetricMatrix,
258    diag: &Array1<f64>,
259    positive_indices: &[usize],
260) -> Result<f64, EstimationError> {
261    let p = penalized_hessian.ncols();
262    let r = positive_indices.len();
263    let mp = (p as f64 - r as f64).max(0.0);
264    if r == 0 {
265        return Ok(p as f64);
266    }
267    let mut rhs_arr = Array2::<f64>::zeros((p, r));
268    for (col, &idx) in positive_indices.iter().enumerate() {
269        rhs_arr[[idx, col]] = 1.0;
270    }
271    let factor =
272        penalized_hessian
273            .factorize()
274            .map_err(|_| EstimationError::ModelIsIllConditioned {
275                condition_number: f64::INFINITY,
276            })?;
277    let sol = factor
278        .solvemulti(&rhs_arr)
279        .map_err(|_| EstimationError::ModelIsIllConditioned {
280            condition_number: f64::INFINITY,
281        })?;
282    let mut tr = 0.0;
283    for (col, &idx) in positive_indices.iter().enumerate() {
284        tr += diag[idx] * sol[[idx, col]];
285    }
286    Ok((p as f64 - tr).clamp(mp, p as f64))
287}
288
289pub(super) fn calculate_edfwithworkspace_from_diagonal_penalty(
290    penalized_hessian: &Array2<f64>,
291    diag: &Array1<f64>,
292    positive_indices: &[usize],
293    workspace: &mut PirlsWorkspace,
294) -> Result<f64, EstimationError> {
295    let p = penalized_hessian.ncols();
296    let r = positive_indices.len();
297    let mp = (p as f64 - r as f64).max(0.0);
298    if r == 0 {
299        return Ok(p as f64);
300    }
301    if workspace.final_aug_matrix.nrows() != p || workspace.final_aug_matrix.ncols() != r {
302        workspace.final_aug_matrix = Array2::zeros((p, r));
303    } else {
304        workspace.final_aug_matrix.fill(0.0);
305    }
306    for (col, &idx) in positive_indices.iter().enumerate() {
307        workspace.final_aug_matrix[[idx, col]] = 1.0;
308    }
309
310    let factor = StableSolver::new()
311        .factorize(penalized_hessian)
312        .map_err(|_| EstimationError::ModelIsIllConditioned {
313            condition_number: f64::INFINITY,
314        })?;
315    {
316        let mut rhsview = array2_to_matmut(&mut workspace.final_aug_matrix);
317        factor.solve_in_place(rhsview.as_mut());
318    }
319    let mut tr = 0.0;
320    for (col, &idx) in positive_indices.iter().enumerate() {
321        tr += diag[idx] * workspace.final_aug_matrix[[idx, col]];
322    }
323    Ok((p as f64 - tr).clamp(mp, p as f64))
324}
325
326#[inline]
327pub(super) fn edf_from_solution<F>(
328    p: usize,
329    r: usize,
330    mp: f64,
331    e_transformed: &Array2<f64>,
332    solved_at: F,
333) -> f64
334where
335    F: Fn(usize, usize) -> f64,
336{
337    let mut tr = 0.0;
338    for j in 0..r {
339        for i in 0..p {
340            tr += solved_at(i, j) * e_transformed[(j, i)];
341        }
342    }
343    (p as f64 - tr).clamp(mp, p as f64)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use gam_linalg::matrix::SymmetricMatrix;
350    use ndarray::array;
351
352    /// Regression: a penalty with MORE rows than coefficient columns (`r > p`)
353    /// is legitimate for factor-smooth / random-slope / random-effect
354    /// structures whose penalty roots are stacked or full-rank. The
355    /// min-penalty-dof floor `mp = max(p - r, 0)` must be computed in `f64`,
356    /// because the `usize` subtraction `p - r` underflows and panics with
357    /// "attempt to subtract with overflow" when `r > p`. This exercises the
358    /// `r > p` path on the dense EDF entry point and asserts that the floor is
359    /// honored (no panic, finite EDF in `[0, p]`).
360    #[test]
361    pub(crate) fn calculate_edf_floors_when_penalty_rank_exceeds_coefficient_dim() {
362        // p = 2 coefficients, r = 3 penalty rows (r > p).
363        let p = 2usize;
364        // SPD penalized Hessian (well-conditioned, dense path).
365        let hessian = SymmetricMatrix::Dense(array![[4.0, 1.0], [1.0, 3.0]]);
366        // e_transformed has shape (r, p) = (3, 2): more penalty rows than
367        // coefficient columns — the factor/random-slope structure.
368        let e_transformed = array![[1.0, 0.0], [0.0, 1.0], [0.5, 0.5]];
369        assert_eq!(e_transformed.nrows(), 3);
370        assert_eq!(e_transformed.ncols(), p);
371
372        let edf = calculate_edf(&hessian, &e_transformed)
373            .expect("EDF solve should succeed for an SPD Hessian with r > p");
374
375        // mp = max(p - r, 0) = 0, so the EDF is floored at 0 and capped at p.
376        assert!(
377            edf.is_finite(),
378            "EDF must be finite for r > p penalty, got {edf}"
379        );
380        assert!(
381            (0.0..=p as f64).contains(&edf),
382            "EDF must lie in [0, {p}] for r > p penalty, got {edf}"
383        );
384    }
385}