前几天一个朋友扔给我一份表格数据,二分类,几百条样本,特征里既有连续数值也有类别变量。我一开始手痒,直接掏出神经网络想硬刚,结果训练没几个 epoch 就过拟合得没眼看,验证集准确率跟抛硬币差不多。后来我老老实实把 SVM,也就是支持向量机,重新捡起来,才发现这个“老古董”在小样本场景下有多能打。更没想到的是,折腾的过程里我还顺手整出了两个自己的“新算法”——准确说是两个针对 SVM 的魔改方案,一个治类别不平衡,一个治特征尺度不统一。今天就把这段整活实录完整写出来,包括思路、代码、翻车现场和最终结果,希望能给同样在小数据上折腾分类问题的朋友一点参考。
1. 为什么我现在还会捡起 SVM:小样本、核技巧和一颗不折腾不舒服的心
1.1 SVM 到底在找一个什么样的边界
很多人第一次接触 SVM 的时候,听到的解释都是“找一个最大间隔超平面”。这个说法没错,但容易让人忽略它真正厉害的地方。
想象二维平面上有两堆点,你要画一条线把它们分开。随便画一条能分开的线并不难,难的是这条线对未来新样本的容忍度。SVM 的思路不是“能分开就行”,而是让这条线离两边最近的点都尽量远,这个距离叫间隔(margin)。离这条线最近的那些点,就是支持向量。整个模型的决策边界其实只由这几个点决定,其他点离得再远也不影响结果,这就是 SVM 在小样本下不容易被“无关样本带偏”的核心原因。
当数据线性不可分的时候,SVM 会通过核函数把样本映射到高维空间,在高维空间里寻找线性边界。这个过程不需要真的算出映射后的坐标,只需要计算样本两两之间的内积,也就是核函数值。比如 RBF 核:
k(x, z) = exp(-γ ||x - z||²)
它的本质是在说:两个样本越接近,相似度越高。通过调整 γ,相当于控制这个相似度随距离衰减的快慢。这个设计让 SVM 能刻画非常复杂的非线性边界,同时仍然保留着支持向量带来的稀疏性。这两点加在一起,使得 SVM 在数据量不大、特征维度中等的情况下,往往比深度学习更实用。
1.2 我为什么不直接上神经网络
这次任务的数据量只有两三百条,神经网络在这种规模下几乎是“灾难现场”。深度模型动辄几十万上百万参数,喂这么点数据,哪怕是加了 Dropout 和正则化,也很容易把训练集的特征背下来,而不是学到真正的规律。反观 SVM,它的复杂度主要由支持向量数量决定,而不是特征维度,在高维小样本场景下有天然的泛化优势。
另外一个现实问题是训练成本。神经网络需要调学习率、调 batch size、调网络层数,还可能要在 GPU 上跑半天;SVM 的 SMO 算法在几千样本内基本是秒级完成,参数也就 C 和 γ 这些,网格搜索一下就能出结果。我这次的数据根本用不上 GPU,一个 CPU 脚本几十毫秒完事,这对快速验证想法来说太重要了。
1.3 我要处理的三个典型场景
为了把这次“整活”做得像样一点,我没有直接拿朋友的数据开测,而是先构造了三个典型的二分类场景,分别对应现实中最常遇到的问题:
- 场景 A:类别不平衡。负类样本 500 个,正类样本只有 50 个,两类中心距离比较近。
- 场景 B:多尺度分布。同一个类别内部存在密集区域和稀疏区域,单一核参数很难两头兼顾。
- 场景 C:类别重叠。两类数据分布有部分区域天然混在一起,无论什么模型都不可能完美分开。
这三个场景不是我拍脑袋想的,都是从实际项目中抽象出来的痛点。后面要说的两个“新算法”,就是在处理场景 A 和场景 B 时慢慢养出来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 算法一号:非对称惩罚,把被“吃掉”的少数类救回来
2.1 数据失衡后,SVM 的决策边界是怎么被带偏的
标准 SVM 的软间隔版本,对所有误分类样本采用同一个惩罚系数 C。这个设计在类别均衡时问题不大,一旦正负样本比例悬殊,问题就来了。
优化目标里,所有样本的松弛变量 ξi 都会被累加,然后乘以 C。负类样本数量多,误分类几个负类带来的总惩罚就大;正类样本数量少,即使全部误分类,总惩罚也有限。优化器为了最小化总损失,会很“聪明”地牺牲少数类,换取多数类的高正确率。最后训练出来的决策边界会整体向少数类方向偏移,导致少数类的召回率低得可怜。
我可以用一个类比来解释:同样一张罚单,对于月入三千和月入三万的人来说冲击力完全不同。标准 SVM 对两类一视同仁,相当于罚款金额固定,结果自然是对“重要”的那一方约束力不足。
2.2 数学改动:给不同类别设定不同的“价格”
算法一号的思路很简单:既然不同类别的误分类代价不同,那就别用同一个 C。目标函数从:
min ½||w||² + C Σ ξi
改成:
min ½||w||² + C₊ Σ_{i: yᵢ=+1} ξi + C₋ Σ_{i: yᵢ=-1} ξi
其中 C₊ 是少数类的惩罚系数,C₋ 是多数类的惩罚系数。通常取 C₊ / C₋ ≈ n₋ / n₊,也就是用样本数目的反比来补偿数量差异。在我的场景 A 里,负样本 500,正样本 50,比例是 10:1,那 C₊ 就是 C₋ 的 10 倍。这个改动本身不是玄学,它只是把“少数类样本更珍贵”这个先验知识显式地写进了优化目标。
在 scikit-learn 里,不需要手动改底层求解器,直接传 class_weight 参数就行:
python复制from sklearn.svm import SVC
model = SVC(
kernel="rbf",
C=1.0,
class_weight={0: 1.0, 1: 10.0} # 正类权重给到负类的10倍
)
model.fit(X_train, y_train)
如果你的数据类别比例没那么固定,也可以直接用 class_weight="balanced",让 sklearn 根据实际样本数自动计算权重。
2.3 实验效果:召回率上来了,但精度代价不小
我在场景 A 上做了对比实验,分别是标准 RBF-SVM 和带非对称惩罚的加权 SVM。结果非常典型:
| 指标 | 标准 RBF-SVM | 加权 SVM(C₊:C₋ = 10:1) |
|---|---|---|
| 整体准确率 | 0.94 | 0.88 |
| 少数类召回率 | 0.52 | 0.86 |
| 少数类精确率 | 0.78 | 0.53 |
| 少数类 F1 | 0.62 | 0.66 |
看到没有,整体准确率反而下降了,因为原本大量被错误分类的少数类样本现在被“抢”回来了,代价是少数类里有更多噪声被误判为多数类,少数类的精确率掉了不少。但 F1 从 0.62 涨到 0.66,说明综合效果是变好的。
这个结果告诉我们一个残酷的事实:在不平衡数据上追求“整体准确率”本身就是陷阱。100 个样本里 90 个是负类,你全猜负类都有 90% 准确率,但这毫无意义。真正应该关注的是少数类的召回率和 F1,而加权 SVM 正是在这个维度上发挥作用。
2.4 这个算法的边界在哪里
权重并不是越大越好。我试过把 C₊ / C₋ 拉到 50:1,结果决策边界被强行拽向少数类,大量多数类样本被误判,整类准确率崩盘。权重过大还会让模型对少数类里的离群点极度敏感,一个噪声点就能把支持向量带偏,导致严重的过拟合。
另外,如果少数类样本本身就存在大量标签噪声,加权只会放大这些噪声的影响。这种情况下,单纯调权重不如先做数据清洗,或者引入异常检测机制把可能的噪声点先过滤掉。
3. 算法二号:局部自适应核,让每个样本都有自己的尺度
3.1 一个固定 γ 处理不了“密度不均”的数据
RBF 核的核心参数是 γ,它决定了相似度随距离衰减的速度。γ 大,决策边界变得复杂,容易过拟合;γ 小,边界平滑,容易欠拟合。问题在于,当数据在不同的区域拥有完全不同的密度时,一个全局 γ 根本无法同时满足所有区域的需求。
我构造的场景 B 是这样的:负类样本集中在一个紧密的团块里,正类样本则散布在周围稀疏的区域中。用较大的 γ(比如 γ=1.0),密集区域的边界能被拟合得很好,但稀疏区域稍远一点的两个样本之间相似度就直接降到近 0,模型在稀疏区域几乎退化成一堆孤立的点;用较小的 γ(比如 γ=0.1),稀疏区域总算能正常分类了,但密集区域内本该区分的细节又被“抹平”了。
这就好比你用同一把尺子去量蚂蚁和大象,必然有一方不合适。单个核参数本质上就是这把“固定尺子”。
3.2 从 k 近邻距离构造逐样本 σ
既然密度在不同区域不一样,那就让每个样本拥有自己的尺度。对于每个样本 xᵢ,我计算它的第 k 近邻距离 dᵢ。如果 xᵢ 周围很稠密,它的第 k 近邻距离就小;如果 xᵢ 在稀疏区域,这个距离就大。这个 dᵢ 天然反映了局部密度。
然后我定义核函数时,不再用全局固定的 σ,而是用两个样本各自局部尺度的组合:
k(xᵢ, xⱼ) = exp(-||xᵢ - xⱼ||² / (2 · σᵢⱼ²))
其中 σᵢⱼ 取 (σᵢ + σⱼ) / 2,σᵢ 就是第 k 近邻距离加上一个很小的平滑项。这样,密集区域的样本之间的距离会被较小尺度放大,稀疏区域的样本则用较松的尺度去衡量相似度,两边各得其所。
还别说,这个方法做出来的核矩阵并不是严格正定的,理论上和标准核函数的性质有差异。但在实际测试里,SVC 训练通常还是能收敛;如果你非要在数学上较真,可以用对称化处理或者只把它当成一次实验性的探索。反正我是“浣熊式”试验,先跑通再说。
3.3 预计算核矩阵接入 sklearn
sklearn 的 SVC 支持 kernel="precomputed",也就是说我们可以自己算核矩阵喂给它。下面是核心代码:
python复制from sklearn.svm import SVC
from sklearn.neighbors import NearestNeighbors
import numpy as np
def local_sigma(X, k=5):
nn = NearestNeighbors(n_neighbors=k + 1).fit(X)
dists, _ = nn.kneighbors(X)
# 第 k 近邻距离(去掉自身)
return dists[:, -1] + 1e-6
def adaptive_kernel_matrix(X, k=5):
n = X.shape[0]
sigma = local_sigma(X, k)
K = np.zeros((n, n))
for i in range(n):
for j in range(i, n):
s = (sigma[i] + sigma[j]) / 2.0
diff = X[i] - X[j]
K[i, j] = K[j, i] = np.exp(-np.dot(diff, diff) / (2.0 * s * s))
return K
K_train = adaptive_kernel_matrix(X_train, k=5)
model = SVC(kernel="precomputed", C=100.0)
model.fit(K_train, y_train)
注意,predict 阶段也必须构造一个测试集样本与训练集样本两两之间的核矩阵,维度是 (n_test, n_train),而不是 (n_test, n_test)。初学者最容易在这里踩坑:
python复制K_test = np.empty((len(X_test), len(X_train)))
for i in range(len(X_test)):
for j in range(len(X_train)):
s = (sigma_test[i] + sigma_train[j]) / 2.0
diff = X_test[i] - X_train[j]
K_test[i, j] = np.exp(-np.dot(diff, diff) / (2.0 * s * s))
y_pred = model.predict(K_test)
这里的 sigma_test 和 sigma_train 是在同一个样本集上计算出来的。实际操作中,我建议把训练集和测试集拼在一起算局部 sigma,再拆回去,因为测试集的局部密度同样需要周围样本(包括训练样本)的信息来估算。无监督地利用测试集信息是可行的,但在严格机器学习流程里要谨慎,至少不能用于特征选择或模型选择。
3.4 局部核在场景 B 上的表现
在场景 B 的合成数据上,局部自适应核 SVM 比标准 RBF-SVM 的准确率提升了大约 8 个百分点。标准 RBF-SVM 在密集区域表现尚可,但到了稀疏区域就直接“迷路”;局部核则能在稀疏区域给出更平滑的决策边界,同时不牺牲密集区域的细节。
这个结果也带来一个副作用:模型参数更多了。除了原本的 C,还要调 k 近邻的 k 值和距离度量。k 太小,sigma 估计不稳定;k 太大,局部密度信息被平均掉,退化成全局核。在我这次实验里,k=5 是一个比较稳妥的起点,通常我会在 3 到 15 之间做网格搜索。
4. 多算法对打:指标对比、翻车现场和后来才想明白的坑
4.1 实验设计:三个场景、四个候选方法
为了给“两个新算法”验明正身,我在三个合成场景上跑了四个候选方法:
- 标准 RBF-SVM(基线)
- 加权 SVM(算法一号)
- 局部自适应核 SVM(算法二号)
- 加权 + 局部核 SVM(两位合体)
每个场景固定训练集规模、随机种子和测试集比例,特征统一做标准化,C 和 γ 通过网格搜索选择,所有模型用同一套评估指标。我把结果整理成了一张表:
| 场景 | 方法 | 准确率 | 少数类 F1 |
|---|---|---|---|
| A(不平衡) | 标准 RBF-SVM | 0.94 | 0.62 |
| A(不平衡) | 加权 SVM | 0.88 | 0.66 |
| B(多尺度) | 标准 RBF-SVM | 0.80 | 0.70 |
| B(多尺度) | 局部核 SVM | 0.88 | 0.81 |
| C(类别重叠) | 标准 RBF-SVM | 0.71 | 0.68 |
| C(类别重叠) | 加权 + 局部核 | 0.73 | 0.73 |
4.2 结果解读:算法各有主场,合体不一定更强
从表里可以清楚看到,加权 SVM 在场景 A 有效,局部核 SVM 在场景 B 明显领先。到了类别重叠严重的场景 C,单独用其中一个算法效果都一般,把两者合在一起才略有改善,但提升幅度远不如前两个场景那么戏剧性。
这说明一个重要道理:任何算法都有自己的“适用范围”,不存在银弹。加权惩罚解决的是类别数量失衡,局部核解决的是类别内部密度不均,当两个问题同时存在时,叠加使用确实有帮助;当边界本身彻底不可分时,再高级的核函数也只是在噪声里找规律,天花板摆在那里。
4.3 预计算核的索引:一个差点让我怀疑人生的 bug
我调试局部核的时候,遇到过一个特别隐蔽的问题:训练阶段准确率几乎 100%,但测试阶段预测结果乱七八糟,怎么看怎么不对。后来我检查测试核矩阵,才发现我构造 K_test 的时候错用了训练集内部的索引顺序,导致测试样本和训练样本的对应关系完全错位。核矩阵本身是一个相似度矩阵,索引一旦错位,模型拿到的就是一份“张冠李戴”的相似度表,预测当然全废了。
这个坑的教训是:预计算核模式下,你不仅要确保核矩阵的数值正确,还要确保行列顺序和标签顺序完全对齐。建议在构造完核矩阵之后,先打印几个元素人工核对,或者画一个相似度热力图,一眼就能看出有没有问题。我后来为了保险,写了一个小的封装函数,专门负责传入一对索引数组,让内核矩阵的构造逻辑永远和训练/测试划分逻辑保持一致。
4.4 其他大大小小的坑:标准化、权重和过拟合
第一个坑是特征标准化。SVM 的 RBF 核计算的是欧氏距离,如果特征之间的量纲差异很大,距离就会被数值大的特征主导。这次数据里有范围在 0 到 1 的特征,也有范围在 0 到 10000 的特征,不标准化直接丢进 SVM,模型基本废掉。后来我在管道里加上了 StandardScaler,效果立竿见影。
第二个坑是权重过犹不及。我调加权 SVM 的时候,把权重从 10 一路加到了 40,想看看少数类召回率能不能更高。结果召回率确实上去了,少数类精确率却掉到 0.2 以下,整体 F1 反而更差。权重本质上是一种对数据的“先入为主”,权重越大,模型越容易把少数类区域扩展成无底洞,连噪声一起全部圈进去。通常权重不要超过样本比例的 20 倍,超过之后收益递减,风险反而递增。
第三个坑是网格搜索里 C 和 γ 的取值范围。很多教学代码直接给 logspace(-3, 3),但我这次实际跑下来,C=100 和 γ=0.01 附近的区域比默认范围表现更好。与其用标准套路,不如先粗跑一遍 GridSearchCV 画出热力图,大致确定最优值的量级,再缩小范围精调。这不是炫技,而是真实场景下更高效的调参方式。
5. 最后想说:Hack SVM 的一点点私藏心得
5.1 什么情况下值得把 SVM 翻出来用
经过这次“浣熊式折腾”,我对 SVM 的使用场景有了更具体的判断。如果你遇到这些问题:训练样本量在几千到几万以内,特征维度在几百到几千之间,对推理速度有要求,或者希望模型具备一定的可解释性,那 SVM 绝对值得优先考虑。相反,如果数据量巨大且带有强序列结构,或者你已经有一套稳定的深度学习基础设施,那 SVM 的优势就没那么明显了。
核方法最大的价值在于它能让你在不显式构造高维特征的情况下,利用样本间相似度去建模复杂边界。这种思路本身没有过时,反而在如今注意力机制满天飞的时代显得异常清爽。
5.2 两个“新算法”什么时候用
算法一号(非对称惩罚)适合样本类别严重不平衡、且少数类本身质量较高的场景。算法二号(局部自适应核)适合特征空间内局部密度差异较大、全局单一核参数明显不够用的场景。两个算法组合使用时,建议先调核参数,再调权重,因为核参数决定的是模型刻画边界的能力,权重则是在这个边界基础上做偏置。如果顺序反了,很容易陷入权重和核参数互相干扰的死循环。
5.3 给同样爱“整活”的同行一句实话
我这一路跑下来最大的体会是:调机器学习模型,真正决定成败的往往不是模型本身,而是对问题的理解和对细节的把控。标准 SVM 到加权 SVM 的改动,数学上只是一行公式;从固定核到局部核,代码量也就多了几十行。但就是这些看似微小的调整,把效果从不可用拉到了可用。
另外,实验源码里最好固定随机种子,不然你以为算法有差别,实际上可能只是数据划分波动造成的假象。我这次所有对比实验都在固定了 seed 的情况下重跑过 3 遍,才敢把表里的数字写出来。这点经验,建议每个在项目里勤勤恳恳改代码的同学都记下来。
