vanity 0.2.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,3 +1,12 @@
1
+ 0.2.2 (2009-11-12)
2
+ * Added: vanity binary, with single command for generating a report.
3
+ * Added: return alternative by value from experiment.alternative(val) method.
4
+ * Added: reset an experiment by calling reset!.
5
+ * Added: experiment alternative name (option 1, option 2, etc).
6
+ * Added: new scoring algorithm: use experiment.score instead of
7
+ alternative.z_score/confidence.
8
+ * Added: experiment.conclusion for plain English results.
9
+
1
10
  0.2.1 (2009-11-11)
2
11
  * Added: z-score and confidence level for A/B test alternatives.
3
12
  * Added: test auto-completion and auto-outcome (complete_it, outcome_is).
data/bin/vanity ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env ruby
2
+ path = File.expand_path("../lib", File.dirname(__FILE__))
3
+ $LOAD_PATH.unshift path unless $LOAD_PATH.include?(path)
4
+
5
+ require "vanity"
6
+ require "optparse"
7
+
8
+ playground = Vanity.playground
9
+ options = Struct.new(:output).new
10
+ OptionParser.new("", 24, " ") do |opts|
11
+ opts.banner = "Usage: #{File.basename($0)} [options]\n"
12
+
13
+ opts.separator ""
14
+ opts.separator "General options:"
15
+ opts.on("--path PATH", "Path to experiments directory (default: #{playground.load_path})") { |v| playground.load_path = v }
16
+ opts.on("--output FILE", "Write report to this file (default: stdout)") { |v| options.output = v }
17
+
18
+ opts.separator ""
19
+ opts.separator "Redis options:"
20
+ opts.on("--host HOST", "Redis server host (default: #{playground.host})") { |v| playground.host = v }
21
+ opts.on("--port PORT", "Redis server port (default: #{playground.port})") { |v| playground.port = v }
22
+ opts.on("--db DB", "Redis database (default: #{playground.db})") { |v| playground.db = v }
23
+ opts.on("--password PWD", "Redis database password") { |v| playground.password = v }
24
+ opts.on("--namespace NS", "Redis namespace (default: #{playground.namespace})") { |v| playground.namespace = v }
25
+
26
+ opts.separator ""
27
+ opts.separator "Common options:"
28
+ opts.on_tail "-h", "-H", "--help", "Show this message" do
29
+ puts opts.to_s.gsub(/^.*DEPRECATED.*$/s, '')
30
+ exit
31
+ end
32
+ opts.on_tail "-v", "--version", "Show version" do
33
+ puts "Vanity #{Vanity::Version::STRING}"
34
+ exit
35
+ end
36
+ end.parse!(ARGV)
37
+
38
+ cmds = ARGV.empty? ? ["report"] : ARGV
39
+ cmds.each do |cmd|
40
+ case cmd
41
+ when "report"
42
+ Vanity::Commands.report options.output
43
+ else fail "No such command: #{cmd}"
44
+ end
45
+ end
@@ -0,0 +1,27 @@
1
+ require "erb"
2
+ require "cgi"
3
+
4
+ module Vanity
5
+ module Commands
6
+ class << self
7
+
8
+ # Generate a report with all available tests. Outputs to the named file,
9
+ # or stdout with no arguments.
10
+ def report(output = nil)
11
+ require "erb"
12
+ erb = ERB.new(File.read("lib/vanity/report.erb"), nil, '<')
13
+ experiments = Vanity.playground.experiments
14
+ html = erb.result(binding)
15
+ if output
16
+ File.open output, 'w' do |file|
17
+ file.write html
18
+ end
19
+ puts "New report available in #{output}"
20
+ else
21
+ $stdout.write html
22
+ end
23
+ end
24
+
25
+ end
26
+ end
27
+ end
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), "commands/report")
@@ -7,12 +7,16 @@ module Vanity
7
7
  def initialize(experiment, id, value) #:nodoc:
8
8
  @experiment = experiment
9
9
  @id = id
10
+ @name = "option #{(@id + 1)}"
10
11
  @value = value
11
12
  end
12
13
 
