简介:基于MATLAB 6.5环境的单类支持向量机(One-Class SVM)算法实现,面向异常检测、数据边界建模等无监督学习场景。这类算法的核心是仅基于正样本学习数据边界,通过最大化间隔寻找最优超平面,从而识别与训练样本分布不一致的异常点。压缩包内的主程序 Main_SVM_One_Class.m 按完整流程组织,涵盖数据加载与标准化、核函数(线性/多项式/RBF)参数设置、单类模型训练、决策函数构造、结果评估以及可能的数据分布可视化,适合正在学习SVM原理或需要在旧版MATLAB中搭建单类分类器的研究开发者参考。包内共1个文件,类型为.m源码脚本,整体仅2KB,轻量短小,便于逐行阅读和按需修改,也可以作为理解SVM间隔最大化目标及支持向量概念的入门范例。该资源目前已有110人学习浏览,代码量虽小但结构清晰,能够帮助读者掌握用单一类别样本训练异常检测模型的方法,同时了解早期MATLAB环境下SVM的基础实现思路,为后续功能扩展或向新版本移植提供有益参照。
1. One-Class SVM 到底在解决什么问题:只有正样本的异常检测
解压 Main_SVM_One_Class.rar 之后,很多人第一件事是运行 Main_SVM_One_Class.m,跑通了就以为拿到一个通用异常检测器。实际情况恰恰相反:这个 One-Class SVM 包能不能落地,取决于你手里有没有一批足够干净的正常样本。
它解决的是单类分类问题——训练集里只有正常数据、没有异常数据,模型在正常样本分布上画出一个边界,边界之外就是异常。这正适合工业质检、设备告警、日志异常这类场景,因为异常样本要么太少、要么太贵,凑不齐有监督的二分类数据集。这篇文章按从解压到上线的路径讲:目录结构、数据接口、训练参数、踩坑点,最后聊怎么验证它真的可信。
2. 读懂 Main_SVM_One_Class:代码结构、数据接口与训练内核
2.1 解压后看到什么:文件结构与各模块职责
一个命名为 Main_SVM_One_Class 的代码包,入口脚本通常是主程序,其余是数据加载、训练封装、预测封装和依赖的 libsvm 接口。拿到压缩包先不要急着跑,把目录摊开看一眼,避免后面训练和预测用错文件。
| 文件/目录 | 职责 | 备注 |
|---|---|---|
| Main_SVM_One_Class.m | 主入口:加载数据、设参、训练、预测、评估 | 参数都从这里改 |
| load_data.m | 读取 libsvm 格式数据,拆标签和特征 | One-Class 标签统一置 1 |
| svm_oneclass_train.m | 封装训练,拼 libsvm 命令行 | 核心是 -s 2 |
| svm_oneclass_predict.m | 封装预测,返回标签与决策值 | 决策值是后续阈值的关键 |
| libsvm/ | 编译好的 MATLAB 接口 | 拿到后先确认位数和版本 |
有人会把 One-Class SVM 和 lasso 放在一起问,其实两者解决的问题不一样:lasso 在回归里做稀疏变量选择,One-Class SVM 在“只有正常样本”的数据上做密度边界估计。唯一沾边的是“压缩”这个直觉——nu 参数压缩掉一部分边缘样本,让决策边界不要贴着每一个点走。理解这一点,调参时就不会把 nu 当成普通惩罚系数来用。
在替换或自写训练函数之前,先确认包里带的 libsvm 接口是 64 位还是 32 位编译的。Windows 下很常见的翻车现场是主函数跑起来报 Invalid MEX-file,原因就是接口和当前 MATLAB 版本不匹配。我一般会先读 libsvm 的 README 里 MATLAB 接口那段,重新 make 一次,比在调用处反复排查省时间。
2.2 数据接口:训练集与测试集的喂法
One-Class SVM 的训练数据格式和普通二分类一样,都是“标签 + 特征”,但语义完全不同:训练时所有样本都被当成正类。也就是说,标签列写什么都行,最终在 load_data 里也要强制置 1。常见的数据加载函数长这样:
% load_data.m % 文件格式:每行 = 标签 特征1:值1 特征2:值2 ... function [X, Y] = load_data(filepath) data = load(filepath); % 一次性读入全部数值 Y = data(:, 1); % 第一列是标签 X = data(:, 2:end); % 后面的列都是特征 Y = ones(size(Y, 1), 1); % One-Class:正类标签统一为 1 X = sparse(X); % 高维稀疏特征建议转 sparse end这段代码里有三个容易忽略的点。第一,标签列虽然被强制置 1,但仍要占一列,否则特征矩阵会整体左移一位。第二,libsvm 的默认特征从 1 开始编号,如果数据文件里出现 0:xxx 这样的列,说明原始数据从 0 开始编号,需要在转换脚本里统一加 1。第三,把 X 转成 sparse,在特征维数上千时能省大量内存,One-Class 训练要算核矩阵,数据量大时这是保命操作。
测试集不需要真实标签,但喂给 libsvmpredict 时必须给一个占位标签向量。下面这段是主脚本里的典型调用:
train_X = load_data('data/train.dat'); test_X = load_data('data/test.dat'); model = svm_oneclass_train(train_X, nu, gamma); dummy = ones(size(test_X, 1), 1); [pred, ~, dec] = svm_oneclass_predict(model, test_X, dummy);这里注意 predict 接口的第三个参数是占位标签,只影响返回值里的准确率统计。One-Class 模式下这个准确率没有意义,因为它把“预测为正常”当作正确,哪怕一个异常都抓不到,准确率照样很高。所以后面评估时,不要直接看 libsvm 打印的 Accuracy。
2.3 训练内核:为什么用 SMO 而不是“硬间隔 svm 的梯度下降”
搜索 One-Class SVM 原理时,经常被带到两个词:svm 的梯度下降、硬间隔 svm 的梯度下降。先说结论:传统 SVM 系列很少直接用朴素梯度下降解原问题。硬间隔 SVM 的原问题带不等式约束,朴素梯度下降没法处理“满足约束的同时最小化目标”这个组合;而 One-Class SVM 的原问题更复杂,所以主流实现(包括 libsvm 这类代码包)都走对偶问题 + SMO。
One-Class SVM 的原问题写成:
min 1/2||w||^2 + (1/(nu*n)) * Σξ_i - ρ
约束是 w·φ(x_i) >= ρ - ξ_i,ξ_i >= 0。对偶之后变成一个二次规划:min 1/2 α^T Q α,约束是 0 <= α_i <= C(C = 1/(nu*n))且 Σα_i = 1,Q 是核矩阵。SMO 的核心是每次挑一对违反 KKT 条件的 α_i、α_j 做解析更新,直到所有变量满足条件。包内如果自带训练函数,通常就是这段逻辑:
% 示意:单轮 SMO 更新主体,选点逻辑省略 % alpha: 拉格朗日乘子, C: 1/(nu*n), Q: 核矩阵 for iter = 1:max_iter [i, j] = select_violating_pair(Q, alpha, C); eta = Q(i,i) + Q(j,j) - 2*Q(i,j); if eta <= 0 break; % 核矩阵不正定,直接放弃本轮 end alpha_j_new = alpha(j) + (decision(j) - decision(i)) / eta; alpha_j_new = max(0, min(C, alpha_j_new)); % 箱式约束 alpha_i_new = alpha(i) + alpha(j) - alpha_j_new; alpha(i) = alpha_i_new; alpha(j) = alpha_j_new; end这段代码里 max/min 那一行是 SMO 能工作的关键:它把更新后的 α 夹在 [0, C] 之间,同时保证 Σα=1 不被破坏。eta 是二阶信息,当核矩阵不正定时 eta <= 0,说明 gamma 选得过大或数据有重复行,继续算只会让目标函数震荡。
如果用梯度下降去硬解原问题,常见情况是:未归一化特征让 w 的梯度方向被少数大数值维度带偏,步长只能取得很小,迭代几百轮还在原地。所以代码包里出现 SMO 或坐标下降是正常的,不是设计者不用梯度下降,而是这个问题结构决定了逐个更新拉格朗日乘子更稳。你只需要记住:如果看到训练函数里写了“梯度下降”四个字,先确认它是不是在解对偶问题的某个子步骤,否则十有八九是教学演示代码。
3. 跑通最小复现:libsvm 参数与训练预测命令
3.1 最小复现命令:svmtrain 与 svmpredict
把 One-Class SVM 跑起来的最小命令不超过三行:构造全 1 标签、调用训练、调用预测。以 libsvm 的 MATLAB 接口为例:
nu = 0.05; % 大致期望的异常比例上界 gamma = 1 / size(X, 2); % RBF 带宽的起点:特征维数的倒数 labels = ones(size(X, 1), 1); cmd = sprintf('-s 2 -t 2 -n %.4f -g %.4f -q', nu, gamma); model = libsvmtrain(labels, X, cmd); dummy = ones(size(TestX, 1), 1); [pred, ~, dec] = libsvmpredict(dummy, TestX, model, '-q');这里 -s 2 指定 one-class SVM,-t 2 指定 RBF 核,-n 是 nu,-g 是 gamma。-q 是安静模式,训练过程不刷屏。libsvmpredict 第一个参数必须给占位标签,但 One-Class 下返回值里的 Accuracy 没有任何参考价值,真正的输出是 dec 里的决策值。
不同 libsvm 版本的 MATLAB 接口有个坑:老版本编译后函数名是 svmtrain/svmpredict,而 MATLAB 自带的统计工具箱也有同名函数。常见做法是编译后把 mex 文件重命名为 libsvmtrain/libsvmpredict,或者调用前用 which 确认用的是哪一个。which libsvmtrain一下,如果路径指向 libsvm 目录,说明调用对了。
3.2 三个必调参数:nu、gamma、kernel 的边界
One-Class SVM 真正决定模型行为的参数只有三个:kernel 决定特征空间长什么样,nu 决定边界收紧程度,gamma 决定 RBF 核的感受范围。
| 参数 | libsvm 写法 | 典型范围 | 一句提醒 |
|---|---|---|---|
| kernel | -t 0 线性,-t 2 RBF | -t 2 优先 | 线性核只在特征维度极高时用 |
| nu | -n | 0.01 ~ 0.2 | 别设小于 1/n,模型会退化 |
| gamma | -g | 1/d ~ 10/d | 越大越贴样本,必过拟合 |
nu 不是“异常比例的目标值”,而是异常比例的上界和支持向量比例的下界。也就是说,设 nu=0.1,模型允许最多 10% 的训练样本被当成异常,但实际决策边界可能只牺牲了 3% 的样本。想精确控制“产出多少异常”,要靠决策值阈值,而不是死磕 nu。
gamma 的物理意义是 RBF 核 exp(-gamma*||x-z||^2) 的带宽。gamma 小,每个样本的影响半径大,边界平滑;gamma 大,影响半径小,边界贴着每个点走,泛化能力差。工程上的起点是 1/特征维数,然后往上下各扫 1~2 个数量级。如果样本特征没归一化,gamma 的搜索范围会失去意义,所以数据标准化要放在调参之前。
kernel 的选择上,我一般只留线性和 RBF 两个选项。线性核适合文本 TF-IDF 一类本身就高维稀疏的特征,RBF 适合连续型数值特征。One-Class 场景里多项式核和 sigmoid 核没有明显优势,还多一个系数要调,直接不碰。
3.3 把决策值变成异常分数:阈值与百分比
libsvm 的 One-Class 预测输出里,决策值的方向和大小才是异常判定的依据:决策值越小越异常,越大表示越靠近正常区域。但“多大算大”没有绝对标准,因为决策值受 gamma 和数据尺度影响。
% dec 是 libsvmpredict 返回的决策值矩阵 % 不同版本可能返回 1 列或 2 列,先确认列数再取值 if size(dec, 2) >= 2 dec_vals = dec(:, 2); % 多数版本第二列是 one-class 分数 else dec_vals = dec(:, 1); end % 用训练集决策值分位数定阈值,nu 只是一个先验参考 thr = prctile(train_dec, 100 * nu); is_anomaly = test_dec < thr;这段代码的实质是把连续决策值转成离散异常标签。先跑一遍训练集拿到 train_dec,再用 prctile 取第 nu*100 分位数作为阈值。这样无论模型把边界画成什么样,产出的异常比例都和你设定的比例一致,业务侧也好交代。
有两个细节要注意:第一,分位数阈值的口径必须是“训练集决策值”,不能用测试集,否则阈值跟着测试集分布跑,评估就失真了。第二,决策值的正负不是可靠的异常标志,libsvm 内部虽然用“决策值小于 0 判异常”,但这个 0 是在 C=1/(nu*n) 的尺度下推导出来的,实际数据分布一偏,0 就不在最佳位置上。用分位数而不是 0,能省掉后面一堆解释成本。
4. 调参与避坑:nu 和 gamma 的配合与四个血泪踩坑记录
4.1 nu 和 gamma 的配合策略:先粗筛再细调
One-Class SVM 的参数调节是典型的“两个旋钮互相牵制”。nu 决定边界多紧,gamma 决定边界多光滑,单独调哪一个都可能自我感觉良好,换一组数据立刻翻车。
先粗筛:固定 gamma 在 1/d 附近,把 nu 按 [0.01, 0.05, 0.1, 0.2] 扫一遍。每档训练后看训练集决策值分布:如果决策值整体挤在一个很窄的区间,说明边界太松;如果训练集异常比例远超 nu,说明边界太紧。粗筛的目标不是找到最优,而是确定 nu 的量级。
再细调:在 nu 量级确定后,把 gamma 按 [0.001, 0.01, 0.1, 1] 相对 1/d 的倍数扫一遍,每次记录验证集上的召回率和误报率。这里要强调一个容易误导人的做法:不要用 libsvm 自己打印的 Accuracy 选参,它在 One-Class 下永远是“预测正常=正确”,异常全漏掉也能有 95% 准确率。
我一般在粗筛阶段就把数据做 z-score 标准化。One-Class 对尺度极其敏感,一个量纲不同的特征列会把核距离全部带偏,gamma 再怎么调都是在错误的几何上雕刻。标准化这一步放在训练脚本里,而不是数据预处理脚本里,这样换数据时不容易漏。
提示:One-Class 的验证集至少要包含少量已知异常,否则“调参”只能算“凑参”。
4.2 踩坑记录1:预测结果全是 1,模型“失灵”
现象:训练过程正常结束,但测试集预测标签全部是 1,一条异常都抓不到,决策值也全部落在阈值一侧。
原因:最常见的是 gamma 设得太小。RBF 核在大 gamma 值下每个点只影响自己附近,小 gamma 值下所有点互相靠近,决策边界变成一个半径极大的球,把测试样本全包进去。其次是核函数选错:用了线性核但特征分布高度非线性,边界根本圈不出来。还有一种隐蔽情况:训练数据里混入了大量重复行,核矩阵的秩不够,SMO 提前退出,模型实际只学到了一部分样本。
解决:先打印训练集决策值的分位数,如果 p95 和 p5 之差小于 1e-3,基本可以断定 gamma 过小。把 gamma 从 1/d 往上调 10 倍、100 倍各试一次,看决策值分布拉开没有。对于重复行,去重后再训练,并在 load_data 里加一句 assert,提醒自己数据质量有问题:
assert(size(unique(X,'rows'),1) > 0.5 * size(X,1), '重复样本过多,先做去重');4.3 踩坑记录2:训练和预测特征不对齐,决策值失真
现象:预测阶段不报错,但决策值整体偏移,评估指标波动很大,同一批次数据换个顺序结果都不稳定。
原因:训练和预测用了两套预处理流程,典型的特征是列数对不上或者列顺序不一致。比如训练时做了特征选择删了 3 列,预测时忘删,X 的维度从 128 变成 131,libsvm 调用时按列顺序硬算,结果全部错位。更隐蔽的是类别型变量编码不一致:训练时某类编码成 0,预测时编码成 2,数值上看似正常,核距离全变了。
解决:在训练和预测入口都写死特征校验。
assert(size(X, 2) == size(TestX, 2), '训练/测试特征维度不一致');特征顺序也要保证一致,最好把特征名列保存成一个 .mat 文件,预测前 compare 一遍。这是最省力的后悔药:与其排错半天,不如让程序在入口就拒绝不一致的数据。同时把标准化参数(均值、标准差)保存下来,预测时用训练集的参数去变换,不要把测试集自己算的均值套回去。
4.4 踩坑记录3:SMO 不收敛或收敛太慢,浮点与初始值
现象:训练时间异常长,或者 libsvm 训练命令反复输出迭代警告,最后模型效果与 nu 设定明显对不上。
原因:One-Class 的对偶问题里 C = 1/(nu*n),当 nu 设得极小(比如 0.0001)而样本量又大时,C 值很大,α 的上界被推高,SMO 的搜索空间变大,收敛变慢。另一个常见原因是数据未标准化导致特征尺度差几个数量级,核矩阵条件数变差,SMO 每次更新步长都很小。gamma 过大也会让 Q 矩阵近似半正定,eta 接近 0,更新失效。
解决:nu 的下限按 1/n 来设,n 是训练样本数。比如样本 2000 条,nu 低于 0.001 基本没有统计学意义,模型只会得到“所有点都正常”的平凡解。数据必须做 z-score 或归一化到 [0,1],这一步做完,同样的 gamma 网格下训练时间能差 5 倍以上。如果确认不是这两个原因,再看 libsvm 的迭代设置,把容差参数从默认值适当放宽到 0.001 级别,多数情况能正常收敛。
4.5 踩坑记录4:类别极端不平衡时拿准确率自我安慰
现象:测试集里真实异常比例只有 1%,模型一个异常都没抓到,但 libsvm 打印的 Accuracy 高达 99%,汇报时差点把坏模型当成了好模型。
原因:One-Class 是单类分类,它的天然倾向是把所有点判为正常。当异常比例极低时,准确率被“正常样本猜中率”主导,一个什么都不做的模型也能拿高分。这不是参数问题,是评估指标选错了。
解决:不要用 Accuracy,改用下面的口径:把测试集里已知的正常、异常分开,算异常召回率、正常误报率,再画 PR 曲线。如果没有真实异常标签,就用训练集决策值分布来定阈值,并且只报告“异常比例 + 误报代价”,把决策权交给业务方。
recall = sum(pred_anomaly & true_anomaly) / max(sum(true_anomaly), 1);这个指标才是 One-Class 模型真正的体检报告。
5. 验证与进阶:如何让 One-Class SVM 真正可信
5.1 评估指标:用召回率-假阳率替代准确率
有了 4.5 的教训,验证环节的指标选择就清楚了。One-Class SVM 的典型评估表如下:
| 指标 | 公式 | 说明 |
|---|---|---|
| 异常召回率 | TP/(TP+FN) | 真实异常抓到多少,主要指标 |
| 正常误报率 | FP/(FP+TN) | 正常被误杀多少,代价指标 |
| F1 | 2PR/(P+R) | 两者折中 |
| PR-AUC | 阈值扫描积分 | 不依赖具体阈值,适合汇报 |
我习惯把决策值阈值从低到高扫一遍,画出一条 PR 曲线,把“业务可接受的误报率”对应的点圈出来。这个点才是最终阈值,而不是训练时设的 nu。
5.2 进阶技巧:特征提取 + 交叉验证网格搜索
纯手工特征的 One-Class SVM 有上限,当前更常见的做法是把 CNN 当特征提取器:卷积层做平移不变的局部特征,倒数第二层的 embedding 作为输入喂给 One-Class SVM。训练时用正常样本的 embedding 拟合边界,预测时对 embedding 打分。这个组合比直接把原始像素喂进 SVM 泛化能力强很多。
参数搜索用两层循环即可,不用一上来就上贝叶斯优化:
nu_list = [0.01 0.05 0.1 0.2]; gamma_list = [0.001 0.01 0.1 1]; for nu = nu_list for gamma = gamma_list % 每一组都跑一遍训练 + 验证集评估,记录召回率和误报率 % 不要用 libsvm 打印的 Accuracy 做选择依据 end end网格大小控制在 4x4,一组数据几分钟出结果,比盲目信任默认参数靠谱得多。最后再强调一个习惯:每次调参后把 nu、gamma、预处理参数和对应指标一起写进实验记录。这个习惯救过我很多次,模型上线三个月后回看,最怕的就是说不清当时为什么选这组参数。One-Class SVM 不是开箱即用的模型,它的价值建立在“对数据分布的理解 + 验证指标的诚实”之上。希望这些踩坑经验能帮到你,让你在这个方向上少走弯路。
本文还有配套的精品资源,点击获取