Class: Vanity::Experiment::AbTest
- Defined in:
- lib/vanity/experiment/ab_test.rb
Overview
The meat.
Instance Attribute Summary
Attributes inherited from Base
Class Method Summary collapse
- .friendly_name ⇒ Object
-
.probability(score) ⇒ Object
Convert z-score to probability.
Instance Method Summary collapse
-
#alternative(value) ⇒ Object
Returns an Alternative with the specified value.
-
#alternatives(*args) ⇒ Object
Call this method once to set alternative values for this experiment (requires at least two values).
-
#choose ⇒ Object
Chooses a value for this experiment.
-
#chooses(value) ⇒ Object
Forces this experiment to use a particular alternative.
- #complete! ⇒ Object
-
#conclusion(score = score) ⇒ Object
Use the result of #score to derive a conclusion.
-
#destroy ⇒ Object
– Store/validate –.
-
#false_true ⇒ Object
(also: #true_false)
Defines an A/B test with two alternatives: false and true.
-
#fingerprint(alternative) ⇒ Object
Returns fingerprint (hash) for given alternative.
-
#initialize(*args) ⇒ AbTest
constructor
A new instance of AbTest.
-
#metrics(*args) ⇒ Object
Tells A/B test which metric we’re measuring, or returns metric in use.
-
#outcome ⇒ Object
Alternative chosen when this experiment completed.
-
#outcome_is(&block) ⇒ Object
Defines how the experiment can choose the optimal outcome on completion.
- #save ⇒ Object
-
#score(probability = 90) ⇒ Object
Scores alternatives based on the current tracking data.
-
#showing?(alternative) ⇒ Boolean
True if this alternative is currently showing (see #chooses).
-
#track!(metric_id, timestamp, count, *args) ⇒ Object
Called when tracking associated metric.
Methods inherited from Base
#active?, #complete_if, #created_at, #description, #identify, load, #type, type
Constructor Details
#initialize(*args) ⇒ AbTest
Returns a new instance of AbTest.
94 95 96 |
# File 'lib/vanity/experiment/ab_test.rb', line 94 def initialize(*args) super end |
Class Method Details
.friendly_name ⇒ Object
88 89 90 |
# File 'lib/vanity/experiment/ab_test.rb', line 88 def friendly_name "A/B Test" end |
Instance Method Details
#alternative(value) ⇒ Object
Returns an Alternative with the specified value.
154 155 156 |
# File 'lib/vanity/experiment/ab_test.rb', line 154 def alternative(value) alternatives.find { |alt| alt.value == value } end |
#alternatives(*args) ⇒ Object
Call this method once to set alternative values for this experiment (requires at least two values). Call without arguments to obtain current list of alternatives.
131 132 133 134 135 136 137 |
# File 'lib/vanity/experiment/ab_test.rb', line 131 def alternatives(*args) @alternatives = args.empty? ? [true, false] : args.clone class << self define_method :alternatives, instance_method(:_alternatives) end nil end |
#choose ⇒ Object
Chooses a value for this experiment. You probably want to use the Rails helper method ab_test instead.
This method picks an alternative for the current identity and returns the alternative’s value. It will consistently choose the same alternative for the same identity, and randomly split alternatives between different identities.
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/vanity/experiment/ab_test.rb', line 183 def choose if @playground.collecting? if active? identity = identity() index = connection.ab_showing(@id, identity) unless index index = alternative_for(identity) connection.ab_add_participant @id, index, identity check_completion! end else index = connection.ab_get_outcome(@id) || alternative_for(identity) end else identity = identity() @showing ||= {} @showing[identity] ||= alternative_for(identity) end @alternatives[index.to_i] end |
#chooses(value) ⇒ Object
Forces this experiment to use a particular alternative. You’ll want to use this from your test cases to test for the different alternatives.
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 |
# File 'lib/vanity/experiment/ab_test.rb', line 230 def chooses(value) if @playground.collecting? if value.nil? connection.ab_not_showing @id, identity else index = @alternatives.index(value) raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index connection.ab_show @id, identity, index end else @showing ||= {} @showing[identity] = value.nil? ? nil : @alternatives.index(value) end self end |
#complete! ⇒ Object
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 |
# File 'lib/vanity/experiment/ab_test.rb', line 376 def complete! return unless @playground.collecting? && active? super if @outcome_is begin result = @outcome_is.call outcome = result.id if result && result.experiment == self rescue # TODO: logging end else best = score.best outcome = best.id if best end # TODO: logging connection.ab_set_outcome @id, outcome || 0 end |
#conclusion(score = score) ⇒ Object
Use the result of #score to derive a conclusion. Returns an array of claims.
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 |
# File 'lib/vanity/experiment/ab_test.rb', line 306 def conclusion(score = score) claims = [] participants = score.alts.inject(0) { |t,alt| t + alt.participants } claims << case participants when 0 ; "There are no participants in this experiment yet." when 1 ; "There is one participant in this experiment." else ; "There are #{participants} participants in this experiment." end # only interested in sorted alternatives with conversion sorted = score.alts.select { |alt| alt.measure > 0.0 }.sort_by(&:measure).reverse if sorted.size > 1 # start with alternatives that have conversion, from best to worst, # then alternatives with no conversion. sorted |= score.alts # we want a result that's clearly better than 2nd best. best, second = sorted[0], sorted[1] if best.measure > second.measure diff = ((best.measure - second.measure) / second.measure * 100).round better = " (%d%% better than %s)" % [diff, second.name] if diff > 0 claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better] if best.probability >= 90 claims << "With %d%% probability this result is statistically significant." % score.best.probability else claims << "This result is not statistically significant, suggest you continue this experiment." end sorted.delete best end sorted.each do |alt| if alt.measure > 0.0 claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.measure * 100] else claims << "%s did not convert." % alt.name.gsub(/^o/, "O") end end else claims << "This experiment did not run long enough to find a clear winner." end claims << "#{score.choice.name.gsub(/^o/, "O")} selected as the best alternative." if score.choice claims end |
#destroy ⇒ Object
– Store/validate –
397 398 399 400 |
# File 'lib/vanity/experiment/ab_test.rb', line 397 def destroy connection.destroy_experiment @id super end |
#false_true ⇒ Object Also known as: true_false
Defines an A/B test with two alternatives: false and true. This is the default pair of alternatives, so just syntactic sugar for those who love being explicit.
168 169 170 |
# File 'lib/vanity/experiment/ab_test.rb', line 168 def false_true alternatives false, true end |
#fingerprint(alternative) ⇒ Object
Returns fingerprint (hash) for given alternative. Can be used to lookup alternative for experiment without revealing what values are available (e.g. choosing alternative from HTTP query parameter).
207 208 209 |
# File 'lib/vanity/experiment/ab_test.rb', line 207 def fingerprint(alternative) Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10] end |
#metrics(*args) ⇒ Object
Tells A/B test which metric we’re measuring, or returns metric in use.
110 111 112 113 |
# File 'lib/vanity/experiment/ab_test.rb', line 110 def metrics(*args) @metrics = args.map { |id| @playground.metric(id) } unless args.empty? @metrics end |
#outcome ⇒ Object
Alternative chosen when this experiment completed.
370 371 372 373 374 |
# File 'lib/vanity/experiment/ab_test.rb', line 370 def outcome return unless @playground.collecting? outcome = connection.ab_get_outcome(@id) outcome && _alternatives[outcome] end |
#outcome_is(&block) ⇒ Object
Defines how the experiment can choose the optimal outcome on completion.
By default, Vanity will take the best alternative (highest conversion rate) and use that as the outcome. You experiment may have different needs, maybe you want the least performing alternative, or factor cost in the equation?
The default implementation reads like this:
outcome_is do
a, b = alternatives
# a is expensive, only choose a if it performs 2x better than b
a.measure > b.measure * 2 ? a : b
end
363 364 365 366 367 |
# File 'lib/vanity/experiment/ab_test.rb', line 363 def outcome_is(&block) raise ArgumentError, "Missing block" unless block raise "outcome_is already called on this experiment" if @outcome_is @outcome_is = block end |
#save ⇒ Object
402 403 404 405 406 407 408 409 410 411 412 413 414 |
# File 'lib/vanity/experiment/ab_test.rb', line 402 def save true_false unless @alternatives fail "Experiment #{name} needs at least two alternatives" unless @alternatives.size >= 2 super if @metrics.nil? || @metrics.empty? warn "Please use metrics method to explicitly state which metric you are measuring against." metric = @playground.metrics[id] ||= Vanity::Metric.new(@playground, name) @metrics = [metric] end @metrics.each do |metric| metric.hook &method(:track!) end end |
#score(probability = 90) ⇒ Object
Scores alternatives based on the current tracking data. This method returns a structure with the following attributes:
- :alts
-
Ordered list of alternatives, populated with scoring info.
- :base
-
Second best performing alternative.
- :least
-
Least performing alternative (but more than zero conversion).
- :choice
-
Choice alterntive, either the outcome or best alternative.
Alternatives returned by this method are populated with the following attributes:
- :z_score
-
Z-score (relative to the base alternative).
- :probability
-
Probability (z-score mapped to 0, 90, 95, 99 or 99.9%).
- :difference
-
Difference from the least performant altenative.
The choice alternative is set only if its probability is higher or equal to the specified probability (default is 90%).
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'lib/vanity/experiment/ab_test.rb', line 275 def score(probability = 90) alts = alternatives # sort by conversion rate to find second best and 2nd best sorted = alts.sort_by(&:measure) base = sorted[-2] # calculate z-score pc = base.measure nc = base.participants alts.each do |alt| p = alt.measure n = alt.participants alt.z_score = (p - pc) / ((p * (1-p)/n) + (pc * (1-pc)/nc)).abs ** 0.5 alt.probability = AbTest.probability(alt.z_score) end # difference is measured from least performant if least = sorted.find { |alt| alt.measure > 0 } alts.each do |alt| if alt.measure > least.measure alt.difference = (alt.measure - least.measure) / least.measure * 100 end end end # best alternative is one with highest conversion rate (best shot). # choice alternative can only pick best if we have high probability (>90%). best = sorted.last if sorted.last.measure > 0.0 choice = outcome ? alts[outcome.id] : (best && best.probability >= probability ? best : nil) Struct.new(:alts, :best, :base, :least, :choice).new(alts, best, base, least, choice) end |
#showing?(alternative) ⇒ Boolean
True if this alternative is currently showing (see #chooses).
247 248 249 250 251 252 253 254 255 |
# File 'lib/vanity/experiment/ab_test.rb', line 247 def showing?(alternative) identity = identity() if @playground.collecting? connection.ab_showing(@id, identity) == alternative.id else @showing ||= {} @showing[identity] == alternative.id end end |
#track!(metric_id, timestamp, count, *args) ⇒ Object
Called when tracking associated metric.
417 418 419 420 421 422 423 424 425 426 |
# File 'lib/vanity/experiment/ab_test.rb', line 417 def track!(metric_id, , count, *args) return unless active? identity = identity() rescue nil if identity return if connection.ab_showing(@id, identity) index = alternative_for(identity) connection.ab_add_conversion @id, index, identity, count check_completion! end end |