feldtruby 0.3.8 → 0.3.9

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -47,3 +47,5 @@ pkg
47
47
 
48
48
  # For rubinius:
49
49
  #*.rbc
50
+
51
+ related/*
data/README.md CHANGED
@@ -2,10 +2,18 @@ feldtruby
2
2
  =========
3
3
  Robert Feldt's Common Ruby Code lib. I will gradually collect the many generally useful Ruby tidbits I have laying around and clean them up into here. Don't want to rewrite these things again and again... So far this collects a number of generally useful additions to the standard Ruby classes/libs and then includes a simple optimization framework (FeldtRuby::Optimize).
4
4
 
5
+ Note that good documentation is not really a focus here. As things mature in here I will move logically unique/separate sets of functionality into separate Ruby libs/gems of useful code. At that point there will be more focus on documentation.
6
+
5
7
  email: robert.feldt ((a)) gmail.com
6
8
 
7
9
  Contents
8
10
  --------
11
+
12
+ ### Statistics
13
+ * Cluster linkage metrics
14
+ * Access to R from Ruby (extends existing lib so that you can more easily transfer complex objects back to Ruby)
15
+ * ...
16
+
9
17
  ### Time
10
18
  Time.timestamp() # Get a timestamp string back with the current time
11
19
 
@@ -47,17 +55,7 @@ numerical optimization using DE:
47
55
  (1 - x)**2 + 100*(y - x*x)**2
48
56
  }
49
57
 
50
- Contributing to feldtruby
51
- -------------------------
52
- * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
53
- * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
54
- * Fork the project.
55
- * Start a feature/bugfix branch.
56
- * Commit and push until you are happy with your contribution.
57
- * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
58
- * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
59
-
60
58
  Copyright
61
59
  ------------
62
- Copyright (c) 2012 Robert Feldt. See LICENSE.txt for
60
+ Copyright (c) 2012-2013 Robert Feldt. See LICENSE.txt for
63
61
  further details.
data/Rakefile CHANGED
@@ -7,14 +7,31 @@ def psys(str)
7
7
  system str
8
8
  end
9
9
 
10
- desc "Run all tests"
11
- task :test do
10
+ def run_tests(testFiles)
12
11
  helper_files = Dir["test/**/*helper*.rb"]
13
- test_files = Dir["test/**/test*.rb"]
14
- require_files = (helper_files + test_files).map {|f| "require \"#{f}\""}.join('; ')
12
+ require_files = (helper_files + testFiles).map {|f| "require \"#{f}\""}.join('; ')
15
13
  psys "ruby -Ilib:. -e '#{require_files}' --"
16
14
  end
17
15
 
16
+ desc "Run all tests"
17
+ task :test do
18
+ run_tests Dir["test/**/test*.rb"]
19
+ end
20
+
21
+ def filter_latest_changed_files(filenames, numLatestChangedToInclude = 1)
22
+ filenames.sort_by{ |f| File.mtime(f) }[-numLatestChangedToInclude, numLatestChangedToInclude]
23
+ end
24
+
25
+ desc "Run only the latest changed test file"
26
+ task :t do
27
+ run_tests filter_latest_changed_files(Dir["test/**/test*.rb"])
28
+ end
29
+
30
+ desc "Run only the latest two changed test file"
31
+ task :t2 do
32
+ run_tests filter_latest_changed_files(Dir["test/**/test*.rb"], 2)
33
+ end
34
+
18
35
  desc "Clean up intermediate/build files"
19
36
  task :clean do
20
37
  FileUtils.rm_rf "pkg"
@@ -1,6 +1,11 @@
1
1
  require 'feldtruby/array/basic_stats'
2
2
 
3
3
  class Array
4
+ def map_with_index(&block)
5
+ index = -1 # Start below zero since we pre-increment in the loop
6
+ self.map {|v| block.call(v,index+=1)}
7
+ end
8
+
4
9
  # Calculate the distance between the elements.
5
10
  def distance_between_elements
6
11
  return nil if self.length == 0
@@ -10,8 +10,18 @@ module MiniTest::Assertions
10
10
  pvalue = FeldtRuby.chi_squared_test(values)
11
11
  assert(pvalue > expectedPValue, msg || "Proportions differ! p-value is #{pvalue} (<0.05), counts: #{values.counts.inspect}")
12
12
  end
