一、背景:调参为什么不能只靠网格搜索
训练一个随机森林、XGBoost 或者微调一个小模型,超参数空间动不动就是几十种组合:树的数量、最大深度、学习率、最小分裂样本……网格搜索在高维空间里是指数灾难,随机搜索又撞大运。更麻烦的是真实需求从来不是”准确率最高”这么单纯:模型要能塞进手机(体积约束)、推理要在 50ms 内(延迟约束)、准确率和体积本身就是矛盾的(多目标权衡)。
Meta 开源的 Ax(Adaptive Experimentation) 就是为这种”带约束、多目标、混合类型”的优化问题设计的:它用贝叶斯优化做代理建模,每一轮根据历史试验结果智能地提下一个最值得试的参数组合;支持整数、浮点、对数尺度、类别、序数等混合搜索空间;支持结果约束(模型体积 < X)、参数约束(x1 + x2 < b);支持多目标并给出帕累托前沿;现代 Client API(ask-tell 接口)把整套流程收敛成几十行代码。
本教程用一份完整可跑的代码,带你走完:混合搜索空间定义 → 约束单目标优化 → 多目标优化与帕累托前沿 → 参数约束 → 内置分析与实验持久化。
二、原理:贝叶斯优化与 Ax 的核心概念
2.1 贝叶斯优化一句话
目标函数(比如交叉验证准确率)很贵——每次求值都要训练模型。贝叶斯优化用一个便宜的代理模型(通常是高斯过程 GP)去拟合”参数 → 指标”的映射,再用采集函数(如期望改进 EI、置信上界 UCB)决定下一个试哪里:在”有潜力的未知区域”(探索)和”已知的好区域附近”(利用)之间权衡。Ax 默认用 BoTorch 做 GP 建模,对噪声观测、中小规模试验(几十到几百轮)效果最好。
2.2 Ax 的三层 API
- Client API(推荐新手):
AxClient,ask-tell 循环,create_experiment→get_next_trial→complete_trial,约束、多目标都在创建实验时声明。 - Service API:更细的调度控制。
- Developer API(Models/Models 模块):自定义代理模型与采集函数,给研究者用。
本教程只用 Client API,覆盖 95% 的工程需求。
2.3 约束的两种形态
- 结果约束(Outcome constraint):对优化目标之外的指标设限,如
model_size <= 2.0MB。优化器会建模约束指标,只在可行域里找最优。 - 参数约束(Parameter constraint):对输入参数的线性约束,如
x1 + x2 <= 1。优化器直接不在不可行区域采样。
2.4 多目标与帕累托前沿
准确率和体积不可兼得时,不存在”唯一最优”,只存在一组帕累托最优解:任何一个目标变好都必然让另一个变差。Ax 的多目标优化(qNEHVI 等采集函数)直接搜索这条前沿,实验者最后按业务偏好在前沿上选点。
三、环境准备
pip install ax-platform scikit-learn matplotlib pandas torch
版本提示:Ax 依赖 BoTorch/GPyTorch(torch 生态),首次安装较慢,建议 Python 3.10+、pip 换国内镜像。验证安装:
from ax.service.ax_client import AxClient
print("Ax import OK")
Colab 用户注意:Ax 日志较啰嗦,教程代码里会关掉警告、只保留关键输出;Jupyter 里跑多轮随机森林注意控制 n_trials,CPU 上 30 轮约几分钟。
四、分步实战
主线任务:在一份合成多分类数据上调随机森林,准确率越高越好,模型体积越小越好。
4.1 步骤一:数据集、评估函数与搜索空间
# 0. 基础设置
import warnings, logging
warnings.filterwarnings("ignore")
logging.getLogger("ax").setLevel(logging.WARNING)
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
import pickle
SEED = 42
np.random.seed(SEED)
# 1. 合成数据:1000样本、20特征、三分类
X, y = make_classification(n_samples=1000, n_features=20, n_informative=12,
n_redundant=4, n_classes=3, random_state=SEED)
# 2. 评估函数:同时返回准确率与模型体积(MB)
def evaluate_rf(n_estimators: int, max_depth, min_samples_split: float,
criterion: str, max_features: float) -> dict:
clf = RandomForestClassifier(
n_estimators=int(n_estimators),
max_depth=None if max_depth == 0 else int(max_depth),
min_samples_split=float(min_samples_split),
criterion=criterion,
max_features=float(max_features),
n_jobs=-1, random_state=SEED)
acc = float(cross_val_score(clf, X, y, cv=3).mean())
clf.fit(X, y) # 拟合一次用于度量体积
size_mb = len(pickle.dumps(clf)) / 1024 / 1024
return {"accuracy": acc, "model_size": size_mb}
# 3. 混合搜索空间:整数 / 整数(0表无限制)/ 浮点 / 对数尺度 / 类别
SEARCH_SPACE = [
{"name": "n_estimators", "type": "range", "bounds": [50, 300],
"value_type": "int"},
{"name": "max_depth", "type": "range", "bounds": [0, 20],
"value_type": "int"}, # 0 = 不限深度
{"name": "min_samples_split", "type": "range", "bounds": [0.01, 0.5],
"value_type": "float", "log_scale": True}, # 对数尺度:小值区采样更密
{"name": "criterion", "type": "choice",
"values": ["gini", "entropy"]}, # 类别参数
{"name": "max_features", "type": "range", "bounds": [0.2, 1.0],
"value_type": "float"},
]
设计讲解:
min_samples_split用对数尺度,因为它在 0.01~0.1 区间最敏感,线性采样会浪费大量试验在大值区。max_depth=0这种”特殊值”技巧在 Ax 里很常见:把”不限深度”编码进整数范围,避免再加一个布尔开关。- 类别参数(criterion)Ax 会自动做 one-hot/嵌入处理,无需手动编码。
再封装一个可复用的 ask-tell 运行器,后面三个实验共用:
def run_study(client, n_trials: int, eval_fn):
for i in range(n_trials):
params, trial_index = client.get_next_trial()
out = eval_fn(**params)
client.complete_trial(trial_index=trial_index, raw_data=out)
return client
4.2 步骤二:约束单目标优化(准确率最高,且体积 ≤ 2MB)
from ax.service.ax_client import AxClient, ObjectiveProperties
client = AxClient()
client.create_experiment(
name="rf_constrained",
parameters=SEARCH_SPACE,
objectives={"accuracy": ObjectiveProperties(minimize=False)},
# 结果约束:模型体积不超过 2MB(Ax 约束写成 metric <= bound 形式)
outcome_constraints=["model_size <= 2.0"],
random_seed=SEED,
)
run_study(client, n_trials=30, eval_fn=evaluate_rf)
best_params, best_values = client.get_best_parameters()
print("最优可行参数:", best_params)
print("最优可行指标:", best_values)
# 最优可行指标形如 ({'accuracy': 0.87}, {'model_size': 1.6}) ——
# 第一个 dict 是目标值,第二个是约束指标的预测均值
关键点:get_best_parameters 返回的是可行域内的最优,即使某轮准确率更高但体积超标,也不会被选为最优。把”最优可行准确率随试验轮数的变化”画出来,就是收敛曲线:
trials = client.experiment.trials_by_status
feasible_best, running = [], -1
order = sorted(trials.keys())
accs, sizes = [], []
for t in order:
arm = trials[t].arms[0]
# 从试验存档里取真实观测
data = trials[t].fetch_data().df
acc = float(data[data.metric_name == "accuracy"]["mean"].iloc[0])
sz = float(data[data.metric_name == "model_size"]["mean"].iloc[0])
accs.append(acc); sizes.append(sz)
if sz <= 2.0:
running = max(running, acc)
feasible_best.append(running)
plt.figure()
plt.plot(order, feasible_best, marker="o")
plt.xlabel("trial"); plt.ylabel("best feasible accuracy")
plt.title("Constrained optimization convergence")
plt.grid(True); plt.show()
你会看到曲线 early 阶段可能是 -1(还没遇到可行点),随后单调上升——这是约束优化的典型形态:先找可行域,再在可行域里爬坡。
4.3 步骤三:多目标优化(准确率 ↑,体积 ↓)与帕累托前沿
mo_client = AxClient()
mo_client.create_experiment(
name="rf_multiobjective",
parameters=SEARCH_SPACE,
objectives={
"accuracy": ObjectiveProperties(minimize=False),
"model_size": ObjectiveProperties(minimize=True),
},
random_seed=SEED,
)
run_study(mo_client, n_trials=30, eval_fn=evaluate_rf)
# 收集全部观测点
pts = []
for t, trial in mo_client.experiment.trials_by_status.items():
df = trial.fetch_data().df
acc = float(df[df.metric_name == "accuracy"]["mean"].iloc[0])
sz = float(df[df.metric_name == "model_size"]["mean"].iloc[0])
pts.append((sz, acc))
pts = np.array(pts)
# 经验帕累托前沿:不被任何点全面支配的点
def pareto_front(points, maximize_y=True):
order = np.argsort(points[:, 0]) # 体积从小到大
front, best_y = [], -np.inf
for i in order:
y = points[i, 1]
if y > best_y:
front.append(i); best_y = y
return points[front]
front = pareto_front(pts)
plt.figure()
plt.scatter(pts[:, 0], pts[:, 1], label="all trials")
plt.plot(front[:, 0], front[:, 1], marker="o", label="pareto frontier")
plt.xlabel("model size (MB)"); plt.ylabel("accuracy")
plt.legend(); plt.grid(True); plt.show()
读图方法:前沿左端是”极小但稍弱”的模型,右端是”极强但臃肿”的模型,拐点(knee)处往往是性价比最高的选择。工程落地时把前沿上 3~5 个候选点全量复测一次再定版,因为单轮交叉验证有噪声。
进阶:Ax 内置 client.get_pareto_optimal_parameters() 可直接拿帕累托最优点,无需手写上面的过滤函数;手写版本的价值是让你理解”支配”关系的定义。
4.4 步骤四:参数约束(二元合成问题,x1 + x2 ≤ 1)
toy_client = AxClient()
toy_client.create_experiment(
name="toy_constrained",
parameters=[
{"name": "x1", "type": "range", "bounds": [0.0, 1.0]},
{"name": "x2", "type": "range", "bounds": [0.0, 1.0]},
],
objectives={"objective": ObjectiveProperties(minimize=True)},
# 参数约束:线性不等式字符串
parameter_constraints=["x1 + x2 <= 1.0"],
random_seed=SEED,
)
def toy_eval(x1: float, x2: float):
# 目标:离 (0.7, 0.7) 越近越好,但该点本身不可行 → 最优应在边界上
return {"objective": (x1 - 0.7) ** 2 + (x2 - 0.7) ** 2}
run_study(toy_client, n_trials=20, eval_fn=toy_eval)
print(toy_client.get_best_parameters())
# 预期:x1+x2≈1 且接近 (0.5, 0.5),即目标点在约束边界上的投影
这个玩具例子验证了参数约束的语义:优化器根本不会去采样 x1+x2>1 的区域,最优点落在边界上。真实场景的例子:两种原料配比之和不超过 100%、两个子预算之和不超过总预算。
4.5 步骤五:内置分析工具与实验持久化
# 分析卡片:灵敏度、交叉验证诊断等(环境支持时生成)
try:
from ax.analysis import display_cards
from ax.analysis.plotly import SensitivityAnalysisCard, CrossValidationAnalysisCard
cards = [
SensitivityAnalysisCard(experiment=client.experiment,
generation_strategy=client.generation_strategy),
]
display_cards(client.experiment, cards=cards)
except Exception as e:
print("分析卡片在当前环境不可用,跳过:", e)
# 持久化:存 JSON,随处复现
client.save_to_json_file("rf_constrained.json")
restored = AxClient.load_from_json_file("rf_constrained.json")
print("恢复后最优:", restored.get_best_parameters())
# 还可以继续优化:restored.get_next_trial() 会接着历史继续提点
持久化的工程价值:调参实验经常要跑几天,存档后可以换机器继续、可以分享给同事复现、可以作为模型发布时的附属产物证明”这个超参数是怎么来的”。
五、常见坑
- 试验轮数太少就下结论:贝叶斯优化前 5~10 轮多是 Sobol quasi-random 热身,代理模型还没拟合好。30 轮是本教程演示量级,真实调参建议至少 50~100 轮,高维空间更多。
- 约束设得不可行:比如体积上限设 0.1MB,随机森林再小也超标,优化器永远找不到可行点。先跑几轮无约束摸底,看指标量级再设约束。
- 噪声 metric 不设重复:交叉验证本身有随机性,单轮观测噪声大时应在
create_experiment里声明tracking_metric或对关键点做重复试验,否则采集函数会被噪声带偏。 - 对数尺度忘记开:学习率、正则系数这类跨数量级的参数不用
log_scale,90% 的试验会浪费在大值区。这是新手最常见的性能杀手。 - 类别参数爆炸:choice 超过十几个取值时 GP 建模吃力,考虑先做人工分组粗筛,再对头部几个取值精调。
- 只看最优点不看前沿:单目标最优往往是”脆弱的尖峰”,换个随机种子就掉下来。多目标前沿 + 候选点复测才是稳健做法。
- 随机种子不固定:
random_seed和 sklearn 的random_state都要固定,否则复现实验时对不上数。 - 把测试集当目标:优化目标必须用验证集指标,测试集只在最后看一次。拿测试集做采集等于作弊,上线必翻车。
六、总结
- Ax 的 Client API 把”混合搜索空间 + 约束 + 多目标”收敛成 ask-tell 循环,心智负担远小于手写贝叶斯优化。
- 结果约束管”输出指标”(体积、延迟),参数约束管”输入组合”(配比、预算),两者可以叠加。
- 多目标优化的产出物是帕累托前沿,最终选点是业务决策,不是数学决策。
- 分析卡片 + JSON 持久化让实验可解释、可复现、可交接。
- 下一步:把教程里的随机森林换成你自己的模型训练函数(XGBoost、LightGBM、甚至 LLM 的 decoding 参数),搜索空间和约束照搬,流程完全一样。
参考资料:Meta Ax GitHub 仓库与官方文档。 点击阅读原文