ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SVM三分类实战:RBF核、OVO/OVR机制与决策边界调优

SVM三分类实战:RBF核、OVO/OVR机制与决策边界调优

简介:SVM(支持向量机)三分类的MATLAB实现包,面向已掌握机器学习基础、希望理解多分类策略并动手复现的读者,重点解决如何将二分类SVM扩展为三分类并完成训练、预测与结果可视化的实际问题。压缩包共6个文件,约4KB,以5个m脚本和1份data数据为主体:脚本覆盖核函数、训练、分类与绘图等环节,数据文件可直接用于实验验证,体量轻便,便于按模块阅读和修改。已有6351人学习下载,说明该案例在课程实验与入门实践中具有一定参考价值。读者可从中获得一对一策略的三分类建模思路、MATLAB中SVM训练与预测的代码组织方式,以及分类边界与结果展示的实现参考,适合用作课程作业、算法复现和参数调整的起点。

1. 三类样本卡在一条直线上时,SVM 怎么把边界弯出来

有位同学拿着一份二维测量数据来找我,三个类别的点摊在平面上,中间那类和左右两类互相咬在一起,逻辑回归画一条直线,怎么切都会误伤一片。这就是支持向量机做三分类最典型的出场场景:样本量不大、特征维度不高、但类别边界不是直的一道线。

SVM 的价值不在“多一个分类器”,而在于间隔最大化和核函数这两件事——它会把边界尽量推到离两类样本都远的位置,再用 RBF 核把原始平面映射到高维,回到二维看就是一条弯过去的曲线。适合谁?手上只有几百到几千条、特征十几维、又不想上深度模型的项目。下面按“结构 → 数据 → 训练 → 踩坑 → 改造”把这条链路走一遍。

2. 技术选型与目录结构:一次三分类训练要经过哪几个文件

先把最容易含糊的地方说清:SVM 原生只解决二分类问题。sklearn 里写SVC()时传进去三列标签能跑,不代表模型内部就是一个“三分类器”,它背后做的事是把三分类拆成若干二分类问题再聚合。这个机制不理解,后面调参、看得分、画边界都会糊涂。

2.1 三分类的真实形态:OVO 与 OVR 的两种拆法

设三个类别为 A、B、C。

OVO(one-vs-one)拆成三场单挑:A vs B、A vs C、B vs C,共 C(3,2)=3 个二分类器。预测时每个分类器投一票,票多者胜。3 个类别时可能出现 1:1:1 的平票,libsvm 内部用决策值之和打破平局。

OVR(one-vs-rest)拆成三场群殴:A vs (B,C)、B vs (A,C)、C vs (A,B),也是 3 个二分类器。预测时取三个决策值最大的那一类,不会平票。

关键点在于:sklearn.svm.SVC底层调的是 libsvm,训练阶段永远是 OVO。decision_function_shape这个参数只改变decision_function()返回值的形状,不改变训练过程:

from sklearn.svm import SVC import numpy as np X = np.array([[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5]]) y = np.array([0, 0, 1, 1, 2, 2]) clf = SVC(kernel="linear", decision_function_shape="ovr").fit(X, y) print(clf.decision_function([[1.5, 1.5]]).shape) # (1, 3),每类一个得分 print(len(clf.dual_coef_[0])) # 反映的是内部 OVO 的支持向量结构 clf2 = SVC(kernel="linear", decision_function_shape="ovo").fit(X, y) print(clf2.decision_function([[1.5, 1.5]]).shape) # (1, 3),3 类时 ovo 也是 3 列

3 个类别时两种设置返回的列数恰好都是 3,肉眼看不出差别,这也是很多人误以为“ovr 就是训练方式”的原因。类别数变成 5,ovo 是 10 列,ovr 是 5 列,差异立刻暴露。我一般统一写decision_function_shape="ovr",因为业务侧要的是“每类一个可比较的分数”,做阈值裁剪、做置信度过滤都方便。

提示:decision_function_shape不影响predict()的结果,只影响decision_function()的输出形状。别指望改它能提升准确率。

2.2 目录骨架:从原始数据到 predict 的五个文件

我按“能单独运行、能单独替换”的原则拆文件,目录长这样:

