使用PsychoPy编写心理学实验PsychoPy

【Python】从零开始运用Pygame/PsychoPy编写一

2020-10-17  本文已影响0人  韦子谦

1 引言

分享一下如何用Python编写一个简单的心理学程序,本文介绍了两个版本的程序,分别是用Python中的第三方库Pygame和PsychoPy编写的。

Pygame是一个很经典的用于制作游戏的Python库,上手也很简单,其发布于2000年。除了做游戏之外,大家也会用它来做一些其他东西,例如心理学实验程序(之前在某本书里看到一句话,大致的意思是,心理学实验其实就相当于“boring game”,我觉得这个说法很有道理哈哈哈)。

PsychoPy则是一个用于运行行为科学实验(例如神经科学、心理学、心理物理学、语言学……)的库,最早在2007年左右面世(详细请参阅:PsychoPy—Psychophysics software in Python),并于2010年在GitHub上开源。相较于Pygame,其更专注于实验程序的编写。得益于后来加入的独立客户端以及支持可视化编程的Builder界面,PsychoPy的使用者越来越多。

编写的例子如下,我之前在编写过MATLAB的版本,感兴趣的话,可以点击链接阅读:【MATLAB】Psychtoolbox:编写一个简单的心理学实验程序

今天要编写的例子是一个很简单的按键判断的实验,只有一个block,block里有五个trial。每个trial的流程图如下。首先呈现一个500~1200ms的注视点,然后随机出现左箭头或右箭头,被试需要根据出现的刺激按方向键反应,如果500ms之内按键则记录反应时、正确率等信息,并且箭头消失,否则箭头会呈现500ms的时间后自动消失,最后是300ms的空屏。


无论是采用Pygame还是PsychoPy,都请新建一个名为“exp_example”的文件夹,在该文件夹中新建一个Python脚本(下文的代码都放在该脚本中),并在文件夹中新建两个名为“pic”和“data”的文件夹,将以下两个图片,分别命名为“exp_end.tif”和“exp_instruction.tif”,放在“pic”文件夹中。这两个图片是我用ppt画的,大家可以自行制作自己需要的指导语图片。

2 实验程序的编写

2.1 导入库

无论采用Pygame还是Psychopy,都需要在脚本文件开头添加以下代码,声明该脚本是用Python3写的,编码格式是utf-8。

#!/usr/bin/python3
# -*- coding: utf-8 -*-

接下来导入一些我们需要用到的库。

Pygame:

import csv
import random
import sys
import time
import pygame

PsychoPy:

import csv
import random
import time
from psychopy import gui, visual, event, clock, core

Python的强大之处就是存在着各种各样的标准库(library)和第三方模组(module),我们可以从各种库中调用各种函数,以满足我们的编程需求,这样就避免了“造车先造轮子”的问题,因为别人已经帮我们把轮子造好了。

例如,我们想使用Pygame的函数,则需要在脚本的开头通过import pygame来导入这个库。

一个模组可能由多个包(package)组成,有时我们只需要使用一个模组中的某个/某些包,此时可以用from 模组 import 包的形式来导入。例如我们只想使用PsychoPy模组中的visual包,则输入from psychopy import visual,然后便可以直接调用该包的函数,例如visual.Window(),而不需要psychopy.visual.Window()。一方面简化了代码,另一方面节省了导入库所需的时间。

2.2 收集被试的基本信息

Pygame:

# 收集被试的基本信息
sub_info = [input('请输入被试编号:'),
            input('请输入性别(1=male,2=female):'),
            input('请输入年龄:'),
            input('请输入利手(1=left, 2=right, 3=both):')]

input()函数的作用是,让用户在控制台输入一些信息,括号内的参数则是对于需要输入的内容的提示。在这里,我们收集了四个基本信息,并以列表的形式储存至sub_info这个变量中,之后我们便可以通过调用该变量的值来获取这些信息。

例如,输入的被试编号为“1001”,之后我们想知道本次实验的被试的编号,就可以通过sub_info[0]来获取(在Python中,列表的第一个元素的索引是0,第二个是1,以此类推)。

PsychoPy:

# 收集被试的基本信息
sub_info = {'subNum': '', 'gender': ['male', 'female'],
            'age': '', 'handedness': ['right', 'left', 'both']}
inputDlg = gui.DlgFromDict(dictionary=sub_info, title='exp_example',
                           order=['subNum', 'gender', 'age', 'handedness'])

在PsychoPy中,我们可以运用更酷炫的方法来收集被试的基本信息,也就是gui.DlgFromDict()函数(gui.Dlg()函数也可以做到相同的功能,但语句相对而言比较繁琐,所以更推荐用gui.DlgFromDict())。

首先,我们需要定义一个名为sub_info的字典(dictionary),在字典变量中,每个元素都是一个键值对,冒号之前的是键(key),冒号之后的是值(value)。我们将需要收集的信息放置在这个字典变量中。

接着调用gui.DlgFromDict()函数,根据sub_info的内容,打开一个对话框,用于输入被试的基本信息。若字典中的键对应的值是空值,那么将在对话框中添加一个输入栏,若键对应的值是一个字符串列表,那么将在对话框中创建一个下拉列表(见下图)。

另外两个参数titleorder,分别是设置对话框的标题,以及设置字典变量在对话框中的呈现顺序(默认的顺序是根据键的首字母来排列)。

填写完毕并点击“OK”之后,sub_info中的值就会根据填写的内容而变化。例如,我们在对话框中输入被试编号为“1001”。那么之后便可以通过sub_info{'subNum'}来调用这个“1001”数值。