13
+
14
+ def assert_falsey(value, msg = nil)
15
+ assert(value.!, msg || "#{value} is not falsey (it is #{value})")
16
+ end
17
+
18
+ def assert_truthy(value, msg = nil)
19
+ assert(value, msg || "#{value} is not truthy (it is #{value})")
20
+ end
13
21
  end
14
22
 
15
23
  module MiniTest::Expectations
16
24
  infect_an_assertion :assert_similar_proportions, :must_have_similar_proportions
25
+ infect_an_assertion :assert_falsey, :must_be_falsey
26
+ infect_an_assertion :assert_truthy, :must_be_truthy
17
27
  end
@@ -1,124 +1,128 @@
1
-
2
1
  require 'feldtruby/optimize'
3
2
  require 'feldtruby/float'
3
+ require 'feldtruby/optimize/sub_qualities_comparators'
4
+
5
+ module FeldtRuby::Optimize
4
6
 
5
- # An Objective captures one or more objectives into a single object
7
+ # An Objective captures one or more (sub-)objectives into a single object
6
8
  # and supports a large number of ways to utilize basic objective
7
9
  # functions in a single framework. You subclass and add instance
8
10
  # methods named as
9
11
  # objective_min_qualityAspectName (for an objective/aspect to be minimized), or
10
12
  # objective_max_qualityAspectName (for an objective/aspect to be minimized).
13
+ #
11
14
  # There can be multiple aspects (sub-objectives) for a single objective.
15
+ #
16
+ # An objective keeps track of the min and max value that has been seen so far
17
+ # for each sub-objective.
18
+
12
19
  # This base class uses mean-weighted-global-ratios (MWGR) as the default mechanism
13
- # for handling multi-objectives i.e. with more than one sub-objective.
14
- # An objective has version numbers to indicate the number of times the scale
15
- # for the calculation of the ratios has been changed.
16
- class FeldtRuby::Optimize::Objective
17
- attr_accessor :current_version, :logger
20
+ # for handling multi-objectives i.e. with more than one sub-objective.
21
+ #
22
+ # An objective has version numbers to indicate the number of times a new min or max
23
+ # value has been identified for a sub-objective.
24
+ class Objective
25
+ # Current version of this objective. Is updated when the min or max values for a sub-objective
26
+ # has been updated.
27
+ attr_accessor :current_version
28
+
29
+ # For logging changes to the objective.
30
+ attr_accessor :logger
18
31
 
19
32
  def initialize
20
33
  @logger = nil # To avoid getting warnings that logger has not been initialized
21
34
  @current_version = 0
22
- @pareto_front = Array.new(num_aspects)
35
+ @pareto_front = Array.new(num_aspects) # The pareto front is misguided since it only has one best value per sub-objective, not the whole front!
23
36
  end
24
37
 
25
- def reset_quality_scale(candidate, aspectIndex, typeOfReset)
26
- if (typeOfReset == :min && is_min_aspect?(aspectIndex)) ||
27
- (typeOfReset == :max && !is_min_aspect?(aspectIndex))
28
- @pareto_front[aspectIndex] = candidate
29
- end
38
+ # Return the number of aspects/sub-objectives of this objective.
39
+ def num_aspects
40
+ @num_aspects ||= aspect_methods.length
41
+ end
30
42
 
31
- # Reset the best object since we have a new scale
32
- @best_candidate = nil
33
- @best_qv = nil
43
+ def num_sub_objectives
44
+ num_aspects
45
+ end
34
46
 
35
- inc_version_number
47
+ def aspect_methods
48
+ @aspect_methods ||= self.methods.select {|m| is_aspect_method?(m)}
36
49
  end
37
50
 
38
- def update_best_candidate(candidate)
39
- @best_candidate = candidate
40
- @best_qv = candidate._quality_value
51
+ def is_min_aspect?(aspectIndex)
52
+ (@is_min_aspect ||= (aspect_methods.map {|m| is_min_aspect_method?(m)}))[aspectIndex]
41
53
  end
42
54
 
43
- def inc_version_number
44
- @current_version += 1
55
+ def is_aspect_method?(methodNameAsSymbolOrString)
56
+ methodNameAsSymbolOrString.to_s =~ /^objective_(min|max)_([\w_]+)$/
45
57
  end
46
58
 
