trailguide 0.1.31 → 0.2.0

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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +73 -8
  3. data/app/assets/javascripts/trail_guide/admin/application.js +20 -1
  4. data/app/assets/stylesheets/trail_guide/admin/application.css +22 -0
  5. data/app/assets/stylesheets/trail_guide/admin/experiments.css +36 -3
  6. data/app/controllers/trail_guide/admin/application_controller.rb +59 -8
  7. data/app/controllers/trail_guide/admin/experiments_controller.rb +209 -16
  8. data/app/controllers/trail_guide/admin/groups_controller.rb +34 -0
  9. data/app/controllers/trail_guide/experiments_controller.rb +1 -1
  10. data/app/views/layouts/trail_guide/admin/_calculator.erb +24 -0
  11. data/app/views/layouts/trail_guide/admin/_footer.html.erb +27 -0
  12. data/app/views/layouts/trail_guide/admin/_header.html.erb +147 -0
  13. data/app/views/layouts/trail_guide/admin/_import_modal.html.erb +45 -0
  14. data/app/views/layouts/trail_guide/admin/application.html.erb +17 -3
  15. data/app/views/trail_guide/admin/experiments/_alert_peek.html.erb +19 -0
  16. data/app/views/trail_guide/admin/experiments/_alert_state.html.erb +49 -0
  17. data/app/views/trail_guide/admin/experiments/_btn_analyze.html.erb +11 -0
  18. data/app/views/trail_guide/admin/experiments/_btn_analyze_goal.html.erb +5 -0
  19. data/app/views/trail_guide/admin/experiments/_btn_convert.html.erb +33 -0
  20. data/app/views/trail_guide/admin/experiments/_btn_enroll.html.erb +3 -0
  21. data/app/views/trail_guide/admin/experiments/_btn_join.html.erb +11 -0
  22. data/app/views/trail_guide/admin/experiments/_btn_leave.html.erb +7 -0
  23. data/app/views/trail_guide/admin/experiments/_btn_pause.html.erb +5 -0
  24. data/app/views/trail_guide/admin/experiments/_btn_peek.html.erb +13 -0
  25. data/app/views/trail_guide/admin/experiments/_btn_reset.html.erb +5 -0
  26. data/app/views/trail_guide/admin/experiments/_btn_restart.html.erb +5 -0
  27. data/app/views/trail_guide/admin/experiments/_btn_resume.html.erb +5 -0
  28. data/app/views/trail_guide/admin/experiments/_btn_schedule.html.erb +6 -0
  29. data/app/views/trail_guide/admin/experiments/_btn_start.html.erb +5 -0
  30. data/app/views/trail_guide/admin/experiments/_btn_stop.html.erb +5 -0
  31. data/app/views/trail_guide/admin/experiments/_experiment.html.erb +68 -172
  32. data/app/views/trail_guide/admin/experiments/_header.html.erb +87 -0
  33. data/app/views/trail_guide/admin/experiments/_start_modal.html.erb +57 -0
  34. data/app/views/trail_guide/admin/experiments/_tbody.html.erb +112 -0
  35. data/app/views/trail_guide/admin/experiments/_thead.html.erb +38 -0
  36. data/app/views/trail_guide/admin/experiments/index.html.erb +8 -16
  37. data/app/views/trail_guide/admin/experiments/show.html.erb +78 -0
  38. data/app/views/trail_guide/admin/groups/index.html.erb +40 -0
  39. data/app/views/trail_guide/admin/groups/show.html.erb +9 -0
  40. data/app/views/trail_guide/admin/orphans/_alert.html.erb +44 -0
  41. data/config/initializers/trailguide.rb +72 -9
  42. data/config/routes/admin.rb +19 -12
  43. data/lib/trail_guide/admin/engine.rb +2 -0
  44. data/lib/trail_guide/admin.rb +2 -0
  45. data/lib/trail_guide/calculators/bayesian.rb +98 -0
  46. data/lib/trail_guide/calculators/calculator.rb +58 -0
  47. data/lib/trail_guide/calculators/score.rb +68 -0
  48. data/lib/trail_guide/calculators.rb +8 -0
  49. data/lib/trail_guide/catalog.rb +134 -19
  50. data/lib/trail_guide/combined_experiment.rb +8 -3
  51. data/lib/trail_guide/config.rb +6 -1
  52. data/lib/trail_guide/engine.rb +2 -0
  53. data/lib/trail_guide/errors.rb +30 -1
  54. data/lib/trail_guide/experiment.rb +0 -1
  55. data/lib/trail_guide/experiments/base.rb +189 -53
  56. data/lib/trail_guide/experiments/config.rb +82 -13
  57. data/lib/trail_guide/experiments/participant.rb +21 -1
  58. data/lib/trail_guide/helper.rb +59 -32
  59. data/lib/trail_guide/metrics/checkpoint.rb +24 -0
  60. data/lib/trail_guide/metrics/config.rb +45 -0
  61. data/lib/trail_guide/metrics/funnel.rb +24 -0
  62. data/lib/trail_guide/metrics/goal.rb +89 -0
  63. data/lib/trail_guide/metrics.rb +9 -0
  64. data/lib/trail_guide/participant.rb +54 -21
  65. data/lib/trail_guide/variant.rb +34 -12
  66. data/lib/trail_guide/version.rb +2 -2
  67. data/lib/trailguide.rb +4 -0
  68. metadata +112 -7
  69. data/app/views/layouts/trail_guide/admin/_footer.erb +0 -10
  70. data/app/views/layouts/trail_guide/admin/_header.erb +0 -42
  71. data/app/views/trail_guide/admin/experiments/_combined_experiment.html.erb +0 -189
  72. data/config/routes.rb +0 -5
