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.
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
@@ -1,3 +1,71 @@
1
- = bandit - A multi-armed bandit optmization framework for Rails
1
+ = bandit
2
2
 
3
- TODO: Write library.
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
+ }
@@ -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
@@ -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
- # Your code goes here...
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,10 @@
1
+ module Bandit
2
+ class UnknownStorageEngineError < RuntimeError
3
+ end
4
+
5
+ class UnknownPlayerEngineError < RuntimeError
6
+ end
7
+
8
+ class MissingConfigurationError < ArgumentError
9
+ end
10
+ 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,5 @@
1
+ class String
2
+ def numeric?
3
+ true if Integer(self) rescue false
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class Time
2
+ def to_date_hour
3
+ Bandit::DateHour.new Date.new(year, month, day), hour
4
+ end
5
+ 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