use crate::common::named;
use approx::assert_abs_diff_eq;
use ndarray::{Array, Array2, ArrayD};
use rustyml::error::Error;
use rustyml::neural_network::layers::ParamCounts;
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::layers::regularization::normalization::batch_normalization::BatchNormalization;
use rustyml::neural_network::losses::mean_squared_error::MeanSquaredError;
use rustyml::neural_network::optimizers::AdaGrad;
use rustyml::neural_network::optimizers::Adam;
use rustyml::neural_network::optimizers::AdamW;
use rustyml::neural_network::optimizers::RMSprop;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::sequential::SequentialBuilder;
use rustyml::neural_network::traits::{
LayerBase, Optimizer, ParamRef, UnaryLayer, WeightMut, WeightRef,
};
use rustyml::neural_network::{Ctx, Shape, Tensor};
fn regression_data() -> (Tensor, Tensor) {
let x = Array::from_shape_vec((4, 1), vec![0.5_f32, 1.0, 1.5, 2.0])
.unwrap()
.into_dyn();
let y = Array::from_shape_vec((4, 1), vec![1.0_f32, 2.0, 3.0, 4.0])
.unwrap()
.into_dyn();
(x, y)
}
fn identity_dense() -> Dense {
let mut layer = Dense::new(1, Linear::new()).unwrap();
layer.build(&Shape::known(&[1, 1])).unwrap();
let w = Array::from_shape_vec((1, 1), vec![1.0_f32]).unwrap();
let b = Array::from_shape_vec((1, 1), vec![0.0_f32]).unwrap();
layer.set_weights(w, b).unwrap();
layer
}
fn eval_mse(model: &Sequential, x: &Tensor, y: &Tensor) -> f32 {
let pred = model.predict(x).unwrap();
let diff = &pred - y;
let sq: Tensor = diff.mapv(|v| v * v);
sq.sum() / sq.len() as f32
}
#[test]
fn sgd_rejects_invalid_learning_rate() {
for lr in [0.0_f32, -0.1, f32::INFINITY, f32::NAN] {
assert!(
matches!(
SGD::new(lr, 0.0, false, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for learning_rate={lr:?}"
);
}
}
#[test]
fn sgd_accepts_valid_learning_rate() {
assert!(SGD::new(0.01, 0.0, false, 0.0).is_ok());
assert!(SGD::new(1.0, 0.0, false, 0.0).is_ok());
assert!(SGD::new(f32::MIN_POSITIVE, 0.0, false, 0.0).is_ok());
}
#[test]
fn adam_rejects_invalid_learning_rate() {
for lr in [0.0_f32, -1e-3, f32::INFINITY, f32::NAN] {
assert!(
matches!(
Adam::new(lr, 0.9, 0.999, 1e-8, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for learning_rate={lr:?}"
);
}
}
#[test]
fn adam_beta1_bounds() {
assert!(
Adam::new(0.001, 0.0, 0.999, 1e-8, 0.0).is_ok(),
"beta1=0.0 (inclusive lower bound) is accepted"
);
for beta1 in [1.0_f32, 1.1, -0.1, f32::NAN] {
assert!(
matches!(
Adam::new(0.001, beta1, 0.999, 1e-8, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for beta1={beta1:?}"
);
}
}
#[test]
fn adam_beta2_bounds() {
assert!(
Adam::new(0.001, 0.9, 0.0, 1e-8, 0.0).is_ok(),
"beta2=0.0 (inclusive lower bound) is accepted"
);
for beta2 in [1.0_f32, f32::NAN] {
assert!(
matches!(
Adam::new(0.001, 0.9, beta2, 1e-8, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for beta2={beta2:?}"
);
}
}
#[test]
fn adam_rejects_invalid_epsilon() {
for eps in [0.0_f32, -1e-8, f32::NAN, f32::INFINITY] {
assert!(
matches!(
Adam::new(0.001, 0.9, 0.999, eps, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for epsilon={eps:?}"
);
}
}
#[test]
fn adam_accepts_valid_hyperparameters() {
assert!(Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0).is_ok());
assert!(Adam::new(0.01, 0.5, 0.9, 1e-6, 0.0).is_ok());
}
#[test]
fn rmsprop_rejects_invalid_learning_rate() {
for lr in [0.0_f32, -0.01, f32::INFINITY, f32::NAN] {
assert!(
matches!(
RMSprop::new(lr, 0.9, 1e-8, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for learning_rate={lr:?}"
);
}
}
#[test]
fn rmsprop_rho_bounds() {
assert!(
RMSprop::new(0.01, 0.0, 1e-8, 0.0).is_ok(),
"rho=0.0 (inclusive lower bound) is accepted"
);
for rho in [1.0_f32, 1.5, -0.5, f32::NAN] {
assert!(
matches!(
RMSprop::new(0.01, rho, 1e-8, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for rho={rho:?}"
);
}
}
#[test]
fn rmsprop_rejects_invalid_epsilon() {
for eps in [0.0_f32, f32::NAN, f32::INFINITY] {
assert!(
matches!(
RMSprop::new(0.01, 0.9, eps, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for epsilon={eps:?}"
);
}
}
#[test]
fn rmsprop_accepts_valid_hyperparameters() {
assert!(RMSprop::new(0.001, 0.9, 1e-8, 0.0).is_ok());
assert!(RMSprop::new(0.01, 0.95, 1e-5, 0.0).is_ok());
}
#[test]
fn adagrad_rejects_invalid_learning_rate() {
for lr in [0.0_f32, -0.01, f32::INFINITY, f32::NAN] {
assert!(
matches!(
AdaGrad::new(lr, 1e-8, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for learning_rate={lr:?}"
);
}
}
#[test]
fn adagrad_rejects_invalid_epsilon() {
for eps in [0.0_f32, -1e-8, f32::NAN, f32::INFINITY] {
assert!(
matches!(
AdaGrad::new(0.01, eps, 0.0),
Err(Error::InvalidParameter { .. })
),
"expected InvalidParameter for epsilon={eps:?}"
);
}
}
#[test]
fn adagrad_accepts_valid_hyperparameters() {
assert!(AdaGrad::new(0.01, 1e-8, 0.0).is_ok());
assert!(AdaGrad::new(0.001, 1e-5, 0.0).is_ok());
}
#[test]
fn identity_dense_initial_mse_is_1_875() {
let (x, y) = regression_data();
let mut model = SequentialBuilder::new()
.add(identity_dense())
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.01, 0.0, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mse = eval_mse(&model, &x, &y);
assert_abs_diff_eq!(mse, 1.875_f32, epsilon = 1e-5);
}
#[test]
fn sgd_single_layer_loss_decreases_over_20_epochs() {
let (x, y) = regression_data();
let initial_mse = 1.875_f32;
let mut model = SequentialBuilder::new()
.add(identity_dense())
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.1, 0.0, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mse_before = eval_mse(&model, &x, &y);
assert_abs_diff_eq!(mse_before, initial_mse, epsilon = 1e-5);
model.fit(&x, &y, 20).unwrap();
let mse_after = eval_mse(&model, &x, &y);
assert!(
mse_after < mse_before,
"SGD: loss should decrease; before={mse_before}, after={mse_after}"
);
}
#[test]
fn adam_single_layer_loss_decreases_over_20_epochs() {
let (x, y) = regression_data();
let initial_mse = 1.875_f32;
let mut model = SequentialBuilder::new()
.add(identity_dense())
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
Adam::new(0.1, 0.9, 0.999, 1e-8, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mse_before = eval_mse(&model, &x, &y);
assert_abs_diff_eq!(mse_before, initial_mse, epsilon = 1e-5);
model.fit(&x, &y, 20).unwrap();
let mse_after = eval_mse(&model, &x, &y);
assert!(
mse_after < mse_before,
"Adam: loss should decrease; before={mse_before}, after={mse_after}"
);
}
#[test]
fn rmsprop_single_layer_loss_decreases_over_20_epochs() {
let (x, y) = regression_data();
let initial_mse = 1.875_f32;
let mut model = SequentialBuilder::new()
.add(identity_dense())
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
RMSprop::new(0.1, 0.9, 1e-8, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mse_before = eval_mse(&model, &x, &y);
assert_abs_diff_eq!(mse_before, initial_mse, epsilon = 1e-5);
model.fit(&x, &y, 20).unwrap();
let mse_after = eval_mse(&model, &x, &y);
assert!(
mse_after < mse_before,
"RMSprop: loss should decrease; before={mse_before}, after={mse_after}"
);
}
#[test]
fn adagrad_single_layer_loss_decreases_over_20_epochs() {
let (x, y) = regression_data();
let initial_mse = 1.875_f32;
let mut model = SequentialBuilder::new()
.add(identity_dense())
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
AdaGrad::new(0.5, 1e-8, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mse_before = eval_mse(&model, &x, &y);
assert_abs_diff_eq!(mse_before, initial_mse, epsilon = 1e-5);
model.fit(&x, &y, 20).unwrap();
let mse_after = eval_mse(&model, &x, &y);
assert!(
mse_after < mse_before,
"AdaGrad: loss should decrease; before={mse_before}, after={mse_after}"
);
}
#[test]
fn adam_two_layer_loss_decreases_and_buffers_allocated_correctly() {
let (x, y) = regression_data();
const SEED: u64 = 0;
let build_model = || -> Sequential {
let layer1 = Dense::new(4, Linear::new())
.unwrap()
.with_random_state(SEED);
let layer2 = Dense::new(1, Linear::new())
.unwrap()
.with_random_state(SEED);
let mut model = SequentialBuilder::new_with_seed(SEED)
.add(layer1)
.add(layer2)
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
Adam::new(0.05, 0.9, 0.999, 1e-8, 0.0).unwrap(),
MeanSquaredError::new(),
);
model
};
let mut model = build_model();
let mse_initial = eval_mse(&model, &x, &y);
model.fit(&x, &y, 20).unwrap();
let mse_after_20 = eval_mse(&model, &x, &y);
model.fit(&x, &y, 20).unwrap();
let mse_after_40 = eval_mse(&model, &x, &y);
assert!(
mse_after_20 < mse_initial,
"Adam 2-layer: loss after 20 epochs ({mse_after_20}) should be < initial ({mse_initial})"
);
assert!(
mse_after_40 <= mse_after_20 + 1e-4,
"Adam 2-layer: loss after 40 epochs ({mse_after_40}) should not be greater than after 20 ({mse_after_20})"
);
}
#[test]
fn sgd_two_layer_loss_decreases() {
const SEED: u64 = 0;
let (x, y) = regression_data();
let layer1 = Dense::new(4, Linear::new())
.unwrap()
.with_random_state(SEED);
let layer2 = Dense::new(1, Linear::new())
.unwrap()
.with_random_state(SEED);
let mut model = SequentialBuilder::new()
.add(layer1)
.add(layer2)
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.05, 0.0, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mse_before = eval_mse(&model, &x, &y);
model.fit(&x, &y, 50).unwrap();
let mse_after = eval_mse(&model, &x, &y);
assert!(
mse_after < mse_before,
"SGD 2-layer: loss should decrease; before={mse_before}, after={mse_after}"
);
assert!(
mse_after < 0.1,
"SGD 2-layer: loss should converge near 0; after={mse_after}"
);
}
#[test]
fn rmsprop_two_layer_loss_decreases() {
const SEED: u64 = 0;
let (x, y) = regression_data();
let layer1 = Dense::new(4, Linear::new())
.unwrap()
.with_random_state(SEED);
let layer2 = Dense::new(1, Linear::new())
.unwrap()
.with_random_state(SEED);
let mut model = SequentialBuilder::new()
.add(layer1)
.add(layer2)
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
RMSprop::new(0.01, 0.9, 1e-8, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mse_before = eval_mse(&model, &x, &y);
model.fit(&x, &y, 150).unwrap();
let mse_after = eval_mse(&model, &x, &y);
assert!(
mse_after < mse_before,
"RMSprop 2-layer: loss should decrease; before={mse_before}, after={mse_after}"
);
assert!(
mse_after < 0.1,
"RMSprop 2-layer: loss should converge near 0; after={mse_after}"
);
}
#[test]
fn adagrad_two_layer_loss_decreases() {
const SEED: u64 = 0;
let (x, y) = regression_data();
let layer1 = Dense::new(4, Linear::new())
.unwrap()
.with_random_state(SEED);
let layer2 = Dense::new(1, Linear::new())
.unwrap()
.with_random_state(SEED);
let mut model = SequentialBuilder::new()
.add(layer1)
.add(layer2)
.build(&Shape::known(&[1, 1]))
.unwrap();
model.compile(
AdaGrad::new(0.5, 1e-8, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mse_before = eval_mse(&model, &x, &y);
model.fit(&x, &y, 30).unwrap();
let mse_after = eval_mse(&model, &x, &y);
assert!(
mse_after < mse_before,
"AdaGrad 2-layer: loss should decrease; before={mse_before}, after={mse_after}"
);
}
#[test]
fn sgd_one_step_weight_update_matches_hand_calculation() {
let x = Array::from_shape_vec((1, 1), vec![2.0_f32])
.unwrap()
.into_dyn();
let y = Array::from_shape_vec((1, 1), vec![6.0_f32])
.unwrap()
.into_dyn();
let w = Array::from_shape_vec((1, 1), vec![1.0_f32]).unwrap();
let b = Array::from_shape_vec((1, 1), vec![0.0_f32]).unwrap();
let mut layer = Dense::new(1, Linear::new()).unwrap();
layer.build(&Shape::known(x.shape())).unwrap();
layer.set_weights(w, b).unwrap();
let mut model = SequentialBuilder::new()
.add(layer)
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.01, 0.0, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
let pred_before = model.predict(&x).unwrap();
let val_before = *pred_before.iter().next().unwrap();
assert_abs_diff_eq!(val_before, 2.0_f32, epsilon = 1e-6);
model.fit(&x, &y, 1).unwrap();
let pred_after = model.predict(&x).unwrap();
let val_after = *pred_after.iter().next().unwrap();
assert_abs_diff_eq!(val_after, 2.40_f32, epsilon = 1e-4);
}
fn dense_wb(model: &Sequential) -> (f32, f32) {
(
model.weight("0.kernel").expect("layer 0 must be Dense")[[0, 0]],
model.weight("0.bias").expect("layer 0 must be Dense")[[0, 0]],
)
}
#[test]
fn clip_by_global_norm_scales_sgd_step() {
let x = Array::from_shape_vec((1, 1), vec![2.0_f32])
.unwrap()
.into_dyn();
let y = Array::from_shape_vec((1, 1), vec![6.0_f32])
.unwrap()
.into_dyn();
let w = Array::from_shape_vec((1, 1), vec![1.0_f32]).unwrap();
let b = Array::from_shape_vec((1, 1), vec![0.0_f32]).unwrap();
let mut layer = Dense::new(1, Linear::new()).unwrap();
layer.build(&Shape::known(x.shape())).unwrap();
layer.set_weights(w, b).unwrap();
let max_norm = 8.0_f32;
let mut model = SequentialBuilder::new()
.add(layer)
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.01, 0.0, false, 0.0)
.unwrap()
.with_global_clipnorm(max_norm)
.unwrap(),
MeanSquaredError::new(),
);
model.fit(&x, &y, 1).unwrap();
let scale = max_norm / 320.0_f32.sqrt();
let (w_new, b_new) = dense_wb(&model);
assert_abs_diff_eq!(w_new, 1.0 + 0.16 * scale, epsilon = 1e-5);
assert_abs_diff_eq!(b_new, 0.0 + 0.08 * scale, epsilon = 1e-5);
assert_abs_diff_eq!((w_new - 1.0) / (b_new), 0.16 / 0.08, epsilon = 1e-4);
}
#[test]
fn clip_by_global_norm_above_norm_is_noop() {
let x = Array::from_shape_vec((1, 1), vec![2.0_f32])
.unwrap()
.into_dyn();
let y = Array::from_shape_vec((1, 1), vec![6.0_f32])
.unwrap()
.into_dyn();
let w = Array::from_shape_vec((1, 1), vec![1.0_f32]).unwrap();
let b = Array::from_shape_vec((1, 1), vec![0.0_f32]).unwrap();
let mut layer = Dense::new(1, Linear::new()).unwrap();
layer.build(&Shape::known(x.shape())).unwrap();
layer.set_weights(w, b).unwrap();
let mut model = SequentialBuilder::new()
.add(layer)
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.01, 0.0, false, 0.0)
.unwrap()
.with_global_clipnorm(100.0)
.unwrap(),
MeanSquaredError::new(),
);
model.fit(&x, &y, 1).unwrap();
let (w_new, b_new) = dense_wb(&model);
assert_abs_diff_eq!(w_new, 1.16_f32, epsilon = 1e-5);
assert_abs_diff_eq!(b_new, 0.08_f32, epsilon = 1e-5);
}
#[test]
fn new_rejects_invalid_global_clipnorm() {
for bad in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
assert!(
matches!(
SGD::new(0.01, 0.0, false, 0.0)
.unwrap()
.with_global_clipnorm(bad),
Err(Error::InvalidParameter { .. })
),
"global_clipnorm {bad} should be rejected"
);
}
assert!(
SGD::new(0.01, 0.0, false, 0.0)
.unwrap()
.with_global_clipnorm(5.0)
.is_ok()
);
assert!(SGD::new(0.01, 0.0, false, 0.0).is_ok());
}
#[test]
fn set_learning_rate_scales_the_step() {
let x = Array::from_shape_vec((1, 1), vec![2.0_f32])
.unwrap()
.into_dyn();
let y = Array::from_shape_vec((1, 1), vec![6.0_f32])
.unwrap()
.into_dyn();
let mut layer = Dense::new(1, Linear::new()).unwrap();
layer.build(&Shape::known(&[1, 1])).unwrap();
layer
.set_weights(
Array::from_shape_vec((1, 1), vec![1.0_f32]).unwrap(),
Array::from_shape_vec((1, 1), vec![0.0_f32]).unwrap(),
)
.unwrap();
let mut model = SequentialBuilder::new()
.add(layer)
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.01, 0.0, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
model.set_learning_rate(0.02); model.fit(&x, &y, 1).unwrap();
let (w_new, b_new) = dense_wb(&model);
assert_abs_diff_eq!(w_new, 1.0 + 0.02 * 16.0, epsilon = 1e-5); assert_abs_diff_eq!(b_new, 0.02 * 8.0, epsilon = 1e-5); }
#[test]
fn sgd_decoupled_weight_decay_shrinks_param() {
let x = Array::from_shape_vec((1, 1), vec![2.0_f32])
.unwrap()
.into_dyn();
let y = Array::from_shape_vec((1, 1), vec![6.0_f32])
.unwrap()
.into_dyn();
let mut layer = Dense::new(1, Linear::new()).unwrap();
layer.build(&Shape::known(&[1, 1])).unwrap();
layer
.set_weights(
Array::from_shape_vec((1, 1), vec![1.0_f32]).unwrap(),
Array::from_shape_vec((1, 1), vec![0.0_f32]).unwrap(),
)
.unwrap();
let mut model = SequentialBuilder::new()
.add(layer)
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.01, 0.0, false, 0.5).unwrap(),
MeanSquaredError::new(),
);
model.fit(&x, &y, 1).unwrap();
let (w_new, b_new) = dense_wb(&model);
assert_abs_diff_eq!(w_new, 0.995 + 0.16, epsilon = 1e-5); assert_abs_diff_eq!(b_new, 0.08, epsilon = 1e-5); }
fn dense_after_one_sgd_step(
w0: &Array2<f32>,
b0: &Array2<f32>,
lr: f32,
weight_decay: f32,
) -> (ArrayD<f32>, ArrayD<f32>) {
let mut layer = Dense::new(2, Linear::new()).unwrap();
layer.build(&Shape::known(&[1, 2])).unwrap();
layer.set_weights(w0.clone(), b0.clone()).unwrap();
let x = Array::from_shape_vec((1, 2), vec![1.0_f32, 2.0])
.unwrap()
.into_dyn();
let mut ctx = Ctx::training();
let _ = layer.forward(&x, &mut ctx).unwrap();
let grad_out = Array::from_shape_vec((1, 2), vec![0.7_f32, -1.3])
.unwrap()
.into_dyn();
layer.backward(&grad_out, &mut ctx).unwrap();
let mut opt = SGD::new(lr, 0.0, false, weight_decay).unwrap();
opt.step();
opt.update(0, &mut layer, ctx.grads(), 1.0);
(
named(&layer, "kernel").to_owned(),
named(&layer, "bias").to_owned(),
)
}
#[test]
fn weight_decay_decays_dense_weights_but_skips_bias() {
let w0 = Array::from_shape_vec((2, 2), vec![1.0_f32, -2.0, 3.0, -4.0]).unwrap();
let b0 = Array::from_shape_vec((1, 2), vec![0.5_f32, -1.5]).unwrap();
let (lr, wd) = (0.1_f32, 0.5_f32);
let (w_plain, b_plain) = dense_after_one_sgd_step(&w0, &b0, lr, 0.0);
let (w_decay, b_decay) = dense_after_one_sgd_step(&w0, &b0, lr, wd);
for i in 0..2 {
assert_abs_diff_eq!(b_decay[[0, i]], b_plain[[0, i]], epsilon = 1e-6);
}
for i in 0..2 {
for j in 0..2 {
assert_abs_diff_eq!(
w_decay[[i, j]],
w_plain[[i, j]] - lr * wd * w0[[i, j]],
epsilon = 1e-6
);
}
}
assert!(
(w_decay[[0, 0]] - w_plain[[0, 0]]).abs() > 1e-4,
"weight decay should change the weights"
);
}
fn batchnorm_gamma_beta_after_one_sgd_step(weight_decay: f32) -> (ArrayD<f32>, ArrayD<f32>) {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let x = Array::from_shape_vec((2, 3), vec![1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0])
.unwrap()
.into_dyn();
let mut ctx = Ctx::training();
let _ = bn.forward_mut(&x, &mut ctx).unwrap();
let grad_out = Array::from_shape_vec((2, 3), vec![0.5_f32, -0.5, 1.0, -1.0, 0.25, -0.25])
.unwrap()
.into_dyn();
bn.backward(&grad_out, &mut ctx).unwrap();
let mut opt = SGD::new(0.1, 0.0, false, weight_decay).unwrap();
opt.step();
opt.update(0, &mut bn, ctx.grads(), 1.0);
(
named(&bn, "gamma").to_owned(),
named(&bn, "beta").to_owned(),
)
}
#[test]
fn weight_decay_skips_batchnorm_gamma_and_beta() {
let (g_plain, b_plain) = batchnorm_gamma_beta_after_one_sgd_step(0.0);
let (g_decay, b_decay) = batchnorm_gamma_beta_after_one_sgd_step(0.5);
assert_eq!(g_plain.shape(), g_decay.shape());
for (p, d) in g_plain.iter().zip(g_decay.iter()) {
assert_abs_diff_eq!(*p, *d, epsilon = 1e-6);
}
for (p, d) in b_plain.iter().zip(b_decay.iter()) {
assert_abs_diff_eq!(*p, *d, epsilon = 1e-6);
}
assert!(
g_plain.iter().any(|&v| (v - 1.0).abs() > 1e-5),
"gamma should have a non-trivial gradient update"
);
}
fn dense_weights_after_one_step<O: Optimizer>(
mut opt: O,
w0: &Array2<f32>,
b0: &Array2<f32>,
) -> (ArrayD<f32>, ArrayD<f32>) {
let mut layer = Dense::new(2, Linear::new()).unwrap();
layer.build(&Shape::known(&[1, 2])).unwrap();
layer.set_weights(w0.clone(), b0.clone()).unwrap();
let x = Array::from_shape_vec((1, 2), vec![1.0_f32, 2.0])
.unwrap()
.into_dyn();
let mut ctx = Ctx::training();
let _ = layer.forward(&x, &mut ctx).unwrap();
let grad_out = Array::from_shape_vec((1, 2), vec![0.7_f32, -1.3])
.unwrap()
.into_dyn();
layer.backward(&grad_out, &mut ctx).unwrap();
opt.step();
opt.update(0, &mut layer, ctx.grads(), 1.0);
(
named(&layer, "kernel").to_owned(),
named(&layer, "bias").to_owned(),
)
}
#[test]
fn adam_equals_adamw_without_weight_decay() {
let w0 = Array::from_shape_vec((2, 2), vec![1.0_f32, -2.0, 3.0, -4.0]).unwrap();
let b0 = Array::from_shape_vec((1, 2), vec![0.5_f32, -1.5]).unwrap();
let (w_adam, b_adam) =
dense_weights_after_one_step(Adam::new(0.1, 0.9, 0.999, 1e-8, 0.0).unwrap(), &w0, &b0);
let (w_adamw, b_adamw) =
dense_weights_after_one_step(AdamW::new(0.1, 0.9, 0.999, 1e-8, 0.0).unwrap(), &w0, &b0);
for (a, w) in w_adam.iter().zip(w_adamw.iter()) {
assert_abs_diff_eq!(*a, *w, epsilon = 1e-7);
}
for (a, w) in b_adam.iter().zip(b_adamw.iter()) {
assert_abs_diff_eq!(*a, *w, epsilon = 1e-7);
}
}
#[test]
fn adam_l2_and_adamw_decoupled_differ_with_weight_decay() {
let w0 = Array::from_shape_vec((2, 2), vec![1.0_f32, -2.0, 3.0, -4.0]).unwrap();
let b0 = Array::from_shape_vec((1, 2), vec![0.5_f32, -1.5]).unwrap();
let wd = 0.5_f32;
let (w_adam, b_adam) =
dense_weights_after_one_step(Adam::new(0.1, 0.9, 0.999, 1e-8, wd).unwrap(), &w0, &b0);
let (w_adamw, b_adamw) =
dense_weights_after_one_step(AdamW::new(0.1, 0.9, 0.999, 1e-8, wd).unwrap(), &w0, &b0);
assert!(
w_adam
.iter()
.zip(w_adamw.iter())
.any(|(a, w)| (a - w).abs() > 1e-5),
"coupled (Adam) and decoupled (AdamW) weight decay should produce different weights"
);
for (a, w) in b_adam.iter().zip(b_adamw.iter()) {
assert_abs_diff_eq!(*a, *w, epsilon = 1e-7);
}
}
#[test]
fn adamw_single_layer_loss_decreases_over_20_epochs() {
let (x, y) = regression_data();
let mut model = SequentialBuilder::new()
.add(identity_dense())
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
AdamW::new(0.1, 0.9, 0.999, 1e-8, 0.01).unwrap(),
MeanSquaredError::new(),
);
let mse_before = eval_mse(&model, &x, &y);
model.fit(&x, &y, 20).unwrap();
let mse_after = eval_mse(&model, &x, &y);
assert!(
mse_after < mse_before,
"AdamW: loss should decrease; before={mse_before}, after={mse_after}"
);
}
#[test]
fn adamw_validates_hyperparameters() {
assert!(matches!(
AdamW::new(0.001, 1.0, 0.999, 1e-8, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
AdamW::new(0.001, 0.9, 0.999, 1e-8, -0.1),
Err(Error::InvalidParameter { .. })
));
assert!(AdamW::new(0.001, 0.9, 0.999, 1e-8, 0.01).is_ok());
assert!(
AdamW::new(0.001, 0.9, 0.999, 1e-8, 0.0)
.unwrap()
.with_global_clipnorm(1.0)
.is_ok()
);
}
#[test]
fn sgd_momentum_loss_decreases() {
let (x, y) = regression_data();
let mut model = SequentialBuilder::new()
.add(identity_dense())
.build(&Shape::known(x.shape()))
.unwrap();
model.compile(
SGD::new(0.05, 0.9, true, 0.0).unwrap(),
MeanSquaredError::new(),
);
let before = eval_mse(&model, &x, &y);
model.fit(&x, &y, 5).unwrap();
let after = eval_mse(&model, &x, &y);
assert!(
after < before,
"SGD+momentum should reduce loss; before={before}, after={after}"
);
}
#[test]
fn new_rejects_negative_momentum_and_weight_decay() {
assert!(matches!(
SGD::new(0.01, -0.1, false, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
SGD::new(0.01, 0.0, false, -0.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Adam::new(0.001, 0.9, 0.999, 1e-8, -0.1),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn every_optimizer_reports_its_current_learning_rate() {
fn round_trip(mut opt: impl Optimizer, configured: f32) {
assert_abs_diff_eq!(opt.learning_rate(), configured, epsilon = 0.0_f32);
let decayed = opt.learning_rate() * 0.1;
opt.set_learning_rate(decayed);
assert_abs_diff_eq!(opt.learning_rate(), configured * 0.1, epsilon = 1e-9);
}
round_trip(SGD::new(0.05, 0.9, false, 0.0).unwrap(), 0.05);
round_trip(Adam::new(0.003, 0.9, 0.999, 1e-8, 0.0).unwrap(), 0.003);
round_trip(AdamW::new(0.003, 0.9, 0.999, 1e-8, 0.01).unwrap(), 0.003);
round_trip(RMSprop::new(0.002, 0.9, 1e-8, 0.0).unwrap(), 0.002);
round_trip(AdaGrad::new(0.02, 1e-8, 0.0).unwrap(), 0.02);
}
fn pass_through_dense() -> Dense {
let mut layer = Dense::new(2, Linear::new()).unwrap();
layer.build(&Shape::known(&[1, 2])).unwrap();
let weights = Array::from_shape_vec((2, 2), vec![1.0_f32, 0.0, 0.0, 1.0]).unwrap();
let bias = Array::from_shape_vec((1, 2), vec![0.0_f32, 0.0]).unwrap();
layer.set_weights(weights, bias).unwrap();
layer
}
fn one_pass(layer: &mut Dense, scope: usize) -> Ctx {
let mut ctx = Ctx::training();
ctx.set_owner(scope);
let x = Array::from_shape_vec((1, 2), vec![1.0_f32, 2.0])
.unwrap()
.into_dyn();
let _ = layer.forward(&x, &mut ctx).unwrap();
let grad_out = Array::from_shape_vec((1, 2), vec![0.7_f32, -1.3])
.unwrap()
.into_dyn();
layer.backward(&grad_out, &mut ctx).unwrap();
ctx
}
fn dense_kernel(layer: &Dense) -> ArrayD<f32> {
named(layer, "kernel").to_owned()
}
fn assert_same_kernel(got: &ArrayD<f32>, want: &ArrayD<f32>, message: &str) {
assert_eq!(got.shape(), want.shape());
for (g, w) in got.iter().zip(want.iter()) {
assert!((*g - *w).abs() <= 1e-6, "{message}: {g} != {w}");
}
}
#[test]
fn a_changing_parameter_count_must_not_move_another_layer_state() {
let mut quiet = pass_through_dense();
let mut tracked = pass_through_dense();
let mut opt = SGD::new(0.1, 0.9, false, 0.0).unwrap();
let quiet_ctx = Ctx::training();
let tracked_ctx = one_pass(&mut tracked, 1);
assert!(
quiet_ctx.grads().is_empty(),
"a layer that ran no backward pass must hold no gradient"
);
assert_eq!(tracked_ctx.grads().len(), 2);
opt.step();
opt.update(0, &mut quiet, quiet_ctx.grads(), 1.0);
opt.update(1, &mut tracked, tracked_ctx.grads(), 1.0);
let quiet_ctx = one_pass(&mut quiet, 0);
let tracked_ctx = one_pass(&mut tracked, 1);
assert_eq!(quiet_ctx.grads().len(), 2);
opt.step();
opt.update(0, &mut quiet, quiet_ctx.grads(), 1.0);
opt.update(1, &mut tracked, tracked_ctx.grads(), 1.0);
let mut tracked_control = pass_through_dense();
let mut tracked_control_opt = SGD::new(0.1, 0.9, false, 0.0).unwrap();
for _ in 0..2 {
let ctx = one_pass(&mut tracked_control, 1);
tracked_control_opt.step();
tracked_control_opt.update(1, &mut tracked_control, ctx.grads(), 1.0);
}
let mut quiet_control = pass_through_dense();
let mut quiet_control_opt = SGD::new(0.1, 0.9, false, 0.0).unwrap();
let quiet_control_ctx = one_pass(&mut quiet_control, 0);
quiet_control_opt.step();
quiet_control_opt.update(0, &mut quiet_control, quiet_control_ctx.grads(), 1.0);
assert_same_kernel(
&dense_kernel(&tracked),
&dense_kernel(&tracked_control),
"the momentum of `tracked` must not move when another layer starts yielding parameters",
);
assert_same_kernel(
&dense_kernel(&quiet),
&dense_kernel(&quiet_control),
"`quiet` must start from its own zero momentum, not inherit another layer's",
);
}
fn shifted_dense() -> Dense {
let mut layer = Dense::new(2, Linear::new()).unwrap();
layer.build(&Shape::known(&[1, 2])).unwrap();
let weights = Array::from_shape_vec((2, 2), vec![1.0_f32, 0.0, 0.0, 1.0]).unwrap();
let bias = Array::from_shape_vec((1, 2), vec![1.0_f32, 0.0]).unwrap();
layer.set_weights(weights, bias).unwrap();
layer
}
fn pass_through_dense_no_bias() -> Dense {
let mut layer = Dense::new(2, Linear::new()).unwrap().with_use_bias(false);
layer.build(&Shape::known(&[1, 2])).unwrap();
let weights = Array::from_shape_vec((2, 2), vec![1.0_f32, 0.0, 0.0, 1.0]).unwrap();
layer.set_weights(weights, None::<Array2<f32>>).unwrap();
layer
}
#[test]
fn a_second_layer_moves_no_state_of_the_first() {
let x = Array::from_shape_vec((2, 2), vec![0.0_f32, 2.0, 1.0, 4.0])
.unwrap()
.into_dyn();
let y = Array::from_shape_vec((2, 2), vec![-1.0_f32, 0.0, 3.0, 5.0])
.unwrap()
.into_dyn();
let mut plain = SequentialBuilder::new()
.add(shifted_dense())
.build(&Shape::known(x.shape()))
.unwrap();
plain.compile(
SGD::new(0.1, 0.9, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
plain.train_batch(&x, &y).unwrap();
plain.train_batch(&x, &y).unwrap();
let mut stacked = SequentialBuilder::new()
.add(shifted_dense())
.add(pass_through_dense_no_bias())
.build(&Shape::known(x.shape()))
.unwrap();
stacked.compile(
SGD::new(0.1, 0.9, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
stacked.train_batch(&x, &y).unwrap();
stacked.train_batch(&x, &y).unwrap();
let kernel_of = |model: &Sequential, path: &str| {
model
.weight(path)
.unwrap_or_else(|| panic!("{path} must name a kernel"))
.to_owned()
};
let mut probe = SequentialBuilder::new()
.add(shifted_dense())
.add(pass_through_dense_no_bias())
.build(&Shape::known(x.shape()))
.unwrap();
probe.compile(
SGD::new(0.1, 0.9, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
probe.train_batch(&x, &y).unwrap();
assert_same_kernel(
&kernel_of(&probe, "1.kernel"),
&Array::from_shape_vec((2, 2), vec![1.0_f32, 0.0, 0.0, 1.0])
.unwrap()
.into_dyn(),
"the second layer must take no step in batch 1",
);
assert_same_kernel(
&kernel_of(&stacked, "0.kernel"),
&kernel_of(&plain, "0.kernel"),
"the first layer must keep its own momentum when a second layer stands after it",
);
}
struct RosterLayer {
gamma: Vec<f32>,
beta: Vec<f32>,
}
impl RosterLayer {
fn new() -> Self {
Self {
gamma: vec![0.0; 4],
beta: vec![0.0; 4],
}
}
}
const ROSTER_GRAD: [f32; 4] = [0.5, -0.25, 1.0, -2.0];
impl LayerBase for RosterLayer {
fn param_count(&self) -> ParamCounts {
ParamCounts::trainable(self.gamma.len() + self.beta.len())
}
fn parameters_mut(&mut self) -> Vec<ParamRef<'_>> {
vec![
ParamRef::no_decay("gamma", &mut self.gamma),
ParamRef::no_decay("beta", &mut self.beta),
]
}
fn weights(&self) -> Vec<WeightRef<'_>> {
Vec::new()
}
fn weights_mut(&mut self) -> Vec<WeightMut<'_>> {
Vec::new()
}
}
fn roster_grads(with_gamma: bool) -> Ctx {
let mut ctx = Ctx::training();
if with_gamma {
ctx.add_grad("gamma", Array::from_vec(ROSTER_GRAD.to_vec()).into_dyn())
.unwrap();
}
ctx.add_grad("beta", Array::from_vec(ROSTER_GRAD.to_vec()).into_dyn())
.unwrap();
ctx
}
#[test]
fn a_tensor_joining_the_roster_must_not_take_another_tensor_state() {
let mut layer = RosterLayer::new();
let mut opt = SGD::new(0.1, 0.9, false, 0.0).unwrap();
opt.step();
opt.update(0, &mut layer, roster_grads(false).grads(), 1.0);
opt.step();
opt.update(0, &mut layer, roster_grads(true).grads(), 1.0);
let mut beta_control = RosterLayer::new();
let mut beta_control_opt = SGD::new(0.1, 0.9, false, 0.0).unwrap();
for _ in 0..2 {
beta_control_opt.step();
beta_control_opt.update(0, &mut beta_control, roster_grads(false).grads(), 1.0);
}
let mut gamma_control = RosterLayer::new();
let mut gamma_control_opt = SGD::new(0.1, 0.9, false, 0.0).unwrap();
gamma_control_opt.step();
gamma_control_opt.update(0, &mut gamma_control, roster_grads(true).grads(), 1.0);
for i in 0..4 {
assert!(
(layer.beta[i] - beta_control.beta[i]).abs() <= 1e-6,
"beta must keep its own momentum when gamma joins: {} != {}",
layer.beta[i],
beta_control.beta[i]
);
assert!(
(layer.gamma[i] - gamma_control.gamma[i]).abs() <= 1e-6,
"gamma must start from its own zero momentum: {} != {}",
layer.gamma[i],
gamma_control.gamma[i]
);
}
assert!(layer.beta[0].abs() > 1e-4 && layer.gamma[0].abs() > 1e-4);
}
const FIRST_LAYER_GRAD: [f32; 2] = [0.7, -1.3];
const SECOND_LAYER_GRAD: [f32; 2] = [-0.4, 2.1];
fn one_pass_with(layer: &mut Dense, scope: usize, grad_out: [f32; 2]) -> Ctx {
let mut ctx = Ctx::training();
ctx.set_owner(scope);
let x = Array::from_shape_vec((1, 2), vec![1.0_f32, 2.0])
.unwrap()
.into_dyn();
let _ = layer.forward(&x, &mut ctx).unwrap();
let grad = Array::from_shape_vec((1, 2), grad_out.to_vec())
.unwrap()
.into_dyn();
layer.backward(&grad, &mut ctx).unwrap();
ctx
}
fn scope_must_separate_2_same_shape_layers<O: Optimizer>(make: impl Fn() -> O, optimizer: &str) {
const STEPS: usize = 3;
let mut first = pass_through_dense();
let mut second = pass_through_dense();
let mut opt = make();
for _ in 0..STEPS {
let first_ctx = one_pass_with(&mut first, 0, FIRST_LAYER_GRAD);
let second_ctx = one_pass_with(&mut second, 1, SECOND_LAYER_GRAD);
opt.step();
opt.update(0, &mut first, first_ctx.grads(), 1.0);
opt.update(1, &mut second, second_ctx.grads(), 1.0);
}
let mut first_control = pass_through_dense();
let mut second_control = pass_through_dense();
let mut first_opt = make();
let mut second_opt = make();
for _ in 0..STEPS {
let first_ctx = one_pass_with(&mut first_control, 0, FIRST_LAYER_GRAD);
let second_ctx = one_pass_with(&mut second_control, 1, SECOND_LAYER_GRAD);
first_opt.step();
second_opt.step();
first_opt.update(0, &mut first_control, first_ctx.grads(), 1.0);
second_opt.update(1, &mut second_control, second_ctx.grads(), 1.0);
}
let first_kernel = dense_kernel(&first);
let second_kernel = dense_kernel(&second);
assert_same_kernel(
&first_kernel,
&dense_kernel(&first_control),
&format!(
"{optimizer}: the layer at scope 0 must not read the state of the layer at scope 1"
),
);
assert_same_kernel(
&second_kernel,
&dense_kernel(&second_control),
&format!(
"{optimizer}: the layer at scope 1 must not read the state of the layer at scope 0"
),
);
let start = dense_kernel(&pass_through_dense());
assert!(
first_kernel
.iter()
.zip(start.iter())
.any(|(a, b)| (a - b).abs() > 1e-4),
"{optimizer}: the layer at scope 0 must have moved"
);
assert!(
second_kernel
.iter()
.zip(start.iter())
.any(|(a, b)| (a - b).abs() > 1e-4),
"{optimizer}: the layer at scope 1 must have moved"
);
assert!(
first_kernel
.iter()
.zip(second_kernel.iter())
.any(|(a, b)| (a - b).abs() > 1e-4),
"{optimizer}: the 2 layers must reach different weights"
);
}
#[test]
fn sgd_scope_must_separate_2_same_shape_layers() {
scope_must_separate_2_same_shape_layers(|| SGD::new(0.1, 0.9, false, 0.0).unwrap(), "SGD");
}
#[test]
fn adam_scope_must_separate_2_same_shape_layers() {
scope_must_separate_2_same_shape_layers(
|| Adam::new(0.05, 0.9, 0.999, 1e-8, 0.0).unwrap(),
"Adam",
);
}
#[test]
fn adamw_scope_must_separate_2_same_shape_layers() {
scope_must_separate_2_same_shape_layers(
|| AdamW::new(0.05, 0.9, 0.999, 1e-8, 0.01).unwrap(),
"AdamW",
);
}
#[test]
fn rmsprop_scope_must_separate_2_same_shape_layers() {
scope_must_separate_2_same_shape_layers(
|| RMSprop::new(0.05, 0.9, 1e-8, 0.0).unwrap(),
"RMSprop",
);
}
#[test]
fn adagrad_scope_must_separate_2_same_shape_layers() {
scope_must_separate_2_same_shape_layers(|| AdaGrad::new(0.1, 1e-8, 0.0).unwrap(), "AdaGrad");
}
#[test]
fn sgd_momentum_velocity_must_survive_between_steps() {
let mut plain = pass_through_dense();
let mut plain_opt = SGD::new(0.1, 0.0, false, 0.0).unwrap();
let plain_ctx = one_pass(&mut plain, 0);
plain_opt.step();
plain_opt.update(0, &mut plain, plain_ctx.grads(), 1.0);
let start = dense_kernel(&pass_through_dense());
let unit = &start - &dense_kernel(&plain);
assert!(
unit.iter().any(|d| d.abs() > 1e-4),
"the plain step must move the kernel, or every comparison below is vacuous"
);
for (nesterov, factor) in [(false, 5.61_f32), (true, 8.049_f32)] {
let mut layer = pass_through_dense();
let mut opt = SGD::new(0.1, 0.9, nesterov, 0.0).unwrap();
for _ in 0..3 {
let ctx = one_pass(&mut layer, 0);
opt.step();
opt.update(0, &mut layer, ctx.grads(), 1.0);
}
let got = dense_kernel(&layer);
for ((g, s), u) in got.iter().zip(start.iter()).zip(unit.iter()) {
let want = *s - factor * *u;
assert!(
(*g - want).abs() <= 1e-4,
"3 momentum steps with nesterov={nesterov} must sum to {factor} plain steps: \
{g} != {want}"
);
}
}
}