gam_solve/pirls/loop_driver.rs
1//! Outer driver for a single fixed-ρ PIRLS fit.
2//!
3//! Owns:
4//! - `fit_model_for_fixed_rho` and `fit_model_for_fixed_rho_with_adaptive_kkt`
5//! — build the working model, run the inner LM loop, assemble the final result.
6//! - `PirlsProblem`, `PenaltyConfig`, `PirlsConfig` — the configuration types.
7//! - Helper functions exclusive to the fixed-ρ fitting path: constraint
8//! transformation, sparse-native decision, reparam materialisation, prior
9//! shift assembly, initial-β guess, Gaussian short-circuit assembly, etc.
10//! - The two GPU dispatch blocks (Stage 3.3) that call into
11//! `crate::gpu::pirls_dispatch_wire`.
12
13use super::{
14 // state re-exports
15 AdaptiveKktTolerance,
16 ExportedLaplaceCurvature,
17 FirthDiagnostics,
18 GamWorkingModel,
19 GaussianFrozenRows,
20 HessianCurvatureKind,
21 // penalty types
22 KroneckerQsTransform,
23 LinearInequalityConstraints,
24 PirlsCoordinateFrame,
25 PirlsLinearSolvePath,
26 PirlsPenalty,
27 PirlsResult,
28 PirlsStatus,
29 PirlsWorkspace,
30 SparsePirlsDecision,
31 WorkingModelIterationInfo,
32 WorkingModelPirlsOptions,
33 WorkingModelPirlsResult,
34 WorkingReparamTransform,
35 WorkingState,
36 // misc helpers
37 array1_l2_norm,
38 attach_penalty_shift,
39 // compute functions
40 calculate_deviance_from_eta,
41 // edf helpers
42 calculate_edf_with_penalty,
43 calculate_edfwithworkspace_with_penalty,
44 compute_constraint_kkt_diagnostics,
45 computeworkingweight_derivatives_from_eta,
46 inf_norm,
47 pirls_data_log_kernel_from_eta,
48 runworking_model_pirls,
49 should_use_sparse_native_pirls,
50 solve_penalized_least_squares_implicit,
51 standard_inverse_link_jet,
52};
53use super::{
54 ArrowSchurInnerConfig, GamModelFinalState, effective_kkt_tolerance,
55 project_coefficients_to_lower_bounds,
56};
57use crate::active_set;
58use crate::estimate::EstimationError;
59use crate::gpu::pirls_host_dispatch::{try_gaussian_pls_gpu, try_pirls_loop_gpu};
60use faer::sparse::{SparseColMat, Triplet};
61use gam_linalg::faer_ndarray::fast_ab;
62use gam_linalg::matrix::{DesignMatrix, LinearOperator, ReparamOperator, SymmetricMatrix};
63use gam_math::probability::standard_normal_quantile;
64use gam_problem::{
65 Coefficients, GlmLikelihoodSpec, InverseLink, LinearPredictor, LinkFunction,
66 LogSmoothingParamsView, MixtureLinkState, ResolvedLikelihoodScale, ResponseFamily,
67 RidgePassport, RidgePolicy, SasLinkState, StandardLink,
68};
69use gam_terms::construction::{KroneckerReparamResult, ReparamResult};
70use ndarray::{ArcArray1, Array1, Array2, ArrayView1, ArrayView2, s};
71use std::borrow::Cow;
72use std::sync::Arc;
73use std::sync::atomic::{AtomicU64, Ordering};
74
75/// #1868 deterministic n-independence instrument.
76///
77/// Process-global accumulator of the number of length-`n` row-element touches
78/// (array allocations / row-wise scans) performed by the Gaussian
79/// zero-iteration inner synthesis on the **#1033 n-free κ-trial skip path**
80/// (`row_prediction_is_stale`). On that path the outer criterion, gradient and
81/// inner solve are all served from the k-space ψ-Gram sufficient statistics, so
82/// the architectural invariant (#1033: "each hyperparameter trial touches only
83/// k×k objects") requires this counter to stay FLAT — it must not grow with `n`.
84/// A value that scales with `n` is exactly the #1868 O(n)-per-callback
85/// regression (the stale-row lane re-materialising `offset`/`y`/`weights` and
86/// the constant working-weight derivative arrays per trial instead of sharing
87/// the once-built frozen row bundle).
88///
89/// This is the *deterministic* replacement for the old wall-clock
90/// per-callback-ratio gate (#1868 / #2055): the same invariant, read as an exact
91/// integer in milliseconds at small `n` instead of a noisy timing ratio that
92/// needed a multi-hour 320k sweep to surface. Monotonic; callers snapshot the
93/// value before and after the κ-trial phase and assert on the delta.
94pub(crate) static NFREE_SKIP_ROW_ELEMENT_TOUCHES: AtomicU64 = AtomicU64::new(0);
95
96/// Record `elems` length-`n` row-element touches on the n-free κ-trial skip
97/// path (see [`NFREE_SKIP_ROW_ELEMENT_TOUCHES`]). Called at each length-`n`
98/// materialisation the stale-row Gaussian synthesis performs; after the #1868
99/// frozen-row-bundle fix the skip path performs none, so the accumulator holds
100/// flat across `n`.
101#[inline]
102pub(crate) fn record_nfree_skip_row_touches(elems: usize) {
103 NFREE_SKIP_ROW_ELEMENT_TOUCHES.fetch_add(elems as u64, Ordering::Relaxed);
104}
105
106/// Read the process-global n-free κ-trial skip-path row-touch accumulator.
107/// Exposed so the spatial length-scale driver can snapshot deltas across the
108/// κ-optimisation phase and thread them into the reported timing.
109pub fn nfree_skip_row_element_touches() -> u64 {
110 NFREE_SKIP_ROW_ELEMENT_TOUCHES.load(Ordering::Relaxed)
111}
112
113pub(crate) fn exact_lambdas_from_rho(rho: LogSmoothingParamsView<'_>) -> Array1<f64> {
114 rho.exact_exp()
115}
116
117pub(super) fn default_beta_guess_external(
118 p: usize,
119 link_function: LinkFunction,
120 y: ArrayView1<f64>,
121 priorweights: ArrayView1<f64>,
122 mixture_link_state: Option<&MixtureLinkState>,
123 sas_link_state: Option<&SasLinkState>,
124) -> Array1<f64> {
125 let mut beta = Array1::<f64>::zeros(p);
126 let intercept_col = 0usize;
127 match link_function {
128 LinkFunction::Logit
129 | LinkFunction::Probit
130 | LinkFunction::CLogLog
131 | LinkFunction::LogLog
132 | LinkFunction::Cauchit
133 | LinkFunction::Sas
134 | LinkFunction::BetaLogistic => {
135 let mut weighted_sum = 0.0;
136 let mut totalweight = 0.0;
137 for (&yi, &wi) in y.iter().zip(priorweights.iter()) {
138 weighted_sum += wi * yi;
139 totalweight += wi;
140 }
141 if totalweight > 0.0 {
142 let prevalence =
143 ((weighted_sum + 0.5) / (totalweight + 1.0)).clamp(1e-6, 1.0 - 1e-6);
144 beta[intercept_col] = match link_function {
145 LinkFunction::Logit => (prevalence / (1.0 - prevalence)).ln(),
146 LinkFunction::Probit => {
147 standard_normal_quantile(prevalence).unwrap_or_else(|_| {
148 // `prevalence` is clamped to (0, 1); this fallback is
149 // only for defensive robustness under non-finite upstream inputs.
150 (prevalence / (1.0 - prevalence)).ln()
151 })
152 }
153 LinkFunction::CLogLog => (-(1.0 - prevalence).ln()).ln(),
154 LinkFunction::LogLog => -(-prevalence.ln()).ln(),
155 LinkFunction::Cauchit => (std::f64::consts::PI * (prevalence - 0.5)).tan(),
156 LinkFunction::Sas => solve_intercept_for_prevalence(
157 link_function,
158 prevalence,
159 mixture_link_state,
160 sas_link_state,
161 )
162 .unwrap_or_else(|| {
163 standard_normal_quantile(prevalence)
164 .unwrap_or_else(|_| (prevalence / (1.0 - prevalence)).ln())
165 }),
166 LinkFunction::BetaLogistic => solve_intercept_for_prevalence(
167 link_function,
168 prevalence,
169 mixture_link_state,
170 sas_link_state,
171 )
172 .unwrap_or_else(|| {
173 standard_normal_quantile(prevalence)
174 .unwrap_or_else(|_| (prevalence / (1.0 - prevalence)).ln())
175 }),
176 // Outer arm guard already filtered out Log/Identity; fall
177 // back to the canonical logit transform for defensive safety
178 // if these are ever reached unexpectedly.
179 LinkFunction::Log | LinkFunction::Identity => {
180 (prevalence / (1.0 - prevalence)).ln()
181 }
182 };
183 if mixture_link_state.is_some() {
184 beta[intercept_col] = solve_intercept_for_prevalence(
185 link_function,
186 prevalence,
187 mixture_link_state,
188 sas_link_state,
189 )
190 .unwrap_or(beta[intercept_col]);
191 }
192 }
193 }
194 LinkFunction::Identity => {
195 let mut weighted_sum = 0.0;
196 let mut totalweight = 0.0;
197 for (&yi, &wi) in y.iter().zip(priorweights.iter()) {
198 weighted_sum += wi * yi;
199 totalweight += wi;
200 }
201 if totalweight > 0.0 {
202 beta[intercept_col] = weighted_sum / totalweight;
203 }
204 }
205 LinkFunction::Log => {
206 // For log link, intercept = ln(weighted mean of y)
207 let mut weighted_sum = 0.0;
208 let mut totalweight = 0.0;
209 for (&yi, &wi) in y.iter().zip(priorweights.iter()) {
210 weighted_sum += wi * yi;
211 totalweight += wi;
212 }
213 if totalweight > 0.0 {
214 let mean_y = (weighted_sum / totalweight).max(1e-10);
215 beta[intercept_col] = mean_y.ln();
216 }
217 }
218 }
219 beta
220}
221
222pub(super) fn solve_intercept_for_prevalence(
223 link_function: LinkFunction,
224 prevalence: f64,
225 mixture_link_state: Option<&MixtureLinkState>,
226 sas_link_state: Option<&SasLinkState>,
227) -> Option<f64> {
228 #[inline]
229 fn f_eta(
230 link_function: LinkFunction,
231 eta: f64,
232 prevalence: f64,
233 mixture_link_state: Option<&MixtureLinkState>,
234 sas_link_state: Option<&SasLinkState>,
235 ) -> f64 {
236 let inverse_link = if let Some(state) = mixture_link_state {
237 InverseLink::Mixture(state.clone())
238 } else if let Some(state) = sas_link_state {
239 match link_function {
240 LinkFunction::BetaLogistic => InverseLink::BetaLogistic(*state),
241 _ => InverseLink::Sas(*state),
242 }
243 } else {
244 // SAFETY: when `sas_link_state` is None, `solve_intercept_for_prevalence`
245 // is only invoked with the five legal `StandardLink` variants (the
246 // dispatch site at pirls.rs:4203 routes Sas/BetaLogistic into the
247 // Some branch above with state).
248 InverseLink::Standard(StandardLink::try_from(link_function).expect(
249 "state-bearing link reached state-less arm in solve_intercept_for_prevalence",
250 ))
251 };
252 standard_inverse_link_jet(&inverse_link, eta)
253 .map(|jet| jet.mu - prevalence)
254 .unwrap_or(f64::NAN)
255 }
256
257 let mut lo = -40.0;
258 let mut hi = 40.0;
259 let mut f_lo = f_eta(
260 link_function,
261 lo,
262 prevalence,
263 mixture_link_state,
264 sas_link_state,
265 );
266 let mut f_hi = f_eta(
267 link_function,
268 hi,
269 prevalence,
270 mixture_link_state,
271 sas_link_state,
272 );
273 if !(f_lo.is_finite() && f_hi.is_finite()) {
274 return None;
275 }
276 for _ in 0..8 {
277 if f_lo <= 0.0 && f_hi >= 0.0 {
278 break;
279 }
280 lo *= 2.0;
281 hi *= 2.0;
282 f_lo = f_eta(
283 link_function,
284 lo,
285 prevalence,
286 mixture_link_state,
287 sas_link_state,
288 );
289 f_hi = f_eta(
290 link_function,
291 hi,
292 prevalence,
293 mixture_link_state,
294 sas_link_state,
295 );
296 if !(f_lo.is_finite() && f_hi.is_finite()) {
297 return None;
298 }
299 }
300 if f_lo > 0.0 {
301 return Some(lo);
302 }
303 if f_hi < 0.0 {
304 return Some(hi);
305 }
306 for _ in 0..80 {
307 let mid = 0.5 * (lo + hi);
308 let f_mid = f_eta(
309 link_function,
310 mid,
311 prevalence,
312 mixture_link_state,
313 sas_link_state,
314 );
315 if !f_mid.is_finite() {
316 return None;
317 }
318 if f_mid > 0.0 {
319 hi = mid;
320 } else {
321 lo = mid;
322 }
323 }
324 Some(0.5 * (lo + hi))
325}
326
327pub(super) fn assemble_pirls_result(
328 working_summary: &WorkingModelPirlsResult,
329 likelihood: GlmLikelihoodSpec,
330 offset: ArrayView1<'_, f64>,
331 penalized_hessian_transformed: SymmetricMatrix,
332 stabilizedhessian_transformed: SymmetricMatrix,
333 edf: f64,
334 penalty_term: f64,
335 finalmu: &Array1<f64>,
336 finalweights: &Array1<f64>,
337 scoreweights: &Array1<f64>,
338 finalz: &Array1<f64>,
339 final_c: &Array1<f64>,
340 final_d: &Array1<f64>,
341 final_dmu_deta: &Array1<f64>,
342 final_d2mu_deta2: &Array1<f64>,
343 final_d3mu_deta3: &Array1<f64>,
344 status: PirlsStatus,
345 reparam_result: ReparamResult,
346 x_transformed: DesignMatrix,
347 coordinate_frame: PirlsCoordinateFrame,
348 linear_constraints_transformed: Option<LinearInequalityConstraints>,
349) -> Result<PirlsResult, EstimationError> {
350 // #1868: the full-assembly path is legitimately O(n) (this is the one-off
351 // final fit, not a per-callback n-free skip); wrap its freshly-realised row
352 // arrays in the shared `ArcArray1` representation (`.into_shared()` moves the
353 // owned buffer into an `Arc`, O(1)). `finalmu`/`solvemu` share one handle.
354 let final_eta_arr = working_summary.state.eta.as_ref().clone();
355 let finalmu_shared = finalmu.clone().into_shared();
356 Ok(PirlsResult {
357 likelihood,
358 beta_transformed: working_summary.beta.clone(),
359 penalized_hessian_transformed,
360 stabilizedhessian_transformed,
361 ridge_passport: RidgePassport::scaled_identity(
362 working_summary.state.ridge_used,
363 RidgePolicy::exact_full_objective(),
364 )?,
365 deviance: working_summary.state.deviance,
366 edf,
367 stable_penalty_term: penalty_term,
368 firth: working_summary.state.firth.clone(),
369 finalweights: finalweights.clone().into_shared(),
370 final_offset: offset.to_owned().into_shared(),
371 final_eta: final_eta_arr.into_shared(),
372 finalmu: finalmu_shared.clone(),
373 solveweights: scoreweights.clone().into_shared(),
374 solveworking_response: finalz.clone().into_shared(),
375 solvemu: finalmu_shared,
376 solve_dmu_deta: final_dmu_deta.clone().into_shared(),
377 solve_d2mu_deta2: final_d2mu_deta2.clone().into_shared(),
378 solve_d3mu_deta3: final_d3mu_deta3.clone().into_shared(),
379 solve_c_array: final_c.clone().into_shared(),
380 solve_c_nontrivial: final_c.iter().any(|&value| value != 0.0),
381 solve_d_array: final_d.clone().into_shared(),
382 derivatives_unsupported: false,
383 status,
384 iteration: working_summary.iterations,
385 max_abs_eta: working_summary.max_abs_eta,
386 lastgradient_norm: working_summary.lastgradient_norm,
387 gradient_natural_scale: working_summary.state.gradient_natural_scale,
388 penalized_gradient_transformed: working_summary.state.gradient.clone(),
389 last_deviance_change: working_summary.last_deviance_change,
390 last_step_halving: working_summary.last_step_halving,
391 hessian_curvature: working_summary.state.hessian_curvature,
392 exported_laplace_curvature: working_summary.exported_laplace_curvature.clone(),
393 final_lm_lambda: working_summary.final_lm_lambda,
394 final_accept_rho: working_summary.final_accept_rho,
395 constraint_kkt: working_summary.constraint_kkt.clone(),
396 linear_constraints_transformed,
397 reparam_result,
398 x_transformed,
399 coordinate_frame,
400 used_device: false,
401 cache_compacted: false,
402 min_penalized_deviance: working_summary.min_penalized_deviance,
403 })
404}
405
406pub(super) fn detect_logit_instability(
407 link: LinkFunction,
408 response: &ResponseFamily,
409 has_penalty: bool,
410 firth_active: bool,
411 summary: &WorkingModelPirlsResult,
412 finalmu: &Array1<f64>,
413 finalweights: &Array1<f64>,
414 y: ArrayView1<'_, f64>,
415) -> bool {
416 // Perfect / quasi-perfect separation is a *Bernoulli/Binomial* pathology.
417 // Every heuristic below is binary-response–specific: saturation toward
418 // μ ∈ {0, 1}, the `yᵢ > 0.5` order-separation split, and working-weight
419 // collapse only carry meaning when each `yᵢ` is a 0/1 outcome (or a
420 // proportion of Bernoulli trials). The Beta family also fits through the
421 // logit link, but its response is *continuous* on (0, 1): a perfectly
422 // healthy monotone mean (μ increasing in a covariate ⇒ rows with y > 0.5
423 // sit at higher η than rows with y ≤ 0.5) trivially satisfies the
424 // `order_separated` test, so gating this detector on the logit link alone
425 // misclassifies well-behaved Beta fits as separated and forces a spurious
426 // inner-solve retreat at every smoothing-parameter seed (issue #499).
427 // Gate strictly on the Binomial response so only binary GLMs are screened.
428 if !matches!(response, ResponseFamily::Binomial) || link != LinkFunction::Logit || firth_active
429 {
430 return false;
431 }
432
433 // Separation-detection policy thresholds. Each is a heuristic cut-off, not
434 // a math identity: they decide when a binary-logit fit has drifted into the
435 // perfect/quasi-perfect separation regime and the inner solve must retreat.
436 //
437 // `ORDER_SEPARATION_ETA_GAP`: a strictly positive η-gap between the lowest
438 // η among y=1 rows and the highest among y=0 rows means the two classes
439 // are linearly separable on the linear predictor.
440 // `EXTREME_ETA`: |η| this large drives μ to within machine-ε of {0,1}.
441 // `SATURATION_FRACTION` / `SEVERE_SATURATION_FRACTION`: share of fitted μ
442 // pinned to the {0,1} boundary that flags (severe) saturation.
443 // `DEGENERATE_DEVIANCE_PER_SAMPLE` / `EXTREME_DEGENERATE_DEVIANCE_PER_SAMPLE`:
444 // near-zero per-sample deviance means the model fits the data perfectly.
445 // `EXTREME_BETA_NORM`: coefficient norm blow-up characteristic of the MLE
446 // escaping to infinity under separation.
447 // `WEIGHT_COLLAPSE_FRACTION`: share of working weights collapsed to ~0.
448 const ORDER_SEPARATION_ETA_GAP: f64 = 1e-3;
449 const EXTREME_ETA: f64 = 30.0;
450 const SATURATION_FRACTION: f64 = 0.98;
451 const SEVERE_SATURATION_FRACTION: f64 = 0.995;
452 const DEGENERATE_DEVIANCE_PER_SAMPLE: f64 = 1e-3;
453 const EXTREME_DEGENERATE_DEVIANCE_PER_SAMPLE: f64 = 1e-6;
454 const EXTREME_BETA_NORM: f64 = 1e4;
455 const WEIGHT_COLLAPSE_FRACTION: f64 = 0.98;
456
457 let n = y.len() as f64;
458 if n == 0.0 {
459 return false;
460 }
461
462 let max_abs_eta = summary.max_abs_eta;
463 let sat_fraction = {
464 const SAT_EPS: f64 = 1e-3;
465 finalmu
466 .iter()
467 .filter(|&&m| m <= SAT_EPS || m >= 1.0 - SAT_EPS)
468 .count() as f64
469 / n
470 };
471
472 let weight_collapse_fraction = {
473 const WEIGHT_EPS: f64 = 1e-8;
474 finalweights
475 .iter()
476 .filter(|&&w| w <= WEIGHT_EPS || !w.is_finite())
477 .count() as f64
478 / n
479 };
480
481 let beta_norm = summary.beta.as_ref().dot(summary.beta.as_ref()).sqrt();
482 let dev_per_sample = summary.state.deviance / n;
483
484 let mut has_pos = false;
485 let mut has_neg = false;
486 let mut min_eta_pos = f64::INFINITY;
487 let mut max_eta_neg = f64::NEG_INFINITY;
488 for (eta_i, &yi) in summary.state.eta.iter().zip(y.iter()) {
489 if yi > 0.5 {
490 has_pos = true;
491 if *eta_i < min_eta_pos {
492 min_eta_pos = *eta_i;
493 }
494 } else {
495 has_neg = true;
496 if *eta_i > max_eta_neg {
497 max_eta_neg = *eta_i;
498 }
499 }
500 }
501 let order_separated =
502 has_pos && has_neg && (min_eta_pos - max_eta_neg) > ORDER_SEPARATION_ETA_GAP;
503
504 let classic_signals = max_abs_eta > EXTREME_ETA
505 || sat_fraction > SATURATION_FRACTION
506 || dev_per_sample < DEGENERATE_DEVIANCE_PER_SAMPLE
507 || beta_norm > EXTREME_BETA_NORM;
508
509 if !has_penalty {
510 return classic_signals || order_separated;
511 }
512
513 let severe_saturation = sat_fraction > SEVERE_SATURATION_FRACTION && max_abs_eta > EXTREME_ETA;
514 let weights_collapsed = weight_collapse_fraction > WEIGHT_COLLAPSE_FRACTION;
515 let dev_extremely_small = dev_per_sample < EXTREME_DEGENERATE_DEVIANCE_PER_SAMPLE;
516
517 order_separated || severe_saturation || weights_collapsed || dev_extremely_small
518}
519
520/// Stack λ-weighted penalty roots from canonical penalties into a single
521/// `total_rank × p` matrix for PIRLS. Each block-local root is embedded
522/// into the full column space on-the-fly.
523pub(super) fn stack_lambdaweighted_penalty_root_canonical(
524 penalties: &[gam_terms::construction::CanonicalPenalty],
525 lambdas: &[f64],
526 p: usize,
527) -> Array2<f64> {
528 let totalrows: usize = penalties.iter().map(|cp| cp.rank()).sum();
529 if totalrows == 0 {
530 return Array2::zeros((0, p));
531 }
532 let mut e = Array2::<f64>::zeros((totalrows, p));
533 let mut row_start = 0usize;
534 for (k, cp) in penalties.iter().enumerate() {
535 let rows = cp.rank();
536 if rows == 0 {
537 continue;
538 }
539 let scale = lambdas.get(k).copied().unwrap_or(0.0).max(0.0).sqrt();
540 if scale != 0.0 {
541 // Embed block-local root (rank × block_dim) into full width (rank × p).
542 let r = &cp.col_range;
543 for row in 0..rows {
544 for col in 0..cp.block_dim() {
545 e[[row_start + row, r.start + col]] = scale * cp.root[[row, col]];
546 }
547 }
548 }
549 row_start += rows;
550 }
551 e
552}
553
554pub(super) fn build_sparse_native_reparam_result(
555 base: ReparamResult,
556 penalties: &[gam_terms::construction::CanonicalPenalty],
557 lambdas: &[f64],
558 p: usize,
559) -> ReparamResult {
560 // Map the engine penalty back into identity (original) coordinates. The
561 // engine returns `s_transformed = Qsᵀ S Qs` (and `e_transformed = E Qs`)
562 // with `S = S_λ + shrinkage·P_range` already folded in (so it matches the
563 // reported `log_det`/`det1`). With the sparse-native `qs = I` we need that
564 // SAME penalty expressed in original coordinates: `S_orig = Qs S_transformed
565 // Qsᵀ`. Rebuilding `S_orig` from the bare lambda-weighted canonical sum
566 // would DROP the shrinkage ridge and desync the inner penalized Hessian from
567 // the penalty log-determinant the REML criterion uses for this fit — the
568 // cross-backend λ-selection divergence (#1266 class). Round-tripping the
569 // engine penalty through `Qs` keeps the inner solve, EDF, and REML logdet on
570 // one penalty.
571 let qs = &base.qs;
572 let s_orig = if qs.nrows() == p && qs.ncols() == base.s_transformed.nrows() {
573 // S_orig = Qs · S_transformed · Qsᵀ
574 let qs_s = fast_ab(qs, &base.s_transformed);
575 qs_s.dot(&qs.t())
576 } else {
577 // Degenerate fallback (engine produced no transform): use the bare
578 // lambda-weighted sum. Shrinkage is zero in this branch by construction.
579 let mut s_original = Array2::<f64>::zeros((p, p));
580 for (k, cp) in penalties.iter().enumerate() {
581 let lambda_k = lambdas.get(k).copied().unwrap_or(0.0);
582 if lambda_k != 0.0 {
583 cp.accumulate_weighted(&mut s_original, lambda_k);
584 }
585 }
586 s_original
587 };
588 // E_orig = E_transformed · Qsᵀ (so that E_origᵀ E_orig = S_orig and the EDF
589 // augmented system matches the inner Hessian).
590 let e_orig = if qs.nrows() == p && base.e_transformed.ncols() == qs.ncols() {
591 base.e_transformed.dot(&qs.t())
592 } else {
593 stack_lambdaweighted_penalty_root_canonical(penalties, lambdas, p)
594 };
595 let u_original = if base.u_truncated.nrows() == p {
596 fast_ab(&base.qs, &base.u_truncated)
597 } else {
598 Array2::<f64>::eye(p)
599 };
600 // In the sparse-native path, qs = I, so the penalties are already in the
601 // right coordinate frame. We keep them as-is in canonical_transformed.
602 let canonical_transformed: Vec<gam_terms::construction::CanonicalPenalty> = penalties.to_vec();
603 ReparamResult {
604 penalty_shrinkage_ridge: base.penalty_shrinkage_ridge,
605 s_transformed: s_orig,
606 log_det: base.log_det,
607 det1: base.det1,
608 qs: Array2::<f64>::eye(p),
609 canonical_transformed,
610 e_transformed: e_orig,
611 u_truncated: u_original,
612 }
613}
614
615pub(super) fn build_diagonal_penalty_from_kronecker(
616 kron_result: &KroneckerReparamResult,
617 lambdas: &[f64],
618) -> PirlsPenalty {
619 let d = kron_result.marginal_dims.len();
620 let p: usize = kron_result.marginal_dims.iter().copied().product();
621 let mut diag = Array1::<f64>::zeros(p);
622 let mut positive_indices = Vec::new();
623
624 const KRONECKER_STRUCTURAL_ZERO_TOL: f64 = 1e-12;
625 let mut multi_idx = vec![0usize; d];
626 let mut flat = 0usize;
627 loop {
628 let mut sigma = 0.0;
629 let mut structural_sigma = 0.0;
630 for k in 0..d {
631 let marginal_eigenvalue = kron_result.marginal_eigenvalues[k][multi_idx[k]];
632 structural_sigma += marginal_eigenvalue;
633 sigma += lambdas[k] * marginal_eigenvalue;
634 }
635 let joint_null = structural_sigma <= KRONECKER_STRUCTURAL_ZERO_TOL;
636 if kron_result.has_double_penalty && lambdas.len() > d && joint_null {
637 sigma += lambdas[d];
638 }
639 if structural_sigma > KRONECKER_STRUCTURAL_ZERO_TOL {
640 sigma += kron_result.penalty_shrinkage_ridge;
641 }
642 diag[flat] = sigma;
643 if sigma > 0.0 {
644 positive_indices.push(flat);
645 }
646 flat += 1;
647
648 let mut carry = true;
649 for dim in (0..d).rev() {
650 if carry {
651 multi_idx[dim] += 1;
652 if multi_idx[dim] < kron_result.marginal_dims[dim] {
653 carry = false;
654 } else {
655 multi_idx[dim] = 0;
656 }
657 }
658 }
659 if carry {
660 break;
661 }
662 }
663
664 PirlsPenalty::Diagonal {
665 diag,
666 positive_indices,
667 linear_shift: Array1::zeros(p),
668 constant_shift: 0.0,
669 prior_mean_target: Array1::zeros(p),
670 }
671}
672
673pub(super) fn canonical_prior_shift(
674 penalties: &[gam_terms::construction::CanonicalPenalty],
675 lambdas: &[f64],
676 p: usize,
677) -> (Array1<f64>, f64) {
678 let mut linear = Array1::<f64>::zeros(p);
679 let mut constant = 0.0;
680 for (idx, cp) in penalties.iter().enumerate() {
681 let Some(&lambda) = lambdas.get(idx) else {
682 continue;
683 };
684 if lambda == 0.0 {
685 continue;
686 }
687 linear += &cp.prior_linear_shift(lambda);
688 constant += cp.prior_constant_shift(lambda);
689 }
690 (linear, constant)
691}
692
693/// Aggregate prior-mean target across canonical penalty blocks: the sum of
694/// each block's `full_width_prior_mean()`. Used by the PIRLS solve sites
695/// that add a fixed stabilization ridge `δI` to the penalized Hessian — they
696/// must also add `δ · prior_mean_target` to the RHS to keep `β = μ` recovery
697/// exact when the data carries no information (X'WX = 0). Equivalent to
698/// `canonical_prior_shift` with all λ = 1 and dropping `S_k` from the linear
699/// piece (i.e., raw μ rather than `S_k μ`). Returned in the *original*
700/// coordinates; callers transform if needed.
701pub(super) fn canonical_prior_mean_aggregate(
702 penalties: &[gam_terms::construction::CanonicalPenalty],
703 p: usize,
704) -> Array1<f64> {
705 let mut mean = Array1::<f64>::zeros(p);
706 for cp in penalties {
707 mean += &cp.full_width_prior_mean();
708 }
709 mean
710}
711
712pub struct PirlsProblem<'a, X> {
713 pub x: X,
714 pub offset: ArrayView1<'a, f64>,
715 pub y: ArrayView1<'a, f64>,
716 pub priorweights: ArrayView1<'a, f64>,
717 pub covariate_se: Option<ArrayView1<'a, f64>>,
718 /// When set, the inner PLS solver reuses the precomputed `XᵀWX` and
719 /// `XᵀW(y − offset)` in *original* coordinates instead of streaming the
720 /// O(N·p²) GEMM and the O(N·p) matvec on every outer REML iteration.
721 ///
722 /// Valid only when the family is Gaussian + Identity link, prior weights
723 /// are constant across outer iterations (always true in the REML outer
724 /// loop), no Firth bias reduction, and no inequality / lower-bound
725 /// constraints (matching the existing Identity short-circuit at
726 /// `pirls.rs:6237`). The penalty `λ·S` is still added per-λ on top of
727 /// the cached `XᵀWX`.
728 pub gaussian_fixed_cache: Option<&'a GaussianFixedCache>,
729 /// Frozen-weight first-Fisher-step data-fit Gram `XᵀWX` for a GLM
730 /// design-moving ψ-trial (#1111 / #1033 mechanism (c)), in *original*
731 /// (conditioned `x_fit`) coordinates. When set, the iterative GLM P-IRLS
732 /// serves its FIRST Fisher-scoring iteration's `XᵀWX` from this matrix
733 /// instead of streaming the O(N·p²) weighted cross-product; every later
734 /// iteration restreams the true moving `W`, so the converged β̂ is
735 /// unchanged. Mutually distinct from `gaussian_fixed_cache` (which is the
736 /// Gaussian-identity converged-objective short-circuit); this is the GLM
737 /// first-step lane and never short-circuits the iteration count.
738 pub glm_first_step_gram: Option<&'a Array2<f64>>,
739}
740
741// GaussianFixedCache is defined in pls_solver.
742pub use super::pls_solver::GaussianFixedCache;
743
744pub struct PenaltyConfig<'a> {
745 /// Block-local canonical penalties with precomputed roots and spectral data.
746 /// This is the single canonical penalty representation — no full-width
747 /// `rank × p` roots are stored. When the reparameterization engine needs
748 /// full-width roots, they are derived on-the-fly from these block-local roots.
749 pub canonical_penalties: &'a [gam_terms::construction::CanonicalPenalty],
750 pub balanced_penalty_root: Option<&'a Array2<f64>>,
751 pub reparam_invariant: Option<&'a gam_terms::construction::ReparamInvariant>,
752 pub p: usize,
753 pub coefficient_lower_bounds: Option<&'a Array1<f64>>,
754 pub linear_constraints_original: Option<&'a LinearInequalityConstraints>,
755 /// Relative shrinkage floor for eigenvalues of the penalized block.
756 /// If `Some(epsilon)`, a rho-independent ridge of `epsilon * max_balanced_eigenvalue`
757 /// is added to prevent barely-penalized directions from causing pathological
758 /// non-Gaussianity in the posterior. Typical value: `1e-6`. `None` disables.
759 pub penalty_shrinkage_floor: Option<f64>,
760 /// When set, the penalties have Kronecker (tensor-product) structure.
761 /// The reparameterization engine will use factored Qs = U_1 ⊗ ... ⊗ U_d
762 /// instead of eigendecomposing the full p×p balanced penalty.
763 pub kronecker_factored: Option<&'a gam_terms::basis::KroneckerFactoredBasis>,
764}
765
766/// P-IRLS solver that follows mgcv's architecture exactly
767///
768/// This function implements the complete algorithm from mgcv's gam.fit3 function
769/// for fitting a GAM model with a fixed set of smoothing parameters:
770///
771/// - Perform stable reparameterization ONCE at the beginning (mgcv's gam.reparam)
772/// - Transform the design matrix into this stable basis
773/// - Extract a single penalty square root from the transformed penalty
774/// - Run the P-IRLS loop entirely in the transformed basis
775/// - Transform the coefficients back to the original basis only when returning
776/// - Reuse a cached balanced penalty root when available to avoid repeated eigendecompositions
777///
778/// This architecture ensures optimal numerical stability throughout the entire
779/// fitting process by working in a well-conditioned parameter space.
780pub fn fit_model_for_fixed_rho<'a, X: Into<DesignMatrix> + Clone>(
781 rho: LogSmoothingParamsView<'_>,
782 problem: PirlsProblem<'a, X>,
783 penalty: PenaltyConfig<'_>,
784 config: &PirlsConfig,
785 warm_start_beta: Option<&Coefficients>,
786) -> Result<(PirlsResult, WorkingModelPirlsResult), EstimationError> {
787 fit_model_for_fixed_rho_with_adaptive_kkt(
788 rho,
789 problem,
790 penalty,
791 config,
792 warm_start_beta,
793 None,
794 false,
795 None,
796 )
797}
798
799/// `refine_dispersion_at_converged_eta`: when `true`, after the inner P-IRLS
800/// solve converges, re-estimate the family's estimated dispersion nuisance — the
801/// Gamma shape ν = 1/φ or the Beta precision φ — at the *converged* linear
802/// predictor and iterate the (β, dispersion) pair to its joint fixed point at the
803/// current λ (see the in-body comments at each refresh loop). This is ON only for
804/// the single final, reported fit at the REML-selected λ (#678 for Gamma, #769
805/// for Beta). It is deliberately OFF for every REML cost / sigma-point evaluation:
806/// re-profiling the dispersion against each trial λ's converged residuals would
807/// couple the scale to the smoothing parameter (a flat over-smoothed μ inflates
808/// the deviance ⇒ a smaller effective precision ⇒ a smaller `deviance/(2φ)` REML
809/// term), perversely rewarding over-smoothing and biasing λ selection. mgcv
810/// likewise estimates the scale at the converged fit, not inside the λ search.
811///
812/// The Gamma and Beta cases differ in what the re-solve buys. For Gamma the shape
813/// is a pure nuisance — β̂ is essentially scale-free — so the re-solve only keeps
814/// the reported dispersion and SEs self-consistent. For Beta the precision φ
815/// enters the *mean* score through the digamma terms
816/// `μ*ᵢ = ψ(μᵢφ) − ψ((1−μᵢ)φ)`, so a φ measured at the cold null predictor
817/// (μ ≈ 0.5) attenuates every slope toward zero; here the fixed point is
818/// load-bearing — it is what recovers the correct mean coefficients (the betareg
819/// alternating mean-fit ↔ φ-estimate scheme).
820pub(crate) fn fit_model_for_fixed_rho_with_adaptive_kkt<'a, X: Into<DesignMatrix> + Clone>(
821 rho: LogSmoothingParamsView<'_>,
822 problem: PirlsProblem<'a, X>,
823 penalty: PenaltyConfig<'_>,
824 config: &PirlsConfig,
825 warm_start_beta: Option<&Coefficients>,
826 adaptive_kkt_tolerance: Option<AdaptiveKktTolerance>,
827 refine_dispersion_at_converged_eta: bool,
828 // Shared invariant row carrier for a Gaussian value-only evaluation.
829 //
830 // `Some` requests sufficient-statistic-only result synthesis: beta,
831 // deviance, gradient, and curvature remain exact, while observation-scale
832 // fields share these placeholders instead of recomputing `X beta`.
833 // Full gradients and accepted fits always pass `None`.
834 cost_only_gaussian_rows: Option<&Arc<GaussianFrozenRows>>,
835) -> Result<(PirlsResult, WorkingModelPirlsResult), EstimationError> {
836 let PirlsProblem {
837 x,
838 offset,
839 y,
840 priorweights,
841 covariate_se,
842 gaussian_fixed_cache,
843 glm_first_step_gram,
844 } = problem;
845 let quadctx = crate::quadrature::QuadratureContext::new();
846 let lambdas = exact_lambdas_from_rho(rho);
847 let lambdas_slice = lambdas.as_slice_memory_order().ok_or_else(|| {
848 EstimationError::InvalidInput("non-contiguous lambda storage".to_string())
849 })?;
850
851 let likelihood = &config.likelihood;
852 // Resolve family and scalar ownership once at the fit boundary. This makes
853 // malformed family/metadata pairs fail before either the CPU or GPU path
854 // can interpret an absent scalar as a unit value.
855 let resolved_likelihood_scale = likelihood
856 .resolved_scale()
857 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
858 let link_function = config.link_function();
859
860 use gam_terms::construction::{
861 EngineDims, create_balanced_penalty_root_from_canonical,
862 stable_reparameterization_engine_canonical,
863 };
864
865 let eb_cow: Cow<'_, Array2<f64>> = if let Some(precomputed) = penalty.balanced_penalty_root {
866 Cow::Borrowed(precomputed)
867 } else {
868 Cow::Owned(create_balanced_penalty_root_from_canonical(
869 penalty.canonical_penalties,
870 penalty.p,
871 )?)
872 };
873 let eb: &Array2<f64> = eb_cow.as_ref();
874
875 // Build a cheap weighted penalty sum for the sparse-native decision
876 // WITHOUT running the expensive eigendecomposition engine.
877 // The full reparameterization is deferred until we know which path we need.
878 let cheap_s_lambda: Option<Array2<f64>> = if penalty.kronecker_factored.is_none() {
879 let mut s = Array2::<f64>::zeros((penalty.p, penalty.p));
880 for (k, cp) in penalty.canonical_penalties.iter().enumerate() {
881 let lam = lambdas_slice.get(k).copied().unwrap_or(0.0);
882 if lam != 0.0 {
883 cp.accumulate_weighted(&mut s, lam);
884 }
885 }
886 Some(s)
887 } else {
888 None
889 };
890 let kronecker_runtime = if let Some(kron) = penalty.kronecker_factored {
891 // The marginal eigensystems and reparameterized marginals depend only on
892 // the fixed marginal designs/penalties, not on λ = exp(ρ). Memoize them
893 // once per fit so each outer REML iterate reuses the eigendecomposition
894 // instead of recomputing `eigh()` + `B_k·U_k` every call; only the cheap
895 // λ-grid logdet/derivative sweep is redone here. Bit-identical to the
896 // unmemoized engine.
897 let invariant = kron.invariant_structure()?;
898 let kron_result =
899 gam_terms::construction::kronecker_reparameterization_engine_with_invariant(
900 invariant.as_ref(),
901 &kron.marginal_dims,
902 lambdas_slice,
903 kron.has_double_penalty,
904 penalty.penalty_shrinkage_floor,
905 )?;
906 let transform = Arc::new(KroneckerQsTransform::new(&kron_result));
907 let penalty_diag = build_diagonal_penalty_from_kronecker(&kron_result, lambdas_slice);
908 Some((kron_result, transform, penalty_diag))
909 } else {
910 None
911 };
912 // Constraint transformation is deferred until after the sparse-native
913 // decision, because the dense reparameterization engine (which provides Qs)
914 // is now run lazily. Kronecker constraints can be built eagerly since
915 // the Kronecker transform is already available.
916 let kronecker_constraints = if let Some((_, transform, _)) = kronecker_runtime.as_ref() {
917 let tb = build_transformed_lower_bound_constraints_with_transform(
918 &WorkingReparamTransform::Kronecker(Arc::clone(transform)),
919 penalty.coefficient_lower_bounds,
920 );
921 let tl = build_transformed_linear_constraints_with_transform(
922 &WorkingReparamTransform::Kronecker(Arc::clone(transform)),
923 penalty.linear_constraints_original,
924 );
925 Some(merge_linear_constraints(tb, tl))
926 } else {
927 None
928 };
929
930 let x_original: DesignMatrix = x.into();
931 // Auto-detect sparse structure in dense designs so the sparse-native path
932 // can engage for structurally sparse models that happen to be stored dense.
933 //
934 // A Gaussian value-only probe already owns the exact dense coefficient
935 // statistics consumed by its solve. Scanning all design rows here to
936 // rediscover a sparse representation cannot change that solve and would
937 // make every rho candidate O(n) before the sufficient-statistic lane even
938 // begins (#2435).
939 let x_original = if cost_only_gaussian_rows.is_some() {
940 x_original
941 } else {
942 let auto_sparse = x_original
943 .as_dense()
944 .and_then(|dense| sparse_from_denseview(dense.view()));
945 auto_sparse.unwrap_or(x_original)
946 };
947 let ebrows = eb.nrows();
948 let erows = if let Some((_, _, penalty_diag)) = kronecker_runtime.as_ref() {
949 penalty_diag.rank()
950 } else {
951 // Compute penalty root rank cheaply from canonical penalties.
952 penalty
953 .canonical_penalties
954 .iter()
955 .map(|cp| cp.rank())
956 .sum::<usize>()
957 };
958 // A value-only Gaussian probe is already represented completely by its
959 // coefficient-space sufficient statistics and shared frozen row carrier.
960 // It exits through the exact zero-iteration branch below, so constructing
961 // the general workspace with n rows would allocate five length-n scratch
962 // vectors that no consumer reads (#2435). Full gradients, final fits, and
963 // every iterative family retain the ordinary observation workspace.
964 let mut workspace = if cost_only_gaussian_rows.is_some() {
965 PirlsWorkspace::coefficient_only(x_original.ncols())
966 } else {
967 PirlsWorkspace::new(x_original.nrows(), x_original.ncols(), ebrows, erows)
968 };
969 let solver_decision = if cost_only_gaussian_rows.is_some() {
970 SparsePirlsDecision {
971 path: PirlsLinearSolvePath::DenseTransformed,
972 reason: "gaussian_sufficient_statistics",
973 p: x_original.ncols(),
974 nnz_x: 0,
975 nnz_xtwx_symbolic: None,
976 nnz_s_lambda: 0,
977 nnz_h_est: None,
978 density_h_est: None,
979 }
980 } else if let Some((_, _, _)) = kronecker_runtime.as_ref() {
981 SparsePirlsDecision {
982 path: PirlsLinearSolvePath::DenseTransformed,
983 reason: "kronecker_runtime",
984 p: x_original.ncols(),
985 nnz_x: 0,
986 nnz_xtwx_symbolic: None,
987 nnz_s_lambda: 0,
988 nnz_h_est: None,
989 density_h_est: None,
990 }
991 } else {
992 should_use_sparse_native_pirls(
993 &mut workspace,
994 &x_original,
995 cheap_s_lambda
996 .as_ref()
997 .expect("cheap_s_lambda should be present outside Kronecker path"),
998 penalty.coefficient_lower_bounds,
999 penalty.linear_constraints_original,
1000 )
1001 };
1002 solver_decision.log_once();
1003
1004 let use_sparse_native = matches!(solver_decision.path, PirlsLinearSolvePath::SparseNative);
1005
1006 // Run the eigendecomposition engine for the dense-transformed path. The
1007 // sparse-native path also needs it, but only to obtain a penalty that is
1008 // *consistent with the REML penalty log-determinant it reports* — see the
1009 // sparse-native `reparam` below. The dense path keeps `qs ≠ I`; the
1010 // sparse-native path discards `qs` (identity coords) and reuses only the
1011 // shrinkage-folded `s_transformed`/`e_transformed`.
1012 let dense_reparam_result = if !use_sparse_native && penalty.kronecker_factored.is_none() {
1013 Some(stable_reparameterization_engine_canonical(
1014 penalty.canonical_penalties,
1015 lambdas_slice,
1016 EngineDims::new(penalty.p, penalty.canonical_penalties.len()),
1017 penalty.reparam_invariant,
1018 penalty.penalty_shrinkage_floor,
1019 )?)
1020 } else {
1021 None
1022 };
1023 // Sparse-native reparam result, in identity (original) coordinates with the
1024 // penalty shrinkage floor folded in. This MUST drive the inner penalized
1025 // solve too: when `penalty_shrinkage_floor` is active (default `Some(1e-6)`)
1026 // the dense engine adds `shrinkage·P_range` to every penalized range
1027 // direction of `S_λ` and rebuilds `s_transformed = EᵀE` from the floored
1028 // roots, so `base.log_det` (the REML penalty pseudo-logdet) is the
1029 // determinant of `S_λ + shrinkage·P_range`, NOT of the bare `S_λ`. Building
1030 // the inner Hessian from an UN-shrunk `S_λ` (the previous behaviour, via the
1031 // `cheap_s_lambda` row-sum) while reporting the shrunk `log_det` made the
1032 // sparse-native REML surface internally inconsistent — the penalty-logdet
1033 // term and the inner H / EDF / β̂ lived on different penalties — which biased
1034 // λ-selection relative to the dense and Kronecker backends for the SAME
1035 // model (the #1266 cross-backend divergence class). Reusing the engine's
1036 // shrinkage-folded penalty here makes all three backends solve the same
1037 // penalized objective.
1038 let sparse_native_reparam = if use_sparse_native && penalty.kronecker_factored.is_none() {
1039 let base = stable_reparameterization_engine_canonical(
1040 penalty.canonical_penalties,
1041 lambdas_slice,
1042 EngineDims::new(penalty.p, penalty.canonical_penalties.len()),
1043 penalty.reparam_invariant,
1044 penalty.penalty_shrinkage_floor,
1045 )?;
1046 Some(build_sparse_native_reparam_result(
1047 base,
1048 penalty.canonical_penalties,
1049 lambdas_slice,
1050 penalty.p,
1051 ))
1052 } else {
1053 None
1054 };
1055 let qs_arc = dense_reparam_result
1056 .as_ref()
1057 .map(|reparam_result| Arc::new(reparam_result.qs.clone()));
1058 let transform_active = if let Some((_, transform, _)) = kronecker_runtime.as_ref() {
1059 Some(WorkingReparamTransform::Kronecker(Arc::clone(transform)))
1060 } else if use_sparse_native {
1061 None
1062 } else {
1063 Some(WorkingReparamTransform::Dense(Arc::clone(
1064 qs_arc
1065 .as_ref()
1066 .expect("dense Qs should exist for non-Kronecker transformed path"),
1067 )))
1068 };
1069 let mut penalty_active = if let Some((_, _, penalty_diag)) = kronecker_runtime.as_ref() {
1070 penalty_diag.clone()
1071 } else if use_sparse_native {
1072 // Sparse-native inner penalty in original (identity) coordinates. Use
1073 // the shrinkage-folded `s_transformed`/`e_transformed` from
1074 // `sparse_native_reparam` so the inner penalized Hessian
1075 // `H = XᵀWX + S` matches the penalty whose log-determinant the REML
1076 // criterion reports for this fit (`base.log_det`). Falling back to the
1077 // bare lambda-weighted sum here (the prior behaviour) omitted the
1078 // `penalty_shrinkage_floor` ridge and desynced the inner solve from the
1079 // REML logdet, biasing λ-selection vs the dense/Kronecker backends.
1080 let sparse_reparam = sparse_native_reparam
1081 .as_ref()
1082 .expect("sparse_native_reparam should be present for sparse-native path");
1083 PirlsPenalty::Dense {
1084 s_transformed: sparse_reparam.s_transformed.clone(),
1085 e_transformed: sparse_reparam.e_transformed.clone(),
1086 linear_shift: Array1::zeros(penalty.p),
1087 constant_shift: 0.0,
1088 prior_mean_target: Array1::zeros(penalty.p),
1089 }
1090 } else {
1091 let dense = dense_reparam_result
1092 .as_ref()
1093 .expect("dense reparam result should be present outside Kronecker path");
1094 PirlsPenalty::Dense {
1095 s_transformed: dense.s_transformed.clone(),
1096 e_transformed: dense.e_transformed.clone(),
1097 linear_shift: Array1::zeros(penalty.p),
1098 constant_shift: 0.0,
1099 prior_mean_target: Array1::zeros(penalty.p),
1100 }
1101 };
1102 let (shift_original, shift_constant) =
1103 canonical_prior_shift(penalty.canonical_penalties, lambdas_slice, penalty.p);
1104 let shift_active = transform_active
1105 .as_ref()
1106 .map(|transform| transform.apply_transpose(&shift_original))
1107 .unwrap_or(shift_original);
1108 let prior_mean_original =
1109 canonical_prior_mean_aggregate(penalty.canonical_penalties, penalty.p);
1110 let prior_mean_active = transform_active
1111 .as_ref()
1112 .map(|transform| transform.apply_transpose(&prior_mean_original))
1113 .unwrap_or(prior_mean_original);
1114 attach_penalty_shift(
1115 &mut penalty_active,
1116 shift_active,
1117 shift_constant,
1118 prior_mean_active,
1119 );
1120 // Build transformed constraints now that dense_reparam_result is available.
1121 let linear_constraints = if let Some(kc) = kronecker_constraints {
1122 kc
1123 } else if let Some(reparam) = dense_reparam_result.as_ref() {
1124 let tb = build_transformed_lower_bound_constraints(
1125 &reparam.qs,
1126 penalty.coefficient_lower_bounds,
1127 );
1128 let tl =
1129 build_transformed_linear_constraints(&reparam.qs, penalty.linear_constraints_original);
1130 merge_linear_constraints(tb, tl)
1131 } else {
1132 // Sparse-native without dense reparam: constraints stay in original
1133 // coordinates (identity Qs). Use an identity matrix of appropriate size.
1134 let p = penalty.p;
1135 let qs_identity = Array2::<f64>::eye(p);
1136 let tb = build_transformed_lower_bound_constraints(
1137 &qs_identity,
1138 penalty.coefficient_lower_bounds,
1139 );
1140 let tl =
1141 build_transformed_linear_constraints(&qs_identity, penalty.linear_constraints_original);
1142 merge_linear_constraints(tb, tl)
1143 };
1144
1145 let coordinate_frame = if use_sparse_native {
1146 PirlsCoordinateFrame::OriginalSparseNative
1147 } else {
1148 PirlsCoordinateFrame::TransformedQs
1149 };
1150 let materialize_final_reparam_result = || -> Result<ReparamResult, EstimationError> {
1151 if let Some((kron_result, _, _)) = kronecker_runtime.as_ref() {
1152 let rs_list: Vec<Array2<f64>> = penalty
1153 .canonical_penalties
1154 .iter()
1155 .map(|cp| cp.full_width_root())
1156 .collect();
1157 kron_result.materialize_dense_artifact_result(&rs_list, lambdas_slice, penalty.p)
1158 } else if use_sparse_native {
1159 // Sparse-native path: reuse the engine result already computed for
1160 // `penalty_active` (with the shrinkage floor folded in and mapped to
1161 // identity coordinates). This is both correct — the REML
1162 // log-determinant now matches the penalty the inner solve used — and
1163 // cheaper, since the eigendecomposition is no longer run twice.
1164 Ok(sparse_native_reparam
1165 .as_ref()
1166 .expect("sparse_native_reparam should be present for sparse-native path")
1167 .clone())
1168 } else {
1169 Ok(dense_reparam_result
1170 .as_ref()
1171 .expect("dense reparam result should be present outside Kronecker path")
1172 .clone())
1173 }
1174 };
1175
1176 // Stage 3.3-GI: GPU exact PLS dispatch — see pirls_host_dispatch::try_gaussian_pls_gpu.
1177 if let Some(result) = try_gaussian_pls_gpu(
1178 link_function,
1179 config,
1180 penalty.coefficient_lower_bounds,
1181 penalty.linear_constraints_original,
1182 gaussian_fixed_cache,
1183 &penalty_active,
1184 &qs_arc,
1185 &x_original,
1186 use_sparse_native,
1187 penalty.p,
1188 || materialize_final_reparam_result(),
1189 y,
1190 priorweights,
1191 offset,
1192 coordinate_frame,
1193 &linear_constraints,
1194 ) {
1195 return result;
1196 }
1197
1198 if matches!(link_function, LinkFunction::Identity) && linear_constraints.is_none() {
1199 // Gaussian-Identity zero-iteration exact solve. The unconstrained
1200 // penalized least-squares system is linear, so for an identity link a
1201 // single solve is the exact minimizer and no PIRLS iteration is needed.
1202 //
1203 // This shortcut is only valid in the *unconstrained* convex program.
1204 // When shape/box/linear inequality constraints are present (e.g. a
1205 // `shape=monotone_increasing` smooth, whose cumulative-sum box-reparam
1206 // bounds `γ_j ≥ 0` are folded into `linear_constraints` above), the
1207 // minimizer is the solution of an inequality-constrained QP, not the
1208 // plain normal-equations solve. Taking this branch then returns the
1209 // unconstrained β, which generically violates the constraints and is
1210 // rejected by the REML startup KKT gate (`enforce_constraint_kkt`),
1211 // aborting the whole fit. Gating on `linear_constraints.is_none()`
1212 // routes every constrained Identity fit to the iterative loop below,
1213 // which builds a feasible initial point and solves the exact QP via
1214 // the active-set solver — mirroring the gate already enforced on the
1215 // GPU Gaussian-PLS path in `try_gaussian_pls_gpu`.
1216 //
1217 // Apply the Gaussian-Identity fixed-data cache only when every
1218 // precondition for the short-circuit's exact reuse holds: the family
1219 // really is Gaussian (z = y), there is no Firth bias-reduction term,
1220 // no coefficient lower bounds, and no linear inequality constraints
1221 // — anything that would change the right-hand side or the system
1222 // beyond the additive penalty would invalidate the cache.
1223 let cache_eligible = gaussian_fixed_cache.is_some()
1224 && likelihood.spec.is_gaussian_identity()
1225 && !config.firth_bias_reduction
1226 && penalty.coefficient_lower_bounds.is_none()
1227 && penalty.linear_constraints_original.is_none();
1228 let cache_for_solve = if cache_eligible {
1229 gaussian_fixed_cache
1230 } else {
1231 None
1232 };
1233 let (pls_result, _) = solve_penalized_least_squares_implicit(
1234 &x_original,
1235 transform_active.as_ref(),
1236 y,
1237 priorweights,
1238 offset,
1239 &penalty_active,
1240 &mut workspace,
1241 y,
1242 link_function,
1243 cache_for_solve,
1244 )?;
1245
1246 let beta_transformed = pls_result.beta;
1247 let penalized_hessian = pls_result.penalized_hessian;
1248 let edf = pls_result.edf;
1249 let baseridge = pls_result.ridge_used;
1250
1251 // eta = offset + X Qs beta (composed, no materialization) unless a
1252 // design-moving ψ tensor cache explicitly says the surface rows are a
1253 // stale reference. In that lane the Gaussian objective and gradient are
1254 // fully determined by (G, r, y'Wy), so applying `x_original` would both
1255 // reintroduce per-trial row work and evaluate the wrong ψ.
1256 let qbeta = transform_active
1257 .as_ref()
1258 .map(|transform| transform.apply(beta_transformed.as_ref()))
1259 .unwrap_or_else(|| beta_transformed.as_ref().clone());
1260 let sufficient_only_row_cache = cache_for_solve.filter(|cache| {
1261 cache.row_prediction_is_stale || cost_only_gaussian_rows.is_some()
1262 });
1263
1264 // #1868: all length-`n` row arrays of the zero-iteration synthesis,
1265 // collected in one place so the skip path can SHARE them O(1) from the
1266 // once-built frozen bundle (zero row touches) while the exact path builds
1267 // them owned and moves them into the shared `ArcArray1` representation
1268 // via `.into_shared()` (O(1), no element copy).
1269 struct ZeroIterRows {
1270 final_offset: ArcArray1<f64>,
1271 final_eta: ArcArray1<f64>,
1272 finalmu: ArcArray1<f64>,
1273 finalz: ArcArray1<f64>,
1274 finalweights: ArcArray1<f64>,
1275 solve_dmu_deta: ArcArray1<f64>,
1276 solve_d2mu_deta2: ArcArray1<f64>,
1277 solve_d3mu_deta3: ArcArray1<f64>,
1278 solve_c_array: ArcArray1<f64>,
1279 solve_d_array: ArcArray1<f64>,
1280 /// Working-state η. Empty on the skip path (the stale rows are never
1281 /// read on the n-free κ criterion path, so it is not materialised —
1282 /// keeping the callback O(1)); the freshly-realised η on the exact
1283 /// path.
1284 working_eta: LinearPredictor,
1285 gradient_data: Array1<f64>,
1286 deviance: f64,
1287 log_likelihood: f64,
1288 max_abs_eta: f64,
1289 }
1290
1291 let rows = if let Some(cache) = sufficient_only_row_cache {
1292 // #1868 FAST PATH: the criterion, gradient and inner solve are served
1293 // entirely from k-space Gram sufficient statistics; the length-`n`
1294 // row arrays are trial-invariant placeholders (η≡μ≡offset, z≡y,
1295 // w≡priorweights, constant Gaussian working-weight derivatives). When
1296 // the producer attached the once-built frozen bundle we clone its
1297 // `ArcArray1` handles (O(1), zero element touches) instead of
1298 // re-materialising ~16·n elements per κ callback — the #1868 fix.
1299 let mut grad_orig = cache.xtwx_orig.dot(&qbeta);
1300 grad_orig -= &cache.xtwy_orig;
1301 let gradient_data = transform_active
1302 .as_ref()
1303 .map(|transform| transform.apply_transpose(&grad_orig))
1304 .unwrap_or(grad_orig);
1305 let weighted_rss = (cache.centered_weighted_y_sq - 2.0 * qbeta.dot(&cache.xtwy_orig)
1306 + qbeta.dot(&cache.xtwx_orig.dot(&qbeta)))
1307 .max(0.0);
1308 match resolved_likelihood_scale {
1309 ResolvedLikelihoodScale::ProfiledGaussian
1310 | ResolvedLikelihoodScale::FixedGaussian { .. } => {}
1311 other => {
1312 return Err(EstimationError::InvalidInput(format!(
1313 "Gaussian identity cache received non-Gaussian resolved scale {other:?}"
1314 )));
1315 }
1316 }
1317 // Conventional Gaussian deviance is raw weighted RSS for both a
1318 // profiled and a fixed dispersion. Scale enters the fixed
1319 // likelihood kernel and working curvature, never this reporting
1320 // statistic.
1321 let deviance = weighted_rss;
1322
1323 if let Some(bundle) = cost_only_gaussian_rows.or(cache.frozen_rows.as_ref()) {
1324 // Zero length-`n` touches: every row array is an O(1) Arc clone
1325 // of the shared frozen bundle (η≡μ≡offset via `bundle.eta`).
1326 ZeroIterRows {
1327 final_offset: bundle.eta.clone(),
1328 final_eta: bundle.eta.clone(),
1329 finalmu: bundle.eta.clone(),
1330 finalz: bundle.z.clone(),
1331 finalweights: bundle.weights.clone(),
1332 solve_dmu_deta: bundle.solve_dmu_deta.clone(),
1333 solve_d2mu_deta2: bundle.solve_d2mu_deta2.clone(),
1334 solve_d3mu_deta3: bundle.solve_d3mu_deta3.clone(),
1335 solve_c_array: bundle.solve_c_array.clone(),
1336 solve_d_array: bundle.solve_d_array.clone(),
1337 working_eta: LinearPredictor::new(Array1::zeros(0)),
1338 gradient_data,
1339 deviance,
1340 log_likelihood: bundle.log_likelihood,
1341 max_abs_eta: bundle.max_abs_eta,
1342 }
1343 } else {
1344 // No bundle attached (producer could not build it): fall back to
1345 // the correct-but-O(n) re-materialisation so the fit is never
1346 // wrong. Counted so the deterministic gate still sees this work.
1347 let n_rows = offset.len();
1348 record_nfree_skip_row_touches(11 * n_rows);
1349 let final_eta = offset.to_owned();
1350 let finalmu = final_eta.clone();
1351 let priorweights_owned = priorweights.to_owned();
1352 let (c, d, dmu_deta, d2mu_deta2, d3mu_deta3) =
1353 computeworkingweight_derivatives_from_eta(
1354 &config.likelihood,
1355 &config.link_kind,
1356 &final_eta,
1357 priorweights_owned.view(),
1358 )?;
1359 let log_likelihood = pirls_data_log_kernel_from_eta(
1360 y,
1361 &final_eta,
1362 likelihood,
1363 &config.link_kind,
1364 priorweights,
1365 deviance,
1366 )?;
1367 let max_abs_eta = inf_norm(finalmu.iter().copied());
1368 ZeroIterRows {
1369 final_offset: offset.to_owned().into_shared(),
1370 final_eta: final_eta.into_shared(),
1371 finalmu: finalmu.into_shared(),
1372 finalz: y.to_owned().into_shared(),
1373 finalweights: priorweights_owned.into_shared(),
1374 solve_dmu_deta: dmu_deta.into_shared(),
1375 solve_d2mu_deta2: d2mu_deta2.into_shared(),
1376 solve_d3mu_deta3: d3mu_deta3.into_shared(),
1377 solve_c_array: c.into_shared(),
1378 solve_d_array: d.into_shared(),
1379 working_eta: LinearPredictor::new(Array1::zeros(0)),
1380 gradient_data,
1381 deviance,
1382 log_likelihood,
1383 max_abs_eta,
1384 }
1385 }
1386 } else {
1387 // EXACT path: rows are freshly realised from the (non-stale) design.
1388 // Legitimately O(n) — this is the one-off final assembly / a
1389 // non-tensor trial, not a per-callback n-free skip.
1390 let priorweights_owned = priorweights.to_owned();
1391 let mut eta = offset.to_owned();
1392 eta += &x_original.apply(&qbeta);
1393 let final_eta = eta.clone();
1394 let finalmu = eta;
1395
1396 let mut weighted_residual = finalmu.clone();
1397 weighted_residual -= &y;
1398 weighted_residual *= &priorweights_owned;
1399 // gradient = Qs^T X^T (w * residual) (composed)
1400 let xt_wr = x_original.apply_transpose(&weighted_residual);
1401 let gradient_data = transform_active
1402 .as_ref()
1403 .map(|transform| transform.apply_transpose(&xt_wr))
1404 .unwrap_or(xt_wr);
1405 let deviance = calculate_deviance_from_eta(
1406 y,
1407 &final_eta,
1408 likelihood,
1409 &config.link_kind,
1410 priorweights,
1411 )?;
1412 let log_likelihood = pirls_data_log_kernel_from_eta(
1413 y,
1414 &final_eta,
1415 likelihood,
1416 &config.link_kind,
1417 priorweights,
1418 deviance,
1419 )?;
1420 let max_abs_eta = inf_norm(finalmu.iter().copied());
1421 let (c, d, dmu_deta, d2mu_deta2, d3mu_deta3) =
1422 computeworkingweight_derivatives_from_eta(
1423 &config.likelihood,
1424 &config.link_kind,
1425 &final_eta,
1426 priorweights_owned.view(),
1427 )?;
1428 ZeroIterRows {
1429 final_offset: offset.to_owned().into_shared(),
1430 working_eta: LinearPredictor::new(finalmu.clone()),
1431 final_eta: final_eta.into_shared(),
1432 finalmu: finalmu.into_shared(),
1433 finalz: y.to_owned().into_shared(),
1434 finalweights: priorweights_owned.into_shared(),
1435 solve_dmu_deta: dmu_deta.into_shared(),
1436 solve_d2mu_deta2: d2mu_deta2.into_shared(),
1437 solve_d3mu_deta3: d3mu_deta3.into_shared(),
1438 solve_c_array: c.into_shared(),
1439 solve_d_array: d.into_shared(),
1440 gradient_data,
1441 deviance,
1442 log_likelihood,
1443 max_abs_eta,
1444 }
1445 };
1446 let ZeroIterRows {
1447 final_offset,
1448 final_eta,
1449 finalmu,
1450 finalz,
1451 finalweights,
1452 solve_dmu_deta,
1453 solve_d2mu_deta2,
1454 solve_d3mu_deta3,
1455 solve_c_array,
1456 solve_d_array,
1457 working_eta,
1458 gradient_data,
1459 deviance,
1460 log_likelihood,
1461 max_abs_eta,
1462 } = rows;
1463 let score_norm = array1_l2_norm(&gradient_data);
1464 let s_beta = penalty_active.shifted_gradient(beta_transformed.as_ref());
1465 let s_beta_norm = array1_l2_norm(&s_beta);
1466 let mut gradient = gradient_data;
1467 gradient += &s_beta;
1468 let mut penalty_term = penalty_active.shifted_quadratic(beta_transformed.as_ref());
1469 let ridge_used = baseridge;
1470 let stabilizedhessian = if ridge_used > 0.0 {
1471 penalized_hessian
1472 .addridge(ridge_used)
1473 .map_err(|e| EstimationError::InvalidInput(format!("ridge addition failed: {e}")))?
1474 } else {
1475 penalized_hessian.clone()
1476 };
1477 let mut ridge_grad_norm = 0.0;
1478 if ridge_used > 0.0 {
1479 let ridge_penalty =
1480 ridge_used * beta_transformed.as_ref().dot(beta_transformed.as_ref());
1481 penalty_term += ridge_penalty;
1482 gradient += &beta_transformed.as_ref().mapv(|v| ridge_used * v);
1483 ridge_grad_norm = ridge_used * array1_l2_norm(beta_transformed.as_ref());
1484 }
1485
1486 let gradient_norm = array1_l2_norm(&gradient);
1487 let working_state = WorkingState {
1488 eta: working_eta,
1489 gradient: gradient.clone(),
1490 hessian: penalized_hessian.clone(),
1491
1492 log_likelihood,
1493 deviance,
1494 penalty_term,
1495 firth: FirthDiagnostics::Inactive,
1496 ridge_used,
1497 hessian_curvature: HessianCurvatureKind::Fisher,
1498 gradient_natural_scale: score_norm + s_beta_norm + ridge_grad_norm,
1499 };
1500
1501 let zero_iter_penalized = deviance + penalty_term;
1502 let working_summary = WorkingModelPirlsResult {
1503 beta: beta_transformed.clone(),
1504 state: working_state,
1505 status: PirlsStatus::Converged,
1506 iterations: 1,
1507 lastgradient_norm: gradient_norm,
1508 last_deviance_change: 0.0,
1509 last_step_size: 1.0,
1510 last_step_halving: 0,
1511 max_abs_eta,
1512 constraint_kkt: linear_constraints.as_ref().map(|lin| {
1513 compute_constraint_kkt_diagnostics(beta_transformed.as_ref(), &gradient, lin)
1514 }),
1515 min_penalized_deviance: if zero_iter_penalized.is_finite() {
1516 zero_iter_penalized
1517 } else {
1518 f64::INFINITY
1519 },
1520 // Zero-iteration synthesis: no LM damping was exercised, so
1521 // hand the next solve the cold default.
1522 final_lm_lambda: 1e-6,
1523 // Zero-iteration synthesis: no LM gain ratio was measured.
1524 final_accept_rho: None,
1525 // Zero-iteration synthesis assembles the Hessian with prior
1526 // weights only; no observed-information re-evaluation has
1527 // happened. Label honestly as a Fisher-type surrogate so
1528 // outer Laplace consumers see the truth.
1529 exported_laplace_curvature: ExportedLaplaceCurvature::ExpectedInformationSurrogate,
1530 };
1531
1532 // #1868: `solve_*`/`final_*` row arrays now come from the row synthesis
1533 // above (shared O(1) from the frozen bundle on the skip path); the exact
1534 // per-callback `computeworkingweight_derivatives_from_eta` re-computation
1535 // that used to run here is folded into that synthesis.
1536 let reparam_result = materialize_final_reparam_result()?;
1537 let qs_arc_final = Arc::new(reparam_result.qs.clone());
1538 let pirls_result = PirlsResult {
1539 likelihood: config.likelihood.clone(),
1540 beta_transformed,
1541 penalized_hessian_transformed: penalized_hessian,
1542 stabilizedhessian_transformed: stabilizedhessian,
1543 ridge_passport: RidgePassport::scaled_identity(
1544 ridge_used,
1545 RidgePolicy::exact_full_objective(),
1546 )?,
1547 deviance,
1548 edf,
1549 stable_penalty_term: penalty_term,
1550 firth: FirthDiagnostics::Inactive,
1551 finalweights: finalweights.clone(),
1552 final_offset,
1553 final_eta,
1554 finalmu: finalmu.clone(),
1555 solveweights: finalweights,
1556 solveworking_response: finalz,
1557 solvemu: finalmu,
1558 solve_dmu_deta,
1559 solve_d2mu_deta2,
1560 solve_d3mu_deta3,
1561 solve_c_array,
1562 solve_c_nontrivial: false,
1563 solve_d_array,
1564 derivatives_unsupported: false,
1565 status: PirlsStatus::Converged,
1566 iteration: 1,
1567 max_abs_eta,
1568 lastgradient_norm: gradient_norm,
1569 gradient_natural_scale: score_norm + s_beta_norm + ridge_grad_norm,
1570 penalized_gradient_transformed: gradient.clone(),
1571 last_deviance_change: 0.0,
1572 last_step_halving: 0,
1573 hessian_curvature: HessianCurvatureKind::Fisher,
1574 exported_laplace_curvature: working_summary.exported_laplace_curvature.clone(),
1575 final_lm_lambda: working_summary.final_lm_lambda,
1576 final_accept_rho: working_summary.final_accept_rho,
1577 constraint_kkt: working_summary.constraint_kkt.clone(),
1578 linear_constraints_transformed: linear_constraints.clone(),
1579 reparam_result,
1580 x_transformed: make_reparam_operator(&x_original, &qs_arc_final, use_sparse_native),
1581 coordinate_frame,
1582 used_device: false,
1583 cache_compacted: false,
1584 min_penalized_deviance: working_summary.min_penalized_deviance,
1585 };
1586
1587 return Ok((pirls_result, working_summary));
1588 }
1589
1590 let x_original_for_result = x_original.clone();
1591 let mut working_model = GamWorkingModel::new(
1592 None, // No pre-materialized x_transformed: use implicit Qs composition
1593 x_original.clone(),
1594 coordinate_frame,
1595 offset,
1596 y,
1597 priorweights,
1598 penalty_active.clone(),
1599 workspace,
1600 config.likelihood.clone(),
1601 config.link_kind.clone(),
1602 // Inner Firth/Jeffreys activation must agree with the caller-requested
1603 // mode. The REML *outer* analytic derivative assembly only carries the
1604 // Jeffreys score/curvature term when `firth_bias_reduction` is set
1605 // (`reml_robust_jeffreys_link` returns `None` otherwise), so arming the
1606 // inner penalty unconditionally would converge the inner mode to the
1607 // Firth-penalized stationary point while the outer H/u/IFT stayed
1608 // non-Firth — the two would then disagree by exactly the Jeffreys
1609 // contribution (broken τ-τ Hessian-vs-FD and stationarity-cancellation
1610 // identities, #825). Gate on `firth_bias_reduction` so inner and outer
1611 // are the same objective.
1612 config.firth_bias_reduction
1613 && matches!(config.likelihood.spec.response, ResponseFamily::Binomial)
1614 && config.link_kind.has_fisher_weight_jet(),
1615 transform_active.clone(),
1616 quadctx,
1617 // #1111 / #1033 mechanism (c): frozen-W first-Fisher-step XᵀWX in the
1618 // original (conditioned x_fit) frame, served n-free on the first inner
1619 // iteration. Suppressed under Firth bias reduction, which shifts the
1620 // working response per iteration (the installer also gates Firth off).
1621 if config.firth_bias_reduction {
1622 None
1623 } else {
1624 glm_first_step_gram.cloned()
1625 },
1626 );
1627
1628 // Apply integrated (GHQ) likelihood if per-observation SE is provided.
1629 // This is used by the calibrator to coherently account for base prediction uncertainty.
1630 if let Some(se) = covariate_se {
1631 working_model = working_model.with_covariate_se(se.to_owned());
1632 }
1633
1634 let mut beta_guess_original = warm_start_beta
1635 .filter(|beta| beta.len() == penalty.p)
1636 .map(|beta| beta.to_owned())
1637 .unwrap_or_else(|| {
1638 Coefficients::new(default_beta_guess_external(
1639 penalty.p,
1640 link_function,
1641 y,
1642 priorweights,
1643 config.link_kind.mixture_state(),
1644 config.link_kind.sas_state(),
1645 ))
1646 });
1647 if let Some(lb) = penalty.coefficient_lower_bounds {
1648 project_coefficients_to_lower_bounds(&mut beta_guess_original.0, lb);
1649 }
1650 let initial_beta = transform_active
1651 .as_ref()
1652 .map(|transform| transform.apply_transpose(beta_guess_original.as_ref()))
1653 .unwrap_or_else(|| beta_guess_original.as_ref().clone());
1654 let initial_beta = if let Some(constraints) = linear_constraints.as_ref() {
1655 // Worst per-row *scaled* (geometric) slack of the current seed against the
1656 // constraint cone. Negative ⇒ the seed violates a row; ~0 ⇒ the seed sits
1657 // ON the boundary (for a homogeneous convex/concave second-difference
1658 // cone, `β = 0` — the unconstrained Gaussian seed — sits on EVERY row's
1659 // boundary, i.e. the cone vertex). Either way the seed must be pushed
1660 // strictly into the interior before P-IRLS starts.
1661 let mut min_scaled_slack = f64::INFINITY;
1662 for i in 0..constraints.a.nrows() {
1663 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1664 let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
1665 let slack = (constraints.a.row(i).dot(&initial_beta) - constraints.b[i]) * inv;
1666 min_scaled_slack = min_scaled_slack.min(slack);
1667 }
1668 // Push the seed to the nearest STRICTLY-INTERIOR feasible point whenever
1669 // any row is tight or violated. A seed on the cone boundary (most acutely
1670 // the vertex `β = 0`) hands the inner active-set QP an all-rows-active
1671 // working set, where it stalls on a degenerate, non-stationary face — so
1672 // the fit silently diverges (or aborts in release) between a cold and a
1673 // warm warm-start cache (#873). A strictly-interior seed makes the QP's
1674 // initial active set empty; it then adds only the genuinely binding rows
1675 // and converges to the certified constrained optimum regardless of cache
1676 // state. The projection keeps the data-driven curvature of `initial_beta`
1677 // and falls back to the min-norm feasible point only if it cannot certify
1678 // a strictly-interior solution.
1679 //
1680 // The min-norm fallback (`feasible_point_for_linear_constraints`) is only
1681 // used for a NON-homogeneous cone (`b ≠ 0`), where it returns a genuine
1682 // interior-of-the-offset-polyhedron point. For a HOMOGENEOUS shape cone
1683 // (`b ≈ 0` — the convex/concave second-difference rows) that function
1684 // returns the minimum-norm feasible point `β = 0`, which is the cone
1685 // *vertex*: the exact all-rows-tight degenerate seed #873 is about. Taking
1686 // it would silently reintroduce the #873 pathology whenever the strict
1687 // projection rarely fails to certify. So for a homogeneous cone we skip the
1688 // vertex fallback entirely and prefer the data-driven `initial_beta`: it
1689 // violates at most *some* rows (a lower-dimensional, non-degenerate face the
1690 // inner active-set QP can recover from), strictly better than the vertex
1691 // where *every* row is simultaneously tight.
1692 let cone_is_homogeneous = constraints.b.iter().all(|v| v.abs() <= 1e-14);
1693 if min_scaled_slack < active_set::interior_seed_margin() {
1694 let projected =
1695 active_set::project_point_strictly_into_feasible_cone(&initial_beta, constraints)
1696 .or_else(|| {
1697 if cone_is_homogeneous {
1698 None
1699 } else {
1700 active_set::feasible_point_for_linear_constraints(
1701 constraints,
1702 initial_beta.len(),
1703 )
1704 }
1705 });
1706 projected.unwrap_or(initial_beta)
1707 } else {
1708 initial_beta
1709 }
1710 } else {
1711 initial_beta
1712 };
1713 // Inner P-IRLS Firth activation. The inner penalized objective must match
1714 // the objective the REML outer derivatives are assembled against: the outer
1715 // path carries the Jeffreys/Firth score+curvature only when the caller set
1716 // `firth_bias_reduction` (`reml_robust_jeffreys_link` is `None` otherwise),
1717 // so the inner Firth term is armed iff the caller requested it AND the link
1718 // exposes a Fisher-weight jet (#825). Forcing it on unconditionally desynced
1719 // the Firth-penalized inner mode from the non-Firth outer assembly.
1720 let firth_active = config.firth_bias_reduction
1721 && matches!(config.likelihood.spec.response, ResponseFamily::Binomial)
1722 && config.link_kind.has_fisher_weight_jet();
1723 let base_max_step_halving = if firth_active { 60 } else { 30 };
1724 let options = WorkingModelPirlsOptions {
1725 // The Firth-penalized P-IRLS converges at the same iteration count as
1726 // the unpenalized fit — the Jeffreys term is a smooth, bounded addition
1727 // to a Newton system that is already well conditioned (the additional
1728 // per-iteration LM step-halving budget above absorbs the early-iteration
1729 // curvature change). Bumping the outer-iteration cap to mask a
1730 // mis-conditioned step would only hide non-convergence, so the cap stays
1731 // the caller's `max_iterations` and trips as a hard error if exceeded.
1732 max_iterations: config.max_iterations,
1733 convergence_tolerance: config.convergence_tolerance,
1734 adaptive_kkt_tolerance,
1735 // LM step-halving is a per-iteration damping retry budget; it is
1736 // independent of the total outer-iteration cap. Tying the two
1737 // together collapsed step halving to 3 under seed screening (where
1738 // max_iterations is intentionally capped low), turning recoverable
1739 // damping into spurious failures.
1740 max_step_halving: base_max_step_halving,
1741 min_step_size: if firth_active { 1e-12 } else { 1e-10 },
1742 firth_bias_reduction: firth_active,
1743 coefficient_lower_bounds: None,
1744 linear_constraints: linear_constraints.clone(),
1745 initial_lm_lambda: config.initial_lm_lambda,
1746 arrow_schur: config.arrow_schur.clone(),
1747 };
1748
1749 let mut iteration_logger = |info: &WorkingModelIterationInfo| {
1750 log::debug!(
1751 "[PIRLS] iter {:>3} | deviance {:.6e} | |grad| {:.3e} | step {:.3e} (halving {})",
1752 info.iteration,
1753 info.deviance,
1754 info.gradient_norm,
1755 info.step_size,
1756 info.step_halving
1757 );
1758 };
1759
1760 // Stage 3.3 GPU PIRLS-loop dispatch — see pirls_host_dispatch::try_pirls_loop_gpu.
1761 if let Some(result) = try_pirls_loop_gpu(
1762 config,
1763 &penalty_active,
1764 kronecker_runtime.is_none(),
1765 use_sparse_native,
1766 &linear_constraints,
1767 &x_original,
1768 &qs_arc,
1769 penalty.p,
1770 &x_original_for_result,
1771 || materialize_final_reparam_result(),
1772 y,
1773 priorweights,
1774 offset,
1775 &initial_beta,
1776 link_function,
1777 coordinate_frame,
1778 ) {
1779 return result;
1780 }
1781
1782 let mut working_summary = runworking_model_pirls(
1783 &mut working_model,
1784 Coefficients::new(initial_beta),
1785 &options,
1786 &mut iteration_logger,
1787 )?;
1788
1789 // ── Gamma dispersion: re-estimate the shape at the *converged* η (#678) ──
1790 //
1791 // The inner LM solve estimates the Gamma shape ν = 1/φ **once** from the
1792 // warm-start η and freezes it for the rest of the solve (see the
1793 // `gamma_shape_locked` doc on `GamWorkingModel`): holding ν fixed keeps the
1794 // product φ·λ — and hence the penalized argmin β̂ — a stationary LM target,
1795 // so the gain ratio compares one objective. That lock is correct *within* a
1796 // solve, but it pins ν to whatever η the solve started from. When the fit
1797 // cold-starts (the final dedicated fit at the converged ρ passes
1798 // `warm_start_beta = None`, and seed screening starts from a default guess),
1799 // that warm-start η has not yet captured the mean structure; the leftover
1800 // spread of μ inflates the Gamma deviance term `mean[y/μ − ln(y/μ) − 1]` and
1801 // biases ν **down** (φ up) by >2× whenever μ varies appreciably. The mean
1802 // surface still converges (β̂ is essentially scale-free here), but the frozen
1803 // ν that survives into `UnifiedFitResult::dispersion_phi()` — and from there
1804 // into every coefficient SE `Vb = H⁻¹·φ̂`, prediction interval, and
1805 // observation-noise interval — is the early, mean-spread-contaminated value.
1806 //
1807 // Fix: after the solve converges, re-estimate ν at the converged η. If it
1808 // moved, re-solve β (warm-started, ν held fixed at the refreshed value) and
1809 // repeat, driving the pair (β, ν) to their joint fixed point at the current
1810 // λ. At convergence the reported dispersion is the Gamma ML estimate at the
1811 // converged mean (mgcv's post-hoc Pearson/deviance scale), and the final
1812 // working state — `finalweights`, the penalized Hessian, the deviance, μ —
1813 // is rebuilt with that same ν, so `Vb = H⁻¹·φ̂` stays internally consistent.
1814 // Warm-started solves (every REML cost eval) already sit near the converged
1815 // η, so the first refresh check confirms ν and exits without a re-solve; the
1816 // added cost there is a single O(n) shape evaluation.
1817 let gamma_scale = working_model
1818 .likelihood
1819 .resolved_scale()
1820 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1821 if refine_dispersion_at_converged_eta
1822 && matches!(
1823 gamma_scale,
1824 ResolvedLikelihoodScale::Gamma {
1825 estimated: true,
1826 ..
1827 }
1828 )
1829 {
1830 // A few passes suffice: the converged-η shape map is a strong
1831 // contraction (β̂ barely moves once the mean is captured), so cold
1832 // starts settle in 1–2 re-solves and warm starts in zero.
1833 const MAX_SHAPE_REFRESH: usize = 5;
1834 // Relative shape tolerance below which a re-solve cannot move any
1835 // reported quantity meaningfully (far under statistical resolution).
1836 const SHAPE_REFRESH_REL_TOL: f64 = 1e-4;
1837 for refresh_iter in 0..MAX_SHAPE_REFRESH {
1838 let refreshed_shape = super::estimate_gamma_shape_from_eta(
1839 y,
1840 working_summary.state.eta.as_ref(),
1841 priorweights,
1842 )?;
1843 let prior_shape = working_model
1844 .likelihood
1845 .resolved_gamma_shape()
1846 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1847 let rel_change =
1848 (refreshed_shape - prior_shape).abs() / prior_shape.max(f64::MIN_POSITIVE);
1849 // Install the refreshed shape and hold it fixed for any re-solve so
1850 // the LM objective stays stationary (the lock is *re-armed*, not
1851 // released — the seed-from-warm-start branch in `update_with_curvature`
1852 // must not overwrite this deliberately chosen value). Because this
1853 // assignment evaluated the shape at the *current* converged η and no
1854 // re-solve follows it on the exit paths below, the reported shape
1855 // always equals `estimate_gamma_shape_from_eta(final_eta)` — the
1856 // self-consistency invariant the in-module Gamma unit test checks.
1857 working_model.likelihood = working_model
1858 .likelihood
1859 .clone()
1860 .with_gamma_shape(refreshed_shape);
1861 working_model.gamma_shape_locked = true;
1862 if rel_change <= SHAPE_REFRESH_REL_TOL {
1863 // Converged: the working-state buffers (weights, Hessian,
1864 // deviance) already reflect a shape within tolerance of
1865 // `refreshed_shape`, because the only way to reach here without
1866 // a re-solve is that the prior solve's shape already matched the
1867 // converged-η estimate. Nothing left to rebuild.
1868 break;
1869 }
1870 if refresh_iter + 1 == MAX_SHAPE_REFRESH {
1871 // Final allowed pass and the shape is still drifting (a
1872 // pathological non-contraction). Do NOT re-solve: re-solving
1873 // would advance `final_eta` past the η the just-installed shape
1874 // was evaluated at, breaking the stored-shape == estimate(final_eta)
1875 // invariant. Stopping here keeps the reported shape exactly the
1876 // ML estimate at the reported η; the residual weight/φ drift is
1877 // bounded by the last `rel_change` and never worse than the
1878 // pre-fix frozen-warm-start value.
1879 break;
1880 }
1881 // The shape moved: re-solve β at the corrected shape, warm-started
1882 // at the converged β, so the final working state is rebuilt with the
1883 // refreshed ν.
1884 working_summary = runworking_model_pirls(
1885 &mut working_model,
1886 working_summary.beta.clone(),
1887 &options,
1888 &mut iteration_logger,
1889 )?;
1890 }
1891 }
1892
1893 // ── Tweedie dispersion φ: re-estimate at the *converged* η (#771) ─────────
1894 //
1895 // Identical in spirit to the Gamma-shape refresh above: the inner LM solve
1896 // estimates φ **once** from the warm-start η and freezes it (the
1897 // `tweedie_phi_locked` lock), keeping the product φ·λ — and hence β̂ — a
1898 // stationary LM target. φ enters only the working weight `prior·μ^{2−p}/φ`
1899 // and not the working response, so (like the Gamma shape, and unlike the
1900 // Beta precision which couples through the digamma mean score) the mean
1901 // surface is essentially scale-free and β̂ barely moves when φ is corrected.
1902 // But the frozen warm-start φ is the value that survives into
1903 // `FitInference::dispersion` and the covariance `Vb = H⁻¹` (whose √φ scaling
1904 // lives in the weight); at a cold-started η ≈ 0 the Pearson residuals carry
1905 // the *marginal* spread of y, biasing the estimate. Re-estimating at the
1906 // converged η — re-solving β only if φ moved materially — drives (β, φ) to
1907 // their joint fixed point, so the reported φ is the converged-mean Pearson
1908 // estimate and the final weights/Hessian/SE are internally consistent with
1909 // it. Held OFF inside the REML λ search (the flag), φ is refreshed only at
1910 // the reported fit, so it cannot couple to the smoothing parameter.
1911 let tweedie_scale = working_model
1912 .likelihood
1913 .resolved_scale()
1914 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1915 if refine_dispersion_at_converged_eta
1916 && matches!(
1917 tweedie_scale,
1918 ResolvedLikelihoodScale::Tweedie {
1919 estimated: true,
1920 ..
1921 }
1922 )
1923 {
1924 if let ResponseFamily::Tweedie { p } = working_model.likelihood.spec.response {
1925 // The converged-η Pearson map is a strong contraction (β̂ scale-free
1926 // here), so cold starts settle in 1–2 re-solves and warm starts in
1927 // zero.
1928 const MAX_PHI_REFRESH: usize = 5;
1929 // Relative φ tolerance below which a re-solve cannot move any reported
1930 // quantity meaningfully (far under statistical resolution).
1931 const PHI_REFRESH_REL_TOL: f64 = 1e-4;
1932 for refresh_iter in 0..MAX_PHI_REFRESH {
1933 let refreshed_phi = super::estimate_tweedie_phi_from_eta(
1934 y,
1935 working_summary.state.eta.as_ref(),
1936 priorweights,
1937 p,
1938 )?;
1939 let prior_phi = working_model
1940 .likelihood
1941 .resolved_tweedie_phi()
1942 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1943 let rel_change =
1944 (refreshed_phi - prior_phi).abs() / prior_phi.max(f64::MIN_POSITIVE);
1945 // Install the refreshed φ (the scale metadata the working weight
1946 // reads via `fixed_phi()`) and re-arm the lock so a following
1947 // re-solve does not overwrite this converged-η value. Because the
1948 // exit paths below evaluate φ at the *current* η with no following
1949 // re-solve, the reported φ always equals
1950 // `estimate_tweedie_phi_from_eta(final_eta)`.
1951 working_model.likelihood = working_model
1952 .likelihood
1953 .clone()
1954 .with_tweedie_phi(refreshed_phi);
1955 working_model.tweedie_phi_locked = true;
1956 if rel_change <= PHI_REFRESH_REL_TOL {
1957 // Converged: the working state already reflects a φ within
1958 // tolerance of `refreshed_phi`. Nothing left to rebuild.
1959 break;
1960 }
1961 if refresh_iter + 1 == MAX_PHI_REFRESH {
1962 // Final allowed pass and φ is still drifting. Do NOT re-solve:
1963 // re-solving would advance η past the point φ was evaluated at,
1964 // breaking the stored-φ == estimate(final_eta) invariant.
1965 break;
1966 }
1967 // φ moved materially: re-solve β at the corrected φ, warm-started
1968 // at the converged β, so the final working state is rebuilt with
1969 // the refreshed φ.
1970 working_summary = runworking_model_pirls(
1971 &mut working_model,
1972 working_summary.beta.clone(),
1973 &options,
1974 &mut iteration_logger,
1975 )?;
1976 }
1977 }
1978 }
1979
1980 // ── Beta precision φ: re-estimate at the *converged* η and drive (β, φ) to
1981 // their joint fixed point (#769) ──────────────────────────────────────
1982 //
1983 // Like the Gamma shape above, the inner LM solve estimates φ **once** from
1984 // the warm-start η and freezes it for the rest of the solve (the
1985 // `beta_phi_locked` doc on `GamWorkingModel`): holding φ fixed keeps the
1986 // penalized argmin β̂ a stationary LM target so the gain ratio compares one
1987 // objective. But that lock pins φ to whatever η the solve started from, and
1988 // for the final dedicated fit at the converged ρ the warm-start is the cold
1989 // default guess (η ≈ 0, μ ≈ 0.5 everywhere). At the null predictor the
1990 // Pearson residuals `(y−μ)²/(μ(1−μ))` capture the full *marginal* spread of
1991 // y rather than its *conditional* spread, so the moment estimator
1992 // `1+φ = Σw / Σ w·s` returns a precision far too small (≈3 when the truth is
1993 // ≈20 here).
1994 //
1995 // Crucially — and unlike the Gamma shape — φ does **not** factor out of the
1996 // Beta mean score. With the logit link the score for β is
1997 // ∂ℓ/∂β = φ · Σᵢ xᵢ (y*ᵢ − μ*ᵢ), y*ᵢ = logit(yᵢ),
1998 // μ*ᵢ = ψ(μᵢφ) − ψ((1−μᵢ)φ),
1999 // so the root β̂ depends on φ through the digamma terms. A φ that is too
2000 // small shrinks every fitted coefficient toward zero. So this refresh is not
2001 // cosmetic (as it is for Gamma): the re-solve is what *recovers the mean*.
2002 //
2003 // Fix: after the cold solve converges, re-estimate φ at the converged η,
2004 // re-solve β at the corrected φ (warm-started), and repeat. This is the
2005 // betareg alternating mean-fit ↔ φ-estimate scheme; the moment estimator is
2006 // a strong contraction once the mean has any structure, so the pair settles
2007 // in a handful of passes. Held OFF inside the REML λ search (see the flag
2008 // doc), φ is refreshed only here at the reported fit, so it cannot couple to
2009 // the smoothing parameter and reward over-smoothing. As with Gamma, every
2010 // exit path installs φ evaluated at the *current* η with no following
2011 // re-solve, so the reported φ (which flows into `EstimatedBetaPhi`, the
2012 // embedded `Beta { phi }`, `dispersion`, and every SE) always equals
2013 // `estimate_beta_phi_from_eta(final_eta)`.
2014 let beta_scale = working_model
2015 .likelihood
2016 .resolved_scale()
2017 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2018 if refine_dispersion_at_converged_eta
2019 && matches!(
2020 beta_scale,
2021 ResolvedLikelihoodScale::BetaPrecision {
2022 estimated: true,
2023 ..
2024 }
2025 )
2026 {
2027 // The mean moves between passes (φ feeds back through the digamma
2028 // score), so allow a few more passes than the scale-free Gamma case;
2029 // the contraction is fast and warm-started re-solves are cheap.
2030 const MAX_PHI_REFRESH: usize = 30;
2031 // Relative φ tolerance below which a re-solve cannot move β̂ — and hence
2032 // any reported quantity — by a statistically meaningful amount.
2033 const PHI_REFRESH_REL_TOL: f64 = 1e-4;
2034 for refresh_iter in 0..MAX_PHI_REFRESH {
2035 let refreshed_phi = super::estimate_beta_phi_from_eta(
2036 y,
2037 working_summary.state.eta.as_ref(),
2038 priorweights,
2039 )?;
2040 let prior_phi = working_model
2041 .likelihood
2042 .resolved_beta_precision()
2043 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2044 let rel_change = (refreshed_phi - prior_phi).abs() / prior_phi.max(f64::MIN_POSITIVE);
2045 // Install the refreshed φ (updates BOTH the `Beta { phi }` family
2046 // variant every weight/deviance expression reads and the
2047 // `EstimatedBetaPhi` scale metadata) and re-arm the lock so a
2048 // following re-solve's `update_with_curvature` does not overwrite
2049 // this deliberately chosen value with a fresh cold estimate.
2050 working_model.likelihood = working_model
2051 .likelihood
2052 .clone()
2053 .with_beta_phi(refreshed_phi);
2054 working_model.beta_phi_locked = true;
2055 if rel_change <= PHI_REFRESH_REL_TOL {
2056 // Converged: the just-installed φ matches (to tolerance) the φ
2057 // the current working state was solved at, so β̂, the weights,
2058 // the Hessian and the deviance are already self-consistent with
2059 // the reported φ. Nothing left to rebuild.
2060 break;
2061 }
2062 if refresh_iter + 1 == MAX_PHI_REFRESH {
2063 // Final allowed pass and φ is still drifting. Do NOT re-solve:
2064 // re-solving would advance η past the point the just-installed φ
2065 // was evaluated at, breaking the stored-φ == estimate(final_eta)
2066 // invariant. Stop here so the reported φ is exactly the moment
2067 // estimate at the reported η.
2068 break;
2069 }
2070 // φ moved materially: re-solve β at the corrected φ, warm-started at
2071 // the converged β, so the mean is refit under the better precision
2072 // and the final working state is rebuilt consistently.
2073 working_summary = runworking_model_pirls(
2074 &mut working_model,
2075 working_summary.beta.clone(),
2076 &options,
2077 &mut iteration_logger,
2078 )?;
2079 }
2080 }
2081
2082 // ── Negative-Binomial overdispersion θ: re-estimate at the *converged* η and
2083 // drive (β, θ) to their joint fixed point (#802) ───────────────────────
2084 //
2085 // Identical in spirit to the Beta-precision refresh above. The inner LM solve
2086 // estimates θ **once** from the warm-start η and freezes it (the
2087 // `negbin_theta_locked` lock), keeping the penalized argmin β̂ a stationary LM
2088 // target. But that lock pins θ to whatever η the solve started from, and for
2089 // the final dedicated fit at the converged ρ the warm-start is the cold
2090 // default guess (η ≈ 0). At the null predictor the Pearson residuals carry
2091 // the *marginal* spread of y rather than its *conditional* spread, biasing
2092 // the moment seed — and the frozen θ is what survives into the working weight
2093 // `W = μθ/(θ+μ)`, the covariance `Vb = H⁻¹` (whose overdispersion scaling
2094 // lives in that weight, not a post-hoc multiply), and every reported SE /
2095 // interval / `generate` draw.
2096 //
2097 // Like the Beta precision — and unlike the scale-free Gamma shape / Tweedie φ
2098 // — θ enters the NB2 working *response*, not only the weight, so re-solving β
2099 // under the corrected θ is not cosmetic: it recovers the mean under the right
2100 // variance function. Re-estimating at the converged η, re-solving β
2101 // (warm-started), and repeating drives (β, θ) to their joint maximum-
2102 // likelihood fixed point. Held OFF inside the REML λ search (the flag), θ is
2103 // refreshed only here at the reported fit, so it cannot couple to the
2104 // smoothing parameter. Every exit path installs θ evaluated at the *current*
2105 // η with no following re-solve, so the reported θ (which flows into the
2106 // embedded `NegativeBinomial { theta }`, the `EstimatedNegBinTheta` scale
2107 // metadata, the predictive-interval variance, and every SE) always equals
2108 // `estimate_negbin_theta_from_eta(final_eta)`.
2109 let negbin_scale = working_model
2110 .likelihood
2111 .resolved_scale()
2112 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2113 if refine_dispersion_at_converged_eta
2114 && matches!(
2115 negbin_scale,
2116 ResolvedLikelihoodScale::NegativeBinomial {
2117 estimated: true,
2118 ..
2119 }
2120 )
2121 {
2122 // θ feeds back through the working response, so allow a few more passes
2123 // than the scale-free Gamma case; the alternation is a strong contraction
2124 // and warm-started re-solves are cheap.
2125 const MAX_THETA_REFRESH: usize = 30;
2126 // Relative θ tolerance below which a re-solve cannot move β̂ — and hence
2127 // any reported quantity — by a statistically meaningful amount.
2128 const THETA_REFRESH_REL_TOL: f64 = 1e-4;
2129 for refresh_iter in 0..MAX_THETA_REFRESH {
2130 let refreshed_theta = super::estimate_negbin_theta_from_eta(
2131 y,
2132 working_summary.state.eta.as_ref(),
2133 priorweights,
2134 )?;
2135 let prior_theta = working_model
2136 .likelihood
2137 .resolved_negbin_theta()
2138 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2139 let rel_change =
2140 (refreshed_theta - prior_theta).abs() / prior_theta.max(f64::MIN_POSITIVE);
2141 // Install the refreshed θ (updates BOTH the `NegativeBinomial { theta }`
2142 // family variant every weight/deviance expression reads and the
2143 // `EstimatedNegBinTheta` scale metadata) and re-arm the lock so a
2144 // following re-solve's `update_with_curvature` does not overwrite this
2145 // deliberately chosen value with a fresh cold estimate.
2146 working_model.likelihood = working_model
2147 .likelihood
2148 .clone()
2149 .with_negbin_theta(refreshed_theta);
2150 working_model.negbin_theta_locked = true;
2151 if rel_change <= THETA_REFRESH_REL_TOL {
2152 // Converged: the just-installed θ matches (to tolerance) the θ the
2153 // current working state was solved at, so β̂, the weights, the
2154 // Hessian and the deviance are already self-consistent with the
2155 // reported θ. Nothing left to rebuild.
2156 break;
2157 }
2158 if refresh_iter + 1 == MAX_THETA_REFRESH {
2159 // Final allowed pass and θ is still drifting. Do NOT re-solve:
2160 // re-solving would advance η past the point the just-installed θ
2161 // was evaluated at, breaking the stored-θ == estimate(final_eta)
2162 // invariant. Stop here so the reported θ is exactly the ML
2163 // estimate at the reported η.
2164 break;
2165 }
2166 // θ moved materially: re-solve β at the corrected θ, warm-started at
2167 // the converged β, so the mean is refit under the better variance
2168 // function and the final working state is rebuilt consistently.
2169 working_summary = runworking_model_pirls(
2170 &mut working_model,
2171 working_summary.beta.clone(),
2172 &options,
2173 &mut iteration_logger,
2174 )?;
2175 }
2176 }
2177
2178 // Candidate screens and a rejected post-loop polish are speculative
2179 // mutations of the model's row-space scratch. Reinstall the certified
2180 // coefficient state's arrays only when that scratch no longer carries its
2181 // exact coefficient identity; the common accepted-state path is an O(p)
2182 // bit comparison and performs no extra curvature work.
2183 working_model.refresh_working_arrays_for_state(
2184 &working_summary.beta,
2185 &working_summary.state,
2186 "finalization",
2187 )?;
2188
2189 // Extract workspace before consuming working_model so we can reuse
2190 // the pre-allocated buffers in calculate_edfwithworkspace_with_penalty.
2191 // into_final_state() drops the workspace field anyway (it uses `..` in
2192 // its destructure); we replace it with a zero-sized stub to satisfy the
2193 // borrow checker, then keep the real workspace alive for the EDF call.
2194 let mut saved_workspace = std::mem::replace(
2195 &mut working_model.workspace,
2196 PirlsWorkspace::new(0, 0, 0, 0),
2197 );
2198 let final_state = working_model.into_final_state();
2199 let GamModelFinalState {
2200 likelihood: final_likelihood,
2201 coordinate_frame,
2202 finalmu,
2203 finalweights,
2204 scoreweights,
2205 finalz,
2206 final_c,
2207 final_d,
2208 final_dmu_deta,
2209 final_d2mu_deta2,
2210 final_d3mu_deta3,
2211 penalty_term,
2212 ..
2213 } = final_state;
2214
2215 // Preserve the Hessian as-is (sparse or dense) — no densification.
2216 // P-IRLS already folded any stabilization ridge directly into the Hessian.
2217 // Keep that exact matrix so outer LAML derivatives stay consistent:
2218 // H_eff = X'W_H X + S_λ + ridge I (if ridge_used > 0).
2219 let penalized_hessian_transformed = working_summary.state.hessian.clone();
2220 let stabilizedhessian_transformed = penalized_hessian_transformed.clone();
2221 // Use the workspace-backed variant for the dense path to reuse the
2222 // `final_aug_matrix` allocation; the sparse path still allocates
2223 // internally because no pre-computed factor is available at this site.
2224 let mut edf = if let Some(dense_h) = penalized_hessian_transformed.as_dense() {
2225 calculate_edfwithworkspace_with_penalty(dense_h, &penalty_active, &mut saved_workspace)?
2226 } else {
2227 calculate_edf_with_penalty(&penalized_hessian_transformed, &penalty_active)?
2228 };
2229 if !edf.is_finite() || edf.is_nan() {
2230 let p = penalized_hessian_transformed.ncols() as f64;
2231 let r = penalty_active.rank() as f64;
2232 edf = (p - r).max(0.0);
2233 }
2234
2235 // Outer rescue: a fit that hit max-iterations may still be a usable
2236 // minimum if progress has effectively stopped (deviance plateaued or
2237 // step size collapsed to the floor) AND the projected gradient is in
2238 // the near-stationary band under the scale-invariant certificate.
2239 // Same logic for non-Firth and Firth paths; firth_active just gates
2240 // the second pass.
2241 let stalled_at_valid_minimum = |summary: &WorkingModelPirlsResult| -> bool {
2242 // Scale-equivariant deviance plateau band (issue #1127). The
2243 // `last_deviance_change` compared below and the deviance both scale as
2244 // `O(a²)` under a response rescaling `y → a·y` (the penalized normal
2245 // equations are linear in `y`, so `β → a·β` and the RSS-deviance
2246 // scales by `a²`). Keying the plateau band to the deviance's own
2247 // magnitude `+ |penalty|` makes the ratio `Δdev / dev_scale`
2248 // scale-invariant. The previous `.max(1.0)` absolute floor broke this:
2249 // for a micro-unit response (`a = 1e-6`) the deviance is `O(1e-12)`, so
2250 // the floor pinned the band at `1.0` — ~1e9× too loose — and this
2251 // max-iteration rescue declared `progress_stopped` at an over-smoothed
2252 // iterate, propagating an inflated `λ̂` to the outer REML loop. For a
2253 // well-scaled (`a ≳ 1`) or up-scaled (`a = 1e6`) objective the floor was
2254 // already a no-op, so those directions are byte-identical. A perfect
2255 // interpolating fit gives a `0` band, so the relative `Δdev` test cannot
2256 // fire spuriously and the scale-invariant `near_stationary_kkt`
2257 // certificate then governs acceptance.
2258 let dev_scale = summary.state.deviance.abs() + summary.state.penalty_term.abs();
2259 // Progress plateau uses the fixed solver tolerance; only the KKT band below adapts.
2260 let dev_tol = options.convergence_tolerance * dev_scale;
2261 let step_floor = options.min_step_size * 2.0;
2262 let progress_stopped =
2263 summary.last_deviance_change.abs() <= dev_tol || summary.last_step_size <= step_floor;
2264 let near_stationary = summary
2265 .state
2266 .near_stationary_kkt(summary.lastgradient_norm, effective_kkt_tolerance(&options));
2267 progress_stopped && near_stationary
2268 };
2269
2270 let mut status = working_summary.status;
2271 if status.is_failed_max_iterations() && stalled_at_valid_minimum(&working_summary) {
2272 status = PirlsStatus::StalledAtValidMinimum;
2273 working_summary.status = status;
2274 }
2275 if status.is_failed_max_iterations()
2276 && firth_active
2277 && stalled_at_valid_minimum(&working_summary)
2278 {
2279 // Firth-adjusted fits can stall; accept under the same dual-criterion
2280 // near-stationary band.
2281 status = PirlsStatus::StalledAtValidMinimum;
2282 working_summary.status = status;
2283 }
2284 let has_penalty = penalty_active.rank() > 0;
2285 let firth_active = options.firth_bias_reduction;
2286 if detect_logit_instability(
2287 link_function,
2288 &final_likelihood.spec.response,
2289 has_penalty,
2290 firth_active,
2291 &working_summary,
2292 &finalmu,
2293 &finalweights,
2294 y,
2295 ) {
2296 status = PirlsStatus::Unstable;
2297 working_summary.status = status;
2298 }
2299
2300 // Store a lazy ReparamOperator instead of materializing X·Qs.
2301 // Consumers that truly need dense access can call .to_dense() on demand.
2302 let reparam_result_final = materialize_final_reparam_result()?;
2303 let qs_arc_final = Arc::new(reparam_result_final.qs.clone());
2304 let x_transformed_final =
2305 make_reparam_operator(&x_original_for_result, &qs_arc_final, use_sparse_native);
2306
2307 let pirls_result = assemble_pirls_result(
2308 &working_summary,
2309 final_likelihood,
2310 offset,
2311 penalized_hessian_transformed,
2312 stabilizedhessian_transformed,
2313 edf,
2314 penalty_term,
2315 &finalmu,
2316 &finalweights,
2317 &scoreweights,
2318 &finalz,
2319 &final_c,
2320 &final_d,
2321 &final_dmu_deta,
2322 &final_d2mu_deta2,
2323 &final_d3mu_deta3,
2324 status,
2325 reparam_result_final,
2326 x_transformed_final,
2327 coordinate_frame,
2328 linear_constraints,
2329 )?;
2330
2331 Ok((pirls_result, working_summary))
2332}
2333
2334#[derive(Clone)]
2335pub struct PirlsConfig {
2336 pub likelihood: GlmLikelihoodSpec,
2337 pub link_kind: InverseLink,
2338 pub max_iterations: usize,
2339 pub convergence_tolerance: f64,
2340 pub firth_bias_reduction: bool,
2341 /// Optional warm-start hint for `WorkingModelPirlsOptions::initial_lm_lambda`.
2342 /// Forwarded directly when `fit_model_for_fixed_rho` builds its
2343 /// internal options. See the field doc on `WorkingModelPirlsOptions`
2344 /// for the seeding semantics.
2345 pub initial_lm_lambda: Option<f64>,
2346 /// Optional arrow-Schur structured-inner-solve descriptor. When
2347 /// `Some`, forwarded to `WorkingModelPirlsOptions::arrow_schur` so
2348 /// each accepted LM step is solved by the per-observation
2349 /// arrow-Schur path
2350 /// ([`crate::arrow_schur::ArrowSchurSystem`]). When `None`
2351 /// (the default), the existing β-only path is used unchanged.
2352 ///
2353 /// See [`ArrowSchurInnerConfig`] for the closure contract.
2354 pub arrow_schur: Option<ArrowSchurInnerConfig>,
2355}
2356
2357impl PirlsConfig {
2358 #[inline]
2359 pub fn link_function(&self) -> LinkFunction {
2360 self.link_kind.link_function()
2361 }
2362}
2363
2364#[inline]
2365pub(super) fn max_symmetric_asymmetry(matrix: &Array2<f64>) -> f64 {
2366 let n = matrix.nrows().min(matrix.ncols());
2367 let mut max_asym = 0.0_f64;
2368 for i in 0..n {
2369 for j in 0..i {
2370 let diff = (matrix[[i, j]] - matrix[[j, i]]).abs();
2371 if diff > max_asym {
2372 max_asym = diff;
2373 }
2374 }
2375 }
2376 max_asym
2377}
2378
2379#[inline]
2380pub(super) fn assert_symmetric_tol(matrix: &Array2<f64>, label: &str, tol: f64) {
2381 let max_asym = max_symmetric_asymmetry(matrix);
2382 assert!(
2383 max_asym <= tol,
2384 "{} asymmetry too large: {:.3e} (tol {:.3e})",
2385 label,
2386 max_asym,
2387 tol
2388 );
2389}
2390
2391/// Build a DesignMatrix wrapping a lazy ReparamOperator (or the original for sparse-native).
2392pub(crate) fn make_reparam_operator(
2393 x_original: &DesignMatrix,
2394 qs_arc: &Arc<Array2<f64>>,
2395 use_sparse_native: bool,
2396) -> DesignMatrix {
2397 if use_sparse_native {
2398 x_original.clone()
2399 } else {
2400 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(
2401 ReparamOperator::new(x_original.clone(), Arc::clone(qs_arc)),
2402 )))
2403 }
2404}
2405
2406// solve_penalized_least_squares_implicit lives in pls_solver (imported above).
2407
2408pub(super) fn build_transformed_lower_bound_constraints(
2409 qs: &Array2<f64>,
2410 coefficient_lower_bounds: Option<&Array1<f64>>,
2411) -> Option<LinearInequalityConstraints> {
2412 let lb = coefficient_lower_bounds?;
2413 if lb.len() != qs.nrows() {
2414 return None;
2415 }
2416 let activerows: Vec<usize> = (0..lb.len()).filter(|&i| lb[i].is_finite()).collect();
2417 if activerows.is_empty() {
2418 return None;
2419 }
2420 let mut a = Array2::<f64>::zeros((activerows.len(), qs.ncols()));
2421 let mut b = Array1::<f64>::zeros(activerows.len());
2422 for (r, &idx) in activerows.iter().enumerate() {
2423 a.row_mut(r).assign(&qs.row(idx));
2424 b[r] = lb[idx];
2425 }
2426 Some(
2427 LinearInequalityConstraints::new(a, b)
2428 .expect("transformed lower-bound constraint shape invariant"),
2429 )
2430}
2431
2432pub(super) fn build_transformed_lower_bound_constraints_with_transform(
2433 transform: &WorkingReparamTransform,
2434 coefficient_lower_bounds: Option<&Array1<f64>>,
2435) -> Option<LinearInequalityConstraints> {
2436 let lb = coefficient_lower_bounds?;
2437 let p = match transform {
2438 WorkingReparamTransform::Dense(qs) => qs.nrows(),
2439 WorkingReparamTransform::Kronecker(kron) => kron.p,
2440 };
2441 if lb.len() != p {
2442 return None;
2443 }
2444 let activerows: Vec<usize> = (0..lb.len()).filter(|&i| lb[i].is_finite()).collect();
2445 if activerows.is_empty() {
2446 return None;
2447 }
2448 let mut a = Array2::<f64>::zeros((activerows.len(), p));
2449 let mut b = Array1::<f64>::zeros(activerows.len());
2450 for (r, &idx) in activerows.iter().enumerate() {
2451 let mut basis = Array1::<f64>::zeros(p);
2452 basis[idx] = 1.0;
2453 let row = transform.apply_transpose(&basis);
2454 a.row_mut(r).assign(&row);
2455 b[r] = lb[idx];
2456 }
2457 Some(
2458 LinearInequalityConstraints::new(a, b)
2459 .expect("transformed lower-bound constraint shape invariant"),
2460 )
2461}
2462
2463pub(super) fn build_transformed_linear_constraints(
2464 qs: &Array2<f64>,
2465 linear_constraints: Option<&LinearInequalityConstraints>,
2466) -> Option<LinearInequalityConstraints> {
2467 let lc = linear_constraints?;
2468 if lc.a.ncols() != qs.nrows() {
2469 return None;
2470 }
2471 Some(
2472 LinearInequalityConstraints::new(lc.a.dot(qs), lc.b.clone())
2473 .expect("transformed linear constraint shape invariant"),
2474 )
2475}
2476
2477pub(super) fn build_transformed_linear_constraints_with_transform(
2478 transform: &WorkingReparamTransform,
2479 linear_constraints: Option<&LinearInequalityConstraints>,
2480) -> Option<LinearInequalityConstraints> {
2481 let lc = linear_constraints?;
2482 let p = match transform {
2483 WorkingReparamTransform::Dense(qs) => qs.nrows(),
2484 WorkingReparamTransform::Kronecker(kron) => kron.p,
2485 };
2486 if lc.a.ncols() != p {
2487 return None;
2488 }
2489 let mut a = Array2::<f64>::zeros((lc.a.nrows(), p));
2490 for row in 0..lc.a.nrows() {
2491 let transformed = transform.apply_transpose(&lc.a.row(row).to_owned());
2492 a.row_mut(row).assign(&transformed);
2493 }
2494 Some(LinearInequalityConstraints { a, b: lc.b.clone() })
2495}
2496
2497pub(super) fn merge_linear_constraints(
2498 first: Option<LinearInequalityConstraints>,
2499 second: Option<LinearInequalityConstraints>,
2500) -> Option<LinearInequalityConstraints> {
2501 match (first, second) {
2502 (None, None) => None,
2503 (Some(c), None) | (None, Some(c)) => Some(c),
2504 (Some(c1), Some(c2)) => {
2505 if c1.a.ncols() != c2.a.ncols() {
2506 return None;
2507 }
2508 let rows = c1.a.nrows() + c2.a.nrows();
2509 let cols = c1.a.ncols();
2510 let mut a = Array2::<f64>::zeros((rows, cols));
2511 a.slice_mut(s![0..c1.a.nrows(), ..]).assign(&c1.a);
2512 a.slice_mut(s![c1.a.nrows()..rows, ..]).assign(&c2.a);
2513 let mut b = Array1::<f64>::zeros(rows);
2514 b.slice_mut(s![0..c1.b.len()]).assign(&c1.b);
2515 b.slice_mut(s![c1.b.len()..rows]).assign(&c2.b);
2516 Some(LinearInequalityConstraints { a, b })
2517 }
2518 }
2519}
2520
2521pub(super) fn sparse_from_denseview(x: ArrayView2<f64>) -> Option<DesignMatrix> {
2522 // Below this column count a dense factorization beats the sparse path even
2523 // at high sparsity, so skip the sparsity scan entirely for narrow designs.
2524 const DENSE_PREFERRED_MAX_COLS: usize = 32;
2525 // Sparse storage + sparse Cholesky only pays off below this density (nnz as
2526 // a fraction of all entries); denser matrices stay dense.
2527 const SPARSE_DENSITY_LIMIT: f64 = 0.20;
2528
2529 let nrows = x.nrows();
2530 let ncols = x.ncols();
2531 if nrows == 0 || ncols == 0 {
2532 return None;
2533 }
2534 // Narrow matrices are faster in dense form; avoid any sparsity scan overhead.
2535 if ncols <= DENSE_PREFERRED_MAX_COLS {
2536 return None;
2537 }
2538
2539 const ZERO_EPS: f64 = 1e-12;
2540 let total = nrows.saturating_mul(ncols);
2541 if total == 0 {
2542 return None;
2543 }
2544 // If a matrix exceeds this nnz count it is too dense for sparse path; bail early.
2545 let sparse_nnz_limit = ((total as f64) * SPARSE_DENSITY_LIMIT).floor() as usize;
2546 let mut nnz = 0usize;
2547 for &val in x.iter() {
2548 if val.abs() > ZERO_EPS {
2549 nnz += 1;
2550 if nnz > sparse_nnz_limit {
2551 return None;
2552 }
2553 }
2554 }
2555 let mut triplets = Vec::with_capacity(nnz);
2556 for (row_idx, row) in x.outer_iter().enumerate() {
2557 for (col_idx, &val) in row.iter().enumerate() {
2558 if val.abs() > ZERO_EPS {
2559 triplets.push(Triplet::new(row_idx, col_idx, val));
2560 }
2561 }
2562 }
2563 SparseColMat::try_new_from_triplets(nrows, ncols, &triplets)
2564 .ok()
2565 .map(DesignMatrix::from)
2566}
2567
2568#[cfg(test)]
2569mod tests {
2570 use super::{PirlsPenalty, build_diagonal_penalty_from_kronecker};
2571 use gam_terms::construction::KroneckerReparamResult;
2572 use ndarray::{Array1, Array2, array};
2573
2574 #[test]
2575 fn kronecker_diagonal_double_penalty_hits_only_joint_null_space() {
2576 let kron_result = KroneckerReparamResult {
2577 reparameterized_marginals: std::sync::Arc::new(Vec::new()),
2578 marginal_eigenvalues: std::sync::Arc::new(vec![array![0.0, 2.0], array![0.0, 3.0]]),
2579 marginal_qs: std::sync::Arc::new(Vec::new()),
2580 log_det: 0.0,
2581 det1: Array1::zeros(3),
2582 det2: Array2::zeros((3, 3)),
2583 penalty_shrinkage_ridge: 0.5,
2584 has_double_penalty: true,
2585 marginal_dims: vec![2usize, 2usize],
2586 };
2587 let penalty = build_diagonal_penalty_from_kronecker(&kron_result, &[5.0, 7.0, 11.0]);
2588
2589 let PirlsPenalty::Diagonal {
2590 diag,
2591 positive_indices,
2592 ..
2593 } = penalty
2594 else {
2595 panic!("expected diagonal Kronecker PIRLS penalty");
2596 };
2597 let expected = [11.0, 21.5, 10.5, 31.5];
2598 for (idx, expected_diag) in expected.iter().copied().enumerate() {
2599 assert!(
2600 (diag[idx] - expected_diag).abs() <= 1e-12,
2601 "diagonal {idx} got {}, expected {expected_diag}",
2602 diag[idx]
2603 );
2604 }
2605 assert_eq!(positive_indices, vec![0, 1, 2, 3]);
2606 }
2607}