47
- # Return the number of aspects/sub-objectives of this objective.
48
- def num_aspects
49
- @num_aspects ||= aspect_methods.length
59
+ def is_min_aspect_method?(methodNameAsSymbolOrString)
60
+ methodNameAsSymbolOrString.to_s =~ /^objective_min_([\w_]+)$/
50
61
  end
51
62
 
52
- # Class for representing multi-objective qualitites...
53
- class QualityValue
54
- attr_reader :qv, :sub_qvs
63
+ # The vectors can be mapped to a more complex candidate object before we call
64
+ # the sub objectives to calc their quality values. Default is no mapping but subclasses
65
+ # can override this for more complex evaluation schemes.
66
+ def map_candidate_vector_to_candidate_to_be_evaluated(vector)
67
+ vector
68
+ end
55
69
 
56
- def initialize(qv, subQvs, objective)
57
- @qv, @sub_qvs, @objective = qv, subQvs, objective
58
- @version = objective.current_version
59
- end
70
+ # Return a vector of the "raw" sub-quality values, i.e. the fitness value for each sub-objective.
71
+ # The candidate vector is assumed to be a vector of values.
72
+ def sub_qualities_of(candidateVector)
73
+ candidate = map_candidate_vector_to_candidate_to_be_evaluated(candidateVector)
74
+ aspect_methods.map {|omethod| self.send(omethod, candidate)}
75
+ end
60
76
 
61
- def <=>(other)
62
- @qv <=> other.qv
63
- end
77
+ # Return a single quality value for the whole objective for a given candidate.
78
+ # By default this uses a variant of Bentley and Wakefield's sum-of-weighted-global-ratios (SWGR)
79
+ # called mean-of-weighted-global-ratios (MWGR) which always returns a fitness value
80
+ # in the range (0.0, 1.0) with 1.0 signaling the best fitness seen so far. The scale is adaptive
81
+ # though so that the best candidate so far always has a fitness value of 1.0.
82
+ def quality_of(candidate, weights = self.default_weights)
83
+ return candidate._quality_value_without_check if quality_value_is_up_to_date?(candidate)
84
+ num_aspects == 1 ? qv_single(candidate) : qv_mwgr(candidate, weights)
85
+ end
64
86
 
65
- # Two quality values are the same if they have the same qv, regardless of their
66
- # sub qualities.
67
- def ==(other)
68
- other = other.qv if QualityValue === other
69
- @qv == other
70
- end
87
+ # Set the default weights to use when calculating a single quality values from
88
+ # the vector of sub-qualities.
89
+ def default_weights=(weights)
90
+ raise "Must be same number of weights as there are sub-objectives (#{num_aspects}), but is #{weights.length}" unless weights.length == num_aspects
91
+ @default_weights = weights
92
+ end
71
93
 
72
- def improvement_in_relation_to(other)
73
- if QualityValue === other
74
- pdiff = @qv.ratio_diff_vs(other.qv)
75
- subpdiffs = @sub_qvs.zip(other.sub_qvs).map {|s, os| s.ratio_diff_vs(os)}
76
- qinspect(pdiff, subpdiffs, "Difference", "SubQ. differences", true) + ", #{report_on_num_differences(subpdiffs)}"
77
- else
78
- @qv.improvement_in_relation_to(other)
79
- end
80
- end
94
+ # Current default weights among the sub-objectives (nil if none have been set)
95
+ attr_reader :default_weights
81
96
 
82
- def report_on_num_differences(subQvRatioDiffs)
83
- num_inc = subQvRatioDiffs.select {|v| v > 0}.length
84
- num_dec = subQvRatioDiffs.select {|v| v < 0}.length
85
- num_same = subQvRatioDiffs.length - num_inc - num_dec
86
- "#{num_inc} increased, #{num_dec} decreased, #{num_same} same"
87
- end
97
+ # Return the fitness of a candidate. It is the same as the quality value above.
98
+ def fitness_for(candidate, weights = nil)
99
+ quality_of(candidate, weights)
100
+ end
88
101
 
