use crate::optim::lbfgs::LbfgsOptimizer;
use ferrolearn_core::error::FerroError;
use ferrolearn_core::introspection::HasCoefficients;
use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
use ferrolearn_core::traits::{Fit, Predict};
use ndarray::{Array1, Array2, ScalarOperand};
use num_traits::{Float, FromPrimitive};
#[inline]
fn cast<F: Float + FromPrimitive>(v: f64) -> F {
F::from(v).unwrap_or_else(F::epsilon)
}
fn robust_scale<F: Float + FromPrimitive>(y: &Array1<F>) -> F {
let n = y.len();
if n == 0 {
return F::zero();
}
let median = |v: &mut [F]| -> F {
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let m = v.len();
if m % 2 == 1 {
v[m / 2]
} else {
(v[m / 2 - 1] + v[m / 2]) / cast::<F>(2.0)
}
};
let mut vals: Vec<F> = y.iter().copied().collect();
let med = median(&mut vals);
let mut dev: Vec<F> = y.iter().map(|&yi| (yi - med).abs()).collect();
let mad = median(&mut dev);
cast::<F>(1.4826) * mad
}
fn gauss_solve<F: Float + FromPrimitive>(a: &Array2<F>, b: &Array1<F>) -> Option<Array1<F>> {
let p = b.len();
let mut aug = Array2::<F>::zeros((p, p + 1));
for r in 0..p {
for c in 0..p {
aug[[r, c]] = a[[r, c]];
}
aug[[r, p]] = b[r];
}
for col in 0..p {
let mut piv = col;
let mut best = aug[[col, col]].abs();
for row in (col + 1)..p {
let v = aug[[row, col]].abs();
if v > best {
best = v;
piv = row;
}
}
if best <= cast::<F>(1e-30) {
return None;
}
if piv != col {
for c in 0..=p {
let t = aug[[col, c]];
aug[[col, c]] = aug[[piv, c]];
aug[[piv, c]] = t;
}
}
let pivot = aug[[col, col]];
for row in (col + 1)..p {
let factor = aug[[row, col]] / pivot;
for c in col..=p {
let above = aug[[col, c]];
aug[[row, c]] = aug[[row, c]] - factor * above;
}
}
}
let mut beta = Array1::<F>::zeros(p);
for i in (0..p).rev() {
let mut s = aug[[i, p]];
for j in (i + 1)..p {
s = s - aug[[i, j]] * beta[j];
}
let diag = aug[[i, i]];
if diag.abs() <= cast::<F>(1e-30) {
return None;
}
beta[i] = s / diag;
}
Some(beta)
}
fn irls_warm_start<F: Float + FromPrimitive>(
x: &Array2<F>,
y: &Array1<F>,
epsilon: F,
fit_intercept: bool,
) -> Option<(Array1<F>, F, F)> {
let (n_samples, n_features) = x.dim();
let p = n_features + usize::from(fit_intercept);
if p == 0 || n_samples == 0 {
return None;
}
let mut beta = Array1::<F>::zeros(p);
let mut sigma = robust_scale(y).max(cast::<F>(1e-3));
for _ in 0..8 {
let mut resid = Array1::<F>::zeros(n_samples);
for i in 0..n_samples {
let xi = x.row(i);
let mut pred = if fit_intercept {
beta[n_features]
} else {
F::zero()
};
for k in 0..n_features {
pred = pred + beta[k] * xi[k];
}
resid[i] = y[i] - pred;
}
sigma = robust_scale(&resid).max(cast::<F>(1e-3));
let band = epsilon * sigma;
let mut ata = Array2::<F>::zeros((p, p));
let mut aty = Array1::<F>::zeros(p);
for i in 0..n_samples {
let xi = x.row(i);
let ar = resid[i].abs();
let w = if ar <= band {
F::one()
} else {
(band / ar).max(cast::<F>(1e-10))
};
let yi = y[i];
for r in 0..p {
let zr = if r < n_features { xi[r] } else { F::one() };
aty[r] = aty[r] + w * zr * yi;
for c in 0..p {
let zc = if c < n_features { xi[c] } else { F::one() };
ata[[r, c]] = ata[[r, c]] + w * zr * zc;
}
}
}
for d in 0..p {
ata[[d, d]] = ata[[d, d]] + cast::<F>(1e-8);
}
beta = gauss_solve(&ata, &aty)?;
}
let coef = beta.slice(ndarray::s![..n_features]).to_owned();
let intercept = if fit_intercept {
beta[n_features]
} else {
F::zero()
};
Some((coef, intercept, sigma.max(cast::<F>(1e-3))))
}
#[derive(Debug, Clone)]
pub struct HuberRegressor<F> {
pub epsilon: F,
pub alpha: F,
pub max_iter: usize,
pub tol: F,
pub fit_intercept: bool,
pub warm_start: bool,
pub warm_start_state: Option<(Array1<F>, F, F)>,
}
impl<F: Float + FromPrimitive> HuberRegressor<F> {
#[must_use]
pub fn new() -> Self {
Self {
epsilon: cast(1.35),
alpha: cast(1e-4),
max_iter: 100,
tol: cast(1e-5),
fit_intercept: true,
warm_start: false,
warm_start_state: None,
}
}
#[must_use]
pub fn with_epsilon(mut self, epsilon: F) -> Self {
self.epsilon = epsilon;
self
}
#[must_use]
pub fn with_alpha(mut self, alpha: F) -> Self {
self.alpha = alpha;
self
}
#[must_use]
pub fn with_max_iter(mut self, max_iter: usize) -> Self {
self.max_iter = max_iter;
self
}
#[must_use]
pub fn with_tol(mut self, tol: F) -> Self {
self.tol = tol;
self
}
#[must_use]
pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
self.fit_intercept = fit_intercept;
self
}
#[must_use]
pub fn with_warm_start(mut self, warm_start: bool) -> Self {
self.warm_start = warm_start;
self
}
#[must_use]
pub fn with_warm_start_state(mut self, coef: Array1<F>, intercept: F, scale: F) -> Self {
self.warm_start_state = Some((coef, intercept, scale));
self
}
}
impl<F: Float + FromPrimitive> Default for HuberRegressor<F> {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct FittedHuberRegressor<F> {
coefficients: Array1<F>,
intercept: F,
scale: F,
outliers: Array1<bool>,
n_iter: usize,
}
fn huber_loss_and_gradient<F: Float + FromPrimitive + ScalarOperand + 'static>(
params: &Array1<F>,
x: &Array2<F>,
y: &Array1<F>,
sample_weight: &Array1<F>,
epsilon: F,
alpha: F,
fit_intercept: bool,
) -> (F, Array1<F>) {
let (n_samples, n_features) = x.dim();
let two = cast::<F>(2.0);
let sigma_floor = cast::<F>(f64::EPSILON * 10.0);
let log_sigma = params[params.len() - 1];
let sigma_raw = log_sigma.exp();
let clamped = sigma_raw < sigma_floor;
let sigma = if clamped { sigma_floor } else { sigma_raw };
let intercept = if fit_intercept {
params[n_features]
} else {
F::zero()
};
let coef = params.slice(ndarray::s![..n_features]).to_owned();
let mut linear_loss = y - &x.dot(&coef);
if fit_intercept {
linear_loss.mapv_inplace(|v| v - intercept);
}
let threshold = epsilon * sigma;
let eps2 = epsilon * epsilon;
let mut grad = Array1::<F>::zeros(params.len());
let mut squared_loss = F::zero(); let mut outlier_abs_sum = F::zero(); let mut num_outliers = F::zero(); let mut sum_inlier_r = F::zero(); let mut sum_signed_outliers = F::zero();
let mut n_sw = F::zero(); for i in 0..n_samples {
let sw = sample_weight[i];
n_sw = n_sw + sw;
let r = linear_loss[i];
let abs_r = r.abs();
let xi = x.row(i);
if abs_r > threshold {
let sign = if r < F::zero() { -F::one() } else { F::one() };
for k in 0..n_features {
grad[k] = grad[k] - two * epsilon * sw * sign * xi[k];
}
outlier_abs_sum = outlier_abs_sum + sw * abs_r;
num_outliers = num_outliers + sw;
sum_signed_outliers = sum_signed_outliers + sw * sign;
} else {
let wr = sw * r;
let g = -(two / sigma) * wr;
for k in 0..n_features {
grad[k] = grad[k] + g * xi[k];
}
squared_loss = squared_loss + wr * r;
sum_inlier_r = sum_inlier_r + wr;
}
}
for k in 0..n_features {
grad[k] = grad[k] + two * alpha * coef[k];
}
let n = n_sw;
let squared_loss_over_sigma = squared_loss / sigma;
let grad_sigma = n - num_outliers * eps2 - squared_loss_over_sigma / sigma;
let last = params.len() - 1;
grad[last] = if clamped {
F::zero()
} else {
sigma * grad_sigma
};
if fit_intercept {
grad[n_features] = -(two * sum_inlier_r) / sigma - two * epsilon * sum_signed_outliers;
}
let outlier_loss = two * epsilon * outlier_abs_sum - sigma * num_outliers * eps2;
let penalty = alpha * coef.dot(&coef);
let loss = n * sigma + squared_loss_over_sigma + outlier_loss + penalty;
(loss, grad)
}
impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array1<F>>
for HuberRegressor<F>
{
type Fitted = FittedHuberRegressor<F>;
type Error = FerroError;
fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedHuberRegressor<F>, FerroError> {
self.fit_with_sample_weight(x, y, None)
}
}
impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> HuberRegressor<F> {
pub fn fit_with_sample_weight(
&self,
x: &Array2<F>,
y: &Array1<F>,
sample_weight: Option<&Array1<F>>,
) -> Result<FittedHuberRegressor<F>, FerroError> {
let (n_samples, n_features) = x.dim();
if n_samples != y.len() {
return Err(FerroError::ShapeMismatch {
expected: vec![n_samples],
actual: vec![y.len()],
context: "y length must match number of samples in X".into(),
});
}
if self.epsilon <= F::one() {
return Err(FerroError::InvalidParameter {
name: "epsilon".into(),
reason: "must be strictly greater than 1.0".into(),
});
}
if self.alpha < F::zero() {
return Err(FerroError::InvalidParameter {
name: "alpha".into(),
reason: "must be non-negative".into(),
});
}
if n_samples == 0 {
return Err(FerroError::InsufficientSamples {
required: 1,
actual: 0,
context: "HuberRegressor requires at least one sample".into(),
});
}
if x.iter().any(|v| !v.is_finite()) {
return Err(FerroError::InvalidParameter {
name: "X".into(),
reason: "Input X contains NaN or infinity.".into(),
});
}
if y.iter().any(|v| !v.is_finite()) {
return Err(FerroError::InvalidParameter {
name: "y".into(),
reason: "Input y contains NaN or infinity.".into(),
});
}
let weights: Array1<F> = match sample_weight {
None => Array1::<F>::ones(n_samples),
Some(w) => {
if w.len() != n_samples {
return Err(FerroError::ShapeMismatch {
expected: vec![n_samples],
actual: vec![w.len()],
context: "sample_weight length must match number of samples in X".into(),
});
}
if w.iter().any(|v| !v.is_finite()) {
return Err(FerroError::InvalidParameter {
name: "sample_weight".into(),
reason: "Input sample_weight contains NaN or infinity.".into(),
});
}
w.clone()
}
};
let n_params = n_features + usize::from(self.fit_intercept) + 1;
let mut x0 = Array1::<F>::zeros(n_params);
let warm = if self.warm_start {
self.warm_start_state.as_ref()
} else {
None
};
if let Some((coef0, intercept0, scale0)) = warm {
if coef0.len() != n_features {
return Err(FerroError::ShapeMismatch {
expected: vec![n_features],
actual: vec![coef0.len()],
context: "warm_start coef length must equal number of features".into(),
});
}
if *scale0 <= F::zero() {
return Err(FerroError::InvalidParameter {
name: "warm_start_state.scale".into(),
reason: "scale must be strictly positive".into(),
});
}
for k in 0..n_features {
x0[k] = coef0[k];
}
if self.fit_intercept {
x0[n_features] = *intercept0;
}
x0[n_params - 1] = scale0.ln();
} else if let Some((coef0, intercept0, sigma0)) =
irls_warm_start(x, y, self.epsilon, self.fit_intercept)
{
for k in 0..n_features {
x0[k] = coef0[k];
}
if self.fit_intercept {
x0[n_features] = intercept0;
}
x0[n_params - 1] = sigma0.max(cast::<F>(0.1)).ln();
} else {
x0[n_params - 1] = robust_scale(y).max(cast::<F>(1.0)).ln();
}
let epsilon = self.epsilon;
let alpha = self.alpha;
let fit_intercept = self.fit_intercept;
let optimizer = LbfgsOptimizer::<F>::new(self.max_iter, self.tol);
let (params, n_iter) = optimizer.minimize_reporting(
|p| huber_loss_and_gradient(p, x, y, &weights, epsilon, alpha, fit_intercept),
x0,
)?;
let coefficients = params.slice(ndarray::s![..n_features]).to_owned();
let intercept = if self.fit_intercept {
params[n_features]
} else {
F::zero()
};
let sigma_floor = cast::<F>(f64::EPSILON * 10.0);
let scale = params[params.len() - 1].exp().max(sigma_floor);
let mut residual = y - &x.dot(&coefficients);
residual.mapv_inplace(|v| (v - intercept).abs());
let band = scale * self.epsilon;
let outliers = residual.mapv(|r| r > band);
Ok(FittedHuberRegressor {
coefficients,
intercept,
scale,
outliers,
n_iter,
})
}
}
impl<F: Float + Send + Sync + ScalarOperand + 'static> FittedHuberRegressor<F> {
#[must_use]
pub fn scale(&self) -> F {
self.scale
}
#[must_use]
pub fn outliers(&self) -> &Array1<bool> {
&self.outliers
}
#[must_use]
pub fn n_iter(&self) -> usize {
self.n_iter
}
}
impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
for FittedHuberRegressor<F>
{
type Output = Array1<F>;
type Error = FerroError;
fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
let n_features = x.ncols();
if n_features != self.coefficients.len() {
return Err(FerroError::ShapeMismatch {
expected: vec![self.coefficients.len()],
actual: vec![n_features],
context: "number of features must match fitted model".into(),
});
}
let preds = x.dot(&self.coefficients) + self.intercept;
Ok(preds)
}
}
impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
for FittedHuberRegressor<F>
{
fn coefficients(&self) -> &Array1<F> {
&self.coefficients
}
fn intercept(&self) -> F {
self.intercept
}
}
impl<F> PipelineEstimator<F> for HuberRegressor<F>
where
F: Float + FromPrimitive + ScalarOperand + Send + Sync + 'static,
{
fn fit_pipeline(
&self,
x: &Array2<F>,
y: &Array1<F>,
) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
let fitted = self.fit(x, y)?;
Ok(Box::new(fitted))
}
}
impl<F> FittedPipelineEstimator<F> for FittedHuberRegressor<F>
where
F: Float + ScalarOperand + Send + Sync + 'static,
{
fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
self.predict(x)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use ndarray::array;
#[test]
fn test_default_constructor() {
let m = HuberRegressor::<f64>::new();
assert_relative_eq!(m.epsilon, 1.35);
assert_relative_eq!(m.alpha, 1e-4);
assert_eq!(m.max_iter, 100);
assert!(m.fit_intercept);
}
#[test]
fn test_builder_setters() {
let m = HuberRegressor::<f64>::new()
.with_epsilon(2.0)
.with_alpha(0.1)
.with_max_iter(50)
.with_tol(1e-6)
.with_fit_intercept(false);
assert_relative_eq!(m.epsilon, 2.0);
assert_relative_eq!(m.alpha, 0.1);
assert_eq!(m.max_iter, 50);
assert!(!m.fit_intercept);
}
#[test]
fn test_epsilon_too_small_error() {
let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
let y = array![1.0, 2.0, 3.0];
let result = HuberRegressor::<f64>::new().with_epsilon(0.5).fit(&x, &y);
assert!(result.is_err());
}
#[test]
fn test_epsilon_exactly_one_error() {
let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
let y = array![1.0, 2.0, 3.0];
let result = HuberRegressor::<f64>::new().with_epsilon(1.0).fit(&x, &y);
assert!(result.is_err());
}
#[test]
fn test_negative_alpha_error() {
let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
let y = array![1.0, 2.0, 3.0];
let result = HuberRegressor::<f64>::new().with_alpha(-1.0).fit(&x, &y);
assert!(result.is_err());
}
#[test]
fn test_shape_mismatch_error() {
let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
let y = array![1.0, 2.0];
let result = HuberRegressor::<f64>::new().fit(&x, &y);
assert!(result.is_err());
}
#[test]
fn test_fits_clean_linear_data() {
let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
let fitted = HuberRegressor::<f64>::new()
.with_alpha(0.0)
.fit(&x, &y)
.unwrap();
assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 0.1);
assert_relative_eq!(fitted.intercept(), 1.0, epsilon = 0.5);
}
#[test]
fn test_robust_to_outliers() {
let x = Array2::from_shape_vec(
(12, 1),
vec![
1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
],
)
.unwrap();
let y_clean = array![
3.3381, 4.9068, 7.0066, 9.0815, 10.8422, 13.0004, 14.9998, 16.6491, 19.2035, 21.1201,
22.8749, 24.9657
];
let y_outlier = array![
3.3381, 4.9068, 7.0066, 9.0815, 10.8422, 13.0004, 14.9998, 16.6491, 19.2035, 21.1201,
22.8749, 200.0
];
let fitted_clean = HuberRegressor::<f64>::new()
.with_alpha(0.0)
.with_max_iter(200)
.fit(&x, &y_clean)
.unwrap();
let fitted_huber = HuberRegressor::<f64>::new()
.with_alpha(0.0)
.with_max_iter(200)
.fit(&x, &y_outlier)
.unwrap();
let ols_coef = {
let n = 12.0_f64;
let xv: Vec<f64> = (1..=12).map(f64::from).collect();
let yv = vec![
3.3381, 4.9068, 7.0066, 9.0815, 10.8422, 13.0004, 14.9998, 16.6491, 19.2035,
21.1201, 22.8749, 200.0,
];
let xmean = xv.iter().sum::<f64>() / n;
let ymean = yv.iter().sum::<f64>() / n;
let num: f64 = xv
.iter()
.zip(yv.iter())
.map(|(xi, yi)| xi * yi)
.sum::<f64>()
- n * xmean * ymean;
let den: f64 = xv.iter().map(|xi| xi * xi).sum::<f64>() - n * xmean * xmean;
num / den
};
let huber_coef = fitted_huber.coefficients()[0];
let clean_coef = fitted_clean.coefficients()[0];
let huber_err = (huber_coef - clean_coef).abs();
let ols_err = (ols_coef - clean_coef).abs();
assert!(
huber_err < ols_err,
"Huber error {huber_err:.4} should be less than OLS error {ols_err:.4} \
(huber coef={huber_coef:.4}, ols coef={ols_coef:.4}, clean coef={clean_coef:.4})"
);
}
#[test]
fn test_no_intercept() {
let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
let y = array![2.0, 4.0, 6.0, 8.0];
let fitted = HuberRegressor::<f64>::new()
.with_alpha(0.0)
.with_fit_intercept(false)
.fit(&x, &y)
.unwrap();
assert_relative_eq!(fitted.intercept(), 0.0, epsilon = 1e-10);
}
#[test]
fn test_predict_length() {
let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
let fitted = HuberRegressor::<f64>::new().fit(&x, &y).unwrap();
let preds = fitted.predict(&x).unwrap();
assert_eq!(preds.len(), 5);
}
#[test]
fn test_predict_feature_mismatch() {
let x = Array2::from_shape_vec(
(6, 2),
vec![
0.4412, -0.3309, 2.4308, -0.2521, 0.1096, 1.5825, -0.9092, -0.5916, 0.1876,
-0.3299, -1.1928, -0.2049,
],
)
.unwrap();
let y = array![-0.2923, 2.0473, 2.9416, -2.2325, -0.2419, -1.2311];
let fitted = HuberRegressor::<f64>::new().fit(&x, &y).unwrap();
let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
assert!(fitted.predict(&x_bad).is_err());
}
#[test]
fn test_has_coefficients_length() {
let x = Array2::from_shape_vec(
(8, 3),
vec![
1.7886, 0.4365, 0.0965, -1.8635, -0.2774, -0.3548, -0.0827, -0.627, -0.0438,
-0.4772, -1.3139, 0.8846, 0.8813, 1.7096, 0.05, -0.4047, -0.5454, -1.5465, 0.9824,
-1.1011, -1.185, -0.2056, 1.4861, 0.2367,
],
)
.unwrap();
let y = array![
2.258, -2.2774, -1.1054, -4.0377, 4.0198, -0.0179, 0.1888, 3.1228
];
let fitted = HuberRegressor::<f64>::new().fit(&x, &y).unwrap();
assert_eq!(fitted.coefficients().len(), 3);
}
#[test]
fn test_scale_positive() {
let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
let fitted = HuberRegressor::<f64>::new().fit(&x, &y).unwrap();
assert!(fitted.scale() > 0.0, "scale must be strictly positive");
}
#[test]
fn test_outliers_mask_length_and_band() {
let x =
Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
let y = array![
3.3499, 4.9428, 6.9031, 8.4693, 10.9983, 12.9361, 14.8927, 25.0
];
let m = HuberRegressor::<f64>::new();
let fitted = m.fit(&x, &y).unwrap();
let outliers = fitted.outliers();
assert_eq!(outliers.len(), 8);
let preds = fitted.predict(&x).unwrap();
let band = fitted.scale() * m.epsilon;
for i in 0..8 {
let resid = (y[i] - preds[i]).abs();
assert_eq!(outliers[i], resid > band, "outliers mask mismatch at {i}");
}
assert!(outliers[7], "large outlier must be flagged");
}
#[test]
fn test_large_epsilon_approaches_ols() {
let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
let fitted = HuberRegressor::<f64>::new()
.with_epsilon(1000.0)
.with_alpha(0.0)
.fit(&x, &y)
.unwrap();
assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 0.1);
assert_relative_eq!(fitted.intercept(), 1.0, epsilon = 0.5);
}
#[test]
fn test_l2_regularization_shrinks_coefficients() {
let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
let low = HuberRegressor::<f64>::new()
.with_alpha(0.0001)
.fit(&x, &y)
.unwrap();
let high = HuberRegressor::<f64>::new()
.with_alpha(100.0)
.fit(&x, &y)
.unwrap();
assert!(
high.coefficients()[0].abs() <= low.coefficients()[0].abs(),
"higher alpha should shrink more"
);
}
#[test]
fn test_pipeline_integration() {
let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
let y = array![3.0, 5.0, 7.0, 9.0];
let model = HuberRegressor::<f64>::new();
let fitted_pipe = model.fit_pipeline(&x, &y).unwrap();
let preds = fitted_pipe.predict_pipeline(&x).unwrap();
assert_eq!(preds.len(), 4);
}
#[test]
fn test_multivariate() {
let x = Array2::from_shape_vec(
(8, 2),
vec![
-1.2441, -0.6264, -0.8038, -2.4191, -0.9238, -1.0239, 1.124, -0.1319, -1.6233,
0.6467, -0.3563, -1.7431, -0.5966, -0.5886, -0.8739, 0.0297,
],
)
.unwrap();
let y = array![
-3.1714, -5.7223, -2.6676, 1.116, 0.0025, -3.5067, -1.3276, -1.1499
];
let fitted = HuberRegressor::<f64>::new()
.with_alpha(0.0)
.fit(&x, &y)
.unwrap();
let preds = fitted.predict(&x).unwrap();
assert_eq!(preds.len(), 8);
}
}