bandido 0.0.6
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +5 -0
- data/Gemfile +4 -0
- data/LICENSE +674 -0
- data/README.rdoc +77 -0
- data/Rakefile +34 -0
- data/bandit.gemspec +22 -0
- 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/array.rb +3 -0
- data/lib/bandit/extensions/controller_concerns.rb +33 -0
- data/lib/bandit/extensions/string.rb +5 -0
- data/lib/bandit/extensions/time.rb +5 -0
- data/lib/bandit/extensions/view_concerns.rb +40 -0
- data/lib/bandit/memoizable.rb +32 -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 +134 -0
- data/lib/bandit/storage/memcache.rb +44 -0
- data/lib/bandit/storage/memory.rb +31 -0
- data/lib/bandit/storage/redis.rb +47 -0
- data/lib/bandit/version.rb +3 -0
- data/lib/bandit.rb +69 -0
- 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 +30 -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 +28 -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 +23 -0
- data/lib/generators/bandit/templates/dashboard/js/bandit.js +51 -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/_experiment_table.html.erb +19 -0
- data/lib/generators/bandit/templates/dashboard/view/index.html.erb +14 -0
- data/lib/generators/bandit/templates/dashboard/view/show.html.erb +19 -0
- data/players.rdoc +21 -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
- data/whybandit.rdoc +31 -0
- metadata +166 -0
data/README.rdoc
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
= bandit
|
2
|
+
|
3
|
+
Bandit is a multi-armed bandit optimization framework for Rails. It provides an alternative to A/B testing in Rails. For background and a comparison with A/B testing, see the whybandit.rdoc document or the blog post here[http://findingscience.com/rails/vanity/statistics/testing/2011/11/12/bandit:-a-b-testing-alternative-for-rails.html].
|
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_<experiment name>" 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 conversion 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
|
+
resources :bandit
|
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_memory
|
65
|
+
rake test_memcache
|
66
|
+
rake test_redis
|
67
|
+
|
68
|
+
To produce fake data for the past week, first create an experiment definition. Then, run the following rake task:
|
69
|
+
|
70
|
+
rake bandit:populate_data[<experiment_name>]
|
71
|
+
|
72
|
+
For instance, to generate a week's worth of fake data for the click_test above:
|
73
|
+
|
74
|
+
rake bandit:populate_data[click_test]
|
75
|
+
|
76
|
+
= Fault Tolerance
|
77
|
+
If the storage mechanism fails, then Bandit will automatically switch to in memory storage. It will then check every 5 minutes after that to see if the original storage mechanism is back up. If you have distributed front ends then each front end will continue to optimize (based on the in memory storage), but this optimization will be inefficient compared to shared storage among all front ends.
|
data/Rakefile
ADDED
@@ -0,0 +1,34 @@
|
|
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('whybandit.rdoc')
|
12
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
13
|
+
}
|
14
|
+
|
15
|
+
desc "Run all unit tests with memory storage"
|
16
|
+
Rake::TestTask.new("test_memory") { |t|
|
17
|
+
t.libs << "lib"
|
18
|
+
t.test_files = FileList['test/memory_*.rb']
|
19
|
+
t.verbose = true
|
20
|
+
}
|
21
|
+
|
22
|
+
desc "Run all unit tests with memcache storage"
|
23
|
+
Rake::TestTask.new("test_memcache") { |t|
|
24
|
+
t.libs << "lib"
|
25
|
+
t.test_files = FileList['test/memcache_*.rb']
|
26
|
+
t.verbose = true
|
27
|
+
}
|
28
|
+
|
29
|
+
desc "Run all unit tests with redis storage"
|
30
|
+
Rake::TestTask.new("test_redis") { |t|
|
31
|
+
t.libs << "lib"
|
32
|
+
t.test_files = FileList['test/redis_*.rb']
|
33
|
+
t.verbose = true
|
34
|
+
}
|
data/bandit.gemspec
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
$:.push File.expand_path("../lib", __FILE__)
|
2
|
+
require "bandit/version"
|
3
|
+
|
4
|
+
Gem::Specification.new do |s|
|
5
|
+
s.name = "bandit"
|
6
|
+
s.version = Bandit::VERSION
|
7
|
+
s.authors = ["Brian Muller"]
|
8
|
+
s.email = ["brian.muller@livingsocial.com"]
|
9
|
+
s.homepage = "https://github.com/bmuller/bandit"
|
10
|
+
s.summary = "Multi-armed bandit testing in rails"
|
11
|
+
s.description = "Bandit provides a way to do multi-armed bandit optimization of alternatives in a rails website"
|
12
|
+
|
13
|
+
s.rubyforge_project = "bandit"
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
s.add_dependency("rails", ">= 3.0.5")
|
20
|
+
s.add_dependency("redis", "= 2.2.2")
|
21
|
+
s.add_development_dependency('rdoc')
|
22
|
+
end
|
@@ -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 default && alternatives.include?(default)
|
25
|
+
alt = default
|
26
|
+
else
|
27
|
+
alt = Bandit.player.choose_alternative(self)
|
28
|
+
@storage.incr_participants(self, alt)
|
29
|
+
end
|
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,33 @@
|
|
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
|
+
cookies.delete(cookiename)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def bandit_final_convert!(exp, alt=nil, count=1)
|
22
|
+
cookiename = "bandit_#{exp}".intern
|
23
|
+
cookiename_converted = "bandit_#{exp}_converted".intern
|
24
|
+
alt ||= cookies.signed[cookiename]
|
25
|
+
unless alt.nil? or cookies.signed[cookiename_converted]
|
26
|
+
cookies.permanent.signed[cookiename_converted] = "true"
|
27
|
+
Bandit.get_experiment(exp).convert!(alt, count)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,40 @@
|
|
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
|
+
|
22
|
+
def bandit_sticky_choose(exp)
|
23
|
+
name = "bandit_#{exp}".intern
|
24
|
+
|
25
|
+
# choose url param with preference
|
26
|
+
value = params[name].nil? ? cookies.signed[name] : params[name]
|
27
|
+
|
28
|
+
# sticky choice may outlast a given alternative
|
29
|
+
alternative = if Bandit.get_experiment(exp).alternatives.include?(value)
|
30
|
+
value
|
31
|
+
else
|
32
|
+
Bandit.get_experiment(exp).choose(value)
|
33
|
+
end
|
34
|
+
|
35
|
+
# re-set cookie
|
36
|
+
cookies.permanent.signed[name] = alternative
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,32 @@
|
|
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
|
+
|
15
|
+
module ClassMethods
|
16
|
+
def memoize_method(method, time=60)
|
17
|
+
original_method = "unmemoized_#{method}_#{Time.now.to_i}"
|
18
|
+
alias_method original_method, method
|
19
|
+
module_eval(<<-EVAL, __FILE__, __LINE__)
|
20
|
+
def #{method}(*args, &block)
|
21
|
+
memoize(:#{original_method}, #{time}) { send(:#{original_method}, *args, &block) }
|
22
|
+
end
|
23
|
+
EVAL
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.included(base)
|
28
|
+
base.extend(ClassMethods)
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
@@ -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.sample
|
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,134 @@
|
|
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
|
+
|
124
|
+
def with_failure_grace(fail_default=0)
|
125
|
+
begin
|
126
|
+
yield
|
127
|
+
rescue
|
128
|
+
Bandit.storage_failed!
|
129
|
+
Rails.logger.error "Storage method #{self.class} failed. Falling back to memory storage."
|
130
|
+
fail_default
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|