Skip to main content

greeners_ols/
ols.rs

1use greeners_core::error::GreenersError;
2use greeners_core::linalg::{LinalgInverse as _, LinalgQR as _};
3use greeners_core::{CovarianceType, InferenceType};
4use greeners_core::{DataFrame, Formula};
5use ndarray::{Array1, Array2};
6use statrs::distribution::{ContinuousCDF, FisherSnedecor, Normal, StudentsT};
7use std::fmt;
8
9/// Type alias for inference computation results: (p_values, conf_lower, conf_upper)
10type InferenceResult = (Array1<f64>, Array1<f64>, Array1<f64>);
11
12/// Prediction with standard errors and confidence intervals.
13#[derive(Debug, Clone)]
14pub struct PredictionResult {
15    pub mean: Array1<f64>,
16    pub se: Array1<f64>,
17    pub ci_lower: Array1<f64>,
18    pub ci_upper: Array1<f64>,
19}
20
21#[derive(Debug, Clone)]
22pub struct OlsResult {
23    pub params: Array1<f64>,
24    pub std_errors: Array1<f64>,
25    pub t_values: Array1<f64>,
26    pub p_values: Array1<f64>,
27    pub conf_lower: Array1<f64>,
28    pub conf_upper: Array1<f64>,
29    pub r_squared: f64,
30    pub adj_r_squared: f64,
31    pub f_statistic: f64,
32    pub prob_f: f64,
33    pub log_likelihood: f64,
34    pub aic: f64,
35    pub bic: f64,
36    pub n_obs: usize,
37    pub df_resid: usize,
38    pub df_model: usize,
39    pub sigma: f64,
40    pub cov_type: CovarianceType,            // Store which type was used
41    pub inference_type: InferenceType,       // Distribution for hypothesis testing
42    pub variable_names: Option<Vec<String>>, // Names of variables (from Formula)
43    pub omitted_vars: Vec<(usize, String)>,  // (position, name) of vars dropped for collinearity
44    pub x_clean: Option<Array2<f64>>,        // Design matrix after collinearity removal
45}
46
47impl OlsResult {
48    /// Generate predictions (fitted values) for new data
49    ///
50    /// # Arguments
51    /// * `x_new` - Design matrix for new observations (must have same number of columns as original X)
52    ///
53    /// # Returns
54    /// Array of predicted values
55    ///
56    /// # Example
57    /// ```no_run
58    /// use greeners_ols::ols::{OLS};
59    /// use greeners_core::{CovarianceType};
60    /// use ndarray::{Array1, Array2};
61    ///
62    /// let y = Array1::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
63    /// let x = Array2::from_shape_vec((5, 2), vec![1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0, 1.0, 5.0]).unwrap();
64    /// let result = OLS::fit(&y, &x, CovarianceType::HC1).unwrap();
65    ///
66    /// // Predict for new data
67    /// let x_new = Array2::from_shape_vec((2, 2), vec![1.0, 6.0, 1.0, 7.0]).unwrap();
68    /// let y_pred = result.predict(&x_new);
69    /// ```
70    pub fn predict(&self, x_new: &Array2<f64>) -> Array1<f64> {
71        x_new.dot(&self.params)
72    }
73
74    /// Calculate residuals for given data
75    ///
76    /// # Arguments
77    /// * `y` - Actual values
78    /// * `x` - Design matrix
79    ///
80    /// # Returns
81    /// Array of residuals (y - ŷ)
82    pub fn residuals(&self, y: &Array1<f64>, x: &Array2<f64>) -> Array1<f64> {
83        let y_hat = x.dot(&self.params);
84        y - &y_hat
85    }
86
87    /// Get fitted values (in-sample predictions)
88    ///
89    /// # Arguments
90    /// * `x` - Original design matrix used in fitting
91    ///
92    /// # Returns
93    /// Array of fitted values
94    pub fn fitted_values(&self, x: &Array2<f64>) -> Array1<f64> {
95        x.dot(&self.params)
96    }
97
98    /// Model comparison statistics
99    ///
100    /// # Returns
101    /// Tuple of (AIC, BIC, Log-Likelihood, Adjusted R²)
102    ///
103    /// # Examples
104    ///
105    /// ```rust
106    /// use greeners_ols::ols::{OLS};
107    /// use greeners_core::{CovarianceType}; // Importe o enum
108    /// use ndarray::{Array1, Array2};
109    ///
110    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
111    /// # let y = Array1::from(vec![1.0, 2.0, 3.0]);
112    /// # let x = Array2::from_shape_vec((3, 2), vec![1.0, 1.0, 1.0, 2.0, 1.0, 3.0])?;
113    /// // Add the extra argument here:
114    /// let result = OLS::fit(&y, &x, CovarianceType::NonRobust)?;
115    ///
116    /// let (aic, bic, loglik, adj_r2) = result.model_stats();
117    /// # Ok(())
118    /// # }
119    /// ```
120    pub fn model_stats(&self) -> (f64, f64, f64, f64) {
121        (self.aic, self.bic, self.log_likelihood, self.adj_r_squared)
122    }
123
124    /// Calculate partial R² for subset of coefficients
125    ///
126    /// Measures the contribution of specific variables to model fit
127    ///
128    /// # Arguments
129    /// * `indices` - Indices of coefficients to test (excluding intercept)
130    /// * `y` - Dependent variable
131    /// * `x` - Full design matrix
132    ///
133    /// # Returns
134    /// Partial R² showing variance explained by specified variables
135    ///
136    /// # Note
137    /// Partial R² = (SSR_restricted - SSR_full) / SSR_restricted
138    pub fn partial_r_squared(&self, indices: &[usize], y: &Array1<f64>, x: &Array2<f64>) -> f64 {
139        // Full model SSR (already fitted)
140        let fitted_full = self.fitted_values(x);
141        let resid_full = y - &fitted_full;
142        let ssr_full = resid_full.dot(&resid_full);
143
144        // Restricted model: drop specified variables
145        let n = x.nrows();
146        let k_full = x.ncols();
147        let k_restricted = k_full - indices.len();
148
149        if k_restricted == 0 {
150            return self.r_squared; // All variables removed = compare to mean
151        }
152
153        // Build restricted design matrix (keep columns NOT in indices)
154        let mut x_restricted = Array2::<f64>::zeros((n, k_restricted));
155        let mut col_idx = 0;
156        for j in 0..k_full {
157            if !indices.contains(&j) {
158                x_restricted.column_mut(col_idx).assign(&x.column(j));
159                col_idx += 1;
160            }
161        }
162
163        // Fit restricted model (simple OLS)
164        use greeners_core::linalg::LinalgInverse as _;
165        let xt_x = x_restricted.t().dot(&x_restricted);
166        let xt_y = x_restricted.t().dot(y);
167
168        if let Ok(xt_x_inv) = xt_x.inv() {
169            let beta_restricted = xt_x_inv.dot(&xt_y);
170            let fitted_restricted = x_restricted.dot(&beta_restricted);
171            let resid_restricted = y - &fitted_restricted;
172            let ssr_restricted = resid_restricted.dot(&resid_restricted);
173
174            // Partial R²
175            (ssr_restricted - ssr_full) / ssr_restricted
176        } else {
177            0.0 // Singular matrix
178        }
179    }
180
181    /// Compute confidence intervals at a custom significance level.
182    ///
183    /// Returns a vector of (lower, upper) tuples, one per coefficient.
184    pub fn conf_int(&self, alpha: f64) -> Result<Vec<(f64, f64)>, GreenersError> {
185        let critical_value = match self.inference_type {
186            InferenceType::StudentT => {
187                let t_dist = StudentsT::new(0.0, 1.0, self.df_resid as f64)
188                    .map_err(|_| GreenersError::OptimizationFailed)?;
189                t_dist.inverse_cdf(1.0 - alpha / 2.0)
190            }
191            InferenceType::Normal => {
192                let normal_dist =
193                    Normal::new(0.0, 1.0).map_err(|_| GreenersError::OptimizationFailed)?;
194                normal_dist.inverse_cdf(1.0 - alpha / 2.0)
195            }
196        };
197
198        Ok((0..self.params.len())
199            .map(|i| {
200                let margin = self.std_errors[i] * critical_value;
201                (self.params[i] - margin, self.params[i] + margin)
202            })
203            .collect())
204    }
205
206    /// Prediction with standard errors and confidence intervals.
207    ///
208    /// Returns predictions for `x_new` with associated uncertainty.
209    /// Requires the covariance matrix, so `x_orig` (the original design matrix) must be provided.
210    ///
211    /// SE(pred) = sqrt(x_new * (X'X)^-1 * x_new' * sigma^2)
212    pub fn get_prediction(
213        &self,
214        x_new: &Array2<f64>,
215        x_orig: &Array2<f64>,
216        alpha: f64,
217    ) -> Result<PredictionResult, GreenersError> {
218        let mean = x_new.dot(&self.params);
219
220        // (X'X)^-1
221        let xt_x = x_orig.t().dot(x_orig);
222        let xt_x_inv = xt_x.inv()?;
223
224        let sigma2 = self.sigma * self.sigma;
225
226        // SE for each prediction
227        let n_pred = x_new.nrows();
228        let mut se = Array1::<f64>::zeros(n_pred);
229        for i in 0..n_pred {
230            let xi = x_new.row(i);
231            let var_i = xi.dot(&xt_x_inv.dot(&xi)) * sigma2;
232            se[i] = var_i.max(0.0).sqrt();
233        }
234
235        let critical_value = match self.inference_type {
236            InferenceType::StudentT => {
237                let t_dist = StudentsT::new(0.0, 1.0, self.df_resid as f64)
238                    .map_err(|_| GreenersError::OptimizationFailed)?;
239                t_dist.inverse_cdf(1.0 - alpha / 2.0)
240            }
241            InferenceType::Normal => {
242                let normal_dist =
243                    Normal::new(0.0, 1.0).map_err(|_| GreenersError::OptimizationFailed)?;
244                normal_dist.inverse_cdf(1.0 - alpha / 2.0)
245            }
246        };
247
248        let margin = &se * critical_value;
249        let ci_lower = &mean - &margin;
250        let ci_upper = &mean + &margin;
251
252        Ok(PredictionResult {
253            mean,
254            se,
255            ci_lower,
256            ci_upper,
257        })
258    }
259
260    /// Wald test for linear restrictions R*beta = q.
261    ///
262    /// H0: R*beta = q
263    /// F = (R*b - q)' * [R * V * R']^-1 * (R*b - q) / J
264    ///
265    /// Returns (F-statistic, p-value, df_num).
266    pub fn wald_test(
267        &self,
268        r_matrix: &Array2<f64>,
269        q: &Array1<f64>,
270        x: &Array2<f64>,
271    ) -> Result<(f64, f64), GreenersError> {
272        let j = r_matrix.nrows();
273        let rb = r_matrix.dot(&self.params);
274        let diff = &rb - q;
275
276        // Reconstruct covariance matrix from std_errors (diagonal approx)
277        // For full covariance we need the original X
278        let xt_x = x.t().dot(x);
279        let xt_x_inv = xt_x.inv()?;
280        let sigma2 = self.sigma * self.sigma;
281        let cov = &xt_x_inv * sigma2;
282
283        let r_cov_r = r_matrix.dot(&cov).dot(&r_matrix.t());
284        let r_cov_r_inv = r_cov_r.inv()?;
285
286        let wald_stat = diff.dot(&r_cov_r_inv.dot(&diff)) / j as f64;
287
288        let f_dist = FisherSnedecor::new(j as f64, self.df_resid as f64)
289            .map_err(|_| GreenersError::OptimizationFailed)?;
290        let p_value = 1.0 - f_dist.cdf(wald_stat);
291
292        Ok((wald_stat, p_value))
293    }
294
295    /// F-test for joint significance of a subset of coefficients.
296    ///
297    /// `indices`: indices of coefficients to test (H0: all are zero).
298    pub fn f_test(&self, indices: &[usize], x: &Array2<f64>) -> Result<(f64, f64), GreenersError> {
299        let j = indices.len();
300        let k = self.params.len();
301
302        let mut r_matrix = Array2::<f64>::zeros((j, k));
303        for (row, &col) in indices.iter().enumerate() {
304            r_matrix[[row, col]] = 1.0;
305        }
306        let q = Array1::<f64>::zeros(j);
307
308        self.wald_test(&r_matrix, &q, x)
309    }
310
311    /// t-test for a single linear restriction r'*beta = q.
312    ///
313    /// Returns (t-statistic, p-value).
314    pub fn t_test(
315        &self,
316        r_vector: &Array1<f64>,
317        q: f64,
318        x: &Array2<f64>,
319    ) -> Result<(f64, f64), GreenersError> {
320        let rb = r_vector.dot(&self.params);
321        let diff = rb - q;
322
323        let xt_x = x.t().dot(x);
324        let xt_x_inv = xt_x.inv()?;
325        let sigma2 = self.sigma * self.sigma;
326        let cov = &xt_x_inv * sigma2;
327
328        let se = r_vector.dot(&cov.dot(r_vector)).max(0.0).sqrt();
329        if se < 1e-15 {
330            return Err(GreenersError::InvalidOperation(
331                "Standard error is zero".into(),
332            ));
333        }
334
335        let t_stat = diff / se;
336
337        let p_value = if t_stat.is_nan() {
338            f64::NAN
339        } else if !t_stat.is_finite() {
340            0.0
341        } else {
342            match self.inference_type {
343                InferenceType::StudentT => {
344                    let t_dist = StudentsT::new(0.0, 1.0, self.df_resid as f64)
345                        .map_err(|_| GreenersError::OptimizationFailed)?;
346                    2.0 * (1.0 - t_dist.cdf(t_stat.abs()))
347                }
348                InferenceType::Normal => {
349                    let normal_dist =
350                        Normal::new(0.0, 1.0).map_err(|_| GreenersError::OptimizationFailed)?;
351                    2.0 * (1.0 - normal_dist.cdf(t_stat.abs()))
352                }
353            }
354        };
355
356        Ok((t_stat, p_value))
357    }
358
359    /// Nonlinear combination of coefficients via delta method.
360    ///
361    /// Computes g(β̂), SE via numerical gradient, t-stat and p-value.
362    /// The covariance matrix is reconstructed as σ²(X'X)⁻¹ (NonRobust).
363    ///
364    /// # Arguments
365    /// * `g` - Function that takes coefficient slice and returns scalar
366    /// * `x` - Design matrix (needed to reconstruct covariance)
367    ///
368    /// # Returns
369    /// (point_estimate, standard_error, t_statistic, p_value)
370    pub fn nlcom<F>(&self, g: F, x: &Array2<f64>) -> Result<(f64, f64, f64, f64), GreenersError>
371    where
372        F: Fn(&[f64]) -> f64,
373    {
374        let params = self.params.as_slice().ok_or_else(|| {
375            GreenersError::InvalidOperation("Non-contiguous parameter array".to_string())
376        })?;
377        let k = params.len();
378        let g_hat = g(params);
379
380        // Numerical gradient (central differences)
381        let h = 1e-7;
382        let mut grad = Array1::<f64>::zeros(k);
383        let mut perturbed = params.to_vec();
384        for j in 0..k {
385            let orig = perturbed[j];
386            perturbed[j] = orig + h;
387            let g_plus = g(&perturbed);
388            perturbed[j] = orig - h;
389            let g_minus = g(&perturbed);
390            grad[j] = (g_plus - g_minus) / (2.0 * h);
391            perturbed[j] = orig;
392        }
393
394        // V = σ²(X'X)⁻¹
395        let xt_x = x.t().dot(x);
396        let xt_x_inv = xt_x.inv()?;
397        let sigma2 = self.sigma * self.sigma;
398        let vcov = &xt_x_inv * sigma2;
399
400        // SE = sqrt(g' V g)
401        let se = grad.dot(&vcov.dot(&grad)).max(0.0).sqrt();
402        let t = if se > 1e-15 { g_hat / se } else { f64::NAN };
403        let p = greeners_core::t_pvalue_two(t, self.df_resid as f64);
404
405        Ok((g_hat, se, t, p))
406    }
407
408    /// Helper function to compute p-values and confidence intervals
409    ///
410    /// This function computes statistical inference quantities using either
411    /// Student's t-distribution or standard Normal distribution.
412    ///
413    /// # Arguments
414    /// * `t_values` - Test statistics (coefficients / standard errors)
415    /// * `std_errors` - Standard errors of coefficient estimates
416    /// * `params` - Coefficient estimates
417    /// * `df_resid` - Residual degrees of freedom (only used for StudentT)
418    /// * `inference_type` - Distribution type to use
419    ///
420    /// # Returns
421    /// Tuple of (p_values, conf_lower, conf_upper)
422    pub fn compute_inference(
423        t_values: &Array1<f64>,
424        std_errors: &Array1<f64>,
425        params: &Array1<f64>,
426        df_resid: usize,
427        inference_type: &InferenceType,
428    ) -> Result<InferenceResult, GreenersError> {
429        let (p_values, critical_value) = match inference_type {
430            InferenceType::StudentT => {
431                let t_dist = StudentsT::new(0.0, 1.0, df_resid as f64)
432                    .map_err(|_| GreenersError::OptimizationFailed)?;
433                let p_vals = t_values.mapv(|t| {
434                    if t.is_nan() {
435                        f64::NAN
436                    } else if !t.is_finite() {
437                        0.0
438                    } else {
439                        2.0 * (1.0 - t_dist.cdf(t.abs()))
440                    }
441                });
442                (p_vals, t_dist.inverse_cdf(0.975))
443            }
444            InferenceType::Normal => {
445                let normal_dist =
446                    Normal::new(0.0, 1.0).map_err(|_| GreenersError::OptimizationFailed)?;
447                let p_vals = t_values.mapv(|t| {
448                    if t.is_nan() {
449                        f64::NAN
450                    } else if !t.is_finite() {
451                        0.0
452                    } else {
453                        2.0 * (1.0 - normal_dist.cdf(t.abs()))
454                    }
455                });
456                (p_vals, normal_dist.inverse_cdf(0.975))
457            }
458        };
459
460        let margin_error = std_errors * critical_value;
461        let conf_lower = params - &margin_error;
462        let conf_upper = params + &margin_error;
463
464        Ok((p_values, conf_lower, conf_upper))
465    }
466
467    /// Change inference type and recompute p-values and confidence intervals
468    ///
469    /// This method allows you to switch between Student's t-distribution and
470    /// Normal distribution for hypothesis testing after the model has been fitted.
471    /// The coefficient estimates and standard errors remain unchanged.
472    ///
473    /// # Arguments
474    /// * `inference_type` - New distribution type to use
475    ///
476    /// # Returns
477    /// Modified OlsResult with updated p-values and confidence intervals
478    ///
479    /// # Example
480    /// ```
481    /// use greeners_ols::ols::{OLS};
482    /// use greeners_core::{CovarianceType, InferenceType};
483    /// use ndarray::{Array1, Array2};
484    ///
485    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
486    /// let y = Array1::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
487    /// let x = Array2::from_shape_vec((5, 2), vec![1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0, 1.0, 5.0])?;
488    ///
489    /// // Fit with default (Student's t)
490    /// let result = OLS::fit(&y, &x, CovarianceType::NonRobust)?;
491    ///
492    /// // Switch to Normal distribution for large sample asymptotics
493    /// let result_z = result.clone().with_inference(InferenceType::Normal)?;
494    ///
495    /// // Coefficients are identical, but p-values differ
496    /// assert_eq!(result.params, result_z.params);
497    /// # Ok(())
498    /// # }
499    /// ```
500    pub fn with_inference(mut self, inference_type: InferenceType) -> Result<Self, GreenersError> {
501        let (p_values, conf_lower, conf_upper) = Self::compute_inference(
502            &self.t_values,
503            &self.std_errors,
504            &self.params,
505            self.df_resid,
506            &inference_type,
507        )?;
508
509        self.p_values = p_values;
510        self.conf_lower = conf_lower;
511        self.conf_upper = conf_upper;
512        self.inference_type = inference_type;
513
514        Ok(self)
515    }
516}
517
518impl fmt::Display for OlsResult {
519    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520        let stat_label = match self.inference_type {
521            InferenceType::StudentT => "t",
522            InferenceType::Normal => "z",
523        };
524
525        let cov_str = match &self.cov_type {
526            CovarianceType::NonRobust => "Non-Robust".to_string(),
527            CovarianceType::HC1 => "Robust (HC1)".to_string(),
528            CovarianceType::HC2 => "Robust (HC2)".to_string(),
529            CovarianceType::HC3 => "Robust (HC3)".to_string(),
530            CovarianceType::HC4 => "Robust (HC4)".to_string(),
531            CovarianceType::NeweyWest(lags) => format!("HAC (Newey-West, L={})", lags),
532            CovarianceType::Clustered(clusters) => {
533                let n_clusters = clusters
534                    .iter()
535                    .collect::<std::collections::HashSet<_>>()
536                    .len();
537                format!("Clustered ({} clusters)", n_clusters)
538            }
539            CovarianceType::ClusteredTwoWay(clusters1, clusters2) => {
540                let n_clusters_1 = clusters1
541                    .iter()
542                    .collect::<std::collections::HashSet<_>>()
543                    .len();
544                let n_clusters_2 = clusters2
545                    .iter()
546                    .collect::<std::collections::HashSet<_>>()
547                    .len();
548                format!("Two-Way Clustered ({}×{})", n_clusters_1, n_clusters_2)
549            }
550        };
551
552        writeln!(f, "\n{:=^78}", " OLS Regression Results ")?;
553        writeln!(
554            f,
555            "{:<20} {:>15} || {:<20} {:>15.4}",
556            "Dep. Variable:", "y", "R-squared:", self.r_squared
557        )?;
558        writeln!(
559            f,
560            "{:<20} {:>15} || {:<20} {:>15.4}",
561            "Model:", "OLS", "Adj. R-squared:", self.adj_r_squared
562        )?;
563        let f_str = if self.f_statistic.is_infinite() {
564            "Inf".to_string()
565        } else {
566            format!("{:.4}", self.f_statistic)
567        };
568        let prob_f_str = if self.f_statistic.is_infinite() {
569            "0.0".to_string()
570        } else {
571            format!("{:.4e}", self.prob_f)
572        };
573        writeln!(
574            f,
575            "{:<20} {:>15} || {:<20} {:>15}",
576            "Covariance Type:", cov_str, "F-statistic:", f_str
577        )?;
578        writeln!(
579            f,
580            "{:<20} {:>15} || {:<20} {:>15}",
581            "No. Observations:", self.n_obs, "Prob (F-statistic):", prob_f_str
582        )?;
583        writeln!(
584            f,
585            "{:<20} {:>15} || {:<20} {:>15.4}",
586            "Df Residuals:", self.df_resid, "Log-Likelihood:", self.log_likelihood
587        )?;
588        writeln!(
589            f,
590            "{:<20} {:>15.4} || {:<20} {:>15.4}",
591            "AIC:", self.aic, "BIC:", self.bic
592        )?;
593
594        writeln!(f, "\n{:-^78}", "")?;
595        writeln!(
596            f,
597            "{:<10} | {:>10} | {:>10} | {:>8} | {:>8} | {:>18}",
598            "Variable",
599            "coef",
600            "std err",
601            stat_label,
602            format!("P>|{}|", stat_label),
603            "[0.025      0.975]"
604        )?;
605        writeln!(f, "{:-^78}", "")?;
606
607        let total = self.params.len() + self.omitted_vars.len();
608        let mut fit_idx = 0usize;
609        for pos in 0..total {
610            if let Some((_, name)) = self.omitted_vars.iter().find(|(p, _)| *p == pos) {
611                writeln!(f, "{:<10} |  (omitted)", name)?;
612            } else {
613                let var_name = if let Some(ref names) = self.variable_names {
614                    if fit_idx < names.len() {
615                        names[fit_idx].clone()
616                    } else {
617                        format!("x{}", fit_idx)
618                    }
619                } else {
620                    format!("x{}", fit_idx)
621                };
622                let t_val = self.t_values[fit_idx];
623                let t_str = if t_val.abs() > 1e10 {
624                    format!("{:.3e}", t_val)
625                } else {
626                    format!("{:.3}", t_val)
627                };
628                writeln!(
629                    f,
630                    "{:<10} | {:>10.4} | {:>10.4} | {:>8} | {:>8.3} | {:>8.4}  {:>8.4}",
631                    var_name,
632                    self.params[fit_idx],
633                    self.std_errors[fit_idx],
634                    t_str,
635                    self.p_values[fit_idx],
636                    self.conf_lower[fit_idx],
637                    self.conf_upper[fit_idx]
638                )?;
639                fit_idx += 1;
640            }
641        }
642
643        writeln!(f, "{:=^78}", "")?;
644        for (_, name) in &self.omitted_vars {
645            writeln!(f, "note: {} omitted because of collinearity", name)?;
646        }
647        Ok(())
648    }
649}
650
651pub struct OLS;
652
653impl OLS {
654    /// Fits an OLS model using a formula and DataFrame.
655    ///
656    /// # Examples
657    /// ```no_run
658    /// use greeners_ols::ols::{OLS};
659    /// use greeners_core::{DataFrame, Formula, CovarianceType};
660    /// use ndarray::Array1;
661    /// use indexmap::IndexMap;
662    ///
663    /// let mut data = IndexMap::new();
664    /// data.insert("y".to_string(), Array1::from(vec![1.0, 2.1, 3.2, 3.9, 5.1]));
665    /// data.insert("x1".to_string(), Array1::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]));
666    /// data.insert("x2".to_string(), Array1::from(vec![2.0, 2.5, 3.0, 3.5, 4.0]));
667    ///
668    /// let df = DataFrame::new(data).unwrap();
669    /// let formula = Formula::parse("y ~ x1 + x2").unwrap();
670    ///
671    /// let result = OLS::from_formula(&formula, &df, CovarianceType::HC1).unwrap();
672    /// println!("R-squared: {}", result.r_squared);
673    /// ```
674    pub fn from_formula(
675        formula: &Formula,
676        data: &DataFrame,
677        cov_type: CovarianceType,
678    ) -> Result<OlsResult, GreenersError> {
679        let (y, x) = data.to_design_matrix(formula)?;
680        let var_names = data.formula_var_names(formula)?;
681        Self::fit_with_names(&y, &x, cov_type, Some(var_names))
682    }
683
684    /// Detect and remove perfectly collinear columns using QR decomposition.
685    ///
686    /// Returns: (clean_x, keep_indices, omitted_indices)
687    pub fn detect_collinearity(
688        x: &Array2<f64>,
689        tolerance: f64,
690    ) -> (Array2<f64>, Vec<usize>, Vec<usize>) {
691        let n = x.nrows();
692        let k = x.ncols();
693
694        // Use QR decomposition to detect rank deficiency
695        // Columns with small R diagonal values are linearly dependent
696        match x.qr() {
697            Ok((_, r)) => {
698                let mut keep_indices = Vec::new();
699                let mut omit_indices = Vec::new();
700
701                // Check diagonal of R matrix
702                for i in 0..k.min(n) {
703                    let r_ii = r[[i, i]].abs();
704                    if r_ii > tolerance {
705                        keep_indices.push(i);
706                    } else {
707                        omit_indices.push(i);
708                    }
709                }
710
711                // If all columns kept, return original matrix
712                if omit_indices.is_empty() {
713                    return (x.clone(), keep_indices, omit_indices);
714                }
715
716                // Build reduced matrix with only independent columns
717                let x_clean = x.select(ndarray::Axis(1), &keep_indices);
718                (x_clean, keep_indices, omit_indices)
719            }
720            Err(_) => {
721                // QR failed, return original (will likely fail in OLS too)
722                let keep: Vec<usize> = (0..k).collect();
723                (x.clone(), keep, vec![])
724            }
725        }
726    }
727
728    /// Fits the model. Now accepts `cov_type` and optional variable names.
729    pub fn fit(
730        y: &Array1<f64>,
731        x: &Array2<f64>,
732        cov_type: CovarianceType,
733    ) -> Result<OlsResult, GreenersError> {
734        Self::fit_with_names(y, x, cov_type, None)
735    }
736
737    /// Fits the model with custom variable names.
738    pub fn fit_with_names(
739        y: &Array1<f64>,
740        x: &Array2<f64>,
741        cov_type: CovarianceType,
742        variable_names: Option<Vec<String>>,
743    ) -> Result<OlsResult, GreenersError> {
744        Self::fit_internal(y, x, cov_type, variable_names, None)
745    }
746
747    pub(crate) fn fit_internal(
748        y: &Array1<f64>,
749        x: &Array2<f64>,
750        cov_type: CovarianceType,
751        variable_names: Option<Vec<String>>,
752        force_intercept: Option<bool>,
753    ) -> Result<OlsResult, GreenersError> {
754        let n = x.nrows();
755        let k = x.ncols();
756
757        if y.len() != n {
758            return Err(GreenersError::ShapeMismatch(format!(
759                "y: {}, X: {}",
760                y.len(),
761                n
762            )));
763        }
764        if variable_names.is_none() && n <= k {
765            return Err(GreenersError::ShapeMismatch(
766                "Degrees of freedom <= 0".into(),
767            ));
768        }
769
770        // Check for NaN/Inf in input data
771        if y.iter().any(|v| !v.is_finite()) || x.iter().any(|v| !v.is_finite()) {
772            return Err(GreenersError::InvalidOperation(
773                "Input data contains NaN or Inf values".into(),
774            ));
775        }
776
777        let (x_to_use, k_clean, omitted_positioned, clean_var_names, x_clean_out) =
778            if let Some(ref names) = variable_names {
779                let cr = greeners_core::linalg::drop_collinear(x, names, 1e-10);
780                let k_clean = cr.x_clean.ncols();
781                if n <= k_clean {
782                    return Err(GreenersError::ShapeMismatch(
783                        "Degrees of freedom <= 0 after removing collinear variables".into(),
784                    ));
785                }
786                let has_omitted = !cr.omitted.is_empty();
787                let x_c = cr.x_clean;
788                (
789                    x_c.clone(),
790                    k_clean,
791                    cr.omitted,
792                    cr.clean_names,
793                    if has_omitted { Some(x_c) } else { None },
794                )
795            } else {
796                (x.clone(), k, Vec::new(), Vec::new(), None)
797            };
798
799        let x_to_use = &x_to_use;
800
801        // 1. Beta Estimation (Same for Robust and Non-Robust)
802        let x_t = x_to_use.t();
803        let xt_x = x_t.dot(x_to_use);
804        let xt_x_inv = xt_x.inv()?;
805        let xt_y = x_t.dot(y);
806        let beta = xt_x_inv.dot(&xt_y);
807
808        // 2. Residuals
809        let predicted = x_to_use.dot(&beta);
810        let residuals = y - &predicted;
811        let ssr = residuals.dot(&residuals);
812
813        let has_intercept = force_intercept.unwrap_or_else(|| {
814            (0..k_clean).any(|j| {
815                x_to_use
816                    .column(j)
817                    .iter()
818                    .all(|&val| (val - 1.0).abs() < 1e-12)
819            })
820        });
821
822        let df_resid = n - k_clean;
823        let df_model = if has_intercept { k_clean - 1 } else { k_clean };
824
825        let sigma2 = ssr / (df_resid as f64);
826        let sigma = sigma2.sqrt();
827
828        // src/ols.rs (inside OLS::fit, replace the 'match cov_type' block)
829
830        // 3. Covariance Matrix Selection
831        let cov_matrix = match &cov_type {
832            CovarianceType::NonRobust => &xt_x_inv * sigma2,
833            CovarianceType::HC1 => {
834                // HC1: White's heteroscedasticity-robust SE with small-sample correction
835                // V = (X'X)^-1 * X' diag(u²) X * (X'X)^-1 * (n / (n-k))
836                let u_squared = residuals.mapv(|r| r.powi(2));
837                let mut x_weighted = x_to_use.clone();
838                for (i, mut row) in x_weighted.axis_iter_mut(nd::Axis(0)).enumerate() {
839                    row *= u_squared[i];
840                }
841                let meat = x_t.dot(&x_weighted);
842                let bread = &xt_x_inv;
843                let sandwich = bread.dot(&meat).dot(bread);
844
845                let correction = (n as f64) / (df_resid as f64);
846                sandwich * correction
847            }
848            CovarianceType::HC2 => {
849                // HC2: Leverage-adjusted heteroscedasticity-robust SE
850                // V = (X'X)^-1 * X' diag(u² / (1 - h_i)) X * (X'X)^-1
851                // More efficient than HC1 with small samples
852
853                // Calculate leverage values: h_i = x_i' (X'X)^-1 x_i
854                let mut leverage = Array1::<f64>::zeros(n);
855                for i in 0..n {
856                    let x_i = x_to_use.row(i);
857                    let temp = xt_x_inv.dot(&x_i);
858                    leverage[i] = x_i.dot(&temp);
859                }
860
861                // Adjust residuals: u²_i / (1 - h_i)
862                let mut u_adjusted = Array1::<f64>::zeros(n);
863                for i in 0..n {
864                    let h_i = leverage[i];
865                    if h_i >= 0.9999 {
866                        u_adjusted[i] = residuals[i].powi(2); // Avoid division by zero
867                    } else {
868                        u_adjusted[i] = residuals[i].powi(2) / (1.0 - h_i);
869                    }
870                }
871
872                // Build sandwich estimator with adjusted weights
873                let mut x_weighted = x_to_use.clone();
874                for (i, mut row) in x_weighted.axis_iter_mut(nd::Axis(0)).enumerate() {
875                    row *= u_adjusted[i];
876                }
877
878                let meat = x_t.dot(&x_weighted);
879                let bread = &xt_x_inv;
880                bread.dot(&meat).dot(bread)
881            }
882            CovarianceType::HC3 => {
883                // HC3: Jackknife heteroscedasticity-robust SE
884                // V = (X'X)^-1 * X' diag(u² / (1 - h_i)²) X * (X'X)^-1
885                // Most robust for small samples - recommended default
886
887                // Calculate leverage values
888                let mut leverage = Array1::<f64>::zeros(n);
889                for i in 0..n {
890                    let x_i = x_to_use.row(i);
891                    let temp = xt_x_inv.dot(&x_i);
892                    leverage[i] = x_i.dot(&temp);
893                }
894
895                // Adjust residuals: u²_i / (1 - h_i)²
896                let mut u_adjusted = Array1::<f64>::zeros(n);
897                for i in 0..n {
898                    let h_i = leverage[i];
899                    if h_i >= 0.9999 {
900                        u_adjusted[i] = residuals[i].powi(2); // Avoid division by zero
901                    } else {
902                        u_adjusted[i] = residuals[i].powi(2) / (1.0 - h_i).powi(2);
903                    }
904                }
905
906                // Build sandwich estimator with adjusted weights
907                let mut x_weighted = x_to_use.clone();
908                for (i, mut row) in x_weighted.axis_iter_mut(nd::Axis(0)).enumerate() {
909                    row *= u_adjusted[i];
910                }
911
912                let meat = x_t.dot(&x_weighted);
913                let bread = &xt_x_inv;
914                bread.dot(&meat).dot(bread)
915            }
916            CovarianceType::HC4 => {
917                // HC4: Refined jackknife (Cribari-Neto, 2004)
918                // V = (X'X)^-1 * X' diag(u² / (1 - h_i)^δᵢ) X * (X'X)^-1
919                // where δᵢ = min(4, n * h_i / k)
920                // Best performance with influential observations
921
922                // Calculate leverage values
923                let mut leverage = Array1::<f64>::zeros(n);
924                for i in 0..n {
925                    let x_i = x_to_use.row(i);
926                    let temp = xt_x_inv.dot(&x_i);
927                    leverage[i] = x_i.dot(&temp);
928                }
929
930                // Adjust residuals with power δᵢ
931                let mut u_adjusted = Array1::<f64>::zeros(n);
932                for i in 0..n {
933                    let h_i = leverage[i];
934                    if h_i >= 0.9999 {
935                        u_adjusted[i] = residuals[i].powi(2);
936                    } else {
937                        // δᵢ = min(4, n * h_i / k)
938                        let delta_i = 4.0_f64.min((n as f64) * h_i / (k_clean as f64));
939                        u_adjusted[i] = residuals[i].powi(2) / (1.0 - h_i).powf(delta_i);
940                    }
941                }
942
943                // Build sandwich estimator
944                let mut x_weighted = x_to_use.clone();
945                for (i, mut row) in x_weighted.axis_iter_mut(nd::Axis(0)).enumerate() {
946                    row *= u_adjusted[i];
947                }
948
949                let meat = x_t.dot(&x_weighted);
950                let bread = &xt_x_inv;
951                bread.dot(&meat).dot(bread)
952            }
953            CovarianceType::NeweyWest(lags) => {
954                // HAC Estimator (Newey-West)
955                // Formula: (X'X)^-1 * [ Omega_0 + sum(w_l * (Omega_l + Omega_l')) ] * (X'X)^-1
956
957                // 1. Calculate Omega_0 (Same as White's Matrix "Meat")
958                let u_squared = residuals.mapv(|r| r.powi(2));
959                let mut x_weighted = x_to_use.clone();
960                for (i, mut row) in x_weighted.axis_iter_mut(nd::Axis(0)).enumerate() {
961                    row *= u_squared[i];
962                }
963                let mut meat = x_t.dot(&x_weighted); // This is Omega_0
964
965                // 2. Add Autocovariance terms (Omega_l)
966                // Bartlett Kernel weights: w(l) = 1 - l / (L + 1)
967                for l in 1..=*lags {
968                    let weight = 1.0 - (l as f64) / ((*lags + 1) as f64);
969
970                    // Calculate Omega_l = sum( u_t * u_{t-l} * x_t * x_{t-l}' )
971                    // Since specific lag logic is tricky in pure matrix algebra without huge memory,
972                    // we iterate carefully.
973
974                    let mut omega_l = Array2::<f64>::zeros((k_clean, k_clean));
975
976                    // Sum over t where lag exists (from l to n)
977                    for t in l..n {
978                        let u_t = residuals[t];
979                        let u_prev = residuals[t - l];
980
981                        let x_row_t = x_to_use.row(t);
982                        let x_row_prev = x_to_use.row(t - l);
983
984                        // Outer product: (x_t * x_{t-l}') scaled by (u_t * u_{t-l})
985                        // Using 'scaled_add' is efficient: matrix += alpha * (vec * vec.t)
986                        //But darray doesn't have concise outside product add, so we do:
987                        // term = (u_t * u_prev) * (x_t outer x_{t-l})
988
989                        let scale = u_t * u_prev;
990
991                        // Manual outer product addition for performance
992                        for i in 0..k_clean {
993                            for j in 0..k_clean {
994                                omega_l[[i, j]] += scale * x_row_t[i] * x_row_prev[j];
995                            }
996                        }
997                    }
998
999                    // Add Weighted (Omega_l + Omega_l') to Meat
1000                    // meat += weight * (omega_l + omega_l.t())
1001                    let omega_l_t = omega_l.t();
1002                    let term = &omega_l + &omega_l_t;
1003                    meat = meat + (&term * weight);
1004                }
1005
1006                let bread = &xt_x_inv;
1007                let sandwich = bread.dot(&meat).dot(bread);
1008
1009                // Small sample correction (n / n-k)
1010                let correction = (n as f64) / (df_resid as f64);
1011                sandwich * correction
1012            }
1013            CovarianceType::Clustered(ref cluster_ids) => {
1014                // Clustered Standard Errors
1015                // Formula: V_cluster = (X'X)^-1 * [Σ_g (X_g' u_g u_g' X_g)] * (X'X)^-1
1016                // Critical for panel data, experiments, and grouped observations
1017
1018                // Validate cluster IDs length
1019                if cluster_ids.len() != n {
1020                    return Err(GreenersError::ShapeMismatch(format!(
1021                        "Cluster IDs length ({}) must match number of observations ({})",
1022                        cluster_ids.len(),
1023                        n
1024                    )));
1025                }
1026
1027                // Group observations by cluster
1028                use indexmap::IndexMap;
1029                let mut clusters: IndexMap<usize, Vec<usize>> = IndexMap::new();
1030                for (obs_idx, &cluster_id) in cluster_ids.iter().enumerate() {
1031                    clusters.entry(cluster_id).or_default().push(obs_idx);
1032                }
1033
1034                let n_clusters = clusters.len();
1035
1036                // Initialize meat matrix (middle part of sandwich)
1037                let mut meat = Array2::<f64>::zeros((k_clean, k_clean));
1038
1039                // For each cluster g: v_g = X_g' u_g, then meat += v_g v_g'.
1040                // This is O(N*k + G*k^2) instead of O(N*c*k^2) with nested loops.
1041                for (_cluster_id, obs_indices) in clusters.iter() {
1042                    let mut v = Array1::<f64>::zeros(k_clean);
1043                    for &obs_idx in obs_indices.iter() {
1044                        let r = residuals[obs_idx];
1045                        let x_i = x_to_use.row(obs_idx);
1046                        for p in 0..k_clean {
1047                            v[p] += r * x_i[p];
1048                        }
1049                    }
1050
1051                    // Add outer product v v' to meat.
1052                    for p in 0..k_clean {
1053                        let vp = v[p];
1054                        let mut meat_row = meat.row_mut(p);
1055                        for q in 0..k_clean {
1056                            meat_row[q] += vp * v[q];
1057                        }
1058                    }
1059                }
1060
1061                // Apply sandwich formula
1062                let bread = &xt_x_inv;
1063                let sandwich = bread.dot(&meat).dot(bread);
1064
1065                // Small sample correction: (G / (G-1)) * ((N-1) / (N-K))
1066                // where G = number of clusters, N = observations, K = parameters
1067                let g_correction = (n_clusters as f64) / ((n_clusters - 1) as f64);
1068                let df_correction = ((n - 1) as f64) / (df_resid as f64);
1069                sandwich * g_correction * df_correction
1070            }
1071            CovarianceType::ClusteredTwoWay(ref cluster_ids_1, ref cluster_ids_2) => {
1072                // Two-Way Clustered Standard Errors (Cameron-Gelbach-Miller, 2011)
1073                // Formula: V = V₁ + V₂ - V₁₂
1074                // Where:
1075                //   V₁ = one-way clustering by dimension 1 (e.g., firm)
1076                //   V₂ = one-way clustering by dimension 2 (e.g., time)
1077                //   V₁₂ = clustering by intersection (firm × time pairs)
1078                //
1079                // This accounts for correlation both within dimension 1,
1080                // within dimension 2, and avoids double-counting the intersection.
1081
1082                // Validate inputs
1083                if cluster_ids_1.len() != n || cluster_ids_2.len() != n {
1084                    return Err(GreenersError::ShapeMismatch(format!(
1085                        "Both cluster ID vectors must match number of observations ({})",
1086                        n
1087                    )));
1088                }
1089
1090                // Helper function to compute clustered meat matrix
1091                let compute_clustered_meat = |cluster_ids: &[usize]| -> Array2<f64> {
1092                    use indexmap::IndexMap;
1093                    let mut clusters: IndexMap<usize, Vec<usize>> = IndexMap::new();
1094                    for (obs_idx, &cluster_id) in cluster_ids.iter().enumerate() {
1095                        clusters.entry(cluster_id).or_default().push(obs_idx);
1096                    }
1097
1098                    let mut meat = Array2::<f64>::zeros((k_clean, k_clean));
1099
1100                    for (_cluster_id, obs_indices) in clusters.iter() {
1101                        let cluster_size = obs_indices.len();
1102                        let mut x_g = Array2::<f64>::zeros((cluster_size, k_clean));
1103                        let mut u_g = Array1::<f64>::zeros(cluster_size);
1104
1105                        for (i, &obs_idx) in obs_indices.iter().enumerate() {
1106                            x_g.row_mut(i).assign(&x_to_use.row(obs_idx));
1107                            u_g[i] = residuals[obs_idx];
1108                        }
1109
1110                        for i in 0..cluster_size {
1111                            for j in 0..cluster_size {
1112                                let scale = u_g[i] * u_g[j];
1113                                let x_i = x_g.row(i);
1114                                let x_j = x_g.row(j);
1115
1116                                for p in 0..k_clean {
1117                                    for q in 0..k_clean {
1118                                        meat[[p, q]] += scale * x_i[p] * x_j[q];
1119                                    }
1120                                }
1121                            }
1122                        }
1123                    }
1124
1125                    meat
1126                };
1127
1128                // 1. Compute V₁ (cluster by dimension 1)
1129                let meat_1 = compute_clustered_meat(cluster_ids_1);
1130
1131                // 2. Compute V₂ (cluster by dimension 2)
1132                let meat_2 = compute_clustered_meat(cluster_ids_2);
1133
1134                // 3. Compute V₁₂ (cluster by intersection)
1135                // Create unique pair IDs: pair_id = cluster1_id * max_cluster2 + cluster2_id
1136                let max_cluster2 = cluster_ids_2.iter().max().unwrap_or(&0) + 1;
1137                let intersection_ids: Vec<usize> = cluster_ids_1
1138                    .iter()
1139                    .zip(cluster_ids_2.iter())
1140                    .map(|(&c1, &c2)| c1 * max_cluster2 + c2)
1141                    .collect();
1142
1143                let meat_12 = compute_clustered_meat(&intersection_ids);
1144
1145                // 4. Apply Cameron-Gelbach-Miller formula: V = V₁ + V₂ - V₁₂
1146                let meat = &meat_1 + &meat_2 - &meat_12;
1147
1148                // Apply sandwich formula
1149                let bread = &xt_x_inv;
1150                let sandwich = bread.dot(&meat).dot(bread);
1151
1152                // Small sample correction
1153                // Use minimum number of clusters for conservative inference
1154                use std::collections::HashSet;
1155                let n_clusters_1: HashSet<_> = cluster_ids_1.iter().collect();
1156                let n_clusters_2: HashSet<_> = cluster_ids_2.iter().collect();
1157                let g = n_clusters_1.len().min(n_clusters_2.len());
1158
1159                let g_correction = (g as f64) / ((g - 1) as f64);
1160                let df_correction = ((n - 1) as f64) / (df_resid as f64);
1161                sandwich * g_correction * df_correction
1162            }
1163        };
1164
1165        // 4. Standard Errors & Inference
1166        let std_errors = cov_matrix.diag().mapv(|v| v.max(0.0).sqrt());
1167        let t_values = &beta / &std_errors;
1168
1169        // Use default inference type (StudentT)
1170        let default_inference = InferenceType::default();
1171        let (p_values, conf_lower, conf_upper) = OlsResult::compute_inference(
1172            &t_values,
1173            &std_errors,
1174            &beta,
1175            df_resid,
1176            &default_inference,
1177        )?;
1178
1179        // 5. Statistics
1180        let sst = if has_intercept {
1181            let y_mean = y.mean().unwrap_or(0.0);
1182            y.mapv(|val| (val - y_mean).powi(2)).sum()
1183        } else {
1184            y.mapv(|val| val.powi(2)).sum()
1185        };
1186
1187        let r_squared = if sst.abs() < 1e-12 {
1188            0.0
1189        } else {
1190            1.0 - (ssr / sst)
1191        };
1192
1193        let adj_r_squared = if has_intercept {
1194            1.0 - (1.0 - r_squared) * ((n as f64 - 1.0) / (df_resid as f64))
1195        } else {
1196            1.0 - (1.0 - r_squared) * ((n as f64) / (df_resid as f64))
1197        };
1198
1199        let msm = (sst - ssr) / (df_model as f64);
1200        let f_statistic = if sigma2 < 1e-12 {
1201            f64::INFINITY
1202        } else {
1203            msm / sigma2
1204        };
1205
1206        let prob_f = if df_model > 0 && f_statistic.is_finite() {
1207            let f_dist = FisherSnedecor::new(df_model as f64, df_resid as f64)
1208                .map_err(|_| GreenersError::OptimizationFailed)?;
1209            1.0 - f_dist.cdf(f_statistic)
1210        } else if f_statistic.is_infinite() {
1211            0.0
1212        } else {
1213            f64::NAN
1214        };
1215
1216        let n_f64 = n as f64;
1217        let log_likelihood =
1218            -n_f64 / 2.0 * ((2.0 * std::f64::consts::PI).ln() + (ssr / n_f64).ln() + 1.0);
1219        let aic = 2.0 * (k_clean as f64) - 2.0 * log_likelihood;
1220        let bic = (k_clean as f64) * n_f64.ln() - 2.0 * log_likelihood;
1221
1222        Ok(OlsResult {
1223            params: beta,
1224            std_errors,
1225            t_values,
1226            p_values,
1227            conf_lower,
1228            conf_upper,
1229            r_squared,
1230            adj_r_squared,
1231            f_statistic,
1232            prob_f,
1233            log_likelihood,
1234            aic,
1235            bic,
1236            n_obs: n,
1237            df_resid,
1238            df_model,
1239            sigma,
1240            cov_type,
1241            inference_type: InferenceType::default(),
1242            variable_names: if !clean_var_names.is_empty() {
1243                Some(clean_var_names)
1244            } else {
1245                variable_names
1246            },
1247            omitted_vars: omitted_positioned,
1248            x_clean: x_clean_out,
1249        })
1250    }
1251}
1252
1253// Helper alias for simpler axis usage inside the function
1254use ndarray as nd;