vanity 1.8.2 → 1.8.3.beta
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +14 -5
- data/Gemfile +5 -0
- data/Gemfile.lock +12 -6
- data/gemfiles/rails3.gemfile +3 -0
- data/gemfiles/rails3.gemfile.lock +11 -5
- data/gemfiles/rails31.gemfile +3 -0
- data/gemfiles/rails31.gemfile.lock +11 -6
- data/gemfiles/rails32.gemfile +3 -0
- data/gemfiles/rails32.gemfile.lock +11 -5
- data/lib/vanity/adapters/abstract_adapter.rb +13 -8
- data/lib/vanity/adapters/active_record_adapter.rb +6 -0
- data/lib/vanity/adapters/mongodb_adapter.rb +6 -0
- data/lib/vanity/adapters/redis_adapter.rb +10 -0
- data/lib/vanity/backport.rb +1 -2
- data/lib/vanity/experiment/ab_test.rb +182 -139
- data/lib/vanity/experiment/alternative.rb +93 -0
- data/lib/vanity/experiment/base.rb +8 -35
- data/lib/vanity/experiment/bayesian_bandit_score.rb +93 -0
- data/lib/vanity/experiment/definition.rb +32 -0
- data/lib/vanity/frameworks/rails.rb +18 -0
- data/lib/vanity/playground.rb +16 -0
- data/lib/vanity/templates/_ab_test.erb +28 -13
- data/lib/vanity/templates/_participant.erb +12 -0
- data/lib/vanity/version.rb +1 -1
- data/test/experiment/ab_test.rb +141 -0
- data/test/experiment/base_test.rb +13 -4
- data/test/playground_test.rb +12 -0
- data/test/rails_dashboard_test.rb +35 -1
- data/test/rails_test.rb +8 -0
- data/vanity.gemspec +4 -3
- metadata +20 -19
- data/.rvmrc +0 -3
data/.gitignore
CHANGED
@@ -1,10 +1,19 @@
|
|
1
|
-
.bundle
|
2
|
-
.yardoc
|
3
|
-
*.gem
|
4
|
-
html
|
5
|
-
test/myapp/log/
|
6
1
|
vendor
|
7
2
|
log/*
|
3
|
+
test/myapp/log/
|
8
4
|
test/dummy/db/*.sqlite3
|
9
5
|
test/dummy/log/*.log
|
10
6
|
test/dummy/tmp/
|
7
|
+
|
8
|
+
# Documentation cache and generated files
|
9
|
+
.yardoc
|
10
|
+
html
|
11
|
+
|
12
|
+
# Generated files
|
13
|
+
*.gem
|
14
|
+
|
15
|
+
# Environment normalisation
|
16
|
+
.bundle
|
17
|
+
.rvmrc
|
18
|
+
.ruby-version
|
19
|
+
.ruby-gemset
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,9 +1,9 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
vanity (1.8.
|
5
|
-
redis (
|
6
|
-
redis-namespace (
|
4
|
+
vanity (1.8.3.beta)
|
5
|
+
redis (>= 2.1)
|
6
|
+
redis-namespace (>= 1.1.0)
|
7
7
|
|
8
8
|
GEM
|
9
9
|
remote: https://rubygems.org/
|
@@ -26,6 +26,7 @@ GEM
|
|
26
26
|
appraisal (0.5.2)
|
27
27
|
bundler
|
28
28
|
rake
|
29
|
+
backports (3.3.5)
|
29
30
|
bson (1.6.0)
|
30
31
|
bson_ext (1.6.0)
|
31
32
|
bson (= 1.6.0)
|
@@ -38,6 +39,7 @@ GEM
|
|
38
39
|
garb (0.9.1)
|
39
40
|
activesupport (>= 2.2.0)
|
40
41
|
crack (>= 0.1.6)
|
42
|
+
integration (0.1.0)
|
41
43
|
jekyll (0.11.2)
|
42
44
|
albino (~> 1.3)
|
43
45
|
classifier (~> 1.3)
|
@@ -70,9 +72,10 @@ GEM
|
|
70
72
|
activesupport (= 2.3.14)
|
71
73
|
rake (>= 0.8.3)
|
72
74
|
rake (10.1.0)
|
73
|
-
redis (
|
74
|
-
redis-namespace (1.
|
75
|
-
redis (
|
75
|
+
redis (3.0.6)
|
76
|
+
redis-namespace (1.3.2)
|
77
|
+
redis (~> 3.0.4)
|
78
|
+
rubystats (0.2.3)
|
76
79
|
shoulda (3.0.1)
|
77
80
|
shoulda-context (~> 1.0.0)
|
78
81
|
shoulda-matchers (~> 1.0.0)
|
@@ -92,8 +95,10 @@ DEPENDENCIES
|
|
92
95
|
RedCloth
|
93
96
|
SystemTimer (= 1.2.3)
|
94
97
|
appraisal
|
98
|
+
backports
|
95
99
|
bson_ext
|
96
100
|
garb
|
101
|
+
integration
|
97
102
|
jekyll
|
98
103
|
mocha
|
99
104
|
mongo
|
@@ -103,6 +108,7 @@ DEPENDENCIES
|
|
103
108
|
rack
|
104
109
|
rails (~> 2.3.8)
|
105
110
|
rake
|
111
|
+
rubystats
|
106
112
|
shoulda
|
107
113
|
timecop
|
108
114
|
vanity!
|
data/gemfiles/rails3.gemfile
CHANGED
@@ -8,8 +8,8 @@ PATH
|
|
8
8
|
remote: /Users/phill/Development/ruby/vanity
|
9
9
|
specs:
|
10
10
|
vanity (1.8.2)
|
11
|
-
redis (
|
12
|
-
redis-namespace (
|
11
|
+
redis (>= 2.1)
|
12
|
+
redis-namespace (>= 1.1.0)
|
13
13
|
|
14
14
|
GEM
|
15
15
|
remote: https://rubygems.org/
|
@@ -47,6 +47,7 @@ GEM
|
|
47
47
|
bundler
|
48
48
|
rake
|
49
49
|
arel (2.0.10)
|
50
|
+
backports (3.3.5)
|
50
51
|
bson (1.6.0)
|
51
52
|
bson_ext (1.6.0)
|
52
53
|
bson (= 1.6.0)
|
@@ -59,6 +60,7 @@ GEM
|
|
59
60
|
activesupport (>= 2.2.0)
|
60
61
|
crack (>= 0.1.6)
|
61
62
|
i18n (0.5.0)
|
63
|
+
integration (0.1.0)
|
62
64
|
json (1.6.5)
|
63
65
|
mail (2.2.19)
|
64
66
|
activesupport (>= 2.3.6)
|
@@ -101,9 +103,10 @@ GEM
|
|
101
103
|
rake (0.9.2.2)
|
102
104
|
rdoc (3.12)
|
103
105
|
json (~> 1.4)
|
104
|
-
redis (
|
105
|
-
redis-namespace (1.
|
106
|
-
redis (
|
106
|
+
redis (3.0.5)
|
107
|
+
redis-namespace (1.3.1)
|
108
|
+
redis (~> 3.0.0)
|
109
|
+
rubystats (0.2.3)
|
107
110
|
shoulda (3.0.1)
|
108
111
|
shoulda-context (~> 1.0.0)
|
109
112
|
shoulda-matchers (~> 1.0.0)
|
@@ -125,9 +128,11 @@ PLATFORMS
|
|
125
128
|
DEPENDENCIES
|
126
129
|
SystemTimer (= 1.2.3)
|
127
130
|
appraisal
|
131
|
+
backports
|
128
132
|
bson_ext
|
129
133
|
fastthread!
|
130
134
|
garb
|
135
|
+
integration
|
131
136
|
mocha
|
132
137
|
mongo
|
133
138
|
mysql
|
@@ -135,6 +140,7 @@ DEPENDENCIES
|
|
135
140
|
pg
|
136
141
|
rack
|
137
142
|
rails (= 3.0.11)
|
143
|
+
rubystats
|
138
144
|
shoulda
|
139
145
|
timecop
|
140
146
|
vanity!
|
data/gemfiles/rails31.gemfile
CHANGED
@@ -8,8 +8,8 @@ PATH
|
|
8
8
|
remote: /Users/phill/Development/ruby/vanity
|
9
9
|
specs:
|
10
10
|
vanity (1.8.2)
|
11
|
-
redis (
|
12
|
-
redis-namespace (
|
11
|
+
redis (>= 2.1)
|
12
|
+
redis-namespace (>= 1.1.0)
|
13
13
|
|
14
14
|
GEM
|
15
15
|
remote: https://rubygems.org/
|
@@ -48,6 +48,7 @@ GEM
|
|
48
48
|
bundler
|
49
49
|
rake
|
50
50
|
arel (2.2.3)
|
51
|
+
backports (3.3.5)
|
51
52
|
bson (1.6.0)
|
52
53
|
bson_ext (1.6.0)
|
53
54
|
bson (= 1.6.0)
|
@@ -60,6 +61,7 @@ GEM
|
|
60
61
|
crack (>= 0.1.6)
|
61
62
|
hike (1.2.1)
|
62
63
|
i18n (0.6.0)
|
64
|
+
integration (0.1.0)
|
63
65
|
json (1.6.5)
|
64
66
|
mail (2.3.0)
|
65
67
|
i18n (>= 0.4.0)
|
@@ -107,16 +109,16 @@ GEM
|
|
107
109
|
rake (0.9.2.2)
|
108
110
|
rdoc (3.12)
|
109
111
|
json (~> 1.4)
|
110
|
-
redis (
|
111
|
-
redis-namespace (1.
|
112
|
-
redis (
|
112
|
+
redis (3.0.5)
|
113
|
+
redis-namespace (1.3.1)
|
114
|
+
redis (~> 3.0.0)
|
115
|
+
rubystats (0.2.3)
|
113
116
|
shoulda (3.0.1)
|
114
117
|
shoulda-context (~> 1.0.0)
|
115
118
|
shoulda-matchers (~> 1.0.0)
|
116
119
|
shoulda-context (1.0.0)
|
117
120
|
shoulda-matchers (1.0.0)
|
118
121
|
sprockets (2.0.3)
|
119
|
-
hike (~> 1.2)
|
120
122
|
rack (~> 1.0)
|
121
123
|
tilt (~> 1.1, != 1.3.0)
|
122
124
|
thor (0.14.6)
|
@@ -136,9 +138,11 @@ PLATFORMS
|
|
136
138
|
DEPENDENCIES
|
137
139
|
SystemTimer (= 1.2.3)
|
138
140
|
appraisal
|
141
|
+
backports
|
139
142
|
bson_ext
|
140
143
|
fastthread!
|
141
144
|
garb
|
145
|
+
integration
|
142
146
|
mocha
|
143
147
|
mongo
|
144
148
|
mysql
|
@@ -146,6 +150,7 @@ DEPENDENCIES
|
|
146
150
|
pg
|
147
151
|
rack
|
148
152
|
rails (= 3.1.3)
|
153
|
+
rubystats
|
149
154
|
shoulda
|
150
155
|
timecop
|
151
156
|
vanity!
|
data/gemfiles/rails32.gemfile
CHANGED
@@ -8,8 +8,8 @@ PATH
|
|
8
8
|
remote: /Users/phill/Development/ruby/vanity
|
9
9
|
specs:
|
10
10
|
vanity (1.8.2)
|
11
|
-
redis (
|
12
|
-
redis-namespace (
|
11
|
+
redis (>= 2.1)
|
12
|
+
redis-namespace (>= 1.1.0)
|
13
13
|
|
14
14
|
GEM
|
15
15
|
remote: https://rubygems.org/
|
@@ -47,6 +47,7 @@ GEM
|
|
47
47
|
bundler
|
48
48
|
rake
|
49
49
|
arel (3.0.2)
|
50
|
+
backports (3.3.5)
|
50
51
|
bson (1.6.0)
|
51
52
|
bson_ext (1.6.0)
|
52
53
|
bson (= 1.6.0)
|
@@ -59,6 +60,7 @@ GEM
|
|
59
60
|
crack (>= 0.1.6)
|
60
61
|
hike (1.2.1)
|
61
62
|
i18n (0.6.0)
|
63
|
+
integration (0.1.0)
|
62
64
|
journey (1.0.3)
|
63
65
|
json (1.6.5)
|
64
66
|
mail (2.4.1)
|
@@ -105,9 +107,10 @@ GEM
|
|
105
107
|
rake (0.9.2.2)
|
106
108
|
rdoc (3.12)
|
107
109
|
json (~> 1.4)
|
108
|
-
redis (
|
109
|
-
redis-namespace (1.
|
110
|
-
redis (
|
110
|
+
redis (3.0.5)
|
111
|
+
redis-namespace (1.3.1)
|
112
|
+
redis (~> 3.0.0)
|
113
|
+
rubystats (0.2.3)
|
111
114
|
shoulda (3.0.1)
|
112
115
|
shoulda-context (~> 1.0.0)
|
113
116
|
shoulda-matchers (~> 1.0.0)
|
@@ -134,9 +137,11 @@ PLATFORMS
|
|
134
137
|
DEPENDENCIES
|
135
138
|
SystemTimer (= 1.2.3)
|
136
139
|
appraisal
|
140
|
+
backports
|
137
141
|
bson_ext
|
138
142
|
fastthread!
|
139
143
|
garb
|
144
|
+
integration
|
140
145
|
mocha
|
141
146
|
mongo
|
142
147
|
mysql
|
@@ -144,6 +149,7 @@ DEPENDENCIES
|
|
144
149
|
pg
|
145
150
|
rack
|
146
151
|
rails (= 3.2.1)
|
152
|
+
rubystats
|
147
153
|
shoulda
|
148
154
|
timecop
|
149
155
|
vanity!
|
@@ -38,15 +38,15 @@ module Vanity
|
|
38
38
|
# Empty the database. This is used during tests.
|
39
39
|
def flushdb
|
40
40
|
end
|
41
|
-
|
41
|
+
|
42
42
|
|
43
43
|
# -- Metrics --
|
44
|
-
|
44
|
+
|
45
45
|
# Return when metric was last updated.
|
46
46
|
def get_metric_last_update_at(metric)
|
47
47
|
fail "Not implemented"
|
48
48
|
end
|
49
|
-
|
49
|
+
|
50
50
|
# Track metric data.
|
51
51
|
def metric_track(metric, timestamp, identity, values)
|
52
52
|
fail "Not implemented"
|
@@ -66,7 +66,7 @@ module Vanity
|
|
66
66
|
|
67
67
|
# -- Experiments --
|
68
68
|
|
69
|
-
# Store when experiment was created (do not write over existing value).
|
69
|
+
# Store when experiment was created (do not write over existing value).
|
70
70
|
def set_experiment_created_at(experiment, time)
|
71
71
|
fail "Not implemented"
|
72
72
|
end
|
@@ -75,8 +75,8 @@ module Vanity
|
|
75
75
|
def get_experiment_created_at(experiment)
|
76
76
|
fail "Not implemented"
|
77
77
|
end
|
78
|
-
|
79
|
-
# Returns true if experiment completed.
|
78
|
+
|
79
|
+
# Returns true if experiment completed.
|
80
80
|
def is_experiment_completed?(experiment)
|
81
81
|
fail "Not implemented"
|
82
82
|
end
|
@@ -86,7 +86,7 @@ module Vanity
|
|
86
86
|
# :conversions.
|
87
87
|
def ab_counts(experiment, alternative)
|
88
88
|
fail "Not implemented"
|
89
|
-
end
|
89
|
+
end
|
90
90
|
|
91
91
|
# Pick particular alternative (by index) to show to this particular
|
92
92
|
# participant (by identity).
|
@@ -112,7 +112,12 @@ module Vanity
|
|
112
112
|
|
113
113
|
# Determines if a participant already has seen this alternative in this experiment.
|
114
114
|
def ab_seen(experiment, identity, assignment)
|
115
|
-
|
115
|
+
fail "Not implemented"
|
116
|
+
end
|
117
|
+
|
118
|
+
# Determines what alternative a participant has already been given, if any
|
119
|
+
def ab_assigned(experiment, identity)
|
120
|
+
fail "Not implemented"
|
116
121
|
end
|
117
122
|
|
118
123
|
# Records a conversion in this experiment for the given alternative.
|
@@ -222,6 +222,12 @@ module Vanity
|
|
222
222
|
participant && participant.seen == alternative.id
|
223
223
|
end
|
224
224
|
|
225
|
+
# Returns the participant's seen alternative in this experiment, if it exists
|
226
|
+
def ab_assigned(experiment, identity)
|
227
|
+
participant = VanityParticipant.retrieve(experiment, identity, false)
|
228
|
+
participant && participant.seen
|
229
|
+
end
|
230
|
+
|
225
231
|
# Records a conversion in this experiment for the given alternative.
|
226
232
|
# Associates a value with the conversion (default to 1). If implicit is
|
227
233
|
# true, add participant if not already recorded for this experiment. If
|
@@ -156,6 +156,12 @@ module Vanity
|
|
156
156
|
participant && participant["seen"].first == alternative.id
|
157
157
|
end
|
158
158
|
|
159
|
+
# Returns the participant's seen alternative in this experiment, if it exists
|
160
|
+
def ab_assigned(experiment, identity)
|
161
|
+
participant = @participants.find_one({ :experiment=>experiment, :identity=>identity }, { :fields=>[:seen] })
|
162
|
+
participant && participant["seen"].first
|
163
|
+
end
|
164
|
+
|
159
165
|
def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
|
160
166
|
if implicit
|
161
167
|
@participants.update({ :experiment=>experiment, :identity=>identity }, { "$push"=>{ :seen=>alternative } }, :upsert=>true)
|
@@ -138,6 +138,16 @@ module Vanity
|
|
138
138
|
end
|
139
139
|
end
|
140
140
|
|
141
|
+
# Returns the participant's seen alternative in this experiment, if it exists
|
142
|
+
def ab_assigned(experiment, identity)
|
143
|
+
Vanity.playground.experiments[experiment].alternatives.each do |alternative|
|
144
|
+
if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
|
145
|
+
return alternative.id
|
146
|
+
end
|
147
|
+
end
|
148
|
+
return nil
|
149
|
+
end
|
150
|
+
|
141
151
|
def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
|
142
152
|
if implicit
|
143
153
|
@experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
|
data/lib/vanity/backport.rb
CHANGED
@@ -1,116 +1,32 @@
|
|
1
1
|
require "digest/md5"
|
2
|
+
require "vanity/experiment/alternative"
|
3
|
+
require "vanity/experiment/bayesian_bandit_score"
|
2
4
|
|
3
5
|
module Vanity
|
4
6
|
module Experiment
|
7
|
+
# The meat.
|
8
|
+
class AbTest < Base
|
9
|
+
class << self
|
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
|
5
16
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
def initialize(experiment, id, value) #, participants, converted, conversions)
|
10
|
-
@experiment = experiment
|
11
|
-
@id = id
|
12
|
-
@name = "option #{(@id + 65).chr}"
|
13
|
-
@value = value
|
14
|
-
end
|
15
|
-
|
16
|
-
# Alternative id, only unique for this experiment.
|
17
|
-
attr_reader :id
|
18
|
-
|
19
|
-
# Alternative name (option A, option B, etc).
|
20
|
-
attr_reader :name
|
21
|
-
|
22
|
-
# Alternative value.
|
23
|
-
attr_reader :value
|
24
|
-
|
25
|
-
# Experiment this alternative belongs to.
|
26
|
-
attr_reader :experiment
|
27
|
-
|
28
|
-
# Number of participants who viewed this alternative.
|
29
|
-
def participants
|
30
|
-
load_counts unless @participants
|
31
|
-
@participants
|
32
|
-
end
|
33
|
-
|
34
|
-
# Number of participants who converted on this alternative (a participant is counted only once).
|
35
|
-
def converted
|
36
|
-
load_counts unless @converted
|
37
|
-
@converted
|
38
|
-
end
|
39
|
-
|
40
|
-
# Number of conversions for this alternative (same participant may be counted more than once).
|
41
|
-
def conversions
|
42
|
-
load_counts unless @conversions
|
43
|
-
@conversions
|
17
|
+
def friendly_name
|
18
|
+
"A/B Test"
|
19
|
+
end
|
44
20
|
end
|
45
21
|
|
46
|
-
|
47
|
-
attr_accessor :z_score
|
48
|
-
|
49
|
-
# Probability derived from z-score. Populated by AbTest#score.
|
50
|
-
attr_accessor :probability
|
51
|
-
|
52
|
-
# Difference from least performing alternative. Populated by AbTest#score.
|
53
|
-
attr_accessor :difference
|
54
|
-
|
55
|
-
# Conversion rate calculated as converted/participants
|
56
|
-
def conversion_rate
|
57
|
-
@conversion_rate ||= (participants > 0 ? converted.to_f/participants.to_f : 0.0)
|
58
|
-
end
|
59
|
-
|
60
|
-
# The measure we use to order (sort) alternatives and decide which one is better (by calculating z-score).
|
61
|
-
# Defaults to conversion rate.
|
62
|
-
def measure
|
63
|
-
conversion_rate
|
64
|
-
end
|
65
|
-
|
66
|
-
def <=>(other)
|
67
|
-
measure <=> other.measure
|
68
|
-
end
|
69
|
-
|
70
|
-
def ==(other)
|
71
|
-
other && id == other.id && experiment == other.experiment
|
72
|
-
end
|
73
|
-
|
74
|
-
def to_s
|
75
|
-
name
|
76
|
-
end
|
77
|
-
|
78
|
-
def inspect
|
79
|
-
"#{name}: #{value} #{converted}/#{participants}"
|
80
|
-
end
|
81
|
-
|
82
|
-
def load_counts
|
83
|
-
if @experiment.playground.collecting?
|
84
|
-
@participants, @converted, @conversions = @experiment.playground.connection.ab_counts(@experiment.id, id).values_at(:participants, :converted, :conversions)
|
85
|
-
else
|
86
|
-
@participants = @converted = @conversions = 0
|
87
|
-
end
|
88
|
-
end
|
89
|
-
end
|
90
|
-
|
91
|
-
|
92
|
-
# The meat.
|
93
|
-
class AbTest < Base
|
94
|
-
class << self
|
95
|
-
|
96
|
-
# Convert z-score to probability.
|
97
|
-
def probability(score)
|
98
|
-
score = score.abs
|
99
|
-
probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
|
100
|
-
probability ? probability.last : 0
|
101
|
-
end
|
102
|
-
|
103
|
-
def friendly_name
|
104
|
-
"A/B Test"
|
105
|
-
end
|
106
|
-
|
107
|
-
end
|
22
|
+
DEFAULT_SCORE_METHOD = :z_score
|
108
23
|
|
109
24
|
def initialize(*args)
|
110
25
|
super
|
26
|
+
@score_method = DEFAULT_SCORE_METHOD
|
27
|
+
@use_probabilities = nil
|
111
28
|
end
|
112
29
|
|
113
|
-
|
114
30
|
# -- Metric --
|
115
31
|
|
116
32
|
# Tells A/B test which metric we're measuring, or returns metric in use.
|
@@ -168,6 +84,23 @@ module Vanity
|
|
168
84
|
alternatives.find { |alt| alt.value == value }
|
169
85
|
end
|
170
86
|
|
87
|
+
# What method to use for calculating score. Default is :ab_test, but can
|
88
|
+
# also be set to :bandit_score to calculate probability of each
|
89
|
+
# alternative being the best.
|
90
|
+
#
|
91
|
+
# @example Define A/B test which uses bayes_bandit_score in reporting
|
92
|
+
# ab_test "noodle_test" do
|
93
|
+
# alternatives "spaghetti", "linguine"
|
94
|
+
# metrics :signup
|
95
|
+
# score_method :bayes_bandit_score
|
96
|
+
# end
|
97
|
+
def score_method(method=nil)
|
98
|
+
if method
|
99
|
+
@score_method = method
|
100
|
+
end
|
101
|
+
@score_method
|
102
|
+
end
|
103
|
+
|
171
104
|
# Defines an A/B test with two alternatives: false and true. This is the
|
172
105
|
# default pair of alternatives, so just syntactic sugar for those who love
|
173
106
|
# being explicit.
|
@@ -197,20 +130,28 @@ module Vanity
|
|
197
130
|
if @playground.collecting?
|
198
131
|
if active?
|
199
132
|
identity = identity()
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
133
|
+
index = connection.ab_showing(@id, identity)
|
134
|
+
unless index
|
135
|
+
index = alternative_for(identity)
|
136
|
+
if !@playground.using_js?
|
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
|
214
155
|
end
|
215
156
|
else
|
216
157
|
index = connection.ab_get_outcome(@id) || alternative_for(identity)
|
@@ -288,12 +229,20 @@ module Vanity
|
|
288
229
|
|
289
230
|
# -- Reporting --
|
290
231
|
|
232
|
+
def calculate_score
|
233
|
+
if respond_to?(score_method)
|
234
|
+
self.send(score_method)
|
235
|
+
else
|
236
|
+
score
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
291
240
|
# Scores alternatives based on the current tracking data. This method
|
292
241
|
# returns a structure with the following attributes:
|
293
242
|
# [:alts] Ordered list of alternatives, populated with scoring info.
|
294
243
|
# [:base] Second best performing alternative.
|
295
244
|
# [:least] Least performing alternative (but more than zero conversion).
|
296
|
-
# [:choice] Choice
|
245
|
+
# [:choice] Choice alternative, either the outcome or best alternative.
|
297
246
|
#
|
298
247
|
# Alternatives returned by this method are populated with the following
|
299
248
|
# attributes:
|
@@ -329,10 +278,48 @@ module Vanity
|
|
329
278
|
# choice alternative can only pick best if we have high probability (>90%).
|
330
279
|
best = sorted.last if sorted.last.measure > 0.0
|
331
280
|
choice = outcome ? alts[outcome.id] : (best && best.probability >= probability ? best : nil)
|
332
|
-
|
281
|
+
Struct.new(:alts, :best, :base, :least, :choice, :method).new(alts, best, base, least, choice, :score)
|
333
282
|
end
|
334
283
|
|
335
|
-
#
|
284
|
+
# Scores alternatives based on the current tracking data, using Bayesian
|
285
|
+
# estimates of the best binomial bandit. Based on the R bandit package,
|
286
|
+
# http://cran.r-project.org/web/packages/bandit, which is based on
|
287
|
+
# Steven L. Scott, A modern Bayesian look at the multi-armed bandit,
|
288
|
+
# Appl. Stochastic Models Bus. Ind. 2010; 26:639-658.
|
289
|
+
# (http://www.economics.uci.edu/~ivan/asmb.874.pdf)
|
290
|
+
#
|
291
|
+
# This method returns a structure with the following attributes:
|
292
|
+
# [:alts] Ordered list of alternatives, populated with scoring info.
|
293
|
+
# [:base] Second best performing alternative.
|
294
|
+
# [:least] Least performing alternative (but more than zero conversion).
|
295
|
+
# [:choice] Choice alternative, either the outcome or best alternative.
|
296
|
+
#
|
297
|
+
# Alternatives returned by this method are populated with the following
|
298
|
+
# attributes:
|
299
|
+
# [:probability] Probability (probability this is the best alternative).
|
300
|
+
# [:difference] Difference from the least performant altenative.
|
301
|
+
#
|
302
|
+
# The choice alternative is set only if its probability is higher or
|
303
|
+
# equal to the specified probability (default is 90%).
|
304
|
+
def bayes_bandit_score(probability = 90)
|
305
|
+
begin
|
306
|
+
require "backports/1.9.1/kernel/define_singleton_method" if RUBY_VERSION < "1.9"
|
307
|
+
require "integration"
|
308
|
+
require "rubystats"
|
309
|
+
rescue LoadError
|
310
|
+
fail "to use bayes_bandit_score, install integration and rubystats gems"
|
311
|
+
end
|
312
|
+
|
313
|
+
begin
|
314
|
+
require "gsl"
|
315
|
+
rescue LoadError
|
316
|
+
warn "for better integration performance, install gsl gem"
|
317
|
+
end
|
318
|
+
|
319
|
+
BayesianBanditScore.new(alternatives, outcome).calculate!
|
320
|
+
end
|
321
|
+
|
322
|
+
# Use the result of #score or #bayes_bandit_score to derive a conclusion. Returns an
|
336
323
|
# array of claims.
|
337
324
|
def conclusion(score = score)
|
338
325
|
claims = []
|
@@ -351,16 +338,24 @@ module Vanity
|
|
351
338
|
# we want a result that's clearly better than 2nd best.
|
352
339
|
best, second = sorted[0], sorted[1]
|
353
340
|
if best.measure > second.measure
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
341
|
+
diff = ((best.measure - second.measure) / second.measure * 100).round
|
342
|
+
better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
|
343
|
+
claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
|
344
|
+
if score.method == :bayes_bandit_score
|
345
|
+
if best.probability >= 90
|
346
|
+
claims << "With %d%% probability this result is the best." % score.best.probability
|
347
|
+
else
|
348
|
+
claims << "This result does not have strong confidence behind it, suggest you continue this experiment."
|
349
|
+
end
|
350
|
+
else
|
351
|
+
if best.probability >= 90
|
352
|
+
claims << "With %d%% probability this result is statistically significant." % score.best.probability
|
353
|
+
else
|
354
|
+
claims << "This result is not statistically significant, suggest you continue this experiment."
|
355
|
+
end
|
356
|
+
end
|
357
|
+
sorted.delete best
|
358
|
+
end
|
364
359
|
sorted.each do |alt|
|
365
360
|
if alt.measure > 0.0
|
366
361
|
claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.measure * 100]
|
@@ -375,6 +370,43 @@ module Vanity
|
|
375
370
|
claims
|
376
371
|
end
|
377
372
|
|
373
|
+
# -- Unequal probability assignments --
|
374
|
+
|
375
|
+
def set_alternative_probabilities(alternative_probabilities)
|
376
|
+
# create @use_probabilities as a function to go from [0,1] to outcome
|
377
|
+
cumulative_probability = 0.0
|
378
|
+
new_probabilities = alternative_probabilities.map {|am| [am, (cumulative_probability += am.probability)/100.0]}
|
379
|
+
@use_probabilities = new_probabilities
|
380
|
+
end
|
381
|
+
|
382
|
+
# -- Experiment rebalancing --
|
383
|
+
|
384
|
+
# Experiment rebalancing allows the app to automatically adjust the probabilities for each alternative; when one is performing better, it will increase its probability
|
385
|
+
# according to Bayesian one-armed bandit theory, in order to (eventually) maximize your overall conversions.
|
386
|
+
|
387
|
+
# Sets or returns how often (as a function of number of people assigned) to rebalance. For example:
|
388
|
+
# ab_test "Simple" do
|
389
|
+
# rebalance_frequency 100
|
390
|
+
# end
|
391
|
+
#
|
392
|
+
# puts "The experiment will automatically rebalance after every " + experiment(:simple).description + " users are assigned."
|
393
|
+
def rebalance_frequency(rf = nil)
|
394
|
+
if rf
|
395
|
+
@assignments_since_rebalancing = 0
|
396
|
+
@rebalance_frequency = rf
|
397
|
+
rebalance!
|
398
|
+
end
|
399
|
+
@rebalance_frequency
|
400
|
+
end
|
401
|
+
|
402
|
+
# Force experiment to rebalance.
|
403
|
+
def rebalance!
|
404
|
+
return unless @playground.collecting?
|
405
|
+
score_results = bayes_bandit_score
|
406
|
+
if score_results.method == :bayes_bandit_score
|
407
|
+
set_alternative_probabilities score_results.alts
|
408
|
+
end
|
409
|
+
end
|
378
410
|
|
379
411
|
# -- Completion --
|
380
412
|
|
@@ -404,19 +436,22 @@ module Vanity
|
|
404
436
|
outcome && _alternatives[outcome]
|
405
437
|
end
|
406
438
|
|
407
|
-
def complete!
|
439
|
+
def complete!(outcome = nil)
|
408
440
|
return unless @playground.collecting? && active?
|
409
441
|
super
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
442
|
+
|
443
|
+
unless outcome
|
444
|
+
if @outcome_is
|
445
|
+
begin
|
446
|
+
result = @outcome_is.call
|
447
|
+
outcome = result.id if Alternative === result && result.experiment == self
|
448
|
+
rescue
|
449
|
+
warn "Error in AbTest#complete!: #{$!}"
|
450
|
+
end
|
451
|
+
else
|
452
|
+
best = score.best
|
453
|
+
outcome = best.id if best
|
416
454
|
end
|
417
|
-
else
|
418
|
-
best = score.best
|
419
|
-
outcome = best.id if best
|
420
455
|
end
|
421
456
|
# TODO: logging
|
422
457
|
connection.ab_set_outcome @id, outcome || 0
|
@@ -484,7 +519,15 @@ module Vanity
|
|
484
519
|
# identity, and randomly distributed alternatives for each identity (in the
|
485
520
|
# same experiment).
|
486
521
|
def alternative_for(identity)
|
487
|
-
|
522
|
+
if @use_probabilities
|
523
|
+
existing_assignment = connection.ab_assigned @id, identity
|
524
|
+
return existing_assignment if existing_assignment
|
525
|
+
random_outcome = rand()
|
526
|
+
@use_probabilities.each do |alternative, max_prob|
|
527
|
+
return alternative.id if random_outcome < max_prob
|
528
|
+
end
|
529
|
+
end
|
530
|
+
return Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % @alternatives.size
|
488
531
|
end
|
489
532
|
|
490
533
|
begin
|