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