:从数据清洗到模型评估的完整实践指南)
简介这套基于LSTM的空气质量指数预测源码是Python期末大作业与课程设计的完整实现方案。项目面向数据科学学习者、毕设及期末大作业开发者围绕空气质量指数预测这一真实场景覆盖数据读取、特征处理、LSTM建模、训练评估与结果可视化全流程代码注释详细部署即可运行。压缩包内含2000个文件其中1989个csv为北京及周边监测站点逐日空气质量历史数据可用于模型训练与验证4个py为项目核心实现脚本另含5个xml配置与1个pyc编译文件约29.33MB已有222人学习下载。项目由导师认可的高分方案整理而成核心价值在于可直接复用的LSTM预测框架、带注释的可读代码与清晰目录结构。读者既能将其作为期末大作业交付也能替换数据迁移至其他时序预测任务是兼顾完成度与扩展性的实战参考。1. 期末大作业选LSTM预测空气质量指数图的就是数据现成、效果直观期末大作业里有一类题目永远不会过时给定一个时间序列预测下一个值。空气质量指数AQI预测就是这个套路的典型——数据公开、背景好讲、结果能用一张折线图看出来。基于LSTM做这个题目几乎满足了“高分项目”的所有表面要求模型不是简单的线性回归代码能跑出漂亮的拟合曲线报告里能写“循环神经网络捕捉时间依赖”。而且LSTM源码结构稳定调参范围有限哪怕你从没跑过深度学习照着时序分类的例子改成回归也能在两周内交差。这篇文章就把我从数据清洗到出图的完整流程拆给你重点是那些让你“跑通”和“翻车”的参数与边界。2. 先把数据喂明白AQI数据集清洗与时间窗口构造2.1 选对数据集从哪拿数据、要哪些字段常见做法是去中国环境监测总站抓历史日数据或者用UCI的AirQuality数据集、Kaggle上的城市AQI历史数据。我一般会推荐先拿“日期逐小时AQI”这种最干净的结构字段越少越好。如果你找的是UCI那个AirQuality数据集里面有很多传感器的响应值还有大量-200表示缺失清洗起来会多花半天。对于期末大作业用日平均AQI数据就够了。数据上必须处理的几个脏点时间列没解析成datetime、有重复行、AQI有负值或零值。零值在AQI里几乎不可能通常当天设备故障或没数据。处理策略是把时间列设为索引按时间排序然后用插值填缺失。不要直接删行因为时间序列的连续性比“真实”更重要。代码放这里这是个清洗的模板import pandas as pd df pd.read_csv(aqi_hour.csv) # 把时间列解析成pandas的datetime类型 df[time] pd.to_datetime(df[time]) df df.set_index(time).sort_index() # 去重同一时刻可能有重复记录保留第一个 df df[~df.index.duplicated(keepfirst)] # 关键AQI为负或0都算异常置为空再插值 df[aqi] df[aqi].mask(df[aqi] 0) # 线性插值填补缺失值时间索引缺空档可以先用resample补全 df df.resample(H).asfreq() df[aqi] df[aqi].interpolate(methodlinear)这段代码的核心在最后两步resample(H)先把缺失的小时整行补成NaN再做线性插值。LSTM要求序列是等间隔的你如果跳过这一步中间缺了几个小时模型会把两个不相邻的时刻当成相邻输入学到一种不存在的“跳跃关系”。interpolate(methodlinear)会让缺失点由前后两个有效值按时间比例计算对短时间缺测很稳。注意不要用methodpolynomial它对尾部外推会产生过冲。清洗完先看一眼数据量。如果你的历史数据只有两三百天那么单变量预测勉强能跑想要模型学出周期性至少需要两年的日数据或半年的小时数据。这一点直接决定后续窗口大小怎么设。2.2 滑动窗口切分lookback不是随便填的一个数LSTM不直接吃一个AQI值它吃一段连续长度n的历史序列输出下一个值。这个n就是lookback也叫时间步。数值含义是用过去多少步预测下一步。常见做法是先用日数据时取lookback7配合一周周期小时数据取24或48。如果你直接填3或5模型只能看到极短上下文预测曲线基本滞后一天看起来就像把昨天的值平移了一下。如果填得太大比如小时数据lookback168样本数量骤降训练变慢而且模型会专注记忆长周期而忽略近期突变。切分代码是这题的骨架建议写成独立函数import numpy as np def create_sequences(data, lookback): X, y [], [] for i in range(len(data) - lookback): X.append(data[i: i lookback]) y.append(data[i lookback]) return np.array(X, dtypenp.float32), np.array(y, dtypenp.float32) # 归一化后的aqi序列 values df[aqi].values.reshape(-1, 1) X, y create_sequences(values, lookback24) print(X.shape, y.shape) # (样本数, 24, 1), (样本数,)这里X的形状是(样本数, lookback, 特征数)这个三维结构是Keras LSTM层的标准输入第一维样本、第二维时间步、第三维特征。很多源码在最开始就把维度搞错少了最后那个1LSTM层会报expected ndim3, found ndim2。切分后必须做训练集和验证集的划分。注意不能随机打乱否则泄露未来信息前80%作为train后20%作为val/test。常见做法是固定比例不shuffle。如果数据量少可以保留最后一段作为最终测试前面的训练集内部再划一部分验证集。2.3 归一化用fit_transform和transform分开别一锅炖LSTM内部用tanh和sigmoid做激活输入如果从0到500都有梯度很容易爆炸。把序列压缩到0-1之间是标配。这里有个隐蔽坑归一化必须在切分之前做并且对整段数据同时做。你如果在切分后对训练集和测试集分别做MinMaxScaler两边的缩放基准不一致模型输出反归一化后会出现系统性偏移。代码上有个约定俗成的写法from sklearn.preprocessing import MinMaxScaler # 对整个序列做fit_transform简单但有泄漏风险只适合快速跑通 scaler MinMaxScaler(feature_range(0, 1)) scaled scaler.fit_transform(values) # 切分再取训练集部分 train_size int(len(scaled) * 0.8) train_data scaled[:train_size] test_data scaled[train_size:] X_train, y_train create_sequences(train_data, lookback24) X_test, y_test create_sequences(test_data, lookback24)注意这里有个常见错误create_sequences会在序列内部形成窗口所以测试集是从train_size开始切。如果你直接把scaled[train_size:]作为测试序列它的第一个窗口会包含训练集尾部数据这不算严重问题但严格说会让测试集“看到”一点历史尾巴。更干净的做法是test_data从train_size lookback处开始这样测试集的每个窗口都完全在训练集时间之后。MinMaxScaler和StandardScaler怎么选AQI没有固定上下界但MinMax会把最大值映射到1最小值映射到0适合我们最后要反归一化画图。StandardScaler更适合数据分布偏正态、有明显的均值中心的情况。AQI经常出现极端值用MinMax可以保留极端值的相对关系。一句话做预测、画对比图我无脑用MinMaxScaler。关于归一化还有一个容易被验收老师抓的点训练集和测试集不能放在同一个fit_transform里。严格流程是先在训练集上fit再用同一个scaler去transform测试集。虽然对纯时间序列这么做的话测试集里的最大值如果超过训练集会被截断到1但这是可接受的因为模型从没见过更大的值。反之如果对整个数据集fit等于让测试集参与了归一化的计算数据泄漏会被问死。很多源码为了图省事整段fit答辩时一句“你的归一化用了未来数据吗”就能让分数掉档。我一般会写成# 规范写法先只对训练段fit再用同一个scaler变换测试段 scaler_train MinMaxScaler(feature_range(0, 1)).fit(scaled[:train_size]) scaled_test scaler_train.transform(scaled[train_size:])上面两行二选一建议用下面的规范写法并在注释里说明原因。如果你把fit和transform用混测试集里的异常值会被压缩到边界导致反归一化后的预测曲线出现“顶部削平”的奇怪形状这类现象很容易被评委抓住。另外要提一句差分。AQI序列有明显季节性和逐年趋势严格的时间序列教程会让你做ADF检验、差分转平稳。但我个人经验是LSTM对非平稳的容忍度比ARIMA高得多只要数据量够、归一化做好直接训练也能出不错的结果。做差分多一步操作预测后还要cumsum还原容易在期末赶工的时候算错所以我只在R²为负时才考虑加差分。3. 搭LSTM模型Keras里的网络结构、损失函数与训练参数3.1 模型结构单层LSTM还是多层LSTMDropout放哪为什么非要选LSTM而不是普通RNN普通RNN在反向传播时梯度要连乘多个时间步序列稍长就梯度消失记不住几天前的污染积累。LSTM靠输入门、遗忘门、输出门把梯度流传得更远这在AQI预测里很关键——一个重污染过程往往是前几天污染物不断累积的结果不是只有昨天的值决定的。在Keras里搭模型常见做法是先试单层LSTMunits设32或64。对AQI这种单变量序列单层通常够了。多层LSTM适合数据量大、有复杂非线性的回归但在期末作业里容易过拟合而且训练时间翻倍。我一般这么设第一层LSTM返回序列return_sequencesTrue第二层LSTM返回最终状态然后接Dense。如果你想在报告里写“加深模型提高表示能力”可以这样写from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dropout, Dense model Sequential([ LSTM(64, return_sequencesTrue, input_shape(lookback, 1)), Dropout(0.2), LSTM(32, return_sequencesFalse), Dropout(0.2), Dense(1) ])lookback就是前面选的24。input_shape必须写成(时间步, 特征数)别漏了特征维。return_sequencesTrue表示这一层输出每一个时间步的隐藏状态给下一层LSTM继续计算最后一层LSTM设为False只输出最后一个时间步的状态再接Dense预测标准值。Dropout放在LSTM层之间比例先设0.2如果你发现训练集损失下降很好但验证集很差再逐步加到0.5。输出层Dense(1)不带激活函数因为这是回归问题。如果你在输出层用了sigmoid或relu预测结果会被压缩到0-1或截断到非负导致反归一化后曲线顶端变平。这是很多源码里预测值上限异常的根源。我见过一份“源码”在Dense里写activationrelu预测值低于0的全变成0测试集越大误差越难看。3.2 训练参数batch_size、epochs、learning_rate的起调值compile和fit的参数决定了模型是几分钟收敛还是一晚上跑不完。先给标准起调from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau model.compile(lossmse, optimizerAdam(learning_rate0.001), metrics[mae]) history model.fit( X_train, y_train, validation_split0.2, # 从训练集尾部再切20%作为验证 batch_size32, epochs200, callbacks[EarlyStopping(monitorval_loss, patience15, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience5, min_lr1e-5)], verbose1 )loss用mse或mae。对于AQI回归mae的数值直接是“预测平均偏了几十个点”答辩时更好解释。但在梯度上MSE对小误差更敏锐我推荐lossmsemetrics里加mae这样训练用MSE汇报用MAE。batch_size的影响小batch比如16会让损失震荡剧烈适合正则化但也更慢大batch比如128训练快但容易收敛到尖锐极小值。AQI这种几百到几千样本规模32是折中。epochs不要设成固定的50或100配合EarlyStopping才是正解。上面设200但通常20-50就能触发早停这样报告里还能写“防止过拟合”的细节。learning_rate从0.001开始。如果你发现loss一路不降先试0.01如果loss在开始就变成NaN试试0.0001。Adam自带自适应学习率但这不代表初始学习率随便设它仍然是最容易翻车的参数。一个被很多人忽略的点如果你的数据没归一化learning_rate0.001也会让Loss直接爆炸而归一化之后同样的学习率就安全了。很多“跑不出来”的案例问题不在模型而在数据缩放。3.3 用EarlyStopping和ReduceLROnPlateau两个回调保住验证集这两个回调是“高分源码”和“能跑源码”的分水岭。EarlyStopping监听验证损失连续patience轮没有改善就停止并恢复到最优权重。参数里最容易被忽略的是restore_best_weightsTrue如果不加模型会在最后一轮结束后返回权重而最后一轮往往已经过拟合损失曲线会在末尾上扬。ReduceLROnPlateau在验证损失平台期把学习率减半这样能让模型在临近收敛时跨过鞍点。这两个回调配合基本可以无脑跑。如果加了回调后仍然过拟合检查Dropout比例和训练集大小不要急着加层数。补充一个细节验证集划分。validation_split0.2是Keras从训练数据尾部切不是随机切这在时间序列里是合理的。但注意这个验证集是从训练集尾部切所以你的测试集是更后面的数据。训练过程中看到的loss曲线是模型在“最近一段历史”上的表现不代表未来泛化能力汇报时不要说“验证集loss很低的模型一定好”一定要留一块从未参与训练的盲测段。顺便说一句verbose1。很多源码里写verbose2或省略训练过程刷屏很严重不利于观察。我习惯用verbose1每个epoch显示一行进度条加上loss值既能看到收敛趋势又不至于把答辩屏幕刷爆。4. 评估与可视化预测曲线、误差指标和“看着高分”的细节4.1 评价指标MAE、RMSE、R²怎么算哪个能撑起报告对回归任务报告里至少要出现三个数字MAE、RMSE、R²。MAE单位就是AQI点数直观RMSE对极端误差更敏感能体现模型在重污染日的表现R²表示相对“拿平均值做预测”的改进程度负值说明模型不如均值预测。代码from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score pred model.predict(X_test) pred_flat pred.flatten() mae mean_absolute_error(y_test, pred_flat) rmse mean_squared_error(y_test, pred_flat, squaredFalse) r2 r2_score(y_test, pred_flat) print(fMAE{mae:.2f}, RMSE{rmse:.2f}, R²{r2:.2f})注意这里计算的是归一化后的误差不是真实AQI误差。如果想报告真实误差需要反归一化见4.2。另一个坑mean_squared_error在旧版sklearn里squaredTrue是MSE新版用squaredFalse才是RMSE。如果你不确定直接用np.sqrt(mean_squared_error(y_test, pred_flat))最稳妥。R²有一个反直觉的坑在时间序列的test集上R²往往很高0.9以上其实是因为AQI的连续性昨天值对今天有超强解释力。不要因为这个在报告里过度自夸。反过来如果你做多步预测R²会骤降那也是正常的不要怀疑代码。4.2 反归一化与预测值还原别直接拿缩放后的数画图预测拿到的是0-1之间的数必须用同一套scaler还原成AQI真实值。# inverse_transform要求形状为(样本数, 特征数)所以把pred reshape pred_real scaler.inverse_transform(pred.reshape(-1, 1)).flatten() y_real scaler.inverse_transform(y_test.reshape(-1, 1)).flatten()因为X是三维y_test是二维直接传进去会报维度错。reshape(-1, 1)是常见修法。这里还得注意一个容易错的地方如果你对特征做了差分反归一化只是把缩放还原并没有还原差分需要再做cumsum才能回到原始AQI这一串在期末项目里很容易少写一步。如果没做差分就不需要管。汇报时MAE等指标也应该在真实尺度上计算图表显示的也是真实AQI。有时你会发现归一化空间的RMSE是0.04看起来很小反归一化后变成20多这两个数字都要写清楚老师会追问。4.3 可视化必做的三张图损失曲线、真实vs预测、误差分布第一张图是训练过程中的loss曲线它验证模型有没有正常收敛。第二张图把验证集/测试集上的真实AQI和预测AQI画在同一条时间轴上这是大作业的“门面”。第三张图是误差分布直方图展示预测误差是否集中在零附近。import matplotlib.pyplot as plt # 图1损失曲线 plt.figure(figsize(10,4)) plt.plot(history.history[loss], labeltrain_loss) plt.plot(history.history[val_loss], labelval_loss) plt.xlabel(epoch) plt.ylabel(mse) plt.legend() plt.show() # 图2真实 vs 预测取最后200个点 plt.figure(figsize(12,4)) plt.plot(y_real[-200:], labeltrue, linewidth1) plt.plot(pred_real[-200:], labelpred, linewidth1, alpha0.8) plt.legend() plt.show() # 图3误差分布 errors pred_real - y_real plt.hist(errors, bins30, edgecolorwhite) plt.xlabel(pred error (AQI)) plt.ylabel(count) plt.show()这里有个默认的大坑matplotlib不显示中文。如果图例写“真实值”“预测值”会显示成方块。一般作业要求里图表标签用中文但代码是英文会出现中文乱码。处理办法有三一是全部用英文标签二是设置中文字体plt.rcParams[font.sans-serif][SimHei]并加plt.rcParams[axes.unicode_minus]False三是在报告里用英文字母标记在图中对应。我建议至少把unicode_minus设好否则坐标轴上的负号也会变方块。第二张图要画多长不要画全量几千个点密密麻麻看不出趋势。一般画最近100-200条测试数据就够。如果你想展示“预测跟着真值走”最好选一个包含突变和高值抬升的时间段因为评委最关心的是模型在污染过程里有没有反应。可以加代码只画test_data最后200个点。另外预测曲线通常比真值滞后一点这几乎是LSTM单步预测的物理规律它依赖过去24小时的信息对突变响应会慢。答辩时主动指出这一点比被老师发现然后沉默强很多。5. 避坑指南LSTM预测AQI常见的5个翻车场景5.1 现象预测曲线像“昨天的值平移”滞后一拍有同学跑出来的预测图总是比真实曲线晚一天像是在做“昨天的AQI预测今天”。原因是单步预测本身用过去n步估计下一时刻AQI序列自相关很强模型发现最省力的做法是输出接近最近输入的值。这不算bug但评委常问。解决一个是缩短lookback比如24改成12降低对长历史的依赖另一个更聪明的方式是在画图时把预测序列对齐到“t时刻预测t1”的时间戳上。代码技巧# 预测的是t1画图时把pred往前移一个点对齐 plt.plot(y_real[:-1], labeltrue) plt.plot(pred_real[1:], labelpred_shifted)这样形状就对齐了。如果还有滞后说明模型确实没学好需要回到2.2节调整窗口长度。5.2 现象训练loss降了验证loss先降后爆典型曲线训练损失一路向下验证损失降到某个epoch后掉头向上形成“开口”的形状。最常见的原因有两个一是模型容量过大记住了训练集中的噪声二是归一化泄漏——你用了整段数据做fit_transform验证集的信息已经参与过训练。解决先改掉泄漏把缩放器只fit在训练段上代码见2.3。如果是纯过拟合把Dropout从0.2提到0.4降低units或者加EarlyStopping并保持restore_best_weightsTrue。还有一种容易被忽略的情况validation_split0.2切出来的验证集恰好落在AQI高值段loss自然波动大。这时候手动切一个和训练集分布接近的时间块来当验证集别让Keras随机切。5.3 现象R²为负模型还不如“拿历史均值预测”测试集R²算出-0.3说明模型预测的平方误差比“每个测试点都预测成训练集均值”还大。常见于数据量太少、训练集和测试集分布差异大或者模型没训练充分就早停。解决先打印训练集和测试集的均值、方差如果差异很大数据分布发生了漂移考虑用更早的一段做测试。然后检查训练loss是否还在下降如果EarlyStopping触发太早把patience从15调到25。最后建一个朴素基线用昨天AQI作为今天预测计算它的R²。如果LSTM连这个都打不过一定是代码里有bug最大的嫌疑是X_test和y_test没有对齐错位一行导致全盘皆输。5.4 现象loss一开始就NaN或卡在0.01不动训练日志里loss直接变成NaN或者几十轮都停在同一个值。先别怀疑模型结构大概率是数据里还有NaN。插值之后也可能存在边界残留比如首尾没数据可插插完仍是NaN。解决在训练前加一行断言assert not np.isnan(X_train).any(), X_train contains NaN assert not np.isnan(y_train).any(), y_train contains NaN如果断言触发回到清洗阶段用df[aqi].ffill().bfill()把首尾填掉。学习率过高也会导致NaN尤其是没归一化时。把learning_rate降到0.0001或0.0003可以稳定。5.5 现象每次运行结果都不一样源码不可复现同一个代码跑两遍预测曲线和图不一样答辩时老师随机指定一个epoch结果就对不上。原因是神经网络初始化、shuffle、GPU算子都有随机性。解决在所有代码的最开头固定随机种子import random, numpy as np, tensorflow as tf random.seed(42) np.random.seed(42) tf.random.set_seed(42)在CPU上这个设置基本能保证可复现。如果你用GPU且结果仍波动把model.fit里的shuffleFalse也加上因为时间序列本来就不该随机打乱。如果是答辩演示更稳妥的做法是用model.save(aqi_lstm.h5)保存权重到现场直接加载效果一定和报告一致。6. 进阶多变量输入与多步预测让大作业从“良好”到“优秀”6.1 从单变量到多变量把PM2.5、PM10、温湿度都喂进LSTM单变量AQI预测只能看到结果看不到污染来源。如果数据里有PM2.5、PM10、SO₂、NO₂、CO、O₃等监测项把它们一起作为特征输入X的形状就从(样本, lookback, 1)变成(样本, lookback, features)。归一化时对每一列分别做MinMaxScaler预测目标仍然是AQI。这个改动能让MAE下降20%-30%报告里也能多写一段“多污染物协同预测”的亮点。# features是一个二维数组每列对应一种污染物 scaler_x MinMaxScaler().fit(features[:train_size]) X_all scaler_x.transform(features) # 构造序列时data直接传X_all形状为(样本, lookback, n_features)6.2 多步预测递归预测和直接多输出期末作业做到单步预测已经能拿80分想再进一步就是预测未来24小时。常见做法有两个递归预测把每一步预测出来的AQI拼到输入尾部继续预测简单但要承担误差累加直接多输出把Dense神经元改成horizon个让模型一次输出未来h步训练更快但各步之间的相关性差。我一般建议用递归预测因为和单步模型的代码兼容答辩时还能讲“模型误差会随时间累积”这个现象背后的原理。6.3 盲测验证用最后一段数据假装“未来”我自己的血泪经验曾经做项目时贪图方便把清洗后的全部数据一起归一化训练和测试共享同一套缩放参数。结果测试集R²高得离谱答辩时老师问“你的测试集最大值是不是大于训练集”当场翻车。后来养成了习惯——先划分时间区间再分别fit/transform测试段只当做“从未见过”的数据。演示时你可以把最后两周的数据完全留出来训练时不碰最后用这段画预测曲线这个盲测图的说服力比任何指标都强。希望这些边界和坑能帮你少走几步弯路。本文还有配套的精品资源点击获取