gam_models/multinomial.rs
1//! Penalized multinomial-logit (softmax) GLM driver — fixed-λ inner solve.
2//!
3//! This is the principled vector-response companion to the scalar PIRLS path:
4//! the inner-loop Newton solver for a multi-class GAM at fixed smoothing
5//! parameters λ, using the canonical multinomial-logit likelihood
6//! ([`MultinomialLogitLikelihood`]) and the existing dense block-Fisher
7//! assembly in [`gam_solve::pirls::dense_block_xtwx`] /
8//! [`gam_solve::pirls::dense_block_xtwy`].
9//!
10//! # What this module does
11//!
12//! Solve, for the reference-coded multinomial-logit GAM with `K` classes and
13//! design matrix `X ∈ ℝ^{N×P}`,
14//!
15//! ```text
16//! β̂ = argmin_β { − log L(β) + ½ Σ_{a=0}^{K-2} λ_a · β_a^T S β_a }
17//! ```
18//!
19//! where `β = [β_0; β_1; …; β_{K-2}]` is the stacked coefficient vector in
20//! output-major order (`β_a ∈ ℝ^P` is the coefficient block for class `a`),
21//! `S ∈ ℝ^{P×P}` is the smoothing penalty matrix (shared across classes,
22//! replicated as `I_{K-1} ⊗ S` over the full parameter space), and `λ_a` is
23//! a per-class smoothing parameter.
24//!
25//! The likelihood uses class `K - 1` as the reference (`η_{K-1} ≡ 0`), so the
26//! softmax gauge is fixed at the η level and no additional sum-to-zero
27//! projection is required.
28//!
29//! # Layering
30//!
31//! * **Fixed-λ inner solve** — [`fit_penalized_multinomial`] is the canonical
32//! coefficient-space Newton solver at *given* smoothing parameters `λ`,
33//! built on the shared [`crate::penalized_vector_glm`] engine.
34//!
35//! * **REML / LAML smoothing-parameter selection** — [`fit_penalized_multinomial_formula`]
36//! routes through [`crate::custom_family::fit_custom_family_with_rho_prior`]
37//! so the per-active-class `λ_a` are selected by the outer REML/LAML loop;
38//! the caller's `init_lambda` is only a warm-start seed. The multinomial
39//! [`crate::multinomial_reml::MultinomialFamily`] `CustomFamily`
40//! impl calls the fixed-λ math above as its inner solve at each ρ trial and
41//! supplies the dense per-row Hessian block for the outer trace terms.
42//!
43//! * **Formula → design integration** — `build_formula_design_for_multinomial`
44//! parses the Wilkinson formula and assembles `X` and the per-term `S`
45//! blocks; the `fit_multinomial_formula_pyfunc` FFI shim wires the Python
46//! `gamfit.fit(..., family='multinomial')` entry straight to this path.
47//!
48//! # Convergence
49//!
50//! The damped-Newton-with-backtracking scaffold lives once in the shared
51//! [`crate::penalized_vector_glm`] engine: at each iteration the
52//! assembled penalized Hessian `H + I_{K-1} ⊗ (λ_a S)` is factored via faer's
53//! symmetric-PD-with-fallback path, the full Newton step `δ = −H^{-1} ∇F` is
54//! computed, and accepted with step halving if the objective fails to decrease
55//! (up to a small backtracking budget). Convergence requires both a relative
56//! coefficient step `‖δ‖ / (1 + ‖β‖) ≤ tol` and a fresh first-order score
57//! certificate at the accepted final iterate; failure produces checkpoint
58//! evidence, never coefficients/covariance behind a false flag. This module is
59//! the softmax adapter over that engine: it
60//! supplies the dense `(K-1)×(K-1)` Fisher block, the residual, and the
61//! log-likelihood through [`MultinomialLogitLikelihood`], and owns the
62//! class-count / simplex preconditions. The independent-binomial sibling
63//! [`crate::binomial_multi`] is the same engine with a row-diagonal
64//! Fisher block instead.
65
66use crate::custom_family::{
67 BlockwiseFitOptions, ParameterBlockSpec, ParameterBlockState, PenaltyMatrix,
68 fit_custom_family_with_rho_prior,
69};
70use crate::fit_orchestration::drivers::freeze_term_collection_from_design;
71use crate::fit_orchestration::{
72 FitConfig, build_termspec_with_geometry_and_overrides, resolved_resource_policy,
73};
74use crate::model_types::EstimationError;
75use crate::multinomial_reml::MultinomialFamily;
76use crate::multinomial_posterior::{
77 MultinomialPosteriorIntegrationControl, integrate_multinomial_design_moments,
78};
79use crate::penalized_vector_glm::{
80 PenalizedVectorGlmInputs, VectorGlmResume, VectorGlmSolve, fit_penalized_vector_glm,
81};
82use crate::vector_response::{MultinomialLogitLikelihood, validate_multinomial_simplex};
83use gam_data::ColumnKindTag;
84use gam_data::EncodedDataset;
85use gam_problem::{
86 FixedLambdaCheckpoint, FixedLambdaResidualKind, FixedLambdaSolverStage, FixedLambdaStallReason,
87 FixedLambdaStationarityEvidence, ResponseColumnKind,
88};
89use gam_runtime::resource::ProblemHints;
90use gam_terms::inference::formula_dsl::parse_formula;
91use gam_terms::smooth::{
92 PenaltyBlockInfo, TermCollectionDesign, TermCollectionSpec, build_term_collection_design,
93};
94use gam_terms::term_builder::resolve_role_col;
95use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayView3};
96use opt::{BacktrackConfig, backtracking_line_search};
97use serde::{Deserialize, Serialize};
98use std::convert::Infallible;
99use std::sync::Arc;
100
101/// Solver-only numerical stabilization floor for the formula-driven
102/// multinomial REML inner solve (gam#747).
103///
104/// Installed with [`RidgePolicy::solver_only`](gam_problem::RidgePolicy::solver_only)
105/// so it stabilizes the inner joint-Newton **linear solve** but never enters
106/// the REML objective, the penalty log-determinant, or the Laplace Hessian.
107///
108/// What it does: the multinomial smoothing penalties are rank-deficient by
109/// design (each smooth carries an unpenalized polynomial null space) and the
110/// formula may add a fully unpenalized parametric term (`x3` / `body_mass`). On
111/// near-separable hard labels the softmax curvature is ill-conditioned along
112/// those directions, so the bare Newton step `H⁻¹∇` is huge. Lifting the
113/// smallest Hessian eigenvalue to `δ` bounds the step (`‖(H+δI)⁻¹∇‖ ≤ ‖∇‖/δ`),
114/// keeping the screening iterates finite without poisoning the softmax with
115/// `inf − inf = NaN`.
116///
117/// What it deliberately does NOT do: it adds no `½·δ·‖β‖²` term to the
118/// objective and no `δ`-shift to the REML log-determinant. The earlier
119/// `explicit_stabilization_pospart` policy folded both into the criterion,
120/// which made `1e-4` a fixed-λ Gaussian prior that shrank every identified
121/// coefficient off the MLE and biased smoothing-parameter selection — a value
122/// that had to be tuned *between* under-stabilization (NaN seeds) and
123/// over-shrinkage (lost VGAM match). As a solver-only floor that tradeoff is
124/// gone: the over-shrinkage failure mode cannot occur (nothing is shrunk), the
125/// optimized objective is the true penalized REML criterion, and the floor
126/// only has to be large enough to keep the linear algebra finite.
127///
128/// The separation defect (#753) is no longer this floor's job. If the
129/// multinomial MLE is genuinely at infinity for an unpenalized/null-space
130/// direction (complete/quasi-complete separation), no solver floor makes that
131/// direction's estimate finite. The formula REML path arms the full-span
132/// Jeffreys/Firth correction CONDITIONALLY — only on separation evidence (see
133/// [`multinomial_formula_separation_evidence`] and the two-attempt logic in
134/// [`fit_penalized_multinomial_formula`]) — so an interior, well-identified fit
135/// optimizes the unbiased penalized-REML criterion with no Firth shrinkage
136/// toward the uniform simplex, while a (quasi-)separated geometry gets the
137/// proper prior that is the only thing able to bound its penalty-null
138/// directions (#715 real-data arm). The bare fixed-λ inner driver
139/// [`fit_penalized_multinomial`] (no outer REML, no Jeffreys term) surfaces the
140/// explicit `MultinomialSeparationDetected` diagnostic for the path that has no
141/// proper prior to lean on.
142const MULTINOMIAL_FORMULA_RIDGE_FLOOR: f64 = 1.0e-4;
143
144/// Inner joint-Newton KKT tolerance for the multinomial formula path.
145///
146/// The softmax Fisher weight `W = diag(p) − ppᵀ` collapses on saturated rows,
147/// so near-separable fits (penguins, #715) reach the OBJECTIVE's f64 noise
148/// floor before the default `inner_tol = 1e-6` KKT target: measured on the
149/// penguins arm (standardized columns), the trust region collapses to 1e-12
150/// with per-attempt objective changes of ~+2e-9 on |obj| ≈ 1e2 (≈ 1e-11
151/// relative — pure rounding) while the KKT residual plateaus at 2.8e-5–9.4e-5
152/// against a scaled tolerance of ~1.9e-5. Demanding a residual below the
153/// floating-point noise floor is certifiable-never: every eval is rejected by
154/// the stall guard and the whole fit fails. `1e-5` certifies the measured
155/// plateaus while still resolving β to ~1e-6 in the relevant metric — the
156/// LAML criterion consumes β̂ with error O(residual²/curvature), far below
157/// any quantity the outer ρ-search can read.
158const MULTINOMIAL_FORMULA_INNER_TOL: f64 = 1.0e-5;
159
160/// Formula-adapter penalty calibration for multinomial softmax REML.
161///
162/// The term builder's normalized penalties are calibrated on single-response
163/// Gaussian-style score curvature. A reference-coded softmax class block sees
164/// per-row active-class Fisher diagonal `p_a(1-p_a)` plus negative cross-class
165/// coupling. At the neutral simplex (`p_k = 1/K`) the active diagonal is
166/// `(K-1)/K²`, so the binary-logit calibration is `2·(K-1)/K² = 1/2` and the
167/// three-class calibration is `4/9` rather than the historical hard-coded
168/// `1/2`. Making the scale a function of `K` keeps the physical smoothness
169/// prior tied to the likelihood curvature instead of over-penalizing every
170/// class as the simplex gains categories.
171fn multinomial_formula_penalty_scale(n_classes: usize) -> f64 {
172 let k = n_classes.max(2) as f64;
173 2.0 * (k - 1.0) / (k * k)
174}
175
176/// Largest smoothing-parameter dimension where exact dense outer curvature is
177/// still worth paying for multinomial formula fits.
178///
179/// `D = (K - 1) * n_penalties`. Medium-size loaded models use exact curvature
180/// so the optimizer does not wander into over-smoothed lambda caps on
181/// near-boundary softmax surfaces. The threshold was originally calibrated at
182/// `D <= 6` when each `s()` term carried ONE penalty; the double-penalty
183/// migration (wiggliness + null-space shrinkage per term, mgcv `select=TRUE`
184/// semantics) doubled `D` for the SAME models, silently flipping the
185/// reference formula fits (2 smooths, K = 3: old `D = 4`, now `D = 8`) onto
186/// the gradient-only route — where the #715 quality arm showed every
187/// wiggliness ρ driven onto the ±10 box bound (smooths collapsed toward their
188/// polynomial null space, truth-RMSE behind VGAM). `12 = 2 × 6` preserves the
189/// original classification boundary under the doubled penalty count while
190/// keeping the four-smooth penguin species quality fixture on the exact ARC
191/// path: that model is `D = 16`, and first-order BFGS can cycle along the
192/// near-separable lambda-to-zero ridge until the wall-clock budget expires
193/// (#1082). ARC observes the same exact curvature and can halt through the
194/// bound-aware cost-stall guard once the REML surface stops making useful
195/// progress.
196const MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM: usize = 16;
197
198fn multinomial_formula_use_outer_hessian(total_rho_dim: usize) -> bool {
199 total_rho_dim <= MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM
200}
201
202/// Logit magnitude beyond which fitted probabilities are saturated at ordinary
203/// double precision diagnostic scale. The bare fixed-λ driver has no outer REML
204/// state and still uses this threshold to reject a non-converged saturated
205/// iterate as a separation artifact. The formula REML path does not use this as
206/// a Firth trigger: with smoothing parameters selected, a finite saturated
207/// surface can be the valid near-separated optimum that should be scored
208/// directly.
209const MULTINOMIAL_SEPARATION_ETA_THRESHOLD: f64 = 25.0;
210
211/// Calibrated convergence tolerance for the OUTER REML/LAML smoothing-parameter
212/// search on the formula multinomial path. Matches the primary GLM REML outer
213/// (`solver::fit_orchestration::materialize` uses `tol = 1e-7`, mirrored by the
214/// `LOG_LAMBDA_TOL` / `KKT_TOL_*` constants across the REML stack): tight enough
215/// that the selected λ reaches the genuine REML optimum (the recovered
216/// probability surface matches the mature reference), loose enough that the
217/// optimizer does not grind surface-irrelevant ρ digits down to the inner KKT
218/// scale (the #1082 wall-clock overrun). The caller's `tol` is floored at this
219/// value for the OUTER loop, while it continues to drive the INNER joint-Newton
220/// KKT target unchanged.
221const MULTINOMIAL_OUTER_REML_TOL: f64 = 1e-7;
222
223/// The first multinomial formula solve is a separation probe: it is accepted
224/// when the unbiased REML criterion converges to a finite interior iterate.
225/// Near-separable data such as the penguin fixture otherwise spend the caller's
226/// full outer budget on an iterate that is discarded before the Firth/Jeffreys
227/// refit. Keep enough iterations for ordinary interior fits to certify quickly,
228/// but hand slow/non-interior probes to the proper-prior refit promptly.
229const MULTINOMIAL_UNBIASED_PROBE_OUTER_MAX_ITER: usize = 20;
230
231/// Per-observation softmax Fisher-information scale for the λ-floor units.
232///
233/// The penalty enters the criterion as `½ λ βᵀ S β` with a Frobenius-normalized
234/// `S` (`‖S‖_F = 1`, see the term-builder calibration referenced by
235/// [`multinomial_formula_penalty_scale`]), so the ridge `λ S` is directly
236/// comparable to data Fisher information. One observation contributes softmax
237/// information `p(1−p)` in a class's logit direction, which is bounded by the
238/// logistic peak `p(1−p) ≤ ¼` at `p = ½`. Using this maximal per-observation
239/// information as the unit makes the floor's strength interpretable as a count
240/// of equivalent **pseudo-observations** of prior: a ridge that equals
241/// `τ · ¼ · ‖S‖_F` carries the same logit-direction curvature as `τ` real rows
242/// sitting at the most-informative point of the likelihood. This scale is
243/// `K`-independent on purpose — the `K`-dependence of the softmax block
244/// curvature already lives in the penalty matrix via
245/// [`multinomial_formula_penalty_scale`], so the floor (a bound on the
246/// multiplier of that already-scaled penalty) must not double-count it.
247const MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS: f64 = 0.25;
248
249/// Target prior strength of the λ-floor, in pseudo-observations, for a
250/// WELL-SUPPORTED class. The floor holds the unbiased REML optimizer off the
251/// zero-penalty boundary (where a boundary-overfit smooth or a Firth switch on
252/// finite data would otherwise be accepted) with a prior worth a fixed small
253/// fraction of one observation. `8e-4` pseudo-observations reproduces the
254/// previously fixture-calibrated large-support floor `τ · ¼ = 2e-4` exactly at
255/// the calibration point, now expressed as an effective-prior-strength rather
256/// than a tuned λ value.
257const MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS: f64 = 8.0e-4;
258
259/// Reference class support `n_ref`: the effective sample size per class at which
260/// the data Fisher information `n_c · I₁` is large enough that the floor sits at
261/// its well-supported value. Below `n_ref` the per-class data information shrinks
262/// like `n_c`, so to keep the floor's prior from vanishing *relative to* that
263/// shrinking data the effective pseudo-observation count is scaled up by
264/// `n_ref / n_c` (the prior is held to a fixed fraction of the data information,
265/// not a fixed absolute λ). At `n_c = n_ref` the scale is exactly 1.
266const MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT: f64 = 50.0;
267
268/// Cap on the floor's prior strength in the very-sparse limit, in
269/// pseudo-observations. As `n_c → 0` the `n_ref / n_c` scaling diverges; the cap
270/// holds the prior at `4e-3` pseudo-observations (`τ_max · ¼ = 1e-3` at the
271/// calibration point, the previously-tuned strong-floor value) so the floor
272/// stays a proper prior rather than a hard constraint that would dominate the
273/// likelihood for a handful-of-rows class.
274const MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX: f64 = 4.0e-3;
275
276/// Continuous, Fisher-information-scaled lower λ floor for the formula path,
277/// derived from the minority class's effective sample size `n_c`.
278///
279/// # Derivation (effective-prior-strength / Fisher geometry)
280///
281/// The penalty `½ λ βᵀ S β` with `‖S‖_F = 1` adds curvature `λ` to the class
282/// logit direction; one observation adds at most `I₁ = ¼` there. So a floor that
283/// sets `λ_floor = τ_eff · I₁` gives the smooth a prior worth `τ_eff`
284/// pseudo-observations. We want a fixed *absolute* prior `τ` for a well-supported
285/// class, but for a minority class with only `n_c` effective observations the
286/// data information in its block is `n_c · I₁`; holding the prior to a fixed
287/// *fraction* of that shrinking data information requires
288///
289/// ```text
290/// τ_eff(n_c) = τ · max(1, n_ref / n_c), clamped to [τ, τ_max]
291/// λ_floor(n_c) = τ_eff(n_c) · I₁
292/// ```
293///
294/// This is the *same* `base · max(1, c0/c)` envelope as before — but `base`,
295/// `sparse`, and `c0` are no longer fixture-tuned magic numbers: `base = τ·I₁`,
296/// `sparse = τ_max·I₁`, and `c0 = n_ref` are an effective-prior-strength of
297/// `τ`/`τ_max` pseudo-observations against the maximal per-observation softmax
298/// information `I₁ = ¼`. Properties preserved by construction:
299/// * reduces EXACTLY to `τ·I₁` for well-supported classes (`n_c ≥ n_ref`);
300/// * reduces EXACTLY to `τ_max·I₁` for very sparse classes
301/// (`n_c ≤ n_ref·τ/τ_max`, here `n_c ≤ 10`);
302/// * interpolates monotonically and continuously between them in the middle —
303/// no cliff at `n_c = n_ref`.
304/// At the calibration point the endpoints equal the previous `2e-4` / `1e-3`, so
305/// fixtures whose smallest class has `n_c ≥ 50` (penguins, the vgam softmax
306/// arms) are unaffected — they sit at `τ·I₁ = 2e-4` exactly as before.
307fn multinomial_formula_min_lambda(y_one_hot: ArrayView2<'_, f64>) -> f64 {
308 let base = MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
309 let sparse =
310 MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
311 let min_class_count = (0..y_one_hot.ncols())
312 .map(|class| y_one_hot.column(class).sum())
313 .fold(f64::INFINITY, f64::min);
314 if !min_class_count.is_finite() || min_class_count <= 0.0 {
315 return base;
316 }
317 // Effective pseudo-observation prior strength: held to a fixed fraction of
318 // the shrinking per-class data information once n_c falls below n_ref.
319 let pseudo_obs_scale =
320 (MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT / min_class_count).max(1.0);
321 (base * pseudo_obs_scale).clamp(base, sparse)
322}
323
324fn max_abs_eta_location(eta: ArrayView2<'_, f64>) -> (f64, usize, usize) {
325 let mut best = (0.0_f64, 0usize, 0usize);
326 for ((row, active_class), &value) in eta.indexed_iter() {
327 let abs = value.abs();
328 if abs > best.0 {
329 best = (abs, row, active_class);
330 }
331 }
332 best
333}
334
335/// Separation gate for the REML/LAML **formula** path.
336///
337/// Unlike the bare fixed-λ driver [`fit_penalized_multinomial`] (which has no
338/// outer REML state and so must reject a saturated, non-converged iterate as a
339/// separation artifact at the [`MULTINOMIAL_SEPARATION_ETA_THRESHOLD`] logit
340/// magnitude), the formula path can return a finite saturated mode after the
341/// coupled outer optimizer has selected smoothing parameters. A `|η| >= 25`
342/// gate is therefore wrong here: the penguins arm can legitimately have large
343/// fitted logits while still producing finite probabilities and a usable REML
344/// mode.
345///
346/// Only a genuinely NON-FINITE `η` (a NaN/Inf blow-up in the inner linear
347/// algebra) is a real formula-path failure. A finite, even saturated, `η` is
348/// accepted so the truth-recovery / match-or-beat bars are evaluated against the
349/// actual fitted surface instead of an adapter diagnostic.
350fn multinomial_formula_separation_diagnostic(
351 inner_cycles: usize,
352 outer_iterations: usize,
353 block_states: &[ParameterBlockState],
354) -> Option<EstimationError> {
355 let mut nonfinite: Option<(f64, usize, usize)> = None;
356 for (active_class, state) in block_states.iter().enumerate() {
357 for (row, &value) in state.eta.iter().enumerate() {
358 if !value.is_finite() {
359 nonfinite = Some((value, row, active_class));
360 break;
361 }
362 }
363 if nonfinite.is_some() {
364 break;
365 }
366 }
367 nonfinite.map(|(value, row_index, active_class_index)| {
368 EstimationError::MultinomialSeparationDetected {
369 iteration: inner_cycles.max(outer_iterations),
370 max_abs_eta: value.abs(),
371 active_class_index,
372 row_index,
373 }
374 })
375}
376
377/// Separation EVIDENCE gate for the conditional Firth/Jeffreys engagement on
378/// the formula REML path (#715 / #753).
379///
380/// The structural mathematics (#715 issue thread): for any coefficient
381/// direction `v` with `S v = 0` (a penalty-null direction — intercept, a
382/// smooth's polynomial null component, an unpenalized parametric term), the
383/// penalized joint Hessian satisfies `(H + S_λ) v = H v` for EVERY smoothing
384/// parameter ρ. When the data (quasi-)separate, the softmax Fisher weight
385/// `W = diag(p) − p pᵀ → 0` on the saturated rows, so `H v = JᵀWJ v → 0` along
386/// the penalty-null directions those rows support: `(H + S_λ) v ≈ 0` for every
387/// ρ — NO λ can repair it, the inner Newton can never certify a KKT point
388/// there, and every outer REML startup seed is rejected (the penguins
389/// real-data arm). The only principled cure is a PROPER prior on that
390/// quotient-null subspace — the Jeffreys/Firth term `Φ = ½ log|ZᵀHZ|`, whose
391/// Gauss–Newton curvature supplies the missing `O(1)` bound.
392///
393/// But the Firth prior is not free on interior data: unconditionally armed, it
394/// shrinks fitted class probabilities toward the uniform simplex `1/K`
395/// (an `O(1/n)` pull that the synthetic match-or-beat arm of #715 measured as
396/// a real truth-RMSE loss vs the unbiased criterion). So the formula path
397/// engages it ONLY on separation evidence, mirroring the #753 "diagnose, then
398/// arm" split:
399///
400/// * a NON-FINITE logit — the inner linear algebra blew up along an unbounded
401/// direction.
402///
403/// Returns `Some(description)` naming the witnessing logit when evidence is
404/// found, `None` for a finite fit (which is then accepted as-is, with zero
405/// Firth bias). A FAILED unbiased solve (`Err` from the rho-prior driver, e.g.
406/// "no startup seed passed") is the second evidence form and is handled
407/// directly at the call site in [`fit_penalized_multinomial_formula`].
408fn multinomial_formula_separation_evidence(block_states: &[ParameterBlockState]) -> Option<String> {
409 for (active_class, state) in block_states.iter().enumerate() {
410 for (row, &value) in state.eta.iter().enumerate() {
411 if !value.is_finite() {
412 return Some(format!(
413 "non-finite logit eta[row {row}, active class {active_class}] = {value}"
414 ));
415 }
416 }
417 }
418 None
419}
420
421/// Extra evidence used only for a NON-CONVERGED capped unbiased probe.
422///
423/// A converged finite saturated formula fit is still a valid optimum and must be
424/// scored without Firth bias. A capped probe that failed to converge while it
425/// already carries separation-scale logits is different: spending the full
426/// unbiased outer budget on the same lambda-to-zero surface is the #1082
427/// Inputs to [`fit_penalized_multinomial`].
428///
429/// The penalty matrix `S` is shared across classes; per-class smoothing
430/// parameters `lambdas` (length `K - 1`) scale `S` independently for each
431/// active class. The full block-replicated penalty is `diag_a(λ_a) ⊗ S`,
432/// which is exactly what [`gam_solve::arrow_schur::KroneckerPenaltyOp`]
433/// expresses in matrix-free form when this driver is later lifted into the
434/// arrow-Schur loop.
435#[derive(Debug, Clone)]
436pub struct MultinomialFitInputs<'a> {
437 /// Design matrix `X ∈ ℝ^{N×P}` (one row per observation).
438 pub design: ArrayView2<'a, f64>,
439 /// Categorical response `Y ∈ ℝ^{N×K}`. Each row must be a point on the
440 /// probability simplex (`y_c ≥ 0`, `Σ_c y_c = 1`): a one-hot indicator for
441 /// hard classification, or a label-smoothed probability vector. Rows whose
442 /// mass departs from 1 are rejected — the softmax residual gradient and
443 /// Fisher block are the derivatives of `Σ_c y_c log p_c` only under the
444 /// simplex constraint (see `validate_multinomial_simplex`).
445 pub y_one_hot: ArrayView2<'a, f64>,
446 /// Shared smoothing penalty `S ∈ ℝ^{P×P}` (symmetric, PSD).
447 pub penalty: ArrayView2<'a, f64>,
448 /// Per-active-class smoothing parameter `λ_a` (length `K - 1`).
449 pub lambdas: ArrayView1<'a, f64>,
450 /// Optional per-row weights (length `N`); `None` ⇒ uniform 1.0.
451 pub row_weights: Option<ArrayView1<'a, f64>>,
452 /// Optional per-row Fisher-block override, shape `(N, K-1, K-1)` in the
453 /// active-class gauge (the reference class `K-1` is dropped). When `Some`,
454 /// each Newton step uses this block as the curvature `W` in place of the
455 /// analytic softmax Fisher `w_n (δ_ab p_a − p_a p_b)`; the gradient/residual
456 /// path stays analytic, so this is a curvature-only override (the
457 /// research escape-hatch for latent multinomial fits, issue #349). Each
458 /// per-row block must be symmetric, PSD, and finite — preconditions the
459 /// FFI boundary discharges before constructing this view.
460 pub fisher_w_override: Option<ArrayView3<'a, f64>>,
461 /// Maximum Newton iterations; recommend 50.
462 pub max_iter: usize,
463 /// Relative-step convergence tolerance; recommend 1e-7.
464 pub tol: f64,
465 /// Optional checkpoint emitted by a prior fixed-λ multinomial stall on
466 /// the same design, response, weights, offsets, penalty, and lambdas. A
467 /// `MultinomialNewton` checkpoint resumes the ordinary softmax objective;
468 /// a `MultinomialFirth` checkpoint resumes the Jeffreys/Firth separation
469 /// objective directly. Any other stage or coefficient shape is rejected.
470 pub resume_from: Option<&'a FixedLambdaCheckpoint>,
471}
472
473/// Outputs of [`fit_penalized_multinomial`].
474#[derive(Debug, Clone)]
475pub struct MultinomialFitOutputs {
476 /// Active-class coefficient block, shape `(P, K-1)` (column `a` is `β_a`).
477 /// The reference class `K - 1` has `β_{K-1} ≡ 0` by construction and is
478 /// not stored.
479 pub coefficients_active: Array2<f64>,
480 /// Fitted probabilities, shape `(N, K)`.
481 pub fitted_probabilities: Array2<f64>,
482 /// Number of Newton iterations executed (including the final step that
483 /// satisfied the tolerance). Non-convergence (outside the separation lane,
484 /// which escalates to the Firth refit) is surfaced as the typed
485 /// [`EstimationError::FixedLambdaNewtonDidNotConverge`] rather than an `Ok`
486 /// with a flag, so every constructed value of this struct is a certified
487 /// converged fit (SPEC: a fit only ever comes from a converged
488 /// optimization).
489 pub iterations: usize,
490 /// Penalized negative log-likelihood at the returned `β̂`:
491 /// `−log L(β̂) + ½ Σ_a λ_a · β̂_a^T S β̂_a`.
492 pub penalized_neg_log_likelihood: f64,
493 /// Unpenalized deviance `−2 log L(β̂)` for diagnostic reporting.
494 pub deviance: f64,
495 /// Joint Laplace posterior coefficient covariance `H⁻¹` at the converged
496 /// `β̂`, shape `(P·(K−1))×(P·(K−1))` (#1101). Block-ordered to match the
497 /// stacked active-class coefficient vector `β = [β_0; …; β_{K-2}]`: active
498 /// class `a`'s `P` coefficients occupy rows/cols `a·P .. (a+1)·P`, indexed
499 /// `θ[a·P + i] = β̂[i, a]`. This is the Laplace covariance from the factored
500 /// penalized Hessian `XᵀWX + diag_a(λ_a)⊗S`; it drives the delta-method
501 /// per-class probability standard errors ([`Self::predict_probabilities_with_se`])
502 /// on the fixed-λ inner-solve path.
503 pub coefficient_covariance: Array2<f64>,
504}
505
506impl MultinomialFitOutputs {
507 /// Number of active classes `M = K − 1` (columns of
508 /// [`Self::coefficients_active`]).
509 pub fn n_active_classes(&self) -> usize {
510 self.coefficients_active.ncols()
511 }
512
513 /// Per-class coefficient dimension `P` (rows of
514 /// [`Self::coefficients_active`]).
515 pub fn p_per_class(&self) -> usize {
516 self.coefficients_active.nrows()
517 }
518
519 /// Integrate the logistic-normal coefficient posterior at fresh design rows.
520 /// Returns posterior-mean class probabilities and their exact-under-the-
521 /// quadrature marginal standard deviations. The full joint coefficient
522 /// covariance, including cross-class blocks, is contracted into each row's
523 /// active-logit covariance before deterministic adaptive integration.
524 pub fn predict_probabilities_with_se(
525 &self,
526 x_new: ArrayView2<'_, f64>,
527 ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
528 self.predict_probabilities_with_se_and_control(
529 x_new,
530 &MultinomialPosteriorIntegrationControl::default(),
531 )
532 }
533
534 pub fn predict_probabilities_with_se_and_control(
535 &self,
536 x_new: ArrayView2<'_, f64>,
537 control: &MultinomialPosteriorIntegrationControl,
538 ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
539 let moments = integrate_multinomial_design_moments(
540 self.coefficients_active.view(),
541 self.coefficient_covariance.view(),
542 x_new,
543 control,
544 )?;
545 Ok((moments.class_mean, moments.class_standard_deviation))
546 }
547}
548
549#[derive(Clone, Copy)]
550struct FirthResume<'a> {
551 coefficients: ArrayView2<'a, f64>,
552 completed_iterations: usize,
553}
554
555fn fixed_lambda_checkpoint_coefficients(
556 checkpoint: &FixedLambdaCheckpoint,
557 expected_stage: FixedLambdaSolverStage,
558 p: usize,
559 m: usize,
560) -> Result<Array2<f64>, EstimationError> {
561 checkpoint.validate().map_err(|reason| {
562 EstimationError::InvalidInput(format!(
563 "multinomial fixed-λ resume checkpoint is invalid: {reason}"
564 ))
565 })?;
566 if checkpoint.stage() != expected_stage {
567 crate::bail_invalid_estim!(
568 "multinomial fixed-λ resume checkpoint stage is {}, expected {}",
569 checkpoint.stage(),
570 expected_stage,
571 );
572 }
573 if checkpoint.rows() != p || checkpoint.cols() != m {
574 crate::bail_invalid_estim!(
575 "multinomial fixed-λ resume checkpoint shape {}x{} does not match P x (K-1) = {p}x{m}",
576 checkpoint.rows(),
577 checkpoint.cols(),
578 );
579 }
580 Array2::from_shape_vec((p, m), checkpoint.values().to_vec()).map_err(|error| {
581 EstimationError::InvalidInput(format!(
582 "multinomial fixed-λ resume checkpoint could not be reshaped: {error}"
583 ))
584 })
585}
586
587/// Fit a penalized multinomial-logit GAM at fixed `λ`.
588///
589/// See the module docs for the optimization problem and conventions. This
590/// function is the canonical inner solve: the outer REML/LAML loop, when
591/// added, calls this at each `ρ = log λ` trial.
592pub fn fit_penalized_multinomial(
593 inputs: MultinomialFitInputs<'_>,
594) -> Result<MultinomialFitOutputs, EstimationError> {
595 let MultinomialFitInputs {
596 design,
597 y_one_hot,
598 penalty,
599 lambdas,
600 row_weights,
601 fisher_w_override,
602 max_iter,
603 tol,
604 resume_from,
605 } = inputs;
606
607 // ──────────────────────── family-specific validation ───────────────────
608 // The shared engine re-validates the geometry common to every vector-GLM
609 // (nonempty design, penalty shape, λ finiteness/non-negativity, override
610 // `(N, M, M)` shape, finite design). The multinomial family owns the
611 // class-count contract (`K ≥ 2`, λ length `K − 1`), the per-row simplex
612 // precondition under which the softmax residual/Fisher are the exact
613 // derivatives of `Σ_c y_c log p_c`, and the row-weight check the likelihood
614 // adapter consumes.
615 let n_obs = design.nrows();
616 let (y_rows, k) = y_one_hot.dim();
617 if y_rows != n_obs {
618 crate::bail_invalid_estim!(
619 "fit_penalized_multinomial: y rows {y_rows} ≠ design rows {n_obs}"
620 );
621 }
622 if k < 2 {
623 crate::bail_invalid_estim!(
624 "fit_penalized_multinomial: need at least 2 classes (got K={k})"
625 );
626 }
627 let m = k - 1;
628 // #2344: the fixed-λ contract is K per-CLASS lambdas (reference class
629 // included), matching the permutation-equivariant carrier the REML route
630 // selects (1326d0794). K−1 per-CONTRAST lambdas anchored the smoothing to
631 // the arbitrary ALR baseline — relabeling the classes changed the fitted
632 // model. No backcompat shim: K lambdas is the honest contract for nominal
633 // classes.
634 if lambdas.len() != k {
635 crate::bail_invalid_estim!(
636 "fit_penalized_multinomial: lambdas length {} ≠ K = {k} (one λ per class, \
637 reference class included — the permutation-equivariant per-class contract, #2344)",
638 lambdas.len()
639 );
640 }
641 if let Some(fw) = fisher_w_override.as_ref() {
642 if fw.dim() != (n_obs, m, m) {
643 crate::bail_invalid_estim!(
644 "fit_penalized_multinomial: fisher_w_override shape {:?} ≠ (N, K-1, K-1) = ({n_obs}, {m}, {m})",
645 fw.dim()
646 );
647 }
648 }
649 if let Some(w) = row_weights.as_ref() {
650 if w.len() != n_obs {
651 crate::bail_invalid_estim!(
652 "fit_penalized_multinomial: row_weights length {} ≠ N = {n_obs}",
653 w.len()
654 );
655 }
656 for (i, &v) in w.iter().enumerate() {
657 if !(v.is_finite() && v >= 0.0) {
658 crate::bail_invalid_estim!(
659 "fit_penalized_multinomial: row_weights[{i}] must be finite and ≥ 0 (got {v})"
660 );
661 }
662 }
663 }
664 validate_multinomial_simplex(y_one_hot, "fit_penalized_multinomial")?;
665
666 let p = design.ncols();
667 let resumed_newton_coefficients = match resume_from {
668 Some(checkpoint) if checkpoint.stage() == FixedLambdaSolverStage::MultinomialFirth => {
669 let coefficients = fixed_lambda_checkpoint_coefficients(
670 checkpoint,
671 FixedLambdaSolverStage::MultinomialFirth,
672 p,
673 m,
674 )?;
675 return fit_penalized_multinomial_firth_fallback(
676 design,
677 y_one_hot,
678 penalty,
679 lambdas,
680 row_weights,
681 max_iter,
682 tol,
683 Some(FirthResume {
684 coefficients: coefficients.view(),
685 completed_iterations: checkpoint.completed_iterations(),
686 }),
687 );
688 }
689 Some(checkpoint) => Some(fixed_lambda_checkpoint_coefficients(
690 checkpoint,
691 FixedLambdaSolverStage::MultinomialNewton,
692 p,
693 m,
694 )?),
695 None => None,
696 };
697 let vector_resume = resumed_newton_coefficients
698 .as_ref()
699 .map(|coefficients| VectorGlmResume {
700 coefficients: coefficients.view(),
701 completed_iterations: resume_from
702 .map(FixedLambdaCheckpoint::completed_iterations)
703 .unwrap_or(0),
704 });
705
706 // ────────────────────────── likelihood construction ───────────────────
707 let mut likelihood = MultinomialLogitLikelihood::with_classes(k)?;
708 if let Some(w) = row_weights.as_ref() {
709 likelihood = likelihood.with_row_weights(w.to_owned())?;
710 }
711
712 // ─────────────────── shared penalized vector-GLM solve ─────────────────
713 // The softmax Fisher block is dense across the `M = K − 1` active classes;
714 // the engine assembles the coupled `(P·M)×(P·M)` penalized Hessian, runs
715 // the damped Newton loop, and returns the converged `β̂` and `η = X β̂`.
716 let solve = fit_penalized_vector_glm(
717 PenalizedVectorGlmInputs {
718 design,
719 y: y_one_hot,
720 penalty,
721 lambdas,
722 fisher_w_override,
723 max_iter,
724 tol,
725 // #2344: the permutation-equivariant per-class metric — the fixed-λ
726 // twin of the REML equivariant carrier (1326d0794). K per-class
727 // lambdas on the centered class functions; reference-free by
728 // construction, collapsing to the shared Centered metric at equal λ.
729 class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::EquivariantPerClass,
730 resume_from: vector_resume,
731 },
732 &likelihood,
733 "fit_penalized_multinomial",
734 )?;
735
736 let fit = match solve {
737 VectorGlmSolve::Converged(fit) => fit,
738 VectorGlmSolve::Stalled(stall) => {
739 return handle_multinomial_fixed_lambda_stall(
740 stall,
741 design,
742 y_one_hot,
743 penalty,
744 lambdas,
745 row_weights,
746 max_iter,
747 tol,
748 );
749 }
750 };
751
752 let fitted_probabilities = likelihood.probabilities(fit.eta.view());
753
754 Ok(MultinomialFitOutputs {
755 coefficients_active: fit.coefficients,
756 fitted_probabilities,
757 iterations: fit.iterations,
758 penalized_neg_log_likelihood: -fit.log_likelihood + fit.penalty_term,
759 deviance: -2.0 * fit.log_likelihood,
760 coefficient_covariance: fit.coefficient_covariance,
761 })
762}
763
764/// Resolve a budget-exhausted fixed-λ softmax Newton solve: either the
765/// separation lane (escalate to the Firth/Jeffreys proper-prior refit) or the
766/// typed non-convergence error. Never mints a fit from the stalled iterate.
767fn handle_multinomial_fixed_lambda_stall(
768 stall: crate::penalized_vector_glm::VectorGlmStall,
769 design: ArrayView2<'_, f64>,
770 y_one_hot: ArrayView2<'_, f64>,
771 penalty: ArrayView2<'_, f64>,
772 lambdas: ArrayView1<'_, f64>,
773 row_weights: Option<ArrayView1<'_, f64>>,
774 max_iter: usize,
775 tol: f64,
776) -> Result<MultinomialFitOutputs, EstimationError> {
777 let (max_abs_eta, row_index, active_class_index) = max_abs_eta_location(stall.eta.view());
778 if max_abs_eta >= MULTINOMIAL_SEPARATION_ETA_THRESHOLD {
779 // Perfect / quasi-perfect separation (#1854): the UNBIASED softmax MLE is
780 // not finite along `active_class_index`'s saturated logit direction, so
781 // the fixed-λ Newton above ran away (`|η| ≥ 25`, no convergence). A
782 // penalty-null direction `v` (`S v = 0`, e.g. an unpenalized intercept /
783 // linear-covariate column) under softmax saturation has
784 // `(XᵀWX + λS) v → 0` for EVERY λ, so no smoothing parameter can bound it
785 // — only a proper prior on that quotient-null subspace can. Rather than
786 // hard-erroring, engage the Firth/Jeffreys proper prior automatically
787 // (magic-by-default): the full-span `½ log|I(β)|` correction supplies the
788 // `O(1)` curvature that keeps the estimate finite on exactly those
789 // separated directions while leaving well-identified fits untouched. This
790 // reuses the same coupled joint-Newton Jeffreys machinery the formula
791 // REML path arms on separation evidence (see
792 // `fit_penalized_multinomial_formula`), only here at the caller's fixed λ.
793 // Engage the fallback, but never let an internal consistency panic in
794 // the coupled joint-Newton assembly (e.g. the #1395 logdet-collapse
795 // guard) escape as a process abort: convert any panic into the
796 // documented hard separation diagnostic, exactly as if the refit had
797 // returned Err. This mirrors the catch_unwind panic-to-typed-error
798 // boundary already used around the faer / cudarc entry points, and keeps
799 // the separation path no worse than the pre-#1854 clean error while the
800 // Firth refit is still being hardened.
801 // Start the Firth refit from the well-conditioned origin (β = 0), NOT
802 // from the stalled Newton iterate. That stalled iterate is the runaway
803 // separated point (`|η| ≥ 25`), where the softmax Fisher information
804 // `I(β)` is numerically singular (every fitted probability is pinned to
805 // the {0,1} simplex boundary, so `I → 0`). Warm-starting the Firth
806 // Newton there is catastrophic: the first step `(I + λS)⁻¹ U*` is
807 // unbounded and every backtracked candidate stays on the boundary, so
808 // the line search exhausts without an accepted step and the refit stalls
809 // at iteration 1 — it can never climb back to the interior Firth mode.
810 // The Firth objective's interior mode is start-independent (the
811 // `firth_solver_rejects_a_truncated_iterate` resume contract asserts the
812 // same mode is reached from any interior start), and from `β = 0` the
813 // information is well-conditioned, so a plain from-zero refit converges
814 // reliably on exactly the separated data that defeated the fixed-λ
815 // Newton above.
816 let firth = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
817 fit_penalized_multinomial_firth_fallback(
818 design,
819 y_one_hot,
820 penalty,
821 lambdas,
822 row_weights,
823 max_iter,
824 tol,
825 None,
826 )
827 }));
828 match firth {
829 // SPEC: a fit object must only ever come from a converged
830 // optimization — the Firth fallback itself surfaces a
831 // budget-exhausted refit as the typed
832 // `FixedLambdaNewtonDidNotConverge`, which is forwarded verbatim so
833 // the caller sees which lane stalled and its evidence.
834 Ok(Ok(out)) => return Ok(out),
835 Ok(Err(err @ EstimationError::FixedLambdaNewtonDidNotConverge { .. })) => {
836 return Err(err);
837 }
838 // Firth refit errored, or an internal consistency guard panicked:
839 // fall back to the explicit hard separation diagnostic.
840 Ok(Err(_)) | Err(_) => {
841 return Err(EstimationError::MultinomialSeparationDetected {
842 iteration: stall.iterations,
843 max_abs_eta,
844 active_class_index,
845 row_index,
846 });
847 }
848 }
849 }
850
851 // SPEC: a fit object must only ever come from a converged optimization.
852 // A stall WITHOUT the separation fingerprint (|η| below the threshold —
853 // e.g. ill-conditioned data exhausting `max_iter`) is a typed error
854 // carrying its evidence, never an Ok(outputs) with a flag.
855 Err(stall.into_nonconvergence_error(
856 FixedLambdaSolverStage::MultinomialNewton,
857 "fit_penalized_multinomial (fixed-λ softmax damped Newton)",
858 )?)
859}
860
861/// Firth/Jeffreys-penalized multinomial refit engaged automatically when the
862/// unbiased softmax MLE separates (#1854).
863///
864/// The unbiased fixed-λ solve ([`fit_penalized_multinomial`]) runs away on
865/// (quasi-)separated data because the softmax likelihood has no finite mode along
866/// the saturated logit direction and the smoothing penalty `S` cannot bound a
867/// penalty-null direction (`S v = 0` ⇒ `(XᵀWX + λS) v → 0` for every λ). This
868/// refit arms the full-span Jeffreys/Firth proper prior `½ log|I(β)|` on the
869/// coupled joint softmax information, which supplies the `O(1)` curvature that
870/// bounds exactly those directions and keeps the estimate finite.
871///
872/// # The estimator
873///
874/// It maximizes the penalized Firth objective at the caller's *fixed* `λ`
875///
876/// ```text
877/// ℓ*(β) = Σ_n w_n Σ_c y_{nc} log p_{nc}
878/// − ½ Σ_a λ_a βₐᵀ S βₐ
879/// + ½ log det I(β)
880/// ```
881///
882/// where `I(β)` is the coupled `(P·M)×(P·M)` softmax Fisher information (block
883/// `(a,b)` is `Σ_n w_n (δ_{ab} p_{na} − p_{na} p_{nb}) x_n x_nᵀ`, block-ordered so
884/// `θ[a·P+i] = β[i,a]`) and `M = K−1` active classes carry the reference-coded
885/// logits (`η_{ref} ≡ 0`). The Jeffreys term `½ log det I(β)` is the standard
886/// Firth penalty: it diverges to `−∞` as any fitted probability approaches the
887/// simplex boundary (`I → 0`), so its maximizer is interior and finite on exactly
888/// the separated directions that defeat every smoothing `λ`.
889///
890/// # Why this fixed-λ solver rather than the outer-REML formula path
891///
892/// The direct entry ([`fit_penalized_multinomial`]) is a fixed-λ inner solve — it
893/// carries no outer smoothing selection — so the natural Firth engagement is a
894/// fixed-λ Firth Newton, not the formula path's outer-REML joint-Newton machinery
895/// (which is armed instead by [`fit_penalized_multinomial_formula`] on separation
896/// evidence). Solving the Firth objective directly here keeps the separation
897/// contract self-contained and independent of the shared trust-region/KKT
898/// certificate machinery.
899///
900/// # The iteration
901///
902/// A Fisher-scoring Newton on `ℓ*`: the ascent direction is
903/// `Δ = (I + Λ⊗S)⁻¹ U*`, where `U*` is the Firth-adjusted penalized score
904///
905/// ```text
906/// U*[(c,s)] = Σ_n w_n x_{ns} (y_{nc} − p_{nc}) (data score)
907/// − λ_c (S β_c)_s (smoothing penalty)
908/// + ½ Σ_n w_n x_{ns} h^c_n (Firth adjustment)
909/// ```
910///
911/// and the Firth adjustment uses `h^c_n = Σ_{a,b} G^c_{n,ab} Q_{n,ab}` with the
912/// per-row information "hat" `Q_{n,ab} = x_nᵀ [I⁻¹]_{(a,b)} x_n` and the softmax
913/// third-derivative tensor
914/// `G^c_{ab} = δ_{ab} p_a (δ_{ac} − p_c) − p_a p_b (δ_{ac} + δ_{bc} − 2 p_c)`.
915/// This `½ Σ tr(I⁻¹ ∂I/∂β)` is exactly `∇[½ log det I]` (finite-difference
916/// verified). Each step is globalized by backtracking on `ℓ*`, so a step that
917/// would push a probability to the boundary (making `I` non-PD) is rejected and
918/// the fit stays interior. Convergence is the Newton decrement `½ U*ᵀΔ`.
919fn fit_penalized_multinomial_firth_fallback(
920 design: ArrayView2<'_, f64>,
921 y_one_hot: ArrayView2<'_, f64>,
922 penalty: ArrayView2<'_, f64>,
923 lambdas: ArrayView1<'_, f64>,
924 row_weights: Option<ArrayView1<'_, f64>>,
925 max_iter: usize,
926 tol: f64,
927 resume_from: Option<FirthResume<'_>>,
928) -> Result<MultinomialFitOutputs, EstimationError> {
929 use faer::Side;
930 use gam_linalg::faer_ndarray::{
931 FaerArrayView, array1_to_col_matmut, array2_to_matmut, factorize_symmetricwith_fallback,
932 };
933 use gam_linalg::matrix::FactorizedSystem;
934
935 let n_obs = design.nrows();
936 let p = design.ncols();
937 let k = y_one_hot.ncols();
938 let m = k - 1;
939 let d = p * m;
940
941 // Local softmax likelihood mirroring the caller's row weights, used to map the
942 // fitted η back to probabilities.
943 let mut likelihood = MultinomialLogitLikelihood::with_classes(k)?;
944 if let Some(w) = row_weights.as_ref() {
945 likelihood = likelihood.with_row_weights(w.to_owned())?;
946 }
947 let weight = |row: usize| -> f64 { row_weights.as_ref().map_or(1.0, |w| w[row]) };
948
949 let tol_eff = if tol.is_finite() && tol > 0.0 {
950 tol
951 } else {
952 1e-8
953 };
954
955 // Probabilities (N, K), active classes 0..M then the pinned reference at M.
956 let probs_at = |beta: &Array2<f64>| -> Array2<f64> {
957 let eta = design.dot(beta);
958 likelihood.probabilities(eta.view())
959 };
960
961 // Coupled softmax Fisher information I (d×d), block-ordered θ[a·P+i] = β[i,a].
962 let assemble_info = |probs: &Array2<f64>| -> Array2<f64> {
963 let mut info = Array2::<f64>::zeros((d, d));
964 for row in 0..n_obs {
965 let w = weight(row);
966 if w == 0.0 {
967 continue;
968 }
969 for a in 0..m {
970 let pa = probs[[row, a]];
971 let ao = a * p;
972 for b in 0..m {
973 let pb = probs[[row, b]];
974 let wab = w * (if a == b { pa - pa * pb } else { -pa * pb });
975 if wab == 0.0 {
976 continue;
977 }
978 let bo = b * p;
979 for i in 0..p {
980 let xi = design[[row, i]];
981 if xi == 0.0 {
982 continue;
983 }
984 let cc = wab * xi;
985 for j in 0..p {
986 info[[ao + i, bo + j]] += cc * design[[row, j]];
987 }
988 }
989 }
990 }
991 }
992 info
993 };
994
995 // Factor a symmetric matrix (with escalating ridge only if it is not SPD) and
996 // return its inverse and log-determinant.
997 //
998 // The ridge ladder is a standard relative-jitter Cholesky recovery, not a
999 // tuned knob: (a) the base jitter is scaled to the matrix by `max_diag`
1000 // (`max_diag · ε` with ε at the double-precision Cholesky floor ~1e-10) so it
1001 // is invariant to the overall scale of the Fisher information, falling back
1002 // to an absolute floor only when the diagonal is degenerate; (b) it is tried
1003 // first at ridge 0 so an already-SPD matrix is factored unperturbed; (c) it
1004 // grows geometrically (×4) to span the ~120 dB from the base jitter to O(1)
1005 // in a bounded number of steps; (d) the attempt count is capped so a
1006 // genuinely singular information (e.g. an exactly rank-deficient Fisher block)
1007 // surfaces as an explicit error rather than an unbounded loop.
1008 let invert_spd = |mat: &Array2<f64>,
1009 context: &str|
1010 -> Result<(Array2<f64>, f64), EstimationError> {
1011 let max_diag = (0..d).fold(0.0_f64, |acc, i| acc.max(mat[[i, i]].abs()));
1012 let base = if max_diag.is_finite() && max_diag > 0.0 {
1013 max_diag * 1e-10
1014 } else {
1015 1e-10
1016 };
1017 let mut ridge = 0.0_f64;
1018 for _ in 0..=60 {
1019 let mut ridged = mat.clone();
1020 if ridge > 0.0 {
1021 for i in 0..d {
1022 ridged[[i, i]] += ridge;
1023 }
1024 }
1025 if let Ok(factor) =
1026 factorize_symmetricwith_fallback(FaerArrayView::new(&ridged).as_ref(), Side::Lower)
1027 {
1028 let logdet = factor.logdet();
1029 if logdet.is_finite() {
1030 let mut rhs = Array2::<f64>::eye(d);
1031 {
1032 let v = array2_to_matmut(&mut rhs);
1033 factor.solve_in_place(v);
1034 }
1035 if rhs.iter().all(|x| x.is_finite()) {
1036 let mut inv = Array2::<f64>::zeros((d, d));
1037 for i in 0..d {
1038 for j in 0..d {
1039 inv[[i, j]] = 0.5 * (rhs[[i, j]] + rhs[[j, i]]);
1040 }
1041 }
1042 return Ok((inv, logdet));
1043 }
1044 }
1045 }
1046 ridge = if ridge > 0.0 { ridge * 4.0 } else { base };
1047 }
1048 Err(EstimationError::InvalidInput(format!(
1049 "multinomial Firth fallback: {context} not invertible (max_diag={max_diag:.3e})"
1050 )))
1051 };
1052
1053 // SPD log-determinant only (no ridge): used by the backtracking line search to
1054 // reject any candidate that pushes a fitted probability to the simplex
1055 // boundary (where I loses positive-definiteness and the Firth term → −∞).
1056 let spd_logdet = |mat: &Array2<f64>| -> Option<f64> {
1057 factorize_symmetricwith_fallback(FaerArrayView::new(mat).as_ref(), Side::Lower)
1058 .ok()
1059 .map(|factor| factor.logdet())
1060 .filter(|ld| ld.is_finite())
1061 };
1062
1063 // Penalized Firth objective ℓ* (MAXIMIZED), given probabilities, β, and the
1064 // precomputed log det I(β).
1065 let objective = |probs: &Array2<f64>, beta: &Array2<f64>, logdet_info: f64| -> f64 {
1066 let mut ll = 0.0_f64;
1067 for row in 0..n_obs {
1068 let w = weight(row);
1069 if w == 0.0 {
1070 continue;
1071 }
1072 for c in 0..k {
1073 let ycn = y_one_hot[[row, c]];
1074 if ycn != 0.0 {
1075 ll += w * ycn * probs[[row, c]].max(f64::MIN_POSITIVE).ln();
1076 }
1077 }
1078 }
1079 // #2344: equivariant per-class penalty ½·Σ_{a,b} A[a,b]·β_aᵀSβ_b —
1080 // the same metric the shared vector-GLM engine applies, so the Firth
1081 // arm optimizes the identical reference-free objective.
1082 let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1083 let mut pen = 0.0_f64;
1084 for a in 0..m {
1085 let bcol = beta.column(a);
1086 for b in 0..m {
1087 let coef = a_mat[[a, b]];
1088 if coef != 0.0 {
1089 let sbeta = penalty.dot(&beta.column(b));
1090 pen += 0.5 * coef * bcol.dot(&sbeta);
1091 }
1092 }
1093 }
1094 ll - pen + 0.5 * logdet_info
1095 };
1096
1097 // Firth-adjusted penalized score U* (length d, block-ordered).
1098 let firth_score =
1099 |probs: &Array2<f64>, beta: &Array2<f64>, iinv: &Array2<f64>| -> Array1<f64> {
1100 let mut u = Array1::<f64>::zeros(d);
1101 let mut xn = vec![0.0_f64; p];
1102 let mut pa = vec![0.0_f64; m];
1103 let mut q = vec![0.0_f64; m * m];
1104 for row in 0..n_obs {
1105 let w = weight(row);
1106 if w == 0.0 {
1107 continue;
1108 }
1109 for i in 0..p {
1110 xn[i] = design[[row, i]];
1111 }
1112 for a in 0..m {
1113 pa[a] = probs[[row, a]];
1114 }
1115 // Data score: U[(a,i)] += w x_{ni} (y_{na} − p_{na}).
1116 for a in 0..m {
1117 let resid = y_one_hot[[row, a]] - pa[a];
1118 let ao = a * p;
1119 for i in 0..p {
1120 u[ao + i] += w * xn[i] * resid;
1121 }
1122 }
1123 // Per-row information hat Q_{ab} = x_nᵀ [I⁻¹]_{(a,b)} x_n.
1124 for a in 0..m {
1125 let ao = a * p;
1126 for b in 0..m {
1127 let bo = b * p;
1128 let mut s = 0.0_f64;
1129 for i in 0..p {
1130 let xi = xn[i];
1131 if xi == 0.0 {
1132 continue;
1133 }
1134 let mut inner = 0.0_f64;
1135 for j in 0..p {
1136 inner += iinv[[ao + i, bo + j]] * xn[j];
1137 }
1138 s += xi * inner;
1139 }
1140 q[a * m + b] = s;
1141 }
1142 }
1143 // Firth adjustment: U[(c,s)] += ½ w x_{ns} h^c_n.
1144 for c in 0..m {
1145 let pc = pa[c];
1146 let mut h = 0.0_f64;
1147 for a in 0..m {
1148 for b in 0..m {
1149 let dab = if a == b { 1.0 } else { 0.0 };
1150 let dac = if a == c { 1.0 } else { 0.0 };
1151 let dbc = if b == c { 1.0 } else { 0.0 };
1152 let g =
1153 dab * pa[a] * (dac - pc) - pa[a] * pa[b] * (dac + dbc - 2.0 * pc);
1154 h += g * q[a * m + b];
1155 }
1156 }
1157 let co = c * p;
1158 for s in 0..p {
1159 u[co + s] += 0.5 * w * h * xn[s];
1160 }
1161 }
1162 }
1163 // Smoothing penalty gradient (#2344 equivariant metric):
1164 // U[(a,i)] −= Σ_b A[a,b]·(S β_b)_i.
1165 let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1166 for b in 0..m {
1167 let sbeta = penalty.dot(&beta.column(b));
1168 for a in 0..m {
1169 let coef = a_mat[[a, b]];
1170 if coef == 0.0 {
1171 continue;
1172 }
1173 let ao = a * p;
1174 for i in 0..p {
1175 u[ao + i] -= coef * sbeta[i];
1176 }
1177 }
1178 }
1179 u
1180 };
1181
1182 // Penalized Hessian H = I + A(λ) ⊗ S (#2344 equivariant metric; PSD sum
1183 // of rank-1 class projections, so H stays positive definite).
1184 let penalized_hessian = |info: &Array2<f64>| -> Array2<f64> {
1185 let mut h = info.clone();
1186 let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1187 for a in 0..m {
1188 for b in 0..m {
1189 let coef = a_mat[[a, b]];
1190 if coef == 0.0 {
1191 continue;
1192 }
1193 let (ao, bo) = (a * p, b * p);
1194 for i in 0..p {
1195 for j in 0..p {
1196 h[[ao + i, bo + j]] += coef * penalty[[i, j]];
1197 }
1198 }
1199 }
1200 }
1201 h
1202 };
1203
1204 // Solve H Δ = U* for the SPD penalized Hessian, ridge-escalating only on
1205 // factorization failure. Same relative-jitter Cholesky-recovery ladder as
1206 // `invert_spd` above (see its comment for the rationale); the base jitter is
1207 // one decade tighter (`max_diag · 1e-12`) because the penalized Hessian
1208 // solved here is better conditioned than the Fisher information inverted
1209 // there, so a smaller perturbation suffices before escalating.
1210 let solve_spd = |mat: &Array2<f64>,
1211 rhs: &Array1<f64>|
1212 -> Result<Array1<f64>, EstimationError> {
1213 let max_diag = (0..d).fold(0.0_f64, |acc, i| acc.max(mat[[i, i]].abs()));
1214 let base = if max_diag.is_finite() && max_diag > 0.0 {
1215 max_diag * 1e-12
1216 } else {
1217 1e-12
1218 };
1219 let mut ridge = 0.0_f64;
1220 for _ in 0..=60 {
1221 let mut ridged = mat.clone();
1222 if ridge > 0.0 {
1223 for i in 0..d {
1224 ridged[[i, i]] += ridge;
1225 }
1226 }
1227 if let Ok(factor) =
1228 factorize_symmetricwith_fallback(FaerArrayView::new(&ridged).as_ref(), Side::Lower)
1229 {
1230 let mut sol = rhs.clone();
1231 {
1232 let v = array1_to_col_matmut(&mut sol);
1233 factor.solve_in_place(v);
1234 }
1235 if sol.iter().all(|x| x.is_finite()) {
1236 return Ok(sol);
1237 }
1238 }
1239 ridge = if ridge > 0.0 { ridge * 4.0 } else { base };
1240 }
1241 Err(EstimationError::InvalidInput(
1242 "multinomial Firth fallback: penalized Hessian solve failed".to_string(),
1243 ))
1244 };
1245
1246 // ─────────────────────────── Firth Newton loop ────────────────────────────
1247 let (mut beta, completed_iterations) = match resume_from {
1248 Some(resume) => {
1249 if resume.coefficients.dim() != (p, m) {
1250 crate::bail_invalid_estim!(
1251 "multinomial Firth resume coefficient shape {:?} does not match P x (K-1) = {p}x{m}",
1252 resume.coefficients.dim(),
1253 );
1254 }
1255 (resume.coefficients.to_owned(), resume.completed_iterations)
1256 }
1257 None => (Array2::<f64>::zeros((p, m)), 0),
1258 };
1259 let mut iterations = completed_iterations;
1260 let mut stall_reason = FixedLambdaStallReason::IterationBudgetExhausted;
1261 let mut small_step_reached = false;
1262 for it in 0..max_iter {
1263 iterations = completed_iterations.checked_add(it + 1).ok_or_else(|| {
1264 EstimationError::InvalidInput(
1265 "multinomial Firth resume iteration count overflowed usize".to_string(),
1266 )
1267 })?;
1268 let probs = probs_at(&beta);
1269 let info = assemble_info(&probs);
1270 let (iinv, logdet_info) = invert_spd(&info, "Fisher information")?;
1271 let u = firth_score(&probs, &beta, &iinv);
1272 let hmat = penalized_hessian(&info);
1273 let step_vec = solve_spd(&hmat, &u)?;
1274
1275 // Newton decrement ½ U*ᵀ H⁻¹ U* = ½ U*ᵀ Δ (≥ 0, scale-aware stop).
1276 let decrement = u.dot(&step_vec);
1277 if 0.5 * decrement.abs() < tol_eff {
1278 break;
1279 }
1280
1281 // Δ as (P, M): delta[i, a] = step_vec[a·P + i].
1282 let mut delta = Array2::<f64>::zeros((p, m));
1283 for a in 0..m {
1284 let ao = a * p;
1285 for i in 0..p {
1286 delta[[i, a]] = step_vec[ao + i];
1287 }
1288 }
1289
1290 // Backtracking line search on ℓ* (ascent) via the shared `opt`
1291 // primitive: t₀ = 1, halving up to 60 trials. A candidate whose expected
1292 // information `I` is not SPD (boundary) is an INVALID trial (`Ok(None)`),
1293 // so the search contracts without consulting the acceptance test, keeping
1294 // the iterate interior. The ascent predicate `o1 ≥ o0 − 1e-12` is inlined
1295 // verbatim, so the accepted step is bit-for-bit the hand-rolled loop's.
1296 let o0 = objective(&probs, &beta, logdet_info);
1297 let accepted_step = match backtracking_line_search::<_, Infallible>(
1298 BacktrackConfig::default(),
1299 |step| {
1300 let cand = &beta + &(&delta * step);
1301 let cand_probs = probs_at(&cand);
1302 let cand_info = assemble_info(&cand_probs);
1303 Ok(spd_logdet(&cand_info)
1304 .map(|cand_logdet| (objective(&cand_probs, &cand, cand_logdet), cand)))
1305 },
1306 |_step, o1| o1 >= o0 - 1e-12,
1307 ) {
1308 Ok(result) => result,
1309 Err(never) => match never {},
1310 };
1311 let Some(accepted_step) = accepted_step else {
1312 // Backtracking exhausted 60 halvings without an admissible ascent
1313 // step. This is convergence ONLY if the iterate is already first-order
1314 // stationary; a line-search stall at a non-stationary point is a
1315 // solver failure and must be reported as such, never papered over as
1316 // `converged = true` (#2066 — SPEC: do not report a non-converged
1317 // iterate as success).
1318 //
1319 // The verdict is the loop's OWN stationarity test — the Newton
1320 // decrement `½·Uᵀ H⁻¹ U` against `tol_eff`, the same criterion the top
1321 // of the loop uses to break as converged. A true interior mode never
1322 // reaches this branch: an infinitesimal step (`step → 0`) leaves the
1323 // iterate SPD with `o1 ≈ o0`, so it is accepted; a numerically flat
1324 // mode is caught by the `max_step` test below after that accepted
1325 // tiny step. Reaching here therefore means Newton still sees a
1326 // meaningful ascent direction it cannot realize (boundary / near-
1327 // singular Fisher information), i.e. a genuine stall → not converged.
1328 stall_reason = FixedLambdaStallReason::LineSearchExhausted;
1329 break;
1330 };
1331
1332 let step = accepted_step.step;
1333 beta = accepted_step.payload;
1334 let max_step = step * delta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1335 let scale = 1.0 + beta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1336 if max_step < tol_eff * scale {
1337 small_step_reached = true;
1338 break;
1339 }
1340 }
1341
1342 // ─────────────────────────── final quantities ─────────────────────────────
1343 for (idx, &v) in beta.iter().enumerate() {
1344 if !v.is_finite() {
1345 crate::bail_invalid_estim!(
1346 "multinomial Firth fallback: non-finite coefficient at flat index {idx} = {v}"
1347 );
1348 }
1349 }
1350 let coefficients_active = beta;
1351
1352 let mut log_likelihood = 0.0_f64;
1353 let probs = probs_at(&coefficients_active);
1354 for row in 0..n_obs {
1355 let w = weight(row);
1356 for c in 0..k {
1357 let ycn = y_one_hot[[row, c]];
1358 if ycn != 0.0 {
1359 log_likelihood += w * ycn * probs[[row, c]].max(f64::MIN_POSITIVE).ln();
1360 }
1361 }
1362 }
1363
1364 // #2344 equivariant metric: the reported penalty term matches the
1365 // objective the solve optimized.
1366 let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1367 let mut penalty_term = 0.0_f64;
1368 for a in 0..m {
1369 let beta_col = coefficients_active.column(a);
1370 for b in 0..m {
1371 let coef = a_mat[[a, b]];
1372 if coef != 0.0 {
1373 let sbeta = penalty.dot(&coefficients_active.column(b));
1374 penalty_term += 0.5 * coef * beta_col.dot(&sbeta);
1375 }
1376 }
1377 }
1378
1379 // Recompute the Firth score and Newton decrement AT the final accepted
1380 // iterate. A tiny backtracked coefficient step is not itself stationarity:
1381 // only this fresh first-order certificate may authorize construction of a
1382 // fit or its covariance.
1383 let info = assemble_info(&probs);
1384 let (information_inverse, final_logdet_info) = invert_spd(&info, "final Fisher information")?;
1385 let final_score = firth_score(&probs, &coefficients_active, &information_inverse);
1386 let hmat = penalized_hessian(&info);
1387 let final_step = solve_spd(&hmat, &final_score)?;
1388 let final_decrement = 0.5 * final_score.dot(&final_step).abs();
1389 if !(final_decrement.is_finite() && final_decrement < tol_eff) {
1390 if small_step_reached {
1391 stall_reason = FixedLambdaStallReason::StationarityCertificateFailed;
1392 }
1393 // SPEC: a fit object must only ever come from a converged optimization.
1394 // A Firth refit that exhausted its budget (or stalled its line search at
1395 // a non-stationary point) is the typed error carrying its evidence — the
1396 // covariance below is never computed for an uncertified iterate.
1397 let checkpoint = FixedLambdaCheckpoint::new(
1398 FixedLambdaSolverStage::MultinomialFirth,
1399 coefficients_active.iter().copied().collect(),
1400 p,
1401 m,
1402 iterations,
1403 )
1404 .map_err(|reason| {
1405 EstimationError::InvalidInput(format!(
1406 "multinomial Firth fallback produced an invalid internal checkpoint: {reason}"
1407 ))
1408 })?;
1409 return Err(EstimationError::FixedLambdaNewtonDidNotConverge {
1410 context: "fit_penalized_multinomial (Firth/Jeffreys separation refit)".to_string(),
1411 reason: stall_reason,
1412 objective_value: -objective(&probs, &coefficients_active, final_logdet_info),
1413 stationarity: FixedLambdaStationarityEvidence {
1414 kind: FixedLambdaResidualKind::NewtonDecrement,
1415 residual: final_decrement,
1416 bound: tol_eff,
1417 },
1418 checkpoint,
1419 });
1420 }
1421
1422 // Laplace covariance H⁻¹ at the converged mode (block-ordered θ[a·P+i]).
1423 // A covariance that cannot be factored at a certified mode is a hard error,
1424 // never a silent zero matrix (a zero covariance is a false certainty claim).
1425 let (coefficient_covariance, _) = invert_spd(&hmat, "penalized Hessian covariance")?;
1426
1427 Ok(MultinomialFitOutputs {
1428 coefficients_active,
1429 fitted_probabilities: probs,
1430 iterations,
1431 penalized_neg_log_likelihood: -log_likelihood + penalty_term,
1432 deviance: -2.0 * log_likelihood,
1433 coefficient_covariance,
1434 })
1435}
1436
1437// ---------------------------------------------------------------------------
1438// Formula-driven multinomial pipeline
1439// ---------------------------------------------------------------------------
1440//
1441// Slice A of the multinomial integration: a single public entry that takes
1442// a parsed `EncodedDataset`, a Wilkinson-style formula, and a uniform initial
1443// smoothing parameter, then runs the full
1444//
1445// parse → termspec → design (X, S blocks) → one-hot Y → REML λ-selection
1446//
1447// pipeline. `fit_penalized_multinomial_formula` drives the outer REML/LAML
1448// loop (via the custom-family path) to select an independent λ per (class,
1449// term); `init_lambda` (default 1.0) is only the warm-start seed for every
1450// block. The reference class is the last level of the categorical response
1451// column as recorded in the dataset schema.
1452
1453/// Saved-model payload for a multinomial fit driven by a Wilkinson formula.
1454///
1455/// This is what the FFI returns to Python. It carries everything the Python
1456/// `MultinomialModel.predict` path needs to evaluate `softmax(X_new · β)` on
1457/// fresh data using the *training* basis / penalty structure (no refit on
1458/// predict, no re-derivation of class levels).
1459#[derive(Debug, Clone, Serialize, Deserialize)]
1460#[serde(deny_unknown_fields)]
1461pub struct MultinomialSavedModel {
1462 /// The training formula, verbatim. Stored so Python's `summary()` and
1463 /// any round-trip persistence path can echo what was fit.
1464 pub formula: String,
1465 /// Names of the *training* response levels in canonical order. The last
1466 /// entry is the reference class (η = 0); the first `K - 1` carry the
1467 /// active linear-predictor blocks. Class permutations are forbidden:
1468 /// this list is fixed at fit time and predictions emit columns in the
1469 /// same order.
1470 pub class_levels: Vec<String>,
1471 /// Index of the reference class within `class_levels` — currently always
1472 /// `class_levels.len() - 1`, exposed as a field so future "user-pinned
1473 /// reference" gauges (e.g. `family='multinomial', reference='setosa'`)
1474 /// can land without changing the on-disk shape.
1475 pub reference_class_index: usize,
1476 /// Resolved term-collection spec used to build `X` at fit time. Replayed
1477 /// on predict via [`gam_terms::smooth::build_term_collection_design`].
1478 pub resolved_termspec: TermCollectionSpec,
1479 /// Active-class coefficient block, shape `(P, K-1)`. Column `a` is the
1480 /// coefficient vector for class `class_levels[a]`. Stored flat in
1481 /// row-major order to keep the serde payload self-describing.
1482 pub coefficients_flat: Vec<f64>,
1483 /// `P` — coefficient count per active class. Matches the column count of
1484 /// the design matrix the saved `resolved_termspec` produces.
1485 pub p_per_class: usize,
1486 /// Number of active classes (`K - 1`).
1487 pub n_active_classes: usize,
1488 /// Original training column headers, in dataset-column order. Needed at
1489 /// predict time so the FFI can align a fresh `Dataset` to the training
1490 /// schema before evaluating the basis.
1491 pub training_headers: Vec<String>,
1492 /// Container type of the training table. `"unknown"` is the explicit value
1493 /// for Rust/CLI callers without a typed table container; the field is always
1494 /// present so persistence never invents presentation state while loading.
1495 pub training_table_kind: String,
1496 /// REML/LAML-selected smoothing parameters, one per `(active class, smooth
1497 /// term)`, flattened in block-major order: all of class 0's per-term λ,
1498 /// then class 1's, and so on. Per-term penalties (#561) mean each active
1499 /// class block selects an *independent* λ for every smooth term, so this
1500 /// vector has length `Σ_a (#terms in class a)` = `(K − 1) · #terms`. Use
1501 /// [`MultinomialSavedModel::lambdas_per_block`] to segment it by class. An
1502 /// unpenalized model (no smooth terms) yields an empty vector.
1503 pub lambdas: Vec<f64>,
1504 /// Number of smoothing parameters (smooth terms) in each active class
1505 /// block, parallel to `class_levels[0..K-1]`. Segments the flat `lambdas`
1506 /// vector: class `a`'s λ are `lambdas[Σ_{b<a} lambdas_per_block[b] ..][..
1507 /// lambdas_per_block[a]]`. Every entry is identical in the shared-design
1508 /// architecture (all classes share the same term structure), but it is
1509 /// stored explicitly so consumers never have to assume that.
1510 pub lambdas_per_block: Vec<usize>,
1511 /// Newton iterations executed; recorded for the summary report.
1512 pub iterations: usize,
1513 /// Penalized negative log-likelihood at the returned `β̂`.
1514 pub penalized_neg_log_likelihood: f64,
1515 /// Unpenalized deviance `−2 log L(β̂)`.
1516 pub deviance: f64,
1517 /// Per-active-class effective degrees of freedom (hat-matrix trace),
1518 /// length `K - 1`. Populated when the REML driver reports an
1519 /// inference block; falls back to `None` for the legacy fixed-λ path.
1520 #[serde(default)]
1521 pub edf_per_class: Option<Vec<f64>>,
1522 /// Per-PENALTY effective degrees of freedom, one entry per smoothing
1523 /// parameter (length `== lambdas.len()`), aligned block-major with the flat
1524 /// [`Self::lambdas`] / [`Self::lambdas_per_block`] layout. Each entry is the
1525 /// penalty-block trace EDF `rank(S_k) − λ_k·tr(H⁻¹ S_k)`, clamped to
1526 /// `[0, rank(S_k)]`. This is the per-(class, term, penalty) resolution that
1527 /// the per-class [`Self::edf_per_class`] SUM deliberately hides: only the
1528 /// per-penalty vector reveals whether an individual smooth collapsed onto its
1529 /// polynomial null space (its wiggliness λ driven to the λ-cap), which a
1530 /// per-class total cannot show. Populated whenever the REML driver reports an
1531 /// inference block; `None` on the legacy fixed-λ path or when the trace
1532 /// channel is mis-shaped. Unlike `edf_per_class`, the entries do NOT sum to
1533 /// the model EDF when several penalties share one coefficient range (a
1534 /// double-penalty smooth has `Σ_k rank(S_k) > p_per_class`).
1535 #[serde(default)]
1536 pub edf_per_penalty: Option<Vec<f64>>,
1537 /// Joint posterior coefficient covariance `H⁻¹` (#1101), block-ordered to
1538 /// match the stacked active-class coefficient vector `β = [β_0; …; β_{K-2}]`
1539 /// (class `a`'s `P` coefficients occupy rows/cols `a·P .. (a+1)·P`). This is
1540 /// the Laplace covariance the REML driver already computes from the factored
1541 /// penalized Hessian; storing it makes posterior-mean prediction and its
1542 /// integrated uncertainty well-defined. Flattened row-major over the
1543 /// `(P·M)×(P·M)` matrix. This is required by the versioned persistence
1544 /// schema: a payload without covariance is not a usable multinomial model.
1545 pub coefficient_covariance_flat: Vec<f64>,
1546 /// Joint coefficient-space influence matrix `F = H⁻¹ X'WX` (#1101),
1547 /// block-ordered identically to [`Self::coefficient_covariance_flat`].
1548 /// Its per-term diagonal block trace is the term's effective degrees of
1549 /// freedom and its `tr(F_jj)²/tr(F_jj²)` the Wood reference d.f., feeding
1550 /// the rank-truncated Wald smooth-term test in `summary()`. Flattened
1551 /// row-major over the `(P·M)×(P·M)` matrix. `None` when unavailable.
1552 #[serde(default)]
1553 pub coefficient_influence_flat: Option<Vec<f64>>,
1554 /// Per-(active class, smooth term) coefficient column range and unpenalized
1555 /// nullspace dimension within the `P`-wide class block (#1101). Parallel to
1556 /// the smooth terms the design produced; replicated across classes by the
1557 /// shared-design architecture. Drives the Wald smooth-term table in
1558 /// `summary()`. Empty for a wholly parametric (no-smooth) model.
1559 #[serde(default)]
1560 pub smooth_term_spans: Vec<MultinomialSmoothTermSpan>,
1561 /// One descriptive label per *penalty component* within a single active-class
1562 /// block, parallel to that block's λ slice (i.e. length
1563 /// `lambdas_per_block[0]`). The Marra–Wood double penalty (and tensor /
1564 /// operator smooths) emit **more than one** penalty component — hence more
1565 /// than one λ — per smooth term, so this is NOT 1:1 with
1566 /// [`Self::smooth_term_spans`]: a single `s(x)` term contributes a primary
1567 /// wiggliness λ labelled `s(x)` and a null-space shrinkage λ labelled
1568 /// `s(x) [null space]`. The summary renderer pairs `lambdas` with these
1569 /// labels component-for-component so no λ is ever dropped (#1544). Built from
1570 /// the per-component term name + penalty role at fit time; empty only for a
1571 /// wholly parametric model.
1572 pub lambda_labels: Vec<String>,
1573}
1574
1575/// One smooth term's coefficient span within a class block, plus its
1576/// unpenalized nullspace dimension and a display label (#1101). The Wald
1577/// smooth-significance test in `summary()` slices the joint covariance /
1578/// influence at `a·P + col_start .. a·P + col_end` for active class `a`.
1579#[derive(Debug, Clone, Serialize, Deserialize)]
1580pub struct MultinomialSmoothTermSpan {
1581 /// Human-readable term label (the smooth's formula token), for the table.
1582 pub label: String,
1583 /// Start column of the term within the per-class `P`-wide coefficient block.
1584 pub col_start: usize,
1585 /// End column (exclusive) of the term within the per-class block.
1586 pub col_end: usize,
1587 /// Leading unpenalized (polynomial nullspace) dimension within the term.
1588 pub nullspace_dim: usize,
1589}
1590
1591/// Descriptive label for one penalty *component* (one λ) within a class block,
1592/// for the `summary()` per-class λ rollup (#1544). A smooth term can emit
1593/// several penalty components — the Marra–Wood double penalty splits `s(x)`
1594/// into a primary wiggliness penalty and a null-space shrinkage penalty, and
1595/// tensor / operator smooths emit a component per margin / differential
1596/// operator — each with its own independently-selected λ. The label is the
1597/// term name (from `PenaltyBlockInfo::termname`) plus a role suffix derived
1598/// from the penalty's [`PenaltySource`], so each λ in the summary names both
1599/// the term it smooths and the role it plays. `pen_idx` is the global penalty
1600/// index, used only as a last-resort fallback label.
1601fn penalty_component_label(info: Option<&PenaltyBlockInfo>, pen_idx: usize) -> String {
1602 use gam_terms::basis::PenaltySource;
1603 let term = info
1604 .and_then(|i| i.termname.clone())
1605 .unwrap_or_else(|| format!("s{pen_idx}"));
1606 let role = match info.map(|i| &i.penalty.source) {
1607 // The primary wiggliness penalty is the term's "main" λ; show the bare
1608 // term name so the common single-penalty case reads cleanly.
1609 Some(PenaltySource::Primary) | None => None,
1610 Some(PenaltySource::DoublePenaltyNullspace) => Some("null space".to_string()),
1611 Some(PenaltySource::OperatorMass) => Some("mass".to_string()),
1612 Some(PenaltySource::OperatorTension) => Some("tension".to_string()),
1613 Some(PenaltySource::OperatorStiffness) => Some("stiffness".to_string()),
1614 Some(PenaltySource::OperatorRelevance { axis }) => Some(format!("axis {axis}")),
1615 Some(PenaltySource::TensorMarginal { dim }) => Some(format!("margin {dim}")),
1616 Some(PenaltySource::TensorSeparable { penalized_margins }) => {
1617 Some(format!("separable {penalized_margins:?}"))
1618 }
1619 Some(PenaltySource::TensorGlobalRidge) => Some("ridge".to_string()),
1620 Some(PenaltySource::Other(s)) => Some(s.clone()),
1621 };
1622 match role {
1623 Some(role) => format!("{term} [{role}]"),
1624 None => term,
1625 }
1626}
1627
1628impl MultinomialSavedModel {
1629 pub fn validate(&self) -> Result<(), EstimationError> {
1630 if self.p_per_class == 0 || self.n_active_classes == 0 {
1631 crate::bail_invalid_estim!(
1632 "multinomial saved model dimensions must be nonzero, got P={} and K-1={}",
1633 self.p_per_class,
1634 self.n_active_classes,
1635 );
1636 }
1637 if self.class_levels.len() != self.n_active_classes + 1 {
1638 crate::bail_invalid_estim!(
1639 "multinomial saved model has {} class levels but K-1={}",
1640 self.class_levels.len(),
1641 self.n_active_classes,
1642 );
1643 }
1644 if self.reference_class_index != self.n_active_classes {
1645 crate::bail_invalid_estim!(
1646 "multinomial saved reference index {} does not equal the final class index {}",
1647 self.reference_class_index,
1648 self.n_active_classes,
1649 );
1650 }
1651 let d = self
1652 .p_per_class
1653 .checked_mul(self.n_active_classes)
1654 .ok_or_else(|| {
1655 EstimationError::InvalidInput(
1656 "multinomial saved coefficient dimension overflowed usize".to_string(),
1657 )
1658 })?;
1659 if self.coefficients_flat.len() != d {
1660 crate::bail_invalid_estim!(
1661 "multinomial saved model has {} coefficient values, expected {d}",
1662 self.coefficients_flat.len(),
1663 );
1664 }
1665 if self.training_table_kind.trim().is_empty() {
1666 crate::bail_invalid_estim!(
1667 "multinomial saved model training_table_kind must be non-empty"
1668 );
1669 }
1670 if self.lambdas_per_block.len() != self.n_active_classes {
1671 crate::bail_invalid_estim!(
1672 "multinomial saved model has {} lambda blocks, expected {}",
1673 self.lambdas_per_block.len(),
1674 self.n_active_classes,
1675 );
1676 }
1677 let lambda_count = self
1678 .lambdas_per_block
1679 .iter()
1680 .try_fold(0usize, |total, &count| total.checked_add(count))
1681 .ok_or_else(|| {
1682 EstimationError::InvalidInput(
1683 "multinomial saved lambda count overflowed usize".to_string(),
1684 )
1685 })?;
1686 if lambda_count != self.lambdas.len() {
1687 crate::bail_invalid_estim!(
1688 "multinomial saved model has {} lambdas but its blocks require {lambda_count}",
1689 self.lambdas.len(),
1690 );
1691 }
1692 if self
1693 .lambdas_per_block
1694 .iter()
1695 .any(|&count| count != self.lambda_labels.len())
1696 {
1697 crate::bail_invalid_estim!(
1698 "multinomial saved model has {} lambda labels but block sizes {:?}",
1699 self.lambda_labels.len(),
1700 self.lambdas_per_block,
1701 );
1702 }
1703 if self.lambda_labels.iter().any(|label| label.trim().is_empty()) {
1704 crate::bail_invalid_estim!("multinomial saved model lambda labels must be non-empty");
1705 }
1706 let covariance_len = d.checked_mul(d).ok_or_else(|| {
1707 EstimationError::InvalidInput(
1708 "multinomial saved covariance dimension overflowed usize".to_string(),
1709 )
1710 })?;
1711 if self.coefficient_covariance_flat.len() != covariance_len {
1712 crate::bail_invalid_estim!(
1713 "multinomial saved model has {} covariance values, expected {covariance_len}",
1714 self.coefficient_covariance_flat.len(),
1715 );
1716 }
1717 if let Some((index, value)) = self
1718 .coefficients_flat
1719 .iter()
1720 .chain(self.coefficient_covariance_flat.iter())
1721 .copied()
1722 .enumerate()
1723 .find(|(_, value)| !value.is_finite())
1724 {
1725 crate::bail_invalid_estim!(
1726 "multinomial saved numeric payload is non-finite at combined index {index}: {value}"
1727 );
1728 }
1729 Ok(())
1730 }
1731
1732 /// Active-class coefficient block as an `(P, K-1)` `ndarray` view.
1733 pub fn coefficients_active(&self) -> Result<Array2<f64>, EstimationError> {
1734 Array2::from_shape_vec(
1735 (self.p_per_class, self.n_active_classes),
1736 self.coefficients_flat.clone(),
1737 )
1738 .map_err(|error| {
1739 EstimationError::InvalidInput(format!(
1740 "multinomial saved coefficient payload is inconsistent with P x (K-1): {error}"
1741 ))
1742 })
1743 }
1744
1745 /// Reconstruct the joint posterior covariance `H⁻¹` as a `(P·M)×(P·M)`
1746 /// `ndarray`, block-ordered to match the stacked coefficient vector
1747 /// `θ[a·P + i] = β[i, a]` (#1101).
1748 pub fn coefficient_covariance(&self) -> Result<Array2<f64>, EstimationError> {
1749 let d = self
1750 .p_per_class
1751 .checked_mul(self.n_active_classes)
1752 .ok_or_else(|| {
1753 EstimationError::InvalidInput(
1754 "multinomial saved covariance dimension overflowed usize".to_string(),
1755 )
1756 })?;
1757 Array2::from_shape_vec((d, d), self.coefficient_covariance_flat.clone()).map_err(|error| {
1758 EstimationError::InvalidInput(format!(
1759 "multinomial saved covariance payload is inconsistent with (P*(K-1)) squared: {error}"
1760 ))
1761 })
1762 }
1763
1764 /// Reconstruct the joint influence matrix `F = H⁻¹ X'WX` as a
1765 /// `(P·M)×(P·M)` `ndarray`, block-ordered like
1766 /// [`Self::coefficient_covariance`] (#1101). `None` when unavailable.
1767 pub fn coefficient_influence(&self) -> Option<Array2<f64>> {
1768 let d = self.p_per_class.checked_mul(self.n_active_classes)?;
1769 let flat = self.coefficient_influence_flat.as_ref()?;
1770 Array2::from_shape_vec((d, d), flat.clone()).ok()
1771 }
1772
1773 /// Default posterior-mean class probabilities. This integrates
1774 /// `softmax(eta)` under the per-row Gaussian predictor posterior rather than
1775 /// evaluating softmax at the coefficient mode.
1776 pub fn predict_probabilities(
1777 &self,
1778 x_new: ArrayView2<'_, f64>,
1779 ) -> Result<Array2<f64>, EstimationError> {
1780 self.predict_probabilities_with_se(x_new)
1781 .map(|(mean, _)| mean)
1782 }
1783
1784 /// Posterior-mean class probabilities and integrated marginal standard
1785 /// deviations at fresh design rows.
1786 pub fn predict_probabilities_with_se(
1787 &self,
1788 x_new: ArrayView2<'_, f64>,
1789 ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
1790 self.predict_probabilities_with_se_and_control(
1791 x_new,
1792 &MultinomialPosteriorIntegrationControl::default(),
1793 )
1794 }
1795
1796 pub fn predict_probabilities_with_se_and_control(
1797 &self,
1798 x_new: ArrayView2<'_, f64>,
1799 control: &MultinomialPosteriorIntegrationControl,
1800 ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
1801 let coefficients = self.coefficients_active()?;
1802 let covariance = self.coefficient_covariance()?;
1803 let moments = integrate_multinomial_design_moments(
1804 coefficients.view(),
1805 covariance.view(),
1806 x_new,
1807 control,
1808 )?;
1809 Ok((moments.class_mean, moments.class_standard_deviation))
1810 }
1811
1812 /// Wood (2013) rank-truncated Wald smooth-significance test per
1813 /// `(active class, smooth term)` (#1101), reusing the exact scalar-summary
1814 /// kernel [`gam_terms::inference::smooth_test::wood_smooth_test`]. For active
1815 /// class `a` and term span `[c0, c1)` within the class block, the global
1816 /// coefficient range is `a·P + c0 .. a·P + c1`; the joint covariance and
1817 /// influence are sliced there. The term EDF is the influence-block trace
1818 /// `tr(F_jj)` (when present) and the reference d.f. uses `tr(F_jj)²/tr(F_jj²)`,
1819 /// exactly as the scalar path. The multinomial softmax is a known-dispersion
1820 /// family, so the χ²_{ref_df} branch applies. Returns one row per
1821 /// `(class label, term label, edf, ref_df, statistic, p_value)`; empty when
1822 /// no covariance/smooth terms are available.
1823 pub fn smooth_significance(&self) -> Vec<MultinomialSmoothSignificance> {
1824 let mut out = Vec::new();
1825 let p = self.p_per_class;
1826 let m = self.n_active_classes;
1827 let Ok(cov) = self.coefficient_covariance() else {
1828 return out;
1829 };
1830 if self.smooth_term_spans.is_empty() {
1831 return out;
1832 }
1833 let Ok(beta) = self.coefficients_active() else {
1834 return out;
1835 };
1836 // Block-ordered θ = [β_0; …; β_{M-1}], θ[a·P + i] = β[i, a].
1837 let d = p * m;
1838 let mut theta = Array1::<f64>::zeros(d);
1839 for a in 0..m {
1840 for i in 0..p {
1841 theta[a * p + i] = beta[[i, a]];
1842 }
1843 }
1844 let influence = self.coefficient_influence();
1845 for a in 0..m {
1846 let class_label = self
1847 .class_levels
1848 .get(a)
1849 .cloned()
1850 .unwrap_or_else(|| format!("class{a}"));
1851 let base = a * p;
1852 for span in &self.smooth_term_spans {
1853 if span.col_end > p {
1854 continue;
1855 }
1856 let start = base + span.col_start;
1857 let end = base + span.col_end;
1858 // Term EDF = tr(F_jj); without an influence matrix fall back to
1859 // the block coefficient count (full-rank Wald on the span).
1860 let block_len = (span.col_end - span.col_start) as f64;
1861 let edf = influence
1862 .as_ref()
1863 .map(|f| (start..end).map(|i| f[[i, i]]).sum::<f64>())
1864 .filter(|v| v.is_finite() && *v > 0.0)
1865 .unwrap_or(block_len);
1866 let result = gam_terms::inference::smooth_test::wood_smooth_test(
1867 gam_terms::inference::smooth_test::SmoothTestInput {
1868 beta: theta.view(),
1869 covariance: &cov,
1870 influence_matrix: influence.as_ref(),
1871 whitening_gram: None,
1872 coeff_range: start..end,
1873 edf,
1874 nullspace_dim: span.nullspace_dim,
1875 residual_df: None,
1876 scale: gam_terms::inference::smooth_test::SmoothTestScale::Known,
1877 },
1878 );
1879 if let Some(res) = result {
1880 out.push(MultinomialSmoothSignificance {
1881 class_label: class_label.clone(),
1882 term_label: span.label.clone(),
1883 edf,
1884 ref_df: res.ref_df,
1885 statistic: res.statistic,
1886 p_value: res.p_value,
1887 });
1888 }
1889 }
1890 }
1891 out
1892 }
1893
1894 /// Draw `n_draws` posterior-predictive replicate class assignments at fresh
1895 /// rows (#1101). Each draw independently samples every row's class from
1896 /// `Categorical(p_row)` with `p = E[softmax(eta) | data]`, so coefficient
1897 /// uncertainty is integrated before adding categorical observation noise.
1898 /// The returned `(n_draws, N)` matrix holds class
1899 /// INDICES `0..K`, aligned to [`Self::class_levels`]. The draw stream is a
1900 /// `StdRng` seeded by `seed`, so `(x_new, n_draws, seed)` reproduce
1901 /// bit-identically — the engine for posterior-predictive checks and
1902 /// simulation-based calibration. `x_new` must have `self.p_per_class`
1903 /// columns (built from the same `resolved_termspec` as fit time).
1904 pub fn sample_replicate_classes(
1905 &self,
1906 x_new: ArrayView2<'_, f64>,
1907 n_draws: usize,
1908 seed: u64,
1909 ) -> Result<Array2<u32>, EstimationError> {
1910 use rand::{RngExt, SeedableRng};
1911 let probs = self.predict_probabilities(x_new)?;
1912 let n = probs.nrows();
1913 let k = probs.ncols();
1914 let mut out = Array2::<u32>::zeros((n_draws, n));
1915 let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
1916 for d in 0..n_draws {
1917 for row in 0..n {
1918 let u: f64 = rng.random::<f64>();
1919 // Inverse-CDF categorical draw over the K simplex weights.
1920 let mut acc = 0.0_f64;
1921 let mut chosen = k - 1; // numerical fallback = reference class
1922 for c in 0..k {
1923 acc += probs[[row, c]];
1924 if u < acc {
1925 chosen = c;
1926 break;
1927 }
1928 }
1929 out[[d, row]] = chosen as u32;
1930 }
1931 }
1932 Ok(out)
1933 }
1934}
1935
1936/// On-disk `model_class` discriminator for a persisted multinomial model. Kept
1937/// as a single constant so every producer / consumer of the envelope agrees on
1938/// the tag without a scattered string literal.
1939pub const MULTINOMIAL_MODEL_CLASS: &str = "multinomial";
1940/// Exact multinomial persistence schema. Version 2 requires the canonical
1941/// per-component lambda labels and training-table provenance; successful
1942/// deserialization therefore yields a complete current model without repair.
1943pub const MULTINOMIAL_MODEL_FORMAT_VERSION: u32 = 2;
1944
1945/// Round-trip persistence envelope for a fitted multinomial model. The
1946/// `model_class` discriminator lets a loader tell a multinomial payload apart
1947/// from the scalar `FittedModel` JSON before deserialising the whole struct.
1948///
1949/// This is the single definition of the multinomial on-disk format, shared by
1950/// the Python FFI (`fit_multinomial_formula` / `predict_multinomial_formula`)
1951/// and the `gam` CLI (`gam fit --family multinomial` / `gam predict`), so a
1952/// model persisted by one surface loads in the other.
1953#[derive(Debug, Clone, Serialize, Deserialize)]
1954#[serde(deny_unknown_fields)]
1955pub struct MultinomialModelEnvelope {
1956 pub model_class: String,
1957 pub format_version: u32,
1958 pub saved: MultinomialSavedModel,
1959}
1960
1961impl MultinomialModelEnvelope {
1962 /// Wrap a fitted model with the canonical `model_class` tag.
1963 pub fn new(saved: MultinomialSavedModel) -> Result<Self, EstimationError> {
1964 saved.validate()?;
1965 Ok(Self {
1966 model_class: MULTINOMIAL_MODEL_CLASS.to_string(),
1967 format_version: MULTINOMIAL_MODEL_FORMAT_VERSION,
1968 saved,
1969 })
1970 }
1971
1972 /// Serialize to the canonical JSON byte payload.
1973 pub fn to_json_bytes(&self) -> Result<Vec<u8>, EstimationError> {
1974 self.saved.validate()?;
1975 serde_json::to_vec(self).map_err(|err| {
1976 EstimationError::InvalidInput(format!("failed to serialize multinomial model: {err}"))
1977 })
1978 }
1979
1980 /// Parse an envelope from JSON bytes, validating the `model_class`
1981 /// discriminator so a non-multinomial payload is rejected with a clear
1982 /// error rather than silently mis-predicted.
1983 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, EstimationError> {
1984 // Gate on the envelope header (`model_class` + `format_version`) *before*
1985 // deserializing the versioned `saved` body. The header parse ignores the
1986 // body, so a payload that predates a field which has since become
1987 // required (e.g. `saved.formula`) is rejected on the version gate rather
1988 // than on whatever inner field is now missing — the version check is the
1989 // contract that tells a caller their payload is stale, and it must fire
1990 // first regardless of how the body schema has since evolved.
1991 #[derive(Deserialize)]
1992 struct EnvelopeHeader {
1993 #[serde(default)]
1994 model_class: Option<String>,
1995 #[serde(default)]
1996 format_version: Option<u32>,
1997 }
1998 let header: EnvelopeHeader = serde_json::from_slice(bytes).map_err(|err| {
1999 EstimationError::InvalidInput(format!("failed to deserialize multinomial model: {err}"))
2000 })?;
2001 match header.model_class.as_deref() {
2002 Some(MULTINOMIAL_MODEL_CLASS) => {}
2003 other => {
2004 return Err(EstimationError::InvalidInput(format!(
2005 "multinomial model: model_class = {other:?}, expected {MULTINOMIAL_MODEL_CLASS:?}",
2006 )));
2007 }
2008 }
2009 match header.format_version {
2010 Some(MULTINOMIAL_MODEL_FORMAT_VERSION) => {}
2011 Some(version) => {
2012 return Err(EstimationError::InvalidInput(format!(
2013 "multinomial model: format_version = {version}, expected {MULTINOMIAL_MODEL_FORMAT_VERSION}",
2014 )));
2015 }
2016 None => {
2017 return Err(EstimationError::InvalidInput(format!(
2018 "multinomial model: format_version is absent (unversioned payload), expected {MULTINOMIAL_MODEL_FORMAT_VERSION}",
2019 )));
2020 }
2021 }
2022 let envelope: Self = serde_json::from_slice(bytes).map_err(|err| {
2023 EstimationError::InvalidInput(format!("failed to deserialize multinomial model: {err}"))
2024 })?;
2025 envelope.saved.validate()?;
2026 Ok(envelope)
2027 }
2028}
2029
2030#[cfg(test)]
2031mod multinomial_persistence_contract_tests {
2032 use super::*;
2033
2034 #[test]
2035 fn unversioned_payload_is_rejected() {
2036 let payload = br#"{"model_class":"multinomial","saved":{}}"#;
2037 let error = MultinomialModelEnvelope::from_json_bytes(payload)
2038 .expect_err("unversioned multinomial persistence must not be guessed");
2039 assert!(
2040 error.to_string().contains("format_version"),
2041 "unexpected persistence error: {error}"
2042 );
2043 }
2044}
2045
2046/// One row of the multinomial smooth-significance table (#1101): the Wood
2047/// rank-truncated Wald test for one `(active class, smooth term)` pair.
2048#[derive(Debug, Clone)]
2049pub struct MultinomialSmoothSignificance {
2050 pub class_label: String,
2051 pub term_label: String,
2052 pub edf: f64,
2053 pub ref_df: f64,
2054 pub statistic: f64,
2055 pub p_value: f64,
2056}
2057
2058/// One-hot-encode the categorical response column and return both the
2059/// encoding and the captured level names. The level order matches the order
2060/// recorded in the dataset schema, which is the canonical (lexicographically
2061/// sorted) factor order produced by inferred-schema construction (#1319) — so
2062/// it is a deterministic function of the label *set*, independent of training
2063/// row order (no silent class permutation under a row shuffle), and matches the
2064/// R `factor()` / pandas `Categorical` convention.
2065fn one_hot_categorical_response(
2066 data: &EncodedDataset,
2067 y_col: usize,
2068 response_name: &str,
2069) -> Result<(Array2<f64>, Vec<String>), EstimationError> {
2070 let levels: Vec<String> = data
2071 .schema
2072 .columns
2073 .get(y_col)
2074 .map(|sc| sc.levels.clone())
2075 .unwrap_or_default();
2076 if levels.len() < 2 {
2077 crate::bail_invalid_estim!(
2078 "multinomial response '{response_name}' must have at least 2 categorical levels (got {})",
2079 levels.len()
2080 );
2081 }
2082 let n = data.values.nrows();
2083 let k = levels.len();
2084 let mut y_one_hot = Array2::<f64>::zeros((n, k));
2085 for row in 0..n {
2086 let encoded = data.values[[row, y_col]];
2087 if !encoded.is_finite() {
2088 crate::bail_invalid_estim!(
2089 "multinomial response '{response_name}' row {row} is non-finite ({encoded})"
2090 );
2091 }
2092 let class_idx = encoded.round() as i64;
2093 if class_idx < 0 || (class_idx as usize) >= k {
2094 crate::bail_invalid_estim!(
2095 "multinomial response '{response_name}' row {row} encoded as {encoded} \
2096 is outside the level range 0..{k}"
2097 );
2098 }
2099 y_one_hot[[row, class_idx as usize]] = 1.0;
2100 }
2101 Ok((y_one_hot, levels))
2102}
2103
2104/// Build `(TermCollectionSpec, TermCollectionDesign)` from a formula against
2105/// a categorical-response dataset. Mirrors the early scaffolding inside
2106/// `materialize_standard` (response role resolution, geometry-aware spec
2107/// build) without touching the scalar-family resolution path — multinomial
2108/// owns its own response kind check.
2109fn build_formula_design_for_multinomial(
2110 formula: &str,
2111 data: &EncodedDataset,
2112 config: &FitConfig,
2113) -> Result<
2114 (
2115 TermCollectionSpec,
2116 TermCollectionDesign,
2117 usize,
2118 String,
2119 ResponseColumnKind,
2120 ),
2121 EstimationError,
2122> {
2123 let parsed = parse_formula(formula).map_err(|err| {
2124 EstimationError::InvalidInput(format!(
2125 "multinomial fit: failed to parse formula {formula:?}: {err}"
2126 ))
2127 })?;
2128 let col_map = data.column_map();
2129 let y_col = resolve_role_col(&col_map, &parsed.response, "response")
2130 .map_err(|err| EstimationError::InvalidInput(format!("multinomial fit: {err}")))?;
2131 let y_kind = crate::fit_orchestration::response_column_kind(data, y_col);
2132 let policy = resolved_resource_policy(config, ProblemHints::default());
2133 let mut inference_notes: Vec<String> = Vec::new();
2134 let spec = build_termspec_with_geometry_and_overrides(
2135 &parsed.terms,
2136 data,
2137 &col_map,
2138 &mut inference_notes,
2139 config.scale_dimensions,
2140 &policy,
2141 config.smooth_overrides.as_ref(),
2142 None,
2143 )
2144 .map_err(|err| {
2145 EstimationError::InvalidInput(format!("multinomial fit: build termspec: {err}"))
2146 })?;
2147 let design = build_term_collection_design(data.values.view(), &spec).map_err(|err| {
2148 EstimationError::InvalidInput(format!("multinomial fit: build design: {err}"))
2149 })?;
2150 if design.affine_offset.iter().any(|value| *value != 0.0) {
2151 crate::bail_invalid_estim!(
2152 "multinomial fit does not support non-zero smooth anchors: the reference-coded \
2153 softmax requires an explicit affine offset for every non-reference class"
2154 );
2155 }
2156 Ok((spec, design, y_col, parsed.response, y_kind))
2157}
2158
2159fn scale_multinomial_formula_penalty(penalty: PenaltyMatrix, scale: f64) -> PenaltyMatrix {
2160 match penalty {
2161 PenaltyMatrix::Dense(matrix) => PenaltyMatrix::Dense(matrix.mapv(|v| v * scale)),
2162 PenaltyMatrix::KroneckerFactored { left, right } => PenaltyMatrix::KroneckerFactored {
2163 left: left.mapv(|v| v * scale),
2164 right,
2165 },
2166 PenaltyMatrix::Blockwise {
2167 local,
2168 col_range,
2169 total_dim,
2170 } => PenaltyMatrix::Blockwise {
2171 local: local.mapv(|v| v * scale),
2172 col_range,
2173 total_dim,
2174 },
2175 PenaltyMatrix::Labeled { label, inner } => PenaltyMatrix::Labeled {
2176 label,
2177 inner: Box::new(scale_multinomial_formula_penalty(*inner, scale)),
2178 },
2179 PenaltyMatrix::Fixed { log_lambda, inner } => PenaltyMatrix::Fixed {
2180 log_lambda,
2181 inner: Box::new(scale_multinomial_formula_penalty(*inner, scale)),
2182 },
2183 }
2184}
2185
2186/// Canonical typed inputs for the formula-driven multinomial fit
2187/// ([`fit_penalized_multinomial_formula`]).
2188///
2189/// Every frontend (Rust, CLI, Python FFI) builds this one request, so the
2190/// warm-start / outer-search defaults live here rather than being duplicated
2191/// per caller. `config` is the same canonical [`FitConfig`] the scalar formula
2192/// families consume: `weight_column` is resolved against the dataset and
2193/// honored as per-row case weights, and fields the softmax family cannot
2194/// consume (offsets, noise/log-slope formulas, manual Firth, frailty, ...) are
2195/// rejected with a typed error instead of being silently dropped.
2196#[derive(Clone, Copy)]
2197pub struct MultinomialFitRequest<'a> {
2198 pub data: &'a EncodedDataset,
2199 pub formula: &'a str,
2200 pub config: &'a FitConfig,
2201 /// Warm-start seed for every per-(class, term) smoothing parameter; λ is
2202 /// REML/LAML-selected, so this only seeds the outer search.
2203 pub init_lambda: f64,
2204 /// OUTER REML/LAML smoothing-parameter iteration budget.
2205 pub max_iter: usize,
2206 /// Requested accuracy; drives the inner joint-Newton KKT target (see the
2207 /// control-split note inside the fit).
2208 pub tol: f64,
2209}
2210
2211impl<'a> MultinomialFitRequest<'a> {
2212 /// The canonical production controls shared by the CLI and the Python FFI.
2213 pub fn new(data: &'a EncodedDataset, formula: &'a str, config: &'a FitConfig) -> Self {
2214 Self {
2215 data,
2216 formula,
2217 config,
2218 init_lambda: 1.0,
2219 max_iter: 50,
2220 tol: 1.0e-7,
2221 }
2222 }
2223}
2224
2225/// Reject canonical-config fields the softmax multinomial family cannot
2226/// consume. Silently dropping a requested offset / noise model / manual Firth
2227/// toggle would quietly change the estimand the caller asked for (SPEC 3), so
2228/// every unsupported field is a typed error shared by all frontends.
2229fn reject_unsupported_multinomial_config(config: &FitConfig) -> Result<(), EstimationError> {
2230 if config.offset_column.is_some() || config.noise_offset_column.is_some() {
2231 crate::bail_invalid_estim!(
2232 "multinomial fit does not support offset columns: a single offset column has no \
2233 canonical per-logit placement in the reference-coded softmax (offsets are per-class \
2234 linear-predictor quantities); remove the offset or fit per-class models"
2235 );
2236 }
2237 if config.noise_formula.is_some() {
2238 crate::bail_invalid_estim!(
2239 "noise_formula is not supported for the multinomial family: the softmax likelihood \
2240 has no dispersion predictor"
2241 );
2242 }
2243 if config.logslope_formula.is_some() || config.z_column.is_some() {
2244 crate::bail_invalid_estim!(
2245 "logslope_formula/z_column is not supported for the multinomial family"
2246 );
2247 }
2248 if config.transformation_normal {
2249 crate::bail_invalid_estim!(
2250 "transformation_normal conflicts with the multinomial family"
2251 );
2252 }
2253 if config.expectile_tau.is_some() {
2254 crate::bail_invalid_estim!("expectile_tau requires the expectile family");
2255 }
2256 if config.firth {
2257 crate::bail_invalid_estim!(
2258 "manual firth is not accepted for the multinomial family: the Firth/Jeffreys \
2259 separation stabilizer is armed automatically on separation evidence"
2260 );
2261 }
2262 if !matches!(
2263 config.frailty,
2264 crate::survival::lognormal_kernel::FrailtySpec::None
2265 ) {
2266 crate::bail_invalid_estim!("frailty is not supported for the multinomial family");
2267 }
2268 Ok(())
2269}
2270
2271/// Resolve the canonical `weight_column` into per-row case weights (`None` ⇒
2272/// uniform 1.0). Finiteness / non-negativity are enforced by
2273/// [`MultinomialFamily::new`], which owns the weight contract.
2274fn resolve_multinomial_row_weights(
2275 data: &EncodedDataset,
2276 config: &FitConfig,
2277) -> Result<Array1<f64>, EstimationError> {
2278 let Some(name) = config.weight_column.as_deref() else {
2279 return Ok(Array1::ones(data.values.nrows()));
2280 };
2281 let column = data.column_map().get(name).copied().ok_or_else(|| {
2282 EstimationError::InvalidInput(format!(
2283 "multinomial fit: weight column '{name}' not found in the dataset"
2284 ))
2285 })?;
2286 Ok(data.values.column(column).to_owned())
2287}
2288
2289/// Top-level formula-driven multinomial fit.
2290///
2291/// Routes through [`fit_custom_family_with_rho_prior`] so the per-active-class
2292/// smoothing parameters `λ_a` (one per class block, shared-penalty
2293/// architecture) are selected by the outer REML/LAML loop rather than pinned
2294/// by the caller. `init_lambda` survives as a warm-start hint that seeds
2295/// every block's `initial_log_lambdas`. `max_iter` / `tol` drive the OUTER
2296/// REML/LAML smoothing-parameter search (`outer_max_iter` / `outer_tol`); the
2297/// inner joint-Newton solve runs on the framework's principled production cycle
2298/// budget at the default KKT tolerance so an ill-conditioned, LM-damped
2299/// near-simplex-boundary solve can certify a stationary point instead of being
2300/// declared non-converged after only `max_iter` cycles (#715).
2301///
2302/// The Jeffreys/Firth proper prior is engaged CONDITIONALLY: attempt 1 runs
2303/// the unbiased penalized-REML criterion; only on separation evidence (a failed
2304/// solve or a non-finite logit; see [`multinomial_formula_separation_evidence`])
2305/// is the fit re-solved once with the full-span Firth prior armed, which bounds
2306/// the penalty-null directions no smoothing parameter can (`S v = 0` ⇒
2307/// `(H + S_λ) v = H v → 0` when the softmax likelihood has no finite mode).
2308///
2309/// The categorical response column is recognised via the dataset schema
2310/// (`ColumnKindTag::Categorical`); reference class = last level. Returns a
2311/// [`MultinomialSavedModel`] that can be serialised to bytes for the Python
2312/// wrapper or used in-process for `predict_probabilities`.
2313/// Everything [`fit_penalized_multinomial_formula`] constructs BEFORE the REML
2314/// solve: the family (unbiased criterion, Jeffreys disarmed), the per-class
2315/// block specs with seeded `initial_log_lambdas`, the calibrated solver
2316/// options, and the design artifacts the post-solve repack consumes. Exposed
2317/// at crate level so diagnostics can drive the EXACT production objective at
2318/// fixed smoothing parameters (finite-difference gates on the outer ρ-gradient
2319/// of the coalesced joint penalty family, #2349) instead of replicating the
2320/// construction and diverging from it.
2321pub(crate) struct PenalizedMultinomialFormulaParts {
2322 pub(crate) family: MultinomialFamily,
2323 pub(crate) blocks: Vec<ParameterBlockSpec>,
2324 pub(crate) options: BlockwiseFitOptions,
2325 pub(crate) spec: TermCollectionSpec,
2326 pub(crate) design: TermCollectionDesign,
2327 pub(crate) class_levels: Vec<String>,
2328 pub(crate) parametric_standardization: Vec<(usize, f64, f64)>,
2329 pub(crate) penalties_arc: Arc<Vec<PenaltyMatrix>>,
2330}
2331
2332pub(crate) fn penalized_multinomial_formula_parts(
2333 request: &MultinomialFitRequest<'_>,
2334) -> Result<PenalizedMultinomialFormulaParts, EstimationError> {
2335 let MultinomialFitRequest {
2336 data,
2337 formula,
2338 config,
2339 init_lambda,
2340 max_iter,
2341 tol,
2342 } = *request;
2343 if !(init_lambda.is_finite() && init_lambda > 0.0) {
2344 crate::bail_invalid_estim!(
2345 "multinomial fit: init_lambda must be finite and > 0 (got {init_lambda})"
2346 );
2347 }
2348 reject_unsupported_multinomial_config(config)?;
2349 let (raw_spec, design, y_col, response_name, y_kind) =
2350 build_formula_design_for_multinomial(formula, data, config)?;
2351 // Freeze the data-derived basis state (B-spline knot vectors, by-factor
2352 // level sets, spatial centers, joint-null rotations, residualization
2353 // charts) from the fit design back onto the spec. The raw geometry spec
2354 // records only *which* columns and *what kind* of basis each smooth uses;
2355 // the actual column count and basis evaluation depend on quantities the
2356 // builder derives from the training data (knot placement, the distinct
2357 // by-factor levels, etc.). Saving the raw spec made predict re-derive those
2358 // from the (smaller, differently-distributed) predict frame, so the rebuilt
2359 // design had a different column count than the fitted one — the panic
2360 // "predict design has 42 cols, saved model expects 191" for an `s(x,
2361 // by=group)` smooth-by-factor model. Every other family's persistence path
2362 // freezes the spec the same way (see `freeze_term_collection_from_design`
2363 // call sites in `main_parts`); multinomial was the lone exception.
2364 let spec = freeze_term_collection_from_design(&raw_spec, &design)?;
2365 let class_levels = match y_kind {
2366 ResponseColumnKind::Categorical { levels } => levels,
2367 ResponseColumnKind::Binary => vec!["0".to_string(), "1".to_string()],
2368 ResponseColumnKind::Numeric => {
2369 crate::bail_invalid_estim!(
2370 "multinomial fit: response '{response_name}' is numeric, not categorical; \
2371 use family='gaussian'/'binomial'/... or convert the column to a categorical type"
2372 );
2373 }
2374 };
2375 if data.column_kinds.get(y_col) == Some(&ColumnKindTag::Binary) {
2376 // Promote to a 2-level categorical for the multinomial driver; the
2377 // caller explicitly asked for multinomial, so we route through the
2378 // K-1 = 1 active-class softmax (equivalent math to logistic).
2379 } else if data.column_kinds.get(y_col) != Some(&ColumnKindTag::Categorical) {
2380 crate::bail_invalid_estim!(
2381 "multinomial fit: response '{response_name}' must be a categorical column \
2382 (got column kind {:?})",
2383 data.column_kinds.get(y_col)
2384 );
2385 }
2386 let (y_one_hot, _) = one_hot_categorical_response(data, y_col, &response_name)?;
2387 // Build the global X dense (the design is a DesignMatrix abstraction).
2388 let mut x_dense = design
2389 .design
2390 .try_to_dense_by_chunks("multinomial fit design")
2391 .map_err(EstimationError::InvalidInput)?;
2392
2393 // ── #715 real-data conditioning: standardize unpenalized parametric
2394 // columns. Raw-unit linear covariates (penguins `body_mass_g` ~ 4e3 grams)
2395 // inflate the joint Newton information by the squared column scale (a κ(H)
2396 // multiplier of ~s² ≈ 1e7 against the intercept), which is what turns the
2397 // near-separable LM-damped inner solve into a geometric grind that
2398 // exhausts its cycle budgets — the adapter-level face of "all REML startup
2399 // seeds rejected". Because these columns are UNPENALIZED (parametric terms
2400 // carry no default ridge, #749), the affine reparameterization
2401 // `x_j ↦ (x_j − m_j)/s_j` is EXACT for the whole criterion: the optimized
2402 // REML/LAML objective, the fitted η, the selected λ, and the separation
2403 // diagnostics are all invariant — only the conditioning of `H` changes.
2404 // Fitted coefficients are mapped back to raw units at repack below, so the
2405 // saved model and the (raw-design) predict path are untouched. Penalized
2406 // columns are left alone (a penalty makes the rescaling non-equivalent),
2407 // and nothing is touched when explicit coefficient bounds/constraints
2408 // exist (those are stated in raw units).
2409 let parametric_standardization: Vec<(usize, f64, f64)> =
2410 if design.coefficient_lower_bounds.is_some() || design.linear_constraints.is_some() {
2411 Vec::new()
2412 } else {
2413 let p_total = x_dense.ncols();
2414 let mut penalized = vec![false; p_total];
2415 for bp in &design.penalties {
2416 for col in bp.col_range.clone() {
2417 if col < p_total {
2418 penalized[col] = true;
2419 }
2420 }
2421 }
2422 let has_intercept = !design.intercept_range.is_empty();
2423 let n_rows = x_dense.nrows().max(1) as f64;
2424 let mut standardized = Vec::new();
2425 for (_, range) in &design.linear_ranges {
2426 for col in range.clone() {
2427 if col >= p_total || penalized[col] {
2428 continue;
2429 }
2430 let column = x_dense.column(col);
2431 let mean = column.sum() / n_rows;
2432 let var = column.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n_rows;
2433 let scale = var.sqrt();
2434 // Skip near-constant or degenerate columns: no conditioning to
2435 // be gained and the back-map would divide by ~0.
2436 if !(scale.is_finite() && scale > 1e-8 * (mean.abs() + 1.0)) {
2437 continue;
2438 }
2439 // Centering shifts mass onto the intercept; without one the
2440 // shift is not representable, so scale only.
2441 let center = if has_intercept { mean } else { 0.0 };
2442 for v in x_dense.column_mut(col).iter_mut() {
2443 *v = (*v - center) / scale;
2444 }
2445 standardized.push((col, center, scale));
2446 }
2447 }
2448 standardized
2449 };
2450 // Preserve the per-smooth-term penalty block structure (#561): each smooth
2451 // term `t` contributes its own `P × P` penalty component (`Blockwise` with
2452 // `total_dim = P`, the term's local `S_t` embedded at its `col_range`), and
2453 // every active class block receives the FULL list. The outer REML/LAML loop
2454 // then selects an independent smoothing parameter λ_{a,t} per (class, term),
2455 // matching mgcv/VGAM. Pre-summing the terms into one fused `S` (the prior
2456 // behaviour) forced a single λ per class that scales `Σ_t S_t`, so one
2457 // shared λ had to over-smooth a rough term while under-smoothing a smooth
2458 // one — biasing any multi-term class-probability surface.
2459 let k = y_one_hot.ncols();
2460 let m = k - 1;
2461 let n_obs = y_one_hot.nrows();
2462 let penalty_scale = multinomial_formula_penalty_scale(k);
2463 let per_term_penalties: Vec<PenaltyMatrix> = design
2464 .penalties_as_penalty_matrix()
2465 .into_iter()
2466 .map(|penalty| scale_multinomial_formula_penalty(penalty, penalty_scale))
2467 .collect();
2468
2469 // ── Custom-family driven REML/LAML path ───────────────────────────────
2470 // Each active class becomes one ParameterBlockSpec, all sharing X and the
2471 // per-term penalty list. `initial_log_lambdas` is seeded from the caller's
2472 // `init_lambda` (one entry per term).
2473 let design_arc = Arc::new(x_dense);
2474 let penalties_arc = Arc::new(per_term_penalties);
2475 let weights = resolve_multinomial_row_weights(data, config)?;
2476 if weights.len() != n_obs {
2477 crate::bail_invalid_estim!(
2478 "multinomial fit: weight column length {} != N = {n_obs}",
2479 weights.len()
2480 );
2481 }
2482 // First attempt runs the UNBIASED penalized-REML criterion (no Firth
2483 // shrinkage toward the uniform simplex); the Jeffreys/Firth proper prior is
2484 // armed conditionally below, only on separation evidence (#715/#753 — see
2485 // `multinomial_formula_separation_evidence`).
2486 let log_init = init_lambda.ln();
2487 let family = MultinomialFamily::new(
2488 y_one_hot.clone(),
2489 weights,
2490 k,
2491 design_arc.clone(),
2492 penalties_arc.clone(),
2493 )
2494 .map_err(EstimationError::InvalidInput)?
2495 .with_joint_jeffreys_term(false)
2496 // gam#1587: the per-block smooth penalties are emptied (the centered `M⊗S_t`
2497 // joint penalty is the sole smoothing carrier), so the `init_lambda` warm
2498 // start must seed the JOINT penalty's `initial_log_lambda` — the per-block
2499 // `initial_log_lambdas` loop below is now a no-op (empty per-block list).
2500 .with_initial_log_lambda(log_init);
2501 let mut blocks = family.build_block_specs();
2502 for spec_block in blocks.iter_mut() {
2503 for v in spec_block.initial_log_lambdas.iter_mut() {
2504 *v = log_init;
2505 }
2506 }
2507
2508 // ── Outer-derivative policy: dimension-gated exact curvature ────────────
2509 // The total smoothing-parameter dimension is `D = (K−1) · n_terms`.
2510 // Medium-D formula fits need exact curvature to keep lambda selection away
2511 // from over-smoothed caps, while smooth-by-factor `D = 8` models still avoid
2512 // the O(D²) dense Hessian path.
2513 let total_rho_dim = m.saturating_mul(penalties_arc.len());
2514 let use_outer_hessian = multinomial_formula_use_outer_hessian(total_rho_dim);
2515
2516 // ── Inner-vs-outer control split (#715 non-convergence root cause) ────────
2517 // The legacy `max_iter` / `tol` parameters are the *outer* REML/LAML
2518 // smoothing-parameter optimization controls — "how hard to search λ". The
2519 // earlier wiring routed them straight into `inner_max_cycles` / `inner_tol`,
2520 // capping the joint-Newton inner solve at `max_iter` (=50 in the quality
2521 // suite) cycles with a `tol`-tight (=1e-8) KKT target. That is the #715
2522 // hang: near the simplex boundary the softmax Fisher weight
2523 // `W = diag(p) − p pᵀ` collapses, so `H = JᵀWJ + S_λ` is full-rank but
2524 // ILL-CONDITIONED. The self-vanishing Levenberg–Marquardt damping
2525 // (`levenberg_on_ill_conditioning()`) that keeps the inner solve from
2526 // oscillating on those near-singular modes makes it converge only
2527 // GEOMETRICALLY (linearly), not quadratically. Reaching a 1e-8 relative KKT
2528 // residual under geometric descent needs FAR more than 50 cycles, so the
2529 // inner returned `converged = false` on every outer ρ-evaluation; with the
2530 // exact-Hessian outer optimizer on `FallbackPolicy::Disabled` that rejects
2531 // every ρ-step — each rejected eval still paying a near-full 50-cycle inner
2532 // solve plus the O(D²) pairwise outer-Hessian directional work — so the
2533 // outer never certifies and the fit runs unbounded (the observed >8-minute
2534 // non-termination). The certificate cannot be reached, not merely slow.
2535 //
2536 // Fix: give the INNER joint-Newton the framework's principled production
2537 // budget (`DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES` cycles at the default
2538 // `inner_tol`), which exists precisely so an ill-conditioned LM-damped solve
2539 // can certify a stationary KKT point instead of being declared non-converged
2540 // prematurely — and the KKT/objective certificates still exit in a handful
2541 // of cycles on the well-conditioned interior fits, so this is free there.
2542 // The caller's `max_iter` / `tol` become the OUTER controls they were always
2543 // meant to be (smoothing-parameter search depth / accuracy). The inner KKT
2544 // target is kept no tighter than the outer accuracy can consume — and no
2545 // tighter than the softmax objective's f64 noise floor on near-separable
2546 // fits (see `MULTINOMIAL_FORMULA_INNER_TOL`).
2547 let outer_max_iter = max_iter.max(1);
2548 // The OUTER REML/LAML smoothing-parameter search must converge to a
2549 // well-calibrated ρ-gradient tolerance, NOT to the caller's (typically very
2550 // tight) INNER KKT tolerance. The #715 control-split repurposed the caller's
2551 // `tol` as the outer control, but feeding an inner-scale `tol = 1e-8`
2552 // straight into `outer_tol` makes REML grind dozens of extra exact-gradient
2553 // outer iterations (each an O(D·p³) Laplace-derivative assembly over the full
2554 // P·M joint design) to squeeze ρ digits that no longer move the fitted
2555 // surface — the smooth-by-factor 269s wall-clock overrun (#1082).
2556 //
2557 // The right target is the framework's CALIBRATED REML convergence tolerance,
2558 // `MULTINOMIAL_OUTER_REML_TOL = 1e-7` — the same value the primary GLM REML
2559 // outer uses (`solver::fit_orchestration::materialize` `tol: 1e-7`, mirrored by the
2560 // `LOG_LAMBDA_TOL`/`KKT_TOL_*` constants across the REML stack). At 1e-7 the
2561 // λ-search reaches the genuine REML optimum (so the recovered probability
2562 // surface matches the mature reference), but it does NOT chase the last
2563 // surface-irrelevant ρ digits down to 1e-8. The earlier 1e-5 floor (the
2564 // generic `BlockwiseFitOptions` default) was too LOOSE: the optimizer halted
2565 // in a low-curvature region with λ still well above its optimum, UNDER-fitting
2566 // the smooth-by-factor surface (truth-RMSE 0.164 vs VGAM's 0.061). So the
2567 // outer tolerance is floored at the calibrated REML tol — never tighter than
2568 // it (perf), never looser (accuracy) — while the caller's `tol` continues to
2569 // drive the INNER joint-Newton KKT target (`inner_tol` below), where its
2570 // precision actually matters.
2571 let outer_tol = if tol.is_finite() && tol > 0.0 {
2572 tol.max(MULTINOMIAL_OUTER_REML_TOL)
2573 } else {
2574 MULTINOMIAL_OUTER_REML_TOL
2575 };
2576 // #1082 root cause: the outer convergence test derives BOTH the absolute
2577 // projected-gradient floor (`max(outer_tol, n·1e-9)`) AND the relative-cost
2578 // stop (`rel_cost = outer_tol`) from the single `outer_tol`. The accuracy of
2579 // the smooth-by-factor surface is governed by the ABSOLUTE floor reaching the
2580 // n-scaled REML resolution `n·1e-9` (≈ 1.8e-6 at n = 1800) — that is why the
2581 // earlier 1e-5 floor UNDER-fit (its absolute floor was pinned at 1e-5, well
2582 // above the genuine optimum's gradient) and why 1e-7 recovered accuracy (it
2583 // unpins the floor down to the n-scaled 1.8e-6). But tightening `outer_tol`
2584 // to 1e-7 ALSO tightened the rel-cost stop to 1e-7, which on this family's
2585 // dead-flat REML ridge NEVER trips — so the optimizer no longer converges and
2586 // grinds all the way to `outer_max_iter`, each surplus step an O(D·p³) Laplace-
2587 // derivative assembly over the 382-dim joint design (the >600s wall-clock
2588 // overrun; tightening tol REINTRODUCED the crawl the 1e-5 floor had removed).
2589 //
2590 // The two requirements live on two different criteria, so they must be set
2591 // independently. Keep `outer_tol = 1e-7` (drives the accurate absolute floor)
2592 // but FLOOR the relative-cost stop at the framework default 1e-5 (the loose,
2593 // fast value that resolves the cost-decrease plateau without chasing the flat
2594 // tail). The absolute n·1e-9 floor still gates final λ accuracy; the rel-cost
2595 // stop just lets the optimizer DECLARE convergence on the flat ridge instead
2596 // of crawling to the iteration cap.
2597 let outer_rel_cost_tol = Some(BlockwiseFitOptions::default().outer_tol);
2598 let inner_tol = MULTINOMIAL_FORMULA_INNER_TOL.max(tol.max(0.0));
2599
2600 let options = BlockwiseFitOptions {
2601 inner_max_cycles: crate::custom_family::DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES,
2602 inner_tol,
2603 outer_max_iter,
2604 outer_tol,
2605 outer_rel_cost_tol,
2606 rho_lower_bound: multinomial_formula_min_lambda(y_one_hot.view()).ln(),
2607 ridge_floor: MULTINOMIAL_FORMULA_RIDGE_FLOOR,
2608 // #747: the stabilization floor is SOLVER-ONLY — it keeps the inner
2609 // joint-Newton linear solve finite during screening (bounding the step
2610 // `(H+δI)⁻¹∇` away from a near-separable, rank-deficient curvature) but
2611 // is excluded from the REML objective, the penalty log-determinant, and
2612 // the Laplace Hessian. The earlier default (`explicit_stabilization_pospart`)
2613 // folded `½·δ·‖β‖²` and a `δ`-shift of the log-determinant into the
2614 // criterion, shrinking every identified coefficient off the MLE and
2615 // perturbing smoothing-parameter selection — a fixed-λ prior masking
2616 // separation, not a numerical stabilizer. With the floor solver-only the
2617 // optimized objective is the true penalized REML criterion (value tracks
2618 // its analytic gradient), and the smooth directions remain governed
2619 // solely by their own REML-selected `λ`.
2620 ridge_policy: gam_problem::RidgePolicy::solver_only(),
2621 use_outer_hessian,
2622 // #715 real-data arm ("canonical-gauge null direction rejects all REML
2623 // seeds"): skip the multi-seed outer screening cascade and let the
2624 // pinned `init_lambda` ρ flow straight to the outer optimizer.
2625 //
2626 // The multinomial family declares `levenberg_on_ill_conditioning() ->
2627 // true`: near the simplex boundary (the near-separable penguins regime)
2628 // the softmax Fisher weight `W = diag(p) − p pᵀ → 0`, so the joint
2629 // information `H = JᵀWJ + S_λ` can become full-rank but
2630 // ILL-CONDITIONED. The self-vanishing LM damping that keeps the inner
2631 // joint-Newton from oscillating on those near-singular modes converges
2632 // only GEOMETRICALLY. The default screening policy ranks candidate seeds
2633 // with a 2-cycle inner cap (`outer_seed_config`); under geometric
2634 // LM-damped descent two cycles never reach a finite, meaningful proxy
2635 // objective, so EVERY capped seed can collapse to non-finite cost and
2636 // the cascade escalates to ×4, ×16, then an UNCAPPED full inner solve
2637 // PER SEED on the near-singular Hessian. That is the adapter-level face
2638 // of "all REML startup seeds rejected" and the multi-minute timeout.
2639 //
2640 // The pinned seed is already principled here: `init_lambda` gives every
2641 // (class, term) ρ a sensible moderate warm start, and the per-term
2642 // effective-df-floor upper bounds (`effective_df_floor_rho_upper_bounds`,
2643 // #715 arm (a)) keep any λ from collapsing the smooth onto its polynomial
2644 // null space. So the outer ARC/BFGS optimizer performs the real REML ρ
2645 // search from this seed; screening only adds the cascade cost and, on the
2646 // near-separable arm, the rejection stall.
2647 screen_initial_rho: false,
2648 // #1101: compute the joint Laplace posterior covariance `H⁻¹` (and the
2649 // influence matrix `F = H⁻¹ X'WX`) at the converged mode so the saved
2650 // model can surface delta-method per-class probability standard errors
2651 // and Wald smooth-term p-values. The driver factorizes the penalized
2652 // Hessian during the inner solve regardless; this only asks it to keep
2653 // and invert the factor instead of discarding it.
2654 compute_covariance: true,
2655 ..BlockwiseFitOptions::default()
2656 };
2657 Ok(PenalizedMultinomialFormulaParts {
2658 family,
2659 blocks,
2660 options,
2661 spec,
2662 design,
2663 class_levels,
2664 parametric_standardization,
2665 penalties_arc,
2666 })
2667}
2668
2669pub fn fit_penalized_multinomial_formula(
2670 request: &MultinomialFitRequest<'_>,
2671) -> Result<MultinomialSavedModel, EstimationError> {
2672 let PenalizedMultinomialFormulaParts {
2673 family,
2674 blocks,
2675 options,
2676 spec,
2677 design,
2678 class_levels,
2679 parametric_standardization,
2680 penalties_arc,
2681 } = penalized_multinomial_formula_parts(request)?;
2682 let MultinomialFitRequest {
2683 data,
2684 formula,
2685 config,
2686 ..
2687 } = *request;
2688 let m = family.active_classes();
2689 // ── Conditional Firth/Jeffreys engagement (#715 arm (b) / #753) ──────────
2690 // Attempt 1: the unbiased criterion (Jeffreys disarmed above). If the
2691 // returned mode is converged, finite, and interior, it is the exact penalized-REML
2692 // optimum with zero Firth bias — accept it (this is the synthetic-arm /
2693 // interior-data path, #715 arm (a)). If the solve FAILS (e.g. the
2694 // (quasi-)separated penguins geometry where `(H + S_λ)v ≈ 0` along
2695 // penalty-null directions for EVERY ρ rejects every REML startup seed) or
2696 // returns a non-finite artifact, that is direct separation evidence:
2697 // re-solve once with the full-span Jeffreys/Firth proper prior armed, which
2698 // supplies the O(1) curvature on the quotient-null subspace that smoothing
2699 // parameters mathematically cannot (`Sv = 0` ⇒ λ never touches `v`). The
2700 // Firth refit is the accepted result only when the unbiased formula solve
2701 // failed, did not converge on its full budget, or blew up; finite
2702 // formula-path logits can be large on valid near-separated optima and
2703 // should not be shrunk toward the uniform simplex once the unbiased outer
2704 // solve has actually certified.
2705 let mut unbiased_probe_options = options.clone();
2706 unbiased_probe_options.outer_max_iter = unbiased_probe_options
2707 .outer_max_iter
2708 .min(MULTINOMIAL_UNBIASED_PROBE_OUTER_MAX_ITER);
2709 // The FINAL accepted Firth/Jeffreys refit runs to the caller's full outer
2710 // budget: it is the result we ship, so it must reach the genuine REML
2711 // optimum, not a truncated iterate. The near-separable penguin refit that
2712 // motivated #1082's wall-clock concern is now halted honestly at its true
2713 // bound optimum by the KKT-stationary-at-bound guard
2714 // (`CostStallGuard`, #1082 / 64711ed82) and the Newton-decrement residual
2715 // certificate (363af9b56 / 2c9580b1f): on separable data the outer ARC
2716 // certifies and stops early on its own, so no artificial iteration cap is
2717 // needed to land in budget. On non-separable data (e.g. the
2718 // `vgam_smooth_by_factor` double-penalty arm) the refit needs the caller's
2719 // full budget to converge, which a `.min(20)` cap would cut off — accepting
2720 // a non-converged fit, which is dishonest. So the refit keeps `options`
2721 // unchanged. Only the discarded unbiased separation probe above is capped.
2722 let firth_refit_options = &options;
2723
2724 let run_firth_refit = |evidence: String| {
2725 let firth_family = family.clone().with_joint_jeffreys_term(true);
2726 fit_custom_family_with_rho_prior(
2727 &firth_family,
2728 &blocks,
2729 firth_refit_options,
2730 gam_problem::RhoPrior::Flat,
2731 )
2732 .map_err(|err| {
2733 EstimationError::InvalidInput(format!(
2734 "multinomial REML: Firth/Jeffreys-armed refit (separation evidence: \
2735 {evidence}) failed: {err}"
2736 ))
2737 })
2738 };
2739
2740 // #1082: the capped unbiased probe and the (separable-path) Firth decision
2741 // are driven by separation scans over the full P×M logit block. The previous
2742 // match recomputed `multinomial_formula_separation_evidence` /
2743 // `..._unresolved_probe_separation_evidence` in BOTH the match guard AND the
2744 // arm body — three to four full logit walks per fit, paid on the hot
2745 // near-separable penguin path where this branch fires every iterate. Run the
2746 // probe once, evaluate each scan once into a binding, and branch on the
2747 // precomputed results. Behaviour is identical (same scans, same order of
2748 // precedence: converged-interior, unresolved-probe-separation,
2749 // no-separation-needs-full-solve, otherwise-Firth); only the duplicate
2750 // O(n·classes) scans are removed.
2751 let probe_attempt = fit_custom_family_with_rho_prior(
2752 &family,
2753 &blocks,
2754 &unbiased_probe_options,
2755 gam_problem::RhoPrior::Flat,
2756 );
2757 let fit = match probe_attempt {
2758 Ok(probe_fit) => {
2759 let separation = multinomial_formula_separation_evidence(&probe_fit.block_states);
2760 if separation.is_none() {
2761 // Fit existence proves both optimization layers certified; no
2762 // post-hoc convergence flag is needed.
2763 probe_fit
2764 } else {
2765 // A certified unbiased optimum can still exhibit separation;
2766 // use the already-computed evidence to select the Firth target.
2767 let evidence = separation.expect("checked as present");
2768 run_firth_refit(evidence)?
2769 }
2770 }
2771 Err(err) => run_firth_refit(format!("unbiased-criterion REML solve failed: {err}"))?,
2772 };
2773 if let Some(err) = multinomial_formula_separation_diagnostic(
2774 fit.inner_cycles,
2775 fit.outer_iterations,
2776 &fit.block_states,
2777 ) {
2778 return Err(err);
2779 }
2780
2781 // ── Repack coefficients (P, K-1) from per-block β vectors ─────────────
2782 if fit.blocks.len() != m {
2783 crate::bail_invalid_estim!(
2784 "multinomial REML: expected {m} fitted blocks (K-1), got {}",
2785 fit.blocks.len()
2786 );
2787 }
2788 let p_per_class = fit.blocks[0].beta.len();
2789 let mut coefficients_active = Array2::<f64>::zeros((p_per_class, m));
2790 for (a, block) in fit.blocks.iter().enumerate() {
2791 if block.beta.len() != p_per_class {
2792 crate::bail_invalid_estim!(
2793 "multinomial REML: block {a} has {} coefs, expected {p_per_class}",
2794 block.beta.len()
2795 );
2796 }
2797 for i in 0..p_per_class {
2798 coefficients_active[[i, a]] = block.beta[i];
2799 }
2800 }
2801 // Map the standardized-column coefficients back to raw units (the exact
2802 // inverse of the conditioning reparameterization above): β_raw = b/s, with
2803 // the centering mass `Σ_j b_j·m_j/s_j` returned to the intercept.
2804 if !parametric_standardization.is_empty() {
2805 let intercept_col = design.intercept_range.clone().next();
2806 for a in 0..m {
2807 let mut intercept_adjust = 0.0;
2808 for &(col, center, scale) in ¶metric_standardization {
2809 if col < p_per_class {
2810 let raw = coefficients_active[[col, a]] / scale;
2811 coefficients_active[[col, a]] = raw;
2812 intercept_adjust += raw * center;
2813 }
2814 }
2815 if let Some(i0) = intercept_col
2816 && i0 < p_per_class
2817 {
2818 coefficients_active[[i0, a]] -= intercept_adjust;
2819 }
2820 }
2821 }
2822 // Flatten every (class, term) smoothing parameter in block-major order
2823 // (class 0's terms, then class 1's, …). With per-term penalties each block
2824 // now carries one λ per smooth term, so a single λ per class would discard
2825 // the independent per-term selection that fixes #561. `lambdas_per_block`
2826 // segments the flat vector by class so callers can recover per-term λ.
2827 // ── gam#1587/#561 joint-penalty reconstruction ───────────────────────────
2828 // Under the #1587 centered-metric architecture every active class block
2829 // leaves its per-block penalty list EMPTY — the entire fit's smoothing rides
2830 // on a single full-width JOINT penalty `S_λ = Σ_t λ_t (M ⊗ S_t)` whose one
2831 // shared `λ_t` per smooth component is selected by the outer REML loop and
2832 // surfaced on `fit.artifacts.joint_log_lambdas`. So `fit.blocks[a].lambdas`
2833 // is `[]`, the inference layer's per-block trace channel is empty, and the
2834 // older per-block reporting (`lambdas_per_block = [0, 0]`, `edf_per_class =
2835 // None`, …) collapsed (#561 reopen).
2836 //
2837 // Reconstruct the per-(class, component) λ and the influence-matrix EDF
2838 // directly from the selected joint `λ_t` and the COUPLED penalty
2839 // `S_λ = Σ_t λ_t (M ⊗ S_t)` (NOT a block-diagonal `Σ_t λ_{a,t} S_t`: the
2840 // centered metric `M` couples classes off the block diagonal, so a
2841 // block-diagonal `S_λ` would mis-state both the influence matrix and every
2842 // trace). With `H⁻¹ = fit.covariance_conditional` now assembled WITH the
2843 // joint penalty (the `compute_joint_covariance` fix), the influence matrix is
2844 // exactly `F = I − H⁻¹ S_λ`, its per-class diagonal-block trace is the honest
2845 // per-class EDF, and `Σ_a edf_a = tr(F) = edf_total`.
2846 let joint_recon = fit.artifacts.joint_log_lambdas.as_ref().and_then(|jll| {
2847 let n_components = penalties_arc.len();
2848 if n_components == 0 {
2849 return None;
2850 }
2851 // The coupled joint penalty family at the selected λ's, in raw stacked
2852 // (class-major) coordinates — exactly the operator the inner solve and
2853 // covariance path penalize with. Under the equivariant carrier this is
2854 // K per-class specs per term, grouped term-major (`s = t·g + c`); the
2855 // K = 2 degenerate arm returns one shared centered spec per term.
2856 let joint_specs = family.equivariant_class_penalty_specs().ok()?;
2857 if jll.len() != joint_specs.len() || joint_specs.len() % n_components != 0 {
2858 return None;
2859 }
2860 let specs_per_term = joint_specs.len() / n_components;
2861 let expected_joint = p_per_class.saturating_mul(m);
2862 let hinv = fit
2863 .covariance_conditional
2864 .as_ref()
2865 .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)?;
2866 let lam: Vec<f64> = jll.iter().map(|&l| l.exp()).collect();
2867 // Per-spec `H⁻¹ M_s` (full mp×mp), reused for both the joint influence
2868 // matrix and the per-(class, component) trace decomposition.
2869 let mut hinv_st: Vec<Array2<f64>> = Vec::with_capacity(joint_specs.len());
2870 for spec in &joint_specs {
2871 if spec.matrix.nrows() != expected_joint || spec.matrix.ncols() != expected_joint {
2872 return None;
2873 }
2874 hinv_st.push(hinv.dot(&spec.matrix));
2875 }
2876 // F = I − H⁻¹ S_λ = I − Σ_s λ_s H⁻¹ M_s.
2877 let mut f = Array2::<f64>::eye(expected_joint);
2878 for (s, hs) in hinv_st.iter().enumerate() {
2879 f.scaled_add(-lam[s], hs);
2880 }
2881 // Per-class diagonal-block trace of F (the honest per-class EDF), and
2882 // the per-(class, component) penalty trace
2883 // `tr_{a,t} = Σ_{c∈term t} λ_{t,c} · Σ_{i∈class a} (H⁻¹ M_{t,c})[i,i]`
2884 // for the per-penalty EDF rollup.
2885 let mut edf_per_class = Vec::with_capacity(m);
2886 // class-major per-penalty EDF (class 0's components, then class 1's, …),
2887 // aligned 1:1 with the flat per-(class, component) λ report below.
2888 let mut edf_per_penalty = Vec::with_capacity(m * n_components);
2889 for a in 0..m {
2890 let base = a * p_per_class;
2891 let mut class_trace = 0.0_f64;
2892 for t in 0..n_components {
2893 let mut tr_at = 0.0_f64;
2894 for c in 0..specs_per_term {
2895 let s = t * specs_per_term + c;
2896 let mut tr = 0.0_f64;
2897 for i in 0..p_per_class {
2898 tr += hinv_st[s][[base + i, base + i]];
2899 }
2900 tr_at += lam[s] * tr;
2901 }
2902 class_trace += tr_at;
2903 // A single component's per-class trace EDF `rank(S_t) − tr_{a,t}`,
2904 // bounded by its local rank (≤ p_per_class). Derive rank(S_t)
2905 // from the spec's MEASURED nullity (per-class spec: rank =
2906 // m·p − nullspace_dim; shared centered spec: m·rank), so the
2907 // reporting rank matches the pseudo-logdet rank exactly.
2908 let spec0 = &joint_specs[t * specs_per_term];
2909 let joint_rank = expected_joint - spec0.nullspace_dim;
2910 let rank_t = if specs_per_term > 1 {
2911 joint_rank as f64
2912 } else {
2913 (joint_rank as f64) / (m as f64)
2914 };
2915 edf_per_penalty.push((rank_t - tr_at).clamp(0.0, p_per_class as f64));
2916 }
2917 edf_per_class.push((p_per_class as f64 - class_trace).clamp(0.0, p_per_class as f64));
2918 }
2919 // Per-(class, component) λ report, class-major. Under the equivariant
2920 // carrier the smoothing applied to active class `a`'s centered function
2921 // for term `t` is its own `λ_{t,c=a}` (spec index `t·K + a`); under the
2922 // K = 2 shared arm every class reports the one `λ_t`.
2923 let mut lam_flat = Vec::with_capacity(m * n_components);
2924 for a in 0..m {
2925 for t in 0..n_components {
2926 let s = if specs_per_term > 1 {
2927 t * specs_per_term + a
2928 } else {
2929 t
2930 };
2931 lam_flat.push(lam[s]);
2932 }
2933 }
2934 Some((f, edf_per_class, edf_per_penalty, n_components, lam_flat))
2935 });
2936
2937 // Flatten every (class, component) smoothing parameter in class-major order.
2938 // Under the equivariant joint-penalty architecture each active class `a`
2939 // reports its own `λ_{t,a}` per term (the per-class centered penalties;
2940 // the K = 2 degenerate arm replicates the shared `λ_t`), so the flat vector
2941 // is class-major with `lambdas_per_block = [n_components; K-1]`. When the
2942 // joint reconstruction is unavailable (legacy fixed-λ path or absent
2943 // covariance) fall back to the raw — now empty — per-block λ lists.
2944 let (lambdas_per_block, lambdas_flat): (Vec<usize>, Vec<f64>) = match joint_recon.as_ref() {
2945 Some((_, _, _, n_components, lam_flat)) => {
2946 let per_block = vec![*n_components; m];
2947 (per_block, lam_flat.clone())
2948 }
2949 None => {
2950 let per_block: Vec<usize> = fit.blocks.iter().map(|b| b.lambdas.len()).collect();
2951 let flat: Vec<f64> = fit
2952 .blocks
2953 .iter()
2954 .flat_map(|b| b.lambdas.iter().copied())
2955 .collect();
2956 (per_block, flat)
2957 }
2958 };
2959 // Per-active-class effective degrees of freedom, length `K-1`, summing to
2960 // the model `edf_total`. The REML inference block reports `edf_by_block` as
2961 // ONE entry per *penalty block* (per (class, term, penalty)), each computed
2962 // as `rank(S_kk) − tr(H⁻¹ λ_kk S_kk)`. That per-block sum OVER-COUNTS the
2963 // model EDF whenever several penalties share one coefficient range — a
2964 // double-penalty / te / ti / adaptive smooth has ≥2 penalty blocks over the
2965 // same columns, so `Σ_kk rank(S_kk) > p` and `Σ_kk edf_by_block > edf_total`
2966 // (the observed ~79 for a ~24-coefficient model). Handing that raw per-block
2967 // vector out as the documented length-(K-1) per-class EDF is therefore both
2968 // the wrong LENGTH (it is `Σ_a n_blocks_a`, not `K-1`) and an over-count.
2969 //
2970 // The honest per-class EDF is the influence-matrix trace over each class's
2971 // coefficient block. Classes occupy DISJOINT `p_per_class`-wide coefficient
2972 // ranges, and the per-block traces `tr_kk = tr(H⁻¹ λ_kk S_kk)` are additive
2973 // (no rank double-counting), so class `a`'s EDF is
2974 // `p_per_class − Σ_{kk ∈ class a} tr_kk`, and `Σ_a edf_a = m·p_per_class −
2975 // Σ_kk tr_kk = p − Σ tr_kk = edf_total` exactly. Segment the block-major
2976 // `penalty_block_trace` by `lambdas_per_block` (the same per-class λ-count
2977 // segmentation `lambdas_flat` uses). Fall back to `None` when the trace
2978 // channel is unavailable or mis-shaped (legacy fixed-λ path), exactly as the
2979 // raw `edf_by_block` map did before.
2980 let edf_per_class = joint_recon
2981 .as_ref()
2982 .map(|(_, epc, _, _, _)| epc.clone())
2983 .or_else(|| {
2984 // Legacy per-block trace path (fixed-λ / pre-#1587 fits whose
2985 // smoothing is still carried per block). Segment the block-major
2986 // `penalty_block_trace` by `lambdas_per_block`, exactly as before.
2987 fit.inference.as_ref().and_then(|info| {
2988 let traces = &info.penalty_block_trace;
2989 if traces.len() != lambdas_per_block.iter().sum::<usize>() {
2990 return None;
2991 }
2992 let mut per_class = Vec::with_capacity(m);
2993 let mut cursor = 0usize;
2994 for &n_blocks in &lambdas_per_block {
2995 let class_trace: f64 = traces[cursor..cursor + n_blocks].iter().sum();
2996 per_class
2997 .push((p_per_class as f64 - class_trace).clamp(0.0, p_per_class as f64));
2998 cursor += n_blocks;
2999 }
3000 Some(per_class)
3001 })
3002 });
3003 // Per-PENALTY EDF: the inference layer's `edf_by_block` is already the
3004 // clamped per-penalty-block trace EDF `rank(S_k) − λ_k·tr(H⁻¹ S_k)`, one
3005 // entry per smoothing parameter and block-major aligned 1:1 with the flat
3006 // `lambdas`. Surface it verbatim (guarding only on the length contract) so
3007 // consumers can inspect per-(class, term, penalty) collapse onto the null
3008 // space — a signal the per-class EDF SUM hides. This is NOT a per-class
3009 // total: with double-penalty smooths `Σ_k rank(S_k) > p_per_class`, so the
3010 // entries deliberately need not sum to the model EDF (the per-class field
3011 // carries that contract instead).
3012 let edf_per_penalty = joint_recon
3013 .as_ref()
3014 .map(|(_, _, epp, _, _)| epp.clone())
3015 .or_else(|| {
3016 // Legacy per-block path: the inference layer's `edf_by_block` is
3017 // already the clamped per-penalty-block trace EDF, aligned 1:1 with
3018 // the flat `lambdas`.
3019 fit.inference.as_ref().and_then(|info| {
3020 if info.edf_by_block.len() != lambdas_flat.len() {
3021 return None;
3022 }
3023 Some(
3024 info.edf_by_block
3025 .iter()
3026 .map(|&e| e.max(0.0))
3027 .collect::<Vec<f64>>(),
3028 )
3029 })
3030 });
3031 let coefficients_flat: Vec<f64> = coefficients_active.iter().copied().collect();
3032
3033 // #1101: surface the joint Laplace posterior covariance `H⁻¹` (block-ordered
3034 // [β_0; …; β_{K-2}]) and the influence matrix `F = H⁻¹ X'WX` the REML driver
3035 // computed at the converged mode. These power the predict path's delta-method
3036 // per-class probability standard errors and the summary's Wald smooth-term
3037 // tests. The joint matrices are `(P·M)×(P·M)`. The covariance is mapped back
3038 // to RAW units (see below) so it pairs with the raw predict design; the
3039 // influence is kept in the fitted basis (the Wald table only slices penalized
3040 // columns, which the standardization affine leaves identity-mapped).
3041 let expected_joint = p_per_class.checked_mul(m).ok_or_else(|| {
3042 EstimationError::InvalidInput(
3043 "multinomial posterior covariance dimension overflowed usize".to_string(),
3044 )
3045 })?;
3046 // The joint Hessian (and thus `H⁻¹`) was assembled in the STANDARDIZED
3047 // parametric basis used during fitting, while the saved coefficients and the
3048 // raw predict design are in raw units. Map the covariance to raw units with
3049 // the same exact affine reparameterization `β_raw = A β_std`: for each
3050 // standardized parametric column `col`, `β_raw[col] = β_std[col]/scale` and
3051 // the intercept absorbs `−Σ_col (center/scale)·β_std[col]`. So `A = I` except
3052 // `A[col,col] = 1/scale` and `A[i0,col] = −center/scale`, replicated
3053 // block-diagonally per active class, and `Cov_raw = A Cov_std Aᵀ`. With no
3054 // standardization (`parametric_standardization` empty) `A = I` and this is a
3055 // no-op. The smooth-term (penalized) columns are untouched by `A`, so the
3056 // Wald table's per-term blocks are identical in both bases.
3057 let intercept_col0 = design.intercept_range.clone().next();
3058 let build_per_class_affine = |amat: &mut Array2<f64>| {
3059 for &(col, center, scale) in ¶metric_standardization {
3060 if col >= p_per_class {
3061 continue;
3062 }
3063 amat[[col, col]] = 1.0 / scale;
3064 if let Some(i0) = intercept_col0
3065 && i0 < p_per_class
3066 {
3067 amat[[i0, col]] = -center / scale;
3068 }
3069 }
3070 };
3071 let coefficient_covariance_flat = fit
3072 .covariance_conditional
3073 .as_ref()
3074 .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
3075 .map(|cov_std| {
3076 if parametric_standardization.is_empty() {
3077 return cov_std.iter().copied().collect::<Vec<f64>>();
3078 }
3079 // Block-diagonal joint A (same per active class).
3080 let mut a_joint = Array2::<f64>::eye(expected_joint);
3081 let mut a_class = Array2::<f64>::eye(p_per_class);
3082 build_per_class_affine(&mut a_class);
3083 for a in 0..m {
3084 let base = a * p_per_class;
3085 for i in 0..p_per_class {
3086 for j in 0..p_per_class {
3087 a_joint[[base + i, base + j]] = a_class[[i, j]];
3088 }
3089 }
3090 }
3091 let cov_raw = a_joint.dot(cov_std).dot(&a_joint.t());
3092 cov_raw.iter().copied().collect::<Vec<f64>>()
3093 })
3094 .ok_or_else(|| {
3095 EstimationError::InvalidInput(format!(
3096 "multinomial REML converged without the required {expected_joint}x{expected_joint} joint posterior covariance"
3097 ))
3098 })?;
3099 // The influence matrix `F = H⁻¹ X'WX = H⁻¹(H − S_λ) = I − H⁻¹ S_λ`. The
3100 // exact-Newton multinomial blocks carry no IRLS pseudo-data, so the generic
3101 // inference path does not export `coefficient_influence`; reconstruct it
3102 // exactly here. Under the #1587 joint-penalty architecture the penalty is the
3103 // COUPLED centered metric `S_λ = Σ_t λ_t (M ⊗ S_t)` (off the class-block
3104 // diagonal), already assembled in `joint_recon` above, so reuse that exact
3105 // `F`. Only fall back to the legacy block-diagonal `Σ_t λ_{a,t} S_t`
3106 // reconstruction when the joint reconstruction is unavailable (pre-#1587
3107 // per-block fits whose class blocks still carry their own penalties).
3108 let coefficient_influence_flat = match joint_recon.as_ref() {
3109 Some((f, _, _, _, _)) => Some(f.iter().copied().collect::<Vec<f64>>()),
3110 None => fit
3111 .covariance_conditional
3112 .as_ref()
3113 .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
3114 .and_then(|hinv| {
3115 if fit.blocks.len() != m {
3116 return None;
3117 }
3118 // Joint S_λ (block-diagonal across active classes).
3119 let mut s_lambda = Array2::<f64>::zeros((expected_joint, expected_joint));
3120 for (a, block) in fit.blocks.iter().enumerate() {
3121 if block.lambdas.len() != penalties_arc.len() {
3122 return None;
3123 }
3124 let base = a * p_per_class;
3125 for (t, pen) in penalties_arc.iter().enumerate() {
3126 let lam = block.lambdas[t];
3127 if lam == 0.0 {
3128 continue;
3129 }
3130 let dense = pen.to_dense();
3131 if dense.nrows() != p_per_class || dense.ncols() != p_per_class {
3132 return None;
3133 }
3134 for i in 0..p_per_class {
3135 for j in 0..p_per_class {
3136 s_lambda[[base + i, base + j]] += lam * dense[[i, j]];
3137 }
3138 }
3139 }
3140 }
3141 // F = I − H⁻¹ S_λ.
3142 let hinv_s = hinv.dot(&s_lambda);
3143 let mut f = Array2::<f64>::eye(expected_joint);
3144 f -= &hinv_s;
3145 Some(f.iter().copied().collect::<Vec<f64>>())
3146 }),
3147 };
3148
3149 // Per-(smooth term) coefficient span within a single class block, deduped by
3150 // col_range (the #561 double-penalty migration emits two penalty blocks per
3151 // term sharing one col_range; the Wald test covers the whole term block once).
3152 let mut smooth_term_spans: Vec<MultinomialSmoothTermSpan> = Vec::new();
3153 for (pen_idx, bp) in design.penalties.iter().enumerate() {
3154 let col_start = bp.col_range.start;
3155 let col_end = bp.col_range.end;
3156 if col_start >= col_end || col_end > p_per_class {
3157 continue;
3158 }
3159 if smooth_term_spans
3160 .iter()
3161 .any(|s| s.col_start == col_start && s.col_end == col_end)
3162 {
3163 continue;
3164 }
3165 let label = design
3166 .penaltyinfo
3167 .get(pen_idx)
3168 .and_then(|info| info.termname.clone())
3169 .unwrap_or_else(|| format!("s{pen_idx}"));
3170 let nullspace_dim = design
3171 .nullspace_dims
3172 .get(pen_idx)
3173 .copied()
3174 .unwrap_or(0)
3175 .min(col_end - col_start);
3176 smooth_term_spans.push(MultinomialSmoothTermSpan {
3177 label,
3178 col_start,
3179 col_end,
3180 nullspace_dim,
3181 });
3182 }
3183
3184 // One descriptive label per penalty *component* within a single class block,
3185 // parallel to that block's λ slice (#1544). `design.penalties` is index-
3186 // parallel to every active class's `block.lambdas` (each block carries the
3187 // full per-component penalty list, validated above by
3188 // `block.lambdas.len() == penalties_arc.len()`), so iterating it in order
3189 // yields exactly `lambdas_per_block[0]` labels aligned with the per-block λ.
3190 // This is deliberately NOT deduped by col_range (unlike `smooth_term_spans`):
3191 // the double penalty's primary and null-space components share one col_range
3192 // but select independent λ, and each must keep its own label so the summary
3193 // renderer never collapses or drops a λ.
3194 let lambda_labels: Vec<String> = design
3195 .penalties
3196 .iter()
3197 .enumerate()
3198 .map(|(pen_idx, _)| penalty_component_label(design.penaltyinfo.get(pen_idx), pen_idx))
3199 .collect();
3200
3201 // Unpenalized deviance read directly from the converged unpenalized
3202 // log-likelihood the rho-prior driver already computed (issue #348):
3203 // MultinomialFamily::evaluate sets FamilyEvaluation.log_likelihood =
3204 // log_lik(η, y) with no penalty term, and that value flows unchanged into
3205 // UnifiedFitResult.log_likelihood. This reproduces the legacy fixed-λ
3206 // path's `deviance = -2 · log_lik` contract bit-for-bit, so the previous
3207 // row-by-row η = Xβ rebuild and softmax recompute were pure dead work.
3208 let deviance = -2.0 * fit.log_likelihood;
3209
3210 Ok(MultinomialSavedModel {
3211 formula: formula.to_string(),
3212 class_levels: class_levels.clone(),
3213 reference_class_index: class_levels.len() - 1,
3214 resolved_termspec: spec,
3215 coefficients_flat,
3216 p_per_class,
3217 n_active_classes: m,
3218 training_headers: data.headers.clone(),
3219 training_table_kind: config.training_table_kind.clone(),
3220 lambdas: lambdas_flat,
3221 lambdas_per_block,
3222 iterations: fit.inner_cycles,
3223 penalized_neg_log_likelihood: -fit.log_likelihood + 0.5 * fit.stable_penalty_term,
3224 deviance,
3225 edf_per_class,
3226 edf_per_penalty,
3227 coefficient_covariance_flat,
3228 coefficient_influence_flat,
3229 smooth_term_spans,
3230 lambda_labels,
3231 })
3232}
3233
3234/// Replay the saved termspec to build the predict-time dense design `X` on a
3235/// fresh dataset, realigning feature columns **by name** so the predict frame
3236/// need not reproduce the training column order or carry the response column.
3237/// Shared by every multinomial predict path (probabilities, SE bands, and the
3238/// posterior-predictive replicate draws).
3239fn build_multinomial_predict_design(
3240 model: &MultinomialSavedModel,
3241 data: &EncodedDataset,
3242) -> Result<Array2<f64>, EstimationError> {
3243 // The saved termspec stores feature columns as absolute indices into the
3244 // *training* table `[response, features...]`. Realign them onto this
3245 // dataset's columns by name, so prediction works on label-free new data
3246 // (the response column is never referenced by any term; issue #803).
3247 let predict_columns = data.column_map();
3248 let realigned = model.resolved_termspec.remap_feature_columns(
3249 |index| -> Result<usize, EstimationError> {
3250 let name = model.training_headers.get(index).ok_or_else(|| {
3251 EstimationError::InvalidInput(format!(
3252 "multinomial predict: saved training column index {index} is out of bounds \
3253 for {} training headers",
3254 model.training_headers.len()
3255 ))
3256 })?;
3257 resolve_role_col(&predict_columns, name, "feature")
3258 .map_err(|err| EstimationError::InvalidInput(err.to_string()))
3259 },
3260 )?;
3261 let design = build_term_collection_design(data.values.view(), &realigned).map_err(|err| {
3262 EstimationError::InvalidInput(format!(
3263 "multinomial predict: rebuild design from saved termspec: {err}"
3264 ))
3265 })?;
3266 if design.affine_offset.iter().any(|value| *value != 0.0) {
3267 crate::bail_invalid_estim!(
3268 "multinomial predict does not support non-zero smooth anchors: the saved \
3269 reference-coded softmax has no per-class affine offset channel"
3270 );
3271 }
3272 let x_dense = design
3273 .design
3274 .try_to_dense_by_chunks("multinomial predict design")
3275 .map_err(EstimationError::InvalidInput)?;
3276 if x_dense.ncols() != model.p_per_class {
3277 crate::bail_invalid_estim!(
3278 "multinomial predict: predict design has {} cols, saved model expects {}",
3279 x_dense.ncols(),
3280 model.p_per_class
3281 );
3282 }
3283 Ok(x_dense)
3284}
3285
3286/// Replay the saved termspec to build the predict-time design on a fresh
3287/// dataset, then evaluate softmax probabilities. The predict dataset must carry
3288/// the same feature columns the training data did, matched **by name** — it need
3289/// not reproduce the training column order, and in particular need not carry the
3290/// response column (prediction is for label-free new data).
3291pub fn predict_multinomial_formula(
3292 model: &MultinomialSavedModel,
3293 data: &EncodedDataset,
3294) -> Result<Array2<f64>, EstimationError> {
3295 model.validate()?;
3296 let x_dense = build_multinomial_predict_design(model, data)?;
3297 model.predict_probabilities(x_dense.view())
3298}
3299
3300/// Draw `n_draws` posterior-predictive replicate class-label assignments for a
3301/// saved multinomial model on fresh data (#1101). Rebuilds the predict design
3302/// exactly as [`predict_multinomial_formula`], then samples each row's class
3303/// from `Categorical(E[softmax(η) | data])` (see
3304/// [`MultinomialSavedModel::sample_replicate_classes`]). Returns an
3305/// `(n_draws, N)` matrix of class INDICES `0..K` aligned to `model.class_levels`,
3306/// deterministic in `seed`.
3307pub fn posterior_predict_multinomial_formula(
3308 model: &MultinomialSavedModel,
3309 data: &EncodedDataset,
3310 n_draws: usize,
3311 seed: u64,
3312) -> Result<Array2<u32>, EstimationError> {
3313 if n_draws == 0 {
3314 crate::bail_invalid_estim!("multinomial posterior_predict: n_draws must be >= 1");
3315 }
3316 model.validate()?;
3317 let x_dense = build_multinomial_predict_design(model, data)?;
3318 model.sample_replicate_classes(x_dense.view(), n_draws, seed)
3319}
3320
3321/// Predict posterior-mean class probabilities and integrated marginal
3322/// standard deviations for a saved multinomial model on fresh data.
3323pub fn predict_multinomial_formula_with_se(
3324 model: &MultinomialSavedModel,
3325 data: &EncodedDataset,
3326) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
3327 model.validate()?;
3328 let x_dense = build_multinomial_predict_design(model, data)?;
3329 model.predict_probabilities_with_se(x_dense.view())
3330}
3331
3332#[derive(Debug, Clone)]
3333pub struct MultinomialPredictionIntervals {
3334 pub mean: Array2<f64>,
3335 pub standard_error: Array2<f64>,
3336 pub mean_lower: Array2<f64>,
3337 pub mean_upper: Array2<f64>,
3338 pub level: f64,
3339}
3340
3341/// Build simplex-clamped normal moment intervals around the integrated
3342/// logistic-normal posterior mean. Both center and spread come from the same
3343/// deterministic posterior integral; no plug-in/delta quantity enters.
3344pub fn predict_multinomial_formula_with_intervals(
3345 model: &MultinomialSavedModel,
3346 data: &EncodedDataset,
3347 level: f64,
3348) -> Result<MultinomialPredictionIntervals, EstimationError> {
3349 if !(level.is_finite() && level > 0.0 && level < 1.0) {
3350 crate::bail_invalid_estim!(
3351 "multinomial prediction interval level must be finite and in (0, 1), got {level}"
3352 );
3353 }
3354 let (mean, standard_error) = predict_multinomial_formula_with_se(model, data)?;
3355 let z = gam_math::probability::standard_normal_quantile(0.5 + 0.5 * level)
3356 .map_err(EstimationError::InvalidInput)?;
3357 let mut mean_lower = mean.clone();
3358 let mut mean_upper = mean.clone();
3359 for ((row, class), &se) in standard_error.indexed_iter() {
3360 mean_lower[[row, class]] = (mean[[row, class]] - z * se).clamp(0.0, 1.0);
3361 mean_upper[[row, class]] = (mean[[row, class]] + z * se).clamp(0.0, 1.0);
3362 }
3363 Ok(MultinomialPredictionIntervals {
3364 mean,
3365 standard_error,
3366 mean_lower,
3367 mean_upper,
3368 level,
3369 })
3370}
3371
3372#[cfg(test)]
3373mod fisher_override_tests {
3374 use super::*;
3375
3376 /// Extra evidence used only for a NON-CONVERGED capped unbiased probe.
3377 ///
3378 /// A converged finite saturated formula fit is still a valid optimum and
3379 /// must be scored without Firth bias. A capped probe that failed to
3380 /// converge while it already carries separation-scale logits is different:
3381 /// spending the full unbiased outer budget on the same lambda-to-zero
3382 /// surface is the #1082 timeout. Route that case straight to the
3383 /// proper-prior refit.
3384 ///
3385 /// Kept in the test module: the production routing that would consume this
3386 /// (the non-converged-probe branch) is not currently wired, so the helper
3387 /// is test-support only rather than dead production code.
3388 fn multinomial_formula_unresolved_probe_separation_evidence(
3389 block_states: &[ParameterBlockState],
3390 ) -> Option<String> {
3391 if let Some(evidence) = multinomial_formula_separation_evidence(block_states) {
3392 return Some(evidence);
3393 }
3394
3395 let mut best = (0.0_f64, 0usize, 0usize);
3396 for (active_class, state) in block_states.iter().enumerate() {
3397 for (row, &value) in state.eta.iter().enumerate() {
3398 let abs = value.abs();
3399 if abs > best.0 {
3400 best = (abs, row, active_class);
3401 }
3402 }
3403 }
3404 if best.0 >= MULTINOMIAL_SEPARATION_ETA_THRESHOLD {
3405 Some(format!(
3406 "separation-scale finite logit |eta[row {}, active class {}]| = {:.3e} \
3407 after capped unbiased probe",
3408 best.1, best.2, best.0
3409 ))
3410 } else {
3411 None
3412 }
3413 }
3414 use ndarray::Array3;
3415
3416 fn toy() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
3417 let n = 15;
3418 let p = 2;
3419 let k = 3;
3420 let design =
3421 Array2::<f64>::from_shape_fn(
3422 (n, p),
3423 |(i, j)| {
3424 if j == 0 { 1.0 } else { ((i + 2) as f64).cos() }
3425 },
3426 );
3427 let mut y = Array2::<f64>::zeros((n, k));
3428 for i in 0..n {
3429 y[[i, i % k]] = 1.0;
3430 }
3431 let penalty = Array2::<f64>::eye(p);
3432 // #2344: K per-class lambdas (reference class included).
3433 let lambdas = Array1::<f64>::from_elem(k, 0.5);
3434 (design, y, penalty, lambdas)
3435 }
3436
3437 #[test]
3438 fn fisher_override_none_reproduces_analytic() {
3439 // Issue #349: None override is exactly the analytic fit.
3440 let (design, y, penalty, lambdas) = toy();
3441 let mk = |over: Option<ndarray::ArrayView3<'_, f64>>| {
3442 fit_penalized_multinomial(MultinomialFitInputs {
3443 design: design.view(),
3444 y_one_hot: y.view(),
3445 penalty: penalty.view(),
3446 lambdas: lambdas.view(),
3447 row_weights: None,
3448 fisher_w_override: over,
3449 max_iter: 50,
3450 tol: 1.0e-9,
3451 resume_from: None,
3452 })
3453 .expect("fit must succeed")
3454 };
3455 let a = mk(None);
3456 let b = mk(None);
3457 for (x, z) in a
3458 .coefficients_active
3459 .iter()
3460 .zip(b.coefficients_active.iter())
3461 {
3462 assert_eq!(x, z);
3463 }
3464 }
3465
3466 #[test]
3467 fn exhausted_fixed_lambda_budget_is_typed_error_not_fit() {
3468 let (design, y, penalty, lambdas) = toy();
3469 let error = fit_penalized_multinomial(MultinomialFitInputs {
3470 design: design.view(),
3471 y_one_hot: y.view(),
3472 penalty: penalty.view(),
3473 lambdas: lambdas.view(),
3474 row_weights: None,
3475 fisher_w_override: None,
3476 max_iter: 0,
3477 tol: 1.0e-9,
3478 resume_from: None,
3479 })
3480 .expect_err("a zero-budget Newton solve must not mint a multinomial fit");
3481 assert!(matches!(
3482 error,
3483 EstimationError::FixedLambdaNewtonDidNotConverge {
3484 objective_value,
3485 checkpoint,
3486 ..
3487 } if objective_value.is_finite()
3488 && checkpoint.stage() == FixedLambdaSolverStage::MultinomialNewton
3489 && checkpoint.completed_iterations() == 0
3490 ));
3491 }
3492
3493 #[test]
3494 fn fixed_lambda_checkpoint_resume_matches_uninterrupted_solve() {
3495 let (design, y, penalty, lambdas) = toy();
3496 let interrupted = fit_penalized_multinomial(MultinomialFitInputs {
3497 design: design.view(),
3498 y_one_hot: y.view(),
3499 penalty: penalty.view(),
3500 lambdas: lambdas.view(),
3501 row_weights: None,
3502 fisher_w_override: None,
3503 max_iter: 1,
3504 tol: 1.0e-9,
3505 resume_from: None,
3506 })
3507 .expect_err("one Newton step must leave this coupled fit uncertified");
3508 let checkpoint = match interrupted {
3509 EstimationError::FixedLambdaNewtonDidNotConverge { checkpoint, .. } => checkpoint,
3510 other => panic!("unexpected interruption error: {other}"),
3511 };
3512 assert_eq!(
3513 checkpoint.stage(),
3514 FixedLambdaSolverStage::MultinomialNewton
3515 );
3516 assert_eq!(checkpoint.completed_iterations(), 1);
3517
3518 let resumed = fit_penalized_multinomial(MultinomialFitInputs {
3519 design: design.view(),
3520 y_one_hot: y.view(),
3521 penalty: penalty.view(),
3522 lambdas: lambdas.view(),
3523 row_weights: None,
3524 fisher_w_override: None,
3525 max_iter: 49,
3526 tol: 1.0e-9,
3527 resume_from: Some(&checkpoint),
3528 })
3529 .expect("resumed multinomial solve must converge");
3530 let uninterrupted = fit_penalized_multinomial(MultinomialFitInputs {
3531 design: design.view(),
3532 y_one_hot: y.view(),
3533 penalty: penalty.view(),
3534 lambdas: lambdas.view(),
3535 row_weights: None,
3536 fisher_w_override: None,
3537 max_iter: 50,
3538 tol: 1.0e-9,
3539 resume_from: None,
3540 })
3541 .expect("uninterrupted multinomial solve must converge");
3542
3543 assert_eq!(resumed.iterations, uninterrupted.iterations);
3544 assert_eq!(
3545 resumed.coefficients_active,
3546 uninterrupted.coefficients_active
3547 );
3548 assert_eq!(
3549 resumed.penalized_neg_log_likelihood,
3550 uninterrupted.penalized_neg_log_likelihood,
3551 );
3552 assert_eq!(
3553 resumed.coefficient_covariance,
3554 uninterrupted.coefficient_covariance,
3555 );
3556 }
3557
3558 #[test]
3559 fn fisher_override_wrong_shape_is_rejected() {
3560 let (design, y, penalty, lambdas) = toy();
3561 let n = design.nrows();
3562 let m = y.ncols(); // K, not K-1 — deliberately wrong
3563 let bad = Array3::<f64>::zeros((n, m, m));
3564 let err = fit_penalized_multinomial(MultinomialFitInputs {
3565 design: design.view(),
3566 y_one_hot: y.view(),
3567 penalty: penalty.view(),
3568 lambdas: lambdas.view(),
3569 row_weights: None,
3570 fisher_w_override: Some(bad.view()),
3571 max_iter: 50,
3572 tol: 1.0e-9,
3573 resume_from: None,
3574 })
3575 .expect_err("wrong active-block shape must error");
3576 assert!(format!("{err}").contains("fisher_w_override shape"));
3577 }
3578
3579 /// #1101 regression: the fixed-λ inner solve now surfaces the joint Laplace
3580 /// coefficient covariance `H⁻¹`, and the multinomial predictor derives
3581 /// finite delta-method per-class probability standard errors from it. Before
3582 /// this change `MultinomialFitOutputs` carried NO covariance at all, so the
3583 /// covariance-dimension / predictor assertions below could not even compile
3584 /// (fail-before). Asserts, with un-weakened bounds:
3585 /// 1. covariance is `(P·(K−1))²`, all-finite, symmetric, and PSD (every
3586 /// diagonal ≥ 0 and `vᵀΣv ≥ 0` on probe vectors);
3587 /// 2. the delta-method per-class probability SEs are finite and within
3588 /// `[0, 1]` (a probability SE can never exceed the unit interval);
3589 /// 3. predicted probabilities are finite, in `[0, 1]`, and each row sums
3590 /// to 1 (simplex).
3591 #[test]
3592 fn covariance_and_delta_method_se_are_finite_and_wellformed_1101() {
3593 let (design, y, penalty, lambdas) = toy();
3594 let p = design.ncols();
3595 let k = y.ncols();
3596 let m = k - 1;
3597 let d = p * m;
3598
3599 let fit = fit_penalized_multinomial(MultinomialFitInputs {
3600 design: design.view(),
3601 y_one_hot: y.view(),
3602 penalty: penalty.view(),
3603 lambdas: lambdas.view(),
3604 row_weights: None,
3605 fisher_w_override: None,
3606 max_iter: 50,
3607 tol: 1.0e-9,
3608 resume_from: None,
3609 })
3610 .expect("fit must succeed");
3611 // (1) Covariance shape, finiteness, symmetry.
3612 let cov = &fit.coefficient_covariance;
3613 assert_eq!(
3614 cov.dim(),
3615 (d, d),
3616 "covariance must be (P·(K−1))² = ({d},{d})"
3617 );
3618 for &v in cov.iter() {
3619 assert!(v.is_finite(), "covariance entry must be finite (got {v})");
3620 }
3621 for i in 0..d {
3622 for j in 0..d {
3623 let asym = (cov[[i, j]] - cov[[j, i]]).abs();
3624 assert!(
3625 asym <= 1e-9 * (1.0 + cov[[i, j]].abs()),
3626 "covariance must be symmetric at ({i},{j}): |Σ_ij − Σ_ji| = {asym:.3e}"
3627 );
3628 }
3629 }
3630 // PSD: diagonal ≥ 0 and quadratic forms on deterministic probe vectors
3631 // (unit axes and the all-ones vector) are non-negative. `H = XᵀWX + λS`
3632 // with W PSD (softmax Fisher) and S PSD (identity here) is positive
3633 // definite, so its inverse is PD; these probes must all be positive.
3634 for i in 0..d {
3635 assert!(
3636 cov[[i, i]] >= 0.0,
3637 "covariance diagonal[{i}] must be ≥ 0 (got {})",
3638 cov[[i, i]]
3639 );
3640 }
3641 let mut probes: Vec<Vec<f64>> = Vec::new();
3642 for i in 0..d {
3643 let mut e = vec![0.0_f64; d];
3644 e[i] = 1.0;
3645 probes.push(e);
3646 }
3647 probes.push(vec![1.0_f64; d]);
3648 for v in &probes {
3649 let mut q = 0.0_f64;
3650 for i in 0..d {
3651 for j in 0..d {
3652 q += v[i] * cov[[i, j]] * v[j];
3653 }
3654 }
3655 assert!(q >= -1e-9, "covariance must be PSD: vᵀΣv = {q:.3e} < 0");
3656 }
3657
3658 // (2) & (3) Delta-method SEs and simplex probabilities on the training
3659 // design (any P-column matrix in the fitted basis works).
3660 let (probs, prob_se) = fit
3661 .predict_probabilities_with_se(design.view())
3662 .expect("delta-method SE must succeed");
3663 let n = design.nrows();
3664 assert_eq!(probs.dim(), (n, k));
3665 assert_eq!(prob_se.dim(), (n, k));
3666 for row in 0..n {
3667 let mut rowsum = 0.0_f64;
3668 for c in 0..k {
3669 let pc = probs[[row, c]];
3670 assert!(
3671 pc.is_finite() && (0.0..=1.0).contains(&pc),
3672 "prob[{row},{c}]={pc}"
3673 );
3674 rowsum += pc;
3675 let se = prob_se[[row, c]];
3676 assert!(
3677 se.is_finite(),
3678 "prob_se[{row},{c}] must be finite (got {se})"
3679 );
3680 assert!(
3681 (0.0..=1.0).contains(&se),
3682 "prob_se[{row},{c}] must be in [0,1] (got {se})"
3683 );
3684 }
3685 assert!(
3686 (rowsum - 1.0).abs() < 1e-9,
3687 "row {row} probabilities must sum to 1 (got {rowsum})"
3688 );
3689 }
3690 }
3691
3692 #[test]
3693 fn formula_outer_route_uses_exact_curvature_for_medium_d() {
3694 // The 2-smooth reference formula fit (K = 3, double-penalty terms) is
3695 // D = (K-1) * 2 terms * 2 penalties = 8 and needs exact curvature to
3696 // avoid over-smoothed lambda caps (#715 arm (a)).
3697 assert!(
3698 multinomial_formula_use_outer_hessian(8),
3699 "D=8 loaded multinomial fits need exact curvature to avoid over-smoothed lambda caps"
3700 );
3701 assert!(
3702 multinomial_formula_use_outer_hessian(12),
3703 "D=12 (3 double-penalty smooth terms, K=3) stays on exact curvature"
3704 );
3705 }
3706
3707 #[test]
3708 fn formula_outer_route_uses_exact_curvature_for_d16_penguin_fixture() {
3709 // Four k=10 penguin smooths (K = 3) are D = 16 under double-penalty
3710 // terms. They must reach the exact ARC route so the #1082 cost-stall
3711 // halt is available on the near-separable lambda-to-zero ridge.
3712 assert!(
3713 multinomial_formula_use_outer_hessian(16),
3714 "D=16 multinomial fits need exact ARC curvature for the #1082 stall halt"
3715 );
3716 }
3717
3718 #[test]
3719 fn formula_min_lambda_floor_is_continuous_and_information_scaled() {
3720 // Build a one-hot label matrix whose smallest class carries `count` rows.
3721 fn floor_for_min_count(count: usize) -> f64 {
3722 // Two classes: a large one (1000 rows) and a minority one (`count`).
3723 let n = 1000 + count;
3724 let mut y = Array2::<f64>::zeros((n, 2));
3725 for r in 0..1000 {
3726 y[[r, 0]] = 1.0;
3727 }
3728 for r in 1000..n {
3729 y[[r, 1]] = 1.0;
3730 }
3731 multinomial_formula_min_lambda(y.view())
3732 }
3733
3734 // The floor's endpoints are now DERIVED from a target prior strength in
3735 // pseudo-observations against the maximal per-observation softmax Fisher
3736 // information I₁ = ¼ (base = τ·I₁, sparse = τ_max·I₁). Pin them to the
3737 // previously fixture-calibrated values so the near-separable quality arms
3738 // (penguins, vgam softmax) — whose smallest class has n_c ≥ 50 — are
3739 // byte-for-byte unaffected: the derivation REDUCES TO the old constants
3740 // at the calibration point.
3741 let base = MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
3742 let sparse = MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX
3743 * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
3744 assert!(
3745 (base - 2.0e-4).abs() < 1e-18,
3746 "derived base floor must equal the calibrated 2e-4"
3747 );
3748 assert!(
3749 (sparse - 1.0e-3).abs() < 1e-18,
3750 "derived sparse floor must equal the calibrated 1e-3"
3751 );
3752
3753 // Well-supported (n_c >= n_ref=50) sits exactly at the base floor.
3754 assert!((floor_for_min_count(50) - base).abs() < 1e-18);
3755 assert!((floor_for_min_count(200) - base).abs() < 1e-18);
3756 // Very sparse (n_c <= n_ref·base/sparse = 10) clamps to the strong floor.
3757 assert!((floor_for_min_count(10) - sparse).abs() < 1e-18);
3758 assert!((floor_for_min_count(5) - sparse).abs() < 1e-18);
3759 // No cliff at the old hard threshold: 49 vs 50 differ by < 5% (the old
3760 // step jumped 5x). Floor is monotone non-increasing in support.
3761 let f49 = floor_for_min_count(49);
3762 let f50 = floor_for_min_count(50);
3763 assert!(
3764 f49 >= f50 && f49 <= f50 * 1.05,
3765 "floor must be continuous across c0, got {f49} vs {f50}"
3766 );
3767 let f25 = floor_for_min_count(25);
3768 assert!(
3769 f25 > f50 && f25 < floor_for_min_count(10),
3770 "mid-support floor must interpolate strictly between the two endpoints"
3771 );
3772
3773 // FIRST-PRINCIPLES SCALING: in the interpolating regime the floor equals
3774 // exactly τ·I₁·(n_ref/n_c) — the effective-pseudo-observation prior held
3775 // to a fixed fraction of the per-class data information n_c·I₁. Halving
3776 // the effective sample size doubles the floor (until the cap), and the
3777 // absolute value matches the closed-form n_c-scaled prior.
3778 for &n_c in &[12usize, 16, 20, 30, 40] {
3779 let expected = base * (MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT / n_c as f64);
3780 assert!(
3781 (floor_for_min_count(n_c) - expected).abs() < 1e-15,
3782 "floor at n_c={n_c} must be τ·I₁·n_ref/n_c = {expected}, got {}",
3783 floor_for_min_count(n_c)
3784 );
3785 }
3786 // Inverse scaling with effective sample size: n_c -> n_c/2 doubles the
3787 // floor inside the unclamped band (20 and 40 are both interior; 40 < 50
3788 // so it is scaled, 20 > 10 so it is not capped).
3789 assert!(
3790 (floor_for_min_count(20) - 2.0 * floor_for_min_count(40)).abs() < 1e-15,
3791 "floor must scale like 1/n_c (effective Fisher information) in the interior band"
3792 );
3793 }
3794
3795 #[test]
3796 fn formula_penalty_scale_tracks_softmax_fisher_curvature() {
3797 assert!(
3798 (multinomial_formula_penalty_scale(2) - 0.5).abs() < 1.0e-12,
3799 "binary-logit neutral-simplex curvature scale should remain at 1/2"
3800 );
3801 assert!(
3802 (multinomial_formula_penalty_scale(3) - 4.0 / 9.0).abs() < 1.0e-12,
3803 "three-class softmax penalties should be calibrated to 2*(K-1)/K^2"
3804 );
3805 assert!(
3806 multinomial_formula_penalty_scale(5) < multinomial_formula_penalty_scale(3),
3807 "active-class Fisher curvature decreases as the simplex gains classes"
3808 );
3809 }
3810
3811 #[test]
3812 fn fixed_lambda_multinomial_firth_keeps_complete_separation_finite() {
3813 // #1854: complete softmax separation used to be a HARD diagnostic
3814 // (`MultinomialSeparationDetected`). It now automatically engages the
3815 // Firth/Jeffreys proper prior (`½ log|I(β)|`, magic-by-default) so the fit
3816 // stays finite instead of running away — the same guarantee the formula
3817 // REML path already provided. The class regions are cleanly separated by
3818 // `x`, so the unbiased MLE is at infinity; the Firth-penalized fit must
3819 // still converge to a finite mode and recover the region structure.
3820 let n = 90;
3821 let design = Array2::<f64>::from_shape_fn((n, 2), |(row, col)| match col {
3822 0 => 1.0,
3823 _ => -3.0 + 6.0 * (row as f64) / ((n - 1) as f64),
3824 });
3825 let mut y = Array2::<f64>::zeros((n, 3));
3826 for row in 0..n {
3827 let x = design[[row, 1]];
3828 let class = if x < -1.0 {
3829 0
3830 } else if x > 1.0 {
3831 1
3832 } else {
3833 2
3834 };
3835 y[[row, class]] = 1.0;
3836 }
3837 let penalty = Array2::<f64>::zeros((2, 2));
3838 // #2344: K per-class lambdas (reference class included); K = 3 here.
3839 let lambdas = Array1::<f64>::zeros(3);
3840 let out = fit_penalized_multinomial(MultinomialFitInputs {
3841 design: design.view(),
3842 y_one_hot: y.view(),
3843 penalty: penalty.view(),
3844 lambdas: lambdas.view(),
3845 row_weights: None,
3846 fisher_w_override: None,
3847 max_iter: 80,
3848 tol: 1.0e-12,
3849 resume_from: None,
3850 })
3851 .expect("Firth/Jeffreys prior keeps the separated multinomial fit finite (#1854)");
3852 // Every coefficient is finite — the whole point of the Firth prior on the
3853 // separated (unpenalized) logit directions.
3854 for &b in out.coefficients_active.iter() {
3855 assert!(
3856 b.is_finite(),
3857 "Firth-penalized coefficients must be finite, got {b}"
3858 );
3859 }
3860 // Fitted probabilities remain a valid simplex per row.
3861 for row in 0..n {
3862 let mut mass = 0.0_f64;
3863 for c in 0..3 {
3864 let p = out.fitted_probabilities[[row, c]];
3865 assert!(
3866 p.is_finite() && (0.0..=1.0 + 1e-9).contains(&p),
3867 "row {row} class {c} probability {p} out of [0,1]"
3868 );
3869 mass += p;
3870 }
3871 assert!(
3872 (mass - 1.0).abs() < 1e-6,
3873 "row {row} probabilities must sum to 1, got {mass}"
3874 );
3875 }
3876 // The finite fit still recovers the separated structure: on a clearly
3877 // interior representative of each region the predicted class is correct.
3878 let predict = |x: f64| -> usize {
3879 let mut eta = [0.0_f64; 3];
3880 for a in 0..2 {
3881 eta[a] = out.coefficients_active[[0, a]] + out.coefficients_active[[1, a]] * x;
3882 }
3883 let mut best = 0usize;
3884 for c in 1..3 {
3885 if eta[c] > eta[best] {
3886 best = c;
3887 }
3888 }
3889 best
3890 };
3891 assert_eq!(predict(-2.5), 0, "deep-left region should predict class 0");
3892 assert_eq!(predict(2.5), 1, "deep-right region should predict class 1");
3893 assert_eq!(predict(0.0), 2, "central region should predict class 2");
3894 }
3895
3896 #[test]
3897 fn formula_multinomial_accepts_finite_saturated_logits() {
3898 // A saturated-but-FINITE logit surface can be a valid formula REML mode
3899 // (the #715 penguins regime: bill/flipper cleanly separate the species,
3900 // so fitted logits can legitimately exceed ±25). `outer_converged ==
3901 // false` then signals only that the driver auto-escalated to never-fail
3902 // posterior sampling about that finite mode (gam#860), NOT a separation
3903 // artifact — the adapter must accept it, never raise
3904 // `MultinomialSeparationDetected`.
3905 let saturated_states = vec![
3906 ParameterBlockState {
3907 beta: Array1::from_vec(vec![1.0, 2.0]),
3908 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3909 },
3910 ParameterBlockState {
3911 beta: Array1::from_vec(vec![-1.0, 3.0]),
3912 eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
3913 },
3914 ];
3915 assert!(
3916 multinomial_formula_separation_diagnostic(17, 9, &saturated_states).is_none(),
3917 "a finite (even saturated, |eta|>25) formula optimum is a valid fit, \
3918 not a separation diagnostic"
3919 );
3920
3921 // Only a genuinely NON-FINITE logit — a NaN/Inf blow-up in the inner
3922 // linear algebra with no finite mode to sample about — is a real
3923 // formula-path failure.
3924 let blown_up = vec![
3925 ParameterBlockState {
3926 beta: Array1::from_vec(vec![1.0, 2.0]),
3927 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3928 },
3929 ParameterBlockState {
3930 beta: Array1::from_vec(vec![-1.0, 3.0]),
3931 eta: Array1::from_vec(vec![1.0, f64::INFINITY, -0.1]),
3932 },
3933 ];
3934 let err = multinomial_formula_separation_diagnostic(17, 9, &blown_up)
3935 .expect("a non-finite formula logit must raise the separation diagnostic");
3936 assert!(
3937 matches!(
3938 err,
3939 EstimationError::MultinomialSeparationDetected {
3940 iteration: 17,
3941 max_abs_eta,
3942 active_class_index: 1,
3943 row_index: 1,
3944 } if !max_abs_eta.is_finite()
3945 ),
3946 "expected typed multinomial separation diagnostic at the non-finite channel, got {err:?}"
3947 );
3948 }
3949
3950 #[test]
3951 fn separation_evidence_gate_arms_firth_only_on_blowup() {
3952 // Interior fit: finite logits well inside the saturation threshold ⇒ NO
3953 // separation evidence ⇒ the unbiased criterion's mode is accepted as-is
3954 // and the Firth/Jeffreys prior stays disarmed (#715 arm (a): no 1/K
3955 // shrinkage on well-identified data).
3956 let interior = vec![
3957 ParameterBlockState {
3958 beta: Array1::from_vec(vec![1.0, 2.0]),
3959 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3960 },
3961 ParameterBlockState {
3962 beta: Array1::from_vec(vec![-1.0, 3.0]),
3963 eta: Array1::from_vec(vec![1.0, -3.5, -0.1]),
3964 },
3965 ];
3966 assert!(
3967 multinomial_formula_separation_evidence(&interior).is_none(),
3968 "an interior finite mode must not arm the Firth refit"
3969 );
3970
3971 // Saturated but finite logits are valid formula-path modes on
3972 // near-separated real data. They must not arm the Firth refit because
3973 // the Jeffreys pull can over-regularize the held-out probabilities.
3974 let saturated = vec![
3975 ParameterBlockState {
3976 beta: Array1::from_vec(vec![1.0, 2.0]),
3977 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3978 },
3979 ParameterBlockState {
3980 beta: Array1::from_vec(vec![-1.0, 3.0]),
3981 eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
3982 },
3983 ];
3984 assert!(
3985 multinomial_formula_separation_evidence(&saturated).is_none(),
3986 "a finite saturated formula-mode logit must not arm the Firth refit"
3987 );
3988
3989 // Non-finite logit ⇒ inner blow-up along an unbounded direction ⇒
3990 // separation evidence.
3991 let blown_up = vec![ParameterBlockState {
3992 beta: Array1::from_vec(vec![1.0, 2.0]),
3993 eta: Array1::from_vec(vec![0.2, f64::NAN, -7.0]),
3994 }];
3995 let evidence = multinomial_formula_separation_evidence(&blown_up)
3996 .expect("a non-finite logit is separation evidence");
3997 assert!(
3998 evidence.contains("non-finite logit") && evidence.contains("row 1"),
3999 "evidence must name the non-finite logit, got {evidence}"
4000 );
4001
4002 // Large finite logits below the fixed-lambda diagnostic threshold are
4003 // likewise accepted on the formula path.
4004 let near = vec![ParameterBlockState {
4005 beta: Array1::from_vec(vec![1.0, 2.0]),
4006 eta: Array1::from_vec(vec![0.2, 24.9, -24.9]),
4007 }];
4008 assert!(
4009 multinomial_formula_separation_evidence(&near).is_none(),
4010 "logits below the saturation threshold must not arm the Firth refit"
4011 );
4012 }
4013
4014 #[test]
4015 fn unresolved_probe_evidence_arms_firth_on_saturated_finite_logits() {
4016 let saturated = vec![
4017 ParameterBlockState {
4018 beta: Array1::from_vec(vec![1.0, 2.0]),
4019 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
4020 },
4021 ParameterBlockState {
4022 beta: Array1::from_vec(vec![-1.0, 3.0]),
4023 eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
4024 },
4025 ];
4026
4027 assert!(
4028 multinomial_formula_separation_evidence(&saturated).is_none(),
4029 "a converged finite saturated formula optimum remains unbiased"
4030 );
4031 let evidence = multinomial_formula_unresolved_probe_separation_evidence(&saturated)
4032 .expect("a non-converged saturated probe should arm the Firth refit");
4033 assert!(
4034 evidence.contains("separation-scale finite logit")
4035 && evidence.contains("row 1")
4036 && evidence.contains("active class 1"),
4037 "unresolved-probe evidence should name the saturated channel, got {evidence}"
4038 );
4039
4040 let near = vec![ParameterBlockState {
4041 beta: Array1::from_vec(vec![1.0, 2.0]),
4042 eta: Array1::from_vec(vec![0.2, 24.9, -24.9]),
4043 }];
4044 assert!(
4045 multinomial_formula_unresolved_probe_separation_evidence(&near).is_none(),
4046 "finite logits below the separation threshold still get the full unbiased retry"
4047 );
4048 }
4049
4050 #[test]
4051 fn scaled_fisher_override_changes_first_step() {
4052 // Curvature scaled by 4× shrinks the first Newton step relative to the
4053 // analytic fit, so a single-iteration fit must differ.
4054 let (design, y, penalty, lambdas) = toy();
4055 let n = design.nrows();
4056 let m = y.ncols() - 1;
4057 // #2344: toy() now carries K per-class lambdas for the multinomial
4058 // ENTRY; the direct Centered-metric ENGINE calls below read
4059 // M = lambdas.len(), so hand them the M-length shared-lambda vector.
4060 let engine_lambdas = Array1::<f64>::from_elem(m, lambdas[0]);
4061 // Analytic block at β = 0: p_a = 1/K = 1/3, so diag = p_a(1−p_a),
4062 // off-diag = −p_a p_b. Scale that exact block by 4.
4063 let pk = 1.0 / (y.ncols() as f64);
4064 let mut over = Array3::<f64>::zeros((n, m, m));
4065 for row in 0..n {
4066 for a in 0..m {
4067 for b in 0..m {
4068 let analytic = if a == b { pk * (1.0 - pk) } else { -pk * pk };
4069 over[[row, a, b]] = 4.0 * analytic;
4070 }
4071 }
4072 }
4073 let likelihood =
4074 MultinomialLogitLikelihood::with_classes(y.ncols()).expect("test class count is valid");
4075 let scaled = fit_penalized_vector_glm(
4076 PenalizedVectorGlmInputs {
4077 design: design.view(),
4078 y: y.view(),
4079 penalty: penalty.view(),
4080 lambdas: engine_lambdas.view(),
4081 fisher_w_override: Some(over.view()),
4082 max_iter: 1,
4083 tol: 1.0e-9,
4084 class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Centered,
4085 resume_from: None,
4086 },
4087 &likelihood,
4088 "multinomial scaled-curvature first-step test",
4089 )
4090 .expect("scaled-curvature engine step must be finite");
4091 let analytic = fit_penalized_vector_glm(
4092 PenalizedVectorGlmInputs {
4093 design: design.view(),
4094 y: y.view(),
4095 penalty: penalty.view(),
4096 lambdas: engine_lambdas.view(),
4097 fisher_w_override: None,
4098 max_iter: 1,
4099 tol: 1.0e-9,
4100 class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Centered,
4101 resume_from: None,
4102 },
4103 &likelihood,
4104 "multinomial analytic-curvature first-step test",
4105 )
4106 .expect("analytic-curvature engine step must be finite");
4107 let checkpoint_coefficients = |solve| match solve {
4108 VectorGlmSolve::Converged(fit) => fit.coefficients,
4109 VectorGlmSolve::Stalled(stall) => stall.coefficients,
4110 };
4111 let scaled = checkpoint_coefficients(scaled);
4112 let analytic = checkpoint_coefficients(analytic);
4113 let differs = scaled
4114 .iter()
4115 .zip(analytic.iter())
4116 .any(|(a, b)| (a - b).abs() > 1.0e-6);
4117 assert!(differs, "scaled curvature must change the first step");
4118 }
4119}
4120
4121#[cfg(test)]
4122mod separation_firth_tests {
4123 //! Regression for #1854: on (quasi-)perfect separation the fixed-λ direct
4124 //! multinomial solve must engage the Firth/Jeffreys penalty and return a
4125 //! finite, converged, well-behaved fit instead of hard-erroring with
4126 //! `MultinomialSeparationDetected`.
4127 use super::*;
4128
4129 /// A perfectly linearly separable 3-class problem with an UNPENALIZED design
4130 /// (`S = 0`), so no smoothing `λ` can bound the saturated logits — only the
4131 /// Firth prior `½ log det I(β)` keeps the estimate finite. The unbiased MLE
4132 /// here runs `|η| → ∞` (separation), which is exactly the #1854 trigger.
4133 fn separated_three_class() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
4134 let n = 21;
4135 let p = 2; // intercept + ordering covariate x
4136 let k = 3;
4137 let mut design = Array2::<f64>::zeros((n, p));
4138 let mut y = Array2::<f64>::zeros((n, k));
4139 for i in 0..n {
4140 let x = -3.0 + 6.0 * (i as f64) / ((n - 1) as f64);
4141 design[[i, 0]] = 1.0;
4142 design[[i, 1]] = x;
4143 let cls = if x < -1.0 {
4144 0
4145 } else if x < 1.0 {
4146 1
4147 } else {
4148 2
4149 };
4150 y[[i, cls]] = 1.0;
4151 }
4152 // S = 0: no smoothing direction can bound the separated logits.
4153 let penalty = Array2::<f64>::zeros((p, p));
4154 // #2344: K per-class lambdas (reference class included).
4155 let lambdas = Array1::<f64>::from_elem(k, 1.0);
4156 (design, y, penalty, lambdas)
4157 }
4158
4159 #[test]
4160 fn separation_engages_firth_finite_converged_fit() {
4161 let (design, y, penalty, lambdas) = separated_three_class();
4162 let out = fit_penalized_multinomial(MultinomialFitInputs {
4163 design: design.view(),
4164 y_one_hot: y.view(),
4165 penalty: penalty.view(),
4166 lambdas: lambdas.view(),
4167 row_weights: None,
4168 fisher_w_override: None,
4169 max_iter: 300,
4170 tol: 1e-10,
4171 resume_from: None,
4172 })
4173 .expect("separated multinomial must engage Firth and return a fit, not error");
4174
4175 assert!(
4176 out.coefficients_active.iter().all(|v| v.is_finite()),
4177 "all coefficients must be finite under the Firth prior"
4178 );
4179 assert!(out.deviance.is_finite(), "deviance must be finite");
4180
4181 // The runaway MLE would drive fitted probabilities to the {0,1} boundary;
4182 // the Firth prior keeps them strictly interior.
4183 for v in out.fitted_probabilities.iter() {
4184 assert!(
4185 *v > 0.0 && *v < 1.0,
4186 "Firth fit must stay interior, got p={v}"
4187 );
4188 }
4189
4190 // Perfect separation ⇒ every training row classified to its true class.
4191 let n = design.nrows();
4192 let k = y.ncols();
4193 for i in 0..n {
4194 let mut best = 0usize;
4195 for c in 1..k {
4196 if out.fitted_probabilities[[i, c]] > out.fitted_probabilities[[i, best]] {
4197 best = c;
4198 }
4199 }
4200 let truth = (0..k)
4201 .find(|&c| y[[i, c]] == 1.0)
4202 .expect("one-hot truth class");
4203 assert_eq!(best, truth, "row {i} misclassified under separation");
4204 }
4205 }
4206
4207 #[test]
4208 fn separation_firth_returns_finite_wellshaped_covariance() {
4209 // Distinct angle: the Firth separation path must also expose a finite,
4210 // correctly-shaped (P·M × P·M) Laplace coefficient covariance — the
4211 // downstream SE machinery consumes it. A runaway MLE would have a
4212 // singular (non-invertible) information here.
4213 let (design, y, penalty, lambdas) = separated_three_class();
4214 let p = design.ncols();
4215 let k = y.ncols();
4216 let m = k - 1;
4217 let out = fit_penalized_multinomial(MultinomialFitInputs {
4218 design: design.view(),
4219 y_one_hot: y.view(),
4220 penalty: penalty.view(),
4221 lambdas: lambdas.view(),
4222 row_weights: None,
4223 fisher_w_override: None,
4224 max_iter: 300,
4225 tol: 1e-10,
4226 resume_from: None,
4227 })
4228 .expect("separated multinomial must return a Firth fit");
4229
4230 assert_eq!(
4231 out.coefficient_covariance.dim(),
4232 (p * m, p * m),
4233 "covariance must be P·M square"
4234 );
4235 assert!(
4236 out.coefficient_covariance.iter().all(|v| v.is_finite()),
4237 "Firth covariance entries must be finite"
4238 );
4239 // A genuine Laplace covariance is PSD ⇒ non-negative diagonal.
4240 for i in 0..(p * m) {
4241 assert!(
4242 out.coefficient_covariance[[i, i]] >= -1e-9,
4243 "covariance diagonal must be non-negative, got {}",
4244 out.coefficient_covariance[[i, i]]
4245 );
4246 }
4247 }
4248
4249 #[test]
4250 fn firth_solver_rejects_a_truncated_iterate() {
4251 // #2066 / SPEC 20 (convergence honesty): the Firth Newton loop may only
4252 // construct a fit after certifying stationarity. Before the fix a
4253 // truncated solve returned coefficients and covariance behind a false
4254 // `converged` flag; now budget exhaustion is a typed error carrying the
4255 // iteration count and objective evidence.
4256 //
4257 // Angle: run the SAME separated problem that converges under a full
4258 // budget (`separation_engages_firth_finite_converged_fit`) but starve the
4259 // iteration budget so it provably cannot reach the interior Firth mode.
4260 // The honest outcome is a typed error, not an inspectable fit.
4261 let (design, y, penalty, lambdas) = separated_three_class();
4262
4263 let truncated = fit_penalized_multinomial_firth_fallback(
4264 design.view(),
4265 y.view(),
4266 penalty.view(),
4267 lambdas.view(),
4268 None,
4269 1, // one Newton iteration — far from the separated mode
4270 1e-12,
4271 None,
4272 )
4273 .expect_err("a one-iteration Firth solve must not mint a fit");
4274 let checkpoint = match truncated {
4275 EstimationError::FixedLambdaNewtonDidNotConverge {
4276 objective_value,
4277 stationarity,
4278 checkpoint,
4279 ..
4280 } => {
4281 assert!(objective_value.is_finite());
4282 assert_eq!(stationarity.kind, FixedLambdaResidualKind::NewtonDecrement);
4283 assert_eq!(checkpoint.stage(), FixedLambdaSolverStage::MultinomialFirth);
4284 assert_eq!(checkpoint.completed_iterations(), 1);
4285 checkpoint
4286 }
4287 other => panic!("unexpected Firth interruption error: {other}"),
4288 };
4289
4290 let resumed = fit_penalized_multinomial(MultinomialFitInputs {
4291 design: design.view(),
4292 y_one_hot: y.view(),
4293 penalty: penalty.view(),
4294 lambdas: lambdas.view(),
4295 row_weights: None,
4296 fisher_w_override: None,
4297 max_iter: 299,
4298 tol: 1e-10,
4299 resume_from: Some(&checkpoint),
4300 })
4301 .expect("Firth checkpoint must resume to the certified mode");
4302
4303 // Contrast: with a full budget the same problem does reach stationarity
4304 // and returns the convergence-only result type.
4305 let uninterrupted = fit_penalized_multinomial_firth_fallback(
4306 design.view(),
4307 y.view(),
4308 penalty.view(),
4309 lambdas.view(),
4310 None,
4311 300,
4312 1e-10,
4313 None,
4314 )
4315 .expect("Firth fallback must converge under a full budget");
4316 assert_eq!(resumed.iterations, uninterrupted.iterations);
4317 assert_eq!(
4318 resumed.coefficients_active,
4319 uninterrupted.coefficients_active
4320 );
4321 assert_eq!(
4322 resumed.penalized_neg_log_likelihood,
4323 uninterrupted.penalized_neg_log_likelihood,
4324 );
4325 assert_eq!(
4326 resumed.coefficient_covariance,
4327 uninterrupted.coefficient_covariance,
4328 );
4329 }
4330}
4331
4332#[cfg(test)]
4333mod reference_class_invariance_tests {
4334 //! Regression for #1587: a penalized multinomial-logit GAM fit must be
4335 //! invariant to which class is the (arbitrary) softmax reference/baseline.
4336 //!
4337 //! The production REML path (`fit_penalized_multinomial_formula`) reference-
4338 //! codes the `K` classes (the last sorted label is the baseline) and, with
4339 //! the legacy `Diagonal` penalty metric, penalizes only the `K−1`
4340 //! reference-anchored ALR contrasts `½ Σ_a λ_a β_aᵀ S β_a`. Relabeling the
4341 //! response so a *different* class sorts last penalizes a different frame of
4342 //! log-odds contrasts, so the predicted probabilities drift (~1e-2 absolute)
4343 //! even though they are mathematically independent of the reference choice.
4344 //!
4345 //! This test fits the SAME 3-class softmax sample under three cyclic
4346 //! relabelings — each making a different original class the baseline —
4347 //! realigns the predicted probability columns back to the original class
4348 //! identities, and asserts the cross-labeling drift is below `1e-3`
4349 //! (the defect is ~1e-2; refitting the same labeling twice agrees to
4350 //! ~1e-12). It is the Rust-level sibling of
4351 //! `tests/bug_hunt_multinomial_fit_depends_on_reference_class_test.py`.
4352
4353 use super::*;
4354 use gam_data::load_dataset_projected;
4355 use std::fmt::Write as _;
4356 use std::fs;
4357 use tempfile::tempdir;
4358
4359 /// Deterministic `splitmix64` → `[0,1)` uniform stream (no external RNG dep;
4360 /// the only requirement is a well-distributed, reproducible draw).
4361 struct SplitMix64(u64);
4362 impl SplitMix64 {
4363 fn next_u64(&mut self) -> u64 {
4364 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
4365 let mut z = self.0;
4366 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
4367 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
4368 z ^ (z >> 31)
4369 }
4370 fn unit(&mut self) -> f64 {
4371 // 53-bit mantissa uniform in [0, 1).
4372 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
4373 }
4374 }
4375
4376 /// Draw a clean 3-class softmax regression sample (the issue's generator).
4377 /// Returns `(x, class)` with integer classes `0/1/2`.
4378 fn sample_classes(seed: u64, n: usize) -> (Vec<f64>, Vec<usize>) {
4379 let mut rng = SplitMix64(seed.wrapping_add(0x1234_5678));
4380 let mut x = Vec::with_capacity(n);
4381 let mut cls = Vec::with_capacity(n);
4382 for _ in 0..n {
4383 let xi = -2.0 + 4.0 * rng.unit();
4384 let eta = [0.5 + 0.8 * xi, -0.3 - 0.5 * xi, 0.0];
4385 let mut p = [eta[0].exp(), eta[1].exp(), eta[2].exp()];
4386 let s: f64 = p.iter().sum();
4387 for v in &mut p {
4388 *v /= s;
4389 }
4390 // Inverse-CDF draw into one of the 3 classes.
4391 let u = rng.unit();
4392 let c = if u < p[0] {
4393 0
4394 } else if u < p[0] + p[1] {
4395 1
4396 } else {
4397 2
4398 };
4399 x.push(xi);
4400 cls.push(c);
4401 }
4402 (x, cls)
4403 }
4404
4405 /// Build an `EncodedDataset` with columns `x` (numeric) and `y`
4406 /// (categorical, from the given string labels) by round-tripping a CSV.
4407 fn dataset_xy(
4408 dir: &std::path::Path,
4409 tag: &str,
4410 x: &[f64],
4411 y: &[String],
4412 ) -> gam_data::EncodedDataset {
4413 let path = dir.join(format!("data_{tag}.csv"));
4414 let mut csv = String::from("x,y\n");
4415 for (xi, yi) in x.iter().zip(y.iter()) {
4416 writeln!(csv, "{xi},{yi}").unwrap();
4417 }
4418 fs::write(&path, csv).expect("write training csv");
4419 load_dataset_projected(&path, &["x".to_string(), "y".to_string()])
4420 .expect("load training dataset")
4421 }
4422
4423 /// Fit `y ~ s(x)` under the relabeling `name_map` (original class `c` gets
4424 /// label `name_map[c]`), predict on `grid`, and return the predicted
4425 /// probabilities **realigned to the original class order** 0/1/2, shape
4426 /// `(grid.len(), 3)`.
4427 fn fit_predict_aligned(
4428 dir: &std::path::Path,
4429 tag: &str,
4430 x: &[f64],
4431 cls: &[usize],
4432 name_map: [&str; 3],
4433 grid: &[f64],
4434 ) -> Array2<f64> {
4435 let labels: Vec<String> = cls.iter().map(|&c| name_map[c].to_string()).collect();
4436 let train = dataset_xy(dir, tag, x, &labels);
4437 let config = FitConfig::default();
4438 let model = fit_penalized_multinomial_formula(&MultinomialFitRequest {
4439 init_lambda: 1.0,
4440 max_iter: 60,
4441 tol: 1e-6,
4442 ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
4443 })
4444 .expect("multinomial formula fit must succeed");
4445
4446 // Predict on the grid. The categorical `y` column is not needed for
4447 // prediction, but the schema is simplest if we supply a dummy.
4448 let grid_y: Vec<String> = grid.iter().map(|_| name_map[0].to_string()).collect();
4449 let grid_ds = dataset_xy(dir, &format!("{tag}_grid"), grid, &grid_y);
4450 let probs = predict_multinomial_formula(&model, &grid_ds)
4451 .expect("multinomial predict must succeed");
4452
4453 // `model.class_levels` is the sorted label order; the column for original
4454 // class `c` is at the rank of `name_map[c]` among the sorted labels.
4455 let mut sorted: Vec<&str> = name_map.to_vec();
4456 sorted.sort_unstable();
4457 let col_of_orig: Vec<usize> = (0..3)
4458 .map(|c| sorted.iter().position(|l| *l == name_map[c]).unwrap())
4459 .collect();
4460 // Sanity: the model's class_levels must match the sorted labels.
4461 assert_eq!(
4462 model.class_levels,
4463 sorted.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
4464 "class_levels must be the sorted label order"
4465 );
4466 let n = grid.len();
4467 let mut aligned = Array2::<f64>::zeros((n, 3));
4468 for r in 0..n {
4469 for c in 0..3 {
4470 aligned[[r, c]] = probs[[r, col_of_orig[c]]];
4471 }
4472 }
4473 aligned
4474 }
4475
4476 fn max_abs_diff(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
4477 a.iter()
4478 .zip(b.iter())
4479 .map(|(p, q)| (p - q).abs())
4480 .fold(0.0_f64, f64::max)
4481 }
4482
4483 // gam#1587: now that the reference-symmetric centered `M⊗S_t` joint penalty
4484 // is wired through the custom-family outer REML loop (per-eval
4485 // `JointPenaltyBundle` + outer penalty_coords/logdet/operator), the
4486 // production multinomial fit is invariant to the arbitrary reference class,
4487 // so this guard runs by default (the opt-in skip attribute it carried while
4488 // the fix was pending is also forbidden by the build.rs ban-scanner). It is
4489 // an end-to-end fit guard (a handful of full softmax `y ~ s(x)` fits) —
4490 // slower than a unit test but a true production-path regression.
4491 #[test]
4492 fn multinomial_fit_is_invariant_to_reference_class_1587() {
4493 let td = tempdir().expect("tempdir");
4494 let dir = td.path();
4495 // The reference-class drift is STRUCTURAL (it does not shrink with n, see
4496 // the issue table), so a modest n exposes it just as cleanly as n=900
4497 // while keeping this an affordable CI guard.
4498 let (x, cls) = sample_classes(0, 300);
4499 let grid: Vec<f64> = (0..7).map(|i| -1.5 + 3.0 * (i as f64) / 6.0).collect();
4500
4501 // Three labelings that each make a DIFFERENT original class the baseline
4502 // (the class whose label sorts LAST is the reference K−1):
4503 // ["A","B","C"] → ref = class 2
4504 // ["B","C","A"] → ref = class 1
4505 // ["C","A","B"] → ref = class 0
4506 let a = fit_predict_aligned(dir, "abc", &x, &cls, ["A", "B", "C"], &grid);
4507 let b = fit_predict_aligned(dir, "bca", &x, &cls, ["B", "C", "A"], &grid);
4508 let c = fit_predict_aligned(dir, "cab", &x, &cls, ["C", "A", "B"], &grid);
4509
4510 // Refitting the SAME labeling twice must agree to ~machine precision —
4511 // this isolates optimizer noise from the structural reference drift.
4512 let a2 = fit_predict_aligned(dir, "abc2", &x, &cls, ["A", "B", "C"], &grid);
4513 let refit_noise = max_abs_diff(&a, &a2);
4514 assert!(
4515 refit_noise < 1e-6,
4516 "refitting the same labeling must be deterministic (got {refit_noise:.3e})"
4517 );
4518
4519 let drift = max_abs_diff(&a, &b)
4520 .max(max_abs_diff(&a, &c))
4521 .max(max_abs_diff(&b, &c));
4522 assert!(
4523 drift < 1e-3,
4524 "predicted probabilities must be invariant to the reference class; \
4525 cross-labeling drift = {drift:.3e} (refit noise = {refit_noise:.3e})"
4526 );
4527 }
4528
4529 /// #2349 diagnostic (zz_measure): finite-difference the OUTER REML
4530 /// criterion of the EXACT production multinomial objective at the refusal
4531 /// checkpoint from MSI job 13390650. The certificate there claimed
4532 /// `|Pg| = 2.047` against a bound of `2.697e-3` after the optimizer
4533 /// stalled — if the fixed-ρ criterion's central FD gradient at that same
4534 /// checkpoint is comparably large, the surface is genuinely non-stationary
4535 /// and the stall is the optimizer's; if it is orders of magnitude smaller,
4536 /// the analytic outer gradient is desynced from the criterion (the
4537 /// coalesced overlapping joint-family pseudo-logdet is the suspect).
4538 /// Prints only; never asserts a bound.
4539 #[test]
4540 fn zz_measure_2349_outer_gradient_fd_at_refusal_checkpoint() {
4541 let td = tempdir().expect("tempdir");
4542 let dir = td.path();
4543 let (x, cls) = sample_classes(0, 300);
4544 let labels: Vec<String> = cls
4545 .iter()
4546 .map(|&c| ["A", "B", "C"][c].to_string())
4547 .collect();
4548 let train = dataset_xy(dir, "fd2349", &x, &labels);
4549 let config = FitConfig::default();
4550 let request = MultinomialFitRequest {
4551 init_lambda: 1.0,
4552 max_iter: 60,
4553 tol: 1e-6,
4554 ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
4555 };
4556 let parts = penalized_multinomial_formula_parts(&request)
4557 .expect("production formula parts must build");
4558 // Unbiased-arm refusal checkpoint (MSI job 13390650, #2349): the
4559 // 6-coordinate joint ρ = 2 terms × 3 per-class λ, term-major.
4560 let rho_star = [
4561 6.50584039279757,
4562 -1.6183906983083074,
4563 5.922109861708934,
4564 -0.5810545109816936,
4565 -0.4894709703255621,
4566 1.299144316808675,
4567 ];
4568 // The criterion probe needs no posterior covariance — and at this
4569 // checkpoint it CANNOT have one: the joint precision H + S_λ is
4570 // measurably singular there (1 flat direction, the first hard datum
4571 // this gate produced), so the covariance factorization honestly
4572 // refuses. The REML criterion value is still well-defined through the
4573 // pseudo-logdet.
4574 let mut probe_options = parts.options.clone();
4575 probe_options.compute_covariance = false;
4576 eprintln!(
4577 "#2349 gate state: use_remlobjective={} (RidgedQuadraticReml default => \
4578 logdet_h/logdet_s included in the fixed-lambda score iff this is true)",
4579 probe_options.use_remlobjective
4580 );
4581 let v_at_with = |rho: &[f64], use_reml: bool| -> f64 {
4582 let fam = parts
4583 .family
4584 .clone()
4585 .with_joint_initial_log_lambdas(rho.to_vec());
4586 let mut opts = probe_options.clone();
4587 opts.use_remlobjective = use_reml;
4588 let fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
4589 &fam,
4590 &parts.blocks,
4591 &opts,
4592 None,
4593 )
4594 .expect("fixed-lambda inner solve at the checkpoint must converge");
4595 fit.reml_score
4596 };
4597 let v_plain = v_at_with(&rho_star, false);
4598 let v_laml = v_at_with(&rho_star, true);
4599 eprintln!(
4600 "#2349 V(rho*): plain(penalized NLL)={v_plain:.9e} \
4601 laml(+0.5logdetH-0.5logdetS)={v_laml:.9e} logdet_pair={:.9e} \
4602 (the refusal reported final objective 2.687403e2 at this checkpoint — \
4603 whichever variant matches IS the outer criterion)",
4604 v_laml - v_plain
4605 );
4606 let outer_uses_laml = (v_laml - 2.687403e2).abs() < (v_plain - 2.687403e2).abs();
4607 let v_at = |rho: &[f64]| -> f64 { v_at_with(rho, outer_uses_laml) };
4608 // Term-for-term decomposition of the fixed-ρ score so the ~12.5 offset
4609 // from the outer criterion can be attributed to a specific missing
4610 // term. A ρ-CONSTANT offset leaves the FD gradient verdict intact; a
4611 // missing ½·log|S_λ|₊ (strongly ρ-dependent, O(1) gradient per
4612 // coordinate) would contaminate it.
4613 {
4614 let fam = parts
4615 .family
4616 .clone()
4617 .with_joint_initial_log_lambdas(rho_star.to_vec());
4618 let fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
4619 &fam,
4620 &parts.blocks,
4621 &probe_options,
4622 None,
4623 )
4624 .expect("fixed-lambda decomposition fit at the checkpoint");
4625 eprintln!(
4626 "#2349 decompose: reml_score={:.9e} penalized_objective={:.9e} \
4627 log_likelihood={:.9e} deviance={:.9e}",
4628 fit.reml_score, fit.penalized_objective, fit.log_likelihood, fit.deviance
4629 );
4630 }
4631 let h = 1.0e-3;
4632 let mut grad_fd = [0.0_f64; 6];
4633 for s in 0..6 {
4634 let mut plus = rho_star;
4635 plus[s] += h;
4636 let mut minus = rho_star;
4637 minus[s] -= h;
4638 grad_fd[s] = (v_at(&plus) - v_at(&minus)) / (2.0 * h);
4639 eprintln!("#2349 FD dV/drho[{s}] = {:+.6e}", grad_fd[s]);
4640 }
4641 let norm = grad_fd.iter().map(|g| g * g).sum::<f64>().sqrt();
4642 eprintln!(
4643 "#2349 |FD grad| = {norm:.6e} on the {} criterion \
4644 (certificate claimed |Pg|=2.047e0, bound 2.697e-3)",
4645 if outer_uses_laml { "LAML" } else { "plain penalized-NLL" }
4646 );
4647
4648 // ── Warm-start stall isolation (#2349, round 3) ────────────────────
4649 //
4650 // The unbiased-arm refusal recorded objective 268.740 at its OWN best
4651 // iterate ρ*, while a cold fixed-λ solve at the same ρ* reaches
4652 // 256.166 — the outer's warm-started inner state sat ~12.6 above the
4653 // mode of a CONVEX objective while claiming convergence. If that stall
4654 // is real it must reproduce in isolation: warm-start the fixed-λ solve
4655 // at ρ* from the mode of a DISTANT ρ (the outer's actual eval pattern)
4656 // and compare against the cold value. A warm-started value ≫ cold with
4657 // an Ok return is the minimal repro of a lying inner certificate; an
4658 // Err is the honest refusal; a matching value clears the inner solver
4659 // and points the 12.6 gap at the outer eval bookkeeping instead.
4660 for delta in [2.0_f64, -2.0] {
4661 let rho_far: Vec<f64> = rho_star.iter().map(|r| r + delta).collect();
4662 let fam_far = parts
4663 .family
4664 .clone()
4665 .with_joint_initial_log_lambdas(rho_far.clone());
4666 let far_fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
4667 &fam_far,
4668 &parts.blocks,
4669 &probe_options,
4670 None,
4671 )
4672 .expect("cold fixed-lambda solve at the far point");
4673 let far_beta: Vec<f64> = far_fit
4674 .block_states
4675 .iter()
4676 .flat_map(|bs| bs.beta.iter().copied())
4677 .collect();
4678 let block_cols: Vec<usize> =
4679 parts.blocks.iter().map(|s| s.design.ncols()).collect();
4680 let warm = crate::custom_family::CustomFamilyWarmStart::from_cached_beta(
4681 &block_cols,
4682 &ndarray::Array1::from(far_beta),
4683 )
4684 .expect("warm start from far-point mode");
4685 let fam_star = parts
4686 .family
4687 .clone()
4688 .with_joint_initial_log_lambdas(rho_star.to_vec());
4689 match crate::custom_family::fit_custom_family_fixed_log_lambdas(
4690 &fam_star,
4691 &parts.blocks,
4692 &probe_options,
4693 Some(&warm),
4694 ) {
4695 Ok(fit) => eprintln!(
4696 "#2349 warm-from(delta={delta:+.1}): V={:.9e} (cold {:.9e}, refusal 2.687403e2) \
4697 gap_to_cold={:+.3e}",
4698 fit.reml_score,
4699 v_laml,
4700 fit.reml_score - v_laml
4701 ),
4702 Err(e) => eprintln!(
4703 "#2349 warm-from(delta={delta:+.1}): inner REFUSED honestly: {}",
4704 format!("{e}").chars().take(220).collect::<String>()
4705 ),
4706 }
4707 }
4708
4709 // ── Round 5: the LABELED production evaluator at ρ* + FD gate ─────
4710 //
4711 // Round 4's hyper-evaluator saw no outer coordinates (grad=[], and its
4712 // objective 245.99 matched an unpenalized solve): the joint λs are
4713 // OUTER coordinates only through the labeled layout. This round calls
4714 // the exact production functional (canonicalize → pulled-back joint
4715 // specs → labeled layout → outerobjectivegradienthessian_labeled) at
4716 // the checkpoint. Its objective settles whether the refusal's 268.740
4717 // is that functional's value (and the 12.574 a criterion difference vs
4718 // the fixed-λ LAML) — and the analytic-vs-FD comparison per coordinate
4719 // is the obj↔grad desync gate (issue suspect 1) on the REAL surface.
4720 {
4721 let fam = parts
4722 .family
4723 .clone()
4724 .with_joint_initial_log_lambdas(rho_star.to_vec());
4725 let eval_at = |rho_vec: &[f64]| -> (f64, ndarray::Array1<f64>, bool) {
4726 crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
4727 &fam,
4728 &parts.blocks,
4729 &probe_options,
4730 &ndarray::Array1::from(rho_vec.to_vec()),
4731 )
4732 .expect("labeled outer evaluation at the checkpoint")
4733 };
4734 let (v0, g0, conv0) = eval_at(&rho_star);
4735 eprintln!(
4736 "#2349 labeled-evaluator at rho*: V={v0:.9e} (refusal 2.687403e2, \
4737 fixed-lambda LAML 2.561663540e2) inner_converged={conv0} |analytic g|={:.6e}",
4738 g0.iter().map(|g| g * g).sum::<f64>().sqrt()
4739 );
4740 let h = 1.0e-3;
4741 for s in 0..6 {
4742 let mut plus = rho_star;
4743 plus[s] += h;
4744 let mut minus = rho_star;
4745 minus[s] -= h;
4746 let (vp, _, _) = eval_at(&plus);
4747 let (vm, _, _) = eval_at(&minus);
4748 let fd = (vp - vm) / (2.0 * h);
4749 eprintln!(
4750 "#2349 labeled grad[{s}]: analytic={:+.6e} fd={fd:+.6e} diff={:+.3e}",
4751 g0[s],
4752 g0[s] - fd
4753 );
4754 }
4755 }
4756 }
4757}