89
- def qinspect(qv, subQvs, qvDesc = "Quality", subQvDesc = "SubQualities", subQvsAreRatios = false, qvIsRatio = true)
90
- subQvs = subQvs.map {|v| v*100.0} if subQvsAreRatios
91
- sqs = subQvs.map do |sqv|
92
- s = (Float === sqv ? sqv.round_to_decimals(4) : sqv).inspect
93
- s += "%" if subQvsAreRatios
94
- s
95
- end.join(", ")
96
- if qvIsRatio
97
- qstr = ("%.4f" % (100.0 * qv)) + "%"
98
- else
99
- qstr = "%.4f" % qv
100
- end
101
- "#{qvDesc}: #{qstr}, #{subQvDesc}: [#{sqs}]"
102
- end
102
+ #############################
103
+ # Sane above here!
104
+ #############################
103
105
 
104
- def inspect
105
- qinspect(@qv, @sub_qvs) + ", Obj. version: #{@version}"
106
+ def reset_quality_scale(candidate, aspectIndex, typeOfReset)
107
+ if (typeOfReset == :min && is_min_aspect?(aspectIndex)) ||
108
+ (typeOfReset == :max && !is_min_aspect?(aspectIndex))
109
+ @pareto_front[aspectIndex] = candidate
106
110
  end
107
111
 
108
- # Refer all other methods to the main quality value
109
- def method_missing(meth, *args, &block)
110
- @qv.send(meth, *args, &block)
111
- end
112
+ # Reset the best object since we have a new scale
113
+ @best_candidate = nil
114
+ @best_qv = nil
115
+
116
+ inc_version_number
112
117
  end
113
118
 
114
- # Return a single quality value for the whole objective for a given candidate.
115
- # By default this uses a variant of Bentley and Wakefield's sum-of-weighted-global-ratios (SWGR)
116
- # called mean-of-weighted-global-ratios (MWGR) which always returns a fitness value
117
- # in the range (0.0, 1.0) with 1.0 signaling the best fitness seen so far. The scale is adaptive
118
- # though so that the best candidate so far always has a fitness value of 1.0.
119
- def quality_value(candidate, weights = nil)
120
- return candidate._quality_value_without_check if quality_value_is_up_to_date?(candidate)
121
- num_aspects == 1 ? qv_single(candidate) : qv_mwgr(candidate, weights)
119
+ def update_best_candidate(candidate)
120
+ @best_candidate = candidate
121
+ @best_qv = candidate._quality_value
122
+ end
123
+
124
+ def inc_version_number
125
+ @current_version += 1
122
126
  end
123
127
 
124
128
  def quality_value_is_up_to_date?(candidate)
@@ -143,7 +147,7 @@ class FeldtRuby::Optimize::Objective
143
147
  # Rand candidates from best to worst. NOTE! We do the steps of MWGR separately since we must
144
148
  # update the global mins and maxs before calculating the SWG ratios.
145
149
  def mwgr_rank_candidates(candidates, weights = nil)
146
- sub_qvss = candidates.map {|c| sub_objective_values(c)}
150
+ sub_qvss = candidates.map {|c| sub_qualities_of(c)}
147
151
  sub_qvss.zip(candidates).each {|sub_qvs, c| update_global_mins_and_maxs(sub_qvs, c)}
148
152
  sub_qvss.each_with_index.map do |sub_qvs, i|
149
153
  qv = mwgr_ratios(sub_qvs).weighted_mean(weights)
@@ -191,18 +195,6 @@ class FeldtRuby::Optimize::Objective
191
195
  numerator.to_f.protected_division_with(max - min)
192
196
  end
193
197
 
194
- # The vectors can be mapped to a more complex candidate object before we call
195
- # the sub objectives to calc their quality values. Default is no mapping but subclasses
196
- # can override this.
197
- def map_candidate_vector_to_candidate_to_be_evaluated(vector)
198
- vector
199
- end
200
-
201
- def sub_objective_values(candidateVector)
202
- candidate = map_candidate_vector_to_candidate_to_be_evaluated(candidateVector)
203
- aspect_methods.map {|omethod| self.send(omethod, candidate)}
204
- end
205
-
206
198
  def update_global_mins_and_maxs(aspectValues, candidate = nil)
207
199
  aspectValues.each_with_index {|v, i| update_global_min_and_max(i, v, candidate)}
208
200
  end
@@ -222,6 +214,68 @@ class FeldtRuby::Optimize::Objective
222
214
  end
223
215
  end
224
216
 
