Coding a full experiment

Why code an experiment?

In this session we’ll create an experiment from scratch using only Python code. We’ve provided more specific detail on Python syntax and objects later in this material for future reference.

Why code an experiment?

Why are we coding an experiment when we just spent two days telling you even good coders should use builder?

Why code an experiment?

To start with we will learn the fastest way to code an experiment (with the simplest code), but this doesn’t mean it is the “best” way - so do check out the suggested Improvements.

The Posner Cuing task

Synopsis of the study:

How do we even start?

Don’t be tempted to try and write a script from beginning to end in one go! Break it down into chunks that you can manage. e.g.:

Basic Lingo

{'name':'Becca', 'height':165, 'hungry': True, 'food choices':['chocolate', 'pizza', 'cheese']}

Create a window

First we need to import the necessary libraries. For an experiment we nearly always need to import the visual, event, data and core modules from PsychoPy:

from psychopy import visual, event, core, data, gui

then creating a window is another single line. We’ll use units of pixels for the window for simplicity. Then all our stimulus dimensions will be in pixels:

win = visual.Window([1024,768], fullscr=False, units='pix')

Save your experiment and run it to make sure a window flashes up.

Our trial starts with a fixation

Immediately after creating our window we usually initialise objects like stimuli and clocks:

#initialise some stimuli
fixation = visual.Circle(win, size = 5,
    lineColor = 'white', fillColor = 'lightGrey')

Later (on each trial) we’ll need to draw the fixation point and then flip the screen so that the drawing becomes visible:

#run one trial
fixation.draw()
win.flip()

Create your probe stimulus

Just for variety, let’s create a Gaussian spot for the probe. You need this code where your stimuli are being initialised (doesn’t matter if it’s before or after probe):

probe = visual.GratingStim(win, size = 80, # 'size' is 3xSD for gauss,
    pos = [300, 0], #we'll change this later
    tex = None, mask = 'gauss',
    color = 'green')

After drawing the fixation point and flipping, we need to do the same for the probe:

probe.draw()
win.flip()

We also need a cue

We could use some image of an arrow for this. Or we could create some shape of our own with custom vertices:

cue = visual.ShapeStim(win,
    vertices = [[-30,-20], [-30,20], [30,0]],
    lineColor = 'red', fillColor = 'salmon')

Also add draw() code like the other objects. Again, it doesn’t matter the order we initialise it, but the drawing needs to be between the fixation and the probe.

Understanding Window.flip()

Understanding Window.flip()

This has various knock-on effects:

Note

If you want to check how reliable your frame rate is. Open PsychoPy coder, select “Demos > timing > timeByFrames.py” this will show you a frequency distribution of the recorded frame intervals. On a 60Hz monitor, you would want a tight normal distribution around 16.66ms.

Set some timing parameters

If you run now the objects will be presented for a single frame each (1/60th of sec). That’s too short for us to see. We need to set times for our objects. we can achieve that with the core.wait() function.

Possible: “hard code” the values by typing them where needed.

Better: store them as variables at the top of the script

Even better: store them in a dictionary that we can save easily in the data files:

info = {} #a dictionary
info['fixTime'] = 0.5 # seconds
info['cueTime'] = 0.2
info['probeTime'] = 0.2

Pause after flipping the window for each object

Add a line to wait after each flip of the window:

# run one trial
fixation.draw()
win.flip()
core.wait(info['fixTime'])

cue.draw()
win.flip()
core.wait(info['cueTime'])

probe.draw()
win.flip()
core.wait(info['probeTime'])

This is not actually a very precise way to control timing, but it’s very easy!

Drawing two objects at the same time

If you draw() two stimuli before a win.flip() then they both appear on the same frame. For the probe presentation let’s have the fixation as well:

fixation.draw()
probe.draw()
win.flip()
core.wait(info['probeTime'])

If the stimuli overlap in space then the later draw() will occlude the earlier one. You can also set the degree of opacity of stimuli so that they are partially visible.

Let’s run two trials

We could copy and paste the trial code to run repeated trials.

Try doing that so that you get two repeats of the trial.

That’s very inefficient though, so undo it.

Exercise: Why not create a loop to run as many trials as you like? That would be more efficient. Add a for… loop and indent your trial code so that it is ‘contained’ in the loop. Set the loop to run for 5 ‘repeats’.

Let's run two trials

Solution:

for trial in range(5):
    fixation.draw()
    win.flip()
    core.wait(info['fixTime'])

    cue.draw()
    win.flip()
    core.wait(info['cueTime'])

    probe.draw()
    win.flip()
    info['probeTime']

Let’s run two trials

Remember us mentioning makign an experiment was a good way to learn to code? Well, this is a useful point for us to practice different methods for Loops in Python.

TrialHandler

The TrialHandler allows you to run multiple trials of different conditions in various ways (random or sequential etc.). It lives in the PsychoPy’s data module, which we already imported. You can think of it as representing the properties of a loop dialogue box in builder.

To repeat our trials using the TrialHandler instead of the basic for loop we can do this:

trials = data.TrialHandler(trialList=[], nReps=5)
for thisTrial in trials:
    #code to run one trial here

For now we’ve set the trialList simply to an empty list, but later we’ll change that.

The code above needs to come somewhere after you initialise your stimuli and it needs to include your trial code

Controlling conditions

We need the stimuli to differ on each trial, which TrialHandler can also help us with. It expects to receive conditions (aka trialTypes) as a list of dictionaries, where one dictionary specifies the parameters for one condition. We could write that by code using a for…loop, but it might be easier this time to use a spreadsheet.

