annealing 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53e5900add60de36b8e3bb441be9bce450f6dcb3cbd67552913684745e90a0c1
4
- data.tar.gz: 00347f761988a73951ed950317c1fc6ebf6d1add2d2ab1889bf8cbe6eebabe36
3
+ metadata.gz: 8b20378dea6d3ebcee0d0adca58d721576605d17c34c91a1828f887733c5fc5e
4
+ data.tar.gz: 62935118c575c16e8e0efaaf58224845fc8e975f230ac58b81da41cf66a9c08c
5
5
  SHA512:
6
- metadata.gz: ef3653ce209af0a76189157cdecbbac58613aa86845b0e8476a22407fc17b9edb75e879f1676497eb7f1d8b4f86549719f67825f7c48db134603ed784d2d5ef1
7
- data.tar.gz: 022accd062db4cc155e50606d22ea7e51db7603a61ef9b40512625110d528975de275b1abbb5ea6604fa98162122ef8b5e143f474593536af4291089e4d3f229
6
+ metadata.gz: 4916ea9c5c854ce9891b9264d7dfe117f56da58fb906735f2afb015f2a0acf08757702991d988ed82096910225cca25794b78e135635d03b41dcf51496543a72
7
+ data.tar.gz: f3c74cb98d4b45512fd78bf6e18dfc751550ba33f6ccfa4ab80720d01dcdf4a1a0ec7215387033523792a503876e4abd6bc7b017330539a8367bed3da3a23741
@@ -0,0 +1,45 @@
1
+ # This workflow uses actions that are not certified by GitHub.
2
+ # They are provided by a third-party and are governed by
3
+ # separate terms of service, privacy policy, and support
4
+ # documentation.
5
+ # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake
6
+ # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby
7
+
8
+ name: Ruby
9
+
10
+ on:
11
+ push:
12
+ branches: [ main ]
13
+ pull_request:
14
+ branches: [ main ]
15
+
16
+ jobs:
17
+ lint:
18
+ runs-on: ubuntu-latest
19
+ strategy:
20
+ matrix:
21
+ ruby-version: ['2.6']
22
+ steps:
23
+ - uses: actions/checkout@v2
24
+ - name: Set up Ruby
25
+ uses: ruby/setup-ruby@v1
26
+ with:
27
+ ruby-version: ${{ matrix.ruby-version }}
28
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
29
+ - name: Run linter
30
+ run: bundle exec rubocop --parallel
31
+ test:
32
+ runs-on: ubuntu-latest
33
+ strategy:
34
+ matrix:
35
+ ruby-version: ['2.6', '2.7', '3.0', '3.1']
36
+
37
+ steps:
38
+ - uses: actions/checkout@v2
39
+ - name: Set up Ruby
40
+ uses: ruby/setup-ruby@v1
41
+ with:
42
+ ruby-version: ${{ matrix.ruby-version }}
43
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
44
+ - name: Run tests
45
+ run: bundle exec rake test
data/.rubocop.yml CHANGED
@@ -1,18 +1,25 @@
1
1
  inherit_from: .rubocop_todo.yml
2
2
 
3
- Metrics/AbcSize:
4
- Max: 25
5
- Metrics/MethodLength:
6
- Max: 15
3
+ require:
4
+ - rubocop-performance
5
+ - rubocop-minitest
6
+ - rubocop-rake
7
+
8
+ AllCops:
9
+ TargetRubyVersion: 2.6
10
+ NewCops: enable
11
+ DefaultFormatter: progress
12
+ DisplayCopNames: true
13
+ DisplayStyleGuide: true
14
+ ExtraDetails: true
15
+
7
16
  Layout/LineLength:
8
17
  Max: 120
9
- Lint/RaiseException:
10
- Enabled: true
11
- Lint/StructNewOverride:
12
- Enabled: false
13
- Style/HashEachMethods:
14
- Enabled: true
15
- Style/HashTransformKeys:
18
+
19
+ Style/StringLiterals:
16
20
  Enabled: true
17
- Style/HashTransformValues:
21
+ EnforcedStyle: double_quotes
22
+
23
+ Style/StringLiteralsInInterpolation:
18
24
  Enabled: true
25
+ EnforcedStyle: double_quotes
data/.rubocop_todo.yml CHANGED
@@ -1,20 +1,42 @@
1
1
  # This configuration was generated by
2
- # `rubocop --auto-gen-config`
3
- # on 2020-06-15 23:46:36 -0500 using RuboCop version 0.81.0.
2
+ # `rubocop --auto-gen-config --auto-gen-only-exclude`
3
+ # on 2022-02-18 23:32:32 UTC using RuboCop version 1.23.0.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
7
7
  # versions of RuboCop, may require this file to be generated again.
8
8
 
9
- # Offense count: 1
10
- # Configuration parameters: IgnoredMethods.
11
-
9
+ # Offense count: 9
10
+ # Configuration parameters: IgnoredMethods, CountRepeatedAttributes, Max.
11
+ Metrics/AbcSize:
12
+ Exclude:
13
+ - 'test/annealing/configuration/configurator_test.rb'
14
+ - 'test/annealing/metal_test.rb'
15
+ - 'test/annealing/simulator_test.rb'
16
+ - 'test/annealing_test.rb'
12
17
 
13
- # Offense count: 1
14
- # Configuration parameters: CountComments, ExcludedMethods.
18
+ # Offense count: 3
19
+ # Configuration parameters: CountComments, Max, CountAsOne.
20
+ Metrics/ClassLength:
21
+ Exclude:
22
+ - 'test/annealing/configuration/configurator_test.rb'
23
+ - 'test/annealing/metal_test.rb'
24
+ - 'test/annealing/simulator_test.rb'
15
25
 
