This tutorial builds an experiment to test your just-noticeable-difference (JND) to orientation, that is it determines the smallest angular deviation that is needed for you to detect that a gabor stimulus isn’t vertical (or at some other reference orientation). The method presents a pair of stimuli at once with the observer having to report with a key press whether the left or the right stimulus was at the reference orientation (e.g. vertical).
You can download the full code here. Note that the entire experiment is constructed of less than 100 lines of code, including the initial presentation of a dialogue for parameters, generation and presentation of stimuli, running the trials, saving data and outputting a simple summary analysis for feedback. Not bad, eh?
There are a great many modifications that can be made to this code, however this example is designed to demonstrate how much can be achieved with very simple code. Modifying existing is an excellent way to begin writing your own scripts, for example you may want to try changing the appearance of the text or the stimuli.
The first lines of code import the necessary libraries. We need lots of the PsychoPy modules for a full experiment, as well as numpy (which handles various numerical/mathematical functions):
1 2 3 4 | """measure your JND in orientation using a staircase method"""
from psychopy import core, visual, gui, data, event
from psychopy.tools.filetools import fromFile, toFile
import numpy, random
|
Also note that there are two ways to insert comments in Python (and you should do this often!). Using triple quotes, as in “”“Here’s my comment”“”, allows you to write a comment that can span several lines. Often you need that at the start of your script to leave yourself a note about the implementation and history of what you’ve written. For single-line comments, as you’ll see below, you can use a simple # to indicate that the rest of the line is a comment.
The try:...except:...
lines allow us to try and load a parameter file from a previous run of the experiment. If that fails (e.g. because the experiment has never been run) then create a default set of parameters. These are easy to store in a python dictionary that we’ll call expInfo:
6 7 8 9 10 | try: # try to get a previous parameters file
expInfo = fromFile('lastParams.pickle')
except: # if not there then use a default set
expInfo = {'observer':'jwp', 'refOrientation':0}
expInfo['dateStr'] = data.getDateStr() # add the current time
|
The last line adds the current date to to the information, whether we loaded from a previous run or created default values.
So having loaded those parameters, let’s allow the user to change them in a dialogue box (which we’ll call dlg
). This is the simplest form of dialogue, created directly from the dictionary above. the dialogue will be presented immediately to the user and the script will wait until they hit OK or Cancel.
If they hit OK then dlg.OK=True, in which case we’ll use the updated values and save them straight to a parameters file (the one we try to load above).
If they hit Cancel then we’ll simply quit the script and not save the values.
11 12 13 14 15 16 | # present a dialogue to change params
dlg = gui.DlgFromDict(expInfo, title='simple JND Exp', fixed=['dateStr'])
if dlg.OK:
toFile('lastParams.pickle', expInfo) # save params to file for next time
else:
core.quit() # the user hit cancel so exit
|
We’ll create a file to which we can output some data as text during each trial (as well as outputting a binary file at the end of the experiment). PsychoPy actually has supporting functions to do this automatically, but here we’re showing you the manual way to do it.
We’ll create a filename from the subject+date+”.csv” (note how easy it is to concatenate strings in python just by ‘adding’ them). csv files can be opened in most spreadsheet packages. Having opened a text file for writing, the last line shows how easy it is to send text to this target document.
18 19 20 21 | # make a text file to save data
fileName = expInfo['observer'] + expInfo['dateStr']
dataFile = open(fileName+'.csv', 'w') # a simple text file with 'comma-separated-values'
dataFile.write('targetSide,oriIncrement,correct\n')
|
PsychoPy allows us to set up an object to handle the presentation of stimuli in a staircase procedure, the StairHandler
. This will define the increment of the orientation (i.e. how far it is from the reference orientation). The staircase can be configured in many ways, but we’ll set it up to begin with an increment of 20deg (very detectable) and home in on the 80% threshold value. We’ll step up our increment every time the subject gets a wrong answer and step down if they get three right answers in a row. The step size will also decrease after every 2 reversals, starting with an 8dB step (large) and going down to 1dB steps (smallish). We’ll finish after 50 trials.
23 24 25 26 27 | # create the staircase handler
staircase = data.StairHandler(startVal = 20.0,
stepType = 'db', stepSizes=[8,4,4,2],
nUp=1, nDown=3, # will home in on the 80% threshold
nTrials=1)
|
Now we need to create a window, some stimuli and timers. We need a ~psychopy.visual.Window in which to draw our stimuli, a fixation point and two ~psychopy.visual.GratingStim stimuli (one for the target probe and one as the foil). We can have as many timers as we like and reset them at any time during the experiment, but I generally use one to measure the time since the experiment started and another that I reset at the beginning of each trial.
29 30 31 32 33 34 35 36 37 38 39 40 | # create window and stimuli
win = visual.Window([800,600],allowGUI=True,
monitor='testMonitor', units='deg')
foil = visual.GratingStim(win, sf=1, size=4, mask='gauss',
ori=expInfo['refOrientation'])
target = visual.GratingStim(win, sf=1, size=4, mask='gauss',
ori=expInfo['refOrientation'])
fixation = visual.GratingStim(win, color=-1, colorSpace='rgb',
tex=None, mask='circle', size=0.2)
# and some handy clocks to keep track of time
globalClock = core.Clock()
trialClock = core.Clock()
|
Once the stimuli are created we should give the subject a message asking if they’re ready. The next two lines create a pair of messages, then draw them into the screen and then update the screen to show what we’ve drawn. Finally we issue the command event.waitKeys() which will wait for a keypress before continuing.
42 43 44 45 46 47 48 49 50 51 | # display instructions and wait
message1 = visual.TextStim(win, pos=[0,+3],text='Hit a key when ready.')
message2 = visual.TextStim(win, pos=[0,-3],
text="Then press left or right to identify the %.1f deg probe." %expInfo['refOrientation'])
message1.draw()
message2.draw()
fixation.draw()
win.flip()#to show our newly drawn 'stimuli'
#pause until there's a keypress
event.waitKeys()
|
OK, so we have everything that we need to run the experiment. The following uses a for-loop that will iterate over trials in the experiment. With each pass through the loop the staircase
object will provide the new value for the intensity (which we will call thisIncrement
). We will randomly choose a side to present the target stimulus using numpy.random.random()
, setting the position of the target to be there and the foil to be on the other side of the fixation point.
53 54 55 56 57 | for thisIncrement in staircase: # will continue the staircase until it terminates!
# set location of stimuli
targetSide= random.choice([-1,1]) # will be either +1(right) or -1(left)
foil.setPos([-5*targetSide, 0])
target.setPos([5*targetSide, 0]) # in other location
|
Then set the orientation of the foil to be the reference orientation plus thisIncrement
, draw all the stimuli (including the fixation point) and update the window.
59 60 61 62 63 64 65 66 | # set orientation of probe
foil.setOri(expInfo['refOrientation'] + thisIncrement)
# draw all stimuli
foil.draw()
target.draw()
fixation.draw()
win.flip()
|
Wait for presentation time of 500ms and then blank the screen (by updating the screen after drawing just the fixation point).
68 69 | # wait 500ms; but use a loop of x frames for more accurate timing
core.wait(0.5)
|
(This is not the most precise way to time your stimuli - you’ll probably overshoot by one frame - but its easy to understand. PsychoPy allows you to present a stimulus for acertian number of screen refreshes instead which is better for short stimuli.)
Still within the for-loop (note the level of indentation is the same) we need to get the response from the subject. The method works by starting off assuming that there hasn’t yet been a response and then waiting for a key press. For each key pressed we check if the answer was correct or incorrect and assign the response appropriately, which ends the trial. We always have to clear the event buffer if we’re checking for key presses like this
75 76 77 78 79 80 81 82 83 84 85 86 87 88 | # get response
thisResp=None
while thisResp==None:
allKeys=event.waitKeys()
for thisKey in allKeys:
if thisKey=='left':
if targetSide==-1: thisResp = 1 # correct
else: thisResp = -1 # incorrect
elif thisKey=='right':
if targetSide== 1: thisResp = 1 # correct
else: thisResp = -1 # incorrect
elif thisKey in ['q', 'escape']:
core.quit() # abort experiment
event.clearEvents() # clear other (eg mouse) events - they clog the buffer
|
Now we must tell the staircase the result of this trial with its addData()
method. Then it can work out whether the next trial is an increment or decrement. Also, on each trial (so still within the for-loop) we may as well save the data as a line of text in that .csv file we created earlier.
90 91 92 93 | # add the data to the staircase so it can calculate the next level
staircase.addData(thisResp)
dataFile.write('%i,%.3f,%i\n' %(targetSide, thisIncrement, thisResp))
core.wait(1)
|
OK! We’re basically done! We’ve reached the end of the for-loop (which occurred because the staircase terminated) which means the trials are over. The next step is to close the text data file and also save the staircase as a binary file (by ‘pickling’ the file in Python speak) which maintains a lot more info than we were saving in the text file.
95 96 97 | # staircase has ended
dataFile.close()
staircase.saveAsPickle(fileName) # special python binary file to save all the info
|
While we’re here, it’s quite nice to give some immediate feedback to the user. Let’s tell them the intensity values at the all the reversals and give them the mean of the last 6. This is an easy way to get an estimate of the threshold, but we might be able to do a better job by trying to reconstruct the psychometric function. To give that a try see the staircase analysis script of Tutorial 3.
Having saved the data you can give your participant some feedback and quit!
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | # give some output to user in the command line in the output window
print('reversals:')
print(staircase.reversalIntensities)
approxThreshold = numpy.average(staircase.reversalIntensities[-6:])
print('mean of final 6 reversals = %.3f' % (approxThreshold))
# give some on-screen feedback
feedback1 = visual.TextStim(
win, pos=[0,+3],
text='mean of final 6 reversals = %.3f' % (approxThreshold))
feedback1.draw()
fixation.draw()
win.flip()
event.waitKeys() # wait for participant to respond
win.close()
core.quit()
|