Skip to content

Commit 07b9b6d

Browse files
author
Carl Brasic
committed
Add an optional cohort block to science experiments
Many experiments operate on data with a very long tail, and the most frequent part of the distribution can wash out notable results in sub-groups. For example, experiment results derived from the data of very large customers often look quite different than the much more common results from the small data. Even the use of percentile metrics can't overcome these effects since often the relevant percentiles are very high (above 99-percentile). This adds an optional block to Science::Experiment which should return a "cohort" when called. The cohort is passed the result of the experiment so it can determine the cohort from the context data, whether the result is a mismatch or any of the observation data. The determined cohort value is available as `Scientist::Result#cohort` and is intended to be used by the user-defined publication mechanism.
1 parent ba65028 commit 07b9b6d

File tree

4 files changed

+77
-5
lines changed

4 files changed

+77
-5
lines changed

lib/scientist/experiment.rb

+8-1
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,13 @@ def use(&block)
290290
try "control", &block
291291
end
292292

293+
# Define a block which will determine the cohort of this experiment
294+
# when called. The block will be passed a `Scientist::Result` as its
295+
# only argument and the cohort will be set on the result.
296+
def cohort(&block)
297+
@_scientist_determine_cohort = block
298+
end
299+
293300
# Whether or not to raise a mismatch error when a mismatch occurs.
294301
def raise_on_mismatches?
295302
if raise_on_mismatches.nil?
@@ -316,7 +323,7 @@ def generate_result(name)
316323
end
317324

318325
control = observations.detect { |o| o.name == name }
319-
Scientist::Result.new(self, observations, control)
326+
Scientist::Result.new(self, observations, control, @_scientist_determine_cohort)
320327
end
321328

322329
private

lib/scientist/result.rb

+18-4
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,33 @@ class Scientist::Result
1919
# An Array of Observations in execution order.
2020
attr_reader :observations
2121

22+
# If the experiment was defined with a cohort block, the cohort this
23+
# result has been determined to belong to.
24+
attr_reader :cohort
25+
2226
# Internal: Create a new result.
2327
#
24-
# experiment - the Experiment this result is for
25-
# observations: - an Array of Observations, in execution order
26-
# control: - the control Observation
28+
# experiment - the Experiment this result is for
29+
# observations: - an Array of Observations, in execution order
30+
# control: - the control Observation
31+
# determine_cohort - An optional callable that is passed the Result to
32+
# determine its cohort
2733
#
28-
def initialize(experiment, observations = [], control = nil)
34+
def initialize(experiment, observations = [], control = nil, determine_cohort = nil)
2935
@experiment = experiment
3036
@observations = observations
3137
@control = control
3238
@candidates = observations - [control]
3339
evaluate_candidates
3440

41+
if determine_cohort
42+
begin
43+
@cohort = determine_cohort.call(self)
44+
rescue StandardError => e
45+
experiment.raised :cohort, e
46+
end
47+
end
48+
3549
freeze
3650
end
3751

test/scientist/experiment_test.rb

+40
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,46 @@ def @ex.enabled?
302302
assert_equal "kaboom", exception.message
303303
end
304304

305+
describe "cohorts" do
306+
it "accepts a cohort config block" do
307+
@ex.cohort { "1" }
308+
end
309+
310+
it "assigns a cohort to the result using the provided block" do
311+
@ex.context(foo: "bar")
312+
@ex.cohort { |res| "foo-#{res.context[:foo]}-#{Math.log10(res.control.value).round}" }
313+
@ex.use { 5670 }
314+
@ex.try { 5670 }
315+
316+
@ex.run
317+
assert_equal "foo-bar-4", @ex.published_result.cohort
318+
end
319+
320+
it "assigns no cohort if no cohort block passed" do
321+
@ex.use { 5670 }
322+
@ex.try { 5670 }
323+
324+
@ex.run
325+
assert_nil @ex.published_result.cohort
326+
end
327+
328+
it "rescues errors raised in the cohort determination block" do
329+
@ex.use { 5670 }
330+
@ex.try { 5670 }
331+
@ex.cohort { |res| raise "intentional" }
332+
333+
@ex.run
334+
335+
refute_nil @ex.published_result
336+
assert_nil @ex.published_result.cohort
337+
338+
assert_equal 1, @ex.exceptions.size
339+
code, exception = @ex.exceptions[0]
340+
assert_equal :cohort, code
341+
assert_equal "intentional", exception.message
342+
end
343+
end
344+
305345
describe "#raise_with" do
306346
it "raises custom error if provided" do
307347
CustomError = Class.new(Scientist::Experiment::MismatchError)

test/scientist/result_test.rb

+11
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@
9898
assert_equal @experiment.name, result.experiment_name
9999
end
100100

101+
it "takes an optional callable to determine cohort" do
102+
a = Scientist::Observation.new("a", @experiment) { 1 }
103+
b = Scientist::Observation.new("b", @experiment) { 1 }
104+
105+
result = Scientist::Result.new @experiment, [a, b], a
106+
assert_nil result.cohort
107+
108+
result = Scientist::Result.new @experiment, [a, b], a, ->(res) { "cohort-1" }
109+
assert_equal "cohort-1", result.cohort
110+
end
111+
101112
it "has the context from an experiment" do
102113
@experiment.context :foo => :bar
103114
a = Scientist::Observation.new("a", @experiment) { 1 }

0 commit comments

Comments
 (0)