ab-split 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|