BACK TO Level 0: A New Language
BACK TO Level 1: Manipulating Variables
BACK TO Level 2: Conditionals and Loops
BACK TO Level 3: PsychoPy 101
BACK TO Level 4: PsychoPy - Showing windows and stimuli
We are already familiar with using keypresses using event.waitKeys(), but this class has a lot more to it than just controlling the sequence of an experiment. We can also use it to store keypresses for response collection. To store a list of keys, simply define "keys":
from psychopy import event, visual, monitors
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
my_text = visual.TextStim(win)
nTrials=10
for trial in range(nTrials):
my_text.text = "Please make a keypress for trial " + str(trial)
My_text.draw()
win.flip()
keys = event.waitKeys()
print(keys)
win.close()
Running this code, you will find that you are only able to make a single keypress for each trial. This is because we have made a keypress a necessary step to move on to the next trial (waitkeys). The experiment waits for any keypress, and as soon as the condition is met, it goes on to the next trial. You can see the name of the key when it is printed. Note that keypresses are always stored as strings.
If you want to make keypresses independent of the trial flow, use event.getKeys:
from psychopy import core
for trial in range(nTrials):
core.wait(2)
keys=event.getKeys()
print(keys)
win.close()
The difference between event.waitKeys and event.getKeys is that the former will stop the experiment until a key is pressed, whereas the latter is independent of trial flow. In the example above, you can make as many keypresses as you want within 2 seconds, and they will be recorded in a list. The list will be refreshed for each trial.
If you only want certain keypresses to count toward response collection, you can add a keylist as an argument in event.getKeys or .waitKeys (remember to code accepted keys as strings):
for trial in range(nTrials):
keys=event.getKeys(keyList=['1','2'])
#draw some stuff
#win flip
#wait
print(keys)
win.close()
Now only a 1 or 2 response will be recorded in keys. If you use event.waitKeys, only a 1 or 2 response will allow you to go on to a subsequent trial. This is good for controlling against accidental responses. This technique is also a good way for an experimenter to control the start of an experiment. For example:
from psychopy import core, event, visual, monitors
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
nTrials=10
my_text=visual.TextStim(win)
#To start the experiment, experimenter presses a "w" (arbitrary)
my_text.text = "Wait for experimenter to start"
my_text.draw()
win.flip()
event.waitKeys(keyList=['w'])
win.close()
The window will only close once you've pressed the correct button.
Although getKeys is more flexible than waitKeys, because getKeys records responses across the whole trial, it can be quite hard to control. For example, say you want to present an initial fixation, followed by a stimulus, and you only want to collect responses during stimulus presentation and not during the fixation. You can implement getKeys like this:
for trial in range(nTrials):
keys = event.getKeys(keyList=['1','2']) #put getkeys HERE??
my_text.text = "trial %i" %trial #insert integer into the string with %i
fix.draw()
win.flip()
core.wait(2)
my_text.draw()
win.flip()
core.wait(1)
print(keys) #which keys were pressed?
win.close()
But you'll notice that you collect keys across the whole trial. Even if you put getKeys only after fixation, it still collects keys across the whole trial:
for trial in range(nTrials):
my_text.text = "trial %i" %trial #insert integer into the string with %i
fix.draw()
win.flip()
core.wait(2)
keys = event.getKeys(keyList=['1','2']) #put getkeys HERE??
my_text.draw()
win.flip()
core.wait(1)
print(keys) #which keys were pressed?
win.close()
To give yourself a bit more control with this, you can add "event.clearEvents" immediately preceding the point at which you want to start collecting responses. This function flushes any irrelevant keys that have been pressed and starts the key collection anew:
from psychopy import core, event, visual, monitors
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
nTrials=10
my_text=visual.TextStim(win)
fix=visual.TextStim(win, text='+')
for trial in range(nTrials):
keys = event.getKeys(keyList=['1','2']) #put getkeys HERE
my_text.text = "trial %i" %trial #insert integer into the string with %i
fix.draw()
win.flip()
core.wait(2)
event.clearEvents() #clear events HERE
my_text.draw()
win.flip()
core.wait(1)
print(keys) #which keys were pressed?
win.close()
You can see the empty lists where I tried to respond during fixation.
With "getKeys", you are prone to recording multiple responses as seen above, because it will automatically collect as many responses as you make within the allotted time. So how do you only take the first response a subject makes during a trial (that is, only record the first response as the "true" response, and ignore subsequent responses)? You should add a separate response collector variable in this case:
if keys: #if there are keypresses stored in keys
sub_resp = keys[0] #only count the first keypress
Implemented in the full script, it looks like this:
from psychopy import core, event, visual, monitors
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
nTrials=10
my_text=visual.TextStim(win)
fix=visual.TextStim(win, text='+')
for trial in range(nTrials):
keys = event.getKeys(keyList=['1','2']) #put getkeys HERE
my_text.text = "trial %i" %trial #insert integer into the string with %i
fix.draw()
win.flip()
core.wait(2)
event.clearEvents() #clear events HERE
my_text.draw()
win.flip()
core.wait(1)
print("keys that were pressed", keys) #which keys were pressed?
if keys:
sub_resp = keys[0] #only take first response
print("response that was counted", sub_resp)
win.close()
getKeys and waitKeys also have a "timeStamped" option, which in principle could be used to record response timing, but personally I have found that this is difficult to control. Instead, there are a couple of different ways of recording responses (the second way we will get to in the psychtoolbox portion of this tutorial). The first way (before the days of PsychoPy3), I recorded responses using clock timing (see level5) and the "maxWait" argument of waitKeys. maxWait allows you to enter an amount of time (in seconds) that a stimulus should appear on screen. For example:
#waits for 2 seconds, then continues if no response
keys = event.waitKeys(maxWait=2, keyList=['1', '2'])
Then, you can record the exact time during a trial in which a participant made a response, using a clock, implemented like so:
from psychopy import core, event, visual, monitors
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
nTrials=10
my_text=visual.TextStim(win)
rt_clock = core.Clock() # create a response time clock
for trial in range(nTrials):
rt_clock.reset() #reset timing for every trial
event.clearEvents(eventType='keyboard') #reset keys for every trial
my_text.text = "trial %i" % trial
my_text.draw()
win.flip()
keys = event.waitKeys(maxWait=2, keyList=['1', '2']) #waits for 2 seconds then continues
if keys:
print(rt_clock.getTime(), keys) #get time at which the subject made a keypress
win.close()
This code will present the stimulus for maximally 2 seconds, and print the response time for each trial. As it is coded now, the stimulus presentation time is pseudo-response-dependent- that is, the response terminates a trial (if a response is made within 2 seconds), or the experiment continues after 2 seconds(if there is no keypress). This may look a little jarring or messy because stimulus presentation times are all different. So how do you make a stimulus appear for 2 seconds regardless of the response made, but still record response time accurately? One method is to add another (identical) stimulus for the remaining time following a response:
#waits for stimulus duration then continues
keys = event.waitKeys(maxWait=2, keyList=['1', '2'])
if keys:
resp_time = rt_clock.getTime() #use getTime to determine the response time
#stimulus duration minus however long it took the subject to respond
remaining_time=2-resp_time
my_text.draw()
win.flip()
core.wait(remaining_time)
Implemented in a functional snippet like this:
from psychopy import core, event, visual, monitors
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
nTrials=10
my_text=visual.TextStim(win)
rt_clock = core.Clock() # create a response time clock
for trial in range(nTrials):
rt_clock.reset() #reset timing for every trial
event.clearEvents(eventType='keyboard') #reset keys for every trial
my_text.text = "trial %i" % trial
my_text.draw()
win.flip()
#waits for 2 seconds then continues
keys = event.waitKeys(maxWait=2, keyList=['1', '2'])
if keys:
resp_time = rt_clock.getTime() #use getTime to determine the response time
#stimulus duration minus however long it took the subject to respond
remaining_time=2-resp_time
my_text.draw()
win.flip()
core.wait(remaining_time)
win.close()
Now, regardless of how long it takes a subject to respond, the stimulus appears for the full 2 seconds, and response time is recorded at the moment the participant made a response.
If you want to use a countdown timer or frame-based timing, recording responses is a little different. Because the presentation timing is dependent on a while loop (see level5), you will have to use "getKeys" instead of "waitKeys", and instruct your experiment to "listen" for keypresses during the while loop. For example:
from psychopy import core, event, visual, monitors
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
nTrials=10
my_text=visual.TextStim(win)
rt_clock = core.Clock() # create a response time clock
cd_timer = core.CountdownTimer() #add countdown timer
for trial in range(nTrials):
rt_clock.reset() # reset timing for every trial
cd_timer.add(2) #add 2 seconds
event.clearEvents(eventType='keyboard') # reset keys for every trial
while cd_timer.getTime() > 0: #for 2 seconds
my_text.text = "trial %i" % trial
my_text.draw()
win.flip()
keys = event.getKeys(keyList=['1', '2']) #collect keypresses after first flip
if keys:
resp_time = rt_clock.getTime() #use getTime to determine the response time
print(keys, resp_time) #print keys and response times
win.close()
This allows you to get rid of core.wait(), and also the repeated stimulus presentation for any "remaining time" following a response.
However, experiment timing with the CountdownTimer recruits that tricky "getKeys" function again for response collection, and if you test out your snippet (as shown above), you'll see you've got the problem of collecting multiple keys. You cannot simply add a "sub_resp" variable because the keys are collected independently for every frame now. So how do you only take the first response in this case? I use "count" to count up the number of responses made on a given trial:
count=-1 #reset the counter for every while loop
while cd_timer.getTime() > 0: #for 2 seconds
my_text.text = "trial %i" % trial
my_text.draw()
win.flip()
keys = event.getKeys(keyList=['1', '2']) #collect keypresses after first flip
if keys:
count=count+1 #count up the number of times a key is pressed
if count == 0: #if this is the first time a key is pressed
resp_time = rt_clock.getTime()
sub_resp = keys
Implemented in a functioning code snippet, it looks like this:
from psychopy import core, event, visual, monitors
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
nTrials=10
my_text=visual.TextStim(win)
rt_clock = core.Clock() # create a response time clock
cd_timer = core.CountdownTimer() #add countdown timer
for trial in range(nTrials):
rt_clock.reset() # reset timing for every trial
cd_timer.add(2) #add 2 seconds
event.clearEvents(eventType='keyboard') # reset keys for every trial
count = -1 #start the counter for the while loop
while cd_timer.getTime() > 0: #for 2 seconds
my_text.text = "trial %i" % trial
my_text.draw()
win.flip()
keys = event.getKeys(keyList=['1', '2']) #collect keypresses after first flip
if keys:
count=count+1 #count up the number of times a key is pressed
if count == 0: #if this is the first time a key is pressed
resp_time = rt_clock.getTime() #get RT for first response in that loop
sub_resp = keys #get key for only the first response in that loop
print(sub_resp, resp_time)
win.close()
Now you only collect one response per trial.
Finally, what if you need to exit in the middle of the experiment? It is good to have an escape route in case a subject needs to stop early, or there is any other unforeseen problem. In this case, you can tell your experiment to do different things depending on which button is pressed:
keys = event.getKeys(keyList=['1', '2', 'escape']) #collect keypresses after first flip
if keys:
if 'escape' in keys: #if someone wants to escape the experiment
win.close() #close the window
else: #otherwise...
resp_time = rt_clock.getTime()
print(resp_time, keys)
It is important to adjust the output of your experiment in a way that is easiest for you to read it, so you can catch errors and easily interpret stored results. This brings us to how to record data so that it can be saved easily. There are many ways to record data across an experiment, so it's up to you how you want to organize it all. I collect data in lists or dictionaries to save to different file formats. Let's start with lists. First, I pre-define lists of zeros that will be filled online:
nTrials=3
nBlocks=2
sub_resp = [[0]*nTrials]*nBlocks
print(sub_resp)
In the above example with sub_resp, I have created 2 lists of zeros (one for each block), each with a size of 3 (number of trials). These lists will then be filled during response collection:
sub_resp = [[0]*nTrials]*nBlocks
for block in range(nBlocks):
#...
for trial in range(ntrials):
#...
if keys:
if 'escape' in keys:
core.quit()
else:
if key == 0:
sub_resp[block][trial] = key
Using indexing, you can fill the relevant keys that are pressed and the response time for every trial. If you exit the experiment early, any keypresses that have been made up until that point will be stored, but everything else will remain zeros.
As for recording accuracy, the correct response for a given trial will depend on your particular task. As an easy example, say you want your participants to do a simple math problem on each trial (sometimes used as an attention check for central visual focus):
math_problems = ['1+3=','1+1=','3-2=','4-1='] #write a list of simple arithmetic
solutions = [4,2,1,3] #write solutions
prob_sol = list(zip(math_problems,solutions))
print(prob_sol)
If you randomly select a problem from the list on a trial-by-trial basis, you can update the "prob" (the problem that will be shown on that trial) and the "corr_resp" (the correct response for that trial), in a trial-by-trial manner before presenting the stimuli:
import numpy as np
nBlocks=2
nTrials=4
corr_resp = [[0]*nTrials]*nBlocks
prob = [[0]*nTrials]*nBlocks
for block in range(nBlocks):
for trial in range(nTrials):
#choose a random problem from the list
prob[block][trial] = prob_sol[np.random.choice(4)]
#the solution is at index 1 in the zipped list
corr_resp[block][trial] = prob[block][trial][1]
print(prob[block][trial], corr_resp[block][trial])
#draw stimulus here
Then, after the stimulus is presented (the math problem), to record subject accuracy, you compare the subject's response to the correct response:
#record subject accuracy
#correct- remembers keys are saved as strings
if sub_resp[block][trial] == str(corr_resp[block][trial]):
sub_acc[block][trial] = 1 #arbitrary number for accurate response
#incorrect- remember keys are saved as strings
elif sub_resp[block][trial] != str(corr_resp[block][trial]):
sub_acc[block][trial] = 2 #arbitrary number for inaccurate response
#(should be something other than 0 to distinguish
#from non-responses)
All together, in a functional snippet, this looks like:
from psychopy import core, event, visual, monitors
#monitor specs
mon = monitors.Monitor('myMonitor', width=35.56, distance=60)
mon.setSizePix([1920, 1080])
win = visual.Window(monitor=mon, size=(400,400), color=[-1,-1,-1])
#blocks, trials, stims, and clocks
nBlocks=2
nTrials=4
my_text=visual.TextStim(win)
rt_clock = core.Clock() # create a response time clock
cd_timer = core.CountdownTimer() #add countdown timer
#prefill lists for responses
sub_resp = [[0]*nTrials]*nBlocks
sub_acc = [[0]*nTrials]*nBlocks
prob = [[0]*nTrials]*nBlocks
corr_resp = [[0]*nTrials]*nBlocks
#create problems and solutions to show
math_problems = ['1+3=','1+1=','3-2=','4-1='] #write a list of simple arithmetic
solutions = [4,2,1,3] #write solutions
prob_sol = list(zip(math_problems,solutions))
for block in range(nBlocks):
for trial in range(nTrials):
#what problem will be shown and what is the correct response?
prob[block][trial] = prob_sol[np.random.choice(4)]
corr_resp[block][trial] = prob[block][trial][1]
rt_clock.reset() # reset timing for every trial
cd_timer.add(3) #add 3 seconds
event.clearEvents(eventType='keyboard') # reset keys for every trial
count=-1 #for counting keys
while cd_timer.getTime() > 0: #for 3 seconds
my_text.text = prob[block][trial][0] #present the problem for that trial
my_text.draw()
win.flip()
#collect keypresses after first flip
keys = event.getKeys(keyList=['1','2','3','4','escape'])
if keys:
count=count+1 #count up the number of times a key is pressed
if count == 0: #if this is the first time a key is pressed
#get RT for first response in that loop
resp_time[block][trial] = rt_clock.getTime()
#get key for only the first response in that loop
sub_resp[block][trial] = keys[0] #remove from list
#record subject accuracy
#correct- remembers keys are saved as strings
if sub_resp[block][trial] == str(corr_resp[block][trial]):
sub_acc[block][trial] = 1 #arbitrary number for accurate response
#incorrect- remember keys are saved as strings
elif sub_resp[block][trial] != str(corr_resp[block][trial]):
sub_acc[block][trial] = 2 #arbitrary number for inaccurate response
#(should be something other than 0 to distinguish
#from non-responses)
#print results
print('problem=', prob[block][trial], 'correct response=',
corr_resp[block][trial], 'subject response=',sub_resp[block][trial],
'subject accuracy=',sub_acc[block][trial])
win.close()
At the end of the experiment, you should have complete lists of everything you would like to save in a file:
print(prob)
print(corr_resp)
print(sub_resp)
print(sub_acc)
The filename for your data should be the filename you have defined at the beginning of your script (which we went over many moons ago in level4). The classic (but least flexible) way of saving data is using a comma-separated value (csv) file. To save these lists in a csv, you need to 1.) import the csv module, 2.) provide the filename and directory name, 3.) data to be saved, and 4.) save location:
import csv #1
#2 create filename and directory
filename = 'savecsv_example.csv'
print(filename)
import os
main_dir = os.getcwd() #define the main directory where experiment info is stored
#point to a data directory to save the output
data_dir = os.path.join(main_dir,'exp','data',filename)
print(data_dir)
#3: a list of lists of all the data you want to save
data_as_list = [prob, corr_resp, sub_resp, sub_acc]
#print(data_as_list)
#4: mode='w' means 'write mode'. "sub_data" is arbitrary, but stay consistent
with open(data_dir, mode='w') as sub_data:
#delimiter=',' for lists of values separated by commas
data_writer = csv.writer(sub_data, delimiter=',')
data_writer.writerow(data_as_list) #write
This will give you a file that looks like this:
"[[('1+1=', 2), ('3-2=', 1), ('1+3=', 4), ('4-1=', 3)], [('1+1=', 2), ('3-2=', 1), ('1+3=', 4), ('4-1=', 3)]]","[[2, 1, 4, 3], [2, 1, 4, 3]]","[['2', '1', '3', '3'], ['2', '1', '3', '3']]","[[1, 1, 2, 1], [1, 1, 2, 1]]"
This looks pretty ugly, and csv is pretty bare-bones in terms of aesthetics. You don't need to save all your experiment data in one file, of course. If you want, you can separate files by block or by type (probs, corr_resp, etc.). You can specify this when you are creating your csv files. For example:
filename = 'savecsv_example2' #leave off the extension for now, since saving multiple files
data_dir = os.path.join(main_dir,'exp','data',filename)
#to save each data type individually with one block per row
data_as_list = [prob, corr_resp, sub_resp, sub_acc]
#add a types list for labelling
types = ['problem','correct_answer','subject_response','subject_accuracy']
count=-1 #add a counter to cycle through filenames
for data in data_as_list: #open each data type to save individually
count=count+1
#add type to filename
with open(data_dir + '_' + types[count] + '.csv', mode='w') as sub_data:
data_writer = csv.writer(sub_data, delimiter=',')
for block in data: #loop over each block now
data_writer.writerow(block) #write a new row for that block
If done correctly, this should create 4 files (one for each data type), with 2 rows in each file (one for each block), with 4 data points per row (one for each trial).
Another method of saving your data is the JSON file. JSON requires that you save your data as a dictionary. You can append data to dictionaries using an online method like I did with the lists, or you could transform your lists into dictionaries if you would rather stick with lists for online data collection. The latter is shown here:
for block in range(nBlocks):
#run experiment
#...
#save data
data_as_dict = []
for a,b,c,d in zip(prob[block], corr_resp[block], sub_resp[block], sub_acc[block]):
#the names listed here do not need to be the samr as the variable names
data_as_dict.append({'problem':a,'corr_resp':b,'sub_resp':c,'sub_acc':d})
print(data_as_dict)
Then, you would use the following syntax to save the data to JSON:
import json as json
for block in range(nBlocks):
#run experiment
#...
#JSON files can be saved with txt or JSON extension, I like to use .txt
filename = 'savejson_example'
data_dir = os.path.join(main_dir,'exp','data',filename)
data_as_dict = []
for a,b,c,d in zip(prob[block], corr_resp[block], sub_resp[block], sub_acc[block]):
#the names listed here do not need to be the samr as the variable names
data_as_dict.append({'problem':a,'corr_resp':b,'sub_resp':c,'sub_acc':d})
with open(data_dir + '_block%i.txt'%block, 'w') as outfile:
json.dump(data_as_dict, outfile)
And you get output that looks something like this for each block:
[{"problem": ["1+1=", 2], "corr_resp": 2, "sub_resp": "2", "sub_acc": 1}, {"problem": ["3-2=", 1], "corr_resp": 1, "sub_resp": "1", "sub_acc": 1}, {"problem": ["1+3=", 4], "corr_resp": 4, "sub_resp": "3", "sub_acc": 2}, {"problem": ["4-1=", 3], "corr_resp": 3, "sub_resp": "3", "sub_acc": 1}]
That was a lot less scripting to get where you wanted, wasn't it? But just like learning how to calculate ANOVAs by hand in year 1 stats, learning how to save CSVs is a lesson in appreciating all the hard work that went into your ancestors' python.
So why save your data in a dictionary in the first place? Saving your data as lists makes it easier to copy/paste saved data into excel, for example. But my opinion is that loading data in excel is rather messy and limited -- you are prone to lose bits of data here and there just by human error, and you can't do a lot of analyses in excel, either. Saving your data as a dictionary makes it much nicer to read and analyze in python. So for the final part of the tutorial, I will demonstrate how to load saved data from a dictionary using the JSON example.
First, importing a dictionary back into python in a readable, tabular format, is as easy as using the "pandas" module (the python data analysis library). First, import pandas:
import pandas as pd #shorten name for ease of reference
When you then type "pd.", you can press tab and scroll through all the various options pandas contains. One of these is "read_json":
#load the imported data as a variable (df)
df = pd.read_json(data_dir+'_block1.txt')
print(df)
Then, your data are loaded as a "DataFrame" object (df), which you can view in tabular format. Doesn't that look nice??
Because your data load as an object, you can specify which columns to print using the df.X method:
print(df.problem)
You can also print your data as a formatted table using the pd.DataFrame function (the aesthetics of results vary depending on the environment you are in):
pd.DataFrame(df)
You can also filter your data in different ways:
acc_trials = df.loc[df['sub_acc'] == 1] #show only trials on which subject was correct
print(acc_trials)
You can also calculate mean accuracy this way:
len(acc_trials)/len(df['sub_resp']) #divide 1 responses by total responses
You can also compute correlations between columns of data:
print("Pearson r:")
print(pd.DataFrame.corr(df,method='pearson'))
print("Spearman rho:")
print(pd.DataFrame.corr(df,method='spearman'))