这次我们来看一类偏理论、但直接决定模型该怎么选的问题:An Optimal Agnostic PAC Algorithm。如果你做机器学习理论、模型选择,或者想搞清楚“为什么 ERM(经验风险最小化)在很多场景下够用”,这篇文章值得往下看。
这里讨论的不是某个需要 24G 显存的大模型,而是一套关于样本复杂度最优的学习框架。它回答的问题很直接:当标签本身有噪声、目标概念不一定落在我们的假设类里时,我们要多少样本,才能保证学到的模型误差不超过“类内最优误差 + ε”。这个标准叫 Agnostic PAC,也就是不可知 PAC。
本文会做四件事:先讲清楚 Agnostic PAC 和“最优”到底指什么;再给出有限假设类和无限假设类下的样本复杂度结论;然后用 Python 写一个可运行的 ERM 加聚合算法骨架;最后给出一套验证流程和常见坑位。全文不依赖 GPU,单机 CPU 就能跑通,适合理论学习、课程实验和算法对比。
1. 核心概念速览
| 维度 | 说明 |
|---|---|
| 问题设定 | 不可知 PAC(Agnostic PAC)学习 |
| 学习目标 | 以至少 1-δ 的概率输出假设 h,使 R(h) ≤ OPT + ε |
| OPT 定义 | 假设类 H 内的最小真实风险,即 min R(h),不要求目标概念在 H 内 |
| 核心对象 | 假设类 H、样本复杂度、ERM、聚合算法、VC 维 |
| 最优含义 | 样本复杂度达到信息论下界,常数意义下最优 |
| 典型算法 | ERM、最小不一致假设、权重聚合、结构风险最小化 |
| 运行环境 | Python 3.9+,NumPy / scikit-learn,CPU 足够 |
| 适合场景 | 模型选择、理论验证、带噪标签分类、算法对比实验 |
这个表里的每一项,后面都会展开。第一步是把问题的定义理清楚。
2. 不可知 PAC 学习要解决什么问题
2.1 从标准 PAC 到不可知 PAC
标准 PAC 学习有一个强假设:存在一个目标概念 c,且 c 一定在假设类 H 中。学习器的目标是输出一个近似 c 的假设。这个设定在理论推导里很干净,但真实场景几乎都不满足:标签有噪声、特征表达不完整、模型族选错了,目标概念根本不在我们枚举的假设类里。
Agnostic PAC 去掉了这个约束。它不要求目标概念属于 H,只要求输出假设的风险接近 H 内最优假设的风险。真实风险定义为:
R(h) = E_{(x,y)~D}[ 1{h(x) ≠ y} ]假设类 H 内的最优风险是:
OPT = min_{h∈H} R(h)算法希望以至少 1-δ 的概率输出 h,满足:
R(h) ≤ OPT + ε这才是 Agnostic PAC 的核心目标。意味着即使数据存在不可消除的噪声,学习器也不比“假设类里最好的假设”差太多。
2.2 “最优”是什么意思
在计算学习理论里,“最优算法”通常不是指某个具体代码实现,而是指样本复杂度达到信息论下界。给定误差 ε 和置信度 δ,算法需要的最少样本量如果能同时匹配上界和下界,就称它在样本复杂度意义下最优。
对于有限假设类 |H|,下界是:
Ω((log|H| + log(1/δ)) / ε²)在常数因子范围内,ERM 可以匹配这个下界,所以它是统计意义上最优的。
这里要区分两个概念:统计最优和计算最优。理论上最优的 ERM,在实际计算中可能因为假设类复杂而 NP-hard。这也是后面引入聚合算法的原因之一:用可计算的近似方式换取接近最优的统计保证。
2.3 误差分解:近似误差与估计误差
理解不可知学习,要把总误差拆成两部分。
近似误差(approximation error)来自 H 本身不够强,即 OPT 不为 0。这不是算法能消除的,只能靠扩大假设类解决。估计误差(estimation error)来自有限样本带来的偏差,即算法输出的 h 与最优假设之间的差距。
Agnostic PAC 的目标是控制估计误差:
R(ĥ) - OPT ≤ εERM 在有限样本下做的事,就是用经验风险代替真实风险,在假设类里找一个经验风险最低的模型。只要样本量足够,经验风险会一致逼近真实风险,估计误差就会被压到 ε 以内。
3. 最优不可知 PAC 算法的理论框架
3.1 有限假设类:ERM 与 Union Bound
先看最简单的情况:H 是有限集合,比如 10 棵决策树、5 个 SVM 变体,一共 15 个候选模型。
对每个 h∈H,用 Hoeffding 不等式可以得到:
P(|R̂(h) - R(h)| > ε) ≤ 2exp(-2nε²)要让所有 h 同时成立,需要用 Union Bound 把所有假设的失败概率加起来:
P(∃h∈H, |R̂(h) - R(h)| > ε) ≤ 2|H|exp(-2nε²)令右侧等于 δ,可以反解出样本复杂度:
n ≥ (log|H| + log(2/δ)) / (2ε²)ERM 输出经验风险最低的假设 ĥ,那么:
R(ĥ) ≤ OPT + 2ε把 ε 换成 ε/2,就得到标准的 Agnostic PAC 上界。误差里出现 log|H| 而不是 |H|,说明假设类数量不要命,只要候选模型数量是有限的,ERM 的样本复杂度就只随 log|H| 增长。
3.2 无限假设类:VC 维与覆盖数
当 H 是无限集合时,log|H| 没有定义,需要用 VC 维或覆盖数来度量假设类的复杂度。VC 维刻画的是 H 能打散的最大样本数,直观理解是“假设类有多强的表达能力”。
无限假设类下的样本复杂度上界是:
n ≥ O((VC(H) + log(1/δ)) / ε²)对应下界同样包含 VC(H),因此 ERM 在无限假设类下也能达到最优量级。但如果 H 的 VC 维太大,比如一个表达能力过强的深度网络,估计误差会很大,模型就过拟合了。
这里引出一个实际建议:不要盲目扩大假设类。Agnostic PAC 的最优性说的是“给定 H 时 ERM 最优”,但 H 本身的复杂度同样进入样本复杂度。模型选择要在近似误差和估计误差之间做权衡,这就是结构风险最小化(SRM)的思路。
3.3 聚合算法:从在线学习到批量最优
ERM 统计最优,但计算可能困难。另一个方向是聚合算法,不直接选一个假设,而是给多个假设分配权重,输出加权投票结果。
经典范式来自在线学习的 Hedge 算法。每个假设当作一个专家,通过 multiplicative weights 更新权重,最后用加权多数投票输出。批处理版本需要在给定 n 个样本时构造一个聚合分布,其风险上界为:
R(agg) ≤ OPT + O(sqrt((log|H|) / n))从量级看,聚合算法和 ERM 一样能达到 O(log|H| / n) 级别的估计误差,但常数因子会更大。好处是计算上更友好,而且对“假设类里谁最优”这件事不需要提前知道。
如果 H 是无限集合,可以把聚合建立在覆盖数上:先用覆盖数把 H 离散化为有限集合,再在覆盖集合上做聚合。这样得到的算法仍然有可证明的样本复杂度上界。
4. 最小可运行的实验环境准备
这部分是实操。先准备环境,本文所有实验不依赖 GPU。
| 依赖 | 用途 | 版本建议 |
|---|---|---|
| Python | 运行环境 | 3.9 或更高 |
| NumPy | 数组与随机数 | 1.24 或更高 |
| scikit-learn | 分类模型、交叉验证、评估 | 1.3 或更高 |
安装命令:
pip install numpy scikit-learn如果想要复现更严谨的随机种子,建议同时固定 Python 的随机数,避免交叉验证划分不一致导致结果抖动。
5. 实现一个基础版最优不可知 PAC 算法
这里实现一个“有限假设类上的 ERM + 交叉验证”骨架,再加一个聚合版本。代码是教学模板,实际项目需要按自己的候选模型列表替换。
5.1 候选假设类
用 scikit-learn 自带模型构造一个有限的假设类集合:
from sklearn.tree import DecisionTreeClassifier from sklearn.linear_model import LogisticRegression from sklearn.svm import SVC CANDIDATE_MODELS = { "tree_depth_1": DecisionTreeClassifier(max_depth=1, random_state=42), "tree_depth_3": DecisionTreeClassifier(max_depth=3, random_state=42), "logistic": LogisticRegression(max_iter=1000), "svm_rbf": SVC(C=1.0, kernel="rbf", probability=True, random_state=42), }这里故意放入不同复杂度的模型,用来模拟一个常见的模型选择场景。注意,SVC的probability=True是为了后面聚合时能输出概率。
5.2 最小经验风险实现
import numpy as np from sklearn.model_selection import KFold, cross_val_score def agnostic_erm(X, y, candidates=None, cv_folds=5, random_state=42): """ERM + 交叉验证选择:返回验证误差最小、再在全量数据上训练的模型""" candidates = candidates or CANDIDATE_MODELS cv = KFold(n_splits=cv_folds, shuffle=True, random_state=random_state) best_model = None best_score = -np.inf scores = {} for name, model in candidates.items(): fold_scores = cross_val_score(model, X, y, cv=cv, scoring="accuracy") mean_score = fold_scores.mean() scores[name] = mean_score if mean_score > best_score: best_score = mean_score best_model = model.fit(X, y) return best_model, scores逻辑很简单:对每个候选假设,用同一组 K 折划分计算交叉验证准确率,取均值最高者,再在全部训练集上重新训练。这与理论上“最小化经验风险”的 ERM 略有差异,但工程上更稳,因为交叉验证能减少一次划分带来的方差。
5.3 加权聚合实现
聚合版本不需要选一个模型,而是让所有候选模型投票。这里用 scikit-learn 的软投票:
from sklearn.ensemble import VotingClassifier def agnostic_voting(X, y, candidates=None, random_state=42): """加权聚合:对所有候选模型的概率做软投票""" candidates = candidates or CANDIDATE_MODELS estimators = list(candidates.items()) ensemble = VotingClassifier(estimators=estimators, voting="soft") ensemble.fit(X, y) return ensemble注意,VotingClassifier默认每个模型权重相同。要接近理论上“根据经验表现分配权重”的聚合,需要手动构建权重,或者用weights参数传入交叉验证准确率。一个简单做法是先跑agnostic_erm拿到scores,再把准确率归一化后当作权重:
def agnostic_weighted_voting(X, y, candidates=None, cv_folds=5, random_state=42): candidates = candidates or CANDIDATE_MODELS _, scores = agnostic_erm(X, y, candidates, cv_folds, random_state) weights = np.array([scores[name] for name in candidates.keys()]) weights = np.clip(weights, 1e-6, None) weights = weights / weights.sum() estimators = list(candidates.items()) ensemble = VotingClassifier( estimators=estimators, voting="soft", weights=weights ) ensemble.fit(X, y) return ensemble这段代码的意义在于:它把“选择最优”改成了“按经验权重聚合”,对应理论里聚合算法的批处理版本。实际运行中它不一定比单个最优 ERM 更准,但通常更稳定。
6. 功能测试与效果验证
6.1 构造带噪声标签的合成数据
为了验证算法在“不可知”设定下的表现,用make_classification生成一组带标签翻转的合成数据:
from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split X, y = make_classification( n_samples=2000, n_features=8, n_informative=6, n_redundant=2, flip_y=0.2, random_state=0 ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42 )flip_y=0.2表示有约 20% 的标签被随机翻转。在这个合成数据里,即使假设类再强,真实风险也降不到 0,所以这是一个典型的 agnostic 场景。
6.2 验证 ERM 在有限假设类上的行为
运行 ERM 选择:
model, scores = agnostic_erm(X_train, y_train) print("候选模型交叉验证准确率:", scores) print("测试集准确率:", model.score(X_test, y_test))预期结果是:不同候选模型的交叉验证准确率有明显差异,ERM 会选择交叉验证得分最高的模型。判断成功的标准是测试集准确率与交叉验证得分差异不大。如果差异过大,优先怀疑数据划分泄漏、样本量不足或假设类过拟合。
6.3 观察样本量对泛化误差的影响
这是重点:验证样本复杂度增长带来的误差下降趋势。用不同规模的训练集重复实验:
sample_sizes = [100, 300, 500, 1000, 2000] for n in sample_sizes: subset_X, _, subset_y, _ = train_test_split( X, y, train_size=n, random_state=0, stratify=y ) model, scores = agnostic_erm(subset_X, subset_y) test_acc = model.score(X_test, y_test) print(f"n={n}, test_acc={test_acc:.4f}")不需要预设具体数字,但通常会观察到:样本量从 100 涨到 1000 时,测试准确率明显上升;再往后上升变缓。这个趋势与不可知 PAC 的样本复杂度 O(log|H| / ε²) 一致:误差减半所需的样本量大致按平方增长。
6.4 对比 ERM 与聚合
再对比一下“选择最优”和“加权聚合”:
erm_model, _ = agnostic_erm(X_train, y_train) vote_model = agnostic_weighted_voting(X_train, y_train) print("ERM 测试准确率:", erm_model.score(X_test, y_test)) print("聚合测试准确率:", vote_model.score(X_test, y_test))单次实验里结果可能互有胜负,更严谨的做法是多次换随机种子跑,然后比较均值和方差。聚合的优势通常体现在方差上,少数几次实验里不明显。
判断实验是否成功的标准很明确:ERM 选中的模型至少不能显著差于随机选择;聚合模型在多次重复中不应出现极端坏结果。如果 ERM 选出来的模型测试准确率反而最低,说明交叉验证划分或候选模型集合配置有问题。
7. 接口设计与批量任务
上面的函数已经具备接口雏形。实际工程里,可以把算法封装成统一入口:
def agnostic_pac_fit(X, y, mode="erm", candidates=None, cv_folds=5, random_state=42): if mode == "erm": return agnostic_erm(X, y, candidates, cv_folds, random_state) if mode == "voting": return agnostic_weighted_voting(X, y, candidates, cv_folds, random_state) raise ValueError(f"unknown mode: {mode}")批量任务可以这样组织:把不同的样本量、噪声比例、候选模型列表写成一个配置,循环执行并记录结果。
results = [] configs = [ {"n_samples": 500, "flip_y": 0.1, "mode": "erm"}, {"n_samples": 500, "flip_y": 0.2, "mode": "erm"}, {"n_samples": 1000, "flip_y": 0.2, "mode": "voting"}, ] for cfg in configs: X, y = make_classification( n_samples=cfg["n_samples"], n_features=8, n_informative=6, n_redundant=2, flip_y=cfg["flip_y"], random_state=0 ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42 ) if cfg["mode"] == "erm": model, scores = agnostic_pac_fit(X_train, y_train, mode="erm") else: model = agnostic_pac_fit(X_train, y_train, mode="voting") results.append({ **cfg, "test_acc": model.score(X_test, y_test), }) print(results)跑批量任务时建议加日志和失败重试。比如某个候选模型在特定数据上不收敛,应该捕获异常并跳过,而不是让整个实验中断。
8. 资源占用与性能观察
这一节不涉及显存,但同样需要关注资源。Agnostic PAC 算法的计算开销主要由三部分构成:
| 开销来源 | 影响因素 | 降低方式 |
|---|---|---|
| 交叉验证训练 | 候选模型数量 × 折数 | 减少候选模型、减少折数、并行计算 |
| 模型拟合 | 样本量、特征维度 | 降采样、特征筛选 |
| 聚合预测 | 候选模型数量 | 剪枝、权重稀疏化 |
可以这样粗略估算:如果候选模型有 10 个,K 折是 5,那么一次函数调用最多触发 50 次训练,实际还有一次全量训练。几百到几千条样本、8 个特征时,训练通常在秒级完成;具体耗时以本机测试为准。
观察训练耗时可以用简单的时间戳:
import time start = time.perf_counter() model, scores = agnostic_erm(X_train, y_train) elapsed = time.perf_counter() - start print(f"训练耗时:{elapsed:.3f}s")内存占用方面,这种规模的数据集不会构成压力。特征维度上升到几万、候选模型变成随机森林或核 SVM 时,才需要关注内存。常规手段是限制候选模型规模、使用线性模型做快速筛选、或者对特征做 PCA 降维。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 交叉验证结果波动大 | 折数太少、样本不均衡、随机种子不同 | 打印每折得分 | 增大折数、使用分层采样、固定随机种子 |
| 训练很慢 | 候选模型复杂、样本量大 | 统计每轮耗时 | 降采样、减少候选模型、并行训练 |
| ERM 选出的模型在测试集上很差 | 假设类过拟合、交叉验证泄漏 | 对比训练集与测试集准确率 | 降低模型复杂度、使用结构风险最小化 |
| 验证准确率和测试准确率差距大 | 数据分布不一致、划分不随机 | 检查数据切分逻辑 | 使用分层 train_test_split、避免时间泄漏 |
| 聚合结果不如单个最优模型 | 聚合权重分配不合理 | 打印各候选模型得分与权重 | 按交叉验证准确率归一化权重 |
| 某些候选模型训练报错 | 数据特征不适合该模型 | 单独测试该模型 | 捕获异常、跳过失败模型 |
| 增加样本后准确率不再提升 | 近似误差占主导 | 观察 OPT 是否远大于 0 | 扩大假设类或增加特征表达 |
其中最容易踩的坑是交叉验证泄漏:如果先在全量数据上做特征缩放,再划分训练集和测试集,验证结果会虚高。本文实验没有做特征缩放,所以不涉及这个问题。如果你用自己的数据,务必把预处理放进交叉验证循环内部。
10. 最佳实践与使用建议
- 先在小样本、低噪声数据上跑通 ERM,确认候选假设类、交叉验证、评估流程都正常,再进入正式实验。
- 固定随机种子。Agnostic PAC 的结论是概率性的,单次实验不能说明问题,多次重复取均值才有意义。
- 候选假设类从小到大逐步加。先放两个简单模型,确认代码无误,再引入复杂模型。
- 把数据集、候选模型、交叉验证折数、随机种子、测试准确率记录成表。批量实验尤其要留日志。
- 聚合不一定总赢过 ERM,但更稳。如果只关心“选一个模型上线”,用 ERM;如果关心稳定性,用加权聚合。
- 当假设类本身很强但样本不足时,优先考虑减少模型复杂度,而不是继续加候选模型。样本复杂度随 VC 维增长。
11. 总结与下一步
An Optimal Agnostic PAC Algorithm 的核心结论可以浓缩成一句话:在不可知设定下,ERM 已经达到样本复杂度的最优量级,而聚合算法提供了一种计算上更稳的近似实现。它不是某个现成模型,而是一种判断“算法好不好”的标准。
如果只验证一个功能,建议先跑通第 6 节的样本量实验,亲眼看一下误差随样本量的变化曲线。最值得踩的坑是交叉验证泄漏,一定要在划分之后再做任何预处理。
下一步可以看两个方向:一是从有限假设类扩展到无限假设类,理解 VC 维和覆盖数如何替代 log|H|;二是把在线学习的聚合算法改写成批处理版本,重新推导权重更新过程和风险上界。这套框架理解到位之后,再去看深度学习里的模型选择、早停和正则化,会有完全不同的视角。