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}