简介:这是一份基于深度神经网络的流量异常检测完整项目,面向希望入门Python神经网络与网络安全的开发者,也适合作为毕业设计或课程设计的参考实现。项目使用加拿大网络安全研究所发布的CICIDS2017数据集,借助Pandas完成数据清洗与标准化,并在TensorFlow平台上利用Keras构建DNN或LSTM模型进行分类检测,同时包含超参数调优思路。压缩包体积约10.58MB,共8个文件,其中5个CSV文件为预处理后的多种攻击类型数据集,2个Python脚本分别实现DNN与LSTM模型,另有1个Markdown说明文档。已有297人学习浏览,内容结构清晰,方便读者快速对照运行。通过阅读代码与文档,可以掌握从数据预处理、神经网络建模到模型评估的完整流程,非常适合作为实战练手或项目起步的参考资料。
1. 流量异常检测为什么值得用神经网络重做一遍
流量异常检测不是新话题,传统做法里 SNMP 阈值、NetFlow 基线、统计模型(EWMA、Holt-Winters)都成熟可用,但它们在两类场景里明显吃力:一是流量基线本身剧烈波动,秒级突发和周期波动混在一起,固定阈值要么误报满天飞要么漏报;二是异常形态不固定,端口扫描、DDoS 小包冲击、慢速渗透、内部横向移动在特征空间里分布各异,靠人工维护规则很难跟上。神经网络的做法是把「正常流量长什么样」这件事交给模型去学,而不是人去定义,这让检测系统能自适应基线漂移,也能捕捉到规则搜不到的组合特征。本文用一个可落地的思路展开:基于 Python 生态,结合神经网络做流量异常检测,从特征工程到模型训练,从参数调整到误报治理,最后给出一个线上可用的小型检测框架。适合已经会用 Python 处理数据、想把手头流量数据变成检测能力的工程师,也适合刚入门想理解神经网络在这个领域如何真正工作的读者。
2. 从流量特征到神经网络输入:特征工程是关键前提
2.1 为什么神经网络不能直接吃原始流量
神经网络处理的是数值张量,而原始流量是字节流、报文头和会话记录。如果直接把报文内容喂给模型,面临三个问题:变长序列需要复杂架构(比如 Transformer 或 LSTM),训练成本高;特征空间里混入大量与异常无关的信息(比如 payload 内容),模型容易被带偏;网络流量的语义在协议层,而神经网络擅长的是数值分布中的模式发现。所以常见的做法是先把流量聚合为流记录(flow record),再从流记录中提取特征向量。
一个流通常用五元组标识:源 IP、源端口、目的 IP、目的端口、协议号。在这个基础上,可以提取统计特征:包数、字节数、流持续时间、每秒包数、平均包长、TCP 标志位分布、源/目的端口分布熵等。这些特征构成一个固定维度的向量,适合作为前馈神经网络或自编码器的输入。
2.2 用 Python 从 pcap 提取特征的参考实现
实际项目中,我一般不会直接解析 pcap 文件里的每个字节,而是用现成库简化工作。常见选择是scapy或pyshark,前者纯 Python 实现,后者包装了 tshark。处理大规模离线文件时,pyshark更省心,因为解析逻辑由 Wireshark 维护,兼容性好。
import pyshark import numpy as np from collections import defaultdict def extract_flows(pcap_path): cap = pyshark.FileCapture(pcap_path, use_json=True, include_raw=False) flows = defaultdict(lambda: {"packets": 0, "bytes": 0, "start": None, "end": None}) for pkt in cap: try: if "IP" not in pkt: continue src = pkt.ip.src dst = pkt.ip.dst proto = pkt.ip.proto sport = pkt[pkt.transport_layer].srcport dport = pkt[pkt.transport_layer].dstport key = (src, sport, dst, dport, proto) flows[key]["packets"] += 1 flow_bytes = int(pkt.length) flows[key]["bytes"] += flow_bytes ts = float(pkt.frame_info.time_epoch) if flows[key]["start"] is None or ts < flows[key]["start"]: flows[key]["start"] = ts if flows[key]["end"] is None or ts > flows[key]["end"]: flows[key]["end"] = ts except AttributeError: continue cap.close() return flows flows = extract_flows("traffic.pcap")这段代码是按五元组聚合流记录。use_json=True让 pyshark 以 JSON 格式输出解析结果,速度比默认方式快不少;include_raw=False不保留原始报文,节省内存。pkt.transport_layer可能是 TCP、UDP 或 SCTP,取端口时要用 try 兜底,避免非 IP 报文导致 AttributeError。
聚合完成后,每个流就是一个样本。下面对每个流做特征向量化:
def flow_to_vector(f): duration = f["end"] - f["start"] if f["end"] and f["start"] else 0 if duration <= 0: duration = 1e-6 pkt = f["packets"] bts = f["bytes"] return np.array([ pkt, bts, duration, pkt / duration, # 每秒包数 bts / duration, # 每秒字节数 bts / pkt, # 平均包长 ], dtype=np.float32)这里只列了 6 个特征作为演示,生产环境至少要到 30 维以上。要注意的是:流持续时间不能为 0,否则除零;时间用 epoch 秒,做差即可。特征向量里的数值尺度差异很大,包数可能是个位数,每秒字节数可能是几十万,直接进神经网络会让梯度被大数值特征主导,后面必须做标准化。
2.3 特征标准化与数据集划分
特征标准化在流量异常检测里比图像领域更敏感。图像像素天然在 0-255,流量特征没有天然上界。我一般用sklearn的StandardScaler,但有一个坑:必须只用训练数据拟合 scaler,不能用全部数据。因为测试集在现实中是「未来」的流量,我们不能让模型偷看到未来的统计分布。这类似于时间序列交叉验证的思路,要把时间顺序考虑进去。
from sklearn.preprocessing import StandardScaler X_raw = [] for flow in flows.values(): X_raw.append(flow_to_vector(flow)) X_raw = np.array(X_raw) # 按时间顺序划分:前80%是训练,后20%是测试 split = int(len(X_raw) * 0.8) scaler = StandardScaler() X_train_raw = X_raw[:split] X_test_raw = X_raw[split:] scaler.fit(X_train_raw) X_train = scaler.transform(X_train_raw) X_test = scaler.transform(X_test_raw)划分原则是「先时间、后随机」。随机划分在这种场景里会造成信息泄漏:训练集和测试集里可能包含同一时间的相似流量模式,模型评估结果虚高。真实部署时更要养成这个习惯,按时间窗口切分数据。
3. 模型选型:自编码器做无监督,一维 CNN 做有监督
3.1 为什么没有异常样本也能建模:自编码器的思路
流量异常检测一个很现实的痛点是「没有标签」。网络里正常流量无限多,异常流量却需要安全人员逐条标注,成本极高。自编码器(Autoencoder)恰好适用于这种场景:只拿正常流量训练,让模型学会「压缩-重建」正常数据,推断时如果某个样本重建误差很大,就说明它不属于模型见过的分布。
具体结构是:输入层 → 编码器(逐层降维)→ 瓶颈层 → 解码器(逐层恢复)→ 输出层。输入维度等于特征数量,瓶颈层维度远小于输入,强迫模型学习正常数据的主要模式。如果特征之间有关联结构(比如每秒包数和每秒字节数高度相关),瓶颈层就能抓住这些关联。
import torch import torch.nn as nn class FlowAutoencoder(nn.Module): def __init__(self, input_dim, hidden_dim=16, bottleneck_dim=4): super().__init__() self.encoder = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, bottleneck_dim), nn.ReLU(), ) self.decoder = nn.Sequential( nn.Linear(bottleneck_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, input_dim), ) def forward(self, x): code = self.encoder(x) return self.decoder(code)hidden_dim选择依据输入维度,一般取输入维度的 2-4 倍做中间层,bottleneck_dim取输入维度的 1/4 到 1/8。输入维度几十时,这个量级足够。训练目标是让重建输出逼近输入,损失函数用均方误差(MSE)即可。
def train_autoencoder(model, train_loader, epochs=20, lr=1e-3): criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=lr) model.train() for epoch in range(epochs): total_loss = 0 for batch in train_loader: optimizer.zero_grad() recon = model(batch) loss = criterion(recon, batch) loss.backward() optimizer.step() total_loss += loss.item() * batch.size(0) print(f"epoch {epoch+1}, loss={total_loss/len(train_loader.dataset):.6f}")训练收敛后,对每个样本计算重建误差。误差度量方式我常用 MSE,即逐元素差的平方和再求平均。然后设定一个阈值:超过阈值判为异常。阈值怎么选?如果有一小部分标注数据,就用验证集上的误差分布取 95% 或 99% 分位点;完全没有标注,就用训练误差的最大值乘一个安全系数(比如 1.5),但这样做误报率高,只适合快速上线后逐步校准。
3.2 有少量标签时,一维 CNN 更直接
如果积累了一批标注数据——哪怕只有几千条——一维卷积神经网络(1D-CNN)是一个性价比很高的选择。它把特征向量当作长度为特征维度的信号,用卷积核在相邻特征上提取局部模式。这里的「局部」是指特征之间的关联,比如包数、字节数、持续时间的组合,卷积核可以捕捉到多特征联合异常。
class FlowClassifier1D(nn.Module): def __init__(self, input_dim, num_classes=2): super().__init__() self.conv = nn.Sequential( nn.Conv1d(1, 16, kernel_size=3, padding=1), nn.ReLU(), nn.Conv1d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) self.fc = nn.Linear(32, num_classes) def forward(self, x): # x: (batch, input_dim) -> (batch, 1, input_dim) x = x.unsqueeze(1) feat = self.conv(x).squeeze(-1) return self.fc(feat)Conv1d的in_channels设为 1,因为每个样本只有一个通道。kernel_size=3意味着每次看 3 个连续特征;padding=1保持长度不变。AdaptiveAvgPool1d(1)把卷积输出压成一个值,再接全连接层输出分类。训练时用交叉熵损失,类别不平衡(正常样本远多于异常)时,给损失函数加权:异常类的权重是正常类的 5-10 倍。
criterion = nn.CrossEntropyLoss(weight=torch.tensor([1.0, 8.0]))这个 8.0 表示异常类别权重,具体值取决于正常:异常比例。如果正负样本比是 100:1,权重至少设 10。权重太大模型会把正常误判为异常,太小则漏报,用验证集 F1 分数调。
3.3 选型决策对照
| 场景 | 推荐网络 | 原因 |
|---|---|---|
| 无标签,正常流量易获取 | 自编码器 | 只需正常数据训练 |
| 有少量标签(几百条) | 一维 CNN 或前馈网络 | 能利用标签信息,模型简单可控 |
| 高维特征且特征有序(如时序特征) | 一维 CNN 或 LSTM | 卷积能捕捉局部特征交互,RNN 捕捉时间依赖 |
| 流量序列从头到尾完整保留 | 循环神经网络 | 能建模会话内的状态变化,如慢速攻击 |
| 想先快速出基线 | 前馈网络 + 主成分分析(PCA) | 训练快,容易解释 |
实际工程中,我推荐先跑自编码器,因为它不需要标签,能立刻投产;几个月后有了标注数据,再切到一维 CNN 提升精度。这个迁移成本很低,因为特征工程部分完全复用。
4. 训练参数、阈值校准与误报治理
4.1 训练的 3 个必调参数
神经网络训练参数在流量异常检测里有特殊性。学习率、批大小和输入特征缩放是影响最大的三个因素。
学习率控制梯度下降的步长。流量特征经过标准化后一般在 -3 到 3 之间,梯度量级相对稳定。用 Adam 优化器时,初始学习率 1e-3 通常能收敛;如果 loss 发散,降到 1e-4。一个信号:训练 loss 降到某值后长时间不动,可能是学习率太大在极小值附近震荡,也可能是模型容量不够。前者降低学习率,后者增加隐藏层维度。
批大小影响梯度估计的噪音。流量数据集通常几十万条,批大小 256 或 512 都合理。批太大(比如 4096)会让每次梯度更新过于平滑,模型容易收敛到平坦的次优解;太小(比如 8)则训练不稳定。
特征缩放我这里强调的第三个参数是 StandardScaler 的with_mean选项。如果特征是稀疏的(大量为 0,比如没有某个协议时该维为 0),with_mean=True会把稀疏矩阵变成密集矩阵,内存爆炸。流量特征一般不稀疏,但如果有 flag 类的 0/1 特征,建议单独处理,不要混进连续特征一起标准化。
from sklearn.preprocessing import StandardScaler import numpy as np scaler = StandardScaler(with_mean=True, with_std=True) X_train_scaled = scaler.fit_transform(X_train)标准化之后,必须保存 scaler 参数(mean_和scale_),在推理时用同一组参数转换,不能用新数据的统计量重新 fit。
4.2 阈值校准:用误差分布而非凭感觉
自编码器训练完,需要确定异常判定阈值。最常见的方法:在验证集上计算所有样本的重建误差,然后取分位数。
def compute_reconstruction_errors(model, loader): model.eval() errors = [] with torch.no_grad(): for batch in loader: recon = model(batch) mse = torch.mean((recon - batch) ** 2, dim=1) errors.extend(mse.cpu().numpy()) return np.array(errors) valid_errors = compute_reconstruction_errors(model, valid_loader) threshold = np.percentile(valid_errors, 99) print(f"99% 分位点阈值: {threshold:.6f}")99% 分位点意味着正常情况下有 1% 的流量会被判为异常。如果你的告警系统每天处理百万条流,1% 就是一万条告警,运维根本看不过来。所以实际中我会先设 99.9% 分位点看告警量,逐步向下调。这个调的过程要有验证集支撑,不能直接在生产上试。
4.3 误报治理的常见手段
误报的根源通常不是模型,而是特征分布里混入了「正常但不常见」的流量。比如凌晨 3 点的健康检查、短时间的高频 DNS 查询,这些在业务上正常,但偏离了主分布。治理手段有三个。
第一,增加特征层面过滤。对单个特征做 min-max 限制,比如每秒包数超过 10000 的直接分流;这不是用模型判断,而是用业务知识降低成本。第二,对预测结果做时间窗口聚合,单条流异常不告警,连续 N 条异常才告警。第三,定期用新数据重新训练分布模型。流量模式会随业务变化,一周前的正常流量分布可能已经过时。
def online_threshold_adjust(mse_values, window_size=1000, quantile=0.99): # 滑窗阈值:用最近 window_size 条流量的误差分位数动态调整 if len(mse_values) < window_size: return float("inf") recent = mse_values[-window_size:] return float(np.percentile(recent, quantile))这是动态阈值的一种简单实现。用最近 1000 条流的误差分布调整阈值,能自适应基线漂移。但注意,极端情况下异常流量占窗口比例过大时,动态阈值会把异常当成正常。所以窗口要足够大,且要有兜底——固定阈值和动态阈值中取较大值。
5. 从离线检测到在线检测:部署与数据流水线
5.1 在线检测的架构:离线训练、在线推理分离
离线训练和在线推理一旦混在一起,系统会变得脆弱。常见的部署方式是把训练做成定时任务(每天凌晨跑一次),把推理做成常驻服务(比如 gRPC 或 HTTP 接口)。推理服务加载固定版本的模型和 scaler,不参与训练,保证延迟稳定。
from flask import Flask, request, jsonify import joblib import numpy as np app = Flask(__name__) model = torch.load("autoencoder.pth", map_location="cpu") scaler = joblib.load("scaler.pkl") @app.route("/detect", methods=["POST"]) def detect(): data = request.get_json() feats = np.array(data["features"], dtype=np.float32).reshape(1, -1) feats_scaled = scaler.transform(feats) with torch.no_grad(): recon = model(torch.from_numpy(feats_scaled)) mse = float(torch.mean((recon - torch.from_numpy(feats_scaled)) ** 2).item()) return jsonify({"anomaly_score": mse, "threshold": threshold}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8088)joblib保存标准化器,torch.load加载模型。每次请求来一个特征向量,返回重建误差。这个服务要关注两个指标:P99 延迟和单请求吞吐。特征只有几十维,纯 CPU 推理单核每秒能处理数千条请求,不需要 GPU。
5.2 流数据的在线聚合窗口
在线检测的难点不是推理,而是「特征怎么来」。流量的流记录是持续到达的,一个流可能持续几十秒甚至几分钟,我们不能等流结束才提取特征。实际做法是滑窗聚合:每 30 秒输出一次当前活跃流的特征快照。这样同一个流会被输出多次,异常检测看到的是流的状态变化。
import time from collections import defaultdict class FlowWindowAggregator: def __init__(self, window_seconds=30): self.window_seconds = window_seconds self.flows = {} self.window_start = time.time() def add_packet(self, key, length, ts): if key not in self.flows: self.flows[key] = {"packets": 0, "bytes": 0, "start": ts} self.flows[key]["packets"] += 1 self.flows[key]["bytes"] += length self.flows[key]["end"] = ts def emit_window(self): now = time.time() if now - self.window_start < self.window_seconds: return [] features = [] for key, f in self.flows.items(): features.append((key, flow_to_vector_with_meta(f))) self.flows.clear() self.window_start = now return features每个 30 秒窗口结束时,先输出特征再清空状态。这个设计与无状态推理服务配合良好:聚合器负责提取特征,推理服务负责打分,两者通过队列或 HTTP 连接。窗口太短(5 秒)特征不稳定,流尚未达到稳定状态;窗口太长(5 分钟)则异常发现延迟过高,安全场景不可接受。30 秒是一个平衡点。
5.3 判断模型退化的三个信号
模型上线后不是一劳永逸。我建议监控以下三个信号,任何一个异常都触发重新训练流程。
一是重建误差分布的整体偏移。正常情况下模型对正常流量的平均误差应该在某个范围内,如果连续 7 天持续上升,说明流量模式在漂移。二是告警数量的趋势性变化。如果告警量每天翻倍,大概率是模型已经过时,而不是攻击变多。三是新增业务上线后误报率升高。比如新部署了一个监控系统,大量新的长连接流量出现,模型没见过这类模式,必然误报。
重新训练的流程要自动化:每天从数据仓库取前一天的流数据,重新 fit scaler 和模型,用校验集评估新旧模型效果,旧模型效果下降超过 5% 就发布新的。
6. 用 SHAP 给异常流量加可解释性,让告警不再是无头冤案
网络异常检测告警最难处理的不是检测本身,而是安全分析师打开告警后不知道「为什么这条是异常」。神经网络的输出是一个分数或标签,但分析师需要的是「哪些特征偏离了正常范围」。SHAP(SHapley Additive exPlanations)可以在模型层面解释每个特征的贡献度,让告警从「模糊可疑」变成「这个流每秒包数是正常值的 20 倍,且目的端口是 445」。
import shap import numpy as np # 用训练数据的一部分作为背景集 background = X_train_scaled[:100] explainer = shap.Explainer(model_wrapper, background) def explain_flow(feature_vector): shap_values = explainer(feature_vector.reshape(1, -1)) return shap_values但这里有个坑:shap.Explainer需要模型的predict返回一维输出。自编码器的「输出」是重建后的向量,不是标量。所以要做个包装:将模型的重建误差作为预测值。
import torch class AEWrapper: def __init__(self, model): self.model = model def predict(self, X): X_t = torch.from_numpy(np.asarray(X, dtype=np.float32)) with torch.no_grad(): recon = self.model(X_t) return torch.mean((recon - X_t) ** 2, dim=1).numpy() wrapper = AEWrapper(model) explainer = shap.Explainer(wrapper.predict, X_train_scaled[:100])对于 1D-CNN 分类模型,SHAP 的predict返回的是类别概率,直接包装分类器的forward中对应输出的 softmax 即可。我在实际项目中更常用shap.Explainer配合前馈神经网络,因为卷积层的可解释输出会更粗糙,特征维度少时前馈网络足够。
解释结果展示给分析师时,不直接输出 SHAP 的原始值,而是做一层翻译:
| 表现 | 解读 | 建议动作 |
|---|---|---|
每秒包数贡献度 +0.83 | 远超正常基线 | 检查是否有扫描行为 |
目的端口 445贡献度 +0.72 | 非常见端口组合 | 确认是否内部横向移动 |
流持续时间贡献度 -0.45 | 远短于正常流 | 请求可能未完成,存在探测流量 |
SHAP 值是相对贡献,不是概率。正值表示该特征值让模型更判定为异常,负值反之。绝对值越大影响越大。
最后留一个实操技巧:SHAP 计算成本较高,不适合在每条流量推理时都实时计算。常见做法是只对告警的 top 10% 流量算 SHAP——也就是那些得分刚刚超过阈值的样本,它们最容易误报也最需要解释。对得分远超阈值的流量,特征偏移往往很直观,解释价值有限。
对于每条告警,把 SHAP 解释结果随告警一起写入票务系统(如 Jira、PagerDuty),分析师打开即可看到特征贡献排序。三个月后,如果连续出现同一模式的特征组合,说明模型漏掉了一个常见异常类别——这就成了数据积累买到的下一轮迭代素材。
本文还有配套的精品资源,点击获取