use crate::error::Error;
use crate::neural_network::NnError;
use crate::neural_network::Shape;
use crate::neural_network::Tensor;
use crate::neural_network::ctx::{Ctx, LayerId};
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, apply, apply_partial, capture};
use crate::neural_network::sequential::{History, read_checkpoint};
use crate::neural_network::traits::{
Layer, Loss, Optimizer, check_addresses, check_every_gradient_is_claimed,
};
use ahash::AHashMap;
use ndarray::{ArrayViewD, Axis};
use ndarray_rand::rand::seq::SliceRandom;
use std::fs::File;
use std::io::{BufWriter, Write};
pub type NodeId = usize;
enum Node {
Input {
shape: Shape,
},
Call {
layer: LayerId,
inputs: Vec<NodeId>,
},
}
impl Node {
fn inputs(&self) -> &[NodeId] {
match self {
Self::Input { .. } => &[],
Self::Call { inputs, .. } => inputs,
}
}
}
#[derive(Default)]
pub struct GraphBuilder {
layers: Vec<Box<dyn Layer>>,
nodes: Vec<Node>,
seed: Option<u64>,
}
impl GraphBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn new_with_seed(seed: u64) -> Self {
Self {
seed: Some(seed),
..Self::default()
}
}
pub fn input(&mut self, shape: Shape) -> NodeId {
self.nodes.push(Node::Input { shape });
self.nodes.len() - 1
}
pub fn layer<L: 'static + Layer>(&mut self, layer: L) -> LayerId {
self.layers.push(Box::new(layer));
self.layers.len() - 1
}
pub fn apply(&mut self, layer: LayerId, inputs: &[NodeId]) -> NodeId {
self.nodes.push(Node::Call {
layer,
inputs: inputs.to_vec(),
});
self.nodes.len() - 1
}
pub fn add<L: 'static + Layer>(&mut self, layer: L, inputs: &[NodeId]) -> NodeId {
let id = self.layer(layer);
self.apply(id, inputs)
}
pub fn build(mut self, outputs: &[NodeId]) -> Result<Graph, Error> {
if self.nodes.is_empty() {
return Err(Error::NeuralNetwork(NnError::EmptyModel));
}
if outputs.is_empty() {
return Err(Error::invalid_input(
"a graph model needs at least 1 output node",
));
}
for &output in outputs {
if output >= self.nodes.len() {
return Err(Error::invalid_input(format!(
"node {output} is named as an output, and the graph holds {} node(s)",
self.nodes.len()
)));
}
}
for (id, node) in self.nodes.iter().enumerate() {
for &input in node.inputs() {
if input >= self.nodes.len() {
return Err(Error::invalid_input(format!(
"node {id} reads node {input}, and the graph holds {} node(s)",
self.nodes.len()
)));
}
if input >= id {
return Err(Error::invalid_input(format!(
"node {id} reads node {input}, which the graph holds at a later \
position. A node reads only the nodes that already exist"
)));
}
}
}
let inputs: Vec<NodeId> = self
.nodes
.iter()
.enumerate()
.filter(|(_, node)| matches!(node, Node::Input { .. }))
.map(|(id, _)| id)
.collect();
if inputs.is_empty() {
return Err(Error::invalid_input(
"a graph model needs at least 1 input node",
));
}
let mut reaches_output = vec![false; self.nodes.len()];
for &output in outputs {
reaches_output[output] = true;
}
for id in (0..self.nodes.len()).rev() {
if reaches_output[id] {
for &input in self.nodes[id].inputs() {
reaches_output[input] = true;
}
}
}
if let Some(dead) = reaches_output.iter().position(|reached| !reached) {
return Err(Error::invalid_input(format!(
"node {dead} reaches no output of the model. Name it as an output, or leave it \
out of the graph"
)));
}
let order: Vec<NodeId> = (0..self.nodes.len()).collect();
let mut inlet_of: Vec<Option<NodeId>> = vec![None; self.nodes.len()];
for (position, &id) in inputs.iter().enumerate() {
inlet_of[id] = Some(position);
}
let mut node_shapes: Vec<Shape> = vec![Shape::new(Vec::new()); self.nodes.len()];
for &id in &order {
match &self.nodes[id] {
Node::Input { shape } => node_shapes[id] = shape.clone(),
Node::Call { layer, inputs } => {
let (layer, inputs) = (*layer, inputs.clone());
let shapes: Vec<Shape> = inputs
.iter()
.map(|&from| node_shapes[from].clone())
.collect();
let held = &mut self.layers[layer];
let layer_type = held.layer_type().to_string();
held.arity()
.check(&layer_type, shapes.len())
.map_err(|source| node_refusal(id, layer, &layer_type, source))?;
held.build_many(&shapes)
.map_err(|source| node_refusal(id, layer, &layer_type, source))?;
node_shapes[id] = held
.compute_output_shape_many(&shapes)
.map_err(|source| node_refusal(id, layer, &layer_type, source))?;
}
}
}
for (index, layer) in self.layers.iter_mut().enumerate() {
check_addresses(index, &mut **layer)?;
}
Ok(Graph {
layers: self.layers,
nodes: self.nodes,
order,
inputs,
outputs: outputs.to_vec(),
node_shapes,
inlet_of,
optimizer: None,
losses: Vec::new(),
loss_weights: Vec::new(),
seed: self.seed,
})
}
}
#[cold]
fn node_refusal(node: NodeId, layer: LayerId, layer_type: &str, source: Error) -> Error {
Error::invalid_input(format!(
"node {node} (layer {layer}, `{layer_type}`) refused what reaches it: {source}"
))
}
pub struct Graph {
layers: Vec<Box<dyn Layer>>,
nodes: Vec<Node>,
order: Vec<NodeId>,
inputs: Vec<NodeId>,
outputs: Vec<NodeId>,
node_shapes: Vec<Shape>,
inlet_of: Vec<Option<NodeId>>,
optimizer: Option<Box<dyn Optimizer>>,
losses: Vec<Box<dyn Loss>>,
loss_weights: Vec<f32>,
seed: Option<u64>,
}
impl Graph {
pub fn compile_many<O: 'static + Optimizer>(
&mut self,
optimizer: O,
losses: Vec<Box<dyn Loss>>,
) -> Result<&mut Self, Error> {
if losses.len() != self.outputs.len() {
return Err(Error::invalid_input(format!(
"the model has {} output(s) and the compile gives {} loss(es). A graph model \
takes 1 loss per output",
self.outputs.len(),
losses.len()
)));
}
self.loss_weights = vec![1.0; losses.len()];
self.losses = losses;
self.optimizer = Some(Box::new(optimizer));
Ok(self)
}
pub fn compile<O: 'static + Optimizer, L: 'static + Loss + Clone>(
&mut self,
optimizer: O,
loss: L,
) -> &mut Self {
let losses: Vec<Box<dyn Loss>> = self
.outputs
.iter()
.map(|_| Box::new(loss.clone()) as Box<dyn Loss>)
.collect();
self.compile_many(optimizer, losses)
.expect("the loss list is built from the output list, so the 2 counts agree")
}
pub fn with_loss_weights(&mut self, weights: &[f32]) -> Result<&mut Self, Error> {
if weights.len() != self.outputs.len() {
return Err(Error::invalid_input(format!(
"the model has {} output(s) and the call gives {} weight(s)",
self.outputs.len(),
weights.len()
)));
}
if let Some(bad) = weights.iter().find(|weight| !weight.is_finite()) {
return Err(Error::invalid_parameter(
"loss weight",
format!("must be finite, got {bad}"),
));
}
self.loss_weights = weights.to_vec();
Ok(self)
}
pub fn input_shapes(&self) -> Vec<&Shape> {
self.inputs
.iter()
.map(|&id| &self.node_shapes[id])
.collect()
}
pub fn output_shapes(&self) -> Vec<&Shape> {
self.outputs
.iter()
.map(|&id| &self.node_shapes[id])
.collect()
}
fn forward_values(&self, xs: &[&Tensor], ctx: &mut Ctx) -> Result<Vec<Option<Tensor>>, Error> {
if xs.len() != self.inputs.len() {
return Err(Error::invalid_input(format!(
"the model has {} inlet(s) and the call gives {} tensor(s)",
self.inputs.len(),
xs.len()
)));
}
for tensor in xs {
if tensor.is_empty() {
return Err(Error::empty_input("input tensor"));
}
}
let mut computed: Vec<Option<Tensor>> = vec![None; self.nodes.len()];
for &id in &self.order {
let Node::Call { layer, inputs } = &self.nodes[id] else {
continue;
};
let taken: Vec<&Tensor> = inputs
.iter()
.map(|from| match self.inlet_of[*from] {
Some(position) => xs[position],
None => computed[*from]
.as_ref()
.expect("a node reads only earlier nodes, which already ran"),
})
.collect();
ctx.set_position(*layer, id);
computed[id] = Some(self.layers[*layer].forward_many(&taken, ctx)?);
}
Ok(computed)
}
fn value_of<'v>(
&self,
node: NodeId,
xs: &[&'v Tensor],
computed: &'v [Option<Tensor>],
) -> &'v Tensor {
match self.inlet_of[node] {
Some(position) => xs[position],
None => computed[node]
.as_ref()
.expect("every node of a built graph runs"),
}
}
fn apply_state(&mut self, ctx: &mut Ctx) -> Result<(), Error> {
for (id, layer) in self.layers.iter_mut().enumerate() {
apply_state_tree(&mut **layer, &LayerPath::root(id), ctx);
check_state_taken(ctx, id)?;
}
Ok(())
}
pub fn train_batch(&mut self, xs: &[&Tensor], ys: &[&Tensor]) -> Result<f32, Error> {
self.check_compiled(true)?;
self.check_targets(ys)?;
self.sample_count(xs, ys)?;
let mut ctx = Ctx::training();
let values = self.forward_values(xs, &mut ctx)?;
check_sublayer_calls(&self.layers, &ctx)?;
self.apply_state(&mut ctx)?;
let mut total = 0.0_f32;
let mut grads: Vec<Option<Tensor>> = vec![None; self.nodes.len()];
for (index, &output) in self.outputs.iter().enumerate() {
let weight = self.loss_weights[index];
let value = self.value_of(output, xs, &values);
total += weight * self.losses[index].compute_loss(ys[index], value)?;
let mut seed = self.losses[index].compute_grad(ys[index], value)?;
if weight != 1.0 {
seed *= weight;
}
accumulate(&mut grads, output, seed)?;
}
if let Some(ref mut optimizer) = self.optimizer {
optimizer.step();
}
for &id in self.order.iter().rev() {
let Node::Call { layer, inputs } = &self.nodes[id] else {
continue;
};
let (layer, inputs) = (*layer, inputs.clone());
let Some(grad) = grads[id].take() else {
continue;
};
ctx.set_position(layer, id);
let upstream = self.layers[layer].backward_many(&grad, &mut ctx)?;
if upstream.len() != inputs.len() {
return Err(Error::computation(format!(
"node {id} (layer {layer}, `{}`) reads {} input(s) and gave back {} \
gradient(s)",
self.layers[layer].layer_type(),
inputs.len(),
upstream.len()
)));
}
for (&from, gradient) in inputs.iter().zip(upstream) {
accumulate(&mut grads, from, gradient)?;
}
}
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(|optimizer| optimizer.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(total)
}
pub fn fit(&mut self, xs: &[&Tensor], ys: &[&Tensor], epochs: u32) -> Result<History, Error> {
self.check_compiled(true)?;
self.check_targets(ys)?;
let mut loss = Vec::with_capacity(epochs as usize);
for _ in 0..epochs {
loss.push(self.train_batch(xs, ys)?);
}
Ok(History::new(loss))
}
pub fn fit_with_batches(
&mut self,
xs: &[&Tensor],
ys: &[&Tensor],
epochs: u32,
batch_size: usize,
) -> Result<History, Error> {
self.check_compiled(true)?;
self.check_targets(ys)?;
if batch_size == 0 {
return Err(Error::invalid_parameter("batch_size", "must be at least 1"));
}
let samples = self.sample_count(xs, ys)?;
let mut rng = crate::random::make_rng(self.seed);
let mut order: Vec<usize> = (0..samples).collect();
let mut loss = Vec::with_capacity(epochs as usize);
for _ in 0..epochs {
order.shuffle(&mut rng);
let mut total = 0.0_f32;
let mut steps = 0_u32;
for chunk in order.chunks(batch_size) {
let batch_x: Vec<Tensor> = xs.iter().map(|t| gather(t, chunk)).collect();
let batch_y: Vec<Tensor> = ys.iter().map(|t| gather(t, chunk)).collect();
let refs_x: Vec<&Tensor> = batch_x.iter().collect();
let refs_y: Vec<&Tensor> = batch_y.iter().collect();
total += self.train_batch(&refs_x, &refs_y)?;
steps += 1;
}
loss.push(if steps == 0 {
0.0
} else {
total / steps as f32
});
}
Ok(History::new(loss))
}
pub fn predict(&self, xs: &[&Tensor]) -> Result<Vec<Tensor>, Error> {
let mut ctx = Ctx::inference();
let values = self.forward_values(xs, &mut ctx)?;
Ok(self
.outputs
.iter()
.map(|&output| self.value_of(output, xs, &values).clone())
.collect())
}
pub fn evaluate(&self, xs: &[&Tensor], ys: &[&Tensor]) -> Result<f32, Error> {
self.check_compiled(false)?;
self.check_targets(ys)?;
self.sample_count(xs, ys)?;
let predicted = self.predict(xs)?;
let mut total = 0.0_f32;
for (index, value) in predicted.iter().enumerate() {
total +=
self.loss_weights[index] * self.losses[index].compute_loss(ys[index], value)?;
}
Ok(total)
}
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>) -> Result<(), Error> {
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>) -> Result<(), Error> {
let file = read_checkpoint(path)?;
apply(&mut self.layers, &file)
}
pub fn load_partial_from_path(
&mut self,
path: impl AsRef<std::path::Path>,
) -> Result<LoadReport, Error> {
let file = read_checkpoint(path)?;
Ok(apply_partial(&mut self.layers, &file))
}
pub fn summary(&self) {
let mut output = String::from("Model: \"graph\"\n");
output.push_str(&format!(
"{:<6} {:<24} {:<22} {:>10} {}\n",
"Node", "Layer (type)", "Output Shape", "Param #", "Reads"
));
output.push_str(&"-".repeat(88));
output.push('\n');
let mut counted: Vec<bool> = vec![false; self.layers.len()];
let mut total = 0_usize;
let mut trainable = 0_usize;
let mut names: AHashMap<&str, usize> = AHashMap::new();
for &id in &self.order {
let (label, params, reads) = match &self.nodes[id] {
Node::Input { .. } => ("input".to_string(), String::new(), String::new()),
Node::Call { layer, inputs } => {
let kind = self.layers[*layer].layer_type();
let count = names.entry(kind).or_insert(0);
let name = if *count == 0 {
kind.to_lowercase()
} else {
format!("{}_{}", kind.to_lowercase(), count)
};
*count += 1;
let counts = total_param_count(&*self.layers[*layer]);
let shown = if counted[*layer] {
"shared".to_string()
} else {
counted[*layer] = true;
total += counts.trainable + counts.non_trainable;
trainable += counts.trainable;
(counts.trainable + counts.non_trainable).to_string()
};
(format!("{name} ({kind})"), shown, format!("{inputs:?}"))
}
};
output.push_str(&format!(
"{:<6} {:<24} {:<22} {:>10} {}\n",
id,
label,
self.node_shapes[id].to_string(),
params,
reads
));
}
output.push_str(&"-".repeat(88));
output.push('\n');
output.push_str(&format!("Total params: {total}\n"));
output.push_str(&format!("Trainable params: {trainable}\n"));
output.push_str(&format!("Non-trainable params: {}\n", total - trainable));
print!("{output}");
}
fn check_compiled(&self, needs_optimizer: bool) -> Result<(), Error> {
if needs_optimizer && self.optimizer.is_none() {
return Err(Error::NeuralNetwork(NnError::NotCompiled("optimizer")));
}
if self.losses.is_empty() {
return Err(Error::NeuralNetwork(NnError::NotCompiled("loss function")));
}
Ok(())
}
fn check_targets(&self, ys: &[&Tensor]) -> Result<(), Error> {
if ys.len() != self.outputs.len() {
return Err(Error::invalid_input(format!(
"the model has {} output(s) and the call gives {} target(s)",
self.outputs.len(),
ys.len()
)));
}
for tensor in ys {
if tensor.is_empty() {
return Err(Error::empty_input("target tensor"));
}
}
Ok(())
}
fn sample_count(&self, xs: &[&Tensor], ys: &[&Tensor]) -> Result<usize, Error> {
let mut samples: Option<usize> = None;
for tensor in xs.iter().chain(ys.iter()) {
if tensor.ndim() == 0 {
return Err(Error::invalid_input(
"a batch call needs a tensor with a batch axis, got a rank-0 tensor",
));
}
let count = tensor.shape()[0];
match samples {
None => samples = Some(count),
Some(held) if held != count => {
return Err(Error::dimension_mismatch(held, count));
}
Some(_) => {}
}
}
samples.ok_or_else(|| Error::empty_input("input tensor"))
}
}
fn accumulate(grads: &mut [Option<Tensor>], node: NodeId, gradient: Tensor) -> Result<(), Error> {
match &mut grads[node] {
Some(total) => {
if total.shape() != gradient.shape() {
return Err(Error::computation(format!(
"node {node} receives a gradient of shape {:?} and already holds one of \
shape {:?}",
gradient.shape(),
total.shape()
)));
}
*total += &gradient;
}
slot @ None => *slot = Some(gradient),
}
Ok(())
}
fn gather(tensor: &Tensor, rows: &[usize]) -> Tensor {
tensor.select(Axis(0), rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::neural_network::layers::{Activation, BatchNormalization, Dense};
use crate::neural_network::losses::mean_squared_error::MeanSquaredError;
use crate::neural_network::optimizers::SGD;
use crate::neural_network::sequential::SequentialBuilder;
use ndarray::{Array, IxDyn};
fn data(shape: &[usize]) -> Tensor {
let count: usize = shape.iter().product();
let values: Vec<f32> = (0..count)
.map(|i| ((((i * 37) % 101) as f32) - 50.0) / 25.0)
.collect();
Array::from_shape_vec(IxDyn(shape), values).expect("the formula fills the shape")
}
#[test]
fn a_chain_graph_agrees_with_the_sequential_model() {
let x = data(&[6, 4]);
let y = data(&[6, 2]);
let mut chain = SequentialBuilder::new_with_seed(11)
.add(
Dense::new(5, Activation::ReLU)
.unwrap()
.with_random_state(3),
)
.add(BatchNormalization::new(0.8, 1e-5).unwrap())
.add(
Dense::new(2, Activation::Linear)
.unwrap()
.with_random_state(4),
)
.build(&Shape::known(&[6, 4]))
.unwrap();
chain.compile(
SGD::new(0.05, 0.9, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
let mut builder = GraphBuilder::new_with_seed(11);
let input = builder.input(Shape::known(&[6, 4]));
let first = builder.add(
Dense::new(5, Activation::ReLU)
.unwrap()
.with_random_state(3),
&[input],
);
let norm = builder.add(BatchNormalization::new(0.8, 1e-5).unwrap(), &[first]);
let head = builder.add(
Dense::new(2, Activation::Linear)
.unwrap()
.with_random_state(4),
&[norm],
);
let mut graph = builder.build(&[head]).unwrap();
graph.compile(
SGD::new(0.05, 0.9, false, 0.0).unwrap(),
MeanSquaredError::new(),
);
let chain_history = chain.fit(&x, &y, 4).unwrap();
let graph_history = graph.fit(&[&x], &[&y], 4).unwrap();
for (a, b) in chain_history.loss().iter().zip(graph_history.loss()) {
assert_eq!(
a.to_bits(),
b.to_bits(),
"the 2 drivers report a different loss"
);
}
assert_eq!(chain.weight_paths(), graph.weight_paths());
for path in chain.weight_paths() {
let from_chain = chain.weight(&path).unwrap();
let from_graph = graph.weight(&path).unwrap();
for (a, b) in from_chain.iter().zip(from_graph.iter()) {
assert_eq!(a.to_bits(), b.to_bits(), "the array {path} differs");
}
}
let chain_prediction = chain.predict(&x).unwrap();
let graph_prediction = graph.predict(&[&x]).unwrap();
for (a, b) in chain_prediction.iter().zip(graph_prediction[0].iter()) {
assert_eq!(a.to_bits(), b.to_bits(), "the 2 predictions differ");
}
}
#[test]
fn a_node_that_reaches_no_output_is_refused() {
let mut builder = GraphBuilder::new();
let input = builder.input(Shape::known(&[2, 3]));
let live = builder.add(Dense::new(2, Activation::Linear).unwrap(), &[input]);
let _dead = builder.add(Dense::new(2, Activation::Linear).unwrap(), &[input]);
let message = match builder.build(&[live]) {
Ok(_) => panic!("a dead node must be refused"),
Err(error) => error.to_string(),
};
assert!(message.contains("reaches no output"), "{message}");
}
#[test]
fn a_graph_with_no_inlet_is_refused() {
let mut builder = GraphBuilder::new();
let orphan = builder.apply(0, &[]);
assert!(builder.build(&[orphan]).is_err());
}
#[test]
fn a_built_graph_is_send_and_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<Graph>();
assert_sync::<Graph>();
}
#[test]
fn a_shared_layer_holds_one_set_of_paths() {
let mut builder = GraphBuilder::new();
let left = builder.input(Shape::known(&[2, 3]));
let right = builder.input(Shape::known(&[2, 3]));
let tower = builder.layer(Dense::new(4, Activation::Tanh).unwrap());
let a = builder.apply(tower, &[left]);
let _b = builder.apply(tower, &[right]);
let model = builder.build(&[a, _b]).unwrap();
assert_eq!(model.weight_paths(), vec!["0.kernel", "0.bias"]);
assert_eq!(model.output_shapes().len(), 2);
assert_eq!(model.input_shapes().len(), 2);
}
}