use bench_harness::rng::Xorshift64Star;
use fandhe_ai::compat::Sequential;
use fandhe_ai_autodiff::AutodiffError;
use fandhe_ai_autodiff::nn::Linear;
use fandhe_ai_autodiff::nn::activation::Relu;
use fandhe_ai_autodiff::nn::loss::{MseLoss, Reduction};
use fandhe_ai_autodiff::nn::optim::{AdamW, AdamWConfig};
use fandhe_ai_autodiff::optim::{Sgd, SgdConfig};
use fandhe_ai_tensor_core::Tensor;
const BATCH: usize = 4;
const D_IN: usize = 8;
const D_HIDDEN: usize = 16;
const D_OUT: usize = 4;
const SEED_DATA: u64 = 0xC0FFEE;
const SEED_L1: u64 = 0x1111_1111;
const SEED_L2: u64 = 0x2222_2222;
fn tensor(data: Vec<f32>, shape: &[usize]) -> Tensor<f32> {
Tensor::new(data, shape).expect("test fixture: shape とデータ長は事前に一致させている")
}
fn scalar(t: &Tensor<f32>) -> f32 {
t.get(&[]).expect("test fixture: スカラー shape [] のはず")
}
fn gen_regression_data(seed: u64) -> (Tensor<f32>, Tensor<f32>) {
let mut rng = Xorshift64Star::new(seed);
let x = rng.fill_vec(BATCH * D_IN);
let y = rng.fill_vec(BATCH * D_OUT);
(tensor(x, &[BATCH, D_IN]), tensor(y, &[BATCH, D_OUT]))
}
fn build_model() -> Sequential {
Sequential::new()
.add_linear(D_IN, D_HIDDEN, SEED_L1)
.unwrap()
.add_relu()
.add_linear(D_HIDDEN, D_OUT, SEED_L2)
.unwrap()
}
fn train_with_sgd(model: &mut Sequential, steps: usize, lr: f32) -> Vec<f32> {
let (x_data, y_data) = gen_regression_data(SEED_DATA);
let mut sgd = Sgd::new(SgdConfig::new(lr)).unwrap();
let mut log = Vec::with_capacity(steps);
for _ in 0..steps {
let updated = {
let tape = fandhe_ai::tape();
let bound = model.bind(&tape);
let x = tape.var(&x_data);
let y = tape.var(&y_data);
let pred = bound.forward(&tape, &x).unwrap();
let loss = MseLoss::new(Reduction::Mean).forward(&pred, &y).unwrap();
log.push(scalar(&loss.to_tensor()));
let grads = tape.backward(&loss).unwrap();
let grad_refs = bound.trainable_grads(&grads).unwrap();
let param_refs = model.trainable_parameters();
sgd.step(¶m_refs, &grad_refs).unwrap()
};
model.apply_parameters(updated).unwrap();
}
log
}
#[test]
fn sequential_sgd_training_loop_converges() {
const STEPS: usize = 100;
const LR: f32 = 0.05;
let mut model = build_model();
let log = train_with_sgd(&mut model, STEPS, LR);
assert_eq!(log.len(), STEPS);
let initial = log[0];
let final_loss = *log.last().unwrap();
assert!(
final_loss < 0.5 * initial,
"loss did not converge sufficiently: initial={initial} final={final_loss}"
);
}
fn manual_sgd_step(
l1: &Linear,
l2: &Linear,
x_data: &Tensor<f32>,
y_data: &Tensor<f32>,
sgd: &mut Sgd,
) -> Result<(f32, [Tensor<f32>; 4]), AutodiffError> {
let tape = fandhe_ai_autodiff::Tape::new_with_ops(Box::new(
fandhe_ai_backend_cpu::CpuBackendOps::new(),
));
let x = tape.var(x_data);
let y = tape.var(y_data);
let l1v = l1.bind(&tape);
let l2v = l2.bind(&tape);
let relu = Relu;
let h1 = l1v.forward(&x)?;
let a1 = <Relu as fandhe_ai_autodiff::nn::Module>::forward(&relu, &tape, &h1)?;
let h2 = l2v.forward(&a1)?;
let loss = MseLoss::new(Reduction::Mean).forward(&h2, &y)?;
let loss_value = scalar(&loss.to_tensor());
let grads = tape.backward(&loss)?;
let l1_weight_grad = grads.get(&l1v.weight)?.unwrap();
let l1_bias_grad = grads.get(l1v.bias.as_ref().unwrap())?.unwrap();
let l2_weight_grad = grads.get(&l2v.weight)?.unwrap();
let l2_bias_grad = grads.get(l2v.bias.as_ref().unwrap())?.unwrap();
let params = [
l1.weight(),
l1.bias().unwrap(),
l2.weight(),
l2.bias().unwrap(),
];
let grad_refs = [l1_weight_grad, l1_bias_grad, l2_weight_grad, l2_bias_grad];
let updated = sgd.step(¶ms, &grad_refs)?;
let updated: [Tensor<f32>; 4] = updated
.try_into()
.map_err(|_| AutodiffError::InvalidArgument("test fixture: expected 4".to_string()))?;
Ok((loss_value, updated))
}
#[test]
fn sequential_training_loop_matches_manual_loop_bit_exact() {
const STEPS: usize = 10;
const LR: f32 = 0.05;
let (x_data, y_data) = gen_regression_data(SEED_DATA);
let mut l1 = Linear::new(D_IN, D_HIDDEN, true, SEED_L1).unwrap();
let mut l2 = Linear::new(D_HIDDEN, D_OUT, true, SEED_L2).unwrap();
let mut manual_sgd = Sgd::new(SgdConfig::new(LR)).unwrap();
let mut manual_losses = Vec::with_capacity(STEPS);
for _ in 0..STEPS {
let (loss_value, updated) =
manual_sgd_step(&l1, &l2, &x_data, &y_data, &mut manual_sgd).unwrap();
let [w1, b1, w2, b2] = updated;
l1 = Linear::from_parameters(w1, Some(b1)).unwrap();
l2 = Linear::from_parameters(w2, Some(b2)).unwrap();
manual_losses.push(loss_value);
}
let mut model = build_model();
let mut seq_sgd = Sgd::new(SgdConfig::new(LR)).unwrap();
let mut seq_losses = Vec::with_capacity(STEPS);
for _ in 0..STEPS {
let updated = {
let tape = fandhe_ai::tape();
let bound = model.bind(&tape);
let x = tape.var(&x_data);
let y = tape.var(&y_data);
let pred = bound.forward(&tape, &x).unwrap();
let loss = MseLoss::new(Reduction::Mean).forward(&pred, &y).unwrap();
seq_losses.push(scalar(&loss.to_tensor()));
let grads = tape.backward(&loss).unwrap();
let grad_refs = bound.trainable_grads(&grads).unwrap();
let param_refs = model.trainable_parameters();
seq_sgd.step(¶m_refs, &grad_refs).unwrap()
};
model.apply_parameters(updated).unwrap();
}
assert_eq!(manual_losses.len(), seq_losses.len());
for (m, s) in manual_losses.iter().zip(seq_losses.iter()) {
assert_eq!(m.to_bits(), s.to_bits(), "loss series diverged: {m} != {s}");
}
let manual_params = [
l1.weight(),
l1.bias().unwrap(),
l2.weight(),
l2.bias().unwrap(),
];
let seq_params = model.trainable_parameters();
assert_eq!(manual_params.len(), seq_params.len());
for (m, s) in manual_params.iter().zip(seq_params.iter()) {
let m_data = m.contiguous().as_slice().unwrap().to_vec();
let s_data = s.contiguous().as_slice().unwrap().to_vec();
assert_eq!(m_data.len(), s_data.len());
for (a, b) in m_data.iter().zip(s_data.iter()) {
assert_eq!(a.to_bits(), b.to_bits());
}
}
}
#[test]
fn sequential_adamw_training_loop_reduces_loss() {
const STEPS: usize = 50;
let (x_data, y_data) = gen_regression_data(SEED_DATA);
let mut model = build_model();
let mut adamw = AdamW::new(AdamWConfig::default()).unwrap();
let mut losses = Vec::with_capacity(STEPS);
for _ in 0..STEPS {
let updated = {
let tape = fandhe_ai::tape();
let bound = model.bind(&tape);
let x = tape.var(&x_data);
let y = tape.var(&y_data);
let pred = bound.forward(&tape, &x).unwrap();
let loss = MseLoss::new(Reduction::Mean).forward(&pred, &y).unwrap();
losses.push(scalar(&loss.to_tensor()));
let grads = tape.backward(&loss).unwrap();
let grad_refs = bound.trainable_grads(&grads).unwrap();
let param_refs = model.trainable_parameters();
let params_and_grads: Vec<(&Tensor<f32>, &Tensor<f32>)> =
param_refs.into_iter().zip(grad_refs).collect();
adamw.step(¶ms_and_grads).unwrap()
};
model.apply_parameters(updated).unwrap();
}
let initial = losses[0];
let final_loss = *losses.last().unwrap();
assert!(
final_loss < initial,
"AdamW loop did not reduce loss: initial={initial} final={final_loss}"
);
}
#[test]
fn trainable_grads_rejects_unreached_parameter() {
let model = build_model();
let tape = fandhe_ai::tape();
let bound = model.bind(&tape);
let x_data = tensor(vec![0.1_f32; BATCH * D_IN], &[BATCH, D_IN]);
let x = tape.var(&x_data);
let first_linear_output = bound.linears()[0].forward(&x).unwrap();
let loss = first_linear_output.sum(None).unwrap();
let grads = tape.backward(&loss).unwrap();
let err = bound.trainable_grads(&grads).unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
}