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.
- data/.gitignore +1 -0
- data/LICENSE +674 -0
- data/README.rdoc +70 -2
- data/Rakefile +32 -0
- data/bandit.gemspec +2 -0
- data/lib/bandit.rb +52 -1
- data/lib/bandit/config.rb +33 -0
- data/lib/bandit/date_hour.rb +82 -0
- data/lib/bandit/exceptions.rb +10 -0
- data/lib/bandit/experiment.rb +63 -0
- data/lib/bandit/extensions/controller_concerns.rb +24 -0
- data/lib/bandit/extensions/string.rb +5 -0
- data/lib/bandit/extensions/time.rb +5 -0
- data/lib/bandit/extensions/view_concerns.rb +24 -0
- data/lib/bandit/memoizable.rb +15 -0
- data/lib/bandit/players/base.rb +37 -0
- data/lib/bandit/players/epsilon_greedy.rb +32 -0
- data/lib/bandit/players/round_robin.rb +7 -0
- data/lib/bandit/storage/base.rb +124 -0
- data/lib/bandit/storage/memcache.rb +34 -0
- data/lib/bandit/storage/memory.rb +31 -0
- data/lib/bandit/storage/redis.rb +37 -0
- data/lib/bandit/version.rb +1 -1
- data/lib/generators/bandit/USAGE +3 -0
- data/lib/generators/bandit/dashboard_generator.rb +31 -0
- data/lib/generators/bandit/install_generator.rb +22 -0
- data/lib/generators/bandit/templates/bandit.rake +20 -0
- data/lib/generators/bandit/templates/bandit.rb +18 -0
- data/lib/generators/bandit/templates/bandit.yml +16 -0
- data/lib/generators/bandit/templates/bandit_controller.rb +28 -0
- data/lib/generators/bandit/templates/dashboard/bandit.html.erb +43 -0
- data/lib/generators/bandit/templates/dashboard/css/application.css +7 -0
- data/lib/generators/bandit/templates/dashboard/css/base.css +22 -0
- data/lib/generators/bandit/templates/dashboard/css/toupee/buttons.css +101 -0
- data/lib/generators/bandit/templates/dashboard/css/toupee/forms.css +89 -0
- data/lib/generators/bandit/templates/dashboard/css/toupee/modules.css +30 -0
- data/lib/generators/bandit/templates/dashboard/css/toupee/reset.css +42 -0
- data/lib/generators/bandit/templates/dashboard/css/toupee/structure.css +124 -0
- data/lib/generators/bandit/templates/dashboard/css/toupee/typography.css +103 -0
- data/lib/generators/bandit/templates/dashboard/helpers/bandit_helper.rb +19 -0
- data/lib/generators/bandit/templates/dashboard/js/bandit.js +34 -0
- data/lib/generators/bandit/templates/dashboard/js/highstock.js +215 -0
- data/lib/generators/bandit/templates/dashboard/js/jquery.min.js +154 -0
- data/lib/generators/bandit/templates/dashboard/view/index.html.erb +21 -0
- data/lib/generators/bandit/templates/dashboard/view/show.html.erb +24 -0
- data/players.rdoc +22 -0
- data/test/config.yml +7 -0
- data/test/helper.rb +18 -0
- data/test/memcache_storage_test.rb +17 -0
- data/test/memory_storage_test.rb +16 -0
- data/test/redis_storage_test.rb +17 -0
- data/test/storage_test_base.rb +54 -0
- 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,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
|
data/lib/bandit/version.rb
CHANGED
@@ -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
|