简介:这是一份基于深度学习的多任务空气质量预测系统完整项目,面向环境科学、数据挖掘方向的开发者与毕业设计学生,目标是同时预测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.py,config.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_len=24, pred_len=6): 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_size=64, num_layers=2, num_tasks=6, pred_len=6, dropout=0.2): super().__init__() self.encoder = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True, dropout=dropout) 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) 进入 LSTM;hidden_size 默认 64,num_layers 默认 2;task_towers 是六个完全独立的 MLP,每个塔输入共享编码器的最终隐含状态,输出形状是 (batch, pred_len)。forward 只取最后一层 LSTM 的隐含状态 h_n[-1],不用每个时间步输出去做逐点预测,这样计算量小,也避免梯度在长序列上消散。返回值是一个长度为 num_tasks 的 list,每个元素对应一个污染物的未来 6 小时预测序列,损失函数在这个 list 上分别算 MSE 再加权求和。
3.3 什么时候从硬共享换 MMoE
硬共享的问题在于:所有任务被迫使用同一个特征表示,如果任务之间冲突明显,比如某个任务更依赖温度,另一个更依赖风速,共享编码器只能取折中。MMoE(Multi-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_len | 24 | 回看多少小时 |
| pred_len | 6 | 预测未来多少小时 |
| lr | 1e-3 | 初始学习率 |
| batch_size | 64 | 每轮迭代样本数 |
| task_weights | [1, 1, 1, 1, 1, 1] | 各污染物任务的损失权重 |
| early_stop | 15 | 验证 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 的量级再决定权重。这两个习惯帮我少翻了好几次车。这套多任务空气质量预测的代码组织清晰,适合照着自己替换成别的城市数据,希望帮到你。
本文还有配套的精品资源,点击获取