diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 5404413..fef7ca6 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,6 +1,9 @@ name: CI-CD -on: [push, pull_request] +on: + push: + branches: [main] + pull_request: jobs: test: diff --git a/Cargo.lock b/Cargo.lock index 656ece5..4ff8f86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -41,9 +50,9 @@ checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "genetic-rs" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec3a578daff9a0d1302fd5ffb32d0f5e2c11c1f62ce921f519709d1e5cc8280" +checksum = "694916cca5538e02a6f04a0fcb5deef9297b460c697520b9f88ea4b9c0a1a09c" dependencies = [ "rand", "rayon", @@ -69,11 +78,14 @@ checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "neat" -version = "0.1.0" +version = "0.2.0" dependencies = [ + "bincode", "genetic-rs", "rand", "rayon", + "serde", + "serde-big-array", ] [[package]] @@ -82,6 +94,24 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + [[package]] name = "rand" version = "0.8.5" @@ -138,6 +168,52 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a8614ee435691de62bcffcf4a66d91b3594bf1428a5722e79103249a095690" +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 67f215f..fd2c603 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "neat" description = "Crate for working with NEAT in rust" -version = "0.1.0" +version = "0.2.0" edition = "2021" authors = ["Inflectrix"] repository = "https://github.com/inflectrix/neat" @@ -11,6 +11,10 @@ keywords = ["genetic", "machine-learning", "ai", "algorithm", "evolution"] categories = ["algorithms", "science", "simulation"] license = "MIT" +[package.metadata.docs.rs] +features = ["serde"] +rustdoc-args = ["--cfg", "docsrs"] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] @@ -18,9 +22,15 @@ default = ["max-index"] #crossover = ["genetic-rs/crossover"] rayon = ["genetic-rs/rayon", "dep:rayon"] max-index = [] +serde = ["dep:serde", "dep:serde-big-array"] [dependencies] -genetic-rs = "0.2.1" +genetic-rs = "0.3" rand = "0.8.5" -rayon = { version = "1.8.1", optional = true } \ No newline at end of file +rayon = { version = "1.8.1", optional = true } +serde = { version = "1.0.197", features = ["derive"], optional = true } +serde-big-array = { version = "0.5.1", optional = true } + +[dev-dependencies] +bincode = "1.3.3" diff --git a/examples/basic.rs b/examples/basic.rs index 57004ed..aa67020 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -15,7 +15,7 @@ impl RandomlyMutable for AgentDNA { impl Prunable for AgentDNA {} impl DivisionReproduction for AgentDNA { - fn spawn_child(&self, rng: &mut impl Rng) -> Self { + fn divide(&self, rng: &mut impl Rng) -> Self { let mut child = self.clone(); child.mutate(self.network.mutation_rate, rng); child @@ -112,7 +112,7 @@ fn main() { sim.next_generation(); } - let fits: Vec<_> = sim.entities.iter().map(fitness).collect(); + let fits: Vec<_> = sim.genomes.iter().map(fitness).collect(); let maxfit = fits .iter() @@ -130,7 +130,7 @@ fn main() { sim.next_generation(); } - let fits: Vec<_> = sim.entities.iter().map(fitness).collect(); + let fits: Vec<_> = sim.genomes.iter().map(fitness).collect(); let maxfit = fits .iter() diff --git a/src/lib.rs b/src/lib.rs index 72bdab4..7bdd42d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,11 +2,13 @@ //! ### Feature Roadmap: //! - [x] base (single-core) crate //! - [x] rayon +//! - [x] serde //! - [ ] crossover //! //! You can get started by looking at [genetic-rs docs](https://docs.rs/genetic-rs) and checking the examples for this crate. #![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] /// A module containing the [`NeuralNetworkTopology`] struct. This is what you want to use in the DNA of your agent, as it is the thing that goes through nextgens and suppors mutation. pub mod topology; @@ -18,3 +20,6 @@ pub mod runnable; pub use genetic_rs::prelude::*; pub use runnable::*; pub use topology::*; + +#[cfg(feature = "serde")] +pub use nnt_serde::*; diff --git a/src/runnable.rs b/src/runnable.rs index efcd3b2..53d69e0 100644 --- a/src/runnable.rs +++ b/src/runnable.rs @@ -8,7 +8,7 @@ use rayon::prelude::*; #[cfg(feature = "rayon")] use std::sync::{Arc, RwLock}; -/// A runnable, stated Neural Network generated from a [NeuralNetworkToplogy]. Use [`NeuralNetwork::from`] to go from stateles to runnable. +/// A runnable, stated Neural Network generated from a [NeuralNetworkTopology]. Use [`NeuralNetwork::from`] to go from stateles to runnable. /// Because this has state, you need to run [`NeuralNetwork::flush_state`] between [`NeuralNetwork::predict`] calls. #[derive(Debug)] #[cfg(not(feature = "rayon"))] @@ -82,7 +82,7 @@ impl NeuralNetwork { n.state.value += self.process_neuron(l) * w; } - n.sigmoid(); + n.activate(); n.state.value } @@ -112,7 +112,7 @@ impl NeuralNetwork { let mut nw = n.write().unwrap(); nw.state.value += val; - nw.sigmoid(); + nw.activate(); nw.state.value } @@ -240,6 +240,9 @@ pub struct Neuron { /// The current state of the neuron. pub state: NeuronState, + + /// The neuron's activation function + pub activation: ActivationFn, } impl Neuron { @@ -248,9 +251,9 @@ impl Neuron { self.state.value = self.bias; } - /// Applies the sigoid activation function to the state's current value. - pub fn sigmoid(&mut self) { - self.state.value = 1. / (1. + std::f32::consts::E.powf(-self.state.value)) + /// Applies the activation function to the neuron + pub fn activate(&mut self) { + self.state.value = (self.activation.func)(self.state.value); } } @@ -263,6 +266,7 @@ impl From<&NeuronTopology> for Neuron { value: value.bias, ..Default::default() }, + activation: value.activation.clone(), } } } diff --git a/src/topology.rs b/src/topology.rs index 1b9fd65..ef7df33 100644 --- a/src/topology.rs +++ b/src/topology.rs @@ -1,8 +1,107 @@ -use std::sync::{Arc, RwLock}; +use std::{ + collections::HashSet, + fmt, + sync::{Arc, RwLock}, +}; use genetic_rs::prelude::*; use rand::prelude::*; +#[cfg(feature = "serde")] +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Contains useful structs for serializing/deserializing a [`NeuronTopology`] +#[cfg_attr(docsrs, doc(cfg(feature = "serde")))] +#[cfg(feature = "serde")] +pub mod nnt_serde { + use super::*; + use serde::{Deserialize, Serialize}; + use serde_big_array::BigArray; + + /// A serializable wrapper for [`NeuronTopology`]. See [`NNTSerde::from`] for conversion. + #[derive(Serialize, Deserialize)] + pub struct NNTSerde { + #[serde(with = "BigArray")] + pub(crate) input_layer: [NeuronTopology; I], + + pub(crate) hidden_layers: Vec, + + #[serde(with = "BigArray")] + pub(crate) output_layer: [NeuronTopology; O], + + pub(crate) mutation_rate: f32, + pub(crate) mutation_passes: usize, + } + + impl From<&NeuralNetworkTopology> for NNTSerde { + fn from(value: &NeuralNetworkTopology) -> Self { + let input_layer = value + .input_layer + .iter() + .map(|n| n.read().unwrap().clone()) + .collect::>() + .try_into() + .unwrap(); + + let hidden_layers = value + .hidden_layers + .iter() + .map(|n| n.read().unwrap().clone()) + .collect(); + + let output_layer = value + .output_layer + .iter() + .map(|n| n.read().unwrap().clone()) + .collect::>() + .try_into() + .unwrap(); + + Self { + input_layer, + hidden_layers, + output_layer, + mutation_rate: value.mutation_rate, + mutation_passes: value.mutation_passes, + } + } + } + + #[cfg(test)] + #[test] + fn serde() { + let mut rng = rand::thread_rng(); + let nnt = NeuralNetworkTopology::<10, 10>::new(0.1, 3, &mut rng); + let nnts = NNTSerde::from(&nnt); + + let encoded = bincode::serialize(&nnts).unwrap(); + + if let Some(_) = option_env!("TEST_CREATEFILE") { + std::fs::write("serde-test.nn", &encoded).unwrap(); + } + + let decoded: NNTSerde<10, 10> = bincode::deserialize(&encoded).unwrap(); + let nnt2: NeuralNetworkTopology<10, 10> = decoded.into(); + + dbg!(nnt, nnt2); + } +} + +/// Creates an [`ActivationFn`] object from a function +#[macro_export] +macro_rules! activation_fn { + ($F: path) => { + ActivationFn { + func: Arc::new($F), + name: String::from(stringify!($F)), + } + }; + + {$($F: path),*} => { + [$(activation_fn!($F)),*] + }; +} + /// A stateless neural network topology. /// This is the struct you want to use in your agent's inheritance. /// See [`NeuralNetwork::from`][crate::NeuralNetwork::from] for how to convert this to a runnable neural network. @@ -28,7 +127,13 @@ impl NeuralNetworkTopology { /// Creates a new [`NeuralNetworkTopology`]. pub fn new(mutation_rate: f32, mutation_passes: usize, rng: &mut impl Rng) -> Self { let input_layer: [Arc>; I] = (0..I) - .map(|_| Arc::new(RwLock::new(NeuronTopology::new(vec![], rng)))) + .map(|_| { + Arc::new(RwLock::new(NeuronTopology::new_with_activation( + vec![], + activation_fn!(linear_activation), + rng, + ))) + }) .collect::>() .try_into() .unwrap(); @@ -51,7 +156,11 @@ impl NeuralNetworkTopology { }) .collect(); - output_layer.push(Arc::new(RwLock::new(NeuronTopology::new(input, rng)))); + output_layer.push(Arc::new(RwLock::new(NeuronTopology::new_with_activation( + input, + activation_fn!(sigmoid), + rng, + )))); } let output_layer = output_layer.try_into().unwrap(); @@ -65,17 +174,61 @@ impl NeuralNetworkTopology { } } - fn is_connection_cyclic(&self, loc1: NeuronLocation, loc2: NeuronLocation) -> bool { - if loc1 == loc2 { + /// Creates a new connection between the neurons. + /// If the connection is cyclic, it does not add a connection and returns false. + /// Otherwise, it returns true. + pub fn add_connection( + &mut self, + from: NeuronLocation, + to: NeuronLocation, + weight: f32, + ) -> bool { + if self.is_connection_cyclic(from, to) { + return false; + } + + // Add the connection since it is not cyclic + self.get_neuron(to) + .write() + .unwrap() + .inputs + .push((from, weight)); + + true + } + + fn is_connection_cyclic(&self, from: NeuronLocation, to: NeuronLocation) -> bool { + if to.is_input() || from.is_output() { return true; } - for &(n, _w) in &self.get_neuron(loc1).read().unwrap().inputs { - if self.is_connection_cyclic(n, loc2) { + let mut visited = HashSet::new(); + self.dfs(from, to, &mut visited) + } + + // TODO rayon implementation + fn dfs( + &self, + current: NeuronLocation, + target: NeuronLocation, + visited: &mut HashSet, + ) -> bool { + if current == target { + return true; + } + + visited.insert(current); + + let n = self.get_neuron(current); + let nr = n.read().unwrap(); + + for &(input, _) in &nr.inputs { + if !visited.contains(&input) && self.dfs(input, target, visited) { return true; } } + visited.remove(¤t); false } @@ -96,11 +249,7 @@ impl NeuralNetworkTopology { let i = rng.gen_range(0..self.input_layer.len()); (self.input_layer[i].clone(), NeuronLocation::Input(i)) } - 1 => { - if self.hidden_layers.is_empty() { - return self.rand_neuron(rng); - } - + 1 if !self.hidden_layers.is_empty() => { let i = rng.gen_range(0..self.hidden_layers.len()); (self.hidden_layers[i].clone(), NeuronLocation::Hidden(i)) } @@ -110,6 +259,64 @@ impl NeuralNetworkTopology { } } } + + fn delete_neuron(&mut self, loc: NeuronLocation) -> NeuronTopology { + if !loc.is_hidden() { + panic!("Invalid neuron deletion"); + } + + let index = loc.unwrap(); + let neuron = Arc::into_inner(self.hidden_layers.remove(index)).unwrap(); + + for n in &self.hidden_layers { + let mut nw = n.write().unwrap(); + + nw.inputs = nw + .inputs + .iter() + .filter_map(|&(input_loc, w)| { + if !input_loc.is_hidden() { + return Some((input_loc, w)); + } + + if input_loc.unwrap() == index { + return None; + } + + if input_loc.unwrap() > index { + return Some((NeuronLocation::Hidden(input_loc.unwrap() - 1), w)); + } + + Some((input_loc, w)) + }) + .collect(); + } + + for n2 in &self.output_layer { + let mut nw = n2.write().unwrap(); + nw.inputs = nw + .inputs + .iter() + .filter_map(|&(input_loc, w)| { + if !input_loc.is_hidden() { + return Some((input_loc, w)); + } + + if input_loc.unwrap() == index { + return None; + } + + if input_loc.unwrap() > index { + return Some((NeuronLocation::Hidden(input_loc.unwrap() - 1), w)); + } + + Some((input_loc, w)) + }) + .collect(); + } + + neuron.into_inner().unwrap() + } } // need to do all this manually because Arcs are cringe @@ -163,27 +370,35 @@ impl RandomlyMutable for NeuralNetworkTopology() <= rate { // add a connection - let (mut n1, mut loc1) = self.rand_neuron(rng); + let (_, mut loc1) = self.rand_neuron(rng); + let (_, mut loc2) = self.rand_neuron(rng); - while n1.read().unwrap().inputs.is_empty() { - (n1, loc1) = self.rand_neuron(rng); + while loc1.is_output() || !self.add_connection(loc1, loc2, rng.gen::()) { + (_, loc1) = self.rand_neuron(rng); + (_, loc2) = self.rand_neuron(rng); } + } - let (mut n2, mut loc2) = self.rand_neuron(rng); + if rng.gen::() <= rate && !self.hidden_layers.is_empty() { + // remove a neuron + let (_, mut loc) = self.rand_neuron(rng); - while self.is_connection_cyclic(loc1, loc2) { - (n2, loc2) = self.rand_neuron(rng); + while !loc.is_hidden() { + (_, loc) = self.rand_neuron(rng); } - n2.write().unwrap().inputs.push((loc1, rng.gen())); + // delete the neuron + self.delete_neuron(loc); } if rng.gen::() <= rate { @@ -197,51 +412,209 @@ impl RandomlyMutable for NeuralNetworkTopology() * rate; + *w += rng.gen_range(-1.0..1.0) * rate; + } + + if rng.gen::() <= rate { + // mutate bias + let (n, _) = self.rand_neuron(rng); + let mut n = n.write().unwrap(); + + n.bias += rng.gen_range(-1.0..1.0) * rate; + } + + if rng.gen::() <= rate && !self.hidden_layers.is_empty() { + // mutate activation function + let activations = activation_fn! { + sigmoid, + relu, + f32::tanh + }; + + let (mut n, mut loc) = self.rand_neuron(rng); + + while !loc.is_hidden() { + (n, loc) = self.rand_neuron(rng); + } + + let mut nw = n.write().unwrap(); + + // should probably not clone, but its not a huge efficiency issue anyways + nw.activation = activations[rng.gen_range(0..activations.len())].clone(); } } } } impl DivisionReproduction for NeuralNetworkTopology { - fn spawn_child(&self, rng: &mut impl rand::Rng) -> Self { + fn divide(&self, rng: &mut impl rand::Rng) -> Self { let mut child = self.clone(); child.mutate(self.mutation_rate, rng); child } } -#[cfg(feature = "crossover")] -impl CrossoverReproduction for NeuralNetworkTopology { - fn spawn_child(&self, other: &Self, rng: &mut impl Rng) -> Self { - todo!(); +#[cfg(feature = "serde")] +impl From> + for NeuralNetworkTopology +{ + fn from(value: nnt_serde::NNTSerde) -> Self { + let input_layer = value + .input_layer + .into_iter() + .map(|n| Arc::new(RwLock::new(n))) + .collect::>() + .try_into() + .unwrap(); + + let hidden_layers = value + .hidden_layers + .into_iter() + .map(|n| Arc::new(RwLock::new(n))) + .collect(); + + let output_layer = value + .output_layer + .into_iter() + .map(|n| Arc::new(RwLock::new(n))) + .collect::>() + .try_into() + .unwrap(); + + NeuralNetworkTopology { + input_layer, + hidden_layers, + output_layer, + mutation_rate: value.mutation_rate, + mutation_passes: value.mutation_passes, + } + } +} + +/// An activation function object that implements [`fmt::Debug`] and is [`Send`] +#[derive(Clone)] +pub struct ActivationFn { + /// The actual activation function. + pub func: Arc f32 + Send + Sync + 'static>, + name: String, +} + +impl fmt::Debug for ActivationFn { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}", self.name) + } +} + +#[cfg(feature = "serde")] +impl Serialize for ActivationFn { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.name) + } +} + +#[cfg(feature = "serde")] +impl<'a> Deserialize<'a> for ActivationFn { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'a>, + { + let name = String::deserialize(deserializer)?; + let activations = activation_fn! { + sigmoid, + relu, + f32::tanh, + linear_activation + }; + + for a in activations { + if a.name == name { + return Ok(a); + } + } + + // eventually will make an activation fn registry of sorts. + panic!("Custom activation functions currently not supported.") // TODO return error instead of raw panic } } +/// The sigmoid activation function. +pub fn sigmoid(n: f32) -> f32 { + 1. / (1. + std::f32::consts::E.powf(-n)) +} + +/// The ReLU activation function. +pub fn relu(n: f32) -> f32 { + n.max(0.) +} + +/// Activation function that does nothing. +pub fn linear_activation(n: f32) -> f32 { + n +} + /// A stateless version of [`Neuron`][crate::Neuron]. #[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct NeuronTopology { /// The input locations and weights. pub inputs: Vec<(NeuronLocation, f32)>, /// The neuron's bias. pub bias: f32, + + /// The neuron's activation function. + pub activation: ActivationFn, } impl NeuronTopology { /// Creates a new neuron with the given input locations. pub fn new(inputs: Vec, rng: &mut impl Rng) -> Self { - let inputs = inputs.into_iter().map(|i| (i, rng.gen::())).collect(); + let activations = activation_fn! { + sigmoid, + relu, + f32::tanh + }; + + Self::new_with_activations(inputs, activations, rng) + } + + /// Takes a collection of activation functions and chooses a random one to use. + pub fn new_with_activations( + inputs: Vec, + activations: impl IntoIterator, + rng: &mut impl Rng, + ) -> Self { + let mut activations: Vec<_> = activations.into_iter().collect(); + + Self::new_with_activation( + inputs, + activations.remove(rng.gen_range(0..activations.len())), + rng, + ) + } + + /// Creates a neuron with the activation. + pub fn new_with_activation( + inputs: Vec, + activation: ActivationFn, + rng: &mut impl Rng, + ) -> Self { + let inputs = inputs + .into_iter() + .map(|i| (i, rng.gen_range(-1.0..1.0))) + .collect(); Self { inputs, bias: rng.gen(), + activation, } } } /// A pseudo-pointer of sorts used to make structural conversions very fast and easy to write. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Hash, Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub enum NeuronLocation { /// Points to a neuron in the input layer at contained index. Input(usize),