vanity 1.8.4 → 1.9.0.beta
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +3 -2
- data/CHANGELOG +12 -0
- data/Gemfile +6 -3
- data/Gemfile.lock +12 -10
- data/README.rdoc +45 -16
- data/Rakefile +14 -9
- data/doc/_layouts/page.html +4 -6
- data/doc/ab_testing.textile +1 -1
- data/doc/configuring.textile +2 -4
- data/doc/email.textile +1 -3
- data/doc/index.textile +3 -63
- data/doc/rails.textile +34 -8
- data/gemfiles/rails3.gemfile +12 -3
- data/gemfiles/rails3.gemfile.lock +37 -11
- data/gemfiles/rails31.gemfile +12 -3
- data/gemfiles/rails31.gemfile.lock +37 -11
- data/gemfiles/rails32.gemfile +12 -3
- data/gemfiles/rails32.gemfile.lock +37 -11
- data/gemfiles/rails4.gemfile +12 -3
- data/gemfiles/rails4.gemfile.lock +37 -11
- data/lib/vanity/adapters/abstract_adapter.rb +4 -0
- data/lib/vanity/adapters/active_record_adapter.rb +18 -10
- data/lib/vanity/adapters/mock_adapter.rb +8 -4
- data/lib/vanity/adapters/mongodb_adapter.rb +11 -7
- data/lib/vanity/adapters/redis_adapter.rb +88 -37
- data/lib/vanity/commands/report.rb +9 -9
- data/lib/vanity/experiment/ab_test.rb +120 -101
- data/lib/vanity/experiment/alternative.rb +21 -21
- data/lib/vanity/experiment/base.rb +5 -5
- data/lib/vanity/experiment/bayesian_bandit_score.rb +51 -51
- data/lib/vanity/experiment/definition.rb +10 -10
- data/lib/vanity/frameworks/rails.rb +39 -36
- data/lib/vanity/helpers.rb +6 -4
- data/lib/vanity/metric/active_record.rb +1 -1
- data/lib/vanity/metric/base.rb +23 -24
- data/lib/vanity/metric/google_analytics.rb +5 -5
- data/lib/vanity/playground.rb +118 -24
- data/lib/vanity/templates/_report.erb +20 -6
- data/lib/vanity/templates/vanity.css +2 -0
- data/lib/vanity/version.rb +1 -1
- data/test/adapters/redis_adapter_test.rb +106 -1
- data/test/dummy/config/database.yml +21 -4
- data/test/dummy/config/routes.rb +1 -1
- data/test/experiment/ab_test.rb +93 -13
- data/test/metric/active_record_test.rb +9 -4
- data/test/passenger_test.rb +43 -42
- data/test/playground_test.rb +50 -1
- data/test/rails_dashboard_test.rb +38 -1
- data/test/rails_helper_test.rb +5 -0
- data/test/rails_test.rb +66 -15
- data/test/test_helper.rb +24 -2
- data/vanity.gemspec +0 -2
- metadata +45 -57
@@ -5,15 +5,29 @@ module Vanity
|
|
5
5
|
#
|
6
6
|
# @since 1.4.0
|
7
7
|
def redis_connection(spec)
|
8
|
+
require "redis"
|
9
|
+
fail "redis >= 2.1 is required" unless valid_redis_version?
|
8
10
|
require "redis/namespace"
|
11
|
+
fail "redis-namespace >= 1.1.0 is required" unless valid_redis_namespace_version?
|
12
|
+
|
9
13
|
RedisAdapter.new(spec)
|
10
14
|
end
|
15
|
+
|
16
|
+
def valid_redis_version?
|
17
|
+
Gem.loaded_specs['redis'].version >= Gem::Version.create('2.1')
|
18
|
+
end
|
19
|
+
|
20
|
+
def valid_redis_namespace_version?
|
21
|
+
Gem.loaded_specs['redis'].version >= Gem::Version.create('1.1.0')
|
22
|
+
end
|
11
23
|
end
|
12
24
|
|
13
25
|
# Redis adapter.
|
14
26
|
#
|
15
27
|
# @since 1.4.0
|
16
28
|
class RedisAdapter < AbstractAdapter
|
29
|
+
attr_reader :redis
|
30
|
+
|
17
31
|
def initialize(options)
|
18
32
|
@options = options.clone
|
19
33
|
@options[:db] ||= @options[:database] || (@options[:path] && @options.delete(:path).split("/")[1].to_i)
|
@@ -43,34 +57,32 @@ module Vanity
|
|
43
57
|
|
44
58
|
def connect!
|
45
59
|
@redis = @options[:redis] || Redis.new(@options)
|
46
|
-
@metrics = Redis::Namespace.new("vanity:metrics", :redis
|
47
|
-
@experiments = Redis::Namespace.new("vanity:experiments", :redis
|
60
|
+
@metrics = Redis::Namespace.new("vanity:metrics", :redis=>redis)
|
61
|
+
@experiments = Redis::Namespace.new("vanity:experiments", :redis=>redis)
|
48
62
|
end
|
49
63
|
|
50
64
|
def to_s
|
51
65
|
redis.id
|
52
66
|
end
|
53
67
|
|
54
|
-
def redis
|
55
|
-
@redis
|
56
|
-
end
|
57
|
-
|
58
68
|
def flushdb
|
59
69
|
@redis.flushdb
|
60
70
|
end
|
61
71
|
|
62
72
|
# -- Metrics --
|
63
|
-
|
73
|
+
|
64
74
|
def get_metric_last_update_at(metric)
|
65
75
|
last_update_at = @metrics["#{metric}:last_update_at"]
|
66
76
|
last_update_at && Time.at(last_update_at.to_i)
|
67
77
|
end
|
68
78
|
|
69
79
|
def metric_track(metric, timestamp, identity, values)
|
70
|
-
values
|
71
|
-
|
80
|
+
call_redis_with_failover(metric, timestamp, identity, values) do
|
81
|
+
values.each_with_index do |v,i|
|
82
|
+
@metrics.incrby "#{metric}:#{timestamp.to_date}:value:#{i}", v
|
83
|
+
end
|
84
|
+
@metrics["#{metric}:last_update_at"] = Time.now.to_i
|
72
85
|
end
|
73
|
-
@metrics["#{metric}:last_update_at"] = Time.now.to_i
|
74
86
|
end
|
75
87
|
|
76
88
|
def metric_values(metric, from, to)
|
@@ -84,9 +96,15 @@ module Vanity
|
|
84
96
|
|
85
97
|
|
86
98
|
# -- Experiments --
|
87
|
-
|
99
|
+
|
100
|
+
def experiment_persisted?(experiment)
|
101
|
+
!!@experiments["#{experiment}:created_at"]
|
102
|
+
end
|
103
|
+
|
88
104
|
def set_experiment_created_at(experiment, time)
|
89
|
-
|
105
|
+
call_redis_with_failover do
|
106
|
+
@experiments.setnx "#{experiment}:created_at", time.to_i
|
107
|
+
end
|
90
108
|
end
|
91
109
|
|
92
110
|
def get_experiment_created_at(experiment)
|
@@ -104,58 +122,76 @@ module Vanity
|
|
104
122
|
end
|
105
123
|
|
106
124
|
def is_experiment_completed?(experiment)
|
107
|
-
|
125
|
+
call_redis_with_failover do
|
126
|
+
@experiments.exists("#{experiment}:completed_at")
|
127
|
+
end
|
108
128
|
end
|
109
129
|
|
110
130
|
def ab_counts(experiment, alternative)
|
111
|
-
{
|
112
|
-
:
|
113
|
-
|
131
|
+
{
|
132
|
+
:participants => @experiments.scard("#{experiment}:alts:#{alternative}:participants").to_i,
|
133
|
+
:converted => @experiments.scard("#{experiment}:alts:#{alternative}:converted").to_i,
|
134
|
+
:conversions => @experiments["#{experiment}:alts:#{alternative}:conversions"].to_i
|
135
|
+
}
|
114
136
|
end
|
115
137
|
|
116
138
|
def ab_show(experiment, identity, alternative)
|
117
|
-
|
139
|
+
call_redis_with_failover do
|
140
|
+
@experiments["#{experiment}:participant:#{identity}:show"] = alternative
|
141
|
+
end
|
118
142
|
end
|
119
143
|
|
120
144
|
def ab_showing(experiment, identity)
|
121
|
-
|
122
|
-
|
145
|
+
call_redis_with_failover do
|
146
|
+
alternative = @experiments["#{experiment}:participant:#{identity}:show"]
|
147
|
+
alternative && alternative.to_i
|
148
|
+
end
|
123
149
|
end
|
124
150
|
|
125
151
|
def ab_not_showing(experiment, identity)
|
126
|
-
|
152
|
+
call_redis_with_failover do
|
153
|
+
@experiments.del "#{experiment}:participant:#{identity}:show"
|
154
|
+
end
|
127
155
|
end
|
128
156
|
|
129
157
|
def ab_add_participant(experiment, alternative, identity)
|
130
|
-
|
158
|
+
call_redis_with_failover(experiment, alternative, identity) do
|
159
|
+
@experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
|
160
|
+
end
|
131
161
|
end
|
132
162
|
|
133
163
|
def ab_seen(experiment, identity, alternative)
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
164
|
+
call_redis_with_failover(experiment, identity, alternative) do
|
165
|
+
if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
|
166
|
+
alternative
|
167
|
+
else
|
168
|
+
nil
|
169
|
+
end
|
170
|
+
end
|
139
171
|
end
|
140
172
|
|
141
173
|
# Returns the participant's seen alternative in this experiment, if it exists
|
142
174
|
def ab_assigned(experiment, identity)
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
175
|
+
call_redis_with_failover do
|
176
|
+
Vanity.playground.experiments[experiment].alternatives.each do |alternative|
|
177
|
+
if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
|
178
|
+
return alternative.id
|
179
|
+
end
|
180
|
+
end
|
181
|
+
nil
|
147
182
|
end
|
148
|
-
return nil
|
149
183
|
end
|
150
184
|
|
151
185
|
def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
186
|
+
call_redis_with_failover(experiment, alternative, identity, count, implicit) do
|
187
|
+
if implicit
|
188
|
+
@experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
|
189
|
+
else
|
190
|
+
participating = @experiments.sismember("#{experiment}:alts:#{alternative}:participants", identity)
|
191
|
+
end
|
192
|
+
@experiments.sadd "#{experiment}:alts:#{alternative}:converted", identity if implicit || participating
|
193
|
+
@experiments.incrby "#{experiment}:alts:#{alternative}:conversions", count
|
156
194
|
end
|
157
|
-
@experiments.sadd "#{experiment}:alts:#{alternative}:converted", identity if implicit || participating
|
158
|
-
@experiments.incrby "#{experiment}:alts:#{alternative}:conversions", count
|
159
195
|
end
|
160
196
|
|
161
197
|
def ab_get_outcome(experiment)
|
@@ -173,6 +209,21 @@ module Vanity
|
|
173
209
|
@experiments.del *alternatives unless alternatives.empty?
|
174
210
|
end
|
175
211
|
|
212
|
+
protected
|
213
|
+
|
214
|
+
def call_redis_with_failover(*arguments)
|
215
|
+
calling_method = caller[0][/`.*'/][1..-2]
|
216
|
+
begin
|
217
|
+
yield
|
218
|
+
rescue => e
|
219
|
+
if Vanity.playground.failover_on_datastore_error?
|
220
|
+
Vanity.playground.on_datastore_error.call(e, self.class, calling_method, arguments)
|
221
|
+
else
|
222
|
+
raise e
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
176
227
|
end
|
177
228
|
end
|
178
229
|
end
|
@@ -7,15 +7,15 @@ module Vanity
|
|
7
7
|
# outside Rails).
|
8
8
|
module Render
|
9
9
|
|
10
|
-
# Render the named template.
|
10
|
+
# Render the named template. Used for reporting and the dashboard.
|
11
11
|
def render(path_or_options, locals = {})
|
12
12
|
if path_or_options.respond_to?(:keys)
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
13
|
+
render_erb(
|
14
|
+
path_or_options[:file] || path_or_options[:partial],
|
15
|
+
path_or_options[:locals]
|
16
|
+
)
|
17
17
|
else
|
18
|
-
|
18
|
+
render_erb(path_or_options, locals)
|
19
19
|
end
|
20
20
|
end
|
21
21
|
|
@@ -54,9 +54,9 @@ module Vanity
|
|
54
54
|
|
55
55
|
def partialize(template_name)
|
56
56
|
if template_name[0] != '_'
|
57
|
-
|
57
|
+
"_#{template_name}"
|
58
58
|
else
|
59
|
-
|
59
|
+
template_name
|
60
60
|
end
|
61
61
|
end
|
62
62
|
end
|
@@ -66,7 +66,7 @@ module Vanity
|
|
66
66
|
class << self
|
67
67
|
include Render
|
68
68
|
|
69
|
-
# Generate an HTML report.
|
69
|
+
# Generate an HTML report. Outputs to the named file, or stdout with no
|
70
70
|
# arguments.
|
71
71
|
def report(output = nil)
|
72
72
|
html = render(Vanity.template("report"))
|
@@ -7,23 +7,23 @@ module Vanity
|
|
7
7
|
# The meat.
|
8
8
|
class AbTest < Base
|
9
9
|
class << self
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
10
|
+
# Convert z-score to probability.
|
11
|
+
def probability(score)
|
12
|
+
score = score.abs
|
13
|
+
probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
|
14
|
+
probability ? probability.last : 0
|
15
|
+
end
|
16
16
|
|
17
|
-
|
18
|
-
|
19
|
-
|
17
|
+
def friendly_name
|
18
|
+
"A/B Test"
|
19
|
+
end
|
20
20
|
end
|
21
21
|
|
22
22
|
DEFAULT_SCORE_METHOD = :z_score
|
23
23
|
|
24
24
|
def initialize(*args)
|
25
25
|
super
|
26
|
-
|
26
|
+
@score_method = DEFAULT_SCORE_METHOD
|
27
27
|
@use_probabilities = nil
|
28
28
|
end
|
29
29
|
|
@@ -46,7 +46,7 @@ module Vanity
|
|
46
46
|
# -- Alternatives --
|
47
47
|
|
48
48
|
# Call this method once to set alternative values for this experiment
|
49
|
-
# (requires at least two values).
|
49
|
+
# (requires at least two values). Call without arguments to obtain
|
50
50
|
# current list of alternatives.
|
51
51
|
#
|
52
52
|
# @example Define A/B test with three alternatives
|
@@ -84,7 +84,7 @@ module Vanity
|
|
84
84
|
alternatives.find { |alt| alt.value == value }
|
85
85
|
end
|
86
86
|
|
87
|
-
# What method to use for calculating score.
|
87
|
+
# What method to use for calculating score. Default is :ab_test, but can
|
88
88
|
# also be set to :bayes_bandit_score to calculate probability of each
|
89
89
|
# alternative being the best.
|
90
90
|
#
|
@@ -95,13 +95,13 @@ module Vanity
|
|
95
95
|
# score_method :bayes_bandit_score
|
96
96
|
# end
|
97
97
|
def score_method(method=nil)
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
98
|
+
if method
|
99
|
+
@score_method = method
|
100
|
+
end
|
101
|
+
@score_method
|
102
102
|
end
|
103
103
|
|
104
|
-
# Defines an A/B test with two alternatives: false and true.
|
104
|
+
# Defines an A/B test with two alternatives: false and true. This is the
|
105
105
|
# default pair of alternatives, so just syntactic sugar for those who love
|
106
106
|
# being explicit.
|
107
107
|
#
|
@@ -116,42 +116,31 @@ module Vanity
|
|
116
116
|
end
|
117
117
|
alias true_false false_true
|
118
118
|
|
119
|
-
#
|
119
|
+
# Returns fingerprint (hash) for given alternative. Can be used to lookup
|
120
|
+
# alternative for experiment without revealing what values are available
|
121
|
+
# (e.g. choosing alternative from HTTP query parameter).
|
122
|
+
def fingerprint(alternative)
|
123
|
+
Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
|
124
|
+
end
|
125
|
+
|
126
|
+
# Chooses a value for this experiment. You probably want to use the
|
120
127
|
# Rails helper method ab_test instead.
|
121
128
|
#
|
122
129
|
# This method picks an alternative for the current identity and returns
|
123
|
-
# the alternative's value.
|
130
|
+
# the alternative's value. It will consistently choose the same
|
124
131
|
# alternative for the same identity, and randomly split alternatives
|
125
132
|
# between different identities.
|
126
133
|
#
|
127
134
|
# @example
|
128
135
|
# color = experiment(:which_blue).choose
|
129
|
-
def choose
|
136
|
+
def choose(request=nil)
|
130
137
|
if @playground.collecting?
|
131
138
|
if active?
|
132
139
|
identity = identity()
|
133
140
|
index = connection.ab_showing(@id, identity)
|
134
141
|
unless index
|
135
|
-
index = alternative_for(identity)
|
136
|
-
|
137
|
-
# if we have an on_assignment block, call it on new assignments
|
138
|
-
if @on_assignment_block
|
139
|
-
assignment = alternatives[index.to_i]
|
140
|
-
if !connection.ab_seen @id, identity, assignment
|
141
|
-
@on_assignment_block.call(Vanity.context, identity, assignment, self)
|
142
|
-
end
|
143
|
-
end
|
144
|
-
# if we are rebalancing probabilities, keep track of how long it has been since we last rebalanced
|
145
|
-
if @rebalance_frequency
|
146
|
-
@assignments_since_rebalancing += 1
|
147
|
-
if @assignments_since_rebalancing >= @rebalance_frequency
|
148
|
-
@assignments_since_rebalancing = 0
|
149
|
-
rebalance!
|
150
|
-
end
|
151
|
-
end
|
152
|
-
connection.ab_add_participant @id, index, identity
|
153
|
-
check_completion!
|
154
|
-
end
|
142
|
+
index = alternative_for(identity).to_i
|
143
|
+
save_assignment_if_valid_visitor(identity, index, request) unless @playground.using_js?
|
155
144
|
end
|
156
145
|
else
|
157
146
|
index = connection.ab_get_outcome(@id) || alternative_for(identity)
|
@@ -165,22 +154,17 @@ module Vanity
|
|
165
154
|
alternatives[index.to_i]
|
166
155
|
end
|
167
156
|
|
168
|
-
# Returns fingerprint (hash) for given alternative. Can be used to lookup
|
169
|
-
# alternative for experiment without revealing what values are available
|
170
|
-
# (e.g. choosing alternative from HTTP query parameter).
|
171
|
-
def fingerprint(alternative)
|
172
|
-
Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
|
173
|
-
end
|
174
|
-
|
175
157
|
|
176
|
-
# -- Testing --
|
158
|
+
# -- Testing and JS Callback --
|
177
159
|
|
178
|
-
# Forces this experiment to use a particular alternative.
|
179
|
-
#
|
160
|
+
# Forces this experiment to use a particular alternative. This may be
|
161
|
+
# used in test cases to force a specific alternative to obtain a
|
162
|
+
# deterministic test. This method also is used in the add_participant
|
163
|
+
# callback action when adding participants via vanity_js.
|
180
164
|
#
|
181
165
|
# @example Setup test to red button
|
182
166
|
# setup do
|
183
|
-
# experiment(:button_color).
|
167
|
+
# experiment(:button_color).chooses(:red)
|
184
168
|
# end
|
185
169
|
#
|
186
170
|
# def test_shows_red_button
|
@@ -189,23 +173,20 @@ module Vanity
|
|
189
173
|
#
|
190
174
|
# @example Use nil to clear selection
|
191
175
|
# teardown do
|
192
|
-
# experiment(:green_button).
|
176
|
+
# experiment(:green_button).chooses(nil)
|
193
177
|
# end
|
194
|
-
def chooses(value)
|
178
|
+
def chooses(value, request=nil)
|
195
179
|
if @playground.collecting?
|
196
180
|
if value.nil?
|
197
181
|
connection.ab_not_showing @id, identity
|
198
182
|
else
|
199
183
|
index = @alternatives.index(value)
|
200
|
-
|
201
|
-
|
202
|
-
connection.ab_add_participant @id, index, identity
|
203
|
-
check_completion!
|
204
|
-
end
|
184
|
+
save_assignment_if_valid_visitor(identity, index, request)
|
185
|
+
|
205
186
|
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
206
187
|
if (connection.ab_showing(@id, identity) && connection.ab_showing(@id, identity) != index) ||
|
207
188
|
alternative_for(identity) != index
|
208
|
-
connection.ab_show
|
189
|
+
connection.ab_show(@id, identity, index)
|
209
190
|
end
|
210
191
|
end
|
211
192
|
else
|
@@ -230,14 +211,14 @@ module Vanity
|
|
230
211
|
# -- Reporting --
|
231
212
|
|
232
213
|
def calculate_score
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
214
|
+
if respond_to?(score_method)
|
215
|
+
self.send(score_method)
|
216
|
+
else
|
217
|
+
score
|
218
|
+
end
|
238
219
|
end
|
239
220
|
|
240
|
-
# Scores alternatives based on the current tracking data.
|
221
|
+
# Scores alternatives based on the current tracking data. This method
|
241
222
|
# returns a structure with the following attributes:
|
242
223
|
# [:alts] Ordered list of alternatives, populated with scoring info.
|
243
224
|
# [:base] Second best performing alternative.
|
@@ -278,7 +259,7 @@ module Vanity
|
|
278
259
|
# choice alternative can only pick best if we have high probability (>90%).
|
279
260
|
best = sorted.last if sorted.last.measure > 0.0
|
280
261
|
choice = outcome ? alts[outcome.id] : (best && best.probability >= probability ? best : nil)
|
281
|
-
|
262
|
+
Struct.new(:alts, :best, :base, :least, :choice, :method).new(alts, best, base, least, choice, :score)
|
282
263
|
end
|
283
264
|
|
284
265
|
# Scores alternatives based on the current tracking data, using Bayesian
|
@@ -302,24 +283,24 @@ module Vanity
|
|
302
283
|
# The choice alternative is set only if its probability is higher or
|
303
284
|
# equal to the specified probability (default is 90%).
|
304
285
|
def bayes_bandit_score(probability = 90)
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
286
|
+
begin
|
287
|
+
require "backports/1.9.1/kernel/define_singleton_method" if RUBY_VERSION < "1.9"
|
288
|
+
require "integration"
|
289
|
+
require "rubystats"
|
290
|
+
rescue LoadError
|
291
|
+
fail "to use bayes_bandit_score, install integration and rubystats gems"
|
292
|
+
end
|
312
293
|
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
294
|
+
begin
|
295
|
+
require "gsl"
|
296
|
+
rescue LoadError
|
297
|
+
warn "for better integration performance, install gsl gem"
|
298
|
+
end
|
318
299
|
|
319
|
-
|
300
|
+
BayesianBanditScore.new(alternatives, outcome).calculate!
|
320
301
|
end
|
321
302
|
|
322
|
-
# Use the result of #score or #bayes_bandit_score to derive a conclusion.
|
303
|
+
# Use the result of #score or #bayes_bandit_score to derive a conclusion. Returns an
|
323
304
|
# array of claims.
|
324
305
|
def conclusion(score = score)
|
325
306
|
claims = []
|
@@ -338,24 +319,24 @@ module Vanity
|
|
338
319
|
# we want a result that's clearly better than 2nd best.
|
339
320
|
best, second = sorted[0], sorted[1]
|
340
321
|
if best.measure > second.measure
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
322
|
+
diff = ((best.measure - second.measure) / second.measure * 100).round
|
323
|
+
better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
|
324
|
+
claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
|
325
|
+
if score.method == :bayes_bandit_score
|
326
|
+
if best.probability >= 90
|
327
|
+
claims << "With %d%% probability this result is the best." % score.best.probability
|
328
|
+
else
|
329
|
+
claims << "This result does not have strong confidence behind it, suggest you continue this experiment."
|
330
|
+
end
|
331
|
+
else
|
332
|
+
if best.probability >= 90
|
333
|
+
claims << "With %d%% probability this result is statistically significant." % score.best.probability
|
334
|
+
else
|
335
|
+
claims << "This result is not statistically significant, suggest you continue this experiment."
|
336
|
+
end
|
337
|
+
end
|
338
|
+
sorted.delete best
|
339
|
+
end
|
359
340
|
sorted.each do |alt|
|
360
341
|
if alt.measure > 0.0
|
361
342
|
claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.measure * 100]
|
@@ -413,7 +394,7 @@ module Vanity
|
|
413
394
|
# Defines how the experiment can choose the optimal outcome on completion.
|
414
395
|
#
|
415
396
|
# By default, Vanity will take the best alternative (highest conversion
|
416
|
-
# rate) and use that as the outcome.
|
397
|
+
# rate) and use that as the outcome. You experiment may have different
|
417
398
|
# needs, maybe you want the least performing alternative, or factor cost
|
418
399
|
# in the equation?
|
419
400
|
#
|
@@ -440,7 +421,7 @@ module Vanity
|
|
440
421
|
return unless @playground.collecting? && active?
|
441
422
|
super
|
442
423
|
|
443
|
-
|
424
|
+
unless outcome
|
444
425
|
if @outcome_is
|
445
426
|
begin
|
446
427
|
result = @outcome_is.call
|
@@ -530,6 +511,44 @@ module Vanity
|
|
530
511
|
return Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % @alternatives.size
|
531
512
|
end
|
532
513
|
|
514
|
+
# Saves the assignment of an alternative to a person and performs the
|
515
|
+
# necessary housekeeping. Ignores repeat identities and filters using
|
516
|
+
# Playground#request_filter.
|
517
|
+
def save_assignment_if_valid_visitor(identity, index, request)
|
518
|
+
return if index == connection.ab_showing(@id, identity) || filter_visitor?(request)
|
519
|
+
|
520
|
+
call_on_assignment_if_available(identity, index)
|
521
|
+
rebalance_if_necessary!
|
522
|
+
|
523
|
+
connection.ab_add_participant(@id, index, identity)
|
524
|
+
check_completion!
|
525
|
+
end
|
526
|
+
|
527
|
+
def filter_visitor?(request)
|
528
|
+
@playground.request_filter.call(request)
|
529
|
+
end
|
530
|
+
|
531
|
+
def call_on_assignment_if_available(identity, index)
|
532
|
+
# if we have an on_assignment block, call it on new assignments
|
533
|
+
if @on_assignment_block
|
534
|
+
assignment = alternatives[index]
|
535
|
+
if !connection.ab_seen @id, identity, assignment
|
536
|
+
@on_assignment_block.call(Vanity.context, identity, assignment, self)
|
537
|
+
end
|
538
|
+
end
|
539
|
+
end
|
540
|
+
|
541
|
+
def rebalance_if_necessary!
|
542
|
+
# if we are rebalancing probabilities, keep track of how long it has been since we last rebalanced
|
543
|
+
if @rebalance_frequency
|
544
|
+
@assignments_since_rebalancing += 1
|
545
|
+
if @assignments_since_rebalancing >= @rebalance_frequency
|
546
|
+
@assignments_since_rebalancing = 0
|
547
|
+
rebalance!
|
548
|
+
end
|
549
|
+
end
|
550
|
+
end
|
551
|
+
|
533
552
|
begin
|
534
553
|
a = 50.0
|
535
554
|
# Returns array of [z-score, percentage]
|
@@ -543,7 +562,7 @@ module Vanity
|
|
543
562
|
|
544
563
|
|
545
564
|
module Definition
|
546
|
-
# Define an A/B test with the given name.
|
565
|
+
# Define an A/B test with the given name. For example:
|
547
566
|
# ab_test "New Banner" do
|
548
567
|
# alternatives :red, :green, :blue
|
549
568
|
# end
|