Skip to main content

regression_diagnostics/glm/
fit.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2use statrs::distribution::{ContinuousCDF, Normal, StudentsT};
3
4use super::family::Family;
5use crate::error::{RegressionError, Result};
6use crate::linalg::dmatrix_from_rows;
7
8/// A generalized linear model fit by **iteratively reweighted least squares**
9/// (Fisher scoring) for an exponential-dispersion [`Family`].
10///
11/// One solver serves every family. Each IRLS step forms the working response
12/// `z = η + (y − μ)/(dμ/dη)` and working weights `w = (dμ/dη)² / V(μ)`, then
13/// solves the weighted normal equations `(XᵀWX) β = XᵀWz`. At convergence the
14/// score equations `Xᵀ(y − μ) · (dμ/dη)/V = 0` hold and `φ·(XᵀWX)⁻¹` is the
15/// coefficient covariance, with `φ = 1` for the fixed-dispersion families
16/// (Poisson, negative binomial) and the Pearson estimate `φ̂ = χ²/(n − p)` for
17/// Gamma.
18///
19/// See the [module docs](crate::glm) for the response conventions and the link
20/// choice.
21#[derive(Debug, Clone)]
22pub struct GlmFit<F: Family> {
23    family: F,
24    x: Array2<f64>,
25    y: Array1<f64>,
26    coefficients: Array1<f64>,
27    /// Linear predictor `η = Xβ`.
28    eta: Array1<f64>,
29    /// Fitted means `μ = g⁻¹(η)`.
30    mu: Array1<f64>,
31    /// IRLS working weights `wᵢ = (dμ/dη)² / V(μ)` at the MLE.
32    weights: Array1<f64>,
33    /// Unscaled inverse Fisher information `(XᵀWX)⁻¹` (no dispersion factor).
34    cov_unscaled: Array2<f64>,
35    /// Dispersion `φ` (`1` when known, else the Pearson estimate).
36    dispersion: f64,
37    log_likelihood: f64,
38    intercept_col: Option<usize>,
39    iterations: usize,
40    n: usize,
41    p: usize,
42}
43
44impl<F: Family> GlmFit<F> {
45    /// Fit `y ~ X` under `family` (default: up to 100 IRLS iterations, tolerance
46    /// `1e-10` on the maximum coefficient step).
47    ///
48    /// The caller owns the design matrix, including any intercept column, exactly
49    /// as with [`OlsFit`](crate::OlsFit) and [`LogisticFit`](crate::logistic::LogisticFit).
50    ///
51    /// # Errors
52    ///
53    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
54    /// * [`RegressionError::InvalidResponse`] if `y` is outside the family's
55    ///   support (negative counts, non-positive Gamma responses).
56    /// * [`RegressionError::RankDeficient`] if the weighted design is singular.
57    /// * [`RegressionError::NotConverged`] if IRLS fails to converge.
58    pub fn new(family: F, x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
59        Self::with_options(family, x, y, 100, 1e-10)
60    }
61
62    /// Like [`GlmFit::new`] with an explicit iteration cap and step tolerance.
63    pub fn with_options(
64        family: F,
65        x: Array2<f64>,
66        y: Array1<f64>,
67        max_iter: usize,
68        tol: f64,
69    ) -> Result<Self> {
70        let n = x.nrows();
71        let p = x.ncols();
72        if n == 0 || p == 0 {
73            return Err(RegressionError::EmptyInput { what: "X" });
74        }
75        if y.len() != n {
76            return Err(RegressionError::ShapeMismatch {
77                what: "y length vs X rows",
78                expected: n,
79                got: y.len(),
80            });
81        }
82        let y_std = y.as_standard_layout();
83        family.validate(y_std.as_slice().expect("standard layout is contiguous"))?;
84
85        let intercept_col = detect_constant_column(&x);
86
87        // Initialize the linear predictor from a smoothed mean, the standard GLM
88        // warm start (η⁰ = g(μ⁰)); β is recovered on the first weighted solve.
89        let mut eta = Array1::from_shape_fn(n, |i| family.link(family.init_mu(y[i])));
90        let mut beta = Array1::<f64>::zeros(p);
91        let mut mu = Array1::<f64>::zeros(n);
92        let mut weights = Array1::<f64>::zeros(n);
93        let mut cov_unscaled = Array2::<f64>::zeros((p, p));
94        let mut iterations = 0usize;
95        let mut converged = false;
96
97        while iterations < max_iter {
98            iterations += 1;
99
100            // Working response z and weights w from the current η.
101            let mut z = Array1::<f64>::zeros(n);
102            for i in 0..n {
103                let mui = family.inverse_link(eta[i]);
104                let dmu = family.dmu_deta(eta[i]);
105                let v = family.variance(mui);
106                mu[i] = mui;
107                weights[i] = if v > 0.0 { dmu * dmu / v } else { 0.0 };
108                z[i] = eta[i] + (y[i] - mui) / dmu;
109            }
110
111            // Weighted normal equations: (XᵀWX) β = XᵀWz.
112            let mut xtwx = Array2::<f64>::zeros((p, p));
113            let mut xtwz = Array1::<f64>::zeros(p);
114            for a in 0..p {
115                let mut sz = 0.0;
116                for i in 0..n {
117                    sz += x[(i, a)] * weights[i] * z[i];
118                }
119                xtwz[a] = sz;
120                for b in a..p {
121                    let mut s = 0.0;
122                    for i in 0..n {
123                        s += x[(i, a)] * weights[i] * x[(i, b)];
124                    }
125                    xtwx[(a, b)] = s;
126                    xtwx[(b, a)] = s;
127                }
128            }
129
130            let xtwx_dm = dmatrix_from_rows(p, p, xtwx.as_standard_layout().as_slice().unwrap());
131            let inv = xtwx_dm
132                .try_inverse()
133                .ok_or(RegressionError::RankDeficient)?;
134            let inv_arr = Array2::from_shape_fn((p, p), |(i, j)| inv[(i, j)]);
135
136            let new_beta = inv_arr.dot(&xtwz);
137            let step = (&new_beta - &beta)
138                .iter()
139                .fold(0.0_f64, |m, v| m.max(v.abs()));
140            beta = new_beta;
141            cov_unscaled = inv_arr;
142            eta = x.dot(&beta);
143
144            if !beta.iter().all(|v| v.is_finite()) || beta.iter().any(|v| v.abs() > 1e8) {
145                return Err(RegressionError::NotConverged {
146                    iterations,
147                    msg: "coefficients diverging (perfect fit or unstable link)".into(),
148                });
149            }
150            if step < tol {
151                converged = true;
152                break;
153            }
154        }
155
156        if !converged {
157            return Err(RegressionError::NotConverged {
158                iterations,
159                msg: "IRLS did not reach tolerance".into(),
160            });
161        }
162
163        // Final means, weights, dispersion, and log-likelihood at the MLE.
164        for i in 0..n {
165            let mui = family.inverse_link(eta[i]);
166            let dmu = family.dmu_deta(eta[i]);
167            let v = family.variance(mui);
168            mu[i] = mui;
169            weights[i] = if v > 0.0 { dmu * dmu / v } else { 0.0 };
170        }
171
172        let dispersion = if family.dispersion_known() {
173            1.0
174        } else if n > p {
175            // Pearson estimate φ̂ = Σ (yᵢ − μᵢ)²/V(μᵢ) / (n − p).
176            let pearson_chi2: f64 = (0..n)
177                .map(|i| {
178                    let v = family.variance(mu[i]);
179                    if v > 0.0 {
180                        let r = y[i] - mu[i];
181                        r * r / v
182                    } else {
183                        0.0
184                    }
185                })
186                .sum();
187            pearson_chi2 / (n - p) as f64
188        } else {
189            f64::NAN
190        };
191
192        let log_likelihood = (0..n)
193            .map(|i| family.loglik(y[i], mu[i], dispersion))
194            .sum();
195
196        Ok(Self {
197            family,
198            x,
199            y,
200            coefficients: beta,
201            eta,
202            mu,
203            weights,
204            cov_unscaled,
205            dispersion,
206            log_likelihood,
207            intercept_col,
208            iterations,
209            n,
210            p,
211        })
212    }
213
214    /// The family this model was fit under.
215    pub fn family(&self) -> &F {
216        &self.family
217    }
218
219    /// Number of observations.
220    pub fn n_observations(&self) -> usize {
221        self.n
222    }
223
224    /// Number of coefficients (design columns, intercept included).
225    pub fn n_parameters(&self) -> usize {
226        self.p
227    }
228
229    /// Whether a constant (intercept) column was detected in the design.
230    pub fn has_intercept(&self) -> bool {
231        self.intercept_col.is_some()
232    }
233
234    /// IRLS iterations taken to converge.
235    pub fn iterations(&self) -> usize {
236        self.iterations
237    }
238
239    /// The design matrix as fitted.
240    pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
241        self.x.view()
242    }
243
244    /// The response.
245    pub fn response(&self) -> ArrayView1<'_, f64> {
246        self.y.view()
247    }
248
249    /// Estimated coefficients (link scale), aligned to the design columns.
250    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
251        self.coefficients.view()
252    }
253
254    /// Linear predictor `η = Xβ`.
255    pub fn linear_predictor(&self) -> ArrayView1<'_, f64> {
256        self.eta.view()
257    }
258
259    /// Fitted means `μᵢ = g⁻¹(ηᵢ)`.
260    pub fn fitted_means(&self) -> ArrayView1<'_, f64> {
261        self.mu.view()
262    }
263
264    /// IRLS working weights `wᵢ = (dμ/dη)² / V(μ)` at the MLE.
265    pub fn weights(&self) -> ArrayView1<'_, f64> {
266        self.weights.view()
267    }
268
269    /// Estimated dispersion `φ`: `1` for Poisson/negative binomial, the Pearson
270    /// estimate `χ²/(n − p)` for Gamma.
271    pub fn dispersion(&self) -> f64 {
272        self.dispersion
273    }
274
275    /// Maximized log-likelihood (evaluated at the estimated dispersion for
276    /// Gamma).
277    pub fn log_likelihood(&self) -> f64 {
278        self.log_likelihood
279    }
280
281    /// Coefficient covariance `φ · (XᵀWX)⁻¹`.
282    pub fn covariance(&self) -> Array2<f64> {
283        &self.cov_unscaled * self.dispersion
284    }
285
286    /// The unscaled inverse Fisher information `(XᵀWX)⁻¹` (no dispersion factor);
287    /// this is what the leverage / hat-matrix computation uses.
288    pub(crate) fn cov_unscaled(&self) -> ArrayView2<'_, f64> {
289        self.cov_unscaled.view()
290    }
291
292    /// Coefficient standard errors `√diag(φ · (XᵀWX)⁻¹)`.
293    pub fn coefficient_standard_errors(&self) -> Array1<f64> {
294        Array1::from_shape_fn(self.p, |j| {
295            (self.cov_unscaled[(j, j)] * self.dispersion).max(0.0).sqrt()
296        })
297    }
298
299    /// Wald statistics `βⱼ / seⱼ`.
300    ///
301    /// The reference distribution is the standard normal when the dispersion is
302    /// known (Poisson, negative binomial) and Student's *t* with `n − p` degrees
303    /// of freedom when it is estimated (Gamma) — see [`GlmFit::p_values`].
304    pub fn wald_statistics(&self) -> Array1<f64> {
305        let se = self.coefficient_standard_errors();
306        Array1::from_shape_fn(self.p, |j| {
307            if se[j] > 0.0 {
308                self.coefficients[j] / se[j]
309            } else {
310                f64::NAN
311            }
312        })
313    }
314
315    /// Two-sided Wald p-values.
316    ///
317    /// Uses the standard normal for fixed-dispersion families and Student's *t*
318    /// with `n − p` degrees of freedom when the dispersion is estimated (the R
319    /// `glm()` convention).
320    pub fn p_values(&self) -> Array1<f64> {
321        let stat = self.wald_statistics();
322        if self.family.dispersion_known() {
323            let normal = Normal::new(0.0, 1.0).expect("standard normal");
324            Array1::from_shape_fn(self.p, |j| {
325                if stat[j].is_finite() {
326                    2.0 * (1.0 - normal.cdf(stat[j].abs()))
327                } else {
328                    f64::NAN
329                }
330            })
331        } else {
332            let df = (self.n.saturating_sub(self.p)) as f64;
333            let t = StudentsT::new(0.0, 1.0, df.max(1.0)).expect("t distribution");
334            Array1::from_shape_fn(self.p, |j| {
335                if stat[j].is_finite() {
336                    2.0 * (1.0 - t.cdf(stat[j].abs()))
337                } else {
338                    f64::NAN
339                }
340            })
341        }
342    }
343
344    /// Predicted means for a new design matrix `x` (same column layout as the
345    /// training design): `μ = g⁻¹(xβ)`.
346    pub fn predict_mean(&self, x: ArrayView2<'_, f64>) -> Array1<f64> {
347        x.dot(&self.coefficients)
348            .mapv(|eta| self.family.inverse_link(eta))
349    }
350}
351
352/// Detect the first constant column (treated as the intercept). Identical
353/// convention to the logistic and OLS fits.
354fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
355    for (j, col) in x.columns().into_iter().enumerate() {
356        let first = col[0];
357        let scale = first.abs().max(1.0);
358        if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
359            return Some(j);
360        }
361    }
362    None
363}