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