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}