diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..6875019 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"0e42f8a2-416e-4d3f-9ea6-c2764947b5a3","pid":14267,"acquiredAt":1774543530483} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d55383b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [master, feature/*] + pull_request: + branches: [master] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt -- --check + + test: + name: Test (${{ matrix.os }}, ${{ matrix.toolchain }}) + needs: fmt + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + toolchain: [stable, beta] + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.toolchain }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --release --verbose + + - name: Run tests + run: cargo test --release --verbose diff --git a/.gitignore b/.gitignore index b7bca7a..4c13e78 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,13 @@ Cargo.lock # Fmt **/*.rs.bk + +# Local files +CURRENT_FEATURE.md +best_config.json +champion.json +champion_backup.json +experiment_results.json +plan.md +CLAUDE.md +AGENT.md diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0031a6c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -sudo: false -language: rust -rust: - - stable - - beta -os: - - linux - - osx -cache: cargo -before_cache: - - cargo prune -env: - global: - - PATH=$PATH:$HOME/.cargo/bin - - RUST_BACKTRACE=1 -before_script: - - | - (which cargo-install-update && cargo install-update cargo-update) || cargo install cargo-update && - (which cargo-prune && cargo install-update cargo-prune) || cargo install cargo-prune - - rustup component add rustfmt-preview - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export OPENSSL_INCLUDE_DIR=`brew --prefix openssl`/include; fi - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export OPENSSL_LIB_DIR=`brew --prefix openssl`/lib; fi - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export DEP_OPENSSL_INCLUDE=`brew --prefix openssl`/include; fi -script: - - if [ "${TRAVIS_RUST_VERSION}" = stable ]; then - ( - cargo fmt -- --check - ); - fi - - cargo test --release diff --git a/Cargo.toml b/Cargo.toml index e5a746b..a147369 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT" name = "rustneat" repository = "https://github.com/TLmaK0/rustneat" version = "0.3.0" +edition = "2018" [dependencies] conv = "0.3.2" @@ -20,18 +21,16 @@ rulinalg = "0.3.4" rusty_dashed = { version = "0.2.2", optional = true } open = { version = "1.2.1", optional = true } clippy = { version = "0.0.103", optional = true } -serde = { version = "1.0", optional = true } -serde_derive = { version = "1.0", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } serde_json = { version = "1.0", optional = true } -cpython = { version = "0.6", optional = true } -python3-sys = { version = "0.6", optional = true } +pyo3 = { version = "0.22", features = ["auto-initialize"], optional = true } ctrlc = { version = "3.1.4", optional = true } [features] default = [] -telemetry = ["rusty_dashed", "open", "serde", "serde_derive", "serde_json"] -openai = ["cpython", "python3-sys", "ctrlc"] +telemetry = ["rusty_dashed", "open", "serde", "serde_json"] +openai = ["pyo3", "ctrlc", "serde", "serde_json"] ctrnn_telemetry = [] [[example]] @@ -50,3 +49,7 @@ name = "function_approximation" [[example]] name = "ctrnn" + +[[example]] +name = "test_champion" +required-features = ["openai"] diff --git a/README.md b/README.md index c0657e1..80f32ca 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,87 @@ -Working-in-progress - ### Rust NEAT -[![travis-ci](https://img.shields.io/travis/TLmaK0/rustneat/master.svg)](https://travis-ci.org/TLmaK0/rustneat) +[![CI](https://github.com/TLmaK0/rustneat/actions/workflows/ci.yml/badge.svg)](https://github.com/TLmaK0/rustneat/actions/workflows/ci.yml) [![Gitter](https://img.shields.io/gitter/room/nwjs/nw.js.svg)](https://gitter.im/rustneat/rustneat) Implementation of **NeuroEvolution of Augmenting Topologies NEAT** http://nn.cs.utexas.edu/downloads/papers/stanley.ec02.pdf -This implementations uses a **CTRNN** based on**On the Dynamics of Small Continuous-Time Recurrent Neural Network** (Beer, 1995) http://www.cs.uvm.edu/~jbongard/2014_CS206/Beer_CTRNNs.pdf +This implementation uses a **CTRNN** (Continuous-Time Recurrent Neural Network) based on **On the Dynamics of Small Continuous-Time Recurrent Neural Network** (Beer, 1995) http://www.cs.uvm.edu/~jbongard/2014_CS206/Beer_CTRNNs.pdf + +### CTRNN time constant (τ) + +The time constant τ controls neuron response speed — like biological membrane resistance time. What matters is the ratio `dt/τ` where `dt=0.01` is the simulation step. Configurable via `MutationConfig::tau`: + +- **Small τ** (e.g. 0.01): `dt/τ = 1.0` — neurons react instantly, state resets each step. Network behaves as **feedforward**. Use for stateless problems like XOR. +- **Large τ** (e.g. 0.1): `dt/τ = 0.1` — neurons update only 10% per step, retaining 90% of previous state. Network has **temporal memory**. Use for control tasks like Lunar Lander where the agent integrates information over time. +- **Very large τ** (e.g. 1.0): `dt/τ = 0.01` — neurons barely respond, very strong inertia. Needs many steps to react to new inputs. + +## Telemetry Dashboard ![telemetry](docs/img/rustneat.png) -## Install Rust +```bash +cargo run --release --example simple_sample --features=telemetry +``` -`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` -`source $HOME/.cargo/env` +Then go to `http://localhost:3000` to see how the neural network evolves. -## Run test +![telemetry](docs/results/cart_pole_dashboard.gif) -To speed up tests, run them with `--release` (XOR classification/simple_sample should take less than a minute) +## Cart Pole -`cargo test --release` -## Run example +![cart pole](docs/results/cart_pole.gif) -`cargo run --release --example simple_sample --features=telemetry` +## Lunar Lander -then go to `http://localhost:3000` to see how neural network evolves +NEAT+CTRNN agent evolved to land on the OpenAI Gym LunarLander-v3 environment using discrete actions with independent threshold-based output control. -![telemetry](docs/results/cart_pole_dashboard.gif) +![lunar lander](docs/results/lunar_lander.gif) -## Run openai tests +### Results +- **Verified average reward**: +70 (fitness 570 with +500 offset) +- **Peak reward**: +278 (fitness 778) +- **Solved threshold**: average reward +200 -![telemetry](docs/results/cart_pole.gif) +The agent uses 2 independent CTRNN outputs (main thruster, lateral direction) with lateral priority — a key insight for CTRNN control problems where argmax causes state lock-in. -Install python dependencies +### Run ```bash -sudo apt install python3 -sudo apt install python3-pip -sudo apt install libpython3.8-dev -sudo apt-get remove swig -sudo apt-get install swig3.0 -sudo ln -s /usr/bin/swig3.0 /usr/bin/swig -sudo pip3 install gym -sudo pip3 install gym[box2d] -sudo apt install python3-opengl +cargo build --release --example openai_lunar_lander --features openai +./target/release/examples/openai_lunar_lander ``` -Run test +### Test champion +```bash +cargo build --release --example test_champion --features openai +./target/release/examples/test_champion ``` -cargo run --release --example openai --features=openai,telemetry -``` - -### Windows Openai -https://github.com/openai/gym/issues/11#issuecomment-242950165 +## Install Rust + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env ``` -Update to the latest version of Windows (>version 1607, "Anniversary Update") -Enable Windows Subsystem for Linux (WSL) -Open cmd, run bash -Install python & gym (using sudo, and NOT PIP to install gym). So by now you should probably be able to run things and get really nasty graphics related errors. This is because WSL doesn't support any displays, so we need to fake it. -Install vcXsrv, and run it (you should just have a little tray icon) -In bash run "export DISPLAY=:0" Now when you run it you should get a display to pop-up, there may be issues related to graphics drivers. Sadly, this is where the instructions diverge if you don't have an NVIDIA graphics card. -Get the drivers: "sudo apt-get install nvidia-319 nvidia-settings-319 nvidia-prime" -Run! + +## Run tests + +To speed up tests, run them with `--release` (XOR classification/simple_sample should take less than a minute): + +```bash +cargo test --release ``` ## Sample usage -Create a new cargo project: +Create a new cargo project and add rustneat to Cargo.toml: -Add rustneat to Cargo.toml -``` +```toml [dependencies] rustneat = "0.2.1" ``` -Then use the library i.e. to implement the above example, use the library as follows: +Then use the library to implement XOR classification: ```rust extern crate rustneat; @@ -117,23 +122,23 @@ fn main() { } println!("{:?}", champion.unwrap().genome); } - ``` -# Develop -Check style guidelines with: +## Develop -`rustup component add rustfmt-preview` -`cargo fmt` +Check style guidelines with: -# Thanks -Thanks for the icon nerves by Delwar Hossain from the Noun Project +```bash +rustup component add rustfmt-preview +cargo fmt +``` -# References: -**NeuroEvolution of Augmenting Topologies NEAT** http://nn.cs.utexas.edu/downloads/papers/stanley.ec02.pdf +## References -**On the Dynamics of Small Continuous-Time Recurrent Neural Network** (Beer, 1995) http://www.cs.uvm.edu/~jbongard/2014_CS206/Beer_CTRNNs.pdf +- **NeuroEvolution of Augmenting Topologies NEAT** http://nn.cs.utexas.edu/downloads/papers/stanley.ec02.pdf +- **On the Dynamics of Small Continuous-Time Recurrent Neural Network** (Beer, 1995) http://www.cs.uvm.edu/~jbongard/2014_CS206/Beer_CTRNNs.pdf +- **An Investigation into the Dynamics of a Continuous Time Recurrent Neural Network Node** http://www.tinyblueplanet.com/easy/FCSReport.pdf -**An Investigation into the Dynamics of a Continuous Time Recurrent Neural Network Node** http://www.tinyblueplanet.com/easy/FCSReport.pdf +## Thanks -**http://indy9000.github.io/posts/analysis-of-a-simple-ctrnn.html** http://indy9000.github.io/posts/analysis-of-a-simple-ctrnn.html +Thanks for the icon nerves by Delwar Hossain from the Noun Project diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..ec5047b --- /dev/null +++ b/build.rs @@ -0,0 +1,27 @@ +use std::process::Command; + +fn main() { + // Only run for the openai feature (which uses PyO3) + #[cfg(feature = "openai")] + { + // Get Python library path from python3-config + let output = Command::new("python3") + .args(&[ + "-c", + "import sysconfig; print(sysconfig.get_config_var('LIBDIR'))", + ]) + .output() + .expect("Failed to get Python library path"); + + let libdir = String::from_utf8(output.stdout) + .expect("Invalid UTF-8 from Python") + .trim() + .to_string(); + + // Tell the linker to add this path to rpath so the binary can find libpython at runtime + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", libdir); + + // Also add standard Python lib search path + println!("cargo:rustc-link-search=native={}", libdir); + } +} diff --git a/docs/results/lunar_lander.gif b/docs/results/lunar_lander.gif new file mode 100644 index 0000000..9fc8cf0 Binary files /dev/null and b/docs/results/lunar_lander.gif differ diff --git a/examples/function_approximation.rs b/examples/function_approximation.rs index 50fb80a..b11e972 100644 --- a/examples/function_approximation.rs +++ b/examples/function_approximation.rs @@ -15,6 +15,12 @@ use rustneat::Population; static mut BEST_FITNESS: f64 = 0.0; struct FunctionApproximation; +impl FunctionApproximation { + fn funct(&self, x: i64) -> i64 { + x.pow(3) - (3 * x) + 2 + } +} + impl Environment for FunctionApproximation { fn test(&self, organism: &mut Organism) -> f64 { let mut output = vec![0f64]; @@ -24,7 +30,7 @@ impl Environment for FunctionApproximation { for x in -10..11 { organism.activate(vec![x as f64 / 10f64], &mut output); - distance += ((x as f64).powf(2f64) - (output[0] * 100f64)).abs(); + distance += (self.funct(x) as f64 - (output[0] * 100f64)).abs(); outputs.push([x, (output[0] * 100f64) as i64]); } @@ -42,7 +48,7 @@ impl Environment for FunctionApproximation { fn main() { let mut population = Population::create_population(150); - let mut environment = FunctionApproximation; + let environment = FunctionApproximation; let mut champion: Option = None; #[cfg(feature = "telemetry")] @@ -55,7 +61,12 @@ fn main() { telemetry!( "approximation1", 1.0, - format!("{:?}", (-10..11).map(|x| [x, x * x]).collect::>()) + format!( + "{:?}", + (-10..11) + .map(|x| [x, environment.funct(x)]) + .collect::>() + ) ); #[cfg(feature = "telemetry")] @@ -63,7 +74,7 @@ fn main() { while champion.is_none() { population.evolve(); - population.evaluate_in(&mut environment); + population.evaluate_in(&environment); for organism in &population.get_organisms() { if organism.fitness >= 96f64 { champion = Some(organism.clone()); diff --git a/examples/lunar_lander_worker.py b/examples/lunar_lander_worker.py new file mode 100644 index 0000000..4bca0fd --- /dev/null +++ b/examples/lunar_lander_worker.py @@ -0,0 +1,101 @@ +import gymnasium as gym +import numpy as np +import sys + +# The rustneat_py module is passed as a global +rustneat_py = None + +# Persistent environment (one per worker process) +_worker_env = None + +def set_rustneat_module(module): + global rustneat_py + rustneat_py = module + +def init_worker(): + """Initialize worker-specific resources (called once per worker).""" + global _worker_env + # Create persistent environment for this worker + _worker_env = gym.make('LunarLander-v3', render_mode=None) + +def run_single_episode(organism, env, max_steps=500): + """Run a single episode and return (reward, behavior_descriptor).""" + # Reset CTRNN state at the start of each episode + organism.reset_state() + + reset_result = env.reset() + observation = reset_result[0] if isinstance(reset_result, tuple) else reset_result + total_reward = 0.0 + done = False + steps = 0 + action_counts = [0, 0, 0, 0] # noop, left, main, right + + while not done and steps < max_steps: + # Activate neural network (calls Rust implementation) + # 2 outputs: [main_desire, lateral_direction] + outputs = organism.activate(observation.tolist()) + + # Lateral priority action selection: + # - lateral < 0.33 → left (priority, main ignored) + # - lateral > 0.66 → right (priority, main ignored) + # - else: main > 0.5 → main + # - else: noop + lateral = outputs[1] + if lateral < 0.33: + action = 1 # left thruster + elif lateral > 0.66: + action = 3 # right thruster + elif outputs[0] > 0.5: + action = 2 # main thruster + else: + action = 0 # noop + + # Step environment + observation, reward, terminated, truncated, _ = env.step(action) + done = terminated or truncated + total_reward += reward + steps += 1 + action_counts[action] += 1 + + # Behavior descriptor: final state [x, y, vx, vy] + action distribution [noop%, left%, main%, right%] + total_actions = max(sum(action_counts), 1) + action_dist = [c / total_actions for c in action_counts] + behavior = [float(observation[0]), float(observation[1]), + float(observation[2]), float(observation[3])] + action_dist + + return total_reward, behavior + + +def evaluate_organism(genome_data, render=False, max_steps=400, num_episodes=1): + """Evaluate organism fitness using rustneat_py for neural network activation. + + Returns (fitness, behavior_descriptor) where behavior is the average final state. + """ + global _worker_env + + genes = genome_data['genes'] + neurons_len = genome_data['neurons_len'] + + # Create organism using rustneat_py (Rust implementation) + organism = rustneat_py.create_organism(genes, neurons_len) + + if render: + # For rendering, just run one episode with visualization + env = gym.make('LunarLander-v3', render_mode='human') + reward, behavior = run_single_episode(organism, env, max_steps) + env.close() + return reward + 500, behavior + else: + # Run multiple episodes and average for more stable fitness + total_reward = 0.0 + behaviors = [] + for _ in range(num_episodes): + reward, behavior = run_single_episode(organism, _worker_env, max_steps) + total_reward += reward + behaviors.append(behavior) + total_reward /= num_episodes + avg_behavior = np.mean(behaviors, axis=0).tolist() + + # Transform to positive fitness: Lunar Lander returns [-500, 300] + # Adding 500 shifts to [0, 800] which NEAT can handle properly + return total_reward + 500, avg_behavior diff --git a/examples/openai_lunar_lander.rs b/examples/openai_lunar_lander.rs index 67b392c..fe59b5b 100644 --- a/examples/openai_lunar_lander.rs +++ b/examples/openai_lunar_lander.rs @@ -1,142 +1,477 @@ -extern crate cpython; extern crate ctrlc; -extern crate python3_sys as ffi; +extern crate pyo3; +extern crate rand; extern crate rustneat; +extern crate serde; +extern crate serde_json; -use cpython::{NoArgs, ObjectProtocol, PyObject, Python}; -use ffi::PySys_SetArgv; -use rustneat::{Environment, Organism, Population}; -use std::cmp::Ordering; -use std::ffi::CString; +use pyo3::prelude::*; +use pyo3::types::{IntoPyDict, PyModule}; +use pyo3::PyResult; +use rustneat::{Environment, Gene, Genome, MutationConfig, Organism, Population}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; use std::process; -use std::{ - ops::{Deref, DerefMut}, - sync::Mutex, -}; + +/// Compute novelty score: average distance to k-nearest neighbors +fn compute_novelty(behavior: &[f64], all_behaviors: &[&Vec], k: usize) -> f64 { + let mut distances: Vec = all_behaviors + .iter() + .map(|other| { + behavior + .iter() + .zip(other.iter()) + .map(|(a, b)| (a - b).powi(2)) + .sum::() + .sqrt() + }) + .collect(); + + distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + // Skip distance=0 (self) and take k nearest + let nearest: Vec = distances + .iter() + .filter(|&&d| d > 1e-10) + .take(k) + .cloned() + .collect(); + + if nearest.is_empty() { + return 0.0; + } + + nearest.iter().sum::() / nearest.len() as f64 +} + +const CONFIG_FILE: &str = "best_config.json"; +const CHAMPION_FILE: &str = "champion.json"; + +/// Serializable champion genome +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChampionFile { + fitness: f64, + generation: usize, + neurons_len: usize, + genes: Vec<(usize, usize, f64, bool, bool)>, // (in, out, weight, enabled, bias) +} + +impl ChampionFile { + fn save(&self, path: &str) { + let content = serde_json::to_string_pretty(self).unwrap(); + fs::write(path, content).expect("Failed to save champion"); + println!("Champion saved to {}", path); + } + + fn from_organism(organism: &Organism, fitness: f64, generation: usize) -> Self { + let genes = organism + .genome + .get_genes() + .iter() + .map(|g| { + ( + g.in_neuron_id(), + g.out_neuron_id(), + g.weight(), + g.enabled(), + g.is_bias(), + ) + }) + .collect(); + ChampionFile { + fitness, + generation, + neurons_len: organism.genome.len(), + genes, + } + } +} + +/// Serializable configuration file +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConfigFile { + weight_mutation_rate: f64, + add_connection_rate: f64, + add_neuron_rate: f64, + toggle_expression_rate: f64, + weight_perturbation_rate: f64, + toggle_bias_rate: f64, + compatibility_threshold: f64, + #[serde(skip_serializing_if = "Option::is_none")] + best_fitness: Option, + #[serde(default = "default_weight_init_range")] + weight_init_range: f64, + #[serde(default = "default_weight_mutate_power")] + weight_mutate_power: f64, +} + +fn default_weight_init_range() -> f64 { + 3.0 +} +fn default_weight_mutate_power() -> f64 { + 1.5 +} + +impl ConfigFile { + fn load(path: &str) -> Option { + if Path::new(path).exists() { + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() + } else { + None + } + } + + fn to_mutation_config(&self) -> MutationConfig { + MutationConfig::new() + .weight_mutation_rate(self.weight_mutation_rate) + .add_connection_rate(self.add_connection_rate) + .add_neuron_rate(self.add_neuron_rate) + .toggle_expression_rate(self.toggle_expression_rate) + .weight_perturbation_rate(self.weight_perturbation_rate) + .toggle_bias_rate(self.toggle_bias_rate) + .compatibility_threshold(self.compatibility_threshold) + .weight_init_range(self.weight_init_range) + .weight_mutate_power(self.weight_mutate_power) + .tau(0.1) + .step_time(0.5) + .build() + } +} #[cfg(feature = "telemetry")] mod telemetry_helper; -struct LunarLander { - env: PyObject, +// ============================================================================ +// PyO3 Wrapper for rustneat - exposes Organism to Python workers +// ============================================================================ + +#[pyclass] +struct PyOrganism { + organism: Organism, +} + +#[pymethods] +impl PyOrganism { + fn activate(&mut self, inputs: Vec) -> PyResult> { + let mut outputs = vec![0.0; 2]; // [main_desire, lateral_direction] + self.organism.activate(inputs, &mut outputs); + Ok(outputs) + } + + fn reset_state(&mut self) { + self.organism.reset_state(); + } } +#[pyfunction] +fn create_organism( + genes: Vec<(usize, usize, f64, bool, bool)>, + neurons_len: usize, +) -> PyResult { + // Reconstruct genes from serialized data + let gene_vec: Vec = genes + .into_iter() + .map(|(in_id, out_id, weight, enabled, is_bias)| { + Gene::new(in_id, out_id, weight, enabled, is_bias) + }) + .collect(); + + // Reconstruct genome with proper neuron count (neurons_len = last_neuron_id + 1) + let last_neuron_id = if neurons_len > 0 { neurons_len - 1 } else { 0 }; + let genome = Genome::from_genes(gene_vec, last_neuron_id); + let organism = Organism::new(genome); + + Ok(PyOrganism { organism }) +} + +#[pymodule] +fn rustneat_py(m: &Bound) -> PyResult<()> { + m.add_class::()?; + m.add_function(wrap_pyfunction!(create_organism, m)?)?; + Ok(()) +} + +// ============================================================================ +// Lunar Lander Environment with Multiprocessing +// ============================================================================ + struct LunarLanderMultiprocess { - lunar_lander_pool: Mutex>, + pool: Py, + worker_module: Py, + /// Novelty archive: stores behavior descriptors from past evaluations + novelty_archive: std::sync::RwLock>>, + /// Latest behaviors from most recent batch evaluation + latest_behaviors: std::sync::RwLock>>, } +const NOVELTY_ARCHIVE_MAX: usize = 2000; +const NOVELTY_K: usize = 15; // k-nearest neighbors for novelty score +const NOVELTY_WEIGHT: f64 = 0.1; // 0.0 = pure fitness, 1.0 = pure novelty + impl LunarLanderMultiprocess { fn new() -> LunarLanderMultiprocess { - let cpus = LunarLanderMultiprocess::threads(); - let lunar_lander_pool = (0..cpus) - .map(|_| LunarLander::new()) - .collect::>(); - LunarLanderMultiprocess { - lunar_lander_pool: Mutex::new(lunar_lander_pool), - } + Python::with_gil(|py| { + let cpus = num_cpus::get(); + // Match workers to CPUs (CPU-bound Box2D simulation) + let workers = cpus; + + // Import multiprocessing + let mp = py.import_bound("multiprocessing").unwrap(); + + // Create the rustneat_py module inline + let module = PyModule::new_bound(py, "rustneat_py").unwrap(); + rustneat_py(&module).unwrap(); + let rustneat_py_module = module; + + // Load worker code from external Python file + let worker_code = include_str!("lunar_lander_worker.py"); + + // Create worker module + let worker_module = PyModule::from_code_bound( + py, + &worker_code, + "lunar_lander_worker.py", + "lunar_lander_worker", + ) + .unwrap(); + + // Inject rustneat_py module into worker + worker_module + .call_method1("set_rustneat_module", (rustneat_py_module,)) + .unwrap(); + + // Get the init_worker function for pool initialization + let init_worker_fn = worker_module.getattr("init_worker").unwrap(); + + // Create multiprocessing pool with 2x workers and initializer + println!( + "Creating pool with {} workers (1x CPUs) with persistent environments", + workers + ); + let pool = mp + .call_method( + "Pool", + (workers,), + Some(&[("initializer", init_worker_fn)].into_py_dict_bound(py)), + ) + .unwrap() + .unbind(); + + LunarLanderMultiprocess { + pool, + worker_module: worker_module.unbind().into(), + novelty_archive: std::sync::RwLock::new(Vec::new()), + latest_behaviors: std::sync::RwLock::new(Vec::new()), + } + }) } pub fn lunar_lander_test(&self, organism: &mut Organism, render: bool) -> f64 { - let lunar_lander = { - self.lunar_lander_pool - .lock() - .unwrap() - .deref_mut() - .pop() - .unwrap() - }; - let result = lunar_lander.lunar_lander_test(organism, render); - self.lunar_lander_pool.lock().unwrap().push(lunar_lander); - return result; + let (fitness, _behavior) = self.lunar_lander_test_with_behavior(organism, render); + fitness + } + + pub fn lunar_lander_test_with_behavior( + &self, + organism: &mut Organism, + render: bool, + ) -> (f64, Vec) { + Python::with_gil(|py| { + let neurons_len = organism.genome.len(); + + let genes_list = organism + .genome + .get_genes() + .iter() + .map(|gene| { + ( + gene.in_neuron_id(), + gene.out_neuron_id(), + gene.weight(), + gene.enabled(), + gene.is_bias(), + ) + }) + .collect::>(); + + let builtins = py.import_bound("builtins").unwrap(); + let dict = builtins.call_method0("dict").unwrap(); + dict.set_item("genes", genes_list).unwrap(); + dict.set_item("neurons_len", neurons_len).unwrap(); + + let worker_fn = self + .worker_module + .bind(py) + .getattr("evaluate_organism") + .unwrap(); + let pool = self.pool.bind(py); + let async_result = pool + .call_method1("apply", (worker_fn, (dict, render))) + .unwrap(); + + let result: (f64, Vec) = async_result.extract().unwrap(); + result + }) } fn close(&self) { - for lunar_lander in self.lunar_lander_pool.lock().unwrap().deref() { - lunar_lander.close(); - } + Python::with_gil(|py| { + let pool = self.pool.bind(py); + pool.call_method0("close").unwrap(); + pool.call_method0("join").unwrap(); + }); } fn threads() -> usize { - //TODO: use multiple processes. Gil still blocking threads. num_cpus::get() } } +const EVALS_PER_ORGANISM: usize = 1; + impl Environment for LunarLanderMultiprocess { fn test(&self, organism: &mut Organism) -> f64 { self.lunar_lander_test(organism, false) } - fn threads(&self) -> usize { - LunarLanderMultiprocess::threads() - } -} + fn test_batch(&self, organisms: &mut [Organism]) { + if organisms.is_empty() { + return; + } -impl LunarLander { - fn new() -> LunarLander { - let gil = Python::acquire_gil(); - let argv = CString::new("").unwrap().as_ptr(); + // Collect indices of organisms that need evaluation (skip elite copies) + let to_evaluate: Vec = organisms + .iter() + .enumerate() + .filter(|(_, org)| !org.preserve_fitness) + .map(|(i, _)| i) + .collect(); - unsafe { - PySys_SetArgv(0, argv as *mut *mut i32); + if to_evaluate.is_empty() { + return; } - let py = gil.python(); - let gym = py.import("gym").unwrap(); - gym.get(py, "logger") - .unwrap() - .call_method(py, "set_level", (40,), None) - .unwrap(); + Python::with_gil(|py| { + let mut batch_data: Vec<_> = Vec::with_capacity(to_evaluate.len() * EVALS_PER_ORGANISM); - let env = gym.call(py, "make", ("LunarLander-v2",), None).unwrap(); + for &idx in &to_evaluate { + let organism = &organisms[idx]; + let neurons_len = organism.genome.len(); + let genes_list = organism + .genome + .get_genes() + .iter() + .map(|gene| { + ( + gene.in_neuron_id(), + gene.out_neuron_id(), + gene.weight(), + gene.enabled(), + gene.is_bias(), + ) + }) + .collect::>(); - LunarLander { env: env } - } + for _ in 0..EVALS_PER_ORGANISM { + let builtins = py.import_bound("builtins").unwrap(); + let dict = builtins.call_method0("dict").unwrap(); + dict.set_item("genes", &genes_list).unwrap(); + dict.set_item("neurons_len", neurons_len).unwrap(); + batch_data.push((dict, false)); + } + } - pub fn close(&self) { - let gil = Python::acquire_gil(); - let py = gil.python(); - self.env.call_method(py, "close", NoArgs, None).unwrap(); - } + let worker_fn = self + .worker_module + .bind(py) + .getattr("evaluate_organism") + .unwrap(); + let pool = self.pool.bind(py); - pub fn lunar_lander_test(&self, organism: &mut Organism, render: bool) -> f64 { - let gil = Python::acquire_gil(); - let py = gil.python(); + // Extract (fitness, behavior) tuples + let results = pool + .call_method1("starmap", (worker_fn, batch_data)) + .unwrap(); + let results_list: Vec<(f64, Vec)> = results.extract().unwrap(); - let mut observation: Vec = - extract_observation(py, self.env.call_method(py, "reset", NoArgs, None).unwrap()); - let mut total_reward = 0f64; + // Collect behaviors and fitness + let mut batch_behaviors: Vec> = Vec::new(); + let mut fitness_values: Vec = Vec::new(); - let mut output = vec![0f64, 0f64, 0f64, 0f64]; - while { - if render { - self.env.call_method(py, "render", NoArgs, None).unwrap(); + for (i, &idx) in to_evaluate.iter().enumerate() { + let start = i * EVALS_PER_ORGANISM; + let mut avg_fitness = 0.0; + let mut avg_behavior = vec![0.0; 8]; + for j in start..start + EVALS_PER_ORGANISM { + avg_fitness += results_list[j].0; + for (k, v) in results_list[j].1.iter().enumerate() { + if k < avg_behavior.len() { + avg_behavior[k] += v; + } + } + } + avg_fitness /= EVALS_PER_ORGANISM as f64; + for v in &mut avg_behavior { + *v /= EVALS_PER_ORGANISM as f64; + } + + fitness_values.push(avg_fitness); + batch_behaviors.push(avg_behavior); + // Store raw fitness first, novelty adjustment below + organisms[idx].fitness = avg_fitness; } - let out_exp = output.clone().into_iter().map(|v| v.exp()); - let sum_exp: f64 = out_exp.clone().sum(); - let softmax = out_exp.map(|e| e / sum_exp); - let value = softmax - .enumerate() - .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Equal)) - .map(|(index, _)| index) - .unwrap(); - organism.activate(observation.clone(), &mut output); - let (observation_tmp, reward, done) = extract_step_result( - py, - self.env.call_method(py, "step", (value,), None).unwrap(), - ); - observation = observation_tmp; - total_reward += reward; + // Compute novelty scores using k-nearest neighbors + let archive = self.novelty_archive.read().unwrap(); + let all_behaviors: Vec<&Vec> = + archive.iter().chain(batch_behaviors.iter()).collect(); + + if all_behaviors.len() > NOVELTY_K { + for (i, &idx) in to_evaluate.iter().enumerate() { + let behavior = &batch_behaviors[i]; + let novelty = compute_novelty(behavior, &all_behaviors, NOVELTY_K); + let raw_fitness = fitness_values[i]; + + // Combined fitness: blend raw fitness with novelty + // Normalize novelty to similar scale as fitness (~0-800) + let novelty_scaled = novelty * 200.0; // scale factor + organisms[idx].fitness = + (1.0 - NOVELTY_WEIGHT) * raw_fitness + NOVELTY_WEIGHT * novelty_scaled; + } + } + + // Add behaviors to archive (randomly sample to keep bounded) + drop(archive); + let mut archive = self.novelty_archive.write().unwrap(); + for behavior in &batch_behaviors { + if archive.len() < NOVELTY_ARCHIVE_MAX { + archive.push(behavior.clone()); + } else { + // Replace random entry + let idx = rand::random::() % NOVELTY_ARCHIVE_MAX; + archive[idx] = behavior.clone(); + } + } - !done - } {} - total_reward + // Store latest behaviors for logging + *self.latest_behaviors.write().unwrap() = batch_behaviors; + }); + } + + fn threads(&self) -> usize { + LunarLanderMultiprocess::threads() } } +// ============================================================================ +// Main +// ============================================================================ + fn main() { - let max_fitness = 300f64; + // Fitness is offset by +500 (Lunar Lander returns [-500,300] -> [0,800]) + let max_fitness = 800f64; #[allow(unused_must_use)] { @@ -149,22 +484,155 @@ fn main() { #[cfg(feature = "telemetry")] telemetry_helper::enable_telemetry(format!("?max_fitness={}", max_fitness).as_str(), true); - let mut population = Population::create_population_initialized(150, 8, 4); - let mut environment = LunarLanderMultiprocess::new(); + // Load config from file or use defaults + let mut population = if let Some(config_file) = ConfigFile::load(CONFIG_FILE) { + println!("Loaded config from {}", CONFIG_FILE); + println!( + " add_connection_rate: {:.4}", + config_file.add_connection_rate + ); + println!(" add_neuron_rate: {:.4}", config_file.add_neuron_rate); + println!( + " weight_mutation_rate: {:.4}", + config_file.weight_mutation_rate + ); + if let Some(fitness) = config_file.best_fitness { + println!(" (tuned with fitness: {:.2})", fitness); + } + println!(); + let config = config_file.to_mutation_config(); + println!(); + Population::create_population_initialized_with_config(150, 8, 2, config) + } else { + println!("No {} found, using default config\n", CONFIG_FILE); + let mut config = MutationConfig::default(); + config.add_connection_rate = 0.20; + config.add_neuron_rate = 0.10; + config.weight_mutation_rate = 0.80; + config.toggle_expression_rate = 0.005; + config.compatibility_threshold = 1.5; + config.weight_init_range = 3.0; + config.weight_mutate_power = 1.5; + config.tau = 0.1; + config.step_time = 0.5; + Population::create_population_initialized_with_config(150, 8, 2, config) + }; + + let environment = LunarLanderMultiprocess::new(); let mut champion: Option = None; let mut generations = 0; + let mut best_fitness = 300.0; // Typical starting fitness (-200 + 500 offset) + let mut last_verified_fitness = 0.0; // Track last fitness we verified to avoid re-verifying same champion + let improvement_threshold = 20.0; // Show render when fitness improves by at least 20 points + + println!("Starting evolution..."); + println!("Target fitness: {}", max_fitness); + println!( + "Improvement threshold for rendering: +{} points\n", + improvement_threshold + ); + + // Performance tracking + use std::time::Instant; + let start_time = Instant::now(); + let mut last_stats_time = Instant::now(); + let mut last_stats_gen = 0; + while champion.is_none() { population.evolve(); - population.evaluate_in(&mut environment); + population.evaluate_in(&environment); + generations += 1; + + // Show performance stats every 50 generations + if generations % 50 == 0 { + let elapsed = last_stats_time.elapsed().as_secs_f64(); + let gens_since_last = generations - last_stats_gen; + let gen_per_sec = gens_since_last as f64 / elapsed; + let total_elapsed = start_time.elapsed().as_secs_f64(); + let avg_gen_per_sec = generations as f64 / total_elapsed; + + println!( + "\n[Performance] Gen {}: {:.1} gen/s (avg: {:.1} gen/s, total time: {:.1}min)", + generations, + gen_per_sec, + avg_gen_per_sec, + total_elapsed / 60.0 + ); + + last_stats_time = Instant::now(); + last_stats_gen = generations; + } + + // Log adaptive mutation status every 50 generations + if generations % 50 == 0 && population.epochs_without_improvements() > 10 { + println!( + "[Adaptive] Population stagnant for {} epochs, mutation rates boosted", + population.epochs_without_improvements() + ); + } + match population.champion { Some(_) => { let tmp_champion = population.champion.clone().unwrap(); - if generations == 30 { - environment.lunar_lander_test(&mut tmp_champion.clone(), true); - generations = 0; + let current_fitness = tmp_champion.fitness; + + // Print progress every generation + println!( + "Gen {}: Best fitness = {:.2} (species: {})", + generations, + current_fitness, + population.species.len() + ); + + // Only verify when fitness exceeds a meaningful threshold AND champion has changed + // With +500 offset: >550 is decent, >600 is good, >700 is landing + if current_fitness > 550.0 + && current_fitness > best_fitness + improvement_threshold + && current_fitness > last_verified_fitness + { + last_verified_fitness = current_fitness; // Mark as verified + + println!( + "\n=== POTENTIAL IMPROVEMENT: {:.2} -> {:.2} (+{:.2}) ===", + best_fitness, + current_fitness, + current_fitness - best_fitness + ); + println!("Verifying with 5 additional tests..."); + + // Evaluate multiple times to verify it's not just luck + let mut total_fitness = current_fitness; + for i in 1..=5 { + let test_fitness = + environment.lunar_lander_test(&mut tmp_champion.clone(), false); + println!(" Test {}/5: {:.2}", i, test_fitness); + total_fitness += test_fitness; + } + let average_fitness = total_fitness / 6.0; + + println!("Average fitness: {:.2}", average_fitness); + + // Only count as real improvement if average is better + if average_fitness > best_fitness + improvement_threshold { + println!("✓ CONFIRMED IMPROVEMENT! Rendering best attempt...\n"); + environment.lunar_lander_test(&mut tmp_champion.clone(), true); + best_fitness = average_fitness; + + // Save champion genome + let champion_data = ChampionFile::from_organism( + &tmp_champion, + current_fitness, + generations, + ); + champion_data.save(CHAMPION_FILE); + } else { + println!("✗ Not consistent enough. Continuing evolution...\n"); + } } if tmp_champion.fitness >= max_fitness { + println!("\n=== TARGET FITNESS REACHED! ==="); + println!("Verifying with second test...\n"); //check again. At least 2 successfully landing if environment.lunar_lander_test(&mut tmp_champion.clone(), true) >= max_fitness { @@ -172,9 +640,10 @@ fn main() { } } } - None => {} + None => { + println!("Gen {}: No champion yet", generations); + } } - generations += 1; } let result = champion.unwrap(); @@ -182,20 +651,3 @@ fn main() { println!("{:?}", result.genome); environment.close(); } - -fn extract_step_result(py: Python, object: PyObject) -> (Vec, f64, bool) { - ( - extract_observation(py, object.get_item(py, 0).unwrap()), - object.get_item(py, 1).unwrap().extract::(py).unwrap(), - object.get_item(py, 2).unwrap().extract::(py).unwrap(), - ) -} - -fn extract_observation(py: Python, object: PyObject) -> Vec { - let mut vec: Vec = Vec::new(); - // TODO: should be a better way to do this - for n in 0..8 { - vec.push(object.get_item(py, n).unwrap().extract::(py).unwrap()); - } - vec -} diff --git a/examples/simple_sample.rs b/examples/simple_sample.rs index 3e6f524..b0db99d 100644 --- a/examples/simple_sample.rs +++ b/examples/simple_sample.rs @@ -23,9 +23,7 @@ impl Environment for XORClassification { organism.activate(vec![1f64, 1f64], &mut output); distance += (0f64 - output[0]).powi(2); - let fitness = 16f64 / (1f64 + distance); - - fitness + 16f64 / (1f64 + distance) } } @@ -37,11 +35,11 @@ fn main() { std::thread::sleep(std::time::Duration::from_millis(2000)); let mut population = Population::create_population(150); - let mut environment = XORClassification; + let environment = XORClassification; let mut champion: Option = None; while champion.is_none() { population.evolve(); - population.evaluate_in(&mut environment); + population.evaluate_in(&environment); for organism in &population.get_organisms() { if organism.fitness > 15.5f64 { champion = Some(organism.clone()); diff --git a/examples/telemetry_helper/mod.rs b/examples/telemetry_helper/mod.rs index 448f386..ea4b1ef 100644 --- a/examples/telemetry_helper/mod.rs +++ b/examples/telemetry_helper/mod.rs @@ -17,7 +17,7 @@ pub fn enable_telemetry(query_string: &str, open: bool) { dashboard.add_graph("fitness1", "fitness", 0, 0, 4, 4); dashboard.add_graph("network1", "network", 4, 0, 4, 4); dashboard.add_graph("species1", "species", 0, 4, 4, 4); - dashboard.add_graph("approximation1", "approximation", 4, 4, 2, 2); + dashboard.add_graph("approximation1", "approximation", 4, 4, 4, 4); run_server(dashboard, query_string, open); } diff --git a/examples/test_champion.rs b/examples/test_champion.rs new file mode 100644 index 0000000..99a41fb --- /dev/null +++ b/examples/test_champion.rs @@ -0,0 +1,211 @@ +extern crate pyo3; +extern crate rustneat; +extern crate serde; +extern crate serde_json; + +use pyo3::prelude::*; +use pyo3::types::IntoPyDict; +use rustneat::{Gene, Genome, Organism}; +use serde::Deserialize; +use std::fs; + +const CHAMPION_FILE: &str = "champion.json"; + +#[derive(Debug, Deserialize)] +struct ChampionFile { + fitness: f64, + generation: usize, + neurons_len: usize, + genes: Vec<(usize, usize, f64, bool, bool)>, +} + +impl ChampionFile { + fn load(path: &str) -> Option { + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() + } + + fn to_organism(&self) -> Organism { + let gene_vec: Vec = self + .genes + .iter() + .map(|(i, o, w, e, b)| Gene::new(*i, *o, *w, *e, *b)) + .collect(); + let last_neuron_id = if self.neurons_len > 0 { + self.neurons_len - 1 + } else { + 0 + }; + let genome = Genome::from_genes(gene_vec, last_neuron_id); + let mut org = Organism::new(genome); + org.tau = 0.1; + org.step_time = 0.5; + org + } +} + +fn run_episode( + organism: &mut Organism, + py: Python, + gym: &Bound, + render_mode: &str, +) -> (f64, Vec) { + let kwargs = [("render_mode", render_mode)].into_py_dict_bound(py); + let env = gym + .call_method("make", ("LunarLander-v3",), Some(&kwargs)) + .expect("Failed to create environment"); + + let reset_result = env.call_method0("reset").unwrap(); + let observation: Vec = reset_result.get_item(0).unwrap().extract().unwrap(); + + organism.reset_state(); + let mut obs = observation; + let mut done = false; + let mut episode_reward = 0.0; + let mut steps = 0; + let max_steps = 500; + let mut frames: Vec = Vec::new(); + + let record = render_mode == "rgb_array"; + + while !done && steps < max_steps { + if record { + let frame = env.call_method0("render").unwrap(); + frames.push(frame.unbind()); + } + + let mut outputs = vec![0.0f64; 2]; + organism.activate(obs.clone(), &mut outputs); + + let lateral = outputs[1]; + let action = if lateral < 0.33 { + 1 + } else if lateral > 0.66 { + 3 + } else if outputs[0] > 0.5 { + 2 + } else { + 0 + }; + + let step_result = env.call_method1("step", (action,)).unwrap(); + obs = step_result.get_item(0).unwrap().extract().unwrap(); + let reward: f64 = step_result.get_item(1).unwrap().extract().unwrap(); + let terminated: bool = step_result.get_item(2).unwrap().extract().unwrap(); + let truncated: bool = step_result.get_item(3).unwrap().extract().unwrap(); + + episode_reward += reward; + done = terminated || truncated; + steps += 1; + } + + if record { + let frame = env.call_method0("render").unwrap(); + frames.push(frame.unbind()); + } + + env.call_method0("close").ok(); + (episode_reward, frames) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let record_mode = args.iter().any(|a| a == "--record"); + + println!("Loading champion from {}...", CHAMPION_FILE); + + let champion_data = ChampionFile::load(CHAMPION_FILE).expect("Failed to load champion.json"); + + println!("Champion info:"); + println!(" Fitness: {:.2}", champion_data.fitness); + println!(" Generation: {}", champion_data.generation); + println!(" Neurons: {}", champion_data.neurons_len); + println!(" Genes: {}", champion_data.genes.len()); + println!(); + + let mut organism = champion_data.to_organism(); + + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let gym = py + .import_bound("gymnasium") + .expect("Failed to import gymnasium"); + + if record_mode { + // Record mode: run multiple attempts, save best as GIF + println!("Recording mode: running 20 attempts...\n"); + let mut best_reward = f64::NEG_INFINITY; + let mut best_frames: Vec = Vec::new(); + + for i in 1..=20 { + let (reward, frames) = run_episode(&mut organism, py, &gym, "rgb_array"); + println!(" Attempt {}: reward = {:.2}", i, reward); + if reward > best_reward { + best_reward = reward; + best_frames = frames; + } + } + + println!( + "\nBest reward: {:.2} ({} frames)", + best_reward, + best_frames.len() + ); + println!("Saving GIF..."); + + let pil = py.import_bound("PIL.Image").expect("Failed to import PIL"); + let mut images: Vec = Vec::new(); + for frame in &best_frames { + let img = pil.call_method1("fromarray", (frame.bind(py),)).unwrap(); + images.push(img.unbind()); + } + + if !images.is_empty() { + let first = images[0].bind(py); + let rest: Vec<&Bound> = images[1..].iter().map(|i| i.bind(py)).collect(); + let save_kwargs = [ + ("save_all", true.into_py(py).into_bound(py)), + ( + "append_images", + pyo3::types::PyList::new_bound(py, &rest).into_any(), + ), + ("duration", 33i32.into_py(py).into_bound(py)), + ("loop", 0i32.into_py(py).into_bound(py)), + ] + .into_py_dict_bound(py); + + let output_path = "docs/results/lunar_lander.gif"; + first + .call_method("save", (output_path,), Some(&save_kwargs)) + .unwrap(); + println!("Saved to {}", output_path); + } + } else { + // Normal test mode + println!("Running 10 test episodes...\n"); + let mut total_reward = 0.0; + let num_tests = 10; + + for i in 1..=num_tests { + let (reward, _) = run_episode(&mut organism, py, &gym, ""); + let fitness = reward + 500.0; + total_reward += fitness; + println!( + "Test {}/{}: Fitness = {:.2} (reward = {:.2})", + i, num_tests, fitness, reward + ); + } + + let avg_fitness = total_reward / num_tests as f64; + println!("\n=== RESULTS ==="); + println!("Average fitness: {:.2}", avg_fitness); + println!("Stored fitness: {:.2}", champion_data.fitness); + + // Visual demo + println!("\nRunning visual demo..."); + let (reward, _) = run_episode(&mut organism, py, &gym, "human"); + println!("Visual demo fitness: {:.2}", reward + 500.0); + } + }); +} diff --git a/graphs/approximation.js b/graphs/approximation.js index 0d83717..35aa022 100644 --- a/graphs/approximation.js +++ b/graphs/approximation.js @@ -2,11 +2,13 @@ var svg, paths = []; var lineChart; +var register = true; + function approximation_init(id){ svg = d3.select('#' + id).append('svg'); var width = $('#' + id).children().width() / 2; var rangeX = [-width, width]; - var height = $('#' + id).children().height(); + var height = $('#' + id).children().height() / 2; var rangeY = [0, height]; for(var n=0; n<10; n++ ) { @@ -32,6 +34,12 @@ function approximation(id, value){ functionApproximations.push(value); + if (!register) return; + else { + register = false; + setTimeout(function() { register = true; }, 2000); + } + paths[0].attr("d", lineChart(functionToApproximate)); for(var n=0; n < functionApproximations.length; n++ ){ diff --git a/src/environment.rs b/src/environment.rs index 68a551e..572e5f5 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -1,14 +1,65 @@ -use organism::Organism; +use crate::organism::Organism; /// A trait that is implemented by user to allow test of the Environment. +/// +/// # Usage +/// +/// There are two ways to implement this trait: +/// +/// ## Option 1: Individual evaluation (simple) +/// Override `test()` to evaluate one organism at a time: +/// ```ignore +/// impl Environment for MyEnv { +/// fn test(&self, organism: &mut Organism) -> f64 { +/// // evaluate and return fitness +/// } +/// } +/// ``` +/// +/// ## Option 2: Batch evaluation (efficient for vectorized environments) +/// Override `test_batch()` to evaluate all organisms together, useful for +/// GPU acceleration or vectorized simulators like gymnasium's VectorEnv: +/// ```ignore +/// impl Environment for MyVectorEnv { +/// fn test_batch(&self, organisms: &mut [Organism]) { +/// // Evaluate all organisms in parallel using VectorEnv +/// // Set organism.fitness for each +/// } +/// } +/// ``` pub trait Environment: Sync { - /// This test will return the value required by this enviroment to test - /// against - fn test(&self, organism: &mut Organism) -> f64; + /// Evaluate a single organism and return its fitness. + /// + /// Override this method for simple sequential evaluation. + /// If you only use `test_batch()`, you don't need to override this. + fn test(&self, _organism: &mut Organism) -> f64 { + unimplemented!( + "Override test() for individual evaluation or test_batch() for batch evaluation" + ) + } + + /// Batch evaluation of multiple organisms. + /// + /// Default implementation calls `test()` sequentially. + /// Override this method for efficient batch evaluation, e.g., using + /// gymnasium's VectorEnv to run multiple environments in parallel. + /// + /// When overriding, you must set `organism.fitness` for each organism. + /// Note: Skip organisms with `preserve_fitness = true` (elite copies). + fn test_batch(&self, organisms: &mut [Organism]) { + for organism in organisms.iter_mut() { + if !organism.preserve_fitness { + organism.fitness = self.test(organism); + } + } + } - /// Returns the number of threads to use on evaluation. - /// Implement this method to use single thread environment returning 1. + /// Returns the number of threads to use for evaluation. + /// + /// When threads > 1, organisms are divided into chunks and each chunk + /// is passed to `test_batch()` in a separate thread. + /// Return 1 for single-threaded evaluation (all organisms in one batch). fn threads(&self) -> usize { - return num_cpus::get(); + num_cpus::get() } } diff --git a/src/gene.rs b/src/gene.rs index 5fcbce9..664e449 100644 --- a/src/gene.rs +++ b/src/gene.rs @@ -73,10 +73,15 @@ impl Gene { } } - /// Generate a weight + /// Generate a weight in [-1, 1] pub fn generate_weight() -> f64 { rand::random::>().0 * 2f64 - 1f64 } + + /// Generate a weight in [-range, range] + pub fn generate_weight_in_range(range: f64) -> f64 { + rand::random::>().0 * 2.0 * range - range + } /// Connection in -> pub fn in_neuron_id(&self) -> usize { self.in_neuron_id diff --git a/src/genome.rs b/src/genome.rs index b840034..8f97c7a 100644 --- a/src/genome.rs +++ b/src/genome.rs @@ -1,5 +1,5 @@ -use gene::Gene; -use mutation::Mutation; +use crate::gene::Gene; +use crate::mutation::Mutation; use rand::{self, Closed01}; use std::cmp; @@ -11,14 +11,32 @@ pub struct Genome { last_neuron_id: usize, } -const MUTATE_CONNECTION_WEIGHT: f64 = 0.90f64; -const MUTATE_ADD_CONNECTION: f64 = 0.005f64; -const MUTATE_ADD_NEURON: f64 = 0.004f64; -const MUTATE_TOGGLE_EXPRESSION: f64 = 0.001f64; -const MUTATE_CONNECTION_WEIGHT_PERTURBED_PROBABILITY: f64 = 0.90f64; -const MUTATE_TOGGLE_BIAS: f64 = 0.01; +pub(crate) const MUTATE_CONNECTION_WEIGHT: f64 = 0.90f64; +pub(crate) const MUTATE_ADD_CONNECTION: f64 = 0.005f64; +pub(crate) const MUTATE_ADD_NEURON: f64 = 0.004f64; +pub(crate) const MUTATE_TOGGLE_EXPRESSION: f64 = 0.001f64; +pub(crate) const MUTATE_CONNECTION_WEIGHT_PERTURBED_PROBABILITY: f64 = 0.90f64; +pub(crate) const MUTATE_TOGGLE_BIAS: f64 = 0.01; +pub(crate) const COMPATIBILITY_THRESHOLD: f64 = 3f64; impl Genome { + /// Create a genome from serialized gene data + pub fn from_genes(genes: Vec, last_neuron_id: usize) -> Genome { + let mut genome = Genome { + genes: Vec::new(), + last_neuron_id, + }; + for gene in genes { + // Directly add gene without validation since we're reconstructing + match genome.genes.binary_search(&gene) { + Ok(pos) => genome.genes[pos].set_enabled(), + Err(_) => genome.genes.push(gene), + } + } + genome.genes.sort(); + genome + } + ///Add initial input and output neurons interconnected pub fn new_initialized(input_neurons: usize, output_neurons: usize) -> Genome { let mut genome = Genome::default(); @@ -30,26 +48,44 @@ impl Genome { genome } + /// Create genome with input/output neuron IDs but NO connections. + /// NEAT will discover connections through mutation. + pub fn new_unconnected(input_neurons: usize, output_neurons: usize) -> Genome { + Genome { + genes: Vec::new(), + last_neuron_id: if input_neurons + output_neurons > 0 { + input_neurons + output_neurons - 1 + } else { + 0 + }, + } + } + /// May add a connection &| neuron &| mutat connection weight &| /// enable/disable connection pub fn mutate(&mut self) { - if rand::random::>().0 < MUTATE_ADD_CONNECTION || self.genes.is_empty() { - self.mutate_add_connection(); + self.mutate_with_config(&crate::mutation_config::MutationConfig::default()); + } + + /// Mutate using specific mutation rates from config + pub fn mutate_with_config(&mut self, config: &crate::mutation_config::MutationConfig) { + if rand::random::>().0 < config.add_connection_rate || self.genes.is_empty() { + self.mutate_add_connection_with_config(config); }; - if rand::random::>().0 < MUTATE_ADD_NEURON { + if rand::random::>().0 < config.add_neuron_rate { self.mutate_add_neuron(); }; - if rand::random::>().0 < MUTATE_CONNECTION_WEIGHT { - self.mutate_connection_weight(); + if rand::random::>().0 < config.weight_mutation_rate { + self.mutate_connection_weight_with_config(config); }; - if rand::random::>().0 < MUTATE_TOGGLE_EXPRESSION { + if rand::random::>().0 < config.toggle_expression_rate { self.mutate_toggle_expression(); }; - if rand::random::>().0 < MUTATE_TOGGLE_BIAS { + if rand::random::>().0 < config.toggle_bias_rate { self.mutate_toggle_bias(); }; } @@ -65,18 +101,48 @@ impl Genome { fn mate_genes(&self, other: &Genome) -> Genome { let mut genome = Genome::default(); + // NEAT paper: 40% of crossovers use average weights for matching genes + let use_average_weights = rand::random::() < 0.4; + for gene in &self.genes { - genome.add_gene({ - //Only mate half of the genes randomly - if rand::random::() > 0.5f64 { + let mut child_gene = match other.genes.binary_search(gene) { + Ok(position) => { + // Matching gene in both parents + if use_average_weights { + // Inherit average weight from both parents + let mut avg_gene = *gene; + avg_gene.set_weight((gene.weight() + other.genes[position].weight()) / 2.0); + avg_gene + } else if rand::random::() > 0.5 { + *gene + } else { + other.genes[position] + } + } + Err(_) => { + // Disjoint/excess gene: inherit from fitter parent (self) *gene + } + }; + + // NEAT rule: if gene is disabled in either parent, 25% chance offspring has it disabled + // (reduced from 75% to allow more gene reactivation) + let other_gene_disabled = other + .genes + .binary_search(gene) + .ok() + .map(|pos| !other.genes[pos].enabled()) + .unwrap_or(false); + + if !gene.enabled() || other_gene_disabled { + if rand::random::() < 0.25 { + child_gene.set_disabled(); } else { - match other.genes.binary_search(gene) { - Ok(position) => other.genes[position], - Err(_) => *gene, - } + child_gene.set_enabled(); } - }); + } + + genome.add_gene(child_gene); } genome } @@ -101,6 +167,13 @@ impl Genome { } fn mutate_add_connection(&mut self) { + self.mutate_add_connection_with_config(&crate::mutation_config::MutationConfig::default()); + } + + fn mutate_add_connection_with_config( + &mut self, + config: &crate::mutation_config::MutationConfig, + ) { let mut rng = rand::thread_rng(); let neuron_ids_to_connect = { if self.last_neuron_id == 0 { @@ -109,15 +182,35 @@ impl Genome { rand::seq::sample_iter(&mut rng, 0..self.last_neuron_id + 1, 2).unwrap() } }; - self.add_connection(neuron_ids_to_connect[0], neuron_ids_to_connect[1]); + let gene = Gene::new( + neuron_ids_to_connect[0], + neuron_ids_to_connect[1], + Gene::generate_weight_in_range(config.weight_init_range), + true, + false, + ); + self.add_gene(gene); } fn mutate_connection_weight(&mut self) { + self.mutate_connection_weight_with_config( + &crate::mutation_config::MutationConfig::default(), + ); + } + + fn mutate_connection_weight_with_config( + &mut self, + config: &crate::mutation_config::MutationConfig, + ) { for gene in &mut self.genes { - ::connection_weight( - gene, - rand::random::() < MUTATE_CONNECTION_WEIGHT_PERTURBED_PROBABILITY, - ); + if rand::random::() < config.weight_perturbation_rate { + // Perturbation: add small random value + let perturbation = Gene::generate_weight_in_range(config.weight_mutate_power); + gene.set_weight(gene.weight() + perturbation); + } else { + // Replace with new random weight + gene.set_weight(Gene::generate_weight_in_range(config.weight_init_range)); + } } } @@ -181,7 +274,12 @@ impl Genome { /// Compare another Genome for species equality // TODO This should be impl Eq pub fn is_same_specie(&self, other: &Genome) -> bool { - self.compatibility_distance(other) < 1f64 + self.is_same_specie_with_threshold(other, COMPATIBILITY_THRESHOLD) + } + + /// Compare another Genome for species equality using a custom threshold + pub fn is_same_specie_with_threshold(&self, other: &Genome, threshold: f64) -> bool { + self.compatibility_distance(other) < threshold } /// Total weigths of all genes @@ -233,7 +331,11 @@ impl Genome { }); // if no matching genes then are completely different - w = if n3 == 0 { 1f64 } else { w / n3 as f64 }; + w = if n3 == 0 { + COMPATIBILITY_THRESHOLD + } else { + w / n3 as f64 + }; // compatibility distance (c2 * d as f64 / n as f64) + c3 * w @@ -301,8 +403,8 @@ mod tests { genome1.add_gene(Gene::new(0, 0, 1f64, true, false)); genome1.add_gene(Gene::new(0, 1, 1f64, true, false)); let mut genome2 = Genome::default(); - genome2.add_gene(Gene::new(0, 0, 5f64, true, false)); - genome2.add_gene(Gene::new(0, 1, 5f64, true, false)); + genome2.add_gene(Gene::new(0, 0, 20f64, true, false)); + genome2.add_gene(Gene::new(0, 1, 20f64, true, false)); genome2.add_gene(Gene::new(0, 2, 1f64, true, false)); genome2.add_gene(Gene::new(0, 3, 1f64, true, false)); assert!(!genome1.is_same_specie(&genome2)); @@ -344,7 +446,7 @@ mod tests { let mut genome1 = Genome::default(); genome1.add_gene(Gene::new(0, 0, 5f64, true, false)); let mut genome2 = Genome::default(); - genome2.add_gene(Gene::new(0, 0, 15f64, true, false)); + genome2.add_gene(Gene::new(0, 0, 25f64, true, false)); assert!(!genome1.is_same_specie(&genome2)); } @@ -365,4 +467,33 @@ mod tests { assert_eq!(genome1.genes[5].in_neuron_id(), 1); assert_eq!(genome1.genes[5].out_neuron_id(), 4); } + + #[test] + fn crossover_disabled_gene_should_stay_disabled_25_percent() { + // Parent 1 has disabled gene, parent 2 has enabled gene + let mut genome1 = Genome::default(); + genome1.add_gene(Gene::new(0, 1, 1.0, false, false)); // disabled + + let mut genome2 = Genome::default(); + genome2.add_gene(Gene::new(0, 1, 1.0, true, false)); // enabled + + // Run many trials to check probability + let trials = 1000; + let mut disabled_count = 0; + for _ in 0..trials { + let child = genome1.mate(&genome2, true); + if !child.genes[0].enabled() { + disabled_count += 1; + } + } + + // Should be approximately 25% disabled (allow ±10% margin) + // (reduced from NEAT's 75% to allow more gene reactivation) + let disabled_ratio = disabled_count as f64 / trials as f64; + assert!( + disabled_ratio > 0.15 && disabled_ratio < 0.35, + "Expected ~25% disabled, got {}%", + disabled_ratio * 100.0 + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 7bdabbf..17f8a6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,7 @@ pub use self::ctrnn::Ctrnn; pub use self::environment::Environment; pub use self::gene::Gene; pub use self::genome::Genome; +pub use self::mutation_config::MutationConfig; pub use self::organism::Organism; pub use self::population::Population; pub use self::specie::Specie; @@ -53,6 +54,8 @@ mod gene; /// A collection of genes pub mod genome; mod mutation; +/// Configurable mutation rates +pub mod mutation_config; /// A genome plus fitness pub mod organism; /// A collection of species with champion diff --git a/src/mutation.rs b/src/mutation.rs index b08fc6f..b8f8a4b 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -1,4 +1,4 @@ -use gene::Gene; +use crate::gene::Gene; pub trait Mutation {} @@ -56,7 +56,7 @@ impl dyn Mutation { #[cfg(test)] mod tests { use super::*; - use gene::Gene; + use crate::gene::Gene; #[test] fn mutate_toggle_gene_should_toggle() { diff --git a/src/mutation_config.rs b/src/mutation_config.rs new file mode 100644 index 0000000..f72e3be --- /dev/null +++ b/src/mutation_config.rs @@ -0,0 +1,161 @@ +use crate::genome::{ + COMPATIBILITY_THRESHOLD, MUTATE_ADD_CONNECTION, MUTATE_ADD_NEURON, MUTATE_CONNECTION_WEIGHT, + MUTATE_CONNECTION_WEIGHT_PERTURBED_PROBABILITY, MUTATE_TOGGLE_BIAS, MUTATE_TOGGLE_EXPRESSION, +}; + +/// Configuration for mutation rates in NEAT +/// +/// Allows customizing mutation probabilities per problem. +/// Use `MutationConfig::default()` for standard NEAT values defined in `genome.rs`. +#[derive(Debug, Clone, Copy)] +pub struct MutationConfig { + /// Probability of mutating connection weights + pub weight_mutation_rate: f64, + /// Probability of adding a new connection + pub add_connection_rate: f64, + /// Probability of adding a new neuron + pub add_neuron_rate: f64, + /// Probability of toggling a connection's enabled state + pub toggle_expression_rate: f64, + /// Probability of perturbing vs replacing weight + pub weight_perturbation_rate: f64, + /// Probability of toggling bias + pub toggle_bias_rate: f64, + /// Compatibility threshold for speciation + pub compatibility_threshold: f64, + /// Probability of mutating vs crossing over (0.0 = all crossover, 1.0 = all mutation) + pub mutation_probability: f64, + /// Range for initial weight generation: [-weight_init_range, weight_init_range] + pub weight_init_range: f64, + /// Power of weight perturbation: random value in [-weight_mutate_power, weight_mutate_power] + pub weight_mutate_power: f64, + /// CTRNN neuron time constant τ (default 0.01). + /// Represents how fast neurons respond — like biological membrane resistance time. + /// Small τ (e.g. 0.01): neurons react instantly, network behaves as feedforward. + /// Large τ (e.g. 0.1-1.0): neurons have inertia, network retains temporal memory. + /// What matters is the ratio dt/τ where dt=0.01 is the simulation step. + pub tau: f64, + /// Simulated time per activate() call in seconds (default 0.1). + /// With dt=0.01, this means 10 Euler integration steps per activation. + /// More time = more steps = more accurate convergence but slower execution. + pub step_time: f64, +} + +impl Default for MutationConfig { + fn default() -> Self { + MutationConfig { + weight_mutation_rate: MUTATE_CONNECTION_WEIGHT, + add_connection_rate: MUTATE_ADD_CONNECTION, + add_neuron_rate: MUTATE_ADD_NEURON, + toggle_expression_rate: MUTATE_TOGGLE_EXPRESSION, + weight_perturbation_rate: MUTATE_CONNECTION_WEIGHT_PERTURBED_PROBABILITY, + toggle_bias_rate: MUTATE_TOGGLE_BIAS, + compatibility_threshold: COMPATIBILITY_THRESHOLD, + mutation_probability: 0.4, + weight_init_range: 1.0, + weight_mutate_power: 1.0, + tau: 0.01, + step_time: 0.1, + } + } +} + +impl MutationConfig { + /// Create a new configuration with custom values + pub fn new() -> MutationConfigBuilder { + MutationConfigBuilder::default() + } +} + +/// Builder for MutationConfig +#[derive(Debug, Clone, Copy)] +pub struct MutationConfigBuilder { + config: MutationConfig, +} + +impl Default for MutationConfigBuilder { + fn default() -> Self { + MutationConfigBuilder { + config: MutationConfig::default(), + } + } +} + +impl MutationConfigBuilder { + /// Set weight mutation rate + pub fn weight_mutation_rate(mut self, rate: f64) -> Self { + self.config.weight_mutation_rate = rate; + self + } + + /// Set add connection rate + pub fn add_connection_rate(mut self, rate: f64) -> Self { + self.config.add_connection_rate = rate; + self + } + + /// Set add neuron rate + pub fn add_neuron_rate(mut self, rate: f64) -> Self { + self.config.add_neuron_rate = rate; + self + } + + /// Set toggle expression rate + pub fn toggle_expression_rate(mut self, rate: f64) -> Self { + self.config.toggle_expression_rate = rate; + self + } + + /// Set weight perturbation rate + pub fn weight_perturbation_rate(mut self, rate: f64) -> Self { + self.config.weight_perturbation_rate = rate; + self + } + + /// Set toggle bias rate + pub fn toggle_bias_rate(mut self, rate: f64) -> Self { + self.config.toggle_bias_rate = rate; + self + } + + /// Set compatibility threshold + pub fn compatibility_threshold(mut self, threshold: f64) -> Self { + self.config.compatibility_threshold = threshold; + self + } + + /// Set mutation vs crossover probability (0.4 = 40% mutation, 60% crossover) + pub fn mutation_probability(mut self, prob: f64) -> Self { + self.config.mutation_probability = prob; + self + } + + /// Set weight initialization range [-range, range] + pub fn weight_init_range(mut self, range: f64) -> Self { + self.config.weight_init_range = range; + self + } + + /// Set weight mutation perturbation power [-power, power] + pub fn weight_mutate_power(mut self, power: f64) -> Self { + self.config.weight_mutate_power = power; + self + } + + /// Set CTRNN time constant + pub fn tau(mut self, tau: f64) -> Self { + self.config.tau = tau; + self + } + + /// Set CTRNN integration time per activate() call + pub fn step_time(mut self, step_time: f64) -> Self { + self.config.step_time = step_time; + self + } + + /// Build the configuration + pub fn build(self) -> MutationConfig { + self.config + } +} diff --git a/src/organism.rs b/src/organism.rs index 6cdbd5e..680cbac 100644 --- a/src/organism.rs +++ b/src/organism.rs @@ -1,5 +1,5 @@ -use ctrnn::{Ctrnn, CtrnnNeuralNetwork}; -use genome::Genome; +use crate::ctrnn::{Ctrnn, CtrnnNeuralNetwork}; +use crate::genome::Genome; use std::cmp; use std::cmp::Ordering; @@ -10,6 +10,38 @@ use std::cmp::Ordering; pub struct Organism { pub genome: Genome, pub fitness: f64, + /// Fitness adjusted by species size (fitness sharing) + pub adjusted_fitness: f64, + /// If true, skip evaluation and preserve current fitness (used for elitism) + pub preserve_fitness: bool, + /// Persistent CTRNN state across activate() calls within an episode + ctrnn_state: Vec, + /// CTRNN neuron time constant τ (default 0.01). + /// Small τ = feedforward (instant response), large τ = temporal memory (slow response). + pub tau: f64, + /// Simulated time per activate() call in seconds (default 0.1). + /// Number of Euler steps = step_time / dt where dt=0.01. + pub step_time: f64, +} + +impl Ord for Organism { + fn cmp(&self, other: &Self) -> Ordering { + other.fitness.partial_cmp(&self.fitness).unwrap() + } +} + +impl Eq for Organism {} + +impl PartialEq for Organism { + fn eq(&self, other: &Self) -> bool { + self.fitness == other.fitness + } +} + +impl PartialOrd for Organism { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } } impl Ord for Organism { @@ -38,27 +70,52 @@ impl Organism { Organism { genome: genome, fitness: 0f64, + adjusted_fitness: 0f64, + preserve_fitness: false, + ctrnn_state: vec![], + tau: 0.01, + step_time: 0.1, } } /// Return a new Orgnaism by mutating this Genome and fitness of zero pub fn mutate(&self) -> Organism { let mut new_genome = self.genome.clone(); new_genome.mutate(); - Organism::new(new_genome) + let mut child = Organism::new(new_genome); + child.tau = self.tau; + child.step_time = self.step_time; + child + } + /// Return a new Organism by mutating with specific config + pub fn mutate_with_config(&self, config: &crate::mutation_config::MutationConfig) -> Organism { + let mut new_genome = self.genome.clone(); + new_genome.mutate_with_config(config); + let mut child = Organism::new(new_genome); + child.tau = self.tau; + child.step_time = self.step_time; + child } /// Mate this organism with another pub fn mate(&self, other: &Organism) -> Organism { - Organism::new( + let mut child = Organism::new( self.genome .mate(&other.genome, self.fitness < other.fitness), - ) + ); + child.tau = self.tau; + child.step_time = self.step_time; + child } + /// Reset the internal CTRNN state (call at the start of each episode) + pub fn reset_state(&mut self) { + self.ctrnn_state = vec![]; + } + /// Activate this organism in the NN pub fn activate(&mut self, sensors: Vec, outputs: &mut Vec) { let neurons_len = self.genome.len(); let sensors_len = sensors.len(); - let tau = vec![0.01; neurons_len]; + let tau = vec![self.tau; neurons_len]; let theta = self.get_bias(); let mut i = sensors.clone(); @@ -71,11 +128,16 @@ impl Organism { let wji = self.get_weights(); + // Initialize state if needed (first call or after reset_state()) + if self.ctrnn_state.len() != neurons_len { + self.ctrnn_state = vec![0.0; neurons_len]; + } + let activations = Ctrnn::default().activate_nn( - 0.1, + self.step_time, 0.01, &CtrnnNeuralNetwork { - y: &vec![0.0; neurons_len], + y: &self.ctrnn_state.clone(), tau: &tau, wji: &wji, theta: &theta, @@ -83,6 +145,9 @@ impl Organism { }, ); + // Persist state for next activate() call + self.ctrnn_state = activations.clone(); + if sensors_len < neurons_len { let outputs_activations = activations.split_at(sensors_len).1.to_vec(); @@ -116,12 +181,12 @@ impl Organism { } #[cfg(test)] -use gene::Gene; +use crate::gene::Gene; #[cfg(test)] mod tests { use super::*; - use genome::Genome; + use crate::genome::Genome; #[test] fn should_propagate_signal_without_hidden_layers() { diff --git a/src/population.rs b/src/population.rs index 8dc4922..395ae3d 100644 --- a/src/population.rs +++ b/src/population.rs @@ -1,7 +1,7 @@ +use crate::environment::Environment; +use crate::genome::Genome; +use crate::organism::Organism; use conv::prelude::*; -use environment::Environment; -use genome::Genome; -use organism::Organism; use std::cmp::Ordering; #[cfg(feature = "telemetry")] use std::time::{SystemTime, UNIX_EPOCH}; @@ -12,8 +12,9 @@ use rusty_dashed; #[cfg(feature = "telemetry")] use serde_json; -use specie::Specie; -use species_evaluator::SpeciesEvaluator; +use crate::mutation_config::MutationConfig; +use crate::specie::Specie; +use crate::species_evaluator::SpeciesEvaluator; /// All species in the network #[derive(Debug)] @@ -24,9 +25,13 @@ pub struct Population { epochs_without_improvements: usize, /// champion of the population pub champion: Option, + /// Mutation configuration + pub mutation_config: MutationConfig, } -const MAX_EPOCHS_WITHOUT_IMPROVEMENTS: usize = 10; +const MAX_EPOCHS_WITHOUT_IMPROVEMENTS: usize = 50; +const STAGNATION_THRESHOLD: usize = 15; // Remove species after 15 generations without improvement +const SPECIES_ELITISM: usize = 2; // Protect top 2 species from stagnation removal impl Population { /// Create a new population of size X. @@ -36,6 +41,7 @@ impl Population { champion_fitness: 0f64, champion: None, epochs_without_improvements: 0usize, + mutation_config: MutationConfig::default(), }; population.create_organisms(population_size); @@ -53,12 +59,52 @@ impl Population { champion_fitness: 0f64, champion: None, epochs_without_improvements: 0usize, + mutation_config: MutationConfig::default(), }; population.create_organisms_initialized(population_size, input_neurons, output_neurons); population } + /// Create a population with custom mutation configuration + pub fn create_population_initialized_with_config( + population_size: usize, + input_neurons: usize, + output_neurons: usize, + config: MutationConfig, + ) -> Population { + let mut population = Population { + species: vec![], + champion_fitness: 0f64, + champion: None, + epochs_without_improvements: 0usize, + mutation_config: config, + }; + + population.create_organisms_initialized(population_size, input_neurons, output_neurons); + population + } + + /// Create a population with unconnected genomes (no initial connections). + /// NEAT will discover connections through structural mutation. + pub fn create_population_unconnected_with_config( + population_size: usize, + input_neurons: usize, + output_neurons: usize, + config: MutationConfig, + ) -> Population { + let mut population = Population { + species: vec![], + champion_fitness: 0f64, + champion: None, + epochs_without_improvements: 0usize, + mutation_config: config, + }; + + population.create_organisms_unconnected(population_size, input_neurons, output_neurons); + population + } + /// Find total of all organisms in the population pub fn size(&self) -> usize { self.species @@ -66,15 +112,63 @@ impl Population { .fold(0usize, |total, specie| total + specie.organisms.len()) } + /// Compute adaptive mutation config based on population-level stagnation. + /// After 10 epochs without improvement, structural mutation rates scale up, + /// capped at 5x at 40 epochs. + fn adaptive_config(&self) -> MutationConfig { + const STAGNATION_START: usize = 5; + const STAGNATION_FULL: usize = 20; + const MAX_MULTIPLIER: f64 = 5.0; + + let stagnation = self.epochs_without_improvements; + if stagnation <= STAGNATION_START { + return self.mutation_config; + } + + let progress = ((stagnation - STAGNATION_START) as f64) + / ((STAGNATION_FULL - STAGNATION_START) as f64); + let multiplier = 1.0 + (MAX_MULTIPLIER - 1.0) * progress.min(1.0); + + // Scale mutation_probability from base up to 0.8 when fully stagnant + let base_mut_prob = self.mutation_config.mutation_probability; + let adaptive_mut_prob = + (base_mut_prob + (0.8 - base_mut_prob) * progress.min(1.0)).min(0.8); + + MutationConfig { + add_connection_rate: (self.mutation_config.add_connection_rate * multiplier).min(0.30), + add_neuron_rate: (self.mutation_config.add_neuron_rate * multiplier).min(0.20), + toggle_expression_rate: (self.mutation_config.toggle_expression_rate * multiplier) + .min(0.25), + toggle_bias_rate: (self.mutation_config.toggle_bias_rate * multiplier).min(0.10), + weight_mutation_rate: self.mutation_config.weight_mutation_rate, + weight_perturbation_rate: self.mutation_config.weight_perturbation_rate, + compatibility_threshold: self.mutation_config.compatibility_threshold, + mutation_probability: adaptive_mut_prob, + weight_init_range: self.mutation_config.weight_init_range, + weight_mutate_power: self.mutation_config.weight_mutate_power, + tau: self.mutation_config.tau, + step_time: self.mutation_config.step_time, + } + } + /// Create offspring by mutation and mating. May create new species. pub fn evolve(&mut self) { self.generate_offspring(); } - /// TODO - pub fn evaluate_in(&mut self, environment: &mut dyn Environment) { + /// Evaluate all organisms in the population using the given environment. + pub fn evaluate_in(&mut self, environment: &dyn Environment) { let champion = SpeciesEvaluator::new(environment).evaluate(&mut self.species); + // Apply fitness sharing and update stagnation tracking + for specie in &mut self.species { + specie.adjust_fitness(); + specie.update_stagnation(); + } + + // Remove stagnant species (but protect top 2 by fitness) + self.remove_stagnant_species(STAGNATION_THRESHOLD, SPECIES_ELITISM); + #[cfg(feature = "telemetry")] telemetry!("fitness1", 1.0, format!("{}", self.champion_fitness)); @@ -89,10 +183,38 @@ impl Population { ); self.epochs_without_improvements = 0usize; self.champion = Some(champion.clone()); + self.champion_fitness = champion.fitness; } self.champion_fitness = champion.fitness; } + /// Remove species that haven't improved for too long + fn remove_stagnant_species(&mut self, max_generations: usize, protect_top_n: usize) { + if self.species.len() <= protect_top_n { + return; + } + + // Sort by champion fitness descending to identify top species + self.species.sort_by(|a, b| { + b.calculate_champion_fitness() + .partial_cmp(&a.calculate_champion_fitness()) + .unwrap_or(Ordering::Equal) + }); + + // Mark which species to keep (top N are protected) + let mut to_remove = vec![]; + for (i, specie) in self.species.iter().enumerate() { + if i >= protect_top_n && specie.is_stagnant(max_generations) { + to_remove.push(i); + } + } + + // Remove stagnant species (in reverse order to preserve indices) + for i in to_remove.into_iter().rev() { + self.species.remove(i); + } + } + /// Return all organisms of the population pub fn get_organisms(&self) -> Vec { self.species @@ -115,14 +237,16 @@ impl Population { let num_of_organisms = self.size(); let organisms = self.get_organisms(); + let config = self.adaptive_config(); if self.epochs_without_improvements > MAX_EPOCHS_WITHOUT_IMPROVEMENTS { let mut best_species = self.get_best_species(); let num_of_selected = best_species.len(); for specie in &mut best_species { - specie.generate_offspring( + specie.generate_offspring_with_config( num_of_organisms.checked_div(num_of_selected).unwrap(), &organisms, + &config, ); } self.epochs_without_improvements = 0; @@ -140,7 +264,7 @@ impl Population { (specie_fitness * organisms_by_average_fitness).round() as usize }; if offspring_size > 0 { - specie.generate_offspring(offspring_size, &organisms); + specie.generate_offspring_with_config(offspring_size, &organisms, &config); } else { specie.remove_organisms(); } @@ -152,15 +276,16 @@ impl Population { return self.species.clone(); } + // Sort by champion fitness descending (best first) self.species.sort_by(|specie1, specie2| { - if specie1.calculate_champion_fitness() > specie2.calculate_champion_fitness() { - Ordering::Greater - } else { - Ordering::Less - } + specie2 + .calculate_champion_fitness() + .partial_cmp(&specie1.calculate_champion_fitness()) + .unwrap_or(Ordering::Equal) }); - self.species[1..2].to_vec().clone() + // Return the top 2 species + self.species[0..2].to_vec() } fn speciate(&mut self) { @@ -194,11 +319,12 @@ impl Population { next_specie_id += 1; } + let threshold = self.mutation_config.compatibility_threshold; for organism in organisms { match self .species .iter_mut() - .find(|specie| specie.match_genome(organism)) + .find(|specie| specie.match_genome_with_threshold(organism, threshold)) { Some(specie) => { specie.add(organism.clone()); @@ -225,10 +351,31 @@ impl Population { let mut organisms = vec![]; while organisms.len() < population_size { - organisms.push(Organism::new(Genome::new_initialized( - input_neurons, - output_neurons, - ))); + let mut org = Organism::new(Genome::new_initialized(input_neurons, output_neurons)); + org.tau = self.mutation_config.tau; + org.step_time = self.mutation_config.step_time; + organisms.push(org); + } + + let mut specie = Specie::new(organisms.first().unwrap().genome.clone()); + specie.organisms = organisms; + self.species.push(specie); + } + + fn create_organisms_unconnected( + &mut self, + population_size: usize, + input_neurons: usize, + output_neurons: usize, + ) { + self.species = vec![]; + let mut organisms = vec![]; + + while organisms.len() < population_size { + let mut org = Organism::new(Genome::new_unconnected(input_neurons, output_neurons)); + org.tau = self.mutation_config.tau; + org.step_time = self.mutation_config.step_time; + organisms.push(org); } let mut specie = Specie::new(organisms.first().unwrap().genome.clone()); @@ -242,14 +389,14 @@ impl Population { } #[cfg(test)] -use gene::Gene; +use crate::gene::Gene; #[cfg(test)] mod tests { use super::*; - use genome::Genome; - use organism::Organism; - use specie::Specie; + use crate::genome::Genome; + use crate::organism::Organism; + use crate::specie::Specie; #[test] fn population_should_be_able_to_speciate_genomes() { @@ -257,12 +404,11 @@ mod tests { genome1.add_gene(Gene::new(0, 0, 1f64, true, false)); genome1.add_gene(Gene::new(0, 1, 1f64, true, false)); let mut genome2 = Genome::default(); - genome1.add_gene(Gene::new(0, 0, 1f64, true, false)); - genome1.add_gene(Gene::new(0, 1, 1f64, true, false)); genome2.add_gene(Gene::new(1, 1, 1f64, true, false)); genome2.add_gene(Gene::new(1, 0, 1f64, true, false)); let mut population = Population::create_population(2); + population.mutation_config.compatibility_threshold = 1.0; let organisms = vec![Organism::new(genome1), Organism::new(genome2)]; let mut specie = Specie::new(organisms.first().unwrap().genome.clone()); specie.organisms = organisms; diff --git a/src/specie.rs b/src/specie.rs index 58679d9..904c010 100644 --- a/src/specie.rs +++ b/src/specie.rs @@ -1,8 +1,8 @@ +use crate::genome::Genome; +use crate::mutation_config::MutationConfig; +use crate::organism::Organism; use conv::prelude::*; -use genome::Genome; -use organism::Organism; use rand; -use rand::distributions::{IndependentSample, Range}; use rand::Rng; /// A species (several organisms) and associated fitnesses @@ -19,9 +19,8 @@ pub struct Specie { pub id: i64, } -const MUTATION_PROBABILITY: f64 = 0.25f64; -const INTERSPECIE_MATE_PROBABILITY: f64 = 0.001f64; -const BEST_ORGANISMS_THRESHOLD: f64 = 1f64; +const INTERSPECIE_MATE_PROBABILITY: f64 = 0.15f64; // Increased from 0.03 to escape local optima +const BEST_ORGANISMS_THRESHOLD: f64 = 0.5f64; // Only top 50% can reproduce impl Specie { /// Create a new species from a Genome @@ -47,6 +46,12 @@ impl Specie { self.representative.is_same_specie(&organism.genome) } + /// Check if another organism is of the same species using a custom threshold. + pub fn match_genome_with_threshold(&self, organism: &Organism, threshold: f64) -> bool { + self.representative + .is_same_specie_with_threshold(&organism.genome, threshold) + } + /// Get the most performant organism pub fn calculate_champion_fitness(&self) -> f64 { self.organisms.iter().fold(0f64, |max, organism| { @@ -58,6 +63,29 @@ impl Specie { }) } + /// Update stagnation tracking. Call after evaluation. + /// Returns true if species improved this generation. + pub fn update_stagnation(&mut self) -> bool { + let current_best = self.calculate_champion_fitness(); + if current_best > self.champion_fitness { + self.champion_fitness = current_best; + self.age_last_improvement = self.age; + true + } else { + false + } + } + + /// Check if species has stagnated (no improvement for max_generations) + pub fn is_stagnant(&self, max_generations: usize) -> bool { + self.age > self.age_last_improvement + max_generations + } + + /// Get generations since last improvement + pub fn generations_without_improvement(&self) -> usize { + self.age.saturating_sub(self.age_last_improvement) + } + /// Work out average fitness of this species pub fn calculate_average_fitness(&mut self) -> f64 { let organisms_count = self.organisms.len().value_as::().unwrap(); @@ -86,10 +114,25 @@ impl Specie { &mut self, num_of_organisms: usize, population_organisms: &[Organism], + ) { + self.generate_offspring_with_config( + num_of_organisms, + population_organisms, + &MutationConfig::default(), + ); + } + + /// Generate offspring with a specific mutation config (supports adaptive mutation) + pub fn generate_offspring_with_config( + &mut self, + num_of_organisms: usize, + population_organisms: &[Organism], + base_config: &MutationConfig, ) { self.age += 1; - let copy_champion = if num_of_organisms > 5 { 1 } else { 0 }; + // Always copy champion (elitism) + let copy_champion = if num_of_organisms > 1 { 1 } else { 0 }; let mut organisms_to_mate = (self.organisms.len() as f64 * BEST_ORGANISMS_THRESHOLD) as usize; @@ -100,17 +143,45 @@ impl Specie { self.organisms.sort(); self.organisms.truncate(organisms_to_mate); - let mut rng = rand::thread_rng(); let mut offspring: Vec = { + // Fitness-proportionate selection (roulette wheel) using adjusted_fitness + let mut rng = rand::thread_rng(); + + // Calculate total adjusted fitness for roulette wheel + let total_adjusted_fitness: f64 = self + .organisms + .iter() + .map(|o| o.adjusted_fitness.max(0.0)) + .sum(); + let mut selected_organisms = vec![]; - let range = Range::new(0, self.organisms.len()); for _ in 0..num_of_organisms - copy_champion { - selected_organisms.push(range.ind_sample(&mut rng)); + if total_adjusted_fitness <= 0.0 { + // Fallback to uniform selection if no positive fitness + selected_organisms.push(rng.gen_range(0, self.organisms.len())); + } else { + // Roulette wheel selection + let spin = rng.gen_range(0.0, total_adjusted_fitness); + let mut cumulative = 0.0; + let mut selected = 0; + for (i, organism) in self.organisms.iter().enumerate() { + cumulative += organism.adjusted_fitness.max(0.0); + if cumulative >= spin { + selected = i; + break; + } + } + selected_organisms.push(selected); + } } selected_organisms .iter() .map(|organism_pos| { - self.create_child(&self.organisms[*organism_pos], population_organisms) + self.create_child( + &self.organisms[*organism_pos], + population_organisms, + base_config, + ) }) .collect::>() }; @@ -125,7 +196,10 @@ impl Specie { } }); - offspring.push(champion.unwrap()); + // Mark champion copy to preserve its fitness (skip re-evaluation) + let mut elite = champion.unwrap(); + elite.preserve_fitness = true; + offspring.push(elite); } self.organisms = offspring; } @@ -146,7 +220,6 @@ impl Specie { /// Clear existing organisms in this species. pub fn remove_organisms(&mut self) { - self.adjust_fitness(); self.organisms = vec![]; } @@ -155,24 +228,36 @@ impl Specie { self.organisms.is_empty() } - /// TODO + /// Apply fitness sharing: divide each organism's fitness by species size + /// This prevents large species from dominating the population pub fn adjust_fitness(&mut self) { - // TODO: adjust fitness + let species_size = self.organisms.len() as f64; + if species_size == 0.0 { + return; + } + for organism in &mut self.organisms { + organism.adjusted_fitness = organism.fitness / species_size; + } } - /// Create a new child by mutating and existing one or mating two genomes. - fn create_child(&self, organism: &Organism, population_organisms: &[Organism]) -> Organism { - if rand::random::() < MUTATION_PROBABILITY || population_organisms.len() < 2 { - self.create_child_by_mutation(organism) + /// Create a new child by crossover+mutation or mutation only. + /// Per NEAT paper: 75% crossover (then mutate), 25% mutation only. + fn create_child( + &self, + organism: &Organism, + population_organisms: &[Organism], + config: &MutationConfig, + ) -> Organism { + if rand::random::() < config.mutation_probability || population_organisms.len() < 2 { + // 25%: mutation only (asexual reproduction) + organism.mutate_with_config(config) } else { - self.create_child_by_mate(organism, population_organisms) + // 75%: crossover then mutate + let child = self.create_child_by_mate(organism, population_organisms); + child.mutate_with_config(config) } } - fn create_child_by_mutation(&self, organism: &Organism) -> Organism { - organism.mutate() - } - fn create_child_by_mate( &self, organism: &Organism, @@ -194,8 +279,8 @@ impl Specie { #[cfg(test)] mod tests { use super::*; - use genome::Genome; - use organism::Organism; + use crate::genome::Genome; + use crate::organism::Organism; use std::f64::EPSILON; #[test] @@ -216,4 +301,29 @@ mod tests { assert!((specie.calculate_average_fitness() - 15f64).abs() < EPSILON); } + + #[test] + fn adjust_fitness_should_divide_by_species_size() { + let mut specie = Specie::new(Genome::default()); + + let mut organism1 = Organism::new(Genome::default()); + organism1.fitness = 30.0; + + let mut organism2 = Organism::new(Genome::default()); + organism2.fitness = 60.0; + + let mut organism3 = Organism::new(Genome::default()); + organism3.fitness = 90.0; + + specie.add(organism1); + specie.add(organism2); + specie.add(organism3); + + specie.adjust_fitness(); + + // Each fitness should be divided by species size (3) + assert!((specie.organisms[0].adjusted_fitness - 10.0).abs() < EPSILON); + assert!((specie.organisms[1].adjusted_fitness - 20.0).abs() < EPSILON); + assert!((specie.organisms[2].adjusted_fitness - 30.0).abs() < EPSILON); + } } diff --git a/src/species_evaluator.rs b/src/species_evaluator.rs index e1b7862..b6eb9b4 100644 --- a/src/species_evaluator.rs +++ b/src/species_evaluator.rs @@ -1,100 +1,106 @@ -use crossbeam::{self, Scope}; -use environment::Environment; -use genome::Genome; -use organism::Organism; -use specie::Specie; +use crate::environment::Environment; +use crate::genome::Genome; +use crate::organism::Organism; +use crate::specie::Specie; +use crossbeam; use std::sync::mpsc; -use std::sync::mpsc::{Receiver, Sender}; -/// Calculate fitness and champions for a species +/// Evaluates all organisms across species using the environment pub struct SpeciesEvaluator<'a> { threads: usize, - environment: &'a mut dyn Environment, + environment: &'a dyn Environment, } impl<'a> SpeciesEvaluator<'a> { - /// Take an environment that will test organisms. - pub fn new(environment: &mut dyn Environment) -> SpeciesEvaluator { + /// Create a new evaluator with the given environment. + pub fn new(environment: &'a dyn Environment) -> SpeciesEvaluator<'a> { SpeciesEvaluator { threads: environment.threads(), - environment: environment, + environment, } } - /// return champion fitness + /// Evaluate all organisms and return the champion pub fn evaluate(&self, species: &mut Vec) -> Organism { - let mut champion: Organism = Organism::new(Genome::default()); + if self.threads <= 1 { + // Single-threaded: iterate directly over species, no drain needed + self.evaluate_single(species) + } else { + // Multi-threaded: need to collect all organisms for parallel chunking + self.evaluate_parallel(species) + } + } - for specie in species { - if specie.organisms.is_empty() { - continue; - } + fn evaluate_single(&self, species: &mut Vec) -> Organism { + let mut champion = Organism::new(Genome::default()); - let organisms_by_thread = (specie.organisms.len() + self.threads - 1) / self.threads; // round up - let (tx, rx): (Sender, Receiver) = mpsc::channel(); - crossbeam::scope(|scope| { - let threads_used = self.dispatch_organisms( - specie.organisms.as_mut_slice(), - organisms_by_thread, - 0, - &tx, - scope, - ); - for _ in 0..threads_used { - let champion_candidate = rx.recv().unwrap(); - if champion_candidate.fitness > champion.fitness { - champion = champion_candidate; + for specie in species.iter_mut() { + if !specie.organisms.is_empty() { + self.environment.test_batch(&mut specie.organisms); + for org in &specie.organisms { + if org.fitness > champion.fitness { + champion = org.clone(); } } - }); + } } + champion } - fn dispatch_organisms<'b>( - &'b self, - organisms: &'b mut [Organism], - organisms_by_thread: usize, - threads_used: usize, - tx: &Sender, - scope: &Scope<'b>, - ) -> usize { - if organisms.len() <= organisms_by_thread { - self.evaluate_organisms(organisms, tx.clone(), scope); - } else { - match organisms.split_at_mut(organisms_by_thread) { - (thread_organisms, remaining_organisms) => { - self.evaluate_organisms(thread_organisms, tx.clone(), scope); - if remaining_organisms.len() > 0 { - return self.dispatch_organisms( - remaining_organisms, - organisms_by_thread, - threads_used + 1, - tx, - scope, - ); - } - } - } + fn evaluate_parallel(&self, species: &mut Vec) -> Organism { + // Save original sizes to restore later + let original_sizes: Vec = species.iter().map(|s| s.organisms.len()).collect(); + + // Drain all organisms into a single vec for parallel chunking + let mut all_organisms: Vec = species + .iter_mut() + .flat_map(|s| s.organisms.drain(..)) + .collect(); + + if all_organisms.is_empty() { + return Organism::new(Genome::default()); } - threads_used + 1 - } - fn evaluate_organisms<'b>( - &'b self, - organisms: &'b mut [Organism], - tx: Sender, - scope: &Scope<'b>, - ) { - scope.spawn(move || { - let mut champion = Organism::new(Genome::default()); - for organism in &mut organisms.iter_mut() { - organism.fitness = self.environment.test(organism); - if organism.fitness > champion.fitness { - champion = organism.clone(); - } + let chunk_size = (all_organisms.len() + self.threads - 1) / self.threads; + let (tx, rx) = mpsc::channel(); + + crossbeam::scope(|scope| { + for chunk in all_organisms.chunks_mut(chunk_size) { + let tx = tx.clone(); + let env = self.environment; + + scope.spawn(move || { + env.test_batch(chunk); + + // Find local champion + let mut local_champion = Organism::new(Genome::default()); + for org in chunk.iter() { + if org.fitness > local_champion.fitness { + local_champion = org.clone(); + } + } + tx.send(local_champion).unwrap(); + }); } - tx.send(champion).unwrap(); }); + + // Find global champion + let num_chunks = (all_organisms.len() + chunk_size - 1) / chunk_size; + let mut champion = Organism::new(Genome::default()); + for _ in 0..num_chunks { + let local_champion = rx.recv().unwrap(); + if local_champion.fitness > champion.fitness { + champion = local_champion; + } + } + + // Restore organisms back to their species + let mut drain_iter = all_organisms.into_iter(); + for (specie, &size) in species.iter_mut().zip(original_sizes.iter()) { + specie.organisms = drain_iter.by_ref().take(size).collect(); + } + + champion } } diff --git a/tests/rustneat_tests.rs b/tests/rustneat_tests.rs index 26b4726..f555141 100644 --- a/tests/rustneat_tests.rs +++ b/tests/rustneat_tests.rs @@ -12,27 +12,6 @@ mod test { } } - struct XORClassification; - - impl Environment for XORClassification { - fn test(&self, organism: &mut Organism) -> f64 { - let mut output = vec![0f64]; - let mut distance: f64; - organism.activate(vec![0f64, 0f64], &mut output); - distance = (0f64 - output[0]).powi(2); - organism.activate(vec![0f64, 1f64], &mut output); - distance += (1f64 - output[0]).powi(2); - organism.activate(vec![1f64, 0f64], &mut output); - distance += (1f64 - output[0]).powi(2); - organism.activate(vec![1f64, 1f64], &mut output); - distance += (0f64 - output[0]).powi(2); - - let fitness = 16f64 / (1f64 + distance); - - fitness - } - } - #[test] fn should_be_able_to_generate_a_population() { let population = Population::create_population(150); @@ -51,34 +30,54 @@ mod test { #[test] fn population_can_be_tested_on_environment() { let mut population = Population::create_population(10); - let mut environment = MyEnvironment; - population.evaluate_in(&mut environment); + let environment = MyEnvironment; + population.evaluate_in(&environment); assert!(population.get_organisms()[0].fitness == 0.1234f64); } + struct XORClassification; + + impl Environment for XORClassification { + fn test(&self, organism: &mut Organism) -> f64 { + let mut output = vec![0f64]; + let mut distance: f64; + organism.activate(vec![0f64, 0f64], &mut output); + distance = (0f64 - output[0]).powi(2); + organism.activate(vec![0f64, 1f64], &mut output); + distance += (1f64 - output[0]).powi(2); + organism.activate(vec![1f64, 0f64], &mut output); + distance += (1f64 - output[0]).powi(2); + organism.activate(vec![1f64, 1f64], &mut output); + distance += (0f64 - output[0]).powi(2); + + 16f64 / (1f64 + distance) + } + } + #[test] fn network_should_be_able_to_solve_xor_classification() { let mut population = Population::create_population(150); - let mut environment = XORClassification; + let environment = XORClassification; let mut champion_option: Option = None; while champion_option.is_none() { population.evolve(); - population.evaluate_in(&mut environment); + population.evaluate_in(&environment); for organism in &population.get_organisms() { - if organism.fitness > 15.9f64 { + if organism.fitness > 15.5f64 { champion_option = Some(organism.clone()); } } } let champion = champion_option.as_mut().unwrap(); let mut output = vec![0f64]; + champion.reset_state(); // match initial state of evolution (zeros) champion.activate(vec![0f64, 0f64], &mut output); - assert!(output[0] < 0.1f64); + assert!(output[0] < 0.2f64); champion.activate(vec![0f64, 1f64], &mut output); - assert!(output[0] > 0.9f64); + assert!(output[0] > 0.8f64); champion.activate(vec![1f64, 0f64], &mut output); - assert!(output[0] > 0.9f64); + assert!(output[0] > 0.8f64); champion.activate(vec![1f64, 1f64], &mut output); - assert!(output[0] < 0.1f64); + assert!(output[0] < 0.2f64); } }