拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于LSTM的PM2.5浓度预测:数据清洗到模型训练全流程

基于LSTM的PM2.5浓度预测:数据清洗到模型训练全流程

简介:基于LSTM循环神经网络的PM2.5预测PDF,是一份聚焦空气质量预测的学术论文,面向环境科学、数据建模与机器学习方向的研究者和学生。针对PM2.5浓度变化突发、非线性且传统方法难以准确预测的问题,该研究将气象与大气污染物指标作为输入,先用灰色关联度分析筛选关键因子,再把时间序列转为监督学习问题,最终搭建多变量LSTM循环神经网络模型,实现PM2.5日值浓度预测。资源包仅含1份PDF文档,大小约1.1MB,方便离线阅读;目前已有176人学习。文档保留了期刊论文的完整结构,包括摘要、关键词、引言、模型方法、仿真实验与结论等,读者可系统学习从特征关联分析、数据处理到LSTM建模与验证的完整流程。文中利用北京市2010—2017年气象和污染物数据验证模型,结果显示能较好预测日变化趋势,对空气质量预报和环境管理具有参考价值。

1. 给PM2.5浓度序列装上“记忆”:LSTM预测要解决什么问题

PM2.5浓度曲线不是一串孤立数字,今天的峰值往往能从过去几十个小时的风速、湿度和浓度变化里找到影子。这种前后依赖关系正是循环神经网络(RNN)的用武之地;基于LSTM循环神经网络的PM2.5预测,就是用一段历史观测序列去推未来一个或多个时刻的浓度值,让模型自己学习污染积累、扩散和日变化规律。

如果你正在做空气质量的课程设计、毕业论文,或是要给环境监测系统加一个预报模块,这篇文章可以一路跟到模型跑完;如果只拿普通回归模型死磕时序数据、预测曲线总是“慢半拍”,这一步正好补上时间序列建模的关键环节。我会按实际项目顺序往下走:数据清洗、滑窗切样本、建模训练、评估和排错,每一处都给可复现的参数和代码。

2. 数据准备:让LSTM吃到干净、连续、有规律的PM2.5序列

2.1 公开历史数据怎么组织:从原始记录到标准化CSV

任何LSTM项目第一步都不是写模型,而是确认数据能不能被模型吃到。PM2.5预测最常用的原始数据是环境监测站点逐小时导出的记录,有些来自公开空气质量平台,有些来自论文附带的压缩包;共同点是时间戳和要素列齐全,但格式千奇百怪,有的按天分表,有的混着字符串。常见做法是先用pandas读进来,统一列名,再输出一份干净的训练CSV。

一份典型的时间序列特征表如下:

变量名含义单位/格式是否作为特征
datetime观测时间YYYY-MM-DD HH:MM:SS索引,不作为特征
pm25PM2.5浓度μg/m³目标变量,也作为滞后特征参与建模
so2 / no2 / o3气态污染物μg/m³特征
temp / pres / dewp气温/气压/露点°C / hPa / °C特征
wspm风速m/s特征

风向这类类别变量不要直接塞进数值矩阵,硬编码成{0,1,2,3}会给LSTM带来伪排序;要保留就做one-hot,或者第一版先删掉,等模型跑通再加。这个选择对预测效果的影响很大,污染过程往往带明显风向特征,但模型并不天然理解类别编号的大小关系。

读取和排序的代码如下:

import pandas as pd df = pd.read_csv('pm25_raw.csv') df['datetime'] = pd.to_datetime(df['datetime']) df = df.sort_values('datetime').reset_index(drop=True) # 如果原始数据有非整点记录,聚合到小时级 df = df.set_index('datetime').resample('1h').mean().reset_index() df.to_csv('pm25_clean_stage1.csv', index=False)

说明:时间戳转成datetime是必须的,排序保证后面滑窗在时间上连续;resample会把不规则记录聚合到整点,均值填充空位会产生NaN,留到下一步清洗。

2.2 清洗与时间戳处理:缺失值、0值异常与乱序记录

PM2.5时间序列数据有三个高频污染源:缺失值、连续0值和乱序记录。缺失值多出现在凌晨站点维护和网络中断时段;更隐蔽的是连续0值,不少仪器在零标定或停机状态会输出0,这不是真实浓度而是无效记录。乱序记录出现在合并多个分片CSV时,如果直接用原始顺序切窗口,会把后一天的数据接到前一天前面,模型看到的“时间段”全是错位的。