217
+ # Class for representing multi-objective qualitites...
218
+ class QualityValue
219
+ attr_reader :qv, :sub_qvs, :objective
220
+
221
+ def initialize(qv, subQvs, objective)
222
+ @qv, @sub_qvs, @objective = qv, subQvs, objective
223
+ @version = objective.current_version
224
+ end
225
+
226
+ def <=>(other)
227
+ @qv <=> other.qv
228
+ end
229
+
230
+ # Two quality values are the same if they have the same qv, regardless of their
231
+ # sub qualities.
232
+ def ==(other)
233
+ other = other.qv if QualityValue === other
234
+ @qv == other
235
+ end
236
+
237
+ def improvement_in_relation_to(other)
238
+ if QualityValue === other
239
+ pdiff = @qv.ratio_diff_vs(other.qv)
240
+ subpdiffs = @sub_qvs.zip(other.sub_qvs).map {|s, os| s.ratio_diff_vs(os)}
241
+ qinspect(pdiff, subpdiffs, "Difference", "SubQ. differences", true) + ", #{report_on_num_differences(subpdiffs)}"
242
+ else
243
+ @qv.improvement_in_relation_to(other)
244
+ end
245
+ end
246
+
247
+ def report_on_num_differences(subQvRatioDiffs)
248
+ num_inc = subQvRatioDiffs.select {|v| v > 0}.length
249
+ num_dec = subQvRatioDiffs.select {|v| v < 0}.length
250
+ num_same = subQvRatioDiffs.length - num_inc - num_dec
251
+ "#{num_inc} increased, #{num_dec} decreased, #{num_same} same"
252
+ end
253
+
254
+ def qinspect(qv, subQvs, qvDesc = "Quality", subQvDesc = "SubQualities", subQvsAreRatios = false, qvIsRatio = true)
255
+ subQvs = subQvs.map {|v| v*100.0} if subQvsAreRatios
256
+ sqs = subQvs.map do |sqv|
257
+ s = (Float === sqv ? sqv.round_to_decimals(4) : sqv).inspect
258
+ s += "%" if subQvsAreRatios
259
+ s
260
+ end.join(", ")
261
+ if qvIsRatio
262
+ qstr = ("%.4f" % (100.0 * qv)) + "%"
263
+ else
264
+ qstr = "%.4f" % qv
265
+ end
266
+ "#{qvDesc}: #{qstr}, #{subQvDesc}: [#{sqs}]"
267
+ end
268
+
269
+ def inspect
270
+ qinspect(@qv, @sub_qvs) + ", Obj. version: #{@version}"
271
+ end
272
+
273
+ # Refer all other methods to the main quality value
274
+ def method_missing(meth, *args, &block)
275
+ @qv.send(meth, *args, &block)
276
+ end
277
+ end
278
+
225
279
  def log_new_min_max(index, newValue, oldValue, description)
