4. 数据清洗实战:缺失值处理与异常值修正

各位工程师朋友,咱们接着聊。上一章我们把数据从历史库里拽了出来,但说实话,那些数据就像刚从泥地里挖出来的电缆——脏得很。今天这章,我就带你亲手把这些数据洗干净。

数据清洗,说白了就是三件事:缺的补上、错的改掉、重复的删掉。听起来简单?我在项目里踩过的坑可不少。咱们一个一个来。

4.1 缺失值处理:别让空值毁了你的模型

SCADA系统里,缺失值太常见了。传感器掉线、通讯中断、存储故障……原因五花八门。但模型训练时,空值就是毒药。

我个人习惯,先看看缺失率。低于5%的,直接删掉那几行;超过30%的,这变量基本废了,考虑直接扔掉。中间那档,才值得花功夫去补。

4.1.1 插值法:线性与样条

插值法适合连续变化的物理量,比如温度、压力、流量。我常用的有两种:

  • 线性插值:两点之间画直线。简单粗暴,适合变化平缓的场景。
  • 样条插值:用多项式曲线连接。更平滑,适合有波动的数据。

举个例子,某条管线的压力数据在10:00和10:05都有值,但10:02缺失了。线性插值就是按时间比例算个中间值。我在某石化项目里,用样条插值补过一段振动数据,效果比线性好不少——因为振动本身就不是线性变化的。

import pandas as pd
import numpy as np
from scipy.interpolate import interp1d

# 模拟数据
time = np.array([0, 1, 2, 3, 4, 5])
value = np.array([10.2, np.nan, 10.5, np.nan, 10.8, 11.0])

# 线性插值
df = pd.DataFrame({'time': time, 'value': value})
df['linear'] = df['value'].interpolate(method='linear')

# 样条插值(需要先去掉NaN)
valid = df[df['value'].notna()]
f_spline = interp1d(valid['time'], valid['value'], kind='cubic')
df['spline'] = f_spline(df['time'])

print(df)
我的经验:插值法只适合缺失值前后都有数据的情况。如果一段连续缺失超过3个点,就别用插值了,误差会累积。

4.1.2 前向填充与后向填充

前向填充,就是用上一个有效值填充后面的空位。后向填充正好相反。

什么时候用?比如设备状态信号——设备一旦启动,状态就是1,直到它停下来。中间如果丢了几个采样点,用前向填充完全合理。我在一个水处理厂的项目里,液位计偶尔会跳变到0然后又恢复,这时候用前向填充就能把那些假零值盖掉。

# 前向填充
df['ffill'] = df['value'].fillna(method='ffill')

# 后向填充
df['bfill'] = df['value'].fillna(method='bfill')
注意:前向填充会引入滞后效应。如果数据变化很快,比如振动或电流,用前向填充会抹掉真实波动。我曾经因为这个原因,把一个电机的故障特征给填没了,排查了好久才发现。

4.1.3 模型预测填充

这是最复杂但也是最精准的方法。用其他相关变量来预测缺失值。比如,用电机电流和转速来预测温度——因为这三者之间有物理关系。

我常用的模型是随机森林或线性回归。注意,训练时只能用完整的数据行,预测时再把缺失的行喂进去。

from sklearn.ensemble import RandomForestRegressor

# 假设有特征A、B,目标变量C有缺失
train = df[df['C'].notna()]
test = df[df['C'].isna()]

model = RandomForestRegressor(n_estimators=100)
model.fit(train[['A', 'B']], train['C'])
df.loc[df['C'].isna(), 'C_pred'] = model.predict(test[['A', 'B']])
避坑指南:模型预测填充不是万能的。如果缺失值集中在某个工况下(比如设备停机时),那模型学到的规律可能不适用于那个工况。我曾经在风电项目里吃过这个亏——风速缺失时恰好是台风天,模型预测出来的值完全不对。

4.2 异常值修正:别让一个坏点带偏全局

异常值比缺失值更隐蔽。它看起来有数值,但那个数值是错的。SCADA系统里,异常值通常来自传感器故障、通讯干扰或人为误操作。

