“基于ConvLSTM的伦敦空气质量预测”这个项目,我是从一鼻子灰开始的。拿到原始监测数据的那一刻,我意识到真正的战场根本不在模型搭建,而在一整套磨人的数据处理流程。ConvLSTM要求的是时空网格输入,但手头摆着的是十几个站点、几十万行的表结构数据,中间还夹杂着设备故障带来的空洞和哨兵值。这篇先不聊模型,把数据处理这条线彻底捋清楚,毕竟喂给模型的东西不对,后面的预测精度全是空中楼阁。
先交代一下项目背景:这是一个用ConvLSTM对伦敦地区小时级空气质量做时空预测的任务,目标是用过去12小时的多站点观测数据,预测未来6小时的关键污染物浓度。这个系列第一篇,重点解决数据从“原始csv”到“可训练的四维张量”之间的所有环节,包括站点网格化、缺失值修复、特征构造、归一化和滑动窗口采样。适合正在做时序预测、尤其是准备上手ConvLSTM或者STGCN这类时空模型的读者参考。
1. 项目背景与模型选型逻辑
1.1 为什么选空气质量预测当切入
伦敦的空气质量监测体系覆盖密度较高,公开可拿到的历史数据时间跨度长、指标相对齐全,对做时间序列的人来讲确实是一块比较理想的试验田。但“成熟”不等于“干净”。监测设备隔三岔五要维护,传感器标定偶发漂移,某站点某几天数据整段消失,这类问题在真实数据集里随处可见,反而比教科书里那些整理得整整齐齐的数据更能暴露一个工程师处理脏数据的能力。
选这个场景还有一个很实际的原因:污染物在空间上是连续扩散的,在时间上是累积变化的,天然属于时空双重依赖的数据形态。如果只用普通LSTM,模型完全没有空间的概念,每个站点的序列被当成独立样本,风速一吹,污染物从A站点飘到B站点这种空间传播关系它根本学不到。而纯CNN又只能处理局部空间特征,对时间维度上的长期依赖无能为力。两者结合的ConvLSTM正好卡在这个任务的点子上。
1.2 ConvLSTM的核心机制与数据处理需求
ConvLSTM是2015年提出的时空序列预测结构,它的核心做法很直接:将LSTM内部状态更新时用到的全连接矩阵乘法,替换成卷积操作。你可以这样理解:普通LSTM在记忆和遗忘信息时,每个时间步用的是向量乘权重,它不管输入数据的邻居是谁;而ConvLSTM在做同样的记忆更新时,用的是卷积核扫过一片区域,既知道当前格子自己的状态,也知道它周围格子的状态。这样一来,模型在时间维度上保留了LSTM的长期依赖能力,在空间维度上又继承了CNN的局部感受野特性。
这个模型特性直接决定了数据处理的最终目标形态:你要把所有监测站点和气象数据折叠成一个四维张量[batch, timesteps, height, width, channels]。height和width是空间网格的维度,channels对应的是PM2.5、NO2、风速、温度这些特征。也就是说,原始数据必须从“一行一个站点一个时刻”的表格结构,转成“每个时刻一张空间特征图”的序列结构。这一步转不好,模型根本跑不起来,这也是为什么数据处理在这个项目里要单独开一篇来讲。
2. 数据源摸底与质量评估
2.1 数据采集与字段构成
数据来自伦敦地区公开的空气质量监测网络,采集方式有两种选择:一种是通过平台提供的API接口按小时拉取实时数据,另一种是直接下载历史数据包。考虑到项目需要三年左右的完整历史数据来做训练和验证,API逐小时拉取效率太低,我直接选择了历史数据包的方式,一次性把全部站点数据落盘。
字段大致分成两类。第一类是污染物浓度:PM2.5、PM10、NO2、O3,单位基本都是微克每立方米。第二类是气象因子:气温、相对湿度、风速、风向、气压。气象数据不一定能从同一个数据源拿齐,可能需要和天气历史数据做多源拼接。这里有个必须提前确认的细节:NO2和O3的原始数据有时以ppb为单位,需要按温度和气压做换算转成µg/m³,换算公式是浓度(µg/m³) = 浓度(ppb) × 分子量 ÷ 摩尔体积,其中摩尔体积随温度气压变化。如果忽略了这一步,单位不一致的特征会被模型当成两类完全不同尺度的输入,预测结果直接跑偏。
2.2 数据质量初步摸查
拿到原始CSV后做的第一件事不是写清洗脚本,而是先出数据体检报告。逐个站点、逐个字段检查四项指标:缺失率、唯一值数量、数值分布范围、时间戳残差间隔。做这件事的经验是,有些问题只靠简单统计是看不出来的,必须画曲线。
比如某站点PM2.5小时均值曲线整体形态正常,但抽查原始数据时发现里面夹杂着-999这样的哨兵值,这是设备维护期间系统自动写入的异常标记。如果不事先排查,这个-999会被当成真实浓度参与训练,模型会莫名其妙学到“某些时候浓度骤降到负值”的错误规律。再比如风向字段,如果直接统计数值范围是0到360,看着没毛病,但这类环形变量不能直接当普通数值喂给模型,后面特征工程部分细说。
3. 数据处理完整实操
3.1 异常值识别与缺失值分层修复
这部分是整个数据流程中最耗时、最容易返工的地方。先说异常值的处理原则:哨兵值和超出物理合理范围的值统一转成NaN再走缺失值流程。判断物理合理性可以设阈值,比如PM2.5超过1000µg/m³、气温超过50摄氏度这类的明显越界,一律认为传感器异常。
缺失值修复我按三个层级从下往上处理:
- 第一层:硬删。如果某个站点整体缺失率超过30%,或者连续缺失窗口超过72小时,直接把这个站点或这段窗口丢弃。缺失比例太高时,任何填充策略都等于在造数据,与其引入大量虚假信息,不如损失一段样本。
- 第二层:时间插值。对单点或者不超过6小时的短窗口缺失,用前后有效值做线性插值。更稳的做法是用同站点前12小时数据的加权中位数填充,抗噪能力比线性插值好,尤其是污染物浓度本身波动剧烈的情况下,线性插值的两端是坏点时会制造尖锐毛刺。
- 第三层:空间修复。某个站点整段缺失但相邻站点正常时,根据站点坐标计算距离,用距离加权的邻站数据做填补。这一步需要提前维护好站点经纬度表。
这里有一个必须盯死的红线:不能用全时段插值。比如某站点7月1日上午缺了6小时数据,做插值时只能用7月1日前后的局部数据,绝不能拿到整月甚至整年数据去拟合一条曲线然后补进去——那就把未来信息焊死在历史数据里了,模型在验证集上会给你一个漂亮但虚假的成绩。
# 数据清洗示意代码,以某个站点为例 import pandas as pd import numpy as np df = pd.read_csv("station_01.csv", parse_dates=["timestamp"]) df = df.set_index("timestamp").sort_index() # 哨兵值和物理越界值转为NaN df.replace(-999, np.nan, inplace=True) df["pm25"] = df["pm25"].mask(df["pm25"] > 1000) # 连续缺失窗口超过72小时的整段丢弃 def drop_long_gaps(series, max_gap=72): mask = series.isna().astype(int) gap_length = mask.groupby((mask != mask.shift()).cumsum()).cumsum() series = series.mask((mask == 1) & (gap_length > max_gap)) return series df["pm25"] = drop_long_gaps(df["pm25"]) # 短窗口缺失用前12小时加权中位数填充 for col in df.columns: df[col] = df[col].fillna(df[col].rolling(12, min_periods=1).median())3.2 时空网格构建
这是ConvLSTM数据处理和普通LSTM最大的分水岭。普通LSTM只需要把每个站点的序列拼成二维矩阵,但ConvLSTM要求每个时刻都有空间结构。具体做法是:拿到所有站点的经纬度坐标,划定一个矩形包围盒,把空间切成固定大小的网格,比如4乘4或者8乘8。每个站点按坐标落到最近的网格中心,落在同一格子里的站点取均值。
网格尺寸的选择要权衡。网格太细,比如切成20乘20,大部分格子是空的,输入矩阵稀疏度极高,模型学的空间特征大部分是无意义插值;网格太粗,比如2乘2,空间分辨率被抹平,站点间的传播关系直接丢失。我当时试下来,项目里大约10个有效站点的情况,4乘4是比较平衡的选择,至少保证多数格子有站点落入,空白格不至于太多。
网格内无站点的位置有两种处理方式:一种是置零,配合后续Mask机制在模型里忽略;另一种是用周围站点的反距离加权插值填值。我实际取的是后者,因为它保留了空间连续性,也让模型在卷积时不会因为大片零值产生伪特征。网格构建完成后,每条时间记录就对应一张4乘4乘C的空间特征图。
# 站点坐标网格化示意 import numpy as np grid_size = 4 lats = np.array([51.45, 51.50, 51.48, 51.52, ...]) # 虚构站点纬度 lons = np.array([-0.12, -0.10, -0.08, -0.14, ...]) # 虚构站点经度 lat_edges = np.linspace(lats.min(), lats.max(), grid_size + 1) lon_edges = np.linspace(lons.min(), lons.max(), grid_size + 1) lat_idx = np.digitize(lats, lat_edges) - 1 lon_idx = np.digitize(lons, lon_edges) - 1 # 将多个站的时刻表数据聚合到网格 # 假设features形状为 (num_stations, num_timestamps, num_channels) grid_data = np.zeros((num_timestamps, grid_size, grid_size, num_channels)) for s in range(num_stations): grid_data[:, lat_idx[s], lon_idx[s], :] = features[s]3.3 特征工程与归一化
原始字段不能直接全塞进模型,有几个特征必须先做转换。
风向是环形变量,0度和360度实际是同一种状态,如果直接把角度当数值输入,模型会认为0和360相差巨大,完全扭曲空间含义。处理方式是拆成两个新特征:wind_x = sin(wind_dir)和wind_y = cos(wind_dir),用点在单位圆上的坐标表示方向。这样既保留了角度之间的真实距离关系,也避免了环形断裂问题。
时间特征同样要处理。小时在一天中是周期的,凌晨0点和深夜23点距离很近,直接拿小时数值喂模型会导致周期性丢失。我的做法是把“小时”拆成sin(2π * hour / 24)和cos(2π * hour / 24),星期几同理拆成周期特征。这套周期编码几乎是时序预测项目的必选项。
归一化方面,首选MinMaxScaler,因为ConvLSTM配合ReLU类激活函数对负值输入也算友好,但StandardScaler同样可用。关键是归一化参数只能在训练集上拟合,再应用到验证集和测试集。很多人图省事对整个数据集统一fit,这会让验证集和测试集的均值方差信息泄漏到训练流程里,等于考试前偷看了答案,线上部署时模型表现立刻现原形。每个特征通道要独立归一化,污染物浓度和气象因子数值范围差异太大,共享一套缩放参数会导致小数值特征被压扁。
from sklearn.preprocessing import MinMaxScaler # 假设grid_data形状为 (T, H, W, C),重塑为二维做fit T, H, W, C = grid_data.shape flat = grid_data.reshape(-1, C) scaler = MinMaxScaler() flat_scaled = scaler.fit_transform(flat) # 仅用训练集拟合 grid_data_scaled = flat_scaled.reshape(T, H, W, C)3.4 滑动窗口采样与数据集划分
网格构建完成之后,数据形态已经变成(时间步数, 高度, 宽度, 通道数)的四维张量,但还不能直接训练。ConvLSTM的输入输出都是序列,必须用滑动窗口切样本。窗口设计我用的配置是:输入过去12小时,预测未来6小时,滑动步长1小时。切出来的样本X形状为(样本数, 12, H, W, C),Y形状为(样本数, 6, H, W, 1),其中最后的1是目标污染物通道。
滑动窗口有三个细节必须注意。第一,样本和样本之间高度重叠,12小时输入窗口滑1小时会产生大量相似样本,这是通过数据增强的方式提升训练稳定性,但也意味着不能把切好的样本随机打乱后划分训练集和验证集,否则相邻样本几乎同源,验证集指标没有意义。第二,步长可以适当调大,比如滑2小时或3小时,减少样本冗余度、压缩训练时间,代价是样本总量下降,这轮取舍视训练时长而定。第三,预测目标可以选择多个污染物通道,但如果一次预测太多目标会导致模型更难收敛,第一个版本建议先从单目标做起。
def create_sequences(grid, input_len=12, pred_len=6, step=1): T = grid.shape[0] X, Y = [], [] for start in range(0, T - input_len - pred_len + 1, step): x = grid[start:start + input_len] y = grid[start + input_len:start + input_len + pred_len, ..., 0:1] X.append(x) Y.append(y) return np.array(X), np.array(Y)数据集划分不按样本数比例来,而是按时间顺序切分。比如三年数据约26000多小时,切掉前70%做训练、15%做验证、15%做测试,三块数据在时间上严格不重叠,模拟“用过去预测未来”的真实场景。如果数据量不够,可以考虑用滚动预测式的验证而不是一次性切分,但样本重叠问题会让这个方案复杂不少。
4. 常见问题与排查技巧实录
4.1 时间戳对齐的暗坑
污染物监测数据和气象数据来自不同数据源时,时间戳对齐问题非常隐蔽。两者采样点看起来都是整点,但一个是整点整分,另一个可能是整点过15秒;更麻烦的是有些数据源用半点整点交替,merge时直接按时间戳硬拼接,会造成一小时错位。我当时排查这个问题是靠画交叉相关图定位的:把两个数据源的风速序列做滞后相关性分析,发现滞后0时相关性不是最高,滞后1小时才是,立刻意识到某一路数据的时间基准有偏移。建议merge之前统一做时间规整,把时间戳floor到小时,并统一时区。
4.2 数据泄露的隐形入口
这个项目里数据泄露有三个常见入口,全部踩过一遍之后我总结成清单:
- 归一化参数用了全数据集拟合,测试集统计量进入训练流程。
- 缺失值用了全时段插值,未来信息混进历史窗口。
- 滑动窗口样本在时间上重叠,且训练集和验证集切分之前没有保证时间序列连续性。
第三点特别值得展开。很多人在切完滑动窗口之后,习惯性地用train_test_split随机切分。样本经过滑动窗口处理后时间维度被打散,随机切意味着验证集里混入大量与训练样本时间重叠的“近亲样本”,模型验证分数会异常漂亮。正确做法是先在原始时间序列上切出三段,让训练集、验证集、测试集在时间上严格先后排列,然后再各自做滑动窗口。
4.3 站点稀疏与传感器漂移问题
站点稀疏问题在真实监测网络中几乎无法回避,尤其是一些新区块的站点可能只运行了一年半载,历史深度和其他站点差了一大截。处理方案没银弹,我的做法是给每个站点记录数据覆盖的起止时间,网格聚合时对覆盖率极低的站点单独剔除,避免拉低整体数据质量。网格内空格插值时也要注意,插值权重需要根据邻站覆盖率动态修正。
传感器漂移是个容易被忽略但影响极大的问题。某个站点在某个时间节点换了传感器,数值可能整体偏移几十个百分点。这种系统性偏移在统计表里几乎不可见,因为均值和方差变化不大,但模型训练时会学到“同一天里污染物数值突然跳变”的错误模式。我在处理时按月份分段计算各站点均值,把均值突变超过合理阈值的站点的对应时间窗直接标记为异常段,再走缺失值流程处理。
5. 实操总结与经验沉淀
整套数据处理跑完之后,我最大感受是这个项目的成败有一大半在数据准备阶段决定了。从拿到原始CSV到产出可以直接喂给ConvLSTM的npy文件,前后花了两周,中间还返工了两次,一次是单位换算,一次是网格稀疏度问题。但这两周时间花得非常值,排查问题练出来的手感,比调参熟练度重要得多。
最后分享一个实打实的小技巧:给每个处理版本打上标签,比如v1_raw、v2_clean、v3_grid、v4_norm。每跑完一轮实验,把模型指标和对应的数据版本记录对应起来。这样当模型效果出现意外时,你能快速判断是数据问题还是模型问题,而不是在几十个文件和参数之间大海捞针。后续我把ConvLSTM模型结构接上这批数据之后,PM2.5预测任务上明显优于普通LSTM基线,这部分模型搭建和训练的细节,下一篇继续展开。