☰
PsychoPy实验编程实战:从刺激呈现到反应时记录与数据清洗
2026/10/11 14:51:53 网站建设 项目流程

简介:这是一份面向心理学、神经科学及机器学习研究者的 PsychoPy 实验编程入门文档,以 Word 格式系统梳理了从基础概念到环境配置的完整学习路径。内容先介绍 PsychoPy 的诞生背景与发展阶段,再围绕实验设计、数据分析、模型训练三大功能展开,并覆盖心理物理学、反应时间、视觉与听觉等常用范式,帮助读者理解如何用 Python 编写、运行和管理实验。资源包共 1 个 docx 文件,大小仅 31KB,轻量便于阅读、批注与打印,适合作为课程讲义或自学笔记使用。目前已有 1027 人学习,编者 zhuzhi 将安装步骤、依赖库配置及常见实验编程要点整理成章节式文档,读者可据此快速搭建 Python 环境并从零开始构建自己的心理学实验,是入门到进阶过程中一份实用的参考资料。

1. 实验编程绕不开 PsychoPy:它到底解决了什么

心理学、认知神经科学领域做行为实验,最头疼的往往不是数据统计,而是刺激呈现的精确性和反应时间记录的可靠性。PsychoPy 正是为这件事而生的 Python 实验编程框架:它把视觉刺激、听觉刺激、按键反馈、事件计时全部封装成可调用的对象,让实验脚本能毫秒级控制呈现顺序,同时把每个试次的行为数据自动落盘。我拆这份《实验编程:PsychoPy 从入门到精通》文档时,最大的感受是它把“从安装到一个能跑的完整实验”整条链路讲全了,适合刚上手做实验编程的研究生,也适合需要移植旧实验、重构刺激程序的熟手。它能帮你解决的核心问题很简单:用一套 Python 代码,把实验设计变成可复现、可审计的实验程序。

2. 环境安装与第一个程序:先把库装对,再谈实验逻辑

2.1 安装路线:pip 安装前的三个前置检查

PsychoPy 本质上是 Python 库,所以第一步不是急着pip install psychopy,而是先把解释器和包管理器确认好。文档里的顺序是:安装 Python、确认 pip、安装 PsychoPy、安装依赖库。这个顺序本身没错,但我在实际拆解中发现有几处容易卡住的地方值得先说清楚。

python --version pip --version python -m ensurepip --upgrade

先运行这三条命令,确认 Python 版本和 pip 是否可用。PsychoPy 对 Python 版本有要求,较新版本需要 3.8 以上环境,如果本机还是 3.6 或者干脆没有 Python,后续的依赖安装会翻车。python -m ensurepip --upgrade是补装 pip 的兜底手段,通常只在新装解释器或系统自带 Python 的特殊环境里才需要执行。

接着用虚拟环境隔离依赖是个好习惯,尤其是同一台机器上还跑着老实验脚本、机器学习项目时。文档虽然没提虚拟环境,但这是实践中最常被忽略的坑:

python -m venv psychopy_env source psychopy_env/bin/activate # Linux / macOS psychopy_env\Scripts\activate # Windows

激活虚拟环境后,再执行pip install psychopy,之后再用pip install numpy matplotlib jupyter pandas补齐依赖库。numpy 负责数值计算,matplotlib 用在数据可视化,jupyter 方便交互调试,pandas 是后续数据处理的主力。这套组合跟文档里强调的完全一致,我一般会顺手多装一个opencv-python,某些图像刺激预处理场景会用到。

2.2 第一个可运行脚本:Window、TextStim 与 flip 的关系

文档里 2.3 节的 Hello World 示例看着简单,但它其实串起了 PsychOpy 三个核心模块的分工。我把代码整理成可直接运行的完整版本,并补上了程序退出前的清理逻辑:

from psychopy import visual, core, event win = visual.Window([800, 600], color=[1, 1, 1], units='pix') hello = visual.TextStim(win, text='Hello World', color=[-1, -1, -1], height=40) hello.draw() win.flip() core.wait(2.0) win.close() core.quit()

visual.Window创建实验窗口,两个参数决定了后续所有刺激的坐标参照系:[800, 600]是窗口宽高(像素),color=[1, 1, 1]是背景色。注意这里的颜色空间不是 0-255 的 RGB,而是 -1 到 1 的线性区间,[1, 1, 1]是白色,[-1, -1, -1]是黑色。这个细微的差异是新手最容易看晕的地方,写颜色值前需要先明确当前窗口的颜色空间。

visual.TextStim创建文字刺激,text参数决定显示内容,color=[-1, -1, -1]是黑色文字,height=40代表文字高度 40 像素。我在原文档基础上加了units='pix',这样height和后续的位置参数都按像素理解,否则默认单位下坐标含义会变得抽象。

