field_test 0.2.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.

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: []