bandit 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
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