做心理物理学实验的同学,十有八九都被阈值测量折磨过。早些年我在课题组做视觉对比度阈值的实验,用的是恒定刺激法:提前定好7个刺激水平,每个水平重复50次,一个被试测下来要三四千次试次,被试累到后面按键都开始敷衍,数据质量也肉眼可见地往下掉。后来换成自适应阶梯法,效率确实上来了,但阶梯法有个天然短板——它只是沿着“对/错”来回折返,刺激维度一旦超过两个,基本无从下手。直到我把aepsych这套Python工具用进实验流程,才算是把阈值估计从“体力活”变成了“算法活”。aepsych是一个专门做自适应实验设计的开源Python工具包,核心思路是用高斯过程分类模型实时学习被试的响应规律,再由贝叶斯优化决定下一个刺激怎么给。它的典型场景包括视觉对比度阈值、听觉检测阈限、触觉分辨能力这类心理物理测量,凡是能用“给一个刺激、记录一个二选一反应”描述的实验,它都能帮你用明显更少的试次拿到同样可用的精度。文章适合谁读?适合已经会用一点Python、被传统阈值测量效率折磨过、又不想自己从零写贝叶斯优化代码的研究者。下面我会把aepsych的语法结构、关键参数和两份完整可改的配置案例拆开细讲,按着我的路线走,你至少能跑通自己的第一个自适应实验。1. 为什么是aepsych:阈值测量从“体力活”变成“算法活”1.1 传统方法的效率瓶颈先聊一个很多人心知肚明但不肯写进论文里的点:传统阈值测量的试次数,是被统计需要而不是被试需要决定的。恒定刺激法要在预估阈值附近铺开一组固定刺激水平,为了保证每个点的正确率估计误差够小,每个水平往往要重复几十上百次。换算下来,一条心理测量函数曲线背后常常是上千次一模一样的流程,被试过几天再测,练习效应和疲劳效应早就混进去了。阶梯法(staircase)虽然把试次数量压下来不少,但它本质上是“上一回合对就降难度、错就升难度”的一维游走,只能告诉你阈值大概在哪,很难告诉你斜率、误报率这些描述响应曲线形状的参数。更麻烦的是,当你想同时估计两个维度——比如对比度阈值和空间频率之间的关系——传统阶梯法要么分多个一维实验分别跑,要么做二维网格搜索,试次数量几乎是指数级膨胀。1.2 aepsych做的核心事情:用模型替代查表aepsych把问题重新拆成了三步:先给刺激,拿到二选一反应;用一个概率模型(默认是高斯过程分类器)去拟合“刺激强度→正确反应概率”的关系;再用一个采集函数(比如期望改进)算出下一个刺激放在哪里信息量最大。这三步循环往复,刺激位置不再依赖死板的规则,而是由模型实时判断哪里最不确定、哪里最有助于收敛到目标阈值。我最初读到这个设计时有点将信将疑,觉得“模型引导的探索”听起来很漂亮,实际跑起来会不会因为模型误判而乱试探?后来测了一轮对比度阈值实验,还真被打脸了——只要你先验给得不太离谱,前20个试次的探索看起来是“乱跳”,但后面很快就会集中到阈值附近,并且模型给出的阈值置信区间是传统方法很难给出的副产品。这个能力对正式实验前的功率分析特别有价值,你可以先跑一个小样本,用后验不确定度估计试次总量。2. 环境搭建与核心概念梳理:从安装到看懂它的配置流2.1 安装与依赖说明aepsych的安装没什么特殊的,走pip就行:pip install aepsych如果是离线环境或者想用某个开发版本的特性,可以从源码安装,但日常实验我建议直接用PyPI发行版。它会自动拉取GPyTorch、Botorch、PyTorch这些底层依赖,版本冲突是实际使用中最容易遇到的坑之一。我碰到过一个情况:项目里原本有旧版PyTorch,安装aepsych时被自动升级,结果同环境里另一个模型脚本崩了。后来我的习惯是单独为aepsych建一个虚拟环境,和主实验环境隔开,互不干扰。python -m venv aepsych_env source aepsych_env/bin/activate # Windows下是 aepsych_env\Scripts\activate pip install aepsych2.2 四个核心认知:Experiment、Outcome、Strategy、Generator我第一次打开aepsych文档时最大的障碍,是它的配置体系里术语和普通Python库不太一样。这里我用自己的话给几个关键概念做个翻译:Experiment(实验):整个测量任务的容器,定义刺激空间范围、结果类型、总试次数上限。你可以把它理解成实验方案书。Outcome(结果):被试反应的记录方式。绝大多数心理物理实验是“二元选择”,也就是对/错或者有/无,所以Outcome类型一般配binary。如果做连续评分或者置信度打分,也可以配成连续结果。Strategy(策略):某一段试次里“怎么选刺激、怎么更新模型”的成套方案。一个实验可以分多个阶段,每个阶段用不同策略。比如前10个试次用纯随机探索(Sobol序列),后面切换到模型驱动的优化采集。Generator(生成器):策略内部真正产出刺激坐标的组件,它决定了下一组刺激参数是什么。常见的有SobolGenerator(均匀探索)和OptimizeAcquisitionGenerator(用采集函数优化刺激位置)。这套拆分初看绕,但好处是复用性非常强。换一个实验时,我只需要改刺激空间范围和结果定义,生成器、模型、采集逻辑都能原样搬走。2.3 配置文件的整体骨架:用INI组织实验方案aepsych里最常用的是基于INI风格配置文件的驱动方式。文件里把所有参数集中管理,实验逻辑和参数面分开,这样记录可复现性特别舒服。一个最简配置的长相大致如下:[Experiment] name = contrast_threshold_demo stimuli_per_trial = 1 outcome_type = binary [Experiment.Outcome] type = binary [Experiment.Strategy.0] name = exploration class = from_config generator = SobolGenerator generator.num_stimuli = 12 model = GPClassificationModel [Experiment.Strategy.1] name = optimization class = from_config generator = OptimizeAcquisitionGenerator model = GPClassificationModel这里的[Experiment.Strategy.0]和[Experiment.Strategy.1]定义了先后两个阶段:先用12次随机探索打底,让高斯过程模型有个粗糙的全局认识,再切换成优化采集。为什么这样分段?因为高斯过程分类器如果一上来就去“优化”,很容易被早期几个偶然的正确/错误反应带偏,先均匀撒点能有效降低这种风险。3. 核心语法与参数逐个拆解:一份INI文件能做的事3.1 实验区段的参数细节[Experiment]区段里我最关注三个参数:stimuli_per_trial:每一回合给几个刺激。视觉实验通常1个试次给1个刺激,但有些听觉实验会同时给多个备选刺激让被试选,这个参数就会大于1。min_total_trials/max_total_trials:最少和最多试次总数。注意,aepsych并不强制你跑满上限,而是会在满足停止准则后提前结束。比如你觉得100试次是预算上限,可以设max_total_trials = 100,如果模型后验精度提前达标,实验就提前收工。stimulus_space:刺激空间的边界,语法是domain:[最小值, 最大值],支持一维也有多维写法。这里有个容易搞混的地方:它定义的是模型输入空间,不是刺激物理单位。比如你的刺激是百分比对比度,建模前可能要做log变换,那么空间里填的是变换后的数值范围。来看一个一维示例:[Experiment] name = contrast_threshold_demo stimuli_per_trial = 1 outcome_type = binary stimulus_space = domain: [-3.0, 0.0] min_total_trials = 30 max_total_trials = 80这里的[-3.0, 0.0]表示log对比度范围,换算成线性就是0.1%到100%对比度。用log尺度建模几乎是视觉心理物理实验的默认做法,因为人类对比度阈值在log尺度上更接近正态分布,高斯过程模型也更容易拟合。3.2 结果模型和判断准则的参数[Experiment.Outcome]区段里,type = binary意味着我们用一个伯努利似然来模拟被试的反应,默认模型是GPClassificationModel。真正值得花时间调的是输出转换函数里的概率参数。比如你关心的不是50%正确率,而是75%正确率对应的刺激强度,就需要在配置里指定目标概率。[Experiment.Outcome] type = binary probability = 0.75这个probability = 0.75直接决定了后面阈值提取的方向。高斯过程分类器拟合的是“刺激强度→正确概率”的响应函数,默认情况下它会取50%作为阈值参考点,心理物理实验里更常见的75%或79%就要在这里改。很多教程不会提醒的是:如果你做的是一组强制选择任务,还要考虑猜测率。二选一强制选择任务的猜测基线是50%,所以目标可能设在75%;如果是二项选择加“不确定”选项,基线会变,目标概率也要相应调整。连续结果的配置略有不同:[Experiment.Outcome] type = continuous连续结果模型用的是回归方式,拟合“刺激强度→反应量”的连续映射。它适用于反应时、评分、幅度估计这类实验,但阈值提取时要自己定义“反应量对应哪个刺激强度”的规则。3.3 生成器与采样策略参数生成器是控制实验效率的核心环节。随机探索阶段我用SobolGenerator,它生成的是低差异序列,比numpy.random的完全随机采样覆盖更均匀,二维以上刺激空间尤其明显。需要给出的是生成数量:[Experiment.Strategy.0] class = from_config name = init_sobol generator = SobolGenerator generator.num_stimuli = 12 model = GPClassificationModel优化阶段用OptimizeAcquisitionGenerator,它做的是在当前模型后验下,用采集函数搜索下一个信息量最大的刺激位置。常用采集函数包括期望改进(Expected Improvement)和概率改进(Probability of Improvement),aepsych里可以通过参数指定,不过多数场景默认的期望改进就够用。[Experiment.Strategy.1] class = from_config name = optimize_acq generator = OptimizeAcquisitionGenerator generator.acquisition_function = ExpectedImprovement model = GPClassificationModel有个经验是:如果被试表现特别稳定,优化阶段的试次数量可以设少一点;如果被试噪声大,比如疲劳或者注意力波动,就多给优化阶段一些试次。这类参数没有标准答案,我会在策略区段里额外关注min_total_trials和max_total_trials的差额,这个差额越大,提前停止的灵活度越高。3.4 模型先验的写法与调整逻辑高斯过程分类器里的先验参数,很多时候是决定收敛速度快慢的隐藏变量。aepsych允许在配置里覆盖模型的均值函数和协方差函数参数:[GPClassificationModel] mean_covar_prior = default lengthscale_prior = lognormal_prior我遇到的常见情况是默认先验对于“刺激空间范围过宽”的实验不够好用。比如刺激范围是[-3, 0],但真实阈值在[-0.5, -0.3]之间,模型初期会在很大一段空间里浪费用试次。解决办法是手动收紧长度尺度先验,让模型默认认为响应概率在刺激空间上的变化不能太陡峭也不能太平缓,并且把均值初始化在预估值附近。调整先验时注意别过头。我曾经把长度尺度的均值设得很小,模型以为响应概率变化极快,结果连续好几十个试次都往一个很小的邻域里钻,阈值方差看着很小,但实际偏差很大——这是典型的过度自信。先验的本意是提供计算便利,不该喧宾夺主。4. 应用案例一:视觉对比度阈值的单维自适应估计4.1 实验背景与任务设计我们课题组当时做的是一个对比度检测任务:屏幕上给一个光栅刺激,被试判断“看到了还是没看到”。刺激参数是光栅的对比度,我们想知道在75%正确率对应的对比度阈值是多少。按照老办法,这需要至少6到8个对比度水平、每个水平几十次重复,一个被试要跑将近千次。改用aepsych之后,方案变成这样:单维刺激空间,log对比度范围从-2.5到0(相当于0.3%到100%对比度),结果类型为二元选择,目标概率设为75%,每个被试预算60个试次。先随机探索12次,再用优化采集跑剩下的,最终从模型后验里提取阈值。4.2 完整配置与代码实现完整的配置文件如下:[Experiment] name = contrast_threshold_single stimuli_per_trial = 1 outcome_type = binary stimulus_space = domain: [-2.5, 0.0] min_total_trials = 40 max_total_trials = 60 [Experiment.Outcome] type = binary probability = 0.75 [Experiment.Strategy.0] name = init_sobol class = from_config generator = SobolGenerator generator.num_stimuli = 12 model = GPClassificationModel [Experiment.Strategy.1] name = optimize_acq class = from_config generator = OptimizeAcquisitionGenerator model = GPClassificationModel generator.acquisition_function = ExpectedImprovement跑实验的调度代码,用Python控制循环比较灵活:from aepsych.server import AEPsychServer server = AEPsychServer("contrast_threshold_single.ini") server.start_experiment() trial_count = 0 while trial_count < 60: # 让服务器决定下一个刺激 next_stimulus = server.ask() # 展示刺激给被试并收集反应,这里替换成你的实验软件接口 correct = run_psychopy_trial(next_stimulus) # 返回True或False # 把反应送回模型 server.tell(correct) # 如果模型认为收敛,可提前停止 if server.should_stop(): print("提前停止,实际试次:", server.trial_count) break trial_count += 1这里ask和tell是流程里最重要的两个动作:ask负责从当前策略里拿下一个刺激,tell负责把这次反应喂回去更新模型和策略状态。用实验软件呈现刺激的部分,不管是PsychoPy还是其他工具,只要把刺激参数转成自己软件的格式就行。4.3 运行结果与阈值提取实验跑完后,aepsych会给出一个训练好的高斯过程分类模型。提取阈值不能只看最后一两个试次,而是要从模型的后验里找“正确概率等于0.75”对应的刺激强度。做法是先沿着刺激空间生成密集的测试点,让模型预测每个点的正确概率,再通过插值找到目标概率的位置。import numpy as np from aepsych.factory import default config = "contrast_threshold_single.ini" experiment = default(config) # 加载配置生成实验对象 # 用模型预测整个刺激空间上的正确概率 test_x = np.linspace(-2.5, 0.0, 500).reshape(-1, 1) prob_pred, var_pred = experiment.model.predict(test_x) # 找到概率等于0.75的位置,即阈值 from scipy.interpolate import interp1d f = interp1d(prob_pred[:, 0], test_x[:, 0], bounds_error=False) threshold_log_contrast = f(0.75) print("阈值(log对比度):", threshold_log_contrast)实测下来,一组30个被试的数据里,大部分被试在40到50个试次以内就能给出稳定的阈值估计,和之后用恒定刺激法验证的结果误差在一个可接受范围内。这个流程不但省了将近一半的被试时间,还能拿到每个阈值点的后验分布,后续做被试间比较时可以直接用分布信息。5. 应用案例二:二维联合估计——对比度乘空间频率的阈值曲面5.1 扩展到二维刺激空间第二个案例是当时实验的进阶版:我们想同时看对比度阈值和空间频率的关系。传统做法是把空间频率分成4档,每一档单独跑一条一维对比度阶梯——4条线测完,被试已经烦躁到不行。aepsych处理这种多维联合估计的方式就干净很多:把刺激空间直接定义成二维,一次实验同时收集两个维度的信息,最后得到一张阈值曲面。二维空间带来的核心变化是:模型要拟合的不再是一条曲线,而是一个曲面;探索策略也更讲究,纯随机均匀撒点在高维空间里效率会明显下降。SobolGenerator在这种情况下比完全随机采样强很多,因为它能更好地覆盖二维网格。5.2 配置差异点与分析二维配置和一维配置的差别,主要集中在刺激空间的定义和结果模型里参数的数量:[Experiment] name = contrast_threshold_2d stimuli_per_trial = 1 outcome_type = binary stimulus_space = domain: [-2.5, 0.0], [-0.8, 0.8] [Experiment.Outcome] type = binary probability = 0.75 [Experiment.Strategy.0] name = init_sobol class = from_config generator = SobolGenerator generator.num_stimuli = 24 model = GPClassificationModel [Experiment.Strategy.1] name = optimize_acq class = from_config generator = OptimizeAcquisitionGenerator model = GPClassificationModel generator.acquisition_function = ExpectedImprovement注意stimulus_space现在有两个区间,分别对应log对比度和log空间频率。第二个参数空间的范围是[-0.8, 0.8],意味着空间频率从大约0.16到6.3周期/度。随机探索阶段的num_stimuli从12提到了24,原因是二维空间需要更多初始点才能让高斯过程形成像样的全局估计,如果还沿用12个点,优化阶段很容易被早期的局部分布拐跑。提取阈值时,模型不再输出一个点,而是输出一条等值线。二维刺激空间上所有“预测正确概率=0.75”的位置串起来,就是对比度和空间频率联合阈值曲线。5.3 可视化与解读模型训练完以后,最直观的检查方式是把预测的阈值等值线画出来。我会先画一个对比度×空间频率的网格热图,填上预测的概率,再把0.75等值线叠上去,看它是否平滑、是否符合预期的“吊床形”趋势。import matplotlib.pyplot as plt grid_x = np.linspace(-2.5, 0.0, 80) grid_y = np.linspace(-0.8, 0.8, 60) xx, yy = np.meshgrid(grid_x, grid_y) grid_pts = np.stack([xx.ravel(), yy.ravel()], axis=1) prob_grid = experiment.model.predict(grid_pts)[0].reshape(xx.shape) plt.contourf(xx, yy, prob_grid, levels=20, cmap="viridis") plt.contour(xx, yy, prob_grid, levels=[0.75], colors="red", linewidths=2) plt.colorbar(label="正确概率") plt.xlabel("log 对比度") plt.ylabel("log 空间频率") plt.title("二维联合阈值估计") plt.show()看图的重点有三个:等值线是否单调;靠近边界处是否出现不合理的振荡;0.25等值线是否和0.75大致平行。如果出现振荡,多半是随机探索点不足或者模型先验不当;如果0.75等值线在某个区域特别密集,说明模型对这个区域特别有把握,但相邻区域的信息可能不足,需要考虑增加试次或调整生成策略。这一轮二维实验跑下来,我们最后拿到了一条平滑的对比度-空间频率阈值曲线,总试次控制在100以内,换作传统做法至少需要4条一维阶梯外加额外重复,总试次大约翻两倍。多维场景是aepsych对比传统方法优势最大的地方,也最值得投入时间去研究。6. 实战中的常见坑与调参心得:从踩坑到顺手6.1 收敛过慢:先怀疑策略分段,而不是加试次我第一次把aepsych用在真实被试上时,遇到最典型的状况是:跑了五十多个试次,阈值估计还在两个极端值之间大幅摆动。我当时第一反应是加大max_total_trials,结果只是把问题拖长了。后来回头看配置才明白,问题出在初始探索阶段太少。模型在信息不足的情况下进入优化采集,采集函数会倾向于反复试探那些高不确定性的边界点,于是刺激参数在边界反复横跳。解决方案:把初始探索的num_stimuli从8提高到15到20,让模型先看到足够覆盖刺激空间的响应结果。如果实验的刺激空间范围特别大,甚至可以考虑把初始探索分成两段,每段12个点,第二段在第一段模型基础上生成,只是生成时用偏向探索的采集策略。6.2 先验过于自信:模型给出的置信区间会骗人高斯过程分类器的后验不确定性天然比实际误差要小,这一点在试次数不足时特别明显。有次我为了赶实验进度,把预算从60砍到35,模型在第30个试次时给出一个看起来很窄的阈值置信区间,我以为估计很准。后来用20次重复的恒定刺激法验证,发现真实阈值落在置信区间边缘。这个教训让我记住一件事:后验不确定性只是模型视角下的不确定性,不等于对真实值的误差保证。如果你需要正式的阈值估计报告,建议至少预留10%的冗余试次,或者跑完以后用独立方法做交叉验证。aepsych也提供了一些离线分析工具,可以把已有数据用不同模型重新拟合并对比,这是我做数据可靠性检查时常用的手段。6.3 按键反应延迟和错误编码:模型对脏数据很敏感高斯过程分类器假定每次反应是条件独立的,但被试很可能因为疲劳出现“偶尔走神”的随机错误,这类噪声在模型里会被当作信息。在一次实验中,一个被试连续多试次在阈值附近答错,模型立刻把阈值往低刺激方向推了一大截。传统方法里单个离群点还可以事后剔除,在自适应实验里,这个离群点已经真实影响了后续刺激的选择,很难事后补救。应对方法有两条:一是实验中途加入少量重复试次,把同一个刺激参数在几轮后再给一次,用来检测被试一致性;二是在配置里考察更适合的模型变体,比如对噪声水平更鲁棒的似然函数。坦白说,第二条路的代价是计算量增加,我目前更常用的是第一条路,既简单又能给被试状态打分。6.4 实验软件与aepsych的接口磨合aepsych本身不做刺激呈现,它只负责“算”刺激参数和“收”反应结果,所以和PsychoPy这类工具对接时,要注意单位换算。我们在案例里用的是log对比度,但呈现软件往往需要线性对比度,这中间一个np.exp()的转换就够,但别忘了。我见过有人把log值直接传给屏幕,结果刺激对比度全错位,实验跑完数据直接报废。建议在正式实验前跑一个自动脚本,生成一串刺激参数,检查软件显示和配置空间是否对应。另外,把每次ask返回的参数和对应反应完整落盘保存,原始的trial级别数据是最有价值的资产。aepsych支持把模型和策略状态存下来,可以跨会话继续实验,这对被试中途需要休息的场景非常实用——休息完继续跑,模型还记得半小时前的所有反应。6.5 一套我常用的配置起步模板最后分享一个我近几个月一直在用的起步模板,它综合了前面说的各种经验:[Experiment] name = my_adaptive_task stimuli_per_trial = 1 outcome_type = binary stimulus_space = domain: [-2.5, 0.0], [-0.8, 0.8] min_total_trials = 40 max_total_trials = 100 [Experiment.Outcome] type = binary probability = 0.75 [Experiment.Strategy.0] name = init_sobol class = from_config generator = SobolGenerator generator.num_stimuli = 20 model = GPClassificationModel [Experiment.Strategy.1] name = optimize_acq class = from_config generator = OptimizeAcquisitionGenerator generator.acquisition_function = ExpectedImprovement model = GPClassificationModel如果刺激维度更高,我会把初始探索点数进一步提升;如果被试特别稳定,我会把max_total_trials往下压。这个模板的优点是min_total_trials给足了下限,即使前20次全在被试瞎猜,也还有40次试次纠正模型;max_total_trials又保证了不会无限拖下去。回到开头那个问题——用aepsych做自适应实验,到底值不值得从传统方法切换过来?我的体会是:单维阈值测量,它和成熟的阶梯法差距不算大,优势主要在拿后验分布、支持多维场景、以及配置可复现这些地方;但只要你涉及二维以上刺激空间,或者需要对阈值做统计层面的下游分析,aepsych带来的收益就非常直观。它不算一个零学习成本的工具,第一次配INI文件、第一次调试模型先验,都需要一点耐心。如果你现在正在被恒定刺激法的试次量折磨,或者想探索多维度阈值联合估计,从我这套配置模板出发,先跑一个单维的虚拟实验验证流程,再换成你自己实验的刺激空间和结果类型,应该能在一天内跑通。等跑通之后再回头调整策略分段和先验,你就会发现,原本累坏被试的阈值测量,其实可以这么省心。
做心理物理学实验的同学,十有八九都被阈值测量折磨过。早些年我在课题组做视觉对比度阈值的实验,用的是恒定刺激法:提前定好7个刺激水平,每个水平重复50次,一个被试测下来要三四千次试次,被试累到后面按键都开始敷衍,数据质量也肉眼可见地往下掉。后来换成自适应阶梯法,效率确实上来了,但阶梯法有个天然短板——它只是沿着“对/错”来回折返,刺激维度一旦超过两个,基本无从下手。直到我把aepsych这套Python工具用进实验流程,才算是把阈值估计从“体力活”变成了“算法活”。aepsych是一个专门做自适应实验设计的开源Python工具包,核心思路是用高斯过程分类模型实时学习被试的响应规律,再由贝叶斯优化决定下一个刺激怎么给。它的典型场景包括视觉对比度阈值、听觉检测阈限、触觉分辨能力这类心理物理测量,凡是能用“给一个刺激、记录一个二选一反应”描述的实验,它都能帮你用明显更少的试次拿到同样可用的精度。文章适合谁读?适合已经会用一点Python、被传统阈值测量效率折磨过、又不想自己从零写贝叶斯优化代码的研究者。下面我会把aepsych的语法结构、关键参数和两份完整可改的配置案例拆开细讲,按着我的路线走,你至少能跑通自己的第一个自适应实验。1. 为什么是aepsych:阈值测量从“体力活”变成“算法活”1.1 传统方法的效率瓶颈先聊一个很多人心知肚明但不肯写进论文里的点:传统阈值测量的试次数,是被统计需要而不是被试需要决定的。恒定刺激法要在预估阈值附近铺开一组固定刺激水平,为了保证每个点的正确率估计误差够小,每个水平往往要重复几十上百次。换算下来,一条心理测量函数曲线背后常常是上千次一模一样的流程,被试过几天再测,练习效应和疲劳效应早就混进去了。阶梯法(staircase)虽然把试次数量压下来不少,但它本质上是“上一回合对就降难度、错就升难度”的一维游走,只能告诉你阈值大概在哪,很难告诉你斜率、误报率这些描述响应曲线形状的参数。更麻烦的是,当你想同时估计两个维度——比如对比度阈值和空间频率之间的关系——传统阶梯法要么分多个一维实验分别跑,要么做二维网格搜索,试次数量几乎是指数级膨胀。1.2 aepsych做的核心事情:用模型替代查表aepsych把问题重新拆成了三步:先给刺激,拿到二选一反应;用一个概率模型(默认是高斯过程分类器)去拟合“刺激强度→正确反应概率”的关系;再用一个采集函数(比如期望改进)算出下一个刺激放在哪里信息量最大。这三步循环往复,刺激位置不再依赖死板的规则,而是由模型实时判断哪里最不确定、哪里最有助于收敛到目标阈值。我最初读到这个设计时有点将信将疑,觉得“模型引导的探索”听起来很漂亮,实际跑起来会不会因为模型误判而乱试探?后来测了一轮对比度阈值实验,还真被打脸了——只要你先验给得不太离谱,前20个试次的探索看起来是“乱跳”,但后面很快就会集中到阈值附近,并且模型给出的阈值置信区间是传统方法很难给出的副产品。这个能力对正式实验前的功率分析特别有价值,你可以先跑一个小样本,用后验不确定度估计试次总量。2. 环境搭建与核心概念梳理:从安装到看懂它的配置流2.1 安装与依赖说明aepsych的安装没什么特殊的,走pip就行:pip install aepsych如果是离线环境或者想用某个开发版本的特性,可以从源码安装,但日常实验我建议直接用PyPI发行版。它会自动拉取GPyTorch、Botorch、PyTorch这些底层依赖,版本冲突是实际使用中最容易遇到的坑之一。我碰到过一个情况:项目里原本有旧版PyTorch,安装aepsych时被自动升级,结果同环境里另一个模型脚本崩了。后来我的习惯是单独为aepsych建一个虚拟环境,和主实验环境隔开,互不干扰。python -m venv aepsych_env source aepsych_env/bin/activate # Windows下是 aepsych_env\Scripts\activate pip install aepsych2.2 四个核心认知:Experiment、Outcome、Strategy、Generator我第一次打开aepsych文档时最大的障碍,是它的配置体系里术语和普通Python库不太一样。这里我用自己的话给几个关键概念做个翻译:Experiment(实验):整个测量任务的容器,定义刺激空间范围、结果类型、总试次数上限。你可以把它理解成实验方案书。Outcome(结果):被试反应的记录方式。绝大多数心理物理实验是“二元选择”,也就是对/错或者有/无,所以Outcome类型一般配binary。如果做连续评分或者置信度打分,也可以配成连续结果。Strategy(策略):某一段试次里“怎么选刺激、怎么更新模型”的成套方案。一个实验可以分多个阶段,每个阶段用不同策略。比如前10个试次用纯随机探索(Sobol序列),后面切换到模型驱动的优化采集。Generator(生成器):策略内部真正产出刺激坐标的组件,它决定了下一组刺激参数是什么。常见的有SobolGenerator(均匀探索)和OptimizeAcquisitionGenerator(用采集函数优化刺激位置)。这套拆分初看绕,但好处是复用性非常强。换一个实验时,我只需要改刺激空间范围和结果定义,生成器、模型、采集逻辑都能原样搬走。2.3 配置文件的整体骨架:用INI组织实验方案aepsych里最常用的是基于INI风格配置文件的驱动方式。文件里把所有参数集中管理,实验逻辑和参数面分开,这样记录可复现性特别舒服。一个最简配置的长相大致如下:[Experiment] name = contrast_threshold_demo stimuli_per_trial = 1 outcome_type = binary [Experiment.Outcome] type = binary [Experiment.Strategy.0] name = exploration class = from_config generator = SobolGenerator generator.num_stimuli = 12 model = GPClassificationModel [Experiment.Strategy.1] name = optimization class = from_config generator = OptimizeAcquisitionGenerator model = GPClassificationModel这里的[Experiment.Strategy.0]和[Experiment.Strategy.1]定义了先后两个阶段:先用12次随机探索打底,让高斯过程模型有个粗糙的全局认识,再切换成优化采集。为什么这样分段?因为高斯过程分类器如果一上来就去“优化”,很容易被早期几个偶然的正确/错误反应带偏,先均匀撒点能有效降低这种风险。3. 核心语法与参数逐个拆解:一份INI文件能做的事3.1 实验区段的参数细节[Experiment]区段里我最关注三个参数:stimuli_per_trial:每一回合给几个刺激。视觉实验通常1个试次给1个刺激,但有些听觉实验会同时给多个备选刺激让被试选,这个参数就会大于1。min_total_trials/max_total_trials:最少和最多试次总数。注意,aepsych并不强制你跑满上限,而是会在满足停止准则后提前结束。比如你觉得100试次是预算上限,可以设max_total_trials = 100,如果模型后验精度提前达标,实验就提前收工。stimulus_space:刺激空间的边界,语法是domain:[最小值, 最大值],支持一维也有多维写法。这里有个容易搞混的地方:它定义的是模型输入空间,不是刺激物理单位。比如你的刺激是百分比对比度,建模前可能要做log变换,那么空间里填的是变换后的数值范围。来看一个一维示例:[Experiment] name = contrast_threshold_demo stimuli_per_trial = 1 outcome_type = binary stimulus_space = domain: [-3.0, 0.0] min_total_trials = 30 max_total_trials = 80这里的[-3.0, 0.0]表示log对比度范围,换算成线性就是0.1%到100%对比度。用log尺度建模几乎是视觉心理物理实验的默认做法,因为人类对比度阈值在log尺度上更接近正态分布,高斯过程模型也更容易拟合。3.2 结果模型和判断准则的参数[Experiment.Outcome]区段里,type = binary意味着我们用一个伯努利似然来模拟被试的反应,默认模型是GPClassificationModel。真正值得花时间调的是输出转换函数里的概率参数。比如你关心的不是50%正确率,而是75%正确率对应的刺激强度,就需要在配置里指定目标概率。[Experiment.Outcome] type = binary probability = 0.75这个probability = 0.75直接决定了后面阈值提取的方向。高斯过程分类器拟合的是“刺激强度→正确概率”的响应函数,默认情况下它会取50%作为阈值参考点,心理物理实验里更常见的75%或79%就要在这里改。很多教程不会提醒的是:如果你做的是一组强制选择任务,还要考虑猜测率。二选一强制选择任务的猜测基线是50%,所以目标可能设在75%;如果是二项选择加“不确定”选项,基线会变,目标概率也要相应调整。连续结果的配置略有不同:[Experiment.Outcome] type = continuous连续结果模型用的是回归方式,拟合“刺激强度→反应量”的连续映射。它适用于反应时、评分、幅度估计这类实验,但阈值提取时要自己定义“反应量对应哪个刺激强度”的规则。3.3 生成器与采样策略参数生成器是控制实验效率的核心环节。随机探索阶段我用SobolGenerator,它生成的是低差异序列,比numpy.random的完全随机采样覆盖更均匀,二维以上刺激空间尤其明显。需要给出的是生成数量:[Experiment.Strategy.0] class = from_config name = init_sobol generator = SobolGenerator generator.num_stimuli = 12 model = GPClassificationModel优化阶段用OptimizeAcquisitionGenerator,它做的是在当前模型后验下,用采集函数搜索下一个信息量最大的刺激位置。常用采集函数包括期望改进(Expected Improvement)和概率改进(Probability of Improvement),aepsych里可以通过参数指定,不过多数场景默认的期望改进就够用。[Experiment.Strategy.1] class = from_config name = optimize_acq generator = OptimizeAcquisitionGenerator generator.acquisition_function = ExpectedImprovement model = GPClassificationModel有个经验是:如果被试表现特别稳定,优化阶段的试次数量可以设少一点;如果被试噪声大,比如疲劳或者注意力波动,就多给优化阶段一些试次。这类参数没有标准答案,我会在策略区段里额外关注min_total_trials和max_total_trials的差额,这个差额越大,提前停止的灵活度越高。3.4 模型先验的写法与调整逻辑高斯过程分类器里的先验参数,很多时候是决定收敛速度快慢的隐藏变量。aepsych允许在配置里覆盖模型的均值函数和协方差函数参数:[GPClassificationModel] mean_covar_prior = default lengthscale_prior = lognormal_prior我遇到的常见情况是默认先验对于“刺激空间范围过宽”的实验不够好用。比如刺激范围是[-3, 0],但真实阈值在[-0.5, -0.3]之间,模型初期会在很大一段空间里浪费用试次。解决办法是手动收紧长度尺度先验,让模型默认认为响应概率在刺激空间上的变化不能太陡峭也不能太平缓,并且把均值初始化在预估值附近。调整先验时注意别过头。我曾经把长度尺度的均值设得很小,模型以为响应概率变化极快,结果连续好几十个试次都往一个很小的邻域里钻,阈值方差看着很小,但实际偏差很大——这是典型的过度自信。先验的本意是提供计算便利,不该喧宾夺主。4. 应用案例一:视觉对比度阈值的单维自适应估计4.1 实验背景与任务设计我们课题组当时做的是一个对比度检测任务:屏幕上给一个光栅刺激,被试判断“看到了还是没看到”。刺激参数是光栅的对比度,我们想知道在75%正确率对应的对比度阈值是多少。按照老办法,这需要至少6到8个对比度水平、每个水平几十次重复,一个被试要跑将近千次。改用aepsych之后,方案变成这样:单维刺激空间,log对比度范围从-2.5到0(相当于0.3%到100%对比度),结果类型为二元选择,目标概率设为75%,每个被试预算60个试次。先随机探索12次,再用优化采集跑剩下的,最终从模型后验里提取阈值。4.2 完整配置与代码实现完整的配置文件如下:[Experiment] name = contrast_threshold_single stimuli_per_trial = 1 outcome_type = binary stimulus_space = domain: [-2.5, 0.0] min_total_trials = 40 max_total_trials = 60 [Experiment.Outcome] type = binary probability = 0.75 [Experiment.Strategy.0] name = init_sobol class = from_config generator = SobolGenerator generator.num_stimuli = 12 model = GPClassificationModel [Experiment.Strategy.1] name = optimize_acq class = from_config generator = OptimizeAcquisitionGenerator model = GPClassificationModel generator.acquisition_function = ExpectedImprovement跑实验的调度代码,用Python控制循环比较灵活:from aepsych.server import AEPsychServer server = AEPsychServer("contrast_threshold_single.ini") server.start_experiment() trial_count = 0 while trial_count < 60: # 让服务器决定下一个刺激 next_stimulus = server.ask() # 展示刺激给被试并收集反应,这里替换成你的实验软件接口 correct = run_psychopy_trial(next_stimulus) # 返回True或False # 把反应送回模型 server.tell(correct) # 如果模型认为收敛,可提前停止 if server.should_stop(): print("提前停止,实际试次:", server.trial_count) break trial_count += 1这里ask和tell是流程里最重要的两个动作:ask负责从当前策略里拿下一个刺激,tell负责把这次反应喂回去更新模型和策略状态。用实验软件呈现刺激的部分,不管是PsychoPy还是其他工具,只要把刺激参数转成自己软件的格式就行。4.3 运行结果与阈值提取实验跑完后,aepsych会给出一个训练好的高斯过程分类模型。提取阈值不能只看最后一两个试次,而是要从模型的后验里找“正确概率等于0.75”对应的刺激强度。做法是先沿着刺激空间生成密集的测试点,让模型预测每个点的正确概率,再通过插值找到目标概率的位置。import numpy as np from aepsych.factory import default config = "contrast_threshold_single.ini" experiment = default(config) # 加载配置生成实验对象 # 用模型预测整个刺激空间上的正确概率 test_x = np.linspace(-2.5, 0.0, 500).reshape(-1, 1) prob_pred, var_pred = experiment.model.predict(test_x) # 找到概率等于0.75的位置,即阈值 from scipy.interpolate import interp1d f = interp1d(prob_pred[:, 0], test_x[:, 0], bounds_error=False) threshold_log_contrast = f(0.75) print("阈值(log对比度):", threshold_log_contrast)实测下来,一组30个被试的数据里,大部分被试在40到50个试次以内就能给出稳定的阈值估计,和之后用恒定刺激法验证的结果误差在一个可接受范围内。这个流程不但省了将近一半的被试时间,还能拿到每个阈值点的后验分布,后续做被试间比较时可以直接用分布信息。5. 应用案例二:二维联合估计——对比度乘空间频率的阈值曲面5.1 扩展到二维刺激空间第二个案例是当时实验的进阶版:我们想同时看对比度阈值和空间频率的关系。传统做法是把空间频率分成4档,每一档单独跑一条一维对比度阶梯——4条线测完,被试已经烦躁到不行。aepsych处理这种多维联合估计的方式就干净很多:把刺激空间直接定义成二维,一次实验同时收集两个维度的信息,最后得到一张阈值曲面。二维空间带来的核心变化是:模型要拟合的不再是一条曲线,而是一个曲面;探索策略也更讲究,纯随机均匀撒点在高维空间里效率会明显下降。SobolGenerator在这种情况下比完全随机采样强很多,因为它能更好地覆盖二维网格。5.2 配置差异点与分析二维配置和一维配置的差别,主要集中在刺激空间的定义和结果模型里参数的数量:[Experiment] name = contrast_threshold_2d stimuli_per_trial = 1 outcome_type = binary stimulus_space = domain: [-2.5, 0.0], [-0.8, 0.8] [Experiment.Outcome] type = binary probability = 0.75 [Experiment.Strategy.0] name = init_sobol class = from_config generator = SobolGenerator generator.num_stimuli = 24 model = GPClassificationModel [Experiment.Strategy.1] name = optimize_acq class = from_config generator = OptimizeAcquisitionGenerator model = GPClassificationModel generator.acquisition_function = ExpectedImprovement注意stimulus_space现在有两个区间,分别对应log对比度和log空间频率。第二个参数空间的范围是[-0.8, 0.8],意味着空间频率从大约0.16到6.3周期/度。随机探索阶段的num_stimuli从12提到了24,原因是二维空间需要更多初始点才能让高斯过程形成像样的全局估计,如果还沿用12个点,优化阶段很容易被早期的局部分布拐跑。提取阈值时,模型不再输出一个点,而是输出一条等值线。二维刺激空间上所有“预测正确概率=0.75”的位置串起来,就是对比度和空间频率联合阈值曲线。5.3 可视化与解读模型训练完以后,最直观的检查方式是把预测的阈值等值线画出来。我会先画一个对比度×空间频率的网格热图,填上预测的概率,再把0.75等值线叠上去,看它是否平滑、是否符合预期的“吊床形”趋势。import matplotlib.pyplot as plt grid_x = np.linspace(-2.5, 0.0, 80) grid_y = np.linspace(-0.8, 0.8, 60) xx, yy = np.meshgrid(grid_x, grid_y) grid_pts = np.stack([xx.ravel(), yy.ravel()], axis=1) prob_grid = experiment.model.predict(grid_pts)[0].reshape(xx.shape) plt.contourf(xx, yy, prob_grid, levels=20, cmap="viridis") plt.contour(xx, yy, prob_grid, levels=[0.75], colors="red", linewidths=2) plt.colorbar(label="正确概率") plt.xlabel("log 对比度") plt.ylabel("log 空间频率") plt.title("二维联合阈值估计") plt.show()看图的重点有三个:等值线是否单调;靠近边界处是否出现不合理的振荡;0.25等值线是否和0.75大致平行。如果出现振荡,多半是随机探索点不足或者模型先验不当;如果0.75等值线在某个区域特别密集,说明模型对这个区域特别有把握,但相邻区域的信息可能不足,需要考虑增加试次或调整生成策略。这一轮二维实验跑下来,我们最后拿到了一条平滑的对比度-空间频率阈值曲线,总试次控制在100以内,换作传统做法至少需要4条一维阶梯外加额外重复,总试次大约翻两倍。多维场景是aepsych对比传统方法优势最大的地方,也最值得投入时间去研究。6. 实战中的常见坑与调参心得:从踩坑到顺手6.1 收敛过慢:先怀疑策略分段,而不是加试次我第一次把aepsych用在真实被试上时,遇到最典型的状况是:跑了五十多个试次,阈值估计还在两个极端值之间大幅摆动。我当时第一反应是加大max_total_trials,结果只是把问题拖长了。后来回头看配置才明白,问题出在初始探索阶段太少。模型在信息不足的情况下进入优化采集,采集函数会倾向于反复试探那些高不确定性的边界点,于是刺激参数在边界反复横跳。解决方案:把初始探索的num_stimuli从8提高到15到20,让模型先看到足够覆盖刺激空间的响应结果。如果实验的刺激空间范围特别大,甚至可以考虑把初始探索分成两段,每段12个点,第二段在第一段模型基础上生成,只是生成时用偏向探索的采集策略。6.2 先验过于自信:模型给出的置信区间会骗人高斯过程分类器的后验不确定性天然比实际误差要小,这一点在试次数不足时特别明显。有次我为了赶实验进度,把预算从60砍到35,模型在第30个试次时给出一个看起来很窄的阈值置信区间,我以为估计很准。后来用20次重复的恒定刺激法验证,发现真实阈值落在置信区间边缘。这个教训让我记住一件事:后验不确定性只是模型视角下的不确定性,不等于对真实值的误差保证。如果你需要正式的阈值估计报告,建议至少预留10%的冗余试次,或者跑完以后用独立方法做交叉验证。aepsych也提供了一些离线分析工具,可以把已有数据用不同模型重新拟合并对比,这是我做数据可靠性检查时常用的手段。6.3 按键反应延迟和错误编码:模型对脏数据很敏感高斯过程分类器假定每次反应是条件独立的,但被试很可能因为疲劳出现“偶尔走神”的随机错误,这类噪声在模型里会被当作信息。在一次实验中,一个被试连续多试次在阈值附近答错,模型立刻把阈值往低刺激方向推了一大截。传统方法里单个离群点还可以事后剔除,在自适应实验里,这个离群点已经真实影响了后续刺激的选择,很难事后补救。应对方法有两条:一是实验中途加入少量重复试次,把同一个刺激参数在几轮后再给一次,用来检测被试一致性;二是在配置里考察更适合的模型变体,比如对噪声水平更鲁棒的似然函数。坦白说,第二条路的代价是计算量增加,我目前更常用的是第一条路,既简单又能给被试状态打分。6.4 实验软件与aepsych的接口磨合aepsych本身不做刺激呈现,它只负责“算”刺激参数和“收”反应结果,所以和PsychoPy这类工具对接时,要注意单位换算。我们在案例里用的是log对比度,但呈现软件往往需要线性对比度,这中间一个np.exp()的转换就够,但别忘了。我见过有人把log值直接传给屏幕,结果刺激对比度全错位,实验跑完数据直接报废。建议在正式实验前跑一个自动脚本,生成一串刺激参数,检查软件显示和配置空间是否对应。另外,把每次ask返回的参数和对应反应完整落盘保存,原始的trial级别数据是最有价值的资产。aepsych支持把模型和策略状态存下来,可以跨会话继续实验,这对被试中途需要休息的场景非常实用——休息完继续跑,模型还记得半小时前的所有反应。6.5 一套我常用的配置起步模板最后分享一个我近几个月一直在用的起步模板,它综合了前面说的各种经验:[Experiment] name = my_adaptive_task stimuli_per_trial = 1 outcome_type = binary stimulus_space = domain: [-2.5, 0.0], [-0.8, 0.8] min_total_trials = 40 max_total_trials = 100 [Experiment.Outcome] type = binary probability = 0.75 [Experiment.Strategy.0] name = init_sobol class = from_config generator = SobolGenerator generator.num_stimuli = 20 model = GPClassificationModel [Experiment.Strategy.1] name = optimize_acq class = from_config generator = OptimizeAcquisitionGenerator generator.acquisition_function = ExpectedImprovement model = GPClassificationModel如果刺激维度更高,我会把初始探索点数进一步提升;如果被试特别稳定,我会把max_total_trials往下压。这个模板的优点是min_total_trials给足了下限,即使前20次全在被试瞎猜,也还有40次试次纠正模型;max_total_trials又保证了不会无限拖下去。回到开头那个问题——用aepsych做自适应实验,到底值不值得从传统方法切换过来?我的体会是:单维阈值测量,它和成熟的阶梯法差距不算大,优势主要在拿后验分布、支持多维场景、以及配置可复现这些地方;但只要你涉及二维以上刺激空间,或者需要对阈值做统计层面的下游分析,aepsych带来的收益就非常直观。它不算一个零学习成本的工具,第一次配INI文件、第一次调试模型先验,都需要一点耐心。如果你现在正在被恒定刺激法的试次量折磨,或者想探索多维度阈值联合估计,从我这套配置模板出发,先跑一个单维的虚拟实验验证流程,再换成你自己实验的刺激空间和结果类型,应该能在一天内跑通。等跑通之后再回头调整策略分段和先验,你就会发现,原本累坏被试的阈值测量,其实可以这么省心。