bandit 0.0.2 → 0.0.3

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 (53) hide show
  1. data/.gitignore +1 -0
  2. data/LICENSE +674 -0
  3. data/README.rdoc +70 -2
  4. data/Rakefile +32 -0
  5. data/bandit.gemspec +2 -0
  6. data/lib/bandit.rb +52 -1
  7. data/lib/bandit/config.rb +33 -0
  8. data/lib/bandit/date_hour.rb +82 -0
  9. data/lib/bandit/exceptions.rb +10 -0
  10. data/lib/bandit/experiment.rb +63 -0
  11. data/lib/bandit/extensions/controller_concerns.rb +24 -0
  12. data/lib/bandit/extensions/string.rb +5 -0
  13. data/lib/bandit/extensions/time.rb +5 -0
  14. data/lib/bandit/extensions/view_concerns.rb +24 -0
  15. data/lib/bandit/memoizable.rb +15 -0
  16. data/lib/bandit/players/base.rb +37 -0
  17. data/lib/bandit/players/epsilon_greedy.rb +32 -0
  18. data/lib/bandit/players/round_robin.rb +7 -0
  19. data/lib/bandit/storage/base.rb +124 -0
  20. data/lib/bandit/storage/memcache.rb +34 -0
  21. data/lib/bandit/storage/memory.rb +31 -0
  22. data/lib/bandit/storage/redis.rb +37 -0
  23. data/lib/bandit/version.rb +1 -1
  24. data/lib/generators/bandit/USAGE +3 -0
  25. data/lib/generators/bandit/dashboard_generator.rb +31 -0
  26. data/lib/generators/bandit/install_generator.rb +22 -0
  27. data/lib/generators/bandit/templates/bandit.rake +20 -0
  28. data/lib/generators/bandit/templates/bandit.rb +18 -0
  29. data/lib/generators/bandit/templates/bandit.yml +16 -0
  30. data/lib/generators/bandit/templates/bandit_controller.rb +28 -0
  31. data/lib/generators/bandit/templates/dashboard/bandit.html.erb +43 -0
  32. data/lib/generators/bandit/templates/dashboard/css/application.css +7 -0
  33. data/lib/generators/bandit/templates/dashboard/css/base.css +22 -0
  34. data/lib/generators/bandit/templates/dashboard/css/toupee/buttons.css +101 -0
  35. data/lib/generators/bandit/templates/dashboard/css/toupee/forms.css +89 -0
  36. data/lib/generators/bandit/templates/dashboard/css/toupee/modules.css +30 -0
  37. data/lib/generators/bandit/templates/dashboard/css/toupee/reset.css +42 -0
  38. data/lib/generators/bandit/templates/dashboard/css/toupee/structure.css +124 -0
  39. data/lib/generators/bandit/templates/dashboard/css/toupee/typography.css +103 -0
  40. data/lib/generators/bandit/templates/dashboard/helpers/bandit_helper.rb +19 -0
  41. data/lib/generators/bandit/templates/dashboard/js/bandit.js +34 -0
  42. data/lib/generators/bandit/templates/dashboard/js/highstock.js +215 -0
  43. data/lib/generators/bandit/templates/dashboard/js/jquery.min.js +154 -0
  44. data/lib/generators/bandit/templates/dashboard/view/index.html.erb +21 -0
  45. data/lib/generators/bandit/templates/dashboard/view/show.html.erb +24 -0
  46. data/players.rdoc +22 -0
  47. data/test/config.yml +7 -0
  48. data/test/helper.rb +18 -0
  49. data/test/memcache_storage_test.rb +17 -0
  50. data/test/memory_storage_test.rb +16 -0
  51. data/test/redis_storage_test.rb +17 -0
  52. data/test/storage_test_base.rb +54 -0
  53. metadata +88 -8
