field_test 0.1.0 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +43 -4
- data/app/controllers/field_test/home_controller.rb +13 -0
- data/app/views/field_test/home/_experiments.html.erb +42 -0
- data/app/views/field_test/home/index.html.erb +11 -0
- data/app/views/layouts/field_test/application.html.erb +39 -0
- data/config/routes.rb +3 -0
- data/field_test.gemspec +1 -0
- data/lib/field_test.rb +10 -1
- data/lib/field_test/engine.rb +4 -0
- data/lib/field_test/experiment.rb +74 -14
- data/lib/field_test/helpers.rb +13 -1
- data/lib/field_test/participant.rb +7 -0
- data/lib/field_test/version.rb +1 -1
- data/lib/generators/field_test/templates/config.yml +2 -2
- metadata +22 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 177ccc428012c8862f9d798b6cf58629d41d54bb
|
4
|
+
data.tar.gz: 5b57269ef51ea55f5f10ff280d3c8aac895db01d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e014861b6912b47d5320511ebfbc0a5a8abd9470fc6a2f8b4c25f0c7d5cd07ff217836cb3d6f6bcda44a1d615fb3bf782ebdfb3161c49a292df251b581717ed4
|
7
|
+
data.tar.gz: d12c7f9d920bd6aa0df5c5aa15876b31c9693cca38f3fd628013ffa3f72e2323852405ea50d3fc9719509a78a8abfdaa2b4a4316e899fb37b692c7149acdfaf4
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -6,6 +6,8 @@
|
|
6
6
|
- Seamlessly handles the transition from anonymous visitor to logged in user
|
7
7
|
- Results are stored in your database
|
8
8
|
|
9
|
+
Uses [Bayesian methods](http://www.evanmiller.org/bayesian-ab-testing.html) to evaluate results so you don’t need to choose a sample size ahead of time.
|
10
|
+
|
9
11
|
## Installation
|
10
12
|
|
11
13
|
Add this line to your application’s Gemfile:
|
@@ -20,6 +22,14 @@ And run:
|
|
20
22
|
rails g field_test:install
|
21
23
|
```
|
22
24
|
|
25
|
+
And mount the dashboard in your `config/routes.rb`:
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
mount FieldTest::Engine, at: "field_test"
|
29
|
+
```
|
30
|
+
|
31
|
+
Be sure to [secure the dashboard](#security) in production.
|
32
|
+
|
23
33
|
## Getting Started
|
24
34
|
|
25
35
|
Add an experiment to `config/field_test.yml`.
|
@@ -28,9 +38,9 @@ Add an experiment to `config/field_test.yml`.
|
|
28
38
|
experiments:
|
29
39
|
button_color:
|
30
40
|
variants:
|
31
|
-
- control
|
32
41
|
- red
|
33
42
|
- green
|
43
|
+
- blue
|
34
44
|
```
|
35
45
|
|
36
46
|
Refer to it in views, controllers, and mailers.
|
@@ -76,18 +86,47 @@ For mailers, you need to specify a participant:
|
|
76
86
|
field_test(:button_color, participant: "test@example.org")
|
77
87
|
```
|
78
88
|
|
89
|
+
Keep track of when experiments started and ended.
|
90
|
+
|
91
|
+
```yml
|
92
|
+
experiments:
|
93
|
+
button_colors:
|
94
|
+
started_at: 2016-12-01 14:00:00
|
95
|
+
ended_at: 2016-12-08 14:00:00
|
96
|
+
```
|
97
|
+
|
79
98
|
## Funnels
|
80
99
|
|
81
100
|
For advanced funnels, we recommend an analytics platform like [Ahoy](https://github.com/ankane/ahoy) or [Mixpanel](https://mixpanel.com/).
|
82
101
|
|
83
102
|
You can pass experiments and variants as properties.
|
84
103
|
|
104
|
+
## Security
|
105
|
+
|
106
|
+
#### Basic Authentication
|
107
|
+
|
108
|
+
Set the following variables in your environment or an initializer.
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
ENV["FIELD_TEST_USERNAME"] = "link"
|
112
|
+
ENV["FIELD_TEST_PASSWORD"] = "hyrule"
|
113
|
+
```
|
114
|
+
|
115
|
+
#### Devise
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
authenticate :user, -> (user) { user.admin? } do
|
119
|
+
mount FieldTest::Engine, at: "field_test"
|
120
|
+
end
|
121
|
+
```
|
122
|
+
|
123
|
+
## Credits
|
124
|
+
|
125
|
+
A huge thanks to [Evan Miller](http://www.evanmiller.org/) for deriving the Bayesian formulas.
|
126
|
+
|
85
127
|
## TODO
|
86
128
|
|
87
|
-
- Add confidence to stats
|
88
|
-
- Add [Bayesian confidence](http://www.evanmiller.org/bayesian-ab-testing.html) to results
|
89
129
|
- Exclude bots
|
90
|
-
- User interface
|
91
130
|
|
92
131
|
## History
|
93
132
|
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module FieldTest
|
2
|
+
class HomeController < ActionController::Base
|
3
|
+
layout "field_test/application"
|
4
|
+
|
5
|
+
protect_from_forgery
|
6
|
+
|
7
|
+
http_basic_authenticate_with name: ENV["FIELD_TEST_USERNAME"], password: ENV["FIELD_TEST_PASSWORD"] if ENV["FIELD_TEST_PASSWORD"]
|
8
|
+
|
9
|
+
def index
|
10
|
+
@active_experiments, @completed_experiments = FieldTest::Experiment.all.sort_by(&:id).partition { |e| e.active? }
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
<% experiments.each do |experiment| %>
|
2
|
+
<% results = experiment.results %>
|
3
|
+
|
4
|
+
<h2><%= experiment.name %></h2>
|
5
|
+
<table>
|
6
|
+
<thead>
|
7
|
+
<tr>
|
8
|
+
<th>Name</th>
|
9
|
+
<th style="width: 20%;">Participants</th>
|
10
|
+
<th style="width: 20%;">Conversions</th>
|
11
|
+
<th style="width: 20%;">Conversion Rate</th>
|
12
|
+
<th style="width: 20%;">Prob Winning</th>
|
13
|
+
</tr>
|
14
|
+
</thead>
|
15
|
+
<tbody>
|
16
|
+
<% results.each do |variant, result| %>
|
17
|
+
<tr>
|
18
|
+
<td>
|
19
|
+
<%= variant %>
|
20
|
+
<% if variant == experiment.winner %>
|
21
|
+
<span style="color: #999;">winner</span>
|
22
|
+
<% end %>
|
23
|
+
</td>
|
24
|
+
<td><%= result[:participated] %></td>
|
25
|
+
<td><%= result[:converted] %></td>
|
26
|
+
<td>
|
27
|
+
<% if result[:conversion_rate] %>
|
28
|
+
<%= (100.0 * result[:conversion_rate]).round %>%
|
29
|
+
<% else %>
|
30
|
+
-
|
31
|
+
<% end %>
|
32
|
+
</td>
|
33
|
+
<td>
|
34
|
+
<% if result[:prob_winning] %>
|
35
|
+
<%= (100.0 * result[:prob_winning]).round %>%
|
36
|
+
<% end %>
|
37
|
+
</td>
|
38
|
+
</tr>
|
39
|
+
<% end %>
|
40
|
+
</tbody>
|
41
|
+
</table>
|
42
|
+
<% end %>
|
@@ -0,0 +1,11 @@
|
|
1
|
+
<% if @active_experiments.any? %>
|
2
|
+
<h1>Active Experiments</h1>
|
3
|
+
|
4
|
+
<%= render partial: "experiments", locals: {experiments: @active_experiments} %>
|
5
|
+
<% end %>
|
6
|
+
|
7
|
+
<% if @completed_experiments.any? %>
|
8
|
+
<h1>Completed Experiments</h1>
|
9
|
+
|
10
|
+
<%= render partial: "experiments", locals: {experiments: @completed_experiments} %>
|
11
|
+
<% end %>
|
@@ -0,0 +1,39 @@
|
|
1
|
+
<html>
|
2
|
+
<head>
|
3
|
+
<title>Field Test</title>
|
4
|
+
|
5
|
+
<style>
|
6
|
+
body {
|
7
|
+
margin: 0;
|
8
|
+
padding: 20px;
|
9
|
+
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
10
|
+
font-size: 14px;
|
11
|
+
line-height: 1.4;
|
12
|
+
color: #333;
|
13
|
+
}
|
14
|
+
|
15
|
+
table {
|
16
|
+
width: 100%;
|
17
|
+
border-collapse: collapse;
|
18
|
+
border-spacing: 0;
|
19
|
+
margin-bottom: 20px;
|
20
|
+
}
|
21
|
+
|
22
|
+
th {
|
23
|
+
text-align: left;
|
24
|
+
border-bottom: solid 2px #ddd;
|
25
|
+
}
|
26
|
+
|
27
|
+
table td, table th {
|
28
|
+
padding: 8px;
|
29
|
+
}
|
30
|
+
|
31
|
+
td {
|
32
|
+
border-top: solid 1px #ddd;
|
33
|
+
}
|
34
|
+
</style>
|
35
|
+
</head>
|
36
|
+
<body>
|
37
|
+
<%= yield %>
|
38
|
+
</body>
|
39
|
+
</html>
|
data/config/routes.rb
ADDED
data/field_test.gemspec
CHANGED
data/lib/field_test.rb
CHANGED
@@ -1,11 +1,20 @@
|
|
1
|
+
require "distribution/math_extension"
|
1
2
|
require "field_test/experiment"
|
2
3
|
require "field_test/engine"
|
3
4
|
require "field_test/helpers"
|
5
|
+
require "field_test/participant"
|
4
6
|
require "field_test/version"
|
5
7
|
|
6
8
|
module FieldTest
|
7
9
|
class Error < StandardError; end
|
8
|
-
|
10
|
+
class ExperimentNotFound < Error; end
|
11
|
+
|
12
|
+
def self.config
|
13
|
+
# reload in dev
|
14
|
+
@config = nil if Rails.env.development?
|
15
|
+
|
16
|
+
@config ||= YAML.load(ERB.new(File.read("config/field_test.yml")).result)
|
17
|
+
end
|
9
18
|
end
|
10
19
|
|
11
20
|
ActiveSupport.on_load(:action_controller) do
|
data/lib/field_test/engine.rb
CHANGED
@@ -1,19 +1,22 @@
|
|
1
1
|
module FieldTest
|
2
2
|
class Experiment
|
3
|
-
attr_reader :id, :variants, :winner
|
3
|
+
attr_reader :id, :name, :variants, :winner, :started_at, :ended_at
|
4
4
|
|
5
5
|
def initialize(attributes)
|
6
6
|
attributes = attributes.symbolize_keys
|
7
7
|
@id = attributes[:id]
|
8
|
+
@name = attributes[:name] || @id.to_s.titleize
|
8
9
|
@variants = attributes[:variants]
|
9
10
|
@winner = attributes[:winner]
|
11
|
+
@started_at = Time.zone.parse(attributes[:started_at].to_s) if attributes[:started_at]
|
12
|
+
@ended_at = Time.zone.parse(attributes[:ended_at].to_s) if attributes[:ended_at]
|
10
13
|
end
|
11
14
|
|
12
15
|
def variant(participants, options = {})
|
13
16
|
return winner if winner
|
14
17
|
return variants.first if options[:exclude]
|
15
18
|
|
16
|
-
participants =
|
19
|
+
participants = FieldTest::Participant.standardize(participants)
|
17
20
|
membership = membership_for(participants) || FieldTest::Membership.new(experiment: id)
|
18
21
|
|
19
22
|
if options[:variant] && variants.include?(options[:variant])
|
@@ -37,7 +40,7 @@ module FieldTest
|
|
37
40
|
end
|
38
41
|
|
39
42
|
def convert(participants)
|
40
|
-
participants =
|
43
|
+
participants = FieldTest::Participant.standardize(participants)
|
41
44
|
membership = membership_for(participants)
|
42
45
|
|
43
46
|
if membership
|
@@ -54,7 +57,10 @@ module FieldTest
|
|
54
57
|
end
|
55
58
|
|
56
59
|
def results
|
57
|
-
data = memberships.group(:variant).group(:converted)
|
60
|
+
data = memberships.group(:variant).group(:converted)
|
61
|
+
data = data.where("created_at >= ?", started_at) if started_at
|
62
|
+
data = data.where("created_at <= ?", ended_at) if ended_at
|
63
|
+
data = data.count
|
58
64
|
results = {}
|
59
65
|
variants.each do |variant|
|
60
66
|
converted = data[[variant, true]].to_i
|
@@ -62,22 +68,51 @@ module FieldTest
|
|
62
68
|
results[variant] = {
|
63
69
|
participated: participated,
|
64
70
|
converted: converted,
|
65
|
-
conversion_rate: converted.to_f / participated
|
71
|
+
conversion_rate: participated > 0 ? converted.to_f / participated : nil
|
66
72
|
}
|
67
73
|
end
|
74
|
+
case variants.size
|
75
|
+
when 1
|
76
|
+
results[variants[0]][:prob_winning] = 1
|
77
|
+
when 2, 3
|
78
|
+
variants.size.times do |i|
|
79
|
+
c = results.values[i]
|
80
|
+
b = results.values[(i + 1) % variants.size]
|
81
|
+
a = results.values[(i + 2) % variants.size]
|
82
|
+
|
83
|
+
alpha_a = 1 + a[:converted]
|
84
|
+
beta_a = 1 + a[:participated] - a[:converted]
|
85
|
+
alpha_b = 1 + b[:converted]
|
86
|
+
beta_b = 1 + b[:participated] - b[:converted]
|
87
|
+
alpha_c = 1 + c[:converted]
|
88
|
+
beta_c = 1 + c[:participated] - c[:converted]
|
89
|
+
|
90
|
+
results[variants[i]][:prob_winning] =
|
91
|
+
if variants.size == 2
|
92
|
+
prob_b_beats_a(alpha_b, beta_b, alpha_c, beta_c)
|
93
|
+
else
|
94
|
+
prob_c_beats_a_and_b(alpha_a, beta_a, alpha_b, beta_b, alpha_c, beta_c)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
68
98
|
results
|
69
99
|
end
|
70
100
|
|
71
|
-
def
|
72
|
-
|
73
|
-
|
101
|
+
def active?
|
102
|
+
!winner
|
103
|
+
end
|
74
104
|
|
75
|
-
|
105
|
+
def self.find(id)
|
106
|
+
experiment = all.index_by(&:id)[id.to_s]
|
107
|
+
raise FieldTest::ExperimentNotFound unless experiment
|
76
108
|
|
77
|
-
|
78
|
-
|
109
|
+
experiment
|
110
|
+
end
|
79
111
|
|
80
|
-
|
112
|
+
def self.all
|
113
|
+
FieldTest.config["experiments"].map do |id, settings|
|
114
|
+
FieldTest::Experiment.new(settings.merge(id: id.to_s))
|
115
|
+
end
|
81
116
|
end
|
82
117
|
|
83
118
|
private
|
@@ -87,8 +122,33 @@ module FieldTest
|
|
87
122
|
participants.map { |part| memberships[part] }.compact.first
|
88
123
|
end
|
89
124
|
|
90
|
-
|
91
|
-
|
125
|
+
# formula from
|
126
|
+
# http://www.evanmiller.org/bayesian-ab-testing.html
|
127
|
+
def prob_b_beats_a(alpha_a, beta_a, alpha_b, beta_b)
|
128
|
+
total = 0.0
|
129
|
+
|
130
|
+
0.upto(alpha_b - 1) do |i|
|
131
|
+
total += Math.exp(Math.logbeta(alpha_a + i, beta_b + beta_a) -
|
132
|
+
Math.log(beta_b + i) - Math.logbeta(1 + i, beta_b) -
|
133
|
+
Math.logbeta(alpha_a, beta_a))
|
134
|
+
end
|
135
|
+
|
136
|
+
total
|
137
|
+
end
|
138
|
+
|
139
|
+
def prob_c_beats_a_and_b(alpha_a, beta_a, alpha_b, beta_b, alpha_c, beta_c)
|
140
|
+
total = 0.0
|
141
|
+
0.upto(alpha_a - 1) do |i|
|
142
|
+
0.upto(alpha_b - 1) do |j|
|
143
|
+
total += Math.exp(Math.logbeta(alpha_c + i + j, beta_a + beta_b + beta_c) -
|
144
|
+
Math.log(beta_a + i) - Math.log(beta_b + j) -
|
145
|
+
Math.logbeta(1 + i, beta_a) - Math.logbeta(1 + j, beta_b) -
|
146
|
+
Math.logbeta(alpha_c, beta_c))
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
1 - prob_b_beats_a(alpha_c, beta_c, alpha_a, beta_a) -
|
151
|
+
prob_b_beats_a(alpha_c, beta_c, alpha_b, beta_b) + total
|
92
152
|
end
|
93
153
|
end
|
94
154
|
end
|
data/lib/field_test/helpers.rb
CHANGED
@@ -24,6 +24,18 @@ module FieldTest
|
|
24
24
|
exp.convert(participants)
|
25
25
|
end
|
26
26
|
|
27
|
+
def field_test_experiments(options = {})
|
28
|
+
participants = field_test_participants(options)
|
29
|
+
memberships = FieldTest::Membership.where(participant: participants).group_by(&:participant)
|
30
|
+
experiments = {}
|
31
|
+
participants.each do |participant|
|
32
|
+
memberships[participant].each do |membership|
|
33
|
+
experiments[membership.experiment] ||= membership.variant
|
34
|
+
end
|
35
|
+
end
|
36
|
+
experiments
|
37
|
+
end
|
38
|
+
|
27
39
|
def field_test_participants(options = {})
|
28
40
|
participants = []
|
29
41
|
|
@@ -48,7 +60,7 @@ module FieldTest
|
|
48
60
|
end
|
49
61
|
end
|
50
62
|
|
51
|
-
participants
|
63
|
+
FieldTest::Participant.standardize(participants)
|
52
64
|
end
|
53
65
|
end
|
54
66
|
end
|
data/lib/field_test/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: field_test
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-12-
|
11
|
+
date: 2016-12-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: railties
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: distribution
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: bundler
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -79,12 +93,18 @@ files:
|
|
79
93
|
- LICENSE.txt
|
80
94
|
- README.md
|
81
95
|
- Rakefile
|
96
|
+
- app/controllers/field_test/home_controller.rb
|
82
97
|
- app/models/field_test/membership.rb
|
98
|
+
- app/views/field_test/home/_experiments.html.erb
|
99
|
+
- app/views/field_test/home/index.html.erb
|
100
|
+
- app/views/layouts/field_test/application.html.erb
|
101
|
+
- config/routes.rb
|
83
102
|
- field_test.gemspec
|
84
103
|
- lib/field_test.rb
|
85
104
|
- lib/field_test/engine.rb
|
86
105
|
- lib/field_test/experiment.rb
|
87
106
|
- lib/field_test/helpers.rb
|
107
|
+
- lib/field_test/participant.rb
|
88
108
|
- lib/field_test/version.rb
|
89
109
|
- lib/generators/field_test/install_generator.rb
|
90
110
|
- lib/generators/field_test/templates/config.yml
|