vanity 0.2.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,4 +1,10 @@
1
- 0.1.1
1
+ 0.2.1 (2009-11-11)
2
+ * Added: z-score and confidence level for A/B test alternatives.
3
+ * Added: test auto-completion and auto-outcome (complete_it, outcome_is).
4
+ * Changed: default alternatives are now false/true, so if can't decide
5
+ outcome, fall back on false.
6
+
7
+ 0.2.0 (2009-11-10)
2
8
  * Added: experiment method on object, used to define and access experiments.
3
9
  * Added: playground configuration (Vanity.playground.namespace = , etc).
4
10
  * Added: use_vanity now accepts block instead of symbol.
data/README.rdoc CHANGED
@@ -51,7 +51,7 @@ the value.
51
51
  Here are some examples:
52
52
 
53
53
  def index
54
- if ab_test(:new_page) # classic true/false test
54
+ if ab_test(:new_page) # classic false/true test
55
55
  render action: "new_page"
56
56
  else
57
57
  render action: "index"
@@ -31,6 +31,15 @@ module Vanity
31
31
  redis.get(key("conversions")).to_i
32
32
  end
33
33
 
34
+ # Conversion rate calculated as converted/participants.
35
+ def conversion_rate
36
+ converted.to_f / participants.to_f
37
+ end
38
+
39
+ def <=>(other)
40
+ conversion_rate <=> other.conversion_rate
41
+ end
42
+
34
43
  def participating!(identity)
35
44
  redis.sadd key("participants"), identity
36
45
  end
@@ -42,6 +51,33 @@ module Vanity
42
51
  end
43
52
  end
44
53
 
54
+ # Z-score this alternativet related to the base alternative. This
55
+ # alternative is better than base if it receives a positive z-score,
56
+ # worse if z-score is negative. Call #confident if you need confidence
57
+ # level (percentage).
58
+ def z_score
59
+ return 0 if base == self
60
+ pc = base.conversion_rate
61
+ nc = base.participants
62
+ p = conversion_rate
63
+ n = participants
64
+ (p - pc) / Math.sqrt((p * (1-p)/n) + (pc * (1-pc)/nc))
65
+ end
66
+
67
+ # How confident are we in this alternative being an improvement over the
68
+ # base alternative. Returns 0, 90, 95, 99 or 99.9 (percentage).
69
+ def confidence
70
+ score = z_score
71
+ confidence = AbTest::Z_TO_CONFIDENCE.find { |z,p| score >= z }
72
+ confidence ? confidence.last : 0
73
+ end
74
+
75
+ def destroy #:nodoc:
76
+ redis.del key("participants")
77
+ redis.del key("converted")
78
+ redis.del key("conversions")
79
+ end
80
+
45
81
  protected
46
82
 
47
83
  def key(name)
@@ -52,39 +88,20 @@ module Vanity
52
88
  @experiment.redis
53
89
  end
54
90
 
91
+ def base
92
+ @base ||= @experiment.alternatives.first
93
+ end
94
+
55
95
  end
56
96
 
97
+
57
98
  # The meat.
58
99
  class AbTest < Base
59
100
  def initialize(*args) #:nodoc:
60
101
  super
61
102
  end
62
103
 
63
- # Chooses a value for this experiment.
64
- #
65
- # This method returns different values for different identity (see
66
- # #identify), and consistenly the same value for the same
67
- # expriment/identity pair.
68
- #
69
- # For example:
70
- # color = experiment(:which_blue).choose
71
- def choose
72
- identity = identify
73
- alt = alternative_for(identity)
74
- alt.participating! identity
75
- alt.value
76
- end
77
-
78
- # Records a conversion.
79
- #
80
- # For example:
81
- # experiment(:which_blue).conversion!
82
- def conversion!
83
- identity = identify
84
- alt = alternative_for(identity)
85
- alt.conversion! identity
86
- alt.id
87
- end
104
+ # -- Alternatives --
88
105
 
89
106
  # Call this method once to specify values for the A/B test. At least two
90
107
  # values are required.