svm-triclass/ ├── data/ │ └── samples.csv # 特征两列 + 标签一列(0/1/2) ├── src/ │ ├── make_data.py # 造样本,方便没有数据时先跑通 │ ├── preprocess.py # 切分 + 标准化,返回矩阵 │ ├── train.py # 训练主入口 │ ├── evaluate.py # 混淆矩阵、分类报告、边界图 │ └── predict.py # 加载模型做单条/批量推理 ├── models/ │ └── svc_rbf.pkl # 模型 + scaler 打包存放 ├── outputs/ │ ├── decision_boundary.png │ └── confusion_matrix.png └── requirements.txt

preprocess.py单独拆出来的理由很实际:训练时要 fit scaler,推理时只能 transform,两边必须共用同一个 scaler 实例。把它和模型打包成一个字典存进 pkl,是这类小项目最省事的做法,后面第 5 章会讲不这么做会翻什么车。

数据流是一条直线:samples.csv→preprocess输出X_train_s / X_test_s→train.py拿到标准化后的矩阵 fit → 模型落到models/→evaluate.py读模型画图 →predict.py读模型对外服务。没有交叉依赖,任何一个环节都能单独跑。

2.3 技术栈与版本约束:为什么是这套组合

核心依赖就四个:

依赖作用注意点
scikit-learnSVC、StandardScaler、train_test_split、GridSearchCV0.22 之后gamma默认从auto改成scale,老教程的默认值对不上
numpy网格生成、数组切片、数据拼装画决策边界靠np.meshgrid
matplotlib决策区域填充、散点叠加无图形界面环境要设Agg后端
joblib模型持久化优于 pickle 处理大 numpy 数组,sklearn 官方推荐

选 sklearn 而不是手写 SMO,理由不是“图省事”,而是 libsvm 的数值稳定性是经过大量验证的。自己实现一个简化版 SMO 用来理解原理没问题,但要放进业务,支持向量缓存、收缩启发式、核矩阵缓存这些工程细节足够写几千行。二次开发真正该动的是数据管道和参数搜索,不是求解器。

如果数据规模上到十万条以上、特征上千维,SVC的 O(n²)~O(n³) 复杂度会顶不住,这时候换LinearSVC或者转SGDClassifier,但那就不是这个项目的范围了。几百到几千条样本,SVC是舒服的区间。

3. 数据构造与预处理:三类样本怎么造得“不那么好分”

很多人第一次跑三分类,用的是一份三类分得干干净净的数据,准确率 0.99,换到自己的数据上直接掉到 0.6,然后怀疑代码写错了。问题往往在数据本身。这一段先把“能暴露问题”的样本造出来,再讲预处理的正确顺序。

3.1 用 make_classification 造三类带噪声样本

import numpy as np from sklearn.datasets import make_classification # n_features=2 是为了能画二维决策边界; # class_sep=0.9 让三类有交叠,flip_y=0.05 注入 5% 标签噪声 X, y = make_classification( n_samples=600, n_features=2, n_informative=2, n_redundant=0, n_classes=3, n_clusters_per_class=1, class_sep=0.9, flip_y=0.05, random_state=42, ) # 故意把第二列放大 20 倍,模拟“量纲不一致”的真实数据 X[:, 1] *= 20 np.savetxt( "data/samples.csv", np.column_stack([X, y]), delimiter=",", header="f1,f2,label", comments="", fmt="%.6f", ) print("类别分布:", np.bincount(y))

参数不是随手写的。n_features=2是为了后面能用网格图把决策区域画出来,特征一多就得降维,调试成本陡增。class_sep=0.9控制类别中心的距离,值太小三类完全糊在一起,准确率永远在 0.5 附近,排查起来分不清是数据问题还是模型问题;值太大又失去调试价值。flip_y=0.05是标签噪声,模拟人工标注的误差,让测试集准确率不会出现 1.000 这种不真实的数字。

最后那行X[:, 1] *= 20是我刻意加进去的坑。它让第二列的数值范围变成第一列的二十倍,标准化之前,RBF 核的 gamma 计算会被大方差特征主导,模型几乎只看第二列。后面标准化那一节会看到效果差异。

n_clusters_per_class=1表示每个类别是一团高斯分布。改成 2 就会造出“一个类分成两簇”的情况,这时候线性核彻底失效,只剩 RBF 能救,是验证核函数选择的快捷手段。

3.2 量纲统一:StandardScaler 该 fit 在哪份数据上

这是我认为三分类项目里最容易写错、后果最隐蔽的一步。

