evolvable 1.0.0 → 1.2.0

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +4 -0
  3. data/CHANGELOG.md +56 -1
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +38 -21
  6. data/LICENSE +21 -0
  7. data/README.md +234 -161
  8. data/README_YARD.md +237 -0
  9. data/bin/console +18 -5
  10. data/evolvable.gemspec +2 -2
  11. data/examples/ascii_art.rb +62 -0
  12. data/examples/ascii_gene.rb +9 -0
  13. data/examples/hello_world.rb +91 -0
  14. data/examples/images/diagram.png +0 -0
  15. data/examples/stickman.rb +77 -0
  16. data/exe/hello +16 -0
  17. data/lib/evolvable/count_gene.rb +42 -0
  18. data/lib/evolvable/equalize_goal.rb +29 -0
  19. data/lib/evolvable/evaluation.rb +29 -6
  20. data/lib/evolvable/evolution.rb +40 -8
  21. data/lib/evolvable/gene.rb +54 -2
  22. data/lib/evolvable/gene_combination.rb +73 -0
  23. data/lib/evolvable/genome.rb +86 -0
  24. data/lib/evolvable/goal.rb +36 -3
  25. data/lib/evolvable/maximize_goal.rb +30 -0
  26. data/lib/evolvable/minimize_goal.rb +29 -0
  27. data/lib/evolvable/mutation.rb +66 -15
  28. data/lib/evolvable/point_crossover.rb +33 -19
  29. data/lib/evolvable/population.rb +171 -31
  30. data/lib/evolvable/rigid_count_gene.rb +17 -0
  31. data/lib/evolvable/search_space.rb +181 -0
  32. data/lib/evolvable/selection.rb +28 -1
  33. data/lib/evolvable/serializer.rb +21 -0
  34. data/lib/evolvable/uniform_crossover.rb +28 -8
  35. data/lib/evolvable/version.rb +1 -1
  36. data/lib/evolvable.rb +197 -29
  37. metadata +38 -27
  38. data/.rubocop.yml +0 -20
  39. data/examples/evolvable_string/char_gene.rb +0 -9
  40. data/examples/evolvable_string.rb +0 -32
  41. data/lib/evolvable/gene_crossover.rb +0 -28
  42. data/lib/evolvable/gene_space.rb +0 -37
  43. data/lib/evolvable/goal/equalize.rb +0 -19
  44. data/lib/evolvable/goal/maximize.rb +0 -19
  45. data/lib/evolvable/goal/minimize.rb +0 -19
@@ -1,25 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evolvable
4
+ #
5
+ # @readme
6
+ # After a population's instances are evaluated, they undergo evolution.
7
+ # The default evolution object is composed of selection,
8
+ # crossover, and mutation objects and applies them as operations to
9
+ # a population's evolvables in that order.
10
+ #
4
11
  class Evolution
5
12
  extend Forwardable
6
13
 
14
+ #
15
+ # Initializes a new evolution object.
16
+ #
17
+ # Keyword arguments:
18
+ #
19
+ # #### selection
20
+ # The default is `Selection.new`
21
+ # #### crossover - deprecated
22
+ # The default is `GeneCrossover.new`
23
+ # #### mutation
24
+ # The default is `Mutation.new`
25
+ #
7
26
  def initialize(selection: Selection.new,
8
- crossover: GeneCrossover.new,
27
+ combination: GeneCombination.new,
28
+ crossover: nil, # deprecated
9
29
  mutation: Mutation.new)
10
30
  @selection = selection
11
- @crossover = crossover
31
+ @combination = crossover || combination
12
32
  @mutation = mutation
13
33
  end
14
34
 
