ab-split 1.0.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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +30 -0
- data/.csslintrc +2 -0
- data/.eslintignore +1 -0
- data/.eslintrc +213 -0
- data/.github/FUNDING.yml +1 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
- data/.rspec +1 -0
- data/.rubocop.yml +7 -0
- data/.rubocop_todo.yml +679 -0
- data/.travis.yml +60 -0
- data/Appraisals +19 -0
- data/CHANGELOG.md +696 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +62 -0
- data/Gemfile +7 -0
- data/LICENSE +22 -0
- data/README.md +955 -0
- data/Rakefile +9 -0
- data/ab-split.gemspec +44 -0
- data/gemfiles/4.2.gemfile +9 -0
- data/gemfiles/5.0.gemfile +9 -0
- data/gemfiles/5.1.gemfile +9 -0
- data/gemfiles/5.2.gemfile +9 -0
- data/gemfiles/6.0.gemfile +9 -0
- data/lib/split.rb +76 -0
- data/lib/split/algorithms/block_randomization.rb +23 -0
- data/lib/split/algorithms/weighted_sample.rb +18 -0
- data/lib/split/algorithms/whiplash.rb +38 -0
- data/lib/split/alternative.rb +191 -0
- data/lib/split/combined_experiments_helper.rb +37 -0
- data/lib/split/configuration.rb +255 -0
- data/lib/split/dashboard.rb +74 -0
- data/lib/split/dashboard/helpers.rb +45 -0
- data/lib/split/dashboard/pagination_helpers.rb +86 -0
- data/lib/split/dashboard/paginator.rb +16 -0
- data/lib/split/dashboard/public/dashboard-filtering.js +43 -0
- data/lib/split/dashboard/public/dashboard.js +24 -0
- data/lib/split/dashboard/public/jquery-1.11.1.min.js +4 -0
- data/lib/split/dashboard/public/reset.css +48 -0
- data/lib/split/dashboard/public/style.css +328 -0
- data/lib/split/dashboard/views/_controls.erb +18 -0
- data/lib/split/dashboard/views/_experiment.erb +155 -0
- data/lib/split/dashboard/views/_experiment_with_goal_header.erb +8 -0
- data/lib/split/dashboard/views/index.erb +26 -0
- data/lib/split/dashboard/views/layout.erb +27 -0
- data/lib/split/encapsulated_helper.rb +42 -0
- data/lib/split/engine.rb +15 -0
- data/lib/split/exceptions.rb +6 -0
- data/lib/split/experiment.rb +486 -0
- data/lib/split/experiment_catalog.rb +51 -0
- data/lib/split/extensions/string.rb +16 -0
- data/lib/split/goals_collection.rb +45 -0
- data/lib/split/helper.rb +165 -0
- data/lib/split/metric.rb +101 -0
- data/lib/split/persistence.rb +28 -0
- data/lib/split/persistence/cookie_adapter.rb +94 -0
- data/lib/split/persistence/dual_adapter.rb +85 -0
- data/lib/split/persistence/redis_adapter.rb +57 -0
- data/lib/split/persistence/session_adapter.rb +29 -0
- data/lib/split/redis_interface.rb +50 -0
- data/lib/split/trial.rb +117 -0
- data/lib/split/user.rb +69 -0
- data/lib/split/version.rb +7 -0
- data/lib/split/zscore.rb +57 -0
- data/spec/algorithms/block_randomization_spec.rb +32 -0
- data/spec/algorithms/weighted_sample_spec.rb +19 -0
- data/spec/algorithms/whiplash_spec.rb +24 -0
- data/spec/alternative_spec.rb +320 -0
- data/spec/combined_experiments_helper_spec.rb +57 -0
- data/spec/configuration_spec.rb +258 -0
- data/spec/dashboard/pagination_helpers_spec.rb +200 -0
- data/spec/dashboard/paginator_spec.rb +37 -0
- data/spec/dashboard_helpers_spec.rb +42 -0
- data/spec/dashboard_spec.rb +210 -0
- data/spec/encapsulated_helper_spec.rb +52 -0
- data/spec/experiment_catalog_spec.rb +53 -0
- data/spec/experiment_spec.rb +533 -0
- data/spec/goals_collection_spec.rb +80 -0
- data/spec/helper_spec.rb +1111 -0
- data/spec/metric_spec.rb +31 -0
- data/spec/persistence/cookie_adapter_spec.rb +106 -0
- data/spec/persistence/dual_adapter_spec.rb +194 -0
- data/spec/persistence/redis_adapter_spec.rb +90 -0
- data/spec/persistence/session_adapter_spec.rb +32 -0
- data/spec/persistence_spec.rb +34 -0
- data/spec/redis_interface_spec.rb +111 -0
- data/spec/spec_helper.rb +52 -0
- data/spec/split_spec.rb +43 -0
- data/spec/support/cookies_mock.rb +20 -0
- data/spec/trial_spec.rb +299 -0
- data/spec/user_spec.rb +87 -0
- metadata +322 -0
data/Rakefile
ADDED
data/ab-split.gemspec
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
# frozen_string_literal: true
|
3
|
+
$:.push File.expand_path("../lib", __FILE__)
|
4
|
+
require "split/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = "ab-split"
|
8
|
+
s.version = Split::VERSION
|
9
|
+
s.platform = Gem::Platform::RUBY
|
10
|
+
s.licenses = ['MIT']
|
11
|
+
s.authors = ["aibotyu"]
|
12
|
+
s.email = ["284894567@qq.com"]
|
13
|
+
s.homepage = "https://github.com/WeiGangqiang/split"
|
14
|
+
s.summary = "Rack based split testing framework"
|
15
|
+
|
16
|
+
s.metadata = {
|
17
|
+
"homepage_uri" => "https://github.com/WeiGangqiang/split",
|
18
|
+
"changelog_uri" => "https://github.com/WeiGangqiang/split/blob/master/CHANGELOG.md",
|
19
|
+
"source_code_uri" => "https://github.com/WeiGangqiang/split",
|
20
|
+
"bug_tracker_uri" => "https://github.com/WeiGangqiang/split/issues",
|
21
|
+
"wiki_uri" => "https://github.com/WeiGangqiang/split/wiki",
|
22
|
+
"mailing_list_uri" => "https://github.com/WeiGangqiang/split/split-ruby"
|
23
|
+
}
|
24
|
+
|
25
|
+
s.required_ruby_version = '>= 1.9.3'
|
26
|
+
s.required_rubygems_version = '>= 2.0.0'
|
27
|
+
|
28
|
+
s.files = `git ls-files`.split("\n")
|
29
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
30
|
+
s.require_paths = ["lib"]
|
31
|
+
|
32
|
+
s.add_dependency 'redis', '>= 2.1'
|
33
|
+
s.add_dependency 'sinatra', '>= 1.2.6'
|
34
|
+
s.add_dependency 'simple-random', '>= 0.9.3'
|
35
|
+
|
36
|
+
s.add_development_dependency 'bundler', '>= 1.17'
|
37
|
+
s.add_development_dependency 'simplecov', '~> 0.15'
|
38
|
+
s.add_development_dependency 'rack-test', '~> 0.6'
|
39
|
+
s.add_development_dependency 'rake', '~> 12'
|
40
|
+
s.add_development_dependency 'rspec', '~> 3.7'
|
41
|
+
s.add_development_dependency 'pry', '~> 0.10'
|
42
|
+
s.add_development_dependency 'fakeredis', '~> 0.7'
|
43
|
+
s.add_development_dependency 'rails', '>= 4.2'
|
44
|
+
end
|
data/lib/split.rb
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'redis'
|
3
|
+
|
4
|
+
require 'split/algorithms/block_randomization'
|
5
|
+
require 'split/algorithms/weighted_sample'
|
6
|
+
require 'split/algorithms/whiplash'
|
7
|
+
require 'split/alternative'
|
8
|
+
require 'split/configuration'
|
9
|
+
require 'split/encapsulated_helper'
|
10
|
+
require 'split/exceptions'
|
11
|
+
require 'split/experiment'
|
12
|
+
require 'split/experiment_catalog'
|
13
|
+
require 'split/extensions/string'
|
14
|
+
require 'split/goals_collection'
|
15
|
+
require 'split/helper'
|
16
|
+
require 'split/combined_experiments_helper'
|
17
|
+
require 'split/metric'
|
18
|
+
require 'split/persistence'
|
19
|
+
require 'split/redis_interface'
|
20
|
+
require 'split/trial'
|
21
|
+
require 'split/user'
|
22
|
+
require 'split/version'
|
23
|
+
require 'split/zscore'
|
24
|
+
require 'split/engine' if defined?(Rails)
|
25
|
+
|
26
|
+
module Split
|
27
|
+
extend self
|
28
|
+
attr_accessor :configuration
|
29
|
+
|
30
|
+
# Accepts:
|
31
|
+
# 1. A redis URL (valid for `Redis.new(url: url)`)
|
32
|
+
# 2. an options hash compatible with `Redis.new`
|
33
|
+
# 3. or a valid Redis instance (one that responds to `#smembers`). Likely,
|
34
|
+
# this will be an instance of either `Redis`, `Redis::Client`,
|
35
|
+
# `Redis::DistRedis`, or `Redis::Namespace`.
|
36
|
+
def redis=(server)
|
37
|
+
@redis = if server.is_a?(String)
|
38
|
+
Redis.new(:url => server, :thread_safe => true)
|
39
|
+
elsif server.is_a?(Hash)
|
40
|
+
Redis.new(server.merge(:thread_safe => true))
|
41
|
+
elsif server.respond_to?(:smembers)
|
42
|
+
server
|
43
|
+
else
|
44
|
+
raise ArgumentError,
|
45
|
+
"You must supply a url, options hash or valid Redis connection instance"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Returns the current Redis connection. If none has been created, will
|
50
|
+
# create a new one.
|
51
|
+
def redis
|
52
|
+
return @redis if @redis
|
53
|
+
self.redis = self.configuration.redis
|
54
|
+
self.redis
|
55
|
+
end
|
56
|
+
|
57
|
+
# Call this method to modify defaults in your initializers.
|
58
|
+
#
|
59
|
+
# @example
|
60
|
+
# Split.configure do |config|
|
61
|
+
# config.ignore_ip_addresses = '192.168.2.1'
|
62
|
+
# end
|
63
|
+
def configure
|
64
|
+
self.configuration ||= Configuration.new
|
65
|
+
yield(configuration)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Check to see if being run in a Rails application. If so, wait until before_initialize to run configuration so Gems that create ENV variables have the chance to initialize first.
|
70
|
+
if defined?(::Rails)
|
71
|
+
class Railtie < Rails::Railtie
|
72
|
+
config.before_initialize { Split.configure {} }
|
73
|
+
end
|
74
|
+
else
|
75
|
+
Split.configure {}
|
76
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# Selects alternative with minimum count of participants
|
3
|
+
# If all counts are even (i.e. all are minimum), samples from all possible alternatives
|
4
|
+
|
5
|
+
module Split
|
6
|
+
module Algorithms
|
7
|
+
module BlockRandomization
|
8
|
+
class << self
|
9
|
+
def choose_alternative(experiment)
|
10
|
+
minimum_participant_alternatives(experiment.alternatives).sample
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def minimum_participant_alternatives(alternatives)
|
16
|
+
alternatives_by_count = alternatives.group_by(&:participant_count)
|
17
|
+
min_group = alternatives_by_count.min_by { |k, v| k }
|
18
|
+
min_group.last
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Split
|
3
|
+
module Algorithms
|
4
|
+
module WeightedSample
|
5
|
+
def self.choose_alternative(experiment)
|
6
|
+
weights = experiment.alternatives.map(&:weight)
|
7
|
+
|
8
|
+
total = weights.inject(:+)
|
9
|
+
point = rand * total
|
10
|
+
|
11
|
+
experiment.alternatives.zip(weights).each do |n,w|
|
12
|
+
return n if w >= point
|
13
|
+
point -= w
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# A multi-armed bandit implementation inspired by
|
3
|
+
# @aaronsw and victorykit/whiplash
|
4
|
+
require 'simple-random'
|
5
|
+
|
6
|
+
module Split
|
7
|
+
module Algorithms
|
8
|
+
module Whiplash
|
9
|
+
class << self
|
10
|
+
def choose_alternative(experiment)
|
11
|
+
experiment[best_guess(experiment.alternatives)]
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def arm_guess(participants, completions)
|
17
|
+
a = [participants, 0].max
|
18
|
+
b = [participants-completions, 0].max
|
19
|
+
s = SimpleRandom.new; s.set_seed; s.beta(a+fairness_constant, b+fairness_constant)
|
20
|
+
end
|
21
|
+
|
22
|
+
def best_guess(alternatives)
|
23
|
+
guesses = {}
|
24
|
+
alternatives.each do |alternative|
|
25
|
+
guesses[alternative.name] = arm_guess(alternative.participant_count, alternative.all_completed_count)
|
26
|
+
end
|
27
|
+
gmax = guesses.values.max
|
28
|
+
best = guesses.keys.select { |name| guesses[name] == gmax }
|
29
|
+
best.sample
|
30
|
+
end
|
31
|
+
|
32
|
+
def fairness_constant
|
33
|
+
7
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Split
|
3
|
+
class Alternative
|
4
|
+
attr_accessor :name
|
5
|
+
attr_accessor :experiment_name
|
6
|
+
attr_accessor :weight
|
7
|
+
attr_accessor :recorded_info
|
8
|
+
|
9
|
+
def initialize(name, experiment_name)
|
10
|
+
@experiment_name = experiment_name
|
11
|
+
if Hash === name
|
12
|
+
@name = name.keys.first
|
13
|
+
@weight = name.values.first
|
14
|
+
else
|
15
|
+
@name = name
|
16
|
+
@weight = 1
|
17
|
+
end
|
18
|
+
@p_winner = 0.0
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
name
|
23
|
+
end
|
24
|
+
|
25
|
+
def goals
|
26
|
+
self.experiment.goals
|
27
|
+
end
|
28
|
+
|
29
|
+
def p_winner(goal = nil)
|
30
|
+
field = set_prob_field(goal)
|
31
|
+
@p_winner = Split.redis.hget(key, field).to_f
|
32
|
+
end
|
33
|
+
|
34
|
+
def set_p_winner(prob, goal = nil)
|
35
|
+
field = set_prob_field(goal)
|
36
|
+
Split.redis.hset(key, field, prob.to_f)
|
37
|
+
end
|
38
|
+
|
39
|
+
def participant_count
|
40
|
+
Split.redis.hget(key, 'participant_count').to_i
|
41
|
+
end
|
42
|
+
|
43
|
+
def participant_count=(count)
|
44
|
+
Split.redis.hset(key, 'participant_count', count.to_i)
|
45
|
+
end
|
46
|
+
|
47
|
+
def completed_count(goal = nil)
|
48
|
+
field = set_field(goal)
|
49
|
+
Split.redis.hget(key, field).to_i
|
50
|
+
end
|
51
|
+
|
52
|
+
def all_completed_count
|
53
|
+
if goals.empty?
|
54
|
+
completed_count
|
55
|
+
else
|
56
|
+
goals.inject(completed_count) do |sum, g|
|
57
|
+
sum + completed_count(g)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def unfinished_count
|
63
|
+
participant_count - all_completed_count
|
64
|
+
end
|
65
|
+
|
66
|
+
def set_field(goal)
|
67
|
+
field = "completed_count"
|
68
|
+
field += ":" + goal unless goal.nil?
|
69
|
+
return field
|
70
|
+
end
|
71
|
+
|
72
|
+
def set_prob_field(goal)
|
73
|
+
field = "p_winner"
|
74
|
+
field += ":" + goal unless goal.nil?
|
75
|
+
return field
|
76
|
+
end
|
77
|
+
|
78
|
+
def set_completed_count(count, goal = nil)
|
79
|
+
field = set_field(goal)
|
80
|
+
Split.redis.hset(key, field, count.to_i)
|
81
|
+
end
|
82
|
+
|
83
|
+
def increment_participation
|
84
|
+
Split.redis.hincrby key, 'participant_count', 1
|
85
|
+
end
|
86
|
+
|
87
|
+
def increment_completion(goal = nil)
|
88
|
+
field = set_field(goal)
|
89
|
+
Split.redis.hincrby(key, field, 1)
|
90
|
+
end
|
91
|
+
|
92
|
+
def control?
|
93
|
+
experiment.control.name == self.name
|
94
|
+
end
|
95
|
+
|
96
|
+
def conversion_rate(goal = nil)
|
97
|
+
return 0 if participant_count.zero?
|
98
|
+
(completed_count(goal).to_f)/participant_count.to_f
|
99
|
+
end
|
100
|
+
|
101
|
+
def experiment
|
102
|
+
Split::ExperimentCatalog.find(experiment_name)
|
103
|
+
end
|
104
|
+
|
105
|
+
def z_score(goal = nil)
|
106
|
+
# p_a = Pa = proportion of users who converted within the experiment split (conversion rate)
|
107
|
+
# p_c = Pc = proportion of users who converted within the control split (conversion rate)
|
108
|
+
# n_a = Na = the number of impressions within the experiment split
|
109
|
+
# n_c = Nc = the number of impressions within the control split
|
110
|
+
|
111
|
+
control = experiment.control
|
112
|
+
alternative = self
|
113
|
+
|
114
|
+
return 'N/A' if control.name == alternative.name
|
115
|
+
|
116
|
+
p_a = alternative.conversion_rate(goal)
|
117
|
+
p_c = control.conversion_rate(goal)
|
118
|
+
|
119
|
+
n_a = alternative.participant_count
|
120
|
+
n_c = control.participant_count
|
121
|
+
|
122
|
+
# can't calculate zscore for P(x) > 1
|
123
|
+
return 'N/A' if p_a > 1 || p_c > 1
|
124
|
+
|
125
|
+
Split::Zscore.calculate(p_a, n_a, p_c, n_c)
|
126
|
+
end
|
127
|
+
|
128
|
+
def extra_info
|
129
|
+
data = Split.redis.hget(key, 'recorded_info')
|
130
|
+
if data && data.length > 1
|
131
|
+
begin
|
132
|
+
JSON.parse(data)
|
133
|
+
rescue
|
134
|
+
{}
|
135
|
+
end
|
136
|
+
else
|
137
|
+
{}
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def record_extra_info(k, value = 1)
|
142
|
+
@recorded_info = self.extra_info || {}
|
143
|
+
|
144
|
+
if value.kind_of?(Numeric)
|
145
|
+
@recorded_info[k] ||= 0
|
146
|
+
@recorded_info[k] += value
|
147
|
+
else
|
148
|
+
@recorded_info[k] = value
|
149
|
+
end
|
150
|
+
|
151
|
+
Split.redis.hset key, 'recorded_info', (@recorded_info || {}).to_json
|
152
|
+
end
|
153
|
+
|
154
|
+
def save
|
155
|
+
Split.redis.hsetnx key, 'participant_count', 0
|
156
|
+
Split.redis.hsetnx key, 'completed_count', 0
|
157
|
+
Split.redis.hsetnx key, 'p_winner', p_winner
|
158
|
+
Split.redis.hsetnx key, 'recorded_info', (@recorded_info || {}).to_json
|
159
|
+
end
|
160
|
+
|
161
|
+
def validate!
|
162
|
+
unless String === @name || hash_with_correct_values?(@name)
|
163
|
+
raise ArgumentError, 'Alternative must be a string'
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def reset
|
168
|
+
Split.redis.hmset key, 'participant_count', 0, 'completed_count', 0, 'recorded_info', nil
|
169
|
+
unless goals.empty?
|
170
|
+
goals.each do |g|
|
171
|
+
field = "completed_count:#{g}"
|
172
|
+
Split.redis.hset key, field, 0
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def delete
|
178
|
+
Split.redis.del(key)
|
179
|
+
end
|
180
|
+
|
181
|
+
private
|
182
|
+
|
183
|
+
def hash_with_correct_values?(name)
|
184
|
+
Hash === name && String === name.keys.first && Float(name.values.first) rescue false
|
185
|
+
end
|
186
|
+
|
187
|
+
def key
|
188
|
+
"#{experiment_name}:#{name}"
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|