资讯详情

资讯详情

CBLOF异常检测算法解析:基于聚类的局部离群因子

我最早接触异常检测时总觉得“聚类”和“找离群点”是两件相反的事聚类是扎堆的异常是落单的。直到有一次在某个跨平台系统的日志数据里做根因筛选把聚类结果画出来后才意识到偏差往往不是单个点离群而是某一条“小簇”整体离群——它们彼此挨得很近但离所有正常群体很远。这时候如果只看点和邻居的距离很容易把它们也当成正常点。今天要聊的 CBLOFCluster-Based Local Outlier Factor基于聚类的局部异常因子正是解决这类问题的算法。它的思路很朴素先用聚类手段把数据切成一堆簇再拿簇的规模当“背景信息”参与异常打分。大簇大概率是正常行为小簇或离大簇太远的点才需要进一步警惕。这篇文章我会先把算法原理拆成三步讲清楚再给一份可完整运行的 Python 示例最后聊一聊我在调参和用到生产环境时踩过的那些坑。适合已经有一定机器学习基础、想深入理解异常检测的朋友也适合刚入门但希望直接上手跑一遍仿真数据的人。1. 为什么异常检测要先给数据分个组1.1 单点视角下的盲区很多经典异常检测算法比如 KNN 或 LOF本质上都在做同一件事看一个点周围的邻居关系。如果一个点离它的邻居太远或者它所在区域的密度明显低于周边这个点就会被判为异常。这种思路对真正的“独行侠”很有效比如一张银行卡突然在异地消费或者传感器读数突然飙到历史极值。但它有一个天然盲区当异常不是一个人而是“一小撮人”时单点视角容易失效。举个例子某个小团伙用脚本批量注册账号这些账号在特征空间里彼此非常接近整体簇形成一个致密的小群体。LOF 这类基于密度的算法会认为它们互为邻居、局部密度很高于是统统放行。只有等它们中间出现一个落单者时算法才会响警报但这已经是漏检很久之后的事情了。聚类能把这种问题提前暴露出来因为它关心的是“人群结构”而不是单个点的局部密度。一旦异常样本自己扎堆它们会形成一个小簇。这个小簇的整体位置、规模以及它和主流簇之间的距离就变成了非常直接的线索。1.2 CBLOF 的赌注大簇是背景小簇是重点CBLOF 的出发点可以浓缩成一句话正常样本通常会聚成规模很大的簇异常样本则倾向于形成小簇或者落在离所有大簇都很远的地方。算法先把数据聚成若干簇再根据簇大小把它们分成“大簇”和“小簇”最后对每个点算一个综合异常分。这里的关键不是“距离”本身而是“距离和簇规模的组合”。一个点偏离簇中心很远但如果它所在的簇本身有上万条样本这个偏移可能只是正常的分布尾巴反过来一个只有几十条样本的小簇整体悬在坐标系的角落那它每一个成员都高度可疑。所以 CBLOF 让簇规模直接参与打分这就是它和纯距离类算法最大的差异。我自己的体会是这种设计非常符合业务直觉。做反欺诈或运维告警时我们经常不是要揪出离群像素而是要找出一组行为相似但与大多数人明显不一样的实体。CBLOF 本质上就是在用聚类结果替你完成“哪些人算大多数”的估计。2. CBLOF 算法拆解聚类、分簇、打分三步走2.1 第一步K-Means 先画好势力范围CBLOF 的第一步是让聚类算法把数据划分成 K 个不相交的簇。最常见的实现是用 K-Means因为它的计算效率高、实现简单而且能直接给出每个簇的质心方便后续计算距离。K-Means 会被随机或按策略初始化 K 个质心然后通过“分配—更新—再分配”的迭代过程收缩目标函数最终把样本分配给最近的质心。训练完成后每个样本都会得到一个簇标签每个簇会有两个属性一个是簇内样本数|C_i|另一个是质心坐标o_i。这两样东西后面都会用到。这里有一个很容易被忽略的细节K-Means 用的是欧氏距离所以特征之间的量纲差异会直接影响聚类结果。如果某个特征的取值范围是 0 到 10000另一个是 0 到 1前者在距离计算里几乎可以淹没后者。因此用 CBLOF 之前做标准化或归一化不是锦上添花而是必须的前置步骤。2.2 第二步用簇规模把簇分成“大簇”和“小簇”聚类完成后CBLOF 会统计每个簇的样本量并按样本量从大到小排序。接下来要做一个判断哪些簇算大簇哪些算小簇这里通常会用到两个参数alpha和beta。一种常见策略是看累积覆盖比例。把簇按大小降序排列后从最大的簇开始累加样本数如果前面若干个簇的累积样本数已经占到总样本量的alpha比例比如 90%那么这些簇就认定为当前数据中的主流群体也就是大簇。剩下的簇就是小簇。另一种策略是设置一个基于平均簇规模的阈值。例如规定“一个簇的样本量大于beta倍的平均簇规模才算大簇”。如果整个数据集有 10000 条样本K 取 10平均每个簇理论上是 1000 条beta5时规模达到 5000 以上的簇才有资格成为大簇。这个阈值非常严它会把绝大多数簇都推到小簇一侧相当于只把绝对主力的超大簇视为正常人群。这两种描述在实际库里的工程实现略有差别但它们的意图是一致的大簇代表“主流”小簇代表“可疑的少数派”。我的建议是先理解这两种判据的直觉再去看具体库的源码因为不同实现细节经常让人产生困惑。2.3 第三步距离乘以簇规模得到异常分CBLOF 的异常分计算分成两种情况。第一个情况样本点本来就属于某个大簇。此时异常分等于该点到自身簇质心的距离再乘上这个簇的样本量写作score(x) dist(x, o_j) × |C_j|第二个情况样本点属于某个小簇。这个点本身所在的簇大小已经不可靠了所以算法不再看小簇内部结构而是去找离它最近的大簇计算它到最近大簇质心的距离并乘上这个最近大簇的样本量score(x) dist(x, o_big) × |C_big|为什么要乘以簇规模我给你算一个直观的例子。假设 A 点从属于一个 1200 人的大簇它偏离簇中心 2 个距离单位异常分就是 2400。B 点属于一个 20 人的小簇它离最近的大簇中心有 10 个距离单位而那个大簇有 800 人异常分就是 8000。只看原始距离B 的偏移量只是 A 的 5 倍但乘上大簇规模后B 的异常程度被明显放大。这正是 CBLOF 想表达的远离主流群体的小群体比主流群体内部的正常波动危险得多。有的变体在小簇场景里还会考虑“到大簇最近边界的距离”而不是质心距离也有的会对距离做平方处理。这些细微差别会影响实验结果但不会改变算法最核心的逻辑大簇规模是放大器它让离群的小群体无处可藏。2.4 PyOD 库实现和论文原版的微妙差异在做工程落地时我经常使用的是开源异常检测库 PyOD。PyOD 里的CBLOF是对论文思路的工程化改编在保留主流程的同时做了一些简化新手如果直接照论文去理解代码容易卡壳。首先PyOD 的CBLOF继承了 scikit-learn 风格构造参数里有n_clusters、alpha、beta、contamination还有一个use_weights。alpha主要负责约束大簇覆盖的样本比例beta主要作为簇大小的阈值倍数。要注意的是不同版本的库对alpha和beta的解释不完全一致这一点我会在第 4 部分详细展开。其次use_weightsFalse是默认值。此时算法在计算异常分时并不会严格按论文建议乘以簇规模而是会退化成“到质心距离”的变体。只有当use_weightsTrue时簇规模的加权才会被打开。因此如果你想贴近原始论文里的 CBLOF 行为构造模型时一定要明确把use_weights设为True否则你实际跑出来的可能更接近一种“基于聚类距离的 KNN 变体”。理解这些差异很重要因为它直接影响你对分数含义的判断。我习惯把 CBLOF 的原始得分当作相对分数来用而不是纠结绝对数值。业务上线时真正有用的往往是排序结果和阈值设定而不是某个特定分数值本身。3. 用 PyOD 跑一次 CBLOF仿真数据全流程3.1 先构造一份肉眼能看出离群点的数据为了把算法看明白我们先用一个最简单的二维仿真数据集。二维的好处是能直接可视化每位读者都可以直观看到哪些点被算法标红了。我用make_blobs生成三个正常簇分别有 400、300、300 个样本。三个簇的中心分别放在[[1,1], [-2,2], [2,-1]]附近标准差都设为 0.5。然后在周围手工加入 5 个离群点比如[5,5]、[-4,-3]、[0,6]、[4,-4]、[-5,4]它们明显不属于任何正常簇。为了方便计算指标我还给所有样本准备了一个二分类标签正常样本为 0异常样本为 1。这段数据规模很小不是用来证明 CBLOF 有多强而是用来帮助我们建立对算法行为的直觉。3.2 核心代码标准化、拟合、打分、预测完整代码如下环境需要安装numpy、scikit-learn、pyod和matplotlib。import numpy as np from sklearn.datasets import make_blobs from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score from pyod.models.cblof import CBLOF # 1. 构造数据 X, _ make_blobs( n_samples[400, 300, 300], centers[[1, 1], [-2, 2], [2, -1]], cluster_std0.5, random_state42 ) outliers np.array([[5, 5], [-4, -3], [0, 6], [4, -4], [-5, 4]]) X np.vstack([X, outliers]) y_true np.zeros(X.shape[0]) y_true[-5:] 1 # 2. 标准化K-Means 对量纲非常敏感这步不能省 scaler StandardScaler() X_scaled scaler.fit_transform(X) # 3. 训练 CBLOF clf CBLOF( contamination0.03, n_clusters8, alpha0.9, beta5, use_weightsTrue, random_state42 ) clf.fit(X_scaled) # 4. 打分与预测 anomaly_scores clf.decision_function(X_scaled) pred_labels clf.predict(X_scaled) # 5. 看一眼最像异常的前 5 个样本 top5_idx np.argsort(anomaly_scores)[::-1][:5] print(前5个异常样本索引, top5_idx) print(对应原始坐标\n, X[top5_idx]) # 6. 算一下 AUC仅作为参考 print(ROC-AUC, roc_auc_score(y_true, anomaly_scores))代码本身的流程非常直白先造数据再标准化然后训练模型最后做评分预测。需要注意contamination0.03这个参数。它表示在拟合阶段模型默认整个数据集中约有 3% 的异常样本。我们这里总共 1005 个样本3% 大约对应 30 个点。也就是说predict返回的 1 会是最异常的前 30 个左右而我们的真实异常只有 5 个所以预测结果里必然包含一些正常样本的误报。3.3 把结果画出来看分数和阈值的关系接下来用散点图观察结果。横纵坐标使用数据标准化之前的原始特征颜色标记预测标签。正常点画成浅色异常点画成深色。import matplotlib.pyplot as plt plt.figure(figsize(8, 6)) plt.scatter( X[:, 0], X[:, 1], cpred_labels, cmapcoolwarm, s30, edgecolorsk, linewidth0.3 ) plt.title(CBLOF Prediction Result) plt.xlabel(feature_1) plt.ylabel(feature_2) plt.show()我在本地跑出来的效果是手工加入的 5 个离群点基本都排在了最高分区域也确实出现在top5_idx里。这符合预期因为它们和正常簇的距离非常远无论用哪种基于距离的评分方式它们都应该是最高分。更有意思的是分数直方图。把anomaly_scores画出来后你会发现大部分样本的分数挤在低分区域分数分布呈现出明显的长尾只有少数点的分数甩开大部队这个长尾就是“异常候选”。PyOD 的predict本质上是拿训练时预设的contamination比例去截断分数分布所以调高contamination会放宽异常判定范围调低则会变得更严格。实际项目中我不会直接拿predict的结果去执行告警而是先从decision_function里取出连续分数人工分析分数分布后再结合业务成本设定阈值。自动化的阈值只能作为初始建议。4. 参数调优的实操经验n_clusters、alpha、beta、contamination4.1 n_clusters 太小或太大分别会发生什么n_clusters是 CBLOF 里最影响结果形态的参数。它本质上是在告诉 K-Means把数据想象成多少堆。如果 K 设置得太小比如正常数据本来有 5 个细分的业务群但 K 只取 2那 K-Means 会把几个业务群硬并成两个大团块异常小簇也很可能被某个团块“吞并”。这时候异常分的区分能力会很差因为所有点都挤在两个大簇里簇规模无法体现个别小群体的特殊性。如果 K 设置得太大又会出现另一个问题正常簇被切得过碎。原本一个正常大簇内部有多个局部密集区域K 变大会把它们拆开导致每个碎片簇的规模都变小最终被算法整体判成小簇产生大量误报。我在实验里把 K 从 3 调到 15最明显的感受就是误报数量先下降、后上升存在一个舒服的中间区间。我的经验是K 不要直接取“你以为的正常业务簇数”最好在这个数量上再加 1 到 3。理由是异常群体也需要一个独立的小簇空间否则它们只能被迫混进大簇里。比如上面的仿真数据正常簇是 3 个取 K6 或 K8 效果都稳定。如果数据规模很大、特征维度很高可以先跑一遍聚类结果的轮廓系数或肘部图找到一个相对稳定的 K再手动调整。4.2 alpha 和 beta 怎么配合异常敏感度随之变化alpha和beta是决定“什么样规模的簇算大簇”的探头。如果把alpha理解为“大簇必须覆盖总样本的比例下限”那么alpha调大意味着只有覆盖绝大部分样本的簇才有资格当大簇。例如alpha0.95时算法会近乎苛刻地要求大簇覆盖 95% 的样本此时凡是不属于超大主流簇的点都会被打高分异常敏感度非常高。反过来alpha0.7时大部分样本所在的簇都能算大簇只有少数特别小的簇会被标记误报会少一些但漏报风险随之上升。beta则更像一个“相对于平均簇规模的大小门槛”。如果beta5就暗示一个簇至少要达到平均簇规模的 5 倍才算真正的主力簇。beta的值越高大簇的数量越少检测越激进beta值越低大簇越容易当选检测越保守。这两个参数是配合使用的。我在调参时通常固定一个再去扫另一个而不是同时乱试。比如先让alpha0.9然后从beta2开始往上加观察高分区间里哪些点被纳入、哪些被排除。最终的选择标准不要只看 AUC还要看业务上哪些误报不能接受、哪些异常必须抓出来。4.3 contamination 只是阈值不是真实异常占比很多初学者会把contamination当成“CBLOF 认为数据里有多少异常”这是一个常见误区。在 PyOD 框架里训练完成后会计算所有样本的异常分数然后按contamination的比例分位数去截断分数靠前的那部分样本的标签会被置为 1。因此contamination影响的是“预测阶段的判定分界线”而不是模型内部的聚类过程。也就是说contamination0.01时哪怕数据里一处异常都没有模型也一定会挑出最极端的 1% 样本报警contamination0.1时则会把报警范围放宽到最极端的 10%。所以这个值应该来自业务先验过去一年真实问题样本占总样本的比例大概是多少或者团队一星期能处理多少条告警工单。这里还要特别提醒特征缩放。CBLOF 内部依赖 K-Means而 K-Means 对尺度极其敏感。如果特征里含有一个取值范围特别大的无关变量它有可能主导距离计算让聚出来的簇完全围绕这个变量转异常检测的效果就会跑偏。我一般习惯先做StandardScaler或RobustScaler后者对噪声和极端值更稳健。5. CBLOF 的边界在哪里退化场景与算法组合5.1 适合 CBLOF 的数据长什么样CBLOF 的优势建立在一个前提上正常数据确实以簇的形式集中在特征空间里。如果你的数据分布非常均匀没有任何成簇结构或者所有簇的大小都差不多CBLOF 的“大簇/小簇”划分就失去了意义效果也会比较平庸。它更适合数据本身有明确群体结构的场景。比如用户画像数据中不同人群在行为特征上天然分成几大群体再比如设备运行数据中主流工况对应几个稳定的运行模式。这时候 CBLOF 可以利用业务先验中的“主流”和“边缘”概念得到容易被解释的结果。相反如果你的数据高维且极度稀疏点与点之间的欧氏距离会变得没有区分度所有点看起来都和质心差不多远CBLOF 会退化得很厉害。高维场景我更推荐先做降维或者改用基于树模型的算法。5.2 和 LOF、KNN、Isolation Forest 摆在一起选型我在项目里做选型时会从三个维度去看优点缺点计算开销、可解释性、对局部密度差异的敏感性。算法核心思路强项弱项CBLOF聚类后按簇规模和质心距离打分可解释性强能识别成簇的群体异常依赖聚类质量数据无簇结构时效果差LOF比较点和邻居的局部密度能处理密度不均的数据集对参数 K 敏感无法识别紧密的小群体异常KNN看点到第 K 近邻的距离简单直观全局视角稳定对局部密度变化不敏感维度高时容易失效Isolation Forest用随机划分快速隔离异常点高维数据一般可用计算快对强相关特征和密集小群异常表现不稳定CBLOF 最独特的价值在于把“群体规模”变成一个参与决定异常程度的因子。这一点是 LOF 和 KNN 很难直接做到的。LOF 考虑的是每个点周围密度KNN 考虑的是绝对距离它们都默认异常是“少数个体”而 CBLOF 同时接收“异常可能是一小撮”的设定。生产环境里我不太喜欢只依赖一个模型。比较稳妥的做法是跑两三个算法把每个算法输出分数做标准化后取交集或加权和。CBLOF 可以当主筛选器再用 Isolation Forest 做高维复核可以显著降低单一算法的结构性漏报。5.3 不依赖现成库时自己实现核心逻辑的思路如果你不想被库的实现细节束缚或者想把它嵌入到自定义的流程里完全可以自己写一个简化版 CBLOF。核心代码只需要 K-Means、簇大小统计和距离计算。import numpy as np from sklearn.cluster import KMeans def cblof_simplified(X, n_clusters8, alpha0.9): km KMeans(n_clustersn_clusters, random_state0).fit(X) labels km.labels_ centers km.cluster_centers_ sizes np.bincount(labels, minlengthn_clusters) # 按簇大小降序排列取累积覆盖率达到 alpha 的簇为大簇 order np.argsort(sizes)[::-1] cumsum np.cumsum(sizes[order]) big_clusters order[cumsum alpha * len(X)] if len(big_clusters) 0: # 防退化最大簇至少保证算一个大簇 big_clusters order[:1] big_clusters set(big_clusters) scores np.zeros(len(X)) for i, label in enumerate(labels): if label in big_clusters: dist np.linalg.norm(X[i] - centers[label]) scores[i] dist * sizes[label] else: # 找最近的大簇质心并乘大簇规模 big_centers [centers[c] for c in big_clusters] big_ids list(big_clusters) nearest_idx np.argmin( [np.linalg.norm(centers[label] - c) for c in big_centers] ) nearest_center big_centers[nearest_idx] nearest_size sizes[big_ids[nearest_idx]] dist np.linalg.norm(X[i] - nearest_center) scores[i] dist * nearest_size return scores这段代码把算法逻辑压缩到最核心的部分聚类、分大簇、算距离、乘规模。跑出来的分数排序和 PyOD 的结果通常非常接近。自己写一遍最大的好处是能彻底理解每个参数在流程里什么时候生效排查问题会更有底气。比如你想把“距离”从欧氏距离改成马氏距离或者把“大簇规模”改成“大簇样本占比”只需要不动整条流程单独替换对应一行就行。如果让我在生产环境里选型我会先跑一个简化版 CBLOF 做快速验证看异常分数的排序是否集中在业务认为可疑的样本上再切换到正式库调整参数和阈值。最后再多说一个实际经验别把 CBLOF 的输出分数当成绝对置信度它更像一个排序器帮你把数据从“最普通”到“最可疑”排出一条线真正的判断永远要结合业务语义。聚类出来的簇怎么解释往往比算法参数本身更能推动一次有效的告警处置。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →