Class: Split::Experiment

Inherits:
Object
  • Object
show all
Defined in:
lib/split/experiment.rb

Constant Summary collapse

DEFAULT_OPTIONS =
{
  :resettable => true
}

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, options = {}) ⇒ Experiment

Returns a new instance of Experiment.



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/split/experiment.rb', line 16

def initialize(name, options = {})
  options = DEFAULT_OPTIONS.merge(options)

  @name = name.to_s

  alternatives = extract_alternatives_from_options(options)

  if alternatives.empty? && (exp_config = Split.configuration.experiment_for(name))
    set_alternatives_and_options(
      alternatives: load_alternatives_from_configuration,
      goals: load_goals_from_configuration,
      metadata: ,
      resettable: exp_config[:resettable],
      algorithm: exp_config[:algorithm]
    )
  else
    set_alternatives_and_options(
      alternatives: alternatives,
      goals: options[:goals],
      metadata: options[:metadata],
      resettable: options[:resettable],
      algorithm: options[:algorithm]
    )
  end
end

Instance Attribute Details

#algorithmObject



133
134
135
# File 'lib/split/experiment.rb', line 133

def algorithm
  @algorithm ||= Split.configuration.algorithm
end

#alternative_probabilitiesObject

Returns the value of attribute alternative_probabilities.



9
10
11
# File 'lib/split/experiment.rb', line 9

def alternative_probabilities
  @alternative_probabilities
end

#alternativesObject

Returns the value of attribute alternatives.



8
9
10
# File 'lib/split/experiment.rb', line 8

def alternatives
  @alternatives
end

#goalsObject

Returns the value of attribute goals.



7
8
9
# File 'lib/split/experiment.rb', line 7

def goals
  @goals
end

#metadataObject

Returns the value of attribute metadata.



10
11
12
# File 'lib/split/experiment.rb', line 10

def 
  
end

#nameObject

Returns the value of attribute name.



4
5
6
# File 'lib/split/experiment.rb', line 4

def name
  @name
end

#resettableObject

Returns the value of attribute resettable.



6
7
8
# File 'lib/split/experiment.rb', line 6

def resettable
  @resettable
end

Instance Method Details

#==(obj) ⇒ Object



125
126
127
# File 'lib/split/experiment.rb', line 125

def ==(obj)
  self.name == obj.name
end

#[](name) ⇒ Object



129
130
131
# File 'lib/split/experiment.rb', line 129

def [](name)
  alternatives.find{|a| a.name == name}
end

#calc_alternative_probabilities(winning_counts, number_of_simulations) ⇒ Object



330
331
332
333
334
335
336
# File 'lib/split/experiment.rb', line 330

def calc_alternative_probabilities(winning_counts, number_of_simulations)
  alternative_probabilities = {}
  winning_counts.each do |alternative, wins|
    alternative_probabilities[alternative] = wins / number_of_simulations.to_f
  end
  return alternative_probabilities
end

#calc_beta_params(goal = nil) ⇒ Object



381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/split/experiment.rb', line 381

def calc_beta_params(goal = nil)
  beta_params = {}
  alternatives.each do |alternative|
    conversions = goal.nil? ? alternative.completed_count : alternative.completed_count(goal)
    alpha = 1 + conversions
    beta = 1 + alternative.participant_count - conversions

    params = [alpha, beta]

    beta_params[alternative] = params
  end
  return beta_params
end

#calc_simulated_conversion_rates(beta_params) ⇒ Object



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/split/experiment.rb', line 363

def calc_simulated_conversion_rates(beta_params)
  # initialize a random variable (from which to simulate conversion rates ~beta-distributed)
  rand = SimpleRandom.new
  rand.set_seed

  simulated_cr_hash = {}

  # create a hash which has the conversion rate pulled from each alternative's beta distribution
  beta_params.each do |alternative, params|
    alpha = params[0]
    beta = params[1]
    simulated_conversion_rate = rand.beta(alpha, beta)
    simulated_cr_hash[alternative] = simulated_conversion_rate
  end

  return simulated_cr_hash
end

#calc_timeObject



399
400
401
# File 'lib/split/experiment.rb', line 399

def calc_time
  Split.redis.hget(experiment_config_key, :calc_time).to_i
end

#calc_time=(time) ⇒ Object



395
396
397
# File 'lib/split/experiment.rb', line 395

def calc_time=(time)
  Split.redis.hset(experiment_config_key, :calc_time, time)
end

#calc_winning_alternativesObject



278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/split/experiment.rb', line 278

def calc_winning_alternatives
  # Super simple cache so that we only recalculate winning alternatives once per day
  days_since_epoch = Time.now.utc.to_i / 86400

  if self.calc_time != days_since_epoch
    if goals.empty?
      self.estimate_winning_alternative
    else
      goals.each do |goal|
        self.estimate_winning_alternative(goal)
      end
    end

    self.calc_time = days_since_epoch

    self.save
  end
