field_test 0.2.0 → 0.2.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.

Potentially problematic release.


This version of field_test might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 715ea396faed10c5b142f003eb76405d8ba34f01
4
- data.tar.gz: 43a830f9996b974a917734f39641d8783b9c33c9
3
+ metadata.gz: 1f722991205f31c2faf4c59852d717ac229f3043
4
+ data.tar.gz: 18b45c92adbcec168c41e051ea1c6613c6dadbfc
5
5
  SHA512:
6
- metadata.gz: fee3d99177f6e2044bc38bba2a557e4832b9a402b4743400a8c6a47cff272885417100caf7522ef48ef62f2641a71790e1107cc4885d079d5624673a26178e18
7
- data.tar.gz: 07a3450cd3e1ad4da4a909841ea7532c7683b65a366907345f186bd261bac53721d1a4b603f97c0f8e541773dc39d2e7398a41655c0811effeece190b29a9741
6
+ metadata.gz: d18d2688655ebd5a1d523b3ec8452fd96701e1a63b6cb7f93eae395fd2bfae39d7bf1c716e6617cf9a96e47e7fd129035ef31f3243e928653c52839dba499a9b
7
+ data.tar.gz: 8cedb82ac15520e091e46cc82ec7fa21faa41ec7ed0fc60f0d351b82948cb6c823ecaca645423893feaa5a1a4dc828e173a1255fd39bffe2401d84a868fc7fbf
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.2.1
2
+
3
+ - Added support for multiple goals
4
+
1
5
  ## 0.2.0
2
6
 
3
7
  - Better web UI
data/README.md CHANGED
@@ -3,10 +3,10 @@
3
3
  :maple_leaf: A/B testing for Rails
4
4
 
5
5
  - Designed for web and email