此外我们还可以根据用户对打开的对话框的操作,在终端输出相应的信息,例如:

if inputDlg.OK:  # 点击OK
    print(sub_info)
else:  # 点击Cancel
    print('user cancelled')

若点击OK,则显示输入的信息,若点击Cancel,则显示“user cancelled”。

2.3 打开窗口

无论是MATLAB中的PsychToolBox,还是Python中的Pygame、PsychoPy,编写实验程序的套路都是一样的。即,先打开一个窗口,然后将刺激呈现在这个窗口上(Draw & Flip!)。

Pygame:

# 打开窗口,设置一些参数
pygame.init()  # pygame初始化
x_pixels, y_pixels = pygame.display.list_modes()[0]  # 获取屏幕分辨率
win = pygame.display.set_mode((x_pixels, y_pixels), pygame.FULLSCREEN)  # 打开窗口
x_center = int(x_pixels / 2)  # 获取屏幕中心坐标
y_center = int(y_pixels / 2)
win.fill((0, 0, 0))  # 将窗口设置为黑色
font = pygame.font.SysFont('SimHei', 50)  # 字体 & 字号
pygame.mouse.set_visible(False)  # 隐藏鼠标指针

为了使用Pygame,首先需要输入pygame.init(),使Pygame初始化,如果没有这段代码,直接调用Pygame的函数,程序就会出错。

接着,我们通过pygame.display.list_modes()这个函数,来获取电脑屏幕的分辨率,该函数以列表(list)的形式返回了该电脑可用的分辨率,例如:

[(1920, 1080), (1680, 1050), (1600, 900), ……(320, 240), (320, 200)]

在这里,我们调用这个列表的第一个元素,即[0],本例中就是(1920, 1080)啦,我们将这两个数值赋给变量x_pixelsy_pixels

接着,我们调用根据x_pixelsy_pixels的值,通过pygame.display.set_mode()函数,打开一个窗口,我们将这个窗口命名为win。顾名思义,pygame.FULLSCREEN这个参数的目的是将窗口设置为全屏。

接着,我们将x_pixelsy_pixels除以2,分别赋值给x_centery_center,作为屏幕的中心坐标。Pygame打开的窗口,左上角的坐标是 (0, 0),右下角的坐标是屏幕的分辨率,因此中心点的坐标就是分辨率除以2啦。如果我们想在屏幕上的一些位置呈现刺激,那么这些刺激的坐标就可以设置为 (x_center ± 数值,y_center ± 数值),这样理解起来更方便。

win.fill()的作用是更改win这个窗口的背景色,0,0,0是黑色的RGB颜色值,我们通过这个函数,将窗口的背景改为黑色。

font = pygame.font.SysFont('SimHei', 50)则是设置窗口中呈现的文本对象的字体(SimHei,也就是“黑体”)和字号(字号为50),之后我们便可以通过调用font这个变量,来呈现文本刺激(包括中文的文本,前提是我们选择了类似SimHei这种支持中文的字体)。

pygame.mouse.set_visible(False)True就是显示鼠标指针,False则是隐藏鼠标指针。

PsychoPy:

# 打开一个1920*1080分辨率的窗口,黑色背景,全屏,单位为像素,中心坐标是(0,0)
win = visual.Window(size=[1920, 1080], color='#010101', fullscr=True, units='pix')
event.Mouse(visible=False)  # 隐藏鼠标指针

PsychoPy的代码就简单多了,直接用visual.Window()打开一个窗口即可。visual.Window()有很多可更改的参数,建议大家前往官网的文档页面学习一下,目前我这里只写了该程序中必须要设定的参数。

值得一提的是,PsychoPy的优势之一在于提供了多种单位类型,我这里使用了像素作为单位。无论采用何种单位,窗口中心的坐标始终为(0, 0)(即,窗口中心始终是坐标轴原点),相应地,设置视觉刺激的坐标时,正数意味着上/右,负数意味着下/左。

以下是对各种可选单位的小结,参考自官方文档:Units for the window and stimuli。表格中提到了窗口(window)和屏幕(screen),前者指的是visual.Window()打开的窗口,后者指的是电脑显示器的屏幕。

单位名称 说明 需要提供的信息
高度单位(Height unit) 基于窗口的高度指定刺激内容。这里的高度对应的是y轴,其范围始终为-0.5至0.5,x轴的范围则可以基于此数值进行换算。
例如,对于一个宽高比为16:10的窗口,左下角的坐标是(-0.8, -0.5),右上角的坐标是(0.8, 0.5)。
标准化单位(Normalised units) 窗口坐标系的范围始终为-1至1,其中左下角的坐标为(-1, -1),右上角的坐标为(1, 1)。使用此单位时,刺激的形状会因为窗口比例的不同而扭曲。
例如,对于4:3的窗口,size=(0.75,1)才是正方形。
基于屏幕的厘米单位(Centimeters on screen) 以厘米为单位设置刺激的大小和位置,坐标轴的范围便是屏幕的高度、宽度。
例如,右上角的坐标是(屏幕宽度/2, 屏幕高度/2)。
屏幕的宽度(单位:cm、像素)
视角度单位(Degrees of visual angle) 使用视角度设置刺激的大小和位置。该单位有三种计算方式:degdegFlatdegFlatPos,具体可以查阅官方文档的说明。 屏幕的宽度(单位:cm、像素),以及被试距离屏幕的距离(单位:cm。可以在Monitor Center里设置)
基于屏幕的像素单位(Pixels in screen) 以像素为单位设置刺激的大小和位置。 屏幕的宽度(单位:像素)

2.4 准备实验材料,呈现指导语

