资讯动态

用Python和pygame构建心理学实验:从刺激呈现到Stroop范式完整教程

发布时间:2026/10/14 0:01:01 来源:尧图企业网站定制
前阵子有个做心理学方向的朋友跑来找我说课题要用 Python 写一个实验程序刺激呈现、反应时记录、数据保存全都要自己搞定。聊了几句之后我给他指了一条路用 pygame。这个库通常被当成写小游戏的入门工具但实际上它做心理学实验程序也相当顺手尤其是视觉刺激呈现、按键响应和毫秒级计时这套逻辑几乎是量身定做的。写完之后我把整个流程整理成了这篇文章从为什么选 pygame、程序怎么分层到计时、按键、随机化这些核心模块怎么实现最后给了一个可以直接运行的最小 Stroop 实验完整代码。适合心理学方向的学生、科研助理或者任何想把实验设计变成可运行程序、但又不打算用商业软件绑定的开发者参考。1. 为什么心理学实验程序要自己写pygame 在工具链里的位置1.1 心理学实验程序到底需要什么很多人觉得心理学实验程序不就是“屏幕上放一个刺激然后等人按键”吗听起来简单真动手就会发现要求非常具体。一个合格的实验程序至少要解决五件事刺激呈现、精确计时、按键响应、数据记录、试次流程控制。而且心理物理实验里反应时最短可以到一两百毫秒计时误差就决定了数据可不可用。刺激呈现不光是显示一张图或者一段文字还涉及背景颜色、尺寸、位置、明暗、透明通道、帧同步这些细节。计时则要精确到毫秒刺激出现的时刻、被试按键的时刻、两者之间的差值都必须有可靠的时间来源。按键响应得能区分“按了哪个键”“按了多久”“是不是多键连按”还得能处理超时。数据记录也不能靠一只记事本硬记每个试次的刺激属性、按键内容、反应时、正确与否最好能直接汇成一张表。试次流程则指的是整个实验的顺序比如注视点、刺激、空屏、反馈、间隔每一步都按预设时间走。这套需求本质上就是一个图形事件循环pygame 把图形绘制、事件获取、时间查询全都打包好了。相比之下它不会替你做实验设计但作为底层工具自由度反而更大。你让刺激显示 300 毫秒就是 300 毫秒想让刺激随机出现在屏幕任意位置也只需要改坐标不会被图形的框框限制住。1.2 pygame、专用实验库和网页方案怎么选开写之前我让朋友先想清楚一件事他到底需要多大的功能范围。如果只是做一个简单的选择反应时任务pygame 完全够用。如果要做 fMRI 同步、眼动仪联动、多通道音频刺激这一类高精度需求那确实得考虑专业的实验软件或专用实验库。市面上的选择大致分成几类。一类是专用心理学实验软件普遍上手快、有图形界面、时间精度有保证但授权不便宜而且实验范式一旦超出内置模板范围调起来反而麻烦。另一类是开源专用实验库内置了刺激组件、计时器和数据接口是很多实验室的默认选择不过对新手来说掌握成本不低部分高级功能也要读源码才敢用。再一类就是网页端实验框架优点是分发方便被试打开浏览器就能跑适合在线收集数据缺点是浏览器本身的计时精度受系统调度影响精细控制不如桌面程序稳定。pygame 属于桌面图形库定位偏向通用游戏开发。跟专用软件比它需要你手动实现的东西更多但换来的是完全可控以及巨大的社区资料量。网上随便搜一下就能找到各种现成的刺激呈现脚本出问题也好查。对我来说最关键的一点是pygame 的代码逻辑很直白——画一个 surface、blit 上去、再 flip 出来这套“画面刷新”的思维模式跟实验流程非常贴合。1.3 pygame 的几个短板和绕过方式pygame 不是没有坑但它的问题基本都能绕。最常被吐槽的是中文显示。pygame 默认字体通常不支持中文直接 render 中文文本出来是一堆方块解决办法是加载一个系统中的中文字体文件后面我会给出具体写法。第二个问题是计时精度。pygame 官方的时间接口是 get_ticks()单位是毫秒但实际上它的精度依赖操作系统的时钟粒度在 Windows 上通常够用如果你做的是对反应时要求特别苛刻的实验就得考虑用时间戳对比或者外部时钟这一点后面详细说。还有一个限制是它对“真彩高帧率”场景的表现不如现代游戏引擎。心理学实验一般用不到那么多复杂特效几百毫秒显示一个刺激刷新率 60 FPS 足够平滑。真要处理大图片的缩放、旋转、混合pygame 也能做只是你得注意预加载不要在实验循环里现场加载文件。我自己选工具的判断标准一直很简单工具本身无所谓最好关键是它对你实验范式的适配度以及你解决 bug 时能搜到多少参考资料。pygame 这两点都做得相当好所以作为第一版实验程序它比一上来就堆重型方案靠谱得多。2. 实验程序架构别把代码写成一坨2.1 模块划分画布、时钟、事件、数据四件事各管各的写实验程序最常见的错误是把所有逻辑塞进一个 while 循环画画面、读按键、算时间、写数据全搅在一起。一开始确实能跑等实验条件一多就乱了。我在动手前先划分了四个模块画面呈现模块负责绘制注视点、刺激和提示时间模块负责记录阶段时间点事件模块负责读取按键数据模块负责生成试次条件、记录结果、写文件。每个模块只做一件事互相之间通过简单接口调用。这个思路其实很朴素就像做菜时切菜的切菜、备料的备料、炒菜的炒菜一个人全包也得有自己的工序。你用函数拆分一下后面改任何一个环节比如把注视点从 500 毫秒改成 800 毫秒只需要动一个地方不用去翻整个循环。新手经常忽略一件事pygame 是一个“帧”驱动的系统。所有显示内容要以帧为单位刷新而刷新帧率默认由 clock.tick() 控制。你在主循环里写得越精简帧率越稳定画面闪烁越少。所以那些耗时操作比如加载图片、读取文件一定要全部放到循环之外。我把画布、字体、颜色、试次列表都提前初始化好循环内只做让它显示和翻帧。2.2 一个试次的完整生命周期我个人习惯是把一个试次的流程写成固定时间线这样既方便调试也方便后面补充条件。一个强制的实验试次大致是这样先显示注视点 500 到 800 毫秒让被试把视线集中到屏幕中央然后立即切换成空白屏或直接进入刺激刺激出现的同时记录当前时间戳被试一旦按下有效按键马上计算反应时也就是“按键时间戳减去刺激时间戳”如果设置了超时还要加一个超时上限比如 2000 毫秒内没有按键就跳入下一个试次最后把这一试次的数据写进内存里的列表再进入下一个试次。这里最微妙的地方在于刺激呈现的“开启时刻”并不是程序里 draw 那行代码执行的瞬间而是画面真正显示出来的瞬间。pygame 里两者最容易混淆draw 只是把内容画到后缓冲区必须执行 flip() 才会真正上屏。所以先调用 flip()随后立刻取时间戳才最贴近被试真正看到刺激的时刻。虽然误差只有几毫秒但对于行为实验来说这几毫秒直接影响组间差异的显著性。我把试次本身设计成一个容易替换的函数这样将来换实验范式只需要替换“呈现刺激”这个环节其他计时和记录逻辑全部复用。这就是模块化带来的最大红利后续扩展新的实验条件比如把单个刺激改成两张图同时出现代码改动量非常小。2.3 数据记录别用 print 凑合不少初学者会把按键和反应时直接 print 到控制台完事试跑看着挺合理真跑完 200 个试次就知道从控制台复制数据回来整理简直是噩梦。数据一定要按行保存一个试次一行字段固定方便后期用表格处理。我统一使用 CSV 格式字段至少包括试次序号、刺激条件、被试按键、反应时、正确与否、时间戳。CSV 的好处是轻量、通用Excel、数据分析软件都能直接打开。写 CSV 推荐用 csv 模块而不是自己拼接逗号字符串因为有些字段里可能包含逗号或者特殊符号writer 会自动处理好。文件名里最好带上被试编号和实验条件比如 exp1_sub01.csv避免多人实验把数据混在一起。实际操作中我还会额外记录一个日志文件把每个试次的原始按键事件存下来。这样即使实验中途出了意外比如被试按了两下键盘也能从日志里还原现场。数据是实验的命根子怎么谨慎都不为过。2.4 文件组织与版本管理建议一个完整的实验项目我不建议把所有代码堆在单个脚本文件里。哪怕不做什么复杂架构也建议至少分出三个文件配置参数文件、主实验脚本、数据处理脚本。配置参数文件把实验条件、刺激路径、时间参数、被试编号全放进去这样换实验设置时不用去代码里到处找常量。版本管理方面建议从一开始就用版本工具。我做实验程序最深的感触就是改方案的速度永远比想象中快。这周觉得反应时限制 1500 毫秒合适下周可能就要改成 1200 毫秒改完还要跑对比数据。如果没有版本记录回头想对比两版行为差异只能对着同名文件发呆。还有一个很多人没意识到的问题被试反应数据不要存到代码目录。程序文件夹是拿来放代码的数据单独放在一个 data 目录后面写分析脚本时只需要扫描一个目录不会误读旧被试的冗余文件。3. 核心模块逐一拆解计时、刺激与按键3.1 精确计时毫秒时间戳怎么拿才对pygame 里最常用的计时函数是 pygame.time.get_ticks()它返回从 pygame.init() 开始经过的毫秒数。为什么不用 time.time()time.time() 返回的是 Unix 时间戳用起来需要做差而且它在不同平台上精度不稳定。get_ticks() 的底层实现针对图形事件循环做过优化属于“单调时间”不会因为系统时钟被手动调整而跳变所以做按键反应时计算更稳。需要注意 get_ticks() 的精度是毫秒它的实际分辨率依赖系统定时器。一般认知心理学实验里几十毫秒的差异就很有意义所以毫秒级是基本要求。如果实验涉及更精细的视觉呈现比如刺激只出现 50 毫秒你需要额外考虑屏幕刷新率。在 60Hz 刷新率下一次真正上屏的最小时间单位大约是 16.7 毫秒你要显示 50 毫秒实际是 3 帧 50.1 毫秒这属于硬件层面的限制任何软件都绕不开只能靠估算和标准化处理。我的处理方式是在每个试次里用变量保存两个关键时间点刺激出现时间flip 之后的 get_ticks()和按键时间事件循环里检测到 KEYDOWN 时的 get_ticks()。两者相减就是反应时。另外框架的 clock.tick(60) 不是计时工具它只负责控制循环帧率防止 CPU 空转和画面闪动别让这两件事搞混。3.2 刺激呈现图片、文字与背景细节pygame 呈现内容的核心流程是三步先创建文字或图像 surface再用 blit 把它“贴”到主画布上最后调用 display.flip() 把整个画布推送到屏幕。很多第一次接触 pygame 的人容易忘了第三步导致程序跑起来屏幕一直是黑的就是这个原因。文字渲染最大的坑是中文。pygame 自带的默认字体 freesansbold.ttf 不支持中文直接 render(红, True, RED) 会得到方块。解决方案是用 pygame.font.Font 加载一个真实的中文字体文件Windows 系统中常见的路径是 C:/Windows/Fonts/msyh.ttc 或 C:/Windows/Fonts/simhei.ttf。如果不确定系统路径也可以把字体文件直接放进项目目录用相对路径加载这样换电脑跑也不会挂。我的代码里就是这么做的指定“msyh.ttc”只要文件在脚本同目录就不会出错。图片呈现时最容易犯的错误是每次刷新都从磁盘加载同一个文件。文件 I/O 比内存操作慢得多加载一次也许才三十毫秒几百个试次累积下来就是在实验里插入无数卡顿。正确姿势是在初始化阶段用 pygame.image.load() 把图片提前读成 surface 对象之后循环里只做 blit 和 flip几百次的量级完全没问题。还有一个容易忽略的细节背景色。pygame 默认窗口背景通常是黑色纯黑背景在暗室里问题不大但在正常亮度环境下观看黑色背景与被试视线里其他环境亮度差异太大容易产生余光效应。统一用白底或浅灰底并且实验全程固定不变这样被试的瞳孔状态和视觉适应保持一致对后续反应时统计更友好。3.3 按键响应事件循环里的几个坑按键响应依赖 pygame.event.get()它会一次性取出从上次调用到现在积累的所有事件。常见实现是在每个主循环帧里执行 event pygame.event.get()逐个检查是否为 KEYDOWN 事件。这里我处理的是每一试次内部的等待循环只用 while 循环直到收到有效按键或超时然后把反应时记录下来。有一个坑必须单独拎出来讲按键自动重复。操作系统默认开启了“按住键盘连续触发”的功能当被试按一个键超过一定时间系统会重复产生 KEYDOWN 事件。如果你不做任何处理程序会以为被试连续做了多次按键导致记录错误。解决方式有两种一种是在获取事件循环里只取第一个 KEYDOWN另一种是调用 pygame.key.set_repeat(0) 禁用重复触发。我在完整示例里采用的是“取第一个有效按键”的方式简单可靠。另一个坑是事件队列的“串味”问题。前一个试次的按键事件留在队列里如果不清理会跑到下一个试次的响应阶段导致反应时出现负值或者错误判断。在两个试次之间调用一次 pygame.event.clear() 是最稳妥的做法。我的代码里就加了这一行注释里也标明了用途。多方按键的处理也不能忽略。被试可能同时按了两个键系统会生成两个独立的 KEYDOWN 事件。实验约定“只取第一个”是一种常见策略如果实验要求多键组合那你需要额外设计但多数心理学实验都是单键反应暂时够用。3.4 随机化如何做出“看起来随机”的顺序随机化看起来是最简单的部分python 里 random.shuffle() 一行搞定但实际实验里讲究可不止这些。最怕的是随机顺序里前十个试次全是同一种条件被试会形成预期反应时数据就被污染了。我的做法是先构建一个完整的条件池每个条件出现次数相等然后统一 shuffle。比如要做字义和颜色交叉的两个变量四个汉字颜色两两组合一共十六种条件每个条件重复两次得到三十二个试次再对整个列表打乱。这样既保证了所有条件出现次数一致顺序又不会出现明显聚集。更严格的话还可以做伪随机控制把试次分成多个 block每个 block 内条件数量均衡block 之间顺序打乱这样被试永远不会觉得“下一个刺激可以被猜到”。实现上只需要在生成列表时多套一层循环代码复杂度增加一点点数据质量的收益却很大。随机种子也要记得设置。设置 random.seed() 后每次运行生成的试次顺序完全相同这方便调试和做预实验正式跑被试的时候再取消种子让每次独立随机。我习惯把是否使用随机种子放进配置文件平时调试设成固定种子正式实验设成 None。4. 实操中的常见坑与排查记录4.1 帧率波动和屏幕闪烁我第一版程序跑起来之后发现刺激出现的一瞬间画面明显闪了一下像是白屏和刺激之间跳了一下。排查下来问题出在两处。一个是加载图片文件放在了循环内导致某些试次帧率突然降到十几帧另一个是主循环里用了太厚的多重缓冲逻辑每一帧都重复创建了背景 surface。解决办法很明确把耗时操作全部提前到初始化阶段每个试次呈现阶段只保留 blit 和 flip背景在每帧开始时单独填充一次即可。另外在等待按键的 while 循环里加上 clock.tick(60)确保帧率被限制在一个稳定的节奏上画面就不会忽快忽慢。关于闪烁还有一个鲜为人知的细节尽量避免在呈现刺激后立刻做大量日志写入或复杂计算因为此刻的延迟会直接拉长画面停留时间。把耗时任务挪到试次结束之后比如刺激消失、按键已经记录完毕之后再去做数据整理和文件写入是对呈现阶段最友好的安排。4.2 中文显示成方块中文方块这个问题几乎每个 pygame 初学者都会撞上。我在实现时直接指定了中文字体文件 msyh.ttc但有些同学的电脑上没有雅黑字体就会出现“文件找不到”错误或者字体路径写错导致整个程序起不来。稳妥的写法是用 pygame.font.SysFont(microsoftyahei, 48)系统会自动搜索已安装的字体。如果觉得这样不够通用也可以在代码里先做一次字体匹配匹配不到就回退到项目目录下的中文字体。字体还有另一个正常途径的坑字体大小。笔记本分辨率很高同一个 font size 在普通桌面和笔记本上显示出来视觉大小差异很大。对于心理学实验刺激的视觉角度最好是固定的。你可以根据屏幕尺寸和视距换算像素值来设置字号不过大多数情况下字号 48 到 64 像素在正常视距下都能满足阅读需求不必过分纠结。4.3 被试按错键和按键自动重复正式实验里被试的情绪状态千奇百怪容易被刺激内容干扰导致按错键。你无法阻止他用错手指但程序要能识别这种情况。我的策略是把所有按键都记录下来包括规范外的按键存成 UNKNOWN。后期分析时可以根据数据筛选有效试次而不是直接在运行现场卡死程序。按键自动重复这个坑更隐蔽。程序跑到一半某被试按住红色键不松手系统开始重复触发 KEYDOWN如果在事件循环里没有做“只取第一次”的处理一个试次内可能记录到几个反应时后续的正确率统计就乱了。我们收到几例“正确率超过 100%”的诡异数据排查后发现就是这么来的。要么用 pygame.key.set_repeat(0) 关闭要么在检测到一次按键后立即跳出当前循环二选一。4.4 实验崩溃导致数据全丢最惨烈的一次事故是实验跑到一半程序抛异常退出因为我是把所有数据攒在内存里、实验结尾才统一写文件最后只留下几个 print 出来的残缺信息。从那以后我再也不在实验结束才落盘。现在的做法是每个试次结束就把结果追加写入 CSV采用追加模式新数据写在文件末尾程序哪怕是中途闪退已完成试次的数据也稳稳躺在硬盘上。同时我在系统里设置了一张备份数据目录每次实验运行自动把原始 CSV 复制一份带时间戳的备份。数据丢失这种事来一次就够长记性了心理实验的被试不容易重新找重新跑一遍的数据跟第一次的数据合并分析会很麻烦。还有个小技巧在开发调试阶段先用一个小尺寸窗口和 8 个试次跑通流程确认落盘文件正确之后再切回正式参数。这个习惯帮我避开了大部分因为逻辑错误造成的无效数据。5. 完整示例一个可以直接跑的最小 Stroop 实验5.1 实验设计说明Stroop 效应应该是心理学界最经典的范式之一。屏幕上显示一个汉字比如“红”但它本身用绿色渲染。被试的任务是忽略字义根据字的颜色去按键响应。当字义和颜色不一致时反应时显著变长这就是冲突效应。这个小实验一共四十八个试次也包含了两轮完整组合打乱。显示界面是 1024x768做了注视点、刺激呈现、按键响应和自动保存数据的完整闭环。设计成可以直接跑通、拿来即用的程度。按键映射采用 R、G、B、Y 四个键分别对应红、绿、蓝、黄四种颜色。指导语会在实验开始前显示按空格键开始正式试次。每个试次先显示注视点 500 毫秒然后呈现刺激等被试按键后立即记录反应时和正确性试次结束前清空事件队列防止按键传到下一个试次。5.2 完整代码import csv import random import pygame # 颜色定义 RED (255, 0, 0) GREEN (0, 255, 0) BLUE (0, 0, 255) YELLOW (255, 255, 0) BLACK (0, 0, 0) WHITE (255, 255, 255) WORDS [红, 绿, 蓝, 黄] COLOR_RGB {红: RED, 绿: GREEN, 蓝: BLUE, 黄: YELLOW} KEY_TO_COLOR { pygame.K_r: 红, pygame.K_g: 绿, pygame.K_b: 蓝, pygame.K_y: 黄, } def show_instruction(screen, font): 显示指导语按空格键开始实验。返回 False 表示用户退出。 screen.fill(WHITE) lines [ 欢迎参加颜色命名实验, 请根据屏幕上汉字的颜色作答忽略字义, 按 R 红 G 绿 B 蓝 Y 黄, 准备好后按空格键开始, ] y 200 for line in lines: text font.render(line, True, BLACK) rect text.get_rect(center(512, y)) screen.blit(text, rect) y 60 pygame.display.flip() waiting True while waiting: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() return False if event.type pygame.KEYDOWN and event.key pygame.K_SPACE: waiting False return True def draw_fixation(screen): 在窗口中央绘制注视点十字。 screen.fill(WHITE) pygame.draw.line(screen, BLACK, (502, 384), (522, 384), 3) pygame.draw.line(screen, BLACK, (512, 374), (512, 394), 3) pygame.display.flip() def draw_stimulus(screen, font, word, rgb): 呈现刺激文字。 screen.fill(WHITE) text font.render(word, True, rgb) rect text.get_rect(center(512, 384)) screen.blit(text, rect) pygame.display.flip() def save_results(results, filenamestroop_results.csv): 将试次数据写入 CSV 文件。 with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([trial, word, color, press, correct, rt_ms]) for idx, r in enumerate(results, start1): writer.writerow([ idx, r[word], r[color], r[press], 1 if r[correct] else 0, r[rt], ]) def build_trials(): 构建条件均衡的试次列表2 轮 x 4 字义 x 4 颜色 32 试次。 trials [] for _ in range(2): for word in WORDS: for color in WORDS: trials.append((word, color)) random.shuffle(trials) return trials def main(): pygame.init() screen pygame.display.set_mode((1024, 768)) pygame.display.set_caption(Stroop 实验) # 字体路径可以换成系统里的其他中文字体 font pygame.font.Font(msyh.ttc, 48) clock pygame.time.Clock() if not show_instruction(screen, font): return trials build_trials() results [] running True trial_index 0 while running and trial_index len(trials): draw_fixation(screen) pygame.time.wait(500) word, color_name trials[trial_index] draw_stimulus(screen, font, word, COLOR_RGB[color_name]) start_time pygame.time.get_ticks() pressed_key None rt None # 等待按键只取第一个有效 KEYDOWN while pressed_key is None: clock.tick(60) for event in pygame.event.get(): if event.type pygame.QUIT: running False pressed_key QUIT elif event.type pygame.KEYDOWN: rt pygame.time.get_ticks() - start_time pressed_key event.key if pressed_key QUIT: break # 清空事件队列避免上一个试次的按键影响下一个试次 pygame.event.clear() press_color KEY_TO_COLOR.get(pressed_key, UNKNOWN) correct press_color color_name results.append({ word: word, color: color_name, press: press_color, correct: correct, rt: rt, }) trial_index 1 save_results(results) pygame.quit() print(实验结束共记录, len(results), 个试次) if __name__ __main__: main()5.3 运行后的数据检查清单代码跑完之后不要立刻宣布大功告成先做几个数据核查。打开生成的 CSV第一行表头应该在字段顺序不能乱。看 rt_ms 那一列如果出现低于 100 毫秒的反应时很有可能是按键事件串队列了说明事件清空时机还有问题。正确率那一列在全实验没有额外干扰的情况下正常人做颜色命名正确率通常高除非字义冲突对被试干扰得厉害。如果发现反应时普遍偏高看是不是指导语阶段没有让被试充分理解按键映射。如果是正式实验建议在指导语之后先安排 5 到 10 个练习试次等被试完全进入状态再开始记录这也是我后来反复强调的实践改动。日志里记录到的按键映射集中落在 R、G、B、Y 四个键上如果出现大量 UNKNOWN多半是指导语中按键说明不够清楚。检查通过之后这套代码就可以作为你自己的实验模板一直用下去。换一个实验范式时只需要独立替换 draw_stimulus 函数并调整试次列表就能保持计时、按键和数据落盘这些底层逻辑不变。我做这类程序时最后一步固定是拿一份真实被试数据跑一遍分析流程。如果脚本能在原始 CSV 上直接算出反应时均值、正确率和冲突效应量说明程序的数据出口跟下游分析完全对接整条实验链路才算真正闭合。编程不是为了写出一堆能运行的代码而是为了拿到干净、可靠、可分析的行为数据这一点从写第一行代码开始就要记在脑子里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