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.
@@ -0,0 +1,93 @@
1
+ module Vanity
2
+ module Experiment
3
+
4
+ # One of several alternatives in an A/B test (see AbTest#alternatives).
5
+ class Alternative
6
+
7
+ def initialize(experiment, id, value) #, participants, converted, conversions)
8
+ @experiment = experiment
9
+ @id = id
10
+ @name = "option #{(@id + 65).chr}"
11
+ @value = value
12
+ end
13
+
14
+ # Alternative id, only unique for this experiment.
15
+ attr_reader :id
16
+
17
+ # Alternative name (option A, option B, etc).
18
+ attr_reader :name
19
+
20
+ # Alternative value.
21
+ attr_reader :value
22
+
23
+ # Experiment this alternative belongs to.
24
+ attr_reader :experiment
25
+
26
+ # Number of participants who viewed this alternative.
27
+ def participants
28
+ load_counts unless @participants
29
+ @participants
30
+ end
31
+
32
+ # Number of participants who converted on this alternative (a
33
+ # participant is counted only once).
34
+ def converted
35
+ load_counts unless @converted
36
+ @converted
37
+ end
38
+
39
+ # Number of conversions for this alternative (same participant may be
40
+ # counted more than once).
41
+ def conversions
42
+ load_counts unless @conversions
43
+ @conversions
44
+ end
45
+
46
+ # Z-score for this alternative, related to 2nd-best performing
47
+ # alternative. Populated by AbTest#score if #score_method is :z_score.
48
+ attr_accessor :z_score
49
+
50
+ # Probability alternative is best. Populated by AbTest#score.
51
+ attr_accessor :probability
52
+
53
+ # Difference from least performing alternative. Populated by
54
+ # AbTest#score.
55
+ attr_accessor :difference
56
+
57
+ # Conversion rate calculated as converted/participants
58
+ def conversion_rate
59
+ @conversion_rate ||= (participants > 0 ? converted.to_f/participants.to_f : 0.0)
60
+ end
61
+
62
+ # The measure we use to order (sort) alternatives and decide which one
63
+ # is better (by calculating z-score). Defaults to conversion rate.
64
+ def measure
65
+ conversion_rate
66
+ end
67
+
68
+ def <=>(other)
69
+ measure <=> other.measure
70
+ end
71
+
72
+ def ==(other)
73
+ other && id == other.id && experiment == other.experiment
74
+ end
75
+
76
+ def to_s
77
+ name
78
+ end
79
+
80
+ def inspect
81
+ "#{name}: #{value} #{converted}/#{participants}"
82
+ end
83
+
84
+ def load_counts
85
+ if @experiment.playground.collecting?
86
+ @participants, @converted, @conversions = @experiment.playground.connection.ab_counts(@experiment.id, id).values_at(:participants, :converted, :conversions)
87
+ else
88
+ @participants = @converted = @conversions = 0
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,40 +1,11 @@
1
+ require "vanity/experiment/definition"
2
+
1
3
  module Vanity
2
4
  module Experiment
3
-
4
- # These methods are available from experiment definitions (files located in
5
- # the experiments directory, automatically loaded by Vanity). Use these
6
- # methods to define you experiments, for example:
7
- # ab_test "New Banner" do
8
- # alternatives :red, :green, :blue
9
- # metrics :signup
10
- # end
11
- module Definition
12
-
13
- attr_reader :playground
14
-
15
- # Defines a new experiment, given the experiment's name, type and
16
- # definition block.
17
- def define(name, type, options = nil, &block)
18
- fail "Experiment #{@experiment_id} already defined in playground" if playground.experiments[@experiment_id]
19
- klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
20
- experiment = klass.new(playground, @experiment_id, name, options)
21
- experiment.instance_eval &block
22
- experiment.save
23
- playground.experiments[@experiment_id] = experiment
24
- end
25
-
26
- def new_binding(playground, id)
27
- @playground, @experiment_id = playground, id
28
- binding
29
- end
30
-
31
- end
32
-
33
5
  # Base class that all experiment types are derived from.