这一部分,Pygame和Psychopy的代码是一样的。

Pygame & Psychopy:

# 准备数据变量
rt = []
resp = []
acc = []
dots_time = []
arrow_orientation = []

# 随机生成注视点时间和刺激材料
for i in range(0, 5):
    dots_time.append(random.uniform(0.5, 1.2))  # 注视点的呈现时间(包含500 & 1200)
    if random.randint(0, 1) == 0:  # 随机决定箭头的朝向
        arrow_orientation.append('←')
    else:
        arrow_orientation.append('→')

先定义几个名为rtrespacc的空列表,之后我们便可以将被试的反应时、反应按键和正确率,添加到这些列表中。dots_timearrow_orientation这两个列表则用于存储注视点时间和箭头刺激。

如果你不记得这个例子的实验设计,可以回头去看看文章开头的实验流程图。在本例中,注视点的呈现时间是在一个范围内随机确定的(500~1200ms),箭头刺激则是左箭头和右箭头随机呈现。

这个例子中共有5个trial,所以我们需要五个注视点时间和五个箭头刺激。for i in range(0, 5):就是将下述的语句循环五次的意思。

random.uniform()是random库中的一个函数,作用是生成一个指定范围之内的随机浮点数,我们通过这个函数随机生成一个数值,并使用dots_time.append()将该数值添加至dots_time这个列表中。

例如,生成的第一个随机数是0.879,我们将这个数值添加至dots_time这个列表中,此时该列表的内容从[]变成了[0.879],之后我们便可以通过dots_time[0]来调用这个数值。

对于箭头刺激也是类似的逻辑,我们先通过random.randint(0, 1)生成一个随机整数(在这里,范围为0~1),并通过if语句判断,如果结果是0,则向arrow_orientation这个列表中添加一个内容为左箭头的字符串,否则添加一个右箭头。

2.5 呈现指导语

Pygame:

# 呈现指导语
instruction = pygame.image.load('pic/exp_instruction.tif')
instruction_size = instruction.get_rect()
win.blit(instruction, (instruction_size[2] - x_center, instruction_size[3] - y_center))
pygame.display.update()
wait = True
while wait:  # 等待按键
    for event in pygame.event.get():
        if event.type == pygame.KEYDOWN:
            wait = False

在Pygame中,呈现图片的一般方法如下:先使用pygame.image.load()函数读取指定文件夹中的图片,将读取到的内容放置到一个变量中,然后使用窗口名.blit(图片变量),将图片变量绘制在指定的窗口内,最后使用pygame.display.flip()或者pygame.display.update()来刷新窗口,从而使绘制好的图片能够呈现出来。

接下来简要说说Pygame窗口的坐标系。

在Pygame的窗口中,窗口左上角的坐标是(0, 0)。窗口上方的边界是X轴,左边的边界是Y轴。假设窗口的大小是1920*1080像素,那么窗口右上角的坐标是(1920, 0),左下角的坐标是(0, 1080)。

对于blit(),第一个参数是图片变量,第二个参数是图片在窗口中的位置,这个位置的参考点是图片的左上角。

所以,如果我们将图片的呈现位置设置为(0, 0),那么图片将呈现在窗口的左上方,且图片的左上角正好位于窗口的左上角。

所以,为了使指导语图片能够呈现在屏幕中心,我们首先需要通过instruction.get_rect()函数来获取指导语图片的大小。假设图片的大小为1280*720像素,那么该函数返回的结果将是<rect(0, 0, 1280, 720)>,四个数值分别指图片左上角距离Y轴的距离、图片左上角距离X轴的距离、图片的宽、图片的高。

于是,将图片的坐标设置为(instruction_size[2] - x_center, instruction_size[3] - y_center),这样图片就可以呈现在屏幕中心了。

PsychoPy:

# 生成实验材料
fixation = visual.Circle(win, fillColor='#FFFFFF', radius=5)
arrow = visual.TextStim(win, font='SimHei', color='#FFFFFF', height=50)
instruction = visual.ImageStim(win, image='pic/exp_instruction.tif')
exp_end = visual.ImageStim(win, image='pic/exp_end.tif')

# 呈现指导语
instruction.draw()
win.flip()
event.waitKeys()

至于PsychoPy,刺激呈现的逻辑大致是这样的:创建(create)刺激、绘制(draw)刺激,然后刷新窗口(flip)。在正式实验开始前,我们先创建所需的刺激,之后当需要呈现这些刺激时,只需要draw & flip就可以了。这样我们就可以在正式实验中省去创建刺激所用的时间,减少计时误差。

这里我们创建了四个刺激,依次为注视点(fixation)、箭头刺激(arrow)指导语(instruction)、结束语(exp_end)。

PsychoPy提供了众多用于创建视觉刺激的函数,这里使用了三种函数。

接着是呈现指导语,正如上文所述,先将刺激变量绘制出来(draw()),然后刷新屏幕(flip())即可。接着我们调用event.waitKeys()函数,等待被试按任意键继续。

2.6 每个trial的内容

对于Pygame和PsychoPy,无论是呈现什么视觉刺激,思路都是一样的:

  1. 绘制刺激(对于Pygame,在绘制刺激之前,还需要将背景填充为黑色,以覆盖掉之前在屏幕上呈现的内容),
  2. 刷新屏幕,使绘制的内容能够呈现出来。
  3. 等待一段时间(例如,实验设计为刺激呈现500ms,那么就等待500ms),或者等待某个事件(例如等待被试按键反应)。

首先添加以下代码:

# 总共5个trial,每个trial的内容如下
for trial in range(0, 5):

这是一个for循环,循环语句内的代码将反复5次,即,总共有5个trial。