from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler import numpy as np raw = np.loadtxt("data/samples.csv", delimiter=",", skiprows=1) X, y = raw[:, :-1], raw[:, -1].astype(int) # 先切分,再 fit scaler,顺序不能反 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y ) scaler = StandardScaler().fit(X_train) # 只在训练集上统计 mean/std X_train_s = scaler.transform(X_train) X_test_s = scaler.transform(X_test) # 测试集只能 transform print("训练集均值:", scaler.mean_.round(2)) print("训练集标准差:", scaler.scale_.round(2)) print("标准化后范围:", X_train_s.min().round(2), X_train_s.max().round(2))

fit在训练集上算均值和标准差,transform把它们应用到测试集。反过来先fit_transform全量数据再切分,测试集的统计信息就泄漏进训练过程,评估结果会虚高,上线后掉点。这个错误在样本量小时特别难发现,因为虚高的幅度不大,看着像“正常波动”。

stratify=y也不能省。三类样本如果比例是 5:3:2,随机切分有可能某一类在测试集里只出现个位数,混淆矩阵直接没法看。分层抽样保证切分前后类别比例一致。

scaler.mean_和scaler.scale_打印出来是为了确认第二列的均值是不是被放大到几十的量级——是的话说明坑生效了,标准化之后两列都回到 0 附近、标准差为 1,RBF 核才公平对待两个特征。

注意:StandardScaler对异常值敏感。如果某一类样本里混进几个离群点,标准差被拉大,其余样本全被压到 0 附近,决策边界会非常怪。造数据阶段可以用np.clip截断,真实数据先画箱线图看一眼。

3.3 划分与基线:先跑一个 DummyClassifier 对照

在写 SVM 之前,先知道“什么都不学”能拿多少分。

from sklearn.dummy import DummyClassifier from sklearn.metrics import accuracy_score # most_frequent:永远预测训练集里最多的那一类 dummy = DummyClassifier(strategy="most_frequent").fit(X_train_s, y_train) base_acc = accuracy_score(y_test, dummy.predict(X_test_s)) print(f"多数类基线准确率: {base_acc:.3f}")

三分类均衡数据这个数是 0.33,不均衡数据可能到 0.5 甚至更高。SVM 的准确率如果不明显高于它,说明模型没学到东西,先去查标签编码、特征列错位,而不是调C。我见过不止一次有人把基线 0.5、模型 0.52 的结果当成“模型效果一般”,实际上模型压根没在工作。

基线跑完,数据侧的准备就算齐了:600 条样本、2 个特征、3 个类别、量纲不一致、5% 标签噪声。这份数据不算难,但足够把核函数、C、gamma的差异放大出来。

4. 训练主链路:SVC 三分类从 fit 到决策边界的完整代码

前面把数据和结构铺好了,这一段是真正的核心:一次完整训练要做哪几步、每个参数在改什么、决策边界怎么画出来、评估指标怎么读。代码按“能直接抄下去跑”的标准写。

4.1 训练脚本逐段拆:从读数据到保存模型

import numpy as np import joblib from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, confusion_matrix # 1) 读数据,最后一列是标签 raw = np.loadtxt("data/samples.csv", delimiter=",", skiprows=1) X, y = raw[:, :-1], raw[:, -1].astype(int) # 2) 分层切分,保证三类比例一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y ) # 3) 标准化:fit 只在训练集 scaler = StandardScaler().fit(X_train) X_train_s = scaler.transform(X_train) X_test_s = scaler.transform(X_test) # 4) 三分类直接交给 SVC,内部按 OVO 拆成 3 个二分类器 clf = SVC( kernel="rbf", # 非线性边界,二维交叠数据的主力 C=1.0, # 惩罚系数,越大越不容忍错分 gamma="scale", # = 1 / (n_features * X.var()) decision_function_shape="ovr", # 决策值输出为每类一列 class_weight="balanced", # 类别不均衡时自动加权 random_state=42, ) clf.fit(X_train_s, y_train) # 5) 评估 pred = clf.predict(X_test_s) print("混淆矩阵:\n", confusion_matrix(y_test, pred)) print(classification_report(y_test, pred, digits=3)) # 6) 模型和 scaler 必须一起存 joblib.dump({"model": clf, "scaler": scaler}, "models/svc_rbf.pkl") print("支持向量个数:", clf.n_support_, "总计:", clf.support_vectors_.shape[0])

