Meta Ax 实战:约束与多目标超参优化完整指南

AI教程22小时前更新 程序员阿超
284 0 0

一、背景:调参为什么不能只靠网格搜索

训练一个随机森林、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_experimentget_next_trialcomplete_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() 会接着历史继续提点

持久化的工程价值:调参实验经常要跑几天,存档后可以换机器继续、可以分享给同事复现、可以作为模型发布时的附属产物证明”这个超参数是怎么来的”。

五、常见坑

  1. 试验轮数太少就下结论:贝叶斯优化前 5~10 轮多是 Sobol quasi-random 热身,代理模型还没拟合好。30 轮是本教程演示量级,真实调参建议至少 50~100 轮,高维空间更多。
  2. 约束设得不可行:比如体积上限设 0.1MB,随机森林再小也超标,优化器永远找不到可行点。先跑几轮无约束摸底,看指标量级再设约束。
  3. 噪声 metric 不设重复:交叉验证本身有随机性,单轮观测噪声大时应在 create_experiment 里声明 tracking_metric 或对关键点做重复试验,否则采集函数会被噪声带偏。
  4. 对数尺度忘记开:学习率、正则系数这类跨数量级的参数不用 log_scale,90% 的试验会浪费在大值区。这是新手最常见的性能杀手。
  5. 类别参数爆炸:choice 超过十几个取值时 GP 建模吃力,考虑先做人工分组粗筛,再对头部几个取值精调。
  6. 只看最优点不看前沿:单目标最优往往是”脆弱的尖峰”,换个随机种子就掉下来。多目标前沿 + 候选点复测才是稳健做法。
  7. 随机种子不固定random_seed 和 sklearn 的 random_state 都要固定,否则复现实验时对不上数。
  8. 把测试集当目标:优化目标必须用验证集指标,测试集只在最后看一次。拿测试集做采集等于作弊,上线必翻车。

六、总结

  • Ax 的 Client API 把”混合搜索空间 + 约束 + 多目标”收敛成 ask-tell 循环,心智负担远小于手写贝叶斯优化。
  • 结果约束管”输出指标”(体积、延迟),参数约束管”输入组合”(配比、预算),两者可以叠加。
  • 多目标优化的产出物是帕累托前沿,最终选点是业务决策,不是数学决策。
  • 分析卡片 + JSON 持久化让实验可解释、可复现、可交接。
  • 下一步:把教程里的随机森林换成你自己的模型训练函数(XGBoost、LightGBM、甚至 LLM 的 decoding 参数),搜索空间和约束照搬,流程完全一样。

参考资料:Meta Ax GitHub 仓库与官方文档。 点击阅读原文

© 版权声明

相关文章

暂无评论

暂无评论...