13
14
  # Alternative id, only unique for this experiment.
14
15
  attr_reader :id
15
16
 
17
+ # Alternative name (option A, option B, etc).
18
+ attr_reader :name
19
+
16
20
  # Alternative value.
17
21
  attr_reader :value
18
22
 
@@ -28,12 +32,13 @@ module Vanity
28
32
 
29
33
  # Number of conversions for this alternative (same participant may be counted more than once).
30
34
  def conversions
31
- redis.get(key("conversions")).to_i
35
+ redis[key("conversions")].to_i
32
36
  end
33
37
 
34
38
  # Conversion rate calculated as converted/participants.
35
39
  def conversion_rate
36
- converted.to_f / participants.to_f
40
+ c, p = converted.to_f, participants.to_f
41
+ p > 0 ? c/p : 0.0
37
42
  end
38
43
 
39
44
  def <=>(other)
@@ -51,33 +56,20 @@ module Vanity
51
56
  end
52
57
  end
53
58
 
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
59
  def destroy #:nodoc:
76
60
  redis.del key("participants")
77
61
  redis.del key("converted")
78
62
  redis.del key("conversions")
79
63
  end
80
64
 
65
+ def to_s #:nodoc:
66
+ name
67
+ end
68
+
69
+ def inspect #:nodoc:
70
+ "#{name}: #{value} #{converted}/#{participants}"
71
+ end
72
+
81
73
  protected
82
74
 
83
75
  def key(name)
@@ -97,6 +89,15 @@ module Vanity
97
89
 
98
90
  # The meat.
99
91
  class AbTest < Base
92
+ class << self
93
+
94
+ def confidence(score) #:nodoc:
95
+ score = score.abs
96
+ confidence = AbTest::Z_TO_CONFIDENCE.find { |z,p| score >= z }
97
+ confidence ? confidence.last : 0
98
+ end
99
+ end
100
+
100
101
  def initialize(*args) #:nodoc:
101
102
  super
102
103
  end
@@ -125,6 +126,11 @@ module Vanity
125
126
  alternatives
126
127
  end
127
128
 
129
+ # Returns an Alternative with the specified value.
130
+ def alternative(value)
131
+ alternatives.find { |alt| alt.value == value }
132
+ end
133
+
128
134
  # Sets this test to two alternatives: false and true.
129
135
  def false_true
130
136
  alternatives false, true
@@ -194,11 +200,84 @@ module Vanity
194
200
 
195
201
  # -- Reporting --
