SMT D316 (rustytorch): train_rollout — truncated K-step BPTT for the memory cell
CI / Format Check (pull_request) Has been cancelled
Performance Benchmarks / Run Benchmarks (pull_request) Has been cancelled
CI / Clippy Check (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build CPU-Only (Explicit) (pull_request) Has been cancelled
Documentation / Build API Documentation (pull_request) Has been cancelled
Documentation / Build User Guide (pull_request) Has been cancelled
CI / Test (macos-latest) (pull_request) Has been cancelled
CI / Test (ubuntu-latest) (pull_request) Has been cancelled
CI / CI Success (pull_request) Has been cancelled

ClonedMemoryUpdater::train_rollout rolls the cell forward over K inputs on its
OWN memory (not teacher-forced) and backprops the accumulated predict-the-future
loss through the whole K-step graph — the tape's first multi-step training path,
directly optimizing the free-rollout behavior the cell is evaluated on (vs the
one-step BC/DAgger paths).

To stay within the finite-diff-gated op set (matmul/gelu/add/mul/sub/sum — no
tensor concat), W_in is split into its memory rows (applied to M) and input rows
(applied to x); the two gradient halves are re-stacked for the Adam update.
Non-finite guard + gradient clip as in the other training paths.

Test: rollout training reduces the K-step loss (>2x). clippy(-D)/fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-06-17 04:12:46 -07:00
co-authored by Claude Opus 4.8
parent f28a92cfa7
commit ca3f12c6c8
@@ -386,6 +386,106 @@ impl ClonedMemoryUpdater {
self.w_mem.adam(&clip(grad_of(&storage, mem_id)), lr, t); self.w_mem.adam(&clip(grad_of(&storage, mem_id)), lr, t);
loss_val loss_val
} }
/// **Multi-step rollout training (truncated K-step BPTT).** Starting from
/// `init_mem`, roll the cell forward over `xs` **on its own memory** (not
/// teacher-forced), accumulate the predict-the-future loss `Σ_k ‖readout(M_k)
/// targets[k]‖²`, and backprop through the *whole* K-step graph — so the
/// cell is optimized for the free-rollout behavior it is actually evaluated
/// on, directly attacking the autoregressive drift that one-step BC leaves.
///
/// To keep the K-step graph within the gradient-checked op set (matmul / gelu
/// / add / mul / sub / sum — no tensor concat), `W_in` is split into its
/// memory rows (applied to the recurrent state `M`) and its input rows
/// (applied to `x`); the two gradient halves are re-stacked for the Adam
/// update. Returns the mean per-step loss.
///
/// # Panics
/// Panics if `xs.len() != targets.len()`, or on any shape mismatch.
pub fn train_rollout(
&mut self,
init_mem: &[f32],
xs: &[Vec<f32>],
targets: &[Vec<f32>],
lr: f32,
) -> f32 {
let dm = self.cfg.d_mem;
let din = self.cfg.d_in;
let dh = self.cfg.d_hidden;
assert_eq!(init_mem.len(), dm);
assert_eq!(xs.len(), targets.len());
assert!(!xs.is_empty());
// W_in split into memory rows [0..dm] and input rows [dm..dm+din].
let w_in_m = Ad::require_grad(Ad::from_data(
&self.w_in.data[..dm * dh],
[dm, dh],
&self.dev,
));
let w_in_x = Ad::require_grad(Ad::from_data(
&self.w_in.data[dm * dh..],
[din, dh],
&self.dev,
));
let (w_mem, mem_id) = self.w_mem.leaf(&self.dev);
let (w_read, read_id) = self.w_read.leaf(&self.dev);
let in_m_id = w_in_m.id().0;
let in_x_id = w_in_x.id().0;
let decay_t = Ad::from_data(&vec![DECAY; dm], [1, dm], &self.dev);
let mut m = Ad::from_data(init_mem, [1, dm], &self.dev); // rollout start (const)
let mut loss: Option<<Ad as Backend>::TensorPrimitive<1>> = None;
for (x, target) in xs.iter().zip(targets.iter()) {
let x_t = Ad::from_data(x, [1, din], &self.dev);
let preact = Ad::add(
Ad::matmul(m.clone(), w_in_m.clone()),
Ad::matmul(x_t, w_in_x.clone()),
);
let h = Ad::gelu(preact);
let delta = Ad::matmul(h, w_mem.clone());
m = Ad::add(Ad::mul(m, decay_t.clone()), delta); // leaky update (no clamp; K short)
let pred = Ad::matmul(m.clone(), w_read.clone());
let err = Ad::sub(pred, Ad::from_data(target, [1, din], &self.dev));
let step_loss = Ad::sum(Ad::mul(err.clone(), err));
loss = Some(match loss {
None => step_loss,
Some(acc) => Ad::add(acc, step_loss),
});
}
let loss = loss.expect("non-empty rollout");
let loss_val = Ad::to_data(&loss)[0];
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones(
[1],
&<CpuBackend as Backend>::Device::default(),
))),
)
.expect("backward");
let k = xs.len() as f32;
if !loss_val.is_finite() {
return loss_val / k;
}
self.step += 1;
let t = self.step;
const CLIP: f32 = 1.0;
let clip = |mut g: Vec<f32>| {
for v in &mut g {
*v = v.clamp(-CLIP, CLIP);
}
g
};
// Re-stack the two W_in gradient halves (memory rows then input rows) into
// the full row-major W_in gradient.
let mut grad_in = grad_of(&storage, in_m_id);
grad_in.extend(grad_of(&storage, in_x_id));
self.w_in.adam(&clip(grad_in), lr, t);
self.w_mem.adam(&clip(grad_of(&storage, mem_id)), lr, t);
self.w_read.adam(&clip(grad_of(&storage, read_id)), lr, t);
loss_val / k
}
} }
/// Exact-ish GELU matching the tape's `Ad::gelu` (tanh approximation), so the /// Exact-ish GELU matching the tape's `Ad::gelu` (tanh approximation), so the
@@ -432,4 +532,25 @@ mod tests {
assert_eq!(g.predict(&new_mem).len(), 4); assert_eq!(g.predict(&new_mem).len(), 4);
assert!(new_mem.iter().all(|v| v.is_finite())); assert!(new_mem.iter().all(|v| v.is_finite()));
} }
#[test]
fn rollout_training_reduces_loss() {
// Truncated K-step BPTT drives down the rollout's predict-the-future loss.
let cfg = ClonedUpdaterConfig::new(4, 6, 16, 1);
let mut g = ClonedMemoryUpdater::new(cfg);
let init = lcg_init(6, 0.3, 20);
let xs: Vec<Vec<f32>> = (0..6).map(|k| lcg_init(4, 1.0, 30 + k)).collect();
let targets: Vec<Vec<f32>> = (0..6).map(|k| lcg_init(4, 0.4, 60 + k)).collect();
let first = g.train_rollout(&init, &xs, &targets, 0.01);
let mut last = first;
for _ in 0..400 {
last = g.train_rollout(&init, &xs, &targets, 0.01);
assert!(last.is_finite());
}
assert!(
last < first * 0.5,
"rollout training did not reduce loss: {first:.4} -> {last:.4}"
);
}
} }