第 4 步是整个脚本的重心。SVC接收三列标签不会报错,内部自动判断类别数,按 OVO 训练 C(3,2)=3 个二分类器。n_support_返回的是每个二分类器各自的支持向量个数,三个数加起来不等于support_vectors_.shape[0]——因为同一个样本可能在多场“单挑”里都当支持向量,去重后才进support_vectors_。这个细节在分析模型复杂度时经常被搞混。

gamma="scale"的计算方式需要单独说:它等于1 / (n_features * X.var()),X.var()是输入矩阵整体的方差。数据标准化之后方差接近 1,两个特征时 gamma 约等于 0.5。如果换成gamma="auto",值是1/n_features,也就是 0.5,两者在标准化数据上碰巧接近,但在未标准化数据上差得很远。老代码里写死的gamma=0.1更是重灾区,换一份数据就崩。

class_weight="balanced"会按类别频率反比给权重,公式是n_samples / (n_classes * bincount(y))。三类完全均衡时它等于 1,什么也不做;一旦某一类只占 5%,它的权重会被放大到十几倍,牺牲整体准确率换少数类召回。要不要开,取决于业务更怕“漏判”还是更怕“误判”。

第 6 步存的是一个字典而不是裸模型。原因很直接:推理时必须用同一个 scaler 做 transform,只存模型、线上重新 fit 一个 scaler,均值方差对不上,预测结果会整体偏移。这个坑我在第 5 章展开。

4.2 参数表:C、gamma、kernel 各自在改什么

参数常用取值作用调大后的表现调小后的表现
C0.01 / 0.1 / 1 / 10 / 100对错分样本的惩罚边界贴合训练点,易过拟合容忍更多错分,边界更平滑,可能欠拟合
gammascale/ 0.01 / 0.1 / 1 / 10RBF 核的辐射范围每个样本影响范围变小,边界碎成孤岛影响范围变大,边界退化成近似线性
kernelrbf/linear/poly/sigmoid映射方式——
class_weightNone/balanced/ 字典类别权重——
decision_function_shapeovr/ovo只影响决策值输出形状——

C和gamma是一对互相拉扯的参数。C大 +gamma大是典型的过拟合组合:训练集 0.99,测试集 0.6,决策边界上是一个个围绕样本的小圈。C小 +gamma小则相反,边界接近一条直线,三类根本分不开。网格搜索时这两个参数必须一起搜,单独调一个没有意义。

kernel="linear"只在两类线性可分、或特征维度远大于样本量时才有优势。前面造的数据三类有交叠,线性核准确率大概在 0.7 上下,RBF 能到 0.85 以上,差距明显。poly核的degree参数在三分类里很少调,阶数一高数值就不稳。

4.3 决策边界可视化:把三类区域画在同一张网格上

import matplotlib matplotlib.use("Agg") # 无图形界面环境必须设置 import matplotlib.pyplot as plt # 在训练集范围外扩 0.5,铺 400x400 网格 x_min, x_max = X_train_s[:, 0].min() - 0.5, X_train_s[:, 0].max() + 0.5 y_min, y_max = X_train_s[:, 1].min() - 0.5, X_train_s[:, 1].max() + 0.5 xx, yy = np.meshgrid( np.linspace(x_min, x_max, 400), np.linspace(y_min, y_max, 400), ) # 网格点已经是标准化空间,直接喂给模型,不要再 transform 一次 grid = np.c_[xx.ravel(), yy.ravel()] Z = clf.predict(grid).reshape(xx.shape) plt.figure(figsize=(6, 5)) # levels 的边界要和类别编号对齐:0/1/2 三类各占一段 plt.contourf(xx, yy, Z, alpha=0.25, levels=[-0.5, 0.5, 1.5, 2.5], colors=["#8ecae6", "#ffb703", "#90be6d"]) plt.scatter(X_train_s[:, 0], X_train_s[:, 1], c=y_train, edgecolors="k", s=25, linewidths=0.5) plt.title("RBF SVM decision regions (3 classes)") plt.xlabel("f1 (scaled)") plt.ylabel("f2 (scaled)") plt.savefig("outputs/decision_boundary.png", dpi=150, bbox_inches="tight")

levels是最容易画错的地方。contourf按数值区间填色,三类的标签是 0、1、2,那么分割点就该取 -0.5、0.5、1.5、2.5,四个边界切出三个区间。写成levels=3让 matplotlib 自动分,颜色块的数量和类别对不上,图看着很漂亮但完全没有解读价值。

