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