简介:面向机器学习和模式识别学习者的 MATLAB 实践代码集,围绕支持向量机完成数据三分类任务,适合已经了解 SVM 二分类原理、希望进一步掌握多分类策略实现细节的读者。压缩包内共 6 个文件,由 5 个 m 脚本和 1 个 data 数据文件组成,体积约 4KB;这些脚本分别承担模型训练、核函数定义、分类器封装与结果可视化等职责,数据文件是经典的 iris 鸢尾花样本,加载后即可运行完整流程。已有 6351 人学习下载,在同类实验素材中具备一定参考价值。代码采用一对一策略,将三分类拆解为多个二分类子问题,借助 fitcsvm 与 predict 完成训练和预测,并预留了核函数切换、参数调整与分类边界绘图的扩展空间,便于读者对照理解支持向量、超平面与最大间隔等概念,也可作为课程实验或小型项目的起步框架。
1. 三分类的 SVM,能跑通不代表跑对
三分类的 SVM,十个人里有八个是先fit再predict,看着 0.9 的准确率就结束。真上手才发现:三个类的决策边界由三场“一对一”比赛共同决定,而不只是三条线;票数打平的时候,谁赢由底层实现决定,跟你的直觉无关。这份资源把 SVM 落到三分类场景的完整链路拆开了——OvR 与 OvO 的拆分类逻辑、C与gamma在三类数据上的耦合、标准化为什么必须放进 Pipeline、混淆矩阵里那些看着正常其实已经错位的格子。适合已经会调用 sklearn、但说不清“三分类到底训练了几个模型”的人。
2. 从二分类到三分类:到底拆出几个模型、票怎么算
二分类的 SVM 是求一个超平面,把两类样本推到间隔两侧;三分类没有“一个超平面切三块”这种便利,只能拆。拆法有两种,选哪种直接决定训练成本、类别不平衡的敏感度,以及你后面读decision_function时会不会读错。
2.1 OvR 与 OvO:三分类下数量一样,性质完全不同
OvR(One-vs-Rest)对每个类训练一个分类器,正类是它自己,负类是剩下所有类的并集。k 个类就是 k 个分类器,每个分类器都见全部样本。OvO(One-vs-One)则对每一对类训练一个分类器,k 个类有 k(k-1)/2 个。三分类是个很特殊的点:两种拆法都是 3 个分类器,所以光看模型数量根本区分不出来,得看每个分类器吃了哪些样本。
| 对比项 | OvR(一对多) | OvO(一对一) |
|---|---|---|
| 三分类下的分类器数量 | 3 | 3 |
| 每个分类器见到的样本 | 全部样本 | 只有对应两个类的样本 |
| 单次训练规模 | 大 | 小 |
| 类别不平衡敏感度 | 高,每次都是大类 vs 其余 | 相对低,只和相邻类比 |
| 决策规则 | 取决策值最大的类 | 投票 |
| sklearn 里真正走这条路的 | OneVsRestClassifier(SVC(...)) | SVC内部默认 |
四分类之后差距就拉开了:OvR 是 4 个,OvO 是 6 个;十分类是 10 对 45。所以常见做法是:类别数不多、每类样本量也还行的时候,OvO 更稳;类别多、单类样本少的时候 OvO 的训练集会被切得很碎,某些对子上只剩十几个样本,这时候 OvR 反而更靠谱。
这里有个必须说清楚的事实:sklearn 的SVC内部始终是 OvO(底层走 libsvm),decision_function_shape这个参数不改变训练方式,它只改变输出形状。很多人看到decision_function_shape="ovr"就以为自己在训 OvR,然后在特征分析、阈值调整上全用错了前提。真要训 OvR,得显式套一层OneVsRestClassifier。另外LinearSVC有multi_class参数,可选"ovr"(默认)和"crammer_singer"(联合优化所有类),后者理论上更“正确”,实测多数场景提升有限,还慢,我不太建议一上来就用。
2.2 decision_function 的形状与投票规则
三分类下两种形状都是(n_samples, 3),但列的语义完全不同,混用一定出错。OvO 形状的三列依次对应(类0, 类1)、(类0, 类2)、(类1, 类2)三场比赛的带符号距离,正号表示判给前面的类;OvR 形状的三列是“该类对其余类”的置信度,数值越大越像这个类。
import numpy as np from sklearn.svm import SVC X = np.random.RandomState(0).randn(120, 4) y = np.repeat([0, 1, 2], 40) # 三分类,每类 40 个样本 m_ovo = SVC(kernel="rbf", decision_function_shape="ovo").fit(X, y) m_ovr = SVC(kernel="rbf", decision_function_shape="ovr").fit(X, y) print(m_ovo.decision_function(X[:2]).shape) # (2, 3):三场比赛的决策值 print(m_ovr.decision_function(X[:2]).shape) # (2, 3):三个类的置信度 print(m_ovo.predict(X[:5]), m_ovr.predict(X[:5])) # 两者 predict 完全一致最后一行是关键:形状变了,predict结果一模一样,因为投票还是那套投票。OvO 的投票逻辑很朴素——每场比赛赢的一方得 1 票,三场比赛共 3 票,得票最多的类胜出。三分类下每类只参加 2 场,所以票型只可能是(2,1,0)或(1,1,1)。前者直接定胜负,后者是平票,libsvm 的处理是按类索引顺序取第一个。这是个真会咬人的细节,后面避坑章节单独说。
OvR 形状的置信度是这么来的:把每场比赛的决策值折算成对某一类的“支持度”,再叠上票数,置信度项被缩放到远小于 1,作用只是平票时兜底。所以别把它当概率看,也别拿它做阈值切分。
2.3 软间隔、C 与 gamma 在三分类下的耦合
二分类里C和gamma的关系已经够拧巴,三分类更麻烦,因为三场比赛的最优参数并不一致。C控制对误分类的惩罚:C 大,间隔窄,训练集拟合得死;C 小,间隔宽,容忍更多越界点。gamma是 RBF 核的局部影响半径,gamma 大则每个样本只影响自己附近一小圈。
这两个参数是乘性耦合的:C大 +gamma大,模型会给每个样本造一个孤立的小岛,训练集 100% 正确,测试集塌得很难看;C小 +gamma小,决策面接近线性,三个类糊成一团,混淆矩阵里中间那一坨全是互相串的。我一般先用C在[0.1, 1, 10, 100]上扫,gamma固定"scale",找到量级之后再对gamma细扫,反着来也试一次,取 macro-F1 稳的那组。
gamma="scale"的默认值是1 / (n_features * X.var()),注意它挂在X.var()上——没标准化之前,同一份特征换个量纲(比如把“米”改成“毫米”),gamma就跟着变,结果自然对不上。这是很多人复现别人代码复现不出来的真正原因,不是随机种子的问题。要么把标准化塞进 Pipeline,要么把gamma写成一个固定数字。
class_weight="balanced"在多分类里的行为也值得说一句:它的权重是n_samples / (n_classes * bincount(y)),算出来是每个类的全局权重,但在 OvO 训练时,每一场比赛只把对应两类的权重喂进去。所以三分类严重不平衡时,OvO 和 OvR 对同一组class_weight的反应不一样,不能拿二分类的经验直接套。
3. 跑通三分类:从造数据到 Pipeline 的完整骨架
概念清楚了,落到代码。这一章给的是能直接抄的骨架,包含造数、建模、以及一个手写 OvO,用来验证你对投票的理解和 sklearn 是否一致。手写这一步看着多余,但它是唯一能让你确认“我读的decision_function到底是哪三列”的办法。
3.1 造一份带重叠区、带不平衡的三分类数据
用完全线性可分的数据练手毫无意义,三个类会被切得干干净净,参数怎么调都是 1.0,什么也学不到。我一般造带重叠、带权重倾斜的数据,把问题提前暴露出来。
import numpy as np from sklearn.datasets import make_classification X, y = make_classification( n_samples=900, n_features=6, n_informative=4, # 只有 4 维真正携带判别信息 n_redundant=1, # 1 维是其他特征的线性组合,制造共线性 n_classes=3, n_clusters_per_class=1, class_sep=0.85, # 类间距离,小于 1 就会有明显重叠 weights=[0.6, 0.3, 0.1],# 类别不平衡:6:3:1 flip_y=0.02, # 2% 标签噪声,模拟真实脏数据 random_state=42, ) print(np.bincount(y)) # [540 270 90],确认不平衡已经造出来了class_sep和flip_y是最该调的两个旋钮。前者控制难度,0.8~1.0 之间出来的决策边界有弧度但不至于全糊;后者制造标签噪声,能让你直观看到 SVM 对噪声点的敏感程度——噪声点会被当成难样本,直接顶到间隔里,C一大就过拟合到这些点上。
3.2 Pipeline:标准化与 SVC 的最小闭环
标准化必须放在 Pipeline 里面,这不是代码风格问题,是评估结果真不真问题。把StandardScaler在外面fit_transform完全量数据、再送进交叉验证,验证折的均值和方差已经泄漏进训练过程,CV 分数会明显虚高。
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.25, stratify=y, random_state=42 # stratify 保证三类的比例一致 ) pipe = Pipeline([ ("scaler", StandardScaler()), # 在每一折内部 fit,不泄漏 ("svc", SVC( kernel="rbf", C=1.0, gamma="scale", decision_function_shape="ovr", # 只影响输出形状,训练仍是 OvO class_weight="balanced", # 6:3:1 的不平衡必须处理 cache_size=500, # MB,样本上万时调大能明显提速 )), ]) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(pipe, X_tr, y_tr, cv=cv, scoring="f1_macro", n_jobs=-1) print(scores.mean().round(4), scores.std().round(4))参数逐个交代一下。kernel="rbf"是三分类的默认选择,特征维度不高、边界带弧度时比线性核强,代价是要多调一个gamma。class_weight="balanced"直接把权重按类别频率反比给进去,6:3:1 的数据里小类权重会是大类的 6 倍左右,小类的召回能明显拉起来。cache_size是 libsvm 的核矩阵缓存,默认 200MB,样本量过万、gamma又小的时候核矩阵会反复重算,调到 500~1000 常有肉眼可见的加速。n_jobs=-1是折间并行,不是分类器内部并行,别指望它加速单次fit。
scoring="f1_macro"而不是accuracy,是因为三分类不平衡时 accuracy 会被大类绑架。6:3:1 的数据,模型把 90 个小类全判错、只把 60% 的大类判对,accuracy 也能有 0.5 以上,而 macro-F1 会直接掉到 0.3 附近,问题立刻暴露。
3.3 手写 OvO,验证你对投票的理解
下面这段用来对齐认知:三分类拆 3 个二分类器,每个只吃两个类的样本,投票定结果,票数打平时用各类的累计决策值兜底。
from itertools import combinations from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.svm import SVC import numpy as np class ManualOvO(BaseEstimator, ClassifierMixin): """手写 OvO:三分类拆 3 个二分类器,投票 + 决策值兜底""" def __init__(self, C=1.0, gamma="scale", kernel="rbf"): self.C, self.gamma, self.kernel = C, gamma, kernel def fit(self, X, y): self.classes_ = np.unique(y) self.pairs_, self.models_ = [], [] for i, j in combinations(range(len(self.classes_)), 2): mask = np.isin(y, self.classes_[[i, j]]) # 只取这两个类的样本 m = SVC(C=self.C, gamma=self.gamma, kernel=self.kernel) m.fit(X[mask], y[mask]) self.pairs_.append((i, j)) self.models_.append(m) return self def decision_function(self, X): # 三列分别对应 (0,1)、(0,2)、(1,2) 三场比赛的带符号距离 return np.column_stack([m.decision_function(X) for m in self.models_]) def predict(self, X): D = self.decision_function(X) # (n_samples, 3) votes = np.zeros((X.shape[0], len(self.classes_)), dtype=int) conf = np.zeros_like(votes, dtype=float) for k, (i, j) in enumerate(self.pairs_): pos = D[:, k] > 0 # 决策值 > 0 判给索引小的类 votes[:, i] += pos votes[:, j] += ~pos conf[:, i] += D[:, k] # 累计置信度,用于平票兜底 conf[:, j] -= D[:, k] best = votes.argmax(axis=1) tie = np.where((votes == votes.max(axis=1, keepdims=True)).sum(axis=1) > 1)[0] if tie.size: # 三分类可能出现 1:1:1 best[tie] = conf[tie].argmax(axis=1) return self.classes_[best]三个细节值得说。np.isin(y, self.classes_[[i, j]])是按真实标签取值筛样本,所以标签不是 0/1/2 也能跑;conf的符号要对称——某场比赛判给 i,i 的置信度加、j 的减,这样平票时比较的是“三类各自的净支持度”,比随便取第一个类要合理;tie那两行只在(1,1,1)这种票型上生效,正常样本不会走到。
验证一下和 sklearn 的一致性:
manual = ManualOvO(C=1.0, gamma="scale").fit(X_tr, y_tr) sk = SVC(kernel="rbf", C=1.0, gamma="scale").fit(X_tr, y_tr) same = (manual.predict(X_te) == sk.predict(X_te)).mean() print(f"一致率: {same:.4f}") # 正常在 0.98 以上,差异来自平票兜底策略不同一致率到不了 1.0 是正常的,差的那几个点基本都是三场比赛打出(1,1,1)的样本——sklearn 按类索引取了第一个,我这边按置信度取。这正好印证了上一章说的:平票规则会真实影响输出。
4. 调参与评估:让 C、gamma、class_weight 落在 macro-F1 上
模型跑通只是及格线,真正花时间的是把参数调到“换一批数据也不崩”的位置。这一章把搜索空间怎么划、指标怎么看、概率输出该不该开,拆成三件事。
4.1 搜索空间:先粗后细,别一上来就铺大网格
C和gamma都是对数尺度敏感的参数,线性等距地扫(比如 1、2、3、4)几乎没意义,要按 10 的幂次扫。第一轮铺粗网格,第二轮在最优值附近按 3 倍步长细扫。
from sklearn.model_selection import GridSearchCV param_grid = [ # 第一轮:gamma 固定 scale,只找 C 的量级 {"svc__C": [0.1, 1, 10, 100, 1000], "svc__gamma": ["scale"]}, # 第二轮:在 C 的量级附近展开 gamma {"svc__C": [1, 10, 100], "svc__gamma": [0.005, 0.01, 0.05, 0.1, 0.5]}, ] gs = GridSearchCV( pipe, param_grid, scoring="f1_macro", # 不平衡三分类优先看 macro cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42), n_jobs=-1, refit=True, # 用最优参数在全量训练集上重训一次 verbose=1, ) gs.fit(X_tr, y_tr) print(gs.best_params_, round(gs.best_score_, 4))refit=True别关。GridSearchCV默认会用最优参数在整个X_tr上重训,最终gs.predict用的是这个重训模型;如果关掉,gs里只有各折的模型,predict会报错或者行为不符合预期。
第二轮网格把C和gamma同时展开,是在捕捉两者的乘性关系。经验上,macro-F1 对gamma的敏感度通常高于C——gamma大一点,三类之间的过渡带立刻碎掉,小类首当其冲。所以如果时间只够调一个,先调gamma。
4.2 评估:先看混淆矩阵,再看 macro-F1
macro-F1 是个标量,它告诉你“有问题”,但不告诉你“哪两类在互相污染”。三分类最典型的失败模式是 A 类和 B 类的边界互相串,C 类被完全吞掉,这时候 macro-F1 掉得厉害但 accuracy 可能还好看。
from sklearn.metrics import confusion_matrix, classification_report, f1_score y_pred = gs.predict(X_te) print(confusion_matrix(y_te, y_pred)) # 行是真值,列是预测 print(classification_report(y_te, y_pred, digits=4)) print("macro-F1:", round(f1_score(y_te, y_pred, average="macro"), 4)) print("weighted-F1:", round(f1_score(y_te, y_pred, average="weighted"), 4))三个指标一起看是有讲究的。macro-F1是三类 F1 的算术平均,小类表现差会直接把均值拽下来,这是你要盯的主指标;weighted-F1按类样本量加权,和 accuracy 的走势更接近,用来判断“大类有没有被带崩”;confusion_matrix看的是错位方向,如果小类的样本大量落到大类那一列,说明class_weight给得不够或者C太大导致小类被当成噪声丢掉了。
对 6:3:1 这种分布,我的经验阈值是:macro-F1 与 weighted-F1 的差距在 0.08 以内算健康;差距超过 0.15,说明小类基本没学到,先去查class_weight有没有真的生效,再查小类样本是不是在train_test_split之后只剩几十个。
4.3 概率输出:probability=True的代价与替代
很多人为了拿predict_proba直接开probability=True,然后在两件事上踩坑:训练突然慢了好几倍,以及predict的结果和不开概率时对不上。
原因是probability=True会内部再跑 5 折交叉验证做 Platt 校准,等于把训练成本乘上去;校准之后predict走的是“校准后概率的 argmax”,而默认走的是 OvO 投票,两套逻辑在某些边界样本上会给出不同答案。所以要么全程开、要么全程不开,别在调参时关着、上线时开着。
# 只需要排序置信度、不需要严格概率时,用 decision_function 更省 scores = gs.decision_function(X_te) # (n_samples, 3),ovr 形状 pred_from_scores = gs.classes_[scores.argmax(axis=1)] print("与 predict 一致率:", (pred_from_scores == y_pred).mean())这段一致率正常情况下是 1.0——decision_function_shape="ovr"的形状本来就是为 argmax 对齐设计的。如果明显低于 1.0,说明类别顺序或模型状态出了问题,是个很好用的自检。
真需要概率(比如要做多模型融合、要设业务阈值),那就开probability=True并固定random_state,因为内部的 5 折划分也受它控制,不固定的话两次训练的概率输出会有细微差异,线上排查时会怀疑人生。
5. 避坑与排查:五个让三分类结果失真的坑
下面这五条都是我在三分类 SVM 上真踩过的,按“现象 → 原因 → 解决”记,遇到了直接对着查。
坑一:CV 分数漂亮,留出集崩了。现象:5 折交叉验证 macro-F1 稳定在 0.95 以上,换一份留出集掉到 0.80 附近。 原因:StandardScaler放在了交叉验证外面,用全量数据fit,验证折的均值方差提前泄漏进训练过程;三分类的不平衡数据上这个泄漏更明显,因为大类的统计量主导了缩放参数。 解决:标准化一律进Pipeline,cross_val_score(pipe, ...)传整个 Pipeline 而不是裸模型;已经写成外面缩放的,把scaler.fit_transform(X)改成scaler.fit_transform(X_tr),验证集只transform。
坑二:以为decision_function_shape="ovr"就是在训 OvR。现象:想拿“每个类对其他的判别方向”做分析,结果发现三列数值加起来接近 0,或者换"ovo"之后列语义完全对不上。 原因:SVC内部永远是 OvO,这个参数只做输出整形,第三列的语义和你想象的 OvR 置信度不是一回事。 解决:真要 OvR 语义,用OneVsRestClassifier(SVC(...))显式包一层,得到的是三个独立训练的模型,estimators_可以直接取;只是想让decision_function能 argmax 对齐predict,保持"ovr"默认值就行。
坑三:三分类出现 1:1:1 平票,某些区域标签跳变。现象:决策边界图上有一小块区域,相邻两个点预测成不同类,而它们的特征几乎一样;或者某个类在整片区域里从来不出现在预测结果中。 原因:三场比赛各投一票,(1,1,1)是合法票型,libsvm 的平票处理按类索引取第一个,导致索引小的类在平票区系统性地占优。 解决:把decision_function_shape设成"ovo",手工算votes和conf(参考 3.3 的实现),平票时用累计决策值兜底;或者直接改成OneVsRestClassifier,从根上避开投票机制。这个坑在数据量小、类间重叠大的时候出现频率明显更高。
坑四:开了probability=True,predict结果变了。现象:调参阶段没开概率,准确率 0.91;上线加了probability=True,同一份测试集变成 0.88,个别样本标签反复横跳。 原因:概率校准内部跑 5 折 Platt scaling,predict改走校准后概率的 argmax,和 OvO 投票不是同一套决策规则;同时校准的折划分受random_state影响,不固定就有随机性。 解决:评估和上线保持同一套配置,别中途改;确实要概率就固定random_state,并且在报告里注明“评估用的是概率 argmax”;只想要排序的话用decision_function,省时且和默认predict完全对齐。
坑五:gamma="scale"在不同批次数据上结果不一致。现象:同一份代码、同一个模型配置,换一批采集数据重训,macro-F1 从 0.88 掉到 0.72,查了半天以为是分布漂移。 原因:gamma="scale"等于1 / (n_features * X.var()),它依赖当批数据的方差;如果新数据里某个特征量纲变了(比如从“米”改成“毫米”),方差变 10^6 倍,gamma就变成原来的百万分之一,模型直接退化成近乎线性的。 解决:把标准化固定进 Pipeline,让进入 SVC 的特征方差恒为 1;或者干脆把gamma写死成调参阶段搜出来的具体数值,别依赖自动推导。这一条我吃过不止一次亏,属于典型的“代码没变、数据变了、结果玄学”。
6. 进阶技巧:把决策边界画出来,把参数固化下来
三分类 SVM 最值得做的一步不是继续调参,是把决策边界画出来。数字看不出问题的地方,图一眼就能看出来。做法是取两个最有判别力的特征(或者用 PCA 压到二维),在平面上铺网格,对每个网格点预测,再按类别填色。
import numpy as np import matplotlib.pyplot as plt from sklearn.decomposition import PCA pca = PCA(n_components=2, random_state=42) X2 = pca.fit_transform(StandardScaler().fit_transform(X)) clf = SVC(kernel="rbf", C=gs.best_params_["svc__C"], gamma=gs.best_params_["svc__gamma"]).fit(X2, y) xx, yy = np.meshgrid(np.linspace(*np.percentile(X2[:, 0], [1, 99]), 300), np.linspace(*np.percentile(X2[:, 1], [1, 99]), 300)) Z = clf.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape) plt.contourf(xx, yy, Z, alpha=0.25, levels=[-0.5, 0.5, 1.5, 2.5]) # 三类的分界 plt.scatter(X2[:, 0], X2[:, 1], c=y, s=8, edgecolors="none") plt.show()levels要按类别数给,三分类是[-0.5, 0.5, 1.5, 2.5],写成默认值会把三类糊成一片。图上重点看两件事:小类是不是被压成一条细带(说明C太大或权重不够),以及三类交汇处有没有锯齿状的碎片区(说明gamma太大,模型在拟合噪声)。
另一个习惯是把最优参数连同gamma的具体数值一起写进配置,不要留"scale"这种自动推导值。调参搜出来的gamma是那批数据下的最优解,自动推导只保证量纲一致、不保证性能。我一般会在配置里同时记下gamma_mode和gamma_value,重训时优先用value,换数据分布时再回退到mode重新搜一遍。
从那以后我每次做三分类 SVM,都会强制走一遍“CV 分数 → 混淆矩阵 → 决策边界图”这三步,任何一步和上一步对不上就停下来查,不再直接看准确率收工。希望帮到你。
本文还有配套的精品资源,点击获取