网格点必须已经在标准化空间。很多人把网格建在原始坐标范围,忘了做scaler.transform,画出来的边界和散点完全错位——散点是标准化后的,网格是原始的,两张图叠在一起毫无意义。我在代码里让散点也用X_train_s,保证坐标系一致。

alpha=0.25是让填充色半透明,散点压在上面还能看清。不设的话颜色块会把点全盖住。

4.4 评估:混淆矩阵与分类报告的读法

classification_report输出四列:precision、recall、f1-score、support。三分类场景下,我优先看两个东西。

第一是混淆矩阵的对角线外哪一格最大。如果 1 类被大量判成 2 类,说明这两类在特征空间上贴得最近,要么补特征,要么对这两类单独提权,而不是全局调C。

第二是macro avg和weighted avg的差距。均衡数据两者应该接近;差距大说明类别比例失衡,weighted avg被多数类拉高,看着好看但少数类可能全军覆没。这时候看macro avg的 recall 才有意义。

from sklearn.metrics import confusion_matrix import numpy as np cm = confusion_matrix(y_test, pred) # 按行归一化,看每一类被判到各处的比例 cm_norm = cm.astype(float) / cm.sum(axis=1, keepdims=True) print(np.round(cm_norm, 3))

按行归一化比原始计数更有信息量。原始矩阵里各类样本数不同,绝对数量没法横向比;归一化后每一行加起来是 1,能直接读出“这个类有多少比例被判对了”。

到这里,一次完整的三分类训练就闭环了。跑通之后,准确率大概在 0.85 上下(取决于随机种子和噪声),决策边界图上能看到三条弯曲线段把平面切成三块,交叠区域有少量散点落在错误色块里——这才是真实数据该有的样子。

5. 避坑与排查:三分类准确率卡在 0.33 时先查这 5 处

下面这 5 条都是我在三分类项目里真真切切踩过或者帮别人定位过的,按“现象 → 原因 → 解决”写,遇到问题可以按顺序排查。

5.1 数据与训练阶段的三个坑

坑一:准确率恰好等于多数类占比,模型像没训练。

现象:三分类均衡数据,准确率稳定在 0.33 附近,换核函数、调C都没变化。

原因:标签列读进来是浮点数(np.loadtxt默认 float),或者特征列把标签也包含进去了,模型在学一个错位的映射。另一种情况是StandardScaler被 fit 在了全量数据上然后又切分,索引错位导致 X 和 y 不匹配。

解决:训练前打印np.unique(y)和X.shape[0] == y.shape[0],再看np.bincount(y)确认三类都存在。标签统一astype(int)。切分后立刻检查X_train.shape[0] == y_train.shape[0]。

坑二:gamma用了默认以外的固定小值,边界退化成直线。

现象:决策边界图上看不到曲线,三个色块被近似直线的边界分开,准确率 0.6 左右上不去。

原因:老教程里gamma=0.001或gamma=0.01,在标准化后的二维数据上辐射范围太大,RBF 核退化成近似线性核。这类代码在特征维度高的时候碰巧能跑,二维数据上直接失效。

解决:先统一用gamma="scale",让 sklearn 按特征数和方差自动算。需要手调时在scale值附近取 0.1 倍、1 倍、10 倍三档做网格搜索,别跨数量级乱试。

坑三:不做标准化,某个特征数值大 20 倍,模型只看它。

现象:两个特征,第二个特征数值范围是第一个的二十倍,准确率比预期低一截,且混淆矩阵显示某一类几乎全错。