34
6
  class Base
35
7
 
36
8
  class << self
37
-
38
9
  # Returns the type of this class as a symbol (e.g. AbTest becomes
39
10
  # ab_test).
40
11
  def type
@@ -69,7 +40,7 @@ module Vanity
69
40
  @id, @name = id.to_sym, name
70
41
  @options = options || {}
71
42
  @identify_block = method(:default_identify)
72
- @on_assignment_block = nil
43
+ @on_assignment_block = nil
73
44
  end
74
45
 
75
46
  # Human readable experiment name (first argument you pass when creating a
@@ -124,8 +95,8 @@ module Vanity
124
95
  # end
125
96
  # end
126
97
  def on_assignment(&block)
127
- fail "Missing block" unless block
128
- @on_assignment_block = block
98
+ fail "Missing block" unless block
99
+ @on_assignment_block = block
129
100
  end
130
101
 
131
102
  # -- Reporting --
@@ -155,7 +126,9 @@ module Vanity
155
126
  end
156
127
 
157
128
  # Force experiment to complete.
158
- def complete!
129
+ # @param optional integer id of the alternative that is the decided
130
+ # outcome of the experiment
131
+ def complete!(outcome = nil)
159
132
  @playground.logger.info "vanity: completed experiment #{id}"
160
133
  return unless @playground.collecting?
161
134
  connection.set_experiment_completed_at @id, Time.now
@@ -0,0 +1,93 @@
1
+ module Vanity
2
+ module Experiment
3
+ class Score
4
+ attr_accessor :alts, :best, :base, :least, :choice, :method
5
+ end
6
+
7
+ class BayesianBanditScore < Score
8
+ DEFAULT_PROBABILITY = 90
9
+
10
+ def initialize(alternatives, outcome)
11
+ @alternatives = alternatives
12
+ @outcome = outcome
13
+ @method = :bayes_bandit_score
14
+ end
15
+
16
+ def calculate!(probability=DEFAULT_PROBABILITY)
17
+ # sort by conversion rate to find second best
18
+ @alts = @alternatives.sort_by(&:measure)
19
+ @base = @alts[-2]
20
+
21
+ assign_alternatives_bayesian_probability(@alts)
22
+
23
+ @least = assign_alternatives_difference(@alts)
24
+
25
+ # best alternative is one with highest conversion rate (best shot).
26
+ @best = @alts.last if @alts.last.measure > 0.0
27
+ # choice alternative can only pick best if we have high probability (>90%).
28
+ @choice = outcome_or_best_probability(@alternatives, @outcome, @best, probability)
29
+ self
30
+ end
31
+
32
+ protected
33
+
34
+ def outcome_or_best_probability(alternatives, outcome, best, probability)
35
+ if outcome
36
+ alternatives[@outcome.id]
37
+ elsif best && best.probability >= probability
38
+ best
39
+ else
40
+ nil
41
+ end
42
+ end
43
+
44
+ # Assign each alternative's bayesian probability of being the best
45
+ # alternative to alternative#probability.
46
+ def assign_alternatives_bayesian_probability(alternatives)
47
+ alternative_posteriors = calculate_alternative_posteriors(alternatives)
48
+ alternatives.each_with_index do |alternative, i|
49
+ alternative.probability = 100 * probability_alternative_is_best(alternative_posteriors[i], alternative_posteriors)
50
+ end
51
+ end
52
+
53
+ def calculate_alternative_posteriors(alternatives)
54
+ alternatives.map do |alternative|
55
+ x = alternative.converted
56
+ n = alternative.participants
57
+ Rubystats::BetaDistribution.new(x+1, n-x+1)
58
+ end
59
+ end
60
+
61
+ def probability_alternative_is_best(alternative_being_examined, all_alternatives)
62
+ Integration.integrate(0, 1, :tolerance=>1e-4) do |z|
63
+ pdf_alternative_is_best(z, alternative_being_examined, all_alternatives)
64
+ end
65
+ end
66
+
67
+ def pdf_alternative_is_best(z, alternative_being_examined, all_alternatives)
68
+ # get the pdf for this alternative at z
69
+ pdf = alternative_being_examined.pdf(z)
70
+ # now multiply by the probability that all the other alternatives are lower
71
+ all_alternatives.each do |alternative|
72
+ if alternative != alternative_being_examined
73
+ pdf = pdf * alternative.cdf(z)
74
+ end
75
+ end
76
+ pdf
77
+ end
78
+
79
+ def assign_alternatives_difference(alternatives)
80
+ # difference is measured from least performant
81
+ least = alternatives.find { |alternative| alternative.measure > 0 }
82
+ if least
83
+ alternatives.each do |alternative|
84
+ if alternative.measure > least.measure
85
+ alternative.difference = (alternative.measure - least.measure) / least.measure * 100
86
+ end
87
+ end
88
+ end
89
+ least
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ module Vanity
2
+ module Experiment
3
+ # These methods are available from experiment definitions (files located in
4
+ # the experiments directory, automatically loaded by Vanity). Use these
5
+ # methods to define you experiments, for example:
6
+ # ab_test "New Banner" do
7
+ # alternatives :red, :green, :blue
8
+ # metrics :signup
9
+ # end
10
+ module Definition
11
+
12
+ attr_reader :playground
13
+
14
+ # Defines a new experiment, given the experiment's name, type and
15
+ # definition block.
16
+ def define(name, type, options = nil, &block)
17
+ fail "Experiment #{@experiment_id} already defined in playground" if playground.experiments[@experiment_id]
18
+ klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
19
+ experiment = klass.new(playground, @experiment_id, name, options)
20
+ experiment.instance_eval &block
21
+ experiment.save
22
+ playground.experiments[@experiment_id] = experiment
23
+ end
24
+
25
+ def new_binding(playground, id)
26
+ @playground, @experiment_id = playground, id
27
+ binding
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -284,6 +284,24 @@ module Vanity
284
284
  render :file=>Vanity.template("_report"), :content_type=>Mime::HTML, :layout=>false
