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 CHANGED
@@ -1,41 +1,56 @@
1
- 0.3.1 (2009-11-13)
2
- * Changed: Redis 1.0 is now vendored into Vanity. This means one less
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
- 0.3.0 (2009-11-13)
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
- structs.
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://farm3.static.flickr.com/2540/4099665871_497f274f68_o.jpg
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
- Requires Ruby 1.9.1 or later, Redis 1.0 or later.
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
- Add Vanity to your Rails app:
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 an A/B test. This test compares three pricing options:
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
- experiment "Price options" do
19
- description "Mirror, mirror on the wall, who's the better price of them all?"
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 the user:
29
+ <b>Step 3:</b> Present the different options to your users:
24
30
 
25
- <h2>Get started for only $<%= ab_test :pricing_options %> a month!</h2>
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
- ab_goal! :pricing_options # <- conversion
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 in templates (when running outside Rails).
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 console.
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 a report with all available tests. Outputs to the named file,
28
- # or stdout with no arguments.
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
- # Experiment alternative. See AbTest#alternatives and AbTest#score.
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) #:nodoc:
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
- # Confidence derived from z-score. Populated by AbTest#score.
40
- attr_accessor :confidence
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
- @rate ||= (participants > 0 ? (converted.to_f/participants.to_f).round(3) : 0.0)
47
+ @conversion_rate ||= (participants > 0 ? (converted.to_f/participants.to_f).round(3) : 0.0)
48
48
  end
49
49
 
50
- def <=>(other) # sort by conversion rate
51
- conversion_rate <=> other.conversion_rate
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 #:nodoc:
64
+ def to_s
59
65
  name
60
66
  end
61
67
 
62
- def inspect #:nodoc:
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
- def confidence(score) #:nodoc:
79
+ # Convert z-score to probability.
80
+ def probability(score)
74
81
  score = score.abs
75
- confidence = AbTest::Z_TO_CONFIDENCE.find { |z,p| score >= z }
76
- confidence ? confidence.last : 0
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) #:nodoc:
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
- # experiment "Background color" do
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 faring, use #score.
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
- alias :alternatives :_alternatives
115
+ define_method :alternatives, instance_method(:_alternatives)
109
116
  end
110
117
  alternatives
111
118
  end
112
119
 
113
- def _alternatives #:nodoc:
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
- if index = @alternatives.index(value)
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
- # Sets this test to two alternatives: false and true.
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 returns different values for different identity (see
143
- # #identify), and consistenly the same value for the same
144
- # expriment/identity pair.
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 = identify
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
- # Records a conversion.
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).conversion!
167
- def conversion!
184
+ # experiment(:which_blue).track!
185
+ def track!
168
186
  return unless active?
169
- identity = identify
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. Useful for
183
- # tests, e.g.
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
- index = @alternatives.index(value)
199
- raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
200
- identity = identify
201
- redis[key("participant:#{identity}:show")] = index
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
- def chosen?(alternative) #:nodoc:
206
- identity = identify
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
- # Returns an object with the following methods:
229
- # [:alts] List of Alternative populated with interesting statistics.
230
- # [:best] Best performing alternative.
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 (if confidence >= 90%).
241
+ # [:choice] Choice alterntive, either the outcome or best alternative.
234
242
  #
235
- # Alternatives returned by this method are populated with the following attributes:
236
- # [:z_score] Z-score (relative to the base alternative).
237
- # [:confidence] Confidence (z-score mapped to 0, 90, 95, 99 or 99.9%).
238
- # [:difference] Difference from the least performant altenative.
239
- def score
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(&:conversion_rate)
254
+ sorted = alts.sort_by(&:measure)
243
255
  base = sorted[-2]
244
256
  # calculate z-score
245
- pc = base.conversion_rate
257
+ pc = base.measure
246
258
  nc = base.participants
247
259
  alts.each do |alt|
248
- p = alt.conversion_rate
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.confidence = AbTest.confidence(alt.z_score)
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.conversion_rate > 0 }
266
+ if least = sorted.find { |alt| alt.measure > 0 }
255
267
  alts.each do |alt|
256
- if alt.conversion_rate > least.conversion_rate
257
- alt.difference = (alt.conversion_rate - least.conversion_rate) / least.conversion_rate * 100
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 confidence (>90%).
263
- best = sorted.last if sorted.last.conversion_rate > 0.0
264
- choice = outcome ? alts[outcome.id] : (best && best.confidence >= 90 ? best : nil)
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 score returned by #score to derive a conclusion. Returns an
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.conversion_rate > 0.0 }.sort_by(&:conversion_rate).reverse
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.conversion_rate > second.conversion_rate
281
- diff = ((best.conversion_rate - second.conversion_rate) / second.conversion_rate * 100).round
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.conversion_rate * 100, better]
284
- if best.confidence >= 90
285
- claims << "With %d%% probability this result is statistically significant." % score.best.confidence
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.conversion_rate > 0.0
293
- claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.conversion_rate * 100]
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
- # The default implementation looks for the best (highest conversion rate)
311
- # alternative. If it's certain (95% or more) that this alternative is
312
- # better than the first alternative, it switches to that one. If it has
313
- # no such certainty, it starts using the first alternative exclusively.
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
- # highest = alternatives.sort.last
318
- # highest.confidence >= 95 ? highest ? alternatives.first
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 was completed.
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 save
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 destroy
369
- reset
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
- private
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 = (-5.0..3.1).step(0.01).map { |x| [x, a += 1 / Math.sqrt(2 * Math::PI) * Math::E ** (-x ** 2 / 2)] }
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
- Z_TO_CONFIDENCE = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |x,a| a >= pct }.first, pct] }.reverse
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