我一般先做一轮比例检查,统计pm25为空、为0的数量占总样本比例。缺失比例低于1%直接插值,超过5%按天剔除而不是硬填。

import numpy as np print("缺失比例:", df['pm25'].isna().mean()) print("零值比例:", (df['pm25'] == 0).mean()) df.loc[df['pm25'] <= 0, 'pm25'] = np.nan # 保守起见,把0先当缺失 df['pm25'] = df['pm25'].interpolate(limit_direction='both') df['pm25'] = df['pm25'].fillna(method='ffill').bfill()

说明:interpolate用前后近邻做线性插值,适合小时序列的短缺失;ffill和bfill兜底处理序列边缘的缺失。把0当NaN要慎重,我知道有些城市冬季PM2.5确实能低到接近0;我的判断标准是看零值占比,如果小于0.5%且没有成段出现,基本可以判断是真实低值而不是停运。北方春季沙尘过程后容易出现连续0值,尽量翻一下原始日志再决定是否剔除,盲目插值会把一段真实过程抹平。

2.3 滑窗切样本:窗口长度、预测步长与训练验证划分

LSTM的样本不是一行行独立记录,而是一个时间片段。以过去48小时预测未来1小时为例,我习惯用一个循环函数生成样本。窗口长度要匹配PM2.5的周期特性:24小时周期至少需要24步才能看到;要捕捉持续两到三天的污染积累过程,24步不够,我更常用48或72步作为窗口。

def make_sequences(data, feature_cols, target_col, window=48, horizon=1): X, y = [], [] values = data[feature_cols].to_numpy(np.float32) targets = data[target_col].to_numpy(np.float32) for i in range(len(data) - window - horizon + 1): X.append(values[i : i + window, :]) # 过去window个时刻的全部特征 y.append(targets[i + window : i + window + horizon]) # 未来horizon个时刻的浓度 return np.array(X), np.array(y)

代码逻辑:X的每个样本形状是(48, 特征数),y是(horizon,)。核心规则是“未来”必须在滑窗结束之后,绝不能把目标时刻的特征混进X,否则模型不学规律,直接读答案。第一版把horizon设为1简单可靠;想要24小时预报,需要改horizon或者做滚动预测,最后一节细说。

切好样本后按时间顺序分割:前80%训练、后20%验证。不要直接用sklearn的train_test_split,它默认随机拆分,用在时序上会出大问题。验证集必须晚于训练集,这样评估的才是“模型预测未来”的能力,而不是“在序列中间回忆”。数据归一化也在这个阶段做:用训练集统计量fit scaler,验证集只transform。若图省事用全量数据fit,后面还原浓度时数值会错位,这个坑会在避坑部分单独讲。

窗口越大,样本数量越少。数据量只有几千小时时,window不要超过总样本的5%;如果切完样本不足两千,batch_size降到32,不要盲目加大窗口。

3. 搭建LSTM模型:为什么是循环神经网络,以及最小可运行的PyTorch代码

3.1 从RNN到LSTM:长程依赖和梯度消失问题

循环神经网络的核心在于“循环”这两个字:它不是把整个序列一次性塞进网络,而是在时间维度上共享同一组权重,逐个时间步更新隐藏状态。PM2.5序列天然适合这种结构:今天上午的浓度受昨晚边界层高度影响,昨天的高浓度颗粒物经过积累和扩散,会在两三天后留有痕迹。普通全连接网络看不到这种长程关系,再宽的隐藏层也只是在拟合“拼出来的长向量”。

标准的RNN在反向传播时要跨所有时间步计算梯度,序列一长,梯度连乘会出现指数级消失或爆炸,前二三十步的信息到后面就没了。LSTM引入记忆细胞和遗忘门、输入门、输出门三套门控,让信息可以在记忆细胞里长距离传递,需要保留时保留,需要丢弃时丢弃。这也是为什么同样是“循环神经网络”,实际工程里基本默认选LSTM而不是原始RNN。

这类小时级时序项目,我用PyTorch而不是TensorFlow/Keras的最直接原因是动态图方便:模型不收敛时可以在forward里打印中间状态,一行行检查hidden state是变成0还是变成NaN。对需要反复调参的小项目,这个调试优势比部署便利更值钱。

3.2 最小可运行的PMLSTM模型定义

PyTorch的nn.LSTM已经封装好门控逻辑,手写三个门反而容易出错。我习惯把模型定义成“LSTM + 全连接输出层”:LSTM负责提炼序列状态,全连接层把最后一个时间步的状态映射成预测值。

