use rand::SeedableRng;
use rill_ml::feature_hasher::FeatureHasher;
use rill_ml::loss::RegressionLoss;
use rill_ml::metrics::{F1Score, Mae};
use rill_ml::models::{
FtrlClassifier, FtrlConfig, FtrlRegressor, LinearRegression, LinearRegressionConfig,
};
use rill_ml::optim::{Optimizer, SgdConfig};
use rill_ml::sparse::SparseFeatures;
use rill_ml::{Metric, OnlineRegressor, SparseClassifier, SparseRegressor};
fn make_regression_data(n: usize) -> Vec<(SparseFeatures, f64)> {
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(42);
let mut data = Vec::with_capacity(n);
for _ in 0..n {
let x1 = rand::Rng::gen_range(&mut rng, 0.0..1.0);
let x2 = rand::Rng::gen_range(&mut rng, 0.0..1.0);
let y = 3.0 * x1 + 2.0 * x2;
let sf = SparseFeatures::from_sorted(vec![(0, x1), (1, x2)]).unwrap();
data.push((sf, y));
}
data
}
fn make_classification_data(n: usize) -> Vec<(SparseFeatures, bool)> {
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(42);
let mut data = Vec::with_capacity(n);
for _ in 0..n {
let x1 = rand::Rng::gen_range(&mut rng, 0.0..1.0);
let x2 = rand::Rng::gen_range(&mut rng, 0.0..1.0);
let label = x1 + x2 > 1.0;
let sf = SparseFeatures::from_sorted(vec![(0, x1), (1, x2)]).unwrap();
data.push((sf, label));
}
data
}
#[test]
fn ftrl_regressor_converges_on_linear_data() {
let mut model = FtrlRegressor::new(FtrlConfig {
alpha: 0.5,
beta: 1.0,
l1: 0.0,
l2: 0.0,
})
.unwrap();
let data = make_regression_data(500);
let mut first_mae = Mae::new();
let mut last_mae = Mae::new();
for (i, (sf, y)) in data.iter().enumerate() {
let pred = model.predict(sf).unwrap();
if i < 50 {
first_mae.update(*y, pred).unwrap();
}
if i >= 450 {
last_mae.update(*y, pred).unwrap();
}
model.learn(sf, *y).unwrap();
}
let first = first_mae
.value()
.expect("first 50 samples should produce an MAE");
let last = last_mae
.value()
.expect("last 50 samples should produce an MAE");
assert!(
last < first,
"MAE should decrease over training: first={first}, last={last}"
);
assert!(
last < 0.5,
"final MAE should be small for a linear DGP, got {last}"
);
}
#[test]
fn ftrl_regressor_comparable_to_linear_regression() {
let data = make_regression_data(500);
let mut ftrl = FtrlRegressor::new(FtrlConfig {
alpha: 0.5,
beta: 1.0,
l1: 0.0,
l2: 0.0,
})
.unwrap();
let d = 2;
let mut linreg = LinearRegression::new(
d,
LinearRegressionConfig {
optimizer: Optimizer::sgd(
d,
SgdConfig {
learning_rate: 0.1,
l2: 0.0,
},
)
.unwrap(),
loss: RegressionLoss::default(),
},
)
.unwrap();
let mut ftrl_mae = Mae::new();
let mut linreg_mae = Mae::new();
for (sf, y) in &data {
let dense = vec![sf.get(0).unwrap(), sf.get(1).unwrap()];
let p_ftrl = ftrl.predict(sf).unwrap();
ftrl_mae.update(*y, p_ftrl).unwrap();
ftrl.learn(sf, *y).unwrap();
let p_lin = linreg.predict(&dense).unwrap();
linreg_mae.update(*y, p_lin).unwrap();
linreg.learn(&dense, *y).unwrap();
}
let ftrl_val = ftrl_mae.value().unwrap();
let lin_val = linreg_mae.value().unwrap();
assert!(ftrl_val < 1.0, "FTRL MAE should be < 1.0, got {ftrl_val}");
assert!(
lin_val < 1.0,
"LinearRegression MAE should be < 1.0, got {lin_val}"
);
}
#[test]
fn ftrl_regressor_l1_sparsity() {
let mut model = FtrlRegressor::new(FtrlConfig {
alpha: 0.1,
beta: 1.0,
l1: 100.0,
l2: 0.0,
})
.unwrap();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(7);
for _ in 0..300 {
let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
let x2 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
let y = 0.5 * x1 + 0.5 * x2;
let sf = SparseFeatures::from_sorted(vec![(0, x1), (1, x2)]).unwrap();
model.learn(&sf, y).unwrap();
}
let weights = model.weights();
assert!(
weights.is_empty(),
"high L1 should zero all weights, got {weights:?}"
);
}
#[test]
fn ftrl_regressor_dynamic_features() {
let mut model = FtrlRegressor::new(FtrlConfig {
alpha: 0.5,
beta: 1.0,
l1: 0.0,
l2: 0.0,
})
.unwrap();
assert_eq!(model.feature_count(), 0);
for _ in 0..50 {
let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 2.0)]).unwrap();
model.learn(&sf, 3.0).unwrap();
}
assert_eq!(model.feature_count(), 2);
for _ in 0..50 {
let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 2.0), (5, 4.0)]).unwrap();
model.learn(&sf, 7.0).unwrap();
}
assert_eq!(model.feature_count(), 3);
let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 2.0), (5, 4.0)]).unwrap();
let pred = model.predict(&sf).unwrap();
assert!(
pred.is_finite(),
"prediction must be finite after dynamic growth"
);
}
#[test]
fn ftrl_classifier_converges() {
let mut model = FtrlClassifier::new(FtrlConfig {
alpha: 0.5,
beta: 1.0,
l1: 0.0,
l2: 0.0,
})
.unwrap();
let data = make_classification_data(500);
for (sf, y) in &data {
model.learn(sf, *y).unwrap();
}
let mut f1 = F1Score::default();
for (sf, y) in &data {
let pred = model.predict(sf).unwrap();
f1.update(*y, pred).unwrap();
}
let score = f1.value().expect("F1 should be available after evaluation");
assert!(
score > 0.5,
"F1 should be > 0.5 after training on separable data, got {score}"
);
}
#[test]
fn ftrl_classifier_predict_proba_in_range() {
let mut model = FtrlClassifier::new(FtrlConfig {
alpha: 0.5,
beta: 1.0,
l1: 0.0,
l2: 0.0,
})
.unwrap();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(13);
for _ in 0..200 {
let x1 = rand::Rng::gen_range(&mut rng, -5.0..5.0);
let x2 = rand::Rng::gen_range(&mut rng, -5.0..5.0);
let y = x1 + x2 > 0.0;
let sf = SparseFeatures::from_sorted(vec![(0, x1), (1, x2)]).unwrap();
model.learn(&sf, y).unwrap();
}
for _ in 0..100 {
let x1 = rand::Rng::gen_range(&mut rng, -10.0..10.0);
let x2 = rand::Rng::gen_range(&mut rng, -10.0..10.0);
let sf = SparseFeatures::from_sorted(vec![(0, x1), (1, x2)]).unwrap();
let p = model.predict_proba(&sf).unwrap();
assert!(
p > 0.0 && p < 1.0,
"probability must be strictly in (0, 1), got {p}"
);
}
}
#[test]
fn ftrl_with_feature_hasher() {
let hasher = FeatureHasher::new(64, 42).unwrap();
let mut model = FtrlClassifier::new(FtrlConfig {
alpha: 0.5,
beta: 1.0,
l1: 0.0,
l2: 0.0,
})
.unwrap();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(31);
for _ in 0..500 {
let pairs_true: Vec<(&str, f64)> = vec![
("alpha_a", 1.0),
("alpha_b", rand::Rng::gen_range(&mut rng, 0.5..1.5)),
];
let sf_true = hasher.hash_strings(&pairs_true).unwrap();
model.learn(&sf_true, true).unwrap();
let pairs_false: Vec<(&str, f64)> = vec![
("beta_a", 1.0),
("beta_b", rand::Rng::gen_range(&mut rng, 0.5..1.5)),
];
let sf_false = hasher.hash_strings(&pairs_false).unwrap();
model.learn(&sf_false, false).unwrap();
}
let sf_true = hasher
.hash_strings(&[("alpha_a", 1.0), ("alpha_b", 1.0)])
.unwrap();
let sf_false = hasher
.hash_strings(&[("beta_a", 1.0), ("beta_b", 1.0)])
.unwrap();
let p_true = model.predict_proba(&sf_true).unwrap();
let p_false = model.predict_proba(&sf_false).unwrap();
assert!(
p_true > 0.5,
"alpha-family should predict > 0.5, got {p_true}"
);
assert!(
p_false < 0.5,
"beta-family should predict < 0.5, got {p_false}"
);
assert!(
p_true > p_false,
"alpha-family probability must exceed beta-family: {p_true} vs {p_false}"
);
}
#[test]
fn ftrl_classifier_logloss_decreases() {
use rill_ml::metrics::LogLoss;
let mut model = FtrlClassifier::new(FtrlConfig {
alpha: 0.5,
beta: 1.0,
l1: 0.0,
l2: 0.0,
})
.unwrap();
let data = make_classification_data(500);
let mut first_loss = LogLoss::default();
let mut last_loss = LogLoss::default();
for (i, (sf, y)) in data.iter().enumerate() {
let p = model.predict_proba(sf).unwrap();
if i < 50 {
first_loss.update(*y, p).unwrap();
}
if i >= 450 {
last_loss.update(*y, p).unwrap();
}
model.learn(sf, *y).unwrap();
}
let first = first_loss
.value()
.expect("first 50 should produce a LogLoss");
let last = last_loss.value().expect("last 50 should produce a LogLoss");
assert!(
last < first,
"LogLoss should decrease over training: first={first}, last={last}"
);
}