split 1.1.0 → 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +31 -10
- data/lib/split.rb +1 -1
- data/lib/split/configuration.rb +4 -0
- data/lib/split/experiment.rb +29 -1
- data/lib/split/trial.rb +9 -5
- data/lib/split/version.rb +1 -1
- data/spec/configuration_spec.rb +31 -0
- data/spec/experiment_spec.rb +23 -0
- data/spec/trial_spec.rb +57 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: acc9cc61f2fbaacd23c4101ce3533baad6fc2f5e
|
4
|
+
data.tar.gz: 0f1608e29221a58215ee240ac01ac921256e8466
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5e4cd8b3e649929c5c3a9f406a8a587c7cc23121146f0c0002ff05d6c86b7c61e508a05acab5d70172864d73f4c544263e72d1476a8d5afe134deb34b6012172
|
7
|
+
data.tar.gz: f9be6dc468f3fe7941e07b3734d1fae28a4a05209501cb093769d8f14001ec596d5b3627f808444523b303fe4d44de0946a328e04a1b9afe64434ba5c54c0493
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,14 @@
|
|
1
|
+
## 1.2.0 (January 24th, 2015)
|
2
|
+
|
3
|
+
Features
|
4
|
+
|
5
|
+
- Configure redis using environment variables if available (@saratovsource , #293)
|
6
|
+
- Store metadata on experiment configuration (@dekz, #291)
|
7
|
+
|
8
|
+
Bugfixes:
|
9
|
+
|
10
|
+
- Revert the Trial#complete! public API to support noargs (@dekz, #292)
|
11
|
+
|
1
12
|
## 1.1.0 (January 9th, 2015)
|
2
13
|
|
3
14
|
Features:
|
data/README.md
CHANGED
@@ -119,7 +119,7 @@ You can find more examples, tutorials and guides on the [wiki](https://github.co
|
|
119
119
|
|
120
120
|
## Statistical Validity
|
121
121
|
|
122
|
-
Split has two options for you to use to determine which alternative is the best.
|
122
|
+
Split has two options for you to use to determine which alternative is the best.
|
123
123
|
|
124
124
|
The first option (default on the dashboard) uses a z test (n>30) for the difference between your control and alternative conversion rates to calculate statistical significance. This test will tell you whether an alternative is better or worse than your control, but it will not distinguish between which alternative is the best in an experiment with multiple alternatives. Split will only tell you if your experiment is 90%, 95%, or 99% significant, and this test only works if you have more than 30 participants and 5 conversions for each branch.
|
125
125
|
|
@@ -364,6 +364,8 @@ Split.configure do |config|
|
|
364
364
|
end
|
365
365
|
```
|
366
366
|
|
367
|
+
You can set different Redis host via environment variable ```REDIS_URL```.
|
368
|
+
|
367
369
|
### Filtering
|
368
370
|
|
369
371
|
In most scenarios you don't want to have AB-Testing enabled for web spiders, robots or special groups of users.
|
@@ -443,6 +445,28 @@ and:
|
|
443
445
|
finished("my_first_experiment")
|
444
446
|
```
|
445
447
|
|
448
|
+
You can also add meta data for each experiment, very useful when you need more than an alternative name to change behaviour:
|
449
|
+
|
450
|
+
```yaml
|
451
|
+
my_first_experiment:
|
452
|
+
alternatives:
|
453
|
+
- a
|
454
|
+
- b
|
455
|
+
meta:
|
456
|
+
a:
|
457
|
+
text: "Have a fantastic day"
|
458
|
+
b:
|
459
|
+
text: "Don't get hit by a bus"
|
460
|
+
```
|
461
|
+
|
462
|
+
This allows for some advanced experiment configuration using methods like:
|
463
|
+
|
464
|
+
```ruby
|
465
|
+
trial.alternative.name # => "a"
|
466
|
+
|
467
|
+
trial.metadata['text'] # => "Have a fantastic day"
|
468
|
+
```
|
469
|
+
|
446
470
|
#### Metrics
|
447
471
|
|
448
472
|
You might wish to track generic metrics, such as conversions, and use
|
@@ -503,7 +527,7 @@ To complete a goal conversion, you do it like:
|
|
503
527
|
finished("link_color" => "purchase")
|
504
528
|
```
|
505
529
|
|
506
|
-
**NOTE:** This does not mean that a single experiment can have/complete progressive goals.
|
530
|
+
**NOTE:** This does not mean that a single experiment can have/complete progressive goals.
|
507
531
|
|
508
532
|
**Good Example**: Test if listing Plan A first result in more conversions to Plan A (goal: "plana_conversion") or Plan B (goal: "planb_conversion").
|
509
533
|
|
@@ -549,11 +573,8 @@ production: redis1.example.com:6379
|
|
549
573
|
And our initializer:
|
550
574
|
|
551
575
|
```ruby
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
split_config = YAML.load_file(rails_root + '/config/split.yml')
|
556
|
-
Split.redis = split_config[rails_env]
|
576
|
+
split_config = YAML.load_file(Rails.root.join('config', 'split.yml'))
|
577
|
+
Split.redis = split_config[Rails.env]
|
557
578
|
```
|
558
579
|
|
559
580
|
## Namespaces
|
@@ -601,11 +622,11 @@ end
|
|
601
622
|
|
602
623
|
## Algorithms
|
603
624
|
|
604
|
-
By default, Split ships with `Split::Algorithms::WeightedSample` that randomly selects from possible alternatives for a traditional a/b test.
|
625
|
+
By default, Split ships with `Split::Algorithms::WeightedSample` that randomly selects from possible alternatives for a traditional a/b test.
|
605
626
|
It is possible to specify static weights to favor certain alternatives.
|
606
627
|
|
607
|
-
`Split::Algorithms::Whiplash` is an implementation of a [multi-armed bandit algorithm](http://stevehanov.ca/blog/index.php?id=132).
|
608
|
-
This algorithm will automatically weight the alternatives based on their relative performance,
|
628
|
+
`Split::Algorithms::Whiplash` is an implementation of a [multi-armed bandit algorithm](http://stevehanov.ca/blog/index.php?id=132).
|
629
|
+
This algorithm will automatically weight the alternatives based on their relative performance,
|
609
630
|
choosing the better-performing ones more often as trials are completed.
|
610
631
|
|
611
632
|
Users may also write their own algorithms. The default algorithm may be specified globally in the configuration file, or on a per experiment basis using the experiments hash of the configuration file.
|
data/lib/split.rb
CHANGED
data/lib/split/configuration.rb
CHANGED
@@ -141,6 +141,10 @@ module Split
|
|
141
141
|
experiment_config[experiment_name.to_sym][:goals] = goals
|
142
142
|
end
|
143
143
|
|
144
|
+
if metadata = value_for(settings, :metadata)
|
145
|
+
experiment_config[experiment_name.to_sym][:metadata] = metadata
|
146
|
+
end
|
147
|
+
|
144
148
|
if (resettable = value_for(settings, :resettable)) != nil
|
145
149
|
experiment_config[experiment_name.to_sym][:resettable] = resettable
|
146
150
|
end
|
data/lib/split/experiment.rb
CHANGED
@@ -6,6 +6,7 @@ module Split
|
|
6
6
|
attr_accessor :goals
|
7
7
|
attr_accessor :alternatives
|
8
8
|
attr_accessor :alternative_probabilities
|
9
|
+
attr_accessor :metadata
|
9
10
|
|
10
11
|
DEFAULT_OPTIONS = {
|
11
12
|
:resettable => true
|
@@ -22,6 +23,7 @@ module Split
|
|
22
23
|
set_alternatives_and_options(
|
23
24
|
alternatives: load_alternatives_from_configuration,
|
24
25
|
goals: load_goals_from_configuration,
|
26
|
+
metadata: load_metadata_from_configuration,
|
25
27
|
resettable: exp_config[:resettable],
|
26
28
|
algorithm: exp_config[:algorithm]
|
27
29
|
)
|
@@ -29,6 +31,7 @@ module Split
|
|
29
31
|
set_alternatives_and_options(
|
30
32
|
alternatives: alternatives,
|
31
33
|
goals: options[:goals],
|
34
|
+
metadata: options[:metadata],
|
32
35
|
resettable: options[:resettable],
|
33
36
|
algorithm: options[:algorithm]
|
34
37
|
)
|
@@ -40,6 +43,7 @@ module Split
|
|
40
43
|
self.goals = options[:goals]
|
41
44
|
self.resettable = options[:resettable]
|
42
45
|
self.algorithm = options[:algorithm]
|
46
|
+
self.metadata = options[:metadata]
|
43
47
|
end
|
44
48
|
|
45
49
|
def extract_alternatives_from_options(options)
|
@@ -56,6 +60,7 @@ module Split
|
|
56
60
|
if exp_config
|
57
61
|
alts = load_alternatives_from_configuration
|
58
62
|
options[:goals] = load_goals_from_configuration
|
63
|
+
options[:metadata] = load_metadata_from_configuration
|
59
64
|
options[:resettable] = exp_config[:resettable]
|
60
65
|
options[:algorithm] = exp_config[:algorithm]
|
61
66
|
end
|
@@ -79,16 +84,20 @@ module Split
|
|
79
84
|
start unless Split.configuration.start_manually
|
80
85
|
@alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
|
81
86
|
@goals.reverse.each {|a| Split.redis.lpush(goals_key, a)} unless @goals.nil?
|
87
|
+
Split.redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
|
82
88
|
else
|
83
89
|
existing_alternatives = load_alternatives_from_redis
|
84
90
|
existing_goals = load_goals_from_redis
|
85
|
-
|
91
|
+
existing_metadata = load_metadata_from_redis
|
92
|
+
unless existing_alternatives == @alternatives.map(&:name) && existing_goals == @goals && existing_metadata == @metadata
|
86
93
|
reset
|
87
94
|
@alternatives.each(&:delete)
|
88
95
|
delete_goals
|
96
|
+
delete_metadata
|
89
97
|
Split.redis.del(@name)
|
90
98
|
@alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
|
91
99
|
@goals.reverse.each {|a| Split.redis.lpush(goals_key, a)} unless @goals.nil?
|
100
|
+
Split.redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
|
92
101
|
end
|
93
102
|
end
|
94
103
|
|
@@ -221,6 +230,10 @@ module Split
|
|
221
230
|
"#{key}:finished"
|
222
231
|
end
|
223
232
|
|
233
|
+
def metadata_key
|
234
|
+
"#{name}:metadata"
|
235
|
+
end
|
236
|
+
|
224
237
|
def resettable?
|
225
238
|
resettable
|
226
239
|
end
|
@@ -238,6 +251,7 @@ module Split
|
|
238
251
|
Split.redis.srem(:experiments, name)
|
239
252
|
Split.redis.del(name)
|
240
253
|
delete_goals
|
254
|
+
delete_metadata
|
241
255
|
Split.configuration.on_experiment_delete.call(self)
|
242
256
|
increment_version
|
243
257
|
end
|
@@ -246,12 +260,17 @@ module Split
|
|
246
260
|
Split.redis.del(goals_key)
|
247
261
|
end
|
248
262
|
|
263
|
+
def delete_metadata
|
264
|
+
Split.redis.del(metadata_key)
|
265
|
+
end
|
266
|
+
|
249
267
|
def load_from_redis
|
250
268
|
exp_config = Split.redis.hgetall(experiment_config_key)
|
251
269
|
self.resettable = exp_config['resettable']
|
252
270
|
self.algorithm = exp_config['algorithm']
|
253
271
|
self.alternatives = load_alternatives_from_redis
|
254
272
|
self.goals = load_goals_from_redis
|
273
|
+
self.metadata = load_metadata_from_redis
|
255
274
|
end
|
256
275
|
|
257
276
|
def calc_winning_alternatives
|
@@ -388,6 +407,10 @@ module Split
|
|
388
407
|
"experiment_configurations/#{@name}"
|
389
408
|
end
|
390
409
|
|
410
|
+
def load_metadata_from_configuration
|
411
|
+
metadata = Split.configuration.experiment_for(@name)[:metadata]
|
412
|
+
end
|
413
|
+
|
391
414
|
def load_goals_from_configuration
|
392
415
|
goals = Split.configuration.experiment_for(@name)[:goals]
|
393
416
|
if goals.nil?
|
@@ -401,6 +424,11 @@ module Split
|
|
401
424
|
Split.redis.lrange(goals_key, 0, -1)
|
402
425
|
end
|
403
426
|
|
427
|
+
def load_metadata_from_redis
|
428
|
+
meta = Split.redis.get(metadata_key)
|
429
|
+
JSON.parse(meta) unless meta.nil?
|
430
|
+
end
|
431
|
+
|
404
432
|
def load_alternatives_from_configuration
|
405
433
|
alts = Split.configuration.experiment_for(@name)[:alternatives]
|
406
434
|
raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
|
data/lib/split/trial.rb
CHANGED
@@ -1,10 +1,12 @@
|
|
1
1
|
module Split
|
2
2
|
class Trial
|
3
3
|
attr_accessor :experiment
|
4
|
+
attr_accessor :metadata
|
4
5
|
|
5
6
|
def initialize(attrs = {})
|
6
7
|
self.experiment = attrs.delete(:experiment)
|
7
8
|
self.alternative = attrs.delete(:alternative)
|
9
|
+
self.metadata = attrs.delete(:metadata)
|
8
10
|
|
9
11
|
@user = attrs.delete(:user)
|
10
12
|
@options = attrs
|
@@ -12,6 +14,10 @@ module Split
|
|
12
14
|
@alternative_choosen = false
|
13
15
|
end
|
14
16
|
|
17
|
+
def metadata
|
18
|
+
@metadata ||= experiment.metadata[alternative.name]
|
19
|
+
end
|
20
|
+
|
15
21
|
def alternative
|
16
22
|
@alternative ||= if @experiment.has_winner?
|
17
23
|
@experiment.winner
|
@@ -26,14 +32,12 @@ module Split
|
|
26
32
|
end
|
27
33
|
end
|
28
34
|
|
29
|
-
def complete!(goals, context = nil)
|
30
|
-
goals = goals || []
|
31
|
-
|
35
|
+
def complete!(goals=[], context = nil)
|
32
36
|
if alternative
|
33
|
-
if goals.empty?
|
37
|
+
if Array(goals).empty?
|
34
38
|
alternative.increment_completion
|
35
39
|
else
|
36
|
-
goals.each {|g| alternative.increment_completion(g) }
|
40
|
+
Array(goals).each {|g| alternative.increment_completion(g) }
|
37
41
|
end
|
38
42
|
|
39
43
|
context.send(Split.configuration.on_trial_complete, self) \
|
data/lib/split/version.rb
CHANGED
data/spec/configuration_spec.rb
CHANGED
@@ -90,6 +90,36 @@ describe Split::Configuration do
|
|
90
90
|
end
|
91
91
|
end
|
92
92
|
|
93
|
+
context "in a configuration with metadata" do
|
94
|
+
before do
|
95
|
+
experiments_yaml = <<-eos
|
96
|
+
my_experiment:
|
97
|
+
alternatives:
|
98
|
+
- name: Control Opt
|
99
|
+
percent: 67
|
100
|
+
- name: Alt One
|
101
|
+
percent: 10
|
102
|
+
- name: Alt Two
|
103
|
+
percent: 23
|
104
|
+
metadata:
|
105
|
+
Control Opt:
|
106
|
+
text: 'Control Option'
|
107
|
+
Alt One:
|
108
|
+
text: 'Alternative One'
|
109
|
+
Alt Two:
|
110
|
+
text: 'Alternative Two'
|
111
|
+
resettable: false
|
112
|
+
eos
|
113
|
+
@config.experiments = YAML.load(experiments_yaml)
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'should have metadata on the experiment' do
|
117
|
+
meta = @config.normalized_experiments[:my_experiment][:metadata]
|
118
|
+
expect(meta).to_not be nil
|
119
|
+
expect(meta['Control Opt']['text']).to eq('Control Option')
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
93
123
|
context "in a complex configuration" do
|
94
124
|
before do
|
95
125
|
experiments_yaml = <<-eos
|
@@ -120,6 +150,7 @@ describe Split::Configuration do
|
|
120
150
|
expect(@config.metrics).not_to be_nil
|
121
151
|
expect(@config.metrics.keys).to eq([:my_metric])
|
122
152
|
end
|
153
|
+
|
123
154
|
end
|
124
155
|
end
|
125
156
|
|
data/spec/experiment_spec.rb
CHANGED
@@ -147,6 +147,29 @@ describe Split::Experiment do
|
|
147
147
|
|
148
148
|
end
|
149
149
|
|
150
|
+
describe '#metadata' do
|
151
|
+
let(:experiment) { Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :algorithm => Split::Algorithms::Whiplash, :metadata => meta) }
|
152
|
+
context 'simple hash' do
|
153
|
+
let(:meta) { { 'basket' => 'a', 'cart' => 'b' } }
|
154
|
+
it "should persist metadata in redis" do
|
155
|
+
experiment.save
|
156
|
+
e = Split::ExperimentCatalog.find('basket_text')
|
157
|
+
expect(e).to eq(experiment)
|
158
|
+
expect(e.metadata).to eq(meta)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
context 'nested hash' do
|
163
|
+
let(:meta) { { 'basket' => { 'one' => 'two' }, 'cart' => 'b' } }
|
164
|
+
it "should persist metadata in redis" do
|
165
|
+
experiment.save
|
166
|
+
e = Split::ExperimentCatalog.find('basket_text')
|
167
|
+
expect(e).to eq(experiment)
|
168
|
+
expect(e.metadata).to eq(meta)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
150
173
|
it "should persist algorithm in redis" do
|
151
174
|
experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :algorithm => Split::Algorithms::Whiplash)
|
152
175
|
experiment.save
|
data/spec/trial_spec.rb
CHANGED
@@ -33,6 +33,27 @@ describe Split::Trial do
|
|
33
33
|
end
|
34
34
|
end
|
35
35
|
|
36
|
+
describe "metadata" do
|
37
|
+
let(:alternatives) { ['basket', 'cart'] }
|
38
|
+
let(:metadata) { Hash[alternatives.map { |k| [k, "Metadata for #{k}"] }] }
|
39
|
+
let(:experiment) do
|
40
|
+
Split::Experiment.new('basket_text', :alternatives => alternatives, :metadata => metadata).save
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'has metadata on each trial' do
|
44
|
+
trial = Split::Trial.new(:experiment => experiment, :user => user, :metadata => metadata['cart'],
|
45
|
+
:override => 'cart')
|
46
|
+
expect(trial.metadata).to eq(metadata['cart'])
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'has metadata on each trial from the experiment' do
|
50
|
+
trial = Split::Trial.new(:experiment => experiment, :user => user)
|
51
|
+
trial.choose!
|
52
|
+
expect(trial.metadata).to eq(metadata[trial.alternative.name])
|
53
|
+
expect(trial.metadata).to match /#{trial.alternative.name}/
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
36
57
|
describe "#choose!" do
|
37
58
|
def expect_alternative(trial, alternative_name)
|
38
59
|
3.times do
|
@@ -109,6 +130,42 @@ describe Split::Trial do
|
|
109
130
|
end
|
110
131
|
end
|
111
132
|
|
133
|
+
describe "#complete!" do
|
134
|
+
let(:trial) { Split::Trial.new(:user => user, :experiment => experiment) }
|
135
|
+
context 'when there are no goals' do
|
136
|
+
it 'should complete the trial' do
|
137
|
+
trial.choose!
|
138
|
+
old_completed_count = trial.alternative.completed_count
|
139
|
+
trial.complete!
|
140
|
+
expect(trial.alternative.completed_count).to be(old_completed_count+1)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
context 'when there are many goals' do
|
145
|
+
let(:goals) { ['first', 'second'] }
|
146
|
+
let(:trial) { Split::Trial.new(:user => user, :experiment => experiment, :goals => goals) }
|
147
|
+
shared_examples_for "goal completion" do
|
148
|
+
it 'should not complete the trial' do
|
149
|
+
trial.choose!
|
150
|
+
old_completed_count = trial.alternative.completed_count
|
151
|
+
trial.complete!(goal)
|
152
|
+
expect(trial.alternative.completed_count).to_not be(old_completed_count+1)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
describe 'Array of Goals' do
|
157
|
+
let(:goal) { [goals.first] }
|
158
|
+
it_behaves_like 'goal completion'
|
159
|
+
end
|
160
|
+
|
161
|
+
describe 'String of Goal' do
|
162
|
+
let(:goal) { goals.first }
|
163
|
+
it_behaves_like 'goal completion'
|
164
|
+
end
|
165
|
+
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
112
169
|
describe "alternative recording" do
|
113
170
|
before(:each) { Split.configuration.store_override = false }
|
114
171
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: split
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Nesbitt
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-01-
|
11
|
+
date: 2015-01-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|