15
- attr_accessor :selection,
16
- :crossover,
17
- :mutation
35
+ attr_reader :selection,
36
+ :combination,
37
+ :mutation
38
+
39
+ def selection=(val)
40
+ @selection = Evolvable.new_object(@selection, val, Selection)
41
+ end
42
+
43
+ def combination=(val)
44
+ @combination = Evolvable.new_object(@combination, val, GeneCombination)
45
+ end
46
+
47
+ def mutation=(val)
48
+ @mutation = Evolvable.new_object(@mutation, val, Mutation)
49
+ end
18
50
 
19
51
  def call(population)
20
- @selection.call(population)
21
- @crossover.call(population)
22
- @mutation.call(population)
52
+ selection.call(population)
53
+ combination.call(population)
54
+ mutation.call(population)
23
55
  population
24
56
  end
25
57
  end
@@ -1,13 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evolvable
4
+ #
5
+ # @readme
6
+ # For evolution to be effective, an evolvable's genes must be able to influence
7
+ # its behavior. Evolvables are composed of genes that can be used to run simple
8
+ # functions or orchestrate complex interactions. The level of abstraction is up
9
+ # to you.
10
+ #
11
+ # Defining gene classes requires encapsulating some "sample space" and returning
12
+ # a sample outcome when a gene attribute is accessed. For evolution to proceed
13
+ # in a non-random way, the same sample outcome should be returned every time
14
+ # a particular gene is accessed with a particular set of parameters.
15
+ # Memoization is a useful technique for doing just this. The
16
+ # [memo_wise](https://github.com/panorama-ed/memo_wise) gem may be useful for
17
+ # more complex memoizations.
18
+ #
19
+ # @example
20
+ # # This gene generates a random hexidecimal color code for use by evolvables.
21
+ #
22
+ # require 'securerandom'
23
+ #
24
+ # class ColorGene
25
+ # include Evolvable::Gene
26
+ #
27
+ # def hex_code
28
+ # @hex_code ||= SecureRandom.hex(3)
29
+ # end
30
+ # end
31
+ #
32
+
4
33
  module Gene
5
34
  def self.included(base)
6
- def base.crossover(gene_a, gene_b)
35
+ base.extend(ClassMethods)
36
+ end
37
+
38
+ module ClassMethods
39
+ def key=(val)
40
+ @key = val
41
+ end
42
+
43
+ def key
44
+ @key
45
+ end
46
+
47
+ def combine(gene_a, gene_b)
7
48
  [gene_a, gene_b].sample
8
49
  end
50
+
51
+ #
52
+ # @deprecated
53
+ # Will be removed in 2.0
54
+ # Use {#combine}
55
+ #
56
+ alias crossover combine
9
57
  end
10
58
 
11
- attr_accessor :instance, :key
59
+ attr_accessor :evolvable
60
+
61
+ def key
62
+ self.class.key
63
+ end
12
64
  end
