ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PsychoPy实验设计全攻略:从图形界面到Python代码的实战解析

PsychoPy实验设计全攻略:从图形界面到Python代码的实战解析

简介:PsychoPy是一套面向心理学与神经科学实验设计的开源工具包,专为需要运行视觉、听觉刺激及行为实验的研究者打造,支持使用Python编写实验程序,是Matlab等商业软件的高性价比替代品。它借助OpenGL实现流畅的图形呈现,同时保留Python语法简洁易读的特点,适合心理学、认知科学及相关专业的高校师生和科研人员使用。包体为ZIP压缩格式,大小约17.5MB,下载页未列出具体文件数量与类型,获取后可按需解压安装。围绕PsychoPy,资源涵盖从刺激创建、窗口呈现到试次控制与数据记录的核心逻辑,可帮助读者免去繁琐配置,快速进入实验编程流程;对于需要设计反应时、视觉搜索、经典条件反射等常见范式的实验而言,也是不错的入门参考与日常开发辅助材料。目前已有506人浏览学习,适合希望用免费开源方案搭建行为实验、推进研究进度的一线科研工作者。

1. 心理学实验跑不通?PsychoPy 把刺激呈现和反应记录做成了一套开源标准

做行为实验最怕的不是数据分析,而是刺激呈现那一关:图片延迟、声音卡顿、按键时间对不上,数据一塌糊涂。PsychoPy 正是为这件事而生的开源工具,它用 Python 驱动视觉、听觉刺激,以毫秒级精度控制呈现和记录反应,既能图形化搭实验,也能写代码精调。对心理学、神经科学的学生和研究者来说,它是替代商用实验软件的免费方案;对产品经理和交互设计师而言,它可以低成本做可用性测试和认知负荷实验。本文从安装、Builder 图形界面到 Coder 代码进阶,把能复现的步骤和参数逐条拆开,最后给出避坑记录,让你拿到资源包就能跑通第一版实验。

2. 为什么是 PsychoPy:选型依据与安装落地

在决定用 PsychoPy 之前,最好先搞清楚它和 E-Prime、Presentation 这类商业软件的本质差异。选型不对,后面写代码都是在给错误方案打工。多数人换到 PsychoPy 的直接原因是:它免费、跨平台,且底层由 Python 驱动,刺激的时序控制可精确到帧。但免费只是表面,真正的优势在于数据管道是开放的——从刺激呈现到数据导出,每一步都看得见、改得动。

2.1 开源生态的底层能力:从 Python 包到完整实验框架

PsychoPy 不是一个单一的软件,而是一整套基于 Python 的生态。它的两个视图——Builder 和 Coder——分别服务不同人群。Builder 用组件拖拽的方式搭建实验,生成的是*.psyexp文件;Coder 则直接写 Python 脚本,生成*.py文件。两者最终都会调取同一套底层库,包括visual(刺激呈现)、event(反应收集)、core(时钟控制)、data(数据存储)等模块。

需要理解的一个关键点是:Builder 生成的脚本也是标准 Python 代码,你可以把它导出后在 Coder 里手动修改。这意味着实验的逻辑复杂度没有上限。比如你用 Builder 搭了一个“图片命名任务”,导出的脚本里会自动包含visual.ImageStim、visual.TextStim、event.waitKeys等核心函数调用。想要在某个 trial 里动态修改图片位置,直接在导出代码里插入一个变量赋值语句即可,不需要重搭整个实验。

资源包里附带了一份组件参考表,它把 Builder 里每个组件的参数对应到底层 Python 函数,这个对照在排错时非常好用。例如ImageStim组件的pos参数,在代码里对应的是ImageStim(win=..., image='xxx.jpg', pos=(0, 0))中的pos,取值单位是屏幕像素(如果设为from单位)或归一化坐标(height单位)。如果你想去掉某个组件在某个时间点的透明度变化,直接在代码里传opacity=1.0就绕过了 Builder 的默认值。

2.2 安装步骤:conda 创建独立环境与恢复测试

安装本身不难,但环境隔离这步不能省。PsychoPy 对依赖版本比较敏感,尤其是numpy和pyglet的搭配。我一般用 conda 建独立环境,避免把系统 Python 搅乱。

conda create -n psychopy_env python=3.9 conda activate psychopy_env pip install psychopy python -c "import psychopy; print(psychopy.__version__)"

