best_choice 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/README.rdoc +3 -0
  3. data/Rakefile +32 -0
  4. data/lib/best_choice.rb +26 -0
  5. data/lib/best_choice/algorithm.rb +65 -0
  6. data/lib/best_choice/algorithms/epsilon_greedy.rb +31 -0
  7. data/lib/best_choice/rails_util.rb +76 -0
  8. data/lib/best_choice/selector.rb +51 -0
  9. data/lib/best_choice/selectors/greedy_redis.rb +16 -0
  10. data/lib/best_choice/storage/redis_hash.rb +71 -0
  11. data/lib/best_choice/version.rb +5 -0
  12. data/test/algorithm_test.rb +40 -0
  13. data/test/algorithms/epsilon_greedy_test.rb +152 -0
  14. data/test/best_choice_test.rb +97 -0
  15. data/test/dummy/app/controllers/application_controller.rb +5 -0
  16. data/test/dummy/app/controllers/main_controller.rb +20 -0
  17. data/test/dummy/app/views/layouts/application.html.erb +11 -0
  18. data/test/dummy/app/views/main/landing.html.erb +22 -0
  19. data/test/dummy/bin/bundle +3 -0
  20. data/test/dummy/bin/rails +4 -0
  21. data/test/dummy/bin/rake +4 -0
  22. data/test/dummy/config.ru +4 -0
  23. data/test/dummy/config/application.rb +12 -0
  24. data/test/dummy/config/boot.rb +5 -0
  25. data/test/dummy/config/database.yml +25 -0
  26. data/test/dummy/config/environment.rb +5 -0
  27. data/test/dummy/config/environments/development.rb +29 -0
  28. data/test/dummy/config/environments/production.rb +80 -0
  29. data/test/dummy/config/environments/test.rb +36 -0
  30. data/test/dummy/config/initializers/secret_token.rb +12 -0
  31. data/test/dummy/config/initializers/session_store.rb +3 -0
  32. data/test/dummy/config/routes.rb +6 -0
  33. data/test/dummy/db/test.sqlite3 +0 -0
  34. data/test/dummy/log/test.log +5059 -0
  35. data/test/dummy/test/controllers/main_controller_test.rb +116 -0
  36. data/test/fixtures/dummy_algorithm.rb +6 -0
  37. data/test/fixtures/dummy_storage.rb +6 -0
  38. data/test/test_helper.rb +20 -0
  39. metadata +178 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 2e6e75982850ff066a696cdb4b59c075d3842719
