best_choice 0.0.1

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 (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