news 2026/10/19 5:35:39

aepsych实战:贝叶斯优化驱动的主动采样阈值估计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
aepsych实战:贝叶斯优化驱动的主动采样阈值估计

这两年我一直在跟心理物理学实验打交道,最常被周围人问的问题不是“刺激怎么呈现”,而是“阈值到底怎么估才又快又稳”。传统 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 = EAVC

3.3 停止条件与收敛判断

aepsych里判断实验是否结束,有两个层面:策略层的stimuli_per_strat配额,和更精细的收敛条件。

如果你希望实验自动在阈值估计稳定后停止,可以在最后一个策略里配置:

[strategy_1] min_asks = 10 min_total_trials = 30

min_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_lbConfig 里没有定义刺激下界在[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 控制了模型的先验、实验的阶段划分和停止条件,该有的掌控感一点不少。如果你正在做阈值估计或者刺激-反应关系建模,我建议先用模拟被试跑通流程,再在真实实验里逐渐增加策略复杂度。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 5:34:29

Playwright codegen与AI补全:UI自动化脚本的工程化实践

运行了近一年的 UI 自动化项目后,我愈发觉得“录制脚本”这件事被很多人低估了。尤其当 AI 编程助手越来越普及的时候,一说到生成自动化脚本,大家下意识就想让 AI“从零写一段”。但我在自己的多个项目里,几乎都是反着来的&#x…

作者头像 李华
网站建设 2026/10/19 5:33:42

Kubernetes容器资源限制实战:requests、limits与QoS详解

从接触Kubernetes到能在生产环境里睡个安稳觉,中间隔着的不是一堆概念,而是对“资源限制”这套机制真正的理解。我见过太多团队,刚开始用k8s时把requests和limits当成两个可填可不填的字段,结果服务一上线就频繁重启,节…

作者头像 李华
网站建设 2026/10/19 5:33:41

企业AI落地:先吃透RAG还是先微调模型?一文讲清决策框架

这两年做企业AI落地,被问得最多的问题之一就是:先吃透RAG还是先训自己的模型?说真的,每次听到这个问题我都有点恍惚。因为它本质上不是一道选择题,而是一个被市场情绪包装成选择题的陷阱。很多技术负责人一上来就拍板&…

作者头像 李华
网站建设 2026/10/19 5:33:18

Python天气数据分析系统:体感温度偏差归因与多城市热力图可视化

简介:这是一套面向计算机专业本科生的Python天气预报系统毕设级项目资源,适用于毕业设计、课程设计及期末大作业场景,帮助初学者快速掌握数据获取、分析与可视化全流程。资源包含完整可运行代码、详细注释文档及系统使用说明,覆盖…

作者头像 李华
网站建设 2026/10/19 5:32:47

LLM应用开发中数据格式的隐形陷阱与实战解析

1. 为什么数据格式是LLM应用里最容易被低估的一环做LLM应用开发的人,十有八九把注意力放在模型选型、提示词调优、RAG召回率这些“显性”环节上,而数据格式往往被当成一个“随便拼拼字符串”的小事。我见过太多项目,模型能力没问题、检索也没…

作者头像 李华
网站建设 2026/10/19 5:32:29

C#+ASP.NET MVC本机LLM与NanoFramework嵌入式机器人开发整体思路

1. 从标题拆解这个项目的真实意图1.1 这个标题到底在说什么把标题拆开看,关键词其实很密集:C#、ASP.NET MVC、本机LLM、.gguf、.onnx、NanoFramework、下位机、嵌入式、学伴机器人、生活机器人。这些词放在一起,指向一个非常具体的工程场景—…

作者头像 李华