26
+ # Offense count: 13
27
+ # Configuration parameters: CountComments, Max, CountAsOne, ExcludedMethods, IgnoredMethods.
28
+ Metrics/MethodLength:
29
+ Exclude:
30
+ - 'lib/annealing/configuration.rb'
31
+ - 'lib/annealing/simulator.rb'
32
+ - 'test/annealing/configuration/configurator_test.rb'
33
+ - 'test/annealing/metal_test.rb'
34
+ - 'test/annealing/simulator_test.rb'
35
+ - 'test/annealing_test.rb'
16
36
 
17
- # Offense count: 2
18
- Style/Documentation:
37
+ # Offense count: 8
38
+ # Configuration parameters: Max.
39
+ Minitest/MultipleAssertions:
19
40
  Exclude:
20
- - 'test/**/*'
41
+ - 'test/annealing/configuration/configurator_test.rb'
42
+ - 'test/annealing/configuration_test.rb'
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ ## [Unreleased]
2
+
3
+
4
+ ## [0.3.0] - 2022-02-21
5
+
6
+ - Removed the default state change function; you are now required to define a custom `state_change` function for the simulation (#2)
7
+ - Added support for specifying a custom `termination_condition` function to override the default condition of the temperature reaching 0 (#5)
8
+ - Added support for specifying a custom `cool_down` function to override the default linear cooling function (#9)
9
+ - Normalized configurations options such that they can be specified in a consistent way across many interfaces (#19)
10
+ - `Annealing::Pool` has been replaced with `Annealing::Metal` which has a different interface from the old class
11
+ - `Annealing.simulate`, `Annealing::Simulator.new` and `Annealing::Simulator#run` method signatures have changed to accommodate normalized configuration options
12
+ - `Annealing::Simulator.new` no longer raises `RuntimeError` exceptions if configuration options are invalid. Instead, they will be raised from `Annealing::Simulator#run` as `ArgumentError` exceptions.
13
+ - Negative `cooling_rate` values are no longer valid; `Annealing::Simulator#run` will raise an error if one is specified
14
+ - Comprehensive test suite added
15
+
16
+ ## [0.2.0] - 2020-07-23
17
+
18
+ - Improve private methods
19
+ - Minor code cleanup
20
+
21
+ ## [0.1.0] - 2020-07-17
22
+
23
+ - It allows custom configuration
24
+ - It use distance method to calculate energy
25
+ - It allows user to implement their own total energy calculator
data/Gemfile CHANGED
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- source 'https://rubygems.org'
3
+ source "https://rubygems.org"
4
4
 
5
5
  # Specify your gem's dependencies in annealing.gemspec
6
6
  gemspec
7
7
 
8
- gem 'minitest', '~> 5.0'
9
- gem 'rake', '~> 12.0'
8
+ gem "debug", ">= 1.0.0", require: false
9
+ gem "minitest", "~> 5.0"
10
+ gem "rake", "~> 13.0", ">= 13.0.6"
11
+ gem "rubocop", "~> 1.23"
12
+ gem "rubocop-minitest", "~> 0.17.0"
13
+ gem "rubocop-performance", "~> 1.12"
14
+ gem "rubocop-rake", "~> 0.6.0"
data/Gemfile.lock CHANGED
@@ -1,21 +1,63 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- annealing (0.1.0)
4
+ annealing (0.2.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
- minitest (5.14.1)
10
- rake (12.3.3)
9
+ ast (2.4.2)
10
+ debug (1.4.0)
11
+ irb (>= 1.3.6)
12
+ reline (>= 0.2.7)
13
+ io-console (0.5.11)
14
+ irb (1.4.1)
15
+ reline (>= 0.3.0)
16
+ minitest (5.14.4)
17
+ parallel (1.21.0)
18
+ parser (3.0.3.2)
19
+ ast (~> 2.4.1)
20
+ rainbow (3.0.0)
21
+ rake (13.0.6)
22
+ regexp_parser (2.2.0)
23
+ reline (0.3.1)
24
+ io-console (~> 0.5)
25
+ rexml (3.2.5)
26
+ rubocop (1.23.0)
27
+ parallel (~> 1.10)
28
+ parser (>= 3.0.0.0)
29
+ rainbow (>= 2.2.2, < 4.0)
30
+ regexp_parser (>= 1.8, < 3.0)
31
+ rexml
32
+ rubocop-ast (>= 1.12.0, < 2.0)
33
+ ruby-progressbar (~> 1.7)
34
+ unicode-display_width (>= 1.4.0, < 3.0)
35
+ rubocop-ast (1.14.0)
36
+ parser (>= 3.0.1.1)
37
+ rubocop-minitest (0.17.0)
38
+ rubocop (>= 0.90, < 2.0)
39
+ rubocop-performance (1.12.0)
40
+ rubocop (>= 1.7.0, < 2.0)
41
+ rubocop-ast (>= 0.4.0)
42
+ rubocop-rake (0.6.0)
43
+ rubocop (~> 1.0)
44
+ ruby-prof (1.4.3)
45
+ ruby-progressbar (1.11.0)
46
+ unicode-display_width (2.1.0)
11
47
 
12
48
  PLATFORMS
13
49
  ruby
14
50
 
15
51
  DEPENDENCIES
16
52
  annealing!
53
+ debug (>= 1.0.0)
17
54
  minitest (~> 5.0)
18
- rake (~> 12.0)
55
+ rake (~> 13.0, >= 13.0.6)
56
+ rubocop (~> 1.23)
57
+ rubocop-minitest (~> 0.17.0)
58
+ rubocop-performance (~> 1.12)
59
+ rubocop-rake (~> 0.6.0)
60
+ ruby-prof (~> 1.4, >= 1.4.3)
19
61
 
20
62
  BUNDLED WITH
21
63
  2.1.4
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2020 Luis Ezcurdia
3
+ Copyright (c) 2022 Luis Ezcurdia
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # Annealing
2
+
2
3
  [![Gem Version](https://badge.fury.io/rb/annealing.svg)](https://badge.fury.io/rb/annealing)
4
+ [![Ruby](https://github.com/3zcurdia/annealing/actions/workflows/ruby.yml/badge.svg)](https://github.com/3zcurdia/annealing/actions/workflows/ruby.yml)
3
5
 
4
- Find the optimal solution in a complex problem through a simulated annealing implementation for enumerable objects. Where the energy of each object can be measured by a distance method between two elements or a lambda function where the total energy is calculated in the objects.
6
+ Find the optimal solution in a complex problem through a simulated annealing implementation for Ruby objects.
5
7
 
6
8
  ## Installation
7
9
 
@@ -13,18 +15,27 @@ gem 'annealing'
13
15
 
14
16
  And then execute:
15
17
 
16
- $ bundle install
18
+ ```shell
19
+ bundle install
20
+ ```
17
21
 
18
22
  Or install it yourself as:
19
23
 
20
- $ gem install annealing
24
+ ```shell
25
+ gem install annealing
26
+ ```
21
27
 
22
28
  ## Usage
23
29
 
24
- To use this gem, you must have an object that implements a `distance` method or a lambda function where you calculate the total energy of an enumerable object.
30
+ Simulated annealing algorithms work by comparing multiple permutations of a given object and measuring their relative efficiencies based on any number of competing factors. If you aren't already familiar with the concept of simulated annealing, we recommend watching [The Most Metal Algorithm in Computer Science](https://www.youtube.com/watch?v=I_0GBWCKft8) from [SciShow](https://www.youtube.com/c/SciShow) as it will help you understand some of the concepts and terms used below.
31
+
32
+ In order to use this algorithm we must first define 3 things:
33
+
34
+ 1. an initial object state to evaluate
35
+ 2. a way to measure the energy of that state
36
+ 3. and a way to change the state of the object over time
25
37
 
26
- Lets solve the traveling salesman, for that we will need an object with a distance method
27
- to mesure the distance between two locations.
38
+ Lets use the the traveling salesperson problem as an example. First we will define a Location object with a `distance` method to measure the distance between two locations.
28
39
 
29
40
  ```ruby
30
41
  Location = Struct.new(:x, :y) do
@@ -40,7 +51,7 @@ Location = Struct.new(:x, :y) do
40
51
  end
41
52
  ```
42
53
 
43
- Now we can create an array of the locations
54
+ Now we can create an array of locations the salesperson will visit. This is our initial state, and the order can be any random starting state.
44
55
 
45
56
  ```ruby
46
57
  locations = [
@@ -49,69 +60,219 @@ locations = [
49
60
  Location.new(40, 120),
50
61
  Location.new(100, 120),
51
62
  Location.new(20, 40)
52
- ]
63
+ ].shuffle
64
+ ```
65
+
66
+ Next we need a way to calculate the total energy of traveling to each location in turn, with low energy states preferable to high energy states. Think of it as a representation of the efficiency of the trip; the further away one point is away from the next, the less efficient the trip is.
67
+
68
+ ```ruby
69
+ energy_calculator = lambda do |locations|
70
+ locations.each_cons(2).sum do |location1, location2|
71
+ location1.distance(location2)
72
+ end
73
+ end
53
74
  ```
54
75
 
55
- Now we can just pass the locations argumen.
76
+ Finally, we need a way to make small, random changes in the order of locations the salesperson will visit as we probe for an optimal route.
56
77
 
57
78
  ```ruby
58
- puts Annealing.simulate(locations)
59
- [(20,40), (40,120), (100,120), (60,200), (180,200)]
79
+ state_change = lambda do |locations|
80
+ size = locations.size
81
+ swapped = locations.dup
82
+ idx_a = rand(size)
83
+ idx_b = rand(size)
84
+ swapped[idx_b], swapped[idx_a] = swapped[idx_a], swapped[idx_b]
85
+ swapped
86
+ end
60
87
  ```
61
88
 
62
- This will run the simulation with the default parameters and it could take a while to finish.
63
- But if you want to iterate with other parameters you can send the parameters `temperature` and `cooling_rate`.
89
+ Now we can run the simulation. With the default configuration it will consider ~33 million permutations of the route, so this may take several minutes to complete.
90
+
91
+ ```ruby
92
+ optimal_route = Annealing.simulate(locations,
93
+ energy_calculator: energy_calculator,
94
+ state_change: state_change)
95
+ optimal_route.state
96
+ # => [(20,40), (40,120), (100,120), (60,200), (180,200)]
97
+ ```
98
+
99
+ ## Configuration options
100
+
101
+ The annealer supports a number of configuration options. See the [configuration precedence](#configuration-precedence) section below for information on the different scopes they can be applied to.
102
+
103
+ ### `cool_down`
104
+
105
+ By default, the simulation will decrease the `temperature` linearly by `cooling_rate` on each step of the annealing process. In some cases you may wish to override this to use a different cooling algorithm. To do so, you can specify a custom `cool_down` function. The function can be any object that responds to `#call` and accepts three arguments: the `energy` calculation of the current object, the current `temperature` of the annealer, the `cooling_rate` for the simulation, and the number of `steps` the annealer has taken so far. It should return the new temperature as a Float.
64
106
 
65
107
  ```ruby
66
- simulator = Annealing::Simulator.new(temperature: 10_000, cooling_rate: 0.5)
67
- solution = simulator.run(locations)
68
- solution.energy
69
- 351.901234
70
- solution.collection
71
- [(20,40), (40,120), (100,120), (60,200), (180,200)]
108
+ Annealing.configuration.cool_down = lambda do |_energy, temperature, cooling_rate, steps|
109
+ # Reduce temperature exponentially
110
+ temperature - (cooling_rate * (steps**2))
111
+ end
72
112
  ```
73
113
 
74
- You can also configure the default parameters
114
+ ### `cooling_rate` and `temperature`
115
+
116
+ In the default configuration, the `cooling_rate` represents the amount by which the `temperature` will be reduced at each step, such that `temperature / cooling_rate` equals the maximum number of steps the annealer will go through in its search for the optimal solution. If a custom `cool_down` function is specified then `cooling_rate` will be passed to that function at each step along with the current temperature. The default `cooling_rate` value is `0.0003` and the default `temperature` is `10_000`.
75
117
 
76
118
  ```ruby
77
- Annealing.configure do |c|
78
- c.temperature = 10_000.0
79
- c.cooling_rate = 0.003
119
+ Annealing.configure do |config|
120
+ config.cooling_rate = 0.001
121
+ config.temperature = 25_000
80
122
  end
81
123
  ```
82
124
 
83
- ### Custom total energy calculator
125
+ Generally speaking, simulations have a higher chance of finding optimal solutions with a high initial temperature and a low cooling rate. A high temperature gives the simulation more time to search through neighboring states for low energy configurations, while a low cooling rate increases the probability that the simulation will select a low-energy configuration when comparing two states. For example, consider two configurations: `temperature(10000) / cooling_rate(1)` and `temperature(100) / cooling_rate(0.01)`. Even though both provide 10,000 steps when using the default cool down function, the latter configuration will allow for smaller temperature readings which is more likely to result in a more optimal final state.
84
126
 
85
- You can set a lambda function to customize the total energy in a collection by setting in the default parameters
127
+ ### `energy_calculator`
128
+
129
+ You must specify a `energy_calculator` function before running any simulations; no default function is provided. The function can be any object that responds to `#call` and accepts a single argument: the `state` representing the current state being measured. It should return a measurement representing the efficiency of the current state based on all of its competing factors, and where a lower value represents a better configuration than a higher value.
86
130
 
87
131
  ```ruby
88
- Annealing.configure do |c|
89
- c.total_energy_calculator = lambda do |enumerable|
90
- enumerable.each_cons(2).sum { |a, b| a.distance(b) }
132
+ # A custom calculator class that takes into account hypothetical external factors
133
+ class PotentialSalesCalculator
134
+ def initialize(initial_time_of_day)
135
+ @initial_time_of_day = initial_time_of_day
136
+ end
137
+
138
+ def energy(locations)
139
+ arrival_time = @initial_time_of_day
140
+ first_location_sales = potential_sales(locations.first, arrival_time)
141
+ locations.each_cons(2).sum do |location1, location2|
142
+ arrival_time += travel_time(location1, location2, arrival_time)
143
+ distance = location1.distance(location2)
144
+ potential_sales(locations.first, arrival_time) / distance
145
+ end + first_location_sales
146
+ end
147
+
148
+ def potential_sales(location, time_of_day)
149
+ habits = CustomerHabits.new(location)
150
+ customers = habits.whos_home_at(time_of_day)
151
+ SalesTrends.estimate(customers)
152
+ end
153
+
154
+ def travel_time(location1, location2, time_of_day)
155
+ traffic = TrafficPredictor.new(location1, location2)
156
+ traffic.travel_time(time_of_day)
91
157
  end
92
158
  end
159
+
160
+ calculator = PotentialSalesCalculator.new(8)
161
+ Annealing.configuration.energy_calculator = calculator.method(:energy)
93
162
  ```
94
163
 
95
- or in the simulator as a parameter
164
+ ### `logger`
165
+
166
+ The default logger is a standard Ruby Logger that writes to stdout. You can specify a different `logger` if you wish.
96
167
 
97
168
  ```ruby
98
- calc = lambda do |enumerable|
99
- enumerable.each_cons(2).sum { |a, b| a.distance(b) }
169
+ logger = Logger.new('logfile.log')
170
+ logger.level = Logger::DEBUG
171
+ Annealing.configuration.logger = logger
172
+ ```
173
+
174
+ ### `state_change`
175
+
176
+ As with `energy_calculator`, you must specify a `state_change` function in order to run any simulations; no default function is provided. The function can be any object that responds to `#call` and accepts a single argument: the `state` representing the current state that should be changed. It should return the changed state.
177
+
178
+ ```ruby
179
+ class MyClass
180
+ def state_change(state)
181
+ size = state.size
182
+ swapped = state.dup
183
+ idx_a = rand(size)
184
+ idx_b = rand(size)
185
+ swapped[idx_b], swapped[idx_a] = swapped[idx_a], swapped[idx_b]
186
+ swapped
187
+ end
100
188
  end
101
- solution = simulator.run(locations, calc).collection
102
- [(20,40), (40,120), (100,120), (60,200), (180,200)]
189
+
190
+ instance = MyClass.new
191
+ Annealing.configuration.state_change = instance.method(:state_change)
192
+ ```
193
+
194
+ ### `termination_condition`
195
+
196
+ By default, a simulation will run until the temperature reaches 0. In some cases, you might want to specify a termination condition that will stop the annealing process as soon as some other condition is met regardless of the current temperature. You can define a custom `termination_condition` function, which can be any object that responds to `#call` and accepts three arguments: the current `state` of the object, the `energy` calculation of the current object, and the current `temperature` of the simulation. It should return a boolean value where `true` indicates the simulation should stop.
197
+
198
+ ```ruby
199
+ Annealing.configuration.termination_condition = lambda do |_state, energy, temperature|
200
+ # Stop early if we encounter any 0-energy state
201
+ energy == 0 || temperature <= 0.0
202
+ end
203
+ ```
204
+
205
+ ## Configuration precedence
206
+
207
+ Configuration options can be set globally using `Annealing.configuration` or `Annealing.configure`, on `Annealing::Simulator.new` to be used on all subsequent runs of that instance, and just-in-time on `Annealing.simulate` and `Annealing::Simulator#run`. They are applied in reverse order of precedence.
208
+
209
+ ### Global configuration
210
+
211
+ Global configuration options, including the defaults, have the lowest precedence. They will be used in every simulation when no overriding configuration options are present. For instance, we can rewrite the traveling salesperson example like so:
212
+
213
+ ```ruby
214
+ # Set globally using block style
215
+ Annealing.configure do |config|
216
+ config.energy_calculator = energy_calculator
217
+ end
218
+
219
+ # Or set individually
220
+ Annealing.configuration.state_change = state_change
221
+
222
+ # Now we don't need to specify them just in time
223
+ solution = Annealing.simulate(locations)
224
+ ```
225
+
226
+ ### Instance configuration
227
+
228
+ Instance configurations can be set on new instances of `Annealing::Simulator` objects and will apply to all subsequent simulation runs for that instance. Instance configuration options override their global configuration counterparts.
229
+
230
+ ```ruby
231
+ Annealing.configure do |config|
232
+ config.energy_calculator = energy_calculator
233
+ config.state_change = state_change
234
+ config.temperature = 10_000
235
+ end
236
+
237
+ simulation = Annealing::Simulator.new(temperature: 1_000)
238
+ simulation.run(locations) # Will use an initial temperature value of 1000
239
+ simulation.run(locations.shuffle) # So will this
240
+ ```
241
+
242
+ ### Just-in-time configuration
243
+
244
+ Just-in-time configuration options have the highest precedence and will override both global and instance options. They are only applied to the current simulation run.
245
+
246
+ ```ruby
247
+ Annealing.configure do |config|
248
+ config.cooling_rate = 0.001
249
+ config.energy_calculator = energy_calculator
250
+ config.state_change = state_change
251
+ config.temperature = 10_000
252
+ end
253
+
254
+ # Will use an initial temperature of 20,000 and a cooling rate of 0.001
255
+ solution = Annealing.simulate(locations, temperature: 20_000)
256
+
257
+ # Set an instance cooling rate of 0.002
258
+ simulation = Annealing::Simulator.new(cooling_rate: 0.002)
259
+
260
+ # Will use an initial temperature of 20,000 and a cooling rate of 0.002
261
+ simulation.run(locations, temperature: 20_000)
262
+
263
+ # Will use an initial temperature of 10,000 and a cooling rate of 0.003
264
+ simulation.run(locations.shuffle, cooling_rate: 0.003)
103
265
  ```
104
266
 
105
267
  ## Development
106
268
 
107
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
269
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake` to run the test suite. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
108
270
 
109
271
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
110
272
 
111
273
  ## Contributing
112
274
 
113
- Bug reports and pull requests are welcome on GitHub at https://github.com/3zcurdia/annealing. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/3zcurdia/annealing/blob/master/CODE_OF_CONDUCT.md).
114
-
275
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/3zcurdia/annealing](https://github.com/3zcurdia/annealing). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/3zcurdia/annealing/blob/master/CODE_OF_CONDUCT.md).
115
276
 
116
277
  ## Code of Conduct
117
278
 
data/Rakefile CHANGED
@@ -1,12 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'bundler/gem_tasks'
4
- require 'rake/testtask'
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "rubocop/rake_task"
5
6
 
6
7
  Rake::TestTask.new(:test) do |t|
7
- t.libs << 'test'
8
- t.libs << 'lib'
9
- t.test_files = FileList['test/**/*_test.rb']
8
+ t.libs << "test"
9
+ t.libs << "lib"
10
+ t.test_files = FileList["test/**/*_test.rb"]
10
11
  end
11
12
 
12
- task default: :test
13
+ RuboCop::RakeTask.new(:rubocop)
14
+
15
+ desc "Execute the end-to-end integration test script"
16
+ task :end_to_end_test do
17
+ puts
18
+ puts "Running full end-to-end test with bin/run"
19
+ puts `bin/run`
20
+ puts
21
+ end
22
+
23
+ task default: %i[test end_to_end_test rubocop]
data/annealing.gemspec CHANGED
@@ -1,31 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'lib/annealing/version'
3
+ require_relative "lib/annealing/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = 'annealing'
6
+ spec.name = "annealing"
7
7
  spec.version = Annealing::VERSION
8
- spec.authors = ['Luis Ezcurdia Razo']
9
- spec.email = ['ing.ezcurdia@gmail.com']
10
- spec.license = 'MIT'
8
+ spec.authors = ["Luis Ezcurdia", "Chris Bloom"]
9
+ spec.email = ["ing.ezcurdia@gmail.com", "chrisbloom7@gmail.com"]
10
+ spec.license = "MIT"
11
11
 
12
- spec.summary = 'Simulated Annealing algoritm'
13
- spec.description = 'Simulated Annealing algoritm implementation.'
14
- spec.homepage = 'https://github.com/3zcurdia/annealing'
15
- spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
12
+ spec.summary = "Simulated Annealing"
13
+ spec.description = "Simulated Annealing algoritm implementation."
14
+ spec.homepage = "https://github.com/3zcurdia/annealing"
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.6.0")
16
16
 
17
17
  # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
18
18
 
19
- spec.metadata['homepage_uri'] = spec.homepage
20
- spec.metadata['source_code_uri'] = 'https://github.com/3zcurdia/annealing'
21
- # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/3zcurdia/annealing"
21
+ spec.metadata["changelog_uri"] = "https://github.com/3zcurdia/annealing/blob/main/CHANGELOG.md"
22
22
 
23
23
  # Specify which files should be added to the gem when it is released.
24
24
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
25
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
26
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
27
27
  end
28
- spec.bindir = 'exe'
28
+ spec.bindir = "exe"
29
29
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
30
- spec.require_paths = ['lib']
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.add_development_dependency "ruby-prof", "~> 1.4", ">= 1.4.3"
33
+ spec.metadata["rubygems_mfa_required"] = "true"
31
34
  end
data/bin/console CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'bundler/setup'
5
- require 'annealing'
4
+ require "bundler/setup"
5
+ require "annealing"
6
6
 
7
7
  # You can add fixtures and/or initialization code here to make experimenting
8
8
  # with your gem easier. You can also use a different console, if you like.
@@ -11,5 +11,5 @@ require 'annealing'
11
11
  # require "pry"
12
12
  # Pry.start
13
13
 
14
- require 'irb'
14
+ require "irb"
15
15
  IRB.start(__FILE__)
data/bin/run ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "annealing"
6
+
7
+ Location = Struct.new(:name, :x, :y) do
8
+ def inspect
9
+ "#{name} (#{x},#{y})"
10
+ end
11
+
12
+ def distance(location)
13
+ dx = (x - location.x).abs
14
+ dy = (y - location.y).abs
15
+ Math.sqrt((dx**2) + (dy**2))
16
+ end
17
+ end
18
+
19
+ locations = [
20
+ Location.new("Blaire Hills", 60, 200),
21
+ Location.new("Smallville", 180, 200),
22
+ Location.new("Boggs Harbor", 40, 120),
23
+ Location.new("Curtisville", 100, 120),
24
+ Location.new("Allentown", 20, 40)
25
+ ].shuffle
26
+
27
+ energy_calculator = lambda do |state|
28
+ state.each_cons(2).sum do |location1, location2|
29
+ location1.distance(location2)
30
+ end
31
+ end
32
+
33
+ state_change = lambda do |state|
34
+ size = state.size
35
+ swapped = state.dup
36
+ idx_a = rand(size)
37
+ idx_b = rand(size)
38
+ swapped[idx_b], swapped[idx_a] = swapped[idx_a], swapped[idx_b]
39
+ swapped
40
+ end
41
+
42
+ Annealing.configuration.logger.level = Logger::DEBUG
43
+ simulator = Annealing::Simulator.new(temperature: 10_000, cooling_rate: 0.01)
44
+ solution = simulator.run(locations,
45
+ energy_calculator: energy_calculator,
46
+ state_change: state_change)
47
+
48
+ puts "\nInitial itinerary:"
49
+ locations.each_cons(2).each_with_index do |(location1, location2), index|
50
+ puts "Stop ##{index + 1}: #{location1.name} -> #{location2.name} (#{location1.distance(location2)})"
51
+ end
52
+ puts "-------\nEnergy: #{energy_calculator.call(locations)}"
53
+
54
+ puts "\nAnnealed itinerary:"
55
+ solution.state.each_cons(2).each_with_index do |(location1, location2), index|
56
+ puts "Stop ##{index + 1}: #{location1.name} -> #{location2.name} (#{location1.distance(location2)})"
57
+ end
58
+ puts "-------\nEnergy: #{solution.energy}"
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Annealing
4
+ class Configuration
5
+ # Configuration mixin
6
+ module Configurator
7
+ def self.included(base)
8
+ base.send :include, InstanceMethods
9
+ end
10
+
11
+ # Mixin methods
12
+ module InstanceMethods
13
+ def init_configuration(config_hash = {})
14
+ @instance_configuration = config_hash
15
+ @temporary_configuration = {}
16
+ end
17
+
18
+ def with_configuration_overrides(local_config_hash = {})
19
+ @temporary_configuration = local_config_hash
20
+ yield
21
+ ensure
22
+ @temporary_configuration = {}
23
+ end
24
+
25
+ def configuration_overrides
26
+ instance_configuration.merge(temporary_configuration)
27
+ end
28
+
29
+ private
30
+
31
+ attr_accessor :instance_configuration, :temporary_configuration
32
+
33
+ def current_config_for(config)
34
+ temporary_configuration[config] ||
35
+ instance_configuration[config] ||
36
+ Annealing.configuration.public_send(config)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -3,17 +3,28 @@
3
3
  module Annealing
4
4
  # It enables the gem configuration
5
5
  class Configuration
6
- attr_accessor :temperature, :cooling_rate, :total_energy_calculator, :logger
6
+ attr_accessor :cool_down,
7
+ :cooling_rate,
8
+ :energy_calculator,
9
+ :logger,
10
+ :state_change,
11
+ :temperature,
12
+ :termination_condition
7
13
 
8
14
  def initialize
9
- @temperature = 10_000.0
15
+ @cool_down = lambda do |_energy, temperature, cooling_rate, _steps|
16
+ # Reduce the temperature linearly by default
17
+ temperature - cooling_rate
18
+ end
10
19
  @cooling_rate = 0.0003
11
- @total_energy_calculator = lambda do |enumerable|
12
- enumerable.each_cons(2).sum do |value_a, value_b|
13
- value_a.distance(value_b)
14
- end
20
+ @energy_calculator = nil
21
+ @logger = Logger.new($stdout, level: Logger::INFO)
22
+ @state_change = nil
23
+ @temperature = 10_000.0
24
+ @termination_condition = lambda do |_state, _energy, temperature|
25
+ # Terminate the simulation as soon as the temperature reaches 0
26
+ temperature <= 0.0
15
27
  end
16
- @logger = Logger.new(STDOUT)
17
28
  end
18
29
  end
19
30
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Annealing
4
+ # It manages the total energy of a given collection
5
+ class Metal
6
+ include Configuration::Configurator
7
+ attr_reader :state, :temperature
8
+
9
+ def initialize(current_state, current_temperature, **config)
10
+ init_configuration(config)
11
+ @state = current_state
12
+ @temperature = current_temperature
13
+
14
+ raise(ArgumentError, "Missing energy calculator function") unless energy_calculator.respond_to?(:call)
15
+
16
+ raise(ArgumentError, "Missing state change function") unless state_change.respond_to?(:call)
17
+ end
18
+
19
+ def energy
20
+ @energy ||= energy_calculator.call(state)
21
+ end
22
+
23
+ # This method is not idempotent!
24
+ # It relies on random probability to select the next state
25
+ def cool!(new_temperature)
26
+ cooled_metal = cool(new_temperature)
27
+ if better_than?(cooled_metal)
28
+ cooled_metal
29
+ else
30
+ @temperature = new_temperature
31
+ self
32
+ end
33
+ end
34
+
35
+ def to_s
36
+ format("%<temperature>.4f:%<energy>.4f:%<value>s",
37
+ temperature: temperature,
38
+ energy: energy,
39
+ value: state)
40
+ end
41
+
42
+ private
43
+
44
+ def energy_calculator
45
+ current_config_for(:energy_calculator)
46
+ end
47
+
48
+ def state_change
49
+ current_config_for(:state_change)
50
+ end
51
+
52
+ def better_than?(cooled_metal)
53
+ energy_delta = energy - cooled_metal.energy
54
+ energy_delta.positive? ||
55
+ (Math::E**(energy_delta / cooled_metal.temperature)) > rand
56
+ end
57
+
58
+ def cool(new_temperature)
59
+ next_state = state_change.call(state)
60
+ Metal.new(next_state, new_temperature, **configuration_overrides)
61
+ end
62
+ end
63
+ end
@@ -3,36 +3,68 @@
3
3
  module Annealing
4
4
  # It runs simulated annealing
5
5
  class Simulator
6
- attr_reader :temperature, :cooling_rate
6
+ include Configuration::Configurator
7
7
 
8
- def initialize(temperature: nil, cooling_rate: nil)
9
- @temperature = temperature || Annealing.configuration.temperature
10
- @cooling_rate = cooling_rate || Annealing.configuration.cooling_rate
11
-
12
- raise 'Invalid initial temperature' if @temperature.negative?
13
-
14
- normalize_cooling_rate
8
+ def initialize(**config)
9
+ init_configuration(config)
15
10
  end
16
11
 
17
- def run(collection, calculator = nil)
18
- best = current = Pool.new(collection.shuffle, calculator)
19
- Annealing.logger.debug(" Original: #{current}")
20
- cool_down do |temp|
21
- current = current.solution_at(temp)
22
- best = current if current.better_than?(best)
12
+ def run(initial_state, config_hash = {})
13
+ with_configuration_overrides(config_hash) do
14
+ validate_configuration!
15
+ current = Metal.new(initial_state, temperature,
16
+ **configuration_overrides)
17
+ Annealing.logger.debug("Original: #{current}")
18
+ steps = 0
19
+ until termination_condition_met?(termination_condition, current)
20
+ steps += 1
21
+ current = reduce_temperature(cool_down, current, steps)
22
+ end
23
+ Annealing.logger.debug("Optimized: #{current}")
24
+ current
23
25
  end
24
- Annealing.logger.debug("Optimized: #{best}")
25
- best
26
26
  end
27
27
 
28
28
  private
29
29
 
30
30
  def cool_down
31
- (temperature..0).step(cooling_rate).each { |temp| yield temp }
31
+ current_config_for(:cool_down)
32
32
  end
33
33
 
34
- def normalize_cooling_rate
35
- @cooling_rate = -1.0 * cooling_rate if cooling_rate.positive?
34
+ def cooling_rate
35
+ current_config_for(:cooling_rate).to_f
36
+ end
37
+
38
+ def logger
39
+ current_config_for(:logger)
40
+ end
41
+
42
+ def temperature
43
+ current_config_for(:temperature).to_f
44
+ end
45
+
46
+ def termination_condition
47
+ current_config_for(:termination_condition)
48
+ end
49
+
50
+ def reduce_temperature(cool_down, metal, steps)
51
+ new_temperature = cool_down.call(metal.energy, metal.temperature,
52
+ cooling_rate, steps)
53
+ metal.cool!(new_temperature)
54
+ end
55
+
56
+ def termination_condition_met?(termination_condition, metal)
57
+ termination_condition.call(metal.state, metal.energy, metal.temperature)
58
+ end
59
+
60
+ def validate_configuration!
61
+ raise(ArgumentError, "Invalid initial temperature") if temperature.negative?
62
+
63
+ raise(ArgumentError, "Invalid initial cooling rate") if cooling_rate.negative?
64
+
65
+ raise(ArgumentError, "Missing cool down function") unless cool_down.respond_to?(:call)
66
+
67
+ raise(ArgumentError, "Missing termination condition function") unless termination_condition.respond_to?(:call)
36
68
  end
37
69
  end
38
70
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Annealing
4
- VERSION = '0.2.0'
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/annealing.rb CHANGED
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'logger'
4
- require 'annealing/version'
5
- require 'annealing/configuration'
6
- require 'annealing/pool'
7
- require 'annealing/simulator'
3
+ require "logger"
4
+ require "annealing/version"
5
+ require "annealing/configuration"
6
+ require "annealing/configuration/configurator"
7
+ require "annealing/metal"
8
+ require "annealing/simulator"
8
9
 
9
10
  # Simulated Annealing algoritm
10
11
  # https://en.wikipedia.org/wiki/Simulated_annealing
11
12
  module Annealing
12
13
  # Default error class
13
14
  class Error < StandardError; end
15
+
14
16
  class << self
15
17
  attr_writer :configuration
16
18
  end
@@ -23,11 +25,8 @@ module Annealing
23
25
  yield(configuration)
24
26
  end
25
27
 
26
- def self.simulate(collection)
27
- return [] if collection.empty?
28
-
29
- simulator = Simulator.new
30
- simulator.run(collection).collection
28
+ def self.simulate(initial_state, config_hash = {})
29
+ Simulator.new.run(initial_state, config_hash).state
31
30
  end
32
31
 
33
32
  def self.logger
metadata CHANGED
@@ -1,26 +1,49 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: annealing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
- - Luis Ezcurdia Razo
7
+ - Luis Ezcurdia
8
+ - Chris Bloom
8
9
  autorequire:
9
10
  bindir: exe
10
11
  cert_chain: []
11
- date: 2020-06-23 00:00:00.000000000 Z
12
- dependencies: []
12
+ date: 2022-02-22 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: ruby-prof
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '1.4'
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 1.4.3
24
+ type: :development
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ - - "~>"
29
+ - !ruby/object:Gem::Version
30
+ version: '1.4'
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 1.4.3
13
34
  description: Simulated Annealing algoritm implementation.
14
35
  email:
15
36
  - ing.ezcurdia@gmail.com
37
+ - chrisbloom7@gmail.com
16
38
  executables: []
17
39
  extensions: []
18
40
  extra_rdoc_files: []
19
41
  files:
42
+ - ".github/workflows/ruby.yml"
20
43
  - ".gitignore"
21
44
  - ".rubocop.yml"
22
45
  - ".rubocop_todo.yml"
23
- - ".travis.yml"
46
+ - CHANGELOG.md
24
47
  - CODE_OF_CONDUCT.md
25
48
  - Gemfile
26
49
  - Gemfile.lock
@@ -29,10 +52,12 @@ files:
29
52
  - Rakefile
30
53
  - annealing.gemspec
31
54
  - bin/console
55
+ - bin/run
32
56
  - bin/setup
33
57
  - lib/annealing.rb
34
58
  - lib/annealing/configuration.rb
35
- - lib/annealing/pool.rb
59
+ - lib/annealing/configuration/configurator.rb
60
+ - lib/annealing/metal.rb
36
61
  - lib/annealing/simulator.rb
37
62
  - lib/annealing/version.rb
38
63
  homepage: https://github.com/3zcurdia/annealing
@@ -41,6 +66,8 @@ licenses:
41
66
  metadata:
42
67
  homepage_uri: https://github.com/3zcurdia/annealing
43
68
  source_code_uri: https://github.com/3zcurdia/annealing
69
+ changelog_uri: https://github.com/3zcurdia/annealing/blob/main/CHANGELOG.md
70
+ rubygems_mfa_required: 'true'
44
71
  post_install_message:
45
72
  rdoc_options: []
46
73
  require_paths:
@@ -49,15 +76,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
49
76
  requirements:
50
77
  - - ">="
51
78
  - !ruby/object:Gem::Version
52
- version: 2.3.0
79
+ version: 2.6.0
53
80
  required_rubygems_version: !ruby/object:Gem::Requirement
54
81
  requirements:
55
82
  - - ">="
56
83
  - !ruby/object:Gem::Version
57
84
  version: '0'
58
85
  requirements: []
59
- rubygems_version: 3.1.3
86
+ rubygems_version: 3.3.7
60
87
  signing_key:
61
88
  specification_version: 4
62
- summary: Simulated Annealing algoritm
89
+ summary: Simulated Annealing
63
90
  test_files: []
data/.travis.yml DELETED
@@ -1,6 +0,0 @@
1
- ---
2
- language: ruby
3
- cache: bundler
4
- rvm:
5
- - 2.7.1
6
- before_install: gem install bundler -v 2.1.4
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Annealing
4
- # It manages the total energy of a given collection
5
- class Pool
6
- attr_reader :collection
7
-
8
- def initialize(collection, energy_calculator = nil)
9
- @collection = collection.dup
10
- @energy_calculator = energy_calculator || Annealing.configuration.total_energy_calculator
11
- end
12
-
13
- def energy
14
- @energy ||= energy_calculator.call(collection)
15
- end
16
-
17
- def better_than?(pool)
18
- energy < pool.energy
19
- end
20
-
21
- def solution_at(temperature)
22
- move = self.next
23
- energy_delta = energy - move.energy
24
- if energy_delta.positive? || (Math::E**(energy_delta / temperature)) > rand
25
- move
26
- else
27
- self
28
- end
29
- end
30
-
31
- def to_s
32
- format('%<energy>.4f:%<value>s', energy: energy, value: collection)
33
- end
34
-
35
- def next
36
- Pool.new(swap_collection, energy_calculator)
37
- end
38
-
39
- private
40
-
41
- attr_reader :energy_calculator
42
-
43
- def swap_collection
44
- swapped = collection.dup
45
- idx_a = rand(size)
46
- idx_b = rand(size)
47
- swapped[idx_b], swapped[idx_a] = swapped[idx_a], swapped[idx_b]
48
- swapped
49
- end
50
-
51
- def size
52
- collection.size
53
- end
54
- end
55
- end