use crate::error::{StatsError, StatsResult};
use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
use super::{
chi2_p_value, cholesky_invert, f_dist_p_value, ols_fit, robust_vcov_hc1, t_critical,
t_dist_p_value,
};
#[derive(Debug, Clone)]
pub struct IvResult {
pub coefficients: Array1<f64>,
pub std_errors: Array1<f64>,
pub t_stats: Array1<f64>,
pub p_values: Array1<f64>,
pub conf_intervals: Array2<f64>,
pub n_obs: usize,
pub n_endog: usize,
pub n_instruments: usize,
pub diagnostics: IvDiagnostics,
pub rss: f64,
}
#[derive(Debug, Clone)]
pub struct IvDiagnostics {
pub first_stage_f: f64,
pub first_stage_f_p: f64,
pub first_stage_f_each: Vec<f64>,
pub partial_r_squared: Vec<f64>,
pub sargan_stat: Option<f64>,
pub sargan_p_value: Option<f64>,
pub wu_hausman_stat: Option<f64>,
pub wu_hausman_p_value: Option<f64>,
pub instruments_weak: bool,
}
#[derive(Debug, Clone)]
pub struct IvSpec {
pub endogenous_indices: Vec<usize>,
pub exogenous_indices: Vec<usize>,
pub instrument_indices: Vec<usize>,
}
pub struct TwoStageLeastSquares {
pub robust_se: bool,
pub compute_hausman: bool,
}
impl TwoStageLeastSquares {
pub fn new(robust_se: bool) -> Self {
Self {
robust_se,
compute_hausman: true,
}
}
pub fn with_options(robust_se: bool, compute_hausman: bool) -> Self {
Self {
robust_se,
compute_hausman,
}
}
pub fn fit(
&self,
y: &ArrayView1<f64>,
x_endog: &ArrayView2<f64>,
x_exog: &ArrayView2<f64>,
z_excl: &ArrayView2<f64>,
) -> StatsResult<IvResult> {
let n = y.len();
let k_endog = x_endog.ncols();
let k_exog = x_exog.ncols();
let l_excl = z_excl.ncols();
let k_total = k_endog + k_exog;
if n < k_total + 1 {
return Err(StatsError::InsufficientData(format!(
"Need at least {} observations for {} regressors, got {n}",
k_total + 1,
k_total
)));
}
if x_endog.nrows() != n || x_exog.nrows() != n || z_excl.nrows() != n {
return Err(StatsError::DimensionMismatch(
"All matrices must have the same number of rows as y".into(),
));
}
if l_excl < k_endog {
return Err(StatsError::InvalidArgument(format!(
"Need at least {k_endog} excluded instruments for {k_endog} endogenous regressors, got {l_excl}"
)));
}
let l_total = l_excl + k_exog;
let z = build_concat_matrix(z_excl, x_exog, n, l_excl, k_exog);
let x = build_concat_matrix(x_endog, x_exog, n, k_endog, k_exog);
let ztz = z.t().dot(&z);
let ztz_inv = cholesky_invert(&ztz.view())?;
let pz = z.dot(&ztz_inv).dot(&z.t());
let mut x_hat = Array2::<f64>::zeros((n, k_total));
for j in 0..k_endog {
let xj = x.column(j);
let xj_hat = pz.dot(&xj);
for i in 0..n {
x_hat[[i, j]] = xj_hat[i];
}
}
for j in 0..k_exog {
for i in 0..n {
x_hat[[i, k_endog + j]] = x_exog[[i, j]];
}
}
let (f_each, pr2_each) =
compute_first_stage_diagnostics(&z.view(), x_endog, l_excl, k_exog)?;
let avg_f = if f_each.is_empty() {
0.0
} else {
f_each.iter().sum::<f64>() / f_each.len() as f64
};
let df1_fs = l_excl;
let df2_fs = n.saturating_sub(l_total);
let avg_f_p = f_dist_p_value(avg_f, df1_fs, df2_fs);
let xht_x = x_hat.t().dot(&x);
let xht_y = x_hat.t().dot(y);
let xhtx_inv = cholesky_invert(&xht_x.view())?;
let beta = xhtx_inv.dot(&xht_y);
let y_hat = x.dot(&beta);
let residuals = y.to_owned() - &y_hat;
let rss: f64 = residuals.iter().map(|&r| r * r).sum();
let df = (n - k_total) as f64;
if df <= 0.0 {
return Err(StatsError::InsufficientData(
"Insufficient degrees of freedom".into(),
));
}
let s2 = rss / df;
let vcov = if self.robust_se {
let bread = &xhtx_inv;
let scale = n as f64 / df;
let mut meat = Array2::<f64>::zeros((k_total, k_total));
for i in 0..n {
let e2 = residuals[i] * residuals[i];
for r in 0..k_total {
for c in 0..k_total {
meat[[r, c]] += e2 * x_hat[[i, r]] * x_hat[[i, c]];
}
}
}
let inner = bread.dot(&meat).dot(&bread.t());
inner.mapv(|v| v * scale)
} else {
xhtx_inv.mapv(|v| v * s2)
};
let std_errors: Array1<f64> = (0..k_total).map(|i| vcov[[i, i]].max(0.0).sqrt()).collect();
let t_stats: Array1<f64> = (0..k_total)
.map(|i| {
if std_errors[i] > 1e-15 {
beta[i] / std_errors[i]
} else {
0.0
}
})
.collect();
let p_values: Array1<f64> = t_stats.iter().map(|&t| t_dist_p_value(t, df)).collect();
let t_crit = t_critical(0.025, df as usize);
let mut conf_intervals = Array2::<f64>::zeros((k_total, 2));
for i in 0..k_total {
conf_intervals[[i, 0]] = beta[i] - t_crit * std_errors[i];
conf_intervals[[i, 1]] = beta[i] + t_crit * std_errors[i];
}
let (sargan_stat, sargan_p_value) = if l_excl > k_endog {
let (_, resid_z, _) = ols_fit(&z.view(), &residuals.view())?;
let ss_res: f64 = resid_z.iter().map(|&r| r * r).sum();
let ss_tot: f64 = residuals.iter().map(|&r| r * r).sum();
let r2_z = 1.0 - ss_res / ss_tot.max(1e-15);
let j_stat = n as f64 * r2_z;
let j_df = l_excl - k_endog;
(Some(j_stat), Some(chi2_p_value(j_stat.max(0.0), j_df)))
} else {
(None, None)
};
let (wh_stat, wh_p_value) = if self.compute_hausman && k_endog > 0 {
self.wu_hausman_test(y, &x.view(), &x_hat.view(), k_endog, k_total, n)?
} else {
(None, None)
};
let diagnostics = IvDiagnostics {
first_stage_f: avg_f,
first_stage_f_p: avg_f_p,
first_stage_f_each: f_each,
partial_r_squared: pr2_each,
sargan_stat,
sargan_p_value,
wu_hausman_stat: wh_stat,
wu_hausman_p_value: wh_p_value,
instruments_weak: avg_f < 10.0,
};
Ok(IvResult {
coefficients: beta,
std_errors,
t_stats,
p_values,
conf_intervals,
n_obs: n,
n_endog: k_endog,
n_instruments: l_excl,
diagnostics,
rss,
})
}
fn wu_hausman_test(
&self,
y: &ArrayView1<f64>,
x: &ArrayView2<f64>,
x_hat: &ArrayView2<f64>,
k_endog: usize,
k_total: usize,
n: usize,
) -> StatsResult<(Option<f64>, Option<f64>)> {
let mut x_aug = Array2::<f64>::zeros((n, k_total + k_endog));
for i in 0..n {
for j in 0..k_total {
x_aug[[i, j]] = x[[i, j]];
}
for j in 0..k_endog {
x_aug[[i, k_total + j]] = x[[i, j]] - x_hat[[i, j]];
}
}
let (_, resid_u, _) = ols_fit(&x_aug.view(), y)?;
let rss_u: f64 = resid_u.iter().map(|&r| r * r).sum();
let (_, resid_r, _) = ols_fit(x, y)?;
let rss_r: f64 = resid_r.iter().map(|&r| r * r).sum();
let df1 = k_endog;
let df2 = n.saturating_sub(k_total + k_endog);
if df2 == 0 {
return Ok((None, None));
}
let f_stat = ((rss_r - rss_u) / df1 as f64) / (rss_u / df2 as f64).max(1e-15);
let p_val = f_dist_p_value(f_stat.max(0.0), df1, df2);
Ok((Some(f_stat), Some(p_val)))
}
}
fn build_concat_matrix(
a: &ArrayView2<f64>,
b: &ArrayView2<f64>,
n: usize,
ka: usize,
kb: usize,
) -> Array2<f64> {
let mut out = Array2::<f64>::zeros((n, ka + kb));
for i in 0..n {
for j in 0..ka {
out[[i, j]] = a[[i, j]];
}
for j in 0..kb {
out[[i, ka + j]] = b[[i, j]];
}
}
out
}
fn compute_first_stage_diagnostics(
z: &ArrayView2<f64>,
x_endog: &ArrayView2<f64>,
l_excl: usize,
k_exog: usize,
) -> StatsResult<(Vec<f64>, Vec<f64>)> {
let n = x_endog.nrows();
let k_endog = x_endog.ncols();
let l_total = z.ncols();
let mut f_stats = Vec::with_capacity(k_endog);
let mut pr2_vec = Vec::with_capacity(k_endog);
for j in 0..k_endog {
let xj = x_endog.column(j).to_owned();
let (_, resid_u, _) = ols_fit(z, &xj.view())?;
let rss_u: f64 = resid_u.iter().map(|&r| r * r).sum();
let x_exog_only = z.slice(scirs2_core::ndarray::s![.., l_excl..l_total]);
let (_, resid_r, _) = ols_fit(&x_exog_only, &xj.view())?;
let rss_r: f64 = resid_r.iter().map(|&r| r * r).sum();
let df_num = l_excl as f64;
let df_den = (n as f64) - (l_total as f64);
if df_den <= 0.0 || df_num <= 0.0 {
f_stats.push(0.0);
pr2_vec.push(0.0);
continue;
}
let f_stat = ((rss_r - rss_u) / df_num) / (rss_u / df_den).max(1e-15);
let partial_r2 = if rss_r > 1e-15 {
(rss_r - rss_u) / rss_r
} else {
0.0
};
f_stats.push(f_stat.max(0.0));
pr2_vec.push(partial_r2.clamp(0.0, 1.0));
}
Ok((f_stats, pr2_vec))
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array1;
fn ones_col(n: usize) -> Array2<f64> {
Array2::ones((n, 1))
}
#[test]
fn test_2sls_consistent_estimate() {
let n = 200_usize;
let z_vals: Array1<f64> = (0..n).map(|i| (i as f64) / 20.0).collect();
let x_vals: Array1<f64> = z_vals.mapv(|z| 0.8 * z + 0.1);
let y_vals: Array1<f64> = x_vals.mapv(|x| 2.0 * x + 0.5);
let mut x_endog = Array2::<f64>::zeros((n, 1));
for i in 0..n {
x_endog[[i, 0]] = x_vals[i];
}
let x_exog = ones_col(n);
let mut z_excl = Array2::<f64>::zeros((n, 1));
for i in 0..n {
z_excl[[i, 0]] = z_vals[i];
}
let est = TwoStageLeastSquares::new(false);
let res = est
.fit(
&y_vals.view(),
&x_endog.view(),
&x_exog.view(),
&z_excl.view(),
)
.expect("2SLS should succeed");
assert!(
(res.coefficients[0] - 2.0).abs() < 0.05,
"Expected beta ~2.0, got {}",
res.coefficients[0]
);
assert!(
(res.coefficients[1] - 0.5).abs() < 0.1,
"Expected intercept ~0.5, got {}",
res.coefficients[1]
);
}
#[test]
fn test_2sls_strong_instrument_f_stat() {
let n = 200_usize;
let z_vals: Array1<f64> = (0..n).map(|i| (i as f64) / 10.0).collect();
let x_vals: Array1<f64> = z_vals.mapv(|z| 0.9 * z + 0.05);
let y_vals: Array1<f64> = x_vals.mapv(|x| 3.0 * x + 1.0);
let mut x_endog = Array2::<f64>::zeros((n, 1));
for i in 0..n {
x_endog[[i, 0]] = x_vals[i];
}
let x_exog = ones_col(n);
let mut z_excl = Array2::<f64>::zeros((n, 1));
for i in 0..n {
z_excl[[i, 0]] = z_vals[i];
}
let est = TwoStageLeastSquares::new(true);
let res = est
.fit(
&y_vals.view(),
&x_endog.view(),
&x_exog.view(),
&z_excl.view(),
)
.expect("2SLS should succeed");
assert!(
res.diagnostics.first_stage_f > 10.0,
"F-stat should be > 10 for strong instrument, got {}",
res.diagnostics.first_stage_f
);
assert!(
!res.diagnostics.instruments_weak,
"Instruments should not be flagged as weak"
);
}
#[test]
fn test_2sls_weak_instrument_warning() {
let n = 100_usize;
let z_vals: Array1<f64> = (0..n).map(|i| i as f64).collect();
let x_vals: Array1<f64> = z_vals.mapv(|z| 0.001 * z + 100.0);
let y_vals: Array1<f64> = x_vals.mapv(|x| x + 1.0);
let mut x_endog = Array2::<f64>::zeros((n, 1));
for i in 0..n {
x_endog[[i, 0]] = x_vals[i];
}
let x_exog = ones_col(n);
let mut z_excl = Array2::<f64>::zeros((n, 1));
for i in 0..n {
z_excl[[i, 0]] = z_vals[i];
}
let est = TwoStageLeastSquares::new(false);
let res = est
.fit(
&y_vals.view(),
&x_endog.view(),
&x_exog.view(),
&z_excl.view(),
)
.expect("2SLS should succeed even with weak instrument");
assert!(res.diagnostics.first_stage_f >= 0.0);
assert!(res.diagnostics.partial_r_squared.len() == 1);
}
#[test]
fn test_2sls_overidentification_sargan() {
let n = 150_usize;
let z1: Array1<f64> = (0..n).map(|i| (i as f64) / 10.0).collect();
let z2: Array1<f64> = (0..n).map(|i| ((i as f64) / 10.0).powi(2) * 0.1).collect();
let x_vals: Array1<f64> = Array1::from_shape_fn(n, |i| 0.5 * z1[i] + 0.3 * z2[i]);
let y_vals: Array1<f64> = x_vals.mapv(|x| 2.5 * x + 1.0);
let mut x_endog = Array2::<f64>::zeros((n, 1));
for i in 0..n {
x_endog[[i, 0]] = x_vals[i];
}
let x_exog = ones_col(n);
let mut z_excl = Array2::<f64>::zeros((n, 2));
for i in 0..n {
z_excl[[i, 0]] = z1[i];
z_excl[[i, 1]] = z2[i];
}
let est = TwoStageLeastSquares::new(false);
let res = est
.fit(
&y_vals.view(),
&x_endog.view(),
&x_exog.view(),
&z_excl.view(),
)
.expect("2SLS should succeed");
assert!(
res.diagnostics.sargan_stat.is_some(),
"Should have Sargan stat"
);
assert!(
res.diagnostics.sargan_p_value.is_some(),
"Should have Sargan p-value"
);
assert!(res.n_instruments == 2);
}
#[test]
fn test_2sls_wu_hausman_no_endogeneity() {
let n = 100_usize;
let x_vals: Array1<f64> = (0..n).map(|i| (i as f64) / 10.0).collect();
let y_vals: Array1<f64> = x_vals.mapv(|x| 1.5 * x + 2.0);
let z_vals: Array1<f64> = x_vals.mapv(|x| x * 1.1);
let mut x_endog = Array2::<f64>::zeros((n, 1));
for i in 0..n {
x_endog[[i, 0]] = x_vals[i];
}
let x_exog = ones_col(n);
let mut z_excl = Array2::<f64>::zeros((n, 1));
for i in 0..n {
z_excl[[i, 0]] = z_vals[i];
}
let est = TwoStageLeastSquares::with_options(false, true);
let res = est
.fit(
&y_vals.view(),
&x_endog.view(),
&x_exog.view(),
&z_excl.view(),
)
.expect("2SLS should succeed");
assert!(res.diagnostics.wu_hausman_stat.is_some());
assert!(res.diagnostics.wu_hausman_p_value.is_some());
}
#[test]
fn test_2sls_robust_se() {
let n = 100_usize;
let z_vals: Array1<f64> = (0..n).map(|i| (i as f64) / 10.0).collect();
let x_vals: Array1<f64> = z_vals.mapv(|z| 0.7 * z);
let y_vals: Array1<f64> = x_vals.mapv(|x| 2.0 * x + 1.0);
let mut x_endog = Array2::<f64>::zeros((n, 1));
for i in 0..n {
x_endog[[i, 0]] = x_vals[i];
}
let x_exog = ones_col(n);
let mut z_excl = Array2::<f64>::zeros((n, 1));
for i in 0..n {
z_excl[[i, 0]] = z_vals[i];
}
let est_robust = TwoStageLeastSquares::new(true);
let res_robust = est_robust
.fit(
&y_vals.view(),
&x_endog.view(),
&x_exog.view(),
&z_excl.view(),
)
.expect("Robust 2SLS should succeed");
let est_homo = TwoStageLeastSquares::new(false);
let res_homo = est_homo
.fit(
&y_vals.view(),
&x_endog.view(),
&x_exog.view(),
&z_excl.view(),
)
.expect("Homoscedastic 2SLS should succeed");
assert!(
(res_robust.coefficients[0] - res_homo.coefficients[0]).abs() < 1e-6,
"Point estimates should be identical"
);
}
}