13
65
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evolvable
4
+ #
5
+ # @readme
6
+ # Combination generates new evolvable instances by combining the genes of selected instances.
7
+ # You can think of it as a mixing of parent genes from one generation to
8
+ # produce the next generation.
9
+ #
10
+ # You may choose from a selection of combination objects or implement your own.
11
+ # The default combination object is `Evolvable::GeneCombination`.
12
+ #
13
+ # Custom crossover objects must implement the `#call` method which accepts
14
+ # the population as the first object.
15
+ # Enables gene types to define combination behaviors.
16
+ #
17
+ # Each gene class can implement a unique behavior for
18
+ # combination by overriding the following default implementation
19
+ # which mirrors the behavior of `Evolvable::UniformCrossover`
20
+ #
21
+ class GeneCombination
22
+ def call(population)
23
+ new_evolvables(population, population.size)
24
+ population
25
+ end
26
+
27
+ def new_evolvables(population, count)
28
+ parent_genome_cycle = population.new_parent_genome_cycle
29
+ Array.new(count) do
30
+ genome = build_genome(parent_genome_cycle.next)
31
+ population.new_evolvable(genome: genome)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def build_genome(genome_pair)
38
+ new_config = {}
39
+ genome_1, genome_2 = genome_pair.shuffle!
40
+ genome_1.each do |gene_key, gene_config_1|
41
+ gene_config_2 = genome_2.config[gene_key]
42
+ count_gene = combine_count_genes(gene_config_1, gene_config_2)
43
+ genes = combine_genes(count_gene.count, gene_config_1, gene_config_2)
44
+ new_config[gene_key] = { count_gene: count_gene, genes: genes }
45
+ end
46
+ Genome.new(config: new_config)
47
+ end
48
+
49
+ def combine_count_genes(gene_config_1, gene_config_2)
50
+ count_gene_1 = gene_config_1[:count_gene]
51
+ count_gene_2 = gene_config_2[:count_gene]
52
+ count_gene_1.class.combine(count_gene_1, count_gene_2)
53
+ end
54
+
55
+ def combine_genes(count, gene_config_1, gene_config_2)
56
+ genes_1 = gene_config_1[:genes]
57
+ genes_2 = gene_config_2[:genes]
58
+ first_gene = genes_1.first || genes_2.first
59
+ return [] unless first_gene
60
+
61
+ gene_class = first_gene.class
62
+ Array.new(count) do |index|
63
+ gene_a = genes_1[index]
64
+ gene_b = genes_2[index]
65
+ if gene_a && gene_b
66
+ gene_class.combine(gene_a, gene_b)
67
+ else
68
+ gene_a || gene_b || gene_class.new
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evolvable
4
+ #
5
+ # @readme
6
+ # TODO...
7
+ #
8
+ class Genome
9
+ extend Forwardable
10
+
11
+ def self.load(data, serializer: Serializer)
12
+ new(config: serializer.load(data))
13
+ end
14
+
15
+ def initialize(config: {})
16
+ @config = config
17
+ end
18
+
19
+ attr_reader :config
20
+
21
+ #
22
+ # Returns the first gene with the given key. In the Melody example above, the instrument gene has the key `:instrument` so we might write something like: `instrument_gene = melody.find_gene(instrument)`
23
+ #
24
+ # @param [<Type>] key <description>
25
+ #
26
+ # @return [<Type>] <description>
27
+ #
28
+ def find_gene(key)
29
+ @config.dig(key, :genes, 0)
30
+ end
31
+
32
+ #
33
+ # Returns an array of genes that have the given key. Gene keys are defined in the [EvolvableClass.search_space](#evolvableclasssearch_space) method. In the Melody example above, the key for the note genes would be `:notes`. The following would return an array of them: `note_genes = melody.find_genes(:notes)`
34
+ #
35
+ # @param [<Type>] *keys <description>
36
+ #
37
+ # @return [<Type>] <description>
38
+ #
39
+ def find_genes(*keys)
40
+ keys.flatten!
41
+ return @config.dig(keys.first, :genes) if keys.count <= 1
42
+
43
+ @config.values_at(*keys).flat_map { _1&.fetch(:genes, []) || [] }
44
+ end
45
+
46
+ #
47
+ # <Description>
48
+ #
49
+ # @param [<Type>] key <description>
50
+ #
51
+ # @return [<Type>] <description>
52
+ #
53
+ def find_genes_count(key)
54
+ find_count_gene(key).count
55
+ end
56
+
57
+ #
58
+ # <Description>
59
+ #
60
+ # @param [<Type>] key <description>
61
+ #
62
+ # @return [<Type>] <description>
63
+ #
64
+ def find_count_gene(key)
65
+ @config.dig(key, :count_gene)
66
+ end
67
+
68
+ def_delegators :config, :each
69
+
70
+ def gene_keys
71
+ @config.keys
72
+ end
73
+
74
+ def genes
75
+ @config.flat_map { |_gene_key, gene_config| gene_config[:genes] }
76
+ end
77
+
78
+ def inspect
79
+ self.class.name
80
+ end
81
+
82
+ def dump(serializer: Serializer)
83
+ serializer.dump @config
84
+ end
85
+ end
86
+ end
@@ -1,18 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evolvable
4
- module Goal
4
+
5
+ #
6
+ # The goal for a population can be specified via assignment - `population.goal = Evolvable::Goal::Equalize.new` - or by passing an evaluation object when [initializing a population](#evolvablepopulationnew).
7
+ #
8
+ # You can intialize the `Evolvable::Evaluation` object with any goal object like this:
9
+ #
10
+ # You can implement custom goal object like so:
11
+ #
12
+ # @example
13
+ # goal_object = SomeGoal.new(value: 100)
14
+ # Evolvable::Evaluation.new(goal_object)
15
+ #
16
+ # or more succinctly like this:
17
+ #
18
+ # @example
19
+ # Evolvable::Evaluation.new(:maximize) # Uses default goal value of Float::INFINITY
20
+ # Evolvable::Evaluation.new(maximize: 50) # Sets goal value to 50
21
+ # Evolvable::Evaluation.new(:minimize) # Uses default goal value of -Float::INFINITY
22
+ # Evolvable::Evaluation.new(minimize: 100) # Sets goal value to 100
23
+ # Evolvable::Evaluation.new(:equalize) # Uses default goal value of 0
24
+ # Evolvable::Evaluation.new(equalize: 1000) # Sets goal value to 1000
25
+ #
26
+ # @example
27
+ # class CustomGoal < Evolvable::Goal
28
+ # def evaluate(instance)
29
+ # # Required by Evolvable::Evaluation in order to sort instances in preparation for selection.
30
+ # end
31
+ #
32
+ # def met?(instance)
33
+ # # Used by Evolvable::Population#evolve to stop evolving when the goal value has been reached.
34
+ # end
35
+ # end
36
+ #
37
+ class Goal
5
38
  def initialize(value: nil)
