use approx::assert_abs_diff_eq;
use ndarray::{Array1, Array2, array};
use rustyml::error::Error;
use rustyml::machine_learning::{Gamma, KernelType, SVC};
fn linearly_separable_data() -> (Array2<f64>, Array1<f64>) {
let x = Array2::from_shape_vec(
(8, 2),
vec![
2.0, 2.0, 3.0, 2.0, 2.0, 3.0, 3.0, 3.0, -2.0, -2.0, -3.0, -2.0, -2.0, -3.0, -3.0, -3.0, ],
)
.unwrap();
let y = array![1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0];
(x, y)
}
fn concentric_rings_data() -> (Array2<f64>, Array1<f64>) {
let x = Array2::from_shape_vec(
(8, 2),
vec![
1.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, -1.0, 5.0, 0.0, -5.0, 0.0, 0.0, 5.0, 0.0, -5.0, ],
)
.unwrap();
let y = array![1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0];
(x, y)
}
#[test]
fn new_rejects_zero_c() {
let result = SVC::new(KernelType::Linear, 0.0, 1e-3, 100);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn new_rejects_negative_c() {
let result = SVC::new(KernelType::Linear, -1.0, 1e-3, 100);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn new_rejects_nan_c() {
let result = SVC::new(KernelType::Linear, f64::NAN, 1e-3, 100);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn new_rejects_inf_c() {
let result = SVC::new(KernelType::Linear, f64::INFINITY, 1e-3, 100);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn new_rejects_zero_tol() {
let result = SVC::new(KernelType::Linear, 1.0, 0.0, 100);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn new_rejects_negative_tol() {
let result = SVC::new(KernelType::Linear, 1.0, -1e-3, 100);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn new_rejects_nan_tol() {
let result = SVC::new(KernelType::Linear, 1.0, f64::NAN, 100);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn new_rejects_zero_max_iter() {
let result = SVC::new(KernelType::Linear, 1.0, 1e-3, 0);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn new_valid_parameters_round_trip() {
let svc = SVC::new(
KernelType::RBF {
gamma: Gamma::Value(0.5),
},
2.0,
1e-3,
500,
)
.expect("valid params must succeed")
.with_random_state(7);
assert_abs_diff_eq!(svc.get_regularization_parameter(), 2.0, epsilon = 1e-10);
assert_abs_diff_eq!(svc.get_tolerance(), 1e-3, epsilon = 1e-12);
assert_eq!(svc.get_max_iterations(), 500);
assert_eq!(svc.get_random_state(), Some(7));
}
#[test]
fn default_has_expected_params() {
let svc = SVC::default();
assert_abs_diff_eq!(svc.get_regularization_parameter(), 1.0, epsilon = 1e-10);
assert_abs_diff_eq!(svc.get_tolerance(), 0.001, epsilon = 1e-12);
assert_eq!(svc.get_max_iterations(), 1000);
assert!(svc.get_support_vectors().is_none());
assert!(svc.get_alphas().is_none());
assert!(svc.get_bias().is_none());
assert!(svc.get_actual_iterations().is_none());
}
#[test]
fn fit_rejects_labels_not_plus_minus_one() {
let x = Array2::from_shape_vec((4, 2), vec![1.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, -1.0]).unwrap();
let y = array![0.0, 0.0, 1.0, 1.0];
let mut svc = SVC::new(KernelType::Linear, 1.0, 1e-3, 100)
.unwrap()
.with_random_state(42);
let result = svc.fit(&x, &y);
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"expected InvalidInput for non-±1 labels, got {:?}",
result
);
}
#[test]
fn fit_rejects_fractional_labels() {
let x = Array2::from_shape_vec((4, 2), vec![1.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, -1.0]).unwrap();
let y = array![1.0, -1.0, 0.5, -0.5];
let mut svc = SVC::new(KernelType::Linear, 1.0, 1e-3, 100)
.unwrap()
.with_random_state(42);
let result = svc.fit(&x, &y);
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"expected InvalidInput for fractional labels, got {:?}",
result
);
}
#[test]
fn predict_before_fit_returns_not_fitted() {
let svc = SVC::new(KernelType::Linear, 1.0, 1e-3, 100)
.unwrap()
.with_random_state(42);
let x = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, -1.0, 0.0]).unwrap();
let result = svc.predict(&x);
assert!(
matches!(result, Err(Error::NotFitted(_))),
"expected NotFitted, got {:?}",
result
);
}
#[test]
fn decision_function_before_fit_returns_not_fitted() {
let svc = SVC::new(KernelType::Linear, 1.0, 1e-3, 100)
.unwrap()
.with_random_state(42);
let x = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, -1.0, 0.0]).unwrap();
let result = svc.decision_function(&x);
assert!(
matches!(result, Err(Error::NotFitted(_))),
"expected NotFitted, got {:?}",
result
);
}
#[test]
fn predict_wrong_feature_dim_returns_dimension_mismatch() {
let (x_train, y_train) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 500)
.unwrap()
.with_random_state(42);
svc.fit(&x_train, &y_train).expect("fit must succeed");
let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 0.0, -1.0, 0.0, 0.0]).unwrap();
let result = svc.predict(&x_bad);
assert!(
matches!(result, Err(Error::DimensionMismatch { .. })),
"expected DimensionMismatch, got {:?}",
result
);
}
#[test]
fn decision_function_wrong_feature_dim_returns_dimension_mismatch() {
let (x_train, y_train) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 500)
.unwrap()
.with_random_state(42);
svc.fit(&x_train, &y_train).expect("fit must succeed");
let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 0.0, -1.0, 0.0, 0.0]).unwrap();
let result = svc.decision_function(&x_bad);
assert!(
matches!(result, Err(Error::DimensionMismatch { .. })),
"expected DimensionMismatch, got {:?}",
result
);
}
#[test]
fn linear_kernel_classifies_separable_data_perfectly() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y)
.expect("fit must succeed on linearly separable data");
let preds = svc.predict(&x).expect("predict must succeed");
for (i, (&pred, &true_label)) in preds.iter().zip(y.iter()).enumerate() {
assert_eq!(
pred, true_label,
"sample {i}: predicted {pred} but true label is {true_label}"
);
}
}
#[test]
fn predict_output_domain_is_plus_minus_one() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let preds = svc.predict(&x).expect("predict must succeed");
for &p in preds.iter() {
assert!(
p == 1.0 || p == -1.0,
"predict returned {p}, which is not in {{+1.0, -1.0}}"
);
}
}
#[test]
fn sign_consistency_linear_kernel() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let df = svc
.decision_function(&x)
.expect("decision_function must succeed");
let preds = svc.predict(&x).expect("predict must succeed");
for (i, (&dv, &p)) in df.iter().zip(preds.iter()).enumerate() {
let expected_pred = if dv >= 0.0 { 1.0_f64 } else { -1.0_f64 };
assert_eq!(
p, expected_pred,
"sample {i}: decision_value={dv}, predict={p} disagrees with sign"
);
}
}
#[test]
fn linear_kernel_fit_populates_state() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
assert!(
svc.get_support_vectors().is_some(),
"support_vectors must be Some after fit"
);
assert!(svc.get_alphas().is_some(), "alphas must be Some after fit");
assert!(svc.get_bias().is_some(), "bias must be Some after fit");
assert!(
svc.get_actual_iterations().is_some(),
"n_iter must be Some after fit"
);
}
#[test]
fn actual_iterations_in_valid_range() {
let (x, y) = linearly_separable_data();
let max_iter = 1000_usize;
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, max_iter)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let n_iter = svc
.get_actual_iterations()
.as_ref()
.copied()
.expect("n_iter must be Some after fit");
assert!(
n_iter >= 1 && n_iter <= max_iter,
"n_iter={n_iter} is outside [1, {max_iter}]"
);
}
#[test]
fn rbf_kernel_classifies_concentric_rings_perfectly() {
let (x, y) = concentric_rings_data();
let mut svc = SVC::new(
KernelType::RBF {
gamma: Gamma::Value(0.5),
},
10.0,
1e-3,
5000,
)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y)
.expect("fit must succeed on concentric rings");
let preds = svc.predict(&x).expect("predict must succeed");
for (i, (&pred, &true_label)) in preds.iter().zip(y.iter()).enumerate() {
assert_eq!(
pred, true_label,
"sample {i}: RBF SVC predicted {pred} but true label is {true_label}"
);
}
}
#[test]
fn sign_consistency_rbf_kernel() {
let (x, y) = concentric_rings_data();
let mut svc = SVC::new(
KernelType::RBF {
gamma: Gamma::Value(0.5),
},
10.0,
1e-3,
2000,
)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let df = svc
.decision_function(&x)
.expect("decision_function must succeed");
let preds = svc.predict(&x).expect("predict must succeed");
for (i, (&dv, &p)) in df.iter().zip(preds.iter()).enumerate() {
let expected_pred = if dv >= 0.0 { 1.0_f64 } else { -1.0_f64 };
assert_eq!(
p, expected_pred,
"sample {i}: RBF decision_value={dv}, predict={p} disagrees with sign"
);
}
}
#[test]
fn all_kernels_fit_and_predict_without_error() {
let kernels: &[KernelType] = &[
KernelType::Linear,
KernelType::Poly {
degree: 2,
gamma: Gamma::Value(1.0),
coef0: 1.0,
},
KernelType::RBF {
gamma: Gamma::Value(0.5),
},
KernelType::Sigmoid {
gamma: Gamma::Value(0.1),
coef0: 0.0,
},
KernelType::Cosine,
];
let (x, y) = linearly_separable_data();
for kernel in kernels {
let mut svc = SVC::new(*kernel, 5.0, 1e-3, 1000)
.expect("constructor must succeed for all kernel variants")
.with_random_state(42);
svc.fit(&x, &y)
.unwrap_or_else(|e| panic!("fit failed for kernel {:?}: {e}", kernel));
let preds = svc
.predict(&x)
.unwrap_or_else(|e| panic!("predict failed for kernel {:?}: {e}", kernel));
for &p in preds.iter() {
assert!(
p == 1.0 || p == -1.0,
"kernel {:?} returned label {p} outside {{±1.0}}",
kernel
);
}
}
}
#[test]
fn poly_kernel_classifies_separable_data_correctly() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(
KernelType::Poly {
degree: 2,
gamma: Gamma::Value(1.0),
coef0: 1.0,
},
10.0,
1e-3,
1000,
)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let preds = svc.predict(&x).expect("predict must succeed");
for (i, (&pred, &true_label)) in preds.iter().zip(y.iter()).enumerate() {
assert_eq!(
pred, true_label,
"Poly sample {i}: predicted {pred} but true label is {true_label}"
);
}
}
#[test]
fn cosine_kernel_zero_vector_does_not_panic() {
let x = Array2::from_shape_vec(
(6, 2),
vec![
2.0, 2.0, 3.0, 3.0, 0.0, 0.0, -2.0, -2.0, -3.0, -3.0, -4.0, -4.0, ],
)
.unwrap();
let y = array![1.0, 1.0, 1.0, -1.0, -1.0, -1.0];
let mut svc = SVC::new(KernelType::Cosine, 5.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
let _ = svc.fit(&x, &y);
}
#[test]
fn same_seed_produces_identical_results() {
let (x, y) = linearly_separable_data();
let mut svc1 = SVC::new(
KernelType::RBF {
gamma: Gamma::Value(0.5),
},
5.0,
1e-3,
1000,
)
.unwrap()
.with_random_state(42);
svc1.fit(&x, &y).expect("first fit must succeed");
let mut svc2 = SVC::new(
KernelType::RBF {
gamma: Gamma::Value(0.5),
},
5.0,
1e-3,
1000,
)
.unwrap()
.with_random_state(42);
svc2.fit(&x, &y).expect("second fit must succeed");
let preds1 = svc1.predict(&x).expect("predict1 must succeed");
let preds2 = svc2.predict(&x).expect("predict2 must succeed");
assert_eq!(preds1, preds2, "same seed must yield identical predictions");
let df1 = svc1.decision_function(&x).expect("df1 must succeed");
let df2 = svc2.decision_function(&x).expect("df2 must succeed");
for (a, b) in df1.iter().zip(df2.iter()) {
assert_abs_diff_eq!(a, b, epsilon = 1e-12);
}
assert_abs_diff_eq!(
svc1.get_bias().unwrap(),
svc2.get_bias().unwrap(),
epsilon = 1e-12
);
}
#[test]
fn fit_predict_agrees_with_fit_then_predict() {
let (x, y) = linearly_separable_data();
let mut svc_a = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
let fp_preds = svc_a.fit_predict(&x, &y).expect("fit_predict must succeed");
let mut svc_b = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc_b.fit(&x, &y).expect("fit must succeed");
let preds = svc_b.predict(&x).expect("predict must succeed");
assert_eq!(fp_preds, preds, "fit_predict must match fit+predict");
}
#[test]
fn save_load_round_trip_yields_identical_predictions() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(
KernelType::RBF {
gamma: Gamma::Value(0.5),
},
5.0,
1e-3,
1000,
)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let original_preds = svc.predict(&x).expect("predict must succeed before save");
let original_df = svc
.decision_function(&x)
.expect("df must succeed before save");
let path = "/tmp/rustyml_svc_test_roundtrip.json";
svc.save_to_path(path).expect("save_to_path must succeed");
let loaded = SVC::load_from_path(path).expect("load_from_path must succeed");
let loaded_preds = loaded.predict(&x).expect("predict must succeed after load");
let loaded_df = loaded
.decision_function(&x)
.expect("df must succeed after load");
assert_eq!(
original_preds, loaded_preds,
"predictions must match after round-trip"
);
for (a, b) in original_df.iter().zip(loaded_df.iter()) {
assert_abs_diff_eq!(a, b, epsilon = 1e-10);
}
assert_abs_diff_eq!(
svc.get_regularization_parameter(),
loaded.get_regularization_parameter(),
epsilon = 1e-12
);
assert_abs_diff_eq!(
svc.get_bias().unwrap(),
loaded.get_bias().unwrap(),
epsilon = 1e-12
);
let _ = std::fs::remove_file(path);
}
#[test]
fn save_load_round_trip_linear_kernel() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let path = "/tmp/rustyml_svc_test_linear_roundtrip.json";
svc.save_to_path(path).expect("save_to_path must succeed");
let loaded = SVC::load_from_path(path).expect("load_from_path must succeed");
let preds_orig = svc.predict(&x).expect("original predict");
let preds_load = loaded.predict(&x).expect("loaded predict");
assert_eq!(
preds_orig, preds_load,
"linear kernel round-trip must match"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn predict_empty_input_returns_error() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let x_empty = Array2::<f64>::zeros((0, 2));
let result = svc.predict(&x_empty);
assert!(
matches!(result, Err(Error::EmptyInput(_))),
"expected EmptyInput for zero-row input, got {:?}",
result
);
}
#[test]
fn decision_function_empty_input_returns_error() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let x_empty = Array2::<f64>::zeros((0, 2));
let result = svc.decision_function(&x_empty);
assert!(
matches!(result, Err(Error::EmptyInput(_))),
"expected EmptyInput for zero-row input, got {:?}",
result
);
}
#[test]
fn sigmoid_kernel_sign_consistency() {
let (x, y) = linearly_separable_data();
let mut svc = SVC::new(
KernelType::Sigmoid {
gamma: Gamma::Value(0.1),
coef0: 0.0,
},
5.0,
1e-3,
1000,
)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).expect("fit must succeed");
let df = svc
.decision_function(&x)
.expect("decision_function must succeed");
let preds = svc.predict(&x).expect("predict must succeed");
for (i, (&dv, &p)) in df.iter().zip(preds.iter()).enumerate() {
let expected_pred = if dv >= 0.0 { 1.0_f64 } else { -1.0_f64 };
assert_eq!(
p, expected_pred,
"sample {i}: Sigmoid decision_value={dv}, predict={p} disagrees"
);
}
}
#[test]
fn fit_single_class_data_returns_not_converged() {
let x = array![[0.0, 0.0], [1.0, 1.0], [2.0, 0.5], [0.5, 2.0]];
let y = array![1.0, 1.0, 1.0, 1.0]; let mut svc = SVC::new(KernelType::Linear, 1.0, 1e-3, 100)
.unwrap()
.with_random_state(42);
let result = svc.fit(&x, &y);
assert!(
matches!(result, Err(Error::NotConverged(_))),
"single-class data must yield no support vectors → NotConverged, got {result:?}"
);
}
#[test]
fn decision_function_and_bias_match_closed_form_linear_kernel() {
let x = array![[0.0], [-1.0], [2.0], [3.0]];
let y = array![-1.0, -1.0, 1.0, 1.0];
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-5, 5000)
.unwrap()
.with_random_state(7);
svc.fit(&x, &y).expect("separable data must fit");
assert_abs_diff_eq!(svc.get_bias().unwrap(), -1.0, epsilon = 1e-2);
let probe = array![[0.0], [2.0], [1.0]];
let df = svc
.decision_function(&probe)
.expect("decision_function must succeed");
assert_abs_diff_eq!(df[0], -1.0, epsilon = 1e-2); assert_abs_diff_eq!(df[1], 1.0, epsilon = 1e-2); assert_abs_diff_eq!(df[2], 0.0, epsilon = 1e-2); }
#[test]
fn rbf_gamma_scale_resolves_and_matches_explicit_equivalent() {
let x = Array2::from_shape_vec(
(6, 2),
vec![
0.0, 0.0, 0.5, 0.2, 0.1, 0.4, 3.0, 3.0, 3.2, 2.8, 2.9, 3.1, ],
)
.unwrap();
let y = array![-1.0, -1.0, -1.0, 1.0, 1.0, 1.0];
let n_features = x.ncols() as f64;
let mean = x.iter().sum::<f64>() / x.len() as f64;
let var = x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / x.len() as f64;
let expected_gamma = 1.0 / (n_features * var);
let mut scale_model = SVC::new(
KernelType::RBF {
gamma: Gamma::Scale,
},
1.0,
1e-3,
200,
)
.unwrap()
.with_random_state(0);
scale_model.fit(&x, &y).unwrap();
let resolved_gamma = match scale_model.get_kernel() {
KernelType::RBF {
gamma: Gamma::Value(g),
} => g,
other => panic!("expected resolved RBF Value after fit, got {other:?}"),
};
assert_abs_diff_eq!(resolved_gamma, expected_gamma, epsilon = 1e-9);
let mut explicit_model = SVC::new(
KernelType::RBF {
gamma: Gamma::Value(resolved_gamma),
},
1.0,
1e-3,
200,
)
.unwrap()
.with_random_state(0);
explicit_model.fit(&x, &y).unwrap();
assert_eq!(
scale_model.predict(&x).unwrap(),
explicit_model.predict(&x).unwrap(),
"Scale must behave identically to its resolved explicit gamma"
);
}
#[test]
fn rbf_gamma_auto_resolves_to_inverse_n_features() {
let x = Array2::from_shape_vec(
(4, 3),
vec![
0.0, 0.0, 0.0, 0.1, 0.2, 0.1, 3.0, 3.0, 3.0, 3.1, 2.9, 3.0, ],
)
.unwrap();
let y = array![-1.0, -1.0, 1.0, 1.0];
let mut model = SVC::new(KernelType::RBF { gamma: Gamma::Auto }, 1.0, 1e-3, 200)
.unwrap()
.with_random_state(0);
model.fit(&x, &y).unwrap();
match model.get_kernel() {
KernelType::RBF {
gamma: Gamma::Value(g),
} => assert_abs_diff_eq!(g, 1.0 / 3.0, epsilon = 1e-12),
other => panic!("expected resolved RBF Value, got {other:?}"),
}
}