@@ -99,7 +116,7 @@ module Vanity
99
116
  # alts = experiment(:background_color).alternatives
100
117
  # puts "#{alts.count} alternatives, with the colors: #{alts.map(&:value).join(", ")}"
101
118
  def alternatives(*args)
102
- args = [true, false] if args.empty?
119
+ args = [false, true] if args.empty?
103
120
  @alternatives = []
104
121
  args.each_with_index do |arg, i|
105
122
  @alternatives << Alternative.new(self, i, arg)
@@ -108,18 +125,50 @@ module Vanity
108
125
  alternatives
109
126
  end
110
127
 
111
- # Sets this test to two alternatives: true and false.
112
- def true_false
113
- alternatives true, false
128
+ # Sets this test to two alternatives: false and true.
129
+ def false_true
130
+ alternatives false, true
114
131
  end
132
+ alias true_false false_true
115
133
 
116
- def report
117
- alts = alternatives.map { |alt|
118
- "<dt>Option #{(65 + alt.id).chr}</dt><dd><code>#{CGI.escape_html alt.value.inspect}</code> viewed #{alt.participants} times, converted #{alt.conversions}<dd>"
119
- }
120
- %{<dl class="data">#{alts.join}</dl>}
134
+ # Chooses a value for this experiment.
135
+ #
136
+ # This method returns different values for different identity (see
137
+ # #identify), and consistenly the same value for the same
138
+ # expriment/identity pair.
139
+ #
140
+ # For example:
141
+ # color = experiment(:which_blue).choose
142
+ def choose
143
+ if active?
144
+ identity = identify
145
+ alt = alternative_for(identity)
146
+ alt.participating! identity
147
+ check_completion!
148
+ alt.value
149
+ elsif alternative = outcome
150
+ alternative.value
151
+ else
152
+ alternatives.first.value
153
+ end
154
+ end
155
+
156
+ # Records a conversion.
157
+ #
158
+ # For example:
159
+ # experiment(:which_blue).conversion!
160
+ def conversion!
161
+ if active?
162
+ identity = identify
163
+ alt = alternative_for(identity)
164
+ alt.conversion! identity
165
+ check_completion!
166
+ end
121
167
  end
122
168
 
169
+
170
+ # -- Testing --
171
+
123
172
  # Forces this experiment to use a particular alternative. Useful for
124
173
  # tests, e.g.
125
174
  #
@@ -142,15 +191,78 @@ module Vanity
142
191
  Vanity.context.session[:vanity][id] = alternative.id
143
192
  end
144
193
 
194
+
195
+ # -- Reporting --
196
+
197
+ def report
198
+ alts = alternatives.map { |alt|
199
+ "<dt>Option #{(65 + alt.id).chr}</dt><dd><code>#{CGI.escape_html alt.value.inspect}</code> viewed #{alt.participants} times, converted #{alt.conversions}, rate #{alt.conversion_rate}, z_score #{alt.z_score}, confidence #{alt.confidence}<dd>"
200
+ }
201
+ %{<dl class="data">#{alts.join}</dl>}
202
+ end
203
+
145
204
  def humanize
146
205
  "A/B Test"
147
206
  end
148
207
 
208
+
209
+ # -- Completion --
210
+
211
+ # Defines how the experiment can choose the optimal outcome on completion.
212
+ #
213
+ # The default implementation looks for the best (highest conversion rate)
214
+ # alternative. If it's certain (95% or more) that this alternative is
215
+ # better than the first alternative, it switches to that one. If it has
216
+ # no such certainty, it starts using the first alternative exclusively.
217
+ #
218
+ # The default implementation reads like this:
219
+ # outcome_is do
220
+ # highest = alternatives.sort.last
221
+ # highest.confidence >= 95 ? highest ? alternatives.first
222
+ # end
223
+ def outcome_is(&block)
224
+ raise ArgumentError, "Missing block" unless block
225
+ raise "outcome_is already called on this experiment" if @outcome_is
226
+ @outcome_is = block
227
+ end
228
+
229
+ # Alternative chosen when this experiment was completed.
230
+ def outcome
231
+ outcome = redis.get(key("outcome"))
232
+ outcome && alternatives[outcome.to_i]
233
+ end
234
+
235
+ def complete! #:nodoc:
236
+ super
237
+ if @outcome_is
238
+ begin
239
+ outcome = alternatives.find_index(@outcome_is.call)
240
+ rescue
241
+ # TODO: logging
242
+ end
243
+ end
244
+ unless outcome
245
+ highest = alternatives.sort.last rescue nil
246
+ outcome = highest && highest.confidence >= 95 ? highest.id : 0
247
+ end
248
+ # TODO: logging
249
+ redis.setnx key("outcome"), outcome
250
+ end
251
+
252
+
253
+ # -- Store/validate --
254
+
149
255
  def save #:nodoc:
150
256
  fail "Experiment #{name} needs at least two alternatives" unless alternatives.count >= 2
151
257
  super
152
258
  end
153
259
 
260
+ def destroy #:nodoc:
261
+ redis.del key(:outcome)
262
+ alternatives.each(&:destroy)
263
+ super
264
+ end
265
+
154
266
  private
155
267
 
156
268
  # Chooses an alternative for the identity and returns its index. This
@@ -164,7 +276,14 @@ module Vanity
164
276
  alternatives[index]
165
277
  end
166
278
 
279
+ begin
280
+ a = 0
281
+ # Returns array of [z-score, percentage]
282
+ norm_dist = (-5.0..3.1).step(0.01).map { |x| [x, a += 1 / Math.sqrt(2 * Math::PI) * Math::E ** (-x ** 2 / 2)] }
283
+ # We're really only interested in 90%, 95%, 99% and 99.9%.
284
+ Z_TO_CONFIDENCE = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |x,a| a >= pct }.first, pct] }.reverse
285
+ end
286
+
167
287
  end