最后一行的输出如果是2024.x.x之类的版本号,说明装好了。注意这里我用的是 Python 3.9,不是最新版。PsychoPy 官方对 Python 版本的兼容窗口有一定滞后,直接用最新版 Python 容易遇到pyglet编译报错。用 3.9 是社区验证过的稳妥组合。

装完之后还需要做一次硬件测试,这部分很多人会跳过,结果跑到一半才发现刷新率不匹配。PsychoPy 自带一个硬件测试 Demo,在 Builder 的demo菜单里可以找到,但更直接的方式是写一个简单脚本验证帧率:

from psychopy import visual, core win = visual.Window([800, 600], screen=0, monitor='testMonitor', useFBO=True) print("帧率时钟精度:", win.getActualFrameRate()) core.wait(1.0) win.close()

这段代码创建了一个 800x600 的窗口,getActualFrameRate()返回当前显示器的实际刷新率。如果返回None,说明计时器没有和显示器垂直同步锁住,后续实验计时会漂移。需要检查显示器的驱动设置,确保刷新率不是“动态省电模式”,而是一个固定值,比如 60Hz 或 144Hz。参数useFBO=True的作用是开启帧缓冲对象,能让刺激呈现在某些显卡上更稳定,但它在老显卡上有时会引发显示异常,如果遇到黑屏就把它改成False。

2.3 资源包内容速览:组件清单与示例实验的匹配关系

拿到压缩包后,先不要急着解压跑实验。资源包里的目录通常分成三块:builder_experiments(图形化示例)、coder_scripts(纯代码示例)、docs(中文说明与参数速查)。我建议先打开builder_experiments里的一个简单“反应时任务”示例,把psyexp文件导入 Builder,点击运行看是否正常弹窗。能跑通,说明环境和资源包的匹配没问题。

如果 Builder 界面打开后组件缺失或脚本直接报红,先检查资源包版本和 PsychoPy 主版本是否一致。一个常见的错误是:资源包是在 PsychoPy 2023 上做的,而你装了 2025 版本,Builder 的某些组件名或参数名已经调整,旧文件加载时会提示 “AttributeError”。遇到这种情况,可以在 Builder 里手动把所有组件重置为默认值再重新设置参数,而不是直接运行。这是检查资源包完整性的一个实操技巧。

3. Builder 图形化实验设计:拖拽组件背后的参数逻辑

Builder 视图是新手最容易上手的入口。它把一个心理物理实验拆分成“组件”,你在一个时间轴上排列这些组件,设置每个组件的属性,系统自动生成运行脚本。但很多教程只教“怎么拖”,不教“每个参数对应代码里的什么”,导致用户离开图形界面就抓瞎。这一章把 Builder 里最常用的几个参数讲透,并对照到 Coder 代码。

3.1 组件与循环:用 Routine 和 Loop 搭建试次结构

Builder 里的核心抽象是Routine(一个时间片段,比如“注视点呈现 500ms”)和Loop(循环一个 Routine 多次)。一个典型实验流程是:InstructionRoutine(呈现指导语,按任意键继续)→TrialRoutine(注视点、刺激、空屏、反馈)→EndRoutine(显示结束语)。

实际操作步骤:

  1. 在 Builder 左侧组件栏选择TextStim,拖到TrialRoutine 的时间轴上。
  2. 双击该组件,设置text为'+',duration为0.5,pos为(0, 0)。
  3. 从组件栏拖入ImageStim,设置image为你准备好的刺激图片路径,duration留空(表示持续到按键或下一个组件开始)。
  4. 拖入Keyboard组件(新版中叫Keyboard,旧版叫keyboard),把allowedKeys设为['f', 'j'],forceEndRoutine设为True。
  5. 在左侧Loop区域新建一个 loop,把TrialRoutine 放进去,并设置nReps为 20,conditionsFile选择你的刺激列表(CSV 格式)。

这里的逻辑是:Loop 每迭代一次,就会从conditionsFile中读取一行数据,行里的列名(比如image_name、correct_answer)可以直接作为变量在组件参数里引用。比如ImageStim的image参数可以写$image_name,那个$符号告诉 PsychoPy “这是一个变量,不是字面文字”。

forceEndRoutine是最值得注意的参数。它的作用是:当参与者按下允许的按键时,当前 Routine 立即结束,跳到下一个 Routine。如果不勾选它,按键会被记录,但 Routine 会一直运行到所有组件预设的duration结束。这个差别在反应时任务中关乎数据质量,务必理解。

3.2 刺激呈现与时间精度:帧同步和 refresh rate 的权衡

