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
@@ -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
|