简介:这是一份基于LSTM的空气质量指数预测Python源码项目,专为高校期末大作业、课程设计与毕业设计打造的高分参考,适合已有Python基础、希望快速落地完整预测系统的学习者。项目代码包含详尽注释,经严格调试可直接运行,曾被导师评为98分。压缩包共2000个文件,大小约29.33MB,其中1989个CSV文件存放北京等城市空气质量历史数据,可作为训练与验证数据集;4个Python脚本覆盖数据预处理、LSTM模型构建、训练及评估流程,另含5个XML配置文件和1份说明文档,结构清晰便于学习。目前已有222人学习下载。通过该资源,读者既能了解LSTM处理时序预测问题的完整思路,也可直接部署使用,作为课程答辩或毕业设计的演示成果,系统功能完善、操作简单,具有很高的实用价值。
1. 为什么是这题:期末大作业里的LSTM空气质量预测到底难在哪
期末大作业选「基于LSTM的空气质量指数预测」是个看起来很稳、实际容易翻车的选题——网上源码一抓一大把,但能直接跑通的不多;跑通了的,又多半停留在「抄过来改个数据集」的程度,答辩时被导师追问一句「你滑窗窗口为什么取12」就卡壳。这份项目真正要解决的问题不是「预测得准」,而是把一个时序预测问题从头到尾做到自圆其说:异常值要不要剔、归一化怎么做才不泄漏、时间步长取多少、损失函数用MSE还是Huber、指标看MAE还是R²——每一环都得有说法。适合的是有Python基础、想用一份结构完整且能讲清原理的代码拿下课程高分的人,也适合第一次接触LSTM、想知道「模型到底在学什么」的人。后面所有内容都围绕一个目标:交上去的工程能复现、答辩问不倒、改参数不慌。
2. 先把地基打好:AQI数据预处理与LSTM选型背后的三个理由
2.1 为什么是LSTM而不是RNN或普通全连接网络
空气质量指数是一个典型的时间序列,今天的污染状况和过去几天甚至几周的气象条件、排放变化都有关联。普通全连接网络把每个时刻的数据当独立样本处理,丢掉了时间顺序;传统RNN虽然引入了循环结构,但在处理超过10~15步的序列时,梯度很容易指数级衰减或爆炸,出现「远期记忆丢失」的问题。
LSTM(长短期记忆网络)在RNN的基础上加入了遗忘门、输入门和输出门。这三个门控机制让信息可以选择性通过:遗忘门决定上一时刻的哪些记忆要丢弃,输入门决定当前时刻的新信息有多少写入记忆,输出门决定最终的隐藏状态如何输出。简单说,LSTM给网络增加了一条可以长距离传输的「记忆通道」,这让它天然适合AQI这种带有周期性和滞后效应的数据。
做期末项目时还有个现实的理由:LSTM在Keras/TensorFlow里的实现非常成熟,一个tf.keras.layers.LSTM层加上前向填充的数据预处理,几十行内就能搭建一个可用模型。相比自己实现RNN的反向传播或者去调Transformer,LSTM的调试成本和算力要求都低得多,这在期末时间紧、显卡可能还是共享的情况下很重要。
2.2 数据清洗:缺失值、异常值处理的两个关键动作
大多数公开的空气质量数据集都有原始站点观测的格式问题:某几天站点检修导致数据为空、传感器漂移产生极大极小异常值、不同城市的时间戳对齐方式不一致。第一次做这个题目的人最常见的操作是df.dropna()一把梭,但这样做有两个隐患:一是缺失率超过2%时,删除会导致时间序列出现断裂,LSTM在滑窗时把这些断裂也当成了有效序列;二是删掉的时间点会破坏相邻时刻的依赖关系,预测结果在断裂处会突然跳动。
我一般会用这样一套清洗流程:
import pandas as pd import numpy as np df = pd.read_csv('air_quality.csv', parse_dates=['date']) df = df.set_index('date').sort_index() # 先看缺失率,而不是直接删 missing_rate = df.isnull().mean() print(missing_rate[missing_rate > 0]) # 缺失率低于5%的列用前向填充,再配合线性插值修正 df = df.fillna(method='ffill').interpolate(method='linear') # 异常值处理:用滚动窗口的Z-score方法,而不是全局Z-score from scipy import stats def remove_outliers(series, window=24, threshold=4): mean = series.rolling(window, center=True).mean() std = series.rolling(window, center=True).std() z_score = (series - mean) / std return series.mask(abs(z_score) > threshold) df['AQI'] = remove_outliers(df['AQI'])这段代码里的两个关键点:ffill后接interpolate是为了避免连续缺失时出现一大段相同值——LSTM会把「长时间不变化」误学习为「平稳期」;异常值检测用的是滚动窗口内Z-score而不是全局Z-score,原因是AQI本身有季节性波动,冬天的300可能正常,夏天的300可能就是异常,全局统计会把这些合法波动误杀。
注意:清洗操作必须在滑窗之前完成。先滑窗再清洗会导致某些窗口内部出现空值,Keras虽然能接受
NaN作为输入,但在反向传播中会产生NaN梯度,训练过程直接发散。
2.3 滑窗与归一化:让模型真正学会时间依赖的最小步骤
LSTM输入必须是(样本数, 时间步长, 特征数)的三维张量。把连续的AQI序列变成这种结构,靠的就是滑窗。窗口大小look_back的选取是第一个关键超参——它代表模型每次看多长的历史来预测下一时刻。
def create_dataset(data, look_back=12): X, y = [], [] for i in range(len(data) - look_back): X.append(data[i:i + look_back]) y.append(data[i + look_back]) return np.array(X), np.array(y) look_back = 12 X, y = create_dataset(df['AQI'].values.reshape(-1, 1), look_back) # 划分数集:严格按时间顺序,不做随机打乱 train_size = int(len(X) * 0.8) X_train, X_test = X[:train_size], X[train_size:] y_train, y_test = y[:train_size], y[train_size:] from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) # 关键:先reshape成二维再fit,避免按单个时间点归一的坑 X_train_flat = X_train.reshape(-1, 1) scaler.fit(X_train_flat) X_train_norm = scaler.transform(X_train.reshape(-1, 1)).reshape(X_train.shape) X_test_norm = scaler.transform(X_test.reshape(-1, 1)).reshape(X_test.shape) y_train_norm = scaler.transform(y_train.reshape(-1, 1)).flatten() y_test_norm = scaler.transform(y_test.reshape(-1, 1)).flatten()这里最容易犯错的是归一化时机。很多人习惯先把全量数据做scaler.fit_transform,再切分训练集和测试集——这在学术上叫数据泄漏,因为测试集的信息在训练阶段就被模型看到了,导致验证指标虚高,答辩时老师一眼就能看出来。正确的做法是:scaler只在训练集上fit,测试集用同一个标度做transform。
窗口大小look_back=12的含义是「用过去12小时的AQI预测下一小时」,这是一个经验性起点。如果你处理的是日均数据而不是小时数据,12天历史可能太短,可以尝试24或者7×24(一周周期);如果你的数据有明确的周末效应,窗口至少应该覆盖7天。这个参数后面要单独做敏感性分析,这是拿高分的关键铺垫。
3. 从零到一搭模型:TensorFlow/Keras下的LSTM训练完整代码
3.1 模型结构设计:层数、神经元数量怎么定
单层LSTM是期末作业的主流选择,不是因为双层不好,而是你在答辩时要把「为什么加第二层」讲清楚。我通常的建议是:先用单层LSTM加一个Dense输出层跑通基线,再在进阶部分比较单层和双层的效果差异,用数据说明问题。
神经元数量的选择有玄学成分,但有个可行策略:输入的特征数乘4到8倍,再向上凑个整数。如果只用AQI单变量预测,units=64就够;如果后面扩展成多变量输入(PM2.5、PM10、温度、风速),units=128更稳妥。
import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau tf.random.set_seed(42) np.random.seed(42) model = Sequential([ LSTM(units=64, return_sequences=False, input_shape=(X_train_norm.shape[1], X_train_norm.shape[2])), Dropout(0.2), Dense(16, activation='relu'), Dense(1, activation='linear') ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss='mse', metrics=['mae'] ) model.summary()两个容易讲不清的细节:一是return_sequences,单层LSTM输出的是最后时刻的隐藏状态,设置为False即可;只有当你再接一层LSTM时,第一层必须返回完整序列。二是Dropout放在LSTM层之后而不是之前——Keras的LSTM本身有dropout参数,但它是作用在循环连接上的,和标准Dropout的含义不同,期末答辩容易混淆。
3.2 训练脚本:compile、fit和三个回调函数的完整写法
训练环节最影响分数的是「你怎么证明训练是收敛的」。不要只贴一行model.fit,要加上早停和学习率衰减,这样在答辩时面对「如果loss一直不下降怎么办」这类问题才有话可说。
early_stop = EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=1e-5 ) history = model.fit( X_train_norm, y_train_norm, batch_size=64, epochs=100, validation_split=0.1, callbacks=[early_stop, reduce_lr], verbose=1 ) model.save('lstm_aqi_model.h5')patience=10的含义是连续10轮验证集loss没有下降就停止训练,这能防止过拟合;restore_best_weights=True保证最终保存的是验证集上表现最好的那组权重,而不是最后一轮的权重——很多初版代码忘了这个参数,导致结果忽好忽坏。ReduceLROnPlateau在验证loss连续5轮不降时自动把学习率减半,这比手工调学习率稳定得多。
batch_size的选择和你的数据量相关。小时级数据三年大约26000条,滑窗后大约25988个样本,batch_size=64是合理区间。如果数据集更小(比如只有半年),建议用32。validation_split=0.1会在训练集内部切出10%做验证,注意它不影响之前已经切分好的测试集。
3.3 预测与反归一化:结果怎么还原到真实尺度
训练完成后的预测环节是翻车重灾区。model.predict输出的是0到1之间的归一化值,直接拿去画图,曲线形状没错,但坐标轴是错的;更隐蔽的是inverse_transform的形状问题——MinMaxScaler要求输入二维(样本数, 1),而模型输出是(样本数, 1)的二维数组,直接转回一维再还原时容易把形状弄混。
y_pred_norm = model.predict(X_test_norm) # 反归一化前先保证shape是二维 y_pred = scaler.inverse_transform(y_pred_norm.reshape(-1, 1)).flatten() y_true = scaler.inverse_transform(y_test_norm.reshape(-1, 1)).flatten() # 计算评价指标 from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score mae = mean_absolute_error(y_true, y_pred) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) r2 = r2_score(y_true, y_pred) print(f'MAE={mae:.2f}, RMSE={rmse:.2f}, R2={r2:.3f}')这里写y_pred_norm.reshape(-1, 1)是必需的,因为scaler.inverse_transform要求输入特征数和fit时一致——你fit的是(n,1)的数据,输入就必须是(n,1)。如果直接传入(n,)的一维数组,部分版本的sklearn会报错,另一部分会静默算出错误结果。
一个更值得关注的细节是「预测曲线整体右移一格」的现象。这是因为我们的任务是「用过去12小时预测下一小时」,预测值和真实值天然差一个时间步。画图对比时应该把预测曲线整体左移一位,或者改用「用过去12小时预测未来6小时」的多步预测来回避这个视觉错位。这个问题放在第5章讲,但你在答辩前一定要自己先想明白,因为它是被问频率最高的问题之一。
4. 期末大作业血泪避坑:五个让LSTM预测跑飞的真实案例
4.1 现象:预测曲线贴着真实值但整体右移一格
第一次跑通模型时看到预测曲线和真实曲线几乎重合,很多人会兴奋地截图发给导师。但仔细对比会发现,预测曲线像是真实曲线向右平移了一个时间单位——这不是模型学得好,而是模型学会了「把上一时刻的值输出出来」。
原因:当look_back较小且数据自相关性较强时,LSTM可以退化成「拷贝上一时刻」的捷径解法,这本质上是欠拟合的表征。解决:把look_back从12增大到24或48,并对比MAE变化;更彻底的做法是改成多步预测,比如用过去48小时预测未来12小时的均值,任何「移动一格」的捷径策略在多步预测中都会暴露。
4.2 现象:训练集loss正常下降,测试集指标却差到离谱
训练过程中训练集MSE降到0.01以下,但测试集MAE有几十甚至上百。查看模型在测试集上的具体预测值,会发现输出全部趋于一个恒定值。
原因:归一化时全量数据一起fit_transform造成数据泄漏是其一;其二是AQI数据存在极端值(比如某天突发的沙尘暴使AQI超过500),MinMaxScaler会把正常值压缩到0~1区间内极小的一段,导致模型学不到区分度。解决:先切分训练集再分别归一化;对异常极端值在清洗阶段就用滚动Z-score剔除或用上限截断。如果极端值确实代表真实污染事件,则改用RobustScaler,它对离群点更鲁棒。
4.3 现象:模型每次跑出来的结果都不一样,时好时坏
同一份代码跑三次,三次的MAE分别是18、26、22——同学之间交换代码复现时结果也对不上。
原因:神经网络初始化是随机的,Dropout也有随机性;TensorFlow在GPU上的算子本身还存在非确定性。期末作业要看可复现性,这是评分维度之一。解决:在代码开头固定随机种子,固定tf.keras.utils.set_random_seed(42),同时设置tf.config.experimental.enable_op_determinism()(注意它会降低训练速度,但结果可精确复现)。固定种子后再跑三次,指标应该完全一致。
4.4 现象:loss在训练中期卡住不动,像是进入了平台期
训练到第20轮左右,loss卡在0.02附近,无论怎么增大epochs都不下降。
原因:最常见的是学习率设置过大或过小——过大时loss会在最小值附近震荡但无法进入更优区域,过小时前期下降极慢,看起来就像卡住。其次可能是滑窗构造的样本内部存在大量重复模式,模型已学到数据中所有规律。解决:给Adam设置初始学习率0.001并接入ReduceLROnPlateau让它在平台期自动减半;如果减半后仍无变化,检查训练数据是否存在长时间重复序列(比如周末数据整体缺失被前向填充填平了)。
4.5 现象:预测值出现负值或超过500的离谱数值
AQI理论上不会为负,但模型输出的反归一化值出现了-35或者700这样的数值。
原因:Dense输出层的激活函数是linear,模型神经元的输出理论上可以是任意实数,拟合误差稍大时就会超出物理范围。这不是模型坏掉了,是范围外推问题。解决:输出层改为sigmoid激活后再反归一化,放缩到0~1之间再映射回原始范围;或者在后处理中加一个np.clip(pred, 0, 500)强制截断。答辩时也可以主动提一句「物理约束后处理」,这在很多工程实践中确实是标准操作。
5. 从完成到高分:把LSTM预测做成一等项目的四个花絮技巧
基线模型跑通只是及格线。想把分数从B+推到A,需要在「工作量」和「系统性」上做文章。下面四个技巧按投入产出比排序,你可以根据剩余时间挑选组合。
第一,把单变量改成多变量输入。只用AQI历史值预测未来,模型只能学到时间自相关,本质上还是在「外推一条曲线的形状」。加上PM2.5、PM10、O₃、风速、相对湿度后,模型学到的是污染物生成和扩散的物理关系,预测误差通常能下降15%~30%。做法很简单:create_dataset函数改成接收多个列,特征维度从1扩展到5~7。这里有个小坑——不同变量的量纲差异很大,风速是个位数,AQI是三位数,MinMaxScaler必须按列分别fit,不要混在一起拟合。
第二,做一张「超参数敏感性分析表」。选3个超参数——look_back取6/12/24/48,units取32/64/128,batch_size取32/64/128——各跑一组固定其他参数的控制变量实验,用表格列出每组实验的MAE、RMSE、R²和训练时长。导师看到这张表,大概率会认为你理解每个参数的意义。表里的注释部分写清楚「当look_back从12增到48时MAE先降后升,说明过长的历史引入了噪声」,这一句话就能体现你对模型边界条件的理解。
第三,加一组「基线对比模型」。拿同样的训练数据跑一个线性回归(把过去12小时的值展平作为特征)和一个简单ARIMA模型,在测试集上比较三个模型的MAE和R²。LSTM的效果可能只比线性回归好一点,这反而是个好的讨论点——说明该数据集中线性趋势占主导,LSTM的优势体现在捕捉非线性突变。诚实呈现对比结果比只吹LSTM好得多,评委对「刻意美化结果」非常敏感。
第四,图表的完成度。别只画一张预测折线图。至少准备三张图:训练集的loss曲线和验证集loss曲线在同一张图里对比;测试集真实值vs预测值的曲线,并标注出预测误差最大的3个时间点——去查一下那几天的天气,往往能发现是沙尘或大风导致的气象突变;真实值和预测值的散点图加对角线,直观展示是否存在系统性偏差。答辩时每张图背后都要有一句话可以讲:这张图说明了什么、为什么会有这个现象、如果重做会怎么改。
我做这个方向的习惯是:先把单步预测跑到能稳定复现,再加多变量和对比实验,最后才补充图表和注释。很多同学一上来就做双LSTM加注意力机制,结果训练时间拉长、指标反而变差,最后只能靠删掉复杂模块收场。结构完整的基线加上扎实的消融对比,比一个花哨但脆弱的模型拿分更稳。
最后说句实在话:期末大作业的技术深度永远排第二,第一是「经得起追问」。你自己写不出来每个模块为什么这么做,那就算代码跑得再漂亮,答辩也是坐立难安。希望帮到你。
本文还有配套的精品资源,点击获取