6
- - Comes with a [nice dashboard](https://fieldtest.dokkuapp.com/)
6
+ - Comes with a [handy dashboard](https://fieldtest.dokkuapp.com/)
7
7
  - Seamlessly handles the transition from anonymous visitor to logged in user
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.
9
+ Uses [Bayesian statistics](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
10
 
11
11
  ## Installation
12
12
 
@@ -62,7 +62,7 @@ When an experiment is over, specify a winner:
62
62
  ```yml
63
63
  experiments:
64
64
  button_color:
65
- winner: red
65
+ winner: green
66
66
  ```
67
67
 
68
68
  All calls to `field_test` will now return the winner, and metrics will stop being recorded.
@@ -72,16 +72,18 @@ All calls to `field_test` will now return the winner, and metrics will stop bein
72
72
  You can specify a variant with query parameters to make testing easier
73
73
 
74
74
  ```
75
- http://localhost:3000/?field_test[button_color]=red
75
+ ?field_test[button_color]=green
76
76
  ```
77
77
 
78
78
  Assign a specific variant to a user with:
79
79
 
80
80
  ```ruby
81
81
  experiment = FieldTest::Experiment.find(:button_color)
82
- experiment.variant(participant, variant: "red")
82
+ experiment.variant(participant, variant: "green")
83
83
  ```
84
84
 
85
+ You can also change a user’s variant from the dashboard.
86
+
85
87
  ## Config
86
88
 
87
89
  By default, bots are returned the first variant and excluded from metrics. Change this with:
@@ -91,7 +93,7 @@ exclude:
91
93
  bots: false
92
94
  ```
93
95
 
94
- Keep track of when experiments started and ended. Use any format `Time.parse` accepts.
96
+ Keep track of when experiments started and ended. Use any format `Time.parse` accepts. Variants assigned outside this window are not included in metrics.
95
97
 
96
98
  ```yml
97
99
  experiments:
@@ -120,8 +122,8 @@ experiments:
120
122
  - red
121
123
  - blue
122
124
  weights:
123
- - 90
124
- - 10
125
+ - 85
126
+ - 15
125
127
  ```
126
128
 
127
129
  If the dashboard gets slow, you can make it faster with:
@@ -134,9 +136,33 @@ This will use the Rails cache to speed up winning probability calculations.
134
136
 
135
137
  ## Funnels
136
138
 
137
- For advanced funnels, we recommend an analytics platform like [Ahoy](https://github.com/ankane/ahoy) or [Mixpanel](https://mixpanel.com/).
139
+ You can set multiple goals for an experiment to track conversions at different parts of the funnel. First, run:
140
+
141
+ ```sh
142
+ rails g field_test:events
143
+ ```
144
+
145
+ And add to your config:
146
+
147
+ ```yml
148
+ experiments:
149
+ button_color:
150
+ goals:
151
+ - signed_up
152
+ - ordered
153
+ ```
154
+
155
+ Specify a goal during conversion with:
156
+
157
+ ```ruby
158
+ field_test_converted(:button_color, goal: "ordered")
159
+ ```
160
+
161
+ The results for all goals will appear on the dashboard.
162
+
163
+ ### Advanced
138
164
 
139
- You can use:
165
+ For advanced funnels, we recommend an analytics platform like [Ahoy](https://github.com/ankane/ahoy) or [Mixpanel](https://mixpanel.com/). You can use:
140
166
 
141
167
  ```ruby
142
168
  field_test_experiments
@@ -11,6 +11,13 @@ module FieldTest
11
11
  @page = [1, params[:page].to_i].max
12
12
  offset = (@page - 1) * @per_page
13
13
  @memberships = @experiment.memberships.order(created_at: :desc).limit(@per_page).offset(offset).to_a
14
+
15
+ @events =
16
+ if FieldTest.events_supported?
17
+ @experiment.events.where(field_test_membership_id: @memberships.map(&:id)).group(:field_test_membership_id, :name).count
18
+ else
19
+ {}
20
+ end
14
21
  rescue FieldTest::ExperimentNotFound
15
22
  raise ActionController::RoutingError, "Experiment not found"
16
23
  end
@@ -2,6 +2,15 @@ module FieldTest
2
2
  class ParticipantsController < BaseController
3
3
  def show
4
4
  @participant = params[:id]
5
+ # TODO better ordering
6
+ @memberships = FieldTest::Membership.where(participant: @participant).order(:id)
7
+
8
+ @events =
9
+ if FieldTest.events_supported?
10
+ FieldTest::Event.where(field_test_membership_id: @memberships.map(&:id)).group(:field_test_membership_id, :name).count
11
+ else
12
+ {}
13
+ end
5
14
  end
6
15
  end
7
16
  end
@@ -0,0 +1,8 @@
1
+ module FieldTest
2
+ class Event < ActiveRecord::Base
3
+ self.table_name = "field_test_events"
4
+
5
+ belongs_to :field_test_membership, class_name: "FieldTest::Membership"
6
+ validates :name, presence: true
7
+ end
8
+ end
@@ -2,6 +2,8 @@ module FieldTest
2
2
  class Membership < ActiveRecord::Base
3
3
  self.table_name = "field_test_memberships"
4
4
 
5
+ has_many :events, class_name: "FieldTest::Event"
6
+
5
7
  validates :participant, presence: true
6
8
  validates :experiment, presence: true
7
9
  validates :variant, presence: true
@@ -1,55 +1,60 @@
1
1
  <% experiments.each do |experiment| %>
2
- <% results = experiment.results %>
3
-
4
2
  <h2>
5
3
  <%= experiment.name %>
6
4
  <small><%= link_to "Details", experiment_path(experiment.id) %></small>
7
5
  </h2>
8
6
 
9
-
10
7
  <% if experiment.description %>
11
8
  <p class="description"><%= experiment.description %></p>
12
9
  <% end %>
13
10
 
14
- <table>
15
- <thead>
16
- <tr>
17
- <th>Variant</th>
18
- <th style="width: 20%;">Participants</th>
19
- <th style="width: 20%;">Conversions</th>
20
- <th style="width: 20%;">Conversion Rate</th>
21
- <th style="width: 20%;">Prob Winning</th>
22
- </tr>
23
- </thead>
24
- <tbody>
25
- <% results.each do |variant, result| %>
11
+ <% experiment.goals.each do |goal| %>
12
+ <% results = experiment.results(goal: goal) %>
13
+
14
+ <% if experiment.multiple_goals? %>
15
+ <h3><%= goal.titleize %></h3>
16
+ <% end %>
17
+
18
+ <table>
19
+ <thead>
26
20
  <tr>
27
- <td>
28
- <%= variant %>
29
- <% if variant == experiment.winner %>
30
- <span class="check">✓</span>
31
- <% end %>
32
- </td>
33
- <td><%= result[:participated] %></td>
34
- <td><%= result[:converted] %></td>
35
- <td>
36
- <% if result[:conversion_rate] %>
37
- <%= (100.0 * result[:conversion_rate]).round %>%
38
- <% else %>
39
- -
40
- <% end %>
41
- </td>
42
- <td>
43
- <% if result[:prob_winning] %>
44
- <% if result[:prob_winning] < 0.01 %>
45
- &lt; 1%
21
+ <th>Variant</th>
22
+ <th style="width: 20%;">Participants</th>
23
+ <th style="width: 20%;">Conversions</th>
24
+ <th style="width: 20%;">Conversion Rate</th>
25
+ <th style="width: 20%;">Prob Winning</th>
26
+ </tr>
27
+ </thead>
28
+ <tbody>
29
+ <% results.each do |variant, result| %>
30
+ <tr>
31
+ <td>
32
+ <%= variant %>
33
+ <% if variant == experiment.winner %>
34
+ <span class="check">✓</span>
35
+ <% end %>
36
+ </td>
37
+ <td><%= result[:participated] %></td>
38
+ <td><%= result[:converted] %></td>
39
+ <td>
40
+ <% if result[:conversion_rate] %>
41
+ <%= (100.0 * result[:conversion_rate]).round %>%
46
42
  <% else %>
47
- <%= (100.0 * result[:prob_winning]).round %>%
43
+ -
48
44
  <% end %>
49
- <% end %>
50
- </td>
51
- </tr>
52
- <% end %>
53
- </tbody>
54
- </table>
45
+ </td>
46
+ <td>
47
+ <% if result[:prob_winning] %>
48
+ <% if result[:prob_winning] < 0.01 %>
49
+ &lt; 1%
50
+ <% else %>
51
+ <%= (100.0 * result[:prob_winning]).round %>%
52
+ <% end %>
53
+ <% end %>
54
+ </td>
55
+ </tr>
56
+ <% end %>
57
+ </tbody>
58
+ </table>
59
+ <% end %>
55
60
  <% end %>
@@ -6,6 +6,10 @@
6
6
 
7
7
  <h1><%= @experiment.name %></h1>
8
8
 
9
+ <% if @experiment.description %>
10
+ <p class="description"><%= @experiment.description %></p>
11
+ <% end %>
12
+
9
13
  <table>
10
14
  <thead>
11
15
  <tr>
@@ -21,7 +25,20 @@
21
25
  <td><%= link_to membership.participant, participant_path(membership.participant) %></td>
22
26
  <td><%= membership.variant %></td>
23
27
  <td>
24
- <% if membership.converted %>
28
+ <% converted = false %>
29
+ <% @experiment.goals.each do |goal| %>
30
+ <% if @events[[membership.id, goal]] %>
31
+ <% converted = true %>
32
+ <div>
33
+ <span class="check">✓</span>
34
+ <% if @experiment.multiple_goals? %>
35
+ <%= goal.titleize %>
36
+ <% end %>
37
+ </div>
38
+ <% end %>
39
+ <% end %>
40
+
41
+ <% if !converted && membership.try(:converted) %>
25
42
  <span class="check">✓</span>
26
43
  <% end %>
27
44
  </td>
@@ -16,7 +16,7 @@
16
16
  </tr>
17
17
  </thead>
18
18
  <tbody>
19
- <% FieldTest::Membership.where(participant: @participant).each do |membership| %>
19
+ <% @memberships.each do |membership| %>
20
20
  <tr>
21
21
  <td><%= link_to membership.experiment, experiment_path(membership.experiment) %></td>
22
22
  <td>
@@ -30,7 +30,22 @@
30
30
  <% end %>
31
31
  </td>
32
32
  <td>
33
- <% if membership.converted %>
33
+ <% converted = false %>
34
+ <% if experiment %>
35
+ <% experiment.goals.each do |goal| %>
36
+ <% if @events[[membership.id, goal]] %>
37
+ <% converted = true %>
38
+ <div>
39
+ <span class="check">✓</span>
40
+ <% if experiment.multiple_goals? %>
41
+ <%= goal.titleize %>
42
+ <% end %>
43
+ </div>
44
+ <% end %>
45
+ <% end %>
46
+ <% end %>
47
+
48
+ <% if !converted && membership.try(:converted) %>
34
49
  <span class="check">✓</span>
35
50
  <% end %>
36
51
  </td>
@@ -36,6 +36,7 @@
36
36
 
37
37
  td {
38
38
  border-top: solid 1px #ddd;
39
+ vertical-align: top;
39
40
  }
40
41
 
41
42
  h2 small {
@@ -43,6 +44,20 @@
43
44
  font-weight: normal;
44
45
  }
45
46
 
47
+ form {
48
+ margin: 0;
49
+ }
50
+
51
+ ul {
52
+ margin: 0;
53
+ padding: 0;
54
+ list-style-type: none;
55
+ }
56
+
57
+ li {
58
+ margin-bottom: 0;
59
+ }
60
+
46
61
  .description {
47
62
  color: #999;
48
63
  }
data/lib/field_test.rb CHANGED
@@ -1,4 +1,3 @@
1
- require "distribution/math_extension"
2
1
  require "browser"
3
2
  require "active_support"
4
3
  require "field_test/calculations"
@@ -28,6 +27,20 @@ module FieldTest
28
27
  def self.cache
29
28
  config["cache"]
30
29
  end
30
+
31
+ def self.events_supported?
32
+ unless defined?(@events_supported)
33
+ connection = FieldTest::Membership.connection
34
+ table_name = "field_test_events"
35
+ @events_supported =
36
+ if connection.respond_to?(:data_source_exists?)
37
+ connection.data_source_exists?(table_name)
38
+ else
39
+ connection.table_exists?(table_name)
40
+ end
41
+ end
42
+ @events_supported
43
+ end
31
44
  end
32
45
 
33
46
  ActiveSupport.on_load(:action_controller) do
@@ -1,6 +1,7 @@
1
+ require "distribution/math_extension"
2
+
1
3
  # formulas from
2
4
  # http://www.evanmiller.org/bayesian-ab-testing.html
3
-
4
5
  module FieldTest
5
6
  module Calculations
6
7
  def self.prob_b_beats_a(alpha_a, beta_a, alpha_b, beta_b)
@@ -27,14 +28,12 @@ module FieldTest
27
28
  abc = beta_a + beta_b + beta_c
28
29
  log_bb_j = []
29
30
  logbeta_j_bb = []
31
+ logbeta_ac_i_j = []
30
32
  0.upto(alpha_b - 1) do |j|
31
33
  log_bb_j[j] = Math.log(beta_b + j)
32
34
  logbeta_j_bb[j] = Math.logbeta(1 + j, beta_b)
33
- end
34
35
 
35
- logbeta_ac_i_j = []
36
- 0.upto(alpha_a - 1) do |i|
37
- 0.upto(alpha_b - 1) do |j|
36
+ 0.upto(alpha_a - 1) do |i|
38
37
  logbeta_ac_i_j[i + j] ||= Math.logbeta(alpha_c + i + j, abc)
39
38
  end
40
39
  end
@@ -1,6 +1,6 @@
1
1
  module FieldTest
2
2
  class Experiment
3
- attr_reader :id, :name, :description, :variants, :weights, :winner, :started_at, :ended_at
3
+ attr_reader :id, :name, :description, :variants, :weights, :winner, :started_at, :ended_at, :goals
4
4
 
5
5
  def initialize(attributes)
6
6
  attributes = attributes.symbolize_keys
@@ -12,6 +12,8 @@ module FieldTest
12
12
  @winner = attributes[:winner]
13
13
  @started_at = Time.zone.parse(attributes[:started_at].to_s) if attributes[:started_at]
14
14
  @ended_at = Time.zone.parse(attributes[:ended_at].to_s) if attributes[:ended_at]
15
+ @goals = attributes[:goals] || ["conversion"]
16
+ @use_events = attributes[:use_events]
15
17
  end
16
18
 
17
19
  def variant(participants, options = {})
@@ -53,14 +55,26 @@ module FieldTest
53
55
  membership.try(:variant) || variants.first
54
56
  end
55
57
 
56
- def convert(participants)
58
+ def convert(participants, goal: nil)
59
+ goal ||= goals.first
60
+
57
61
  participants = FieldTest::Participant.standardize(participants)
58
62
  check_participants(participants)
59
63
  membership = membership_for(participants)
60
64
 
61
65
  if membership
62
- membership.converted = true
63
- membership.save! if membership.changed?
66
+ if membership.respond_to?(:converted)
67
+ membership.converted = true
68
+ membership.save! if membership.changed?
69
+ end
70
+
71
+ if use_events?
72
+ FieldTest::Event.create!(
73
+ name: goal,
74
+ field_test_membership_id: membership.id
75
+ )
76
+ end
77
+
64
78
  true
65
79
  else
66
80
  false
@@ -71,11 +85,36 @@ module FieldTest
71
85
  FieldTest::Membership.where(experiment: id)
72
86
  end
73
87
 
74
- def results
75
- data = memberships.group(:variant).group(:converted)
76
- data = data.where("created_at >= ?", started_at) if started_at
77
- data = data.where("created_at <= ?", ended_at) if ended_at
78
- data = data.count
88
+ def events
89
+ FieldTest::Event.joins(:field_test_membership).where(field_test_memberships: {experiment: id})
90
+ end
91
+
92
+ def multiple_goals?
93
+ goals.size > 1
94
+ end
95
+
96
+ def results(goal: nil)
97
+ goal ||= goals.first
98
+
99
+ relation = memberships.group(:variant)
100
+ relation = relation.where("created_at >= ?", started_at) if started_at
101
+ relation = relation.where("created_at <= ?", ended_at) if ended_at
102
+
103
+ if use_events?
104
+ data = {}
105
+ sql =
106
+ relation.joins("LEFT JOIN field_test_events ON field_test_events.field_test_membership_id = field_test_memberships.id")
107
+ .select("variant, COUNT(DISTINCT participant) AS participated, COUNT(DISTINCT field_test_membership_id) AS converted")
108
+ .where(field_test_events: {name: goal})
109
+
110
+ FieldTest::Membership.connection.select_all(sql).each do |row|
111
+ data[[row["variant"], true]] = row["converted"].to_i
112
+ data[[row["variant"], false]] = row["participated"].to_i - row["converted"].to_i
113
+ end
114
+ else
115
+ data = relation.group(:converted).count
116
+ end
117
+
79
118
  results = {}
80
119
  variants.each do |variant|
81
120
  converted = data[[variant, true]].to_i
@@ -127,6 +166,14 @@ module FieldTest
127
166
  !winner
128
167
  end
129
168
 
169
+ def use_events?
170
+ if @use_events.nil?
171
+ FieldTest.events_supported?
172
+ else
173
+ @use_events
174
+ end
175
+ end
176
+
130
177
  def self.find(id)
131
178
  experiment = all.index_by(&:id)[id.to_s]
132
179
  raise FieldTest::ExperimentNotFound unless experiment
@@ -28,7 +28,7 @@ module FieldTest
28
28
 
29
29
  participants = field_test_participants(options)
30
30
 
31
- exp.convert(participants)
31
+ exp.convert(participants, goal: options[:goal])
32
32
  end
33
33
 
34
34
  def field_test_experiments(options = {})
@@ -1,3 +1,3 @@
1
1
  module FieldTest
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end
@@ -0,0 +1,27 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+ require "active_record"
4
+ require "rails/generators/active_record"
5
+
6
+ module FieldTest
7
+ module Generators
8
+ class EventsGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+ source_root File.expand_path("../templates", __FILE__)
11
+
12
+ # Implement the required interface for Rails::Generators::Migration.
13
+ def self.next_migration_number(dirname) #:nodoc:
14
+ next_migration_number = current_migration_number(dirname) + 1
15
+ if ::ActiveRecord::Base.timestamped_migrations
16
+ [Time.now.utc.strftime("%Y%m%d%H%M%S"), "%.14d" % next_migration_number].max
17
+ else
18
+ "%.3d" % next_migration_number
19
+ end
20
+ end
21
+
22
+ def copy_migration
23
+ migration_template "events.rb", "db/migrate/create_field_test_events.rb"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration
2
+ def change
3
+ create_table :field_test_events do |t|
4
+ t.integer :field_test_membership_id
5
+ t.string :name
6
+ t.timestamp :created_at
7
+ end
8
+
9
+ add_index :field_test_events, :field_test_membership_id
10
+ end
11
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: field_test
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
@@ -125,6 +125,7 @@ files:
125
125
  - app/controllers/field_test/experiments_controller.rb
126
126
  - app/controllers/field_test/memberships_controller.rb
127
127
  - app/controllers/field_test/participants_controller.rb
128
+ - app/models/field_test/event.rb
128
129
  - app/models/field_test/membership.rb
129
130
  - app/views/field_test/experiments/_experiments.html.erb
130
131
  - app/views/field_test/experiments/index.html.erb
@@ -140,8 +141,10 @@ files:
140
141
  - lib/field_test/helpers.rb
141
142
  - lib/field_test/participant.rb
142
143
  - lib/field_test/version.rb
144
+ - lib/generators/field_test/events_generator.rb
143
145
  - lib/generators/field_test/install_generator.rb
144
146
  - lib/generators/field_test/templates/config.yml
147
+ - lib/generators/field_test/templates/events.rb
145
148
  - lib/generators/field_test/templates/memberships.rb
146
149
  homepage: https://github.com/ankane/field_test
147
150
  licenses: []