vanity 1.8.4 → 1.9.0.beta
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/.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
|