6
39
  @value = value if value
7
40
  end
8
41
 
9
42
  attr_accessor :value
10
43
 
11
- def evaluate(_instance)
44
+ def evaluate(_evolvable)
12
45
  raise Errors::UndefinedMethod, "#{self.class.name}##{__method__}"
13
46
  end
14
47
 
15
- def met?(_instance)
48
+ def met?(_evolvable)
16
49
  raise Errors::UndefinedMethod, "#{self.class.name}##{__method__}"
17
50
  end
18
51
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evolvable
4
+ #
5
+ # Prioritizes instances with greater values. This is the default.
6
+ #
7
+ # The default goal value is `Float::INFINITY`, but it can be reassigned
8
+ # to any numeric value.
9
+ #
10
+ class MaximizeGoal < Goal
11
+ def value
12
+ @value ||= Float::INFINITY
13
+ end
14
+
15
+ def evaluate(evolvable)
16
+ evolvable.value
17
+ end
18
+
19
+ def met?(evolvable)
20
+ evolvable.value >= value
21
+ end
22
+ end
23
+
24
+ #
25
+ # @deprecated
26
+ # Will be removed in 2.0.
27
+ # Use {MaximizeGoal} instead
28
+ #
29
+ class Goal::Maximize < MaximizeGoal; end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evolvable
4
+ # Prioritizes instances with lesser values.
5
+ #
6
+ # The default goal value is `-Float::INFINITY`, but it can be reassigned
7
+ # to any numeric value
8
+ #
9
+ class MinimizeGoal < Goal
10
+ def value
11
+ @value ||= -Float::INFINITY
12
+ end
13
+
14
+ def evaluate(evolvable)
15
+ -evolvable.value
16
+ end
17
+
18
+ def met?(evolvable)
19
+ evolvable.value <= value
20
+ end
21
+ end
22
+
23
+ #
24
+ # @deprecated
25
+ # Will be removed in 2.0.
26
+ # Use {MinimizeGoal} instead
27
+ #
28
+ class Goal::Minimize < MinimizeGoal; end
29
+ end
@@ -1,42 +1,93 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evolvable
4
+ #
5
+ # @readme
6
+ # Mutation serves the role of increasing genetic variation. When an evolvable
7
+ # undergoes a mutation, one or more of its genes are replaced by newly
8
+ # initialized ones. In effect, a gene mutation invokes a new random outcome
9
+ # from the genetic search space.
10
+ #
11
+ # Mutation frequency is configurable using the `probability` and `rate`
12
+ # parameters.
13
+ #
14
+ # @example
15
+ # # Show how to initialize/assign population with a specific mutation object
16
+ #
4
17
  class Mutation
