之前有不少人问过我:在 scikit-learn 里直接SVC.fit(X, y),y 传多类标签,跑出来结果还挺好,于是以为“支持向量机天生就支持多分类”。实际上原始 SVM 的数学形式只解决二分类,一个超平面把样本劈成正负两侧,它压根没有“类别 3”的概念。你在框架里拿到的多分类结果,全是框架在背后替你拆解、组合后的产物。
多分类-支持向量机(Multi-SVM)的三种实现策略,就是指“如何把一个多分类任务改造成多个二分类任务”的不同套路。这篇内容适合三类人:正在用 SVM 做分类但只停留在调库、没搞懂背后拆解逻辑的开发者;需要从 OvR、OvO、ECOC 里做选型的算法工程师;以及面试前想把多分类 SVM 讲清楚的求职者。我会把三种策略的原理、代码、优劣和坑全部拆开讲,最后给出我实际项目里的选型经验。
1. 先破题:SVM 天生是二分类模型,多分类拆解要回答的三个问题
1.1 超平面只会“一刀两断”
SVM 的核心是寻找一个最大化间隔的决策超平面,数学形式是:
[ \min_{w,b} \frac{1}{2}|w|^2 \quad \text{s.t.} \quad y_i(w\cdot x_i + b) \ge 1 ]
这里面 (y_i) 的取值只有 ( {-1, +1} ),所以原始优化问题的输出sign(w·x+b)只有两种结果。就算你把一个 20 分类任务的标签直接塞进去,SVM 也不知道“类别 3”和“类别 17”之间的关系——它只会机械地二分化。
1.2 拆解的基本思路:把多分类切成一组二分类
既然原生 SVM 只会做二分类,多分类就必须“拆开打”。业界主流的拆解路线有二:
- 按“一个类别 vs 其余所有”拆分,一个分类器对应一个类别,这就是 One-vs-Rest(OvR);
- 按“每两个类别互相打”拆分,每对类别训练一个分类器,再让它们投票,这就是 One-vs-One(OvO);
- 按“编码 + 解码”拆分,给每个类别分配一段二进制码,训练一组分类器后按距离解码,这就是纠错输出码(ECOC)。
这三条路线,就是本项目标题里“三种实现策略”的源头。
1.3 拆解之后必须解决的关键问题
拆分只是第一步。拆完之后,你立刻会遇到三个新问题,不同库的实现差异也恰恰体现在这里:
- 每个二分类器的训练集怎么构建?OvR 是“一类 vs 其余全量”,OvO 是“两类各取样本”,ECOC 是按编码列分组;
- 多个二分类器的输出如何融合?是取最大决策值、投票,还是比汉明距离?融合方式决定了最终预测的稳定性;
- 类别不平衡和决策值尺度不一致怎么处理?这是实践中踩坑最多的地方,比如 OvR 中负类样本远多于正类,或者两个分类器输出的“距离”根本不在同一个量纲上,直接比较没有意义。
理解这三个问题,再回头看三种策略的源码,就会豁然开朗。下面我逐个策略展开。
2. 策略一:One-vs-Rest(一对多)——思路最直白,但坑也最隐蔽
2.1 训练阶段:K 次二分类
假设总共有 K 个类别。OvR 的做法是训练 K 个 SVM:
- 第 i 个分类器把“类别 i”当作正类(+1);
- 把其余 K-1 个类别全部合并成负类(-1)。
预测时,K 个分类器全部对样本做决策,每个分类器输出一个决策值(样本到超平面的带符号距离),最后取决策值最大的类别作为预测结果。之所以取“最大”而不是“正类胜出”,是因为可能出现多个分类器都判正类的情况,这时候只能比谁更“自信”。
2.2 代码落地:10 行跑通 OvR
在 scikit-learn 里,OvR 最直接的实现是LinearSVC,它默认就是multi_class='ovr':
from sklearn.svm import LinearSVC from sklearn.datasets import load_iris from sklearn.model_selection import cross_val_score X, y = load_iris(return_X_y=True) # LinearSVC 默认 multi_class='ovr',不用额外设置 model = LinearSVC(random_state=42) scores = cross_val_score(model, X, y, cv=5) print("平均准确率:", scores.mean())还可以显式写出包装器OneVsRestClassifier,它对任意二分类器都有效:
from sklearn.svm import SVC from sklearn.multiclass import OneVsRestClassifier clf = OneVsRestClassifier(SVC(kernel='rbf')) clf.fit(X, y) decision = clf.decision_function(X) # 每列对应一个类别的决策值 print(decision.shape) # (n_samples, n_classes)2.3 OvR 的三个隐蔽坑
OvR 表面简单,但实际用起来有几个问题非常容易翻车:
坑一:负类合并导致的大类淹没。类别数 K 越大,每个二分类器里负类样本占比越高。假设 10 个类别且样本均衡,每个分类器的正类只占 1/10,负类占 9/10。class_weight='balanced'是对付这个问题最简单的手段,让 SVM 在训练时给正类错误更高的惩罚。
坑二:决策值无法跨分类器比较。SVM 输出的决策值是“样本到超平面的几何距离的度量”,但不同分类器的超平面位置、尺度都不一样。A 分类器给出决策值 0.7,B 分类器给出 0.5,能不能说“模型更倾向于 A”?严格来说不能,因为量纲不同。要跨模型比较,最好先把每个分类器的输出校准成概率,而不是直接对原始决策值取 argmax。
坑三:重叠区域与全负区域。一个样本可能被两个分类器同时判正,也可能被所有分类器判负。后者说明样本落入了决策边界之外的真空地带,这时候你只能选“负得最轻”的那个类别,置信度其实很低。如果业务对置信度敏感,务必要做概率校准。
提示:在类别较多或类别不平衡时,OvR 的默认做法容易产生系统性偏差。建议训练时加
class_weight='balanced',预测前用CalibratedClassifierCV做概率校准,再去取最大概率类。
2.4 什么场景优先选 OvR
我个人的经验是:当 K 不超过 10、类别相对均衡,或者特征维度很高、必须用线性核时,OvR 是务实首选。因为线性 SVM 在大规模稀疏特征(比如文本 TF-IDF)上训练极快,OvR 只训练 K 个模型,推理时也只需计算 K 个决策函数,时延完全可控。
3. 策略二:One-vs-One(一对一)——LibSVM 默认的“两两 PK”路线
3.1 训练阶段:N(N-1)/2 次配对比赛
OvO 的思路和 OvR 完全不同:它为每一对类别训练一个二分类器。K 个类别一共需要:
[ \frac{K(K-1)}{2} ]
个 SVM。每个子分类器只拿这两个类别的原始样本训练,负类不再“合并”,因此每个子问题都相当干净。预测时,所有子分类器对样本投票,最终得票最多的类别胜出;如果出现平票,实际实现中一般取索引较小的那个类别。
3.2 用 SVC 实现 OvO:默认就是它
注意一个细节:sklearn.svm.SVC的多分类内部实现就是 OvO,这是继承自 LIBSVM 的设计。你不需要额外设置multi_class,decision_function_shape只改变decision_function的返回列数,不改变训练方式:
from sklearn.svm import SVC model = SVC(kernel='rbf', decision_function_shape='ovo') model.fit(X, y) # ovr 形状:返回 (n_samples, n_classes) decision_ovr = model.decision_function(X) # ovo 形状:返回 (n_samples, n_classes*(n_classes-1)/2) model.set_params(decision_function_shape='ovo') decision_ovo = model.decision_function(X)如果你想让任意二分类器走 OvO 路线,可以用通用包装器OneVsOneClassifier:
from sklearn.multiclass import OneVsOneClassifier clf = OneVsOneClassifier(SVC(kernel='rbf')) clf.fit(X, y)3.3 为什么 LibSVM 在多分类上默认用 OvO?
核心原因有两个:
- 工程实现简单且天然局部。每个子问题只涉及两个类别的样本,训练数据量小,而且不会出现 OvR 那种“负类样本总数压过正类”的不平衡局面。
- 类别不平衡的缓解是结构性的。在 OvR 中,即使设置
class_weight,本质上还是在对“合并出来的负类”做补偿;而 OvO 中每个分类器只面对两个类,子问题天然更平衡。
代价也非常明显:
- 模型数量是 (O(K^2)),类别数一多立刻爆炸。20 个类别需要 190 个分类器,50 个类别需要 1225 个;
- 每个子分类器只见过两类样本,在全局边界附近缺乏“第三类”信息;
- 投票只数票数、不看“赢多少”,一个分类器以微弱优势获胜和以绝对优势获胜,权重一样。
3.4 概率校准与平票处理
如果你想用 OvO 拿概率,SVC(probability=True)会在 Platt 缩放基础上再做pairwise coupling,把两两比较的输出耦合为一个统一的概率分布。但这个开关会显著增加训练开销(内部要多做交叉验证来拟合缩放参数)。业务上只需要排序或取 Top-K 时,用decision_function就够了。
平票处理也值得留意:当多个类别得票一样多时,不同库可能有不同行为。scikit-learn 的 SVC 会返回classes_[argmax(vote_result)],在票数相同的情况下,索引小的类胜出。这在小数据集上不太容易被注意到,但在很多类别的任务里可能影响边界样本的最终输出。
3.5 什么场景优先选 OvO
我做过的分类实验中,样本量中等(几千到几万)、类别数不超过 15、需要非线性边界时,直接用SVC(kernel='rbf')默认的 OvO,精度通常比 OvR 稳定。原因还是那句话:RBF 核的局部决策函数配合“两两对决”,在复杂边界上的刻画能力更强。
4. 策略三:纠错输出码与原生多类 SVM——两条进阶路线
前两种策略本质上都是“拆解后再融合”。第三种策略可以走两条路:一条是ECOC(纠错输出码),工程上可操作性强;另一条是原生多类 SVM,直接修改目标函数,多个超平面一起联合优化。两条都值得理解。
4.1 ECOC:给每个类别发一段“密码”,用汉明距离解码
ECOC 的核心做法分三步:
- 编码:给每个类别分配一个长度为 L 的二进制码(L 通常不小于 K)。第 i 个类别的编码可以视为它在 L 个二分类任务上的“标签说明”;
- 训练:训练 L 个二分类器。第 j 个分类器把编码第 j 位为 1 的所有类别合并成正类,把第 j 位为 0 的所有类别合并成负类。注意,实践中常用三值编码 ({ -1, 0, +1}),0 表示该分类器直接忽略某个类别;
- 解码:预测时,L 个分类器输出一个长度为 L 的向量,把这个向量与每个类别的编码计算汉明距离,距离最小的类别获胜。
为什么叫“纠错输出码”?因为如果某个二分类器预测错了,输出向量的某些位就翻转了;只要翻转的位数不超过编码最小距离的一半,汉明距离解码仍然能找回正确的类别。这相当于给“二分类器集合”加了一层冗余保护。
一个简化版的自定义实现思路如下:
import numpy as np from sklearn.svm import SVC from sklearn.model_selection import train_test_split # 假设 K=4 个类别,L=6 个基分类器 rng = np.random.RandomState(0) K, L = 4, 6 codebook = rng.choice([-1, 1], size=(K, L)) def fit_ecoc(X, y, codebook): estimators = [] for j in range(codebook.shape[1]): # 第 j 列编码为 +1 的类视为正类,-1 视为负类 pos_mask = np.isin(y, np.where(codebook[:, j] == 1)[0]) neg_mask = np.isin(y, np.where(codebook[:, j] == -1)[0]) y_bin = np.zeros(len(y)) y_bin[pos_mask] = 1 y_bin[neg_mask] = -1 # 注意:有些类别可能被本列忽略(编码为 0) trainable = pos_mask | neg_mask clf = SVC(kernel='rbf') clf.fit(X[trainable], y_bin[trainable]) estimators.append(clf) return estimators def predict_ecoc(X, estimators, codebook): votes = np.array([clf.predict(X) for clf in estimators]).T # (n, L) pred = [] for v in votes: distances = [np.sum(v != code) for code in codebook] pred.append(np.argmin(distances)) return np.array(pred)如果不用汉明距离这种硬编码输出,还可以用连续决策值算欧氏距离,效果通常更好——因为决策值保留了“赢多少”的信息,而不是把每个分类器压成硬标签。
4.2 ECOC 的坑:码长、码随机性与过拟合
ECOC 的效果非常依赖编码矩阵设计:
- 码长 L 过小:纠错能力不足,解码结果对单个分类器错误很敏感;
- 码长 L 过大:训练成本线性上涨,而且可能出现“编码过于冗余、单个二分类器学到噪声”的问题;
- 随机码有方差:同一个任务,换一组随机码,最终精度可能波动。实际做法是生成多组候选码,用交叉验证挑一组效果最好的。
使用 ECOC 时,另一个容易忽略的点是:每个基分类器的训练集大小由编码决定。某些编码列可能只有很少的类别参与训练(大量 0),导致这个分类器的正负样本严重失衡。设计编码矩阵时,要尽量保证每一列的正负样本量接近。
4.3 原生多类 SVM:一次优化 K 个超平面
跳过“拆解 + 融合”,直接把多分类写成一个联合优化问题,这就是原生多类 SVM。它的核心思想是:训练 K 个超平面 (w_1, ..., w_K),要求对于样本 (x),真实类别 (y) 的超平面得分 (w_y \cdot x) 不仅要是最高的,还要比所有其他类别的得分至少高出一个 margin:
[ w_y \cdot x - w_{c} \cdot x \ge 1 - \xi, \quad \forall c \ne y ]
在 scikit-learn 里,LinearSVC(multi_class='crammer_singer')就是这种思路的一种工程实现。它只训练 K 个超平面,不需要两两配对。
优点很明显:理论统一、模型数量只有 K 个、没有 OvR 的负类合并偏斜。
缺点也很现实:
- 联合优化问题的求解通常比 OvR/OvO 更复杂,大规模数据上收敛慢;
- 工程库支持少。除了
LinearSVC提供这个选项,绝大多数框架不会默认使用它; - 在大量实测数据上,它的精度优势并不稳定。很多基准实验里,精心调参的 OvO 或 OvR 并不逊色于原生多类。
4.4 原生多类的定位:学术对照组,而非业务默认项
理解原生多类的价值在于:当你去读LinearSVC的multi_class参数文档、或者看到论文里出现“多类 margin 损失”时,不会一脸懵。但你不需要在业务系统里强行使用它。就我的经验而言,跑跑它和 OvR 的对比,更多是为了验证“拆解方案损失了多少信息”,而不是为了上线时选它。
5. 实战选型:三种策略在真实任务中的对比与切换建议
5.1 一张表看全景
| 策略 | 分类器数量 | 训练样本拆分 | 预测融合方式 | 主要优点 | 主要缺点 |
|---|---|---|---|---|---|
| OvR | K | 每类 vs 其余全量 | 取最大决策值 / 最大概率 | 模型少、推理快、接口简单 | 负类合并导致偏斜,决策值跨模型不可比 |
| OvO | K(K-1)/2 | 每对类别各取样本 | 投票 / pairwise 概率 | 子问题干净、精度通常稳定 | 模型数量平方级增长,训练耗时高 |
| ECOC | L(L ≥ K) | 按编码列分组 | 汉明距离 / 欧氏距离 | 有纠错能力、设计灵活 | 实现复杂,码长与码随机性需要调 |
| 原生多类 | K | 所有样本参与一个联合优化 | 最大得分类 | 理论统一、模型数少 | 库支持少,大规模收敛慢,收益不稳定 |
5.2 我自己用下来的选型经验
这里直接给结论,省得你在不同策略之间反复试:
场景一:小样本 + 非线性边界 + 类别数 ≤ 15。不要犹豫,用SVC(kernel='rbf'),它内部就是 OvO。RBF 核的局部刻画能力配合两两对决,通常能拿到稳定且不错的结果。牺牲一点时间,换来少踩坑。
场景二:高维稀疏特征 + 追求速度。文本分类、稀疏特征、几万几十万样本,优先LinearSVC默认 OvR,记得加class_weight='balanced'。我在一个文本多分类任务上,20 个类别、几万样本,同样精度要求下,LinearSVC(OvR) 训练只要几分钟,而 SVC(RBF) 的 OvO 要训练几百个核模型,跑几个小时都未必收敛。
场景三:类别数极多(50 以上)。OvO 直接不可行,K(K-1)/2 会迅速突破一千个分类器。OvR 是最务实的方案。如果业务对误分类方向特别敏感,可以考虑 ECOC 用冗余编码分摊单个分类器的失误。
场景四:需要概率输出或不确定性估计。用 OvR 配CalibratedClassifierCV,或者用 OvO 配SVC(probability=True)。注意概率校准会增加不少训练时间,如果只是排序或取 Top-K,用原始决策值即可。
5.3 与调参、不平衡、概率要求联动的几个提醒
最后再说几个我实际踩过的坑:
- 不要跨策略直接比较原始决策值。同一个
decision_function,在'ovo'和'ovr'输出形状和物理含义都不同,写代码时要注意列数; class_weight对所有策略都有意义,但作用位置不同。OvR 里它对抗“负类合并”的不平衡,OvO 里它处理“某一对类别本身样本量悬殊”,两者都需要分别设置;- SVC 的
probability=True会显著变慢。因为 Platt 缩放需要把训练集再交叉验证一遍。你在大数据集上想要概率,优先考虑 LinearSVC + CalibratedClassifierCV,而不是直接开 SVC 的概率开关; - 全局参数共享是三个策略共同的简化假设。你为整个模型搜出的 C 和 gamma 会应用到所有子分类器上,实际上每个子问题的最佳参数未必相同,但为每个子问题单独调参是不现实的。所以先花时间把全局参数调好,比纠结选哪种策略更重要。
我自己跑过不少分类任务,最深的感触是:在 iris 这种小数据集上,三种策略的差异几乎看不出来;一旦类别数超过 10、样本几千条、特征几百维,OvO 和 OvR 在训练时间上的差距就不是百分之几十,而是几倍到几十倍。有一次做一个 20 类别的业务分类,用 SVC(RBF) 的 OvO 跑了一整晚,换成 LinearSVC(OvR) 加 balanced 类别权重后五分钟跑完,精度只掉了 1% 出头。这种取舍,在真实业务里往往才是决定性的——别迷信“哪种策略理论最优”,先看清你的类别数、样本量和算力,再决定怎么拆解。