285
285
  end
286
286
 
287
+ def participant
288
+ render :file=>Vanity.template("_participant"), :locals=>{:participant_id => params[:id], :participant_info => Vanity.playground.participant_info(params[:id])}, :content_type=>Mime::HTML, :layout=>false
289
+ end
290
+
291
+ def complete
292
+ exp = Vanity.playground.experiment(params[:e].to_sym)
293
+ alt = exp.alternatives[params[:a].to_i]
294
+ confirmed = params[:confirmed]
295
+ # make the user confirm before completing an experiment
296
+ if confirmed && confirmed.to_i==alt.id && exp.active?
297
+ exp.complete!(alt.id)
298
+ render :file=>Vanity.template("_experiment"), :locals=>{:experiment=>exp}
299
+ else
300
+ @to_confirm = alt.id
301
+ render :file=>Vanity.template("_experiment"), :locals=>{:experiment=>exp}
302
+ end
303
+ end
304
+
287
305
  def chooses
288
306
  exp = Vanity.playground.experiment(params[:e].to_sym)
289
307
  exp.chooses(exp.alternatives[params[:a].to_i].value)
@@ -95,6 +95,22 @@ module Vanity
95
95
  end
96
96
 
97
97
 
98
+ # -- Participant Information --
99
+
100
+ # Returns an array of all experiments this participant is involved in, with their assignment.
101
+ # This is done as an array of arrays [[<experiment_1>, <assignment_1>], [<experiment_2>, <assignment_2>]], sorted by experiment name, so that it will give a consistent string
102
+ # when converted to_s (so could be used for caching, for example)
103
+ def participant_info(participant_id)
104
+ participant_array = []
105
+ experiments.values.sort_by{|e| e.name}.each do |e|
106
+ index = connection.ab_assigned(e.id, participant_id)
107
+ if index
108
+ participant_array << [e, e.alternatives[index.to_i]]
109
+ end
110
+ end
111
+ return participant_array
112
+ end
113
+
98
114
  # -- Robot Detection --