168
288
  end
169
289
  end
170
-
@@ -18,8 +18,8 @@ module Vanity
18
18
  @playground = playground
19
19
  @id, @name = id.to_sym, name
20
20
  @namespace = "#{@playground.namespace}:#{@id}"
21
- created = redis.get(key(:created_at)) || (redis.setnx(key(:created_at), Time.now.to_i) ; redis.get(key(:created_at)))
22
- @created_at = Time.at(created.to_i)
21
+ redis.setnx key(:created_at), Time.now.to_i
22
+ @created_at = Time.at(redis.get(key(:created_at)).to_i)
23
23
  @identify_block = ->(context){ context.vanity_identity }
24
24
  end
25
25
 
@@ -31,26 +31,10 @@ module Vanity
31
31
 
32
32
  # Experiment creation timestamp.
33
33
  attr_reader :created_at
34
-
35
- # Sets or returns description. For example
36
- # experiment :simple do
37
- # description "Simple experiment"
38
- # end
39
- #
40
- # puts "Just defined: " + experiment(:simple).description
41
- def description(text = nil)
42
- @description = text if text
43
- @description
44
- end
45
-
46
- def report
47
- fail "Implement me"
48
- end
49
-
50
- # Called to save the experiment definition.
51
- def save #:nodoc:
52
- end
53
34
 
35
+ # Experiment completion timestamp.
36
+ attr_reader :completed_at
37
+
54
38
  # Call this method with no argument or block to return an identity. Call
55
39
  # this method with a block to define how to obtain an identity for the
56
40
  # current experiment.
@@ -80,6 +64,70 @@ module Vanity
80
64
  end
81
65
  end
82
66
 
