vanity 0.3.1 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +34 -19
- data/README.rdoc +19 -15
- data/lib/vanity/commands/report.rb +11 -4
- data/lib/vanity/experiment/ab_test.rb +149 -108
- data/lib/vanity/experiment/base.rb +42 -53
- data/lib/vanity/playground.rb +48 -24
- data/lib/vanity/rails.rb +2 -1
- data/lib/vanity/rails/dashboard.rb +15 -0
- data/lib/vanity/rails/helpers.rb +19 -32
- data/lib/vanity/rails/testing.rb +3 -1
- data/lib/vanity/templates/_ab_test.erb +7 -5
- data/lib/vanity/templates/_experiment.erb +5 -0
- data/lib/vanity/templates/_experiments.erb +2 -7
- data/lib/vanity/templates/_report.erb +14 -14
- data/lib/vanity/templates/vanity.css +13 -0
- data/test/ab_test_test.rb +147 -110
- data/test/experiment_test.rb +15 -22
- data/test/experiments/age_and_zipcode.rb +17 -2
- data/test/experiments/null_abc.rb +1 -1
- data/test/playground_test.rb +53 -31
- data/test/rails_test.rb +1 -1
- data/test/test_helper.rb +2 -0
- data/vanity.gemspec +2 -2
- metadata +7 -6
- data/lib/vanity/rails/console.rb +0 -14
- data/lib/vanity/templates/_vanity.css +0 -13
data/CHANGELOG
CHANGED
@@ -1,41 +1,56 @@
|
|
1
|
-
0.
|
2
|
-
|
3
|
-
dependecy ... actually two, since Redis brings with it RSpec.
|
1
|
+
== 1.0.0 (2009-11-19)
|
2
|
+
This release changes the way you define a new experiment. You can use a method suitable for the type of experiment you want to define, or call the generic define method (previously: experiment method). For example:
|
4
3
|
|
5
|
-
|
4
|
+
ab_test "My A/B test" do
|
5
|
+
alternatives :a, :b
|
6
|
+
end
|
7
|
+
|
8
|
+
The experiment method is no longer overloaded: it looks up an experiment (loading its definition if necessary), but does not define an experiment. The ab_test method is overloaded, though this may change in the future.
|
9
|
+
|
10
|
+
In addition, the ab_goal! method is now track!. This method may be used for other tests in the future.
|
11
|
+
|
12
|
+
* Added: A/B test report now showing number of participants.
|
13
|
+
* Added: AbTest.score method accepts minimum probability (default 90), and
|
14
|
+
* Removed: Experiment.reset! method. Destroy and save have the same effect.
|
15
|
+
* Changed: Playground.define now requires an experiment type, ab_test is not the default any more.
|
16
|
+
* Changed: When you run Vanity in development mode (configuration.cache_classes = false), it will reload experiments on each request. You can also Vanity.playground.reload!.
|
17
|
+
* Changed: Fancy AJAX trickery in Rails console.
|
18
|
+
* Changed: You can break long experiment descriptions into multiple paragraphs using two consecutive newlines.
|
19
|
+
* Changed: AbTest confidence becomes probability; only returns choice alternative with probability equal or higher than that.
|
20
|
+
* Changed: ab_goal! becomes track!.
|
21
|
+
* Changed: Console becomes Dashboard, which is less confusing with rails console (script/console).
|
22
|
+
|
23
|
+
== 0.3.1 (2009-11-13)
|
24
|
+
* Changed: Redis 1.0 is now vendored into Vanity. This means one less dependecy ... actually two, since Redis brings with it RSpec.
|
25
|
+
|
26
|
+
== 0.3.0 (2009-11-13)
|
6
27
|
* Added: score now includes least performing alternatives, names and values.
|
7
28
|
* Added: shiny reports.
|
8
|
-
* Added: Rails console shows current experiments status and also allows you to
|
9
|
-
choose which alternative you want to see.
|
29
|
+
* Added: Rails console shows current experiments status and also allows you to choose which alternative you want to see.
|
10
30
|
* Changed: letters instead of numbers for options (option 1 => option A).
|
11
31
|
* Changed: experiment.alternatives is now an immutable snapshot.
|
12
|
-
* Changed: experiment.score returns populated alternative objects instead of
|
13
|
-
|
14
|
-
* Changed: experiment.chooses uses Redis to store state, better for (when we
|
15
|
-
get to) browser integration.
|
32
|
+
* Changed: experiment.score returns populated alternative objects instead of structs.
|
33
|
+
* Changed: experiment.chooses uses Redis to store state, better for (when we get to) browser integration.
|
16
34
|
* Changed: experiment.chooses skips recording participant or conversion.
|
17
35
|
* Changed: to MIT license.
|
18
36
|
|
19
|
-
0.2.2 (2009-11-12)
|
37
|
+
== 0.2.2 (2009-11-12)
|
20
38
|
* Added: vanity binary, with single command for generating a report.
|
21
39
|
* Added: return alternative by value from experiment.alternative(val) method.
|
22
40
|
* Added: reset an experiment by calling reset!.
|
23
41
|
* Added: experiment alternative name (option 1, option 2, etc).
|
24
|
-
* Added: new scoring algorithm: use experiment.score instead of
|
25
|
-
alternative.z_score/confidence.
|
42
|
+
* Added: new scoring algorithm: use experiment.score instead of alternative.z_score/confidence.
|
26
43
|
* Added: experiment.conclusion for plain English results.
|
27
44
|
|
28
|
-
0.2.1 (2009-11-11)
|
45
|
+
== 0.2.1 (2009-11-11)
|
29
46
|
* Added: z-score and confidence level for A/B test alternatives.
|
30
47
|
* Added: test auto-completion and auto-outcome (complete_it, outcome_is).
|
31
|
-
* Changed: default alternatives are now false/true, so if can't decide
|
32
|
-
outcome, fall back on false.
|
48
|
+
* Changed: default alternatives are now false/true, so if can't decide outcome, fall back on false.
|
33
49
|
|
34
|
-
0.2.0 (2009-11-10)
|
50
|
+
== 0.2.0 (2009-11-10)
|
35
51
|
* Added: experiment method on object, used to define and access experiments.
|
36
52
|
* Added: playground configuration (Vanity.playground.namespace = , etc).
|
37
53
|
* Added: use_vanity now accepts block instead of symbol.
|
38
54
|
* Changed: Vanity::Helpers becomes Vanity::Rails.
|
39
|
-
* Changed: A/B test experiments alternatives now handled using Alternatives
|
40
|
-
object.
|
55
|
+
* Changed: A/B test experiments alternatives now handled using Alternatives object.
|
41
56
|
* Removed: A/B test measure method no longer in use.
|
data/README.rdoc
CHANGED
@@ -1,36 +1,42 @@
|
|
1
1
|
Vanity is an Experiment Driven Development framework for Rails.
|
2
2
|
|
3
|
-
http://
|
3
|
+
* Documentation: http://vanity.labnotes.org
|
4
|
+
* On github: http://github.com/assaf/vanity
|
5
|
+
* Vanity requires Ruby 1.9.1 or later, Redis 1.0 or later.
|
4
6
|
|
5
|
-
|
7
|
+
http://farm3.static.flickr.com/2540/4099665871_497f274f68_o.jpg
|
6
8
|
|
7
9
|
|
8
10
|
== A/B Testing with Rails (in 5 easy steps)
|
9
11
|
|
10
|
-
|
12
|
+
<b>Step 1:</b> Start using Vanity in your Rails application:
|
13
|
+
|
14
|
+
gem.config "vanity"
|
15
|
+
|
16
|
+
And:
|
11
17
|
|
12
18
|
class ApplicationController < ActionController::Base
|
13
19
|
use_vanity :current_user
|
14
20
|
end
|
15
21
|
|
16
|
-
Define
|
22
|
+
<b>Step 2:</b> Define your first A/B test. This experiment goes in the file <code>experiments/price_options.rb</code>:
|
17
23
|
|
18
|
-
|
19
|
-
description "Mirror, mirror on the wall, who's the better price of
|
24
|
+
ab_test "Price options" do
|
25
|
+
description "Mirror, mirror on the wall, who's the better price of all?"
|
20
26
|
alternatives 19, 25, 29
|
21
27
|
end
|
22
28
|
|
23
|
-
Present different options to
|
29
|
+
<b>Step 3:</b> Present the different options to your users:
|
24
30
|
|
25
|
-
<h2>Get started for only $<%= ab_test :
|
31
|
+
<h2>Get started for only $<%= ab_test :price_options %> a month!</h2>
|
26
32
|
|
27
|
-
Measure conversion:
|
33
|
+
<b>Step 4:</b> Measure conversion:
|
28
34
|
|
29
35
|
class SignupController < ApplicationController
|
30
36
|
def signup
|
31
37
|
@account = Account.new(params[:account])
|
32
38
|
if @account.save
|
33
|
-
|
39
|
+
track! :pricing_options # <- here be conversion!
|
34
40
|
redirect_to @acccount
|
35
41
|
else
|
36
42
|
render action: :offer
|
@@ -38,15 +44,13 @@ Measure conversion:
|
|
38
44
|
end
|
39
45
|
end
|
40
46
|
|
41
|
-
Check the report:
|
47
|
+
<b>Step 5:</b> Check the report:
|
42
48
|
|
43
49
|
vanity --output vanity.html
|
44
50
|
|
45
|
-
Learn more about Vanity: http://assaf.github.com/vanity
|
46
|
-
|
47
51
|
|
48
|
-
== Credits
|
52
|
+
== Credits/License
|
49
53
|
|
50
|
-
Experiment Driven Development: Nathaniel Talbott (http://blog.talbott.ws).
|
54
|
+
Idea behind Experiment Driven Development: Nathaniel Talbott (http://blog.talbott.ws).
|
51
55
|
|
52
56
|
Copyright (C) 2009 Assaf Arkin, released under the MIT license.
|
@@ -3,10 +3,11 @@ require "cgi"
|
|
3
3
|
|
4
4
|
module Vanity
|
5
5
|
|
6
|
-
# Render method available
|
6
|
+
# Render method available to templates (when used by Vanity command line,
|
7
|
+
# outside Rails).
|
7
8
|
module Render
|
8
9
|
|
9
|
-
# Render the named template. Used for reporting and the
|
10
|
+
# Render the named template. Used for reporting and the dashboard.
|
10
11
|
def render(path, locals = {})
|
11
12
|
locals[:playground] = self
|
12
13
|
keys = locals.keys
|
@@ -18,14 +19,20 @@ module Vanity
|
|
18
19
|
ERB.new(path, nil, '<').result(locals.instance_eval { binding })
|
19
20
|
end
|
20
21
|
|
22
|
+
# Escape HTML.
|
23
|
+
def h(html)
|
24
|
+
CGI.escape_html(html)
|
25
|
+
end
|
26
|
+
|
21
27
|
end
|
22
28
|
|
29
|
+
# Commands available when running Vanity from the command line (see bin/vanity).
|
23
30
|
module Commands
|
24
31
|
class << self
|
25
32
|
include Render
|
26
33
|
|
27
|
-
# Generate
|
28
|
-
#
|
34
|
+
# Generate an HTML report. Outputs to the named file, or stdout with no
|
35
|
+
# arguments.
|
29
36
|
def report(output = nil)
|
30
37
|
html = render(Vanity.template("report"))
|
31
38
|
if output
|
@@ -1,10 +1,10 @@
|
|
1
1
|
module Vanity
|
2
2
|
module Experiment
|
3
3
|
|
4
|
-
#
|
4
|
+
# One of several alternatives in an A/B test (see AbTest#alternatives).
|
5
5
|
class Alternative
|
6
6
|
|
7
|
-
def initialize(experiment, id, value, participants, converted, conversions)
|
7
|
+
def initialize(experiment, id, value, participants, converted, conversions)
|
8
8
|
@experiment = experiment
|
9
9
|
@id = id
|
10
10
|
@name = "option #{(@id + 65).chr}"
|
@@ -27,39 +27,45 @@ module Vanity
|
|
27
27
|
# Number of participants who viewed this alternative.
|
28
28
|
attr_reader :participants
|
29
29
|
|
30
|
-
# Number of participants who converted on this alternative.
|
30
|
+
# Number of participants who converted on this alternative (a participant is counted only once).
|
31
31
|
attr_reader :converted
|
32
32
|
|
33
33
|
# Number of conversions for this alternative (same participant may be counted more than once).
|
34
34
|
attr_reader :conversions
|
35
35
|
|
36
|
-
# Z-score for this alternative. Populated by AbTest#score.
|
36
|
+
# Z-score for this alternative, related to 2nd-best performing alternative. Populated by AbTest#score.
|
37
37
|
attr_accessor :z_score
|
38
38
|
|
39
|
-
#
|
40
|
-
attr_accessor :
|
39
|
+
# Probability derived from z-score. Populated by AbTest#score.
|
40
|
+
attr_accessor :probability
|
41
41
|
|
42
42
|
# Difference from least performing alternative. Populated by AbTest#score.
|
43
43
|
attr_accessor :difference
|
44
44
|
|
45
45
|
# Conversion rate calculated as converted/participants, rounded to 3 places.
|
46
46
|
def conversion_rate
|
47
|
-
@
|
47
|
+
@conversion_rate ||= (participants > 0 ? (converted.to_f/participants.to_f).round(3) : 0.0)
|
48
48
|
end
|
49
49
|
|
50
|
-
|
51
|
-
|
50
|
+
# The measure we use to order (sort) alternatives and decide which one is better (by calculating z-score).
|
51
|
+
# Defaults to conversion rate.
|
52
|
+
def measure
|
53
|
+
conversion_rate
|
54
|
+
end
|
55
|
+
|
56
|
+
def <=>(other)
|
57
|
+
measure <=> other.measure
|
52
58
|
end
|
53
59
|
|
54
60
|
def ==(other)
|
55
61
|
other && id == other.id && experiment == other.experiment
|
56
62
|
end
|
57
63
|
|
58
|
-
def to_s
|
64
|
+
def to_s
|
59
65
|
name
|
60
66
|
end
|
61
67
|
|
62
|
-
def inspect
|
68
|
+
def inspect
|
63
69
|
"#{name}: #{value} #{converted}/#{participants}"
|
64
70
|
end
|
65
71
|
|
@@ -70,10 +76,11 @@ module Vanity
|
|
70
76
|
class AbTest < Base
|
71
77
|
class << self
|
72
78
|
|
73
|
-
|
79
|
+
# Convert z-score to probability.
|
80
|
+
def probability(score)
|
74
81
|
score = score.abs
|
75
|
-
|
76
|
-
|
82
|
+
probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
|
83
|
+
probability ? probability.last : 0
|
77
84
|
end
|
78
85
|
|
79
86
|
def friendly_name
|
@@ -82,7 +89,7 @@ module Vanity
|
|
82
89
|
|
83
90
|
end
|
84
91
|
|
85
|
-
def initialize(*args)
|
92
|
+
def initialize(*args)
|
86
93
|
super
|
87
94
|
@alternatives = [false, true]
|
88
95
|
end
|
@@ -91,7 +98,7 @@ module Vanity
|
|
91
98
|
|
92
99
|
# Call this method once to set alternative values for this experiment.
|
93
100
|
# Require at least two values. For example:
|
94
|
-
#
|
101
|
+
# ab_test "Background color" do
|
95
102
|
# alternatives "red", "blue", "orange"
|
96
103
|
# end
|
97
104
|
#
|
@@ -99,18 +106,18 @@ module Vanity
|
|
99
106
|
# alts = experiment(:background_color).alternatives
|
100
107
|
# puts "#{alts.count} alternatives, with the colors: #{alts.map(&:value).join(", ")}"
|
101
108
|
#
|
102
|
-
# If you want to know how well each alternative is
|
109
|
+
# If you want to know how well each alternative is doing, use #score.
|
103
110
|
def alternatives(*args)
|
104
111
|
unless args.empty?
|
105
112
|
@alternatives = args.clone
|
106
113
|
end
|
107
114
|
class << self
|
108
|
-
|
115
|
+
define_method :alternatives, instance_method(:_alternatives)
|
109
116
|
end
|
110
117
|
alternatives
|
111
118
|
end
|
112
119
|
|
113
|
-
def _alternatives
|
120
|
+
def _alternatives
|
114
121
|
alts = []
|
115
122
|
@alternatives.each_with_index do |value, i|
|
116
123
|
participants = redis.scard(key("alts:#{i}:participants")).to_i
|
@@ -120,34 +127,44 @@ module Vanity
|
|
120
127
|
end
|
121
128
|
alts
|
122
129
|
end
|
130
|
+
private :_alternatives
|
123
131
|
|
124
|
-
# Returns an Alternative with the specified value.
|
132
|
+
# Returns an Alternative with the specified value. For example, given:
|
133
|
+
# ab_test "Which color" do
|
134
|
+
# alternatives :red, :green, :blue
|
135
|
+
# end
|
136
|
+
# Then:
|
137
|
+
# alternative(:red) == alternatives[0]
|
138
|
+
# alternative(:blue) == alternatives[2]
|
125
139
|
def alternative(value)
|
126
|
-
|
127
|
-
participants = redis.scard(key("alts:#{index}:participants")).to_i
|
128
|
-
converted = redis.scard(key("alts:#{index}:converted")).to_i
|
129
|
-
conversions = redis[key("alts:#{index}:conversions")].to_i
|
130
|
-
Alternative.new(self, index, value, participants, converted, conversions)
|
131
|
-
end
|
140
|
+
alternatives.find { |alt| alt.value == value }
|
132
141
|
end
|
133
142
|
|
134
|
-
#
|
143
|
+
# Defines an A/B test with two alternatives: false and true. For example:
|
144
|
+
# ab_test "More bacon" do
|
145
|
+
# false_true
|
146
|
+
# end
|
147
|
+
#
|
148
|
+
# This is the default pair of alternatives, so just syntactic sugar for
|
149
|
+
# those who love being explicit.
|
135
150
|
def false_true
|
136
151
|
alternatives false, true
|
137
152
|
end
|
138
153
|
alias true_false false_true
|
139
154
|
|
140
|
-
# Chooses a value for this experiment.
|
155
|
+
# Chooses a value for this experiment. You probably want to use the
|
156
|
+
# Rails helper method ab_test instead.
|
141
157
|
#
|
142
|
-
# This method
|
143
|
-
#
|
144
|
-
#
|
158
|
+
# This method picks an alternative for the current identity and returns
|
159
|
+
# the alternative's value. It will consistently choose the same
|
160
|
+
# alternative for the same identity, and randomly split alternatives
|
161
|
+
# between different identities.
|
145
162
|
#
|
146
163
|
# For example:
|
147
164
|
# color = experiment(:which_blue).choose
|
148
165
|
def choose
|
149
166
|
if active?
|
150
|
-
identity =
|
167
|
+
identity = identity()
|
151
168
|
index = redis[key("participant:#{identity}:show")]
|
152
169
|
unless index
|
153
170
|
index = alternative_for(identity)
|
@@ -160,13 +177,14 @@ module Vanity
|
|
160
177
|
@alternatives[index.to_i]
|
161
178
|
end
|
162
179
|
|
163
|
-
#
|
164
|
-
#
|
180
|
+
# Tracks a conversion. You probably want to use the Rails helper method
|
181
|
+
# track! instead.
|
182
|
+
#
|
165
183
|
# For example:
|
166
|
-
# experiment(:which_blue).
|
167
|
-
def
|
184
|
+
# experiment(:which_blue).track!
|
185
|
+
def track!
|
168
186
|
return unless active?
|
169
|
-
identity =
|
187
|
+
identity = identity()
|
170
188
|
return if redis[key("participants:#{identity}:show")]
|
171
189
|
index = alternative_for(identity)
|
172
190
|
if redis.sismember(key("alts:#{index}:participants"), identity)
|
@@ -179,9 +197,9 @@ module Vanity
|
|
179
197
|
|
180
198
|
# -- Testing --
|
181
199
|
|
182
|
-
# Forces this experiment to use a particular alternative.
|
183
|
-
#
|
184
|
-
#
|
200
|
+
# Forces this experiment to use a particular alternative. You'll want to
|
201
|
+
# use this from your test cases to test for the different alternatives.
|
202
|
+
# For example:
|
185
203
|
# setup do
|
186
204
|
# experiment(:green_button).select(true)
|
187
205
|
# end
|
@@ -195,102 +213,102 @@ module Vanity
|
|
195
213
|
# experiment(:green_button).select(nil)
|
196
214
|
# end
|
197
215
|
def chooses(value)
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
216
|
+
if value.nil?
|
217
|
+
redis.del key("participant:#{identity}:show")
|
218
|
+
else
|
219
|
+
index = @alternatives.index(value)
|
220
|
+
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
221
|
+
redis[key("participant:#{identity}:show")] = index
|
222
|
+
end
|
202
223
|
self
|
203
224
|
end
|
204
225
|
|
205
|
-
|
206
|
-
|
226
|
+
# True if this alternative is currently showing (see #chooses).
|
227
|
+
def showing?(alternative)
|
228
|
+
identity = identity()
|
207
229
|
index = redis[key("participant:#{identity}:show")]
|
208
230
|
index && index.to_i == alternative.id
|
209
231
|
end
|
210
232
|
|
211
|
-
# Used for testing.
|
212
|
-
def count(identity, value, *what) #:nodoc:
|
213
|
-
index = @alternatives.index(value)
|
214
|
-
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
215
|
-
if what.empty? || what.include?(:participant)
|
216
|
-
redis.sadd key("alts:#{index}:participants"), identity
|
217
|
-
end
|
218
|
-
if what.empty? || what.include?(:conversion)
|
219
|
-
redis.sadd key("alts:#{index}:converted"), identity
|
220
|
-
redis.incr key("alts:#{index}:conversions")
|
221
|
-
end
|
222
|
-
self
|
223
|
-
end
|
224
|
-
|
225
233
|
|
226
234
|
# -- Reporting --
|
227
235
|
|
228
|
-
#
|
229
|
-
#
|
230
|
-
# [:
|
236
|
+
# Scores alternatives based on the current tracking data. This method
|
237
|
+
# returns a structure with the following attributes:
|
238
|
+
# [:alts] Ordered list of alternatives, populated with scoring info.
|
231
239
|
# [:base] Second best performing alternative.
|
232
240
|
# [:least] Least performing alternative (but more than zero conversion).
|
233
|
-
# [:choice] Choice alterntive, either the outcome or best alternative
|
241
|
+
# [:choice] Choice alterntive, either the outcome or best alternative.
|
234
242
|
#
|
235
|
-
# Alternatives returned by this method are populated with the following
|
236
|
-
#
|
237
|
-
# [:
|
238
|
-
# [:
|
239
|
-
|
243
|
+
# Alternatives returned by this method are populated with the following
|
244
|
+
# attributes:
|
245
|
+
# [:z_score] Z-score (relative to the base alternative).
|
246
|
+
# [:probability] Probability (z-score mapped to 0, 90, 95, 99 or 99.9%).
|
247
|
+
# [:difference] Difference from the least performant altenative.
|
248
|
+
#
|
249
|
+
# The choice alternative is set only if its probability is higher or
|
250
|
+
# equal to the specified probability (default is 90%).
|
251
|
+
def score(probability = 90)
|
240
252
|
alts = alternatives
|
241
253
|
# sort by conversion rate to find second best and 2nd best
|
242
|
-
sorted = alts.sort_by(&:
|
254
|
+
sorted = alts.sort_by(&:measure)
|
243
255
|
base = sorted[-2]
|
244
256
|
# calculate z-score
|
245
|
-
pc = base.
|
257
|
+
pc = base.measure
|
246
258
|
nc = base.participants
|
247
259
|
alts.each do |alt|
|
248
|
-
p = alt.
|
260
|
+
p = alt.measure
|
249
261
|
n = alt.participants
|
250
262
|
alt.z_score = (p - pc) / ((p * (1-p)/n) + (pc * (1-pc)/nc)).abs ** 0.5
|
251
|
-
alt.
|
263
|
+
alt.probability = AbTest.probability(alt.z_score)
|
252
264
|
end
|
253
265
|
# difference is measured from least performant
|
254
|
-
if least = sorted.find { |alt| alt.
|
266
|
+
if least = sorted.find { |alt| alt.measure > 0 }
|
255
267
|
alts.each do |alt|
|
256
|
-
if alt.
|
257
|
-
alt.difference = (alt.
|
268
|
+
if alt.measure > least.measure
|
269
|
+
alt.difference = (alt.measure - least.measure) / least.measure * 100
|
258
270
|
end
|
259
271
|
end
|
260
272
|
end
|
261
273
|
# best alternative is one with highest conversion rate (best shot).
|
262
|
-
# choice alternative can only pick best if we have high
|
263
|
-
best = sorted.last if sorted.last.
|
264
|
-
choice = outcome ? alts[outcome.id] : (best && best.
|
274
|
+
# choice alternative can only pick best if we have high probability (>90%).
|
275
|
+
best = sorted.last if sorted.last.measure > 0.0
|
276
|
+
choice = outcome ? alts[outcome.id] : (best && best.probability >= probability ? best : nil)
|
265
277
|
Struct.new(:alts, :best, :base, :least, :choice).new(alts, best, base, least, choice)
|
266
278
|
end
|
267
279
|
|
268
|
-
# Use the
|
280
|
+
# Use the result of #score to derive a conclusion. Returns an
|
269
281
|
# array of claims.
|
270
282
|
def conclusion(score = score)
|
271
283
|
claims = []
|
284
|
+
participants = score.alts.inject(0) { |t,alt| t + alt.participants }
|
285
|
+
claims << case participants
|
286
|
+
when 0 ; "There are no participants in this experiment yet."
|
287
|
+
when 1 ; "There is one participant in this experiment."
|
288
|
+
else ; "There are #{participants} participants in this experiment."
|
289
|
+
end
|
272
290
|
# only interested in sorted alternatives with conversion
|
273
|
-
sorted = score.alts.select { |alt| alt.
|
291
|
+
sorted = score.alts.select { |alt| alt.measure > 0.0 }.sort_by(&:measure).reverse
|
274
292
|
if sorted.size > 1
|
275
293
|
# start with alternatives that have conversion, from best to worst,
|
276
294
|
# then alternatives with no conversion.
|
277
295
|
sorted |= score.alts
|
278
296
|
# we want a result that's clearly better than 2nd best.
|
279
297
|
best, second = sorted[0], sorted[1]
|
280
|
-
if best.
|
281
|
-
diff = ((best.
|
298
|
+
if best.measure > second.measure
|
299
|
+
diff = ((best.measure - second.measure) / second.measure * 100).round
|
282
300
|
better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
|
283
|
-
claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.
|
284
|
-
if best.
|
285
|
-
claims << "With %d%% probability this result is statistically significant." % score.best.
|
301
|
+
claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
|
302
|
+
if best.probability >= 90
|
303
|
+
claims << "With %d%% probability this result is statistically significant." % score.best.probability
|
286
304
|
else
|
287
305
|
claims << "This result is not statistically significant, suggest you continue this experiment."
|
288
306
|
end
|
289
307
|
sorted.delete best
|
290
308
|
end
|
291
309
|
sorted.each do |alt|
|
292
|
-
if alt.
|
293
|
-
claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.
|
310
|
+
if alt.measure > 0.0
|
311
|
+
claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.measure * 100]
|
294
312
|
else
|
295
313
|
claims << "%s did not convert." % alt.name.gsub(/^o/, "O")
|
296
314
|
end
|
@@ -306,16 +324,17 @@ module Vanity
|
|
306
324
|
# -- Completion --
|
307
325
|
|
308
326
|
# Defines how the experiment can choose the optimal outcome on completion.
|
309
|
-
|
310
|
-
#
|
311
|
-
#
|
312
|
-
#
|
313
|
-
#
|
327
|
+
|
328
|
+
# By default, Vanity will take the best alternative (highest conversion
|
329
|
+
# rate) and use that as the outcome. You experiment may have different
|
330
|
+
# needs, maybe you want the least performing alternative, or factor cost
|
331
|
+
# in the equation?
|
314
332
|
#
|
315
333
|
# The default implementation reads like this:
|
316
334
|
# outcome_is do
|
317
|
-
#
|
318
|
-
#
|
335
|
+
# a, b = alternatives
|
336
|
+
# # a is expensive, only choose a if it performs 2x better than b
|
337
|
+
# a.measure > b.measure * 2 ? a : b
|
319
338
|
# end
|
320
339
|
def outcome_is(&block)
|
321
340
|
raise ArgumentError, "Missing block" unless block
|
@@ -323,7 +342,7 @@ module Vanity
|
|
323
342
|
@outcome_is = block
|
324
343
|
end
|
325
344
|
|
326
|
-
# Alternative chosen when this experiment
|
345
|
+
# Alternative chosen when this experiment completed.
|
327
346
|
def outcome
|
328
347
|
outcome = redis[key("outcome")]
|
329
348
|
outcome && alternatives[outcome.to_i]
|
@@ -350,12 +369,7 @@ module Vanity
|
|
350
369
|
|
351
370
|
# -- Store/validate --
|
352
371
|
|
353
|
-
def
|
354
|
-
fail "Experiment #{name} needs at least two alternatives" unless alternatives.count >= 2
|
355
|
-
super
|
356
|
-
end
|
357
|
-
|
358
|
-
def reset!
|
372
|
+
def destroy
|
359
373
|
@alternatives.count.times do |i|
|
360
374
|
redis.del key("alts:#{i}:participants")
|
361
375
|
redis.del key("alts:#{i}:converted")
|
@@ -365,12 +379,12 @@ module Vanity
|
|
365
379
|
super
|
366
380
|
end
|
367
381
|
|
368
|
-
def
|
369
|
-
|
382
|
+
def save
|
383
|
+
fail "Experiment #{name} needs at least two alternatives" unless alternatives.count >= 2
|
370
384
|
super
|
371
385
|
end
|
372
386
|
|
373
|
-
|
387
|
+
protected
|
374
388
|
|
375
389
|
# Chooses an alternative for the identity and returns its index. This
|
376
390
|
# method always returns the same alternative for a given experiment and
|
@@ -380,14 +394,41 @@ module Vanity
|
|
380
394
|
Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % @alternatives.count
|
381
395
|
end
|
382
396
|
|
397
|
+
# Used for testing Vanity.
|
398
|
+
def count_participant(identity, value)
|
399
|
+
index = @alternatives.index(value)
|
400
|
+
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
401
|
+
redis.sadd key("alts:#{index}:participants"), identity
|
402
|
+
self
|
403
|
+
end
|
404
|
+
|
405
|
+
# Used for testing Vanity.
|
406
|
+
def count_conversion(identity, value)
|
407
|
+
index = @alternatives.index(value)
|
408
|
+
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
409
|
+
redis.sadd key("alts:#{index}:converted"), identity
|
410
|
+
redis.incr key("alts:#{index}:conversions")
|
411
|
+
self
|
412
|
+
end
|
413
|
+
|
383
414
|
begin
|
384
|
-
a = 0
|
415
|
+
a = 50.0
|
385
416
|
# Returns array of [z-score, percentage]
|
386
|
-
norm_dist = (
|
417
|
+
norm_dist = (0.0..3.1).step(0.01).map { |x| [x, a += 1 / Math.sqrt(2 * Math::PI) * Math::E ** (-x ** 2 / 2)] }
|
387
418
|
# We're really only interested in 90%, 95%, 99% and 99.9%.
|
388
|
-
|
419
|
+
Z_TO_PROBABILITY = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |x,a| a >= pct }.first, pct] }.reverse
|
389
420
|
end
|
390
421
|
|
391
422
|
end
|
392
423
|
end
|
424
|
+
|
425
|
+
module Definition
|
426
|
+
# Define an A/B test with the given name. For example:
|
427
|
+
# ab_test "New Banner" do
|
428
|
+
# alternatives :red, :green, :blue
|
429
|
+
# end
|
430
|
+
def ab_test(name, &block)
|
431
|
+
define name, :ab_test, &block
|
432
|
+
end
|
433
|
+
end
|
393
434
|
end
|