Skip to main content

gam_solve/pirls/
sparse_system.rs

1//! Sparse-native P-IRLS eligibility decision and the sparse penalized-system
2//! (XᵀWX + Sλ) symbolic pattern / numeric cache used by the sparse solve path.
3
4use super::*;
5
6pub struct SparsePirlsDecision {
7    pub path: PirlsLinearSolvePath,
8    pub reason: &'static str,
9    pub p: usize,
10    pub nnz_x: usize,
11    pub nnz_xtwx_symbolic: Option<usize>,
12    pub nnz_s_lambda: usize,
13    pub nnz_h_est: Option<usize>,
14    pub density_h_est: Option<f64>,
15}
16
17pub(crate) fn fmt_opt_usize(v: Option<usize>) -> String {
18    v.map(|v| v.to_string()).unwrap_or_else(|| "na".to_string())
19}
20
21pub(crate) fn fmt_opt_f64(v: Option<f64>) -> String {
22    v.map(|v| format!("{v:.4}"))
23        .unwrap_or_else(|| "na".to_string())
24}
25
26impl SparsePirlsDecision {
27    pub(crate) fn path_str(&self) -> &'static str {
28        match self.path {
29            PirlsLinearSolvePath::DenseTransformed => "dense_transformed",
30            PirlsLinearSolvePath::SparseNative => "sparse_native",
31        }
32    }
33
34    pub(crate) fn format_fields(&self, path: &str) -> String {
35        format!(
36            "path={path} reason={} p={} nnz_x={} nnz_xtwx_symbolic={} nnz_s_lambda={} nnz_h_est={} density_h_est={}",
37            self.reason,
38            self.p,
39            self.nnz_x,
40            fmt_opt_usize(self.nnz_xtwx_symbolic),
41            self.nnz_s_lambda,
42            fmt_opt_usize(self.nnz_h_est),
43            fmt_opt_f64(self.density_h_est),
44        )
45    }
46
47    /// Emit at `info`, beside the `[reml-geometry]` verdict this is the twin
48    /// of (#2569). Both gates ask "is this design's penalized Hessian sparse
49    /// enough", and only this one measures a density — but it sat at `debug`,
50    /// so an `--log-level info` trace showed the REML gate saying
51    /// `density_h_est=na` on a route that decided before measuring, with the
52    /// measurement that would have answered the question suppressed one gate
53    /// away. Volume stays bounded by the dedup below: a distinct decision logs
54    /// once, then only at repetition counts that are powers of two.
55    pub(crate) fn log_once(&self) {
56        let path = self.path_str();
57        let key = self.format_fields(path);
58        let repetition_count = pirls_decision_repetition_count(key.clone());
59        if repetition_count == 1 {
60            log::info!("[pirls-path] {key}");
61            return;
62        }
63
64        if should_log_pirls_decision_summary(repetition_count) {
65            log::info!(
66                "[pirls-path] repeated path={} reason={} count={} (suppressing identical decisions)",
67                path,
68                self.reason,
69                repetition_count,
70            );
71        }
72    }
73}
74
75pub(crate) fn pirls_decision_repetition_count(log_key: String) -> usize {
76    static PIRLS_DECISION_LOG_COUNTS: OnceLock<Mutex<HashMap<String, usize>>> = OnceLock::new();
77    let counts = PIRLS_DECISION_LOG_COUNTS.get_or_init(|| Mutex::new(HashMap::new()));
78    let mut counts = counts.lock().expect("pirls decision log counter poisoned");
79    let count = counts.entry(log_key).or_insert(0);
80    *count += 1;
81    *count
82}
83
84pub(crate) fn should_log_pirls_decision_summary(repetition_count: usize) -> bool {
85    repetition_count > 1 && repetition_count.is_power_of_two()
86}
87
88pub(crate) const SPARSE_NATIVE_MAX_H_DENSITY: f64 = 0.30;
89
90#[derive(Clone, Debug)]
91pub(crate) struct SparsePenaltyPattern {
92    pub(crate) upper_triplets: Vec<(usize, usize, f64)>,
93    pub(crate) nnz_upper: usize,
94}
95
96impl SparsePenaltyPattern {
97    pub(crate) fn from_dense_upper(matrix: &Array2<f64>, tol: f64) -> Self {
98        let p = matrix.nrows().min(matrix.ncols());
99        let mut upper_triplets = Vec::new();
100        for col in 0..p {
101            for row in 0..=col {
102                let value = matrix[[row, col]];
103                if value.abs() > tol {
104                    upper_triplets.push((row, col, value));
105                }
106            }
107        }
108        let nnz_upper = upper_triplets.len();
109        Self {
110            upper_triplets,
111            nnz_upper,
112        }
113    }
114}
115
116#[derive(Clone, Debug)]
117pub(crate) struct SparsePenalizedSystemStats {
118    pub(crate) nnz_xtwx_symbolic: usize,
119    pub(crate) nnz_s_lambda_upper: usize,
120    pub(crate) nnz_h_upper: usize,
121    pub(crate) density_upper: f64,
122}
123
124// Phase 2 sparse-native PIRLS will reuse this cache for symbolic structure and
125// repeated numeric assembly of H = X'WX + S_lambda + ridge I.
126//
127// This is the natural insertion point for any future selected-inversion /
128// Takahashi trace backend. In original spline coefficient order, the assembled
129// penalized system can remain sparse/banded, so exact traces like
130// tr(H^{-1} S_k) can be computed from a sparse factorization without ever
131// materializing a dense inverse. That is not true after the REML
132// reparameterization rotates the problem into the dense Qs basis.
133//
134// Algebra:
135//   H = X'WX + sum_k lambda_k S_k + delta I
136// and the REML/LAML first-order trace terms have the form
137//   T_k = tr(H^{-1} S_k).
138// Since tr(AB) = sum_ij A_ij B_ji, for symmetric sparse S_k we only need
139// inverse entries on the support of S_k:
140//   T_k = sum_{(i,j) in nz(S_k), i>=j} (2 - 1{i=j}) (H^{-1})_{ij} (S_k)_{ij}.
141// Takahashi/selected inversion exploits exactly this fact. Given a sparse
142// Cholesky-type factorization H = LDL', it computes only those entries of
143// H^{-1} that lie on the filled graph of L, which contains the structural
144// nonzeros needed for spline penalties. For banded spline systems with
145// half-bandwidth b, the work scales like sum_j |N(j)|^2 = O(p b^2) instead of
146// dense O(p^3), where N(j) is the subdiagonal nonzero pattern of column j of L.
147pub(crate) struct SparsePenalizedSystemCache {
148    pub(crate) xtwx_cache: SparseXtWxCache,
149    pub(crate) penalty_pattern: SparsePenaltyPattern,
150    pub(crate) h_upper_symbolic: SymbolicSparseColMat<usize>,
151    pub(crate) h_uppervalues: Vec<f64>,
152    pub(crate) h_upper_col_ptr: Vec<usize>,
153    pub(crate) h_upperrow_idx: Vec<usize>,
154    pub(crate) p: usize,
155}
156
157impl SparsePenalizedSystemCache {
158    pub(crate) fn new(
159        x: &SparseColMat<usize, f64>,
160        penalty_pattern: SparsePenaltyPattern,
161    ) -> Result<Self, EstimationError> {
162        let xtwx_cache = SparseXtWxCache::new(x)?;
163        let p = x.ncols();
164        let h_upper_symbolic = build_penalized_symbolic(
165            p,
166            xtwx_cache.xtwx_symbolic.col_ptr(),
167            xtwx_cache.xtwx_symbolic.row_idx(),
168            &penalty_pattern.upper_triplets,
169        )?;
170        let h_uppervalues = vec![0.0; h_upper_symbolic.row_idx().len()];
171        Ok(Self {
172            xtwx_cache,
173            penalty_pattern,
174            h_upper_col_ptr: h_upper_symbolic.col_ptr().to_vec(),
175            h_upperrow_idx: h_upper_symbolic.row_idx().to_vec(),
176            h_upper_symbolic,
177            h_uppervalues,
178            p,
179        })
180    }
181
182    pub(crate) fn matches(
183        &self,
184        x: &SparseColMat<usize, f64>,
185        penalty_pattern: &SparsePenaltyPattern,
186    ) -> bool {
187        self.xtwx_cache.matches(x)
188            && self.penalty_pattern.nnz_upper == penalty_pattern.nnz_upper
189            && self.penalty_pattern.upper_triplets == penalty_pattern.upper_triplets
190    }
191
192    pub(crate) fn stats(&self) -> SparsePenalizedSystemStats {
193        let upper_total = self.p.saturating_mul(self.p + 1) / 2;
194        SparsePenalizedSystemStats {
195            nnz_xtwx_symbolic: self.xtwx_cache.xtwx_symbolic.row_idx().len(),
196            nnz_s_lambda_upper: self.penalty_pattern.nnz_upper,
197            nnz_h_upper: self.h_upper_symbolic.row_idx().len(),
198            density_upper: if upper_total == 0 {
199                0.0
200            } else {
201                self.h_upper_symbolic.row_idx().len() as f64 / upper_total as f64
202            },
203        }
204    }
205
206    pub(crate) fn assemble_upper(
207        &mut self,
208        x: &SparseColMat<usize, f64>,
209        weights: &Array1<f64>,
210        ridge: f64,
211        precomputed_xtwx: Option<&SparseXtwxPrecomputed>,
212    ) -> Result<SparseColMat<usize, f64>, EstimationError> {
213        if weights.len() != self.xtwx_cache.nrows {
214            crate::bail_invalid_estim!(
215                "weights length {} does not match design rows {}",
216                weights.len(),
217                self.xtwx_cache.nrows
218            );
219        }
220        // Gaussian-Identity fast path: when the caller has pre-built the
221        // `XᵀWX` numerical values (weights are constant across the outer
222        // loop), install them into the inner cache and skip the SpGEMM.
223        // We verify symbolic-pattern equivalence first; on mismatch we
224        // fall back to the regular per-call recomputation rather than
225        // installing values keyed to a different sparsity layout.
226        let use_precomputed = match precomputed_xtwx {
227            Some(pre) => {
228                let col_ptr_ok =
229                    pre.xtwx_symbolic_col_ptr.as_slice() == self.xtwx_cache.xtwx_symbolic.col_ptr();
230                let row_idx_ok =
231                    pre.xtwx_symbolic_row_idx.as_slice() == self.xtwx_cache.xtwx_symbolic.row_idx();
232                let values_ok = pre.xtwxvalues.len() == self.xtwx_cache.xtwxvalues.len();
233                if col_ptr_ok && row_idx_ok && values_ok {
234                    self.xtwx_cache.xtwxvalues.copy_from_slice(&pre.xtwxvalues);
235                    true
236                } else {
237                    log::warn!(
238                        "[sparse-xtwx-cache] precomputed XᵀWX pattern mismatch; \
239                         falling back to per-call recompute"
240                    );
241                    false
242                }
243            }
244            None => false,
245        };
246        if !use_precomputed {
247            self.xtwx_cache.compute_numeric(x, weights)?;
248        }
249        self.h_uppervalues.fill(0.0);
250
251        let mut cursor = self.h_upper_col_ptr[..self.p].to_vec();
252
253        let xtwx_col_ptr = self.xtwx_cache.xtwx_symbolic.col_ptr();
254        let xtwxrow_idx = self.xtwx_cache.xtwx_symbolic.row_idx();
255        for col in 0..self.p {
256            let start = xtwx_col_ptr[col];
257            let end = xtwx_col_ptr[col + 1];
258            for idx in start..end {
259                let row = xtwxrow_idx[idx];
260                if row <= col {
261                    let cursor_idx = &mut cursor[col];
262                    while *cursor_idx < self.h_upper_col_ptr[col + 1]
263                        && self.h_upperrow_idx[*cursor_idx] < row
264                    {
265                        *cursor_idx += 1;
266                    }
267                    if *cursor_idx >= self.h_upper_col_ptr[col + 1]
268                        || self.h_upperrow_idx[*cursor_idx] != row
269                    {
270                        crate::bail_invalid_estim!("penalized symbolic pattern missing XtWX entry");
271                    }
272                    self.h_uppervalues[*cursor_idx] += self.xtwx_cache.xtwxvalues[idx];
273                }
274            }
275        }
276
277        cursor.copy_from_slice(&self.h_upper_col_ptr[..self.p]);
278        for &(row, col, value) in &self.penalty_pattern.upper_triplets {
279            let cursor_idx = &mut cursor[col];
280            while *cursor_idx < self.h_upper_col_ptr[col + 1]
281                && self.h_upperrow_idx[*cursor_idx] < row
282            {
283                *cursor_idx += 1;
284            }
285            if *cursor_idx >= self.h_upper_col_ptr[col + 1]
286                || self.h_upperrow_idx[*cursor_idx] != row
287            {
288                crate::bail_invalid_estim!("penalized symbolic pattern missing penalty entry");
289            }
290            self.h_uppervalues[*cursor_idx] += value;
291        }
292
293        if ridge > 0.0 {
294            cursor.copy_from_slice(&self.h_upper_col_ptr[..self.p]);
295            for col in 0..self.p {
296                let cursor_idx = &mut cursor[col];
297                while *cursor_idx < self.h_upper_col_ptr[col + 1]
298                    && self.h_upperrow_idx[*cursor_idx] < col
299                {
300                    *cursor_idx += 1;
301                }
302                if *cursor_idx >= self.h_upper_col_ptr[col + 1]
303                    || self.h_upperrow_idx[*cursor_idx] != col
304                {
305                    crate::bail_invalid_estim!("penalized symbolic pattern missing diagonal entry");
306                }
307                self.h_uppervalues[*cursor_idx] += ridge;
308            }
309        }
310
311        Ok(SparseColMat::new(
312            self.h_upper_symbolic.clone(),
313            self.h_uppervalues.clone(),
314        ))
315    }
316}
317
318pub(crate) fn build_penalized_symbolic(
319    p: usize,
320    xtwx_col_ptr: &[usize],
321    xtwxrow_idx: &[usize],
322    penalty_triplets: &[(usize, usize, f64)],
323) -> Result<SymbolicSparseColMat<usize>, EstimationError> {
324    let mut cols: Vec<BTreeSet<usize>> = (0..p).map(|_| BTreeSet::new()).collect();
325    for col in 0..p {
326        cols[col].insert(col);
327        let start = xtwx_col_ptr[col];
328        let end = xtwx_col_ptr[col + 1];
329        for &row in &xtwxrow_idx[start..end] {
330            if row <= col {
331                cols[col].insert(row);
332            }
333        }
334    }
335    for &(row, col, _) in penalty_triplets {
336        if row > col || col >= p {
337            crate::bail_invalid_estim!(
338                "penalty sparse pattern must be upper-triangular within bounds"
339            );
340        }
341        cols[col].insert(row);
342    }
343
344    let mut col_ptr = Vec::with_capacity(p + 1);
345    let mut row_idx = Vec::new();
346    col_ptr.push(0);
347    for rows in cols {
348        row_idx.extend(rows.into_iter());
349        col_ptr.push(row_idx.len());
350    }
351    // `cols` has exactly p BTreeSet columns. Draining them into CSC order
352    // gives p+1 monotone col_ptr entries ending at row_idx.len(), and each
353    // per-column row slice is sorted and duplicate-free. Every inserted row
354    // satisfies row <= col < p: diagonal and XᵀWX entries are inserted only
355    // for the current column's upper triangle, and penalty triplets were
356    // checked above.
357    // SAFETY: the generated col_ptr length, monotonicity, terminal nnz,
358    // sorted per-column rows, absence of duplicates, and row bounds are
359    // exactly the CSC invariants skipped by new_unchecked.
360    Ok(unsafe { SymbolicSparseColMat::new_unchecked(p, p, col_ptr, None, row_idx) })
361}
362
363#[derive(Clone)]
364pub struct SparsePenalizedSystem {
365    pub h_sparse: SparseColMat<usize, f64>,
366    pub factor: gam_linalg::sparse_exact::SparseExactFactor,
367    pub logdet_h: f64,
368}
369
370pub(crate) fn sparse_reml_penalized_hessian(
371    workspace: &mut PirlsWorkspace,
372    x: &SparseColMat<usize, f64>,
373    weights: &Array1<f64>,
374    s_lambda: &Array2<f64>,
375    ridge: f64,
376    precomputed_xtwx: Option<&SparseXtwxPrecomputed>,
377) -> Result<SparseColMat<usize, f64>, EstimationError> {
378    workspace.assemble_sparse_penalized_hessian(x, weights, s_lambda, ridge, precomputed_xtwx)
379}
380
381pub fn assemble_and_factor_sparse_penalized_system(
382    workspace: &mut PirlsWorkspace,
383    x: &SparseColMat<usize, f64>,
384    weights: &Array1<f64>,
385    s_lambda: &Array2<f64>,
386    ridge: f64,
387    precomputed_xtwx: Option<&SparseXtwxPrecomputed>,
388) -> Result<SparsePenalizedSystem, EstimationError> {
389    use gam_linalg::sparse_exact::{factorize_sparse_spd, logdet_from_factor};
390
391    let logdet_h_start = std::time::Instant::now();
392    let h_sparse =
393        sparse_reml_penalized_hessian(workspace, x, weights, s_lambda, ridge, precomputed_xtwx)?;
394    let factor = factorize_sparse_spd(&h_sparse)?;
395    let logdet_h = logdet_from_factor(&factor)?;
396    log::info!(
397        "[STAGE] logdet H (sparse Cholesky) p={} elapsed={:.3}s",
398        h_sparse.nrows(),
399        logdet_h_start.elapsed().as_secs_f64(),
400    );
401    Ok(SparsePenalizedSystem {
402        h_sparse,
403        factor,
404        logdet_h,
405    })
406}