67
+
68
+ # -- Reporting --
69
+
70
+ # Sets or returns description. For example
71
+ # experiment :simple do
72
+ # description "Simple experiment"
73
+ # end
74
+ #
75
+ # puts "Just defined: " + experiment(:simple).description
76
+ def description(text = nil)
77
+ @description = text if text
78
+ @description
79
+ end
80
+
81
+ def report
82
+ fail "Implement me"
83
+ end
84
+
85
+
86
+ # -- Experiment completion --
87
+
88
+ # Define experiment completion condition. For example:
89
+ # complete_if do
90
+ # alternatives.all? { |alt| alt.participants >= 100 } &&
91
+ # alternatives.any? { |alt| alt.confidence >= 0.95 }
92
+ # end
93
+ def complete_if(&block)
94
+ raise ArgumentError, "Missing block" unless block
95
+ raise "complete_if already called on this experiment" if @complete_block
96
+ @complete_block = block
97
+ end
98
+
99
+ # Derived classes call this after state changes that may lead to
100
+ # experiment completing.
101
+ def check_completion!
102
+ if @complete_block
103
+ begin
104
+ complete! if @complete_block.call
105
+ rescue
106
+ # TODO: logging
107
+ end
108
+ end
109
+ end
110
+ protected :check_completion!
111
+
112
+ # Force experiment to complete.
113
+ def complete!
114
+ redis.setnx key(:completed_at), Time.now.to_i
115
+ # TODO: logging
116
+ end
117
+
118
+ # Time stamp when experiment was completed.
119
+ def completed_at
120
+ Time.at(redis.get(key(:completed_at)).to_i)
121
+ end
122
+
123
+ # Returns true if experiment active, false if completed.
124
+ def active?
125
+ redis.get(key(:completed_at)).nil?
126
+ end
127
+
128
+
129
+ # -- Store/validate --
130
+
83
131
  # Returns key for this experiment, or with an argument, return a key
84
132
  # using the experiment as the namespace. Examples:
85
133
  # key => "vanity:experiments:green_button"
@@ -92,6 +140,17 @@ module Vanity
92
140
  def redis #:nodoc:
93
141
  @playground.redis
94
142
  end
143
+
144
+ # Called to save the experiment definition.
145
+ def save #:nodoc:
146
+ end
147
+
148
+ # Get rid of all experiment data.
149
+ def destroy
150
+ redis.del key(:created_at)
151
+ redis.del key(:completed_at)
152
+ end
153
+
95
154
  end
96
155
  end
97
156
  end
data/test/ab_test_test.rb CHANGED
@@ -13,7 +13,7 @@ class AbTestController < ActionController::Base
13
13
  end
14
14
 
15
15
  def test_capture
16
- render file: File.join(File.dirname(__FILE__), "ab_test_template.erb")
16
+ render inline: "<% ab_test :simple_ab do |value| %><%= value %><% end %>"
17
17
  end
18
18
 
19
19
  def goal
@@ -29,7 +29,8 @@ class AbTestTest < ActionController::TestCase
29
29
  experiment(:simple_ab) { }
30
30
  end
31
31
 
32
- # Experiment definition
32
+
33
+ # -- Experiment definition --
33
34
 
34
35
  def uses_ab_test_when_type_is_ab_test
35
36
  experiment(:ab, type: :ab_test) { }
@@ -52,7 +53,8 @@ class AbTestTest < ActionController::TestCase
52
53
  end
53
54
  end
54
55
 
55
- # Running experiment
56
+
57
+ # -- Running experiment --
56
58
 
57
59
  def returns_the_same_alternative_consistently
58
60
  experiment :foobar do
@@ -122,7 +124,7 @@ class AbTestTest < ActionController::TestCase
122
124
  end
123
125
 
124
126
 
125
- # A/B helper methods
127
+ # -- A/B helper methods --
126
128
 
127
129
  def test_fail_if_no_experiment
128
130
  new_playground
@@ -167,7 +169,7 @@ class AbTestTest < ActionController::TestCase
167
169
  end
168
170
 
169
171
 
170
- # Testing with tests
172
+ # -- Testing with tests --
171
173
 
172
174
  def test_with_given_choice
173
175
  100.times do
@@ -177,8 +179,8 @@ class AbTestTest < ActionController::TestCase
177
179
  post :goal
178
180
  end
179
181
  alts = experiment(:simple_ab).alternatives
180
- assert_equal [100,0], alts.map { |alt| alt.participants }
181
- assert_equal [100,0], alts.map { |alt| alt.conversions }
182
+ assert_equal [0,100], alts.map { |alt| alt.participants }
183
+ assert_equal [0,100], alts.map { |alt| alt.conversions }
182
184
  end