end

#controlObject



175
176
177
# File 'lib/split/experiment.rb', line 175

def control
  alternatives.first
end

#count_simulated_wins(winning_alternatives) ⇒ Object



338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/split/experiment.rb', line 338

def count_simulated_wins(winning_alternatives)
   # initialize a hash to keep track of winning alternative in simulations
  winning_counts = {}
  alternatives.each do |alternative|
    winning_counts[alternative] = 0
  end
  # count number of times each alternative won, calculate probabilities, place in hash
  winning_alternatives.each do |alternative|
    winning_counts[alternative] += 1
  end
  return winning_counts
end

#deleteObject



250
251
252
253
254
255
256
257
258
259
# File 'lib/split/experiment.rb', line 250

def delete
  alternatives.each(&:delete)
  reset_winner
  Split.redis.srem(:experiments, name)
  Split.redis.del(name)
  delete_goals
  
  Split.configuration.on_experiment_delete.call(self)
  increment_version
end

#delete_goalsObject



261
262
263
# File 'lib/split/experiment.rb', line 261

def delete_goals
  Split.redis.del(goals_key)
end

#delete_metadataObject



265
266
267
# File 'lib/split/experiment.rb', line 265

def 
  Split.redis.del()
end

#estimate_winning_alternative(goal = nil) ⇒ Object



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/split/experiment.rb', line 297

def estimate_winning_alternative(goal = nil)
  # TODO - refactor out functionality to work with and without goals

  # initialize a hash of beta distributions based on the alternatives' conversion rates
  beta_params = calc_beta_params(goal)

  winning_alternatives = []

  Split.configuration.beta_probability_simulations.times do
    # calculate simulated conversion rates from the beta distributions
    simulated_cr_hash = calc_simulated_conversion_rates(beta_params)

    winning_alternative = find_simulated_winner(simulated_cr_hash)

    # push the winning pair to the winning_alternatives array
    winning_alternatives.push(winning_alternative)
  end

  winning_counts = count_simulated_wins(winning_alternatives)

  @alternative_probabilities = calc_alternative_probabilities(winning_counts, Split.configuration.beta_probability_simulations)

  write_to_alternatives(goal)

  self.save
end

#extract_alternatives_from_options(options) ⇒ Object



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/split/experiment.rb', line 50

def extract_alternatives_from_options(options)
  alts = options[:alternatives] || []

  if alts.length == 1
    if alts[0].is_a? Hash
      alts = alts[0].map{|k,v| {k => v} }
    end
  end

  if alts.empty?
    exp_config = Split.configuration.experiment_for(name)
    if exp_config
      alts = load_alternatives_from_configuration
      options[:goals] = load_goals_from_configuration
      options[:metadata] = 
      options[:resettable] = exp_config[:resettable]
      options[:algorithm] = exp_config[:algorithm]
    end
  end

  self.alternatives = alts
  self.goals = options[:goals]
  self.algorithm = options[:algorithm]
  self.resettable = options[:resettable]

  # calculate probability that each alternative is the winner
  @alternative_probabilities = {}
  alts
end

#find_simulated_winner(simulated_cr_hash) ⇒ Object



351
352
353
354
355
356
357
358
359
360
361
# File 'lib/split/experiment.rb', line 351

def find_simulated_winner(simulated_cr_hash)
  # figure out which alternative had the highest simulated conversion rate
  winning_pair = ["",0.0]
  simulated_cr_hash.each do |alternative, rate|
    if rate > winning_pair[1]
      winning_pair = [alternative, rate]
    end
  end
  winner = winning_pair[0]
  return winner
end

#finished_keyObject



231
232
233
# File 'lib/split/experiment.rb', line 231

def finished_key
  "#{key}:finished"
end

#goals_keyObject



227
228
229
# File 'lib/split/experiment.rb', line 227

def goals_key
  "#{name}:goals"
end

#has_winner?Boolean

Returns:

  • (Boolean)


163
164
165
# File 'lib/split/experiment.rb', line 163

def has_winner?
  !winner.nil?
end

#increment_versionObject



215
216
217
# File 'lib/split/experiment.rb', line 215

def increment_version
  @version = Split.redis.incr("#{name}:version")
end

#jstring(goal = nil) ⇒ Object



403
404
405
406
407
408
409
410
# File 'lib/split/experiment.rb', line 403

def jstring(goal = nil)
  js_id = if goal.nil?
            name
          else
            name + "-" + goal
          end
  js_id.gsub('/', '--')
end

#keyObject



219
220
221
222
223
224
225
# File 'lib/split/experiment.rb', line 219

def key
  if version.to_i > 0
    "#{name}:#{version}"
  else
    name
  end
end