Builder 中每个组件的时间参数有duration和start两类字段,单位是s(秒)或frames(帧)。选帧单位能保证刺激呈现的时序和刷新率对齐,代价是你必须预先知道显示器的实际刷新率。如果你的实验对毫秒级时序有硬性要求(比如视觉掩蔽范式),直接选frames;如果只用文本和图片做问卷式任务,s单位省心。

举个例子。在 60Hz 显示器上,呈现 500ms 的注视点,代码里要写duration=30(Frame)。如果你写0.5秒,PsychoPy 会将其换算成最接近的帧数——表面上看数值对,但实际可能是0.483s或0.517s,取决于舍入方向。这类误差在单次试验里无所谓,可怕的是累积。如果一个实验有 200 个 trial,每个 trial 偏差 17ms,最终的耗时和预期差出几秒,日志里也能看到帧计数参差。

Builder 组件参数里还有一个syncToRefresh选项(在部分版本叫sync),它是影响刺激质量的关键开关。置为真时,刺激的翻转(flip)会与屏幕垂直同步信号对齐,避免画面撕裂;置为假时,翻转操作按系统时间触发,可能造成画面部分刷新。在视觉实验里,尤其涉及快速运动刺激时,撕裂会直接污染被试的感知,所以默认保持开启。但在一些特殊场景中,比如你想同时呈现两个不同刷新率的窗口,就不得不牺牲它。

3.3 反应数据记录:从 CSV 到变量命名的一致性检查

Builder 自动生成的数据文件是 CSV(旧版)或.csv+.log(新版)。CSV 中每一行是一个 trial 级别的数据,包含反应键、反应时、刺激属性、循环序号等。但有个坑:如果你在 Loop 的conditionsFile里定义了变量resp,又在Keyboard组件里把correctAns设为resp,那么数据文件里会出现两列resp,造成后续分析时的命名冲突。

我习惯在每组实验开始前做一次命名审计:打开生成的 CSV 表头,逐一检查是否存在重复列名。重复列名在 pandas 读取时会自动变成resp.1、resp.2,如果没察觉,后面 merge 数据时会错位。另一个常见问题是:Builder 记录的 reaction time 起点是Keyboard组件所处的start时间点,而不是刺激出现的时间点。如果换个需求,想记录“刺激真正翻转上屏”到“按键”的 RT,需要把start设为stimulus组件的结束时间,或者干脆在 Coder 里用frame flip回调来记录时间戳。

4. Coder 代码进阶:用 Python 脚本接管精细控制

Builder 适合搭建 70% 的常规实验,但遇到动态调整刺激参数、实时计算下一轮刺激、跨 trial 累积反馈这类需求,图形界面会变得笨重。这时需要进入 Coder 视图写脚本。这一章给三个能直接抄的代码片段,覆盖视觉刺激、按键反应时间记录、以及按数据动态生成刺激。

4.1 视觉刺激控制:ImageStim 与 TextStim 的精确呈现函数

下面这段代码是 Coder 里最基础的视觉实验骨架,呈现一张图片,等待按键,然后退出:

from psychopy import visual, event, core win = visual.Window(size=[800, 600], color='gray', units='pix') image = visual.ImageStim(win, image='face.jpg', size=(300, 300), pos=(0, 0)) text = visual.TextStim(win, text='请判断:高兴还是悲伤?', pos=(0, -200), color='white') for trial in range(5): image.draw() win.flip() keys = event.waitKeys(maxWait=3.0, keyList=['f', 'j']) if keys is None: print(f"Trial {trial+1}: 超时无响应") else: print(f"Trial {trial+1}: 按键 {keys[0]}, RT = {event.getRT()}") win.close() core.quit()

ImageStim的size参数决定图片在窗口中的尺寸,单位为pix(像素);pos决定位置中心点。event.waitKeys会阻塞执行,直到按键或超时(maxWait=3.0即最多等 3 秒)。event.getRT()返回上一次按键相对于win.flip()调用的反应时间,单位秒。很多人在这里踩坑:getRT()的时间基准是最后一次flip()调用,如果你想记录“按键相对于刺激出现的延迟”,必须在flip()之后就立即调用waitKeys,不能在中间插入其他耗时操作。

关于win.flip()需要多说一句。它不仅仅是“刷新画面”,它同时是 PsychoPy 的帧同步点——所有在这之前draw()的内容会被一次性呈现到屏幕,然后计时重新开始。所以实验计时逻辑必须围绕flip()来设计,而不是依赖 Python 的time.sleep()。这个观念转换是新手到熟手的关键跃迁。