4.2.1 截断法

设定一个合理范围,超出范围的直接拉到边界。比如,某温度传感器正常范围是0-100°C,突然蹦出个500°C,直接截断到100°C。

我习惯用3σ原则或IQR(四分位距)来确定边界。3σ就是均值±3倍标准差,IQR则是Q1-1.5*IQR到Q3+1.5*IQR。

# 3σ截断
mean = df['value'].mean()
std = df['value'].std()
df['value_clipped'] = df['value'].clip(mean - 3*std, mean + 3*std)

# IQR截断
Q1 = df['value'].quantile(0.25)
Q3 = df['value'].quantile(0.75)
IQR = Q3 - Q1
df['value_clipped'] = df['value'].clip(Q1 - 1.5*IQR, Q3 + 1.5*IQR)
我的习惯:对于关键参数(比如反应釜温度),我会用更严格的2σ截断。对于一般参数,3σ就够了。别一刀切,要根据业务场景来。

4.2.2 替换法

截断只是把异常值拉到边界,替换则是用更合理的值换掉它。常用的替换值有:

  • 中位数:对异常值不敏感,适合有离群点的数据
  • 前后均值:取异常点前后几个点的平均值
  • 模型预测值:和缺失值处理一样,用模型算一个

我在一个钢铁厂的项目里,遇到过轧机电流频繁跳变。后来发现是某个传感器接触不良。我用了前后5个点的均值替换,效果很好——既去掉了毛刺,又保留了趋势。

# 用中位数替换异常值
median = df['value'].median()
df.loc[df['value'] > mean + 3*std, 'value'] = median
df.loc[df['value'] < mean - 3*std, 'value'] = median

4.2.3 标记法

有时候,异常值本身也是信息。比如设备故障前的异常波动,你把它改掉了,反而丢了故障特征。

我的做法是:加一列标记位。异常点标1,正常点标0。这样模型既能用正常数据训练,又能知道哪些点是异常的。

df['is_anomaly'] = 0
df.loc[(df['value'] > mean + 3*std) | (df['value'] < mean - 3*std), 'is_anomaly'] = 1
重要提醒:标记法不是让你不管异常值。模型训练时,异常值仍然会影响统计量。我通常的做法是:训练时用修正后的数据,但保留标记列作为特征输入。

4.3 重复数据去重:别让相同的数据算两遍

重复数据在SCADA系统里很常见。比如,数据采集程序重启后,可能会把缓存里的数据再发一遍。或者,两个不同的采集点采集了同一个信号。

去重很简单,但要注意:

  • 完全重复:所有列都一样,直接删掉
  • 时间戳重复但数值不同:保留最新的,或者取平均
  • 数值重复但时间戳不同:可能是设备没变化,保留即可
# 完全去重
df_clean = df.drop_duplicates()

# 按时间戳去重,保留最后一条
df_clean = df.drop_duplicates(subset=['timestamp'], keep='last')

# 按时间戳去重,取平均值
df_clean = df.groupby('timestamp').mean().reset_index()
我的经验:去重之前,先检查一下数据量。如果重复率超过10%,说明采集系统有问题,建议先修系统再处理数据。否则你每次跑模型前都得去重,治标不治本。

4.4 本章知识体系

下面这张图,是我梳理的数据清洗整体流程。你把它打印出来贴在工位上,每次处理数据前看一眼,能少走很多弯路。

数据清洗实战流程 原始SCADA数据 数据质量检查 缺失值处理 异常值修正 重复数据去重 插值法 前向/后向填充 模型预测填充 截断法 替换法 标记法 完全重复删除 时间戳去重 清洗后的干净数据

数据清洗这件事,说难不难,说简单也不简单。关键是要理解你的数据从哪来、到哪去。我在项目里见过太多人,上来就套模型,结果被脏数据坑得怀疑人生。记住一句话:垃圾进,垃圾出。花在清洗上的时间,永远值得。


公众号:蓝海资料掘金营,微信deep3321