You could have achieved exactly the same as this using code to create a list of dictionaries with one dictionary for each type of trial in your conditions.

Create a conditions file

We can import conditions from either .xlsx or .csv files (the same way we do in builder).

Create a file with:

Create a conditions file

For the Posner task we need control of:

For analysis it’s handy also to store:

Create a conditions file

So we might have a sheet like this:

cueOri probeX valid descr
0 300 1 right
180 -300 1 left
0 300 1 right
180 -300 1 left
0 300 1 right
180 -300 1 left
0 300 1 right
180 -300 1 left
180 300 0 conflict
0 -300 0 conflict

Save the file in xlsx or csv format. e.g. “conditions.csv”

Import that file and put it to use

The data module in PsychoPy has a function to import such files. It gives a list of dicts that can be used directly in the TrialHandler:

conditions = data.importConditions('conditions.csv')
trials = data.TrialHandler(trialList=conditions, nReps=5)
for thisTrial in trials:
    #code to run one trial here
    ...

This will run 5 repeats of our 10 trial types randomly. The way we’ve set this up we’ll get 50 trials with 80% valid probes.

Updating stimuli

Each time through the loop the value thisTrial is a dictionary for one trial, with keys that have the column names:

for thisTrial in trials:
    #code to run one trial here
    probe.setPos( [thisTrial['probeX'], 0] )
    cue.setOri( thisTrial['cueOri'] )

You can see the code changes here through looking at the version history on the gitlab project page.

Collect responses

Now let’s get a key-press after each trial and measure the reaction time (RT).

Before starting our trials we could create a clock/timer to measure response times:

respClock = core.Clock()

Then when we present our stimulus we could reset that clock to zero:

fixation.draw()
probe.draw()
win.flip()
respClock.reset()
...

Collect responses

After our stimulus has finished we should flip the screen (without doing any drawing so it will be blank) and then wait for a response to occur:

#clear screen
win.flip()
#wait for response
keys = event.waitKeys(keyList = ['left','right','escape'])
resp = keys[0] #take first response
rt = respClock.getTime()

Collect responses

Check if that response was correct:

if thisTrial['probeX']>0 and resp=='right':
    corr = 1
elif thisTrial['probeX']<0 and resp=='left':
    corr = 1
else:
    corr = 0

Collect responses

And store the responses in the TrialHandler:

trials.addData('resp', resp)
trials.addData('rt', rt)
trials.addData('corr', corr)

(Note that we aren’t saving the data file yet though!)

Using the ExperimentHandler

For today the ExperimentHandler isn’t strictly needed, but it allows some nice things so we’ll use it:

Note

The experiment handler kind of represents your flow in builder, it can handle several loops and routines. You can also make useful calls like thisExp.addData() and thisExp.nextEntry()

Using the ExperimentHandler

All we need to do is:

Create a base filename

Let’s create a filename using the participant name and the date. OK, so we’ll need to get those!

For the username, we can easily create a dialog box that uses our info dictionary to store information (top of our script):

info = {} #a dictionary
#present dialog to collect info
info['participant'] = ''
dlg = gui.DlgFromDict(info) #(and from psychopy import gui at top of script)
if not dlg.OK:
    core.quit()
#add additional info after the dialog has gone
info['fixTime'] = 0.5 # seconds
info['cueTime'] = 0.2
info['probeTime'] = 0.2
info['dateStr'] = data.getDateStr() #will create str of current date/time

Create a base filename

Now we’ve collected the information there are various ways to create our filename string. All of these achieve the same thing, e.g. data/jwp_2014_Apr_13_1406

filename = "data/" + info['participant'] + "_" + info['dateStr']
filename = "data/%s_%s"%(info['participant'], info['dateStr'])
filename = "data/{0}_{1}".format(info['participant'], info['dateStr'])
filename = "data/{0['participant']}_{0['dateStr']}".format(info)
filename = "data/{participant}_{dateStr}".format(**info)

You can see them looking increasingly obscure, but increasingly brief.

Create ExperimentHandler

After your code to create the TrialHandler loop:

#add trials to the experiment handler to store data
thisExp = data.ExperimentHandler(
        name='Posner', version='1.0', #not needed, just handy
        extraInfo = info, #the info we created earlier
        dataFileName = filename, # using our string with data/name_date
        )
thisExp.addLoop(trials) #there could be other loops (like practice loop)

AND at the end of the response collection we need to inform the experiment handler that it’s time to consider the trial complete:

...
trials.addData('rt', rt)
trials.addData('corr', corr)
thisExp.nextEntry()

Quiting during a run

Let’s make it possible to end the experiment during a run using the ‘escape’ key

Where you checked your responses we need to add something to handle that:

elif resp=='escape':
    trials.finished = True

Alternatives to trials.finished=True

break #will end the innermost loop, not necessarily `trials`
core.quit() #from psychopy lib will exit Python

NB: If you hit the red stop button in PsychoPy it issues a very severe abort and no data will be saved!

Exercise

You can use the Coder “demos” to help find relevant code snippets.

In code:

  1. Store if the participant was correct or incorrect to the data file.
  2. Add some feedback text to tell the participant if they were correct or incorrect.
  3. Make this feedback green/red for correct/incorrect responses.
  4. Add instructions to the start of the experiment.

All done!

If I push these changes to pavlovia, you can see the changes we make to the task throughout task creation…

Improvements

There are a few problems with this version, that we could definitely improve on. Currently:

Summary

Hopefully you’ve learned how to: - create and present stimuli - set timings - receive responses from a keyboard - save data in various formats

Summary

What next? Improvements