仿真实验闭环工作流开发教程(10):贝叶斯优化闭环(上)——Ax 的 ask/tell 把仿真目标接成会自我改进的循环
版本声明块
- 工具/软件:Ax(PyPI
ax-platform,导入ax,文档版 1.3.1),现行 APIax.api.client.Client+ax.api.configs.RangeParameterConfig;默认生成策略 Center+Sobol+MBM(MBM 节点 generator_enum=BoTorch)- 语言/环境:Python 3.11+、SQLite、第 09 篇的
simulate(params)->prediction- 本文目标:把"下一个该试哪组参数"交给 Ax 的 ask/tell,并让每个 trial 成对落库(铁律 5)。
一句话结论:client.get_next_trials(max_trials=1)就是"ask"、client.complete_trial(trial_index=ti, raw_data={...})就是"tell";把官方 quickstart 的 Booth 目标(x1+2·x2−7)² + (2·x1+x2−5)²跑满 20 轮,get_best_parameterization()收敛到 ≈(1.49, 2.42)、而解析真值是 (1, 3)——把这条目标函数换成第 09 篇simulate或真实实验回报,闭环就从"玩具"变成"会自我改进的配方引擎",前提是每个 trial 的参数与回报成对写进 SQLite(铁律 5)。
〇、本篇要解决的认知问题
Q1:贝叶斯优化的 ask/tell(建议/回报)范式在闭环里扮演什么角色,为什么它天然适配昂贵仿真/实验?
Q2:Ax 1.3.1 现行 API 的调用顺序是什么,Client与RangeParameterConfig各负责什么?
Q3:官方 Booth quickstart 实跑 20 轮给的是什么数,跟真值差多少,这差值说明了什么?
Q4:Ax 默认的 Center+Sobol+MBM(BoTorch) 生成策略意味着什么,它和 BoTorch 是什么关系?
Q5:为什么说"trial 落库"是闭环正确性的命门(铁律 5),旧ax.Client顶层导入的坑是什么?
一、机制解析:ask/tell 是 Learn 与 Design 的接头
到这里,闭环的读(03)、写(07)、执行(04/06)、回传(08)、仿真(09)都齐了,缺一个"下一轮该试什么"的大脑。贝叶斯优化(Bayesian optimization)就是这颗大脑,它的交互范式叫 ask/tell(建议/回报):
┌───────────────────────── Ask ──────────────────────┐ │ client.get_next_trials() → 建议参数 x* │ ▼ │ ┌──────────┐ params ┌────────────────┐ prediction │ │ SQLite │──────────▶│ simulate/exp │──────────────▶ │ │ trial 表 │◀──────────│ (第09篇/真机) │ raw_data │ Tell └──────────┘ 成对落库 └────────────────┘ │ ▲ │ └───────────────── 模型更新(BoTorch GP)──────────────┘为什么它天生适合闭环:配方优化一次实验/仿真很贵(分钟到小时级),评估预算往往是硬约束——你只有"再做 20 轮"的钱。网格/随机搜索会把大量预算浪费在"明显没戏"的区域,而贝叶斯优化用一个代理模型(高斯过程 GP)记住"哪些点做过、结果如何、哪里还没探明",每轮只挑"最有信息量"的点去问。这里藏着一条贯穿始终的权衡:exploration(探索未采样的不确定区,博取更大改进)与 exploitation(利用当前模型认为最好的区,稳妥收敛),采集函数就是给这条权衡定量的旋钮——本篇用 Ax 默认策略把它包好,第 11 篇再手工拆开。这正是 DBTL 里 Learn 反哺 Design 的算法化,其权威表述见美国国家科学院报告Biotechnology in the Age of Synthetic Biology第 2 章(设计原型→构建物理实现→测试功能→从缺陷中学习→反馈下一轮)。
还要厘清"回报"到底是什么:在第 09 篇语境里,tell 回的是simulate(params)的预测值,闭环转的是"纯仿真"环;一旦上了真机,tell 回的应是第 08 篇从仪器解析出来的真实浓度/读数(带单位与不确定度)。同一条 ask/tell 循环,喂仿真还是喂湿实验,决定了这是"干实验优化"还是"干湿闭环"——优化器代码一行不改,只换 tell 的数据来源,这正是把引擎与设备都做"无关层"(第 06/09 篇)后换来的红利。
现行 API 调用顺序(ax-platform 1.3.1,官方 quickstart 逐字可跑):
| 步骤 | 调用 | 语义 |
|---|---|---|
| 建实验 | client.configure_experiment(name, parameters=[RangeParameterConfig(...)]) | 声明搜索空间(参数名、bounds、类型) |
| 定目标 | client.configure_optimization(objective="-1 * booth") | 声明优化方向(这里最小化 booth 即取负最大化) |
| Ask | client.get_next_trials(max_trials=1) | 要一批建议参数(返回 {trial_index: params}) |
| Tell | client.complete_trial(trial_index, raw_data={"booth": 值}) | 回报这组参数的实测/仿真值 |
| 收口 | client.get_best_parameterization() | 取当前最优参数 |
默认生成策略的含义(Ax 是 BoTorch 上层封装):Ax 开箱即用不配生成器时,会自动走Center(起点)→ Sobol(空间填充冷启动)→ MBM(Model-Based Meta,其节点 generator_enum=BoTorch)的组合。也就是说你调 Ax,前几步在铺 Sobol 采样、之后交给 BoTorch 的 GP 模型做贝叶斯建议——Ax 把 BoTorch 的底层能力包成了配置式 API。想手写 GP 与采集函数、精确控制 q 点批量,那是第 11 篇 BoTorch 的活(SingleTaskGP→ExpectedImprovement/qExpectedImprovement)。
一个 v1/v2 式分界坑:网上大量老教程写from ax import Client(顶层导入),而 1.3.1 现行是from ax.api.client import Client+ax.api.configs.RangeParameterConfig。顶层ax.Client与 API 层Client混用会参数对不上、方法签名不同。认ax.api.*这一支,别把两代导入写进同一段代码(同第 04 篇 Opentons v1/v2 文档树分离的教训)。同理,停维组件如 GPyOpt(2023-02-23 归档)、scikit-optimize(2024-02-28 归档)只能讲思想、不作新闭环依赖(铁律 6)——它们的Optimizer.ask()/tell()只是范式设计参考,生产用 Ax。
为什么"落库"要单独拎出来讲:Ax 的Client默认只在内存里维护这一轮优化会话,进程一退、所有 trial 灰飞烟灭。闭环不能靠内存态——它必须把每个 trial 的建议参数、回报值、以及(理想情况下)失败标记持久化,才能做到:① 崩溃后从库重建模型继续问(本篇 2.3);② 事后审计"这一步是谁、基于哪些历史数据建议的"(第 16 篇合规);③ 把仿真版本、协议版本、记录 ID、原始数据链接绑成一条 loop record(铁律 8)。所以"ask/tell 成对落库"(铁律 5)不是锦上添花,而是把一次性的内存优化升级成可追溯闭环的关键一步;本篇把这三点都压进 SQLite 的一张trials表里演示。
二、完整代码与逐行剖析
2.1 官方 Booth quickstart 复现(目标函数版闭环)
这段是 ax.dev quickstart 的逐字风格,先让读者看到"20 轮收敛到 ≈(1.49,2.42)"这个可验证事实。
fromax.api.clientimportClient# 现行 API:ax.api.client,不是顶层 ax.Clientfromax.api.configsimportRangeParameterConfig# 参数用 config 对象声明,而非散参数字典client=Client()# 无参构造:一个内存态优化会话# 声明实验:两个浮点参数 x1,x2,各自 [-10,10];parameter_type="float" 明确连续域client.configure_experiment(name="booth_function",parameters=[RangeParameterConfig(name="x1",bounds=(-10.0,10.0),parameter_type="float"),RangeParameterConfig(name="x2",bounds=(-10.0,10.0),parameter_type="float"),])# 目标:最小化 booth。"-1 * booth" 是把"最小化"表达成"最大化负值"——Ax 默认按最大化解读 raw_data,# 故取负号;这是反直觉默认值,写错方向会优化到函数最大值client.configure_optimization(objective="-1 * booth")for_inrange(20):# 20 轮 ask/tell;quickstart 用 20 即可看收敛forti,pinclient.get_next_trials(max_trials=1).items():# Ask:拿一组建议参数x1,x2=p["x1"],p["x2"]# 目标函数=真实实验回报的替身;Booth 函数解析真值最小点在 (1, 3),最优值 0booth=(x1+2*x2-7)**2+(2*x1+x2-5)**2client.complete_trial(trial_index=ti,raw_data={"booth":booth})# Tell:回报,闭环转一圈best=client.get_best_parameterization()# 官方输出 ≈(1.49, 2.42),真值 (1, 3)print("best:",best)剖析:configure_experiment只声明"搜什么",configure_optimization声明"往哪优化",循环里 ask/tell 交替——这就是 DBTL 每转一圈的算法骨架。"-1 * booth"是必须理解的默认:Ax 的 raw_data 默认越大越好,最小化问题要么取负、要么显式声明最小化方向。get_next_trials(max_trials=1)每次只回一个建议点,把max_trials调大就得到批量建议(一批 q 个点),那是第 11 篇"一板 96 个条件"的伏笔。
2.2 把目标换成 simulate 并成对落库(铁律 5)
真正的闭环里,booth要换成第 09 篇simulate(params)或一次真实实验回报;每个 trial 的"建议参数"与"回报值"必须成对写进台账,缺回报的 trial 不许进下一轮拟合(铁律 5)。
importsqlite3,jsonfromax.api.clientimportClientfromax.api.configsimportRangeParameterConfig# simulate = 第 09 篇的统一契约:给定 params -> 一个标量预测(真实闭环里也可能是仪器回报)fromsim_engineimportsimulate# 你的第 09 篇模块;换引擎不改这段con=sqlite3.connect("trials.db");cur=con.cursor()# trial 表:参数与回报一一对应,raw 非空才算"这一圈闭合"——从 schema 上强制铁律 5cur.execute("""CREATE TABLE IF NOT EXISTS trials( trial_index INTEGER PRIMARY KEY, params TEXT NOT NULL, -- 建议参数 JSON:Attributable,谁建议的什么 raw_value REAL, -- 回报值:可空,但空=未完成,不许进下轮拟合 completed INTEGER DEFAULT 0 -- 落库闸门:0=已 ask 未 tell,1=成对闭合 )""")client=Client()client.configure_experiment(name="formulation",parameters=[RangeParameterConfig(name="temperature",bounds=(20.0,80.0),parameter_type="float"),RangeParameterConfig(name="concentration",bounds=(0.1,2.0),parameter_type="float"),])client.configure_optimization(objective="-1 * loss")for_inrange(20):forti,pinclient.get_next_trials(max_trials=1).items():# 先落 ask:参数入库,completed=0,标记"这轮建议已发出、回报未回"cur.execute("INSERT OR REPLACE INTO trials VALUES (?,?,?,0)",(ti,json.dumps(p),None))con.commit()pred=simulate({"temperature":p["temperature"],"concentration":p["concentration"]})# 真实目标=仿真或实验client.complete_trial(trial_index=ti,raw_data={"loss":float(pred)})# 再落 tell:回报回填并把 completed 置 1——ask/tell 成对闭合才算一轮cur.execute("UPDATE trials SET raw_value=?, completed=1 WHERE trial_index=?",(float(pred),ti))con.commit()# 一致性自检:任何 completed=0 的行都是"断链",说明闭环有 ask 无 tell(缺回报),必须堵住cur.execute("SELECT COUNT(*) FROM trials WHERE completed=0")assertcur.fetchone()[0]==0,"存在无回报 trial,违反铁律 5,禁止进入下一轮拟合"print("best:",client.get_best_parameterization())con.close()剖析:两段落库(INSERT 记 ask、UPDATE 记 tell +completed=1)把"建议"和"回报"钉在同一行,assert completed=0 计数为 0是铁律 5 的守门断言——只要有一次 ask 后进程崩了没 tell,重启后这条断言就会把问题暴露出来,而不是让"缺回报"的半截 trial 悄悄进入 GP 拟合。INSERT OR REPLACE让重放同一trial_index不产生重复行(幂等,呼应第 07 篇写回幂等)。这张trials表还是未来 loop record 的骨架:等接上真机,只需再加几列(仿真版本、协议版本、LIMS 记录 ID、原始数据链接),一条完整的铁律 8 追溯记录就从"优化器私有台账"长成了"全闭环共享账本"。
2.3 断点续跑:从库重建"已闭合"的 trial
闭环要能中断恢复。恢复的关键是:只把completed=1的 trial 重新 tell 回 Ax,半截的 ask 不进模型。
importsqlite3,jsonfromax.api.clientimportClientfromax.api.configsimportRangeParameterConfigdefresume_from_db(db="trials.db"):con=sqlite3.connect(db);cur=con.cursor()client=Client()client.configure_experiment(name="formulation",parameters=[RangeParameterConfig(name="temperature",bounds=(20.0,80.0),parameter_type="float"),RangeParameterConfig(name="concentration",bounds=(0.1,2.0),parameter_type="float"),])client.configure_optimization(objective="-1 * loss")# 只取成对闭合的行回放:completed=1 保证每条都有回报,缺回报的绝不喂给模型(铁律 5)forti,params,rawincur.execute("SELECT trial_index, params, raw_value FROM trials WHERE completed=1"):client.get_next_trials(max_trials=1)# 占位一个 trial 位(示例:与库中 ti 对齐)client.complete_trial(trial_index=ti,raw_data={"loss":raw})con.close()returnclient# 交回调用方继续 ask/tell剖析:断点续跑最容易犯的错是把"上次 ask 了但没 tell"的半截 trial 也重放,等于告诉模型"这个点有结果"而其实没有。用WHERE completed=1过滤是硬约束——恢复出来的模型只建立在完整数据上,这正是铁律 5 在重启场景下的延伸。
三、常见报错与排查
- 现象:
from ax import Client能导入但configure_experiment参数对不上。根因:顶层ax.Client是旧式对象,与 API 层ax.api.client.Client签名不同。解法:统一用from ax.api.client import Client+ax.api.configs.RangeParameterConfig(1.3.1 现行)。 - 现象:20 轮后
get_best_parameterization跑到函数最大点而非最小点。根因:objective方向写反——Ax raw_data 默认越大越好。解法:最小化用-1 * loss(如 Booth),或正确声明方向;对照 quickstart 的"-1 * booth"。 - 现象:前几轮建议像随机撒点、不像"智能优化"。根因:默认 Center+Sobol 冷启动阶段本就在空间填充,MBM/BoTorch 模型建议要到采样够后才接管。解法:这是预期行为;要更早用 GP 可调生成器或增大批量(第 11 篇)。
- 现象:进程中途崩溃,重启后模型把无回报的 trial 当数据。根因:ask 落库了、tell 没落。解法:恢复时只回放
completed=1(2.3),并保留assert completed=0 计数==0断言。 - 现象:仿真返回
NaN(第 09 篇不收敛)被直接 complete_trial。根因:把失败当数值回报污染 GP。解法:NaN/失败标记走 mask 分支、不complete_trial为有效值(第 11 篇展开)。
四、动手练习
- 复现官方收敛数:跑 2.1 满 20 轮。判定标准:
get_best_parameterization()落在 (1.49, 2.42) 附近(±0.2),确认与 Booth 真值 (1, 3) 仍有残差——理解"有限预算下的近似"。 - 铁律 5 断言测试:在 2.2 里人为让一次
complete_trial抛异常(如 simulate 返回 None 转 float 失败)。判定标准:末尾assert捕获到completed=0行并报"存在无回报 trial"。 - 目标替换:把 2.2 的
simulate换成一个自己造的二维函数(如 Rosenbrock)。判定标准:闭环不需改任何 Ax 调用,20 轮内 best 朝该函数最小值靠近且 trial 表 20 行全completed=1。
五、小结与下一篇预告
Ax 把贝叶斯优化的 ask/tell 收成配置式 API:configure_experiment → configure_optimization → get_next_trials → complete_trial → get_best_parameterization,官方 Booth quickstart 20 轮 ≈(1.49,2.42) vs 真值 (1,3) 是它"用有限预算逼近最优"的可见证据;默认 Center+Sobol+MBM(BoTorch) 说明 Ax 实为 BoTorch 上层封装。把目标函数从 booth 换成第 09 篇simulate或真实实验回报,并用 SQLite trial 表成对落库(completed=1闸门 + 只回放闭合行),闭环才真正"会自我改进"且可审计(铁律 5)。第 09 篇给了被优化的黑盒simulate,本篇给了驱动它的问价器;下一篇(第 11 篇)钻进 Ax 底下那层——手写 BoTorch 的SingleTaskGP+ExpectedImprovement/qExpectedImprovement,解释 Ax 默认生成器"看不见的地方",并处理批量建议与失败样本 mask。
本篇认知问题回显(FAQ)
Q1:贝叶斯优化的 ask/tell 在闭环里扮演什么角色,为什么适配昂贵仿真/实验?
A:它是 Learn 反哺 Design 的算法大脑:ask(get_next_trials)要一组"最有信息量"的建议参数,tell(complete_trial)回报该点实测/仿真值。昂贵场景下网格/随机搜索浪费预算,而它用 GP 代理模型记住已做/未探区域,每轮只挑最值得做的点,天然贴合闭环。
Q2:Ax 1.3.1 现行 API 的调用顺序是什么,Client 与 RangeParameterConfig 各负责什么?
A:from ax.api.client import Client会话对象负责实验编排;configure_experiment(name, parameters=[RangeParameterConfig(name, bounds, parameter_type)])声明搜索空间,configure_optimization(objective)定方向,循环get_next_trials/complete_trial,最后get_best_parameterization。
Q3:官方 Booth quickstart 实跑 20 轮给什么数,跟真值差多少?
A:configure_optimization(objective="-1 * booth")、目标(x1+2x2−7)²+(2x1+x2−5)²,跑满 20 轮get_best_parameterization()≈(1.49, 2.42),解析真值 (1, 3)。残差说明有限评估预算下的近似性,不是 bug。
Q4:默认 Center+Sobol+MBM(BoTorch) 生成策略意味着什么,和 BoTorch 什么关系?
A:不配生成器时 Ax 自动 Center 起点→Sobol 空间填充冷启动→MBM(节点 generator_enum=BoTorch)做模型建议,即 Ax 是 BoTorch 上层封装。想手写 GP 与采集函数、精确控制 q 批量,用第 11 篇的 BoTorch。
Q5:为什么 trial 落库是闭环命门,旧 ax.Client 顶层导入有什么坑?
A:ask 的建议参数与 tell 的回报必须成对入库(如 SQLitecompleted=1闸门、断言无completed=0行、恢复只回放completed=1),缺回报的 trial 不许进下轮拟合(铁律 5);顶层from ax import Client是旧式对象,与 1.3.1 现行ax.api.client.Client签名不同,混用会导致参数/方法对不上。