split 0.4.6 → 0.5.0
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 +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
|