field_test 0.1.0 → 0.1.1
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 +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
|