@@ -0,0 +1,78 @@
1
+ <div class="experiment row justify-content-center">
2
+ <div class="col-sm-12 col-md-10 col-lg-8">
3
+ <%= render partial: "header", locals: { experiment: @experiment } %>
4
+
5
+ <% if @experiment.combined? %>
6
+ <table class="table table-hover">
7
+ <% combined_experiments = @experiment.combined.map { |e| TrailGuide.catalog.find(e) } %>
8
+ <% combined_experiments.each do |experiment| %>
9
+ <%= render partial: 'thead', locals: { experiment: experiment } %>
10
+ <%= render partial: 'tbody', locals: { experiment: experiment, calculator: experiment_calculator(experiment) } %>
11
+ <% end %>
12
+
13
+ <tfoot class="thead-light">
14
+ <tr>
15
+ <th class="btn-col">&nbsp;</th>
16
+ <th scope="row">&nbsp;</th>
17
+ <th>
18
+ <span><%= number_with_delimiter @experiment.participants %></span>
19
+ <% if @experiment.configuration.target_sample_size %>
20
+ <span class="text-muted">/ <%= number_with_delimiter @experiment.configuration.target_sample_size %></span>
21
+ <% end %>
22
+ </th>
23
+ <% if @experiment.goals.empty? %>
24
+ <th><%= experiment_metric @experiment, combined_experiments.sum(&:converted) %></th>
25
+ <% else %>
26
+ <% @experiment.goals.each do |goal| %>
27
+ <th><%= experiment_metric @experiment, combined_experiments.sum { |e| e.converted(goal) } %></th>
28
+ <% end %>
29
+ <% end %>
30
+ <th class="btn-col">&nbsp;</th>
31
+ </tr>
32
+ </tfoot>
33
+ </table>
34
+ <% else %>
35
+ <table class="table table-hover">
36
+ <%= render partial: 'thead', locals: { experiment: @experiment } %>
37
+ <%= render partial: 'tbody', locals: { experiment: @experiment, calculator: experiment_calculator(@experiment) } %>
38
+
39
+ <tfoot class="thead-light">
40
+ <tr>
41
+ <th class="btn-col">&nbsp;</th>
42
+ <th scope="row">&nbsp;</th>
43
+ <th>
44
+ <span><%= number_with_delimiter @experiment.participants %></span>
45
+ <% if @experiment.configuration.target_sample_size %>
46
+ <span class="text-muted">/ <%= number_with_delimiter @experiment.configuration.target_sample_size %></span>
47
+ <% end %>
48
+ </th>
49
+ <% if @experiment.goals.empty? %>
50
+ <th><%= experiment_metric @experiment, @experiment.converted %></th>
51
+ <% else %>
52
+ <% @experiment.goals.each do |goal| %>
53
+ <th><%= experiment_metric @experiment, @experiment.converted(goal) %></th>
54
+ <% end %>
55
+ <% end %>
56
+ <th class="btn-col">&nbsp;</th>
57
+ </tr>
58
+ </tfoot>
59
+ </table>
60
+ <% end %>
61
+
62
+ <div class="row" style="display: none;">
63
+ <div class="col-12">
64
+ <% if @analyzing && experiment_metrics_visible?(@experiment) %>
65
+ <div class="alert alert-primary">
66
+ <h6>
67
+ <span class="fas fa-chart-line"></span>
68
+ Analysis
69
+ </h6>
70
+ <p><small>TODO</small></p>
71
+ </div>
72
+ <% end %>
73
+ </div>
74
+ </div>
75
+
76
+ <%= render partial: 'alert_peek', locals: {experiment: @experiment} %>
77
+ </div>
78
+ </div>
@@ -0,0 +1,40 @@
1
+ <%= render partial: 'trail_guide/admin/orphans/alert' %>
2
+
3
+ <div class="row justify-content-center">
4
+ <div class="col-12 col-md-10 col-lg-8">
5
+ <div class="experiments">
6
+ <% @groups.each do |group| %>
7
+ <button href="<%= trail_guide_admin.group_path(group) %>" class="experiment btn btn-outline-secondary rounded-lg text-left">
8
+ <div class="row align-items-center">
9
+ <div class="col-12 col-md-8 col-lg-9 experiment-text">
10
+ <h4 id="<%= group %>">
11
+ <%= group.to_s.humanize.titleize %>
12
+ </h4>
13
+ </div>
14
+ <div class="col-12 col-md-4 col-lg-3 text-center text-md-right">
15
+ <div class="row align-items-center">
16
+ <div class="col-6 col-md-7 col-lg-8 experiment-text text-center">
17
+ <h4>
18
+ <span class="fas fa-flask"></span>
19
+ &times;
20
+ <%= TrailGuide.catalog.select(group).count %>
21
+ </h4>
22
+ </div>
23
+
24
+ <div class="col-6 col-md-5 col-lg-4 experiment-text text-center">
25
+ <h4>
26
+ <% if TrailGuide.catalog.select(group).all? { |e| e.goals.map(&:name).include?(group) } %>
27
+ <span class="fas fa-fill" data-toggle="tooltip" title="this group represents a shared conversion goal between multiple experiments"></span>
28
+ <% else %>
29
+ <span class="fas fa-th-large" data-toggle="tooltip" title="this group is not linked to any shared goals, and is purely organizational"></span>
30
+ <% end %>
31
+ </h4>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </button>
37
+ <% end %>
38
+ </div>
39
+ </div>
40
+ </div>
@@ -0,0 +1,9 @@
1
+ <div class="row justify-content-center">
2
+ <div class="col-12 col-md-10 col-lg-8">
3
+ <div class="experiments">
4
+ <% @experiments.each do |experiment| %>
5
+ <%= render 'trail_guide/admin/experiments/experiment', experiment: experiment %>
6
+ <% end %>
7
+ </div>
8
+ </div>
9
+ </div>
@@ -0,0 +1,44 @@
1
+ <% unless TrailGuide.configuration.ignore_orphaned_groups? || TrailGuide.catalog.orphans.empty? %>
2
+ <div class="row justify-content-center">
3
+ <div class="col-sm-12 col-md-10 col-lg-8">
4
+ <div class="alert alert-warning alert-dismissable">
5
+ <button type="button" class="close" data-dismiss="alert" aria-label="Close">
6
+ <span aria-hidden="true">&times;</span>
7
+ </button>
8
+
9
+ <h5>
10
+ <span class="fas fa-ghost"></span>
11
+ &nbsp;
12
+ Orphaned Groups
13
+ </h5>
14
+
15
+ <br />
16
+ <p>TrailGuide has detected <strong><%= TrailGuide.catalog.orphans.values.sum(&:count) %></strong> calls to <code>trailguide.convert</code> against <strong><%= TrailGuide.catalog.orphans.count %></strong> unknown group <%= "name".pluralize(TrailGuide.catalog.orphans.count) %>.</p>
17
+
18
+ <p><small>This usually means that the last experiment that belonged to a given group was removed without realizing it, and references to the group were overlooked. <strong>Don't worry, your users won't notice a thing</strong>, and you can find all the logged references below. You can find <%= link_to "more info here.", "https://github.com/markrebec/trailguide#orphaned-groups", target: :blank %></small></p>
19
+
20
+ <div class="accordion bg-transparent border-0" id="orphans">
21
+ <% TrailGuide.catalog.orphans.each do |orphan,traces| %>
22
+ <div class="card">
23
+ <button class="btn btn-outline-light text-left btn-block" type="button" data-toggle="collapse" data-target="#orphan-<%= orphan %>" aria-expanded="true" aria-controls="collapseOne">
24
+ <strong><code><%= orphan %></code></strong>
25
+ </button>
26
+
27
+ <div id="orphan-<%= orphan %>" class="collapse" aria-labelledby="orphan-btn-<%= orphan %>" data-parent="#orphans">
28
+ <div class="card-body">
29
+ <ul style="list-style: none;">
30
+ <% traces.each do |trace| %>
31
+ <li><code><%= trace %></code></li>
32
+ <% end %>
33
+ </ul>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ <% end %>
38
+ </div>
39
+
40
+ <p><small>These warnings will expire after 15 minutes of inactivity, and they will disappear automatically once you remove the references listed above and are no longer being reactivated. If you prefer to clear these warnings immediately after cleaning up, you can run <code>TrailGuide.catalog.adopted(:group_name)</code> from a console, rake task, or background job.</small></p>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ <% end %>
@@ -17,6 +17,17 @@ TrailGuide.configure do |config|
17
17
  # false you'll need to include the helper module manually where you want