4
+ data.tar.gz: d85afa3104d948cc738f4e9481d60a6072e86e2f
5
+ SHA512:
6
+ metadata.gz: a7b1352cae375f7465245be07ef977320c048cec1f053cc85f63106470b85b71b141f8c1acb2786992c259cfd5cdfc79bedd3469fe0e93fde72c15b6c6ca398a
7
+ data.tar.gz: 10dd60b5a74bcca468a1507b33f69b583e719d58a9ae43f094824b589882f61367777f1cfdaf315cd5e3eb185b5271b5598e9abbeb5302777e5c56a6d5802ec3
data/README.rdoc ADDED
@@ -0,0 +1,3 @@
1
+ = BestChoice
2
+
3
+ This project rocks and uses MIT-LICENSE.
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'BestChoice'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+
18
+
19
+
20
+ Bundler::GemHelper.install_tasks
21
+
22
+ require 'rake/testtask'
23
+
24
+ Rake::TestTask.new(:test) do |t|
25
+ t.libs << 'lib'
26
+ t.libs << 'test'
27
+ t.pattern = 'test/**/*_test.rb'
28
+ t.verbose = false
29
+ end
30
+
31
+
32
+ task default: :test
@@ -0,0 +1,26 @@
1
+ require './lib/best_choice/algorithm'
2
+ require './lib/best_choice/selector'
3
+
4
+ require './lib/best_choice/algorithms/epsilon_greedy'
5
+ require './lib/best_choice/storage/redis_hash'
6
+
7
+ require './lib/best_choice/selectors/greedy_redis'
8
+
9
+
10
+ module BestChoice
11
+
12
+ module_function
13
+
14
+ def for selector_name, selector: default_selector, **opts
15
+ selector.new(selector_name, opts)
16
+ end
17
+
18
+ def default_selector
19
+ @default_selector ||= Selectors::GreedyRedis
20
+ end
21
+
22
+ def default_selector= selector
23
+ @default_selector = selector
24
+ end
25
+
26
+ end
@@ -0,0 +1,65 @@
1
+ module BestChoice
2
+
3
+ class Algorithm
4
+
5
+ class NoAvailableOptionsError < StandardError ; end
6
+
7
+
8
+ def initialize
9
+ raise NotImplementedError.new 'Expected subclass to implement'
10
+ end
11
+
12
+ def pick_option stats
13
+ unless stats.kind_of? Array
14
+ raise ArgumentError.new 'Expected an array'
15
+ end
16
+
17
+ case stats.length
18
+ when 0
19
+ raise NoAvailableOptionsError
20
+ when 1
21
+ stats.first
22
+ else
23
+ do_actual_pick stats
24
+ end
25
+ end
26
+
27
+
28
+ protected
29
+
30
+ def do_actual_pick stats=[]
31
+ raise NotImplementedError.new 'Expected subclass to implement'
32
+ end
33
+
34
+ def choice_rate option_data
35
+ display_count = option_data[:display_count]
36
+ success_count = option_data[:success_count]
37
+
38
+ unless is_non_negative_int?(display_count)
39
+ raise ArgumentError.new "Invalid display_count: #{display_count.inspect}"
40
+ end
41
+ unless is_non_negative_int?(success_count)
42
+ raise ArgumentError.new "Invalid success_count: #{success_count.inspect}"
43
+ end
44
+ if option_data[:success_count] > option_data[:display_count]
45
+ raise ArgumentError.new "success_count: #{success_count} " \
46
+ "higher than display_count: #{display_count}"
47
+ end
48
+
49
+ if option_data[:display_count] == 0
50
+ 100
51
+ else
52
+ option_data[:success_count].to_f / option_data[:display_count]
53
+ end
54
+ end
55
+
56
+
57
+ private
58
+
59
+ def is_non_negative_int? arg
60
+ arg.kind_of?(Integer) && arg >= 0
61
+ end
62
+
63
+ end
64
+
65
+ end
@@ -0,0 +1,31 @@
1
+ module BestChoice
2
+ module Algorithms
3
+
4
+ class EpsilonGreedy < BestChoice::Algorithm
5
+
6
+ DEFAULT_RANDOMNESS_FACTOR = 10
7
+
8
+
9
+ def initialize randomness_factor: DEFAULT_RANDOMNESS_FACTOR
10
+ unless randomness_factor.between? 0, 100
11
+ raise ArgumentError.new "Invalid randomness_factor: #{randomness_factor}"
12
+ end
13
+ @randomness_factor = randomness_factor
14
+ end
15
+
16
+
17
+ protected
18
+
19
+ def do_actual_pick stats
20
+ options_by_rate = stats.sort_by{ |o| choice_rate(o) }
21
+ if rand(100)+1 > @randomness_factor
22
+ options_by_rate.last
23
+ else
24
+ options_by_rate[0..-2].sample
25
+ end
26
+ end
27
+
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,76 @@
1
+ require 'set'
2
+
3
+ module BestChoice
4
+ module RailsUtil
5
+
6
+ extend ActiveSupport::Concern
7
+
8
+
9
+ included do
10
+ before_filter do
11
+ RailsUtil.instance_variable_set('@session', session)
12
+ end
13
+
14
+ after_filter do
15
+ if @_best_choice_selectors
16
+ RailsUtil.best_choice_picks = @_best_choice_selectors.inject(
17
+ RailsUtil.best_choice_picks) do |picks, (_, selector)|
18
+ picks.merge!(selector.name => selector.picked_option)
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+
25
+ module SelectorExtension
26
+
27
+ def self.extended klass
28
+ klass.class_eval do
29
+ define_method :mark_success do
30
+ if pick = RailsUtil.best_choice_picks[name]
31
+ super pick
32
+ RailsUtil.best_choice_picks.delete name
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ end
39
+
40
+
41
+ # -------------------------------------------------
42
+ # Module functions
43
+
44
+ def self.best_choice_picks
45
+ @session[:_best_choice_picks] ||= {}
46
+ end
47
+
48
+ def self.best_choice_picks= picked_options
49
+ @session[:_best_choice_picks] = picked_options
50
+ end
51
+
52
+
53
+ # -------------------------------------------------
54
+ # Methods visible to Rails controller
55
+
56
+ def best_choice_for s_name, selector: BestChoice.default_selector, **opts
57
+ s = selector.new s_name, opts
58
+ s.extend SelectorExtension
59
+ best_choice_selector_register s
60
+ s.picked_option = RailsUtil.best_choice_picks[s_name]
61
+ s
62
+ end
63
+
64
+
65
+ private
66
+
67
+ # Save the test data in the instance variable, so that
68
+ # it can be later retrieved in the after_filter.
69
+ #
70
+ def best_choice_selector_register selector
71
+ @_best_choice_selectors ||= {}
72
+ @_best_choice_selectors[selector.name] ||= selector
73
+ end
74
+
75
+ end
76
+ end
@@ -0,0 +1,51 @@
1
+ module BestChoice
2
+
3
+ class Selector
4
+
5
+ attr_reader :name
6
+ attr_writer :picked_option
7
+
8
+
9
+ def initialize name, storage:, algorithm:, **opts
10
+ @name = name
11
+ @storage = storage.new(name)
12
+ @algorithm = algorithm.new(opts)
13
+ end
14
+
15
+ def option option
16
+ @storage.add option
17
+
18
+ if should_display? option
19
+ mark_display option
20
+ yield
21
+ end
22
+ end
23
+
24
+ def mark_success option
25
+ @marked_success ||= !!@storage.success_count_incr(option)
26
+ end
27
+
28
+ def picked_option
29
+ @picked_option ||= @algorithm.pick_option(@storage.stats)[:name]
30
+ end
31
+
32
+ def equal? selector
33
+ self.class == selector.class && name == selector.name
34
+ end
35
+
36
+ alias :== :equal?
37
+
38
+
39
+ private
40
+
41
+ def should_display? option
42
+ picked_option == option
43
+ end
44
+
45
+ def mark_display option
46
+ @marked_display ||= !!@storage.display_count_incr(option)
47
+ end
48
+
49
+ end
50
+
51
+ end
@@ -0,0 +1,16 @@
1
+ module BestChoice
2
+ module Selectors
3
+
4
+ class GreedyRedis < BestChoice::Selector
5
+
6
+ def initialize name, opts={}
7
+ super name, opts.merge({
8
+ storage: Storage::RedisHash,
9
+ algorithm: Algorithms::EpsilonGreedy
10
+ })
11
+ end
12
+
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,71 @@
1
+ require 'json'
2
+ require 'redis-objects'
3
+
4
+ module BestChoice
5
+ module Storage
6
+
7
+ class RedisHash
8
+
9
+ def initialize storage_name
10
+ @hash_key = Redis::HashKey.new "best_choice:#{storage_name}"
11
+ end
12
+
13
+ def options
14
+ if @hash_key[:options]
15
+ JSON.parse @hash_key[:options]
16
+ else
17
+ []
18
+ end
19
+ end
20
+
21
+ def add option
22
+ unless has_option? option
23
+ @hash_key[:options] = (options << option).to_json
24
+ end
25
+ end
26
+
27
+ def display_count_incr option
28
+ @hash_key.incr display_count_key(option)
29
+ end
30
+
31
+ def success_count_incr option
32
+ @hash_key.incr success_count_key(option)
33
+ end
34
+
35
+ def stats
36
+ options.inject([]) { |memo, option|
37
+ memo << {
38
+ name: option,
39
+ display_count: display_count(option),
40
+ success_count: success_count(option)
41
+ }
42
+ }
43
+ end
44
+
45
+
46
+ private
47
+
48
+ def has_option? option
49
+ !!options.index(option)
50
+ end
51
+
52
+ def display_count option
53
+ (@hash_key[display_count_key(option)] || 0).to_i
54
+ end
55
+
56
+ def success_count option
57
+ (@hash_key[success_count_key(option)] || 0).to_i
58
+ end
59
+
60
+ def display_count_key option
61
+ "display_#{option}"
62
+ end
63
+
64
+ def success_count_key option
65
+ "success_#{option}"
66
+ end
67
+
68
+ end
69
+
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ module BestChoice
2
+
3
+ VERSION = '0.0.1'
4
+
5
+ end
@@ -0,0 +1,40 @@
1
+ require 'test_helper'
2
+
3
+ class AlgorithmTest < ActiveSupport::TestCase
4
+
5
+ def setup
6
+ @algorithm = DummyAlgorithm.new
7
+ end
8
+
9
+ test 'pick_option' do
10
+ # Empty stats
11
+ assert_raises(BestChoice::Algorithm::NoAvailableOptionsError) do
12
+ @algorithm.pick_option []
13
+ end
14
+
15
+ # One option
16
+ assert_equal 'an_option', @algorithm.pick_option(['an_option'])
17
+
18
+ # Multiple options
19
+ options = ['a','b']
20
+ mock(@algorithm).do_actual_pick options
21
+ @algorithm.pick_option options
22
+ end
23
+
24
+ test 'the class itself does not implement do_actual_pick method' do
25
+ assert_raises(NotImplementedError) do
26
+ @algorithm.send :do_actual_pick
27
+ end
28
+ end
29
+
30
+ test 'is_non_negative_int?' do
31
+ [ nil, '', 'a', -1, -0.01, 0.1, 12.23 ].each do |arg|
32
+ refute @algorithm.send :is_non_negative_int?, arg
33
+ end
34
+
35
+ [ 0, 1 ].each do |arg|
36
+ assert @algorithm.send :is_non_negative_int?, arg
37
+ end
38
+ end
39
+
40
+ end
@@ -0,0 +1,152 @@
1
+ require 'test_helper'
2
+
3
+ class EpsilonGreedyTest < ActiveSupport::TestCase
4
+
5
+ def setup
6
+ @algorithm = BestChoice::Algorithms::EpsilonGreedy.new
7
+ end
8
+
9
+ test 'randomness_factor validation' do
10
+ invalid_values = [ -1, -0.1, 100.5, 101 ]
11
+ invalid_values.each { |val|
12
+ assert_raises(ArgumentError) do
13
+ BestChoice::Algorithms::EpsilonGreedy.new(randomness_factor: val)
14
+ end
15
+ }
16
+
17
+ valid_values = [ 0.1, 1, 100 ]
18
+ valid_values.each { |val|
19
+ assert_nothing_raised do
20
+ BestChoice::Algorithms::EpsilonGreedy.new(randomness_factor: val)
21
+ end
22
+ }
23
+ end
24
+
25
+ test 'choice_rate' do
26
+ invalid_data = [
27
+ { display_count: nil , success_count: 0 },
28
+ { display_count: 1 , success_count: nil },
29
+ { display_count: 0 , success_count: -1 },
30
+ { display_count: -1 , success_count: -2 },
31
+ { display_count: 1.0 , success_count: 0 },
32
+ { display_count: 1.1 , success_count: 0 },
33
+ { display_count: 1 , success_count: 2 },
34
+ ]
35
+
36
+ invalid_data.each { |data|
37
+ assert_raises(ArgumentError) do
38
+ @algorithm.send :choice_rate, data
39
+ end
40
+ }
41
+
42
+ assert_equal 100,
43
+ @algorithm.send(:choice_rate, { display_count: 0, success_count: 0 })
44
+ assert_equal 0.5,
45
+ @algorithm.send(:choice_rate, { display_count: 6, success_count: 3 })
46
+ assert_equal 2/3.to_f,
47
+ @algorithm.send(:choice_rate, { display_count: 6, success_count: 4 })
48
+ end
49
+
50
+ test 'actual picking via pick_option' do
51
+
52
+ # --------------------------------------------------------
53
+ # Non-random version
54
+ # --------------------------------------------------------
55
+
56
+ non_random_algorithm = BestChoice::Algorithms::EpsilonGreedy.new(
57
+ randomness_factor: 0)
58
+
59
+ # The first option should be always chosen.
60
+ stats = [
61
+ { display_count: 2 , success_count: 2 },
62
+ { display_count: 2 , success_count: 1 },
63
+ { display_count: 1 , success_count: 0 }
64
+ ]
65
+
66
+ 10.times {
67
+ assert_equal stats[0], non_random_algorithm.pick_option(stats)
68
+ }
69
+
70
+ # If the second option has the same success_count as the first one, but
71
+ # a higher display_count, the first option should still be chosen.
72
+ #
73
+ stats = [
74
+ { display_count: 2 , success_count: 2 },
75
+ { display_count: 3 , success_count: 2 },
76
+ { display_count: 1 , success_count: 0 }
77
+ ]
78
+
79
+ 10.times {
80
+ assert_equal stats[0], non_random_algorithm.pick_option(stats)
81
+ }
82
+
83
+ # Even if the ratio of success_count to display_count of the first option
84
+ # drops to the ratio of the second option, it would still be preferred
85
+ # if its display_count is higher.
86
+ #
87
+ stats = [
88
+ { display_count: 6 , success_count: 4 },
89
+ { display_count: 3 , success_count: 2 },
90
+ { display_count: 1 , success_count: 0 }
91
+ ]
92
+
93
+ 10.times {
94
+ assert_equal stats[0], non_random_algorithm.pick_option(stats)
95
+ }
96
+
97
+ # Changing the ratio in favor of the second option, should make it the
98
+ # new optimal choice.
99
+ #
100
+ stats = [
101
+ { display_count: 6 , success_count: 3 },
102
+ { display_count: 4 , success_count: 3 },
103
+ { display_count: 1 , success_count: 0 }
104
+ ]
105
+
106
+ 10.times {
107
+ assert_equal stats[1], non_random_algorithm.pick_option(stats)
108
+ }
109
+
110
+ # --------------------------------------------------------
111
+ # 100% random version
112
+ # --------------------------------------------------------
113
+
114
+ totally_random_algorithm = BestChoice::Algorithms::EpsilonGreedy.new(
115
+ randomness_factor: 100)
116
+
117
+ stats = [
118
+ { display_count: 5, success_count: 2 },
119
+ { display_count: 5, success_count: 1 },
120
+ { display_count: 2, success_count: 0 }
121
+ ]
122
+
123
+ # The probability of choosing each non-optimal option should be equal.
124
+ #
125
+ chosen_options = []
126
+ 20.times {
127
+ chosen_options << totally_random_algorithm.pick_option(stats)
128
+ }
129
+ assert_equal stats[1..2],
130
+ chosen_options.uniq.sort_by{ |d| d[:success_count] }.reverse
131
+
132
+ # --------------------------------------------------------
133
+ # Default version
134
+ # --------------------------------------------------------
135
+
136
+ stats = [
137
+ { display_count: 5, success_count: 2 },
138
+ { display_count: 5, success_count: 1 }
139
+ ]
140
+
141
+ # The second option should be chosen only in ~10% of cases.
142
+ #
143
+ chosen_options = []
144
+ 1000.times { chosen_options << @algorithm.pick_option(stats) }
145
+
146
+ counts = Hash.new 0
147
+ chosen_options.each { |opt| counts[opt] += 1 }
148
+
149
+ assert counts[stats[1]] > 80 # Leave some margin.
150
+ end
151
+
152
+ end