vanity 0.2.0 → 0.2.1

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