18
18
  config.include_helpers = true
19
19
 
20
+ # whether or not to ignore orphaned group/experiment conversion calls in the
21
+ # admin - this can be useful if you intentionally leave calls to
22
+ # `trailguide.convert(:group_name)` at key points in your application, but
23
+ # also periodically add/remove experiments using those groups
24
+ #
25
+ # true will not track orphaned convert calls nor expose them in the admin
26
+ # interface
27
+ # false will track orphaned convert calls, and notify you via the admin
28
+ # interface so you can track them down and remove them as you'd like
29
+ config.ignore_orphaned_groups = false
30
+
20
31
  # request param for overriding/previewing variants - allows previewing
21
32
  # variants with request params
22
33
  # i.e. example.com/somepage/?experiment[my_experiment]=option_b
@@ -56,11 +67,13 @@ TrailGuide.configure do |config|
56
67
  # a couple milliseconds to participant initialization, but isn't strictly
57
68
  # necessary, since expired enrollment will never affect future experiment
58
69
  # participation - it might be good practice if you're using redis to store
59
- # participant data without expiration
70
+ # participant data without expiration, or to avoid overflowing a client cookie
60
71
  #
61
- # true will clean up any old/inactive experiment keys for each
62
- # participant the first time they're encountered during a script
72
+ # true will explicitly clean up any old/inactive experiment keys for each
73
+ # participant the first time they're initialized during a script
63
74
  # execution (web request, etc.)