hello.draw()只是把刺激画到后台缓冲区,屏幕上还看不见;win.flip()才把后台内容一次性推到前台。这个“先画后翻”的机制是 PsychoPy 精确计时的根基:所有刺激在同一个缓冲区里准备好,翻转时同步刷新,避免逐帧绘制的时间误差。core.wait(2.0)让窗口停留 2 秒,win.close()关闭窗口,core.quit()退出程序。不加后两行的话,实验结束后进程会悬挂,终端里看到的就是“窗口关了但 Python 没退出”。

2.3 帧同步与等待逻辑:为什么你的窗口会一闪而过

很多第一次跑这段脚本的人会遇到窗口一闪而过、看不清内容的情况。原因不是脚本写错了,而是忘了让程序“等一等”。虽然win.flip()之后画面确实显示了出来,但脚本紧接着就执行到下一行,如果没有core.wait()或者等待按键的代码,程序会立刻跑到win.close()把窗口关了。这跟实验设计的播放节奏是同一套逻辑:呈现刺激之后必须明确告诉程序下一步做什么,要么等待固定时间,要么等待被试按键。

core.wait(2.0) # 方式一:等待固定秒数 event.waitKeys(keyList=['space']) # 方式二:等待被试按空格

两种方式对应不同的实验场景。core.wait(2.0)适合固定时长的刺激呈现,比如注视点显示 500ms、刺激显示 1000ms;event.waitKeys(keyList=['space'])适合被试自己控制节奏的任务,按了键才进入下一步。后者是反应时间实验的基础,后面的章节会展开讲。

3. 刺激呈现与实验流程设计:从 Hello World 到完整试次

3.1 视觉刺激的三类基础对象:什么场景用哪个

PsychoPy 的视觉刺激对象都挂在visual模块下,除了文档里演示的TextStim,我拆这份文档时顺手整理了最常见的三类:文字刺激、图片刺激、形状刺激。它们的使用逻辑完全一致:创建对象、设置属性、draw()绘制、flip()翻转,但各自的参数侧重点不同。

from psychopy import visual import numpy as np # 文字刺激:适合指导语、注视点、反馈信息 text_stim = visual.TextStim(win, text='请注视十字', height=30, color=[-1,-1,-1]) # 图片刺激:适合面孔、物体、场景类实验材料 image_stim = visual.ImageStim(win, image='face001.jpg', size=[200, 250], pos=[0, 0]) # 形状刺激:适合色块、边框、掩蔽刺激 rect_stim = visual.Rect(win, width=100, height=100, fillColor=[1,-1,-1], pos=[150, 0]) # 圆形刺激:适合空间线索、视觉搜索目标 circle_stim = visual.Circle(win, radius=50, fillColor=[-1,1,-1], pos=[-150, 0])

参数上,image='face001.jpg'是图片路径,size=[200, 250]控制图片显示尺寸而不是原始像素大小。Rect和Circle的关键参数是width/height与radius,fillColor控制填充色,不带fillColor时只画出描边。这些对象创建后可以随时改属性再重新绘制,实现刺激的动态变化——比如通过循环修改pos参数让圆形移动。

3.2 反应时间任务:如何组织一个完整试次

文档第三章给出了反应时间实验的设计思路,但只有思路没有完整脚本。我按照文档的步骤,补了一个可运行的简单反应时间任务。它包含典型的三段式试次结构:注视点、目标刺激、按键反应。

from psychopy import visual, core, event import random win = visual.Window([1024, 768], color=[1, 1, 1], units='pix') fixation = visual.TextStim(win, text='+', height=40, color=[-1, -1, -1]) target = visual.Circle(win, radius=60, fillColor=[-1, 1, -1], pos=[0, 0]) trials = [{'target_color': [-1, 1, -1]} for _ in range(10)] # 10 个试次 random.shuffle(trials) for i, trial in enumerate(trials): # 呈现注视点 500ms fixation.draw() win.flip() core.wait(0.5) # 呈现目标刺激,等待按键,记录反应时 target.fillColor = trial['target_color'] target.draw() win.flip() clock = core.Clock() keys = event.waitKeys(keyList=['space', 'escape'], timeStamped=clock) if keys is None or keys[0][0] == 'escape': break # 被试主动退出 rt = keys[0][1] # 反应时(秒) print(f'Trial {i+1}: RT = {rt:.3f}s') # 简单的刺激间间隔 win.flip() core.wait(0.3) win.close() core.quit()

这段脚本的试次流程是:先画注视点,win.flip()后等待 500ms;然后画目标圆形,翻转后立刻创建core.Clock()计时器,用event.waitKeys(timeStamped=clock)同时获取按键和按键发生的时间戳。timeStamped=clock是关键,它让返回的每个按键都带上相对于clock创建时点的时间,也就是反应时。

