Putting it all together—a framework and an example

Objectives

  • Be able to use a framework to guide experimental design and implementation.
  • Be able to follow a worked example of a complete experiment implemented in Python.

Across the series of previous lessons, we have covered a lot of the mechanics that go into creating a vision science experiment using Python and psychopy. Here, we are going to investigate how we can put all these components together in order to actually implement an experiment.

You now have a set of skills that mean there are very few technical impediments to running the experiment that you want to run. However, a very important thing to keep in mind is that the experience of an experiment is almost completely separate from its implementation. Hence, we need to consider precisely what it is that we want to happen during an experiment.

We are going to investigate a framework that you can use to think about the key components of your experiment. Once these components are in place, there is a very natural translation of their requirements into Python code. Note that these assume that you have already decided on the experiment’s question and hypothesis, its independent variable(s), its dependent variable(s), and its design.

We are going to examine the framework in the context of an example experiment. In this experiment, we are interested to understand whether the speed at which an ambiguous figure rotates affects how often its perceptual interpretation switches. We will be using a structure-from-motion stimulus, in which changes in local dot speed give the impression of a rotating cylinder. The independent variable will be the rotational speed of the cylinder (either 0.2 or 0.1 revolutions per second) and the dependent variable will be the frequency at which the percept of the cylinder switches.

We will consider each of the components of the experiment creation framework in turn, building up the code for the example experiment as we go.

1. What information is needed to begin an experiment session?

Here, we need to think about what information will be unique to a particular session. This will almost always include some participant identifier (such as “subj_1”), usually also in combination with a detail specific to that participant (such as the “run”/repeat number or an indicator of the experimental condition).

For the example experiment, we need the participant identifier, the condition number (1 or 2), and the repeat number:

import os
import sys

import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Cond. num.:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
cond_num = gui.data[1]
rep_num = gui.data[2]

data_path = subj_id + "_cond_" + cond_num + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

The above should all be familiar from the Providing input and Saving data lessons.

2. What data will be recorded from the experiment session?

Here, we think about the nature of the data that the experiment will produce. At this stage, we are thinking about the data at a “high level” rather than specific details. For example, we may want to simply record behavioural responses, or we may want to also track eye movements, or we may want to record EEG signals.

For the example experiment, our requirements are simple—we just need to collect behavioural responses. We prepare to record behavioural data by creating an empty list, which we will expand with data as the experiment progresses.

import os
import sys

import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Cond. num.:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
cond_num = gui.data[1]
rep_num = gui.data[2]

data_path = subj_id + "_cond_" + cond_num + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

responses = []

3. What sort of stimuli are required?

Here, we need to think about the types of stimuli that will be used in the experiment. We need to do so in a great amount of detail, taking into account aspects like size, position, form, dynamics, etc.

For the example experiment, the primary stimulus will be the structure-from-motion cylinder. We want it to be 200 pixels in height and width, have a dot size of 5 pixels, be made from 1000 dots, with each dot having a Gaussian profile. We want it to rotate around the vertical axis, with the vertical location randomised and the phase of the horizontal motion randomised.

import os
import sys

import numpy as np

import psychopy.visual
import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Cond. num.:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
cond_num = gui.data[1]
rep_num = gui.data[2]

data_path = subj_id + "_cond_" + cond_num + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

responses = []

sfm_size_pix = 200
sfm_dot_size_pix = 5
sfm_n_dots = 1000
sfm_dot_shape = "gauss"

if cond_num == "1":
    sfm_speed_rev_per_s = 0.2
elif cond_num == "2":
    sfm_speed_rev_per_s = 0.1
else:
    sys.exit("Unknown condition number")

sfm_y_pos = np.random.uniform(-sfm_size_pix / 2, +sfm_size_pix / 2, sfm_n_dots)

sfm_x_phase = np.random.uniform(0, 2 * np.pi, sfm_n_dots)

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False
)

sfm_stim = psychopy.visual.ElementArrayStim(
    win=win,
    units="pix",
    nElements=sfm_n_dots,
    elementTex=None,
    elementMask=sfm_dot_shape,
    sizes=sfm_dot_size_pix
)

win.close()

The above should be mostly familiar from the Drawing to a window and Drawing—dots lessons. The specifics on how a structure-from-motion cylinder is created will, however, be new. Here, we first set the vertical position of each dot in the cylinder (sfm_y_pos) to a random value. We also set the horizontal position of each dot in the cylinder (sfm_x_phase) to a random value, but indirectly. Rather than giving each dot a random position in pixels, we give it a random phase between 0 and 2 pi. This is because we will use a cosine function to transform the phase value into a position value during presentation.