4.2 反应时数据收集与保存:TrialHandler 的正确加入姿势

手动记录数据再拼接容易出错,PsychoPy 提供了data.TrialHandler和data.ExperimentHandler两个容器,专门管理逐 trial 数据和最终保存。下面展示一个包含正确数据添加顺序的完整脚本:

from psychopy import visual, event, data, core import os win = visual.Window([800, 600], color='gray') stim = visual.TextStim(win, text='+', color='white') trials = data.TrialHandler(trialList=[{'correct': 'f'}, {'correct': 'j'}], nReps=10, method='sequential') for trial in trials: stim.text = trial['correct'] stim.draw() win.flip() resp = event.waitKeys(keyList=['f', 'j'], maxWait=2.0) if resp is None: trial['rt'] = None trial['response'] = None trial['accuracy'] = 0 else: trial['rt'] = event.getRT() trial['response'] = resp[0] trial['accuracy'] = int(resp[0] == trial['correct']) trials.saveAsExcel('data.xlsx') trials.saveAsText('data.csv', delim=',')

这段代码里最容易被误解的是trialList和nReps的关系。trialList定义了不同类型的 trial(这里有两种,分别对应正确答案 f 和 j),nReps=10表示每种重复 10 次。method='sequential'表示按顺序播放;改为'random'则每个 trial 随机出现,且 PsychoPy 保证两种 trial 各自出现 10 次。for trial in trials这个循环里,每次迭代自动从上一次的 trial 数据中提取correct值,用完再存到下一行的对应字段。

注意到我用trial['rt'] = event.getRT()这种方式存储反应时。trial对象允许动态添加键,保存时会合并到数据文件。这个特性非常有用,但要小心:所有 trial 必须有相同的键集合,否则导出 Excel 时会报错,因为某一行缺列。如果有 trial 因超时没有反应时,也必须显式赋一个None,不能直接跳过。

4.3 动态生成刺激:用代码实时绘制几何图形与文字

有些实验不能预先准备全部刺激,比如自适应阈值的阶梯法(staircase),每次呈现的对比度取决于上一次的答案。这种需求在 Builder 里要借助额外的 Python 代码组件才能实现,在 Coder 里则是自然表达:

from psychopy import visual, event, core import random win = visual.Window([800, 600], color='gray') grating = visual.GratingStim(win, tex='sin', size=5, sf=0.05) contrast_levels = [0.1, 0.2, 0.4, 0.8] current = 0 responses = [] for trial in range(30): contrast = contrast_levels[current] grating.contrast = contrast grating.draw() win.flip() key = event.waitKeys(keyList=['up', 'down'])[0] responses.append((trial, contrast, key)) if key == 'up': current = min(current + 1, len(contrast_levels) - 1) elif key == 'down': current = max(current - 1, 0) print("阶梯法数据:", responses)

严格说这是一个简化的“一上一下”阶梯,不是完整算法,但它展示了关键点:刺激呈现的contrast参数是根据上一次反应在循环内动态赋值的。GratingStim的sf是空间频率,单位是“周期/像素”,这个值要在系统里根据屏幕尺寸和观察距离换算,不能拍脑袋。tex='sin'表示用正弦光栅,也可以用'sqr'得到方波光栅。这类实时参数修改是 Coder 相对 Builder 的最大优势,也是它能承担高级实验设计的原因。

5. 避坑指南:PsychoPy 实验设计里最常见的五个翻车事件

这一章是血泪经验汇总。PsychoPy 功能强,但不少默认行为和你直觉不一样。每条问题都按“现象 → 原因 → 解决”来写,你可以直接对照自己的实验排查。

5.1 数据文件出现大量空白反应行

现象:跑完实验打开 CSV,发现反应速度(RT)那一列有超过 30% 的行是空的,但这些 trial 明明有按键。

原因:event.waitKeys的maxWait设得太短,或者Keyboard组件的forceEndRoutine没有设为真。如果组件还在等输入,用户已经按了键,但程序已经把当前 trial 记成了无响应。

解决:把maxWait延长到合理范围(比如 5 秒);Builder 里检查Keyboard组件的forceEndRoutine是否为真。另外确认按键事件没有在前一个 trial 末尾被意外捕获——比如上一 trial 结束时用户提前按下按键,event.clearEvents()没有调用,导致本 trial 的第一次按键被丢弃。

5.2 图片刺激有残影或拖尾

现象:快速连续显示两张不同的图片时,屏幕上出现上一张图的残像。

