ngboost_rs/dist/
studentt.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, StudentsT as StudentsTDist};
6use statrs::function::beta::ln_beta;
7use statrs::function::gamma::digamma;
8
9/// The Student's T distribution with learnable degrees of freedom.
10///
11/// Has three parameters: loc (mean), log(scale), and log(df).
12#[derive(Debug, Clone)]
13pub struct StudentT {
14    /// The location parameter (mean).
15    pub loc: Array1<f64>,
16    /// The scale parameter.
17    pub scale: Array1<f64>,
18    /// The variance (scale^2).
19    pub var: Array1<f64>,
20    /// The degrees of freedom.
21    pub df: Array1<f64>,
22    /// The parameters of the distribution, stored as a 2D array.
23    _params: Array2<f64>,
24}
25
26impl Distribution for StudentT {
27    fn from_params(params: &Array2<f64>) -> Self {
28        let loc = params.column(0).to_owned();
29        let scale = params.column(1).mapv(f64::exp);
30        let var = &scale * &scale;
31        let df = params.column(2).mapv(f64::exp);
32        StudentT {
33            loc,
34            scale,
35            var,
36            df,
37            _params: params.clone(),
38        }
39    }
40
41    fn fit(y: &Array1<f64>) -> Array1<f64> {
42        // Simple estimation using method of moments
43        let mean = y.mean().unwrap_or(0.0);
44        let std_dev = y.std(0.0).max(1e-6);
45        // Default df to 3.0 (common choice for robust estimation)
46        let df = 3.0_f64;
47        array![mean, std_dev.ln(), df.ln()]
48    }
49
50    fn n_params(&self) -> usize {
51        3
52    }
53
54    fn predict(&self) -> Array1<f64> {
55        // Mean of Student's T is loc (for df > 1)
56        self.loc.clone()
57    }
58
59    fn params(&self) -> &Array2<f64> {
60        &self._params
61    }
62}
63
64impl RegressionDistn for StudentT {}
65
66impl DistributionMethods for StudentT {
67    fn mean(&self) -> Array1<f64> {
68        // Mean of Student's T is loc for df > 1, undefined otherwise
69        let mut result = Array1::zeros(self.loc.len());
70        for i in 0..self.loc.len() {
71            if self.df[i] > 1.0 {
72                result[i] = self.loc[i];
73            } else {
74                result[i] = f64::NAN;
75            }
76        }
77        result
78    }
79
80    fn variance(&self) -> Array1<f64> {
81        // Variance of Student's T is scale^2 * df / (df - 2) for df > 2
82        let mut result = Array1::zeros(self.loc.len());
83        for i in 0..self.loc.len() {
84            if self.df[i] > 2.0 {
85                result[i] = self.var[i] * self.df[i] / (self.df[i] - 2.0);
86            } else if self.df[i] > 1.0 {
87                result[i] = f64::INFINITY;
88            } else {
89                result[i] = f64::NAN;
90            }
91        }
92        result
93    }
94
95    fn pdf(&self, y: &Array1<f64>) -> Array1<f64> {
96        let mut result = Array1::zeros(y.len());
97        for i in 0..y.len() {
98            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
99                result[i] = d.pdf(y[i]);
100            }
101        }
102        result
103    }
104
105    fn logpdf(&self, y: &Array1<f64>) -> Array1<f64> {
106        let mut result = Array1::zeros(y.len());
107        for i in 0..y.len() {
108            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
109                result[i] = d.ln_pdf(y[i]);
110            }
111        }
112        result
113    }
114
115    fn cdf(&self, y: &Array1<f64>) -> Array1<f64> {
116        let mut result = Array1::zeros(y.len());
117        for i in 0..y.len() {
118            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
119                result[i] = d.cdf(y[i]);
120            }
121        }
122        result
123    }
124
125    fn ppf(&self, q: &Array1<f64>) -> Array1<f64> {
126        let mut result = Array1::zeros(q.len());
127        for i in 0..q.len() {
128            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
129                let q_clamped = q[i].clamp(1e-15, 1.0 - 1e-15);
130                result[i] = d.inverse_cdf(q_clamped);
131            }
132        }
133        result
134    }
135
136    fn sample(&self, n_samples: usize) -> Array2<f64> {
137        let n_obs = self.loc.len();
138        let mut samples = Array2::zeros((n_samples, n_obs));
139        let mut rng = rand::rng();
140
141        for i in 0..n_obs {
142            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
143                for s in 0..n_samples {
144                    let u: f64 = rng.random();
145                    samples[[s, i]] = d.inverse_cdf(u);
146                }
147            }
148        }
149        samples
150    }
151
152    fn median(&self) -> Array1<f64> {
153        // Median of Student's T is loc (symmetric distribution)
154        self.loc.clone()
155    }
156
157    fn mode(&self) -> Array1<f64> {
158        // Mode of Student's T is loc
159        self.loc.clone()
160    }
161}
162
163impl StudentT {
164    /// Sample from the distribution using inverse CDF method.
165    /// Returns an array of shape (n_samples, n_obs) where each column is samples for one observation.
166    pub fn sample(&self, n_samples: usize) -> Array2<f64> {
167        let n_obs = self.loc.len();
168        let mut samples = Array2::zeros((n_samples, n_obs));
169        let mut rng = rand::rng();
170
171        for i in 0..n_obs {
172            let d = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]).unwrap();
173            for s in 0..n_samples {
174                // Use inverse CDF sampling
175                let u: f64 = rng.random();
176                samples[[s, i]] = d.inverse_cdf(u);
177            }
178        }
179        samples
180    }
181}
182
183impl Scorable<LogScore> for StudentT {
184    fn score(&self, y: &Array1<f64>) -> Array1<f64> {
185        let mut scores = Array1::zeros(y.len());
186        for (i, &y_i) in y.iter().enumerate() {
187            let d = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]).unwrap();
188            scores[i] = -d.ln_pdf(y_i);
189        }
190        scores
191    }
192
193    fn d_score(&self, y: &Array1<f64>) -> Array2<f64> {
194        let n_obs = y.len();
195        let mut d_params = Array2::zeros((n_obs, 3));
196
197        for i in 0..n_obs {
198            let loc_i = self.loc[i];
199            let var_i = self.var[i];
200            let df_i = self.df[i];
201            let y_i = y[i];
202
203            let diff = y_i - loc_i;
204            let diff_sq = diff * diff;
205
206            // Denominator: df * var + (y - loc)^2
207            let denom = df_i * var_i + diff_sq;
208
209            // d/d(loc): -(df + 1) * (y - loc) / (df * var + (y - loc)^2)
210            d_params[[i, 0]] = -(df_i + 1.0) * diff / denom;
211
212            // d/d(log(scale)): 1 - (df + 1) * (y - loc)^2 / (df * var + (y - loc)^2)
213            d_params[[i, 1]] = 1.0 - (df_i + 1.0) * diff_sq / denom;
214
215            // d/d(log(df)) is more complex
216            let term_1 = (df_i / 2.0) * digamma((df_i + 1.0) / 2.0);
217            let term_2 = (-df_i / 2.0) * digamma(df_i / 2.0);
218            let term_3 = -0.5;
219            let term_4_1 = (-df_i / 2.0) * (1.0 + diff_sq / (df_i * var_i)).ln();
220            let term_4_2_num = (df_i + 1.0) * diff_sq;
221            let term_4_2_den = 2.0 * (df_i * var_i) * (1.0 + diff_sq / (df_i * var_i));
222
223            d_params[[i, 2]] = -(term_1 + term_2 + term_3 + term_4_1 + term_4_2_num / term_4_2_den);
224        }
225
226        d_params
227    }
228
229    fn metric(&self) -> Array3<f64> {
230        // Python's TLogScore does NOT implement metric(), so it falls back to
231        // the default LogScore.metric() which returns identity matrix.
232        // We match that behavior here.
233        let n_obs = self.loc.len();
234        let n_params = 3;
235
236        let mut fi = Array3::zeros((n_obs, n_params, n_params));
237        for i in 0..n_obs {
238            for j in 0..n_params {
239                fi[[i, j, j]] = 1.0;
240            }
241        }
242        fi
243    }
244}
245
246impl Scorable<CRPScore> for StudentT {
247    fn score(&self, y: &Array1<f64>) -> Array1<f64> {
248        // CRPS for Student's T distribution
249        // CRPS(F, y) = σ * [z * (2*F_ν(z) - 1) + 2*f_ν(z) * (ν + z²)/(ν - 1)
250        //              - 2*sqrt(ν) * B(0.5, ν - 0.5) / ((ν - 1) * B(0.5, ν/2)²)]
251        // where z = (y - μ)/σ, F_ν is standard t CDF, f_ν is standard t PDF,
252        // and B is the beta function.
253        //
254        // For ν = 1 (Cauchy), CRPS is undefined (infinite).
255        // For ν ≤ 1, we return a large penalty.
256
257        let mut scores = Array1::zeros(y.len());
258
259        for i in 0..y.len() {
260            let mu = self.loc[i];
261            let sigma = self.scale[i];
262            let nu = self.df[i];
263            let y_i = y[i];
264
265            // For df <= 1, CRPS is not well-defined
266            if nu <= 1.0 {
267                scores[i] = 1e10; // Large penalty
268                continue;
269            }
270
271            let z = (y_i - mu) / sigma;
272
273            // Standard t distribution with df = nu
274            if let Ok(std_t) = StudentsTDist::new(0.0, 1.0, nu) {
275                let f_z = std_t.pdf(z);
276                let big_f_z = std_t.cdf(z);
277
278                // Term 1: z * (2*F(z) - 1)
279                let term1 = z * (2.0 * big_f_z - 1.0);
280
281                // Term 2: 2*f(z) * (ν + z²)/(ν - 1)
282                let term2 = 2.0 * f_z * (nu + z * z) / (nu - 1.0);
283
284                // Term 3: 2*sqrt(ν) * B(0.5, ν - 0.5) / ((ν - 1) * B(0.5, ν/2)²)
285                // Using ln_beta for numerical stability
286                let ln_b1 = ln_beta(0.5, nu - 0.5);
287                let ln_b2 = ln_beta(0.5, nu / 2.0);
288                let term3 = 2.0 * nu.sqrt() * (ln_b1 - 2.0 * ln_b2).exp() / (nu - 1.0);
289
290                scores[i] = sigma * (term1 + term2 - term3);
291            } else {
292                scores[i] = 1e10;
293            }
294        }
295        scores
296    }
297
298    fn d_score(&self, y: &Array1<f64>) -> Array2<f64> {
299        // Gradient of CRPS for Student T w.r.t. (loc, log(scale), log(df))
300        // This is complex, so we use numerical approximation for simplicity
301        let n_obs = y.len();
302        let mut d_params = Array2::zeros((n_obs, 3));
303        let eps = 1e-6;
304
305        for i in 0..n_obs {
306            let mu = self.loc[i];
307            let sigma = self.scale[i];
308            let nu = self.df[i];
309            let y_i = y[i];
310
311            if nu <= 1.0 {
312                continue; // Skip for invalid df
313            }
314
315            let z = (y_i - mu) / sigma;
316
317            if let Ok(std_t) = StudentsTDist::new(0.0, 1.0, nu) {
318                let big_f_z = std_t.cdf(z);
319
320                // d(CRPS)/d(μ) = -σ * d(CRPS_std)/dz / σ = -(2*F(z) - 1)
321                d_params[[i, 0]] = -(2.0 * big_f_z - 1.0);
322
323                // d(CRPS)/d(log(σ)) = CRPS + (y - μ) * d(CRPS)/d(μ)
324                // Compute CRPS at this point
325                let f_z = std_t.pdf(z);
326                let term1 = z * (2.0 * big_f_z - 1.0);
327                let term2 = 2.0 * f_z * (nu + z * z) / (nu - 1.0);
328                let ln_b1 = ln_beta(0.5, nu - 0.5);
329                let ln_b2 = ln_beta(0.5, nu / 2.0);
330                let term3 = 2.0 * nu.sqrt() * (ln_b1 - 2.0 * ln_b2).exp() / (nu - 1.0);
331                let crps_std = term1 + term2 - term3;
332
333                d_params[[i, 1]] = sigma * crps_std + (y_i - mu) * d_params[[i, 0]];
334
335                // d(CRPS)/d(log(ν)) - use numerical differentiation
336                let nu_plus = nu * (1.0 + eps);
337                let nu_minus = nu * (1.0 - eps);
338
339                let crps_plus = compute_t_crps_std(z, nu_plus);
340                let crps_minus = compute_t_crps_std(z, nu_minus);
341
342                d_params[[i, 2]] = sigma * nu * (crps_plus - crps_minus) / (2.0 * eps * nu);
343            }
344        }
345
346        d_params
347    }
348
349    fn metric(&self) -> Array3<f64> {
350        // Approximate metric using identity matrix (matching LogScore behavior for T)
351        let n_obs = self.loc.len();
352        let n_params = 3;
353
354        let mut fi = Array3::zeros((n_obs, n_params, n_params));
355        for i in 0..n_obs {
356            for j in 0..n_params {
357                fi[[i, j, j]] = 1.0;
358            }
359        }
360        fi
361    }
362}
363
364/// Helper function to compute standardized CRPS for t-distribution
365fn compute_t_crps_std(z: f64, nu: f64) -> f64 {
366    if nu <= 1.0 {
367        return 1e10;
368    }
369
370    if let Ok(std_t) = StudentsTDist::new(0.0, 1.0, nu) {
371        let f_z = std_t.pdf(z);
372        let big_f_z = std_t.cdf(z);
373
374        let term1 = z * (2.0 * big_f_z - 1.0);
375        let term2 = 2.0 * f_z * (nu + z * z) / (nu - 1.0);
376        let ln_b1 = ln_beta(0.5, nu - 0.5);
377        let ln_b2 = ln_beta(0.5, nu / 2.0);
378        let term3 = 2.0 * nu.sqrt() * (ln_b1 - 2.0 * ln_b2).exp() / (nu - 1.0);
379
380        term1 + term2 - term3
381    } else {
382        1e10
383    }
384}
385
386/// The Student's T distribution with fixed degrees of freedom.
387///
388/// Has two parameters: loc (mean) and log(scale).
389/// Degrees of freedom is fixed at construction time (default: 3.0).
390#[derive(Debug, Clone)]
391pub struct TFixedDf {
392    /// The location parameter (mean).
393    pub loc: Array1<f64>,
394    /// The scale parameter.
395    pub scale: Array1<f64>,
396    /// The variance (scale^2).
397    pub var: Array1<f64>,
398    /// The degrees of freedom (fixed).
399    pub df: Array1<f64>,
400    /// The fixed df value used for all observations.
401    pub fixed_df: f64,
402    /// The parameters of the distribution, stored as a 2D array.
403    _params: Array2<f64>,
404}
405
406impl TFixedDf {
407    /// The default fixed degrees of freedom.
408    pub const DEFAULT_FIXED_DF: f64 = 3.0;
409
410    /// Creates a new TFixedDf distribution from parameters with a custom fixed df.
411    pub fn from_params_with_df(params: &Array2<f64>, fixed_df: f64) -> Self {
412        let loc = params.column(0).to_owned();
413        let scale = params.column(1).mapv(f64::exp);
414        let var = &scale * &scale;
415        let n = loc.len();
416        let df = Array1::from_elem(n, fixed_df);
417        TFixedDf {
418            loc,
419            scale,
420            var,
421            df,
422            fixed_df,
423            _params: params.clone(),
424        }
425    }
426}
427
428impl Distribution for TFixedDf {
429    fn from_params(params: &Array2<f64>) -> Self {
430        Self::from_params_with_df(params, Self::DEFAULT_FIXED_DF)
431    }
432
433    fn fit(y: &Array1<f64>) -> Array1<f64> {
434        let mean = y.mean().unwrap_or(0.0);
435        let std_dev = y.std(0.0).max(1e-6);
436        array![mean, std_dev.ln()]
437    }
438
439    fn n_params(&self) -> usize {
440        2
441    }
442
443    fn predict(&self) -> Array1<f64> {
444        self.loc.clone()
445    }
446
447    fn params(&self) -> &Array2<f64> {
448        &self._params
449    }
450}
451
452impl RegressionDistn for TFixedDf {}
453
454impl DistributionMethods for TFixedDf {
455    fn mean(&self) -> Array1<f64> {
456        // Mean is loc for df > 1 (df=3 by default, so always defined)
457        self.loc.clone()
458    }
459
460    fn variance(&self) -> Array1<f64> {
461        // Variance is scale^2 * df / (df - 2) for df > 2
462        let mut result = Array1::zeros(self.loc.len());
463        for i in 0..self.loc.len() {
464            if self.df[i] > 2.0 {
465                result[i] = self.var[i] * self.df[i] / (self.df[i] - 2.0);
466            } else {
467                result[i] = f64::INFINITY;
468            }
469        }
470        result
471    }
472
473    fn pdf(&self, y: &Array1<f64>) -> Array1<f64> {
474        let mut result = Array1::zeros(y.len());
475        for i in 0..y.len() {
476            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
477                result[i] = d.pdf(y[i]);
478            }
479        }
480        result
481    }
482
483    fn cdf(&self, y: &Array1<f64>) -> Array1<f64> {
484        let mut result = Array1::zeros(y.len());
485        for i in 0..y.len() {
486            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
487                result[i] = d.cdf(y[i]);
488            }
489        }
490        result
491    }
492
493    fn ppf(&self, q: &Array1<f64>) -> Array1<f64> {
494        let mut result = Array1::zeros(q.len());
495        for i in 0..q.len() {
496            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
497                let q_clamped = q[i].clamp(1e-15, 1.0 - 1e-15);
498                result[i] = d.inverse_cdf(q_clamped);
499            }
500        }
501        result
502    }
503
504    fn sample(&self, n_samples: usize) -> Array2<f64> {
505        let n_obs = self.loc.len();
506        let mut samples = Array2::zeros((n_samples, n_obs));
507        let mut rng = rand::rng();
508
509        for i in 0..n_obs {
510            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
511                for s in 0..n_samples {
512                    let u: f64 = rng.random();
513                    samples[[s, i]] = d.inverse_cdf(u);
514                }
515            }
516        }
517        samples
518    }
519
520    fn median(&self) -> Array1<f64> {
521        self.loc.clone()
522    }
523
524    fn mode(&self) -> Array1<f64> {
525        self.loc.clone()
526    }
527}
528
529impl Scorable<LogScore> for TFixedDf {
530    fn score(&self, y: &Array1<f64>) -> Array1<f64> {
531        let mut scores = Array1::zeros(y.len());
532        for (i, &y_i) in y.iter().enumerate() {
533            let d = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]).unwrap();
534            scores[i] = -d.ln_pdf(y_i);
535        }
536        scores
537    }
538
539    fn d_score(&self, y: &Array1<f64>) -> Array2<f64> {
540        let n_obs = y.len();
541        let mut d_params = Array2::zeros((n_obs, 2));
542
543        for i in 0..n_obs {
544            let loc_i = self.loc[i];
545            let var_i = self.var[i];
546            let df_i = self.df[i];
547            let y_i = y[i];
548
549            let diff = y_i - loc_i;
550            let diff_sq = diff * diff;
551            let denom = df_i * var_i + diff_sq;
552
553            // d/d(loc)
554            d_params[[i, 0]] = -(df_i + 1.0) * diff / denom;
555
556            // d/d(log(scale))
557            d_params[[i, 1]] = 1.0 - (df_i + 1.0) * diff_sq / denom;
558        }
559
560        d_params
561    }
562
563    fn metric(&self) -> Array3<f64> {
564        // Fisher Information Matrix for T with fixed df
565        let n_obs = self.loc.len();
566        let mut fi = Array3::zeros((n_obs, 2, 2));
567
568        for i in 0..n_obs {
569            let df_i = self.df[i];
570            let var_i = self.var[i];
571
572            // FI[0, 0] = (df + 1) / ((df + 3) * var)
573            fi[[i, 0, 0]] = (df_i + 1.0) / ((df_i + 3.0) * var_i);
574
575            // FI[1, 1] = df / (2 * (df + 3) * var)
576            // Note: Python uses var in denominator but this seems wrong dimensionally
577            // Matching Python behavior exactly:
578            fi[[i, 1, 1]] = df_i / (2.0 * (df_i + 3.0) * var_i);
579        }
580
581        fi
582    }
583}
584
585impl Scorable<CRPScore> for TFixedDf {
586    fn score(&self, y: &Array1<f64>) -> Array1<f64> {
587        // CRPS for Student's T with fixed df
588        let mut scores = Array1::zeros(y.len());
589
590        for i in 0..y.len() {
591            let mu = self.loc[i];
592            let sigma = self.scale[i];
593            let nu = self.df[i];
594            let y_i = y[i];
595
596            if nu <= 1.0 {
597                scores[i] = 1e10;
598                continue;
599            }
600
601            let z = (y_i - mu) / sigma;
602            scores[i] = sigma * compute_t_crps_std(z, nu);
603        }
604        scores
605    }
606
607    fn d_score(&self, y: &Array1<f64>) -> Array2<f64> {
608        // Gradient w.r.t. (loc, log(scale))
609        let n_obs = y.len();
610        let mut d_params = Array2::zeros((n_obs, 2));
611
612        for i in 0..n_obs {
613            let mu = self.loc[i];
614            let sigma = self.scale[i];
615            let nu = self.df[i];
616            let y_i = y[i];
617
618            if nu <= 1.0 {
619                continue;
620            }
621
622            let z = (y_i - mu) / sigma;
623
624            if let Ok(std_t) = StudentsTDist::new(0.0, 1.0, nu) {
625                let big_f_z = std_t.cdf(z);
626
627                // d(CRPS)/d(μ)
628                d_params[[i, 0]] = -(2.0 * big_f_z - 1.0);
629
630                // d(CRPS)/d(log(σ))
631                let crps_std = compute_t_crps_std(z, nu);
632                d_params[[i, 1]] = sigma * crps_std + (y_i - mu) * d_params[[i, 0]];
633            }
634        }
635
636        d_params
637    }
638
639    fn metric(&self) -> Array3<f64> {
640        // Use similar metric to LogScore
641        let n_obs = self.loc.len();
642        let mut fi = Array3::zeros((n_obs, 2, 2));
643
644        for i in 0..n_obs {
645            let df_i = self.df[i];
646            let var_i = self.var[i];
647            fi[[i, 0, 0]] = (df_i + 1.0) / ((df_i + 3.0) * var_i);
648            fi[[i, 1, 1]] = df_i / (2.0 * (df_i + 3.0) * var_i);
649        }
650
651        fi
652    }
653}
654
655/// The Student's T distribution with fixed df=3 and fixed variance=1.
656///
657/// Has one parameter: loc (mean).
658#[derive(Debug, Clone)]
659pub struct TFixedDfFixedVar {
660    /// The location parameter (mean).
661    pub loc: Array1<f64>,
662    /// The scale parameter (fixed at 1.0).
663    pub scale: Array1<f64>,
664    /// The variance (fixed at 1.0).
665    pub var: Array1<f64>,
666    /// The degrees of freedom (fixed).
667    pub df: Array1<f64>,
668    /// The fixed df value.
669    pub fixed_df: f64,
670    /// The parameters of the distribution, stored as a 2D array.
671    _params: Array2<f64>,
672}
673
674impl TFixedDfFixedVar {
675    /// The default fixed degrees of freedom.
676    pub const DEFAULT_FIXED_DF: f64 = 3.0;
677
678    /// Creates a new TFixedDfFixedVar distribution from parameters with a custom fixed df.
679    pub fn from_params_with_df(params: &Array2<f64>, fixed_df: f64) -> Self {
680        let loc = params.column(0).to_owned();
681        let n = loc.len();
682        let scale = Array1::ones(n);
683        let var = Array1::ones(n);
684        let df = Array1::from_elem(n, fixed_df);
685        TFixedDfFixedVar {
686            loc,
687            scale,
688            var,
689            df,
690            fixed_df,
691            _params: params.clone(),
692        }
693    }
694}
695
696impl Distribution for TFixedDfFixedVar {
697    fn from_params(params: &Array2<f64>) -> Self {
698        Self::from_params_with_df(params, Self::DEFAULT_FIXED_DF)
699    }
700
701    fn fit(y: &Array1<f64>) -> Array1<f64> {
702        let mean = y.mean().unwrap_or(0.0);
703        array![mean]
704    }
705
706    fn n_params(&self) -> usize {
707        1
708    }
709
710    fn predict(&self) -> Array1<f64> {
711        self.loc.clone()
712    }
713
714    fn params(&self) -> &Array2<f64> {
715        &self._params
716    }
717}
718
719impl RegressionDistn for TFixedDfFixedVar {}
720
721impl DistributionMethods for TFixedDfFixedVar {
722    fn mean(&self) -> Array1<f64> {
723        self.loc.clone()
724    }
725
726    fn variance(&self) -> Array1<f64> {
727        // For df=3, variance = 1 * 3 / (3 - 2) = 3
728        let mut result = Array1::zeros(self.loc.len());
729        for i in 0..self.loc.len() {
730            result[i] = self.var[i] * self.df[i] / (self.df[i] - 2.0);
731        }
732        result
733    }
734
735    fn pdf(&self, y: &Array1<f64>) -> Array1<f64> {
736        let mut result = Array1::zeros(y.len());
737        for i in 0..y.len() {
738            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
739                result[i] = d.pdf(y[i]);
740            }
741        }
742        result
743    }
744
745    fn cdf(&self, y: &Array1<f64>) -> Array1<f64> {
746        let mut result = Array1::zeros(y.len());
747        for i in 0..y.len() {
748            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
749                result[i] = d.cdf(y[i]);
750            }
751        }
752        result
753    }
754
755    fn ppf(&self, q: &Array1<f64>) -> Array1<f64> {
756        let mut result = Array1::zeros(q.len());
757        for i in 0..q.len() {
758            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
759                let q_clamped = q[i].clamp(1e-15, 1.0 - 1e-15);
760                result[i] = d.inverse_cdf(q_clamped);
761            }
762        }
763        result
764    }
765
766    fn sample(&self, n_samples: usize) -> Array2<f64> {
767        let n_obs = self.loc.len();
768        let mut samples = Array2::zeros((n_samples, n_obs));
769        let mut rng = rand::rng();
770
771        for i in 0..n_obs {
772            if let Ok(d) = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]) {
773                for s in 0..n_samples {
774                    let u: f64 = rng.random();
775                    samples[[s, i]] = d.inverse_cdf(u);
776                }
777            }
778        }
779        samples
780    }
781
782    fn median(&self) -> Array1<f64> {
783        self.loc.clone()
784    }
785
786    fn mode(&self) -> Array1<f64> {
787        self.loc.clone()
788    }
789}
790
791impl Scorable<LogScore> for TFixedDfFixedVar {
792    fn score(&self, y: &Array1<f64>) -> Array1<f64> {
793        let mut scores = Array1::zeros(y.len());
794        for (i, &y_i) in y.iter().enumerate() {
795            let d = StudentsTDist::new(self.loc[i], self.scale[i], self.df[i]).unwrap();
796            scores[i] = -d.ln_pdf(y_i);
797        }
798        scores
799    }
800
801    fn d_score(&self, y: &Array1<f64>) -> Array2<f64> {
802        let n_obs = y.len();
803        let mut d_params = Array2::zeros((n_obs, 1));
804
805        for i in 0..n_obs {
806            let loc_i = self.loc[i];
807            let var_i = self.var[i];
808            let df_i = self.df[i];
809            let y_i = y[i];
810
811            let diff = y_i - loc_i;
812            let diff_sq = diff * diff;
813
814            // d/d(loc) for fixed var case
815            let num = (df_i + 1.0) * (2.0 / (df_i * var_i)) * diff;
816            let den = 2.0 * (1.0 + (1.0 / (df_i * var_i)) * diff_sq);
817            d_params[[i, 0]] = -num / den;
818        }
819
820        d_params
821    }
822
823    fn metric(&self) -> Array3<f64> {
824        let n_obs = self.loc.len();
825        let mut fi = Array3::zeros((n_obs, 1, 1));
826
827        for i in 0..n_obs {
828            let df_i = self.df[i];
829            let var_i = self.var[i];
830            fi[[i, 0, 0]] = (df_i + 1.0) / ((df_i + 3.0) * var_i);
831        }
832
833        fi
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    #[test]
842    fn test_studentt_crpscore() {
843        // Student T with df=5, loc=0, scale=1
844        let params = Array2::from_shape_vec((1, 3), vec![0.0, 0.0, 5.0_f64.ln()]).unwrap();
845        let dist = StudentT::from_params(&params);
846
847        let y = Array1::from_vec(vec![0.0]);
848        let score = Scorable::<CRPScore>::score(&dist, &y);
849
850        // CRPS should be finite and non-negative
851        assert!(score[0].is_finite());
852        assert!(score[0] >= 0.0);
853    }
854
855    #[test]
856    fn test_studentt_crpscore_at_mean() {
857        // CRPS at the mean should be relatively small
858        let params = Array2::from_shape_vec((1, 3), vec![5.0, 0.0, 3.0_f64.ln()]).unwrap();
859        let dist = StudentT::from_params(&params);
860
861        let y = Array1::from_vec(vec![5.0]); // At the mean
862        let score = Scorable::<CRPScore>::score(&dist, &y);
863
864        assert!(score[0].is_finite());
865        assert!(score[0] >= 0.0);
866        assert!(score[0] < 1.0); // Should be small at the mean
867    }
868
869    #[test]
870    fn test_studentt_crpscore_df_1_penalty() {
871        // For df=1 (Cauchy), CRPS should return large penalty
872        let params = Array2::from_shape_vec((1, 3), vec![0.0, 0.0, 0.0]).unwrap(); // df = exp(0) = 1
873        let dist = StudentT::from_params(&params);
874
875        let y = Array1::from_vec(vec![0.0]);
876        let score = Scorable::<CRPScore>::score(&dist, &y);
877
878        // Should be very large penalty
879        assert!(score[0] > 1e9);
880    }
881
882    #[test]
883    fn test_studentt_crpscore_d_score() {
884        let params = Array2::from_shape_vec((1, 3), vec![0.0, 0.0, 3.0_f64.ln()]).unwrap();
885        let dist = StudentT::from_params(&params);
886
887        let y = Array1::from_vec(vec![1.0]);
888        let d_score = Scorable::<CRPScore>::d_score(&dist, &y);
889
890        // Gradients should be finite
891        assert!(d_score[[0, 0]].is_finite());
892        assert!(d_score[[0, 1]].is_finite());
893        assert!(d_score[[0, 2]].is_finite());
894    }
895
896    #[test]
897    fn test_tfixeddf_crpscore() {
898        // TFixedDf with default df=3
899        let params = Array2::from_shape_vec((1, 2), vec![0.0, 0.0]).unwrap();
900        let dist = TFixedDf::from_params(&params);
901
902        let y = Array1::from_vec(vec![0.0]);
903        let score = Scorable::<CRPScore>::score(&dist, &y);
904
905        assert!(score[0].is_finite());
906        assert!(score[0] >= 0.0);
907    }
908
909    #[test]
910    fn test_tfixeddf_crpscore_d_score() {
911        let params = Array2::from_shape_vec((1, 2), vec![0.0, 0.0]).unwrap();
912        let dist = TFixedDf::from_params(&params);
913
914        let y = Array1::from_vec(vec![1.0]);
915        let d_score = Scorable::<CRPScore>::d_score(&dist, &y);
916
917        assert!(d_score[[0, 0]].is_finite());
918        assert!(d_score[[0, 1]].is_finite());
919    }
920
921    #[test]
922    fn test_tfixeddfixedvar_crpscore() {
923        // TFixedDfFixedVar with default df=3, var=1
924        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
925        let dist = TFixedDfFixedVar::from_params(&params);
926
927        let y = Array1::from_vec(vec![0.0]);
928        let score = Scorable::<CRPScore>::score(&dist, &y);
929
930        assert!(score[0].is_finite());
931        assert!(score[0] >= 0.0);
932    }
933
934    #[test]
935    fn test_tfixeddfixedvar_crpscore_d_score() {
936        let params = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
937        let dist = TFixedDfFixedVar::from_params(&params);
938
939        let y = Array1::from_vec(vec![1.0]);
940        let d_score = Scorable::<CRPScore>::d_score(&dist, &y);
941
942        assert!(d_score[[0, 0]].is_finite());
943    }
944
945    #[test]
946    fn test_studentt_crps_converges_to_normal() {
947        // As df -> infinity, Student T converges to Normal
948        // CRPS should be similar to Normal CRPS for large df
949        let params_t = Array2::from_shape_vec((1, 3), vec![0.0, 0.0, 100.0_f64.ln()]).unwrap();
950        let dist_t = StudentT::from_params(&params_t);
951
952        let y = Array1::from_vec(vec![1.0]);
953        let score_t = Scorable::<CRPScore>::score(&dist_t, &y);
954
955        // Compare to Normal CRPS (Student T converges to Normal as df -> infinity)
956        use crate::dist::Normal;
957        let params_n = Array2::from_shape_vec((1, 2), vec![0.0, 0.0]).unwrap();
958        let dist_n = Normal::from_params(&params_n);
959        let score_n = Scorable::<CRPScore>::score(&dist_n, &y);
960
961        // Student T with large df should be very close to Normal
962        assert!(score_t[0].is_finite());
963        assert!((score_t[0] - score_n[0]).abs() < 0.01);
964    }
965}
966
967impl Scorable<CRPScore> for TFixedDfFixedVar {
968    fn score(&self, y: &Array1<f64>) -> Array1<f64> {
969        // CRPS for Student's T with fixed df and fixed variance
970        let mut scores = Array1::zeros(y.len());
971
972        for i in 0..y.len() {
973            let mu = self.loc[i];
974            let sigma = self.scale[i]; // Fixed at 1.0
975            let nu = self.df[i];
976            let y_i = y[i];
977
978            if nu <= 1.0 {
979                scores[i] = 1e10;
980                continue;
981            }
982
983            let z = (y_i - mu) / sigma;
984            scores[i] = sigma * compute_t_crps_std(z, nu);
985        }
986        scores
987    }
988
989    fn d_score(&self, y: &Array1<f64>) -> Array2<f64> {
990        // Gradient w.r.t. loc only
991        let n_obs = y.len();
992        let mut d_params = Array2::zeros((n_obs, 1));
993
994        for i in 0..n_obs {
995            let mu = self.loc[i];
996            let sigma = self.scale[i];
997            let nu = self.df[i];
998            let y_i = y[i];
999
1000            if nu <= 1.0 {
1001                continue;
1002            }
1003
1004            let z = (y_i - mu) / sigma;
1005
1006            if let Ok(std_t) = StudentsTDist::new(0.0, 1.0, nu) {
1007                let big_f_z = std_t.cdf(z);
1008                // d(CRPS)/d(μ) = -(2*F(z) - 1)
1009                d_params[[i, 0]] = -(2.0 * big_f_z - 1.0);
1010            }
1011        }
1012
1013        d_params
1014    }
1015
1016    fn metric(&self) -> Array3<f64> {
1017        let n_obs = self.loc.len();
1018        let mut fi = Array3::zeros((n_obs, 1, 1));
1019
1020        for i in 0..n_obs {
1021            let df_i = self.df[i];
1022            let var_i = self.var[i];
1023            fi[[i, 0, 0]] = (df_i + 1.0) / ((df_i + 3.0) * var_i);
1024        }
1025
1026        fi
1027    }
1028}