用Python+pygame自建心理学实验程序:精确刺激呈现与反应时记录

发布时间:2026/10/14 19:46:14
用Python+pygame自建心理学实验程序:精确刺激呈现与反应时记录
很多做心理学实验的组一开始都是拿现成的商用实验平台跑范式。我早些年也这么干过后来发现有两个地方实在忍不了一是每台被试机都要处理授权二是特殊范式想加个自定义反馈界面在平台里绕来绕去特别费劲。于是我用Python和pygame自己写了一套心理学实验程序从最基础的视觉搜索任务做起慢慢搭出了能跑正式实验、能精确记录反应时、能直接产出CSV数据的完整流程。这篇内容围绕我实际动手的过程展开适合有心理学实验需求、又不想被商业平台束缚的研究者看也适合刚接触pygame、想理解它如何支撑实验逻辑的开发者参考。1. 为什么我最终选了Python加pygame这条路先交代一下背景。我当时要做的任务是经典的颜色视觉搜索屏幕上同时出现若干个彩色圆形被试需要判断目标色块是否存在并尽快按下左右键反应。听起来不难但真正的心理学实验对三个东西有硬性要求刺激呈现时间可控、反应时记录要精确到毫秒、试次顺序和数据导出要严谨。商业平台能做到但可定制性差从零写图形界面又容易陷进底层细节。pygame刚好卡在中间它封装了窗口、绘图、事件处理同时保留了对时间戳、刷新率和视频模式的直接控制。我对比过几条路线。基于矩阵计算软件的那套实验工具包功能确实强尤其兼容很多老式刺激呈现设备但前提是实验室有对应软件授权而且写复杂逻辑时语法风格跟通用编程差异较大。另一类是网页端实验框架被试机只要开浏览器就行部署方便但精确控制显示器刷新和键盘事件在浏览器里总会隔一层偶发掉帧时很难定位是网络还是渲染问题。pygame的优势是纯本地运行没有浏览器那一层开销画圆形、画矩形、贴图片都是GPU加速代码逻辑也跟普通Python一致组里学生上手很快。安装和初始化也很直接。我建议用虚拟环境管理项目依赖避免不同实验程序之间互相污染。核心依赖其实只有一个pygame录制到requirements.txt里即可。初始化的固定动作如下import pygame import sys import csv import random pygame.init() screen pygame.display.set_mode((1024, 768)) pygame.display.set_caption(Visual Search Experiment) clock pygame.time.Clock()这里有一个常被忽略的细节pygame.display.set_mode()默认会自动初始化显示驱动如果机器有多个显示器需要先查清哪个屏幕是实验屏避免刺激出现在错误的显示设备上。写实验程序跟写游戏不同实验屏幕、被试按键映射、房间灯光都要在程序里作为实验条件固定下来不能依赖被试手动切换窗口。所以我后面把显示器序号、背景亮度、刺激颜色这些全部抽成了配置项每次实验开始前自动读配置。选择pygame还有一个不那么显眼的理由它的日志和调试成本低。实验一旦跑起来你不能打断被试去打印变量所以所有调试信息在正式实验阶段必须关闭。但开发阶段可以任意print还能把每一帧的逻辑状态写进临时文本。这一点帮我排查了很多逻辑错误比如刺激坐标越界、按键事件重复触发等。换成商业平台想看到内部状态往往得靠它自带的调试器远不如直接看Python输出来得痛快。2. 从需求到可运行框架一个视觉搜索任务的功能拆解写代码之前先把实验需求列成可执行的参数清单。我的视觉搜索任务分成练习和正式两阶段每个试次流程是注视点800毫秒空屏200毫秒刺激屏最常3000毫秒或被试按键后消失然后进入下一个间隔1000毫秒。刺激屏上会出现6个圆形其中一部分是目标色数量随机另一部分是干扰色数量也随机。被试需要报告“有没有看到目标色”。因为目标是颜色所以要特别注意色彩之间的区分度我用的是红和绿这对互补色亮度差值也调过避免某些显示器上因为色域窄导致难以区分。下面这段代码是刺激屏的核心逻辑我简化了无关的浮夸部分保留主干def draw_stimulus(screen, target_color, distractor_color, target_present): positions [] while len(positions) 6: x random.randint(80, 944) y random.randint(80, 688) pos (x, y) if all((pos[0] - p[0]) ** 2 (pos[1] - p[1]) ** 2 14400 for p in positions): positions.append(pos) for i, pos in enumerate(positions): if target_present and i 0: pygame.draw.circle(screen, target_color, pos, 24) else: pygame.draw.circle(screen, distractor_color, pos, 24)这个间距检查很重要。如果不检查随机点之间的距离两个刺激可能重叠或贴在一起被试就会把两个紧挨着的圆形看成一个图形反应模式就不一样了。心理学实验要求同一条件下所有试次的视觉特征尽可能一致所以我会固定圆形直径48像素最小圆心间距≥120像素这个数值经过预实验确认既能保证分布满屏又不会产生重叠。整个实验的主循环长这样for trial in range(total_trials): condition random.choice([present, absent]) draw_fixation(screen) pygame.display.flip() pygame.time.wait(800) draw_blank(screen) pygame.display.flip() pygame.time.wait(200) draw_stimulus(screen, RED, GREEN, condition present) onset_time pygame.time.get_ticks() pygame.display.flip() response wait_response(onset_time) save_result(trial, condition, response.reaction_time, response.correct) draw_blank(screen) pygame.display.flip() pygame.time.wait(1000)为什么要用pygame.time.wait而不是pygame.time.delay这是我从实际测试里总结的差异wait会把CPU控制权交还给系统减少忙等待时的发热和输入延迟抖动delay则是忙等计时更准但在部分Windows机器上会导致事件队列响应变慢。对于实验程序我更倾向于先用wait如果预实验发现时间精度不达标再切换到delay并逐试次记录时间戳。很多人第一次写实验程序时会忽略“空屏”的存在。空屏不是为了好看而是为了消除上一个试次的视觉后像避免被试靠刺激残影判断内容。空屏的背景色必须和刺激屏背景完全一致在此之上不要画任何元素。我见过有程序在空屏阶段画了一个像素级的白点结果被试报告说能隐约看到屏幕上有个亮斑白白浪费了整批数据。这个坑我踩过一次后来用全屏纯色填充并强制刷新两次解决。3. 计时与响应心理学实验最不能妥协的地方如果只能挑一个心理学实验程序的重中之重我会选时间精度。pygame本身并不是实时系统它跑在标准操作系统上线程调度、显卡驱动、后台进程都可能导致定时偏差。但我实际测试下来的结论是对于绝大多数按键反应时和固定刺激时长的实验pygame的精度完全够用前提是你知道误差出在哪里。先说固定刺激时长的呈现。我早期写法是画完刺激后立刻pygame.time.wait(500)然后清屏。后来发现一个隐患如果绘制操作本身耗时超过几毫秒wait(500)结束后清屏的那一瞬间实际呈现时长可能是500加上绘制耗时。也就是说每个试次之间会有一个系统性的延迟漂移。解决方法是把“绘制完成”当作呈现起点把“清屏前的最后时间戳”当作呈现终点两者相减就是真实呈现时长跑完实验后可以统计这一段时长的均值和标准差。我现在的程序里会专门输出一个日志文件记录每个试次刺激出现的精确时间点。反应时记录方面我采用的方法是在pygame.display.flip()刷出刺激屏之后立刻记录onset_time pygame.time.get_ticks()然后进入事件等待循环。一旦检测到被试按键就取当时的event.time或pygame.time.get_ticks()作为响应时间。这里有个关键区别event.time是pygame事件进入队列时系统打的时间戳比get_ticks()更接近物理按键发生的时刻。我建议优先用event.time因为它不包含事件从键盘驱动传到Python的额外开销。def wait_response(onset_time): while True: for event in pygame.event.get(): if event.type pygame.KEYDOWN: if event.key pygame.K_LEFT: rt event.time - onset_time return {key: left, rt: rt} if event.key pygame.K_RIGHT: rt event.time - onset_time return {key: right, rt: rt}按键事件去重也是个隐藏问题。pygame在键盘按住不放时会根据系统设置产生重复KEYDOWN事件如果被试按键时间长一点程序可能把第二次重复事件误判为新的反应。实验程序和游戏不同游戏巴不得长按触发多次实验程序必须只记录第一次有效按键。解决方法是开局时调用pygame.key.set_repeat(0, 0)彻底关闭按键重复这是任何正经实验程序的必修课。帧率设置也值得聊一下。很多人以为clock.tick(60)能保证刷新率60帧这是个误解。它只是让主循环的帧率上限为60显卡实际刷新率是显示器决定的。心理学实验应尽量把刺激呈现和刷新周期对齐比如显示器是60Hz那么刺激呈现时间最好控制在16.7毫秒的整数倍附近否则你会看到部分试次中刺激少刷了一帧或多刷了一帧带来额外的时间抖动。我的经验是在正式实验前写一段计时测试程序连续记录500次flip()间隔算标准差。如果标准差超过2毫秒优先检查显卡驱动、关闭垂直同步干扰项、退出无关后台进程再不行就换一台实验机。关于反应时数据的质量我认为应该保留试次级别的原始日志而不是等全部实验跑完再汇总。这样你可以事后回看具体某个试次是否出现异常。比如被试在刺激出现前就按了键这在原始按键日志里会表现为负反应时如果只看最终统计表这种数据会被平均掩盖。我现在每次实验都会同时输出两个文件一个是被试可读的结果表格一个是按时间戳记录的完整事件日志后者本身也是检查程序是否有问题的第一手证据。4. 模块化结构和数据记录不把实验写成一坨脚本实验程序写几周后会变得复杂如果一开始不拆分模块后期加一个练习阶段都可能会牵连到数据存储逻辑。我把整个项目按功能拆成四个模块这个拆分方式从一开始就固定下来后面加新范式时也能直接复用。模块职责关键文件config保存所有实验参数config.pyvisual画注视点、画刺激、画空屏visual.pyruntime主循环、试次调度、练习/正式切换runtime.pydatalog记录结果、写CSV、写事件日志datalog.pyconfig模块里的参数全部大写例如TRIAL_NUM_PER_BLOCK、FIXATION_DURATION、MAX_RESPONSE_TIME。这样做不是为了炫技而是让每个参数都像合同条款一样明确改一个变量就能调整整个实验的节奏。主试者不需要理解代码逻辑只要改config文件就能调实验参数这对心理学实验室非常友好。data模块我采用“先建字典再写入CSV”的方式。每个试次的结果先存成一个字典数值全部在试次结束时即时计算最后统一写入文件。这样做的好处是如果你试次中途崩溃已经完成试次的数据还在内存里只要加一段异常处理就能保存到临时文件。我的异常处理会在KeyboardInterrupt或普通异常发生时把当前已收集的数据强制落盘避免被试坐到一半出现意外导致全部数据丢失。def save_trial(writer, row): writer.writerow({ participant: participant_id, block: block_index, trial: trial_index, condition: condition, target_present: present if condition present else absent, response_key: response.get(key, none), reaction_time: response.get(rt, -1), correct: 1 if correct else 0, onset_timestamp: onset_time, })字段命名我坚持用带下划线的英文便于后续统计分析同时保证每个字段含义单一。比如reaction_time就是纯粹的按键相对刺激出现的时间差单位毫秒不掺杂其他信息correct只能是0或1不存“对/错”这样的文本。数据清洗时最怕含糊字段宁可多写几行代码也要让原始数据的每个值都可解释。练习阶段和正式阶段的数据一定要分开。练习阶段的数据不能进入最后统计但也不能完全丢弃因为可以通过练习数据看出被试是否理解任务。我通常会记录练习阶段的正确率和平均反应时但CSV文件名里用practice和formal区分并且每名被试的文件夹单独建文件夹名用被试编号日期时间这样即使同一天跑多个人也不会混淆。5. 实际落地过程中的踩坑记录颜色、闪烁和后台干扰进入正式实验后我遇到的第一类问题都跟视觉呈现质量有关。心理学被试不是忠实观众他们会在实验结束后反馈“屏幕闪了一下”“某个圆看起来颜色不对”“好像有个残影”。这些反馈往往意味着刺激呈现没有达到预设状态。最典型的是颜色偏差。pygame里指定颜色是(R, G, B)的8位色值但同一组RGB值在不同显示器上看起来完全不同。我在预实验阶段用普通笔记本跑程序红色圆和绿色圆的区分度很好换到实验室的显示器后同一组颜色明显偏暗被试说绿色和红色看起来像棕褐色。这是因为显示器的色域、亮度曲线和对照明环境的适应度不同。心理学实验常用的做法是固定显示设备、固定背景亮度、固定环境光照并且用光度计标定屏幕的亮度输出。如果没有光度计至少要固定一台实验机不要频繁更换显示设备。闪烁问题的排查链路值得一提。我最初在刺激屏上只绘制圆形背景直接用之前的灰色填充结果试次切换时被试报告有轻微闪烁。后来我查了事件日志发现闪烁来自两个地方一是绘制圆形时用了抗锯齿抗锯齿边缘会引入额外的灰度过渡在纯灰背景上特别显眼二是刺激屏出现前上一帧的空白画面没有完全清理干净残留了几个像素的图像。最终我把刺激绘制统一到一张后台Surface上每轮试次开始前重新填充全屏背景再一次性绘制刺激最后只调用一次flip()整体呈现。后台进程对时间精度的干扰也让我吃了亏。某次正式实验跑到一半被试反应时普遍比预实验慢了几十毫秒。我查了被试状态和按键设备都没问题。后来打开事件日志看到刺激出现时间戳和按键时间戳之间有不规律的延迟抖动检查之后发现那台实验机后台挂着一个自动更新服务每隔几分钟就占用一次CPU和磁盘。从那以后我每次正式实验前会在实验机上执行一个“实验环境检测脚本”检查是否有高占用进程并关闭自动更新、屏保、休眠和网络同步服务。这套检测脚本虽然简单但比事后处理异常数据省力得多。键盘输入的坑也不止按键重复一个。USB键盘的扫描间隔通常约1到10毫秒不等特别便宜的键盘在快速连续按键时可能丢事件。如果被试需要在一个试次内快速按多个键建议用游戏级键盘或直接让被试使用两根手指同时准备两个按键。另外跨界键盘布局在不同语言系统下可能把某些键识别成不同的keycode我在一个团队里遇到被试机器键盘布局不同导致熟悉的按键失效所以后来整个实验室统一使用同一型号键盘并且在实验开始前有一段按键测试屏幕让被试按几下确认程序能正确识别。时间戳的归档也是容易忽视的坑。pygame的get_ticks()和event.time返回的是相对于pygame初始化时刻的毫秒数并不是系统时间。如果你需要把反应时数据和脑电或眼动数据对齐就必须把pygame的时间戳转换成系统绝对时间并在刺激出现瞬间打一个同步标记。我在事件日志里会同时记录ticks和walltime两列walltime来自time.time()精度足够用来后期对齐。这个做法在多模态数据采集时特别重要否则等你采集完再想对齐可能已经对不上了。6. 项目复盘这套框架还能往哪些方向扩展视觉搜索任务跑通之后整个逻辑骨架直接迁移到了另一个情绪面孔识别实验。那个实验需要显示图片而不是圆形刺激屏从draw_circle换成pygame.image.load而后blit到Surface其他试次调度、按键逻辑、数据存储几乎原封不动。这让我意识到写实验程序时真正有复用价值的不是某一个画图函数而是试次状态机、时间戳记录和错误容忍机制。图片刺激有一点和图形刺激不同需要特别小心图片文件自身的解码耗时。如果每一轮试次都现场加载一张大图加载时间会造成刺激延迟解决方法是在实验准备阶段把所有图片一次性加载进内存每个试次只做内存到显存的拷贝避免磁盘I/O带来的时间抖动。我的做法是建立一个字典键是图片名称值是已经pygame.image.load并转换为适合屏幕格式的Surface对象正式实验过程中只查询字典不再触碰文件系统。如果我下一步要做停止信号任务或者Go/No-Go范式我大概率会复用现有的wait_response和区块组织逻辑只需要替换刺激类型和按键规则。pygame的灵活之处在于它不是专为实验设计的反而没有过多限制你改变任务逻辑。比如做停止信号任务时需要在刺激出现后某个随机延迟点突然改变屏幕内容这时只要在wait_response循环里额外检测一个pygame.time.get_ticks()是否到达计划中的停止信号时刻即可不需要重写主循环。这个扩展成本比商业平台低很多。另外如果你需要同步采集问卷数据可以考虑在pygame窗口内直接做量表页面或者实验结束后单独跑一个表单程序。我的建议是主实验和问卷分离因为问卷涉及键盘输入和多行文本处理混在刺激实验里会让主循环变得复杂。实验程序只专注于刺激与反应问卷程序单独负责数据收集最后靠被试编号关联数据文件这样分工清晰也方便其他成员维护。还有一个我后来补上的设置实验程序在开始前自动检查横竖屏和分辨率。现在的实验室显示器越来越多样如果刺激屏分辨率跟程序配置不一致圆形的位置和大小就会整体偏移。我写的检测函数会比对pygame.display.Info()和config中的分辨率不一致时直接拒绝继续运行并提示修改配置而不是自动缩放。自动缩放听起来方便但会让刺激的物理大小在不同设备上不可比违背了心理学实验控制视觉角度的基本原则。最后说一句关于时间精度的体会。很多人一听到pygame就默认它不够严谨但我实测下来做反应时任务、注意任务、简单学习任务只要固定显示器、关闭垂直同步干扰、禁用按键重复、保留原始事件时间戳pygame的记录精度完全能胜任大多数行为实验的需求。真正需要更硬实时方案的是对呈现时间要求到单帧级别且不可容忍任何抖动的场景比如超快速刺激掩蔽范式。遇到那种需求我不会硬用pygame而会考虑结合硬件同步方案。但能在Python生态里把常规实验做到这个程度已经帮我们节省了大量时间和授权成本。感兴趣的朋友可以从一个简单的Go/No-Go任务开始练手把config、visual、runtime、datalog这四个模块跑通一次基本就掌握了这套方式的核心。