4. What trial sequence will be used?

A typical session will be broken down into a series of trials, where each trial typically probes a particular level of the independent variable(s). Here, we need to decide how many trials will be conducted for each condition and the order in which they are presented.

For the example experiment, a given session (a participant for a particular condition at a particular repeat) only consists of a single trial—so there is no need to do anything in the code.

5. What instructions need to be provided?

It is important to think about what information a participant would need in order to be able to complete the assigned task. Such “instructions” may take the form of one or more sets of on-screen text, or they may require extensive training, practice, and feedback.

For the example experiment, we assume that participants are reasonably familiar with the stimulus and just need to be told how to respond. We will do this via on-screen text, which participants can view for as long as they like before pressing any key to commence the experiment:

import os
import sys

import numpy as np

import psychopy.visual
import psychopy.event
import psychopy.gui

gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Cond. num.:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
cond_num = gui.data[1]
rep_num = gui.data[2]

data_path = subj_id + "_cond_" + cond_num + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

responses = []

sfm_size_pix = 200
sfm_dot_size_pix = 5
sfm_n_dots = 1000
sfm_dot_shape = "gauss"

if cond_num == "1":
    sfm_speed_rev_per_s = 0.2
elif cond_num == "2":
    sfm_speed_rev_per_s = 0.1
else:
    sys.exit("Unknown condition number")

sfm_y_pos = np.random.uniform(-sfm_size_pix / 2, +sfm_size_pix / 2, sfm_n_dots)

sfm_x_phase = np.random.uniform(0, 2 * np.pi, sfm_n_dots)

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False
)

sfm_stim = psychopy.visual.ElementArrayStim(
    win=win,
    units="pix",
    nElements=sfm_n_dots,
    elementTex=None,
    elementMask=sfm_dot_shape,
    sizes=sfm_dot_size_pix
)

instructions = psychopy.visual.TextStim(
    win=win,
    wrapWidth=350,
)

instructions.text = """
Press the left arrow key when you see the front surface rotating to the left.\n
Press the right arrow key when you see the front surface rotating to the right.\n
\n
Press any key to begin.
"""

instructions.draw()
win.flip()

psychopy.event.waitKeys()

win.close()

Note the slightly new spin on defining a string in the above (see Data—strings and booleans for a reminder about strings). Rather than using the typical single quotes ("), we have used three quotes """. This is the same idea, but using three quotes allows the string to be defined across multiple lines. You also may not have yet encountered the special "\n" sequence—it indicates a new line.

Other aspects of the above should be familiar from the Introducing PsychoPy, Drawing to a window, and Providing input lessons. Something you may not have come across is the wrapWidth argument to TextStim—this asks psychopy to allow the text that is displayed to be a maximum of 350 pixels wide before moving to the next line.

6. What happens on a given trial?

This is a particularly key component of the experiment, as it defines what the participant actually sees and does. Here, we define precisely what happens on a given trial. This sequence often follows a stereotypical format, such as a 1AFC (single stimulus presentation, then response), spatial 2AFC (simultaneous presentation of two stimuli, then response), and temporal 2AFC (presentation of two stimuli one after another, then response).

For the example experiment, the trial format is straightforward. The stimulus is shown for the trial duration (set to 120 seconds), during which time the participant responds whenever they perceive a change in the subjective appearance of the stimulus.

import os
import sys

import numpy as np

import psychopy.visual
import psychopy.event
import psychopy.gui
import psychopy.core


gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Cond. num.:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
cond_num = gui.data[1]
rep_num = gui.data[2]

data_path = subj_id + "_cond_" + cond_num + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

responses = []

sfm_size_pix = 200
sfm_dot_size_pix = 5
sfm_n_dots = 1000
sfm_dot_shape = "gauss"

if cond_num == "1":
    sfm_speed_rev_per_s = 0.2
elif cond_num == "2":
    sfm_speed_rev_per_s = 0.1
else:
    sys.exit("Unknown condition number")

sfm_y_pos = np.random.uniform(-sfm_size_pix / 2, +sfm_size_pix / 2, sfm_n_dots)

sfm_x_phase = np.random.uniform(0, 2 * np.pi, sfm_n_dots)

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False
)

sfm_stim = psychopy.visual.ElementArrayStim(
    win=win,
    units="pix",
    nElements=sfm_n_dots,
    elementTex=None,
    elementMask=sfm_dot_shape,
    sizes=sfm_dot_size_pix
)

instructions = psychopy.visual.TextStim(
    win=win,
    wrapWidth=350,
)

