diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index fef7ca6..fd849f4 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -2,7 +2,7 @@ name: CI-CD on: push: - branches: [main] + branches: [main, dev] pull_request: jobs: diff --git a/.gitignore b/.gitignore index 1b71596..b2d8069 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target/ -/.vscode/ \ No newline at end of file +/.vscode/ +best-agent.json +fitness-plot.svg \ No newline at end of file diff --git a/CONTRIBUTING b/CONTRIBUTING deleted file mode 100644 index 68c1a8c..0000000 --- a/CONTRIBUTING +++ /dev/null @@ -1,6 +0,0 @@ -Thanks for contributing to this project. - -To get started, check out the [issues page](https://github.com/inflectrix/neat). You can either find a feature/fix from there or start a new issue, then begin implementing it in your own fork of this repo. - -Once you are done making the changes you'd like the make, start a pull request to the [dev](https://github.com/inflectrix/neat/tree/dev) branch. State your changes and request a review. After all branch rules have been satisfied, someone with management permissions on this repository will merge it. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8ede4a4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +Thanks for contributing to this project. + +To get started, check out the [issues page](https://github.com/hypercodec/neat). You can either find a feature/fix from there or start a new issue, then begin implementing it in your own fork of this repo. + +Once you are done making the changes you'd like the make, start a pull request to the [dev](https://github.com/hypercodec/neat/tree/dev) branch. State your changes and request a review. After all branch rules have been satisfied and the pull request has a valid reason, someone with management permissions on this repository will merge it. + +You could also make a draft PR while implementing your features if you want feedback or discussion before finalizing your changes. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index be4d7b8..0a9a53b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,21 +1,18 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] -name = "bincode" -version = "1.3.3" +name = "atomic_float" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" [[package]] name = "cfg-if" @@ -56,9 +53,9 @@ checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "genetic-rs" -version = "0.5.1" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94601f3db2fb341f71a4470134eb1f71d39f54c2fe264122698eda67cd1c91b" +checksum = "a68bb62a836f6ea3261d77cfec4012316e206f53e7d0eab519f5f3630e86001f" dependencies = [ "genetic-rs-common", "genetic-rs-macros", @@ -66,9 +63,9 @@ dependencies = [ [[package]] name = "genetic-rs-common" -version = "0.5.1" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f41b0e3f6ccb66a00e7fc9170d4e02b1ae80c85f03c67b76b067b3637fd314a" +checksum = "3be7aaffd4e4dc82d11819d40794f089c37d02595a401f229ed2877d1a4c401d" dependencies = [ "rand", "rayon", @@ -77,9 +74,9 @@ dependencies = [ [[package]] name = "genetic-rs-macros" -version = "0.5.1" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5ec3b9e69a6836bb0f0c8fa6972e6322e0b49108f7b3ed40769feb452c120a" +checksum = "4e73b1f36ea3e799232e1a3141a2765fa6ee9ed7bb3fed96ccfb3bf272d1832e" dependencies = [ "genetic-rs-common", "proc-macro2", @@ -100,32 +97,38 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "neat" version = "0.5.1" dependencies = [ - "bincode", + "atomic_float", "bitflags", "genetic-rs", "lazy_static", - "rand", "rayon", + "replace_with", "serde", "serde-big-array", "serde_json", @@ -139,9 +142,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "307e3004becf10f5a6e0d59d20f3cd28231b0e0827a96cd3e0ce6d14bc1e4bb3" dependencies = [ "unicode-ident", ] @@ -187,9 +190,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.8.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7237101a77a10773db45d62004a272517633fbcc3df19d96455ede1122e051" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", @@ -213,15 +216,15 @@ checksum = "e3a8614ee435691de62bcffcf4a66d91b3594bf1428a5722e79103249a095690" [[package]] name = "ryu" -version = "1.0.17" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" [[package]] name = "serde" -version = "1.0.197" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" dependencies = [ "serde_derive", ] @@ -237,9 +240,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", @@ -248,20 +251,21 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.114" +version = "1.0.138" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] [[package]] name = "syn" -version = "2.0.51" +version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab617d94515e94ae53b8406c628598680aa0c9587474ecbe58188f7b345d66c" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 91247ad..4b26e0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,9 @@ name = "neat" description = "Crate for working with NEAT in rust" version = "0.5.1" edition = "2021" -authors = ["Inflectrix"] -repository = "https://github.com/inflectrix/neat" -homepage = "https://github.com/inflectrix/neat" +authors = ["HyperCodec"] +repository = "https://github.com/HyperCodec/neat" +homepage = "https://github.com/HyperCodec/neat" readme = "README.md" keywords = ["genetic", "machine-learning", "ai", "algorithm", "evolution"] categories = ["algorithms", "science", "simulation"] @@ -18,22 +18,19 @@ rustdoc-args = ["--cfg", "docsrs"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["max-index"] -crossover = ["genetic-rs/crossover"] -rayon = ["genetic-rs/rayon", "dep:rayon"] -max-index = [] +default = [] serde = ["dep:serde", "dep:serde-big-array"] [dependencies] -bitflags = "2.5.0" -genetic-rs = { version = "0.5.1", features = ["derive"] } -lazy_static = "1.4.0" -rand = "0.8.5" -rayon = { version = "1.8.1", optional = true } -serde = { version = "1.0.197", features = ["derive"], optional = true } +atomic_float = "1.1.0" +bitflags = "2.8.0" +genetic-rs = { version = "0.5.4", features = ["rayon", "derive"] } +lazy_static = "1.5.0" +rayon = "1.10.0" +replace_with = "0.1.7" +serde = { version = "1.0.217", features = ["derive"], optional = true } serde-big-array = { version = "0.5.1", optional = true } [dev-dependencies] -bincode = "1.3.3" -serde_json = "1.0.114" +serde_json = "1.0.138" \ No newline at end of file diff --git a/README.md b/README.md index ad775e2..4e9828b 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,17 @@ # neat -[github](https://github.com/inflectrix/neat) +[github](https://github.com/hypercodec/neat) [crates.io](https://crates.io/crates/neat) [docs.rs](https://docs.rs/neat) Implementation of the NEAT algorithm using `genetic-rs`. ### Features -- rayon - Uses parallelization on the `NeuralNetwork` struct and adds the `rayon` feature to the `genetic-rs` re-export. -- serde - Adds the NNTSerde struct and allows for serialization of `NeuralNetworkTopology` -- crossover - Implements the `CrossoverReproduction` trait on `NeuralNetworkTopology` and adds the `crossover` feature to the `genetic-rs` re-export. +- serde - Implements `Serialize` and `Deserialize` on most of the types in this crate. -*Do you like this repo and want to support it? If so, leave a ⭐* +*Do you like this crate and want to support it? If so, leave a ⭐* -### How To Use -When working with this crate, you'll want to use the `NeuralNetworkTopology` struct in your agent's DNA and -the use `NeuralNetwork::from` when you finally want to test its performance. The `genetic-rs` crate is also re-exported with the rest of this crate. - -Here's an example of how one might use this crate: -```rust -use neat::*; - -#[derive(Clone, RandomlyMutable, DivisionReproduction)] -struct MyAgentDNA { - network: NeuralNetworkTopology<1, 2>, -} - -impl GenerateRandom for MyAgentDNA { - fn gen_random(rng: &mut impl rand::Rng) -> Self { - Self { - network: NeuralNetworkTopology::new(0.01, 3, rng), - } - } -} - -struct MyAgent { - network: NeuralNetwork<1, 2>, - // ... other state -} - -impl From<&MyAgentDNA> for MyAgent { - fn from(value: &MyAgentDNA) -> Self { - Self { - network: NeuralNetwork::from(&value.network), - } - } -} - -fn fitness(dna: &MyAgentDNA) -> f32 { - // agent will simply try to predict whether a number is greater than 0.5 - let mut agent = MyAgent::from(dna); - let mut rng = rand::thread_rng(); - let mut fitness = 0; - - // use repeated tests to avoid situational bias and some local maximums, overall providing more accurate score - for _ in 0..10 { - let n = rng.gen::(); - let above = n > 0.5; - - let res = agent.network.predict([n]); - let resi = res.iter().max_index(); - - if resi == 0 ^ above { - // agent did not guess correctly, punish slightly (too much will hinder exploration) - fitness -= 0.5; - - continue; - } - - // agent guessed correctly, they become more fit. - fitness += 3.; - } - - fitness -} - -fn main() { - let mut rng = rand::thread_rng(); - - let mut sim = GeneticSim::new( - Vec::gen_random(&mut rng, 100), - fitness, - division_pruning_nextgen, - ); - - // simulate 100 generations - for _ in 0..100 { - sim.next_generation(); - } - - // display fitness results - let fits: Vec<_> = sim.entities - .iter() - .map(fitness) - .collect(); - - dbg!(&fits, fits.iter().max()); -} -``` +# How To Use +TODO ### License This crate falls under the `MIT` license diff --git a/examples/basic.rs b/examples/basic.rs index 9ad0419..85f58cb 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,130 +1,3 @@ -//! A basic example of NEAT with this crate. Enable the `crossover` feature for it to use crossover reproduction - -use neat::*; -use rand::prelude::*; - -#[derive(PartialEq, Clone, Debug, DivisionReproduction, RandomlyMutable)] -#[cfg_attr(feature = "crossover", derive(CrossoverReproduction))] -struct AgentDNA { - network: NeuralNetworkTopology<2, 4>, -} - -impl Prunable for AgentDNA {} - -impl GenerateRandom for AgentDNA { - fn gen_random(rng: &mut impl rand::Rng) -> Self { - Self { - network: NeuralNetworkTopology::new(0.01, 3, rng), - } - } -} - -#[derive(Debug)] -struct Agent { - network: NeuralNetwork<2, 4>, -} - -impl From<&AgentDNA> for Agent { - fn from(value: &AgentDNA) -> Self { - Self { - network: (&value.network).into(), - } - } -} - -fn fitness(dna: &AgentDNA) -> f32 { - let agent = Agent::from(dna); - - let mut fitness = 0.; - let mut rng = rand::thread_rng(); - - for _ in 0..10 { - // 10 games - - // set up game - let mut agent_pos: (i32, i32) = (rng.gen_range(0..10), rng.gen_range(0..10)); - let mut food_pos: (i32, i32) = (rng.gen_range(0..10), rng.gen_range(0..10)); - - while food_pos == agent_pos { - food_pos = (rng.gen_range(0..10), rng.gen_range(0..10)); - } - - let mut step = 0; - - loop { - // perform actions in game - let action = agent.network.predict([ - (food_pos.0 - agent_pos.0) as f32, - (food_pos.1 - agent_pos.1) as f32, - ]); - let action = action.iter().max_index(); - - match action { - 0 => agent_pos.0 += 1, - 1 => agent_pos.0 -= 1, - 2 => agent_pos.1 += 1, - _ => agent_pos.1 -= 1, - } - - step += 1; - - if agent_pos == food_pos { - fitness += 10.; - break; // new game - } else { - // lose fitness for being slow and far away - fitness -= - (food_pos.0 - agent_pos.0 + food_pos.1 - agent_pos.1).abs() as f32 * 0.001; - } - - // 50 steps per game - if step == 50 { - break; - } - } - } - - fitness -} - fn main() { - #[cfg(not(feature = "rayon"))] - let mut rng = rand::thread_rng(); - - let mut sim = GeneticSim::new( - #[cfg(not(feature = "rayon"))] - Vec::gen_random(&mut rng, 100), - #[cfg(feature = "rayon")] - Vec::gen_random(100), - fitness, - #[cfg(not(feature = "crossover"))] - division_pruning_nextgen, - #[cfg(feature = "crossover")] - crossover_pruning_nextgen, - ); - - for _ in 0..100 { - sim.next_generation(); - } - - #[cfg(not(feature = "serde"))] - let mut fits: Vec<_> = sim.genomes.iter().map(fitness).collect(); - - #[cfg(feature = "serde")] - let mut fits: Vec<_> = sim.genomes.iter().map(|e| (e, fitness(e))).collect(); - - #[cfg(not(feature = "serde"))] - fits.sort_by(|a, b| a.partial_cmp(&b).unwrap()); - - #[cfg(feature = "serde")] - fits.sort_by(|(_, a), (_, b)| a.partial_cmp(&b).unwrap()); - - dbg!(&fits); - - #[cfg(feature = "serde")] - { - let intermediate = NNTSerde::from(&fits[0].0.network); - let serialized = serde_json::to_string(&intermediate).unwrap(); - println!("{}", serialized); - } + todo!("use NeuralNetwork as the entire DNA"); } diff --git a/examples/extra_dna.rs b/examples/extra_dna.rs new file mode 100644 index 0000000..038709f --- /dev/null +++ b/examples/extra_dna.rs @@ -0,0 +1,3 @@ +fn main() { + todo!("use AgentDNA with additional params") +} diff --git a/src/topology/activation.rs b/src/activation.rs similarity index 78% rename from src/topology/activation.rs rename to src/activation.rs index a711851..af9f74e 100644 --- a/src/topology/activation.rs +++ b/src/activation.rs @@ -1,7 +1,12 @@ +/// Contains some builtin activation functions ([`sigmoid`], [`relu`], etc.) +pub mod builtin; + +use bitflags::bitflags; +use builtin::*; + #[cfg(feature = "serde")] use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use bitflags::bitflags; use lazy_static::lazy_static; use std::{ collections::HashMap, @@ -15,11 +20,11 @@ use crate::NeuronLocation; #[macro_export] macro_rules! activation_fn { ($F: path) => { - ActivationFn::new(Arc::new($F), ActivationScope::default(), stringify!($F).into()) + ActivationFn::new(std::sync::Arc::new($F), NeuronScope::default(), stringify!($F).into()) }; ($F: path, $S: expr) => { - ActivationFn::new(Arc::new($F), $S, stringify!($F).into()) + ActivationFn::new(std::sync::Arc::new($F), $S, stringify!($F).into()) }; {$($F: path),*} => { @@ -73,11 +78,11 @@ impl ActivationRegistry { } /// Gets all activation functions that are valid for a scope. - pub fn activations_in_scope(&self, scope: ActivationScope) -> Vec { + pub fn activations_in_scope(&self, scope: NeuronScope) -> Vec { let acts = self.activations(); acts.into_iter() - .filter(|a| a.scope != ActivationScope::NONE && a.scope.contains(scope)) + .filter(|a| !a.scope.contains(NeuronScope::NONE) && a.scope.contains(scope)) .collect() } } @@ -88,51 +93,18 @@ impl Default for ActivationRegistry { fns: HashMap::new(), }; + // TODO add a way to disable this s.batch_register(activation_fn! { - sigmoid => ActivationScope::HIDDEN | ActivationScope::OUTPUT, - relu => ActivationScope::HIDDEN | ActivationScope::OUTPUT, - linear_activation => ActivationScope::INPUT | ActivationScope::HIDDEN | ActivationScope::OUTPUT, - f32::tanh => ActivationScope::HIDDEN | ActivationScope::OUTPUT + sigmoid => NeuronScope::HIDDEN | NeuronScope::OUTPUT, + relu => NeuronScope::HIDDEN | NeuronScope::OUTPUT, + linear_activation => NeuronScope::INPUT | NeuronScope::HIDDEN | NeuronScope::OUTPUT, + f32::tanh => NeuronScope::HIDDEN | NeuronScope::OUTPUT }); s } } -bitflags! { - /// Specifies where an activation function can occur - #[derive(Copy, Clone, Debug, Eq, PartialEq)] - pub struct ActivationScope: u8 { - /// Whether the activation can be applied to the input layer. - const INPUT = 0b001; - - /// Whether the activation can be applied to the hidden layer. - const HIDDEN = 0b010; - - /// Whether the activation can be applied to the output layer. - const OUTPUT = 0b100; - - /// The activation function will not be randomly placed anywhere - const NONE = 0b000; - } -} - -impl Default for ActivationScope { - fn default() -> Self { - Self::HIDDEN - } -} - -impl From<&NeuronLocation> for ActivationScope { - fn from(value: &NeuronLocation) -> Self { - match value { - NeuronLocation::Input(_) => Self::INPUT, - NeuronLocation::Hidden(_) => Self::HIDDEN, - NeuronLocation::Output(_) => Self::OUTPUT, - } - } -} - /// A trait that represents an activation method. pub trait Activation { /// The activation function. @@ -152,17 +124,13 @@ pub struct ActivationFn { pub func: Arc, /// The scope defining where the activation function can appear. - pub scope: ActivationScope, + pub scope: NeuronScope, pub(crate) name: String, } impl ActivationFn { /// Creates a new ActivationFn object. - pub fn new( - func: Arc, - scope: ActivationScope, - name: String, - ) -> Self { + pub fn new(func: Arc, scope: NeuronScope, name: String) -> Self { Self { func, name, scope } } } @@ -206,17 +174,36 @@ impl<'a> Deserialize<'a> for ActivationFn { } } -/// The sigmoid activation function. -pub fn sigmoid(n: f32) -> f32 { - 1. / (1. + std::f32::consts::E.powf(-n)) +bitflags! { + /// Specifies where an activation function can occur + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + pub struct NeuronScope: u8 { + /// Whether the activation can be applied to the input layer. + const INPUT = 0b001; + + /// Whether the activation can be applied to the hidden layer. + const HIDDEN = 0b010; + + /// Whether the activation can be applied to the output layer. + const OUTPUT = 0b100; + + /// The activation function will not be randomly placed anywhere + const NONE = 0b000; + } } -/// The ReLU activation function. -pub fn relu(n: f32) -> f32 { - n.max(0.) +impl Default for NeuronScope { + fn default() -> Self { + Self::HIDDEN + } } -/// Activation function that does nothing. -pub fn linear_activation(n: f32) -> f32 { - n +impl> From for NeuronScope { + fn from(value: L) -> Self { + match value.as_ref() { + NeuronLocation::Input(_) => Self::INPUT, + NeuronLocation::Hidden(_) => Self::HIDDEN, + NeuronLocation::Output(_) => Self::OUTPUT, + } + } } diff --git a/src/activation/builtin.rs b/src/activation/builtin.rs new file mode 100644 index 0000000..fdf7ab7 --- /dev/null +++ b/src/activation/builtin.rs @@ -0,0 +1,14 @@ +/// The sigmoid activation function. Scales all values nonlinearly in the range of 1 to -1. +pub fn sigmoid(n: f32) -> f32 { + 1. / (1. + std::f32::consts::E.powf(-n)) +} + +/// The ReLU activation function. Equal to `n.max(0)`` +pub fn relu(n: f32) -> f32 { + n.max(0.) +} + +/// Activation function that does nothing. +pub fn linear_activation(n: f32) -> f32 { + n +} diff --git a/src/lib.rs b/src/lib.rs index ee9f769..0de7360 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,25 +1,21 @@ -//! A simple crate that implements the Neuroevolution Augmenting Topologies algorithm using [genetic-rs](https://crates.io/crates/genetic-rs) -//! ### Feature Roadmap: -//! - [x] base (single-core) crate -//! - [x] rayon -//! - [x] serde -//! - [x] crossover +//! A crate implementing NeuroEvolution of Augmenting Topologies (NEAT). //! -//! You can get started by looking at [genetic-rs docs](https://docs.rs/genetic-rs) and checking the examples for this crate. +//! The goal is to provide a simple-to-use, very dynamic [`NeuralNetwork`] type that +//! integrates directly into the [`genetic-rs`](https://crates.io/crates/genetic-rs) ecosystem. +//! +//! Look at the README, docs, or examples to learn how to use 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; +/// Contains the types surrounding activation functions. +pub mod activation; + +/// Contains the [`NeuralNetwork`] and related types. +pub mod neuralnet; -/// A module containing the main [`NeuralNetwork`] struct. -/// This has state/cache and will run the predictions. Make sure to run [`NeuralNetwork::flush_state`] between uses of [`NeuralNetwork::predict`]. -pub mod runnable; +pub use neuralnet::*; -pub use genetic_rs::prelude::*; -pub use runnable::*; -pub use topology::*; +pub use genetic_rs::{self, prelude::*}; -#[cfg(feature = "serde")] -pub use nnt_serde::*; +#[cfg(test)] +mod tests; diff --git a/src/neuralnet.rs b/src/neuralnet.rs new file mode 100644 index 0000000..cce0d61 --- /dev/null +++ b/src/neuralnet.rs @@ -0,0 +1,860 @@ +use std::{ + collections::HashSet, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, +}; + +use atomic_float::AtomicF32; +use genetic_rs::prelude::*; +use rand::Rng; +use replace_with::replace_with_or_abort; + +use crate::{ + activation::{builtin::*, *}, + activation_fn, +}; + +use rayon::prelude::*; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "serde")] +use serde_big_array::BigArray; + +/// The mutation settings for [`NeuralNetwork`]. +/// Does not affect [`NeuralNetwork::mutate`], only [`NeuralNetwork::divide`] and [`NeuralNetwork::crossover`]. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct MutationSettings { + /// The chance of each mutation type to occur. + pub mutation_rate: f32, + + /// The number of times to try to mutate the network. + pub mutation_passes: usize, + + /// The maximum amount that the weights will be mutated by. + pub weight_mutation_amount: f32, +} + +impl Default for MutationSettings { + fn default() -> Self { + Self { + mutation_rate: 0.01, + mutation_passes: 3, + weight_mutation_amount: 0.5, + } + } +} + +/// An abstract neural network type with `I` input neurons and `O` output neurons. +/// Hidden neurons are not organized into layers, but rather float and link freely +/// (or at least in any way that doesn't cause a cyclic dependency). +/// +/// See [`NeuralNetwork::predict`] for usage. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct NeuralNetwork { + /// The input layer of neurons. Values specified in [`NeuralNetwork::predict`] will start here. + #[cfg_attr(feature = "serde", serde(with = "BigArray"))] + pub input_layer: [Neuron; I], + + /// The hidden layer(s) of neurons. They are not actually layered, but rather free-floating. + pub hidden_layers: Vec, + + /// The output layer of neurons. Their values will be returned from [`NeuralNetwork::predict`]. + #[cfg_attr(feature = "serde", serde(with = "BigArray"))] + pub output_layer: [Neuron; O], + + /// The mutation settings for the network. + pub mutation_settings: MutationSettings, +} + +impl NeuralNetwork { + // TODO option to set default output layer activations + /// Creates a new random neural network with the given settings. + pub fn new(mutation_settings: MutationSettings, rng: &mut impl Rng) -> Self { + let mut output_layer = Vec::with_capacity(O); + + for _ in 0..O { + output_layer.push(Neuron::new_with_activation( + vec![], + activation_fn!(sigmoid), + rng, + )); + } + + let mut input_layer = Vec::with_capacity(I); + + for _ in 0..I { + let mut already_chosen = Vec::new(); + let outputs = (0..rng.gen_range(1..=O)) + .map(|_| { + let mut j = rng.gen_range(0..O); + while already_chosen.contains(&j) { + j = rng.gen_range(0..O); + } + + output_layer[j].input_count += 1; + already_chosen.push(j); + + (NeuronLocation::Output(j), rng.gen()) + }) + .collect(); + + input_layer.push(Neuron::new_with_activation( + outputs, + activation_fn!(linear_activation), + rng, + )); + } + + let input_layer = input_layer.try_into().unwrap(); + let output_layer = output_layer.try_into().unwrap(); + + Self { + input_layer, + hidden_layers: vec![], + output_layer, + mutation_settings, + } + } + + /// Runs the neural network, propagating values from input to output layer. + pub fn predict(&self, inputs: [f32; I]) -> [f32; O] { + let cache = Arc::new(NeuralNetCache::from(self)); + cache.prime_inputs(inputs); + + (0..I) + .into_par_iter() + .for_each(|i| self.eval(NeuronLocation::Input(i), cache.clone())); + + cache.output() + } + + fn eval(&self, loc: impl AsRef, cache: Arc>) { + let loc = loc.as_ref(); + + if !cache.claim(loc) { + // some other thread is already + // waiting to do this task, currently doing it, or done. + // no need to do it again. + return; + } + + while !cache.is_ready(loc) { + // essentially spinlocks until the dependency tasks are complete, + // while letting this thread do some work on random tasks. + rayon::yield_now(); + } + + let val = cache.get(loc); + let n = self.get_neuron(loc); + + n.outputs.par_iter().for_each(|(loc2, weight)| { + cache.add(loc2, n.activate(val * weight)); + self.eval(loc2, cache.clone()); + }); + } + + /// Get a neuron at the specified [`NeuronLocation`]. + pub fn get_neuron(&self, loc: impl AsRef) -> &Neuron { + match loc.as_ref() { + NeuronLocation::Input(i) => &self.input_layer[*i], + NeuronLocation::Hidden(i) => &self.hidden_layers[*i], + NeuronLocation::Output(i) => &self.output_layer[*i], + } + } + + /// Get a mutable reference to the neuron at the specified [`NeuronLocation`]. + pub fn get_neuron_mut(&mut self, loc: impl AsRef) -> &mut Neuron { + match loc.as_ref() { + NeuronLocation::Input(i) => &mut self.input_layer[*i], + NeuronLocation::Hidden(i) => &mut self.hidden_layers[*i], + NeuronLocation::Output(i) => &mut self.output_layer[*i], + } + } + + /// Split a [`Connection`] into two of the same weight, joined by a new [`Neuron`] in the hidden layer(s). + pub fn split_connection(&mut self, connection: Connection, rng: &mut impl Rng) { + let newloc = NeuronLocation::Hidden(self.hidden_layers.len()); + + let a = self.get_neuron_mut(connection.from); + let weight = unsafe { a.remove_connection(connection.to) }.unwrap(); + + a.outputs.push((newloc, weight)); + + let n = Neuron::new(vec![(connection.to, weight)], NeuronScope::HIDDEN, rng); + self.hidden_layers.push(n); + } + + /// Adds a connection but does not check for cyclic linkages. + /// + /// # Safety + /// This is marked as unsafe because it could cause a hang/livelock when predicting due to cyclic linkage. + /// There is no actual UB or unsafe code associated with it. + pub unsafe fn add_connection_raw(&mut self, connection: Connection, weight: f32) { + let a = self.get_neuron_mut(connection.from); + a.outputs.push((connection.to, weight)); + + // let b = self.get_neuron_mut(connection.to); + // b.inputs.insert(connection.from); + } + + /// Returns false if the connection is cyclic. + pub fn is_connection_safe(&self, connection: Connection) -> bool { + let mut visited = HashSet::from([connection.from]); + + self.dfs(&mut visited, connection.to) + } + + // TODO maybe parallelize + fn dfs(&self, visited: &mut HashSet, current: NeuronLocation) -> bool { + if !visited.insert(current) { + return false; + } + + let n = self.get_neuron(current); + for (loc, _) in &n.outputs { + if !self.dfs(visited, *loc) { + return false; + } + } + + true + } + + /// Safe, checked add connection method. Returns false if it aborted connecting due to cyclic linkage. + pub fn add_connection(&mut self, connection: Connection, weight: f32) -> bool { + if !self.is_connection_safe(connection) { + return false; + } + + unsafe { + self.add_connection_raw(connection, weight); + } + + true + } + + /// Mutates a connection's weight. + pub fn mutate_weight(&mut self, connection: Connection, rng: &mut impl Rng) { + let rate = self.mutation_settings.weight_mutation_amount; + let n = self.get_neuron_mut(connection.from); + n.mutate_weight(connection.to, rate, rng).unwrap(); + } + + /// Get a random valid location within the network. + pub fn random_location(&self, rng: &mut impl Rng) -> NeuronLocation { + match rng.gen_range(0..3) { + 0 => NeuronLocation::Input(rng.gen_range(0..self.input_layer.len())), + 1 => NeuronLocation::Hidden(rng.gen_range(0..self.hidden_layers.len())), + 2 => NeuronLocation::Output(rng.gen_range(0..self.output_layer.len())), + _ => unreachable!(), + } + } + + /// Get a random valid location within a [`NeuronScope`]. + pub fn random_location_in_scope( + &self, + rng: &mut impl Rng, + scope: NeuronScope, + ) -> NeuronLocation { + let loc = self.random_location(rng); + + // this is a lazy and slow way of donig it, TODO better version. + if !scope.contains(NeuronScope::from(loc)) { + return self.random_location_in_scope(rng, scope); + } + + loc + } + + /// Remove a connection and any hanging neurons caused by the deletion. + /// Returns whether there was a hanging neuron. + pub fn remove_connection(&mut self, connection: Connection) -> bool { + let a = self.get_neuron_mut(connection.from); + unsafe { a.remove_connection(connection.to) }.unwrap(); + + let b = self.get_neuron_mut(connection.to); + b.input_count -= 1; + + if b.input_count == 0 { + self.remove_neuron(connection.to); + return true; + } + + false + } + + /// Remove a neuron and downshift all connection indexes to compensate for it. + pub fn remove_neuron(&mut self, loc: impl AsRef) { + let loc = loc.as_ref(); + if !loc.is_hidden() { + panic!("Can only remove neurons from hidden layer"); + } + + unsafe { + self.downshift_connections(loc.unwrap()); + } + } + + unsafe fn downshift_connections(&mut self, i: usize) { + self.input_layer + .par_iter_mut() + .for_each(|n| n.downshift_outputs(i)); + + self.hidden_layers + .par_iter_mut() + .for_each(|n| n.downshift_outputs(i)); + } + + // TODO maybe more parallelism and pass Connection info. + /// Runs the `callback` on the weights of the neural network in parallel, allowing it to modify weight values. + pub fn map_weights(&mut self, callback: impl Fn(&mut f32) + Sync) { + for n in &mut self.input_layer { + n.outputs.par_iter_mut().for_each(|(_, w)| callback(w)); + } + + for n in &mut self.hidden_layers { + n.outputs.par_iter_mut().for_each(|(_, w)| callback(w)); + } + } + + unsafe fn clear_input_counts(&mut self) { + // not sure whether all this parallelism is necessary or if it will just generate overhead + // rayon::scope(|s| { + // s.spawn(|_| self.input_layer.par_iter_mut().for_each(|n| n.input_count = 0)); + // s.spawn(|_| self.hidden_layers.par_iter_mut().for_each(|n| n.input_count = 0)); + // s.spawn(|_| self.output_layer.par_iter_mut().for_each(|n| n.input_count = 0)); + // }); + + self.input_layer + .par_iter_mut() + .for_each(|n| n.input_count = 0); + self.hidden_layers + .par_iter_mut() + .for_each(|n| n.input_count = 0); + self.output_layer + .par_iter_mut() + .for_each(|n| n.input_count = 0); + } + + /// Recalculates the [`input_count`][`Neuron::input_count`] field for all neurons in the network. + pub fn recalculate_input_counts(&mut self) { + unsafe { self.clear_input_counts() }; + + for i in 0..I { + for j in 0..self.input_layer[i].outputs.len() { + let (loc, _) = self.input_layer[i].outputs[j]; + self.get_neuron_mut(loc).input_count += 1; + } + } + + for i in 0..self.hidden_layers.len() { + for j in 0..self.hidden_layers[i].outputs.len() { + let (loc, _) = self.hidden_layers[i].outputs[j]; + self.get_neuron_mut(loc).input_count += 1; + } + } + } +} + +impl RandomlyMutable for NeuralNetwork { + fn mutate(&mut self, rate: f32, rng: &mut impl Rng) { + if rng.gen::() <= rate { + // split connection + let from = self.random_location_in_scope(rng, !NeuronScope::OUTPUT); + let n = self.get_neuron(from); + let (to, _) = n.random_output(rng); + + self.split_connection(Connection { from, to }, rng); + } + + if rng.gen::() <= rate { + // add connection + let weight = rng.gen::(); + + let from = self.random_location_in_scope(rng, !NeuronScope::OUTPUT); + let to = self.random_location_in_scope(rng, !NeuronScope::INPUT); + + let mut connection = Connection { from, to }; + while !self.add_connection(connection, weight) { + let from = self.random_location_in_scope(rng, !NeuronScope::OUTPUT); + let to = self.random_location_in_scope(rng, !NeuronScope::INPUT); + connection = Connection { from, to }; + } + } + + if rng.gen::() <= rate { + // remove connection + + let from = self.random_location_in_scope(rng, !NeuronScope::OUTPUT); + let a = self.get_neuron(from); + let (to, _) = a.random_output(rng); + + self.remove_connection(Connection { from, to }); + } + + self.map_weights(|w| { + // TODO maybe `Send`able rng. + let mut rng = rand::thread_rng(); + + if rng.gen::() <= rate { + *w += rng.gen_range(-rate..rate); + } + }); + } +} + +impl DivisionReproduction for NeuralNetwork { + fn divide(&self, rng: &mut impl Rng) -> Self { + let mut child = self.clone(); + + for _ in 0..self.mutation_settings.mutation_passes { + child.mutate(child.mutation_settings.mutation_rate, rng); + } + + child + } +} + +#[allow(clippy::needless_range_loop)] +impl CrossoverReproduction for NeuralNetwork { + fn crossover(&self, other: &Self, rng: &mut impl rand::Rng) -> Self { + let mut output_layer = self.output_layer.clone(); + + for (i, n) in output_layer.iter_mut().enumerate() { + if rng.gen::() >= 0.5 { + *n = other.output_layer[i].clone(); + } + } + + let hidden_len = self.hidden_layers.len().max(other.hidden_layers.len()); + let mut hidden_layers = Vec::with_capacity(hidden_len); + + for i in 0..hidden_len { + if rng.gen::() >= 0.5 { + if let Some(n) = self.hidden_layers.get(i) { + let mut n = n.clone(); + n.prune_invalid_outputs(hidden_len, O); + + hidden_layers[i] = n; + + continue; + } + } + + let mut n = other.hidden_layers[i].clone(); + n.prune_invalid_outputs(hidden_len, O); + + hidden_layers[i] = n; + } + + let mut input_layer = self.input_layer.clone(); + + for (i, n) in input_layer.iter_mut().enumerate() { + if rng.gen::() >= 0.5 { + *n = other.input_layer[i].clone(); + } + n.prune_invalid_outputs(hidden_len, O); + } + + // crossover mutation settings just in case. + let mutation_settings = if rng.gen::() >= 0.5 { + self.mutation_settings.clone() + } else { + other.mutation_settings.clone() + }; + + let mut child = Self { + input_layer, + hidden_layers, + output_layer, + mutation_settings, + }; + + // TODO maybe find a way to do this while doing crossover stuff instead of recalculating everything. + // would be annoying to implement though. + child.recalculate_input_counts(); + + for _ in 0..child.mutation_settings.mutation_passes { + child.mutate(child.mutation_settings.mutation_rate, rng); + } + + child + } +} + +fn output_exists(loc: NeuronLocation, hidden_len: usize, output_len: usize) -> bool { + match loc { + NeuronLocation::Input(_) => false, + NeuronLocation::Hidden(i) => i < hidden_len, + NeuronLocation::Output(i) => i < output_len, + } +} + +/// A helper struct for operations on connections between neurons. +/// It does not contain information about the weight. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Connection { + /// The source of the connection. + pub from: NeuronLocation, + + /// The destination of the connection. + pub to: NeuronLocation, +} + +/// A stateless neuron. Contains info about bias, activation, and connections. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Neuron { + /// The input count used in [`NeuralNetCache`]. Not safe to modify. + pub input_count: usize, + + /// The connections and weights to other neurons. + pub outputs: Vec<(NeuronLocation, f32)>, + + /// The initial value of the neuron. + pub bias: f32, + + /// The activation function applied to the value before propagating to [`outputs`][Neuron::outputs]. + pub activation_fn: ActivationFn, +} + +impl Neuron { + /// Creates a new neuron with a specified activation function and outputs. + pub fn new_with_activation( + outputs: Vec<(NeuronLocation, f32)>, + activation_fn: ActivationFn, + rng: &mut impl Rng, + ) -> Self { + Self { + input_count: 0, + outputs, + bias: rng.gen(), + activation_fn, + } + } + + /// Creates a new neuron with the given output locations. + /// Chooses a random activation function within the specified scope. + pub fn new( + outputs: Vec<(NeuronLocation, f32)>, + current_scope: NeuronScope, + rng: &mut impl Rng, + ) -> Self { + let reg = ACTIVATION_REGISTRY.read().unwrap(); + let activations = reg.activations_in_scope(current_scope); + + Self::new_with_activations(outputs, activations, rng) + } + + /// Creates a new neuron with the given outputs. + /// Takes a collection of activation functions and chooses a random one from them to use. + pub fn new_with_activations( + outputs: Vec<(NeuronLocation, f32)>, + activations: impl IntoIterator, + rng: &mut impl Rng, + ) -> Self { + // TODO get random in iterator form + let mut activations: Vec<_> = activations.into_iter().collect(); + + // TODO maybe Result instead. + if activations.is_empty() { + panic!("Empty activations list provided"); + } + + Self::new_with_activation( + outputs, + activations.remove(rng.gen_range(0..activations.len())), + rng, + ) + } + + /// Runs the [activation function][Neuron::activation_fn] on the given value and returns it. + pub fn activate(&self, v: f32) -> f32 { + self.activation_fn.func.activate(v) + } + + /// Get the weight of the provided output location. Returns `None` if not found. + pub fn get_weight(&self, output: impl AsRef) -> Option { + let loc = *output.as_ref(); + for out in &self.outputs { + if out.0 == loc { + return Some(out.1); + } + } + + None + } + + /// Tries to remove a connection from the neuron and returns the weight if it was found. + /// + /// # Safety + /// This is marked as unsafe because it will not update the destination's [`input_count`][Neuron::input_count]. + /// Similar to [`add_connection_raw`][NeuralNetwork::add_connection_raw], this does not mean UB or anything. + pub unsafe fn remove_connection(&mut self, output: impl AsRef) -> Option { + let loc = *output.as_ref(); + let mut i = 0; + + while i < self.outputs.len() { + if self.outputs[i].0 == loc { + return Some(self.outputs.remove(i).1); + } + i += 1; + } + + None + } + + /// Randomly mutates the specified weight with the rate. + pub fn mutate_weight( + &mut self, + output: impl AsRef, + rate: f32, + rng: &mut impl Rng, + ) -> Option { + let loc = *output.as_ref(); + let mut i = 0; + + while i < self.outputs.len() { + let o = &mut self.outputs[i]; + if o.0 == loc { + o.1 += rng.gen_range(-rate..rate); + + return Some(o.1); + } + + i += 1; + } + + None + } + + /// Get a random output location and weight. + pub fn random_output(&self, rng: &mut impl Rng) -> (NeuronLocation, f32) { + self.outputs[rng.gen_range(0..self.outputs.len())] + } + + pub(crate) fn downshift_outputs(&mut self, i: usize) { + // TODO par_iter_mut instead of replace + replace_with_or_abort(&mut self.outputs, |o| { + o.into_par_iter() + .map(|(loc, w)| match loc { + NeuronLocation::Hidden(j) if j > i => (NeuronLocation::Hidden(j - 1), w), + _ => (loc, w), + }) + .collect() + }); + } + + /// Removes any outputs pointing to a nonexistent neuron. + pub fn prune_invalid_outputs(&mut self, hidden_len: usize, output_len: usize) { + self.outputs + .retain(|(loc, _)| output_exists(*loc, hidden_len, output_len)); + } +} + +/// A pseudo-pointer of sorts that is used for caching. +#[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), + + /// Points to a neuron in the hidden layer at contained index. + Hidden(usize), + + /// Points to a neuron in the output layer at contained index. + Output(usize), +} + +impl NeuronLocation { + /// Returns `true` if it points to the input layer. Otherwise, returns `false`. + pub fn is_input(&self) -> bool { + matches!(self, Self::Input(_)) + } + + /// Returns `true` if it points to the hidden layer. Otherwise, returns `false`. + pub fn is_hidden(&self) -> bool { + matches!(self, Self::Hidden(_)) + } + + /// Returns `true` if it points to the output layer. Otherwise, returns `false`. + pub fn is_output(&self) -> bool { + matches!(self, Self::Output(_)) + } + + /// Retrieves the index value, regardless of layer. Does not consume. + pub fn unwrap(&self) -> usize { + match self { + Self::Input(i) => *i, + Self::Hidden(i) => *i, + Self::Output(i) => *i, + } + } +} + +impl AsRef for NeuronLocation { + fn as_ref(&self) -> &NeuronLocation { + self + } +} + +/// Handles the state of a single neuron for [`NeuralNetCache`]. +#[derive(Debug, Default)] +pub struct NeuronCache { + /// The value of the neuron. + pub value: AtomicF32, + + /// The expected input count. + pub expected_inputs: usize, + + /// The number of inputs that have finished evaluating. + pub finished_inputs: AtomicUsize, + + /// Whether or not a thread has claimed this neuron to work on it. + pub claimed: AtomicBool, +} + +impl NeuronCache { + /// Creates a new [`NeuronCache`] given relevant info. + /// Use [`NeuronCache::from`] instead to create cache for a [`Neuron`]. + pub fn new(bias: f32, expected_inputs: usize) -> Self { + Self { + value: AtomicF32::new(bias), + expected_inputs, + ..Default::default() + } + } +} + +impl From<&Neuron> for NeuronCache { + fn from(value: &Neuron) -> Self { + Self { + value: AtomicF32::new(value.bias), + expected_inputs: value.input_count, + finished_inputs: AtomicUsize::new(0), + claimed: AtomicBool::new(false), + } + } +} + +/// A cache type used in [`NeuralNetwork::predict`] to track state. +#[derive(Debug)] +pub struct NeuralNetCache { + /// The input layer cache. + pub input_layer: [NeuronCache; I], + + /// The hidden layer(s) cache. + pub hidden_layers: Vec, + + /// The output layer cache. + pub output_layer: [NeuronCache; O], +} + +impl NeuralNetCache { + /// Gets the value of a neuron at the given location. + pub fn get(&self, loc: impl AsRef) -> f32 { + match loc.as_ref() { + NeuronLocation::Input(i) => self.input_layer[*i].value.load(Ordering::SeqCst), + NeuronLocation::Hidden(i) => self.hidden_layers[*i].value.load(Ordering::SeqCst), + NeuronLocation::Output(i) => self.output_layer[*i].value.load(Ordering::SeqCst), + } + } + + /// Adds a value to the neuron at the specified location and increments [`finished_inputs`][NeuronCache::finished_inputs]. + pub fn add(&self, loc: impl AsRef, n: f32) -> f32 { + match loc.as_ref() { + NeuronLocation::Input(i) => self.input_layer[*i].value.fetch_add(n, Ordering::SeqCst), + NeuronLocation::Hidden(i) => { + let c = &self.hidden_layers[*i]; + let v = c.value.fetch_add(n, Ordering::SeqCst); + c.finished_inputs.fetch_add(1, Ordering::SeqCst); + v + } + NeuronLocation::Output(i) => { + let c = &self.output_layer[*i]; + let v = c.value.fetch_add(n, Ordering::SeqCst); + c.finished_inputs.fetch_add(1, Ordering::SeqCst); + v + } + } + } + + /// Returns whether [`finished_inputs`][NeuronCache::finished_inputs] matches [`expected_inputs`][NeuronCache::expected_inputs]. + pub fn is_ready(&self, loc: impl AsRef) -> bool { + match loc.as_ref() { + NeuronLocation::Input(i) => { + let c = &self.input_layer[*i]; + c.expected_inputs >= c.finished_inputs.load(Ordering::SeqCst) + } + NeuronLocation::Hidden(i) => { + let c = &self.hidden_layers[*i]; + c.expected_inputs >= c.finished_inputs.load(Ordering::SeqCst) + } + NeuronLocation::Output(i) => { + let c = &self.output_layer[*i]; + c.expected_inputs >= c.finished_inputs.load(Ordering::SeqCst) + } + } + } + + /// Adds the input values to the input layer of neurons. + pub fn prime_inputs(&self, inputs: [f32; I]) { + for (i, v) in inputs.into_iter().enumerate() { + self.input_layer[i].value.fetch_add(v, Ordering::SeqCst); + } + } + + /// Fetches and packs the output layer values into an array. + pub fn output(&self) -> [f32; O] { + let output: Vec<_> = self + .output_layer + .par_iter() + .map(|c| c.value.load(Ordering::SeqCst)) + .collect(); + + output.try_into().unwrap() + } + + /// Attempts to claim a neuron. Returns false if it has already been claimed. + pub fn claim(&self, loc: impl AsRef) -> bool { + match loc.as_ref() { + NeuronLocation::Input(i) => self.input_layer[*i] + .claimed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + NeuronLocation::Hidden(i) => self.hidden_layers[*i] + .claimed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + NeuronLocation::Output(i) => self.output_layer[*i] + .claimed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + } + } +} + +impl From<&NeuralNetwork> for NeuralNetCache { + fn from(net: &NeuralNetwork) -> Self { + let input_layer: Vec<_> = net.input_layer.par_iter().map(|n| n.into()).collect(); + let input_layer = input_layer.try_into().unwrap(); + + let hidden_layers = net.hidden_layers.par_iter().map(|n| n.into()).collect(); + + let output_layer: Vec<_> = net.output_layer.par_iter().map(|n| n.into()).collect(); + let output_layer = output_layer.try_into().unwrap(); + + Self { + input_layer, + hidden_layers, + output_layer, + } + } +} diff --git a/src/runnable.rs b/src/runnable.rs deleted file mode 100644 index 5b28f54..0000000 --- a/src/runnable.rs +++ /dev/null @@ -1,300 +0,0 @@ -use crate::topology::*; - -#[cfg(not(feature = "rayon"))] -use std::{cell::RefCell, rc::Rc}; - -#[cfg(feature = "rayon")] -use rayon::prelude::*; -#[cfg(feature = "rayon")] -use std::sync::{Arc, RwLock}; - -/// 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"))] -pub struct NeuralNetwork { - input_layer: [Rc>; I], - hidden_layers: Vec>>, - output_layer: [Rc>; O], -} - -/// Parallelized version of the [`NeuralNetwork`] struct. -#[derive(Debug)] -#[cfg(feature = "rayon")] -pub struct NeuralNetwork { - input_layer: [Arc>; I], - hidden_layers: Vec>>, - output_layer: [Arc>; O], -} - -impl NeuralNetwork { - /// Predicts an output for the given inputs. - #[cfg(not(feature = "rayon"))] - pub fn predict(&self, inputs: [f32; I]) -> [f32; O] { - for (i, v) in inputs.iter().enumerate() { - let mut nw = self.input_layer[i].borrow_mut(); - nw.state.value = *v; - nw.state.processed = true; - } - - (0..O) - .map(NeuronLocation::Output) - .map(|loc| self.process_neuron(loc)) - .collect::>() - .try_into() - .unwrap() - } - - /// Parallelized prediction of outputs from inputs. - #[cfg(feature = "rayon")] - pub fn predict(&self, inputs: [f32; I]) -> [f32; O] { - inputs.par_iter().enumerate().for_each(|(i, v)| { - let mut nw = self.input_layer[i].write().unwrap(); - nw.state.value = *v; - nw.state.processed = true; - }); - - (0..O) - .map(NeuronLocation::Output) - .collect::>() - .into_par_iter() - .map(|loc| self.process_neuron(loc)) - .collect::>() - .try_into() - .unwrap() - } - - #[cfg(not(feature = "rayon"))] - fn process_neuron(&self, loc: NeuronLocation) -> f32 { - let n = self.get_neuron(loc); - - { - let nr = n.borrow(); - - if nr.state.processed { - return nr.state.value; - } - } - - let mut n = n.borrow_mut(); - - for (l, w) in n.inputs.clone() { - n.state.value += self.process_neuron(l) * w; - } - - n.activate(); - - n.state.value - } - - #[cfg(feature = "rayon")] - fn process_neuron(&self, loc: NeuronLocation) -> f32 { - let n = self.get_neuron(loc); - - { - let nr = n.read().unwrap(); - - if nr.state.processed { - return nr.state.value; - } - } - - let val: f32 = n - .read() - .unwrap() - .inputs - .par_iter() - .map(|&(n2, w)| { - let processed = self.process_neuron(n2); - processed * w - }) - .sum(); - - let mut nw = n.write().unwrap(); - nw.state.value += val; - nw.activate(); - - nw.state.value - } - - #[cfg(not(feature = "rayon"))] - fn get_neuron(&self, loc: NeuronLocation) -> Rc> { - match loc { - NeuronLocation::Input(i) => self.input_layer[i].clone(), - NeuronLocation::Hidden(i) => self.hidden_layers[i].clone(), - NeuronLocation::Output(i) => self.output_layer[i].clone(), - } - } - - #[cfg(feature = "rayon")] - fn get_neuron(&self, loc: NeuronLocation) -> Arc> { - match loc { - NeuronLocation::Input(i) => self.input_layer[i].clone(), - NeuronLocation::Hidden(i) => self.hidden_layers[i].clone(), - NeuronLocation::Output(i) => self.output_layer[i].clone(), - } - } - - /// Flushes the network's state after a [prediction][NeuralNetwork::predict]. - #[cfg(not(feature = "rayon"))] - pub fn flush_state(&self) { - for n in &self.input_layer { - n.borrow_mut().flush_state(); - } - - for n in &self.hidden_layers { - n.borrow_mut().flush_state(); - } - - for n in &self.output_layer { - n.borrow_mut().flush_state(); - } - } - - /// Flushes the neural network's state. - #[cfg(feature = "rayon")] - pub fn flush_state(&self) { - self.input_layer - .par_iter() - .for_each(|n| n.write().unwrap().flush_state()); - - self.hidden_layers - .par_iter() - .for_each(|n| n.write().unwrap().flush_state()); - - self.output_layer - .par_iter() - .for_each(|n| n.write().unwrap().flush_state()); - } -} - -impl From<&NeuralNetworkTopology> for NeuralNetwork { - #[cfg(not(feature = "rayon"))] - fn from(value: &NeuralNetworkTopology) -> Self { - let input_layer = value - .input_layer - .iter() - .map(|n| Rc::new(RefCell::new(Neuron::from(&n.read().unwrap().clone())))) - .collect::>() - .try_into() - .unwrap(); - - let hidden_layers = value - .hidden_layers - .iter() - .map(|n| Rc::new(RefCell::new(Neuron::from(&n.read().unwrap().clone())))) - .collect(); - - let output_layer = value - .output_layer - .iter() - .map(|n| Rc::new(RefCell::new(Neuron::from(&n.read().unwrap().clone())))) - .collect::>() - .try_into() - .unwrap(); - - Self { - input_layer, - hidden_layers, - output_layer, - } - } - - #[cfg(feature = "rayon")] - fn from(value: &NeuralNetworkTopology) -> Self { - let input_layer = value - .input_layer - .iter() - .map(|n| Arc::new(RwLock::new(Neuron::from(&n.read().unwrap().clone())))) - .collect::>() - .try_into() - .unwrap(); - - let hidden_layers = value - .hidden_layers - .iter() - .map(|n| Arc::new(RwLock::new(Neuron::from(&n.read().unwrap().clone())))) - .collect(); - - let output_layer = value - .output_layer - .iter() - .map(|n| Arc::new(RwLock::new(Neuron::from(&n.read().unwrap().clone())))) - .collect::>() - .try_into() - .unwrap(); - - Self { - input_layer, - hidden_layers, - output_layer, - } - } -} - -/// A state-filled neuron. -#[derive(Clone, Debug)] -pub struct Neuron { - inputs: Vec<(NeuronLocation, f32)>, - bias: f32, - - /// The current state of the neuron. - pub state: NeuronState, - - /// The neuron's activation function - pub activation: ActivationFn, -} - -impl Neuron { - /// Flushes a neuron's state. Called by [`NeuralNetwork::flush_state`] - pub fn flush_state(&mut self) { - self.state.value = self.bias; - } - - /// Applies the activation function to the neuron - pub fn activate(&mut self) { - self.state.value = self.activation.func.activate(self.state.value); - } -} - -impl From<&NeuronTopology> for Neuron { - fn from(value: &NeuronTopology) -> Self { - Self { - inputs: value.inputs.clone(), - bias: value.bias, - state: NeuronState { - value: value.bias, - ..Default::default() - }, - activation: value.activation.clone(), - } - } -} - -/// A state used in [`Neuron`]s for cache. -#[derive(Clone, Debug, Default)] -pub struct NeuronState { - /// The current value of the neuron. Initialized to a neuron's bias when flushed. - pub value: f32, - - /// Whether or not [`value`][NeuronState::value] has finished processing. - pub processed: bool, -} - -/// A blanket trait for iterators meant to help with interpreting the output of a [`NeuralNetwork`] -#[cfg(feature = "max-index")] -pub trait MaxIndex { - /// Retrieves the index of the max value. - fn max_index(self) -> usize; -} - -#[cfg(feature = "max-index")] -impl, T: PartialOrd> MaxIndex for I { - // slow and lazy implementation but it works (will prob optimize in the future) - fn max_index(self) -> usize { - self.enumerate() - .max_by(|(_, v), (_, v2)| v.partial_cmp(v2).unwrap()) - .unwrap() - .0 - } -} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..825cdee --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,179 @@ +use crate::*; +use rand::prelude::*; + +// no support for tuple structs derive in genetic-rs yet :( +#[derive(Debug, Clone, PartialEq)] +struct Agent(NeuralNetwork<4, 1>); + +impl Prunable for Agent {} + +impl RandomlyMutable for Agent { + fn mutate(&mut self, rate: f32, rng: &mut impl Rng) { + self.0.mutate(rate, rng); + } +} + +impl DivisionReproduction for Agent { + fn divide(&self, rng: &mut impl rand::Rng) -> Self { + Self(self.0.divide(rng)) + } +} + +impl CrossoverReproduction for Agent { + fn crossover(&self, other: &Self, rng: &mut impl rand::Rng) -> Self { + Self(self.0.crossover(&other.0, rng)) + } +} + +struct GuessTheNumber(f32); + +impl GuessTheNumber { + fn new(rng: &mut impl Rng) -> Self { + Self(rng.gen()) + } + + fn guess(&self, n: f32) -> Option { + if n > self.0 + 1.0e-5 { + return Some(1.); + } + + if n < self.0 - 1.0e-5 { + return Some(-1.); + } + + // guess was correct (or at least within margin of error). + None + } +} + +fn fitness(agent: &Agent) -> f32 { + let mut rng = rand::thread_rng(); + + let mut fitness = 0.; + + // 10 games for consistency + for _ in 0..10 { + let game = GuessTheNumber::new(&mut rng); + + let mut last_guess = 0.; + let mut last_result = 0.; + + let mut last_guess_2 = 0.; + let mut last_result_2 = 0.; + + let mut steps = 0; + loop { + if steps >= 20 { + // took too many guesses + fitness -= 50.; + break; + } + + let [cur_guess] = + agent + .0 + .predict([last_guess, last_result, last_guess_2, last_result_2]); + + let cur_result = game.guess(cur_guess); + + if let Some(result) = cur_result { + last_guess = last_guess_2; + last_result = last_result_2; + + last_guess_2 = cur_guess; + last_result_2 = result; + + fitness -= 1.; + steps += 1; + + continue; + } + + fitness += 50.; + break; + } + } + + fitness +} + +#[test] +fn division() { + let mut rng = rand::thread_rng(); + + let starting_genomes = (0..100) + .map(|_| Agent(NeuralNetwork::new(MutationSettings::default(), &mut rng))) + .collect(); + + let mut sim = GeneticSim::new(starting_genomes, fitness, division_pruning_nextgen); + + sim.perform_generations(100); +} + +#[test] +fn crossover() { + let mut rng = rand::thread_rng(); + + let starting_genomes = (0..100) + .map(|_| Agent(NeuralNetwork::new(MutationSettings::default(), &mut rng))) + .collect(); + + let mut sim = GeneticSim::new(starting_genomes, fitness, crossover_pruning_nextgen); + + sim.perform_generations(100); +} + +#[cfg(feature = "serde")] +#[test] +fn serde() { + let mut rng = rand::thread_rng(); + let net: NeuralNetwork<5, 10> = NeuralNetwork::new(MutationSettings::default(), &mut rng); + + let text = serde_json::to_string(&net).unwrap(); + + let net2: NeuralNetwork<5, 10> = serde_json::from_str(&text).unwrap(); + + assert_eq!(net, net2); +} + +#[test] +fn neural_net_cache_sync() { + let cache = NeuralNetCache { + input_layer: [NeuronCache::new(0.3, 0), NeuronCache::new(0.25, 0)], + hidden_layers: vec![ + NeuronCache::new(0.2, 2), + NeuronCache::new(0.0, 2), + NeuronCache::new(1.5, 2), + ], + output_layer: [NeuronCache::new(0.0, 3), NeuronCache::new(0.0, 3)], + }; + + for i in 0..2 { + let input_loc = NeuronLocation::Input(i); + + assert!(cache.claim(&input_loc)); + + for j in 0..3 { + cache.add( + NeuronLocation::Hidden(j), + f32::tanh(cache.get(&input_loc) * 1.2), + ); + } + } + + for i in 0..3 { + let hidden_loc = NeuronLocation::Hidden(i); + + assert!(cache.is_ready(&hidden_loc)); + assert!(cache.claim(&hidden_loc)); + + for j in 0..2 { + cache.add( + NeuronLocation::Output(j), + activation::builtin::sigmoid(cache.get(&hidden_loc) * 0.7), + ); + } + } + + assert_eq!(cache.output(), [2.0688455, 2.0688455]); +} diff --git a/src/topology/mod.rs b/src/topology/mod.rs deleted file mode 100644 index 02ad296..0000000 --- a/src/topology/mod.rs +++ /dev/null @@ -1,626 +0,0 @@ -/// Contains useful structs for serializing/deserializing a [`NeuronTopology`] -#[cfg_attr(docsrs, doc(cfg(feature = "serde")))] -#[cfg(feature = "serde")] -pub mod nnt_serde; - -/// Contains structs and traits used for activation functions. -pub mod activation; - -pub use activation::*; - -use std::{ - collections::HashSet, - sync::{Arc, RwLock}, -}; - -use genetic_rs::prelude::*; -use rand::prelude::*; - -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - -use crate::activation_fn; - -/// 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. -#[derive(Debug)] -pub struct NeuralNetworkTopology { - /// The input layer of the neural network. Uses a fixed length of `I`. - pub input_layer: [Arc>; I], - - /// The hidden layers of the neural network. Because neurons have a flexible connection system, all of them exist in the same flat vector. - pub hidden_layers: Vec>>, - - /// The output layer of the neural netowrk. Uses a fixed length of `O`. - pub output_layer: [Arc>; O], - - /// The mutation rate used in [`NeuralNetworkTopology::mutate`] after crossover/division. - pub mutation_rate: f32, - - /// The number of mutation passes (and thus, maximum number of possible mutations that can occur for each entity in the generation). - pub mutation_passes: usize, -} - -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_with_activation( - vec![], - activation_fn!(linear_activation), - rng, - ))) - }) - .collect::>() - .try_into() - .unwrap(); - - let mut output_layer = Vec::with_capacity(O); - - for _ in 0..O { - // random number of connections to random input neurons. - let input = (0..rng.gen_range(1..=I)) - .map(|_| { - let mut already_chosen = Vec::new(); - let mut i = rng.gen_range(0..I); - while already_chosen.contains(&i) { - i = rng.gen_range(0..I); - } - - already_chosen.push(i); - - NeuronLocation::Input(i) - }) - .collect(); - - output_layer.push(Arc::new(RwLock::new(NeuronTopology::new_with_activation( - input, - activation_fn!(sigmoid), - rng, - )))); - } - - let output_layer = output_layer.try_into().unwrap(); - - Self { - input_layer, - hidden_layers: vec![], - output_layer, - mutation_rate, - mutation_passes, - } - } - - /// 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; - } - - 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 - } - - /// Gets a neuron pointer from a [`NeuronLocation`]. - /// You shouldn't ever need to directly call this unless you are doing complex custom mutations. - pub fn get_neuron(&self, loc: NeuronLocation) -> Arc> { - match loc { - NeuronLocation::Input(i) => self.input_layer[i].clone(), - NeuronLocation::Hidden(i) => self.hidden_layers[i].clone(), - NeuronLocation::Output(i) => self.output_layer[i].clone(), - } - } - - /// Gets a random neuron and its location. - pub fn rand_neuron(&self, rng: &mut impl Rng) -> (Arc>, NeuronLocation) { - match rng.gen_range(0..3) { - 0 => { - 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() => { - let i = rng.gen_range(0..self.hidden_layers.len()); - (self.hidden_layers[i].clone(), NeuronLocation::Hidden(i)) - } - _ => { - let i = rng.gen_range(0..self.output_layer.len()); - (self.output_layer[i].clone(), NeuronLocation::Output(i)) - } - } - } - - 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 -impl Clone for NeuralNetworkTopology { - fn clone(&self) -> Self { - let input_layer = self - .input_layer - .iter() - .map(|n| Arc::new(RwLock::new(n.read().unwrap().clone()))) - .collect::>() - .try_into() - .unwrap(); - - let hidden_layers = self - .hidden_layers - .iter() - .map(|n| Arc::new(RwLock::new(n.read().unwrap().clone()))) - .collect(); - - let output_layer = self - .output_layer - .iter() - .map(|n| Arc::new(RwLock::new(n.read().unwrap().clone()))) - .collect::>() - .try_into() - .unwrap(); - - Self { - input_layer, - hidden_layers, - output_layer, - mutation_rate: self.mutation_rate, - mutation_passes: self.mutation_passes, - } - } -} - -impl RandomlyMutable for NeuralNetworkTopology { - fn mutate(&mut self, rate: f32, rng: &mut impl rand::Rng) { - for _ in 0..self.mutation_passes { - if rng.gen::() <= rate { - // split preexisting connection - let (mut n2, _) = self.rand_neuron(rng); - - while n2.read().unwrap().inputs.is_empty() { - (n2, _) = self.rand_neuron(rng); - } - - let mut n2 = n2.write().unwrap(); - let i = rng.gen_range(0..n2.inputs.len()); - let (loc, w) = n2.inputs.remove(i); - - let loc3 = NeuronLocation::Hidden(self.hidden_layers.len()); - - let n3 = NeuronTopology::new(vec![loc], ActivationScope::HIDDEN, rng); - - self.hidden_layers.push(Arc::new(RwLock::new(n3))); - - n2.inputs.insert(i, (loc3, w)); - } - - if rng.gen::() <= rate { - // add a connection - let (_, mut loc1) = self.rand_neuron(rng); - let (_, mut loc2) = 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); - } - } - - if rng.gen::() <= rate && !self.hidden_layers.is_empty() { - // remove a neuron - let (_, mut loc) = self.rand_neuron(rng); - - while !loc.is_hidden() { - (_, loc) = self.rand_neuron(rng); - } - - // delete the neuron - self.delete_neuron(loc); - } - - if rng.gen::() <= rate { - // mutate a connection - let (mut n, _) = self.rand_neuron(rng); - - while n.read().unwrap().inputs.is_empty() { - (n, _) = self.rand_neuron(rng); - } - - let mut n = n.write().unwrap(); - let i = rng.gen_range(0..n.inputs.len()); - let (_, w) = &mut n.inputs[i]; - *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 reg = ACTIVATION_REGISTRY.read().unwrap(); - let activations = reg.activations_in_scope(ActivationScope::HIDDEN); - - 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 divide(&self, rng: &mut impl rand::Rng) -> Self { - let mut child = self.clone(); - child.mutate(self.mutation_rate, rng); - child - } -} - -impl PartialEq for NeuralNetworkTopology { - fn eq(&self, other: &Self) -> bool { - if self.mutation_rate != other.mutation_rate - || self.mutation_passes != other.mutation_passes - { - return false; - } - - for i in 0..I { - if *self.input_layer[i].read().unwrap() != *other.input_layer[i].read().unwrap() { - return false; - } - } - - for i in 0..self.hidden_layers.len().min(other.hidden_layers.len()) { - if *self.hidden_layers[i].read().unwrap() != *other.hidden_layers[i].read().unwrap() { - return false; - } - } - - for i in 0..O { - if *self.output_layer[i].read().unwrap() != *other.output_layer[i].read().unwrap() { - return false; - } - } - - true - } -} - -#[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, - } - } -} - -#[cfg(feature = "crossover")] -impl CrossoverReproduction for NeuralNetworkTopology { - fn crossover(&self, other: &Self, rng: &mut impl rand::Rng) -> Self { - let input_layer = self - .input_layer - .iter() - .map(|n| Arc::new(RwLock::new(n.read().unwrap().clone()))) - .collect::>() - .try_into() - .unwrap(); - - let mut hidden_layers = - Vec::with_capacity(self.hidden_layers.len().max(other.hidden_layers.len())); - - for i in 0..hidden_layers.len() { - if rng.gen::() <= 0.5 { - if let Some(n) = self.hidden_layers.get(i) { - let mut n = n.read().unwrap().clone(); - - n.inputs - .retain(|(l, _)| input_exists(*l, &input_layer, &hidden_layers)); - hidden_layers[i] = Arc::new(RwLock::new(n)); - - continue; - } - } - - let mut n = other.hidden_layers[i].read().unwrap().clone(); - - n.inputs - .retain(|(l, _)| input_exists(*l, &input_layer, &hidden_layers)); - hidden_layers[i] = Arc::new(RwLock::new(n)); - } - - let mut output_layer: [Arc>; O] = self - .output_layer - .iter() - .map(|n| Arc::new(RwLock::new(n.read().unwrap().clone()))) - .collect::>() - .try_into() - .unwrap(); - - for (i, n) in self.output_layer.iter().enumerate() { - if rng.gen::() <= 0.5 { - let mut n = n.read().unwrap().clone(); - - n.inputs - .retain(|(l, _)| input_exists(*l, &input_layer, &hidden_layers)); - output_layer[i] = Arc::new(RwLock::new(n)); - - continue; - } - - let mut n = other.output_layer[i].read().unwrap().clone(); - - n.inputs - .retain(|(l, _)| input_exists(*l, &input_layer, &hidden_layers)); - output_layer[i] = Arc::new(RwLock::new(n)); - } - - let mut child = Self { - input_layer, - hidden_layers, - output_layer, - mutation_rate: self.mutation_rate, - mutation_passes: self.mutation_passes, - }; - - child.mutate(self.mutation_rate, rng); - - child - } -} - -#[cfg(feature = "crossover")] -fn input_exists( - loc: NeuronLocation, - input: &[Arc>; I], - hidden: &[Arc>], -) -> bool { - match loc { - NeuronLocation::Input(i) => i < input.len(), - NeuronLocation::Hidden(i) => i < hidden.len(), - NeuronLocation::Output(_) => false, - } -} - -/// A stateless version of [`Neuron`][crate::Neuron]. -#[derive(PartialEq, 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, - current_scope: ActivationScope, - rng: &mut impl Rng, - ) -> Self { - let reg = ACTIVATION_REGISTRY.read().unwrap(); - let activations = reg.activations_in_scope(current_scope); - - 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(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), - - /// Points to a neuron in the hidden layer at contained index. - Hidden(usize), - - /// Points to a neuron in the output layer at contained index. - Output(usize), -} - -impl NeuronLocation { - /// Returns `true` if it points to the input layer. Otherwise, returns `false`. - pub fn is_input(&self) -> bool { - matches!(self, Self::Input(_)) - } - - /// Returns `true` if it points to the hidden layer. Otherwise, returns `false`. - pub fn is_hidden(&self) -> bool { - matches!(self, Self::Hidden(_)) - } - - /// Returns `true` if it points to the output layer. Otherwise, returns `false`. - pub fn is_output(&self) -> bool { - matches!(self, Self::Output(_)) - } - - /// Retrieves the index value, regardless of layer. Does not consume. - pub fn unwrap(&self) -> usize { - match self { - Self::Input(i) => *i, - Self::Hidden(i) => *i, - Self::Output(i) => *i, - } - } -} diff --git a/src/topology/nnt_serde.rs b/src/topology/nnt_serde.rs deleted file mode 100644 index 14f392c..0000000 --- a/src/topology/nnt_serde.rs +++ /dev/null @@ -1,71 +0,0 @@ -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); -}