接下来,2.6.1至2.6.4的内容都放置在这个for循环语句内。

2.6.1 注视点

Pygame:

    # 注视点:500~1200ms
    win.fill((0, 0, 0))
    pygame.draw.circle(win, (255, 255, 255), (x_center, y_center), 5)
    pygame.display.update()
    time.sleep(dots_time[trial])

首先是注视点的绘制,pygame.draw.circle()是Pygame绘制圆形的函数,我们就通过该函数来绘制注视点。函数中的几个参数分别是:绘制的窗口(就是win啦),圆形的颜色(255, 255, 255指的是白色),圆形的坐标位置((x_center, y_center)就是屏幕中心啦),5则是圆形的半径。绘制完毕后刷新屏幕,接着我们通过time.sleep()来使程序等待一会,time.sleep()中的参数就相当于注视点的呈现时间(单位:秒),这里我们直接调用了dots_time中的数值。

PsychoPy:

    # 注视点:500~1200ms
    fixation.draw()
    win.flip()
    clock.wait(dots_time[trial])

PsychoPy的部分就简单多了,因为我们刚刚已经把所以刺激都创建好了,现在只需要draw & flip就好。刷新屏幕后,通过clock.wait()使程序等待一段时间(单位:秒),这里直接调用dots_time这个列表中的值即可。

2.6.2 箭头刺激

    # 箭头刺激:500ms
    win.fill((0, 0, 0))
    arrow = font.render(arrow_orientation[trial], True, (255, 255, 255))  # 创建文本
    arrow_x, arrow_y = arrow.get_size()  # 获取文字的高度和宽度
    win.blit(arrow, (int(x_center - arrow_x / 2), int(y_center - arrow_y / 2)))
    pygame.display.update()

刚刚绘制的注视点是一个图形,而现在要绘制的则是一个文本字符串。我们通过font.render()函数来绘制箭头,其中的参数分别是:字符串(我们已经将准备好的箭头刺激放在arrow_orientation这个列表里啦,所以这里直接调用就好),是否抗锯齿(当然是选“True”啦),字体颜色。

win.blit()中设置坐标时,注意横坐标、纵坐标要分别减去字符串高度、宽度的一半,这样才可以呈现在屏幕正中心。

PsychoPy:

    # 箭头刺激:500ms
    arrow.text = arrow_orientation[trial]
    arrow.draw()
    win.flip()

和呈现注视点类似。区别在于,我们先调用arrow_orientation这个列表中的值,作为arrow的文本内容。

2.6.3 记录反应信息

该部分,Pygame和PsychoPy版本的程序的代码,基本是相同的,只是个别函数不同,因此先展示两个版本的代码,然后一齐讲解。

Pygame:

    # 记录反应信息
    # 如果在500ms之内反应,则记录按键和反应时
    key_check = False
    t0 = time.time()  # 获取刺激开始呈现的时间
    while time.time() - t0 < 0.5:  # 在500ms内可以反应
        for event in pygame.event.get():
            if event.type == pygame.KEYDOWN:
                if not key_check:
                    key_check = True
                    if event.key == pygame.K_ESCAPE:
                        pygame.quit()
                        sys.exit()
                    else:
                        rt.append(time.time() - t0)
                        resp.append(pygame.key.name(event.key))
                        if event.key == pygame.K_LEFT and arrow_orientation[trial] == '←':
                            acc.append(1)
                        elif event.key == pygame.K_RIGHT and arrow_orientation[trial] == '→':
                            acc.append(1)
                        else:
                            acc.append(0)
                    break
    if not key_check:  # 未按键的情况下,反应信息记为"None"
        rt.append('None')
        resp.append('None')
        acc.append('None')

PsychoPy:

    # 记录反应信息
    # 如果在500ms之内反应,则记录按键和反应时
    key_check = False
    t0 = core.getTime()
    while core.getTime() - t0 < 0.5:  # 在500ms内可以反应
        key = event.getKeys()
        if len(key) != 0:
            rt.append(core.getTime() - t0)
            resp.append(key[0])
            key_check = True
            if key[0] == 'escape':
                win.close()
            else:
                if key[0] == 'left' and arrow_orientation[trial] == '←':
                    acc.append(1)
                elif key[0] == 'right' and arrow_orientation[trial] == '→':
                    acc.append(1)
                else:
                    acc.append(0)
            break
    if not key_check:  # 未按键的情况下,反应信息记为"None"
        rt.append('None')
        resp.append('None')
        acc.append('None')

或许大家会注意到,在上述的代码中,我们并没有使用time.sleep()(Pygame部分)或者clock.wait()(PsychoPy部分)来指定箭头刺激呈现的时间。因为time.sleep()clock.wait()实际上是将程序挂起,而挂起的这段时间内我们是无法进行任何操作的。

我们的设想是,被试需要在刺激呈现的500ms内反应,若反应,则刺激消失,否则500ms后刺激自动消失。

因此,我们可以通过while循环语句来反复获取时间,当时间在500ms之内时,获取被试的按键并记录反应信息。

此外,我们还定义了一个变量key_check,其初始值为“False”,当被试按键时,我们将key_check的值改为“True”。500ms的循环结束后,假若key_check的值仍然是“False”,则我们将反应时、反应按键、正确率记为“None”。

现在来看看while循环中的内容,在500ms内,我们反复地通过pygame.event.get()(Pygame部分)或key = event.getKeys()来(PsychoPy部分)来获取被试的操作:

K_ESCAPE(Pygame版本)、'escape'(PsychoPy版本)指的是“Esc”键,我们将这个按键设置为退出键,当按下这个按键时,程序将被强制关闭。