196
202
 
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>}
203
+ # Returns an object with the following attributes:
204
+ # [:alts] List of alternatives as structures (see below).
205
+ # [:best] Best alternative.
206
+ # [:base] Second best alternative.
207
+ # [:choice] Choice alterntive, either selected outcome or best alternative (with confidence).
208
+ #
209
+ # Each alternative is an object with the following attributes:
210
+ # [:id] Identifier.
211
+ # [:conv] Conversion rate (0.0 to 1.0, rounded to 3 places).
212
+ # [:pop] Population size (participants).
213
+ # [:diff] Difference from least performant altenative (percentage).
214
+ # [:z] Z-score compared to base (above).
215
+ # [:conf] Confidence based on z-score (0, 90, 95, 99, 99.9).
216
+ def score
217
+ struct = Struct.new(:id, :conv, :pop, :diff, :z, :conf)
218
+ alts = alternatives.map { |alt| struct.new(alt.id, alt.conversion_rate.round(3), alt.participants) }
219
+ # sort by conversion rate to find second best and 2nd best
220
+ sorted = alts.sort_by(&:conv)
221
+ base = sorted[-2]
222
+ # calculate z-score
223
+ pc = base.conv
224
+ nc = base.pop
225
+ alts.each do |alt|
226
+ p = alt.conv
227
+ n = alt.pop
228
+ alt.z = (p - pc) / ((p * (1-p)/n) + (pc * (1-pc)/nc)).abs ** 0.5
229
+ alt.conf = AbTest.confidence(alt.z)
230
+ end
231
+ # difference is measured from least performant
232
+ if least = sorted.find { |alt| alt.conv > 0 }
233
+ alts.each do |alt|
234
+ alt.diff = (alt.conv - least.conv) / least.conv * 100 if alt.conv > least.conv
235
+ end
236
+ end
237
+ # best alternative is one with highest conversion rate (best shot).
238
+ # choice alternative can only pick best if we have high confidence (>90%).
239
+ best = sorted.last if sorted.last.conv > 0
240
+ choice = outcome ? alts[outcome.id] : (best && best.conf >= 90 ? best : nil)
241
+ Struct.new(:alts, :best, :base, :choice).new(alts, best, base, choice)
242
+ end
243
+
244
+ # Use the score returned by #score to derive a conclusion. Returns an
245
+ # array of claims.
246
+ def conclusion(score = score)
247
+ claims = []
248
+ # find name form alt structure returned from score
249
+ name = ->(alt){ alternatives[alt.id].name }
250
+ # only interested in sorted alternatives with conversion
251
+ sorted = score.alts.select { |alt| alt.conv > 0.0 }.sort_by(&:conv).reverse
252
+ if sorted.size > 1
253
+ # start with alternatives that have conversion, from best to worst,
254
+ # then alternatives with no conversion.
255
+ sorted |= score.alts
256
+ # we want a result that's clearly better than 2nd best.
257
+ best, second = sorted[0], sorted[1]
258
+ if best.conv > second.conv
259
+ diff = ((best.conv - second.conv) / second.conv * 100).round
260
+ better = " (%d%% better than %s)" % [diff, name[second]] if diff > 0
261
+ claims << "The best choice is %s: it converted at %.1f%%%s." % [name[best], best.conv * 100, better]
262
+ if best.conf >= 90
263
+ claims << "With %d%% probability this result is statistically significant." % score.best.conf
264
+ else
265
+ claims << "This result is not statistically significant, suggest you continue this experiment."
266
+ end
267
+ sorted.delete best
268
+ end
269
+ sorted.each do |alt|
270
+ if alt.conv > 0.0
271
+ claims << "%s converted at %.1f%%." % [name[alt].capitalize, alt.conv * 100]
272
+ else
273
+ claims << "%s did not convert." % name[alt].capitalize
274
+ end
275
+ end
276
+ else
277
+ claims << "This experiment did not run long enough to find a clear winner."
278
+ end
279
+ claims << "#{name[score.choice].capitalize} selected as the best alternative." if score.choice
280
+ claims
202
281
  end
203
282
 
204
283
  def humanize
@@ -228,7 +307,7 @@ module Vanity
228
307
 
229
308
  # Alternative chosen when this experiment was completed.
230
309
  def outcome
231
- outcome = redis.get(key("outcome"))
310
+ outcome = redis[key("outcome")]
232
311
  outcome && alternatives[outcome.to_i]
233
312
  end
234
313
 
@@ -242,8 +321,8 @@ module Vanity
242
321
  end
243
322
  end
244
323
  unless outcome
245
- highest = alternatives.sort.last rescue nil
246
- outcome = highest && highest.confidence >= 95 ? highest.id : 0
324
+ best = score.best
325
+ outcome = best.id if best
247
326
  end
248
327
  # TODO: logging
249
328
  redis.setnx key("outcome"), outcome
@@ -257,6 +336,12 @@ module Vanity
257
336
  super
258
337
  end
259
338
 
339
+ def reset! #:nodoc:
340
+ redis.del key(:outcome)
341
+ alternatives.each(&:destroy)
342
+ super
343
+ end
344
+
260
345
  def destroy #:nodoc:
261
346
  redis.del key(:outcome)
262
347
  alternatives.each(&:destroy)
@@ -19,7 +19,7 @@ module Vanity
19
19
  @id, @name = id.to_sym, name
20
20
  @namespace = "#{@playground.namespace}:#{@id}"
21
21
  redis.setnx key(:created_at), Time.now.to_i
22
- @created_at = Time.at(redis.get(key(:created_at)).to_i)
22
+ @created_at = Time.at(redis[key(:created_at)].to_i)
23
23
  @identify_block = ->(context){ context.vanity_identity }
