这段时间帮某个环境监测项目处理城市空气质量数据,遇到最多的不是模型效果差,而是数据本身的问题。一套PM2.5、PM10、NO2、SO2浓度数据拿过来,缺失值、异常值、重复记录混在一起,直接建模结果根本没法看。把这些数据清洗干净,用到的核心工具就是Pandas的插值函数,尤其是interpolate()里的线性插值方式。这篇就围绕这个过程,把异常值处理和线性插值的完整思路、坑点和实操代码整理出来,希望对正在跟空气质量数据“搏斗”的朋友能有点帮助。
1. 问题全貌:城市空气质量数据到底“脏”在哪里
1.1 三类常见数据问题
城市空气质量数据来源很多,既有国控监测站点自动上传的逐小时数据,也有第三方传感器部署在路边、小区、工业园区的补充数据。不管来源是哪一种,落到手里的DataFrame通常都会带着下面三类毛病。
第一是缺失值。监测仪器需要定期校准,网络传输偶尔丢包,断电维护更是家常便饭,结果就是时间序列上出现一个个空洞。有些是单个小时的缺失,有些则是连续几天完全没有记录。第二是异常值,传感器故障可能把PM2.5从80直接报成800,或者出现负值这种明显不符合物理常识的记录。第三是重复值和格式问题,比如相同时间戳重复出现两条记录,或者时间列读进来是字符串而不是datetime类型。
这三类问题如果不处理,后面做趋势分析、模型预测、甚至简单的每日均值统计,都会被严重带偏。尤其是异常值,它对均值和方差的影响非常大,几万个正常数据里混进几个极端值,均值可能就被拉高了一大截。
1.2 动手之前先明确目标
处理数据之前,我会先问自己三个问题:这批数据要做描述性统计还是建模预测?处理后的数据需要保留原始时间粒度还是聚合到小时/日均值?后续对数据质量有没有明确的业务要求?
这三个问题的答案决定了异常值和缺失值的处理策略。如果只是做月均浓度统计,单个小时的缺失值完全可以直接忽略或简单填充;但如果要做小时级预测模型,缺失值就必须谨慎插值,异常值也不能随便删除,否则模型会学到错误的模式。
我习惯把原始数据先用info()和head()快速扫一遍,确认列名、数据类型、时间范围、记录条数,再决定后续处理管线。这一步虽然简单,但能避免后面反复返工。
2. 识别异常值:先把问题“看出来”
2.1 用describe()和箱线图做快速体检
拿到数据后,我一般先跑一段快速的探索性代码,对每一列污染物浓度做基础统计。以PM2.5为例:
import pandas as pd import numpy as np import matplotlib.pyplot as plt df = pd.read_csv('air_quality.csv', parse_dates=['time']) df.set_index('time', inplace=True) print(df.describe())describe()会把每一列的计数、均值、标准差、最小值、四分位数、最大值都列出来。单看这张表就能发现很多问题:如果count明显少于总行数,说明有缺失;如果min出现负数,浓度字段肯定有异常;如果max远远大于75%分位数的好几倍,极有可能是少数极端值在作怪。
只看统计数字还不够直观,箱线图是更好的辅助工具:
plt.figure(figsize=(12, 6)) df['PM2.5'].plot(kind='box') plt.title('PM2.5 Boxplot') plt.show()箱线图能清楚展示数据的分布范围,超出上下须的点就会被单独画出来,这些点往往是异常值的高概率候选。但我必须提醒一句:箱线图不能代替业务判断,离群点不一定都是错误值,有时候重污染天气下PM2.5浓度冲到300以上是真实存在的,不能一股脑当作异常删掉。
2.2 两种常用判定方法
常用的异常值判定方法有两种:IQR(四分位距)法和Z-score(标准分数)法。
IQR方法的计算逻辑是:先算出第一四分位数Q1和第三四分位数Q3,IQR = Q3 - Q1,然后把小于Q1 - 1.5 * IQR或大于Q3 + 1.5 * IQR的值视为异常。这个方法的优点是无需假设数据服从正态分布,对偏态分布比较稳健,所以在空气质量数据这种右偏分布明显的场景里很常用。
Z-score方法则是计算每个值与均值之间的标准距离,公式为:z = (x - mean) / std,一般认为绝对值大于3的值是异常值。这个方法适合近似正态分布的数据,但如果数据本身有多个极端值,均值和标准差都会被拉偏,Z-score判定效果会打折扣。
实际处理时我通常两种方法都试一遍,把两个方法的异常候选集合并集作为重点关注对象,然后逐个检查,而不是直接自动删除。
2.3 结合业务规则的二次判断
统计方法圈定的是“统计意义上的异常”,但空气质量领域还有自己的业务规则。比如PM2.5浓度的物理范围基本在0到1000微克每立方米之间,超过这个范围要么是沙尘暴级别的极端事件,要么就是传感器故障。负值一定是异常,浓度突然从100跳变到0再跳回100,大概率是仪器切换或传输故障。
我自己的做法是:先按IQR和Z-score筛一遍,再结合业务上下限把明显不合理的记录挑出来,最后把这些候选异常值用散点图可视化,看它们在时间序列上的位置和形态。如果某个异常值出现在数据跳变的边缘,且周围数据完全正常,那基本可以判定为传感器毛刺。
3. 异常值处理:不是只有删除一条路
3.1 直接删除的适用场景
最省事的办法是把异常值所在的整行删除。当异常值只占全部数据的极小比例(比如小于1%),且删掉之后不会破坏时间序列的连续性时,直接删除是合理的选择。
# 按IQR方法标记异常值 Q1 = df['PM2.5'].quantile(0.25) Q3 = df['PM2.5'].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR outlier_mask = (df['PM2.5'] < lower_bound) | (df['PM2.5'] > upper_bound) print(f'异常值数量: {outlier_mask.sum()}') df_clean = df[~outlier_mask]删除之后一定要留意索引和时间连续性。如果后面还要用interpolate()插值,删除异常值会在时间轴上留下空洞,这些空洞反而成为新的缺失值。所以删除异常值需要和缺失值填充一起考虑,最好先分析异常、再确定是否删除、最后统一处理缺失。
3.2 用估计值替换异常值
有些场景不适合直接删除,比如异常值数量较多,或者删除后会破坏后续模型训练的数据连续性。这时候可以用估计值替换。替换的思路有两种:一是用该列的中位数或均值填充,二是用时序上相邻的正常值通过插值得到。
用中位数替换简单粗暴,对数据分布影响较小,但会造成该时间点的数值与前后趋势脱节。用插值替换则可以把异常点“拉回”到正常趋势线上,效果更自然。
我个人的经验是:先把异常值统一改为NaN,然后用后面的线性插值一并处理。这样既能识别异常,又能用趋势信息填充,比单独替换成一整个固定值要灵活得多。
3.3 处理原则与注意事项
处理异常值最重要的一条原则:不要盲目自动删除。我在实际项目中遇到过一种情况,某城市春节期间的PM2.5浓度因烟花爆竹燃放出现了持续多天的高值,从统计角度看这些值是离群点,但实际上它们反映的是真实污染事件。如果把这类值删掉,后续做空气质量评价反而失真。
所以我通常把异常值处理分成两步走:先自动识别并标记,再结合业务背景人工判断哪些要删、哪些要改、哪些保留。自动化的“一键清洗”工具看起来很爽,但在空气质量数据这种对真实性要求很高的场景里,人工复核环节不可省略。
4. 缺失值处理与interpolate()线性插值
4.1 为什么插值优于固定值填充
缺失值的处理方式大致分三类:删除缺失行、固定值填充、插值填充。删除缺失行会损失大量宝贵数据,特别是连续监测数据中缺失率超过10%时,直接删除会让后续分析样本量大打折扣。固定值填充(比如用均值、中位数)虽然简单,但没有考虑时间序列的趋势特征,容易在缺失点产生突兀的“平台”效果。
线性插值则是在已知数据点之间画一条直线,用直线上的值估计缺失点的数值。它能保持时间序列的连续性和趋势,对短时间段的缺失效果尤其好。比如某个小时PM2.5是50,下个小时是70,中间一个小时缺失,线性插值就会平滑地填上60,这比用均值填一个固定值要合理得多。
4.2 interpolate()的参数详解
Pandas的Series.interpolate()和DataFrame.interpolate()方法提供了多种插值方式,最常用的是method='linear',即线性插值。它的使用很简单:
# 先将异常值也转为NaN,统一交给插值处理 df['PM2.5'] = df['PM2.5'].mask(df['PM2.5'] < 0) # 线性插值填补缺失 df['PM2.5'] = df['PM2.5'].interpolate(method='linear', limit_direction='both')这里有两个参数值得重点说明。limit_direction控制插值方向:默认是'forward',表示只填补序列中间和后部的缺失值,如果开头就有缺失是不管的;改成'both'可以在序列开头和结尾都会尝试用最近的有效值进行填充。
另一个参数是limit,它控制连续缺失点的最大填补数量。比如连续缺失超过5个小时,线性插值可能不够准确,可以设置limit=4限制单段最大连续插值长度,超过部分保持NaN,交给其他方式处理。这个参数在实际数据中非常实用,因为空气质量数据的长时间连续缺失很常见,盲目用线性插值补齐连续几十个小时的缺失,插出来的是直线,完全丢失了真实的波动信息。
时间序列插值还要注意索引类型。如果索引是整型,线性插值直接按整数位置计算;如果索引是DatetimeIndex,Pandas会按时间间隔做插值计算。默认情况下interpolate()利用的是索引值作为插值的x坐标,所以提前把时间列转成DatetimeIndex非常重要。
4.3 线性插值的适用边界
线性插值适合缺失段较短、前后数据变化平稳的场景。如果某一天内PM2.5浓度在早高峰和晚高峰之间自然起伏,缺失一两个小时完全可以用线性插值补。但如果缺失发生在冷空气过境、污染物浓度剧烈波动的时段,线性插值补出来的值会低估波峰或高估波谷。
对于更长、波动更大的缺失段,可以考虑method='time'(按时间间隔加权)、method='polynomial'(多项式插值)或method='spline'(样条插值),但高阶插值容易产生过冲现象。在多数空气质量分析场景中,线性插值已经足够,不必盲目追求高级方法。
5. 完整实操流程:从原始数据到干净数据
5.1 清洗流程脚本
下面是一套完整的流程,把前面讲到的异常值识别、转换NaN、线性插值串在一起,并加了详细注释,可以直接复制使用。
import pandas as pd import numpy as np import matplotlib.pyplot as plt # 读入数据,把时间列解析为datetime格式 df = pd.read_csv('air_quality.csv', parse_dates=['time']) df.set_index('time', inplace=True) # 1. 初步检查 print(df.info()) print(df.head()) # 2. 按业务规则把物理上不可能的值转为NaN for col in ['PM2.5', 'PM10', 'NO2', 'SO2']: df.loc[df[col] < 0, col] = np.nan # 3. 用IQR方法标记统计意义上的异常值,先转为NaN for col in ['PM2.5', 'PM10', 'NO2', 'SO2']: Q1 = df[col].quantile(0.25) Q3 = df[col].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 1.5 * IQR upper = Q3 + 1.5 * IQR df.loc[(df[col] < lower) | (df[col] > upper), col] = np.nan # 4. 对每一列做线性插值,允许向前和向后 for col in ['PM2.5', 'PM10', 'NO2', 'SO2']: df[col] = df[col].interpolate(method='linear', limit_direction='both', limit=8) # 5. 检查剩余缺失情况 print(df.isnull().sum())这段代码把异常值识别和缺失值处理合并成了一个流程。注意我用了limit=8,也就是单段最长允许8个连续缺失点做线性插值,超过8个的连续缺失段保持NaN。这个数值可以根据数据实际情况调整,如果每小时一个数据点,8个小时的连续缺失用线性插值补出来还说得过去;如果缺失超过两天,建议考虑用前后日均趋势或其他方法。
5.2 清洗效果评估
清洗完不能直接拿去用,要先评估效果。我会做两类检查:一是看isnull().sum()是否还有大量残留缺失;二是把清洗前后的数据分布打印对比,确认没有引入新的极端值。
print(df.describe())如果describe()里出现最小值仍然为负、最大值比清洗前更离谱等情况,说明清洗流程中有环节出了问题,要回头检查是异常值判断条件写错了,还是插值后的数据仍包含异常值。
另外一个快速有效的可视化检查是画清洗前后的时间序列对比图:
fig, ax = plt.subplots(2, 1, figsize=(12, 8)) ax[0].plot(df.index, df['PM2.5'], color='gray', alpha=0.5) ax[1].plot(df.index, df['PM2.5_original'], color='blue', alpha=0.5) plt.show()如果清洗后曲线明显出现大段直线平台,那很可能是连续缺失被线性插值强行拉平了,说明limit参数设得太宽松或缺失段本身太长,需要调整策略。
清洗完成后,我习惯把清洗前后的数据另存为两份文件,比如raw_air_quality.csv和clean_air_quality.csv。原始数据保留一份是必须的,因为任何清洗操作都可能导致数据失真,保留原始数据可以随时回溯和恢复,这在后续写报告或审计时非常重要。
5.3 后续扩展方向
清洗只完成了第一步,后续可以根据项目需求继续扩展。如果要做空气质量预测,可以基于清洗后的数据构造滞后特征、滚动均值特征,按时间窗口滑动生成建模数据集。如果要做站点对比,可以把多个站点数据按时间对齐合并,处理量会成倍增加。
我自己在这类项目里最常用到的扩展是:把逐小时数据聚合到日均值,计算AQI指数分指数,然后对各站点做相关性热力图。这些下游任务都建立在数据质量可靠的基础上,前面的清洗步骤做不好,后面再漂亮的图表都只是“垃圾进垃圾出”。
6. 常见问题与避坑指南
6.1 interpolate()不生效?先从这三个地方排查
很多人会遇到调用interpolate()后数据仍然是NaN的情况。我排查这个问题会按顺序看三处。
先看数据类型:如果列里面混入字符串或者object类型,interpolate()不会报错,但也不会产生任何插值效果,因为数值运算在这种列上根本不会生效。用pd.to_numeric()强制转换一下通常能解决。第二看缺失值形态:如果连续的缺失点太多,超过limit限制,插值就会被跳过。第三看索引是否有序:Pandas在有些插值方法上会因索引未排序产生警告或非预期结果,提前sort_index()总能避免很多麻烦。
6.2 时间索引的坑
这是最容易被忽略的细节。interpolate()默认按索引的数值关系计算插值权重。如果索引是DatetimeIndex,那么插值会按时间间隔比例计算;如果索引是普通整数索引,则按位置等距计算。
举个例子:某个数据集下午14点到16点之间缺失数据,但索引本身是不等间距的时间戳,那么整数索引下的线性插值结果会和真实时间权重下的结果有差距。正确做法是在读取数据时就parse_dates=['time']并set_index('time'),确保后续所有插值都基于时间轴。
另外要注意索引里如果有重复时间戳,interpolate()的结果会变得奇怪,应先用df.index.duplicated()检查重复索引,并及早处理。
6.3 异常值删除后的连锁反应
前面提到的“先转NaN再插值”方案虽然灵活,但也有一个隐患:异常值点被转换为NaN后,如果插值间距过长,会被线性插值拉出一个“回归均值”的低谷。这会让原本的极端观测值被“平滑”掉,在重污染时段尤其明显。
如果你认为某些异常值其实是真实极端天气的反映,就不要把它们当异常处理。务必结合业务背景人工筛选,不要让算法替你决定什么该删、什么该留。我吃过一次亏:某次处理一个沿海城市的NO2数据,把台风登陆前后的极高浓度值一并用IQR标记为异常删掉了,结果后来源分析时发现那几天浓度升高是真实气象过程造成的,删掉后整个事件信号就消失了,后来只能从原始备份里找回重做。
6.4 inplace=True 的争议
Pandas的interpolate()支持inplace=True参数,可以原地修改DataFrame。我个人的习惯是一律不用,因为清洗流程很长,中间每个步骤都可能出问题,用inplace=True修改后如果发现前面的步骤有误,就得从头重新读取原始文件,非常不方便。正确做法是每次处理都生成新的DataFrame,或者显式赋值给新的变量,虽然多占一些内存,但可追溯性大大提高。
6.5 长连续缺失的正确打开方式
前面提到连续缺失段超过limit时,插值不会生效。这时候如果删掉这些行,可能会丢失一整段有效数据。我建议的做法是:评估这段缺失在整体数据中的占比,如果缺失段前后有明确的周期规律,可以用同期历史数据的均值做填充;如果缺失段实在太长(比如超过1个月),直接标记该时间段为无效,在后续分析中剔除,不做强行插值。
空气质量数据强调真实性和可解释性,插值补出来的值只是估算值,如果估算段过长,反而会误导下游分析。我有一次处理某站点的逐小时数据,出现连续一周的仪器维护导致缺失,直接线性插值补出一条近乎直线的浓度曲线,后来做趋势分析时这一段被识别为“异常平稳”,不仅没有解决质量问题,反而引入了新的误导信息。
6.6 多列同时插值的注意点
DataFrame.interpolate()可以一次性对多列插值,非常方便。但要注意不同列的缺失模式可能完全不同,统一设置limit=8对某些列合适,对另一些列可能太宽松。最好先看各列缺失数量和连续缺失段的分布,再决定全局参数还是分列参数。
我在处理PM2.5和PM10时通常会设置不同的插值参数,因为PM10的波动往往更大,连续缺失容忍度要比PM2.5更低一些。这种细节上的差异,在后续模型评估时会有明显体现。
写在最后
处理城市空气质量数据,最花时间的往往不是写代码,而是理解数据的“脾气”。我刚开始接触这类项目时,也犯过“拿到数据就建模”的错,结果模型的结果看起来不错,但经过仔细核查后发现所谓的规律根本是异常值制造出来的幻觉。后来学乖了,把异常值处理和缺失值插值这一步当成正式项目流程的前置模块,花大力气做扎实,后续每一步都顺畅很多。
interpolate()线性插值不是银弹,它适合处理短段的、前后数据走势平稳的缺失;面对连续长缺失、剧烈波动时段,还是要结合业务规则和人工判断。最好的做法是:用统计方法识别问题,用业务逻辑判断问题,用插值方法修复少量问题,用人工复核兜底大问题。这套流程看起来朴素,但在真实项目中,往往比任何花哨算法都管用。