use super::traits::{Layer, Loss, Optimizer, check_addresses, check_every_gradient_is_claimed};
use crate::error::{Error, IoError};
use crate::neural_network::NnError;
use crate::neural_network::Shape;
use crate::neural_network::Tensor;
use crate::neural_network::ctx::Ctx;
use crate::neural_network::layer_path::{
LayerPath, apply_state_tree, check_state_taken, check_sublayer_calls, find_weight,
global_grad_norm, model_weight_paths, total_param_count, update_model,
};
use crate::neural_network::layers::checkpoint::{
LoadReport, MODEL_FORMAT_VERSION, MODEL_MAGIC, ModelCheckpoint, apply, apply_partial, capture,
};
use ahash::AHashMap;
use ndarray::{ArrayViewD, Axis};
use ndarray_rand::rand::seq::SliceRandom;
use std::fs::File;
use std::io::{BufWriter, Write};
pub struct Sequential {
layers: Vec<Box<dyn Layer>>,
input_shapes: Vec<Shape>,
optimizer: Option<Box<dyn Optimizer>>,
loss: Option<Box<dyn Loss>>,
seed: Option<u64>,
}
#[derive(Default)]
pub struct SequentialBuilder {
layers: Vec<Box<dyn Layer>>,
seed: Option<u64>,
}
impl SequentialBuilder {
pub fn new() -> Self {
Self {
layers: Vec::new(),
seed: None,
}
}
pub fn new_with_seed(seed: u64) -> Self {
Self {
layers: Vec::new(),
seed: Some(seed),
}
}
#[allow(clippy::should_implement_trait)]
pub fn add<L: 'static + Layer>(mut self, layer: L) -> Self {
self.layers.push(Box::new(layer));
self
}
pub fn build(mut self, input_shape: &Shape) -> Result<Sequential, Error> {
if self.layers.is_empty() {
return Err(Error::NeuralNetwork(NnError::EmptyModel));
}
if let Some(axis) = input_shape
.axes()
.iter()
.skip(1)
.position(|extent| *extent == Some(0))
{
return Err(Error::invalid_input(format!(
"the model input shape {input_shape} holds 0 elements on axis {}. A model \
cannot take an input with an empty feature axis",
axis + 1
)));
}
let mut input_shapes = Vec::with_capacity(self.layers.len());
let mut shape = input_shape.clone();
for (index, layer) in self.layers.iter_mut().enumerate() {
let layer_type = layer.layer_type().to_string();
if !layer.arity().accepts(1) {
return Err(Error::invalid_input(format!(
"layer {index} (`{layer_type}`) takes more than 1 input, and a sequential \
model gives each layer exactly 1. Wire it in a graph instead"
)));
}
let inputs = [shape.clone()];
layer
.build_many(&inputs)
.map_err(|source| build_refusal(index, &layer_type, &shape, source))?;
let output = layer
.compute_output_shape_many(&inputs)
.map_err(|source| build_refusal(index, &layer_type, &shape, source))?;
input_shapes.push(shape);
shape = output;
}
for (index, layer) in self.layers.iter_mut().enumerate() {
check_addresses(index, &mut **layer)?;
}
Ok(Sequential {
layers: self.layers,
input_shapes,
optimizer: None,
loss: None,
seed: self.seed,
})
}
}
#[cold]
fn build_refusal(index: usize, layer_type: &str, input: &Shape, source: Error) -> Error {
Error::invalid_input(format!(
"layer {index} (`{layer_type}`) refused the input shape {input}: {source}"
))
}
#[derive(Debug, Clone, PartialEq)]
pub struct History {
loss: Vec<f32>,
}
impl History {
pub(crate) fn new(loss: Vec<f32>) -> Self {
Self { loss }
}
pub fn loss(&self) -> &[f32] {
&self.loss
}
}
impl Sequential {
pub fn set_seed(&mut self, seed: u64) -> &mut Self {
self.seed = Some(seed);
self
}
pub fn set_learning_rate(&mut self, learning_rate: f32) -> &mut Self {
if let Some(ref mut optimizer) = self.optimizer {
optimizer.set_learning_rate(learning_rate);
}
self
}
pub fn learning_rate(&self) -> Option<f32> {
self.optimizer.as_ref().map(|opt| opt.learning_rate())
}
pub fn compile<O, LFunc>(&mut self, optimizer: O, loss: LFunc) -> &mut Self
where
O: 'static + Optimizer,
LFunc: 'static + Loss,
{
self.optimizer = Some(Box::new(optimizer));
self.loss = Some(Box::new(loss));
self
}
fn validate_training_inputs(&self, x: &Tensor, y: &Tensor) -> Result<(), Error> {
if self.optimizer.is_none() {
return Err(Error::NeuralNetwork(NnError::NotCompiled("optimizer")));
}
self.validate_evaluation_inputs(x, y)
}
fn validate_evaluation_inputs(&self, x: &Tensor, y: &Tensor) -> Result<(), Error> {
if self.loss.is_none() {
return Err(Error::NeuralNetwork(NnError::NotCompiled("loss function")));
}
if self.layers.is_empty() {
return Err(Error::NeuralNetwork(NnError::EmptyModel));
}
if x.ndim() == 0 || y.ndim() == 0 {
return Err(Error::invalid_input(
"input tensors must have a leading batch axis, but a rank-0 tensor was supplied",
));
}
if x.is_empty() || y.is_empty() {
return Err(Error::empty_input("input tensors"));
}
if x.shape()[0] != y.shape()[0] {
return Err(Error::dimension_mismatch(x.shape()[0], y.shape()[0]));
}
Ok(())
}
pub fn train_batch(&mut self, x: &Tensor, y: &Tensor) -> Result<f32, Error> {
self.validate_training_inputs(x, y)?;
let mut ctx = Ctx::training();
let mut output: Option<Tensor> = None;
for scope in 0..self.layers.len() {
ctx.set_owner(scope);
let input = output.as_ref().unwrap_or(x);
let next = self.layers[scope].forward_many(&[input], &mut ctx)?;
check_sublayer_calls(&self.layers[..=scope], &ctx)?;
apply_state_tree(&mut *self.layers[scope], &LayerPath::root(scope), &mut ctx);
check_state_taken(&ctx, scope)?;
output = Some(next);
}
let output = output.ok_or(Error::NeuralNetwork(NnError::EmptyModel))?;
let loss_value = self.loss.as_ref().unwrap().compute_loss(y, &output)?;
let mut grad = self.loss.as_ref().unwrap().compute_grad(y, &output)?;
if let Some(ref mut optimizer) = self.optimizer {
optimizer.step();
}
for (scope, layer) in self.layers.iter_mut().enumerate().rev() {
ctx.set_owner(scope);
let mut inputs = layer.backward_many(&grad, &mut ctx)?;
grad = inputs.pop().ok_or_else(|| {
Error::computation(format!(
"layer {scope} (`{}`) gave back no input gradient",
layer.layer_type()
))
})?;
}
check_sublayer_calls(&self.layers, &ctx)?;
check_every_gradient_is_claimed(&mut self.layers, ctx.grads())?;
let global_clipnorm = self
.optimizer
.as_ref()
.and_then(|opt| opt.global_clipnorm());
let grad_scale = match global_clipnorm {
Some(max_norm) => {
let norm = global_grad_norm(&mut self.layers, ctx.grads());
if norm.is_finite() && norm > max_norm {
max_norm / norm
} else {
1.0
}
}
None => 1.0,
};
if let Some(ref mut optimizer) = self.optimizer {
update_model(&mut **optimizer, &mut self.layers, ctx.grads(), grad_scale);
}
Ok(loss_value)
}
pub fn fit(&mut self, x: &Tensor, y: &Tensor, epochs: u32) -> Result<History, Error> {
self.validate_training_inputs(x, y)?;
#[cfg(feature = "show_progress")]
let progress_bar = crate::create_progress_bar(
epochs as u64,
"[{elapsed_precise}] {bar:40} {pos}/{len} | Loss: {msg}",
);
let mut loss = Vec::new();
for _ in 0..epochs {
let epoch_loss = self.train_batch(x, y)?;
loss.push(epoch_loss);
#[cfg(feature = "show_progress")]
{
progress_bar.set_message(format!("{:.6}", epoch_loss));
progress_bar.inc(1);
}
}
#[cfg(feature = "show_progress")]
progress_bar.finish_with_message("Training completed");
Ok(History { loss })
}
pub fn fit_with_batches(
&mut self,
x: &Tensor,
y: &Tensor,
epochs: u32,
batch_size: usize,
) -> Result<History, Error> {
self.validate_training_inputs(x, y)?;
let n_samples = x.shape()[0];
if batch_size == 0 {
return Err(Error::invalid_parameter(
"batch_size",
"must be greater than 0",
));
}
if batch_size > n_samples {
return Err(Error::invalid_parameter(
"batch_size",
format!(
"({}) cannot be larger than dataset size ({})",
batch_size, n_samples
),
));
}
let create_batch_tensors =
|x: &Tensor, y: &Tensor, indices: &[usize]| -> Result<(Tensor, Tensor), Error> {
Ok((x.select(Axis(0), indices), y.select(Axis(0), indices)))
};
let mut indices: Vec<usize> = (0..n_samples).collect();
let mut shuffle_rng = crate::random::make_rng(self.seed);
#[cfg(feature = "show_progress")]
let total_batches = n_samples.div_ceil(batch_size);
#[cfg(feature = "show_progress")]
let total_iterations = epochs as u64 * total_batches as u64;
#[cfg(feature = "show_progress")]
let progress_bar = crate::create_progress_bar(
total_iterations,
"[{elapsed_precise}] {bar:40} {pos}/{len} | Epoch {msg}",
);
let mut loss = Vec::new();
for epoch in 0..epochs {
indices.shuffle(&mut shuffle_rng);
let (mut weighted_loss, mut samples_seen) = (0.0_f64, 0_usize);
for batch_indices in indices.chunks(batch_size) {
let (batch_x, batch_y) = create_batch_tensors(x, y, batch_indices)?;
let batch_loss = self.train_batch(&batch_x, &batch_y)?;
weighted_loss += batch_loss as f64 * batch_indices.len() as f64;
samples_seen += batch_indices.len();
#[cfg(feature = "show_progress")]
{
progress_bar.set_message(format!(
"{}/{} | Avg Loss: {:.6}",
epoch + 1,
epochs,
weighted_loss / samples_seen as f64
));
progress_bar.inc(1);
}
}
loss.push((weighted_loss / samples_seen as f64) as f32);
#[cfg(not(feature = "show_progress"))]
let _ = epoch;
}
#[cfg(feature = "show_progress")]
progress_bar.finish_with_message("Training completed");
Ok(History { loss })
}
pub fn evaluate(&self, x: &Tensor, y: &Tensor) -> Result<f32, Error> {
self.validate_evaluation_inputs(x, y)?;
let predictions = self.predict(x)?;
self.loss.as_ref().unwrap().compute_loss(y, &predictions)
}
pub fn predict(&self, x: &Tensor) -> Result<Tensor, Error> {
if x.is_empty() {
return Err(Error::empty_input("input tensor"));
}
let mut ctx = Ctx::inference();
let mut output: Option<Tensor> = None;
for (scope, layer) in self.layers.iter().enumerate() {
ctx.set_owner(scope);
let input = output.as_ref().unwrap_or(x);
let next = layer.forward_many(&[input], &mut ctx)?;
output = Some(next);
}
output.ok_or(Error::NeuralNetwork(NnError::EmptyModel))
}
pub fn summary(&self) {
let col1_width = 33;
let col2_width = 24;
let col3_width = 15;
let mut output = String::new();
output.push_str("Model: \"sequential\"\n");
output.push_str(&format!(
"┏{}┳{}┳{}┓\n",
"━".repeat(col1_width),
"━".repeat(col2_width),
"━".repeat(col3_width)
));
output.push_str(&format!(
"┃ {:<31} ┃ {:<22} ┃ {:>13} ┃\n",
"Layer (type)", "Output Shape", "Param #"
));
output.push_str(&format!(
"┡{}╇{}╇{}┩\n",
"━".repeat(col1_width),
"━".repeat(col2_width),
"━".repeat(col3_width)
));
let mut total_params: usize = 0;
let mut trainable_param_count: usize = 0;
let mut non_trainable_param_count: usize = 0;
let mut type_counts: AHashMap<&str, usize> = AHashMap::new();
for (index, layer) in self.layers.iter().enumerate() {
let layer_type = layer.layer_type();
let count = type_counts.entry(layer_type).or_insert(0);
let layer_name = if *count == 0 {
layer_type.to_lowercase()
} else {
format!("{}_{}", layer_type.to_lowercase(), count)
};
*count += 1;
let out_shape =
match layer.compute_output_shape_many(&[self.input_shapes[index].clone()]) {
Ok(shape) => shape.to_string(),
Err(_) => "Unknown".to_string(),
};
let counts = total_param_count(&**layer);
trainable_param_count += counts.trainable;
non_trainable_param_count += counts.non_trainable;
total_params += counts.total();
let param_count_num = counts.total();
output.push_str(&format!(
"│ {:<31} │ {:<22} │ {:>13} │\n",
format!("{} ({})", layer_name, layer_type),
out_shape,
param_count_num
));
}
output.push_str(&format!(
"└{}┴{}┴{}┘\n",
"─".repeat(col1_width),
"─".repeat(col2_width),
"─".repeat(col3_width)
));
output.push_str(&format!(
" Total params: {} ({} B)\n",
total_params,
total_params * 4
)); output.push_str(&format!(
" Trainable params: {} ({} B)\n",
trainable_param_count,
trainable_param_count * 4
));
output.push_str(&format!(
" Non-trainable params: {} ({} B)",
non_trainable_param_count,
non_trainable_param_count * 4
));
println!("{}", output);
}
pub fn input_shape(&self) -> &Shape {
&self.input_shapes[0]
}
pub fn output_shape(&self) -> Result<Shape, Error> {
let last = self.layers.len() - 1;
self.layers[last].compute_output_shape_many(&[self.input_shapes[last].clone()])
}
pub fn weight_paths(&self) -> Vec<String> {
model_weight_paths(&self.layers)
}
pub fn weight(&self, path: &str) -> Option<ArrayViewD<'_, f32>> {
find_weight(&self.layers, path)
}
pub fn save_to_path(
&self,
path: impl AsRef<std::path::Path>,
) -> crate::error::RustymlResult<()> {
let bytes = postcard::to_allocvec(&capture(&self.layers))?;
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
writer.write_all(&bytes)?;
writer.flush()?;
Ok(())
}
pub fn load_from_path(
&mut self,
path: impl AsRef<std::path::Path>,
) -> crate::error::RustymlResult<()> {
let file = read_checkpoint(path)?;
apply(&mut self.layers, &file)?;
Ok(())
}
pub fn load_partial_from_path(
&mut self,
path: impl AsRef<std::path::Path>,
) -> crate::error::RustymlResult<LoadReport> {
let file = read_checkpoint(path)?;
Ok(apply_partial(&mut self.layers, &file))
}
}
pub(crate) fn read_checkpoint(
path: impl AsRef<std::path::Path>,
) -> crate::error::RustymlResult<ModelCheckpoint<'static>> {
let bytes = std::fs::read(path)?;
let (magic, format_version): (u32, u32) = postcard::take_from_bytes(&bytes)
.map(|(header, _rest)| header)
.map_err(|_| {
Error::Io(IoError::UnsupportedModelFormat(
"file is too short to contain a model header".to_string(),
))
})?;
if magic != MODEL_MAGIC {
return Err(Error::Io(IoError::UnsupportedModelFormat(format!(
"not a RustyML model file: expected magic {MODEL_MAGIC:#010x}, found {magic:#010x} \
(a model saved before the format carried a header must be re-saved)"
))));
}
if format_version != MODEL_FORMAT_VERSION {
return Err(Error::Io(IoError::UnsupportedModelFormat(format!(
"model file is format version {format_version}, and this build reads version \
{MODEL_FORMAT_VERSION}; re-save the model with this version of RustyML"
))));
}
Ok(postcard::from_bytes(&bytes)?)
}
#[cfg(test)]
mod tests {
use super::Sequential;
#[test]
fn a_built_model_is_send_and_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<Sequential>();
assert_sync::<Sequential>();
}
}