24
24
  end
25
25
 
@@ -34,6 +34,11 @@ module Vanity
34
34
 
35
35
  # Experiment completion timestamp.
36
36
  attr_reader :completed_at
37
+
38
+ # Returns the type of this class as a symbol (e.g. ab_test).
39
+ def type
40
+ self.class.type
41
+ end
37
42
 
38
43
  # Call this method with no argument or block to return an identity. Call
39
44
  # this method with a block to define how to obtain an identity for the
@@ -117,12 +122,13 @@ module Vanity
117
122
 
118
123
  # Time stamp when experiment was completed.
119
124
  def completed_at
120
- Time.at(redis.get(key(:completed_at)).to_i)
125
+ time = redis[key(:completed_at)]
126
+ time && Time.at(time.to_i)
121
127
  end
122
128
 
123
129
  # Returns true if experiment active, false if completed.
124
130
  def active?
125
- redis.get(key(:completed_at)).nil?
131
+ redis[key(:completed_at)].nil?
126
132
  end
127
133
 
128
134
 
@@ -145,6 +151,13 @@ module Vanity
145
151
  def save #:nodoc:
146
152
  end
147
153
 
154
+ # Reset experiment.
155
+ def reset!
156
+ @created_at = Time.now
157
+ redis[key(:created_at)] = @created_at.to_i
158
+ redis.del key(:completed_at)
159
+ end
160
+
148
161
  # Get rid of all experiment data.
149
162
  def destroy
150
163
  redis.del key(:created_at)
@@ -15,6 +15,7 @@ module Vanity
15
15
  # Created new Playground. Unless you need to, use the global Vanity.playground.
16
16
  def initialize
17
17
  @experiments = {}
18
+ @host, @port, @db = "127.0.0.1", 6379, 0
18
19
  @namespace = "vanity:#{Vanity::Version::MAJOR}"
19
20
  @load_path = "experiments"
20
21
  end
@@ -15,7 +15,7 @@
15
15
  <li class="experiment" id="experiment_<%= CGI.escape exp.id.to_s %>">
16
16
  <h3><%= CGI.escape_html exp.name %></h3>
17
17
  <blockquote><%= CGI.escape_html exp.description.to_s %></blockquote>
18
- <%= exp.report %>
18
+ <%= exp.conclusion.join(" ") %>
19
19
  <p class="meta"><%= exp.humanize %> started <%= exp.created_at.strftime("%a, %b %-d %Y") %></p>
20
20
  </li>
21
21
  <% end %>
data/lib/vanity.rb CHANGED
@@ -21,3 +21,4 @@ require File.join(File.dirname(__FILE__), "vanity/playground")
21
21
  require File.join(File.dirname(__FILE__), "vanity/experiment/base")
22
22
  require File.join(File.dirname(__FILE__), "vanity/experiment/ab_test")
23
23
  require File.join(File.dirname(__FILE__), "vanity/rails") if defined?(Rails)
24
+ Vanity.autoload :Commands, File.join(File.dirname(__FILE__), "vanity/commands")
data/test/ab_test_test.rb CHANGED
@@ -32,12 +32,12 @@ class AbTestTest < ActionController::TestCase
32
32
 
33
33
  # -- Experiment definition --
34
34
 
35
- def uses_ab_test_when_type_is_ab_test
35
+ def test_uses_ab_test_when_type_is_ab_test
36
36
  experiment(:ab, type: :ab_test) { }
37
37
  assert_instance_of Vanity::Experiment::AbTest, experiment(:ab)
38
38
  end
39
39
 
40
- def requires_at_least_two_alternatives_per_experiment
40
+ def test_requires_at_least_two_alternatives_per_experiment
41
41
  assert_raises RuntimeError do
42
42
  experiment :none, type: :ab_test do
43
43
  alternatives []
@@ -52,11 +52,27 @@ class AbTestTest < ActionController::TestCase
52
52
  alternatives "foo", "bar"
53
53
  end
54
54
  end
