From 34dcccc70b46fdfd514b110412cf4fafa576ffca Mon Sep 17 00:00:00 2001 From: Eric Matte <14026234@usherbrooke.ca> Date: Sat, 9 Nov 2019 15:52:52 -0500 Subject: [PATCH] Generic genetic optimizer + tests --- .rubocop.yml | 3 + .vscode/settings.json | 3 +- Gemfile | 1 + Gemfile.lock | 18 ++++ lib/genetic/algorithm/chromosome.rb | 34 +++---- lib/genetic/algorithm/fitness_recorder.rb | 31 ++++--- lib/genetic/algorithm/optimiser.rb | 76 ++++++++++++++++ test/genetic/algorithm/chromosome_test.rb | 45 +++------ test/genetic/algorithm/optimiser_test.rb | 106 ++++++++++++++++++++++ 9 files changed, 253 insertions(+), 64 deletions(-) create mode 100644 lib/genetic/algorithm/optimiser.rb create mode 100644 test/genetic/algorithm/optimiser_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 8d3f4a6..49ee8da 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,6 +10,9 @@ Metrics/LineLength: Metrics/MethodLength: Max: 35 +Metrics/ClassLength: + Enabled: false + Gemspec/OrderedDependencies: Enabled: false diff --git a/.vscode/settings.json b/.vscode/settings.json index e0fcbe8..48eaef8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { - "editor.formatOnSave": false, + "editor.formatOnSave": true, + "editor.formatOnSaveTimeout": 5000, "editor.tabSize": 2, "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, diff --git a/Gemfile b/Gemfile index 47e1f4d..85d6d23 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,4 @@ source "https://rubygems.org" # Specify your gem's dependencies in genetic-algorithm-resolver.gemspec gemspec +gem "rubocop" diff --git a/Gemfile.lock b/Gemfile.lock index 9203824..12d8a84 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,13 +6,30 @@ PATH GEM remote: https://rubygems.org/ specs: + ast (2.4.0) debase (0.2.4.1) debase-ruby_core_source (>= 0.10.2) debase-ruby_core_source (0.10.6) + jaro_winkler (1.5.2) minitest (5.11.3) + parallel (1.13.0) + parser (2.6.2.0) + ast (~> 2.4.0) + psych (3.1.0) + rainbow (3.0.0) rake (12.3.3) + rubocop (0.66.0) + jaro_winkler (~> 1.5.1) + parallel (~> 1.10) + parser (>= 2.5, != 2.5.1.1) + psych (>= 3.1.0) + rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 1.6) ruby-debug-ide (0.7.0) rake (>= 0.8.1) + ruby-progressbar (1.10.0) + unicode-display_width (1.5.0) PLATFORMS ruby @@ -23,6 +40,7 @@ DEPENDENCIES genetic-algorithm-resolver! minitest (~> 5.0) rake (~> 12.3.2) + rubocop ruby-debug-ide (~> 0.7) BUNDLED WITH diff --git a/lib/genetic/algorithm/chromosome.rb b/lib/genetic/algorithm/chromosome.rb index aa10c62..fbeced9 100644 --- a/lib/genetic/algorithm/chromosome.rb +++ b/lib/genetic/algorithm/chromosome.rb @@ -1,21 +1,21 @@ -# A member of a given population -class Genetic::Algorithm::Chromosome - attr_accessor :genes - attr_accessor :fitness +module Genetic + module Algorithm + # A member of a given population + class Chromosome + attr_reader :genes + attr_reader :fitness - def initialize(genes:, fitness:) - @genes = genes - @fitness = fitness - end + def initialize(genes, fitness_calculator) + @genes = genes + @fitness = fitness_calculator.call(genes) + end - # Randomly change one of its gene. - # Help maintain diversity within the population and prevent premature convergence. - # @param [(Object | Float)[][]] ranges - The available ranges to mutate from for all genes. ranges.lenght must equals genes.length - # @param [Boolean] sampleFromRanges - # If true: take one of the available values for a given range - # if false: take a random floating value between ranges[i][0] and ranges[i][1] - def mutate(ranges, sample_from_ranges = false) - i = rand(0..(genes.length - 1)) - @genes[i] = sample_from_ranges ? ranges[i].sample : rand(ranges[i][0]..ranges[i][1]) + # Randomly change one of its gene. + # Help maintain diversity within the population and prevent premature convergence. + def mutate(genes_generator) + i = rand(0..(genes.length - 1)) + @genes[i] = genes_generator.call(i) + end + end end end diff --git a/lib/genetic/algorithm/fitness_recorder.rb b/lib/genetic/algorithm/fitness_recorder.rb index c554a0c..bc14ea3 100644 --- a/lib/genetic/algorithm/fitness_recorder.rb +++ b/lib/genetic/algorithm/fitness_recorder.rb @@ -1,9 +1,10 @@ # Record and display best individuals accross generations class Genetic::Algorithm::FitnessRecorder - attr_accessor :best_individual + attr_reader :best_individual def initialize @best_individual = nil + @best_individual_generation = nil @max_fitness = [] @max_overall_finess = [] @average_fitness = [] @@ -12,12 +13,13 @@ def initialize def record_generation(generation, population) best_generation_individual = population.max_by(&:fitness) - # Save best individual across all generations - if best_generation_individual.fitness > (@best_individual&.fitness || -1e10) + # Save the best individual across all generations + if best_generation_individual.fitness > @best_individual&.fitness @best_individual = best_generation_individual + @best_individual_generation = generation end - # Record progress information + # Record progress information across all generations @max_fitness << best_generation_individual.fitness @max_overall_finess << @best_individual.fitness average = population.map(&:fitness).inject(:+) / population.length @@ -29,18 +31,17 @@ def record_generation(generation, population) puts " Average population fitness: #{average}" end - def display_best_individual(shifts) + def display_best_individual puts "\n" - puts "Result: Best overall individual fitness: #{@best_individual.fitness}" - puts " Shift\t->\tSelected employee:" - print_shift = ->(shift) { "[#{shift.start_at.localtime} - #{shift.end_at.localtime}]: #{shift.position.name}" } - print_employee = ->(employee) { "#{employee.profile.full_name} (#{employee.email})" } - puts shifts.each_with_index.map { |s, i| "#{print_shift[s[:shift]]}\t->\t#{print_employee[@best_individual.genes[i].user]}" } - - # puts "#{@max_fitness.each_with_index.map { |_, i| i }}," - # puts "#{@max_fitness}," - # puts "#{@max_overall_finess}," - # puts "#{@average_fitness}," + puts "Result: The best overall individual was found on generation #{@best_individual_generation} with:" + puts " Fitness: #{@best_individual.fitness}" + puts " Genes: #{@best_individual.fitness}" + puts "\n---\n" + puts "Generations evlotion:" + puts "#{@max_fitness.each_with_index.map { |_, i| i }}," + puts "#{@max_fitness}," + puts "#{@max_overall_finess}," + puts "#{@average_fitness}," end def save_best_individual_genes diff --git a/lib/genetic/algorithm/optimiser.rb b/lib/genetic/algorithm/optimiser.rb new file mode 100644 index 0000000..bcf8821 --- /dev/null +++ b/lib/genetic/algorithm/optimiser.rb @@ -0,0 +1,76 @@ +module Genetic + module Algorithm + class Optimiser + attr_accessor :crossover_probability # Int + attr_accessor :mutation_probability # Int + attr_accessor :population_size # Int + attr_accessor :number_of_generations # Int + attr_accessor :genes_generator # (index: Int?) -> Array | Gene if index.nil? + attr_accessor :fitness_calculator # (genes: Array) -> Int + + def execute + population = initialize_population + + fitness_recorder = FitnessRecorder.new + @number_of_generations.times.each do |generation| + if generation != @number_of_generations + population = do_natural_selection(population) + end + fitness_recorder.record_generation(generation, population) + end + + fitness_recorder.display_best_individual + end + + private + + def initialize_population + population = [] + @population_size.times.each do + population << Chromosome.new(@genes_generator.call, @fitness_calculator) + end + population + end + + def do_natural_selection(population) + total_fitness = population.map(&:fitness).inject(:+) + new_population = [] + (@population_size / 2).times.each do + parent1 = weighted_random_sampling(population, total_fitness) + parent2 = weighted_random_sampling(population - [parent1], total_fitness) + + child1, child2 = mate(parent1, parent2) + + new_population << child1 + new_population << child2 + end + + new_population + end + + def weighted_random_sampling(population, total_fitness) + population.max_by { |chromosome| rand**(total_fitness / chromosome.fitness) } + end + + def mate(parent1, parent2) + # crossover + if rand < @crossover_probability + i = rand(1..parent1.genes.length - 1) + child1 = Chromosome.new(parent1.genes[0..i - 1] + parent2.genes[i..-1], @fitness_calculator) + child2 = Chromosome.new(parent2.genes[0..i - 1] + parent1.genes[i..-1], @fitness_calculator) + else + child1 = Chromosome.new(parent1.genes.dup, @fitness_calculator) + child2 = Chromosome.new(parent2.genes.dup, @fitness_calculator) + end + + # mutation + if rand < @mutation_probability + child1.mutate(@genes_generator) + child2.mutate(@genes_generator) + end + + [child1, child2] + end + end + end +end diff --git a/test/genetic/algorithm/chromosome_test.rb b/test/genetic/algorithm/chromosome_test.rb index 2fc9a88..1223286 100644 --- a/test/genetic/algorithm/chromosome_test.rb +++ b/test/genetic/algorithm/chromosome_test.rb @@ -2,48 +2,31 @@ require "genetic/algorithm/chromosome" class Genetic::Algorithm::ChromosomeTest < Minitest::Test - def test_it_can_mutate_floats - 20.times do - test_genes = Array.new(rand(1..20)) { rand(0..100) } - test_ranges = Array.new(test_genes.length) do |i| - el = test_genes[i].to_f - [rand(el - 50.0..el), rand(el..el + 50.0)] - end - - chromosome = Genetic::Algorithm::Chromosome.new(fitness: nil, genes: test_genes.dup) - chromosome.mutate(test_ranges) - - number_of_mutated_genes = 0 - chromosome.genes.each_with_index do |el, i| - if el != test_genes[i] - number_of_mutated_genes += 1 - end - - assert(test_ranges[i][0] < el && el < test_ranges[i][1]) - end - assert_equal(1, number_of_mutated_genes) - end - end - def test_it_can_mutate_with_data_range test_ranges = [%w[male female], [18, 19, 20, 21, 22], ["🔥", "❄️", "🌎", "💨"]] - test_genes = Array.new(test_ranges.length) { |i| test_ranges[i][0] } + genes_generator = proc do |index = nil| + index.nil? ? Array.new(test_ranges.length) { |i| test_ranges[i][0] } : test_ranges[index].sample + end + test_genes = genes_generator.call - chromosome = Genetic::Algorithm::Chromosome.new(fitness: nil, genes: test_genes.dup) + chromosome = Genetic::Algorithm::Chromosome.new(test_genes.dup, method(:fitness_calculator)) 20.times do |i| # Discrete mutations can sometimes return the same value assert(false, "Unable to mutate") if i == 20 - chromosome.mutate(test_ranges, true) + chromosome.mutate(genes_generator) break if chromosome.genes != test_genes end number_of_mutated_genes = 0 chromosome.genes.each_with_index do |el, i| - if el != test_genes[i] - number_of_mutated_genes += 1 - end - - assert((test_ranges[i].include? el)) + number_of_mutated_genes += 1 if el != test_genes[i] + assert(test_ranges[i].include?(el)) end assert_equal(1, number_of_mutated_genes) end + + private + + def fitness_calculator(_genes) + 1 + end end diff --git a/test/genetic/algorithm/optimiser_test.rb b/test/genetic/algorithm/optimiser_test.rb new file mode 100644 index 0000000..d710cd0 --- /dev/null +++ b/test/genetic/algorithm/optimiser_test.rb @@ -0,0 +1,106 @@ +require "test_helper" +require "genetic/algorithm/optimiser" + +class Genetic::Algorithm::OptimiserTest < Minitest::Test + def test_it_initialize_population + ga_optimiser = Genetic::Algorithm::Optimiser.new + ga_optimiser.population_size = population_size + ga_optimiser.genes_generator = method(:genes_generator) + ga_optimiser.fitness_calculator = method(:fitness_calculator) + + test_population = ga_optimiser.send(:initialize_population) + + assert(test_population.length == population_size) + assert(test_population.map(&:fitness).inject(:+) >= population_size) + end + + def test_it_do_natural_selection + ga_optimiser = Genetic::Algorithm::Optimiser.new + ga_optimiser.crossover_probability = 0.7 + ga_optimiser.mutation_probability = 0.1 + ga_optimiser.population_size = population_size + ga_optimiser.genes_generator = method(:genes_generator) + ga_optimiser.fitness_calculator = method(:fitness_calculator) + first_population = ga_optimiser.send(:initialize_population) + last_population = first_population + + 20.times do + last_population = ga_optimiser.send(:do_natural_selection, last_population) + end + + assert(first_population.length == population_size) + assert(last_population.length == population_size) + + first_gen_fitness = first_population.map(&:fitness).inject(:+) + last_gen_fitness = last_population.map(&:fitness).inject(:+) + assert(first_gen_fitness < last_gen_fitness) + end + + def test_mate_with_0_proability_returns_same_chromosomes + ga_optimiser = Genetic::Algorithm::Optimiser.new + ga_optimiser.crossover_probability = 0.0 + ga_optimiser.mutation_probability = 0.0 + ga_optimiser.genes_generator = method(:genes_generator) + ga_optimiser.fitness_calculator = method(:fitness_calculator) + + parent1 = Genetic::Algorithm::Chromosome.new(genes_generator, method(:fitness_calculator)) + parent2 = Genetic::Algorithm::Chromosome.new(genes_generator, method(:fitness_calculator)) + child1, child2 = ga_optimiser.send(:mate, parent1, parent2) + + assert(parent1.genes != parent2.genes) + assert(child1.genes == parent1.genes) + assert(child2.genes == parent2.genes) + end + + def test_mate_crossover + ga_optimiser = Genetic::Algorithm::Optimiser.new + ga_optimiser.crossover_probability = 1.0 + ga_optimiser.mutation_probability = 0.0 + ga_optimiser.genes_generator = method(:genes_generator) + ga_optimiser.fitness_calculator = method(:fitness_calculator) + + parent1 = Genetic::Algorithm::Chromosome.new(genes_generator, method(:fitness_calculator)) + parent2 = Genetic::Algorithm::Chromosome.new(genes_generator, method(:fitness_calculator)) + child1, child2 = ga_optimiser.send(:mate, parent1, parent2) + + assert(parent1.genes != parent2.genes) + assert(child1.genes != parent1.genes) + assert(child2.genes != parent2.genes) + end + + def test_mate_make_one_mutation_per_child + ga_optimiser = Genetic::Algorithm::Optimiser.new + ga_optimiser.crossover_probability = 0.0 + ga_optimiser.mutation_probability = 1.0 + ga_optimiser.genes_generator = method(:genes_generator) + ga_optimiser.fitness_calculator = method(:fitness_calculator) + + parent1 = Genetic::Algorithm::Chromosome.new(genes_generator, method(:fitness_calculator)) + parent2 = Genetic::Algorithm::Chromosome.new(genes_generator, method(:fitness_calculator)) + child1, child2 = ga_optimiser.send(:mate, parent1, parent2) + + assert(parent1.genes != parent2.genes) + assert(child1.genes != parent1.genes) + assert(child2.genes != parent2.genes) + + number_of_mutations = 0 + child1.genes.each_with_index { |g, i| number_of_mutations += 1 if g != parent1.genes[i] } + child2.genes.each_with_index { |g, i| number_of_mutations += 1 if g != parent2.genes[i] } + assert(number_of_mutations == 2) + end + + private + + def population_size + 50 + end + + def genes_generator(index = nil) + random_genes = Array.new(5) { rand(1..100) } + index.nil? ? random_genes : random_genes[index] + end + + def fitness_calculator(genes) + genes.inject(:+) + end +end