use rust_physics_engine::learn::nn::{
conv2d_forward, linear_regression_gd_check, Act, Loss, Mlp,
};
use rust_physics_engine::linalg::matrix::Matrix;
use rust_physics_engine::monte_carlo::Rng;
fn architecture(rng: &mut Rng) -> Vec<usize> {
let depth = 2 + (rng.below(2)) as usize;
let mut sizes = vec![1 + (rng.below(4)) as usize];
for _ in 0..depth {
sizes.push(1 + (rng.next_u64() % 5) as usize);
}
sizes.push(1 + (rng.below(4)) as usize);
sizes
}
#[test]
fn prop_the_gradient_agrees_with_a_central_difference_everywhere() {
let mut rng = Rng::new(0x4d70_1cb2);
for _ in 0..40 {
let sizes = architecture(&mut rng);
let hidden = match rng.next_u64() % 3 {
0 => Act::Tanh,
1 => Act::Sigmoid,
_ => Act::Identity,
};
let classify = rng.next_f64() < 0.5;
let (output, loss) = if classify {
(Act::Softmax, Loss::CrossEntropy)
} else {
match rng.next_u64() % 3 {
0 => (Act::Identity, Loss::Mse),
1 => (Act::Sigmoid, Loss::Mse),
_ => (Act::Tanh, Loss::Mse),
}
};
let net = Mlp::new(&sizes, hidden, output, &mut rng).unwrap();
let x: Vec<f64> = (0..net.input_size()).map(|_| 2.0 * rng.next_f64() - 1.0).collect();
let y: Vec<f64> = if classify {
let mut t = vec![0.0; net.output_size()];
t[(rng.next_u64() as usize) % net.output_size()] = 1.0;
t
} else {
(0..net.output_size()).map(|_| rng.next_gaussian()).collect()
};
let relative = net.numerical_grad_check(&x, &y, loss).unwrap();
assert!(
relative < 1e-7,
"{sizes:?} {hidden:?}/{output:?}/{loss:?} disagreed by {relative}"
);
}
}
#[test]
fn prop_a_rectifier_only_disagrees_at_its_kinks() {
let mut rng = Rng::new(0x21e9_5f70);
let step = 1e-5;
let mut disagreements = 0;
let mut total = 0;
for _ in 0..25 {
let sizes = architecture(&mut rng);
let net = Mlp::new(&sizes, Act::Relu, Act::Identity, &mut rng).unwrap();
for _ in 0..8 {
let x: Vec<f64> =
(0..net.input_size()).map(|_| 2.0 * rng.next_f64() - 1.0).collect();
let y: Vec<f64> = (0..net.output_size()).map(|_| rng.next_gaussian()).collect();
total += 1;
let relative = net.numerical_grad_check(&x, &y, Loss::Mse).unwrap();
if relative < 1e-7 {
continue;
}
disagreements += 1;
let z = net.preactivations(&x).unwrap();
let closest = z[..z.len() - 1]
.iter()
.flat_map(|layer| layer.iter())
.map(|v| v.abs())
.fold(f64::INFINITY, f64::min);
assert!(
closest < 1000.0 * step,
"a disagreement of {relative} with the nearest kink {closest} away"
);
}
}
assert!(disagreements > 0, "no rectifier draw ever straddled a kink");
assert!(disagreements * 2 < total, "{disagreements} of {total} draws disagreed");
}
#[test]
fn prop_softmax_ignores_a_shift_of_its_input() {
let mut rng = Rng::new(0x0b64_9a31);
for _ in 0..40 {
let n = 2 + (rng.next_u64() % 6) as usize;
let z: Vec<f64> = (0..n).map(|_| 20.0 * rng.next_gaussian()).collect();
let net = Mlp {
layers: vec![(Matrix::identity(n), vec![0.0; n])],
activation: Act::Tanh,
output_activation: Act::Softmax,
};
let p = net.forward(&z).unwrap();
assert!((p.iter().sum::<f64>() - 1.0).abs() < 1e-12);
assert!(p.iter().all(|v| v.is_finite() && *v >= 0.0));
for shift in [-500.0, -1.0, 7.5, 600.0] {
let moved: Vec<f64> = z.iter().map(|v| v + shift).collect();
let q = net.forward(&moved).unwrap();
for k in 0..n {
assert!((p[k] - q[k]).abs() < 1e-12, "shift {shift} moved component {k}");
}
}
for i in 0..n {
for j in 0..n {
assert_eq!(z[i] < z[j], p[i] < p[j], "softmax reordered its inputs");
}
}
}
}
#[test]
fn prop_the_fused_output_gradient_is_exactly_the_residual() {
let mut rng = Rng::new(0x7c05_e3aa);
for _ in 0..30 {
let n = 2 + (rng.next_u64() % 5) as usize;
for (output, loss) in [(Act::Softmax, Loss::CrossEntropy), (Act::Identity, Loss::Mse)] {
let net = Mlp::new(&[3, 4, n], Act::Tanh, output, &mut rng).unwrap();
let x: Vec<f64> = (0..3).map(|_| rng.next_gaussian()).collect();
let y: Vec<f64> = if loss == Loss::CrossEntropy {
let mut t = vec![0.0; n];
t[(rng.next_u64() as usize) % n] = 1.0;
t
} else {
(0..n).map(|_| rng.next_gaussian()).collect()
};
let p = net.forward(&x).unwrap();
let g = net.backward(&x, &y, loss).unwrap();
let bias = &g.layers[g.layers.len() - 1].1;
for k in 0..n {
assert!(
(bias[k] - (p[k] - y[k])).abs() < 1e-12,
"{output:?}/{loss:?} component {k}"
);
}
}
}
}
#[test]
fn prop_a_bias_free_rectifier_network_is_positively_homogeneous() {
let mut rng = Rng::new(0x58c1_02de);
for _ in 0..30 {
let sizes = architecture(&mut rng);
let mut net = Mlp::new(&sizes, Act::Relu, Act::Relu, &mut rng).unwrap();
for (_, b) in net.layers.iter_mut() {
for v in b.iter_mut() {
*v = 0.0;
}
}
let x: Vec<f64> = (0..net.input_size()).map(|_| rng.next_gaussian()).collect();
let base = net.forward(&x).unwrap();
for c in [0.25, 1.0, 7.0] {
let scaled: Vec<f64> = x.iter().map(|v| c * v).collect();
let got = net.forward(&scaled).unwrap();
for k in 0..got.len() {
let want = c * base[k];
assert!(
(got[k] - want).abs() < 1e-10 * (1.0 + want.abs()),
"scale {c} component {k}"
);
}
}
}
}
#[test]
fn prop_permuting_a_hidden_layer_computes_the_same_function() {
let mut rng = Rng::new(0x33fa_7168);
for _ in 0..30 {
let hidden = 2 + (rng.next_u64() % 5) as usize;
let net = Mlp::new(&[3, hidden, 2], Act::Tanh, Act::Identity, &mut rng).unwrap();
let mut order: Vec<usize> = (0..hidden).collect();
for i in (1..hidden).rev() {
let j = (rng.next_u64() % (i as u64 + 1)) as usize;
order.swap(i, j);
}
let mut shuffled = net.clone();
{
let (w0, b0) = &net.layers[0];
let (nw0, nb0) = &mut shuffled.layers[0];
for (new_row, &old_row) in order.iter().enumerate() {
nb0[new_row] = b0[old_row];
for j in 0..w0.cols {
nw0.set(new_row, j, w0.get(old_row, j));
}
}
let w1 = &net.layers[1].0;
let nw1 = &mut shuffled.layers[1].0;
for (new_col, &old_col) in order.iter().enumerate() {
for i in 0..w1.rows {
nw1.set(i, new_col, w1.get(i, old_col));
}
}
}
for _ in 0..5 {
let x: Vec<f64> = (0..3).map(|_| rng.next_gaussian()).collect();
let a = net.forward(&x).unwrap();
let b = shuffled.forward(&x).unwrap();
for k in 0..a.len() {
assert!((a[k] - b[k]).abs() < 1e-12, "permutation changed component {k}");
}
}
}
}
#[test]
fn prop_descent_reaches_the_closed_form_least_squares_answer() {
let mut rng = Rng::new(0x6ee2_bc59);
for _ in 0..20 {
let cols = 2 + (rng.below(4)) as usize;
let rows = cols + 10 + (rng.next_u64() % 30) as usize;
let mut x = Matrix::zeros(rows, cols);
for i in 0..rows {
x.set(i, 0, 1.0);
for j in 1..cols {
x.set(i, j, rng.next_gaussian());
}
}
let truth: Vec<f64> = (0..cols).map(|_| 2.0 * rng.next_gaussian()).collect();
let y: Vec<f64> = (0..rows)
.map(|i| {
(0..cols).map(|j| x.get(i, j) * truth[j]).sum::<f64>()
+ 0.05 * rng.next_gaussian()
})
.collect();
let relative = linear_regression_gd_check(&x, &y, 5000).unwrap();
assert!(relative < 1e-5, "descent stopped {relative} from the exact answer");
}
}
#[test]
fn prop_convolution_is_linear_and_shift_equivariant() {
let mut rng = Rng::new(0x158d_40f7);
for _ in 0..25 {
let w = 7 + (rng.next_u64() % 5) as usize;
let h = 7 + (rng.next_u64() % 5) as usize;
let k = 1 + 2 * (rng.below(2)) as usize; let a: Vec<f64> = (0..w * h).map(|_| rng.next_gaussian()).collect();
let b: Vec<f64> = (0..w * h).map(|_| rng.next_gaussian()).collect();
let kernel: (Vec<f64>, usize, usize) =
((0..k * k).map(|_| rng.next_gaussian()).collect(), k, k);
let run = |img: &[f64]| conv2d_forward(img, w, h, std::slice::from_ref(&kernel), 1, 0).unwrap();
let (ra, out_w, out_h) = run(&a);
let (rb, _, _) = run(&b);
let (alpha, beta) = (2.0 * rng.next_f64() - 1.0, 2.0 * rng.next_f64() - 1.0);
let mixed: Vec<f64> = a.iter().zip(&b).map(|(x, y)| alpha * x + beta * y).collect();
let (rm, _, _) = run(&mixed);
for i in 0..out_w * out_h {
let want = alpha * ra[0][i] + beta * rb[0][i];
assert!((rm[0][i] - want).abs() < 1e-11 * (1.0 + want.abs()), "linearity at {i}");
}
let mut shifted = vec![0.0; w * h];
for y in 0..h {
for x in 1..w {
shifted[y * w + x] = a[y * w + x - 1];
}
}
let (rs, _, _) = run(&shifted);
for y in 0..out_h {
for x in 1..out_w {
let want = ra[0][y * out_w + x - 1];
assert!(
(rs[0][y * out_w + x] - want).abs() < 1e-11 * (1.0 + want.abs()),
"shift at ({x}, {y})"
);
}
}
for stride in 1..=3 {
for pad in 0..=2 {
let padded_w = w + 2 * pad;
let padded_h = h + 2 * pad;
let (_, ow, oh) =
conv2d_forward(&a, w, h, std::slice::from_ref(&kernel), stride, pad).unwrap();
assert_eq!(ow, (padded_w - k) / stride + 1);
assert_eq!(oh, (padded_h - k) / stride + 1);
}
}
}
}
#[test]
fn prop_a_uniform_kernel_averages_and_a_delta_copies() {
let mut rng = Rng::new(0x2a71_c8e4);
for _ in 0..25 {
let w = 6 + (rng.next_u64() % 6) as usize;
let h = 6 + (rng.next_u64() % 6) as usize;
let k = 3;
let img: Vec<f64> = (0..w * h).map(|_| rng.next_gaussian()).collect();
let mean_kernel = (vec![1.0 / (k * k) as f64; k * k], k, k);
let (blurred, ow, oh) = conv2d_forward(&img, w, h, &[mean_kernel], 1, 0).unwrap();
for oy in 0..oh {
for ox in 0..ow {
let want: f64 = (0..k)
.flat_map(|dy| (0..k).map(move |dx| (dx, dy)))
.map(|(dx, dy)| img[(oy + dy) * w + ox + dx])
.sum::<f64>()
/ (k * k) as f64;
let got = blurred[0][oy * ow + ox];
assert!((got - want).abs() < 1e-12 * (1.0 + want.abs()), "mean at ({ox},{oy})");
}
}
let mut delta = vec![0.0; k * k];
delta[k * k / 2] = 1.0;
let (copied, _, _) = conv2d_forward(&img, w, h, &[(delta, k, k)], 1, 1).unwrap();
for i in 0..w * h {
assert!((copied[0][i] - img[i]).abs() < 1e-15, "the delta did not copy at {i}");
}
}
}