5
18
  extend Forwardable
6
19
 
20
+ DEFAULT_PROBABILITY = 0.03
21
+
22
+ #
23
+ # Initializes a new mutation object.
24
+ #
25
+ # Keyword arguments:
26
+ #
27
+ # #### probability
28
+ # The probability that a particular instance undergoes a mutation.
29
+ # By default, the probability is 0.03 which translates to 3%.
30
+ # If initialized with a `rate`, the probability will be 1 which
31
+ # means all genes _can_ undergo mutation, but actual gene mutations
32
+ # will be subject to the given mutation rate.
33
+ # #### rate
34
+ # the rate at which individual genes mutate. The default rate is 0 which,
35
+ # when combined with a non-zero `probability` (the default), means that
36
+ # one gene for each instance that undergoes mutation will change.
37
+ # If a rate is given, but no `probability` is given, then the `probability`
38
+ # will bet set to 1 which always defers to the mutation rate.
39
+ #
40
+ # To summarize, the `probability` represents the chance of mutation on
41
+ # the instance level and the `rate` represents the chance on the gene level.
42
+ # The `probability` and `rate` can be any number from 0 to 1. When the
43
+ # `probability` is 0, no mutation will ever happen. When the `probability`
44
+ # is not 0 but the rate is 0, then any instance that undergoes mutation
45
+ # will only receive one mutant gene. If the rate is not 0, then if an
46
+ # instance has been chosen to undergo mutation, each of its genes will
47
+ # mutate with a probability as defined by the `rate`.
48
+ #
49
+ # @example Example Initializations:
50
+ # Evolvable::Mutation.new # Approximately #{DEFAULT_PROBABILITY} of instances will receive one mutant gene
51
+ # Evolvable::Mutation.new(probability: 0.5) # Approximately 50% of instances will receive one mutant gene
52
+ # Evolvable::Mutation.new(rate: 0.03) # Approximately 3% of all genes in the population will mutate.
53
+ # Evolvable::Mutation.new(probability: 0.3, rate: 0.03) # Approximately 30% of instances will have approximately 3% of their genes mutated.
54
+ #
55
+ # Custom mutation objects must implement the `#call` method which accepts the population as the first object.
56
+ #
7
57
  def initialize(probability: nil, rate: nil)
8
- @probability = probability || (rate ? 1 : 0.03)
9
- @rate = rate || 0
58
+ @probability = probability || (rate ? 1 : DEFAULT_PROBABILITY)
59
+ @rate = rate
10
60
  end
11
61
 
12
62
  attr_accessor :probability,
13
63
  :rate
14
64
 
15
65
  def call(population)
16
- return population if probability.zero?
66
+ mutate_evolvables(population.evolvables) unless probability.zero?
67
+ population
68
+ end
17
69
 
18
- population.instances.each do |instance|
19
- mutate_instance(instance) if rand <= probability
70
+ def mutate_evolvables(evolvables)
71
+ evolvables.each do |evolvable|
72
+ next unless rand <= probability
73
+
74
+ evolvable.genome.each { |_key, config| mutate_genes(config[:genes]) }
20
75
  end
21
- population
22
76
  end
23
77
 
24
78
  private
25
79
 