#load_from_redisObject



269
270
271
272
273
274
275
276
# File 'lib/split/experiment.rb', line 269

def load_from_redis
  exp_config = Split.redis.hgetall(experiment_config_key)
  self.resettable = exp_config['resettable']
  self.algorithm = exp_config['algorithm']
  self.alternatives = load_alternatives_from_redis
  self.goals = load_goals_from_redis
  self. = 
end

#metadata_keyObject



235
236
237
# File 'lib/split/experiment.rb', line 235

def 
  "#{name}:metadata"
end

#new_record?Boolean

Returns:

  • (Boolean)


121
122
123
# File 'lib/split/experiment.rb', line 121

def new_record?
  !Split.redis.exists(name)
end

#next_alternativeObject



199
200
201
# File 'lib/split/experiment.rb', line 199

def next_alternative
  winner || random_alternative
end

#participant_countObject



171
172
173
# File 'lib/split/experiment.rb', line 171

def participant_count
  alternatives.inject(0){|sum,a| sum + a.participant_count}
end

#random_alternativeObject



203
204
205
206
207
208
209
# File 'lib/split/experiment.rb', line 203

def random_alternative
  if alternatives.length > 1
    algorithm.choose_alternative(self)
  else
    alternatives.first
  end
end

#resetObject



243
244
245
246
247
248
# File 'lib/split/experiment.rb', line 243

def reset
  alternatives.each(&:reset)
  reset_winner
  Split.configuration.on_experiment_reset.call(self)
  increment_version
end

#reset_winnerObject



179
180
181
# File 'lib/split/experiment.rb', line 179

def reset_winner
  Split.redis.hdel(:experiment_winner, name)
end

#resettable?Boolean

Returns:

  • (Boolean)


239
240
241
# File 'lib/split/experiment.rb', line 239

def resettable?
  resettable
end

#saveObject



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/split/experiment.rb', line 80

def save
  validate!

  if new_record?
    Split.redis.sadd(:experiments, name)
    start unless Split.configuration.start_manually
    @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
    save_goals
    
    Split.redis.set(, .to_json) unless .nil?
  else
    existing_alternatives = load_alternatives_from_redis
    existing_goals = load_goals_from_redis
     = 
    unless existing_alternatives == @alternatives.map(&:name) && existing_goals == @goals &&  == 
      reset
      @alternatives.each(&:delete)
      delete_goals
      
      Split.redis.del(@name)
      @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
      save_goals
      
    end
  end

  Split.redis.hset(experiment_config_key, :resettable, resettable)
  Split.redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
  self
end

#set_alternatives_and_options(options) ⇒ Object



42
43
44
45
46
47
48
# File 'lib/split/experiment.rb', line 42

def set_alternatives_and_options(options)
  self.alternatives = options[:alternatives]
  self.goals = options[:goals]
  self.resettable = options[:resettable]
  self.algorithm = options[:algorithm]
  self. = options[:metadata]
end

#startObject



183
184
185
# File 'lib/split/experiment.rb', line 183

def start
  Split.redis.hset(:experiment_start_times, @name, Time.now.to_i)
end

#start_timeObject



187
188
189
190
191
192
193
194
195
196
197
# File 'lib/split/experiment.rb', line 187

def start_time
  t = Split.redis.hget(:experiment_start_times, @name)
  if t
    # Check if stored time is an integer
    if t =~ /^[-+]?[0-9]+$/
      t = Time.at(t.to_i)
    else
      t = Time.parse(t)
    end
  end
end

#validate!Object



111
112
113
114
115
116
117
118
119
# File 'lib/split/experiment.rb', line 111

def validate!
  if @alternatives.empty? && Split.configuration.experiment_for(@name).nil?
    raise ExperimentNotFound.new("Experiment #{@name} not found")
  end
  @alternatives.each {|a| a.validate! }
  unless @goals.nil? || goals.kind_of?(Array)
    raise ArgumentError, 'Goals must be an array'
  end
end

#versionObject



211
212
213
# File 'lib/split/experiment.rb', line 211

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

#winnerObject



155
156
157
158
159
160
161
# File 'lib/split/experiment.rb', line 155

def winner
  if w = Split.redis.hget(:experiment_winner, name)
    Split::Alternative.new(w, name)
  else
    nil
  end
end

#winner=(winner_name) ⇒ Object



167
168
169
# File 'lib/split/experiment.rb', line 167

def winner=(winner_name)
  Split.redis.hset(:experiment_winner, name, winner_name.to_s)
end

#write_to_alternatives(goal = nil) ⇒ Object



324
325
326
327
328
# File 'lib/split/experiment.rb', line 324

def write_to_alternatives(goal = nil)
  alternatives.each do |alternative|
    alternative.set_p_winner(@alternative_probabilities[alternative], goal)
  end
end