The PlanOut interpreter

PlanOut experiments can be implemented in pure Python, but they may also be specified via a platform-independent serialization, which can be generated via the PlanOut compiler or through some user interface. Since interpreters can be written in any language, it is possible for serialized PlanOut code to executed on multiple kinds of devices and server platforms.

The PlanOut language

The PlanOut language provides a few simple constructs for defining experiments. It looks a lot like JavaScript, e.g.,

x = uniformChoice(choices=['a','b'], unit=userid);
y = bernoulliTrial(p=0.2, unit=[userid, albumid]);

More documentation about the PlanOut interpreter and language can be found on the PlanOut homepage. One thing to note is that operators in the PlanOut language are lowercase, whereas pure Python operators are objects and start with upper case.

To compile this code, you might write it to a file (say, myfile.planout), and type something like:

node planout.js myfile.planout

planout.js can be found in the compiler/ directory of the PlanOut Github repository.

Calling the interpreter from Python

We use this handy-dandy function to compile PlanOut scripts directly from IPython notebooks.


In [1]:
from os import popen, system
import json

def compile(s):
    "compile planout code using planout.js compiler"
    with open('tmp.planout','w') as f:
        f.write(s)
    # the error handling here could be better...
    d = json.loads(popen('node ../../compiler/planout.js tmp.planout').read())
    system('rm tmp.planout')
    return d

Serialized PlanOut code

The PlanOut language gets compiled into JSON, which can be loaded into Python as a dictionary and run by the interpreter.

Here is what PlanOut code looks like when it gets compiled into the serialization.


In [2]:
serial = compile("""
  button_text = uniformChoice(choices=['Purchase','Buy'], unit=userid);
  has_discount = bernoulliTrial(p=0.3, unit=userid);
""")
serial


Out[2]:
{u'op': u'seq',
 u'seq': [{u'op': u'set',
   u'value': {u'choices': {u'op': u'array', u'values': [u'Purchase', u'Buy']},
    u'op': u'uniformChoice',
    u'unit': {u'op': u'get', u'var': u'userid'}},
   u'var': u'button_text'},
  {u'op': u'set',
   u'value': {u'op': u'bernoulliTrial',
    u'p': 0.3,
    u'unit': {u'op': u'get', u'var': u'userid'}},
   u'var': u'has_discount'}]}

Running serialized PlanOut code

The Interpreter object executes a dictionary containing serialized PlanOut code. Interpreter has three required arguments:

  • A dictionary containing serialized PlanOut code
  • An experiment-level salt
  • Input data

Let's run the serialized code above with userid 4.


In [3]:
from planout.interpreter import *

i = Interpreter(serial, 'my_salt', {'userid':4})
i.get_params()


Out[3]:
{u'button_text': u'Purchase', u'has_discount': 0}

And with a few more users...


In [4]:
for i in xrange(5):
    print i, Interpreter(serial, 'my_salt', {'userid':i}).get_params()


0 {u'button_text': u'Buy', u'has_discount': 0}
1 {u'button_text': u'Buy', u'has_discount': 0}
2 {u'button_text': u'Purchase', u'has_discount': 0}
3 {u'button_text': u'Purchase', u'has_discount': 0}
4 {u'button_text': u'Purchase', u'has_discount': 0}

Serialized PlanOut code acts just like pure Python experiments

Given that you use the same experiment-level and parameter-level salts, random assignment is completely deterministic. We can replicate the same assignments using the SimpleExperiment class.


In [ ]:
from planout.experiment import SimpleExperiment
from planout.ops.random import *

class Doppelganger(SimpleExperiment):
    def setup(self):
        self.salt = 'my_salt'  # same salt as above
    
    def assign(self, params, userid):
        params.button_text = UniformChoice(choices=['Purchase','Buy'], unit=userid);
        params.has_discount = BernoulliTrial(p=0.3, unit=userid);

for i in xrange(5):
    print i, Doppelganger(userid=i).get_params()

Running SimpleExperiments with serialized PlanOut code

We define an abstract class SimpleDictExperiment, which inherets from SimpleExperiment but replaces the work done by assign() with serialized PlanOut code, which gets stored in class variable script.


In [ ]:
import hashlib
from abc import ABCMeta

class SimpleDictExperiment(SimpleExperiment):
  """Simple class for loading a dictionary-based PlanOut interpreter experiment"""
  __metaclass__ = ABCMeta
  script = None

  def assign(self, params, **kwargs):
    i = Interpreter(
      self.script,
      self.salt,
      kwargs
      )
    # this sets all of params' attributes to the key-value pairs in i
    params.update(i.get_params())

  def checksum(self):
    # we log a checksum of the script so that analysts know if the experiment definition
    # changed in some way.
    src = json.dumps(self.script)
    return hashlib.sha1(src).hexdigest()[:8]

We can then subclass SimpleDictExperiment.


In [ ]:
class IExp1(SimpleDictExperiment):
    def setup(self):
        self.salt = 'my_salt'

    script = compile("""
      button_text = uniformChoice(choices=['Purchase','Buy'], unit=userid);
      has_discount = bernoulliTrial(p=0.3, unit=userid);
    """)

for i in xrange(5):
    e = IExp1(userid=i)
    print i, e.get_params()

This is probably pretty close to something you'd like to use in a production setting.

Other ways of generating serialized experiments

Serialized PlanOut code can also be generated automatically, including via user interfaces.

Let's consider a hypothetical graphical user interface for constructing full factorial experiments. We assume that the users' Web front end makes an AJAX request to the server, and sends a dictionary whose keys are factors (parameters) and values are lists of possible values.

We would then use a function like this to generate PlanOut code for the given input data


In [ ]:
def serialize_full_factorial(x,uid='userid'):
    items = []
    for k,v in x.iteritems():
        items.append({"op":"set","var":k, "value":
          {"op":"uniformChoice", "choices":v, "unit":
           {"op": "get", "var":uid}}})
    return {"op":"seq", "seq": items}

Here is an example configuration file that might be generated by the GUI, and how it gets transformed into PlanOut code.


In [ ]:
my_config = {'button_text':['Post', 'Share', 'X'], 'button_color':['#00ff00', '#aaaaaa']}
serialize_full_factorial(my_config)

We can generate experiments on the fly from the configuration file:


In [ ]:
class ExperimentStub(SimpleDictExperiment):
    script = None
    
def gen_exp(experiment_name, config, **kwargs):
    e = ExperimentStub(**kwargs)
    e.name = experiment_name
    e.salt = experiment_name
    e.script = serialize_full_factorial(config)
    return e

Now let's perform some random assignments. You can also see the log file, my-generated-experiment.log.


In [ ]:
for i in xrange(5):
    print i, gen_exp('my generated experiment', my_config, userid=i).get_params()