use ndarray::{ArrayD, Dimension};
use rustyml::neural_network::Ctx;
use rustyml::neural_network::Shape;
use rustyml::neural_network::layers::regularization::normalization::batch_normalization::BatchNormalization;
use rustyml::neural_network::layers::regularization::normalization::layer_normalization::{
LayerNormalization, LayerNormalizationAxis,
};
use rustyml::neural_network::traits::{LayerBase, ParamId, UnaryLayer};
use rustyml::{error::Error, neural_network::NnError};
use crate::common::assert_allclose;
fn tensor1(data: Vec<f32>) -> rustyml::neural_network::Tensor {
let n = data.len();
ArrayD::from_shape_vec(vec![n], data).expect("tensor1: shape/data mismatch")
}
fn tensor2(data: Vec<f32>, rows: usize, cols: usize) -> rustyml::neural_network::Tensor {
ArrayD::from_shape_vec(vec![rows, cols], data).expect("tensor2: shape/data mismatch")
}
#[test]
fn bn_build_rejects_empty_input_shape() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let result = bn.build(&Shape::known(&[]));
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"expected InvalidInput, got {:?}",
result
);
}
#[test]
fn bn_constructor_rejects_invalid_scalar_params() {
let cases = [
(1.5, 1e-5, "momentum > 1.0"),
(-0.1, 1e-5, "momentum < 0.0"),
(0.9, 0.0, "epsilon == 0.0"),
(0.9, -1e-5, "epsilon < 0.0"),
];
for (momentum, epsilon, label) in cases {
let result = BatchNormalization::new(momentum, epsilon);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for {label} (momentum={momentum}, epsilon={epsilon}), got {:?}",
result
);
}
}
#[test]
fn bn_constructor_accepts_boundary_momentum_values() {
assert!(BatchNormalization::new(0.0, 1e-5).is_ok());
assert!(BatchNormalization::new(1.0, 1e-5).is_ok());
}
#[test]
fn bn_forward_rejects_wrong_input_shape() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
bn.build(&Shape::known(&[4, 3])).unwrap();
let wrong = tensor2(vec![1.0f32; 8], 4, 2);
let result = bn.forward(&wrong, &mut Ctx::training());
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"expected InvalidInput, got {:?}",
result
);
}
#[test]
fn bn_forward_accepts_different_batch_size() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
bn.build(&Shape::known(&[4, 3])).unwrap();
let smaller = tensor2(vec![1.0f32; 6], 2, 3);
let out = bn
.forward(&smaller, &mut Ctx::training())
.expect("a smaller batch must be accepted");
assert_eq!(out.shape(), &[2, 3]);
}
#[test]
fn bn_train_output_has_batch_mean_zero_and_var_one() {
let data = vec![
2.0f32, 4.0, 6.0, 4.0, 2.0, 8.0, 6.0, 4.0, 4.0, 8.0, 2.0, 6.0, ];
let input = tensor2(data, 4, 3);
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let output = bn.forward_mut(&input, &mut Ctx::training()).unwrap();
assert_eq!(output.shape(), &[4, 3]);
for feat in 0..3 {
let col: Vec<f32> = (0..4).map(|r| output[[r, feat]]).collect();
let mean: f32 = col.iter().sum::<f32>() / 4.0;
let var: f32 = col.iter().map(|&v| (v - mean).powi(2)).sum::<f32>() / 4.0;
assert!(
mean.abs() < 1e-5,
"feature {feat}: batch mean too far from 0, got {mean}"
);
assert!(
(var - 1.0).abs() < 5e-4,
"feature {feat}: batch var too far from 1, got {var}"
);
}
}
#[test]
fn bn_train_forward_concrete_values_4x1() {
let input = tensor2(vec![2.0f32, 4.0, 6.0, 8.0], 4, 1);
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let output = bn.forward_mut(&input, &mut Ctx::training()).unwrap();
let std = (5.0f32 + 1e-5f32).sqrt();
let expected = tensor2(vec![-3.0 / std, -1.0 / std, 1.0 / std, 3.0 / std], 4, 1);
assert_allclose(&output, &expected, 1e-5);
}
#[test]
fn bn_running_stats_update_after_one_forward() {
let input_train = tensor2(vec![2.0f32, 4.0, 6.0, 8.0], 4, 1);
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let mut ctx = Ctx::training();
bn.forward_mut(&input_train, &mut ctx).unwrap();
bn.apply_state(&mut ctx.state_slot(0));
let input_eval = tensor2(vec![5.0f32, 5.0, 5.0, 5.0], 4, 1);
let output = bn.forward(&input_eval, &mut Ctx::inference()).unwrap();
let expected_val = 4.5_f32 / (1.4f32 + 1e-5f32).sqrt();
let expected = tensor2(vec![expected_val; 4], 4, 1);
assert_allclose(&output, &expected, 1e-4);
}
#[test]
fn bn_eval_uses_running_stats_from_set_weights() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
bn.build(&Shape::known(&[2, 3])).unwrap();
let gamma = tensor1(vec![1.0f32, 1.0, 1.0]);
let beta = tensor1(vec![0.0f32, 0.0, 0.0]);
let running_mean = tensor1(vec![1.0f32, 2.0, 3.0]);
let running_var = tensor1(vec![4.0f32, 9.0, 1.0]);
bn.set_weights(gamma, beta, running_mean, running_var)
.unwrap();
let input = tensor2(vec![1.0f32, 2.0, 3.0, 5.0, 8.0, 4.0], 2, 3);
let output = bn.forward(&input, &mut Ctx::inference()).unwrap();
let eps = 1e-5f32;
let e00 = 0.0f32;
let e01 = 0.0f32;
let e02 = 0.0f32;
let e10 = 4.0 / (4.0f32 + eps).sqrt();
let e11 = 6.0 / (9.0f32 + eps).sqrt();
let e12 = 1.0 / (1.0f32 + eps).sqrt();
let expected = tensor2(vec![e00, e01, e02, e10, e11, e12], 2, 3);
assert_allclose(&output, &expected, 1e-4);
}
#[test]
fn bn_predict_equals_forward_in_eval_mode() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
bn.build(&Shape::known(&[2, 3])).unwrap();
let gamma = tensor1(vec![1.0f32, 1.0, 1.0]);
let beta = tensor1(vec![0.0f32, 0.0, 0.0]);
let running_mean = tensor1(vec![1.0f32, 2.0, 3.0]);
let running_var = tensor1(vec![4.0f32, 9.0, 1.0]);
bn.set_weights(gamma, beta, running_mean, running_var)
.unwrap();
let input = tensor2(vec![1.0f32, 2.0, 3.0, 5.0, 8.0, 4.0], 2, 3);
let mut first = Ctx::inference();
let out_forward = bn.forward(&input, &mut first).unwrap();
let mut second = Ctx::inference();
let out_again = bn.forward(&input, &mut second).unwrap();
assert_eq!(first.pending_caches(), 0);
assert!(!first.has_state(0));
assert_allclose(&out_forward, &out_again, 0.0f32);
}
#[test]
fn bn_set_weights_rejects_wrong_gamma_shape() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
bn.build(&Shape::known(&[4, 3])).unwrap();
let gamma_bad = tensor1(vec![1.0f32, 1.0]); let beta = tensor1(vec![0.0f32, 0.0, 0.0]);
let rm = tensor1(vec![0.0f32, 0.0, 0.0]);
let rv = tensor1(vec![1.0f32, 1.0, 1.0]);
let result = bn.set_weights(gamma_bad, beta, rm, rv);
assert!(
matches!(
result,
Err(Error::NeuralNetwork(NnError::WeightShape { .. }))
),
"expected NnError::WeightShape, got {:?}",
result
);
}
#[test]
fn bn_eval_applies_custom_gamma_and_beta() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
bn.build(&Shape::known(&[1, 1])).unwrap();
let gamma = tensor1(vec![2.0f32]);
let beta = tensor1(vec![1.0f32]);
let running_mean = tensor1(vec![0.0f32]);
let running_var = tensor1(vec![1.0f32]);
bn.set_weights(gamma, beta, running_mean, running_var)
.unwrap();
let input = tensor2(vec![3.0f32], 1, 1);
let output = bn.forward(&input, &mut Ctx::inference()).unwrap();
let eps = 1e-5f32;
let x_norm = 3.0_f32 / (1.0f32 + eps).sqrt();
let expected_val = x_norm * 2.0 + 1.0;
let expected = tensor2(vec![expected_val], 1, 1);
assert_allclose(&output, &expected, 1e-4);
}
#[test]
fn bn_running_stats_accumulate_over_multiple_forwards() {
let mut bn = BatchNormalization::new(0.5, 1e-5).unwrap();
let x = tensor2(vec![0.0f32, 2.0], 2, 1);
let mut first = Ctx::training();
let mut second = Ctx::training();
bn.forward_mut(&x, &mut first).unwrap();
bn.apply_state(&mut first.state_slot(0));
bn.forward(&x, &mut second).unwrap();
bn.apply_state(&mut second.state_slot(0));
let x_eval = tensor2(vec![1.0f32, 5.0], 2, 1);
let output = bn.forward(&x_eval, &mut Ctx::inference()).unwrap();
let denom = (1.0f32 + 1e-5f32).sqrt();
let e0 = (1.0 - 0.75) / denom;
let e1 = (5.0 - 0.75) / denom;
let expected = tensor2(vec![e0, e1], 2, 1);
assert_allclose(&output, &expected, 1e-4);
}
#[test]
fn bn_uniform_batch_output_is_zero() {
let data = vec![
3.0f32, 7.0, -2.0, 3.0, 7.0, -2.0, 3.0, 7.0, -2.0, 3.0, 7.0, -2.0, ];
let input = tensor2(data, 4, 3);
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let output = bn.forward_mut(&input, &mut Ctx::training()).unwrap();
let zeros = tensor2(vec![0.0f32; 12], 4, 3);
assert_allclose(&output, &zeros, 1e-6);
}
#[test]
fn bn_training_and_eval_modes_produce_different_outputs() {
let input = tensor2(vec![1.0f32, 1.0, 3.0, 3.0], 2, 2);
let mut bn_train = BatchNormalization::new(0.9, 1e-5).unwrap();
let out_train = bn_train.forward_mut(&input, &mut Ctx::training()).unwrap();
let mut bn_eval = BatchNormalization::new(0.9, 1e-5).unwrap();
let out_eval = bn_eval.forward_mut(&input, &mut Ctx::inference()).unwrap();
let differs = out_train
.iter()
.zip(out_eval.iter())
.any(|(a, b)| (a - b).abs() > 1e-5);
assert!(
differs,
"training and eval outputs are unexpectedly identical"
);
}
#[test]
fn ln_constructor_rejects_invalid_epsilon() {
let cases = [(0.0, "epsilon == 0.0"), (-1e-5, "epsilon < 0.0")];
for (epsilon, label) in cases {
let result = LayerNormalization::new(epsilon);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for {label} (epsilon={epsilon}), got {:?}",
result
);
}
}
#[test]
fn ln_constructor_rejects_multiple_empty_axes() {
let result = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Multiple(vec![]));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn ln_constructor_rejects_multiple_out_of_bounds_axis() {
let mut ln = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Multiple(vec![0, 2]))
.unwrap();
let result = ln.build(&Shape::known(&[4, 3]));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn ln_constructor_rejects_multiple_duplicate_axes() {
let result = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Multiple(vec![0, 0]));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn ln_default_each_sample_has_mean_zero_and_var_one() {
let data = vec![
1.0f32, 3.0, 5.0, 7.0, 2.0, -2.0, 0.0, 4.0, ];
let input = tensor2(data, 2, 4);
let mut ln = LayerNormalization::new(1e-5).unwrap();
let output = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
assert_eq!(output.shape(), &[2, 4]);
for row in 0..2 {
let vals: Vec<f32> = (0..4).map(|c| output[[row, c]]).collect();
let mean: f32 = vals.iter().sum::<f32>() / 4.0;
let var: f32 = vals.iter().map(|&v| (v - mean).powi(2)).sum::<f32>() / 4.0;
assert!(mean.abs() < 1e-5, "row {row}: mean {mean} too far from 0");
assert!(
(var - 1.0).abs() < 5e-4,
"row {row}: var {var} too far from 1"
);
}
}
#[test]
fn ln_default_forward_concrete_values() {
let data = vec![
1.0f32, 3.0, 5.0, 7.0, 2.0, -2.0, 0.0, 4.0, ];
let input = tensor2(data, 2, 4);
let mut ln = LayerNormalization::new(1e-5).unwrap();
let output = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
let std_val = (5.0f32 + 1e-5f32).sqrt();
let expected = tensor2(
vec![
-3.0 / std_val,
-1.0 / std_val,
1.0 / std_val,
3.0 / std_val,
1.0 / std_val,
-3.0 / std_val,
-1.0 / std_val,
3.0 / std_val,
],
2,
4,
);
assert_allclose(&output, &expected, 1e-5);
}
#[test]
fn ln_custom_axis0_concrete_values() {
let data = vec![1.0f32, 4.0, 3.0, 2.0, 5.0, 6.0];
let input = tensor2(data, 3, 2);
let mut ln = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Custom(0))
.unwrap();
let output = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
let std_val = (8.0f32 / 3.0 + 1e-5f32).sqrt();
let expected = tensor2(
vec![
-2.0 / std_val,
0.0 / std_val,
0.0 / std_val,
-2.0 / std_val,
2.0 / std_val,
2.0 / std_val,
],
3,
2,
);
assert_allclose(&output, &expected, 1e-5);
}
#[test]
fn ln_custom_axis0_each_column_has_mean_zero_and_var_one() {
let data: Vec<f32> = (0..15).map(|v| v as f32 * 1.3 - 4.0).collect();
let input = tensor2(data, 5, 3);
let mut ln = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Custom(0))
.unwrap();
let output = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
assert_eq!(output.shape(), &[5, 3]);
for col in 0..3 {
let vals: Vec<f32> = (0..5).map(|r| output[[r, col]]).collect();
let mean: f32 = vals.iter().sum::<f32>() / 5.0;
let var: f32 = vals.iter().map(|&v| (v - mean).powi(2)).sum::<f32>() / 5.0;
assert!(mean.abs() < 1e-5, "col {col}: mean {mean} too far from 0");
assert!(
(var - 1.0).abs() < 5e-4,
"col {col}: var {var} too far from 1"
);
}
}
#[test]
fn ln_multiple_axes_output_has_mean_zero_and_var_one() {
let data: Vec<f32> = (0..12).map(|i| 0.5 * i as f32 - 2.75).collect();
let input = tensor2(data, 3, 4);
let mut ln = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Multiple(vec![0, 1]))
.unwrap();
let output = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
assert_eq!(output.shape(), &[3, 4]);
let flat: Vec<f32> = output.iter().cloned().collect();
let n = flat.len() as f32;
let mean: f32 = flat.iter().sum::<f32>() / n;
let var: f32 = flat.iter().map(|&v| (v - mean).powi(2)).sum::<f32>() / n;
assert!(mean.abs() < 1e-5, "global mean {mean} too far from 0");
assert!((var - 1.0).abs() < 5e-4, "global var {var} too far from 1");
}
#[test]
fn ln_multiple_single_axis_on_3d_input() {
let data: Vec<f32> = (0..24).map(|i| i as f32 * 0.5 - 5.0).collect();
let shape = vec![2, 3, 4];
let input = ArrayD::from_shape_vec(shape.clone(), data).unwrap();
let mut ln = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Multiple(vec![1]))
.unwrap();
let output = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
assert_eq!(output.shape(), &[2, 3, 4]);
for b in 0..2 {
for s in 0..4 {
let vals: Vec<f32> = (0..3).map(|c| output[[b, c, s]]).collect();
let mean: f32 = vals.iter().sum::<f32>() / 3.0;
let var: f32 = vals.iter().map(|&v| (v - mean).powi(2)).sum::<f32>() / 3.0;
assert!(mean.abs() < 1e-5, "b={b} s={s}: mean {mean} too far from 0");
assert!(
(var - 1.0).abs() < 5e-4,
"b={b} s={s}: var {var} too far from 1"
);
}
}
}
#[test]
fn ln_default_constant_row_is_finite_and_zero() {
let data = vec![
5.0f32, 5.0, 5.0, 5.0, 1.0, 2.0, 3.0, 4.0, ];
let input = tensor2(data, 2, 4);
let mut ln = LayerNormalization::new(1e-5).unwrap();
let output = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
for c in 0..4 {
assert!(
output[[0, c]].is_finite(),
"output[0,{c}] = {} is not finite",
output[[0, c]]
);
assert_eq!(
output[[0, c]],
0.0,
"output[0,{c}] should be 0 for constant row"
);
}
for c in 0..4 {
assert!(
output[[1, c]].is_finite(),
"output[1,{c}] = {} is not finite",
output[[1, c]]
);
}
}
#[test]
fn ln_predict_equals_forward() {
let data = vec![1.0f32, 3.0, 5.0, 7.0, 2.0, -2.0, 0.0, 4.0];
let input = tensor2(data, 2, 4);
let mut ln = LayerNormalization::new(1e-5).unwrap();
let out_forward = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
let out_predict = ln.forward(&input, &mut Ctx::inference()).unwrap();
assert_allclose(&out_forward, &out_predict, 1e-6);
}
#[test]
fn ln_set_weights_custom_gamma_beta() {
let input = tensor2(vec![0.0f32, 4.0], 1, 2);
let mut ln = LayerNormalization::new(1e-5).unwrap();
ln.build(&Shape::known(&[1, 2])).unwrap();
let gamma = tensor1(vec![3.0f32, 3.0]);
let beta = tensor1(vec![10.0f32, 10.0]);
ln.set_weights(gamma, beta).unwrap();
let output = ln.forward(&input, &mut Ctx::training()).unwrap();
let eps = 1e-5f32;
let std = (4.0f32 + eps).sqrt();
let x_norm_0 = -2.0_f32 / std;
let x_norm_1 = 2.0_f32 / std;
let e0 = x_norm_0 * 3.0 + 10.0;
let e1 = x_norm_1 * 3.0 + 10.0;
let expected = tensor2(vec![e0, e1], 1, 2);
assert_allclose(&output, &expected, 1e-4);
}
#[test]
fn ln_set_weights_rejects_wrong_gamma_shape() {
let mut ln = LayerNormalization::new(1e-5).unwrap();
ln.build(&Shape::known(&[1, 4])).unwrap();
let bad_gamma = tensor1(vec![1.0f32, 1.0, 1.0]); let beta = tensor1(vec![0.0f32; 4]);
let result = ln.set_weights(bad_gamma, beta);
assert!(
matches!(
result,
Err(Error::NeuralNetwork(NnError::WeightShape { .. }))
),
"expected NnError::WeightShape, got {:?}",
result
);
}
#[test]
fn ln_forward_rejects_wrong_input_shape() {
let mut ln = LayerNormalization::new(1e-5).unwrap();
ln.build(&Shape::known(&[2, 4])).unwrap();
let wrong = tensor2(vec![1.0f32; 10], 2, 5);
let result = ln.forward(&wrong, &mut Ctx::training());
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"expected InvalidInput, got {:?}",
result
);
}
#[test]
fn ln_mode_switch_does_not_change_forward_output() {
let data = vec![1.0f32, 3.0, 5.0, 7.0, 2.0, -2.0, 0.0, 4.0];
let input = tensor2(data, 2, 4);
let mut ln = LayerNormalization::new(1e-5).unwrap();
let out_train = ln.forward_mut(&input, &mut Ctx::training()).unwrap();
let out_eval = ln.forward(&input, &mut Ctx::inference()).unwrap();
assert_allclose(&out_train, &out_eval, 1e-6);
}
#[test]
fn ln_predict_equals_forward_in_eval_mode() {
let data = vec![1.0f32, 3.0, 5.0, 7.0, 2.0, -2.0, 0.0, 4.0];
let input = tensor2(data, 2, 4);
let mut ln = LayerNormalization::new(1e-5).unwrap();
let out_forward = ln.forward_mut(&input, &mut Ctx::inference()).unwrap();
let out_predict = ln.forward(&input, &mut Ctx::inference()).unwrap();
assert_allclose(&out_forward, &out_predict, 0.0f32);
}
#[test]
fn ln_multiple_valid_axes_forward_succeeds() {
let data: Vec<f32> = (0..12).map(|i| i as f32).collect();
let input = tensor2(data, 3, 4);
let mut ln = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Multiple(vec![0, 1]))
.unwrap();
let result = ln.forward_mut(&input, &mut Ctx::training());
assert!(
result.is_ok(),
"forward with valid Multiple axes failed: {:?}",
result
);
}
#[test]
fn bn_backward_before_forward_errors() {
let bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let grad = tensor2(vec![1.0f32; 6], 2, 3);
let err = bn.backward(&grad, &mut Ctx::training()).unwrap_err();
assert!(
matches!(
err,
Error::NeuralNetwork(NnError::ForwardPassNotRun("BatchNormalization"))
),
"expected ForwardPassNotRun(\"BatchNormalization\"), got {:?}",
err
);
}
#[test]
fn ln_backward_before_forward_errors() {
let ln = LayerNormalization::new(1e-5).unwrap();
let grad = tensor2(vec![1.0f32; 8], 2, 4);
let err = ln.backward(&grad, &mut Ctx::training()).unwrap_err();
assert!(
matches!(
err,
Error::NeuralNetwork(NnError::ForwardPassNotRun("LayerNormalization"))
),
"expected ForwardPassNotRun(\"LayerNormalization\"), got {:?}",
err
);
}
#[test]
fn ln_custom_axis_out_of_bounds_forward_errors() {
let mut ln = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Custom(5))
.unwrap();
let input = tensor1(vec![1.0f32, 2.0, 3.0, 4.0]); let result = ln.forward_mut(&input, &mut Ctx::training());
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn ln_custom_axis_out_of_bounds_predict_errors() {
let mut ln = LayerNormalization::new(1e-5)
.unwrap()
.with_normalized_axis(LayerNormalizationAxis::Custom(5))
.unwrap();
ln.build(&Shape::known(&[4])).unwrap();
let input = tensor1(vec![1.0f32, 2.0, 3.0, 4.0]); let result = ln.forward(&input, &mut Ctx::inference());
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter, got {:?}",
result
);
}
#[test]
fn ln_default_scalar_input_forward_errors() {
let mut ln = LayerNormalization::new(1e-5).unwrap();
let scalar = ArrayD::from_shape_vec(vec![], vec![3.0f32]).unwrap(); let result = ln.forward_mut(&scalar, &mut Ctx::training());
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"expected InvalidInput, got {:?}",
result
);
}
#[test]
fn ln_default_scalar_input_predict_errors() {
let mut ln = LayerNormalization::new(1e-5).unwrap();
ln.build(&Shape::known(&[4])).unwrap();
let scalar = ArrayD::from_shape_vec(vec![], vec![3.0f32]).unwrap(); let result = ln.forward(&scalar, &mut Ctx::inference());
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"expected InvalidInput, got {:?}",
result
);
}
#[test]
fn bn_backward_eval_mode_passes_gradient_through() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let mut ctx = Ctx::inference();
let input = tensor2(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], 2, 3);
bn.forward_mut(&input, &mut ctx).unwrap();
let grad = tensor2(vec![0.5f32, -1.5, 2.0, -3.0, 4.5, -6.0], 2, 3);
let grad_input = bn.backward(&grad, &mut ctx).unwrap();
assert_allclose(&grad_input, &grad, 0.0f32);
}
#[test]
fn ln_backward_eval_mode_passes_gradient_through() {
let mut ln = LayerNormalization::new(1e-5).unwrap();
let mut ctx = Ctx::inference();
let input = tensor2(vec![1.0f32, 3.0, 5.0, 7.0, 2.0, -2.0, 0.0, 4.0], 2, 4);
ln.forward_mut(&input, &mut ctx).unwrap();
let grad = tensor2(vec![0.5f32, -1.5, 2.0, -3.0, 4.5, -6.0, 7.0, -8.5], 2, 4);
let grad_input = ln.backward(&grad, &mut ctx).unwrap();
assert_allclose(&grad_input, &grad, 0.0f32);
}
#[test]
fn bn_new_scalar_param_branch_forward_1d() {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let input = tensor1(vec![1.0f32, 2.0, 3.0, 4.0]);
let output = bn.forward_mut(&input, &mut Ctx::training()).unwrap();
assert_eq!(output.shape(), &[4]);
let std = (1.25f32 + 1e-5f32).sqrt();
let expected = tensor1(vec![-1.5 / std, -0.5 / std, 0.5 / std, 1.5 / std]);
assert_allclose(&output, &expected, 1e-5);
}
#[test]
fn bn_spatial_4d_normalizes_per_channel() {
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::ParamCounts;
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
bn.build(&Shape::known(&[1, 2, 2, 2])).unwrap();
assert_eq!(bn.param_count(), ParamCounts::new(4, 4));
let x: Tensor =
ArrayD::from_shape_vec(vec![1, 2, 2, 2], vec![1., 5., 2., 6., 3., 7., 4., 8.]).unwrap();
let out = bn.forward(&x, &mut Ctx::training()).unwrap();
assert_eq!(out.shape(), &[1, 2, 2, 2]);
let inv = 1.0 / (1.25f32 + 1e-5).sqrt();
let expected: Tensor = ArrayD::from_shape_vec(
vec![1, 2, 2, 2],
vec![
-1.5 * inv,
-1.5 * inv,
-0.5 * inv,
-0.5 * inv,
0.5 * inv,
0.5 * inv,
1.5 * inv,
1.5 * inv,
],
)
.unwrap();
assert_allclose(&out, &expected, 1e-4);
}
#[test]
fn bn_spatial_4d_backward_shape() {
use rustyml::neural_network::Tensor;
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let mut ctx = Ctx::training();
let x: Tensor =
ArrayD::from_shape_fn(vec![2, 3, 2, 2], |idx| (idx[1] + idx[2] + idx[3]) as f32);
bn.forward_mut(&x, &mut ctx).unwrap();
let grad: Tensor = ArrayD::ones(vec![2, 3, 2, 2]);
let grad_in = bn.backward(&grad, &mut ctx).unwrap();
assert_eq!(grad_in.shape(), &[2, 3, 2, 2]);
assert!(grad_in.iter().all(|v| v.is_finite()));
}
fn bn_run(shape: &[usize], gate: usize) -> (ArrayD<f32>, ArrayD<f32>, Vec<f32>) {
let _guard = crate::common::GateGuard::acquire();
rustyml::tuning::norm::set_batch_norm(gate);
rustyml::tuning::norm::set_col_fold(gate);
let x: ArrayD<f32> = ArrayD::from_shape_fn(shape.to_vec(), |idx| {
let k: usize = idx
.slice()
.iter()
.enumerate()
.map(|(a, d)| d * (a + 1))
.sum();
((k % 23) as f32) * 0.125 - 1.5
});
let grad: ArrayD<f32> = ArrayD::from_shape_fn(shape.to_vec(), |idx| {
let k: usize = idx
.slice()
.iter()
.enumerate()
.map(|(a, d)| d * (a + 3))
.sum();
((k % 19) as f32) * 0.0625 - 0.5
});
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
let mut ctx = Ctx::training();
let out = bn.forward_mut(&x, &mut ctx).unwrap();
let grad_in = bn.backward(&grad, &mut ctx).unwrap();
let mut grads: Vec<f32> = Vec::new();
for name in ["gamma", "beta"] {
let grad = ctx
.grads()
.get(ParamId::new(0, name))
.expect("the backward pass must write the gradient of gamma and of beta");
grads.extend(grad.iter().copied());
}
assert!(!grads.is_empty(), "the backward pass must write gradients");
(out, grad_in, grads)
}
#[test]
fn bn_gate_move_does_not_change_any_bit() {
let shape = [11usize, 64, 96, 5];
let rows: usize = shape[..shape.len() - 1].iter().product();
assert!(!rows.is_power_of_two(), "M must not be a power of 2");
let work: usize = shape.iter().product();
{
let _gates = crate::common::read_gates();
assert!(
work >= rustyml::tuning::norm::get_batch_norm(),
"the shape must clear the shipped gate"
);
}
let (s_out, s_grad, s_par) = bn_run(&shape, usize::MAX);
let (p_out, p_grad, p_par) = bn_run(&shape, 0);
for (name, a, b) in [
(
"output",
s_out.as_slice().unwrap(),
p_out.as_slice().unwrap(),
),
(
"grad_input",
s_grad.as_slice().unwrap(),
p_grad.as_slice().unwrap(),
),
("gamma/beta gradients", &s_par[..], &p_par[..]),
] {
assert_eq!(a.len(), b.len(), "{name} length differs");
let mismatches = a
.iter()
.zip(b.iter())
.enumerate()
.filter(|(_, (x, y))| x.to_bits() != y.to_bits())
.take(3)
.map(|(i, (x, y))| format!("[{i}] serial {x:e} vs parallel {y:e}"))
.collect::<Vec<_>>();
assert!(
mismatches.is_empty(),
"{name}: the serial and parallel arms differ; first mismatches: {}",
mismatches.join(", ")
);
}
}
#[test]
fn bn_param_count_splits_the_trainable_and_the_non_trainable_half() {
for channels in [1usize, 3, 8] {
let mut bn = BatchNormalization::new(0.9, 1e-5).unwrap();
bn.build(&Shape::known(&[2, channels])).unwrap();
let counts = bn.param_count();
assert_eq!(
counts.trainable,
2 * channels,
"gamma and beta over {channels} channels"
);
assert_eq!(
counts.non_trainable,
2 * channels,
"running mean and running variance over {channels} channels"
);
assert_eq!(
counts.total(),
4 * channels,
"the total over {channels} channels must be 4 per channel"
);
assert_eq!(
counts.total(),
counts.trainable + counts.non_trainable,
"the total must be the sum of the 2 counts, and must not drop either half"
);
}
}
#[test]
fn bn_param_count_counts_the_channel_axis_alone() {
let mut flat = BatchNormalization::new(0.9, 1e-5).unwrap();
flat.build(&Shape::known(&[2, 7])).unwrap();
let mut spatial = BatchNormalization::new(0.9, 1e-5).unwrap();
spatial.build(&Shape::known(&[1, 2, 2, 7])).unwrap();
assert_eq!(spatial.param_count(), flat.param_count());
assert_eq!(spatial.param_count().trainable, 14);
assert_eq!(spatial.param_count().non_trainable, 14);
assert_eq!(spatial.param_count().total(), 28);
let mut scalar = BatchNormalization::new(0.9, 1e-5).unwrap();
scalar.build(&Shape::known(&[5])).unwrap();
assert_eq!(scalar.param_count().trainable, 2);
assert_eq!(scalar.param_count().non_trainable, 2);
assert_eq!(scalar.param_count().total(), 4);
}
#[test]
fn ln_param_count_reports_no_non_trainable_parameter() {
let mut ln = LayerNormalization::new(1e-5).unwrap();
ln.build(&Shape::known(&[2, 3])).unwrap();
let counts = ln.param_count();
assert_eq!(counts.trainable, 6, "gamma and beta over 3 features");
assert_eq!(
counts.non_trainable, 0,
"LayerNormalization keeps no running statistic"
);
assert_eq!(
counts.total(),
counts.trainable,
"with no non-trainable half, the total is the trainable count"
);
}