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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 21a7309f61348d5b03aa7a6073e2d7493dbc4f9a
4
- data.tar.gz: 486affca9a9ade457fa2481925995bdc55b4a954
3
+ metadata.gz: 177ccc428012c8862f9d798b6cf58629d41d54bb
4
+ data.tar.gz: 5b57269ef51ea55f5f10ff280d3c8aac895db01d
5
5
  SHA512:
6
- metadata.gz: b9c4d1fa745aa15f132cf8c74dcfd4f456864601c661139f96bef2b9cd17266b3a8c1dfcaec56d8b7fed21617e25d83c51ead8e73a7d7c59ae0ac057c5959eff
7
- data.tar.gz: d971c7c1907456a6af2162738ad4473891b02c17fd4b9b48e316757b596291cc84ee6dee73016f573284113f73a0884f611e20ab2e053af99f4929ada7e0a361
6
+ metadata.gz: e014861b6912b47d5320511ebfbc0a5a8abd9470fc6a2f8b4c25f0c7d5cd07ff217836cb3d6f6bcda44a1d615fb3bf782ebdfb3161c49a292df251b581717ed4
7
+ data.tar.gz: d12c7f9d920bd6aa0df5c5aa15876b31c9693cca38f3fd628013ffa3f72e2323852405ea50d3fc9719509a78a8abfdaa2b4a4316e899fb37b692c7149acdfaf4
@@ -1,3 +1,7 @@
1
+ ## 0.1.1
2
+
3
+ - Added basic web UI
4
+
1
5
  ## 0.1.0
2
6
 
3
7
  - First release
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>
@@ -0,0 +1,3 @@
1
+ FieldTest::Engine.routes.draw do
2
+ root "home#index"
3
+ end
@@ -21,6 +21,7 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.add_dependency "railties"
23
23
  spec.add_dependency "activerecord"
24
+ spec.add_dependency "distribution"
24
25
 
25
26
  spec.add_development_dependency "bundler"
26
27
  spec.add_development_dependency "rake"
@@ -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
- class ExperimentNotFound < Error; end
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
@@ -1,4 +1,8 @@
1
1
  module FieldTest
2
2
  class Engine < ::Rails::Engine
3
+ isolate_namespace FieldTest
4
+
5
+ # prevents conflict with field_test method in views
6
+ engine_name "field_test_engine"
3
7
  end
4
8
  end
@@ -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 = standardize_participants(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 = standardize_participants(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).count
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 self.find(id)
72
- # reload in dev
73
- @config = nil if Rails.env.development?
101
+ def active?
102
+ !winner
103
+ end
74
104
 
75
- @config ||= YAML.load(ERB.new(File.read("config/field_test.yml")).result)
105
+ def self.find(id)
106
+ experiment = all.index_by(&:id)[id.to_s]
107
+ raise FieldTest::ExperimentNotFound unless experiment
76
108
 
77
- settings = @config["experiments"][id.to_s]
78
- raise FieldTest::ExperimentNotFound unless settings
109
+ experiment
110
+ end
79
111
 
80
- FieldTest::Experiment.new(settings.merge(id: id.to_s))
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
- def standardize_participants(participants)
91
- Array(participants).map { |v| v.respond_to?(:model_name) ? "#{v.model_name.name}:#{v.id}" : v.to_s }
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
@@ -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
@@ -0,0 +1,7 @@
1
+ module FieldTest
2
+ class Participant
3
+ def self.standardize(participants)
4
+ Array(participants).map { |v| v.respond_to?(:model_name) ? "#{v.model_name.name}:#{v.id}" : v.to_s }
5
+ end
6
+ end
7
+ end
@@ -1,3 +1,3 @@
1
1
  module FieldTest
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
@@ -1,7 +1,7 @@
1
1
  experiments:
2
2
  button_color:
3
3
  variants:
4
- - control
5
4
  - red
6
5
  - green
7
- # winner: red
6
+ - blue
7
+ # winner: green
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.0
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-14 00:00:00.000000000 Z
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