use std::collections::HashMap;
use std::sync::Arc;
use fuel::lazy::Tensor;
use fuel::{Device, Result, Shape};
use fuel_training::train::{OptimizerConfig, Parameter, TrainState, loss};
#[derive(Clone, Copy, Debug)]
pub struct MlpConfig {
pub in_dim: usize,
pub hidden: usize,
pub out_dim: usize,
pub lr: f32,
pub seed: u32,
}
fn lcg(state: &mut u32) -> f32 {
*state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
(*state >> 16) as u16 as f32 / 65_535.0 - 0.5
}
fn vec_of(n: usize, state: &mut u32, scale: f32) -> Arc<[f32]> {
(0..n)
.map(|_| lcg(state) * scale)
.collect::<Vec<_>>()
.into()
}
fn init_params(cfg: &MlpConfig) -> Vec<Parameter> {
let mut s = cfg.seed.wrapping_add(1);
let w1_scale = 1.0 / (cfg.in_dim as f32).sqrt();
let w2_scale = 1.0 / (cfg.hidden as f32).sqrt();
vec![
Parameter::new_f32(
"w1",
Shape::from_dims(&[cfg.in_dim, cfg.hidden]),
vec_of(cfg.in_dim * cfg.hidden, &mut s, w1_scale),
),
Parameter::new_f32(
"b1",
Shape::from_dims(&[cfg.hidden]),
vec![0.0f32; cfg.hidden],
),
Parameter::new_f32(
"w2",
Shape::from_dims(&[cfg.hidden, cfg.out_dim]),
vec_of(cfg.hidden * cfg.out_dim, &mut s, w2_scale),
),
Parameter::new_f32(
"b2",
Shape::from_dims(&[cfg.out_dim]),
vec![0.0f32; cfg.out_dim],
),
]
}
fn mlp_logits(x: &Tensor, w1: &Tensor, b1: &Tensor, w2: &Tensor, b2: &Tensor) -> Result<Tensor> {
let h = x.matmul(w1)?.broadcast_add(b1)?.relu();
h.matmul(w2)?.broadcast_add(b2)
}
fn one_hot(labels: &[u32], out_dim: usize) -> Arc<[f32]> {
let mut oh = vec![0.0f32; labels.len() * out_dim];
for (i, &l) in labels.iter().enumerate() {
oh[i * out_dim + (l as usize).min(out_dim - 1)] = 1.0;
}
oh.into()
}
pub struct MnistTrainer {
state: TrainState,
cfg: MlpConfig,
}
impl MnistTrainer {
pub fn new(cfg: MlpConfig) -> Result<Self> {
let device = Device::cpu();
let state = TrainState::new(&init_params(&cfg), &device, OptimizerConfig::adam_w(cfg.lr))?;
Ok(Self { state, cfg })
}
pub fn train_batch(&mut self, images: &[f32], labels: &[u32], n_samples: usize) -> Result<f32> {
assert_eq!(images.len(), n_samples * self.cfg.in_dim);
assert_eq!(labels.len(), n_samples);
let x_data: Arc<[f32]> = images.to_vec().into();
let t_data = one_hot(labels, self.cfg.out_dim);
let (in_dim, out_dim) = (self.cfg.in_dim, self.cfg.out_dim);
self.state
.step(move |_graph, params: &HashMap<String, Tensor>| {
let (w1, b1, w2, b2) = (¶ms["w1"], ¶ms["b1"], ¶ms["w2"], ¶ms["b2"]);
let x = w1.const_f32_like(x_data, Shape::from_dims(&[n_samples, in_dim]))?;
let logits = mlp_logits(&x, w1, b1, w2, b2)?;
let target = w1.const_f32_like(t_data, Shape::from_dims(&[n_samples, out_dim]))?;
loss::cross_entropy_with_logits(&logits, &target)
})
}
pub fn eval_accuracy(&self, images: &[f32], labels: &[u32], n_samples: usize) -> Result<f32> {
let dev = Device::cpu();
let cfg = &self.cfg;
let mk = |name: &str, dims: &[usize]| -> Result<Tensor> {
let data: Arc<[f32]> = self.state.param_to_host(name)?.into();
Tensor::from_f32(data, Shape::from_dims(dims), &dev)
};
let w1 = mk("w1", &[cfg.in_dim, cfg.hidden])?;
let b1 = mk("b1", &[cfg.hidden])?;
let w2 = mk("w2", &[cfg.hidden, cfg.out_dim])?;
let b2 = mk("b2", &[cfg.out_dim])?;
let x = Tensor::from_f32(
images.to_vec(),
Shape::from_dims(&[n_samples, cfg.in_dim]),
&dev,
)?;
let logits = mlp_logits(&x, &w1, &b1, &w2, &b2)?.realize_f32();
let mut correct = 0usize;
for i in 0..n_samples {
let row = &logits[i * cfg.out_dim..(i + 1) * cfg.out_dim];
let pred = row
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(j, _)| j as u32)
.unwrap_or(0);
if pred == labels[i] {
correct += 1;
}
}
Ok(correct as f32 / n_samples as f32)
}
}
pub fn train_steps(
images: &[f32],
labels: &[u32],
n_samples: usize,
cfg: &MlpConfig,
n_steps: usize,
) -> Result<Vec<f32>> {
let mut trainer = MnistTrainer::new(*cfg)?;
(0..n_steps)
.map(|_| trainer.train_batch(images, labels, n_samples))
.collect::<Result<Vec<f32>>>()
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic_batch(n: usize, in_dim: usize, out_dim: usize, seed: u32) -> (Vec<f32>, Vec<u32>) {
let mut s = seed;
let images: Vec<f32> = (0..n * in_dim).map(|_| lcg(&mut s)).collect();
let labels: Vec<u32> = (0..n)
.map(|_| ((lcg(&mut s) + 0.5) * out_dim as f32) as u32 % out_dim as u32)
.collect();
(images, labels)
}
fn tiny_cfg(lr: f32) -> MlpConfig {
MlpConfig {
in_dim: 16,
hidden: 8,
out_dim: 3,
lr,
seed: 7,
}
}
#[test]
fn mlp_loss_decreases_over_steps() {
let cfg = tiny_cfg(1e-2);
let (images, labels) = synthetic_batch(8, cfg.in_dim, cfg.out_dim, 42);
let losses = train_steps(&images, &labels, 8, &cfg, 30).expect("train");
assert!(
losses.iter().all(|l| l.is_finite()),
"all losses finite, got {losses:?}"
);
assert!(
*losses.last().unwrap() < losses[0],
"training loss must decrease (wired up + gradients flowing): {losses:?}",
);
}
#[test]
fn mlp_loss_is_flat_when_learning_rate_is_zero() {
let cfg = tiny_cfg(0.0);
let (images, labels) = synthetic_batch(8, cfg.in_dim, cfg.out_dim, 42);
let losses = train_steps(&images, &labels, 8, &cfg, 30).expect("train");
assert!(losses.iter().all(|l| l.is_finite()), "losses finite");
assert!(
(losses.last().unwrap() - losses[0]).abs() < 1e-6,
"with lr=0 the loss must stay flat (no learning), got {losses:?}",
);
}
}