event.waitKeys返回的格式是列表,每个元素是(按键名, 时间戳)这样的元组,所以代码里用keys[0][1]取出时间戳。keyList=['space', 'escape']限制了有效按键范围,被试按空格正常反应、按 escape 退出实验。这个设计比文档里的方案多了一层退出机制,长时间运行的实验必须保留这个出口,否则被试中途想退出只能靠拔电源。

3.3 试次随机化与条件平衡:别让顺序效应毁掉数据

上面代码里我用random.shuffle(trials)打乱了 10 个试次的顺序,这背后是实验设计的基本原则。行为实验里,被试的疲劳、练习、期望都会随试次累积而影响表现,如果把所有条件的试次按固定顺序排列,条件之间会被顺序效应污染。

before: [A, A, A, A, A, B, B, B, B, B] # 错误的:条件内集聚 after: [A, B, A, B, B, A, A, B, A, B] # 正确的:完全随机或伪随机

对简单设计,random.shuffle就够了。更复杂的实验还要考虑条件在试次间的平衡,常见做法是用itertools.product生成所有条件组合的笛卡尔积,再打散重复若干遍。

from itertools import product import random conditions = list(product(['face', 'house'], ['left', 'right'])) trials = conditions * 5 # 每个条件重复5次 random.shuffle(trials)

conditions是四组条件组合,乘以 5 得到 20 个试次,再 shuffle 保证被试无法预测下一个试次的类型。这种做法在识别任务、记忆任务里几乎是标准操作,文档里提到的“随机化程度”参数化配置,本质上就是这么实现的。

4. 数据采集与避坑:记录、导出、排查的完整方案

4.1 数据记录的两种姿势:逐试次写入还是最后统一导出

PsychoPy 提供了.csv数据保存函数,文档里提到文本文件、Excel、JSON 三种输出格式,我在实际拆解中强烈推荐用.csv,它跟 pandas 配合最顺手,Excel 格式在几百个试次后打开速度会明显变慢。数据写入有两种常见姿势,各有适用场景。

方式一:实验结束后统一导出。把每个试次的数据累积到 Python 列表里,循环结束再用 pandas 一次性写文件。

import pandas as pd results = [] for i, trial in enumerate(trials): # ... 实验逻辑 ... results.append({ 'trial': i + 1, 'condition': trial['target_color'], 'rt': rt, 'correct': 1 # 或者根据实际按键判断 }) df = pd.DataFrame(results) df.to_csv('data/exp1_results.csv', index=False)

方式二:边跑边存。用data.ExperimentHandler组件,每个试次结束自动落盘。这种方式的好处是实验中途崩溃不会丢失已完成的数据,坏处是代码复杂度略高、对新手不友好。我一般做正式实验用第二种,做演示和调试用第一种。

数据清洗是数据处理的第一步,文档第四章提到的“去除异常和无关数据”落到实处通常是两步:过滤掉没按键的试次、剔除反应时小于 100ms 或大于 3 个标准差的极端值。前者是实验协议问题,后者是统计惯例,但标准要根据具体任务调整,不能一刀切。

4.2 新手最容易翻车的五个注意点

写 PsychoPy 实验脚本的翻车现场,翻来覆去就是这几个,每条都是我看过不少人踩过的坑。

坑一:窗口一片白,文字看不到。

现象:窗口正常弹出,但只有背景色,没有刺激内容。

原因:visual.TextStim创建时忘了调用hello.draw(),或者draw()放在了win.flip()之后。draw 是把内容画到后台缓冲区,flip 才上台面,顺序反了就什么都不显示。

解决:把stim.draw()放在win.flip()之前,确保每次 flip 前都把所有要显示的刺激 draw 一遍。

坑二:第一次按键没反应,第二次才生效。

现象:被试按空格,实验窗口没动静,再按一次才进入下一步。

原因:上一阶段留下的按键事件没有清空。event.waitKeys是等到新按键出现才返回的,如果上次实验阶段结束前被试多按了几下,这些按键会留在缓冲区里,被下一次waitKeys立刻读到。

解决:在每个关键阶段前调用event.clearEvents(),把缓冲区清干净。特别是从指导语页面切换到正式试次时,这条几乎是一定要加的。

坑三:中文刺激显示成方块。

原因:PsychoPy 默认字体不支持中文字符,Windows 上尤其常见,TextStim的font参数没设成中文字体。

text_stim = visual.TextStim(win, text='请注视屏幕中央', font='SimHei', height=30)

