Skip to main content

gam_models/
vector_response.rs

1//! Vector-valued response support.
2//!
3//! Many smooths sharing one latent: the shape function in the latent-variable
4//! engine maps to a reduced activation vector (tens-to-hundreds of dimensions,
5//! after a random-matrix noise cut). This module defines the response-side
6//! types, the Gaussian vector likelihood, and the connector trait the inner
7//! solver consumes.
8//!
9//! Conventions:
10//! - `Y` is shape `(N, M)`: `N` rows, `M` output dimensions.
11//! - `eta` is shape `(N, M)`: the linear predictor with one column per output.
12//! - For Gaussian identity-link, mean(η) = η, so the likelihood depends only
13//!   on `eta` and `Y`.
14//!
15//! The Hessian is block-structured: per-row (N independent blocks for the
16//! Gaussian case), each of size `(M, M)`. For a Gaussian likelihood with
17//! Diagonal/Isotropic noise this per-row block is itself diagonal — exactly
18//! what the arrow Schur elimination in `solver/arrow_schur.rs` consumes.
19
20use crate::model_types::EstimationError;
21use crate::multinomial_reml::{MultinomialLogitRowProgram, multinomial_logit_probabilities_into};
22use ndarray::{Array1, Array2, Array3, ArrayView2};
23
24/// Per-output noise model for a vector response.
25///
26/// `LowRank` stores the symmetric structured precision
27/// `W = diag(diag) + U Uᵀ`, with `factor` holding `U`. The vector likelihood
28/// consumes the owned arrays directly; PIRLS low-rank Gram assembly is handled
29/// by `gam_linalg::low_rank_weight::LowRankWeight` and
30/// `gam_solve::pirls`.
31#[derive(Clone, Debug)]
32pub enum VectorNoise {
33    /// Shared σ across all M outputs: Σ = σ² I_M.
34    Isotropic(f64),
35    /// Per-output σ_m: Σ = diag(σ_m²).
36    Diagonal(Array1<f64>),
37    /// Symmetric structured form `W = diag(diag) + factor · factorᵀ`.
38    LowRank {
39        diag: Array1<f64>,
40        factor: Array2<f64>,
41    },
42}
43
44impl VectorNoise {
45    /// Per-output precision vector (1/σ_m²) for the Isotropic / Diagonal cases.
46    /// LowRank returns the diagonal piece only; the low-rank correction is
47    /// applied separately by the Piece 5 weight code.
48    pub fn diag_precision(&self, m: usize) -> Result<Array1<f64>, EstimationError> {
49        match self {
50            Self::Isotropic(sigma) => {
51                if !sigma.is_finite() || *sigma <= 0.0 {
52                    crate::bail_invalid_estim!(
53                        "VectorNoise::Isotropic: σ must be > 0 and finite (got {sigma})",
54                    );
55                }
56                let p = 1.0 / (sigma * sigma);
57                Ok(Array1::from_elem(m, p))
58            }
59            Self::Diagonal(sigma) => {
60                if sigma.len() != m {
61                    crate::bail_invalid_estim!(
62                        "VectorNoise::Diagonal: σ length {} ≠ M={m}",
63                        sigma.len()
64                    );
65                }
66                let mut out = Array1::<f64>::zeros(m);
67                for j in 0..m {
68                    let s = sigma[j];
69                    if !s.is_finite() || s <= 0.0 {
70                        crate::bail_invalid_estim!(
71                            "VectorNoise::Diagonal: σ[{j}] must be > 0 and finite (got {s})",
72                        );
73                    }
74                    out[j] = 1.0 / (s * s);
75                }
76                Ok(out)
77            }
78            Self::LowRank { diag, .. } => {
79                if diag.len() != m {
80                    crate::bail_invalid_estim!(
81                        "VectorNoise::LowRank: diag length {} ≠ M={m}",
82                        diag.len()
83                    );
84                }
85                let mut out = Array1::<f64>::zeros(m);
86                for j in 0..m {
87                    let d = diag[j];
88                    if !d.is_finite() || d <= 0.0 {
89                        crate::bail_invalid_estim!(
90                            "VectorNoise::LowRank: diag[{j}] must be > 0 (got {d})",
91                        );
92                    }
93                    // `diag` is the PRECISION diagonal (W = diag(d) + F·Fᵀ).
94                    // Pass it through unchanged.
95                    out[j] = d;
96                }
97                Ok(out)
98            }
99        }
100    }
101}
102
103/// Vector-valued response target.
104///
105/// `y` is `(N, M)`; `row_weights` (if present) is length `N` and scales the
106/// per-row contribution to the likelihood (e.g. observation weights from a
107/// re-sampling or inverse-probability scheme).
108#[derive(Clone, Debug)]
109pub struct VectorResponseTarget {
110    /// shape (N, M) — N rows × M output dimensions.
111    pub y: Array2<f64>,
112    /// per-output noise (or shared scalar).
113    pub noise: VectorNoise,
114    /// optional row weights (N,).
115    pub row_weights: Option<Array1<f64>>,
116}
117
118impl VectorResponseTarget {
119    pub fn new(y: Array2<f64>, noise: VectorNoise) -> Self {
120        Self {
121            y,
122            noise,
123            row_weights: None,
124        }
125    }
126
127    pub fn with_row_weights(mut self, w: Array1<f64>) -> Result<Self, EstimationError> {
128        validate_row_weights(&w, self.y.nrows())?;
129        self.row_weights = Some(w);
130        Ok(self)
131    }
132
133    pub fn n(&self) -> usize {
134        self.y.nrows()
135    }
136    pub fn m(&self) -> usize {
137        self.y.ncols()
138    }
139}
140
141/// Relative tolerance on the per-row simplex constraint `Σ_c y_{n,c} = 1`.
142///
143/// The multinomial-logit log-likelihood `ℓ = Σ_c y_c log p_c` has the
144/// canonical residual gradient `y_a − p_a` and Fisher block
145/// `p_a δ_{ab} − p_a p_b` **only** when each target row is a probability
146/// vector (`y_c ≥ 0`, `Σ_c y_c = 1`). For a general row mass `s = Σ_c y_c`
147/// the true derivatives are `y_a − s p_a` and `s (p_a δ_{ab} − p_a p_b)`, so
148/// any row whose mass deviates from 1 makes the implemented gradient/Hessian
149/// disagree with the implemented objective. We therefore require simplex rows
150/// at every construction boundary and reject anything else, rather than
151/// silently fitting with inconsistent curvature. The tolerance absorbs only
152/// floating-point round-off in an otherwise-exact one-hot / label-smoothed
153/// row (e.g. a sum of `K` rationals), not genuine count or proportional data.
154pub(crate) const MULTINOMIAL_SIMPLEX_TOL: f64 = 1.0e-9;
155
156/// Validate that every row of a multinomial target `y ∈ ℝ^{N×K}` is a point on
157/// the probability simplex: `y_{n,c} ≥ 0` for all entries and
158/// `Σ_c y_{n,c} = 1` for every row (up to [`MULTINOMIAL_SIMPLEX_TOL`]). This
159/// is the precondition under which [`MultinomialLogitLikelihood`]'s residual
160/// gradient and Fisher block are the exact derivatives of its log-likelihood;
161/// see the constant's docs. Finiteness is checked first so the message points
162/// at the offending entry rather than at a NaN-poisoned row sum.
163pub(crate) fn validate_multinomial_simplex(
164    y: ArrayView2<f64>,
165    context: &str,
166) -> Result<(), EstimationError> {
167    let (n, k) = y.dim();
168    for row in 0..n {
169        let mut row_sum = 0.0_f64;
170        for c in 0..k {
171            let v = y[[row, c]];
172            if !v.is_finite() {
173                crate::bail_invalid_estim!("{context}: y[{row},{c}] must be finite (got {v})");
174            }
175            if v < 0.0 {
176                crate::bail_invalid_estim!(
177                    "{context}: multinomial target must be a probability vector \
178                     (y_c ≥ 0); got y[{row},{c}] = {v}"
179                );
180            }
181            row_sum += v;
182        }
183        if (row_sum - 1.0).abs() > MULTINOMIAL_SIMPLEX_TOL {
184            crate::bail_invalid_estim!(
185                "{context}: multinomial target rows must sum to 1 (one-hot for \
186                 hard labels, or a label-smoothed probability vector); row {row} \
187                 sums to {row_sum}. The softmax residual gradient y_a − p_a and \
188                 Fisher block p_a δ_ab − p_a p_b are the derivatives of \
189                 Σ_c y_c log p_c only when the row mass is 1."
190            );
191        }
192    }
193    Ok(())
194}
195
196fn validate_row_weights(weights: &Array1<f64>, n: usize) -> Result<(), EstimationError> {
197    if weights.len() != n {
198        crate::bail_invalid_estim!("row_weights length {} ≠ N={n}", weights.len());
199    }
200    for (idx, weight) in weights.iter().copied().enumerate() {
201        if !(weight.is_finite() && weight >= 0.0) {
202            crate::bail_invalid_estim!(
203                "row_weights[{idx}] must be finite and non-negative (got {weight})"
204            );
205        }
206    }
207    Ok(())
208}
209
210/// Connector trait the inner solver (Piece 1) plugs into.
211///
212/// `eta` is the `(N, M)` linear predictor; `y` is the `(N, M)` target. The
213/// implementation is responsible for any link inversion. The `hess_diag`
214/// return is the per-element diagonal of the per-row Hessian block; for a
215/// Diagonal-noise Gaussian this is exactly `(N, M)` of per-output precisions.
216pub trait VectorLikelihood {
217    /// log p(Y | η).
218    fn log_lik(&self, eta: ArrayView2<f64>, y: ArrayView2<f64>) -> Result<f64, EstimationError>;
219
220    /// ∂ log p(Y | η) / ∂ η, shape (N, M).
221    fn grad_eta(
222        &self,
223        eta: ArrayView2<f64>,
224        y: ArrayView2<f64>,
225    ) -> Result<Array2<f64>, EstimationError>;
226
227    /// Diagonal of the per-row Hessian −∂² log p / ∂ η ∂ η, shape (N, M).
228    /// This is the per-row block consumed by `solver/arrow_schur.rs`.
229    fn hess_diag(
230        &self,
231        eta: ArrayView2<f64>,
232        y: ArrayView2<f64>,
233    ) -> Result<Array2<f64>, EstimationError>;
234
235    /// Per-row dense Hessian block −∂² log p / ∂η_a ∂η_b, shape (N, M, M).
236    ///
237    /// Default implementation lifts [`Self::hess_diag`] onto the per-row
238    /// diagonal, valid only when the per-row Hessian is genuinely diagonal
239    /// across outputs (e.g. Gaussian with Isotropic/Diagonal noise).
240    /// Likelihoods with off-diagonal output coupling must override this:
241    /// [`GaussianVectorLikelihood`] with a low-rank precision factor `F`
242    /// (block `w·(diag(precision) + F·Fᵀ)`, off-diagonals `w·Σ_k F[a,k]·F[b,k]`)
243    /// and multinomial-logit (per-row Fisher block `p_a (δ_ab − p_b)`).
244    ///
245    /// The returned array is consumed by
246    /// [`gam_solve::pirls::dense_block_xtwx`] /
247    /// [`gam_solve::pirls::dense_block_xtwy`] to build `XᵀWX` and `XᵀWy`
248    /// for vector-response IRLS in output-major coefficient ordering.
249    fn hess_block(
250        &self,
251        eta: ArrayView2<f64>,
252        y: ArrayView2<f64>,
253    ) -> Result<Array3<f64>, EstimationError> {
254        let diag = self.hess_diag(eta, y)?;
255        let (n, m) = diag.dim();
256        let mut out = Array3::<f64>::zeros((n, m, m));
257        for row in 0..n {
258            for j in 0..m {
259                out[[row, j, j]] = diag[[row, j]];
260            }
261        }
262        Ok(out)
263    }
264}
265
266pub(crate) fn validate_vector_likelihood_inputs(
267    context: &str,
268    eta: ArrayView2<'_, f64>,
269    y: ArrayView2<'_, f64>,
270    expected_columns: Option<usize>,
271) -> Result<(), EstimationError> {
272    if eta.dim() != y.dim() {
273        crate::bail_invalid_estim!(
274            "{context}: eta shape {:?} does not match response shape {:?}",
275            eta.dim(),
276            y.dim()
277        );
278    }
279    if let Some(expected) = expected_columns
280        && eta.ncols() != expected
281    {
282        crate::bail_invalid_estim!(
283            "{context}: eta has {} columns; expected {expected}",
284            eta.ncols()
285        );
286    }
287    if let Some(((row, column), value)) = eta.indexed_iter().find(|(_, value)| !value.is_finite()) {
288        crate::bail_invalid_estim!("{context}: eta[{row},{column}] must be finite, got {value}");
289    }
290    if let Some(((row, column), value)) = y.indexed_iter().find(|(_, value)| !value.is_finite()) {
291        crate::bail_invalid_estim!(
292            "{context}: response[{row},{column}] must be finite, got {value}"
293        );
294    }
295    Ok(())
296}
297
298/// Gaussian vector likelihood with identity link.
299///
300/// `log p(Y|η) = −½ Σ_n w_n · rᵀ W r` where `r = Y_n − η_n` and `W` is the
301/// per-output **precision** matrix. For Isotropic / Diagonal `W = diag(prec)`;
302/// for `LowRank` it is `W = diag(prec) + F · Fᵀ`, with `F` carried alongside
303/// the diagonal here.
304///
305/// (Up to the constant log-determinant of the noise covariance, dropped here
306/// because it does not depend on β or the latent t; the determinant is
307/// accounted for in the REML score, not the inner likelihood.)
308#[derive(Clone, Debug)]
309pub struct GaussianVectorLikelihood {
310    /// Per-output diagonal precision (length M). For Isotropic / Diagonal /
311    /// LowRank this is the diagonal piece of the precision matrix
312    /// (`1/σ_m²` for Diagonal/Isotropic; `diag` for LowRank).
313    pub precision: Array1<f64>,
314    /// Optional dense rank-r factor `F` of size `(M, r)` such that the full
315    /// per-row precision is `diag(precision) + F · Fᵀ`. `None` for the
316    /// Isotropic / Diagonal cases.
317    pub factor: Option<Array2<f64>>,
318    /// Optional row weights (length N), or None for uniform.
319    pub row_weights: Option<Array1<f64>>,
320}
321
322impl GaussianVectorLikelihood {
323    pub fn from_target(target: &VectorResponseTarget) -> Result<Self, EstimationError> {
324        if let Some(weights) = target.row_weights.as_ref() {
325            validate_row_weights(weights, target.n())?;
326        }
327        let precision = target.noise.diag_precision(target.m())?;
328        let factor = match &target.noise {
329            VectorNoise::LowRank { factor, .. } => {
330                if factor.nrows() != target.m() {
331                    crate::bail_invalid_estim!(
332                        "VectorNoise::LowRank: factor has {} rows but M={}",
333                        factor.nrows(),
334                        target.m()
335                    );
336                }
337                for ((row, col), value) in factor.indexed_iter() {
338                    if !value.is_finite() {
339                        crate::bail_invalid_estim!(
340                            "VectorNoise::LowRank: factor[{row},{col}] must be finite (got {value})"
341                        );
342                    }
343                }
344                Some(factor.clone())
345            }
346            _ => None,
347        };
348        Ok(Self {
349            precision,
350            factor,
351            row_weights: target.row_weights.clone(),
352        })
353    }
354
355    #[inline]
356    fn row_weight(&self, n: usize) -> f64 {
357        self.row_weights.as_ref().map_or(1.0, |w| w[n])
358    }
359}
360
361impl VectorLikelihood for GaussianVectorLikelihood {
362    fn log_lik(&self, eta: ArrayView2<f64>, y: ArrayView2<f64>) -> Result<f64, EstimationError> {
363        validate_vector_likelihood_inputs(
364            "GaussianVectorLikelihood::log_lik",
365            eta,
366            y,
367            Some(self.precision.len()),
368        )?;
369        let m = eta.ncols();
370        let rank = self.factor.as_ref().map_or(0, |f| f.ncols());
371        let mut acc = 0.0;
372        // Scratch buffer for Fᵀ r (length rank), reused across rows.
373        let mut ftr = vec![0.0f64; rank];
374        for n in 0..eta.nrows() {
375            let w = self.row_weight(n);
376            // Diagonal part: Σ_m d_m r_m²
377            let mut row_acc = 0.0;
378            for j in 0..m {
379                let r = y[[n, j]] - eta[[n, j]];
380                row_acc += self.precision[j] * r * r;
381            }
382            // Low-rank part: ||Fᵀ r||²
383            if let Some(f) = self.factor.as_ref() {
384                for k in 0..rank {
385                    ftr[k] = 0.0;
386                }
387                for j in 0..m {
388                    let r = y[[n, j]] - eta[[n, j]];
389                    for k in 0..rank {
390                        ftr[k] += f[[j, k]] * r;
391                    }
392                }
393                for k in 0..rank {
394                    row_acc += ftr[k] * ftr[k];
395                }
396            }
397            acc += w * row_acc;
398        }
399        Ok(-0.5 * acc)
400    }
401
402    fn grad_eta(
403        &self,
404        eta: ArrayView2<f64>,
405        y: ArrayView2<f64>,
406    ) -> Result<Array2<f64>, EstimationError> {
407        validate_vector_likelihood_inputs(
408            "GaussianVectorLikelihood::grad_eta",
409            eta,
410            y,
411            Some(self.precision.len()),
412        )?;
413        let (n_rows, n_cols) = eta.dim();
414        let rank = self.factor.as_ref().map_or(0, |f| f.ncols());
415        let mut out = Array2::<f64>::zeros((n_rows, n_cols));
416        let mut ftr = vec![0.0f64; rank];
417        for n in 0..n_rows {
418            let w = self.row_weight(n);
419            // Diagonal part: w · d_m · (y − η)_m
420            for j in 0..n_cols {
421                out[[n, j]] = w * self.precision[j] * (y[[n, j]] - eta[[n, j]]);
422            }
423            // Low-rank part: + w · F (Fᵀ r) for r = y − η
424            if let Some(f) = self.factor.as_ref() {
425                for k in 0..rank {
426                    ftr[k] = 0.0;
427                }
428                for j in 0..n_cols {
429                    let r = y[[n, j]] - eta[[n, j]];
430                    for k in 0..rank {
431                        ftr[k] += f[[j, k]] * r;
432                    }
433                }
434                for j in 0..n_cols {
435                    let mut s = 0.0;
436                    for k in 0..rank {
437                        s += f[[j, k]] * ftr[k];
438                    }
439                    out[[n, j]] += w * s;
440                }
441            }
442        }
443        Ok(out)
444    }
445
446    fn hess_diag(
447        &self,
448        eta: ArrayView2<f64>,
449        y: ArrayView2<f64>,
450    ) -> Result<Array2<f64>, EstimationError> {
451        validate_vector_likelihood_inputs(
452            "GaussianVectorLikelihood::hess_diag",
453            eta,
454            y,
455            Some(self.precision.len()),
456        )?;
457        // Diagonal of −∂² log p / ∂η² = w · diag(diag(d) + F·Fᵀ); the diagonal
458        // of (F·Fᵀ) at output m is Σ_k F[m, k]². This is the diagonal
459        // *preconditioner* only — the off-diagonal cross terms F[a, k]·F[b, k]
460        // are carried by the full per-row block in [`Self::hess_block`] (which
461        // this type overrides whenever `factor` is present). Callers that need
462        // the true Hessian must use `hess_block`, not this diagonal.
463        let (n_rows, n_cols) = eta.dim();
464        let mut out = Array2::<f64>::zeros((n_rows, n_cols));
465        // Pre-compute Σ_k F[m, k]² per output m (independent of n).
466        let f_row_sqsum: Option<Array1<f64>> = self.factor.as_ref().map(|f| {
467            let m = f.nrows();
468            let r = f.ncols();
469            let mut s = Array1::<f64>::zeros(m);
470            for j in 0..m {
471                let mut acc = 0.0;
472                for k in 0..r {
473                    let v = f[[j, k]];
474                    acc += v * v;
475                }
476                s[j] = acc;
477            }
478            s
479        });
480        for n in 0..n_rows {
481            let w = self.row_weight(n);
482            for j in 0..n_cols {
483                let mut d = self.precision[j];
484                if let Some(s) = f_row_sqsum.as_ref() {
485                    d += s[j];
486                }
487                out[[n, j]] = w * d;
488            }
489        }
490        Ok(out)
491    }
492
493    fn hess_block(
494        &self,
495        eta: ArrayView2<f64>,
496        y: ArrayView2<f64>,
497    ) -> Result<Array3<f64>, EstimationError> {
498        // Per-row dense block −∂² log p / ∂η_a ∂η_b. With log-likelihood
499        //     ℓ = −½ Σ_n w_n · rₙᵀ W rₙ,   r = y − η,   W = diag(precision) + F·Fᵀ,
500        // the gradient is wₙ · W rₙ and the negative Hessian block is exactly
501        //     H_{n,a,b} = w_n · ( precision_a · δ_ab + Σ_k F[a,k] · F[b,k] ).
502        // This is the true second derivative of `log_lik` (it differentiates
503        // `grad_eta` exactly); the diagonal-only trait default would drop the
504        // F·Fᵀ cross terms F[a,k]·F[b,k] for a ≠ b, so it must be overridden
505        // whenever a low-rank factor is present.
506        validate_vector_likelihood_inputs(
507            "GaussianVectorLikelihood::hess_block",
508            eta,
509            y,
510            Some(self.precision.len()),
511        )?;
512        let (n_rows, m) = eta.dim();
513        let rank = self.factor.as_ref().map_or(0, |f| f.ncols());
514
515        // Per-output Gram of the low-rank factor, G_{a,b} = Σ_k F[a,k]·F[b,k].
516        // Independent of the row n, so assemble once and scale by w_n.
517        let gram: Option<Array2<f64>> = self.factor.as_ref().map(|f| {
518            let mut g = Array2::<f64>::zeros((m, m));
519            for a in 0..m {
520                for b in a..m {
521                    let mut acc = 0.0;
522                    for k in 0..rank {
523                        acc += f[[a, k]] * f[[b, k]];
524                    }
525                    g[[a, b]] = acc;
526                    g[[b, a]] = acc;
527                }
528            }
529            g
530        });
531
532        let mut out = Array3::<f64>::zeros((n_rows, m, m));
533        for n in 0..n_rows {
534            let w = self.row_weight(n);
535            for a in 0..m {
536                for b in 0..m {
537                    let mut val = if a == b { self.precision[a] } else { 0.0 };
538                    if let Some(g) = gram.as_ref() {
539                        val += g[[a, b]];
540                    }
541                    out[[n, a, b]] = w * val;
542                }
543            }
544        }
545        Ok(out)
546    }
547}
548
549// ─────────────────────────────────────────────────────────────────────────────
550// Piece 5 / Piece 1 row-block support
551// ─────────────────────────────────────────────────────────────────────────────
552
553/// Multinomial-logit (softmax) likelihood with explicit reference class.
554///
555/// Conventions:
556/// - `K` is the total number of classes; the linear predictor has `M = K - 1`
557///   columns corresponding to the *active* classes. Class `K - 1` is the
558///   reference class with η_{K-1} ≡ 0 (so the gauge is fixed by construction
559///   and no additional sum-to-zero projection is required at the η level).
560/// - `y` is the categorical response with shape `(N, K)`. Each row must be a
561///   point on the probability simplex (`y_c ≥ 0`, `Σ_c y_c = 1`): a one-hot
562///   indicator for hard-label classification, or a label-smoothed probability
563///   vector. The row *weight* `w_n` scales the whole row's likelihood
564///   contribution and is independent of the row mass — it is **not** the row
565///   sum. Callers enforce the simplex precondition via
566///   `validate_multinomial_simplex` at every construction boundary; under it
567///   the residual gradient `y_a − p_a` and Fisher block `p_a δ_ab − p_a p_b`
568///   below are the exact derivatives of the log-likelihood `Σ_c y_c log p_c`.
569/// - `eta` is the active linear predictor with shape `(N, M = K - 1)`.
570///
571/// Softmax with baseline:
572/// ```text
573///     p_a   = exp(η_a) / (1 + Σ_b exp(η_b))           for a ∈ [0, K-1)
574///     p_{K-1} = 1 / (1 + Σ_b exp(η_b))
575/// ```
576///
577/// Log-likelihood (rows with weight `w_n`, default 1.0):
578/// ```text
579///     log L = Σ_n w_n · ( Σ_{a < K-1} y_{n,a} · η_{n,a} − log(1 + Σ_b exp(η_{n,b})) )
580///           = Σ_n w_n · Σ_{c ∈ [0, K)} y_{n,c} · log p_{n,c}
581/// ```
582///
583/// Per-row gradient w.r.t. the active η is the canonical Bernoulli/softmax
584/// residual:
585/// ```text
586///     ∂ log L / ∂η_{n,a} = w_n · (y_{n,a} − p_{n,a})       for a ∈ [0, K-1)
587/// ```
588///
589/// Per-row Fisher (= observed, since logit is canonical for the multinomial)
590/// information block, shape `(M, M)`:
591/// ```text
592///     H_{n,a,b} = w_n · ( p_{n,a} · δ_{ab} − p_{n,a} · p_{n,b} )
593/// ```
594///
595/// This is the standard reference-coded multinomial-logit GLM. The dense
596/// per-row block flows through [`VectorLikelihood::hess_block`] into
597/// [`gam_solve::pirls::dense_block_xtwx`], which builds the stacked
598/// `XᵀWX` in output-major coefficient ordering `β = [β_0; β_1; …; β_{K-2}]`
599/// with each per-class block of size `(P, P)`.
600#[derive(Clone, Debug)]
601pub struct MultinomialLogitLikelihood {
602    /// Number of active classes `M = K − 1`. Cached for shape checks.
603    pub active_classes: usize,
604    /// Optional row weights (length N), or `None` for uniform 1.0.
605    pub row_weights: Option<Array1<f64>>,
606}
607
608impl MultinomialLogitLikelihood {
609    /// Construct from the total number of classes `K ≥ 2`.
610    pub fn with_classes(total_classes: usize) -> Result<Self, EstimationError> {
611        if total_classes < 2 {
612            crate::bail_invalid_estim!(
613                "MultinomialLogitLikelihood requires K ≥ 2 classes (got {total_classes})"
614            );
615        }
616        Ok(Self {
617            active_classes: total_classes - 1,
618            row_weights: None,
619        })
620    }
621
622    /// Attach per-row weights (length N, finite and non-negative).
623    pub fn with_row_weights(mut self, w: Array1<f64>) -> Result<Self, EstimationError> {
624        validate_row_weights(&w, w.len())?;
625        self.row_weights = Some(w);
626        Ok(self)
627    }
628
629    /// Total class count `K = M + 1`.
630    #[inline]
631    pub fn total_classes(&self) -> usize {
632        self.active_classes + 1
633    }
634
635    #[inline]
636    fn row_weight(&self, n: usize) -> f64 {
637        self.row_weights.as_ref().map_or(1.0, |w| w[n])
638    }
639
640    /// Numerically-stable softmax with implicit reference column (η_{K-1} = 0).
641    ///
642    /// Writes `K` probabilities into `out` (length `M + 1`). The shift uses
643    /// `max(0, max(eta_active))` so the reference class is included in the
644    /// max and the denominator stays bounded. This is the canonical
645    /// reference implementation; the FFI surface and any direct
646    /// matrix-free callers route through this method rather than carrying
647    /// their own softmax.
648    pub fn softmax_with_baseline(eta_active: &[f64], out: &mut [f64]) {
649        multinomial_logit_probabilities_into(eta_active, out);
650    }
651
652    /// Convenience: compute the full (N, K) probability matrix from
653    /// (N, K-1) active linear predictor. This is the multinomial inverse
654    /// link used by prediction.
655    pub fn probabilities(&self, eta: ArrayView2<f64>) -> Array2<f64> {
656        let n = eta.nrows();
657        let m = self.active_classes;
658        assert_eq!(eta.ncols(), m, "η must have K-1 columns");
659        let k = self.total_classes();
660        let eta = eta.as_standard_layout();
661        let eta_values = eta
662            .as_slice()
663            .expect("standard-layout multinomial logits are contiguous");
664        let mut probs = Array2::<f64>::zeros((n, k));
665        let probs_values = probs
666            .as_slice_mut()
667            .expect("fresh multinomial probabilities are contiguous");
668        let mut eta_row = vec![0.0_f64; m];
669        let mut probs_row = vec![0.0_f64; k];
670        for row in 0..n {
671            eta_row.copy_from_slice(&eta_values[row * m..(row + 1) * m]);
672            Self::softmax_with_baseline(&eta_row, &mut probs_row);
673            probs_values[row * k..(row + 1) * k].copy_from_slice(&probs_row);
674        }
675        probs
676    }
677
678    #[inline]
679    fn row_program<'row>(
680        &self,
681        row: usize,
682        eta: &'row [f64],
683        response: &'row [f64],
684    ) -> Result<MultinomialLogitRowProgram<'row>, EstimationError> {
685        MultinomialLogitRowProgram::new(eta, response, self.row_weight(row)).map_err(|error| {
686            EstimationError::InvalidInput(format!("invalid multinomial row {row}: {error}"))
687        })
688    }
689
690    /// Fused live value/gradient/Hessian evaluation. This is the one production
691    /// batch entry used by the joint REML adapter, so it performs one stable
692    /// normalization per row rather than three independent likelihood passes.
693    pub(crate) fn value_gradient_hessian(
694        &self,
695        eta: ArrayView2<f64>,
696        y: ArrayView2<f64>,
697    ) -> Result<(f64, Array2<f64>, Array3<f64>), EstimationError> {
698        let n = eta.nrows();
699        let m = self.active_classes;
700        let k = self.total_classes();
701        if y.dim() != (n, k) {
702            crate::bail_invalid_estim!(
703                "MultinomialLogitLikelihood::value_gradient_hessian: response shape {:?} must be ({n}, {k})",
704                y.dim()
705            );
706        }
707        validate_vector_likelihood_inputs(
708            "MultinomialLogitLikelihood::value_gradient_hessian active response",
709            eta,
710            y.slice(ndarray::s![.., ..m]),
711            Some(m),
712        )?;
713        let eta = eta.as_standard_layout();
714        let eta_values = eta
715            .as_slice()
716            .expect("standard-layout multinomial logits are contiguous");
717        let y = y.as_standard_layout();
718        let response_values = y
719            .as_slice()
720            .expect("standard-layout multinomial responses are contiguous");
721        let mut gradient_log_likelihood = Array2::<f64>::zeros((n, m));
722        let mut hessian = Array3::<f64>::zeros((n, m, m));
723        let gradient_values = gradient_log_likelihood
724            .as_slice_mut()
725            .expect("fresh multinomial gradient is contiguous");
726        let hessian_values = hessian
727            .as_slice_mut()
728            .expect("fresh multinomial Hessian is contiguous");
729        let mut eta_row = vec![0.0_f64; m];
730        let mut response_row = vec![0.0_f64; k];
731        let mut probabilities = vec![0.0_f64; k];
732        let mut gradient_nll = vec![0.0_f64; m];
733        let mut hessian_row = vec![0.0_f64; m * m];
734        let mut negative_log_likelihood = 0.0_f64;
735        for row in 0..n {
736            eta_row.copy_from_slice(&eta_values[row * m..(row + 1) * m]);
737            response_row.copy_from_slice(&response_values[row * k..(row + 1) * k]);
738            let program = self.row_program(row, &eta_row, &response_row)?;
739            negative_log_likelihood += program.value_gradient_hessian_into(
740                &mut probabilities,
741                &mut gradient_nll,
742                &mut hessian_row,
743            );
744            for axis in 0..m {
745                gradient_values[row * m + axis] = -gradient_nll[axis];
746            }
747            hessian_values[row * m * m..(row + 1) * m * m].copy_from_slice(&hessian_row);
748        }
749        Ok((-negative_log_likelihood, gradient_log_likelihood, hessian))
750    }
751
752    /// Fused value/gradient entry for callers that do not consume curvature.
753    pub(crate) fn value_gradient(
754        &self,
755        eta: ArrayView2<f64>,
756        y: ArrayView2<f64>,
757    ) -> Result<(f64, Array2<f64>), EstimationError> {
758        let n = eta.nrows();
759        let m = self.active_classes;
760        let k = self.total_classes();
761        if y.dim() != (n, k) {
762            crate::bail_invalid_estim!(
763                "MultinomialLogitLikelihood::value_gradient: response shape {:?} must be ({n}, {k})",
764                y.dim()
765            );
766        }
767        validate_vector_likelihood_inputs(
768            "MultinomialLogitLikelihood::value_gradient active response",
769            eta,
770            y.slice(ndarray::s![.., ..m]),
771            Some(m),
772        )?;
773        let eta = eta.as_standard_layout();
774        let eta_values = eta
775            .as_slice()
776            .expect("standard-layout multinomial logits are contiguous");
777        let y = y.as_standard_layout();
778        let response_values = y
779            .as_slice()
780            .expect("standard-layout multinomial responses are contiguous");
781        let mut gradient_log_likelihood = Array2::<f64>::zeros((n, m));
782        let gradient_values = gradient_log_likelihood
783            .as_slice_mut()
784            .expect("fresh multinomial gradient is contiguous");
785        let mut eta_row = vec![0.0_f64; m];
786        let mut response_row = vec![0.0_f64; k];
787        let mut probabilities = vec![0.0_f64; k];
788        let mut gradient_nll = vec![0.0_f64; m];
789        let mut negative_log_likelihood = 0.0_f64;
790        for row in 0..n {
791            eta_row.copy_from_slice(&eta_values[row * m..(row + 1) * m]);
792            response_row.copy_from_slice(&response_values[row * k..(row + 1) * k]);
793            let program = self.row_program(row, &eta_row, &response_row)?;
794            negative_log_likelihood +=
795                program.value_gradient_into(&mut probabilities, &mut gradient_nll);
796            for axis in 0..m {
797                gradient_values[row * m + axis] = -gradient_nll[axis];
798            }
799        }
800        Ok((-negative_log_likelihood, gradient_log_likelihood))
801    }
802}
803
804impl VectorLikelihood for MultinomialLogitLikelihood {
805    fn log_lik(&self, eta: ArrayView2<f64>, y: ArrayView2<f64>) -> Result<f64, EstimationError> {
806        let n = eta.nrows();
807        let m = self.active_classes;
808        let k = self.total_classes();
809        if y.dim() != (n, k) {
810            crate::bail_invalid_estim!(
811                "MultinomialLogitLikelihood::log_lik: response shape {:?} must be ({n}, {k})",
812                y.dim()
813            );
814        }
815        validate_vector_likelihood_inputs(
816            "MultinomialLogitLikelihood::log_lik active response",
817            eta,
818            y.slice(ndarray::s![.., ..m]),
819            Some(m),
820        )?;
821        let mut eta_row = vec![0.0_f64; m];
822        let mut response_row = vec![0.0_f64; k];
823        let mut negative_log_likelihood = 0.0_f64;
824        for row in 0..n {
825            for axis in 0..m {
826                eta_row[axis] = eta[[row, axis]];
827            }
828            for class in 0..k {
829                response_row[class] = y[[row, class]];
830            }
831            negative_log_likelihood += self
832                .row_program(row, &eta_row, &response_row)?
833                .negative_log_likelihood();
834        }
835        Ok(-negative_log_likelihood)
836    }
837
838    fn grad_eta(
839        &self,
840        eta: ArrayView2<f64>,
841        y: ArrayView2<f64>,
842    ) -> Result<Array2<f64>, EstimationError> {
843        Ok(self.value_gradient(eta, y)?.1)
844    }
845
846    fn hess_diag(
847        &self,
848        eta: ArrayView2<f64>,
849        y: ArrayView2<f64>,
850    ) -> Result<Array2<f64>, EstimationError> {
851        // Per-row diagonal of the (M, M) Fisher block:
852        //     H_{n,a,a} = w_n · p_{n,a} · (1 − p_{n,a})
853        // Provided for callers that explicitly want the diagonal-only
854        // preconditioner; the joint dense block ships through `hess_block`.
855        let n = eta.nrows();
856        let m = self.active_classes;
857        let k = self.total_classes();
858        if y.dim() != (n, k) {
859            crate::bail_invalid_estim!(
860                "MultinomialLogitLikelihood::hess_diag: response shape {:?} must be ({n}, {k})",
861                y.dim()
862            );
863        }
864        validate_vector_likelihood_inputs(
865            "MultinomialLogitLikelihood::hess_diag active response",
866            eta,
867            y.slice(ndarray::s![.., ..m]),
868            Some(m),
869        )?;
870        let mut out = Array2::<f64>::zeros((n, m));
871        let mut eta_row = vec![0.0_f64; m];
872        let mut response_row = vec![0.0_f64; k];
873        let mut probabilities = vec![0.0_f64; k];
874        let mut diagonal = vec![0.0_f64; m];
875        for row in 0..n {
876            for axis in 0..m {
877                eta_row[axis] = eta[[row, axis]];
878            }
879            for class in 0..k {
880                response_row[class] = y[[row, class]];
881            }
882            self.row_program(row, &eta_row, &response_row)?
883                .hessian_diagonal_into(&mut probabilities, &mut diagonal);
884            for axis in 0..m {
885                out[[row, axis]] = diagonal[axis];
886            }
887        }
888        Ok(out)
889    }
890
891    fn hess_block(
892        &self,
893        eta: ArrayView2<f64>,
894        y: ArrayView2<f64>,
895    ) -> Result<Array3<f64>, EstimationError> {
896        Ok(self.value_gradient_hessian(eta, y)?.2)
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use ndarray::{Array1, Array2};
904
905    // Macro (not fn) so the assertion / panic tokens are inlined into each
906    // caller's test body, satisfying the build.rs scanner that looks for
907    // `assert!(` / `panic!(` directly in the `#[test]` function.
908    macro_rules! expect_invalid_input {
909        ($result:expr, $needle:expr $(,)?) => {{
910            let needle: &str = $needle;
911            match $result {
912                Ok(_) => {
913                    panic!("expected EstimationError::InvalidInput containing `{needle}`, got Ok")
914                }
915                Err(EstimationError::InvalidInput(msg)) => {
916                    assert!(
917                        msg.contains(needle),
918                        "InvalidInput message `{msg}` does not contain `{needle}`"
919                    );
920                    msg
921                }
922                Err(other) => panic!(
923                    "expected EstimationError::InvalidInput containing `{needle}`, got {other:?}"
924                ),
925            }
926        }};
927    }
928
929    fn dummy_target(n: usize, m: usize) -> VectorResponseTarget {
930        VectorResponseTarget::new(Array2::<f64>::zeros((n, m)), VectorNoise::Isotropic(1.0))
931    }
932
933    #[test]
934    fn with_row_weights_rejects_wrong_length() {
935        let target = dummy_target(4, 2);
936        let weights = Array1::from(vec![1.0, 1.0, 1.0]);
937        expect_invalid_input!(target.with_row_weights(weights), "row_weights length");
938    }
939
940    #[test]
941    fn with_row_weights_rejects_negative_entry() {
942        let target = dummy_target(3, 2);
943        let weights = Array1::from(vec![1.0, -0.5, 2.0]);
944        expect_invalid_input!(
945            target.with_row_weights(weights),
946            "must be finite and non-negative",
947        );
948    }
949
950    #[test]
951    fn with_row_weights_rejects_nan_entry() {
952        let target = dummy_target(3, 2);
953        let weights = Array1::from(vec![1.0, f64::NAN, 2.0]);
954        expect_invalid_input!(
955            target.with_row_weights(weights),
956            "must be finite and non-negative",
957        );
958    }
959
960    #[test]
961    fn with_row_weights_rejects_infinite_entry() {
962        let target = dummy_target(3, 2);
963        let weights = Array1::from(vec![1.0, f64::INFINITY, 2.0]);
964        expect_invalid_input!(
965            target.with_row_weights(weights),
966            "must be finite and non-negative",
967        );
968    }
969
970    #[test]
971    fn with_row_weights_accepts_zero_and_positive() {
972        let target = dummy_target(3, 2);
973        let weights = Array1::from(vec![0.0, 1.5, 3.0]);
974        let weighted = target
975            .with_row_weights(weights)
976            .expect("zero / positive weights should be accepted");
977        assert!(weighted.row_weights.is_some());
978    }
979
980    #[test]
981    fn from_target_rejects_low_rank_factor_with_wrong_row_count() {
982        let n = 4;
983        let m = 3;
984        // factor has 2 rows instead of M = 3.
985        let factor = Array2::from_shape_vec((2, 2), vec![0.1, 0.2, 0.3, 0.4]).unwrap();
986        let target = VectorResponseTarget::new(
987            Array2::<f64>::zeros((n, m)),
988            VectorNoise::LowRank {
989                diag: Array1::from(vec![1.0; m]),
990                factor,
991            },
992        );
993        expect_invalid_input!(GaussianVectorLikelihood::from_target(&target), "factor has",);
994    }
995
996    #[test]
997    fn from_target_rejects_non_finite_low_rank_factor_entry() {
998        let n = 4;
999        let m = 3;
1000        let mut factor = Array2::<f64>::zeros((m, 2));
1001        factor[[1, 0]] = f64::NAN;
1002        let target = VectorResponseTarget::new(
1003            Array2::<f64>::zeros((n, m)),
1004            VectorNoise::LowRank {
1005                diag: Array1::from(vec![1.0; m]),
1006                factor,
1007            },
1008        );
1009        expect_invalid_input!(
1010            GaussianVectorLikelihood::from_target(&target),
1011            "must be finite",
1012        );
1013    }
1014
1015    #[test]
1016    fn from_target_accepts_well_formed_low_rank_factor() {
1017        let n = 2;
1018        let m = 3;
1019        let factor = Array2::from_shape_vec((m, 2), vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]).unwrap();
1020        let target = VectorResponseTarget::new(
1021            Array2::<f64>::zeros((n, m)),
1022            VectorNoise::LowRank {
1023                diag: Array1::from(vec![1.0; m]),
1024                factor: factor.clone(),
1025            },
1026        );
1027        let lik = GaussianVectorLikelihood::from_target(&target)
1028            .expect("well-formed low-rank factor should be accepted");
1029        let stored = lik.factor.expect("low-rank factor should be carried");
1030        assert_eq!(stored.dim(), (m, 2));
1031        for ((i, j), v) in stored.indexed_iter() {
1032            assert_eq!(*v, factor[[i, j]]);
1033        }
1034        // `GaussianVectorLikelihood::precision` is the per-output diagonal
1035        // of length `M`, populated from `target.noise.diag_precision(M)`
1036        // — not a per-row precision of length `N`. The historical
1037        // `assert_eq!(n, lik.precision.len().max(n))` reduces to
1038        // `precision.len() ≤ n`, which is the opposite of the contract
1039        // (and false for any `M > N`, the typical multivariate-response
1040        // shape).
1041        assert_eq!(m, lik.precision.len());
1042    }
1043
1044    #[test]
1045    fn from_target_propagates_row_weight_length_mismatch() {
1046        let n = 3;
1047        let m = 2;
1048        let target = VectorResponseTarget {
1049            y: Array2::<f64>::zeros((n, m)),
1050            noise: VectorNoise::Isotropic(1.0),
1051            row_weights: Some(Array1::from(vec![1.0, 1.0])),
1052        };
1053        expect_invalid_input!(
1054            GaussianVectorLikelihood::from_target(&target),
1055            "row_weights length",
1056        );
1057    }
1058
1059    #[test]
1060    fn vector_likelihood_rejects_nonfinite_optimizer_state_without_panicking_932() {
1061        let target = dummy_target(1, 2);
1062        let likelihood =
1063            GaussianVectorLikelihood::from_target(&target).expect("finite Gaussian vector target");
1064        let eta = Array2::from_shape_vec((1, 2), vec![0.0, f64::NAN]).expect("eta shape");
1065        expect_invalid_input!(
1066            likelihood.log_lik(eta.view(), target.y.view()),
1067            "eta[0,1] must be finite",
1068        );
1069    }
1070
1071    #[test]
1072    fn multinomial_row_validation_propagates_as_typed_likelihood_error_932() {
1073        let likelihood = MultinomialLogitLikelihood::with_classes(3)
1074            .expect("three-class reference-coded likelihood");
1075        let eta =
1076            Array2::from_shape_vec((1, 2), vec![f64::INFINITY, 0.0]).expect("active eta shape");
1077        let response =
1078            Array2::from_shape_vec((1, 3), vec![1.0, 0.0, 0.0]).expect("simplex response shape");
1079        expect_invalid_input!(
1080            likelihood.log_lik(eta.view(), response.view()),
1081            "eta[0,0] must be finite",
1082        );
1083    }
1084}