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 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