,
31 | document.getElementById('planouteditor')
32 | );
33 |
34 | PlanOutExperimentActions.loadScript(DemoData.getDemoScript());
35 |
--------------------------------------------------------------------------------
/planout-editor/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "planout-editor",
3 | "version": "0.0.1",
4 | "description": "Reactive PlanOut interface",
5 | "main": "js/app.js",
6 | "dependencies": {
7 | "es6-promise": ">=0.1.1",
8 | "underscore": "~1.6.0",
9 | "codemirror": "~4.4.0",
10 | "flux": "^2.0.0",
11 | "keymirror": "~0.1.0",
12 | "object-assign": "^1.0.0",
13 | "react": "^0.12.0",
14 | "react-bootstrap": "^0.13.0"
15 | },
16 | "devDependencies": {
17 | "browserify": ">=2.36.0",
18 | "envify": ">=1.2.0",
19 | "reactify": ">=0.4.0",
20 | "statics": ">=0.1.0",
21 | "uglify-js": ">=2.4.13",
22 | "watchify": ">=0.4.1",
23 | "jest-cli": "~0.1.5"
24 | },
25 | "scripts": {
26 | "start": "STATIC_ROOT=./static watchify -o static/js/bundle.js -v -d .",
27 | "build": "STATIC_ROOT=./static NODE_ENV=production browserify . | uglifyjs -cm > static/js/bundle.min.js",
28 | "collect-static": "collect-static . ./static",
29 | "test": "jest"
30 | },
31 | "author": "Eytan Bakshy",
32 | "license": "BSD",
33 | "browserify": {
34 | "transform": [
35 | "reactify",
36 | "envify"
37 | ]
38 | },
39 | "jest": {
40 | "rootDir": "./js"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/alpha/ruby/planout.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'plan_out/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "planout"
8 | spec.version = PlanOut::VERSION
9 | spec.authors = ["Eytan Bakshy", "Mohnish Thallavajhula"]
10 | spec.email = ["ebakshy@gmail.com", "i@mohni.sh"]
11 | spec.summary = %q{PlanOut is a framework and programming language for online field experimentation.}
12 | spec.description = %q{PlanOut is a framework and programming language for online field experimentation. PlanOut was created to make it easy to run and iterate on sophisticated experiments, while satisfying the constraints of deployed Internet services with many users.}
13 | spec.homepage = "https://facebook.github.io/planout"
14 | spec.license = "BSD"
15 |
16 | spec.files = `git ls-files -z`.split("\x0")
17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19 | spec.require_paths = ["lib"]
20 |
21 | spec.add_development_dependency "bundler", "~> 1.7"
22 | spec.add_development_dependency "minitest", "~> 5.5"
23 | spec.add_development_dependency "rake", "~> 10.0"
24 | end
25 |
--------------------------------------------------------------------------------
/python/docs/04.1-extending-logging.md:
--------------------------------------------------------------------------------
1 | ## Extending logging functionality
2 | If you have already adopted some method for logging events, you may want to use that method to log experiment-related events, such as exposures and experiment-specific outcomes. You can do this by extending the `Experiment` abstract class and overriding the `log` method.
3 |
4 | ### Overriding the `log` method
5 | To log exposures using your existing logging system, just override the `log` method when extending the `Experiment` abstract class. For example, if you write to your logs with `MyLogger`, then you might create a class as follows.
6 | ```python
7 |
8 | def log(self, data):
9 | MyLogger.log(data)
10 |
11 | ```
12 |
13 | ### Writing a log configuration method
14 | For some logging frameworks, you may want to have a way to setup your logger once per `Experiment` instance. In this case, you can implement a method to do this setup, which then gets called, as needed, by your `log` method.
15 |
16 |
17 | ### Adding caching
18 | By default, each instance of an Experiment class will only write to your logs once. But this can still result in a lot of writing when there are many instances created in a single request. So you may want to add some caching to prevent lots of unnecessary logging. Perhaps your logging system already handles this. Otherwise, you can add a key to a cache in the `log` method and check it before actually logging.
19 |
--------------------------------------------------------------------------------
/alpha/ruby/test/plan_out/assignment_test.rb:
--------------------------------------------------------------------------------
1 | require_relative '../test_helper'
2 |
3 | module PlanOut
4 | class AssignmentTest < Minitest::Test
5 | def setup
6 | @assignment = Assignment.new('mtsalt')
7 | end
8 |
9 | def test_salt
10 | assert_equal('mtsalt', @assignment.experiment_salt)
11 | end
12 |
13 | def test_evaluate
14 | assert_equal(1, @assignment.evaluate(1))
15 | assert_equal(2, @assignment.evaluate(2))
16 | end
17 |
18 | def test_set
19 | @assignment.set(:color, 'green')
20 | @assignment.set('platform', 'ios')
21 | @assignment.set('foo', UniformChoice.new({ unit: 1, choices: ['x', 'y'] }))
22 | assert_equal('green', @assignment.data[:color])
23 | assert_equal('ios', @assignment.data[:platform])
24 | assert_equal('y', @assignment.data[:foo])
25 | end
26 |
27 | def test_get
28 | @assignment.set(:button_text, 'Click Me!')
29 | @assignment.set(:gender, 'f')
30 | assert_equal('Click Me!', @assignment.get('button_text'))
31 | assert_equal('f', @assignment.get('gender'))
32 | assert_equal(10, @assignment.get('missing_key', 10))
33 | assert_nil(@assignment.get('missing_key'))
34 | end
35 |
36 | def test_get_params
37 | @assignment.set('foo', 'bar')
38 | @assignment.set(:baz, 'qux')
39 | assert_equal({ foo: 'bar', baz: 'qux' }, @assignment.get_params)
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/alpha/ruby/test/plan_out/experiment_test.rb:
--------------------------------------------------------------------------------
1 | require_relative '../test_helper'
2 | require_relative '../../examples/plan_out/voting_experiment'
3 |
4 | module PlanOut
5 | class ExperimentTest < Minitest::Test
6 | def setup
7 | @voting_experiment = VotingExperiment.new(userid: 14)
8 | @voting_experiment2 = VotingExperiment.new(userid: 15)
9 | @voting_experiment.auto_exposure_log = false
10 | @voting_experiment2.auto_exposure_log = false
11 | end
12 |
13 | def test_get_attributes
14 | assert_equal('ff0000', @voting_experiment.get(:button_color))
15 | assert_equal(1, @voting_experiment.get(:missing_key, 1))
16 | assert_equal('ff0000', @voting_experiment2.get(:button_color))
17 | assert_equal("I'm voting", @voting_experiment.get(:button_text))
18 | assert_equal("I'm voting", @voting_experiment2.get(:button_text))
19 | end
20 |
21 | def test_get_params
22 | assert_equal({ button_color: 'ff0000', button_text: "I'm voting" }, @voting_experiment.get_params)
23 | assert_equal({ button_color: 'ff0000', button_text: "I'm voting" }, @voting_experiment2.get_params)
24 | end
25 |
26 | def test_as_blob
27 | result = @voting_experiment.as_blob
28 | assert_equal('PlanOut::VotingExperiment', result[:name])
29 | assert_equal('PlanOut::VotingExperiment', result[:salt])
30 | assert_equal({ userid: 14 }, result[:inputs])
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/PATENTS:
--------------------------------------------------------------------------------
1 | Additional Grant of Patent Rights
2 |
3 | "Software" means the PlanOut software distributed by Facebook, Inc.
4 |
5 | Facebook hereby grants you a perpetual, worldwide, royalty-free, non-exclusive,
6 | irrevocable (subject to the termination provision below) license under any
7 | rights in any patent claims owned by Facebook, to make, have made, use, sell,
8 | offer to sell, import, and otherwise transfer the Software. For avoidance of
9 | doubt, no license is granted under Facebook’s rights in any patent claims that
10 | are infringed by (i) modifications to the Software made by you or a third party,
11 | or (ii) the Software in combination with any software or other technology
12 | provided by you or a third party.
13 |
14 | The license granted hereunder will terminate, automatically and without notice,
15 | for anyone that makes any claim (including by filing any lawsuit, assertion or
16 | other action) alleging (a) direct, indirect, or contributory infringement or
17 | inducement to infringe any patent: (i) by Facebook or any of its subsidiaries or
18 | affiliates, whether or not such claim is related to the Software, (ii) by any
19 | party if such claim arises in whole or in part from any software, product or
20 | service of Facebook or any of its subsidiaries or affiliates, whether or not
21 | such claim is related to the Software, or (iii) by any party relating to the
22 | Software; or (b) that any right in any patent claim of Facebook is invalid or
23 | unenforceable.
24 |
--------------------------------------------------------------------------------
/planout-editor/js/utils/DemoData.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2013-2014 Facebook, Inc.
3 | *
4 | * DemoData
5 | */
6 |
7 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants');
8 | var TesterBoxTypes = PlanOutEditorConstants.TesterBoxTypes;
9 |
10 | module.exports = {
11 | getDemoTests: function() {
12 | // this will eventually come from the server
13 | var defaultTests = [
14 | {
15 | "id": "playground",
16 | "inputs": {"userid": 42},
17 | "type": TesterBoxTypes.PLAYGROUND
18 | },
19 | {
20 | "id": "test3",
21 | "inputs":{"userid":5243},
22 | "overrides": {},
23 | "assertions": {"ratings_goal":640},
24 | "type": TesterBoxTypes.TEST
25 | },
26 | {
27 | "id": "test2",
28 | "inputs":{"userid":52433},
29 | "overrides": {"group_size":10},
30 | "assertions": {"ratings_goal": 200},
31 | "type": TesterBoxTypes.TEST
32 | },
33 | ];
34 | return defaultTests;
35 | },
36 |
37 | getDemoScript: function() {
38 | return [
39 | "group_size = uniformChoice(choices=[1, 10], unit=userid);",
40 | "specific_goal = bernoulliTrial(p=0.8, unit=userid);",
41 | "if (specific_goal) {",
42 | " ratings_per_user_goal = uniformChoice(",
43 | " choices=[8, 16, 32, 64], unit=userid);",
44 | " ratings_goal = group_size * ratings_per_user_goal;",
45 | "}"
46 | ].join('\n');
47 | }
48 | };
49 |
--------------------------------------------------------------------------------
/contrib/postgres_logger.py:
--------------------------------------------------------------------------------
1 | from planout.experiment import SimpleExperiment
2 |
3 | import psycopg2 as pg
4 | from psycopg2.extras import Json as pJson
5 |
6 | class PostgresLoggedExperiment(SimpleExperiment):
7 |
8 | def configure_logger(self):
9 | """ Sets up a logger to postgres.
10 |
11 | 1. Modify the connection_parameters variable to be a dictionary of the
12 | parameters to create a connection to your postgres database.
13 | 2. Modify the table variable to be the table to which you plan on
14 | logging.
15 | """
16 |
17 | connection_parameters = {'host': 'localhost',
18 | 'database': 'experiments'}
19 | table = 'experiments'
20 |
21 | self.conn = pg.connect(**connection_parameters)
22 | self.table = table
23 |
24 | def log(self, data):
25 | """ Log exposure. """
26 |
27 | columns = ['inputs', 'name', 'checksum', 'params', 'time', 'salt',
28 | 'event']
29 |
30 | names = ','.join(columns)
31 | placeholders = ','.join(['%s']*len(columns))
32 | ins_statement = ('insert into {} ({}) values ({})'
33 | .format(self.table, names, placeholders))
34 |
35 | row = []
36 | for column in columns:
37 | value = data[column]
38 | row.append(pJson(value) if isinstance(value, dict) else value)
39 |
40 | with self.conn.cursor() as curr:
41 | curr.execute(ins_statement, row)
42 |
43 | self.conn.commit()
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD License
2 |
3 | For PlanOut software
4 |
5 | Copyright (c) 2014, Facebook, Inc. All rights reserved.
6 |
7 | Redistribution and use in source and binary forms, with or without modification,
8 | are permitted provided that the following conditions are met:
9 |
10 | * Redistributions of source code must retain the above copyright notice, this
11 | list of conditions and the following disclaimer.
12 |
13 | * Redistributions in binary form must reproduce the above copyright notice,
14 | this list of conditions and the following disclaimer in the documentation
15 | and/or other materials provided with the distribution.
16 |
17 | * Neither the name Facebook nor the names of its contributors may be used to
18 | endorse or promote products derived from this software without specific
19 | prior written permission.
20 |
21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
25 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
28 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31 |
--------------------------------------------------------------------------------
/alpha/ruby/LICENSE:
--------------------------------------------------------------------------------
1 | BSD License
2 |
3 | For PlanOut software
4 |
5 | Copyright (c) 2014, Facebook, Inc. All rights reserved.
6 |
7 | Redistribution and use in source and binary forms, with or without modification,
8 | are permitted provided that the following conditions are met:
9 |
10 | * Redistributions of source code must retain the above copyright notice, this
11 | list of conditions and the following disclaimer.
12 |
13 | * Redistributions in binary form must reproduce the above copyright notice,
14 | this list of conditions and the following disclaimer in the documentation
15 | and/or other materials provided with the distribution.
16 |
17 | * Neither the name Facebook nor the names of its contributors may be used to
18 | endorse or promote products derived from this software without specific
19 | prior written permission.
20 |
21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
25 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
28 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31 |
--------------------------------------------------------------------------------
/python/setup.py:
--------------------------------------------------------------------------------
1 | from distutils.core import setup
2 |
3 | setup(
4 | name='PlanOut',
5 | version='0.6.0',
6 | author='Facebook, Inc.',
7 | author_email='eytan@fb.com',
8 | packages=[
9 | 'planout',
10 | 'planout.ops',
11 | 'planout.test'
12 | ],
13 | requires=[
14 | 'six'
15 | ],
16 | url='http://pypi.python.org/pypi/PlanOut/',
17 | license='LICENSE',
18 | description='PlanOut is a framework for online field experimentation.',
19 | keywords=['experimentation', 'A/B testing'],
20 | classifiers=[
21 | 'Development Status :: 5 - Production/Stable',
22 | 'Intended Audience :: Developers',
23 | 'License :: OSI Approved :: BSD License',
24 | 'Programming Language :: Python',
25 | 'Programming Language :: Python :: 2',
26 | 'Programming Language :: Python :: 2.7',
27 | 'Programming Language :: Python :: 3',
28 | 'Programming Language :: Python :: 3.4',
29 | 'Programming Language :: Python :: 3.5',
30 | 'Topic :: Software Development :: Libraries',
31 | 'Topic :: Software Development :: Testing',
32 | ],
33 | long_description="""PlanOut is a framework for online field experimentation.
34 | PlanOut makes it easy to design both simple A/B tests and more complex
35 | experiments, including multi-factorial designs and within-subjects designs.
36 | It also includes advanced features, including built-in logging, experiment
37 | management, and serialization of experiments via a domain-specific language.
38 | """,
39 | )
40 |
41 | # long_description=open('README.md').read(),
42 |
--------------------------------------------------------------------------------
/demos/demo_namespaces.py:
--------------------------------------------------------------------------------
1 | from planout.namespace import SimpleNamespace
2 | from planout.experiment import SimpleExperiment, DefaultExperiment
3 | from planout.ops.random import *
4 |
5 |
6 | class V1(SimpleExperiment):
7 | def assign(self, params, userid):
8 | params.banner_text = UniformChoice(
9 | choices=['Hello there!', 'Welcome!'],
10 | unit=userid)
11 |
12 | class V2(SimpleExperiment):
13 | def assign(self, params, userid):
14 | params.banner_text = WeightedChoice(
15 | choices=['Hello there!', 'Welcome!'],
16 | weights=[0.8, 0.2],
17 | unit=userid)
18 |
19 | class V3(SimpleExperiment):
20 | def assign(self, params, userid):
21 | params.banner_text = WeightedChoice(
22 | choices=['Nice to see you!', 'Welcome back!'],
23 | weights=[0.8, 0.2],
24 | unit=userid)
25 |
26 |
27 | class DefaultButtonExperiment(DefaultExperiment):
28 | def get_default_params(self):
29 | return {'banner_text': 'Generic greetings!'}
30 |
31 | class ButtonNamespace(SimpleNamespace):
32 | def setup(self):
33 | self.name = 'my_demo'
34 | self.primary_unit = 'userid'
35 | self.num_segments = 100
36 | self.default_experiment_class = DefaultButtonExperiment
37 |
38 | def setup_experiments(self):
39 | self.add_experiment('first version phase 1', V1, 10)
40 | self.add_experiment('first version phase 2', V1, 30)
41 | self.add_experiment('second version phase 1', V2, 40)
42 | self.remove_experiment('second version phase 1')
43 | self.add_experiment('third version phase 1', V3, 30)
44 |
45 | if __name__ == '__main__':
46 | for i in xrange(100):
47 | e = ButtonNamespace(userid=i)
48 | print 'user %s: %s' % (i, e.get('banner_text'))
49 |
--------------------------------------------------------------------------------
/python/planout/test/test_assignment.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2014, Facebook, Inc.
2 | # All rights reserved.
3 | #
4 | # This source code is licensed under the BSD-style license found in the
5 | # LICENSE file in the root directory of this source tree. An additional grant
6 | # of patent rights can be found in the PATENTS file in the same directory.
7 |
8 | import unittest
9 |
10 | from planout.assignment import Assignment
11 | from planout.ops.random import UniformChoice
12 |
13 |
14 | class AssignmentTest(unittest.TestCase):
15 | tester_unit = 4
16 | tester_salt = 'test_salt'
17 |
18 | def test_set_get_constant(self):
19 | a = Assignment(self.tester_salt)
20 | a.foo = 12
21 | self.assertEqual(a.foo, 12)
22 |
23 | def test_set_get_uniform(self):
24 | a = Assignment(self.tester_salt)
25 | a.foo = UniformChoice(choices=['a', 'b'], unit=self.tester_unit)
26 | a.bar = UniformChoice(choices=['a', 'b'], unit=self.tester_unit)
27 | a.baz = UniformChoice(choices=['a', 'b'], unit=self.tester_unit)
28 | self.assertEqual(a.foo, 'b')
29 | self.assertEqual(a.bar, 'a')
30 | self.assertEqual(a.baz, 'a')
31 |
32 | def test_overrides(self):
33 | a = Assignment(self.tester_salt)
34 | a.set_overrides({'x': 42, 'y': 43})
35 | a.x = 5
36 | a.y = 6
37 | self.assertEqual(a.x, 42)
38 | self.assertEqual(a.y, 43)
39 |
40 | def test_custom_salt(self):
41 | a = Assignment(self.tester_salt)
42 | custom_salt = lambda x,y: '%s-%s' % (x,y)
43 | a.foo = UniformChoice(choices=list(range(8)), unit=self.tester_unit)
44 | self.assertEqual(a.foo, 7)
45 |
46 | if __name__ == '__main__':
47 | unittest.main()
48 |
--------------------------------------------------------------------------------
/python/planout/test/test_namespace.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from planout.namespace import SimpleNamespace
4 | from planout.experiment import DefaultExperiment
5 |
6 |
7 | class VanillaExperiment(DefaultExperiment):
8 | def setup(self):
9 | self.name = 'test_name'
10 |
11 | def assign(self, params, i):
12 | params.foo = 'bar'
13 |
14 |
15 | class DefaultExperiment(DefaultExperiment):
16 | def get_default_params(self):
17 | return {'foo': 'default'}
18 |
19 |
20 | class NamespaceTest(unittest.TestCase):
21 | def test_namespace_remove_experiment(self):
22 | class TestVanillaNamespace(SimpleNamespace):
23 | def setup(self):
24 | self.name = 'test_namespace'
25 | self.primary_unit = 'i'
26 | self.num_segments = 100
27 | self.default_experiment_class = DefaultExperiment
28 |
29 | def setup_experiments(self):
30 | self.add_experiment('test_name', VanillaExperiment, 100)
31 | self.remove_experiment('test_name')
32 |
33 | assert TestVanillaNamespace(i=1).get('foo') == 'default'
34 |
35 | def test_namespace_add_experiment(self):
36 | class TestVanillaNamespace(SimpleNamespace):
37 | def setup(self):
38 | self.name = 'test_namespace'
39 | self.primary_unit = 'i'
40 | self.num_segments = 100
41 | self.default_experiment_class = DefaultExperiment
42 |
43 | def setup_experiments(self):
44 | self.add_experiment('test_name', VanillaExperiment, 100)
45 |
46 | assert TestVanillaNamespace(i=1).get('foo') == 'bar'
47 |
48 |
49 | if __name__ == '__main__':
50 | unittest.main()
51 |
--------------------------------------------------------------------------------
/planout-editor/js/constants/PlanOutEditorConstants.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2013-2014 Facebook, Inc.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | * PlanOutEditorConstants
17 | */
18 |
19 | var keyMirror = require('react/lib/keyMirror');
20 |
21 | module.exports = {
22 |
23 | ActionTypes: keyMirror({
24 | /**
25 | * Constants for actions
26 | *
27 | */
28 |
29 | // Creating and destroying PlanOutTesterBoxes
30 | CREATE_TESTER: null,
31 | LOAD_SERIALIZED_TESTERS: null,
32 | INIT_TESTER_PANEL: null,
33 | TESTER_DESTROY: null,
34 |
35 | // Updating PlanOutTesterBox states
36 | TESTER_USER_UPDATE_TEST: null,
37 | TESTER_SERVER_UPDATE_TEST: null,
38 | TESTER_INVALID_TEST_FORM: null,
39 | TESTER_REFRESH_TEST: null,
40 | TESTER_REFRESH_ALL_TESTS: null,
41 |
42 | // Code Editor / compilation related actions
43 | EDITOR_LOAD_SCRIPT: null,
44 | EDITOR_COMPILE_SCRIPT: null,
45 | EDITOR_UPDATE_COMPILED_CODE: null
46 | }),
47 |
48 | TesterStatusCodes: keyMirror({
49 | SUCCESS: null,
50 | FAILURE: null,
51 | INVALID_FORM: null,
52 | PENDING: null
53 | }),
54 |
55 | TesterBoxTypes: keyMirror({
56 | TEST: null,
57 | PLAYGROUND: null
58 | })
59 | };
60 |
--------------------------------------------------------------------------------
/python/docs/02-operators.md:
--------------------------------------------------------------------------------
1 | # Extending PlanOut
2 |
3 | Experiments
4 | - Logging
5 | - Assignment scheme
6 | - Salt
7 | - Uses a mapper
8 |
9 | Assignment schemes
10 | - Mappers are helpers for doing deterministic pseudorandom assignment
11 |
12 | Mappers:
13 | - One where you code in python
14 | -
15 |
16 | ## Core concepts
17 | An *Experiment* object takes inputs and maps it to parameter assignments. Experiment objects also handle logging and caching.
18 |
19 | *Mappers* are execution environments used to implement experiments. They execute *operators* which are functions that perform basic operations, including deterministic random assignment.
20 |
21 |
22 | ## Mappers
23 | A mapper translates inputs to parameter assignments.
24 | There are two main types of PlanOut mappers: `PlanOutKitMapper`, which is useful for many (ad hoc) experimentation needs, and `PlanOutInterpreterMapper`, which reads in serialized experiment definitions and is suitable for use in production environments when experiments are centrally managed via a Web interface. ``PlanOutInterpreterMapper``
25 |
26 | ## Experiment class
27 |
28 | The experiment class implements core functionality associated with each experiment. In particular, every experiment has a:
29 | - name
30 | - experiment-level salt
31 | - an assignment scheme using a PlanOut mapper that translates inputs to parameter values.
32 | - logging, which by default maintains an "exposure log" of when inputs (e.g., userids) get mapped to parameter. This makes it easier to keep track of who was in your experiment, and restrict your analysis to the experiment.
33 |
34 | To define a new experiment, one subclasses the Experiment class. By default, the name of the experiment will be the name of the subclass, and the experiment-level salt will be the name of the experiment.
35 |
--------------------------------------------------------------------------------
/planout-editor/static/js/mode/javascript/typescript.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | CodeMirror: TypeScript mode
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
61 |
62 |
--------------------------------------------------------------------------------
/demos/simple_experiment_examples.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2014, Facebook, Inc.
2 | # All rights reserved.
3 | #
4 | # This source code is licensed under the BSD-style license found in the
5 | # LICENSE file in the root directory of this source tree. An additional grant
6 | # of patent rights can be found in the PATENTS file in the same directory.
7 |
8 | from planout.experiment import SimpleExperiment
9 | from planout.ops.random import *
10 |
11 | class Exp1(SimpleExperiment):
12 | def assign(self, e, userid):
13 | e.group_size = UniformChoice(choices=[1, 10], unit=userid);
14 | e.specific_goal = BernoulliTrial(p=0.8, unit=userid);
15 | if e.specific_goal:
16 | e.ratings_per_user_goal = UniformChoice(
17 | choices=[8, 16, 32, 64], unit=userid)
18 | e.ratings_goal = e.group_size * e.ratings_per_user_goal
19 | return e
20 |
21 | class Exp2(SimpleExperiment):
22 | def assign(self, params, userid, pageid, liking_friends):
23 | params.num_cues = RandomInteger(
24 | min=1,
25 | max=min(len(liking_friends), 3),
26 | unit=[userid, pageid]
27 | )
28 | params.friends_shown = Sample(
29 | choices=liking_friends,
30 | draws=params.num_cues,
31 | unit=[userid, pageid]
32 | )
33 |
34 | class Exp3(SimpleExperiment):
35 | def assign(self, e, userid):
36 | e.has_banner = BernoulliTrial(p=0.97, unit=userid)
37 | cond_probs = [0.5, 0.95]
38 | e.has_feed_stories = BernoulliTrial(p=cond_probs[e.has_banner], unit=userid)
39 | e.button_text = UniformChoice(
40 | choices=["I'm a voter", "I'm voting"], unit=userid)
41 |
42 |
43 | class Exp4(SimpleExperiment):
44 | def assign(self, e, sourceid, storyid, viewerid):
45 | e.prob_collapse = RandomFloat(min=0.0, max=1.0, unit=sourceid)
46 | e.collapse = BernoulliTrial(p=e.prob_collapse, unit=[storyid, viewerid])
47 | return e
48 |
--------------------------------------------------------------------------------
/demos/demo_experiments.py:
--------------------------------------------------------------------------------
1 | import interpreter_experiment_examples as interpreter
2 | import simple_experiment_examples as simple_experiment
3 |
4 | def demo_experiment1(module):
5 | print 'using %s...' % module.__name__
6 | exp1_runs = [module.Exp1(userid=i) for i in xrange(10)]
7 | print [(e.get('group_size'), e.get('ratings_goal')) for e in exp1_runs]
8 |
9 | def demo_experiment2(module):
10 | print 'using %s...' % module.__name__
11 | # number of cues and selection of cues depends on userid and pageid
12 | for u in xrange(1,4):
13 | for p in xrange(1, 4):
14 | print module.Exp2(userid=u, pageid=p, liking_friends=['a','b','c','d'])
15 |
16 | def demo_experiment3(module):
17 | print 'using %s...' % module.__name__
18 | for i in xrange(5):
19 | print module.Exp3(userid=i)
20 |
21 | def demo_experiment4(module):
22 | print 'using %s...' % module.__name__
23 | for i in xrange(5):
24 | # probability of collapsing is deterministic on sourceid
25 | e = module.Exp4(sourceid=i, storyid=1, viewerid=1)
26 | # whether or not the story is collapsed depends on the sourceid
27 | exps = [module.Exp4(sourceid=i, storyid=1, viewerid=v) for v in xrange(10)]
28 | print e.get('prob_collapse'), [exp.get('collapse') for exp in exps]
29 |
30 | if __name__ == '__main__':
31 | # run each experiment implemented using SimpleExperiment (simple_experiment)
32 | # or using the interpreter
33 | print '\nDemoing experiment 1...'
34 | demo_experiment1(simple_experiment)
35 | demo_experiment1(interpreter)
36 |
37 | print '\nDemoing experiment 2...'
38 | demo_experiment2(simple_experiment)
39 | demo_experiment2(interpreter)
40 |
41 | print '\nDemoing experiment 3...'
42 | demo_experiment3(simple_experiment)
43 | demo_experiment3(interpreter)
44 |
45 | print '\nDemoing experiment 4...'
46 | demo_experiment4(simple_experiment)
47 | demo_experiment4(interpreter)
48 |
--------------------------------------------------------------------------------
/planout-editor/README.md:
--------------------------------------------------------------------------------
1 | ## PlanOut Editor
2 |
3 | The PlanOut editor lets you interactively edit and test PlanOut code. It's built on Flux and React.
4 |
5 | ## Running
6 |
7 | The PlanOut editor executes PlanOut scripts and tests by sending data to a
8 | Python-based kernel which responds to AJAX requests. To start the kernel,
9 | run the following from your command line:
10 |
11 | `python planout-editor-kernel.py`
12 |
13 | Then, navigate to:
14 |
15 | [http://localhost:5000/](http://localhost:5000/).
16 |
17 |
18 | Note that the kernel requires that you have Flask and PlanOut already installed.
19 | If you don't have either package, you can install them via
20 | [pip](http://pip.readthedocs.org/en/latest/installing.html) by typing
21 | `pip install Flask` and `pip install planout`.
22 |
23 |
24 | ## Hacking the Editor
25 | If you want to make changes or contribute to the PlanOut editor, you must first
26 | have [npm](https://www.npmjs.org/) installed on your computer.
27 |
28 | You can then install all the package dependencies by going to the root directory
29 | and entering in the following into the command line.
30 |
31 | `npm install`
32 |
33 | If you don't already have watchify installed, you may have to type `npm install watchify` before entering `npm install`.
34 |
35 | Then, to build the project, run this command:
36 |
37 | `npm start`
38 |
39 | This will perform an initial build and start a watcher process that will
40 | continuously update the main application file, `bundle.js`, with any changes you make. This way, you can test your changes to the editor by simply saving your code and hitting refresh in your Web browser.
41 |
42 | This watcher is
43 | based on [Browserify](http://browserify.org/) and
44 | [Watchify](https://github.com/substack/watchify), and it transforms
45 | React's JSX syntax into standard JavaScript with
46 | [Reactify](https://github.com/andreypopp/reactify).
47 |
--------------------------------------------------------------------------------
/python/docs/05-namespaces.md:
--------------------------------------------------------------------------------
1 | # Namespaces
2 |
3 | Namespaces are used to manage related experiments that manipulate the same parameter. These experiments might be run sequentially (over time) or in parallel. When experiments are conducted in parallel, namespaces can be used to keep experiments "exclusive" or "non-overlapping".
4 |
5 |
6 | Namespaces and models like [Google's "layers"](http://research.google.com/pubs/pub36500.html) and Facebook's "universes" solve the overlapping experiment problem by centering experiments around a primary unit, such as user IDs. Within a given namespace, each primary unit belongs to at most one experiment.
7 |
8 |
9 | ### How do namespaces work?
10 | Rather than requesting a parameter from an experiment, developers request a parameter from a namespace, which then handles identifying the experiment (if there is one) that that unit is part of.
11 |
12 | Under the hood, primary units are mapped to one of a large number of segments (e.g., 10,000).
13 | These segments are allocated to experiments. For any given unit, a namespace manager looks up that unit's segment. If the segment is allocated to an experiment, the input data is passed to the experiment, and random assignment occurs using the regular logic of the corresponding `Experiment` object.
14 |
15 | If the primary unit is not mapped to an experiment, or a parameter is requested that is not defined by the experiment, a default experiment or value may be used.
16 | This allows experimenters to configure default values for parameters on the fly in a way that does not interfere with currently running experiments.
17 |
18 |
19 | ### When do I need to use a namespace?
20 | Namespaces are useful whenever there is at least one variable in your code base that you would like to experiment with, either over time or simultaneously.
21 |
22 | As a starting point, PlanOut provides a basic implementation of namespaces with the `SimpleNamespace` class.
23 |
--------------------------------------------------------------------------------
/contrib/pydata14_tutorial/README.md:
--------------------------------------------------------------------------------
1 | # PyData '14 PlanOut Tutorial
2 | Welcome to the PyData '14 Silicon Valley PlanOut tutorial! You'll find a collection of IPython notebooks for Eytan Bakshy's tutorial on PlanOut.
3 |
4 | If you want to follow along the live tutorial on your own computer (or are not at PyData!), you need to install some software and clone the PlanOut git repository.
5 |
6 | ## Requirements
7 | ### Software requirements
8 | The tutorial requires IPython, Pandas, and PlanOut. The former two come with Anaconda. PlanOut has only been tested on Mac OS X and Linux.
9 |
10 | * IPython ([installation instructions](http://ipython.org/install.html). We recommend Anaconda.)
11 | * PlanOut v0.2 or greater (first timers `sudo easy_install planout`, older timers `sudo easy_install --upgrade planout`)
12 | * Node.js - optional (installation link on the [node.js homepage](http://nodejs.org))
13 |
14 | Note that you may need to re-install the `planout` package if you installed PlanOut before installing IPython.
15 |
16 | ### Downloading the tutorial files
17 | If you have git, you can checkout PlanOut by typing:
18 |
19 | ```
20 | git clone https://github.com/facebook/planout.git
21 | ```
22 |
23 | or you can [click here](https://github.com/facebook/planout/archive/master.zip) to download a zip archive.
24 |
25 |
26 | ## Loading up the tutorial notebooks
27 | Navigate to your checked out version of PlanOut and type:
28 |
29 | ```
30 | cd contrib/pydata14_tutorial/
31 | ipython notebook --pylab inline
32 | ```
33 |
34 | Tutorial files include:
35 | * `0-getting-started.ipynb`: This teaches you the basics of how to implement experiments in pure Python.
36 | * `1-logging.ipynb`: How data is logged in PlanOut and examples of how to analyze PlanOut log data with Pandas.
37 | * `2-interpreter.ipynb`: How to generate serialized experiment definitions using (1) the PlanOut domain-specific language (2) automatically, e.g., via configuration files or GUIs.
38 | * `3-namespaces.ipynb`: How to manage simultaneous and follow-on experiments.
39 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to PlanOut
2 | We want to make contributing to this project as easy and transparent as
3 | possible. If you identify and bugs, have improvements to the code or documentation,
4 | we will be happy to review your code and integrate the changes into the main
5 | branch.
6 |
7 | If you are planning on porting PlanOut to other languages or platforms,
8 | please let us know! We are happy to review ports in detail, and either post
9 | them on our own repository, or link to your own repository as a submodule.
10 |
11 | ## Code of Conduct
12 | The code of conduct is described in [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
13 |
14 | ## Our Development Process
15 | The Python reference implementation mirrors the functionality of PlanOut and
16 | core parts of Facebook's experimentation stack, which is written in Hack (we
17 | hope to open source parts of this implementation in the near future). As
18 | we improve PlanOut internally, we will continue to maintain the Python
19 | implementation as to maintain feature parity.
20 |
21 | ## Pull Requests
22 | We actively welcome your pull requests.
23 | 1. Fork the repo and create your branch from `master`.
24 | 2. If you've added code that should be tested (most code should be!), add unit tests.
25 | 3. If you've changed APIs, update the documentation.
26 | 4. Ensure the test suite passes.
27 | 5. Make sure your code lints.
28 | 6. If you haven't already, complete the Contributor License Agreement ("CLA").
29 |
30 | ## Contributor License Agreement ("CLA")
31 | In order to accept your pull request, we need you to submit a CLA. You only need
32 | to do this once to work on any of Facebook's open source projects.
33 |
34 | Complete your CLA here:
35 |
36 | ## Issues
37 | We use GitHub issues to track public bugs. Please ensure your description is
38 | clear and has sufficient instructions to be able to reproduce the issue.
39 |
40 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe
41 | disclosure of security bugs. In those cases, please go through the process
42 | outlined on that page and do not file a public issue.
43 |
44 | ## Coding Style
45 | * 80 character line length
46 | * PEP-8 (if contributing Python code)
47 |
48 |
49 | ## License
50 | By contributing to PlanOut, you agree that your contributions will be licensed
51 | under its BSD license.
52 |
--------------------------------------------------------------------------------
/python/docs/01-why-planout.md:
--------------------------------------------------------------------------------
1 | A/B tests and other randomized experiments are widely used as part of continually improving Web and mobile apps and services. PlanOut makes it easy to run both simple and complex experiments.
2 |
3 | ## Focus on parameters
4 |
5 | PlanOut is all about providing randomized values of parameters that control your service. Instead of using a constant, just use PlanOut to determine these parameters (e.g., text size or color, the presence of a new feature, the number of items in a list). Now you have an experiment.
6 |
7 | ## From simple to complex
8 |
9 | It is easy to implement an A/B test in PlanOut, or other simple experiments like those involving a factorial design. But is not much harder to implement more complex designs. Multiple types of units (e.g., users, pieces of content) can be randomly assigned to parameter values in the same experiment. Experiments can also involve directly randomizing other inputs, such as randomly selecting which three friends to display to a user.
10 |
11 | ## Automatic logging
12 | You will often want to keep track of which users (or other units) have been exposed to your experiment. This can make subsequent analysis more precise and prevent common errors in analysis. PlanOut calls your logging code whenever a parameter value is checked.
13 |
14 |
15 | ## Advanced features
16 |
17 | We created PlanOut to meet requirements from running experiments at Facebook, which gives rise to some of its more advanced features.
18 |
19 | ### Serialization
20 | Experiments can also be specified through JSON code. This can enable separate review processes for changes to the experiment, support multi-platform execution, and restrict the range of operations that should occur during experimental assignment (for reasons of, e.g., performance, correctness, static analysis). It also allows developers to implement their own tools to specify experiments without writing any code at all.
21 |
22 | ### Domain-specific language
23 | PlanOut experiments can be specified through the PlanOut language, which concisely describes an experiment using a set of primitive operations. PlanOut language code is compiled into the JSON serialization, which can be executed by the PlanOut interpreter as needed.
24 |
25 | ### Iterative experimentation
26 | The PlanOut library includes a basic namespace class (link) for managing multiple, iterative experiments that run concurrently.
27 |
--------------------------------------------------------------------------------
/planout-editor/static/js/mode/javascript/json-ld.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | CodeMirror: JSON-LD mode
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
42 | );
43 | },
44 |
45 | renderInputItem: function(label, prop) {
46 | return (
47 |
53 | );
54 | },
55 |
56 |
57 | // Parses JSON-encoded form element strings and returns object
58 | // containing each element: inputs, overrides, assertions
59 | // May be subbed out for other extract
60 | extractItemData: function() {
61 | var jsonBlob = {
62 | inputs: this.refs.inputs.getJSON(),
63 | overrides: this.refs.overrides.getJSON()
64 | };
65 | if (this.props.type === TesterBoxTypes.TEST) {
66 | jsonBlob.assertions = this.refs.assertions.getJSON();
67 | }
68 |
69 | for (var key in jsonBlob) {
70 | if (jsonBlob[key] === null) {
71 | return null;
72 | }
73 | }
74 | return jsonBlob;
75 | },
76 |
77 | _updateJSON: function(event, ref) {
78 | this.refs[ref].updateJSON(event.target.value);
79 | },
80 |
81 | _onChange: function(event, ref) {
82 | /*
83 | var payload = {};
84 | payload[ref] = this.refs[ref].getJSON();
85 | if (payload[ref]) {
86 | PlanOutTesterActions.updateTester(
87 | this.props.id,
88 | payload
89 | );
90 | }
91 | */
92 |
93 |
94 | var itemData = this.extractItemData();
95 | // should probably add more granular checks to make sure
96 | // input data is given. these checks may not be necessary
97 | // once we move to better form UI components
98 | if (itemData) {
99 | PlanOutTesterActions.updateTester(
100 | this.props.id,
101 | {
102 | inputs: itemData.inputs,
103 | overrides: itemData.overrides,
104 | assertions: itemData.assertions
105 | }
106 | );
107 | }
108 | }
109 | });
110 |
111 | module.exports = PlanOutTesterBoxForm;
112 |
--------------------------------------------------------------------------------
/python/docs/07-language.md:
--------------------------------------------------------------------------------
1 | # The PlanOut Language
2 | The PlanOut language is a way to concisely define experiments.
3 | PlanOut language code is compiled into JSON. The language is very basic: it contains basic logical operators, conditional execution, and arrays, but does not include things like loops and function definitions. This makes it easier to statically analyze experiments, and prevents users from shooting themselves in the foot. The syntax mostly resembles JavaScript.
4 |
5 | All variables set by PlanOut code are passed back via `Interpreter.get_params()` and by default, are automatically logged when used in conjunction with `SimpleInterpretedExperiment`.
6 |
7 | ## Overview
8 | * Lines are terminated with a `;`
9 | * Arrays are defined like `[1,2,3]`, and are indexed starting at `0`.
10 | * Random assignment operators (e.g., `uniformChoice`, `weightedChoice`, `bernoulliTrial`) require named parameters, and the ordering of parameter is arbitrary.
11 | * `True` and `False` values are equivalent to `1` and `0`.
12 | * `#`s are used to write comments
13 | * You can use the `PlanOutLanguageInspector` class to validate whether all PlanOut operators use the required and optional methods.
14 |
15 | ## Compiling PlanOut code
16 | PlanOut code can be compiled via the [Web-based compiler interface](http://facebook.github.io/planout/demo/planout-compiler.html) or using the node.js script in the `compiler/` directory of the PlanOut github repository:
17 |
18 | ```
19 | node compiler/planout.js planoutscriptname
20 | ```
21 |
22 | ## Built-in operators
23 |
24 | ### Random assignment operators
25 | The operators described in [...] can be used similar to how they are used in Python, except are lower case. The variable named given on the left hand side of an assignment operation is used as the `salt' given random assignment operators if no salt is specified manually.
26 |
27 | ```
28 | colors = ['#aa2200', '#22aa00', '#0022aa'];
29 | x = uniformChoice(choices=colors, unit=userid); # 'x' used as salt
30 | y = uniformChoice(choices=colors, unit=userid); # 'y' used as salt, generally != x
31 | z = uniformChoice(choices=colors, unit=userid, salt='x'); # same value as x
32 | ```
33 |
34 |
35 | ### Array operators
36 | Arrays can include constants and variables, and can be arbitrarily nested, and can contain arbitrary types.
37 |
38 | ```
39 | a = [4, 5, 'foo'];
40 | b = [a, 2, 3]; # evaluates to [[1,2,'foo'], 2,3]
41 | x = a[0]; # evaluates to 4
42 | y = b[0][2] # evaluates to 'foo'
43 | l = length(b); # evaluates to 3
44 | ```
45 |
46 | ### Logical operators
47 | Logical operators include *and* (`&&`), *or* (`||`), *not* (`!`), as in:
48 |
49 | ```
50 | a = 1; b = 0; c = 1;
51 | x = a && b; # evaluates to False
52 | y = a || b || c; # evaluates to True
53 | y = !b; # evaluates to True
54 | ```
55 |
56 | ### Control flow
57 | PlanOut supports if / else if / else.
58 | ```
59 | if (country == 'US') {
60 | p = 0.2;
61 | } else if (country == 'UK') {
62 | p = 0.4;
63 | } else {
64 | p = 0.1;
65 | }
66 | ```
67 |
68 | ### Arithmetic
69 | Current arithmetic operators supported in the PlanOut language are: addition, subtraction, modulo, multiplication, and division.
70 | ```
71 | a = 2 + 3 + 4; # 9
72 | b = 2 * 3 * 4; # 24
73 | c = -2; # -2
74 | d = 2 + 3 - 4; # 1
75 | e = 4 % 2; # 0
76 | f = 4 / 2; # 2.0
77 | ```
78 |
79 | ### Other operators
80 | Other operators that are part of the core language include `min` and `max`:
81 | ```
82 | x = min(1, 2, -4); # -4
83 | y = min([1, 2, -4]) # -4
84 | y = max(1, 2, -4) # 2
85 | ```
86 |
--------------------------------------------------------------------------------
/python/docs/05.1-simple-namespaces.md:
--------------------------------------------------------------------------------
1 | # Iterating on experiments with `SimpleNamespace`
2 |
3 | `SimpleNamespace` provides namespace functionality without being backed by a database or involving a larger experimentation management system. For both organizational and performance reasons, it is not recommended for namespaces that would be used to run many (e.g., thousands) experiments, but it should be sufficient for iterating on smaller-scale experiments.
4 |
5 | Similar to how you create experiments with the `SimpleExperiment` class, new namespaces are created through subclassing. `SimpleNamespace` two requires that developers implement two methods:
6 | - `setup_attributes()`: this method sets the namespace's name, the primary unit, and number of segments. The primary unit is the input unit that gets mapped to segments, which are allocated to experiments.
7 | - `setup_experiments()`: this method allocates and deallocates experiments. When used in production, lines of code should only be added to this method.
8 |
9 | ```python
10 | class ButtonNamespace(SimpleNamespace):
11 | def setup_attributes(self):
12 | self.name = 'my_demo'
13 | self.primary_unit = 'userid'
14 | self.num_segments = 1000
15 |
16 | def setup_experiments():
17 | # create and remove experiments here
18 | ```
19 |
20 | In the example above, the name of the namespace is `my_demo`. This gets used, in addition to the experiment name and variable names, to hash units to experimental conditions. The number of segments is the granularity of the experimental groups.
21 |
22 | ### Allocating and deallocating segments to experiments
23 | When you extend `SimpleNamespace` class, you implement the `setup_experiments` method. This specifies a series of allocations and deallocations of segments to experiments.
24 |
25 | For example, the following setup method:
26 | ```python
27 | def setup_experiments():
28 | self.add_experiment('first experiment', MyFirstExperiment, 100)
29 | ```
30 | would allocate 'first experiment' is the name of your experiment, 100 of 1000 segments, selected at random to be allocated to the `MyFirstExperiment` class. In this example (see demo/demo_namespace.py), `MyFirstExperiment` is a subclass of `SimpleExperiment`.
31 |
32 | Adding additional experiments would just involve appending additional lines to the method. For example, you might run a larger follow-up experiment with the same design:
33 | ```python
34 | def setup_experiments():
35 | self.add_experiment('first experiment', MyFirstExperiment, 10)
36 | self.add_experiment('first experiment, replication 1', MyFirstExperiment, 40)
37 | ```
38 | Or you might run a new experiment with a different design:
39 | ```python
40 | def setup_experiments():
41 | self.add_experiment('first experiment', MyFirstExperiment, 10)
42 | self.add_experiment('second experiment', MySecondExperiment, 20)
43 | ```
44 | When an experiment is complete and you wish to make its segments available to new experiments, append a call to `remove_experiment`:
45 | ```python
46 | def setup_experiments():
47 | self.add_experiment('first experiment', MyFirstExperiment, 10)
48 | self.add_experiment('second experiment', MySecondExperiment, 20)
49 | self.remove_experiment('first experiment')
50 | self.add_experiment('third experiment', MyThirdExperiment, 50)
51 | ```
52 | Note: In modifying this method, you should only add lines after all previous lines. Inserting calls to `add_experiment` or `remove_experiment` will likely move segments from one experiment to another -- not what you want! This is because there is no stored allocation state (e.g., in a database), so the current allocation is dependent on the full history.
53 |
--------------------------------------------------------------------------------
/python/docs/03-how-planout-works.md:
--------------------------------------------------------------------------------
1 | # How PlanOut works
2 |
3 | PlanOut works by hashing input data into numbers, and using these numbers to generate what are effectively pseudo-random values to pick numbers. All PlanOut operators include basic unit tests (link) to verify that they generate assignments with the expected distribution.
4 |
5 | Good randomization procedures produce assignments that are independent of one another. Below, we show how PlanOut uses experiment-level and variable-level "salts" (strings that get appended to the data thats being hashed) to make sure that variables within and across experiments remain independent.
6 |
7 | ## Pseudo-random assignment through hashing
8 | Consider the following experiment:
9 | ```python
10 | class SharingExperiment(SimpleExperiment):
11 | def set_attributes(self):
12 | self.name = 'sharing_name'
13 | self.salt = 'sharing_salt'
14 |
15 | def assign(self, params, userid):
16 | params.button_text = UniformChoice(
17 | choices=['OK', 'Share', 'Share with friends'],
18 | unit=userid
19 | )
20 | ```
21 | Here, we define a single randomized parameter, `button_text`. The assignment is generated by first composing a string containing experiment-level salt, `sharing_salt`, the parameter-level salt `button_text`, and the input unit. By default, PlanOut uses the variable name as the parameter-level salt.
22 |
23 | When we choose the button text for a particular unit, e.g.,
24 |
25 | ```python
26 | SharingExperiment(userid=4).get('button_text')
27 | ```
28 |
29 | PlanOut would compute the SHA1 checksum for:
30 | ```
31 | sharing_salt.button_text.4
32 | ```
33 | and then use the last few digits of this checksum to index into the given list of `choices`. Since SHA1 is cryptographically safe, even minor changes to the hashing string (e.g., considering `userid=41` instead of 4) will result in a totally different number.
34 |
35 | Multiple units are handled through concatenation. Had the `unit` parameter been `unit=[userid, url]`,
36 |
37 | ```python
38 | SharingExperiment(userid=4, url='http://www.facebook.com').get('button_text')
39 | ```
40 |
41 | PlanOut would compute the SHA1 checksum for:
42 | ```
43 | sharing_salt.button_text.4.http://www.facebook.com
44 | ```
45 |
46 | Note that because PlanOut simply concatenates the units, the order in which you specify lists of units matters.
47 |
48 |
49 | ## Salts
50 |
51 | ### Experiment-level salts
52 | Experiment-level salts can be manually in the `set_attributes()` method (as we have above). If the salt is not specified, then the experiment name is used as the salt. With SimpleExperiment, if the name is not set in `set_attributes()`, then the name of the class is used as the experiment name.
53 |
54 | ### Parameter-level salts
55 | The parameter name is automatically used to salt random assignment operations, but parameter level salts can be specified manually. For example, in the following code
56 |
57 | ```python
58 | params.x = UniformChoice(choices=['a','b'], unit=userid)
59 | params.y = UniformChoice(choices=['a','b'], unit=userid, salt='x')
60 | ```
61 |
62 | both `x` and `y` will always be assigned to the same exact same value.
63 |
64 | This lets you change the name of the variable you are logging without changing the assignment. Use variable level salts with caution, since they might lead to failures in randomization (link).
65 |
66 | ### Salts with namespaces
67 | Namespaces (link) are a way to manage concurrent and iterative experiments. When using `SimpleNamespace`, the namespace-level salt is appended to the experiment-level salt. This ensures that random assignment is independent across experiments with the same name running under different namespaces.
68 |
--------------------------------------------------------------------------------
/python/planout/ops/utils.py:
--------------------------------------------------------------------------------
1 | import json
2 | import six
3 |
4 | class StopPlanOutException(Exception):
5 |
6 | """Exception that gets raised when "return" op is evaluated"""
7 |
8 | def __init__(self, in_experiment):
9 | self.in_experiment = in_experiment
10 |
11 |
12 | class Operators():
13 | """Singleton class for inspecting and registering operators"""
14 |
15 | @staticmethod
16 | def initFactory():
17 | from . import core, random
18 | Operators.operators = {
19 | "literal": core.Literal,
20 | "get": core.Get,
21 | "seq": core.Seq,
22 | "set": core.Set,
23 | "return": core.Return,
24 | "index": core.Index,
25 | "array": core.Array,
26 | "map": core.Map,
27 | "equals": core.Equals,
28 | "cond": core.Cond,
29 | "and": core.And,
30 | "or": core.Or,
31 | ">": core.GreaterThan,
32 | "<": core.LessThan,
33 | ">=": core.GreaterThanOrEqualTo,
34 | "<=": core.LessThanOrEqualTo,
35 | "%": core.Mod,
36 | "/": core.Divide,
37 | "not": core.Not,
38 | "round": core.Round,
39 | "negative": core.Negative,
40 | "min": core.Min,
41 | "max": core.Max,
42 | "length": core.Length,
43 | "coalesce": core.Coalesce,
44 | "product": core.Product,
45 | "sum": core.Sum,
46 | "exp": core.Exp,
47 | "sqrt": core.Sqrt,
48 | "randomFloat": random.RandomFloat,
49 | "randomInteger": random.RandomInteger,
50 | "bernoulliTrial": random.BernoulliTrial,
51 | "bernoulliFilter": random.BernoulliFilter,
52 | "uniformChoice": random.UniformChoice,
53 | "weightedChoice": random.WeightedChoice,
54 | "sample": random.Sample,
55 | "fastSample": random.FastSample
56 | }
57 |
58 | @staticmethod
59 | def registerOperators(operators):
60 | for op, obj in six.iteritems(operators):
61 | assert op not in Operators.operators
62 | Operators.operators[op] = operators[op]
63 |
64 | @staticmethod
65 | def isOperator(op):
66 | return type(op) is dict and "op" in op
67 |
68 | @staticmethod
69 | def operatorInstance(params):
70 | op = params['op']
71 | assert (op in Operators.operators), "Unknown operator: %s" % op
72 | return Operators.operators[op](**params)
73 |
74 | @staticmethod
75 | def prettyParamFormat(params):
76 | ps = [p + '=' + Operators.pretty(params[p])
77 | for p in params if p != 'op']
78 | return ', '.join(ps)
79 |
80 | @staticmethod
81 | def strip_array(params):
82 | if type(params) is list:
83 | return params
84 | if type(params) is dict and params.get('op', None) == 'array':
85 | return params['values']
86 | else:
87 | return params
88 |
89 | @staticmethod
90 | def pretty(params):
91 | if Operators.isOperator(params):
92 | try:
93 | # if an op is invalid, we may not be able to pretty print it
94 | my_pretty = Operators.operatorInstance(params).pretty()
95 | except:
96 | my_pretty = params
97 | return my_pretty
98 | elif type(params) is list:
99 | return '[%s]' % ', '.join([Operators.pretty(p) for p in params])
100 | else:
101 | return json.dumps(params)
102 |
--------------------------------------------------------------------------------
/python/docs/04-logging.md:
--------------------------------------------------------------------------------
1 | # Logging
2 |
3 | You will usually want to log which units (e.g., users) are exposed to your experiment.
4 |
5 | Logging this information enables monitoring your experiment and improving your analysis of the results. In particular, many experiments only change your service for the small portion of users that use a particular part of the service; keeping track of these users will make your analysis more precise.
6 |
7 | The `SimpleExperiment` class providing functionality for logging data to a file using Python's `logger` module. Additionally, you can extend `Experiment` to call your logging code instead (link to extending-logging).
8 |
9 | ## Anatomy of a log
10 | Consider Experiment 1 from the PlanOut paper (link):
11 | ```
12 | class Exp1(SimpleExperiment):
13 | def assign(self, e, userid):
14 | e.group_size = UniformChoice(choices=[1, 10], unit=userid);
15 | e.specific_goal = BernoulliTrial(p=0.8, unit=userid);
16 | if e.specific_goal:
17 | e.ratings_per_user_goal = UniformChoice(
18 | choices=[8, 16, 32, 64], unit=userid)
19 | e.ratings_goal = e.group_size * e.ratings_per_user_goal
20 | return e
21 | ```
22 | It takes a `userid` as input, and assigns three paramers, `group_size`, `specific_goal`, and `ratings goal`. It does not specify a custom salt (link) or experiment name, so the experiment salt and name are automatically set to the class name `Exp3`. The default logger in `SimpleExperiment` log all of these fields:
23 |
24 | ```
25 | {'inputs': {'userid': 3}, 'checksum': '4b80881d', 'salt': 'Exp1', 'name': 'Exp1', 'params': {'specific_goal': 1, 'ratings_goal': 160, 'group_size': 10, 'ratings_per_user_goal': 16}}
26 | ```
27 |
28 | In addition, a `checksum` field is attached. This is part of an MD5 checksum of your code, so that analysts can keep track of when an experiments' assignments have potentially changed: whenever the checksum changes, the assignment procedure code is different, and whenever the salt changes, the assignments will be completely different.
29 |
30 | TODO: exposure-log logs should have a field that specifies its an exposure. This payload should also include a timestamp.
31 |
32 | ## Types of logs
33 |
34 | ### Auto-exposure logging
35 | By default, exposures are logged once per instance of an experiment object when you get a parameter. This is auto-exposure logging. It is recommended for most situations, since you will want to track whenever a unit is exposed to your experiment. Generally, any unit for which a parameter has been retrieved should be counted as exposed, unless you wish to make further assumptions.
36 |
37 | ### Manual exposure logging
38 | In some cases, you might want to choose exactly when exposures are logged. You can disable auto-exposure logging with the `set_auto_exposure_logging` method and instead choose to directly call `log_exposure` to keep track of exposures.
39 |
40 | Why might you want to do this? You might be adding experimental assignment information to other existing logs of outcome data, but many of the users who have outcome observations may not actually have been exposed. Other cases occur when some advance preparation of some components (e.g., UI) or data are required, but you can assume that parameter values set at this stage do not yet affect the user.
41 |
42 | ### Conversion logging
43 | You want to see how the parameters you are manipulating affect outcomes or conversion events. It can also be convenient to log these events along with exposures. You can do this by calling the `log_outcome` method.
44 |
45 | You may have existing logs for many of these events. In this case, you could add experimental assignment information to these logs by instantiating an experiment, turning off auto-exposure logging for that instance, and adding parameter information to your logs. Alternatively, you can later join exposure and outcome data on unit identifiers (e.g., user IDs).
46 |
--------------------------------------------------------------------------------
/python/planout/interpreter.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2014, Facebook, Inc.
2 | # All rights reserved.
3 | #
4 | # This source code is licensed under the BSD-style license found in the
5 | # LICENSE file in the root directory of this source tree. An additional grant
6 | # of patent rights can be found in the PATENTS file in the same directory.
7 |
8 | from copy import deepcopy
9 | from .ops.utils import Operators, StopPlanOutException
10 | from .assignment import Assignment
11 |
12 |
13 | Operators.initFactory()
14 |
15 |
16 | class Interpreter(object):
17 |
18 | """PlanOut interpreter"""
19 |
20 | def __init__(self, serialization, experiment_salt='global_salt',
21 | inputs={}, environment=None):
22 | self._serialization = serialization
23 | if environment is None:
24 | self._env = Assignment(experiment_salt)
25 | else:
26 | self._env = environment
27 | self.experiment_salt = self._experiment_salt = experiment_salt
28 | self._evaluated = False
29 | self._in_experiment = True
30 | self._inputs = inputs.copy()
31 |
32 | def register_operators(self, operators):
33 | Operators.registerOperators(operators)
34 | return self
35 |
36 | def get_params(self):
37 | """Get all assigned parameter values from an executed interpreter script"""
38 | # evaluate code if it hasn't already been evaluated
39 | if not self._evaluated:
40 | try:
41 | self.evaluate(self._serialization)
42 | except StopPlanOutException as e:
43 | # StopPlanOutException is raised when script calls "return", which
44 | # short circuits execution and sets in_experiment
45 | self._in_experiment = e.in_experiment
46 | self._evaluated = True
47 | return self._env
48 |
49 | @property
50 | def in_experiment(self):
51 | return self._in_experiment
52 |
53 | @property
54 | def salt_sep(self):
55 | return self._env.salt_sep
56 |
57 | def set_env(self, new_env):
58 | """Replace the current environment with a dictionary"""
59 | self._env = deepcopy(new_env)
60 | # note that overrides are inhereted from new_env
61 | return self
62 |
63 | def has(self, name):
64 | """Check if a variable exists in the PlanOut environment"""
65 | return name in self._env
66 |
67 | def get(self, name, default=None):
68 | """Get a variable from the PlanOut environment"""
69 | return self._env.get(name, self._inputs.get(name, default))
70 |
71 | def set(self, name, value):
72 | """Set a variable in the PlanOut environment"""
73 | self._env[name] = value
74 | return self
75 |
76 | def set_overrides(self, overrides):
77 | """
78 | Sets variables to maintain a frozen state during the interpreter's
79 | execution. This is useful for debugging PlanOut scripts.
80 | """
81 | self._env.set_overrides(overrides)
82 | return self
83 |
84 | def get_overrides(self):
85 | """Get a dictionary of all overrided values"""
86 | return self._env.get_overrides()
87 |
88 | def has_override(self, name):
89 | """Check to see if a variable has an override."""
90 | return name in self.get_overrides()
91 |
92 | def evaluate(self, planout_code):
93 | """Recursively evaluate PlanOut interpreter code"""
94 | # if the object is a PlanOut operator, execute it it.
95 | if type(planout_code) is dict and 'op' in planout_code:
96 | return Operators.operatorInstance(planout_code).execute(self)
97 | # if the object is a list, iterate over the list and evaluate each
98 | # element
99 | elif type(planout_code) is list:
100 | return [self.evaluate(i) for i in planout_code]
101 | else:
102 | return planout_code # data is a literal
103 |
--------------------------------------------------------------------------------
/planout-editor/js/components/PlanOutScriptPanel.react.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014 Facebook, Inc.
3 | *
4 | * @providesModule PlanOutScriptPanel.react
5 | * @jsx React.DOM
6 | */
7 |
8 | var React = require('react');
9 |
10 | var PlanOutExperimentActions = require('../actions/PlanOutExperimentActions');
11 | var PlanOutExperimentStore = require('../stores/PlanOutExperimentStore');
12 |
13 |
14 | function getStateFromStores() {
15 | return {
16 | compilerMessage: PlanOutExperimentStore.getCompilerMessage(),
17 | doesCompile: PlanOutExperimentStore.doesCompile(),
18 | inputVariables: PlanOutExperimentStore.getInputVariables(),
19 | json: PlanOutExperimentStore.getJSON(),
20 | params: PlanOutExperimentStore.getParams(),
21 | script: PlanOutExperimentStore.getScript()
22 | };
23 | }
24 |
25 | var PlanOutScriptPanel = React.createClass({
26 |
27 | getInitialState: function() /*object*/ {
28 | var state_data = getStateFromStores();
29 | state_data.showCompiledBlock = false;
30 | return state_data;
31 | },
32 |
33 | componentDidMount: function() {
34 | PlanOutExperimentStore.addChangeListener(this._onChange);
35 | },
36 |
37 | componentWillUnmount: function() {
38 | PlanOutExperimentStore.removeChangeListener(this._onChange);
39 | },
40 |
41 | _onChange: function() {
42 | this.setState(getStateFromStores());
43 | },
44 |
45 |
46 | render: function() {
47 | return (
48 |
117 | );
118 | },
119 |
120 | /**
121 | * Toggles whether compiled JSON code gets shown.
122 | */
123 | _toggleShowCompiled: function(event) {
124 | this.setState({showCompiledBlock: !this.state.showCompiledBlock});
125 | return false;
126 | },
127 |
128 | _onCodeChange: function() {
129 | var script = this.refs.qe_planout_source.getDOMNode().value;
130 | PlanOutExperimentActions.compile(script);
131 | }
132 | });
133 |
134 | module.exports = PlanOutScriptPanel;
135 |
--------------------------------------------------------------------------------
/planout-editor/js/utils/PlanOutAsyncRequests.js:
--------------------------------------------------------------------------------
1 | /** Copyright (c) 2014, Facebook, Inc.
2 | * All rights reserved.
3 | *
4 | * This source code is licensed under the BSD-style license found in the
5 | * LICENSE file in the root directory of this source tree. An additional grant
6 | * of patent rights can be found in the PATENTS file in the same directory.
7 | */
8 |
9 | var _ = require('underscore');
10 |
11 | var PlanOutCompiler = require('./planout_compiler');
12 | var FileSaver = require('./FileSaver');
13 |
14 | var PlanOutTesterActions = require('../actions/PlanOutTesterActions');
15 | var PlanOutExperimentActions = require('../actions/PlanOutExperimentActions');
16 |
17 | var PlanOutEditorDispatcher = require('../dispatcher/PlanOutEditorDispatcher');
18 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants');
19 | var TesterBoxTypes = PlanOutEditorConstants.TesterBoxTypes;
20 |
21 | var ASYNC_DELAY = 250;
22 |
23 |
24 | // NOTE: need to update python endpoint to return test id
25 | function _runTest(/*string*/ id, /*object*/ updateBlob) /*bool*/ {
26 | var stringBlob = {};
27 | for (var key in updateBlob) {
28 | stringBlob[key] = JSON.stringify(updateBlob[key]);
29 | }
30 | $.ajax({
31 | url: 'run_test',
32 | dataType: 'json',
33 | data: stringBlob,
34 | success: function(data) {
35 | PlanOutTesterActions.updateTesterOutput(
36 | id,
37 | data.errors,
38 | data.results
39 | );
40 | }.bind(this),
41 | error: function(xhr, status, err) {
42 | console.error(this.props.url, status, err.toString());
43 | return false;
44 | }.bind(this)
45 | });
46 | return true;
47 | }
48 |
49 | module.exports = {
50 |
51 | getDemoTests: function() /*array*/ {
52 | // this will eventually come from the server
53 | var defaultTests = [
54 | {
55 | "id": "playground",
56 | "inputs": {"userid": 42},
57 | "type": TesterBoxTypes.PLAYGROUND
58 | },
59 | {
60 | "id": "test3",
61 | "inputs":{"userid":5243},
62 | "overrides": {},
63 | "assertions": {"ratings_goal":640},
64 | "type": TesterBoxTypes.TEST
65 | },
66 | {
67 | "id": "test2",
68 | "inputs":{"userid":52433},
69 | "overrides": {"group_size":10},
70 | "assertions": {"ratings_goal": 200},
71 | "type": TesterBoxTypes.TEST
72 | },
73 | ];
74 | return defaultTests;
75 | //at some point we should call something to update all tester output
76 | },
77 |
78 | getDemoScript: function() /*string*/ {
79 | return [
80 | "group_size = uniformChoice(choices=[1, 10], unit=userid);",
81 | "specific_goal = bernoulliTrial(p=0.8, unit=userid);",
82 | "if (specific_goal) {",
83 | " ratings_per_user_goal = uniformChoice(",
84 | " choices=[8, 16, 32, 64], unit=userid);",
85 | " ratings_goal = group_size * ratings_per_user_goal;",
86 | "}"
87 | ].join('\n');
88 | },
89 |
90 | saveState: function(/*object*/ data, /*string*/ filename) {
91 | var blob = new Blob(
92 | [JSON.stringify(data, false, " ")],
93 | {type: "text/plain;charset=utf-8"}
94 | );
95 | FileSaver(blob, filename);
96 | },
97 |
98 | genRunner: function(/*string*/ id) /*function*/ {
99 | // generates a throttled function for each test id
100 | return _.throttle(
101 | function(updateBlob) {
102 | _runTest(id, updateBlob);
103 | },
104 | ASYNC_DELAY
105 | );
106 | },
107 |
108 | compilerCallback: function(/*string*/ script) {
109 | try {
110 | // this can be subbed out for a callback if using server-side compilation
111 | var json = PlanOutCompiler.parse(script);
112 | PlanOutExperimentActions.updateCompiledCode(script, "success", json);
113 | } catch (err) {
114 | PlanOutExperimentActions.updateCompiledCode(script, err.message, {});
115 | }
116 | },
117 |
118 | compileScript: _.throttle(function(/*string*/ script) {
119 | setTimeout((function() {
120 | this.compilerCallback(script);
121 | }).bind(this), 1);
122 | }, ASYNC_DELAY)
123 | };
124 |
--------------------------------------------------------------------------------
/NEWS:
--------------------------------------------------------------------------------
1 | * Noteworthy changes in release 0.6.0
2 | - Python 3 compatibility
3 | - Language-level control of logging: The return value of assign now determines
4 | whether or not an exposure event will be automatically logged. If either a
5 | truth-y value or nothing is returned from assign, then an exposure will be
6 | logged. If a false-y value (excluding None) is returned then exposure will not
7 | be logged.
8 | - SimpleInterpretedExperiment has been updated to take utilize the return op
9 | - Ability to register custom operators with the PlanOut interpreter. This
10 | allows developers to more easily integrate PlanOut with other aspects of their
11 | production stack, including gating / eligibility checks.
12 | - Fast sampling: There is now an alternative Sample Op called FastSample that is
13 | more efficient than the existing Sample Op especially when sampling k elements
14 | from a population of n when k << n. If you want to enable this op to speed up
15 | namespace segment allocation then when instantiating your Namespace you can
16 | use the optional keyword argument, use_fast_sample=True in the Namespace
17 | constructor. Thanks to Guy Aridor for this excellent speed up!
18 | - ProductionExperiment class which enforces runtime checks on what PlanOut
19 | variables are available. If undefined variables are requested, exposure log
20 | events do not occur.
21 |
22 | * Noteworthy changes in release 0.5 (2014-11-23)
23 | - A SimpleInterpretedExperiment to make it easier to get up and running with
24 | the PlanOut language and interpreter.
25 |
26 | - PlanOut language:
27 | - Standardized handling of undefined keys: indexing into a list or dictionary
28 | for an invalid index or key always yields null; nulls may be coalesced via
29 | the null coalescing operator, coalesce()
30 | - A return() operator for determining whether the input units are
31 | "in the experiment" (and therefore logged). Calling "return;" or
32 | "return " sets in_experiment to True.
33 | Returning a value with negative truthiness sets in_experiment to False.
34 |
35 | - PlanOut interpreter:
36 | - Convenience methods for extracting and checking typed operator arguments,
37 | e.g., self.getArgInt('x') will retreive an argument named 'x'. If 'x' is
38 | not given, or 'x' is not an integer, an error will be thrown. This improves
39 | ease of debugging, and also provides a clear path for those who are
40 | implementing the interpreter in strongly typed language.
41 | - Improved argument checking for built-in operators.
42 |
43 | - PlanOut editor: the PlanOut editor is a reactive development environment and
44 | testing system for PlanOut-language experiments.
45 |
46 | - Breaking changes:
47 | - Namespaces: when a unit is assigned to an experiment and in_experiment is
48 | set to False, the default experiment is executed.
49 | - PlanOut interpreter:
50 | - The length() operator's argument is now 'value', as to allow for unnamed
51 | arguments to be passed in.
52 | - Min and max are now required arguments for randomInteger and randomFloat
53 | - Deprecated validate() and options() methods
54 | - Deprecated self.parameters for operators: self.args refers to arguments
55 | passed into an operator. If an object is a subclass of SimpleOp, then
56 | the arguments are pre-evaluated (and act just like self.parameters).
57 |
58 |
59 | * Noteworthy changes in release 0.4 (2014-10-29)
60 | - Full support for parameter overrides, which allow for testing of PlanOut
61 | experiments
62 | - Alpha version of PlanOut editor
63 | - Bugfixes to how the PlanOut compiler handles indexing into arrays multiple
64 | times (e.g., a['b'][1][0])
65 |
66 |
67 | * Noteworthy changes in release 0.3 (2014-06-18) [alpha]
68 | - IMPORTANT UPDATE: The hash function used in prior versions did not match the expected behavior in the documentation (caught by @akalicki). We have updated PlanOut's hashing function, which will likely cause units to be randomly assigned to different parameters. If you are already using PlanOut in a production environment, please upgrade with care. If you are not currently using PlanOut in a production environment, please upgrade immediately:
69 |
70 | pip install -U planout
71 |
72 | - Other updates:
73 | -The assign() method is now only called once. Thanks to @akalicki for catching this.
74 |
--------------------------------------------------------------------------------
/planout-editor/static/js/mode/planout/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | CodeMirror: JavaScript mode
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
109 |
110 |
--------------------------------------------------------------------------------
/python/docs/06-best-practices.md:
--------------------------------------------------------------------------------
1 | # Best practices
2 |
3 | PlanOut makes it easy to implement bug-free code that randomly assigns users (or other units) to parameters. The Experiment and Namespace classes are designed to reduce common errors in deploying and logging experiments. Here are a few tips for running experiments:
4 |
5 | * Use auto-exposure logging (link), which is enabled by default. Auto-exposure logging makes it easier to check that your assignment procedure is working correctly, increases the precision of your experiment, and reduces errors in downstream analysis.
6 | * Avoid changing an experiment while it is running. Instead, either run a follow-up experiment using a namespace (link), or create a new experiment with a different salt to re-randomize the units. These experiments should be analyzed separately from the original experiment.
7 | * Automate the analysis of your experiment. If you are running multiple related experiments, create a pipeline to automatically do the analysis.
8 |
9 |
10 | There are no hard and fast rules for what kinds of changes are actually a problem, but if you follow the best practices above, you should be in reasonable shape.
11 |
12 |
13 | ## Randomization failures
14 | Experiments are used to test the change of one or more parameters on some average outcome (e.g., messages sent, or clicks on a button). Differences can be safely attributed to a change in parameters if treatments are assigned to users completely at random.
15 |
16 | In practice, there are a number of common ways for two groups to not be equivalent (beyond random imbalance):
17 | - Some units from one group were previously in a different group, while users from the other group were not.
18 | - Some units in one group were recently added to the experiment.
19 | - There was a bug in the code for one group but not the other, and that bug recently got fixed.
20 |
21 | In these cases, we suggest that you launch a new experiment, either through the use of namespaces (links), or by re-assigning all of the units in your experiment. This can be done by simply changing the salt of your experiment:
22 |
23 | ```python
24 | class MyNewExperiment(MyOldExperiment):
25 | def set_experiment_properties(self):
26 | self.name = 'new_experiment_name'
27 | self.salt = 'new_experiment_salt'
28 | ```
29 |
30 |
31 | ### Unanticipated consequences from changing experiments
32 | Changes to experiment definitions will generally alter which parameters users are assigned to. For example, consider an experiment that manipulates the label of a button for sharing a link. The main outcome of interest is the effect of this text on how many links users share per day.
33 |
34 |
35 | ```python
36 | class SharingExperiment(SimpleExperiment):
37 | def assign(self, params, userid):
38 | params.button_text = UniformChoice(
39 | choices=['OK', 'Share', 'Share with friends'],
40 | unit=userid
41 | )
42 | ```
43 | Changing the variable name `button_text` changes the assignment, since it is used to salt (link) to assignment procedure (see `Experiment` intro document).
44 |
45 | Changing the number of choices for the `button_text` also affects users previously randomized into other conditions. For example, removing the 'Share' item from the `choices` list, will allocate some users who were previosuly in the 'Share' condition to the 'OK' and 'Share with friends group'. Their outcomes will now be a weighted average of the two, which may decrease the observed difference between 'OK' and 'Share with friends'.
46 |
47 | If an additional choice were added to `choices`, some percentage of each prior choice would be allocated to the new choice, whose outcome represents an average of all groups. Comparisons between users still in the old groups (the newly added parameters may be subject to greater novelty effects.
48 |
49 | ## Detecting problems
50 | If you suspect your experiment might have changed, check the `salt` and `checksum` fields of your log. If either of these items change, it is likely that your assignments have also changed mid-way through the experiment.
51 |
52 | ## Learn more
53 | [Designing and Deploying Online Field Experiments](http://www-personal.umich.edu/~ebakshy/planout.pdf). WWW 2014. Eytan Bakshy, Dean Eckles, Michael S. Bernstein.
54 | [Seven Pitfalls to Avoid when Running Controlled Experiments on the Web.](http://www.exp-platform.com/Documents/2009-ExPpitfalls.pdf) KDD 2009. Thomas Crook, Brian Frasca, Ron Kohavi, and Roger Longbotham.
55 |
--------------------------------------------------------------------------------
/python/planout/ops/random.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import six
3 | from .base import PlanOutOpSimple
4 |
5 |
6 | class PlanOutOpRandom(PlanOutOpSimple):
7 | LONG_SCALE = float(0xFFFFFFFFFFFFFFF)
8 |
9 | def getUnit(self, appended_unit=None):
10 | unit = self.getArgMixed('unit')
11 | if type(unit) is not list:
12 | unit = [unit]
13 | if appended_unit is not None:
14 | unit += [appended_unit]
15 | return unit
16 |
17 | def getHash(self, appended_unit=None):
18 | if 'full_salt' in self.args:
19 | full_salt = self.getArgString('full_salt') + '.' # do typechecking
20 | else:
21 | full_salt = '%s.%s%s' % (
22 | self.mapper.experiment_salt,
23 | self.getArgString('salt'),
24 | self.mapper.salt_sep)
25 |
26 | unit_str = '.'.join(map(str, self.getUnit(appended_unit)))
27 | hash_str = '%s%s' % (full_salt, unit_str)
28 | if not isinstance(hash_str, six.binary_type):
29 | hash_str = hash_str.encode("ascii")
30 | return int(hashlib.sha1(hash_str).hexdigest()[:15], 16)
31 |
32 | def getUniform(self, min_val=0.0, max_val=1.0, appended_unit=None):
33 | zero_to_one = self.getHash(appended_unit) / PlanOutOpRandom.LONG_SCALE
34 | return min_val + (max_val - min_val) * zero_to_one
35 |
36 |
37 | class RandomFloat(PlanOutOpRandom):
38 |
39 | def simpleExecute(self):
40 | min_val = self.getArgFloat('min')
41 | max_val = self.getArgFloat('max')
42 |
43 | return self.getUniform(min_val, max_val)
44 |
45 |
46 | class RandomInteger(PlanOutOpRandom):
47 |
48 | def simpleExecute(self):
49 | min_val = self.getArgInt('min')
50 | max_val = self.getArgInt('max')
51 |
52 | return min_val + self.getHash() % (max_val - min_val + 1)
53 |
54 |
55 | class BernoulliTrial(PlanOutOpRandom):
56 |
57 | def simpleExecute(self):
58 | p = self.getArgNumeric('p')
59 | assert p >= 0 and p <= 1.0, \
60 | '%s: p must be a number between 0.0 and 1.0, not %s!' \
61 | % (self.__class__, p)
62 |
63 | rand_val = self.getUniform(0.0, 1.0)
64 | return 1 if rand_val <= p else 0
65 |
66 |
67 | class BernoulliFilter(PlanOutOpRandom):
68 |
69 | def simpleExecute(self):
70 | p = self.getArgNumeric('p')
71 | values = self.getArgList('choices')
72 | assert p >= 0 and p <= 1.0, \
73 | '%s: p must be a number between 0.0 and 1.0, not %s!' \
74 | % (self.__class__, p)
75 |
76 | if len(values) == 0:
77 | return []
78 | return [i for i in values if self.getUniform(0.0, 1.0, i) <= p]
79 |
80 |
81 | class UniformChoice(PlanOutOpRandom):
82 |
83 | def simpleExecute(self):
84 | choices = self.getArgList('choices')
85 |
86 | if len(choices) == 0:
87 | return []
88 | rand_index = self.getHash() % len(choices)
89 | return choices[rand_index]
90 |
91 |
92 | class WeightedChoice(PlanOutOpRandom):
93 |
94 | def simpleExecute(self):
95 | choices = self.getArgList('choices')
96 | weights = self.getArgList('weights')
97 |
98 | if len(choices) == 0:
99 | return []
100 | cum_weights = dict(enumerate(weights))
101 | cum_sum = 0.0
102 | for index in cum_weights:
103 | cum_sum += cum_weights[index]
104 | cum_weights[index] = cum_sum
105 | stop_value = self.getUniform(0.0, cum_sum)
106 | for index in cum_weights:
107 | if stop_value <= cum_weights[index]:
108 | return choices[index]
109 |
110 |
111 | class BaseSample(PlanOutOpRandom):
112 |
113 | def copyChoices(self):
114 | return [x for x in self.getArgList('choices')]
115 |
116 | def getNumDraws(self, choices):
117 | if 'draws' in self.args:
118 | num_draws = self.getArgInt('draws')
119 | assert num_draws <= len(choices), \
120 | "%s: cannot make %s draws when only %s choices are available" \
121 | % (self.__class__, num_draws, len(choices))
122 | return num_draws
123 | else:
124 | return len(choices)
125 |
126 | class FastSample(BaseSample):
127 |
128 | def simpleExecute(self):
129 | choices = self.copyChoices()
130 | num_draws = self.getNumDraws(choices)
131 | stopping_point = len(choices) - num_draws
132 |
133 | for i in six.moves.range(len(choices) - 1, 0, -1):
134 | j = self.getHash(i) % (i + 1)
135 | choices[i], choices[j] = choices[j], choices[i]
136 | if stopping_point == i:
137 | return choices[i:]
138 | return choices[:num_draws]
139 |
140 | class Sample(BaseSample):
141 |
142 | def simpleExecute(self):
143 | choices = self.copyChoices()
144 | num_draws = self.getNumDraws(choices)
145 |
146 | for i in six.moves.range(len(choices) - 1, 0, -1):
147 | j = self.getHash(i) % (i + 1)
148 | choices[i], choices[j] = choices[j], choices[i]
149 | return choices[:num_draws]
150 |
--------------------------------------------------------------------------------
/planout-editor/js/components/PlanOutTesterBox.react.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014 Facebook, Inc.
3 | *
4 | * @providesModule PlanOutTesterBox.react
5 | * @jsx React.DOM
6 | */
7 |
8 | var React = require('react');
9 | var ReactPropTypes = React.PropTypes;
10 |
11 | var PlanOutTesterActions = require('../actions/PlanOutTesterActions');
12 | var PlanOutTesterBoxForm = require('./PlanOutTesterBoxForm.react');
13 | var PlanOutTesterBoxOutput = require('./PlanOutTesterBoxOutput.react');
14 |
15 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants');
16 | var TesterBoxTypes = PlanOutEditorConstants.TesterBoxTypes;
17 | var TesterStatusCodes = PlanOutEditorConstants.TesterStatusCodes;
18 |
19 | //var Bootstrap = require('react-bootstrap');
20 | //var Panel = Bootstrap.Panel;
21 |
22 |
23 | var PlanOutTesterBox = React.createClass({
24 | propTypes: {
25 | id: ReactPropTypes.string.isRequired,
26 | assertions: ReactPropTypes.object,
27 | errors: ReactPropTypes.array,
28 | inputs: ReactPropTypes.object,
29 | overrides: ReactPropTypes.object,
30 | results: ReactPropTypes.object,
31 | status: ReactPropTypes.string.isRequired,
32 | type: ReactPropTypes.string.isRequired
33 | },
34 |
35 | getInitialState: function() {
36 | return {expanded: this.props.type === TesterBoxTypes.PLAYGROUND};
37 | },
38 |
39 | _toggleExpand: function() {
40 | this.setState({expanded: !this.state.expanded});
41 | },
42 |
43 | _destroy: function() {
44 | PlanOutTesterActions.destroy(this.props.id);
45 | },
46 |
47 | render: function() {
48 | var titleBarSettings = this.getTitleBarStrings();
49 |
50 | var collapse_class = "panel-collapse collapse";
51 | collapse_class += this.state.expanded ? " in" : "";
52 |
53 | return (
54 |