你有没有遇到过这样的场景:手里有一批正常工况的工业设备数据,想用机器学习识别出“异类”,但异常样本少得可怜,根本凑不齐两类问题所需的训练集。传统的分类算法这时候往往全部失灵,因为它们在设计上就默认“正负样本都得有”。而我今天想聊的支持向量数据描述(SVDD,Support Vector Data Description),恰恰就是为了解决这种“只有一类样本也能建模”的困境而生的。
SVDD 的核心思路说起来其实很朴素:不为每一类找一条“界线”,而是找一个能圈住绝大多数目标样本的“最小超球体”。这个球体边界外的点,就被认为是异常或者别的类别。我第一次接触这个思想时,觉得它特别像在自己的院子里画一道篱笆——我不关心邻居家是什么样,我只需要把自己的院子圈明白就行。也正是因为这个特性,SVDD 不但可以用在异常检测、工业故障诊断上,还可以通过巧妙的组合方式扩展到多类分类任务。
这篇文章主要面向正在做算法调研、或者想把单类模型用到实际项目中的开发者。我会从 SVDD 的原理出发,拆解把单个超球体拓展成多类分类器的几种常见思路,然后把训练过程中的关键参数、避坑经验、以及验证流程一起梳理清楚。看完之后,你至少能动手搭一个可用的多分类框架,而不是停留在“懂概念”的阶段。
1. 内容整体设计与思路拆解
1.1 为什么单类模型也能做多分类
先澄清一个容易混乱的点:SVDD 本身是单类分类算法,它的目标函数只描述“一个类”的分布范围。但多类分类本质上可以看成“一个测试样本到底属于哪一类”的判别问题。把 SVDD 拓展到多类,思路通常是在训练阶段为每个类别训练一个独立的超球体,然后在预测阶段让测试样本跟所有球体比较,看它被哪个球“接受”。
这里面最有意思的地方在于:SVDD 的“拒绝”能力是天然自带的。任何一个类别在训练时都会得到一个半径,只有落入这个半径内的样本才会被接受。这意味着多类组合后,系统不仅能判断“属于哪一类”,还能判断“这个样本不属于任何已知类”,这是很多普通多分类器做不到的。对于工业场景里的“已知故障 + 未知新故障”并存问题,这个特性就显得特别有价值。
不过,把多个单类模型拼成一个多分类器,并不是简单地堆几个球就完事。不同类别之间如果分布重叠,同一个样本可能被两三个球同时接受,这时候怎么裁决;或者一个样本没有任何球接受,怎么处理,都需要在整体设计里提前考虑清楚。
1.2 方案选型背后:为什么不直接上传统分类器
你可能会问:既然要分多类,直接用支持向量机(SVM)、随机森林不就行了,为什么还要绕一圈用 SVDD 来做多类?
答案要回到数据形态上看。传统分类器需要每类都有足够的训练样本,类别之间还要尽量平衡。但在很多真实场景下,这个前提根本不成立。比如设备故障诊断,正常运行的数据可能攒了几十万条,而某一种特定故障只出现过几次;又比如新品类上市阶段的质检,只有良品样本是可靠的,次品形态还在不断变化。在这种“单类充足、异类稀缺”的数据上,传统分类器很容易偏向样本多的类别,泛化效果很差。
SVDD 的优势在于,它只需要用一个类别的数据就能定义“正常边界”,对于样本量少的类别甚至可以单独建模。这就在设计层面解决了类别不平衡的老大难问题。此外,SVDD 的训练不依赖异类样本的分布,更新成本也低——如果某天出现了一种新的故障类型,只需要对这个新类别单独训练一个球,不用把所有模型推翻重来。这种增量扩展性,在项目实践中是实打实的维护便利。
1.3 多类分类器的整体框架设计
在实际开发中,我一般会把多类 SVDD 分成两层结构来设计:底层是为每个类别独立训练的单类 SVDD 模型,上层是决策融合模块。底层的每个球体负责输出“该样本是否属于本类”的得分,比如归一化距离、基于半径的判断等;上层则综合这些得分做最终分类。
决策融合模块的设计是整个框架的灵魂。虽然“把样本分配给得分最高的类”是最直接的做法,但它忽略了 SVDD 的“拒绝”语义。更稳妥的设计是加入一个“接受阈值”的概念:如果样本在所有球体中的最高得分仍然低于某个阈值,那么宁可把它判为未知类,也不要强行塞进某个已知类。这个思路在开放集识别(Open Set Recognition)里尤其重要。
我后面会详细展开这些组合策略的差别,以及具体编码和调参时需要注意的细节。
2. 多类扩展的几种主流组合策略
2.1 一对多:为每个类别圈一个“势力范围”
一对多策略是最直观的做法。假设总共有 K 个类别,就训练 K 个独立的 SVDD 模型。第 i 个模型只使用第 i 类的样本作为目标样本,描述出一个代表第 i 类分布的超球体。预测时,把测试样本分别送入 K 个模型中,计算它到每个球心的距离。如果样本落在某个球体内,就把它判为对应类别;如果落在多个球体内,可以选距离球心最近的那个,或者选相对半径比例最小的那个。
这种策略在训练上的代价是 O(K) 个模型,但是每个模型之间互相独立,非常容易并行化。当新增类别时,也不需要重训已有类别的模型,这部分做过工程项目的人都懂有多香。
不过它也有麻烦的地方:不同类别球体的大小可能差异很大,直接比较“到球心的距离”并不公平。一个半径很大的球和一个半径很小的球虽然都接受了同一个样本,但前者可能类别分布很松散,后者则非常集中。这时候需要用样本到球心的距离除以该球的半径,得到相对距离,再按相对距离做判别,效果会好很多。这个细节我第一次实现时漏掉了,当时测试集上准确率一直提不上去,后来才发现是两个球体的尺度差异在做怪。
2.2 一对一:两两配对,互相博弈
如果说一对多是“各管各的”,那一对一就是“互相打架”。针对 K 个类别,任选两个类别为一组,总共训练 C(K, 2) 个二分类 SVDD 模型。这里的二分类 SVDD 不再是把每个类别单独描述,而是用两个类别分别训练各自的球体,然后通过比较哪个球更“认可”该样本来做裁决。
预测时,让样本在所有两两组合的模型里投票——某两个类别竞争时,赢的一方得一分。所有配对结束后,累计分最高的类别就是最终预测结果。这种策略在类别数量不多时效果相当好,因为它每次只面对两个类别,判别边界更加精细,不容易出现一对多那种“一个球罩住所有类别”的情况。
但代价也很直观:当 K 变大时,C(K, 2) 会迅速膨胀。比如 10 个类别就需要 45 个模型,50 个类别就是 1225 个模型。如果每个模型训练时间要几秒钟,整个训练周期会让人等到怀疑人生。所以一对一更适合类别不多、但类别间重叠比较严重的场景。我个人的经验是,K 小于 15 时用一对一通常都能接受。
2.3 决策层融合与多种策略横评
既然每组球体都会输出“接受/拒绝”和“距离/相对距离”,决策层就可以做很多文章。除了上面说到的投票和相对距离排序,还可以用归一化得分加权。比如对第 i 个类别的球,定义得分 s_i = 1 - d_i / R_i,其中 d_i 是样本到球心的距离,R_i 是球半径。这样得分范围在 0 到 1 之间,得分最高的类别胜出。如果所有得分都很低,说明样本没有进入任何一个球体,可以判为“未知”。
从我的使用经验看,三种策略没有绝对的好坏,只看适用场景。这里用一张表来对比:
| 策略 | 训练模型数 | 预测速度 | 类别不平衡适应 | 重叠类别区分 | 新增类别开销 |
|---|---|---|---|---|---|
| 一对多 | K | 快 | 很好 | 一般 | 只需新增一个球 |
| 一对一 | C(K,2) | 中等 | 较好 | 较好 | 需新增 K-1 个配对模型 |
| 决策层融合(相对距离) | K | 最快 | 很好 | 视阈值设置而定 | 只需新增一个球 |
如果你刚开始尝试多类 SVDD,我建议从一对多起步,因为它的语义最简单、排查问题也方便。等把单类模型的调参手感练出来,再根据自己的数据特性去尝试一对一或者更复杂的融合机制。
3. 核心细节解析与实操要点
3.1 核函数选择:为什么 RBF 是默认选项
SVDD 在原始空间里只能描述一个球形的边界,这在实际数据里不够用。解决办法跟 SVM 一样:通过核函数把样本映射到高维特征空间,在高维空间里再找超球体。常用的核函数有线性核、多项式核和 RBF(径向基)核。我自己的经验是,除非数据维度极低且本身就近似球状分布,否则直接默认选择 RBF 核,不要犹豫。
RBF 核的表达式是 K(x, y) = exp(-γ ||x - y||²),其中 γ 控制着“影响范围”。γ 越小,高斯分布越平缓,映射后的球体会越接近原始空间的大球;γ 越大,边界会越精细,但也很容易过拟合,把一个类别描述成很多小块,反而失去了整体的“代表”意义。
在实操中,我一般会在 log 尺度下搜索 γ,比如从 0.001 到 1000 之间按数量级取几个值,配合交叉验证来选择。这里有个小技巧:如果训练完成后支持向量的比例非常高(比如超过一半的训练样本都成了支持向量),多半是 γ 或者惩罚系数设得不对,模型在硬记样本,拿着这样的模型去做预测风险会很大。
3.2 正则参数 C:球的“收紧程度”由它决定
SVDD 的目标函数里有一个惩罚系数 C,它控制着对落在球外样本的容忍度。 C 越大,系统越不能接受违反约束的样本,边界会尽量把每一个训练样本都包进去;C 越小,边界越宽松,允许少量样本外溢,球体更紧凑。
理解 C 的一个好方式是看它与错误的“预算”之间的关系。实际工程中,C 经常设为 1 / (n · f),其中 n 是训练样本数,f 是期望的异常比例。比如你有 1000 个目标样本,希望大约 5% 的训练样本可以被视作外点,那么 C 就可以取 1 / (1000 × 0.05) = 0.02。这个初始值在多数项目中都能得到合理的边界,省去不少盲调的时间。
但要注意,C 跟核函数参数 γ 是相互影响的。C 大了模型紧凑,但如果 γ 也很小,球体在特征空间里依然会显得很大,两者必须放在一起调,不能分开单独调。我通常的做法是先用默认值跑通流程,再做一轮网格搜索,搜索目标从准确率、召回率这些指标里选一个最贴合业务诉求的。
3.3 数据预处理的三个细节坑
数据预处理对 SVDD 的影响,往往比很多人想象的要大。第一个坑是训练和测试缩放不一致。有人会直接对全量数据做标准化,这相当于在训练模型时“偷看”了测试数据的统计信息,会造成验证结果虚高,上线就崩。正确的做法是只用训练集计算均值和方差,然后用这组参数去变换训练集和测试集。
第二个坑是特征尺度悬殊。SVDD 依赖距离计算,如果某个特征的数值范围是 0 到 1,另一个是 1000 到 10000,后者会直接主导整个距离计算,等价于降维只剩一两个特征。所以标准化几乎不是可选项,而是必选项。
第三个坑出现在类别差异明显、各类样本量悬殊的时候。不要对所有类别使用同一个标准化的均值和方差,最好基于每个类别自己的训练数据做标准化,再分别训练各自的球体。否则样本量大的类会拉着全局均值偏移,导致样本量小的类在标准化后整体漂移,边界描述失真。
3.4 支持向量、半径与球心的关系再拆解
SVDD 训练完成后,会得到一组支持向量。这些支持向量可以分为两类:落在球面上、恰好等于半径的,是边界支持向量;落在球外、被惩罚项容忍的,是带有松弛变量的支持向量。球心和半径直接由这些向量确定,这意味着超球体的边界完全由训练数据中“最难的那些点”塑造。
这给调试带来一个很直观的启示:如果你的训练集里存在标注错误或者严重离群的脏样本,它们很可能变成支持向量,并把球体撑大。这时候模型的表现并不是整体样本的特征,而是被少数脏数据绑架了。所以我建议在训练前做一轮离群点筛查,或者用较保守的 C 值让部分极端点落在球外。
我还见过一种高效的调参思路:先用线性核或者很小的 γ 快速训练一个球,把所有训练样本的“到球心距离”画出来,观察长尾分布。距离特别大的那几个点,十有八九是有问题的样本。把这些点剔掉之后再用正式的参数重训,模型的稳定性会提升很多。这个技巧在我处理工业传感器数据时非常管用。
4. 实操过程与核心环节实现
4.1 实验环境与数据准备
这次演示我用的是一个模拟的工业质检数据集,为了让流程更贴近实际,我刻意把它构造得不那么“干净”。总共 6 个类别,其中第 1 类到第 3 类的样本数量在 600 到 800 之间,第 4 类是 200 个,第 5 类只有 50 个,第 6 类是 1000 个并带有 3% 的噪声样本。每个样本是 32 维的数值特征,这里不再引入额外的复杂结构。
环境方面,Python 3.9 + scikit-learn 就够用。SVDD 的完整实现可以用 scikit-learn 的 OneClassSVM 作为基础组件,因为它也采用核技巧,训练目标和 SVDD 很接近,能快速验证组合逻辑。如果项目对性能要求更高,可以考虑基于自己实现的 QP 求解器做定制化优化,但那是另一个话题了,后面如果有机会再单独展开。
4.2 核心训练与预测封装
我先把一个基于 OneClassSVM 的多类 SVDD 框架的核心代码思路写出来。这里的关键不是代码本身,而是背后的流程设计,所以我会用伪代码来展示,方便迁移到你自己的工程语言里。
def train_one_class_svdd(X_train, gamma, nu): # 使用 one-class SVM 近似 SVDD # nu 等价于之前提到的外点比例 f model = OneClassSVM(kernel='rbf', gamma=gamma, nu=nu) model.fit(X_train) # 记录球心相关的距离信息 train_dist = model.decision_function(X_train) radius = np.percentile(train_dist, 100 * (1 - nu)) return model, radius def predict_multi_svdd(models, radii, X_test): # models 是每个类别一个字典 scores = [] for i, model in enumerate(models): dist = model.decision_function(X_test) rel_dist = 1.0 - dist / radii[i] scores.append(rel_dist) scores = np.vstack(scores) # shape: (n_classes, n_samples) pred = np.argmax(scores, axis=0) # 如果最高得分低于接受阈值,则判为未知类 max_scores = np.max(scores, axis=0) unknown_mask = max_scores < ACCEPT_THRESHOLD pred[unknown_mask] = -1 return pred, scores这段代码里有两个关键点。第一,radius 的估计用的是训练集得分分布的百分位数。因为 OneClassSVM 的决策函数得分本身跟半径成正比,我们不需要精确知道特征空间里的球心坐标,只需要一个跟“球面边界”对齐的刻度即可。第二,预测时把得分转换为相对得分,再送入 argmax 判断,这样不同类别球体尺寸的差异就被抵消了。
很多文章只讲模型训练,不讲预测阶段的相对距离处理,导致换了数据集以后准确率忽高忽低。我踩过太多次这种坑,所以这里特意强调一下:相对距离处理几乎是多类 SVDD 必做的一步,少做一步,效果可能差好几个百分点。
4.3 参数搜索与交叉验证
SVDD 参数搜索最常用的方法还是网格搜索加交叉验证。虽然每个类别的模型可以独立训练,但验证时一定不能把类别单独切分开,因为最终结果是在全局预测层面评估的。我建议把整个多类分类流水线做成一个函数,内部包含每个类别的标准化、模型训练、组合预测和评估,然后把这个流水线套进交叉验证循环里。
验证指标需要根据业务场景区分。如果更关心“未知类发现率”,就要重点看类别的召回率;如果更关心“已知类不误杀”,则要卡准每类的精确率。有一件事我想反复提醒:不要只看整体准确率。多类 SVDD 在类别不平衡时,整体准确率极具欺骗性。比如第 5 类只有 50 个样本,就算完全认不出它,准确率也只掉 1% 不到。这时候去看每个类别的 F1 值,才能真正暴露问题。
我在实际项目中试过成人数据集里整出一个 30 维的低维特征子集,准确率可以达到 92% 左右。而用所有 200 维原始特征时,反而掉到 88%——原因就是高维噪声特征干扰了距离度量。对于 SVDD 这类基于距离的模型,特征选择不是一个加分项,而是必要步骤。这一点经常被人忽略,尤其是习惯用树模型的人,因为树模型本身有特征选择机制,不太吃这套。
4.4 一次典型的实验结果复盘
我用上面的流程跑完这个模拟数据后,把结果按类别拆开来看,得到这样一个分布:第 1 类到第 3 类(样本充足)的 F1 都在 0.9 以上;第 6 类虽然样本最多但是有 3% 噪声,F1 在 0.86 左右;第 4 类样本只有 200 个,F1 掉到了 0.78;第 5 类只有 50 个样本,F1 只有 0.61,而且误判到第 4 类的情况特别多。
这个结果完全符合我对 SVDD 特性的预期。样本量少的类别,超球体的描述不够完整,边界容易出现空洞,导致真正的类内样本反而落在球外,或者被邻近类别的大球“吸入”。想缓解这个问题,有几个方向:一是给每个类别的半径加一个松弛系数,允许小样本类的球半径稍微扩大一些;二是改造打分函数,对样本量少的类别做得分加权;三是在训练阶段做 SMOTE 之类的过采样,让描述边界时更有参考点。
我把这三条都试了一遍,效果最好的是“半径松弛 + 得分加权”的组合,把第 5 类的 F1 从 0.61 拉到了 0.74。当然,这类手工设计需要谨慎,原则是不要让边界扩到淹没其他类别。每一步改动都要回到交叉验证里重新看全局结果,而不是只盯单个类别的分数。
5. 常见问题与排查技巧实录
5.1 边界被离群样本撑大,球体失去区分度
这是我在 SVDD 项目里遇到最多的问题。表现是训练集上的效果很好,但一到测试集就觉得模型“什么都愿意接受”,很多明显不属于这个类别的样本也被划进去了。打开训练日志一看,支持向量的比例高得离谱,比如 80% 的样本都是支持向量。
原因通常是正则参数 C 偏大、核函数的 γ 偏大,或者训练集中存在脏样本。排查思路是先看训练样本到球心的距离分布,找出距离特别大的那部分样本,逐个检查是不是标注异常。如果脏样本确实存在,把它们剔除后重训,不要指望模型自动忽略它们。如果数据本身没问题,那就把 C 调小一些,让球体更紧凑。
5.2 类别不平衡导致“大球吞小球”
当某个类别样本量特别大,它的球体往往也很“膨胀”,边界扩张到邻近类别的腹地。预测时,隶属小类别的样本反而被大类别的球抢先接收。这种情况在一对多策略下尤其常见,因为每个球体都是独立训练的,并不知道其他球体的存在。
解决办法分两部分:训练阶段可以限制每个球体的最大半径,或者按类别比例调各自的惩罚系数;预测阶段则一定要用相对距离而不是绝对距离。如果这两步都做了,吞并问题依然严重,考虑切换到一对一策略,因为一对一每次只面对两个类别,大球吞小球的场景会被有效抑制。
5.3 多个球体同时接受样本,裁决规则含糊
样本落在两个或多个球体的重叠区域时,系统不知道该判给谁。有些开发者随手写一个“取第一个接受它的模型”,这显然是不对的,因为模型的顺序会直接影响结果,带进很多随机性。
合理的处理办法是用相对距离的比值做裁决:比如样本到 A 球球心的距离占 A 球半径的 60%,到 B 球球心距离占 B 球半径的 95%,那就判给 A,因为在各自边界标准下,它更靠近 A 的“内核”。这个比值其实就是前面代码中的 rel_dist,挑 rel_dist 最大的那个就好。
5.4 问题排查速查表
| 现象 | 可能原因 | 排查方式 | 解决建议 |
|---|---|---|---|
| 支持向量比例过高 | γ 过大或 C 过大 | 查看训练后支持向量占比 | 调小 γ 或 C,观察球体大小 |
| 测试集误判集中在某个类 | 该类球体半径过大 | 查看各类 radius 分布 | 按类别调 C,或强加半径上限 |
| 小样本类别几乎全被大类别吞掉 | 绝对距离比较,球体尺度不公平 | 检查预测阶段是否用了相对距离 | 用 rel_dist 做 argmax 对比 |
| 模型对训练集变化过于敏感 | 过拟合到个别离群点 | 训练-测试划分换多个随机种子 | 剔除离群点,调小 γ 或 C |
| 在线场景中新增类别后整体准确率下降 | 旧类别球体未重新校准 | 观察旧类别 F1 是否有变化 | 重新校准旧类别半径或阈值 |
6. 写在最后的个人体会
SVDD 这套方法,我把它的原理和工程细节掰开揉碎讲了这么多,最想分享的一点其实是:多类 SVDD 的性能天花板,往往不取决于模型本身,而取决于你是否理解了“边界”背后的语义。每一个超球体不只是分类器里的一个零件,它代表了你对这个类别数据的信任程度。参数调来调去,本质上是在回答一个问题:你愿意在多少误报的基础上,换取多少对已知类别的覆盖率。
在几个实际项目里,我发现把每个类别的支持向量可视化出来,会收获很多直觉上的洞察。支持向量并不是均匀分布在球面上的,它们通常集中在几个困难区域。这些区域往往对应着真实生产环境中的某些特定模式,比如传感器顿变、启停瞬间等。“支持向量在哪里”这件事,比“准确率是多少”更能告诉你模型到底学到了什么。
最后再留一个小技巧:如果条件允许,请一定在验证阶段加入一个“负类样本”集合。这个集合不属于任何已知类别,专门用来测试模型能不能把它们拒之门外。很多模型在已知类别上跑得很好,但在未知类别上表现得极其糟糕,而 SVDD 的核心价值恰恰就体现在“已知和未知的分界”上。没有这个负类样本集,你很难量化模型在这方面的真实水平。这一步,是普通分类器完全无法给你的,也是 SVDD 最值得被记住的地方。