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, CustomFamily, 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_posterior::{
76 MultinomialPosteriorIntegrationControl, integrate_multinomial_design_moments,
77 softmax_with_reference,
78};
79use crate::multinomial_reml::MultinomialFamily;
80use crate::penalized_vector_glm::{
81 PenalizedVectorGlmInputs, VectorGlmResume, VectorGlmSolve, fit_penalized_vector_glm,
82};
83use crate::vector_response::{MultinomialLogitLikelihood, validate_multinomial_simplex};
84use gam_data::ColumnKindTag;
85use gam_data::EncodedDataset;
86use gam_problem::{
87 FixedLambdaCheckpoint, FixedLambdaResidualKind, FixedLambdaSolverStage, FixedLambdaStallReason,
88 FixedLambdaStationarityEvidence, ResponseColumnKind,
89};
90use gam_runtime::resource::ProblemHints;
91/// The covariance-definition axis, re-exported so a caller of this module's
92/// predict surface names the same enum `gam-predict` and the CLI do rather than
93/// reaching across crates for it.
94pub use gam_solve::model_types::InferenceCovarianceMode;
95use gam_terms::inference::formula_dsl::parse_formula;
96use gam_terms::smooth::{
97 PenaltyBlockInfo, TermCollectionDesign, TermCollectionSpec, build_term_collection_design,
98};
99use gam_terms::term_builder::resolve_role_col;
100use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayView3};
101use opt::{BacktrackConfig, backtracking_line_search};
102use serde::{Deserialize, Serialize};
103use std::convert::Infallible;
104use std::sync::Arc;
105
106/// Solver-only numerical stabilization floor for the formula-driven
107/// multinomial REML inner solve (gam#747).
108///
109/// Installed with [`RidgePolicy::solver_only`](gam_problem::RidgePolicy::solver_only)
110/// so it stabilizes the inner joint-Newton **linear solve** but never enters
111/// the REML objective, the penalty log-determinant, or the Laplace Hessian.
112///
113/// What it does: the multinomial smoothing penalties are rank-deficient by
114/// design (each smooth carries an unpenalized polynomial null space) and the
115/// formula may add a fully unpenalized parametric term (`x3` / `body_mass`). On
116/// near-separable hard labels the softmax curvature is ill-conditioned along
117/// those directions, so the bare Newton step `H⁻¹∇` is huge. Lifting the
118/// smallest Hessian eigenvalue to `δ` bounds the step (`‖(H+δI)⁻¹∇‖ ≤ ‖∇‖/δ`),
119/// keeping the screening iterates finite without poisoning the softmax with
120/// `inf − inf = NaN`.
121///
122/// What it deliberately does NOT do: it adds no `½·δ·‖β‖²` term to the
123/// objective and no `δ`-shift to the REML log-determinant. The earlier
124/// `explicit_stabilization_pospart` policy folded both into the criterion,
125/// which made `1e-4` a fixed-λ Gaussian prior that shrank every identified
126/// coefficient off the MLE and biased smoothing-parameter selection — a value
127/// that had to be tuned *between* under-stabilization (NaN seeds) and
128/// over-shrinkage (lost VGAM match). As a solver-only floor that tradeoff is
129/// gone: the over-shrinkage failure mode cannot occur (nothing is shrunk), the
130/// optimized objective is the true penalized REML criterion, and the floor
131/// only has to be large enough to keep the linear algebra finite.
132///
133/// The separation defect (#753) is no longer this floor's job. If the
134/// multinomial MLE is genuinely at infinity for an unpenalized/null-space
135/// direction (complete/quasi-complete separation), no solver floor makes that
136/// direction's estimate finite. The formula REML path arms the full-span
137/// Jeffreys/Firth correction CONDITIONALLY — only on separation evidence (see
138/// [`multinomial_formula_penalized_separation_evidence`] and the two-attempt logic
139/// in [`fit_penalized_multinomial_formula`]) — so an interior, well-identified
140/// fit optimizes the unbiased penalized-REML criterion with no Firth shrinkage
141/// toward the uniform simplex, while a finite-but-Fisher-underidentified or
142/// non-finite geometry gets the proper prior that is the only thing able to
143/// bound its penalty-null directions (#715/#2612 real-data arm). The bare
144/// fixed-λ inner driver
145/// [`fit_penalized_multinomial`] (no outer REML, no Jeffreys term) surfaces the
146/// explicit `MultinomialSeparationDetected` diagnostic for the path that has no
147/// proper prior to lean on.
148const MULTINOMIAL_FORMULA_RIDGE_FLOOR: f64 = 1.0e-4;
149
150/// Inner joint-Newton KKT tolerance for the multinomial formula path.
151///
152/// The softmax Fisher weight `W = diag(p) − ppᵀ` collapses on saturated rows,
153/// so near-separable fits (penguins, #715) reach the OBJECTIVE's f64 noise
154/// floor before the default `inner_tol = 1e-6` KKT target: measured on the
155/// penguins arm (standardized columns), the trust region collapses to 1e-12
156/// with per-attempt objective changes of ~+2e-9 on |obj| ≈ 1e2 (≈ 1e-11
157/// relative — pure rounding) while the KKT residual plateaus at 2.8e-5–9.4e-5
158/// against a scaled tolerance of ~1.9e-5. Demanding a residual below the
159/// floating-point noise floor is certifiable-never: every eval is rejected by
160/// the stall guard and the whole fit fails. `1e-5` certifies the measured
161/// plateaus while still resolving β to ~1e-6 in the relevant metric — the
162/// LAML criterion consumes β̂ with error O(residual²/curvature), far below
163/// any quantity the outer ρ-search can read.
164const MULTINOMIAL_FORMULA_INNER_TOL: f64 = 1.0e-5;
165
166/// Formula-adapter penalty calibration for multinomial softmax REML.
167///
168/// The term builder's normalized penalties are calibrated on single-response
169/// Gaussian-style score curvature. A reference-coded softmax class block sees
170/// per-row active-class Fisher diagonal `p_a(1-p_a)` plus negative cross-class
171/// coupling. At the neutral simplex (`p_k = 1/K`) the active diagonal is
172/// `(K-1)/K²`, so the binary-logit calibration is `2·(K-1)/K² = 1/2` and the
173/// three-class calibration is `4/9` rather than the historical hard-coded
174/// `1/2`. Making the scale a function of `K` keeps the physical smoothness
175/// prior tied to the likelihood curvature instead of over-penalizing every
176/// class as the simplex gains categories.
177fn multinomial_formula_penalty_scale(n_classes: usize) -> f64 {
178 let k = n_classes.max(2) as f64;
179 2.0 * (k - 1.0) / (k * k)
180}
181
182/// Largest smoothing-parameter dimension where exact dense outer curvature is
183/// still worth paying for multinomial formula fits.
184///
185/// `D = (K - 1) * n_penalties`. Medium-size loaded models use exact curvature
186/// so the optimizer does not wander into over-smoothed lambda caps on
187/// near-boundary softmax surfaces. The threshold was originally calibrated at
188/// `D <= 6` when each `s()` term carried ONE penalty; the double-penalty
189/// migration (wiggliness + null-space shrinkage per term, mgcv `select=TRUE`
190/// semantics) doubled `D` for the SAME models, silently flipping the
191/// reference formula fits (2 smooths, K = 3: old `D = 4`, now `D = 8`) onto
192/// the gradient-only route — where the #715 quality arm showed every
193/// wiggliness ρ driven onto the ±10 box bound (smooths collapsed toward their
194/// polynomial null space, truth-RMSE behind VGAM). `12 = 2 × 6` preserves the
195/// original classification boundary under the doubled penalty count while
196/// keeping the four-smooth penguin species quality fixture on the exact ARC
197/// path: that model is `D = 16`, and first-order BFGS can cycle along the
198/// near-separable lambda-to-zero ridge until the wall-clock budget expires
199/// (#1082). ARC observes the same exact curvature and can halt through the
200/// bound-aware cost-stall guard once the REML surface stops making useful
201/// progress.
202///
203/// CORRECTED 2026-08-08 (#2612), 16 -> 24, WITHOUT MOVING THE CALIBRATION POINT.
204/// The value above was chosen so the four-smooth penguin fixture lands on the
205/// exact path, and the `D = 16` it quotes for that fixture was computed as
206/// `(K-1) * n_penalties`. That has not been the number of outer coordinates
207/// since #1587: `equivariant_class_penalty_specs` emits one spec per class per
208/// penalty component whenever `K > 2`, so the same fixture is `8 * 3 = 24`, not
209/// `2 * 8 = 16` — confirmed against the refusal's own `last_evaluated_rho`,
210/// which carries 24 entries. The gate now reads
211/// `MultinomialFamily::joint_smoothing_dimension()`, and the threshold is the
212/// SAME fixture re-read with a corrected ruler rather than a new number: at
213/// `K = 3` the classification is unchanged (`3n <= 24` and `2n <= 16` are both
214/// `n <= 8` components), and where it differs at other `K` it differs by
215/// admitting MORE exact curvature, which is the side this constant's own
216/// rationale says is safe ("medium-D formula fits need exact curvature to keep
217/// lambda selection away from over-smoothed caps").
218const MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM: usize = 24;
219
220fn multinomial_formula_use_outer_hessian(total_rho_dim: usize) -> bool {
221 total_rho_dim <= MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM
222}
223
224/// Logit magnitude beyond which fitted probabilities are saturated at ordinary
225/// double precision diagnostic scale. The bare fixed-λ driver has no outer REML
226/// state and still uses this threshold to reject a non-converged saturated
227/// iterate as a separation artifact. The formula REML path does not use this as
228/// a Firth trigger: with smoothing parameters selected, a finite saturated
229/// surface can be the valid near-separated optimum that should be scored
230/// directly.
231const MULTINOMIAL_SEPARATION_ETA_THRESHOLD: f64 = 25.0;
232
233/// Calibrated convergence tolerance for the OUTER REML/LAML smoothing-parameter
234/// search on the formula multinomial path. Matches the primary GLM REML outer
235/// (`solver::fit_orchestration::materialize` uses `tol = 1e-7`, mirrored by the
236/// `LOG_LAMBDA_TOL` / `KKT_TOL_*` constants across the REML stack): tight enough
237/// that the selected λ reaches the genuine REML optimum (the recovered
238/// probability surface matches the mature reference), loose enough that the
239/// optimizer does not grind surface-irrelevant ρ digits down to the inner KKT
240/// scale (the #1082 wall-clock overrun). The caller's `tol` is floored at this
241/// value for the OUTER loop, while it continues to drive the INNER joint-Newton
242/// KKT target unchanged.
243const MULTINOMIAL_OUTER_REML_TOL: f64 = 1e-7;
244
245/// Per-observation softmax Fisher-information scale for the λ-floor units.
246///
247/// The penalty enters the criterion as `½ λ βᵀ S β` with a Frobenius-normalized
248/// `S` (`‖S‖_F = 1`, see the term-builder calibration referenced by
249/// [`multinomial_formula_penalty_scale`]), so the ridge `λ S` is directly
250/// comparable to data Fisher information. One observation contributes softmax
251/// information `p(1−p)` in a class's logit direction, which is bounded by the
252/// logistic peak `p(1−p) ≤ ¼` at `p = ½`. Using this maximal per-observation
253/// information as the unit makes the floor's strength interpretable as a count
254/// of equivalent **pseudo-observations** of prior: a ridge that equals
255/// `τ · ¼ · ‖S‖_F` carries the same logit-direction curvature as `τ` real rows
256/// sitting at the most-informative point of the likelihood. This scale is
257/// `K`-independent on purpose — the `K`-dependence of the softmax block
258/// curvature already lives in the penalty matrix via
259/// [`multinomial_formula_penalty_scale`], so the floor (a bound on the
260/// multiplier of that already-scaled penalty) must not double-count it.
261const MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS: f64 = 0.25;
262
263/// Target prior strength of the λ-floor, in pseudo-observations, for a
264/// WELL-SUPPORTED class. The floor holds the unbiased REML optimizer off the
265/// zero-penalty boundary (where a boundary-overfit smooth or a Firth switch on
266/// finite data would otherwise be accepted) with a prior worth a fixed small
267/// fraction of one observation. `8e-4` pseudo-observations reproduces the
268/// previously fixture-calibrated large-support floor `τ · ¼ = 2e-4` exactly at
269/// the calibration point, now expressed as an effective-prior-strength rather
270/// than a tuned λ value.
271///
272/// MEASURED 2026-07-31 (#2612). This value is not inert and it has never been
273/// measured against the quantity it controls. On the penguins real-data arm
274/// (stride-3, `n_train = 228`, minority class 46, so the wall is
275/// `8.0e-4 × 0.25 × 50/46 = 2.173913043e-4`), **four of the eight live
276/// null-space λ sit on this wall exactly** — `ratio_to_wall = 1.000000`. They
277/// are boundary solutions, not stationary points of the REML criterion: the box
278/// stopped them, and the box is this constant.
279///
280/// Everything downstream is therefore a readout of it. Sweeping ONLY this
281/// constant, same base commit, same split, exact conditioned quadrature:
282///
283/// ```text
284/// pseudo_obs λ wall posterior log-loss plug-in log-loss calib gap
285/// 8.0e-4 2.174e-4 0.161820 0.024718 +0.1187
286/// 8.0e-3 2.174e-3 0.069526 0.025097 +0.0401
287/// 8.0e-2 2.174e-2 0.065275 0.035297 +0.0360
288/// ```
289///
290/// `calib gap` is `mean predicted probability on the argmax class − held-out
291/// accuracy` (accuracy is `0.982456` in every row; no reference tool involved).
292/// A 10× wall MORE THAN HALVES the published posterior log-loss while the mode
293/// barely moves, so the width is this constant's and the mode is not.
294///
295/// An exact subspace attribution — same fit, saved Laplace covariance zeroed
296/// outside one subspace at a time — says the width does not sit where the wall
297/// is applied. Cost in nats above the plug-in, shipped wall / 10× wall:
298///
299/// ```text
300/// intercept (2 coords, unpenalized) 0.071778 → 0.014949
301/// range space (64 coords, edf≈6e-4) 0.062538 → 0.034422
302/// null space (8 coords, ON the wall) 0.002564 → 0.000989
303/// ```
304///
305/// The railed coordinates carry 1.9% of the cost, yet tightening their prior
306/// tenfold cuts the UNPENALIZED intercept's marginal contribution 4.8×. The
307/// near-flat prior on the linear direction does not make that direction wide; it
308/// makes the joint Hessian near-singular, and the width surfaces on whatever is
309/// correlated with it. Attribution by marginal block therefore names the
310/// carrier, not the cause.
311///
312/// DO NOT TUNE THIS ON THAT CURVE. Choosing `8.0e-3` because it clears a
313/// held-out log-loss bar on one dataset is the objection #2615 raised against
314/// choosing `EFFECTIVE_DF_FLOOR_RELATIVE_FRACTION`, and the sweep above is
315/// diagnostic evidence, not a selection procedure. The measurement says the
316/// criterion has no interior optimum here — a quasi-separated multinomial's
317/// marginal likelihood keeps rewarding a flatter prior on the separating
318/// direction — so the repair is a prior that is derived for that regime (the
319/// Jeffreys/Firth path this same issue is already driving), not a wall chosen to
320/// stop the slide at a convenient place.
321const MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS: f64 = 8.0e-4;
322
323/// Reference class support `n_ref`: the effective sample size per class at which
324/// the data Fisher information `n_c · I₁` is large enough that the floor sits at
325/// its well-supported value. Below `n_ref` the per-class data information shrinks
326/// like `n_c`, so to keep the floor's prior from vanishing *relative to* that
327/// shrinking data the effective pseudo-observation count is scaled up by
328/// `n_ref / n_c` (the prior is held to a fixed fraction of the data information,
329/// not a fixed absolute λ). At `n_c = n_ref` the scale is exactly 1.
330const MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT: f64 = 50.0;
331
332/// Cap on the floor's prior strength in the very-sparse limit, in
333/// pseudo-observations. As `n_c → 0` the `n_ref / n_c` scaling diverges; the cap
334/// holds the prior at `4e-3` pseudo-observations (`τ_max · ¼ = 1e-3` at the
335/// calibration point, the previously-tuned strong-floor value) so the floor
336/// stays a proper prior rather than a hard constraint that would dominate the
337/// likelihood for a handful-of-rows class.
338const MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX: f64 = 4.0e-3;
339
340/// Continuous, Fisher-information-scaled lower λ floor for the formula path,
341/// derived from the minority class's effective sample size `n_c`.
342///
343/// # Derivation (effective-prior-strength / Fisher geometry)
344///
345/// The penalty `½ λ βᵀ S β` with `‖S‖_F = 1` adds curvature `λ` to the class
346/// logit direction; one observation adds at most `I₁ = ¼` there. So a floor that
347/// sets `λ_floor = τ_eff · I₁` gives the smooth a prior worth `τ_eff`
348/// pseudo-observations. We want a fixed *absolute* prior `τ` for a well-supported
349/// class, but for a minority class with only `n_c` effective observations the
350/// data information in its block is `n_c · I₁`; holding the prior to a fixed
351/// *fraction* of that shrinking data information requires
352///
353/// ```text
354/// τ_eff(n_c) = τ · max(1, n_ref / n_c), clamped to [τ, τ_max]
355/// λ_floor(n_c) = τ_eff(n_c) · I₁
356/// ```
357///
358/// This is the *same* `base · max(1, c0/c)` envelope as before — but `base`,
359/// `sparse`, and `c0` are no longer fixture-tuned magic numbers: `base = τ·I₁`,
360/// `sparse = τ_max·I₁`, and `c0 = n_ref` are an effective-prior-strength of
361/// `τ`/`τ_max` pseudo-observations against the maximal per-observation softmax
362/// information `I₁ = ¼`. Properties preserved by construction:
363/// * reduces EXACTLY to `τ·I₁` for well-supported classes (`n_c ≥ n_ref`);
364/// * reduces EXACTLY to `τ_max·I₁` for very sparse classes
365/// (`n_c ≤ n_ref·τ/τ_max`, here `n_c ≤ 10`);
366/// * interpolates monotonically and continuously between them in the middle —
367/// no cliff at `n_c = n_ref`.
368/// At the calibration point the endpoints equal the previous `2e-4` / `1e-3`, so
369/// fixtures whose smallest class has `n_c ≥ 50` (penguins, the vgam softmax
370/// arms) are unaffected — they sit at `τ·I₁ = 2e-4` exactly as before.
371fn multinomial_formula_min_lambda(y_one_hot: ArrayView2<'_, f64>) -> f64 {
372 let base = MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
373 let sparse =
374 MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
375 let min_class_count = (0..y_one_hot.ncols())
376 .map(|class| y_one_hot.column(class).sum())
377 .fold(f64::INFINITY, f64::min);
378 if !min_class_count.is_finite() || min_class_count <= 0.0 {
379 return base;
380 }
381 // Effective pseudo-observation prior strength: held to a fixed fraction of
382 // the shrinking per-class data information once n_c falls below n_ref.
383 let pseudo_obs_scale =
384 (MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT / min_class_count).max(1.0);
385 (base * pseudo_obs_scale).clamp(base, sparse)
386}
387
388fn max_abs_eta_location(eta: ArrayView2<'_, f64>) -> (f64, usize, usize) {
389 let mut best = (0.0_f64, 0usize, 0usize);
390 for ((row, active_class), &value) in eta.indexed_iter() {
391 let abs = value.abs();
392 if abs > best.0 {
393 best = (abs, row, active_class);
394 }
395 }
396 best
397}
398
399/// Separation gate for the REML/LAML **formula** path.
400///
401/// Unlike the bare fixed-λ driver [`fit_penalized_multinomial`] (which has no
402/// outer REML state and so must reject a saturated, non-converged iterate as a
403/// separation artifact at the [`MULTINOMIAL_SEPARATION_ETA_THRESHOLD`] logit
404/// magnitude), the formula path can return a finite saturated mode after the
405/// coupled outer optimizer has selected smoothing parameters. A `|η| >= 25`
406/// gate is therefore wrong here: the penguins arm can legitimately have large
407/// fitted logits while still producing finite probabilities and a usable REML
408/// mode.
409///
410/// Only a genuinely NON-FINITE `η` (a NaN/Inf blow-up in the inner linear
411/// algebra) is a real formula-path failure. A finite, even saturated, `η` is
412/// accepted so the truth-recovery / match-or-beat bars are evaluated against the
413/// actual fitted surface instead of an adapter diagnostic.
414fn multinomial_formula_separation_diagnostic(
415 inner_cycles: usize,
416 outer_iterations: usize,
417 block_states: &[ParameterBlockState],
418) -> Option<EstimationError> {
419 let mut nonfinite: Option<(f64, usize, usize)> = None;
420 for (active_class, state) in block_states.iter().enumerate() {
421 for (row, &value) in state.eta.iter().enumerate() {
422 if !value.is_finite() {
423 nonfinite = Some((value, row, active_class));
424 break;
425 }
426 }
427 if nonfinite.is_some() {
428 break;
429 }
430 }
431 nonfinite.map(|(value, row_index, active_class_index)| {
432 EstimationError::MultinomialSeparationDetected {
433 iteration: inner_cycles.max(outer_iterations),
434 max_abs_eta: value.abs(),
435 active_class_index,
436 row_index,
437 }
438 })
439}
440
441/// Separation EVIDENCE gate for the conditional Firth/Jeffreys engagement on
442/// the formula REML path (#715 / #753).
443///
444/// The structural mathematics (#715 issue thread): for any coefficient
445/// direction `v` with `S v = 0` (a penalty-null direction — intercept, a
446/// smooth's polynomial null component, an unpenalized parametric term), the
447/// penalized joint Hessian satisfies `(H + S_λ) v = H v` for EVERY smoothing
448/// parameter ρ. When the data (quasi-)separate, the softmax Fisher weight
449/// `W = diag(p) − p pᵀ → 0` on the saturated rows, so `H v = JᵀWJ v → 0` along
450/// the penalty-null directions those rows support: `(H + S_λ) v ≈ 0` for every
451/// ρ — NO λ can repair it, the inner Newton can never certify a KKT point
452/// there, and every outer REML startup seed is rejected (the penguins
453/// real-data arm). The only principled cure is a PROPER prior on that
454/// quotient-null subspace — the Jeffreys/Firth term `Φ = ½ log|ZᵀHZ|`, whose
455/// Gauss–Newton curvature supplies the missing `O(1)` bound.
456///
457/// But the Firth prior is not free on interior data: unconditionally armed, it
458/// shrinks fitted class probabilities toward the uniform simplex `1/K`
459/// (an `O(1/n)` pull that the synthetic match-or-beat arm of #715 measured as
460/// a real truth-RMSE loss vs the unbiased criterion). So the formula path
461/// engages it ONLY on separation evidence, mirroring the #753 "diagnose, then
462/// arm" split:
463///
464/// * a NON-FINITE logit — the inner linear algebra blew up along an unbounded
465/// direction.
466///
467/// Returns `Some(description)` naming the witnessing logit when evidence is
468/// found, `None` for a finite fit (which is then accepted as-is, with zero
469/// Firth bias). A FAILED unbiased solve (`Err` from the rho-prior driver, e.g.
470/// "no startup seed passed") is the second evidence form and is handled
471/// directly at the call site in [`fit_penalized_multinomial_formula`].
472fn multinomial_formula_separation_evidence(block_states: &[ParameterBlockState]) -> Option<String> {
473 for (active_class, state) in block_states.iter().enumerate() {
474 for (row, &value) in state.eta.iter().enumerate() {
475 if !value.is_finite() {
476 return Some(format!(
477 "non-finite logit eta[row {row}, active class {active_class}] = {value}"
478 ));
479 }
480 }
481 }
482 None
483}
484
485/// Certify (quasi-)separation at a converged multinomial mode from the curvature
486/// the fit ACTUALLY has — the exact Fisher information PLUS the selected joint
487/// penalty — on the fit's certified identifiable tangent span.
488///
489/// Finiteness is not an identification certificate: a tiny smoothing floor can
490/// keep every logit finite while the likelihood contributes less than one
491/// observation-equivalent of curvature along a separating direction. The
492/// Jeffreys objective already owns the canonical absolute/relative conditioning
493/// gate for precisely that geometry. Preparing its plan here makes the
494/// conditional-refit decision use the same reduced spectrum, smooth gate, and
495/// coefficient gauge as the term that will bound the refit. Passing the fit's
496/// saved gauge is essential: an aliased raw column is not separation evidence,
497/// because it is absent from the active statistical model.
498///
499/// # Why `H + S_λ` and not `H` (#2612)
500///
501/// The gate's absolute arm saturates at `λ_min < 1`, i.e. when the
502/// worst-determined direction holds less than **one observation-equivalent** of
503/// curvature, and its own derivation says why that is the right scale: such a
504/// direction "is, by construction, not identified by the data and is the regime
505/// Firth exists to stabilise". It also states the premise that makes it
506/// conservative — "it never fires on a genuinely well-conditioned large-`n` fit,
507/// whose `λ_min = O(n) ≫ 1`".
508///
509/// (The predicate this call site consults is `JointJeffreysPlan::is_active`,
510/// which is `gate_weight != 0`, and the weight is a C¹ ramp reaching exactly
511/// zero only at `CONDITIONING_GATE_ABSOLUTE_CLEAR = 16`. So the arming boundary
512/// is sixteen observation-equivalents, not one — measured: an `n = 3000, k = 5`
513/// fit at `λ_min = 1.989` still armed, at weight `≈ 0.988`. That ramp is a
514/// continuity device for an always-on term — a binary gate makes `Φ(ρ)` jump,
515/// which is the #787 regression — so it stays; what changes is which matrix it
516/// is asked about.)
517///
518/// That premise is false for **every penalized smooth basis**, and this call site
519/// used to hand the gate the bare Fisher information `H`. A `k`-dimensional
520/// spline basis has high-frequency directions the data barely resolve — that is
521/// the entire reason they are penalized — so `λ_min(H)` sits well below one
522/// observation-equivalent on ordinary, well-behaved, nowhere-separating data.
523/// Measured on labels DRAWN from a smooth softmax truth (`y ~ s(x1,k=6) +
524/// s(x2,k=6)`, `n = 600`, every class keeping appreciable probability
525/// everywhere):
526///
527/// ```text
528/// lambda_min = 4.051e-1 lambda_max = 1.575e2 ratio = 2.572e-3
529/// Jeffreys gate weight = 1 => "separation evidence" => Firth/Jeffreys refit
530/// ```
531///
532/// The relative arm is nowhere near firing (`2.6e-3` against a `1e-6` clear
533/// knot); the verdict is the absolute arm reading a *penalized* direction as if
534/// nothing were holding it. So the "#715 arm ONLY on separation evidence" design
535/// was unconditional in practice on any multinomial GAM carrying a smooth.
536///
537/// The distinction the gate has to make is exactly the one #715 derives: a
538/// direction `v` is beyond `λ`'s reach only when `S v = 0`, because
539/// `(H + S_λ)v = Hv + λSv`. Where `Sv ≠ 0` the smoothing parameter supplies the
540/// missing curvature and the direction is identified — by the prior the model
541/// already has. Reading `H` alone cannot tell those apart; reading the penalized
542/// curvature `H + S_λ` at the selected `λ` is the same question asked of the
543/// objective that was actually optimized, and its units are unchanged (the
544/// Frobenius-normalized `S` makes `λS` directly comparable to data Fisher
545/// information — see [`MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS`]).
546///
547/// The unpenalized spectrum is reported alongside the deciding one whenever the
548/// certificate fires, because "the data do not determine this direction" and
549/// "and no `λ` repairs it" are two different statements and the verdict rests on
550/// the second. That costs one extra reduced eigendecomposition, paid only on the
551/// branch that arms.
552///
553/// # And the SUBSPACE, not only the verdict (#2612)
554///
555/// Reading `H + S_λ` fixed which matrix the verdict is taken on. It did not fix
556/// which directions the verdict is taken *over*, and that is the second half of
557/// the same sentence: `(H + S_λ)v = Hv + λSv`, so a direction is beyond every
558/// `λ`'s reach **exactly when `S_λ v = 0`**. Where `S_λ v ≠ 0` the model already
559/// carries a proper prior on `v`; the posterior there is proper, and
560/// [`crate::multinomial_predictive`] integrates it EXACTLY — the published
561/// probability is a ratio of normalising constants with a per-row measured error,
562/// not a Gaussian approximation — so a diffuse penalized direction already shows
563/// up in the published probability as the width it is. Arming a second, much
564/// stronger prior there is not stabilisation; it is a different model.
565///
566/// That distinction is not cosmetic, because the term this certificate arms does
567/// not act only on the direction that armed it. `jeffreys_antiderivative`
568/// saturates at `Λ = CONDITIONING_GATE_ABSOLUTE_CLEAR = 16` observation-
569/// equivalents: below `Λ` a direction earns the full `1/λ` prior push, above it
570/// essentially none. The full-span choice was justified by "the Jeffreys score is
571/// `O(1)` against the data's `O(n)` Fisher information, so on a data-identified
572/// direction its only effect is the `O(1/n)` Firth bias correction". **On a
573/// quasi-separated multinomial the data's information is not `O(n)` in ANY
574/// direction**: the softmax Fisher weight `W = diag(p) − p pᵀ` collapses to
575/// `≈ 0.005` per row on a confident fit, so measured at the penguins witness's
576/// certified unbiased mode,
577///
578/// ```text
579/// likelihood alone: lambda_min = -8.8e-19 lambda_max = 1.4423
580/// H + S_lambda: lambda_min = 1.92e-4 lambda_max = 2298.5
581/// ```
582///
583/// — `λ_max(H) = 1.44` over a **74-dimensional** span at `n = 228`, i.e. every
584/// direction of the basis sits inside the window where the prior acts at full
585/// strength, while the fit's own penalty already bounds most of them up to
586/// `2298`. The armed fit then publishes mean argmax probability `0.828` against
587/// held-out accuracy `0.965`: a 13.7-point calibration deficit, in the MODE
588/// (plug-in `0.20755` against posterior-mean `0.20917`), which is the
589/// under-confidence #2612 is named for.
590///
591/// So the certificate is taken on the subspace no `λ` reaches. With NO penalised
592/// component that subspace is the whole identifiable span and this is byte-for-
593/// byte the previous decision — which is why a quasi-separated design carrying no
594/// penalty still arms, and must. `ker(S_λ)` is measured by the same relative rule
595/// [`crate::multinomial_reml::measured_penalty_rank`] reports as a count, so the
596/// dimension and the basis can never disagree.
597///
598/// REJECTED: handing the Jeffreys TERM `H + S_λ` instead of `H` (the honest
599/// completion, and the right long-run answer) — the term's information would then
600/// depend explicitly on `ρ`, and the outer hypergradient carries no explicit-`ρ`
601/// channel for it, so the analytic gradient would silently stop matching its own
602/// value; that is a larger change than this issue can verify. Widening the gate's
603/// knots (they are derived, and they are not what is wrong). Reading the
604/// deficient subspace of `H + S_λ` itself (it moves with `β` and `ρ`, so the
605/// Jeffreys derivative tower would no longer be differentiating a fixed span).
606fn multinomial_formula_penalized_separation_evidence(
607 family: &MultinomialFamily,
608 specs: &[ParameterBlockSpec],
609 block_states: &[ParameterBlockState],
610 identifiable_span: ArrayView2<'_, f64>,
611 joint_specs: &[gam_problem::JointPenaltySpec],
612 n_penalty_components: usize,
613 joint_log_lambdas: Option<&Array1<f64>>,
614) -> Result<Option<MultinomialSeparationCertificate>, String> {
615 if let Some(evidence) = multinomial_formula_separation_evidence(block_states) {
616 // A saturated or non-finite mode: there is no certified curvature to
617 // measure a span from, so the term keeps its derived `ker(S_lambda)`
618 // span and only the verdict travels.
619 return Ok(Some(MultinomialSeparationCertificate {
620 evidence,
621 measured_span: None,
622 }));
623 }
624 let information = family
625 .joint_jeffreys_information_with_specs(block_states, specs)?
626 .ok_or_else(|| {
627 "multinomial separation certificate requires exact joint Fisher information".to_string()
628 })?;
629 let coefficient_dim = information.nrows();
630 if information.ncols() != coefficient_dim {
631 return Err(format!(
632 "multinomial separation certificate received non-square Fisher information {}x{}",
633 information.nrows(),
634 information.ncols()
635 ));
636 }
637 // The curvature the certified mode actually sits in. `multinomial_joint_
638 // penalty_operator` is the fit's ONE assembly of `S_λ` — the same matrix the
639 // influence reconstruction and the published payload read — so the arming
640 // decision and the published penalty cannot describe different priors.
641 let s_lambda = multinomial_joint_penalty_operator(
642 joint_specs,
643 joint_log_lambdas,
644 n_penalty_components,
645 coefficient_dim,
646 )
647 .map_err(|error| {
648 format!(
649 "multinomial separation certificate could not assemble the selected joint \
650 penalty: {error}"
651 )
652 })?;
653 let penalized = &information + &s_lambda;
654 // The subspace no `λ` reaches. `S_λ` is PSD, so `ker(S_λ)` is exactly the
655 // eigenvectors at (relative) zero, classified by the same rule
656 // `measured_penalty_rank` reports as a count. With no penalised component
657 // `S_λ` is the zero operator and this is the whole identifiable span, which
658 // is what makes the unpenalised quasi-separated design's verdict unchanged.
659 let reduced_penalty = identifiable_span.t().dot(&s_lambda.dot(&identifiable_span));
660 let unreached =
661 crate::multinomial_reml::measured_penalty_nullspace(&reduced_penalty).map_err(|error| {
662 format!("multinomial separation certificate could not measure ker(S_lambda): {error}")
663 })?;
664 log::info!(
665 "multinomial separation certificate: {}/{} identifiable direction(s) are unreached by \
666 any smoothing parameter (S_lambda v = 0)",
667 unreached.ncols(),
668 reduced_penalty.nrows(),
669 );
670 // Every direction is penalised: the model's own prior is proper everywhere,
671 // and whatever width is left belongs to the posterior the predictive
672 // integrates exactly.
673 if unreached.ncols() == 0 {
674 return Ok(None);
675 }
676 let unreached_span = identifiable_span.dot(&unreached);
677 let plan = gam_solve::estimate::reml::jeffreys_subspace::JointJeffreysPlan::prepare(
678 penalized.view(),
679 unreached_span.view(),
680 )?;
681 // The DECISION, not the contribution: `is_under_identified` is the gate's
682 // derived predicate at one observation-equivalent, while `is_active` is
683 // `weight != 0` and therefore boundaries on the ramp's far knot at sixteen.
684 // Measured: the synthetic softmax-drawn fixture lands at
685 // `λ_min(H + S_λ) = 5.508` — five and a half observations' worth of
686 // curvature in the worst direction, comfortably identified by the
687 // constant's own derivation — and `is_active` still says yes, at weight
688 // `0.783`, because the ramp has not finished tapering. Choosing an
689 // estimand on the support of a smoothing device is the same category error
690 // as choosing one on a cost cap.
691 let (unreached_min, unreached_max) = plan.information_extrema();
692 log::info!(
693 "multinomial separation certificate: on the unreached subspace H+S_lambda lies in \
694 [{unreached_min:e}, {unreached_max:e}], gate weight {:e}, under_identified={}, \
695 singular={}",
696 plan.conditioning_gate_weight(),
697 plan.is_under_identified(),
698 plan.reduced_information_is_singular(),
699 );
700 // UNDER-IDENTIFIED, not merely singular (#2612). "Improper" is the
701 // mathematically minimal reason to add a prior, and it was measured here and
702 // rejected: with the certificate keyed on singularity instead, penguins
703 // disarms and produces a beautiful MODE — held-out plug-in log-loss 0.02481
704 // against nnet::multinom's 0.09494, calibration gap +0.010, in 4.0 s — and
705 // then cannot publish a probability at all, because
706 // `predict_multinomial_formula` is the posterior MEAN and that posterior is
707 // not describable:
708 //
709 // ```text
710 // penguins stride-3: row 24 predictive mass 0.944 (defect 5.6e-2)
711 // penguins stride-4: row 20 predictive mass 0.517 (defect 4.8e-1)
712 // banded n_train=360, 720: augmented mode did not converge in 100 Newton
713 // iterations
714 // ```
715 //
716 // A posterior that is proper only because a direction carries 2.9e-3
717 // observation-equivalents is proper in name: the mode exists, and nothing
718 // else about it does. The mass-defect identity refuses rather than lying,
719 // which is the right behaviour and also the proof — the estimand this path
720 // publishes cannot be computed there. So the criterion stays the gate's own
721 // derived one, at ONE observation-equivalent, and what #2612 changes is
722 // WHERE it is asked (the directions no lambda reaches) and where the term it
723 // arms is allowed to act (the same subspace).
724 if !plan.is_under_identified() {
725 return Ok(None);
726 }
727 let (lambda_min, lambda_max) = plan.information_extrema();
728 let relative = if lambda_max > 0.0 {
729 lambda_min / lambda_max
730 } else {
731 f64::NEG_INFINITY
732 };
733 // Both wider readings, so the refusal shows what it did NOT decide on: the
734 // whole penalized span (which the model's own prior bounds) and the
735 // likelihood alone (which is not the curvature the fit has).
736 let whole_penalized = gam_solve::estimate::reml::jeffreys_subspace::JointJeffreysPlan::prepare(
737 penalized.view(),
738 identifiable_span,
739 )?;
740 let (span_min, span_max) = whole_penalized.information_extrema();
741 let unpenalized = gam_solve::estimate::reml::jeffreys_subspace::JointJeffreysPlan::prepare(
742 information.view(),
743 identifiable_span,
744 )?;
745 let (data_min, data_max) = unpenalized.information_extrema();
746 // ── The verdict has fired. WHERE the prior belongs is a second question ──
747 //
748 // The two are not the same question and this lane measured what happens when
749 // one object answers both.
750 //
751 // **The verdict** — *is there evidence this model needs a proper prior?* — is
752 // a statement about the model's STRUCTURE: a direction no smoothing parameter
753 // can ever reach, which the data does not determine either. That is
754 // `ker(S_λ)` and the gate's predicate on it, exactly as above, and it is what
755 // keeps a fit on non-separating data byte-unchanged (arm 1) and a genuinely
756 // separated design armed (arm 2).
757 //
758 // **The span** — *where does that prior belong?* — is a statement about the
759 // fit's ARITHMETIC at the smoothing it selected. Measured at the penguins
760 // stride-4 unbiased mode:
761 //
762 // ```text
763 // ker(S_lambda): 2 of 74 directions, lambda_min(H+S_lambda) = 1.9e-3
764 // whole identifiable span: lambda_min(H+S_lambda) = 5.1e-5
765 // ```
766 //
767 // The worst-bounded direction — five orders below one observation-equivalent
768 // — is NOT in the kernel. It is a `range(S)` direction whose selected λ railed
769 // at `MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS = 8e-4` pseudo-observations, so the
770 // claim that backs the kernel ("on `range(S)` the model already carries a
771 // proper prior") is true in name and false in magnitude. Left unarmed the
772 // coefficient runs to `|η|∞ ≈ 45` and the posterior-mean predictive refuses to
773 // publish. Arming the measured set instead takes the same fixture to
774 // `acc = 0.9767`, `log-loss 0.07420` and a publishable posterior.
775 //
776 // Trying to make ONE object answer both was measured and rejected: at every
777 // scale of the metric below, either `genuine_separation_still_arms_the_prior`
778 // disarmed (the verdict got stricter) or
779 // `a_quasi_separated_smooth_fit_is_calibrated` broke at `-0.0525` against a
780 // `0.05` bar (the span got wider). Threading that needle by choosing the
781 // scale is choosing an estimand on a curve, which is the objection #2615
782 // raised against choosing `EFFECTIVE_DF_FLOOR_RELATIVE_FRACTION`.
783 //
784 // The span is measured in the CLR METRIC, not in the raw ALR coordinates.
785 // Relabelling classes acts on θ by a non-orthogonal contrast change, so a
786 // threshold on the raw spectrum selects a different PHYSICAL subspace for each
787 // choice of reference class; a kernel is congruence-invariant and never had
788 // that problem. Measured cost of taking it raw, on
789 // `multinomial_fit_is_invariant_to_reference_class_1587`: predicted-probability
790 // drift `4.093e-3` across three labelings of one dataset against a `1e-3` bar,
791 // with refit noise exactly `0`.
792 let coefficient_metric = crate::multinomial_reml::centered_class_coefficient_metric(
793 family.active_classes(),
794 family.total_classes,
795 family.design.ncols(),
796 );
797 let reduced_penalized = identifiable_span
798 .t()
799 .dot(&penalized.dot(&identifiable_span));
800 let reduced_metric = identifiable_span
801 .t()
802 .dot(&coefficient_metric.dot(&identifiable_span));
803 let measured =
804 crate::multinomial_reml::under_identified_subspace(&reduced_penalized, &reduced_metric)
805 .map_err(|error| {
806 format!(
807 "multinomial separation certificate could not measure the under-identified \
808 subspace of H+S_lambda: {error}"
809 )
810 })?;
811 log::info!(
812 "multinomial separation certificate: the armed term acts on {}/{} identifiable \
813 direction(s) holding under one observation-equivalent of curvature in H+S_lambda at \
814 the certified mode, of which {} are unreached by any smoothing parameter",
815 measured.ncols(),
816 reduced_penalized.nrows(),
817 unreached.ncols(),
818 );
819 // A measured span that came out EMPTY leaves the derived `ker(S_λ)` route in
820 // place rather than disarming a term the verdict just armed: the verdict is
821 // the decision, and a span this measurement cannot produce is a reason to
822 // fall back, not to overrule it.
823 let measured_span = if measured.ncols() == 0 {
824 None
825 } else {
826 Some(std::sync::Arc::new(identifiable_span.dot(&measured)))
827 };
828 Ok(Some(MultinomialSeparationCertificate {
829 evidence: format!(
830 "no smoothing parameter reaches {}/{} identifiable direction(s), and on that \
831 subspace the penalized curvature H+S_lambda is under-identified at the certified \
832 mode: lambda_min={lambda_min:e}, lambda_max={lambda_max:e}, \
833 lambda_min/lambda_max={relative:e}, Jeffreys gate weight={:e}; the armed term acts \
834 on the {} direction(s) the model fails to bound at the selected lambda \
835 (whole identifiable span: H+S_lambda in [{span_min:e}, {span_max:e}], likelihood \
836 alone in [{data_min:e}, {data_max:e}])",
837 unreached.ncols(),
838 reduced_penalty.nrows(),
839 plan.conditioning_gate_weight(),
840 measured.ncols(),
841 ),
842 measured_span,
843 }))
844}
845
846/// The separation certificate's two answers (#2612): whether the Jeffreys/Firth
847/// prior is armed, and — separately — where it acts.
848#[derive(Debug, Clone)]
849pub(crate) struct MultinomialSeparationCertificate {
850 /// Human-readable evidence, carried to the payload as
851 /// `MultinomialSavedModel::separation_evidence`.
852 pub(crate) evidence: String,
853 /// Orthonormal basis, in raw joint coefficient order, of the directions the
854 /// model fails to bound at the smoothing it selected — the span the armed
855 /// term acts on. `None` when there is none to measure (a saturated or
856 /// non-finite solve) or when the measurement came out empty, in which case
857 /// the term keeps its derived `ker(S_lambda)` span.
858 pub(crate) measured_span: Option<std::sync::Arc<Array2<f64>>>,
859}
860
861impl MultinomialSeparationCertificate {
862 pub(crate) fn as_str(&self) -> &str {
863 &self.evidence
864 }
865}
866
867impl std::fmt::Display for MultinomialSeparationCertificate {
868 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
869 f.write_str(self.as_str())
870 }
871}
872
873/// Inputs to [`fit_penalized_multinomial`].
874///
875/// The penalty matrix `S` is shared across classes; per-class smoothing
876/// parameters `lambdas` (length `K - 1`) scale `S` independently for each
877/// active class. The full block-replicated penalty is `diag_a(λ_a) ⊗ S`,
878/// which is exactly what [`gam_solve::arrow_schur::KroneckerPenaltyOp`]
879/// expresses in matrix-free form when this driver is later lifted into the
880/// arrow-Schur loop.
881#[derive(Debug, Clone)]
882pub struct MultinomialFitInputs<'a> {
883 /// Design matrix `X ∈ ℝ^{N×P}` (one row per observation).
884 pub design: ArrayView2<'a, f64>,
885 /// Categorical response `Y ∈ ℝ^{N×K}`. Each row must be a point on the
886 /// probability simplex (`y_c ≥ 0`, `Σ_c y_c = 1`): a one-hot indicator for
887 /// hard classification, or a label-smoothed probability vector. Rows whose
888 /// mass departs from 1 are rejected — the softmax residual gradient and
889 /// Fisher block are the derivatives of `Σ_c y_c log p_c` only under the
890 /// simplex constraint (see `validate_multinomial_simplex`).
891 pub y_one_hot: ArrayView2<'a, f64>,
892 /// Shared smoothing penalty `S ∈ ℝ^{P×P}` (symmetric, PSD).
893 pub penalty: ArrayView2<'a, f64>,
894 /// Per-active-class smoothing parameter `λ_a` (length `K - 1`).
895 pub lambdas: ArrayView1<'a, f64>,
896 /// Optional per-row weights (length `N`); `None` ⇒ uniform 1.0.
897 pub row_weights: Option<ArrayView1<'a, f64>>,
898 /// Optional per-row Fisher-block override, shape `(N, K-1, K-1)` in the
899 /// active-class gauge (the reference class `K-1` is dropped). When `Some`,
900 /// each Newton step uses this block as the curvature `W` in place of the
901 /// analytic softmax Fisher `w_n (δ_ab p_a − p_a p_b)`; the gradient/residual
902 /// path stays analytic, so this is a curvature-only override (the
903 /// research escape-hatch for latent multinomial fits, issue #349). Each
904 /// per-row block must be symmetric, PSD, and finite — preconditions the
905 /// FFI boundary discharges before constructing this view.
906 pub fisher_w_override: Option<ArrayView3<'a, f64>>,
907 /// Maximum Newton iterations; recommend 50.
908 pub max_iter: usize,
909 /// Relative-step convergence tolerance; recommend 1e-7.
910 pub tol: f64,
911 /// Optional checkpoint emitted by a prior fixed-λ multinomial stall on
912 /// the same design, response, weights, offsets, penalty, and lambdas. A
913 /// `MultinomialNewton` checkpoint resumes the ordinary softmax objective;
914 /// a `MultinomialFirth` checkpoint resumes the Jeffreys/Firth separation
915 /// objective directly. Any other stage or coefficient shape is rejected.
916 pub resume_from: Option<&'a FixedLambdaCheckpoint>,
917}
918
919/// Outputs of [`fit_penalized_multinomial`].
920#[derive(Debug, Clone)]
921pub struct MultinomialFitOutputs {
922 /// Active-class coefficient block, shape `(P, K-1)` (column `a` is `β_a`).
923 /// The reference class `K - 1` has `β_{K-1} ≡ 0` by construction and is
924 /// not stored.
925 pub coefficients_active: Array2<f64>,
926 /// Fitted probabilities, shape `(N, K)`.
927 pub fitted_probabilities: Array2<f64>,
928 /// Number of Newton iterations executed (including the final step that
929 /// satisfied the tolerance). Non-convergence (outside the separation lane,
930 /// which escalates to the Firth refit) is surfaced as the typed
931 /// [`EstimationError::FixedLambdaNewtonDidNotConverge`] rather than an `Ok`
932 /// with a flag, so every constructed value of this struct is a certified
933 /// converged fit (SPEC: a fit only ever comes from a converged
934 /// optimization).
935 pub iterations: usize,
936 /// Penalized negative log-likelihood at the returned `β̂`:
937 /// `−log L(β̂) + ½ Σ_a λ_a · β̂_a^T S β̂_a`.
938 pub penalized_neg_log_likelihood: f64,
939 /// Unpenalized deviance `−2 log L(β̂)` for diagnostic reporting.
940 pub deviance: f64,
941 /// Joint Laplace posterior coefficient covariance `H⁻¹` at the converged
942 /// `β̂`, shape `(P·(K−1))×(P·(K−1))` (#1101). Block-ordered to match the
943 /// stacked active-class coefficient vector `β = [β_0; …; β_{K-2}]`: active
944 /// class `a`'s `P` coefficients occupy rows/cols `a·P .. (a+1)·P`, indexed
945 /// `θ[a·P + i] = β̂[i, a]`. This is the Laplace covariance from the factored
946 /// penalized Hessian `XᵀWX + diag_a(λ_a)⊗S`; it drives the delta-method
947 /// per-class probability standard errors
948 /// ([`Self::logistic_normal_softmax_moments`])
949 /// on the fixed-λ inner-solve path.
950 pub coefficient_covariance: Array2<f64>,
951}
952
953impl MultinomialFitOutputs {
954 /// Number of active classes `M = K − 1` (columns of
955 /// [`Self::coefficients_active`]).
956 pub fn n_active_classes(&self) -> usize {
957 self.coefficients_active.ncols()
958 }
959
960 /// Per-class coefficient dimension `P` (rows of
961 /// [`Self::coefficients_active`]).
962 pub fn p_per_class(&self) -> usize {
963 self.coefficients_active.nrows()
964 }
965
966 /// Integrate the logistic-normal coefficient posterior at fresh design rows:
967 /// `E[softmax(η)]` and its marginal standard deviations with
968 /// `η ~ N(x'β̂, x'Σx)`, the full joint covariance (cross-class blocks
969 /// included) contracted into each row's active-logit covariance before
970 /// deterministic adaptive integration.
971 ///
972 /// # This is NOT the posterior mean, and the name says so on purpose (#2612)
973 ///
974 /// The quantity a caller usually wants — the posterior mean probability —
975 /// is not this. Integrating a nonlinear functional over the Laplace Gaussian
976 /// keeps the curvature half of the `O(n⁻¹)` correction to a posterior mean
977 /// and drops the skewness half; on a (quasi-)separated softmax the two are
978 /// neither small nor same-signed, and the result is under-confident by up to
979 /// tens of percentage points at unchanged argmax. The posterior mean is
980 /// computed by [`crate::multinomial_predictive`] as a ratio of normalising
981 /// constants, and [`MultinomialSavedModel::predict_probabilities`] is the
982 /// entry point that publishes it.
983 ///
984 /// What this function IS, and why it stays: the exact moments of `softmax`
985 /// under a STATED Gaussian. That is a well-defined object with its own uses
986 /// (propagating a declared coefficient uncertainty through the link, and
987 /// pinning the integrator itself), and naming it for the Gaussian rather
988 /// than for the posterior is what keeps the two from being confused again.
989 pub fn logistic_normal_softmax_moments(
990 &self,
991 x_new: ArrayView2<'_, f64>,
992 ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
993 self.logistic_normal_softmax_moments_with_control(
994 x_new,
995 &MultinomialPosteriorIntegrationControl::default(),
996 )
997 }
998
999 pub fn logistic_normal_softmax_moments_with_control(
1000 &self,
1001 x_new: ArrayView2<'_, f64>,
1002 control: &MultinomialPosteriorIntegrationControl,
1003 ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
1004 let moments = integrate_multinomial_design_moments(
1005 self.coefficients_active.view(),
1006 self.coefficient_covariance.view(),
1007 x_new,
1008 control,
1009 )?;
1010 Ok((moments.class_mean, moments.class_standard_deviation))
1011 }
1012}
1013
1014#[derive(Clone, Copy)]
1015struct FirthResume<'a> {
1016 coefficients: ArrayView2<'a, f64>,
1017 completed_iterations: usize,
1018}
1019
1020fn fixed_lambda_checkpoint_coefficients(
1021 checkpoint: &FixedLambdaCheckpoint,
1022 expected_stage: FixedLambdaSolverStage,
1023 p: usize,
1024 m: usize,
1025) -> Result<Array2<f64>, EstimationError> {
1026 checkpoint.validate().map_err(|reason| {
1027 EstimationError::InvalidInput(format!(
1028 "multinomial fixed-λ resume checkpoint is invalid: {reason}"
1029 ))
1030 })?;
1031 if checkpoint.stage() != expected_stage {
1032 crate::bail_invalid_estim!(
1033 "multinomial fixed-λ resume checkpoint stage is {}, expected {}",
1034 checkpoint.stage(),
1035 expected_stage,
1036 );
1037 }
1038 if checkpoint.rows() != p || checkpoint.cols() != m {
1039 crate::bail_invalid_estim!(
1040 "multinomial fixed-λ resume checkpoint shape {}x{} does not match P x (K-1) = {p}x{m}",
1041 checkpoint.rows(),
1042 checkpoint.cols(),
1043 );
1044 }
1045 Array2::from_shape_vec((p, m), checkpoint.values().to_vec()).map_err(|error| {
1046 EstimationError::InvalidInput(format!(
1047 "multinomial fixed-λ resume checkpoint could not be reshaped: {error}"
1048 ))
1049 })
1050}
1051
1052/// Fit a penalized multinomial-logit GAM at fixed `λ`.
1053///
1054/// See the module docs for the optimization problem and conventions. This
1055/// function is the canonical inner solve: the outer REML/LAML loop, when
1056/// added, calls this at each `ρ = log λ` trial.
1057pub fn fit_penalized_multinomial(
1058 inputs: MultinomialFitInputs<'_>,
1059) -> Result<MultinomialFitOutputs, EstimationError> {
1060 let MultinomialFitInputs {
1061 design,
1062 y_one_hot,
1063 penalty,
1064 lambdas,
1065 row_weights,
1066 fisher_w_override,
1067 max_iter,
1068 tol,
1069 resume_from,
1070 } = inputs;
1071
1072 // ──────────────────────── family-specific validation ───────────────────
1073 // The shared engine re-validates the geometry common to every vector-GLM
1074 // (nonempty design, penalty shape, λ finiteness/non-negativity, override
1075 // `(N, M, M)` shape, finite design). The multinomial family owns the
1076 // class-count contract (`K ≥ 2`, λ length `K`), the per-row simplex
1077 // precondition under which the softmax residual/Fisher are the exact
1078 // derivatives of `Σ_c y_c log p_c`, and the row-weight check the likelihood
1079 // adapter consumes.
1080 let n_obs = design.nrows();
1081 let (y_rows, k) = y_one_hot.dim();
1082 if y_rows != n_obs {
1083 crate::bail_invalid_estim!(
1084 "fit_penalized_multinomial: y rows {y_rows} ≠ design rows {n_obs}"
1085 );
1086 }
1087 if k < 2 {
1088 crate::bail_invalid_estim!(
1089 "fit_penalized_multinomial: need at least 2 classes (got K={k})"
1090 );
1091 }
1092 let m = k - 1;
1093 // #2344: the fixed-λ contract is K per-CLASS lambdas (reference class
1094 // included), matching the permutation-equivariant carrier the REML route
1095 // selects (1326d0794). K−1 per-CONTRAST lambdas anchored the smoothing to
1096 // the arbitrary ALR baseline — relabeling the classes changed the fitted
1097 // model. No backcompat shim: K lambdas is the honest contract for nominal
1098 // classes.
1099 if lambdas.len() != k {
1100 crate::bail_invalid_estim!(
1101 "fit_penalized_multinomial: lambdas length {} ≠ K = {k} (one λ per class, \
1102 reference class included — the permutation-equivariant per-class contract, #2344)",
1103 lambdas.len()
1104 );
1105 }
1106 if let Some(fw) = fisher_w_override.as_ref() {
1107 if fw.dim() != (n_obs, m, m) {
1108 crate::bail_invalid_estim!(
1109 "fit_penalized_multinomial: fisher_w_override shape {:?} ≠ (N, K-1, K-1) = ({n_obs}, {m}, {m})",
1110 fw.dim()
1111 );
1112 }
1113 }
1114 if let Some(w) = row_weights.as_ref() {
1115 if w.len() != n_obs {
1116 crate::bail_invalid_estim!(
1117 "fit_penalized_multinomial: row_weights length {} ≠ N = {n_obs}",
1118 w.len()
1119 );
1120 }
1121 for (i, &v) in w.iter().enumerate() {
1122 if !(v.is_finite() && v >= 0.0) {
1123 crate::bail_invalid_estim!(
1124 "fit_penalized_multinomial: row_weights[{i}] must be finite and ≥ 0 (got {v})"
1125 );
1126 }
1127 }
1128 }
1129 validate_multinomial_simplex(y_one_hot, "fit_penalized_multinomial")?;
1130
1131 let p = design.ncols();
1132 let resumed_newton_coefficients = match resume_from {
1133 Some(checkpoint) if checkpoint.stage() == FixedLambdaSolverStage::MultinomialFirth => {
1134 let coefficients = fixed_lambda_checkpoint_coefficients(
1135 checkpoint,
1136 FixedLambdaSolverStage::MultinomialFirth,
1137 p,
1138 m,
1139 )?;
1140 return fit_penalized_multinomial_firth_fallback(
1141 design,
1142 y_one_hot,
1143 penalty,
1144 lambdas,
1145 row_weights,
1146 max_iter,
1147 tol,
1148 Some(FirthResume {
1149 coefficients: coefficients.view(),
1150 completed_iterations: checkpoint.completed_iterations(),
1151 }),
1152 );
1153 }
1154 Some(checkpoint) => Some(fixed_lambda_checkpoint_coefficients(
1155 checkpoint,
1156 FixedLambdaSolverStage::MultinomialNewton,
1157 p,
1158 m,
1159 )?),
1160 None => None,
1161 };
1162 let vector_resume = resumed_newton_coefficients
1163 .as_ref()
1164 .map(|coefficients| VectorGlmResume {
1165 coefficients: coefficients.view(),
1166 completed_iterations: resume_from
1167 .map(FixedLambdaCheckpoint::completed_iterations)
1168 .unwrap_or(0),
1169 });
1170
1171 // ────────────────────────── likelihood construction ───────────────────
1172 let mut likelihood = MultinomialLogitLikelihood::with_classes(k)?;
1173 if let Some(w) = row_weights.as_ref() {
1174 likelihood = likelihood.with_row_weights(w.to_owned())?;
1175 }
1176
1177 // ─────────────────── shared penalized vector-GLM solve ─────────────────
1178 // The softmax Fisher block is dense across the `M = K − 1` active classes;
1179 // the engine assembles the coupled `(P·M)×(P·M)` penalized Hessian, runs
1180 // the damped Newton loop, and returns the converged `β̂` and `η = X β̂`.
1181 let solve = fit_penalized_vector_glm(
1182 PenalizedVectorGlmInputs {
1183 design,
1184 y: y_one_hot,
1185 penalty,
1186 lambdas,
1187 fisher_w_override,
1188 max_iter,
1189 tol,
1190 // #2344: the permutation-equivariant per-class metric — the fixed-λ
1191 // twin of the REML equivariant carrier (1326d0794). K per-class
1192 // lambdas on the centered class functions; reference-free by
1193 // construction, collapsing to the shared Centered metric at equal λ.
1194 class_penalty_metric:
1195 crate::penalized_vector_glm::ClassPenaltyMetric::EquivariantPerClass,
1196 resume_from: vector_resume,
1197 },
1198 &likelihood,
1199 "fit_penalized_multinomial",
1200 )?;
1201
1202 let fit = match solve {
1203 VectorGlmSolve::Converged(fit) => fit,
1204 VectorGlmSolve::Stalled(stall) => {
1205 return handle_multinomial_fixed_lambda_stall(
1206 stall,
1207 design,
1208 y_one_hot,
1209 penalty,
1210 lambdas,
1211 row_weights,
1212 max_iter,
1213 tol,
1214 );
1215 }
1216 };
1217
1218 let fitted_probabilities = likelihood.probabilities(fit.eta.view());
1219
1220 Ok(MultinomialFitOutputs {
1221 coefficients_active: fit.coefficients,
1222 fitted_probabilities,
1223 iterations: fit.iterations,
1224 penalized_neg_log_likelihood: -fit.log_likelihood + fit.penalty_term,
1225 deviance: -2.0 * fit.log_likelihood,
1226 coefficient_covariance: fit.coefficient_covariance,
1227 })
1228}
1229
1230/// Resolve a budget-exhausted fixed-λ softmax Newton solve: either the
1231/// separation lane (escalate to the Firth/Jeffreys proper-prior refit) or the
1232/// typed non-convergence error. Never mints a fit from the stalled iterate.
1233fn handle_multinomial_fixed_lambda_stall(
1234 stall: crate::penalized_vector_glm::VectorGlmStall,
1235 design: ArrayView2<'_, f64>,
1236 y_one_hot: ArrayView2<'_, f64>,
1237 penalty: ArrayView2<'_, f64>,
1238 lambdas: ArrayView1<'_, f64>,
1239 row_weights: Option<ArrayView1<'_, f64>>,
1240 max_iter: usize,
1241 tol: f64,
1242) -> Result<MultinomialFitOutputs, EstimationError> {
1243 let (max_abs_eta, row_index, active_class_index) = max_abs_eta_location(stall.eta.view());
1244 if max_abs_eta >= MULTINOMIAL_SEPARATION_ETA_THRESHOLD {
1245 // Perfect / quasi-perfect separation (#1854): the UNBIASED softmax MLE is
1246 // not finite along `active_class_index`'s saturated logit direction, so
1247 // the fixed-λ Newton above ran away (`|η| ≥ 25`, no convergence). A
1248 // penalty-null direction `v` (`S v = 0`, e.g. an unpenalized intercept /
1249 // linear-covariate column) under softmax saturation has
1250 // `(XᵀWX + λS) v → 0` for EVERY λ, so no smoothing parameter can bound it
1251 // — only a proper prior on that quotient-null subspace can. Rather than
1252 // hard-erroring, engage the Firth/Jeffreys proper prior automatically
1253 // (magic-by-default): the full-span `½ log|I(β)|` correction supplies the
1254 // `O(1)` curvature that keeps the estimate finite on exactly those
1255 // separated directions while leaving well-identified fits untouched. This
1256 // reuses the same coupled joint-Newton Jeffreys machinery the formula
1257 // REML path arms on separation evidence (see
1258 // `fit_penalized_multinomial_formula`), only here at the caller's fixed λ.
1259 // Engage the fallback, but never let an internal consistency panic in
1260 // the coupled joint-Newton assembly (e.g. the #1395 logdet-collapse
1261 // guard) escape as a process abort: convert any panic into the
1262 // documented hard separation diagnostic, exactly as if the refit had
1263 // returned Err. This mirrors the catch_unwind panic-to-typed-error
1264 // boundary already used around the faer / cudarc entry points, and keeps
1265 // the separation path no worse than the pre-#1854 clean error while the
1266 // Firth refit is still being hardened.
1267 // Start the Firth refit from the well-conditioned origin (β = 0), NOT
1268 // from the stalled Newton iterate. That stalled iterate is the runaway
1269 // separated point (`|η| ≥ 25`), where the softmax Fisher information
1270 // `I(β)` is numerically singular (every fitted probability is pinned to
1271 // the {0,1} simplex boundary, so `I → 0`). Warm-starting the Firth
1272 // Newton there is catastrophic: the first step `(I + λS)⁻¹ U*` is
1273 // unbounded and every backtracked candidate stays on the boundary, so
1274 // the line search exhausts without an accepted step and the refit stalls
1275 // at iteration 1 — it can never climb back to the interior Firth mode.
1276 // The Firth objective's interior mode is start-independent (the
1277 // `firth_solver_rejects_a_truncated_iterate` resume contract asserts the
1278 // same mode is reached from any interior start), and from `β = 0` the
1279 // information is well-conditioned, so a plain from-zero refit converges
1280 // reliably on exactly the separated data that defeated the fixed-λ
1281 // Newton above.
1282 let firth = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1283 fit_penalized_multinomial_firth_fallback(
1284 design,
1285 y_one_hot,
1286 penalty,
1287 lambdas,
1288 row_weights,
1289 max_iter,
1290 tol,
1291 None,
1292 )
1293 }));
1294 match firth {
1295 // SPEC: a fit object must only ever come from a converged
1296 // optimization — the Firth fallback itself surfaces a
1297 // budget-exhausted refit as the typed
1298 // `FixedLambdaNewtonDidNotConverge`, which is forwarded verbatim so
1299 // the caller sees which lane stalled and its evidence.
1300 Ok(Ok(out)) => return Ok(out),
1301 Ok(Err(err @ EstimationError::FixedLambdaNewtonDidNotConverge { .. })) => {
1302 return Err(err);
1303 }
1304 // Firth refit errored, or an internal consistency guard panicked:
1305 // fall back to the explicit hard separation diagnostic.
1306 Ok(Err(_)) | Err(_) => {
1307 return Err(EstimationError::MultinomialSeparationDetected {
1308 iteration: stall.iterations,
1309 max_abs_eta,
1310 active_class_index,
1311 row_index,
1312 });
1313 }
1314 }
1315 }
1316
1317 // SPEC: a fit object must only ever come from a converged optimization.
1318 // A stall WITHOUT the separation fingerprint (|η| below the threshold —
1319 // e.g. ill-conditioned data exhausting `max_iter`) is a typed error
1320 // carrying its evidence, never an Ok(outputs) with a flag.
1321 Err(stall.into_nonconvergence_error(
1322 FixedLambdaSolverStage::MultinomialNewton,
1323 "fit_penalized_multinomial (fixed-λ softmax damped Newton)",
1324 )?)
1325}
1326
1327/// Firth/Jeffreys-penalized multinomial refit engaged automatically when the
1328/// unbiased softmax MLE separates (#1854).
1329///
1330/// The unbiased fixed-λ solve ([`fit_penalized_multinomial`]) runs away on
1331/// (quasi-)separated data because the softmax likelihood has no finite mode along
1332/// the saturated logit direction and the smoothing penalty `S` cannot bound a
1333/// penalty-null direction (`S v = 0` ⇒ `(XᵀWX + λS) v → 0` for every λ). This
1334/// refit arms the full-span Jeffreys/Firth proper prior `½ log|I(β)|` on the
1335/// coupled joint softmax information, which supplies the `O(1)` curvature that
1336/// bounds exactly those directions and keeps the estimate finite.
1337///
1338/// # The estimator
1339///
1340/// It maximizes the penalized Firth objective at the caller's *fixed* `λ`
1341///
1342/// ```text
1343/// ℓ*(β) = Σ_n w_n Σ_c y_{nc} log p_{nc}
1344/// − ½ Σ_a λ_a βₐᵀ S βₐ
1345/// + ½ log det I(β)
1346/// ```
1347///
1348/// where `I(β)` is the coupled `(P·M)×(P·M)` softmax Fisher information (block
1349/// `(a,b)` is `Σ_n w_n (δ_{ab} p_{na} − p_{na} p_{nb}) x_n x_nᵀ`, block-ordered so
1350/// `θ[a·P+i] = β[i,a]`) and `M = K−1` active classes carry the reference-coded
1351/// logits (`η_{ref} ≡ 0`). The Jeffreys term `½ log det I(β)` is the standard
1352/// Firth penalty: it diverges to `−∞` as any fitted probability approaches the
1353/// simplex boundary (`I → 0`), so its maximizer is interior and finite on exactly
1354/// the separated directions that defeat every smoothing `λ`.
1355///
1356/// # Why this fixed-λ solver rather than the outer-REML formula path
1357///
1358/// The direct entry ([`fit_penalized_multinomial`]) is a fixed-λ inner solve — it
1359/// carries no outer smoothing selection — so the natural Firth engagement is a
1360/// fixed-λ Firth Newton, not the formula path's outer-REML joint-Newton machinery
1361/// (which is armed instead by [`fit_penalized_multinomial_formula`] on separation
1362/// evidence). Solving the Firth objective directly here keeps the separation
1363/// contract self-contained and independent of the shared trust-region/KKT
1364/// certificate machinery.
1365///
1366/// # The iteration
1367///
1368/// A Fisher-scoring Newton on `ℓ*`: the ascent direction is
1369/// `Δ = (I + Λ⊗S)⁻¹ U*`, where `U*` is the Firth-adjusted penalized score
1370///
1371/// ```text
1372/// U*[(c,s)] = Σ_n w_n x_{ns} (y_{nc} − p_{nc}) (data score)
1373/// − λ_c (S β_c)_s (smoothing penalty)
1374/// + ½ Σ_n w_n x_{ns} h^c_n (Firth adjustment)
1375/// ```
1376///
1377/// and the Firth adjustment uses `h^c_n = Σ_{a,b} G^c_{n,ab} Q_{n,ab}` with the
1378/// per-row information "hat" `Q_{n,ab} = x_nᵀ [I⁻¹]_{(a,b)} x_n` and the softmax
1379/// third-derivative tensor
1380/// `G^c_{ab} = δ_{ab} p_a (δ_{ac} − p_c) − p_a p_b (δ_{ac} + δ_{bc} − 2 p_c)`.
1381/// This `½ Σ tr(I⁻¹ ∂I/∂β)` is exactly `∇[½ log det I]` (finite-difference
1382/// verified). Each step is globalized by backtracking on `ℓ*`, so a step that
1383/// would push a probability to the boundary (making `I` non-PD) is rejected and
1384/// the fit stays interior. Convergence is the Newton decrement `½ U*ᵀΔ`.
1385fn fit_penalized_multinomial_firth_fallback(
1386 design: ArrayView2<'_, f64>,
1387 y_one_hot: ArrayView2<'_, f64>,
1388 penalty: ArrayView2<'_, f64>,
1389 lambdas: ArrayView1<'_, f64>,
1390 row_weights: Option<ArrayView1<'_, f64>>,
1391 max_iter: usize,
1392 tol: f64,
1393 resume_from: Option<FirthResume<'_>>,
1394) -> Result<MultinomialFitOutputs, EstimationError> {
1395 use faer::Side;
1396 use gam_linalg::faer_ndarray::{
1397 FaerArrayView, array1_to_col_matmut, array2_to_matmut, factorize_symmetricwith_fallback,
1398 };
1399 use gam_linalg::matrix::FactorizedSystem;
1400
1401 let n_obs = design.nrows();
1402 let p = design.ncols();
1403 let k = y_one_hot.ncols();
1404 let m = k - 1;
1405 let d = p * m;
1406
1407 // Local softmax likelihood mirroring the caller's row weights, used to map the
1408 // fitted η back to probabilities.
1409 let mut likelihood = MultinomialLogitLikelihood::with_classes(k)?;
1410 if let Some(w) = row_weights.as_ref() {
1411 likelihood = likelihood.with_row_weights(w.to_owned())?;
1412 }
1413 let weight = |row: usize| -> f64 { row_weights.as_ref().map_or(1.0, |w| w[row]) };
1414
1415 let tol_eff = if tol.is_finite() && tol > 0.0 {
1416 tol
1417 } else {
1418 1e-8
1419 };
1420
1421 // Probabilities (N, K), active classes 0..M then the pinned reference at M.
1422 let probs_at = |beta: &Array2<f64>| -> Array2<f64> {
1423 let eta = design.dot(beta);
1424 likelihood.probabilities(eta.view())
1425 };
1426
1427 // Coupled softmax Fisher information I (d×d), block-ordered θ[a·P+i] = β[i,a].
1428 let assemble_info = |probs: &Array2<f64>| -> Array2<f64> {
1429 let mut info = Array2::<f64>::zeros((d, d));
1430 for row in 0..n_obs {
1431 let w = weight(row);
1432 if w == 0.0 {
1433 continue;
1434 }
1435 for a in 0..m {
1436 let pa = probs[[row, a]];
1437 let ao = a * p;
1438 for b in 0..m {
1439 let pb = probs[[row, b]];
1440 let wab = w * (if a == b { pa - pa * pb } else { -pa * pb });
1441 if wab == 0.0 {
1442 continue;
1443 }
1444 let bo = b * p;
1445 for i in 0..p {
1446 let xi = design[[row, i]];
1447 if xi == 0.0 {
1448 continue;
1449 }
1450 let cc = wab * xi;
1451 for j in 0..p {
1452 info[[ao + i, bo + j]] += cc * design[[row, j]];
1453 }
1454 }
1455 }
1456 }
1457 }
1458 info
1459 };
1460
1461 // Factor a symmetric matrix (with escalating ridge only if it is not SPD) and
1462 // return its inverse and log-determinant.
1463 //
1464 // The ridge ladder is a standard relative-jitter Cholesky recovery, not a
1465 // tuned knob: (a) the base jitter is scaled to the matrix by `max_diag`
1466 // (`max_diag · ε` with ε at the double-precision Cholesky floor ~1e-10) so it
1467 // is invariant to the overall scale of the Fisher information, falling back
1468 // to an absolute floor only when the diagonal is degenerate; (b) it is tried
1469 // first at ridge 0 so an already-SPD matrix is factored unperturbed; (c) it
1470 // grows geometrically (×4) to span the ~120 dB from the base jitter to O(1)
1471 // in a bounded number of steps; (d) the attempt count is capped so a
1472 // genuinely singular information (e.g. an exactly rank-deficient Fisher block)
1473 // surfaces as an explicit error rather than an unbounded loop.
1474 let invert_spd = |mat: &Array2<f64>,
1475 context: &str|
1476 -> Result<(Array2<f64>, f64), EstimationError> {
1477 let max_diag = (0..d).fold(0.0_f64, |acc, i| acc.max(mat[[i, i]].abs()));
1478 let base = if max_diag.is_finite() && max_diag > 0.0 {
1479 max_diag * 1e-10
1480 } else {
1481 1e-10
1482 };
1483 let mut ridge = 0.0_f64;
1484 for _ in 0..=60 {
1485 let mut ridged = mat.clone();
1486 if ridge > 0.0 {
1487 for i in 0..d {
1488 ridged[[i, i]] += ridge;
1489 }
1490 }
1491 if let Ok(factor) =
1492 factorize_symmetricwith_fallback(FaerArrayView::new(&ridged).as_ref(), Side::Lower)
1493 {
1494 let logdet = factor.logdet();
1495 if logdet.is_finite() {
1496 let mut rhs = Array2::<f64>::eye(d);
1497 {
1498 let v = array2_to_matmut(&mut rhs);
1499 factor.solve_in_place(v);
1500 }
1501 if rhs.iter().all(|x| x.is_finite()) {
1502 let mut inv = Array2::<f64>::zeros((d, d));
1503 for i in 0..d {
1504 for j in 0..d {
1505 inv[[i, j]] = 0.5 * (rhs[[i, j]] + rhs[[j, i]]);
1506 }
1507 }
1508 return Ok((inv, logdet));
1509 }
1510 }
1511 }
1512 ridge = if ridge > 0.0 { ridge * 4.0 } else { base };
1513 }
1514 Err(EstimationError::InvalidInput(format!(
1515 "multinomial Firth fallback: {context} not invertible (max_diag={max_diag:.3e})"
1516 )))
1517 };
1518
1519 // SPD log-determinant only (no ridge): used by the backtracking line search to
1520 // reject any candidate that pushes a fitted probability to the simplex
1521 // boundary (where I loses positive-definiteness and the Firth term → −∞).
1522 let spd_logdet = |mat: &Array2<f64>| -> Option<f64> {
1523 factorize_symmetricwith_fallback(FaerArrayView::new(mat).as_ref(), Side::Lower)
1524 .ok()
1525 .map(|factor| factor.logdet())
1526 .filter(|ld| ld.is_finite())
1527 };
1528
1529 // Penalized Firth objective ℓ* (MAXIMIZED), given probabilities, β, and the
1530 // precomputed log det I(β).
1531 let objective = |probs: &Array2<f64>, beta: &Array2<f64>, logdet_info: f64| -> f64 {
1532 let mut ll = 0.0_f64;
1533 for row in 0..n_obs {
1534 let w = weight(row);
1535 if w == 0.0 {
1536 continue;
1537 }
1538 for c in 0..k {
1539 let ycn = y_one_hot[[row, c]];
1540 if ycn != 0.0 {
1541 ll += w * ycn * probs[[row, c]].max(f64::MIN_POSITIVE).ln();
1542 }
1543 }
1544 }
1545 // #2344: equivariant per-class penalty ½·Σ_{a,b} A[a,b]·β_aᵀSβ_b —
1546 // the same metric the shared vector-GLM engine applies, so the Firth
1547 // arm optimizes the identical reference-free objective.
1548 let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1549 let mut pen = 0.0_f64;
1550 for a in 0..m {
1551 let bcol = beta.column(a);
1552 for b in 0..m {
1553 let coef = a_mat[[a, b]];
1554 if coef != 0.0 {
1555 let sbeta = penalty.dot(&beta.column(b));
1556 pen += 0.5 * coef * bcol.dot(&sbeta);
1557 }
1558 }
1559 }
1560 ll - pen + 0.5 * logdet_info
1561 };
1562
1563 // Firth-adjusted penalized score U* (length d, block-ordered).
1564 let firth_score =
1565 |probs: &Array2<f64>, beta: &Array2<f64>, iinv: &Array2<f64>| -> Array1<f64> {
1566 let mut u = Array1::<f64>::zeros(d);
1567 let mut xn = vec![0.0_f64; p];
1568 let mut pa = vec![0.0_f64; m];
1569 let mut q = vec![0.0_f64; m * m];
1570 for row in 0..n_obs {
1571 let w = weight(row);
1572 if w == 0.0 {
1573 continue;
1574 }
1575 for i in 0..p {
1576 xn[i] = design[[row, i]];
1577 }
1578 for a in 0..m {
1579 pa[a] = probs[[row, a]];
1580 }
1581 // Data score: U[(a,i)] += w x_{ni} (y_{na} − p_{na}).
1582 for a in 0..m {
1583 let resid = y_one_hot[[row, a]] - pa[a];
1584 let ao = a * p;
1585 for i in 0..p {
1586 u[ao + i] += w * xn[i] * resid;
1587 }
1588 }
1589 // Per-row information hat Q_{ab} = x_nᵀ [I⁻¹]_{(a,b)} x_n.
1590 for a in 0..m {
1591 let ao = a * p;
1592 for b in 0..m {
1593 let bo = b * p;
1594 let mut s = 0.0_f64;
1595 for i in 0..p {
1596 let xi = xn[i];
1597 if xi == 0.0 {
1598 continue;
1599 }
1600 let mut inner = 0.0_f64;
1601 for j in 0..p {
1602 inner += iinv[[ao + i, bo + j]] * xn[j];
1603 }
1604 s += xi * inner;
1605 }
1606 q[a * m + b] = s;
1607 }
1608 }
1609 // Firth adjustment: U[(c,s)] += ½ w x_{ns} h^c_n.
1610 for c in 0..m {
1611 let pc = pa[c];
1612 let mut h = 0.0_f64;
1613 for a in 0..m {
1614 for b in 0..m {
1615 let dab = if a == b { 1.0 } else { 0.0 };
1616 let dac = if a == c { 1.0 } else { 0.0 };
1617 let dbc = if b == c { 1.0 } else { 0.0 };
1618 let g =
1619 dab * pa[a] * (dac - pc) - pa[a] * pa[b] * (dac + dbc - 2.0 * pc);
1620 h += g * q[a * m + b];
1621 }
1622 }
1623 let co = c * p;
1624 for s in 0..p {
1625 u[co + s] += 0.5 * w * h * xn[s];
1626 }
1627 }
1628 }
1629 // Smoothing penalty gradient (#2344 equivariant metric):
1630 // U[(a,i)] −= Σ_b A[a,b]·(S β_b)_i.
1631 let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1632 for b in 0..m {
1633 let sbeta = penalty.dot(&beta.column(b));
1634 for a in 0..m {
1635 let coef = a_mat[[a, b]];
1636 if coef == 0.0 {
1637 continue;
1638 }
1639 let ao = a * p;
1640 for i in 0..p {
1641 u[ao + i] -= coef * sbeta[i];
1642 }
1643 }
1644 }
1645 u
1646 };
1647
1648 // Penalized Hessian H = I + A(λ) ⊗ S (#2344 equivariant metric; PSD sum
1649 // of rank-1 class projections, so H stays positive definite).
1650 let penalized_hessian = |info: &Array2<f64>| -> Array2<f64> {
1651 let mut h = info.clone();
1652 let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1653 for a in 0..m {
1654 for b in 0..m {
1655 let coef = a_mat[[a, b]];
1656 if coef == 0.0 {
1657 continue;
1658 }
1659 let (ao, bo) = (a * p, b * p);
1660 for i in 0..p {
1661 for j in 0..p {
1662 h[[ao + i, bo + j]] += coef * penalty[[i, j]];
1663 }
1664 }
1665 }
1666 }
1667 h
1668 };
1669
1670 // Solve H Δ = U* for the SPD penalized Hessian, ridge-escalating only on
1671 // factorization failure. Same relative-jitter Cholesky-recovery ladder as
1672 // `invert_spd` above (see its comment for the rationale); the base jitter is
1673 // one decade tighter (`max_diag · 1e-12`) because the penalized Hessian
1674 // solved here is better conditioned than the Fisher information inverted
1675 // there, so a smaller perturbation suffices before escalating.
1676 let solve_spd = |mat: &Array2<f64>,
1677 rhs: &Array1<f64>|
1678 -> Result<Array1<f64>, EstimationError> {
1679 let max_diag = (0..d).fold(0.0_f64, |acc, i| acc.max(mat[[i, i]].abs()));
1680 let base = if max_diag.is_finite() && max_diag > 0.0 {
1681 max_diag * 1e-12
1682 } else {
1683 1e-12
1684 };
1685 let mut ridge = 0.0_f64;
1686 for _ in 0..=60 {
1687 let mut ridged = mat.clone();
1688 if ridge > 0.0 {
1689 for i in 0..d {
1690 ridged[[i, i]] += ridge;
1691 }
1692 }
1693 if let Ok(factor) =
1694 factorize_symmetricwith_fallback(FaerArrayView::new(&ridged).as_ref(), Side::Lower)
1695 {
1696 let mut sol = rhs.clone();
1697 {
1698 let v = array1_to_col_matmut(&mut sol);
1699 factor.solve_in_place(v);
1700 }
1701 if sol.iter().all(|x| x.is_finite()) {
1702 return Ok(sol);
1703 }
1704 }
1705 ridge = if ridge > 0.0 { ridge * 4.0 } else { base };
1706 }
1707 Err(EstimationError::InvalidInput(
1708 "multinomial Firth fallback: penalized Hessian solve failed".to_string(),
1709 ))
1710 };
1711
1712 // ─────────────────────────── Firth Newton loop ────────────────────────────
1713 let (mut beta, completed_iterations) = match resume_from {
1714 Some(resume) => {
1715 if resume.coefficients.dim() != (p, m) {
1716 crate::bail_invalid_estim!(
1717 "multinomial Firth resume coefficient shape {:?} does not match P x (K-1) = {p}x{m}",
1718 resume.coefficients.dim(),
1719 );
1720 }
1721 (resume.coefficients.to_owned(), resume.completed_iterations)
1722 }
1723 None => (Array2::<f64>::zeros((p, m)), 0),
1724 };
1725 let mut iterations = completed_iterations;
1726 let mut stall_reason = FixedLambdaStallReason::IterationBudgetExhausted;
1727 let mut small_step_reached = false;
1728 for it in 0..max_iter {
1729 iterations = completed_iterations.checked_add(it + 1).ok_or_else(|| {
1730 EstimationError::InvalidInput(
1731 "multinomial Firth resume iteration count overflowed usize".to_string(),
1732 )
1733 })?;
1734 let probs = probs_at(&beta);
1735 let info = assemble_info(&probs);
1736 let (iinv, logdet_info) = invert_spd(&info, "Fisher information")?;
1737 let u = firth_score(&probs, &beta, &iinv);
1738 let hmat = penalized_hessian(&info);
1739 let step_vec = solve_spd(&hmat, &u)?;
1740
1741 // Newton decrement ½ U*ᵀ H⁻¹ U* = ½ U*ᵀ Δ (≥ 0, scale-aware stop).
1742 let decrement = u.dot(&step_vec);
1743 if 0.5 * decrement.abs() < tol_eff {
1744 break;
1745 }
1746
1747 // Δ as (P, M): delta[i, a] = step_vec[a·P + i].
1748 let mut delta = Array2::<f64>::zeros((p, m));
1749 for a in 0..m {
1750 let ao = a * p;
1751 for i in 0..p {
1752 delta[[i, a]] = step_vec[ao + i];
1753 }
1754 }
1755
1756 // Backtracking line search on ℓ* (ascent) via the shared `opt`
1757 // primitive: t₀ = 1, halving up to 60 trials. A candidate whose expected
1758 // information `I` is not SPD (boundary) is an INVALID trial (`Ok(None)`),
1759 // so the search contracts without consulting the acceptance test, keeping
1760 // the iterate interior. The ascent predicate `o1 ≥ o0 − 1e-12` is inlined
1761 // verbatim, so the accepted step is bit-for-bit the hand-rolled loop's.
1762 let o0 = objective(&probs, &beta, logdet_info);
1763 let accepted_step = match backtracking_line_search::<_, Infallible>(
1764 BacktrackConfig::default(),
1765 |step| {
1766 let cand = &beta + &(&delta * step);
1767 let cand_probs = probs_at(&cand);
1768 let cand_info = assemble_info(&cand_probs);
1769 Ok(spd_logdet(&cand_info)
1770 .map(|cand_logdet| (objective(&cand_probs, &cand, cand_logdet), cand)))
1771 },
1772 |_, o1| o1 >= o0 - 1e-12,
1773 ) {
1774 Ok(result) => result,
1775 Err(never) => match never {},
1776 };
1777 let Some(accepted_step) = accepted_step else {
1778 // Backtracking exhausted 60 halvings without an admissible ascent
1779 // step. This is convergence ONLY if the iterate is already first-order
1780 // stationary; a line-search stall at a non-stationary point is a
1781 // solver failure and must be reported as such, never papered over as
1782 // `converged = true` (#2066 — SPEC: do not report a non-converged
1783 // iterate as success).
1784 //
1785 // The verdict is the loop's OWN stationarity test — the Newton
1786 // decrement `½·Uᵀ H⁻¹ U` against `tol_eff`, the same criterion the top
1787 // of the loop uses to break as converged. A true interior mode never
1788 // reaches this branch: an infinitesimal step (`step → 0`) leaves the
1789 // iterate SPD with `o1 ≈ o0`, so it is accepted; a numerically flat
1790 // mode is caught by the `max_step` test below after that accepted
1791 // tiny step. Reaching here therefore means Newton still sees a
1792 // meaningful ascent direction it cannot realize (boundary / near-
1793 // singular Fisher information), i.e. a genuine stall → not converged.
1794 stall_reason = FixedLambdaStallReason::LineSearchExhausted;
1795 break;
1796 };
1797
1798 let step = accepted_step.step;
1799 beta = accepted_step.payload;
1800 let max_step = step * delta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1801 let scale = 1.0 + beta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1802 if max_step < tol_eff * scale {
1803 small_step_reached = true;
1804 break;
1805 }
1806 }
1807
1808 // ─────────────────────────── final quantities ─────────────────────────────
1809 for (idx, &v) in beta.iter().enumerate() {
1810 if !v.is_finite() {
1811 crate::bail_invalid_estim!(
1812 "multinomial Firth fallback: non-finite coefficient at flat index {idx} = {v}"
1813 );
1814 }
1815 }
1816 let coefficients_active = beta;
1817
1818 let mut log_likelihood = 0.0_f64;
1819 let probs = probs_at(&coefficients_active);
1820 for row in 0..n_obs {
1821 let w = weight(row);
1822 for c in 0..k {
1823 let ycn = y_one_hot[[row, c]];
1824 if ycn != 0.0 {
1825 log_likelihood += w * ycn * probs[[row, c]].max(f64::MIN_POSITIVE).ln();
1826 }
1827 }
1828 }
1829
1830 // #2344 equivariant metric: the reported penalty term matches the
1831 // objective the solve optimized.
1832 let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1833 let mut penalty_term = 0.0_f64;
1834 for a in 0..m {
1835 let beta_col = coefficients_active.column(a);
1836 for b in 0..m {
1837 let coef = a_mat[[a, b]];
1838 if coef != 0.0 {
1839 let sbeta = penalty.dot(&coefficients_active.column(b));
1840 penalty_term += 0.5 * coef * beta_col.dot(&sbeta);
1841 }
1842 }
1843 }
1844
1845 // Recompute the Firth score and Newton decrement AT the final accepted
1846 // iterate. A tiny backtracked coefficient step is not itself stationarity:
1847 // only this fresh first-order certificate may authorize construction of a
1848 // fit or its covariance.
1849 let info = assemble_info(&probs);
1850 let (information_inverse, final_logdet_info) = invert_spd(&info, "final Fisher information")?;
1851 let final_score = firth_score(&probs, &coefficients_active, &information_inverse);
1852 let hmat = penalized_hessian(&info);
1853 let final_step = solve_spd(&hmat, &final_score)?;
1854 let final_decrement = 0.5 * final_score.dot(&final_step).abs();
1855 if !(final_decrement.is_finite() && final_decrement < tol_eff) {
1856 if small_step_reached {
1857 stall_reason = FixedLambdaStallReason::StationarityCertificateFailed;
1858 }
1859 // SPEC: a fit object must only ever come from a converged optimization.
1860 // A Firth refit that exhausted its budget (or stalled its line search at
1861 // a non-stationary point) is the typed error carrying its evidence — the
1862 // covariance below is never computed for an uncertified iterate.
1863 let checkpoint = FixedLambdaCheckpoint::new(
1864 FixedLambdaSolverStage::MultinomialFirth,
1865 coefficients_active.iter().copied().collect(),
1866 p,
1867 m,
1868 iterations,
1869 )
1870 .map_err(|reason| {
1871 EstimationError::InvalidInput(format!(
1872 "multinomial Firth fallback produced an invalid internal checkpoint: {reason}"
1873 ))
1874 })?;
1875 return Err(EstimationError::FixedLambdaNewtonDidNotConverge {
1876 context: "fit_penalized_multinomial (Firth/Jeffreys separation refit)".to_string(),
1877 reason: stall_reason,
1878 objective_value: -objective(&probs, &coefficients_active, final_logdet_info),
1879 stationarity: FixedLambdaStationarityEvidence {
1880 kind: FixedLambdaResidualKind::NewtonDecrement,
1881 residual: final_decrement,
1882 bound: tol_eff,
1883 },
1884 checkpoint,
1885 });
1886 }
1887
1888 // Laplace covariance H⁻¹ at the converged mode (block-ordered θ[a·P+i]).
1889 // A covariance that cannot be factored at a certified mode is a hard error,
1890 // never a silent zero matrix (a zero covariance is a false certainty claim).
1891 let (coefficient_covariance, _) = invert_spd(&hmat, "penalized Hessian covariance")?;
1892
1893 Ok(MultinomialFitOutputs {
1894 coefficients_active,
1895 fitted_probabilities: probs,
1896 iterations,
1897 penalized_neg_log_likelihood: -log_likelihood + penalty_term,
1898 deviance: -2.0 * log_likelihood,
1899 coefficient_covariance,
1900 })
1901}
1902
1903// ---------------------------------------------------------------------------
1904// Formula-driven multinomial pipeline
1905// ---------------------------------------------------------------------------
1906//
1907// Slice A of the multinomial integration: a single public entry that takes
1908// a parsed `EncodedDataset`, a Wilkinson-style formula, and a uniform initial
1909// smoothing parameter, then runs the full
1910//
1911// parse → termspec → design (X, S blocks) → one-hot Y → REML λ-selection
1912//
1913// pipeline. `fit_penalized_multinomial_formula` drives the outer REML/LAML
1914// loop (via the custom-family path) to select an independent λ per (class,
1915// term); `init_lambda` (default 1.0) is only the warm-start seed for every
1916// block. The reference class is the last level of the categorical response
1917// column as recorded in the dataset schema.
1918
1919/// Saved-model payload for a multinomial fit driven by a Wilkinson formula.
1920///
1921/// This is what the FFI returns to Python. It carries everything the Python
1922/// `MultinomialModel.predict` path needs to evaluate `softmax(X_new · β)` on
1923/// fresh data using the *training* basis / penalty structure (no refit on
1924/// predict, no re-derivation of class levels).
1925#[derive(Debug, Clone, Serialize, Deserialize)]
1926#[serde(deny_unknown_fields)]
1927pub struct MultinomialSavedModel {
1928 /// The training formula, verbatim. Stored so Python's `summary()` and
1929 /// any round-trip persistence path can echo what was fit.
1930 pub formula: String,
1931 /// Names of the *training* response levels in canonical order. The last
1932 /// entry is the reference class (η = 0); the first `K - 1` carry the
1933 /// active linear-predictor blocks. Class permutations are forbidden:
1934 /// this list is fixed at fit time and predictions emit columns in the
1935 /// same order.
1936 pub class_levels: Vec<String>,
1937 /// Index of the reference class within `class_levels` — currently always
1938 /// `class_levels.len() - 1`, exposed as a field so future "user-pinned
1939 /// reference" gauges (e.g. `family='multinomial', reference='setosa'`)
1940 /// can land without changing the on-disk shape.
1941 pub reference_class_index: usize,
1942 /// Resolved term-collection spec used to build `X` at fit time. Replayed
1943 /// on predict via [`gam_terms::smooth::build_term_collection_design`].
1944 pub resolved_termspec: TermCollectionSpec,
1945 /// Active-class coefficient block, shape `(P, K-1)`. Column `a` is the
1946 /// coefficient vector for class `class_levels[a]`. Stored flat in
1947 /// row-major order to keep the serde payload self-describing.
1948 pub coefficients_flat: Vec<f64>,
1949 /// `P` — coefficient count per active class. Matches the column count of
1950 /// the design matrix the saved `resolved_termspec` produces.
1951 pub p_per_class: usize,
1952 /// Number of active classes (`K - 1`).
1953 pub n_active_classes: usize,
1954 /// Original training column headers, in dataset-column order. Needed at
1955 /// predict time so the FFI can align a fresh `Dataset` to the training
1956 /// schema before evaluating the basis.
1957 pub training_headers: Vec<String>,
1958 /// Container type of the training table. `"unknown"` is the explicit value
1959 /// for Rust/CLI callers without a typed table container; the field is always
1960 /// present so persistence never invents presentation state while loading.
1961 pub training_table_kind: String,
1962 /// REML/LAML-selected smoothing parameters, one per `(active class, smooth
1963 /// term)`, flattened in block-major order: all of class 0's per-term λ,
1964 /// then class 1's, and so on. Per-term penalties (#561) mean each active
1965 /// class block selects an *independent* λ for every smooth term, so this
1966 /// vector has length `Σ_a (#terms in class a)` = `(K − 1) · #terms`. Use
1967 /// [`MultinomialSavedModel::lambdas_per_block`] to segment it by class. An
1968 /// unpenalized model (no smooth terms) yields an empty vector.
1969 pub lambdas: Vec<f64>,
1970 /// Number of smoothing parameters (smooth terms) in each active class
1971 /// block, parallel to `class_levels[0..K-1]`. Segments the flat `lambdas`
1972 /// vector: class `a`'s λ are `lambdas[Σ_{b<a} lambdas_per_block[b] ..][..
1973 /// lambdas_per_block[a]]`. Every entry is identical in the shared-design
1974 /// architecture (all classes share the same term structure), but it is
1975 /// stored explicitly so consumers never have to assume that.
1976 pub lambdas_per_block: Vec<usize>,
1977 /// Newton iterations executed; recorded for the summary report.
1978 pub iterations: usize,
1979 /// The separation evidence that armed the Jeffreys/Firth proper prior on
1980 /// this fit, or `None` when the unbiased penalized-REML criterion was
1981 /// accepted with the prior disarmed (#2612).
1982 ///
1983 /// The two branches publish **different estimands**. Disarmed, the
1984 /// coefficients are the exact penalized-REML mode with zero Firth bias;
1985 /// armed, they are the mode of that objective plus the proper prior
1986 /// `Φ = ½ log|ZᵀHZ|`, which pulls fitted class probabilities toward the
1987 /// uniform simplex `1/K` by an `O(1/n)` amount that #715 measured as a real
1988 /// truth-RMSE cost on interior data. A consumer scoring calibration, a
1989 /// reader comparing two fits, and the CLI summary all need to know which
1990 /// objective produced the numbers in front of them, and until #2612 the
1991 /// decision existed only in a `log::info!` line the caller never sees.
1992 ///
1993 /// The string is the certificate itself, not a flag: a verdict that carries
1994 /// the spectrum it was taken on can be checked, and one that carries only a
1995 /// boolean cannot.
1996 #[serde(default)]
1997 pub separation_evidence: Option<String>,
1998 /// Penalized negative log-likelihood at the returned `β̂`.
1999 pub penalized_neg_log_likelihood: f64,
2000 /// Unpenalized deviance `−2 log L(β̂)`.
2001 pub deviance: f64,
2002 /// Per-active-class effective degrees of freedom (hat-matrix trace),
2003 /// length `K - 1`. Populated when the REML driver reports an
2004 /// inference block; falls back to `None` for the legacy fixed-λ path.
2005 #[serde(default)]
2006 pub edf_per_class: Option<Vec<f64>>,
2007 /// Per-PENALTY effective degrees of freedom, one entry per smoothing
2008 /// parameter (length `== lambdas.len()`), aligned block-major with the flat
2009 /// [`Self::lambdas`] / [`Self::lambdas_per_block`] layout. Each entry is the
2010 /// penalty-block trace EDF `rank(S_k) − λ_k·tr(H⁻¹ S_k)`, clamped to
2011 /// `[0, rank(S_k)]`. This is the per-(class, term, penalty) resolution that
2012 /// the per-class [`Self::edf_per_class`] SUM deliberately hides: only the
2013 /// per-penalty vector reveals whether an individual smooth collapsed onto its
2014 /// polynomial null space (its wiggliness λ driven to the λ-cap), which a
2015 /// per-class total cannot show. Populated whenever the REML driver reports an
2016 /// inference block; `None` on the legacy fixed-λ path or when the trace
2017 /// channel is mis-shaped. Unlike `edf_per_class`, the entries do NOT sum to
2018 /// the model EDF when several penalties share one coefficient range (a
2019 /// double-penalty smooth has `Σ_k rank(S_k) > p_per_class`).
2020 #[serde(default)]
2021 pub edf_per_penalty: Option<Vec<f64>>,
2022 /// Joint posterior coefficient covariance `H⁻¹` (#1101), block-ordered to
2023 /// match the stacked active-class coefficient vector `β = [β_0; …; β_{K-2}]`
2024 /// (class `a`'s `P` coefficients occupy rows/cols `a·P .. (a+1)·P`). This is
2025 /// the Laplace covariance the REML driver already computes from the factored
2026 /// penalized Hessian; storing it makes posterior-mean prediction and its
2027 /// integrated uncertainty well-defined. Flattened row-major over the
2028 /// `(P·M)×(P·M)` matrix. This is required by the versioned persistence
2029 /// schema: a payload without covariance is not a usable multinomial model.
2030 pub coefficient_covariance_flat: Vec<f64>,
2031 /// The first-order smoothing-parameter-uncertainty correction
2032 /// `C = J·Var(ρ̂)·Jᵀ` (#2346), in the SAME raw units and block order as
2033 /// [`Self::coefficient_covariance_flat`], flattened row-major over the
2034 /// `(P·M)×(P·M)` matrix.
2035 ///
2036 /// `Self::coefficient_covariance_flat` is `V_cond = Var(β | λ̂)`: it answers
2037 /// "how wide is the posterior once the smoothing parameters are known
2038 /// exactly", and λ̂ is an estimate, not a known. Every other family in this
2039 /// library publishes the pair — `InferenceCovarianceMode::{Conditional,
2040 /// SmoothingCorrected}` in `gam-predict`, `beta_covariance` /
2041 /// `beta_covariance_corrected` on `FitInference` — and the multinomial is
2042 /// the one that never got it, so its bands were conditional-only
2043 /// (gam#2612, and the #1871 defect one family over).
2044 ///
2045 /// `C` is stored rather than `V_c = V_cond + C` deliberately: the sum is
2046 /// recoverable exactly by addition (see
2047 /// [`Self::coefficient_covariance_corrected`]) while the difference is not
2048 /// — subtracting two nearly-equal covariances would lose every digit of a
2049 /// correction that is small relative to `V_cond`, which is the regime this
2050 /// matrix is most often in.
2051 ///
2052 /// `None` is a TYPED ABSENCE, never a silent substitution: the outer solve
2053 /// retained no ρ curvature, or every ρ coordinate is railed and so has no
2054 /// finite variance to propagate (#2337 Thm 2.3).
2055 #[serde(default)]
2056 pub smoothing_correction_flat: Option<Vec<f64>>,
2057 /// Joint coefficient-space influence matrix `F = H⁻¹ X'WX` (#1101),
2058 /// block-ordered identically to [`Self::coefficient_covariance_flat`].
2059 /// Its per-term diagonal block trace is the term's effective degrees of
2060 /// freedom and its `tr(F_jj)²/tr(F_jj²)` the Wood reference d.f., feeding
2061 /// the rank-truncated Wald smooth-term test in `summary()`. Flattened
2062 /// row-major over the `(P·M)×(P·M)` matrix. `None` when unavailable.
2063 #[serde(default)]
2064 pub coefficient_influence_flat: Option<Vec<f64>>,
2065 /// Per-(active class, smooth term) coefficient column range and unpenalized
2066 /// nullspace dimension within the `P`-wide class block (#1101). Parallel to
2067 /// the smooth terms the design produced; replicated across classes by the
2068 /// shared-design architecture. Drives the Wald smooth-term table in
2069 /// `summary()`. Empty for a wholly parametric (no-smooth) model.
2070 #[serde(default)]
2071 pub smooth_term_spans: Vec<MultinomialSmoothTermSpan>,
2072 /// Training design in the RAW basis, flattened row-major over `(n, P)`.
2073 ///
2074 /// # Why a saved multinomial model carries its own rows (#2612)
2075 ///
2076 /// The published prediction is the posterior MEAN probability
2077 /// `E[softmax(x'β)]`, and a Laplace summary — `β̂` plus
2078 /// [`Self::coefficient_covariance_flat`] — cannot produce it. That summary
2079 /// IS the quadratic model of the log-posterior, and the quadratic model is
2080 /// exactly what fails: integrating `softmax` against `N(β̂, H⁻¹)` keeps the
2081 /// curvature half of the `O(n⁻¹)` correction to a posterior mean and drops
2082 /// the skewness half, which on a (quasi-)separated fit is neither small nor
2083 /// the same sign. Computing the estimand honestly means evaluating the
2084 /// posterior away from the mode, which means the likelihood, which means the
2085 /// rows. See [`crate::multinomial_predictive`].
2086 ///
2087 /// This is the same choice `mgcv` makes when it stores the model frame with
2088 /// the fitted object, and it is required rather than optional: a payload
2089 /// without it cannot answer the question `predict` is asked.
2090 pub training_design_flat: Vec<f64>,
2091 /// Number of training rows `n`; segments [`Self::training_design_flat`].
2092 pub training_rows: usize,
2093 /// Training class index per row, values in `0..K`, aligned to
2094 /// [`Self::class_levels`].
2095 pub training_class_index: Vec<u32>,
2096 /// Training row weights, length `n`.
2097 pub training_weights: Vec<f64>,
2098 /// The joint penalty `S_λ` at the selected smoothing parameters, flattened
2099 /// row-major over `(P·M, P·M)` in the same stacked class-major order as
2100 /// [`Self::coefficient_covariance_flat`].
2101 ///
2102 /// Stored rather than rebuilt because the coupled `Σ_t λ_t (M ⊗ S_t)`
2103 /// carrier (#1587) is assembled from the family's equivariant specs, which
2104 /// the saved payload does not otherwise carry — and because reconstructing
2105 /// it as `Σ⁻¹(I − F)` would put an inverse of the very matrix whose
2106 /// conditioning is the problem on the prediction path.
2107 pub joint_penalty_flat: Vec<f64>,
2108 /// One descriptive label per *penalty component* within a single active-class
2109 /// block, parallel to that block's λ slice (i.e. length
2110 /// `lambdas_per_block[0]`). The Marra–Wood double penalty (and tensor /
2111 /// operator smooths) emit **more than one** penalty component — hence more
2112 /// than one λ — per smooth term, so this is NOT 1:1 with
2113 /// [`Self::smooth_term_spans`]: a single `s(x)` term contributes a primary
2114 /// wiggliness λ labelled `s(x)` and a null-space shrinkage λ labelled
2115 /// `s(x) [null space]`. The summary renderer pairs `lambdas` with these
2116 /// labels component-for-component so no λ is ever dropped (#1544). Built from
2117 /// the per-component term name + penalty role at fit time; empty only for a
2118 /// wholly parametric model.
2119 pub lambda_labels: Vec<String>,
2120}
2121
2122/// One smooth term's coefficient span within a class block, plus its
2123/// unpenalized nullspace dimension and a display label (#1101). The Wald
2124/// smooth-significance test in `summary()` slices the joint covariance /
2125/// influence at `a·P + col_start .. a·P + col_end` for active class `a`.
2126#[derive(Debug, Clone, Serialize, Deserialize)]
2127pub struct MultinomialSmoothTermSpan {
2128 /// Human-readable term label (the smooth's formula token), for the table.
2129 pub label: String,
2130 /// Start column of the term within the per-class `P`-wide coefficient block.
2131 pub col_start: usize,
2132 /// End column (exclusive) of the term within the per-class block.
2133 pub col_end: usize,
2134 /// Leading unpenalized (polynomial nullspace) dimension within the term.
2135 pub nullspace_dim: usize,
2136}
2137
2138/// Descriptive label for one penalty *component* (one λ) within a class block,
2139/// for the `summary()` per-class λ rollup (#1544). A smooth term can emit
2140/// several penalty components — the Marra–Wood double penalty splits `s(x)`
2141/// into a primary wiggliness penalty and a null-space shrinkage penalty, and
2142/// tensor / operator smooths emit a component per margin / differential
2143/// operator — each with its own independently-selected λ. The label is the
2144/// term name (from `PenaltyBlockInfo::termname`) plus a role suffix derived
2145/// from the penalty's [`PenaltySource`], so each λ in the summary names both
2146/// the term it smooths and the role it plays. `pen_idx` is the global penalty
2147/// index, used only as a last-resort fallback label.
2148fn penalty_component_label(info: Option<&PenaltyBlockInfo>, pen_idx: usize) -> String {
2149 use gam_terms::basis::PenaltySource;
2150 let term = info
2151 .and_then(|i| i.termname.clone())
2152 .unwrap_or_else(|| format!("s{pen_idx}"));
2153 let role = match info.map(|i| &i.penalty.source) {
2154 // The primary wiggliness penalty is the term's "main" λ; show the bare
2155 // term name so the common single-penalty case reads cleanly.
2156 Some(PenaltySource::Primary) | None => None,
2157 Some(PenaltySource::DoublePenaltyNullspace) => Some("null space".to_string()),
2158 Some(PenaltySource::OperatorMass) => Some("mass".to_string()),
2159 Some(PenaltySource::OperatorTension) => Some("tension".to_string()),
2160 Some(PenaltySource::OperatorStiffness) => Some("stiffness".to_string()),
2161 Some(PenaltySource::OperatorRelevance { axis }) => Some(format!("axis {axis}")),
2162 Some(PenaltySource::TensorMarginal { dim }) => Some(format!("margin {dim}")),
2163 Some(PenaltySource::TensorSeparable { penalized_margins }) => {
2164 Some(format!("separable {penalized_margins:?}"))
2165 }
2166 Some(PenaltySource::TensorGlobalRidge) => Some("ridge".to_string()),
2167 Some(PenaltySource::Other(s)) => Some(s.clone()),
2168 };
2169 match role {
2170 Some(role) => format!("{term} [{role}]"),
2171 None => term,
2172 }
2173}
2174
2175impl MultinomialSavedModel {
2176 pub fn validate(&self) -> Result<(), EstimationError> {
2177 if self.p_per_class == 0 || self.n_active_classes == 0 {
2178 crate::bail_invalid_estim!(
2179 "multinomial saved model dimensions must be nonzero, got P={} and K-1={}",
2180 self.p_per_class,
2181 self.n_active_classes,
2182 );
2183 }
2184 if self.class_levels.len() != self.n_active_classes + 1 {
2185 crate::bail_invalid_estim!(
2186 "multinomial saved model has {} class levels but K-1={}",
2187 self.class_levels.len(),
2188 self.n_active_classes,
2189 );
2190 }
2191 if self.reference_class_index != self.n_active_classes {
2192 crate::bail_invalid_estim!(
2193 "multinomial saved reference index {} does not equal the final class index {}",
2194 self.reference_class_index,
2195 self.n_active_classes,
2196 );
2197 }
2198 let d = self
2199 .p_per_class
2200 .checked_mul(self.n_active_classes)
2201 .ok_or_else(|| {
2202 EstimationError::InvalidInput(
2203 "multinomial saved coefficient dimension overflowed usize".to_string(),
2204 )
2205 })?;
2206 if self.coefficients_flat.len() != d {
2207 crate::bail_invalid_estim!(
2208 "multinomial saved model has {} coefficient values, expected {d}",
2209 self.coefficients_flat.len(),
2210 );
2211 }
2212 if self.training_table_kind.trim().is_empty() {
2213 crate::bail_invalid_estim!(
2214 "multinomial saved model training_table_kind must be non-empty"
2215 );
2216 }
2217 if self.lambdas_per_block.len() != self.n_active_classes {
2218 crate::bail_invalid_estim!(
2219 "multinomial saved model has {} lambda blocks, expected {}",
2220 self.lambdas_per_block.len(),
2221 self.n_active_classes,
2222 );
2223 }
2224 let lambda_count = self
2225 .lambdas_per_block
2226 .iter()
2227 .try_fold(0usize, |total, &count| total.checked_add(count))
2228 .ok_or_else(|| {
2229 EstimationError::InvalidInput(
2230 "multinomial saved lambda count overflowed usize".to_string(),
2231 )
2232 })?;
2233 if lambda_count != self.lambdas.len() {
2234 crate::bail_invalid_estim!(
2235 "multinomial saved model has {} lambdas but its blocks require {lambda_count}",
2236 self.lambdas.len(),
2237 );
2238 }
2239 if self
2240 .lambdas_per_block
2241 .iter()
2242 .any(|&count| count != self.lambda_labels.len())
2243 {
2244 crate::bail_invalid_estim!(
2245 "multinomial saved model has {} lambda labels but block sizes {:?}",
2246 self.lambda_labels.len(),
2247 self.lambdas_per_block,
2248 );
2249 }
2250 if self
2251 .lambda_labels
2252 .iter()
2253 .any(|label| label.trim().is_empty())
2254 {
2255 crate::bail_invalid_estim!("multinomial saved model lambda labels must be non-empty");
2256 }
2257 let covariance_len = d.checked_mul(d).ok_or_else(|| {
2258 EstimationError::InvalidInput(
2259 "multinomial saved covariance dimension overflowed usize".to_string(),
2260 )
2261 })?;
2262 if self.coefficient_covariance_flat.len() != covariance_len {
2263 crate::bail_invalid_estim!(
2264 "multinomial saved model has {} covariance values, expected {covariance_len}",
2265 self.coefficient_covariance_flat.len(),
2266 );
2267 }
2268 if let Some(correction) = self.smoothing_correction_flat.as_ref() {
2269 // A correction that is present but mis-shaped is worse than one that
2270 // is absent: absence is typed and the consumers fall back to the
2271 // conditional definition, while a wrong shape would silently pair a
2272 // ρ-uncertainty term with the wrong coefficients.
2273 if correction.len() != covariance_len {
2274 crate::bail_invalid_estim!(
2275 "multinomial saved model has {} smoothing-correction values, expected \
2276 {covariance_len}",
2277 correction.len(),
2278 );
2279 }
2280 }
2281 if let Some((index, value)) = self
2282 .coefficients_flat
2283 .iter()
2284 .chain(self.coefficient_covariance_flat.iter())
2285 .chain(self.smoothing_correction_flat.iter().flat_map(|c| c.iter()))
2286 .copied()
2287 .enumerate()
2288 .find(|(_, value)| !value.is_finite())
2289 {
2290 crate::bail_invalid_estim!(
2291 "multinomial saved numeric payload is non-finite at combined index {index}: {value}"
2292 );
2293 }
2294 // #2612: the training frame is what makes the published posterior mean
2295 // computable at all, so its shape is a contract, not a hint.
2296 let design_len = self
2297 .training_rows
2298 .checked_mul(self.p_per_class)
2299 .ok_or_else(|| {
2300 EstimationError::InvalidInput(
2301 "multinomial saved training design size overflowed usize".to_string(),
2302 )
2303 })?;
2304 if self.training_design_flat.len() != design_len {
2305 crate::bail_invalid_estim!(
2306 "multinomial saved model has {} training design values, expected \
2307 {design_len} = {} rows x {} columns",
2308 self.training_design_flat.len(),
2309 self.training_rows,
2310 self.p_per_class,
2311 );
2312 }
2313 if self.training_class_index.len() != self.training_rows
2314 || self.training_weights.len() != self.training_rows
2315 {
2316 crate::bail_invalid_estim!(
2317 "multinomial saved model has {} training rows, {} labels and {} weights",
2318 self.training_rows,
2319 self.training_class_index.len(),
2320 self.training_weights.len(),
2321 );
2322 }
2323 let n_classes = self.class_levels.len();
2324 if let Some(label) = self
2325 .training_class_index
2326 .iter()
2327 .find(|&&label| label as usize >= n_classes)
2328 {
2329 crate::bail_invalid_estim!(
2330 "multinomial saved training label {label} is outside 0..{n_classes}"
2331 );
2332 }
2333 if self.joint_penalty_flat.len() != covariance_len {
2334 crate::bail_invalid_estim!(
2335 "multinomial saved model has {} joint-penalty values, expected {covariance_len}",
2336 self.joint_penalty_flat.len(),
2337 );
2338 }
2339 if let Some((index, value)) = self
2340 .training_design_flat
2341 .iter()
2342 .chain(self.training_weights.iter())
2343 .chain(self.joint_penalty_flat.iter())
2344 .copied()
2345 .enumerate()
2346 .find(|(_, value)| !value.is_finite())
2347 {
2348 crate::bail_invalid_estim!(
2349 "multinomial saved training payload is non-finite at combined index \
2350 {index}: {value}"
2351 );
2352 }
2353 Ok(())
2354 }
2355
2356 /// The training frame and penalty this model carries, as the borrowed view
2357 /// [`crate::multinomial_predictive`] consumes.
2358 pub fn predictive_model<'a>(
2359 &'a self,
2360 training_design: ndarray::ArrayView2<'a, f64>,
2361 training_weights: ndarray::ArrayView1<'a, f64>,
2362 joint_penalty: ndarray::ArrayView2<'a, f64>,
2363 ) -> crate::multinomial_predictive::MultinomialPredictiveModel<'a> {
2364 crate::multinomial_predictive::MultinomialPredictiveModel {
2365 training_design,
2366 training_class_index: &self.training_class_index,
2367 training_weights,
2368 joint_penalty,
2369 n_classes: self.class_levels.len(),
2370 }
2371 }
2372
2373 /// Training design as an `(n, P)` `ndarray`.
2374 pub fn training_design(&self) -> Result<Array2<f64>, EstimationError> {
2375 Array2::from_shape_vec(
2376 (self.training_rows, self.p_per_class),
2377 self.training_design_flat.clone(),
2378 )
2379 .map_err(|error| {
2380 EstimationError::InvalidInput(format!(
2381 "multinomial saved training design is inconsistent with n x P: {error}"
2382 ))
2383 })
2384 }
2385
2386 /// Joint penalty `S_λ` as a `(P·M, P·M)` `ndarray`.
2387 pub fn joint_penalty(&self) -> Result<Array2<f64>, EstimationError> {
2388 let d = self
2389 .p_per_class
2390 .checked_mul(self.n_active_classes)
2391 .ok_or_else(|| {
2392 EstimationError::InvalidInput(
2393 "multinomial saved joint-penalty dimension overflowed usize".to_string(),
2394 )
2395 })?;
2396 Array2::from_shape_vec((d, d), self.joint_penalty_flat.clone()).map_err(|error| {
2397 EstimationError::InvalidInput(format!(
2398 "multinomial saved joint penalty is inconsistent with (P·M)x(P·M): {error}"
2399 ))
2400 })
2401 }
2402
2403 /// Active-class coefficient block as an `(P, K-1)` `ndarray` view.
2404 pub fn coefficients_active(&self) -> Result<Array2<f64>, EstimationError> {
2405 Array2::from_shape_vec(
2406 (self.p_per_class, self.n_active_classes),
2407 self.coefficients_flat.clone(),
2408 )
2409 .map_err(|error| {
2410 EstimationError::InvalidInput(format!(
2411 "multinomial saved coefficient payload is inconsistent with P x (K-1): {error}"
2412 ))
2413 })
2414 }
2415
2416 /// Reconstruct the joint posterior covariance `H⁻¹` as a `(P·M)×(P·M)`
2417 /// `ndarray`, block-ordered to match the stacked coefficient vector
2418 /// `θ[a·P + i] = β[i, a]` (#1101).
2419 pub fn coefficient_covariance(&self) -> Result<Array2<f64>, EstimationError> {
2420 let d = self
2421 .p_per_class
2422 .checked_mul(self.n_active_classes)
2423 .ok_or_else(|| {
2424 EstimationError::InvalidInput(
2425 "multinomial saved covariance dimension overflowed usize".to_string(),
2426 )
2427 })?;
2428 Array2::from_shape_vec((d, d), self.coefficient_covariance_flat.clone()).map_err(|error| {
2429 EstimationError::InvalidInput(format!(
2430 "multinomial saved covariance payload is inconsistent with (P*(K-1)) squared: {error}"
2431 ))
2432 })
2433 }
2434
2435 /// Reconstruct the first-order smoothing correction `C = J·Var(ρ̂)·Jᵀ` as a
2436 /// `(P·M)×(P·M)` `ndarray`, block-ordered like
2437 /// [`Self::coefficient_covariance`] (#2346, gam#2612). `None` is the typed
2438 /// absence documented on [`Self::smoothing_correction_flat`].
2439 pub fn smoothing_correction(&self) -> Option<Array2<f64>> {
2440 let d = self.p_per_class.checked_mul(self.n_active_classes)?;
2441 let flat = self.smoothing_correction_flat.as_ref()?;
2442 Array2::from_shape_vec((d, d), flat.clone()).ok()
2443 }
2444
2445 /// The smoothing-CORRECTED joint posterior covariance `V_c = V_cond + C`,
2446 /// the multinomial's `InferenceCovarianceMode::SmoothingCorrected` matrix.
2447 ///
2448 /// `None` exactly when [`Self::smoothing_correction`] is `None`. This never
2449 /// falls back to the conditional matrix: a caller that asks for the
2450 /// unconditional covariance and is handed the conditional one cannot tell
2451 /// that its interval is narrower than it asked for, which is the whole
2452 /// defect the mode axis exists to make visible.
2453 pub fn coefficient_covariance_corrected(&self) -> Option<Array2<f64>> {
2454 let correction = self.smoothing_correction()?;
2455 let conditional = self.coefficient_covariance().ok()?;
2456 if conditional.dim() != correction.dim() {
2457 return None;
2458 }
2459 Some(conditional + correction)
2460 }
2461
2462 /// Reconstruct the joint influence matrix `F = H⁻¹ X'WX` as a
2463 /// `(P·M)×(P·M)` `ndarray`, block-ordered like
2464 /// [`Self::coefficient_covariance`] (#1101). `None` when unavailable.
2465 pub fn coefficient_influence(&self) -> Option<Array2<f64>> {
2466 let d = self.p_per_class.checked_mul(self.n_active_classes)?;
2467 let flat = self.coefficient_influence_flat.as_ref()?;
2468 Array2::from_shape_vec((d, d), flat.clone()).ok()
2469 }
2470
2471 /// Default posterior-mean class probabilities. This integrates
2472 /// `softmax(eta)` under the per-row Gaussian predictor posterior rather than
2473 /// evaluating softmax at the coefficient mode.
2474 pub fn predict_probabilities(
2475 &self,
2476 x_new: ArrayView2<'_, f64>,
2477 ) -> Result<Array2<f64>, EstimationError> {
2478 // Second moments cost `K(K+1)/2` extra augmented solves per row and
2479 // nothing here reads them, so the mean-only caller does not pay for
2480 // them.
2481 self.predictive_moments(x_new, false)
2482 .map(|moments| moments.class_mean)
2483 }
2484
2485 /// Posterior-mean class probabilities and integrated marginal standard
2486 /// deviations at fresh design rows, under the covariance definition this
2487 /// model can support — [`Self::predict_probabilities_with_se_in_mode`] with
2488 /// `SmoothingCorrected` when the correction is present and `Conditional`
2489 /// when it is not.
2490 ///
2491 /// The DEFAULT is the corrected definition, matching
2492 /// `PredictUncertaintyOptions::default()` for every other family
2493 /// (`gam-predict`), because `λ̂` is an estimate and a band that conditions on
2494 /// it being exact is answering a narrower question than the caller asked.
2495 pub fn predict_probabilities_with_se(
2496 &self,
2497 x_new: ArrayView2<'_, f64>,
2498 ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
2499 let (mean, standard_error, _) = self.predict_probabilities_with_se_and_source(x_new)?;
2500 Ok((mean, standard_error))
2501 }
2502
2503 /// The same pair, plus the covariance definition it was actually built
2504 /// from. A caller that cannot tell a conditional band from an unconditional
2505 /// one cannot reason about the interval it was handed, so the provenance is
2506 /// returned rather than inferred.
2507 pub fn predict_probabilities_with_se_and_source(
2508 &self,
2509 x_new: ArrayView2<'_, f64>,
2510 ) -> Result<(Array2<f64>, Array2<f64>, InferenceCovarianceMode), EstimationError> {
2511 let source = if self.smoothing_correction_flat.is_some() {
2512 InferenceCovarianceMode::SmoothingCorrected
2513 } else {
2514 InferenceCovarianceMode::Conditional
2515 };
2516 let (mean, standard_error) = self.predict_probabilities_with_se_in_mode(x_new, source)?;
2517 Ok((mean, standard_error, source))
2518 }
2519
2520 /// Posterior-mean class probabilities and their marginal standard
2521 /// deviations under an explicitly named covariance definition.
2522 ///
2523 /// # What the two modes are
2524 ///
2525 /// [`InferenceCovarianceMode::Conditional`] is `sd(p_c | λ̂)`, computed by
2526 /// [`crate::multinomial_predictive`] as `√(E[p_c²] − E[p_c]²)` from two
2527 /// ratios of normalising constants at the selected smoothing. It is the
2528 /// exact posterior spread of the probability GIVEN that `λ̂` is the truth.
2529 ///
2530 /// [`InferenceCovarianceMode::SmoothingCorrected`] additionally propagates
2531 /// the uncertainty in `λ̂` itself, by the law of total variance:
2532 ///
2533 /// ```text
2534 /// Var(p_c) = E_ρ[ Var(p_c | ρ) ] + Var_ρ( E[p_c | ρ] )
2535 /// ≈ Var(p_c | ρ̂) + (∂E[p_c|ρ]/∂ρ)ᵀ V_ρ (∂E[p_c|ρ]/∂ρ)
2536 /// ```
2537 ///
2538 /// and the second term needs no new object. To the order the correction is
2539 /// itself computed at, `E[p_c | ρ]` moves with `ρ` through the mode
2540 /// `θ̂(ρ)`, so `∂E[p_c|ρ]/∂ρ = gᵀ ∂θ̂/∂ρ` with `g = ∂p_c/∂θ` the softmax
2541 /// Jacobian at the mode, and
2542 ///
2543 /// ```text
2544 /// Var_ρ( E[p_c|ρ] ) = gᵀ (J V_ρ Jᵀ) g = gᵀ C g
2545 /// ```
2546 ///
2547 /// with `C` exactly the matrix [`Self::smoothing_correction`] carries. That
2548 /// is the response-scale statement of `V_c = V_cond + C`: the same
2549 /// correction, contracted through the same delta-method Jacobian the rest of
2550 /// the library uses on the response scale, with no new constant and no new
2551 /// approximation order.
2552 ///
2553 /// `g` is evaluated at the PLUG-IN probabilities `softmax(x'β̂)` rather than
2554 /// at the published posterior mean. Those differ by `O(n⁻¹)`, and `g`
2555 /// multiplies a term that is itself the first-order correction, so the
2556 /// difference enters at an order neither term is accurate to; the plug-in
2557 /// Jacobian is the exact derivative of the leading term.
2558 ///
2559 /// # Errors
2560 ///
2561 /// Asking for `SmoothingCorrected` on a model that carries no correction is
2562 /// an ERROR, not a silent downgrade — the same contract
2563 /// `gam-predict`'s `SmoothingCorrected` mode holds.
2564 pub fn predict_probabilities_with_se_in_mode(
2565 &self,
2566 x_new: ArrayView2<'_, f64>,
2567 mode: InferenceCovarianceMode,
2568 ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
2569 let moments = self.predictive_moments(x_new, true)?;
2570 let conditional = crate::multinomial_predictive::predictive_standard_deviation(&moments)?;
2571 match mode {
2572 InferenceCovarianceMode::Conditional => Ok((moments.class_mean, conditional)),
2573 InferenceCovarianceMode::SmoothingCorrected => {
2574 let correction = self.smoothing_correction().ok_or_else(|| {
2575 EstimationError::InvalidInput(
2576 "multinomial predict: the smoothing-corrected covariance was requested \
2577 but this fit retained no ρ-uncertainty correction (no outer ρ curvature, \
2578 or every ρ coordinate is railed and has no finite variance); ask for \
2579 the conditional definition by name rather than receiving it unannounced"
2580 .to_string(),
2581 )
2582 })?;
2583 let smoothing_variance =
2584 self.smoothing_variance_of_class_probability(x_new, correction.view())?;
2585 let mut total = conditional;
2586 for ((row, class), value) in total.indexed_iter_mut() {
2587 *value = (*value * *value + smoothing_variance[[row, class]]).sqrt();
2588 }
2589 Ok((moments.class_mean, total))
2590 }
2591 }
2592 }
2593
2594 /// `gᵀ C g` per (row, class): the response-scale variance the smoothing
2595 /// parameters' own uncertainty contributes to the mean class probability.
2596 ///
2597 /// The softmax Jacobian factorises as `g_c = u_c ⊗ x` with
2598 /// `u_c[a] = p_c(δ_{ca} − p_a)` over the `M` ACTIVE logits (the reference
2599 /// class `c = K−1` is included and simply has `δ_{ca} = 0` for every active
2600 /// `a`), so the whole `(row, class)` table costs one `M×M` Gram
2601 /// `Q[a,b] = xᵀ C_{ab} x` per row and then `K` quadratic forms of size `M`
2602 /// against it — never a `d`-dimensional contraction per class.
2603 fn smoothing_variance_of_class_probability(
2604 &self,
2605 x_new: ArrayView2<'_, f64>,
2606 correction: ArrayView2<'_, f64>,
2607 ) -> Result<Array2<f64>, EstimationError> {
2608 let p = self.p_per_class;
2609 let m = self.n_active_classes;
2610 let k = self.class_levels.len();
2611 let d = p * m;
2612 if correction.dim() != (d, d) {
2613 crate::bail_invalid_estim!(
2614 "multinomial predict: smoothing correction is {}x{}, expected {d}x{d}",
2615 correction.nrows(),
2616 correction.ncols(),
2617 );
2618 }
2619 if x_new.ncols() != p {
2620 crate::bail_invalid_estim!(
2621 "multinomial predict: design has {} columns, expected {p}",
2622 x_new.ncols(),
2623 );
2624 }
2625 let coefficients = self.coefficients_active()?;
2626 let rows = x_new.nrows();
2627 let mut out = Array2::<f64>::zeros((rows, k));
2628 let mut gram = Array2::<f64>::zeros((m, m));
2629 // `C x_b` for each active block `b`, reused across `a` and across classes.
2630 let mut correction_times_x = Array2::<f64>::zeros((m, d));
2631 for row in 0..rows {
2632 let x = x_new.row(row);
2633 for b in 0..m {
2634 for i in 0..d {
2635 let mut acc = 0.0;
2636 for j in 0..p {
2637 acc += correction[[i, b * p + j]] * x[j];
2638 }
2639 correction_times_x[[b, i]] = acc;
2640 }
2641 }
2642 for a in 0..m {
2643 for b in 0..m {
2644 let mut acc = 0.0;
2645 for j in 0..p {
2646 acc += correction_times_x[[b, a * p + j]] * x[j];
2647 }
2648 gram[[a, b]] = acc;
2649 }
2650 }
2651 let eta: Vec<f64> = (0..m)
2652 .map(|a| (0..p).map(|i| x[i] * coefficients[[i, a]]).sum::<f64>())
2653 .collect();
2654 let probabilities = softmax_with_reference(&eta)?;
2655 for class in 0..k {
2656 let jacobian: Vec<f64> = (0..m)
2657 .map(|a| {
2658 let delta = if class == a { 1.0 } else { 0.0 };
2659 probabilities[class] * (delta - probabilities[a])
2660 })
2661 .collect();
2662 let mut variance = 0.0;
2663 for a in 0..m {
2664 for b in 0..m {
2665 variance += jacobian[a] * gram[[a, b]] * jacobian[b];
2666 }
2667 }
2668 // `C` is PSD by construction (`J V_ρ Jᵀ` with `V_ρ` PSD), so a
2669 // negative quadratic form here is round-off on a direction the
2670 // correction does not reach, not a measurement. Clamping at zero
2671 // is exact for that case and the alternative — a NaN from
2672 // `sqrt` — would destroy an otherwise valid band.
2673 out[[row, class]] = variance.max(0.0);
2674 }
2675 }
2676 Ok(out)
2677 }
2678
2679 /// The posterior mode in the stacked class-major order the joint covariance,
2680 /// the joint penalty and [`crate::multinomial_predictive`] all use:
2681 /// `θ[a·P + i] = β[i, a]`.
2682 pub fn stacked_mode(&self) -> Result<Array1<f64>, EstimationError> {
2683 let coefficients = self.coefficients_active()?;
2684 let p = self.p_per_class;
2685 let m = self.n_active_classes;
2686 let mut theta = Array1::<f64>::zeros(p * m);
2687 for a in 0..m {
2688 for i in 0..p {
2689 theta[a * p + i] = coefficients[[i, a]];
2690 }
2691 }
2692 Ok(theta)
2693 }
2694
2695 /// Posterior-predictive moments at fresh design rows, by the ratio of
2696 /// normalising constants (#2612).
2697 ///
2698 /// The Smolyak accuracy/level control this method used to take is gone
2699 /// rather than ignored. That control existed because the old mechanism was
2700 /// a quadrature whose answer could be bought with more nodes; this one is
2701 /// not, and its accuracy is a property of the expansion (`O(n⁻²)`) that no
2702 /// amount of extra work changes. What replaces it is a per-row exactness
2703 /// check the caller does not have to configure: `Σ_c E[p_c] = 1` is an
2704 /// identity of the estimand, so the deviation of the computed sum from one
2705 /// IS the approximation's error at that row, and a row past
2706 /// [`crate::multinomial_predictive::PREDICTIVE_MASS_DEFECT_TOLERANCE`] is
2707 /// refused rather than published. See [`crate::multinomial_predictive`] for
2708 /// why integrating `softmax` over `N(β̂, H⁻¹)` is not an approximation of
2709 /// this estimand at all.
2710 fn predictive_moments(
2711 &self,
2712 x_new: ArrayView2<'_, f64>,
2713 want_second_moments: bool,
2714 ) -> Result<crate::multinomial_predictive::MultinomialPredictiveMoments, EstimationError> {
2715 let design = self.training_design()?;
2716 let penalty = self.joint_penalty()?;
2717 let weights = Array1::from(self.training_weights.clone());
2718 let mode = self.stacked_mode()?;
2719 let model = self.predictive_model(design.view(), weights.view(), penalty.view());
2720 model.predictive_moments(mode.view(), x_new, want_second_moments)
2721 }
2722
2723 /// Wood (2013) rank-truncated Wald smooth-significance test per
2724 /// `(active class, smooth term)` (#1101), reusing the exact scalar-summary
2725 /// kernel [`gam_terms::inference::smooth_test::wood_smooth_test`]. For active
2726 /// class `a` and term span `[c0, c1)` within the class block, the global
2727 /// coefficient range is `a·P + c0 .. a·P + c1`; the joint covariance and
2728 /// influence are sliced there. The term EDF is the influence-block trace
2729 /// `tr(F_jj)` (when present) and the reference d.f. uses `tr(F_jj)²/tr(F_jj²)`,
2730 /// exactly as the scalar path. The multinomial softmax is a known-dispersion
2731 /// family, so the χ²_{ref_df} branch applies. Returns one row per
2732 /// `(class label, term label, edf, ref_df, statistic, p_value)`; empty when
2733 /// no covariance/smooth terms are available.
2734 pub fn smooth_significance(&self) -> Vec<MultinomialSmoothSignificance> {
2735 let mut out = Vec::new();
2736 let p = self.p_per_class;
2737 let m = self.n_active_classes;
2738 let Ok(cov) = self.coefficient_covariance() else {
2739 return out;
2740 };
2741 if self.smooth_term_spans.is_empty() {
2742 return out;
2743 }
2744 let Ok(beta) = self.coefficients_active() else {
2745 return out;
2746 };
2747 // Block-ordered θ = [β_0; …; β_{M-1}], θ[a·P + i] = β[i, a].
2748 let d = p * m;
2749 let mut theta = Array1::<f64>::zeros(d);
2750 for a in 0..m {
2751 for i in 0..p {
2752 theta[a * p + i] = beta[[i, a]];
2753 }
2754 }
2755 let influence = self.coefficient_influence();
2756 for a in 0..m {
2757 let class_label = self
2758 .class_levels
2759 .get(a)
2760 .cloned()
2761 .unwrap_or_else(|| format!("class{a}"));
2762 let base = a * p;
2763 for span in &self.smooth_term_spans {
2764 if span.col_end > p {
2765 continue;
2766 }
2767 let start = base + span.col_start;
2768 let end = base + span.col_end;
2769 // Term EDF = tr(F_jj); without an influence matrix fall back to
2770 // the block coefficient count (full-rank Wald on the span).
2771 let block_len = (span.col_end - span.col_start) as f64;
2772 let edf = influence
2773 .as_ref()
2774 .map(|f| (start..end).map(|i| f[[i, i]]).sum::<f64>())
2775 .filter(|v| v.is_finite() && *v > 0.0)
2776 .unwrap_or(block_len);
2777 let result = gam_terms::inference::smooth_test::wood_smooth_test(
2778 gam_terms::inference::smooth_test::SmoothTestInput {
2779 beta: theta.view(),
2780 covariance: &cov,
2781 influence_matrix: influence.as_ref(),
2782 whitening_gram: None,
2783 coeff_range: start..end,
2784 edf,
2785 nullspace_dim: span.nullspace_dim,
2786 residual_df: None,
2787 scale: gam_terms::inference::smooth_test::SmoothTestScale::Known,
2788 },
2789 );
2790 if let Some(res) = result {
2791 out.push(MultinomialSmoothSignificance {
2792 class_label: class_label.clone(),
2793 term_label: span.label.clone(),
2794 edf,
2795 ref_df: res.ref_df,
2796 statistic: res.statistic,
2797 p_value: res.p_value,
2798 });
2799 }
2800 }
2801 }
2802 out
2803 }
2804
2805 /// Draw `n_draws` posterior-predictive replicate class assignments at fresh
2806 /// rows (#1101). Each draw independently samples every row's class from
2807 /// `Categorical(p_row)` with `p = E[softmax(eta) | data]`, so coefficient
2808 /// uncertainty is integrated before adding categorical observation noise.
2809 /// The returned `(n_draws, N)` matrix holds class
2810 /// INDICES `0..K`, aligned to [`Self::class_levels`]. The draw stream is a
2811 /// `StdRng` seeded by `seed`, so `(x_new, n_draws, seed)` reproduce
2812 /// bit-identically — the engine for posterior-predictive checks and
2813 /// simulation-based calibration. `x_new` must have `self.p_per_class`
2814 /// columns (built from the same `resolved_termspec` as fit time).
2815 pub fn sample_replicate_classes(
2816 &self,
2817 x_new: ArrayView2<'_, f64>,
2818 n_draws: usize,
2819 seed: u64,
2820 ) -> Result<Array2<u32>, EstimationError> {
2821 use rand::{RngExt, SeedableRng};
2822 let probs = self.predict_probabilities(x_new)?;
2823 let n = probs.nrows();
2824 let k = probs.ncols();
2825 let mut out = Array2::<u32>::zeros((n_draws, n));
2826 let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
2827 for d in 0..n_draws {
2828 for row in 0..n {
2829 let u: f64 = rng.random::<f64>();
2830 // Inverse-CDF categorical draw over the K simplex weights.
2831 let mut acc = 0.0_f64;
2832 let mut chosen = k - 1; // numerical fallback = reference class
2833 for c in 0..k {
2834 acc += probs[[row, c]];
2835 if u < acc {
2836 chosen = c;
2837 break;
2838 }
2839 }
2840 out[[d, row]] = chosen as u32;
2841 }
2842 }
2843 Ok(out)
2844 }
2845}
2846
2847/// On-disk `model_class` discriminator for a persisted multinomial model. Kept
2848/// as a single constant so every producer / consumer of the envelope agrees on
2849/// the tag without a scattered string literal.
2850pub const MULTINOMIAL_MODEL_CLASS: &str = "multinomial";
2851/// Exact multinomial persistence schema. Version 2 requires the canonical
2852/// per-component lambda labels and training-table provenance; successful
2853/// deserialization therefore yields a complete current model without repair.
2854pub const MULTINOMIAL_MODEL_FORMAT_VERSION: u32 = 2;
2855
2856/// Round-trip persistence envelope for a fitted multinomial model. The
2857/// `model_class` discriminator lets a loader tell a multinomial payload apart
2858/// from the scalar `FittedModel` JSON before deserialising the whole struct.
2859///
2860/// This is the single definition of the multinomial on-disk format, shared by
2861/// the Python FFI (`fit_multinomial_formula` / `predict_multinomial_formula`)
2862/// and the `gam` CLI (`gam fit --family multinomial` / `gam predict`), so a
2863/// model persisted by one surface loads in the other.
2864#[derive(Debug, Clone, Serialize, Deserialize)]
2865#[serde(deny_unknown_fields)]
2866pub struct MultinomialModelEnvelope {
2867 pub model_class: String,
2868 pub format_version: u32,
2869 pub saved: MultinomialSavedModel,
2870}
2871
2872impl MultinomialModelEnvelope {
2873 /// Wrap a fitted model with the canonical `model_class` tag.
2874 pub fn new(saved: MultinomialSavedModel) -> Result<Self, EstimationError> {
2875 saved.validate()?;
2876 Ok(Self {
2877 model_class: MULTINOMIAL_MODEL_CLASS.to_string(),
2878 format_version: MULTINOMIAL_MODEL_FORMAT_VERSION,
2879 saved,
2880 })
2881 }
2882
2883 /// Serialize to the canonical JSON byte payload.
2884 pub fn to_json_bytes(&self) -> Result<Vec<u8>, EstimationError> {
2885 self.saved.validate()?;
2886 serde_json::to_vec(self).map_err(|err| {
2887 EstimationError::InvalidInput(format!("failed to serialize multinomial model: {err}"))
2888 })
2889 }
2890
2891 /// Parse an envelope from JSON bytes, validating the `model_class`
2892 /// discriminator so a non-multinomial payload is rejected with a clear
2893 /// error rather than silently mis-predicted.
2894 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, EstimationError> {
2895 // Gate on the envelope header (`model_class` + `format_version`) *before*
2896 // deserializing the versioned `saved` body. The header parse ignores the
2897 // body, so a payload that predates a field which has since become
2898 // required (e.g. `saved.formula`) is rejected on the version gate rather
2899 // than on whatever inner field is now missing — the version check is the
2900 // contract that tells a caller their payload is stale, and it must fire
2901 // first regardless of how the body schema has since evolved.
2902 #[derive(Deserialize)]
2903 struct EnvelopeHeader {
2904 #[serde(default)]
2905 model_class: Option<String>,
2906 #[serde(default)]
2907 format_version: Option<u32>,
2908 }
2909 let header: EnvelopeHeader = serde_json::from_slice(bytes).map_err(|err| {
2910 EstimationError::InvalidInput(format!("failed to deserialize multinomial model: {err}"))
2911 })?;
2912 match header.model_class.as_deref() {
2913 Some(MULTINOMIAL_MODEL_CLASS) => {}
2914 other => {
2915 return Err(EstimationError::InvalidInput(format!(
2916 "multinomial model: model_class = {other:?}, expected {MULTINOMIAL_MODEL_CLASS:?}",
2917 )));
2918 }
2919 }
2920 match header.format_version {
2921 Some(MULTINOMIAL_MODEL_FORMAT_VERSION) => {}
2922 Some(version) => {
2923 return Err(EstimationError::InvalidInput(format!(
2924 "multinomial model: format_version = {version}, expected {MULTINOMIAL_MODEL_FORMAT_VERSION}",
2925 )));
2926 }
2927 None => {
2928 return Err(EstimationError::InvalidInput(format!(
2929 "multinomial model: format_version is absent (unversioned payload), expected {MULTINOMIAL_MODEL_FORMAT_VERSION}",
2930 )));
2931 }
2932 }
2933 let envelope: Self = serde_json::from_slice(bytes).map_err(|err| {
2934 EstimationError::InvalidInput(format!("failed to deserialize multinomial model: {err}"))
2935 })?;
2936 envelope.saved.validate()?;
2937 Ok(envelope)
2938 }
2939}
2940
2941#[cfg(test)]
2942mod multinomial_persistence_contract_tests {
2943 use super::*;
2944
2945 #[test]
2946 fn unversioned_payload_is_rejected() {
2947 let payload = br#"{"model_class":"multinomial","saved":{}}"#;
2948 let error = MultinomialModelEnvelope::from_json_bytes(payload)
2949 .expect_err("unversioned multinomial persistence must not be guessed");
2950 assert!(
2951 error.to_string().contains("format_version"),
2952 "unexpected persistence error: {error}"
2953 );
2954 }
2955}
2956
2957/// One row of the multinomial smooth-significance table (#1101): the Wood
2958/// rank-truncated Wald test for one `(active class, smooth term)` pair.
2959#[derive(Debug, Clone)]
2960pub struct MultinomialSmoothSignificance {
2961 pub class_label: String,
2962 pub term_label: String,
2963 pub edf: f64,
2964 pub ref_df: f64,
2965 pub statistic: f64,
2966 pub p_value: f64,
2967}
2968
2969/// One-hot-encode the categorical response column and return both the
2970/// encoding and the captured level names. The level order matches the order
2971/// recorded in the dataset schema, which is the canonical (lexicographically
2972/// sorted) factor order produced by inferred-schema construction (#1319) — so
2973/// it is a deterministic function of the label *set*, independent of training
2974/// row order (no silent class permutation under a row shuffle), and matches the
2975/// R `factor()` / pandas `Categorical` convention.
2976fn one_hot_categorical_response(
2977 data: &EncodedDataset,
2978 y_col: usize,
2979 response_name: &str,
2980) -> Result<(Array2<f64>, Vec<String>), EstimationError> {
2981 let levels: Vec<String> = data
2982 .schema
2983 .columns
2984 .get(y_col)
2985 .map(|sc| sc.levels.clone())
2986 .unwrap_or_default();
2987 if levels.len() < 2 {
2988 crate::bail_invalid_estim!(
2989 "multinomial response '{response_name}' must have at least 2 categorical levels (got {})",
2990 levels.len()
2991 );
2992 }
2993 let n = data.values.nrows();
2994 let k = levels.len();
2995 let mut y_one_hot = Array2::<f64>::zeros((n, k));
2996 for row in 0..n {
2997 let encoded = data.values[[row, y_col]];
2998 if !encoded.is_finite() {
2999 crate::bail_invalid_estim!(
3000 "multinomial response '{response_name}' row {row} is non-finite ({encoded})"
3001 );
3002 }
3003 let class_idx = encoded.round() as i64;
3004 if class_idx < 0 || (class_idx as usize) >= k {
3005 crate::bail_invalid_estim!(
3006 "multinomial response '{response_name}' row {row} encoded as {encoded} \
3007 is outside the level range 0..{k}"
3008 );
3009 }
3010 y_one_hot[[row, class_idx as usize]] = 1.0;
3011 }
3012 Ok((y_one_hot, levels))
3013}
3014
3015/// Build `(TermCollectionSpec, TermCollectionDesign)` from a formula against
3016/// a categorical-response dataset. Mirrors the early scaffolding inside
3017/// `materialize_standard` (response role resolution, geometry-aware spec
3018/// build) without touching the scalar-family resolution path — multinomial
3019/// owns its own response kind check.
3020fn build_formula_design_for_multinomial(
3021 formula: &str,
3022 data: &EncodedDataset,
3023 config: &FitConfig,
3024) -> Result<
3025 (
3026 TermCollectionSpec,
3027 TermCollectionDesign,
3028 usize,
3029 String,
3030 ResponseColumnKind,
3031 ),
3032 EstimationError,
3033> {
3034 let parsed = parse_formula(formula).map_err(|err| {
3035 EstimationError::InvalidInput(format!(
3036 "multinomial fit: failed to parse formula {formula:?}: {err}"
3037 ))
3038 })?;
3039 let col_map = data.column_map();
3040 let y_col = resolve_role_col(&col_map, &parsed.response, "response")
3041 .map_err(|err| EstimationError::InvalidInput(format!("multinomial fit: {err}")))?;
3042 let y_kind = crate::fit_orchestration::response_column_kind(data, y_col);
3043 let policy = resolved_resource_policy(config, ProblemHints::default());
3044 let mut inference_notes: Vec<String> = Vec::new();
3045 let spec = build_termspec_with_geometry_and_overrides(
3046 &parsed.terms,
3047 data,
3048 &col_map,
3049 &mut inference_notes,
3050 config.scale_dimensions,
3051 &policy,
3052 config.smooth_overrides.as_ref(),
3053 None,
3054 )
3055 .map_err(|err| {
3056 EstimationError::InvalidInput(format!("multinomial fit: build termspec: {err}"))
3057 })?;
3058 let design = build_term_collection_design(data.values.view(), &spec).map_err(|err| {
3059 EstimationError::InvalidInput(format!("multinomial fit: build design: {err}"))
3060 })?;
3061 if design.affine_offset.iter().any(|value| *value != 0.0) {
3062 crate::bail_invalid_estim!(
3063 "multinomial fit does not support non-zero smooth anchors: the reference-coded \
3064 softmax requires an explicit affine offset for every non-reference class"
3065 );
3066 }
3067 Ok((spec, design, y_col, parsed.response, y_kind))
3068}
3069
3070fn scale_multinomial_formula_penalty(penalty: PenaltyMatrix, scale: f64) -> PenaltyMatrix {
3071 match penalty {
3072 PenaltyMatrix::Dense(matrix) => PenaltyMatrix::Dense(matrix.mapv(|v| v * scale)),
3073 PenaltyMatrix::KroneckerFactored { left, right } => PenaltyMatrix::KroneckerFactored {
3074 left: left.mapv(|v| v * scale),
3075 right,
3076 },
3077 PenaltyMatrix::Blockwise {
3078 local,
3079 col_range,
3080 total_dim,
3081 } => PenaltyMatrix::Blockwise {
3082 local: local.mapv(|v| v * scale),
3083 col_range,
3084 total_dim,
3085 },
3086 PenaltyMatrix::Labeled { label, inner } => PenaltyMatrix::Labeled {
3087 label,
3088 inner: Box::new(scale_multinomial_formula_penalty(*inner, scale)),
3089 },
3090 PenaltyMatrix::Fixed { log_lambda, inner } => PenaltyMatrix::Fixed {
3091 log_lambda,
3092 inner: Box::new(scale_multinomial_formula_penalty(*inner, scale)),
3093 },
3094 }
3095}
3096
3097/// Canonical typed inputs for the formula-driven multinomial fit
3098/// ([`fit_penalized_multinomial_formula`]).
3099///
3100/// Every frontend (Rust, CLI, Python FFI) builds this one request, so the
3101/// warm-start / outer-search defaults live here rather than being duplicated
3102/// per caller. `config` is the same canonical [`FitConfig`] the scalar formula
3103/// families consume: `weight_column` is resolved against the dataset and
3104/// honored as per-row case weights, and fields the softmax family cannot
3105/// consume (offsets, noise/log-slope formulas, manual Firth, frailty, ...) are
3106/// rejected with a typed error instead of being silently dropped.
3107#[derive(Clone, Copy)]
3108pub struct MultinomialFitRequest<'a> {
3109 pub data: &'a EncodedDataset,
3110 pub formula: &'a str,
3111 pub config: &'a FitConfig,
3112 /// Warm-start seed for every per-(class, term) smoothing parameter; λ is
3113 /// REML/LAML-selected, so this only seeds the outer search.
3114 pub init_lambda: f64,
3115 /// OUTER REML/LAML smoothing-parameter iteration budget.
3116 pub max_iter: usize,
3117 /// Requested accuracy; drives the inner joint-Newton KKT target (see the
3118 /// control-split note inside the fit).
3119 pub tol: f64,
3120}
3121
3122impl<'a> MultinomialFitRequest<'a> {
3123 /// The canonical production controls shared by the CLI and the Python FFI.
3124 pub fn new(data: &'a EncodedDataset, formula: &'a str, config: &'a FitConfig) -> Self {
3125 Self {
3126 data,
3127 formula,
3128 config,
3129 init_lambda: 1.0,
3130 max_iter: 50,
3131 tol: 1.0e-7,
3132 }
3133 }
3134}
3135
3136/// Reject canonical-config fields the softmax multinomial family cannot
3137/// consume. Silently dropping a requested offset / noise model / manual Firth
3138/// toggle would quietly change the estimand the caller asked for (SPEC 3), so
3139/// every unsupported field is a typed error shared by all frontends.
3140fn reject_unsupported_multinomial_config(config: &FitConfig) -> Result<(), EstimationError> {
3141 if config.offset_column.is_some() || config.noise_offset_column.is_some() {
3142 crate::bail_invalid_estim!(
3143 "multinomial fit does not support offset columns: a single offset column has no \
3144 canonical per-logit placement in the reference-coded softmax (offsets are per-class \
3145 linear-predictor quantities); remove the offset or fit per-class models"
3146 );
3147 }
3148 if config.noise_formula.is_some() {
3149 crate::bail_invalid_estim!(
3150 "noise_formula is not supported for the multinomial family: the softmax likelihood \
3151 has no dispersion predictor"
3152 );
3153 }
3154 if config.logslope_formula.is_some() || config.z_column.is_some() {
3155 crate::bail_invalid_estim!(
3156 "logslope_formula/z_column is not supported for the multinomial family"
3157 );
3158 }
3159 if config.transformation_normal {
3160 crate::bail_invalid_estim!("transformation_normal conflicts with the multinomial family");
3161 }
3162 if config.expectile_tau.is_some() {
3163 crate::bail_invalid_estim!("expectile_tau requires the expectile family");
3164 }
3165 if config.firth {
3166 crate::bail_invalid_estim!(
3167 "manual firth is not accepted for the multinomial family: the Firth/Jeffreys \
3168 separation stabilizer is armed automatically on separation evidence"
3169 );
3170 }
3171 if !matches!(
3172 config.frailty,
3173 crate::survival::lognormal_kernel::FrailtySpec::None
3174 ) {
3175 crate::bail_invalid_estim!("frailty is not supported for the multinomial family");
3176 }
3177 Ok(())
3178}
3179
3180/// Resolve the canonical `weight_column` into per-row case weights (`None` ⇒
3181/// uniform 1.0). Finiteness / non-negativity are enforced by
3182/// [`MultinomialFamily::new`], which owns the weight contract.
3183fn resolve_multinomial_row_weights(
3184 data: &EncodedDataset,
3185 config: &FitConfig,
3186) -> Result<Array1<f64>, EstimationError> {
3187 let Some(name) = config.weight_column.as_deref() else {
3188 return Ok(Array1::ones(data.values.nrows()));
3189 };
3190 let column = data.column_map().get(name).copied().ok_or_else(|| {
3191 EstimationError::InvalidInput(format!(
3192 "multinomial fit: weight column '{name}' not found in the dataset"
3193 ))
3194 })?;
3195 Ok(data.values.column(column).to_owned())
3196}
3197
3198/// Top-level formula-driven multinomial fit.
3199///
3200/// Routes through [`fit_custom_family_with_rho_prior`] so the per-active-class
3201/// smoothing parameters `λ_a` (one per class block, shared-penalty
3202/// architecture) are selected by the outer REML/LAML loop rather than pinned
3203/// by the caller. `init_lambda` survives as a warm-start hint that seeds
3204/// every block's `initial_log_lambdas`. `max_iter` / `tol` drive the OUTER
3205/// REML/LAML smoothing-parameter search (`outer_max_iter` / `outer_tol`); the
3206/// inner joint-Newton solve runs on the framework's principled production cycle
3207/// budget at the default KKT tolerance so an ill-conditioned, LM-damped
3208/// near-simplex-boundary solve can certify a stationary point instead of being
3209/// declared non-converged after only `max_iter` cycles (#715).
3210///
3211/// The Jeffreys/Firth proper prior is engaged CONDITIONALLY: attempt 1 runs
3212/// the unbiased penalized-REML criterion; only on separation evidence (a failed
3213/// solve, a non-finite logit, or an exact full-span PENALIZED-curvature conditioning
3214/// certificate; see [`multinomial_formula_penalized_separation_evidence`]) is the
3215/// fit re-solved once with the full-span Firth prior armed, which bounds the
3216/// penalty-null directions no smoothing parameter can (`S v = 0` ⇒
3217/// `(H + S_λ) v = H v → 0` when the softmax likelihood has no identified finite
3218/// mode).
3219///
3220/// The categorical response column is recognised via the dataset schema
3221/// (`ColumnKindTag::Categorical`); reference class = last level. Returns a
3222/// [`MultinomialSavedModel`] that can be serialised to bytes for the Python
3223/// wrapper or used in-process for `predict_probabilities`.
3224/// Everything [`fit_penalized_multinomial_formula`] constructs BEFORE the REML
3225/// solve: the family (unbiased criterion, Jeffreys disarmed), the per-class
3226/// block specs with seeded `initial_log_lambdas`, the calibrated solver
3227/// options, and the design artifacts the post-solve repack consumes. Exposed
3228/// at crate level so diagnostics can drive the EXACT production objective at
3229/// fixed smoothing parameters (finite-difference gates on the outer ρ-gradient
3230/// of the coalesced joint penalty family, #2349) instead of replicating the
3231/// construction and diverging from it.
3232pub(crate) struct PenalizedMultinomialFormulaParts {
3233 pub(crate) family: MultinomialFamily,
3234 pub(crate) blocks: Vec<ParameterBlockSpec>,
3235 pub(crate) options: BlockwiseFitOptions,
3236 pub(crate) spec: TermCollectionSpec,
3237 pub(crate) design: TermCollectionDesign,
3238 pub(crate) class_levels: Vec<String>,
3239 pub(crate) parametric_standardization: Vec<(usize, f64, f64)>,
3240 pub(crate) penalties_arc: Arc<Vec<PenaltyMatrix>>,
3241 /// The design BEFORE the parametric standardization — the basis the saved
3242 /// coefficients and the predict-time rebuild share, and therefore the one
3243 /// the posterior-predictive ratio evaluates the likelihood in (#2612).
3244 pub(crate) raw_training_design: Array2<f64>,
3245 /// Training class index per row, values in `0..K` (#2612).
3246 pub(crate) training_class_index: Vec<u32>,
3247 /// Resolved per-row case weights (#2612).
3248 pub(crate) training_weights: Array1<f64>,
3249}
3250
3251pub(crate) fn penalized_multinomial_formula_parts(
3252 request: &MultinomialFitRequest<'_>,
3253) -> Result<PenalizedMultinomialFormulaParts, EstimationError> {
3254 let MultinomialFitRequest {
3255 data,
3256 formula,
3257 config,
3258 init_lambda,
3259 max_iter,
3260 tol,
3261 } = *request;
3262 if !(init_lambda.is_finite() && init_lambda > 0.0) {
3263 crate::bail_invalid_estim!(
3264 "multinomial fit: init_lambda must be finite and > 0 (got {init_lambda})"
3265 );
3266 }
3267 reject_unsupported_multinomial_config(config)?;
3268 let (raw_spec, design, y_col, response_name, y_kind) =
3269 build_formula_design_for_multinomial(formula, data, config)?;
3270 // Freeze the data-derived basis state (B-spline knot vectors, by-factor
3271 // level sets, spatial centers, joint-null rotations, residualization
3272 // charts) from the fit design back onto the spec. The raw geometry spec
3273 // records only *which* columns and *what kind* of basis each smooth uses;
3274 // the actual column count and basis evaluation depend on quantities the
3275 // builder derives from the training data (knot placement, the distinct
3276 // by-factor levels, etc.). Saving the raw spec made predict re-derive those
3277 // from the (smaller, differently-distributed) predict frame, so the rebuilt
3278 // design had a different column count than the fitted one — the panic
3279 // "predict design has 42 cols, saved model expects 191" for an `s(x,
3280 // by=group)` smooth-by-factor model. Every other family's persistence path
3281 // freezes the spec the same way (see `freeze_term_collection_from_design`
3282 // call sites in `main_parts`); multinomial was the lone exception.
3283 let spec = freeze_term_collection_from_design(&raw_spec, &design)?;
3284 let class_levels = match y_kind {
3285 ResponseColumnKind::Categorical { levels } => levels,
3286 ResponseColumnKind::Binary => vec!["0".to_string(), "1".to_string()],
3287 ResponseColumnKind::Numeric => {
3288 crate::bail_invalid_estim!(
3289 "multinomial fit: response '{response_name}' is numeric, not categorical; \
3290 use family='gaussian'/'binomial'/... or convert the column to a categorical type"
3291 );
3292 }
3293 };
3294 // A Binary response is promoted to a 2-level categorical for the
3295 // multinomial driver: the caller explicitly asked for multinomial, so we
3296 // route through the K-1 = 1 active-class softmax (equivalent math to
3297 // logistic). Anything that is neither Binary nor Categorical has no class
3298 // set to softmax over.
3299 let y_column_kind = data.column_kinds.get(y_col);
3300 if y_column_kind != Some(&ColumnKindTag::Binary)
3301 && y_column_kind != Some(&ColumnKindTag::Categorical)
3302 {
3303 crate::bail_invalid_estim!(
3304 "multinomial fit: response '{response_name}' must be a categorical column \
3305 (got column kind {:?})",
3306 y_column_kind
3307 );
3308 }
3309 let (y_one_hot, _) = one_hot_categorical_response(data, y_col, &response_name)?;
3310 // Build the global X dense (the design is a DesignMatrix abstraction).
3311 let mut x_dense = design
3312 .design
3313 .try_to_dense_by_chunks("multinomial fit design")
3314 .map_err(EstimationError::InvalidInput)?;
3315
3316 // ── #715 real-data conditioning: standardize unpenalized parametric
3317 // columns. Raw-unit linear covariates (penguins `body_mass_g` ~ 4e3 grams)
3318 // inflate the joint Newton information by the squared column scale (a κ(H)
3319 // multiplier of ~s² ≈ 1e7 against the intercept), which is what turns the
3320 // near-separable LM-damped inner solve into a geometric grind that
3321 // exhausts its cycle budgets — the adapter-level face of "all REML startup
3322 // seeds rejected". Because these columns are UNPENALIZED (parametric terms
3323 // carry no default ridge, #749), the affine reparameterization
3324 // `x_j ↦ (x_j − m_j)/s_j` is EXACT for the whole criterion: the optimized
3325 // REML/LAML objective, the fitted η, the selected λ, and the separation
3326 // diagnostics are all invariant — only the conditioning of `H` changes.
3327 // Fitted coefficients are mapped back to raw units at repack below, so the
3328 // saved model and the (raw-design) predict path are untouched. Penalized
3329 // columns are left alone (a penalty makes the rescaling non-equivalent),
3330 // and nothing is touched when explicit coefficient bounds/constraints
3331 // exist (those are stated in raw units).
3332 // #2612: the RAW design, before the parametric standardization below, is the
3333 // basis the saved coefficients and the predict-time rebuild both live in, so
3334 // it is the one the posterior-predictive ratio must evaluate the likelihood
3335 // in. Snapshot it here rather than un-standardizing later: an inverse map
3336 // applied to a matrix is a second source of truth for the same rows.
3337 let raw_training_design = x_dense.clone();
3338 let parametric_standardization: Vec<(usize, f64, f64)> =
3339 if design.coefficient_lower_bounds.is_some() || design.linear_constraints.is_some() {
3340 Vec::new()
3341 } else {
3342 let p_total = x_dense.ncols();
3343 let mut penalized = vec![false; p_total];
3344 for bp in &design.penalties {
3345 for col in bp.col_range.clone() {
3346 if col < p_total {
3347 penalized[col] = true;
3348 }
3349 }
3350 }
3351 let has_intercept = !design.intercept_range.is_empty();
3352 let n_rows = x_dense.nrows().max(1) as f64;
3353 let mut standardized = Vec::new();
3354 for (_, range) in &design.linear_ranges {
3355 for col in range.clone() {
3356 if col >= p_total || penalized[col] {
3357 continue;
3358 }
3359 let column = x_dense.column(col);
3360 let mean = column.sum() / n_rows;
3361 let var = column.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n_rows;
3362 let scale = var.sqrt();
3363 // Skip near-constant or degenerate columns: no conditioning to
3364 // be gained and the back-map would divide by ~0.
3365 if !(scale.is_finite() && scale > 1e-8 * (mean.abs() + 1.0)) {
3366 continue;
3367 }
3368 // Centering shifts mass onto the intercept; without one the
3369 // shift is not representable, so scale only.
3370 let center = if has_intercept { mean } else { 0.0 };
3371 for v in x_dense.column_mut(col).iter_mut() {
3372 *v = (*v - center) / scale;
3373 }
3374 standardized.push((col, center, scale));
3375 }
3376 }
3377 standardized
3378 };
3379 // Preserve the per-smooth-term penalty block structure (#561): each smooth
3380 // term `t` contributes its own `P × P` penalty component (`Blockwise` with
3381 // `total_dim = P`, the term's local `S_t` embedded at its `col_range`), and
3382 // every active class block receives the FULL list. The outer REML/LAML loop
3383 // then selects an independent smoothing parameter λ_{a,t} per (class, term),
3384 // matching mgcv/VGAM. Pre-summing the terms into one fused `S` (the prior
3385 // behaviour) forced a single λ per class that scales `Σ_t S_t`, so one
3386 // shared λ had to over-smooth a rough term while under-smoothing a smooth
3387 // one — biasing any multi-term class-probability surface.
3388 let k = y_one_hot.ncols();
3389 let n_obs = y_one_hot.nrows();
3390 let penalty_scale = multinomial_formula_penalty_scale(k);
3391 let per_term_penalties: Vec<PenaltyMatrix> = design
3392 .penalties_as_penalty_matrix()
3393 .into_iter()
3394 .map(|penalty| scale_multinomial_formula_penalty(penalty, penalty_scale))
3395 .collect();
3396
3397 // ── Custom-family driven REML/LAML path ───────────────────────────────
3398 // Each active class becomes one ParameterBlockSpec, all sharing X and the
3399 // per-term penalty list. `initial_log_lambdas` is seeded from the caller's
3400 // `init_lambda` (one entry per term).
3401 let design_arc = Arc::new(x_dense);
3402 let penalties_arc = Arc::new(per_term_penalties);
3403 let weights = resolve_multinomial_row_weights(data, config)?;
3404 if weights.len() != n_obs {
3405 crate::bail_invalid_estim!(
3406 "multinomial fit: weight column length {} != N = {n_obs}",
3407 weights.len()
3408 );
3409 }
3410 // #2612: the class index the posterior-predictive ratio appends its extra
3411 // row's label from. Read off the one-hot the fit itself validated, so the
3412 // two cannot disagree about which class a row is.
3413 let training_class_index: Vec<u32> = (0..n_obs)
3414 .map(|row| {
3415 let mut best = 0usize;
3416 for class in 1..k {
3417 if y_one_hot[[row, class]] > y_one_hot[[row, best]] {
3418 best = class;
3419 }
3420 }
3421 best as u32
3422 })
3423 .collect();
3424 let training_weights = weights.clone();
3425 // First attempt runs the UNBIASED penalized-REML criterion (no Firth
3426 // shrinkage toward the uniform simplex); the Jeffreys/Firth proper prior is
3427 // armed conditionally below, only on separation evidence (#715/#753 — see
3428 // `multinomial_formula_separation_evidence`).
3429 let log_init = init_lambda.ln();
3430 let family = MultinomialFamily::new(
3431 y_one_hot.clone(),
3432 weights,
3433 k,
3434 design_arc.clone(),
3435 penalties_arc.clone(),
3436 )
3437 .map_err(EstimationError::InvalidInput)?
3438 .with_joint_jeffreys_term(false)
3439 // gam#1587: the per-block smooth penalties are emptied (the centered `M⊗S_t`
3440 // joint penalty is the sole smoothing carrier), so the `init_lambda` warm
3441 // start must seed the JOINT penalty's `initial_log_lambda` — the per-block
3442 // `initial_log_lambdas` loop below is now a no-op (empty per-block list).
3443 .with_initial_log_lambda(log_init);
3444 let mut blocks = family.build_block_specs();
3445 for spec_block in blocks.iter_mut() {
3446 for v in spec_block.initial_log_lambdas.iter_mut() {
3447 *v = log_init;
3448 }
3449 }
3450
3451 // ── Outer-derivative policy: dimension-gated exact curvature ────────────
3452 // Medium-D formula fits need exact curvature to keep lambda selection away
3453 // from over-smoothed caps, while smooth-by-factor models still avoid the
3454 // O(D²) dense Hessian path.
3455 //
3456 // `D` is the number of coordinates the OUTER search actually has, which is
3457 // the joint penalty spec count and NOT `(K−1) · n_penalties`. Under the
3458 // equivariant carrier (#1587) each penalty component emits one spec PER
3459 // CLASS, so a `K = 3` model carries `3·n_penalties` coordinates; the
3460 // per-block product this used to compute has not been the outer dimension
3461 // for any `K > 2` model since that landing. On the four-smooth penguin
3462 // fixture the two differ by 50% (`8·3 = 24` against `2·8 = 16`), which put
3463 // it on the opposite side of the threshold from the one
3464 // `MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM`'s own doc block says it was
3465 // chosen to keep it on.
3466 let total_rho_dim = family.joint_smoothing_dimension();
3467 let use_outer_hessian = multinomial_formula_use_outer_hessian(total_rho_dim);
3468
3469 // ── Inner-vs-outer control split (#715 non-convergence root cause) ────────
3470 // The legacy `max_iter` / `tol` parameters are the *outer* REML/LAML
3471 // smoothing-parameter optimization controls — "how hard to search λ". The
3472 // earlier wiring routed them straight into `inner_max_cycles` / `inner_tol`,
3473 // capping the joint-Newton inner solve at `max_iter` (=50 in the quality
3474 // suite) cycles with a `tol`-tight (=1e-8) KKT target. That is the #715
3475 // hang: near the simplex boundary the softmax Fisher weight
3476 // `W = diag(p) − p pᵀ` collapses, so `H = JᵀWJ + S_λ` is full-rank but
3477 // ILL-CONDITIONED. The self-vanishing Levenberg–Marquardt damping
3478 // (`levenberg_on_ill_conditioning()`) that keeps the inner solve from
3479 // oscillating on those near-singular modes makes it converge only
3480 // GEOMETRICALLY (linearly), not quadratically. Reaching a 1e-8 relative KKT
3481 // residual under geometric descent needs FAR more than 50 cycles, so the
3482 // inner returned `converged = false` on every outer ρ-evaluation; with the
3483 // exact-Hessian outer optimizer on `FallbackPolicy::Disabled` that rejects
3484 // every ρ-step — each rejected eval still paying a near-full 50-cycle inner
3485 // solve plus the O(D²) pairwise outer-Hessian directional work — so the
3486 // outer never certifies and the fit runs unbounded (the observed >8-minute
3487 // non-termination). The certificate cannot be reached, not merely slow.
3488 //
3489 // Fix: give the INNER joint-Newton the framework's principled production
3490 // budget (`DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES` cycles at the default
3491 // `inner_tol`), which exists precisely so an ill-conditioned LM-damped solve
3492 // can certify a stationary KKT point instead of being declared non-converged
3493 // prematurely — and the KKT/objective certificates still exit in a handful
3494 // of cycles on the well-conditioned interior fits, so this is free there.
3495 // The caller's `max_iter` / `tol` become the OUTER controls they were always
3496 // meant to be (smoothing-parameter search depth / accuracy). The inner KKT
3497 // target is kept no tighter than the outer accuracy can consume — and no
3498 // tighter than the softmax objective's f64 noise floor on near-separable
3499 // fits (see `MULTINOMIAL_FORMULA_INNER_TOL`).
3500 let outer_max_iter = max_iter.max(1);
3501 // The OUTER REML/LAML smoothing-parameter search must converge to a
3502 // well-calibrated ρ-gradient tolerance, NOT to the caller's (typically very
3503 // tight) INNER KKT tolerance. The #715 control-split repurposed the caller's
3504 // `tol` as the outer control, but feeding an inner-scale `tol = 1e-8`
3505 // straight into `outer_tol` makes REML grind dozens of extra exact-gradient
3506 // outer iterations (each an O(D·p³) Laplace-derivative assembly over the full
3507 // P·M joint design) to squeeze ρ digits that no longer move the fitted
3508 // surface — the smooth-by-factor 269s wall-clock overrun (#1082).
3509 //
3510 // The right target is the framework's CALIBRATED REML convergence tolerance,
3511 // `MULTINOMIAL_OUTER_REML_TOL = 1e-7` — the same value the primary GLM REML
3512 // outer uses (`solver::fit_orchestration::materialize` `tol: 1e-7`, mirrored by the
3513 // `LOG_LAMBDA_TOL`/`KKT_TOL_*` constants across the REML stack). At 1e-7 the
3514 // λ-search reaches the genuine REML optimum (so the recovered probability
3515 // surface matches the mature reference), but it does NOT chase the last
3516 // surface-irrelevant ρ digits down to 1e-8. The earlier 1e-5 floor (the
3517 // generic `BlockwiseFitOptions` default) was too LOOSE: the optimizer halted
3518 // in a low-curvature region with λ still well above its optimum, UNDER-fitting
3519 // the smooth-by-factor surface (truth-RMSE 0.164 vs VGAM's 0.061). So the
3520 // outer tolerance is floored at the calibrated REML tol — never tighter than
3521 // it (perf), never looser (accuracy) — while the caller's `tol` continues to
3522 // drive the INNER joint-Newton KKT target (`inner_tol` below), where its
3523 // precision actually matters.
3524 let outer_tol = if tol.is_finite() && tol > 0.0 {
3525 tol.max(MULTINOMIAL_OUTER_REML_TOL)
3526 } else {
3527 MULTINOMIAL_OUTER_REML_TOL
3528 };
3529 // #1082 root cause: the outer convergence test derives BOTH the absolute
3530 // projected-gradient floor (`max(outer_tol, n·1e-9)`) AND the relative-cost
3531 // stop (`rel_cost = outer_tol`) from the single `outer_tol`. The accuracy of
3532 // the smooth-by-factor surface is governed by the ABSOLUTE floor reaching the
3533 // n-scaled REML resolution `n·1e-9` (≈ 1.8e-6 at n = 1800) — that is why the
3534 // earlier 1e-5 floor UNDER-fit (its absolute floor was pinned at 1e-5, well
3535 // above the genuine optimum's gradient) and why 1e-7 recovered accuracy (it
3536 // unpins the floor down to the n-scaled 1.8e-6). But tightening `outer_tol`
3537 // to 1e-7 ALSO tightened the rel-cost stop to 1e-7, which on this family's
3538 // dead-flat REML ridge NEVER trips — so the optimizer no longer converges and
3539 // grinds all the way to `outer_max_iter`, each surplus step an O(D·p³) Laplace-
3540 // derivative assembly over the 382-dim joint design (the >600s wall-clock
3541 // overrun; tightening tol REINTRODUCED the crawl the 1e-5 floor had removed).
3542 //
3543 // The two requirements live on two different criteria, so they must be set
3544 // independently. Keep `outer_tol = 1e-7` (drives the accurate absolute floor)
3545 // but FLOOR the relative-cost stop at the framework default 1e-5 (the loose,
3546 // fast value that resolves the cost-decrease plateau without chasing the flat
3547 // tail). The absolute n·1e-9 floor still gates final λ accuracy; the rel-cost
3548 // stop just lets the optimizer DECLARE convergence on the flat ridge instead
3549 // of crawling to the iteration cap.
3550 let outer_rel_cost_tol = Some(BlockwiseFitOptions::default().outer_tol);
3551 let inner_tol = MULTINOMIAL_FORMULA_INNER_TOL.max(tol.max(0.0));
3552
3553 let options = BlockwiseFitOptions {
3554 inner_max_cycles: crate::custom_family::DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES,
3555 inner_tol,
3556 outer_max_iter,
3557 outer_tol,
3558 outer_rel_cost_tol,
3559 rho_lower_bound: multinomial_formula_min_lambda(y_one_hot.view()).ln(),
3560 ridge_floor: MULTINOMIAL_FORMULA_RIDGE_FLOOR,
3561 // #747: the stabilization floor is SOLVER-ONLY — it keeps the inner
3562 // joint-Newton linear solve finite during screening (bounding the step
3563 // `(H+δI)⁻¹∇` away from a near-separable, rank-deficient curvature) but
3564 // is excluded from the REML objective, the penalty log-determinant, and
3565 // the Laplace Hessian. The earlier default (`explicit_stabilization_pospart`)
3566 // folded `½·δ·‖β‖²` and a `δ`-shift of the log-determinant into the
3567 // criterion, shrinking every identified coefficient off the MLE and
3568 // perturbing smoothing-parameter selection — a fixed-λ prior masking
3569 // separation, not a numerical stabilizer. With the floor solver-only the
3570 // optimized objective is the true penalized REML criterion (value tracks
3571 // its analytic gradient), and the smooth directions remain governed
3572 // solely by their own REML-selected `λ`.
3573 ridge_policy: gam_problem::RidgePolicy::solver_only(),
3574 use_outer_hessian,
3575 // #715 real-data arm ("canonical-gauge null direction rejects all REML
3576 // seeds"): skip the multi-seed outer screening cascade and let the
3577 // pinned `init_lambda` ρ flow straight to the outer optimizer.
3578 //
3579 // The multinomial family declares `levenberg_on_ill_conditioning() ->
3580 // true`: near the simplex boundary (the near-separable penguins regime)
3581 // the softmax Fisher weight `W = diag(p) − p pᵀ → 0`, so the joint
3582 // information `H = JᵀWJ + S_λ` can become full-rank but
3583 // ILL-CONDITIONED. The self-vanishing LM damping that keeps the inner
3584 // joint-Newton from oscillating on those near-singular modes converges
3585 // only GEOMETRICALLY. The default screening policy ranks candidate seeds
3586 // with a 2-cycle inner cap (`outer_seed_config`); under geometric
3587 // LM-damped descent two cycles never reach a finite, meaningful proxy
3588 // objective, so EVERY capped seed can collapse to non-finite cost and
3589 // the cascade escalates to ×4, ×16, then an UNCAPPED full inner solve
3590 // PER SEED on the near-singular Hessian. That is the adapter-level face
3591 // of "all REML startup seeds rejected" and the multi-minute timeout.
3592 //
3593 // The pinned seed is already principled here: `init_lambda` gives every
3594 // (class, term) ρ a sensible moderate warm start, and the per-term
3595 // effective-df-floor upper bounds (`effective_df_floor_rho_upper_bounds`,
3596 // #715 arm (a)) keep any λ from collapsing the smooth onto its polynomial
3597 // null space. So the outer ARC/BFGS optimizer performs the real REML ρ
3598 // search from this seed; screening only adds the cascade cost and, on the
3599 // near-separable arm, the rejection stall.
3600 screen_initial_rho: false,
3601 // #1101: compute the joint Laplace posterior covariance `H⁻¹` (and the
3602 // influence matrix `F = H⁻¹ X'WX`) at the converged mode so the saved
3603 // model can surface delta-method per-class probability standard errors
3604 // and Wald smooth-term p-values. The driver factorizes the penalized
3605 // Hessian during the inner solve regardless; this only asks it to keep
3606 // and invert the factor instead of discarding it.
3607 compute_covariance: true,
3608 ..BlockwiseFitOptions::default()
3609 };
3610 Ok(PenalizedMultinomialFormulaParts {
3611 family,
3612 blocks,
3613 options,
3614 spec,
3615 design,
3616 class_levels,
3617 parametric_standardization,
3618 penalties_arc,
3619 raw_training_design,
3620 training_class_index,
3621 training_weights,
3622 })
3623}
3624
3625/// The coupled joint penalty `S_λ = Σ_s λ_s M_s` at the selected smoothing
3626/// parameters, in the stacked class-major coordinates the joint covariance, the
3627/// influence matrix and the posterior-mean predictive all share.
3628///
3629/// # Why the penalty is measured on its own (#2612)
3630///
3631/// `S_λ` used to be a by-product of the influence-matrix reconstruction
3632/// (`F = I − H⁻¹S_λ`), which needs two things the penalty does not:
3633///
3634/// * **at least one penalty component.** A wholly parametric multinomial —
3635/// `y ~ x1 + x2`, the shape both `quality_vs_statsmodels_multinomial` arms
3636/// fit — has none, so `S_λ` is the **zero operator**: a value, not an
3637/// absence. Reading it off a reconstruction that returns `None` on
3638/// `n_components == 0` turned "this model is unpenalized" into "this model
3639/// has no penalty operator", and the predictive then refused to publish any
3640/// parametric multinomial fit at all;
3641/// * **the joint posterior covariance `H⁻¹`.** That is a different measurement
3642/// of a different object. Conditioning the penalty's availability on it makes
3643/// a covariance failure surface as a missing penalty, naming the wrong one of
3644/// the two.
3645///
3646/// So the operator is assembled here from the family's own equivariant specs
3647/// and the selected `λ`, and every consumer reads THIS matrix — the influence
3648/// matrix and the published payload cannot describe different penalties.
3649///
3650/// # Basis
3651///
3652/// The specs live in the FITTED (standardized-parametric) basis, which is also
3653/// the basis of `training_design`'s penalized columns: the standardization
3654/// affine rescales parametric columns only and leaves every penalized column
3655/// identity-mapped, and `S_λ` is exactly zero outside the penalized columns. So
3656/// `A⁻ᵀS_λA⁻¹ = S_λ` and the published operator pairs with the raw design
3657/// without a change of variables. A penalty that ever reached a standardized
3658/// column would break that identity, which is why the zero-outside-penalized
3659/// structure is a property of the specs and not an assumption made here.
3660///
3661/// # Refusals
3662///
3663/// Only genuine inconsistencies: a selected-λ vector that does not match the
3664/// spec list, a spec of the wrong dimension, or λ reported for a model with
3665/// nothing to apply them to. Each of those is a disagreement between two parts
3666/// of the same fit, not a capability the fit lacks.
3667fn multinomial_joint_penalty_operator(
3668 joint_specs: &[gam_problem::JointPenaltySpec],
3669 joint_log_lambdas: Option<&Array1<f64>>,
3670 n_components: usize,
3671 expected_joint: usize,
3672) -> Result<Array2<f64>, EstimationError> {
3673 let mut s_lambda = Array2::<f64>::zeros((expected_joint, expected_joint));
3674 if n_components == 0 {
3675 let reported = joint_log_lambdas.map_or(0, |jll| jll.len());
3676 if reported != 0 || !joint_specs.is_empty() {
3677 crate::bail_invalid_estim!(
3678 "multinomial REML reported {reported} selected smoothing parameter(s) and {} \
3679 coupled penalty spec(s) for a model carrying no penalty component; there is \
3680 nothing for them to multiply",
3681 joint_specs.len(),
3682 );
3683 }
3684 // No penalized component exists, so the penalized log-posterior IS the
3685 // log-likelihood and the penalty is exactly zero.
3686 return Ok(s_lambda);
3687 }
3688 let jll = joint_log_lambdas.ok_or_else(|| {
3689 EstimationError::InvalidInput(format!(
3690 "multinomial REML converged with {n_components} penalty component(s) but surfaced no \
3691 selected joint smoothing parameters"
3692 ))
3693 })?;
3694 if jll.len() != joint_specs.len() || joint_specs.len() % n_components != 0 {
3695 crate::bail_invalid_estim!(
3696 "multinomial REML selected {} smoothing parameter(s) against {} coupled penalty \
3697 spec(s) over {n_components} component(s)",
3698 jll.len(),
3699 joint_specs.len(),
3700 );
3701 }
3702 for (spec, &log_lambda) in joint_specs.iter().zip(jll.iter()) {
3703 if spec.matrix.nrows() != expected_joint || spec.matrix.ncols() != expected_joint {
3704 crate::bail_invalid_estim!(
3705 "multinomial REML coupled penalty spec is {}x{}, expected \
3706 {expected_joint}x{expected_joint}",
3707 spec.matrix.nrows(),
3708 spec.matrix.ncols(),
3709 );
3710 }
3711 s_lambda.scaled_add(log_lambda.exp(), &spec.matrix);
3712 }
3713 Ok(s_lambda)
3714}
3715
3716pub fn fit_penalized_multinomial_formula(
3717 request: &MultinomialFitRequest<'_>,
3718) -> Result<MultinomialSavedModel, EstimationError> {
3719 let PenalizedMultinomialFormulaParts {
3720 family,
3721 blocks,
3722 options,
3723 spec,
3724 design,
3725 class_levels,
3726 parametric_standardization,
3727 penalties_arc,
3728 raw_training_design,
3729 training_class_index,
3730 training_weights,
3731 } = penalized_multinomial_formula_parts(request)?;
3732 let MultinomialFitRequest {
3733 data,
3734 formula,
3735 config,
3736 ..
3737 } = *request;
3738 let m = family.active_classes();
3739 // ── Conditional Firth/Jeffreys engagement (#715 arm (b) / #753) ──────────
3740 // Attempt 1: the unbiased criterion (Jeffreys disarmed above). If the
3741 // returned mode is converged, finite, and interior, it is the exact penalized-REML
3742 // optimum with zero Firth bias — accept it (this is the synthetic-arm /
3743 // interior-data path, #715 arm (a)). If the solve FAILS (e.g. the
3744 // (quasi-)separated penguins geometry where `(H + S_λ)v ≈ 0` along
3745 // penalty-null directions for EVERY ρ rejects every REML startup seed) or
3746 // returns a non-finite artifact, that is direct separation evidence:
3747 // re-solve once with the full-span Jeffreys/Firth proper prior armed, which
3748 // supplies the O(1) curvature on the quotient-null subspace that smoothing
3749 // parameters mathematically cannot (`Sv = 0` ⇒ λ never touches `v`). The
3750 // Firth refit is the accepted result only when the unbiased formula solve
3751 // failed, did not converge on its full budget, or blew up; finite
3752 // formula-path logits can be large on valid near-separated optima and
3753 // should not be shrunk toward the uniform simplex once the unbiased outer
3754 // solve has actually certified.
3755 //
3756 // BOTH solves run on the caller's own `outer_max_iter` (#2612). The probe
3757 // used to run under `outer_max_iter.min(MULTINOMIAL_UNBIASED_PROBE_OUTER_MAX_ITER)`
3758 // — a 20-iteration ceiling introduced as a bare one-line `perf(#1082)` change
3759 // (`8d9a7b53b`, no test, no measurement) — while EVERY `Err` from it was
3760 // routed below as separation evidence. Those two decisions are not
3761 // independent: SPEC forbids minting a fit from an exhausted budget, so an
3762 // outer search stopped by that ceiling returns `RemlDidNotConverge`, and the
3763 // ceiling could therefore convert "this probe was still descending when I
3764 // stopped it" into "the data separate" — and from there into a proper prior
3765 // that shrinks the published probabilities toward the uniform simplex `1/K`.
3766 //
3767 // Nor was the ceiling saving work where it was honest. If the probe certifies
3768 // within its budget the ceiling is a no-op: the search stops on its own
3769 // certificate either way. The ONLY runs it shortened are the ones where it
3770 // stopped a still-descending search — exactly the runs whose verdict it
3771 // changed. Its entire benefit was co-extensive with the misdecision. SPEC:
3772 // "Wall-clock time budgets and deadlines are never allowed, except in tests.
3773 // In general, do not paper over solver issues."
3774 //
3775 // The FINAL accepted Firth/Jeffreys refit likewise runs to the caller's full
3776 // outer budget: it is the result we ship, so it must reach the genuine REML
3777 // optimum, not a truncated iterate. The near-separable penguin refit that
3778 // motivated #1082's wall-clock concern is halted honestly at its true bound
3779 // optimum by the KKT-stationary-at-bound guard (`CostStallGuard`, #1082 /
3780 // 64711ed82) and the Newton-decrement residual certificate (363af9b56 /
3781 // 2c9580b1f): on separable data the outer ARC certifies and stops early on
3782 // its own, so no artificial iteration cap is needed to land in budget. On
3783 // non-separable data (e.g. the `vgam_smooth_by_factor` double-penalty arm)
3784 // the refit needs the caller's full budget to converge, which a `.min(20)`
3785 // cap would cut off — accepting a non-converged fit, which is dishonest.
3786 let unbiased_probe_options = &options;
3787 let firth_refit_options = &options;
3788
3789 // The coupled penalty specs, built ONCE. They depend only on the family's
3790 // term structure, not on λ, so the same list serves the separation
3791 // certificate below (at the probe's selected λ) and the published `S_λ` at
3792 // the shipped fit's λ — the two cannot describe different penalty families.
3793 let n_penalty_components = penalties_arc.len();
3794 let joint_specs = if n_penalty_components == 0 {
3795 Vec::new()
3796 } else {
3797 family.equivariant_class_penalty_specs().map_err(|error| {
3798 EstimationError::InvalidInput(format!(
3799 "multinomial REML could not rebuild the coupled joint penalty: {error}"
3800 ))
3801 })?
3802 };
3803
3804 let run_firth_refit =
3805 |evidence: String,
3806 measured_span: Option<std::sync::Arc<Array2<f64>>>,
3807 warm_start: Option<(&[ParameterBlockState], Option<&Array1<f64>>)>| {
3808 // The span the certificate MEASURED travels into the refit with the
3809 // verdict it justified (#2612). `None` leaves the derived
3810 // `ker(S_lambda)` route in place, which is what a degenerate mode gets.
3811 let mut firth_family = family
3812 .clone()
3813 .with_joint_jeffreys_term(true)
3814 .with_joint_jeffreys_span(measured_span);
3815 let mut firth_blocks = blocks.clone();
3816 if let Some((states, log_lambdas)) = warm_start {
3817 if states.len() != firth_blocks.len() {
3818 return Err(EstimationError::InvalidInput(format!(
3819 "multinomial Firth warm start has {} coefficient blocks, expected {}",
3820 states.len(),
3821 firth_blocks.len()
3822 )));
3823 }
3824 for (block, state) in firth_blocks.iter_mut().zip(states) {
3825 if state.beta.len() != block.design.ncols() {
3826 return Err(EstimationError::InvalidInput(format!(
3827 "multinomial Firth warm-start block '{}' has {} coefficients, expected {}",
3828 block.name,
3829 state.beta.len(),
3830 block.design.ncols()
3831 )));
3832 }
3833 if state.beta.iter().any(|value| !value.is_finite()) {
3834 return Err(EstimationError::InvalidInput(format!(
3835 "multinomial Firth warm-start block '{}' contains non-finite coefficients",
3836 block.name
3837 )));
3838 }
3839 block.initial_beta = Some(state.beta.clone());
3840 }
3841 if let Some(log_lambdas) = log_lambdas {
3842 if log_lambdas.iter().any(|value| !value.is_finite()) {
3843 return Err(EstimationError::InvalidInput(
3844 "multinomial Firth warm start contains non-finite log-lambdas"
3845 .to_string(),
3846 ));
3847 }
3848 firth_family =
3849 firth_family.with_joint_initial_log_lambdas(log_lambdas.to_vec());
3850 }
3851 }
3852 log::info!(
3853 "multinomial REML: arming the Jeffreys/Firth proper prior — separation evidence: \
3854 {evidence}"
3855 );
3856 fit_custom_family_with_rho_prior(
3857 &firth_family,
3858 &firth_blocks,
3859 firth_refit_options,
3860 gam_problem::RhoPrior::Flat,
3861 )
3862 .map_err(|err| {
3863 EstimationError::InvalidInput(format!(
3864 "multinomial REML: Firth/Jeffreys-armed refit (separation evidence: \
3865 {evidence}) failed: {err}"
3866 ))
3867 })
3868 };
3869
3870 // The separation scans walk the full P×M logit block, so they are evaluated
3871 // ONCE into a binding and branched on, rather than recomputed in both a match
3872 // guard and its arm (three to four full logit walks per fit on the hot
3873 // near-separable path).
3874 let probe_attempt = fit_custom_family_with_rho_prior(
3875 &family,
3876 &blocks,
3877 unbiased_probe_options,
3878 gam_problem::RhoPrior::Flat,
3879 );
3880 // The evidence that armed the proper prior, or `None` when the unbiased
3881 // penalized-REML criterion was accepted. This is a FACT ABOUT THE FIT — the
3882 // two branches publish different estimands (the unbiased mode versus the
3883 // Firth-biased one) — so it is carried to the payload rather than living only
3884 // in a log line the caller never sees.
3885 let (fit, separation_evidence) = match probe_attempt {
3886 Ok(probe_fit) => {
3887 let identifiable_span = probe_fit
3888 .geometry
3889 .as_ref()
3890 .ok_or_else(|| {
3891 EstimationError::InvalidInput(
3892 "multinomial unbiased fit omitted its certified coefficient geometry"
3893 .to_string(),
3894 )
3895 })?
3896 .coefficient_gauge
3897 .t_full
3898 .view();
3899 // #2612: a model with NO penalty component selects no smoothing
3900 // parameter, so `joint_log_lambdas` is legitimately absent — the same
3901 // "no penalty is a value, not an absence" the joint-penalty operator
3902 // carries. Only a PENALIZED probe that reached a certified optimum
3903 // without surfacing its own selected λ is a broken fit, and only that
3904 // is refused. The certificate below needs those λ to form the
3905 // curvature the mode actually sits in, so the check comes first.
3906 let joint_log_lambdas = probe_fit.artifacts.joint_log_lambdas.as_ref();
3907 if joint_log_lambdas.is_none() && n_penalty_components != 0 {
3908 return Err(EstimationError::InvalidInput(format!(
3909 "multinomial unbiased fit carries {n_penalty_components} penalty \
3910 component(s) but omitted its converged joint log-lambdas"
3911 )));
3912 }
3913 let separation = multinomial_formula_penalized_separation_evidence(
3914 &family,
3915 &blocks,
3916 &probe_fit.block_states,
3917 identifiable_span,
3918 &joint_specs,
3919 n_penalty_components,
3920 joint_log_lambdas,
3921 )
3922 .map_err(|error| {
3923 EstimationError::InvalidInput(format!(
3924 "multinomial REML separation certification failed: {error}"
3925 ))
3926 })?;
3927 match separation {
3928 None => {
3929 // Fit existence proves both optimization layers certified; no
3930 // post-hoc convergence flag is needed.
3931 log::info!(
3932 "multinomial REML: unbiased criterion accepted (no separation evidence; \
3933 Jeffreys/Firth prior disarmed)"
3934 );
3935 (probe_fit, None)
3936 }
3937 Some(certificate) => {
3938 let MultinomialSeparationCertificate {
3939 evidence,
3940 measured_span,
3941 } = certificate;
3942 // A certified unbiased optimum can still exhibit separation;
3943 // use the already-computed mode and rho as the exact Firth
3944 // continuation seed. The objectives differ only by the smooth
3945 // Jeffreys term, so restarting from zero would discard the
3946 // strongest available local information and repeat the whole
3947 // unbiased path.
3948 let refit = run_firth_refit(
3949 evidence.clone(),
3950 measured_span,
3951 Some((&probe_fit.block_states, joint_log_lambdas)),
3952 )?;
3953 (refit, Some(evidence))
3954 }
3955 }
3956 }
3957 Err(err) => {
3958 let evidence = format!(
3959 "the unbiased criterion has no certified optimum on the caller's own outer \
3960 budget ({} iteration(s)): {err}",
3961 options.outer_max_iter,
3962 );
3963 // No certified mode, so no measured span: the derived
3964 // `ker(S_lambda)` route stands.
3965 let refit = run_firth_refit(evidence.clone(), None, None)?;
3966 (refit, Some(evidence))
3967 }
3968 };
3969 if let Some(err) = multinomial_formula_separation_diagnostic(
3970 fit.inner_cycles,
3971 fit.outer_iterations,
3972 &fit.block_states,
3973 ) {
3974 return Err(err);
3975 }
3976
3977 // ── Repack coefficients (P, K-1) from per-block β vectors ─────────────
3978 if fit.blocks.len() != m {
3979 crate::bail_invalid_estim!(
3980 "multinomial REML: expected {m} fitted blocks (K-1), got {}",
3981 fit.blocks.len()
3982 );
3983 }
3984 let p_per_class = fit.blocks[0].beta.len();
3985 let mut coefficients_active = Array2::<f64>::zeros((p_per_class, m));
3986 for (a, block) in fit.blocks.iter().enumerate() {
3987 if block.beta.len() != p_per_class {
3988 crate::bail_invalid_estim!(
3989 "multinomial REML: block {a} has {} coefs, expected {p_per_class}",
3990 block.beta.len()
3991 );
3992 }
3993 for i in 0..p_per_class {
3994 coefficients_active[[i, a]] = block.beta[i];
3995 }
3996 }
3997 // Map the standardized-column coefficients back to raw units (the exact
3998 // inverse of the conditioning reparameterization above): β_raw = b/s, with
3999 // the centering mass `Σ_j b_j·m_j/s_j` returned to the intercept.
4000 if !parametric_standardization.is_empty() {
4001 let intercept_col = design.intercept_range.clone().next();
4002 for a in 0..m {
4003 let mut intercept_adjust = 0.0;
4004 for &(col, center, scale) in ¶metric_standardization {
4005 if col < p_per_class {
4006 let raw = coefficients_active[[col, a]] / scale;
4007 coefficients_active[[col, a]] = raw;
4008 intercept_adjust += raw * center;
4009 }
4010 }
4011 if let Some(i0) = intercept_col
4012 && i0 < p_per_class
4013 {
4014 coefficients_active[[i0, a]] -= intercept_adjust;
4015 }
4016 }
4017 }
4018 // Flatten every (class, term) smoothing parameter in block-major order
4019 // (class 0's terms, then class 1's, …). With per-term penalties each block
4020 // now carries one λ per smooth term, so a single λ per class would discard
4021 // the independent per-term selection that fixes #561. `lambdas_per_block`
4022 // segments the flat vector by class so callers can recover per-term λ.
4023 let expected_joint = p_per_class.checked_mul(m).ok_or_else(|| {
4024 EstimationError::InvalidInput(
4025 "multinomial posterior covariance dimension overflowed usize".to_string(),
4026 )
4027 })?;
4028 // ── The penalty operator, measured once (#2612) ──────────────────────────
4029 // `S_λ` is a fact about the fit in its own right: the influence-matrix
4030 // reconstruction below and the published predictive payload both read THIS
4031 // matrix, so they cannot describe different penalties, and a model with no
4032 // penalized term publishes the zero operator it has instead of being refused
4033 // for not having one. See `multinomial_joint_penalty_operator`.
4034 //
4035 // `joint_specs` was built once above the probe (it is λ-independent) and is
4036 // the same list the separation certificate formed `H + S_λ` from, so the
4037 // arming decision, the influence matrix and the published payload all read
4038 // one penalty family. Under the equivariant carrier this is K per-class specs
4039 // per term, grouped term-major (`s = t·K + c`); the K = 2 degenerate arm
4040 // returns one shared centered spec per term.
4041 let joint_penalty = multinomial_joint_penalty_operator(
4042 &joint_specs,
4043 fit.artifacts.joint_log_lambdas.as_ref(),
4044 n_penalty_components,
4045 expected_joint,
4046 )?;
4047 // ── gam#1587/#561 joint-penalty reconstruction ───────────────────────────
4048 // Under the #1587 centered-metric architecture every active class block
4049 // leaves its per-block penalty list EMPTY — the entire fit's smoothing rides
4050 // on a single full-width JOINT penalty `S_λ = Σ_t λ_t (M ⊗ S_t)` whose one
4051 // shared `λ_t` per smooth component is selected by the outer REML loop and
4052 // surfaced on `fit.artifacts.joint_log_lambdas`. So `fit.blocks[a].lambdas`
4053 // is `[]`, the inference layer's per-block trace channel is empty, and the
4054 // older per-block reporting (`lambdas_per_block = [0, 0]`, `edf_per_class =
4055 // None`, …) collapsed (#561 reopen).
4056 //
4057 // Reconstruct the per-(class, component) λ and the influence-matrix EDF
4058 // directly from the selected joint `λ_t` and the COUPLED penalty
4059 // `S_λ = Σ_t λ_t (M ⊗ S_t)` (NOT a block-diagonal `Σ_t λ_{a,t} S_t`: the
4060 // centered metric `M` couples classes off the block diagonal, so a
4061 // block-diagonal `S_λ` would mis-state both the influence matrix and every
4062 // trace). With `H⁻¹ = fit.covariance_conditional` now assembled WITH the
4063 // joint penalty (the `compute_joint_covariance` fix), the influence matrix is
4064 // exactly `F = I − H⁻¹ S_λ`, its per-class diagonal-block trace is the honest
4065 // per-class EDF, and `Σ_a edf_a = tr(F) = edf_total`.
4066 let joint_recon = fit.artifacts.joint_log_lambdas.as_ref().and_then(|jll| {
4067 let n_components = n_penalty_components;
4068 if n_components == 0 {
4069 return None;
4070 }
4071 // `joint_specs` is the list `joint_penalty` was assembled from, so the
4072 // influence matrix and the published operator cannot describe different
4073 // penalties.
4074 if jll.len() != joint_specs.len() || joint_specs.len() % n_components != 0 {
4075 return None;
4076 }
4077 let specs_per_term = joint_specs.len() / n_components;
4078 let hinv = fit
4079 .covariance_conditional
4080 .as_ref()
4081 .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)?;
4082 let lam: Vec<f64> = jll.iter().map(|&l| l.exp()).collect();
4083 // Per-spec `H⁻¹ M_s` (full mp×mp), reused for both the joint influence
4084 // matrix and the per-(class, component) trace decomposition.
4085 let mut hinv_st: Vec<Array2<f64>> = Vec::with_capacity(joint_specs.len());
4086 for spec in &joint_specs {
4087 if spec.matrix.nrows() != expected_joint || spec.matrix.ncols() != expected_joint {
4088 return None;
4089 }
4090 hinv_st.push(hinv.dot(&spec.matrix));
4091 }
4092 // F = I − H⁻¹ S_λ = I − Σ_s λ_s H⁻¹ M_s.
4093 let mut f = Array2::<f64>::eye(expected_joint);
4094 for (s, hs) in hinv_st.iter().enumerate() {
4095 f.scaled_add(-lam[s], hs);
4096 }
4097 // Per-class diagonal-block trace of F (the honest per-class EDF), and
4098 // the per-(class, component) penalty trace
4099 // `tr_{a,t} = Σ_{c∈term t} λ_{t,c} · Σ_{i∈class a} (H⁻¹ M_{t,c})[i,i]`
4100 // for the per-penalty EDF rollup.
4101 let mut edf_per_class = Vec::with_capacity(m);
4102 // class-major per-penalty EDF (class 0's components, then class 1's, …),
4103 // aligned 1:1 with the flat per-(class, component) λ report below.
4104 let mut edf_per_penalty = Vec::with_capacity(m * n_components);
4105 for a in 0..m {
4106 let base = a * p_per_class;
4107 let mut class_trace = 0.0_f64;
4108 for t in 0..n_components {
4109 let mut tr_at = 0.0_f64;
4110 for c in 0..specs_per_term {
4111 let s = t * specs_per_term + c;
4112 let mut tr = 0.0_f64;
4113 for i in 0..p_per_class {
4114 tr += hinv_st[s][[base + i, base + i]];
4115 }
4116 tr_at += lam[s] * tr;
4117 }
4118 class_trace += tr_at;
4119 // A single component's per-class trace EDF `rank(S_t) − tr_{a,t}`,
4120 // bounded by its local rank (≤ p_per_class). Derive rank(S_t)
4121 // from the spec's MEASURED nullity (per-class spec: rank =
4122 // m·p − nullspace_dim; shared centered spec: m·rank), so the
4123 // reporting rank matches the pseudo-logdet rank exactly.
4124 let spec0 = &joint_specs[t * specs_per_term];
4125 let joint_rank = expected_joint - spec0.nullspace_dim;
4126 let rank_t = if specs_per_term > 1 {
4127 joint_rank as f64
4128 } else {
4129 (joint_rank as f64) / (m as f64)
4130 };
4131 edf_per_penalty.push((rank_t - tr_at).clamp(0.0, p_per_class as f64));
4132 }
4133 edf_per_class.push((p_per_class as f64 - class_trace).clamp(0.0, p_per_class as f64));
4134 }
4135 // Per-(class, component) λ report, class-major. Under the equivariant
4136 // carrier the smoothing applied to active class `a`'s centered function
4137 // for term `t` is its own `λ_{t,c=a}` (spec index `t·K + a`); under the
4138 // K = 2 shared arm every class reports the one `λ_t`.
4139 let mut lam_flat = Vec::with_capacity(m * n_components);
4140 for a in 0..m {
4141 for t in 0..n_components {
4142 let s = if specs_per_term > 1 {
4143 t * specs_per_term + a
4144 } else {
4145 t
4146 };
4147 lam_flat.push(lam[s]);
4148 }
4149 }
4150 Some((f, edf_per_class, edf_per_penalty, n_components, lam_flat))
4151 });
4152
4153 // Flatten every (class, component) smoothing parameter in class-major order.
4154 // Under the equivariant joint-penalty architecture each active class `a`
4155 // reports its own `λ_{t,a}` per term (the per-class centered penalties;
4156 // the K = 2 degenerate arm replicates the shared `λ_t`), so the flat vector
4157 // is class-major with `lambdas_per_block = [n_components; K-1]`. When the
4158 // joint reconstruction is unavailable (legacy fixed-λ path or absent
4159 // covariance) fall back to the raw — now empty — per-block λ lists.
4160 let (lambdas_per_block, lambdas_flat): (Vec<usize>, Vec<f64>) = match joint_recon.as_ref() {
4161 Some((_, _, _, n_components, lam_flat)) => {
4162 let per_block = vec![*n_components; m];
4163 (per_block, lam_flat.clone())
4164 }
4165 None => {
4166 let per_block: Vec<usize> = fit.blocks.iter().map(|b| b.lambdas.len()).collect();
4167 let flat: Vec<f64> = fit
4168 .blocks
4169 .iter()
4170 .flat_map(|b| b.lambdas.iter().copied())
4171 .collect();
4172 (per_block, flat)
4173 }
4174 };
4175 // Per-active-class effective degrees of freedom, length `K-1`, summing to
4176 // the model `edf_total`. The REML inference block reports `edf_by_block` as
4177 // ONE entry per *penalty block* (per (class, term, penalty)), each computed
4178 // as `rank(S_kk) − tr(H⁻¹ λ_kk S_kk)`. That per-block sum OVER-COUNTS the
4179 // model EDF whenever several penalties share one coefficient range — a
4180 // double-penalty / te / ti / adaptive smooth has ≥2 penalty blocks over the
4181 // same columns, so `Σ_kk rank(S_kk) > p` and `Σ_kk edf_by_block > edf_total`
4182 // (the observed ~79 for a ~24-coefficient model). Handing that raw per-block
4183 // vector out as the documented length-(K-1) per-class EDF is therefore both
4184 // the wrong LENGTH (it is `Σ_a n_blocks_a`, not `K-1`) and an over-count.
4185 //
4186 // The honest per-class EDF is the influence-matrix trace over each class's
4187 // coefficient block. Classes occupy DISJOINT `p_per_class`-wide coefficient
4188 // ranges, and the per-block traces `tr_kk = tr(H⁻¹ λ_kk S_kk)` are additive
4189 // (no rank double-counting), so class `a`'s EDF is
4190 // `p_per_class − Σ_{kk ∈ class a} tr_kk`, and `Σ_a edf_a = m·p_per_class −
4191 // Σ_kk tr_kk = p − Σ tr_kk = edf_total` exactly. Segment the block-major
4192 // `penalty_block_trace` by `lambdas_per_block` (the same per-class λ-count
4193 // segmentation `lambdas_flat` uses). Fall back to `None` when the trace
4194 // channel is unavailable or mis-shaped (legacy fixed-λ path), exactly as the
4195 // raw `edf_by_block` map did before.
4196 let edf_per_class = joint_recon
4197 .as_ref()
4198 .map(|(_, epc, _, _, _)| epc.clone())
4199 .or_else(|| {
4200 // Legacy per-block trace path (fixed-λ / pre-#1587 fits whose
4201 // smoothing is still carried per block). Segment the block-major
4202 // `penalty_block_trace` by `lambdas_per_block`, exactly as before.
4203 fit.inference.as_ref().and_then(|info| {
4204 let traces = &info.penalty_block_trace;
4205 if traces.len() != lambdas_per_block.iter().sum::<usize>() {
4206 return None;
4207 }
4208 let mut per_class = Vec::with_capacity(m);
4209 let mut cursor = 0usize;
4210 for &n_blocks in &lambdas_per_block {
4211 let class_trace: f64 = traces[cursor..cursor + n_blocks].iter().sum();
4212 per_class
4213 .push((p_per_class as f64 - class_trace).clamp(0.0, p_per_class as f64));
4214 cursor += n_blocks;
4215 }
4216 Some(per_class)
4217 })
4218 });
4219 // Per-PENALTY EDF: the inference layer's `edf_by_block` is already the
4220 // clamped per-penalty-block trace EDF `rank(S_k) − λ_k·tr(H⁻¹ S_k)`, one
4221 // entry per smoothing parameter and block-major aligned 1:1 with the flat
4222 // `lambdas`. Surface it verbatim (guarding only on the length contract) so
4223 // consumers can inspect per-(class, term, penalty) collapse onto the null
4224 // space — a signal the per-class EDF SUM hides. This is NOT a per-class
4225 // total: with double-penalty smooths `Σ_k rank(S_k) > p_per_class`, so the
4226 // entries deliberately need not sum to the model EDF (the per-class field
4227 // carries that contract instead).
4228 let edf_per_penalty = joint_recon
4229 .as_ref()
4230 .map(|(_, _, epp, _, _)| epp.clone())
4231 .or_else(|| {
4232 // Legacy per-block path: the inference layer's `edf_by_block` is
4233 // already the clamped per-penalty-block trace EDF, aligned 1:1 with
4234 // the flat `lambdas`.
4235 fit.inference.as_ref().and_then(|info| {
4236 if info.edf_by_block.len() != lambdas_flat.len() {
4237 return None;
4238 }
4239 Some(
4240 info.edf_by_block
4241 .iter()
4242 .map(|&e| e.max(0.0))
4243 .collect::<Vec<f64>>(),
4244 )
4245 })
4246 });
4247 let coefficients_flat: Vec<f64> = coefficients_active.iter().copied().collect();
4248
4249 // #1101: surface the joint Laplace posterior covariance `H⁻¹` (block-ordered
4250 // [β_0; …; β_{K-2}]) and the influence matrix `F = H⁻¹ X'WX` the REML driver
4251 // computed at the converged mode. These power the predict path's delta-method
4252 // per-class probability standard errors and the summary's Wald smooth-term
4253 // tests. The joint matrices are `(P·M)×(P·M)`. The covariance is mapped back
4254 // to RAW units (see below) so it pairs with the raw predict design; the
4255 // influence is kept in the fitted basis (the Wald table only slices penalized
4256 // columns, which the standardization affine leaves identity-mapped).
4257 // The joint Hessian (and thus `H⁻¹`) was assembled in the STANDARDIZED
4258 // parametric basis used during fitting, while the saved coefficients and the
4259 // raw predict design are in raw units. Map the covariance to raw units with
4260 // the same exact affine reparameterization `β_raw = A β_std`: for each
4261 // standardized parametric column `col`, `β_raw[col] = β_std[col]/scale` and
4262 // the intercept absorbs `−Σ_col (center/scale)·β_std[col]`. So `A = I` except
4263 // `A[col,col] = 1/scale` and `A[i0,col] = −center/scale`, replicated
4264 // block-diagonally per active class, and `Cov_raw = A Cov_std Aᵀ`. With no
4265 // standardization (`parametric_standardization` empty) `A = I` and this is a
4266 // no-op. The smooth-term (penalized) columns are untouched by `A`, so the
4267 // Wald table's per-term blocks are identical in both bases.
4268 let intercept_col0 = design.intercept_range.clone().next();
4269 let build_per_class_affine = |amat: &mut Array2<f64>| {
4270 for &(col, center, scale) in ¶metric_standardization {
4271 if col >= p_per_class {
4272 continue;
4273 }
4274 amat[[col, col]] = 1.0 / scale;
4275 if let Some(i0) = intercept_col0
4276 && i0 < p_per_class
4277 {
4278 amat[[i0, col]] = -center / scale;
4279 }
4280 }
4281 };
4282 // One raw-unit map, used by BOTH joint covariance-frame matrices. `C` lives
4283 // in the same lifted frame as `V_cond` (it is assembled against it — see
4284 // `BlockwiseFitAssembly::smoothing_corrected`), so it must take the same
4285 // congruence; mapping only one of the pair would publish `V_cond + C` in two
4286 // different parameterizations.
4287 let to_raw_units = |matrix_std: &Array2<f64>| -> Vec<f64> {
4288 if parametric_standardization.is_empty() {
4289 return matrix_std.iter().copied().collect::<Vec<f64>>();
4290 }
4291 // Block-diagonal joint A (same per active class).
4292 let mut a_joint = Array2::<f64>::eye(expected_joint);
4293 let mut a_class = Array2::<f64>::eye(p_per_class);
4294 build_per_class_affine(&mut a_class);
4295 for a in 0..m {
4296 let base = a * p_per_class;
4297 for i in 0..p_per_class {
4298 for j in 0..p_per_class {
4299 a_joint[[base + i, base + j]] = a_class[[i, j]];
4300 }
4301 }
4302 }
4303 let raw = a_joint.dot(matrix_std).dot(&a_joint.t());
4304 raw.iter().copied().collect::<Vec<f64>>()
4305 };
4306 let coefficient_covariance_flat = fit
4307 .covariance_conditional
4308 .as_ref()
4309 .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
4310 .map(|cov_std| to_raw_units(cov_std))
4311 .ok_or_else(|| {
4312 EstimationError::InvalidInput(format!(
4313 "multinomial REML converged without the required {expected_joint}x{expected_joint} joint posterior covariance"
4314 ))
4315 })?;
4316 // gam#2612: the same fit already computed the first-order ρ-uncertainty
4317 // correction `C = J·Var(ρ̂)·Jᵀ` (#2346) and published it on the inference
4318 // block; every other family in the library carries it onto its predict
4319 // surface and this one dropped it at the read boundary, which is what made
4320 // every multinomial band conditional-on-λ̂ and the standing coverage gate
4321 // for the mean-probability band anti-conservative. A mis-shaped correction
4322 // is a typed absence rather than an error: the conditional definition is
4323 // still a correct answer to a narrower question, and the consumers say which
4324 // one they used.
4325 let smoothing_correction_flat = fit.inference.as_ref().and_then(|info| {
4326 info.smoothing_correction
4327 .as_ref()
4328 .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
4329 .map(|c| to_raw_units(c))
4330 });
4331 // The influence matrix `F = H⁻¹ X'WX = H⁻¹(H − S_λ) = I − H⁻¹ S_λ`. The
4332 // exact-Newton multinomial blocks carry no IRLS pseudo-data, so the generic
4333 // inference path does not export `coefficient_influence`; reconstruct it
4334 // exactly here. Under the #1587 joint-penalty architecture the penalty is the
4335 // COUPLED centered metric `S_λ = Σ_t λ_t (M ⊗ S_t)` (off the class-block
4336 // diagonal), already assembled in `joint_recon` above, so reuse that exact
4337 // `F`. Only fall back to the legacy block-diagonal `Σ_t λ_{a,t} S_t`
4338 // reconstruction when the joint reconstruction is unavailable (pre-#1587
4339 // per-block fits whose class blocks still carry their own penalties).
4340 let coefficient_influence_flat = match joint_recon.as_ref() {
4341 Some((f, _, _, _, _)) => Some(f.iter().copied().collect::<Vec<f64>>()),
4342 None => fit
4343 .covariance_conditional
4344 .as_ref()
4345 .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
4346 .and_then(|hinv| {
4347 if fit.blocks.len() != m {
4348 return None;
4349 }
4350 // Joint S_λ (block-diagonal across active classes).
4351 let mut s_lambda = Array2::<f64>::zeros((expected_joint, expected_joint));
4352 for (a, block) in fit.blocks.iter().enumerate() {
4353 if block.lambdas.len() != penalties_arc.len() {
4354 return None;
4355 }
4356 let base = a * p_per_class;
4357 for (t, pen) in penalties_arc.iter().enumerate() {
4358 let lam = block.lambdas[t];
4359 if lam == 0.0 {
4360 continue;
4361 }
4362 let dense = pen.to_dense();
4363 if dense.nrows() != p_per_class || dense.ncols() != p_per_class {
4364 return None;
4365 }
4366 for i in 0..p_per_class {
4367 for j in 0..p_per_class {
4368 s_lambda[[base + i, base + j]] += lam * dense[[i, j]];
4369 }
4370 }
4371 }
4372 }
4373 // F = I − H⁻¹ S_λ.
4374 let hinv_s = hinv.dot(&s_lambda);
4375 let mut f = Array2::<f64>::eye(expected_joint);
4376 f -= &hinv_s;
4377 Some(f.iter().copied().collect::<Vec<f64>>())
4378 }),
4379 };
4380
4381 // Per-(smooth term) coefficient span within a single class block, deduped by
4382 // col_range (the #561 double-penalty migration emits two penalty blocks per
4383 // term sharing one col_range; the Wald test covers the whole term block once).
4384 let mut smooth_term_spans: Vec<MultinomialSmoothTermSpan> = Vec::new();
4385 for (pen_idx, bp) in design.penalties.iter().enumerate() {
4386 let col_start = bp.col_range.start;
4387 let col_end = bp.col_range.end;
4388 if col_start >= col_end || col_end > p_per_class {
4389 continue;
4390 }
4391 if smooth_term_spans
4392 .iter()
4393 .any(|s| s.col_start == col_start && s.col_end == col_end)
4394 {
4395 continue;
4396 }
4397 let label = design
4398 .penaltyinfo
4399 .get(pen_idx)
4400 .and_then(|info| info.termname.clone())
4401 .unwrap_or_else(|| format!("s{pen_idx}"));
4402 let nullspace_dim = design
4403 .nullspace_dims
4404 .get(pen_idx)
4405 .copied()
4406 .unwrap_or(0)
4407 .min(col_end - col_start);
4408 smooth_term_spans.push(MultinomialSmoothTermSpan {
4409 label,
4410 col_start,
4411 col_end,
4412 nullspace_dim,
4413 });
4414 }
4415
4416 // One descriptive label per penalty *component* within a single class block,
4417 // parallel to that block's λ slice (#1544). `design.penalties` is index-
4418 // parallel to every active class's `block.lambdas` (each block carries the
4419 // full per-component penalty list, validated above by
4420 // `block.lambdas.len() == penalties_arc.len()`), so iterating it in order
4421 // yields exactly `lambdas_per_block[0]` labels aligned with the per-block λ.
4422 // This is deliberately NOT deduped by col_range (unlike `smooth_term_spans`):
4423 // the double penalty's primary and null-space components share one col_range
4424 // but select independent λ, and each must keep its own label so the summary
4425 // renderer never collapses or drops a λ.
4426 let lambda_labels: Vec<String> = design
4427 .penalties
4428 .iter()
4429 .enumerate()
4430 .map(|(pen_idx, _)| penalty_component_label(design.penaltyinfo.get(pen_idx), pen_idx))
4431 .collect();
4432
4433 // Unpenalized deviance read directly from the converged unpenalized
4434 // log-likelihood the rho-prior driver already computed (issue #348):
4435 // MultinomialFamily::evaluate sets FamilyEvaluation.log_likelihood =
4436 // log_lik(η, y) with no penalty term, and that value flows unchanged into
4437 // UnifiedFitResult.log_likelihood. This reproduces the legacy fixed-λ
4438 // path's `deviance = -2 · log_lik` contract bit-for-bit, so the previous
4439 // row-by-row η = Xβ rebuild and softmax recompute were pure dead work.
4440 let deviance = -2.0 * fit.log_likelihood;
4441
4442 // #2612: the training frame and the penalty operator the posterior-mean
4443 // predictive needs. `joint_penalty` was measured above from the family's own
4444 // equivariant specs and the selected λ — the same matrix the influence
4445 // reconstruction consumed — so the payload and `F` cannot describe different
4446 // penalties, and an unpenalized model publishes the zero operator it has
4447 // rather than being refused for not having one.
4448 let joint_penalty_flat: Vec<f64> = joint_penalty.iter().copied().collect();
4449 if raw_training_design.ncols() != p_per_class {
4450 crate::bail_invalid_estim!(
4451 "multinomial REML: raw training design has {} columns but the fit reports \
4452 p_per_class = {p_per_class}",
4453 raw_training_design.ncols(),
4454 );
4455 }
4456 let training_rows = raw_training_design.nrows();
4457 let training_design_flat: Vec<f64> = raw_training_design.iter().copied().collect();
4458 let training_weights_flat: Vec<f64> = training_weights.iter().copied().collect();
4459
4460 Ok(MultinomialSavedModel {
4461 formula: formula.to_string(),
4462 class_levels: class_levels.clone(),
4463 reference_class_index: class_levels.len() - 1,
4464 resolved_termspec: spec,
4465 coefficients_flat,
4466 p_per_class,
4467 n_active_classes: m,
4468 training_headers: data.headers.clone(),
4469 training_table_kind: config.training_table_kind.clone(),
4470 lambdas: lambdas_flat,
4471 lambdas_per_block,
4472 iterations: fit.inner_cycles,
4473 separation_evidence,
4474 penalized_neg_log_likelihood: -fit.log_likelihood + 0.5 * fit.stable_penalty_term,
4475 deviance,
4476 edf_per_class,
4477 edf_per_penalty,
4478 coefficient_covariance_flat,
4479 smoothing_correction_flat,
4480 coefficient_influence_flat,
4481 smooth_term_spans,
4482 training_design_flat,
4483 training_rows,
4484 training_class_index,
4485 training_weights: training_weights_flat,
4486 joint_penalty_flat,
4487 lambda_labels,
4488 })
4489}
4490
4491/// Replay the saved termspec to build the predict-time dense design `X` on a
4492/// fresh dataset, realigning feature columns **by name** so the predict frame
4493/// need not reproduce the training column order or carry the response column.
4494/// Shared by every multinomial predict path (probabilities, SE bands, and the
4495/// posterior-predictive replicate draws).
4496fn build_multinomial_predict_design(
4497 model: &MultinomialSavedModel,
4498 data: &EncodedDataset,
4499) -> Result<Array2<f64>, EstimationError> {
4500 // The saved termspec stores feature columns as absolute indices into the
4501 // *training* table `[response, features...]`. Realign them onto this
4502 // dataset's columns by name, so prediction works on label-free new data
4503 // (the response column is never referenced by any term; issue #803).
4504 let predict_columns = data.column_map();
4505 let realigned = model.resolved_termspec.remap_feature_columns(
4506 |index| -> Result<usize, EstimationError> {
4507 let name = model.training_headers.get(index).ok_or_else(|| {
4508 EstimationError::InvalidInput(format!(
4509 "multinomial predict: saved training column index {index} is out of bounds \
4510 for {} training headers",
4511 model.training_headers.len()
4512 ))
4513 })?;
4514 resolve_role_col(&predict_columns, name, "feature")
4515 .map_err(|err| EstimationError::InvalidInput(err.to_string()))
4516 },
4517 )?;
4518 let design = build_term_collection_design(data.values.view(), &realigned).map_err(|err| {
4519 EstimationError::InvalidInput(format!(
4520 "multinomial predict: rebuild design from saved termspec: {err}"
4521 ))
4522 })?;
4523 if design.affine_offset.iter().any(|value| *value != 0.0) {
4524 crate::bail_invalid_estim!(
4525 "multinomial predict does not support non-zero smooth anchors: the saved \
4526 reference-coded softmax has no per-class affine offset channel"
4527 );
4528 }
4529 let x_dense = design
4530 .design
4531 .try_to_dense_by_chunks("multinomial predict design")
4532 .map_err(EstimationError::InvalidInput)?;
4533 if x_dense.ncols() != model.p_per_class {
4534 crate::bail_invalid_estim!(
4535 "multinomial predict: predict design has {} cols, saved model expects {}",
4536 x_dense.ncols(),
4537 model.p_per_class
4538 );
4539 }
4540 Ok(x_dense)
4541}
4542
4543/// Replay the saved termspec to build the predict-time design on a fresh
4544/// dataset, then evaluate the POSTERIOR-MEAN class probabilities
4545/// `E[softmax(x'β) | data]`. The predict dataset must carry the same feature
4546/// columns the training data did, matched **by name** — it need not reproduce
4547/// the training column order, and in particular need not carry the response
4548/// column (prediction is for label-free new data).
4549///
4550/// The posterior mean is computed as a ratio of normalising constants rather
4551/// than by integrating `softmax` over the Laplace Gaussian; see
4552/// [`crate::multinomial_predictive`] for why the latter is not an approximation
4553/// of this estimand (#2612). For the plug-in `softmax(x'β̂)` every other softmax
4554/// implementation reports, ask [`predict_multinomial_formula_plugin`] by name.
4555pub fn predict_multinomial_formula(
4556 model: &MultinomialSavedModel,
4557 data: &EncodedDataset,
4558) -> Result<Array2<f64>, EstimationError> {
4559 model.validate()?;
4560 let x_dense = build_multinomial_predict_design(model, data)?;
4561 model.predict_probabilities(x_dense.view())
4562}
4563
4564/// Plug-in class probabilities `softmax(x'β̂)` at the posterior MODE, for a
4565/// saved multinomial model on fresh data.
4566///
4567/// [`predict_multinomial_formula`] returns a different estimand: the
4568/// posterior-mean probability `E[softmax(η)]`. Both are legitimate and they are
4569/// not interchangeable, but the difference between them is much smaller than it
4570/// used to appear, and the reason is worth stating here because this function is
4571/// where a reader compares the two.
4572///
4573/// `softmax` is concave along the winning coordinate, so averaging it over
4574/// posterior width pulls the answer toward the centre of the simplex. The
4575/// posterior of a logit is also right-skewed toward larger `|η|`, which pulls
4576/// the other way. A correct posterior mean carries BOTH; the Gaussian
4577/// integration this path used to perform carried only the first, which is why
4578/// the published probability read as under-confident at unchanged argmax
4579/// (#2612). Measured on a quasi-separated fixture against an MCMC posterior, the
4580/// Gaussian-integrated quantity is off by up to `2.1e-1` in probability while
4581/// the ratio estimator [`predict_multinomial_formula`] now uses is off by
4582/// `4.4e-3` — and the plug-in below sits much closer to the posterior mean than
4583/// the Gaussian did.
4584///
4585/// `nnet::multinom`, `scikit-learn`, `statsmodels` and every other softmax
4586/// reference report the plug-in quantity, so a held-out log-loss comparison
4587/// against any of them is a comparison of two estimands unless this function is
4588/// the one supplying gam's side.
4589///
4590/// This is deliberately NOT a fallback: the posterior-mean path refuses rather
4591/// than degrading to a plug-in when it cannot certify its own accuracy, and that
4592/// refusal stands. A caller who wants the mode's own probability has to ask for
4593/// it here, by name.
4594pub fn predict_multinomial_formula_plugin(
4595 model: &MultinomialSavedModel,
4596 data: &EncodedDataset,
4597) -> Result<Array2<f64>, EstimationError> {
4598 model.validate()?;
4599 let x_dense = build_multinomial_predict_design(model, data)?;
4600 let coefficients = model.coefficients_active()?;
4601 let eta = x_dense.dot(&coefficients);
4602 let n_rows = eta.nrows();
4603 let n_classes = model.n_active_classes + 1;
4604 let mut probabilities = Array2::<f64>::zeros((n_rows, n_classes));
4605 for (row, mut destination) in eta.rows().into_iter().zip(probabilities.rows_mut()) {
4606 let active: Vec<f64> = row.iter().copied().collect();
4607 let row_probabilities = softmax_with_reference(&active)?;
4608 for (class, probability) in row_probabilities.iter().enumerate() {
4609 destination[class] = *probability;
4610 }
4611 }
4612 Ok(probabilities)
4613}
4614
4615/// Draw `n_draws` posterior-predictive replicate class-label assignments for a
4616/// saved multinomial model on fresh data (#1101). Rebuilds the predict design
4617/// exactly as [`predict_multinomial_formula`], then samples each row's class
4618/// from `Categorical(E[softmax(η) | data])` (see
4619/// [`MultinomialSavedModel::sample_replicate_classes`]). Returns an
4620/// `(n_draws, N)` matrix of class INDICES `0..K` aligned to `model.class_levels`,
4621/// deterministic in `seed`.
4622pub fn posterior_predict_multinomial_formula(
4623 model: &MultinomialSavedModel,
4624 data: &EncodedDataset,
4625 n_draws: usize,
4626 seed: u64,
4627) -> Result<Array2<u32>, EstimationError> {
4628 if n_draws == 0 {
4629 crate::bail_invalid_estim!("multinomial posterior_predict: n_draws must be >= 1");
4630 }
4631 model.validate()?;
4632 let x_dense = build_multinomial_predict_design(model, data)?;
4633 model.sample_replicate_classes(x_dense.view(), n_draws, seed)
4634}
4635
4636/// Predict posterior-mean class probabilities and integrated marginal
4637/// standard deviations for a saved multinomial model on fresh data, under the
4638/// best covariance definition the model can support (see
4639/// [`MultinomialSavedModel::predict_probabilities_with_se`]).
4640pub fn predict_multinomial_formula_with_se(
4641 model: &MultinomialSavedModel,
4642 data: &EncodedDataset,
4643) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
4644 model.validate()?;
4645 let x_dense = build_multinomial_predict_design(model, data)?;
4646 model.predict_probabilities_with_se(x_dense.view())
4647}
4648
4649/// The same pair under an EXPLICITLY named covariance definition.
4650///
4651/// The conditional arm is reachable by name rather than only as a fallback,
4652/// which is what lets the two modes be audited against each other: the
4653/// difference between the two bands on one fit IS the smoothing-parameter
4654/// uncertainty, and a gate that can only see the default cannot tell a
4655/// correction that is present from one that is zero.
4656pub fn predict_multinomial_formula_with_se_in_mode(
4657 model: &MultinomialSavedModel,
4658 data: &EncodedDataset,
4659 mode: InferenceCovarianceMode,
4660) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
4661 model.validate()?;
4662 let x_dense = build_multinomial_predict_design(model, data)?;
4663 model.predict_probabilities_with_se_in_mode(x_dense.view(), mode)
4664}
4665
4666#[derive(Debug, Clone)]
4667pub struct MultinomialPredictionIntervals {
4668 pub mean: Array2<f64>,
4669 pub standard_error: Array2<f64>,
4670 pub mean_lower: Array2<f64>,
4671 pub mean_upper: Array2<f64>,
4672 pub level: f64,
4673 /// Exactly which posterior covariance definition the spread — and therefore
4674 /// the band — was built from. The multinomial's counterpart of
4675 /// `PredictUncertaintyResult::covariance_source`; see
4676 /// [`MultinomialSavedModel::predict_probabilities_with_se_in_mode`].
4677 pub covariance_source: InferenceCovarianceMode,
4678}
4679
4680/// Build a central posterior interval around the integrated logistic-normal
4681/// posterior mean class probability. Both centre and spread come from the same
4682/// deterministic posterior integral; no plug-in/delta quantity enters the
4683/// centre.
4684///
4685/// # The band is built on the log-odds scale, not on the probability scale
4686///
4687/// The endpoints are `expit(logit(m) ± z·sd/(m(1−m)))`, which is the
4688/// `gam_predict::MeanIntervalMethod::TransformEta` construction this library
4689/// already prefers for every nonlinear link: build the symmetric interval where
4690/// the posterior is closest to Gaussian, then carry it through a MONOTONE map.
4691///
4692/// A symmetric `m ± z·sd` band on the probability scale, clamped into `[0, 1]`,
4693/// is wrong in two ways that both bite exactly where a class probability lives.
4694/// It is symmetric about `m` when the posterior of a bounded quantity is not —
4695/// the skew grows without bound as `m` approaches an endpoint — and the clamp
4696/// silently DELETES the part of the interval that fell outside, so a nominally
4697/// 95% band can carry materially less than 95% of the posterior while still
4698/// reporting `level = 0.95`. Transforming a log-odds interval has neither
4699/// property: `expit` is a bijection onto `(0, 1)`, so no mass is ever clipped
4700/// and the asymmetry is produced by the map rather than approximated away.
4701///
4702/// At `m = ½` the two constructions agree to first order (`d logit/dp = 4` and
4703/// the transform is locally affine), so this is not a re-scaling of well-posed
4704/// bands — it is a repair of the ones near the boundary.
4705pub fn predict_multinomial_formula_with_intervals(
4706 model: &MultinomialSavedModel,
4707 data: &EncodedDataset,
4708 level: f64,
4709) -> Result<MultinomialPredictionIntervals, EstimationError> {
4710 let source = if model.smoothing_correction_flat.is_some() {
4711 InferenceCovarianceMode::SmoothingCorrected
4712 } else {
4713 InferenceCovarianceMode::Conditional
4714 };
4715 predict_multinomial_formula_with_intervals_in_mode(model, data, level, source)
4716}
4717
4718/// The same band under an EXPLICITLY named covariance definition; see
4719/// [`predict_multinomial_formula_with_se_in_mode`].
4720pub fn predict_multinomial_formula_with_intervals_in_mode(
4721 model: &MultinomialSavedModel,
4722 data: &EncodedDataset,
4723 level: f64,
4724 covariance_source: InferenceCovarianceMode,
4725) -> Result<MultinomialPredictionIntervals, EstimationError> {
4726 if !(level.is_finite() && level > 0.0 && level < 1.0) {
4727 crate::bail_invalid_estim!(
4728 "multinomial prediction interval level must be finite and in (0, 1), got {level}"
4729 );
4730 }
4731 model.validate()?;
4732 let x_dense = build_multinomial_predict_design(model, data)?;
4733 let (mean, standard_error) =
4734 model.predict_probabilities_with_se_in_mode(x_dense.view(), covariance_source)?;
4735 let z = gam_math::probability::standard_normal_quantile(0.5 + 0.5 * level)
4736 .map_err(EstimationError::InvalidInput)?;
4737 let mut mean_lower = mean.clone();
4738 let mut mean_upper = mean.clone();
4739 for ((row, class), &se) in standard_error.indexed_iter() {
4740 let (lower, upper) = log_odds_interval(mean[[row, class]], se, z);
4741 mean_lower[[row, class]] = lower;
4742 mean_upper[[row, class]] = upper;
4743 }
4744 Ok(MultinomialPredictionIntervals {
4745 mean,
4746 standard_error,
4747 mean_lower,
4748 mean_upper,
4749 level,
4750 covariance_source,
4751 })
4752}
4753
4754/// `expit(logit(m) ± z·sd/(m(1−m)))`, the monotone-transform central interval
4755/// for a probability with posterior mean `m` and posterior standard deviation
4756/// `sd`.
4757///
4758/// The delta-method log-odds spread is `sd / (m(1−m))`; `m(1−m)` is the
4759/// Jacobian `dp/dlogit p` at `m`. The degenerate ends are handled where they
4760/// arise rather than by a clamp afterwards: at `m ∈ {0, 1}` the log-odds scale
4761/// does not exist, and a mean that has reached a vertex of the simplex with
4762/// spread `sd` has no interval this transform can express, so the symmetric
4763/// probability-scale band is the honest answer THERE and only there. `sd = 0`
4764/// is a point interval on either scale.
4765fn log_odds_interval(mean: f64, standard_error: f64, z: f64) -> (f64, f64) {
4766 if !(mean.is_finite() && standard_error.is_finite()) || standard_error <= 0.0 {
4767 return (mean, mean);
4768 }
4769 let jacobian = mean * (1.0 - mean);
4770 if !(mean > 0.0 && mean < 1.0) || jacobian <= f64::MIN_POSITIVE {
4771 return (
4772 (mean - z * standard_error).clamp(0.0, 1.0),
4773 (mean + z * standard_error).clamp(0.0, 1.0),
4774 );
4775 }
4776 let center = (mean / (1.0 - mean)).ln();
4777 let half_width = z * standard_error / jacobian;
4778 let expit = |value: f64| -> f64 {
4779 if value >= 0.0 {
4780 1.0 / (1.0 + (-value).exp())
4781 } else {
4782 let e = value.exp();
4783 e / (1.0 + e)
4784 }
4785 };
4786 (expit(center - half_width), expit(center + half_width))
4787}
4788
4789#[cfg(test)]
4790mod fisher_override_tests {
4791 use super::*;
4792
4793 use ndarray::Array3;
4794
4795 fn toy() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
4796 let n = 15;
4797 let p = 2;
4798 let k = 3;
4799 let design =
4800 Array2::<f64>::from_shape_fn(
4801 (n, p),
4802 |(i, j)| {
4803 if j == 0 { 1.0 } else { ((i + 2) as f64).cos() }
4804 },
4805 );
4806 let mut y = Array2::<f64>::zeros((n, k));
4807 for i in 0..n {
4808 y[[i, i % k]] = 1.0;
4809 }
4810 let penalty = Array2::<f64>::eye(p);
4811 // #2344: K per-class lambdas (reference class included).
4812 let lambdas = Array1::<f64>::from_elem(k, 0.5);
4813 (design, y, penalty, lambdas)
4814 }
4815
4816 #[test]
4817 fn fisher_override_none_reproduces_analytic() {
4818 // Issue #349: None override is exactly the analytic fit.
4819 let (design, y, penalty, lambdas) = toy();
4820 let mk = |over: Option<ndarray::ArrayView3<'_, f64>>| {
4821 fit_penalized_multinomial(MultinomialFitInputs {
4822 design: design.view(),
4823 y_one_hot: y.view(),
4824 penalty: penalty.view(),
4825 lambdas: lambdas.view(),
4826 row_weights: None,
4827 fisher_w_override: over,
4828 max_iter: 50,
4829 tol: 1.0e-9,
4830 resume_from: None,
4831 })
4832 .expect("fit must succeed")
4833 };
4834 let a = mk(None);
4835 let b = mk(None);
4836 for (x, z) in a
4837 .coefficients_active
4838 .iter()
4839 .zip(b.coefficients_active.iter())
4840 {
4841 assert_eq!(x, z);
4842 }
4843 }
4844
4845 #[test]
4846 fn exhausted_fixed_lambda_budget_is_typed_error_not_fit() {
4847 let (design, y, penalty, lambdas) = toy();
4848 let error = fit_penalized_multinomial(MultinomialFitInputs {
4849 design: design.view(),
4850 y_one_hot: y.view(),
4851 penalty: penalty.view(),
4852 lambdas: lambdas.view(),
4853 row_weights: None,
4854 fisher_w_override: None,
4855 max_iter: 0,
4856 tol: 1.0e-9,
4857 resume_from: None,
4858 })
4859 .expect_err("a zero-budget Newton solve must not mint a multinomial fit");
4860 assert!(matches!(
4861 error,
4862 EstimationError::FixedLambdaNewtonDidNotConverge {
4863 objective_value,
4864 checkpoint,
4865 ..
4866 } if objective_value.is_finite()
4867 && checkpoint.stage() == FixedLambdaSolverStage::MultinomialNewton
4868 && checkpoint.completed_iterations() == 0
4869 ));
4870 }
4871
4872 #[test]
4873 fn fixed_lambda_checkpoint_resume_matches_uninterrupted_solve() {
4874 let (design, y, penalty, lambdas) = toy();
4875 let interrupted = fit_penalized_multinomial(MultinomialFitInputs {
4876 design: design.view(),
4877 y_one_hot: y.view(),
4878 penalty: penalty.view(),
4879 lambdas: lambdas.view(),
4880 row_weights: None,
4881 fisher_w_override: None,
4882 max_iter: 1,
4883 tol: 1.0e-9,
4884 resume_from: None,
4885 })
4886 .expect_err("one Newton step must leave this coupled fit uncertified");
4887 let checkpoint = match interrupted {
4888 EstimationError::FixedLambdaNewtonDidNotConverge { checkpoint, .. } => checkpoint,
4889 other => panic!("unexpected interruption error: {other}"),
4890 };
4891 assert_eq!(
4892 checkpoint.stage(),
4893 FixedLambdaSolverStage::MultinomialNewton
4894 );
4895 assert_eq!(checkpoint.completed_iterations(), 1);
4896
4897 let resumed = fit_penalized_multinomial(MultinomialFitInputs {
4898 design: design.view(),
4899 y_one_hot: y.view(),
4900 penalty: penalty.view(),
4901 lambdas: lambdas.view(),
4902 row_weights: None,
4903 fisher_w_override: None,
4904 max_iter: 49,
4905 tol: 1.0e-9,
4906 resume_from: Some(&checkpoint),
4907 })
4908 .expect("resumed multinomial solve must converge");
4909 let uninterrupted = fit_penalized_multinomial(MultinomialFitInputs {
4910 design: design.view(),
4911 y_one_hot: y.view(),
4912 penalty: penalty.view(),
4913 lambdas: lambdas.view(),
4914 row_weights: None,
4915 fisher_w_override: None,
4916 max_iter: 50,
4917 tol: 1.0e-9,
4918 resume_from: None,
4919 })
4920 .expect("uninterrupted multinomial solve must converge");
4921
4922 assert_eq!(resumed.iterations, uninterrupted.iterations);
4923 assert_eq!(
4924 resumed.coefficients_active,
4925 uninterrupted.coefficients_active
4926 );
4927 assert_eq!(
4928 resumed.penalized_neg_log_likelihood,
4929 uninterrupted.penalized_neg_log_likelihood,
4930 );
4931 assert_eq!(
4932 resumed.coefficient_covariance,
4933 uninterrupted.coefficient_covariance,
4934 );
4935 }
4936
4937 #[test]
4938 fn fisher_override_wrong_shape_is_rejected() {
4939 let (design, y, penalty, lambdas) = toy();
4940 let n = design.nrows();
4941 let m = y.ncols(); // K, not K-1 — deliberately wrong
4942 let bad = Array3::<f64>::zeros((n, m, m));
4943 let err = fit_penalized_multinomial(MultinomialFitInputs {
4944 design: design.view(),
4945 y_one_hot: y.view(),
4946 penalty: penalty.view(),
4947 lambdas: lambdas.view(),
4948 row_weights: None,
4949 fisher_w_override: Some(bad.view()),
4950 max_iter: 50,
4951 tol: 1.0e-9,
4952 resume_from: None,
4953 })
4954 .expect_err("wrong active-block shape must error");
4955 assert!(format!("{err}").contains("fisher_w_override shape"));
4956 }
4957
4958 /// #1101 regression: the fixed-λ inner solve now surfaces the joint Laplace
4959 /// coefficient covariance `H⁻¹`, and the multinomial predictor derives
4960 /// finite delta-method per-class probability standard errors from it. Before
4961 /// this change `MultinomialFitOutputs` carried NO covariance at all, so the
4962 /// covariance-dimension / predictor assertions below could not even compile
4963 /// (fail-before). Asserts, with un-weakened bounds:
4964 /// 1. covariance is `(P·(K−1))²`, all-finite, symmetric, and PSD (every
4965 /// diagonal ≥ 0 and `vᵀΣv ≥ 0` on probe vectors);
4966 /// 2. the delta-method per-class probability SEs are finite and within
4967 /// `[0, 1]` (a probability SE can never exceed the unit interval);
4968 /// 3. predicted probabilities are finite, in `[0, 1]`, and each row sums
4969 /// to 1 (simplex).
4970 #[test]
4971 fn covariance_and_delta_method_se_are_finite_and_wellformed_1101() {
4972 let (design, y, penalty, lambdas) = toy();
4973 let p = design.ncols();
4974 let k = y.ncols();
4975 let m = k - 1;
4976 let d = p * m;
4977
4978 let fit = fit_penalized_multinomial(MultinomialFitInputs {
4979 design: design.view(),
4980 y_one_hot: y.view(),
4981 penalty: penalty.view(),
4982 lambdas: lambdas.view(),
4983 row_weights: None,
4984 fisher_w_override: None,
4985 max_iter: 50,
4986 tol: 1.0e-9,
4987 resume_from: None,
4988 })
4989 .expect("fit must succeed");
4990 // (1) Covariance shape, finiteness, symmetry.
4991 let cov = &fit.coefficient_covariance;
4992 assert_eq!(
4993 cov.dim(),
4994 (d, d),
4995 "covariance must be (P·(K−1))² = ({d},{d})"
4996 );
4997 for &v in cov.iter() {
4998 assert!(v.is_finite(), "covariance entry must be finite (got {v})");
4999 }
5000 for i in 0..d {
5001 for j in 0..d {
5002 let asym = (cov[[i, j]] - cov[[j, i]]).abs();
5003 assert!(
5004 asym <= 1e-9 * (1.0 + cov[[i, j]].abs()),
5005 "covariance must be symmetric at ({i},{j}): |Σ_ij − Σ_ji| = {asym:.3e}"
5006 );
5007 }
5008 }
5009 // PSD: diagonal ≥ 0 and quadratic forms on deterministic probe vectors
5010 // (unit axes and the all-ones vector) are non-negative. `H = XᵀWX + λS`
5011 // with W PSD (softmax Fisher) and S PSD (identity here) is positive
5012 // definite, so its inverse is PD; these probes must all be positive.
5013 for i in 0..d {
5014 assert!(
5015 cov[[i, i]] >= 0.0,
5016 "covariance diagonal[{i}] must be ≥ 0 (got {})",
5017 cov[[i, i]]
5018 );
5019 }
5020 let mut probes: Vec<Vec<f64>> = Vec::new();
5021 for i in 0..d {
5022 let mut e = vec![0.0_f64; d];
5023 e[i] = 1.0;
5024 probes.push(e);
5025 }
5026 probes.push(vec![1.0_f64; d]);
5027 for v in &probes {
5028 let mut q = 0.0_f64;
5029 for i in 0..d {
5030 for j in 0..d {
5031 q += v[i] * cov[[i, j]] * v[j];
5032 }
5033 }
5034 assert!(q >= -1e-9, "covariance must be PSD: vᵀΣv = {q:.3e} < 0");
5035 }
5036
5037 // (2) & (3) Delta-method SEs and simplex probabilities on the training
5038 // design (any P-column matrix in the fitted basis works).
5039 let (probs, prob_se) = fit
5040 .logistic_normal_softmax_moments(design.view())
5041 .expect("logistic-normal softmax moments must succeed");
5042 let n = design.nrows();
5043 assert_eq!(probs.dim(), (n, k));
5044 assert_eq!(prob_se.dim(), (n, k));
5045 for row in 0..n {
5046 let mut rowsum = 0.0_f64;
5047 for c in 0..k {
5048 let pc = probs[[row, c]];
5049 assert!(
5050 pc.is_finite() && (0.0..=1.0).contains(&pc),
5051 "prob[{row},{c}]={pc}"
5052 );
5053 rowsum += pc;
5054 let se = prob_se[[row, c]];
5055 assert!(
5056 se.is_finite(),
5057 "prob_se[{row},{c}] must be finite (got {se})"
5058 );
5059 assert!(
5060 (0.0..=1.0).contains(&se),
5061 "prob_se[{row},{c}] must be in [0,1] (got {se})"
5062 );
5063 }
5064 assert!(
5065 (rowsum - 1.0).abs() < 1e-9,
5066 "row {row} probabilities must sum to 1 (got {rowsum})"
5067 );
5068 }
5069 }
5070
5071 #[test]
5072 fn formula_outer_route_uses_exact_curvature_for_medium_d() {
5073 // The 2-smooth reference formula fit (K = 3, double-penalty terms)
5074 // carries 2 terms x 2 penalties = 4 components, and the equivariant
5075 // carrier gives each component one coordinate PER CLASS: D = 4 x 3 = 12.
5076 // It needs exact curvature to avoid over-smoothed lambda caps
5077 // (#715 arm (a)).
5078 assert!(
5079 multinomial_formula_use_outer_hessian(8),
5080 "D=8 loaded multinomial fits need exact curvature to avoid over-smoothed lambda caps"
5081 );
5082 assert!(
5083 multinomial_formula_use_outer_hessian(12),
5084 "D=12 (2 double-penalty smooth terms, K=3) stays on exact curvature"
5085 );
5086 }
5087
5088 #[test]
5089 fn formula_outer_route_uses_exact_curvature_for_the_penguin_fixture() {
5090 // Four k=10 penguin smooths (K = 3) are 8 double-penalty components and
5091 // therefore D = 8 x 3 = 24 outer coordinates. They must reach the exact
5092 // ARC route so the #1082 cost-stall halt is available on the
5093 // near-separable lambda-to-zero ridge.
5094 assert!(
5095 multinomial_formula_use_outer_hessian(24),
5096 "the four-smooth penguin fixture needs exact ARC curvature for the #1082 stall halt"
5097 );
5098 }
5099
5100 #[test]
5101 fn formula_min_lambda_floor_is_continuous_and_information_scaled() {
5102 // Build a one-hot label matrix whose smallest class carries `count` rows.
5103 fn floor_for_min_count(count: usize) -> f64 {
5104 // Two classes: a large one (1000 rows) and a minority one (`count`).
5105 let n = 1000 + count;
5106 let mut y = Array2::<f64>::zeros((n, 2));
5107 for r in 0..1000 {
5108 y[[r, 0]] = 1.0;
5109 }
5110 for r in 1000..n {
5111 y[[r, 1]] = 1.0;
5112 }
5113 multinomial_formula_min_lambda(y.view())
5114 }
5115
5116 // The floor's endpoints are now DERIVED from a target prior strength in
5117 // pseudo-observations against the maximal per-observation softmax Fisher
5118 // information I₁ = ¼ (base = τ·I₁, sparse = τ_max·I₁). Pin them to the
5119 // previously fixture-calibrated values so the near-separable quality arms
5120 // (penguins, vgam softmax) — whose smallest class has n_c ≥ 50 — are
5121 // byte-for-byte unaffected: the derivation REDUCES TO the old constants
5122 // at the calibration point.
5123 let base = MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
5124 let sparse = MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX
5125 * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
5126 assert!(
5127 (base - 2.0e-4).abs() < 1e-18,
5128 "derived base floor must equal the calibrated 2e-4"
5129 );
5130 assert!(
5131 (sparse - 1.0e-3).abs() < 1e-18,
5132 "derived sparse floor must equal the calibrated 1e-3"
5133 );
5134
5135 // Well-supported (n_c >= n_ref=50) sits exactly at the base floor.
5136 assert!((floor_for_min_count(50) - base).abs() < 1e-18);
5137 assert!((floor_for_min_count(200) - base).abs() < 1e-18);
5138 // Very sparse (n_c <= n_ref·base/sparse = 10) clamps to the strong floor.
5139 assert!((floor_for_min_count(10) - sparse).abs() < 1e-18);
5140 assert!((floor_for_min_count(5) - sparse).abs() < 1e-18);
5141 // No cliff at the old hard threshold: 49 vs 50 differ by < 5% (the old
5142 // step jumped 5x). Floor is monotone non-increasing in support.
5143 let f49 = floor_for_min_count(49);
5144 let f50 = floor_for_min_count(50);
5145 assert!(
5146 f49 >= f50 && f49 <= f50 * 1.05,
5147 "floor must be continuous across c0, got {f49} vs {f50}"
5148 );
5149 let f25 = floor_for_min_count(25);
5150 assert!(
5151 f25 > f50 && f25 < floor_for_min_count(10),
5152 "mid-support floor must interpolate strictly between the two endpoints"
5153 );
5154
5155 // FIRST-PRINCIPLES SCALING: in the interpolating regime the floor equals
5156 // exactly τ·I₁·(n_ref/n_c) — the effective-pseudo-observation prior held
5157 // to a fixed fraction of the per-class data information n_c·I₁. Halving
5158 // the effective sample size doubles the floor (until the cap), and the
5159 // absolute value matches the closed-form n_c-scaled prior.
5160 for &n_c in &[12usize, 16, 20, 30, 40] {
5161 let expected = base * (MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT / n_c as f64);
5162 assert!(
5163 (floor_for_min_count(n_c) - expected).abs() < 1e-15,
5164 "floor at n_c={n_c} must be τ·I₁·n_ref/n_c = {expected}, got {}",
5165 floor_for_min_count(n_c)
5166 );
5167 }
5168 // Inverse scaling with effective sample size: n_c -> n_c/2 doubles the
5169 // floor inside the unclamped band (20 and 40 are both interior; 40 < 50
5170 // so it is scaled, 20 > 10 so it is not capped).
5171 assert!(
5172 (floor_for_min_count(20) - 2.0 * floor_for_min_count(40)).abs() < 1e-15,
5173 "floor must scale like 1/n_c (effective Fisher information) in the interior band"
5174 );
5175 }
5176
5177 #[test]
5178 fn formula_penalty_scale_tracks_softmax_fisher_curvature() {
5179 assert!(
5180 (multinomial_formula_penalty_scale(2) - 0.5).abs() < 1.0e-12,
5181 "binary-logit neutral-simplex curvature scale should remain at 1/2"
5182 );
5183 assert!(
5184 (multinomial_formula_penalty_scale(3) - 4.0 / 9.0).abs() < 1.0e-12,
5185 "three-class softmax penalties should be calibrated to 2*(K-1)/K^2"
5186 );
5187 assert!(
5188 multinomial_formula_penalty_scale(5) < multinomial_formula_penalty_scale(3),
5189 "active-class Fisher curvature decreases as the simplex gains classes"
5190 );
5191 }
5192
5193 #[test]
5194 fn fixed_lambda_multinomial_firth_keeps_complete_separation_finite() {
5195 // #1854: complete softmax separation used to be a HARD diagnostic
5196 // (`MultinomialSeparationDetected`). It now automatically engages the
5197 // Firth/Jeffreys proper prior (`½ log|I(β)|`, magic-by-default) so the fit
5198 // stays finite instead of running away — the same guarantee the formula
5199 // REML path already provided. The class regions are cleanly separated by
5200 // `x`, so the unbiased MLE is at infinity; the Firth-penalized fit must
5201 // still converge to a finite mode and recover the region structure.
5202 let n = 90;
5203 let design = Array2::<f64>::from_shape_fn((n, 2), |(row, col)| match col {
5204 0 => 1.0,
5205 _ => -3.0 + 6.0 * (row as f64) / ((n - 1) as f64),
5206 });
5207 let mut y = Array2::<f64>::zeros((n, 3));
5208 for row in 0..n {
5209 let x = design[[row, 1]];
5210 let class = if x < -1.0 {
5211 0
5212 } else if x > 1.0 {
5213 1
5214 } else {
5215 2
5216 };
5217 y[[row, class]] = 1.0;
5218 }
5219 let penalty = Array2::<f64>::zeros((2, 2));
5220 // #2344: K per-class lambdas (reference class included); K = 3 here.
5221 let lambdas = Array1::<f64>::zeros(3);
5222 let out = fit_penalized_multinomial(MultinomialFitInputs {
5223 design: design.view(),
5224 y_one_hot: y.view(),
5225 penalty: penalty.view(),
5226 lambdas: lambdas.view(),
5227 row_weights: None,
5228 fisher_w_override: None,
5229 max_iter: 80,
5230 tol: 1.0e-12,
5231 resume_from: None,
5232 })
5233 .expect("Firth/Jeffreys prior keeps the separated multinomial fit finite (#1854)");
5234 // Every coefficient is finite — the whole point of the Firth prior on the
5235 // separated (unpenalized) logit directions.
5236 for &b in out.coefficients_active.iter() {
5237 assert!(
5238 b.is_finite(),
5239 "Firth-penalized coefficients must be finite, got {b}"
5240 );
5241 }
5242 // Fitted probabilities remain a valid simplex per row.
5243 for row in 0..n {
5244 let mut mass = 0.0_f64;
5245 for c in 0..3 {
5246 let p = out.fitted_probabilities[[row, c]];
5247 assert!(
5248 p.is_finite() && (0.0..=1.0 + 1e-9).contains(&p),
5249 "row {row} class {c} probability {p} out of [0,1]"
5250 );
5251 mass += p;
5252 }
5253 assert!(
5254 (mass - 1.0).abs() < 1e-6,
5255 "row {row} probabilities must sum to 1, got {mass}"
5256 );
5257 }
5258 // The finite fit still recovers the separated structure: on a clearly
5259 // interior representative of each region the predicted class is correct.
5260 let predict = |x: f64| -> usize {
5261 let mut eta = [0.0_f64; 3];
5262 for a in 0..2 {
5263 eta[a] = out.coefficients_active[[0, a]] + out.coefficients_active[[1, a]] * x;
5264 }
5265 let mut best = 0usize;
5266 for c in 1..3 {
5267 if eta[c] > eta[best] {
5268 best = c;
5269 }
5270 }
5271 best
5272 };
5273 assert_eq!(predict(-2.5), 0, "deep-left region should predict class 0");
5274 assert_eq!(predict(2.5), 1, "deep-right region should predict class 1");
5275 assert_eq!(predict(0.0), 2, "central region should predict class 2");
5276 }
5277
5278 #[test]
5279 fn formula_multinomial_accepts_finite_saturated_logits() {
5280 // A saturated-but-FINITE logit surface can be a valid formula REML mode
5281 // (the #715 penguins regime: bill/flipper cleanly separate the species,
5282 // so fitted logits can legitimately exceed ±25). `outer_converged ==
5283 // false` then signals only that the driver auto-escalated to never-fail
5284 // posterior sampling about that finite mode (gam#860), NOT a separation
5285 // artifact — the adapter must accept it, never raise
5286 // `MultinomialSeparationDetected`.
5287 let saturated_states = vec![
5288 ParameterBlockState {
5289 beta: Array1::from_vec(vec![1.0, 2.0]),
5290 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
5291 },
5292 ParameterBlockState {
5293 beta: Array1::from_vec(vec![-1.0, 3.0]),
5294 eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
5295 },
5296 ];
5297 assert!(
5298 multinomial_formula_separation_diagnostic(17, 9, &saturated_states).is_none(),
5299 "a finite (even saturated, |eta|>25) formula optimum is a valid fit, \
5300 not a separation diagnostic"
5301 );
5302
5303 // Only a genuinely NON-FINITE logit — a NaN/Inf blow-up in the inner
5304 // linear algebra with no finite mode to sample about — is a real
5305 // formula-path failure.
5306 let blown_up = vec![
5307 ParameterBlockState {
5308 beta: Array1::from_vec(vec![1.0, 2.0]),
5309 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
5310 },
5311 ParameterBlockState {
5312 beta: Array1::from_vec(vec![-1.0, 3.0]),
5313 eta: Array1::from_vec(vec![1.0, f64::INFINITY, -0.1]),
5314 },
5315 ];
5316 let err = multinomial_formula_separation_diagnostic(17, 9, &blown_up)
5317 .expect("a non-finite formula logit must raise the separation diagnostic");
5318 assert!(
5319 matches!(
5320 err,
5321 EstimationError::MultinomialSeparationDetected {
5322 iteration: 17,
5323 max_abs_eta,
5324 active_class_index: 1,
5325 row_index: 1,
5326 } if !max_abs_eta.is_finite()
5327 ),
5328 "expected typed multinomial separation diagnostic at the non-finite channel, got {err:?}"
5329 );
5330 }
5331
5332 #[test]
5333 fn separation_evidence_gate_arms_firth_only_on_blowup() {
5334 // Interior fit: finite logits well inside the saturation threshold ⇒ NO
5335 // separation evidence ⇒ the unbiased criterion's mode is accepted as-is
5336 // and the Firth/Jeffreys prior stays disarmed (#715 arm (a): no 1/K
5337 // shrinkage on well-identified data).
5338 let interior = vec![
5339 ParameterBlockState {
5340 beta: Array1::from_vec(vec![1.0, 2.0]),
5341 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
5342 },
5343 ParameterBlockState {
5344 beta: Array1::from_vec(vec![-1.0, 3.0]),
5345 eta: Array1::from_vec(vec![1.0, -3.5, -0.1]),
5346 },
5347 ];
5348 assert!(
5349 multinomial_formula_separation_evidence(&interior).is_none(),
5350 "an interior finite mode must not arm the Firth refit"
5351 );
5352
5353 // Saturated but finite logits are valid formula-path modes on
5354 // near-separated real data. They must not arm the Firth refit because
5355 // the Jeffreys pull can over-regularize the held-out probabilities.
5356 let saturated = vec![
5357 ParameterBlockState {
5358 beta: Array1::from_vec(vec![1.0, 2.0]),
5359 eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
5360 },
5361 ParameterBlockState {
5362 beta: Array1::from_vec(vec![-1.0, 3.0]),
5363 eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
5364 },
5365 ];
5366 assert!(
5367 multinomial_formula_separation_evidence(&saturated).is_none(),
5368 "a finite saturated formula-mode logit must not arm the Firth refit"
5369 );
5370
5371 // Non-finite logit ⇒ inner blow-up along an unbounded direction ⇒
5372 // separation evidence.
5373 let blown_up = vec![ParameterBlockState {
5374 beta: Array1::from_vec(vec![1.0, 2.0]),
5375 eta: Array1::from_vec(vec![0.2, f64::NAN, -7.0]),
5376 }];
5377 let evidence = multinomial_formula_separation_evidence(&blown_up)
5378 .expect("a non-finite logit is separation evidence");
5379 assert!(
5380 evidence.contains("non-finite logit") && evidence.contains("row 1"),
5381 "evidence must name the non-finite logit, got {evidence}"
5382 );
5383
5384 // Large finite logits below the fixed-lambda diagnostic threshold are
5385 // likewise accepted on the formula path.
5386 let near = vec![ParameterBlockState {
5387 beta: Array1::from_vec(vec![1.0, 2.0]),
5388 eta: Array1::from_vec(vec![0.2, 24.9, -24.9]),
5389 }];
5390 assert!(
5391 multinomial_formula_separation_evidence(&near).is_none(),
5392 "logits below the saturation threshold must not arm the Firth refit"
5393 );
5394 }
5395
5396 #[test]
5397 fn fisher_certificate_distinguishes_finite_quasi_separation_2612() {
5398 fn fixture(rows: usize, separated: bool) -> (MultinomialFamily, Vec<ParameterBlockState>) {
5399 let design = Arc::new(Array2::<f64>::from_shape_fn((rows, 2), |(row, column)| {
5400 if column == 0 {
5401 1.0
5402 } else {
5403 let side = if row % 2 == 0 { -1.0 } else { 1.0 };
5404 side * (1.0 + row as f64 / rows as f64)
5405 }
5406 }));
5407 let mut response = Array2::<f64>::zeros((rows, 3));
5408 for row in 0..rows {
5409 response[[row, row % 3]] = 1.0;
5410 }
5411 let family = MultinomialFamily::new(
5412 response,
5413 Array1::ones(rows),
5414 3,
5415 Arc::clone(&design),
5416 Arc::new(vec![PenaltyMatrix::Dense(Array2::eye(2))]),
5417 )
5418 .expect("finite three-class fixture");
5419 let slopes = if separated { [30.0, -30.0] } else { [0.0, 0.0] };
5420 let states = slopes
5421 .into_iter()
5422 .map(|slope| {
5423 let beta = Array1::from_vec(vec![0.0, slope]);
5424 let eta = design.dot(&beta);
5425 ParameterBlockState { beta, eta }
5426 })
5427 .collect();
5428 (family, states)
5429 }
5430
5431 let (separated_family, separated_states) = fixture(60, true);
5432 assert!(
5433 multinomial_formula_separation_evidence(&separated_states).is_none(),
5434 "finite logits alone deliberately carry no separation evidence"
5435 );
5436 let separated_specs = separated_family.build_block_specs();
5437 let separated_span = Array2::<f64>::eye(4);
5438 let evidence = multinomial_formula_penalized_separation_evidence(
5439 &separated_family,
5440 &separated_specs,
5441 &separated_states,
5442 separated_span.view(),
5443 &[],
5444 0,
5445 None,
5446 )
5447 .expect("exact Fisher certification")
5448 .expect("finite quasi-separation must arm the Firth refit");
5449 let evidence = evidence.evidence;
5450 assert!(
5451 evidence.contains("under-identified")
5452 && evidence.contains("lambda_min")
5453 && evidence.contains("Jeffreys gate weight"),
5454 "the certificate must report its authoritative spectrum, got {evidence}"
5455 );
5456
5457 let (identified_family, identified_states) = fixture(600, false);
5458 let identified_specs = identified_family.build_block_specs();
5459 let identified_span = Array2::<f64>::eye(4);
5460 assert!(
5461 multinomial_formula_penalized_separation_evidence(
5462 &identified_family,
5463 &identified_specs,
5464 &identified_states,
5465 identified_span.view(),
5466 &[],
5467 0,
5468 None,
5469 )
5470 .expect("exact Fisher certification")
5471 .is_none(),
5472 "a finite, well-identified Fisher geometry must keep Firth disarmed"
5473 );
5474 }
5475
5476 /// #2612: the certificate must judge the curvature the FIT HAS, not the
5477 /// curvature the likelihood alone supplies.
5478 ///
5479 /// The absolute conditioning gate fires when the worst-determined direction
5480 /// holds less than one observation-equivalent (`λ_min < 1`). A penalized
5481 /// direction routinely sits there — that is *why* it is penalized — so a
5482 /// certificate reading the bare Fisher information `H` calls every penalized
5483 /// smooth "separated". The discriminator: hold the data, the mode and the
5484 /// span fixed and supply ONLY the penalty the fit actually selected. If the
5485 /// verdict is unchanged the certificate is ignoring `S_λ`; if it flips to
5486 /// disarmed, `λ` was reaching that direction all along.
5487 ///
5488 /// This is a unit statement about the certificate, independent of any fit or
5489 /// optimizer, so it holds even if every end-to-end fixture changes shape.
5490 #[test]
5491 fn the_separation_certificate_counts_the_penalty_the_fit_selected_2612() {
5492 let rows = 400usize;
5493 // Column 1 is a deliberately weak direction: `s` scales its contribution
5494 // to `X'WX` by `s²`, so `λ_min(H)` lands well under one
5495 // observation-equivalent while the intercept stays strongly determined.
5496 let weak_scale = 2.0e-2_f64;
5497 let design = Arc::new(Array2::<f64>::from_shape_fn((rows, 2), |(row, column)| {
5498 if column == 0 {
5499 1.0
5500 } else {
5501 weak_scale * ((row as f64 / rows as f64) - 0.5)
5502 }
5503 }));
5504 let mut response = Array2::<f64>::zeros((rows, 3));
5505 for row in 0..rows {
5506 response[[row, row % 3]] = 1.0;
5507 }
5508 let family = MultinomialFamily::new(
5509 response,
5510 Array1::ones(rows),
5511 3,
5512 Arc::clone(&design),
5513 Arc::new(vec![PenaltyMatrix::Dense(Array2::eye(2))]),
5514 )
5515 .expect("weak-direction three-class fixture");
5516 let specs = family.build_block_specs();
5517 let states: Vec<ParameterBlockState> = (0..2)
5518 .map(|_| {
5519 let beta = Array1::from_vec(vec![0.0, 0.0]);
5520 let eta = design.dot(&beta);
5521 ParameterBlockState { beta, eta }
5522 })
5523 .collect();
5524 let span = Array2::<f64>::eye(4);
5525
5526 // Without the penalty the weak direction reads as separation.
5527 let unpenalized = multinomial_formula_penalized_separation_evidence(
5528 &family,
5529 &specs,
5530 &states,
5531 span.view(),
5532 &[],
5533 0,
5534 None,
5535 )
5536 .expect("certification")
5537 .expect(
5538 "the fixture must be one the LIKELIHOOD alone cannot identify, or this test proves \
5539 nothing",
5540 );
5541 let unpenalized = unpenalized.evidence;
5542 assert!(
5543 unpenalized.contains("lambda_min"),
5544 "the certificate must report the spectrum it decided on, got {unpenalized}"
5545 );
5546
5547 // The same mode, with a penalty on the weak direction worth many
5548 // observation-equivalents. `S_λ` is what the fit's own inner solve adds
5549 // to `H`, so the direction is determined and nothing is separated.
5550 let mut penalty = Array2::<f64>::zeros((4, 4));
5551 penalty[[1, 1]] = 1.0;
5552 penalty[[3, 3]] = 1.0;
5553 let spec = gam_problem::JointPenaltySpec {
5554 matrix: penalty,
5555 initial_log_lambda: 0.0,
5556 nullspace_dim: 2,
5557 label: None,
5558 group: None,
5559 };
5560 let selected = Array1::from_vec(vec![(1.0e3_f64).ln()]);
5561 let penalized = multinomial_formula_penalized_separation_evidence(
5562 &family,
5563 &specs,
5564 &states,
5565 span.view(),
5566 std::slice::from_ref(&spec),
5567 1,
5568 Some(&selected),
5569 )
5570 .expect("certification");
5571 assert!(
5572 penalized.is_none(),
5573 "a direction the selected smoothing parameter determines is not separation \
5574 evidence; the certificate still armed with S_lambda supplied: {penalized:?}"
5575 );
5576 }
5577
5578 #[test]
5579 fn scaled_fisher_override_changes_first_step() {
5580 // Curvature scaled by 4× shrinks the first Newton step relative to the
5581 // analytic fit, so a single-iteration fit must differ.
5582 let (design, y, penalty, lambdas) = toy();
5583 let n = design.nrows();
5584 let m = y.ncols() - 1;
5585 // #2344: toy() now carries K per-class lambdas for the multinomial
5586 // ENTRY; the direct Centered-metric ENGINE calls below read
5587 // M = lambdas.len(), so hand them the M-length shared-lambda vector.
5588 let engine_lambdas = Array1::<f64>::from_elem(m, lambdas[0]);
5589 // Analytic block at β = 0: p_a = 1/K = 1/3, so diag = p_a(1−p_a),
5590 // off-diag = −p_a p_b. Scale that exact block by 4.
5591 let pk = 1.0 / (y.ncols() as f64);
5592 let mut over = Array3::<f64>::zeros((n, m, m));
5593 for row in 0..n {
5594 for a in 0..m {
5595 for b in 0..m {
5596 let analytic = if a == b { pk * (1.0 - pk) } else { -pk * pk };
5597 over[[row, a, b]] = 4.0 * analytic;
5598 }
5599 }
5600 }
5601 let likelihood =
5602 MultinomialLogitLikelihood::with_classes(y.ncols()).expect("test class count is valid");
5603 let scaled = fit_penalized_vector_glm(
5604 PenalizedVectorGlmInputs {
5605 design: design.view(),
5606 y: y.view(),
5607 penalty: penalty.view(),
5608 lambdas: engine_lambdas.view(),
5609 fisher_w_override: Some(over.view()),
5610 max_iter: 1,
5611 tol: 1.0e-9,
5612 class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Centered,
5613 resume_from: None,
5614 },
5615 &likelihood,
5616 "multinomial scaled-curvature first-step test",
5617 )
5618 .expect("scaled-curvature engine step must be finite");
5619 let analytic = fit_penalized_vector_glm(
5620 PenalizedVectorGlmInputs {
5621 design: design.view(),
5622 y: y.view(),
5623 penalty: penalty.view(),
5624 lambdas: engine_lambdas.view(),
5625 fisher_w_override: None,
5626 max_iter: 1,
5627 tol: 1.0e-9,
5628 class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Centered,
5629 resume_from: None,
5630 },
5631 &likelihood,
5632 "multinomial analytic-curvature first-step test",
5633 )
5634 .expect("analytic-curvature engine step must be finite");
5635 let checkpoint_coefficients = |solve| match solve {
5636 VectorGlmSolve::Converged(fit) => fit.coefficients,
5637 VectorGlmSolve::Stalled(stall) => stall.coefficients,
5638 };
5639 let scaled = checkpoint_coefficients(scaled);
5640 let analytic = checkpoint_coefficients(analytic);
5641 let differs = scaled
5642 .iter()
5643 .zip(analytic.iter())
5644 .any(|(a, b)| (a - b).abs() > 1.0e-6);
5645 assert!(differs, "scaled curvature must change the first step");
5646 }
5647}
5648
5649#[cfg(test)]
5650mod separation_firth_tests {
5651 //! Regression for #1854: on (quasi-)perfect separation the fixed-λ direct
5652 //! multinomial solve must engage the Firth/Jeffreys penalty and return a
5653 //! finite, converged, well-behaved fit instead of hard-erroring with
5654 //! `MultinomialSeparationDetected`.
5655 use super::*;
5656
5657 /// A perfectly linearly separable 3-class problem with an UNPENALIZED design
5658 /// (`S = 0`), so no smoothing `λ` can bound the saturated logits — only the
5659 /// Firth prior `½ log det I(β)` keeps the estimate finite. The unbiased MLE
5660 /// here runs `|η| → ∞` (separation), which is exactly the #1854 trigger.
5661 fn separated_three_class() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
5662 let n = 21;
5663 let p = 2; // intercept + ordering covariate x
5664 let k = 3;
5665 let mut design = Array2::<f64>::zeros((n, p));
5666 let mut y = Array2::<f64>::zeros((n, k));
5667 for i in 0..n {
5668 let x = -3.0 + 6.0 * (i as f64) / ((n - 1) as f64);
5669 design[[i, 0]] = 1.0;
5670 design[[i, 1]] = x;
5671 let cls = if x < -1.0 {
5672 0
5673 } else if x < 1.0 {
5674 1
5675 } else {
5676 2
5677 };
5678 y[[i, cls]] = 1.0;
5679 }
5680 // S = 0: no smoothing direction can bound the separated logits.
5681 let penalty = Array2::<f64>::zeros((p, p));
5682 // #2344: K per-class lambdas (reference class included).
5683 let lambdas = Array1::<f64>::from_elem(k, 1.0);
5684 (design, y, penalty, lambdas)
5685 }
5686
5687 #[test]
5688 fn separation_engages_firth_finite_converged_fit() {
5689 let (design, y, penalty, lambdas) = separated_three_class();
5690 let out = fit_penalized_multinomial(MultinomialFitInputs {
5691 design: design.view(),
5692 y_one_hot: y.view(),
5693 penalty: penalty.view(),
5694 lambdas: lambdas.view(),
5695 row_weights: None,
5696 fisher_w_override: None,
5697 max_iter: 300,
5698 tol: 1e-10,
5699 resume_from: None,
5700 })
5701 .expect("separated multinomial must engage Firth and return a fit, not error");
5702
5703 assert!(
5704 out.coefficients_active.iter().all(|v| v.is_finite()),
5705 "all coefficients must be finite under the Firth prior"
5706 );
5707 assert!(out.deviance.is_finite(), "deviance must be finite");
5708
5709 // The runaway MLE would drive fitted probabilities to the {0,1} boundary;
5710 // the Firth prior keeps them strictly interior.
5711 for v in out.fitted_probabilities.iter() {
5712 assert!(
5713 *v > 0.0 && *v < 1.0,
5714 "Firth fit must stay interior, got p={v}"
5715 );
5716 }
5717
5718 // Perfect separation ⇒ every training row classified to its true class.
5719 let n = design.nrows();
5720 let k = y.ncols();
5721 for i in 0..n {
5722 let mut best = 0usize;
5723 for c in 1..k {
5724 if out.fitted_probabilities[[i, c]] > out.fitted_probabilities[[i, best]] {
5725 best = c;
5726 }
5727 }
5728 let truth = (0..k)
5729 .find(|&c| y[[i, c]] == 1.0)
5730 .expect("one-hot truth class");
5731 assert_eq!(best, truth, "row {i} misclassified under separation");
5732 }
5733 }
5734
5735 #[test]
5736 fn separation_firth_returns_finite_wellshaped_covariance() {
5737 // Distinct angle: the Firth separation path must also expose a finite,
5738 // correctly-shaped (P·M × P·M) Laplace coefficient covariance — the
5739 // downstream SE machinery consumes it. A runaway MLE would have a
5740 // singular (non-invertible) information here.
5741 let (design, y, penalty, lambdas) = separated_three_class();
5742 let p = design.ncols();
5743 let k = y.ncols();
5744 let m = k - 1;
5745 let out = fit_penalized_multinomial(MultinomialFitInputs {
5746 design: design.view(),
5747 y_one_hot: y.view(),
5748 penalty: penalty.view(),
5749 lambdas: lambdas.view(),
5750 row_weights: None,
5751 fisher_w_override: None,
5752 max_iter: 300,
5753 tol: 1e-10,
5754 resume_from: None,
5755 })
5756 .expect("separated multinomial must return a Firth fit");
5757
5758 assert_eq!(
5759 out.coefficient_covariance.dim(),
5760 (p * m, p * m),
5761 "covariance must be P·M square"
5762 );
5763 assert!(
5764 out.coefficient_covariance.iter().all(|v| v.is_finite()),
5765 "Firth covariance entries must be finite"
5766 );
5767 // A genuine Laplace covariance is PSD ⇒ non-negative diagonal.
5768 for i in 0..(p * m) {
5769 assert!(
5770 out.coefficient_covariance[[i, i]] >= -1e-9,
5771 "covariance diagonal must be non-negative, got {}",
5772 out.coefficient_covariance[[i, i]]
5773 );
5774 }
5775 }
5776
5777 #[test]
5778 fn firth_solver_rejects_a_truncated_iterate() {
5779 // #2066 / SPEC 20 (convergence honesty): the Firth Newton loop may only
5780 // construct a fit after certifying stationarity. Before the fix a
5781 // truncated solve returned coefficients and covariance behind a false
5782 // `converged` flag; now budget exhaustion is a typed error carrying the
5783 // iteration count and objective evidence.
5784 //
5785 // Angle: run the SAME separated problem that converges under a full
5786 // budget (`separation_engages_firth_finite_converged_fit`) but starve the
5787 // iteration budget so it provably cannot reach the interior Firth mode.
5788 // The honest outcome is a typed error, not an inspectable fit.
5789 let (design, y, penalty, lambdas) = separated_three_class();
5790
5791 let truncated = fit_penalized_multinomial_firth_fallback(
5792 design.view(),
5793 y.view(),
5794 penalty.view(),
5795 lambdas.view(),
5796 None,
5797 1, // one Newton iteration — far from the separated mode
5798 1e-12,
5799 None,
5800 )
5801 .expect_err("a one-iteration Firth solve must not mint a fit");
5802 let checkpoint = match truncated {
5803 EstimationError::FixedLambdaNewtonDidNotConverge {
5804 objective_value,
5805 stationarity,
5806 checkpoint,
5807 ..
5808 } => {
5809 assert!(objective_value.is_finite());
5810 assert_eq!(stationarity.kind, FixedLambdaResidualKind::NewtonDecrement);
5811 assert_eq!(checkpoint.stage(), FixedLambdaSolverStage::MultinomialFirth);
5812 assert_eq!(checkpoint.completed_iterations(), 1);
5813 checkpoint
5814 }
5815 other => panic!("unexpected Firth interruption error: {other}"),
5816 };
5817
5818 let resumed = fit_penalized_multinomial(MultinomialFitInputs {
5819 design: design.view(),
5820 y_one_hot: y.view(),
5821 penalty: penalty.view(),
5822 lambdas: lambdas.view(),
5823 row_weights: None,
5824 fisher_w_override: None,
5825 max_iter: 299,
5826 tol: 1e-10,
5827 resume_from: Some(&checkpoint),
5828 })
5829 .expect("Firth checkpoint must resume to the certified mode");
5830
5831 // Contrast: with a full budget the same problem does reach stationarity
5832 // and returns the convergence-only result type.
5833 let uninterrupted = fit_penalized_multinomial_firth_fallback(
5834 design.view(),
5835 y.view(),
5836 penalty.view(),
5837 lambdas.view(),
5838 None,
5839 300,
5840 1e-10,
5841 None,
5842 )
5843 .expect("Firth fallback must converge under a full budget");
5844 assert_eq!(resumed.iterations, uninterrupted.iterations);
5845 assert_eq!(
5846 resumed.coefficients_active,
5847 uninterrupted.coefficients_active
5848 );
5849 assert_eq!(
5850 resumed.penalized_neg_log_likelihood,
5851 uninterrupted.penalized_neg_log_likelihood,
5852 );
5853 assert_eq!(
5854 resumed.coefficient_covariance,
5855 uninterrupted.coefficient_covariance,
5856 );
5857 }
5858}
5859
5860#[cfg(test)]
5861mod reference_class_invariance_tests {
5862 //! Regression for #1587: a penalized multinomial-logit GAM fit must be
5863 //! invariant to which class is the (arbitrary) softmax reference/baseline.
5864 //!
5865 //! The production REML path (`fit_penalized_multinomial_formula`) reference-
5866 //! codes the `K` classes (the last sorted label is the baseline) and, with
5867 //! the legacy `Diagonal` penalty metric, penalizes only the `K−1`
5868 //! reference-anchored ALR contrasts `½ Σ_a λ_a β_aᵀ S β_a`. Relabeling the
5869 //! response so a *different* class sorts last penalizes a different frame of
5870 //! log-odds contrasts, so the predicted probabilities drift (~1e-2 absolute)
5871 //! even though they are mathematically independent of the reference choice.
5872 //!
5873 //! This test fits the SAME 3-class softmax sample under three cyclic
5874 //! relabelings — each making a different original class the baseline —
5875 //! realigns the predicted probability columns back to the original class
5876 //! identities, and asserts the cross-labeling drift is below `1e-3`
5877 //! (the defect is ~1e-2; refitting the same labeling twice agrees to
5878 //! ~1e-12). It is the Rust-level sibling of
5879 //! `tests/bug_hunt_multinomial_fit_depends_on_reference_class_test.py`.
5880
5881 use super::*;
5882 use gam_data::load_dataset_projected;
5883 use gam_linalg::faer_ndarray::FaerEigh;
5884 use std::fmt::Write as _;
5885 use std::fs;
5886 use tempfile::tempdir;
5887
5888 /// Deterministic `splitmix64` → `[0,1)` uniform stream (no external RNG dep;
5889 /// the only requirement is a well-distributed, reproducible draw).
5890 struct SplitMix64(u64);
5891 impl SplitMix64 {
5892 fn next_u64(&mut self) -> u64 {
5893 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
5894 let mut z = self.0;
5895 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
5896 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
5897 z ^ (z >> 31)
5898 }
5899 fn unit(&mut self) -> f64 {
5900 // 53-bit mantissa uniform in [0, 1).
5901 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
5902 }
5903 }
5904
5905 /// Draw a clean 3-class softmax regression sample (the issue's generator).
5906 /// Returns `(x, class)` with integer classes `0/1/2`.
5907 fn sample_classes(seed: u64, n: usize) -> (Vec<f64>, Vec<usize>) {
5908 let mut rng = SplitMix64(seed.wrapping_add(0x1234_5678));
5909 let mut x = Vec::with_capacity(n);
5910 let mut cls = Vec::with_capacity(n);
5911 for _ in 0..n {
5912 let xi = -2.0 + 4.0 * rng.unit();
5913 let eta = [0.5 + 0.8 * xi, -0.3 - 0.5 * xi, 0.0];
5914 let mut p = [eta[0].exp(), eta[1].exp(), eta[2].exp()];
5915 let s: f64 = p.iter().sum();
5916 for v in &mut p {
5917 *v /= s;
5918 }
5919 // Inverse-CDF draw into one of the 3 classes.
5920 let u = rng.unit();
5921 let c = if u < p[0] {
5922 0
5923 } else if u < p[0] + p[1] {
5924 1
5925 } else {
5926 2
5927 };
5928 x.push(xi);
5929 cls.push(c);
5930 }
5931 (x, cls)
5932 }
5933
5934 /// Build an `EncodedDataset` with columns `x` (numeric) and `y`
5935 /// (categorical, from the given string labels) by round-tripping a CSV.
5936 fn dataset_xy(
5937 dir: &std::path::Path,
5938 tag: &str,
5939 x: &[f64],
5940 y: &[String],
5941 ) -> gam_data::EncodedDataset {
5942 let path = dir.join(format!("data_{tag}.csv"));
5943 let mut csv = String::from("x,y\n");
5944 for (xi, yi) in x.iter().zip(y.iter()) {
5945 writeln!(csv, "{xi},{yi}").unwrap();
5946 }
5947 fs::write(&path, csv).expect("write training csv");
5948 load_dataset_projected(&path, &["x".to_string(), "y".to_string()])
5949 .expect("load training dataset")
5950 }
5951
5952 /// Fit `y ~ s(x)` under the relabeling `name_map` (original class `c` gets
5953 /// label `name_map[c]`), predict on `grid`, and return the predicted
5954 /// probabilities **realigned to the original class order** 0/1/2, shape
5955 /// `(grid.len(), 3)`.
5956 fn fit_predict_aligned(
5957 dir: &std::path::Path,
5958 tag: &str,
5959 x: &[f64],
5960 cls: &[usize],
5961 name_map: [&str; 3],
5962 grid: &[f64],
5963 ) -> Array2<f64> {
5964 let labels: Vec<String> = cls.iter().map(|&c| name_map[c].to_string()).collect();
5965 let train = dataset_xy(dir, tag, x, &labels);
5966 let config = FitConfig::default();
5967 let model = fit_penalized_multinomial_formula(&MultinomialFitRequest {
5968 init_lambda: 1.0,
5969 max_iter: 60,
5970 tol: 1e-6,
5971 ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
5972 })
5973 .expect("multinomial formula fit must succeed");
5974
5975 // Predict on the grid. The categorical `y` column is not needed for
5976 // prediction, but the schema is simplest if we supply a dummy.
5977 let grid_y: Vec<String> = grid.iter().map(|_| name_map[0].to_string()).collect();
5978 let grid_ds = dataset_xy(dir, &format!("{tag}_grid"), grid, &grid_y);
5979 let probs = predict_multinomial_formula(&model, &grid_ds)
5980 .expect("multinomial predict must succeed");
5981
5982 // `model.class_levels` is the sorted label order; the column for original
5983 // class `c` is at the rank of `name_map[c]` among the sorted labels.
5984 let mut sorted: Vec<&str> = name_map.to_vec();
5985 sorted.sort_unstable();
5986 let col_of_orig: Vec<usize> = (0..3)
5987 .map(|c| sorted.iter().position(|l| *l == name_map[c]).unwrap())
5988 .collect();
5989 // Sanity: the model's class_levels must match the sorted labels.
5990 assert_eq!(
5991 model.class_levels,
5992 sorted.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
5993 "class_levels must be the sorted label order"
5994 );
5995 let n = grid.len();
5996 let mut aligned = Array2::<f64>::zeros((n, 3));
5997 for r in 0..n {
5998 for c in 0..3 {
5999 aligned[[r, c]] = probs[[r, col_of_orig[c]]];
6000 }
6001 }
6002 aligned
6003 }
6004
6005 fn max_abs_diff(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
6006 a.iter()
6007 .zip(b.iter())
6008 .map(|(p, q)| (p - q).abs())
6009 .fold(0.0_f64, f64::max)
6010 }
6011
6012 /// #2579 diagnostic (zz_measure): print the λ that
6013 /// `multinomial_fit_is_invariant_to_reference_class_1587` actually selects
6014 /// in each of its three labelings.
6015 ///
6016 /// The `#1587` gate asserts that predicted probabilities are invariant to
6017 /// the reference class. On penguins every one of the sixteen selected λ is
6018 /// railed at `exp(EFFECTIVE_DF_CEILING) = 162754.79141900392` to the last
6019 /// bit, and all-λ-equal is invariant TRIVIALLY. If this fixture's λ are
6020 /// likewise all pinned to that same wall, the gate is currently satisfied by
6021 /// railing rather than by the `M⊗S_t` symmetry it was written to protect —
6022 /// which means any fix that frees λ (i.e. the `#2579` fix, teaching
6023 /// `effective_df_floor_rho_upper_bounds` the joint bundle) will trip it for
6024 /// a reason that is not a regression in the symmetry. Prints only; never
6025 /// asserts a bound.
6026 #[test]
6027 fn zz_measure_2579_reference_class_gate_lambdas() {
6028 let td = tempdir().expect("tempdir");
6029 let dir = td.path();
6030 let (x, cls) = sample_classes(0, 300);
6031 let wall = gam_custom_family::EFFECTIVE_DF_CEILING.exp();
6032
6033 for (tag, name_map) in [
6034 ("abc", ["A", "B", "C"]),
6035 ("bca", ["B", "C", "A"]),
6036 ("cab", ["C", "A", "B"]),
6037 ] {
6038 let labels: Vec<String> = cls.iter().map(|&c| name_map[c].to_string()).collect();
6039 let train = dataset_xy(dir, tag, &x, &labels);
6040 let config = FitConfig::default();
6041 let model = fit_penalized_multinomial_formula(&MultinomialFitRequest {
6042 init_lambda: 1.0,
6043 max_iter: 60,
6044 tol: 1e-6,
6045 ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
6046 })
6047 .expect("multinomial formula fit must succeed");
6048
6049 // "Railed" is bit-equality with the wall, exactly as the
6050 // `EFFECTIVE_DF_CEILING` doc defines it.
6051 let railed = model.lambdas.iter().filter(|l| **l == wall).count();
6052 eprintln!(
6053 "#2579 gate-lambda probe [{tag}]: n_lambda={} railed_at_exp12={}/{} \
6054 per_block={:?}",
6055 model.lambdas.len(),
6056 railed,
6057 model.lambdas.len(),
6058 model.lambdas_per_block
6059 );
6060 for (i, l) in model.lambdas.iter().enumerate() {
6061 eprintln!(
6062 "#2579 gate-lambda probe [{tag}]: lambda[{i}] = {l:.17e} ln = {:.12} \
6063 railed = {}",
6064 l.ln(),
6065 *l == wall
6066 );
6067 }
6068 }
6069 eprintln!("#2579 gate-lambda probe: wall = exp(12) = {wall:.17e}");
6070 }
6071
6072 // gam#1587: now that the reference-symmetric centered `M⊗S_t` joint penalty
6073 // is wired through the custom-family outer REML loop (per-eval
6074 // `JointPenaltyBundle` + outer penalty_coords/logdet/operator), the
6075 // production multinomial fit is invariant to the arbitrary reference class,
6076 // so this guard runs by default (the opt-in skip attribute it carried while
6077 // the fix was pending is also forbidden by the build.rs ban-scanner). It is
6078 // an end-to-end fit guard (a handful of full softmax `y ~ s(x)` fits) —
6079 // slower than a unit test but a true production-path regression.
6080 #[test]
6081 fn multinomial_fit_is_invariant_to_reference_class_1587() {
6082 let td = tempdir().expect("tempdir");
6083 let dir = td.path();
6084 // The reference-class drift is STRUCTURAL (it does not shrink with n, see
6085 // the issue table), so a modest n exposes it just as cleanly as n=900
6086 // while keeping this an affordable CI guard.
6087 let (x, cls) = sample_classes(0, 300);
6088 let grid: Vec<f64> = (0..7).map(|i| -1.5 + 3.0 * (i as f64) / 6.0).collect();
6089
6090 // Three labelings that each make a DIFFERENT original class the baseline
6091 // (the class whose label sorts LAST is the reference K−1):
6092 // ["A","B","C"] → ref = class 2
6093 // ["B","C","A"] → ref = class 1
6094 // ["C","A","B"] → ref = class 0
6095 let a = fit_predict_aligned(dir, "abc", &x, &cls, ["A", "B", "C"], &grid);
6096 let b = fit_predict_aligned(dir, "bca", &x, &cls, ["B", "C", "A"], &grid);
6097 let c = fit_predict_aligned(dir, "cab", &x, &cls, ["C", "A", "B"], &grid);
6098
6099 // Refitting the SAME labeling twice must agree to ~machine precision —
6100 // this isolates optimizer noise from the structural reference drift.
6101 let a2 = fit_predict_aligned(dir, "abc2", &x, &cls, ["A", "B", "C"], &grid);
6102 let refit_noise = max_abs_diff(&a, &a2);
6103 assert!(
6104 refit_noise < 1e-6,
6105 "refitting the same labeling must be deterministic (got {refit_noise:.3e})"
6106 );
6107
6108 let drift = max_abs_diff(&a, &b)
6109 .max(max_abs_diff(&a, &c))
6110 .max(max_abs_diff(&b, &c));
6111 assert!(
6112 drift < 1e-3,
6113 "predicted probabilities must be invariant to the reference class; \
6114 cross-labeling drift = {drift:.3e} (refit noise = {refit_noise:.3e})"
6115 );
6116 }
6117
6118 /// #2615 diagnostic (zz_measure): profile the REML criterion along ONE
6119 /// rank-1 null-space coordinate on the REAL penguins design, at the
6120 /// production fit's own rho, with and without the Jeffreys/Firth term.
6121 ///
6122 /// On non-separable linear data (`zz_measure_2615_nullspace_outer_gradient_fd`)
6123 /// REML has an interior optimum in this coordinate, so the effective-df
6124 /// floor is redundant there. Penguins is near-separable and the production
6125 /// fit rails every coordinate on its wall, so the question is what the
6126 /// criterion looks like when the wall is removed: an interior optimum
6127 /// (wall irrelevant, something else is wrong) or a monotone descent toward
6128 /// total collapse (marginal likelihood genuinely prefers killing the
6129 /// linear direction, which is the near-separation pathology the
6130 /// Jeffreys/Firth arm exists for). The second profile arms that term, so
6131 /// the two curves answer whether the floor is standing in for it.
6132 ///
6133 /// Prints only; never asserts a bound.
6134 #[test]
6135 fn zz_measure_2615_penguins_nullspace_criterion_profile() {
6136 const PENGUINS_CSV: &str = concat!(
6137 env!("CARGO_MANIFEST_DIR"),
6138 "/../../bench/datasets/penguins.csv"
6139 );
6140 let raw = match fs::read_to_string(PENGUINS_CSV) {
6141 Ok(text) => text,
6142 Err(err) => {
6143 eprintln!("#2615 penguins profile SKIPPED: {PENGUINS_CSV}: {err}");
6144 return;
6145 }
6146 };
6147 let mut lines = raw.lines();
6148 let header: Vec<&str> = lines
6149 .next()
6150 .expect("penguins header")
6151 .trim()
6152 .split(',')
6153 .collect();
6154 let idx = |name: &str| header.iter().position(|c| *c == name).expect("column");
6155 let (i_species, i_bl, i_bd, i_fl, i_bm) = (
6156 idx("species"),
6157 idx("bill_length_mm"),
6158 idx("bill_depth_mm"),
6159 idx("flipper_length_mm"),
6160 idx("body_mass_g"),
6161 );
6162 // The quality test's stride-3 TRAIN split, so this profiles the exact
6163 // design the reported numbers come from.
6164 let mut kept = 0usize;
6165 let mut csv =
6166 String::from("bill_length_mm,bill_depth_mm,flipper_length_mm,body_mass_g,species\n");
6167 for line in lines {
6168 let line = line.trim();
6169 if line.is_empty() {
6170 continue;
6171 }
6172 let f: Vec<&str> = line.split(',').collect();
6173 let num = |i: usize| f.get(i).and_then(|v| v.trim().parse::<f64>().ok());
6174 let (Some(bl), Some(bd), Some(fl), Some(bm)) =
6175 (num(i_bl), num(i_bd), num(i_fl), num(i_bm))
6176 else {
6177 continue;
6178 };
6179 let species = f[i_species].trim().trim_matches('"');
6180 if species.is_empty() || species == "NA" {
6181 continue;
6182 }
6183 if kept % 3 == 0 {
6184 kept += 1;
6185 continue; // held out by the quality test
6186 }
6187 kept += 1;
6188 writeln!(csv, "{bl},{bd},{fl},{bm},{species}").unwrap();
6189 }
6190 let td = tempdir().expect("tempdir");
6191 let path = td.path().join("penguins_train.csv");
6192 fs::write(&path, csv).expect("write penguins train csv");
6193 let cols: Vec<String> = [
6194 "bill_length_mm",
6195 "bill_depth_mm",
6196 "flipper_length_mm",
6197 "body_mass_g",
6198 "species",
6199 ]
6200 .iter()
6201 .map(|s| s.to_string())
6202 .collect();
6203 let train = load_dataset_projected(&path, &cols).expect("load penguins train");
6204
6205 let config = FitConfig::default();
6206 let request = MultinomialFitRequest {
6207 init_lambda: 1.0,
6208 max_iter: 100,
6209 tol: 1e-8,
6210 ..MultinomialFitRequest::new(
6211 &train,
6212 "species ~ s(bill_length_mm, k=10) + s(bill_depth_mm, k=10) \
6213 + s(flipper_length_mm, k=10) + s(body_mass_g, k=10)",
6214 &config,
6215 )
6216 };
6217 let parts = penalized_multinomial_formula_parts(&request)
6218 .expect("production formula parts must build");
6219 let mut probe_options = parts.options.clone();
6220 probe_options.compute_covariance = false;
6221
6222 // The production fit's own rho (every coordinate ON its
6223 // effective-df-floor wall), term-major with K=3 per-class copies:
6224 // t0 wiggle s1, t1 null s1, t2 wiggle s2, t3 null s2, ...
6225 const WALL: [f64; 8] = [
6226 8.498719656305731,
6227 -2.802688029911322,
6228 8.909882365327132,
6229 0.0313034971834482,
6230 8.69436831319073,
6231 -0.6515139527220641,
6232 8.897044748938537,
6233 -0.5549484578569235,
6234 ];
6235 let base: Vec<f64> = WALL.iter().flat_map(|&r| [r, r, r]).collect();
6236 assert_eq!(base.len(), 24, "4 smooths x 2 penalties x 3 classes");
6237
6238 // The null-space coordinate's own optimum is CONDITIONAL on how hard the
6239 // wiggliness half is smoothed: an unsmoothed range space can absorb a
6240 // linear trend over a bounded interval, which makes the null-space
6241 // direction redundant and its lambda uninformative. The production
6242 // search seeds at rho_wiggle ~ 1 and only reaches the wiggliness wall
6243 // late, so sweep that dependence explicitly with the null coordinate
6244 // held ON its wall -- the sign of g[3] there decides whether the
6245 // projector is right to pin it.
6246 eprintln!("#2615 penguins g[3] at the null-space WALL, as a function of rho_wiggle");
6247 for step in 0..8 {
6248 let rho_w = -4.0 + 2.0 * step as f64;
6249 let mut rho = base.clone();
6250 for (t, chunk) in rho.chunks_mut(3).enumerate() {
6251 if t % 2 == 0 {
6252 for v in chunk {
6253 *v = rho_w;
6254 }
6255 }
6256 }
6257 let fam = parts
6258 .family
6259 .clone()
6260 .with_joint_initial_log_lambdas(rho.clone());
6261 match crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6262 &fam,
6263 &parts.blocks,
6264 &probe_options,
6265 &ndarray::Array1::from(rho.clone()),
6266 gam_problem::EvalMode::ValueAndGradient,
6267 ) {
6268 Ok(d) => eprintln!(
6269 "#2615 rho_wiggle={rho_w:+.1} V={:.9e} g[3]={:+.9e} g[0]={:+.6e}",
6270 d.objective, d.gradient[3], d.gradient[0]
6271 ),
6272 Err(e) => eprintln!(
6273 "#2615 rho_wiggle={rho_w:+.1} REFUSED: {}",
6274 format!("{e}").chars().take(200).collect::<String>()
6275 ),
6276 }
6277 }
6278
6279 for firth in [false, true] {
6280 eprintln!(
6281 "#2615 penguins null-space profile (coordinate 3 = null space of s(bill_length), \
6282 jeffreys={firth})"
6283 );
6284 for step in 0..12 {
6285 let rho_n = -8.0 + 2.0 * step as f64;
6286 let mut rho = base.clone();
6287 for c in 3..6 {
6288 rho[c] = rho_n;
6289 }
6290 let mut fam = parts
6291 .family
6292 .clone()
6293 .with_joint_initial_log_lambdas(rho.clone());
6294 if firth {
6295 fam = fam.with_joint_jeffreys_term(true);
6296 }
6297 match crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6298 &fam,
6299 &parts.blocks,
6300 &probe_options,
6301 &ndarray::Array1::from(rho.clone()),
6302 gam_problem::EvalMode::ValueAndGradient,
6303 ) {
6304 Ok(d) => eprintln!(
6305 "#2615 rho_null={rho_n:+.1} V={:.9e} g[3]={:+.6e} \
6306 g[0]={:+.6e} inner_conv={}",
6307 d.objective, d.gradient[3], d.gradient[0], d.inner_converged
6308 ),
6309 Err(e) => eprintln!(
6310 "#2615 rho_null={rho_n:+.1} REFUSED: {}",
6311 format!("{e}").chars().take(200).collect::<String>()
6312 ),
6313 }
6314 }
6315 }
6316 }
6317
6318 /// #2612 discriminator (zz_measure): does the outer REML criterion's ANALYTIC
6319 /// gradient agree with the criterion it reports, once the Jeffreys/Firth term
6320 /// is armed?
6321 ///
6322 /// The armed refit fails with `line_search=StepSizeTooSmall` — "the direction
6323 /// descended but no step improved the objective" — at a point whose analytic
6324 /// outer Hessian is indefinite. A correct gradient makes that impossible: a
6325 /// short enough step along `−g` must decrease a differentiable objective. So
6326 /// either the criterion is not differentiable there or the gradient is not
6327 /// its gradient, and central finite differences separate the two.
6328 ///
6329 /// Both criteria are profiled at the SAME rho so the comparison is
6330 /// unbiased-vs-armed rather than point-vs-point.
6331 ///
6332 /// Prints only; never asserts a bound.
6333 #[test]
6334 fn zz_measure_2612_penguins_firth_outer_gradient_fd() {
6335 let td = tempdir().expect("tempdir");
6336 let Some(train) = penguins_stride3_train(&td) else {
6337 eprintln!("#2612 penguins outer-gradient FD SKIPPED: dataset unavailable");
6338 return;
6339 };
6340 let config = FitConfig::default();
6341 let request = MultinomialFitRequest {
6342 init_lambda: 1.0,
6343 max_iter: 100,
6344 tol: 1e-8,
6345 ..MultinomialFitRequest::new(
6346 &train,
6347 "species ~ s(bill_length_mm, k=10) + s(bill_depth_mm, k=10) \
6348 + s(flipper_length_mm, k=10) + s(body_mass_g, k=10)",
6349 &config,
6350 )
6351 };
6352 let parts = penalized_multinomial_formula_parts(&request)
6353 .expect("production formula parts must build");
6354 let mut probe_options = parts.options.clone();
6355 probe_options.compute_covariance = false;
6356
6357 // The rho the Jeffreys-armed refit stalls at (its own reported
6358 // `last_evaluated_rho`), so the FD is taken where the line search failed.
6359 const STALL_RHO: [f64; 24] = [
6360 -0.03913583685376737,
6361 4.944294591230679,
6362 8.498719656305731,
6363 -2.802688029911322,
6364 -2.802688029911322,
6365 -2.802688029911322,
6366 8.909882365327132,
6367 8.909882365327132,
6368 8.909882365327132,
6369 -1.0331096452357313,
6370 0.0313034971834482,
6371 -8.433811582477187,
6372 8.69436831319073,
6373 8.69436831319073,
6374 8.69436831319073,
6375 -0.6515139527220641,
6376 -0.6515139527220641,
6377 -0.6515139527220641,
6378 8.897044748938537,
6379 6.638496386409038,
6380 6.954188147738595,
6381 -0.5549484578569235,
6382 -8.433811582477187,
6383 -0.9368158566294944,
6384 ];
6385
6386 for armed in [false, true] {
6387 let rho = ndarray::Array1::from(STALL_RHO.to_vec());
6388 let build = |rho: &ndarray::Array1<f64>| {
6389 let mut fam = parts
6390 .family
6391 .clone()
6392 .with_joint_initial_log_lambdas(rho.to_vec());
6393 if armed {
6394 fam = fam.with_joint_jeffreys_term(true);
6395 }
6396 fam
6397 };
6398 let evaluate = |rho: &ndarray::Array1<f64>, mode| {
6399 crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6400 &build(rho),
6401 &parts.blocks,
6402 &probe_options,
6403 rho,
6404 mode,
6405 )
6406 };
6407 let base = match evaluate(&rho, gam_problem::EvalMode::ValueAndGradient) {
6408 Ok(d) => d,
6409 Err(e) => {
6410 eprintln!(
6411 "#2612 FD (jeffreys={armed}) base REFUSED: {}",
6412 format!("{e}").chars().take(300).collect::<String>()
6413 );
6414 continue;
6415 }
6416 };
6417 eprintln!(
6418 "#2612 FD (jeffreys={armed}) V={:.12e} inner_conv={} |g|inf={:.3e}",
6419 base.objective,
6420 base.inner_converged,
6421 base.gradient.iter().fold(0.0_f64, |a, v| a.max(v.abs())),
6422 );
6423 // Coordinates the stall report named as carrying the residual
6424 // gradient, plus one wiggliness coordinate as a control.
6425 for k in [0usize, 1, 4, 9, 10, 11, 20, 23] {
6426 let mut worst = String::new();
6427 for h in [1e-3_f64, 1e-2] {
6428 let mut plus = rho.clone();
6429 plus[k] += h;
6430 let mut minus = rho.clone();
6431 minus[k] -= h;
6432 let (vp, vm) = (
6433 evaluate(&plus, gam_problem::EvalMode::ValueOnly),
6434 evaluate(&minus, gam_problem::EvalMode::ValueOnly),
6435 );
6436 match (vp, vm) {
6437 (Ok(p), Ok(m)) => {
6438 let fd = (p.objective - m.objective) / (2.0 * h);
6439 let g = base.gradient[k];
6440 let denom = g.abs().max(fd.abs()).max(1e-12);
6441 write!(
6442 worst,
6443 " h={h:.0e}: fd={fd:+.6e} analytic={g:+.6e} \
6444 rel={:.2e} inner=({},{})",
6445 (fd - g).abs() / denom,
6446 p.inner_converged,
6447 m.inner_converged
6448 )
6449 .unwrap();
6450 }
6451 (p, m) => {
6452 write!(
6453 worst,
6454 " h={h:.0e}: REFUSED (plus_ok={}, minus_ok={})",
6455 p.is_ok(),
6456 m.is_ok()
6457 )
6458 .unwrap();
6459 }
6460 }
6461 }
6462 eprintln!("#2612 FD (jeffreys={armed}) coord {k:2}:{worst}");
6463 }
6464 }
6465 }
6466
6467 /// #2612 discriminator (zz_measure): at the point the armed refit now stops
6468 /// at, is the negative curvature the terminal certificate reports a property
6469 /// of the CRITERION, or of the analytic Hessian that reports it?
6470 ///
6471 /// With the mode-response operator fixed the armed refit reaches
6472 /// `|Pg| = 1.276e-3` against `bound = 2.290e-3` and BFGS terminates on its
6473 /// gradient tolerance — stationarity is certified. What refuses is the
6474 /// second-order conjunct: `interior lambda_min = -1.128e-2` against
6475 /// `gradient_floor = 9.616e-4`. The outer Hessian for a Jeffreys-armed family
6476 /// is knowingly incomplete (the `D2_beta H_Phi` term needs third directional
6477 /// derivatives no family exposes), so that verdict has two readings and they
6478 /// call for opposite work:
6479 ///
6480 /// * the criterion really does fall along that eigenvector, and the SEARCH
6481 /// stopped early at a saddle — the optimizer owes an escape;
6482 /// * the criterion rises along it, and the CERTIFICATE is reading a matrix
6483 /// that is not the criterion's curvature — the Hessian owes a repair.
6484 ///
6485 /// The discriminator does not need an exact Hessian: walk the criterion
6486 /// itself along the reported minimum eigenvector and read whether the value
6487 /// goes down. Prints only; never asserts a bound.
6488 #[test]
6489 fn zz_measure_2612_penguins_terminal_negative_curvature_is_real() {
6490 let td = tempdir().expect("tempdir");
6491 let Some(train) = penguins_stride3_train(&td) else {
6492 eprintln!("#2612 penguins curvature probe SKIPPED: dataset unavailable");
6493 return;
6494 };
6495 let config = FitConfig::default();
6496 let request = MultinomialFitRequest {
6497 init_lambda: 1.0,
6498 max_iter: 100,
6499 tol: 1e-8,
6500 ..MultinomialFitRequest::new(
6501 &train,
6502 "species ~ s(bill_length_mm, k=10) + s(bill_depth_mm, k=10) \
6503 + s(flipper_length_mm, k=10) + s(body_mass_g, k=10)",
6504 &config,
6505 )
6506 };
6507 let parts = penalized_multinomial_formula_parts(&request)
6508 .expect("production formula parts must build");
6509 let mut probe_options = parts.options.clone();
6510 probe_options.compute_covariance = false;
6511
6512 // The rho the armed refit terminates at, from its own refusal report.
6513 const TERMINAL_RHO: [f64; 24] = [
6514 0.5479760615083404,
6515 4.953765284885532,
6516 8.498719656305731,
6517 -2.802688029911322,
6518 -2.995598248166518,
6519 -2.802688029911322,
6520 8.909882365327132,
6521 8.909882365327132,
6522 1.9116946378497857,
6523 -1.058535206379601,
6524 0.0313034971834482,
6525 -8.433811582477187,
6526 8.69436831319073,
6527 8.69436831319073,
6528 8.69436831319073,
6529 -0.6515139527220641,
6530 -0.6515139527220641,
6531 -0.6515139527220641,
6532 8.897044748938537,
6533 6.570949527783591,
6534 7.234627505361151,
6535 -0.5549484578569235,
6536 -8.433811582477187,
6537 -0.9887101040185263,
6538 ];
6539 let rho = ndarray::Array1::from(TERMINAL_RHO.to_vec());
6540 let evaluate = |rho: &ndarray::Array1<f64>, mode| {
6541 let fam = parts
6542 .family
6543 .clone()
6544 .with_joint_jeffreys_term(true)
6545 .with_joint_initial_log_lambdas(rho.to_vec());
6546 crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6547 &fam,
6548 &parts.blocks,
6549 &probe_options,
6550 rho,
6551 mode,
6552 )
6553 };
6554
6555 let base = match evaluate(&rho, gam_problem::EvalMode::ValueGradientHessian) {
6556 Ok(d) => d,
6557 Err(e) => {
6558 eprintln!(
6559 "#2612 curvature probe base REFUSED: {}",
6560 format!("{e}").chars().take(300).collect::<String>()
6561 );
6562 return;
6563 }
6564 };
6565 let Some(hessian) = base.outer_hessian.clone() else {
6566 eprintln!("#2612 curvature probe: no analytic outer Hessian was materialized");
6567 return;
6568 };
6569 eprintln!(
6570 "#2612 curvature probe: V={:.12e} |g|inf={:.3e} inner_conv={}",
6571 base.objective,
6572 base.gradient.iter().fold(0.0_f64, |a, v| a.max(v.abs())),
6573 base.inner_converged,
6574 );
6575
6576 // The certificate's curvature verdict is taken on the INTERIOR sub-block
6577 // — the free (un-railed) coordinates only — so the direction to walk is
6578 // that block's minimum eigenvector, embedded with zeros on the railed
6579 // ones. Walking the FULL Hessian's minimum eigenvector instead measures
6580 // a direction the box blocks, and reads back the criterion's first-order
6581 // slope against the rail rather than its curvature on the face.
6582 //
6583 // The railed set is the one the refit's own refusal reported.
6584 const RAILED: [usize; 18] = [
6585 2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 18, 21, 22, 23,
6586 ];
6587 let free: Vec<usize> = (0..hessian.nrows())
6588 .filter(|k| !RAILED.contains(k))
6589 .collect();
6590 let mut sub = ndarray::Array2::<f64>::zeros((free.len(), free.len()));
6591 for (i, &ri) in free.iter().enumerate() {
6592 for (j, &rj) in free.iter().enumerate() {
6593 sub[[i, j]] = 0.5 * (hessian[[ri, rj]] + hessian[[rj, ri]]);
6594 }
6595 }
6596 eprintln!(
6597 "#2612 curvature probe: free coordinates {free:?} gradient there = {:?}",
6598 free.iter()
6599 .map(|&k| format!("{:+.4e}", base.gradient[k]))
6600 .collect::<Vec<_>>()
6601 );
6602 let (evals, evecs) = match sub.eigh(faer::Side::Lower) {
6603 Ok(pair) => pair,
6604 Err(e) => {
6605 eprintln!("#2612 curvature probe: interior-block eigendecomposition failed: {e}");
6606 return;
6607 }
6608 };
6609 let mut order: Vec<usize> = (0..evals.len()).collect();
6610 order.sort_by(|a, b| evals[*a].partial_cmp(&evals[*b]).expect("finite"));
6611 eprintln!(
6612 "#2612 curvature probe: interior sub-block spectrum = {:?}",
6613 order
6614 .iter()
6615 .map(|&i| format!("{:.4e}", evals[i]))
6616 .collect::<Vec<_>>()
6617 );
6618
6619 // Walk the criterion along the interior block's minimum eigenvector. A
6620 // genuine negative-curvature direction lowers the value on BOTH sides.
6621 let mut direction = ndarray::Array1::<f64>::zeros(hessian.nrows());
6622 for (i, &k) in free.iter().enumerate() {
6623 direction[k] = evecs[[i, order[0]]];
6624 }
6625 for step in [1e-3_f64, 1e-2, 1e-1, 5e-1] {
6626 let mut readings = Vec::new();
6627 for sign in [1.0_f64, -1.0] {
6628 let trial = &rho + &(&direction * (sign * step));
6629 match evaluate(&trial, gam_problem::EvalMode::ValueOnly) {
6630 Ok(d) => readings.push(format!(
6631 "{sign:+.0}: dV={:+.6e} inner_conv={}",
6632 d.objective - base.objective,
6633 d.inner_converged
6634 )),
6635 Err(e) => readings.push(format!(
6636 "{sign:+.0}: REFUSED {}",
6637 format!("{e}").chars().take(120).collect::<String>()
6638 )),
6639 }
6640 }
6641 let predicted = 0.5 * evals[order[0]] * step * step;
6642 eprintln!(
6643 "#2612 curvature probe: step={step:.0e} predicted dV={predicted:+.6e} {}",
6644 readings.join(" ")
6645 );
6646 }
6647
6648 // The SHARPER instrument, and the one the verdict is actually about.
6649 //
6650 // The value walk above second-differences the criterion, so its noise is
6651 // `2·ε_V/h²`: at `λ_min ~ 1e-3` and `h = 1e-3` the signal is `5e-10`,
6652 // which is under any plausible `ε_V`. Central differences of the
6653 // ANALYTIC gradient are a FIRST difference of an exact quantity, so the
6654 // same `h` buys two more orders, and the quantity they produce —
6655 // `vᵀ J(g) v` — is exactly what `vᵀ H v` claims to be. Any disagreement
6656 // between the two is `‖δH‖₂` along the direction that decided, measured
6657 // rather than assumed (SPEC 2 keeps this in a test, which is where it
6658 // belongs: it is evidence about the assembly, not a production
6659 // derivative).
6660 //
6661 // The armed Hessian is KNOWN to be incomplete — `D²_β H_Φ[−v_l, −v_k]`
6662 // is not folded in, because `H_Φ` is a divided-difference object whose
6663 // first β-derivative already consumes the family's second directional
6664 // derivatives and a second would need the third (see
6665 // `JeffreysHphiAwareJointDerivatives`). This measures how much that
6666 // costs on the direction the certificate refuses on, at production
6667 // scale, rather than on the seconds-scale synthetic fixture.
6668 let analytic_curvature = evals[order[0]];
6669 let mut measured_curvatures: Vec<f64> = Vec::new();
6670 for h in [1e-4_f64, 1e-3, 1e-2] {
6671 let plus = &rho + &(&direction * h);
6672 let minus = &rho - &(&direction * h);
6673 let (up, down) = (
6674 evaluate(&plus, gam_problem::EvalMode::ValueAndGradient),
6675 evaluate(&minus, gam_problem::EvalMode::ValueAndGradient),
6676 );
6677 match (up, down) {
6678 (Ok(up), Ok(down)) => {
6679 let measured = (&up.gradient - &down.gradient).dot(&direction) / (2.0 * h);
6680 let gap = (analytic_curvature - measured).abs();
6681 eprintln!(
6682 "#2612 curvature probe: h={h:.0e} v'Hv(analytic)={analytic_curvature:+.6e} \
6683 v'J(g)v(measured)={measured:+.6e} |gap|={gap:.6e} \
6684 gap/|analytic|={:.3e} inner_conv={}/{}",
6685 gap / analytic_curvature.abs().max(f64::MIN_POSITIVE),
6686 up.inner_converged,
6687 down.inner_converged,
6688 );
6689 measured_curvatures.push(measured);
6690 }
6691 (up, down) => eprintln!(
6692 "#2612 curvature probe: h={h:.0e} directional gradient difference \
6693 unavailable (+ok={} -ok={})",
6694 up.is_ok(),
6695 down.is_ok(),
6696 ),
6697 }
6698 }
6699 // The one BAR this file carries, and it is the file's own title: is the
6700 // reported negative curvature REAL?
6701 //
6702 // Everything above prints. This asserts the single thing the terminal
6703 // certificate's whole verdict rests on — that the analytic Hessian and
6704 // the criterion agree about the SIGN of the direction that decides —
6705 // at production scale, where the armed gate in
6706 // `multinomial_jeffreys_outer_gradient_fd_2612` cannot reach (it runs a
6707 // seconds-scale synthetic fixture).
6708 //
6709 // Measured here: analytic `-6.709810e-5` against `-9.248844e-5`, stable
6710 // to five digits across `h` spanning two decades, so the `3.784e-1`
6711 // relative gap is the omitted `D²_β H_Φ` term and not FD noise. That gap
6712 // EXCEEDS the `0.25` bar the armed gate applies to `λ_min` on its own
6713 // fixture, which is the finding; the sign, which is what the certificate
6714 // spends, holds. A sign flip is the #2665 failure (`-1721.5` analytic
6715 // against `+121.6` measured), and it is what this refuses to let recur
6716 // unnoticed.
6717 assert!(
6718 !measured_curvatures.is_empty(),
6719 "no directional gradient difference could be taken, so the sign bar below was never \
6720 evaluated -- that is absence of coverage, not a pass"
6721 );
6722 for measured in &measured_curvatures {
6723 assert_eq!(
6724 measured.is_sign_negative(),
6725 analytic_curvature.is_sign_negative(),
6726 "the analytic curvature {analytic_curvature:+.6e} on the direction the \
6727 certificate judges disagrees in SIGN with the criterion's own \
6728 {measured:+.6e}. The terminal certificate's entire second-order verdict is \
6729 that sign (#2665)."
6730 );
6731 }
6732 }
6733
6734 /// The quality arm's stride-3 penguins TRAIN split, materialized through the
6735 /// production dataset loader. `None` when the checked-in CSV is unavailable.
6736 fn penguins_stride3_train(td: &tempfile::TempDir) -> Option<gam_data::EncodedDataset> {
6737 const PENGUINS_CSV: &str = concat!(
6738 env!("CARGO_MANIFEST_DIR"),
6739 "/../../bench/datasets/penguins.csv"
6740 );
6741 let raw = match fs::read_to_string(PENGUINS_CSV) {
6742 Ok(text) => text,
6743 Err(err) => {
6744 eprintln!("penguins CSV unavailable: {PENGUINS_CSV}: {err}");
6745 return None;
6746 }
6747 };
6748 let mut lines = raw.lines();
6749 let header: Vec<&str> = lines
6750 .next()
6751 .expect("penguins header")
6752 .trim()
6753 .split(',')
6754 .collect();
6755 let idx = |name: &str| header.iter().position(|c| *c == name).expect("column");
6756 let (i_species, i_bl, i_bd, i_fl, i_bm) = (
6757 idx("species"),
6758 idx("bill_length_mm"),
6759 idx("bill_depth_mm"),
6760 idx("flipper_length_mm"),
6761 idx("body_mass_g"),
6762 );
6763 let mut kept = 0usize;
6764 let mut csv =
6765 String::from("bill_length_mm,bill_depth_mm,flipper_length_mm,body_mass_g,species\n");
6766 for line in lines {
6767 let line = line.trim();
6768 if line.is_empty() {
6769 continue;
6770 }
6771 let f: Vec<&str> = line.split(',').collect();
6772 let num = |i: usize| f.get(i).and_then(|v| v.trim().parse::<f64>().ok());
6773 let (Some(bl), Some(bd), Some(fl), Some(bm)) =
6774 (num(i_bl), num(i_bd), num(i_fl), num(i_bm))
6775 else {
6776 continue;
6777 };
6778 let species = f[i_species].trim().trim_matches('"');
6779 if species.is_empty() || species == "NA" {
6780 continue;
6781 }
6782 if kept % 3 == 0 {
6783 kept += 1;
6784 continue; // held out by the quality test
6785 }
6786 kept += 1;
6787 writeln!(csv, "{bl},{bd},{fl},{bm},{species}").unwrap();
6788 }
6789 let path = td.path().join("penguins_train.csv");
6790 fs::write(&path, csv).expect("write penguins train csv");
6791 let cols: Vec<String> = [
6792 "bill_length_mm",
6793 "bill_depth_mm",
6794 "flipper_length_mm",
6795 "body_mass_g",
6796 "species",
6797 ]
6798 .iter()
6799 .map(|s| s.to_string())
6800 .collect();
6801 Some(load_dataset_projected(&path, &cols).expect("load penguins train"))
6802 }
6803
6804 /// #2615 diagnostic (zz_measure): does the outer REML criterion SEE the
6805 /// rank-1 null-space smoothing coordinate at all?
6806 ///
6807 /// The production penguins fit selects EVERY joint λ exactly on its
6808 /// effective-df-floor wall, and the raw outer gradient prints `+0.0000` at
6809 /// every rank-1 (null-space) coordinate while the rank-8 (wiggliness)
6810 /// coordinates carry |g| ≈ 1.9. If that near-zero is the true derivative,
6811 /// the criterion is genuinely flat in the linear direction's λ and the
6812 /// df floor is making a modelling choice REML declines to make. If central
6813 /// FD disagrees, the analytic outer gradient is desynced from its own
6814 /// criterion on exactly the coordinate #2608/#2612 had to pin by hand.
6815 ///
6816 /// `sample_classes` draws a PURELY LINEAR softmax, so the whole signal
6817 /// lives in the null space of the wiggliness penalty — the same geometry
6818 /// as penguins, at a fixture cost. Prints only; never asserts a bound.
6819 #[test]
6820 fn zz_measure_2615_nullspace_outer_gradient_fd() {
6821 let td = tempdir().expect("tempdir");
6822 let dir = td.path();
6823 let (x, cls) = sample_classes(0, 300);
6824 let labels: Vec<String> = cls
6825 .iter()
6826 .map(|&c| ["A", "B", "C"][c].to_string())
6827 .collect();
6828 let train = dataset_xy(dir, "fd2615", &x, &labels);
6829 let config = FitConfig::default();
6830 let request = MultinomialFitRequest {
6831 init_lambda: 1.0,
6832 max_iter: 60,
6833 tol: 1e-6,
6834 ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
6835 };
6836 let parts = penalized_multinomial_formula_parts(&request)
6837 .expect("production formula parts must build");
6838 let mut probe_options = parts.options.clone();
6839 probe_options.compute_covariance = false;
6840
6841 // Coordinate layout: joint specs are emitted term-major, K per-class
6842 // specs per term, so with one smooth (wiggliness + null space) and
6843 // K = 3 this is [w,w,w, n,n,n].
6844 let eval_at = |rho_vec: &[f64]| -> (f64, ndarray::Array1<f64>, bool) {
6845 let fam = parts
6846 .family
6847 .clone()
6848 .with_joint_initial_log_lambdas(rho_vec.to_vec());
6849 let diagnostics =
6850 crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6851 &fam,
6852 &parts.blocks,
6853 &probe_options,
6854 &ndarray::Array1::from(rho_vec.to_vec()),
6855 gam_problem::EvalMode::ValueAndGradient,
6856 )
6857 .expect("labeled outer evaluation");
6858 (
6859 diagnostics.objective,
6860 diagnostics.gradient,
6861 diagnostics.inner_converged,
6862 )
6863 };
6864
6865 // The wiggliness coordinate is held where the production fit puts it
6866 // (hard against its own wall); only the null-space coordinate moves.
6867 const RHO_WIGGLE: f64 = 8.0;
6868 let h = 1.0e-3;
6869 eprintln!("#2615 null-space profile (rho_wiggle={RHO_WIGGLE}, one smooth, K=3, n=300)");
6870 for step in 0..11 {
6871 let rho_n = -8.0 + 2.0 * step as f64;
6872 let rho: Vec<f64> = vec![RHO_WIGGLE, RHO_WIGGLE, RHO_WIGGLE, rho_n, rho_n, rho_n];
6873 let (v, g, conv) = eval_at(&rho);
6874 let mut plus = rho.clone();
6875 let mut minus = rho.clone();
6876 plus[3] += h;
6877 minus[3] -= h;
6878 let (vp, _, _) = eval_at(&plus);
6879 let (vm, _, _) = eval_at(&minus);
6880 let fd = (vp - vm) / (2.0 * h);
6881 eprintln!(
6882 "#2615 rho_null={rho_n:+.1} V={v:.9e} g[3]analytic={:+.6e} g[3]fd={fd:+.6e} ratio={:+.3e} g[0]analytic={:+.6e} inner_conv={conv}",
6883 g[3],
6884 if fd.abs() > 0.0 { g[3] / fd } else { f64::NAN },
6885 g[0],
6886 );
6887 }
6888 }
6889
6890 /// #2349 diagnostic (zz_measure): finite-difference the OUTER REML
6891 /// criterion of the EXACT production multinomial objective at the refusal
6892 /// checkpoint from MSI job 13390650. The certificate there claimed
6893 /// `|Pg| = 2.047` against a bound of `2.697e-3` after the optimizer
6894 /// stalled — if the fixed-ρ criterion's central FD gradient at that same
6895 /// checkpoint is comparably large, the surface is genuinely non-stationary
6896 /// and the stall is the optimizer's; if it is orders of magnitude smaller,
6897 /// the analytic outer gradient is desynced from the criterion (the
6898 /// coalesced overlapping joint-family pseudo-logdet is the suspect).
6899 /// Prints only; never asserts a bound.
6900 #[test]
6901 fn zz_measure_2349_outer_gradient_fd_at_refusal_checkpoint() {
6902 let td = tempdir().expect("tempdir");
6903 let dir = td.path();
6904 let (x, cls) = sample_classes(0, 300);
6905 let labels: Vec<String> = cls
6906 .iter()
6907 .map(|&c| ["A", "B", "C"][c].to_string())
6908 .collect();
6909 let train = dataset_xy(dir, "fd2349", &x, &labels);
6910 let config = FitConfig::default();
6911 let request = MultinomialFitRequest {
6912 init_lambda: 1.0,
6913 max_iter: 60,
6914 tol: 1e-6,
6915 ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
6916 };
6917 let parts = penalized_multinomial_formula_parts(&request)
6918 .expect("production formula parts must build");
6919 // Unbiased-arm refusal checkpoint (MSI job 13390650, #2349): the
6920 // 6-coordinate joint ρ = 2 terms × 3 per-class λ, term-major.
6921 let rho_star = [
6922 6.50584039279757,
6923 -1.6183906983083074,
6924 5.922109861708934,
6925 -0.5810545109816936,
6926 -0.4894709703255621,
6927 1.299144316808675,
6928 ];
6929 // The criterion probe needs no posterior covariance — and at this
6930 // checkpoint it CANNOT have one: the joint precision H + S_λ is
6931 // measurably singular there (1 flat direction, the first hard datum
6932 // this gate produced), so the covariance factorization honestly
6933 // refuses. The REML criterion value is still well-defined through the
6934 // pseudo-logdet.
6935 let mut probe_options = parts.options.clone();
6936 probe_options.compute_covariance = false;
6937 eprintln!(
6938 "#2349 gate state: use_remlobjective={} (RidgedQuadraticReml default => \
6939 logdet_h/logdet_s included in the fixed-lambda score iff this is true)",
6940 probe_options.use_remlobjective
6941 );
6942 let v_at_with = |rho: &[f64], use_reml: bool| -> f64 {
6943 let fam = parts
6944 .family
6945 .clone()
6946 .with_joint_initial_log_lambdas(rho.to_vec());
6947 let mut opts = probe_options.clone();
6948 opts.use_remlobjective = use_reml;
6949 let fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
6950 &fam,
6951 &parts.blocks,
6952 &opts,
6953 None,
6954 )
6955 .expect("fixed-lambda inner solve at the checkpoint must converge");
6956 fit.reml_score()
6957 .expect("a fixed-lambda custom-family solve reports its criterion")
6958 };
6959 let v_plain = v_at_with(&rho_star, false);
6960 let v_laml = v_at_with(&rho_star, true);
6961 eprintln!(
6962 "#2349 V(rho*): plain(penalized NLL)={v_plain:.9e} \
6963 laml(+0.5logdetH-0.5logdetS)={v_laml:.9e} logdet_pair={:.9e} \
6964 (the refusal reported final objective 2.687403e2 at this checkpoint — \
6965 whichever variant matches IS the outer criterion)",
6966 v_laml - v_plain
6967 );
6968 let outer_uses_laml = (v_laml - 2.687403e2).abs() < (v_plain - 2.687403e2).abs();
6969 let v_at = |rho: &[f64]| -> f64 { v_at_with(rho, outer_uses_laml) };
6970 // Term-for-term decomposition of the fixed-ρ score so the ~12.5 offset
6971 // from the outer criterion can be attributed to a specific missing
6972 // term. A ρ-CONSTANT offset leaves the FD gradient verdict intact; a
6973 // missing ½·log|S_λ|₊ (strongly ρ-dependent, O(1) gradient per
6974 // coordinate) would contaminate it.
6975 {
6976 let fam = parts
6977 .family
6978 .clone()
6979 .with_joint_initial_log_lambdas(rho_star.to_vec());
6980 let fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
6981 &fam,
6982 &parts.blocks,
6983 &probe_options,
6984 None,
6985 )
6986 .expect("fixed-lambda decomposition fit at the checkpoint");
6987 eprintln!(
6988 "#2349 decompose: reml_score={:.9e} penalized_objective={:.9e} \
6989 log_likelihood={:.9e} deviance={:.9e}",
6990 fit.reml_score().unwrap_or(f64::NAN),
6991 fit.penalized_objective().unwrap_or(f64::NAN),
6992 fit.log_likelihood,
6993 fit.deviance
6994 );
6995 }
6996 let h = 1.0e-3;
6997 let mut grad_fd = [0.0_f64; 6];
6998 for s in 0..6 {
6999 let mut plus = rho_star;
7000 plus[s] += h;
7001 let mut minus = rho_star;
7002 minus[s] -= h;
7003 grad_fd[s] = (v_at(&plus) - v_at(&minus)) / (2.0 * h);
7004 eprintln!("#2349 FD dV/drho[{s}] = {:+.6e}", grad_fd[s]);
7005 }
7006 let norm = grad_fd.iter().map(|g| g * g).sum::<f64>().sqrt();
7007 eprintln!(
7008 "#2349 |FD grad| = {norm:.6e} on the {} criterion \
7009 (certificate claimed |Pg|=2.047e0, bound 2.697e-3)",
7010 if outer_uses_laml {
7011 "LAML"
7012 } else {
7013 "plain penalized-NLL"
7014 }
7015 );
7016
7017 // ── Warm-start stall isolation (#2349, round 3) ────────────────────
7018 //
7019 // The unbiased-arm refusal recorded objective 268.740 at its OWN best
7020 // iterate ρ*, while a cold fixed-λ solve at the same ρ* reaches
7021 // 256.166 — the outer's warm-started inner state sat ~12.6 above the
7022 // mode of a CONVEX objective while claiming convergence. If that stall
7023 // is real it must reproduce in isolation: warm-start the fixed-λ solve
7024 // at ρ* from the mode of a DISTANT ρ (the outer's actual eval pattern)
7025 // and compare against the cold value. A warm-started value ≫ cold with
7026 // an Ok return is the minimal repro of a lying inner certificate; an
7027 // Err is the honest refusal; a matching value clears the inner solver
7028 // and points the 12.6 gap at the outer eval bookkeeping instead.
7029 for delta in [2.0_f64, -2.0] {
7030 let rho_far: Vec<f64> = rho_star.iter().map(|r| r + delta).collect();
7031 let fam_far = parts
7032 .family
7033 .clone()
7034 .with_joint_initial_log_lambdas(rho_far.clone());
7035 let far_fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
7036 &fam_far,
7037 &parts.blocks,
7038 &probe_options,
7039 None,
7040 )
7041 .expect("cold fixed-lambda solve at the far point");
7042 let far_beta: Vec<f64> = far_fit
7043 .block_states
7044 .iter()
7045 .flat_map(|bs| bs.beta.iter().copied())
7046 .collect();
7047 let block_cols: Vec<usize> = parts.blocks.iter().map(|s| s.design.ncols()).collect();
7048 let warm = crate::custom_family::CustomFamilyWarmStart::from_cached_beta(
7049 &block_cols,
7050 &ndarray::Array1::from(far_beta),
7051 )
7052 .expect("warm start from far-point mode");
7053 let fam_star = parts
7054 .family
7055 .clone()
7056 .with_joint_initial_log_lambdas(rho_star.to_vec());
7057 match crate::custom_family::fit_custom_family_fixed_log_lambdas(
7058 &fam_star,
7059 &parts.blocks,
7060 &probe_options,
7061 Some(&warm),
7062 ) {
7063 Ok(fit) => eprintln!(
7064 "#2349 warm-from(delta={delta:+.1}): V={:.9e} (cold {:.9e}, refusal 2.687403e2) \
7065 gap_to_cold={:+.3e}",
7066 fit.reml_score().unwrap_or(f64::NAN),
7067 v_laml,
7068 fit.reml_score().unwrap_or(f64::NAN) - v_laml
7069 ),
7070 Err(e) => eprintln!(
7071 "#2349 warm-from(delta={delta:+.1}): inner REFUSED honestly: {}",
7072 format!("{e}").chars().take(220).collect::<String>()
7073 ),
7074 }
7075 }
7076
7077 // ── Round 5: the LABELED production evaluator at ρ* + FD gate ─────
7078 //
7079 // Round 4's hyper-evaluator saw no outer coordinates (grad=[], and its
7080 // objective 245.99 matched an unpenalized solve): the joint λs are
7081 // OUTER coordinates only through the labeled layout. This round calls
7082 // the exact production functional (canonicalize → pulled-back joint
7083 // specs → labeled layout → outerobjectivegradienthessian_labeled) at
7084 // the checkpoint. Its objective settles whether the refusal's 268.740
7085 // is that functional's value (and the 12.574 a criterion difference vs
7086 // the fixed-λ LAML) — and the analytic-vs-FD comparison per coordinate
7087 // is the obj↔grad desync gate (issue suspect 1) on the REAL surface.
7088 {
7089 let fam = parts
7090 .family
7091 .clone()
7092 .with_joint_initial_log_lambdas(rho_star.to_vec());
7093 let eval_at = |rho_vec: &[f64]| -> (f64, ndarray::Array1<f64>, bool) {
7094 let diagnostics =
7095 crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
7096 &fam,
7097 &parts.blocks,
7098 &probe_options,
7099 &ndarray::Array1::from(rho_vec.to_vec()),
7100 gam_problem::EvalMode::ValueAndGradient,
7101 )
7102 .expect("labeled outer evaluation at the checkpoint");
7103 (
7104 diagnostics.objective,
7105 diagnostics.gradient,
7106 diagnostics.inner_converged,
7107 )
7108 };
7109 let (v0, g0, conv0) = eval_at(&rho_star);
7110 eprintln!(
7111 "#2349 labeled-evaluator at rho*: V={v0:.9e} (refusal 2.687403e2, \
7112 fixed-lambda LAML 2.561663540e2) inner_converged={conv0} |analytic g|={:.6e}",
7113 g0.iter().map(|g| g * g).sum::<f64>().sqrt()
7114 );
7115 let h = 1.0e-3;
7116 for s in 0..6 {
7117 let mut plus = rho_star;
7118 plus[s] += h;
7119 let mut minus = rho_star;
7120 minus[s] -= h;
7121 let (vp, _, _) = eval_at(&plus);
7122 let (vm, _, _) = eval_at(&minus);
7123 let fd = (vp - vm) / (2.0 * h);
7124 eprintln!(
7125 "#2349 labeled grad[{s}]: analytic={:+.6e} fd={fd:+.6e} diff={:+.3e}",
7126 g0[s],
7127 g0[s] - fd
7128 );
7129 }
7130 }
7131 }
7132
7133 /// A deterministic two-covariate three-class dataset whose labels are DRAWN
7134 /// from a smooth softmax truth.
7135 ///
7136 /// Drawing rather than taking the argmax is load-bearing: an argmax label is
7137 /// a deterministic function of `x`, so the classes are exactly separated by
7138 /// their own decision boundaries and every fit on them is legitimately on
7139 /// the separation lane. Here every class keeps appreciable probability
7140 /// everywhere, so **no direction separates** and the unbiased criterion has
7141 /// an interior optimum. Same generator as
7142 /// `tests/multinomial_parametric_penalty_2612.rs`.
7143 fn softmax_drawn_two_covariate(
7144 dir: &std::path::Path,
7145 tag: &str,
7146 n: usize,
7147 ) -> gam_data::EncodedDataset {
7148 const CLASS_NAMES: [&str; 3] = ["a", "b", "c"];
7149 let mut lcg: u64 = 0x2612_2612_2612_2612;
7150 let mut next_unit = || -> f64 {
7151 lcg = lcg
7152 .wrapping_mul(6_364_136_223_846_793_005)
7153 .wrapping_add(1_442_695_040_888_963_407);
7154 ((lcg >> 11) as f64) / ((1u64 << 53) as f64)
7155 };
7156 let mut csv = String::from("x1,x2,y\n");
7157 for index in 0..n {
7158 let x1 = -2.0 + 4.0 * (((index as f64) * 0.618_033_988_749_894_8) % 1.0);
7159 let x2 = -2.0 + 4.0 * (((index as f64) * 0.414_213_562_373_095_1) % 1.0);
7160 let scores = [
7161 0.6 * x1 - 0.3 * x2,
7162 -0.4 * x1 + 0.5 * x2,
7163 0.2 * (x1 * x1 - x2 * x2) * 0.25,
7164 ];
7165 let shift = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max);
7166 let weights: Vec<f64> = scores.iter().map(|s| (s - shift).exp()).collect();
7167 let total: f64 = weights.iter().sum();
7168 let mut draw = next_unit() * total;
7169 let mut label = CLASS_NAMES.len() - 1;
7170 for (class, weight) in weights.iter().enumerate() {
7171 if draw < *weight {
7172 label = class;
7173 break;
7174 }
7175 draw -= weight;
7176 }
7177 writeln!(csv, "{x1},{x2},{}", CLASS_NAMES[label]).unwrap();
7178 }
7179 let path = dir.join(format!("softmax_drawn_{tag}.csv"));
7180 fs::write(&path, csv).expect("write softmax-drawn csv");
7181 let cols: Vec<String> = ["x1", "x2", "y"].iter().map(|s| s.to_string()).collect();
7182 load_dataset_projected(&path, &cols).expect("load softmax-drawn dataset")
7183 }
7184
7185 /// #2612 diagnostic (zz_measure): **which matrix decides the arming, and
7186 /// what does each one say?**
7187 ///
7188 /// The conditional Firth/Jeffreys engagement fires when the reduced
7189 /// conditioning gate reports under-identification at the certified mode. The
7190 /// gate's absolute arm fires at `λ_min < 1` — one observation-equivalent of
7191 /// curvature — and until #2612 this call site handed it the bare Fisher
7192 /// information `H`. A penalized spline basis has high-frequency directions
7193 /// the data barely resolve BY CONSTRUCTION, so that reading calls an ordinary
7194 /// smooth "separated"; reading the penalized curvature `H + S_λ` asks the
7195 /// question of the objective that was actually optimized.
7196 ///
7197 /// This prints both spectra at the same certified mode, on the same span, on
7198 /// data drawn from a smooth softmax truth (nothing separates) and — when the
7199 /// checked-in CSV is present — on the penguins witness, which genuinely is
7200 /// quasi-separated. The two rows are the discriminator: the fix is only right
7201 /// if it disarms the first and leaves the second armed.
7202 ///
7203 /// Prints only; never asserts a bound.
7204 #[test]
7205 fn zz_measure_2612_arming_certificate_with_and_without_the_penalty() {
7206 let td = tempdir().expect("tempdir");
7207 let synthetic = softmax_drawn_two_covariate(td.path(), "arm2612", 600);
7208 let config = FitConfig::default();
7209 let mut cases: Vec<(&str, gam_data::EncodedDataset, &str)> = vec![(
7210 "synthetic softmax-drawn (nothing separates)",
7211 synthetic,
7212 "y ~ s(x1, k=6) + s(x2, k=6)",
7213 )];
7214 if let Some(train) = penguins_stride3_train(&td) {
7215 cases.push((
7216 "penguins stride-3 (the quasi-separated witness)",
7217 train,
7218 "species ~ s(bill_length_mm, k=10) + s(bill_depth_mm, k=10) \
7219 + s(flipper_length_mm, k=10) + s(body_mass_g, k=10)",
7220 ));
7221 } else {
7222 eprintln!("#2612 penguins arm SKIPPED: dataset unavailable");
7223 }
7224
7225 for (label, data, formula) in &cases {
7226 let request = MultinomialFitRequest {
7227 init_lambda: 1.0,
7228 max_iter: 100,
7229 tol: 1e-8,
7230 ..MultinomialFitRequest::new(data, formula, &config)
7231 };
7232 let parts =
7233 penalized_multinomial_formula_parts(&request).expect("production formula parts");
7234 let started = std::time::Instant::now();
7235 let probe = crate::custom_family::fit_custom_family_with_rho_prior(
7236 &parts.family,
7237 &parts.blocks,
7238 &parts.options,
7239 gam_problem::RhoPrior::Flat,
7240 );
7241 let seconds = started.elapsed().as_secs_f64();
7242 let probe = match probe {
7243 Ok(fit) => fit,
7244 Err(err) => {
7245 eprintln!(
7246 "#2612 [{label}] unbiased probe FAILED after {seconds:.1}s: {}",
7247 format!("{err}").chars().take(400).collect::<String>()
7248 );
7249 continue;
7250 }
7251 };
7252 let span = probe
7253 .geometry
7254 .as_ref()
7255 .expect("probe geometry")
7256 .coefficient_gauge
7257 .t_full
7258 .clone();
7259 let specs = parts
7260 .family
7261 .equivariant_class_penalty_specs()
7262 .expect("coupled joint penalty specs");
7263 let n_components = parts.penalties_arc.len();
7264 let selected = probe.artifacts.joint_log_lambdas.as_ref();
7265 // The historical reading: the likelihood alone, `S_λ` omitted.
7266 let likelihood_only = multinomial_formula_penalized_separation_evidence(
7267 &parts.family,
7268 &parts.blocks,
7269 &probe.block_states,
7270 span.view(),
7271 &[],
7272 0,
7273 None,
7274 );
7275 // The curvature the mode actually sits in.
7276 let penalized = multinomial_formula_penalized_separation_evidence(
7277 &parts.family,
7278 &parts.blocks,
7279 &probe.block_states,
7280 span.view(),
7281 &specs,
7282 n_components,
7283 selected,
7284 );
7285 let lambda_range = selected.map(|jll| {
7286 let lo = jll.iter().copied().fold(f64::INFINITY, f64::min);
7287 let hi = jll.iter().copied().fold(f64::NEG_INFINITY, f64::max);
7288 (lo.exp(), hi.exp(), jll.len())
7289 });
7290 eprintln!(
7291 "#2612 [{label}] probe converged in {seconds:.1}s; selected lambda \
7292 (min, max, count) = {lambda_range:?}\n H alone -> {:?}\n H + S_lambda -> {:?}",
7293 likelihood_only
7294 .as_ref()
7295 .map(|e| e.as_ref().map(MultinomialSeparationCertificate::as_str)),
7296 penalized
7297 .as_ref()
7298 .map(|e| e.as_ref().map(MultinomialSeparationCertificate::as_str)),
7299 );
7300 }
7301 }
7302}