abingo_port 0.1.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.
@@ -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