Coding a full experiment§
In this session we’ll create an experiment from scratch using only Python code.
In this session we’ll create an experiment from scratch using only Python code.
Synopsis of the study:
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.:
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
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.
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()
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 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.
This has various knock-on effects:
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
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!
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.
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’.
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']
This 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.
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
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.
We can import conditions from either .xlsx or .csv files.
Create a file with:
For the Posner task we need control of:
For analysis it’s handy also to store:
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”
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.
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.
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()
...
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()
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
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!)
For today the ExperimentHandler isn’t strictly needed, but it allows some nice things so we’ll use it:
All we need to do is:
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
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.
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()
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!
In code:
If I push these changes to pavlovia, you can see the changes we make to the task throughout task creation…
There are a few problems with this version, that we could definitely improve on. Currently:
Hopefully you’ve learned how to: - create and present stimuli - set timings - receive responses from a keyboard - save data in various formats
What next? Improvements