解决:显式设置font='SimHei'(Windows 黑体)或font='Arial Unicode MS'(macOS)。Linux 环境可以设font='WenQuanYi Zen Hei'这类中文字体。

坑四:实验数据没保存,文件是空的。

现象:跑完实验控制台没报错,但输出文件不存在或内容为空。

原因:路径问题通常是主因。用相对路径'data/results.csv'时,如果data文件夹不存在,写入直接失败;to_csv在路径不存在时会静默报错,控制台未必能看到。

import os os.makedirs('data', exist_ok=True) df.to_csv('data/results.csv', index=False)

解决:写文件前用os.makedirs('data', exist_ok=True)确保目录存在。另一个常见坑是用了中文文件名,在某些编码环境里也会写入失败。

坑五:反应时间记录不准,整体偏大或抖动严重。

现象:跟外部设备比对时发现反应时总是多几十毫秒,或者忽大忽小。

原因:最常见的是逻辑里用了core.wait(0.5)代替计时,或者在按键之后处理了太多额外逻辑才记录时间。core.wait本身就有几毫秒的调度误差,反应时任务里计时起点必须和刺激出现严格对齐。

解决:用core.Clock()计时器并在win.flip()之后立即获取时间戳,已经写在上一节的反应时脚本里了。这是 PsychoPy 反应时任务的推荐做法,别用time.time()当计时工具。

4.3 数据导出与初步分析:从 csv 到描述统计

实验跑完拿到.csv文件后,后续分析在 PsychoPy 生态内也能完成。文档里提到 PsychoPy 支持文本文件、Excel、JSON 输出,也支持内置分析工具,但正式写论文时大家通常还是回到 pandas 和 matplotlib 这套更通用的工具链。

import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv('data/exp1_results.csv') df = df[df['rt'].notna()] # 去掉没有反应的试次 df['rt_ms'] = df['rt'] * 1000 # 秒转毫秒 summary = df.groupby('condition')['rt_ms'].agg(['mean', 'std', 'count']) print(summary) df.boxplot(column='rt_ms', by='condition') plt.savefig('data/rt_boxplot.png', dpi=150)

这段代码做了三件事:读入数据、转换单位、按条件分组做描述统计。groupby('condition')的前提是保存数据时把条件字段写进了每一行,这也是为什么上一节我强调手动构造results列表时要把条件也记录进去。很多新手只保存了反应时和正确率,没存条件变量,后期再想按条件分组分析就得回去重跑实验,这是数据记录层面最亏的一件事。

5. 进阶:把实验脚本提升为可复用工具的配置分离技巧

用 PsychoPy 写了十来个实验之后,你会意识到一个问题:每次改实验参数都得翻脚本,改错了还可能把已有的逻辑搞坏。我拆完这份文档后的最大收获,是把实验参数从代码里抽出来,用配置文件统一管理。

做法是创建一个简单的.json或者.yaml文件存放所有可调参数,实验脚本启动时读入,整个实验逻辑只跟配置字典打交道。改字号、改刺激颜色、改试次数量,都不用动核心代码。

import json from psychopy import visual, core, event with open('config.json', 'r', encoding='utf-8') as f: config = json.load(f) win = visual.Window([config['screen']['width'], config['screen']['height']], color=config['screen']['bg_color'], units='pix') fixation = visual.TextStim(win, text='+', height=config['fixation']['size'], color=config['fixation']['color']) for trial in range(config['exp']['n_trials']): fixation.draw() win.flip() if config['exp']['debug_mode']: # 调试模式:跳过等待 core.wait(0.1) else: core.wait(config['timing']['fixation_duration'])

对应的config.json长这样:

{ "screen": {"width": 1024, "height": 768, "bg_color": [1, 1, 1]}, "fixation": {"size": 40, "color": [-1, -1, -1]}, "timing": {"fixation_duration": 0.5, "isi": 0.3}, "exp": {"n_trials": 60, "debug_mode": true} }

配置分离带来一个直接福利:调试时可以临时把debug_mode设为true,所有刺激呈现等待时间缩短到十分之一,跑完整个流程只需要几秒,方便快速验证逻辑有没有 bug。正式收集数据前再切回false,等待时间恢复正常。这个习惯救过我很多次——某次正式实验前半小时,我发现改了刺激大小后位置偏移了,靠着调试模式在两分钟内跑完了 60 个试次的完整流程并定位到问题出在坐标计算,如果按正常速度得磨蹭将近十分钟。

从那以后我每次写新实验,第一步永远是建config.json,再写脚本逻辑。实验参数、刺激大小、颜色、等待时长、试次数全部集中在外置配置里,脚本里再找不到一个裸数字。希望这个思路帮你在 PsychoPy 上少走几趟弯路,把时间花在实验设计本身而不是调参数上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询