如果按下的按键不是“Esc”键,那么:

Pygame:

PsychoPy:

Pygame中的按键名可以在Pygame的官方文档中查阅(如下图所示)。

PsychoPy的按键名列表我没有找到,我是先运行一遍,然后看看按下某个按键会返回什么。

2.6.4 空屏

Pygame:

    # 空屏:300ms
    win.fill((0, 0, 0))
    pygame.display.update()
    time.sleep(0.3)

PsychoPy:

    # 空屏:300ms
    win.flip()
    clock.wait(0.3)

这部分比较容易理解,就不展开说了。

2.7 呈现结束语,关闭窗口

Pygame:

# 呈现结束语
exp_end = pygame.image.load('pic/exp_end.tif')
exp_end_size = exp_end.get_rect()
win.blit(exp_end, (exp_end_size[2] - x_center, exp_end_size[3] - y_center))
pygame.display.update()
time.sleep(1)

# 关闭窗口
pygame.quit()

PsychoPy:

# 呈现结束语
exp_end.draw()
win.flip()
clock.wait(1)

# 关闭窗口
win.close()

呈现结束语的套路和呈现指导语是一样的,区别在于,呈现结束语的1秒之后,我们通过pygame.quit()(Pygame版本)或win.close()(PsychoPy版本)将窗口关闭。

2.8 储存数据

Pygame:

# 储存数据
date = (time.strftime("%Y_%b_%d_%H%M%S"))  # 获取时间
c = open('data/exp_example_pygame_{}_{}.csv'.format(sub_info[0], date),
         'w', encoding='utf-8', newline='')  # 创建csv表格
csv_writer = csv.writer(c)  # 基于文件对象构建csv写入对象
csv_writer.writerow(['SubjectNumber', 'Gender', 'Age', 'Handedness',
                     'DotsTime', 'Arrow', 'RT', 'Resp', 'ACC'])  # 表头
for trial in range(0, 5):  # 写入csv
    csv_writer.writerow([sub_info[0], sub_info[1], sub_info[2], sub_info[3],
                         dots_time[trial], ord(arrow_orientation[trial]),
                         rt[trial], resp[trial], acc[trial]])
c.close()  # 关闭csv表格

print('Succeed!')

PsychoPy:

# 储存数据
date = (time.strftime("%Y_%b_%d_%H%M%S"))  # 获取时间
c = open('data/exp_example_psychopy_{}_{}.csv'.format(sub_info['subNum'], date),
         'w', encoding='utf-8', newline='')  # 创建csv表格
csv_writer = csv.writer(c)  # 基于文件对象构建csv写入对象
csv_writer.writerow(['SubjectNumber', 'Gender', 'Age', 'Handedness',
                     'DotsTime', 'Arrow', 'RT', 'Resp', 'ACC'])  # 表头
for trial in range(0, 5):  # 写入csv
    csv_writer.writerow([sub_info['subNum'], sub_info['gender'], sub_info['age'], sub_info['handedness'],
                         dots_time[trial], ord(arrow_orientation[trial]),
                         rt[trial], resp[trial], acc[trial]])
c.close()  # 关闭csv表格

print('Succeed!')

两个版本的代码是基本一样的,所以一齐讲解。

首先,我们使用time.strftime()函数,获取日期和时间,例如:

'2020_Oct_16_223718'

表示“2020年10月16日,22点37分18秒”。

接着,通过open()函数,在“data”文件夹中创建一个新文件,将文件名命名为:exp_example_pygame_{}_{}.csv(Pygame版本)或exp_example_psychopy_{}_{}.csv(PsychoPy版本),我们通过format()函数,将sub_info[0](Pygame版本)或sub_info['subNum'](PsychoPy版本)和date的值放入到文件名中。

最后生成的文件名将会类似这种样子:

exp_example_psychopy_1001_2020_Oct_16_223718.csv

这将保证,每次运行所得的数据文件,文件名都是独一无二的,因此即便输入了相同的被试编号,也无需担心数据文件会被同名文件覆盖掉。

接下来,我们构建一个csv写入对象,名为csv_writer,然后就可以通过writerow()一行行地写入啦。

首先写个表头,依次是被试编号、性别、年龄、利手、注视点时间、箭头朝向、反应按键、反应时、正确率。

然后循环5次,依次写入每个trial的数据。其中,arrow_orientation的内容是特殊符号,写入时会乱码,所以我通过ord函数将其转换为ASCII字符串了,'←'是8592,'→'是8594。

最后关闭数据文件。

然后在终端输出“Succeed!”(这个不是必要操作,但是我喜欢哈哈哈)。

3 实验程序的运行效果

PsychoPy的程序在进入全屏之后,录屏软件就不起作用了,目前我还搞不懂是咋回事,先放个Pygame版本的运行效果好了(其实两个版本运行起来都差不多)。

运行结束后,显示“Succeed!”,看来是顺利运行了!

前往“data”文件夹,找到运行完毕后生成的数据文件,如下。

首先,被试编号、性别、年龄、利手,都和我们输入的数值一致。注视点时间确实处于500~1200ms的范围内。刚刚的运行过程中,5个trial的箭头朝向分别是“右左左右右”,数据文件中的记录也是一致的(“左箭头”记录为8592,“右箭头”记录为8594)。反应时也处于合理的范围。刚刚运行时,我的按键反应分别是“右方向键、左方向键、上方向键、左方向键、无反应”,其中前两个是正确反应,第三、第四个是错误反应,在数据文件中,这些信息的记录也是无误的。

Pygame版本完整代码:

#!/usr/bin/python3
# -*- coding: utf-8 -*-

import csv
import random
import sys
import time
import pygame

# 收集被试的基本信息
sub_info = [input('请输入被试编号:'),
            input('请输入性别(1=male,2=female):'),
            input('请输入年龄:'),
            input('请输入利手(1=left, 2=right, 3=both):')]

# 打开窗口,设置一些参数
pygame.init()  # pygame初始化
x_pixels, y_pixels = pygame.display.list_modes()[0]  # 获取屏幕分辨率
win = pygame.display.set_mode((x_pixels, y_pixels), pygame.FULLSCREEN)  # 打开窗口
x_center = int(x_pixels / 2)  # 获取屏幕中心坐标
y_center = int(y_pixels / 2)
win.fill((0, 0, 0))  # 将窗口设置为黑色
font = pygame.font.SysFont('SimHei', 50)  # 字体 & 字号
pygame.mouse.set_visible(False)  # 隐藏鼠标指针

# 准备数据变量
rt = []
resp = []
acc = []
dots_time = []
arrow_orientation = []

# 随机生成注视点时间和刺激材料
for i in range(0, 5):
    dots_time.append(random.uniform(0.5, 1.2))  # 注视点的呈现时间(包含500 & 1200)
    if random.randint(0, 1) == 0:  # 随机决定箭头的朝向
        arrow_orientation.append('←')
    else:
        arrow_orientation.append('→')

# 呈现指导语
instruction = pygame.image.load('pic/exp_instruction.tif')
instruction_size = instruction.get_rect()
win.blit(instruction, (instruction_size[2] - x_center, instruction_size[3] - y_center))
pygame.display.update()
wait = True
while wait:  # 等待按键
    for event in pygame.event.get():
        if event.type == pygame.KEYDOWN:
            wait = False

# 总共5个trial,每个trial的内容如下
for trial in range(0, 5):

    # 注视点:500~1200ms
    win.fill((0, 0, 0))
    pygame.draw.circle(win, (255, 255, 255), (x_center, y_center), 5)
    pygame.display.update()
    time.sleep(dots_time[trial])

    # 箭头刺激:500ms
    win.fill((0, 0, 0))
    arrow = font.render(arrow_orientation[trial], True, (255, 255, 255))  # 创建文本
    arrow_x, arrow_y = arrow.get_size()  # 获取文字的高度和宽度
    win.blit(arrow, (int(x_center - arrow_x / 2), int(y_center - arrow_y / 2)))
    pygame.display.update()

    # 记录反应信息
    # 如果在500ms之内反应,则记录按键和反应时
    key_check = False
    t0 = time.time()  # 获取刺激开始呈现的时间
    while time.time() - t0 < 0.5:  # 在500ms内可以反应
        for event in pygame.event.get():
            if event.type == pygame.KEYDOWN:
                if not key_check:
                    key_check = True
                    if event.key == pygame.K_ESCAPE:
                        pygame.quit()
                        sys.exit()
                    else:
                        rt.append(time.time() - t0)
                        resp.append(pygame.key.name(event.key))
                        if event.key == pygame.K_LEFT and arrow_orientation[trial] == '←':
                            acc.append(1)
                        elif event.key == pygame.K_RIGHT and arrow_orientation[trial] == '→':
                            acc.append(1)
                        else:
                            acc.append(0)
                    break
    if not key_check:  # 未按键的情况下,反应信息记为"None"
        rt.append('None')
        resp.append('None')
        acc.append('None')

    # 空屏:300ms
    win.fill((0, 0, 0))
    pygame.display.update()
    time.sleep(0.3)

# 呈现结束语
exp_end = pygame.image.load('pic/exp_end.tif')
exp_end_size = exp_end.get_rect()
win.blit(exp_end, (exp_end_size[2] - x_center, exp_end_size[3] - y_center))
pygame.display.update()
time.sleep(1)

# 关闭窗口
pygame.quit()

# 储存数据
date = (time.strftime("%Y_%b_%d_%H%M%S"))  # 获取时间
c = open('data/exp_example_pygame_{}_{}.csv'.format(sub_info[0], date),
         'w', encoding='utf-8', newline='')  # 创建csv表格
csv_writer = csv.writer(c)  # 基于文件对象构建csv写入对象
csv_writer.writerow(['SubjectNumber', 'Gender', 'Age', 'Handedness',
                     'DotsTime', 'Arrow', 'RT', 'Resp', 'ACC'])  # 表头
for trial in range(0, 5):  # 写入csv
    csv_writer.writerow([sub_info[0], sub_info[1], sub_info[2], sub_info[3],
                         dots_time[trial], ord(arrow_orientation[trial]),
                         rt[trial], resp[trial], acc[trial]])
c.close()  # 关闭csv表格

print('Succeed!')

PsychoPy版本完整代码:

#!/usr/bin/python3
# -*- coding: utf-8 -*-

import csv
import random
import time
from psychopy import gui, visual, event, clock, core

# 收集被试的基本信息
sub_info = {'subNum': '', 'gender': ['male', 'female'],
            'age': '', 'handedness': ['right', 'left', 'both']}
inputDlg = gui.DlgFromDict(dictionary=sub_info, title='exp_example',
                           order=['subNum', 'gender', 'age', 'handedness'])

# 打开一个1920*1080分辨率的窗口,黑色背景,全屏,单位为像素,中心坐标是(0,0)
win = visual.Window(size=[1920, 1080], color='#010101', fullscr=True, units='pix')
event.Mouse(visible=False)  # 隐藏鼠标指针

# 准备数据变量
rt = []
resp = []
acc = []
dots_time = []
arrow_orientation = []