原因:RBF 核的核函数是exp(-gamma * ||x - x'||²),距离被大方差特征主导。gamma="scale"里的X.var()也被这个特征拉大,进一步压缩了有效辐射范围。模型实质上退化成单特征分类。

解决:StandardScaler放到管道里,训练和推理共用。检查scaler.scale_两个值是否都在 1 附近,差一个数量级以上就说明原始数据量纲有问题。

5.2 推理与上线阶段的两个坑

坑四:只存了模型没存 scaler,线上线下预测结果对不上。

现象:离线评估 0.85,封装成接口后同样的输入返回的类别和离线对不上,或者整体偏向某一类。

原因:joblib.dump(clf, ...)只序列化了模型,线上用StandardScaler().fit_transform(新数据)重新算均值和方差,和训练时的统计量完全不同。SVM 的决策完全依赖支持向量在特征空间的位置,坐标一偏移,预测就乱。

解决:joblib.dump({"model": clf, "scaler": scaler}, ...)打包存,加载时一起取出。推理函数里固定顺序:先scaler.transform再clf.predict,中间不允许插入其他变换。

import joblib import numpy as np bundle = joblib.load("models/svc_rbf.pkl") model, scaler = bundle["model"], bundle["scaler"] def predict_one(features: list) -> int: arr = np.asarray(features, dtype=float).reshape(1, -1) if arr.shape[1] != scaler.n_features_in_: raise ValueError(f"特征数应为 {scaler.n_features_in_},实际 {arr.shape[1]}") return int(model.predict(scaler.transform(arr))[0])

这段封装里那个特征数校验不是多余的。线上最常见的故障就是把列顺序换掉、或者少传一列,reshape(1, -1)会默默把它当合法输入,SVM 照样吐一个类别出来,错得悄无声息。

坑五:decision_function_shape改了之后,阈值过滤逻辑全乱。

现象:业务侧用decision_function的得分做置信度过滤,把ovo改成ovr之后,过滤掉的比例突然大幅变化。

原因:ovo返回的是每场“单挑”的决策值聚合结果,ovr返回的是“该类对其余类”的得分,两者的数值尺度和含义都不同。3 个类别时列数碰巧都是 3,很容易以为只是形状调整。

解决:先确定业务要的是“相对比较”还是“绝对阈值”。只是取最大类,predict就够;要做置信度,固定用ovr并在训练、评估、上线三处保持一致,阈值在验证集上重新标定,不能沿用旧值。

提示:排查顺序建议固定为“标签与维度 → 标准化 → 核参数 → 持久化”,这四步覆盖了九成以上的问题,比上来就网格搜索效率高得多。

6. 二次开发:把三分类扩到 N 类并接进自己的业务

SVC本身不限类别数,n_classes从 3 改到 5,代码一行不用动,内部 OVO 拆出的分类器数从 3 个涨到 10 个,训练时间大约翻三倍。真正要改的是三处:数据管道、参数搜索、封装层。

数据管道的改造点在于标签编码。类别名从“甲/乙/丙”这种字符串映射成 0..N-1,用LabelEncoder存一份映射表跟着模型一起持久化,推理时再inverse_transform回业务名。这一步不做,接口返回 0/1/2,调用方还得再查一次表,迟早出错。

参数搜索不要手写循环。GridSearchCV配StratifiedKFold是标准做法:

from sklearn.model_selection import GridSearchCV, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC # 标准化放进 Pipeline,交叉验证时每一折单独 fit,避免泄漏 pipe = Pipeline([ ("scaler", StandardScaler()), ("svc", SVC(kernel="rbf", decision_function_shape="ovr", random_state=42)), ]) param_grid = { "svc__C": [0.1, 1, 10, 100], "svc__gamma": ["scale", 0.01, 0.1, 1], } grid = GridSearchCV( pipe, param_grid, cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42), scoring="f1_macro", # 类别不均衡时比 accuracy 更可靠 n_jobs=-1, ) grid.fit(X_train, y_train) print(grid.best_params_, round(grid.best_score_, 4))

关键是scoring="f1_macro"。默认的 accuracy 在类别不均衡时会让模型偏向多数类,f1_macro对每一类平等加权,逼着模型照顾少数类。标准化放进Pipeline而不是外面,是因为交叉验证的每一折都要用当前折的训练部分重新算均值方差,放外面就又泄漏了。

验证方法上,我的习惯是留一份从不参与任何调参的测试集,只在最后跑一次。网格搜索用的best_score_是交叉验证均值,会偏乐观,最后那份独立测试集上的classification_report才是能对外报的数字。两者差距超过 5 个百分点,说明参数搜过头了,把C往小调。

最后一个具体技巧:样本量小于 2000 时,RBF 核的cache_size默认 200MB 通常够用,但特征维度一高,核矩阵缓存会频繁换出,训练时间能差好几倍。改成cache_size=1000往往比调C更立竿见影。我之前有个三分类的小模型怎么调都是 0.78,换了核缓存和gamma="scale"之后稳定到 0.86,当时盯着参数表排查了大半天,最后发现是缓存换出拖慢了收敛、早停判断被误导。从那以后我每次跑SVC之前都强制先看一眼数据量级、量纲分布和cache_size,再动参数。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进