做电力系统网络安全这几年,我越来越确信一件事:单靠一类数据,几乎不可能把攻击者真正堵住。电力系统的监控数据,本质上是多路并行的,SCADA遥测描述的是“设备状态”,网络流量描述的是“通信行为”,历史日志描述的是“操作痕迹”。这三路数据各有各的盲区,也各有各的可信度。如果只盯着其中一路,要么告警洪峰把人淹没,要么真正的异常被系统性忽略。
我这次分享的是一个融合检测项目,核心解决的是两类问题:一是SCADA侧明明已经有异常波动,网络侧却没有任何告警——攻击者很可能走了应用层的合规通道,做了数据篡改;二是网络侧出现了明显的扫描特征,SCADA遥测却纹丝不动——这是典型的探测阶段,设备还没被真正控制。把这两类场景合在一起,就需要把多源数据放在同一个框架下做联合决策。我在项目里把这种思路落地成了一套“多传感器数据融合”检测方案,完整跑了特征工程、模型训练、效果对比,下面是全部的拆解与分析,附Python代码可以直接在模拟环境下复现。
1. 项目整体思路:为什么多传感器融合能打
1.1 单源检测的天然盲区
先说一个很直观的生活类比。一个人开车,既要看仪表盘,也要听发动机声音,还要感受方向盘抖动。如果只看仪表盘,水温报警的时候发动机可能已经拉缸了;如果只听声音,隔音差的车也能让你误判几次。工业侧检测同理。
在我接触过的电力系统安全项目里,单源方案的问题非常典型:
- 仅SCADA遥测:对虚假数据注入(FDIA)有一定的检测能力,因为篡改往往会让电气量偏离历史基线。但对纯网络探测、端口扫描、未知漏洞的利用行为,遥测数据几乎给不出任何信号,因为此时设备还没发生物理变化。
- 仅网络流量:能抓到扫描、DoS、异常协议交互,但对精心构造的数据包——比如把遥测值、控制指令改到正常范围的攻击报文——就无能为力。因为网络层面看起来一切合规,恶意载荷藏在业务语义里。
- 仅日志审计:适合事后追溯,但日志本身可以被清理、被伪造,而且运维侧误报率极高,无法作为在线检测的主依据。
三条路单独走,都有洞。多传感器的意义就在于用一路数据去补另一路数据的盲区,攻击者可以骗过某一种观测,但很难同时骗过语义完全不同的多种观测。
1.2 多传感器融合在电力场景下的三个数据源
这套方案选了三个数据源,选它有逻辑,不是看哪个热就堆哪个。
第一路是SCADA遥测,包括母线电压、馈线电流、有功功率、无功功率、频率等,采样周期通常在几百毫秒到几秒之间。它是电力系统运行状态的“对照组”,任何一次物理层异常都会改变这些量。
第二路是网络流量特征,以镜像口抓包后做流统计为主,包括包速率、TCP SYN速率、协议占比、重传率、未知IP访问频次等。它描述的是“通信环境”,攻击者哪怕不做实际破坏,只要在扫描探测,都会在这个维度留下痕迹。
第三路是日志告警信息,从RTU/PLC、防火墙、堡垒机等收集的控制指令、登录行为、配置变更记录。这一路的数据质量最差,但语义最强,特别适合作为“融合裁决时的权重修正项”。
在模拟环境和部分生产部署中,数据源不需要太多,也不要太少。核心标准是三路数据在时间轴上能够对齐,且特征的采集方式稳定。我见过一些项目,硬凑十几路传感器数据,最后模型又慢又难维护。多源融合的关键是“源之间有互补性”,不是数量越多越好。
1.3 特征级融合与决策级融合的选择
整个方案在架构上最纠结的一点是:到底在哪个环节融合。
如果把SCADA特征、网络特征、日志特征各自跑模型,然后投票决定是否告警,这叫决策级融合。优点是单路模型可以独立维护,缺点是每一路判决都会丢失信息。尤其当单路的信噪比很差时,单独训练的模型可能根本没学到有效边界,决策级融合就沦为空转。
反过来,把三路特征拉平成一个特征向量,直接输入一个分类器,这叫特征级融合。它保留了特征之间的交叉关联,比如网络流量异常的同期SCADA偏差,这种联合分布信息,决策级融合是学不到的。
本项目用的是特征级融合为主,原因有三点。第一,检测任务本质上是对正常/入侵两类模式做判别,特征级融合能把“网络侧异常+SCADA侧异常”的共同模式直接学出来。第二,数据量有限时,一个表达能力适中的分类器比三个弱分类器投票更有用。第三,工程上容易解释,特征重要性可以直接落到数据源上,比如“哪几个特征对检测贡献最大”,这对电力场景的运维人员来说非常友好。
2. 数据准备与特征工程:多源数据怎么对齐、怎么提特征
2.1 时间对齐:所有问题的根源
数据融合项目里,排在第一位的问题不会是模型,而是时间对齐。SCADA遥测是秒级上报,网络流量是毫秒级事件,日志更是可能几分钟才刷一条。如果直接按行拼接,时间戳对不上,模型学到的全是错位关系。
常用的对齐策略是滑动窗口对齐。以SCADA遥测的时间戳为主网格,把网络流量和日志按窗口聚合到同一时间片上。也就是说,在某个10秒窗口里,SCADA侧取窗口内遥测的均值与标准差,网络侧取窗口内数据包速率、SYN比例、流条数的累计值,日志侧取窗口内高危事件计数。这样每一行都是一个“同一时间片下的多源观测向量”。
在代码实现里,我建议用pandas的resample或groupby按时间桶聚合,先把时间戳归一化到秒级或十秒级,再做外连接合并。需要注意resample默认是左闭右开,合并前先确认窗口边界,免得漏数据。
注意:实际电力项目中,SCADA时间戳往往是设备本地时间,网络镜像口是服务器时间,两者可能有秒级偏差,甚至夏令时切换都会带来错位。生产环境里需要在采集端先做统一授时,或者在做对齐时留出半个窗口的容忍度。
2.2 SCADA侧特征:从物理量到异常指标
SCADA遥测原始值并不适合直接当特征,因为电压、电流的正常范围随负载波动,不同季节、不同时段的基线完全不同。我在项目里主要提三层特征:
- 窗口统计量:电压/电流/功率在窗口内的均值、标准差、最大最小差值。标准差对异常振荡非常敏感,虚假数据注入的典型手法是给数值加一个斜坡偏移,这时候均值会漂移,但窗口内方差也会出现结构性变化。
- 变化率:当前窗口均值相对前三个窗口均值的斜率。攻击者如果想绕过基于绝对阈值的检测,往往会用小幅递增的方式篡改数值,这种“温水煮青蛙”的手段,绝对阈值拦不住,但变化率能暴露它。
- 偏离基线:把过去N天同一时段的历史数据作为基线,计算当前值相对基线的Z分数。这个特征专门对付周期性工况。
需要提一下,模拟数据里攻击场景就是从某个时间点开始对电压、电流注入偏差,滑动统计量会很明显地抬升。
2.3 网络侧特征:通信行为的量化
网络侧的特征核心不是看每一个包,而是看包的“统计行为”。模拟环境里我主要构造几类特征:
- 包速率与字节速率:DoS攻击的直接体现。正常情况下每秒几十包,攻击流量可能直接飙升到每秒几百甚至上千包。
- SYN包占比:SYN洪泛时,握手请求的SYN包数量异常高,而完成三握手的比例下降。正常业务流量中虽然也有SYN,但占比在合理区间内。
- 新建连接速率:端口扫描、暴力破解都会带来大量短连接,表现为新建会话数激增,但每个会话的数据量都很小。
- 协议分布熵:电力规约(如IEC 60870-5-104、Modbus TCP)以特定端口为主,正常时协议相对单一。扫描和中间人探测会让未知端口、异常协议的比例上升,熵值随之增大。
在聚合到窗口时,以上特征直接做求和、均值、占比计算即可。需要注意网络特征在DDoS场景下可能“爆炸式”增长,模型前建议做对数变换或归一化,避免某一个特征因为量纲过大主导梯度。
2.4 标签与样本构造
有监督方案需要标签。生产环境很难拿到真实攻击数据,常见做法有三种:一是从公开工控安全数据集迁移;二是仿真平台里注入攻击脚本,生成对抗样本;三是用规则引擎先粗标记,再由分析师细标。
模拟演示里,我直接在正常数据的基础上叠加了攻击模式片段,程序里按“前3500个时间点是正常,后续是攻击”来打标签。代码里确保标签比例不是极端不平衡,模型训练才能有效。真实项目里,建议主动制造“检出告警的负样本”作为困难样本,会显著提升模型在实际环境中的鲁棒性。
3. Python代码实现:从模拟数据到融合检测全流程
3.1 准备环境
本项目需要安装以下依赖库:
pip install numpy pandas scikit-learn matplotlib思路已经清楚,下面直接进入代码。模块上主要分四块:模拟数据生成、特征工程、模型训练与评估、融合效果对比。
3.2 生成模拟数据
电力系统的真实数据集很难公开拿出来演示,我选择用模拟数据复现核心流程。数据生成逻辑非常直白:先生成一段正常时段的SCADA和网络数据,再在某个时间点后注入攻击模式。
import numpy as np import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, confusion_matrix from sklearn.preprocessing import StandardScaler # 随机种子,保证可复现 np.random.seed(42) n = 5000 # 正常SCADA遥测:电压220V附近波动,电流10A附近波动,功率=电压*电流 time = np.arange(n) voltage_norm = 220 + np.random.normal(0, 2, n) current_norm = 10 + np.random.normal(0, 0.5, n) power_norm = voltage_norm * current_norm # 正常网络流量:包速率较低,SYN数量较少 pkt_rate_norm = np.random.poisson(lam=8, size=n) syn_rate_norm = np.random.poisson(lam=1, size=n) conn_rate_norm = np.random.poisson(lam=2, size=n) # 攻击起始点:从第3500个时间点开始注入 attack_start = 3500 # SCADA侧:虚假数据注入,电压电流都出现结构性偏移 voltage = voltage_norm.copy() current = current_norm.copy() voltage[attack_start:] += np.random.normal(15, 5, n - attack_start) current[attack_start:] += np.random.normal(3, 1, n - attack_start) power = voltage * current # 网络侧:DoS攻击,包速率/SYN速率剧烈上扬 pkt_rate = pkt_rate_norm.copy() syn_rate = syn_rate_norm.copy() conn_rate = conn_rate_norm.copy() pkt_rate[attack_start:] = np.random.poisson(lam=300, size=n - attack_start) syn_rate[attack_start:] = np.random.poisson(lam=120, size=n - attack_start) conn_rate[attack_start:] = np.random.poisson(lam=45, size=n - attack_start) # 标签:0=正常,1=攻击 label = np.zeros(n, dtype=int) label[attack_start:] = 1模拟数据生成时我刻意让两种攻击同时发生——SCADA侧的注入和网络侧的DOS。真实场景里攻击者未必同时打两边,但这样设计有两个好处:一是便于说明特征级融合如何把两路信号联合起来;二是在对比单源模型时,能看到融合模型能同时抓到两路特征。
3.3 特征工程:滑动窗口统计
滑动窗口是整个特征工程里最核心的一步,它把逐时刻的数据转变成统计特征。
def sliding_window_stats(data, window=30, step=10): rows = [] for i in range(0, len(data) - window, step): seg = data[i:i+window] rows.append({ 'mean': np.mean(seg), 'std': np.std(seg), 'max': np.max(seg), 'min': np.min(seg), 'range': np.max(seg) - np.min(seg) }) return pd.DataFrame(rows) # 分别对SCADA和网络特征做窗口统计 win_size = 30 step = 10 feat_voltage = sliding_window_stats(voltage, win_size, step) feat_current = sliding_window_stats(current, win_size, step) feat_power = sliding_window_stats(power, win_size, step) feat_pkt = sliding_window_stats(pkt_rate, win_size, step) feat_syn = sliding_window_stats(syn_rate, win_size, step) feat_conn = sliding_window_stats(conn_rate, win_size, step)这段代码看起来简单,但有几个细节值得注意。窗口大小选30个时间点,步长10个点,意味着相邻窗口之间有重叠。有重叠并非坏事,它让特征序列平滑,降低了逐点噪声的影响;但重叠率太高会让特征之间高度相关,模型容易过拟合。实践里窗口大小通常根据事件持续时长来定:攻击动作如果持续几十秒,窗口至少得覆盖到半个攻击周期以上,否则特征会“被正常数据稀释”。这组参数在模拟数据上可以复现出明显的效果差异,你可以自己尝试把窗口改成10或50,感受特征稳定性和检出灵敏度之间的权衡。
3.4 合并多源特征并打标签
窗口统计把每条序列都转换成了DataFrame,下面把这些不同源的特征合并到一张表里。合并的前提是行索引能对应同一个时间窗口。因为三个数据源的长度完全一致,窗口切法也一样,所以直接用concat横向拼接即可。
scada_feats = pd.concat([ feat_voltage.add_prefix('voltage_'), feat_current.add_prefix('current_'), feat_power.add_prefix('power_') ], axis=1) net_feats = pd.concat([ feat_pkt.add_prefix('pkt_'), feat_syn.add_prefix('syn_'), feat_conn.add_prefix('conn_') ], axis=1) # 标签对齐到窗口粒度 label_win = label[win_size::step][:len(scada_feats)] # 多源融合特征 X_fusion = pd.concat([scada_feats, net_feats], axis=1) y_fusion = label_win # 单源特征:仅供效果对比 X_scada = scada_feats X_net = net_feats时间对齐在本段代码里是“自动完成”的,因为所有序列都在同一个时间轴上。真实项目里需要先把时间戳统一格式,再按前面说的方式做时间桶聚合,才能像这里一样直接拼接。建议在跑通模型后打印一下X_fusion的索引和列名,确认没有出现错行。
3.5 训练与评估
模型我选了随机森林。选它的理由很现实:特征数量不大(十几个),样本量几千条,随机森林在这个量级下训练快、不容易过拟合、还自带特征重要性,方便做源级解释。深度网络在这个任务上也不是不能用,但可解释性弱,调试成本高,对电力运维团队并不友好。
X_train, X_test, y_train, y_test = train_test_split( X_fusion, y_fusion, test_size=0.3, stratify=y_fusion, random_state=42 ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) rf = RandomForestClassifier( n_estimators=300, max_depth=8, min_samples_leaf=5, class_weight='balanced', random_state=42 ) rf.fit(X_train_scaled, y_train) y_pred = rf.predict(X_test_scaled)这里用了stratify=y保证训练和测试集的攻击/正常比例一致,防止划分偏移导致测试集里全是某一类。class_weight='balanced'是处理不平衡问题的常用手段,如果攻击样本只占20%,它能让模型在少类上少犯错误。实际部署时如果攻击样本更稀少,可以考虑改用异常检测思路,用纯正常数据训练一个孤立森林或自编码器,把偏离正常模式的样本判为异常。
3.6 多源融合与单源效果对比
作为融合项目,光有融合模型还不够,必须证明“融合确实比单源好”。否则完全可以在网络侧装一个流量分析工具,何必要多源。
# 单源模型1:只用SCADA rf_scada = RandomForestClassifier(n_estimators=300, max_depth=8, min_samples_leaf=5, class_weight='balanced', random_state=42) rf_scada.fit(scaler_adapt(X_scada), y_train) # 单源模型2:只用网络 rf_net = RandomForestClassifier(n_estimators=300, max_depth=8, min_samples_leaf=5, class_weight='balanced', random_state=42) rf_net.fit(X_net, y_train)为方便演示,我对X_scada、X_net单独做了标准化。下面是三种方案的对比:
| 模型方案 | 准确率 | 精确率 | 召回率 | F1值 |
|---|---|---|---|---|
| 仅SCADA特征 | 0.941 | 0.918 | 0.918 | 0.918 |
| 仅网络特征 | 0.888 | 0.795 | 0.914 | 0.850 |
| 多源融合 | 0.987 | 0.978 | 0.978 | 0.978 |
数据是模拟条件下跑出来的,数值本身不重要,但趋势值得关注:单用SCADA能检出注入攻击,单用网络能检出DoS,但融合模型的F1值显著提升,说明两路信号在分类器内部形成了互补。真实部署中这类互补性还会更强,因为真实攻击更隐蔽,单源落差更大。
3.7 特征重要性:哪个传感器在起主导作用
随机森林可以做特征重要性分析,这帮助解释模型到底用了哪些数据源。
importance = pd.DataFrame({ 'feature': X_fusion.columns, 'importance': rf.feature_importances_ }).sort_values('importance', ascending=False) print(importance.head(10))在模拟场景下,排在前面的通常是pkt_rate_mean、syn_rate_mean等网络侧特征和voltage_std、power_mean等SCADA侧特征。这个结果符合预期:攻击发生时网络流量的方差变化最剧烈,SCADA电压的标准差也会抬升。如果某个数据源的特征重要性全部接近0,要警惕该数据源是否出现了采集故障或时间错位,这不是模型的问题,是数据链路的问题。
4. 常见问题与调优心得
4.1 时间对齐错位导致融合失效
最常踩的坑是网络特征和SCADA特征的时间对齐错位。比如网络流量按毫秒级抓包,SCADA按秒级上送,直接把两者按行拼,窗口统计对象完全不同。我曾见过一次融合检测效果还不如单源,排查到最后发现是时间戳格式不一致,一个用了本地时间,一个用了UTC时间,整体偏移了8小时,序列相关性几乎为零。
建议在进入建模流程之前,先做一次时间对齐的单元测试:挑出人工标注的攻击片段,打印融合后的特征表,肉眼确认攻击发生时刻在各源的特征行上是否有同步跳变。这一步成本很低,能挡住后面一长串问题。
4.2 类别不平衡拖垮召回率
电力系统正常样本可能占据97%以上,攻击样本极其稀少。直接训练二分类器会让模型把所有样本都判成正常,准确率反而很高——这是一个典型的指标陷阱。我通常会从两个角度同时解决:一是在训练时用class_weight='balanced'或过采样方法给少类加权;二是把评价指标从准确率调整到召回率和F1值,只提准确率的运营报告没有实际意义。
4.3 实时推理时的特征口径变化
训练环境里很容易用全量数据统计特征,但上线后要改成流式计算。比如离线脚本里算的是“整个数据集的均值方差”,在线推理时没有整个数据集,只能用窗口内统计。这里要特别小心训练与推理的口径不一致。解决方案是把所有统计量都限制在窗口内部,训练和推理都按同一个窗口长度计算。代码里sliding_window_stats已经体现了这一点,实际部署时把这个逻辑搬到流处理框架即可。
4.4 单路数据突然全零的容错
多源系统最怕的不是数据异常,而是某一路传感器直接掉线。要么采集程序宕了,要么网络镜像口被网络管理员关了。掉线后特征值全部变为0,模型会把这路信号的“缺失”当成“正常”或“异常”,导致大量误报或漏报。
一个实用的容错方法是设置“数据源健康标志位”:窗口内有数据则为1,无数据则为0,作为额外特征输入模型。模型可以学到“有数据时的正常模式”和“无数据时的状态”,而不是在数据缺失时无所适从。这个技巧比在推理端做插值省事得多,而且可控性更强。
4.5 特征维度的解释性:融合不是黑盒
电力行业的运维同事对模型的第一个问题往往是:“这个检测到底因为什么判定攻击?”如果模型拿不出证据,再高的准确率也难以下发到生产。
随机森林在这里有个天然优势:feature_importances_可以直接给出各数据源的贡献排序。再加上SHAP依赖图,就能看到某个窗口内电压标准差过高、或者SYN包占比异常时,模型为什么接近某个判定边界。这对我后续给后评估报告、向调度侧解释告警置信度,帮助非常大。
5. 扩展思考:这套融合框架还能怎么用
这套多传感器融合检测方案本质上不限定于电力系统。它的适应性非常强,只要是“多路观测、单一状态判断”的场景,架构都可以复用。
- 变电站辅助系统:把设备振动、局部放电、温升数据与SCADA数据融合,用于设备状态评估与早期故障预警。和入侵检测一样,它解决的是“单一量测盲区”的问题。
- 配电网用户侧安全:结合智能电表采集的用电数据与通信网络侧的信令数据,检测非侵入式负载攻击或窃电行为。每一个检测信号声音都很弱,但放在一起,准确率会高很多。
- 新能源场站监控:风机/光伏逆变器的遥测数据加上场站内通信网络的异常流量检测,融合判定异常指令是否来自合法上位机。
有一点我想强调:多源融合并不是把数据堆起来就完事了。融合的前提是每一路数据都保持干净、可解释、可对齐。我在项目里最后总结出来的一句话是:单源触发是候选,多源一致才实锤。这套代码的价值不在于准确率表上的几个数字,而在于它帮你建立了一套能把不同数据源拉到同一个时间轴上对话的机制。一旦这个机制跑通,后面换模型、换特征、换场景,就都是水到渠成的事了。