183
185
 
184
186
  def test_which_chooses_non_existent_alternative
@@ -187,4 +189,162 @@ class AbTestTest < ActionController::TestCase
187
189
  end
188
190
  end
189
191
 
192
+
193
+ # -- Z-score --
194
+
195
+ def test_z_score
196
+ experiment :abcd do
197
+ alternatives :a, :b, :c, :d
198
+ end
199
+ alts = experiment(:abcd).alternatives
200
+ # participating, conversions, rate, z-score
201
+ # Control: 182 35 19.23% N/A
202
+ 182.times { |i| alts[0].participating!(i) }
203
+ 35.times { |i| alts[0].conversion!(i) }
204
+ # Treatment A: 180 45 25.00% 1.33
205
+ 180.times { |i| alts[1].participating!(i + 200) }
206
+ 45.times { |i| alts[1].conversion!(i + 200) }
207
+ # Treatment B: 189 28 14.81% -1.13
208
+ 189.times { |i| alts[2].participating!(i + 400) }
209
+ 28.times { |i| alts[2].conversion!(i + 400) }
210
+ # Treatment C: 188 61 32.45% 2.94
211
+ 188.times { |i| alts[3].participating!(i + 600) }
212
+ 61.times { |i| alts[3].conversion!(i + 600) }
213
+
214
+ z_scores = alts.map { |alt| sprintf("%4.2f", alt.z_score) }
215
+ assert_equal %w{0.00 1.33 -1.13 2.94}, z_scores
216
+
217
+ confidences = alts.map { |alt| alt.confidence }
218
+ assert_equal [0, 90, 0, 99], confidences
219
+ end
220
+
221
+
222
+ # -- Completion --
223
+
224
+ def test_completion_if
225
+ experiment :simple do
226
+ identify { rand }
227
+ complete_if { true }
228
+ end
229
+ experiment(:simple).choose
230
+ refute experiment(:simple).active?
231
+ end
232
+
233
+ def test_completion_if_fails
234
+ experiment :simple do
235
+ identify { rand }
236
+ complete_if { fail }
237
+ end
238
+ experiment(:simple).choose
239
+ assert experiment(:simple).active?
240
+ end
241
+
242
+ def test_completion
243
+ ids = Array.new(100) { |i| i.to_s }.shuffle
244
+ experiment :simple do
245
+ identify { ids.pop }
246
+ complete_if { alternatives.map(&:participants).sum >= 100 }
247
+ end
248
+ 99.times do |i|
249
+ experiment(:simple).choose
250
+ assert experiment(:simple).active?
251
+ end
252
+
253
+ experiment(:simple).choose
254
+ refute experiment(:simple).active?
255
+ end
256
+
257
+ def test_ab_methods_after_completion
258
+ ids = Array.new(200) { |i| i.to_s }.shuffle
259
+ test = self
260
+ experiment :simple do
261
+ identify { test.identity ||= ids.pop }
262
+ complete_if { alternatives.map(&:participants).sum >= 100 }
263
+ outcome_is { alternatives[1] }
264
+ end
265
+ # Run experiment to completion (100 participants)
266
+ results = Set.new
267
+ 100.times do
268
+ test.identity = nil
269
+ results << experiment(:simple).choose
270
+ experiment(:simple).conversion!
271
+ end
272
+ assert results.include?(true) && results.include?(false)
273
+ refute experiment(:simple).active?
274
+
275
+ # Test that we always get the same choice (true)
276
+ 100.times do
277
+ test.identity = nil
278
+ assert_equal true, experiment(:simple).choose
279
+ experiment(:simple).conversion!
280
+ end
281
+ # We don't get to count the 100 participant's conversion, but that's ok.
282
+ assert_equal 99, experiment(:simple).alternatives.map(&:converted).sum
283
+ assert_equal 99, experiment(:simple).alternatives.map(&:conversions).sum
284
+ end
285
+
286
+
287
+ # -- Outcome --
288
+
289
+ def test_completion_outcome
290
+ experiment :quick do
291
+ outcome_is { alternatives[1] }
292
+ end
293
+ experiment(:quick).complete!
294
+ assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
295
+ end
296
+
297
+ def test_outcome_is_returns_nil
298
+ experiment :quick do
299
+ outcome_is { nil }
300
+ end
301
+ experiment(:quick).complete!
302
+ assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
303
+ end
304
+
305
+ def test_outcome_is_returns_something_else
306
+ experiment :quick do
307
+ outcome_is { "error" }
308
+ end
309
+ experiment(:quick).complete!
310
+ assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
311
+ end
312
+
313
+ def test_outcome_is_fails
314
+ experiment :quick do
315
+ outcome_is { fail }
316
+ end
317
+ experiment(:quick).complete!
318
+ assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
319
+ end
320
+
321
+ def test_outcome_choosing_best_alternative
322
+ experiment :quick do
323
+ end
324
+ 2.times do |i|
325
+ experiment(:quick).alternatives[0].participating!(i)
326
+ end
327
+ 10.times do |i|
328
+ experiment(:quick).alternatives[1].participating!(i)
329
+ experiment(:quick).alternatives[1].conversion!(i)
330
+ end
331
+ experiment(:quick).complete!
332
+ assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
333
+ end
334
+
335
+ def test_outcome_choosing_first_alternative
336
+ experiment :quick do
337
+ end
338
+ 8.times do |i|
339
+ experiment(:quick).alternatives[0].participating!(i)
340
+ experiment(:quick).alternatives[0].conversion!(i)
341
+ end
342
+ 7.times do |i|
343
+ experiment(:quick).alternatives[1].participating!(i)
344
+ experiment(:quick).alternatives[1].conversion!(i)
345
+ end
346
+ experiment(:quick).complete!
347
+ assert_equal experiment(:quick).alternatives[0], experiment(:quick).outcome
348
+ end
349
+
190
350
  end