import torch import torch.nn as nn class PMLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_layers, horizon): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=0.1 if num_layers > 1 else 0, ) self.fc = nn.Linear(hidden_size, horizon) def forward(self, x): out, (h_n, c_n) = self.lstm(x) last = out[:, -1, :] # (batch, hidden_size) return self.fc(last) # (batch, horizon)

代码逻辑说明:batch_first=True让输入和输出形状都保持(batch, seq_len, features),切片和排查都直观。nn.LSTM返回的out包含每个时间步的隐藏状态,这里只取最后一个时间步,因为预测目标是未来,序列结尾的状态才是对“当前已发生的所有信息”的浓缩。两层LSTM之间加了dropout=0.1缓解过拟合,num_layers=1时dropout参数会被PyTorch忽略,不影响运行。

实例化模型时把input_size设为7,对应7个特征列;这里把pm25自身作为第一个特征列参与建模,模型能看到过去浓度的轨迹。hidden_size=64代表每个LSTM单元输出64维的状态,这是“记忆容量”,不是网络层数。hidden_size太小,比如16,记住的细节有限;太大到256,在小数据集上容易过拟合到噪声。数据量上万小时、特征七八列时,64到128是安全区间。

3.3 三个必须对齐的维度:seq_len、input_size、hidden_size

新手最容易翻车的就是维度错位。seq_len是滑窗窗口长度,对应X.shape[1];input_size是特征列数,对应X.shape[2];hidden_size是模型定义里的超参数。模型定义只写input_size和hidden_size,seq_len由训练数据动态决定,PyTorch的LSTM允许不同批次使用不同序列长度,但同一批内的seq_len必须一致,否则dataloader默认的stack会报错。

训练前先随机生成一个样本自检shape:

with torch.no_grad(): xb = torch.randn(4, 48, 7) # 4个样本,窗口48,特征7 yb = model(xb) print(yb.shape) # 期望 (4, 1)

输出如果是(4,1),说明模型对horizon=1的尺寸正确。horizon改成24时,重新实例化再试;fc层不知道horizon是几,它只认构造参数,所以改horizon要重新训练,不能沿用旧权重。模型参数量主要集中在LSTM层,nn.LSTM(input=7, hidden=64)单层参数量约4×(7×64 + 64×64 + 64)=18432,两层再加fc层65个参数,总计约3.7万。这个规模在CPU上都能轻松训练,不需要纠结显存。

另一个容易忽略的点:LSTM内部激活是tanh和sigmoid,输入数值过大会把激活压到饱和区,梯度基本消失。这就是前面归一化要存在的原因。如果跳过归一化直接喂原始浓度,几百μg/m³的值会让训练loss掉得极慢,而且看不出模型结构有什么问题。

4. 训练与评估:让预测曲线贴合真实浓度的参数调整

4.1 损失函数、优化器和学习率的选择

回归任务首选MSE均方误差。它对大偏差的惩罚呈平方级放大,训练时会让模型优先压缩污染峰值附近的误差。评估指标一般用RMSE,因为单位是μg/m³,可以直接和日均值75μg/m³这种标准对比。MAE可以作为辅助指标,但单独拿MAE训练时模型容易趋向输出中位数,峰值会被系统性低估。

优化器用Adam,学习率默认1e-3起步。loss曲线在第一轮就降得很低不代表模型好,很有可能只是把上一时刻的观测值抄了过来;训练loss持续下降但验证loss开始回升,是过拟合信号,下面要讲的早停就是为它准备的。

4.2 训练循环里的三个“后悔药”:梯度裁剪、早停与学习率衰减

LSTM反向传播跨多个时间步,梯度连乘之后可能瞬间爆炸。常见做法是每次backward之后做梯度裁剪,限制梯度的范数不超过5.0。这是一个“后悔药”:避免一次夸张的更新把整个模型状态破坏掉。第二个后悔药是权重保存:只在验证loss比历史最优时保存,训练崩了随时恢复。第三个是学习率衰减,ReduceLROnPlateau在验证loss进入平台期时把学习率减半,让模型在最优解附近继续微调而不是震荡。

from torch.utils.data import TensorDataset, DataLoader X_train, y_train = X[:split], y[:split] X_val, y_val = X[split:], y[split:] train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=64, shuffle=True) val_loader = DataLoader(TensorDataset(X_val, y_val), batch_size=256, shuffle=False)

