Skip to main content

gam_models/
binomial_multi.rs

1//! Penalized multi-output binomial-logit fitter at fixed λ.
2//!
3//! This is the row-diagonal sibling of [`crate::multinomial`]: the
4//! same shared design `X ∈ ℝ^{N×P}` and shared penalty `S ∈ ℝ^{P×P}` are
5//! reused across `K` independent binomial-logit response columns. Per-column
6//! smoothing parameters `λ_a` (length `K`) scale `S` independently for each
7//! response. Because the Fisher information has no cross-column coupling
8//! (`H_{n,a,b} = δ_{ab} · w_n · μ_{n,a} (1 − μ_{n,a})`), the joint penalized
9//! Hessian is block-diagonal in the `K` `P × P` per-response systems; the
10//! shared [`crate::penalized_vector_glm`] engine factors that
11//! block-diagonal Hessian in a single coupled damped-Newton loop, which is
12//! mathematically identical to `K` independent per-column solves.
13//!
14//! # Fit problem
15//!
16//! Minimise the penalized negative log-likelihood
17//!
18//! ```text
19//!   F(β) = − Σ_n Σ_a w_n [ y_{n,a} log μ_{n,a} + (1 − y_{n,a}) log(1 − μ_{n,a}) ]
20//!           + ½ Σ_a λ_a · β_aᵀ S β_a
21//! ```
22//!
23//! with `μ_{n,a} = σ(η_{n,a})`, `η_{n,a} = (X β_a)_n`. The per-column Newton
24//! step solves
25//!
26//! ```text
27//!   (Xᵀ diag(w_n μ_{n,a}(1 − μ_{n,a})) X + λ_a S) δ_a = − [Xᵀ diag(w_n)(μ_{·,a} − y_{·,a}) + λ_a S β_a]
28//! ```
29//!
30//! followed by a backtracking line search on `F` (full step first, halve up
31//! to 8 times) so monotone descent is enforced even when the quadratic
32//! model overshoots near saturation. This is precisely the shared
33//! [`crate::penalized_vector_glm`] scaffold; this module supplies
34//! only the row-diagonal binomial Fisher block, residual, and log-likelihood
35//! via [`BinomialMultiLikelihood`].
36//!
37//! # Relation to the multi-class softmax driver
38//!
39//! [`crate::multinomial::fit_penalized_multinomial`] handles the
40//! coupled softmax Fisher block `H_{n,a,b} = w_n μ_{n,a} (δ_{ab} − μ_{n,b})`
41//! and is the right entry when the user wants a single normalized
42//! probability vector per row. This driver is the right entry when the
43//! user has `K` independent binary marginals sharing a smooth basis (e.g.
44//! multi-label classification, multi-trait penalised logistic regression
45//! on a Duchon latent design). Both families are thin Fisher-block adapters
46//! over the same `penalized_vector_glm` engine: the only difference is that
47//! the softmax block is dense across outputs while these binomial columns are
48//! row-diagonal.
49//!
50//! The function-boundary contract mirrors `fit_penalized_multinomial` so
51//! the two are interchangeable at the FFI layer: same input arity, same
52//! convergence semantics, same `(N, K)` fitted-probability output.
53
54use crate::model_types::EstimationError;
55use crate::penalized_vector_glm::{
56    PenalizedVectorGlmInputs, VectorGlmSolve, fit_penalized_vector_glm,
57};
58use crate::vector_response::{VectorLikelihood, validate_vector_likelihood_inputs};
59use gam_problem::FixedLambdaSolverStage;
60use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3};
61
62/// Inputs for [`fit_penalized_binomial_multi`].
63#[derive(Debug, Clone)]
64pub struct BinomialMultiFitInputs<'a> {
65    /// Design matrix `X ∈ ℝ^{N×P}` (one row per observation, shared across
66    /// all response columns).
67    pub design: ArrayView2<'a, f64>,
68    /// Multi-column binomial response `Y ∈ ℝ^{N×K}`. Each column is treated
69    /// as an independent binomial-logit response, so every entry must be a
70    /// binomial proportion in `[0, 1]` (hard `{0, 1}` Bernoulli labels and soft
71    /// proportions / probabilities alike). Entries outside `[0, 1]` are
72    /// rejected because the per-entry log-likelihood is then unbounded in `η`.
73    pub y: ArrayView2<'a, f64>,
74    /// Shared smoothing penalty `S ∈ ℝ^{P×P}` (symmetric, PSD).
75    pub penalty: ArrayView2<'a, f64>,
76    /// Per-response smoothing parameter `λ_a` (length `K`).
77    pub lambdas: ArrayView1<'a, f64>,
78    /// Optional per-row weights (length `N`); `None` ⇒ uniform 1.0.
79    pub row_weights: Option<ArrayView1<'a, f64>>,
80    /// Optional per-row Fisher-block override, shape `(N, K, K)`. The `K`
81    /// binomial-logit columns are fit independently, so only the per-column
82    /// diagonal `[n, a, a]` is consumed as the curvature `w_n μ_a(1 − μ_a)`;
83    /// off-diagonals must be zero (enforced at the FFI boundary) since a
84    /// non-zero cross term cannot be represented by the separable per-column
85    /// solve. The gradient/residual path stays analytic — this is a
86    /// curvature-only override (issue #349). Diagonal entries must be finite
87    /// and non-negative.
88    pub fisher_w_override: Option<ArrayView3<'a, f64>>,
89    /// Maximum Newton iterations per response column; recommend 50.
90    pub max_iter: usize,
91    /// Relative-step convergence tolerance; recommend 1e-7.
92    pub tol: f64,
93}
94
95/// Outputs of [`fit_penalized_binomial_multi`].
96#[derive(Debug, Clone)]
97pub struct BinomialMultiFitOutputs {
98    /// Coefficient matrix, shape `(P, K)` (column `a` is `β_a`).
99    pub coefficients: Array2<f64>,
100    /// Fitted probabilities `μ_{n,a} = σ((X β_a)_n)`, shape `(N, K)`.
101    pub fitted_probabilities: Array2<f64>,
102    /// Number of joint Newton iterations executed (including the final step
103    /// that satisfied the tolerance). The `K` columns share the design and
104    /// are fitted by a single coupled damped-Newton loop over the
105    /// block-diagonal penalized Hessian, so there is one iteration count for
106    /// the whole solve.
107    pub iterations: usize,
108    /// Penalized negative log-likelihood at the returned `β̂`:
109    /// `−log L(β̂) + ½ Σ_a λ_a · β̂_aᵀ S β̂_a`.
110    pub penalized_neg_log_likelihood: f64,
111    /// Unpenalized deviance `−2 log L(β̂)` for diagnostic reporting.
112    pub deviance: f64,
113}
114
115/// Numerically stable logistic CDF used by the Newton driver. Mirrors the
116/// inline helper that previously lived in `crates/gam-pyffi/src/lib.rs`.
117#[inline]
118fn sigmoid_stable(eta: f64) -> f64 {
119    if eta >= 0.0 {
120        let e = (-eta).exp();
121        1.0 / (1.0 + e)
122    } else {
123        let e = eta.exp();
124        e / (1.0 + e)
125    }
126}
127
128/// Row-diagonal multi-output binomial-logit likelihood adapter for the shared
129/// [`crate::penalized_vector_glm`] engine.
130///
131/// The `K` response columns are mutually independent binomial-logit marginals
132/// sharing the design `X`, so the per-row Fisher block is **diagonal across
133/// outputs**: `H_{n,a,b} = δ_{ab} · w_n · μ_{n,a} (1 − μ_{n,a})`. The engine
134/// works in `η = X β` space with `μ_{n,a} = σ(η_{n,a})`; this adapter supplies
135/// the log-likelihood, the residual gradient `w_n (y_a − μ_a)`, and that
136/// row-diagonal block.
137struct BinomialMultiLikelihood {
138    /// Optional per-row weights (length N), or `None` for uniform 1.0.
139    row_weights: Option<Array1<f64>>,
140}
141
142impl BinomialMultiLikelihood {
143    #[inline]
144    fn row_weight(&self, n: usize) -> f64 {
145        self.row_weights.as_ref().map_or(1.0, |w| w[n])
146    }
147}
148
149impl VectorLikelihood for BinomialMultiLikelihood {
150    /// `Σ_n Σ_a w_n [ y_{n,a} log μ_{n,a} + (1 − y_{n,a}) log(1 − μ_{n,a}) ]`,
151    /// evaluated in log-space via `log μ = −softplus(−η)`,
152    /// `log(1 − μ) = −softplus(η)` — exact and finite for every η, with no
153    /// probability clamp. The former `μ.clamp(1e-12, 1−1e-12)` made this value
154    /// FLAT beyond |η| ≈ 27.6 while [`Self::grad_eta`]/[`Self::hess_diag`]
155    /// kept reporting the unclamped derivatives, so the line search scored a
156    /// surface the Newton direction was not the derivative of: on a
157    /// misclassified saturated row the gradient pushed full-strength while
158    /// the objective registered no improvement. The softplus form keeps the
159    /// true slope (≈ |η| per unit) at any saturation, so value, gradient, and
160    /// curvature are exact surfaces of ONE function.
161    fn log_lik(
162        &self,
163        eta: ArrayView2<'_, f64>,
164        y: ArrayView2<'_, f64>,
165    ) -> Result<f64, EstimationError> {
166        validate_vector_likelihood_inputs("BinomialMultiLikelihood::log_lik", eta, y, None)?;
167        let (n, k) = eta.dim();
168        let mut acc = 0.0_f64;
169        for row in 0..n {
170            let w = self.row_weight(row);
171            for a in 0..k {
172                let e = eta[[row, a]];
173                let yv = y[[row, a]];
174                acc -= w
175                    * (yv * gam_linalg::utils::stable_softplus(-e)
176                        + (1.0 - yv) * gam_linalg::utils::stable_softplus(e));
177            }
178        }
179        Ok(acc)
180    }
181
182    /// `∂ log L / ∂η_{n,a} = w_n (y_{n,a} − μ_{n,a})`.
183    fn grad_eta(
184        &self,
185        eta: ArrayView2<'_, f64>,
186        y: ArrayView2<'_, f64>,
187    ) -> Result<Array2<f64>, EstimationError> {
188        validate_vector_likelihood_inputs("BinomialMultiLikelihood::grad_eta", eta, y, None)?;
189        let (n, k) = eta.dim();
190        let mut out = Array2::<f64>::zeros((n, k));
191        for row in 0..n {
192            let w = self.row_weight(row);
193            for a in 0..k {
194                let mu = sigmoid_stable(eta[[row, a]]);
195                out[[row, a]] = w * (y[[row, a]] - mu);
196            }
197        }
198        Ok(out)
199    }
200
201    /// Per-output diagonal curvature `w_n μ_{n,a} (1 − μ_{n,a})`. The Fisher
202    /// information of independent Bernoulli outputs is `y`-independent; `y` is
203    /// read only to assert the target shape matches `eta`, as in the sibling
204    /// [`VectorLikelihood`] implementations.
205    fn hess_diag(
206        &self,
207        eta: ArrayView2<'_, f64>,
208        y: ArrayView2<'_, f64>,
209    ) -> Result<Array2<f64>, EstimationError> {
210        validate_vector_likelihood_inputs("BinomialMultiLikelihood::hess_diag", eta, y, None)?;
211        let (n, k) = eta.dim();
212        let mut out = Array2::<f64>::zeros((n, k));
213        for row in 0..n {
214            let w = self.row_weight(row);
215            for a in 0..k {
216                let mu = sigmoid_stable(eta[[row, a]]);
217                out[[row, a]] = w * mu * (1.0 - mu);
218            }
219        }
220        Ok(out)
221    }
222
223    /// Row-diagonal Fisher block `H_{n,a,b} = δ_{ab} · w_n μ_{n,a}(1 − μ_{n,a})`.
224    /// The independent columns have no cross-output coupling, so the off-diagonal
225    /// entries are identically zero; lifting [`Self::hess_diag`] onto the per-row
226    /// diagonal (the [`VectorLikelihood`] default) is exact here.
227    fn hess_block(
228        &self,
229        eta: ArrayView2<'_, f64>,
230        y: ArrayView2<'_, f64>,
231    ) -> Result<Array3<f64>, EstimationError> {
232        let diag = self.hess_diag(eta, y)?;
233        let (n, k) = diag.dim();
234        let mut out = Array3::<f64>::zeros((n, k, k));
235        for row in 0..n {
236            for a in 0..k {
237                out[[row, a, a]] = diag[[row, a]];
238            }
239        }
240        Ok(out)
241    }
242}
243
244/// Fit `K` independent penalized binomial-logit GLMs sharing the design `X`
245/// and penalty `S`. See the module docs for the optimization problem.
246pub fn fit_penalized_binomial_multi(
247    inputs: BinomialMultiFitInputs<'_>,
248) -> Result<BinomialMultiFitOutputs, EstimationError> {
249    let BinomialMultiFitInputs {
250        design,
251        y,
252        penalty,
253        lambdas,
254        row_weights,
255        fisher_w_override,
256        max_iter,
257        tol,
258    } = inputs;
259
260    // ──────────────────────── family-specific validation ───────────────────
261    // The engine re-validates the shared geometry (nonempty design, penalty
262    // shape, λ finiteness/non-negativity, override `(N, M, M)` shape, finite
263    // design), but the binomial family owns three preconditions the generic
264    // scaffold cannot know: the response must be a `[0, 1]` proportion, the
265    // optional row weights must be finite and non-negative, and the optional
266    // curvature override must be **row-diagonal** (independent columns carry no
267    // cross-output coupling, so a non-zero off-diagonal cannot be represented).
268    let n_obs = design.nrows();
269    let (y_rows, k) = y.dim();
270    if y_rows != n_obs {
271        crate::bail_invalid_estim!(
272            "fit_penalized_binomial_multi: y rows {y_rows} ≠ design rows {n_obs}"
273        );
274    }
275    if k == 0 {
276        crate::bail_invalid_estim!(
277            "fit_penalized_binomial_multi: y must have at least one column (got K=0)"
278        );
279    }
280    if lambdas.len() != k {
281        crate::bail_invalid_estim!(
282            "fit_penalized_binomial_multi: lambdas length {} ≠ K = {k}",
283            lambdas.len()
284        );
285    }
286    if let Some(fw) = fisher_w_override.as_ref() {
287        if fw.dim() != (n_obs, k, k) {
288            crate::bail_invalid_estim!(
289                "fit_penalized_binomial_multi: fisher_w_override shape {:?} ≠ (N, K, K) = ({n_obs}, {k}, {k})",
290                fw.dim()
291            );
292        }
293        // Independent binomial columns have a strictly row-diagonal Fisher
294        // block; a non-zero cross term `[n, a, b]` (a ≠ b) cannot be the
295        // curvature of a separable per-column objective, so reject it rather
296        // than silently couple the columns through the shared dense solve.
297        for ((n_idx, a, b), &v) in fw.indexed_iter() {
298            if a != b && v != 0.0 {
299                crate::bail_invalid_estim!(
300                    "fit_penalized_binomial_multi: fisher_w_override[{n_idx},{a},{b}] must be zero \
301                     (independent columns have a row-diagonal Fisher block); got {v}"
302                );
303            }
304        }
305    }
306    if let Some(w) = row_weights.as_ref() {
307        if w.len() != n_obs {
308            crate::bail_invalid_estim!(
309                "fit_penalized_binomial_multi: row_weights length {} ≠ N = {n_obs}",
310                w.len()
311            );
312        }
313        for (i, &v) in w.iter().enumerate() {
314            if !(v.is_finite() && v >= 0.0) {
315                crate::bail_invalid_estim!(
316                    "fit_penalized_binomial_multi: row_weights[{i}] must be finite and ≥ 0 (got {v})"
317                );
318            }
319        }
320    }
321    for ((i, j), &v) in y.indexed_iter() {
322        // The per-entry objective y log μ + (1 − y) log(1 − μ) is the binomial
323        // (Bernoulli / proportion) log-likelihood only when 0 ≤ y ≤ 1. Outside
324        // that range it is unbounded above in η (e.g. y = 2 gives
325        // 2η − log(1 + e^η) → ∞), so a finite-but-invalid entry would make the
326        // stated likelihood not a binomial likelihood at all. Reject it here.
327        if !(v.is_finite() && (0.0..=1.0).contains(&v)) {
328            crate::bail_invalid_estim!(
329                "fit_penalized_binomial_multi: y[{i},{j}] must be a binomial proportion in [0,1] (got {v})"
330            );
331        }
332    }
333
334    // ─────────────────── shared penalized vector-GLM solve ─────────────────
335    let likelihood = BinomialMultiLikelihood {
336        row_weights: row_weights.map(|w| w.to_owned()),
337    };
338    let solve = fit_penalized_vector_glm(
339        PenalizedVectorGlmInputs {
340            design,
341            y,
342            penalty,
343            lambdas,
344            fisher_w_override,
345            max_iter,
346            tol,
347            // Independent-binomial columns ARE genuinely independent outputs, so
348            // the per-output Diagonal penalty is correct here (the #1587 Centered
349            // metric is softmax-specific — there is no shared reference class).
350            class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Diagonal,
351            resume_from: None,
352        },
353        &likelihood,
354        "fit_penalized_binomial_multi",
355    )?;
356
357    let fit = match solve {
358        VectorGlmSolve::Converged(fit) => fit,
359        VectorGlmSolve::Stalled(stall) => {
360            // SPEC: a fit object must only ever come from a converged
361            // optimization. Exhausting `max_iter` is a typed error carrying
362            // evidence from the checkpoint, never an `Ok` with a flag.
363            return Err(stall.into_nonconvergence_error(
364                FixedLambdaSolverStage::BinomialMultiNewton,
365                "fit_penalized_binomial_multi (fixed-λ vector-GLM damped Newton)",
366            )?);
367        }
368    };
369
370    // η → μ = σ(η) is the binomial inverse link applied column-wise.
371    let fitted = fit.eta.mapv(sigmoid_stable);
372
373    Ok(BinomialMultiFitOutputs {
374        coefficients: fit.coefficients,
375        fitted_probabilities: fitted,
376        iterations: fit.iterations,
377        penalized_neg_log_likelihood: -fit.log_likelihood + fit.penalty_term,
378        deviance: -2.0 * fit.log_likelihood,
379    })
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use ndarray::Array3;
386
387    fn toy_inputs() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
388        let n = 12;
389        let p = 2;
390        let k = 2;
391        let design =
392            Array2::<f64>::from_shape_fn(
393                (n, p),
394                |(i, j)| {
395                    if j == 0 { 1.0 } else { ((i + 1) as f64).sin() }
396                },
397            );
398        let y =
399            Array2::<f64>::from_shape_fn((n, k), |(i, a)| if (i + a) % 2 == 0 { 1.0 } else { 0.0 });
400        let penalty = Array2::<f64>::eye(p);
401        let lambdas = Array1::<f64>::from_elem(k, 0.5);
402        (design, y, penalty, lambdas)
403    }
404
405    #[test]
406    fn fisher_override_none_reproduces_analytic_bit_for_bit() {
407        // Issue #349: a None override must give exactly the analytic result.
408        let (design, y, penalty, lambdas) = toy_inputs();
409        let base = fit_penalized_binomial_multi(BinomialMultiFitInputs {
410            design: design.view(),
411            y: y.view(),
412            penalty: penalty.view(),
413            lambdas: lambdas.view(),
414            row_weights: None,
415            fisher_w_override: None,
416            max_iter: 50,
417            tol: 1.0e-9,
418        })
419        .expect("analytic fit must succeed");
420        // Explicit None again — identical result.
421        let again = fit_penalized_binomial_multi(BinomialMultiFitInputs {
422            design: design.view(),
423            y: y.view(),
424            penalty: penalty.view(),
425            lambdas: lambdas.view(),
426            row_weights: None,
427            fisher_w_override: None,
428            max_iter: 50,
429            tol: 1.0e-9,
430        })
431        .expect("analytic fit must succeed");
432        for (a, b) in base.coefficients.iter().zip(again.coefficients.iter()) {
433            assert_eq!(a, b, "None override must be deterministic");
434        }
435    }
436
437    #[test]
438    fn exhausted_fixed_lambda_budget_is_typed_error_not_fit() {
439        let (design, y, penalty, lambdas) = toy_inputs();
440        let error = fit_penalized_binomial_multi(BinomialMultiFitInputs {
441            design: design.view(),
442            y: y.view(),
443            penalty: penalty.view(),
444            lambdas: lambdas.view(),
445            row_weights: None,
446            fisher_w_override: None,
447            max_iter: 0,
448            tol: 1.0e-9,
449        })
450        .expect_err("a zero-budget Newton solve must not mint a binomial fit");
451        assert!(matches!(
452            error,
453            EstimationError::FixedLambdaNewtonDidNotConverge {
454                objective_value,
455                checkpoint,
456                ..
457            } if objective_value.is_finite()
458                && checkpoint.stage() == FixedLambdaSolverStage::BinomialMultiNewton
459                && checkpoint.completed_iterations() == 0
460        ));
461    }
462
463    #[test]
464    fn out_of_range_response_is_rejected() {
465        // Issue #452: a finite but invalid entry (y = 2) makes the per-entry
466        // binomial log-likelihood unbounded in η, so it must be rejected rather
467        // than silently fit. The same guard covers negative entries.
468        let (design, y, penalty, lambdas) = toy_inputs();
469        let mut bad = y.clone();
470        bad[[0, 0]] = 2.0;
471        let err = fit_penalized_binomial_multi(BinomialMultiFitInputs {
472            design: design.view(),
473            y: bad.view(),
474            penalty: penalty.view(),
475            lambdas: lambdas.view(),
476            row_weights: None,
477            fisher_w_override: None,
478            max_iter: 50,
479            tol: 1.0e-9,
480        })
481        .expect_err("out-of-range response must error");
482        assert!(format!("{err}").contains("binomial proportion in [0,1]"));
483
484        let mut neg = y.clone();
485        neg[[1, 1]] = -0.5;
486        let err = fit_penalized_binomial_multi(BinomialMultiFitInputs {
487            design: design.view(),
488            y: neg.view(),
489            penalty: penalty.view(),
490            lambdas: lambdas.view(),
491            row_weights: None,
492            fisher_w_override: None,
493            max_iter: 50,
494            tol: 1.0e-9,
495        })
496        .expect_err("negative response must error");
497        assert!(format!("{err}").contains("binomial proportion in [0,1]"));
498    }
499
500    #[test]
501    fn fisher_override_shape_mismatch_is_rejected() {
502        let (design, y, penalty, lambdas) = toy_inputs();
503        let n = design.nrows();
504        let k = y.ncols();
505        let bad = Array3::<f64>::zeros((n, k + 1, k + 1));
506        let err = fit_penalized_binomial_multi(BinomialMultiFitInputs {
507            design: design.view(),
508            y: y.view(),
509            penalty: penalty.view(),
510            lambdas: lambdas.view(),
511            row_weights: None,
512            fisher_w_override: Some(bad.view()),
513            max_iter: 50,
514            tol: 1.0e-9,
515        })
516        .expect_err("mismatched override shape must error");
517        assert!(format!("{err}").contains("fisher_w_override shape"));
518    }
519
520    #[test]
521    fn fisher_override_replaces_curvature_diagonal() {
522        // A scaled curvature override changes the Newton step from β = 0:
523        // with curvature scaled by α the first step is 1/α of the analytic
524        // step (gradient unchanged), so the fitted β must differ from analytic.
525        let (design, y, penalty, lambdas) = toy_inputs();
526        let n = design.nrows();
527        let k = y.ncols();
528        // Analytic diagonal at β = 0 is μ(1−μ) = 0.25 for every column.
529        let mut over = Array3::<f64>::zeros((n, k, k));
530        for row in 0..n {
531            for a in 0..k {
532                over[[row, a, a]] = 0.25 * 4.0; // 4× the analytic curvature
533            }
534        }
535        let likelihood = BinomialMultiLikelihood { row_weights: None };
536        let scaled = fit_penalized_vector_glm(
537            PenalizedVectorGlmInputs {
538                design: design.view(),
539                y: y.view(),
540                penalty: penalty.view(),
541                lambdas: lambdas.view(),
542                fisher_w_override: Some(over.view()),
543                max_iter: 1,
544                tol: 1.0e-9,
545                class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Diagonal,
546                resume_from: None,
547            },
548            &likelihood,
549            "binomial scaled-curvature first-step test",
550        )
551        .expect("scaled-curvature engine step must be finite");
552        let analytic = fit_penalized_vector_glm(
553            PenalizedVectorGlmInputs {
554                design: design.view(),
555                y: y.view(),
556                penalty: penalty.view(),
557                lambdas: lambdas.view(),
558                fisher_w_override: None,
559                max_iter: 1,
560                tol: 1.0e-9,
561                class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Diagonal,
562                resume_from: None,
563            },
564            &likelihood,
565            "binomial analytic-curvature first-step test",
566        )
567        .expect("analytic-curvature engine step must be finite");
568        let checkpoint_coefficients = |solve| match solve {
569            VectorGlmSolve::Converged(fit) => fit.coefficients,
570            VectorGlmSolve::Stalled(stall) => stall.coefficients,
571        };
572        let scaled = checkpoint_coefficients(scaled);
573        let analytic = checkpoint_coefficients(analytic);
574        let differs = scaled
575            .iter()
576            .zip(analytic.iter())
577            .any(|(a, b)| (a - b).abs() > 1.0e-6);
578        assert!(differs, "scaled curvature override must change the step");
579    }
580}