moses-vanity 1.7.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.autotest +22 -0
- data/.gitignore +7 -0
- data/.rvmrc +3 -0
- data/.travis.yml +13 -0
- data/CHANGELOG +374 -0
- data/Gemfile +28 -0
- data/MIT-LICENSE +21 -0
- data/README.rdoc +108 -0
- data/Rakefile +189 -0
- data/bin/vanity +16 -0
- data/doc/_config.yml +2 -0
- data/doc/_layouts/_header.html +34 -0
- data/doc/_layouts/page.html +47 -0
- data/doc/_metrics.textile +12 -0
- data/doc/ab_testing.textile +210 -0
- data/doc/configuring.textile +45 -0
- data/doc/contributing.textile +93 -0
- data/doc/credits.textile +23 -0
- data/doc/css/page.css +83 -0
- data/doc/css/print.css +43 -0
- data/doc/css/syntax.css +7 -0
- data/doc/email.textile +129 -0
- data/doc/experimental.textile +31 -0
- data/doc/faq.textile +8 -0
- data/doc/identity.textile +43 -0
- data/doc/images/ab_in_dashboard.png +0 -0
- data/doc/images/clear_winner.png +0 -0
- data/doc/images/price_options.png +0 -0
- data/doc/images/sidebar_test.png +0 -0
- data/doc/images/signup_metric.png +0 -0
- data/doc/images/vanity.png +0 -0
- data/doc/index.textile +91 -0
- data/doc/metrics.textile +231 -0
- data/doc/rails.textile +89 -0
- data/doc/site.js +27 -0
- data/generators/templates/vanity_migration.rb +53 -0
- data/generators/vanity_generator.rb +8 -0
- data/lib/generators/templates/vanity_migration.rb +53 -0
- data/lib/generators/vanity_generator.rb +15 -0
- data/lib/vanity.rb +36 -0
- data/lib/vanity/adapters/abstract_adapter.rb +140 -0
- data/lib/vanity/adapters/active_record_adapter.rb +248 -0
- data/lib/vanity/adapters/mock_adapter.rb +157 -0
- data/lib/vanity/adapters/mongodb_adapter.rb +178 -0
- data/lib/vanity/adapters/redis_adapter.rb +160 -0
- data/lib/vanity/backport.rb +26 -0
- data/lib/vanity/commands/list.rb +21 -0
- data/lib/vanity/commands/report.rb +64 -0
- data/lib/vanity/commands/upgrade.rb +34 -0
- data/lib/vanity/experiment/ab_test.rb +507 -0
- data/lib/vanity/experiment/base.rb +214 -0
- data/lib/vanity/frameworks.rb +16 -0
- data/lib/vanity/frameworks/rails.rb +318 -0
- data/lib/vanity/helpers.rb +66 -0
- data/lib/vanity/images/x.gif +0 -0
- data/lib/vanity/metric/active_record.rb +85 -0
- data/lib/vanity/metric/base.rb +244 -0
- data/lib/vanity/metric/google_analytics.rb +83 -0
- data/lib/vanity/metric/remote.rb +53 -0
- data/lib/vanity/playground.rb +396 -0
- data/lib/vanity/templates/_ab_test.erb +28 -0
- data/lib/vanity/templates/_experiment.erb +5 -0
- data/lib/vanity/templates/_experiments.erb +7 -0
- data/lib/vanity/templates/_metric.erb +14 -0
- data/lib/vanity/templates/_metrics.erb +13 -0
- data/lib/vanity/templates/_report.erb +27 -0
- data/lib/vanity/templates/_vanity.js.erb +20 -0
- data/lib/vanity/templates/flot.min.js +1 -0
- data/lib/vanity/templates/jquery.min.js +19 -0
- data/lib/vanity/templates/vanity.css +26 -0
- data/lib/vanity/templates/vanity.js +82 -0
- data/lib/vanity/version.rb +11 -0
- data/test/adapters/redis_adapter_test.rb +17 -0
- data/test/experiment/ab_test.rb +771 -0
- data/test/experiment/base_test.rb +150 -0
- data/test/experiments/age_and_zipcode.rb +19 -0
- data/test/experiments/metrics/cheers.rb +3 -0
- data/test/experiments/metrics/signups.rb +2 -0
- data/test/experiments/metrics/yawns.rb +3 -0
- data/test/experiments/null_abc.rb +5 -0
- data/test/metric/active_record_test.rb +277 -0
- data/test/metric/base_test.rb +293 -0
- data/test/metric/google_analytics_test.rb +104 -0
- data/test/metric/remote_test.rb +109 -0
- data/test/myapp/app/controllers/application_controller.rb +2 -0
- data/test/myapp/app/controllers/main_controller.rb +7 -0
- data/test/myapp/config/boot.rb +110 -0
- data/test/myapp/config/environment.rb +10 -0
- data/test/myapp/config/environments/production.rb +0 -0
- data/test/myapp/config/routes.rb +3 -0
- data/test/passenger_test.rb +43 -0
- data/test/playground_test.rb +26 -0
- data/test/rails_dashboard_test.rb +37 -0
- data/test/rails_helper_test.rb +36 -0
- data/test/rails_test.rb +389 -0
- data/test/test_helper.rb +145 -0
- data/vanity.gemspec +26 -0
- metadata +202 -0
@@ -0,0 +1,26 @@
|
|
1
|
+
class Time
|
2
|
+
unless method_defined?(:to_date)
|
3
|
+
# Backported from Ruby 1.9.
|
4
|
+
def to_date
|
5
|
+
jd = Date.__send__(:civil_to_jd, year, mon, mday, Date::ITALY)
|
6
|
+
Date.new!(Date.__send__(:jd_to_ajd, jd, 0, 0), 0, Date::ITALY)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
class Date
|
12
|
+
unless method_defined?(:to_date)
|
13
|
+
# Backported from Ruby 1.9.
|
14
|
+
def to_date
|
15
|
+
self
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
unless method_defined?(:to_time)
|
20
|
+
# Backported from Ruby 1.9.
|
21
|
+
def to_time
|
22
|
+
Time.local(year, mon, mday)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Vanity
|
2
|
+
module Commands
|
3
|
+
class << self
|
4
|
+
# Lists all experiments and metrics.
|
5
|
+
def list
|
6
|
+
Vanity.playground.experiments.each do |id, experiment|
|
7
|
+
puts "experiment :%-.20s (%-.40s)" % [id, experiment.name]
|
8
|
+
if experiment.respond_to?(:alternatives)
|
9
|
+
experiment.alternatives.each do |alt|
|
10
|
+
hash = experiment.fingerprint(alt)
|
11
|
+
puts " %s: %-40.40s (%s)" % [alt.name, alt.value, hash]
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
Vanity.playground.metrics.each do |id, metric|
|
16
|
+
puts "metric :%-.20s (%-.40s)" % [id, metric.name]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require "erb"
|
2
|
+
require "cgi"
|
3
|
+
|
4
|
+
module Vanity
|
5
|
+
|
6
|
+
# Render method available to templates (when used by Vanity command line,
|
7
|
+
# outside Rails).
|
8
|
+
module Render
|
9
|
+
|
10
|
+
# Render the named template. Used for reporting and the dashboard.
|
11
|
+
def render(path, locals = {})
|
12
|
+
locals[:playground] = self
|
13
|
+
keys = locals.keys
|
14
|
+
struct = Struct.new(*keys)
|
15
|
+
struct.send :include, Render
|
16
|
+
locals = struct.new(*locals.values_at(*keys))
|
17
|
+
dir, base = File.split(path)
|
18
|
+
path = File.join(dir, "_#{base}")
|
19
|
+
erb = ERB.new(File.read(path), nil, '<>')
|
20
|
+
erb.filename = path
|
21
|
+
erb.result(locals.instance_eval { binding })
|
22
|
+
end
|
23
|
+
|
24
|
+
# Escape HTML.
|
25
|
+
def vanity_h(html)
|
26
|
+
CGI.escapeHTML(html.to_s)
|
27
|
+
end
|
28
|
+
|
29
|
+
def vanity_html_safe(text)
|
30
|
+
text
|
31
|
+
end
|
32
|
+
|
33
|
+
# Dumbed down from Rails' simple_format.
|
34
|
+
def vanity_simple_format(text, options={})
|
35
|
+
open = "<p #{options.map { |k,v| "#{k}=\"#{CGI.escapeHTML v}\"" }.join(" ")}>"
|
36
|
+
text = open + text.gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
|
37
|
+
gsub(/\n\n+/, "</p>\n\n#{open}"). # 2+ newline -> paragraph
|
38
|
+
gsub(/([^\n]\n)(?=[^\n])/, '\1<br />') + # 1 newline -> br
|
39
|
+
"</p>"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Commands available when running Vanity from the command line (see bin/vanity).
|
44
|
+
module Commands
|
45
|
+
class << self
|
46
|
+
include Render
|
47
|
+
|
48
|
+
# Generate an HTML report. Outputs to the named file, or stdout with no
|
49
|
+
# arguments.
|
50
|
+
def report(output = nil)
|
51
|
+
html = render(Vanity.template("report"))
|
52
|
+
if output
|
53
|
+
File.open output, 'w' do |file|
|
54
|
+
file.write html
|
55
|
+
end
|
56
|
+
puts "New report available in #{output}"
|
57
|
+
else
|
58
|
+
$stdout.write html
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Vanity
|
2
|
+
module Commands
|
3
|
+
class << self
|
4
|
+
# Upgrade to newer version of Vanity (this usually means doing magic in
|
5
|
+
# the database)
|
6
|
+
def upgrade
|
7
|
+
if Vanity.playground.connection.respond_to?(:redis)
|
8
|
+
redis = Vanity.playground.connection.redis
|
9
|
+
# Upgrade metrics from 1.3 to 1.4
|
10
|
+
keys = redis.keys("metrics:*")
|
11
|
+
if keys.empty?
|
12
|
+
puts "No metrics to upgrade"
|
13
|
+
else
|
14
|
+
puts "Updating #{keys.map { |name| name.split(":")[1] }.uniq.length} metrics"
|
15
|
+
keys.each do |key|
|
16
|
+
key << ":value:0" if key[/\d{4}-\d{2}-\d{2}$/]
|
17
|
+
redis.renamenx key, "vanity:#{key}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
# Upgrade experiments from 1.3 to 1.4
|
21
|
+
keys = redis.keys("vanity:1:*")
|
22
|
+
if keys.empty?
|
23
|
+
puts "No experiments to upgrade"
|
24
|
+
else
|
25
|
+
puts "Updating #{keys.map { |name| name.split(":")[2] }.uniq.length} experiments"
|
26
|
+
keys.each do |key|
|
27
|
+
redis.renamenx key, key.gsub(":1:", ":experiments:")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,507 @@
|
|
1
|
+
require "digest/md5"
|
2
|
+
|
3
|
+
module Vanity
|
4
|
+
module Experiment
|
5
|
+
|
6
|
+
# One of several alternatives in an A/B test (see AbTest#alternatives).
|
7
|
+
class Alternative
|
8
|
+
|
9
|
+
def initialize(experiment, id, value) #, participants, converted, conversions)
|
10
|
+
@experiment = experiment
|
11
|
+
@id = id
|
12
|
+
@name = "option #{(@id + 65).chr}"
|
13
|
+
@value = value
|
14
|
+
end
|
15
|
+
|
16
|
+
# Alternative id, only unique for this experiment.
|
17
|
+
attr_reader :id
|
18
|
+
|
19
|
+
# Alternative name (option A, option B, etc).
|
20
|
+
attr_reader :name
|
21
|
+
|
22
|
+
# Alternative value.
|
23
|
+
attr_reader :value
|
24
|
+
|
25
|
+
# Experiment this alternative belongs to.
|
26
|
+
attr_reader :experiment
|
27
|
+
|
28
|
+
# Number of participants who viewed this alternative.
|
29
|
+
def participants
|
30
|
+
load_counts unless @participants
|
31
|
+
@participants
|
32
|
+
end
|
33
|
+
|
34
|
+
# Number of participants who converted on this alternative (a participant is counted only once).
|
35
|
+
def converted
|
36
|
+
load_counts unless @converted
|
37
|
+
@converted
|
38
|
+
end
|
39
|
+
|
40
|
+
# Number of conversions for this alternative (same participant may be counted more than once).
|
41
|
+
def conversions
|
42
|
+
load_counts unless @conversions
|
43
|
+
@conversions
|
44
|
+
end
|
45
|
+
|
46
|
+
# Z-score for this alternative, related to 2nd-best performing alternative. Populated by AbTest#score.
|
47
|
+
attr_accessor :z_score
|
48
|
+
|
49
|
+
# Probability derived from z-score. Populated by AbTest#score.
|
50
|
+
attr_accessor :probability
|
51
|
+
|
52
|
+
# Difference from least performing alternative. Populated by AbTest#score.
|
53
|
+
attr_accessor :difference
|
54
|
+
|
55
|
+
# Conversion rate calculated as converted/participants
|
56
|
+
def conversion_rate
|
57
|
+
@conversion_rate ||= (participants > 0 ? converted.to_f/participants.to_f : 0.0)
|
58
|
+
end
|
59
|
+
|
60
|
+
# The measure we use to order (sort) alternatives and decide which one is better (by calculating z-score).
|
61
|
+
# Defaults to conversion rate.
|
62
|
+
def measure
|
63
|
+
conversion_rate
|
64
|
+
end
|
65
|
+
|
66
|
+
def <=>(other)
|
67
|
+
measure <=> other.measure
|
68
|
+
end
|
69
|
+
|
70
|
+
def ==(other)
|
71
|
+
other && id == other.id && experiment == other.experiment
|
72
|
+
end
|
73
|
+
|
74
|
+
def to_s
|
75
|
+
name
|
76
|
+
end
|
77
|
+
|
78
|
+
def inspect
|
79
|
+
"#{name}: #{value} #{converted}/#{participants}"
|
80
|
+
end
|
81
|
+
|
82
|
+
def load_counts
|
83
|
+
if @experiment.playground.collecting?
|
84
|
+
@participants, @converted, @conversions = @experiment.playground.connection.ab_counts(@experiment.id, id).values_at(:participants, :converted, :conversions)
|
85
|
+
else
|
86
|
+
@participants = @converted = @conversions = 0
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
# The meat.
|
93
|
+
class AbTest < Base
|
94
|
+
class << self
|
95
|
+
|
96
|
+
# Convert z-score to probability.
|
97
|
+
def probability(score)
|
98
|
+
score = score.abs
|
99
|
+
probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
|
100
|
+
probability ? probability.last : 0
|
101
|
+
end
|
102
|
+
|
103
|
+
def friendly_name
|
104
|
+
"A/B Test"
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
def initialize(*args)
|
110
|
+
super
|
111
|
+
end
|
112
|
+
|
113
|
+
|
114
|
+
# -- Metric --
|
115
|
+
|
116
|
+
# Tells A/B test which metric we're measuring, or returns metric in use.
|
117
|
+
#
|
118
|
+
# @example Define A/B test against coolness metric
|
119
|
+
# ab_test "Background color" do
|
120
|
+
# metrics :coolness
|
121
|
+
# alternatives "red", "blue", "orange"
|
122
|
+
# end
|
123
|
+
# @example Find metric for A/B test
|
124
|
+
# puts "Measures: " + experiment(:background_color).metrics.map(&:name)
|
125
|
+
def metrics(*args)
|
126
|
+
@metrics = args.map { |id| @playground.metric(id) } unless args.empty?
|
127
|
+
@metrics
|
128
|
+
end
|
129
|
+
|
130
|
+
|
131
|
+
# -- Alternatives --
|
132
|
+
|
133
|
+
# Call this method once to set alternative values for this experiment
|
134
|
+
# (requires at least two values). Call without arguments to obtain
|
135
|
+
# current list of alternatives.
|
136
|
+
#
|
137
|
+
# @example Define A/B test with three alternatives
|
138
|
+
# ab_test "Background color" do
|
139
|
+
# metrics :coolness
|
140
|
+
# alternatives "red", "blue", "orange"
|
141
|
+
# end
|
142
|
+
#
|
143
|
+
# @example Find out which alternatives this test uses
|
144
|
+
# alts = experiment(:background_color).alternatives
|
145
|
+
# puts "#{alts.count} alternatives, with the colors: #{alts.map(&:value).join(", ")}"
|
146
|
+
def alternatives(*args)
|
147
|
+
@alternatives = args.empty? ? [true, false] : args.clone
|
148
|
+
class << self
|
149
|
+
define_method :alternatives, instance_method(:_alternatives)
|
150
|
+
end
|
151
|
+
nil
|
152
|
+
end
|
153
|
+
|
154
|
+
def _alternatives
|
155
|
+
alts = []
|
156
|
+
@alternatives.each_with_index do |value, i|
|
157
|
+
alts << Alternative.new(self, i, value)
|
158
|
+
end
|
159
|
+
alts
|
160
|
+
end
|
161
|
+
private :_alternatives
|
162
|
+
|
163
|
+
# Returns an Alternative with the specified value.
|
164
|
+
#
|
165
|
+
# @example
|
166
|
+
# alternative(:red) == alternatives[0]
|
167
|
+
# alternative(:blue) == alternatives[2]
|
168
|
+
def alternative(value)
|
169
|
+
alternatives.find { |alt| alt.value == value }
|
170
|
+
end
|
171
|
+
|
172
|
+
# Defines an A/B test with two alternatives: false and true. This is the
|
173
|
+
# default pair of alternatives, so just syntactic sugar for those who love
|
174
|
+
# being explicit.
|
175
|
+
#
|
176
|
+
# @example
|
177
|
+
# ab_test "More bacon" do
|
178
|
+
# metrics :yummyness
|
179
|
+
# false_true
|
180
|
+
# end
|
181
|
+
#
|
182
|
+
def false_true
|
183
|
+
alternatives false, true
|
184
|
+
end
|
185
|
+
alias true_false false_true
|
186
|
+
|
187
|
+
# Chooses a value for this experiment. You probably want to use the
|
188
|
+
# Rails helper method ab_test instead.
|
189
|
+
#
|
190
|
+
# This method picks an alternative for the current identity and returns
|
191
|
+
# the alternative's value. It will consistently choose the same
|
192
|
+
# alternative for the same identity, and randomly split alternatives
|
193
|
+
# between different identities.
|
194
|
+
#
|
195
|
+
# @example
|
196
|
+
# color = experiment(:which_blue).choose
|
197
|
+
def choose
|
198
|
+
if @playground.collecting?
|
199
|
+
if active?
|
200
|
+
identity = identity()
|
201
|
+
index = connection.ab_showing(@id, identity)
|
202
|
+
unless index
|
203
|
+
index = alternative_for(identity)
|
204
|
+
if !@playground.using_js?
|
205
|
+
connection.ab_add_participant @id, index, identity
|
206
|
+
check_completion!
|
207
|
+
end
|
208
|
+
end
|
209
|
+
else
|
210
|
+
index = connection.ab_get_outcome(@id) || alternative_for(identity)
|
211
|
+
end
|
212
|
+
else
|
213
|
+
identity = identity()
|
214
|
+
@showing ||= {}
|
215
|
+
@showing[identity] ||= alternative_for(identity)
|
216
|
+
index = @showing[identity]
|
217
|
+
end
|
218
|
+
alternatives[index.to_i]
|
219
|
+
end
|
220
|
+
|
221
|
+
# Returns fingerprint (hash) for given alternative. Can be used to lookup
|
222
|
+
# alternative for experiment without revealing what values are available
|
223
|
+
# (e.g. choosing alternative from HTTP query parameter).
|
224
|
+
def fingerprint(alternative)
|
225
|
+
Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
|
226
|
+
end
|
227
|
+
|
228
|
+
|
229
|
+
# -- Testing --
|
230
|
+
|
231
|
+
# Forces this experiment to use a particular alternative. You'll want to
|
232
|
+
# use this from your test cases to test for the different alternatives.
|
233
|
+
#
|
234
|
+
# @example Setup test to red button
|
235
|
+
# setup do
|
236
|
+
# experiment(:button_color).select(:red)
|
237
|
+
# end
|
238
|
+
#
|
239
|
+
# def test_shows_red_button
|
240
|
+
# . . .
|
241
|
+
# end
|
242
|
+
#
|
243
|
+
# @example Use nil to clear selection
|
244
|
+
# teardown do
|
245
|
+
# experiment(:green_button).select(nil)
|
246
|
+
# end
|
247
|
+
def chooses(value)
|
248
|
+
if @playground.collecting?
|
249
|
+
if value.nil?
|
250
|
+
connection.ab_not_showing @id, identity
|
251
|
+
else
|
252
|
+
index = @alternatives.index(value)
|
253
|
+
#add them to the experiment unless they are already in it
|
254
|
+
unless index == connection.ab_showing(@id, identity)
|
255
|
+
connection.ab_add_participant @id, index, identity
|
256
|
+
check_completion!
|
257
|
+
end
|
258
|
+
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
259
|
+
if (connection.ab_showing(@id, identity) && connection.ab_showing(@id, identity) != index) ||
|
260
|
+
alternative_for(identity) != index
|
261
|
+
connection.ab_show @id, identity, index
|
262
|
+
end
|
263
|
+
end
|
264
|
+
else
|
265
|
+
@showing ||= {}
|
266
|
+
@showing[identity] = value.nil? ? nil : @alternatives.index(value)
|
267
|
+
end
|
268
|
+
self
|
269
|
+
end
|
270
|
+
|
271
|
+
# True if this alternative is currently showing (see #chooses).
|
272
|
+
def showing?(alternative)
|
273
|
+
identity = identity()
|
274
|
+
if @playground.collecting?
|
275
|
+
(connection.ab_showing(@id, identity) || alternative_for(identity)) == alternative.id
|
276
|
+
else
|
277
|
+
@showing ||= {}
|
278
|
+
@showing[identity] == alternative.id
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
|
283
|
+
# -- Reporting --
|
284
|
+
|
285
|
+
# Scores alternatives based on the current tracking data. This method
|
286
|
+
# returns a structure with the following attributes:
|
287
|
+
# [:alts] Ordered list of alternatives, populated with scoring info.
|
288
|
+
# [:base] Second best performing alternative.
|
289
|
+
# [:least] Least performing alternative (but more than zero conversion).
|
290
|
+
# [:choice] Choice alterntive, either the outcome or best alternative.
|
291
|
+
#
|
292
|
+
# Alternatives returned by this method are populated with the following
|
293
|
+
# attributes:
|
294
|
+
# [:z_score] Z-score (relative to the base alternative).
|
295
|
+
# [:probability] Probability (z-score mapped to 0, 90, 95, 99 or 99.9%).
|
296
|
+
# [:difference] Difference from the least performant altenative.
|
297
|
+
#
|
298
|
+
# The choice alternative is set only if its probability is higher or
|
299
|
+
# equal to the specified probability (default is 90%).
|
300
|
+
def score(probability = 90)
|
301
|
+
alts = alternatives
|
302
|
+
# sort by conversion rate to find second best and 2nd best
|
303
|
+
sorted = alts.sort_by(&:measure)
|
304
|
+
base = sorted[-2]
|
305
|
+
# calculate z-score
|
306
|
+
pc = base.measure
|
307
|
+
nc = base.participants
|
308
|
+
alts.each do |alt|
|
309
|
+
p = alt.measure
|
310
|
+
n = alt.participants
|
311
|
+
alt.z_score = (p - pc) / ((p * (1-p)/n) + (pc * (1-pc)/nc)).abs ** 0.5
|
312
|
+
alt.probability = AbTest.probability(alt.z_score)
|
313
|
+
end
|
314
|
+
# difference is measured from least performant
|
315
|
+
if least = sorted.find { |alt| alt.measure > 0 }
|
316
|
+
alts.each do |alt|
|
317
|
+
if alt.measure > least.measure
|
318
|
+
alt.difference = (alt.measure - least.measure) / least.measure * 100
|
319
|
+
end
|
320
|
+
end
|
321
|
+
end
|
322
|
+
# best alternative is one with highest conversion rate (best shot).
|
323
|
+
# choice alternative can only pick best if we have high probability (>90%).
|
324
|
+
best = sorted.last if sorted.last.measure > 0.0
|
325
|
+
choice = outcome ? alts[outcome.id] : (best && best.probability >= probability ? best : nil)
|
326
|
+
Struct.new(:alts, :best, :base, :least, :choice).new(alts, best, base, least, choice)
|
327
|
+
end
|
328
|
+
|
329
|
+
# Use the result of #score to derive a conclusion. Returns an
|
330
|
+
# array of claims.
|
331
|
+
def conclusion(score = score)
|
332
|
+
claims = []
|
333
|
+
participants = score.alts.inject(0) { |t,alt| t + alt.participants }
|
334
|
+
claims << case participants
|
335
|
+
when 0 ; "There are no participants in this experiment yet."
|
336
|
+
when 1 ; "There is one participant in this experiment."
|
337
|
+
else ; "There are #{participants} participants in this experiment."
|
338
|
+
end
|
339
|
+
# only interested in sorted alternatives with conversion
|
340
|
+
sorted = score.alts.select { |alt| alt.measure > 0.0 }.sort_by(&:measure).reverse
|
341
|
+
if sorted.size > 1
|
342
|
+
# start with alternatives that have conversion, from best to worst,
|
343
|
+
# then alternatives with no conversion.
|
344
|
+
sorted |= score.alts
|
345
|
+
# we want a result that's clearly better than 2nd best.
|
346
|
+
best, second = sorted[0], sorted[1]
|
347
|
+
if best.measure > second.measure
|
348
|
+
diff = ((best.measure - second.measure) / second.measure * 100).round
|
349
|
+
better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
|
350
|
+
claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
|
351
|
+
if best.probability >= 90
|
352
|
+
claims << "With %d%% probability this result is statistically significant." % score.best.probability
|
353
|
+
else
|
354
|
+
claims << "This result is not statistically significant, suggest you continue this experiment."
|
355
|
+
end
|
356
|
+
sorted.delete best
|
357
|
+
end
|
358
|
+
sorted.each do |alt|
|
359
|
+
if alt.measure > 0.0
|
360
|
+
claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.measure * 100]
|
361
|
+
else
|
362
|
+
claims << "%s did not convert." % alt.name.gsub(/^o/, "O")
|
363
|
+
end
|
364
|
+
end
|
365
|
+
else
|
366
|
+
claims << "This experiment did not run long enough to find a clear winner."
|
367
|
+
end
|
368
|
+
claims << "#{score.choice.name.gsub(/^o/, "O")} selected as the best alternative." if score.choice
|
369
|
+
claims
|
370
|
+
end
|
371
|
+
|
372
|
+
|
373
|
+
# -- Completion --
|
374
|
+
|
375
|
+
# Defines how the experiment can choose the optimal outcome on completion.
|
376
|
+
#
|
377
|
+
# By default, Vanity will take the best alternative (highest conversion
|
378
|
+
# rate) and use that as the outcome. You experiment may have different
|
379
|
+
# needs, maybe you want the least performing alternative, or factor cost
|
380
|
+
# in the equation?
|
381
|
+
#
|
382
|
+
# The default implementation reads like this:
|
383
|
+
# outcome_is do
|
384
|
+
# a, b = alternatives
|
385
|
+
# # a is expensive, only choose a if it performs 2x better than b
|
386
|
+
# a.measure > b.measure * 2 ? a : b
|
387
|
+
# end
|
388
|
+
def outcome_is(&block)
|
389
|
+
raise ArgumentError, "Missing block" unless block
|
390
|
+
raise "outcome_is already called on this experiment" if @outcome_is
|
391
|
+
@outcome_is = block
|
392
|
+
end
|
393
|
+
|
394
|
+
# Alternative chosen when this experiment completed.
|
395
|
+
def outcome
|
396
|
+
return unless @playground.collecting?
|
397
|
+
outcome = connection.ab_get_outcome(@id)
|
398
|
+
outcome && _alternatives[outcome]
|
399
|
+
end
|
400
|
+
|
401
|
+
def complete!
|
402
|
+
return unless @playground.collecting? && active?
|
403
|
+
super
|
404
|
+
if @outcome_is
|
405
|
+
begin
|
406
|
+
result = @outcome_is.call
|
407
|
+
outcome = result.id if Alternative === result && result.experiment == self
|
408
|
+
rescue
|
409
|
+
warn "Error in AbTest#complete!: #{$!}"
|
410
|
+
end
|
411
|
+
else
|
412
|
+
best = score.best
|
413
|
+
outcome = best.id if best
|
414
|
+
end
|
415
|
+
# TODO: logging
|
416
|
+
connection.ab_set_outcome @id, outcome || 0
|
417
|
+
end
|
418
|
+
|
419
|
+
|
420
|
+
# -- Store/validate --
|
421
|
+
|
422
|
+
def destroy
|
423
|
+
connection.destroy_experiment @id
|
424
|
+
super
|
425
|
+
end
|
426
|
+
|
427
|
+
def save
|
428
|
+
true_false unless @alternatives
|
429
|
+
fail "Experiment #{name} needs at least two alternatives" unless @alternatives.size >= 2
|
430
|
+
super
|
431
|
+
if @metrics.nil? || @metrics.empty?
|
432
|
+
warn "Please use metrics method to explicitly state which metric you are measuring against."
|
433
|
+
metric = @playground.metrics[id] ||= Vanity::Metric.new(@playground, name)
|
434
|
+
@metrics = [metric]
|
435
|
+
end
|
436
|
+
@metrics.each do |metric|
|
437
|
+
metric.hook &method(:track!)
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
# Called when tracking associated metric.
|
442
|
+
def track!(metric_id, timestamp, count, *args)
|
443
|
+
return unless active?
|
444
|
+
identity = identity() rescue nil
|
445
|
+
if identity
|
446
|
+
return if connection.ab_showing(@id, identity)
|
447
|
+
index = alternative_for(identity)
|
448
|
+
connection.ab_add_conversion @id, index, identity, count
|
449
|
+
check_completion!
|
450
|
+
end
|
451
|
+
end
|
452
|
+
|
453
|
+
# If you are not embarrassed by the first version of your product, you’ve
|
454
|
+
# launched too late.
|
455
|
+
# -- Reid Hoffman, founder of LinkedIn
|
456
|
+
|
457
|
+
protected
|
458
|
+
|
459
|
+
# Used for testing.
|
460
|
+
def fake(values)
|
461
|
+
values.each do |value, (participants, conversions)|
|
462
|
+
conversions ||= participants
|
463
|
+
participants.times do |identity|
|
464
|
+
index = @alternatives.index(value)
|
465
|
+
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
466
|
+
connection.ab_add_participant @id, index, "#{index}:#{identity}"
|
467
|
+
end
|
468
|
+
conversions.times do |identity|
|
469
|
+
index = @alternatives.index(value)
|
470
|
+
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
471
|
+
connection.ab_add_conversion @id, index, "#{index}:#{identity}"
|
472
|
+
end
|
473
|
+
end
|
474
|
+
end
|
475
|
+
|
476
|
+
# Chooses an alternative for the identity and returns its index. This
|
477
|
+
# method always returns the same alternative for a given experiment and
|
478
|
+
# identity, and randomly distributed alternatives for each identity (in the
|
479
|
+
# same experiment).
|
480
|
+
def alternative_for(identity)
|
481
|
+
Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % @alternatives.size
|
482
|
+
end
|
483
|
+
|
484
|
+
begin
|
485
|
+
a = 50.0
|
486
|
+
# Returns array of [z-score, percentage]
|
487
|
+
norm_dist = []
|
488
|
+
(0.0..3.1).step(0.01) { |x| norm_dist << [x, a += 1 / Math.sqrt(2 * Math::PI) * Math::E ** (-x ** 2 / 2)] }
|
489
|
+
# We're really only interested in 90%, 95%, 99% and 99.9%.
|
490
|
+
Z_TO_PROBABILITY = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |x,a| a >= pct }.first, pct] }.reverse
|
491
|
+
end
|
492
|
+
|
493
|
+
end
|
494
|
+
|
495
|
+
|
496
|
+
module Definition
|
497
|
+
# Define an A/B test with the given name. For example:
|
498
|
+
# ab_test "New Banner" do
|
499
|
+
# alternatives :red, :green, :blue
|
500
|
+
# end
|
501
|
+
def ab_test(name, &block)
|
502
|
+
define name, :ab_test, &block
|
503
|
+
end
|
504
|
+
end
|
505
|
+
|
506
|
+
end
|
507
|
+
end
|