99
115
 
100
116
  # Call to indicate that participants should be added via js
@@ -1,7 +1,8 @@
1
- <% score = experiment.score %>
1
+ <% score = experiment.calculate_score %>
2
2
  <table>
3
3
  <caption>
4
- <%= experiment.conclusion(score).join(" ") %></caption>
4
+ <%= experiment.conclusion(score).join(" ") %>
5
+ </caption>
5
6
  <% score.alts.each do |alt| %>
6
7
  <tr class="<%= "choice" if score.choice == alt %>">
7
8
  <td class="option"><%= alt.name.gsub(/^o/, "O") %>:</td>
@@ -10,19 +11,33 @@
10
11
  <td class="value"><%= alt.converted %> converted</td>
11
12
  <td>
12
13
  <%= "%.1f%%" % [alt.conversion_rate * 100] %>
14
+ <%= "(%d%% this is best)" % [alt.probability] if score.method == :bayes_score %>
13
15
  <%= "(%d%% better than %s)" % [alt.difference, score.least.name] if alt.difference && alt.difference >= 1 %>
14
16
  </td>
15
- <td class="action">
16
- <% if experiment.active? && respond_to?(:url_for) %>
17
- <% if experiment.showing?(alt) %>
18
- showing
19
- <% else %>
20
- <a class="button chooses" title="Show me this alternative from now on" href="#"
21
- data-id="<%= experiment.id %>" data-url="<%= url_for(:action=>:chooses, :e=>experiment.id, :a=>alt.id) %>">show</a>
22
- <% end %>
23
- <% end %>
24
- </td>
17
+ <% if experiment.active? && respond_to?(:url_for) %>
18
+ <td class="action">
19
+ <small>
20
+ <% if experiment.showing?(alt) %>
21
+ currently <br> shown
22
+ <% else %>
23
+ <a class="button chooses" title="Show me this alternative from now on" href="#"
24
+ data-id="<%= experiment.id %>" data-url="<%= url_for(:action=>:chooses, :e=>experiment.id, :a=>alt.id) %>">show me</a>
25
+ <% end %>
26
+ </small>
27
+ </td>
28
+ <td class="action">
29
+ <small>
30
+ <% if @to_confirm == alt.id %>
31
+ Finish this experiment and assign all current and future participants to <%= alt.name %>?
32
+ <a class="button chooses" title="Confirm experiment completion and assignment of all current and future participants to <%= alt.name %>" href="#"
33
+ data-id="<%= experiment.id %>" data-url="<%= url_for(:action=>:complete, :e=>experiment.id, :a=>alt.id, :confirmed=>alt.id) %>">confirm</a>
34
+ <% else %>
35
+ <a class="button chooses" title="Complete the experiment and assign all current and future participants to <%= alt.name %>" href="#"
36
+ data-id="<%= experiment.id %>" data-url="<%= url_for(:action=>:complete, :e=>experiment.id, :a=>alt.id) %>">make permanent</a>
37
+ <% end %>
38
+ </small>
39
+ </td>
40
+ <% end %>
25
41
  </tr>
26
42
  <% end %>
27
43
  </table>
