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
的内容,打开一个对话框,用于输入被试的基本信息。若字典中的键对应的值是空值,那么将在对话框中添加一个输入栏,若键对应的值是一个字符串列表,那么将在对话框中创建一个下拉列表(见下图)。
另外两个参数title
和order
,分别是设置对话框的标题,以及设置字典变量在对话框中的呈现顺序(默认的顺序是根据键的首字母来排列)。
填写完毕并点击“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_pixels
和y_pixels
。
接着,我们调用根据x_pixels
和y_pixels
的值,通过pygame.display.set_mode()
函数,打开一个窗口,我们将这个窗口命名为win
。顾名思义,pygame.FULLSCREEN
这个参数的目的是将窗口设置为全屏。
接着,我们将x_pixels
和y_pixels
除以2,分别赋值给x_center
和y_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) | 使用视角度设置刺激的大小和位置。该单位有三种计算方式:deg 、degFlat 、degFlatPos ,具体可以查阅官方文档的说明。 |
屏幕的宽度(单位: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('→')
先定义几个名为rt
、resp
、acc
的空列表,之后我们便可以将被试的反应时、反应按键和正确率,添加到这些列表中。dots_time
和arrow_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提供了众多用于创建视觉刺激的函数,这里使用了三种函数。
-
visual.Circle()
的作用是根据给定的半径,创建一个圆形。我们使用这个函数来创建注视点。 -
visual.TextStim()
的作用是创建文本刺激。我们通过这个函数来创建箭头刺激。在这个函数内,通过text=
来指定文本的内容,目前我们还没有指定text,因为每个trial的arrow都是不一样的,我们需要在每个trial中指定具体的文本内容。 - 最后,
visual.ImageStim()
就是呈现图片啦,参数也很简单,分别选择“pic”文件夹下的两张图片即可。
接着是呈现指导语,正如上文所述,先将刺激变量绘制出来(draw()
),然后刷新屏幕(flip()
)即可。接着我们调用event.waitKeys()
函数,等待被试按任意键继续。
2.6 每个trial的内容
对于Pygame和PsychoPy,无论是呈现什么视觉刺激,思路都是一样的:
- 绘制刺激(对于Pygame,在绘制刺激之前,还需要将背景填充为黑色,以覆盖掉之前在屏幕上呈现的内容),
- 刷新屏幕,使绘制的内容能够呈现出来。
- 等待一段时间(例如,实验设计为刺激呈现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部分)来获取被试的操作:
- Pygame部分:当
pygame.event.get()
获取的操作是按下按键(KEYDOWN
)的时候,我们先判断key_check
是的值是否为“False”(if not key_check:
相当于if key_check == False:
),若为“False”,则赋值为“True”,这么做的目的是,当被试多次按键时,只记录第一次按键的信息。 - PsychoPy部分:
event.getKeys()
返回的是一个列表,其中的元素是按键名。当未识别到按键时,key
是一个空列表。我们可以通过len()
函数来获取列表key
的长度,当len(key)
不等于0时,说明被试按下了至少一个按键。如果被试在500ms内多次按键,那么key
这个列表内将有多个元素,而我们只需要记录被试的第一个按键,第一个按键便是key[0]
(即列表的第一个元素)。
K_ESCAPE
(Pygame版本)、'escape'
(PsychoPy版本)指的是“Esc”键,我们将这个按键设置为退出键,当按下这个按键时,程序将被强制关闭。
如果按下的按键不是“Esc”键,那么:
Pygame:
- 将
time.time() - t0
作为这个trial的反应时,添加至rt
列表中。 - 通过
pygame.key.name(event.key)
获取按键名,添加至resp
列表中。 - 通过if语句进行判断,若按键为
K_LEFT
/K_RIGHT
(即,左方向键/右方向键)且这个trial的箭头刺激为左箭头/右箭头,则将数字1添加至acc
列表中。否则将数字0添加至acc
列表中。
PsychoPy:
- 将
core.getTime() - t0
作为这个trial的反应时,添加至rt
列表中。 - 将
key[0]
作为反应按键,添加至resp
列表中。 - 通过if语句进行判断,若按键为
'left'
/'right'
(即,左方向键/右方向键)且这个trial的箭头刺激为左箭头/右箭头,则将数字1添加至acc
列表中。否则将数字0添加至acc
列表中。
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)
在这里,我们先将rt
和acc
这两个列表,转换为“Numpy”中的数组(array),然后调用mean()
函数,其作用是计算出一个数组中所有元素的算术平均值,然后我们将计算得到的结果,赋值给rt_mean
和acc_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退出循环)
网友评论