75
+ # :inline will only clean up any old/inactive experiment keys that are
76
+ # encountered when referencing a participant's active experiments
64
77
  # false will skip the cleanup process entirely
65
78
  config.cleanup_participant_experiments = true
66
79
 
@@ -125,6 +138,19 @@ TrailGuide::Experiment.configure do |config|
125
138
  # the experiment if they encounter it again
126
139
  config.reset_manually = true
127
140
 
141
+ # whether or not to store individual participation when returning a variant
142
+ #
143
+ # this can be useful if you are using a custom, content-based algorithm where
144
+ # the variant is determined by content rather than user bucketing, and you
145
+ # want to treat participation more like impressions (i.e. for seo experiments)
146
+ #
147
+ # true participation is incremented the first time a participant is
148
+ # enrolled, and the participant is assigned their selection for future
149
+ # reference, stored via the configured participant adapter
150
+ # false participation will be incremented every time a variant is returned,
151
+ # and the participant will not have their assignment stored
152
+ config.store_participation = true
153
+
128
154
  # whether or not to enter participants into a variant when using the override
129
155
  # parameter to preview variants
130
156
  #
@@ -167,6 +193,21 @@ TrailGuide::Experiment.configure do |config|
167
193
  # false prevents converting to multiple goals for a single participant
