简介:面向Python机器学习与数据挖掘实践的一套完整LSTM空气质量预测与可视化项目,适合作为高校期末大作业、毕业设计或课程实训参考,项目曾通过学术导师评审并获99分评价。系统覆盖时间序列数据归一化、多层LSTM特征学习、全连接层输出及Matplotlib/Seaborn可视化,典型流程包含构造时间窗口训练集、堆叠LSTM层、观察损失曲线并完成测试集评估。压缩包内含312个文件,大小约6.98MB,核心部分包括15个Python脚本、15个pyc编译文件、2份CSV示例数据集、SQLite3数据库,以及大量用于前端图表展示的scss/js/css样式与HTML页面;scss/css搭建仪表板外观,js与HTML承载交互视图,csv与sqlite3提供训练数据和持久化结果,配套依赖说明与目录文档便于直接部署运行。已有58人浏览学习,适合希望深入理解LSTM在环境时序预测中落地流程的学习者;通过调整网络层数、超参数与优化器,还可进一步适配不同的空气质量预警场景。
1. 一个把 Air Quality 项目跑通的关键:LSTM 预测与可视化缺一不可
做过空气质量预测的人都清楚,数据拿到手时看似整洁,真到建模环节全是隐蔽的坑——时间戳错位、缺失值、归一化顺序错误,任何一个都能让你的 LSTM 预测曲线变成一根水平线。这套基于 LSTM 的空气质量数据预测与可视化分析系统,是 Python 方向课设里少见的完整闭环:从两个数据文件到网络训练脚本,再到三个前端可视化页面,每一环都有对应代码,课题评审综合得分 99 分。它的价值不在于网络结构有多新,而在于把“时序预测”这件新手容易翻车的事,拆成了可照抄的流水线。适合期末大作业、毕设参考,以及想搞懂 LSTM 落地流程的数据分析入门者。
2. 项目底座:从两个 CSV 与三个 HTML 反推这套系统的结构
2.1 从文件清单看架构:训练脚本、数据文件与前端页面的分工
拿到项目压缩包后,先别急着找.py文件,把视野放大到整个目录才更容易看清它的骨架。压缩包里有几个很有辨识度的文件:pm25.csv、t_pm25.csv、current.html、analysis.html、provinces.html,还有一串material-dashboard.css、material-dashboard.min.css之类的前端资源文件。
前两个 CSV 是数据层,负责给模型提供原料;三个 HTML 是展示层,负责把预测结果和污染形势用图表呈现给用户;中间那层,也就是 LSTM 训练与推理的 Python 代码,得自己在 IDE 里打开看。没有把训练脚本命名为main.py这种一眼即知的入口,说明作者默认使用者在 Jupyter Notebook 或 PyCharm 里逐个单元格执行——这是课设项目的常态。
Material Dashboard 是一套基于 Bootstrap 的开源后台模板,蓝紫配色、左侧导航栏,material-dashboard.css被引用这件事至少说明两点:作者重视界面观感,而不是停留在“用 Matplotlib 画个图就交差”;前端的质感在评委那里是加分的。
2.2 理解 PM2.5 时序数据:字段含义与两个 CSV 的差异
两个 CSV 文件名很接近,但作用大概率不同。以国内环境监测数据的通用规范来看,字段通常包含这几类:
| 字段 | 含义 | 单位 | 数据类型 |
|---|---|---|---|
| date / timestamp | 监测时间,小时级或日级 | 时间戳 | datetime |
| PM2.5 | 细颗粒物浓度 | μg/m³ | float |
| PM10 | 可吸入颗粒物浓度 | μg/m³ | float |
| SO₂ | 二氧化硫浓度 | μg/m³ | float |
| NO₂ | 二氧化氮浓度 | μg/m³ | float |
| CO | 一氧化碳浓度 | mg/m³ | float |
| O₃ | 臭氧浓度 | μg/m³ | float |
| AQI | 空气质量指数 | 无量纲 | int |
pm25.csv倾向于承载多维原始数据,也就是上面这种完整字段;t_pm25.csv里的t_前缀,常出现在时间序列(time series)或训练集(training)的场景里,它大概率是把pm25.csv清洗之后整理出的两列表格:一列时间、一列 PM2.5 浓度值,直接被训练脚本读取。这种“原始数据 + 干净数据”的双文件做法值得借鉴,它把数据预处理和模型训练解耦了,换一份数据时不必重写全流程。
2.3 HTML 可视化页面:这三个大屏分别解决什么问题
current.html从命名看是当前状态页,适合值班看板场景——页面上一般展示最新时刻的 AQI 数值、主要污染物卡片、空气质量等级色块,再配一个雷达图或仪表盘。analysis.html是历史分析页,重点承载时间序列趋势线、移动平均线、LSTM 预测与真实值的对比图,读者评估模型效果主要看这一页。provinces.html则是省份维度的地域对比,用柱状图或地图热力展示不同城市的污染排名,属于宏观视角。
在课设答辩时,这套组合的展示逻辑非常顺:先讲当前空气质量怎么样,再讲历史趋势和预测走向,最后给一个地域横向对比。评委很容易理解你的工作内容。三个页面共用一套 Material Dashboard 样式,菜单切换起来是一个整体,不会给人“东拼西凑”的感觉。
3. 数据预处理与 LSTM 建模:滑动窗口、归一化、网络选型,一步不落
3.1 数据清洗与缺失值处理:先填坑,再谈预测
时序数据最常见的两个问题,一是时间戳不连续,二是浓度值存在缺失。处理顺序千万别搞反:先排序去重,再做缺失值填充。排序是为了保证时间序列的自回归顺序,如果原始文件按监测站上报顺序记录,时间戳可能是乱序的。
import pandas as pd import numpy as np df = pd.read_csv('pm25.csv', parse_dates=['date']) df.sort_values('date', inplace=True) df = df.drop_duplicates(subset='date').reset_index(drop=True) df['pm2_5'] = df['pm2_5'].ffill() df.loc[df['pm2_5'] < 0, 'pm2_5'] = np.nan df['pm2_5'] = df['pm2_5'].bfill()ffill()是前向填充,用上一个有效值填补缺口,它不借助任何未来信息,对时间序列来说是安全的。我一般不用interpolate()做线性插值,因为 LSTM 训练时输入输出的自回归结构容易被“伪造的中间值”带偏;前向填充保留的是真实观测值,最多只是把上一次浓度值沿用了一小段时间。bfill()只用来处理表头缺失的极端场景,比如序列一开始就是空值。浓度小于 0 的异常值置为 NaN 再填充,这是物理常识筛选,PM2.5 不可能是负数。
3.2 归一化:为什么选择 MinMaxScaler 而不是 StandardScaler
LSTM 内部使用 tanh 激活函数,tanh 对输入数值范围极其敏感。PM2.5 浓度经常出现几十到几百的大幅波动,如果不归一化,梯度在反向传播时容易把网络权重推到饱和区。项目里常见的做法是使用 MinMaxScaler 把数据压缩到 0 到 1 之间。
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) train_size = int(len(df) * 0.8) train_data = df['pm2_5'].iloc[:train_size].values.reshape(-1, 1) test_data = df['pm2_5'].iloc[train_size:].values.reshape(-1, 1) scaler.fit(train_data) train_scaled = scaler.transform(train_data) test_scaled = scaler.transform(test_data)这里reshape(-1, 1)很关键,MinMaxScaler 要求输入是二维数组。我曾经见过有人直接传一维 Series 进去,报错后把数据转成列表硬凑,最后反归一化时全乱套。fit只能作用在训练集上,这一点放到第 5 章展开讲,它属于典型的“看着没毛病、跑起来翻车”的环节。选择 MinMaxScaler 而不是 StandardScaler,是因为标准化后的数据可能产生负数和超出 1 的数值,不符合 tanh 的输入习惯,训练收敛速度会明显变慢。
3.3 构建监督学习样本:时间步长 look_back 怎么定
LSTM 不会自己“记住”历史,必须把最近 look_back 个时间步组织成一条样本输入。这一步是时序预测的核心,很多人一开始不理解为什么预测要用过去 24 个小时的数据拼成矩阵。
def create_dataset(data, look_back=24): X, y = [], [] for i in range(look_back, len(data)): X.append(data[i - look_back:i, 0]) y.append(data[i, 0]) return np.array(X), np.array(y) X_train, y_train = create_dataset(train_scaled, look_back=24) X_test, y_test = create_dataset(test_scaled, look_back=24)look_back=24的直觉来源是:空气质量按小时采样,24 恰好覆盖一天,模型能看到完整“昨日曲线”再预测此刻浓度。如果数据是日级的,24 就不合适,通常取 7 或 30 对应一周或一月。这个参数建议实验对比,我会在 24、48、72 三档里各跑一轮,对比验证集 loss 再定。构建好之后,X_train的形状是(样本数, 24, 1),三个维度分别对应样本、时间步、特征数。
3.4 搭建 LSTM 模型:每层的作用与参数语义
LSTM 模型在课设里最常见的是双层结构,第一层输出完整序列,第二层只输出最后一个时间步的隐藏状态,再接一个全连接层输出预测值。这样设计的理由是:单层 LSTM 对周期性模式捕捉能力有限,叠加第二层能学到更高阶的时序特征,但层数过多小数据集上必然过拟合,两层是课设场景的甜点值。
from keras.models import Sequential from keras.layers import LSTM, Dense, Dropout model = Sequential() model.add(LSTM(units=64, return_sequences=True, input_shape=(24, 1))) model.add(Dropout(0.2)) model.add(LSTM(units=32, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(units=1)) model.compile(optimizer='adam', loss='mean_squared_error') model.summary() history = model.fit( X_train, y_train, validation_split=0.1, epochs=50, batch_size=32, verbose=1 )逐层解释:第一层LSTM(units=64, return_sequences=True)输出每个时间步的隐藏状态,64 是记忆容量,数值越大拟合能力越强,但参数量也成倍增长;return_sequences=True保证输出的是完整序列,否则第二层收不到中间时间步的信息。中间的Dropout(0.2)随机掐断 20% 的神经元输出连接,防止网络死记训练集。第二层LSTM(units=32, return_sequences=False)只需要最后一个隐藏状态,所以不需要返回序列。最后Dense(units=1)全连接层把 32 维映射到单个预测值。
validation_split=0.1表示从训练集尾部切 10% 做验证,注意它不会打乱时间顺序,这比随机切分更符合时序任务的逻辑。训练集本身已经占了总数据量的 80%,再切 10% 验证,模型实际只看了总数据的 72%,留足了泛化余量。batch_size=32一次喂 32 条样本更新一次梯度,数值越大梯度越平滑但内存开销越高。
4. 把预测结果讲清楚:评估指标、Matplotlib 曲线与可视化大屏的衔接
4.1 训练过程观察:从 Loss 曲线判断模型有没有好好学
训练完第一时间要看history.history里的 loss 和 val_loss。理想状态是两条曲线都持续下降并在末尾趋平,两者间距不大。如果训练 loss 低但验证 loss 反弹,就是过拟合;如果两条线都在高位震荡,多半是数据预处理出了岔子。
import matplotlib.pyplot as plt plt.figure(figsize=(10, 4)) plt.plot(history.history['loss'], label='Train Loss') plt.plot(history.history['val_loss'], label='Validation Loss') plt.xlabel('Epoch') plt.ylabel('MSE Loss') plt.legend() plt.grid(alpha=0.3) plt.savefig('training_loss.png', dpi=150)我习惯把这张图存下来放进课设报告里,它是评委判断你“是否真正训练过模型”最直接的证据。Jupyter 里直接显示的话,答辩现场不方便来回翻,保存成文件后可以随时插入文档。
4.2 评估指标:RMSE、MAE 与 R² 的计算口径
模型预测出来的是归一化数值,直接拿来算误差毫无物理意义。必须先反归一化,再用原始浓度单位计算指标。
pred_scaled = model.predict(X_test) pred = scaler.inverse_transform(pred_scaled).flatten() real = scaler.inverse_transform(y_test.reshape(-1, 1)).flatten() rmse = np.sqrt(np.mean((real - pred) ** 2)) mae = np.mean(np.abs(real - pred)) ss_res = np.sum((real - pred) ** 2) ss_tot = np.sum((real - np.mean(real)) ** 2) r2 = 1 - ss_res / ss_tot print(f"RMSE: {rmse:.2f} μg/m³, MAE: {mae:.2f} μg/m³, R²: {r2:.3f}")RMSE 对偏大的误差更敏感,适合空气质量这种偶发重污染的序列,因为最大误差往往来自极端污染事件;MAE 更稳健,两者结合看才能判断模型是普遍偏差还是个别点离谱。R² 的解释性最强,在 0 到 1 之间,越接近 1 说明模型解释了越多的方差变化。课设报告中这三个指标一放,模型评价部分基本就站住了。
4.3 可视化输出:Matplotlib 对比图与前端大屏的交接方式
Matplotlib 适合生成报告插图,但真正演示时要靠 HTML 页面。两个环境的数据交接,常见做法是把预测结果导出成 JSON 文件,由前端 ECharts 读取。
import json output_data = { 'dates': df['date'].iloc[train_size + 24:].astype(str).tolist(), 'actual': real.tolist(), 'predicted': pred.tolist() } with open('prediction_data.json', 'w', encoding='utf-8') as f: json.dump(output_data, f, ensure_ascii=False, indent=2)ECharts 在 HTML 里通过fetch('prediction_data.json')拉取数据后渲染折线图,这个过程不依赖后端服务,直接双击 HTML 文件就能在浏览器里演示,课设答辩现场非常省心。
current.html对应的是实时看板,展示最新时刻 AQI、各污染物浓度和等级;analysis.html承接历史趋势与 LSTM 预测对比图,对应这里导出的 JSON 数据;provinces.html做省份维度横向对比。三个页面共用左侧导航栏,切换时不需要重新打开文件,整个演示流程一气呵成。
5. 避坑手册:归一化泄漏、相位滞后、NaN,三条高频翻车现场
5.1 归一化泄漏:验证集 loss 很低,真实预测却崩了
现象:训练集和验证集上 RMSE 都很漂亮,R² 逼近 0.95,但模型在真实新数据上的预测几乎是一条平坦直线。
原因:偷看了未来。有一段代码把整段数据都用于scaler.fit(),然后再切分训练集与测试集。测试集的最小值和最大值已经参与了归一化参数的估算,相当于模型训练时“间接见过了”测试数据的统计信息。
解决:严格遵循先切分、后拟合的顺序。训练集上执行fit之后,测试集只调用transform,绝对不要再碰fit。检查代码里是否出现对scaler.fit(test_data)的调用,有就立刻删除。
5.2 反归一化形状不匹配:预测值全在 0 到 1 之间
现象:输出的pred数值范围始终在 0 和 1 之间,画出的曲线严重偏离真实浓度值,而real正常。
原因:反归一化时inverse_transform收到的是形状不正确的数组。常见误操作是scaler.inverse_transform(pred_scaled.flatten()),把二维变成一维传给要求二维输入的方法,报错后又想当然地换别的写法绕开。
解决:统一保持reshape(-1, 1)的列向量形状。预测结果pred_scaled本身就是(样本数, 1),直接传给inverse_transform即可;如果是从自定义函数返回的扁平数组,先.reshape(-1, 1)再传。
5.3 相位滞后:预测曲线比真实值晚一步
现象:预测曲线与真实曲线形态几乎一致,但整体向右平移了一个时间步,峰值和谷值对不上。
原因:LSTM 学到的是最朴素的“把上一时刻的值搬过来”——在平滑序列里,时间戳 t 的浓度值与 t-1 差距很小,模型发现直接复制上一步的误差最小,就不再去学习真正的时间依赖结构。这是一个经典的时序模型陷阱。
解决:升级损失函数,在 MSE 基础上加入一阶差分惩罚项,强制模型关注变化趋势而不是绝对数值。另外可以尝试增大units到 128,给模型更多容量去记忆模式,但要注意防过拟合。
5.4 梯度爆炸 NaN:loss 曲线突然消失
现象:训练跑到十几轮时,loss 突然变成nan,之后所有指标全部失效。
原因:输入数据里含有无穷大值,或学习率过大导致梯度累加爆炸。空气质量监测设备偶尔输出异常值如9999或Infinity,这类脏数据在归一化后会变成一个极端数值,LSTM 在长序列传播时会把这种异常值放大到溢出。
解决:在数据清洗阶段用df.replace([np.inf, -np.inf], np.nan)强制清除无穷值;同时给优化器加梯度裁剪。Adam 优化器的clipnorm参数直接限制梯度范数。
from keras.optimizers import Adam model.compile( optimizer=Adam(learning_rate=0.001, clipnorm=1.0), loss='mean_squared_error' )clipnorm=1.0的含义是:如果梯度的 L2 范数超过 1.0,就等比缩放回来。这个值在课设规模的数据集上足够宽松,不影响正常收敛,又能挡住极端梯度的冲击。
5.5 训练后期 loss 不再下降:卡平台期
现象:前 20 轮 loss 稳步下降,到 30 轮以后进入平台期,怎么调 epochs 都没用。
原因:学习率固定为 0.001,后期参数更新步长相对最优解附近的地形已经过大,产生“震荡式蜗行”。这是固定学习率的通病。
解决:用回调动态降低学习率。ReduceLROnPlateau 会在验证指标不再改善时自动把学习率减半,配合 EarlyStopping 防止白白空跑。
6. 进阶玩法:单步改多步、早停与学习率调度的组合拳
项目默认是单步预测,即用过去 24 小时预测下一小时的浓度。如果你想把课设做成“未来 24 小时空气质量预警”,需要同时改三个地方:标签生成、输出层结构、损失函数含义。标签生成上,把create_dataset里的y从单值改成向量:
def create_multistep_dataset(data, look_back=24, horizon=24): X, y = [], [] for i in range(look_back, len(data) - horizon + 1): X.append(data[i - look_back:i, 0]) y.append(data[i:i + horizon, 0]) return np.array(X), np.array(y)输出层从Dense(1)改成Dense(horizon),因为模型需要同时输出 24 个未来时刻的预测值。评估阶段对每个时间步分别计算 RMSE,再取平均值,避免只看一条曲线产生误判。
训练阶段,回调函数是提高训练质量的捷径:
from keras.callbacks import EarlyStopping, ReduceLROnPlateau early_stop = EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True ) lr_scheduler = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=0.00001 ) model.fit( X_train, y_train, validation_split=0.1, epochs=100, batch_size=32, callbacks=[early_stop, lr_scheduler] )patience=10的意思是最多容忍验证集连续 10 轮不改善,超了就恢复到历史最优权重;这个参数设太大会让训练过程冗长,设太小又容易在早期误杀。min_lr=0.00001是学习率下限,防止调度器把学习率压到过于微小而失去学习能力。我一般从 epochs=100 起步,早停会自动截断,实际跑满 100 轮的情况很少见。
这套组合同样能用在单步预测上,它改变的是训练质量和收敛效率,不是任务定义。做过一次多步改造之后,你会更理解单步模型的局限在哪里。
有一回我熬夜跑一个 72 小时多步预测实验,验证集 loss 降到很低,结果画出曲线一看,24 小时之后的预测值全部汇聚到一条平线——模型根本学不会长期趋势。从那以后我每次拿到时序项目,第一步永远是画出序列的自相关图审视周期长度,第二步检查归一化切分顺序,第三步才碰网络结构。这套流程救过我太多次。希望帮到你。
本文还有配套的精品资源,点击获取