原因:绘制流程不对。你可能是这样写的:img1.draw(); win.flip(); img2.draw(); win.flip()。如果中间缺少win.flip()或调用了两次draw()但不翻转,PsychoPy 会把两帧合并到同一个缓冲。

解决:确保每次呈现新的内容,都完整执行一次“清空缓冲 → 绘制新内容 → flip”。如果用的是让视觉刺激周期性闪烁的范式,建议改用visual.Radar或visual.BufferImageStim来处理残影问题,而不是手动在时序上抠帧。

5.3 Builder 界面打不开旧版文件

现象:用新版本 PsychoPy 打开从旧电脑拷来的psyexp文件,提示AttributeError或组件缺失。

原因:版本升级带来的组件 API 变化。最常见的差异是Keyboard取代了旧的KeyPress,Mouse组件的clickable参数被移除。

解决:先备份原文件,然后在文本编辑器里打开psyexp(它是 JSON 格式),搜索旧组件名并替换为新版名称。优先替换KeyPress→Keyboard。如果文件里还有Participants相关参数,清空即可。要养成每次升级后跑一遍自带的tests/test_import.py脚本的习惯,它会自动扫描所有组件是否可加载。

5.4 反应时记录偏差大于 30ms

现象:你用量尺对照发现,反应时间整体比实际偏长或偏短 30ms 以上。

原因:计时基准不对。如果代码里用core.getTime()记录按键时间,而刺激呈现用的是win.flip()的帧时间点,两者会存在 1-2 帧的误差。另一处常见原因是event.waitKeys()计时从调用那一刻开始,而不是从刺激呈现开始。

解决:在实验脚本开头定义一个“帧锁存”回调函数,将每次flip()的实际时间戳记录下来,计算 RT 时用“按键绝对时间 - 刺激翻转绝对时间”。具体代码是在每次刺激呈现前调用io.ft或使用core.monotonicClock.getTime()记录时间戳。一个更简单的做法是在 Builder 里用Keyboard组件自带的Store RT relative to参数设置为“刺激开始”。

5.5 被试机运行时 CPU 占用 100% 导致卡顿

现象:实验进行到一半,画面明显卡顿,帧率从 60Hz 掉到 30Hz,CPU 占用飙升到 100%。

原因:最常见的是实验脚本在循环内不停创建新的刺激对象,而没有复用旧对象。比如在for trial循环里写img = visual.ImageStim(...),每次迭代都重新建对象,内存泄漏和 CPU 开销叠加。

解决:将刺激对象创建放在循环外,循环内只更新属性(.image、.pos等)。另外检查是否在循环里调用了event.getKeys()而未清空事件队列。最后确认显示器的gamma校正没有在运行时反复重新计算——这需要单独设置一次,而不是在每次 trial 里调用。

6. 用真实帧数据验证实验精度:最后一段代码把关

实验跑完,数据也导出了,但严谨的验证还没结束。我建议每个实验在正式采集前,都用一段“人眼校准脚本”输出实际的帧时间序列,确认刺激时序和设计值偏差在容忍范围内。这个习惯能过滤掉大量潜在的硬件问题。

from psychopy import visual, core win = visual.Window([800, 600], monitor='testMonitor', useFBO=True) timestamps = [] for i in range(60): win.flip() timestamps.append(win.lastFrameT) diffs = [(timestamps[i+1] - timestamps[i]) for i in range(len(timestamps)-1)] avg_interval = sum(diffs) / len(diffs) print(f"平均帧间隔: {avg_interval*1000:.2f}ms") print(f"帧间隔标准差: {__import__('statistics').pstdev(diffs)*1000:.2f}ms") print(f"最大帧间隔: {max(diffs)*1000:.2f}ms")

运行这段脚本,观察三个数字。平均帧间隔接近1000/刷新率,标准差越小越好,最大帧间隔如果超过平均值的 1.5 倍,说明存在掉帧,正式实验里这类掉帧会直接影响 RT 精度。我曾经因为忽略最大帧间隔这个指标,导致一个视觉掩蔽实验的数据多出了 15% 的异常 trial,后来的做法是:每次更换实验室的显示器或显卡驱动之后,都强制跑一遍这个检查脚本,并把结果截图存档。设备参数变了,验证必须重跑,这套流程坚持下来,实验事故率低了很多。

资源的下载和使用并不复杂,关键是走进来之后能把每一步的边界和参数都摸清。希望这份笔记能把你在 PsychoPy 上的试错时间压缩到最小,助你更早跑到稳定、可复现的实验数据。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进