55
+
56
+ def test_returning_alternative_by_value
57
+ experiment :abcd do
58
+ alternatives :a, :b, :c, :d
59
+ end
60
+ assert_equal experiment(:abcd).alternatives[1], experiment(:abcd).alternative(:b)
61
+ assert_equal experiment(:abcd).alternatives[3], experiment(:abcd).alternative(:d)
62
+ end
63
+
64
+ def test_alternative_name
65
+ experiment :abcd do
66
+ alternatives :a, :b
67
+ end
68
+ assert_equal "option 1", experiment(:abcd).alternative(:a).name
69
+ assert_equal "option 2", experiment(:abcd).alternative(:b).name
70
+ end
55
71
 
56
72
 
57
73
  # -- Running experiment --
58
74
 
59
- def returns_the_same_alternative_consistently
75
+ def test_returns_the_same_alternative_consistently
60
76
  experiment :foobar do
61
77
  alternatives "foo", "bar"
62
78
  identify { "6e98ec" }
@@ -68,7 +84,7 @@ class AbTestTest < ActionController::TestCase
68
84
  end
69
85
  end
70
86
 
71
- def returns_different_alternatives_for_each_participant
87
+ def test_returns_different_alternatives_for_each_participant
72
88
  experiment :foobar do
73
89
  alternatives "foo", "bar"
74
90
  identify { rand(1000).to_s }
@@ -78,7 +94,7 @@ class AbTestTest < ActionController::TestCase
78
94
  assert_in_delta alts.select { |a| a == "foo" }.count, 500, 100 # this may fail, such is propability
79
95
  end
80
96
 
81
- def records_all_participants_in_each_alternative
97
+ def test_records_all_participants_in_each_alternative
82
98
  ids = (Array.new(200) { |i| i.to_s } * 5).shuffle
83
99
  experiment :foobar do
84
100
  alternatives "foo", "bar"
@@ -90,7 +106,7 @@ class AbTestTest < ActionController::TestCase
90
106
  assert_in_delta alts.first.participants, 100, 20
91
107
  end
92
108
 
93
- def records_each_converted_participant_only_once
109
+ def test_records_each_converted_participant_only_once
94
110
  ids = (Array.new(100) { |i| i.to_s } * 5).shuffle
95
111
  test = self
96
112
  experiment :foobar do
@@ -123,6 +139,26 @@ class AbTestTest < ActionController::TestCase
123
139
  assert_equal 100, alts.inject(0) { |t,a| t + a.converted }
124
140
  end
125
141
 
142
+ def test_reset_experiment
143
+ experiment :simple do
144
+ identify { "me" }
145
+ complete_if { alternatives.map(&:converted).sum >= 1 }
146
+ outcome_is { alternative(true) }
147
+ end
148
+ experiment(:simple).choose
149
+ experiment(:simple).conversion!
150
+ refute experiment(:simple).active?
151
+ assert_equal true, experiment(:simple).outcome.value
152
+
153
+ experiment(:simple).reset!
154
+ assert experiment(:simple).active?
155
+ assert_nil experiment(:simple).outcome
156
+ assert_nil experiment(:simple).completed_at
157
+ assert_equal 0, experiment(:simple).alternatives.map(&:participants).sum
158
+ assert_equal 0, experiment(:simple).alternatives.map(&:conversions).sum
159
+ assert_equal 0, experiment(:simple).alternatives.map(&:converted).sum
160
+ end
161
+
126
162
 
127
163
  # -- A/B helper methods --
128
164
 
@@ -190,34 +226,187 @@ class AbTestTest < ActionController::TestCase
190
226
  end
191
227
 
192
228
 
193
- # -- Z-score --
229
+ # -- Scoring --
194
230
 
195
- def test_z_score
196
- experiment :abcd do
197
- alternatives :a, :b, :c, :d
198
- end
199
- alts = experiment(:abcd).alternatives
231
+ def test_scoring
232
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
200
233
  # participating, conversions, rate, z-score
201
234
  # Control: 182 35 19.23% N/A
202
- 182.times { |i| alts[0].participating!(i) }
203
- 35.times { |i| alts[0].conversion!(i) }
235
+ 182.times { |i| experiment(:abcd).alternative(:a).participating!(i) }
236
+ 35.times { |i| experiment(:abcd).alternative(:a).conversion!(i) }
204
237
  # 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) }
