use crate::algorithms::natural_grad::softmax;
use ndarray::{Array1, Array2, ArrayView2, Axis};
pub fn matmul(a: &[f32], a_rows: usize, a_cols: usize, b: &[f32], b_cols: usize) -> Vec<f32> {
assert_eq!(a.len(), a_rows * a_cols, "matmul: a dimensions mismatch");
assert_eq!(b.len(), a_cols * b_cols, "matmul: b dimensions mismatch");
let mut c = vec![0.0f32; a_rows * b_cols];
matmul_into(a, a_rows, a_cols, b, b_cols, &mut c);
c
}
pub fn matmul_into(
a: &[f32],
a_rows: usize,
a_cols: usize,
b: &[f32],
b_cols: usize,
c: &mut [f32],
) {
assert_eq!(
a.len(),
a_rows * a_cols,
"matmul_into: a dimensions mismatch"
);
assert_eq!(
b.len(),
a_cols * b_cols,
"matmul_into: b dimensions mismatch"
);
assert_eq!(
c.len(),
a_rows * b_cols,
"matmul_into: c dimensions mismatch"
);
unsafe {
matrixmultiply::sgemm(
a_rows,
a_cols,
b_cols,
1.0,
a.as_ptr(),
a_cols as isize,
1,
b.as_ptr(),
b_cols as isize,
1,
0.0,
c.as_mut_ptr(),
b_cols as isize,
1,
);
}
}
pub fn cross_entropy_loss(probs: &[f32], target: usize) -> f32 {
assert!(!probs.is_empty(), "cross_entropy_loss: empty distribution");
assert!(
target < probs.len(),
"cross_entropy_loss: target out of bounds"
);
-probs[target].max(1e-30).ln()
}
pub fn cross_entropy_from_logits(logits: &[f32], target: usize) -> f32 {
assert!(
!logits.is_empty(),
"cross_entropy_from_logits: empty logits"
);
assert!(
target < logits.len(),
"cross_entropy_from_logits: target out of bounds"
);
let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let log_sum_exp = logits.iter().map(|&l| (l - max).exp()).sum::<f32>().ln() + max;
-logits[target] + log_sum_exp
}
pub fn cross_entropy_logits_grad(logits: &[f32], target: usize) -> Vec<f32> {
let mut probs = softmax(logits);
probs[target] -= 1.0;
probs
}
pub fn one_hot(class: usize, classes: usize) -> Vec<f32> {
assert!(class < classes, "one_hot: class out of bounds");
let mut v = vec![0.0f32; classes];
v[class] = 1.0;
v
}
pub fn relu(x: f32) -> f32 {
x.max(0.0)
}
pub fn relu_grad(x: f32) -> f32 {
if x > 0.0 {
1.0
} else {
0.0
}
}
struct XorShift32 {
state: u32,
}
impl XorShift32 {
fn new(seed: u32) -> Self {
Self {
state: seed.wrapping_add(0x9e37_79b9),
}
}
fn next(&mut self) -> u32 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.state = x;
x
}
fn uniform_f32(&mut self, scale: f32) -> f32 {
let u = self.next();
let normalised = (u as f32 / u32::MAX as f32) * 2.0 - 1.0;
normalised * scale
}
}
pub struct MlpForward {
pub z1: Vec<f32>,
pub hidden: Vec<f32>,
pub logits: Vec<f32>,
}
pub struct MlpForwardBatch {
pub z1: Array2<f32>,
pub hidden: Array2<f32>,
pub logits: Array2<f32>,
}
#[derive(Debug, Clone)]
pub struct MlpGradients {
pub dw1: Vec<f32>,
pub db1: Vec<f32>,
pub dw2: Vec<f32>,
pub db2: Vec<f32>,
}
#[derive(Debug, Clone)]
pub struct MlpClassifier {
input_dim: usize,
hidden_dim: usize,
output_dim: usize,
pub w1: Vec<f32>,
pub b1: Vec<f32>,
pub w2: Vec<f32>,
pub b2: Vec<f32>,
}
impl MlpClassifier {
pub fn new(input_dim: usize, hidden_dim: usize, output_dim: usize, seed: u32) -> Self {
let mut rng = XorShift32::new(seed);
let scale1 = (2.0 / input_dim as f32).sqrt() * 0.1;
let mut w1 = vec![0.0f32; input_dim * hidden_dim];
for v in w1.iter_mut() {
*v = rng.uniform_f32(scale1);
}
let mut b1 = vec![0.0f32; hidden_dim];
for v in b1.iter_mut() {
*v = rng.uniform_f32(0.01);
}
let scale2 = (2.0 / hidden_dim as f32).sqrt() * 0.1;
let mut w2 = vec![0.0f32; hidden_dim * output_dim];
for v in w2.iter_mut() {
*v = rng.uniform_f32(scale2);
}
let mut b2 = vec![0.0f32; output_dim];
for v in b2.iter_mut() {
*v = rng.uniform_f32(0.01);
}
Self {
input_dim,
hidden_dim,
output_dim,
w1,
b1,
w2,
b2,
}
}
pub fn forward(&self, x: &[f32]) -> MlpForward {
assert_eq!(x.len(), self.input_dim, "forward: input dimension mismatch");
let z1 = matmul(x, 1, self.input_dim, &self.w1, self.hidden_dim)
.iter()
.zip(self.b1.iter())
.map(|(&z, &b)| z + b)
.collect::<Vec<_>>();
let hidden = z1.iter().map(|&z| relu(z)).collect::<Vec<_>>();
let logits = matmul(&hidden, 1, self.hidden_dim, &self.w2, self.output_dim)
.iter()
.zip(self.b2.iter())
.map(|(&z, &b)| z + b)
.collect::<Vec<_>>();
MlpForward { z1, hidden, logits }
}
pub fn forward_hidden(&self, x: &[f32]) -> Vec<f32> {
assert_eq!(
x.len(),
self.input_dim,
"forward_hidden: input dimension mismatch"
);
matmul(x, 1, self.input_dim, &self.w1, self.hidden_dim)
.iter()
.zip(self.b1.iter())
.map(|(&z, &b)| relu(z + b))
.collect()
}
pub fn predict(&self, x: &[f32]) -> usize {
let fwd = self.forward(x);
fwd.logits
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(i, _)| i)
.unwrap_or(0)
}
pub fn probabilities(&self, x: &[f32]) -> Vec<f32> {
let fwd = self.forward(x);
softmax(&fwd.logits)
}
pub fn loss(&self, x: &[f32], target: usize) -> f32 {
let fwd = self.forward(x);
cross_entropy_from_logits(&fwd.logits, target)
}
pub fn backward(&self, x: &[f32], target: usize) -> MlpGradients {
assert_eq!(
x.len(),
self.input_dim,
"backward: input dimension mismatch"
);
assert!(
target < self.output_dim,
"backward: target class out of bounds"
);
let fwd = self.forward(x);
let dlogits = cross_entropy_logits_grad(&fwd.logits, target);
let mut dw2 = vec![0.0f32; self.hidden_dim * self.output_dim];
for (i, &h_i) in fwd.hidden.iter().enumerate() {
for (k, &dlogit_k) in dlogits.iter().enumerate() {
dw2[i * self.output_dim + k] = h_i * dlogit_k;
}
}
let db2 = dlogits.clone();
let mut dh = vec![0.0f32; self.hidden_dim];
for (i, dh_i) in dh.iter_mut().enumerate() {
let mut acc = 0.0f32;
for (k, &dlogit_k) in dlogits.iter().enumerate() {
acc += dlogit_k * self.w2[i * self.output_dim + k];
}
*dh_i = acc;
}
let dz1: Vec<f32> = dh
.iter()
.zip(fwd.z1.iter())
.map(|(&dh_i, &z1_i)| dh_i * relu_grad(z1_i))
.collect();
let mut dw1 = vec![0.0f32; self.input_dim * self.hidden_dim];
for j in 0..self.input_dim {
for i in 0..self.hidden_dim {
dw1[j * self.hidden_dim + i] = x[j] * dz1[i];
}
}
let db1 = dz1;
MlpGradients { dw1, db1, dw2, db2 }
}
pub fn apply_sgd(&mut self, grad: &MlpGradients, lr: f32) {
for i in 0..self.w1.len() {
self.w1[i] -= lr * grad.dw1[i];
}
for i in 0..self.b1.len() {
self.b1[i] -= lr * grad.db1[i];
}
for i in 0..self.w2.len() {
self.w2[i] -= lr * grad.dw2[i];
}
for i in 0..self.b2.len() {
self.b2[i] -= lr * grad.db2[i];
}
}
pub fn forward_batch(&self, x: &[f32], batch_size: usize) -> MlpForwardBatch {
assert_eq!(
x.len(),
batch_size * self.input_dim,
"forward_batch: input dimension mismatch"
);
let x_arr = Array2::from_shape_vec((batch_size, self.input_dim), x.to_vec())
.expect("forward_batch: invalid input shape");
let w1_view = ArrayView2::from_shape((self.input_dim, self.hidden_dim), &self.w1).unwrap();
let b1_view = ArrayView2::from_shape((1, self.hidden_dim), &self.b1).unwrap();
let z1 = x_arr.dot(&w1_view) + &b1_view;
let hidden = z1.mapv(|v| relu(v));
let w2_view = ArrayView2::from_shape((self.hidden_dim, self.output_dim), &self.w2).unwrap();
let b2_view = ArrayView2::from_shape((1, self.output_dim), &self.b2).unwrap();
let logits = hidden.dot(&w2_view) + &b2_view;
MlpForwardBatch { z1, hidden, logits }
}
pub fn loss_batch(&self, x: &[f32], targets: &[usize], batch_size: usize) -> f32 {
assert_eq!(
targets.len(),
batch_size,
"loss_batch: target count mismatch"
);
let fwd = self.forward_batch(x, batch_size);
let mut total = 0.0f32;
for (logits_row, &target) in fwd.logits.axis_iter(Axis(0)).zip(targets.iter()) {
total += cross_entropy_from_logits(logits_row.as_slice().unwrap(), target);
}
total / batch_size as f32
}
pub fn backward_batch(
&self,
x: &[f32],
targets: &[usize],
batch_size: usize,
) -> (MlpGradients, Vec<f32>, f32) {
assert_eq!(
x.len(),
batch_size * self.input_dim,
"backward_batch: input dimension mismatch"
);
assert_eq!(
targets.len(),
batch_size,
"backward_batch: target count mismatch"
);
for &target in targets {
assert!(
target < self.output_dim,
"backward_batch: target class out of bounds"
);
}
let fwd = self.forward_batch(x, batch_size);
let batch_f = batch_size as f32;
let mut dlogits = Array2::zeros((batch_size, self.output_dim));
let mut total_loss = 0.0f32;
for ((mut row, logits_row), &target) in dlogits
.axis_iter_mut(Axis(0))
.zip(fwd.logits.axis_iter(Axis(0)))
.zip(targets.iter())
{
let probs = softmax(logits_row.as_slice().unwrap());
total_loss += -probs[target].max(1e-30).ln();
let mut shifted = Array1::from_vec(probs);
shifted[target] -= 1.0;
row.assign(&shifted);
}
let avg_loss = total_loss / batch_f;
let dw2 = fwd.hidden.t().dot(&dlogits) / batch_f;
let db2 = dlogits.mean_axis(Axis(0)).unwrap();
let w2_view = ArrayView2::from_shape((self.hidden_dim, self.output_dim), &self.w2).unwrap();
let dh = dlogits.dot(&w2_view.t());
let dz1 = dh * fwd.z1.mapv(|v| relu_grad(v));
let w1_view = ArrayView2::from_shape((self.input_dim, self.hidden_dim), &self.w1).unwrap();
let dx = dz1.dot(&w1_view.t());
let x_arr = Array2::from_shape_vec((batch_size, self.input_dim), x.to_vec())
.expect("backward_batch: invalid input shape");
let dw1 = x_arr.t().dot(&dz1) / batch_f;
let db1 = dz1.mean_axis(Axis(0)).unwrap();
(
MlpGradients {
dw1: dw1.into_raw_vec(),
db1: db1.into_raw_vec(),
dw2: dw2.into_raw_vec(),
db2: db2.into_raw_vec(),
},
dx.into_raw_vec(),
avg_loss,
)
}
pub fn flatten_params(&self) -> Vec<f32> {
let mut params =
Vec::with_capacity(self.w1.len() + self.b1.len() + self.w2.len() + self.b2.len());
params.extend_from_slice(&self.w1);
params.extend_from_slice(&self.b1);
params.extend_from_slice(&self.w2);
params.extend_from_slice(&self.b2);
params
}
pub fn flatten_grad(&self, grad: &MlpGradients) -> Vec<f32> {
let mut flat =
Vec::with_capacity(grad.dw1.len() + grad.db1.len() + grad.dw2.len() + grad.db2.len());
flat.extend_from_slice(&grad.dw1);
flat.extend_from_slice(&grad.db1);
flat.extend_from_slice(&grad.dw2);
flat.extend_from_slice(&grad.db2);
flat
}
pub fn load_flat_params(&mut self, params: &[f32]) {
let w1_len = self.w1.len();
let b1_len = self.b1.len();
let w2_len = self.w2.len();
let b2_len = self.b2.len();
let expected = w1_len + b1_len + w2_len + b2_len;
assert_eq!(params.len(), expected, "load_flat_params: size mismatch");
let mut off = 0;
self.w1.copy_from_slice(¶ms[off..off + w1_len]);
off += w1_len;
self.b1.copy_from_slice(¶ms[off..off + b1_len]);
off += b1_len;
self.w2.copy_from_slice(¶ms[off..off + w2_len]);
off += w2_len;
self.b2.copy_from_slice(¶ms[off..off + b2_len]);
}
}
#[derive(Debug, Clone)]
pub struct EmbeddingMlpGradients {
pub dembedding: Vec<f32>,
pub mlp: MlpGradients,
}
#[derive(Debug, Clone)]
pub struct EmbeddingMlpClassifier {
vocab_size: usize,
embed_dim: usize,
n_context_tokens: usize,
continuous_dim: usize,
rope_theta: Option<f32>,
pub embedding: Vec<f32>,
pub mlp: MlpClassifier,
}
fn apply_rope_in_place(vec: &mut [f32], embed_dim: usize, position: usize, theta: f32) {
assert_eq!(
vec.len(),
embed_dim,
"apply_rope_in_place: dimension mismatch"
);
assert!(
embed_dim % 2 == 0,
"apply_rope_in_place: embed_dim must be even"
);
let ln_theta = theta.ln();
let pos_f = position as f32;
let embed_dim_f = embed_dim as f32;
for pair in 0..embed_dim / 2 {
let d0 = pair * 2;
let d1 = d0 + 1;
let freq = (-2.0f32 * pair as f32 * ln_theta / embed_dim_f).exp();
let angle = pos_f * freq;
let (cos_a, sin_a) = (angle.cos(), angle.sin());
let v0 = vec[d0];
let v1 = vec[d1];
vec[d0] = v0 * cos_a - v1 * sin_a;
vec[d1] = v0 * sin_a + v1 * cos_a;
}
}
fn apply_rope_inv_in_place(vec: &mut [f32], embed_dim: usize, position: usize, theta: f32) {
assert_eq!(
vec.len(),
embed_dim,
"apply_rope_inv_in_place: dimension mismatch"
);
assert!(
embed_dim % 2 == 0,
"apply_rope_inv_in_place: embed_dim must be even"
);
let ln_theta = theta.ln();
let pos_f = position as f32;
let embed_dim_f = embed_dim as f32;
for pair in 0..embed_dim / 2 {
let d0 = pair * 2;
let d1 = d0 + 1;
let freq = (-2.0f32 * pair as f32 * ln_theta / embed_dim_f).exp();
let angle = pos_f * freq;
let (cos_a, sin_a) = (angle.cos(), angle.sin());
let v0 = vec[d0];
let v1 = vec[d1];
vec[d0] = v0 * cos_a + v1 * sin_a;
vec[d1] = -v0 * sin_a + v1 * cos_a;
}
}
impl EmbeddingMlpClassifier {
pub fn new(
vocab_size: usize,
embed_dim: usize,
n_context_tokens: usize,
continuous_dim: usize,
hidden_dim: usize,
output_dim: usize,
seed: u32,
rope_theta: Option<f32>,
) -> Self {
if let Some(theta) = rope_theta {
assert!(
theta > 0.0,
"EmbeddingMlpClassifier: rope_theta must be positive"
);
assert!(
embed_dim % 2 == 0,
"EmbeddingMlpClassifier: RoPE requires even embed_dim"
);
}
let input_dim = n_context_tokens * embed_dim + continuous_dim;
let mut rng = XorShift32::new(seed);
let scale = (2.0 / embed_dim as f32).sqrt() * 0.1;
let mut embedding = vec![0.0f32; vocab_size * embed_dim];
for v in embedding.iter_mut() {
*v = rng.uniform_f32(scale);
}
let mlp = MlpClassifier::new(input_dim, hidden_dim, output_dim, seed.wrapping_add(1));
Self {
vocab_size,
embed_dim,
n_context_tokens,
continuous_dim,
rope_theta,
embedding,
mlp,
}
}
pub fn input_dim(&self) -> usize {
self.n_context_tokens * self.embed_dim + self.continuous_dim
}
fn fill_input(&self, token_ids: &[u32], continuous: Option<&[f32]>, input: &mut [f32]) {
assert_eq!(
token_ids.len(),
self.n_context_tokens,
"fill_input: token count mismatch"
);
if let Some(cont) = continuous {
assert_eq!(
cont.len(),
self.continuous_dim,
"fill_input: continuous dimension mismatch"
);
}
let mut off = 0;
for (t, &tid) in token_ids.iter().enumerate() {
let idx = (tid as usize).min(self.vocab_size - 1);
let src = &self.embedding[idx * self.embed_dim..(idx + 1) * self.embed_dim];
input[off..off + self.embed_dim].copy_from_slice(src);
if let Some(theta) = self.rope_theta {
let position = self.n_context_tokens - t;
apply_rope_in_place(
&mut input[off..off + self.embed_dim],
self.embed_dim,
position,
theta,
);
}
off += self.embed_dim;
}
if let Some(cont) = continuous {
input[off..off + self.continuous_dim].copy_from_slice(cont);
}
}
fn fill_batch_input(
&self,
token_ids: &[u32],
continuous: Option<&[f32]>,
batch_size: usize,
input: &mut [f32],
) {
let input_dim = self.input_dim();
assert_eq!(
token_ids.len(),
batch_size * self.n_context_tokens,
"fill_batch_input: token count mismatch"
);
assert_eq!(
input.len(),
batch_size * input_dim,
"fill_batch_input: input buffer size mismatch"
);
for b in 0..batch_size {
let tok_start = b * self.n_context_tokens;
let cont = continuous.map(|c| {
let start = b * self.continuous_dim;
&c[start..start + self.continuous_dim]
});
self.fill_input(
&token_ids[tok_start..tok_start + self.n_context_tokens],
cont,
&mut input[b * input_dim..(b + 1) * input_dim],
);
}
}
pub fn forward(&self, token_ids: &[u32], continuous: Option<&[f32]>) -> MlpForward {
let mut input = vec![0.0f32; self.input_dim()];
self.fill_input(token_ids, continuous, &mut input);
self.mlp.forward(&input)
}
pub fn predict(&self, token_ids: &[u32], continuous: Option<&[f32]>) -> usize {
let fwd = self.forward(token_ids, continuous);
fwd.logits
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(i, _)| i)
.unwrap_or(0)
}
pub fn loss(&self, token_ids: &[u32], continuous: Option<&[f32]>, target: usize) -> f32 {
let fwd = self.forward(token_ids, continuous);
cross_entropy_from_logits(&fwd.logits, target)
}
pub fn forward_batch(
&self,
token_ids: &[u32],
continuous: Option<&[f32]>,
batch_size: usize,
) -> MlpForwardBatch {
let mut input = vec![0.0f32; batch_size * self.input_dim()];
self.fill_batch_input(token_ids, continuous, batch_size, &mut input);
self.mlp.forward_batch(&input, batch_size)
}
pub fn backward_batch(
&self,
token_ids: &[u32],
continuous: Option<&[f32]>,
targets: &[usize],
batch_size: usize,
) -> (EmbeddingMlpGradients, f32) {
assert_eq!(
token_ids.len(),
batch_size * self.n_context_tokens,
"backward_batch: token count mismatch"
);
assert_eq!(
targets.len(),
batch_size,
"backward_batch: target count mismatch"
);
if let Some(cont) = continuous {
assert_eq!(
cont.len(),
batch_size * self.continuous_dim,
"backward_batch: continuous size mismatch"
);
}
let mut input = vec![0.0f32; batch_size * self.input_dim()];
self.fill_batch_input(token_ids, continuous, batch_size, &mut input);
let (mlp_grad, dx, loss) = self.mlp.backward_batch(&input, targets, batch_size);
let mut dembedding = vec![0.0f32; self.embedding.len()];
let input_dim = self.input_dim();
for b in 0..batch_size {
let dx_row = &dx[b * input_dim..(b + 1) * input_dim];
let tok_start = b * self.n_context_tokens;
for (t, &tid) in token_ids[tok_start..tok_start + self.n_context_tokens]
.iter()
.enumerate()
{
let idx = (tid as usize).min(self.vocab_size - 1);
let mut dx_emb = dx_row[t * self.embed_dim..(t + 1) * self.embed_dim].to_vec();
if let Some(theta) = self.rope_theta {
let position = self.n_context_tokens - t;
apply_rope_inv_in_place(&mut dx_emb, self.embed_dim, position, theta);
}
let dst = &mut dembedding[idx * self.embed_dim..(idx + 1) * self.embed_dim];
for (d, g) in dst.iter_mut().zip(dx_emb.iter()) {
*d += *g;
}
}
}
let inv_b = 1.0 / batch_size as f32;
for v in dembedding.iter_mut() {
*v *= inv_b;
}
(
EmbeddingMlpGradients {
dembedding,
mlp: mlp_grad,
},
loss,
)
}
pub fn apply_sgd(&mut self, grad: &EmbeddingMlpGradients, lr: f32) {
for i in 0..self.embedding.len() {
self.embedding[i] -= lr * grad.dembedding[i];
}
self.mlp.apply_sgd(&grad.mlp, lr);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matmul_identity() {
let a = vec![1.0f32, 2.0, 3.0, 4.0];
let i = vec![1.0f32, 0.0, 0.0, 1.0];
let c = matmul(&a, 2, 2, &i, 2);
assert_eq!(c, a);
}
#[test]
fn matmul_small() {
let a = vec![1.0f32, 2.0, 3.0, 4.0];
let b = vec![5.0f32, 6.0, 7.0, 8.0];
let c = matmul(&a, 2, 2, &b, 2);
assert_eq!(c, vec![19.0, 22.0, 43.0, 50.0]);
}
#[test]
fn cross_entropy_decreases_with_target_probability() {
let p = vec![0.1f32, 0.8, 0.1];
assert!(cross_entropy_loss(&p, 1) < cross_entropy_loss(&p, 0));
}
#[test]
fn mlp_learns_xor() {
let examples: Vec<(Vec<f32>, usize)> = vec![
(vec![0.0, 0.0], 0),
(vec![0.0, 1.0], 1),
(vec![1.0, 0.0], 1),
(vec![1.0, 1.0], 0),
];
let mut model = MlpClassifier::new(2, 8, 2, 7);
let lr = 0.2;
for _ in 0..2000 {
for (x, y) in &examples {
let grad = model.backward(x, *y);
model.apply_sgd(&grad, lr);
}
}
for (x, y) in &examples {
assert_eq!(
model.predict(x),
*y,
"failed XOR input {:?}, logits {:?}",
x,
model.forward(x).logits
);
}
}
#[test]
fn flatten_and_load_round_trip() {
let model = MlpClassifier::new(3, 4, 2, 13);
let params = model.flatten_params();
let mut restored = MlpClassifier::new(3, 4, 2, 99);
restored.load_flat_params(¶ms);
assert_eq!(model.w1, restored.w1);
assert_eq!(model.b1, restored.b1);
assert_eq!(model.w2, restored.w2);
assert_eq!(model.b2, restored.b2);
}
#[test]
fn backward_batch_matches_per_example_average() {
let input_dim = 4;
let hidden_dim = 5;
let output_dim = 3;
let model = MlpClassifier::new(input_dim, hidden_dim, output_dim, 21);
let batch_size = 3;
let inputs: Vec<f32> = (0..input_dim * batch_size)
.map(|i| (i as f32) * 0.13 - 0.4)
.collect();
let targets = vec![0, 2, 1];
let (grad_batch, _dx, loss_batch) = model.backward_batch(&inputs, &targets, batch_size);
let mut dw1 = vec![0.0f32; grad_batch.dw1.len()];
let mut db1 = vec![0.0f32; grad_batch.db1.len()];
let mut dw2 = vec![0.0f32; grad_batch.dw2.len()];
let mut db2 = vec![0.0f32; grad_batch.db2.len()];
let mut loss_sum = 0.0f32;
for b in 0..batch_size {
let x = &inputs[b * input_dim..(b + 1) * input_dim];
let g = model.backward(x, targets[b]);
for (a, v) in dw1.iter_mut().zip(g.dw1.iter()) {
*a += v;
}
for (a, v) in db1.iter_mut().zip(g.db1.iter()) {
*a += v;
}
for (a, v) in dw2.iter_mut().zip(g.dw2.iter()) {
*a += v;
}
for (a, v) in db2.iter_mut().zip(g.db2.iter()) {
*a += v;
}
loss_sum += model.loss(x, targets[b]);
}
let inv = 1.0 / batch_size as f32;
for v in dw1.iter_mut() {
*v *= inv;
}
for v in db1.iter_mut() {
*v *= inv;
}
for v in dw2.iter_mut() {
*v *= inv;
}
for v in db2.iter_mut() {
*v *= inv;
}
let tol = 1e-5;
for (a, b) in grad_batch.dw1.iter().zip(dw1.iter()) {
assert!((a - b).abs() < tol, "dw1 mismatch: {} vs {}", a, b);
}
for (a, b) in grad_batch.db1.iter().zip(db1.iter()) {
assert!((a - b).abs() < tol, "db1 mismatch: {} vs {}", a, b);
}
for (a, b) in grad_batch.dw2.iter().zip(dw2.iter()) {
assert!((a - b).abs() < tol, "dw2 mismatch: {} vs {}", a, b);
}
for (a, b) in grad_batch.db2.iter().zip(db2.iter()) {
assert!((a - b).abs() < tol, "db2 mismatch: {} vs {}", a, b);
}
assert!((loss_batch - loss_sum * inv).abs() < tol);
}
#[test]
fn embedding_mlp_learns_xor_with_continuous() {
let examples: Vec<(u32, u32, usize)> = vec![(0, 0, 0), (0, 1, 1), (1, 0, 1), (1, 1, 0)];
let mut model = EmbeddingMlpClassifier::new(2, 8, 2, 0, 16, 2, 7, None);
let lr = 0.2;
for _ in 0..8000 {
for &(t1, t2, y) in &examples {
let grad = model.backward_batch(&[t1, t2], None, &[y], 1);
model.apply_sgd(&grad.0, lr);
}
}
for &(t1, t2, y) in &examples {
assert_eq!(
model.predict(&[t1, t2], None),
y,
"failed XOR input ({}, {})",
t1,
t2
);
}
}
#[test]
fn embedding_mlp_batch_matches_single_example() {
let vocab_size = 5;
let embed_dim = 3;
let n_context = 2;
let continuous_dim = 4;
let model = EmbeddingMlpClassifier::new(
vocab_size,
embed_dim,
n_context,
continuous_dim,
6,
3,
11,
None,
);
let token_ids = vec![0u32, 2, 4, 1];
let continuous: Vec<f32> = (0..(2 * continuous_dim) as i32)
.map(|i| i as f32 * 0.1 - 0.5)
.collect();
let targets = vec![0, 2];
let (batch_grad, batch_loss) =
model.backward_batch(&token_ids, Some(&continuous), &targets, 2);
let mut dembedding = vec![0.0f32; vocab_size * embed_dim];
let mut dw1 = vec![0.0f32; model.mlp.w1.len()];
let mut db1 = vec![0.0f32; model.mlp.b1.len()];
let mut dw2 = vec![0.0f32; model.mlp.w2.len()];
let mut db2 = vec![0.0f32; model.mlp.b2.len()];
let mut loss_sum = 0.0f32;
for b in 0..2 {
let t = &token_ids[b * n_context..(b + 1) * n_context];
let c = &continuous[b * continuous_dim..(b + 1) * continuous_dim];
let (g, loss) = model.backward_batch(t, Some(c), &[targets[b]], 1);
loss_sum += loss;
for (a, v) in dembedding.iter_mut().zip(g.dembedding.iter()) {
*a += v;
}
for (a, v) in dw1.iter_mut().zip(g.mlp.dw1.iter()) {
*a += v;
}
for (a, v) in db1.iter_mut().zip(g.mlp.db1.iter()) {
*a += v;
}
for (a, v) in dw2.iter_mut().zip(g.mlp.dw2.iter()) {
*a += v;
}
for (a, v) in db2.iter_mut().zip(g.mlp.db2.iter()) {
*a += v;
}
}
let inv = 1.0 / 2.0;
for v in dembedding.iter_mut() {
*v *= inv;
}
for v in dw1.iter_mut() {
*v *= inv;
}
for v in db1.iter_mut() {
*v *= inv;
}
for v in dw2.iter_mut() {
*v *= inv;
}
for v in db2.iter_mut() {
*v *= inv;
}
let tol = 1e-5;
for (a, b) in batch_grad.dembedding.iter().zip(dembedding.iter()) {
assert!((a - b).abs() < tol, "dembedding mismatch: {} vs {}", a, b);
}
for (a, b) in batch_grad.mlp.dw1.iter().zip(dw1.iter()) {
assert!((a - b).abs() < tol, "dw1 mismatch: {} vs {}", a, b);
}
for (a, b) in batch_grad.mlp.db1.iter().zip(db1.iter()) {
assert!((a - b).abs() < tol, "db1 mismatch: {} vs {}", a, b);
}
for (a, b) in batch_grad.mlp.dw2.iter().zip(dw2.iter()) {
assert!((a - b).abs() < tol, "dw2 mismatch: {} vs {}", a, b);
}
for (a, b) in batch_grad.mlp.db2.iter().zip(db2.iter()) {
assert!((a - b).abs() < tol, "db2 mismatch: {} vs {}", a, b);
}
assert!((batch_loss - loss_sum * inv).abs() < tol);
}
#[test]
fn rope_rotates_differently_by_position() {
let mut v1 = vec![1.0f32, 0.0, 0.0, 1.0];
let mut v2 = v1.clone();
apply_rope_in_place(&mut v1, 4, 1, 10000.0);
apply_rope_in_place(&mut v2, 4, 2, 10000.0);
assert!(v1.iter().zip(v2.iter()).any(|(a, b)| (a - b).abs() > 1e-6));
}
#[test]
fn rope_inverse_recovers_original() {
let original = vec![0.3f32, -0.7, 1.2, 0.4];
let mut rotated = original.clone();
apply_rope_in_place(&mut rotated, 4, 3, 10000.0);
let mut recovered = rotated.clone();
apply_rope_inv_in_place(&mut recovered, 4, 3, 10000.0);
let tol = 1e-5;
for (a, b) in original.iter().zip(recovered.iter()) {
assert!((a - b).abs() < tol, "RoPE inverse mismatch: {} vs {}", a, b);
}
}
#[test]
fn embedding_mlp_batch_with_rope_matches_per_example() {
let vocab_size = 5;
let embed_dim = 4;
let n_context = 2;
let continuous_dim = 0;
let model = EmbeddingMlpClassifier::new(
vocab_size,
embed_dim,
n_context,
continuous_dim,
6,
3,
11,
Some(10000.0),
);
let token_ids = vec![0u32, 2, 4, 1];
let targets = vec![0, 2];
let (batch_grad, batch_loss) = model.backward_batch(&token_ids, None, &targets, 2);
let mut dembedding = vec![0.0f32; vocab_size * embed_dim];
let mut dw1 = vec![0.0f32; model.mlp.w1.len()];
let mut db1 = vec![0.0f32; model.mlp.b1.len()];
let mut dw2 = vec![0.0f32; model.mlp.w2.len()];
let mut db2 = vec![0.0f32; model.mlp.b2.len()];
let mut loss_sum = 0.0f32;
for b in 0..2 {
let t = &token_ids[b * n_context..(b + 1) * n_context];
let (g, loss) = model.backward_batch(t, None, &[targets[b]], 1);
loss_sum += loss;
for (a, v) in dembedding.iter_mut().zip(g.dembedding.iter()) {
*a += v;
}
for (a, v) in dw1.iter_mut().zip(g.mlp.dw1.iter()) {
*a += v;
}
for (a, v) in db1.iter_mut().zip(g.mlp.db1.iter()) {
*a += v;
}
for (a, v) in dw2.iter_mut().zip(g.mlp.dw2.iter()) {
*a += v;
}
for (a, v) in db2.iter_mut().zip(g.mlp.db2.iter()) {
*a += v;
}
}
let inv = 1.0 / 2.0;
for v in dembedding.iter_mut() {
*v *= inv;
}
for v in dw1.iter_mut() {
*v *= inv;
}
for v in db1.iter_mut() {
*v *= inv;
}
for v in dw2.iter_mut() {
*v *= inv;
}
for v in db2.iter_mut() {
*v *= inv;
}
let tol = 1e-5;
for (a, b) in batch_grad.dembedding.iter().zip(dembedding.iter()) {
assert!((a - b).abs() < tol, "dembedding mismatch: {} vs {}", a, b);
}
for (a, b) in batch_grad.mlp.dw1.iter().zip(dw1.iter()) {
assert!((a - b).abs() < tol, "dw1 mismatch: {} vs {}", a, b);
}
for (a, b) in batch_grad.mlp.db1.iter().zip(db1.iter()) {
assert!((a - b).abs() < tol, "db1 mismatch: {} vs {}", a, b);
}
for (a, b) in batch_grad.mlp.dw2.iter().zip(dw2.iter()) {
assert!((a - b).abs() < tol, "dw2 mismatch: {} vs {}", a, b);
}
for (a, b) in batch_grad.mlp.db2.iter().zip(db2.iter()) {
assert!((a - b).abs() < tol, "db2 mismatch: {} vs {}", a, b);
}
assert!((batch_loss - loss_sum * inv).abs() < tol);
}
}