instructions.text = """
Press the left arrow key when you see the front surface rotating to the left.\n
Press the right arrow key when you see the front surface rotating to the right.\n
\n
Press any key to begin.
"""

instructions.draw()
win.flip()

psychopy.event.waitKeys()

duration_s = 120.0

clock = psychopy.core.Clock()

while clock.getTime() < duration_s:

    phase_offset = (clock.getTime() * sfm_speed_rev_per_s) * (2 * np.pi)

    sfm_xys = []

    for i_dot in range(sfm_n_dots):

        dot_x_pos = np.cos(sfm_x_phase[i_dot] + phase_offset) * sfm_size_pix / 2.0
        sfm_xys.append([dot_x_pos, sfm_y_pos[i_dot]])

    sfm_stim.xys = sfm_xys

    sfm_stim.draw()

    win.flip()

win.close()

Much of the above will be familiar from the Temporal dynamics lesson. However, the way in which we implement the structure-from-motion will be new.

No need to worry too much about the specifics, but if you’re interested—recall that we have specified the horizontal position of each dot by a phase value between 0 and 2 pi. We first need to work out how much this phase value should be changed, given how much time has elapsed since the start of the experiment. A good way to think about it is if we wanted the dots to go through one complete revolution per second—that is, their phase should have changed by 2 pi units in one second of time. If we instead want it to go through two revolutions per second, the phase will have changed 2 pi units in half a second. Hence, to work out how many phase units we should have advanced at the current time we multiply the time by the desired number of revolutions per second and then by 2 pi.

Tip

Note that the “phase” units here are different to our previous experience with phase in relation to GratingStim. In that case, phase was bounded between 0 and 1. Here, the cos function represents phase as radians, between 0 and 2 pi. We will quickly exceed 2 pi in the code above, and we could consider using a mod function to appropriately wrap the phase to be within 0 and 2 pi. However, since that is handled naturally by the cos function, we don’t worry about it.

Having calculated how much the phase of the dots should be advanced given the time that has elapsed, we then add it to each dot’s initial random phase value and use the cos function. The result is a position between -1 and +1, which we then convert into pixels by multiplying by half of the stimulus size.

Tip

In the above code, and in the code in these lessons in general, we have emphasised clarity and readability over performance. However, if we were actually conducting this experiment we would need to either confirm that the computations were sufficiently fast that we were not “dropping frames” (not able to keep up with the refresh rate of the screen) or we would need to use (slightly) more sophisticated techniques to improve performance.

7. What data needs to be recorded for each trial?

It is important to think about what data about each trial needs to be saved. The guiding strategy is that you want to be able to have enough information to re-create the experiment afterwards. While things like participant responses are obvious information that needs to be saved, we also need to be particularly aware of any random components to the experiment. For example, if the order of conditions is randomised across the experiment, this trial sequence will be lost unless the condition information for each trial is saved. As a general rule, it is much better to save everything that you think might be useful later on than it is to not save something.

For the example experiment, we want to save the time (measured from the start of the experiment) at which the participant pressed a key, and which key was pressed. We will code the left arrow key as the number 1 and the right arrow key as the number 2.

import os
import sys

import numpy as np

import psychopy.visual
import psychopy.event
import psychopy.gui
import psychopy.core


gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Cond. num.:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
cond_num = gui.data[1]
rep_num = gui.data[2]

data_path = subj_id + "_cond_" + cond_num + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

responses = []

sfm_size_pix = 200
sfm_dot_size_pix = 5
sfm_n_dots = 1000
sfm_dot_shape = "gauss"

if cond_num == "1":
    sfm_speed_rev_per_s = 0.2
elif cond_num == "2":
    sfm_speed_rev_per_s = 0.1
else:
    sys.exit("Unknown condition number")

sfm_y_pos = np.random.uniform(-sfm_size_pix / 2, +sfm_size_pix / 2, sfm_n_dots)

sfm_x_phase = np.random.uniform(0, 2 * np.pi, sfm_n_dots)

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False
)

sfm_stim = psychopy.visual.ElementArrayStim(
    win=win,
    units="pix",
    nElements=sfm_n_dots,
    elementTex=None,
    elementMask=sfm_dot_shape,
    sizes=sfm_dot_size_pix
)

instructions = psychopy.visual.TextStim(
    win=win,
    wrapWidth=350,
)

instructions.text = """
Press the left arrow key when you see the front surface rotating to the left.\n
Press the right arrow key when you see the front surface rotating to the right.\n
\n
Press any key to begin.
"""

instructions.draw()
win.flip()

psychopy.event.waitKeys()

duration_s = 120.0

clock = psychopy.core.Clock()

