简介:面向郑州地区空气质量预测的Python源码,主要服务环境数据分析、机器学习实践者以及相关毕业设计课题,用于解决区域空气质量建模与预测问题。压缩包共20个文件、大小仅652KB,覆盖5个XML配置、4个Python核心源码、5个文本说明、3张PNG图像和1个HDF5权重文件等;其中Python代码依次承担数据预处理、模型构建、训练与预测任务,配合loss.png、model.png等图像,可清晰看到损失变化和模型结构,降低理解门槛。目前已有395人学习,适合希望参考完整工程组织方式并开展空气质量预测实验的读者。通过研读源码、配置与记录,可快速接手LSTM/CNN等时序模型的搭建流程,并依托HDF5历史数据继续调参优化,为毕业设计、课设或科研实践提供可直接复用的基准。
1. 郑州空气质量预测:模型不是第一个该看的文件
接到这份郑州空气质量预测模型的 Python 源码时,我刻意先打开utils.py而不是model.py。原因很简单:郑州国控站点逐小时数据里,SO₂、NO₂、CO、O₃、PM10、PM2.5 经常不在同一个时间戳对齐,哪怕 LSTM 结构写得再漂亮,样本没对齐,预测结果也是废的。这套项目包含了data.txt、x_params_list.txt、y_params.txt和 4 个 Python 文件,正好把“原始浓度读入 → 滑动窗口样本构造 → LSTM 训练 → 滚动预测”这条链路完整落地。适合正在做时间序列预测、想直接拿 Python 改数据管线和模型参数的工程师,也适合想从源码层面理解空气质量预测模型设计的入门者。
2. data.txt 到监督学习样本:滑动窗口与参数列表构造
这份源码里真正决定预测效果上限的不是网络层数,而是utils.py里如何把连续监测数据变成“特征-标签”对。data.txt每一行是一条按时间排序的记录,前几列是污染物浓度和气象因子,数值列之间用逗号分隔;像日期时间这样的文本列,在这个项目里没有混入data.txt,而是单独记录在record.txt里。x_params_list.txt保存了输入列的索引,y_params.txt保存预测目标列的索引,这样的设计让特征选择和训练代码解耦,换一组特征时不需要改动模型主体。
2.1 特征列和目标列:参数列表决定数据对齐方式
常见配置里,x_params_list.txt的内容是0,1,2,3,4,5,6,7,代表从data.txt取第 0 到第 7 列作为输入特征;y_params.txt里写5,表示预测第 5 列 PM2.5。特征含义可以对应这张表:
| data.txt 列索引 | 字段含义 | 在本项目中的角色 |
|---|---|---|
| 0 | SO₂(μg/m³) | 输入特征 |
| 1 | NO₂(μg/m³) | 输入特征 |
| 2 | CO(mg/m³) | 输入特征 |
| 3 | O₃(μg/m³) | 输入特征 |
| 4 | PM10(μg/m³) | 输入特征 |
| 5 | PM2.5(μg/m³) | 输入特征,也是默认预测目标 |
| 6 | 温度(℃) | 输入特征 |
| 7 | 相对湿度(%) | 输入特征 |
如果想预测 AQI 而不是 PM2.5,只要在data.txt末尾追加一列 AQI 数值,再把y_params.txt改成对应列索引即可,模型结构不用动。注意loadtxt读取时如果文件第一行是表头,必须用skiprows=1跳过,否则把所有字段当成数值去解析,数据矩阵整体错位。项目里常见做法是data.txt不带表头,只在record.txt里说明字段顺序,这样省去字符串解析,也避免中文编码问题。
2.2 滑动窗口拼接:把二维监测表变成 LSTM 能吃的三维张量
原始数据整理后是二维矩阵,行是时间点,列是特征。LSTM 需要的输入是三维张量(样本数, 时间步长, 特征数),所以要构造滑动窗口:用前 24 小时的特征预测下一个小时的 PM2.5。这里有一段典型的utils.py逻辑:
# utils.py 中的滑动窗口构造 import numpy as np def load_params(param_path): with open(param_path, 'r', encoding='utf-8') as fp: values = fp.read().strip().split(',') return [int(v) for v in values if v != ''] def make_sequences(x, y, seq_len=24, horizon=1): xs, ys = [], [] total = len(x) - seq_len - horizon + 1 for i in range(total): # 取连续 seq_len 个时间步作为输入 xs.append(x[i:i + seq_len, :]) # 取窗口结束后的 horizon 个值作为目标 ys.append(y[i + seq_len:i + seq_len + horizon]) return np.array(xs), np.array(ys)代码里seq_len=24是用过去一天的数据预测下一天,horizon=1是预测未来 1 小时。ys切片得到的是长度horizon的数组,所以即便把horizon改成 3,也不需要改数据结构,模型输出层的神经元数量对应改成 3 就可以。窗口长度不建议拍脑袋定 24,郑州的污染过程往往持续 1 到 3 天,备选值可以放 24、48、72 对比。样本构造时先按时间顺序切片,不要在这里手动 shuffle,打乱由训练时的model.fit(shuffle=True)负责,否则时间序列的顺序关系会被破坏。
2.3 标准化顺序:先分训练集,再 fit scaler
空气质量数据量纲差异很大,CO 是 mg/m³,其他污染物是 μg/m³,不归一化会让 LSTM 训练过程偏向数值大的特征。但标准化有个容易踩坑的细节:必须先切分训练集和验证集,再在训练集上fitscaler,验证集只做transform。
# 标准化与训练/验证集划分 from sklearn.preprocessing import StandardScaler split = int(len(xs) * 0.8) x_train_seq, x_val_seq = xs[:split], xs[split:] y_train_seq, y_val_seq = ys[:split], ys[split:] scaler_x = StandardScaler() # 把三维训练样本重塑成二维后 fit,得到每个特征维度的均值和方差 x_train_2d = x_train_seq.reshape(-1, x_train_seq.shape[-1]) scaler_x.fit(x_train_2d) x_train_norm = scaler_x.transform(x_train_2d).reshape(x_train_seq.shape) x_val_2d = x_val_seq.reshape(-1, x_val_seq.shape[-1]) x_val_norm = scaler_x.transform(x_val_2d).reshape(x_val_seq.shape)reshape(-1, n_features)是把所有时间窗口拼成一个大二维表,对每个特征维度求统计量。验证集只用transform,如果这里也调fit_transform,相当于验证集信息提前参与了标准化,预测时会高估模型表现。项目里没有单独保存 scaler 文件,实际使用时有几种补法:一是把scaler_x.mean_和scaler_x.scale_存成npz,二是在x_params_list.txt旁边加一个偏置文件。我一般会直接np.savez('scaler_params.npz', mean=scaler_x.mean_, scale=scaler_x.scale_),预测脚本里重新构造StandardScaler再赋值。
3. model.py 的 LSTM 结构设计:从 3 维输入到 PM2.5 浓度输出
数据已经整理成(样本数, 24, 8)的三维张量,接下来 model.py 要解决的是“怎么从时间序列里提取污染趋势”。项目落地时没有直接调第三方时间序列库的默认参数,而是显式写了两层 LSTM 加全连接输出。直接看源码可能会觉得网络简单,但空气质量预测本质上是一个小样本回归问题,郑州单站一年逐小时数据也就 8760 条,去掉缺失值后更少,模型容量太大会在重污染过程上过拟合。
3.1 输入和输出形状:先想清楚样本长什么样
LSTM 层的输入形状是三部分:batch_size、time_steps、n_features。经过utils.py构造后,每批数据形状是(None, 24, 8),其中None是批次维度,24是时间步长,8是特征数。输出层只有一个神经元,因为任务是预测连续浓度值,属于回归任务,所以最后一层不加激活函数。如果将来改成 AQI 等级分类,才需要把最后的Dense(1)换成Dense(num_classes, activation='softmax'),损失函数也要换成categorical_crossentropy。
3.2 两层 LSTM 加 Dropout:结构和参数明细
model.py的核心结构可以这样写:
# model.py from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout def build_model(seq_len=24, n_features=8, lstm_units=64, dropout=0.2): model = Sequential() model.add(LSTM( lstm_units, return_sequences=True, # 保持时间步,供第二层 LSTM 使用 input_shape=(seq_len, n_features) )) model.add(Dropout(dropout)) model.add(LSTM(lstm_units // 2, return_sequences=False)) model.add(Dropout(dropout)) model.add(Dense(16, activation='relu')) model.add(Dense(1)) # 回归任务输出连续值 model.compile(optimizer='adam', loss='mse', metrics=['mae']) return model模型各层信息可以概览成下表:
| 层名 | 输出形状 | 说明 |
|---|---|---|
| LSTM 1 | (None, 24, 64) | 64 个单元,保留每个时间步输出 |
| Dropout | (None, 24, 64) | 训练时随机丢弃 20% 神经元 |
| LSTM 2 | (None, 32) | 32 个单元,只输出最后一个时间步 |
| Dropout | (None, 32) | 继续抑制过拟合 |
| Dense | (None, 16) | ReLU 激活,做非线性映射 |
| Dense | (None, 1) | 输出 PM2.5 或 AQI |
第二层 LSTM 的return_sequences=False是关键:模型在最后一个时间步输出一个状态向量,再经过全连接层映射成浓度值。如果这里也改成True,输出仍然是 24 个时间步,还需要额外决定取最后一个还是所有时间步的均值,徒增复杂度。Dropout 放在 LSTM 层后面是常见做法,但dropout不建议超过 0.3,否则模型学习短期污染过程会变得很吃力。lstm_units从 32 起步,一般到 128 就够用,再往上提升有限且训练时间成倍增加。
3.3 保存权重到 HDF5:model_weights.h5 的加载方式
model_weights.h5是训练过程中由ModelCheckpoint保存的权重文件。加载前必须先重建模型结构,常见做法是复用同一个build_model(seq_len, n_features)函数,再调用model.load_weights('model_weights.h5')。如果直接用load_model去读只有权重的 HDF5 文件,会报结构缺失或 KeyError。这份项目里保留了独立的model.py,说明设计意图就是“结构定义与权重文件分离”,加载时保持参数一致即可。
3.4 超参数既有结论:seq_len、lstm_units、dropout 怎么搭配
seq_len 影响模型能看到多长的污染过程。24 小时适合短期过程,48 小时能覆盖一天的昼夜循环和一次完整的污染积累,72 小时则尝试捕捉连续重污染过程。郑州秋冬季节 PM2.5 容易积累,夏季则 O₃ 突出,单一固定窗口很难同时适配两个季节,所以建议把 seq_len 放进网格搜索,和 lstm_units 一起比较验证集 MAE。实际调参时先固定 seq_len=24,把 lstm_units 从 32 调到 128,看验证集 loss 是上升还是下降;如果下降不明显,问题往往不在模型容量,而在上游特征对齐。
4. train_model.py 训练循环:用 loss.png 判断该加数据还是加参数
数据准备好了,模型也建起来了,接下来就是训练。看train_model.py时我最关心三件事:是否划分验证集、是否做早停、loss.png是否同时画 train 和 val 两条线。很多代码只在最后打印一个 loss 数值,那样很难区分过拟合还是欠拟合,也无法判断该加数据还是加网络容量。
4.1 回调和训练主循环
常见的训练代码会带上三个回调:EarlyStopping、ReduceLROnPlateau、ModelCheckpoint。它们的作用分别是提前停止、动态降低学习率、保存最优权重。
# train_model.py 中的关键训练逻辑 from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint callbacks = [ EarlyStopping(monitor='val_loss', patience=8, restore_best_weights=True), ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=3, min_lr=1e-5), ModelCheckpoint('model_weights.h5', monitor='val_loss', save_best_only=True, save_weights_only=True) ] history = model.fit( x_train_norm, y_train_seq, validation_data=(x_val_norm, y_val_seq), epochs=100, batch_size=64, callbacks=callbacks, verbose=1 )patience=8表示验证集 loss 连续 8 轮不下降就停止训练,并回滚到最优权重,避免后期过拟合。ReduceLROnPlateau的学习率衰减因子设为 0.5,意思是验证集 loss 卡住 3 轮后,学习率减半,让模型更细腻地收敛。ModelCheckpoint的save_weights_only=True对应第 3 章说的分开保存方式。batch_size 用 64 时,单站一年数据大概 100 多轮一个 epoch,训练速度很快;如果数据量只有几千条,建议把 batch_size 降到 32,梯度更新更平滑。
4.2 loss 曲线不是只看 loss 大小,而是看两条线的间距
训练结束可以把 history 里的 loss 画成loss.png:
import matplotlib.pyplot as plt plt.figure(figsize=(8, 4)) plt.plot(history.history['loss'], label='train_loss') plt.plot(history.history['val_loss'], label='val_loss') plt.xlabel('epoch') plt.ylabel('loss') plt.legend() plt.savefig('loss.png', dpi=150)如果 train_loss 持续下降,val_loss 先降后升,说明模型开始记忆训练集中的噪声,也就是过拟合。此时不应该继续增加训练轮数,而是考虑增大 dropout、缩小 lstm_units,或者检查训练数据里是不是混入了重复的缺失值填充记录。如果两条曲线都在高位不降,说明模型容量不足,先把 lstm_units 从 64 调到 128,或者把滑动窗口从 24 调到 48。项目里的model_struct.png是模型结构图,loss.png是训练过程图,两个文件配合看,能快速定位“结构问题”还是“数据问题”。
4.3 用 MAE 和 RMSE 评估:多少算可用
训练完成后不要只盯着 loss 值,还要在时间轴上计算回归指标。评估时常用下面三个:
| 指标 | 表达式 | 在 PM2.5 预测里的经验区间 |
|---|---|---|
| MAE | mean(abs(actual - pred)) | 10~20 μg/m³ 说明趋势可用 |
| RMSE | sqrt(mean((actual - pred)^2)) | 明显高于 MAE 时说明有极端误差 |
| R² | 1 - (残差平方和 / 总平方和) | 0.85 以上比较理想 |
MAE 更直观,但 RMSE 对重污染日的低估更敏感。郑州秋冬季出现静稳天气时,PM2.5 容易突然冲高,模型如果总是把峰值预测低,RMSE 会明显高于 MAE。项目里的model.png一般就是预测值与实测值的散点图,看散点是否贴近 45 度线。训练结束可以打印model.evaluate(x_val_norm, y_val_seq)拿到验证集的 MSE 和 MAE,再额外算 RMSE 和 R²。
5. predict.py 加载权重做 24 小时滚动预测:避免误差累积
训练完成拿到model_weights.h5后,predict.py要做的是用最近 24 小时的真实监测序列,滚动预测未来 24 小时 PM2.5。这一步最大的坑不是模型加载,而是滚动时用预测值去替换新输入,导致误差在一个小时内被放大。
5.1 加载权重并复用标准化参数
预测脚本必须复用训练时的参数列表和标准化参数,不能重新fitscaler。训练环境里保存的scaler_params.npz来自第 2 章,预测时先重建模型结构:
# predict.py 片段 model = build_model(seq_len=24, n_features=8) model.load_weights('model_weights.h5') # 读取最近 24 小时原始数据,并应用训练时的标准化参数 x_recent = load_recent_window('data.txt', x_params_list='x_params_list.txt') x_last = x_recent[-24:]加载权重时seq_len和n_features必须和训练时一致,否则权重形状对不上。
5.2 滚动预测时只替换目标列
滚动预测的核心是每次把窗口向前移动一步,用刚才的预测值填充最新一行,再预测下一个小时。这里建议只替换目标列,其他气象特征继续用最近真实值:
def rolling_forecast(model, scaler_x, scaler_y, last_window, steps=24, target_col=0): preds = [] cur = scaler_x.transform(last_window) for _ in range(steps): pred_scaled = model.predict(cur[np.newaxis, :, :], verbose=0)[0, 0] pred = scaler_y.inverse_transform([[pred_scaled]])[0, 0] preds.append(pred) # 将新预测值放回目标列,其他特征保持最近真实观测 cur = np.roll(cur, -1, axis=0) cur[-1, target_col] = pred_scaled return np.array(preds)np.roll把整个窗口往前平移一个时间步,target_col是 PM2.5 在特征矩阵中的位置。只替换目标列能避免预测误差向温度、湿度等其他特征扩散。另一个技巧是每拿到一个真实监测值就立刻更新last_window,而不是连续预测 24 小时之后再校正;如果气象数据每 3 小时才更新一次,就把新观测按时间戳插进窗口。把这个“谁更新、谁保留”的逻辑写成配置项,多站点同时预测时会省下大量重复代码。
本文还有配套的精品资源,点击获取