简介:这份资源面向网络安全与机器学习方向的学习者与研究人员,围绕IoT23数据集提供一套完整的网络流量异常检测与多分类实验方案,覆盖朴素贝叶斯、决策树、随机森林、逻辑回归与SVM等经典算法的对比实践,适合具备Python基础、希望入门物联网安全或完成课程设计的中级学习者。资源包共215个文件,以164张png可视化图表、31个py脚本、9个xlsx结果表格和8个json评分文件为主,压缩包约12.14MB,图表与脚本相互对应,便于复现实验结论。目前已有1456人学习下载。读者可从中获得从数据预处理、模型训练到多分类评估的完整流程参考,借助脚本与结果表格快速对比各算法表现,并通过可视化图表直观理解流量异常分布,为后续调参与论文写作提供可复用的素材与排错思路。
1. 从一次误报说起:IoT23 流量分类到底在解决什么
凌晨两点,测试环境的告警群炸了——某台摄像头设备在十分钟内触发了 47 次“异常外联”告警。值班同事爬起来查了半天,最后发现只是固件在批量上报心跳包。这类误报在物联网流量分析里太常见了:设备行为模式单一、流量基数小、正常与异常的边界模糊,随便一个阈值规则就能把心跳包判成 C2 通信。
IoT23-network-traffic-anomalies-classification 这个方向,核心就是拿 IoT23 数据集做网络流量的异常检测与分类。IoT23 是一份公开的物联网流量数据集,采集了多种 IoT 设备在正常和受控异常场景下的网络流量,标注了 20 多类攻击行为(如 Mirai、BASHLITE、端口扫描等)以及正常流量。它的价值在于:流量来自真实设备、标注粒度细、类别不均衡程度高——这恰好是工业界做流量分类时最头疼的几个问题的缩影。
这篇文章面向的是想用 IoT23 做异常检测落地的人:不管你是要复现论文基线、搭一个可用的分类 pipeline,还是想搞清楚“为什么我的模型在 IoT23 上 F1 只有 0.6”,下面从数据处理、特征工程、模型选型到排错,我会把踩过的坑和能跑通的路径讲清楚。
2. 把 IoT23 读进内存:数据加载与标签对齐的四个关键决策
2.1 先搞清楚 IoT23 的文件结构再动手
IoT23 原始数据以 pcap 分片形式组织,每个场景对应若干 pcap 文件,标注信息在配套的 CSV 里。常见做法是先看目录结构:
# 查看 IoT23 数据目录的典型组织方式 ls -R ./IoT23-data/ | head -40 # 输出示例(目录名已做泛化处理): # ./IoT23-data/ # CTU-IoT-Malware-Capture-1/ # bro/conn.log.labeled # pcap/ # capture.pcap # CTU-IoT-Malware-Capture-2/ # ...每个 capture 目录下通常有 pcap 原始包和一份conn.log.labeled(或类似命名的连接日志)。连接日志是已经按五元组聚合好的流记录,字段包括时间戳、源 IP、目的 IP、端口、协议、持续时间、字节数、包数以及标签。直接读连接日志比从 pcap 重新做流聚合快得多,除非你需要包级别的特征。
2.2 用 pandas 加载连接日志并统一标签
import pandas as pd import glob import os def load_iot23_conn_logs(root_dir): """ 遍历 IoT23 目录,加载所有 conn.log.labeled 文件 返回合并后的 DataFrame """ records = [] # 匹配所有 capture 目录下的连接日志 pattern = os.path.join(root_dir, "CTU-IoT-Malware-Capture-*", "bro", "conn.log.labeled") for filepath in glob.glob(pattern): # 连接日志通常以 tab 分隔,注释行以 # 开头 df = pd.read_csv( filepath, sep="\t", comment="#", header=None, low_memory=False ) # 不同批次列数可能不一致,这里只取前 21 列(常见字段范围) df = df.iloc[:, :21] records.append(df) if not records: raise FileNotFoundError("未找到任何连接日志文件,请检查路径") combined = pd.concat(records, ignore_index=True) return combined raw = load_iot23_conn_logs("./IoT23-data") print(raw.shape) print(raw.head(3))这段代码做了三件事:用 glob 匹配所有 capture 目录、用comment="#"跳过注释行、用iloc[:, :21]截断列数不一致的问题。参数上,sep="\t"是连接日志的标准分隔符,low_memory=False避免 pandas 在混合类型列上推断出错。如果你的数据里列数差异很大,建议先统计每个文件的列数分布,再决定是截断还是补列。
2.3 标签对齐:别让“正常”被当成“异常”
IoT23 的标签字段在不同版本里命名可能不同,常见的是最后一列包含label和detailed-label两部分,用空格或制表符分隔。需要拆开:
def parse_labels(df): """ 解析最后一列的标签信息,拆分为 label 和 detailed_label """ # 假设最后一列是类似 "Benign -" 或 "Malicious Mirai" last_col = df.columns[-1] split_labels = df[last_col].astype(str).str.split(r"\s+", n=1, expand=True) df["label"] = split_labels[0] df["detailed_label"] = split_labels[1] if split_labels.shape[1] > 1 else "unknown" # 统一大小写,避免 "benign" 和 "Benign" 被当成两类 df["label"] = df["label"].str.strip().str.lower() df["detailed_label"] = df["detailed_label"].str.strip().str.lower() return df raw = parse_labels(raw) print(raw["label"].value_counts()) print(raw["detailed_label"].value_counts().head(10))这里的关键决策是:统一大小写和去除空白。我见过不止一次因为Benign和benign被当成两个类,导致二分类模型直接把“正常”学成了两个簇。另外,detailed_label里的-通常表示无细分,可以统一替换为benign或unknown,取决于你的任务粒度。
2.4 处理类别不均衡:先看分布再决定策略
# 查看类别分布 label_counts = raw["detailed_label"].value_counts() print(label_counts) print(f"类别数: {len(label_counts)}") print(f"最大类占比: {label_counts.max() / label_counts.sum():.2%}")IoT23 的类别不均衡非常严重:正常流量和 Mirai 可能占 80% 以上,而某些攻击子类只有几百条。常见做法有三种:对少数类过采样(SMOTE 或其变体)、对多数类欠采样、或者在损失函数里加类别权重。我一般会先跑一个不加权的基线,看混淆矩阵里少数类是不是全被吞了,再决定用哪种策略。如果少数类样本少于 500 条,SMOTE 在流量特征上容易生成不合理的合成样本,这时候用class_weight='balanced'更稳。
3. 特征工程:从五元组到模型能吃的数值矩阵
3.1 哪些字段直接可用,哪些必须转换
连接日志里的字段大致分三类:数值型(持续时间、字节数、包数)、类别型(协议、连接状态、服务)、标识型(源 IP、目的 IP、端口)。标识型字段直接扔进模型会导致过拟合——模型会记住“这个 IP 出现就是攻击”,换一个网络环境就废了。常见做法是:
- 数值型:做对数变换或分箱,缓解长尾分布。
- 类别型:用 one-hot 或 target encoding,但要注意在训练集上拟合编码器。
- 标识型:可以提取统计特征(如某 IP 在窗口内的出现频次),但不建议直接用原始值。
import numpy as np def build_features(df): """ 从连接日志构建数值特征矩阵 """ feat = pd.DataFrame() # 数值特征:持续时间、源字节数、目的字节数、源包数、目的包数 # 列索引根据实际数据调整,这里假设第 0-4 列是时间戳和五元组 # 实际使用时请先 print(df.columns) 确认 numeric_cols = [4, 5, 6, 7, 8] # 示例索引,需按实际列名替换 for i, col in enumerate(numeric_cols): feat[f"num_{i}"] = pd.to_numeric(df.iloc[:, col], errors="coerce") # 对数变换,加 1 避免 log(0) for c in feat.columns: feat[c] = np.log1p(feat[c].clip(lower=0)) # 类别特征:协议、连接状态 cat_cols = [2, 3] # 示例索引 for i, col in enumerate(cat_cols): dummies = pd.get_dummies(df.iloc[:, col], prefix=f"cat_{i}") feat = pd.concat([feat, dummies], axis=1) # 填充缺失值 feat = feat.fillna(0) return feat X = build_features(raw) print(X.shape)这段代码的逻辑是:先提取数值列做对数变换,再把类别列做 one-hot。参数上,errors="coerce"把无法转换的值变成 NaN,后续用 0 填充;clip(lower=0)防止负值导致 log 出错。注意 one-hot 后维度会膨胀,如果类别取值超过 50 个,建议改用 target encoding 或频率编码。
3.2 时间窗口特征:让模型看到“行为”而不是“单条流”
单条流记录的信息量有限,很多攻击行为体现在一段时间内的流量模式上。常见做法是按源 IP 或目的 IP 做滑动窗口聚合:
def add_window_features(df, window="5min"): """ 按源 IP 做时间窗口聚合,添加统计特征 """ df = df.copy() # 假设第 0 列是时间戳,第 1 列是源 IP df["ts"] = pd.to_datetime(df.iloc[:, 0], unit="s", errors="coerce") df = df.set_index("ts") # 按源 IP 分组后做滚动窗口 grouped = df.groupby(df.iloc[:, 1]) # 窗口内流数量 df["flow_count_5min"] = grouped.rolling(window).size().reset_index(level=0, drop=True) # 窗口内唯一目的 IP 数 df["unique_dst_5min"] = grouped[df.columns[2]].rolling(window).nunique().reset_index(level=0, drop=True) df = df.reset_index(drop=True) return df raw = add_window_features(raw)窗口大小的选择取决于你的检测延迟要求:5 分钟窗口适合离线分析,1 分钟窗口适合近实时。注意rolling在 groupby 后返回的索引需要重置,否则和原 DataFrame 对不齐——这是 pandas 里经典的坑。
3.3 特征选择:别把 200 维特征直接塞给模型
特征维度太高时,树模型还能扛,但线性模型和 KNN 会明显退化。我一般用两种方法快速筛:
from sklearn.ensemble import RandomForestClassifier from sklearn.feature_selection import SelectFromModel # 先用随机森林拟合,取重要性前 50 的特征 rf = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1) rf.fit(X, y) selector = SelectFromModel(rf, max_features=50, threshold=-np.inf) X_selected = selector.transform(X) print(f"原始维度: {X.shape[1]}, 筛选后: {X_selected.shape[1]}")max_features=50是经验值,实际可以根据验证集表现调整。如果筛选后 F1 掉得厉害,说明被扔掉的特征里有交互项,这时候考虑用 PCA 或自编码器做降维而不是硬筛。
4. 模型选型与训练:从基线到能用的分类器
4.1 先跑一个逻辑回归基线,别一上来就上深度学习
很多人在 IoT23 上直接上 LSTM 或 Transformer,结果发现训练慢、调参难、F1 还不如随机森林。我的习惯是:先跑逻辑回归和随机森林两个基线,确认特征工程没问题,再考虑复杂模型。
from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, f1_score from sklearn.preprocessing import StandardScaler # 划分训练集和测试集,按标签分层 X_train, X_test, y_train, y_test = train_test_split( X_selected, y, test_size=0.3, random_state=42, stratify=y ) # 逻辑回归需要标准化 scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) lr = LogisticRegression(max_iter=1000, class_weight="balanced", n_jobs=-1) lr.fit(X_train_scaled, y_train) y_pred_lr = lr.predict(X_test_scaled) print("逻辑回归 F1:", f1_score(y_test, y_pred_lr, average="macro")) rf = RandomForestClassifier(n_estimators=200, class_weight="balanced", random_state=42, n_jobs=-1) rf.fit(X_train, y_train) y_pred_rf = rf.predict(X_test) print("随机森林 F1:", f1_score(y_test, y_pred_rf, average="macro"))class_weight="balanced"让模型自动按类别频率反比加权,对不均衡数据很有效。average="macro"比weighted更能反映少数类的表现——如果 macro F1 远低于 weighted F1,说明少数类被牺牲了。
4.2 用 XGBoost 或 LightGBM 提升效果
树模型在表格型流量特征上通常表现最好。LightGBM 训练快、内存占用低,适合 IoT23 这种几十万到百万级样本的数据集。
import lightgbm as lgb # 将标签编码为整数 from sklearn.preprocessing import LabelEncoder le = LabelEncoder() y_train_enc = le.fit_transform(y_train) y_test_enc = le.transform(y_test) # 处理类别不均衡:计算类别权重 from sklearn.utils.class_weight import compute_class_weight classes = np.unique(y_train_enc) weights = compute_class_weight("balanced", classes=classes, y=y_train_enc) weight_dict = dict(zip(classes, weights)) train_data = lgb.Dataset(X_train, label=y_train_enc, weight=[weight_dict[c] for c in y_train_enc]) valid_data = lgb.Dataset(X_test, label=y_test_enc, reference=train_data) params = { "objective": "multiclass", "num_class": len(classes), "metric": "multi_logloss", "boosting_type": "gbdt", "num_leaves": 63, "learning_rate": 0.05, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 5, "verbose": -1, "seed": 42 } model = lgb.train( params, train_data, num_boost_round=500, valid_sets=[valid_data], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)] ) y_pred_lgb = model.predict(X_test, num_iteration=model.best_iteration) y_pred_lgb_label = np.argmax(y_pred_lgb, axis=1) print("LightGBM F1:", f1_score(y_test_enc, y_pred_lgb_label, average="macro"))参数上,num_leaves=63控制树复杂度,learning_rate=0.05配合early_stopping(50)防止过拟合。feature_fraction和bagging_fraction是 LightGBM 的正则化手段,流量特征噪声大时调低这两个值通常有帮助。weight参数传入类别权重,让少数类在梯度更新中占更大比重。
4.3 深度学习方案:什么时候值得上
如果你有包级别数据、或者需要端到端学习流之间的时序关系,可以考虑 1D-CNN 或 LSTM。但要注意:IoT23 的连接日志特征维度不高,深度学习容易过拟合。我一般只在以下条件同时满足时才上深度学习:样本量超过 50 万、有包序列数据、树模型 F1 已经到瓶颈。
import torch import torch.nn as nn class TrafficCNN(nn.Module): def __init__(self, input_dim, num_classes): super().__init__() self.conv = nn.Sequential( nn.Conv1d(1, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool1d(1) ) self.fc = nn.Linear(64, num_classes) def forward(self, x): # x: (batch, 1, input_dim) x = self.conv(x) x = x.squeeze(-1) return self.fc(x)这个结构把特征向量当成一维序列处理,适合捕捉局部特征组合。训练时用交叉熵损失加类别权重,优化器选 Adam,学习率 1e-3 起步。
5. 避坑与排查:IoT23 分类任务里最容易翻车的五个地方
5.1 标签泄漏:时间戳和 IP 直接进模型
现象:验证集 F1 高达 0.99,上线后掉到 0.5。原因:训练时把时间戳或源 IP 当特征,模型学到了“这个时间段/这个 IP 就是攻击”,而不是流量行为本身。解决:训练前显式 drop 掉时间戳、IP、端口等标识列。如果确实需要时间信息,只保留“小时”“星期几”这类周期特征。
5.2 训练集和测试集按流随机划分导致高估
现象:随机划分下 F1 0.95,按 capture 划分后只有 0.7。原因:同一次攻击的流被分到了训练集和测试集,模型见过类似样本。解决:按 capture 目录或时间做 group split,确保测试集里的攻击类型在训练集中没有完全相同的样本。用GroupShuffleSplit指定 group 列。
5.3 类别权重设了但没生效
现象:少数类召回率依然接近 0。原因:LightGBM 的weight参数需要和label长度一致,且传入的是样本权重列表;如果用了scale_pos_weight但任务是多分类,它不生效。解决:多分类用compute_class_weight("balanced")生成字典,再映射到每个样本。训练后看混淆矩阵确认少数类是否被预测。
5.4 特征标准化只在训练集上拟合
现象:逻辑回归在测试集上表现远差于训练集。原因:用全量数据拟合了 StandardScaler,测试集的均值和方差信息泄漏到了训练过程。解决:fit_transform只在训练集上调用,测试集用transform。树模型不需要标准化,但线性模型和神经网络必须遵守这个规则。
5.5 推理时特征列顺序不一致
现象:离线评估正常,在线推理报错或结果乱跳。原因:训练时用pd.get_dummies生成的列顺序,和推理时新数据生成的列顺序不一致。解决:保存训练时的列名列表,推理前用reindex(columns=train_columns, fill_value=0)对齐。或者用 sklearn 的Pipeline把编码器和模型绑在一起。
6. 进阶技巧:用 SHAP 做特征归因与模型验证
模型跑通之后,下一步是确认它学到的规律是否符合领域知识。SHAP 是我常用的工具,它能给出每个特征对单条预测的贡献值。
import shap # 用 LightGBM 模型做 SHAP 解释 explainer = shap.TreeExplainer(model) # 取测试集前 500 条做解释,避免计算量过大 shap_values = explainer.shap_values(X_test.iloc[:500]) # 对某个攻击类别做特征重要性汇总 # shap_values 是多分类时是列表,每个元素对应一个类别 class_idx = 1 # 假设 1 是某个攻击类 shap.summary_plot(shap_values[class_idx], X_test.iloc[:500], plot_type="bar")如果 SHAP 显示“目的端口”或“字节数”是主要判别特征,这和领域知识一致;如果显示某个 one-hot 后的稀疏列权重异常高,可能是过拟合信号。我一般会对比正常类和攻击类的 SHAP 值分布,如果两者在某个特征上完全重叠,说明这个特征没有区分度,可以考虑剔除。
另一个实用技巧是用对抗验证检测分布偏移:把训练集和测试集混在一起,训练一个二分类器区分它们。如果 AUC 明显高于 0.5,说明两个集合分布不一致,模型在测试集上的表现不可信。这个方法在 IoT23 上尤其有用,因为不同 capture 批次的流量特征可能有差异。
from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import cross_val_score # 对抗验证:训练集标 0,测试集标 1 adv_X = pd.concat([X_train, X_test], axis=0) adv_y = np.array([0] * len(X_train) + [1] * len(X_test)) adv_clf = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1) auc = cross_val_score(adv_clf, adv_X, adv_y, cv=3, scoring="roc_auc").mean() print(f"对抗验证 AUC: {auc:.3f}") # 如果 AUC > 0.7,说明训练集和测试集分布差异大,需要检查划分方式最后说一个我自己的习惯:每次跑完模型,先把混淆矩阵里“正常被判成攻击”和“攻击被判成正常”的样本各抽 20 条出来,逐条看原始流记录。很多时候模型犯的错,人一眼就能看出原因——比如某个攻击的流持续时间特别短,而特征里没有“每秒包数”这个维度。这种人工检查比调参更能找到改进方向。希望帮到你。
本文还有配套的精品资源,点击获取