vanity 1.8.2 → 1.8.3.beta

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 CHANGED
@@ -1,10 +1,19 @@
1
- .bundle
2
- .yardoc
3
- *.gem
4
- html
5
- test/myapp/log/
6
1
  vendor
7
2
  log/*
3
+ test/myapp/log/
8
4
  test/dummy/db/*.sqlite3
9
5
  test/dummy/log/*.log
10
6
  test/dummy/tmp/
7
+
8
+ # Documentation cache and generated files
9
+ .yardoc
10
+ html
11
+
12
+ # Generated files
13
+ *.gem
14
+
15
+ # Environment normalisation
16
+ .bundle
17
+ .rvmrc
18
+ .ruby-version
19
+ .ruby-gemset
data/Gemfile CHANGED
@@ -14,6 +14,11 @@ gem "mongo"
14
14
  gem "mysql"
15
15
  gem "pg"
16
16
 
17
+ # Math libraries
18
+ gem "backports", :platforms => :mri_18
19
+ gem "integration"
20
+ gem "rubystats"
21
+
17
22
  # APIs
18
23
  gem "garb"
19
24
 
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- vanity (1.8.2)
5
- redis (~> 2.0)
6
- redis-namespace (~> 1.0.0)
4
+ vanity (1.8.3.beta)
5
+ redis (>= 2.1)
6
+ redis-namespace (>= 1.1.0)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
@@ -26,6 +26,7 @@ GEM
26
26
  appraisal (0.5.2)
27
27
  bundler
28
28
  rake
29
+ backports (3.3.5)
29
30
  bson (1.6.0)
30
31
  bson_ext (1.6.0)
31
32
  bson (= 1.6.0)
@@ -38,6 +39,7 @@ GEM
38
39
  garb (0.9.1)
39
40
  activesupport (>= 2.2.0)
40
41
  crack (>= 0.1.6)
42
+ integration (0.1.0)
41
43
  jekyll (0.11.2)
42
44
  albino (~> 1.3)
43
45
  classifier (~> 1.3)
@@ -70,9 +72,10 @@ GEM
70
72
  activesupport (= 2.3.14)
71
73
  rake (>= 0.8.3)
72
74
  rake (10.1.0)
73
- redis (2.2.2)
74
- redis-namespace (1.0.4)
75
- redis (< 3.0.0)
75
+ redis (3.0.6)
76
+ redis-namespace (1.3.2)
77
+ redis (~> 3.0.4)
78
+ rubystats (0.2.3)
76
79
  shoulda (3.0.1)
77
80
  shoulda-context (~> 1.0.0)
78
81
  shoulda-matchers (~> 1.0.0)
@@ -92,8 +95,10 @@ DEPENDENCIES
92
95
  RedCloth
93
96
  SystemTimer (= 1.2.3)
94
97
  appraisal
98
+ backports
95
99
  bson_ext
96
100
  garb
101
+ integration
97
102
  jekyll
98
103
  mocha
99
104
  mongo
@@ -103,6 +108,7 @@ DEPENDENCIES
103
108
  rack
104
109
  rails (~> 2.3.8)
105
110
  rake
111
+ rubystats
106
112
  shoulda
107
113
  timecop
108
114
  vanity!
@@ -7,6 +7,9 @@ gem "bson_ext"
7
7
  gem "mongo"
8
8
  gem "mysql"
9
9
  gem "pg"
10
+ gem "backports", :platforms=>:mri_18
11
+ gem "integration"
12
+ gem "rubystats"
10
13
  gem "garb"
11
14
  gem "SystemTimer", "1.2.3", :platforms=>:mri_18
12
15
  gem "appraisal"
@@ -8,8 +8,8 @@ PATH
8
8
  remote: /Users/phill/Development/ruby/vanity
9
9
  specs:
10
10
  vanity (1.8.2)
11
- redis (~> 2.0)
12
- redis-namespace (~> 1.0.0)
11
+ redis (>= 2.1)
12
+ redis-namespace (>= 1.1.0)
13
13
 
14
14
  GEM
15
15
  remote: https://rubygems.org/
@@ -47,6 +47,7 @@ GEM
47
47
  bundler
48
48
  rake
49
49
  arel (2.0.10)
50
+ backports (3.3.5)
50
51
  bson (1.6.0)
51
52
  bson_ext (1.6.0)
52
53
  bson (= 1.6.0)
@@ -59,6 +60,7 @@ GEM
59
60
  activesupport (>= 2.2.0)
60
61
  crack (>= 0.1.6)
61
62
  i18n (0.5.0)
63
+ integration (0.1.0)
62
64
  json (1.6.5)
63
65
  mail (2.2.19)
64
66
  activesupport (>= 2.3.6)
@@ -101,9 +103,10 @@ GEM
101
103
  rake (0.9.2.2)
102
104
  rdoc (3.12)
103
105
  json (~> 1.4)
104
- redis (2.2.2)
105
- redis-namespace (1.0.4)
106
- redis (< 3.0.0)
106
+ redis (3.0.5)
107
+ redis-namespace (1.3.1)
108
+ redis (~> 3.0.0)
109
+ rubystats (0.2.3)
107
110
  shoulda (3.0.1)
108
111
  shoulda-context (~> 1.0.0)
109
112
  shoulda-matchers (~> 1.0.0)
@@ -125,9 +128,11 @@ PLATFORMS
125
128
  DEPENDENCIES
126
129
  SystemTimer (= 1.2.3)
127
130
  appraisal
131
+ backports
128
132
  bson_ext
129
133
  fastthread!
130
134
  garb
135
+ integration
131
136
  mocha
132
137
  mongo
133
138
  mysql
@@ -135,6 +140,7 @@ DEPENDENCIES
135
140
  pg
136
141
  rack
137
142
  rails (= 3.0.11)
143
+ rubystats
138
144
  shoulda
139
145
  timecop
140
146
  vanity!
@@ -7,6 +7,9 @@ gem "bson_ext"
7
7
  gem "mongo"
8
8
  gem "mysql"
9
9
  gem "pg"
10
+ gem "backports", :platforms=>:mri_18
11
+ gem "integration"
12
+ gem "rubystats"
10
13
  gem "garb"
11
14
  gem "SystemTimer", "1.2.3", :platforms=>:mri_18
12
15
  gem "appraisal"
@@ -8,8 +8,8 @@ PATH
8
8
  remote: /Users/phill/Development/ruby/vanity
9
9
  specs:
10
10
  vanity (1.8.2)
11
- redis (~> 2.0)
12
- redis-namespace (~> 1.0.0)
11
+ redis (>= 2.1)
12
+ redis-namespace (>= 1.1.0)
13
13
 
14
14
  GEM
15
15
  remote: https://rubygems.org/
@@ -48,6 +48,7 @@ GEM
48
48
  bundler
49
49
  rake
50
50
  arel (2.2.3)
51
+ backports (3.3.5)
51
52
  bson (1.6.0)
52
53
  bson_ext (1.6.0)
53
54
  bson (= 1.6.0)
@@ -60,6 +61,7 @@ GEM
60
61
  crack (>= 0.1.6)
61
62
  hike (1.2.1)
62
63
  i18n (0.6.0)
64
+ integration (0.1.0)
63
65
  json (1.6.5)
64
66
  mail (2.3.0)
65
67
  i18n (>= 0.4.0)
@@ -107,16 +109,16 @@ GEM
107
109
  rake (0.9.2.2)
108
110
  rdoc (3.12)
109
111
  json (~> 1.4)
110
- redis (2.2.2)
111
- redis-namespace (1.0.4)
112
- redis (< 3.0.0)
112
+ redis (3.0.5)
113
+ redis-namespace (1.3.1)
114
+ redis (~> 3.0.0)
115
+ rubystats (0.2.3)
113
116
  shoulda (3.0.1)
114
117
  shoulda-context (~> 1.0.0)
115
118
  shoulda-matchers (~> 1.0.0)
116
119
  shoulda-context (1.0.0)
117
120
  shoulda-matchers (1.0.0)
118
121
  sprockets (2.0.3)
119
- hike (~> 1.2)
120
122
  rack (~> 1.0)
121
123
  tilt (~> 1.1, != 1.3.0)
122
124
  thor (0.14.6)
@@ -136,9 +138,11 @@ PLATFORMS
136
138
  DEPENDENCIES
137
139
  SystemTimer (= 1.2.3)
138
140
  appraisal
141
+ backports
139
142
  bson_ext
140
143
  fastthread!
141
144
  garb
145
+ integration
142
146
  mocha
143
147
  mongo
144
148
  mysql
@@ -146,6 +150,7 @@ DEPENDENCIES
146
150
  pg
147
151
  rack
148
152
  rails (= 3.1.3)
153
+ rubystats
149
154
  shoulda
150
155
  timecop
151
156
  vanity!
@@ -7,6 +7,9 @@ gem "bson_ext"
7
7
  gem "mongo"
8
8
  gem "mysql"
9
9
  gem "pg"
10
+ gem "backports", :platforms=>:mri_18
11
+ gem "integration"
12
+ gem "rubystats"
10
13
  gem "garb"
11
14
  gem "SystemTimer", "1.2.3", :platforms=>:mri_18
12
15
  gem "appraisal"
@@ -8,8 +8,8 @@ PATH
8
8
  remote: /Users/phill/Development/ruby/vanity
9
9
  specs:
10
10
  vanity (1.8.2)
11
- redis (~> 2.0)
12
- redis-namespace (~> 1.0.0)
11
+ redis (>= 2.1)
12
+ redis-namespace (>= 1.1.0)
13
13
 
14
14
  GEM
15
15
  remote: https://rubygems.org/
@@ -47,6 +47,7 @@ GEM
47
47
  bundler
48
48
  rake
49
49
  arel (3.0.2)
50
+ backports (3.3.5)
50
51
  bson (1.6.0)
51
52
  bson_ext (1.6.0)
52
53
  bson (= 1.6.0)
@@ -59,6 +60,7 @@ GEM
59
60
  crack (>= 0.1.6)
60
61
  hike (1.2.1)
61
62
  i18n (0.6.0)
63
+ integration (0.1.0)
62
64
  journey (1.0.3)
63
65
  json (1.6.5)
64
66
  mail (2.4.1)
@@ -105,9 +107,10 @@ GEM
105
107
  rake (0.9.2.2)
106
108
  rdoc (3.12)
107
109
  json (~> 1.4)
108
- redis (2.2.2)
109
- redis-namespace (1.0.4)
110
- redis (< 3.0.0)
110
+ redis (3.0.5)
111
+ redis-namespace (1.3.1)
112
+ redis (~> 3.0.0)
113
+ rubystats (0.2.3)
111
114
  shoulda (3.0.1)
112
115
  shoulda-context (~> 1.0.0)
113
116
  shoulda-matchers (~> 1.0.0)
@@ -134,9 +137,11 @@ PLATFORMS
134
137
  DEPENDENCIES
135
138
  SystemTimer (= 1.2.3)
136
139
  appraisal
140
+ backports
137
141
  bson_ext
138
142
  fastthread!
139
143
  garb
144
+ integration
140
145
  mocha
141
146
  mongo
142
147
  mysql
@@ -144,6 +149,7 @@ DEPENDENCIES
144
149
  pg
145
150
  rack
146
151
  rails (= 3.2.1)
152
+ rubystats
147
153
  shoulda
148
154
  timecop
149
155
  vanity!
@@ -38,15 +38,15 @@ module Vanity
38
38
  # Empty the database. This is used during tests.
39
39
  def flushdb
40
40
  end
41
-
41
+
42
42
 
43
43
  # -- Metrics --
44
-
44
+
45
45
  # Return when metric was last updated.
46
46
  def get_metric_last_update_at(metric)
47
47
  fail "Not implemented"
48
48
  end
49
-
49
+
50
50
  # Track metric data.
51
51
  def metric_track(metric, timestamp, identity, values)
52
52
  fail "Not implemented"
@@ -66,7 +66,7 @@ module Vanity
66
66
 
67
67
  # -- Experiments --
68
68
 
69
- # Store when experiment was created (do not write over existing value).
69
+ # Store when experiment was created (do not write over existing value).
70
70
  def set_experiment_created_at(experiment, time)
71
71
  fail "Not implemented"
72
72
  end
@@ -75,8 +75,8 @@ module Vanity
75
75
  def get_experiment_created_at(experiment)
76
76
  fail "Not implemented"
77
77
  end
78
-
79
- # Returns true if experiment completed.
78
+
79
+ # Returns true if experiment completed.
80
80
  def is_experiment_completed?(experiment)
81
81
  fail "Not implemented"
82
82
  end
@@ -86,7 +86,7 @@ module Vanity
86
86
  # :conversions.
87
87
  def ab_counts(experiment, alternative)
88
88
  fail "Not implemented"
89
- end
89
+ end
90
90
 
91
91
  # Pick particular alternative (by index) to show to this particular
92
92
  # participant (by identity).
@@ -112,7 +112,12 @@ module Vanity
112
112
 
113
113
  # Determines if a participant already has seen this alternative in this experiment.
114
114
  def ab_seen(experiment, identity, assignment)
115
- fail "Not implemented"
115
+ fail "Not implemented"
116
+ end
117
+
118
+ # Determines what alternative a participant has already been given, if any
119
+ def ab_assigned(experiment, identity)
120
+ fail "Not implemented"
116
121
  end
117
122
 
118
123
  # Records a conversion in this experiment for the given alternative.
@@ -222,6 +222,12 @@ module Vanity
222
222
  participant && participant.seen == alternative.id
223
223
  end
224
224
 
225
+ # Returns the participant's seen alternative in this experiment, if it exists
226
+ def ab_assigned(experiment, identity)
227
+ participant = VanityParticipant.retrieve(experiment, identity, false)
228
+ participant && participant.seen
229
+ end
230
+
225
231
  # Records a conversion in this experiment for the given alternative.
226
232
  # Associates a value with the conversion (default to 1). If implicit is
227
233
  # true, add participant if not already recorded for this experiment. If
@@ -156,6 +156,12 @@ module Vanity
156
156
  participant && participant["seen"].first == alternative.id
157
157
  end
158
158
 
159
+ # Returns the participant's seen alternative in this experiment, if it exists
160
+ def ab_assigned(experiment, identity)
161
+ participant = @participants.find_one({ :experiment=>experiment, :identity=>identity }, { :fields=>[:seen] })
162
+ participant && participant["seen"].first
163
+ end
164
+
159
165
  def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
160
166
  if implicit
161
167
  @participants.update({ :experiment=>experiment, :identity=>identity }, { "$push"=>{ :seen=>alternative } }, :upsert=>true)
@@ -138,6 +138,16 @@ module Vanity
138
138
  end
139
139
  end
140
140
 
141
+ # Returns the participant's seen alternative in this experiment, if it exists
142
+ def ab_assigned(experiment, identity)
143
+ Vanity.playground.experiments[experiment].alternatives.each do |alternative|
144
+ if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
145
+ return alternative.id
146
+ end
147
+ end
148
+ return nil
149
+ end
150
+
141
151
  def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
142
152
  if implicit
143
153
  @experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
@@ -19,8 +19,7 @@ class Date
19
19
  unless method_defined?(:to_time)
20
20
  # Backported from Ruby 1.9.
21
21
  def to_time
22
- Time.local(year, mon, mday)
22
+ Time.local(year, mon, mday)
23
23
  end
24
24
  end
25
25
  end
26
-
@@ -1,116 +1,32 @@
1
1
  require "digest/md5"
2
+ require "vanity/experiment/alternative"
3
+ require "vanity/experiment/bayesian_bandit_score"
2
4
 
3
5
  module Vanity
4
6
  module Experiment
7
+ # The meat.
8
+ class AbTest < Base
9
+ class << self
10
+ # Convert z-score to probability.
11
+ def probability(score)
12
+ score = score.abs
13
+ probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
14
+ probability ? probability.last : 0
15
+ end
5
16
 
6
- # One of several alternatives in an A/B test (see AbTest#alternatives).
7
- class Alternative
8
-
9
- def initialize(experiment, id, value) #, participants, converted, conversions)
10
- @experiment = experiment
11
- @id = id
12
- @name = "option #{(@id + 65).chr}"
13
- @value = value
14
- end
15
-
16
- # Alternative id, only unique for this experiment.
17
- attr_reader :id
18
-
19
- # Alternative name (option A, option B, etc).
20
- attr_reader :name
21
-
22
- # Alternative value.
23
- attr_reader :value
24
-
25
- # Experiment this alternative belongs to.
26
- attr_reader :experiment
27
-
28
- # Number of participants who viewed this alternative.
29
- def participants
30
- load_counts unless @participants
31
- @participants
32
- end
33
-
34
- # Number of participants who converted on this alternative (a participant is counted only once).
35
- def converted
36
- load_counts unless @converted
37
- @converted
38
- end
39
-
40
- # Number of conversions for this alternative (same participant may be counted more than once).
41
- def conversions
42
- load_counts unless @conversions
43
- @conversions
17
+ def friendly_name
18
+ "A/B Test"
19
+ end
44
20
  end
45
21
 
46
- # Z-score for this alternative, related to 2nd-best performing alternative. Populated by AbTest#score.
47
- attr_accessor :z_score
48
-
49
- # Probability derived from z-score. Populated by AbTest#score.
50
- attr_accessor :probability
51
-
52
- # Difference from least performing alternative. Populated by AbTest#score.
53
- attr_accessor :difference
54
-
55
- # Conversion rate calculated as converted/participants
56
- def conversion_rate
57
- @conversion_rate ||= (participants > 0 ? converted.to_f/participants.to_f : 0.0)
58
- end
59
-
60
- # The measure we use to order (sort) alternatives and decide which one is better (by calculating z-score).
61
- # Defaults to conversion rate.
62
- def measure
63
- conversion_rate
64
- end
65
-
66
- def <=>(other)
67
- measure <=> other.measure
68
- end
69
-
70
- def ==(other)
71
- other && id == other.id && experiment == other.experiment
72
- end
73
-
74
- def to_s
75
- name
76
- end
77
-
78
- def inspect
79
- "#{name}: #{value} #{converted}/#{participants}"
80
- end
81
-
82
- def load_counts
83
- if @experiment.playground.collecting?
84
- @participants, @converted, @conversions = @experiment.playground.connection.ab_counts(@experiment.id, id).values_at(:participants, :converted, :conversions)
85
- else
86
- @participants = @converted = @conversions = 0
87
- end
88
- end
89
- end
90
-
91
-
92
- # The meat.
93
- class AbTest < Base
94
- class << self
95
-
96
- # Convert z-score to probability.
97
- def probability(score)
98
- score = score.abs
99
- probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
100
- probability ? probability.last : 0
101
- end
102
-
103
- def friendly_name
104
- "A/B Test"
105
- end
106
-
107
- end
22
+ DEFAULT_SCORE_METHOD = :z_score
108
23
 
109
24
  def initialize(*args)
110
25
  super
26
+ @score_method = DEFAULT_SCORE_METHOD
27
+ @use_probabilities = nil
111
28
  end
112
29
 
113
-
114
30
  # -- Metric --
115
31
 
116
32
  # Tells A/B test which metric we're measuring, or returns metric in use.
@@ -168,6 +84,23 @@ module Vanity
168
84
  alternatives.find { |alt| alt.value == value }
169
85
  end
170
86
 
87
+ # What method to use for calculating score. Default is :ab_test, but can
88
+ # also be set to :bandit_score to calculate probability of each
89
+ # alternative being the best.
90
+ #
91
+ # @example Define A/B test which uses bayes_bandit_score in reporting
92
+ # ab_test "noodle_test" do
93
+ # alternatives "spaghetti", "linguine"
94
+ # metrics :signup
95
+ # score_method :bayes_bandit_score
96
+ # end
97
+ def score_method(method=nil)
98
+ if method
99
+ @score_method = method
100
+ end
101
+ @score_method
102
+ end
103
+
171
104
  # Defines an A/B test with two alternatives: false and true. This is the
172
105
  # default pair of alternatives, so just syntactic sugar for those who love
173
106
  # being explicit.
@@ -197,20 +130,28 @@ module Vanity
197
130
  if @playground.collecting?
198
131
  if active?
199
132
  identity = identity()
200
- index = connection.ab_showing(@id, identity)
201
- unless index
202
- index = alternative_for(identity)
203
- if !@playground.using_js?
204
- # if we have an on_assignment block, call it on new assignments
205
- if @on_assignment_block
206
- assignment = alternatives[index.to_i]
207
- if !connection.ab_seen @id, identity, assignment
208
- @on_assignment_block.call(Vanity.context, identity, assignment, self)
209
- end
210
- end
211
- connection.ab_add_participant @id, index, identity
212
- check_completion!
213
- end
133
+ index = connection.ab_showing(@id, identity)
134
+ unless index
135
+ index = alternative_for(identity)
136
+ if !@playground.using_js?
137
+ # if we have an on_assignment block, call it on new assignments
138
+ if @on_assignment_block
139
+ assignment = alternatives[index.to_i]
140
+ if !connection.ab_seen @id, identity, assignment
141
+ @on_assignment_block.call(Vanity.context, identity, assignment, self)
142
+ end
143
+ end
144
+ # if we are rebalancing probabilities, keep track of how long it has been since we last rebalanced
145
+ if @rebalance_frequency
146
+ @assignments_since_rebalancing += 1
147
+ if @assignments_since_rebalancing >= @rebalance_frequency
148
+ @assignments_since_rebalancing = 0
149
+ rebalance!
150
+ end
151
+ end
152
+ connection.ab_add_participant @id, index, identity
153
+ check_completion!
154
+ end
214
155
  end
215
156
  else
216
157
  index = connection.ab_get_outcome(@id) || alternative_for(identity)
@@ -288,12 +229,20 @@ module Vanity
288
229
 
289
230
  # -- Reporting --
290
231
 
232
+ def calculate_score
233
+ if respond_to?(score_method)
234
+ self.send(score_method)
235
+ else
236
+ score
237
+ end
238
+ end
239
+
291
240
  # Scores alternatives based on the current tracking data. This method
292
241
  # returns a structure with the following attributes:
293
242
  # [:alts] Ordered list of alternatives, populated with scoring info.
294
243
  # [:base] Second best performing alternative.
295
244
  # [:least] Least performing alternative (but more than zero conversion).
296
- # [:choice] Choice alterntive, either the outcome or best alternative.
245
+ # [:choice] Choice alternative, either the outcome or best alternative.
297
246
  #
298
247
  # Alternatives returned by this method are populated with the following
299
248
  # attributes:
@@ -329,10 +278,48 @@ module Vanity
329
278
  # choice alternative can only pick best if we have high probability (>90%).
330
279
  best = sorted.last if sorted.last.measure > 0.0
331
280
  choice = outcome ? alts[outcome.id] : (best && best.probability >= probability ? best : nil)
332
- Struct.new(:alts, :best, :base, :least, :choice).new(alts, best, base, least, choice)
281
+ Struct.new(:alts, :best, :base, :least, :choice, :method).new(alts, best, base, least, choice, :score)
333
282
  end
334
283
 
335
- # Use the result of #score to derive a conclusion. Returns an
284
+ # Scores alternatives based on the current tracking data, using Bayesian
285
+ # estimates of the best binomial bandit. Based on the R bandit package,
286
+ # http://cran.r-project.org/web/packages/bandit, which is based on
287
+ # Steven L. Scott, A modern Bayesian look at the multi-armed bandit,
288
+ # Appl. Stochastic Models Bus. Ind. 2010; 26:639-658.
289
+ # (http://www.economics.uci.edu/~ivan/asmb.874.pdf)
290
+ #
291
+ # This method returns a structure with the following attributes:
292
+ # [:alts] Ordered list of alternatives, populated with scoring info.
293
+ # [:base] Second best performing alternative.
294
+ # [:least] Least performing alternative (but more than zero conversion).
295
+ # [:choice] Choice alternative, either the outcome or best alternative.
296
+ #
297
+ # Alternatives returned by this method are populated with the following
298
+ # attributes:
299
+ # [:probability] Probability (probability this is the best alternative).
300
+ # [:difference] Difference from the least performant altenative.
301
+ #
302
+ # The choice alternative is set only if its probability is higher or
303
+ # equal to the specified probability (default is 90%).
304
+ def bayes_bandit_score(probability = 90)
305
+ begin
306
+ require "backports/1.9.1/kernel/define_singleton_method" if RUBY_VERSION < "1.9"
307
+ require "integration"
308
+ require "rubystats"
309
+ rescue LoadError
310
+ fail "to use bayes_bandit_score, install integration and rubystats gems"
311
+ end
312
+
313
+ begin
314
+ require "gsl"
315
+ rescue LoadError
316
+ warn "for better integration performance, install gsl gem"
317
+ end
318
+
319
+ BayesianBanditScore.new(alternatives, outcome).calculate!
320
+ end
321
+
322
+ # Use the result of #score or #bayes_bandit_score to derive a conclusion. Returns an
336
323
  # array of claims.
337
324
  def conclusion(score = score)
338
325
  claims = []
@@ -351,16 +338,24 @@ module Vanity
351
338
  # we want a result that's clearly better than 2nd best.
352
339
  best, second = sorted[0], sorted[1]
353
340
  if best.measure > second.measure
354
- diff = ((best.measure - second.measure) / second.measure * 100).round
355
- better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
356
- claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
357
- if best.probability >= 90
358
- claims << "With %d%% probability this result is statistically significant." % score.best.probability
359
- else
360
- claims << "This result is not statistically significant, suggest you continue this experiment."
361
- end
362
- sorted.delete best
363
- end
341
+ diff = ((best.measure - second.measure) / second.measure * 100).round
342
+ better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
343
+ claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
344
+ if score.method == :bayes_bandit_score
345
+ if best.probability >= 90
346
+ claims << "With %d%% probability this result is the best." % score.best.probability
347
+ else
348
+ claims << "This result does not have strong confidence behind it, suggest you continue this experiment."
349
+ end
350
+ else
351
+ if best.probability >= 90
352
+ claims << "With %d%% probability this result is statistically significant." % score.best.probability
353
+ else
354
+ claims << "This result is not statistically significant, suggest you continue this experiment."
355
+ end
356
+ end
357
+ sorted.delete best
358
+ end
364
359
  sorted.each do |alt|
365
360
  if alt.measure > 0.0
366
361
  claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.measure * 100]
@@ -375,6 +370,43 @@ module Vanity
375
370
  claims
376
371
  end
377
372
 
373
+ # -- Unequal probability assignments --
374
+
375
+ def set_alternative_probabilities(alternative_probabilities)
376
+ # create @use_probabilities as a function to go from [0,1] to outcome
377
+ cumulative_probability = 0.0
378
+ new_probabilities = alternative_probabilities.map {|am| [am, (cumulative_probability += am.probability)/100.0]}
379
+ @use_probabilities = new_probabilities
380
+ end
381
+
382
+ # -- Experiment rebalancing --
383
+
384
+ # Experiment rebalancing allows the app to automatically adjust the probabilities for each alternative; when one is performing better, it will increase its probability
385
+ # according to Bayesian one-armed bandit theory, in order to (eventually) maximize your overall conversions.
386
+
387
+ # Sets or returns how often (as a function of number of people assigned) to rebalance. For example:
388
+ # ab_test "Simple" do
389
+ # rebalance_frequency 100
390
+ # end
391
+ #
392
+ # puts "The experiment will automatically rebalance after every " + experiment(:simple).description + " users are assigned."
393
+ def rebalance_frequency(rf = nil)
394
+ if rf
395
+ @assignments_since_rebalancing = 0
396
+ @rebalance_frequency = rf
397
+ rebalance!
398
+ end
399
+ @rebalance_frequency
400
+ end
401
+
402
+ # Force experiment to rebalance.
403
+ def rebalance!
404
+ return unless @playground.collecting?
405
+ score_results = bayes_bandit_score
406
+ if score_results.method == :bayes_bandit_score
407
+ set_alternative_probabilities score_results.alts
408
+ end
409
+ end
378
410
 
379
411
  # -- Completion --
380
412
 
@@ -404,19 +436,22 @@ module Vanity
404
436
  outcome && _alternatives[outcome]
405
437
  end
406
438
 
407
- def complete!
439
+ def complete!(outcome = nil)
408
440
  return unless @playground.collecting? && active?
409
441
  super
410
- if @outcome_is
411
- begin
412
- result = @outcome_is.call
413
- outcome = result.id if Alternative === result && result.experiment == self
414
- rescue
415
- warn "Error in AbTest#complete!: #{$!}"
442
+
443
+ unless outcome
444
+ if @outcome_is
445
+ begin
446
+ result = @outcome_is.call
447
+ outcome = result.id if Alternative === result && result.experiment == self
448
+ rescue
449
+ warn "Error in AbTest#complete!: #{$!}"
450
+ end
451
+ else
452
+ best = score.best
453
+ outcome = best.id if best
416
454
  end
417
- else
418
- best = score.best
419
- outcome = best.id if best
420
455
  end
421
456
  # TODO: logging
422
457
  connection.ab_set_outcome @id, outcome || 0
@@ -484,7 +519,15 @@ module Vanity
484
519
  # identity, and randomly distributed alternatives for each identity (in the
485
520
  # same experiment).
486
521
  def alternative_for(identity)
487
- Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % @alternatives.size
522
+ if @use_probabilities
523
+ existing_assignment = connection.ab_assigned @id, identity
524
+ return existing_assignment if existing_assignment
525
+ random_outcome = rand()
526
+ @use_probabilities.each do |alternative, max_prob|
527
+ return alternative.id if random_outcome < max_prob
528
+ end
529
+ end
530
+ return Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % @alternatives.size
488
531
  end
489
532
 
490
533
  begin