# 随机生成注视点时间和刺激材料
for i in range(0, 5):
    dots_time.append(random.uniform(0.5, 1.2))  # 注视点的呈现时间(包含500 & 1200)
    if random.randint(0, 1) == 0:  # 随机决定箭头的朝向
        arrow_orientation.append('←')
    else:
        arrow_orientation.append('→')

# 生成实验材料
fixation = visual.Circle(win, fillColor='#FFFFFF', radius=5)
arrow = visual.TextStim(win, font='SimHei', color='#FFFFFF', height=50)
instruction = visual.ImageStim(win, image='pic/exp_instruction.tif')
exp_end = visual.ImageStim(win, image='pic/exp_end.tif')

# 呈现指导语
instruction.draw()
win.flip()
event.waitKeys()

# 总共5个trial,每个trial的内容如下
for trial in range(0, 5):

    # 注视点:500~1200ms
    fixation.draw()
    win.flip()
    clock.wait(dots_time[trial])

    # 箭头刺激:500ms
    arrow.text = arrow_orientation[trial]
    arrow.draw()
    win.flip()

    # 记录反应信息
    # 如果在500ms之内反应,则记录按键和反应时
    key_check = False
    t0 = core.getTime()
    while core.getTime() - t0 < 0.5:  # 在500ms内可以反应
        key = event.getKeys()
        if len(key) != 0:
            rt.append(core.getTime() - t0)
            resp.append(key[0])
            key_check = True
            if key[0] == 'escape':
                win.close()
            else:
                if key[0] == 'left' and arrow_orientation[trial] == '←':
                    acc.append(1)
                elif key[0] == 'right' and arrow_orientation[trial] == '→':
                    acc.append(1)
                else:
                    acc.append(0)
            break
    if not key_check:  # 未按键的情况下,反应信息记为"None"
        rt.append('None')
        resp.append('None')
        acc.append('None')

    # 空屏:300ms
    win.flip()
    clock.wait(0.3)

# 呈现结束语
exp_end.draw()
win.flip()
clock.wait(1)

# 关闭窗口
win.close()

# 储存数据
date = (time.strftime("%Y_%b_%d_%H%M%S"))  # 获取时间
c = open('data/exp_example_psychopy_{}_{}.csv'.format(sub_info['subNum'], date),
         'w', encoding='utf-8', newline='')  # 创建csv表格
csv_writer = csv.writer(c)  # 基于文件对象构建csv写入对象
csv_writer.writerow(['SubjectNumber', 'Gender', 'Age', 'Handedness',
                     'DotsTime', 'Arrow', 'RT', 'Resp', 'ACC'])  # 表头
for trial in range(0, 5):  # 写入csv
    csv_writer.writerow([sub_info['subNum'], sub_info['gender'], sub_info['age'], sub_info['handedness'],
                         dots_time[trial], ord(arrow_orientation[trial]),
                         rt[trial], resp[trial], acc[trial]])
c.close()  # 关闭csv表格

print('Succeed!')

4 扩展

4.1 扩展1:文本形式的指导语

有时候,我们并不需要很复杂的指导语,或者只是希望呈现一两句话(例如告知被试休息一会再继续实验),那么可以通过以下代码来文本,这样就不需要另外准备文字图片了。其中,\n是Python中的换行符。

Pygame:

#呈现指导语
win.fill((0,0,0))
instruction=font.render('这是指导语!\n这是指导语的第二行!',True,(255,255,255))#创建文本
arrow_x,arrow_y=instruction.get_size()#获取文字的高度和宽度
win.blit(instruction,(int(x_center-arrow_x/2),int(y_center-arrow_y/2)))
pygame.display.update()
wait=True
while wait:#等待按键
    for eventinpygame.event.get():
        if event.type == pygame.KEYDOWN:
wait=False

至于PsychoPy,其实就是用visual.TextStim()函数,上文已经提到过了。

4.2 扩展2 Python中的代码复用

如果程序比较复杂,可以通过代码复用来节省代码量,增加脚本的可读性。

以spatial cueing task为例,这是注意研究中经常用到的一个实验范式。

cueing task (Asplund, Todd, Snyder, & Marois, 2010)

在该任务中,每个trial有四个“页面”,每个页面都需要画一个注视点和两个方框。如果没有代码复用的话,编写出来的脚本会显得非常冗余。

解决的办法是,我们可以自己定义一个函数,这个函数的作用就是画两个方框和一个注视点。绘制每个页面时,只需要调用刚刚自定义的函数即可。

例如,自定义一个叫draw_circle_and_rect()的函数(以PsychoPy为例):

def draw_circle_and_rect(fixation_color):
    """
    usage: 画一个注视点以及两个矩形方框
    :param fixation_color: 设置注视点的颜色
    :return: none
    """
    fixation = visual.TextStim(win, font='SimHei', color=fixation_color, height=50)
    rect_l = 绘制左边矩形的代码  # 可以使用visual.Rect()函数,具体代码就不写了
    rect_r = 绘制右边矩形的代码

    fiaxtion.draw()
    rect_l.draw()
    rect_r.draw()

这部分代码放在脚本开头,import部分的后面。

然后,当我们想要绘制每个trial的内容时,就可以直接调用这个函数(以第一、第二个页面为例):

  # cue:200ms
  draw_circle_and_rect(fixation_color=其他颜色)  # 根据需要填写颜色代码
  win.flip()
  clock.wait(0.2)
  
  # variable delay:2000-8000ms
  draw_circle_and_rect(fixation_color='#FFFFFF')
  win.flip()
  clock.wait(random.random.uniform(2, 8))

  # 后面的以此类推

