In this post, we’ll work on a category learning task. Participants will be shown pictures of beetles with different beak styles, antennae, abdomens, legs, and eyes. In other words, each beetle will have 5 features with 2 different possible values (e.g., eyes can be red or green). To get this study going on your own computer, you’ll need the beetle images linked here:
https://drive.google.com/file/d/1sh9pfqjohWV_L6XnmJz7aOvF5xJGuKq1/view?usp=sharing
Getting started
The following code imports all the packages we’ll be using, creates a window called “window” to display stimuli in, and (at the end) creates a list of paths too the beetle images called “bug_paths”. There are also two functions I added in called “instructions()” and “fixation()”.
# Set up. Importing packages and setting up a window to show stuff in.
from psychopy import visual, event
import os
import glob
import time
import random
window = visual.Window(monitor="testMonitor", fullscr=True)
# This is a convenient function to call anytime I want to show instructions
def instructions(message):
contTxt = visual.TextStim(window,text=message)
contTxt.draw()
window.flip()
time.sleep(1.5)
pressC = visual.TextStim(window,text="Press 'C' to Continue", color='yellow', pos=(0,-0.8))
pressC.draw()
contTxt.draw()
window.flip()
resp_key = event.waitKeys(keyList=['c'])
# Handy function for showing a fixation cross for 0.5 seconds
def fixation():
fix_text=visual.TextStim(window,text="+")
fix_text.draw()
window.flip()
time.sleep(0.5)
# Grab bug images
currentDirectory = os.getcwd()
bug_paths = glob.glob(currentDirectory+'/bugfamily/*.jpg')
“instruction()”, as you can probably (hopefully, by now) tell by reading it, will display a message to participants, whatever message you put inside the parentheses. After 1.5 seconds, it will show another message in yellow at the bottom of the screen, saying, “Press ‘C’ to Continue”. The program won’t continue until the participant has pressed the C key. I copy and paste this function into a lot of my studies because it’s easy to just write, e.g., “instruction(“For the next phase, we ask that you blah blah blah”), rather than write out all the code for displaying the text, the message to press ‘C’, etc. every time I want to give the participant instructions.
Another useful addition is “fixation()”. This function shows a fixation cross for half a second. It’s four lines of code that I don’t have to write out every time I want to display a fixation cross. I can instead just say “fixation()” every time I need to since I defined this function.
Instructions
Watch this new “instructions()” code in action.
instructions("In this study, we will show you pictures of beetles. Each one is either from location #1 or location #2. Start by guessing where each beetle came from. We'll give you feedback on whether you're right or wrong.")
See? I just saved myself from writing about 9 lines of code and 9 more every time I want to display some instructions. That’s why writing your own functions is useful.
Set up a beetle “data base”
There are many sophisticated and efficient ways to create objects with attributes that can be called upon, sorted, and manipulated. We’re going to use probably the least sophisticated way because it only uses stuff we already know (“for” loops and lists) and it works.
(Note: the line starting with “bugs.append” is supposed to be indented under the “for” loop but Word Press won’t let me do that for some stupid reason.)
# Set up an empty list to put file paths and bug attributes into
bugs=[]
for i in range(len(bug_paths)):
props_only=os.path.basename(os.path.normpath(bug_paths[i]))[:-4] bugs.append([bug_paths[i],props_only[0],props_only[1],props_only[2],props_only[3],props_only[4]])
random.shuffle(bugs) # randomize the order of bugs
The line “bugs=[]” is setting up an empty list to fill up. It’s actually going to be a list of lists (whoa, Inception!). The next line initiates a “for” loop that will iterate through each element within “bug_paths”. The first indented line, starting with “props_only”, is taking just the last part of the path name and removing the “.jpg” from the end. This will leave us with names like “11121” and “21212”. These file names are list of features. Specifically, it is whether the legs, abdomens, antennae, beaks, and eyes are type “1” or “2” in that order.
The line starting with “bugs.append” is taking the current path and saving it as the first item in a new list that’s going to be added to our list-of-lists “bugs”. The next five items in the list are the 1s and 2s of the list. One convenient aspect of character strings in python is that you can use the same indexing you use for lists to refer to different characters in a string. For instance, if I saved “save” as a variable called “myString”, I could type in “myString[0]” and get back “s” or “myString[1]” and get back “a”. (Remember, python starts counting from 0).
So, we’re basically going through each path and creating a list of [<the path to this specific image>, <what kind of legs, type 1 or 2>, <what kind of abdomens>, <antennae>, <beaks>, <eyes>]. We’re taking this list and putting it another list called “bugs”. So, when I refer to “bugs[0]”, it’ll return the first list, which will contain a path to an image of a beetle as well as its features. If you want to refer ONLY to the path of the first beetle picture, you’d type “bugs[0][0]”. Since the zeroth element in “bugs” is a list, you can put another index at the end saying which element in that list you want to look at. So, if you want to know what kind of legs the second bug has in “bugs”, you’d type “bugs[1][1]”.
The last line in the code block randomizes the order of the lists of beetle images and their properties.
Pick a critical feature
We’re going to randomly select a critical features (i.e., beak style, antennae, abdomen, legs, or eyes) that will determine whether each beetle belongs to group 1 or 2. To do this, we’ll use the “random.sample()” function.
#pick a random feature to differentiate 1s from 2s
critical_feature=random.sample(range(5),1)
“random.sample()” needs a range to draw a random number from and the amount of random numbers to draw out of that range. Here, I put “range(5)” because we have features, and a “1” because we only want one critical feature. Note, the output of the “random.sample()” is a list, so if you need to reference the random number it just generated, you need to do it as “critical_feature[0]”.
Running total of correct categorizations
You might want something more nuanced from your own categorization study, but for this one we’ll just be keeping track of how many correct categorizations the participant makes overall.
# set up a running total of correct categorizations
number_correct=0
We’re going to just add 1 to “number_correct” every time the participant makes a correct categorization. You could do this by having a line that says “number_correct=number_correct+1”. It’s easier though to take advantage of a special assignment you can do: “number_correct+=1”, which basically means “this variable is equal to itself plus 1.”
Looping through the list
Setting up a “for” loop to go through each bug path is fairly simple. Just notice that we’re referring to “bugs[i][0]”. “bugs” is a list of lists, so the “i” is going to reference the specific list the loop is currently on and then the “0” further specifies that we want to reference the first element of that list, which is the path to the image.
for i in range(len(bugs)):
image=visual.ImageStim(window,bugs[i][0])
image.draw()
window.flip()
User input
Things are about to get a bit complicated. We want to show people a beetle, then, after they have pressed either “1” or “2” to display “1” or “2” for a moment, then display a message indicating whether they were correct or not. To do this, we’re going to set up a couple of things that won’t make as much sense until a bit later.
instruction=visual.TextStim(window,pos=(0,-0.4))
keyboardKeys = ['1','2']
answer = ''
question_posed=1
(Note: these lines are all indented under the “for” loop). The first line “instruction = …” is setting up a text stimulus that will appear underneath the beetle pictures. We want it to say “My answer: ___” and fill in the blank after they’ve pressed something. That’s why we haven’t specified a “text=” yet.
“keyboardKeys” are a set of keys we will accept as inputs. “answer” is a blank character string by default. It will be replaced by a “1” or a “2” as soon as the participant clicks either of those keys. The last statement in that code block might be strangest of all: “question_posed=1”. While “question_posed” is equal to 1, we want to display the current beetle picture and wait for an answer. Once they’ve answered, we want to set “question_posed” back to zero. Why, you might ask? Because we’re going to use a “while” loop…
“while” loops
A “while” loop executes all code indented underneath it over and over again until a condition is met. We initiate our “while” loop here with…
while question_posed==1:
(Note: This line is also under the “for” loop that’s going through all the bug images.) This statement says “while ‘question_posed is equal to 1, I’ll do the code indented underneath over and over again until ‘question_posed doesn’t equal 1.” The idea is to eventually set ‘question_posed’ back to 0 once the participant has answered.
In the meantime… (aka “while”)
While we’re waiting for the participant to answer, we’re going to set “instruction” to read ‘My answer: <insert ‘answer variable, which starts off as a blank string”. We’re going to draw() and flip() this, along with the current beetle picture over and over again. We’re also going to check whether “answer” is equal to a blank string, ”. If it isn’t blank, we want to set “question_posed” back to 0, tell them whether they categorized the beetle correctly or not, and increment the “number_correct” variable if they got it right.
while question_posed==1:
instruction.setText('My answer'u': {0}'.format(answer))
image.draw()
instruction.draw()
window.flip()
if(answer != ''):
question_posed=0
time.sleep(0.5)
if(answer==bugs[i][1+critical_feature[0]]):
number_correct+=1
image.draw()
correct_text=visual.TextStim(window,text="Correct!",pos=(0,-0.4),color="green")
correct_text.draw()
window.flip()
time.sleep(1.5)
fixation()
if(answer!=bugs[i][1+critical_feature[0]]):
image.draw()
incorrect_text=visual.TextStim(window,text="Incorrect!",pos=(0,-0.4),color="red")
incorrect_text.draw()
window.flip()
time.sleep(1.5)
fixation()
for letter in (keyboardKeys):
if event.getKeys([letter]):
answer += letter
That’s a lot of code… Some of it will already make sense–namely, all the .draw()s and .flip()s. That’s basically what the first four lines under the “while” loop initiation are doing. They’re setting the text of “instruction” to whatever “answer” is set to, which may have changed the lat time the lip iterated. It draws the current answer, the beetle picture, and displays them.
From “if(answer != ”):” and downward for several lines, we have a bunch of stuff that’s going to happen in case the answer does not equal a blank character string, ”. First, it changes “question_posed” to 0 so the “while” loop will stop repeating after the current iteration. Then, it waits for half a second. (Just flows nicer that way, I find).
After this, there are two blocks of code starting with “if( …): ” statements. The top one is for when “answer” is the correct answer. We’re checking the current image with “bugs[i]”. The part that says “[1+critical_feature[0]]” takes some unpacking. Remember that “critical_feature” is a list of one random number drawn between 0-4, which in pythonese is range(5). That’s the feature of the beetle that determines whether it’s category 1 or 2. Remember, too, that the lists in bugs are structures as, “element 0 = path to the bug image, element 1 = the kind of legs the beetle has, etc.”. So, we have to take “critical_feature” plus one to pass up the path and land on the feature that determines which group the current beetle belongs in .
If “answer==bugs[i][1+critical_feature[0]])” evaluates as false, the indented code underneath it won’t execute. The next “if():” statement will execute if they entered the wrong answer–in other words, when “answer != bugs[i][1+critical_feature[0]]”. If you examine the indented code under each “if():” statement, you’ll notice that both give feedback via “TextStim()” for 1.5 seconds, then run the “fixation()” function. The only difference is what the feedback actually is (“correct” vs “incorrect”) and the “correct” block increments “number_correct” via the line “number_correct+=1.
The last block of code at the very bottom, starting with “for letter in (keyboardKeys):” is kind of important. We’ve been talking this whole time as if “answer” might change from a blank character string to a non-blank one. That’s what this block is allowing. It basically searches for whether any of the two elements in “keyboardKeys” have been pressed and, if so, adds them to the character string “answer”, which will be blank at first every time we start over the “for” loop, and it’ll stay that way until the participant presses either the “1” or “2” key.
Stepping back and marveling at this monstrosity
We end up with a “for” loop with a “while” loop inside of it and some hefty if-then statements inside that “while” loop for good measure. You may want to zoom out, scroll around, or whatever, and try and make sure you understand the “big picture” of what this monstrous code is doing. The “for” loop part is going through each stimulus and repeating the same procedures: show them the stimulus, wait for a response, react to the response, etc. The “while” loop starts up once the stimulus is shown, “question_posed=1”. It’s going to keep doing whatever’s indented under the “while” keyword over and over again until “question_posed=0”. This particular “while” loop is re-drawing the stimulus, the “My answer: “, which will update if the participant clicks any of the relevant keys, and shows something different depending on whether they answered correctly or incorrectly. That’s where the if-then statements come in. They’re basically just reacting to whether the participant categorized the beetle correctly or not.
Finishing up
The last couple of lines should be old hat by now.
# print the percent of correct categorizationos
print(number_correct/32)
# Tell the participant to leave
bye_text=visual.TextStim(window,text="You are now done with the study. Please let the experimenter on dury know.")
bye_text.draw()
window.flip()
resp_key = event.waitKeys(keyList=['d'])
The first line prints the “number_correct” divided by 32, the number of beetle pictures. In other words, it prints the proportion of correct categorizations. The last 5 lines under “#tell the participant to leave” tells the participant to leave, but in a nice way. It’s more or less the same lines I’ve closed a few of the experiments with so far, so it should look pretty self-explanatory by now.
Below is the entire script with comments.
# Set up. Importing packages and setting up a window to show stuff in.
from psychopy import visual, event
import os
import glob
import time
import random
window = visual.Window(monitor="testMonitor", fullscr=True)
# This is a convenient function to call anytime I want to show instructions
def instructions(message):
contTxt = visual.TextStim(window,text=message)
contTxt.draw()
window.flip()
time.sleep(1.5)
pressC = visual.TextStim(window,text="Press 'C' to Continue", color='yellow', pos=(0,-0.8))
pressC.draw()
contTxt.draw()
window.flip()
resp_key = event.waitKeys(keyList=['c'])
# Handy function for showing a fixation cross for 0.5 seconds
def fixation():
fix_text=visual.TextStim(window,text="+")
fix_text.draw()
window.flip()
time.sleep(0.5)
# Grab bug images
currentDirectory = os.getcwd()
bug_paths = glob.glob(currentDirectory+'/bugfamily/*.jpg')
instructions("In this study, we will show you pictures of beetles. Each one is either from location #1 or location #2. Start by guessing where each beetle came from. We'll give you feedback on whether you're right or wrong.")
# Set up an empty list to put file paths and bug attributes into
bugs=[]
for i in range(len(bug_paths)):
props_only=os.path.basename(os.path.normpath(bug_paths[i]))[:-4]
bugs.append([bug_paths[i],props_only[0],props_only[1],props_only[2],props_only[3],props_only[4]])
random.shuffle(bugs) # randomize the order of bugs
#pick a random feature to differentiate 1s from 2s
critical_feature=random.sample(range(5),1)
# set up a running total of correct categorizations
number_correct=0
# loop through the list of bugs
# commenting every major line here would be aggravating, just go read
# this part of the blog again : P
for i in range(len(bugs)):
image=visual.ImageStim(window,bugs[i][0])
image.draw()
window.flip()
instruction=visual.TextStim(window,pos=(0,-0.4))
keyboardKeys = ['1','2']
answer = ''
question_posed=1
while question_posed==1:
instruction.setText('My answer'u': {0}'.format(answer))
image.draw()
instruction.draw()
window.flip()
if(answer != ''):
question_posed=0
time.sleep(0.5)
if(answer==bugs[i][1+critical_feature[0]]):
number_correct+=1
image.draw()
correct_text=visual.TextStim(window,text="Correct!",pos=(0,-0.4),color="green")
correct_text.draw()
window.flip()
time.sleep(1.5)
fixation()
if(answer!=bugs[i][1+critical_feature[0]]):
image.draw()
incorrect_text=visual.TextStim(window,text="Incorrect!",pos=(0,-0.4),color="red")
incorrect_text.draw()
window.flip()
time.sleep(1.5)
fixation()
for letter in (keyboardKeys):
if event.getKeys([letter]):
answer += letter
# print the percent of correct categorizationos
print(number_correct/32)
# Tell the participant to leave
bye_text=visual.TextStim(window,text="You are now done with the study. Please let the experimenter on dury know.")
bye_text.draw()
window.flip()
resp_key = event.waitKeys(keyList=['d'])