ngboost_rs/dist/
halfnormal.rs

1use crate::dist::{Distribution, DistributionMethods, RegressionDistn};
2use crate::scores::{CRPScore, LogScore, Scorable};
3use ndarray::{array, Array1, Array2, Array3};
4use rand::prelude::*;
5use statrs::distribution::{Continuous, ContinuousCDF, Normal as NormalDist};
6
7/// The Half-Normal distribution.
8///
9/// The Half-Normal distribution is a Normal distribution folded at zero,
10/// with loc fixed at 0. It has one parameter: scale (sigma).
11#[derive(Debug, Clone)]
12pub struct HalfNormal {
13    /// The scale parameter (sigma).
14    pub scale: Array1<f64>,
15    /// The parameters of the distribution, stored as a 2D array.
16    _params: Array2<f64>,
17}
18
19impl Distribution for HalfNormal {
20    fn from_params(params: &Array2<f64>) -> Self {
21        let scale = params.column(0).mapv(f64::exp);
22        HalfNormal {
23            scale,
24            _params: params.clone(),
25        }
26    }
27
28    fn fit(y: &Array1<f64>) -> Array1<f64> {
29        // For half-normal, MLE for scale is sqrt(mean(y^2))
30        // Since E[X^2] = sigma^2 for half-normal
31        let n = y.len();
32        if n == 0 {
33            return array![0.0];
34        }
35
36        let sum_sq: f64 = y.iter().map(|&x| x * x).sum();
37        let scale = (sum_sq / n as f64).sqrt().max(1e-6);
38
39        array![scale.ln()]
40    }
41
42    fn n_params(&self) -> usize {
43        1
44    }
45
46    fn predict(&self) -> Array1<f64> {
47        // Mean of half-normal is scale * sqrt(2/pi)
48        let sqrt_2_over_pi = (2.0 / std::f64::consts::PI).sqrt();
49        &self.scale * sqrt_2_over_pi
50    }
51
52    fn params(&self) -> &Array2<f64> {
53        &self._params
54    }
55}
56
57impl RegressionDistn for HalfNormal {}
58
59impl DistributionMethods for HalfNormal {
60    fn mean(&self) -> Array1<f64> {
61        // Mean of half-normal is scale * sqrt(2/pi)
62        let sqrt_2_over_pi = (2.0 / std::f64::consts::PI).sqrt();
63        &self.scale * sqrt_2_over_pi
64    }
65
66    fn variance(&self) -> Array1<f64> {
67        // Variance of half-normal is scale^2 * (1 - 2/pi)
68        let one_minus_2_over_pi = 1.0 - 2.0 / std::f64::consts::PI;
69        &self.scale * &self.scale * one_minus_2_over_pi
70    }
71
72    fn std(&self) -> Array1<f64> {
73        self.variance().mapv(f64::sqrt)
74    }
75
76    fn pdf(&self, y: &Array1<f64>) -> Array1<f64> {
77        // pdf(y) = sqrt(2/pi) / scale * exp(-y^2 / (2 * scale^2)) for y >= 0
78        let sqrt_2_over_pi = (2.0 / std::f64::consts::PI).sqrt();
79        let mut result = Array1::zeros(y.len());
80
81        for i in 0..y.len() {
82            if y[i] >= 0.0 {
83                let scale_sq = self.scale[i] * self.scale[i];
84                result[i] =
85                    sqrt_2_over_pi / self.scale[i] * (-y[i] * y[i] / (2.0 * scale_sq)).exp();
86            }
87        }
88        result
89    }
90
91    fn logpdf(&self, y: &Array1<f64>) -> Array1<f64> {
92        // logpdf = log(sqrt(2/pi)) - log(scale) - y^2 / (2 * scale^2)
93        let log_sqrt_2_over_pi = (2.0 / std::f64::consts::PI).sqrt().ln();
94        let mut result = Array1::zeros(y.len());
95
96        for i in 0..y.len() {
97            if y[i] >= 0.0 {
98                let scale_sq = self.scale[i] * self.scale[i];
99                result[i] =
100                    log_sqrt_2_over_pi - self.scale[i].ln() - (y[i] * y[i]) / (2.0 * scale_sq);
101            } else {
102                result[i] = f64::NEG_INFINITY;
103            }
104        }
105        result
106    }
107
108    fn cdf(&self, y: &Array1<f64>) -> Array1<f64> {
109        // CDF of half-normal: 2 * Phi(y/scale) - 1 for y >= 0
110        // where Phi is the standard normal CDF
111        let std_normal = NormalDist::new(0.0, 1.0).unwrap();
112        let mut result = Array1::zeros(y.len());
113
114        for i in 0..y.len() {
115            if y[i] >= 0.0 {
116                result[i] = 2.0 * std_normal.cdf(y[i] / self.scale[i]) - 1.0;
117            }
118        }
119        result
120    }
121
122    fn ppf(&self, q: &Array1<f64>) -> Array1<f64> {
123        // Inverse CDF: scale * Phi^{-1}((1 + q) / 2)
124        let std_normal = NormalDist::new(0.0, 1.0).unwrap();
125        let mut result = Array1::zeros(q.len());
126
127        for i in 0..q.len() {
128            let q_clamped = q[i].clamp(0.0, 1.0 - 1e-15);
129            result[i] = self.scale[i] * std_normal.inverse_cdf((1.0 + q_clamped) / 2.0);
130        }
131        result
132    }
133
134    fn sample(&self, n_samples: usize) -> Array2<f64> {
135        let n_obs = self.scale.len();
136        let mut samples = Array2::zeros((n_samples, n_obs));
137        let mut rng = rand::rng();
138
139        for i in 0..n_obs {
140            let std_normal = NormalDist::new(0.0, 1.0).unwrap();
141            for s in 0..n_samples {
142                // Sample from normal and take absolute value
143                let u: f64 = rng.random();
144                let z = std_normal.inverse_cdf(u);
145                samples[[s, i]] = self.scale[i] * z.abs();
146            }
147        }
148        samples
149    }
150
151    fn median(&self) -> Array1<f64> {
152        // Median = scale * Phi^{-1}(0.75) ≈ scale * 0.6745
153        let std_normal = NormalDist::new(0.0, 1.0).unwrap();
154        let median_factor = std_normal.inverse_cdf(0.75);
155        &self.scale * median_factor
156    }
157
158    fn mode(&self) -> Array1<f64> {
159        // Mode of half-normal is 0
160        Array1::zeros(self.scale.len())
161    }
162}
163
164impl Scorable<LogScore> for HalfNormal {
165    fn score(&self, y: &Array1<f64>) -> Array1<f64> {
166        // -logpdf(y) for half-normal
167        // logpdf = log(sqrt(2/pi)) - log(scale) - y^2 / (2 * scale^2)
168        // -logpdf = -log(sqrt(2/pi)) + log(scale) + y^2 / (2 * scale^2)
169        let log_sqrt_2_over_pi = (2.0 / std::f64::consts::PI).sqrt().ln();
170        let mut scores = Array1::zeros(y.len());
171
172        for i in 0..y.len() {
173            let scale_sq = self.scale[i] * self.scale[i];
174            scores[i] = -log_sqrt_2_over_pi + self.scale[i].ln() + (y[i] * y[i]) / (2.0 * scale_sq);
175        }
176        scores
177    }
178
179    fn d_score(&self, y: &Array1<f64>) -> Array2<f64> {
180        let n_obs = y.len();
181        let mut d_params = Array2::zeros((n_obs, 1));
182
183        for i in 0..n_obs {
184            let scale_sq = self.scale[i] * self.scale[i];
185            // d/d(log(scale)) = scale * d/d(scale)
186            // d(-logpdf)/d(scale) = 1/scale - y^2/scale^3
187            // d(-logpdf)/d(log(scale)) = 1 - y^2/scale^2
188            d_params[[i, 0]] = 1.0 - (y[i] * y[i]) / scale_sq;
189        }
190
191        d_params
192    }
193
194    fn metric(&self) -> Array3<f64> {
195        // Fisher Information for half-normal is 2 (constant)
196        let n_obs = self.scale.len();
197        let mut fi = Array3::zeros((n_obs, 1, 1));
198
199        for i in 0..n_obs {
200            fi[[i, 0, 0]] = 2.0;
201        }
202
203        fi
204    }
205}
206
207impl Scorable<CRPScore> for HalfNormal {
208    fn score(&self, y: &Array1<f64>) -> Array1<f64> {
209        // CRPS for Half-Normal distribution
210        // For a half-normal with scale σ and observation y >= 0:
211        // CRPS = σ * [z * (2*Φ(z) - 1) + 2*φ(z) - (1/√π) * (2*Φ(z*√2) - 1)]
212        // where z = y/σ, Φ is standard normal CDF, φ is standard normal PDF
213        //
214        // Simplified form:
215        // CRPS = σ * [z * erf(z/√2) + √(2/π) * (exp(-z²/2) - 1) + z]
216        //      = y * erf(y/(σ*√2)) + σ * √(2/π) * (exp(-y²/(2σ²)) - 1) + y
217        //
218        // Alternative form using standard normal:
219        // CRPS = σ * [z*(2Φ(z)-1) + 2φ(z) - 1/√π]   for y >= 0
220
221        let std_normal = NormalDist::new(0.0, 1.0).unwrap();
222        let sqrt_pi = std::f64::consts::PI.sqrt();
223        let sqrt_2 = std::f64::consts::SQRT_2;
224
225        let mut scores = Array1::zeros(y.len());
226        for i in 0..y.len() {
227            let y_i = y[i].max(0.0); // Half-normal only supports y >= 0
228            let sigma = self.scale[i];
229            let z = y_i / sigma;
230
231            // CRPS for folded/half-normal:
232            // CRPS = σ * [z*(2Φ(z) - 1) + 2φ(z)] - σ/√π * (2Φ(z√2) - 1) - σ/√π
233            // Simplified: CRPS = σ * [z*(2Φ(z)-1) + 2φ(z) - (2Φ(z√2)-1)/√π - 1/√π]
234
235            let phi_z = std_normal.pdf(z);
236            let big_phi_z = std_normal.cdf(z);
237            let big_phi_z_sqrt2 = std_normal.cdf(z * sqrt_2);
238
239            // CRPS for half-normal
240            scores[i] = sigma
241                * (z * (2.0 * big_phi_z - 1.0) + 2.0 * phi_z
242                    - (2.0 * big_phi_z_sqrt2 - 1.0) / sqrt_pi
243                    - 1.0 / sqrt_pi);
244        }
245        scores
246    }
247
248    fn d_score(&self, y: &Array1<f64>) -> Array2<f64> {
249        // Gradient of CRPS w.r.t. log(scale)
250        let std_normal = NormalDist::new(0.0, 1.0).unwrap();
251        let sqrt_pi = std::f64::consts::PI.sqrt();
252        let sqrt_2 = std::f64::consts::SQRT_2;
253
254        let n_obs = y.len();
255        let mut d_params = Array2::zeros((n_obs, 1));
256
257        for i in 0..n_obs {
258            let y_i = y[i].max(0.0);
259            let sigma = self.scale[i];
260            let z = y_i / sigma;
261
262            let phi_z = std_normal.pdf(z);
263            let big_phi_z = std_normal.cdf(z);
264            let phi_z_sqrt2 = std_normal.pdf(z * sqrt_2);
265            let big_phi_z_sqrt2 = std_normal.cdf(z * sqrt_2);
266
267            // d(CRPS)/d(σ) then multiply by σ to get d(CRPS)/d(log(σ))
268            // Let S = CRPS/σ = z*(2Φ(z)-1) + 2φ(z) - (2Φ(z√2)-1)/√π - 1/√π
269            // d(CRPS)/d(σ) = S + σ * dS/dσ
270            // dz/dσ = -z/σ
271            // dS/dσ = dS/dz * dz/dσ = -z/σ * dS/dz
272
273            // dS/dz = (2Φ(z)-1) + z*2φ(z) + 2*(-z*φ(z)) - 2*√2*φ(z√2)/√π
274            //       = (2Φ(z)-1) - 2√2*φ(z√2)/√π
275            let ds_dz = 2.0 * big_phi_z - 1.0 - 2.0 * sqrt_2 * phi_z_sqrt2 / sqrt_pi;
276
277            let s = z * (2.0 * big_phi_z - 1.0) + 2.0 * phi_z
278                - (2.0 * big_phi_z_sqrt2 - 1.0) / sqrt_pi
279                - 1.0 / sqrt_pi;
280
281            // d(CRPS)/d(log(σ)) = σ * d(CRPS)/d(σ) = σ * (S - z * dS/dz)
282            d_params[[i, 0]] = sigma * (s - z * ds_dz);
283        }
284
285        d_params
286    }
287
288    fn metric(&self) -> Array3<f64> {
289        // Fisher Information matrix for CRPS
290        // For half-normal, this is approximately constant
291        let n_obs = self.scale.len();
292        let mut fi = Array3::zeros((n_obs, 1, 1));
293
294        // The metric is E[∇S ∇S^T] which for half-normal CRPS
295        // is approximately 2/(π) based on the variance structure
296        let metric_val = 2.0 / std::f64::consts::PI;
297
298        for i in 0..n_obs {
299            fi[[i, 0, 0]] = metric_val;
300        }
301
302        fi
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use approx::assert_relative_eq;
310
311    #[test]
312    fn test_halfnormal_distribution_methods() {
313        // First obs: scale=1 (log(scale)=0)
314        // Second obs: scale=2 (log(scale)=ln(2))
315        let params = Array2::from_shape_vec((2, 1), vec![0.0, 2.0_f64.ln()]).unwrap();
316        let dist = HalfNormal::from_params(&params);
317
318        // Test mean: scale * sqrt(2/pi)
319        let mean = dist.mean();
320        let sqrt_2_over_pi = (2.0 / std::f64::consts::PI).sqrt();
321        assert_relative_eq!(mean[0], sqrt_2_over_pi, epsilon = 1e-10);
322        assert_relative_eq!(mean[1], 2.0 * sqrt_2_over_pi, epsilon = 1e-10);
323
324        // Test variance: scale^2 * (1 - 2/pi)
325        let var = dist.variance();
326        let one_minus_2_over_pi = 1.0 - 2.0 / std::f64::consts::PI;
327        assert_relative_eq!(var[0], one_minus_2_over_pi, epsilon = 1e-10);
328        assert_relative_eq!(var[1], 4.0 * one_minus_2_over_pi, epsilon = 1e-10);
329
330        // Test mode is 0
331        let mode = dist.mode();
332        assert_relative_eq!(mode[0], 0.0, epsilon = 1e-10);
333        assert_relative_eq!(mode[1], 0.0, epsilon = 1e-10);
334    }
335
336    #[test]
337    fn test_halfnormal_cdf_ppf() {
338        // Create 3 observations for ppf inverse test
339        let params = Array2::from_shape_vec((3, 1), vec![0.0, 0.0, 0.0]).unwrap();
340        let dist = HalfNormal::from_params(&params);
341
342        // CDF at 0 should be 0
343        let y = Array1::from_vec(vec![0.0, 0.0, 0.0]);
344        let cdf = dist.cdf(&y);
345        assert_relative_eq!(cdf[0], 0.0, epsilon = 1e-10);
346
347        // PPF at 0 should be 0
348        let q = Array1::from_vec(vec![0.0, 0.0, 0.0]);
349        let ppf = dist.ppf(&q);
350        assert_relative_eq!(ppf[0], 0.0, epsilon = 1e-10);
351
352        // PPF inverse test
353        let q = Array1::from_vec(vec![0.25, 0.5, 0.75]);
354        let ppf = dist.ppf(&q);
355        let cdf_of_ppf = dist.cdf(&ppf);
356        assert_relative_eq!(cdf_of_ppf[0], 0.25, epsilon = 1e-6);
357        assert_relative_eq!(cdf_of_ppf[1], 0.5, epsilon = 1e-6);
358        assert_relative_eq!(cdf_of_ppf[2], 0.75, epsilon = 1e-6);
359    }
360
361    #[test]
362    fn test_halfnormal_pdf() {
363        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
364        let dist = HalfNormal::from_params(&params);
365
366        // PDF at 0 should be sqrt(2/pi) for scale=1
367        let y = Array1::from_vec(vec![0.0]);
368        let pdf = dist.pdf(&y);
369        let sqrt_2_over_pi = (2.0 / std::f64::consts::PI).sqrt();
370        assert_relative_eq!(pdf[0], sqrt_2_over_pi, epsilon = 1e-10);
371
372        // PDF should be 0 for negative values
373        let y_neg = Array1::from_vec(vec![-1.0]);
374        let pdf_neg = dist.pdf(&y_neg);
375        assert_relative_eq!(pdf_neg[0], 0.0, epsilon = 1e-10);
376    }
377
378    #[test]
379    fn test_halfnormal_sample() {
380        // scale = 2 (log(scale) = ln(2))
381        let params = Array2::from_shape_vec((1, 1), vec![2.0_f64.ln()]).unwrap();
382        let dist = HalfNormal::from_params(&params);
383
384        let samples = dist.sample(1000);
385        assert_eq!(samples.shape(), &[1000, 1]);
386
387        // All samples should be non-negative
388        assert!(samples.iter().all(|&x| x >= 0.0));
389
390        // Check that sample mean is close to scale * sqrt(2/pi) = 2 * sqrt(2/pi)
391        let sample_mean: f64 = samples.column(0).iter().sum::<f64>() / samples.nrows() as f64;
392        let expected_mean = 2.0 * (2.0 / std::f64::consts::PI).sqrt();
393        assert!((sample_mean - expected_mean).abs() / expected_mean < 0.15);
394    }
395
396    #[test]
397    fn test_halfnormal_fit() {
398        let y = Array1::from_vec(vec![0.5, 1.0, 1.5, 2.0, 2.5]);
399        let params = HalfNormal::fit(&y);
400        assert_eq!(params.len(), 1);
401        // log(scale) should be based on sqrt(mean(y^2))
402    }
403
404    #[test]
405    fn test_halfnormal_logscore() {
406        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
407        let dist = HalfNormal::from_params(&params);
408
409        let y = Array1::from_vec(vec![1.0]);
410        let score = Scorable::<LogScore>::score(&dist, &y);
411
412        // Score should be finite and positive
413        assert!(score[0].is_finite());
414        assert!(score[0] > 0.0);
415    }
416
417    #[test]
418    fn test_halfnormal_d_score() {
419        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
420        let dist = HalfNormal::from_params(&params);
421
422        let y = Array1::from_vec(vec![1.0]);
423        let d_score = Scorable::<LogScore>::d_score(&dist, &y);
424
425        // Gradient should be finite
426        assert!(d_score[[0, 0]].is_finite());
427    }
428
429    #[test]
430    fn test_halfnormal_median() {
431        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
432        let dist = HalfNormal::from_params(&params);
433
434        // Median ≈ 0.6745 for scale=1
435        let median = dist.median();
436        assert_relative_eq!(median[0], 0.6744897501960817, epsilon = 1e-6);
437    }
438
439    #[test]
440    fn test_halfnormal_survival_function() {
441        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
442        let dist = HalfNormal::from_params(&params);
443
444        // SF at 0 should be 1
445        let y = Array1::from_vec(vec![0.0]);
446        let sf = dist.sf(&y);
447        assert_relative_eq!(sf[0], 1.0, epsilon = 1e-10);
448
449        // SF + CDF should equal 1
450        let y = Array1::from_vec(vec![1.0]);
451        let sf = dist.sf(&y);
452        let cdf = dist.cdf(&y);
453        assert_relative_eq!(sf[0] + cdf[0], 1.0, epsilon = 1e-10);
454    }
455
456    #[test]
457    fn test_halfnormal_crpscore() {
458        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap(); // scale = 1
459        let dist = HalfNormal::from_params(&params);
460
461        let y = Array1::from_vec(vec![1.0]);
462        let score = Scorable::<CRPScore>::score(&dist, &y);
463
464        // CRPS should be finite and non-negative
465        assert!(score[0].is_finite());
466        assert!(score[0] >= 0.0);
467
468        // CRPS at y=0 should be σ * (2φ(0) - 1/√π - 1/√π) = σ * (2/√(2π) - 2/√π)
469        let y_zero = Array1::from_vec(vec![0.0]);
470        let score_zero = Scorable::<CRPScore>::score(&dist, &y_zero);
471        assert!(score_zero[0].is_finite());
472        assert!(score_zero[0] >= 0.0);
473    }
474
475    #[test]
476    fn test_halfnormal_crpscore_d_score() {
477        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
478        let dist = HalfNormal::from_params(&params);
479
480        let y = Array1::from_vec(vec![1.0]);
481        let d_score = Scorable::<CRPScore>::d_score(&dist, &y);
482
483        // Gradient should be finite
484        assert!(d_score[[0, 0]].is_finite());
485    }
486
487    #[test]
488    fn test_halfnormal_crpscore_metric() {
489        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
490        let dist = HalfNormal::from_params(&params);
491
492        let metric = Scorable::<CRPScore>::metric(&dist);
493
494        // Metric should be positive definite
495        assert!(metric[[0, 0, 0]] > 0.0);
496    }
497
498    #[test]
499    fn test_halfnormal_crpscore_scaling() {
500        // CRPS should scale linearly with sigma
501        let params1 = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap(); // scale = 1
502        let params2 = Array2::from_shape_vec((1, 1), vec![2.0_f64.ln()]).unwrap(); // scale = 2
503        let dist1 = HalfNormal::from_params(&params1);
504        let dist2 = HalfNormal::from_params(&params2);
505
506        // For y=0, CRPS(σ) = σ * constant, so CRPS(2σ) / CRPS(σ) ≈ 2
507        let y = Array1::from_vec(vec![0.0]);
508        let score1 = Scorable::<CRPScore>::score(&dist1, &y);
509        let score2 = Scorable::<CRPScore>::score(&dist2, &y);
510
511        assert_relative_eq!(score2[0] / score1[0], 2.0, epsilon = 1e-10);
512    }
513}