168
194
  config.allow_multiple_goals = false
169
195
 
196
+ # whether or not to enable calibration before an experiment is started - the
197
+ # participants and conversions will be tracked for your control group while
198
+ # an experiment remains unstarted, which can be useful for gathering a
199
+ # baseline conversion rate if you don't already have one
200
+ #
201
+ # control is always returned for unstarted experiments by default, and this
202
+ # configuration only affects whether or not to track metrics
203
+ #
204
+ # this setting only applies when start_manually is also true
205
+ #
206
+ # true metrics for participation and conversion will be tracked for the
207
+ # control group until the experiment is started
208
+ # false metrics will not be tracked while the experiment remains unstarted
209
+ config.enable_calibration = false
210
+
170
211
  # whether or not to skip the request filtering for this experiment - can be
171
212
  # useful when defining content-based experiments with custom algorithms which
172
213
  # bucket participants strictly based on additional content metadata and you
@@ -208,6 +249,18 @@ TrailGuide::Experiment.configure do |config|
208
249
  #
209
250
  # config.on_start = -> (experiment, context) { ... }
210
251
 
252
+ # callback on experiment schedule manually via UI/console, can be used for logging,
253
+ # tracking, etc.
254
+ #
255
+ # experiments can only be scheduled if config.start_manually is true
256
+ #
257
+ # context may or may not be present depending on how you're triggering the
258
+ # action - if you're using the admin, this will be the admin controller
259
+ # context, if you're in a console you have the option to pass a context to
260
+ # `experiment.schedule!` or not
261
+ #
262
+ # config.on_schedule = -> (experiment, start_at, stop_at, context) { ... }
263
+
211
264
  # callback on experiment stop manually via UI/console, can be used for
212
265
  # logging, tracking, etc.
213
266
  #
@@ -272,26 +325,28 @@ TrailGuide::Experiment.configure do |config|
272
325
  # callback when a participant is entered into a variant for the first time,
273
326
  # can be used for logging, tracking, etc.
274
327
  #
275
- # config.on_choose = -> (experiment, variant, metadata) { ... }
328
+ # config.on_choose = -> (experiment, variant, participant, metadata) { ... }
276
329
 
277
330
  # callback every time a participant is returned a variant in the experiment,
278
331
  # can be used for logging, tracking, etc.
279
332
  #
280
- # config.on_use = -> (experiment, variant, metadata) { ... }
333
+ # config.on_use = -> (experiment, variant, participant, metadata) { ... }
281
334
 
282
335
  # callback when a participant converts for a variant in the experiment, can be
283
336
  # used for logging, tracking, etc.
284
337
  #
285
- # config.on_convert = -> (experiment, variant, checkpoint, metadata) { ... }
338
+ # config.on_convert = -> (experiment, checkpoint, variant, participant, metadata) { ... }
286
339
 
287
340
 
288
341
  # callback that can short-circuit participation based on your own logic, which
289
342
  # gets called *after* all the core engine checks (i.e. that the user is
290
343
  # not excluded or already participating, etc.)
291
344
  #
345
+ # `allowed` will be the value returned by any previous callbacks in the chain
346
+ #
292
347
  # should return true or false
293
348
  #
294
- # config.allow_participation = -> (experiment, metadata) { ... return true }
349
+ # config.allow_participation = -> (experiment, allowed, participant, metadata) { ... return true }
295
350
 
296
351
 
297
352
  # callback that can short-circuit conversion based on your own logic, which
@@ -299,9 +354,11 @@ TrailGuide::Experiment.configure do |config|
299
354
  # participating in the experiment, is within the bounds of the experiment
300
355
  # configuration for allow_multiple_*, etc.)
301
356
  #
357
+ # `allowed` will be the value returned by any previous callbacks in the chain
358
+ #
302
359
  # should return true or false
