The exptools
Python package provides a way to easily and quickly create (psychophysics) experiments with accurate ("non-slip") timing. It is basically a wrapper around Psychopy which automates the boring but important parts of building experiments (such as stimulus timing and logging), while maintaining the flexibility of Psychopy in terms of how you want to present your stimuli and run your experiment.
The latest master branch on github can be installed by creating a conda environment using the following commands:
conda create -n exptools2 python=3.9
conda activate exptools2
conda install numpy scipy matplotlib pandas pyopengl pillow lxml openpyxl xlrd configobj pyyaml gevent greenlet msgpack-python psutil pytables requests[security] cffi seaborn wxpython cython pyzmq pyserial qt pyqt
conda install -c conda-forge pyglet pysoundfile python-bidi moviepy pyosf
pip install zmq json-tricks pyparallel sounddevice pygame pysoundcard psychopy_ext psychopy
pip install git+https://github.com/VU-Cog-Sci/exptools2/
For using the eyetracker, you also need to install pylink
.
If you want to run a exptools2
-script, you now should always start by activating the exptool2
-conda environment. This is done in a shell by typing:
conda activate exptools2
If you have the EyeLink
-folder from SRResearch
, you can install pylink
for various python versions. You can find the wheels in /usr/share/EyeLink/SampleExperiments/Python/wheels
, which are directly pip-installable for your given python version.
The package is not yet pip-installable. To install it, clone the repository (git clone https://github.com/VU-Cog-Sci/exptools2.git
) and install the package (python setup.py install
). The package assumes that the following dependencies are installed:
psychopy>=3.0.5
pyyaml
pyglet==1.3.2
pandas>=0.23.0
numpy>=1.14
msgpack_numpy
matplotlib
If you want to use the eytracker functionality with Eyelink eyetrackers, you also need the pylink
package (for Python3!) from SR Research. This is not yet publicly available; if you need it, send Lukas an email.
You're getting a pyglet
error when exptools2
tries to initialize a Window.
This is a weird bug caused when installing a pyglet
version > 1.3.2. Uninstall pyglet
and install version 1.3.2. specifically (pip install pyglet==1.3.2
).
You're getting a pylink
error when exptools2
tries to initialize the Eyelink eyetracker.
Did you install the pylink
library (for Python 3.6)? Note that this is not yet publicly available, but Lukas has beta builds (for Windows/Mac/Linux) available, so send him an email if you need this. Another issue could be that you're using Python 3.7, which is not compatible with the pylink
package (yet).
The package assumes that your experiment (or session) consists of a predetermined number of trials, which may in turn consist of a number of phases. For example, in a Stroop-experiment, a session may consist of 100 trials, which consist of two phases: a phase in which the stimulus (usually the word for a color, like "red", in a particular color) is shown, and another phase (the "interstimulus interval", ISI) in which a fixation dot is shown. Usually, you want your trials, and their phases, to have a predetermined onset and duration. This is especially relevant in studies in which concurrent fMRI, EEG/MEG, or eye gaze/pupil size is recorded. In exptools2
, dedicated classes for functionality related to your session (the Session
class) and your trials (the Trial
class) are provided. Below, we explain these two classes in more detail.
In the core
module of exptools
, the Session
class is defined. This class represents a "template" for experimental sessions, which contains functionality/boilerplate code for creating a (Psychopy) window, stimulus/response logging, among other things.
The base Session
class is not meant to be used directly; instead, if you want to use its functionality in your own experiment, you should create your own class that inherits from the base Session
class. For example, suppose that we want to implement a Stroop-experiment (we'll use this example throughout the docs). We can create a custom subclass based on Session
as follows:
from exptools2.core import Session
class StroopSession(Session):
pass
Right now, the example StroopSession
class is a copy of the base Session
class, just with a different name. This is of course not how we want to use it: we want to adapt it such that it is specific to our experiment! We may even want to modify the way we initialize a StroopSession
object. For example, suppose that we want to add an attribute called n_trials
to our object during initialization (which may be used later in other methods). As such, we should overwrite the class' __init__
method:
class StroopSession(Session):
def __init__(self, output_str, output_dir, settings_file, n_trials):
""" Initializes StroopSession object.
Parameters
----------
output_str : str
Basename for all output-files (like logs), e.g., "sub-01_task-stroop_run-1"
output_dir : str
Path to desired output-directory (default: None, which results in $pwd/logs)
settings_file : str
Path to yaml-file with settings (default: None, which results in the package's
default settings file (in data/default_settings.yml)
n_trials : int
Number of trials to present (a custom parameter for this class)
"""
super().__init__(output_str, output_dir, settings_file) # initialize parent class!
self.n_trials = n_trials # just an example argument
Note that we're still calling the parent's __init__
method (the super().__init__()
call), because this is executing the boilerplate code that is needed to setup any Session
! Note that the base Session
class is initialized with three arguments: output_str
, output_dir
, and settings_file
, of which only output_str
is mandatory. Don't forget to add these arguments to the __init__
method of your custom class! After calling the super().__init__()
function, you may add whatever you like, such as binding the n_trials
variable to self
.
Now, before we explain the other important aspects of (custom) Session
objects, we need to digress slightly and talk about the settings-file.
An important part of exptools2
is the settings-file, which is needed by the Session
class (and thus every custom session class which inherits from Session
). The package contains a default settings-file (in data/default_settings.yml
), which is used when you do not provide a custom settings-file to the session object during initialization. This is fine for testing your experiment, but for your "real" experiment, you should provide your own (custom) settings-file that is specific to your experiment. Your custom settings-file does not have to contain all possible settings; those settings that are not listed in your custom settings-file will be "inherited" from the default-settings file (which contain sensible defaults).
Your custom settings-file should be a YAML file, i.e., it should use the YAML-specific syntax. Any settings-file may contain the following top-level items: preferences
, window
, monitor
, mouse
, eyetracker
, and mri
. Each top-level item may contain one or more "key: value" pairs, in which the "key" represents the name of the particular parameter and the "value" represents the actual value of the parameter. For example, the monitor
top-level item contains (amongst others) the parameters name
, width
, distance
, and gamma
. To specify your experiment-specific parameters for these settings, include the following in your settings-file:
# Note that you may indent your file with any number of spaces, as long as it's consistent
monitor:
name: monitor_lab201
width: 50 # width of monitor (in cm)
distance: 80 # distance of participant from monitor (in cm)
gamma: 1 # specific value for gamma correction
It is important to set these parameter values specific to your experiment, for example if you want to specify the size of stimuli in visual degree angle (the default of exptools
). Within the base Session
, the parameters for the monitor
settings will be used for initializing a Psychopy Monitor
object. As such, you can include any argument from the Psychopy Monitor
class in your settings file. For example, if you want to set the verbose
parameter of the Monitor
object to True
, you could simply include this parameter in the settings-file:
monitor:
name: monitor_lab201
width: 50 # width of monitor (in cm)
distance: 80 # distance of participant from monitor (in cm)
gamma: 1 # specific value for gamma correction
verbose: True
The same idea applies to the window
item (which refers to the parameters for the Psychopy Window
class), the mouse
item (which refers to the parameters for the Psychopy Mouse
class), the preferences
item (which refers to the Psychopy prefs
class), and the mri
item (which refers to the Psychopy SyncGenerator
class).
For example, amongst many other arguments, the Psychopy Window
class contains the argument units
(which sets the default units for stimulus size). If you want to set this, e.g., to pixels (pix
) instead of visual degree angle (deg
, the package's default), you include the following in your custom settings-file:
window:
units: pix
If you specify a parameter in your custom settings-file that is also included in the default settings-file (such as size
in the window
top-level item), your custom parameter will overwrite the default. Also, any parameter that is not explicitly set in your settings-file will inherit the default from the default settings-file.
Unlike the other top-level items in settings-files, parameters under the eyetracker
item do not specifically refer to the arguments of a particular Psychopy class. Instead, it contains three "main" parameters: address
, referring to the Eyelink eyetracker IP, dot_size
, referring to the size of dots during calibration in visual degree angle, and most importantly options
, which contains lower-level key-value settings. These settings under options
correspond to Eyelink specific settings (which can be found in the Eyelink Programmer's Guide). For example, to set the calibration_type
(default: HV9
, i.e., 9-point calibration) to HV3
(i.e., 3-point calibration), you can include the following in your settings-file:
eyetracker:
address: '100.1.1.1'
dot_size: 0.1 # in deg
options:
calibration_type: HV3
As outlined before, any session should contain a (predefined) number of trials. In exptools
, we recommend that you create your trials, which are operationalized as Trial
objects from the exptools2
-specific Trial
class, before you run your session (we'll explain how to do this later). Then, once you have created your trials and stored this, e.g., in an attributed called trials
, you can start
your experiment, loop over your trials (i.e., run
them one by one), and finally close
your session. Below, we outline how our example GstroopSession
may look like:
class StroopSession(Session):
def __init__(self, output_str, output_dir, settings_file, n_trials):
super().__init__(output_str, output_dir, settings_file) # initialize parent class!
self.n_trials = n_trials # just an example argument
self.trials = [] # will be filled with Trials later
def create_trials(self):
""" Creates trials (ideally before running your session!) """
for i in range(self.n_trials):
self.trials.append(<<<your trial object>>>)
def run(self):
""" Loops over trials and runs them! """
self.create_trials() # create them *before* running!
self.start_experiment()
for trail in self.trials:
trial.run()
self.close()
As you can see above, custom sessions should do three things:
- create trials before running your session (e.g., in a method called
create_trials
, but you may call/implement this any way you like); - call
self.start_experiment()
whenever you want to start running your trials (this method sets the timer which keeps track of trial/phase onsets); - loop over trials and run them (using the
run
method ofTrial
objects, which are explained in the next section); - call
self.close()
after rpass unning all the trials (which does some housekeeping, writes out the logfile, etc.)
You may include your call to start_experiment
and your loop over trials in a method called run
(like in the example above) but this is not mandatory (but we believe it's a nice way of structuring your code). With this setup, your class is ready to be used! You could for example run your session in the same file as you implemented your custom session by including the following at the very bottom of the file:
# your custom class should be defined above
if __name__ == '__main__':
my_sess = StroopSession('sub-01', output_dir='~/logs', settings_file='settings.yml')
my_sess.run()
After your session finished running, there should be an (BIDS-formatted) events-file in the specified output_dir
(with the format {output_str}_events.tsv
) along with some extra information (such as the Psychopy specific logfile and an image with the frame-intervals, which gives information about potential stimulus timing issues).
Now, let's discuss these Trial
objects that we discussed earlier!
Next to the base Session
class, exptools2
also includes a "template" for trials with the (surprise surprise) Trial
class. This template again contains some boilerplate code that takes care of accurately timing (and logging) stimuli and responses and should be, just like the Session
class, not be directly used in your experiment. Instead, you should create a new class specific to your experiment that inherits from the base Trial
class. Let's do this for our Stroop-experiment (this may be implemented in the same file, e.g. stroop.py
, as your custom session class):
from exptools2.core import Trial
class StroopTrial(Trial):
pass
Now, we of course want to tailor this StroopTrial
class to our experiment. This most likely starts with defining the stimuli that you want to show during your trial; these stimuli can be (or actually, should be) defined using Psychopy objects (such as TextStim
, Circle
, ImageStim
, SoundStim
, etc.).
In a Stroop-task, this usually is a (colored) word and (afterwards) a fixation cross/dot. To reduce the chance of timing issues, we recommend initializing these stimuli "as soon as possible", for example, during initialization (i.e., within the __init__()
method). Let's do that for our StroopTrial
class. We'll build a very simple version of the Stroop-task, in which trials can be congruent (the word "red" in the color red) or incongruent (the word "red" in the color green). (Usually, the Stroop-task of course contains more words/colors.) As such, per trial, we have to define two stimuli: a fixation dot and a (colored) text-stimulus. (Ignore the initialization-parameters for now, which will be explained later.)
from psychopy.visual import Circle, TextStim
class StroopTrial(Trial):
def __init__(self, session, trial_nr, phase_durations, phase_names,
parameters, timing, load_next_during_phase,
verbose, condition='congruent'):
""" Initializes a StroopTrial object.
Parameters
----------
session : exptools Session object
A Session object (needed for metadata)
trial_nr: int
Trial nr of trial
phase_durations : array-like
List/tuple/array with phase durations
phase_names : array-like
List/tuple/array with names for phases (only for logging),
optional (if None, all are named 'stim')
parameters : dict
Dict of parameters that needs to be added to the log of this trial
timing : str
The "units" of the phase durations. Default is 'seconds', where we
assume the phase-durations are in seconds. The other option is
'frames', where the phase-"duration" refers to the number of frames.
load_next_during_phase : int (or None)
If not None, the next trial will be loaded during this phase
verbose : bool
Whether to print extra output (mostly timing info)
condition : str
Condition of the Stroop trial (either 'congruent' or 'incongruent')
"""
super().__init__(session, trial_nr, phase_durations, phase_names,
parameters, timing, verbose, load_next_during_phase)
self.condition = condition
self.fixation_dot = Circle(self.session.win, radius=0.1, edges=100)
if self.condition == 'congruent':
self.word = TextStim(self.session.win, text='red', color=(255, 0, 0)) # red!
else:
self.word = TextStim(self.session.win, text='red', color=(0, 255, 0)) # green!
Now, after initialization of a StroopTrial
object, it has the attributes fixation_dot
and word
which correspond to the stimuli that we want to show during this trial. Again, the reason we want to define our stimuli during initialization (as opposed to during "runtime") is that it takes a little bit of time to create these stimuli, which may negatively impact the timing/duration of your trials.
Now, before explaining the class arguments (such as session
, trial_nr
, phase_durations
, etc.), let's discuss the only thing that is missing from our custom StroopTrial
class: the draw
method. This method defines what happens (and when this happens) during our trial. You always need to define this method in your custom trials (otherwise exptools2
/Psychopy does not know what to do with your stimuli!). In this method is where the "phases" come in. As said, we assume that trials contain (one or more) phases, in which different things need to happen. Therefore, the structure of any draw
method is something along the lines of: "if we're in phase 0, then draw this stimulus, elif we're in phase 1, then draw this stimulus, etc.". So, for our Stroop-task, our method could look something like this:
class StroopTrial(Trial):
def __init__(self, session, trial_nr, phase_durations, phase_names,
parameters, timing, load_next_during_phase,
verbose, condition='congruent'):
""" Initializes a StroopTrial object. """
super().__init__(session, trial_nr, phase_durations, phase_names,
parameters, timing, verbose, load_next_during_phase)
self.condition = condition
self.fixation_dot = Circle(self.session.win, radius=0.1, edges=100)
if self.condition == 'congruent':
self.word = TextStim(self.session.win, text='red', color=(255, 0, 0)) # red!
else:
self.word = TextStim(self.session.win, text='red', color=(0, 255, 0)) # green!
def draw(self):
if self.phase == 0: # Python starts counting from 0, and so should you
self.fixation_dot.draw()
else: # assuming that there are only 2 phases
self.word.draw()
That's it! Of course, your draw
method may be much more complex depending on the number of stimuli/phases of your trials. You won't actually call the draw
method yourself; this happens in the run
method defined in the base Trial
class. Basically, this method loops over your custom draw
method for a prespecific time period and advances the phase (i.e., self.phase
) when the time period has finished. But how does exptools2
know how long to run a particular phase? This is were the arguments during initialization of your Trial
object come in! We'll discuss these parameters one by one, because they're quite important.
In order to run your trial correctly, the Trial
object should now some settings from the session, such as monitor settings, the session timer, etc. To allow access to this information about the session, we simply add the Session
object to the list of expected parameters of trials! This may be a bit counterintuitive, because we told you to create trials within the session -- so how should you pass the session object itself to your (custom) Trial
class upon initialization? Well, you probably guessed it from the phrasing: we can simply pass self
!
Let's take a look at how that would look like. Remember, we recommended creating your trials upfront (e.g., in a method called create_trials
within your custom session object). As such this method could look something like the following (note that we also add the trial_nr
here!):
import random
class StroopSession(Session):
def __init__(self, output_str, output_dir, settings_file, n_trials):
super().__init__(output_str, output_dir, settings_file) # initialize parent class!
self.n_trials = n_trails # just an example argument
self.trials = [] # will be filled with Trials later
def create_trials(self):
""" Creates trials (ideally before running your session!) """
conditions = ['congruent' if i % 2 == 0 else 'incongruent'
for i in range(self.n_trials)]
random.shuffle(conditions)
for i in range(self.n_trials):
trial = StroopTrial(session=self, trial_nr=i, condition=condition)
# ^It actually needs more arguments than just these two,
# which we'll explain later
self.trials.append(trial)
The phase_durations
arguments does what it suggest: it defines how long each phase in your trial should last. This should be a list-like object, but we recommend using a tuple for this. The length of your phase_durations
tuple should of course match the number of phases in your trial. If you use phase_durations=(1, 2)
, but you have not two, but three phases, your trial will never draw the stimuli of phase 3 (i.e., self.phase == 2; Python is 0-based)!
But what do this 1
and 2
refer to in the phase_durations
argument? This depends on the timing
argument! The timing
argument can take two values: either 'seconds'
(the default) or 'frames'
. So, settings phase_durations
to (1, 2)
and timing
to 'seconds'
will show phase zero for 1 second and phase one for two seconds. If you would set timing
to 'frames'
, however, it will show phase zero for 1 frame and phase one to 2 frames -- the exact duration in seconds, here, thus depends on the specific framerate of your monitor! Technically, the 'frames'
method should be more accurate in terms of duration, assuming that you don't drop any frames during your experiment. Use this method if timing/stimulus onsets are absolutely crucial (like in EEG/MEG experiments or subliminal/unconscious/masking tasks).
The phase_names
and parameters
arguments have to do with logging your trial-information. They are optional, but serve to make your logfile more information/more complete. Basically, exptools
will log each phase of every trial separately. So every phase will be logged as a separate row in your logfile (in addition to responses by the participant, which will also get their own row). By default, the logfile contains a column called event_type
, which will by default be "stim"
for every phase. But if you want to give every phase a separate name, you can assign a tuple (or list) of strings to phase_names
, e.g., phase_names=('word', 'fix')
for our StroopTrial
.
The parameters
argument servers a similar function. The argument, which should be a dictionary, allows you to add extra information to your logfile for that trial. For our StroopTrial
, this could for example be the condition
(i.e., either "congruent"
or "incongruent"
):
class StroopSession(Session):
def __init__(self, output_str, output_dir, settings_file, n_trials):
super().__init__(output_str, output_dir, settings_file) # initialize parent class!
self.n_trials = n_trials # just an example argument
self.trials = [] # will be filled with Trials later
def create_trials(self):
""" Creates trials (ideally before running your session!) """
conditions = ['congruent' if i % 2 == 0 else 'incongruent'
for i in range(self.n_trials)]
random.shuffle(conditions)
for i in range(self.n_trials):
trial = StroopTrial(
session=self,
trial_nr=i,
phase_durations=(2, 1),
timing='seconds',
phase_names=('word', 'fix'),
parameters={'condition': conditions[i]},
condition=conditions[i]
)
self.trials.append(trial)
Setting the verbose
argument to True
prints a bunch of stuff to the terminal while running your experiment (such as timing/onset of phases/trials) which may be nice during testing/debugging your experiment. As printing to stdout
takes non-trivial amount of time, set this parameter to False
when you're running your experiment for real.
This option (default None
) is quite "advanced". It allows you to specify a particular phase during which exptools2
should load the next trial. This option is useful when you don't want to initialize all trials before running your trial-loop, for example when this would take very long time (e.g., when loading thousands of images in a rapid visual processing experiment). When using this method, your session class should have a method called create_trial
with a single argument reflecting the index of the trial that should be loaded. You are responsible of making sure that, given a particular trial-index, the correct trial will be loaded. To "load" a trial, you could append it to a list of trials, e.g., self.trials
:
class SessionWithManyImages(Session):
# assume that self.trials = [] is created upon initialization
def create_trial(self, trial_nr):
trial = YourTrials(
session=session,
trial_nr, trial_nr,
phase_durations=(1, 1),
load_during_next=1 # load next trial during phase 1
)
self.trials.append(trial)
def run(self):
self.create_trial(trial_nr=0) # set first trial
for i in range(10): # assuming that there are 10 trials
self.trials[i].run()
import random
from exptools2.core import Trial, Session
from psychopy.visual import TextStim, Circle
class StroopTrial(Trial):
def __init__(self, session, trial_nr, phase_durations, phase_names,
parameters, timing, load_next_during_phase,
verbose, condition='congruent'):
""" Initializes a StroopTrial object. """
super().__init__(session, trial_nr, phase_durations, phase_names,
parameters, timing, load_next_during_phase, verbose)
self.condition = condition
self.fixation_dot = Circle(self.session.win, radius=0.1, edges=100)
if self.condition == 'congruent':
self.word = TextStim(self.session.win, text='red', color=(1, 0, 0)) # red!
else:
self.word = TextStim(self.session.win, text='red', color=(0, 1, 0)) # green!
def draw(self):
if self.phase == 0: # Python starts counting from 0, and so should you
self.word.draw()
else: # assuming that there are only 2 phases
self.fixation_dot.draw()
class StroopSession(Session):
def __init__(self, output_str, output_dir, settings_file, n_trials):
super().__init__(output_str, output_dir, settings_file) # initialize parent class!
self.n_trials = n_trials # just an example argument
self.trials = [] # will be filled with Trials later
def create_trials(self):
""" Creates trials (ideally before running your session!) """
conditions = ['congruent' if i % 2 == 0 else 'incongruent'
for i in range(self.n_trials)]
random.shuffle(conditions)
for i in range(self.n_trials):
trial = StroopTrial(
session=self,
trial_nr=i,
phase_durations=(2, 1),
timing='seconds',
phase_names=('word', 'fix'),
parameters={'condition': conditions[i]},
load_next_during_phase=None,
verbose=True,
condition=conditions[i]
)
self.trials.append(trial)
def run(self):
self.create_trials()
self.start_experiment()
for trial in self.trials:
trial.run()
self.close()
if __name__ == '__main__':
my_sess = StroopSession('sub-01', '~/logs', '/Users/lukas/settings.yml', n_trials=10)
my_sess.run()
TBD