ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

网络流量异常检测毕设全指南:从pcap特征提取到模型避坑

网络流量异常检测毕设全指南:从pcap特征提取到模型避坑 简介面向毕业设计与网络安全从业者的网络流量异常检测Python项目聚焦DeepSVDD、DeepSAD与FT-Transformer三种深度模型在CICIDS2017数据集上的异常检测对比实验覆盖数据清洗、归一化、模型调参与性能评估全流程。包内共205个文件以Python脚本py为核心包含模型训练与评估代码另有pyc编译文件、png结果图、h5预训练模型、xml配置、csv数据及md文档等压缩包约58.88MB结构清晰便于按需调用。目前已有410人学习下载。读者可由此掌握DeepSVDD的无监督超球面建模、DeepSAD的半监督异常样本利用、FT-Transformer的频率域时序特征提取等关键思路获得可直接运行的脚本与预训练权重为毕业设计或流量异常检测研究提供可复现的基准方案也可作为后续改进的起点。1. 网络流量异常检测毕设这个方向的第一周该做什么开题答辩前两周一个学生拿着校园网出口的一段 pcap 找过来说想做个网络流量异常检测分析当毕设。我给他的第一个建议是第一周不要碰任何模型先把数据从 pcap 变成一张带标签的表否则后面所有算法都是黑匣子。网络流量异常检测本质上是在流量特征里找出偏离正常行为的通信模式——端口扫描、DDoS、暴力破解、数据外泄都属于检测目标。它适合毕设是因为公开数据集成熟、结果能画图、答辩有故事可讲但也因为流量数据高度不平衡、时间顺序敏感真正落地时要躲过不少坑。这篇文章面向的就是准备选这个方向、或者已经开题但还在数据阶段卡住的人从数据来源、预处理、算法选型到踩坑一路写到能跑出可信结果。2. 数据准备链路从 pcap 到可训练特征表的落地路径先说一个常被忽略的事实流量数据形态决定了后面整个项目的写法。很多毕设翻车不是在算法上而是连“我到底在检测什么”都没说清。所以这一章先把数据链路打通再谈模型。2.1 三种数据来源怎么选抓包、公开集与模拟流量做网络流量异常检测数据来源通常有三条路自己抓包、公开数据集、模拟流量。我一般会建议先用公开数据集做主体实验原因放到第 3 章再用自己抓包或模拟流量做“场景验证”两条腿走路。自己抓包的优点是真实校园网或本机用 Wireshark、tshark 抓一段时间就能拿到 pcap缺点是基本没有标签你很难知道某个时间段里是不是真的有攻击在发生。人工标注流量这件事做过一次就再也不想做第二次它比调模型费时得多。公开数据集的优点是标签完整、能复现、答辩时老师可以拿去跑结果比如 NSL-KDD 和 CICIDS2017下一步展开讲。模拟流量则是用 scapy 发一些特定形态的包UDP flood、SYN flood混在正常请求里标签自己写干净可控适合用来验证算法是不是真的认得出某种攻击形态。三者不是互斥的。我的习惯是公开集训练选型模拟流量做单类攻击的泛化测试自己抓的 pcap 放最后当“真实场景演示”。这个顺序写进论文就是一条完整的实验链路。2.2 从包到流特征体系怎么搭原始流量数据分两种粒度。一种是 packet-based一个 pcap 里每一行是一个包字段包括时间戳、源目 IP、源目端口、协议、包长另一种是 flow-based把五元组相同的包聚合成一条流再对这条流计算统计特征。公开数据集大多给的是 flow-based 特征自己抓的 pcap 则是 packet-based需要自己做聚合。特征怎么搭直接决定模型能不能学到东西。以自己抓包后聚合为例我常用的特征组是这几个第一是长度特征包长均值、包长方差、最大包长、最小包长。网络中 DDoS 和扫描流量的包长分布和正常网页访问差异很大这是最值钱的指标。第二是时间特征流持续时间、包到达间隔的均值与方差、每秒包数。扫描器通常一秒发很多短包人工访问则间隔不规则。第三是协议特征TCP 标志位的计数比如 SYN 占比高多半在扫描或握手攻击。第四是方向特征上行与下行包的字节比例视频、网页下载下行流量大木马回连则相反。把这些特征组装成一个 DataFrame每行是一条流列是数值特征这才是检测模型的输入。常见误区是直接把 pcap 里所有字段原样塞进模型后面第 5 章会说这种行为为什么翻车。2.3 用 scapy 把 pcap 转成特征表一个可直接运行的脚本自己抓包拿到 pcap 之后我一般直接用 scapy 做解析它比手写 dpkt 直观。下面这段代码可以照抄下来当模板它会把 pcap 读进来按五元组聚合成流计算全套统计特征后导出 csv。from scapy.all import rdpcap, IP, TCP, UDP import pandas as pd import numpy as np from collections import defaultdict def pcap_to_flow_features(pcap_path): packets rdpcap(pcap_path) flows defaultdict(list) for pkt in packets: if IP not in pkt: continue if TCP in pkt: proto TCP sport, dport pkt.sport, pkt.dport elif UDP in pkt: proto UDP sport, dport pkt.sport, pkt.dport else: proto OTHER sport, dport 0, 0 key (pkt[IP].src, pkt[IP].dst, sport, dport, proto) flows[key].append({len: len(pkt), ts: float(pkt.time)}) rows [] for key, pkts in flows.items(): lens [p[len] for p in pkts] timestamps sorted([p[ts] for p in pkts]) intervals np.diff(timestamps) if len(timestamps) 1 else [0] rows.append({ src_ip: key[0], dst_ip: key[1], src_port: key[2], dst_port: key[3], proto: key[4], pkt_count: len(pkts), byte_sum: int(np.sum(lens)), len_mean: float(np.mean(lens)), len_std: float(np.std(lens)), duration: timestamps[-1] - timestamps[0], interval_mean: float(np.mean(intervals)), pps: len(pkts) / max(timestamps[-1] - timestamps[0], 1e-6), }) return pd.DataFrame(rows) if __name__ __main__: df pcap_to_flow_features(capture.pcap) df.to_csv(flow_features.csv, indexFalse)逻辑说明rdpcap 把整个 pcap 加载进内存循环里用 IP、TCP、UDP 三个判断过滤非 IP 包并按五元组塞进一个 defaultdict聚合阶段计算包数、总字节数、包长均值与标准差、流时长、包间隔均值和每秒包数。输出的 DataFrame 每一行就是一条流。参数说明scapy 的 rdpcap 对超过几百 MB 的大 pcap 很吃内存如果抓包文件很大建议换用 PcapReader 做流式读取或直接用 tshark 出 CSV命令是tshark -r capture.pcap -T fields -e ip.src -e ip.dst -e tcp.srcport -e tcp.dstport -e frame.len -E separator, packets.csv然后在 pandas 里 groupby 聚合。scapy 方案胜在写起来清晰适合毕设小实验tshark 方案胜在速度快适合真实流量验证。如果脚本跑完发现 flows 数量是 0先确认 pcap 里有没有 IP 层包scapy 在读取某些链路层封装时会读不到 IP 层。如果 len_std 全是 0说明抓包时包长度被网卡截断了这个特征直接报废换一个抓包环境重抓比硬抠代码快得多。2.4 滑动窗口聚合把流特征变成时间序列基线流特征是静态快照但异常检测里另一个常用输入是时间序列。做法很直接把时间轴切成固定窗口统计每个窗口里新流数量、总字节数、SYN 包比例。# 假设2.3产出的df里有时戳列这里做窗口聚合 df[ts] df[duration] # 占位实际应从原始pcap取窗口起点 df[window] (df[ts] // 60).astype(int) windowed df.groupby(window).agg( new_flows(pkt_count, count), total_bytes(byte_sum, sum), avg_len(len_mean, mean), ).reset_index()逻辑说明时间戳整除以窗口大小得到窗口编号再按窗口做聚合。new_flows 表示这个窗口内新建了多少条流total_bytes 是窗口内总流量avg_len 反映包大小均值的变化。这三列就是后续统计基线模型的输入。参数说明窗口大小选 60 秒是经验起步值。流量大到校园网出口级别时建议缩到 10 秒或 30 秒否则检测延迟太高小规模实验网络可以把窗口放大到 300 秒让曲线更平滑。判断标准很简单一个窗口内至少要有几十条流太少则统计噪声大太多则异常被摊平。如果聚合出来的曲线毛刺太多优先加大窗口而不是上来就做平滑滤波。3. 公开数据集选型与预处理NSL-KDD 与 CICIDS2017 的取舍流量数据自建标签的成本过高绝大多数毕设会选公开数据集。选哪个数据集不是随便点一个下载它直接影响预处理写法和最终指标的可信度。3.1 公开数据集横向对比从 KDD99 到 CICIDS2017我先给一张对比表把四个常见的选择放一起看。表格里很多细节是踩过坑才知道的比如 KDD99 有大量重复记录如果直接拿去做训练模型会过拟合到重复样本上。数据集发布年份特征数样本规模攻击类型适用定位KDD99199941约500万4大类39种算法教学样本冗余多早不推荐NSL-KDD200941约14.8万4大类毕设首选量小、类别均衡UNSW-NB15201549约250万9类攻击现代网络场景偏大CICIDS2017201780约280万14类攻击特征现代、文件大适合做第二个实验选型建议就一句话主实验用 NSL-KDD想要撑场面再加一个 CICIDS2017。NSL-KDD 的训练集约 12.5 万条、测试集约 2.2 万条普通笔记本几分钟就能跑完一个模型答辩时复现成本低。CICIDS2017 的 80 维双向流特征更贴近现在网络里的 DDoS、端口扫描、暴力破解但原始文件几十 GB即便官方 CSV 特征文件也有几百 MB需要机器内存跟得上。3.2 NSL-KDD 预处理标签二元化与 One-Hot 编码NSL-KDD 的每一行有 41 个特征加一个类别列类别列里 normal 是正常其余全是攻击类型。做异常检测课题时我一般先把它转成二分类正常是 0异常是 1这样模型只需要回答“是不是可疑”。import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(NSL_KDD_Train.csv) df[binary_label] (df[class] ! normal).astype(int) X df.drop([class, binary_label], axis1) X pd.get_dummies(X, columns[protocol_type, service, flag]) y df[binary_label] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) print(y_train.value_counts(normalizeTrue))逻辑说明get_dummies 把 protocol_type、service、flag 这三列字符串转换成 One-Hot 编码。这三列里的 service 字段比较特殊取值几十种One-Hot 之后特征维度会从 41 扩到一百多这是正常的。stratifyy 让训练和测试里正常与异常的占比保持一致避免随机切分把比例弄偏。参数说明random_state 设成 42 只是一个可复现种子论文里记得写死并公开后面所有实验都用同一个值。test_size 取 0.2 是常规值数据规模不大时也可以改成 0.3差别不大。注意这里用的是随机切分只适用于样本独立性较强的场景如果样本本身是连续采集的流量第 5 章会讲为什么要用时间切分替换它。3.3 CICIDS2017 读取注意大文件、时间戳与类别文本CICIDS2017 的特征文件每一行是一条双向流类别有 BENIGN 加多种攻击名。读它容易在内存上翻车我建议用 usecols 只取需要的列不要一次把 80 列全部载入。import pandas as pd # 只读指定列Label是类别Flow Duration等是典型特征列 cols [ Flow Duration, Total Fwd Packets, Total Backward Packets, Total Length of Fwd Packets, Flow Bytes/s, Flow Packets/s, Label] df pd.read_csv(MachineLearningCSV.csv, usecolscols) # CICIDS2017的列名前后常有空格先清洗 df.columns [c.strip() for c in df.columns] df[Label] df[Label].str.strip() # 清洗无穷值和空值 df df.replace([float(inf), -float(inf)], 0).dropna() valid_labels {BENIGN, DDoS, PortScan, Brute Force, Web Attack} df df[df[Label].isin(valid_labels)] df[binary_label] (df[Label] ! BENIGN).astype(int) print(df[binary_label].value_counts())逻辑说明usecols 只加载指定列减少内存。列名清洗是因为官方 CSV 里列名前后混着空格不去掉会找不到列。把 inf 和 NaN 一并处理是这类数据集的常见坑否则 StandardScaler 会直接报错或产出 NaN。Label 里有很多低频攻击类我用一个集合把常见类筛选出来其余丢弃避免类别太碎。参数说明DDoS 和 PortScan 在 CICIDS2017 里样本量最大这两个类加 BENIGN 已经足够撑起一个二元检测实验。如果机器内存足够可以把全部 80 维特征读进来做完整实验内存不足时就按我上面这个精简列方案效果一样能出因为那些网络统计列本身就是强特征。3.4 归一化与不平衡处理SMOTE 怎么用才安全流量特征的量纲差异巨大Flow Duration 是微秒级包数是个位数Bytes/s 可能上亿。不做归一化树模型还好说神经网络和距离类模型会直接被大数值列带偏。标准写法是 StandardScaler 只 fit 训练集再 transform 测试集这步的顺序不能反。from sklearn.preprocessing import StandardScaler from imblearn.over_sampling import SMOTE scaler StandardScaler() X_train_s scaler.fit_transform(X_train) X_test_s scaler.transform(X_test) print(ftrain shape{X_train_s.shape}, test shape{X_test_s.shape}) # 如果异常类占比过低再用SMOTE不是必须 smote SMOTE(random_state42, sampling_strategy0.3) X_tr_res, y_tr_res smote.fit_resample(X_train_s, y_train) print(after smote:, pd.Series(y_tr_res).value_counts().to_dict())逻辑说明fit_transform 是“拟合均值和标准差再把训练集转换掉”test 侧只用 transform绝不能在测试集上重新 fit。这行顺序写错第 5 章的第一个坑就来了。SMOTE 在异常类极稀疏时能起平衡作用但 sampling_strategy 不要设成 1.01:1 的平衡会让检测器误认为正常流量和攻击流量一样常见实际部署时误报爆炸。参数说明sampling_strategy0.3 表示把少数类扩充到多数类的 30%这是我在异常检测里常用的保守值既能缓解类别倾斜又不过度改变原始分布。如果用了 SMOTE后面评估时必须用原始测试集测试集不做任何过采样这一条经常被忘。4. 检测算法落地统计基线、孤立森林与自编码器的三条路线数据准备好了接下来是核心决策异常检测算法选哪条路线。我给毕设的建议是不要只做一个模型而是做一个“基线对照主力模型进阶方案”的三角结构答辩时工作量清楚性能对比也有得写。4.1 统计阈值法10 行代码的 z-score 基线异常检测最朴素的版本是统计阈值法。对每个特征列计算均值和标准差z-score 的绝对值大于某个阈值就认为是异常。它的难点不在实现而在让老师相信它的价值它是一切复杂模型的“下界参照”。import numpy as np from scipy.stats import zscore def detect_by_zscore(X, threshold3.0): zs np.abs(zscore(X, axis0)) # 对每一列独立算z-score anomalies np.any(zs threshold, axis1) # 任一特征越界即算异常 return anomalies, zs X_demo np.random.randn(1000, 5) anoms, zs detect_by_zscore(X_demo) print(fanomaly rate: {anoms.mean():.3f})逻辑说明zscore 的 axis0 表示按列计算每一列一个均值、一个标准差。np.any 是在做“或”逻辑任一维度的 z-score 超过阈值就把这个样本标成异常。这个策略偏向高召回会带来一定误报但异常检测场景里宁可多报一些让运营去看。参数说明threshold3.0 对应约 0.3% 的单侧误判率是正态分布下的理论值。流量数据很少严格正态我一般会在 2.5 到 3.5 之间扫一遍用验证集 F1 定。这个扫描技巧放到第 6 章展开。注意 z-score 对不同特征列的尺度天然免疫所以放在归一化前后结果都一样这也是我把它当“稳定基线”的原因。4.2 孤立森林无监督异常检测的默认起手式如果不想准备标签又想比统计基线强孤立森林IsolationForest是性价比最高的选择。它的原理是随机切割特征空间异常点因为离群通常只需要很少的切割次数就能被单独切出来所以“被切得更早”的样本就是异常。sklearn 里直接可用。from sklearn.ensemble import IsolationForest clf IsolationForest( n_estimators300, max_samples256, contamination0.05, random_state42, ) clf.fit(X_train_s) y_pred clf.predict(X_test_s) # 正常1异常-1 y_pred_binary (y_pred -1).astype(int) print(np.unique(y_pred, return_countsTrue))逻辑说明fit 阶段不需要传入标签模型在正常数据里学习“什么是孤立点”。predict 的返回值是 1 和 -1和常见的 0/1 风格不一样所以要手工映射成 y_pred_binary 再算指标这一步很容易写漏。参数说明contamination 是训练集里异常占比的先验估计设成 0.05 意味着模型认为大约 5% 的样本是异常。这个值不要靠猜先用第 5 章提的分数量化方法在验证集上扫。n_estimators300 是树的数量一般 200 到 500 之间够用超过 500 收益递减max_samples256 限制每棵树的采样量数据量大时改得更小能大幅加速。random_state 固定后才能复现对比实验。4.3 AutoEncoder用重构误差识别“没见过”的攻击流量攻击的形态一直在变监督模型对没训练过的攻击类型识别很差AutoEncoder 是解决这个问题的常见提法。它只拿正常样本训练学会用低维编码重建正常的流特征攻击样本的特征分布和正常差异大重建误差MSE会明显偏高阈值一卡就能判定异常。import torch import torch.nn as nn class AutoEncoder(nn.Module): def __init__(self, n_features, hidden(32, 8)): super().__init__() self.encoder nn.Sequential( nn.Linear(n_features, hidden[0]), nn.ReLU(), nn.Linear(hidden[0], hidden[1]), nn.ReLU(), ) self.decoder nn.Sequential( nn.Linear(hidden[1], hidden[0]), nn.ReLU(), nn.Linear(hidden[0], n_features), ) def forward(self, x): return self.decoder(self.encoder(x)) model AutoEncoder(n_featuresX_train_s.shape[1]) optimizer torch.optim.Adam(model.parameters(), lr1e-3) loss_fn nn.MSELoss() # 只用正常样本训练攻击样本不进训练过程 X_normal X_train_s[y_train 0] X_normal_t torch.tensor(X_normal, dtypetorch.float32) for epoch in range(30): model.train() recon model(X_normal_t) loss loss_fn(recon, X_normal_t) optimizer.zero_grad() loss.backward() optimizer.step() if (epoch 1) % 10 0: print(fepoch {epoch 1:02d}, loss{loss.item():.4f})逻辑说明模型的瓶颈是 hidden 里最小的 8 维正常样本的信息必须压缩到这 8 维再解压回来重建误差就是“这 8 个维度丢了多少信息”的度量。推理时对测试集每个样本计算一次 MSEMSE 大的判为异常。训练时只喂正常样本这一步是 AutoEncoder 能识别未知攻击形态的关键放进异常样本训练会让模型学着去重建攻击前功尽弃。参数说明hidden(32, 8) 是经验结构80 维输入用 (64, 16) 更稳特征少时 (16, 4) 也行。lr1e-3 是 Adam 的常用起点loss 不降就降到 1e-4 再试。epoch 先跑 30 轮看 loss 曲线正常样本量大时可以加 EarlyStopping。batch size 在小数据集上直接用全量也可以样本多再包 DataLoader 分 batch。推理阈值部分实际使用时这样写model.eval() X_test_t torch.tensor(X_test_s, dtypetorch.float32) with torch.no_grad(): recon model(X_test_t) mse ((recon - X_test_t) ** 2).mean(dim1) # 每个样本的重建误差 threshold torch.quantile(mse, 0.95) # 95%分位数作阈值 anomaly (mse threshold).numpy().astype(int) print(fthreshold{threshold:.4f}, anomaly rate{anomaly.mean():.3f})逻辑说明torch.quantile 按分位数选阈值0.95 表示默认允许 5% 的样本被判异常这样不依赖对异常比例的强假设。推理时要包在 torch.no_grad() 里既省显存又避免把梯度计算图存下来。参数说明0.95 这个分位同样是起步值要和统计基线一样在验证集上扫。它的物理意义是“宁可错杀 5%也不放过潜在攻击”安全场景的常规取舍。4.4 三条路线怎么选对照表与实验顺序三条路线的定位差异比较明显我给个直观的对照。方案是否需要标签对未知攻击解释性工程复杂度答辩价值统计阈值不需要弱强极低基线参照孤立森林不需要中中低主力结果AutoEncoder需要正常样本强弱中进阶亮点我的实验安排是这样统计基线先跑通产出第一个检测率和误报率孤立森林做主力调参后作为最终指标AutoEncoder 作为对未知攻击的补充实验配合重构误差分布图写进论文。三个方案共享同一套特征和同一份划分指标之间才可比。5. 流量异常检测避坑手册五个让结果翻车的常见问题5.1 数据泄露归一化用了全量数据验证集 AUC 虚高到 0.99现象测试集 AUC 高达 0.99换成真实流量后指标立刻崩盘准确率跌到六成以下。原因最常见的写法是先把完整数据集做成 DataFrame然后 fit_transform 整份数据再切分StandardScaler 在计算均值方差时偷偷“看”了测试集的信息。测试集的分布被平移到了训练集附近模型等于提前知道了考试答案这就是数据泄露。解决先切分、后 fit让归一化参数只学到训练集。更稳妥的做法是把整个流程包进 Pipeline切分和缩放都在同一个对象里完成不会漏步。from sklearn.pipeline import make_pipeline from sklearn.ensemble import IsolationForest pipe make_pipeline(StandardScaler(), IsolationForest(random_state42, contamination0.05)) pipe.fit(X_train) # 内部先缩放再训练 y_pred pipe.predict(X_test)逻辑说明make_pipeline 把缩放器嵌入管道fit 时只接触训练数据测试数据只在 predict 时经过已经学好的转换。这个写法把数据泄露的概率降到最低我现在的实验脚本统一用这招。5.2 类别不平衡正常流量占 99%模型全猜正常也有 97% 准确率现象打印 accuracy 是 0.97看起来很好但查看混淆矩阵攻击样本的召回率只有 0.02所有攻击几乎全漏了。原因流量数据天然是极度不平衡的正常流量占比常超过 95%。准确率这个指标在高度不平衡场景下没有意义模型的“高准确率”来自把所有样本都判成正常。解决不要用 accuracy 做主要指标看 Precision、Recall、F1再看 PR-AUC。sklearn 一条命令就能出全from sklearn.metrics import classification_report # y_pred_binary是模型输出的0/1y_test是真实标签 print(classification_report(y_test, y_pred_binary, target_names[normal, attack]))逻辑说明classification_report 同时输出精确率报出来的有多少是攻击、召回率真攻击里抓到了多少和 F1这三项才能反映检测器的真实水平。安全场景一般更多看召回率宁可多报也不放过。5.3 时序泄漏随机切分把同一段攻击拆进了训练集和测试集现象模型在测试集上表现不错但把模型换到另一天抓的流量上几乎完全失效连重复出现过的攻击类型都认不出来。原因流量样本不是孤立的同一时刻的扫描或 DDoS 会产生成百上千条相似样本。用 train_test_split 随机切分时这些相似样本会同时落进训练集和测试集模型相当于“见过”测试集。它不是学到了一般规律而是记住了特定 IP 和端口组合。解决改用时间顺序切分。先按时间戳排序前 80% 做训练后 20% 做测试或者用 GroupKFold 按会话分组切分。# 假设特征表里有ts列流开始时间 df_sorted df.sort_values(ts).reset_index(dropTrue) split_idx int(len(df_sorted) * 0.8) train df_sorted.iloc[:split_idx].copy() test df_sorted.iloc[split_idx:].copy() print(ftrain time span: {train[ts].min()} ~ {train[ts].max()}) print(ftest time span: {test[ts].min()} ~ {test[ts].max()})逻辑说明时间切分的核心是确保训练集的时间范围完全在测试集之前。打印出两段的时间跨度是判断是否切错了的最快方式。注意顺序一定是先排时间再切分直接给 DataFrame 排序后随手取前 80% 行是不对的。5.4 特征陷阱源 IP、目的 IP、端口直接当特征喂给模型现象训练集 AUC 是 1.0但把攻击工具的源 IP 从 192.168.1.10 换成 10.0.0.5模型立刻把所有攻击都漏了。原因IP 地址和端口是离散标识符模型完全不理解它们之间的语义关系只能死记硬背“哪个 IP 对应攻击”。流量分析落地的第一课就是不能把标识符当特征。很多同学觉得加这些列能提高准确率其实是在让模型背答案。解决把这些列去掉或者把标识符转成聚合统计量。比如窗口内某个 IP 出现的次数、某个目的端口被访问过的不同源 IP 数量这类统计特征表达的是“行为”而不是“身份”。5.5 阈值拍脑袋contamination 设成 0.1每天误报几千条现象孤立森林设定异常占比 0.1 后测试集上误报率接近 10%安全运营告警刷屏没人愿意看。原因contamination 应该是对真实异常比例的估计而不是随便填的默认值。真实流量里异常比例可能只有 0.01模型按 10% 去切全是在抓正常流量的“边缘分子”。解决用模型输出分数来定阈值而不是直接定比例。孤立森林的 decision_function 输出的分数本身就代表异常程度在验证集上按分数排序选 F1 或误报率最合理的那个切点。# 孤立森林的异常分数越低越异常 scores clf.decision_function(X_test_s) threshold np.percentile(scores, 5) # 取分数最低的5%为异常 y_pred_binary (scores threshold).astype(int) print(fscore range: {scores.min():.3f} ~ {scores.max():.3f}, threshold{threshold:.3f})逻辑说明decision_function 的分数表征样本偏离正常分布的强度分数越低越异常所以阈值取低分位的 5%。percentile 的参数可以跟在验证集上扫出来的结果调整。这个方法把“先验的比例”变成一个“可调参数”对流量这种分布变化快的数据更稳。6. 从能跑到能用分数分布可视化与阈值校准不管选哪条算法路线模型输出到最后都是一列分数。分数是连续值它浓缩了“这个样本有多异常”的全部信息也是选阈值时唯一能依靠的证据。所以我的习惯是任何检测器训练完第一件事不是打印指标而是画出分数的分布直方图肉眼确认有没有区分度。import matplotlib.pyplot as plt # scores来自孤立森林或AutoEncoder的重建误差 plt.figure(figsize(8, 4)) plt.hist(scores[y_test 0], bins50, alpha0.6, labelnormal) plt.hist(scores[y_test 1], bins50, alpha0.6, labelattack) plt.axvline(threshold, colorred, linestyle--, labelfthreshold{threshold:.3f}) plt.xlabel(anomaly score) plt.ylabel(count) plt.legend() plt.tight_layout() plt.savefig(score_distribution.png, dpi150)这段代码里我把正常样本和攻击样本的分数分两组画在一张图上再补上当前阈值线。如果两个分布有明显分开说明特征有区分度阈值线卡在谷底是最理想的如果两个分布几乎重叠说明特征没选好再调模型结构的收益也不大回到第 2 章改特征才是正路。实际跑下来最常见的状态是正常样本的分数集中在低区攻击样本拖了一条长尾到高区。这时候阈值线的位置就是取舍的位置往左移漏报多往右移误报多。我一般在验证集上从左往右扫百分位每次记录 F1取最高点作为最终阈值这个数直接用在测试集上测试集绝不参与调阈值。回想我自己做这类项目的经历最亏的一段就是把时间全耗在 SMOTE 参数和三层网络结构上后来发现把分数分布图挂在屏幕边、按图调阈值比瞎试快得多。这个项目真正难的不是写出模型而是让你的模型在另一个时段、另一批主机上还能站得住。把数据链路做扎实把阈值选得有据可依结果自然能说服人。希望帮到你。本文还有配套的精品资源点击获取
返回列表