profitably-abingo 0.1.2

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,24 @@
1
+ class Abingo
2
+ module Controller
3
+ module Dashboard
4
+
5
+ def index
6
+ @experiments = Abingo::Experiment.all
7
+ render :template => 'ab_dashboard/index'
8
+ end
9
+
10
+ def end_experiment
11
+ @alternative = Abingo::Alternative.find(params[:id])
12
+ @experiment = Abingo::Experiment.find(@alternative.experiment_id)
13
+ if (@experiment.status != "Completed")
14
+ @experiment.end_experiment!(@alternative.content)
15
+ flash[:notice] = "Experiment marked as ended. All users will now see the chosen alternative."
16
+ else
17
+ flash[:notice] = "This experiment is already ended."
18
+ end
19
+ redirect_to :action => "index"
20
+ end
21
+
22
+ end
23
+ end
24
+ 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,101 @@
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
+ before_destroy :delete_cache_keys
8
+
9
+ def cache_keys
10
+ [
11
+ "Abingo::Experiment::exists(#{test_name})".gsub(" ", "_"),
12
+ "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_"),
13
+ "Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_")
14
+ ]
15
+ end
16
+
17
+ def delete_cache_keys
18
+ cache_keys.each do |key|
19
+ Abingo.cache.delete key
20
+ end
21
+ true
22
+ end
23
+
24
+ def participants
25
+ alternatives.sum("participants")
26
+ end
27
+
28
+ def conversions
29
+ alternatives.sum("conversions")
30
+ end
31
+
32
+ def best_alternative
33
+ alternatives.max do |a,b|
34
+ a.conversion_rate <=> b.conversion_rate
35
+ end
36
+ end
37
+
38
+ def self.exists?(test_name)
39
+ cache_key = "Abingo::Experiment::exists(#{test_name})".gsub(" ", "_")
40
+ ret = Abingo.cache.fetch(cache_key) do
41
+ count = Abingo::Experiment.count(:conditions => {:test_name => test_name})
42
+ count > 0 ? count : nil
43
+ end
44
+ (!ret.nil?)
45
+ end
46
+
47
+ def self.alternatives_for_test(test_name)
48
+ cache_key = "Abingo::#{test_name}::alternatives".gsub(" ","_")
49
+ Abingo.cache.fetch(cache_key) do
50
+ experiment = Abingo::Experiment.find_by_test_name(test_name)
51
+ alternatives_array = Abingo.cache.fetch(cache_key) do
52
+ tmp_array = experiment.alternatives.map do |alt|
53
+ [alt.content, alt.weight]
54
+ end
55
+ tmp_hash = tmp_array.inject({}) {|hash, couplet| hash[couplet[0]] = couplet[1]; hash}
56
+ Abingo.parse_alternatives(tmp_hash)
57
+ end
58
+ alternatives_array
59
+ end
60
+ end
61
+
62
+ def self.start_experiment!(test_name, alternatives_array, conversion_name = nil)
63
+ conversion_name ||= test_name
64
+ conversion_name.gsub!(" ", "_")
65
+ cloned_alternatives_array = alternatives_array.clone
66
+ ActiveRecord::Base.transaction do
67
+ experiment = Abingo::Experiment.find_or_create_by_test_name(test_name)
68
+ experiment.alternatives.destroy_all #Blows away alternatives for pre-existing experiments.
69
+ while (cloned_alternatives_array.size > 0)
70
+ alt = cloned_alternatives_array[0]
71
+ weight = cloned_alternatives_array.size - (cloned_alternatives_array - [alt]).size
72
+ experiment.alternatives.build(:content => alt, :weight => weight,
73
+ :lookup => Abingo::Alternative.calculate_lookup(test_name, alt))
74
+ cloned_alternatives_array -= [alt]
75
+ end
76
+ experiment.status = "Live"
77
+ experiment.save(:validate => false) #Calling the validation here causes problems b/c of transaction.
78
+ Abingo.cache.write("Abingo::Experiment::exists(#{test_name})".gsub(" ", "_"), 1)
79
+
80
+ #This might have issues in very, very high concurrency environments...
81
+
82
+ tests_listening_to_conversion = Abingo.cache.read("Abingo::tests_listening_to_conversion#{conversion_name}") || []
83
+ tests_listening_to_conversion << test_name unless tests_listening_to_conversion.include? test_name
84
+ Abingo.cache.write("Abingo::tests_listening_to_conversion#{conversion_name}", tests_listening_to_conversion)
85
+ experiment
86
+ end
87
+ end
88
+
89
+ def end_experiment!(final_alternative, conversion_name = nil)
90
+ conversion_name ||= test_name
91
+ ActiveRecord::Base.transaction do
92
+ alternatives.each do |alternative|
93
+ alternative.lookup = "Experiment completed. #{alternative.id}"
94
+ alternative.save!
95
+ end
96
+ update_attribute(:status, "Finished")
97
+ Abingo.cache.write("Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_"), final_alternative)
98
+ end
99
+ end
100
+
101
+ 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,9 @@
1
+ class Railtie < Rails::Railtie
2
+
3
+ initializer "abingo.initialize" do |app|
4
+ ActionController::Base.send :include, AbingoSugar
5
+
6
+ ActionView::Base.send :include, AbingoViewHelper
7
+ end
8
+
9
+ end
@@ -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,49 @@
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].nil?)
10
+ choice = params[test_name]
11
+ elsif (Abingo.options[:enable_override_in_session] && !session[test_name].nil?)
12
+ choice = session[test_name]
13
+ elsif (alternatives.nil?)
14
+ choice = Abingo.flip(test_name)
15
+ else
16
+ choice = Abingo.test(test_name, alternatives, options)
17
+ end
18
+
19
+ if block_given?
20
+ yield(choice)
21
+ else
22
+ choice
23
+ end
24
+ end
25
+
26
+ def bingo!(test_name, options = {})
27
+ Abingo.bingo!(test_name, options)
28
+ end
29
+
30
+ #Mark the user as a human.
31
+ def abingo_mark_human
32
+ textual_result = "1"
33
+ begin
34
+ a = params[:a].to_i
35
+ b = params[:b].to_i
36
+ c = params[:c].to_i
37
+ if (request.method == :post && (a + b == c))
38
+ Abingo.human!
39
+ else
40
+ textual_result = "0"
41
+ end
42
+ rescue #If a bot doesn't pass a, b, or c, to_i will fail. This scarfs up the exception, to save it from polluting our logs.
43
+ textual_result = "0"
44
+ end
45
+ render :text => textual_result, :layout => false #Not actually used by browser
46
+
47
+ end
48
+
49
+ end
@@ -0,0 +1,41 @@
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 = {}, &block)
6
+
7
+ if (Abingo.options[:enable_specification] && !params[test_name].nil?)
8
+ choice = params[test_name]
9
+ elsif (Abingo.options[:enable_override_in_session] && !session[test_name].nil?)
10
+ choice = session[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
18
+ content_tag = capture(choice, &block)
19
+ block_called_from_erb?(block) ? concat(content_tag) : content_tag
20
+ else
21
+ choice
22
+ end
23
+ end
24
+
25
+ def bingo!(test_name, options = {})
26
+ Abingo.bingo!(test_name, options)
27
+ end
28
+
29
+ #This causes an AJAX post against the URL. That URL should call Abingo.human!
30
+ #This guarantees that anyone calling Abingo.human! is capable of at least minimal Javascript execution, and thus is (probably) not a robot.
31
+ def include_humanizing_javascript(url = "/abingo_mark_human", style = :prototype)
32
+ script = nil
33
+ if (style == :prototype)
34
+ script = "var a=Math.floor(Math.random()*11); var b=Math.floor(Math.random()*11);var x=new Ajax.Request('#{url}', {parameters:{a: a, b: b, c: a+b}})"
35
+ elsif (style == :jquery)
36
+ script = "var a=Math.floor(Math.random()*11); var b=Math.floor(Math.random()*11);var x=jQuery.post('#{url}', {a: a, b: b, c: a+b})"
37
+ end
38
+ script.nil? ? "" : %Q|<script type="text/javascript">#{script}</script>|
39
+ end
40
+
41
+ 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