简介:这是一份基于深度强化学习构建的网络入侵检测系统完整源代码,并附带实验数据集。项目以异步优势 actor critic 算法为核心,覆盖数据清洗与格式转换、智能体交互环境、策略网络训练、实时监控等模块,可用于计算机、信息安全、人工智能、大数据等专业的毕业设计、课程设计或期末大作业,也适合有一定基础的开发者在此之上做功能扩展。资源包共五十个文件,主要包含源代码脚本、文本说明、数据文件等类型,另附图表与一键启动脚本,整体约十九兆,目录结构清晰,方便按模块查阅学习。目前已有三百四十三人学习下载,代码经过运行验证,并带有完整的训练集与测试集,可支撑不同入侵类型的检测实验和结果对比。无论是希望快速入门深度强化学习在网络防御中的应用,还是需要一套可运行的基线系统,这份资料都具备较高的参考与二次开发价值。
1. 基于深度强化学习的网络入侵检测,到底在解决什么
网络入侵检测系统的传统方案有两条路:一条靠规则库,用 Snort 或 Suricata 的签名表匹配已知攻击特征,碰到变种和加密流量往往直接哑火;另一条靠监督式机器学习,在训练集上刷 AUC 很好看,但换到真实流量里要重训、要调阈值,漏报和误报的平衡仍需手工反复折腾。基于深度强化学习的方案把检测问题换了个思路:不再让模型“记住攻击长得什么样”,而是把流量交互变成试错过程,让智能体自己学会什么情况下该放行、什么情况下该告警,把业务目标直接写进奖励函数里收敛。这套 Python 源码完整覆盖了环境封装、DQN 智能体、训练主循环,并附带预处理好的数据集,适合有 Python 基础、想在网络侧把深度强化学习真正落到实处的安全工程师阅读。以下是我复现这类项目的完整过程和踩坑记录。
2. 为什么偏偏是深度强化学习:状态、动作与奖励的建模逻辑
2.1 静态模型吃不到的“长尾攻击”,正是 DRL 的切入点
先放结论:用深度强化学习做网络入侵检测,目标不是把 Benchmark 刷到 99.99% 的准确率,而是把人工调阈值、调特征权重的琐碎工作替换成“奖励函数设计”。监督学习框架里,模型只看当前样本的特征和标签,学到的本质是“这个特征组合以前被标成攻击,所以下次大概率也是”。这类模型最大的问题就是静态:攻击者会变,特征分布会漂移,决策边界不会自己跟着调整。
强化学习的差异在于把检测当成序列决策问题。智能体不是判完一条就收工,它会在当前流量反馈的基础上调整后续策略,让长期累计收益最大。网络流量天然有很强的时序习惯:攻击有扫描、渗透、回传几个阶段,前序行为往往能预示后续行为。这种长尾时序信息恰好是监督模型不擅长、而强化学习擅长利用的。所以这一类项目选深度强化学习,不是为了炫技,是冲着“决策闭环可演化”来的。
2.2 NIDS 里四个关键映射:环境、状态、动作、奖励
要把深度强化学习套到网络入侵检测上,首先得把概念映射清楚。我一般会先画一张表,做映射不是为了学术严谨,而是为了后面写代码时每一行都知道自己对应的是什么:
| 强化学习要素 | NIDS 中的含义 | 常见实现 |
|---|---|---|
| 环境 | 带有反馈信号的流量样本流 | 封装一批会话特征,每次暴露一条样本 |
| 状态 | 当前连接或会话的特征向量 | 数值特征标准化,必要时拼接前几条样本做时序窗口 |
| 动作 | 检测决策的输出空间 | 0 表示放行,1 表示告警 |
| 奖励 | 检测质量与业务代价的组合 | 正确告警给正分,误报和漏报给负分 |
| 终止条件 | 一批样本遍历结束 | 当前 episode 跑完整个数据文件 |
这里最容易被忽略的是动作空间。很多初次接触的人会把动作设计成“哪一类攻击”,实际工程里这么做会把问题复杂化。入侵检测的核心诉求是“这个流量要不要引起安全人员注意”,二值决策就足够。想做攻击分类,是下游威胁情报系统的事,不要在检测这个环节里硬塞多分类。
2.3 算法选型:DQN、DDPG、PPO 怎么选
深度强化学习的算法家族很大,但能用在入侵检测这种场景里的其实就几个:
| 算法 | 适用动作空间 | 用在 NIDS 里的判断 |
|---|---|---|
| DQN | 离散小动作空间 | 二分类决策最稳定,先跑通的首选 |
| Double DQN | 离散小动作空间 | 解决 DQN 的 Q 值过估计,训练更平稳 |
| DDPG | 连续动作空间 | 很少用,除非把告警阈值设计成连续输出 |
| PPO | 离散或连续都行 | 适合把状态序列拉长、需要策略平滑更新的场景 |
只有“告警 / 放行”两个动作时,我不建议一上来就上 PPO。动作空间小,DQN 的经验回放机制能让训练更好收敛,网络结构简单,出问题也好排查。PPO 的优势在连续控制和大动作空间里才明显;用在二值动作上属于杀鸡用牛刀,还要多调 GAE 系数和 clip 范围。我复现这类项目时,起步阶段都是 DQN 或 Double DQN,跑通之后再做 PPO 对照实验。
3. 跑通最小系统:Python 依赖、数据预处理和首次训练
3.1 先把环境装好:Python 版本与依赖清单
压缩包解压后,我习惯先建一个干净的虚拟环境,避免和系统 Python 打架。项目依赖主要是 PyTorch、pandas、numpy、scikit-learn 和 gymnasium。gymnasium 是 OpenAI Gym 的维护分支,新代码建议直接用它,API 兼容性好,后面做环境封装时会省很多事。
# Python 3.8 以上都可以,建议用 3.10 python -m venv nids_env source nids_env/bin/activate # 先装 CPU 版本跑通,特征维度高或样本量大再换 GPU 版本 pip install torch pip install pandas numpy scikit-learn gymnasium如果你还在 Python 入门阶段,建议先跑几个 gymnasium 自带的小环境熟悉一下 reset、step、reward 的交互逻辑,再进来看这个项目。这块环境交互的语法不熟,后面封装 NIDS 环境会容易看晕。
3.2 解压后的目录结构长什么样
压缩包里的内容各家整理风格不同,但跑这类项目常见的目录形态是这样:
nids_drl/ ├── agent/ │ ├── dqn_agent.py # DQN 智能体:网络、训练、动作选择 │ └── replay_buffer.py # 经验回放 ├── env/ │ └── nids_env.py # gymnasium 环境封装 ├── data/ │ ├── raw/ # 原始数据集,不建议直接改动 │ └── processed/ # 预处理后的 CSV,训练直接读这里 ├── train.py # 训练入口 ├── evaluate.py # 评估入口 └── config.yaml # 超参数配置data 目录里放的数据集,来源通常是 NSL-KDD 或 UNSW-NB15 的某个切片。这类数据集的共同特点是:特征列多、混合类型(数值加类别)、标签列做二分类映射。如果你的压缩包里是原始格式,按下面的预处理流程过一遍即可;如果已经是处理好的 CSV,直接跳到 3.4 节开始训练。
3.3 数据预处理:从原始 CSV 到强化学习能用的状态向量
预处理是整个流水线里最不该省的一步。很多强化学习训练不收敛,问题不在算法,而是喂进去的特征没有标准化、类别列没有编码。下面这段脚本是我每次都会先跑的通用的预处理流程:
# preprocess.py import pandas as pd import numpy as np from sklearn.preprocessing import LabelEncoder, StandardScaler def load_and_preprocess(raw_csv, save_csv): df = pd.read_csv(raw_csv) # 约定最后一列是标签列,先取出来单独处理 label_col = df.columns[-1] y = df[label_col].map(lambda x: 0 if str(x).strip().lower() == "normal" else 1) df = df.drop(columns=[label_col]) # 分类特征做标签编码,不能直接给神经网络 for col in df.select_dtypes(include=["object"]).columns: df[col] = LabelEncoder().fit_transform(df[col].astype(str)) # 数值特征做标准化,避免量纲差异淹没模型 num_cols = df.select_dtypes(include=[np.number]).columns.tolist() scaler = StandardScaler() df[num_cols] = scaler.fit_transform(df[num_cols]) # 把标签拼回去,输出给训练用的 CSV df["label"] = y.values df.to_csv(save_csv, index=False) return scaler, df if __name__ == "__main__": scaler, _ = load_and_preprocess("data/raw/train_raw.csv", "data/processed/train.csv")这段代码有两个关键点:一是标签必须在标准化之前取出来,否则标签列会被 StandardScaler 当成数值特征一起缩放;二是 scaler 对象要保存到本地文件,推理阶段用同一套均值方差来做 transform,不能用新数据的统计量重新 fit。我通常用 pickle 或 joblib 把 scaler 存下来,这是后面做真实流量评估时的后悔药——流数据分布和训练集不可能完全一致,scaler 不一致会直接放大检测误差。
3.4 首次训练:最小命令和观察指标
数据准备好了之后,看下项目里有没有 config.yaml。没有的话自己建一个,把最基本的参数写进去:
env: reward_weights: tp: 1.0 # 正确告警 fp: -0.5 # 误报 fn: -2.0 # 漏报 tn: 0.1 # 正确放行 agent: lr: 0.001 gamma: 0.99 epsilon: 1.0 epsilon_min: 0.05 epsilon_decay: 0.995 train: episodes: 50 batch_size: 64 target_update_freq: 10然后跑训练入口:
python train.py --data data/processed/train.csv --config config.yaml第一次训练别指望指标完美。我判断训练是否正常启动,重点看三件事:episode 内累计奖励有没有整体向上的趋势;epsilon 是否在按预期衰减;训练日志里有没有出现 NaN。只要这三件事正常,说明环境封装、数据读取、智能体更新环路是通的。后面再花时间调优,千万不要一开始就纠结检测率数字。
4. 核心实现:环境封装、DQN 智能体与训练主循环
4.1 把流量样本包装成 gymnasium 环境
整个项目的核心是环境封装。强化学习环境要回答三个问题:当前状态是什么?下一步能做什么?做了之后获得什么反馈?下面的代码是我自定义的 NIDSEnv,把每条样本当作一个时间步的状态:
# env/nids_env.py import numpy as np import gymnasium as gym from gymnasium import spaces class NIDSEnv(gym.Env): """ 网络入侵检测环境。 每一步暴露一条样本的特征,智能体决策是否发出告警。 """ def __init__(self, features, labels, reward_weights=None): super().__init__() self.features = np.array(features, dtype=np.float32) self.labels = np.array(labels, dtype=np.int64) self.reward_weights = reward_weights or { "tp": 1.0, "fp": -0.5, "fn": -2.0, "tn": 0.1 } # 状态就是单条样本的特征向量,观测空间是连续值 self.observation_space = spaces.Box( low=-np.inf, high=np.inf, shape=(self.features.shape[1],) ) # 动作 0 表示放行,1 表示告警 self.action_space = spaces.Discrete(2) self._index = 0 def reset(self, *, seed=None, options=None): super().reset(seed=seed) self._index = 0 return self.features[self._index], {} def step(self, action): true_label = self.labels[self._index] w = self.reward_weights if action == 1 and true_label == 1: reward = w["tp"] # 正确告警 elif action == 1 and true_label == 0: reward = w["fp"] # 误报 elif action == 0 and true_label == 1: reward = w["fn"] # 漏报 else: reward = w["tn"] # 正确放行 self._index += 1 terminated = self._index >= len(self.features) - 1 truncated = False if terminated: # 环境终止时返回零向量占位,保证 API 一致 next_obs = np.zeros_like(self.features[0]) else: next_obs = self.features[self._index] return next_obs, reward, terminated, truncated, {}这里要特别说明奖励的设计逻辑。我把漏报的惩罚设成 -2.0,误报是 -0.5,因为在实际安全运营里,一条漏掉的攻击可能造成几小时甚至几天的横向移动,而一次误报只是让安全员多点一次“忽略”。正确放行给 +0.1,是为了让智能体在大量正常流量的场景里不至于因为“什么都不做没有收益”而倾向于随意告警。这个权重矩阵是第一个要调的超参数,后面会说它带来的典型问题。
4.2 DQN 网络结构与经验回放
环境就绪后,接下来是 DQN 智能体。网络结构不需要复杂,三层全连接加 ReLU 就够处理特征向量类型的状态表示。经验回放缓冲区的作用是打乱样本间的时间相关性,让神经网络更新满足独立同分布的假设:
# agent/dqn_agent.py import random from collections import deque import torch import torch.nn as nn import torch.optim as optim import numpy as np class QNet(nn.Module): def __init__(self, input_dim, output_dim, hidden_dim=128): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, output_dim) ) def forward(self, x): return self.net(x) class DQNAgent: def __init__(self, input_dim, output_dim, lr=1e-3, gamma=0.99, epsilon=1.0, epsilon_min=0.05, epsilon_decay=0.995): self.q_net = QNet(input_dim, output_dim) self.target_net = QNet(input_dim, output_dim) self.target_net.load_state_dict(self.q_net.state_dict()) self.optimizer = optim.Adam(self.q_net.parameters(), lr=lr) self.gamma = gamma self.epsilon = epsilon self.epsilon_min = epsilon_min self.epsilon_decay = epsilon_decay self.replay_buffer = deque(maxlen=20000) def act(self, state, eval_mode=False): # 训练阶段 epsilon-greedy 探索,评估阶段直接取 argmax if not eval_mode and np.random.rand() < self.epsilon: return np.random.randint(0, 2) with torch.no_grad(): q_values = self.q_net(torch.FloatTensor(state).unsqueeze(0)) return int(q_values.argmax(dim=1).item()) def remember(self, state, action, reward, next_state, done): self.replay_buffer.append((state, action, reward, next_state, done)) def update(self, batch_size=64): if len(self.replay_buffer) < batch_size: return 0.0 batch = random.sample(self.replay_buffer, batch_size) states = torch.FloatTensor([x[0] for x in batch]) actions = torch.LongTensor([x[1] for x in batch]).unsqueeze(1) rewards = torch.FloatTensor([x[2] for x in batch]) next_states = torch.FloatTensor([x[3] for x in batch]) dones = torch.BoolTensor([x[4] for x in batch]) # 当前 Q 值 q_pred = self.q_net(states).gather(1, actions).squeeze(1) # 目标 Q 值用 target_net 计算,避免自举造成的震荡 q_next = self.target_net(next_states).max(dim=1).values.detach() q_target = rewards + self.gamma * q_next * (~dones) loss = nn.MSELoss()(q_pred, q_target) self.optimizer.zero_grad() loss.backward() self.optimizer.step() return loss.item() def update_target(self): self.target_net.load_state_dict(self.q_net.state_dict()) def decay_epsilon(self): self.epsilon = max(self.epsilon * self.epsilon_decay, self.epsilon_min)target_net 的引入是 DQN 训练稳定的关键。它不参与梯度更新,只负责计算目标 Q 值,每隔一定步数把 q_net 的参数复制过去。如果不这样做,模型每次更新都用自己刚算出来的值当目标,Q 值会陷入正反馈发散,Loss 越跑越高。
4.3 训练主循环与关键超参数说明
训练主循环的骨架比较固定:每个 episode 重置环境、按 epsilon-greedy 选动作、存经验、批量更新网络、周期更新目标网络。下面是 train.py 的核心逻辑:
# train.py 主循环简化版 def train(env, agent, episodes=50, batch_size=64, target_update_freq=10): for ep in range(episodes): obs, _ = env.reset() total_reward = 0.0 done = False step_count = 0 while not done: action = agent.act(obs) next_obs, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated agent.remember(obs, action, reward, next_obs, done) loss = agent.update(batch_size) obs = next_obs total_reward += reward step_count += 1 if step_count % target_update_freq == 0: agent.update_target() agent.decay_epsilon() print(f"episode={ep}, reward={total_reward:.2f}, " f"epsilon={agent.epsilon:.3f}, loss={loss:.4f}")几个关键超参数的作用和调整方向值得记住:
| 参数 | 作用 | 起步值 | 调优方向 |
|---|---|---|---|
| lr | 控制 Q 网络参数更新步长 | 0.001 | 不收敛时降到 0.0003 |
| gamma | 折扣因子,决定远期奖励的重要程度 | 0.99 | 告警时效性强可降到 0.95 |
| epsilon_decay | 探索率衰减速度 | 0.995 | 衰减太快会过早进入纯利用,错过攻击模式 |
| replay_buffer 容量 | 经验池大小 | 20000 | 样本类别不均时加大到 50000 |
| batch_size | 每次梯度更新使用的样本数 | 64 | 训练不稳时加大到 128 |
| target_update_freq | 目标网络同步频率 | 10 | 波动大时可以降低到 20 |
如果项目代码里用的是 Double DQN,改动点只在 update 函数:用 q_net 选动作、用 target_net 算 Q 值,其余部分完全相同。建议把 DQN 跑通后再切 Double DQN 做对比,你会发现 Q 值过估计带来的误报问题有明显改善。
5. 入坑排查:检测率上不去的 5 个典型问题
5.1 智能体学会了“全放行”:奖励函数被钻了空子
现象:训练日志里累计奖励稳步上升,但测试集上检测率接近 0,误报也很少。看起来训练“成功”了,实际上智能体找到了最偷懒的策略——所有流量都放行。
原因:数据集里正常样本占比很高时,全放行的期望奖励未必是负的。比如正常样本占 90%,漏报惩罚是 -2,正确放行是 +0.1,那么全放行每个样本的期望奖励是 0.9×0.1 + 0.1×(-2) = -0.11。如果漏报惩罚不够重,这个负数可能比乱告警造成的期望损失还“划算”,智能体就学不到告警行为。
解决:把漏报惩罚调大。我一般会把 fn 压到 -5 以下,必要时按类别比例加权。调完奖励矩阵后最关键的是别只看训练奖励,要在测试集上算 F1。只盯训练 reward 会把这个“全放行”策略误判成好模型。
5.2 训练 Loss 像玄学一样震荡:学习率与 epsilon 衰减不匹配
现象:Loss 忽高忽低,reward 曲线大幅摆动,甚至出现 Q 值输出到几万的情况。训练跑了二三十个 episode,模型好像失忆了一样。
原因:学习率太大导致参数更新幅度不稳定;epsilon 衰减太快,模型过早放弃探索,陷入局部最优;target_net 更新太频繁,目标值本身在剧烈变化。这三条可以独立导致震荡,更多时候是叠加作用。
解决:先换一组保守的参数。我的起步组合是 lr=0.0003、epsilon_decay=0.998、target_update_freq=20、batch_size=128。这组参数牺牲了收敛速度,换来的是一眼看得到的稳定曲线。如果还想再稳一点,就上 Double DQN,Q 值过估计带来的正向偏差能被明显压住。调参这一步最忌讳一次动好几个参数,改一个、跑一轮、看一个,否则出了问题说不清是谁引起的。
5.3 少数类攻击检测率趋近于零:类别不均衡问题
现象:测试集里多数攻击类别(比如 DoS)检测率尚可,但少数类攻击(比如 U2R 这类样本量极少的攻击)几乎全部漏检。训练时的平均奖励还挺正常。
原因:少数类样本在经验回放里出现的频次太少,每次采样到它们的概率接近零,神经网络根本没有机会针对这类样本做有效的梯度更新。强化学习的奖励函数是按样本平均的,少数类贡献的梯度被多数类淹没。
解决:最直接的做法是在预处理阶段对少数类做上采样,复制少数类样本让它们在批次里占据合理比例。另外在构造批次时可以按标签分层采样,保证每个 batch 里都有少数类。用专业一点的方案还可以上优先经验回放(PER),把那些让智能体“猜错”的样本放在更大概率被采样到的位置。验证时务必用 F1 而不是 accuracy,accuracy 在不均衡数据上会给出虚高假象。
5.4 灾难性遗忘:训练后期把早期学会的攻击又忘了
现象:训练到中间某个 episode 时检测率不错,继续训练后反而下降,尤其是一开始学到的那几类攻击又开始漏报。感觉模型学会了后面的,把前面的忘了。
原因:网络参数在持续更新,新学到的策略会覆盖旧策略。如果把整份训练数据当流一样反复过,每个 episode 的采样顺序不同,模型容易被最近批次的数据带偏。经验回放虽然缓解了时序相关性,但缓冲区容量有限,早期样本不断被弹出。
解决:两个手段配合使用。一个是扩大回放缓冲区到 50000 以上,让早期经验在更长时间内保持存在感;另一个是训练过程中定期保存 checkpoint,并在固定的验证集上评估,最后选择验证指标最好的 checkpoint 而不是最后一个。工程上这一条极其重要,很多最终效果差的项目都是直接拿最后一个模型上线的。
5.5 测试集指标好看、真实流量翻车:特征分布漂移
现象:在测试集上 F1 有 0.92,但放到真实网络环境里跑,误报陡增,正常业务流量被大量告警。明明是同一个模型,换个场景就崩。
原因:两个层面的问题叠加。第一,预处理时 scaler 用的是训练集的均值和方差,真实流量的特征分布比训练集宽,标准化后数值落到了模型没见过的范围;第二,强化学习训练时环境暴露的是数据集里的样本,这些样本是离线抓取的、相对“干净”,真实流量里的噪声和冗余字段会干扰状态表示。
解决:推理阶段必须保存并使用训练时的 scaler,不能重新 fit。在数据链路层面给推理特征做范围截断,超出训练分布的部分压缩到边界值。更实际的做法是在推理端做二次确认,比如叠加滑动窗口规则,单条告警不直接触发,连续多条告警才真正发出,这样能把偶发性的分布漂移噪声过滤掉。真实流量环境里的长期稳定运行,要靠定期用新数据重训模型来维持,这不是一次训练能解决的。
6. 把模型从离线搬到在线:两个验证门槛和一个降低误报的技巧
训练脚本跑通只是开始,真正上线前我有两个强制门槛。第一个是复现验证:固定随机种子、固定推理模式、固定数据顺序,重复三次评估,指标波动超过一个百分点就要排查。实现上就是把下面这段放在评估脚本最前面:
# evaluate.py 开头固定随机种子 import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) set_seed()第二个门槛是评估指标要用混淆矩阵说话,不能拿训练时的 reward 当业绩。训练奖励反映的是“智能体有没有学会获取奖励的技巧”,不代表检测能力。我一般会输出一列测试集混淆矩阵,算好 precision、recall、F1,再对比一个 Random 策略和全放行策略的基线。如果 DQN 连这两个 trivial baseline 都打不过,说明奖励函数设计有问题,不值得花时间上线。
在线推理阶段,我最常用也最推荐的一个技巧是滑动窗口二次确认。单个样本的告警决策波动太大,真实网络里一条正常访问可能因为特征分布边缘触发误报。把 DQN 的原始输出累积到一个窗口里,连续多次判断为攻击才真正发出告警,误报率能降一个量级:
# detector.py:DQN 原始输出到最终告警的滑动窗口 class SlidingWindowDetector: def __init__(self, window=20, threshold=3): self.decisions = [] self.window = window self.threshold = threshold def update(self, dqn_alert: bool) -> bool: self.decisions.append(dqn_alert) if len(self.decisions) > self.window: self.decisions.pop(0) # 窗口内告警次数达到阈值才真正触发 return sum(self.decisions) >= self.thresholdwindow 和 threshold 的比例决定了告警灵敏度和误报的取舍。window=20、threshold=3 意味着攻击行为需要短时间内在多个样本上持续触发,这对扫描和渗透类的多步攻击很有效,但对单包攻击可能不敏感。我在实际使用中会保留两组配置,一组给边界防护设备用,一组给内网流量审计用。
最后说一个我自己栽过的坑:早期我以为强化学习训练出来的模型天然适应在线环境,结果发现真实流量里一个很小的字段空值就能让环境封装报错。后来我养成了一个习惯,每次做完范化推理都先跑一遍空值和异常特征注入测试,确保模型在坏数据下也能退化成“安全侧”的保守判断——宁可漏报一条,也比整个检测进程崩溃强。希望这些能帮你在跑通这个项目时少走几步弯路。
本文还有配套的精品资源,点击获取