4.3 扩展3 block & trial

本文的例子很简单,只有一个变量(箭头朝向),5个trial。

但如果我们的实验有多个变量,应该怎么安排呢?

例如,一个两因素的研究,有四个水平,实验流程共计4个block,每个block有30个trial。

这时候,我们可以把每个block的刺激安排好,放在一个list里,然后:

# 总共4个block,每个block的内容如下
for block in range(0, 4):
    # 每个block有30个trial,每个trial的内容如下
    for trial in range(0, 30):

这样脚本的可读性会更好一些。

4.4 扩展4 古典概率和统计概率

抛一次硬币,正面朝上和反面朝上的概率是相等的,这就是古典概率

但实际上,抛若干次硬币,正面朝上和反面朝上的频次并不一定是50:50,有可能抛十次硬币,都是正面朝上,这就是统计概率

一般而言,大部分实验设计在提到“不同刺激出现的概率”这样的描述时,说的都是古典概率。

例如,在4.2部分提到的Asplund等人 (2010) 的研究中,注视点的颜色有效预测了80%trial的目标刺激的位置。这里提到的80%就是古典概率。

在本文的例子中,左箭头和右箭头出现的比例为50:50,这里的50%其实是统计概率。

如果想实现古典概率的话,首先保证trial的次数是刺激类型的倍数。例如我们有两种刺激(左箭头和右箭头),所以至少有6个trial。

实现的方法有很多,这里提供其中一种思路。

import random

arrow_orientation = []

# 将两种类型的箭头刺激添加至list中
for i in range(0, 3):
    arrow_orientation.append('←')
    arrow_orientation.append('→')

# 打乱list中元素的顺序
random.shuffle(arrow_orientation)

打乱之前:

['←', '→', '←', '→', '←', '→']

打乱之后:

['←', '→', '→', '←', '→', '←']

4.5 扩展5 完全随机和伪随机

以本文的实验为例,完全随机意味着,每次运行,注释点呈现时间和箭头的朝向都是不同的,例如,这次运行,5个trial的箭头朝向分别是“左右左左右”,再运行一次,又变成“左左右右左”了。而伪随机意味着,虽然刺激依然是随机生成的,但每次运行,都会是一个固定的随机序列,例如每次运行的箭头朝向都会是“左右左左右”。

本文的例子就是完全随机,如果想实现伪随机的话,也很简单,我们可以另外新建一个脚本,在这个脚本里随机生成所需的刺激序列,保存至一个csv表格里,然后每次运行都调用这个表格中的刺激序列。

4.6 扩展6 计算反应信息的均值

与E-prime等开发工具相比,通过编程语言来编写实验程序时,我们可以进行更多灵活的操作。

例如,我们不再需要在Excel中整理出每一个被试的反应时、正确率均值了,这些事情完全可以让Python帮我们完成。

例如,我们可以在实验程序脚本的结尾添加几行代码,计算出本次运行的反应时、正确率的均值(这里使用了“Numpy”模组,如果你需要在Python中进行数据处理,那么往后会经常用到它):

import numpy as np

rt = np.array(rt)
acc = np.array(acc)

rt_mean = np.mean(rt)
acc_mean = np.mean(acc)

在这里,我们先将rtacc这两个列表,转换为“Numpy”中的数组(array),然后调用mean()函数,其作用是计算出一个数组中所有元素的算术平均值,然后我们将计算得到的结果,赋值给rt_meanacc_mean两个变量。

最后,我们还可以将把算好的数值,连同被试的基本信息,以及实验分组的信息等等,通过writerow()写入一个单独的csv表格,例如命名为“exp_data.csv”。

于是,每做完一个被试,“exp_data.csv”就会新添一行,这样可以节省不少整理数据的时间。

4.7 扩展7 关于计时误差

以PsychopPy为例,在呈现空屏的代码前,添加如下代码,以记录箭头刺激的呈现时间。

print(timer.getTime() - t0)

运行一次程序,过程中不进行按键反应,结果如下:

0.500005500000043
0.5000073000001066
0.5000078000002759
0.5000015000000531
0.5000036000001273

在MATLAB版本的该程序中添加类似的代码,结果如下:

0.5004
0.5000
0.5002
0.5003
0.5002

可以发现,实际上刺激呈现的时间并不是500.00000000ms,而是一个近似值,但即便如此,PsychoPy确实提供了毫秒级的时间精度。

此外,MATLAB提供了以帧为单位的刺激呈现方式,这种方式同样具备很好的时间精度。

5 结语

本文的目的是用尽量简单的方法在Python中编写一个心理学实验程序。不过鉴于作者水平有限,某些部分可能会有更便捷的方法。以及,文中难免会有一些错漏,请大家多多指正。

经过这次尝试,我的建议是,如果想在Python中编写心理学实验程序,请优先使用PsychoPy。因为就编写心理学实验而言,与Pygame相比,PsychoPy的语法更加简洁,可读性更好,时间精度也更高。

 

Reference

[1] Pygame Documentation
[2] PsychoPy Reference Manual (API)
[3] Asplund, C. L., Todd, J. J., Snyder, A. P., & Marois, R. (2010). A central role for the lateral prefrontal cortex in goal-directed and stimulus-driven attention. Nature Neuroscience, 13(4), 507–512. doi:10.1038/nn.2509

 
----------2020.10.25更新----------
添加了一个介绍PsychoPy坐标单位的表格

----------2021.01.13更新----------
修正了代码中的一处错误(按键反应后未使用break退出循环)

上一篇下一篇

猜你喜欢

热点阅读