@@ -0,0 +1,37 @@
1
+ module Bandit
2
+ class BasePlayer
3
+ def self.get_player(name, config)
4
+ config ||= {}
5
+
6
+ case name
7
+ when :round_robin then RoundRobinPlayer.new(config)
8
+ when :epsilon_greedy then EpsilonGreedyPlayer.new(config)
9
+ else raise UnknownPlayerEngineError, "#{name} not a known player type"
10
+ end
11
+ end
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ @storage = Bandit.storage
16
+ end
17
+
18
+ def choose_alternative(experiment)
19
+ # return the alternative that should be chosen
20
+ raise NotImplementedError
21
+ end
22
+
23
+ # store state variable by name
24
+ def set(name, value)
25
+ @storage.player_state_set(self, name, value)
26
+ end
27
+
28
+ # get state variable by name
29
+ def get(name)
30
+ @storage.player_state_get(self, name)
31
+ end
32
+
33
+ def name
34
+ self.class.to_s
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,32 @@
1
+ module Bandit
2
+ class EpsilonGreedyPlayer < BasePlayer
3
+ include Memoizable
4
+
5
+ def choose_alternative(experiment)
6
+ epsilon = @config['epsilon'].to_f || 0.1
7
+
8
+ # choose best with probability of 1-epsilon
9
+ if rand <= (1-epsilon)
10
+ best_alternative(experiment)
11
+ else
12
+ experiment.alternatives.choice
13
+ end
14
+ end
15
+
16
+ def best_alternative(experiment)
17
+ memoize(experiment.name) {
18
+ best = nil
19
+ best_rate = nil
20
+ experiment.alternatives.each { |alt|
21
+ rate = experiment.conversion_rate(alt)
22
+ if best_rate.nil? or rate > best_rate
23
+ best = alt
24
+ best_rate = rate
25
+ end
26
+ }
27
+ best
28
+ }
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,7 @@
1
+ module Bandit
2
+ class RoundRobinPlayer < BasePlayer
3
+ def choose_alternative(experiment)
4
+ experiment.alternatives.choice
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,124 @@
1
+ # store total count for this alternative
2
+ # conversions:<experiment>:<alternative> = count
3
+ # participants:<experiment>:<alternative> = count
4
+
5
+ # store first time an alternative is used
6
+ # altstart:<experiment>:<alternative> = timestamp
7
+
8
+ # store total count for this alternative per day and hour
9
+ # conversions:<experiment>:<alternative>:<date>:<hour> = count
10
+ # participants:<experiment>:<alternative>:<date>:<hour> = count
11
+
12
+ # every so often store current epsilon
13
+ # state:<experiment>:<player>:epsilon = 0.1
14
+
15
+ module Bandit
16
+ class BaseStorage
17
+ def self.get_storage(name, config)
18
+ config ||= {}
19
+
20
+ case name
21
+ when :memory then MemoryStorage.new(config)
22
+ when :memcache then MemCacheStorage.new(config)
23
+ when :redis then RedisStorage.new(config)
24
+ else raise UnknownStorageEngineError, "#{name} not a known storage method"
25
+ end
26
+ end
27
+
28
+ # increment key by count
29
+ def incr(key, count)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # initialize key if not set
34
+ def init(key, value)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ # get key if exists, otherwise 0
39
+ def get(key, default=0)
40
+ raise NotImplementedError
41
+ end
42
+
43
+ # set key with value, regardless of whether it is set or not
44
+ def set(key, value)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ # clear all stored values
49
+ def clear!
50
+ raise NotImplementedError
51
+ end
52
+
53
+ def incr_participants(experiment, alternative, count=1, date_hour=nil)
54
+ date_hour ||= DateHour.now
55
+
56
+ # initialize first start time for alternative if we haven't inited yet
57
+ init alt_started_key(experiment, alternative), date_hour.to_i
58
+
59
+ # increment total count and per hour count
60
+ incr part_key(experiment, alternative), count
61
+ incr part_key(experiment, alternative, date_hour), count
62
+ end
63
+
64
+ def incr_conversions(experiment, alternative, count=1, date_hour=nil)
65
+ # increment total count and per hour count
66
+ incr conv_key(experiment, alternative), count
67
+ incr conv_key(experiment, alternative, date_hour || DateHour.now), count
68
+ end
69
+
70
+ # if date_hour isn't specified, get total count
71
+ # if date_hour is specified, return count for DateHour
72
+ def participant_count(experiment, alternative, date_hour=nil)
73
+ get part_key(experiment, alternative, date_hour)
74
+ end
75
+
76
+ # if date_hour isn't specified, get total count
77
+ # if date_hour is specified, return count for DateHour
78
+ def conversion_count(experiment, alternative, date_hour=nil)
79
+ get conv_key(experiment, alternative, date_hour)
80
+ end
81
+
82
+ def player_state_set(experiment, player, name, value)
83
+ set player_state_key(experiment, player, name), value
84
+ end
85
+
86
+ def player_state_get(experiment, player, name)
87
+ get player_state_key(experiment, player, name), nil
88
+ end
89
+
90
+ def alternative_start_time(experiment, alternative)
91
+ secs = get alt_started_key(experiment, alternative), nil
92
+ secs.nil? ? nil : Time.at(secs).to_date_hour
93
+ end
94
+
95
+ # if date_hour is nil, create key for total
96
+ # otherwise, create key for hourly based
97
+ def part_key(exp, alt, date_hour=nil)
98
+ parts = [ "participants", exp.name, alt ]
99
+ parts += [ date_hour.date, date_hour.hour ] unless date_hour.nil?
100
+ make_key parts
101
+ end
102
+
103
+ # key for alternative start
104
+ def alt_started_key(experiment, alternative)
105
+ make_key [ "altstarted", experiment.name, alternative ]
106
+ end
107
+
108
+ # if date_hour is nil, create key for total
109
+ # otherwise, create key for hourly based
110
+ def conv_key(exp, alt, date_hour=nil)
111
+ parts = [ "conversions", exp.name, alt ]
112
+ parts += [ date_hour.date, date_hour.hour ] unless date_hour.nil?
113
+ make_key parts
114
+ end
115
+
116
+ def player_state_key(exp, player, varname)
117
+ make_key [ "state", exp.name, player.name, varname ]
118
+ end
119
+
120
+ def make_key(parts)
121
+ parts.join(":")
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,34 @@
1
+ module Bandit
2
+ class MemCacheStorage < BaseStorage
3
+ def initialize(config)
4
+ require 'memcache'
5
+ config[:namespace] ||= 'bandit'
6
+ @memcache = MemCache.new(config.fetch(:host, 'localhost:11211'), config)
7
+ end
8
+
9
+ # increment key by count
10
+ def incr(key, count=1)
11
+ # memcache incr seems to be broken in memcache-client gem
12
+ set(key, get(key, 0) + count)
13
+ end
14
+
15
+ # initialize key if not set
16
+ def init(key, value)
17
+ @memcache.add(key, value)
18
+ end
19
+
20
+ # get key if exists, otherwise 0
21
+ def get(key, default=0)
22
+ @memcache.get(key) || default
23
+ end
24
+
25
+ # set key with value, regardless of whether it is set or not
26
+ def set(key, value)
27
+ @memcache.set(key, value)
28
+ end
29
+
30
+ def clear!
31
+ @memcache.flush_all
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ module Bandit
2
+ class MemoryStorage < BaseStorage
3
+ def initialize(config)
4
+ @memory = Hash.new nil
5
+ end
6
+
7
+ # increment key by count
8
+ def incr(key, count=1)
9
+ @memory[key] = get(key) + count
10
+ end
11
+
12
+ # initialize key if not set
13
+ def init(key, value)
14
+ @memory[key] = value if @memory[key].nil?
15
+ end
16
+
17
+ # get key if exists, otherwise 0
18
+ def get(key, default=0)
19
+ @memory.fetch(key, default)
20
+ end
21
+
22
+ # set key with value, regardless of whether it is set or not
23
+ def set(key, value)
24
+ @memory[key] = value
25
+ end
26
+
27
+ def clear!
28
+ @memory = Hash.new nil
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ module Bandit
2
+ class RedisStorage < BaseStorage
3
+ def initialize(config)
4
+ require 'redis'
5
+ config[:host] ||= 'localhost'
6
+ config[:port] ||= 6379
7
+ config[:db] ||= "bandit"
8
+ @redis = Redis.new config
9
+ end
10
+
11
+ # increment key by count
12
+ def incr(key, count=1)
13
+ @redis.incrby(key, count)
14
+ end
15
+
16
+ # initialize key if not set
17
+ def init(key, value)
18
+ @redis.set(key, value) if get(key, nil).nil?
19
+ end
20
+
21
+ # get key if exists, otherwise 0
22
+ def get(key, default=0)
23
+ val = @redis.get(key)
24
+ return default if val.nil?
25
+ val.numeric? ? val.to_i : val
26
+ end
27
+
28
+ # set key with value, regardless of whether it is set or not
29
+ def set(key, value)
30
+ @redis.set(key, value)
31
+ end
32
+
33
+ def clear!
34
+ @redis.flushdb
35
+ end
36
+ end
37
+ end
@@ -1,3 +1,3 @@
1
1
  module Bandit