238
+ 180.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
239
+ 45.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
240
+ # treatment B: 189 28 14.81% -1.13
241
+ 189.times { |i| experiment(:abcd).alternative(:c).participating!(i) }
242
+ 28.times { |i| experiment(:abcd).alternative(:c).conversion!(i) }
243
+ # treatment C: 188 61 32.45% 2.94
244
+ 188.times { |i| experiment(:abcd).alternative(:d).participating!(i) }
245
+ 61.times { |i| experiment(:abcd).alternative(:d).conversion!(i) }
246
+
247
+ z_scores = experiment(:abcd).score.alts.map { |alt| "%.2f" % alt.z }
248
+ assert_equal %w{-1.33 0.00 -2.47 1.58}, z_scores
249
+ confidences = experiment(:abcd).score.alts.map(&:conf)
250
+ assert_equal [90, 0, 99, 90], confidences
251
+
252
+ diff = experiment(:abcd).score.alts.map { |alt| alt.diff && alt.diff.round }
253
+ assert_equal [30, 69, nil, 119], diff
254
+ assert_equal 3, experiment(:abcd).score.best.id
255
+ assert_equal 3, experiment(:abcd).score.choice.id
256
+ end
213
257
 
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
258
+ def test_scoring_with_no_performers
259
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
260
+ assert experiment(:abcd).score.alts.all? { |alt| alt.z.nan? }
261
+ assert experiment(:abcd).score.alts.all? { |alt| alt.conf == 0 }
262
+ assert experiment(:abcd).score.alts.all? { |alt| alt.diff.nil? }
263
+ assert_nil experiment(:abcd).score.best
264
+ assert_nil experiment(:abcd).score.choice
265
+ end
216
266
 
217
- confidences = alts.map { |alt| alt.confidence }
218
- assert_equal [0, 90, 0, 99], confidences
267
+ def test_scoring_with_one_performer
268
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
269
+ 10.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
270
+ 8.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
271
+ assert experiment(:abcd).score.alts.all? { |alt| alt.z.nan? }
272
+ assert experiment(:abcd).score.alts.all? { |alt| alt.conf == 0 }
273
+ assert experiment(:abcd).score.alts.all? { |alt| alt.diff.nil? }
274
+ assert 1, experiment(:abcd).score.best.id
275
+ assert_nil experiment(:abcd).score.choice
276
+ end
277
+
278
+ def test_scoring_with_some_performers
279
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
280
+ 10.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
281
+ 8.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
282
+ 12.times { |i| experiment(:abcd).alternative(:d).participating!(i) }
283
+ 5.times { |i| experiment(:abcd).alternative(:d).conversion!(i) }
284
+
285
+ z_scores = experiment(:abcd).score.alts.map { |alt| "%.2f" % alt.z }
286
+ assert_equal %w{NaN 2.01 NaN 0.00}, z_scores
287
+ confidences = experiment(:abcd).score.alts.map(&:conf)
288
+ assert_equal [0, 95, 0, 0], confidences
289
+ diff = experiment(:abcd).score.alts.map { |alt| alt.diff && alt.diff.round }
290
+ assert_equal [nil, 92, nil, nil], diff
291
+ assert_equal 1, experiment(:abcd).score.best.id
292
+ assert_equal 1, experiment(:abcd).score.choice.id
293
+ end
294
+
295
+
296
+ # -- Conclusion --
297
+
298
+ def test_conclusion
299
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
300
+ # participating, conversions, rate, z-score
301
+ # Control: 182 35 19.23% N/A
302
+ 182.times { |i| experiment(:abcd).alternative(:a).participating!(i) }
303
+ 35.times { |i| experiment(:abcd).alternative(:a).conversion!(i) }
304
+ # Treatment A: 180 45 25.00% 1.33
305
+ 180.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
306
+ 45.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
307
+ # treatment B: 189 28 14.81% -1.13
308
+ 189.times { |i| experiment(:abcd).alternative(:c).participating!(i) }
309
+ 28.times { |i| experiment(:abcd).alternative(:c).conversion!(i) }
310
+ # treatment C: 188 61 32.45% 2.94
311
+ 188.times { |i| experiment(:abcd).alternative(:d).participating!(i) }
312
+ 61.times { |i| experiment(:abcd).alternative(:d).conversion!(i) }
313
+
314
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
315
+ The best choice is option 4: it converted at 32.4% (30% better than option 2).
316
+ With 90% probability this result is statistically significant.
317
+ Option 2 converted at 25.0%.
318
+ Option 1 converted at 19.2%.
319
+ Option 3 converted at 14.8%.
320
+ Option 4 selected as the best alternative.
321
+ TEXT
219
322
  end