while clock.getTime() < duration_s:

    phase_offset = (clock.getTime() * sfm_speed_rev_per_s) * (2 * np.pi)

    sfm_xys = []

    for i_dot in range(sfm_n_dots):

        dot_x_pos = np.cos(sfm_x_phase[i_dot] + phase_offset) * sfm_size_pix / 2.0
        sfm_xys.append([dot_x_pos, sfm_y_pos[i_dot]])

    sfm_stim.xys = sfm_xys

    sfm_stim.draw()

    win.flip()

    keys = psychopy.event.getKeys(
        keyList=["left", "right"],
        timeStamped=clock
    )

    for key in keys:

        if key[0] == "left":
            key_num = 1
        else:
            key_num = 2

        responses.append([key_num, key[1]])

win.close()

This should all be familiar from the Collecting responses lesson.

8. How should the data be saved for further analysis?

The data that is saved from a given invocation of the experimental code is highly unlikely to be the final format in which it is used. Typically, the data would need to be aggregated across repeats, summarised for a given participant, compared across participants, etc.—it is useful to think about what the most useful data format is to facilitate such future analyses. For example, if you know that your analysis will be performed in SPSS, you might want to think about directly writing an SPSS file.

The most flexible strategy, and the one we adopt here, is to use a simple text-based file format. This uses a tabular representation of the data to save it to a file that can then be imported into a variety of other programs, such as Excel and SPSS.

For the example experiment, we want to save a text file where each row corresponds to a button press by the participant, and the two columns indicate the key that was pressed and the time that it was pressed.

import os
import sys

import numpy as np

import psychopy.visual
import psychopy.event
import psychopy.gui
import psychopy.core


gui = psychopy.gui.Dlg()

gui.addField("Subject ID:")
gui.addField("Cond. num.:")
gui.addField("Repeat num:")

gui.show()

subj_id = gui.data[0]
cond_num = gui.data[1]
rep_num = gui.data[2]

data_path = subj_id + "_cond_" + cond_num + "_rep_" + rep_num + ".tsv"

if os.path.exists(data_path):
    sys.exit("Data path " + data_path + " already exists!")

responses = []

sfm_size_pix = 200
sfm_dot_size_pix = 5
sfm_n_dots = 1000
sfm_dot_shape = "gauss"

if cond_num == "1":
    sfm_speed_rev_per_s = 0.2
elif cond_num == "2":
    sfm_speed_rev_per_s = 0.1
else:
    sys.exit("Unknown condition number")

sfm_y_pos = np.random.uniform(-sfm_size_pix / 2, +sfm_size_pix / 2, sfm_n_dots)

sfm_x_phase = np.random.uniform(0, 2 * np.pi, sfm_n_dots)

win = psychopy.visual.Window(
    size=[400, 400],
    units="pix",
    fullscr=False
)

sfm_stim = psychopy.visual.ElementArrayStim(
    win=win,
    units="pix",
    nElements=sfm_n_dots,
    elementTex=None,
    elementMask=sfm_dot_shape,
    sizes=sfm_dot_size_pix
)

instructions = psychopy.visual.TextStim(
    win=win,
    wrapWidth=350,
)

instructions.text = """
Press the left arrow key when you see the front surface rotating to the left.\n
Press the right arrow key when you see the front surface rotating to the right.\n
\n
Press any key to begin.
"""

instructions.draw()
win.flip()

psychopy.event.waitKeys()

duration_s = 120.0

clock = psychopy.core.Clock()

while clock.getTime() < duration_s:

    phase_offset = (clock.getTime() * sfm_speed_rev_per_s) * (2 * np.pi)

    sfm_xys = []

    for i_dot in range(sfm_n_dots):

        dot_x_pos = np.cos(sfm_x_phase[i_dot] + phase_offset) * sfm_size_pix / 2.0
        sfm_xys.append([dot_x_pos, sfm_y_pos[i_dot]])

    sfm_stim.xys = sfm_xys

    sfm_stim.draw()

    win.flip()

    keys = psychopy.event.getKeys(
        keyList=["left", "right"],
        timeStamped=clock
    )

    for key in keys:

        if key[0] == "left":
            key_num = 1
        else:
            key_num = 2

        responses.append([key_num, key[1]])

np.savetxt(data_path, responses, delimiter="\t")

win.close()

This should all be familiar from the Saving data lesson.

Summary

We now have a fully-functioning vision science experiment. We reached this point by first emphasising the distinction between the experimental design/requirements and how the experiment is implemented in Python code. We investigated a framework in which a series of questions about the experiment are considered, and how we can use the responses to guide our process of implementing the experiment.