
简介这是一份基于深度学习的多任务空气质量预测系统完整项目面向环境科学、数据挖掘方向的开发者与毕业设计学生目标是同时预测PM2.5、PM10、二氧化硫、氮氧化物等多项污染物指标。压缩包共46个文件总大小5.08MB主要由36个csv格式监测数据、7个Python源码脚本和3个Markdown说明文档构成data、models、utils、train、eval等模块分工清晰覆盖从原始数据整理到模型部署的全流程。项目不仅给出了数据清洗、缺失值处理与特征工程的具体脚本还实现了多任务学习的网络结构设计可选用CNN、RNN或Transformer等架构通过共享层与独立子网联合优化多个预测目标并提供MSE、MAE等评估与可视化脚本方便拓展到其他时序预测场景。目前已有142人参与学习适合具备Python与深度学习基础、希望快速搭建整套预测流程的读者。1. 多任务空气质量预测一次训练同时输出 PM2.5、PM10 和气体污染物做空气质量预测的同行应该都遇到过这个尴尬单任务模型在 PM2.5 上拟合得不错一到 SO2、NO2 这种样本少、波动大的指标就基本靠猜。这份基于深度学习的多任务空气质量预测模型设计与实现恰好是冲这个问题来的。它把 PM2.5、PM10、SO2、NO2 等指标放进同一个模型里共享底层时序特征每个任务各接一个小网络独立输出。项目用 Python PyTorch 实现从 data_process、config、models、train 到 eval 是一条完整链路不是只丢一个 notebook 的玩具 Demo。我拆完包第一感受是它很适合正在做深度学习毕设、时序预测实战项目或者想给竞赛基线换个多任务方案的人拿来改数据路径就能跑通全流程。2. 数据预处理从 stations_data 到 xy序列切片与归一化的做法多任务模型能不能学出东西七成由数据组织方式决定神经网络只是把剩下三成做好。这个包的数据链路分得比较清楚我先按目录拆一遍再讲切片和归一化里最容易出问题的两个点。2.1 先看清目录stations_data、xy 和 models.py 的分工整个压缩包解压后是 Graduation-Design-main 目录我拿到包会先看目录结构再决定从哪个脚本开始读。常见的组织方式是原始数据放 stations_data预处理后的训练样本放 xy模型定义集中在 models.py训练和评估分别是 train.py 和 eval.pyconfig.py 放全局参数。目录结构大致是下面这样Graduation-Design-main/ ├── stations_data/ # 原始监测站点数据 ├── xy/ # data_process.py 生成的切片样本 ├── data_process.py # 数据清洗、采样、归一化 ├── config.py # 全局参数 ├── models.py # 多任务模型定义 ├── train.py # 训练入口 ├── eval.py # 验证评估脚本 ├── utils.py # 公共工具绘图、指标计算 ├── cuda_test.py # 检查 GPU 环境是否可用 └── models.md # 模型结构说明文档这样划分的好处是数据、模型、训练三层解耦。要换成自己的数据最该动的是 data_process.py 和 config.py想换网络结构只需要改 models.py训练循环基本不用碰。cuda_test.py 是我建议先跑的一个脚本它帮你确认 PyTorch 能不能正常调用 GPU避免后面 train.py 一上来就报 CUDA 错误你还以为是模型写错了。2.2 从站点原始数据到 xy滑动窗口切序列stations_data 里通常按站点或城市存放小时级监测数据列包括时间戳、PM2.5、PM10、SO2、NO2、CO、O3 浓度以及温度、湿度、风速、风向、气压等气象特征。data_process.py 做的事是把这些原始表对齐到统一时间索引做缺失值填充再按滑动窗口切成 x 和 y。核心切片逻辑我复述一下import numpy as np def build_samples(values, seq_len24, pred_len6): samples_x, samples_y [], [] for i in range(len(values) - seq_len - pred_len 1): x values[i : i seq_len] # 过去 24 步 y values[i seq_len : i seq_len pred_len] # 未来 6 步 samples_x.append(x) samples_y.append(y) return np.array(samples_x), np.array(samples_y)参数很好理解seq_len 是回看窗口默认 24对应小时级数据的过去一天pred_len 是预测步长默认 6也就是预测未来 6 小时。x 的形状是 (样本数, 24, 特征数)y 的形状是 (样本数, 6, 目标数)。特征数包含气象和污染物列目标数就是你要做多任务预测的那几个污染物指标。窗口每次滑动 1 步所以样本之间是重叠的这是时序预测的常规做法能最大化利用有限的数据。切片完成后data_process.py 会把结果存成 xy 目录下的 npy 文件train.py 直接加载。2.3 归一化策略训练集的 mean/std 要单独存归一化这个环节看着简单其实是整个项目里最容易埋雷的地方。很多同学会把整段数据放在一起 fit 一个 StandardScaler再切训练集和验证集这种做法在时序预测里是错的因为验证集的均值已经混进了归一化参数属于未来信息泄露。正确做法是只用训练集 fit再拿同一组参数去 transform 验证集和测试集。from sklearn.preprocessing import StandardScaler scaler StandardScaler() train_values scaler.fit_transform(df_train[feature_cols]) val_values scaler.transform(df_val[feature_cols]) np.save(xy/feature_mean.npy, scaler.mean_) np.save(xy/feature_std.npy, scaler.scale_)这里有个关键点scaler.mean_ 和 scaler.scale_ 必须保存下来eval.py 在做反归一化时要读这两个文件否则你评估出来的是标准化空间里的误差没法换算成真实的 ug/m³。我一般会在 train.py 里加一句断言检查验证集首行数据的 mean 是否接近训练集均值如果偏差超过 1 个数量级就先回头检查是不是 fit 范围切错了。多任务场景里这个习惯更重要因为每个污染物列的量纲差异本来就大归一化参数稍微不对任务权重就全乱了。注意z-score 是处理这种多列量纲混合数据的底线方案min-max 缩放也可以但预测时新来的数据可能超出训练范围反归一化容易失真我一般优先 z-score。3. 模型结构models.py 里的共享编码器与多任务塔选型数据准备好之后接下来就是模型侧的设计。这个项目的核心价值不在网络有多深而在“共享编码器 独立任务塔”这个多任务结构怎么组织。我先讲为什么值得这么做再拆默认的 MultiTaskLSTM最后说何时值得换到 MMoE。3.1 为什么多任务要比单任务“划算”单独训练六个单任务模型每个模型都要重新学一遍“晴天风大污染物扩散快”“凌晨边界层压低导致 PM2.5 积累”这类底层天气规律。问题在于空气质量数据的样本通常集中在少数站点气体指标 SO2、NO2 的缺失率和波动又大单任务模型很容易在小样本上过拟合。多任务学习解决的是这个问题共享的 LSTM 编码器负责提取通用的时序模式比如周期性、趋势、气象条件对扩散的影响这些规律只学一次每个任务塔再在共享特征之上做各自的非线性映射。PM2.5 和 PM10 同源同沉降SO2 和 NO2 都来自燃烧排放它们之间本来就有物理耦合关系共享表示能让稀疏任务借用丰富任务学到的上下文。这个设计在数据量有限的气象、交通、能耗预测场景里几乎总是优于同等参数量的单任务模型。3.2 MultiTaskLSTM共享 LSTM 编码器 任务塔models.py 里默认的多任务模型结构核心是两层 LSTM 作编码器后接六个独立的任务塔。我用 PyTorch 复述一遍它的典型写法import torch.nn as nn class MultiTaskLSTM(nn.Module): def __init__(self, input_size, hidden_size64, num_layers2, num_tasks6, pred_len6, dropout0.2): super().__init__() self.encoder nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.task_towers nn.ModuleList([ nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, pred_len) ) for _ in range(num_tasks) ]) def forward(self, x): out, (h_n, _) self.encoder(x) last h_n[-1] # (batch, hidden_size) return [tower(last) for tower in self.task_towers]几个参数说清楚input_size 是特征列总数序列以 (batch, seq_len, input_size) 进入 LSTMhidden_size 默认 64num_layers 默认 2task_towers 是六个完全独立的 MLP每个塔输入共享编码器的最终隐含状态输出形状是 (batch, pred_len)。forward 只取最后一层 LSTM 的隐含状态 h_n[-1]不用每个时间步输出去做逐点预测这样计算量小也避免梯度在长序列上消散。返回值是一个长度为 num_tasks 的 list每个元素对应一个污染物的未来 6 小时预测序列损失函数在这个 list 上分别算 MSE 再加权求和。3.3 什么时候从硬共享换 MMoE硬共享的问题在于所有任务被迫使用同一个特征表示如果任务之间冲突明显比如某个任务更依赖温度另一个更依赖风速共享编码器只能取折中。MMoEMulti-gate Mixture-of-Experts是常见的替代方案它对每个任务单独生成一组门控权重动态组合多个专家网络的输出。方案共享方式适用场景硬共享所有任务共用同一个编码器任务相关性较强、样本量中等MMoE多个专家网络按门控加权组合任务冲突明显、指标特性差异大这个项目的 models.py 里两种结构都有涉及。默认走硬共享因为样本量不算大多专家会显著增加参数量收益不一定覆盖成本。如果实测发现 SO2 任务被 PM2.5 带偏得非常厉害再用 config 里的 model_type 切换 MMoE。MMoE 的改动核心是定义四到六个专家网络每个任务配一个门控线性层门控输出经过 softmax 后对专家输出做加权求和再送入任务塔。换过去之后训练收敛会变慢batch_size 也需要相应调小这是正常现象。4. 训练与评估train.py、eval.py 的参数口径和反归一化模型结构定了接下来就是怎么把它训练出来。这个包的 train.py 和 eval.py 分工明确前者负责加载数据、跑训练循环、保存最优权重后者负责在测试集上输出每个任务的独立指标。我重点讲 config.py 里要动的参数、多任务损失怎么写以及 eval.py 为什么必须先反归一化再报指标。4.1 config.py训练前要动的六个参数打开 config.py 会发现参数都集中在顶部改动不用到处找。我列了一份我通常按下表检查的参数参数参考取值作用seq_len24回看多少小时pred_len6预测未来多少小时lr1e-3初始学习率batch_size64每轮迭代样本数task_weights[1, 1, 1, 1, 1, 1]各污染物任务的损失权重early_stop15验证 loss 连续多少轮不降就停学习率建议先按 1e-3 跑 20 个 epoch观察 loss 曲线是否出现反复震荡如果训练 loss 降到某一步后长期不动再考虑降到 5e-4。task_weights 默认全 1 只是基线它不是最终答案具体怎么调我在避坑章节细说。4.2 train.py 的训练循环多任务加权损失怎么算多任务训练和单任务最大的区别在 loss 计算。每个任务各有自己的预测输出不能直接对整个输出张量算一个 MSE得先按任务索引拆开再逐一算损失最后按权重合并。核心循环写出来大概是for epoch in range(epochs): model.train() train_loss 0.0 for x_batch, y_batch in train_loader: x_batch x_batch.to(device) # (batch, seq_len, n_feat) y_batch y_batch.to(device) # (batch, pred_len, n_task) preds model(x_batch) # list of (batch, pred_len) loss 0.0 for t, pred in enumerate(preds): loss task_weights[t] * mse_loss(pred, y_batch[:, :, t]) optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() * x_batch.size(0)这段逻辑里最关键的是 y_batch[:, :, t]它把目标张量的第三个维度当作任务索引取出对应污染物的未来序列和预测值计算 MSE。task_weights 的意义在于平衡不同任务对共享编码器梯度的影响PM2.5 浓度数值大、波动剧烈MSE 天然比其他气体高出一个量级如果不加权共享编码器的梯度基本被它主导。数据稀疏的气体任务权重一般要往上调比如 1.2 到 1.5但也不能调太高否则编码器偏向某个任务整体性能会掉。提示训练前先跑一遍 cuda_test.py确认 PyTorch 能调用 GPU。如果没有 GPU把 batch_size 降到 16 到 32 在 CPU 上跑小规模数据一样能验证全流程。4.3 eval.py反归一化后的指标才是人话训练过程用的归一化数据如果 eval.py 直接报 MSE 0.12读者根本不知道这对应多少微克每立方米。评估前必须做一次反归一化把预测值和真实值换回原量纲再计算 MAE、RMSE、R² 这类指标。反归一化的坑在于 StandardScaler 是按所有特征列拟合的所以要把预测值先放回完整的特征矩阵里target_idx [0, 1, 2, 3, 4, 5] # 目标列在 feature_cols 中的位置 y_pred_expand np.zeros((y_pred.shape[0], num_feat)) y_pred_expand[:, target_idx] y_pred y_pred_raw scaler.inverse_transform(y_pred_expand)[:, target_idx] y_true_expand np.zeros((y_true.shape[0], num_feat)) y_true_expand[:, target_idx] y_true y_true_raw scaler.inverse_transform(y_true_expand)[:, target_idx] mae np.mean(np.abs(y_pred_raw - y_true_raw)) rmse np.sqrt(np.mean((y_pred_raw - y_true_raw) ** 2))inverse_transform 要求输入列数等于拟合时的特征列数所以先用零矩阵占位再把目标列填进去反归一化后切出目标列。MAE 给的是平均绝对误差量纲直观RMSE 对大偏差更敏感如果你关注的是重污染时段的峰值预测能力就重点看 RMSE。eval.py 还会按任务分别输出这两个指标六个任务各一张表方便定位是哪个污染物拖了后腿。5. 避坑指南多任务预测里最常见的五个翻车点这部分是我自己跑时序项目踩出来的血泪经验。按“现象 → 原因 → 解决”写每一条都对应一种实际会发生的情况拿到这个包复现时大概率会撞上其中至少一条。5.1 预测曲线整体偏平输出去拟合均值了现象测试集预测曲线像一条缓慢蠕动的均值线PM2.5 的早晚高峰峰值全被削平RMSE 看着不高但图一画出来就知道模型废了。原因LSTM 隐层容量不够或者回看窗口太短。模型学到的妥协方案是“过去 24 小时均值约等于未来 6 小时均值”这个近似在平滑时段误差小在突变时段基本失效。解决先把 hidden_size 从 64 提到 128、num_layers 从 2 提到 3再把 seq_len 从 24 拉到 48看 val loss 是否明显下降。我一般会同时打印训练集和验证集各自 loss 的差值如果训练集远低于验证集说明模型容量已经够了问题出在数据切分而不是容量。5.2 随机切分导致未来信息泄露验证集虚高现象训练和验证 loss 都低得离谱但模型一换到未来几个月的真实数据就崩像换了个环境。原因对整个时间序列直接用了 sklearn 的 train_test_split默认 shuffle 打开模型在训练时见过验证集相邻时段的上下文这是未来信息泄露。时序预测里这不是玄学是确定性的错误。解决按时间顺序切分前 70% 日期训练、中间 15% 验证、最后 15% 测试不允许随机打乱。split_idx int(len(df) * 0.7) train_df df.iloc[:split_idx] val_df df.iloc[split_idx:] val_split_idx int(len(val_df) * 0.85) val_df val_df.iloc[:val_split_idx] test_df val_df.iloc[val_split_idx:]切完先打印三个数据段的起始日期和结束日期确认没有交叉。这一步花不了两分钟能省掉后面所有“验证集指标好、上线就翻车”的排查时间。5.3 任务权重失衡气体指标被 PM2.5 带偏现象总 loss 一直下降但分开看 SO2、NO2 的任务 MAE 纹丝不动甚至比单任务还差。原因PM2.5 浓度数值大MSE 天然比其他气体高一个量级。共享编码器的梯度被大数值任务主导气体任务的小误差对总 loss 的贡献可以忽略等于白学了。解决把 task_weights 从全 1 调整到 PM2.5 适当降权、气体任务升权比如 [0.6, 0.8, 1.3, 1.4, 1.2, 1.0]。更稳的做法是按各任务 loss 的倒数做缩放每 5 个 epoch 更新一次权重而不是全局固定。5.4 按站点维度切分导致的评估偏差现象训练时指标很好看但测试结果比单任务还差而且换一个种子结果差异巨大。原因空气质量数据是站点和时间共同构成的。如果切分时按站点划分有些站点数据几乎全进了训练测试只剩下少量站点多任务模型学到的是站点特异模式而不是可迁移的时间依赖规律。解决检查 dataloader 的切分逻辑确保同一天的样本不会跨训练、验证边界。最简单的方式是先用日期生成一个全局时间索引再基于索引切分站点维度的随机划分只在特殊场景用比如测试新站点的泛化能力。5.5 cuda_test.py 报错或 batch_size 稍高就 OOM现象跑 train.py 先报 CUDA error或者把 batch_size 提到 64 之后直接显存溢出任务塔数量一多显存占用比预期高很多。原因前半句通常是 PyTorch 版本和显卡驱动不匹配后半句是硬共享编码器虽然省了部分参数但六个任务塔同时计算还是会占显存尤其 pred_len 大时中间张量多。解决先单独跑 cuda_test.py它会输出能不能调用 GPU、CUDA 版本、驱动信息报错就去匹配对应版本的 PyTorch 安装命令或者临时切到 CPU 跑小规模数据验证逻辑。显存不够时把 batch_size 减半配合梯度累积等价于没降低模型效果。这个习惯我后来一直保留换任何机器跑项目第一件事永远是跑环境检测脚本而不是直接跑训练。6. 进阶验证任务权重自动调整与逐时段误差卡点模型能稳定跑通之后一个实用的进阶方向是让任务权重“自动”起来。固定权重的问题在于训练中期不同任务的收敛速度不一样早期 SO2 学得慢后期 PM2.5 又开始过拟合一套权重很难照顾全程。我常用一个简化版 GradNorm 思路记录每个任务当前的 loss 值按比例反推下一轮的权重让大 loss 任务在梯度里多占些话语权。loss_scale torch.tensor([ 1.0 / (mse_loss(pred, y_batch[:, :, t]).item() 1e-6) for t, pred in enumerate(preds) ]) task_weights (loss_scale / loss_scale.sum() * num_tasks).tolist()这段代码每隔 N 个 epoch 执行一次不逐 batch 更新。核心逻辑是当前 loss 大的任务权重更高补偿量纲差异同时损失函数里加了 1e-6 防止除零。要注意权重不要每个 batch 都动荡更新否则共享编码器的优化方向反复横跳训练曲线会很抖。如果发现震荡就把更新周期拉长到 10 个 epoch。另一个值得做的验证是把误差按小时分组。把测试集预测结果按真实时刻的 0 到 23 点分别算 MAE你会发现早高峰 7 到 9 点误差显著上升夜间反而很低。这对应排放源变化快的时段模型难以捕捉突增的交通源贡献。看到这个结果后我会给特征加上节假日和工作日的虚拟变量再重跑一遍通常能压掉 3% 到 5% 的峰值误差。这个分析也很适合写进毕设的实验章节比起只报一个总指标更有说服力。我后来再跑时序项目都会在 train.py 里先打印一份日期范围确认 train、val、test 三段没有重叠多任务项目每次换数据也先看一眼各任务 loss 的量级再决定权重。这两个习惯帮我少翻了好几次车。这套多任务空气质量预测的代码组织清晰适合照着自己替换成别的城市数据希望帮到你。本文还有配套的精品资源点击获取