shuffle=True只用于训练集,让每个epoch见到的样本顺序不同;验证集不能shuffle,因为评估的是一个完整连续的时段。滑窗重叠会让相邻样本高度相似,打乱后存在信息重叠,但验证集在时间上完全晚于训练集,总体评估仍然可信。

训练循环和早停一体写完:

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5) criterion = nn.MSELoss() best_val = float('inf') wait = 0 early_stop_patience = 15 for epoch in range(100): model.train() for xb, yb in train_loader: optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() model.eval() with torch.no_grad(): val_pred = model(X_val) val_loss = criterion(val_pred, y_val).item() scheduler.step(val_loss) if val_loss < best_val: best_val = val_loss wait = 0 torch.save(model.state_dict(), 'best_pm25_lstm.pth') print(f"epoch {epoch}, val_mseloss {val_loss:.6f}") else: wait += 1 if wait >= early_stop_patience: print(f"early stop at epoch {epoch}") break

代码逻辑:scheduler.step(val_loss)把验证loss传进去,连续5个epoch没下降学习率自动减半;早停逻辑连续15个epoch没有刷新best_val就break。这类小时级序列预测,100个epoch很少真跑满,通常40到60个epoch验证集就进入平台期。

4.3 反归一化后看指标:RMSE、MAE与相关系数

训练时的loss是在归一化空间计算的,只是一个无量纲数字。评估预测质量必须把预测值和真实值都拉回原始浓度尺度,否则RMSE没有物理意义。

from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score pred_norm = model(X_val).detach().cpu().numpy() y_norm = y_val.numpy() pred_raw = scaler_y.inverse_transform(pred_norm.reshape(-1, 1)).flatten() y_raw = scaler_y.inverse_transform(y_norm.reshape(-1, 1)).flatten() rmse = np.sqrt(mean_squared_error(y_raw, pred_raw)) mae = mean_absolute_error(y_raw, pred_raw) r2 = r2_score(y_raw, pred_raw) print(f"RMSE={rmse:.1f} μg/m³, MAE={mae:.1f} μg/m³, R2={r2:.3f}")

参数说明:scaler_y是只对目标变量y fit的scaler,训练时记录训练集的范围,验证预测后用它还原;reshape(-1, 1)是为了满足inverse_transform要求的二维输入,还原后flatten回一维。RMSE受验证时段影响明显,如果三个月验证集里恰好跨了一次沙尘暴,RMSE会比平常高出一大截,这并不代表模型变差。同参数同数据的前提下比较不同模型才有意义。正常LSTM的R2在0.6到0.8区间;如果常见峰值时段R2超过0.9,先怀疑有没有数据泄漏。

5. LSTM预测PM2.5避坑指南:五个让模型翻车的高频细节

5.1 数据泄漏:验证集指标虚高,换一段数据立刻失灵

现象:训练过程里验证集loss很低,自信地把模型切到另一段时间或者另一个站点,预测曲线几乎完全不能用。原因:最常见的是特征里夹带了未来时刻的信息,比如把“当天24小时浓度均值”放进特征列,做小时预测时当天均值天然包含预测目标所在时刻的信息;其次是没有按时间划分数据,随机抽样的train_test_split会让模型“见过”验证时间段。解决:特征工程阶段逐列排查是否依赖未来数据;划分数据用截断方式而不是随机抽样;另外可以做一个便宜的自检:把预测时间整体向前平移,如果RMSE变化剧烈,说明模型在强记验证时段的统计分布而不是在学规律。

5.2 把序列打乱训练:误差曲线正常但预测全是滞后值

现象:loss下降得很漂亮,验证RMSE也还行,但把预测和真实曲线叠在一起,预测曲线总是滞后真实曲线一两小时,峰值被削平。原因:滑窗样本之间高度重叠,随机打乱后同一时刻的样本可能同时落在训练集和验证集,模型学到的是“把上一时刻的观测值搬运过来”,这是一种恒等映射,短期预测误差很小,看起来很美,实际没有任何预测能力。解决:验证集严格晚于训练集;用滞后相关性检验,把预测序列和真实序列错位1到3小时后重新算相关系数,如果错位后相关性反而更高,基本可以确认模型在“抄近道”。

5.3 归一化参数用错:预测值永远在0-1区间

