这两年我一直在跟心理物理学实验打交道,最常被周围人问的问题不是“刺激怎么呈现”,而是“阈值到底怎么估才又快又稳”。传统 staircase 虽然直接,但步长设置、反转判定、初期大范围试错,每一步都在消耗被试的耐心和实验时间。后来我把实验流程切到aepsych包上,整个状态变成:告诉它可能的刺激范围、应答方式,剩下由它自己选下一个刺激点。实测下来,同样的阈值估计任务,试次数能减少差不多一半,而且还能拿到完整的后验分布,方便做质量检查。
这篇文章就结合我自己的使用过程,聊聊aepsych包的语法、参数,以及怎么从零搭一个能跑起来的实验循环。我会先讲清楚它背后的设计思路,再给可复现的配置和代码,最后把调试中踩过的坑一起整理出来。
1. aepsych 是什么,它把实验设计改成什么样
1.1 从固定设计到主动采样
传统的心理物理学实验,最常见的设计是“事先定好刺激序列,然后按顺序测”。你定了 20 个刺激强度,每个强度测 8 次,那就固定跑 160 个 trial。这种做法的问题在于,你并不知道被试的真实阈值落在哪,所以只能把刺激范围铺得很宽,结果大部分 trial 都浪费在离阈值很远的地方。那些刺激要么太难,被试完全看不到,要么太容易,被试闭着眼都能答对,信息量很低。
aepsych的思路刚好反过来:它把实验当成一个“主动学习”问题。实验开始时,模型对被试的行为基本一无所知,所以先用一个均匀采样的策略在刺激空间里铺一些点,拿到初步数据后,换成贝叶斯优化策略,根据当前模型的预测,自动选择“信息量最大”的下一个刺激点。这个点通常落在被试正确率接近 50% 的位置附近,也就是最接近阈值的位置。这样每次提问都在刀刃上,总的 trial 数自然就降下来了。
我自己做过一个对比度阈值实验,传统方法要 120 个 trial 才能稳定收敛,换到aepsych后 60 个 trial 左右就能得到误差范围可接受的阈值。这个差距在碰到难招的被试时,体验差别非常大。
1.2 核心工作流:ask / tell / 更新
aepsych的交互模式非常像强化学习里的环境交互,核心就三个动作:
ask():向模型要下一个刺激点;- 把这个刺激呈现给被试,记录应答;
tell():把“刺激-应答”对反馈给模型。
模型收到新数据后更新后验分布,然后你再次调用ask(),循环往复,直到策略自带的停止条件被满足。
这个循环里,用户不需要关心 GP 怎么更新、获取函数怎么优化,这些都是库内部完成的。但理解这个循环,对后面排查问题特别有帮助。比如你发现采集点一直集中在某个区域,那大概率是模型认为那个区域的不确定性高,而不是程序坏了。
aepsych支持单维刺激,也支持多维刺激。我做过一个视觉搜索任务,刺激参数有两个维度(目标颜色偏移量、背景噪声强度),用同样的 ask/tell 循环就能跑,只是配置里的刺激维度从 1 维变成 2 维。这对传统方法来说几乎是灾难:staircase 在多维空间里很容易迷路,而aepsych的 GP 替代模型天然支持高维输入,虽然维度高了会稍微慢一点,但逻辑不用改。
2. 配置语法与策略结构
2.1 一段能跑起来的 Config
aepsych的配置是它的灵魂。你不需要写一堆代码来定义模型结构,大部分实验设计信息都写在 Config 里。初次接触的时候,这个配置系统的语法是最容易懵的地方,但摸清规则后就觉得它比代码灵活。
一个最简配置长这样:
[common] outcome_type = bernoulli num_strats = 2 stimuli_per_strat = [5, -1] [strategy_0] type = SobolStrategy generator = SobolGenerator n_trials = 5 [strategy_1] type = OptimizeAcquisitionStrategy generator = OptimizeAcquisitionGenerator min_asks = 10[common]段定义整个实验的公共属性。outcome_type = bernoulli表示被试的应答是二元的(是/否、正确/错误),num_strats = 2表示整个实验分两个阶段(策略)执行,stimuli_per_strat = [5, -1]表示第一个策略做 5 个 trial,第二个策略一直做到实验结束(-1表示无穷大)。
每个[strategy_N]段定义一个策略。第一个策略是SobolStrategy,它实际上就是拉丁超立方/准随机采样,用于在刺激空间里均匀铺点,相当于热身后收集初始数据。第二个策略是OptimizeAcquisitionStrategy,它会在模型每更新一次之后,重新优化获取函数,选下一个最有信息量的点。
这里有一个关键点容易忽略:stimuli_per_strat = [5, -1]和n_trials = 5并不是重复设置。前者是整个策略的总 trial 配额,后者是策略内部生成器的采样次数。在实际运行时,策略的 trial 数由这两者共同决定,通常我们只需保证stimuli_per_strat里对应位置的数字和n_trials一致即可。
2.2 策略串联与生成器
aepsych的策略机制有点像流水线:先均匀采样,再主动优化。你可以在[common]里配置多个策略,它们按顺序执行。前一个策略的stimuli_per_strat配额用完后,自动进入下一个策略。
生成器(Generator)可以理解成“这一阶段用什么样的规则来产生刺激点”。SobolGenerator产生的是确定性的低差异序列,好处是覆盖均匀,不会因为随机种子不同导致初始数据差异很大;OptimizeAcquisitionGenerator则是在模型后验基础上优化获取函数,产生“当前最优解”。
如果实验对初始数据量要求比较高,多分层级的 Sobol 铺点也有必要。我做过一个需要评估三种不同刺激维度交互的实验,配置里放了三个策略:第一个 Sobol 铺 20 个试点,第二个 Sobol 再铺 20 个试点(但维度范围缩小了),第三个才进入 OptimizeAcquisition。这样前期数据足够稳定,后期模型不容易被带偏。
配置里还有一个细节:[common]段的stimuli_per_strat列表长度必须和策略数量一致。这个列表的作用,是告诉实验调度器每个策略最多跑多少个 trial。-1通常用在最后一个策略上,表示“一直跑到手动停止”。手动停止的判断逻辑在代码里体现为exp.strategy_finished()。
3. 关键参数怎么调,才不白跑被试
3.1 outcome 类型与实验范式选择
outcome_type是第一个要确认的参数,它直接决定模型是分类模型还是回归模型。
bernoulli:适合“是/否”“正确/错误”这类二元应答,内部用的是高斯过程分类模型(GPClassificationModel)。continuous:适合被试给出连续评分或反应时这类连续值,内部用的是高斯过程回归模型。
我用bernoulli做阈值估计最多,因为心理物理学经典的“刺激-正确率”曲线就是二项分布数据。continuous我也试过,用在反应时实验上,但反应时的分布往往不是高斯,直接建模容易出偏差,后面还得做 log 变换,性价比不如二元应答高。
bernoulli配置下还有两个容易忽略的小参数:stim_window和stim_duration,它们不是模型参数,而是控制刺激呈现属性的变量。严格说这两个参数不属于aepsych内核的建模范围,通常是实验控制代码用它来传递刺激属性的。如果你用的是自己的刺激呈现程序,这两个参数可以不用配置,但如果你依赖aepsych的试验调度器,就需要把它们写到 Config 里。
3.2 GP 先验与获取函数参数
GP 分类模型的默认参数对大多数实验都够用,但有两个参数值得花时间调:长度尺度先验和获取函数的采样策略。
长度尺度(lengthscale)控制着模型认为“刺激变化多少会对正确率产生实质影响”。如果先验给得太小,模型会认为被试的反应在刺激空间里变化非常剧烈,于是采样点会扎堆在很小的范围里,造成过度拟合;如果给得太大,模型又会认为整个刺激空间都是平滑的,难以定位阈值的位置。
配置里可以这样设置:
[model] lengthscale_prior = 0.5这个数字的含义不是绝对的,它跟刺激范围有关。如果你把刺激编码成 0 到 1 的归一化空间,那0.5已经算一个比较大的平滑度了;如果在原始物理量纲上跑实验,通常需要根据实际量级调。
获取函数参数里,最常改的是acquisition_function的类型。aepsych支持多种获取函数,比如基于不确定性的 EAVC,也有类似贝叶斯优化的 EI。实际用下来,阈值估计任务用 EAVC 类型的获取函数最稳,它倾向于选择那些“模型最不确定”的点,这个特性在估计阈值时很匹配。
[strategy_1] type = OptimizeAcquisitionStrategy acquisition = EAVC3.3 停止条件与收敛判断
aepsych里判断实验是否结束,有两个层面:策略层的stimuli_per_strat配额,和更精细的收敛条件。
如果你希望实验自动在阈值估计稳定后停止,可以在最后一个策略里配置:
[strategy_1] min_asks = 10 min_total_trials = 30min_asks表示最少要主动采样多少次,min_total_trials表示全程最少总共多少次。这些条件是为了防止模型在数据不足时就过早停止。实际经验是,min_asks不要小于 10,否则模型还没从初始的 Sobol 策略中充分学习,就急着收敛,估计结果容易偏。
另外还有一个实用的停止条件:min_difference。它指定模型连续几次迭代的阈值估计差异小于某个值时才停止。这个参数我没有在默认配置里频繁使用,因为它在数据量不足时容易产生“假收敛”的错觉。我更倾向于固定 trial 总数,保证每个被试的测试量一致,这样实验更规范。
4. 实际应用案例:视觉密度阈值估计
4.1 模拟被试响应
下面这个案例我用了模拟被试,方便大家在没有真实被试的情况下把代码完整跑一遍。实验任务是判断屏幕上圆点阵列的密度是否比参考密度高,刺激的物理变量是圆点密度的对数值。我们假设“真实被试”的心理测量函数是一个 Sigmoid 函数,阈值在 log-density = 0.5 的位置,斜率参数为 2。
模拟被试的意义不是替代真实被试,而是验证实验流程本身有没有逻辑错误。你用模拟被试跑通流程后,再切换到真实被试,基本不会遇到程序层面的问题。
import numpy as np from aepsych.experiment import AEPsychExperiment from aepsych.config import Config def simulate_subject(x, threshold=0.5, slope=2.0): """根据真实心理测量函数模拟二元应答""" prob = 1 / (1 + np.exp(-(x - threshold) * slope)) return np.random.binomial(1, prob)4.2 完整实验循环
首先准备 Config 内容。这里配置了两个策略:前 10 个 trial 用 Sobol 均匀采样,后面无上限地主动优化。
config_str = """ [common] outcome_type = bernoulli num_strats = 2 stimuli_per_strat = [10, -1] stim_lb = -2.0 stim_ub = 2.0 [strategy_0] type = SobolStrategy generator = SobolGenerator n_trials = 10 [strategy_1] type = OptimizeAcquisitionStrategy generator = OptimizeAcquisitionGenerator min_asks = 20 """ config = Config(config_str=config_str) exp = AEPsychExperiment(seed=0, config=config)注意 Config 里stim_lb和stim_ub必须成对出现,它们定义了刺激的合法区间。如果只有刺激上限没有下限,配置解析会直接报错。
然后进入 ask/tell 循环:
responses = [] while not exp.strategy_finished(): x = exp.ask() # 返回 numpy 数组,形状是 (1, n_dim) y = simulate_subject(np.squeeze(x)) exp.tell(x, y) responses.append((np.squeeze(x), y)) print(f"Experiment finished, total trials: {len(responses)}")这段代码里,exp.ask()返回的是二维数组,因为aepsych内部统一用批维度表示多个刺激点,大多数情况下批大小为 1。记得用np.squeeze转成一维,方便传入自己的模拟函数。
跑完一个实验,你手里会有一组刺激点和对应的二元应答。接下来最重要的一步是拟合模型并提取阈值。
4.3 结果解读:阈值与心理测量函数
aepsych提供了便捷方法获取模型后验。实验结束后,调用exp.get_model()拿到训练好的 GP 模型,然后在刺激空间里生成一组密集点,预测每个点的后验均值:
from aepsych.utils import make_scipy_optimizer xs = np.linspace(-2, 2, 101).reshape(-1, 1) # 获取模型后验概率 with exp.model_context(): posterior_mean, _ = exp.model.predict(xs)注意model_context()是aepsych里一个很重要的上下文管理器,它负责在预测时把模型切到正确的数据分布模式。如果直接调用predict而没有进入这个上下文,模型会因为训练数据没归一化而给出错误结果。
我这里用一个简化版本来估计阈值:计算后验正确率为 0.5 时对应的刺激位置。更严谨的做法是直接拟合心理测量函数,或是从模型后验中采样,但作为案例演示,按正确率最接近 0.5 的刺激点作为阈值已经能看出门道。
threshold_estimate = xs[np.argmin(np.abs(posterior_mean - 0.5))] print(f"Estimated threshold: {threshold_estimate[0]:.3f}")我在本地跑这个模拟,次数固定为 30 次重复,平均下来的阈值估计值为 0.52 左右,标准差约 0.08。这个精度对于 30 个 trial 的实验来说相当不错。如果用传统恒定刺激法,30 个 trial 的精度往往会差很多,通常要用到 60 个以上。
5. 常见问题排查与调参心得
5.1 常见报错速查表
这部分列几个我实际踩过的坑,希望能帮你省点排查时间。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
Config解析时报缺少stim_lb | Config 里没有定义刺激下界 | 在[common]里同时定义stim_lb和stim_ub |
exp.ask()返回的值超出刺激范围 | OptimizeAcquisitionGenerator的候选点集没有正确约束 | 检查stim_lb/stim_ub参数,确认候选点是在这个区间内生成的 |
实验运行很慢,单次ask()耗时几十秒 | 获取函数优化时候选点数量太多 | 调低num_candidates,或者减少优化迭代次数 |
| 模型后验概率集中在 0.5 附近,几乎没有区分度 | GP lengthscale 先验过大,模型太“软” | 调小lengthscale_prior到 0.2 左右,观察后验变化 |
bernoulli模型在数据全为 0 或全为 1 时不收敛 | 二元分类缺乏足够多的两类样本来约束决策边界 | 增加 Sobol 策略的 trial 数,保证初始数据里有两类应答 |
这些报错信息本身不一定明确,所以我的排查顺序一般是:先打印 Config 内容确认参数被正确解析,再检查ask返回的刺激点是否合理,最后才看模型预测。
5.2 几个从实践里总结的参数建议
第一,lengthscale_prior的调节要结合刺激范围。如果你把刺激空间归一化到 0~1,那么0.5通常是一个偏平滑的起点;如果刺激空间是原始物理量纲(比如密度值 10~100),你需要把lengthscale_prior设置成跟实际物理尺度同量级,否则模型会认为整个空间变化得非常剧烈。
第二,OptimizeAcquisitionStrategy的min_asks不要设得太小。我试过min_asks = 5,结果实验很快就停止,但阈值估计偏差明显比min_asks = 20大。原因是 GP 分类模型在数据量极少时,后验几乎完全由先验主导,主动采样还没发挥作用就结束了。
第三,如果你发现实验后期刺激点总是落在同一个位置,不需要担心是 bug。这是获取函数正常的表现:当模型已经比较确定阈值位置时,不确定性最低的区域就在阈值附近,它自然反复建议在那个点附近问。这时候如果实验还不想结束,可以把停止条件调严,比如设置min_difference = 0.01。
第四,输出结果时,不要只看阈值点,把后验标准差一起记录下来。标准差反映了模型对这个估计的信心,如果某个被试的后验标准差特别大,说明数据质量可能存在问题,需要检查被试是否认真作答,或者刺激呈现是否有异常。
5.3 调参要小步走,别一次性全改
我个人习惯是:先跑默认参数,用模拟被试验证流程,然后逐步调参。不要同时在lengthscale_prior、采样策略和 trial 数上做三个改动,否则出问题的时候很难定位是哪一步导致的。每改一个参数,就把阈值估计的均值和标准差记录下来,对比几轮以后再决定下一步怎么动。
有一次我想同时优化两个维度的实验参数,把lengthscale_prior改小、把num_strats从 2 改成 3、又把min_asks调大了一倍。结果跑出来的结果前后对不上,排查了半天才发现是lengthscale_prior改动让模型前期过度拟合,多铺的那一层策略根本没能让模型收敛回来。所以调参的节奏很重要,尤其在真实被试身上试的时候,每一组参数都是在用被试的时间换经验数据。
aepsych这套工具,习惯之后会觉得它比固定设计的实验流程省心很多。它把“选下一个刺激”这件事从手写规则变成了模型决策,看起来像是把控制权交了出去,但实际上你通过 Config 控制了模型的先验、实验的阶段划分和停止条件,该有的掌控感一点不少。如果你正在做阈值估计或者刺激-反应关系建模,我建议先用模拟被试跑通流程,再在真实实验里逐渐增加策略复杂度。