gam_solve/pirls/pls_solver.rs
1//! Penalized least-squares solver and Gaussian fast paths.
2//!
3//! Owns:
4//! - `GaussianFixedCache` — `XᵀWX`/`XᵀW(y−offset)` cache for the
5//! Gaussian-Identity short-circuit that the REML outer loop reuses across
6//! smoothing-parameter candidates.
7//! - `SparseXtwxPrecomputed` — the sparse-pattern-aligned twin of the above
8//! for designs that take the sparse-native PIRLS path.
9//! - `solve_penalized_least_squares_implicit` — identity/Gaussian implicit
10//! PLS, dense and sparse-native paths.
11
12use super::loop_driver::max_symmetric_asymmetry;
13use super::{
14 FIXED_STABILIZATION_RIDGE, PirlsPenalty, PirlsWorkspace, SparseXtWxCache, StablePLSResult,
15 WorkingReparamTransform, calculate_edf_from_sparse_factor,
16 calculate_edfwithworkspace_from_factor, ensure_sparse_positive_definitewithridge,
17 solve_sparse_spd,
18};
19use super::{
20 calculate_deviance_from_eta, computeworkingweight_derivatives_from_eta,
21 pirls_data_log_kernel_from_eta,
22};
23use crate::estimate::EstimationError;
24use faer::sparse::SparseColMat;
25use gam_linalg::faer_ndarray::{FaerLinalgError, array1_to_col_matmut};
26use gam_linalg::matrix::{DesignMatrix, LinearOperator, SymmetricMatrix};
27use gam_linalg::utils::{StableSolver, array_is_finite, inf_norm};
28use gam_problem::{Coefficients, GlmLikelihoodSpec, InverseLink, LinkFunction};
29use ndarray::{ArcArray1, Array1, Array2, ArrayView1, ShapeBuilder};
30use std::sync::Arc;
31
32/// Once-built, hyperparameter-invariant length-`n` row carrier for a
33/// Gaussian-identity sufficient-statistic-only evaluation.
34///
35/// On the n-free κ skip path and fixed-design value-only ρ path (#2435), the
36/// inner "solve" is a zero-iteration synthesis whose every
37/// length-`n` array is a trial-INVARIANT placeholder — the row predictions are
38/// not recomputed, so `η ≡ μ ≡ offset`, the working response `z ≡ y`, the
39/// score/Hessian weights `w ≡ priorweights`, and the working-weight
40/// derivatives are `computeworkingweight_derivatives_from_eta(offset)` — all
41/// functions of the frozen `(offset, y, weights)` and the fixed link, never of
42/// the trial ψ. Re-materialising them on every κ callback is the O(n)-per-call
43/// regression #1868 tracks (~16·n element touches per trial).
44///
45/// Building them **once per surface** and sharing them by `ArcArray1` (a reference-counted
46/// ndarray whose `.clone()` is O(1)) lets each trial's `PirlsResult` reuse the
47/// same rows with zero per-callback row work, so the κ outer loop touches only
48/// k×k objects per trial — the #1033 architectural invariant. The two cached
49/// scalars (the P-IRLS data log-kernel at `μ=offset`,
50/// `max_abs_eta = ‖offset‖∞`) are the only other length-`n` reductions the
51/// synthesis performed per trial.
52#[derive(Debug, Clone)]
53pub struct GaussianFrozenRows {
54 /// `η ≡ μ ≡ offset` (identity link, stale rows) — shared by the
55 /// `final_offset`, `final_eta`, `finalmu`, and `solvemu` result fields.
56 pub eta: ArcArray1<f64>,
57 /// Working response `z ≡ y` — shared by `solveworking_response`.
58 pub z: ArcArray1<f64>,
59 /// Score/Hessian weights `w ≡ priorweights` — shared by `finalweights`
60 /// and `solveweights`.
61 pub weights: ArcArray1<f64>,
62 /// `dμ/dη` at `η=offset`.
63 pub solve_dmu_deta: ArcArray1<f64>,
64 /// `d²μ/dη²` at `η=offset`.
65 pub solve_d2mu_deta2: ArcArray1<f64>,
66 /// `d³μ/dη³` at `η=offset`.
67 pub solve_d3mu_deta3: ArcArray1<f64>,
68 /// `dW_H/dη` at `η=offset`.
69 pub solve_c_array: ArcArray1<f64>,
70 /// `d²W_H/dη²` at `η=offset`.
71 pub solve_d_array: ArcArray1<f64>,
72 /// Trial-invariant zero-iteration P-IRLS data log-kernel. For a profiled
73 /// Gaussian this is exactly negative one half of the raw weighted RSS, not
74 /// a physical unit-dispersion likelihood.
75 pub log_likelihood: f64,
76 /// `‖offset‖∞` — the trial-invariant `max_abs_eta`.
77 pub max_abs_eta: f64,
78}
79
80impl GaussianFrozenRows {
81 /// Build the hyperparameter-invariant row carrier ONCE from the fit's frozen
82 /// `(offset, y, weights)` and fixed link. This is the single O(n)
83 /// materialization the sufficient-statistic lane is allowed to pay, amortized
84 /// across every κ or value-only ρ trial; subsequent callbacks share these
85 /// rows O(1) and touch zero length-`n` objects (#1868/#2435).
86 ///
87 /// The values are bit-identical to what the loop_driver stale-row synthesis
88 /// used to re-materialise per trial: `η ≡ μ ≡ offset` (the tensor path is
89 /// Gaussian-identity, so the row predictions are stale placeholders), the
90 /// working-weight derivatives are `computeworkingweight_derivatives_from_eta`
91 /// at `η=offset` (constant `(1,0,0,0,0)` for Gaussian-identity), and the two
92 /// scalars are the zero-iteration P-IRLS data log-kernel and `‖offset‖∞`.
93 pub(crate) fn build(
94 offset: ArrayView1<'_, f64>,
95 y: ArrayView1<'_, f64>,
96 weights: ArrayView1<'_, f64>,
97 likelihood: &GlmLikelihoodSpec,
98 inverse_link: &InverseLink,
99 ) -> Result<Self, EstimationError> {
100 let eta_owned = offset.to_owned();
101 let (solve_c_array, solve_d_array, solve_dmu_deta, solve_d2mu_deta2, solve_d3mu_deta3) =
102 computeworkingweight_derivatives_from_eta(
103 likelihood,
104 inverse_link,
105 &eta_owned,
106 weights,
107 )?;
108 let deviance = calculate_deviance_from_eta(
109 y.view(),
110 &eta_owned,
111 likelihood,
112 inverse_link,
113 weights.view(),
114 )?;
115 let log_likelihood = pirls_data_log_kernel_from_eta(
116 y,
117 &eta_owned,
118 likelihood,
119 inverse_link,
120 weights,
121 deviance,
122 )?;
123 let max_abs_eta = inf_norm(eta_owned.iter().copied());
124 Ok(Self {
125 eta: eta_owned.into_shared(),
126 z: y.to_owned().into_shared(),
127 weights: weights.to_owned().into_shared(),
128 solve_dmu_deta: solve_dmu_deta.into_shared(),
129 solve_d2mu_deta2: solve_d2mu_deta2.into_shared(),
130 solve_d3mu_deta3: solve_d3mu_deta3.into_shared(),
131 solve_c_array: solve_c_array.into_shared(),
132 solve_d_array: solve_d_array.into_shared(),
133 log_likelihood,
134 max_abs_eta,
135 })
136 }
137}
138
139/// Reusable `XᵀWX` and `XᵀW(y − offset)` for Gaussian + Identity REML fits.
140///
141/// The Gaussian-identity P-IRLS short-circuit solves a single linear system
142/// `(XᵀWX + Σ λ_k S_k + ρ·I) β = XᵀW(y − offset)`. The right-hand-side matrix
143/// and vector are independent of the smoothing parameters `λ`, so when the
144/// outer REML loop evaluates the same problem at many `(λ_1, …, λ_k)`
145/// candidates we only need to assemble them **once** before the loop and
146/// reuse them inside every inner PIRLS call.
147///
148/// Stored in *original* coordinates (no Qs rotation applied). When the
149/// inner solver uses a `WorkingReparamTransform`, it conjugates / projects
150/// these matrices on the fly — that step is O(p³) / O(p²), independent of N.
151#[derive(Debug)]
152pub struct GaussianFixedCache {
153 /// `XᵀWX` in the original coefficient basis. Symmetric, p × p.
154 pub xtwx_orig: Array2<f64>,
155 /// `XᵀW(y − offset)` in the original basis. Length p.
156 pub xtwy_orig: Array1<f64>,
157 /// `(y − offset)ᵀW(y − offset)`.
158 ///
159 /// Together with `xtwx_orig` and `xtwy_orig`, this is the last scalar
160 /// sufficient statistic needed to evaluate the Gaussian penalized RSS
161 /// exactly at any λ without re-streaming the rows.
162 pub centered_weighted_y_sq: f64,
163 /// When true, the caller is deliberately serving a design-moving trial from
164 /// sufficient statistics and the `DesignMatrix` rows on the current REML
165 /// surface may be a stale reference surface. Consumers must not apply those
166 /// rows for fitted values, RSS, or likelihood summaries.
167 pub row_prediction_is_stale: bool,
168 /// `XᵀWX` precomputed for the sparse path, aligned with the symbolic
169 /// pattern of `SparseXtWxCache::new(x)` on the original sparse design.
170 /// `None` when the design has no sparse form (e.g. dense-only fits).
171 ///
172 /// The sparse REML path rebuilds `H = XᵀWX + Sλ + δI` per outer
173 /// evaluation. For Gaussian-Identity the weights never change, so the
174 /// `XᵀWX` contribution is invariant across the outer loop and can be
175 /// scattered from this cached values vector instead of re-doing the
176 /// O(nnz²/n) SpGEMM each call.
177 pub xtwx_sparse_orig: Option<Arc<SparseXtwxPrecomputed>>,
178 /// #1868 / #1033: the once-built ψ-invariant frozen row bundle for the
179 /// n-free κ-trial skip path. Present exactly when `row_prediction_is_stale`
180 /// is `true` and the producer (`gaussian_fixed_cache_at` via
181 /// `install_psi_gram_statistics`) attached it. When present the Gaussian
182 /// zero-iteration inner synthesis shares these length-`n` placeholders O(1)
183 /// instead of re-materialising `offset`/`y`/`weights` and the working-weight
184 /// derivatives per trial. `None` on the exact (non-stale) path, where the
185 /// rows are freshly realised from the design.
186 pub frozen_rows: Option<Arc<GaussianFrozenRows>>,
187}
188
189/// Precomputed numerical values of `XᵀWX` aligned with the symbolic pattern
190/// that `SparseXtWxCache::new(x)` produces on its first call. Two such caches
191/// built from the same sparse `x` produce byte-identical symbolic patterns
192/// (faer's `sparse_sparse_matmul_symbolic` is deterministic), so the cached
193/// values can be installed back into a fresh `SparseXtWxCache` for the same
194/// `x` without rerunning the SpGEMM.
195///
196/// We snapshot the symbolic pattern (`col_ptr` / `row_idx`) alongside the
197/// values so the consumer can verify pattern equivalence and fall through to
198/// the per-call recomputation if anything diverges (e.g. an `x` with a
199/// different symbolic shape sneaks in).
200#[derive(Debug, Clone)]
201pub struct SparseXtwxPrecomputed {
202 pub xtwx_symbolic_col_ptr: Vec<usize>,
203 pub xtwx_symbolic_row_idx: Vec<usize>,
204 pub xtwxvalues: Vec<f64>,
205}
206
207impl SparseXtwxPrecomputed {
208 /// Build the precomputed `XᵀWX` value layout for `x` at the given
209 /// `weights`. The output reuses the same construction path the inner
210 /// PIRLS workspace uses, so it lands in exactly the symbolic pattern
211 /// the consumer expects.
212 pub fn build(
213 x: &SparseColMat<usize, f64>,
214 weights: &Array1<f64>,
215 ) -> Result<Self, EstimationError> {
216 let mut cache = SparseXtWxCache::new(x)?;
217 cache.compute_numeric(x, weights)?;
218 Ok(Self {
219 xtwx_symbolic_col_ptr: cache.xtwx_symbolic.col_ptr().to_vec(),
220 xtwx_symbolic_row_idx: cache.xtwx_symbolic.row_idx().to_vec(),
221 xtwxvalues: cache.xtwxvalues,
222 })
223 }
224}
225
226/// Identity-link solver that operates in original or QS-transformed coordinates
227/// without materializing X·Qs. When the design is sparse and `qs` is `None`
228/// (sparse-native path), uses sparse Cholesky for O(nnz^{1.5}) cost instead
229/// of the O(p³) dense Cholesky.
230pub(super) fn solve_penalized_least_squares_implicit(
231 x_original: &DesignMatrix,
232 transform: Option<&WorkingReparamTransform>,
233 z: ArrayView1<f64>,
234 weights: ArrayView1<f64>,
235 offset: ArrayView1<f64>,
236 penalty: &PirlsPenalty,
237 workspace: &mut PirlsWorkspace,
238 y: ArrayView1<f64>,
239 link_function: LinkFunction,
240 gaussian_fixed_cache: Option<&GaussianFixedCache>,
241) -> Result<(StablePLSResult, usize), EstimationError> {
242 let p_dim = penalty.dim();
243
244 // ── Sparse-native fast path ──────────────────────────────────────────
245 // When design is sparse and we are in original coordinates (qs = None),
246 // assemble the penalized Hessian in sparse format and solve with sparse
247 // Cholesky. This avoids O(p²) dense X'WX and O(p³) dense factorization.
248 if transform.is_none()
249 && let Some(x_sparse) = x_original.as_sparse()
250 {
251 let PirlsPenalty::Dense { s_transformed, .. } = penalty else {
252 crate::bail_invalid_estim!(
253 "sparse-native PIRLS requires a dense transformed penalty matrix"
254 );
255 };
256 let weights_owned = weights.to_owned();
257
258 // Gaussian-Identity fast path: the inner sparse `XᵀWX` is invariant
259 // across the outer REML loop because the IRLS weights are constant
260 // (W = priorweights). The cached values land in the inner workspace
261 // and bypass the per-eval SpGEMM.
262 let precomputed_xtwx =
263 gaussian_fixed_cache.and_then(|c| c.xtwx_sparse_orig.as_ref().map(|arc| arc.as_ref()));
264
265 // 1. Sparse penalized Hessian: H = X'diag(w)X + S_λ + ridge·I.
266 // The Cholesky factor is reused from the SPD check so we avoid
267 // factorizing the same matrix twice.
268 let (h_sparse, factor, ridge_used) = ensure_sparse_positive_definitewithridge(|ridge| {
269 let ridge = if ridge == 0.0 {
270 FIXED_STABILIZATION_RIDGE
271 } else {
272 ridge
273 };
274 workspace.assemble_sparse_penalized_hessian(
275 x_sparse,
276 &weights_owned,
277 s_transformed,
278 ridge,
279 precomputed_xtwx,
280 )
281 })?;
282
283 // 2. RHS = X'W(z - offset) + S_λ μ + ridge_used · μ.
284 // The `ridge_used · μ` term matches the diagonal ridge added to
285 // the Hessian in step 1, keeping the augmented system a
286 // Tikhonov regularization centered at the prior mean target
287 // rather than at zero (see `prior_mean_target` field docs).
288 let mut wz = z.to_owned();
289 wz -= &offset;
290 wz *= &weights_owned;
291 let mut rhs = x_original.transpose_vector_multiply(&wz);
292 rhs += penalty.linear_shift();
293 if ridge_used > 0.0 {
294 let prior_mean_target = penalty.prior_mean_target();
295 if prior_mean_target.len() == rhs.len() {
296 rhs.scaled_add(ridge_used, prior_mean_target);
297 }
298 }
299
300 // 3. Sparse Cholesky solve (factor reused from step 1)
301 let betavec = solve_sparse_spd(&factor, &rhs)?;
302
303 // 4. EDF — reuse the sparse Cholesky factor from step 1 to avoid a
304 // second O(nnz·…) factorization of the identical penalized Hessian.
305 let h_sym = SymmetricMatrix::Sparse(h_sparse);
306 let edf = calculate_edf_from_sparse_factor(&factor, penalty)?;
307
308 // 5. Scale. When Gaussian sufficient statistics are installed, compute
309 // RSS from k-space only; the design rows may be a stale reference
310 // surface on the #1033 ψ-tensor fast path.
311 let standard_deviation = match link_function {
312 LinkFunction::Identity => {
313 let weighted_rss = if let Some(cache) = gaussian_fixed_cache {
314 let quadratic = betavec.dot(&cache.xtwx_orig.dot(&betavec));
315 (cache.centered_weighted_y_sq - 2.0 * betavec.dot(&cache.xtwy_orig) + quadratic)
316 .max(0.0)
317 } else {
318 let fitted_vals = {
319 let xb = x_original.apply(&betavec);
320 let mut f = xb;
321 f += &offset;
322 f
323 };
324 let residuals = &y - &fitted_vals;
325 weights
326 .iter()
327 .zip(residuals.iter())
328 .map(|(&w, &r)| w * r * r)
329 .sum()
330 };
331 let effective_n = y.len() as f64;
332 (weighted_rss / (effective_n - edf).max(1.0)).sqrt()
333 }
334 _ => 1.0,
335 };
336
337 return Ok((
338 StablePLSResult {
339 beta: Coefficients::new(betavec),
340 penalized_hessian: h_sym,
341 edf,
342 standard_deviation,
343 ridge_used,
344 },
345 p_dim,
346 ));
347 }
348
349 // ── Dense / QS-rotated path ──────────────────────────────────────────
350
351 // 1. Prepare the row-weighted response only when no exact Gaussian
352 // sufficient statistics were supplied. A cached solve consumes XᵀWX and
353 // XᵀW(y-offset) directly, so materializing W(z-offset) would be an unused
354 // O(n) allocation and traversal on every rho candidate (#2435).
355 if gaussian_fixed_cache.is_none() {
356 if workspace.wz.len() != z.len() {
357 workspace.wz = Array1::zeros(z.len());
358 }
359 workspace.wz.assign(&z);
360 workspace.wz -= &offset;
361 workspace.wz *= &weights;
362 }
363
364 // 2. Form X'WX: compute in original coordinates, then rotate by Qs.
365 //
366 // Gaussian + Identity REML reuses a precomputed `XᵀWX` (the weights and
367 // design never change across the outer loop in that family), so when the
368 // caller supplied a `GaussianFixedCache` we skip the O(N·p²) dense
369 // assembly here and adopt the cached matrix as-is.
370 let xtwx_orig = if let Some(cache) = gaussian_fixed_cache {
371 // Cache hit: weights and design are invariant for Gaussian-Identity
372 // across the outer REML loop, so adopt the precomputed XᵀWX directly
373 // and avoid the O(N·p²) dense assembly entirely.
374 let p = x_original.ncols();
375 if cache.xtwx_orig.nrows() != p || cache.xtwx_orig.ncols() != p {
376 return Err(EstimationError::InvalidInput(format!(
377 "GaussianFixedCache XᵀWX shape {}×{} does not match design p={}",
378 cache.xtwx_orig.nrows(),
379 cache.xtwx_orig.ncols(),
380 p,
381 )));
382 }
383 cache.xtwx_orig.clone()
384 } else {
385 let weights_owned = weights.to_owned();
386 match x_original {
387 // Only materialized dense designs can use the shared dense assembly path.
388 // Lazy operator-backed dense designs route to diag_xtw_x like sparse.
389 DesignMatrix::Dense(x_dense) if x_dense.is_materialized_dense() => {
390 let p = x_dense.ncols();
391 let x_dense = x_dense.to_dense_arc();
392 if workspace.hessian_buf.nrows() != p || workspace.hessian_buf.ncols() != p {
393 workspace.hessian_buf = Array2::zeros((p, p).f());
394 } else {
395 workspace.hessian_buf.fill(0.0);
396 }
397 PirlsWorkspace::add_dense_xtwx_signed(
398 &weights_owned,
399 &mut workspace.weighted_x_chunk,
400 x_dense.as_ref(),
401 &mut workspace.hessian_buf,
402 );
403 std::mem::take(&mut workspace.hessian_buf)
404 }
405 _ => {
406 // Operator-form fallback: sparse designs and lazy operator-backed
407 // dense designs cannot be densified, so route through the signed
408 // XᵀWX operator.
409 gam_linalg::matrix::xt_diag_x_signed(
410 x_original,
411 gam_linalg::matrix::FiniteSignedWeightsView::try_from_array(&weights_owned)
412 .map_err(EstimationError::InvalidInput)?,
413 )
414 .map(|h| h.to_dense())
415 .map_err(EstimationError::InvalidInput)?
416 }
417 }
418 };
419 let xtwx_orig_asym = max_symmetric_asymmetry(&xtwx_orig);
420 let xtwx_transformed = if let Some(transform) = transform {
421 transform.conjugate_matrix(&xtwx_orig)
422 } else {
423 xtwx_orig
424 };
425 let mut penalized_hessian = xtwx_transformed.clone();
426 penalty.add_to_hessian(&mut penalized_hessian);
427
428 // 3. Form X'Wz: compute in original coordinates, then rotate.
429 // With the Gaussian-Identity cache `z = y` and `wz = W·(y − offset)`
430 // is identical across outer iterations, so reuse the precomputed
431 // `XᵀW(y − offset)` directly.
432 let xtwy_orig = if let Some(cache) = gaussian_fixed_cache {
433 assert_eq!(
434 cache.xtwy_orig.len(),
435 x_original.ncols(),
436 "GaussianFixedCache XᵀW(y−offset) length must match design p"
437 );
438 cache.xtwy_orig.clone()
439 } else {
440 x_original.transpose_vector_multiply(&workspace.wz)
441 };
442 if workspace.vec_buf_p.len() != p_dim {
443 workspace.vec_buf_p = Array1::zeros(p_dim);
444 }
445 if let Some(transform) = transform {
446 workspace
447 .vec_buf_p
448 .assign(&transform.apply_transpose(&xtwy_orig));
449 } else {
450 workspace.vec_buf_p.assign(&xtwy_orig);
451 }
452 workspace.vec_buf_p += penalty.linear_shift();
453
454 {
455 // The penalized Hessian is assembled from symmetric pieces (XᵀWX and
456 // the penalty), so any asymmetry is pure floating-point accumulation
457 // error; anything above this floor signals a genuine assembly bug.
458 const PENALIZED_HESSIAN_ASYMMETRY_TOL: f64 = 1e-8;
459 let xtwx_asym = max_symmetric_asymmetry(&xtwx_transformed);
460 let penalty_asym = match penalty {
461 PirlsPenalty::Dense { s_transformed, .. } => max_symmetric_asymmetry(s_transformed),
462 PirlsPenalty::Diagonal { .. } => 0.0,
463 };
464 let total_asym = max_symmetric_asymmetry(&penalized_hessian);
465 assert!(
466 total_asym <= PENALIZED_HESSIAN_ASYMMETRY_TOL,
467 "implicit PLS penalized Hessian asymmetry too large: total={total_asym:.3e}, xtwx_orig={xtwx_orig_asym:.3e}, xtwx={xtwx_asym:.3e}, penalty={penalty_asym:.3e}, tol={PENALIZED_HESSIAN_ASYMMETRY_TOL:.3e}",
468 );
469 }
470
471 // 4. Ridge stabilization — CONDITIONAL, matching the sparse path
472 // (`ensure_sparse_positive_definitewithridge`) and the dense Newton path
473 // (`ensure_positive_definitewithridge`). A penalized Hessian assembled from
474 // `XᵀWX + S_λ` is mathematically PSD; a fixed tiny nugget is only needed to
475 // cure round-off when the bare matrix narrowly fails Cholesky. Applying the
476 // nugget UNCONDITIONALLY (the previous behaviour) made β̂ the stationary
477 // point of the RIDGED objective `½βᵀ(H+δI)β`, so the inner residual was
478 // `Xᵀu − S_λβ̂ = δβ̂` rather than 0. The outer REML ψ-gradient differentiates
479 // the BARE objective via the envelope theorem (it assumes exact
480 // stationarity), so the gratuitous δ broke the envelope identity: the
481 // analytic datafit derivative `a` was short by `½·δ·βᵀ(dβ̂/dψ)` and the
482 // β-independent `log|H|` term was differentiated on the un-ridged surface
483 // while the criterion VALUE used `log|H+δI|`. For the Matérn iso-κ joint
484 // REML at θ₀ (`TransformedQs` frame, δ_eff ≈ 1.75e-6 in the original basis)
485 // this is exactly the residual outer-gradient↔FD DESYNC of #1122 (gap
486 // 2.565e-2, with `cos(Xᵀu−S_λβ̂, β̂) = 1.0000` pinning the residual to the
487 // ridge gradient). Try the bare matrix first so the well-conditioned common
488 // case carries NO ridge (`ridge_used = 0`) and the envelope identity holds
489 // exactly; fall back to the Tikhonov nugget only when the bare factorization
490 // actually fails. The augmented RHS `r + δμ` keeps the fallback a Tikhonov
491 // regularization centered at the prior-mean target.
492 let bare_factor = StableSolver::new().factorize(&penalized_hessian).ok();
493 let (factor, ridge_used) = if let Some(factor) = bare_factor {
494 (factor, 0.0)
495 } else {
496 let nugget = FIXED_STABILIZATION_RIDGE;
497 let mut regularizedhessian = penalized_hessian.clone();
498 if nugget > 0.0 {
499 for i in 0..p_dim {
500 regularizedhessian[[i, i]] += nugget;
501 }
502 }
503 let factor = StableSolver::new()
504 .factorize(®ularizedhessian)
505 .map_err(EstimationError::LinearSystemSolveFailed)?;
506 (factor, nugget)
507 };
508
509 // 5. Solve
510 if workspace.rhs_full.len() != p_dim {
511 workspace.rhs_full = Array1::zeros(p_dim);
512 }
513 workspace.rhs_full.assign(&workspace.vec_buf_p);
514 if ridge_used > 0.0 {
515 let prior_mean_target = penalty.prior_mean_target();
516 if prior_mean_target.len() == p_dim {
517 workspace.rhs_full.scaled_add(ridge_used, prior_mean_target);
518 }
519 }
520 let mut rhsview = array1_to_col_matmut(&mut workspace.rhs_full);
521 factor.solve_in_place(rhsview.as_mut());
522 if !array_is_finite(&workspace.rhs_full) {
523 return Err(EstimationError::LinearSystemSolveFailed(
524 FaerLinalgError::FactorizationFailed {
525 context: "PIRLS implicit PLS non-finite solve",
526 },
527 ));
528 }
529 let betavec = workspace.rhs_full.clone();
530
531 // 6. EDF — reuse the factor already produced in step 5 to avoid a second
532 // O(p³) factorization of the identical regularized Hessian.
533 let edf = calculate_edfwithworkspace_from_factor(&factor, penalty, workspace)?;
534
535 // 7. Scale (composed: eta = offset + X Qs beta). When Gaussian sufficient
536 // statistics are installed, compute RSS from k-space only; the design rows
537 // may be a stale reference surface on the #1033 ψ-tensor fast path.
538 let qbeta = if let Some(transform) = transform {
539 transform.apply(&betavec)
540 } else {
541 betavec.clone()
542 };
543 let standard_deviation = match link_function {
544 LinkFunction::Identity => {
545 let weighted_rss = if let Some(cache) = gaussian_fixed_cache {
546 let quadratic = qbeta.dot(&cache.xtwx_orig.dot(&qbeta));
547 (cache.centered_weighted_y_sq - 2.0 * qbeta.dot(&cache.xtwy_orig) + quadratic)
548 .max(0.0)
549 } else {
550 let xqbeta = x_original.apply(&qbeta);
551 let mut fitted = xqbeta;
552 fitted += &offset;
553 let residuals = &y - &fitted;
554 weights
555 .iter()
556 .zip(residuals.iter())
557 .map(|(&w, &r)| w * r * r)
558 .sum()
559 };
560 let effective_n = y.len() as f64;
561 (weighted_rss / (effective_n - edf).max(1.0)).sqrt()
562 }
563 _ => 1.0,
564 };
565
566 Ok((
567 StablePLSResult {
568 beta: Coefficients::new(betavec),
569 penalized_hessian: SymmetricMatrix::Dense(penalized_hessian),
570 edf,
571 standard_deviation,
572 ridge_used,
573 },
574 p_dim,
575 ))
576}