Friday, August 8, 2008

A skeleton Experiment class

I've experimented with lots of different ways of organizing my PyEPL experiment code. I like to place different parts of the experiment in different functions - this makes it easier to figure out what's going on where, and helps greatly when you want to reuse pieces in future experiments. Because most experiments require you to keep track of lots of variables, I'd often end up feeding many, many parameters into each of my functions, e.g.


############################################################
def individStimulus(sessionlog,
video, audio, backgrounds, clips,
clock, bc, wp, state,
config, study_run_no, wordno, taskno, prevTask):
...


Sadly, that is a real example.

In order to avoid having to pass the same horde of variables into each function (VideoTrack, PresentationClock etc.), I tried creating a bunch of global variables that would be shared throughout my experiment code. I'd initialize them all at the very top, but then every function that used any of them would have to declare those variables as global, e.g.


############################################################
def prepare():
"""
Prepare the sequences.
"""

global exp
global config
global video
global audio
global keyboard
global sessionlog
global cueslog
global clock
global study_seqs
global loci_pool
global items_pool


This was really no better. In fact, there's a potential gotcha awaiting the unwary when using global variables inside a function if you forget to declare the variables as global, since Python will simply assume that these are new local variables of the same name (see Example 2).

Now, older and wiser, I have realized that the one true way of organizing your experiment code is to subclass the PyEPL Experiment class, and make use of member variables. Describing classes is beyond the scope of this document, but see here for more info on learning Python if you're unfamiliar with object-oriented programming.

Without further ado, here's a skeleton Experiment subclass that you can build on when first starting a new experiment:

my_exp.py


#!/usr/bin/python

from pyepl.locals import *
import shutil

# only add this if there really is a good reason to exclude
# earlier versions. i like 1.0.29 because it adds
# functionality for resetting accumulated timing error
MIN_PYEPL_VERSION = '1.0.29'


# TODO: change the classname to something more appropriate
# for your experiment
class MyExperiment(Experiment):

############################################################
def __init__(self,**kwargs):
"""
Create the Experiment superclass, set basic
Experiment stuff up, get the config, and prepare all
our main Experiment member variables.
"""

# this is all standard
#
# initialize the Experiment superclass
Experiment.__init__(self,**kwargs)

# allow users to break out of the experiment with
# escape-F1 (the default key combo)
self.setBreak()
# get config
self.cfg = self.getConfig()

# if you have any randomization, you'll probably
# want to make sure it's different very time. i
# think python automatically resets the seed each
# time, but i keep this in just in case
random.seed()

# set up the logs
self.vid = VideoTrack("video")
self.key = KeyTrack("keyboard")
# this is the one i use for storing all my main
# experiment data
self.slog = LogTrack("session")

# set up the presentation clock
try:
# requires 1.0.29, auto-adjusts timing if there's a lag
self.clk = PresentationClock(correctAccumulatedErrors=True)
except:
# if you don't have 1.0.29 loaded, then just
# fall back (since the timing will probably be
# fine anyway)
self.clk = PresentationClock()

self.instr = open(self.cfg.instr_fname,'r').read()

# copy the instructions text into the data directory
# for this session, so that you can refer to it
# later if you need to
shutil.copy(self.cfg.instr_fname, self.session.fullPath())

self._resetVideo()

# TODO call any stimulus generation or other
# preparation functions that you need to get things ready
# for the experiment to run.
self._prepare()

self._sanity_check()


############################################################
def _prepare(self):
"""
Set everything up before we try and run.
"""

# we'll make use of this later
self.greeting_msg = 'Hello'


############################################################
def _resetVideo(self):
"""
And then there was no light.
"""

self.vid.clear("black")
self.vid.updateScreen(self.clk)


############################################################
def _sanity_check(self):

"""
This is where i confirm that everything has been set
up correctly and that everything we'll need for the
rest of the experiment is in place (so that any
problems cause a crash now rather than halfway
through an experiment).

For instance, it's a good place to make sure any
basic constraints are adhered to in your config.
"""

# TODO - add your sanity checks here
assert 2 + 2 == 4


############################################################
def go(self):
"""
Run the entire loci1h experiment
"""

# log start of experiment
self.slog.logMessage('SESS_START')

# tell the subject what's about to happen
instruct(self.instr)

# TODO this is where your experiment code kicks off
flashStimulus(Text(self.greeting_msg),duration=3000)

self.slog.logMessage('SESS_END')

Text("Thank you!\nYou have completed the session.").present(
clk=self.clk,duration=5000)



############################################################
def main_interactive(config='config.py'):

"""
If you want to run things interactively from ipython,
call this. It's basically the same as the main()
function.

from my_exp import *; exp = main_interactive()

OR if you want to load in a special config file:

from my_exp import *; exp = main_interactive('debug_config.py')
"""

exp = MyExperiment(subject='subj00',
fullscreen=False,
resolution=(640,480),
config=config)

exp.go()

return exp



############################################################
if __name__ == "__main__":
# make sure we have the min pyepl version
checkVersion(MIN_PYEPL_VERSION)

# start PyEPL, parse command line options, and do
# subject housekeeping
exp = MyExperiment()

# now run the subject
exp.go()


Notice for instance how the _prepare() function creates a member variable (self.greeting_msg). That gets stored in the MyExperiment object, and it's ready to be deployed in go() by the flashStimulus call. No passing around of arguments, no global variables, and it's easy to subclass this MyExperiment class as MySlightlyImprovedExperiment, just overriding the functions that differ.

I've marked places where you'll almost certainly want to modify and add code for your experiment with a 'TODO' comment. Save the above as my_exp.py.

You'll also need a config.py:

config.py


instr_fname = 'instr.txt'


and some instructions:

instr.txt


These are the instructions. You're about to be greeted by
the program.

Press UP/DOWN or PgUP/PgDOWN to scroll these
instructions. When you get to the bottom of the page, please
discuss what you've read with the experimenter to be sure
that everything is clear, and then press RETURN to move on.

--- please discuss what you have read with the experimenter ---

--- then press RETURN to continue ---


Put all of them in a directory, and you should be able to run them with:


python my_exp.py -s subj00 --resolution 640x480 --no-eeg --no-fs


or fire up python and type:


from my_exp import *; exp = main_interactive()

No comments: