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
@@ -0,0 +1,18 @@
|
|
1
|
+
<% if experiment.has_winner? %>
|
2
|
+
<form action="<%= url "/reopen?experiment=#{experiment.name}" %>" method='post' onclick="return confirmReopen()">
|
3
|
+
<input type="submit" value="Reopen Experiment">
|
4
|
+
</form>
|
5
|
+
<% end %>
|
6
|
+
<% if experiment.start_time %>
|
7
|
+
<form action="<%= url "/reset?experiment=#{experiment.name}" %>" method='post' onclick="return confirmReset()">
|
8
|
+
<input type="submit" value="Reset Data">
|
9
|
+
</form>
|
10
|
+
<% else%>
|
11
|
+
<form action="<%= url "/start?experiment=#{experiment.name}" %>" method='post'>
|
12
|
+
<input type="submit" value="Start">
|
13
|
+
</form>
|
14
|
+
<% end %>
|
15
|
+
<form action="<%= url "/experiment?experiment=#{experiment.name}" %>" method='post' onclick="return confirmDelete()">
|
16
|
+
<input type="hidden" name="_method" value="delete"/>
|
17
|
+
<input type="submit" value="Delete" class="red">
|
18
|
+
</form>
|
@@ -0,0 +1,155 @@
|
|
1
|
+
<% unless goal.nil? %>
|
2
|
+
<% experiment_class = "experiment experiment_with_goal" %>
|
3
|
+
<% else %>
|
4
|
+
<% experiment_class = "experiment" %>
|
5
|
+
<% end %>
|
6
|
+
|
7
|
+
<% experiment.calc_winning_alternatives %>
|
8
|
+
<%
|
9
|
+
extra_columns = []
|
10
|
+
experiment.alternatives.each do |alternative|
|
11
|
+
extra_info = alternative.extra_info || {}
|
12
|
+
extra_columns += extra_info.keys
|
13
|
+
end
|
14
|
+
|
15
|
+
extra_columns.uniq!
|
16
|
+
summary_texts = {}
|
17
|
+
extra_columns.each do |column|
|
18
|
+
extra_infos = experiment.alternatives.map(&:extra_info).select{|extra_info| extra_info && extra_info[column] }
|
19
|
+
if extra_infos[0][column].kind_of?(Numeric)
|
20
|
+
summary_texts[column] = extra_infos.inject(0){|sum, extra_info| sum += extra_info[column]}
|
21
|
+
else
|
22
|
+
summary_texts[column] = "N/A"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
%>
|
26
|
+
|
27
|
+
|
28
|
+
<div class="<%= experiment_class %>" data-name="<%= experiment.name %>" data-complete="<%= experiment.has_winner? %>">
|
29
|
+
<div class="experiment-header">
|
30
|
+
<h2>
|
31
|
+
Experiment: <%= experiment.name %>
|
32
|
+
<% if experiment.version > 1 %><span class='version'>v<%= experiment.version %></span><% end %>
|
33
|
+
<% unless goal.nil? %><span class='goal'>Goal:<%= goal %></span><% end %>
|
34
|
+
<% metrics = @metrics.select {|metric| metric.experiments.include? experiment} %>
|
35
|
+
<% unless metrics.empty? %>
|
36
|
+
<span class='goal'>Metrics:<%= metrics.map(&:name).join(', ') %></span>
|
37
|
+
<% end %>
|
38
|
+
</h2>
|
39
|
+
|
40
|
+
<% if goal.nil? %>
|
41
|
+
<div class='inline-controls'>
|
42
|
+
<small><%= experiment.start_time ? experiment.start_time.strftime('%Y-%m-%d') : 'Unknown' %></small>
|
43
|
+
<%= erb :_controls, :locals => {:experiment => experiment} %>
|
44
|
+
</div>
|
45
|
+
<% end %>
|
46
|
+
</div>
|
47
|
+
<table>
|
48
|
+
<tr>
|
49
|
+
<th>Alternative Name</th>
|
50
|
+
<th>Participants</th>
|
51
|
+
<th>Non-finished</th>
|
52
|
+
<th>Completed</th>
|
53
|
+
<th>Conversion Rate</th>
|
54
|
+
<% extra_columns.each do |column| %>
|
55
|
+
<th><%= column %></th>
|
56
|
+
<% end %>
|
57
|
+
<th>
|
58
|
+
<form>
|
59
|
+
<select id="dropdown-<%=experiment.jstring(goal)%>" name="dropdown-<%=experiment.jstring(goal)%>">
|
60
|
+
<option value="confidence-<%=experiment.jstring(goal)%>">Confidence</option>
|
61
|
+
<option value="probability-<%=experiment.jstring(goal)%>">Probability of being Winner</option>
|
62
|
+
</select>
|
63
|
+
</form>
|
64
|
+
</th>
|
65
|
+
<th>Finish</th>
|
66
|
+
</tr>
|
67
|
+
|
68
|
+
<% total_participants = total_completed = total_unfinished = 0 %>
|
69
|
+
<% experiment.alternatives.each do |alternative| %>
|
70
|
+
<tr>
|
71
|
+
<td>
|
72
|
+
<%= alternative.name %>
|
73
|
+
<% if alternative.control? %>
|
74
|
+
<em>control</em>
|
75
|
+
<% end %>
|
76
|
+
<form action="<%= url('force_alternative') + '?experiment=' + experiment.name %>" method='post'>
|
77
|
+
<input type='hidden' name='alternative' value='<%= h alternative.name %>'>
|
78
|
+
<input type="submit" value="Force for current user" class="green">
|
79
|
+
</form>
|
80
|
+
</td>
|
81
|
+
<td><%= alternative.participant_count %></td>
|
82
|
+
<td><%= alternative.unfinished_count %></td>
|
83
|
+
<td><%= alternative.completed_count(goal) %></td>
|
84
|
+
<td>
|
85
|
+
<%= number_to_percentage(alternative.conversion_rate(goal)) %>%
|
86
|
+
<% if experiment.control.conversion_rate(goal) > 0 && !alternative.control? %>
|
87
|
+
<% if alternative.conversion_rate(goal) > experiment.control.conversion_rate(goal) %>
|
88
|
+
<span class='better'>
|
89
|
+
+<%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>%
|
90
|
+
</span>
|
91
|
+
<% elsif alternative.conversion_rate(goal) < experiment.control.conversion_rate(goal) %>
|
92
|
+
<span class='worse'>
|
93
|
+
<%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>%
|
94
|
+
</span>
|
95
|
+
<% end %>
|
96
|
+
<% end %>
|
97
|
+
</td>
|
98
|
+
<script type="text/javascript" id="sourcecode">
|
99
|
+
$(document).ready(function(){
|
100
|
+
$('.probability-<%=experiment.jstring(goal)%>').hide();
|
101
|
+
$('#dropdown-<%=experiment.jstring(goal)%>').change(function() {
|
102
|
+
$('.box-<%=experiment.jstring(goal)%>').hide();
|
103
|
+
$('.' + $(this).val()).show();
|
104
|
+
});
|
105
|
+
});
|
106
|
+
</script>
|
107
|
+
<% extra_columns.each do |column| %>
|
108
|
+
<td><%= alternative.extra_info && alternative.extra_info[column] %></td>
|
109
|
+
<% end %>
|
110
|
+
<td>
|
111
|
+
<div class="box-<%=experiment.jstring(goal)%> confidence-<%=experiment.jstring(goal)%>">
|
112
|
+
<span title='z-score: <%= round(alternative.z_score(goal), 3) %>'><%= confidence_level(alternative.z_score(goal)) %></span>
|
113
|
+
<br>
|
114
|
+
</div>
|
115
|
+
<div class="box-<%=experiment.jstring(goal)%> probability-<%=experiment.jstring(goal)%>">
|
116
|
+
<span title="p_winner: <%= round(alternative.p_winner(goal), 3) %>"><%= number_to_percentage(round(alternative.p_winner(goal), 3)) %>%</span>
|
117
|
+
</div>
|
118
|
+
</td>
|
119
|
+
<td>
|
120
|
+
<% if experiment.has_winner? %>
|
121
|
+
<% if experiment.winner.name == alternative.name %>
|
122
|
+
Winner
|
123
|
+
<% else %>
|
124
|
+
Loser
|
125
|
+
<% end %>
|
126
|
+
<% else %>
|
127
|
+
<form action="<%= url('experiment') + '?experiment=' + experiment.name %>" method='post' onclick="return confirmWinner()">
|
128
|
+
<input type='hidden' name='alternative' value='<%= h alternative.name %>'>
|
129
|
+
<input type="submit" value="Use this" class="green">
|
130
|
+
</form>
|
131
|
+
<% end %>
|
132
|
+
</td>
|
133
|
+
</tr>
|
134
|
+
|
135
|
+
<% total_participants += alternative.participant_count %>
|
136
|
+
<% total_unfinished += alternative.unfinished_count %>
|
137
|
+
<% total_completed += alternative.completed_count(goal) %>
|
138
|
+
<% end %>
|
139
|
+
|
140
|
+
<tr class="totals">
|
141
|
+
<td>Totals</td>
|
142
|
+
<td><%= total_participants %></td>
|
143
|
+
<td><%= total_unfinished %></td>
|
144
|
+
<td><%= total_completed %></td>
|
145
|
+
<td>N/A</td>
|
146
|
+
<% extra_columns.each do |column| %>
|
147
|
+
<td>
|
148
|
+
<%= summary_texts[column] %>
|
149
|
+
</td>
|
150
|
+
<% end %>
|
151
|
+
<td>N/A</td>
|
152
|
+
<td>N/A</td>
|
153
|
+
</tr>
|
154
|
+
</table>
|
155
|
+
</div>
|
@@ -0,0 +1,8 @@
|
|
1
|
+
<div class="experiment">
|
2
|
+
<div class="experiment-header">
|
3
|
+
<div class='inline-controls'>
|
4
|
+
<small><%= experiment.start_time ? experiment.start_time.strftime('%Y-%m-%d') : 'Unknown' %></small>
|
5
|
+
<%= erb :_controls, :locals => {:experiment => experiment} %>
|
6
|
+
</div>
|
7
|
+
</div>
|
8
|
+
</div>
|
@@ -0,0 +1,26 @@
|
|
1
|
+
<% if @experiments.any? %>
|
2
|
+
<p class="intro">The list below contains all the registered experiments along with the number of test participants, completed and conversion rate currently in the system.</p>
|
3
|
+
|
4
|
+
<input type="text" placeholder="Begin typing to filter" id="filter" />
|
5
|
+
<input type="button" id="toggle-completed" value="Hide completed" />
|
6
|
+
<input type="button" id="toggle-active" value="Hide active" />
|
7
|
+
<input type="button" id="clear-filter" value="Clear filters" />
|
8
|
+
|
9
|
+
<% paginated(@experiments).each do |experiment| %>
|
10
|
+
<% if experiment.goals.empty? %>
|
11
|
+
<%= erb :_experiment, :locals => {:goal => nil, :experiment => experiment} %>
|
12
|
+
<% else %>
|
13
|
+
<%= erb :_experiment_with_goal_header, :locals => {:experiment => experiment} %>
|
14
|
+
<% experiment.goals.each do |g| %>
|
15
|
+
<%= erb :_experiment, :locals => {:goal => g, :experiment => experiment} %>
|
16
|
+
<% end %>
|
17
|
+
<% end %>
|
18
|
+
<% end %>
|
19
|
+
|
20
|
+
<div class="pagination">
|
21
|
+
<%= pagination(@experiments) %>
|
22
|
+
</div>
|
23
|
+
<% else %>
|
24
|
+
<p class="intro">No experiments have started yet, you need to define them in your code and introduce them to your users.</p>
|
25
|
+
<p class="intro">Check out the <a href='https://github.com/splitrb/split#readme'>Readme</a> for more help getting started.</p>
|
26
|
+
<% end %>
|
@@ -0,0 +1,27 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta content='text/html; charset=utf-8' http-equiv='Content-Type'>
|
5
|
+
<link href="<%= url 'reset.css' %>" media="screen" rel="stylesheet" type="text/css">
|
6
|
+
<link href="<%= url 'style.css' %>" media="screen" rel="stylesheet" type="text/css">
|
7
|
+
<script type="text/javascript" src='<%= url 'dashboard.js' %>'></script>
|
8
|
+
<script type="text/javascript" src='<%= url 'jquery-1.11.1.min.js' %>'></script>
|
9
|
+
<script type="text/javascript" src='<%= url 'dashboard-filtering.js' %>'></script>
|
10
|
+
<title>Split</title>
|
11
|
+
|
12
|
+
</head>
|
13
|
+
<body>
|
14
|
+
<div class="header">
|
15
|
+
<h1>Split Dashboard</h1>
|
16
|
+
<p class="environment"><%= @current_env %></p>
|
17
|
+
</div>
|
18
|
+
|
19
|
+
<div id="main">
|
20
|
+
<%= yield %>
|
21
|
+
</div>
|
22
|
+
|
23
|
+
<div id="footer">
|
24
|
+
<p>Powered by <a href="https://github.com/splitrb/split">Split</a> v<%=Split::VERSION %></p>
|
25
|
+
</div>
|
26
|
+
</body>
|
27
|
+
</html>
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "split/helper"
|
3
|
+
|
4
|
+
# Split's helper exposes all kinds of methods we don't want to
|
5
|
+
# mix into our model classes.
|
6
|
+
#
|
7
|
+
# This module exposes only two methods:
|
8
|
+
# - ab_test()
|
9
|
+
# - ab_finished()
|
10
|
+
# that can safely be mixed into any class.
|
11
|
+
#
|
12
|
+
# Passes the instance of the class that it's mixed into to the
|
13
|
+
# Split persistence adapter as context.
|
14
|
+
#
|
15
|
+
module Split
|
16
|
+
module EncapsulatedHelper
|
17
|
+
|
18
|
+
class ContextShim
|
19
|
+
include Split::Helper
|
20
|
+
public :ab_test, :ab_finished
|
21
|
+
|
22
|
+
def initialize(context)
|
23
|
+
@context = context
|
24
|
+
end
|
25
|
+
|
26
|
+
def ab_user
|
27
|
+
@ab_user ||= Split::User.new(@context)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def ab_test(*arguments,&block)
|
32
|
+
split_context_shim.ab_test(*arguments,&block)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# instantiate and memoize a context shim in case of multiple ab_test* calls
|
38
|
+
def split_context_shim
|
39
|
+
@split_context_shim ||= ContextShim.new(self)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/split/engine.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Split
|
3
|
+
class Engine < ::Rails::Engine
|
4
|
+
initializer "split" do |app|
|
5
|
+
if Split.configuration.include_rails_helper
|
6
|
+
ActiveSupport.on_load(:action_controller) do
|
7
|
+
::ActionController::Base.send :include, Split::Helper
|
8
|
+
::ActionController::Base.helper Split::Helper
|
9
|
+
::ActionController::Base.send :include, Split::CombinedExperimentsHelper
|
10
|
+
::ActionController::Base.helper Split::CombinedExperimentsHelper
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,486 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Split
|
3
|
+
class Experiment
|
4
|
+
attr_accessor :name
|
5
|
+
attr_accessor :goals
|
6
|
+
attr_accessor :alternative_probabilities
|
7
|
+
attr_accessor :metadata
|
8
|
+
|
9
|
+
attr_reader :alternatives
|
10
|
+
attr_reader :resettable
|
11
|
+
|
12
|
+
DEFAULT_OPTIONS = {
|
13
|
+
:resettable => true
|
14
|
+
}
|
15
|
+
|
16
|
+
def initialize(name, options = {})
|
17
|
+
options = DEFAULT_OPTIONS.merge(options)
|
18
|
+
|
19
|
+
@name = name.to_s
|
20
|
+
|
21
|
+
alternatives = extract_alternatives_from_options(options)
|
22
|
+
|
23
|
+
if alternatives.empty? && (exp_config = Split.configuration.experiment_for(name))
|
24
|
+
options = {
|
25
|
+
alternatives: load_alternatives_from_configuration,
|
26
|
+
goals: Split::GoalsCollection.new(@name).load_from_configuration,
|
27
|
+
metadata: load_metadata_from_configuration,
|
28
|
+
resettable: exp_config[:resettable],
|
29
|
+
algorithm: exp_config[:algorithm]
|
30
|
+
}
|
31
|
+
else
|
32
|
+
options[:alternatives] = alternatives
|
33
|
+
end
|
34
|
+
|
35
|
+
set_alternatives_and_options(options)
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.finished_key(key)
|
39
|
+
"#{key}:finished"
|
40
|
+
end
|
41
|
+
|
42
|
+
def set_alternatives_and_options(options)
|
43
|
+
options_with_defaults = DEFAULT_OPTIONS.merge(
|
44
|
+
options.reject { |k, v| v.nil? }
|
45
|
+
)
|
46
|
+
|
47
|
+
self.alternatives = options_with_defaults[:alternatives]
|
48
|
+
self.goals = options_with_defaults[:goals]
|
49
|
+
self.resettable = options_with_defaults[:resettable]
|
50
|
+
self.algorithm = options_with_defaults[:algorithm]
|
51
|
+
self.metadata = options_with_defaults[:metadata]
|
52
|
+
end
|
53
|
+
|
54
|
+
def extract_alternatives_from_options(options)
|
55
|
+
alts = options[:alternatives] || []
|
56
|
+
|
57
|
+
if alts.length == 1
|
58
|
+
if alts[0].is_a? Hash
|
59
|
+
alts = alts[0].map{|k,v| {k => v} }
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
if alts.empty?
|
64
|
+
exp_config = Split.configuration.experiment_for(name)
|
65
|
+
if exp_config
|
66
|
+
alts = load_alternatives_from_configuration
|
67
|
+
options[:goals] = Split::GoalsCollection.new(@name).load_from_configuration
|
68
|
+
options[:metadata] = load_metadata_from_configuration
|
69
|
+
options[:resettable] = exp_config[:resettable]
|
70
|
+
options[:algorithm] = exp_config[:algorithm]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
options[:alternatives] = alts
|
75
|
+
|
76
|
+
set_alternatives_and_options(options)
|
77
|
+
|
78
|
+
# calculate probability that each alternative is the winner
|
79
|
+
@alternative_probabilities = {}
|
80
|
+
alts
|
81
|
+
end
|
82
|
+
|
83
|
+
def save
|
84
|
+
validate!
|
85
|
+
|
86
|
+
if new_record?
|
87
|
+
start unless Split.configuration.start_manually
|
88
|
+
persist_experiment_configuration
|
89
|
+
elsif experiment_configuration_has_changed?
|
90
|
+
reset unless Split.configuration.reset_manually
|
91
|
+
persist_experiment_configuration
|
92
|
+
end
|
93
|
+
|
94
|
+
redis.hset(experiment_config_key, :resettable, resettable)
|
95
|
+
redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
|
96
|
+
self
|
97
|
+
end
|
98
|
+
|
99
|
+
def validate!
|
100
|
+
if @alternatives.empty? && Split.configuration.experiment_for(@name).nil?
|
101
|
+
raise ExperimentNotFound.new("Experiment #{@name} not found")
|
102
|
+
end
|
103
|
+
@alternatives.each {|a| a.validate! }
|
104
|
+
goals_collection.validate!
|
105
|
+
end
|
106
|
+
|
107
|
+
def new_record?
|
108
|
+
!redis.exists(name)
|
109
|
+
end
|
110
|
+
|
111
|
+
def ==(obj)
|
112
|
+
self.name == obj.name
|
113
|
+
end
|
114
|
+
|
115
|
+
def [](name)
|
116
|
+
alternatives.find{|a| a.name == name}
|
117
|
+
end
|
118
|
+
|
119
|
+
def algorithm
|
120
|
+
@algorithm ||= Split.configuration.algorithm
|
121
|
+
end
|
122
|
+
|
123
|
+
def algorithm=(algorithm)
|
124
|
+
@algorithm = algorithm.is_a?(String) ? algorithm.constantize : algorithm
|
125
|
+
end
|
126
|
+
|
127
|
+
def resettable=(resettable)
|
128
|
+
@resettable = resettable.is_a?(String) ? resettable == 'true' : resettable
|
129
|
+
end
|
130
|
+
|
131
|
+
def alternatives=(alts)
|
132
|
+
@alternatives = alts.map do |alternative|
|
133
|
+
if alternative.kind_of?(Split::Alternative)
|
134
|
+
alternative
|
135
|
+
else
|
136
|
+
Split::Alternative.new(alternative, @name)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def winner
|
142
|
+
experiment_winner = redis.hget(:experiment_winner, name)
|
143
|
+
if experiment_winner
|
144
|
+
Split::Alternative.new(experiment_winner, name)
|
145
|
+
else
|
146
|
+
nil
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def has_winner?
|
151
|
+
return @has_winner if defined? @has_winner
|
152
|
+
@has_winner = !winner.nil?
|
153
|
+
end
|
154
|
+
|
155
|
+
def winner=(winner_name)
|
156
|
+
redis.hset(:experiment_winner, name, winner_name.to_s)
|
157
|
+
@has_winner = true
|
158
|
+
end
|
159
|
+
|
160
|
+
def participant_count
|
161
|
+
alternatives.inject(0){|sum,a| sum + a.participant_count}
|
162
|
+
end
|
163
|
+
|
164
|
+
def control
|
165
|
+
alternatives.first
|
166
|
+
end
|
167
|
+
|
168
|
+
def reset_winner
|
169
|
+
redis.hdel(:experiment_winner, name)
|
170
|
+
@has_winner = false
|
171
|
+
end
|
172
|
+
|
173
|
+
def start
|
174
|
+
redis.hset(:experiment_start_times, @name, Time.now.to_i)
|
175
|
+
end
|
176
|
+
|
177
|
+
def start_time
|
178
|
+
t = redis.hget(:experiment_start_times, @name)
|
179
|
+
if t
|
180
|
+
# Check if stored time is an integer
|
181
|
+
if t =~ /^[-+]?[0-9]+$/
|
182
|
+
Time.at(t.to_i)
|
183
|
+
else
|
184
|
+
Time.parse(t)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def next_alternative
|
190
|
+
winner || random_alternative
|
191
|
+
end
|
192
|
+
|
193
|
+
def random_alternative
|
194
|
+
if alternatives.length > 1
|
195
|
+
algorithm.choose_alternative(self)
|
196
|
+
else
|
197
|
+
alternatives.first
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def version
|
202
|
+
@version ||= (redis.get("#{name}:version").to_i || 0)
|
203
|
+
end
|
204
|
+
|
205
|
+
def increment_version
|
206
|
+
@version = redis.incr("#{name}:version")
|
207
|
+
end
|
208
|
+
|
209
|
+
def key
|
210
|
+
if version.to_i > 0
|
211
|
+
"#{name}:#{version}"
|
212
|
+
else
|
213
|
+
name
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def goals_key
|
218
|
+
"#{name}:goals"
|
219
|
+
end
|
220
|
+
|
221
|
+
def finished_key
|
222
|
+
self.class.finished_key(key)
|
223
|
+
end
|
224
|
+
|
225
|
+
def metadata_key
|
226
|
+
"#{name}:metadata"
|
227
|
+
end
|
228
|
+
|
229
|
+
def resettable?
|
230
|
+
resettable
|
231
|
+
end
|
232
|
+
|
233
|
+
def reset
|
234
|
+
Split.configuration.on_before_experiment_reset.call(self)
|
235
|
+
alternatives.each(&:reset)
|
236
|
+
reset_winner
|
237
|
+
Split.configuration.on_experiment_reset.call(self)
|
238
|
+
increment_version
|
239
|
+
end
|
240
|
+
|
241
|
+
def delete
|
242
|
+
Split.configuration.on_before_experiment_delete.call(self)
|
243
|
+
if Split.configuration.start_manually
|
244
|
+
redis.hdel(:experiment_start_times, @name)
|
245
|
+
end
|
246
|
+
reset_winner
|
247
|
+
redis.srem(:experiments, name)
|
248
|
+
remove_experiment_configuration
|
249
|
+
Split.configuration.on_experiment_delete.call(self)
|
250
|
+
increment_version
|
251
|
+
end
|
252
|
+
|
253
|
+
def delete_metadata
|
254
|
+
redis.del(metadata_key)
|
255
|
+
end
|
256
|
+
|
257
|
+
def load_from_redis
|
258
|
+
exp_config = redis.hgetall(experiment_config_key)
|
259
|
+
|
260
|
+
options = {
|
261
|
+
resettable: exp_config['resettable'],
|
262
|
+
algorithm: exp_config['algorithm'],
|
263
|
+
alternatives: load_alternatives_from_redis,
|
264
|
+
goals: Split::GoalsCollection.new(@name).load_from_redis,
|
265
|
+
metadata: load_metadata_from_redis
|
266
|
+
}
|
267
|
+
|
268
|
+
set_alternatives_and_options(options)
|
269
|
+
end
|
270
|
+
|
271
|
+
def calc_winning_alternatives
|
272
|
+
# Cache the winning alternatives so we recalculate them once per the specified interval.
|
273
|
+
intervals_since_epoch =
|
274
|
+
Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
|
275
|
+
|
276
|
+
if self.calc_time != intervals_since_epoch
|
277
|
+
if goals.empty?
|
278
|
+
self.estimate_winning_alternative
|
279
|
+
else
|
280
|
+
goals.each do |goal|
|
281
|
+
self.estimate_winning_alternative(goal)
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
self.calc_time = intervals_since_epoch
|
286
|
+
|
287
|
+
self.save
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
def estimate_winning_alternative(goal = nil)
|
292
|
+
# initialize a hash of beta distributions based on the alternatives' conversion rates
|
293
|
+
beta_params = calc_beta_params(goal)
|
294
|
+
|
295
|
+
winning_alternatives = []
|
296
|
+
|
297
|
+
Split.configuration.beta_probability_simulations.times do
|
298
|
+
# calculate simulated conversion rates from the beta distributions
|
299
|
+
simulated_cr_hash = calc_simulated_conversion_rates(beta_params)
|
300
|
+
|
301
|
+
winning_alternative = find_simulated_winner(simulated_cr_hash)
|
302
|
+
|
303
|
+
# push the winning pair to the winning_alternatives array
|
304
|
+
winning_alternatives.push(winning_alternative)
|
305
|
+
end
|
306
|
+
|
307
|
+
winning_counts = count_simulated_wins(winning_alternatives)
|
308
|
+
|
309
|
+
@alternative_probabilities = calc_alternative_probabilities(winning_counts, Split.configuration.beta_probability_simulations)
|
310
|
+
|
311
|
+
write_to_alternatives(goal)
|
312
|
+
|
313
|
+
self.save
|
314
|
+
end
|
315
|
+
|
316
|
+
def write_to_alternatives(goal = nil)
|
317
|
+
alternatives.each do |alternative|
|
318
|
+
alternative.set_p_winner(@alternative_probabilities[alternative], goal)
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
def calc_alternative_probabilities(winning_counts, number_of_simulations)
|
323
|
+
alternative_probabilities = {}
|
324
|
+
winning_counts.each do |alternative, wins|
|
325
|
+
alternative_probabilities[alternative] = wins / number_of_simulations.to_f
|
326
|
+
end
|
327
|
+
return alternative_probabilities
|
328
|
+
end
|
329
|
+
|
330
|
+
def count_simulated_wins(winning_alternatives)
|
331
|
+
# initialize a hash to keep track of winning alternative in simulations
|
332
|
+
winning_counts = {}
|
333
|
+
alternatives.each do |alternative|
|
334
|
+
winning_counts[alternative] = 0
|
335
|
+
end
|
336
|
+
# count number of times each alternative won, calculate probabilities, place in hash
|
337
|
+
winning_alternatives.each do |alternative|
|
338
|
+
winning_counts[alternative] += 1
|
339
|
+
end
|
340
|
+
return winning_counts
|
341
|
+
end
|
342
|
+
|
343
|
+
def find_simulated_winner(simulated_cr_hash)
|
344
|
+
# figure out which alternative had the highest simulated conversion rate
|
345
|
+
winning_pair = ["",0.0]
|
346
|
+
simulated_cr_hash.each do |alternative, rate|
|
347
|
+
if rate > winning_pair[1]
|
348
|
+
winning_pair = [alternative, rate]
|
349
|
+
end
|
350
|
+
end
|
351
|
+
winner = winning_pair[0]
|
352
|
+
return winner
|
353
|
+
end
|
354
|
+
|
355
|
+
def calc_simulated_conversion_rates(beta_params)
|
356
|
+
# initialize a random variable (from which to simulate conversion rates ~beta-distributed)
|
357
|
+
rand = SimpleRandom.new
|
358
|
+
rand.set_seed
|
359
|
+
|
360
|
+
simulated_cr_hash = {}
|
361
|
+
|
362
|
+
# create a hash which has the conversion rate pulled from each alternative's beta distribution
|
363
|
+
beta_params.each do |alternative, params|
|
364
|
+
alpha = params[0]
|
365
|
+
beta = params[1]
|
366
|
+
simulated_conversion_rate = rand.beta(alpha, beta)
|
367
|
+
simulated_cr_hash[alternative] = simulated_conversion_rate
|
368
|
+
end
|
369
|
+
|
370
|
+
return simulated_cr_hash
|
371
|
+
end
|
372
|
+
|
373
|
+
def calc_beta_params(goal = nil)
|
374
|
+
beta_params = {}
|
375
|
+
alternatives.each do |alternative|
|
376
|
+
conversions = goal.nil? ? alternative.completed_count : alternative.completed_count(goal)
|
377
|
+
alpha = 1 + conversions
|
378
|
+
beta = 1 + alternative.participant_count - conversions
|
379
|
+
|
380
|
+
params = [alpha, beta]
|
381
|
+
|
382
|
+
beta_params[alternative] = params
|
383
|
+
end
|
384
|
+
return beta_params
|
385
|
+
end
|
386
|
+
|
387
|
+
def calc_time=(time)
|
388
|
+
redis.hset(experiment_config_key, :calc_time, time)
|
389
|
+
end
|
390
|
+
|
391
|
+
def calc_time
|
392
|
+
redis.hget(experiment_config_key, :calc_time).to_i
|
393
|
+
end
|
394
|
+
|
395
|
+
def jstring(goal = nil)
|
396
|
+
js_id = if goal.nil?
|
397
|
+
name
|
398
|
+
else
|
399
|
+
name + "-" + goal
|
400
|
+
end
|
401
|
+
js_id.gsub('/', '--')
|
402
|
+
end
|
403
|
+
|
404
|
+
protected
|
405
|
+
|
406
|
+
def experiment_config_key
|
407
|
+
"experiment_configurations/#{@name}"
|
408
|
+
end
|
409
|
+
|
410
|
+
def load_metadata_from_configuration
|
411
|
+
Split.configuration.experiment_for(@name)[:metadata]
|
412
|
+
end
|
413
|
+
|
414
|
+
def load_metadata_from_redis
|
415
|
+
meta = redis.get(metadata_key)
|
416
|
+
JSON.parse(meta) unless meta.nil?
|
417
|
+
end
|
418
|
+
|
419
|
+
def load_alternatives_from_configuration
|
420
|
+
alts = Split.configuration.experiment_for(@name)[:alternatives]
|
421
|
+
raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
|
422
|
+
if alts.is_a?(Hash)
|
423
|
+
alts.keys
|
424
|
+
else
|
425
|
+
alts.flatten
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
def load_alternatives_from_redis
|
430
|
+
alternatives = case redis.type(@name)
|
431
|
+
when 'set' # convert legacy sets to lists
|
432
|
+
alts = redis.smembers(@name)
|
433
|
+
redis.del(@name)
|
434
|
+
alts.reverse.each {|a| redis.lpush(@name, a) }
|
435
|
+
redis.lrange(@name, 0, -1)
|
436
|
+
else
|
437
|
+
redis.lrange(@name, 0, -1)
|
438
|
+
end
|
439
|
+
alternatives.map do |alt|
|
440
|
+
alt = begin
|
441
|
+
JSON.parse(alt)
|
442
|
+
rescue
|
443
|
+
alt
|
444
|
+
end
|
445
|
+
Split::Alternative.new(alt, @name)
|
446
|
+
end
|
447
|
+
end
|
448
|
+
|
449
|
+
private
|
450
|
+
|
451
|
+
def redis
|
452
|
+
Split.redis
|
453
|
+
end
|
454
|
+
|
455
|
+
def redis_interface
|
456
|
+
RedisInterface.new
|
457
|
+
end
|
458
|
+
|
459
|
+
def persist_experiment_configuration
|
460
|
+
redis_interface.add_to_set(:experiments, name)
|
461
|
+
redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
|
462
|
+
goals_collection.save
|
463
|
+
redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
|
464
|
+
end
|
465
|
+
|
466
|
+
def remove_experiment_configuration
|
467
|
+
@alternatives.each(&:delete)
|
468
|
+
goals_collection.delete
|
469
|
+
delete_metadata
|
470
|
+
redis.del(@name)
|
471
|
+
end
|
472
|
+
|
473
|
+
def experiment_configuration_has_changed?
|
474
|
+
existing_alternatives = load_alternatives_from_redis
|
475
|
+
existing_goals = Split::GoalsCollection.new(@name).load_from_redis
|
476
|
+
existing_metadata = load_metadata_from_redis
|
477
|
+
existing_alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
|
478
|
+
existing_goals != @goals ||
|
479
|
+
existing_metadata != @metadata
|
480
|
+
end
|
481
|
+
|
482
|
+
def goals_collection
|
483
|
+
Split::GoalsCollection.new(@name, @goals)
|
484
|
+
end
|
485
|
+
end
|
486
|
+
end
|