split 0.4.6 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +11 -3
- data/CHANGELOG.mdown +22 -1
- data/CONTRIBUTING.md +10 -0
- data/LICENSE +1 -1
- data/README.mdown +235 -60
- data/lib/split.rb +8 -9
- data/lib/split/algorithms.rb +3 -0
- data/lib/split/algorithms/weighted_sample.rb +17 -0
- data/lib/split/algorithms/whiplash.rb +35 -0
- data/lib/split/alternative.rb +12 -4
- data/lib/split/configuration.rb +91 -1
- data/lib/split/dashboard/helpers.rb +3 -3
- data/lib/split/dashboard/views/_experiment.erb +1 -1
- data/lib/split/exceptions.rb +4 -0
- data/lib/split/experiment.rb +112 -24
- data/lib/split/extensions.rb +3 -0
- data/lib/split/extensions/array.rb +4 -0
- data/lib/split/extensions/string.rb +15 -0
- data/lib/split/helper.rb +87 -55
- data/lib/split/metric.rb +68 -0
- data/lib/split/persistence.rb +28 -0
- data/lib/split/persistence/cookie_adapter.rb +44 -0
- data/lib/split/persistence/session_adapter.rb +28 -0
- data/lib/split/trial.rb +43 -0
- data/lib/split/version.rb +3 -3
- data/spec/algorithms/weighted_sample_spec.rb +18 -0
- data/spec/algorithms/whiplash_spec.rb +23 -0
- data/spec/alternative_spec.rb +81 -9
- data/spec/configuration_spec.rb +61 -9
- data/spec/dashboard_helpers_spec.rb +2 -5
- data/spec/dashboard_spec.rb +0 -2
- data/spec/experiment_spec.rb +144 -74
- data/spec/helper_spec.rb +234 -29
- data/spec/metric_spec.rb +30 -0
- data/spec/persistence/cookie_adapter_spec.rb +31 -0
- data/spec/persistence/session_adapter_spec.rb +31 -0
- data/spec/persistence_spec.rb +33 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/support/cookies_mock.rb +19 -0
- data/spec/trial_spec.rb +59 -0
- data/split.gemspec +7 -3
- metadata +58 -29
- data/Guardfile +0 -5
data/lib/split.rb
CHANGED
@@ -1,8 +1,7 @@
|
|
1
|
-
|
2
|
-
require
|
3
|
-
|
4
|
-
|
5
|
-
require 'split/configuration'
|
1
|
+
%w[algorithms extensions metric trial experiment alternative helper version configuration persistence exceptions].each do |f|
|
2
|
+
require "split/#{f}"
|
3
|
+
end
|
4
|
+
|
6
5
|
require 'split/engine' if defined?(Rails)
|
7
6
|
require 'redis/namespace'
|
8
7
|
|
@@ -52,9 +51,9 @@ module Split
|
|
52
51
|
# config.ignore_ips = '192.168.2.1'
|
53
52
|
# end
|
54
53
|
def configure
|
55
|
-
|
56
|
-
|
57
|
-
|
54
|
+
self.configuration ||= Configuration.new
|
55
|
+
yield(configuration)
|
56
|
+
end
|
58
57
|
end
|
59
58
|
|
60
|
-
Split.configure {}
|
59
|
+
Split.configure {}
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Split
|
2
|
+
module Algorithms
|
3
|
+
module WeightedSample
|
4
|
+
def self.choose_alternative(experiment)
|
5
|
+
weights = experiment.alternatives.map(&:weight)
|
6
|
+
|
7
|
+
total = weights.inject(:+)
|
8
|
+
point = rand * total
|
9
|
+
|
10
|
+
experiment.alternatives.zip(weights).each do |n,w|
|
11
|
+
return n if w >= point
|
12
|
+
point -= w
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# A multi-armed bandit implementation inspired by
|
2
|
+
# @aaronsw and victorykit/whiplash
|
3
|
+
require 'simple-random'
|
4
|
+
|
5
|
+
module Split
|
6
|
+
module Algorithms
|
7
|
+
module Whiplash
|
8
|
+
def self.choose_alternative(experiment)
|
9
|
+
experiment[best_guess(experiment.alternatives)]
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def self.arm_guess(participants, completions)
|
15
|
+
a = [participants, 0].max
|
16
|
+
b = [participants-completions, 0].max
|
17
|
+
s = SimpleRandom.new; s.set_seed; s.beta(a+fairness_constant, b+fairness_constant)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.best_guess(alternatives)
|
21
|
+
guesses = {}
|
22
|
+
alternatives.each do |alternative|
|
23
|
+
guesses[alternative.name] = arm_guess(alternative.participant_count, alternative.completed_count)
|
24
|
+
end
|
25
|
+
gmax = guesses.values.max
|
26
|
+
best = guesses.keys.select {|name| guesses[name] == gmax }
|
27
|
+
return best.sample
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.fairness_constant
|
31
|
+
7
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/split/alternative.rb
CHANGED
@@ -20,27 +20,33 @@ module Split
|
|
20
20
|
end
|
21
21
|
|
22
22
|
def participant_count
|
23
|
-
Split.redis.hget(key, 'participant_count').to_i
|
23
|
+
@participant_count ||= Split.redis.hget(key, 'participant_count').to_i
|
24
24
|
end
|
25
25
|
|
26
26
|
def participant_count=(count)
|
27
|
+
@participant_count = count
|
27
28
|
Split.redis.hset(key, 'participant_count', count.to_i)
|
28
29
|
end
|
29
30
|
|
30
31
|
def completed_count
|
31
|
-
Split.redis.hget(key, 'completed_count').to_i
|
32
|
+
@completed_count ||= Split.redis.hget(key, 'completed_count').to_i
|
33
|
+
end
|
34
|
+
|
35
|
+
def unfinished_count
|
36
|
+
participant_count - completed_count
|
32
37
|
end
|
33
38
|
|
34
39
|
def completed_count=(count)
|
40
|
+
@completed_count = count
|
35
41
|
Split.redis.hset(key, 'completed_count', count.to_i)
|
36
42
|
end
|
37
43
|
|
38
44
|
def increment_participation
|
39
|
-
Split.redis.hincrby key, 'participant_count', 1
|
45
|
+
@participant_count = Split.redis.hincrby key, 'participant_count', 1
|
40
46
|
end
|
41
47
|
|
42
48
|
def increment_completion
|
43
|
-
|
49
|
+
@completed_count = Split.redis.hincrby key, 'completed_count', 1
|
44
50
|
end
|
45
51
|
|
46
52
|
def control?
|
@@ -87,6 +93,8 @@ module Split
|
|
87
93
|
end
|
88
94
|
|
89
95
|
def reset
|
96
|
+
@participant_count = nil
|
97
|
+
@completed_count = nil
|
90
98
|
Split.redis.hmset key, 'participant_count', 0, 'completed_count', 0
|
91
99
|
end
|
92
100
|
|
data/lib/split/configuration.rb
CHANGED
@@ -1,5 +1,18 @@
|
|
1
1
|
module Split
|
2
2
|
class Configuration
|
3
|
+
BOTS = {
|
4
|
+
'Baidu' => 'Chinese spider',
|
5
|
+
'Gigabot' => 'Gigabot spider',
|
6
|
+
'Googlebot' => 'Google spider',
|
7
|
+
'libwww-perl' => 'Perl client-server library loved by script kids',
|
8
|
+
'lwp-trivial' => 'Another Perl library loved by script kids',
|
9
|
+
'msnbot' => 'Microsoft bot',
|
10
|
+
'SiteUptime' => 'Site monitoring services',
|
11
|
+
'Slurp' => 'Yahoo spider',
|
12
|
+
'WordPress' => 'WordPress spider',
|
13
|
+
'ZIBB' => 'ZIBB spider',
|
14
|
+
'ZyBorg' => 'Zyborg? Hmmm....'
|
15
|
+
}
|
3
16
|
attr_accessor :robot_regex
|
4
17
|
attr_accessor :ignore_ip_addresses
|
5
18
|
attr_accessor :db_failover
|
@@ -7,15 +20,92 @@ module Split
|
|
7
20
|
attr_accessor :db_failover_allow_parameter_override
|
8
21
|
attr_accessor :allow_multiple_experiments
|
9
22
|
attr_accessor :enabled
|
23
|
+
attr_accessor :experiments
|
24
|
+
attr_accessor :persistence
|
25
|
+
attr_accessor :algorithm
|
26
|
+
|
27
|
+
def disabled?
|
28
|
+
!enabled
|
29
|
+
end
|
30
|
+
|
31
|
+
def experiment_for(name)
|
32
|
+
if normalized_experiments
|
33
|
+
normalized_experiments[name]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def metrics
|
38
|
+
return @metrics if defined?(@metrics)
|
39
|
+
@metrics = {}
|
40
|
+
if self.experiments
|
41
|
+
self.experiments.each do |key, value|
|
42
|
+
metric_name = value[:metric]
|
43
|
+
if metric_name
|
44
|
+
@metrics[metric_name] ||= []
|
45
|
+
@metrics[metric_name] << Split::Experiment.load_from_configuration(key)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
@metrics
|
50
|
+
end
|
51
|
+
|
52
|
+
def normalized_experiments
|
53
|
+
if @experiments.nil?
|
54
|
+
nil
|
55
|
+
else
|
56
|
+
experiment_config = {}
|
57
|
+
@experiments.keys.each do | name |
|
58
|
+
experiment_config[name] = {}
|
59
|
+
end
|
60
|
+
@experiments.each do | experiment_name, settings|
|
61
|
+
experiment_config[experiment_name][:alternatives] = normalize_alternatives(settings[:alternatives]) if settings[:alternatives]
|
62
|
+
end
|
63
|
+
experiment_config
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
def normalize_alternatives(alternatives)
|
69
|
+
given_probability, num_with_probability = alternatives.inject([0,0]) do |a,v|
|
70
|
+
p, n = a
|
71
|
+
if v.kind_of?(Hash) && v[:percent]
|
72
|
+
[p + v[:percent], n + 1]
|
73
|
+
else
|
74
|
+
a
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
num_without_probability = alternatives.length - num_with_probability
|
79
|
+
unassigned_probability = ((100.0 - given_probability) / num_without_probability / 100.0)
|
80
|
+
|
81
|
+
if num_with_probability.nonzero?
|
82
|
+
alternatives = alternatives.map do |v|
|
83
|
+
if v.kind_of?(Hash) && v[:name] && v[:percent]
|
84
|
+
{ v[:name] => v[:percent] / 100.0 }
|
85
|
+
elsif v.kind_of?(Hash) && v[:name]
|
86
|
+
{ v[:name] => unassigned_probability }
|
87
|
+
else
|
88
|
+
{ v => unassigned_probability }
|
89
|
+
end
|
90
|
+
end
|
91
|
+
[alternatives.shift, alternatives]
|
92
|
+
else
|
93
|
+
alternatives = alternatives.dup
|
94
|
+
[alternatives.shift, alternatives]
|
95
|
+
end
|
96
|
+
end
|
10
97
|
|
11
98
|
def initialize
|
12
|
-
@robot_regex = /\b(
|
99
|
+
@robot_regex = /\b(#{BOTS.keys.join('|')})\b/i
|
13
100
|
@ignore_ip_addresses = []
|
14
101
|
@db_failover = false
|
15
102
|
@db_failover_on_db_error = proc{|error|} # e.g. use Rails logger here
|
16
103
|
@db_failover_allow_parameter_override = false
|
17
104
|
@allow_multiple_experiments = false
|
18
105
|
@enabled = true
|
106
|
+
@experiments = {}
|
107
|
+
@persistence = Split::Persistence::SessionAdapter
|
108
|
+
@algorithm = Split::Algorithms::WeightedSample
|
19
109
|
end
|
20
110
|
end
|
21
111
|
end
|
@@ -37,7 +37,7 @@
|
|
37
37
|
<% end %>
|
38
38
|
</td>
|
39
39
|
<td><%= alternative.participant_count %></td>
|
40
|
-
<td><%= alternative.
|
40
|
+
<td><%= alternative.unfinished_count %></td>
|
41
41
|
<td><%= alternative.completed_count %></td>
|
42
42
|
<td>
|
43
43
|
<%= number_to_percentage(alternative.conversion_rate) %>%
|
data/lib/split/experiment.rb
CHANGED
@@ -1,12 +1,40 @@
|
|
1
1
|
module Split
|
2
2
|
class Experiment
|
3
3
|
attr_accessor :name
|
4
|
+
attr_writer :algorithm
|
5
|
+
attr_accessor :resettable
|
4
6
|
|
5
|
-
def initialize(name,
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
7
|
+
def initialize(name, options = {})
|
8
|
+
options = {
|
9
|
+
:resettable => true,
|
10
|
+
}.merge(options)
|
11
|
+
|
12
|
+
@name = name.to_s
|
13
|
+
@alternatives = options[:alternatives] if !options[:alternatives].nil?
|
14
|
+
|
15
|
+
if !options[:algorithm].nil?
|
16
|
+
@algorithm = options[:algorithm].is_a?(String) ? options[:algorithm].constantize : options[:algorithm]
|
17
|
+
end
|
18
|
+
|
19
|
+
if !options[:resettable].nil?
|
20
|
+
@resettable = options[:resettable].is_a?(String) ? options[:resettable] == 'true' : options[:resettable]
|
21
|
+
end
|
22
|
+
|
23
|
+
if !options[:alternative_names].nil?
|
24
|
+
@alternatives = options[:alternative_names].map do |alternative|
|
25
|
+
Split::Alternative.new(alternative, name)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
def algorithm
|
33
|
+
@algorithm ||= Split.configuration.algorithm
|
34
|
+
end
|
35
|
+
|
36
|
+
def ==(obj)
|
37
|
+
self.name == obj.name
|
10
38
|
end
|
11
39
|
|
12
40
|
def winner
|
@@ -16,6 +44,10 @@ module Split
|
|
16
44
|
nil
|
17
45
|
end
|
18
46
|
end
|
47
|
+
|
48
|
+
def participant_count
|
49
|
+
alternatives.inject(0){|sum,a| sum + a.participant_count}
|
50
|
+
end
|
19
51
|
|
20
52
|
def control
|
21
53
|
alternatives.first
|
@@ -33,6 +65,10 @@ module Split
|
|
33
65
|
t = Split.redis.hget(:experiment_start_times, @name)
|
34
66
|
Time.parse(t) if t
|
35
67
|
end
|
68
|
+
|
69
|
+
def [](name)
|
70
|
+
alternatives.find{|a| a.name == name}
|
71
|
+
end
|
36
72
|
|
37
73
|
def alternatives
|
38
74
|
@alternatives.dup
|
@@ -47,14 +83,10 @@ module Split
|
|
47
83
|
end
|
48
84
|
|
49
85
|
def random_alternative
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
alternatives.zip(weights).each do |n,w|
|
56
|
-
return n if w >= point
|
57
|
-
point -= w
|
86
|
+
if alternatives.length > 1
|
87
|
+
Split.configuration.algorithm.choose_alternative(self)
|
88
|
+
else
|
89
|
+
alternatives.first
|
58
90
|
end
|
59
91
|
end
|
60
92
|
|
@@ -78,6 +110,10 @@ module Split
|
|
78
110
|
"#{key}:finished"
|
79
111
|
end
|
80
112
|
|
113
|
+
def resettable?
|
114
|
+
resettable
|
115
|
+
end
|
116
|
+
|
81
117
|
def reset
|
82
118
|
alternatives.each(&:reset)
|
83
119
|
reset_winner
|
@@ -105,9 +141,31 @@ module Split
|
|
105
141
|
Split.redis.del(name)
|
106
142
|
@alternatives.reverse.each {|a| Split.redis.lpush(name, a.name) }
|
107
143
|
end
|
144
|
+
config_key = Split::Experiment.experiment_config_key(name)
|
145
|
+
Split.redis.hset(config_key, :resettable, resettable)
|
146
|
+
Split.redis.hset(config_key, :algorithm, algorithm.to_s)
|
147
|
+
self
|
108
148
|
end
|
109
149
|
|
110
150
|
def self.load_alternatives_for(name)
|
151
|
+
if Split.configuration.experiment_for(name)
|
152
|
+
load_alternatives_from_configuration_for(name)
|
153
|
+
else
|
154
|
+
load_alternatives_from_redis_for(name)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def self.load_alternatives_from_configuration_for(name)
|
159
|
+
alts = Split.configuration.experiment_for(name)[:alternatives]
|
160
|
+
raise ArgumentError, "Experiment configuration is missing :alternatives array" if alts.nil?
|
161
|
+
if alts.is_a?(Hash)
|
162
|
+
alts.keys
|
163
|
+
else
|
164
|
+
alts.flatten
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def self.load_alternatives_from_redis_for(name)
|
111
169
|
case Split.redis.type(name)
|
112
170
|
when 'set' # convert legacy sets to lists
|
113
171
|
alts = Split.redis.smembers(name)
|
@@ -119,14 +177,46 @@ module Split
|
|
119
177
|
end
|
120
178
|
end
|
121
179
|
|
180
|
+
def self.load_from_configuration(name)
|
181
|
+
exp_config = Split.configuration.experiment_for(name) || {}
|
182
|
+
self.new(name, :alternative_names => load_alternatives_for(name),
|
183
|
+
:resettable => exp_config[:resettable],
|
184
|
+
:algorithm => exp_config[:algorithm])
|
185
|
+
end
|
186
|
+
|
187
|
+
def self.load_from_redis(name)
|
188
|
+
exp_config = Split.redis.hgetall(experiment_config_key(name))
|
189
|
+
self.new(name, :alternative_names => load_alternatives_for(name),
|
190
|
+
:resettable => exp_config['resettable'],
|
191
|
+
:algorithm => exp_config['algorithm'])
|
192
|
+
end
|
193
|
+
|
194
|
+
def self.experiment_config_key(name)
|
195
|
+
"experiment_configurations/#{name}"
|
196
|
+
end
|
197
|
+
|
122
198
|
def self.all
|
123
|
-
Array(
|
199
|
+
Array(all_experiment_names_from_redis + all_experiment_names_from_configuration).map {|e| find(e)}
|
124
200
|
end
|
125
201
|
|
202
|
+
def self.all_experiment_names_from_redis
|
203
|
+
Split.redis.smembers(:experiments)
|
204
|
+
end
|
205
|
+
|
206
|
+
def self.all_experiment_names_from_configuration
|
207
|
+
Split.configuration.experiments ? Split.configuration.experiments.keys : []
|
208
|
+
end
|
209
|
+
|
210
|
+
|
126
211
|
def self.find(name)
|
127
|
-
if Split.
|
128
|
-
|
212
|
+
if Split.configuration.experiment_for(name)
|
213
|
+
obj = load_from_configuration(name)
|
214
|
+
elsif Split.redis.exists(name)
|
215
|
+
obj = load_from_redis(name)
|
216
|
+
else
|
217
|
+
obj = nil
|
129
218
|
end
|
219
|
+
obj
|
130
220
|
end
|
131
221
|
|
132
222
|
def self.find_or_create(key, *alternatives)
|
@@ -135,8 +225,6 @@ module Split
|
|
135
225
|
if alternatives.length == 1
|
136
226
|
if alternatives[0].is_a? Hash
|
137
227
|
alternatives = alternatives[0].map{|k,v| {k => v} }
|
138
|
-
else
|
139
|
-
raise InvalidArgument, 'You must declare at least 2 alternatives'
|
140
228
|
end
|
141
229
|
end
|
142
230
|
|
@@ -145,16 +233,16 @@ module Split
|
|
145
233
|
if Split.redis.exists(name)
|
146
234
|
existing_alternatives = load_alternatives_for(name)
|
147
235
|
if existing_alternatives == alts.map(&:name)
|
148
|
-
experiment = self.new(name,
|
236
|
+
experiment = self.new(name, :alternative_names => alternatives)
|
149
237
|
else
|
150
|
-
exp = self.new(name,
|
238
|
+
exp = self.new(name, :alternative_names => existing_alternatives)
|
151
239
|
exp.reset
|
152
240
|
exp.alternatives.each(&:delete)
|
153
|
-
experiment = self.new(name,
|
241
|
+
experiment = self.new(name, :alternative_names =>alternatives)
|
154
242
|
experiment.save
|
155
243
|
end
|
156
244
|
else
|
157
|
-
experiment = self.new(name,
|
245
|
+
experiment = self.new(name, :alternative_names => alternatives)
|
158
246
|
experiment.save
|
159
247
|
end
|
160
248
|
return experiment
|
@@ -164,7 +252,7 @@ module Split
|
|
164
252
|
def self.initialize_alternatives(alternatives, name)
|
165
253
|
|
166
254
|
unless alternatives.all? { |a| Split::Alternative.valid?(a) }
|
167
|
-
raise
|
255
|
+
raise ArgumentError, 'Alternatives must be strings'
|
168
256
|
end
|
169
257
|
|
170
258
|
alternatives.map do |alternative|
|
@@ -172,4 +260,4 @@ module Split
|
|
172
260
|
end
|
173
261
|
end
|
174
262
|
end
|
175
|
-
end
|
263
|
+
end
|