68、C++项目实战:支持向量机(SVM)
支持向量机,这个名字听起来挺唬人的。说白了,它就是一种二分类算法,核心任务是在数据中找一条「分界线」,把不同类别的样本隔开。而且这条线还不是随便画的——它要离两边最近的样本点都尽可能远。
我记得刚接触SVM时,被那些拉格朗日对偶、核函数绕得晕头转向。后来在做一个文本分类项目时,硬着头皮手撸了一遍,才真正搞明白。嗯,今天我们就用C++把它从零实现一遍。
SVM的核心思想
先看一个简单场景:二维平面上有两类点,我们要画一条直线把它们分开。哪条线最好?
直觉告诉我们,应该选那条「居中」的线。因为如果线太靠近某一类点,新来的数据稍微有点波动,就可能被分错。
SVM把这个直觉数学化了:它要找的直线(在高维空间叫超平面),不仅要能分开两类数据,还要让离它最近的样本点(支持向量)到它的距离最大化。这个距离叫「间隔」。
核心公式:
最大化间隔 = 最小化 ||w||²,同时满足 yᵢ(w·xᵢ + b) ≥ 1
其中 w 是法向量,b 是偏置,yᵢ 是标签(+1或-1)。
从数学到代码:线性可分SVM
我们先处理最简单的情况——数据完全线性可分。这时候问题转化为一个凸二次规划,可以用SMO(序列最小优化)算法求解。
SMO算法的思路很巧妙:每次只优化两个拉格朗日乘子,固定其他所有乘子。这样就把大问题拆成了无数个小问题,每个小问题都能直接求解。
我在项目中遇到过一个问题:当数据量很大时,SMO的收敛速度会变慢。后来我加了一个缓存机制,把频繁访问的核函数结果存起来,速度提升了不少。
// SVM核心类定义
class SVM {
private:
std::vector<double> alpha; // 拉格朗日乘子
double b; // 偏置
std::vector<std::vector<double>> X; // 训练数据
std::vector<int> y; // 标签
double C; // 惩罚参数
double tol; // 容差
int max_iter; // 最大迭代次数
// 核函数:这里用线性核
double kernel(const std::vector<double>& x1,
const std::vector<double>& x2) {
double sum = 0.0;
for (size_t i = 0; i < x1.size(); ++i) {
sum += x1[i] * x2[i];
}
return sum;
}
// 预测单个样本
double predict(const std::vector<double>& x) {
double sum = 0.0;
for (size_t i = 0; i < alpha.size(); ++i) {
if (alpha[i] > 0) {
sum += alpha[i] * y[i] * kernel(X[i], x);
}
}
return sum + b;
}
};
SMO算法的关键步骤
SMO的核心就两步:选择两个要优化的乘子,然后更新它们。选择策略直接影响收敛速度。
- 外层循环:遍历所有违反KKT条件的样本点。先找那些0 < αᵢ < C的样本,因为它们最可能违反条件。
- 内层循环:为选中的αᵢ找一个搭档αⱼ,使得|Eᵢ - Eⱼ|最大。E是预测误差,差值越大,说明这两个乘子一起优化时步长越大。
- 更新乘子:根据约束条件裁剪αⱼ,然后更新αᵢ和b。
避坑指南:我曾经在实现SMO时,忘记处理αⱼ的上下界裁剪。结果迭代了几百次都不收敛,最后发现αⱼ的值早就跑到约束范围外了。记住:每次更新后都要做裁剪,这是SMO能稳定运行的关键。
// SMO算法核心更新逻辑
bool SVM::smo() {
int num_samples = X.size();
int iter = 0;
while (iter < max_iter) {
int num_changed = 0;
for (int i = 0; i < num_samples; ++i) {
double Ei = predict(X[i]) - y[i];
// 检查是否违反KKT条件
if ((y[i] * Ei < -tol && alpha[i] < C) ||
(y[i] * Ei > tol && alpha[i] > 0)) {
// 选择第二个乘子
int j = select_second(i, Ei);
double Ej = predict(X[j]) - y[j];
double old_alpha_i = alpha[i];
double old_alpha_j = alpha[j];
// 计算上下界
double L, H;
if (y[i] != y[j]) {
L = std::max(0.0, alpha[j] - alpha[i]);
H = std::min(C, C + alpha[j] - alpha[i]);
} else {
L = std::max(0.0, alpha[i] + alpha[j] - C);
H = std::min(C, alpha[i] + alpha[j]);
}
if (L == H) continue;
// 计算η
double eta = 2 * kernel(X[i], X[j])
- kernel(X[i], X[i])
- kernel(X[j], X[j]);
if (eta >= 0) continue;
// 更新αⱼ
alpha[j] -= y[j] * (Ei - Ej) / eta;
alpha[j] = clip(alpha[j], L, H);
// 更新αᵢ
alpha[i] += y[i] * y[j] * (old_alpha_j - alpha[j]);
// 更新偏置b
double b1 = b - Ei - y[i] * (alpha[i] - old_alpha_i)
* kernel(X[i], X[i])
- y[j] * (alpha[j] - old_alpha_j)
* kernel(X[i], X[j]);
double b2 = b - Ej - y[i] * (alpha[i] - old_alpha_i)
* kernel(X[i], X[j])
- y[j] * (alpha[j] - old_alpha_j)
* kernel(X[j], X[j]);
if (alpha[i] > 0 && alpha[i] < C) b = b1;
else if (alpha[j] > 0 && alpha[j] < C) b = b2;
else b = (b1 + b2) / 2;
num_changed++;
}
}
if (num_changed == 0) iter++;
else iter = 0;
}
return true;
}
核函数:让SVM处理非线性问题
现实中的数据很少是线性可分的。比如同心圆分布的两类点,你永远找不到一条直线把它们分开。
SVM的解决方案是:把数据映射到更高维的空间。在低维空间线性不可分,到了高维空间可能就分开了。核函数就是干这个的——它让你不用显式地做高维映射,直接在原空间计算内积。
| 核函数类型 | 公式 | 适用场景 |
|---|---|---|
| 线性核 | K(x, z) = x·z | 数据本身线性可分 |
| 多项式核 | K(x, z) = (γx·z + r)ᵈ | 有交互特征的数据 |
| RBF核(高斯核) | K(x, z) = exp(-γ||x - z||²) | 最常用,适合大多数场景 |
| Sigmoid核 | K(x, z) = tanh(γx·z + r) | 类似神经网络的激活函数 |
注意:RBF核虽然强大,但参数γ很敏感。γ太大容易过拟合(每个样本都变成支持向量),γ太小又欠拟合(决策边界太平滑)。我一般用交叉验证来调这个参数,或者先用默认值跑一遍,看看支持向量的数量是否合理。
软间隔:容忍噪声
如果数据中有一些离群点,硬间隔SVM会强行把它们分开,导致决策边界扭曲。这时候需要引入软间隔——允许一些样本点落在间隔内部甚至被分错。
做法是引入松弛变量ξᵢ,每个样本点可以有一定程度的「违规」。目标函数变成:
最小化 ½||w||² + C·Σξᵢ
C是惩罚参数。C越大,对违规的容忍度越低;C越小,决策边界越平滑。我在做金融风控项目时,C通常设得比较大,因为误判的代价很高。
// 软间隔SVM的预测函数
double SVM::predict_soft(const std::vector<double>& x) {
double sum = 0.0;
for (size_t i = 0; i < alpha.size(); ++i) {
// 只有支持向量(alpha > 0)才参与计算
if (alpha[i] > 1e-5) {
sum += alpha[i] * y[i] * kernel(X[i], x);
}
}
return sum + b;
}
// 判断样本是否为支持向量
bool SVM::is_support_vector(int idx) {
return alpha[idx] > 1e-5 && alpha[idx] < C - 1e-5;
}
SVM的完整工作流程
下面这张图展示了SVM从训练到预测的完整流程。你想想看,整个流程其实就三步:数据准备、SMO训练、预测分类。
完整代码示例:训练与预测
最后,我们把这些模块拼起来,形成一个完整的SVM训练和预测流程。这里我用了一个简单的二维数据集做演示。
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>
#include <random>
int main() {
// 生成线性可分数据
std::vector<std::vector<double>> data = {
{2.0, 3.0}, {3.0, 2.0}, {4.0, 4.0}, {1.0, 1.0},
{5.0, 1.0}, {6.0, 2.0}, {7.0, 3.0}, {8.0, 1.0}
};
std::vector<int> labels = {1, 1, 1, 1, -1, -1, -1, -1};
// 创建SVM对象并训练
SVM svm;
svm.set_params(1.0, 0.001, 1000); // C, tol, max_iter
svm.train(data, labels);
// 预测新样本
std::vector<double> test_point = {4.5, 2.5};
double result = svm.predict(test_point);
std::cout << "预测结果: " << (result > 0 ? "正类" : "负类") << std::endl;
std::cout << "决策函数值: " << result << std::endl;
// 输出支持向量数量
std::cout << "支持向量数量: " << svm.get_support_vector_count() << std::endl;
return 0;
}
个人经验:在实际项目中,我很少直接用自己写的SVM。但理解它的原理后,用libsvm或OpenCV的SVM接口时,调参就有底气多了。比如我知道C越大越容易过拟合,γ越大决策边界越复杂——这些都不是黑盒,而是我能掌控的参数。
SVM虽然诞生于上世纪90年代,但在小样本、高维数据的场景下依然能打。尤其是文本分类、图像识别这些领域,配合RBF核,效果往往不输深度学习。嗯,掌握它的原理和实现,对你理解整个机器学习体系都有帮助。
公众号:蓝海资料掘金营,微信deep3321