220
-
323
+
324
+ def test_conclusion_with_some_performers
325
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
326
+ # Treatment A: 180 45 25.00% 1.33
327
+ 180.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
328
+ 45.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
329
+ # treatment C: 188 61 32.45% 2.94
330
+ 188.times { |i| experiment(:abcd).alternative(:d).participating!(i) }
331
+ 61.times { |i| experiment(:abcd).alternative(:d).conversion!(i) }
332
+
333
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
334
+ The best choice is option 4: it converted at 32.4% (30% better than option 2).
335
+ With 90% probability this result is statistically significant.
336
+ Option 2 converted at 25.0%.
337
+ Option 1 did not convert.
338
+ Option 3 did not convert.
339
+ Option 4 selected as the best alternative.
340
+ TEXT
341
+ end
342
+
343
+ def test_conclusion_without_clear_winner
344
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
345
+ # Treatment A: 180 45 25.00% 1.33
346
+ 180.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
347
+ 58.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
348
+ # treatment C: 188 61 32.45% 2.94
349
+ 188.times { |i| experiment(:abcd).alternative(:d).participating!(i) }
350
+ 61.times { |i| experiment(:abcd).alternative(:d).conversion!(i) }
351
+
352
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
353
+ The best choice is option 4: it converted at 32.4% (1% better than option 2).
354
+ This result is not statistically significant, suggest you continue this experiment.
355
+ Option 2 converted at 32.2%.
356
+ Option 1 did not convert.
357
+ Option 3 did not convert.
358
+ TEXT
359
+ end
360
+
361
+ def test_conclusion_without_close_performers
362
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
363
+ # Treatment A: 180 45 25.00% 1.33
364
+ 186.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
365
+ 60.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
366
+ # treatment C: 188 61 32.45% 2.94
367
+ 188.times { |i| experiment(:abcd).alternative(:d).participating!(i) }
368
+ 61.times { |i| experiment(:abcd).alternative(:d).conversion!(i) }
369
+
370
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
371
+ The best choice is option 4: it converted at 32.4%.
372
+ This result is not statistically significant, suggest you continue this experiment.
373
+ Option 2 converted at 32.3%.
374
+ Option 1 did not convert.
375
+ Option 3 did not convert.
376
+ TEXT
377
+ end
378
+
379
+ def test_conclusion_without_equal_performers
380
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
381
+ # Treatment A: 180 45 25.00% 1.33
382
+ 188.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
383
+ 61.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
384
+ # treatment C: 188 61 32.45% 2.94
385
+ 188.times { |i| experiment(:abcd).alternative(:d).participating!(i) }
386
+ 61.times { |i| experiment(:abcd).alternative(:d).conversion!(i) }
387
+
388
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
389
+ Option 4 converted at 32.4%.
390
+ Option 2 converted at 32.4%.
391
+ Option 1 did not convert.
392
+ Option 3 did not convert.
393
+ TEXT
394
+ end
395
+
396
+ def test_conclusion_with_one_performers
397
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
398
+ # Treatment A: 180 45 25.00% 1.33
399
+ 180.times { |i| experiment(:abcd).alternative(:b).participating!(i) }
400
+ 45.times { |i| experiment(:abcd).alternative(:b).conversion!(i) }
401
+
402
+ assert_equal "This experiment did not run long enough to find a clear winner.", experiment(:abcd).conclusion.join("\n")
403
+ end
404
+
405
+ def test_conclusion_with_no_performers
406
+ experiment(:abcd) { alternatives :a, :b, :c, :d }
407
+ assert_equal "This experiment did not run long enough to find a clear winner.", experiment(:abcd).conclusion.join("\n")
408
+ end
409
+
221
410
 
