简介:面向电力系统网络安全方向科研人员与研究生的一份技术文档,聚焦网络入侵检测中的多源数据融合问题。内容围绕多传感器融合思路展开,串联数据预处理、特征提取、融合策略设计与入侵检测模型构建等环节,并以Python代码实现辅助理解异构数据协同识别异常行为的过程,适合具备一定Python基础、关注工业控制系统与物联网安全的读者用于算法复现和方案参考。
压缩包共1个文件,为docx文档,约77KB,篇幅精简,便于按模块查阅融合逻辑与模型设计要点,也方便在仿真环境或实际数据集中对照调优。已有32人学习,显示该主题在电力安全与多源感知交叉领域具有一定关注度。
读者可获得从多源异构数据到入侵判别结果的完整实现思路,理解融合算法如何提升检测精度与响应效率,并据此扩展实验设计、复现流程或优化融合策略,为关键基础设施安全监控研究提供参考。
1. 单源告警为什么压不住误报:一份面向电力监控网的多源数据融合 Python 包
在电力监控网里做过告警收敛的同行大概都有类似体会:只盯网络流量做入侵检测,阈值定得松就天天误报,定得紧就漏掉真实攻击;只盯 SCADA 遥测,又分不清是设备故障还是人为注入的异常指令。这份资源包的切入点就是解决这个问题——把多传感器融合的思路搬进网络安全领域,用 Python 实现电力系统场景下的多源数据融合,把网络流量、遥测量测、主机审计日志这几类异构数据对齐后送进融合检测层,提升异常行为识别的精度和响应效率。它适合有 Python 基础、懂一点机器学习、正在做工业控制系统安全或关键基础设施监控方向的人,也适合想把数据融合从论文公式落到可跑代码的研究生。下面按「数据怎么接、融合怎么选、模型怎么调、坑在哪」拆开讲。
2. 多源数据接入与对齐:把流量、遥测、审计日志拼成一张宽表的四步
多源数据融合最容易翻车的地方不是模型,是数据进来那一公里。电力系统的数据源天然不同步:网络侧是交换机镜像出来的流记录,秒级甚至亚秒级;SCADA 侧是 polling 采集的遥测点,常见 1 秒到 5 秒一次;主机侧是审计日志,事件驱动,稀疏且时间戳精度参差。这三类数据如果直接按行号拼接,得到的训练集在时间维度上是错位的,模型学到的所谓"融合特征"其实是随机对齐的噪声。资源包的做法是老老实实走四步:定字段口径、统一时区、容差对齐、滑窗聚合。
2.1 数据源分层与字段口径约定
先把三类源的角色分清楚,这决定了后面特征怎么用。网络流记录提供的是通信行为特征,比如单位时间连接数、目的端口熵、包长分布、TCP 标志位组合;遥测量测提供的是物理侧状态,比如电压、电流、有功无功、开关位置;审计日志提供的是主机侧上下文,比如登录失败次数、提权操作、进程创建。三类数据在检测里的价值不一样:流量是主证据,遥测是交叉验证证据,日志是补充上下文。
字段口径要提前约定好,不然融合的时候命名冲突会让你怀疑人生。常见做法是给每个源的字段加前缀,flow_、tele_、log_,并且统一主键名称为ts。资源包里通常会有一个config.yaml或schema.py描述每个字段的类型、单位、是否参与融合,改数据源的时候只改这一处,不用翻训练脚本。
import pandas as pd import numpy as np def load_sources(base_dir="data"): """加载三类数据源,统一时间戳为 UTC 并排序""" flow = pd.read_csv(f"{base_dir}/flow_agg.csv", parse_dates=["ts"]) tele = pd.read_csv(f"{base_dir}/scada_telemetry.csv", parse_dates=["ts"]) log = pd.read_csv(f"{base_dir}/host_audit.csv", parse_dates=["ts"]) # 关键一步:全部转 UTC。混用本地时间和 UTC 会造成整段数据的假错位 for df in (flow, tele, log): df["ts"] = pd.to_datetime(df["ts"], utc=True) # merge_asof 要求参与对齐的表按 key 升序,先排好再进下一步 return (flow.sort_values("ts").reset_index(drop=True), tele.sort_values("ts").reset_index(drop=True), log.sort_values("ts").reset_index(drop=True))这段代码只做两件事:解析时间列、统一时区并排序。parse_dates指定哪一列是时间列,utc=True是硬性要求——只要有一个源用了本地时间且跨了夏令时切换点,后面merge_asof的对齐结果就会整体偏一小时,而这种错误在指标上看不出来,只会表现为"某些时段特征明显异常",属于典型的玄学问题。排序则是因为merge_asof内部依赖有序索引,表乱序时它不报错,但结果不可靠。
2.2 时间戳对齐与最近邻匹配的容差设置
对齐策略要按源的语义来定,不能一刀切。遥测是周期性采样,取"最近的采样点"合理,所以用direction="nearest";审计日志是事件流,只能取"事件发生之前最近的一条",用direction="backward",用 forward 就等于把未来事件泄漏给了当前窗口。
容差tolerance是最需要调参的地方。设太小,大量样本匹配不上变成 NaN;设太大,会把不相关的事件硬拉进来,等于人为制造相关性。经验值:遥测容差取采样周期的 2 倍左右,日志容差取滑窗长度的 1/2 左右。
def align_sources(flow, tele, log, tele_tol="2s", log_tol="30s"): """以流量窗口为基准,向左对齐遥测与审计日志""" merged = pd.merge_asof( flow, tele, on="ts", direction="nearest", tolerance=pd.Timedelta(tele_tol), suffixes=("", "_tele") ) merged = pd.merge_asof( merged, log, on="ts", direction="backward", tolerance=pd.Timedelta(log_tol), suffixes=("", "_log") ) # 对齐率是必须打印的健康指标,低于 70% 说明容差或时钟有问题 rate = merged.notna().mean() print("对齐率:\n", rate[rate < 1.0]) return mergedsuffixes用于处理同名列冲突,比如三个源都有bytes字段。最后那几行打印对齐率是我强烈建议保留的——它把"数据接得好不好"从感觉变成了可观测数字。实测中对齐率掉到 70% 以下,八成不是容差问题,而是某一侧采集端时钟没同步,或者时区标识根本没写。
2.3 缺失值处理:区分「没采到」和「本来就没有」
融合后的 NaN 分两种语义,处理方式完全相反。遥测缺失通常意味着采集通道故障或者设备离线,这个信息本身有检测价值,应该编码成一个tele_missing标志位,再对数值做前向填充;日志缺失往往意味着这段时间没有审计事件,属于"本来就没有",填 0 比填均值合理,填均值会伪造出一个"平均活跃度",把攻击窗口的正常基线抬高。
def fill_missing(df): """按语义分别处理缺失:遥测置标志+前向填充,日志填 0""" tele_cols = [c for c in df.columns if c.startswith("tele_")] log_cols = [c for c in df.columns if c.startswith("log_")] # 先落标志位,再填值;顺序反了标志位就永远为 0 df["tele_missing"] = df[tele_cols].isna().any(axis=1).astype(int) df[tele_cols] = df[tele_cols].ffill(limit=5) # 最多前向填 5 个窗口 df[log_cols] = df[log_cols].fillna(0) return dfffill(limit=5)的limit是止损阀:连续缺失超过 5 个窗口就不再前向填充,宁可留 NaN 让下游模型自己处理,也不要把一个过期很久的旧值一路带下去。很多复现失败就败在这一步——无限前向填充会让某个卡死的遥测点变成一条完美水平的直线,模型很容易把它当成强特征,线下指标漂亮,上线后设备一停采就集体误报。
2.4 滑窗聚合与未来信息泄漏
原始数据是点,检测模型吃的是窗口。滑窗聚合时最容易埋的雷是窗口边界。resample的closed和label参数默认值在不同场景下含义不同,如果窗口右边界包含进了当前时刻之后的数据,就等于给模型开了后门,训练指标虚高,上线立刻打回原形。
def window_features(df, win="60s"): """按 60 秒窗口聚合数值特征,窗口左闭右开,标签取左边界""" num = df.set_index("ts").select_dtypes("number") agg = num.resample(win, closed="left", label="left").agg(["mean", "std", "max"]) agg.columns = [f"{a}_{b}" for a, b in agg.columns] # std 在单点窗口里为 NaN,这里填 0 而不是丢样本 return agg.fillna(0.0)closed="left"保证窗口是[t, t+win),label="left"让这一行的标签落在窗口起点,两者配合才能保证特征和标签严格同时刻对齐。std在窗口内只有一个采样点时为 NaN,这里选择填 0 而不是丢行,因为丢行会让攻击样本(往往伴随采集异常)被系统性剔除,直接把召回率做没了。窗口长度win的取值要看攻击类型:扫描类攻击适合短窗(30 到 60 秒),指令注入类攻击适合长窗(180 到 300 秒),两种窗口同时跑再拼起来,比死磕单一窗口稳。
3. 融合层级怎么选:特征级拼接与决策级投票的参数边界
多源数据融合在文献里分层很多,落到能跑的代码上其实就两条路:特征级融合(early fusion)把各源特征拼成一个大向量喂给单模型;决策级融合(late fusion)让每个源各自训练一个基模型,再把输出概率合成。资源包里两套都实现了,不是让你二选一炫技,而是因为它们在数据条件不同的场景下表现差异很大。
3.1 特征级融合:标准化、拼接与维度膨胀控制
特征级融合的优点是模型能自己学跨源交互,比如"流量突增且遥测电压跌落"这种组合模式,单源模型永远看不到。缺点是维度膨胀和量纲污染:网络侧的字节计数动辄上百万,遥测的标幺值在 0 到 1 之间,直接拼接后基于距离或梯度的模型会被大数值字段完全支配。
from sklearn.preprocessing import RobustScaler from scipy.sparse import hstack def build_feature_fusion(df, groups): """按源分组做鲁棒标准化后横向拼接,返回稀疏矩阵""" blocks, names = [], [] for g, cols in groups.items(): cols = [c for c in cols if c in df.columns] if not cols: continue # 用 RobustScaler:中位数和四分位距对遥测里的尖峰不敏感 scaled = RobustScaler(quantile_range=(5, 95)).fit_transform(df[cols]) blocks.append(scaled) names += [f"{g}__{c}" for c in cols] return hstack(blocks).tocsr(), namesRobustScaler而不是StandardScaler是有理由的:遥测和流量里都充满尖峰,均值和标准差会被极端值拉偏,标准化后正常样本被压成一条窄带,模型反而分不开。quantile_range=(5, 95)把最两端的 5% 截掉,等于内置了一次温和的异常裁剪。分组标准化而不是全局标准化,是为了防止某个源的量纲通过共享统计量污染其他源。
维度控制上,我的习惯是每个源先做一轮方差过滤和相关性去重,把同源内相关系数高于 0.95 的字段砍掉一个,再做拼接。三源拼接后维度控制在 60 到 120 之间比较健康,超过 200 维且样本量不到一万的话,特征级融合基本必然过拟合。
3.2 决策级融合:加权投票与证据合成的实现
决策级融合的假设是各源独立提供证据。它的好处是容错——某一路数据源临时不可用时,可以把它权重置零继续跑,不会整个模型崩掉。实现上有两种粒度,加权投票简单直接,D-S 证据理论适合处理"某一路自己也拿不准"的情况。
import numpy as np class WeightedVote: """决策级融合:按验证集表现给每个基模型分配权重""" def __init__(self, models, weights=None): self.models = models self.weights = weights def fit_weights(self, Xs, y): """用验证集召回率做权重,且做归一化防止某一路独大""" scores = np.array([ recall_score(y, m.predict(Xs[i]), zero_division=0) for i, m in enumerate(self.models) ]) w = scores / scores.sum() # 权重下限 0.1,避免弱源被彻底归零后失去容错能力 self.weights = np.clip(w, 0.1, None) self.weights /= self.weights.sum() return self def predict_proba(self, Xs): return sum(w * m.predict_proba(X)[:, 1] for w, m, X in zip(self.weights, self.models, Xs))权重用召回率而不是准确率来分配,是因为入侵检测里攻击样本占比低,准确率对所有模型都接近 1,完全区分不开。np.clip(w, 0.1, None)是给弱源保底——某一路在当前数据上表现差,不代表它在特定攻击类型上没用,直接归零会让系统对该类攻击彻底失明。
D-S 证据理论的简化实现如下,只处理二分类的两个证据源,够用且不容易写错:
def dempster_combine(m1, m2, eps=1e-6): """两个二分类证据的 Dempster 组合,m 为 (异常, 正常) 的信任度""" a1, n1 = m1 a2, n2 = m2 conflict = a1 * n2 + n1 * a2 # 冲突系数 K if conflict > 0.9: return m1 # 冲突过大时退回单源,别硬合成 denom = 1 - conflict + eps return ((a1 * a2) / denom, (n1 * n2) / denom)conflict这个冲突系数非常关键。两个源意见严重对立时(一个说 0.95 是攻击,一个说 0.95 是正常),Dempster 规则会给出反直觉的结果,这是该方法的经典缺陷。工程上的处理不是改公式,而是加个阈值直接退回保守策略——冲突超过 0.9 就取更保守的那个源,宁可多报一次让人工确认。
3.3 两条路线的取舍:什么数据条件选什么融合层
| 维度 | 特征级融合 | 决策级融合 |
|---|---|---|
| 样本量需求 | 大,万级起步 | 小,千级可用 |
| 跨源交互建模 | 能自动学到 | 学不到,靠人设计 |
| 单源失效容忍 | 差,NaN 直接进模型 | 好,权重置零即可 |
| 可解释性 | 弱,特征重要性混杂 | 强,能说清哪一路报警 |
| 调参成本 | 高,维度一多就难收敛 | 低,各源独立调 |
| 典型适用 | 三源同步率高、样本充足 | 源稀疏、采样率差异大 |
选择逻辑不复杂:你的对齐率能稳定在 85% 以上、样本量过万,就走特征级,让模型自己找交互;对齐率不稳、某个源经常断采、或者需要向运维解释"为什么报这一条",就走决策级。真实项目里我一般两个都跑一遍,用同一套时间切分评估,差距在 2 个点以内就用决策级,因为它的运维成本低得多。
4. 检测模型训练与评估:召回率、误报率和阈值的三角拉扯
模型这一层反而是整条链路里最标准化的部分。电网入侵检测的公开数据大多是连接记录型,随机森林和梯度提升这类树模型在这个特征空间里表现稳定,深度模型不是不能上,但在样本量几千到几万的量级上很难拉开差距,还搭进去一堆调参时间。
4.1 基学习器训练与时间序列切分
切分方式是这一节唯一值得强调的事。按时间切,不要随机切。
from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import TimeSeriesSplit def train_base(X, y, n_splits=5): """时间序列交叉验证训练基学习器,禁止随机打乱""" tscv = TimeSeriesSplit(n_splits=n_splits) models, scores = [], [] for tr, va in tscv.split(X): clf = RandomForestClassifier( n_estimators=300, max_depth=12, min_samples_leaf=5, class_weight="balanced_subsample", n_jobs=-1, random_state=42, ) clf.fit(X[tr], y[tr]) models.append(clf) scores.append(recall_score(y[va], clf.predict(X[va]), zero_division=0)) print(f"各折召回:{[round(s, 3) for s in scores]}") return modelsTimeSeriesSplit保证训练集永远在验证集之前,这是防泄漏的底线。class_weight="balanced_subsample"在每棵树的 bootstrap 样本内重新计算权重,比全局balanced更适合不平衡数据。max_depth=12和min_samples_leaf=5是两个防过拟合的手刹——电力监控数据的噪声比例高,树长得太深会把采集抖动学成攻击模式。random_state=42固定住,不然每次跑出来的指标都有小幅波动,评审的时候很难解释。
4.2 指标口径:准确率 99% 为什么不能交付
入侵检测的样本分布通常是几十比一甚至几百比一,这种情况下准确率几乎没有信息量。全判正常就能拿到 98% 以上的准确率,但召回是 0。
def security_metrics(y_true, y_pred): """面向运维的四个指标:召回、精确率、误报率、单位时间误报数""" tp = ((y_true == 1) & (y_pred == 1)).sum() fn = ((y_true == 1) & (y_pred == 0)).sum() fp = ((y_true == 0) & (y_pred == 1)).sum() tn = ((y_true == 0) & (y_pred == 0)).sum() recall = tp / max(tp + fn, 1) # 漏报的直接反面 precision = tp / max(tp + fp, 1) far = fp / max(fp + tn, 1) # 误报率:正常被判成攻击的比例 return {"recall": recall, "precision": precision, "far": far}far是真正对运维有意义的指标。它回答的是"每处理一千条正常流量,会误报几条"。这个数超过 1% 在实际系统里就基本不可用了,因为监控链路一天的正常事件量很大,1% 意味着成百上千条待确认告警,值班人员很快就会开始无脑关单,检测系统形同虚设。谈指标的时候我一般直接报召回和far这一对,比 F1 更能对齐双方预期。
4.3 类别不平衡与阈值搜索
模型输出的概率阈值不要用默认 0.5。0.5 是二分类的数学中点,不是业务最优点。
import numpy as np def search_threshold(y_true, proba, far_budget=0.01): """在误报率预算内,找召回最高的阈值""" best_t, best_r = 1.0, 0.0 for t in np.arange(0.05, 0.96, 0.01): pred = (proba >= t).astype(int) m = security_metrics(y_true, pred) if m["far"] <= far_budget and m["recall"] > best_r: best_t, best_r = t, m["recall"] return best_t, best_r这个函数把阈值选择变成了一个带约束的优化问题:误报率预算是硬约束(far_budget,通常取 0.5% 到 1%),召回是优化目标。这样选出来的阈值可能低到 0.2,意味着模型只要有一点怀疑就报警,看起来激进,但在有误报预算兜底的前提下,这是挽回召回的正确做法。反过来如果预算给到 0.5%,阈值可能升到 0.7 以上,那就得接受一部分漏报,这个取舍必须由业务方拍板,不能由算法工程师默认。
阈值一定要在验证集上搜,然后在独立的测试时间段上验证。用同一段数据既搜阈值又报指标,得到的结果至少虚高 5 个点。
5. 避坑与排查:五种跑得通但结论不可信的情况
这一节是我在实际复现和给别人看代码时踩过的坑,症状都是"脚本没报错、指标还挺好看",但结论根本站不住。
5.1 线上误报爆炸,线下指标 0.99
现象:训练和验证指标都很漂亮,接进测试环境后一天几千条告警,人工核对几乎全是误报。
原因:按随机方式切分了训练集和测试集。同一次攻击持续几十秒,滑窗后会产生几十个高度相似的样本,随机切分把这些样本同时分到了训练和测试两侧,模型实质上是在背答案。这是最经典的血泪经验,几乎每个做时序检测的人都栽过一次。
解决:改成TimeSeriesSplit或按固定时间点硬切,训练集永远早于测试集,中间留一段 gap(比如 10 分钟)避免窗口重叠。改完指标通常会掉 5 到 15 个点,那才是真实水平。
5.2 对齐后大片 NaN,融合层拿不到数据
现象:merge_asof之后遥测列超过一半是 NaN,模型只能靠流量特征,融合等于没做。
原因:三类数据源的时钟没有统一基准,或者某一侧采集端的时区标识缺失被当成 UTC 解析,导致整体偏移若干小时。这类问题不会抛异常,只会让对齐率悄悄掉下去。
解决:先算出对齐率,低于 70% 就停下来查。做法是取同一时段的三源数据,各自画一条事件计数曲线,肉眼比对峰值位置;如果整体错位且偏移量稳定,就加一个固定 offset 补偿;如果偏移量随时间漂移,那是时钟同步问题,得从采集侧解决,代码层补不了。
5.3 融合之后指标反而比单源差
现象:三源特征拼一起训练,召回和误报率都不如只用网络流量。
原因:量纲没统一,某个数量级大的源在树模型的分裂点上抢占了主导;或者某一路源本身的信噪比极低,进去纯粹是噪声。多源不等于更好,弱源会拖累强源。
解决:先做单源基线,记录每个源单独跑的成绩;融合后如果下降,逐个做消融,把明显拖后腿的源踢掉或者只保留它的少数几个高价值特征。另外把标准化从全局改成按源分组,这一步能解决大部分量纲问题。
5.4 召回为 0,模型全判正常
现象:混淆矩阵里预测列全是 0,召回率 0,但准确率依然很高。
原因:类别不平衡没处理,加上默认阈值 0.5。攻击样本占比不到 2% 时,模型把所有样本判成正常就是损失最小的解。这不是模型坏了,是优化目标错了。
解决:三件事一起做——class_weight打开,阈值改用带误报预算的搜索,评估指标换成召回和误报率。重采样(过采样/合成少数类)要谨慎,它在时序数据上很容易破坏时间结构,我的做法是优先调权重和阈值,实在不行再考虑。
5.5 同一份代码两次结果对不上
现象:别人复现你的结果,指标差好几个点,代码看起来一模一样。
原因:随机种子没固定、滑窗的closed参数在版本间行为差异、依赖库版本不同(尤其是 pandas 的时间处理)。还有一种隐蔽情况是数据加载时用了sort_values但没reset_index,后续按位置索引取值时顺序错乱。
解决:脚本开头统一设种子,所有涉及时间的操作显式写出closed和label参数不依赖默认值,把依赖版本写进 requirements。跑完打印一次数据形状和标签分布,两次运行的这几个数字必须完全一致,不一致就说明还有随机性没锁住。
提示:这五条里,第 1 条和第 3 条的杀伤力最大,因为它们不会让脚本报错,只会让你带着错误的结论往下走很久。
6. 从离线到流式:把融合检测接进监控链路的验证清单
离线指标再漂亮,接进链路才算数。资源包里除了训练脚本,还有一份流式推理的最小骨架,核心是把滑窗从"整表 resample"换成"增量维护的环形缓冲"。这里最容易忽略的是状态管理:离线时每个窗口独立计算,流式时窗口之间有重叠,如果缓冲区清理逻辑写错,特征会跨窗口污染,表现是同一段流量在离线跑是正常,在线跑一直报警。
验证方法我一般按这张表走一遍,每项都过了才敢说这套融合能落地。
| 验证项 | 具体做法 | 不通过的典型表现 |
|---|---|---|
| 时间一致性 | 用离线脚本回放同一段历史数据,比对在线输出 | 同一时刻标签不一致 |
| 单源降级 | 人为断掉一路数据源 | 整体指标崩掉而非小幅下降 |
| 时钟漂移 | 把某源时间戳整体偏移 3 秒 | 对齐率骤降但无告警 |
| 空窗处理 | 注入连续 10 分钟无事件区间 | 特征出现异常跳变 |
| 阈值稳定性 | 分时段统计告警量 | 某时段告警量翻十倍 |
单源降级这一项特别值得做。决策级融合的设计初衷就是容错,如果断掉一路之后系统直接不可用,说明权重分配写死了,或者特征级融合的路径被误当成主路径。做法很简单,把某一源的输入置为全 NaN,看输出概率的分布是否还在合理区间。
def replay_check(model, stream, window="60s"): """离线回放校验:每个窗口的输出必须可复现""" buf, out = [], [] for rec in stream: buf.append(rec) buf = [r for r in buf if r.ts >= rec.ts - pd.Timedelta(window)] feats = window_features(pd.DataFrame(buf)) out.append(float(model.predict_proba(feats)[0, 1])) return out这段代码的关键在缓冲区那行过滤:按时间戳而不是按条数裁剪,因为流量突发时条数会暴涨,按条数裁会让窗口实际覆盖的时间长度剧烈变化,特征的含义跟着变。window要和训练时完全一致,训练用 60 秒、在线用 45 秒这种不一致,指标对不上是必然的。
从那以后我每次交付这类融合检测,都强制先跑一遍单源消融和单源降级,再谈融合带来的增益——因为这个领域里"多源一定比单源好"是个错觉,弱源和错位数据造成的伤害,往往比不做融合还大。这套包把数据对齐、融合层级、阈值搜索、降级验证的骨架都摆在那儿了,按上面的顺序改配置、跑脚本、对指标,比自己从零搭省不少时间。希望帮到你。
本文还有配套的精品资源,点击获取