petri_dish_lab 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2b6825c1a801fb7bf1b0055bf7ad08eeafb11b07ba28738e119649e78da901e5
4
+ data.tar.gz: 8b4ec7f7f3aecf5dfe436b17e4fe1fc47cdbce4414cbfd0762a91f229787f7d6
5
+ SHA512:
6
+ metadata.gz: f606acc337b9e3854a4d5f5fd97f322ab92819352d42ae5ad96904600451a31de000649cfc3db816fb111fa0146a3a0eeec58ebd8cc22e796450f048fbf9f08c
7
+ data.tar.gz: ce04833309b18e813f8ded79dc9d6d73af440754dc802045e4a4ed892d0c2c83843952491cf6474690f4a389c51998b6abab4a9705acd9fd71839c296e300824
data/.DS_Store ADDED
Binary file
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.standard.yml ADDED
@@ -0,0 +1,2 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ ## [Unreleased]
2
+ ### Added
3
+ ### Changed
4
+ ### Fixed
5
+ ### Removed
6
+
7
+ ## [0.1.1] - 2023-07-26
8
+
9
+ ### Added
10
+
11
+ - Added `Configuration` class for customizing the parameters of the evolutionary algorithm.
12
+ - Added `Member` class to represent an individual in the population.
13
+ - Added `Metadata` class to keep track of the evolution process.
14
+ - Added `World` class to run the evolutionary algorithm.
15
+ - Initial implementation of evolutionary algorithm operations, including selection, crossover, and mutation.
16
+ - Added fitness function support for evaluating the quality of individuals in the population.
17
+ - Added callback functions for various events in the evolution process, including when a new highest fitness is found, when the maximum number of generations is reached, and when the end condition is met.
18
+ - Included an example of using the library to solve a simple genetic algorithm problem (lazy dog example).
19
+ - Included an example of using the library to solve the Traveling Salesperson Problem (salesperson example).
@@ -0,0 +1,27 @@
1
+ # Code of Conduct
2
+
3
+ Hey there, it's Thomas! I'm really keen on keeping this project fun and inclusive. Everyone's welcome, no matter who you are or where you come from.
4
+
5
+ ## Let's Have Fun
6
+
7
+ * Be kind and empathetic.
8
+ * Respect other's opinions and viewpoints.
9
+ * Learn from your mistakes, and most importantly, from each other.
10
+ * Focus on what's best for everyone.
11
+
12
+ ## Not Fun
13
+
14
+ * No inappropriate language or imagery.
15
+ * No personal attacks or offensive behavior.
16
+ * No harassment, public or private.
17
+ * No publishing others' private info without permission.
18
+
19
+ ## If Things Are Not Fun
20
+
21
+ If you see or experience something that's not fun, drop me an email at thomascountz@gmail.com. I'll take it seriously, and handle it respectfully and fairly.
22
+
23
+ Depending on what happened, I might send a warning, or for repeated or serious incidents, issue a temporary or even permanent ban from the project.
24
+
25
+ Let's keep it fun, respectful, and awesome!
26
+
27
+ This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html), version 2.0, and inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Thomas Countz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,233 @@
1
+ # Petri Dish - A Ruby library for Evolutionary Algorithms
2
+
3
+ > **Note**
4
+ > `gem install petri_dish_lab` to install the gem, _not_ `petri_dish`.
5
+
6
+ ## Introduction
7
+
8
+ Welcome to Petri Dish, a Ruby library designed to provide an easy-to-use interface for implementing evolutionary algorithms. Petri Dish is a flexible library that allows you to configure and run your own evolutionary algorithms by simply providing your own genetic material, fitness function, and other parameters. This library is perfect for both beginners who are just starting to learn about evolutionary algorithms, and experts who want to experiment with different configurations and parameters.
9
+
10
+ ## Overview of Evolutionary Algorithms
11
+
12
+ Evolutionary algorithms are a class of optimization algorithms that are inspired by the process of natural evolution. They work by maintaining a population of candidate solutions for the problem at hand and iteratively improving that population by applying operations that mimic natural evolution, such as mutation, crossover (or recombination), and selection.
13
+
14
+ The basic steps of an evolutionary algorithm are as follows:
15
+
16
+ 1. **Initialization**: Begin with a population of randomly generated individuals.
17
+ 2. **Evaluation**: Compute the fitness of each individual in the population.
18
+ 3. **Selection**: Select individuals for reproduction based on their fitness.
19
+ 4. **Crossover**: Generate offspring by combining the traits of selected individuals.
20
+ 5. **Mutation**: Randomly alter some traits of the offspring.
21
+ 6. **Replacement**: Replace the current population with the offspring.
22
+ 7. **Termination**: If a termination condition is met (e.g., a solution of sufficient quality is found, or a maximum number of generations is reached), stop and return the best solution found. Otherwise, go back to step 2.
23
+
24
+ ## Key Concepts of this Library
25
+
26
+ Petri Dish is built around a few key classes: `Configuration`, `Member`, `Metadata`, and `World`.
27
+
28
+ - `Configuration`: This class provides a way to configure the behavior of the evolutionary algorithm. It exposes various parameters like `population_size`, `mutation_rate`, `genetic_material`, and several callback functions that can be used to customize the evolution process.
29
+
30
+ - `Member`: This class represents an individual in the population. It has a set of genes and a fitness value, which is computed by a fitness function provided in the configuration.
31
+
32
+ - `Metadata`: This class keeps track of the evolution process, like the number of generations that have passed and the highest fitness value found so far.
33
+
34
+ - `World`: This class is responsible for running the evolutionary algorithm. It takes a configuration and a population of members as input, and runs the evolution process until a termination condition is met.
35
+
36
+ ## Configuration
37
+
38
+ The `Configuration` class in Petri Dish allows you to customize various aspects of the evolutionary algorithm. Here are the parameters you can set:
39
+
40
+ Here is the reformatted list as a markdown table:
41
+
42
+ | Parameter | Description | Type |
43
+ |---|---|---|
44
+ | `logger` | An object that responds to `:info` for logging purposes | `Logger` |
45
+ | `population_size` | The number of individuals in the population | `Integer` |
46
+ | `mutation_rate` | The chance that a gene will change during mutation (between 0 and 1, inclusive) | `Float` |
47
+ | `genetic_material` | An array of possible gene values | `Array[untyped]` |
48
+ | `elitism_rate` | The proportion of the population preserved through elitism (between 0 and 1, inclusive) | `Float` |
49
+ | `target_genes` | The ideal set of genes for the problem at hand | `Array[untyped]` |
50
+ | `max_generations` | The maximum number of generations to run the evolution for | `Integer` |
51
+ | `parents_selection_function` | A function used to select parents for crossover | `Proc[Array[Member], Array[Member]]` |
52
+ | `crossover_function` | A function used to perform crossover between two parents | `Proc[Array[Member], Member]` |
53
+ | `mutation_function` | A function used to mutate the genes of an individual | `Proc[Member, Member]` |
54
+ | `fitness_function` | A function used to calculate the fitness of an individual | `Proc[Member, Numeric]` |
55
+ | `highest_fitness_callback` | A callback function invoked when a new highest fitness is found | `Proc[Member, void]` |
56
+ | `max_generation_reached_callback` | A callback function invoked when the maximum number of generations is reached | `Proc[void, void]` |
57
+ | `end_condition_function` | A function that determines whether the evolution process should stop premature of `max_generations` | `Proc[Member, bool]` |
58
+ | `next_generation_callback` | A callback function invoked at the start of each new generation | `Proc[void, void]` |
59
+ | `end_condition_reached_callback` | A callback function invoked when the end condition is met. It is called with the `Member` which triggered the `end_condition_function` | `Proc[Member, void]` |
60
+
61
+ You can create a new `Configuration` object by calling `Configuration.configure` and providing a block:
62
+
63
+ ```ruby
64
+ configuration = PetriDish::Configuration.configure do |config|
65
+ # set your configuration parameters here
66
+ end
67
+ ```
68
+
69
+ In the block, you can set the parameters of the configuration to customize the behavior of your evolutionary algorithm.
70
+
71
+ ## Member
72
+
73
+ The `Member` class in Petri Dish represents an individual in the population. Each member has a set of genes and a fitness value, which is calculated by a fitness function provided in the configuration. Here are the parameters and methods you can interact with:
74
+
75
+ - `new(genes:, fitness_function:)`: This method is used to create a new member. It takes an array of genes and a fitness function as arguments.
76
+
77
+ - `genes` (`Array[untyped]`): The genetic material of the individual, represented as an array .
78
+
79
+ - `fitness_function` (`Proc[Member, Float]`): The function used to calculate the fitness of the individual. It is provided during the initialization of the member.
80
+
81
+ - `fitness`: This method calls the provided fitness function. The resulting fitness value is cached after the first calculation and reused in subsequent calls.
82
+
83
+ Here's an example of how to create a new member:
84
+
85
+ ```ruby
86
+ member = PetriDish::Member.new(
87
+ genes: ["gene1", "gene2", "gene3"],
88
+ fitness_function: ->(member) { # calculate fitness }
89
+ )
90
+ ```
91
+
92
+ In this example, `["gene1", "gene2", "gene3"]` is the genetic material for the member, and the lambda function is used to calculate the fitness of the member. You should replace `# calculate fitness` with the actual logic for calculating fitness based on the problem you're trying to solve.
93
+
94
+ ## Fitness Function
95
+
96
+ A fitness function is crucial as it provides a way to evaluate how good or "fit" an individual member of the population is in solving the problem at hand. The fitness function is a measure of quality or performance, and it guides the evolutionary algorithm in the search for optimal solutions.
97
+
98
+ Here are the necessary technical properties required when defining a fitness function for the Petri Dish framework:
99
+
100
+ 1. Callable: The fitness function should be a callable object (for example, a lambda or a `Proc`). It should respond to `#call`.
101
+
102
+ 2. Input: The fitness function should take a single argument, which is an instance of the `Member` class. This represents an individual member of the population whose fitness is to be evaluated.
103
+
104
+ 3. Output: The fitness function should return a numerical value that represents the fitness of the given member. This could be an `Integer` or a `Float`, depending on the precision required. **Higher values should signify better fitness**.
105
+
106
+ 4. Deterministic: Given the same `Member`, the fitness function should always return the same fitness score. This is because the fitness of a member may be evaluated multiple times during the evolutionary process, and inconsistent results could lead to unpredictable behavior.
107
+
108
+ 5. Non-negative: The fitness function should ideally return non-negative values. This isn't a strict requirement, but having non-negative fitness values can make the algorithm easier to understand and debug.
109
+
110
+ 6. Discriminative: The fitness function should be able to discriminate between different members of the population. That is, members with different genes should have different fitness scores. If many members have the same fitness score, the evolutionary algorithm will have a harder time deciding which members are better.
111
+
112
+ ## Install and Setup
113
+
114
+ > **Warning**
115
+ > The name of the _repo_ is `petri_dish`.
116
+ > The name of the _module_ is `PetriDish`.
117
+ > The name of the _gem_ is `petri_dish_lab`.
118
+
119
+ You can install `petri_dish_lab` as a gem in your application. Add this line to your application's Gemfile:
120
+
121
+ ```ruby
122
+ gem 'petri_dish_lab'
123
+ ```
124
+
125
+ And then execute:
126
+
127
+ ```bash
128
+ bundle install
129
+ ```
130
+
131
+ Or install it yourself as:
132
+
133
+ ```bash
134
+ gem install petri_dish_lab
135
+ ```
136
+
137
+ At the top of your Ruby file, require the `petri_dish` _module_ name:
138
+
139
+ ```ruby
140
+ require "petri_dish"
141
+ ```
142
+
143
+ ### Setup for Development
144
+
145
+ If you want to set up the `petri_dish_lab` gem for development, follow these steps:
146
+
147
+ 1. Clone the repository:
148
+
149
+ ```bash
150
+ git clone https://github.com/thomascountz/petri_dish.git
151
+ ```
152
+
153
+ 2. Change into the `petri_dish` directory:
154
+
155
+ ```bash
156
+ cd petri_dish
157
+ ```
158
+
159
+ 3. Run the setup script:
160
+
161
+ ```bash
162
+ bin/setup
163
+ ```
164
+
165
+ This will install the necessary dependencies for development and testing.
166
+
167
+ ### Using Console for Development
168
+
169
+ After setting up, you can use the development console to experiment with the `petri_dish` library:
170
+
171
+ ```bash
172
+ bin/console
173
+ ```
174
+
175
+ This will start an interactive Ruby session (IRB) with `PetriDish` pre-loaded. You can use this console to experiment with `PetriDish`, create `PetriDish::Member` instances, run evolutionary algorithms, etc.
176
+
177
+ Remember to run your tests frequently during development to ensure everything is working as expected:
178
+
179
+ ```bash
180
+ bundle exec rspec
181
+ ```
182
+
183
+ If you add new code, remember to add corresponding tests and ensure all tests pass before committing your changes.
184
+
185
+ ## Examples
186
+
187
+ ### Lazy Dog Example
188
+
189
+ The `lazy_dog_example.rb` is an example of using the Petri Dish library to solve a simple problem: Evolving a string to match "the quick brown fox jumped over the lazy white dog". This is a classic example of using a genetic algorithm to find a solution to a problem.
190
+
191
+ The genetic material in this case is the array of all lowercase letters and space. The target genes are the characters in the target string. The fitness function is defined as the cube of the sum of matches between the genes of a member and the target genes. This means that members with more matching characters will have a much higher fitness.
192
+
193
+ The parents for crossover are selected using a tournament selection function which picks the best 2 out of a random sample of 20% of the population. Crossover is performed at a random midpoint in the genes.
194
+
195
+ Mutation is implemented as a chance to replace a gene with a random gene from the genetic material. The mutation rate is set to 0.005, which means that on average, 0.5% of the genes in a member will mutate in each generation.
196
+
197
+ The end condition for the evolutionary process is when a member with genes exactly matching the target genes is found.
198
+
199
+ To run the example, simply execute the following command in your terminal:
200
+
201
+ ```bash
202
+ bundle exec ruby examples/lazy_dog_example.rb
203
+ ```
204
+
205
+ ### Traveling Salesperson Example
206
+
207
+ The `salesperson_example.rb` is an example of using the Petri Dish library to solve a more complex problem: The Traveling Salesperson Problem. In this problem, a salesperson needs to visit a number of cities, each at a different location, and return to the starting city. The goal is to find the shortest possible route that visits each city exactly once.
208
+
209
+ In this example, each city is represented as a `Gene` object with `x` and `y` coordinates. The genetic material is the array of all possible `x` and `y` coordinates. The fitness function is defined as the inverse of the total distance of the route, which means that shorter routes will have higher fitness.
210
+
211
+ The parents for crossover are selected using a tournament selection function which picks the best 2 out of a random sample of 20% of the population. Crossover is performed using an ordered crossover method which maintains the relative order of the genes from both parents.
212
+
213
+ Mutation is implemented as a chance to swap two genes in a member. The mutation rate is set to 0.01, which means that on average, 1% of the genes in a member will mutate in each generation.
214
+
215
+ The evolutionary process runs for a fixed number of generations, and the highest fitness member in each generation is saved to a CSV file.
216
+
217
+ To run the example, simply execute the following command in your terminal:
218
+
219
+ ```bash
220
+ bundle exec ruby examples/salesperson_example.rb
221
+ ```
222
+
223
+ You can then visualize the best route using the provided `uplot` command.
224
+
225
+ ## Resources
226
+ - [Genetic Algorithms Explained By Example - Youtube](https://www.youtube.com/watch?v=uQj5UNhCPuo)
227
+ - [Genetic Algorithms for Autonomous Robot Navigation - Paper](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.208.9941&rep=rep1&type=pdf)
228
+ - [Nature of Code, Chapter 9 - The Evolution of Code - Book](https://natureofcode.com/book/chapter-9-the-evolution-of-code/)
229
+ - [Weighted Random Sampling in Ruby - Gist](https://gist.github.com/O-I/3e0654509dd8057b539a)
230
+ - [Tail Call Optimization in Ruby - Blog](https://nithinbekal.com/posts/ruby-tco/)
231
+ - [Neural network and genetic algorithm based global path planning in a static environment - Paper](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.583.3340&rep=rep1&type=pdf)
232
+ - [Traveling Salesman Problem using Genetic Algorithm - Blog](https://www.geeksforgeeks.org/traveling-salesman-problem-using-genetic-algorithm/)
233
+ - [A KNOWLEDGE-BASED GENETIC ALGORITHM FOR PATH PLANNING OF MOBILE ROBOTS - Thesis](https://atrium.lib.uoguelph.ca/xmlui/bitstream/handle/10214/22039/Hu_Yanrong_MSc.pdf?sequence=2)
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
@@ -0,0 +1,62 @@
1
+ # require "petri_dish" # Uncomment this line and comment/remove the line below if you're using Petri Dish as a gem
2
+ require_relative "../lib/petri_dish"
3
+
4
+ target_genes = "the quick brown fox jumped over the lazy white dog".chars
5
+ genetic_material = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", " "]
6
+
7
+ def genes_match_target_end_condition_function(configuration)
8
+ ->(member) do
9
+ member.genes == configuration.target_genes
10
+ end
11
+ end
12
+
13
+ def twenty_percent_tournament_function(configuration)
14
+ ->(members) do
15
+ members.sample(configuration.population_size * 0.2).max_by(2) { |member| member.fitness }
16
+ end
17
+ end
18
+
19
+ def exponential_fitness_function(configuration)
20
+ ->(member) do
21
+ member.genes.zip(configuration.target_genes).map do |target_gene, member_gene|
22
+ (target_gene == member_gene) ? 1 : 0
23
+ end.sum**3
24
+ end
25
+ end
26
+
27
+ def random_midpoint_crossover_function(configuration)
28
+ ->(parents) do
29
+ midpoint = rand(parents[0].genes.length)
30
+ PetriDish::Member.new(fitness_function: configuration.fitness_function, genes: parents[0].genes[0...midpoint] + parents[1].genes[midpoint..])
31
+ end
32
+ end
33
+
34
+ def random_mutation_function(configuration)
35
+ ->(member) do
36
+ mutated_genes = member.genes.map do |gene|
37
+ if rand < configuration.mutation_rate
38
+ configuration.genetic_material.sample
39
+ else
40
+ gene
41
+ end
42
+ end
43
+ PetriDish::Member.new(fitness_function: configuration.fitness_function, genes: mutated_genes)
44
+ end
45
+ end
46
+
47
+ configuration = PetriDish::Configuration.configure do |config|
48
+ config.max_generations = 5000
49
+ config.population_size = 250
50
+ config.mutation_rate = 0.005
51
+ config.genetic_material = genetic_material
52
+ config.target_genes = target_genes
53
+ config.parents_selection_function = twenty_percent_tournament_function(config)
54
+ config.fitness_function = exponential_fitness_function(config)
55
+ config.crossover_function = random_midpoint_crossover_function(config)
56
+ config.mutation_function = random_mutation_function(config)
57
+ config.end_condition_function = genes_match_target_end_condition_function(config)
58
+ config.highest_fitness_callback = ->(member) { puts "Highest fitness: #{member.fitness} (#{member})" }
59
+ end
60
+
61
+ init_members = Array.new(configuration.population_size) { PetriDish::Member.new(fitness_function: configuration.fitness_function, genes: Array.new(target_genes.size) { genetic_material.sample }) }
62
+ PetriDish::World.run(configuration: configuration, members: init_members)
@@ -0,0 +1,135 @@
1
+ # require "petri_dish" # Uncomment this line and comment/remove the line below if you're using Petri Dish as a gem
2
+ require_relative "../lib/petri_dish"
3
+ require "csv"
4
+
5
+ XLIMIT = 10
6
+ YLIMIT = XLIMIT
7
+ NUM_OF_CITIES = 10
8
+ GENETIC_MATERIAL = (0..XLIMIT - 1).to_a
9
+
10
+ def random_uniq_city_gene_generation
11
+ result = []
12
+ until result.size == NUM_OF_CITIES
13
+ result << Gene.new(
14
+ x: GENETIC_MATERIAL.sample,
15
+ y: GENETIC_MATERIAL.sample
16
+ )
17
+ result.uniq!
18
+ end
19
+ result.shuffle
20
+ end
21
+
22
+ def fitness_function
23
+ ->(member) do
24
+ city_pairs = []
25
+ member.genes.each_cons(2) { |cities| city_pairs << cities }
26
+ # Return to the starting city
27
+ city_pairs << [member.genes.last, member.genes.first]
28
+ 1.0 / city_pairs.sum { |a, b| a.distance_to(b) }
29
+ end
30
+ end
31
+
32
+ def swap_mutation_function(configuration)
33
+ ->(member) do
34
+ mutated_genes = member.genes.dup
35
+ if configuration.mutation_rate > rand
36
+ gene_one_index = rand(mutated_genes.size)
37
+ gene_two_index = rand(mutated_genes.size)
38
+ mutated_genes[gene_one_index], mutated_genes[gene_two_index] = mutated_genes[gene_two_index], mutated_genes[gene_one_index]
39
+ end
40
+ PetriDish::Member.new(fitness_function: configuration.fitness_function, genes: mutated_genes)
41
+ end
42
+ end
43
+
44
+ def twenty_percent_tournament(configuration)
45
+ ->(members) do
46
+ members.sample(configuration.population_size * 0.2).max_by(2) { |member| member.fitness }
47
+ end
48
+ end
49
+
50
+ # In ordered crossover, we randomly select a subset of the first
51
+ # parent string and then fill the remainder of the route with the
52
+ # genes from the second parent in the order in which they appear,
53
+ # without duplicating any genes in the selected subset from the
54
+ # first parent
55
+ def random_ordered_crossover_function(configuration)
56
+ ->(members) do
57
+ start_slice_index, end_slice_index = rand(members[0].genes.size), rand(members[0].genes.size)
58
+ parent1_slice = members[0].genes[start_slice_index...end_slice_index]
59
+ parent2_contribution = members[1].genes - parent1_slice
60
+ child_genes = Array.new(members[0].genes.size)
61
+ child_genes[start_slice_index...end_slice_index] = parent1_slice
62
+ child_genes.map! { |gene| gene.nil? ? parent2_contribution.shift : gene }
63
+ PetriDish::Member.new(fitness_function: configuration.fitness_function, genes: child_genes)
64
+ end
65
+ end
66
+
67
+ def append_best_member_to_file
68
+ ->(member) do
69
+ File.open("best_member.txt", "a") do |file|
70
+ file.puts member.genes.join
71
+ end
72
+ end
73
+ end
74
+
75
+ def write_best_member_to_csv
76
+ ->(member) do
77
+ CSV.open("best_member.csv", "wb") do |csv|
78
+ csv << ["x", "y"]
79
+ member.genes.each do |gene|
80
+ csv << [gene.x, gene.y]
81
+ end
82
+ csv << [member.genes.first.x, member.genes.first.y]
83
+ end
84
+ end
85
+ end
86
+
87
+ class Gene
88
+ attr_reader :x, :y
89
+ def initialize(x: nil, y: nil)
90
+ @x = x
91
+ @y = y
92
+ end
93
+
94
+ def distance_to(other)
95
+ Math.sqrt((x - other.x)**2 + (y - other.y)**2)
96
+ end
97
+
98
+ def to_s
99
+ "(#{x}, #{y})"
100
+ end
101
+
102
+ # Override equality methods
103
+ def ==(other)
104
+ x == other.x && y == other.y
105
+ end
106
+
107
+ def eql?(other)
108
+ self == other
109
+ end
110
+
111
+ def hash
112
+ [x, y].hash
113
+ end
114
+ end
115
+
116
+ configuration = PetriDish::Configuration.configure do |config|
117
+ config.max_generations = 100
118
+ config.population_size = 100
119
+ config.mutation_rate = 0.01
120
+ config.genetic_material = GENETIC_MATERIAL
121
+ config.target_genes = random_uniq_city_gene_generation
122
+ config.mutation_function = swap_mutation_function(config)
123
+ config.fitness_function = fitness_function
124
+ config.parents_selection_function = twenty_percent_tournament(config)
125
+ config.crossover_function = random_ordered_crossover_function(config)
126
+ config.highest_fitness_callback = write_best_member_to_csv
127
+ # Rely on number of generations for end condition
128
+ config.end_condition_function = ->(_member) { false }
129
+ end
130
+
131
+ init_members = Array.new(configuration.population_size) { PetriDish::Member.new(fitness_function: configuration.fitness_function, genes: random_uniq_city_gene_generation) }
132
+ PetriDish::World.run(configuration: configuration, members: init_members)
133
+
134
+ # View CSV with YouPlot (https://github.com/red-data-tools/YouPlot):
135
+ # ruby examples/salesperson_example.rb && uplot line best_member.csv --canvas dot -h 45 -w 150 -H -d ',' && rm best_member.csv
@@ -0,0 +1,125 @@
1
+ module PetriDish
2
+ class Configuration
3
+ attr_accessor :logger,
4
+ :population_size,
5
+ :mutation_rate,
6
+ :genetic_material,
7
+ :elitism_rate,
8
+ :target_genes,
9
+ :max_generations,
10
+ :parents_selection_function,
11
+ :crossover_function,
12
+ :mutation_function,
13
+ :fitness_function,
14
+ :highest_fitness_callback,
15
+ :max_generation_reached_callback,
16
+ :end_condition_function,
17
+ :next_generation_callback,
18
+ :end_condition_reached_callback
19
+
20
+ def self.configure
21
+ yield(configuration = new)
22
+ configuration.validate!
23
+ configuration
24
+ end
25
+
26
+ def initialize
27
+ @logger = default_logger
28
+ @max_generations = default_max_generations
29
+ @population_size = default_population_size
30
+ @mutation_rate = default_mutation_rate
31
+ @elitism_rate = default_elitism_rate
32
+ @genetic_material = default_genetic_material
33
+ @target_genes = default_target_genes
34
+ @fitness_function = default_fitness_function
35
+ @parents_selection_function = default_parents_selection_function
36
+ @crossover_function = default_crossover_function
37
+ @mutation_function = default_mutation_function
38
+ @highest_fitness_callback = default_highest_fitness_callback
39
+ @end_condition_function = default_end_condition_function
40
+ @max_generation_reached_callback = default_max_generation_reached_callback
41
+ @next_generation_callback = default_next_generation_callback
42
+ @end_condition_reached_callback = default_end_condition_reached_callback
43
+ end
44
+
45
+ def validate!
46
+ raise ArgumentError, "logger must respond to :info" unless logger.respond_to?(:info)
47
+ raise ArgumentError, "max_generations must be greater than 0" unless max_generations > 0
48
+ raise ArgumentError, "population_size must be greater than 0" unless population_size > 0
49
+ raise ArgumentError, "mutation_rate must be between 0 and 1" unless mutation_rate >= 0 && mutation_rate <= 1
50
+ raise ArgumentError, "elitism_rate must be between 0 and 1" unless elitism_rate >= 0 && elitism_rate <= 1
51
+ raise ArgumentError, "genetic_material must be an Array" unless genetic_material.is_a?(Array)
52
+ raise ArgumentError, "target_genes must be an Array" unless target_genes.is_a?(Array)
53
+ raise ArgumentError, "fitness_function must respond to :call" unless fitness_function.respond_to?(:call)
54
+ raise ArgumentError, "parents_selection_function must respond to :call" unless parents_selection_function.respond_to?(:call)
55
+ raise ArgumentError, "crossover_function must respond to :call" unless crossover_function.respond_to?(:call)
56
+ raise ArgumentError, "mutation_function must respond to :call" unless mutation_function.respond_to?(:call)
57
+ raise ArgumentError, "end_condition_function must respond to :call" unless end_condition_function.respond_to?(:call)
58
+ raise ArgumentError, "highest_fitness_callback must respond to :call" unless highest_fitness_callback.respond_to?(:call)
59
+ raise ArgumentError, "max_generation_reached_callback must respond to :call" unless max_generation_reached_callback.respond_to?(:call)
60
+ raise ArgumentError, "next_generation_callback must respond to :call" unless next_generation_callback.respond_to?(:call)
61
+ raise ArgumentError, "end_condition_reached_callback must respond to :call" unless end_condition_reached_callback.respond_to?(:call)
62
+ end
63
+
64
+ def reset!
65
+ @logger = default_logger
66
+ @max_generations = default_max_generations
67
+ @population_size = default_population_size
68
+ @mutation_rate = default_mutation_rate
69
+ @elitism_rate = default_elitism_rate
70
+ @genetic_material = default_genetic_material
71
+ @target_genes = default_target_genes
72
+ @fitness_function = default_fitness_function
73
+ @parents_selection_function = default_parents_selection_function
74
+ @crossover_function = default_crossover_function
75
+ @mutation_function = default_mutation_function
76
+ @end_condition_function = default_end_condition_function
77
+ @highest_fitness_callback = default_highest_fitness_callback
78
+ @max_generation_reached_callback = default_max_generation_reached_callback
79
+ @next_generation_callback = default_next_generation_callback
80
+ @end_condition_reached_callback = default_end_condition_reached_callback
81
+ end
82
+
83
+ private
84
+
85
+ def default_logger
86
+ @logger = Logger.new($stdout).tap do |logger|
87
+ logger.level = Logger::INFO
88
+ end
89
+ end
90
+
91
+ def default_max_generations = 1
92
+
93
+ def default_population_size = 100
94
+
95
+ def default_mutation_rate = 0.005
96
+
97
+ def default_elitism_rate = 0.00
98
+
99
+ def default_genetic_material = []
100
+
101
+ def default_target_genes = nil
102
+
103
+ def default_fitness_function = ->(_member) { raise ArgumentError, "fitness_function must be set" }
104
+
105
+ def default_parents_selection_function = ->(_members) { raise ArgumentError, "parents_selection_function must be set" }
106
+
107
+ def default_crossover_function = ->(_members) { raise ArgumentError, "crossover_function must be set" }
108
+
109
+ def default_mutation_function = ->(_member) { raise ArgumentError, "mutation_function must be set" }
110
+
111
+ def default_end_condition_function = ->(_member) { false }
112
+
113
+ def default_highest_fitness_callback = ->(_member) { :noop }
114
+
115
+ # TODO: We might want to consider whether we really want to use `exit` as a
116
+ # default callback. This will stop the entire Ruby process, which could be
117
+ # surprising behavior if the user of the library doesn't override these
118
+ # callbacks.
119
+ def default_max_generation_reached_callback = -> { exit }
120
+
121
+ def default_next_generation_callback = ->(_generation) { :noop }
122
+
123
+ def default_end_condition_reached_callback = ->(_member) { exit }
124
+ end
125
+ end
@@ -0,0 +1,18 @@
1
+ module PetriDish
2
+ class Member
3
+ attr_reader :genes, :fitness_function
4
+
5
+ def initialize(genes:, fitness_function:)
6
+ @fitness_function = fitness_function
7
+ @genes = genes
8
+ end
9
+
10
+ def fitness
11
+ @fitness ||= fitness_function.call(self)
12
+ end
13
+
14
+ def to_s
15
+ genes.join("")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,40 @@
1
+ module PetriDish
2
+ class Metadata
3
+ attr_reader :generation_count, :id, :start_time, :highest_fitness, :last_fitness_increase
4
+
5
+ def initialize
6
+ @id = SecureRandom.uuid
7
+ @generation_count = 0
8
+ @highest_fitness = 0
9
+ @last_fitness_increase = 0
10
+ @start_time = nil
11
+ end
12
+
13
+ def start
14
+ @start_time = Time.now
15
+ end
16
+
17
+ def increment_generation
18
+ @generation_count += 1
19
+ end
20
+
21
+ def set_highest_fitness(fitness)
22
+ @highest_fitness = fitness
23
+ @last_fitness_increase = generation_count
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ id: id,
29
+ generation_count: generation_count,
30
+ highest_fitness: highest_fitness,
31
+ elapsed_time: (Time.now - start_time).round(2),
32
+ last_fitness_increase: last_fitness_increase
33
+ }
34
+ end
35
+
36
+ def to_json
37
+ to_h.to_json
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriDish
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,66 @@
1
+ RubyVM::InstructionSequence.compile_option = {
2
+ tailcall_optimization: true,
3
+ trace_instruction: false
4
+ }
5
+
6
+ require "json"
7
+ require "logger"
8
+ require "securerandom"
9
+
10
+ require_relative "../petri_dish"
11
+
12
+ module PetriDish
13
+ class World
14
+ class << self
15
+ attr_accessor :metadata
16
+ attr_reader :configuration, :end_condition_reached
17
+
18
+ def run(
19
+ members:,
20
+ configuration: Configuration.new,
21
+ metadata: Metadata.new
22
+ )
23
+ configuration.next_generation_callback.call(metadata.generation_count)
24
+
25
+ end_condition_reached = false
26
+ max_generation_reached = false
27
+
28
+ if metadata.generation_count.zero?
29
+ configuration.logger.info "Run started."
30
+ metadata.start
31
+ end
32
+
33
+ configuration.logger.info(metadata.to_json)
34
+
35
+ if metadata.generation_count >= configuration.max_generations
36
+ configuration.max_generation_reached_callback.call
37
+ max_generation_reached = true
38
+ end
39
+
40
+ elitism_count = (configuration.population_size * configuration.elitism_rate).round
41
+ elite_members = members.sort_by(&:fitness).last(elitism_count)
42
+
43
+ new_members = (configuration.population_size - elitism_count).times.map do
44
+ child_member = configuration.crossover_function.call(configuration.parents_selection_function.call(members))
45
+
46
+ configuration.mutation_function.call(child_member).tap do |mutated_child|
47
+ if metadata.highest_fitness < mutated_child.fitness
48
+ metadata.set_highest_fitness(mutated_child.fitness)
49
+ configuration.highest_fitness_callback.call(mutated_child)
50
+
51
+ configuration.logger.info(metadata.to_json)
52
+ end
53
+
54
+ if configuration.end_condition_function.call(mutated_child)
55
+ configuration.end_condition_reached_callback.call(mutated_child)
56
+ end_condition_reached = true
57
+ end
58
+ end
59
+ end
60
+
61
+ metadata.increment_generation
62
+ run(members: (new_members + elite_members), configuration: configuration, metadata: metadata) unless end_condition_reached || max_generation_reached
63
+ end
64
+ end
65
+ end
66
+ end
data/lib/petri_dish.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "petri_dish/version"
4
+ require_relative "petri_dish/configuration"
5
+ require_relative "petri_dish/metadata"
6
+ require_relative "petri_dish/world"
7
+ require_relative "petri_dish/member"
8
+
9
+ module PetriDish
10
+ class Error < StandardError; end
11
+ # Your code goes here...
12
+ end
@@ -0,0 +1,4 @@
1
+ module PetriDish
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: petri_dish_lab
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Thomas Countz
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-07-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: ruby-lsp
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: standardrb
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: Petri Dish allows for various configuration options to let users to customize
70
+ and experiment with different parameters and functions of the evolutionary algorithm.
71
+ email:
72
+ - thomascountz@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".DS_Store"
78
+ - ".rspec"
79
+ - ".standard.yml"
80
+ - CHANGELOG.md
81
+ - CODE_OF_CONDUCT.md
82
+ - LICENSE.txt
83
+ - README.md
84
+ - Rakefile
85
+ - examples/lazy_dog_example.rb
86
+ - examples/salesperson_example.rb
87
+ - lib/petri_dish.rb
88
+ - lib/petri_dish/configuration.rb
89
+ - lib/petri_dish/member.rb
90
+ - lib/petri_dish/metadata.rb
91
+ - lib/petri_dish/version.rb
92
+ - lib/petri_dish/world.rb
93
+ - sig/petri_dish.rbs
94
+ homepage: https://github.com/thomascountz/petri_dish
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ allowed_push_host: https://rubygems.org
99
+ homepage_uri: https://github.com/thomascountz/petri_dish
100
+ source_code_uri: https://github.com/thomascountz/petri_dish
101
+ changelog_uri: https://github.com/thomascountz/petri_dish/CHANGELOG.md
102
+ post_install_message:
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 2.6.0
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 3.4.17
118
+ signing_key:
119
+ specification_version: 4
120
+ summary: A Ruby library for implementing and experimenting with evolutionary algorithms.
121
+ test_files: []