vanity 0.3.1 → 0.4.0
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/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
|