vanity 1.8.2 → 1.8.3.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/.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
|