vanity 3.1.0 → 4.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/linting.yml +28 -0
- data/.github/workflows/test.yml +3 -6
- data/.rubocop.yml +114 -0
- data/.rubocop_todo.yml +67 -0
- data/Appraisals +9 -31
- data/CHANGELOG +15 -0
- data/Gemfile +7 -3
- data/Gemfile.lock +35 -7
- data/README.md +4 -9
- data/Rakefile +25 -24
- data/bin/vanity +1 -1
- data/doc/configuring.textile +1 -0
- data/gemfiles/rails52.gemfile +6 -3
- data/gemfiles/rails52.gemfile.lock +36 -11
- data/gemfiles/rails60.gemfile +6 -3
- data/gemfiles/rails60.gemfile.lock +36 -11
- data/gemfiles/rails61.gemfile +6 -3
- data/gemfiles/rails61.gemfile.lock +36 -11
- data/lib/generators/vanity/migration_generator.rb +5 -7
- data/lib/vanity/adapters/abstract_adapter.rb +43 -45
- data/lib/vanity/adapters/active_record_adapter.rb +30 -30
- data/lib/vanity/adapters/mock_adapter.rb +14 -18
- data/lib/vanity/adapters/mongodb_adapter.rb +73 -69
- data/lib/vanity/adapters/redis_adapter.rb +26 -28
- data/lib/vanity/adapters.rb +1 -1
- data/lib/vanity/autoconnect.rb +6 -7
- data/lib/vanity/commands/list.rb +7 -7
- data/lib/vanity/commands/report.rb +18 -22
- data/lib/vanity/configuration.rb +19 -19
- data/lib/vanity/connection.rb +12 -14
- data/lib/vanity/experiment/ab_test.rb +82 -70
- data/lib/vanity/experiment/alternative.rb +3 -5
- data/lib/vanity/experiment/base.rb +24 -19
- data/lib/vanity/experiment/bayesian_bandit_score.rb +7 -13
- data/lib/vanity/experiment/definition.rb +6 -6
- data/lib/vanity/frameworks/rails.rb +39 -39
- data/lib/vanity/frameworks.rb +2 -2
- data/lib/vanity/helpers.rb +1 -1
- data/lib/vanity/metric/active_record.rb +21 -19
- data/lib/vanity/metric/base.rb +22 -23
- data/lib/vanity/metric/google_analytics.rb +6 -9
- data/lib/vanity/metric/remote.rb +3 -5
- data/lib/vanity/playground.rb +3 -6
- data/lib/vanity/vanity.rb +8 -12
- data/lib/vanity/version.rb +1 -1
- data/test/adapters/active_record_adapter_test.rb +1 -5
- data/test/adapters/mock_adapter_test.rb +0 -2
- data/test/adapters/mongodb_adapter_test.rb +1 -5
- data/test/adapters/redis_adapter_test.rb +3 -4
- data/test/adapters/shared_tests.rb +9 -12
- data/test/autoconnect_test.rb +3 -3
- data/test/cli_test.rb +0 -1
- data/test/configuration_test.rb +18 -34
- data/test/connection_test.rb +3 -3
- data/test/dummy/Rakefile +1 -1
- data/test/dummy/app/controllers/use_vanity_controller.rb +12 -8
- data/test/dummy/app/mailers/vanity_mailer.rb +3 -3
- data/test/dummy/config/application.rb +1 -1
- data/test/dummy/config/boot.rb +3 -3
- data/test/dummy/config/environment.rb +1 -1
- data/test/dummy/config/environments/development.rb +0 -1
- data/test/dummy/config/environments/test.rb +1 -1
- data/test/dummy/config/initializers/session_store.rb +1 -1
- data/test/dummy/config/initializers/vanity.rb +3 -0
- data/test/dummy/config/vanity.yml +7 -0
- data/test/dummy/config.ru +1 -1
- data/test/dummy/script/rails +2 -2
- data/test/experiment/ab_test.rb +148 -154
- data/test/experiment/base_test.rb +48 -32
- data/test/frameworks/rails/action_controller_test.rb +25 -25
- data/test/frameworks/rails/action_mailer_test.rb +2 -2
- data/test/frameworks/rails/action_view_test.rb +5 -6
- data/test/frameworks/rails/rails_test.rb +147 -181
- data/test/helper_test.rb +2 -2
- data/test/metric/active_record_test.rb +174 -212
- data/test/metric/base_test.rb +21 -46
- data/test/metric/google_analytics_test.rb +17 -25
- data/test/metric/remote_test.rb +7 -10
- data/test/playground_test.rb +7 -14
- data/test/templates_test.rb +16 -20
- data/test/test_helper.rb +28 -29
- data/test/vanity_test.rb +4 -10
- data/test/web/rails/dashboard_test.rb +21 -21
- data/vanity.gemspec +8 -7
- metadata +32 -30
- data/gemfiles/rails42.gemfile +0 -33
- data/gemfiles/rails42.gemfile.lock +0 -265
- data/gemfiles/rails42_protected_attributes.gemfile +0 -34
- data/gemfiles/rails42_protected_attributes.gemfile.lock +0 -264
- data/gemfiles/rails51.gemfile +0 -33
- data/gemfiles/rails51.gemfile.lock +0 -285
@@ -10,7 +10,7 @@ module Vanity
|
|
10
10
|
# Convert z-score to probability.
|
11
11
|
def probability(score)
|
12
12
|
score = score.abs
|
13
|
-
probability = AbTest::Z_TO_PROBABILITY.find { |z,
|
13
|
+
probability = AbTest::Z_TO_PROBABILITY.find { |z, _p| score >= z }
|
14
14
|
probability ? probability.last : 0
|
15
15
|
end
|
16
16
|
|
@@ -48,6 +48,7 @@ module Vanity
|
|
48
48
|
class << self
|
49
49
|
define_method :default do |*args|
|
50
50
|
raise ArgumentError, "default has already been set to #{@default.inspect}" unless args.empty?
|
51
|
+
|
51
52
|
alternative(@default)
|
52
53
|
end
|
53
54
|
end
|
@@ -69,9 +70,10 @@ module Vanity
|
|
69
70
|
# has had #save invoked previous to any enabled= calls.
|
70
71
|
def enabled=(bool)
|
71
72
|
return unless @playground.collecting? && active?
|
73
|
+
|
72
74
|
if created_at.nil?
|
73
75
|
Vanity.logger.warn(
|
74
|
-
'DB has no created_at for this experiment! This most likely means'
|
76
|
+
'DB has no created_at for this experiment! This most likely means' \
|
75
77
|
'you didn\'t call #save before calling enabled=, which you should.'
|
76
78
|
)
|
77
79
|
else
|
@@ -146,10 +148,8 @@ module Vanity
|
|
146
148
|
# metrics :signup
|
147
149
|
# score_method :bayes_bandit_score
|
148
150
|
# end
|
149
|
-
def score_method(method=nil)
|
150
|
-
if method
|
151
|
-
@score_method = method
|
152
|
-
end
|
151
|
+
def score_method(method = nil)
|
152
|
+
@score_method = method if method
|
153
153
|
@score_method
|
154
154
|
end
|
155
155
|
|
@@ -172,7 +172,7 @@ module Vanity
|
|
172
172
|
# alternative for experiment without revealing what values are available
|
173
173
|
# (e.g. choosing alternative from HTTP query parameter).
|
174
174
|
def fingerprint(alternative)
|
175
|
-
Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
|
175
|
+
Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10, 10]
|
176
176
|
end
|
177
177
|
|
178
178
|
# Chooses a value for this experiment. You probably want to use the
|
@@ -185,10 +185,10 @@ module Vanity
|
|
185
185
|
#
|
186
186
|
# @example
|
187
187
|
# color = experiment(:which_blue).choose
|
188
|
-
def choose(request=nil)
|
188
|
+
def choose(request = nil)
|
189
189
|
if @playground.collecting?
|
190
190
|
if active?
|
191
|
-
if enabled?
|
191
|
+
if enabled? # rubocop:todo Style/GuardClause
|
192
192
|
return assignment_for_identity(request)
|
193
193
|
else
|
194
194
|
# Show the default if experiment is disabled.
|
@@ -229,7 +229,7 @@ module Vanity
|
|
229
229
|
# teardown do
|
230
230
|
# experiment(:green_button).chooses(nil)
|
231
231
|
# end
|
232
|
-
def chooses(value, request=nil)
|
232
|
+
def chooses(value, request = nil)
|
233
233
|
if @playground.collecting?
|
234
234
|
if value.nil?
|
235
235
|
connection.ab_not_showing @id, identity
|
@@ -238,8 +238,9 @@ module Vanity
|
|
238
238
|
save_assignment(identity, index, request) unless filter_visitor?(request, identity)
|
239
239
|
|
240
240
|
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
241
|
+
|
241
242
|
if (connection.ab_showing(@id, identity) && connection.ab_showing(@id, identity) != index) ||
|
242
|
-
|
243
|
+
alternative_for(identity) != index
|
243
244
|
connection.ab_show(@id, identity, index)
|
244
245
|
end
|
245
246
|
end
|
@@ -261,12 +262,11 @@ module Vanity
|
|
261
262
|
end
|
262
263
|
end
|
263
264
|
|
264
|
-
|
265
265
|
# -- Reporting --
|
266
266
|
|
267
267
|
def calculate_score
|
268
268
|
if respond_to?(score_method)
|
269
|
-
|
269
|
+
send(score_method)
|
270
270
|
else
|
271
271
|
score
|
272
272
|
end
|
@@ -298,22 +298,24 @@ module Vanity
|
|
298
298
|
alts.each do |alt|
|
299
299
|
p = alt.measure
|
300
300
|
n = alt.participants
|
301
|
-
alt.z_score = (p - pc) / ((p * (1-p)/n) + (pc * (1-pc)/nc)).abs
|
301
|
+
alt.z_score = (p - pc) / (((p * (1 - p) / n) + (pc * (1 - pc) / nc)).abs**0.5)
|
302
302
|
alt.probability = AbTest.probability(alt.z_score)
|
303
303
|
end
|
304
304
|
# difference is measured from least performant
|
305
|
-
if least = sorted.find { |alt| alt.measure > 0 }
|
305
|
+
if least = sorted.find { |alt| alt.measure > 0 } # rubocop:todo Lint/AssignmentInCondition
|
306
306
|
alts.each do |alt|
|
307
|
-
if alt.measure > least.measure
|
308
|
-
alt.difference = (alt.measure - least.measure) / least.measure * 100
|
309
|
-
end
|
307
|
+
alt.difference = (alt.measure - least.measure) / least.measure * 100 if alt.measure > least.measure
|
310
308
|
end
|
311
309
|
end
|
312
310
|
# best alternative is one with highest conversion rate (best shot).
|
313
311
|
# choice alternative can only pick best if we have high probability (>90%).
|
314
312
|
best = sorted.last if sorted.last.measure > 0.0
|
315
|
-
choice =
|
316
|
-
|
313
|
+
choice = if outcome
|
314
|
+
alts[outcome.id]
|
315
|
+
else
|
316
|
+
(best && best.probability >= probability ? best : nil)
|
317
|
+
end
|
318
|
+
Struct.new(:alts, :best, :base, :least, :choice, :method).new(alts, best, base, least, choice, :score) # rubocop:todo Lint/StructNewOverride
|
317
319
|
end
|
318
320
|
|
319
321
|
# Scores alternatives based on the current tracking data, using Bayesian
|
@@ -336,13 +338,13 @@ module Vanity
|
|
336
338
|
#
|
337
339
|
# The choice alternative is set only if its probability is higher or
|
338
340
|
# equal to the specified probability (default is 90%).
|
339
|
-
def bayes_bandit_score(
|
341
|
+
def bayes_bandit_score(_probability = 90)
|
340
342
|
begin
|
341
343
|
require "backports/1.9.1/kernel/define_singleton_method" if RUBY_VERSION < "1.9"
|
342
344
|
require "integration"
|
343
345
|
require "rubystats"
|
344
346
|
rescue LoadError
|
345
|
-
|
347
|
+
raise("to use bayes_bandit_score, install integration and rubystats gems")
|
346
348
|
end
|
347
349
|
|
348
350
|
begin
|
@@ -358,12 +360,12 @@ module Vanity
|
|
358
360
|
# array of claims.
|
359
361
|
def conclusion(score = score())
|
360
362
|
claims = []
|
361
|
-
participants = score.alts.inject(0) { |t,alt| t + alt.participants }
|
363
|
+
participants = score.alts.inject(0) { |t, alt| t + alt.participants }
|
362
364
|
claims << if participants.zero?
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
365
|
+
I18n.t('vanity.no_participants')
|
366
|
+
else
|
367
|
+
I18n.t('vanity.experiment_participants', count: participants)
|
368
|
+
end
|
367
369
|
# only interested in sorted alternatives with conversion
|
368
370
|
sorted = score.alts.select { |alt| alt.measure > 0.0 }.sort_by(&:measure).reverse
|
369
371
|
if sorted.size > 1
|
@@ -371,46 +373,45 @@ module Vanity
|
|
371
373
|
# then alternatives with no conversion.
|
372
374
|
sorted |= score.alts
|
373
375
|
# we want a result that's clearly better than 2nd best.
|
374
|
-
best
|
376
|
+
best = sorted[0]
|
377
|
+
second = sorted[1]
|
375
378
|
if best.measure > second.measure
|
376
379
|
diff = ((best.measure - second.measure) / second.measure * 100).round
|
377
|
-
better = I18n.t('vanity.better_alternative_than', :
|
378
|
-
claims << I18n.t('vanity.best_alternative_measure', :
|
379
|
-
if score.method == :bayes_bandit_score
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
end
|
391
|
-
end
|
380
|
+
better = I18n.t('vanity.better_alternative_than', probability: diff.to_i, alternative: second.name) if diff > 0
|
381
|
+
claims << I18n.t('vanity.best_alternative_measure', best_alternative: best.name, measure: format('%.1f', (best.measure * 100)), better_than: better)
|
382
|
+
claims << if score.method == :bayes_bandit_score
|
383
|
+
if best.probability >= 90
|
384
|
+
I18n.t('vanity.best_alternative_probability', probability: score.best.probability.to_i)
|
385
|
+
else
|
386
|
+
I18n.t('vanity.low_result_confidence')
|
387
|
+
end
|
388
|
+
elsif best.probability >= 90
|
389
|
+
I18n.t('vanity.best_alternative_is_significant', probability: score.best.probability.to_i)
|
390
|
+
else
|
391
|
+
I18n.t('vanity.result_isnt_significant')
|
392
|
+
end
|
392
393
|
sorted.delete best
|
393
394
|
end
|
394
395
|
sorted.each do |alt|
|
395
|
-
if alt.measure > 0.0
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
396
|
+
claims << if alt.measure > 0.0
|
397
|
+
I18n.t('vanity.converted_percentage', alternative: alt.name.sub(/^\w/, &:upcase), percentage: format('%.1f', (alt.measure * 100)))
|
398
|
+
else
|
399
|
+
I18n.t('vanity.didnt_convert', alternative: alt.name.sub(/^\w/, &:upcase))
|
400
|
+
end
|
400
401
|
end
|
401
402
|
else
|
402
403
|
claims << I18n.t('vanity.no_clear_winner')
|
403
404
|
end
|
404
|
-
claims << I18n.t('vanity.selected_as_best', :
|
405
|
+
claims << I18n.t('vanity.selected_as_best', alternative: score.choice.name.sub(/^\w/, &:upcase)) if score.choice
|
405
406
|
claims
|
406
407
|
end
|
407
408
|
|
408
409
|
# -- Unequal probability assignments --
|
409
410
|
|
410
|
-
def set_alternative_probabilities(alternative_probabilities)
|
411
|
+
def set_alternative_probabilities(alternative_probabilities) # rubocop:todo Naming/AccessorMethodName
|
411
412
|
# create @use_probabilities as a function to go from [0,1] to outcome
|
412
413
|
cumulative_probability = 0.0
|
413
|
-
new_probabilities = alternative_probabilities.map {|am| [am, (cumulative_probability += am.probability)/100.0]}
|
414
|
+
new_probabilities = alternative_probabilities.map { |am| [am, (cumulative_probability += am.probability) / 100.0] }
|
414
415
|
@use_probabilities = new_probabilities
|
415
416
|
end
|
416
417
|
|
@@ -425,7 +426,7 @@ module Vanity
|
|
425
426
|
# end
|
426
427
|
#
|
427
428
|
# puts "The experiment will automatically rebalance after every " + experiment(:simple).description + " users are assigned."
|
428
|
-
def rebalance_frequency(rf = nil)
|
429
|
+
def rebalance_frequency(rf = nil) # rubocop:todo Naming/MethodParameterName
|
429
430
|
if rf
|
430
431
|
@assignments_since_rebalancing = 0
|
431
432
|
@rebalance_frequency = rf
|
@@ -437,10 +438,9 @@ module Vanity
|
|
437
438
|
# Force experiment to rebalance.
|
438
439
|
def rebalance!
|
439
440
|
return unless @playground.collecting?
|
441
|
+
|
440
442
|
score_results = bayes_bandit_score
|
441
|
-
if score_results.method == :bayes_bandit_score
|
442
|
-
set_alternative_probabilities score_results.alts
|
443
|
-
end
|
443
|
+
set_alternative_probabilities score_results.alts if score_results.method == :bayes_bandit_score
|
444
444
|
end
|
445
445
|
|
446
446
|
# -- Completion --
|
@@ -461,12 +461,14 @@ module Vanity
|
|
461
461
|
def outcome_is(&block)
|
462
462
|
raise ArgumentError, "Missing block" unless block
|
463
463
|
raise "outcome_is already called on this experiment" if defined?(@outcome_is)
|
464
|
+
|
464
465
|
@outcome_is = block
|
465
466
|
end
|
466
467
|
|
467
468
|
# Alternative chosen when this experiment completed.
|
468
469
|
def outcome
|
469
470
|
return unless @playground.collecting?
|
471
|
+
|
470
472
|
outcome = connection.ab_get_outcome(@id)
|
471
473
|
outcome && alternatives[outcome]
|
472
474
|
end
|
@@ -474,6 +476,7 @@ module Vanity
|
|
474
476
|
def complete!(outcome = nil)
|
475
477
|
# This statement is equivalent to: return unless collecting?
|
476
478
|
return unless @playground.collecting? && active?
|
479
|
+
|
477
480
|
self.enabled = false
|
478
481
|
super
|
479
482
|
|
@@ -481,8 +484,8 @@ module Vanity
|
|
481
484
|
if defined?(@outcome_is)
|
482
485
|
begin
|
483
486
|
result = @outcome_is.call
|
484
|
-
outcome = result.id if Alternative
|
485
|
-
rescue => e
|
487
|
+
outcome = result.id if result.is_a?(Alternative) && result.experiment == self
|
488
|
+
rescue StandardError => e
|
486
489
|
Vanity.logger.warn("Error in AbTest#complete!: #{e}")
|
487
490
|
end
|
488
491
|
else
|
@@ -494,7 +497,6 @@ module Vanity
|
|
494
497
|
connection.ab_set_outcome(@id, outcome || 0)
|
495
498
|
end
|
496
499
|
|
497
|
-
|
498
500
|
# -- Store/validate --
|
499
501
|
|
500
502
|
def destroy
|
@@ -505,6 +507,7 @@ module Vanity
|
|
505
507
|
# clears all collected data for the experiment
|
506
508
|
def reset
|
507
509
|
return unless @playground.collecting?
|
510
|
+
|
508
511
|
connection.destroy_experiment(@id)
|
509
512
|
connection.set_experiment_created_at(@id, Time.now)
|
510
513
|
@outcome = @completed_at = nil
|
@@ -523,7 +526,8 @@ module Vanity
|
|
523
526
|
end
|
524
527
|
@saved = true
|
525
528
|
true_false unless defined?(@alternatives)
|
526
|
-
|
529
|
+
raise "Experiment #{name} needs at least two alternatives" unless @alternatives.size >= 2
|
530
|
+
|
527
531
|
if !@is_default_set
|
528
532
|
default(@alternatives.first)
|
529
533
|
Vanity.logger.warn("No default alternative specified; choosing #{@default} as default.")
|
@@ -540,17 +544,23 @@ module Vanity
|
|
540
544
|
@metrics = [default_metric]
|
541
545
|
end
|
542
546
|
@metrics.each do |metric|
|
543
|
-
metric.hook(&method(:track!))
|
547
|
+
metric.hook(&method(:track!)) # rubocop:todo Performance/MethodObjectAsBlock
|
544
548
|
end
|
545
549
|
end
|
546
550
|
|
547
551
|
# Called via a hook by the associated metric.
|
548
|
-
def track!(
|
552
|
+
def track!(_metric_id, _timestamp, count, *args)
|
549
553
|
return unless active? && enabled?
|
554
|
+
|
550
555
|
identity = args.last[:identity] if args.last.is_a?(Hash)
|
551
|
-
identity ||=
|
552
|
-
|
556
|
+
identity ||= begin
|
557
|
+
identity()
|
558
|
+
rescue StandardError
|
559
|
+
nil
|
560
|
+
end
|
561
|
+
if identity # rubocop:todo Style/GuardClause
|
553
562
|
return if connection.ab_showing(@id, identity)
|
563
|
+
|
554
564
|
index = alternative_for(identity)
|
555
565
|
connection.ab_add_conversion(@id, index, identity, count)
|
556
566
|
check_completion!
|
@@ -561,7 +571,7 @@ module Vanity
|
|
561
571
|
# launched too late.
|
562
572
|
# -- Reid Hoffman, founder of LinkedIn
|
563
573
|
|
564
|
-
|
574
|
+
protected
|
565
575
|
|
566
576
|
# Used for testing.
|
567
577
|
def fake(values)
|
@@ -570,11 +580,13 @@ module Vanity
|
|
570
580
|
participants.times do |identity|
|
571
581
|
index = @alternatives.index(value)
|
572
582
|
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
583
|
+
|
573
584
|
connection.ab_add_participant @id, index, "#{index}:#{identity}"
|
574
585
|
end
|
575
586
|
conversions.times do |identity|
|
576
587
|
index = @alternatives.index(value)
|
577
588
|
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
589
|
+
|
578
590
|
connection.ab_add_conversion @id, index, "#{index}:#{identity}"
|
579
591
|
end
|
580
592
|
end
|
@@ -605,7 +617,7 @@ module Vanity
|
|
605
617
|
return existing_assignment if existing_assignment
|
606
618
|
|
607
619
|
if @use_probabilities
|
608
|
-
random_outcome = rand
|
620
|
+
random_outcome = rand
|
609
621
|
@use_probabilities.each do |alternative, max_prob|
|
610
622
|
return alternative.id if random_outcome < max_prob
|
611
623
|
end
|
@@ -651,7 +663,7 @@ module Vanity
|
|
651
663
|
|
652
664
|
def rebalance_if_necessary!
|
653
665
|
# if we are rebalancing probabilities, keep track of how long it has been since we last rebalanced
|
654
|
-
if defined?(@rebalance_frequency) && @rebalance_frequency
|
666
|
+
if defined?(@rebalance_frequency) && @rebalance_frequency # rubocop:todo Style/GuardClause
|
655
667
|
@assignments_since_rebalancing += 1
|
656
668
|
if @assignments_since_rebalancing >= @rebalance_frequency
|
657
669
|
@assignments_since_rebalancing = 0
|
@@ -660,13 +672,13 @@ module Vanity
|
|
660
672
|
end
|
661
673
|
end
|
662
674
|
|
663
|
-
def has_alternative_weights?(args)
|
675
|
+
def has_alternative_weights?(args) # rubocop:todo Naming/PredicateName
|
664
676
|
(!defined?(@alternatives) || @alternatives.nil?) && args.size == 1 && args[0].is_a?(Hash)
|
665
677
|
end
|
666
678
|
|
667
679
|
def build_alternatives_with_weights(args)
|
668
680
|
@alternatives = args[0]
|
669
|
-
sum_of_probability = @alternatives.values.reduce(0) { |a,b| a+b }
|
681
|
+
sum_of_probability = @alternatives.values.reduce(0) { |a, b| a + b }
|
670
682
|
cumulative_probability = 0.0
|
671
683
|
@use_probabilities = []
|
672
684
|
result = []
|
@@ -691,9 +703,9 @@ module Vanity
|
|
691
703
|
avg = 50.0
|
692
704
|
# Returns array of [z-score, percentage]
|
693
705
|
norm_dist = []
|
694
|
-
(0.0..3.1).step(0.01) { |x| norm_dist << [x, avg += 1 / Math.sqrt(2 * Math::PI) * Math::E
|
706
|
+
(0.0..3.1).step(0.01) { |x| norm_dist << [x, avg += 1 / Math.sqrt(2 * Math::PI) * (Math::E**(-x**2 / 2))] }
|
695
707
|
# We're really only interested in 90%, 95%, 99% and 99.9%.
|
696
|
-
Z_TO_PROBABILITY = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |
|
708
|
+
Z_TO_PROBABILITY = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |_x, a| a >= pct }.first, pct] }.reverse
|
697
709
|
end
|
698
710
|
end
|
699
711
|
|
@@ -1,13 +1,11 @@
|
|
1
1
|
module Vanity
|
2
2
|
module Experiment
|
3
|
-
|
4
3
|
# One of several alternatives in an A/B test (see AbTest#alternatives).
|
5
4
|
class Alternative
|
6
|
-
|
7
|
-
def initialize(experiment, id, value) #, participants, converted, conversions)
|
5
|
+
def initialize(experiment, id, value) # , participants, converted, conversions)
|
8
6
|
@experiment = experiment
|
9
7
|
@id = id
|
10
|
-
@name = I18n.t('vanity.option_number', :
|
8
|
+
@name = I18n.t('vanity.option_number', char: (@id + 65).chr.upcase)
|
11
9
|
@value = value
|
12
10
|
end
|
13
11
|
|
@@ -56,7 +54,7 @@ module Vanity
|
|
56
54
|
|
57
55
|
# Conversion rate calculated as converted/participants
|
58
56
|
def conversion_rate
|
59
|
-
@conversion_rate ||= (participants > 0 ? converted.to_f/participants
|
57
|
+
@conversion_rate ||= (participants > 0 ? converted.to_f / participants : 0.0)
|
60
58
|
end
|
61
59
|
|
62
60
|
# The measure we use to order (sort) alternatives and decide which one
|
@@ -4,40 +4,41 @@ module Vanity
|
|
4
4
|
module Experiment
|
5
5
|
# Base class that all experiment types are derived from.
|
6
6
|
class Base
|
7
|
-
|
8
7
|
class << self
|
9
8
|
# Returns the type of this class as a symbol (e.g. AbTest becomes
|
10
9
|
# ab_test).
|
11
10
|
def type
|
12
|
-
name.split("::").last.gsub(/([a-z])([A-Z])/) { "#{
|
11
|
+
name.split("::").last.gsub(/([a-z])([A-Z])/) { "#{Regexp.last_match(1)}_#{Regexp.last_match(2)}" }.gsub(/([A-Z])([A-Z][a-z])/) { "#{Regexp.last_match(1)}_#{Regexp.last_match(2)}" }.downcase
|
13
12
|
end
|
14
13
|
|
15
14
|
# Playground uses this to load experiment definitions.
|
16
15
|
def load(playground, stack, file)
|
17
|
-
|
16
|
+
raise "Circular dependency detected: #{stack.join('=>')}=>#{file}" if stack.include?(file)
|
17
|
+
|
18
18
|
source = File.read(file)
|
19
19
|
stack.push file
|
20
20
|
id = File.basename(file, ".rb").downcase.gsub(/\W/, "_").to_sym
|
21
21
|
context = Object.new
|
22
22
|
context.instance_eval do
|
23
23
|
extend Definition
|
24
|
-
experiment = eval(source, context.new_binding(playground, id), file)
|
25
|
-
|
24
|
+
experiment = eval(source, context.new_binding(playground, id), file) # rubocop:todo Security/Eval
|
25
|
+
raise NameError.new("Expected #{file} to define experiment #{id}", id) unless playground.experiments[id]
|
26
|
+
|
26
27
|
return experiment
|
27
28
|
end
|
28
|
-
rescue
|
29
|
+
rescue StandardError
|
29
30
|
error = NameError.exception($!.message, id)
|
30
31
|
error.set_backtrace $!.backtrace
|
31
32
|
raise error
|
32
33
|
ensure
|
33
34
|
stack.pop
|
34
35
|
end
|
35
|
-
|
36
36
|
end
|
37
37
|
|
38
38
|
def initialize(playground, id, name, options = nil)
|
39
39
|
@playground = playground
|
40
|
-
@id
|
40
|
+
@id = id.to_sym
|
41
|
+
@name = name
|
41
42
|
@options = options || {}
|
42
43
|
@identify_block = method(:default_identify)
|
43
44
|
@on_assignment_block = nil
|
@@ -46,7 +47,7 @@ module Vanity
|
|
46
47
|
# Human readable experiment name (first argument you pass when creating a
|
47
48
|
# new experiment).
|
48
49
|
attr_reader :name
|
49
|
-
alias
|
50
|
+
alias to_s name
|
50
51
|
|
51
52
|
# Unique identifier, derived from name experiment name, e.g. "Green
|
52
53
|
# Button" becomes :green_button.
|
@@ -78,7 +79,8 @@ module Vanity
|
|
78
79
|
# end
|
79
80
|
# end
|
80
81
|
def identify(&block)
|
81
|
-
|
82
|
+
raise "Missing block" unless block
|
83
|
+
|
82
84
|
@identify_block = block
|
83
85
|
end
|
84
86
|
|
@@ -92,7 +94,8 @@ module Vanity
|
|
92
94
|
# end
|
93
95
|
# end
|
94
96
|
def on_assignment(&block)
|
95
|
-
|
97
|
+
raise "Missing block" unless block
|
98
|
+
|
96
99
|
@on_assignment_block = block
|
97
100
|
end
|
98
101
|
|
@@ -109,7 +112,6 @@ module Vanity
|
|
109
112
|
@description if defined?(@description)
|
110
113
|
end
|
111
114
|
|
112
|
-
|
113
115
|
# -- Experiment completion --
|
114
116
|
|
115
117
|
# Define experiment completion condition. For example:
|
@@ -119,15 +121,17 @@ module Vanity
|
|
119
121
|
def complete_if(&block)
|
120
122
|
raise ArgumentError, "Missing block" unless block
|
121
123
|
raise "complete_if already called on this experiment" if defined?(@complete_block)
|
124
|
+
|
122
125
|
@complete_block = block
|
123
126
|
end
|
124
127
|
|
125
128
|
# Force experiment to complete.
|
126
129
|
# @param optional integer id of the alternative that is the decided
|
127
130
|
# outcome of the experiment
|
128
|
-
def complete!(
|
131
|
+
def complete!(_outcome = nil)
|
129
132
|
@playground.logger.info "vanity: completed experiment #{id}"
|
130
133
|
return unless @playground.collecting?
|
134
|
+
|
131
135
|
connection.set_experiment_completed_at @id, Time.now
|
132
136
|
@completed_at = connection.get_experiment_completed_at(@id)
|
133
137
|
end
|
@@ -153,6 +157,7 @@ module Vanity
|
|
153
157
|
# Called by Playground to save the experiment definition.
|
154
158
|
def save
|
155
159
|
return unless @playground.collecting?
|
160
|
+
|
156
161
|
connection.set_experiment_created_at @id, Time.now
|
157
162
|
end
|
158
163
|
|
@@ -165,12 +170,13 @@ module Vanity
|
|
165
170
|
# end
|
166
171
|
#
|
167
172
|
def reject(&block)
|
168
|
-
|
173
|
+
raise "Missing block" unless block
|
169
174
|
raise "filter already called on this experiment" if @request_filter_block
|
175
|
+
|
170
176
|
@request_filter_block = block
|
171
177
|
end
|
172
178
|
|
173
|
-
|
179
|
+
protected
|
174
180
|
|
175
181
|
def identity
|
176
182
|
@identify_block.call(Vanity.context)
|
@@ -179,16 +185,17 @@ module Vanity
|
|
179
185
|
def default_identify(context)
|
180
186
|
raise "No Vanity.context" unless context
|
181
187
|
raise "Vanity.context does not respond to vanity_identity" unless context.respond_to?(:vanity_identity, true)
|
188
|
+
|
182
189
|
context.send(:vanity_identity) or raise "Vanity.context.vanity_identity - no identity"
|
183
190
|
end
|
184
191
|
|
185
192
|
# Derived classes call this after state changes that may lead to
|
186
193
|
# experiment completing.
|
187
194
|
def check_completion!
|
188
|
-
if defined?(@complete_block) && @complete_block
|
195
|
+
if defined?(@complete_block) && @complete_block # rubocop:todo Style/GuardClause
|
189
196
|
begin
|
190
197
|
complete! if @complete_block.call
|
191
|
-
rescue => e
|
198
|
+
rescue StandardError => e
|
192
199
|
Vanity.logger.warn("Error in Vanity::Experiment::Base: #{e}")
|
193
200
|
end
|
194
201
|
end
|
@@ -206,11 +213,9 @@ module Vanity
|
|
206
213
|
def connection
|
207
214
|
@playground.connection
|
208
215
|
end
|
209
|
-
|
210
216
|
end
|
211
217
|
end
|
212
218
|
|
213
219
|
class NoExperimentError < NameError
|
214
220
|
end
|
215
|
-
|
216
221
|
end
|
@@ -7,13 +7,13 @@ module Vanity
|
|
7
7
|
class BayesianBanditScore < Score
|
8
8
|
DEFAULT_PROBABILITY = 90
|
9
9
|
|
10
|
-
def initialize(alternatives, outcome)
|
10
|
+
def initialize(alternatives, outcome) # rubocop:todo Lint/MissingSuper
|
11
11
|
@alternatives = alternatives
|
12
12
|
@outcome = outcome
|
13
13
|
@method = :bayes_bandit_score
|
14
14
|
end
|
15
15
|
|
16
|
-
def calculate!(probability=DEFAULT_PROBABILITY)
|
16
|
+
def calculate!(probability = DEFAULT_PROBABILITY)
|
17
17
|
# sort by conversion rate to find second best
|
18
18
|
@alts = @alternatives.sort_by(&:measure)
|
19
19
|
@base = @alts[-2]
|
@@ -36,8 +36,6 @@ module Vanity
|
|
36
36
|
alternatives[@outcome.id]
|
37
37
|
elsif best && best.probability >= probability
|
38
38
|
best
|
39
|
-
else
|
40
|
-
nil
|
41
39
|
end
|
42
40
|
end
|
43
41
|
|
@@ -54,24 +52,22 @@ module Vanity
|
|
54
52
|
alternatives.map do |alternative|
|
55
53
|
x = alternative.converted
|
56
54
|
n = alternative.participants
|
57
|
-
Rubystats::BetaDistribution.new(x+1, n-x+1)
|
55
|
+
Rubystats::BetaDistribution.new(x + 1, n - x + 1)
|
58
56
|
end
|
59
57
|
end
|
60
58
|
|
61
59
|
def probability_alternative_is_best(alternative_being_examined, all_alternatives)
|
62
|
-
Integration.integrate(0, 1, :
|
60
|
+
Integration.integrate(0, 1, tolerance: 1e-4) do |z|
|
63
61
|
pdf_alternative_is_best(z, alternative_being_examined, all_alternatives)
|
64
62
|
end
|
65
63
|
end
|
66
64
|
|
67
|
-
def pdf_alternative_is_best(z, alternative_being_examined, all_alternatives)
|
65
|
+
def pdf_alternative_is_best(z, alternative_being_examined, all_alternatives) # rubocop:todo Naming/MethodParameterName
|
68
66
|
# get the pdf for this alternative at z
|
69
67
|
pdf = alternative_being_examined.pdf(z)
|
70
68
|
# now multiply by the probability that all the other alternatives are lower
|
71
69
|
all_alternatives.each do |alternative|
|
72
|
-
if alternative != alternative_being_examined
|
73
|
-
pdf = pdf * alternative.cdf(z)
|
74
|
-
end
|
70
|
+
pdf *= alternative.cdf(z) if alternative != alternative_being_examined
|
75
71
|
end
|
76
72
|
pdf
|
77
73
|
end
|
@@ -81,9 +77,7 @@ module Vanity
|
|
81
77
|
least = alternatives.find { |alternative| alternative.measure > 0 }
|
82
78
|
if least
|
83
79
|
alternatives.each do |alternative|
|
84
|
-
if alternative.measure > least.measure
|
85
|
-
alternative.difference = (alternative.measure - least.measure) / least.measure * 100
|
86
|
-
end
|
80
|
+
alternative.difference = (alternative.measure - least.measure) / least.measure * 100 if alternative.measure > least.measure
|
87
81
|
end
|
88
82
|
end
|
89
83
|
least
|