@@ -27,11 +27,13 @@ class ExperimentTest < MiniTest::Spec
27
27
  end
28
28
 
29
29
  it "keeps creation timestamp across definitions" do
30
- early = Time.now - 1.day
30
+ early, late = Time.now - 1.day, Time.now
31
31
  Time.expects(:now).once.returns(early)
32
32
  experiment(:simple) { }
33
33
  assert_equal early.to_i, experiment(:simple).created_at.to_i
34
+
34
35
  new_playground
36
+ Time.expects(:now).once.returns(late)
35
37
  experiment(:simple) { }
36
38
  assert_equal early.to_i, experiment(:simple).created_at.to_i
37
39
  end
@@ -42,4 +44,5 @@ class ExperimentTest < MiniTest::Spec
42
44
  end
43
45
  assert_equal "Simple experiment", experiment(:simple).description
44
46
  end
47
+
45
48
  end
data/vanity.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |spec|
2
2
  spec.name = "vanity"
3
- spec.version = "0.2.0"
3
+ spec.version = "0.2.1"
4
4
  spec.author = "Assaf Arkin"
5
5
  spec.email = "assaf@labnotes.org"
6
6
  spec.homepage = "http://github.com/assaf/vanity"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vanity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Assaf Arkin
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2009-11-10 00:00:00 -08:00
12
+ date: 2009-11-11 00:00:00 -08:00
13
13
  default_executable:
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
@@ -40,7 +40,6 @@ files:
40
40
  - lib/vanity/rails.rb
41
41
  - lib/vanity/report.erb
42
42
  - lib/vanity.rb
43
- - test/ab_test_template.erb
44
43
  - test/ab_test_test.rb
45
44
  - test/experiment_test.rb
46
45
  - test/playground_test.rb
@@ -56,7 +55,7 @@ licenses: []
56
55
  post_install_message:
57
56
  rdoc_options:
58
57
  - --title
59
- - Vanity 0.2.0
58
+ - Vanity 0.2.1
60
59
  - --main
61
60
  - README.rdoc
62
61
  - --webcvs
@@ -1,3 +0,0 @@
1
- <% ab_test :simple_ab do |value| %>
2
- <%= value %>
3
- <% end %>