2
- VERSION = "0.0.2"
2
+ VERSION = "0.0.3"
3
3
  end
@@ -0,0 +1,3 @@
1
+ To copy bandit config and initilizer:
2
+
3
+ rails generate bandit:install
@@ -0,0 +1,31 @@
1
+ module Bandit
2
+ module Generators
3
+
4
+ class DashboardGenerator < Rails::Generators::Base
5
+ desc "Create a bandit dashboard controller"
6
+ source_root File.expand_path('../templates', __FILE__)
7
+
8
+ def copy_controller
9
+ copy_file 'bandit_controller.rb', 'app/controllers/bandit_controller.rb'
10
+ end
11
+
12
+ def copy_view
13
+ directory 'dashboard/view', 'app/views/bandit'
14
+ copy_file 'dashboard/bandit.html.erb', 'app/views/layouts/bandit.html.erb'
15
+ directory 'dashboard/helpers', 'app/helpers'
16
+ end
17
+
18
+ def copy_assets
19
+ directory 'dashboard/js', 'public/javascripts/bandit'
20
+ directory 'dashboard/css', 'public/stylesheets/bandit'
21
+ end
22
+
23
+ def message
24
+ say "\n\tNow, add the following to your config/routes.rb file:"
25
+ say "\t\tresources :bandit\n\n"
26
+ end
27
+
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ module Bandit
2
+ module Generators
3
+
4
+ class InstallGenerator < Rails::Generators::Base
5
+ desc "Copy Bandit default config/initialization files"
6
+ source_root File.expand_path('../templates', __FILE__)
7
+
8
+ def copy_initializers
9
+ copy_file 'bandit.rb', 'config/initializers/bandit.rb'
10
+ end
11
+
12
+ def copy_config
13
+ copy_file 'bandit.yml', 'config/bandit.yml'
14
+ end
15
+
16
+ def copy_rakefile
17
+ copy_file 'bandit.rake', 'lib/tasks/bandit.rake'
18
+ end
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ namespace :bandit do
2
+
3
+ desc "Generate fake data for a named experiment"
4
+ task :populate_data, [:experiment] => [:environment] do |t, args|
5
+ storage = Bandit.storage
6
+ exp = Bandit.get_experiment args[:experiment].intern
7
+ raise "No such experiment defined: #{args[:experiment]}" if exp.nil?
8
+
9
+ start = Bandit::DateHour.new((Date.today - 7), 0)
10
+ start.upto(Bandit::DateHour.now) { |dh|
11
+ puts "Adding data for alternatives on #{dh}"
12
+ exp.alternatives.each { |alt|
13
+ count = 1000 + rand(1000)
14
+ storage.incr_participants(exp, alt, count, dh)
15
+ storage.incr_conversions(exp, alt, (rand * count).floor, dh)
16
+ }
17
+ }
18
+ end
19
+
20
+ end
@@ -0,0 +1,18 @@
1
+ # Use this setup block to configure all options for Bandit.
2
+ Bandit.setup do |config|
3
+ yml = YAML.load_file("#{Rails.root}/config/bandit.yml")[Rails.env]
4
+
5
+ config.player = yml['player']
6
+ config.player_config = yml['player_config']
7
+
8
+ config.storage = yml['storage']
9
+ config.storage_config = yml['storage_config']
10
+ end
11
+
12
+ # Create your experiments here - like this:
13
+ # Bandit::Experiment.create(:click_test) { |exp|
14
+ # exp.alternatives = [ 20, 30, 40 ]
15
+ # exp.title = "Click Test"
16
+ # exp.description = "A test of clicks on purchase page with varying link sizes."
17
+ # }
18
+
@@ -0,0 +1,16 @@
1
+ development:
2
+ player: round_robin
3
+ storage: memcache
4
+
5
+ test:
6
+ player: round_robin
7
+ storage: memcache
8
+
9
+ production:
10
+ player: epsilon_greedy
11
+ player_config:
12
+ epsilon: 0.1
13
+ storage: redis
14
+ storage_config:
15
+ host: localhost
16
+ port: 6379
@@ -0,0 +1,28 @@
1
+ class BanditController < ApplicationController
2
+ layout 'bandit'
3
+
4
+ def index
5
+ @experiments = Bandit.experiments
6
+ end
7
+
8
+ def show
9
+ @experiment = Bandit.get_experiment params[:id].intern
10
+ respond_to do |format|
11
+ format.html
12
+ format.csv { render :text => experiment_csv(@experiment) }
13
+ end
14
+ end
15
+
16
+ private
17
+ def experiment_csv(experiment)
18
+ rows = []
19
+ experiment.alternatives.each { |alt|
20
+ experiment.alternative_start(alt).date.upto(Date.today) { |d|
21
+ pcount = Bandit::DateHour.date_inject(d, 0) { |sum,dh| sum + experiment.participant_count(alt, dh) }
22
+ ccount = Bandit::DateHour.date_inject(d, 0) { |sum,dh| sum + experiment.conversion_count(alt, dh) }
23
+ rows << [ alt, d.year, d.month, d.day, pcount, ccount ].join("\t")
24
+ }
25
+ }
26
+ rows.join("\n")
27
+ end
28
+ end