简介:针对仅有一类样本即可建模的场景,面向需要在MATLAB中实现单类支持向量机的开发者,这份压缩包提供了一套完整的单类分类算法源代码,可用于异常检测、数据边界建模、设备状态监控等无监督学习场景。压缩包内只有1个M文件,大小仅2KB,代码非常精简,适合在MATLAB 6.5及后续版本中直接运行与研读。由于单类支持向量机只需要正样本即可完成训练,该实现覆盖了数据加载、参数设置、模型训练、决策函数构造等关键环节,能够帮助读者快速理解核函数选择与间隔最大化原理如何落地为可运行代码。目前已有110人学习浏览,对于具备一定机器学习基础、希望掌握旧版MATLAB编程风格并快速上手异常检测算法的初学者,是一份轻量而直接的参考资料。
1. One-Class SVM 到底在解决什么问题:从一次质检漏检说起
做工业质检的朋友常跟我抱怨一个场景:正常产品的特征数据攒了几千条,真正有瑕疵的样本却只有零星几条——坏件太少,连一个像样的二分类训练集都凑不齐。One-Class SVM 就是为这类任务准备的。它在特征空间里画一个尽可能紧的边界,把绝大多数正常样本包进去,边界之外的统统判为异常。你不需要收集大量正反样本,只需要一份干净的正常数据,就能得到一个可用的异常检测器。标题里的 Main_SVM_One_Class 正是这一类方案的典型实现:单类支持向量机,用在设备状态监测、手写数字识别里的离群样本清洗、信贷反欺诈这些冷启动场景中。这篇笔记会讲清楚它的原理、最小可运行代码、三个必调参数,以及我踩过的几个坑。
2. One-Class SVM 的原理:为什么只需一类样本就能画出决策边界
2.1 支持向量数据描述(SVDD)与 ν-SVM 的关系
One-Class SVM 的目标是学出一个决策函数:对于新的样本点,函数输出 +1 表示"正常",输出 -1 表示"异常"。它跟普通 SVM 最大的区别是训练阶段只用正常样本,不出现负类。实现上有两条常见路线:Schölkopf 提出的 ν-SVM 把原点当作唯一的负样本,在特征空间里学习一个超平面,尽可能把数据点和原点分开;Tax 和 Duin 提出的 SVDD 则是直接找一个最小半径的超球把所有正常样本装进去。两条路线在 RBF 核下表现非常接近,边界都能写成核函数的线性组合:f(x) = sign(∑ αᵢ K(xᵢ, x) - ρ)。
这个公式值得多看两眼。αᵢ 是支持向量的权重,ρ 是阈值,K(xᵢ, x) 是核函数计算的相似度。决策过程本质上是计算新样本与所有支持向量的加权相似度之和,再跟阈值比较。所以当你调参时,实际在调整的是"这群正常样本在特征空间里聚集得有多紧"。理解了这一点,后面调 nu 和 gamma 就不再是盲猜了。
2.2 核函数把数据映射到高维空间:线性不可分问题的突破口
原始特征空间里,正常样本往往不是球形分布,直接用欧氏距离做阈值判断误差很大。RBF 核的作用是把数据映射到高维空间,让原本纠缠在一起的点在高维空间中变得线性可分。以手写数字识别中的 optdigits 数据集为例,同一个数字的不同书写风格在 64 维像素空间里散布很广,字体粗细、倾斜角度带来的差异有时比数字间的差异还大。直接算原始像素的距离做异常检测,几乎必败。换成 RBF 核之后,核函数会把"像素向量的内积"换成"高斯相似度",距离近的点权重高、距离远的点迅速衰减,边界才有区分能力。
核函数的选择直接决定模型的表达能力。RBF 核是默认首选,因为它隐含地映射到无穷维空间,能拟合任意形状的正常分布。线性核计算量小,适合特征维度非常高、样本量也大的场景,比如文本 TF-IDF 特征。多项式核偶尔用,但参数多了容易过拟合,实际项目里我基本只用 RBF。
2.3 从手机传感器异常检测说起:One-Class SVM 的适用场景边界
One-Class SVM 适合"正常数据丰富、异常数据稀缺"的场景。手机陀螺仪漂移检测是典型例子:正常姿态数据每天几百条,但真的漂移发生可能也就几秒钟。数控机床的刀具磨损预警也一样,正常磨损数据多,崩刃的数据难得。不过它有一个硬前提:正常样本的分布必须稳定。如果设备有多个工况,低速、高速、空载的数据混在一起喂进去,模型就会画一个包住所有工况的大圈,边界松松垮垮,真实异常混进来根本分不出。
常见的解决办法是按工况分模型。先把数据用 KMeans 或按转速区间切片,对每个簇单独训练一个单类模型。推理时先判断样本属于哪个工况,再进对应的模型。这样做模型数量会变多,但每个模型的边界都是紧的,灵敏度和误报都更可控。另外,One-Class SVM 对异常类型不敏感——它只能告诉你"这个点跟正常分布不一致",至于它是传感器坏了还是外部干扰,它不会说。下游需要再接一个分类器或者人工分析去定位原因。
3. 把 Main_SVM_One_Class 跑起来:最小实现与核心代码
3.1 环境准备与数据加载:用 optdigits 手写数字做单类训练
要快速验证这个方案,我一般用 UCI 的 optdigits 数据集。它有 5620 个手写数字样本,每个是 8x8 像素的 64 维向量。做法是挑一个数字(比如"8"),把它的全部样本当作正常数据训练 One-Class SVM,然后把其他数字的样本当作异常来测。这个设计很贴近真实场景:有一类数据,验证另一类是否会被正确拒之门外。
环境上只需要numpy、scikit-learn、matplotlib。先说数据加载,sklearn 里没有直接封装的 optdigits,需要从.csv读。UCI 的 optdigits 原始文件是每行 65 个数字,前 64 个是像素灰度值,最后一个 0-9 是标签。读取代码这样写:
import numpy as np def load_optdigits(path): # 读取 UCI optdigits 原始 CSV,每行 65 个数 data = np.loadtxt(path, delimiter=",") X = data[:, :64] # 前 64 列是像素值 y = data[:, 64].astype(int) # 最后一列是数字标签 return X, y # 示例:加载后筛出数字 8 作为正常类 X, y = load_optdigits("optdigits.tra") X_normal = X[y == 8] print("数字 8 的样本数:", len(X_normal))这段代码先读原始文件,再把像素和标签分开。像素值是 0 到 16 的灰度整数,整体范围不大,但后面做标准化仍然必要——不同特征列的方差差异会在核函数计算中被放大,这个坑第 5 章会细说。
3.2 训练一个 One-Class SVM 的最小代码(Python 示例)
核心训练代码很短,sklearn.svm.OneClassSVM封装好了所有东西:
from sklearn.svm import OneClassSVM from sklearn.preprocessing import StandardScaler # 标准化:让每个特征均值为 0、方差为 1 scaler = StandardScaler() X_scaled = scaler.fit_transform(X_normal) # 训练单类 SVM model = OneClassSVM(kernel="rbf", nu=0.05, gamma="scale") model.fit(X_scaled) # 预测:返回 1 表示正常,-1 表示异常 pred = model.predict(X_scaled) print("训练集上被判为异常的样本数:", (pred == -1).sum())代码逻辑分三步:先做标准化,再建模型,最后预测。nu是训练误差上界的比例,设 0.05 表示允许约 5% 的训练样本被当作异常;gamma设 "scale" 是 sklearn 的自动估计模式,它取 1 / (特征数 * 方差),比手写一个固定值稳。预测返回的-1就是模型认为的离群点,这些点往往是训练集里写得很潦草或者有噪声的数字,把它们剔掉后再训练一遍,模型会更纯。
3.3 预测与决策边界可视化:画 ROC 还是画等高线
模型训完不能只看准确率,得直观看看边界长什么样。二维数据可以画等高线,64 维数据没法直接画,但可以用 TSNE 降维到 2D 再叠加决策区域。不过更实用于评估的方法是算 ROC AUC——把其他数字当异常样本,看模型能否排对。
from sklearn.metrics import roc_auc_score from sklearn.model_selection import train_test_split # 构造测试集:正常类是数字 8,异常类是数字 3 X_test_normal = X[y == 8][:200] X_test_abnormal = X[y == 3][:200] X_test = np.vstack([X_test_normal, X_test_abnormal]) y_test = np.hstack([np.ones(len(X_test_normal)), -np.ones(len(X_test_abnormal))]) # 标准化后获取决策分数(decision_function,越大越正常) X_test_scaled = scaler.transform(X_test) scores = model.decision_function(X_test_scaled) # 计算 ROC AUC auc = roc_auc_score(y_test, scores) print("ROC AUC:", round(auc, 4))这里用decision_function而不是predict,因为 ROC 曲线需要连续分数,而predict只给离散的 1 和 -1。decision_function返回的就是前面公式里 ∑ αᵢ K(xᵢ, x) - ρ 的值,正值越大代表越接近正常分布中心,负值越大代表越远离。ROC AUC 在 0.9 以上说明模型能把数字 8 和数字 3 分开;如果在 0.5 附近,说明边界没学到东西,多半是 gamma 或者 nu 设得不对。
提示:实际项目中至少把 20% 的正常样本拿出来当验证集,不要用训练集本身评 AUC,否则会高估模型能力。
4. 参数调优的三个必调项:nu、gamma、kernel
4.1 nu 参数:训练误差的上界与支持向量比率的平衡
nu是 One-Class SVM 最核心的参数,它同时控制训练误差的上界和支持向量比重的下界。nu 设 0.01,模型只容忍 1% 的训练点被拒绝,边界会尽量大,误报少但漏报可能多;nu 设 0.2,边界会明显收窄,把更多边缘样本拒掉,适合异常比例本身较高的场景。
nu 的取值跟你的数据污染率直接相关。如果正常训练集里本来就混了一小撮异常点,nu 至少设到 0.05 甚至 0.1,让模型有空间"扔掉"这些脏点。如果数据清洗做得彻底,nu 可以设小一点但别低于 0.01——太小会让边界几乎包住每个训练点,边界形状变得非常复杂,泛化能力下降。常见做法是先设 0.05,看一眼被拒样本是什么样的,再微调。
4.2 gamma 参数:RBF 核的带宽如何决定过拟合
gamma 决定了 RBF 核的径向作用范围。gamma 大,核函数随距离衰减极快,每个训练点只管周围很小的区域,边界会绕着训练样本走很多弯曲,过拟合;gamma 小,每个点的影响范围大,边界更平滑,单模型可能欠拟合,几乎把所有点都判为正常。
调 gamma 有一个玄学但实用的经验:用gamma="scale"起步,看 AUC 和训练集异常数。如果训练集被拒太多(超过 nu 设定的比例),说明 gamma 偏大,边界在追噪声;如果 AUC 上不去,试试把 gamma 乘以 2 或除以 2,观察 AUC 变化方向。这个调整对 AUC 不敏感时,先查数据标准化是否到位,再考虑换核函数。
4.3 kernel 怎么选:RBF 默认够用,但 Linear 在某些任务上更稳
RBF 是默认选择,但不是所有情况都最优。特征维度高(几千维)且样本量大(几十万)时,RBF 的计算开销大,训练也慢,线性核反而表现稳定且快。我用过一个电商点击流异常检测场景,特征是用户行为统计,维度 500+,样本量百万级,线性核 One-Class SVM 训练时间只有 RBF 的十分之一,AUC 还略高一点——因为高维稀疏特征下,RBF 的局部性反而只剩噪声。
维度不高、分布复杂的数据,RBF 是更安全的选择。一句话总结:不知道选什么就 RBF,特征维度高就试 Linear,核函数换成多项式通常不值得折腾。
4.4 用网格搜索找到最优参数:一份可复制的代码
One-Class SVM 没有标准分类那样的交叉验证精度,所以网格搜索需要自定义评估指标。我用正常验证集和异常验证集构造一个分数,在网格里选最大 AUC 的参数组合:
from sklearn.model_selection import ParameterGrid import numpy as np # 正常验证集和异常验证集(假设已经构造好) X_val_normal = scaler.transform(val_normal) X_val_abnormal = scaler.transform(val_abnormal) y_val = np.hstack([np.ones(len(X_val_normal)), -np.ones(len(X_val_abnormal))]) X_val = np.vstack([X_val_normal, X_val_abnormal]) best_params = None best_auc = 0.0 grid = {"nu": [0.01, 0.05, 0.1, 0.2], "gamma": [0.001, 0.01, 0.1, "scale"]} for params in ParameterGrid(grid): m = OneClassSVM(kernel="rbf", **params) m.fit(X_scaled) score = m.decision_function(X_val) auc = roc_auc_score(y_val, score) if auc > best_auc: best_auc = auc best_params = params print("最优参数:", best_params, "AUC:", round(best_auc, 4))网格搜索的关键是验证集必须独立于训练集,否则 nu 和 gamma 会一起把训练集的噪声"记住"。多层网格也值得做:先粗网格锁定范围,再在最优值附近细搜。nu 和 gamma 之间有联动关系,不要单独调一个而固定另一个。
注意:网格搜索找到的最优参数只在当前数据分布下有效。设备工况变了、传感器换了型号,都要重新标定参数。
5. 避坑指南:One-Class SVM 的 5 个常见翻车点
5.1 现象:异常样本混入训练集导致模型失效
训练集里染色的异常点会让边界被"撑开",真实异常进来时模型不再报警。原因在于 One-Class SVM 的目标是包住尽可能多的训练点,如果混进的异常点恰好落在正常分布边缘,模型会把它当作正常的一部分。解决:先用 nu=0.1 训一遍,把 predict 结果为 -1 的样本打印出来人工看一眼。如果这些被拒样本有明显的特征(比如手写数字里笔画特别粗的),把它们剔除后重新训练,再逐步降低 nu。
5.2 现象:nu 取 0.5 模型几乎全预测为正常
nu 是训练误差比例的上界,不是固定的拒绝比例。nu=0.5 时模型允许一半的训练点被拒,但这不表示它一定会拒绝这么多。如果模型发现一个很小的超球就能包住绝大多数数据,它会选择那个小球,剩下的全判为异常。但反过来,如果训练集分布很散,边界被撑巨大,就会"几乎全正常"。这种情况常见于多个工况数据混在一起时。解决:回到第 2.3 节,按工况拆模型。
5.3 现象:gamma 太大训练集上完美但测试集一塌糊涂
gamma 设成 1 或 10 时,RBF 核在 64 维特征上衰减极快,模型在训练集中间画了很多小泡泡,每个训练点都被自己附近的几个邻居包住。训练集 AUC 能到 0.99,但换一批正常样本,一半以上被判异常。解决:用独立验证集检查 AUC 差距,差距超过 0.1 就是过拟合。gamma 往小了调,优先试 0.001 到 0.01 这个区间。
5.4 现象:数据未标准化导致距离计算被某个特征主导
像素灰度值范围 0-16,但有些特征(比如某个量化特征)可能方差特别大。RBF 核内部计算的是欧氏距离的指数函数,方差大的特征会在距离中占绝对权重,模型的异常检测变成"只看那一个特征"。解决:标准化是 One-Class SVM 的前置步骤,必须做而且要用训练集的统计量,测试集和推理时都复用训练集的 scaler,不能各自 fit。
5.5 现象:样本量太少时模型性能剧烈波动
正常样本只有几十条时,nu 和 gamma 的微小变化都会大幅改变边界形状,支持向量几乎覆盖全部训练点,模型成了查表器。解决:样本量少于 200 时,优先考虑用简单的统计方法(比如马氏距离)做基线,再和 One-Class SVM 对比。如果坚持用 SVM,把 nu 设到 0.01 以下,并固定随机种子多次重训看结果稳定性。
6. 最后一招:用伪造异常点验证模型,再决定要不要上生产
One-Class SVM 上线前最容易被质疑的是"你怎么证明它真的能抓住异常"。我的习惯是先伪造一批异常点做定量验证。做法很简单:对正常样本的每个特征列做随机扰动,比如加上 3 到 5 倍标准差的偏移,生成一批"合成异常"。这些点保留正常样本的相关结构,但明显偏离均值。
rng = np.random.default_rng(42) std = X_scaled.std(axis=0) fake_abnormal = X_scaled + rng.normal(0, 3.0, size=X_scaled.shape) * std把伪造异常点加进验证集,算 AUC 和 F1。如果 AUC 低于 0.8,说明模型边界太松,需要收紧 gamma 或提高 nu。如果 AUC 很高但真实故障场景里误报频发,就要回头审视伪造方式是否过假——真实异常往往只在某几个特征上偏离,而不是整体偏离。这时可以只扰动一半的特征列,再观察模型是否仍能捕获。
另外,模型上线后要定期用新数据重新评估分布漂移。One-Class SVM 的参数基于训练时的分布,设备老化、季节变化都会让正常分布缓慢移动,边界会逐渐失效。常见做法是每周离线重训一次,用近两周的数据当训练集,并保留上一版模型做 A/B 对比。我见过不止一次后者在召回率上明显更好。
对于要不要投入这个方案,我的判断标准是:正常数据容易收集、异常样本极度稀缺、且你能接受一定误报率的场景,One-Class SVM 是非常划算的起点。它在几十到几万样本量上都有稳定的性能表现,训练一个模型不过几百毫秒,比深度学习方案轻太多。但如果你的任务有大量标注异常数据,二分类或者隔离森林往往更好——别为了用 SVM 而用 SVM。
希望这套从原理到踩坑的路径能帮你少走点弯路,祝调试顺利。
本文还有配套的精品资源,点击获取