简介:这是一套基于长短期记忆网络(LSTM)的空气质量数据预测与可视化分析系统,采用Python编程语言实现,面向计算机相关专业的高阶课程实践、毕业设计及机器学习入门人群。系统覆盖数据预处理、异常值清洗、归一化、多层LSTM网络构建、全连接输出与Matplotlib/Seaborn可视化等完整流程,并经学术导师评审,综合评分99分,均可复现污染物浓度趋势预测与预测精度变化曲线。压缩包共312个文件,约6.98MB,包含Python源码(.py、.pyc)、数据表格(.csv、.sqlite3)、Web可视化页面(.html、.css、.js)、样式表(.scss)等,目录结构划分清晰,便于按模块查阅。目前已有58人学习下载。使用者可依据附带的环境配置说明与示例数据集直接部署运行,或调整超参数、优化器进一步探索不同空气质量预警场景,适合作为课程作业、毕业设计及项目实训的重要参考资料。
1. 这个空气质量预测系统,值得用LSTM做吗
环境监测每小时产生一条记录,PM2.5、PM10、NO2 这些数据堆了大半年,却只在月底统计一下均值,这大概是很多环境数据从业者手头的真实状态。基于 LSTM 的空气质量预测与可视化分析系统(Python 实现)要解决的,就是把“沉睡的监测记录”变成“未来 24 小时的浓度趋势”,用过去几十个小时的多污染物序列预测下一小时甚至未来几小时的 PM2.5 浓度,再把预测结果画成能直接放进日报的趋势图。这套方案适合手里有历史监测数据、想预测但没搭过深度学习流程的人,也适合做设备寿命预测、传感器数据时序建模的工程师参考。它的核心不是刷出一个漂亮的 R²,而是把数据清洗、LSTM 建模、结果反标准化、可视化验证这一整条链路跑通。
2. 数据准备:从原始监测 CSV 到可训练的 LSTM 样本
LSTM 不直接吃 DataFrame,它吃的是形状固定的浮点数张量。数据里有空洞、有飘高值、有量纲不一致,这些不处理干净,后面模型结构再标准也白搭。这一章按“清洗 → 切分 → 标准化 → 构造窗口”的顺序,把原始 CSV 变成可训练样本。
2.1 监测数据长什么样:字段、时间粒度与缺失值规律
空气质量监测站导出的 CSV,常见结构是时间列加若干污染物浓度列,有的还带气象参数。小时级数据中最常用的字段如下。
| 字段 | 含义 | 典型量纲 | 备注 |
|---|---|---|---|
| datetime | 监测时间 | 小时级 | 必须解析成时间索引 |
| PM25 | 细颗粒物 | μg/m³ | 预测目标一般选它 |
| PM10 | 可吸入颗粒物 | μg/m³ | 与PM2.5强相关 |
| NO2 | 二氧化氮 | μg/m³ | 交通源污染指示 |
| SO2 | 二氧化硫 | μg/m³ | 燃煤源指示 |
| CO | 一氧化碳 | mg/m³ | 燃烧源指示 |
| O3 | 臭氧 | μg/m³ | 夏季光化学污染 |
| AQI | 空气质量指数 | 无量纲 | 由各污染物折算 |
时间粒度建议选小时级。分钟级数据噪声太大,模型学到的是传感器抖动而不是污染规律;日级数据又太稀,一年才 365 条,不够喂 LSTM。小时级数据既有日内周期(早晚高峰、夜间累积),又不会让序列长到训练不动。
缺失值在这个场景里很常见。仪器校准会整段缺失,通信断点会造成长达几小时的缺口,还会偶尔出现浓度突然飙到上千的异常点。常见的错误是先 dropna 再训练,这个坑后面单独讲,先按稳妥方式处理。
import pandas as pd import numpy as np # 读取监测数据,datetime 直接解析为索引 df = pd.read_csv("air_quality.csv", parse_dates=["datetime"], index_col="datetime") df = df[["PM25", "PM10", "NO2", "SO2", "CO", "O3", "AQI"]] # 统计缺失量,决定用哪种处理策略 missing_info = df.isna().sum() print(missing_info) # 短段缺失:前向填充兜底 + 线性插值平滑 df = df.ffill().interpolate(method="linear", limit_direction="both") # 异常值裁剪:中位数 ± 5 倍 MAD,避免个别飘高值拖坏训练 mad = (df - df.median()).abs().median() lower = df.median() - 5 * mad upper = df.median() + 5 * mad df = df.clip(lower, upper)这里没有用均值填充,是因为污染物浓度突变很有信息量,均值会把突变抹平。也没有直接用 3σ 法则,因为空气质量数据是重尾分布,高浓度事件本来就有,均值容易被极端值带偏,MAD 基于中位数更稳。先 ffill 再 interpolate 的原因是线性插值需要两端有值,纯 interpolate 在开头结尾会留下 NaN,ffill 先把边界兜住。注意 if 成段缺失超过几十个小时,ffill 会制造一段平台期,这种情况应该直接删除这一段并记录时间索引,而不是强行补。
2.2 先切分再缩放:MinMaxScaler 的正确使用顺序
很多入门代码先把整个数据集 MinMaxScaler 一下再切分训练验证集,这是时间序列预测里最容易踩的泄漏问题。MinMaxScaler 的 min 和 max 来自全量数据,等于验证集和测试集的取值范围已经混进训练阶段,模型等于提前看过部分未来信息,验证指标虚高,上线就崩。
正确做法是先按时间顺序切分,再只在训练集上 fit,验证集和测试集只做 transform。另外要单独给预测目标 PM2.5 建一个 scaler,因为后面评估时要把预测结果反标准化回真实浓度。
from sklearn.preprocessing import MinMaxScaler def split_and_scale(df, train_ratio=0.7, val_ratio=0.15): values = df.values.astype(np.float32) # [T, feature_num] n = len(values) n_train = int(n * train_ratio) n_val = int(n * val_ratio) # 严格按时间顺序切,绝不随机打乱 train_raw = values[:n_train] val_raw = values[n_train:n_train + n_val] test_raw = values[n_train + n_val:] # 特征统一缩放,只 fit 训练段 scaler_X = MinMaxScaler(feature_range=(0, 1)) train_scaled = scaler_X.fit_transform(train_raw) val_scaled = scaler_X.transform(val_raw) test_scaled = scaler_X.transform(test_raw) # 预测目标单独缩放,单独保存 scaler_y pm25_idx = df.columns.get_loc("PM25") scaler_y = MinMaxScaler(feature_range=(0, 1)) train_y = scaler_y.fit_transform(train_raw[:, pm25_idx].reshape(-1, 1)) val_y = scaler_y.transform(val_raw[:, pm25_idx].reshape(-1, 1)) test_y = scaler_y.transform(test_raw[:, pm25_idx].reshape(-1, 1)) # 返回特征序列和目标序列,以及用于反标准化的 scaler_y return (train_scaled, val_scaled, test_scaled, train_y, val_y, test_y, scaler_y) train_x, val_x, test_x, train_y, val_y, test_y, scaler_y = split_and_scale(df)为什么选 MinMaxScaler 而不是 StandardScaler?污染物浓度是非负的,高浓度重尾会把 Z-score 的均值和方差拉偏,MinMax 把数据压到 0~1 之间,对 LSTM 这类以 sigmoid/tanh 为内部激活的网络更友好。PM2.5 在低浓度时接近 0,MinMax 不会生成负值,符合浓度物理意义。
切分比例 70/15/15 是经验值。空气质量小时级数据通常有几千到几万条,这个比例够用。如果数据量不足两千条,验证集和测试集会因为窗口滑掉开头一段后变得太小,这时可以把比例调成 80/10/10,或者改用时间序列交叉验证。
2.3 滑动窗口:把时序序列变成监督学习样本
LSTM 不是直接吃一维序列,它需要的是“过去一段窗口的特征 → 未来一个值”这样的样本对。这一步叫滑动窗口,也叫序列到样本的转换。
def make_windows(data, target, seq_len, horizon): """ 把连续时间序列切成 (X, y) 监督样本 data: 缩放后的全特征序列 [T, feature_num] target: 缩放后的目标序列 [T, 1] seq_len: 回看窗口长度(小时) horizon: 预测未来第几个时刻 """ X, y = [], [] for i in range(len(data) - seq_len - horizon + 1): X.append(data[i : i + seq_len]) # 目标取窗口结束后的第 horizon 个值 y.append(target[i + seq_len + horizon - 1]) return np.array(X), np.array(y) SEQ_LEN = 24 # 用过去 24 小时预测未来 HORIZON = 1 # 预测未来 1 小时 X_train, y_train = make_windows(train_x, train_y, SEQ_LEN, HORIZON) X_val, y_val = make_windows(val_x, val_y, SEQ_LEN, HORIZON) X_test, y_test = make_windows(test_x, test_y, SEQ_LEN, HORIZON)X_train 的形状是 [样本数, 24, 7],7 是特征数,正好符合 PyTorch 里 LSTM 要求的 (batch, seq_len, features) 布局。y_train 的形状是 [样本数, 1],是未来那一小时的 PM2.5 归一化浓度。
SEQ_LEN 取 24 是因为空气污染有明显的日内周期:凌晨累积、早高峰抬升、午后扩散、夜间再累积,24 小时刚好覆盖一轮完整周期。如果数据里有明显的周周期(比如周末工业排放降低),可以尝试 168(一周小时数),但序列太长会显著拖慢训练,建议先跑 24,验证集上效果不够再考虑加长。HORIZON 取 1 是单步预测,模型结构最简单;想预测未来 6 小时,可以把 HORIZON 改成 6,但样本数会同步减少,且误差会随预测距离累积。
窗口构造这一步完成后,训练数据就不缺了。接下来进入模型搭建。
3. 用 PyTorch 搭 LSTM 空气质量预测模型:结构设计与训练参数
数据准备好之后,模型结构并不复杂。空气质量预测本质是回归问题,LSTM 负责从历史序列里提取时序特征,最后接一个全连接层输出浓度值。这一章把模型骨架、训练循环和关键参数一次说清。
3.1 为什么选 LSTM 而不是 RNN 或普通 MLP
普通 RNN 在长序列上存在梯度消失问题,时间步一长,前面的信息传不到最后。空气质量的规律恰恰是长依赖:今天夜间 PM2.5 的累积状态会影响明天早上的峰值,跨了十几个小时。LSTM 用遗忘门、输入门、输出门控制信息保留与丢弃,能把这种跨小时的相关性带到最后一步。
那为什么不用 MLP 直接喂“最近 24 小时的 PM2.5”做回归?可以,但 MLP 把 24 个时间步当独立特征,学不到“第 3 小时到第 4 小时的变化趋势”这种时序关系。LSTM 的循环结构在时间步之间共享权重,天然建模序列演化。
实现层面,PyTorch 比 Keras 更适合这个场景。一是调试方便,网络中间输出随手可以打印检查;二是滚动预测、自定义训练循环都更灵活;三是小时级空气质量数据量不大,CPU 也能训练,不需要分布式环境。
3.2 模型骨架:两层 LSTM 加回归头的实现代码
import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, n_features, hidden_size=64, num_layers=2, output_size=1, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=n_features, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, # 输入形状用 (batch, seq_len, features) dropout=dropout # 两层及以上时,层间 dropout 生效 ) self.reg_head = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, output_size) ) def forward(self, x): # x: [batch, seq_len, n_features] out, _ = self.lstm(x) # out: [batch, seq_len, hidden_size] last = out[:, -1, :] # 取最后一个时间步的隐状态 return self.reg_head(last) # 输出 [batch, 1]两个关键设计点。第一,取 out 最后一个时间步而不是全部时间步做平均,因为预测未来浓度时,最新的状态信息量最大,平均池化会把前面老旧状态混进来。第二,LSTM 的 dropout 参数只对多层结构的非最后一层生效,单层网络传 dropout 会被 PyTorch 忽略,这个行为容易让人误以为设置了没效果。
hidden_size 取 64 是小时级数据的稳妥起点。64 能捕获多污染物之间的交互,又不至于在几千条样本上过拟合。数据量超过两万条可以试 128,但训练时间会翻倍。num_layers 取 2 是权衡结果:1 层容易欠拟合,3 层在空气质量这类信噪比不高的数据上提升有限,反而更容易过拟合。
3.3 训练循环:loss、优化器、早停与随机种子
训练循环要解决三个问题:用什么损失函数、学习率怎么设、什么时候停。
import torch.optim as optim from torch.utils.data import TensorDataset, DataLoader # 转成 PyTorch Dataset,batch_first 的数据直接喂 train_ds = TensorDataset( torch.tensor(X_train, dtype=torch.float32), torch.tensor(y_train, dtype=torch.float32) ) val_ds = TensorDataset( torch.tensor(X_val, dtype=torch.float32), torch.tensor(y_val, dtype=torch.float32) ) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False) model = LSTMPredictor(n_features=X_train.shape[2], hidden_size=64, num_layers=2) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.MSELoss() # 固定随机种子,否则调参像抽卡,同一份代码两次结果不同 def set_seed(seed=42): np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42) best_val_loss = float("inf") patience = 0 max_patience = 5 # 连续 5 轮验证损失不降就早停 for epoch in range(50): model.train() train_loss = 0.0 for xb, yb in train_loader: optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb) loss.backward() optimizer.step() train_loss += loss.item() # 验证阶段关掉梯度,节省内存并防止影响 BN 等层状态 model.eval() val_loss = 0.0 with torch.no_grad(): for xb, yb in val_loader: pred = model(xb) val_loss += criterion(pred, yb).item() val_loss /= len(val_loader) print(f"epoch {epoch+1:02d} | train_loss {train_loss:.4f} | val_loss {val_loss:.4f}") if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), "best_lstm.pt") patience = 0 else: patience += 1 if patience >= max_patience: print("early stop") breakloss 用 MSELoss 而不是 MAE,是因为 MSE 对大误差的惩罚更重。空气质量预测的业务上,高浓度事件(重污染过程)比低浓度的小误差更值得关注,MSE 会让模型优先拟合那些浓度飙升的时刻。代价是会让整体 MAE 略高,这是合理的取舍。
学习率 1e-3 配 Adam 是默认起点。空气质量数据特征尺度在 0~1 之间,梯度不会爆炸,这个学习率基本安全。如果发现 val_loss 震荡不降,把 lr 降到 3e-4;如果训练集 loss 都降不下去,先查数据预处理而不是调网络。
shuffle=True 在这里是允许的。虽然样本来自连续时间序列,但训练集内部 shuffle 只是打乱了 batch 组合顺序,不会把未来的时间步混进训练集,因为切分阶段已经隔离了。time shuffle 反而能让每个 batch 的样本来源更分散,梯度更新更稳定。
early stopping 的 patience 设 5。空气质量数据噪声大,验证损失会在 5~10 轮里来回波动,patience 太小会过早停掉,太大又浪费时间。训练完成后加载 best_lstm.pt,不要用最后一轮的权重。
4. 预测效果评估与可视化分析:不是画张曲线就完事
模型训练完了,接下来要回答“预测得到底准不准”。这一步要做的不是把两条曲线叠在一起喊一句“拟合得很好”,而是用指标衡量误差、用图形定位系统性偏差,再用相关性分析验证特征选择是否合理。
4.1 三个指标看预测质量:RMSE、MAE 与 R²
先加载最优权重,在测试集上做预测。注意预测结果是在 0~1 区间里的归一化值,必须反标准化成真实浓度才能算指标。
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score model.load_state_dict(torch.load("best_lstm.pt", map_location="cpu")) model.eval() with torch.no_grad(): pred_norm = model(torch.tensor(X_test, dtype=torch.float32)).numpy() # 反标准化:MinMax 逆变换公式 # 原始值 = 归一化值 / scale_ + data_min_ pm25_idx = df.columns.get_loc("PM25") pm_scale = scaler_y.scale_[0] pm_min = scaler_y.data_min_[0] y_true = y_test / pm_scale + pm_min y_pred = pred_norm.ravel() / pm_scale + pm_min rmse = mean_squared_error(y_true, y_pred, squared=False) mae = mean_absolute_error(y_true, y_pred) r2 = r2_score(y_true, y_pred) # MAPE 要过滤掉接近 0 的样本,否则分母爆炸 mask = y_true > 10 mape = np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100 print(f"RMSE: {rmse:.2f} μg/m³") print(f"MAE: {mae:.2f} μg/m³") print(f"R²: {r2:.3f}") print(f"MAPE(PM2.5>10): {mape:.1f}%")RMSE 是主指标,它放大高浓度样本的误差,重污染过程预测偏了,RMSE 立刻变难看。MAE 反映整体平均偏移,适合衡量日常预测稳定性。R² 看趋势解释力,但时间序列的 R² 比回归场景更容易虚高,因为相邻小时浓度本来就高度自相关,不能单独拿来下结论。我一般不设绝对达标线,而是用“RMSE 控制在当季 PM2.5 均值的三分之一以内”作为可用线,不同城市、不同季节差异很大,硬套数字没有意义。
MAPE 在这里只统计 PM2.5 大于 10 的样本。夜间浓度常年在个位数徘徊,真实值 5 预测 8,绝对误差只有 3,MAPE 却会算出 60% 的误差率,严重拉低指标可信度。
4.2 预测曲线、误差分布与按小时误差箱线图
可视化分析的第一张图永远是“真实曲线 vs 预测曲线”。画图前先把 matplotlib 的中文字体配好,否则图里全是方块。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False # 解决负号显示成方块的问题 test_index = df.index[train_size + val_size + SEQ_LEN:] # 与 X_test 对齐 # 注意窗口构造截掉了开头的 SEQ_LEN 个时间点 fig, ax = plt.subplots(figsize=(14, 5)) ax.plot(test_index, y_true, label="真实 PM2.5", linewidth=0.8, color="black") ax.plot(test_index, y_pred, label="LSTM 预测", linewidth=0.8, color="crimson") ax.set_xlabel("时间") ax.set_ylabel("PM2.5 浓度 (μg/m³)") ax.legend() plt.tight_layout() plt.savefig("pred_curve.png", dpi=150)这张图重点看两个地方:峰值出现的位置是否滞后,以及低浓度段预测是否系统性偏高。滞后问题后面避坑章节专门讲,这里先用图确认整体趋势是否跟得住。
第二张图是误差分布,第三张图按小时分组看误差,用来定位“哪个时段模型最不靠谱”。
error = y_true.ravel() - y_pred.ravel() # 误差直方图:看偏差是否集中在 0 附近,是否右偏 fig, ax = plt.subplots(figsize=(8, 4)) ax.hist(error, bins=50, color="steelblue", edgecolor="white") ax.set_xlabel("预测误差 (μg/m³)") ax.set_ylabel("频数") plt.tight_layout() plt.savefig("error_hist.png", dpi=150) # 按小时分组误差箱线图:定位早晚高峰的系统性偏差 error_df = pd.DataFrame({"hour": test_index.hour, "error": error}) fig, ax = plt.subplots(figsize=(12, 5)) error_df.boxplot(column="error", by="hour", ax=ax) ax.set_xlabel("小时") ax.set_ylabel("预测误差 (μg/m³)") plt.suptitle("") plt.tight_layout() plt.savefig("error_by_hour.png", dpi=150)按小时箱线图很能说明问题。比如早上 7~9 点误差整体为正,说明早高峰排放过程模型低估了;夜间误差离散度大,说明低浓度时段噪声本来就高。看到这种规律,下一步不是调 LSTM 参数,而是考虑加气象特征或把早晚高峰单独建模。
4.3 相关性热力图:用可视化辅助选特征
可视化分析不只在评估阶段有用,建特征之前更该看一眼相关性矩阵。
import seaborn as sns corr = df[["PM25", "PM10", "NO2", "SO2", "CO", "O3", "AQI"]].corr() plt.figure(figsize=(8, 6)) sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm", vmin=-1, vmax=1, linewidths=0.5) plt.tight_layout() plt.savefig("corr_heatmap.png", dpi=150)PM10 与 PM2.5 的相关性通常很高,因为同源且粒径分布有重叠;CO 和 PM2.5 强相关一般指向燃烧源;O3 与 PM2.5 的关系复杂,夏季高 O3 往往伴随光化学二次气溶胶生成,冬季则常常负相关。这些规律可以帮助决定哪些列要进模型。但要注意:相关性高不代表因果,CO 与 PM2.5 都来自机动车排放,去掉 CO 模型未必变差。热力图只是辅助特征选择的起点,真正判断得靠“加特征 vs 不加特征”的对照实验。
5. 避坑:空气质量时间序列预测最常见的 5 个翻车点
LSTM 预测空气质量这事,模型结构翻车的少,数据流程翻车的多。下面五条是我自己踩过的坑,按“现象 → 原因 → 解决”写清楚。
5.1 预测曲线比真实曲线慢一拍
现象:预测曲线和真实曲线形状几乎一样,但整条线往右平移了一个时间步,真实浓度开始涨了,预测还在低位,等到真实值到顶了,预测才追上来。
原因:单步预测的模型在训练时发现,最优策略就是“复读最近的值”。因为 PM2.5 小时序列自相关性极强,前一小时浓度对后一小时浓度的解释力超过任何特征,MSE 损失下模型学会了抄近道。
解决:先把滞后程度定量化,确认不是肉眼错觉。
from scipy.stats import pearsonr # 计算预测与真实值的同期相关,以及与真实值滞后1小时的相关 corr_same = pearsonr(y_true[1:], y_pred[1:])[0] corr_shift = pearsonr(y_true[:-1], y_pred[1:])[0] print(f"同期相关: {corr_same:.3f}, 预测比真实滞后1小时的相关: {corr_shift:.3f}")如果 corr_shift 明显大于 corr_same,基本可以断定滞后。缓解办法有三个:把 HORIZON 改成 3 或 6 做真正的多步预测,让模型为更远的目标优化;加入温度、风速、边界层高度等气象特征,切断“只靠上一小时浓度”的捷径;或者接受单步滞后,只把预测用于未来 3 小时以上的趋势判断。
5.2 直接 dropna 导致时间轴断裂
现象:训练曲线看起来正常,一旦看预测曲线,发现某个时刻预测值凭空跳变,而真实数据并没有对应突变。
原因:很多人清洗数据时直接 df.dropna(),缺失行被删掉后,物理时间上相隔几小时甚至几天的两条记录,在 DataFrame 里变成了相邻行。滑动窗口把这两个不相邻时刻拼成一个样本,模型学到的是“跨越缺失段的伪规律”。
解决:先看缺失率,缺失率低于 5% 用 ffill 加插值;成段缺失超过 24 小时的直接砍掉这段,并在构造窗口时让窗口不跨过断裂点。
# 找出成段缺失的区间,窗口构造时跳过 missing_mask = df.isna().any(axis=1) # 连续缺失超过 24 小时的段,记录起止时间,切窗口时丢弃跨段样本这个“跳过跨段样本”的细节我吃过一次大亏。补上之后模型在真实污染过程中的表现立刻正常了。
5.3 全量数据 fit MinMaxScaler 造成信息泄漏
现象:验证集指标非常漂亮,R² 上了 0.95,一放到新数据上预测就明显失真。
原因:标准化时对整个数据集调用了 fit_transform,测试集的 min 和 max 已经参与训练数据缩放。模型在训练时等于见过了未来数据的取值边界,这是典型的泄漏。
解决:严格按 2.2 节的顺序——先按时间切分,再只在训练段上 fit scaler,验证和测试段只 transform。切分必须在任何统计量计算之前完成。
5.4 MAPE 遇到 PM2.5 接近 0 时算出一百多
现象:MAPE 算出来一两百,怎么看都不合理,但 RMSE 和 MAE 都正常。
原因:PM2.5 浓度在清洁天气下可能只有个位数,真实值 5、预测值 3,绝对误差很小,MAPE 分母是 5,直接算出 40% 误差率。夜里一堆接近 0 的样本,MAPE 瞬间爆炸。
解决:不要对全量样本算 MAPE,过滤掉 PM2.5 低于 10 的样本再算,并同时报 RMSE 和 MAE。做业务汇报时,MAPE 结合 RMSE 一起说,避免单指标误导。
5.5 随机种子不固定导致调参像抽卡
现象:同样的代码、同样的数据,两次训练结果不一样,今天跑 R² 0.82,明天跑 0.78,想比较两组参数谁更好都没法定论。
原因:PyTorch 的权重初始化、DataLoader 的 shuffle、甚至操作系统的线程调度都会带来随机性。神经网络本身不是确定性过程。
解决:固定全链路随机种子,并保存一份训练参数记录。
import random random.seed(42) np.random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42) # 如果追求严格可复现,把 DataLoader 的 worker 随机种子也固定 def _worker_init(worker_id): np.random.seed(42 + worker_id) DataLoader(train_ds, batch_size=64, shuffle=True, worker_init_fn=_worker_init)固定种子后,同参数结果稳定,调参才有可比性。这不算玄学,而是工程上可复现性的基本要求。
6. 把模型用起来:滚动预测、模型导出与后续验证
训练和评估做完,模型还躺在 Jupyter 里。要真正用起来,至少需要三步:写一个滚动预测函数做多步外推,把模型和 scaler 参数一起导出,规划定期重训练。
滚动预测的思路是把预测出的浓度值当作已知值拼回输入序列,再预测下一个时刻。下面是预测未来 24 小时的简版实现。
def rolling_forecast(model, last_seq, steps=24, first_pred_idx=pm25_idx): """last_seq: [seq_len, n_features] 已归一化的最近窗口""" model.eval() seq = last_seq.clone() preds = [] with torch.no_grad(): for _ in range(steps): p = model(seq.unsqueeze(0)) # [1,1] preds.append(p.item()) new_step = seq[-1].clone() new_step[first_pred_idx] = p.item() # 把新预测的PM2.5接进去 seq = torch.cat([seq[1:], new_step.unsqueeze(0)], dim=0) return np.array(preds)这段代码最要注意的是 new_step 的处理。多污染物预测时,我们只滚动 PM2.5,其他特征列暂时沿用最近观测值。这是常见近似,误差会随时间步累积,预测未来 3~6 小时还能看,超过 12 小时就只能看趋势方向,不要当精确值用。严格做法是给每个特征都建预测模型,或者引入气象预报值作为外部输入。
导出模型时,把 scaler 参数一起保存,否则换了环境无法反标准化。
torch.save(model.state_dict(), "lstm_air.pt") np.savez("scaler_params.npz", scale_x=scaler_X.scale_, min_x=scaler_X.data_min_, scale_y=scaler_y.scale_, min_y=scaler_y.data_min_)空气质量存在明显的季节漂移:冬季采暖期排放结构完全不同于夏季,同一个模型跨季往往变差。我的习惯是每周用最新数据重训一次,或者用新数据对旧模型微调几个 epoch 而不是从零训练。做任何新一批监测数据时,先查滞后和标准化泄漏这两个问题再动网络结构,这是我做这套系统最深的教训。希望帮到你。
本文还有配套的精品资源,点击获取