abingo_port 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,23 @@
1
+ class Abingo::Alternative < ActiveRecord::Base
2
+ include Abingo::ConversionRate
3
+
4
+ belongs_to :experiment, :class_name => "Abingo::Experiment"
5
+ serialize :content
6
+
7
+ def self.calculate_lookup(test_name, alternative_name)
8
+ Digest::MD5.hexdigest(Abingo.salt + test_name + alternative_name.to_s)
9
+ end
10
+
11
+ def self.score_conversion(test_name)
12
+ viewed_alternative = Abingo.find_alternative_for_user(test_name,
13
+ Abingo::Experiment.alternatives_for_test(test_name))
14
+ self.update_all("conversions = conversions + 1", :lookup => self.calculate_lookup(test_name, viewed_alternative))
15
+ end
16
+
17
+ def self.score_participation(test_name)
18
+ viewed_alternative = Abingo.find_alternative_for_user(test_name,
19
+ Abingo::Experiment.alternatives_for_test(test_name))
20
+ self.update_all("participants = participants + 1", :lookup => self.calculate_lookup(test_name, viewed_alternative))
21
+ end
22
+
23
+ end
@@ -0,0 +1,25 @@
1
+ class Abingo
2
+ module Controller
3
+ module Dashboard
4
+
5
+ # ActionController::Base.view_paths.unshift File.join(File.dirname(__FILE__), "../views")
6
+
7
+ def index
8
+ @experiments = Abingo::Experiment.all
9
+ render :template => 'dashboard/index'
10
+ end
11
+
12
+ def end_experiment
13
+ @alternative = Abingo::Alternative.find(params[:id])
14
+ @experiment = Abingo::Experiment.find(@alternative.experiment_id)
15
+ if (@experiment.status != "Completed")
16
+ @experiment.end_experiment!(@alternative.content)
17
+ flash[:notice] = "Experiment marked as ended. All users will now see the chosen alternative."
18
+ else
19
+ flash[:notice] = "This experiment is already ended."
20
+ end
21
+ redirect_to :action => "index"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,9 @@
1
+ module Abingo::ConversionRate
2
+ def conversion_rate
3
+ 1.0 * conversions / participants
4
+ end
5
+
6
+ def pretty_conversion_rate
7
+ sprintf("%4.2f%%", conversion_rate * 100)
8
+ end
9
+ end
@@ -0,0 +1,99 @@
1
+ class Abingo::Experiment < ActiveRecord::Base
2
+ include Abingo::Statistics
3
+ include Abingo::ConversionRate
4
+
5
+ has_many :alternatives, :dependent => :destroy, :class_name => "Abingo::Alternative"
6
+ validates_uniqueness_of :test_name
7
+
8
+ def cache_keys
9
+ ["Abingo::Experiment::exists(#{test_name})".gsub(" ", "_"),
10
+ "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_"),
11
+ "Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_")
12
+ ]
13
+ end
14
+
15
+ def before_destroy
16
+ cache_keys.each do |key|
17
+ Abingo.cache.delete key
18
+ end
19
+ true
20
+ end
21
+
22
+ def participants
23
+ alternatives.sum("participants")
24
+ end
25
+
26
+ def conversions
27
+ alternatives.sum("conversions")
28
+ end
29
+
30
+ def best_alternative
31
+ alternatives.max do |a,b|
32
+ a.conversion_rate <=> b.conversion_rate
33
+ end
34
+ end
35
+
36
+ def self.exists?(test_name)
37
+ cache_key = "Abingo::Experiment::exists(#{test_name})".gsub(" ", "_")
38
+ ret = Abingo.cache.fetch(cache_key) do
39
+ count = Abingo::Experiment.count(:conditions => {:test_name => test_name})
40
+ count > 0 ? count : nil
41
+ end
42
+ (!ret.nil?)
43
+ end
44
+
45
+ def self.alternatives_for_test(test_name)
46
+ cache_key = "Abingo::#{test_name}::alternatives".gsub(" ","_")
47
+ Abingo.cache.fetch(cache_key) do
48
+ experiment = Abingo::Experiment.find_by_test_name(test_name)
49
+ alternatives_array = Abingo.cache.fetch(cache_key) do
50
+ tmp_array = experiment.alternatives.map do |alt|
51
+ [alt.content, alt.weight]
52
+ end
53
+ tmp_hash = tmp_array.inject({}) {|hash, couplet| hash[couplet[0]] = couplet[1]; hash}
54
+ Abingo.parse_alternatives(tmp_hash)
55
+ end
56
+ alternatives_array
57
+ end
58
+ end
59
+
60
+ def self.start_experiment!(test_name, alternatives_array, conversion_name = nil)
61
+ conversion_name ||= test_name
62
+ conversion_name.gsub!(" ", "_")
63
+ cloned_alternatives_array = alternatives_array.clone
64
+ ActiveRecord::Base.transaction do
65
+ experiment = Abingo::Experiment.find_or_create_by_test_name(test_name)
66
+ experiment.alternatives.destroy_all #Blows away alternatives for pre-existing experiments.
67
+ while (cloned_alternatives_array.size > 0)
68
+ alt = cloned_alternatives_array[0]
69
+ weight = cloned_alternatives_array.size - (cloned_alternatives_array - [alt]).size
70
+ experiment.alternatives.build(:content => alt, :weight => weight,
71
+ :lookup => Abingo::Alternative.calculate_lookup(test_name, alt))
72
+ cloned_alternatives_array -= [alt]
73
+ end
74
+ experiment.status = "Live"
75
+ experiment.save(false) #Calling the validation here causes problems b/c of transaction.
76
+ Abingo.cache.write("Abingo::Experiment::exists(#{test_name})".gsub(" ", "_"), 1)
77
+
78
+ #This might have issues in very, very high concurrency environments...
79
+
80
+ tests_listening_to_conversion = Abingo.cache.read("Abingo::tests_listening_to_conversion#{conversion_name}") || []
81
+ tests_listening_to_conversion << test_name unless tests_listening_to_conversion.include? test_name
82
+ Abingo.cache.write("Abingo::tests_listening_to_conversion#{conversion_name}", tests_listening_to_conversion)
83
+ experiment
84
+ end
85
+ end
86
+
87
+ def end_experiment!(final_alternative, conversion_name = nil)
88
+ conversion_name ||= test_name
89
+ ActiveRecord::Base.transaction do
90
+ alternatives.each do |alternative|
91
+ alternative.lookup = "Experiment completed. #{alternative.id}"
92
+ alternative.save!
93
+ end
94
+ update_attribute(:status, "Finished")
95
+ Abingo.cache.write("Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_"), final_alternative)
96
+ end
97
+ end
98
+
99
+ end
@@ -0,0 +1,13 @@
1
+ module Abingo
2
+ module Rails
3
+ module Controller
4
+ module Dashboard
5
+
6
+ def index
7
+ @experiments = Abingo::Experiment.all
8
+ end
9
+
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+
2
+
3
+ class Railtie < Rails::Railtie
4
+
5
+ initializer "abingo.initialize" do |app|
6
+ ActionController::Base.send :include, AbingoSugar
7
+
8
+ ActionView::Base.send :include, AbingoViewHelper
9
+ end
10
+
11
+ end
12
+
@@ -0,0 +1,90 @@
1
+ #Designed to be included into Abingo::Experiment, but you can feel free to adapt this
2
+ #to anything you want.
3
+
4
+ module Abingo::Statistics
5
+
6
+ HANDY_Z_SCORE_CHEATSHEET = [[0.10, 1.29], [0.05, 1.65], [0.01, 2.33], [0.001, 3.08]]
7
+
8
+ PERCENTAGES = {0.10 => '90%', 0.05 => '95%', 0.01 => '99%', 0.001 => '99.9%'}
9
+
10
+ DESCRIPTION_IN_WORDS = {0.10 => 'fairly confident', 0.05 => 'confident',
11
+ 0.01 => 'very confident', 0.001 => 'extremely confident'}
12
+ def zscore
13
+ if alternatives.size != 2
14
+ raise "Sorry, can't currently automatically calculate statistics for A/B tests with > 2 alternatives."
15
+ end
16
+
17
+ if (alternatives[0].participants == 0) || (alternatives[1].participants == 0)
18
+ raise "Can't calculate the z score if either of the alternatives lacks participants."
19
+ end
20
+
21
+ cr1 = alternatives[0].conversion_rate
22
+ cr2 = alternatives[1].conversion_rate
23
+
24
+ n1 = alternatives[0].participants
25
+ n2 = alternatives[1].participants
26
+
27
+ numerator = cr1 - cr2
28
+ frac1 = cr1 * (1 - cr1) / n1
29
+ frac2 = cr2 * (1 - cr2) / n2
30
+
31
+ numerator / ((frac1 + frac2) ** 0.5)
32
+ end
33
+
34
+ def p_value
35
+ index = 0
36
+ z = zscore
37
+ z = z.abs
38
+ found_p = nil
39
+ while index < HANDY_Z_SCORE_CHEATSHEET.size do
40
+ if (z > HANDY_Z_SCORE_CHEATSHEET[index][1])
41
+ found_p = HANDY_Z_SCORE_CHEATSHEET[index][0]
42
+ end
43
+ index += 1
44
+ end
45
+ found_p
46
+ end
47
+
48
+ def is_statistically_significant?(p = 0.05)
49
+ p_value <= p
50
+ end
51
+
52
+ def pretty_conversion_rate
53
+ sprintf("%4.2f%%", conversion_rate * 100)
54
+ end
55
+
56
+ def describe_result_in_words
57
+ begin
58
+ z = zscore
59
+ rescue
60
+ return "Could not execute the significance test because one or more of the alternatives has not been seen yet."
61
+ end
62
+ p = p_value
63
+
64
+ words = ""
65
+ if (alternatives[0].participants < 10) || (alternatives[1].participants < 10)
66
+ words += "Take these results with a grain of salt since your samples are so small: "
67
+ end
68
+
69
+ alts = alternatives - [best_alternative]
70
+ worst_alternative = alts.first
71
+
72
+ words += "The best alternative you have is: [#{best_alternative.content}], which had "
73
+ words += "#{best_alternative.conversions} conversions from #{best_alternative.participants} participants "
74
+ words += "(#{best_alternative.pretty_conversion_rate}). The other alternative was [#{worst_alternative.content}], "
75
+ words += "which had #{worst_alternative.conversions} conversions from #{worst_alternative.participants} participants "
76
+ words += "(#{worst_alternative.pretty_conversion_rate}). "
77
+
78
+ if (p.nil?)
79
+ words += "However, this difference is not statistically significant."
80
+ else
81
+ words += "This difference is #{PERCENTAGES[p]} likely to be statistically significant, which means you can be "
82
+ words += "#{DESCRIPTION_IN_WORDS[p]} that it is the result of your alternatives actually mattering, rather than "
83
+ words += "being due to random chance. However, this statistical test can't measure how likely the currently "
84
+ words += "observed magnitude of the difference is to be accurate or not. It only says \"better\", not \"better "
85
+ words += "by so much\"."
86
+ end
87
+ words
88
+ end
89
+
90
+ end
@@ -0,0 +1,43 @@
1
+ <h3><%= experiment.test_name.titleize %> <%= %Q|<i>(Test completed)</i>| if experiment.status != "Live" %> </h3>
2
+ <% short_circuit = Abingo.cache.read("Abingo::Experiment::short_circuit(#{experiment.test_name})".gsub(" ", "")) %>
3
+ <table class="experiment" style="border: 1px black;">
4
+ <tr class="header_row">
5
+ <th>Name</th>
6
+ <th>Participants</th>
7
+ <th>Conversions</th>
8
+ <th>Notes</th>
9
+ </tr>
10
+ <tr class="experiment_row">
11
+ <td>Experiment Total: </td>
12
+ <td><%= experiment.participants %> </td>
13
+ <td><%= experiment.conversions %> (<%= experiment.pretty_conversion_rate %>)</td>
14
+ <td></td>
15
+ </tr>
16
+ <% experiment.alternatives.each do |alternative| %>
17
+ <tr class="alternative_row">
18
+ <td>
19
+ <%= h alternative.content %>
20
+ </td>
21
+ <td><%= alternative.participants %></td>
22
+ <td><%= alternative.conversions %> (<%= alternative.pretty_conversion_rate %>)</td>
23
+ <td>
24
+ <% unless experiment.status != "Live" %>
25
+ <%= link_to("End experiment, picking this.", url_for(:id => alternative.id,
26
+ :action => "end_experiment"),
27
+ :method => :post,
28
+ :confirm => "Are you sure you want to terminate this experiment? This is not reversible."
29
+ ) %>
30
+ <% else %>
31
+ <% if alternative.content == short_circuit %>
32
+ <b>(All users seeing this.)</b>
33
+ <% end %>
34
+ <% end %>
35
+ </td>
36
+ </tr>
37
+ <% end %>
38
+ <tr>
39
+ <td colspan="4">
40
+ <b>Significance test results: </b><%= experiment.describe_result_in_words %>
41
+ </td>
42
+ </tr>
43
+ </table>
@@ -0,0 +1,20 @@
1
+ <div id="abingo_dashboard">
2
+ <p><h1>Welcome to your A/Bingo dashboard!</h1>
3
+
4
+ See <a href="http://www.bingocardcreator.com/abingo">the official site</a> for documentation.
5
+ I encourage you to customize this page to fit your needs. See /vendor/plugins/abingo/views for
6
+ the view templates. Hack them to pieces -- please!
7
+ </p>
8
+
9
+ <p>
10
+ <% if flash[:notice] %>
11
+ <span style="color: green;"><%= flash[:notice] %> </span>
12
+ <% end %>
13
+ <h2>All Experiments</h2>
14
+
15
+ <% @experiments.each do |experiment| %>
16
+ <%= render :partial => "dashboard/experiment", :locals => {:experiment => experiment} %>
17
+ <br/>
18
+ <% end %>
19
+ </p>
20
+ </div>
@@ -0,0 +1,28 @@
1
+ #This module exists entirely to save finger strain for programmers.
2
+ #It is designed to be included in your ApplicationController.
3
+ #
4
+ #See abingo.rb for descriptions of what these do.
5
+
6
+ module AbingoSugar
7
+
8
+ def ab_test(test_name, alternatives = nil, options = {})
9
+ if (Abingo.options[:enable_specification] && !params[test_name].blank?)
10
+ choice = params[test_name]
11
+ elsif (alternatives.nil?)
12
+ choice = Abingo.flip(test_name)
13
+ else
14
+ choice = Abingo.test(test_name, alternatives, options)
15
+ end
16
+
17
+ if block_given?
18
+ yield(choice)
19
+ else
20
+ choice
21
+ end
22
+ end
23
+
24
+ def bingo!(test_name, options = {})
25
+ Abingo.bingo!(test_name, options)
26
+ end
27
+
28
+ end
@@ -0,0 +1,42 @@
1
+ #Gives you easy syntax to use ABingo in your views.
2
+
3
+ module AbingoViewHelper
4
+
5
+ def ab_test(test_name, alternatives = nil, options = {})
6
+ if (Abingo.options[:enable_specification] && !params[test_name].blank?)
7
+ choice = params[test_name]
8
+ elsif (alternatives.nil?)
9
+ choice = Abingo.flip(test_name)
10
+ else
11
+ choice = Abingo.test(test_name, alternatives, options)
12
+ end
13
+
14
+ if block_given?
15
+ yield(choice)
16
+ else
17
+ choice
18
+ end
19
+ end
20
+
21
+ def ab_test(test_name, alternatives = nil, options = {}, &block)
22
+ if (Abingo.options[:enable_specification] && !params[test_name].blank?)
23
+ choice = params[test_name]
24
+ elsif (alternatives.nil?)
25
+ choice = Abingo.flip(test_name)
26
+ else
27
+ choice = Abingo.test(test_name, alternatives, options)
28
+ end
29
+
30
+ if block
31
+ content_tag = capture(choice, &block)
32
+ block_called_from_erb?(block) ? concat(content_tag) : content_tag
33
+ else
34
+ choice
35
+ end
36
+ end
37
+
38
+ def bingo!(test_name, options = {})
39
+ Abingo.bingo!(test_name, options)
40
+ end
41
+
42
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Explain the generator
3
+
4
+ Example:
5
+ rails generate abingo_migration Thing
6
+
7
+ This will create:
8
+ what/will/it/create
@@ -0,0 +1,13 @@
1
+ class AbingoMigrationGenerator < Rails::Generators::Base
2
+ source_root File.expand_path('../templates', __FILE__)
3
+
4
+ def generate_migration
5
+ copy_file "create_abingo_tables.rb", "db/migrate/#{migration_timestamp}_create_abingo_tables.rb"
6
+ end
7
+
8
+ private
9
+
10
+ def migration_timestamp
11
+ @migration_timestamp ||= Time.now.strftime("%Y%m%d%H%M%S")
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ class CreateAbingoTables < ActiveRecord::Migration
2
+ def self.up
3
+ create_table "experiments", :force => true do |t|
4
+ t.string "test_name"
5
+ t.string "status"
6
+ t.timestamps
7
+ end
8
+
9
+ add_index "experiments", "test_name"
10
+ #add_index "experiments", "created_on"
11
+
12
+ create_table "alternatives", :force => true do |t|
13
+ t.integer :experiment_id
14
+ t.string :content
15
+ t.string :lookup, :limit => 32
16
+ t.integer :weight, :default => 1
17
+ t.integer :participants, :default => 0
18
+ t.integer :conversions, :default => 0
19
+ end
20
+
21
+ add_index "alternatives", "experiment_id"
22
+ add_index "alternatives", "lookup" #Critical for speed, since we'll primarily be updating by that.
23
+ end
24
+
25
+ def self.down
26
+ drop_table :experiments
27
+ drop_table :alternatives
28
+ end
29
+ end