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