use fuel::Result;
use fuel::lazy::Tensor;
use fuel::lazy_training_augmentations::{LrSchedule, clip_grad_norm, clip_grad_value};
use fuel_nn::optim::{Optimizer, Var};
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct StepOutcome {
pub loss: f64,
pub grad_norm: Option<f64>,
pub lr: f64,
pub global_step: usize,
}
pub struct TrainingLoop {
max_grad_norm: Option<f64>,
max_grad_value: Option<f64>,
scheduler: Option<Box<dyn LrSchedule>>,
global_step: usize,
log_interval: Option<usize>,
}
impl TrainingLoop {
pub fn new() -> Self {
Self {
max_grad_norm: None,
max_grad_value: None,
scheduler: None,
global_step: 0,
log_interval: None,
}
}
pub fn with_max_grad_norm(mut self, max_norm: f64) -> Self {
self.max_grad_norm = Some(max_norm);
self
}
pub fn with_max_grad_value(mut self, v: f64) -> Self {
self.max_grad_value = Some(v);
self
}
pub fn with_scheduler<S: LrSchedule + 'static>(mut self, scheduler: S) -> Self {
self.scheduler = Some(Box::new(scheduler));
self
}
pub fn with_log_interval(mut self, interval: usize) -> Self {
self.log_interval = Some(interval);
self
}
pub fn set_global_step(&mut self, step: usize) {
self.global_step = step;
}
pub fn global_step(&self) -> usize {
self.global_step
}
pub fn step<O: Optimizer>(&mut self, loss: &Tensor, opt: &mut O) -> Result<StepOutcome> {
let loss_val = loss.realize_f32()[0] as f64;
let grads = harvest_grads(loss, opt.params());
let grads = if let Some(max_norm) = self.max_grad_norm {
clip_grad_norm(&grads, max_norm, 2.0)?
} else {
grads
};
let grad_norm = if self.max_grad_norm.is_some() {
Some(global_l2_norm(&grads))
} else {
None
};
let grads = if let Some(max_val) = self.max_grad_value {
clip_grad_value(&grads, max_val)?
} else {
grads
};
opt.step(&grads)?;
if let Some(sched) = &self.scheduler {
opt.set_learning_rate(sched.lr_at(self.global_step));
}
self.global_step += 1;
let lr = opt.learning_rate();
let outcome = StepOutcome {
loss: loss_val,
grad_norm,
lr,
global_step: self.global_step,
};
if let Some(interval) = self.log_interval
&& self.global_step.is_multiple_of(interval)
{
match grad_norm {
Some(gn) => tracing::info!(
step = self.global_step,
loss = loss_val,
lr = lr,
grad_norm = gn,
"training step"
),
None => tracing::info!(
step = self.global_step,
loss = loss_val,
lr = lr,
"training step"
),
}
}
Ok(outcome)
}
}
impl Default for TrainingLoop {
fn default() -> Self {
Self::new()
}
}
fn harvest_grads(loss: &Tensor, vars: &[Var]) -> HashMap<String, Tensor> {
let grad_map = loss.backward();
let mut grads = HashMap::with_capacity(vars.len());
for var in vars {
let Some(node_id) = var.last_node_id() else {
continue;
};
let handle =
fuel_graph::NodeHandle::from_existing(loss.graph_tensor().graph().clone(), node_id);
if let Some(grad) = grad_map.get(&handle) {
grads.insert(var.name().to_string(), Tensor::from_graph_tensor(grad));
}
}
grads
}
fn global_l2_norm(grads: &HashMap<String, Tensor>) -> f64 {
let mut acc: f64 = 0.0;
for g in grads.values() {
let host = g.sqr().sum_all().realize_f32();
acc += host[0] as f64;
}
acc.sqrt()
}