心理学实验程序是个很特殊的软件它看起来像游戏但又不能像游戏那样“差不多能玩就行”。研究者要在几十毫秒级别控制刺激的出现和消失记录每一次按键的时间还得保证不同被试、不同电脑上跑出来的结果能横向比较。我接过不少这类需求包括用Python自编Stroop实验、注意瞬脱范式、视觉搜索任务最常用的方案就是Pygame。这篇就把Pygame做心理学实验程序这件事从头拆一遍覆盖架构设计、计时精度、刺激呈现、数据记录以及那些不跑上几十遍根本发现不了的坑。如果你是心理学专业准备做毕业设计的学生或者刚接触实验编程的开发者又或者只是想快速验证一个实验想法这篇文章应该能帮你少走很多弯路。我会用一套经典的Stroop效应实验作为贯穿案例代码可以直接改改用。1. 先想明白心理学实验程序到底要管哪几件事很多第一次写实验程序的人上来就打开编辑器开始画界面这是最容易翻车的姿势。实验程序和普通应用软件有个本质区别它的核心不是“功能”而是“可重复的时间控制”。我习惯把需求拆成四个支柱缺一个都不行。1.1 四个不可妥协的支柱第一是计时精度。认知心理学实验里反应时常常以毫秒为单位比较屏幕上刺激呈现100毫秒还是120毫秒可能直接影响实验结论。Pygame里的计时精度虽然达不到专用硬件级别但普通显示器配合垂直同步加上合理的代码写法稳定在几毫秒偏差内是没问题的。第二是刺激控制。什么刺激、什么颜色、出现在屏幕哪个位置、持续多久、后面跟什么掩蔽这些都要能被程序精确调度。麻烦在于刺激不是孤立存在的它前后往往有注视点、掩蔽、空屏、反馈实验程序必须像一台按剧本运行的机器。第三是响应收集。被试按下哪个键、按下的时刻、按早了还是按晚了程序都要准确捕获。这里不只是“有没有按到键”还要区分是反应键还是退出键避免Q键被误当成实验反应。第四是数据记录。每个试次的条件、刺激内容、按键结果、反应时、正确与否最好还能带上被试编号和实验阶段逐行写进结构化文件里。没有这一步前面做得再精细也是白搭。1.2 为什么是Pygame它和PsychoPy差在哪经常有人问我既然心理学实验圈主流是PsychoPy为什么还要用Pygame我的回答很简单看场景。Pygame胜在轻、快、可控。它本质是个游戏开发库没有预设的实验范式这意味着所有细节你都要自己写但反过来也意味着你能完全掌控流程。对于需要定制的任务或者想在实验之外做点交互演示Pygame比PsychoPy更顺手尤其是不那么依赖PsychoPy那套“可视化组件自动生成代码”的工作流时。还有一点很实际Pygame的依赖很少pip装完就能跑跨平台稳定无论Windows还是macOS都能用。对课程作业、毕业论文预实验、行为实验原型的场景来说Pygame是完全够用的成熟方案。那什么时候不建议用Pygame如果是fMRI实验需要把刺激触发时刻和设备扫描同步到亚毫秒级那应该考虑专门工具或硬件方案如果要做复杂自适应算法加现成的多范式库PsychoPy会更省力如果被试在千里之外需要远程在线收集数据那就该转用Web技术。其实这些不是Pygame的短板而是它本就不是为这些场景设计的。2. 实验程序的地基试次、区组与控制流程写完需求分析下一步不是写代码而是把实验流程“结构化”。心理学实验说到底是把人的认知过程拆成一个个可重复的最小单元这个最小单元就是试次trial。2.1 把整个实验拆成“会话-区组-试次”从大到小看一个完整实验通常长这样被试坐下来完成一次实验这是一个“会话”会话里按顺序跑若干个“区组”比如先做练习区组再做正式区组中间可能有休息每个区组又由若干个“试次”组成。单个试次的内部节奏也要设计好。以我常用的Stroop任务为例一个试次大概是这样的节奏先出现注视点让被试集中注意力然后呈现刺激词被试看到后按键判断字体颜色按键之后给一个非常简短的反馈或直接进入空屏最后有一段试次间间隔再进入下一个试次。阶段作用典型时长注视点固定注视位置提示试次开始400-600 ms刺激呈现显示色词等待被试判断不超时则直到按键响应窗口按键后短暂停留避免提前进入下一试次视实验设计而定反馈告诉被试对错可选200-500 ms空屏/间隔清除视觉后效准备下一试次200-500 ms时长不是随便拍的。注视点太短被试来不及集中太长又会被试觉得拖沓。设计时要把被试的平均反应时、任务难度和实验总时长都考虑进去。比如正式实验每个试次预计2秒到3秒做了100个试次那么这一区组就是5分钟左右中途要安排休息。2.2 为什么流程控制要用“状态机”而不是time.sleep新手最常见的错误是在Pygame里这么写先画注视点然后time.sleep(0.5)再画刺激词然后time.sleep(1.0)……这种写法在普通程序里没问题但在交互式实验里是灾难。为什么因为time.sleep会让整个进程睡过去睡着的期间Pygame窗口完全无法处理事件。如果被试恰好在刺激呈现后50毫秒就按了键程序还在睡眠中这个按键会被丢掉或者延迟读取反应时直接失真。而且睡眠期间画面也卡住界面看起来就像死机了。正确的做法是“非阻塞状态机”程序不断刷新画面、不断检查事件但通过“每个阶段的起始时间戳”来判断当前应该显示什么。框架很朴素state fixation state_start pygame.time.get_ticks() while state ! done: for event in pygame.event.get(): if event.type pygame.KEYDOWN: # 这里记录按键和反应时 pass now pygame.time.get_ticks() if state fixation and now - state_start 500: state stimulus state_start now elif state stimulus: # 直到按键或超时 pass # 根据当前状态绘制画面 draw() pygame.display.flip()这样程序永远不会睡觉每帧都在“监听”键盘和时钟按键不会丢计时也不会因为睡眠而错位。2.3 伪随机与条件平衡实验流程确定后试次的顺序不能是“先全部呈现A条件再全部呈现B条件”这样被试很容易猜到规律练习效应也会混进数据。常见做法是先生成所有试次的条件组合然后打乱顺序。真正的难点是“平衡”。用Stroop任务举例实验里有红、绿、蓝三种字色又有红、绿、蓝三种词义一共9种组合。设计上要保证每种组合出现次数一样同一词义或同一种颜色不能连续出现太多次避免顺序效应最好在正式实验前设置一个随机种子让实验可复现。伪随机不是丢进random.shuffle就完事。我通常先构造一个列表每个元素是“词义颜色”的组合保证每种组合出现次数相等然后shuffle再检查是否存在某个条件连续出现超过3次如果超过了就重新shuffle一次或者手动交换。3. Pygame核心细节初始化、计时与刺激呈现架构想清楚后才轮到具体的Pygame实现。这一章的每一个细节都会直接影响实验数据质量我会详细讲。3.1 初始化画布与刷新策略窗口初始化有几个参数值得认真对待。首先不建议用全屏模式全屏在不同分辨率显示器上会造成刺激大小和位置不一致最好固定窗口尺寸比如800×600。其次可以开启双重缓冲DOUBLEBUF用它配合display.flip()切换画面避免绘制过程中出现撕裂现象。Pygame 2.0之后set_mode支持一个vsync参数我强烈建议在能支持的平台上把它设为1。垂直同步会让每次画面刷新都和显示器的刷新率对齐60Hz显示器就是16.7毫秒一帧。有了它画面更稳定时间抖动也更小。帧率控制用pygame.time.Clock().tick(60)就行。这里有个经验主循环里的帧率设置为60就够不是越高越好。如果设为0Pygame不会限制帧率程序会疯狂刷新把CPU占用和计时抖动都拉上去反而影响精度。3.2 反应时到底该怎么记计时这块值得单独拿出来聊。很多教材会告诉你用pygame.time.get_ticks()它返回从初始化到现在经过的毫秒数确实常用。但真正处理按键反应时的时候有一个更容易被忽视的点应该直接读取事件自带的时间戳。Pygame的键盘事件里有个event.time字段它记录的是这个按键事件被放入事件队列时的时间。如果你在for event in pygame.event.get()循环里才调用get_ticks()那么这一瞬间与按键实际发生的瞬间之间可能已经隔了一次或多次帧间隔反应时会偏大几毫秒。别小看这几毫秒Stroop效应量的平均值往往也就在几十到一百毫秒左右几毫秒的系统误差可能把小效应淹没掉。正确做法是for event in pygame.event.get(): if event.type pygame.KEYDOWN: rt event.time # 比在循环里再取get_ticks()更接近真实按键时刻至于阶段切换用的时间基准比如刺激呈现了多久、注视点显示了几毫秒仍然用now pygame.time.get_ticks()来对比start_time这部分没有问题。3.3 文字、图片与中文渲染的注意点Stroop任务的核心刺激是“带颜色的中文词”于是中文渲染就成了绕不开的问题。pygame.font.SysFont(None, 48)这种写法可能拿不到中文字体结果就是满屏方块。我的习惯是用系统常见字体创建字体对象font pygame.font.SysFont(microsoftyahei,simhei, 48)如果目标是跨平台发布更稳妥的做法是把一个开源中文字体文件比如思源黑体的TTF放在项目目录里然后用pygame.font.Font(font.ttf, 48)加载程序在任何电脑上跑出来的样子都是一样的。图片刺激的处理也有讲究。pygame.image.load()官方文档说支持多种格式但实验程序里最好统一用PNG为什么因为PNG支持透明通道可以把刺激图和背景融合不会出现难看的黑色方块。另外所有图片要在实验开始前全部加载到内存里绝对不要在每个试次里临时load文件否则遇到磁盘IO慢的电脑画面会卡顿时间线全乱。还有一个关于刺激时长的经验在60Hz显示器上真实呈现时长只能以帧为单位变化。比如你想让刺激呈现100毫秒程序逻辑上到点就切换但由于帧率固定实际可能是100毫秒或者116.7毫秒。常用的办法是让时长落在整数帧上比如设计成100.2毫秒或者直接接受这个量化误差并且在论文里报告设备刷新率。这一点对短时刺激尤其重要。3.4 完整可运行的小例子一个极简Stroop实验把上面的原则都落到代码里一个极简但完整可跑的Stroop实验大概长这样。这个版本做了必要的拆解练习试次和正式实验各跑一遍并把数据写入CSV文件。import pygame import random import csv import time COLOR_INK { red: (220, 30, 30), green: (30, 180, 60), blue: (30, 60, 220), } WORD_TO_COLOR {红: red, 绿: green, 蓝: blue} COLOR_WORDS [红, 绿, 蓝] WIDTH, HEIGHT 800, 600 BG_COLOR (240, 240, 240) FIX_DURATION 500 TIMEOUT 2000 FEEDBACK_DURATION 300 ITI_DURATION 300 def load_font(size): return pygame.font.SysFont(microsoftyahei,simhei, size) def make_trials(n_per_condition8): trials [] for word in COLOR_WORDS: for ink in COLOR_INK: trials.extend([(word, ink)] * n_per_condition) random.shuffle(trials) return trials def run_session(screen, trials, font, session_name, writer): for idx, (word, ink) in enumerate(trials): screen.fill(BG_COLOR) fix font.render(, True, (80, 80, 80)) screen.blit(fix, fix.get_rect(center(WIDTH // 2, HEIGHT // 2))) pygame.display.flip() pygame.time.wait(FIX_DURATION) stim_start pygame.time.get_ticks() correct_key {red: pygame.K_d, green: pygame.K_f, blue: pygame.K_j}[ink] responded False rt None key_pressed None while pygame.time.get_ticks() - stim_start TIMEOUT: for event in pygame.event.get(): if event.type pygame.KEYDOWN: key_pressed event.key rt event.time - stim_start responded True break if responded: break screen.fill(BG_COLOR) text_img font.render(word, True, COLOR_INK[ink]) screen.blit(text_img, text_img.get_rect(center(WIDTH // 2, HEIGHT // 2))) pygame.display.flip() # 反馈 accuracy 1 if responded and key_pressed correct_key else 0 feedback_str 正确 if accuracy else 错误 if key_pressed is None: feedback_str 超时 screen.fill(BG_COLOR) fb font.render(feedback_str, True, (0, 0, 0)) screen.blit(fb, fb.get_rect(center(WIDTH // 2, HEIGHT // 2))) pygame.display.flip() pygame.time.wait(FEEDBACK_DURATION) screen.fill(BG_COLOR) pygame.display.flip() pygame.time.wait(ITI_DURATION) writer.writerow([session_name, idx 1, word, ink, correct_key, key_pressed, int(rt) if rt else NA, accuracy]) def main(): pygame.init() screen pygame.display.set_mode((WIDTH, HEIGHT), pygame.DOUBLEBUF) pygame.display.set_caption(Stroop 实验) data_file stroop_data_{}.csv.format(int(time.time())) with open(data_file, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([session, trial, word, ink, correct_key, pressed_key, rt_ms, accuracy]) practice make_trials(n_per_condition2) formal make_trials(n_per_condition8) for t in practice: run_session(screen, [t], load_font(60), practice, writer) for t in formal: run_session(screen, [t], load_font(60), formal, writer) pygame.quit() if __name__ __main__: main()这段代码的意义在于展示了完整骨架试次生成、刺激呈现、按键检测、超时判定、反馈、CSV记录。真正做正式实验时我会把run_session里的每个阶段拆成独立状态但核心逻辑已经具备。4. 数据怎么记才不算白跑CSV设计与试次控制实验程序好不好最终要看数据。跑完一百个试次如果日志里缺了关键字段这组被试的数据就废了。数据记录这块怎么做我有些固定的习惯。4.1 试次信息全量落盘每次写CSV我至少会包含这些列被试编号或会话名、试次序号、条件名、刺激内容、刺激颜色、正确反应键、被试实际按键、反应时、正确与否。条件名这件事很多人会漏但如果之后想按Stroop效应里“一致/不一致”分组分析条件名缺了就只能靠猜很不划算。编码方面直接用encodingutf-8-sig这样用Excel打开CSV文件时中文表头不会乱码。列之间的分隔不要用空格统一用逗号避免后面数据分析时还要手动清洗。4.2 练习和正式实验的分区为什么要把练习和正式分开因为练习阶段被试还在熟悉按键数据稳定性差直接混进正式分析会把“学习效应”和“Stroop效应”混在一起。但练习数据也不是完全没用。我会把练习数据单独保存而不是直接丢弃。万一正式实验中发现按键映射有系统性问题还能回头查练习阶段的表现来排除是程序问题还是被试问题。我的做法是CSV里加一列session值为practice或formal这样一份文件里什么都有分析时按列筛选就行。练习区组的试次数量不用多每个条件出现一到两次即可重点是让被试理解规则。正式区组的试次数则要根据统计检验的需求和实验时长来定。比如要求每个条件至少30个试次三个字色×三个词义就是9个条件那正式试次至少270个再算上练习整个实验大概20分钟试次间必须安排休息。4.3 超时与错误按键的处理超时是实验里一定会遇到的情况。被试可能走神、分心、或者没理解规则一直不按键。程序不能无限等下去每个试次要设一个最大响应窗口。比如Stroop反应窗口常见是2000毫秒超过就算超时记录为错误反应但反应时字段留空作为无效数据。还有一个细节是错误按键。被试按了条件映射之外的键怎么办我的处理是照常记录按键和反应时正确率记为0但不要立刻中止实验。实验程序不是考试系统它只负责记录被试真实做了什么而不是纠正被试。想要实时更正的可以像我上面的代码一样在每次按键后给一个小反馈被试自己会慢慢调整。5. 踩坑实录我在跑实验时遇到的几个诡异问题下面这部分是我最想分享的。这些问题不踩一遍你根本意识不到实验程序有多容易被环境因素影响。5.1 记录到的反应时比真实按键慢了几十毫秒某次预实验我写了个自动按键脚本做验证发现日志里记录的“反应时”比脚本里指定的按键时刻普遍大了30毫秒左右。排查后发现两个原因叠加一是脚本和主程序同时跑导致事件队列拥塞二是代码里用get_ticks()而不是event.time记录按键时间。这个问题的排查思路是先造一个“已知反应时”的输入再对比日志输出。我用pygame.event.post()向事件队列塞进一个模拟的KEYDOWN事件事件里的时间戳是手动指定的然后看程序记录的结果对不对。如果对不上问题一定出在事件读取或时间戳选择上和硬件无关。5.2 刺激图案偶尔闪一下或者出现残影预实验里最让人抓狂的是刺激明明画好了屏幕上却闪了一下才稳定甚至上一试次的画面会残留。原因通常是绘制和刷新没有对齐比如在阶段切换时多调了一次pygame.display.flip()或者在每次绘制前没有填满背景色。我的经验是每个试次的每个阶段永远遵循“先清屏再绘制最后只调一次flip”的顺序。不要在一个阶段里多次调用flip去“提前显示”什么所有元素同帧呈现才能保证刺激的整体性。5.3 中文全部变成方块这个坑几乎每个用Pygame的中文用户都撞过。pygame.font.get_default_font()在不少系统上拿到的默认字体不支持中文。解决办法就是刚才提到的显式指定中文字体或者直接带一个开源TTF文件。还有一点字体对象要在初始化时创建一次不要在每个试次里重复创建否则绘制性能会下降。5.4 被试不小心切走了窗口回来以后流程乱套真实实验中被试有可能碰到窗口边缘或者按到Win键导致窗口失焦。Pygame在窗口失焦期间键盘事件部分会丢失甚至某些按键会进入“按键状态不同步”的怪状态回来以后画面和逻辑对不上。我做实验程序时会加一道保险让程序监听焦点事件。检测到窗口失焦时自动暂停实验并显示提示直到窗口重新获得焦点再继续。不要小看这个细节实验现场没有开发者随时盯着一个被试误触就可能让一整天数据作废。5.5 交付前的自动试跑清单我现在接手任何实验程序交付前都会做一轮自动试跑。写好一个脚本按预定时间间隔向窗口发送模拟按键连续跑500个模拟试次然后检查数据文件里的反应时和正确率是否与预期一致。这套流程能一次性发现很多低级错误比如事件处理分支写错、超时判断边界不对、CSV列漏了字段等等。自动脚本只需要用pygame.event.post()构造事件就行跑完数据一统计所有问题就暴露了。6. 往哪走Pygame之后的选择说到这可能有人会想我是不是应该直接上PsychoPy这是一个值得正面回答的问题。6.1 需要严谨到发论文时该不该换平台如果实验要被用于正式的同行评审论文尤其涉及毫秒级时间精度、设备同步或者特殊硬件那Pygame的定位就是“原型验证”而PsychoPy这类专业工具会更省心。它们自带垂直同步、精确计时组件、刺激库还提供大量现成范式模板能把时间精度和安全性的门槛拉低很多。但这不等于Pygame不能发论文。很多人用Pygame发表过行为实验结果前提是你要在方法部分写清楚设备、刷新率、计时方案和数据筛选标准也就是你能向别人证明你的程序在可控误差范围内运行。这篇文章里提到的所有细节本质上就是为这个“可证明”服务的。6.2 眼动仪、EEG设备怎么接入心理学实验经常要连外部设备。Pygame的优势是它只是一个普通Python库完全可以用pyserial读写串口或者用TCP/UDP向眼动仪、EEG放大器发触发码。做法也很直白在刺激呈现的同一帧里向串口发送一个字符设备端收到后就开始打标记。这里要注意的是串口发送本身有延迟应该提前打开连接不要在每个试次现场建立连接。6.3 在线实验是另外一码事如果你需要的不是本地实验而是让被试远程在家做任务那Pygame基本派不上用场。这种场景要用Web技术比如jsPsych、lab.js这类在线实验框架配合平台招募被试。它们的好处是跨平台、免安装但代价是时间精度受浏览器和设备影响更大采集方式也需要重新设计。这个方向与Pygame是两种完全不同的技术路线就不展开说了。做实验程序这件事外表看是写代码内核是“用自己的程序替他人在时间维度上做精确测量”。我个人的体会是先别急着写主程序把试次流程画出来把每个阶段的时间参数列成表把数据字段定义清楚再动手写Pygame进度反而会更快。最后再分享一个小技巧正式实验开始前一定让一个没参与编程的人先跑一遍练习你可能意外地发现某些说明文字、按键提示在“外人”眼里原来那么难懂。程序稳定、说明清楚、数据完整实验就成功了一大半。
阅读完成 · 觉得有帮助?