303
360
  #
304
- # config.allow_conversion = -> (experiment, checkpoint, metadata) { ... return true }
361
+ # config.allow_conversion = -> (experiment, allowed, checkpoint, variant, participant, metadata) { ... return true }
305
362
 
306
363
 
307
364
  # callback that can be used to modify the rollout of a selected winner - for
@@ -309,9 +366,15 @@ TrailGuide::Experiment.configure do |config|
309
366
  # gem to do a "feature rollout" from your control variant to your winner for
310
367
  # all users
311
368
  #
369
+ # be aware that when using this alongside track_winner_conversions, whatever
370
+ # variant is returned from this callback chain is what will be tracked for
371
+ # participation and conversion as long as the experiment is still running
372
+ #
373
+ # `winner` will be the variant returned by any previous callbacks in the chain
374
+ #
312
375
  # must return an experiment variant
313
376
  #
314
- # config.rollout_winner = -> (experiment, winner) { ... return variant }
377
+ # config.rollout_winner = -> (experiment, winner, participant) { ... return variant }
315
378
  end
316
379
 
317
380
  # admin ui configuration
@@ -1,22 +1,29 @@
1
1
  TrailGuide::Admin::Engine.routes.draw do
2
- resources :experiments, path: '/', only: [:index] do
2
+ resources :groups, only: [:index, :show]
3
+
4
+ resources :experiments, path: '/', only: [:index, :show] do
3
5
  member do
4
- match :start, via: [:put, :post, :get]
5
- match :pause, via: [:put, :post, :get]
6
- match :stop, via: [:put, :post, :get]
7
- match :reset, via: [:put, :post, :get]
8
- match :resume, via: [:put, :post, :get]
9
- match :restart, via: [:put, :post, :get]
6
+ match :start, via: [:put, :post, :get]
7
+ match :schedule, via: [:put, :post]
8
+ match :pause, via: [:put, :post, :get]
9
+ match :stop, via: [:put, :post, :get]
10
+ match :reset, via: [:put, :post, :get]
11
+ match :resume, via: [:put, :post, :get]
12
+ match :restart, via: [:put, :post, :get]
10
13
 
11
- match :join, via: [:put, :post, :get], path: 'join/:variant'
12
- match :leave, via: [:put, :post, :get]
14
+ match :enroll, via: [:put, :post, :get], path: 'enroll'
15
+ match :join, via: [:put, :post, :get], path: 'join/:variant'
16
+ match :convert, via: [:put, :post, :get], path: 'convert/:goal'
17
+ match :leave, via: [:put, :post, :get]
13
18
 
14
- match :winner, via: [:put, :post, :get], path: 'winner/:variant'
15
- match :clear, via: [:put, :post, :get]
19
+ match :winner, via: [:put, :post, :get], path: 'winner/:variant'
20
+ match :clear, via: [:put, :post, :get]
16
21
  end
17
22
 
18
23
  collection do
19
- get '/:scope', action: :index, as: :scoped
24
+ put '/import', action: :import, as: :import
25
+ get '/export', action: :index, as: :export, format: [:json]
26
+ get '/scope/:scope', action: :index, as: :scoped
20
27
  end
21
28
  end
22
29
  end
@@ -6,6 +6,8 @@ module TrailGuide
6
6
  config.generators do |g|
7
7
  g.test_framework = :rspec
8
8
  end
9
+
10
+ paths["config/routes.rb"] = "config/routes/admin.rb"
9
11
  end
10
12
  end
11
13
  end
@@ -3,5 +3,7 @@ require "trail_guide/admin/engine"
3
3
  module TrailGuide
4
4
  module Admin
5
5
  include Canfig::Module
6
+
7
+ DISPLAY_DATE_FORMAT = "%b %e %Y @ %l:%M %p"
6
8
  end
7
9
  end
