sklearn SVM三兄弟选型:SVC、NuSVC、LinearSVC避坑
简介:围绕 Python 中支持向量机分类模型的三类实现展开的 docx 文档,面向已具备 sklearn 基础、希望把 SVM 从公式落实到代码的数据挖掘初学者。内容按三个小节递进:SVC 用平面上 8 个点演示线性核建模,输出权重 w、截距与支持向量,并画出决策边界与两条平行的边缘直线;NuSVC 以简单二分类数据展示 nu 参数及 support_、classes_ 的用法;LinearSVC 则加载鸢尾花数据集,切分训练测试集后完成评分与预测。此外还梳理核函数、支持向量、间隔、C 与 nu 参数等关键概念,并给出线性可分数据优先 LinearSVC、非线性数据尝试 SVC 或 NuSVC 的选型提示。包内共 1 个 docx 文件,约 179KB,代码、注释与运行结果并排呈现,便于边读边复现,适合作为课堂示例或自学时的随手参照。目前已有 1261 人学习。
1. 小样本点到决策边界:sklearn.svm 三兄弟怎么选
手上只有 8 个二维点,想让模型自己画出那条分界线,还想把支持向量、间隔边界一并摆在纸面上——这种事用 sklearn.svm 里的支持向量机,配一个线性核,十几行代码就能做到。跑完之后,你能亲眼看到 w、截距和两条虚线之间的距离关系,比看十页推导都直观。同一个 sklearn.svm 模块里还包含另外两个实现,它们的特性很不一样:NuSVC 用 nu 参数直接约束支持向量占比,适合这种场景——你对噪声比例心里有数,却不想反复试 C;LinearSVC 基于 liblinear,速度快、适合大样本量,是文本分类和特征维度上万时的常见选择。这三个类经常被新手混着调,结果就是参数名对不上、评分忽高忽低、换个数据规模就得推倒重来。下面按“先画出来、再读懂参数、最后挑对实现”的顺序走一遍,适合已经会 fit/predict、但还没弄清 kernel、C、nu 三者边界的人。
2. 从八个点画出一条分界线:SVC 线性核的三条线复现
2.1 先拿线性核开刀的理由
SVM 的二分类几何解释很干净:找一个超平面 w·x + b = 0,让两类样本到它的最小距离尽量大。把两类的间隔归一化成 1 之后,支持向量就落在 w·x + b = ±1 这两张“纸”上,间隔宽度等于 2/‖w‖,训练过程就是在约束下最小化 ‖w‖²/2。线性核的价值在于,权重向量 w 是特征空间里的真实法向量,可以直接读出来、算出斜率、画到图上,中间不存在核函数带来的映射歧义。
换成 RBF 核,w 会被隐式映射到无穷维空间,你拿到的只有dual_coef_和支持向量,画不出那条线,也解释不了某个特征到底贡献了多少。所以八点这个小例子必须用kernel='linear'。我见过不少人上来就SVC()默认 RBF,然后在 8 个点上得到一条扭曲的曲线,还以为模型学得更好——实际上是模型把噪声也记住了,样本外预测全靠运气。
另外要分清楚 SVC 与 LinearSVC 的定位。SVC 走 libsvm 的对偶求解,支持任何核函数,适合中小样本、需要核技巧的场景;LinearSVC 走 liblinear 的坐标下降,只做线性,但对样本量和特征维度都不敏感,几千行几万列也扛得住。第 4 章会专门量一次两者的差距。
2.2 逐行跑通:训练、反解斜率、画三条线
把原始代码整理成可直接执行的一版,加了画图需要的全部变量和一处在原始示例里没有显式写出的细节——间隔边界最好从支持向量反算,而不是直接拿intercept_加减 1 再除以 w[1],后者在 w[1] 接近 0 时会数值爆炸。
from sklearn import svm import numpy as np import matplotlib.pyplot as plt # 平面上的 8 个点,二维特征,方便直接画在坐标系里 X = [[0.39, 0.17], [0.49, 0.71], [0.92, 0.61], [0.74, 0.89], [0.18, 0.06], [0.41, 0.26], [0.94, 0.81], [0.21, 0.01]] Y = [1, -1, -1, -1, 1, 1, -1, 1] clf = svm.SVC(kernel='linear') # linear 必须小写,'Linear' 会抛 ValueError clf.fit(X, Y) # 8 个点,训练耗时可以忽略 w = clf.coef_[0] # 权重向量,二维:w[0] 对应 x 轴,w[1] 对应 y 轴 a = -w[0] / w[1] # 决策边界 w·x+b=0 在平面上的斜率 xx = np.linspace(0, 1, 50) # 画图用的横轴采样点 yy = a * xx - clf.intercept_[0] / w[1] # 由 w·x+b=0 解出 x2 # 两条间隔边界:取首尾两个支持向量,过它们作平行线 b = clf.support_vectors_[0] yy_down = a * xx + (b[1] - a * b[0]) c = clf.support_vectors_[-1] yy_up = a * xx + (c[1] - a * c[0]) print('模型参数 w:', w) print('边缘直线斜率:', a) print('支持向量:', clf.support_vectors_) plt.plot(xx, yy, 'k-') # 决策边界,实线 plt.plot(xx, yy_down, 'k--') # 下间隔边界,虚线 plt.plot(xx, yy_up, 'k--') # 上间隔边界,虚线 plt.scatter([s[0] for s in X], [s[1] for s in X], c=Y, cmap=plt.cm.Paired) plt.show()逻辑上分三段。第一段fit完成对偶问题求解,得到coef_(形状(1, 2),二分类只有一行)和intercept_(形状(1,))。第二段做几何反解:把 w₀x₁ + w₁x₂ + b = 0 移项成 x₂ = (-w₀/w₁)x₁ - b/w₁,系数a就是屏幕上那条线的斜率,截距部分是-intercept_[0]/w[1]。第三段取支持向量首尾两个点,套进点斜式得到两条平行线——因为支持向量恰好满足 w·x + b = ±1,过它们作的平行线就是间隔边界。
参数上有几个容易忽略的地方。kernel='linear'之外,C默认是 1.0,控制对误分类的惩罚强度,调小会让间隔变宽、支持向量变多,调大则反过来;class_weight默认 None,样本不均衡时设成'balanced'会按类别频率自动加权;probability默认 False,想要predict_proba必须显式打开,代价是内部会多跑一次五折交叉验证,小数据上看不出,几万样本时会明显变慢。
运行结果里w大致是[1.0, -1.0]量级,斜率接近 1,说明两类样本沿着对角线被分开。这和散点图的直觉一致:标记为 1 的点集中在左下和右下一带,标记为 -1 的在上方,一条 45 度左右的线正好切开。
2.3 改 C 和换核函数:三个立刻能做的对照实验
不改数据、只改一个参数,是理解 SVM 最快的办法。把C依次设成 0.01、1、100,观察三件事:支持向量的个数(len(clf.support_))、权重w的数值、图上两条虚线的间距。C=0.01 时你会发现支持向量几乎覆盖全部 8 个点,间隔被拉得很宽,个别点已经落到间隔内部;C=100 时支持向量缩减到两三个,间隔收紧,模型开始贴着噪声走。这条经验在真实项目里同样成立:小 C 抗过拟合、大 C 拟合能力强。
换核函数则要看数据。把kernel改成'rbf',代码里coef_那一行会直接抛AttributeError,因为非线性核没有原始空间的权重向量。这个报错本身是个好提醒——一旦你画不出决策边界,模型的可解释性就交出去了。实际工程里我的习惯是:先跑线性核拿到基线分数和系数,如果分数差距明显(比如线性 0.78、RBF 0.93),再考虑上核函数,同时准备好人能看懂的替代解释手段,比如置换重要性或者单特征偏依赖。
gamma只对'rbf'、'poly'、'sigmoid'生效,线性核下传了也不报错,只是被忽略。默认'scale'等价于 1/(n_features × X.var()),比老版本的'auto'稳,手工调的时候从默认值上下各取一个数量级试就行。
3. NuSVC 的 nu 参数:支持向量比例到底怎么约束
3.1 nu 与 C 的关系:一个管比例,一个管惩罚
NuSVC 是 SVC 的参数化变体,把“惩罚系数 C”换成了“支持向量占比 nu”。理论上 nu 是支持向量占训练样本比例的下界,同时是间隔错误样本比例的上界,取值落在 (0, 1],默认 0.5。说人话就是:nu 越大,允许落进间隔内、被当作支持向量的点越多,模型越“松”;nu 越小,模型越倾向于只保留极少数关键点,间隔被压窄。
它跟 C 不是一一对应的换算关系,但方向相反:C 越大越紧,nu 越大越松。真实项目里我更愿意用 C,因为它的量纲和正则化概念对得上,调参时容易和逻辑回归那一套经验互借;nu 的优势在于你能直接说“我预计有 30% 的样本是不可靠的”,把业务判断翻译成参数,比盲试 C 更有依据。做异常检测类的边界划分时,这个表达力很值钱。
需要注意 NuSVC 和 SVC 共用 libsvm 后端,同样支持各种核函数,同样有support_vectors_、dual_coef_,但没有C这个构造参数,写了会直接TypeError。反过来 SVC 也没有nu。这类参数名对不上的错误在复制粘贴代码时特别常见。
3.2 四个点跑通:predict、support_、classes_ 各返回什么
原始示例的数据只有 4 个点、2 个特征、2 个类别,规模小到可以把每一步的中间结果都打出来看。
import numpy as np from sklearn.svm import NuSVC # 两簇点,左上、右下各两个,标签 1 和 2 X = np.array([[-1, -1], [-2, -1], [1, 1], [2, 1]]) y = np.array([1, 1, 2, 2]) clf = NuSVC(nu=0.5, kernel='rbf') # nu 默认就是 0.5,写出来是为了显式 clf.fit(X, y) print('predict:', clf.predict([[-0.8, -1]])) # 预测单个新样本 print('support index:', clf.support_) # 支持向量在训练集里的下标 print('class label:', clf.classes_) # 模型见到的类别,升序排列 print('support vectors:', clf.support_vectors_) # 支持向量本身 print('n_support:', clf.n_support_) # 每个类别的支持向量个数predict返回的是数组而不是标量,即使只传一个样本也是array([1]),想拿单个值要写clf.predict(...)[0]。support_返回的是下标,对应X里的第几行,很多人第一次看会误以为是支持向量的坐标,结果拿去画图时对不上位置。真正的坐标在support_vectors_里,两者顺序一致但类型不同,这个区别在第 5 章还会单独拿出来说。
classes_一定按升序排列,跟你在y里写的顺序无关。这点很重要:多分类时predict_proba的列顺序、decision_function的列顺序都跟着classes_走,如果你自己维护了一份标签映射表,务必以classes_为准而不是以原始y的出现顺序为准。n_support_给出每个类的支持向量数量,加起来等于len(support_)。
在上面这组数据上,nu=0.5通常会让三个点成为支持向量,预测[-0.8, -1]落在类 1 那一侧。把 nu 调到 0.1,支持向量会缩减;调到 0.9,几乎全部样本都被拉进支持向量集合,模型对训练集近乎完美拟合,但泛化能力基本没了。
3.3 nu 的取值边界与网格搜索的写法
nu 有一个硬性约束:不能大到让问题不可行。极端情况下 sklearn 会抛ValueError: specified nu is infeasible,通常发生在 nu 设得很高、而两类样本又严重不均衡的时候。稳妥的做法是把它当成一个待搜索超参,从 0.1 扫到 0.9,步长 0.1,配合交叉验证挑。
from sklearn.model_selection import GridSearchCV, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler pipe = Pipeline([ ('scaler', StandardScaler()), # SVM 对量纲敏感,标准化必做 ('clf', NuSVC(kernel='rbf', gamma='scale')) ]) param_grid = { 'clf__nu': [0.1, 0.3, 0.5, 0.7, 0.9], 'clf__gamma': ['scale', 0.1, 1.0], } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) grid = GridSearchCV(pipe, param_grid, cv=cv, scoring='f1_macro', n_jobs=-1) grid.fit(X, y) # 换成自己的数据即可 print('最优参数:', grid.best_params_) print('交叉验证得分: %.3f' % grid.best_score_)这里几个写法值得固定下来。管道把标准化和分类器绑成一个整体,交叉验证时每一折的均值方差都只从训练折算,不会把验证折的信息漏进来——这一点在直接手写循环时经常被忽略,导致分数虚高。StratifiedKFold保证每折的类别比例和全集一致,小样本上尤其重要,普通KFold在只有四个点这种规模下可能切出单类别的折,直接报错。评分用f1_macro而不是默认的 accuracy,是因为不均衡数据上准确率会骗人。
上面这段代码用的是第 3.2 节的四点数据,样本太少,网格搜索的结果基本没有参考价值,跑通流程才是目的。真实数据上把 nu 的上界压在 0.7 以内通常更稳,太高的 nu 会让模型退化成几乎不过滤的边界。
4. LinearSVC 上鸢尾花:大数据集的线性快车道
4.1 单独实现一个 LinearSVC 的理由
SVC 用核矩阵,时间和内存都是 O(n²) 到 O(n³) 量级,样本过万就开始明显卡顿;LinearSVC 用 liblinear 的对偶坐标下降,复杂度大致线性,样本十万、特征几万也能在几分钟内出结果。这是它存在的核心理由,不是为了“多一个类”。
代价是能力边界很清楚:只做线性,不能换核;多分类默认 one-vs-rest,不是 SVC 那种 one-vs-one;没有support_vectors_,因为对偶坐标下降不显式保留支持向量集合;coef_的形状是(n_classes, n_features),二分类时是(1, n_features),多分类每一行是一个类的超平面。惩罚项默认penalty='l2'、loss='squared_hinge',这两个默认值组合起来训练最稳;改成loss='hinge'才是标准 SVM 的合页损失,但对偶形式要求dual=True,sklearn 新版本里dual='auto'会自己判断,别手写dual=False配hinge,会直接报参数不合法。
文本分类是它的主场。TF-IDF 出来动辄几万维稀疏特征,样本几千到几十万,SVC 在这种规模下要么跑不动,要么内存爆掉,LinearSVC 加上标准化或者直接吃稀疏矩阵都能扛。我的经验是先用 LinearSVC 出一版基线,如果分数够用就收工,不够再考虑上核或者换模型。
4.2 80/20 切分、训练、评分的完整流程
鸢尾花数据集是内置的,150 个样本、4 个特征、3 个类别,拿来做流程演示刚好。原始示例只取了第 0 列和第 2 列,是为了能在平面上画决策边界,这个取舍在教学中合理,但要注意它丢掉了一半信息。
from sklearn import datasets, model_selection, svm from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler iris = datasets.load_iris() X = iris.data[:, [0, 2]] # 花萼长度 + 花瓣长度,便于二维可视化 y = iris.target # 80% 训练、20% 测试,stratify 保证三层类别比例一致 X_train, X_test, y_train, y_test = model_selection.train_test_split( X, y, test_size=0.2, random_state=1, stratify=y) pipe = make_pipeline( StandardScaler(), # 两列量纲接近,但流程里保留更通用 svm.LinearSVC(C=1.0, max_iter=5000, random_state=1) ) pipe.fit(X_train, y_train) print('分类器评分: %.2f' % pipe.score(X_test, y_test)) print('预测的类别:', pipe.predict(X_test)) print('实际的类别:', y_test)test_size=0.2在 150 个样本上就是 30 个测试样本,每类 10 个左右,stratify=y保证这个比例。random_state=1是原始示例用的值,换个数分数会动一两个百分点,这不是模型不稳定,是测试集太小带来的方差。想让评估更可信,把cross_val_score跑五折取均值,比单次切分靠谱得多。
max_iter=5000是我加的。sklearn 新版本对LinearSVC的默认迭代上限是 1000,数据没标准化或者 C 设得很大时容易触发ConvergenceWarning,警告本身不影响结果,但说明解还没收敛,分数不可信。遇到这个警告优先做两件事:标准化、调大max_iter,而不是直接warnings.filterwarnings('ignore')把它盖掉。
运行结果评分通常在 0.9 上下,预测类别和实际类别逐行对下来会有一两个错分,集中在 versicolor 和 virginica 之间——这两类在花萼长度这个维度上本来就重叠严重,只用两列特征到这个水平是正常表现。用全部四列特征再跑一次,分数会明显上去,可以自己对照一次,体会特征选择对结果的影响。
4.3 特征切片、random_state 与结果复现
iris.data[:, [0, 2]]这种花式索引要留意返回的是副本还是视图。numpy 的花式索引返回副本,所以后面无论怎么改X都不会动到iris.data,这一点比切片安全。但如果写成iris.data[:, 0:2],拿到的是视图,再对它做原地标准化就会污染原始数据集,同一进程里第二次加载load_iris()拿到的还是被改过的数据——这个坑我踩过一次,排查了半天才发现是上游把内置数据集改了。
random_state要贯穿始终。train_test_split一个、LinearSVC一个、如果后面接GridSearchCV的cv还有一个,三处不一致的话,同一个脚本两次运行结果不同,你根本分不清是改了参数带来的差异还是随机性。习惯做法是在文件开头定义一个SEED = 42,所有需要随机种子的地方都传它。
最后是量纲。SVM 基于距离,特征尺度差一个数量级,权重就会被大尺度特征主导。鸢尾花四列都是厘米级别,不标准化也能跑出不错的结果,所以很多人从小数据集上学到“SVM 不用标准化”的错误结论。换成年龄(0-100)和收入(0-1000000)这种组合,不标准化的话模型基本只看得见收入。管道里常驻一个StandardScaler,比事后补救省事。
5. 避坑与排查:SVM 落地最容易翻车的五件事
5.1 五条踩坑记录
现象一:同一份脚本,上午跑 0.94,下午跑 0.89,代码一个字没改。原因是切分和模型初始化都没锁种子,train_test_split每次重新洗牌,测试集换了一批样本。小数据集上这个波动能有五个百分点。解决是把random_state显式传给所有带随机性的环节,包括交叉验证的切分器;再把评估从单次切分换成五折交叉验证取均值和标准差,波动立刻变成可解释的数字。
现象二:SVC(kernel='rbf')在四万条样本上跑了半小时还没结束,内存一路涨。不是代码写错,是核矩阵的复杂度摆在那里。样本量上万之后,SVC 的时间随 n² 增长,内存要存 n×n 的核矩阵。解决办法按优先级排:先试 LinearSVC 看分数掉多少,掉得少就直接换;掉得多再做特征降维或者抽样;实在要非线性,换Nystroem近似核映射加 LinearSVC,或者考虑树模型。硬扛 SVM 是最差的选择。
现象三:想拿support_去画支持向量的位置,画出来一片乱点。support_返回的是训练集里的行下标,不是坐标。要画点得用X[clf.support_],或者直接用clf.support_vectors_。这两个属性顺序一致,但一个是整数索引、一个是浮点坐标,混用不会报错,只会画出错误的图,属于最难发现的那类 bug。
现象四:SVC(kernel='rbf')之后访问clf.coef_,AttributeError: coef_ is not available when the kernel is not linear。非线性核的决策函数是在对偶空间里用核函数展开的,没有原始特征空间的权重向量。想要系数就换线性核,或者改用LinearSVC;如果必须用 RBF 又要解释性,退而求其次看dual_coef_和支持向量的分布,或者用置换重要性在预测层面做解释。
现象五:训练集准确率 0.99,测试集 0.62,怎么调参都上不去。先别调参,去看特征量纲和类别分布。量纲差异大时,大尺度特征吃掉全部权重,模型在小尺度特征上学不到东西,训练集靠拟合大尺度特征拿到高分,测试集一换分布就崩。另一个常见原因是 C 设得过大,模型把训练集里的噪声全记住了。处理顺序是:标准化 → 检查类别是否严重不均衡(不均衡就加class_weight='balanced')→ 再把 C 从 1 往下调一个数量级试。三步走完还上不去,问题多半在特征而不在模型。
5.2 上线前固定要过的检查项
模型跑通和模型能用是两件事。我习惯在交付前固定过一遍下面这几项,每项都对应上面某一条坑。
| 检查项 | 怎么查 | 不通过的表现 |
|---|---|---|
| 随机性是否锁定 | 连跑三次比对分数 | 三次结果不一致 |
| 是否进入管道标准化 | 看Pipeline里有没有 scaler | 特征量纲差异大时分数虚高 |
| 是否收敛 | 有没有ConvergenceWarning | 警告刷屏,分数不可信 |
| 训练/测试分布 | 对比两边类别比例 | 测试集某类样本极少 |
| 属性是否用对 | support_与support_vectors_分开 | 画图位置错乱、索引越界 |
第三项补充一句:ConvergenceWarning在 LinearSVC 上出现的频率比想象中高,尤其是数据没标准化的时候。看到它先别调max_iter,先看标准化做没做,做完标准化大部分警告会自己消失。
6. 进阶:用同一把尺子量三个实现
三个类的参数名不一样,直接比分数容易失真。我一般把标准化和分类器封进管道,用同一个交叉验证切分器、同一个评分口径跑一遍,拿到可比的数字再谈选型。
from sklearn.model_selection import cross_val_score, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC, NuSVC, LinearSVC from sklearn import datasets X, y = datasets.load_iris(return_X_y=True) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) candidates = { 'SVC-linear': SVC(kernel='linear', C=1.0), 'SVC-rbf': SVC(kernel='rbf', C=1.0, gamma='scale'), 'NuSVC-rbf': NuSVC(nu=0.5, kernel='rbf', gamma='scale'), 'LinearSVC': LinearSVC(C=1.0, max_iter=5000, random_state=42), } for name, clf in candidates.items(): pipe = Pipeline([('scaler', StandardScaler()), ('clf', clf)]) scores = cross_val_score(pipe, X, y, cv=cv, scoring='f1_macro', n_jobs=-1) print('%-12s 均值 %.3f 标准差 %.3f' % (name, scores.mean(), scores.std()))在鸢尾花这种线性可分度很高的数据上,四个实现的分差一般在两三个百分点以内,LinearSVC往往还略快出结果,这正说明数据简单时没必要为核函数付出代价。真正拉开差距的是样本规模和特征维度:把样本量放大到五万,SVC-rbf的训练时间会变成LinearSVC的几十倍;而如果数据是明显的环形分布,线性核怎么调都追不上 RBF。
我的选型顺序是固定的:先LinearSVC拿基线,因为它快、稳、不吃内存;分数不满意再上SVC(kernel='rbf')试核,同时记下训练耗时;只有当业务上对“不可靠样本占比”有明确预估时,才换成NuSVC,用 nu 把这个预估直接写进参数。三种实现共用同一套管道和同一个切分器,换起来只改一行字典项,比每次重写脚本靠谱。
还有个小技巧值得固定下来:把decision_function的输出分布也打出来看一眼。SVM 的分数不是概率,LinearSVC连predict_proba都没有,直接拿分数当置信度排序会出问题。需要概率就老老实实给SVC打开probability=True,或者用CalibratedClassifierCV在外面包一层做 Platt 校准,别自己拍脑袋把分数线性映射到 0 到 1。
从那以后我每次接 SVM 的活儿,都会先把随机种子、标准化管道和实现类这三件事钉在脚本开头,跑完一轮基线再谈调参,再没出现过“调了一下午发现是量纲问题”。希望帮到你。
本文还有配套的精品资源,点击获取