现象:模型训练一切正常,反归一化之后数值还在0点几,或者出现负浓度。原因:训练前用全量数据fit了scaler,切分后再拿验证集的数据也参与了scaler构建,反归一化自然错位;另一个常见错误是MinMaxScaler拟合的是训练集范围,验证集一旦出现超出训练范围的浓度,反归一化后会出现负值或大于1000的怪值。解决:先切分再fit,验证集和测试集只调用transform,不要重新fit;预测值出现负浓度时先别急着clip到0,检查训练集范围是否覆盖完整,或者原始数据里是否混入了异常大值。通过99.9分位数对特征截断,比无脑clip更合理。

5.4 loss出现NaN或训练不收敛:梯度爆炸与学习率

现象:训练到十几或几十个epoch,loss突然变成NaN,退回最近的checkpoint才能继续;另一种是loss一直挂在0.01量级上下不降。原因:LSTM反向传播经过多层多时间步,梯度范数可能瞬间爆炸,Adam的自适应学习率挡不住特别大的梯度;另外数据里有极端离群值时,标准化后虽然数值不大,但这些样本产生的loss仍然很大。解决:在backward之后紧跟clip_grad_norm_,把梯度范数限制在5.0;学习率从1e-3降到5e-4重试;按特征列做99.9%分位数截断。loss不降时优先打印torch.isnan(xb).any(),确认特征序列里没有混入NaN。

5.5 换站点就失效:训练集指标好,外推能力弱

现象:A站点训练出来的模型在A站点验证时段表现很好,直接搬到B站点,预测曲线整体偏高或偏低,RMSE比简单基线还差。原因:模型学到的是A站点浓度分布、气象条件和浓度之间的统计关系,不同站点的源排放结构、地形、仪器标定都不一样。LSTM不学物理过程,它是统计模型,站点差异对它来说就是两个不同的世界。解决:先分别训练单站点模型作为baseline,确认跨站点预测的难度;部署时用B站点最近几个月的观测数据,在加载好的A站点模型上继续训练几个epoch,这叫轻量级迁移学习,效果比从零开始训练快很多,也比直接硬套好得多。

6. 进阶验证:用残差分析检查模型可信度,再做24小时多步预测

6.1 用残差和滞后校验确认模型不是“记住上一个值”

模型训练完别急着交付,我习惯先检查残差的自相关。残差是真实值减预测值,如果残差序列在时间上强烈正相关,说明模型漏掉了本该被建模的时序结构,当前输出只是在“延迟复制上一时刻”。更直观的做法是把预测序列整体向右平移24小时,再和真实序列算相关系数,如果平移后的相关系数反而更高,说明模型的有效输出是滞后项。

corr0 = np.corrcoef(y_raw, pred_raw)[0, 1] shifted = pred_raw[:-24] target = y_raw[24:] corr_shift = np.corrcoef(target, shifted)[0, 1] print(f"原始corr {corr0:.3f}, 预测平移24h后corr {corr_shift:.3f}")

平移后相关性明显更高时,回头看特征:当前时刻PM2.5作为特征时权重太高,短窗口下模型天然走捷径。一个简单改法是加大window到72甚至120,或者直接把horizon改成24,强迫模型学习更长期的规律。

6.2 从单步到未来24小时:滚动预测与直接多步的取舍

真实业务通常要求预报未来24小时浓度。直接多步做法是把模型输出维度改成horizon=24,损失函数一次约束24个时刻,实现简单,但后续输出很容易回归到均值,峰值越来越平。滚动预测做法是先训练horizon=1的模型,预测出t+1后把它拼到窗口末尾,再预测t+2,循环24次;滚动法保留了浓度动态轨迹,但误差逐步累积,第24小时的RMSE通常比第1小时高不少。

我的实践是:先做滚动预测,把误差累积曲线画出来,分别看第6、12、24小时的RMSE。如果第24小时RMSE超过训练集标准差的一半,说明这个模型只适合短期预报,不要强行撑到24小时,把目标降回12小时或6小时,可能比硬拟合更实用。这类项目最终交付的往往不是一个全能24小时预报器,而是一个“未来6小时趋势可靠、峰值能预警”的实用模块。

如果让我只给一条习惯,就是每轮实验都把验证集曲线截图和RMSE一起存档,包括这次改了什么、坏在哪一步。模型输出靠不靠谱,最终判断依据永远是反归一化后的真实浓度曲线,而不是终端里越来越低的loss。踩坑踩多了就明白:LSTM的效果一半靠参数,另一半靠数据边界意识。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表