Skip to main content

ferrolearn_linear/
glm.rs

1//! Generalized Linear Models (GLM).
2//!
3//! This module provides IRLS-based GLM regressors for count and positive
4//! continuous data:
5//!
6//! - **[`GLMRegressor`]** — Generic GLM with selectable [`GLMFamily`]
7//! - **[`PoissonRegressor`]** — Convenience wrapper with Poisson family
8//! - **[`GammaRegressor`]** — Convenience wrapper with Gamma family
9//! - **[`TweedieRegressor`]** — Convenience wrapper with Tweedie family
10//!
11//! All models use Iteratively Reweighted Least Squares (IRLS) and L2
12//! regularization. The link function is fixed to **log** for Poisson and Gamma
13//! (their sklearn losses are log-link only); [`TweedieRegressor`] selects its
14//! [`Link`] via a `link` configuration (`auto`/`identity`/`log`), matching
15//! `sklearn/linear_model/_glm/glm.py:889-903`.
16//!
17//! # Examples
18//!
19//! ```
20//! use ferrolearn_linear::PoissonRegressor;
21//! use ferrolearn_core::{Fit, Predict};
22//! use ndarray::{array, Array1, Array2};
23//!
24//! let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
25//! let y = array![2.0, 5.0, 10.0, 20.0];
26//!
27//! let model = PoissonRegressor::<f64>::new().with_alpha(0.0);
28//! let fitted = model.fit(&x, &y).unwrap();
29//! let preds = fitted.predict(&x).unwrap();
30//! assert_eq!(preds.len(), 4);
31//! ```
32//!
33//! ## REQ status (per `.design/linear/glm.md`, mirrors `sklearn/linear_model/_glm/glm.py` @ 1.5.2, commit 156ef14)
34//!
35//! Binary classification (R-DEFER-2): SHIPPED = impl + tests + green oracle
36//! verification; NOT-STARTED = concrete open blocker referenced by `#`-number.
37//! The public estimator types re-exported at the crate root are the consumer
38//! surface (R-DEFER-1; no `ferrolearn-python` GLM binding yet).
39//!
40//! | REQ | Status | Evidence |
41//! |---|---|---|
42//! | REQ-4 (penalized objective: mean half-deviance + ½·alpha, intercept-free) | SHIPPED | `fn weighted_ridge_solve` adds the L2 penalty `weight_sum * alpha` to feature columns only, skipping the intercept column (`intercept_col`), matching sklearn's mean-deviance objective + unpenalized intercept (`glm.py:229-258`: `obj = average(½·deviance) + ½·alpha·‖coef‖²`, `l2_reg_strength = self.alpha`). Oracle parity tests `glm_poisson_intercept_unpenalized` (alpha=1e6 → `intercept_ = log(mean y)`, coef → 0) and `glm_poisson_penalty_scaling` (alpha=1.0 → `coef_=[0.34151720,0.18859745]`, `intercept_=-0.37680132`) green in `tests/divergence_glm_fit.rs`. |
43//! | REQ-1 (Poisson family + log link) | SHIPPED | #548. `fn fit_glm_irls` with `GLMFamily::Poisson` (`variance => mu`, log-link Fisher scoring) under the REQ-4 mean-deviance/unpenalized-intercept objective fits `PoissonRegressor` to sklearn's `PoissonRegressor` (`HalfPoissonLoss`, log link, `glm.py:589-590`) at BOTH alpha=0 and alpha>0. Consumer: `PoissonRegressor::fit` (crate-root export). Oracle parity tests `glm_poisson_alpha_half_parity` (alpha=0.5 → live coef `[0.38388476754733647,0.2024000617918683]`, int `-0.519356533563308`), `glm_solver_param_invariant`, `glm_poisson_sample_weight`, `glm_poisson_penalty_scaling` green in `tests/divergence_glm_fit.rs` (the alpha=0 MLE matches to <1e-9, the module-header note). |
44//! | REQ-2 (Gamma family + log link) | SHIPPED | #549. `GLMFamily::Gamma` (`variance => mu²`) drives `w = mu²/V(mu)` log-link IRLS; `GammaRegressor` matches sklearn's `GammaRegressor` (`HalfGammaLoss`, log link, y-domain `0 < y`, `glm.py:721-722`) at alpha=0 and alpha>0. The `y == 0` rejection (`HalfGammaLoss` open at 0) is enforced under REQ-14 (`YDomain::Positive`). Consumer: `GammaRegressor::fit` (crate-root export). Oracle tests `glm_gamma_alpha_half_parity` (alpha=0.5 → live coef `[0.24773782526507374,0.11636425618936652]`, int `0.3599464049766692`), `glm_gamma_sample_weight`, `glm_gamma_rejects_zero_y` green. |
45//! | REQ-3 (Tweedie family + power) | SHIPPED | #550. `GLMFamily::Tweedie(p)` (`variance => mu^p`) with the link resolved from `power`/`link` (REQ-8); `TweedieRegressor(power=p)` matches sklearn's `TweedieRegressor` for the log-link powers `p>0` AND the identity-link `p<=0` (`HalfTweedieLoss`/`HalfTweedieLossIdentity`, `glm.py:889-903`) at alpha=0 and alpha>0. Verified live against the oracle for `p ∈ {0,1,1.5,2,3}` to <1e-8. Consumer: `TweedieRegressor::fit` (crate-root export). Oracle tests `glm_tweedie_alpha_half_parity` (`power=1.5, alpha=0.5` → live coef `[0.25606046404981164,0.11657692670900446]`, int `0.3563978246931595`), `glm_tweedie_power0_identity_link`, `glm_tweedie_power0_predict_identity_inverse`, `glm_tweedie_power2_rejects_zero_y` green. |
46//! | REQ-5 (intercept init = link(weighted_mean(y))) | SHIPPED | #552. In `fn fit_glm_irls`, when `fit_intercept` AND NOT (warm_start with `coef_init`), the intercept entry is seeded at `coef[0] = link.link(weighted_mean(y))` (feature coefs stay 0) and `eta`/`mu` are recomputed from that seed, mirroring sklearn's `coef[-1] = link.link(np.average(y, weights=sample_weight))` (`glm.py:251-256`); `weighted_mean(y) = Σ(sᵢ·yᵢ)/Σ(sᵢ)`. warm_start with an explicit `coef_init` (REQ-11) takes precedence (the warm seed overrides the init), and a non-finite seed (e.g. `log(0)` for all-zero Poisson `y`) falls back to the previous cold start (intercept 0) with NO panic/NaN (R-CODE-2). The penalized GLM objective is convex, so the converged `coef_`/`intercept_` are init-invariant — all 22 pre-existing oracle tests stay byte-identical at convergence. Consumer: each estimator's `Fit::fit` (crate-root export). Oracle tests `glm_intercept_init_matches_sklearn_first_iterate` (constant `y=7` → `max_iter=1` intercept = `log(7) = 1.9459101490553132`, == live sklearn's first iterate; feature coef 0), `glm_intercept_init_converged_optimum_unchanged` (alpha=0.5 optimum unchanged vs oracle), `glm_intercept_init_all_zero_y_no_nan` (non-finite-seed fallback, finite result) green in `tests/divergence_glm_fit.rs`. |
47//! | REQ-13 (score = D², deviance-explained) | SHIPPED | #559. `#[must_use] pub fn score(&self, x, y) -> Result<F, FerroError>` on `FittedGLMRegressor` computes `D² = 1 − (deviance + constant)/(deviance_null + constant)` (`glm.py:365-438`): `μ = predict(x)`, the null model predicts the (unweighted) mean `ȳ` for every sample, and the per-family unit deviance comes from `GLMFamily::unit_deviance` (Poisson `2·(y·ln(y/μ) − y + μ)`, y=0→`2μ`; Gamma `2·(−ln(y/μ) + (y−μ)/μ)`; Tweedie p=0 `(y−μ)²`; general-p `2·(y^(2−p)/((1−p)(2−p)) − y·μ^(1−p)/(1−p) + μ^(2−p)/(2−p))`), verified term-for-term against `sklearn/_loss/loss.py` (`HalfPoissonLoss:728-742`, `HalfGammaLoss:754-773`, `HalfTweedieLoss:789-837`). `GLMFamily::constant_to_optimal_zero` restores sklearn's `+ constant` so the degenerate constant-`y` boundary matches the oracle. `score` re-validates the y-domain (`YDomain::for_power`), mirroring `glm.py:413-417`. Consumer: the crate-root-exported `FittedGLMRegressor::score` (a public method on the boundary fitted type). Oracle tests `glm_poisson_d2_score` (D²=0.7979479374534378), `glm_gamma_d2_score` (0.8987486959882107), `glm_tweedie_power0_d2_score` (0.9319946452476573, == R²), `glm_tweedie_d2_score` (0.9277805586816806), `glm_score_rejects_out_of_domain_y` green in `tests/divergence_glm_fit.rs`; all 14 pre-existing glm divergence tests stay green. |
48//! | REQ-7 (predict = link.inverse) | SHIPPED | `fn predict` applies `self.link.inverse(eta)` (`Link::Log => exp`, `Link::Identity => eta`), mirroring `glm.py:362` (`y_pred = link.inverse(raw_prediction)`). Consumer: the crate-root-exported `FittedGLMRegressor::predict` used by every wrapper; oracle test `glm_tweedie_power0_predict_identity_inverse` (identity link → raw linear predictor `[0.4,6.3,12.2,18.1]`) green in `tests/divergence_glm_fit.rs`. |
49//! | REQ-8 (Tweedie link='auto'/identity/log) | SHIPPED | `pub enum Link { Log, Identity }` + `pub enum LinkConfig { Auto, Log, Identity }` with `LinkConfig::resolve(power)`: Auto → identity for `power <= 0`, log otherwise (`glm.py:889-893`). `TweedieRegressor.link: LinkConfig` (default `Auto`) is resolved at fit time and threaded into `fit_glm_irls`'s link-parameterized IRLS (`w = dmu_deta^2/V(mu)`, `z = eta + (y-mu)/dmu_deta`) and the fitted struct. Consumer: `TweedieRegressor::fit` (crate-root export); oracle test `glm_tweedie_power0_identity_link` (`coef_=[5.9]`, `intercept_=-5.5`, OLS) green. Poisson/Gamma wire `Link::Log` explicitly. |
50//! | REQ-10 (solver param: lbfgs/newton-cholesky) | SHIPPED | #556. **R-DEV-2 (API parity):** `pub enum Solver { Lbfgs, NewtonCholesky }` + a `pub solver: Solver` field (default `Solver::Lbfgs`) on `GLMRegressor`/`PoissonRegressor`/`GammaRegressor`/`TweedieRegressor`, plus `#[must_use] fn with_solver`, mirroring sklearn's validated `solver` constructor parameter `StrOptions({"lbfgs","newton-cholesky"})` default `"lbfgs"` (`glm.py:140-145, :155`); the two-variant enum mirrors the `StrOptions` constraint. **R-DEV-7 (implementation differs, observable contract preserved):** ferrolearn fits all GLMs via IRLS/Fisher-scoring (`fn fit_glm_irls`) regardless of `solver` — the penalized GLM objective is convex, so IRLS reaches the SAME minimizer as both sklearn solvers (verified live: `PoissonRegressor(alpha=0.5)` gives coef `[0.38388523,0.20239975]`, int `-0.51935749` for `lbfgs` AND `newton-cholesky`, identical to ~1e-9). Consumer: each estimator's `Fit::fit` (crate-root export) — the `solver` field is part of the boundary estimator ABI. Oracle test `glm_solver_param_invariant` (fits with `Solver::Lbfgs` and `Solver::NewtonCholesky`, both coef/intercept match the solver-invariant live sklearn 1.5.2 oracle to 1e-4) green in `tests/divergence_glm_fit.rs`; the 19 pre-existing glm divergence tests stay green. |
51//! | REQ-9 (Tweedie default power=0.0) | SHIPPED | `TweedieRegressor::new` sets `power: 0.0` (sklearn default, `glm.py:867`). Consumer: `TweedieRegressor::default`/`new` (crate-root export); oracle test `glm_tweedie_default_power` (`new().power == 0.0`) green. |
52//! | REQ-11 (warm_start) | SHIPPED | #557. **R-DEV-2 (API parity):** `pub warm_start: bool` (default `false`) + `#[must_use] fn with_warm_start` on `GLMRegressor`/`PoissonRegressor`/`GammaRegressor`/`TweedieRegressor`, mirroring sklearn's `warm_start` parameter (`"boolean"`, default `False`, `glm.py:146, :158, :576, :708, :874`). **R-DEV-7 (Rust analog — immutable-estimator design, observable contract preserved):** sklearn's `warm_start=True` reuses the stateful `self.coef_`/`self.intercept_` mutated across `fit` calls as the optimizer's start (`glm.py:243-254`); ferrolearn's estimators are immutable (`fit(&self, ...)` never mutates `self`, no `self.coef_` to reuse), so the warm-start point is supplied EXPLICITLY via `pub coef_init: Option<(Array1<F>, F)>` + `#[must_use] fn with_coef_init(coef, intercept)`. `fn fit_glm_irls` seeds the IRLS coefficient vector (and derived `eta`/`mu`) from `coef_init` when `warm_start && coef_init.is_some()` (validating `feature_coef.len() == n_features`, else `ShapeMismatch`); otherwise the cold start (`coef = 0`) is byte-for-byte preserved. The penalized GLM objective is convex, so the converged `coef_`/`intercept_` are warm-start-INVARIANT — the init only changes the starting point (and iteration count), never the optimum — so the warm fit matches the cold fit AND the sklearn oracle (`glm.py:244-256`). Consumer: each estimator's `Fit::fit` (crate-root export) — the `warm_start`/`coef_init` fields are part of the boundary estimator ABI. Oracle tests `glm_warm_start_observable_contract` (warm fit from a perturbed init == cold fit == live sklearn 1.5.2 oracle `coef_=[0.38388477,0.20240006]`, `intercept_=-0.51935653` to 1e-6/1e-4) and `glm_warm_start_init_used` (seeding the exact optimum with `max_iter=1` lands at the solution, a cold `max_iter=1` fit does not — proves the init is genuinely used) green in `tests/divergence_glm_fit.rs`; the 20 pre-existing glm divergence tests stay green (all cold-start, byte-identical). |
53//! | REQ-12 (sample_weight) | SHIPPED | `fn fit_with_sample_weight` on `GLMRegressor`/`PoissonRegressor`/`GammaRegressor`/`TweedieRegressor` threads an `Array1<F>` `sample_weight` into `fn fit_glm_irls`, where the IRLS `W` diagonal becomes `s_i * w_irls,i` (`weights[i] = weights[i] * sample_weight[i]`) and the L2-penalty scale is `weight_sum = S = sum_i s_i` (`sample_weight.iter().fold(..)`), matching sklearn's `sample_weight`-averaged deviance objective normalized by `sum(sample_weight)` (`glm.py:229-242`; `_check_sample_weight`, `glm.py:208-211`). Consumer: each estimator's `Fit::fit` (crate-root export) delegates with an all-ones weight vector, so the unweighted path is byte-identical (`weight_sum = n_samples`). Oracle tests `glm_poisson_sample_weight` (coef `[0.35738828,0.19717462]`, int `-0.43719203`) and `glm_gamma_sample_weight` (coef `[0.23049054,0.11350454]`, int `0.41955357`) green in `tests/divergence_glm_fit.rs`; the 8 pre-existing unweighted oracle tests stay green. |
54//! | REQ-15 (non-finite input rejected) | SHIPPED | The shared IRLS entry `fn fit_glm_irls` — which every estimator (`PoissonRegressor`/`GammaRegressor`/`TweedieRegressor`/`GLMRegressor`) routes through — rejects any NaN/+/-inf in X, y, or `sample_weight` BEFORE the y-domain check and the IRLS loop with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`glm.py:189-196`) + `_check_sample_weight` (default `force_all_finite=True`, `glm.py:211`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. Placed ONCE at the shared entry (R-DEFER-8 single instance). `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (the unweighted `Fit::fit` delegates an all-ones weight). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `PoissonRegressor`/`GammaRegressor`/`TweedieRegressor`(`.fit`) raise `ValueError` for NaN/+inf/-inf in X, NaN/inf in y, and NaN/inf in sample_weight (`tests/divergence_linear_nonfinite_batch3.rs::{poisson,gamma,tweedie}_*`). Non-test consumer: each estimator's crate-root-exported `Fit::fit`. (#2261) |
55//! | REQ-14 (n_iter_ + per-family y-domain validation) | SHIPPED | #560. Per-family y-domain guard in `fn fit_glm_irls`: `YDomain::for_power(family.domain_power())` then `y.iter().any(|&yi| !y_domain.contains(yi))` → `FerroError::InvalidParameter{name:"y", reason:"Some value(s) of y are out of the valid range of the loss '<loss>'."}`, mirroring sklearn's `if not base_loss.in_y_true_range(y): raise ValueError(...)` (`glm.py:221-225`). The valid range is keyed on the family's Tweedie `power` (NOT the link — verified vs the live oracle that `HalfTweedieLoss(p).interval_y_true == HalfTweedieLossIdentity(p).interval_y_true`): `power <= 0` unconstrained (Normal), `0 < power < 2` → `y >= 0` (Poisson `power=1`), `power >= 2` → `y > 0` (Gamma `power=2`, open at 0). `FittedGLMRegressor` gains `n_iter: usize` (the IRLS iteration count captured in the convergence loop) with `#[must_use] pub fn n_iter(&self) -> usize` — sklearn's `n_iter_` is the lbfgs count (`glm.py:110-114, :283`); ferrolearn's is the IRLS count (solvers differ, both report iterations-to-convergence). Consumer: `FittedGLMRegressor::n_iter` accessor on the crate-root-exported fitted type. Oracle tests `glm_gamma_rejects_zero_y` (Gamma rejects `y==0`, accepts `y>0`), `glm_tweedie_power2_rejects_zero_y` (`power=2.0` rejects `y==0`; `power=1.5` accepts it), `glm_poisson_rejects_negative_y` (rejects `y<0`, accepts `y==0`), `glm_n_iter_exposed` (`1 <= n_iter() <= max_iter`) green in `tests/divergence_glm_fit.rs`; the 10 pre-existing glm divergence tests stay green (all their `y` are in-domain). |
56
57use ferrolearn_core::error::FerroError;
58use ferrolearn_core::introspection::HasCoefficients;
59use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
60use ferrolearn_core::traits::{Fit, Predict};
61use ndarray::{Array1, Array2, ScalarOperand};
62use num_traits::{Float, FromPrimitive};
63
64// ---------------------------------------------------------------------------
65// Link
66// ---------------------------------------------------------------------------
67
68/// The link function `g` of a Generalized Linear Model, mapping the mean `mu`
69/// to the linear predictor `eta = g(mu)` (and back via the inverse link `h`,
70/// `mu = h(eta)`).
71///
72/// Mirrors the link carried by sklearn's loss classes
73/// (`sklearn/linear_model/_glm/glm.py:119-131`): `HalfPoissonLoss`,
74/// `HalfGammaLoss` and `HalfTweedieLoss` use the **log** link
75/// (`y_pred = exp(X @ coef + intercept)`); `HalfSquaredError` and
76/// `HalfTweedieLossIdentity` use the **identity** link
77/// (`y_pred = X @ coef + intercept`).
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Link {
80    /// Log link: `g(mu) = ln(mu)`, inverse `h(eta) = exp(eta)`.
81    ///
82    /// Used by Poisson, Gamma and Tweedie-with-`power > 0` losses.
83    Log,
84    /// Identity link: `g(mu) = mu`, inverse `h(eta) = eta`.
85    ///
86    /// Used by the Normal/least-squares loss and Tweedie-with-`power <= 0`.
87    Identity,
88}
89
90impl Link {
91    /// Inverse link `h(eta) = mu`: maps the linear predictor to the mean.
92    ///
93    /// - [`Link::Log`] → `exp(eta)`
94    /// - [`Link::Identity`] → `eta`
95    ///
96    /// Mirrors `link.inverse(raw_prediction)` in `glm.py:362`.
97    #[must_use]
98    fn inverse<F: Float>(self, eta: F) -> F {
99        match self {
100            Link::Log => eta.exp(),
101            Link::Identity => eta,
102        }
103    }
104
105    /// Forward link `g(mu) = eta`: maps the mean to the linear predictor.
106    ///
107    /// - [`Link::Log`] → `ln(mu)`
108    /// - [`Link::Identity`] → `mu`
109    ///
110    /// Mirrors `link.link(...)` used by sklearn to seed the intercept at
111    /// `link.link(average(y))` (`glm.py:254-256`).
112    #[must_use]
113    fn link<F: Float>(self, mu: F) -> F {
114        match self {
115            Link::Log => mu.ln(),
116            Link::Identity => mu,
117        }
118    }
119
120    /// Link derivative of the mean w.r.t. the linear predictor, `dmu/deta`,
121    /// used to form the IRLS working weight and response.
122    ///
123    /// - [`Link::Log`] (`mu = exp(eta)`) → `dmu/deta = mu`
124    /// - [`Link::Identity`] (`mu = eta`) → `dmu/deta = 1`
125    #[must_use]
126    fn dmu_deta<F: Float>(self, mu: F) -> F {
127        match self {
128            Link::Log => mu,
129            Link::Identity => F::one(),
130        }
131    }
132}
133
134/// Configuration of the GLM link function, resolved to a concrete [`Link`] at
135/// fit time.
136///
137/// Mirrors sklearn's `TweedieRegressor(link={'auto','identity','log'})`
138/// (`glm.py:861, :889-903`). `Auto` selects the link from the Tweedie `power`:
139/// identity for `power <= 0` (Normal), log otherwise (Poisson/Gamma/etc.).
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum LinkConfig {
142    /// Resolve the link from the Tweedie `power` at fit time
143    /// (`power <= 0` → identity, `power > 0` → log). The default.
144    Auto,
145    /// Force the log link regardless of `power`.
146    Log,
147    /// Force the identity link regardless of `power`.
148    Identity,
149}
150
151impl LinkConfig {
152    /// Resolve to a concrete [`Link`] given the Tweedie `power`.
153    ///
154    /// Mirrors `TweedieRegressor._get_loss` (`glm.py:889-903`):
155    /// - `Auto` → identity for `power <= 0`, log for `power > 0`
156    /// - `Log` → log; `Identity` → identity.
157    #[must_use]
158    fn resolve(self, power: f64) -> Link {
159        match self {
160            LinkConfig::Auto => {
161                if power <= 0.0 {
162                    Link::Identity
163                } else {
164                    Link::Log
165                }
166            }
167            LinkConfig::Log => Link::Log,
168            LinkConfig::Identity => Link::Identity,
169        }
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Solver
175// ---------------------------------------------------------------------------
176
177/// The optimization algorithm requested for fitting a GLM, mirroring sklearn's
178/// `solver` constructor parameter (`sklearn/linear_model/_glm/glm.py:140-145`,
179/// `StrOptions({"lbfgs", "newton-cholesky"})`, default `"lbfgs"`).
180///
181/// **Implementation note (R-DEV-7 — Rust analog).** ferrolearn fits all GLMs via
182/// IRLS / Fisher-scoring (`fn fit_glm_irls`); the `solver` parameter is accepted
183/// for scikit-learn API parity (R-DEV-2) and selects the requested optimizer's
184/// *contract*. The penalized GLM objective is convex, so IRLS converges to the
185/// **same** minimizer as both sklearn's `lbfgs` (scipy L-BFGS-B) and
186/// `newton-cholesky` (Newton-Raphson with an inner Cholesky solve) — all three
187/// are descent methods on one convex objective. Therefore the observable
188/// contract (`coef_` / `intercept_`) matches sklearn for **either** `solver`
189/// value, and ferrolearn does not vary the numerical path between them
190/// (verified live: `PoissonRegressor(alpha=0.5)` gives the same fitted
191/// attributes to ~1e-9 for `lbfgs` and `newton-cholesky`).
192///
193/// The type system constrains valid values to the two sklearn options, mirroring
194/// the role of sklearn's `StrOptions` parameter constraint.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum Solver {
197    /// L-BFGS-B (sklearn's default `"lbfgs"`): a quasi-Newton optimizer on the
198    /// penalized loss + gradient (`glm.py:263-284`). In ferrolearn the fit is
199    /// performed by IRLS, which reaches the same convex optimum (R-DEV-7).
200    Lbfgs,
201    /// Newton-Cholesky (sklearn's `"newton-cholesky"`): Newton-Raphson steps
202    /// with an inner Cholesky solve, equivalent in exact arithmetic to iterated
203    /// reweighted least squares (`glm.py:72-78, :285-296`). In ferrolearn the
204    /// fit is performed by IRLS, which reaches the same convex optimum (R-DEV-7).
205    NewtonCholesky,
206}
207
208// ---------------------------------------------------------------------------
209// GLMFamily
210// ---------------------------------------------------------------------------
211
212/// The distributional family for a Generalized Linear Model.
213///
214/// Determines the variance function V(mu):
215/// - **Poisson**: V(mu) = mu
216/// - **Gamma**: V(mu) = mu^2
217/// - **Tweedie(p)**: V(mu) = mu^p
218#[derive(Debug, Clone, Copy)]
219pub enum GLMFamily {
220    /// Poisson family — variance proportional to the mean.
221    Poisson,
222    /// Gamma family — variance proportional to the squared mean.
223    Gamma,
224    /// Tweedie family with power parameter `p`.
225    ///
226    /// - `p = 0` gives Normal (constant variance)
227    /// - `p = 1` gives Poisson
228    /// - `p = 2` gives Gamma
229    /// - `1 < p < 2` gives compound Poisson-Gamma
230    Tweedie(f64),
231}
232
233impl GLMFamily {
234    /// Compute the variance function V(mu) for a given mean `mu`.
235    fn variance<F: Float + FromPrimitive>(&self, mu: F) -> F {
236        match self {
237            GLMFamily::Poisson => mu,
238            GLMFamily::Gamma => mu * mu,
239            GLMFamily::Tweedie(p) => {
240                let power = F::from(*p).unwrap_or_else(F::zero);
241                mu.powf(power)
242            }
243        }
244    }
245
246    /// The Tweedie power that determines this family's target (`y`) domain.
247    ///
248    /// The sklearn EDM losses derive their `interval_y_true` from the Tweedie
249    /// `power` alone (`HalfPoissonLoss` is `power = 1`, `HalfGammaLoss` is
250    /// `power = 2`); the link function does NOT change the valid `y` range
251    /// (verified against the live oracle: `HalfTweedieLoss(power).interval_y_true
252    /// == HalfTweedieLossIdentity(power).interval_y_true`). See
253    /// [`YDomain::for_power`].
254    #[must_use]
255    fn domain_power(&self) -> f64 {
256        match self {
257            GLMFamily::Poisson => 1.0,
258            GLMFamily::Gamma => 2.0,
259            GLMFamily::Tweedie(p) => *p,
260        }
261    }
262
263    /// The per-sample **unit deviance** `dev(y, mu)` of this family, the
264    /// quantity whose `sample_weight`-weighted sum forms the D² numerator and
265    /// denominator (`fn score`).
266    ///
267    /// This equals `2 * (loss(y, mu) + constant_to_optimal_zero(y))` of the
268    /// matching sklearn EDM loss — i.e. twice the half-deviance with the
269    /// `raw_prediction`-independent constant restored so a perfect prediction
270    /// has zero deviance (`sklearn/_loss/loss.py`: `HalfPoissonLoss` `:728-742`,
271    /// `HalfGammaLoss` `:754-773`, `HalfTweedieLoss` `:789-837`). Verified
272    /// term-for-term against the live sklearn 1.5.2 oracle.
273    ///
274    /// Per family (`p` = Tweedie power):
275    /// - **Poisson** (`p = 1`): `2·(y·ln(y/mu) − y + mu)`, with the convention
276    ///   `y·ln(y/mu) → 0` at `y == 0` (so `dev = 2·mu`).
277    /// - **Gamma** (`p = 2`): `2·(−ln(y/mu) + (y − mu)/mu)`.
278    /// - **Tweedie `p == 0`** (Normal): `(y − mu)²`.
279    /// - **Tweedie general** `p ∉ {0, 1, 2}`:
280    ///   `2·( max(y,0)^(2−p)/((1−p)(2−p)) − y·mu^(1−p)/(1−p) + mu^(2−p)/(2−p) )`,
281    ///   matching `HalfTweedieLoss.loss` (`loss.py:792-794`); `max(y, 0)` guards
282    ///   `y == 0` (the `y^(2−p)` term is then 0).
283    #[must_use]
284    fn unit_deviance<F: Float + FromPrimitive>(&self, y: F, mu: F) -> F {
285        let two = F::from(2.0).unwrap_or_else(|| F::one() + F::one());
286        match self {
287            GLMFamily::Poisson => {
288                // 2·(y·ln(y/mu) − y + mu); y·ln(y/mu) → 0 at y == 0.
289                let y_term = if y > F::zero() {
290                    y * (y / mu).ln()
291                } else {
292                    F::zero()
293                };
294                two * (y_term - y + mu)
295            }
296            GLMFamily::Gamma => {
297                // 2·(−ln(y/mu) + (y − mu)/mu).
298                two * (F::zero() - (y / mu).ln() + (y - mu) / mu)
299            }
300            GLMFamily::Tweedie(p) => self.tweedie_unit_deviance(*p, y, mu),
301        }
302    }
303
304    /// Tweedie unit deviance for an arbitrary power `p`, dispatching the
305    /// `p ∈ {1, 2}` special cases to Poisson/Gamma and `p == 0` to the Normal
306    /// squared error, matching `HalfTweedieLoss` taking the `p → 0, 1, 2` limits
307    /// (`loss.py:796-797`).
308    #[must_use]
309    fn tweedie_unit_deviance<F: Float + FromPrimitive>(&self, p: f64, y: F, mu: F) -> F {
310        let two = F::from(2.0).unwrap_or_else(|| F::one() + F::one());
311        if p == 0.0 {
312            // Normal: (y − mu)².
313            let d = y - mu;
314            return d * d;
315        }
316        if p == 1.0 {
317            return GLMFamily::Poisson.unit_deviance(y, mu);
318        }
319        if p == 2.0 {
320            return GLMFamily::Gamma.unit_deviance(y, mu);
321        }
322        // General p: 2·( max(y,0)^(2−p)/((1−p)(2−p)) − y·mu^(1−p)/(1−p)
323        //                 + mu^(2−p)/(2−p) ).
324        let pf = F::from(p).unwrap_or_else(F::zero);
325        let one = F::one();
326        let one_minus_p = one - pf;
327        let two_minus_p = two - pf;
328        let y_pos = if y > F::zero() { y } else { F::zero() };
329        let t1 = y_pos.powf(two_minus_p) / (one_minus_p * two_minus_p);
330        let t2 = y * mu.powf(one_minus_p) / one_minus_p;
331        let t3 = mu.powf(two_minus_p) / two_minus_p;
332        two * (t1 - t2 + t3)
333    }
334
335    /// The `sample_weight`-independent constant `constant_to_optimal_zero(y)` of
336    /// the matching sklearn EDM loss — the term sklearn DROPS from its
337    /// half-loss so that a perfect prediction scores zero deviance/2 (it is
338    /// restored when forming the unit deviance).
339    ///
340    /// Mirrors `sklearn/_loss/loss.py`: `HalfPoissonLoss.constant_to_optimal_zero`
341    /// `:738-742` = `xlogy(y, y) − y` (`xlogy(0, 0) = 0`); `HalfGammaLoss` `:769-773`
342    /// = `−ln(y) − 1`; `HalfSquaredError`/Tweedie-identity `:453-458` = `0`;
343    /// `HalfTweedieLoss.constant_to_optimal_zero` `:819-837` dispatches `p ∈ {0,1,2}`
344    /// and is `max(y,0)^(2−p)/((1−p)(2−p))` otherwise.
345    ///
346    /// `score` adds this constant to both the model and the null half-deviance so
347    /// the D² expression `1 − (deviance + constant)/(deviance_null + constant)`
348    /// is byte-for-byte sklearn's (`glm.py:419-438`); it only affects the result
349    /// at the degenerate constant-`y` boundary (`deviance_null == 0`), where
350    /// sklearn returns `0` for Poisson (`constant ≠ 0`) and `NaN` for
351    /// Gamma/Normal (`constant == 0`).
352    #[must_use]
353    fn constant_to_optimal_zero<F: Float + FromPrimitive>(&self, y: F) -> F {
354        match self {
355            GLMFamily::Poisson => {
356                // xlogy(y, y) − y; xlogy(0, 0) = 0.
357                let xlogy = if y > F::zero() { y * y.ln() } else { F::zero() };
358                xlogy - y
359            }
360            GLMFamily::Gamma => {
361                // −ln(y) − 1.
362                F::zero() - y.ln() - F::one()
363            }
364            GLMFamily::Tweedie(p) => self.tweedie_constant_to_optimal_zero(*p, y),
365        }
366    }
367
368    /// Tweedie `constant_to_optimal_zero` for an arbitrary power `p`, dispatching
369    /// `p ∈ {0, 1, 2}` to Normal/Poisson/Gamma (`loss.py:819-831`) and using
370    /// `max(y,0)^(2−p)/((1−p)(2−p))` otherwise (`loss.py:832-837`).
371    #[must_use]
372    fn tweedie_constant_to_optimal_zero<F: Float + FromPrimitive>(&self, p: f64, y: F) -> F {
373        if p == 0.0 {
374            // HalfSquaredError: 0.
375            return F::zero();
376        }
377        if p == 1.0 {
378            return GLMFamily::Poisson.constant_to_optimal_zero(y);
379        }
380        if p == 2.0 {
381            return GLMFamily::Gamma.constant_to_optimal_zero(y);
382        }
383        let two = F::from(2.0).unwrap_or_else(|| F::one() + F::one());
384        let pf = F::from(p).unwrap_or_else(F::zero);
385        let one = F::one();
386        let one_minus_p = one - pf;
387        let two_minus_p = two - pf;
388        let y_pos = if y > F::zero() { y } else { F::zero() };
389        y_pos.powf(two_minus_p) / (one_minus_p * two_minus_p)
390    }
391}
392
393/// The valid target (`y`) domain of a GLM loss, derived from its Tweedie
394/// `power`, mirroring sklearn's `BaseLoss.interval_y_true` /
395/// `in_y_true_range(y)` (`sklearn/linear_model/_glm/glm.py:221-225`).
396///
397/// The interval depends on `power` only (NOT the link); verified against the
398/// live sklearn 1.5.2 oracle:
399///
400/// | power range          | valid `y`     | sklearn loss / interval                            |
401/// |----------------------|---------------|----------------------------------------------------|
402/// | `power <= 0`         | any real `y`  | `HalfSquaredError`/Tweedie identity, `(-inf, inf)` |
403/// | `0 < power < 2`      | `y >= 0`      | Poisson (`power = 1`): `[0, inf)`                  |
404/// | `power >= 2`         | `y > 0`       | Gamma (`power = 2`): `(0, inf)`                    |
405///
406/// (`power < 0` is the identity-link case, unconstrained; `0 < power < 1` is not
407/// a standard Tweedie EDM but sklearn's `HalfTweedieLoss(power).interval_y_true`
408/// is `[0, inf)` there, so we treat it as `y >= 0`.)
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410enum YDomain {
411    /// `y` may be any real number (`power <= 0`, Normal/identity).
412    Unconstrained,
413    /// `y >= 0` (closed at 0; `0 < power < 2`, e.g. Poisson `power = 1`).
414    NonNegative,
415    /// `y > 0` (open at 0; `power >= 2`, e.g. Gamma `power = 2`).
416    Positive,
417}
418
419impl YDomain {
420    /// Resolve the valid `y` domain from a Tweedie `power`.
421    ///
422    /// Mirrors the boundaries of `HalfTweedieLoss(power).interval_y_true`
423    /// (live sklearn 1.5.2 oracle): `power <= 0` → unconstrained, `0 < power < 2`
424    /// → `y >= 0`, `power >= 2` → `y > 0`.
425    #[must_use]
426    fn for_power(power: f64) -> Self {
427        if power <= 0.0 {
428            YDomain::Unconstrained
429        } else if power < 2.0 {
430            YDomain::NonNegative
431        } else {
432            YDomain::Positive
433        }
434    }
435
436    /// Human-readable description of the loss whose domain this is, for the
437    /// error message (mirrors sklearn's `loss.__class__.__name__`,
438    /// `glm.py:224`).
439    #[must_use]
440    fn loss_name(self) -> &'static str {
441        match self {
442            YDomain::Unconstrained => "HalfSquaredError",
443            YDomain::NonNegative => "HalfPoissonLoss",
444            YDomain::Positive => "HalfGammaLoss",
445        }
446    }
447
448    /// Whether a single target value `yi` is inside this domain.
449    #[must_use]
450    fn contains<F: Float>(self, yi: F) -> bool {
451        match self {
452            YDomain::Unconstrained => true,
453            YDomain::NonNegative => yi >= F::zero(),
454            YDomain::Positive => yi > F::zero(),
455        }
456    }
457}
458
459// ---------------------------------------------------------------------------
460// GLMRegressor
461// ---------------------------------------------------------------------------
462
463/// Generalized Linear Model regressor.
464///
465/// Fitted via IRLS with a log link function. The [`GLMFamily`] controls
466/// the assumed variance-mean relationship.
467///
468/// # Type Parameters
469///
470/// - `F`: The floating-point type (`f32` or `f64`).
471#[derive(Debug, Clone)]
472pub struct GLMRegressor<F> {
473    /// Distributional family (Poisson, Gamma, or Tweedie).
474    pub family: GLMFamily,
475    /// L2 regularization strength.
476    pub alpha: F,
477    /// Maximum number of IRLS iterations.
478    pub max_iter: usize,
479    /// Convergence tolerance on the maximum coefficient change.
480    pub tol: F,
481    /// Whether to fit an intercept (bias) term.
482    pub fit_intercept: bool,
483    /// Optimization algorithm requested, mirroring sklearn's `solver` parameter
484    /// (`glm.py:140-145`, default `"lbfgs"`).
485    ///
486    /// ferrolearn fits via IRLS / Fisher-scoring regardless of this value
487    /// (R-DEV-7); the parameter is accepted for sklearn API parity (R-DEV-2) and
488    /// the observable `coef_` / `intercept_` match sklearn for either value
489    /// because IRLS reaches the same convex optimum as both `lbfgs` and
490    /// `newton-cholesky`. See [`Solver`].
491    pub solver: Solver,
492    /// Whether to warm-start the optimizer from an explicit initial point,
493    /// mirroring sklearn's `warm_start` constructor parameter (default `false`,
494    /// `glm.py:146, :158, :244`).
495    ///
496    /// **R-DEV-2 (API parity):** the name and default match sklearn. **R-DEV-7
497    /// (Rust analog):** sklearn reuses the stateful `self.coef_` / `self.intercept_`
498    /// across `fit` calls (`glm.py:244-250`); ferrolearn's estimators are immutable
499    /// (`fit(&self, ...)` never mutates `self`), so the warm-start point is supplied
500    /// EXPLICITLY via [`GLMRegressor::with_coef_init`]. When `warm_start == true`
501    /// and an init is set, the IRLS seeds from it; otherwise it cold-starts at
502    /// `coef = 0`. Because the GLM objective is convex, the converged
503    /// `coef_` / `intercept_` are warm-start-invariant — identical to the cold
504    /// fit and to sklearn regardless of the init.
505    pub warm_start: bool,
506    /// Explicit warm-start initial point `(feature_coefficients, intercept)` —
507    /// the ferrolearn analog of sklearn reusing `self.coef_` / `self.intercept_`
508    /// across `fit` calls (R-DEV-7, `glm.py:244-250`).
509    ///
510    /// Only consulted when [`GLMRegressor::warm_start`] is `true`. The
511    /// feature-coefficient vector length must equal the number of features in `X`
512    /// (else [`FerroError::ShapeMismatch`] at fit time). Set via
513    /// [`GLMRegressor::with_coef_init`]; typically a previous fit's
514    /// `coefficients()` / `intercept()`.
515    pub coef_init: Option<(Array1<F>, F)>,
516}
517
518impl<F: Float + FromPrimitive> GLMRegressor<F> {
519    /// Create a new `GLMRegressor` with the given family.
520    ///
521    /// Defaults: `alpha = 1.0`, `max_iter = 100`, `tol = 1e-4`,
522    /// `fit_intercept = true`, `solver = Solver::Lbfgs` (sklearn default).
523    #[must_use]
524    pub fn new(family: GLMFamily) -> Self {
525        Self {
526            family,
527            alpha: F::one(),
528            max_iter: 100,
529            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
530            fit_intercept: true,
531            solver: Solver::Lbfgs,
532            warm_start: false,
533            coef_init: None,
534        }
535    }
536
537    /// Set the L2 regularization strength.
538    #[must_use]
539    pub fn with_alpha(mut self, alpha: F) -> Self {
540        self.alpha = alpha;
541        self
542    }
543
544    /// Set the maximum number of IRLS iterations.
545    #[must_use]
546    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
547        self.max_iter = max_iter;
548        self
549    }
550
551    /// Set the convergence tolerance.
552    #[must_use]
553    pub fn with_tol(mut self, tol: F) -> Self {
554        self.tol = tol;
555        self
556    }
557
558    /// Set whether to fit an intercept term.
559    #[must_use]
560    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
561        self.fit_intercept = fit_intercept;
562        self
563    }
564
565    /// Set the optimization [`Solver`], mirroring sklearn's `solver` parameter
566    /// (`glm.py:140-145`, default `"lbfgs"`).
567    ///
568    /// ferrolearn fits via IRLS regardless of the value (R-DEV-7); the parameter
569    /// is accepted for sklearn API parity (R-DEV-2) and both values produce the
570    /// same observable `coef_` / `intercept_` (IRLS reaches the same convex
571    /// optimum as both `lbfgs` and `newton-cholesky`).
572    #[must_use]
573    pub fn with_solver(mut self, solver: Solver) -> Self {
574        self.solver = solver;
575        self
576    }
577
578    /// Enable or disable warm-starting, mirroring sklearn's `warm_start`
579    /// parameter (default `false`, `glm.py:146, :158`).
580    ///
581    /// **R-DEV-2 / R-DEV-7.** sklearn reuses the stateful `self.coef_` /
582    /// `self.intercept_` across `fit` calls (`glm.py:244-250`); ferrolearn's
583    /// immutable estimators take the warm-start point EXPLICITLY via
584    /// [`GLMRegressor::with_coef_init`]. When `warm_start` is `true` and an init
585    /// is set, the IRLS seeds from it; otherwise it cold-starts at `coef = 0`. The
586    /// convex GLM objective makes the converged `coef_` / `intercept_`
587    /// warm-start-invariant.
588    #[must_use]
589    pub fn with_warm_start(mut self, warm_start: bool) -> Self {
590        self.warm_start = warm_start;
591        self
592    }
593
594    /// Set the explicit warm-start initial point `(feature_coefficients,
595    /// intercept)` — the ferrolearn analog of sklearn reusing `self.coef_` /
596    /// `self.intercept_` (R-DEV-7, `glm.py:244-250`).
597    ///
598    /// Only consulted when [`GLMRegressor::warm_start`] is `true`. `coef`'s length
599    /// must equal the number of features in `X` (validated at fit time). Pass a
600    /// previous fit's `coefficients()` / `intercept()` to resume from it.
601    #[must_use]
602    pub fn with_coef_init(mut self, coef: Array1<F>, intercept: F) -> Self {
603        self.coef_init = Some((coef, intercept));
604        self
605    }
606}
607
608impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> GLMRegressor<F> {
609    /// Fit the GLM via IRLS with per-sample weights `sample_weight`.
610    ///
611    /// Mirrors sklearn's `fit(X, y, sample_weight)` (`glm.py:170`,
612    /// `:229-242`): the deviance term is a `sample_weight`-weighted average
613    /// (normalized by `S = sum_i s_i`), so the IRLS `W` diagonal becomes
614    /// `s_i * w_irls,i` and the L2 penalty scales with `S`. The working
615    /// response `z_i` is unchanged per sample. Passing an all-ones weight
616    /// vector reproduces [`Fit::fit`] exactly.
617    ///
618    /// # Errors
619    ///
620    /// - [`FerroError::ShapeMismatch`] — `sample_weight.len() != n_samples`,
621    ///   or `y` length mismatch.
622    /// - [`FerroError::InvalidParameter`] — a negative sample weight, negative
623    ///   alpha, or (log link) negative `y`.
624    /// - [`FerroError::InsufficientSamples`] — zero samples.
625    pub fn fit_with_sample_weight(
626        &self,
627        x: &Array2<F>,
628        y: &Array1<F>,
629        sample_weight: &Array1<F>,
630    ) -> Result<FittedGLMRegressor<F>, FerroError> {
631        fit_glm_irls(
632            x,
633            y,
634            sample_weight,
635            &self.family,
636            Link::Log,
637            self.alpha,
638            self.max_iter,
639            self.tol,
640            self.fit_intercept,
641            self.warm_start,
642            self.coef_init.as_ref(),
643        )
644    }
645}
646
647/// Fitted GLM regressor.
648///
649/// Stores the learned coefficients and intercept on the link scale, together
650/// with the [`Link`] used at fit time. Predictions are
651/// `link.inverse(X @ coef + intercept)` — `exp(...)` for [`Link::Log`], the raw
652/// linear predictor for [`Link::Identity`] (`glm.py:362`).
653#[derive(Debug, Clone)]
654pub struct FittedGLMRegressor<F> {
655    /// Learned coefficient vector on the link scale.
656    coefficients: Array1<F>,
657    /// Learned intercept on the link scale.
658    intercept: F,
659    /// Link function applied by `predict` (inverse link maps `eta` to `mu`).
660    link: Link,
661    /// Distributional family, retained so [`FittedGLMRegressor::score`] can
662    /// compute this family's unit deviance for the D² (deviance-explained)
663    /// score (`glm.py:365-438`).
664    family: GLMFamily,
665    /// Number of IRLS iterations actually run (until convergence or `max_iter`).
666    ///
667    /// Mirrors sklearn's fitted `n_iter_` attribute (`glm.py:110-114, :283`),
668    /// the solver's iteration count. ferrolearn's solver is IRLS (not lbfgs), so
669    /// this is the **IRLS** iteration count; both report iterations-to-convergence
670    /// but the exact value is solver-dependent.
671    n_iter: usize,
672}
673
674impl<F> FittedGLMRegressor<F> {
675    /// Number of IRLS iterations run during the fit
676    /// (until convergence or `max_iter`).
677    ///
678    /// Mirrors scikit-learn's fitted `n_iter_` attribute
679    /// (`sklearn/linear_model/_glm/glm.py:110-114, :283`), which reports the
680    /// solver's iteration count. ferrolearn's solver is **IRLS** (sklearn's
681    /// default is lbfgs), so this is the IRLS iteration count, not the lbfgs
682    /// one — both report iterations-to-convergence, but the exact value differs
683    /// because the solvers differ. The value is in `1..=max_iter`.
684    #[must_use]
685    pub fn n_iter(&self) -> usize {
686        self.n_iter
687    }
688}
689
690impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> FittedGLMRegressor<F> {
691    /// Compute **D²**, the fraction of deviance explained — the GLM
692    /// generalization of R² (which is the special case for the Normal family).
693    ///
694    /// Mirrors `sklearn`'s `_GeneralizedLinearRegressor.score`
695    /// (`sklearn/linear_model/_glm/glm.py:365-438`):
696    ///
697    /// ```text
698    /// D² = 1 − (deviance + constant) / (deviance_null + constant)
699    /// ```
700    ///
701    /// where `deviance` is the `sample_weight`-mean half-deviance of the fitted
702    /// model's predictions `mu = predict(X)`, `deviance_null` is the same for the
703    /// constant null model that predicts the weighted mean of `y` for every
704    /// sample (`y_pred = link.inverse(link(mean_w(y))) = mean_w(y)`), and
705    /// `constant` is the family's `constant_to_optimal_zero(y)` mean
706    /// (`glm.py:419-438`). Because the dropped factor-of-2 and the `constant`
707    /// cancel whenever `deviance_null ≠ 0`, the result equals the unit-deviance
708    /// ratio `1 − Σ sᵢ·dev(yᵢ, muᵢ) / Σ sᵢ·dev(yᵢ, ȳ_w)`. The best possible
709    /// score is `1.0`; it can be negative (an arbitrarily worse-than-null model).
710    ///
711    /// This is the unweighted path (`sample_weight = None`); the weighted mean
712    /// `ȳ_w` reduces to the plain mean of `y`.
713    ///
714    /// **Degenerate (constant-`y`) boundary.** When `y` is constant the null
715    /// deviance is `0`, so sklearn evaluates `1 − (deviance + constant)/0`. The
716    /// returned value is sklearn's exact algebraic form, but the result there
717    /// depends on the fitted model reproducing `mu == ȳ` to full precision: with
718    /// sklearn's lbfgs solver `deviance + constant == deviance_null + constant`
719    /// (both equal the float roundoff of `loss + constant`), giving `0` for
720    /// Poisson and `NaN` for Gamma/Normal (`constant == 0`). ferrolearn's IRLS
721    /// converges `mu` to `ȳ` only to solver tolerance (not bit-exactly), so on a
722    /// constant-`y` Poisson input the ratio is `~1 ± ε` (≈ `0`) rather than
723    /// exactly `0`; this is a fit-precision artifact of the degenerate input, not
724    /// of the D² formula (non-degenerate inputs match the oracle to `< 1e-6`).
725    ///
726    /// `y` is re-validated against the family's target domain, mirroring
727    /// sklearn's `if not base_loss.in_y_true_range(y): raise ValueError(...)` in
728    /// `score` (`glm.py:413-417`).
729    ///
730    /// # Errors
731    ///
732    /// - [`FerroError::ShapeMismatch`] — `X` feature count mismatch (via
733    ///   [`Predict::predict`]) or `y` length mismatch.
734    /// - [`FerroError::InvalidParameter`] — a value of `y` is out of the family's
735    ///   valid target range.
736    #[must_use = "the computed D² score should be used"]
737    pub fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
738        let mu = self.predict(x)?;
739
740        if y.len() != mu.len() {
741            return Err(FerroError::ShapeMismatch {
742                expected: vec![mu.len()],
743                actual: vec![y.len()],
744                context: "y length must match number of samples in X".into(),
745            });
746        }
747
748        // Re-validate y against the family's target domain, mirroring sklearn's
749        // `score`: `if not base_loss.in_y_true_range(y): raise ValueError(...)`
750        // (`glm.py:413-417`). Same per-family domain as `fit` (keyed on power).
751        let y_domain = YDomain::for_power(self.family.domain_power());
752        if y.iter().any(|&yi| !y_domain.contains(yi)) {
753            return Err(FerroError::InvalidParameter {
754                name: "y".into(),
755                reason: format!(
756                    "Some value(s) of y are out of the valid range of the loss '{}'.",
757                    y_domain.loss_name()
758                ),
759            });
760        }
761
762        // Weighted mean of y (unweighted => plain mean). The null model predicts
763        // `ȳ` for every sample (`glm.py:431`: `link.link(average(y))` mapped back
764        // through `link.inverse` in the null deviance is just `ȳ`).
765        let n = F::from(y.len()).unwrap_or_else(F::one);
766        let y_bar = y.iter().fold(F::zero(), |acc, &yi| acc + yi) / n;
767
768        // sklearn forms `deviance + constant` and `deviance_null + constant`,
769        // where `deviance = mean(loss)`, `deviance_null = mean(loss_null)` and
770        // `constant = mean(constant_to_optimal_zero(y))` (`glm.py:419-437`).
771        // Because per sample `loss + constant_to_optimal_zero = ½·unit_deviance`
772        // (the `loss` drops exactly the constant the unit deviance restores), the
773        // two terms sklearn divides are simply the mean half unit deviances:
774        //   deviance + constant      = mean(½·unit_deviance(y, mu_model))
775        //   deviance_null + constant = mean(½·unit_deviance(y, ȳ)).
776        // We accumulate `½·unit_deviance` on each side directly — adding `constant`
777        // again would double-count it. The factor ½ cancels in the ratio for any
778        // nonzero denominator; we keep it so the constant-`y` boundary
779        // (`deviance_null == 0`) reproduces sklearn's exact `½·unit_dev_model / 0`
780        // → it must, however, also restore the dropped `constant` at that boundary
781        // to distinguish Poisson (0) from Gamma/Normal (NaN). We therefore compute
782        // sklearn's `(deviance + constant)` / `(deviance_null + constant)` form
783        // with `deviance = mean(loss)`, `loss = ½·unit_deviance − constant`.
784        let half = F::from(0.5).unwrap_or_else(|| F::one() / (F::one() + F::one()));
785        let mut sum_loss_model = F::zero();
786        let mut sum_loss_null = F::zero();
787        let mut sum_const = F::zero();
788        for (&yi, &mui) in y.iter().zip(mu.iter()) {
789            let c = self.family.constant_to_optimal_zero(yi);
790            sum_loss_model = sum_loss_model + (half * self.family.unit_deviance(yi, mui) - c);
791            sum_loss_null = sum_loss_null + (half * self.family.unit_deviance(yi, y_bar) - c);
792            sum_const = sum_const + c;
793        }
794        let deviance = sum_loss_model / n;
795        let deviance_null = sum_loss_null / n;
796        let constant = sum_const / n;
797
798        // `1 − (deviance + constant) / (deviance_null + constant)` (glm.py:438).
799        // Away from the constant-`y` boundary this equals the unit-deviance ratio
800        // `1 − Σ dev(y, mu) / Σ dev(y, ȳ)`; at `deviance_null == 0` the restored
801        // `constant` reproduces sklearn's family-dependent result — 0 for Poisson
802        // (`constant ≠ 0`), NaN for Gamma/Normal (`constant == 0`) — verified
803        // against the live sklearn 1.5.2 oracle.
804        Ok(F::one() - (deviance + constant) / (deviance_null + constant))
805    }
806}
807
808// ---------------------------------------------------------------------------
809// Convenience wrappers
810// ---------------------------------------------------------------------------
811
812/// Poisson regressor — GLM with Poisson family and log link.
813///
814/// Suitable for modelling count data (y >= 0, integer-valued).
815///
816/// # Type Parameters
817///
818/// - `F`: The floating-point type (`f32` or `f64`).
819#[derive(Debug, Clone)]
820pub struct PoissonRegressor<F> {
821    /// L2 regularization strength.
822    pub alpha: F,
823    /// Maximum number of IRLS iterations.
824    pub max_iter: usize,
825    /// Convergence tolerance.
826    pub tol: F,
827    /// Whether to fit an intercept.
828    pub fit_intercept: bool,
829    /// Optimization algorithm requested, mirroring sklearn's `solver` parameter
830    /// (`glm.py:140-145`, default `"lbfgs"`).
831    ///
832    /// ferrolearn fits via IRLS regardless of this value (R-DEV-7); the parameter
833    /// is accepted for sklearn API parity (R-DEV-2) and the observable
834    /// `coef_` / `intercept_` match sklearn for either value. See [`Solver`].
835    pub solver: Solver,
836    /// Whether to warm-start from an explicit initial point, mirroring sklearn's
837    /// `warm_start` parameter (default `false`, `glm.py:146, :576`). See
838    /// [`GLMRegressor::warm_start`] for the R-DEV-2 / R-DEV-7 rationale.
839    pub warm_start: bool,
840    /// Explicit warm-start initial point `(feature_coefficients, intercept)` — the
841    /// ferrolearn analog of sklearn reusing `self.coef_` / `self.intercept_`
842    /// (R-DEV-7, `glm.py:244-250`). Consulted only when `warm_start` is `true`. Set
843    /// via [`PoissonRegressor::with_coef_init`].
844    pub coef_init: Option<(Array1<F>, F)>,
845}
846
847impl<F: Float + FromPrimitive> PoissonRegressor<F> {
848    /// Create a new `PoissonRegressor` with default settings.
849    ///
850    /// Defaults: `alpha = 1.0`, `max_iter = 100`, `tol = 1e-4`,
851    /// `fit_intercept = true`, `solver = Solver::Lbfgs` (sklearn default).
852    #[must_use]
853    pub fn new() -> Self {
854        Self {
855            alpha: F::one(),
856            max_iter: 100,
857            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
858            fit_intercept: true,
859            solver: Solver::Lbfgs,
860            warm_start: false,
861            coef_init: None,
862        }
863    }
864
865    /// Set the L2 regularization strength.
866    #[must_use]
867    pub fn with_alpha(mut self, alpha: F) -> Self {
868        self.alpha = alpha;
869        self
870    }
871
872    /// Set the maximum number of IRLS iterations.
873    #[must_use]
874    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
875        self.max_iter = max_iter;
876        self
877    }
878
879    /// Set the convergence tolerance.
880    #[must_use]
881    pub fn with_tol(mut self, tol: F) -> Self {
882        self.tol = tol;
883        self
884    }
885
886    /// Set whether to fit an intercept.
887    #[must_use]
888    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
889        self.fit_intercept = fit_intercept;
890        self
891    }
892
893    /// Set the optimization [`Solver`], mirroring sklearn's `solver` parameter
894    /// (`glm.py:140-145`, default `"lbfgs"`).
895    ///
896    /// ferrolearn fits via IRLS regardless of the value (R-DEV-7); both values
897    /// produce the same observable `coef_` / `intercept_` (sklearn API parity,
898    /// R-DEV-2).
899    #[must_use]
900    pub fn with_solver(mut self, solver: Solver) -> Self {
901        self.solver = solver;
902        self
903    }
904
905    /// Enable or disable warm-starting, mirroring sklearn's `warm_start`
906    /// parameter (default `false`, `glm.py:146, :576`). See
907    /// [`GLMRegressor::with_warm_start`] for the R-DEV-2 / R-DEV-7 rationale; the
908    /// warm-start point is supplied explicitly via
909    /// [`PoissonRegressor::with_coef_init`].
910    #[must_use]
911    pub fn with_warm_start(mut self, warm_start: bool) -> Self {
912        self.warm_start = warm_start;
913        self
914    }
915
916    /// Set the explicit warm-start initial point `(feature_coefficients,
917    /// intercept)` — the ferrolearn analog of sklearn reusing `self.coef_` /
918    /// `self.intercept_` (R-DEV-7, `glm.py:244-250`). Only consulted when
919    /// `warm_start` is `true`; `coef`'s length must equal the number of features.
920    #[must_use]
921    pub fn with_coef_init(mut self, coef: Array1<F>, intercept: F) -> Self {
922        self.coef_init = Some((coef, intercept));
923        self
924    }
925}
926
927impl<F: Float + FromPrimitive> Default for PoissonRegressor<F> {
928    fn default() -> Self {
929        Self::new()
930    }
931}
932
933impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> PoissonRegressor<F> {
934    /// Fit the Poisson GLM via IRLS with per-sample weights `sample_weight`.
935    ///
936    /// Mirrors sklearn's `PoissonRegressor.fit(X, y, sample_weight)`
937    /// (`glm.py:170`, `:229-242`). See
938    /// [`GLMRegressor::fit_with_sample_weight`] for the weighting semantics;
939    /// an all-ones weight vector reproduces [`Fit::fit`] exactly.
940    ///
941    /// # Errors
942    ///
943    /// See [`GLMRegressor::fit_with_sample_weight`].
944    pub fn fit_with_sample_weight(
945        &self,
946        x: &Array2<F>,
947        y: &Array1<F>,
948        sample_weight: &Array1<F>,
949    ) -> Result<FittedGLMRegressor<F>, FerroError> {
950        fit_glm_irls(
951            x,
952            y,
953            sample_weight,
954            &GLMFamily::Poisson,
955            Link::Log,
956            self.alpha,
957            self.max_iter,
958            self.tol,
959            self.fit_intercept,
960            self.warm_start,
961            self.coef_init.as_ref(),
962        )
963    }
964}
965
966/// Gamma regressor — GLM with Gamma family and log link.
967///
968/// Suitable for modelling positive continuous data (y > 0).
969///
970/// # Type Parameters
971///
972/// - `F`: The floating-point type (`f32` or `f64`).
973#[derive(Debug, Clone)]
974pub struct GammaRegressor<F> {
975    /// L2 regularization strength.
976    pub alpha: F,
977    /// Maximum number of IRLS iterations.
978    pub max_iter: usize,
979    /// Convergence tolerance.
980    pub tol: F,
981    /// Whether to fit an intercept.
982    pub fit_intercept: bool,
983    /// Optimization algorithm requested, mirroring sklearn's `solver` parameter
984    /// (`glm.py:140-145`, default `"lbfgs"`).
985    ///
986    /// ferrolearn fits via IRLS regardless of this value (R-DEV-7); the parameter
987    /// is accepted for sklearn API parity (R-DEV-2) and the observable
988    /// `coef_` / `intercept_` match sklearn for either value. See [`Solver`].
989    pub solver: Solver,
990    /// Whether to warm-start from an explicit initial point, mirroring sklearn's
991    /// `warm_start` parameter (default `false`, `glm.py:146, :708`). See
992    /// [`GLMRegressor::warm_start`] for the R-DEV-2 / R-DEV-7 rationale.
993    pub warm_start: bool,
994    /// Explicit warm-start initial point `(feature_coefficients, intercept)` — the
995    /// ferrolearn analog of sklearn reusing `self.coef_` / `self.intercept_`
996    /// (R-DEV-7, `glm.py:244-250`). Consulted only when `warm_start` is `true`. Set
997    /// via [`GammaRegressor::with_coef_init`].
998    pub coef_init: Option<(Array1<F>, F)>,
999}
1000
1001impl<F: Float + FromPrimitive> GammaRegressor<F> {
1002    /// Create a new `GammaRegressor` with default settings.
1003    ///
1004    /// Defaults: `alpha = 1.0`, `max_iter = 100`, `tol = 1e-4`,
1005    /// `fit_intercept = true`, `solver = Solver::Lbfgs` (sklearn default).
1006    #[must_use]
1007    pub fn new() -> Self {
1008        Self {
1009            alpha: F::one(),
1010            max_iter: 100,
1011            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
1012            fit_intercept: true,
1013            solver: Solver::Lbfgs,
1014            warm_start: false,
1015            coef_init: None,
1016        }
1017    }
1018
1019    /// Set the L2 regularization strength.
1020    #[must_use]
1021    pub fn with_alpha(mut self, alpha: F) -> Self {
1022        self.alpha = alpha;
1023        self
1024    }
1025
1026    /// Set the maximum number of IRLS iterations.
1027    #[must_use]
1028    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
1029        self.max_iter = max_iter;
1030        self
1031    }
1032
1033    /// Set the convergence tolerance.
1034    #[must_use]
1035    pub fn with_tol(mut self, tol: F) -> Self {
1036        self.tol = tol;
1037        self
1038    }
1039
1040    /// Set whether to fit an intercept.
1041    #[must_use]
1042    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
1043        self.fit_intercept = fit_intercept;
1044        self
1045    }
1046
1047    /// Set the optimization [`Solver`], mirroring sklearn's `solver` parameter
1048    /// (`glm.py:140-145`, default `"lbfgs"`).
1049    ///
1050    /// ferrolearn fits via IRLS regardless of the value (R-DEV-7); both values
1051    /// produce the same observable `coef_` / `intercept_` (sklearn API parity,
1052    /// R-DEV-2).
1053    #[must_use]
1054    pub fn with_solver(mut self, solver: Solver) -> Self {
1055        self.solver = solver;
1056        self
1057    }
1058
1059    /// Enable or disable warm-starting, mirroring sklearn's `warm_start`
1060    /// parameter (default `false`, `glm.py:146, :708`). See
1061    /// [`GLMRegressor::with_warm_start`] for the R-DEV-2 / R-DEV-7 rationale; the
1062    /// warm-start point is supplied explicitly via
1063    /// [`GammaRegressor::with_coef_init`].
1064    #[must_use]
1065    pub fn with_warm_start(mut self, warm_start: bool) -> Self {
1066        self.warm_start = warm_start;
1067        self
1068    }
1069
1070    /// Set the explicit warm-start initial point `(feature_coefficients,
1071    /// intercept)` — the ferrolearn analog of sklearn reusing `self.coef_` /
1072    /// `self.intercept_` (R-DEV-7, `glm.py:244-250`). Only consulted when
1073    /// `warm_start` is `true`; `coef`'s length must equal the number of features.
1074    #[must_use]
1075    pub fn with_coef_init(mut self, coef: Array1<F>, intercept: F) -> Self {
1076        self.coef_init = Some((coef, intercept));
1077        self
1078    }
1079}
1080
1081impl<F: Float + FromPrimitive> Default for GammaRegressor<F> {
1082    fn default() -> Self {
1083        Self::new()
1084    }
1085}
1086
1087impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> GammaRegressor<F> {
1088    /// Fit the Gamma GLM via IRLS with per-sample weights `sample_weight`.
1089    ///
1090    /// Mirrors sklearn's `GammaRegressor.fit(X, y, sample_weight)`
1091    /// (`glm.py:170`, `:229-242`). See
1092    /// [`GLMRegressor::fit_with_sample_weight`] for the weighting semantics;
1093    /// an all-ones weight vector reproduces [`Fit::fit`] exactly.
1094    ///
1095    /// # Errors
1096    ///
1097    /// See [`GLMRegressor::fit_with_sample_weight`].
1098    pub fn fit_with_sample_weight(
1099        &self,
1100        x: &Array2<F>,
1101        y: &Array1<F>,
1102        sample_weight: &Array1<F>,
1103    ) -> Result<FittedGLMRegressor<F>, FerroError> {
1104        fit_glm_irls(
1105            x,
1106            y,
1107            sample_weight,
1108            &GLMFamily::Gamma,
1109            Link::Log,
1110            self.alpha,
1111            self.max_iter,
1112            self.tol,
1113            self.fit_intercept,
1114            self.warm_start,
1115            self.coef_init.as_ref(),
1116        )
1117    }
1118}
1119
1120/// Tweedie regressor — GLM with Tweedie family and log link.
1121///
1122/// The `power` parameter controls the variance-mean relationship:
1123/// V(mu) = mu^power.
1124///
1125/// # Type Parameters
1126///
1127/// - `F`: The floating-point type (`f32` or `f64`).
1128#[derive(Debug, Clone)]
1129pub struct TweedieRegressor<F> {
1130    /// Tweedie power parameter.
1131    pub power: f64,
1132    /// Link-function configuration (`Auto`/`Log`/`Identity`).
1133    ///
1134    /// `Auto` (the default) resolves to the identity link for `power <= 0`
1135    /// (Normal) and the log link for `power > 0`, matching sklearn's
1136    /// `link='auto'` (`glm.py:889-893`).
1137    pub link: LinkConfig,
1138    /// L2 regularization strength.
1139    pub alpha: F,
1140    /// Maximum number of IRLS iterations.
1141    pub max_iter: usize,
1142    /// Convergence tolerance.
1143    pub tol: F,
1144    /// Whether to fit an intercept.
1145    pub fit_intercept: bool,
1146    /// Optimization algorithm requested, mirroring sklearn's `solver` parameter
1147    /// (`glm.py:140-145`, default `"lbfgs"`).
1148    ///
1149    /// ferrolearn fits via IRLS regardless of this value (R-DEV-7); the parameter
1150    /// is accepted for sklearn API parity (R-DEV-2) and the observable
1151    /// `coef_` / `intercept_` match sklearn for either value. See [`Solver`].
1152    pub solver: Solver,
1153    /// Whether to warm-start from an explicit initial point, mirroring sklearn's
1154    /// `warm_start` parameter (default `false`, `glm.py:146, :874`). See
1155    /// [`GLMRegressor::warm_start`] for the R-DEV-2 / R-DEV-7 rationale.
1156    pub warm_start: bool,
1157    /// Explicit warm-start initial point `(feature_coefficients, intercept)` — the
1158    /// ferrolearn analog of sklearn reusing `self.coef_` / `self.intercept_`
1159    /// (R-DEV-7, `glm.py:244-250`). Consulted only when `warm_start` is `true`. Set
1160    /// via [`TweedieRegressor::with_coef_init`].
1161    pub coef_init: Option<(Array1<F>, F)>,
1162}
1163
1164impl<F: Float + FromPrimitive> TweedieRegressor<F> {
1165    /// Create a new `TweedieRegressor` with default settings.
1166    ///
1167    /// Defaults match sklearn's `TweedieRegressor.__init__` (`glm.py:864-887`):
1168    /// `power = 0.0` (Normal), `link = LinkConfig::Auto`, `alpha = 1.0`,
1169    /// `max_iter = 100`, `tol = 1e-4`, `fit_intercept = true`,
1170    /// `solver = Solver::Lbfgs` (sklearn default). With the default
1171    /// `power = 0.0` and `Auto` link, the model is Normal/identity-link (OLS).
1172    #[must_use]
1173    pub fn new() -> Self {
1174        Self {
1175            power: 0.0,
1176            link: LinkConfig::Auto,
1177            alpha: F::one(),
1178            max_iter: 100,
1179            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
1180            fit_intercept: true,
1181            solver: Solver::Lbfgs,
1182            warm_start: false,
1183            coef_init: None,
1184        }
1185    }
1186
1187    /// Set the Tweedie power parameter.
1188    #[must_use]
1189    pub fn with_power(mut self, power: f64) -> Self {
1190        self.power = power;
1191        self
1192    }
1193
1194    /// Set the link-function configuration (`Auto`/`Log`/`Identity`).
1195    ///
1196    /// Mirrors sklearn's `link={'auto','identity','log'}` (`glm.py:861`).
1197    #[must_use]
1198    pub fn with_link(mut self, link: LinkConfig) -> Self {
1199        self.link = link;
1200        self
1201    }
1202
1203    /// Set the L2 regularization strength.
1204    #[must_use]
1205    pub fn with_alpha(mut self, alpha: F) -> Self {
1206        self.alpha = alpha;
1207        self
1208    }
1209
1210    /// Set the maximum number of IRLS iterations.
1211    #[must_use]
1212    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
1213        self.max_iter = max_iter;
1214        self
1215    }
1216
1217    /// Set the convergence tolerance.
1218    #[must_use]
1219    pub fn with_tol(mut self, tol: F) -> Self {
1220        self.tol = tol;
1221        self
1222    }
1223
1224    /// Set whether to fit an intercept.
1225    #[must_use]
1226    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
1227        self.fit_intercept = fit_intercept;
1228        self
1229    }
1230
1231    /// Set the optimization [`Solver`], mirroring sklearn's `solver` parameter
1232    /// (`glm.py:140-145`, default `"lbfgs"`).
1233    ///
1234    /// ferrolearn fits via IRLS regardless of the value (R-DEV-7); both values
1235    /// produce the same observable `coef_` / `intercept_` (sklearn API parity,
1236    /// R-DEV-2).
1237    #[must_use]
1238    pub fn with_solver(mut self, solver: Solver) -> Self {
1239        self.solver = solver;
1240        self
1241    }
1242
1243    /// Enable or disable warm-starting, mirroring sklearn's `warm_start`
1244    /// parameter (default `false`, `glm.py:146, :874`). See
1245    /// [`GLMRegressor::with_warm_start`] for the R-DEV-2 / R-DEV-7 rationale; the
1246    /// warm-start point is supplied explicitly via
1247    /// [`TweedieRegressor::with_coef_init`].
1248    #[must_use]
1249    pub fn with_warm_start(mut self, warm_start: bool) -> Self {
1250        self.warm_start = warm_start;
1251        self
1252    }
1253
1254    /// Set the explicit warm-start initial point `(feature_coefficients,
1255    /// intercept)` — the ferrolearn analog of sklearn reusing `self.coef_` /
1256    /// `self.intercept_` (R-DEV-7, `glm.py:244-250`). Only consulted when
1257    /// `warm_start` is `true`; `coef`'s length must equal the number of features.
1258    #[must_use]
1259    pub fn with_coef_init(mut self, coef: Array1<F>, intercept: F) -> Self {
1260        self.coef_init = Some((coef, intercept));
1261        self
1262    }
1263}
1264
1265impl<F: Float + FromPrimitive> Default for TweedieRegressor<F> {
1266    fn default() -> Self {
1267        Self::new()
1268    }
1269}
1270
1271impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> TweedieRegressor<F> {
1272    /// Fit the Tweedie GLM via IRLS with per-sample weights `sample_weight`.
1273    ///
1274    /// Mirrors sklearn's `TweedieRegressor.fit(X, y, sample_weight)`
1275    /// (`glm.py:170`, `:229-242`). The link is resolved from `link`/`power`
1276    /// as in [`Fit::fit`] (`glm.py:889-903`); see
1277    /// [`GLMRegressor::fit_with_sample_weight`] for the weighting semantics.
1278    /// An all-ones weight vector reproduces [`Fit::fit`] exactly.
1279    ///
1280    /// # Errors
1281    ///
1282    /// See [`GLMRegressor::fit_with_sample_weight`].
1283    pub fn fit_with_sample_weight(
1284        &self,
1285        x: &Array2<F>,
1286        y: &Array1<F>,
1287        sample_weight: &Array1<F>,
1288    ) -> Result<FittedGLMRegressor<F>, FerroError> {
1289        let link = self.link.resolve(self.power);
1290        fit_glm_irls(
1291            x,
1292            y,
1293            sample_weight,
1294            &GLMFamily::Tweedie(self.power),
1295            link,
1296            self.alpha,
1297            self.max_iter,
1298            self.tol,
1299            self.fit_intercept,
1300            self.warm_start,
1301            self.coef_init.as_ref(),
1302        )
1303    }
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Internal helpers
1308// ---------------------------------------------------------------------------
1309
1310/// Cholesky solve for `A x = b`.
1311fn cholesky_solve<F: Float>(a: &Array2<F>, b: &Array1<F>) -> Result<Array1<F>, FerroError> {
1312    let n = a.nrows();
1313    let mut l = Array2::<F>::zeros((n, n));
1314
1315    for i in 0..n {
1316        for j in 0..=i {
1317            let mut s = a[[i, j]];
1318            for k in 0..j {
1319                s = s - l[[i, k]] * l[[j, k]];
1320            }
1321            if i == j {
1322                if s <= F::zero() {
1323                    return Err(FerroError::NumericalInstability {
1324                        message: "Cholesky: matrix not positive definite".into(),
1325                    });
1326                }
1327                l[[i, j]] = s.sqrt();
1328            } else {
1329                l[[i, j]] = s / l[[j, j]];
1330            }
1331        }
1332    }
1333
1334    let mut z = Array1::<F>::zeros(n);
1335    for i in 0..n {
1336        let mut s = b[i];
1337        for k in 0..i {
1338            s = s - l[[i, k]] * z[k];
1339        }
1340        z[i] = s / l[[i, i]];
1341    }
1342
1343    let mut x_sol = Array1::<F>::zeros(n);
1344    for i in (0..n).rev() {
1345        let mut s = z[i];
1346        for k in (i + 1)..n {
1347            s = s - l[[k, i]] * x_sol[k];
1348        }
1349        x_sol[i] = s / l[[i, i]];
1350    }
1351
1352    Ok(x_sol)
1353}
1354
1355/// Gaussian elimination with partial pivoting.
1356fn gaussian_solve<F: Float>(
1357    n: usize,
1358    a: &Array2<F>,
1359    b: &Array1<F>,
1360) -> Result<Array1<F>, FerroError> {
1361    let mut aug = Array2::<F>::zeros((n, n + 1));
1362    for i in 0..n {
1363        for j in 0..n {
1364            aug[[i, j]] = a[[i, j]];
1365        }
1366        aug[[i, n]] = b[i];
1367    }
1368
1369    for col in 0..n {
1370        let mut max_val = aug[[col, col]].abs();
1371        let mut max_row = col;
1372        for row in (col + 1)..n {
1373            let v = aug[[row, col]].abs();
1374            if v > max_val {
1375                max_val = v;
1376                max_row = row;
1377            }
1378        }
1379
1380        if max_val < F::from(1e-12).unwrap_or_else(F::epsilon) {
1381            return Err(FerroError::NumericalInstability {
1382                message: "singular matrix in Gaussian elimination".into(),
1383            });
1384        }
1385
1386        if max_row != col {
1387            for j in 0..=n {
1388                let tmp = aug[[col, j]];
1389                aug[[col, j]] = aug[[max_row, j]];
1390                aug[[max_row, j]] = tmp;
1391            }
1392        }
1393
1394        let pivot = aug[[col, col]];
1395        for row in (col + 1)..n {
1396            let factor = aug[[row, col]] / pivot;
1397            for j in col..=n {
1398                let above = aug[[col, j]];
1399                aug[[row, j]] = aug[[row, j]] - factor * above;
1400            }
1401        }
1402    }
1403
1404    let mut x_sol = Array1::<F>::zeros(n);
1405    for i in (0..n).rev() {
1406        let mut s = aug[[i, n]];
1407        for j in (i + 1)..n {
1408            s = s - aug[[i, j]] * x_sol[j];
1409        }
1410        if aug[[i, i]].abs() < F::from(1e-12).unwrap_or_else(F::epsilon) {
1411            return Err(FerroError::NumericalInstability {
1412                message: "near-zero pivot in back substitution".into(),
1413            });
1414        }
1415        x_sol[i] = s / aug[[i, i]];
1416    }
1417
1418    Ok(x_sol)
1419}
1420
1421/// Solve the weighted ridge system `(X^T W X + P) w = X^T W z`, where the
1422/// penalty matrix `P` adds the L2 regularization to the diagonal of the
1423/// feature columns only.
1424///
1425/// # Penalty scaling and the intercept (sklearn parity, `glm.py:229-258`)
1426///
1427/// scikit-learn minimizes the per-sample-MEAN half-deviance plus an L2 prior on
1428/// the feature coefficients (NOT the intercept):
1429///
1430/// ```text
1431/// J(w) = 1/(2*S) * sum_i s_i * deviance_i + 1/2 * alpha * ||coef||^2,
1432/// ```
1433///
1434/// with `S = sum_i s_i` (= `n_samples` for unweighted fits). Its stationarity
1435/// condition is `(1/S) * grad[sum 1/2 dev] + alpha * w_features = 0`.
1436///
1437/// The IRLS normal equations `X^T W X w = X^T W z` are the linearization of the
1438/// SUMMED half-deviance `sum_i 1/2 dev_i` (no `1/S` factor): `X^T W X` is the
1439/// summed-scale Hessian. To make those summed equations correspond to sklearn's
1440/// mean-scale objective we multiply the penalty by `S` (the sum of weights, =
1441/// `n_samples` unweighted) before adding it to the diagonal: the added penalty
1442/// is `weight_sum * alpha`, applied to feature columns only, leaving the
1443/// intercept (column 0 of the augmented design, when present) unpenalized.
1444///
1445/// `weight_sum` is `S = sum_i s_i`, the sum of the GLM `sample_weight`
1446/// (= `n_samples` for an all-ones / unweighted fit); `intercept_col` is
1447/// `Some(0)` when an intercept column was prepended to `x`, `None` otherwise.
1448fn weighted_ridge_solve<F: Float + FromPrimitive>(
1449    x: &Array2<F>,
1450    z: &Array1<F>,
1451    weights: &Array1<F>,
1452    alpha: F,
1453    weight_sum: F,
1454    intercept_col: Option<usize>,
1455) -> Result<Array1<F>, FerroError> {
1456    let (n_samples, n_features) = x.dim();
1457
1458    let mut xtwx = Array2::<F>::zeros((n_features, n_features));
1459    let mut xtwz = Array1::<F>::zeros(n_features);
1460
1461    for i in 0..n_samples {
1462        let wi = weights[i];
1463        let xi = x.row(i);
1464        for r in 0..n_features {
1465            xtwz[r] = xtwz[r] + wi * xi[r] * z[i];
1466            for c in 0..n_features {
1467                xtwx[[r, c]] = xtwx[[r, c]] + wi * xi[r] * xi[c];
1468            }
1469        }
1470    }
1471
1472    // Add L2 regularization. The IRLS normal equations are at the SUMMED-deviance
1473    // scale, so to match sklearn's MEAN-deviance objective the diagonal penalty is
1474    // `weight_sum * alpha` (glm.py:229-242). The intercept column is excluded:
1475    // sklearn's `l2_reg_strength = self.alpha` weights only `||coef||^2`
1476    // (glm.py:258), never the intercept.
1477    let penalty = weight_sum * alpha;
1478    for i in 0..n_features {
1479        if Some(i) == intercept_col {
1480            continue;
1481        }
1482        xtwx[[i, i]] = xtwx[[i, i]] + penalty;
1483    }
1484
1485    cholesky_solve(&xtwx, &xtwz).or_else(|_| gaussian_solve(n_features, &xtwx, &xtwz))
1486}
1487
1488/// Core IRLS fitting logic shared by all GLM variants.
1489///
1490/// The IRLS update is parameterized by the [`Link`]: with linear predictor
1491/// `eta = X @ coef`, mean `mu = link.inverse(eta)` and link derivative
1492/// `dmu/deta`, the standard Fisher-scoring working weight and response are
1493/// `w = (dmu/deta)^2 / V(mu)` and `z = eta + (y - mu) / (dmu/deta)`
1494/// (`glm.py:362` for the inverse-link mapping). For [`Link::Log`]
1495/// (`dmu/deta = mu`) this is `w = mu^2 / V(mu)`, `z = eta + (y - mu)/mu`,
1496/// byte-identical to the previous log-only code. For [`Link::Identity`] with
1497/// `V(mu) = mu^0 = 1` (Normal/`power = 0`), `w = 1`, `z = y`, so IRLS reduces to
1498/// ordinary least squares.
1499///
1500/// # Warm start (R-DEV-2 + R-DEV-7)
1501///
1502/// sklearn's `warm_start=True` reuses the previous fit's `self.coef_` /
1503/// `self.intercept_` as the optimizer's starting point (`glm.py:243-254`).
1504/// ferrolearn's estimators are immutable (`fit(&self, ...)` returns a fresh
1505/// fitted object and never mutates `self`), so there is no `self.coef_` to reuse
1506/// across calls; the Rust-idiomatic analog (R-DEV-7) is an EXPLICIT initial point
1507/// supplied via `coef_init = Some((feature_coef, intercept))`. When
1508/// `warm_start == true` and `coef_init` is provided, the IRLS coefficient vector
1509/// (and the derived `eta` / `mu`) are seeded from `coef_init` instead of the
1510/// cold start (`coef = 0`, `eta = link(y)`). The feature-coefficient length must
1511/// equal `n_features` (else [`FerroError::ShapeMismatch`]). Otherwise the cold
1512/// start is kept byte-for-byte.
1513///
1514/// Because the penalized GLM objective is convex, the converged `coef_` /
1515/// `intercept_` are warm-start-INVARIANT: the init only changes the starting
1516/// point (and so the iteration count), never the optimum — so the warm-started
1517/// fit matches both the cold-start fit and the sklearn oracle (`glm.py:244-256`
1518/// reaches the same minimizer regardless of the seed).
1519#[allow(
1520    clippy::too_many_arguments,
1521    reason = "shared IRLS core threads the link and the warm-start init \
1522    alongside the family/penalty/convergence parameters; splitting into a config \
1523    struct would obscure the 1:1 mapping to sklearn's fit signature"
1524)]
1525fn fit_glm_irls<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static>(
1526    x: &Array2<F>,
1527    y: &Array1<F>,
1528    sample_weight: &Array1<F>,
1529    family: &GLMFamily,
1530    link: Link,
1531    alpha: F,
1532    max_iter: usize,
1533    tol: F,
1534    fit_intercept: bool,
1535    warm_start: bool,
1536    coef_init: Option<&(Array1<F>, F)>,
1537) -> Result<FittedGLMRegressor<F>, FerroError> {
1538    let (n_samples, n_features_orig) = x.dim();
1539
1540    if n_samples != y.len() {
1541        return Err(FerroError::ShapeMismatch {
1542            expected: vec![n_samples],
1543            actual: vec![y.len()],
1544            context: "y length must match number of samples in X".into(),
1545        });
1546    }
1547
1548    if sample_weight.len() != n_samples {
1549        return Err(FerroError::ShapeMismatch {
1550            expected: vec![n_samples],
1551            actual: vec![sample_weight.len()],
1552            context: "sample_weight length must match number of samples in X".into(),
1553        });
1554    }
1555
1556    // Non-finite input validation, mirroring sklearn's
1557    // `self._validate_data(X, y, ..., y_numeric=True)` (`glm.py:189-196`) which
1558    // keeps the default `force_all_finite=True`, so `check_array` rejects any
1559    // NaN or +/-inf in X OR y with a `ValueError` BEFORE the IRLS loop. sklearn
1560    // also validates `sample_weight` via `_check_sample_weight` (default
1561    // `force_all_finite=True`, `glm.py:211`). This is the SHARED IRLS entry that
1562    // every estimator (PoissonRegressor/GammaRegressor/TweedieRegressor/
1563    // GLMRegressor) routes through, so the check lands ONCE here.
1564    // `.iter().any(|v| !v.is_finite())` rejects both NaN and Inf (bounds-safe,
1565    // no panic, R-CODE-2), matching the crate idiom (`ridge.rs`). The finite
1566    // path is byte-identical (the guard never fires on finite input). The
1567    // sample_weight finiteness check MUST precede the non-negative check below:
1568    // sklearn's `_check_sample_weight` runs `check_array(force_all_finite=True)`
1569    // first, so a `-inf` weight raises the infinity `ValueError` (verified live),
1570    // NOT a non-negative error. This guard also precedes the per-family y-domain
1571    // validation so a non-finite y is reported as a finiteness failure
1572    // (`is_finite()` is `false` for NaN/Inf), matching sklearn's `check_array`
1573    // running before `in_y_true_range`.
1574    if x.iter().any(|v| !v.is_finite()) {
1575        return Err(FerroError::InvalidParameter {
1576            name: "X".into(),
1577            reason: "Input X contains NaN or infinity.".into(),
1578        });
1579    }
1580    if y.iter().any(|v| !v.is_finite()) {
1581        return Err(FerroError::InvalidParameter {
1582            name: "y".into(),
1583            reason: "Input y contains NaN or infinity.".into(),
1584        });
1585    }
1586    if sample_weight.iter().any(|v| !v.is_finite()) {
1587        return Err(FerroError::InvalidParameter {
1588            name: "sample_weight".into(),
1589            reason: "Input sample_weight contains NaN or infinity.".into(),
1590        });
1591    }
1592
1593    // sklearn's GLM family does NOT reject finite negative `sample_weight`: it
1594    // calls `_check_sample_weight(sample_weight, X, dtype=loss_dtype)` WITHOUT
1595    // `only_non_negative=True` (`glm.py:211`), so a finite negative weight passes
1596    // validation and reaches the optimizer (verified live: `PoissonRegressor()
1597    // .fit(X, y, sample_weight=[..,-1.0])` fits, coef `[-0.04268902, 0.24080026]`).
1598    // Negative weights are mathematically valid for the GLM objective — the
1599    // deviance is averaged `sum_i s_i * deviance_i` (`glm.py:229-242`), so a
1600    // negative `s_i` simply NEGATES that sample's contribution. ferrolearn's IRLS
1601    // uses `sample_weight` LINEARLY (`weights[i] *= sample_weight[i]` feeding the
1602    // `X^T W X` / `X^T W z` accumulation in `weighted_ridge_solve`, NO `sqrt`), so
1603    // a negative weight flows through unchanged and reaches the same optimum as
1604    // sklearn's lbfgs. The non-finite guard above (#2261) still rejects NaN/Inf
1605    // weights (sklearn's `_check_sample_weight` runs `check_array(force_all_finite
1606    // =True)` first), matching exception parity for non-finite input.
1607
1608    if n_samples == 0 {
1609        return Err(FerroError::InsufficientSamples {
1610            required: 1,
1611            actual: 0,
1612            context: "GLM requires at least one sample".into(),
1613        });
1614    }
1615
1616    if alpha < F::zero() {
1617        return Err(FerroError::InvalidParameter {
1618            name: "alpha".into(),
1619            reason: "must be non-negative".into(),
1620        });
1621    }
1622
1623    // Per-family target-domain validation, mirroring sklearn's
1624    //   `if not linear_loss.base_loss.in_y_true_range(y): raise ValueError(...)`
1625    // (`glm.py:221-225`). The valid `y` range is determined by the family's
1626    // Tweedie `power` (NOT the link — verified against the live oracle):
1627    //   * `power <= 0`  (Normal/identity)        -> y unconstrained
1628    //   * `0 < power < 2` (Poisson `power = 1`)  -> y >= 0   (closed at 0)
1629    //   * `power >= 2`    (Gamma `power = 2`)     -> y > 0    (open at 0)
1630    // Confirmed live-oracle behaviors: `GammaRegressor().fit(X, [0,1,2])` and
1631    // `TweedieRegressor(power=2.0).fit(X, [0,1,2])` raise ValueError;
1632    // `PoissonRegressor().fit(X, [-1,1,2])` raises; `TweedieRegressor(power=1.5)`
1633    // accepts y == 0.
1634    let min_y = F::from(1e-10).unwrap_or_else(F::epsilon);
1635    let y_domain = YDomain::for_power(family.domain_power());
1636    if y.iter().any(|&yi| !y_domain.contains(yi)) {
1637        return Err(FerroError::InvalidParameter {
1638            name: "y".into(),
1639            reason: format!(
1640                "Some value(s) of y are out of the valid range of the loss '{}'.",
1641                y_domain.loss_name()
1642            ),
1643        });
1644    }
1645
1646    // Build design matrix (optionally prepend intercept column).
1647    let n_cols = if fit_intercept {
1648        n_features_orig + 1
1649    } else {
1650        n_features_orig
1651    };
1652
1653    let mut x_design = Array2::<F>::zeros((n_samples, n_cols));
1654    if fit_intercept {
1655        for i in 0..n_samples {
1656            x_design[[i, 0]] = F::one();
1657            for j in 0..n_features_orig {
1658                x_design[[i, j + 1]] = x[[i, j]];
1659            }
1660        }
1661    } else {
1662        x_design.assign(x);
1663    }
1664
1665    // The intercept (column 0 of the augmented design when fitting one) is
1666    // excluded from the L2 penalty, matching sklearn (glm.py:258).
1667    let intercept_col = if fit_intercept { Some(0) } else { None };
1668
1669    // Sum of sample weights `S = sum_i s_i`. For an unweighted GLM every weight
1670    // is 1, so this is `n_samples` (byte-identical to the previous unweighted
1671    // path). It scales the L2 penalty so the summed-deviance IRLS normal
1672    // equations minimize sklearn's mean-deviance objective normalized by
1673    // `sum(sample_weight)` (glm.py:229-242).
1674    let weight_sum = sample_weight.iter().fold(F::zero(), |acc, &si| acc + si);
1675
1676    // For the log link, clamp y away from 0 so `ln(y)` and `mu` stay finite.
1677    // For the identity link y is used as-is (mu = eta = y, no positivity
1678    // constraint).
1679    let y_safe: Array1<F> = match link {
1680        Link::Log => y.mapv(|v| if v < min_y { min_y } else { v }),
1681        Link::Identity => y.clone(),
1682    };
1683
1684    // Initialise mu = y_safe, eta = g(mu) = link(mu): log link → ln(y),
1685    // identity link → y.
1686    let mut mu: Array1<F> = y_safe.clone();
1687    let mut eta: Array1<F> = match link {
1688        Link::Log => y_safe.mapv(|v| v.ln()),
1689        Link::Identity => y_safe.clone(),
1690    };
1691
1692    // Coefficient initialization.
1693    //
1694    // COLD START (default, `warm_start == false` or no `coef_init`): feature
1695    // coefficients are 0; when `fit_intercept` the intercept entry is seeded at
1696    // `link.link(weighted_mean(y))` (REQ-5, `glm.py:251-256`), and `eta`/`mu`
1697    // are recomputed from that seed (so `eta == link(mean_w(y))` constant). When
1698    // `fit_intercept` is false the intercept seed is skipped and `eta`/`mu` keep
1699    // the per-sample `link(y)` seed above. The objective is convex, so this
1700    // changes only the starting point (and the iteration count), never the
1701    // converged optimum — the cold fit still matches the sklearn oracle.
1702    //
1703    // WARM START (R-DEV-7 analog of sklearn's `warm_start=True`,
1704    // `glm.py:243-254`): seed the IRLS coefficient vector from the explicit
1705    // `coef_init = (feature_coef, intercept)` instead, then recompute
1706    // `eta = X_design @ coef` and `mu = link.inverse(eta)` so the first IRLS
1707    // weights/working-response are formed at the supplied point (sklearn likewise
1708    // hands `coef` straight to the optimizer). When `warm_start && coef_init`,
1709    // the explicit init takes precedence over the REQ-5 intercept seed.
1710    let mut coef = Array1::<F>::zeros(n_cols);
1711    if warm_start && let Some((feature_coef, intercept_init)) = coef_init {
1712        if feature_coef.len() != n_features_orig {
1713            return Err(FerroError::ShapeMismatch {
1714                expected: vec![n_features_orig],
1715                actual: vec![feature_coef.len()],
1716                context: "coef_init feature-coefficient length must match \
1717                          number of features in X"
1718                    .into(),
1719            });
1720        }
1721        // Place the supplied coefficients into the (optionally
1722        // intercept-augmented) design layout: column 0 is the intercept when
1723        // `fit_intercept`, features follow. When `fit_intercept == false` the
1724        // supplied intercept is ignored (the model has no intercept term),
1725        // matching sklearn's `coef = self.coef_` (no intercept) branch
1726        // (`glm.py:248-249`).
1727        if fit_intercept {
1728            coef[0] = *intercept_init;
1729            for (j, &cj) in feature_coef.iter().enumerate() {
1730                coef[j + 1] = cj;
1731            }
1732        } else {
1733            for (j, &cj) in feature_coef.iter().enumerate() {
1734                coef[j] = cj;
1735            }
1736        }
1737        // Recompute eta/mu at the warm-start point so the first IRLS step is
1738        // formed there (link.inverse with the same clamps as the loop body).
1739        eta = x_design.dot(&coef);
1740        match link {
1741            Link::Log => {
1742                let hi = F::from(20.0).unwrap_or_else(F::max_value);
1743                let lo = F::zero() - hi;
1744                let mmu = F::from(1e-10).unwrap_or_else(F::epsilon);
1745                let xmu = F::from(1e10).unwrap_or_else(F::max_value);
1746                for i in 0..n_samples {
1747                    let eta_i = eta[i].max(lo).min(hi);
1748                    eta[i] = eta_i;
1749                    mu[i] = link.inverse(eta_i).max(mmu).min(xmu);
1750                }
1751            }
1752            Link::Identity => {
1753                for i in 0..n_samples {
1754                    mu[i] = link.inverse(eta[i]);
1755                }
1756            }
1757        }
1758    } else if fit_intercept {
1759        // COLD-START INTERCEPT INITIALIZATION (REQ-5, `glm.py:251-256`).
1760        //
1761        // sklearn cold-starts with `coef = init_zero_coef(X)` and, when
1762        // `fit_intercept`, seeds the intercept entry at
1763        //   `coef[-1] = link.link(np.average(y, weights=sample_weight))`
1764        // (the feature coefficients stay 0). For the log link this is
1765        // `intercept_init = log(weighted_mean(y))`; for the identity link it is
1766        // `weighted_mean(y)`. We mirror this exactly: the intercept is design
1767        // column 0, so `coef[0] = link.link(weighted_mean(y))`.
1768        //
1769        // `weighted_mean(y) = Σ(s_i·y_i) / Σ(s_i)` (= plain mean for an all-ones
1770        // sample_weight). `weight_sum = Σ s_i` was computed above.
1771        //
1772        // Edge case (R-CODE-2, R-DEV-1): the seed must lie in the link's domain.
1773        // For the log link `weighted_mean(y)` can be 0 (e.g. all-zero Poisson
1774        // `y`, which is IN the Poisson domain `y >= 0`), where `log(0) = -inf` —
1775        // sklearn likewise computes `link.link(0) = -inf` there (`glm.py:254`)
1776        // and its lbfgs optimum STAYS at `-inf`: the penalized data deviance is
1777        // minimized as `mu -> 0` (`eta -> -inf`), and the L2 penalty is on the
1778        // feature coefficients only (`l2_reg_strength = self.alpha`,
1779        // `glm.py:258`), which are 0. So sklearn returns `intercept_ = -inf`,
1780        // `coef_ = 0`, and `predict = inverse_link(X @ 0 - inf) = exp(-inf) =
1781        // 0.0` EXACTLY for every sample. We short-circuit to that same degenerate
1782        // optimum: IRLS cannot iterate from `mu = 0` (the Fisher weights blow up),
1783        // so we skip the loop entirely and return `coef_ = 0`, `intercept_ =
1784        // link(weighted_mean(y))` (the non-finite seed). This is alpha-INVARIANT
1785        // (the penalty on coef is 0 since coef = 0; the deviance drives
1786        // `eta -> -inf` regardless of alpha) and fires ONLY for a non-finite seed
1787        // (log link + `mean(y) == 0`) — the exact all-zero case. Gamma requires
1788        // `y > 0` so `mean(y) > 0` always (domain guard rejects `y == 0`); the
1789        // identity link gives `link(0) = 0` (finite), so neither hits this path.
1790        let weighted_y_sum = y
1791            .iter()
1792            .zip(sample_weight.iter())
1793            .fold(F::zero(), |acc, (&yi, &si)| acc + si * yi);
1794        let denom = if weight_sum > F::zero() {
1795            weight_sum
1796        } else {
1797            F::from(n_samples).unwrap_or_else(F::one)
1798        };
1799        let weighted_mean_y = weighted_y_sum / denom;
1800        let intercept_init = link.link(weighted_mean_y);
1801
1802        if !intercept_init.is_finite() {
1803            // Degenerate optimum (all-zero-y log-link case): short-circuit to
1804            // sklearn's lbfgs landing point `coef_ = 0`, `intercept_ = -inf`
1805            // (`glm.py:254`), skipping IRLS (which cannot iterate from `mu = 0`).
1806            // `predict = exp(-inf) = 0.0` then matches sklearn exactly. The
1807            // feature coefficients are already 0 (cold start); set the intercept
1808            // column to the non-finite seed and return immediately. `n_iter_ = 0`
1809            // records that the optimum was reached without any IRLS iteration.
1810            coef[0] = intercept_init;
1811            let intercept = coef[0];
1812            let coefficients = Array1::from_iter(coef.iter().skip(1).copied());
1813            return Ok(FittedGLMRegressor {
1814                coefficients,
1815                intercept,
1816                link,
1817                family: *family,
1818                n_iter: 0,
1819            });
1820        }
1821
1822        {
1823            coef[0] = intercept_init;
1824            // Recompute eta/mu from the seeded coef (feature coefs are 0, so
1825            // `eta = intercept_init` for every sample, i.e. `eta = link(mean y)`).
1826            eta = x_design.dot(&coef);
1827            match link {
1828                Link::Log => {
1829                    let hi = F::from(20.0).unwrap_or_else(F::max_value);
1830                    let lo = F::zero() - hi;
1831                    let mmu = F::from(1e-10).unwrap_or_else(F::epsilon);
1832                    let xmu = F::from(1e10).unwrap_or_else(F::max_value);
1833                    for i in 0..n_samples {
1834                        let eta_i = eta[i].max(lo).min(hi);
1835                        eta[i] = eta_i;
1836                        mu[i] = link.inverse(eta_i).max(mmu).min(xmu);
1837                    }
1838                }
1839                Link::Identity => {
1840                    for i in 0..n_samples {
1841                        mu[i] = link.inverse(eta[i]);
1842                    }
1843                }
1844            }
1845        }
1846        // (The non-finite-seed branch above returns early, so there is no
1847        // finite-init fall-through here.)
1848    }
1849
1850    let min_mu = F::from(1e-10).unwrap_or_else(F::epsilon);
1851    let max_mu = F::from(1e10).unwrap_or_else(F::max_value);
1852
1853    // Count the IRLS iterations actually run (sklearn's `n_iter_`, `glm.py:283`).
1854    // At least one iteration always runs (`max_iter >= 1`); on convergence we
1855    // break after the iteration that satisfied the tolerance.
1856    let mut n_iter = 0usize;
1857    for _iter in 0..max_iter {
1858        n_iter += 1;
1859        let coef_old = coef.clone();
1860
1861        // Compute IRLS weights and working response.
1862        let mut weights = Array1::<F>::zeros(n_samples);
1863        let mut z = Array1::<F>::zeros(n_samples);
1864
1865        for i in 0..n_samples {
1866            // IRLS (Fisher scoring) with the configured link:
1867            //   dmu/deta  : Log => mu, Identity => 1
1868            //   weight w  = (dmu/deta)^2 / V(mu)
1869            //   response z = eta + (y - mu) / (dmu/deta)
1870            // For Log this is `w = mu^2/V(mu)`, `z = eta + (y - mu)/mu`,
1871            // byte-identical to the previous log-only code (clamped `mu_i`
1872            // throughout). For Identity + power=0 (V=1): w=1, z=y => OLS.
1873            match link {
1874                Link::Log => {
1875                    let mu_i = mu[i].max(min_mu).min(max_mu);
1876                    let var_i = family.variance(mu_i).max(min_mu);
1877                    let g_prime = F::one() / mu_i; // derivative of log link
1878                    z[i] = eta[i] + (y_safe[i] - mu_i) * g_prime;
1879                    weights[i] = F::one() / (g_prime * g_prime * var_i);
1880                }
1881                Link::Identity => {
1882                    let mu_i = mu[i];
1883                    // V(mu) for the identity link can see mu <= 0 (eta is
1884                    // unbounded); for power=0, V(mu)=mu^0=1 always. Clamp the
1885                    // magnitude for non-zero powers so V stays finite/positive.
1886                    let var_i = family.variance(mu_i.abs().max(min_mu)).max(min_mu);
1887                    let dmu_deta = link.dmu_deta(mu_i); // = 1
1888                    z[i] = eta[i] + (y_safe[i] - mu_i) / dmu_deta;
1889                    weights[i] = dmu_deta * dmu_deta / var_i;
1890                }
1891            }
1892            // Clamp the Fisher (GLM) working weight.
1893            if weights[i] < min_mu {
1894                weights[i] = min_mu;
1895            }
1896            // Apply the per-sample weight `s_i`: the `W` diagonal entry for
1897            // sample i becomes `s_i * w_irls,i` (standard weighted IRLS).
1898            // sklearn weights the deviance average by `sample_weight`
1899            // (glm.py:229-242); the working response `z_i` is unchanged. For
1900            // all-ones weights this is a no-op (byte-identical unweighted path).
1901            weights[i] = weights[i] * sample_weight[i];
1902        }
1903
1904        // Solve weighted ridge. `weights` now carry `s_i * w_irls,i`;
1905        // `weight_sum = S = sum_i s_i` (= n_samples for an all-ones fit). The
1906        // penalty is scaled by `S` so the summed-deviance normal equations
1907        // minimize sklearn's sample-weight-averaged deviance objective
1908        // (glm.py:229-242). The intercept column (column 0 of the
1909        // augmented design, present iff `fit_intercept`) is left unpenalized
1910        // (glm.py:258, `l2_reg_strength = self.alpha` weighs only `||coef||^2`).
1911        coef = weighted_ridge_solve(&x_design, &z, &weights, alpha, weight_sum, intercept_col)?;
1912
1913        // Update eta = X @ coef and mu = link.inverse(eta).
1914        eta = x_design.dot(&coef);
1915        match link {
1916            Link::Log => {
1917                let hi = F::from(20.0).unwrap_or_else(F::max_value);
1918                let lo = F::zero() - hi;
1919                for i in 0..n_samples {
1920                    // Clamp eta to prevent overflow in exp.
1921                    let eta_i = eta[i].max(lo).min(hi);
1922                    eta[i] = eta_i;
1923                    mu[i] = link.inverse(eta_i).max(min_mu).min(max_mu);
1924                }
1925            }
1926            Link::Identity => {
1927                // Identity link: eta is unbounded; mu = eta (no exp clamp).
1928                for i in 0..n_samples {
1929                    mu[i] = link.inverse(eta[i]);
1930                }
1931            }
1932        }
1933
1934        // Check convergence.
1935        let max_change = coef
1936            .iter()
1937            .zip(coef_old.iter())
1938            .map(|(&c, &co)| (c - co).abs())
1939            .fold(F::zero(), |a, b| if b > a { b } else { a });
1940
1941        if max_change < tol {
1942            break;
1943        }
1944    }
1945
1946    // Extract intercept and feature coefficients.
1947    let (intercept, coefficients) = if fit_intercept {
1948        let intercept = coef[0];
1949        let coefficients = Array1::from_iter(coef.iter().skip(1).copied());
1950        (intercept, coefficients)
1951    } else {
1952        (F::zero(), coef)
1953    };
1954
1955    Ok(FittedGLMRegressor {
1956        coefficients,
1957        intercept,
1958        link,
1959        family: *family,
1960        n_iter,
1961    })
1962}
1963
1964// ---------------------------------------------------------------------------
1965// Fit — GLMRegressor
1966// ---------------------------------------------------------------------------
1967
1968impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array1<F>>
1969    for GLMRegressor<F>
1970{
1971    type Fitted = FittedGLMRegressor<F>;
1972    type Error = FerroError;
1973
1974    /// Fit the GLM via IRLS.
1975    ///
1976    /// # Errors
1977    ///
1978    /// - [`FerroError::ShapeMismatch`] — sample count mismatch.
1979    /// - [`FerroError::InsufficientSamples`] — zero samples.
1980    /// - [`FerroError::InvalidParameter`] — negative alpha or negative y.
1981    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedGLMRegressor<F>, FerroError> {
1982        // GLMRegressor's families are all log-link (Poisson/Gamma/Tweedie>0).
1983        // The Tweedie identity link is exposed only through TweedieRegressor's
1984        // `link` configuration (sklearn similarly only exposes `link` on
1985        // `TweedieRegressor`, `glm.py:861`). The default (no sample_weight) path
1986        // delegates with an all-ones weight vector, matching sklearn's
1987        // `_check_sample_weight` default (`glm.py:208-211`); `weight_sum` then
1988        // equals `n_samples`, so this is byte-identical to the unweighted fit.
1989        self.fit_with_sample_weight(x, y, &Array1::ones(x.nrows()))
1990    }
1991}
1992
1993// ---------------------------------------------------------------------------
1994// Fit — PoissonRegressor
1995// ---------------------------------------------------------------------------
1996
1997impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array1<F>>
1998    for PoissonRegressor<F>
1999{
2000    type Fitted = FittedGLMRegressor<F>;
2001    type Error = FerroError;
2002
2003    /// Fit the Poisson GLM via IRLS.
2004    ///
2005    /// # Errors
2006    ///
2007    /// See [`GLMRegressor::fit`].
2008    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedGLMRegressor<F>, FerroError> {
2009        // Poisson uses the log link only (`HalfPoissonLoss`, `glm.py:589-590`).
2010        // Default (no sample_weight) path: all-ones weights => byte-identical to
2011        // the unweighted fit (`weight_sum = n_samples`).
2012        self.fit_with_sample_weight(x, y, &Array1::ones(x.nrows()))
2013    }
2014}
2015
2016// ---------------------------------------------------------------------------
2017// Fit — GammaRegressor
2018// ---------------------------------------------------------------------------
2019
2020impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array1<F>>
2021    for GammaRegressor<F>
2022{
2023    type Fitted = FittedGLMRegressor<F>;
2024    type Error = FerroError;
2025
2026    /// Fit the Gamma GLM via IRLS.
2027    ///
2028    /// # Errors
2029    ///
2030    /// See [`GLMRegressor::fit`].
2031    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedGLMRegressor<F>, FerroError> {
2032        // Gamma uses the log link only (`HalfGammaLoss`, `glm.py:721-722`).
2033        // Default (no sample_weight) path: all-ones weights => byte-identical to
2034        // the unweighted fit (`weight_sum = n_samples`).
2035        self.fit_with_sample_weight(x, y, &Array1::ones(x.nrows()))
2036    }
2037}
2038
2039// ---------------------------------------------------------------------------
2040// Fit — TweedieRegressor
2041// ---------------------------------------------------------------------------
2042
2043impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array1<F>>
2044    for TweedieRegressor<F>
2045{
2046    type Fitted = FittedGLMRegressor<F>;
2047    type Error = FerroError;
2048
2049    /// Fit the Tweedie GLM via IRLS.
2050    ///
2051    /// # Errors
2052    ///
2053    /// See [`GLMRegressor::fit`].
2054    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedGLMRegressor<F>, FerroError> {
2055        // Resolve the link from the configuration and Tweedie power, mirroring
2056        // `TweedieRegressor._get_loss` (`glm.py:889-903`): `auto` selects
2057        // identity for `power <= 0` (Normal/OLS) and log for `power > 0`.
2058        // Default (no sample_weight) path: all-ones weights => byte-identical to
2059        // the unweighted fit (`weight_sum = n_samples`).
2060        self.fit_with_sample_weight(x, y, &Array1::ones(x.nrows()))
2061    }
2062}
2063
2064// ---------------------------------------------------------------------------
2065// Predict / HasCoefficients / Pipeline — FittedGLMRegressor
2066// ---------------------------------------------------------------------------
2067
2068impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
2069    for FittedGLMRegressor<F>
2070{
2071    type Output = Array1<F>;
2072    type Error = FerroError;
2073
2074    /// Predict using the fitted GLM.
2075    ///
2076    /// Computes `link.inverse(X @ coefficients + intercept)` (`glm.py:362`):
2077    /// `exp(...)` for a [`Link::Log`] model (Poisson/Gamma/Tweedie with
2078    /// `power > 0`), and the raw linear predictor `X @ coef + intercept` for a
2079    /// [`Link::Identity`] model (Tweedie with `power <= 0`, Normal).
2080    ///
2081    /// # Errors
2082    ///
2083    /// Returns [`FerroError::ShapeMismatch`] if the number of features
2084    /// does not match the fitted model.
2085    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
2086        if x.ncols() != self.coefficients.len() {
2087            return Err(FerroError::ShapeMismatch {
2088                expected: vec![self.coefficients.len()],
2089                actual: vec![x.ncols()],
2090                context: "number of features must match fitted model".into(),
2091            });
2092        }
2093        let eta = x.dot(&self.coefficients) + self.intercept;
2094        let link = self.link;
2095        Ok(eta.mapv(|v| link.inverse(v)))
2096    }
2097}
2098
2099impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
2100    for FittedGLMRegressor<F>
2101{
2102    fn coefficients(&self) -> &Array1<F> {
2103        &self.coefficients
2104    }
2105
2106    fn intercept(&self) -> F {
2107        self.intercept
2108    }
2109}
2110
2111// Pipeline integration for GLMRegressor.
2112impl<F> PipelineEstimator<F> for GLMRegressor<F>
2113where
2114    F: Float + FromPrimitive + ScalarOperand + Send + Sync + 'static,
2115{
2116    fn fit_pipeline(
2117        &self,
2118        x: &Array2<F>,
2119        y: &Array1<F>,
2120    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
2121        let fitted = self.fit(x, y)?;
2122        Ok(Box::new(fitted))
2123    }
2124}
2125
2126impl<F> FittedPipelineEstimator<F> for FittedGLMRegressor<F>
2127where
2128    F: Float + ScalarOperand + Send + Sync + 'static,
2129{
2130    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
2131        self.predict(x)
2132    }
2133}
2134
2135// Pipeline integration for PoissonRegressor.
2136impl<F> PipelineEstimator<F> for PoissonRegressor<F>
2137where
2138    F: Float + FromPrimitive + ScalarOperand + Send + Sync + 'static,
2139{
2140    fn fit_pipeline(
2141        &self,
2142        x: &Array2<F>,
2143        y: &Array1<F>,
2144    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
2145        let fitted = self.fit(x, y)?;
2146        Ok(Box::new(fitted))
2147    }
2148}
2149
2150// Pipeline integration for GammaRegressor.
2151impl<F> PipelineEstimator<F> for GammaRegressor<F>
2152where
2153    F: Float + FromPrimitive + ScalarOperand + Send + Sync + 'static,
2154{
2155    fn fit_pipeline(
2156        &self,
2157        x: &Array2<F>,
2158        y: &Array1<F>,
2159    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
2160        let fitted = self.fit(x, y)?;
2161        Ok(Box::new(fitted))
2162    }
2163}
2164
2165// Pipeline integration for TweedieRegressor.
2166impl<F> PipelineEstimator<F> for TweedieRegressor<F>
2167where
2168    F: Float + FromPrimitive + ScalarOperand + Send + Sync + 'static,
2169{
2170    fn fit_pipeline(
2171        &self,
2172        x: &Array2<F>,
2173        y: &Array1<F>,
2174    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
2175        let fitted = self.fit(x, y)?;
2176        Ok(Box::new(fitted))
2177    }
2178}
2179
2180// ---------------------------------------------------------------------------
2181// Tests
2182// ---------------------------------------------------------------------------
2183
2184#[cfg(test)]
2185mod tests {
2186    use super::*;
2187    use approx::assert_relative_eq;
2188    use ndarray::array;
2189
2190    // ---- GLMRegressor ----
2191
2192    #[test]
2193    fn test_glm_poisson_defaults() {
2194        let m = GLMRegressor::<f64>::new(GLMFamily::Poisson);
2195        assert_relative_eq!(m.alpha, 1.0);
2196        assert_eq!(m.max_iter, 100);
2197        assert!(m.fit_intercept);
2198    }
2199
2200    #[test]
2201    fn test_glm_builder() {
2202        let m = GLMRegressor::<f64>::new(GLMFamily::Gamma)
2203            .with_alpha(0.5)
2204            .with_max_iter(200)
2205            .with_tol(1e-6)
2206            .with_fit_intercept(false);
2207        assert_relative_eq!(m.alpha, 0.5);
2208        assert_eq!(m.max_iter, 200);
2209        assert!(!m.fit_intercept);
2210    }
2211
2212    #[test]
2213    fn test_glm_shape_mismatch() {
2214        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2215        let y = array![1.0, 2.0];
2216        assert!(
2217            GLMRegressor::<f64>::new(GLMFamily::Poisson)
2218                .fit(&x, &y)
2219                .is_err()
2220        );
2221    }
2222
2223    #[test]
2224    fn test_glm_negative_alpha() {
2225        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2226        let y = array![1.0, 2.0, 3.0];
2227        assert!(
2228            GLMRegressor::<f64>::new(GLMFamily::Poisson)
2229                .with_alpha(-1.0)
2230                .fit(&x, &y)
2231                .is_err()
2232        );
2233    }
2234
2235    #[test]
2236    fn test_glm_poisson_fit_predict() {
2237        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2238        let y = array![2.0, 5.0, 10.0, 20.0];
2239
2240        let fitted = GLMRegressor::<f64>::new(GLMFamily::Poisson)
2241            .with_alpha(0.0)
2242            .with_max_iter(200)
2243            .fit(&x, &y)
2244            .unwrap();
2245        let preds = fitted.predict(&x).unwrap();
2246        assert_eq!(preds.len(), 4);
2247        // Predictions should be positive.
2248        for &p in preds.iter() {
2249            assert!(p > 0.0);
2250        }
2251    }
2252
2253    #[test]
2254    fn test_glm_gamma_fit_predict() {
2255        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2256        let y = array![2.0, 5.0, 10.0, 20.0];
2257
2258        let fitted = GLMRegressor::<f64>::new(GLMFamily::Gamma)
2259            .with_alpha(0.0)
2260            .with_max_iter(200)
2261            .fit(&x, &y)
2262            .unwrap();
2263        let preds = fitted.predict(&x).unwrap();
2264        assert_eq!(preds.len(), 4);
2265        for &p in preds.iter() {
2266            assert!(p > 0.0);
2267        }
2268    }
2269
2270    #[test]
2271    fn test_glm_tweedie_fit_predict() {
2272        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2273        let y = array![2.0, 5.0, 10.0, 20.0];
2274
2275        let fitted = GLMRegressor::<f64>::new(GLMFamily::Tweedie(1.5))
2276            .with_alpha(0.0)
2277            .with_max_iter(200)
2278            .fit(&x, &y)
2279            .unwrap();
2280        let preds = fitted.predict(&x).unwrap();
2281        assert_eq!(preds.len(), 4);
2282        for &p in preds.iter() {
2283            assert!(p > 0.0);
2284        }
2285    }
2286
2287    #[test]
2288    fn test_glm_predict_feature_mismatch() {
2289        let x = Array2::from_shape_vec((3, 2), vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0]).unwrap();
2290        let y = array![1.0, 2.0, 3.0];
2291        let fitted = GLMRegressor::<f64>::new(GLMFamily::Poisson)
2292            .fit(&x, &y)
2293            .unwrap();
2294        let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2295        assert!(fitted.predict(&x_bad).is_err());
2296    }
2297
2298    #[test]
2299    fn test_glm_has_coefficients() {
2300        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
2301        let y = array![1.0, 2.0, 3.0];
2302        let fitted = GLMRegressor::<f64>::new(GLMFamily::Poisson)
2303            .fit(&x, &y)
2304            .unwrap();
2305        assert_eq!(fitted.coefficients().len(), 2);
2306    }
2307
2308    #[test]
2309    fn test_glm_pipeline() {
2310        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2311        let y = array![2.0, 5.0, 10.0, 20.0];
2312        let model = GLMRegressor::<f64>::new(GLMFamily::Poisson).with_alpha(0.0);
2313        let fitted = model.fit_pipeline(&x, &y).unwrap();
2314        let preds = fitted.predict_pipeline(&x).unwrap();
2315        assert_eq!(preds.len(), 4);
2316    }
2317
2318    // ---- PoissonRegressor ----
2319
2320    #[test]
2321    fn test_poisson_defaults() {
2322        let m = PoissonRegressor::<f64>::new();
2323        assert_relative_eq!(m.alpha, 1.0);
2324        assert_eq!(m.max_iter, 100);
2325        assert!(m.fit_intercept);
2326    }
2327
2328    #[test]
2329    fn test_poisson_fit_predict() {
2330        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2331        let y = array![2.0, 5.0, 10.0, 20.0];
2332
2333        let fitted = PoissonRegressor::<f64>::new()
2334            .with_alpha(0.0)
2335            .with_max_iter(200)
2336            .fit(&x, &y)
2337            .unwrap();
2338        let preds = fitted.predict(&x).unwrap();
2339        assert_eq!(preds.len(), 4);
2340        for &p in preds.iter() {
2341            assert!(p > 0.0);
2342        }
2343    }
2344
2345    #[test]
2346    fn test_poisson_pipeline() {
2347        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2348        let y = array![2.0, 5.0, 10.0, 20.0];
2349        let fitted = PoissonRegressor::<f64>::new()
2350            .with_alpha(0.0)
2351            .fit_pipeline(&x, &y)
2352            .unwrap();
2353        let preds = fitted.predict_pipeline(&x).unwrap();
2354        assert_eq!(preds.len(), 4);
2355    }
2356
2357    // ---- GammaRegressor ----
2358
2359    #[test]
2360    fn test_gamma_defaults() {
2361        let m = GammaRegressor::<f64>::new();
2362        assert_relative_eq!(m.alpha, 1.0);
2363        assert_eq!(m.max_iter, 100);
2364    }
2365
2366    #[test]
2367    fn test_gamma_fit_predict() {
2368        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2369        let y = array![2.0, 5.0, 10.0, 20.0];
2370
2371        let fitted = GammaRegressor::<f64>::new()
2372            .with_alpha(0.0)
2373            .with_max_iter(200)
2374            .fit(&x, &y)
2375            .unwrap();
2376        let preds = fitted.predict(&x).unwrap();
2377        assert_eq!(preds.len(), 4);
2378        for &p in preds.iter() {
2379            assert!(p > 0.0);
2380        }
2381    }
2382
2383    #[test]
2384    fn test_gamma_pipeline() {
2385        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2386        let y = array![2.0, 5.0, 10.0, 20.0];
2387        let fitted = GammaRegressor::<f64>::new()
2388            .with_alpha(0.0)
2389            .fit_pipeline(&x, &y)
2390            .unwrap();
2391        let preds = fitted.predict_pipeline(&x).unwrap();
2392        assert_eq!(preds.len(), 4);
2393    }
2394
2395    // ---- TweedieRegressor ----
2396
2397    #[test]
2398    fn test_tweedie_defaults() {
2399        let m = TweedieRegressor::<f64>::new();
2400        // sklearn TweedieRegressor default power=0.0 (Normal), link='auto'
2401        // (glm.py:867, :870).
2402        assert_relative_eq!(m.power, 0.0);
2403        assert_eq!(m.link, LinkConfig::Auto);
2404        assert_relative_eq!(m.alpha, 1.0);
2405    }
2406
2407    #[test]
2408    fn test_tweedie_fit_predict() {
2409        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2410        let y = array![2.0, 5.0, 10.0, 20.0];
2411
2412        let fitted = TweedieRegressor::<f64>::new()
2413            .with_power(1.5)
2414            .with_alpha(0.0)
2415            .with_max_iter(200)
2416            .fit(&x, &y)
2417            .unwrap();
2418        let preds = fitted.predict(&x).unwrap();
2419        assert_eq!(preds.len(), 4);
2420        for &p in preds.iter() {
2421            assert!(p > 0.0);
2422        }
2423    }
2424
2425    #[test]
2426    fn test_tweedie_pipeline() {
2427        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2428        let y = array![2.0, 5.0, 10.0, 20.0];
2429        let fitted = TweedieRegressor::<f64>::new()
2430            .with_alpha(0.0)
2431            .fit_pipeline(&x, &y)
2432            .unwrap();
2433        let preds = fitted.predict_pipeline(&x).unwrap();
2434        assert_eq!(preds.len(), 4);
2435    }
2436
2437    // ---- Link ----
2438
2439    #[test]
2440    fn test_link_inverse() {
2441        assert_relative_eq!(Link::Log.inverse(0.0_f64), 1.0);
2442        assert_relative_eq!(Link::Identity.inverse(3.5_f64), 3.5);
2443    }
2444
2445    #[test]
2446    fn test_link_config_resolve_auto() {
2447        // glm.py:889-893: auto -> identity for power<=0, log for power>0.
2448        assert_eq!(LinkConfig::Auto.resolve(0.0), Link::Identity);
2449        assert_eq!(LinkConfig::Auto.resolve(-1.0), Link::Identity);
2450        assert_eq!(LinkConfig::Auto.resolve(1.5), Link::Log);
2451        assert_eq!(LinkConfig::Log.resolve(0.0), Link::Log);
2452        assert_eq!(LinkConfig::Identity.resolve(2.0), Link::Identity);
2453    }
2454
2455    #[test]
2456    fn test_tweedie_with_link_builder() {
2457        let m = TweedieRegressor::<f64>::new().with_link(LinkConfig::Log);
2458        assert_eq!(m.link, LinkConfig::Log);
2459    }
2460
2461    // ---- Solver (sklearn API parity, glm.py:140-145) ----
2462
2463    #[test]
2464    fn test_solver_default_lbfgs() {
2465        // sklearn default solver='lbfgs' (glm.py:155).
2466        assert_eq!(
2467            GLMRegressor::<f64>::new(GLMFamily::Poisson).solver,
2468            Solver::Lbfgs
2469        );
2470        assert_eq!(PoissonRegressor::<f64>::new().solver, Solver::Lbfgs);
2471        assert_eq!(GammaRegressor::<f64>::new().solver, Solver::Lbfgs);
2472        assert_eq!(TweedieRegressor::<f64>::new().solver, Solver::Lbfgs);
2473    }
2474
2475    #[test]
2476    fn test_with_solver_builder() {
2477        assert_eq!(
2478            PoissonRegressor::<f64>::new()
2479                .with_solver(Solver::NewtonCholesky)
2480                .solver,
2481            Solver::NewtonCholesky
2482        );
2483        assert_eq!(
2484            GLMRegressor::<f64>::new(GLMFamily::Gamma)
2485                .with_solver(Solver::NewtonCholesky)
2486                .solver,
2487            Solver::NewtonCholesky
2488        );
2489    }
2490
2491    // ---- Variance function ----
2492
2493    #[test]
2494    fn test_variance_poisson() {
2495        let v = GLMFamily::Poisson.variance(3.0_f64);
2496        assert_relative_eq!(v, 3.0);
2497    }
2498
2499    #[test]
2500    fn test_variance_gamma() {
2501        let v = GLMFamily::Gamma.variance(3.0_f64);
2502        assert_relative_eq!(v, 9.0);
2503    }
2504
2505    #[test]
2506    fn test_variance_tweedie() {
2507        let v = GLMFamily::Tweedie(1.5).variance(4.0_f64);
2508        assert_relative_eq!(v, 4.0_f64.powf(1.5), epsilon = 1e-10);
2509    }
2510
2511    #[test]
2512    fn test_glm_negative_y() {
2513        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2514        let y = array![1.0, -2.0, 3.0];
2515        assert!(
2516            GLMRegressor::<f64>::new(GLMFamily::Poisson)
2517                .fit(&x, &y)
2518                .is_err()
2519        );
2520    }
2521}