26
- def mutate_instance(instance)
27
- genes_count = instance.genes.count
80
+ def mutate_genes(genes)
81
+ genes_count = genes.count
28
82
  return if genes_count.zero?
29
83
 
30
- return mutate_gene(instance, rand(genes_count)) if rate.zero?
84
+ return mutate_gene_by_index(genes, rand(genes_count)) unless rate
31
85
 
32
- genes_count.times { |index| mutate_gene(instance, index) if rand <= rate }
86
+ genes_count.times { |index| mutate_gene_by_index(genes, index) if rand <= rate }
33
87
  end
34
88
 
35
- def mutate_gene(instance, gene_index)
36
- gene = instance.genes[gene_index]
37
- mutant_gene = gene.class.new
38
- mutant_gene.key = gene.key
39
- instance.genes[gene_index] = mutant_gene
89
+ def mutate_gene_by_index(genes, gene_index)
90
+ genes[gene_index] = genes[gene_index].class.new
40
91
  end
41
92
  end
42
93
  end
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evolvable
4
+ #
5
+ # Supports single and multi-point crossover. The default is single-point
6
+ # crossover via a `points_count` of 1 which can be changed on an existing population
7
+ # (`population.crossover.points_count = 5`) or during initialization
8
+ # (`Evolvable::PointCrossover.new(5)`)
9
+ #
4
10
  class PointCrossover
5
11
  def initialize(points_count: 1)
6
12
  @points_count = points_count
@@ -9,35 +15,43 @@ module Evolvable
9
15
  attr_accessor :points_count
10
16
 
11
17
  def call(population)
12
- population.instances = initialize_offspring(population)
18
+ population.evolvables = new_evolvables(population, population.size)
13
19
  population
14
20
  end
15
21
 
16
- private
17
-
18
- def initialize_offspring(population)
19
- parent_genes = population.instances.map!(&:genes)
20
- parent_gene_couples = parent_genes.combination(2).cycle
21
- offspring = []
22
- population_index = 0
22
+ def new_evolvables(population, count)
23
+ parent_genome_cycle = population.new_parent_genome_cycle
24
+ evolvables = []
23
25
  loop do
24
- genes_1, genes_2 = parent_gene_couples.next
25
- crossover_genes(genes_1, genes_2).each do |genes|
26
- offspring << population.new_instance(genes: genes, population_index: population_index)
27
- population_index += 1
28
- return offspring if population_index == population.size
26
+ genome_1, genome_2 = parent_genome_cycle.next
27
+ crossover_genomes(genome_1, genome_2).each do |genome|
28
+ evolvable = population.new_evolvable(genome: genome)
29
+ evolvables << evolvable
30
+ return evolvables if evolvable.generation_index == count
29
31
  end
30
32
  end
31
33
  end
32
34
 
33
- def crossover_genes(genes_1, genes_2)
34
- offspring_genes = [[], []]
35
+ private
36
+
37
+ def crossover_genomes(genome_1, genome_2)
38
+ genome_1 = genome_1.dup
39
+ genome_2 = genome_2.dup
40
+ genome_1.each do |gene_key, gene_config_1|
41
+ gene_config_2 = genome_2.config[gene_key]
42
+ genes_1 = gene_config_1[:genes]
43
+ genes_2 = gene_config_2[:genes]
44
+ crossover_genes!(genes_1, genes_2)
45
+ end
46
+ [genome_1, genome_2]
47
+ end
48
+
49
+ def crossover_genes!(genes_1, genes_2)
35
50
  generate_ranges(genes_1.length).each do |range|
36
- offspring_genes.reverse!
37
- offspring_genes[0][range] = genes_1[range]
38
- offspring_genes[1][range] = genes_2[range]
51
+ genes_2_range_values = genes_2[range]
52
+ genes_2[range] = genes_1[range]
53
+ genes_1[range] = genes_2_range_values
39
54
  end
40
- offspring_genes
41
55
  end
42
56
 
43
57
  def generate_ranges(genes_count)