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 +14 -5
- data/Gemfile +5 -0
- data/Gemfile.lock +12 -6
- data/gemfiles/rails3.gemfile +3 -0
- data/gemfiles/rails3.gemfile.lock +11 -5
- data/gemfiles/rails31.gemfile +3 -0
- data/gemfiles/rails31.gemfile.lock +11 -6
- data/gemfiles/rails32.gemfile +3 -0
- data/gemfiles/rails32.gemfile.lock +11 -5
- data/lib/vanity/adapters/abstract_adapter.rb +13 -8
- data/lib/vanity/adapters/active_record_adapter.rb +6 -0
- data/lib/vanity/adapters/mongodb_adapter.rb +6 -0
- data/lib/vanity/adapters/redis_adapter.rb +10 -0
- data/lib/vanity/backport.rb +1 -2
- data/lib/vanity/experiment/ab_test.rb +182 -139
- data/lib/vanity/experiment/alternative.rb +93 -0
- data/lib/vanity/experiment/base.rb +8 -35
- data/lib/vanity/experiment/bayesian_bandit_score.rb +93 -0
- data/lib/vanity/experiment/definition.rb +32 -0
- data/lib/vanity/frameworks/rails.rb +18 -0
- data/lib/vanity/playground.rb +16 -0
- data/lib/vanity/templates/_ab_test.erb +28 -13
- data/lib/vanity/templates/_participant.erb +12 -0
- data/lib/vanity/version.rb +1 -1
- data/test/experiment/ab_test.rb +141 -0
- data/test/experiment/base_test.rb +13 -4
- data/test/playground_test.rb +12 -0
- data/test/rails_dashboard_test.rb +35 -1
- data/test/rails_test.rb +8 -0
- data/vanity.gemspec +4 -3
- metadata +20 -19
- data/.rvmrc +0 -3
@@ -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
|
-
|
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
|
-
|
128
|
-
|
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
|
-
|
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)
|
data/lib/vanity/playground.rb
CHANGED
@@ -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.
|
1
|
+
<% score = experiment.calculate_score %>
|
2
2
|
<table>
|
3
3
|
<caption>
|
4
|
-
<%= experiment.conclusion(score).join(" ")
|
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
|
-
|
16
|
-
|
17
|
-
|
18
|
-
showing
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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>
|
data/lib/vanity/version.rb
CHANGED
data/test/experiment/ab_test.rb
CHANGED
@@ -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 }
|