28
- <%= %>
@@ -0,0 +1,12 @@
1
+ <p>Participant id <%= participant_id %> is taking part in the following experiments:
2
+ <ul class="experiments">
3
+ <% participant_info.each do |experiment, alt| %>
4
+ <li class="experiment <%= experiment.type %>" id="experiment_<%=vanity_h experiment.id.to_s %>">
5
+ <h3><%=vanity_h experiment.name %> <span class="type">(<%= experiment.class.friendly_name %>)</span></h3>
6
+ <%= experiment.description.to_s.split(/\n\s*\n/).map { |para| vanity_html_safe(%{<p class="description">#{vanity_h para}</p>}) }.join.html_safe %>
7
+ <br /><span class="option"><%= alt.name.gsub(/^o/, "O") %>:</span>
8
+ <span class="value"><code><%=vanity_h alt.value.to_s %></code></span>
9
+ </li>
10
+ <% end %>
11
+ </ul>
12
+ </p>
@@ -1,5 +1,5 @@
1
1
  module Vanity
2
- VERSION = "1.8.2"
2
+ VERSION = "1.8.3.beta"
3
3
 
4
4
  module Version
5
5
  version = VERSION.to_s.split(".").map { |i| i.to_i }
@@ -174,6 +174,106 @@ class AbTestTest < ActionController::TestCase
174
174
  end
175
175
  end
176
176
 
177
+ # -- ab_assigned --
178
+
179
+ def test_ab_assigned
180
+ new_ab_test :foobar do
181
+ alternatives "foo", "bar"
182
+ identify { "6e98ec" }
183
+ metrics :coolness
184
+ end
185
+ assert_equal nil, experiment(:foobar).playground.connection.ab_assigned(experiment(:foobar).id, "6e98ec")
186
+ assert id = experiment(:foobar).choose.id
187
+ assert_equal id, experiment(:foobar).playground.connection.ab_assigned(experiment(:foobar).id, "6e98ec")
188
+ end
189
+
190
+ # -- Unequal probabilities --
191
+
192
+ def test_returns_the_same_alternative_consistently_when_using_probabilities
193
+ new_ab_test :foobar do
194
+ alternatives "foo", "bar"
195
+ identify { "6e98ec" }
196
+ rebalance_frequency 100
197
+ metrics :coolness
198
+ end
199
+ assert value = experiment(:foobar).choose.value
200
+ assert_match /foo|bar/, value
201
+ 1000.times do
202
+ assert_equal value, experiment(:foobar).choose.value
203
+ end
204
+ end
205
+
206
+ def test_uses_probabilities_for_new_assignments
207
+ new_ab_test :foobar do
208
+ alternatives "foo", "bar"
209
+ identify { rand }
210
+ rebalance_frequency 10000
211
+ metrics :coolness
212
+ end
213
+ altered_alts = experiment(:foobar).alternatives
214
+ altered_alts[0].probability=30
215
+ altered_alts[1].probability=70
216
+ experiment(:foobar).set_alternative_probabilities altered_alts
217
+ alts = Array.new(1000) { experiment(:foobar).choose.value }
218
+ assert_equal %w{bar foo}, alts.uniq.sort
219
+ assert_in_delta alts.select { |a| a == altered_alts[0].value }.size, 300, 100 # this may fail, such is propability
220
+ end
221
+
222
+ # -- Rebalancing probabilities --
223
+
224
+ def test_rebalances_probabilities_after_rebalance_frequency_calls
225
+ new_ab_test :foobar do
226
+ alternatives "foo", "bar"
227
+ identify { rand }
228
+ rebalance_frequency 12
229
+ metrics :coolness
230
+ end
231
+ class <<experiment(:foobar)
232
+ def times_called
233
+ @times_called || 0
234
+ end
235
+ def rebalance!
236
+ @times_called = times_called + 1
237
+ end
238
+ end
239
+ 11.times { experiment(:foobar).choose.value }
240
+ assert_equal 0, experiment(:foobar).times_called
241
+ experiment(:foobar).choose.value
242
+ assert_equal 1, experiment(:foobar).times_called
243
+ 12.times { experiment(:foobar).choose.value }
244
+ assert_equal 2, experiment(:foobar).times_called
245
+ end
246
+
247
+ def test_rebalance_uses_bayes_score_probabilities_to_update_probabilities
248
+ new_ab_test :foobar do
249
+ alternatives "foo", "bar", "baa"
250
+ identify { rand }
251
+ rebalance_frequency 12
252
+ metrics :coolness
253
+ end
254
+ corresponding_probabilities = [[experiment(:foobar).alternatives[0], 0.3], [experiment(:foobar).alternatives[1], 0.6], [experiment(:foobar).alternatives[2], 1.0]]
255
+
256
+ class <<experiment(:foobar)
257
+ def was_called
258
+ @was_called
259
+ end
260
+ def bayes_bandit_score(probability=90)
261
+ @was_called = true
262
+ altered_alts = Vanity.playground.experiment(:foobar).alternatives
263
+ altered_alts[0].probability=30
264
+ altered_alts[1].probability=30
265
+ altered_alts[2].probability=40
266
+ Struct.new(:alts,:method).new(altered_alts,:bayes_bandit_score)
267
+ end
268
+ def use_probabilities
269
+ @use_probabilities
270
+ end
271
+ end
272
+ experiment(:foobar).rebalance!
273
+ assert experiment(:foobar).was_called
274
+ assert_equal experiment(:foobar).use_probabilities, corresponding_probabilities
275
+ end
276
+
177
277
  # -- Running experiment --
178
278
 
179
279
  def test_returns_the_same_alternative_consistently
@@ -365,6 +465,22 @@ class AbTestTest < ActionController::TestCase
365
465
 
366
466
 
367
467
  # -- Scoring --
468
+ def test_calculate_score
469
+ new_ab_test :abcd do
470
+ alternatives :a, :b, :c, :d
471
+ metrics :coolness
472
+ end
473
+ score_result = experiment(:abcd).calculate_score
474
+ assert_equal :score, score_result.method
475
+
476
+ new_ab_test :bayes_abcd do
477
+ alternatives :a, :b, :c, :d
478
+ metrics :coolness
479
+ score_method :bayes_bandit_score
480
+ end
481
+ bayes_score_result = experiment(:bayes_abcd).calculate_score
482
+ assert_equal :bayes_bandit_score, bayes_score_result.method
483
+ end
368
484
 
369
485
  def test_scoring
370
486
  new_ab_test :abcd do
@@ -392,6 +508,23 @@ class AbTestTest < ActionController::TestCase
392
508
  assert_equal 2, experiment(:abcd).score.least.id
393
509
  end
394
510
 
511
+ def test_bayes_scoring
512
+ new_ab_test :abcd do
513
+ alternatives :a, :b, :c, :d
514
+ metrics :coolness
515
+ end
516
+ # participating, conversions, rate, z-score
517
+ # Control: 182 35 19.23% N/A
518
+ # Treatment A: 180 45 25.00% 1.33
519
+ # treatment B: 189 28 14.81% -1.13
520
+ # treatment C: 188 61 32.45% 2.94
521
+ fake :abcd, :a=>[182, 35], :b=>[180, 45], :c=>[189,28], :d=>[188, 61]
522
+
523
+ score_result = experiment(:abcd).bayes_bandit_score
524
+ probabilities = score_result.alts.map{|a| a.probability.round}
525
+ assert_equal [0,0,6,94], probabilities
526
+ end
527
+
395
528
  def test_scoring_with_no_performers
396
529
  new_ab_test :abcd do
397
530
  alternatives :a, :b, :c, :d
@@ -647,6 +780,14 @@ This experiment did not run long enough to find a clear winner.
647
780
  assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
648
781
  end
649
782
 
783
+ def test_completion_with_outcome
784
+ new_ab_test :quick do
785
+ metrics :coolness
786
+ end
787
+ experiment(:quick).complete!(1)
788
+ assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
789
+ end
790
+
650
791
  def test_error_in_completion
651
792
  new_ab_test :quick do
652
793
  outcome_is { raise RuntimeError }