226
280
  log("New global #{description} for sub-objective #{aspect_methods[index]}",
227
281
  ("a %.3f" % (100.0 * (newValue - oldValue).protected_division_with(oldValue))) + "% difference",
@@ -247,38 +301,15 @@ class FeldtRuby::Optimize::Objective
247
301
  def global_max_values_per_aspect
248
302
  @global_max_values_per_aspect ||= Array.new(num_aspects).map {-Float::INFINITY}
249
303
  end
250
-
251
- private
252
-
253
- def aspect_methods
254
- @aspect_methods ||= self.methods.select {|m| is_aspect_method?(m)}
255
- end
256
-
257
- def is_min_aspect?(aspectIndex)
258
- (@is_min_aspect ||= (aspect_methods.map {|m| is_min_aspect_method?(m)}))[aspectIndex]
259
- end
260
-
261
- def is_aspect_method?(methodNameAsSymbolOrString)
262
- methodNameAsSymbolOrString.to_s =~ /^objective_(min|max)_([\w_]+)$/
263
- end
264
-
265
- def is_min_aspect_method?(methodNameAsSymbolOrString)
266
- methodNameAsSymbolOrString.to_s =~ /^objective_min_([\w_]+)$/
267
- end
268
304
  end
269
305
 
270
- # We add strangely named accessor methods so we can attach the quality values to objects.
271
- # We use strange names to minimize risk of method name conflicts.
272
- class Object
273
- attr_accessor :_quality_value_without_check, :_objective, :_objective_version
274
- def _quality_value
275
- @_objective.ensure_updated_quality_value(self) if defined?(@_objective) && @_objective
276
- @_quality_value_without_check ||= nil # To avoid warning if unset
277
- end
306
+ # The MWGR is a simple way to weigh the fitness values of multiple sub-objectives into a single
307
+ # fitness value.
308
+ module MeanWeigthedGlobalRatios
278
309
  end
279
310
 
280
311
  # Short hand for when the objective function is given as a block that should be minimized.
281
- class FeldtRuby::Optimize::ObjectiveMinimizeBlock < FeldtRuby::Optimize::Objective
312
+ class ObjectiveMinimizeBlock < Objective
282
313
  def initialize(&objFunc)
283
314
  super()
284
315
  @objective_function = objFunc
@@ -290,7 +321,7 @@ class FeldtRuby::Optimize::ObjectiveMinimizeBlock < FeldtRuby::Optimize::Objecti
290
321
  end
291
322
 
292
323
  # Short hand for when the objective function is given as a block that should be minimized.
293
- class FeldtRuby::Optimize::ObjectiveMaximizeBlock < FeldtRuby::Optimize::Objective
324
+ class ObjectiveMaximizeBlock < Objective
294
325
  def initialize(&objFunc)
295
326
  super()
296
327
  @objective_function = objFunc
@@ -299,4 +330,16 @@ class FeldtRuby::Optimize::ObjectiveMaximizeBlock < FeldtRuby::Optimize::Objecti
299
330
  def objective_max_cost_function(candidate)
300
331
  @objective_function.call(*candidate.to_a)
301
332
  end
333
+ end
334
+
335
+ end
336
+
337
+ # We add strangely named accessor methods so we can attach the quality values to objects.
338
+ # We use strange names to minimize risk of method name conflicts.
339
+ class Object
340
+ attr_accessor :_quality_value_without_check, :_objective, :_objective_version
341
+ def _quality_value
342
+ @_objective.ensure_updated_quality_value(self) if defined?(@_objective) && @_objective
343
+ @_quality_value_without_check ||= nil # To avoid warning if unset
344
+ end
302
345
  end
@@ -1,14 +1,18 @@
1
1
  require 'feldtruby/optimize'
2
2
 
3
+ module FeldtRuby::Optimize
4
+
3
5
  # A search space is a set of constraints that limits which values
4
6
  # are searched for. The search space can generate valid candidate
5
7
  # solutions that are inside the space. It can also check if a
6
8
  # given candidate is in the space. The default search space has min
7
9
  # and max values for each element of a continuous vector.
8
- class FeldtRuby::Optimize::SearchSpace
9
- attr_reader :min_values, :max_values
10
+ class SearchSpace
11
+ attr_reader :min_values, :max_values, :deltas
10
12
 
11
- def initialize(minValues, maxValues)
13
+ def initialize(minValues, maxValues, sampler = LatinHypercubeSampler.new)
14
+ sampler.search_space = self
15
+ @sampler = sampler
12
16
  # Check that we have valid min and max values
13
17
  raise "Not same num of min values (#{minValues.length}) as there are max values (#{maxValues.length})" if minValues.length != maxValues.length
14
18
  raise "A search space must have >= 1 variable to be searched, here you specified min values: #{minValues.inspect}" if minValues.length < 1
@@ -19,6 +23,15 @@ class FeldtRuby::Optimize::SearchSpace
19
23
  @deltas = @min_values.zip(@max_values).map {|min,max| max-min}
20
24
  end
21
25
 
26
+ # Generate a new candidate.
27
+ def gen_candidate
28
+ @sampler.sample_candidate
29
+ end
30
+
31
+ def gen_value_for_position(index)
32
+ @sampler.sample_value_for_dimension(index)
33
+ end
34
+
22
35
  # Bound candidate using the min and max values. We randomly generate a new value inside the space
23
36
  # for each element that is outside.
24
37
  def bound(candidate)
@@ -48,13 +61,94 @@ class FeldtRuby::Optimize::SearchSpace
48
61
  @min_values.length
49
62
  end
50
63
 
51
- def gen_candidate
52
- (0...num_variables).map {|i| gen_value_for_position(i)}
64
+ # A sampler generates a new candidate (or set of candidates) that is (are) within a search space.
65
+ # This default sampler does uniform random sampling over the whole search space.
66
+ class Sampler
67
+ attr_accessor :search_space
68
+
69
+ def initialize(searchSpace = nil)
70
+ self.search_space = searchSpace
71
+ end
72
+
73
+ # Random uniform sampling of a valid value for a given dimension _index_ in the search space.
74
+ def sample_value_for_dimension(index)
75
+ min, delta = search_space.min_values[index], search_space.deltas[index]
76
+ min + delta * rand()
77
+ end
78
+
79
+ # Sample one candidate within the space. Default is to do random uniform sampling.
80
+ def sample_candidate
81
+ num_vars = search_space.num_variables
82
+ (0...num_vars).map {|i| sample_value_for_dimension(i)}
83
+ end
84
+
85
+ # Sample multiple candidates from the search space. The default is just to call the method sampling one
86
+ # candidate mutliple times. But subclasses can implement more sophisticated schemes.
87
+ def sample_candidates(numCandidates)
88
+ Array.new(numCandidates) { sample_candidate() }
89
+ end
90
+ end
91
+
92
+ # Set samplers sample many candidates in one go, often to create a better "spread" of genotypes within
93
+ # the search space. This is an abstract base class so sub-classes ust override the sample_candidates method.
94
+ class SetSampler < Sampler
95
+ # The chunk size is the number of candidates that are generated in one go and from which the individual
96
+ # candidates are then taken. Default is 200 to get a nice initial spread.
97
+ def initialize(chunkSize = 200)
98
+ @chunk_size = chunkSize
99
+ @chunk = []
100
+ end
101
+
102
+ # Sample a set of candidates.
103
+ def sample_candidates(numCandidates)
104
+ raise NotImplementedError # Subclasses must implement this
105
+ end
106
+
107
+ def sample_candidate
108
+ sample_new_chunk() if chunk_empty?
109
+ pop_candidate_from_chunk()
110
+ end
111
+
112
+ def sample_new_chunk
113
+ @chunk = sample_candidates(@chunk_size)
114
+ end
115
+
116
+ def chunk_empty?
117
+ @chunk.nil? || @chunk.length < 1
118
+ end
119
+
120
+ def pop_candidate_from_chunk
121
+ @chunk.pop
122
+ end
53
123
  end
54
124
 
55
- def gen_value_for_position(i)
56
- min, delta = @min_values[i], @deltas[i]
57
- min + delta * rand()
125
+ # LatinHypercube sampling is a stratified random sampling in bins over the search space. It is a simple
126
+ # way to ensure a more even spread over the search space when sampling. It has shown to be generally
127
+ # useful for seeding initial populations in population based search without requiring any fitness
128
+ # evaluations. For example used in Deb's OmniOptimizer and in Tiwari's AMGA2 etc.
129
+ class LatinHypercubeSampler < SetSampler
130
+ # Sample the latin hypercube evenly for each dimension in the search space and then
131
+ # use Knuth unbiased shuffling to create individuals from the evenly spread out samples.
132
+ def sample_candidates(numCandidates)
133
+ set = Array.new(numCandidates).map {Array.new}
134
+ (0...(search_space.num_variables)).each do |dimension|
135
+ samples = latin_samples_for_dimension(dimension, numCandidates)
136
+ pi = (0...numCandidates).to_a.shuffle # Ruby has Knuth shuffle built in so no need to implement
137
+ (0...numCandidates).each {|i| set[i] << samples[pi[i]]}
138
+ end
139
+ set
140
+ end
141
+
142
+ # Evenly spread _numSamples_ random samples over the search space dimension with _index_.
143
+ def latin_samples_for_dimension(index, numSamples)
144
+ low, delta = search_space.min_values[index], search_space.deltas[index]
145
+ interval_size = delta / numSamples.to_f
146
+ i = 0
147
+ Array.new(numSamples).map do
148
+ i += 1
149
+ (low + (i-1) * interval_size) + (interval_size * rand())
150
+ end
151
+ end
58
152
  end
59
153
 
60
154
  def is_candidate?(c)
@@ -66,4 +160,6 @@ class FeldtRuby::Optimize::SearchSpace
66
160
  end
67
161
  end
68
162
 
69
- FeldtRuby::Optimize::DefaultSearchSpace = FeldtRuby::Optimize::SearchSpace.new_symmetric(2, 1)
163
+ DefaultSearchSpace = SearchSpace.new_symmetric(2, 1)
164
+
165
+ end