222
411
  # -- Completion --
223
412
 
@@ -332,19 +521,30 @@ class AbTestTest < ActionController::TestCase
332
521
  assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
333
522
  end
334
523
 
335
- def test_outcome_choosing_first_alternative
524
+ def test_outcome_only_performing_alternative
525
+ experiment :quick do
526
+ end
527
+ 2.times do |i|
528
+ experiment(:quick).alternatives[1].participating!(i)
529
+ experiment(:quick).alternatives[1].conversion!(i)
530
+ end
531
+ experiment(:quick).complete!
532
+ assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
533
+ end
534
+
535
+ def test_outcome_choosing_equal_alternatives
336
536
  experiment :quick do
337
537
  end
338
538
  8.times do |i|
339
539
  experiment(:quick).alternatives[0].participating!(i)
340
540
  experiment(:quick).alternatives[0].conversion!(i)
341
541
  end
342
- 7.times do |i|
542
+ 8.times do |i|
343
543
  experiment(:quick).alternatives[1].participating!(i)
344
544
  experiment(:quick).alternatives[1].conversion!(i)
345
545
  end
346
546
  experiment(:quick).complete!
347
- assert_equal experiment(:quick).alternatives[0], experiment(:quick).outcome
547
+ assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
348
548
  end
349
549
 
350
550
  end
@@ -0,0 +1,4 @@
1
+ experiment "Null/ABC" do
2
+ description "Testing A, B, C alternatives against current feature (0)"
3
+ alternatives 0, :a, :b, :c
4
+ 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.1"
3
+ spec.version = "0.2.2"
4
4
  spec.author = "Assaf Arkin"
5
5
  spec.email = "assaf@labnotes.org"
6
6
  spec.homepage = "http://github.com/assaf/vanity"
@@ -9,6 +9,7 @@ Gem::Specification.new do |spec|
9
9
  #spec.post_install_message = "To get started run vanity --help"
10
10
 
11
11
  spec.files = Dir["{bin,lib,rails,test}/**/*", "CHANGELOG", "README.rdoc", "vanity.gemspec"]
12
+ spec.executable = "vanity"
12
13
 
13
14
  spec.has_rdoc = true
14
15
  spec.extra_rdoc_files = "README.rdoc", "CHANGELOG"
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.1
4
+ version: 0.2.2
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-11 00:00:00 -08:00
12
+ date: 2009-11-12 00:00:00 -08:00
13
13
  default_executable:
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
@@ -24,14 +24,17 @@ dependencies:
24
24
  version:
25
25
  description: ""
26
26
  email: assaf@labnotes.org
27
- executables: []
28
-
27
+ executables:
28
+ - vanity
29
29
  extensions: []
30
30
 
31
31
  extra_rdoc_files:
32
32
  - README.rdoc
33
33
  - CHANGELOG
34
34
  files:
35
+ - bin/vanity
36
+ - lib/vanity/commands/report.rb
37
+ - lib/vanity/commands.rb
35
38
  - lib/vanity/experiment/ab_test.rb
36
39
  - lib/vanity/experiment/base.rb
37
40
  - lib/vanity/playground.rb
@@ -42,6 +45,7 @@ files:
42
45
  - lib/vanity.rb
43
46
  - test/ab_test_test.rb
44
47
  - test/experiment_test.rb
48
+ - test/experiments/null_abc.rb
45
49
  - test/playground_test.rb
46
50
  - test/rails_test.rb
47
51
  - test/test_helper.rb
@@ -55,7 +59,7 @@ licenses: []
55
59
  post_install_message:
56
60
  rdoc_options:
57
61
  - --title
58
- - Vanity 0.2.1
62
+ - Vanity 0.2.2
59
63
  - --main
60
64
  - README.rdoc
61
65
  - --webcvs