vanity 1.8.2 → 1.8.3.beta

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