@@ -0,0 +1,98 @@
1
+ module TrailGuide
2
+ module Calculators
3
+ class Bayesian < Calculator
4
+ def self.enabled?
5
+ !!(defined?(::Integration) && (defined?(::Rubystats) || defined?(::Distribution)))
6
+ end
7
+
8
+ attr_reader :beta
9
+
10
+ def initialize(*args, beta: nil, **opts)
11
+ raise NoIntegrationLibrary if !defined?(::Integration)
12
+
13
+ if beta.nil?
14
+ # prefer rubystats if not specified
15
+ if defined?(::Rubystats)
16
+ beta = :rubystats
17
+ elsif defined?(::Distribution)
18
+ beta = :distribution
19
+ else
20
+ raise NoBetaDistributionLibrary
21
+ end
22
+ end
23
+
24
+ case beta.to_sym
25
+ when :distribution
26
+ raise NoBetaDistributionLibrary, beta unless defined?(::Distribution)
27
+ TrailGuide.logger.debug "Using Distribution::Beta to calculate beta distributions"
28
+ TrailGuide.logger.debug "GSL detected, Distribution::Beta will use GSL for better performance" if defined?(::GSL)
29
+ when :rubystats
30
+ raise NoBetaDistributionLibrary, beta unless defined?(::Rubystats)
31
+ TrailGuide.logger.debug "Using Rubystats::BetaDistribution to calculate beta distributions"
32
+ else
33
+ raise UnknownBetaDistributionLibrary, beta
34
+ end
35
+
36
+ super(*args, **opts)
37
+ @beta = beta.to_sym
38
+ end
39
+
40
+ def pdf(variant, z)
41
+ x = variant.subset
42
+ n = variant.superset
43
+ if beta == :distribution
44
+ Distribution::Beta.pdf(z, x+1, n-x+1)
45
+ else
46
+ Rubystats::BetaDistribution.new(x+1, n-x+1).pdf(z)
47
+ end
48
+ end
49
+
50
+ def cdf(variant, z)
51
+ x = variant.subset
52
+ n = variant.superset
53
+ if beta == :distribution
54
+ Distribution::Beta.cdf(z, x+1, n-x+1)
55
+ else
56
+ Rubystats::BetaDistribution.new(x+1, n-x+1).cdf(z)
57
+ end
58
+ end
59
+
60
+ def variant_probability(variant)
61
+ Integration.integrate(0, 1, tolerance: 1e-4) do |z|
62
+ vpdf = pdf(variant, z)
63
+ variants.each do |var|
64
+ next if var == variant
65
+ vpdf = vpdf * cdf(var, z)
66
+ end
67
+ vpdf
68
+ end * 100.0
69
+ end
70
+
71
+ def calculate!
72
+ variants_with_conversion.each do |variant|
73
+ expvar = experiment.variants.find { |var| var.name == variant.name }
74
+ vprob = variant_probability(variant)
75
+ variant.probability = vprob
76
+ variant.significance = TrailGuide::Calculators::SIGNIFICANT_PROBABILITIES.reverse.find { |pct| vprob >= pct } || 0
77
+
78
+ #if worst && variant.measure > worst.measure
79
+ # variant.difference = (variant.measure - worst.measure) / worst.measure * 100
80
+ #end
81
+ if base
82
+ if variant.measure > base.measure
83
+ variant.difference = (variant.measure - base.measure) / base.measure * 100
84
+ elsif base.measure > variant.measure
85
+ variant.difference = -((base.measure - variant.measure) / base.measure * 100)
86
+ else
87
+ variant.difference = 0
88
+ end
89
+ end
90
+ end
91
+
92
+ @choice = best && best.probability >= probability ? best : nil
93
+
94
+ self
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,58 @@
1
+ module TrailGuide
2
+ module Calculators
3
+ SIGNIFICANT_PROBABILITIES = [10, 50, 75, 90, 95, 99, 99.9]
4
+ DEFAULT_PROBABILITY = 90
5
+
6
+ class Calculator
7
+ attr_reader :experiment, :goal, :probability, :choice
8
+
9
+ def initialize(experiment, probability=TrailGuide::Calculators::DEFAULT_PROBABILITY, base: :default, goal: nil, against: nil)
10
+ @experiment = experiment
11
+ @probability = probability
12
+ @base_type = base
13
+ @goal = goal
14
+ @against = against
15
+ end
16
+
17
+ def variants
18
+ @variants ||= experiment.variants.map do |variant|
19
+ superset = @against ? variant.converted(@against) : variant.participants
20
+ converts = variant.converted(goal)
21
+ measure = (converts.to_f / superset.to_f) rescue 0
22
+ measure = 0 if measure.nan?
23
+
24
+ Struct.new(:name, :control, :superset, :subset, :measure,
25
+ :difference, :probability, :significance, :z_score)
26
+ .new(variant.name, variant.control?, superset, converts, measure, 0, 0, nil, nil)
27
+ end.sort_by { |v| v.measure }
28
+ end
29
+
30
+ def variants_with_conversion
31
+ @variants_with_conversion ||= variants.select { |variant| variant.measure > 0.0 }
32
+ end
33
+
34
+ def base
35
+ @base ||= case @base_type
36
+ when :control
37
+ # use the control as the "base"
38
+ variants.find { |variant| variant.control }
39
+ else
40
+ # use the second-best converting as the "base" (default behavior)
41
+ variants[-2]
42
+ end
43
+ end
44
+
45
+ def best
46
+ @best ||= variants_with_conversion.last
47
+ end
48
+
49
+ def worst
50
+ @worst ||= variants_with_conversion.first
51
+ end
52
+
53
+ def calculate!
54
+ raise NotImplementedError, "You must define a calculate! method on your calculator class"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,68 @@
1
+ module TrailGuide
2
+ module Calculators
3
+ class Score < Calculator
4
+ # array of [z-score, percentage]
5
+ def self.z_score_probabilities
6
+ @@z_score_probabilities ||= begin
7
+ avg = 50.0
8
+ norm_dist = []
9
+ (0.0..3.1).step(0.01) { |x| norm_dist << [x, avg += 1 / Math.sqrt(2 * Math::PI) * Math::E ** (-x ** 2 / 2)] }
10
+ norm_dist
11
+ end
12
+ end
13
+
14
+ def self.all_probabilities
15
+ @@all_probabilities ||= (0.0..100.0).step(0.1).map { |pct| [z_score_probabilities.find { |x,a| a >= pct }.first, pct] }.reverse
16
+ end
17
+
18
+ def self.significant_probabilities
19
+ @@significant_probabilities ||= TrailGuide::Calculators::SIGNIFICANT_PROBABILITIES.map { |pct| [z_score_probabilities.find { |x,a| a >= pct }.first, pct] }.reverse
20
+ end
21
+
22
+ def self.z_score_probability(score)
23
+ score = score.abs
24
+ probability = all_probabilities.find { |z,p| score >= z }
25
+ probability ? probability.last : 0
26
+ end
27
+
28
+ def self.significant_probability(score)
29
+ score = score.abs
30
+ probability = significant_probabilities.find { |z,p| score >= z }
31
+ probability ? probability.last : 0
32
+ end
33
+
34
+ def calculate!
35
+ pc = base.measure
36
+ nc = base.superset
37
+ variants_with_conversion.each do |var|
38
+ p = var.measure
39
+ n = var.superset
40
+ z_score = (p - pc) / ((p * (1-p)/n) + (pc * (1-pc)/nc)).abs ** 0.5
41
+
42
+ var.z_score = z_score
43
+ var.probability = self.class.z_score_probability(z_score)
44
+ var.probability = -(var.probability) if var.z_score.negative?
45
+ var.significance = self.class.significant_probability(z_score)
46
+ var.significance = -(var.significance) if var.z_score.negative?
47
+
48
+ #if worst && var.measure > worst.measure
49
+ # var.difference = (var.measure - worst.measure) / worst.measure * 100
50
+ #end
51
+ if base
52
+ if var.measure > base.measure
53
+ var.difference = (var.measure - base.measure) / base.measure * 100
54
+ elsif base.measure > var.measure
55
+ var.difference = -((base.measure - var.measure) / base.measure * 100)
56
+ else
57
+ var.difference = 0
58
+ end
59
+ end
60
+ end
61
+
62
+ @choice = best && best.probability >= probability ? best : nil
63
+
64
+ self
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,8 @@
1
+ require 'trail_guide/calculators/calculator'
2
+ require 'trail_guide/calculators/score'
3
+ require 'trail_guide/calculators/bayesian'
4
+
5
+ module TrailGuide
6
+ module Calculators
7
+ end
8
+ end