experimentator: Python experiment builder¶
Do you write code to run experiments? If so, you’ve probably had the experience of sitting down to code an experiment but getting side-tracked by all the logistics: crossing your independent variables to form conditions, repeating your conditions, randomization, storing intermediate data, etc. It’s frustrating to put all that effort in before even getting to what’s really unique about your experiment. Worse, it encourages bad coding practices like copy-pasting boilerplate from someone else’s experiment code without understanding it.
The purpose of experimentator is to handle all the boring logistics of running experiments and allow you to get straight to what really interests you, whatever that may be. This package was originally intended for behavioral experiments in which human participants are interacting with a graphical interface, but there is nothing domain-specific about it–it should be useful for anyone running experiments with a computer. You might say that experimentator is a library for ‘repeatedly calling a function while systematically varying its inputs and saving the data’ (although that doesn’t do it full justice).
Not handled here¶
- graphics
- timing
- hardware interfacing
- statistics
- data processing
The philosophy of experimentator is to do one thing and do it well. It is meant to be used with other libraries that handle the above functionality, and gives you the freedom to choose which you prefer. It is best suited for someone with programming experience and some knowledge of the Python ecosystem, who would rather choose the best tool for each aspect of a project than use an all-in-one package.
Of course, there are alternatives that offer experimental design features along with other capabilities. A selection, as well as recommended complimentary packages, are listed later in the documentation.
An example¶
To demonstrate, let’s create a simple perceptual experiment. For the sake of example, imagine we will present some stimulus to either the left or right side of the screen for a specified amount of time, and ask the participant to identify it. We’ll use a factorial 2 (side) x 3 (display time) design, and have a total of 60 trials per participant (10 per condition). Here’s how it might look in experimentator:
import random
from time import time
from experimentator import Experiment, order
def present_stimulus_and_get_response(stimulus, side, duration):
# Use your imagination...
return random.choice(['yes', 'no'])
def run_trial(experiment, trial):
stimulus, answer = random.choice(
list(experiment.experiment_data['stimuli'].items()))
start_time = time()
response = present_stimulus_and_get_response(trial.data['side'], trial.data['display_time'])
result = {
'reaction_time': time() - start_time,
'correct': response == answer
}
return result
if __name__ == '__main__':
independent_variables = {
'side': ['left', 'right'],
'display_time': [0.1, 0.55, 1],
}
stimuli_and_answers = {
'cat.jpg': 'yes',
'dog.jpg': 'no',
}
experiment = Experiment.within_subjects(independent_variables,
n_participants=20,
ordering=order.Shuffle(10),
filename='exp_1.exp')
experiment.experiment_data['stimuli'] = stimuli_and_answers
experiment.add_callback('trial', run_trial)
experiment.save()
Running this script will create the experiment in the file exp_1.exp
.
We can now run sessions from the command line:
exp run exp_1.exp participant 1
# or
exp run exp_1.exp --next participant
Eventually, we can export the data to a text file:
exp export exp_1.exp exp_1_data.csv
Or, access the data in a Python session:
from experimentator import Experiment
data = Experiment.load('exp_1.exp').dataframe
In this example the data will be a pandas DataFrame
with six columns:
two index columns with labels 'participant'
and 'trial'
,
two columns from the IVs, with labels 'side'
and 'display_time'
,
and two data columns with labels 'reaction_time'
and 'correct'
(the keys in the dictionary returned by run_Trial
).
Installation¶
Note
If you use experimentator in your work, published or not, please let me know. I’m curious to know what you use it for! If you do publish, citation information can be found here.
Dependencies¶
Experimentator requires Python 3.3 or later. It also depends on the following Python libraries:
Required for tests:
Required for generating docs:
The easiest way to install these libraries, especially on Windows,
is with Continuum’s free Python distribution Anaconda.
For experimentator, Anaconda3 or the lightweight Miniconda3 is recommended,
although you can create a Python3 conda
environment regardless of which
version you initially download.
For example, to install dependencies to a clean environment (with name experiment
):
conda update conda
conda create -n experiment python=3 pip
source activate experiment
conda install numpy pandas pyyaml networkx
pip install docopt schema
From PyPi¶
To install (and upgrade) experimentator:
pip install --upgrade experimentator
Be sure to run pip
from a Python 3 environment.
Other libraries¶
Please, feel free to submit a pull request to add your software to one of these lists.
Alternatives¶
The Python ecosystem offers some wonderful alternatives that provide experiment logistics in addition to other functionality like graphics and input/output:
- expyriment: Graphics, input/output, hardware interfacing, data preprocessing, experimental design. If you are coming from the Matlab world, this is the closest thing to Psychtoolbox.
- OpenSesame: An all-in-one package with a graphical interface to boot. An impressive piece of software.
Complimentary libraries¶
What about all those important things that experimentator doesn’t do? Here’s a short selection. If you’re already using Python some of these will go without saying, but they’re included here for completeness:
- experimental design
- pyDOE: Construct design matrices in a format that experimentator can use to build your experiment.
- graphics
- PsychoPy: A stimulus-presentation library with an emphasis on calibration and temporal precision. Unfortunately, at the time of this writing it is not yet Python3-compatible, and so cannot be easily combined with experimentator.
- Pygame: Very popular.
- Pyglet: A smaller community than Pygame, but has several advantages, including cross-compatibility and a more pythonic API. Includes OpenGL bindings.
- PyOpenGL: If all you need is to make OpenGL calls.
- statistics and data processing
- pandas:
Convenient data structures. Experimental data in experimentator is stored in a pandas
DataFrame
. - numpy: Matrix operations. The core of the Python scientific computing stack.
- SciPy: A comprehensive scientific computing library spanning many domains.
- Statsmodels: Statistical modeling and hypothesis testing.
- scikit-learn: Machine learning.
- rpy2:
Call
R
from Python. Because sometimes the model or test you need isn’t in statsmodels or scikit-learn.
- pandas:
Convenient data structures. Experimental data in experimentator is stored in a pandas
License¶
Licensed under the MIT license.
Contents¶
Concepts¶
About this documentation
This documentation aims to be comprehensive, but be aware that there is also rich information available in docstrings.
These can be accessed at the interactive prompt with the help
function;
they are also reproduced in API reference.
Experiment structure¶
In experimentator, experiments (represented by an Experiment
instance) are organized as hierarchical tree structures.
Each section of the experiment (represented by an ExperimentSection
instance)
is a node in this tree, and its children are the sections it contains.
Levels on the tree are named;
common level names in behavioral research are 'participant'
, 'session'
, 'block'
, and 'trial'
.
For example, an experiment with two participants with two blocks of three trials each would have a tree that looks like this:
'_base' ______________1______________
/ \
'participant' ______1______ ______2______
/ \ / \
'block' ___1___ ___2___ ___1___ ___2___
/ | \ / | \ / | \ / | \
'trial' 1 2 3 1 2 3 1 2 3 1 2 3
The top level is always called '_base'
;
the leading underscore indicates that you should not have to refer to this level directly.
All other level names are arbitrary and are specified when the experiment is created.
An important principle of experimentator is that each section only handles its children,
the sections immediately below it.
In a structure with levels 'participant'
, 'block'
, and 'trial'
,
every block section knows how to create and order trials (e.g., by crossing independent variables),
but knows nothing of participants.
Likewise, every participant section organizes the blocks under it,
but lets each block figure out its constitutent trials.
The only exception to this rule is in the case of non-atomic orderings.
Note
For simplicity, this documentation uses the term trial to mean the lowest level of an experiment, even though experimentator will let you use whatever string you want to name this level.
Design¶
In experimentator, every section has a design, represented by a Design
object
(usually, these will be created for you).
Most of the time, all sections at the same level have the same design
(but see Heterogeneous experiment structures).
The design is a high-level description of one level of an experiment.
It includes everything experimentator needs to know to create the children of a section.
This consists of two things:
independent variables and an ordering method.
An experiment requires multiple Design
instances in a certain relationship to each other.
Such a collection is modeled with DesignTree
objects.
Again, you usually will not manually create these.
Independent variables¶
A central concept in experimentator (and in experimental design more generally)
is that of independent variables, or IVs.
An IV is a variable that you are explicitly varying in order to test its effects.
The easiest way to represent IVs in experimentator is using a dictionary.
Each key is a string, the name of an IV.
Each value is either a list, representing the possible values the IV can take,
or None
if the IV takes continuous values (continuous values are only possible with a design matrix).
For example:
>>> independent_variables = {
... 'congruent': [True, False],
... 'distractor': [None, 'left', 'right'],
... }
Note
In Python, dictionaries have no order.
In most cases, the order of IVs is not important and so representing IVs as dictionaries will work fine.
However, there are times when the order you specify the IVs is important.
This is the case, for example, when using a design matrix, because each column of the design matrix refers to one IV.
You will need to rely on the order of IVs in order to know which column controls which IV.
In these cases you should use one of two alternative ways of representing IVs:
using a collections.OrderedDict
, or a list of 2-tuples.
Here is an example of the latter method (equivalent to the previous example):
>>> independent_variables = [
... ('congruent', [True, False]),
... ('distractor', [None, 'left', 'right']),
... ]
When you specify your IVs, you will specify them separately for every level of the experiment.
That is, every IV is associated with a level of the experimental hierarchy.
This determines how often the IV value changes.
For example, a within-subjects experiment will probably have IVs at the 'trial'
level,
a between-subjects experiment will have IVs at the 'participant'
level,
and a mixed-design experiment will have both.
An IV at the 'participant'
level will always take the same value within each participant.
Similarly, a blocked experiment may have IVs at the 'block'
level;
these IVs will only take on a new value when a new block is reached.
IV values are ultimately passed to your run callback as a condition. A condition is a combination of specific IV values. Although you don’t need to create conditions yourself, you can think of them as dictionaries mapping IV names to values. For example, the six conditions generated by a full factorial cross of the IVs above are:
[{'congruent': True, 'distractor': None},
{'congruent': True, 'distractor': 'left'},
{'congruent': True, 'distractor': 'right'},
{'congruent': False, 'distractor': None},
{'congruent': False, 'distractor': 'left'},
{'congruent': False, 'distractor': 'right'}]
Just like IVs, different conditions apply at different levels of the experimental hierarchy.
These conditions propagate down the tree.
For example, imagine a trial has one of the conditions in the list above,
{'congruent': True, 'distractor': None}
.
The block that the trial is part of may have an additional condition, like {'practice': False}
.
When the trial is run, these conditions are effectively merged.
Note
This merging is implemented with the standard-library object collections.ChainMap
.
A ChainMap
can be accessed just like a dictionary;
this is the sense in which it is correct to say that the conditions are merged.
To continue the example, one can access the IV values without worrying about what level each IV came from:
>>> condition['congruent']
True
>>> condition['practice']
False
However, it is possible to differentiate the conditions if needed,
using the maps
attribute.
See the ChainMap
docs for details.
You might see something like this:
>>> condition.maps[0]
{'trial': 1,
'congruent': True,
'distractor': None}
>>> condition.maps[1]
{'block': 2,
'practice': False}
>>> condition.maps[2]
{'participant': 1}
Orderings¶
The second element of a design is an ordering method. The ordering method determines how children of a section wll be ordered (and possibly repeated). For example, an experiment may shuffle trials within each block, counter-balance blocks within each session, and put all sessions within each participant in the same order.
Each ordering method is a class in the experimentator.order
module.
Currently, experimentator includes
Ordering
(the base class, resulting in a deterministic order),
Shuffle
,
CompleteCounterbalance
,
Sorted
, and
LatinSquare
.
Shuffle
is usually the default, except if you’re using a design matrix,
in which case experimentator assumes you want a deterministic order and makes Ordering
the default.
Each ordering method class has different parameters, so see the specific API reference for details.
Commonly, the first argument is number
, which specifies the number of times each condition will be repeated.
For example, with the ordering method Shuffle(3)
,
each unique condition will be repeated three times, and then the order will be randomized.
The included ordering classes can be divided into two categories: atomic and non-atomic. If every ordering of sections is independent of all other orderings, then the ordering method is atomic. For example, if trials within a block are shuffled, then the ordering of trials within each block will be independent. Each block can shuffle its trials without needing to know the order of trials within the other blocks.
However, this is not the case for non-atomic orderings.
The ordering of sections using non-atomic orderings are dependent on each other.
For example, if blocks within a session are counterbalanced using CompleteCounterbalance
,
then each session cannot, on its own, determine the order of blocks within it.
Non-atomic orderings are implemented by automatically creating a new independent variable.
For example, if the 'block'
level has three conditions (e.g., one IV with three possible values)
and a CompleteCounterbalance
ordering (with number=1
),
then there are six possible orderings of blocks.
A new IV called 'counterbalance_order'
will be automatically created one level up (e.g., at the 'session'
level),
with six possible values (the integers 0-5).
Don’t forget to take this automatically-created IV into account when designing your experiment.
In the above example, if there are no other IVs at the 'session'
level, and number=1
for the 'session'
ordering,
there will still be six sessions per participant due to the six conditions defined by the 'counterbalance_order'
IV.
Only Ordering
and Shuffle
are atomic; the other ordering methods provided in experimentator are non-atomic
(the Sorted
ordering method straddles the line; it may or may not be atomic, depending on the parameter order
.
If order='ascending'
or order='descending'
,
then the ordering method is atomic as it is sorted the same way at every section.
However, if order='both'
, then it is non-atomic and a new IV {'order': ['ascending', 'descending']}
will be created).
Why use levels?¶
You may be wondering how many levels to use, or why to use them at all
(after all, flat is better than nested).
That decision must be made on a case-by-case basis.
For example, imagine your experiment has sessions of 20 trials, divided into two blocks.
As long as the order of conditions within each session is correctly specified
(for example, by using a design matrix),
using an explicit 'block'
level may not be necessary.
Alternatively, you could define a 'block'
level but not a 'trial'
level
and stick a trial loop inside the block.
However, using levels makes it possible to…
associate an IV with a level, facilitating the creation and ordering of conditions.
run code before and/or after every section at a particular level, using section context managers. For example, offer participants a break between blocks.
run experiment sections by level (using the command-line interface). For example, using blocks you could do
exp run my_exp.exp participant 1 block 2
rather than the more awkward
exp run my_exp.exp participant 1 --from 11
index the data by level, after running the experiment, using hierarchical indexing. For example, to get the third trial of the first participant’s second block you could do
experiment.dataframe.loc[(1, 2, 3), :]
or to get the first trial of the second block of every participant,
data.xs((2, 1), level=('block', 'trial'))
Heterogeneous experiment structures¶
A final concept to explain is the difference between homogeneous and heterogeneous experiment structures. In a homogeneous experiment, every section at the same level has the same design. For example, if the first block contains ten trials and the second block contains twenty, the experiment structure is heterogeneous. If the order of blocks within the first session is random but the order of blocks within the second session is counterbalanced, the experiment structure is heterogeneous. Even different possible IV values across sections is enough to break homogeneity.
Heterogeneous experiments are a little trickier to set up, but they are fully supported by experimentator. See Constructing heterogeneous experiments.
Creating an experiment¶
The typical workflow using experimentator is relatively straightforward:
- Create an
Experiment
instance. - Run the experiment using the command-line interface.
- Inspect, analyze or export the resulting data.
Constructor methods¶
The most general way to create an Experiment
is to use Experiment.new
, but
there are a number of other methods that may be easier for many use cases.
Simple constructor methods¶
These methods construct Experiment
instances based on common experimental designs.
Experiment.within_subjects
: Construct an experiment with levels'participant'
and'trial'
, and IVs only at the'trial'
level. For example:>>> from experimentator import Experiment, order ... independent_variables = { ... 'side': ['left', 'right'], ... 'display_time': [0.1, 0.55, 1], ... } ... experiment = Experiment.within_subjects( ... independent_variables, ... n_participants=20, ... ordering=order.Shuffle(10) ... )
The above creates a 2 (side) by 3 (display time) within-subjects experiment, with 10 trials of each condition and 20 participants. Trials will be shuffled within participants.
Experiment.blocked
: Construct an experiment with levels'participant'
,'block'
, and'trial'
, with IVs at the'trial'
level (and optionally at the'block'
level also). The following constructs an experiment identical to the previous example, except with each participant’s 60 trials split into two blocks:>>> from experimentator import Experiment, order ... independent_variables = { ... 'side': ['left', 'right'], ... 'display_time': [0.1, 0.55, 1], ... } >>> experiment = Experiment.blocked( ... independent_variables, ... n_participants=20, ... orderings={ ... 'trial': order.Shuffle(5), ... 'participant': order.Ordering(2), ... } ... )
In the above example, it doesn’t matter what ordering method we use at the
'block'
level; since there are no block-level IVs, all blocks are identical. We could, alternatively, introduce an IV at the block level:>>> from experimentator import Experiment, order ... independent_variables = { ... 'side': ['left', 'right'], ... 'display_time': [0.1, 0.55, 1], ... } >>> experiment = Experiment.blocked( ... independent_variables, ... block_ivs={'difficulty': ['easy', 'hard']} ... n_participants=20, ... orderings={'trial': order.Shuffle(5)} ... )
In this example, we introduced the IV
'difficulty'
with two levels. Since we didn’t specify an ordering for blocks,Shuffle(1)
will be used. In other words, each participant will experience one'easy'
and one'hard'
block, in a random order.Experiment.basic
: Construct an experiment with arbitrary levels but a homogeneous structure. This constructor can handle any experimental structure, with the exception of heterogeneity. For example, to create the same blocked experiment as in the previous example:>>> from experimentator import Exeriment, order >>> independent_variables = { ... 'trial': { ... 'side': ['left', 'right'], ... 'display_time': [0.1, 0.55, 1], ... }, ... 'block': {'difficulty': ['easy', 'hard']}, ... } >>> experiment = Experiment.basic( ... ('participant', 'block', 'trial'), ... independent_variables, ... ordering_by_level={ ... 'participant': order.Ordering(20), ... 'trial': order.Shuffle(5), ... } ... )
Again, the default
Shuffle(1)
will be used at the'block'
level.We could also use
Experiment.basic
to make a mixed-design experiment, by adding a new IV at the'participant'
level:>>> from experimentator import Exeriment, Shuffle >>> independent_variables = { ... 'trial': { ... 'side': ['left', 'right'], ... 'display_time': [0.1, 0.55, 1], ... }, ... 'block': {'difficulty': ['easy', 'hard']}, ... 'participant': {'vision': ['monocular', 'binocular']}, ... } >>> experiment = Experiment.basic( ... ('participant', 'block', 'trial'), ... independent_variables, ... ordering_by_level={ ... 'participant': Shuffle(20), ... 'trial': Shuffle(5), ... } ... )
In addition to adding the IV
'vision'
at the'participant'
level, we also changed the'participant'
ordering fromOrdering
toShuffle
in order to assign participants to conditions randomly. Note that we kept thenumber
parameter on the'participant'
ordering at 20; this means our experiment will now require 40 participants, since there will be 2 conditions at the'participant'
level.
Specification-based constructor methods¶
Experimentator provides a dictionary-based specification format for creating new Experiment
instances.
There are two relevant constructor methods:
Experiment.from_dict
constructs an Experiment
given a dictionary, and
Experiment.from_yaml_file
constructs an Experiment
given the path to a file containing the specification in YAML format.
The specification is the same for both.
Central to the specification format is specifying a DesignTree
and its constituent Design
instances.
See also
- Design
- More information on the
Design
concept. Design.from_dict
- The method that implements the construction of a
Design
from a specification dictionary.
A single Design
instance can be created from a dictionary
(either a Python dict or read from a YAML file via Experiment.from_yaml_file
).
The dictionary can contain any of the following keys, all optional:
'name'
: The name of the level.'ivs'
: The designs’s independent variables. Can be a dictionary mapping IV names to possible IV values, or a list of(name, values)
tuples. See Independent variables. If'ivs'
is not specified, the design will have no IVs.'order'
or'ordering'
: The design’s ordering method. Can be specified in three ways:- as a string, interpreted as a class name in the
order
module; - as a dictionary, with the key
'class'
containing the class name and the rest of the items containing keyword arguments to its constructor; or - as a sequence, with the first item containing the class name and the rest of the items containing positional arguments to its constructor.
If no ordering is specified, the default is
Shuffle
(Ordering
if a design matrix is used).- as a string, interpreted as a class name in the
'n'
or'number'
: Thenumber
argument to the specified ordering class can be specified here (or as part of theordering
specification).'design_matrix'
: An array-like (e.g., a list of lists) specifying a design matrix to use at this level. See Design matrices.Any remaining fields are passed to the
Design
constructor as theextra_data
argument. These values are associated with any sections created under this design. For example, you could pass{'practice': True}
to practice blocks, to mark them as such.
For example, the following creates a Design
instance
equivalent to the one at the 'trial'
level in the previous example (of Experiment.basic
):
>>> from experimentator import Design
>>> level_name, design = Design.from_dict(dict(
... name='trial',
... ivs={
... 'side': ['left', 'right'],
... 'display_time': [0.1, 0.55, 1],
... },
... ordering='Shuffle',
... n=5,
... ))
For internal reasons, Design.from_dict
outputs the level name as well as the Design
object.
This shouldn’t be too important,
because you will probably not be calling Design.from_dict
directly,
but rather using the dictionary format within Experiment.from_dict
or Experiment.from_yaml_file
.
See also
DesignTree.from_spec
- The method that implements this specification.
To create an Experiment
, multiple Design
instances are needed, collected under a single DesignTree
.
This can also be done with a relatively simple specification format.
To create a DesignTree
with a homogeneous structure, simply create a list of dictionaries,
each specifying the Design
of one level, ordered from top to bottom.
For example, to create the DesignTree
equivalent to the Experiment.basic
mixed-design example above:
>>> from experimentator import DesignTree
>>> tree = DesignTree.from_spec([
... dict(name='participant',
... ivs={'vision': ['monocular', 'binocular']},
... n=20),
... dict(name='block',
... ivs={'difficulty': ['easy', 'hard']}),
... dict(name='trial',
... ivs={
... 'side': ['left', 'right'],
... 'display_time': [0.1, 0.55, 1]},
... n=5),
... ])
This example takes advantage of the default ordering of Shuffle
for all three levels.
A DesignTree
can also be constructed with a list of (level_name, level_design)
tuples,
though the specification format is more convenient as it can be used as part of the Experiment
specification format.
Creating heterogeneous structures is a little more tricky; it will be described below.
Once you can build a specification input suitable for DesignTree.from_spec
,
constructing an Experiment
is straightforward.
Create a dictionary with the following keys:
'design'
: TheDesignTree
spec goes here (the list of dictionaries described above). This is the only required key.'file'
or'filename'
: Use this field to associate your experiment with a data file. This is saved in theExperiment.filename
attribute. Note that theExperiment
will not be saved automatically; you still have to callExperiment.save()
.- Any remaining fields will be saved as a dictionary in
Experiment.experiment_data
. This is a good place to put local configuration that you read during a callback.
All the nested lists and dictionaries required for Experiment.from_dict
can be unwieldy.
An alternative is Experiment.from_yaml_file
, which allows you to save your specification in an external file.
YAML is a file-format designed to be both human- and computer-readable.
Porting the previous mixed-design example into a YAML file would look like this:
design:
- name: participant
ivs:
vision: [monocular, binocular]
n: 20
- name: block
ivs:
difficulty: [easy, hard]
- name: trial
ivs:
side: [left, right]
display_time: [0.1, 0.55, 1]
n: 5
filename: mixed_experiment.exp
The only new piece of information here is the filename. It probably makes sense to include the filename in your YAML file, so you have a record of which data file is associated with the YAML file.
You can then create instantiate an Experiment
, assuming the YAML above is stored in mixed_experiment.yaml
:
>>> from experimentator import Experiment
>>> experiment = Experiment.from_yaml_file('mixed_experiment.yaml')
Note
This method is specifically for creating an Experiment
from scratch.
The data format used by Experiment.save
for saving an in-progress experiment is also YAML,
but using a different syntax, so it could be confused.
This is why we recommend a different file suffix (our examples use .exp
).
The in-progress experiment file with the .exp
suffix will still contain YAML data,
but it will be less likely to be confused with the YAML file passed to Experiment.from_yaml_file
.
Constructing an Experiment from a DesignTree¶
A final option for constructing an Experiment
is to pass a DesignTree
directly to the general constructor Experiment.new
.
For example, the following code would create the same Experiment
as the previous example:
>>> from experimentator import DesignTree, Experiment
>>> tree = DesignTree.from_spec([
... dict(name='participant',
... ivs={'vision': ['monocular', 'binocular']},
... n=20),
... dict(name='block',
... ivs={'difficulty': ['easy', 'hard']}),
... dict(name='trial',
... ivs={
... 'side': ['left', 'right'],
... 'display_time': [0.1, 0.55, 1]},
... n=5),
... ])
>>> experiment = Experiment.new(tree, filename='mixed_experiment.exp')
Constructing heterogeneous experiments¶
As we’ve noted, constructing a heterogenous Experiment
is a bit more complicated.
To expand on the above example, let’s imagine we want to create a two-session experiment.
The first session contains only one block, with only easy trials.
The second session will then contain an easy and a hard block.
Furthermore, we would like to add four practice trials at the beginning of each session.
Heterogeneity is created at the level of the DesignTree
.
Remember how we built a DesignTree
as a list of dictionaries?
To create a heterogeneous DesignTree
, we need multiple lists of dictionaries.
We use a dictionary, where each value is a list of dictionaries
(specifying an internally homogeneous section of the tree),
and the keys give names to these sub-trees.
Experimentator will create the DesignTree
by starting at the sub-tree with the key 'main'
.
When it reaches the bottom of this sub-tree, it decides how to continue by looking for a special IV named 'design'
.
If this IV exists, it uses its value to decide which sub-tree to use next.
When it reaches the end of these sub-trees, if there is an IV called 'design'
it again uses it to determine which sub-tree to use next.
If there is no IV called 'design'
, then three tree ends.
In other words, the possible values of the 'design'
IV should be names of sub-trees.
For example, let’s make our experiment more complex by adding practice trials and two different session types.
We’ll add the practice trials by creating a new level called 'section'
,
with the first section of each session proceeding to the practice trials, and the second into the experimental blocks.
We’ll use the Experiment.from_yaml_file
format:
design:
main:
- name: participant
ivs:
vision: [monocular, binocular]
n: 20
- name: session
ivs:
design: [first_session, second_session]
ordering: Ordering
first_session:
- name: section
ivs:
design: [practice, first_experimental_section]
ordering: Ordering
second_session:
- name: section
ivs:
design: [practice, second_experimental_section]
ordering: Ordering
practice:
- name: trial
ivs:
difficulty: [easy]
side: [left, right]
display_time: [0.1, 1]
practice: True
first_experimental_section:
- name: trial
ivs:
difficulty: [easy]
side: [left, right]
display_time: [0.1, 0.55, 1]
n: 10
practice: False
second_experimental_section:
- name: block
ivs:
difficulty: [easy, hard]
- name: trial
ivs:
side: [left, right]
display_time: [0.1, 0.55, 1]
n: 5
practice: False
filename: mixed_experiment.dat
Now we have a complex, heterogeneous experiment.
Each participant will have two sessions;
each session will start with four practice trials
(a cross of two levels of the IV 'side'
,
two levels of the IV 'display_time'
,
and one level of the IV 'easy'
).
The first session will include, after the practice section, sixty trials all with difficulty 'easy'
.
The second session will include, after the practice session, two blocks in random order,
the first with difficulty 'easy'
and the second 'hard'
, each with 30 trials.
To make this happen we created four sub-trees in addition to the 'main
tree.
Note that we added the custom key 'practice'
to the 'trial'
level,
to be able to more easily identify practice and experimental trials later
(Alternatively, we could separate them later by looking for trials with section==2
and ignoring trials with section==1
).
Also note that we use the Ordering
method to produce a predictable order of the sub-trees.
Otherwise, Shuffle
is the default and we would get our sub-trees in a random order.
Sometimes this is what we want, however.
Because sub-trees are determined based on IV values, we can manipulate them in the same way as with other IVs,
with ordering methods, design matrices, and even crossing them with other “normal” IVs.
It is not necessary to have the same level names for all possible routes down the tree.
In this example, there are no blocks in the first session
(or the practice section of the second session, for that matter).
However, it is critical that all IVs get assigned a value in one place or another.
In this example, the only place that the IV 'difficulty'
can take the value 'hard'
is at the 'block'
level of the second session.
In other places on the design tree, we have to create an IV 'difficulty'
with only one level ('easy'
)
to ensure that we never generate a trial without assigning a value to the IV 'difficulty'
.
Manually modifying experiments¶
Another way to create complex experiment structures is to first construct a simple experiment,
then manually modify it.
For example, you can use the method ExperimentSection.append_child
to add a child under any given section,
or ExperimentSection.append_design_tree
to add an entire sub-tree.
See these methods’ docstrings for details.
Be sure to call Experiment.save
after to make the changes permanent.
Design matrices¶
In all the examples so far, we’ve only specified possible IV values; we let experimentator handle the creation of conditions of them. Experimentator will use a full factorial cross, constructing a condition for every possible combination of IV values. Sometimes this isn’t what we want, though. In a fractional factorial design, for example, only a subset of the possible combinations are used. We can specify these, and other, designs in experimentator using design matrices.
The support for design matrices in experimenator is designed to be compatible with the Python library pyDOE. This is a library that allows for easy creation of various common design matrices.
Design matrices can be specified during the creation of Design
objects.
This is the same place where IVs are specified when using the Specification-based constructor methods.
Each column of the design matrix is associated with one IV;
a design matrix should have the same number of columns as the number of IVs in the design at that level.
The order of IVs is important when using design matrices;
because dictionaries in Python have no inherent order,
OrderedDict
should be used when defining IVs with design matrices,
or alternatively IVs can be specified as a list of tuples (see the IV docs).
Each row of the design matrix is one condition, and the values of the matrix are interpreted in one of two ways:
- If the levels of an IV are passed as
None
rather than a list, then the IV is assumed to take arbitrary, continuous values. The values in the associated column of the design matrix are then interpreted at “face value”. - Otherwise, each value in the design matrix is interpreted as an index, determining which value to take from the list of possible IV values. Experimentator is smart about this and only cares about the relative value of these “indices”. For example, if a design matrix column contains the values 0 and 1, they will be associated with the first and second IV values, respectively. Alternatively, if the column contains 1 and 2, then 1 will be associated with the first and 2 the second IV value.
A design matrix can also specify the order of conditions, by the order of its rows.
For this reason, the default ordering method is Ordering
when a design matrix is used.
Change this to Shuffle
, for example,
if you instead want the rows of the design matrix to appear in a random order.
Here is an example of using a Box-Behnken design with pyDOE:
>>> import pyDOE
>>> from experimentator import Design
>>> design_matrix = pyDOE.bbdesign(3)
>>> print(design_matrix)
[[-1. -1. 0.]
[ 1. -1. 0.]
[-1. 1. 0.]
[ 1. 1. 0.]
[-1. 0. -1.]
[ 1. 0. -1.]
[-1. 0. 1.]
[ 1. 0. 1.]
[ 0. -1. -1.]
[ 0. 1. -1.]
[ 0. -1. 1.]
[ 0. 1. 1.]
[ 0. 0. 0.]
[ 0. 0. 0.]
[ 0. 0. 0.]]
>>> trial_design = Design.from_dict(dict(
... ivs=[('target_size', [10, 20, 30]),
... ('target_speed', [5, 10, 20]),
... ('target_position', None)],
... design_matrix=design_matrix,
... ))
>>> # The following is just to demonstrate the conditions that are created.
>>> # These methods are usually called behind the scenes.
>>> trial_design.first_pass()
IndependentValue(name=(), values=())
>>> trial_design.get_order()
[{'target_position': 0.0, 'target_size': 10, 'target_speed': 5},
{'target_position': 0.0, 'target_size': 30, 'target_speed': 5},
{'target_position': 0.0, 'target_size': 10, 'target_speed': 20},
{'target_position': 0.0, 'target_size': 30, 'target_speed': 20},
{'target_position': -1.0, 'target_size': 10, 'target_speed': 10},
{'target_position': -1.0, 'target_size': 30, 'target_speed': 10},
{'target_position': 1.0, 'target_size': 10, 'target_speed': 10},
{'target_position': 1.0, 'target_size': 30, 'target_speed': 10},
{'target_position': -1.0, 'target_size': 20, 'target_speed': 5},
{'target_position': -1.0, 'target_size': 20, 'target_speed': 20},
{'target_position': 1.0, 'target_size': 20, 'target_speed': 5},
{'target_position': 1.0, 'target_size': 20, 'target_speed': 20},
{'target_position': 0.0, 'target_size': 20, 'target_speed': 10},
{'target_position': 0.0, 'target_size': 20, 'target_speed': 10},
{'target_position': 0.0, 'target_size': 20, 'target_speed': 10}]
Design.get_order
(usually called behind the scenes)
gives us a list of conditions, each a dictionary.
We can see here the correspondence between the design matrix and the conditions.
Because we used None
with 'target_position'
, its values are taken directly from the matrix.
For the other IVs, the values are taken from the list of possible values that we defined them with.
Callbacks¶
Up to this point, we’ve explained how to create an experiment of arbitrary complexity.
But presumably you actually something to happen when you run a trial.
This is accomplished with callbacks.
In general, a callback is a function that you supply that is automatically triggered at a certain time.
There are two types of callbacks in experimentator,
the function callbacks and context-managers.
Both are set with Experiment.add_callback
.
Note
Be sure to save your experiment to disk after setting a callback, using Experiment.save
,
to make the changes permanent.
Note
Experimentator does not store the callbacks with your Experiment
, but rather
every time you load your experiment, the callbacks are re-imported.
Experimentator looks for a Python file with the same name as the functions were originally defined in.
As a result, the data file exported by Experiment.save
is not sufficient when you want to move an experiment
between computers.
You will also need to move the Python file(s) in which any callbacks are defined.
Experiment.add_callback
also takes optional keyword arguments
func_name
and func_module
that you can set to tell experimentator where to look for the callback.
Function callbacks¶
The most basic callbacks are function callbacks. A function callback runs at the start of every section at its level. Most commonly, this is used at the trial level to set the “trial function”; on other words, the behavior of every trial.
Callbacks should take two positional arguments.
It will be passed the current Experiment
and ExperimentSection
instances, respectively.
Everything that the run callback might need to know can be taken from these arguments.
Here are the most useful attributes:
ExperimentSection.data
: This is theChainMap
that contains the condition (IV values) for the currently running trial. It also includes the section numbers, for examplesection.data['trial']
will get the current trial number.Experiment.experiment_data
: This is a dictionary that you can use to store persistent data that every callback will have access to. By default, it is empty, but you can put data in here and it will always be available, even across sessions of the Python interpreter. This means that everything you put here must be picklable, so not everything will work.Experiment.session_data
: This is where you can store data that is only persistent within the current session of the Python interpreter. Every time Python exits, this dictionary is emptied. This means you can store data here even if it is not picklable. This is the place to store external resources like multimedia data. You can reload these resources during a context-manager callback.
The callback should return a dictionary, mapping dependent variable (DV) names to values.
The DV names are only used to label the columns in the final representation of the experiment’s data,
Experiment.dataframe
.
Set function callbacks using the Experiment.add_callback
method.
You can also pass this method arbitrary positional and keyword arguments.
Therefore, the full signature for a callback is func(experiment, section, *args, **kwargs)
,
where func
(the callback itself), *args
, and **kwargs
, are arguments to Experiment.add_callback
.
Context-managers¶
The second type of callback is the context manager.
The name context manager is taken from the Python standard library, where they are referred to as With Statement Context Managers
(the with
statement is one way to use context managers,
but it is not generally used to create them).
Fundamentally, a context manager specifies behavior that should occur before something,
and behavior that should occur after.
In experimentator, the idea is that you will use context managers
to define behavior that occurs before, between, and after sections of the experiment.
One may want to open external resources (e.g., a sound file) at the beginning of each session,
and close them afterward, for example.
Another common use case would be to offer a break between blocks.
The most verbose way to create a context manager is to make a class that contains the magic methods
__enter__
and __exit__
with “before” and “after” behavior, respectively.
See Context Manager Types.
A much more convenient way is to use the contextlib.contextmanager
decorator in the standard library.
See the documentation for details, but it works like this:
first you code the “before” behavior, then the keyword yield
, then the “after” behavior.
Here is an example context manager that offers a break between blocks:
from contextlib import contextmanager
@contextmanager
def offer_break(experiment, section):
# Don't need to offer a break before the first block.
if section.data['block'] > 1:
input('Take a break if you would like.\nPress ENTER when you are ready to continue.')
yield
print('Block {} completed.'.format(section.data['block']))
As you see, the signature of a context manager is the same as the signature of a function callback.
All the same data in the Experiment
and ExperimentSection
objects are also available to context managers.
Note
In the above example, we could make the offer_break
function work on any level of the experiment.
Every ExperimentSection
stores its level name in the attribute
level
.
If we replace section.data['block']
with section.data[section.level]
(we’d want to change the print
message as well),
then we could use offer_break
at multiple levels.
Context-manager callbacks have the same signature as regular function callbacks, and are the added the same way.
The only exception is to pass the keyword argument is_context=True
to Experiment.add_callback
.
With both types of callback, pass the level name to Experiment.add_callback
.
Continuing the previous example:
experiment.add_callback('block', offer_break, is_context=True)
If you are using the context manager to close resources, it may be a good idea to use a try-finally block (see Defining Clean-up Actions) to ensure that the resource is still closed in the case of an exception occurring. Here is an example that loads audio using the library pyglet:
from contextlib import contextmanager
import pyglet
@contextmanager
def load_audio(experiment, section):
player = pyglet.media.Player()
source = pyglet.media.load('background_music.mp3')
# Make the Player available to other callbacks by saving it in session_data.
experiment.session_data['player'] = player
try:
# Run the section.
yield
finally:
# This block will run even if an error occurs during the try block.
# If no error occurs, it will run after the section ends.
player.delete()
Note
This example is just for illustration.
Pyglet is actually smart enough to delete player
for you when the Python interpreter exits.
An alternative to manually editing Experiment.session_data
is to put objects after the yield
keyword.
Anything yielded by a context manager is stored in experiment.session_data[level_name]
for the duration of the session.
In the above example, if we have yield player
, then we can access player
from other callbacks
as experiment.session_data['session']
(assuming load_audio
is set as the context manager of the level 'session'
).
Command-line interface¶
You’ve generated your experiment, now what?
A major feature of experimentator is that it automatically turns your experiment into a command-line program,
via the command exp
.
The general format for running experimentator commands is:
exp COMMAND <exp-file> OPTIONS
The available commands are run, resume, and export.
Additionally, exp --help
(or -h
) will show the usage information,
and exp --version
will print experimentator’s version number.
Commands¶
Here is the abridged usage message:
exp run [options] <exp-file> (--next=<level> [--not-finished] | (<level> <n>)... [--from=<n>])
exp resume [options] <exp-file> (<level> | (<level> <n>)...)
exp export <exp-file> <data-file> [ --no-index-label --delim=<sep> --skip=<columns> --float=<format> --nan=<rep>]
exp -h | --help
exp --version
Don’t fear, an in-depth explanation follows.
run¶
The central command is run
.
There are a few ways to call it.
To run the first section at <level>
that hasn’t been started:
exp run <exp-file> --next <level>
For example, to run the next participant from example.exp
:
exp run example.exp --next participant
To run the next participant that hasn’t been finished (as opposed to the next that hasn’t started, the default):
exp run example.exp --next participant --not-finished
To run a specific section:
exp run <exp-file> (<level> <n>)...
The elipsis means that the previous element (the pair <level> <n>
)
can be repeated any number of times.
For example, to run the second session of the third participant:
exp run example.exp participant 3 session 2
In either version of run
, you can add the --from <n>
option
to start at a specific section.
For example, to run the second session of the third participant,
starting at the second block:
exp run example.exp participant 3 session 2 --from 2
<n>
can also be a comma-separated list of integers.
To start at the fourth trial of the second block:
exp run example.exp participant 3 session 2 --from 2,4
--from=<n>
works the same as the from_section
parameter to run_experiment_section
;
see documentation for that method for details.
Here is the full set of options for the run
command:
-d, --debug Set logging level to DEBUG. -o <options> Pass <options> to the experiment and save it as string in Experiment.session_data[‘options’]. --demo Don’t save data. --not-finished Run the first <level> that hasn’t finished (rather than first that hasn’t started). --skip-parents Don’t enter context of parent levels. --from=<n> Start running at child number <n> of the specified section. <n> can also be a comma-separated list of ints; see run_experiment_section for details (specifically, –from=<n> works like the parameter from_section).
resume¶
resume
is similar to run
.
There is nothing resume
can do that run
cannot, but resume
makes resuming interrupted session easier.
There are two ways to call it.
If you only pass a level, experimentator will try to resume the first section at that level that has been started but not finished. The syntax is:
exp resume <exp-file> <level>
For example, to resume the first block that has been started but not finished:
exp resume example.expblock
One can also use specific section numbers with resume
:
exp resume <exp-file> (<level> <n>)...
The specified section must have been started but not finished. For example:
exp resume example.expparticipant 3 session 2
The difference between the above example and using run
is that with resume
,
experimentator will automatically start at the appropriate place;
with run
, experimentator will start at the beginning of the section
(unless an explicit starting point is passed with --from
).
resume
takes the same options
as run
.
export¶
export
generates a text file with the experiment’s data.
Its basic syntax is:
exp export <exp-file> <data-file>
This one should be straightforward, but here is an example anyway:
exp export example.expexample.csv
Its associated options:
--no-index-label Don’t put column labels on index columns (e.g. participant, trial), for easier importing into R. --delim=<sep> Field delimiter [default: ,]. --skip=<columns> Comma-separated list of columns to skip. --float=<format> Format string for floating point numbers. --nan=<rep> Missing data representation.
See pandas.DataFrame.to_csv
for details on these options.
Note
If your experiment has any complex data structures (e.g., a timeseries for every trial),
it is not recommended to use the export
command, as this will create an unparseable mess.
Instead, access your data programmatically through the Experiment.dataframe
attribute.
API reference¶
Experiment¶
-
class
experimentator.
Experiment
(tree, data=None, has_started=False, has_finished=False, _children=None, filename=None, callback_by_level=None, callback_type_by_level=None, session_data=None, experiment_data=None, _callback_info=None)¶ Bases:
experimentator.section.ExperimentSection
An
ExperimentSection
subclass that represents the largest ‘section’ of the experiment; that is, the entire experiment. Functionality added on top ofExperimentSection
includes various constructors, saving to disk, and management of callbacks.To create a new experiment, rather than instantiating directly it is recommended to use one of the constructor methods:
Attributes
tree ( DesignTree
) TheDesignTree
instance defining the experiment’s hierarchy.filename (str) The file location where the Experiment
will be pickled.callback_by_level (dict) A dictionary, mapping level names to functions or With Statement Context Managers (e.g., generator functions decorated with contextlib.contextmanager
). Defines behavior to run at each section (for functions) or before and/or after each section (for context managers) at the associated level.callback_type_by_level (dict) A dictionary mapping level names to either the string 'context'
or'function'
. This keeps track of which callbacks inExperiment.callback_by_level
are context managers.session_data (dict) A dictionary where temporary data can be stored, persistent only within one session of the Python interpreter. This is a good place to store external resources that aren’t picklable; external resources, for example, can be loaded in a context-manager callback and stored here. In addition, anything returned by the __exit__
method of a context-manager callback will be stored here, with the callback’s level name as the key. This dictionary is emptied before saving theExperiment
to disk.experiment_data (dict) A dictionary where data can be stored that is persistent across Python sessions. Everything stored here must be picklable. -
add_callback
(level, callback, *args, is_context=False, func_module=None, func_name=None, **kwargs)¶ Add a callback to run at a certain level.
A callback can be either a regular function, or a context-manager. The latter is useful for defining code to run at the start and end of every section at the level. For example, a block context manager could specify behavior that occurs before every trial in the block, and behavior that occurs after every trial in the block. See
contextlib
for various ways to create context managers, and experimentator’s context-manager docs for more details.Any value returned by the
__enter__
method of a context manager will be stored inExperiment.session_data
under the key level.If the callback is not a context manager, it should return a dictionary (or nothing), which is automatically passed to
ExperimentSection.add_data
. In theory, it should map dependent-variable names to results. See the callback docs for more details.Parameters: level : str
Which level of the hierarchy to manage.
callback : function or context-manager
The callback should have the signature
callback(experiment, section, *args, **kwargs)
where experiment and section are the currentExperiment
andExperimentSection
instances, respectively, and args and kwargs are arbitrary arguments passed to this method.*args
Any arbitrary positional arguments to be passed to callback.
func_module : str, optional
func_name : str, optional
These two arguments specify where the given function should be imported from in future Python sessions (i.e.,
from <func_module> import <func_name>
). Usually, this is figured out automatically by introspection; these arguments are provided for the rare situation where introspection fails.**kwargs
Any arbitrary keyword arguments to be passed to callback.
-
add_data
(data)¶ Update the
ExperimentSection.data
ChainMap
. This data will apply to this section and all child sections. This can be used, for example, to manually record a participant’s age.Parameters: data : dict
Elements to be added to
ExperimentSection.data
.
-
all_subsections
(**section_numbers)¶ Find all subsections in the experiment matching the given section numbers.
Yields specified
ExperimentSection
instances. The yielded sections will be at the lowest level given in section_numbers. If levels not in section_numbers are encountered before reaching its lowest level, all sections will be descended into.Parameters: **section_numbers
Keyword arguments describing what subsections to find. Keys are level names, values are ints or sequences of ints.
Examples
Assuming the levels of the experiment saved in
'example.exp'
are('participant', 'session', 'block', 'trial')
:>>> from experimentator import Experiment >>> exp = Experiment.load('example.exp')
Get the first session of each participant:
>>> all_first_sessions = list(exp.all_subsections(session=1))
Get the second trial of the first block in each session:
>>> trials = list(exp.all_subsections(block=1, trial=2))
Get the first three trials of each block in the first session of each participant:
>>> more_trials = list(exp.all_subsections(session=1, trial=[1, 2, 3]))
-
append_child
(data, tree=None, to_start=False, _renumber=True)¶ Create a new
ExperimentSection
(and its descendants) and append it as a child of the currentExperimentSection
.Parameters: data : dict
Data to be included in the new section’s
ExperimentSection.data
ChainMap
. Should include values of IVs at the section’s level, for example.tree :
DesignTree
, optionalIf given, the section will be appended from the top level of tree. If not passed, the tree of the current section will be used. Note that this does not affect IV values; IV values must still be included in data.
to_start : bool, optional
If True, the new
ExperimentSection
will be appended to the beginning of the current section. If False (the default), it will be appended to the end.Notes
After calling this method, the section numbers in the children’s
ExperimentSection.data
attributes will be automatically replaced with the correct numbers.
-
append_design_tree
(tree, to_start=False, _renumber=True)¶ Append all sections associated with the top level of a
DesignTree
(and therefore also create descendant sections) to theExperimentSection
.Parameters: tree :
DesignTree
The tree to append.
to_start : bool, optional
If True, the sections will be inserted at the beginning of the section. If False (the default), they will be appended to the end.
Notes
After calling this method, the section numbers in the children’s
ExperimentSection.data
attributes will be automatically replaced with the correct numbers.
-
as_graph
()¶ Build a
networkx.DiGraph
out of the experiment structure, starting at this section. Nodes are sections and graphs are parent-child relations. Node data are non-duplicated entries inExperimentSection.data
.Returns: networkx.DiGraph
-
classmethod
basic
(levels, ivs_by_level, design_matrices_by_level=None, ordering_by_level=None, filename=None)¶ Construct a homogeneously-organized
Experiment
, with arbitrary levels but only oneDesign
at each level, and the same structure throughout its hierarchy.Parameters: levels : sequence of str
Names of the levels of the experiment
ivs_by_level : dict
Dictionary specifying the IVs and their possible values at every level. The keys are be the level names, and the values are lists of the IVs at that level, specified in the form of tuples with the first element being the IV name and the second element a list of its possible values. Alternatively, the IVs at each level can be specified in a dictionary. See IV docs for more on specifying IVs.
design_matrices_by_level : dict, optional
Specify the design matrix for any levels. Keys are level names; values are design matrices. Any levels without a design matrix will be fully crossed. See design matrix docs for details.
ordering_by_level : dict, optional
Specify the ordering for each level. Keys are level names; values are instance objects from
experimentator.order
. For any levels without an order specified,Shuffle
will be used.filename : str, optional
File location to save the experiment.
Returns:
-
classmethod
blocked
(trial_ivs, n_participants, design_matrices=None, orderings=None, block_ivs=None, filename=None)¶ Create a blocked within-subjects
Experiment
, in which all the IVs are at either the trial level or the block level.Parameters: trial_ivs : list or dict
A list of the IVs to define at the trial level, specified in the form of tuples with the first element being the IV name and the second element a list of its possible values. Alternatively, the IVs at each level can be specified in a dictionary. See the IV docs more on specifying IVs.
n_participants : int
Number of participants to initialize. If a
NonAtomicOrdering
is used, this is the number of participants per order.design_matrices : dict, optional
Design matrices for the experiment. Keys are
'trial'
and'block'
; values are the respective design matrices (if any). If not specified, IVs will be fully crossed. See the design matrix docs for details.orderings : dict, optional
block_ivs : list or dict, optional
IVs to define at the block level. See IV docs for more on specifying IVs.
filename : str, optional
File location to save the experiment.
Returns: Notes
For blocks to have any effect, you should either define at least one IV at the block level or use the ordering
Ordering(n)
to createn
blocks for every participant.
-
breadth_first_search
(key)¶ Breadth-first search starting from here. Returns the entire search path.
Parameters: key : func
Function that returns True or False when passed an
ExperimentSection
.Returns: list of
ExperimentSection
-
depth_first_search
(key, path_key=None, _path=None)¶ Depth-first search starting from here. Returns the entire search path.
Parameters: key : func
Function that returns True or False when passed an
ExperimentSection
.path_key : func, optional
Function that returns True or False when passed an
ExperimentSection
. If given, the search will proceed only via sections for which path_key returns True.Returns: list of
ExperimentSection
-
export_data
(filename, skip_columns=None, **kwargs)¶ Export
Experiment.dataframe
in.csv
format.Parameters: filename : str
A file location where the data should be saved.
skip_columns : list of str, optional
Columns to skip.
**kwargs
Arbitrary keyword arguments to pass to
pandas.DataFrame.to_csv
.Notes
This method is not recommended for experiments with compound data types, for example an experiment which stores a time series for every trial. In those cases it is recommended to write a custom script that parses the
Experiment.dataframe
attribute as desired, or use the skip_columns option to skip any compound columns.
-
find_first_not_run
(at_level, by_started=True)¶ Search the experimental hierarchy, and return the first descendant
ExperimentSection
at at_level that has not yet been run.Parameters: at_level : str
Which level to search.
by_started : bool, optional
If True (default), returns the first section that has not been started. Otherwise, finds the first section that has not finished.
Returns:
-
find_first_partially_run
(at_level)¶ Search the experimental hierarchy, and return the first descendant
ExperimentSection
at at_level that has been started but not finished.Parameters: at_level : str
Which level to search.
Returns:
-
classmethod
from_dict
(spec)¶ Construct an
Experiment
based on a dictionary specification.Parameters: spec : dict
spec should have, at minimum, a key named
'design'
. The value of this key specifies theDesignTree
. SeeDesignTree.from_spec
for details. The value of the key'filename'
or'file'
, if one exists,is saved inExperiment.filename
. All other fields are saved inExperiment.experiment_data
.Returns:
-
classmethod
from_yaml_file
(filename)¶ Construct an
Experiment
based on specification in a YAML file. Requires PyYAML.Parameters: filename : str
YAML file location. The YAML should specify a dictionary matching the specification of
Experiment.from_dict
.Returns:
-
get_next_tree
()¶ Get a tree to use for creating child
ExperimentSection
instances.Returns: DesignTree
-
static
load
(filename)¶ Load an experiment from disk.
Parameters: filename : str
Path to a file generated by
Experiment.save
.Returns:
-
classmethod
new
(tree, filename=None)¶ Make a new
Experiment
.Parameters: tree :
DesignTree
A
DesignTree
instance defining the experiment hierarchy.filename : str, optional
A file location where the
Experiment
will be saved.
-
parent
(section)¶ Find the parent of a section.
Parameters: section :
ExperimentSection
The section to find the parent of.
Returns:
-
parents
(section)¶ Find all parents of a section, in top-to-bottom order.
Parameters: section :
ExperimentSection
The section to find the parents of.
Returns: list of
ExperimentSection
-
resume_section
(section, **kwargs)¶ Rerun a section that has been started but not finished, starting where running last left off.
Parameters: section :
ExperimentSection
The section to resume.
**kwargs
Keyword arguments to pass to
Experiment.run_section
.Notes
The wrapper function
run_experiment_section
should be used instead of this method, if possible.
-
run_section
(section, demo=False, parent_callbacks=True, from_section=None)¶ Run a section and all its descendant sections. Saves the results in the
data
attribute of each lowest-levelExperimentSection
.Parameters: section :
ExperimentSection
The section to be run.
demo : bool, optional
Data will only be saved if demo is False (the default).
parent_callbacks : bool, optional
If True (the default), all parent callbacks will be called.
from_section : int or list of int, optional
Which section to start running from. If a list is passed, it specifies where to start running on multiple levels. For example, assuming the experiment hierarchy is
('participant', 'session', 'block', 'trial')
, this would start from the fifth trial of the second block (of the first participant’s second session):>>> exp = Experiment.load('example.exp') >>> exp.run_section(exp.subsection(participant=1, session=2), from_section=[2, 5])
Notes
The wrapper function
run_experiment_section
should be used instead of this method, if possible.
-
save
(filename=None)¶ Save the
Experiment
to disk.Parameters: filename : str, optional
If specified, overrides
Experiment.filename
.
-
subsection
(**section_numbers)¶ Find a single, descendant
ExperimentSection
based on section numbers.Parameters: **section_numbers
Keyword arguments describing which subsection to find Must include every level higher than the desired section.
Returns: Examples
Assuming the levels of the experiment saved in
'example.exp'
are('participant', 'session', 'block', 'trial')
, this will return the third block of the second participant’s first session:>>> from experimentator import Experiment >>> exp = Experiment.load('example.exp') >>> some_block = exp.subsection(participant=2, session=1, block=3)
-
walk
()¶ Walk the tree depth-first, starting from here. Yields this section and every descendant section.
-
classmethod
within_subjects
(ivs, n_participants, design_matrix=None, ordering=None, filename=None)¶ Create a within-subjects
Experiment
, with all the IVs at the'trial'
level.Parameters: ivs : list or dict
A list of the experiment’s IVs, specified in the form of tuples with the first element being the IV name and the second element a list of its possible values. Alternatively, the IVs at each level can be specified in a dictionary. See the IV docs more on specifying IVs.
n_participants : int
Number of participants to initialize.
design_matrix : array-like, optional
Design matrix for the experiment. If not specified, IVs will be fully crossed. See the design matrix docs for more details.
ordering :
Ordering
, optionalfilename : str, optional
File location to save the experiment.
Returns:
-
Helper functions¶
-
experimentator.
run_experiment_section
(experiment, section_obj=None, demo=False, resume=False, parent_callbacks=True, from_section=1, session_options='', **section_numbers)¶ Run an experiment from a file or an
Experiment
instance, and save it. If an exception is encountered, theExperiment
will be backed up and saved.Parameters: experiment : str or
Experiment
File location where an
Experiment
instance is pickled, or anExperiment
instance.demo : bool, optional
If True, data will not be saved and sections will not be marked as run.
resume: bool, optional
If True, the specified section will be resumed (started automatically where it left off).
parent_callbacks : bool, optional
If True (the default), all parent callbacks will be called.
section_obj :
ExperimentSection
, optionalThe section of the experiment to run. Alternatively, the section can be specified using **section_numbers.
from_section : int or list of int, optional
Which section to start running from. If a list is passed, it specifies where to start running on multiple levels. See the example below.
session_options : str, optional
Pass an experiment-specific options string to be stored in
Experiment.session_data
under the key'options'
.**section_numbers
Keyword arguments describing how to descend the experiment hierarchy to find the section to run. See the example below.
Examples
- A simple example:
>>> exp = Experiment.load('example.exp') >>> run_experiment_section(exp, exp.subsection(participant=1, session=2))
Equivalently:
>>> run_experiment_section(exp, participant=1, session=2)
To demonstrate from_section, assuming the experiment hierarchy is
('participant', 'session', 'block', 'trial')
, this would start from the second block:>>> run_experiment_section(exp, participant=1, session=2, from_section=2)
To start from the fifth trial of the second block:
>>> run_experiment_section(exp, participant=1, session=2, from_section=[2, 5])
-
experimentator.
export_experiment_data
(exp_filename, data_filename, **kwargs)¶ Reads a pickled
Experiment
instance and saves its data in.csv
format.Parameters: exp_filename : str
The file location where an
Experiment
instance is pickled.data_filename : str
The file location where the data will be written.
skip_columns : list of str, optional
Data columns to skip.
**kwargs
Arbitrary keyword arguments passed through to
pandas.DataFrame.to_csv
.Notes
This shortcut function is not recommended for experiments with compound data types, for example an experiment which stores a time series for every trial. In such cases it is recommended to write a custom script that parses
Experiment.dataframe
as desired (or use the skip_columns option to ignore the compound data).
ExperimentSection¶
-
class
experimentator.section.
ExperimentSection
(tree, data=None, has_started=False, has_finished=False, _children=None)¶ A section of the experiment, at any level of the hierarchy. Single trials and groups of trials (blocks, sessions, participants, etc.) are represented as
ExperimentSection
instances. A complete experiment consists ofExperimentSection
instances arranged in a tree. The root element should be anExperiment
(a subclass ofExperimentSection
); the rest of the sections can be reached via its descendants (see below on the sequence protocol). A newExperimentSection
instance is automatically populated withExperimentSection
descendants according to theDesignTree
passed to its constructor.ExperimentSection
implements Python’s sequence protocol; its contents areExperimentSection
instances at the level below. In other words, children can be accessed using the[index]
notation, as well as with slices ([3:6]
) or iteration (for section in experiment_section
). However,ExperimentSection
breaks the Python convention of 0-based indexing, using 1-based indexing to match the convention in experimental science.The direct constructor is used to create an arbitrary
ExperimentSection
(i.e., possibly reloading an in-progress section), whereasExperimentSection.new
creates a section that hasn’t yet started.Notes
Use 1-based indexing to refer to
ExperimentSection
children, both when when using indexing or slicing with anExperimentSection
, and when identifying sections in keyword arguments to methods such asExperimentSection.subsection
. This better corresponds to the language commonly used by scientists to identify participants, trials, etc.Attributes
tree ( DesignTree
)data ( ChainMap
)description (str) The name and number of the section (e.g., 'trial 3'
).dataframe ( DataFrame
) All data associated with theExperimentSection
and its descendants.heterogeneous_design_iv_name (str) IV name determining which branch of the DesignTree
to follow.level (str) The level of the hierarchy at which this section lives. levels (list of str) Level names below this section. local_levels (set) Level names of this section’s children. Usually a single-element set. is_bottom_level (bool) If true, this is the lowest level of the hierarchy. is_top_level (bool) If true, this is the highest level of the hierarchy (likely an Experiment
).has_started: bool Whether this section has started to be run. has_finished (bool) Whether this section has finished running. -
add_data
(data)¶ Update the
ExperimentSection.data
ChainMap
. This data will apply to this section and all child sections. This can be used, for example, to manually record a participant’s age.Parameters: data : dict
Elements to be added to
ExperimentSection.data
.
-
all_subsections
(**section_numbers)¶ Find all subsections in the experiment matching the given section numbers.
Yields specified
ExperimentSection
instances. The yielded sections will be at the lowest level given in section_numbers. If levels not in section_numbers are encountered before reaching its lowest level, all sections will be descended into.Parameters: **section_numbers
Keyword arguments describing what subsections to find. Keys are level names, values are ints or sequences of ints.
Examples
Assuming the levels of the experiment saved in
'example.exp'
are('participant', 'session', 'block', 'trial')
:>>> from experimentator import Experiment >>> exp = Experiment.load('example.exp')
Get the first session of each participant:
>>> all_first_sessions = list(exp.all_subsections(session=1))
Get the second trial of the first block in each session:
>>> trials = list(exp.all_subsections(block=1, trial=2))
Get the first three trials of each block in the first session of each participant:
>>> more_trials = list(exp.all_subsections(session=1, trial=[1, 2, 3]))
-
append_child
(data, tree=None, to_start=False, _renumber=True)¶ Create a new
ExperimentSection
(and its descendants) and append it as a child of the currentExperimentSection
.Parameters: data : dict
Data to be included in the new section’s
ExperimentSection.data
ChainMap
. Should include values of IVs at the section’s level, for example.tree :
DesignTree
, optionalIf given, the section will be appended from the top level of tree. If not passed, the tree of the current section will be used. Note that this does not affect IV values; IV values must still be included in data.
to_start : bool, optional
If True, the new
ExperimentSection
will be appended to the beginning of the current section. If False (the default), it will be appended to the end.Notes
After calling this method, the section numbers in the children’s
ExperimentSection.data
attributes will be automatically replaced with the correct numbers.
-
append_design_tree
(tree, to_start=False, _renumber=True)¶ Append all sections associated with the top level of a
DesignTree
(and therefore also create descendant sections) to theExperimentSection
.Parameters: tree :
DesignTree
The tree to append.
to_start : bool, optional
If True, the sections will be inserted at the beginning of the section. If False (the default), they will be appended to the end.
Notes
After calling this method, the section numbers in the children’s
ExperimentSection.data
attributes will be automatically replaced with the correct numbers.
-
as_graph
()¶ Build a
networkx.DiGraph
out of the experiment structure, starting at this section. Nodes are sections and graphs are parent-child relations. Node data are non-duplicated entries inExperimentSection.data
.Returns: networkx.DiGraph
-
breadth_first_search
(key)¶ Breadth-first search starting from here. Returns the entire search path.
Parameters: key : func
Function that returns True or False when passed an
ExperimentSection
.Returns: list of
ExperimentSection
-
depth_first_search
(key, path_key=None, _path=None)¶ Depth-first search starting from here. Returns the entire search path.
Parameters: key : func
Function that returns True or False when passed an
ExperimentSection
.path_key : func, optional
Function that returns True or False when passed an
ExperimentSection
. If given, the search will proceed only via sections for which path_key returns True.Returns: list of
ExperimentSection
-
find_first_not_run
(at_level, by_started=True)¶ Search the experimental hierarchy, and return the first descendant
ExperimentSection
at at_level that has not yet been run.Parameters: at_level : str
Which level to search.
by_started : bool, optional
If True (default), returns the first section that has not been started. Otherwise, finds the first section that has not finished.
Returns:
-
find_first_partially_run
(at_level)¶ Search the experimental hierarchy, and return the first descendant
ExperimentSection
at at_level that has been started but not finished.Parameters: at_level : str
Which level to search.
Returns:
-
get_next_tree
()¶ Get a tree to use for creating child
ExperimentSection
instances.Returns: DesignTree
-
classmethod
new
(tree, data=None)¶ Create a new
ExperimentSection
.Parameters: tree :
DesignTree
Describes the design of the experiment hierarchy.
data :
ChainMap
All data to be associated with the
ExperimentSection
, including the values of independent variables, the section numbers indicating the section’s location in the experiment, and any results associated with this section, arising from either the run callback of theExperiment
or from the methodExperimentSection.add_data
. data should be acollections.ChainMap
, which behaves like a dictionary but has a hierarchical organization such that children can access values from the parent but not vice-versa.
-
parent
(section)¶ Find the parent of a section.
Parameters: section :
ExperimentSection
The section to find the parent of.
Returns:
-
parents
(section)¶ Find all parents of a section, in top-to-bottom order.
Parameters: section :
ExperimentSection
The section to find the parents of.
Returns: list of
ExperimentSection
-
subsection
(**section_numbers)¶ Find a single, descendant
ExperimentSection
based on section numbers.Parameters: **section_numbers
Keyword arguments describing which subsection to find Must include every level higher than the desired section.
Returns: Examples
Assuming the levels of the experiment saved in
'example.exp'
are('participant', 'session', 'block', 'trial')
, this will return the third block of the second participant’s first session:>>> from experimentator import Experiment >>> exp = Experiment.load('example.exp') >>> some_block = exp.subsection(participant=2, session=1, block=3)
-
walk
()¶ Walk the tree depth-first, starting from here. Yields this section and every descendant section.
-
Design¶
-
class
experimentator.
Design
(ivs=None, design_matrix=None, ordering=None, extra_data=None)¶ Design
instances specify the experimental design at one level of the experimental hierarchy. They guide the creation ofExperimentSection
instances by parsing design matrices or crossing independent variables (IVs).Parameters: ivs : dict or list of tuple, optional
Independent variables can be specified as a dictionary mapping names to possible values, or as a list of
(name, values)
tuples. If an IV takes continuous values, useNone
for its levels. This only works when specifying values using design_matrix. See the IV docs for more information.design_matrix : array-like, optional
A
numpy array
(or convertible, e.g. a list-of-lists) representing a design matrix specifying how IV values should be grouped to form conditions. When no design_matrix is passed, IVs are fully crossed. See the design matrix docs for more details. Note that a design matrix may also specify the order of the conditions. For this reason, the default ordering changes fromShuffle
toOrdering
, preserving the order of the conditions.ordering :
Ordering
, optionalextra_data : dict, optional
Items from this dictionary will be included in the
data
attribute of anyExperimentSection
instances created with thisDesign
.Examples
>>> from experimentator.order import Shuffle >>> design = Design(ivs={'side': ['left', 'right'], 'difficulty': ['easy', 'hard']}, ordering=Shuffle(2)) >>> design.first_pass() IndependentVariable(name=(), values=()) >>> design.get_order() [{'difficulty': 'easy', 'side': 'left'}, {'difficulty': 'hard', 'side': 'left'}, {'difficulty': 'easy', 'side': 'left'}, {'difficulty': 'hard', 'side': 'right'}, {'difficulty': 'easy', 'side': 'right'}, {'difficulty': 'easy', 'side': 'right'}, {'difficulty': 'hard', 'side': 'left'}, {'difficulty': 'hard', 'side': 'right'}]
Attributes
iv_names (list of str) iv_values (list of tuple) design_matrix (array-like) extra_data (dict) ordering ( Ordering
)heterogeneous_design_iv_name (str) The IV name that triggers a heterogeneous (i.e., branching) tree structure when it is encountered. 'design'
by default.is_heterogeneous (bool) True if this Design
is the lowest level before the tree structure diverges.branches (dict) The IV values corresponding to named heterogeneous branches in the tree structure following this Design
.-
first_pass
()¶ Initialize design.
Initializes the design by parsing the design matrix or crossing the IVs If a
NonAtomicOrdering
is used, an additional IV will be returned which should be incorporated into the design one level up in the experimental hierarchy. For this reason, thefirst_pass
methods in a hierarchy ofDesign
instances should be called in reverse order, from bottom up. Use aDesignTree
to ensure this occurs properly.Returns: iv_name : str or tuple
The name of the IV, for
non-atomic orderings
. Otherwise, an empty tuple.iv_values : tuple
The possible values of the IV. Empty for atomic orderings.
-
classmethod
from_dict
(spec)¶ Construct a
Design
instance from a specification based on dictionaries (e.g., parsed from a YAML file).Parameters: spec : dict
A dictionary containing some of the following keys (all optional):
'name'
, the name of the level;'ivs'
,'design_matrix'
,'extra_data'
, keyword arguments to theDesign
constructor;'order'
or'ordering'
, a string, dictionary, or list determining the ordering method; and'n'
or'number'
, thenumber
argument to the specified ordering. A dictionary containing any fields not otherwise used is passed to theDesign
constructor as theextra_data
argument. See the description in the docs for more information.Returns: name : str
Only returned if spec contains a field
'name'
.design :
Design
See also
Examples
>>> design_spec = { ...'name': 'block', ...'ivs': {'speed': [1, 2, 3], 'size': [15, 30]}, ...'ordering': 'Shuffle', ...'n': 3} >>> Design.from_dict(design_spec) Level(name='block', design=Design(ivs=[('speed', [1, 2, 3]), ('size', [15, 30])], design_matrix=None, ordering=Shuffle(number=3, avoid_repeats=False), extra_data={}))
-
static
full_cross
(iv_names, iv_values)¶ Perform a full factorial cross of the independent variables. Yields dictionaries, each describing one condition, a mapping from IV names to IV values. One dictionary is yielded for every possible combination of IV values.
Parameters: iv_names : list of str
Names of IVs.
iv_values : list of list
Each element defines the possible values of an IV. Must be the same length as iv_names. Its elements must be hashable.
-
get_order
(data=None)¶ Order the conditions.
Returns: list of dict
A list of dictionaries, each specifying a condition (a mapping from IV names to values).
-
update
(names, values)¶ Add additional independent variables to the
Design
. This will have no effect afterDesign.first_pass
has been called.Parameters: names : list of str
Names of IVs to add.
values : list of list
For each IV, a list of possible values.
-
DesignTree¶
-
class
experimentator.
DesignTree
(levels_and_designs=None, other_designs=None, branches=None)¶ A container for
Design
instances, describing the entire hierarchy of a basicExperiment
.DesignTree
instances are iterators; callingnext
on one will return anotherDesignTree
with the top level removed. In this way, the entire experimental hierarchy can be created by recursively callingnext
.Use
DesignTree.new
to create a new tree, the generic constructor is for instantiating trees whose attributes have already been processed (i.e., reloading already-created trees).Notes
Calling
next
on the last level of a heterogeneousDesignTree
will return a dictionary of namedDesignTree
instances (rather than a singleDesignTree
instance). The keys are the possible values of the IV'design'
and the values are the correspondingDesignTree
instances.Attributes
levels_and_designs (list of tuple) other_designs (dict) branches (dict) Only those items from other_designs that follow directly from this tree. -
add_base_level
()¶ Adds a section to the top of the tree called
'_base'
. This makes theDesignTree
suitable for constructing anExperiment
.Notes
The
Experiment
constructor calls this automatically, and this shouldn’t be called when appending a tree to an existingExperiment
, so there is no use case for manually calling this method.
-
static
first_pass
(levels_and_designs)¶ Make a first pass of all designs in a
DesignTree
, from bottom to top. This callsDesign.first_pass
on everyDesign
instance in the tree in the proper order, updating designs when a new IV is returned. This is necessary fornon-atomic orderings
because they modify the parentDesign
.
-
classmethod
from_spec
(spec)¶ Constructs a
DesignTree
instance from a specification (e.g., parsed from a YAML file).- spec : dict or list of dict
- The
DesignTree
specification. A dictionary with keys as tree names and values as lists of dictionaries. Each sub-dictionary should specify aDesign
according toDesign.from_dict
. The main tree should be named'main'
. Other names are used for generating heterogeneous trees (seeDesignTree
docs). A homogeneous tree can be specified as a dictionary with only a single key'main'
, or directly as a list of dictionaries
Returns: DesignTree
-
classmethod
new
(levels_and_designs, **other_designs)¶ Create a new
DesignTree
.Parameters: levels_and_designs :
OrderedDict
or list of tupleThis input defines the structure of the tree, and is either an
OrderedDict
or a list of 2-tuples. Keys (or first element of each tuple) are level names. Values (or second element of each tuple) are design specifications, in the form of either aDesign
instance, or a list ofDesign
instances to occur in sequence.**other_designs
Named design trees, can be other
DesignTree
instances or suitable levels_and_designs inputs (i.e.,OrderedDict
or list of tuples). These designs allow for heterogeneous design structures (i.e. not every section at the same level has the sameDesign
). To make a heterogeneousDesignTree
, use an IV named'design'
at the level where the heterogeneity should occur. Values of this IV should be strings, each corresponding to the name of aDesignTree
from` other_designs`. The value of the IV'design'
at each section determines whichDesignTree
is used for children of that section.
-
experimentator.order¶
This module contains the class Ordering
and its descendants.
These classes handle how unique conditions at a particular experimental level are ordered and duplicated.
Ordering
instances should be passed directly to the Design
constructor;
there is no reason to otherwise interact with them in normal use.
Of special note are non-atomic orderings:
the class NonAtomicOrdering
and its descendants.
‘’Non-atomic’’ here means that the orderings between sections are not independent.
A Shuffle
ordering is atomic;
the order in one section is independent of the order in another.
However, if one wants to ensure, for example, that possible block orders are evenly distributed among participants
(a counterbalanced design
),
the block orders within each participants are not independent.
Each participant can decide its order of blocks only in the context of the other participants’ block orders.
This means that the parent section must handle orderings
(in the example of counterbalanced blocks,
the Experiment.base_section
–the experiment itself, essentially–must tell each participant what block order to use).
-
class
experimentator.order.
Ordering
(number=1)¶ Bases:
object
The base ordering class. It will keep conditions in the order they are defined by the
Design
instance (either the order of rows in the design matrix, or a full factorial cross–the output of callingitertools.product
on the IV levels). Remember not to rely on the order of dictionary items. Therefore, if a specific order is desired, it is recommended to use a design matrix or anOrderedDict
to define the IVs. See the IV docs for more information.Parameters: number : int, optional
The number of times each unique condition should appear. The default is 1. If
number > 1
, the entire order will be cycled (as opposed to repeating each condition within the order).-
first_pass
(conditions)¶ Handle operations that should only be performed once, initializing the object before ordering conditions. For
Ordering
, the only operation is duplication of the list of conditions (ifOrdering.number
> 1). This methods should not be called manually.Parameters: conditions : sequence of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
Returns: iv_name : str or tuple
The name of the IV, for non-atomic orderings. Otherwise, an empty tuple.
iv_values : tuple
The possible values of the IV. Empty for atomic orderings.
-
get_order
(data=None)¶ Get an order of conditions. For
Ordering
, always returns the same order.Parameters: data : dict, optional
A dictionary describing the data of the parent section. Unused for atomic orderings.
Returns: list of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
-
static
possible_orders
(conditions, unique=True)¶ Yield all possible orders of the conditions. Each order is a list of dictionaries, with each dictionary representing a condition.
Parameters: conditions : sequence of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
unique : bool, optional
If true (the default), will only return unique orders. If false, some identical orders will be generated if conditions contains identical elements.
-
-
class
experimentator.order.
Shuffle
(number=1, avoid_repeats=False)¶ Bases:
experimentator.order.Ordering
This ordering randomly shuffles the conditions.
Parameters: number : int, optional
Number of times each condition should appear (default=1). Conditions are duplicated before shuffling.
avoid_repeats : bool, optional
If True (default is False), no identical conditions will appear back-to-back.
-
get_order
(data=None)¶ Get an order of conditions. For
Shuffle
, returns the conditions in a random order.Parameters: data : dict, optional
A dictionary describing the data of the parent section. Unused for atomic orderings.
Returns: list of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
-
-
class
experimentator.order.
NonAtomicOrdering
(number=1)¶ Bases:
experimentator.order.Ordering
This is a base class for non-atomic orderings, and is not meant to be directly instantiated. Non-atomic orderings work by creating a new independent variable one level up. The IV name will start with an underscore, a convention to avoid name clashes.
-
get_order
(data=None)¶ Get an order of conditions.
Parameters: data : dict, optional
A dictionary describing the data of the parent section.
Returns: list of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
-
iv
¶ The IV associated with the non-atomic ordering. It will be added to the design one level up in the experiment hierarchy.
Returns: iv_name : str or tuple
The name of the IV, for non-atomic orderings. Otherwise, an empty tuple.
iv_values : seq
The possible values of the IV. Empty for atomic orderings.
-
-
class
experimentator.order.
CompleteCounterbalance
(number=1)¶ Bases:
experimentator.order.NonAtomicOrdering
In a complete counterbalance design, every unique ordering of the conditions appears the same numbers of times.
Parameters: number : int, optional
The number of times each condition should be duplicated. Note that conditions are duplicated before determining the possible orderings.
Notes
The number of possible orderings can get very large very quickly. Therefore, a complete counterbalance is not recommended for more than 3 conditions. The number of unique orderings can be determined by
factorial(number * k) // number**k
, where k is the number of conditions (assuming all conditions are unique). For example, with 5 conditions there are 120 possible orders; with 3 conditions andnumber==2
, there are 90 unique orders.-
first_pass
(conditions)¶ Handle operations that should only be performed once, initializing the object before ordering conditions. For
CompleteCounterbalance
, all possible orders are determined. This method should not be called manually.Parameters: conditions : sequence of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
Returns: iv_name : str or tuple
The name of the IV to be created one level up,
'counterbalance_order'
.iv_values : tuple
Values of the IV to be created one level up, integers each associated with an order of the conditions.
-
-
class
experimentator.order.
Sorted
(number=1, order='both')¶ Bases:
experimentator.order.NonAtomicOrdering
Sorts the conditions based on the value of the IV defined at its level. This ordering can be non-atomic (if order
== 'both'
), creating an IV with levels of'ascending'
and'descending'
. If order is'ascending'
or'descending'
, the ordering will be atomic (each section will be ordered the same way).Parameters: order : {‘both’, ‘ascending’, ‘descending’}, optional
The order to sort the sections. If
'both'
(the default), half the sections will be created in ascending order, and half in descending order, depending on the value of the new IV'sorted_order'
.number : int, optional
The number of times each condition should appear.
Notes
To avoid ambiguity,
Sorted
can only be used at levels containing only one IV.-
first_pass
(conditions)¶ Handle operations that should only be performed once, initializing the object before ordering conditions. For
Sorted
, the conditions are sorted on IV values.Parameters: conditions : sequence of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
Returns: iv_name : str or tuple
If order is
'both'
, the name of the IV to be created one level up,'sorted_order'
. Otherwise, an empty tuple (denoting that no IV will be created).iv_values : tuple
If order is
'both'
, values of the IV to be created one level up, integers each associated with an order of the conditions. Otherwise, empty tuple.
-
get_order
(data=None)¶ Get an order of conditions.
Parameters: data : dict, optional
A dictionary describing the data of the parent section.
Returns: list of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
-
-
class
experimentator.order.
LatinSquare
(number=1, balanced=True, uniform=False)¶ Bases:
experimentator.order.NonAtomicOrdering
Orders the conditions by constructing an NxN Latin square, where N is the number of unique conditions. A Latin square is a matrix with each element appearing exactly once in each row and column. Each row represents a different potential ordering of the conditions. This allows for balanced counterbalancing, in designs too large to accommodate a complete counterbalance.
Parameters: number : int, optional
The number of times the Latin square should be repeated (default=1). Duplication occurs after constructing the square.
balanced : bool, optional
If True (the default), first-order order effects will be balanced Each condition will appear the same number of times immediately before and immediately after every other condition. Balanced latin squares can only be constructed with an even number of conditions.
uniform : bool, optional
If True (default is False), the Latin square will be randomly sampled from a uniform distribution of Latin squares of size NxN. Otherwise, the sampling will be biased. The construction of balanced, uniform Latin squares is not implemented.
Notes
The algorithm for computing unbalanced Latin squares is not very efficient. It is not recommended to construct unbalanced, uniform Latin squares of order above 5; for non-uniform, unbalanced Latin squares it is safe to go up to an order of 10. Higher than that, computation times increase rapidly.
The algorithm for computing balanced Latin squares is fast only because it is not robust; it is very biased and only samples from the same limited set of balanced Latin squares. However, this is usually not an issue. For more implementation details, see
latin_square
andbalanced_latin_square
.-
first_pass
(conditions)¶ Handle operations that should only be performed once, initializing the object before ordering conditions. For
LatinSquare
, the square is constructed.Parameters: conditions : sequence of dict
A list of conditions, where each condition is a dictionary mapping IV names to IV values.
Returns: iv_name : str or tuple
The name of the IV to be created one level up,
'latin_square_row'
.iv_values : tuple
Values of the IV to be created one level up, integers each corresponding to one row of the Latin square.
-