use crate::error::PramanaError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinearModel {
pub slope: f64,
pub intercept: f64,
pub r_squared: f64,
}
#[must_use = "returns the fitted model"]
pub fn linear_regression(x: &[f64], y: &[f64]) -> Result<LinearModel, PramanaError> {
if x.len() != y.len() {
return Err(PramanaError::DimensionMismatch(
"x and y must have the same length".into(),
));
}
if x.len() < 2 {
return Err(PramanaError::InvalidSample(
"need at least 2 data points".into(),
));
}
let n = x.len() as f64;
let sum_x: f64 = x.iter().sum();
let sum_y: f64 = y.iter().sum();
let sum_xy: f64 = x.iter().zip(y.iter()).map(|(&xi, &yi)| xi * yi).sum();
let sum_x2: f64 = x.iter().map(|&xi| xi * xi).sum();
let mean_x = sum_x / n;
let mean_y = sum_y / n;
let denom = sum_x2 - sum_x * sum_x / n;
if denom.abs() < 1e-30 {
return Err(PramanaError::InvalidSample(
"zero variance in x (all x values are equal)".into(),
));
}
let slope = (sum_xy - sum_x * sum_y / n) / denom;
let intercept = mean_y - slope * mean_x;
let ss_tot: f64 = y.iter().map(|&yi| (yi - mean_y).powi(2)).sum();
let ss_res: f64 = x
.iter()
.zip(y.iter())
.map(|(&xi, &yi)| (yi - (slope * xi + intercept)).powi(2))
.sum();
let r_squared = if ss_tot.abs() < 1e-30 {
1.0
} else {
1.0 - ss_res / ss_tot
};
Ok(LinearModel {
slope,
intercept,
r_squared,
})
}
#[must_use]
#[inline]
pub fn predict(model: &LinearModel, x: f64) -> f64 {
model.slope * x + model.intercept
}
#[must_use = "returns the residual vector"]
pub fn residuals(model: &LinearModel, x: &[f64], y: &[f64]) -> Result<Vec<f64>, PramanaError> {
if x.len() != y.len() {
return Err(PramanaError::DimensionMismatch(
"x and y must have the same length".into(),
));
}
Ok(x.iter()
.zip(y.iter())
.map(|(&xi, &yi)| yi - predict(model, xi))
.collect())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolynomialModel {
pub coefficients: Vec<f64>,
pub degree: usize,
pub r_squared: f64,
}
#[must_use = "returns the fitted model"]
pub fn polynomial_regression(
x: &[f64],
y: &[f64],
degree: usize,
) -> Result<PolynomialModel, PramanaError> {
if x.len() != y.len() {
return Err(PramanaError::DimensionMismatch(
"x and y must have the same length".into(),
));
}
if degree == 0 {
return Err(PramanaError::InvalidParameter(
"degree must be at least 1".into(),
));
}
let n = degree + 1;
if x.len() < n {
return Err(PramanaError::InvalidSample(format!(
"need at least {} data points for degree {degree}",
n
)));
}
let coefficients = hisab::num::least_squares_poly(x, y, degree)
.map_err(|e| PramanaError::ComputationError(format!("polynomial fit failed: {e}")))?;
let mean_y: f64 = y.iter().sum::<f64>() / y.len() as f64;
let ss_tot: f64 = y.iter().map(|&yi| (yi - mean_y).powi(2)).sum();
let ss_res: f64 = x
.iter()
.zip(y.iter())
.map(|(&xi, &yi)| {
let pred = eval_polynomial(&coefficients, xi);
(yi - pred).powi(2)
})
.sum();
let r_squared = if ss_tot.abs() < 1e-30 {
1.0
} else {
1.0 - ss_res / ss_tot
};
Ok(PolynomialModel {
coefficients,
degree,
r_squared,
})
}
#[must_use]
#[inline]
pub fn predict_poly(model: &PolynomialModel, x: f64) -> f64 {
eval_polynomial(&model.coefficients, x)
}
#[must_use = "returns the residual vector"]
pub fn residuals_poly(
model: &PolynomialModel,
x: &[f64],
y: &[f64],
) -> Result<Vec<f64>, PramanaError> {
if x.len() != y.len() {
return Err(PramanaError::DimensionMismatch(
"x and y must have the same length".into(),
));
}
Ok(x.iter()
.zip(y.iter())
.map(|(&xi, &yi)| yi - predict_poly(model, xi))
.collect())
}
#[must_use]
#[inline]
fn eval_polynomial(coeffs: &[f64], x: f64) -> f64 {
let mut result = 0.0;
for &c in coeffs.iter().rev() {
result = result * x + c;
}
result
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogisticModel {
pub coefficients: Vec<f64>,
pub iterations: usize,
pub converged: bool,
}
#[must_use = "returns the fitted model"]
pub fn logistic_regression(
features: &[Vec<f64>],
labels: &[f64],
l2_reg: f64,
max_iter: usize,
) -> Result<LogisticModel, PramanaError> {
let n = features.len();
if n != labels.len() {
return Err(PramanaError::DimensionMismatch(
"features and labels must have the same length".into(),
));
}
if n < 2 {
return Err(PramanaError::InvalidSample(
"need at least 2 data points".into(),
));
}
if max_iter == 0 {
return Err(PramanaError::InvalidParameter(
"max_iter must be positive".into(),
));
}
if l2_reg < 0.0 {
return Err(PramanaError::InvalidParameter(
"l2_reg must be non-negative".into(),
));
}
let p = features[0].len();
for (i, row) in features.iter().enumerate() {
if row.len() != p {
return Err(PramanaError::DimensionMismatch(format!(
"feature row {i} has length {}, expected {p}",
row.len()
)));
}
}
for (i, &label) in labels.iter().enumerate() {
if label != 0.0 && label != 1.0 {
return Err(PramanaError::InvalidSample(format!(
"label[{i}] = {label}, expected 0 or 1"
)));
}
}
let dim = p + 1;
let x: Vec<Vec<f64>> = features
.iter()
.map(|row| {
let mut xrow = Vec::with_capacity(dim);
xrow.push(1.0);
xrow.extend_from_slice(row);
xrow
})
.collect();
let mut beta = vec![0.0; dim];
let tol = 1e-8;
let mut converged = false;
let mut iter = 0;
for _ in 0..max_iter {
iter += 1;
let probs: Vec<f64> = x
.iter()
.map(|xi| {
let z: f64 = xi.iter().zip(&beta).map(|(xij, bj)| xij * bj).sum();
sigmoid(z)
})
.collect();
let mut gradient = vec![0.0; dim];
for (i, xi) in x.iter().enumerate() {
let residual = labels[i] - probs[i];
for (j, &xij) in xi.iter().enumerate() {
gradient[j] += xij * residual;
}
}
for j in 1..dim {
gradient[j] -= l2_reg * beta[j];
}
let mut neg_hessian = vec![vec![0.0; dim]; dim];
for (i, xi) in x.iter().enumerate() {
let w = probs[i] * (1.0 - probs[i]);
for (j, &xij) in xi.iter().enumerate() {
for (k, &xik) in xi.iter().enumerate().skip(j) {
let val = xij * w * xik;
neg_hessian[j][k] += val;
if k != j {
neg_hessian[k][j] += val;
}
}
}
}
let eps = 1e-10;
neg_hessian[0][0] += eps;
for (j, row) in neg_hessian.iter_mut().enumerate().skip(1) {
row[j] += l2_reg + eps;
}
let delta = match hisab::num::cholesky(&neg_hessian) {
Ok(l) => match hisab::num::cholesky_solve(&l, &gradient) {
Ok(d) => d,
Err(_) => break,
},
Err(_) => break,
};
let mut max_change = 0.0_f64;
for (bj, dj) in beta.iter_mut().zip(&delta) {
*bj += dj;
max_change = max_change.max(dj.abs());
}
if max_change < tol {
converged = true;
break;
}
}
Ok(LogisticModel {
coefficients: beta,
iterations: iter,
converged,
})
}
#[must_use]
pub fn predict_logistic_proba(model: &LogisticModel, features: &[f64]) -> f64 {
let z = model.coefficients[0]
+ model.coefficients[1..]
.iter()
.zip(features)
.map(|(b, x)| b * x)
.sum::<f64>();
sigmoid(z)
}
#[must_use]
pub fn predict_logistic_class(model: &LogisticModel, features: &[f64], threshold: f64) -> u8 {
if predict_logistic_proba(model, features) >= threshold {
1
} else {
0
}
}
#[must_use]
#[inline]
fn sigmoid(x: f64) -> f64 {
if x >= 0.0 {
1.0 / (1.0 + (-x).exp())
} else {
let ex = x.exp();
ex / (1.0 + ex)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_perfect_line() {
let x = [1.0, 2.0, 3.0, 4.0, 5.0];
let y = [3.0, 5.0, 7.0, 9.0, 11.0];
let model = linear_regression(&x, &y).unwrap();
assert!((model.slope - 2.0).abs() < 1e-10);
assert!((model.intercept - 1.0).abs() < 1e-10);
assert!((model.r_squared - 1.0).abs() < 1e-10);
}
#[test]
fn test_predict() {
let model = LinearModel {
slope: 2.0,
intercept: 1.0,
r_squared: 1.0,
};
assert!((predict(&model, 3.0) - 7.0).abs() < 1e-10);
}
#[test]
fn test_residuals() {
let model = LinearModel {
slope: 1.0,
intercept: 0.0,
r_squared: 1.0,
};
let x = [1.0, 2.0, 3.0];
let y = [1.1, 1.9, 3.2];
let r = residuals(&model, &x, &y).unwrap();
assert!((r[0] - 0.1).abs() < 1e-10);
assert!((r[1] - -0.1).abs() < 1e-10);
assert!((r[2] - 0.2).abs() < 1e-10);
}
#[test]
fn test_dimension_mismatch() {
assert!(linear_regression(&[1.0], &[1.0, 2.0]).is_err());
}
#[test]
fn serde_roundtrip() {
let model = LinearModel {
slope: 2.5,
intercept: -1.3,
r_squared: 0.98,
};
let json = serde_json::to_string(&model).unwrap();
let model2: LinearModel = serde_json::from_str(&json).unwrap();
assert_eq!(model.slope, model2.slope);
assert_eq!(model.intercept, model2.intercept);
}
#[test]
fn poly_recovers_quadratic() {
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|&xi| 1.0 + 2.0 * xi + 3.0 * xi * xi).collect();
let model = polynomial_regression(&x, &y, 2).unwrap();
assert_eq!(model.degree, 2);
assert_eq!(model.coefficients.len(), 3);
assert!(
(model.coefficients[0] - 1.0).abs() < 1e-6,
"a0 = {}",
model.coefficients[0]
);
assert!(
(model.coefficients[1] - 2.0).abs() < 1e-6,
"a1 = {}",
model.coefficients[1]
);
assert!(
(model.coefficients[2] - 3.0).abs() < 1e-6,
"a2 = {}",
model.coefficients[2]
);
assert!((model.r_squared - 1.0).abs() < 1e-6);
}
#[test]
fn poly_predict() {
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|&xi| 1.0 + 2.0 * xi + 3.0 * xi * xi).collect();
let model = polynomial_regression(&x, &y, 2).unwrap();
let pred = predict_poly(&model, 5.0);
let expected = 1.0 + 10.0 + 75.0;
assert!((pred - expected).abs() < 1e-4, "pred = {pred}");
}
#[test]
fn poly_residuals() {
let x = [1.0, 2.0, 3.0, 4.0, 5.0];
let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
let model = polynomial_regression(&x, &y, 2).unwrap();
let r = residuals_poly(&model, &x, &y).unwrap();
for (i, &ri) in r.iter().enumerate() {
assert!(ri.abs() < 1e-6, "residual[{i}] = {ri}");
}
}
#[test]
fn poly_cubic() {
let x: Vec<f64> = (-5..=5).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|&xi| xi.powi(3)).collect();
let model = polynomial_regression(&x, &y, 3).unwrap();
assert!((model.r_squared - 1.0).abs() < 1e-6);
assert!(
(model.coefficients[3] - 1.0).abs() < 1e-4,
"a3 = {}",
model.coefficients[3]
);
}
#[test]
fn poly_invalid_params() {
let x = [1.0, 2.0, 3.0];
let y = [1.0, 4.0, 9.0];
assert!(polynomial_regression(&x, &y, 0).is_err());
assert!(polynomial_regression(&x, &y, 3).is_err());
assert!(polynomial_regression(&x, &[1.0, 2.0], 2).is_err());
}
#[test]
fn poly_serde_roundtrip() {
let model = PolynomialModel {
coefficients: vec![1.0, 2.0, 3.0],
degree: 2,
r_squared: 0.99,
};
let json = serde_json::to_string(&model).unwrap();
let model2: PolynomialModel = serde_json::from_str(&json).unwrap();
assert_eq!(model.coefficients, model2.coefficients);
assert_eq!(model.degree, model2.degree);
assert_eq!(model.r_squared, model2.r_squared);
}
#[test]
fn logistic_1d() {
let features = vec![
vec![-3.0],
vec![-2.5],
vec![-2.0],
vec![-1.5],
vec![-1.0],
vec![-0.5],
vec![0.5],
vec![1.0],
vec![1.5],
vec![2.0],
vec![2.5],
vec![3.0],
vec![0.1],
vec![-0.1],
];
let labels = vec![
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0,
];
let model = logistic_regression(&features, &labels, 1.0, 100).unwrap();
assert!(model.converged);
assert!(
model.coefficients[1] > 0.0,
"β₁ should be positive: {}",
model.coefficients[1]
);
assert_eq!(predict_logistic_class(&model, &[10.0], 0.5), 1);
assert_eq!(predict_logistic_class(&model, &[-10.0], 0.5), 0);
}
#[test]
fn logistic_proba_range() {
let features: Vec<Vec<f64>> = (-5..=5).map(|i| vec![i as f64]).collect();
let labels: Vec<f64> = (-5..=5).map(|i| if i >= 0 { 1.0 } else { 0.0 }).collect();
let model = logistic_regression(&features, &labels, 1.0, 100).unwrap();
for i in -20..=20 {
let p = predict_logistic_proba(&model, &[i as f64]);
assert!((0.0..=1.0).contains(&p), "proba({i}) = {p} out of range");
}
}
#[test]
fn logistic_2d_features() {
let features = vec![
vec![1.0, 1.0],
vec![2.0, 1.0],
vec![1.0, 2.0],
vec![-1.0, -1.0],
vec![-2.0, -1.0],
vec![-1.0, -2.0],
vec![3.0, 0.0],
vec![0.0, 3.0],
vec![-3.0, 0.0],
vec![0.0, -3.0],
vec![0.2, -0.2],
vec![-0.2, 0.2],
];
let labels = vec![1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0];
let model = logistic_regression(&features, &labels, 1.0, 100).unwrap();
assert!(model.converged);
assert!(model.coefficients[1] > 0.0);
assert!(model.coefficients[2] > 0.0);
}
#[test]
fn logistic_invalid_params() {
let f = vec![vec![1.0], vec![2.0]];
let y = vec![0.0, 1.0];
assert!(logistic_regression(&f, &y, 1.0, 0).is_err());
assert!(logistic_regression(&f, &[0.0], 1.0, 10).is_err());
assert!(logistic_regression(&f, &[0.0, 0.5], 1.0, 10).is_err());
assert!(logistic_regression(&[vec![1.0]], &[0.0], 1.0, 10).is_err());
}
#[test]
fn logistic_serde_roundtrip() {
let model = LogisticModel {
coefficients: vec![0.5, -1.2, 3.0],
iterations: 10,
converged: true,
};
let json = serde_json::to_string(&model).unwrap();
let model2: LogisticModel = serde_json::from_str(&json).unwrap();
assert_eq!(model.coefficients, model2.coefficients);
assert_eq!(model.iterations, model2.iterations);
assert_eq!(model.converged, model2.converged);
}
#[test]
fn sigmoid_known_values() {
assert!((sigmoid(0.0) - 0.5).abs() < 1e-10);
assert!(sigmoid(100.0) > 0.999);
assert!(sigmoid(-100.0) < 0.001);
for &x in &[-5.0, -1.0, 0.0, 1.0, 5.0] {
assert!((sigmoid(x) + sigmoid(-x) - 1.0).abs() < 1e-10);
}
}
}