<<Tutorial1 | Tutorials|

Tutorial 2:

Measuring a JND using a staircase procedure

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).

The full code can be seen here without as many comments. 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 outputing a simple summary analysis for feedback.

  1. Get info from the user
  2. Setup the information for trials
  3. Build your stimuli
  4. Control the presentation of the stimuli
  5. Get input from the subject
  6. Output your data

Get info from the user

The first lines of code import the necessary libraries. We need lots of the psychopy components for a full experiment, as well as python's time library (to get the current date) and numpy (which handles various numerical/mathematical functions).

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.

from psychopy import core, visual, gui, data, misc, event
import time, numpy

try:#try to get a previous parameters file
    expInfo = misc.fromFile('lastParams.pickle')
except:#if not there then use a default set
    expInfo = {'observer':'jwp', 'refOrientation':0}

Useful to add the current date to the dictionary (ie. don't accidentally reload it from last time!):

expInfo['date']=time.strftime("%b_%d_%H%M", time.localtime())

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.

#present a dialogue to change params
dlg = gui.DlgFromDict(expInfo, title='simple JND Exp', fixed=['date'])
if dlg.OK:
    misc.toFile('lastParams.pickle', expInfo)#save params to file for next time
else:
    core.quit()#the user hit cancel so exit

Setup the information for trials

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 (ie. 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.

#create the staircase handler
staircase = data.StairHandler(startVal = 20.0,
              stepType = 'db', stepSizes=[8,4,4,2,2,1,1], #reduce step size every two reversals
              nUp=1, nDown=3,  #will home in on the 80% threshold
              nTrials=50)

We'll create a file to which we can output some data as text during each trial (as well as saving a more complete binary file at the end of the experiment). We'll create a filename from the subject+date+".txt" (note how easy it is to concatenate strings in python just by 'adding' them). Having opened a text file for writing, the last line shows how easy it is to send text to this target document.

#make a text file to save data
fileName = expInfo['observer'] + expInfo['date']
dataFile = open(fileName+'.txt', 'w')
dataFile.write('targetSide	oriIncrement	correct\n')

Build your stimuli

Now we need to create a window, some stimuli and timers. We need a Window in which to draw our stimuli, a fixation point and two PatchStim 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.

#create window and stimuli
win = visual.Window([800,600],allowGUI=False, monitor='testMonitor', units='deg')
foil = visual.PatchStim(win, sf=1, size=4, mask='gauss', ori=expInfo['refOrientation'])
target = visual.PatchStim(win, sf=1,  size=4, mask='gauss', ori=expInfo['refOrientation'])
fixation = visual.PatchStim(win, rgb=-1, tex=None, mask='circle',size=0.2)
globalClock = core.Clock()#to keep track of time over the exp
trialClock = core.Clock()#to keep track of time during each trial

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.

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 %.1fdegree probe." %(expInfo['refOrientation']))
#display instructions and wait
message1.draw()
message2.draw()
fixation.draw()
win.update()
#check for a keypress
event.waitKeys()

Control the presentation of the stimuli

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. 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.

for thisIncrement in staircase: #will step through the staircase
    #set location of stimuli
    targetSide= round(numpy.random.random())*2-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.

    foil.setOri(expInfo['refOrientation'] + thisIncrement)

    #draw all stimuli
    foil.draw()
    target.draw()
    fixation.draw()
    win.update()

Wait for presentation time of 500ms and then blank the screen (by updating the screen after drawing just the fixation point).

    core.wait(0.5)#wait 500ms (use a loop of x frames for more accurate timing)

    #blank screen
    fixation.draw()
    win.update()

Get input from the subject

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

    thisResp=None
    while thisResp==None:
        allKeys=event.waitKeys()
        for thisKey in allKeys:
            if (thisKey=='left' and targetSide==-1) or (thisKey=='right' and targetSide==1):
                thisResp = 1#correct
            elif (thisKey=='right' and targetSide==-1) or (thisKey=='left' and targetSide==1):
                thisResp = 0#incorrect
            elif thisKey in ['q', 'escape']:
                core.quit()#abort experiment
        event.clearEvents() #must clear other events (like mouse movements) 

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 .txt file.

    staircase.addData(thisResp)
    dataFile.write('%i	%.3f	%i\n' %(targetSide, thisIncrement, thisResp))

Output your data and clean up

OK! We're basically done! We've reached the end of the for-loop (which occured 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.

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 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.

Having saved the data you can quit!

#give some output to user
print 'reversals:'
print staircase.reversalIntensities
print 'mean of final 6 reversals = %.3f' %(numpy.average(staircase.reversalIntensities[-6:]))

core.quit()
Valid XHTML 1.0! Valid CSS!