Intro to Psychopy coding
In this section we will use Psychopy code to create a small prototype experiment. We decided to start from the “hard” part (coding) and then move to the “easier” part (using the GUI) for a few reasons:
- It provides an idea of why a GUI is useful
- It provides some understanding of the mechanics of an experiment
- It allows us to learn some basics of coding and Python
So, buckle up and let’s start coding!
Imports
Coding languages, like Python, R and Matlab, contain default functions that we can use to do pretty much whatever we like (granted, we know how to do it). Now, imagine you want to run a multilevel linear regression or a machine learning algorithm. You could definitely go down the route of using the essential functions provided by your selected language, but this would take ages and could be tricky and time-consuming. Thankfully, some people decided to spend their free time writing helpful code, encapsulate it into a function (so it’s easy to use) and make it freely available to everyone. Most often, people don’t write a single function, but they create multiple ones, all related to a specific topic or goal to achieve. These are then bundled together and closed inside a package.
You can think of a package as a box containing useful code, data and information.
If you want to use the functions stored in a package, you first need to ask for it to be shipped to your computer from the Internet. In other words, you need to download it. For today this is all done for you as downloading packages in Python, although easy, would require explaining more content that we can cover in this boot camp. If you are interested, read about pip.
Once the packages you need are installed, we need to let Python know that we want to use them in our script. In Python, this is achieved by adding ‘import package_name’ at the beginning of the script. For instance, to import the package numpy - used to work with arrays efficiently - we would do:
import numpyOne thing to know is that a package usually contains different sections, called modules, where functions are bundled depending on their usage and topic. For example, numpy includes a module called random containing functions related to generating and using random numbers. So, what if we are only interested in using runadom numbers and nothing else included in numpy? It would be a waste of computing memory and resources to load the whole numpy package. Luckily, in Python we can load only the specific modules we are interested in!
To so so, we use the formula from package_name import module/function name. For instance:
from numpy import randomThe packages/modules have been imported so how do we use the functions inside them? Well, it’s quite simple. You call something like this: package.module.function_name().
For instance, we could select 10 random integers between 100 and 1000 using the randint function in the random module:
numpy.random.randint(low=100, high=1001, size=10)Please note the high=1001 and remember that as Python start counting from 0, often the upper boundary of a range is excluded. So, we need to go one integer higher to be sure to include 1000 in the range.
A side note. You can provide a nickname for each package while you are loading them. Indeed, some packages are basically always loaded with a nickname. For instance, the numpy nickname is np. To provide a nickname you do the following:
import numpy as npAnd when you use the package, you refer to it with the nickname
np.random.randint()Psychopy
Although it doesn’t look like it, Psychopy is just a set of functions we use to create experiments. Every time we use the GUI to create an experiment, in the background Psycopy generates Python code with a bunch of functions that create stimuli, present them, record responses, etc… Thus, Psychopy is a package with multiple modules and tons of functions that we can use.
So, we will start by loading not the entire Psychopy package (it’s not very efficient) but only the modules we need. We will update this line as we go.
We start by loading the visual module, which is necessary for creating windows and presenting visual stimuli.
# Import required modules
from psychopy import visualCreate a window
The module visual is imported, and we can use the function window to create the, well…, the window where the stimuli will be presented. Note the parameters that this function takes. You can try to modify them to see what happens.
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")If we run this code, we will see a window briefly appearing on the screen. This is our canvas - the place where our experiment will be shown. Now, we need to fill it up and make it last longer.
Creating the first stimulus
We will begin by creating one single stimulus, the letter R. We will present it at the centre of the window. Psychopy allows us to create multiple types of stimuli using different functions. In this case, we will use the TextStim() to create a text stimulus.
# Create letter R
top_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0, 0],
height = 50,
)Sweet! Try to run the whole code. You’ll see a window appearing. Sadly, there is nothing in it…not really useful for presenting an experiment. Why is this happening?
The answer is because we didn’t tell Psychopy to render the stimulus on the screen. Think about this. If a stimulus is presented as soon as it is created in code (aka when the top_r = ... line is run), it would be challenging to create trials. You would need to repeat the same code over and over again, exactly in the same way. Maybe you could use a for loop to optimize the script, but another problem would arise. Creating a stimulus takes some time, especially if you are loading files as stimuli, for example images. This means that you would not be able to run experiments with a high time precision. There is also a more concrete reason why no stimuli were presented when we ran the script. We did not tell the computer to generate this stimulus on the graphic card…your computer did not know the letter was supposed to be presented.
So, how do we tell the computer to show a stimulus? We need two functions, draw() and flip(). The former is used to tell the graphic card (GPU) to render the stimulus (we went into more details during the bootcamp), while the second one updates the screen to show the rendered stimulus.
Add these two lines at the end of your script and run it again.
# Draw stimulus to GPU
top_r.draw()
# Update screen
win.flip()Waiting
Alright, now we can see a red R. However, the window is still displayed too briefly. Let’s make it longer. We can tell Psychopy to keep a window up using the module core and the function wait() contained in it. Thus, we first need to load the module at the top of the script and then add the required function. You can use this function to set the number of seconds you want the window up and running.
Here is the updated script:
# Import required modules
from psychopy import visual, core
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0, 0],
height = 50,
)
# Draw stimulus to GPU
top_r.draw()
# Update screen
win.flip()
# Stop for 5 seconds before doing anything else
core.wait(5)BUUUUUUTTTTTT….PAY ATTENTION USING CORE.WAIT()!
This function can be used when you write your own script, as we are doing now. But you’ll probably never do this. You will likely use the GUI (as we will do later). If you end up adding this function through the GUI, you could break the timing of your experiment. As explained by Michael MacAskill in this comment (there are many other similar one online):
core.wait() is a PsychoPy function that doesn’t exist in PsychoJS. Having said that, core.wait() should never be used in a Builder experiment, only in Python scripts that are crafted by hand. Builder scripts are structured around a drawing and event loop that assumes that any code that runs can be completed within one screen refresh (typically at 60 Hz this is 16.666 ms), because Builder is actively drawing to the screen on every refresh, checking for responses and so on, even if stimuli don’t appear to be changing. core.wait() breaks this active drawing cycle and can completely muck up Builder’s internal timing. You should restructure your experiment to avoid core.wait(). The translation to PsychoJS should then hopefully be straightforward.
From this comment we discover that we should be able to run code within a screen refresh. This makes sense if we look ad stimulus.draw() and win.flip(). The speed at which we can update the screen with win.flip() is limited by the screen refresh rate. For a screen with a refresh rate of 60 Hz, the quickest time we can update the screen is \(\frac{1}{60} = 16.67\) ms. Moreover, we can update the screen only at multiple times of this value. Can you see the problem of using core.wait()? There are two main issues:
- Let’s say you run
core.wait(0.130)because you want to present a stimulus for 130 ms. Well, you cannot do this if your refresh rate is 60 Hz. It’s physically impossible. 130 ms is not a multiple of 16.67 ms. Doing this could skew your stimulus presentation by a few ms. It doesn’t seem much, but if you run hundreds of trials, your timings could be off by seconds by the end of your experiment. - If
core.wait()halts the program’s execution (Python does not run the next lines until the time if off). So, consider what we said in point 1 and imagine runningcore.wait()in the middle of a frame being presented. What’s going to happen? nothing good…or better, you won’t notice much on your screen, but in the background, you’re screwing up all your timings.
Now that we know this, let’s change the code to use frames rather than seconds. Doing this is just a matter of introducing a for-loop and exploiting the fact our stimulus presentation is controlled and limited by our screen refresh rate (aka, it doesn’t matter how fast we present run opur code. As long as it runs faster than one screen refresh, we are all good).### Waiting
Alright, now we can see a red R. However, the window is still displayed too briefly. Let’s make it longer. We can tell Psychopy to keep a window up using the module core and the function wait() contained in it. Thus, we first need to load the module at the top of the script and then add the required function. You can use this function to set the number of seconds you want the window up and running.
Here is the updated script:
# Import required modules
from psychopy import visual, core
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0, 0],
height = 50,
)
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Use frames for presentation
for iFrame in range(frames):
# Draw stimulus to GPU
top_r.draw()
# Update screen
win.flip()Relocating the stimulus
In our experiment, we want to show two Rs on our screen, one in the top half of the screen and the other in the bottom half. Thus, we need to move the first letter we have created so that it will be displayed at the top. We modify the pos parameter of the TextStim() function.
# Import required modules
from psychopy import visual, core
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0, 150],
height = 50,
)
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Use frames for presentation
for iFrame in range(frames):
# Draw stimulus to GPU
top_r.draw()
# Update screen
win.flip()Run the script and check what happens. Try to play around with the position to get a sense of what this parameter does.
Adding a second R
As said above, ultimately, we want to show two Rs. We got the one on the top, so we need one on the bottom. To achieve this, we need to create a second text stimulus, and then we need to draw it with the first and present it.
# Import required modules
from psychopy import visual, core
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0, 150],
height = 50,
)
# Create a second R
bottom_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0,-150],
height = 50,
)
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Use frames for presentation
for iFrame in range(frames):
# Draw stimuli to GPU
top_r.draw()
bottom_r.draw()
# Update screen
win.flip()While we are here, let’s add a fixation cross in the middle of the screen. Because an experiment is never completed without a fixation cross. The process is the same as we used above, with the only difference being that we need to use the visual.ShapeStim component.
# Import required modules
from psychopy import visual, core
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0, 150],
height = 50,
)
# Create a second R
bottom_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0,-150],
height = 50,
)
# Create fixation cross
fixation = visual.ShapeStim(
win = win,
vertices = 'cross',
size = (25, 25),
pos = (0, 0),
lineColor = 'white',
fillColor = 'white'
)
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Use frames for presentation
for iFrame in range(frames):
# Draw stimuli to GPU
top_r.draw()
bottom_r.draw()
fixation.draw()
# Update screen
win.flip()Make it spin!
The goal of today’s session is to create a mental rotation task. Although we will do the complete experiment through Psychopy’s GUI, here we can at least try to modify the angle and flip of the stimuli. Let’s start by rotating the bottom R every few seconds.
To achieve this, we need some more code, and we need to work more on for-loops and use a list. Let’s start by defining a list of possible angles from which we want to present our letter. You can use the angles you like.
# Possible angles
angles = [0, 30, 90, 270, 340]Now, we can use a loop to go through each of these angles. For each angle, we want to draw the stimuli on the screen for one second. We have already coded the second part (presenting stimuli for one second). Everything we need to do to go through the angles is to enclose our existing for loop (for iFrame in frames) in another for loop that iterates across angles.
# Import required modules
from psychopy import visual, core
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0, 150],
height = 50,
)
# Create a second R
bottom_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0,-150],
height = 50,
)
# Create fixation cross
fixation = visual.ShapeStim(
win = win,
vertices = 'cross',
size = (25, 25),
pos = (0, 0),
lineColor = 'white',
fillColor = 'white'
)
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Possible angles
angles = [0, 30, 90, 270, 340]
# Loop through angles
for iAngle in angles:
# set the angle for the current presentation
bottom_r.setOri(iAngle)
# Use frames for presentation
for iFrame in range(frames):
# Draw stimuli to GPU
top_r.draw()
bottom_r.draw()
fixation.draw()
# Update screen
win.flip()If you run the code now, you should see that the bottom R rotates every second, going through all the angles on your list. Sweet, we can now modify the flip of the letter. With flip, we mean whether the bottom R is presented as mirrored or not.
To achieve this, we can do something similar to what we have done before for the rotation. Firstly, we define whether the stimulus should be flipped or not. Let’s do this with a list of 1s and 0s, where 1 means the stimulus is flipped, and 0 means it’s not. This list should be as long as the angles list - we need one value for each rotation. I let you decide when to flip the R. I’ll flip the second, third and fifth Rs.
# Possible angles
angles = [0, 30, 90, 270, 340]
# Is stimulus flipped?
is_flip = [0, 1, 1, 0, 1]Then, we modify the for loop so that we not only access and modify the angle but also access and modify the flip. To achieve this, we will use enumerate (we saw it during the first day in person), which allows us to access the values and provide an index at the same time.
# Import required modules
from psychopy import visual, core
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win,
text = "R",
color = [1, 1, 1],
pos = [0, 150],
height = 50,
)
# Create a second R
bottom_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0,-150],
height = 50,
)
# Create fixation cross
fixation = visual.ShapeStim(
win = win,
vertices = 'cross',
size = (25, 25),
pos = (0, 0),
lineColor = 'white',
fillColor = 'white'
)
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Possible angles
angles = [0, 30, 90, 270, 340]
# Is stimulus flipped?
is_flip = [0, 1, 1, 0, 1]
# Loop through angles
for iTrial, iAngle in enumerate(angles):
# set the angle for the current presentation
bottom_r.setOri(iAngle)
# Set flip
bottom_r.flipHoriz = is_flip[iTrial]
# Use frames for presentation
for iFrame in range(frames):
# Draw stimuli to GPU
top_r.draw()
bottom_r.draw()
fixation.draw()
# Update screen
win.flip()Note the line bottom_r.flipHoriz = is_flip[iTrial]. What we are doing here is exploiting the fact that 1 and 0 can be interpreted by most programming languages as True and False (logical values). The property flipHoriz takes a value of either true or false that indicates whether the stimulus should be presented flipped on the horizontal axis.
Collecting responses
Ok, we got the stimuli, but we don’t have a crucial part of an experiment…the participant’s response. We want the participant to tell us whether the two letters are flipped (like the left and right hand) or not, compared to each other. Specifically, we want the participant to press the X key if they are not a mirrored version of each other and M if they are.
Psychopy offers an easy way to collect key presses from a keyboard. We just need to create a new component that logs what and when a key has been pressed.
The first thing we need to do is to import the keyboard class from Psychopy’s hardware module. At the beginning of the screen, we add:
from psychopy.hardware import keyboardThen, we create a component in a similar way we did for the Rs and the fixation cross. Add this to your script, right after the fixation cross.
# Create keyboard component to record key presses
keyboard = keyboard.Keyboard()Finally, we modify the for loop to “listen” to key presses. Note that we restrict the key presses to X and M, as we don’t want the participant to be able to press any other key. By doing so, if you press any other key, that press will not be registered.
# Import required modules
from psychopy import visual, core
from psychopy.hardware import keyboard
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win,
text = "R",
color = [1, 1, 1],
pos = [0, 150],
height = 50,
)
# Create a second R
bottom_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0,-150],
height = 50,
)
# Create fixation cross
fixation = visual.ShapeStim(
win = win,
vertices = 'cross',
size = (25, 25),
pos = (0, 0),
lineColor = 'white',
fillColor = 'white'
)
# Create keyboard component to record key presses
keyboard = keyboard.Keyboard()
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Possible angles
angles = [0, 30, 90, 270, 340]
# Is stimulus flipped?
is_flip = [0, 1, 1, 0, 1]
# Loop through angles
for iTrial, iAngle in enumerate(angles):
# set the angle for the current presentation
bottom_r.setOri(iAngle)
# Set flip
bottom_r.flipHoriz = is_flip[iTrial]
# Use frames for presentation
for iFrame in range(frames):
# Draw stimuli to GPU
top_r.draw()
bottom_r.draw()
fixation.draw()
# Update screen
win.flip()
# Get key presses
keys = keyboard.getKeys(['x', 'm'])You can now press the two keys every time a stimulus is presented. However, at the moment, we don’t have a way to tell whether we are actually recording anything. Why don’t we provide feedback to tell whether the right key has been pressed? As we said, X should be pressed if the two Rs are flipped the same way; M if they are mirrored. Maybe we could change the colour of the fixation cross to GREEN if the correct key was pressed and RED if the wrong key was pressed.
We can start by adding a check on the key that have been pressed. This is a job for a if statement.
# Import required modules
from psychopy import visual, core
from psychopy.hardware import keyboard
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win,
text = "R",
color = [1, 1, 1],
pos = [0, 150],
height = 50,
)
# Create a second R
bottom_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0,-150],
height = 50,
)
# Create fixation cross
fixation = visual.ShapeStim(
win = win,
vertices = 'cross',
size = (25, 25),
pos = (0, 0),
lineColor = 'white',
fillColor = 'white'
)
# Create keyboard component to record key presses
keyboard = keyboard.Keyboard()
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Possible angles
angles = [0, 30, 90, 270, 340]
# Is stimulus flipped?
is_flip = [0, 1, 1, 0, 1]
# Loop through angles
for iTrial, iAngle in enumerate(angles):
# set the angle for the current presentation
bottom_r.setOri(iAngle)
# Set flip
bottom_r.flipHoriz = is_flip[iTrial]
# Use frames for presentation
for iFrame in range(frames):
# Draw stimuli to GPU
top_r.draw()
bottom_r.draw()
fixation.draw()
# Update screen
win.flip()
# Get key presses
keys = keyboard.getKeys(['x', 'm'])
# Check answer
if 'm' in keys and not is_flip[iTrial]:
# Answer is correct = Make Fixation Green
elif 'x' in keys and is_flip[iTrial]:
# Answer is correct = Make Fixation Green
elif 'm' in keys and is_flip[iTrial]:
# Answer is wrong = Make Fixation Red
elif 'x' in keys and not is_flip[iTrial]:
# Answer is wrong = Make Fixation RedCan you think how we can change the colour of the fixation cross?
# Import required modules
from psychopy import visual, core
from psychopy.hardware import keyboard
# Create window
win = visual.Window(
size = [500, 500],
fullscr = False,
color = [0, 0, 0],
units = "pix")
# Create letter R
top_r = visual.TextStim(
win,
text = "R",
color = [1, 1, 1],
pos = [0, 150],
height = 50,
)
# Create a second R
bottom_r = visual.TextStim(
win = win,
text = "R",
color = [1, 1, 1],
pos = [0,-150],
height = 50,
)
# Create fixation cross
fixation = visual.ShapeStim(
win = win,
vertices = 'cross',
size = (25, 25),
pos = (0, 0),
lineColor = 'white',
fillColor = 'white'
)
# Create keyboard component to record key presses
keyboard = keyboard.Keyboard()
# Time in frames (1 second at 60Hz is 60 frames)
frames = 60
# Possible angles
angles = [0, 30, 90, 270, 340]
# Is stimulus flipped?
is_flip = [0, 1, 1, 0, 1]
# Loop through angles
for iTrial, iAngle in enumerate(angles):
# Reset fixation colour to white
fixation.color = [1, 1, 1]
# set the angle for the current presentation
bottom_r.setOri(iAngle)
# Set flip
bottom_r.flipHoriz = is_flip[iTrial]
# Use frames for presentation
for iFrame in range(frames):
# Draw stimuli to GPU
top_r.draw()
bottom_r.draw()
fixation.draw()
# Update screen
win.flip()
# Get key presses
keys = keyboard.getKeys(['x', 'm'])
# Check answer
if 'm' in keys and not is_flip[iTrial]:
fixation.color = [-1, 1, -1]
elif 'x' in keys and is_flip[iTrial]:
fixation.color = [-1, 1, -1]
elif 'm' in keys and is_flip[iTrial]:
fixation.color = [1, -1, -1]
elif 'x' in keys and not is_flip[iTrial]:
fixation.color = [1, -1, -1]Well done! We have coded a small mental rotation experiment! Obviously, this would not be enough for a proper study, but it’s a good starting point. Below, we have left some exercises and challenges you should try to solve. They will help you improve your coding and problem-solving skills, and you will end up with a better experiment.
Exercises
Our trial sequence is set in stone. This is not a good thing to do. Ideally, we should randomize the trials so that the responses do not depend on the specific order we present the stimuli. Try to modify the code so that the stimuli are randomized each time you run the code. Remember that the answers should still match the stimuli. That is, you cannot randomize the
anglesandis_flipvectors independently.Currently, there is no delay between our trials. We should try to add an interstimulus interval (ITI) so that once a trial ends, there is a brief period where nothing is presented. This period is followed by the next trial. Try first to add the ISI. Once you have achieve that, try to jitter the ISI, so that it’s duration is selected at random between 500 ms and 1500 ms (check that these values are possible at your screen refresh rate).
If you have completed exercise 2, try now to modify the code so that a white fixation cross is presented during the ISI. That is, the experiment workflow should be: fixation cross (duration between 500 ms and 1500 ms), stimuli (R, for 1 second) + response, fixation cross (duration between 500 ms and 1500 ms), etc…