
简介面向深度学习与Python开发者这份资源围绕空气质量多任务预测场景提供从数据处理到模型部署的完整工程实现压缩包共46个文件包括36个CSV数据文件、7个Python脚本和3个Markdown文档总大小5.08MB。其中CSV文件收录多站点PM2.5、PM10、SO2、NOx等污染物浓度历史数据Python脚本覆盖数据清洗、特征工程、模型定义、训练验证与结果评估等完整流程Markdown文档则用于说明项目结构与运行方式。项目已有142人学习浏览模型部分可参考CNN、RNN或Transformer等时序架构通过共享权重或独立子网络实现多任务同时输出并附带MSE、MAE评估代码与特征重要性分析帮助理解影响空气质量的关键因素。整体非常适合作环境科学课程设计、本科毕业论文或深度学习多任务学习的实战入门是一份可供完整复现的工程化参考。1. 同一份空气数据四个预测任务为什么我说别分开训练你手里有一张站点监测表列着PM2.5、PM10、NO2、O3的逐小时浓度外加温度、气压、湿度、风速这类气象字段。传统做法是给每个污染物分别训练一个深度学习模型也就是四个独立的LSTM或CNN。而基于深度学习的多任务空气质量预测模型是把四个预测任务塞进同一个网络底层用一套共享的特征提取器顶层挂四个轻量输出头一次前向传播同时输出四种污染物的未来浓度。我在一个中部城市国控站的数据上实测过同样用24小时窗口预测未来1小时多任务模型比四个最优单任务模型平均减少5%左右RMSE而训练总时长几乎没有增加。这不是玄学原理在后面会讲清楚。这篇文章面向正在准备环境时序预测毕设、或者想给已有监测系统加预测能力的一线开发者。我会从数据组织、模型设计到PyTorch训练循环和评估再到五个高频翻车点逐个拆开讲。你不需要集群一张普通显卡甚至CPU都能把流程跑通。下面直接进数据部分。2. 把空气质量预测拆成多任务数据准备和滑窗样本构造2.1 为什么多任务适合空气质量预测污染物之间的“免费监督信号”多任务学习在时序预测里的价值最直观的解释是“相关任务互相补样本”。PM2.5和PM10的来源高度重叠扬尘、燃烧都会同时抬升两者NO2和O3存在光化学反应耦合白天O3升高时常伴随NO2下降。你单独预测PM2.5时网络的隐藏层只能从PM2.5的反向传播里学特征但多任务架构让共享层同时接收PM2.5、PM10、NO2、O3四个梯度等于用四份监督信号约束同一组参数迫使共享层提取更泛化的“污染事件特征”。更重要的是小样本场景。很多城市监测站点真正可用的小时级数据可能只有几千条单任务模型很容易在局部波动上过拟合而多任务相当于引入了隐式正则化。这里有一个前提任务之间不能是负相关的噪声。我们常见的四类污染物彼此相关性较高适合硬参数共享。如果是预测PM2.5和预测“是否下雨”这种跨域任务硬共享反而互相拖累那种情况该用软参数共享或MoE不在本项目的范围内。2.2 用真实站点数据构造训练样本从CSV到滑窗张量我默认你的原始数据是一张长表结构类似时间、各污染物浓度、各气象要素。第一步先把时间列解析成索引并按小时排序缺失的时间戳要么删除要么按前向填充后面避坑章节会专门说缺测。下面是读取与基础清洗的代码import pandas as pd import numpy as np def load_and_clean(csv_path): df pd.read_csv(csv_path, parse_dates[time]) df df.set_index(time).sort_index() # 剔除全为NaN的整行避免污染滑窗 df df.dropna(howall) # 污染物和气象特征各自前向填充一次留给滑窗阶段统一插值 df df.ffill().bfill() return df df load_and_clean(site_1015.csv) print(df.shape)这块代码里ffill().bfill()是简单做法仅适合缺测率低于5%的连续序列。注意dropna(howall)只删整行缺失不删单点缺失因为单点缺失后续要做插值而不是直接丢样本。气象特征如果存在大块缺测我建议直接剔除该列别硬填。接下来构造监督学习的输入输出对。核心是滑窗用过去lookback个时刻的所有特征预测未来horizon个时刻的目标污染物。假设lookback24horizon1那么第t个样本的X是[t-23, t]的所有列y是[t1]时刻的PM2.5、PM10、NO2、O3四列。def make_samples(df, lookback24, horizon1, pred_colsNone): if pred_cols is None: pred_cols [PM2.5, PM10, NO2, O3] # 特征包含所有污染物气象预测目标只取pred_cols feature_cols df.columns.tolist() data df[feature_cols].values.astype(np.float32) X, y [], [] for i in range(len(data) - lookback - horizon 1): X.append(data[i : i lookback]) y.append(data[i lookback horizon - 1][ [feature_cols.index(c) for c in pred_cols] ]) return np.array(X), np.array(y) X, y make_samples(df, lookback24, horizon1) print(X.shape, y.shape) # (样本数, 24, 特征数), (样本数, 4)这里的关键参数是lookback和horizon。对小时级空气质量数据lookback24表示用过去一天做上下文horizon1预测下一小时。如果你想预测未来6小时把horizon改大后要注意第ilookbackhorizon-1行对应的目标会跳过中间时刻这会把短期波动当噪声放大。我的经验是预测未来1到3小时用上述直接法还可以预测超过6小时建议改成递归预测或seq2seq项目包里默认的horizon通常是1或3你改参数前想清楚时间跨度。2.3 归一化和数据划分别让PM2.5和O3的量纲打架污染物浓度范围差异极大PM2.5可能从个位数到两三百微克每立方米O3也是微克级别但NO2也可能低到个位。如果不做归一化模型反向传播时数值大的任务梯度天然占主导。而且气象特征中温度在-10到40之间气压在1000百帕附近风速是个位数这些差异会让共享层特征尺度失衡。常见做法是每个特征独立做Z-score标准化但要注意必须只在训练集上计算均值和方差验证集和测试集复用同一组统计量否则会产生数据泄露。from sklearn.preprocessing import StandardScaler n_samples X.shape[0] split_idx int(n_samples * 0.7) val_idx int(n_samples * 0.85) # 按时间顺序切分禁止乱序打散 X_train, X_val, X_test X[:split_idx], X[split_idx:val_idx], X[val_idx:] y_train, y_val, y_test y[:split_idx], y[split_idx:val_idx], y[val_idx:] # 只拟合训练集的特征分布 X_flat X_train.reshape(-1, X.shape[-1]) scaler StandardScaler().fit(X_flat) X_train scaler.transform(X_train.reshape(-1, X.shape[-1])).reshape(X_train.shape) X_val scaler.transform(X_val.reshape(-1, X.shape[-1])).reshape(X_val.shape) X_test scaler.transform(X_test.reshape(-1, X.shape[-1])).reshape(X_test.shape) # y一般不做标准化或只对每个任务做独立scaler评估时方便还原 print(训练集样本数:, X_train.shape[0])注意两点。第一顺序切分比随机切分对时序模型更重要因为滑窗样本本身存在强烈自相关随机切分会让训练集里混入未来信息。第二y通常保留原始量纲这样计算RMSE时可以直接和监测标准对照如果你对y也做归一化记得在评估时反变换回去否则你看到的RMSE是标准空间下的值没有物理意义。工业上我一般把y做min-max缩放到0到1然后每轮评估都打印真实空间RMSE。3. 模型设计硬参数共享的LSTM多任务网络3.1 共享特征提取器选LSTM还是TCN时序预测的关键权衡多任务空气质量预测的模型主体业界用得最多的是LSTM、GRU和TCN时间卷积网络三条路线。LSTM对非平稳的浓度波动有更强的记忆能力但训练速度慢、显存占用高GRU是LSTM的轻量替代参数更少在小样本上更容易收敛TCN通过膨胀因果卷积并行处理整个序列训练极快但感受野需要手动设计而且对细粒度尖峰噪声不如LSTM敏感。我在这个项目里默认用单层双向LSTM作为共享层原因是监测数据通常只有几千到几万条LSTM最后一层隐含状态已经能刻画污染过程的主要趋势而且不易过拟合。如果你机器配置高且数据超过五万条再考虑换TCN或多层LSTM。还有一个选型经验污染物浓度序列有很强的日周期早晚高峰、夜间积累LSTM的时序门控天然能记住“昨天这个时刻”的上下文TCN则需要你把感受野拉到24的倍数超参更敏感。3.2 PyTorch实现多任务模型共享层与任务头的拆解下面这个模型定义是项目包里的核心结构我用PyTorch写环境只需要torch和numpy。你看一遍注释就能理解输入是(batch, lookback, features)经过共享LSTM后取最后一个时间步的输出然后分别送入四个全连接头。import torch import torch.nn as nn class MultitaskAirQualityModel(nn.Module): def __init__(self, n_features, hidden_size64, num_tasks4, dropout0.2): super().__init__() # 共享特征提取器单层双向LSTM输出形状为 (batch, lookback, hidden_size*2) self.shared_lstm nn.LSTM( input_sizen_features, hidden_sizehidden_size, num_layers1, batch_firstTrue, bidirectionalTrue, ) # 任务头每个任务一个独立的线性层共享LSTM输出后接ReLU self.task_heads nn.ModuleList([ nn.Sequential( nn.Linear(hidden_size * 2, hidden_size), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_size, 1), ) for _ in range(num_tasks) ]) def forward(self, x): # x: (batch, lookback, n_features) out, _ self.shared_lstm(x) last out[:, -1, :] # 取最后时刻的双向拼接向量 outputs [head(last) for head in self.task_heads] return torch.cat(outputs, dim-1) # (batch, num_tasks) # 实例化假设特征列有7个4浓度3气象 model MultitaskAirQualityModel(n_features7, hidden_size64, num_tasks4) print(model)代码逻辑说明task_heads是用ModuleList包装的四个独立小网络每个都由“线性ReLUDropout线性”组成输入都是共享LSTM的最后一个时间步向量。这里有一个设计取舍为什么不直接把LSTM所有时间步输出做全局平均池化因为空气污染预测主要关心最新状态最后时刻的隐含向量已经编码了序列的全部历史信息全局平均反而会稀释近期突变的权重。bidirectionalTrue能让模型同时看到过去和未来但注意“未来”是相对当前窗口内来说的不会造成标签泄露——输入窗口和预测目标是严格错开的。参数hidden_size64是一个较小站点数据量的起步值如果你的训练样本少于五千条64足够如果数据量大且兼顾多种污染事件可以调到128但对应的Dropout建议同时加大到0.3防止多任务共享层过拟合。3.3 任务头该用几层从单层线性到MCDropout的取舍任务头是整个多任务网络最容易被低估的地方。很多开原实现里任务头就是一个nn.Linear(hidden_size, 1)直接输出单值。这样做在样本量充足时没问题但在污染物波动剧烈、且不同任务与共享特征的映射关系差异大时单层线性拟合能力不足PM2.5和O3的浓度突变会被平滑掉。我倾向于每个任务头用两层线性加ReLU并在中间加Dropout。上面代码里就是两层。但有个边界当任务数增加到八到十个、每个任务头都要单算梯度时两层线性会让参数量膨胀共享层学到的特征可能被任务头“各取所需”地带偏。此时可以把任务头收缩成一个共享的隐藏层加各任务单线性输出等于只让任务头最后一层独立。判断依据很简单训练集不大少于一万样本优先用单层头训练集几万以上用两层头能显著降低训练损失。另外如果预测频段覆盖小时级和日均级你可以在任务头里为每个任务设置独立的Dropout比例高频任务用0.3低频任务用0.1这比全局统一Dropout更稳。4. 训练与评估多任务损失怎么配比指标怎么各算各的4.1 多任务损失函数简单加权和为什么总翻车怎么补救把四个任务的MSE直接相加是最粗暴的多任务损失但在空气质量数据上几乎必翻车。翻车原因见下一章踩坑记录这里先给解法。常见补救有三层固定经验权重、可学习权重、不确定性加权。固定权重最简单比如PM2.5给1.2、PM10给1.0、NO2给1.0、O3给1.0因为PM2.5浓度范围大、MSE绝对值高需要人为压低不确定性加权是让模型自学每个任务损失的置信度在最后一章讲。可学习权重本质上是把权重当作模型参数一起优化但如果不加正则权重容易收敛到“把损失小的任务忽略掉”的极端解。我推荐的起步方案是“梯度归一化”——每个任务损失除以其对应标签的标准差的平方再用默认等权重相加。这样不需要额外超参统计量从训练集y上直接算出来。# 从训练集y计算每个任务的标准差用于损失缩放 std_per_task y_train.std(axis0) # shape (num_tasks,) std_per_task torch.tensor(std_per_task 1e-6) # 防除零 def multitask_loss(y_pred, y_true, stdstd_per_task): # 每个任务MSE除以方差等价于标准化后的MSE per_task_mse (y_pred - y_true) ** 2 weighted_mse per_task_mse / std ** 2 return weighted_mse.mean()这个损失函数帮你自动避免量纲主导问题。注意std要用训练集算不要混入验证集。如果你的目标不是浓度绝对值而是污染等级分类那损失要换成CrossEntropy但本项目的核心是回归预测MSE和MAE是主流。4.2 训练循环代码早停、学习率与梯度裁剪训练循环我见过很多版本但真正能稳定收敛的只有一套固定套路Adam优化器、初始学习率1e-3、Cosine退火或ReduceLROnPlateau、梯度裁剪max_norm1.0、加上基于验证损失的早停。下面是一个完整可跑的最小训练循环。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) opt optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-5) # ReduceLROnPlateau在验证损失连续3轮不降时学习率减半 scheduler optim.lr_scheduler.ReduceLROnPlateau( opt, modemin, factor0.5, patience3 ) Xt torch.tensor(X_train).float().to(device) yt torch.tensor(y_train).float().to(device) Xv torch.tensor(X_val).float().to(device) yv torch.tensor(y_val).float().to(device) def train_one_epoch(): model.train() opt.zero_grad() pred model(Xt) loss multitask_loss(pred, yt) loss.backward() # 梯度裁剪防治LSTM梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) opt.step() return loss.item() best_val float(inf) patience_counter 0 for epoch in range(100): train_loss train_one_epoch() model.eval() with torch.no_grad(): val_pred model(Xv) val_loss multitask_loss(val_pred, yv).item() scheduler.step(val_loss) if val_loss best_val: best_val val_loss torch.save(model.state_dict(), best_model.pt) patience_counter 0 else: patience_counter 1 if patience_counter 10: print(fepoch {epoch} 早停) break if epoch % 10 0: print(fepoch {epoch} 训练损失 {train_loss:.4f} 验证损失 {val_loss:.4f})这段代码把训练、验证、早停、模型快照全部整合了。几个参数需要解释weight_decay1e-5是L2正则对抗小样本过拟合max_norm1.0是梯度裁剪阈值LSTM反向传播容易梯度爆炸尤其当lookback24时如果不裁剪loss会突然变成NaNpatience3和patience_counter10分别控制学习率衰减和早停我用的是5轮学习率衰减、15轮早停更稳你可以按数据规模调。注意best_model.pt保存的是能复现最佳验证损失的权重而不是最后一轮权重这是最基本的后悔药。4.3 评估指标每个污染物单独算RMSE别用合并MAE糊弄多任务模型的评估要回到“独立任务”视角。你最终给导师或业务方的数字应该是每个污染物的RMSE、MAE、R2而不是四个任务合并后的平均MSE。因为合并指标会掩盖某个任务变差的事实。我建议写一个评估函数输出一张汇总表。from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score def evaluate_model(model, X, y, labeltest): model.eval() Xt torch.tensor(X).float().to(device) with torch.no_grad(): pred model(Xt).cpu().numpy() names [PM2.5, PM10, NO2, O3] print(f--- {label} ---) for i, name in enumerate(names): rmse np.sqrt(mean_squared_error(y[:, i], pred[:, i])) mae mean_absolute_error(y[:, i], pred[:, i]) r2 r2_score(y[:, i], pred[:, i]) print(f{name}: RMSE{rmse:.3f} MAE{mae:.3f} R2{r2:.3f}) return pred evaluate_model(model, X_test, y_test, labeltest)评估时有一个常见错误用训练集上的R2来论证模型“多任务有效”。这会被过拟合迷惑正确姿势是单独拿测试集并且与单任务基线模型做对比。所谓单任务基线不是指把多任务模型的任务头摘掉而是训练四个独立的、结构完全相同的LSTM网络每个只预测一种污染物。对比时你还要关注训练时间、参数量多任务模型的优势要在相近的参数量前提下成立。如果只是网络参数量多导致效果好那结论就不该归功于多任务。5. 多任务空气质量预测的踩坑笔记五个高频翻车现场5.1 翻车一PM2.5还没遇到O3损失就被PM10主导了现象训练开始后loss下降很快但验证集上PM2.5和O3的RMSE几乎不降只有PM10在变化。打印每个任务的MSE后发现PM10的MSE比其他任务大一个数量级。原因PM10浓度的数值范围通常比PM2.5还宽受沙尘影响偶尔飙到几百甚至上千而O3的浓度区间可能只有几十到两百。在平方误差下PM10的梯度幅度天然碾压小量程任务。如果直接做损失相加共享LSTM会优先满足PM10其他任务在共享层上被压制。解决不要只调初始权重最省事的是使用4.1节的方差缩放损失。在训练循环里每个epoch打印每个任务单独的MSE实时监控差距。如果方差缩放后仍有任务拉胯再手动给弱势任务乘以1.5到2.0的系数。记住权重调参不是一次性的事每隔20轮观察一次各个任务的收敛速度。5.2 翻车二验证集指标好得离谱原来是时间泄露现象验证集RMSE比实际业务场景低30%以上测试集表现也很漂亮但把模型部署到新一周的数据上预测值偏高一截R2直接掉到0.2。原因滑窗样本之间存在重叠。比如X[i]覆盖[t-23, t]X[i1]覆盖[t-22, t1]它们的输入窗口和预测目标在时间轴上互相咬合。如果模型在一次epoch里看到全部样本LSTM有机会从相邻样本的标签中“记住”目标的变化趋势。尤其是当验证集的起始时间紧跟在训练集后训练集最后一个样本的预测目标与验证集第一个样本的输入窗口时间重叠等价于泄露。解决严格按时间切分并设置一个间隔间隙。例如训练集最后一条样本的预测目标是2月1日0点那么验证集第一条样本的输入开始时间应该至少从2月1日1点之后即训练与验证之间留出lookback个时刻的间隙。代码实现上你已经用了split_idx顺序切分如果还出现异常好的验证结果检查是否在切分前打乱了数据。另外序列预测中常见做法是“滚动验证”即每次验证都用模型前24小时真实值预测后1小时这更接近部署场景不要只用一次性切分。5.3 翻车三滑窗把训练集和验证集“粘”在一起预测成了开卷考试现象验证集R2达到0.98但换一个完全没参与训练年份的数据R2降到0.3这是“开卷考试”的典型特征。原因除了时间泄漏还有重复样本跨集合的问题。滑窗以1小时滑动生成样本相邻样本共享大量重复时间步。假设训练集尾声和验证集开头的两个小时都被用来构造样本那么验证集里可能包含与训练样本几乎相同输入但不同输出的实例。LSTM虽然不能直接复制但可以通过记住这段时间的模式来“蒙对”短期趋势。解决在切分时取决于精度我做三件事。第一样本构造完成后按时间顺序取索引确保每个样本的最后一个时间戳严格小于切分界限。第二去掉训练集与验证集边界的重叠样本即切分后用np.isin剔除那些输入窗口跨越边界的样本。第三如果你需要更严谨的评估采用分组交叉验证把连续30天划为一个折叠训练集和验证集不在同一时间窗内重叠。代码上我建议在make_samples返回时同时返回每个样本的结束时间戳便于清洗。# 假设X和time_idx是对齐的切分时用时间戳做硬边界 X_train X[time_idx[-1] split_time] X_val X[time_idx[0] split_time pd.Timedelta(hourslookback)]这段代码的关键是split_time lookback它强制验证集第一条样本的最早输入时刻比训练集最后一条样本的预测目标晚至少一个窗口彻底切断重叠。5.4 翻车四站点数据缺测率30%补全方法选错直接污染标签现象模型整体的RMSE在测试集上尚可但一到污染重污染日PM2.5大于200预测值总是偏低画曲线发现模型把峰值削平了。原因原始小时数据在重污染日经常出现传感器超量程或设备维护导致高浓度值被记为NaN。如果在前向填充时用下一小时的值填上一小时或者用同一站点均值填充会把30%左右的标签替换成平滑的均值模型自然学不到极值分布。解决缺测率高于20%的站点建议直接放弃该站点的单独建模使用邻站数据插值如果必须保留分特征处理。污染物的缺失值不要用全局均值使用“前一天同时刻”填充即时间匹配的循环插值。气象特征缺失可以线性插值因为气象变化连续。判断方法补全后统计每个污染物的分布分位数若P99比原始数据小很多说明极值被抹掉了。最稳妥的做法是把“是否缺失”也作为一个二值特征拼进输入让模型自己学习对缺失时刻降权。5.5 翻车五多任务模型不如单任务先检查共享层容量现象你兴致勃勃训练完多任务模型发现四个任务的RMSE都比各自单任务基线高特别是在NO2上差3%。这时候最容易得出“多任务没用”的结论。原因多任务模型要想追平甚至超过单任务前提是共享层容量足够。我见过很多代码把LSTM hidden_size设为32这对单任务够用但四个任务同时抢梯度时32维的共享特征根本不够表达四种污染物的差异化模式。另一个原因是任务头太薄单层线性把共享特征的个性化调度能力砍掉了。解决先做一个简单实验——把hidden_size从32翻到64同时每个任务头保持两层线性再对比。经验上这个改动就能把多任务从“略逊”扳成“略优”。如果仍然不行检查你选的四个任务相关性是否真的高计算各污染物浓度序列的Pearson相关系数相关性低于0.3的任务对会拖累彼此应当从同一模型里摘出去或者换用软共享结构。多任务不是万灵药它适合的是“任务间有可共享的底层模式”的场景空气质量正好是但不同站点不同季节这个前提也会变弱。6. 进阶一步用不确定性加权损失和早停把模型调到能上线到了这个阶段你已经能跑通基本流程。真正让模型从“能收敛”变成“可部署”的核心是学会把损失的配比交给模型自己学。常见做法是采用Kendall提出的同方差不确定性加权把每个任务的损失权重当作可学习参数初始化为0训练时与网络一起更新。import math class UncertaintyLoss(nn.Module): def __init__(self, num_tasks): super().__init__() # log_variance是可学习参数初始为0代表信任初始预测 self.log_vars nn.Parameter(torch.zeros(num_tasks)) def forward(self, y_pred, y_true): losses [] for i in range(y_true.shape[1]): mse (y_pred[:, i] - y_true[:, i]) ** 2 # 加权MSE 正则项保留数值稳定性 loss torch.mean(mse * torch.exp(-self.log_vars[i]) self.log_vars[i] * 0.5) losses.append(loss) return torch.stack(losses).sum() criterion UncertaintyLoss(num_tasks4)这个损失的含义很直观log_vars[i]如果变负权重exp(-log_vars[i])增大该任务梯度被放大正则项log_vars[i] * 0.5会阻止权重无限增大让模型自动找到平衡。实际使用中配合前面的早停epoch 10以后每个任务的log_var会稳定下来你可以打印它们来看模型认为哪个任务最难学。另外部署前别忘用测试集按4.3节的逐指标评估并且保存一份带预处理参数的完整配置比如Scaler的均值和方差、补全策略、lookback值否则换环境后你无法复现同一个预测。我做这类项目最大的教训是不要在调参前急着上多任务。基线单任务先跑通确认数据没问题再切到多任务模型对比。多任务带来的收益通常有5%到10%的RMSE提升但如果前期数据或切分有洞整个对比就失去意义。把上面每一章的坑都踩过一遍之后我建议你用连续两个季度的数据做一次滚动回测看模型在季节交替时是否稳定。这个点比任何花哨的网络结构都重要。希望这个方向能帮你在自己的空气质量预测任务里少走几段弯路。本文还有配套的精品资源点击获取