Skip to content

Commit

Permalink
Added weights to alternatives
Browse files Browse the repository at this point in the history
This allows fine grain control of the share of visitors to a feature,
perhaps it is new or experimental and you only want to expose 10% of
your users to it.
  • Loading branch information
andrew committed Sep 25, 2011
1 parent 60443ee commit 86812ba
Show file tree
Hide file tree
Showing 6 changed files with 76 additions and 14 deletions.
14 changes: 14 additions & 0 deletions README.mdown
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ Example: Conversion tracking (in a view)

You can find more examples, tutorials and guides on the [wiki](https://github.com/andrew/split/wiki).

### Weighted alternatives

Perhaps you only want to show an alternative to 10% of your visitors because it is very experimental or not yet fully load tested.

To do this you can pass a weight with each alternative in the following ways:

ab_test('homepage design', 'Old', {'New' => 0.1})

ab_test('homepage design', {'Old' => 10}, 'New')

ab_test('homepage design', {'Old' => 20}, {'New' => 0.2})

This will only show the new alternative to visitors 1 in 10 times, the default weight for an alternative is 1.

### Overriding alternatives

For development and testing, you may wish to force your app to always return an alternative.
Expand Down
21 changes: 20 additions & 1 deletion lib/split/alternative.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ class Alternative
attr_accessor :participant_count
attr_accessor :completed_count
attr_accessor :experiment_name
attr_accessor :weight

def initialize(name, experiment_name, counters = {})
@experiment_name = experiment_name
@name = name
@participant_count = counters['participant_count'].to_i
@completed_count = counters['completed_count'].to_i
if name.class == Hash
@name = name.keys.first
@weight = name.values.first
else
@name = name
@weight = 1
end
end

def to_s
Expand Down Expand Up @@ -97,5 +104,17 @@ def self.create(name, experiment_name)
alt.save
alt
end

def self.valid?(name)
string?(name) or hash_with_correct_values?(name)
end

def self.string?(name)
name.class == String
end

def self.hash_with_correct_values?(name)
name.class == Hash && name.keys.first.class == String && Float(name.values.first) rescue false
end
end
end
35 changes: 31 additions & 4 deletions lib/split/experiment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ class Experiment

def initialize(name, *alternative_names)
@name = name.to_s
@alternative_names = alternative_names
@alternative_names = alternative_names.map do |alternative|
Split::Alternative.new(alternative, name)
end.map(&:name)

@version = (Split.redis.get("#{name.to_s}:version").to_i || 0)
end

Expand Down Expand Up @@ -36,7 +39,19 @@ def alternatives
end

def next_alternative
winner || alternatives.sort_by{|a| a.participant_count + rand}.first
winner || random_alternative
end

def random_alternative
weights = alternatives.map(&:weight)

total = weights.inject(0.0) {|t,w| t+w}
point = rand * total

alternatives.zip(weights).each do |n,w|
return n if w >= point
point -= w
end
end

def version
Expand Down Expand Up @@ -108,10 +123,10 @@ def self.find(name)
def self.find_or_create(key, *alternatives)
name = key.to_s.split(':')[0]

raise InvalidArgument, 'Alternatives must be strings' if alternatives.map(&:class).uniq != [String]
alts = initialize_alternatives(alternatives, name)

if Split.redis.exists(name)
if load_alternatives_for(name) == alternatives
if load_alternatives_for(name) == alts.map(&:name)
experiment = self.new(name, *load_alternatives_for(name))
else
exp = self.new(name, *load_alternatives_for(name))
Expand All @@ -125,6 +140,18 @@ def self.find_or_create(key, *alternatives)
experiment.save
end
return experiment

end

def self.initialize_alternatives(alternatives, name)

if alternatives.reject {|a| Split::Alternative.valid? a}.any?
raise InvalidArgument, 'Alternatives must be strings'
end

alternatives.map do |alternative|
Split::Alternative.new(alternative, name)
end
end
end
end
6 changes: 6 additions & 0 deletions spec/alternative_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
alternative = Split::Alternative.new('Basket', 'basket_text')
alternative.name.should eql('Basket')
end

it "return only the name" do
experiment = Split::Experiment.new('basket_text', {'Basket' => 0.6}, {"Cart" => 0.4})
alternative = Split::Alternative.new('Basket', 'basket_text')
alternative.name.should eql('Basket')
end

it "should have a default participation count of 0" do
alternative = Split::Alternative.new('Basket', 'basket_text')
Expand Down
9 changes: 0 additions & 9 deletions spec/experiment_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,6 @@
end

describe 'next_alternative' do
it "should return a random alternative from those with the least participants" do
experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red', 'green')

Split::Alternative.find('blue', 'link_color').increment_participation
Split::Alternative.find('red', 'link_color').increment_participation

experiment.next_alternative.name.should eql('green')
end

it "should always return the winner if one exists" do
experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red', 'green')
green = Split::Alternative.find('green', 'link_color')
Expand Down
5 changes: 5 additions & 0 deletions spec/helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
ret = ab_test('link_color', 'blue', 'red') { |alternative| "shared/#{alternative}" }
ret.should eql("shared/#{alt}")
end

it "should allow the share of visitors see an alternative to be specificed" do
ab_test('link_color', {'blue' => 0.8}, {'red' => 0.2})
['red', 'blue'].should include(ab_user['link_color'])
end
end

describe 'finished' do
Expand Down

0 comments on commit 86812ba

Please sign in to comment.