Skip to main content

gam_models/
penalized_vector_glm.rs

1//! Generic penalized vector-response GLM Newton solver (fixed λ).
2//!
3//! This is the shared scaffold extracted from
4//! [`crate::multinomial::fit_penalized_multinomial`] (dense softmax
5//! Fisher block) and
6//! [`crate::binomial_multi::fit_penalized_binomial_multi`]
7//! (row-diagonal independent-binomial Fisher block). Both families fit a
8//! penalized vector-response GLM with a shared design `X ∈ ℝ^{N×P}` and a
9//! shared penalty `S ∈ ℝ^{P×P}` replicated per output, differing **only** in
10//! the per-row Fisher-block algebra and the likelihood/residual. Everything
11//! else — input validation, penalized objective / gradient / Hessian assembly,
12//! damped Newton with backtracking, convergence certification, and the final
13//! penalized-objective / deviance tally — is written once here.
14//!
15//! # Fit problem
16//!
17//! With `β = [β_0; β_1; …; β_{M-1}]` stacked in output-major order
18//! (`β_a ∈ ℝ^P` is the coefficient block for output `a`), minimise the
19//! penalized negative log-likelihood
20//!
21//! ```text
22//!   F(β) = − log L(β) + ½ Σ_{a=0}^{M-1} λ_a · β_aᵀ S β_a
23//! ```
24//!
25//! where `log L` and its η-derivatives are supplied by the family's
26//! [`VectorLikelihood`] adapter and `λ_a` is a per-output smoothing parameter
27//! scaling the shared penalty `S`. The active linear predictor is
28//! `η_{n,a} = (X β_a)_n`, shape `(N, M)`.
29//!
30//! # Newton step
31//!
32//! Each iteration assembles the coupled penalized Hessian and gradient in
33//! output-major coefficient ordering `flat[a·P + i] = β[i, a]` (matching
34//! [`gam_solve::pirls::dense_block_xtwx`]):
35//!
36//! ```text
37//!   H[a·P + i, b·P + j] = Σ_n W_{n,a,b} · X[n,i] · X[n,j]   (+ δ_{ab} λ_a S[i,j])
38//!   g[a·P + i]          = Σ_n r_{n,a} · X[n,i]              (+ λ_a (S β_a)[i])
39//! ```
40//!
41//! with the per-row Fisher block `W_{n,·,·} = −∂² log L / ∂η ∂η` (the family's
42//! [`VectorLikelihood::hess_block`], or a caller override) and the residual
43//! `r_{n,a} = −∂ log L / ∂η_a` (`−`[`VectorLikelihood::grad_eta`]). The step
44//! `δ = − H^{-1} g` is solved through faer's symmetric-PD-with-fallback
45//! factorisation under an adaptive Levenberg–Marquardt ridge: when a
46//! rank-deficient block (collinear / quasi-separated columns under a small
47//! per-output λ) makes the Bunch–Kaufman fallback back-substitute through
48//! near-zero pivots into a non-finite δ, a diagonal ridge `τ·I` — scaled by the
49//! Hessian's largest diagonal so it is curvature-scale invariant — is added and
50//! the system re-solved, escalating τ geometrically until δ is finite. The
51//! step is then accepted by a backtracking line search on `F` (full step first,
52//! halve up to 8 times). Because the line search validates against the
53//! *unridged* objective `F`, the ridge never biases the converged β̂ (at the
54//! optimum the gradient vanishes and δ → 0 for any τ). Convergence requires
55//! both the relative coefficient step `‖δ‖ / (1 + ‖β‖) ≤ tol` and an exact
56//! curvature-scaled first-order score certificate recomputed at the accepted
57//! final iterate.
58//!
59//! # Fisher-block override
60//!
61//! When `fisher_w_override` is `Some`, each Newton step uses the supplied
62//! per-row `(N, M, M)` curvature block in place of the analytic
63//! [`VectorLikelihood::hess_block`]; the gradient/residual path stays analytic
64//! (issue #349). The two families differ in what they accept off the diagonal:
65//! multinomial admits a full dense block, while independent-binomial columns
66//! only consume the per-output diagonal (a non-zero cross term cannot be
67//! represented by the separable columns). That family-specific precondition is
68//! enforced by the adapter before it constructs the override view; the engine
69//! consumes whatever block it is given.
70
71use crate::model_types::EstimationError;
72use crate::vector_response::VectorLikelihood;
73use faer::Side;
74use gam_linalg::faer_ndarray::{FaerArrayView, array2_to_matmut, factorize_symmetricwith_fallback};
75use gam_problem::{
76    FixedLambdaCheckpoint, FixedLambdaResidualKind, FixedLambdaSolverStage, FixedLambdaStallReason,
77    FixedLambdaStationarityEvidence,
78};
79use gam_solve::pirls::dense_block_xtwx;
80use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayView3};
81use opt::{BacktrackConfig, RidgeSchedule, backtracking_line_search, escalate_ridge};
82use std::convert::Infallible;
83
84/// Base Levenberg–Marquardt ridge as a fraction of the penalized Hessian's
85/// largest diagonal entry (so it is invariant to the problem's overall
86/// curvature scale). At ~1e-10 of the dominant curvature it is negligible
87/// relative to identified-direction curvature — it never biases the identified
88/// optimum (at β̂ the unridged gradient still vanishes there) — yet large
89/// enough to lift an exactly rank-deficient null direction off zero so the
90/// Bunch–Kaufman fallback yields a finite, descent Newton step (gam#856).
91const BASE_RIDGE_FRACTION_OF_MAX_DIAG: f64 = 1.0e-10;
92
93/// Geometric ridge-escalation budget for a single Newton step. 30 doublings
94/// span ~9 orders of magnitude over the base ridge, which covers any
95/// conditioning a finite-curvature softmax/binomial block can present.
96const MAX_RIDGE_ESCALATIONS: usize = 30;
97
98/// Backtracking budget for the damped-Newton line search: full step first, then
99/// halve up to this many times if the penalized objective fails to decrease.
100const MAX_BACKTRACKS: usize = 8;
101
102/// Per-step line-search contraction factor (halving).
103const LINE_SEARCH_SHRINK: f64 = 0.5;
104
105/// Slack on the "objective decreased" acceptance test, absorbing floating-point
106/// round-off so a step that is flat to machine precision is not rejected.
107const OBJECTIVE_DECREASE_SLACK: f64 = 1.0e-12;
108
109/// First-order optimality gate (gam#856) as a fraction of `1 + max_diag`: the
110/// unridged penalized gradient norm must fall below this curvature-scaled
111/// threshold before convergence is declared, certifying stationarity on the
112/// identified subspace rather than a premature step-norm stall.
113const OPTIMALITY_GRAD_FRACTION: f64 = 1.0e-6;
114
115/// Class-space metric of the replicated smoothing penalty (#1587).
116///
117/// * `Diagonal` — the historical `diag_a(λ_a) ⊗ S`: each active output's
118///   coefficient block is penalised independently. Correct for genuinely
119///   independent outputs (independent-binomial columns), but for a *softmax*
120///   multinomial it penalises the reference-anchored log-odds contrasts
121///   `η_a = log(p_a/p_ref)`, so the fit is NOT invariant to the arbitrary
122///   reference-class choice (#1587).
123/// * `Centered` — the reference-symmetric `λ · ((I_{M} − J_{M}/K) ⊗ S)` with a
124///   single shared `λ` (= `lambdas[0]`; the caller must pass uniform `lambdas`)
125///   and `K = M + 1`. This is exactly the symmetric CLR penalty
126///   `Σ_{k=0}^{K-1} β̃_kᵀ S β̃_k` (with `Σ_k β̃_k = 0`) written in the active-class
127///   (ALR) gauge — invariant to which class is the baseline (the multinomial
128///   analogue of #1549's `G^{1/2}` Aitchison whitening). Couples the class
129///   blocks via the `−(λ/K)·S` off-diagonals; the engine already factors a
130///   class-coupled Hessian (the softmax Fisher block is dense), so this is a
131///   penalty-assembly change only.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum ClassPenaltyMetric {
134    /// Independent per-output penalty `diag_a(λ_a) ⊗ S` (historical default).
135    #[default]
136    Diagonal,
137    /// Reference-symmetric centered penalty `λ·((I − J/K) ⊗ S)`, `K = M + 1`.
138    Centered,
139}
140
141/// Inputs to [`fit_penalized_vector_glm`].
142///
143/// `M` (the number of active outputs / linear-predictor columns) is taken from
144/// `lambdas.len()`; the engine validates it against the design and override
145/// shapes. The response `y` is passed verbatim to the [`VectorLikelihood`]
146/// adapter, which owns its own `(N, ·)` shape contract (binomial columns use
147/// `K = M`; multinomial one-hot uses `K = M + 1`), so the engine does not
148/// constrain its column count beyond `y.nrows() == N`.
149pub struct PenalizedVectorGlmInputs<'a> {
150    /// Design matrix `X ∈ ℝ^{N×P}` (one row per observation, shared across
151    /// every output column).
152    pub design: ArrayView2<'a, f64>,
153    /// Response `Y ∈ ℝ^{N×·}`, interpreted by the [`VectorLikelihood`].
154    pub y: ArrayView2<'a, f64>,
155    /// Shared smoothing penalty `S ∈ ℝ^{P×P}` (symmetric, PSD).
156    pub penalty: ArrayView2<'a, f64>,
157    /// Per-output smoothing parameter `λ_a`, length `M`.
158    pub lambdas: ArrayView1<'a, f64>,
159    /// Optional per-row Fisher-block override, shape `(N, M, M)`. When `Some`,
160    /// it replaces the analytic [`VectorLikelihood::hess_block`] as the Newton
161    /// curvature; the gradient/residual path stays analytic (issue #349). The
162    /// adapter is responsible for any family-specific structural precondition
163    /// on the block (e.g. zero off-diagonals for independent columns).
164    pub fisher_w_override: Option<ArrayView3<'a, f64>>,
165    /// Number of Newton iterations available to this invocation. On resume,
166    /// this is an additional budget beyond the checkpoint's completed count.
167    pub max_iter: usize,
168    /// Relative-step convergence tolerance.
169    pub tol: f64,
170    /// Class-space metric of the replicated penalty (#1587). `Diagonal`
171    /// preserves the historical independent-per-output penalty; `Centered`
172    /// selects the reference-symmetric softmax penalty (requires uniform
173    /// `lambdas`). See [`ClassPenaltyMetric`].
174    pub class_penalty_metric: ClassPenaltyMetric,
175    /// Optional checkpoint from the SAME design/response/penalty/weight
176    /// problem. Coefficients are sufficient to resume because η, the score,
177    /// Hessian, and objective are deterministically rebuilt before the first
178    /// additional Newton step.
179    pub resume_from: Option<VectorGlmResume<'a>>,
180}
181
182/// Borrowed fixed-λ vector-GLM checkpoint used to continue a stalled solve.
183#[derive(Debug, Clone, Copy)]
184pub struct VectorGlmResume<'a> {
185    pub coefficients: ArrayView2<'a, f64>,
186    pub completed_iterations: usize,
187}
188
189/// Outputs of a CONVERGED [`fit_penalized_vector_glm`] solve.
190///
191/// SPEC: a fit object only ever comes from a converged optimization. This
192/// struct is constructed exclusively on the [`VectorGlmSolve::Converged`] arm,
193/// so every consumer holding one holds a certified stationary point; there is
194/// no `converged` flag to check. A budget-exhausted solve surfaces instead as
195/// [`VectorGlmSolve::Stalled`], which carries the abandoned iterate as
196/// checkpoint evidence but deliberately has NO Laplace covariance — posterior
197/// uncertainty evaluated at a non-stationary iterate is not a posterior.
198pub struct PenalizedVectorGlmOutputs {
199    /// Coefficient matrix, shape `(P, M)` (column `a` is `β_a`).
200    pub coefficients: Array2<f64>,
201    /// Final active linear predictor `η = X β̂`, shape `(N, M)`. The adapter
202    /// turns this into fitted probabilities via its own inverse link.
203    pub eta: Array2<f64>,
204    /// Number of Newton iterations executed (including the final step that
205    /// satisfied the tolerance).
206    pub iterations: usize,
207    /// Unpenalized log-likelihood `log L(β̂)`.
208    pub log_likelihood: f64,
209    /// Penalty term `½ Σ_a λ_a · β̂_aᵀ S β̂_a` at the returned `β̂`.
210    pub penalty_term: f64,
211    /// Joint Laplace posterior coefficient covariance `H⁻¹` at the converged
212    /// `β̂`, shape `(P·M)×(P·M)` (#1101). `H = block(XᵀWX) + diag_a(λ_a)⊗S` is
213    /// the penalized Hessian the Newton loop already assembles and factors at
214    /// every step, discarding the factor; here it is re-assembled once at the
215    /// mode and inverted (solve against the identity through the same symmetric
216    /// factorization used for the Newton step). Block-ordered to match the
217    /// stacked coefficient vector `θ[a·P + i] = β̂[i, a]`, i.e.
218    /// `β = [β_0; …; β_{M-1}]`. This is the covariance the predict / inference
219    /// surface uses for posterior-mean probabilities and prediction intervals.
220    pub coefficient_covariance: Array2<f64>,
221}
222
223/// Checkpoint evidence for a Newton solve that stopped without certification.
224///
225/// This is NOT a fit: it exists so family adapters can inspect the abandoned
226/// iterate (e.g. the multinomial separation fingerprint `|η| ≥ 25` that routes
227/// to the Firth/Jeffreys proper-prior refit) and so the typed non-convergence
228/// error can carry honest evidence — the iteration count and the penalized
229/// objective at the last iterate. It carries no covariance and no fitted
230/// probabilities on purpose: nothing downstream may dress it up as a result.
231pub struct VectorGlmStall {
232    /// Why the convergence certificate was not reached.
233    pub reason: VectorGlmStallReason,
234    /// Coefficient checkpoint at the last accepted iterate, shape `(P, M)`.
235    pub coefficients: Array2<f64>,
236    /// Linear predictor `η = X β` at the abandoned iterate, shape `(N, M)`.
237    pub eta: Array2<f64>,
238    /// Newton iterations executed before the stall was diagnosed.
239    pub iterations: usize,
240    /// Unpenalized log-likelihood at the abandoned iterate.
241    pub log_likelihood: f64,
242    /// Penalty term at the abandoned iterate.
243    pub penalty_term: f64,
244    /// Norm of the exact penalized score at the checkpoint.
245    pub gradient_norm: f64,
246    /// Curvature-scaled score bound required by the stationarity certificate.
247    pub gradient_bound: f64,
248}
249
250impl VectorGlmStall {
251    /// Convert this solver checkpoint into the canonical typed fixed-lambda
252    /// non-convergence error. Family adapters supply only the objective stage
253    /// and a human-readable entry-point name; the evidence and resumable
254    /// coefficient state come from the solver that produced the stall.
255    pub fn into_nonconvergence_error(
256        self,
257        stage: FixedLambdaSolverStage,
258        context: impl Into<String>,
259    ) -> Result<EstimationError, EstimationError> {
260        let rows = self.coefficients.nrows();
261        let cols = self.coefficients.ncols();
262        let checkpoint = FixedLambdaCheckpoint::new(
263            stage,
264            self.coefficients.iter().copied().collect(),
265            rows,
266            cols,
267            self.iterations,
268        )
269        .map_err(|reason| {
270            EstimationError::InvalidInput(format!(
271                "fixed-lambda vector-GLM produced an invalid internal checkpoint: {reason}"
272            ))
273        })?;
274        let reason = match self.reason {
275            VectorGlmStallReason::IterationBudgetExhausted => {
276                FixedLambdaStallReason::IterationBudgetExhausted
277            }
278            VectorGlmStallReason::LineSearchExhausted => {
279                FixedLambdaStallReason::LineSearchExhausted
280            }
281            VectorGlmStallReason::PostStepCertificateFailed => {
282                FixedLambdaStallReason::StationarityCertificateFailed
283            }
284        };
285        Ok(EstimationError::FixedLambdaNewtonDidNotConverge {
286            context: context.into(),
287            reason,
288            objective_value: -self.log_likelihood + self.penalty_term,
289            stationarity: FixedLambdaStationarityEvidence {
290                kind: FixedLambdaResidualKind::PenalizedGradientNorm,
291                residual: self.gradient_norm,
292                bound: self.gradient_bound,
293            },
294            checkpoint,
295        })
296    }
297}
298
299/// Exhaustive reason a fixed-λ vector solve produced checkpoint evidence
300/// instead of a converged result.
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum VectorGlmStallReason {
303    /// The caller's iteration budget ended before both certificates passed.
304    IterationBudgetExhausted,
305    /// No backtracked candidate satisfied the objective-descent certificate.
306    LineSearchExhausted,
307    /// The small-step gate passed, but the exact score at the accepted iterate
308    /// exceeded its curvature-scaled stationarity bound.
309    PostStepCertificateFailed,
310}
311
312/// Two-outcome result of the fixed-λ vector-GLM Newton solve. Hard input /
313/// linear-algebra failures remain `Err`; any terminal state without a
314/// stationarity certificate is a first-class `Stalled` outcome so adapters must
315/// decide explicitly (typed error, or the multinomial separation → Firth
316/// escalation) instead of ever forwarding a non-converged iterate as a fit.
317pub enum VectorGlmSolve {
318    /// Certified stationary point (step-norm AND first-order optimality gates
319    /// passed), with the Laplace covariance computed at the mode.
320    Converged(PenalizedVectorGlmOutputs),
321    /// Solver stopped without a convergence certificate.
322    Stalled(VectorGlmStall),
323}
324
325/// Quadratic form `½ β_aᵀ S β_a` accumulated across outputs with per-output
326/// weight `λ_a`. Shared by the objective evaluator and the final tally.
327fn weighted_penalty_sum(
328    beta: &Array2<f64>,
329    penalty: ArrayView2<'_, f64>,
330    lambdas: ArrayView1<'_, f64>,
331    metric: ClassPenaltyMetric,
332) -> f64 {
333    let (p, m) = beta.dim();
334    match metric {
335        ClassPenaltyMetric::Diagonal => {
336            let mut pen = 0.0_f64;
337            for a in 0..m {
338                let la = lambdas[a];
339                if la == 0.0 {
340                    continue;
341                }
342                let beta_col = beta.column(a);
343                let mut quad = 0.0_f64;
344                for i in 0..p {
345                    let mut s_beta_i = 0.0_f64;
346                    for j in 0..p {
347                        s_beta_i += penalty[[i, j]] * beta_col[j];
348                    }
349                    quad += beta_col[i] * s_beta_i;
350                }
351                pen += 0.5 * la * quad;
352            }
353            pen
354        }
355        // Centered (#1587): ½·λ·[ Σ_a β_aᵀSβ_a − (1/K)·gᵀSg ], g = Σ_a β_a,
356        // K = M + 1. Equals the symmetric CLR penalty Σ_k β̃_kᵀSβ̃_k (Σβ̃=0) in
357        // the active-class gauge — reference-invariant. Shared λ = lambdas[0].
358        ClassPenaltyMetric::Centered => {
359            if m == 0 {
360                return 0.0;
361            }
362            let lam = lambdas[0];
363            if lam == 0.0 {
364                return 0.0;
365            }
366            let k = (m + 1) as f64;
367            // g = Σ_a β_a (the active-class coefficient sum, a p-vector).
368            let mut g = vec![0.0_f64; p];
369            for a in 0..m {
370                let col = beta.column(a);
371                for i in 0..p {
372                    g[i] += col[i];
373                }
374            }
375            // Σ_a β_aᵀSβ_a.
376            let mut sum_quad = 0.0_f64;
377            for a in 0..m {
378                let col = beta.column(a);
379                for i in 0..p {
380                    let mut s_beta_i = 0.0_f64;
381                    for j in 0..p {
382                        s_beta_i += penalty[[i, j]] * col[j];
383                    }
384                    sum_quad += col[i] * s_beta_i;
385                }
386            }
387            // gᵀSg.
388            let mut g_quad = 0.0_f64;
389            for i in 0..p {
390                let mut s_g_i = 0.0_f64;
391                for j in 0..p {
392                    s_g_i += penalty[[i, j]] * g[j];
393                }
394                g_quad += g[i] * s_g_i;
395            }
396            0.5 * lam * (sum_quad - g_quad / k)
397        }
398    }
399}
400
401/// Fill the gradient of the penalized negative log-likelihood in the engine's
402/// class-major coefficient order. `residual = -∂ log L / ∂η`; the penalty
403/// contribution uses the same class-space metric as the objective and Hessian.
404/// Keeping this algebra in one production helper lets the loop and the final
405/// convergence certificate evaluate exactly the same score at different
406/// iterates.
407fn fill_penalized_gradient(
408    design: ArrayView2<'_, f64>,
409    residual: ArrayView2<'_, f64>,
410    beta: &Array2<f64>,
411    penalty: ArrayView2<'_, f64>,
412    lambdas: ArrayView1<'_, f64>,
413    metric: ClassPenaltyMetric,
414    out: &mut Array1<f64>,
415) {
416    let (p, m) = beta.dim();
417    for a in 0..m {
418        for i in 0..p {
419            let mut acc = 0.0_f64;
420            for row in 0..design.nrows() {
421                acc += design[[row, i]] * residual[[row, a]];
422            }
423            out[a * p + i] = acc;
424        }
425    }
426    match metric {
427        ClassPenaltyMetric::Diagonal => {
428            for a in 0..m {
429                let la = lambdas[a];
430                if la == 0.0 {
431                    continue;
432                }
433                let beta_col = beta.column(a);
434                for i in 0..p {
435                    let mut s_beta_i = 0.0_f64;
436                    for j in 0..p {
437                        s_beta_i += penalty[[i, j]] * beta_col[j];
438                    }
439                    out[a * p + i] += la * s_beta_i;
440                }
441            }
442        }
443        ClassPenaltyMetric::Centered if m > 0 && lambdas[0] != 0.0 => {
444            let lam = lambdas[0];
445            let inv_k = 1.0 / ((m + 1) as f64);
446            let mut beta_bar = vec![0.0_f64; p];
447            for a in 0..m {
448                let col = beta.column(a);
449                for i in 0..p {
450                    beta_bar[i] += col[i];
451                }
452            }
453            for value in &mut beta_bar {
454                *value *= inv_k;
455            }
456            for a in 0..m {
457                let beta_col = beta.column(a);
458                for i in 0..p {
459                    let mut s_centered_i = 0.0_f64;
460                    for j in 0..p {
461                        s_centered_i += penalty[[i, j]] * (beta_col[j] - beta_bar[j]);
462                    }
463                    out[a * p + i] += lam * s_centered_i;
464                }
465            }
466        }
467        ClassPenaltyMetric::Centered => {}
468    }
469}
470
471/// Invert the symmetric penalized Hessian `H` to the joint Laplace covariance
472/// `Σ = H⁻¹` by solving `H·Σ = I` through the shared symmetric factorization
473/// (#1101). `dim` is the flat block dimension `P·M`; `context` prefixes any
474/// diagnostic. A curvature-scaled Tikhonov ridge `τ·I` — floored at
475/// [`BASE_RIDGE_FRACTION_OF_MAX_DIAG`]·max_diag and escalated geometrically up
476/// to [`MAX_RIDGE_ESCALATIONS`] times — is added ONLY when the raw factor/solve
477/// is non-finite (a rank-deficient null direction), exactly mirroring the
478/// Newton step's ridge so the covariance is always finite; at full rank the
479/// ridge is never engaged and `Σ` is the exact `H⁻¹`. The returned matrix is
480/// symmetrized `(Σ + Σᵀ)/2` to null round-off asymmetry from the back-solve.
481fn invert_symmetric_penalized_hessian(
482    hessian: &Array2<f64>,
483    dim: usize,
484    context: &str,
485) -> Result<Array2<f64>, EstimationError> {
486    let max_diag = (0..dim).fold(0.0_f64, |acc, idx| acc.max(hessian[[idx, idx]].abs()));
487    let base_ridge = if max_diag.is_finite() && max_diag > 0.0 {
488        max_diag * BASE_RIDGE_FRACTION_OF_MAX_DIAG
489    } else {
490        BASE_RIDGE_FRACTION_OF_MAX_DIAG
491    };
492    // `last_failure` distinguishes the two exhaustion modes so their distinct
493    // terminal errors survive the migration: `Some((ridge, err))` when the
494    // final attempt died in the factorization, `None` when it factored but the
495    // back-solve stayed non-finite.
496    let mut last_failure: Option<(f64, String)> = None;
497    let mut try_ridge = |ridge: f64| -> Option<Array2<f64>> {
498        let mut ridged = hessian.clone();
499        if ridge > 0.0 {
500            for idx in 0..dim {
501                ridged[[idx, idx]] += ridge;
502            }
503        }
504        let factor = match factorize_symmetricwith_fallback(
505            FaerArrayView::new(&ridged).as_ref(),
506            Side::Lower,
507        ) {
508            Ok(factor) => factor,
509            Err(err) => {
510                last_failure = Some((ridge, err.to_string()));
511                return None;
512            }
513        };
514        // Solve H·Σ = I: identity RHS, back-solved in place to yield Σ = H⁻¹.
515        let mut rhs = Array2::<f64>::eye(dim);
516        {
517            let rhs_view = array2_to_matmut(&mut rhs);
518            factor.solve_in_place(rhs_view);
519        }
520        if !rhs.iter().all(|v| v.is_finite()) {
521            last_failure = None;
522            return None;
523        }
524        // Symmetrize to remove round-off asymmetry from the back-solve.
525        let mut cov = Array2::<f64>::zeros((dim, dim));
526        for i in 0..dim {
527            for j in 0..dim {
528                cov[[i, j]] = 0.5 * (rhs[[i, j]] + rhs[[j, i]]);
529            }
530        }
531        Some(cov)
532    };
533    // Bare (unridged) attempt first — at full rank the ridge is never engaged —
534    // then the geometric escalation from `base_ridge` with the doubling growth
535    // this site has always used.
536    if let Some(cov) = try_ridge(0.0) {
537        return Ok(cov);
538    }
539    match escalate_ridge(
540        RidgeSchedule {
541            initial: base_ridge,
542            growth: 2.0,
543            max_escalations: MAX_RIDGE_ESCALATIONS,
544        },
545        &mut try_ridge,
546    ) {
547        Ok(success) => Ok(success.value),
548        Err(_) => match last_failure {
549            Some((ridge, err)) => Err(EstimationError::InvalidInput(format!(
550                "{context}: covariance factorization failed even with ridge \
551                 {ridge:.3e}: {err}"
552            ))),
553            None => Err(EstimationError::InvalidInput(format!(
554                "{context}: covariance solve remained non-finite after {} ridge escalations \
555                 (max_diag={max_diag:.3e})",
556                MAX_RIDGE_ESCALATIONS,
557            ))),
558        },
559    }
560}
561
562/// Fit a penalized vector-response GLM at fixed `λ` via damped Newton.
563///
564/// The `likelihood` adapter supplies the per-row Fisher block, the residual
565/// gradient, and the log-likelihood; the engine owns the entire optimisation
566/// scaffold. See the module docs for the optimisation problem, the
567/// output-major coefficient ordering, and the convergence semantics.
568///
569/// `context` is woven into every diagnostic message so each family keeps its
570/// own error prefix (e.g. `"fit_penalized_multinomial"`).
571pub fn fit_penalized_vector_glm<L: VectorLikelihood>(
572    inputs: PenalizedVectorGlmInputs<'_>,
573    likelihood: &L,
574    context: &str,
575) -> Result<VectorGlmSolve, EstimationError> {
576    let PenalizedVectorGlmInputs {
577        design,
578        y,
579        penalty,
580        lambdas,
581        fisher_w_override,
582        max_iter,
583        tol,
584        class_penalty_metric,
585        resume_from,
586    } = inputs;
587
588    // ────────────────────────────── shape checks ──────────────────────────
589    let n_obs = design.nrows();
590    let p = design.ncols();
591    if n_obs == 0 || p == 0 {
592        crate::bail_invalid_estim!("{context}: design must be nonempty (got {n_obs}x{p})");
593    }
594    let m = lambdas.len();
595    if m == 0 {
596        crate::bail_invalid_estim!("{context}: need at least one active output (got M=0)");
597    }
598    if y.nrows() != n_obs {
599        crate::bail_invalid_estim!("{context}: y rows {} ≠ design rows {n_obs}", y.nrows());
600    }
601    if penalty.dim() != (p, p) {
602        crate::bail_invalid_estim!(
603            "{context}: penalty shape {:?} ≠ (P, P) = ({p}, {p})",
604            penalty.dim()
605        );
606    }
607    for (i, &v) in lambdas.iter().enumerate() {
608        if !(v.is_finite() && v >= 0.0) {
609            crate::bail_invalid_estim!("{context}: lambdas[{i}] must be finite and ≥ 0 (got {v})");
610        }
611    }
612    if let Some(fw) = fisher_w_override.as_ref() {
613        if fw.dim() != (n_obs, m, m) {
614            crate::bail_invalid_estim!(
615                "{context}: fisher_w_override shape {:?} ≠ (N, M, M) = ({n_obs}, {m}, {m})",
616                fw.dim()
617            );
618        }
619    }
620    for ((i, j), &v) in design.indexed_iter() {
621        if !v.is_finite() {
622            crate::bail_invalid_estim!("{context}: design[{i},{j}] must be finite (got {v})");
623        }
624    }
625
626    // ────────────────────────── Newton iteration ──────────────────────────
627    // β stored as (P, M) column-major-per-output; flat index uses output-major
628    // ordering `flat[a · P + i] = β[i, a]` to align with `dense_block_xtwx`.
629    let (mut beta, completed_iterations) = match resume_from {
630        Some(resume) => {
631            if resume.coefficients.dim() != (p, m) {
632                crate::bail_invalid_estim!(
633                    "{context}: resume checkpoint coefficient shape {:?} ≠ (P, M) = ({p}, {m})",
634                    resume.coefficients.dim()
635                );
636            }
637            for ((i, a), &value) in resume.coefficients.indexed_iter() {
638                if !value.is_finite() {
639                    crate::bail_invalid_estim!(
640                        "{context}: resume checkpoint coefficient[{i},{a}] must be finite (got {value})"
641                    );
642                }
643            }
644            (resume.coefficients.to_owned(), resume.completed_iterations)
645        }
646        None => (Array2::<f64>::zeros((p, m)), 0),
647    };
648    let mut eta = Array2::<f64>::zeros((n_obs, m));
649    // Reused η scratch for the line-search objective probes (see
650    // `evaluate_objective`): overwritten in full on every call, so it carries
651    // no state between calls and hoisting it out of the backtracking loop is a
652    // pure heap-allocation removal with no effect on the computed objective.
653    let mut eta_objective_scratch = Array2::<f64>::zeros((n_obs, m));
654    let beta_flat_dim = p * m;
655    // Reused penalized-gradient buffer: each Newton iteration writes every entry
656    // `grad_flat[a·p + i] = Xᵀr` (direct assignment over all a∈0..m, i∈0..p)
657    // before adding the penalty term and before any read, so it carries no state
658    // across iterations and hoisting it out of the Newton loop is a pure
659    // heap-allocation removal with no effect on the computed gradient.
660    let mut grad_flat = Array1::<f64>::zeros(beta_flat_dim);
661
662    let mut iterations = completed_iterations;
663    let mut small_step_reached = false;
664    let mut stall_reason = VectorGlmStallReason::IterationBudgetExhausted;
665    let mut last_objective = f64::INFINITY;
666
667    // η = X · β for the current β, reused by the analytic Fisher / gradient.
668    let recompute_eta = |beta: &Array2<f64>, eta: &mut Array2<f64>| {
669        for a in 0..m {
670            let beta_col = beta.column(a);
671            for row in 0..n_obs {
672                let mut eta_val = 0.0_f64;
673                for i in 0..p {
674                    eta_val += design[[row, i]] * beta_col[i];
675                }
676                eta[[row, a]] = eta_val;
677            }
678        }
679    };
680
681    // Penalized objective F(β) = − log L(X β) + ½ Σ_a λ_a β_aᵀ S β_a.
682    // The caller supplies a reused `(n_obs, m)` scratch for η = X·β so the
683    // backtracking line search (which calls this up to `MAX_BACKTRACKS + 1`
684    // times per Newton iteration) does not heap-allocate a fresh η buffer on
685    // every probe. The scratch is overwritten in full by `recompute_eta` before
686    // it is read, so reusing it is bit-for-bit identical to the prior
687    // allocate-fresh body: `recompute_eta` runs the SAME `Σ_i design·β` loop in
688    // the SAME order this closure used inline.
689    let evaluate_objective = |beta_trial: &Array2<f64>, eta_scratch: &mut Array2<f64>| -> f64 {
690        recompute_eta(beta_trial, eta_scratch);
691        let ll = likelihood.log_lik(eta_scratch.view(), y);
692        let pen = weighted_penalty_sum(beta_trial, penalty, lambdas, class_penalty_metric);
693        -ll + pen
694    };
695
696    for iter in 0..max_iter {
697        iterations = completed_iterations.checked_add(iter + 1).ok_or_else(|| {
698            EstimationError::InvalidInput(format!(
699                "{context}: resume checkpoint iteration count overflowed usize"
700            ))
701        })?;
702
703        recompute_eta(&beta, &mut eta);
704
705        // Per-row dense Fisher block W_{n,a,b} = −∂² log L / ∂η_a ∂η_b: either
706        // the caller-supplied curvature override (issue #349 escape-hatch —
707        // curvature only) or the analytic [`VectorLikelihood::hess_block`]. The
708        // residual r_{n,a} = −∂ log L / ∂η_a stays analytic in both cases.
709        let analytic_fisher = fisher_w_override
710            .as_ref()
711            .map_or_else(|| Some(likelihood.hess_block(eta.view(), y)), |_| None);
712        let fisher_blocks = match fisher_w_override.as_ref() {
713            Some(fw) => *fw,
714            None => analytic_fisher
715                .as_ref()
716                .expect("analytic Fisher computed when no override")
717                .view(),
718        };
719        let residual = likelihood.grad_eta(eta.view(), y).mapv(|v| -v);
720
721        // Penalized Hessian: H = block(XᵀWX) + diag_a(λ_a S).
722        let mut hessian = dense_block_xtwx(design, fisher_blocks, None)?;
723        if hessian.nrows() != beta_flat_dim || hessian.ncols() != beta_flat_dim {
724            crate::bail_invalid_estim!(
725                "{context}: assembled Hessian shape {:?} ≠ ({beta_flat_dim}, {beta_flat_dim})",
726                hessian.dim()
727            );
728        }
729        match class_penalty_metric {
730            ClassPenaltyMetric::Diagonal => {
731                for a in 0..m {
732                    let la = lambdas[a];
733                    if la == 0.0 {
734                        continue;
735                    }
736                    let base = a * p;
737                    for i in 0..p {
738                        for j in 0..p {
739                            hessian[[base + i, base + j]] += la * penalty[[i, j]];
740                        }
741                    }
742                }
743            }
744            // Centered (#1587): H_{ab} += λ·(δ_ab − 1/K)·S, K = M+1, shared
745            // λ = lambdas[0] — couples every class pair via the −(λ/K)·S
746            // off-diagonals. Reference-invariant softmax penalty.
747            ClassPenaltyMetric::Centered if m > 0 && lambdas[0] != 0.0 => {
748                let lam = lambdas[0];
749                let inv_k = 1.0 / ((m + 1) as f64);
750                for a in 0..m {
751                    for b in 0..m {
752                        let coef = lam * (if a == b { 1.0 } else { 0.0 } - inv_k);
753                        let (ba, bb) = (a * p, b * p);
754                        for i in 0..p {
755                            for j in 0..p {
756                                hessian[[ba + i, bb + j]] += coef * penalty[[i, j]];
757                            }
758                        }
759                    }
760                }
761            }
762            ClassPenaltyMetric::Centered => {}
763        }
764
765        fill_penalized_gradient(
766            design,
767            residual.view(),
768            &beta,
769            penalty,
770            lambdas,
771            class_penalty_metric,
772            &mut grad_flat,
773        );
774
775        // δ = − H^{-1} · grad, solved through an adaptive Levenberg–Marquardt
776        // ridge. The penalized Hessian `H = block(XᵀWX) + diag_a(λ_a S)` can be
777        // rank-deficient — a multinomial class block with quasi-separated /
778        // collinear columns and a small per-class λ leaves `XᵀW_aX + λ_a S`
779        // singular. faer's symmetric fallback chain ends at Bunch–Kaufman
780        // (LBLᵀ), which factorizes indefinite/singular matrices "successfully"
781        // and then back-substitutes through near-zero pivots, yielding a
782        // non-finite δ. Rather than aborting the whole fit on one bad block, we
783        // add a small ridge `τ·I` (Levenberg style) to the diagonal and
784        // re-factorize, escalating τ geometrically until the step is finite.
785        //
786        // The base ridge is scaled by the Hessian's largest diagonal entry so
787        // it is invariant to the problem's overall curvature scale: a tiny
788        // nudge relative to the dominant curvature, large enough to lift the
789        // null directions off zero. A finite δ from the ridged system is a
790        // descent direction for the *unridged* penalized objective `F`
791        // (ridging only shrinks the step toward the gradient direction), and
792        // the backtracking line search below validates it against `F` itself,
793        // so the ridge never biases the converged β̂ — at the optimum the
794        // gradient vanishes and the step → 0 regardless of τ.
795        let max_diag =
796            (0..beta_flat_dim).fold(0.0_f64, |acc, idx| acc.max(hessian[[idx, idx]].abs()));
797        // The ridge floors at `base_ridge` (not 0) for every solve. An exactly
798        // rank-deficient block (e.g. duplicate / collinear design columns under
799        // a near-zero λ) leaves `H = block(XᵀWX) + diag_a(λ_a S)` singular along
800        // a null direction. faer's Bunch–Kaufman fallback factorizes a singular
801        // matrix "successfully" and back-substitutes through the zero pivot to a
802        // *finite but arbitrary* component in the null space, so the resulting
803        // Newton direction is not a descent direction in the identified
804        // subspace — the line search then shrinks α toward 0 and the step-norm
805        // test declares a false convergence at a point where the unridged
806        // penalized gradient on identified directions is still large (gam#856).
807        // A minimal Tikhonov ridge `base_ridge·I` resolves the null direction to
808        // its minimum-norm representative, giving a true descent direction.
809        let base_ridge = if max_diag.is_finite() && max_diag > 0.0 {
810            max_diag * BASE_RIDGE_FRACTION_OF_MAX_DIAG
811        } else {
812            BASE_RIDGE_FRACTION_OF_MAX_DIAG
813        };
814        // A genuine factorization failure (not just a singular pivot) is
815        // remembered so exhaustion can surface its distinct terminal error;
816        // singular pivots back-substituted to ±inf/NaN just escalate.
817        let mut last_factor_err: Option<(f64, String)> = None;
818        let delta = match escalate_ridge(
819            RidgeSchedule {
820                initial: base_ridge,
821                growth: 2.0,
822                max_escalations: MAX_RIDGE_ESCALATIONS + 1,
823            },
824            |ridge| {
825                let mut ridged = hessian.clone();
826                for idx in 0..beta_flat_dim {
827                    ridged[[idx, idx]] += ridge;
828                }
829                let factor = match factorize_symmetricwith_fallback(
830                    FaerArrayView::new(&ridged).as_ref(),
831                    Side::Lower,
832                ) {
833                    Ok(factor) => factor,
834                    Err(err) => {
835                        last_factor_err = Some((ridge, err.to_string()));
836                        return None;
837                    }
838                };
839                last_factor_err = None;
840                let mut rhs = Array2::<f64>::zeros((beta_flat_dim, 1));
841                for i in 0..beta_flat_dim {
842                    rhs[[i, 0]] = -grad_flat[i];
843                }
844                {
845                    let rhs_view = array2_to_matmut(&mut rhs);
846                    factor.solve_in_place(rhs_view);
847                }
848                (0..beta_flat_dim)
849                    .all(|i| rhs[[i, 0]].is_finite())
850                    .then(|| Array1::from_iter((0..beta_flat_dim).map(|i| rhs[[i, 0]])))
851            },
852        ) {
853            Ok(success) => success.value,
854            Err(exhausted) => {
855                if let Some((ridge, err)) = last_factor_err {
856                    return Err(EstimationError::InvalidInput(format!(
857                        "{context}: Hessian factorization failed at iter {iter} \
858                         even with ridge {ridge:.3e}: {err}"
859                    )));
860                }
861                return Err(EstimationError::InvalidInput(format!(
862                    "{context}: Newton step remained non-finite at iter {iter} after {} ridge \
863                     escalations up to {:.3e}; the penalized Hessian is pathologically \
864                     rank-deficient (grad_norm={:.3e}, max_diag={max_diag:.3e})",
865                    MAX_RIDGE_ESCALATIONS,
866                    exhausted.next_ridge,
867                    grad_flat.iter().map(|v| v * v).sum::<f64>().sqrt(),
868                )));
869            }
870        };
871
872        // Damped acceptance: full step first, halve up to `MAX_BACKTRACKS` times
873        // if the penalized negative log-likelihood fails to decrease. The first
874        // iteration seeds `last_objective` from the initial β.
875        let proposed_beta = |alpha: f64| -> Array2<f64> {
876            let mut out = beta.clone();
877            for a in 0..m {
878                for i in 0..p {
879                    out[[i, a]] += alpha * delta[a * p + i];
880                }
881            }
882            out
883        };
884        if iter == 0 {
885            last_objective = evaluate_objective(&beta, &mut eta_objective_scratch);
886            if !last_objective.is_finite() {
887                crate::bail_invalid_estim!("{context}: non-finite objective at β = 0");
888            }
889        }
890        let accepted = match backtracking_line_search::<_, Infallible>(
891            BacktrackConfig {
892                contraction: LINE_SEARCH_SHRINK,
893                max_steps: MAX_BACKTRACKS + 1,
894                ..BacktrackConfig::default()
895            },
896            |alpha| {
897                let candidate = proposed_beta(alpha);
898                let objective = evaluate_objective(&candidate, &mut eta_objective_scratch);
899                Ok(Some((objective, candidate)))
900            },
901            |_alpha, f| f.is_finite() && f <= last_objective + OBJECTIVE_DECREASE_SLACK,
902        ) {
903            Ok(accepted) => accepted,
904            Err(never) => match never {},
905        };
906        let Some(accepted) = accepted else {
907            // Every candidate failed the descent certificate. Keep the last
908            // ACCEPTED iterate as checkpoint evidence; a rejected trial can
909            // never become a result merely because the line-search budget was
910            // exhausted.
911            stall_reason = VectorGlmStallReason::LineSearchExhausted;
912            break;
913        };
914        let accepted_beta = accepted.payload;
915        let new_objective = accepted.value;
916
917        let mut step_norm_sq = 0.0_f64;
918        let mut beta_norm_sq = 0.0_f64;
919        for a in 0..m {
920            for i in 0..p {
921                let d = accepted_beta[[i, a]] - beta[[i, a]];
922                step_norm_sq += d * d;
923                let v = accepted_beta[[i, a]];
924                beta_norm_sq += v * v;
925            }
926        }
927
928        beta = accepted_beta;
929        last_objective = new_objective;
930
931        let step_norm = step_norm_sq.sqrt();
932        let beta_norm = beta_norm_sq.sqrt();
933        // First-order optimality gate (gam#856): the step-norm test alone can
934        // fire prematurely when a backtracking line search has shrunk α on a
935        // poor direction, leaving a point that is NOT stationary. `grad_flat`
936        // is the unridged penalized gradient ∇F(β) at the pre-step β; with a
937        // small step it is ≈ ∇F at the accepted β. Its norm reflects only
938        // identified directions (it is exactly zero along an unidentified null
939        // direction such as a duplicate-column e₁−e₂ split), so requiring it to
940        // be small certifies first-order optimality on the identified subspace
941        // without penalizing legitimate non-identifiability. Scale the gate by
942        // the data magnitude so it is invariant to problem scale.
943        let grad_norm = grad_flat.iter().map(|v| v * v).sum::<f64>().sqrt();
944        // Curvature-scaled optimality threshold: `max_diag` is the dominant
945        // penalized-Hessian diagonal entry, so `OPTIMALITY_GRAD_FRACTION·max_diag`
946        // is a tiny gradient relative to the problem's curvature scale and is
947        // reached by a few quadratically-converging Newton steps on this smooth,
948        // bounded softmax/binomial likelihood.
949        let grad_optimal = grad_norm <= OPTIMALITY_GRAD_FRACTION * (1.0 + max_diag);
950        if step_norm <= tol * (1.0 + beta_norm) && grad_optimal {
951            small_step_reached = true;
952            break;
953        }
954    }
955
956    // ──────────────────────────── post-process ────────────────────────────
957    recompute_eta(&beta, &mut eta);
958    let log_likelihood = likelihood.log_lik(eta.view(), y);
959    let penalty_term = weighted_penalty_sum(&beta, penalty, lambdas, class_penalty_metric);
960
961    // Re-assemble the final penalized Hessian before certification. This is not
962    // posterior work: its diagonal supplies the same curvature scale used by
963    // the loop's first-order gate. Covariance inversion remains below the gate
964    // and is therefore impossible for an uncertified iterate.
965    //
966    // Joint Laplace covariance `H⁻¹` at the converged mode (#1101). Re-assemble
967    // the penalized Hessian `H = block(XᵀWX) + penalty` at β̂ — the SAME algebra
968    // the Newton loop runs each iteration — and invert it by solving `H·Σ = I`
969    // through the shared symmetric factorization. The Newton loop discarded its
970    // per-step factor; this recomputes the factor once at the mode where the
971    // curvature is the correct posterior precision. A tiny curvature-scaled
972    // ridge is added only when the raw factorization / solve is non-finite
973    // (rank-deficient null direction), mirroring the Newton step's ridge logic,
974    // so the covariance is always finite; at full rank the ridge is never used.
975    let analytic_fisher_final = fisher_w_override
976        .as_ref()
977        .map_or_else(|| Some(likelihood.hess_block(eta.view(), y)), |_| None);
978    let fisher_blocks_final = match fisher_w_override.as_ref() {
979        Some(fw) => *fw,
980        None => analytic_fisher_final
981            .as_ref()
982            .expect("analytic Fisher computed when no override")
983            .view(),
984    };
985    let mut hessian_final = dense_block_xtwx(design, fisher_blocks_final, None)?;
986    match class_penalty_metric {
987        ClassPenaltyMetric::Diagonal => {
988            for a in 0..m {
989                let la = lambdas[a];
990                if la == 0.0 {
991                    continue;
992                }
993                let base = a * p;
994                for i in 0..p {
995                    for j in 0..p {
996                        hessian_final[[base + i, base + j]] += la * penalty[[i, j]];
997                    }
998                }
999            }
1000        }
1001        ClassPenaltyMetric::Centered if m > 0 && lambdas[0] != 0.0 => {
1002            let lam = lambdas[0];
1003            let inv_k = 1.0 / ((m + 1) as f64);
1004            for a in 0..m {
1005                for b in 0..m {
1006                    let coef = lam * (if a == b { 1.0 } else { 0.0 } - inv_k);
1007                    let (ba, bb) = (a * p, b * p);
1008                    for i in 0..p {
1009                        for j in 0..p {
1010                            hessian_final[[ba + i, bb + j]] += coef * penalty[[i, j]];
1011                        }
1012                    }
1013                }
1014            }
1015        }
1016        ClassPenaltyMetric::Centered => {}
1017    }
1018
1019    // Re-evaluate the exact penalized score AT the accepted final iterate. The
1020    // loop's inexpensive gate uses the pre-step score (valid to first order
1021    // when the accepted step is tiny); this second evaluation closes the only
1022    // gap through which heavy backtracking could otherwise certify a point
1023    // whose post-step score is still material.
1024    let final_residual = likelihood.grad_eta(eta.view(), y).mapv(|value| -value);
1025    fill_penalized_gradient(
1026        design,
1027        final_residual.view(),
1028        &beta,
1029        penalty,
1030        lambdas,
1031        class_penalty_metric,
1032        &mut grad_flat,
1033    );
1034    let final_grad_norm = grad_flat
1035        .iter()
1036        .map(|value| value * value)
1037        .sum::<f64>()
1038        .sqrt();
1039    let final_max_diag =
1040        (0..beta_flat_dim).fold(0.0_f64, |acc, i| acc.max(hessian_final[[i, i]].abs()));
1041    let final_grad_optimal = final_grad_norm <= OPTIMALITY_GRAD_FRACTION * (1.0 + final_max_diag);
1042    if !(small_step_reached && final_grad_optimal) {
1043        if small_step_reached {
1044            stall_reason = VectorGlmStallReason::PostStepCertificateFailed;
1045        }
1046        // Budget exhausted (or the post-step score failed certification). Hand
1047        // back checkpoint evidence — never a covariance or fitted probabilities.
1048        // The adapter decides between a typed non-convergence error and the
1049        // multinomial separation → Firth/Jeffreys escalation.
1050        return Ok(VectorGlmSolve::Stalled(VectorGlmStall {
1051            reason: stall_reason,
1052            coefficients: beta,
1053            eta,
1054            iterations,
1055            log_likelihood,
1056            penalty_term,
1057            gradient_norm: final_grad_norm,
1058            gradient_bound: OPTIMALITY_GRAD_FRACTION * (1.0 + final_max_diag),
1059        }));
1060    }
1061
1062    let coefficient_covariance =
1063        invert_symmetric_penalized_hessian(&hessian_final, beta_flat_dim, context)?;
1064
1065    Ok(VectorGlmSolve::Converged(PenalizedVectorGlmOutputs {
1066        coefficients: beta,
1067        eta,
1068        iterations,
1069        log_likelihood,
1070        penalty_term,
1071        coefficient_covariance,
1072    }))
1073}
1074
1075#[cfg(test)]
1076mod parity_tests {
1077    //! Parity tests for the shared scaffold across both Fisher-block families
1078    //! (issue #409). The engine is exercised through the two public adapters —
1079    //! [`crate::binomial_multi::fit_penalized_binomial_multi`]
1080    //! (row-diagonal block) and
1081    //! [`crate::multinomial::fit_penalized_multinomial`] (dense
1082    //! softmax block) — and we assert, with un-weakened bounds, that:
1083    //!
1084    //!   1. each fit hits the first-order optimality condition `∇F(β̂) = 0`,
1085    //!      verified by a central finite difference of the penalized objective
1086    //!      (the engine never sees this gradient, so this is an independent
1087    //!      check that the shared Newton scaffold converged correctly);
1088    //!   2. the reported fitted probabilities are consistent with `β̂` and the
1089    //!      reported deviance equals `−2 · log L(β̂)`;
1090    //!   3. for the binomial family, the `K`-column joint solve reproduces a
1091    //!      from-scratch single-column penalized logistic Newton solve column
1092    //!      for column (the row-diagonal block must decouple exactly).
1093
1094    use super::{ClassPenaltyMetric, weighted_penalty_sum};
1095    use crate::binomial_multi::{BinomialMultiFitInputs, fit_penalized_binomial_multi};
1096    use crate::multinomial::{MultinomialFitInputs, fit_penalized_multinomial};
1097    use ndarray::{Array1, Array2};
1098
1099    /// #1587: the `Centered` class-penalty metric is invariant to the arbitrary
1100    /// reference-class choice. Penalizing the `K−1` ALR contrasts under ANY of
1101    /// the `K` baselines yields the same value (the symmetric CLR penalty
1102    /// `Σ_k β̃_kᵀSβ̃_k`), whereas the historical `Diagonal` metric does not — that
1103    /// non-invariance is exactly the #1587 defect. Pure-algebra check on the
1104    /// penalty form (no fit), so it pins the engine foundation the production
1105    /// wiring (REML per-term λ re-key) will build on.
1106    #[test]
1107    fn centered_penalty_is_reference_class_invariant_1587() {
1108        // K = 3 classes, p = 2 coefficients; symmetric PSD penalty S.
1109        let s = ndarray::array![[2.0_f64, 0.5], [0.5, 1.0]];
1110        // A CLR (sum-to-zero) coefficient set: β̃_0 + β̃_1 + β̃_2 = 0.
1111        let bt = [[1.0_f64, 0.5], [-0.3, 0.2], [-0.7, -0.7]];
1112        for j in 0..2 {
1113            let colsum: f64 = (0..3).map(|k| bt[k][j]).sum();
1114            assert!(colsum.abs() < 1e-12, "test CLR set must sum to zero");
1115        }
1116        // Direct symmetric penalty Σ_k β̃_kᵀ S β̃_k.
1117        let mut symmetric = 0.0_f64;
1118        for k in 0..3 {
1119            for i in 0..2 {
1120                for j in 0..2 {
1121                    symmetric += bt[k][i] * s[[i, j]] * bt[k][j];
1122                }
1123            }
1124        }
1125        let lambdas = Array1::from(vec![1.0_f64, 1.0]);
1126        let mut centered_vals = Vec::new();
1127        let mut diagonal_vals = Vec::new();
1128        // For each reference class r, the two ALR contrasts are β̃_a − β̃_r (a≠r).
1129        for r in 0..3 {
1130            let others: Vec<usize> = (0..3).filter(|&k| k != r).collect();
1131            let mut beta = Array2::<f64>::zeros((2, 2));
1132            for (a, &o) in others.iter().enumerate() {
1133                for i in 0..2 {
1134                    beta[[i, a]] = bt[o][i] - bt[r][i];
1135                }
1136            }
1137            let c = weighted_penalty_sum(
1138                &beta,
1139                s.view(),
1140                lambdas.view(),
1141                ClassPenaltyMetric::Centered,
1142            );
1143            let d = weighted_penalty_sum(
1144                &beta,
1145                s.view(),
1146                lambdas.view(),
1147                ClassPenaltyMetric::Diagonal,
1148            );
1149            assert!(
1150                (c - 0.5 * symmetric).abs() < 1e-12,
1151                "ref {r}: Centered penalty {c} must equal ½·symmetric {}",
1152                0.5 * symmetric
1153            );
1154            centered_vals.push(c);
1155            diagonal_vals.push(d);
1156        }
1157        let cspread = centered_vals.iter().cloned().fold(f64::MIN, f64::max)
1158            - centered_vals.iter().cloned().fold(f64::MAX, f64::min);
1159        assert!(
1160            cspread < 1e-12,
1161            "Centered must be reference-invariant; got {centered_vals:?}"
1162        );
1163        let dspread = diagonal_vals.iter().cloned().fold(f64::MIN, f64::max)
1164            - diagonal_vals.iter().cloned().fold(f64::MAX, f64::min);
1165        assert!(
1166            dspread > 1e-6,
1167            "Diagonal is the non-invariant #1587 path; references must disagree, got {diagonal_vals:?}"
1168        );
1169    }
1170
1171    fn sigmoid(eta: f64) -> f64 {
1172        if eta >= 0.0 {
1173            1.0 / (1.0 + (-eta).exp())
1174        } else {
1175            let e = eta.exp();
1176            e / (1.0 + e)
1177        }
1178    }
1179
1180    /// Softmax with implicit reference column (η_ref = 0) over `M` active η.
1181    fn softmax_ref(eta_active: &[f64]) -> Vec<f64> {
1182        let m = eta_active.len();
1183        let mut out = vec![0.0_f64; m + 1];
1184        let mut max_eta = 0.0_f64;
1185        for &v in eta_active {
1186            if v > max_eta {
1187                max_eta = v;
1188            }
1189        }
1190        let baseline = (-max_eta).exp();
1191        let mut denom = baseline;
1192        for (idx, &v) in eta_active.iter().enumerate() {
1193            let e = (v - max_eta).exp();
1194            out[idx] = e;
1195            denom += e;
1196        }
1197        for v in out.iter_mut().take(m) {
1198            *v /= denom;
1199        }
1200        out[m] = baseline / denom;
1201        out
1202    }
1203
1204    /// Penalized negative log-likelihood for the independent-binomial family at
1205    /// a candidate coefficient matrix `β ∈ ℝ^{P×K}`, computed directly from the
1206    /// definition (no engine internals).
1207    fn binomial_objective(
1208        design: &Array2<f64>,
1209        y: &Array2<f64>,
1210        penalty: &Array2<f64>,
1211        lambdas: &Array1<f64>,
1212        beta: &Array2<f64>,
1213    ) -> f64 {
1214        let (n, p) = design.dim();
1215        let k = y.ncols();
1216        let mut ll = 0.0_f64;
1217        for row in 0..n {
1218            for a in 0..k {
1219                let mut eta = 0.0_f64;
1220                for i in 0..p {
1221                    eta += design[[row, i]] * beta[[i, a]];
1222                }
1223                let mu = sigmoid(eta).clamp(1.0e-12, 1.0 - 1.0e-12);
1224                let yv = y[[row, a]];
1225                ll += yv * mu.ln() + (1.0 - yv) * (1.0 - mu).ln();
1226            }
1227        }
1228        let mut pen = 0.0_f64;
1229        for a in 0..k {
1230            let la = lambdas[a];
1231            for i in 0..p {
1232                let mut sbi = 0.0_f64;
1233                for j in 0..p {
1234                    sbi += penalty[[i, j]] * beta[[j, a]];
1235                }
1236                pen += 0.5 * la * beta[[i, a]] * sbi;
1237            }
1238        }
1239        -ll + pen
1240    }
1241
1242    /// Penalized negative log-likelihood for the multinomial family at a
1243    /// candidate active-class coefficient matrix `β ∈ ℝ^{P×(K-1)}`.
1244    fn multinomial_objective(
1245        design: &Array2<f64>,
1246        y_one_hot: &Array2<f64>,
1247        penalty: &Array2<f64>,
1248        lambdas: &Array1<f64>,
1249        beta: &Array2<f64>,
1250    ) -> f64 {
1251        let (n, p) = design.dim();
1252        let k = y_one_hot.ncols();
1253        let m = k - 1;
1254        let mut ll = 0.0_f64;
1255        let mut eta_active = vec![0.0_f64; m];
1256        for row in 0..n {
1257            for a in 0..m {
1258                let mut eta = 0.0_f64;
1259                for i in 0..p {
1260                    eta += design[[row, i]] * beta[[i, a]];
1261                }
1262                eta_active[a] = eta;
1263            }
1264            let probs = softmax_ref(&eta_active);
1265            for c in 0..k {
1266                let yc = y_one_hot[[row, c]];
1267                if yc != 0.0 {
1268                    ll += yc * probs[c].max(1.0e-300).ln();
1269                }
1270            }
1271        }
1272        let mut pen = 0.0_f64;
1273        for a in 0..m {
1274            let la = lambdas[a];
1275            for i in 0..p {
1276                let mut sbi = 0.0_f64;
1277                for j in 0..p {
1278                    sbi += penalty[[i, j]] * beta[[j, a]];
1279                }
1280                pen += 0.5 * la * beta[[i, a]] * sbi;
1281            }
1282        }
1283        -ll + pen
1284    }
1285
1286    /// Central finite-difference gradient of an objective over every entry of a
1287    /// `(P, C)` coefficient matrix. The optimum must drive every component to
1288    /// ~0; we assert the max |component| against an un-weakened bound.
1289    fn fd_grad<F: Fn(&Array2<f64>) -> f64>(beta: &Array2<f64>, f: F) -> f64 {
1290        let (p, c) = beta.dim();
1291        let h = 1.0e-6;
1292        let mut max_abs = 0.0_f64;
1293        for i in 0..p {
1294            for a in 0..c {
1295                let mut up = beta.clone();
1296                let mut dn = beta.clone();
1297                up[[i, a]] += h;
1298                dn[[i, a]] -= h;
1299                let g = (f(&up) - f(&dn)) / (2.0 * h);
1300                max_abs = max_abs.max(g.abs());
1301            }
1302        }
1303        max_abs
1304    }
1305
1306    fn binomial_fixture() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
1307        let n = 40;
1308        let p = 3;
1309        let k = 3;
1310        let design = Array2::<f64>::from_shape_fn((n, p), |(i, j)| match j {
1311            0 => 1.0,
1312            1 => ((i + 1) as f64 * 0.37).sin(),
1313            _ => ((i + 1) as f64 * 0.11).cos(),
1314        });
1315        let y = Array2::<f64>::from_shape_fn((n, k), |(i, a)| {
1316            // Deterministic but non-degenerate {0,1} labels per column.
1317            if ((i * 7 + a * 13 + 3) % 5) < 3 {
1318                1.0
1319            } else {
1320                0.0
1321            }
1322        });
1323        let penalty = Array2::<f64>::eye(p);
1324        let lambdas = Array1::from(vec![0.3_f64, 1.2, 2.5]);
1325        (design, y, penalty, lambdas)
1326    }
1327
1328    fn multinomial_fixture() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
1329        let n = 45;
1330        let p = 3;
1331        let k = 4;
1332        let design = Array2::<f64>::from_shape_fn((n, p), |(i, j)| match j {
1333            0 => 1.0,
1334            1 => ((i + 2) as f64 * 0.29).sin(),
1335            _ => ((i + 2) as f64 * 0.17).cos(),
1336        });
1337        let mut y = Array2::<f64>::zeros((n, k));
1338        for i in 0..n {
1339            y[[i, (i * 3 + 1) % k]] = 1.0;
1340        }
1341        let penalty = Array2::<f64>::eye(p);
1342        let lambdas = Array1::from(vec![0.5_f64, 1.0, 2.0]);
1343        (design, y, penalty, lambdas)
1344    }
1345
1346    #[test]
1347    fn binomial_engine_hits_optimum_and_is_self_consistent() {
1348        let (design, y, penalty, lambdas) = binomial_fixture();
1349        let fit = fit_penalized_binomial_multi(BinomialMultiFitInputs {
1350            design: design.view(),
1351            y: y.view(),
1352            penalty: penalty.view(),
1353            lambdas: lambdas.view(),
1354            row_weights: None,
1355            fisher_w_override: None,
1356            max_iter: 100,
1357            tol: 1.0e-12,
1358        })
1359        .expect("binomial fit must succeed");
1360        // First-order optimality: ∇F(β̂) = 0 (engine never used this gradient).
1361        let g = fd_grad(&fit.coefficients, |b| {
1362            binomial_objective(&design, &y, &penalty, &lambdas, b)
1363        });
1364        assert!(
1365            g < 1.0e-6,
1366            "binomial penalized gradient at β̂ must vanish (max |∂F| = {g})"
1367        );
1368
1369        // Fitted probabilities reproduce σ(X β̂) and deviance = −2 log L.
1370        let (n, p) = design.dim();
1371        let k = y.ncols();
1372        let mut log_lik = 0.0_f64;
1373        for row in 0..n {
1374            for a in 0..k {
1375                let mut eta = 0.0_f64;
1376                for i in 0..p {
1377                    eta += design[[row, i]] * fit.coefficients[[i, a]];
1378                }
1379                let mu = sigmoid(eta);
1380                assert!(
1381                    (fit.fitted_probabilities[[row, a]] - mu).abs() < 1.0e-10,
1382                    "fitted probability must equal σ(X β̂)"
1383                );
1384                let muc = mu.clamp(1.0e-12, 1.0 - 1.0e-12);
1385                let yv = y[[row, a]];
1386                log_lik += yv * muc.ln() + (1.0 - yv) * (1.0 - muc).ln();
1387            }
1388        }
1389        assert!(
1390            (fit.deviance - (-2.0 * log_lik)).abs() < 1.0e-9,
1391            "deviance must equal −2 log L"
1392        );
1393    }
1394
1395    #[test]
1396    fn binomial_joint_solve_decouples_into_single_column_solves() {
1397        // Parity: the row-diagonal Fisher block means the K-column joint solve
1398        // must reproduce, column for column, an independent single-column
1399        // penalized logistic Newton solve. This is the defining property the
1400        // shared engine preserves for the independent-binomial family.
1401        let (design, y, penalty, lambdas) = binomial_fixture();
1402        let joint = fit_penalized_binomial_multi(BinomialMultiFitInputs {
1403            design: design.view(),
1404            y: y.view(),
1405            penalty: penalty.view(),
1406            lambdas: lambdas.view(),
1407            row_weights: None,
1408            fisher_w_override: None,
1409            max_iter: 100,
1410            tol: 1.0e-12,
1411        })
1412        .expect("joint fit must succeed");
1413
1414        let k = y.ncols();
1415        for a in 0..k {
1416            // Single-column problem: one binomial response, one λ.
1417            let y_col = y.column(a).to_owned().insert_axis(ndarray::Axis(1));
1418            let lam = Array1::from(vec![lambdas[a]]);
1419            let single = fit_penalized_binomial_multi(BinomialMultiFitInputs {
1420                design: design.view(),
1421                y: y_col.view(),
1422                penalty: penalty.view(),
1423                lambdas: lam.view(),
1424                row_weights: None,
1425                fisher_w_override: None,
1426                max_iter: 100,
1427                tol: 1.0e-12,
1428            })
1429            .expect("single-column fit must succeed");
1430            for i in 0..design.ncols() {
1431                let dj = joint.coefficients[[i, a]];
1432                let ds = single.coefficients[[i, 0]];
1433                assert!(
1434                    (dj - ds).abs() < 1.0e-8,
1435                    "joint column {a} coef {i} ({dj}) must match single-column solve ({ds})"
1436                );
1437            }
1438        }
1439    }
1440
1441    #[test]
1442    fn multinomial_engine_hits_optimum_and_is_self_consistent() {
1443        let (design, y, penalty, lambdas) = multinomial_fixture();
1444        let fit = fit_penalized_multinomial(MultinomialFitInputs {
1445            design: design.view(),
1446            y_one_hot: y.view(),
1447            penalty: penalty.view(),
1448            lambdas: lambdas.view(),
1449            row_weights: None,
1450            fisher_w_override: None,
1451            max_iter: 100,
1452            tol: 1.0e-12,
1453            resume_from: None,
1454        })
1455        .expect("multinomial fit must succeed");
1456        // First-order optimality: ∇F(β̂) = 0.
1457        let g = fd_grad(&fit.coefficients_active, |b| {
1458            multinomial_objective(&design, &y, &penalty, &lambdas, b)
1459        });
1460        assert!(
1461            g < 1.0e-6,
1462            "multinomial penalized gradient at β̂ must vanish (max |∂F| = {g})"
1463        );
1464
1465        // Fitted probabilities are a valid simplex per row and reproduce the
1466        // softmax of X β̂; deviance = −2 log L.
1467        let (n, p) = design.dim();
1468        let k = y.ncols();
1469        let m = k - 1;
1470        let mut log_lik = 0.0_f64;
1471        let mut eta_active = vec![0.0_f64; m];
1472        for row in 0..n {
1473            for a in 0..m {
1474                let mut eta = 0.0_f64;
1475                for i in 0..p {
1476                    eta += design[[row, i]] * fit.coefficients_active[[i, a]];
1477                }
1478                eta_active[a] = eta;
1479            }
1480            let probs = softmax_ref(&eta_active);
1481            let mut row_sum = 0.0_f64;
1482            for c in 0..k {
1483                assert!(
1484                    (fit.fitted_probabilities[[row, c]] - probs[c]).abs() < 1.0e-10,
1485                    "fitted probability must equal softmax(X β̂)"
1486                );
1487                row_sum += fit.fitted_probabilities[[row, c]];
1488                let yc = y[[row, c]];
1489                if yc != 0.0 {
1490                    log_lik += yc * probs[c].max(1.0e-300).ln();
1491                }
1492            }
1493            assert!(
1494                (row_sum - 1.0).abs() < 1.0e-10,
1495                "fitted probabilities must sum to 1 per row"
1496            );
1497        }
1498        assert!(
1499            (fit.deviance - (-2.0 * log_lik)).abs() < 1.0e-9,
1500            "deviance must equal −2 log L"
1501        );
1502    }
1503
1504    #[test]
1505    fn multinomial_rank_deficient_block_recovers_via_ridge_not_crash() {
1506        // Issue #557: a rank-deficient class block under a tiny per-class λ used
1507        // to make faer's Bunch–Kaufman fallback back-substitute through near-zero
1508        // pivots into a non-finite Newton step δ, and the solver aborted with
1509        // "Newton step is non-finite". The adaptive Levenberg–Marquardt ridge
1510        // must instead lift the null direction off zero, keep δ finite, and let
1511        // the backtracking line search converge to the penalized optimum.
1512        //
1513        // Construct an exactly rank-deficient design: column 2 is a perfect
1514        // duplicate of column 1, so XᵀWX is singular along (e₁ − e₂) for every
1515        // class, and we drive the corresponding λ to a tiny value so the penalty
1516        // cannot regularize that null direction. A non-robust solver crashes
1517        // here; the ridge path must produce a finite, self-consistent fit.
1518        let n = 50;
1519        let p = 4;
1520        let k = 4;
1521        let design = Array2::<f64>::from_shape_fn((n, p), |(i, j)| match j {
1522            0 => 1.0,
1523            1 => ((i + 1) as f64 * 0.23).sin(),
1524            2 => ((i + 1) as f64 * 0.23).sin(), // exact duplicate of column 1
1525            _ => ((i + 1) as f64 * 0.19).cos(),
1526        });
1527        let mut y = Array2::<f64>::zeros((n, k));
1528        for i in 0..n {
1529            y[[i, (i * 5 + 2) % k]] = 1.0;
1530        }
1531        // Penalty touches only the smooth-ish columns 1..p; columns 0/1/2 share
1532        // the collinearity, and a near-zero λ leaves the (e₁ − e₂) null direction
1533        // unregularized — exactly the rank-deficient regime that triggered #557.
1534        let mut penalty = Array2::<f64>::zeros((p, p));
1535        penalty[[3, 3]] = 1.0;
1536        let lambdas = Array1::from(vec![1.0e-10_f64, 1.0e-10, 1.0e-10]);
1537
1538        let fit = fit_penalized_multinomial(MultinomialFitInputs {
1539            design: design.view(),
1540            y_one_hot: y.view(),
1541            penalty: penalty.view(),
1542            lambdas: lambdas.view(),
1543            row_weights: None,
1544            fisher_w_override: None,
1545            max_iter: 200,
1546            tol: 1.0e-10,
1547            resume_from: None,
1548        })
1549        .expect("rank-deficient multinomial fit must NOT crash (#557): the ridge path recovers it");
1550
1551        // Every coefficient and fitted probability must be finite (no inf/NaN
1552        // leaked from the near-singular solve).
1553        for &c in fit.coefficients_active.iter() {
1554            assert!(c.is_finite(), "coefficient must be finite, got {c}");
1555        }
1556        for &pr in fit.fitted_probabilities.iter() {
1557            assert!(
1558                pr.is_finite() && (-1.0e-9..=1.0 + 1.0e-9).contains(&pr),
1559                "fitted probability must be a finite simplex entry, got {pr}"
1560            );
1561        }
1562        // Rows must remain on the simplex.
1563        let (nn, kk) = fit.fitted_probabilities.dim();
1564        for row in 0..nn {
1565            let s: f64 = (0..kk).map(|c| fit.fitted_probabilities[[row, c]]).sum();
1566            assert!(
1567                (s - 1.0).abs() < 1.0e-9,
1568                "row {row} probabilities must sum to 1, got {s}"
1569            );
1570        }
1571
1572        // The recovered fit must satisfy first-order optimality of the penalized
1573        // objective along every NON-NULL coordinate. The (e₁ − e₂) null
1574        // direction is unidentified (the ridge picks the minimum-norm split
1575        // between the duplicate columns), so the gradient is exactly zero along
1576        // every identified direction; a central finite difference of F over the
1577        // full coefficient matrix is dominated by the identified part and must be
1578        // small. We assert the penalized objective gradient is near-zero — the
1579        // ridge biases the step but never the optimum (at β̂ the unridged
1580        // gradient vanishes for any τ).
1581        let g = fd_grad(&fit.coefficients_active, |b| {
1582            multinomial_objective(&design, &y, &penalty, &lambdas, b)
1583        });
1584        assert!(
1585            g < 1.0e-4,
1586            "penalized objective gradient at the ridge-recovered β̂ must (near-)vanish \
1587             along identified directions (max |∂F| = {g})"
1588        );
1589    }
1590}