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
data/README.rdoc
CHANGED
@@ -1,3 +1,71 @@
|
|
1
|
-
= bandit
|
1
|
+
= bandit
|
2
2
|
|
3
|
-
|
3
|
+
Bandit is a multi-armed bandit optmization framework for Rails.
|
4
|
+
|
5
|
+
= Installation
|
6
|
+
First, add the following to your Gemfile in your Rails 3 app:
|
7
|
+
|
8
|
+
gem 'bandit'
|
9
|
+
|
10
|
+
Then, run the following:
|
11
|
+
|
12
|
+
bundle install
|
13
|
+
rails generate bandit:install
|
14
|
+
|
15
|
+
You can then edit the bandit.yml file in your config directory to set your storage and player parameters. Redis, memcache, and memory storage options are available. Memory storage should only be used for testing.
|
16
|
+
|
17
|
+
See the file players.rdoc for information about available players.
|
18
|
+
|
19
|
+
== Configuration
|
20
|
+
To set up an experiment, add it either somewhere in your code or in the bandit initializer. Creating an experiment is simple:
|
21
|
+
|
22
|
+
Bandit::Experiment.create(:click_test) { |exp|
|
23
|
+
exp.alternatives = [ 20, 30, 40 ]
|
24
|
+
exp.title = "Click Test"
|
25
|
+
exp.description = "A test of clicks on purchase page with varying link sizes."
|
26
|
+
}
|
27
|
+
|
28
|
+
|
29
|
+
== View
|
30
|
+
To get an alternative (per viewer, based on cookies):
|
31
|
+
|
32
|
+
<%= bandit_choose :click_test %>
|
33
|
+
|
34
|
+
For instance, in a link:
|
35
|
+
|
36
|
+
<%= link_to "new purchase", new_purchase_path, :style => "font-size: #{bandit_choose(:click_test)}px;" %>
|
37
|
+
|
38
|
+
You can force a particular alternative by adding a query parameter named "bandit_<expname>" and setting it's value to the alternative you want. For instance, given the above experiment in the configuration example:
|
39
|
+
|
40
|
+
http://<yourhost>/<path>?bandit_click_test=40
|
41
|
+
|
42
|
+
will then force the alternative to be "40".
|
43
|
+
|
44
|
+
== Controller
|
45
|
+
To track a converstion in your controller:
|
46
|
+
|
47
|
+
bandit_convert! :click_test
|
48
|
+
|
49
|
+
= Dashboard
|
50
|
+
|
51
|
+
rails generate bandit:dashboard
|
52
|
+
|
53
|
+
Then, add the following to your config/routes.rb file:
|
54
|
+
|
55
|
+
match 'bandit' => 'bandit#index'
|
56
|
+
|
57
|
+
To see a dashboard with relevant information, go to:
|
58
|
+
|
59
|
+
http://<yourhost>/bandit
|
60
|
+
|
61
|
+
= Tests
|
62
|
+
To run tests:
|
63
|
+
|
64
|
+
rake test
|
65
|
+
|
66
|
+
To produce fake data for the past week, first create an experiment definition. Then, run the following rake task:
|
67
|
+
|
68
|
+
rake bandit:populate_data[experiment_name]
|
69
|
+
|
70
|
+
= Reference
|
71
|
+
http://untyped.com/untyping/2011/02/11/stop-ab-testing-and-make-out-like-a-bandit
|
data/Rakefile
CHANGED
@@ -1 +1,33 @@
|
|
1
1
|
require 'bundler/gem_tasks'
|
2
|
+
require 'rdoc/task'
|
3
|
+
require 'rake/testtask'
|
4
|
+
|
5
|
+
desc "Create documentation"
|
6
|
+
Rake::RDocTask.new("doc") { |rdoc|
|
7
|
+
rdoc.title = "bandit - A multi-armed bandit optmization framework for Rails"
|
8
|
+
rdoc.rdoc_dir = 'docs'
|
9
|
+
rdoc.rdoc_files.include('README.rdoc')
|
10
|
+
rdoc.rdoc_files.include('players.rdoc')
|
11
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
12
|
+
}
|
13
|
+
|
14
|
+
desc "Run all unit tests with memory storage"
|
15
|
+
Rake::TestTask.new("test_memory") { |t|
|
16
|
+
t.libs << "lib"
|
17
|
+
t.test_files = FileList['test/memory_*.rb']
|
18
|
+
t.verbose = true
|
19
|
+
}
|
20
|
+
|
21
|
+
desc "Run all unit tests with memcache storage"
|
22
|
+
Rake::TestTask.new("test_memcache") { |t|
|
23
|
+
t.libs << "lib"
|
24
|
+
t.test_files = FileList['test/memcache_*.rb']
|
25
|
+
t.verbose = true
|
26
|
+
}
|
27
|
+
|
28
|
+
desc "Run all unit tests with redis storage"
|
29
|
+
Rake::TestTask.new("test_redis") { |t|
|
30
|
+
t.libs << "lib"
|
31
|
+
t.test_files = FileList['test/redis_*.rb']
|
32
|
+
t.verbose = true
|
33
|
+
}
|
data/bandit.gemspec
CHANGED
@@ -16,4 +16,6 @@ Gem::Specification.new do |s|
|
|
16
16
|
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
17
|
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
18
|
s.require_paths = ["lib"]
|
19
|
+
s.add_dependency("rails", ">= 3.0.5")
|
20
|
+
s.add_dependency("rdoc")
|
19
21
|
end
|
data/lib/bandit.rb
CHANGED
@@ -1,5 +1,56 @@
|
|
1
1
|
require "bandit/version"
|
2
|
+
require "bandit/exceptions"
|
3
|
+
require "bandit/config"
|
4
|
+
require "bandit/experiment"
|
5
|
+
require "bandit/date_hour"
|
6
|
+
require "bandit/memoizable"
|
7
|
+
|
8
|
+
require "bandit/players/base"
|
9
|
+
require "bandit/players/round_robin"
|
10
|
+
require "bandit/players/epsilon_greedy"
|
11
|
+
|
12
|
+
require "bandit/storage/base"
|
13
|
+
require "bandit/storage/memory"
|
14
|
+
require "bandit/storage/memcache"
|
15
|
+
require "bandit/storage/redis"
|
16
|
+
|
17
|
+
require "bandit/extensions/controller_concerns"
|
18
|
+
require "bandit/extensions/view_concerns"
|
19
|
+
require "bandit/extensions/time"
|
20
|
+
require "bandit/extensions/string"
|
2
21
|
|
3
22
|
module Bandit
|
4
|
-
|
23
|
+
def self.config
|
24
|
+
@config ||= Config.new
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.setup(&block)
|
28
|
+
yield config
|
29
|
+
config.check!
|
30
|
+
# intern keys in storage config
|
31
|
+
config.storage_config = config.storage_config.inject({}) { |n,o| n[o.first.intern] = o.last; n }
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.storage
|
35
|
+
@storage ||= BaseStorage.get_storage(Bandit.config.storage.intern, Bandit.config.storage_config)
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.player
|
39
|
+
@player ||= BasePlayer.get_player(Bandit.config.player.intern, Bandit.config.player_config)
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.get_experiment(name)
|
43
|
+
exp = Experiment.instances.select { |e| e.name == name }
|
44
|
+
exp.length > 0 ? exp.first : nil
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.experiments
|
48
|
+
Experiment.instances
|
49
|
+
end
|
5
50
|
end
|
51
|
+
|
52
|
+
require 'action_controller'
|
53
|
+
ActionController::Base.send :include, Bandit::ControllerConcerns
|
54
|
+
|
55
|
+
require 'action_view'
|
56
|
+
ActionView::Base.send :include, Bandit::ViewConcerns
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Bandit
|
2
|
+
|
3
|
+
class Config
|
4
|
+
def self.required_fields
|
5
|
+
[:storage, :player]
|
6
|
+
end
|
7
|
+
|
8
|
+
# storage should be name of storage engine
|
9
|
+
attr_accessor :storage
|
10
|
+
|
11
|
+
# storage_config should be hash of storage config values
|
12
|
+
attr_accessor :storage_config
|
13
|
+
|
14
|
+
# player should be name of player
|
15
|
+
attr_accessor :player
|
16
|
+
|
17
|
+
# player_config should be hash of player config values
|
18
|
+
attr_accessor :player_config
|
19
|
+
|
20
|
+
def check!
|
21
|
+
self.class.required_fields.each do |required_field|
|
22
|
+
unless send(required_field)
|
23
|
+
raise MissingConfigurationError, "#{required_field} must be set"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
@storage_config ||= {}
|
28
|
+
@player_config ||= {}
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module Bandit
|
4
|
+
|
5
|
+
class DateHour
|
6
|
+
attr_accessor :date, :hour
|
7
|
+
|
8
|
+
def initialize(date, hour)
|
9
|
+
@date = date
|
10
|
+
@hour = hour
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.now
|
14
|
+
DateHour.new Date.today, Time.now.hour
|
15
|
+
end
|
16
|
+
|
17
|
+
def upto(other)
|
18
|
+
n = DateHour.new(@date, @hour)
|
19
|
+
while n <= other
|
20
|
+
yield n
|
21
|
+
n += 1
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# given a block that is called w/ each DateHour for a day, inject over all DateHours
|
26
|
+
def self.date_inject(date, initial=0)
|
27
|
+
DateHour.new(date, 0).upto(DateHour.new(date, 23)) { |dh|
|
28
|
+
initial = yield initial, dh
|
29
|
+
}
|
30
|
+
initial
|
31
|
+
end
|
32
|
+
|
33
|
+
def +(hours)
|
34
|
+
(to_time + (hours * 3600)).to_date_hour
|
35
|
+
end
|
36
|
+
|
37
|
+
def ==(other)
|
38
|
+
@date == other.date and @hour == other.hour
|
39
|
+
end
|
40
|
+
|
41
|
+
def <(other)
|
42
|
+
@date < other.date or (@date == other.date and @hour < other.hour)
|
43
|
+
end
|
44
|
+
|
45
|
+
def >(other)
|
46
|
+
@date > other.date or (@date == other.date and @hour > other.hour)
|
47
|
+
end
|
48
|
+
|
49
|
+
def >=(other)
|
50
|
+
self > other or self == other
|
51
|
+
end
|
52
|
+
|
53
|
+
def <=(other)
|
54
|
+
self < other or self == other
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_time
|
58
|
+
Time.mktime year, month, day, hour, 0
|
59
|
+
end
|
60
|
+
|
61
|
+
def to_s
|
62
|
+
"#{@date.to_s} #{@hour}:00:00"
|
63
|
+
end
|
64
|
+
|
65
|
+
def to_i
|
66
|
+
to_time.to_i
|
67
|
+
end
|
68
|
+
|
69
|
+
def year
|
70
|
+
@date.year
|
71
|
+
end
|
72
|
+
|
73
|
+
def month
|
74
|
+
@date.month
|
75
|
+
end
|
76
|
+
|
77
|
+
def day
|
78
|
+
@date.day
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Bandit
|
2
|
+
class Experiment
|
3
|
+
attr_accessor :name, :title, :description, :alternatives
|
4
|
+
@@instances = []
|
5
|
+
|
6
|
+
def self.create(name)
|
7
|
+
e = Experiment.new(:name => name)
|
8
|
+
yield e
|
9
|
+
e.validate!
|
10
|
+
e
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(args=nil)
|
14
|
+
args.each { |k,v| send "#{k}=", v } unless args.nil?
|
15
|
+
@@instances << self
|
16
|
+
@storage = Bandit.storage
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.instances
|
20
|
+
@@instances
|
21
|
+
end
|
22
|
+
|
23
|
+
def choose(default = nil)
|
24
|
+
if not default.nil? and alternatives.include? default
|
25
|
+
alt = default
|
26
|
+
else
|
27
|
+
alt = Bandit.player.choose_alternative(self)
|
28
|
+
end
|
29
|
+
@storage.incr_participants(self, alt)
|
30
|
+
alt
|
31
|
+
end
|
32
|
+
|
33
|
+
def convert!(alt, count=1)
|
34
|
+
@storage.incr_conversions(self, alt, count)
|
35
|
+
end
|
36
|
+
|
37
|
+
def validate!
|
38
|
+
[:title, :alternatives].each { |field|
|
39
|
+
unless send(field)
|
40
|
+
raise MissingConfigurationError, "#{field} must be set in experiment #{name}"
|
41
|
+
end
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
def conversion_count(alt, date_hour=nil)
|
46
|
+
@storage.conversion_count(self, alt, date_hour)
|
47
|
+
end
|
48
|
+
|
49
|
+
def participant_count(alt, date_hour=nil)
|
50
|
+
@storage.participant_count(self, alt, date_hour)
|
51
|
+
end
|
52
|
+
|
53
|
+
def conversion_rate(alt)
|
54
|
+
pcount = participant_count(alt)
|
55
|
+
ccount = conversion_count(alt)
|
56
|
+
(pcount == 0 or ccount == 0) ? 0 : (ccount.to_f / pcount.to_f * 100.0)
|
57
|
+
end
|
58
|
+
|
59
|
+
def alternative_start(alt)
|
60
|
+
@storage.alternative_start_time(self, alt)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
module Bandit
|
4
|
+
module ControllerConcerns
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
|
9
|
+
end
|
10
|
+
|
11
|
+
module InstanceMethods
|
12
|
+
def bandit_convert!(exp, alt=nil, count=1)
|
13
|
+
cookiename = "bandit_#{exp}".intern
|
14
|
+
alt ||= cookies.signed[cookiename]
|
15
|
+
unless alt.nil?
|
16
|
+
Bandit.get_experiment(exp).convert!(alt, count)
|
17
|
+
# delete cookie so we don't double track
|
18
|
+
cookies.delete(cookiename)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
module Bandit
|
4
|
+
module ViewConcerns
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
|
9
|
+
end
|
10
|
+
|
11
|
+
module InstanceMethods
|
12
|
+
def bandit_choose(exp)
|
13
|
+
name = "bandit_#{exp}".intern
|
14
|
+
|
15
|
+
# choose url param with preference
|
16
|
+
value = params[name].nil? ? cookies.signed[name] : params[name]
|
17
|
+
|
18
|
+
# choose with default, and set cookie
|
19
|
+
cookies.signed[name] = Bandit.get_experiment(exp).choose(value)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Bandit
|
2
|
+
module Memoizable
|
3
|
+
# remember a block for some time (60 seconds by default)
|
4
|
+
def memoize(key, time=60)
|
5
|
+
@memoized ||= {}
|
6
|
+
@memoized_times ||= {}
|
7
|
+
now = Time.now.to_i
|
8
|
+
if not @memoized.has_key?(key) or now > @memoized_times[key]
|
9
|
+
@memoized[key] = yield
|
10
|
+
@memoized_times[key] = now + time
|
11
|
+
end
|
12
|
+
@memoized[key]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|