regression_diagnostics/logistic/goodness.rs
1use statrs::distribution::{ChiSquared, ContinuousCDF};
2
3use super::LogisticFit;
4
5/// Result of the Hosmer–Lemeshow goodness-of-fit test.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct HosmerLemeshow {
8 /// The Ĥ statistic.
9 pub statistic: f64,
10 /// Degrees of freedom `g − 2` (`g` = number of groups).
11 pub df: usize,
12 /// Upper-tail p-value under χ²(df). A **small** p-value indicates **poor**
13 /// fit (observed and expected event counts disagree across risk groups).
14 pub p_value: f64,
15 /// Number of groups actually used.
16 pub groups: usize,
17}
18
19/// Bundle of overall logistic goodness-of-fit statistics.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct GoodnessOfFit {
22 /// Deviance of the intercept-only (null) model, `−2ℓ₀`.
23 pub null_deviance: f64,
24 /// Residual deviance of the fitted model, `−2ℓ`.
25 pub residual_deviance: f64,
26 /// Degrees of freedom of the null deviance (`n − 1`).
27 pub df_null: f64,
28 /// Degrees of freedom of the residual deviance (`n − p`).
29 pub df_residual: f64,
30 /// McFadden's pseudo-R², `1 − ℓ/ℓ₀`.
31 pub mcfadden_r2: f64,
32 /// Akaike information criterion, `deviance + 2p`.
33 pub aic: f64,
34 /// Bayesian information criterion, `deviance + ln(n)·p`.
35 pub bic: f64,
36}
37
38impl LogisticFit {
39 /// Overall goodness-of-fit summary: null/residual deviance, McFadden's
40 /// pseudo-R², and AIC/BIC.
41 ///
42 /// The **residual deviance** is `−2ℓ` (the saturated-model log-likelihood is
43 /// zero for ungrouped binary data), and equals the sum of squared deviance
44 /// residuals. **McFadden's pseudo-R²** compares the fitted log-likelihood to
45 /// the intercept-only model; it is bounded in `[0, 1)` but runs lower than an
46 /// OLS R² for comparable fits, so judge it on that scale.
47 pub fn goodness_of_fit(&self) -> GoodnessOfFit {
48 let n = self.n_observations() as f64;
49 let p = self.n_parameters() as f64;
50 let y = self.response();
51
52 let residual_deviance = -2.0 * self.log_likelihood();
53
54 // Null model: constant probability = mean(y).
55 let ybar = y.sum() / n;
56 let ll_null: f64 = y
57 .iter()
58 .map(|&yi| yi * ybar.ln() + (1.0 - yi) * (1.0 - ybar).ln())
59 .sum();
60 let null_deviance = -2.0 * ll_null;
61
62 let mcfadden_r2 = if ll_null != 0.0 {
63 1.0 - self.log_likelihood() / ll_null
64 } else {
65 f64::NAN
66 };
67
68 GoodnessOfFit {
69 null_deviance,
70 residual_deviance,
71 df_null: n - 1.0,
72 df_residual: n - p,
73 mcfadden_r2,
74 aic: residual_deviance + 2.0 * p,
75 bic: residual_deviance + n.ln() * p,
76 }
77 }
78
79 /// Hosmer–Lemeshow goodness-of-fit test with `groups` risk deciles
80 /// (`groups = 10` is the customary choice).
81 ///
82 /// Observations are ordered by fitted probability and split into `groups`
83 /// near-equal bins; the statistic compares observed and expected event counts
84 /// per bin,
85 ///
86 /// `Ĥ = Σ_g (O_g − E_g)² / (n_g · π̄_g · (1 − π̄_g))`,
87 ///
88 /// which is asymptotically χ²(groups − 2). Unlike most tests here, a **small
89 /// p-value means the model fits poorly**. Bins whose mean probability is
90 /// exactly 0 or 1 contribute nothing (their variance term is undefined).
91 ///
92 /// The test is only meaningful when `groups ≥ 3` and there are enough
93 /// observations to populate the bins; with fewer than `groups + 1`
94 /// observations the statistic is returned as `NaN`.
95 pub fn hosmer_lemeshow(&self, groups: usize) -> HosmerLemeshow {
96 let n = self.n_observations();
97 let y = self.response();
98 let p = self.fitted_probabilities();
99
100 if groups < 3 || n < groups + 1 {
101 return HosmerLemeshow {
102 statistic: f64::NAN,
103 df: groups.saturating_sub(2),
104 p_value: f64::NAN,
105 groups,
106 };
107 }
108
109 // Order indices by fitted probability.
110 let mut order: Vec<usize> = (0..n).collect();
111 order.sort_by(|&a, &b| p[a].partial_cmp(&p[b]).unwrap_or(std::cmp::Ordering::Equal));
112
113 let mut statistic = 0.0;
114 let mut used_groups = 0usize;
115 for g in 0..groups {
116 let start = g * n / groups;
117 let end = (g + 1) * n / groups;
118 if end <= start {
119 continue;
120 }
121 let ng = (end - start) as f64;
122 let mut observed = 0.0;
123 let mut expected = 0.0;
124 for &idx in &order[start..end] {
125 observed += y[idx];
126 expected += p[idx];
127 }
128 let pbar = expected / ng;
129 if pbar <= 0.0 || pbar >= 1.0 {
130 continue;
131 }
132 let diff = observed - expected;
133 statistic += diff * diff / (ng * pbar * (1.0 - pbar));
134 used_groups += 1;
135 }
136
137 let df = used_groups.saturating_sub(2);
138 let p_value = match ChiSquared::new(df as f64) {
139 Ok(dist) if df >= 1 && statistic.is_finite() => 1.0 - dist.cdf(statistic),
140 _ => f64::NAN,
141 };
142
143 HosmerLemeshow {
144 statistic,
145 df,
146 p_value,
147 groups: used_groups,
148 }
149 }
150}