多任务学习空气质量预测:PyTorch实现、损失函数与训练避坑指南
简介面向本科毕业设计场景的深度学习多任务空气质量预测项目完整解决从多站点监测数据整理、特征工程到模型构建、训练评估的闭环需求。压缩包共49个文件约5.08MB其中36个CSV文件对应不同地面站点的空气质量时序数据7个Python脚本分工明确涵盖数据预处理、模型定义、训练、评估、GPU环境检测及配置管理等环节4个Markdown说明文档与1个TXT说明文件提供项目信息、模型结构和使用指引整体目录结构清晰便于按模块逐层阅读。内容包含可复用的多任务预测模型代码与真实站点数据读者可深入理解多任务学习在空气质量预测中的设计思路例如如何共享底层特征、组织多任务损失函数、实现统一的评价指标同时脚本中注释与文档说明兼顾可作为毕业设计实验复现和论文写作的参考蓝本。无论是直接进行实验对比还是抽取其中模块做改造都能显著缩短从零搭建的周期。目前已有83人学习下载适合计算机、环境科学及相关专业正在开展此类课题的本科生。1. 本科毕设做多任务空气质量预测先想清楚它和单任务差在哪拿到“基于深度学习的多任务空气质量预测模型设计与实现”这个题目时很多人的第一反应是“多任务 多跑几个模型”比如预测完 PM2.5 再预测 PM10最后拼在一起交差。这个理解会让整个毕设跑偏。真正的多任务学习是一套深度学习模型共享底层的特征提取器同时预测多个污染物浓度或其他相关指标而不是训练多个独立网络再合并结果。它的价值在于不同污染物受同一套气象和排放条件驱动共享编码器能学到更鲁棒的时序特征在数据量有限时还能互相约束、降低过拟合。这篇笔记面向正在做毕业设计、需要把这个题目落地跑通的从业者——包括打算用 PyTorch 实现、想搞懂多任务损失怎么写、以及踩过验证集指标虚高这类坑的人。我把多任务架构的取舍、数据构造、训练调参、避坑清单和答辩验证方式一次讲清楚。2. 先定架构再碰代码任务头设计、特征构造与数据集组织2.1 任务头怎么设计硬共享编码器加任务专属输出头多任务学习在空气质量预测里最常见的落地形态是“硬共享 多输出头”。底部是一个共享编码器负责从历史时序里提取特征编码器之后分出几个并行的小全连接层每个对应一个预测任务。常见任务头有两种定义方式按污染物定义任务头PM2.5、PM10、NO2、O3、CO 各占一个输出头每个头输出未来 12 小时或未来 24 小时的浓度序列。按预测步长定义任务头同一个污染物每个头负责不同的预测时延比如 1 小时头、6 小时头、24 小时头。我一般建议毕设选第一种理由是它最直观答辩时解释成本低而且各输出头的物理意义明确——每个头预测一种污染物的未来浓度变化。按步长分头会让损失函数计算变得绕预测长度一旦不同标签维度和评估口径都会打架。还有一种备选方案是分站点每个站点一个输出头。但如果你的数据集是单站点或多站点混合站点头的鲁棒性很差某个站点数据缺失时整个头就废了。相比起来按污染物分头对数据缺失的容忍度更高。编解码器结构上常见做法是先用 1D 卷积做局部特征提取再接 GRU 或 LSTM 抓长期依赖最后每个任务头接一个全连接层。这个结构比单纯堆 LSTM 要好训练收敛也更稳定。2.2 输入特征与滑窗构造把历史天气、浓度和时间编码拼成张量空气污染预测的输入特征一般分三组特征类别典型字段说明历史浓度PM2.5、PM10、NO2、SO2、O3、CO 小时均值核心特征也是各任务头的标签来源气象温度、相对湿度、风速、风向、气压、降水量污染物扩散的关键外部变量通常要滞后对齐时间编码小时、星期几、是否节假日交通排放和工作日效应的弱特征但很管用风速和风向不要直接丢进模型。风向是 0 到 360 度的循环量直接当数值输入会让模型误以为 350 度和 10 度相差很大。常见做法是拆成 sin 和 cos 两个分量再输入。构造训练样本时我一般用过去 24 小时的数据预测未来 12 小时。对应到张量形状上输入 X 的形状是[batch_size, 24, num_features]输出标签 Y 的形状是[batch_size, 12, num_tasks]。num_tasks 就是任务头数量选了 5 个污染物就是 5。下面这段代码构造滑窗样本注释里写了关键边界处理import numpy as np def create_samples(data, input_steps24, output_steps12, step1): data: 按时间排序的二维数组 [num_timestamps, num_features] 返回 X 和 Y形状分别为 [num_samples, input_steps, num_features] 和 [num_samples, output_steps, num_tasks] X, Y [], [] # time_index 用来判断样本时间归属防止 train/test 穿越 time_tags [] for t in range(len(data) - input_steps - output_steps): # 过去 input_steps 小时的特征 x data[t : t input_steps] # 未来 output_steps 小时的目标浓度只取污染物列 y data[t input_steps : t input_steps output_steps, pollutant_indices] X.append(x) Y.append(y) time_tags.append(t input_steps) # 记录每个样本的预测起点 return np.array(X), np.array(Y), np.array(time_tags)这段代码有两个细节容易出错。第一是step参数默认取 1 表示滑窗每次移动一小时样本量最大但相邻样本高度重叠。如果显存吃紧或训练太慢可以把 step 调成 2 或 3但不要调太大否则样本数骤减多任务训练容易欠拟合。第二是time_tags它记录每个样本预测起始的真实时间戳这个后面切分训练集和测试集时要用来避免数据穿越比简单按样本序号硬切要安全得多。2.3 zip 解压后的目录组织与数据划分顺序毕设代码包拿到手通常是个压缩包很多人第一个念头是直接解压跑通再看代码。我的建议是先把原始数据、中间处理结果和模型权重分开全程用相对路径。否则代码跑一半找不到数据文件或者混合进上一步错误的缓存排查成本很高。常见的目录结构长这样air_quality/ ├── data/ │ ├── raw/ # 原始 CSV一般不修改 │ ├── processed/ # 滑窗切分后的 npy 文件或清洗后的 CSV │ └── splits/ # train/val/test 的时间索引文件 ├── models/ # 训练好的权重和 checkpoint ├── src/ │ ├── dataset.py # 数据加载 │ ├── model.py # 多任务网络定义 │ ├── train.py # 训练循环 │ └── evaluate.py # 指标计算与可视化 └── runs/ # 训练日志、TensorBoard 输出数据划分顺序上时间序列数据永远按时间顺序切不能按普通分类任务那样随机打乱后切。随机切会让模型在训练时“看到”未来数据效应在空气质量预测里非常明显——静稳天气持续几天训练集和验证集如果交错在同一个污染过程里验证指标会异常好看等到换一个时间段的真实数据就原形毕露。常见切分比例是前 70% 训练、15% 验证、15% 测试而且要留出足够大的时间缓冲带避免滑窗把训练样本的结尾和测试样本的开头拼在同一个窗口里。3. 多任务模型从零跑通PyTorch 代码、损失函数与训练循环3.1 共享编码器加多任务头的模型定义我用 PyTorch 实现时模型分为两层底层是共享的卷积加循环编码器上层是并行任务头。卷积层的作用类似局部滤波器抓相邻小时的突变特征比如风速骤降导致的浓度快速上升GRU 层再把局部特征组合成全局时序表示。以下是核心代码import torch import torch.nn as nn class AirQualityMultiTaskModel(nn.Module): def __init__(self, num_features, hidden_size128, num_layers2, num_tasks5, output_steps12): super().__init__() # 共享编码器Conv1d 抓局部模式GRU 抓长程依赖 self.conv nn.Sequential( nn.Conv1d(num_features, 64, kernel_size3, padding1), nn.ReLU(), nn.Conv1d(64, 32, kernel_size5, padding2), nn.ReLU(), ) self.gru nn.GRU( input_size32, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropout0.2 if num_layers 1 else 0.0, ) # 每个任务一个输出头预测 output_steps 小时的浓度序列 self.task_heads nn.ModuleList([ nn.Sequential( nn.Linear(hidden_size, 64), nn.ReLU(), nn.Linear(64, output_steps), ) for _ in range(num_tasks) ]) def forward(self, x): # 输入 x: [batch, input_steps, num_features] # Conv1d 要求 [batch, channels, length]所以先转置 x x.permute(0, 2, 1) x self.conv(x) x x.permute(0, 2, 1) out, _ self.gru(x) # 取最后一个时间步的隐藏状态作为整段序列的表示 last_hidden out[:, -1, :] results [head(last_hidden) for head in self.task_heads] # 堆叠成 [batch, output_steps, num_tasks] return torch.stack(results, dim-1)这个结构里有两个参数直接影响训练行为。hidden_size128是 GRU 的隐层维度决定共享表示的表达能力如果数据量只有几千个样本128 足够再大会明显过拟合。output_steps12是每个任务头的输出长度对应未来 12 小时浓度序列。需要注意task_heads放在ModuleList里前向时逐个调用每个头是独立参数不会互相干扰。Batch 维度上输入形状是[batch, 24, num_features]经过卷积转置后变为[batch, channels, length]卷积输出再转回来给 GRU。3.2 多任务损失函数加权求和是标配权重要按量级调多任务的损失函数最常用做法是把各任务的损失加权求和。空气质量数据里PM2.5 和 O3 的数值范围差别很大甚至同一个污染物的不同季节也有量级差异所以不能简单把 5 个任务的 MAE 相加必须给每个任务单独设权重。常见的损失代码def multi_task_loss(pred, target, weights, loss_fnnn.L1Loss()): pred: [batch, output_steps, num_tasks] target: [batch, output_steps, num_tasks] weights: 长度为 num_tasks 的张量各任务损失权重 total_loss 0.0 for t in range(pred.size(-1)): # 对每个任务分别算损失后再加权 task_loss loss_fn(pred[..., t], target[..., t]) total_loss weights[t] * task_loss return total_loss损失函数的选择上我通常用 L1Loss 也就是 MAE而不是 MSE。PM2.5 浓度分布偏态很强偶尔的重污染事件数值极大MSE 会被这些极端样本主导模型为了压低个别大误差反而牺牲多数普通时段的精度。MAE 对偶发高值更客观训练也更稳定。如果某个任务的数值范围确实和其他任务差很多建议先把标签做标准化而不是盲目调权重。标准化之后各任务损失的量级才具备可比性权重也更容易解释。关于权重本身最简单的做法是均等权重也就是weights [1.0] * num_tasks适合快速验证。进阶做法是在损失里加可学习的噪声参数让模型自己学任务置信度但毕设场景没必要一开始就走这个方向。我的调参路线是先均等权重跑一轮看每个任务的验证损失哪个任务的 loss 居高不下说明它的任务太难或标签噪声大就单独给它降权重让模型把容量让给更主流的任务。这里说的“主流”不是指哪个污染物更重要而是指样本规律更清晰、噪声更低的任务。3.3 训练循环与早停checkpoint 和 patience 一个都不能省训练循环里最容易忽视的是 checkpoint 保存策略和早停逻辑。很多毕设代码训练时只在最后存一次模型要是中途验证指标已经最好、后面过拟合了就只能重新跑。我一般每个 epoch 验证一次只要验证损失创新低就覆盖保存一次权重同时记录连续多少个 epoch 没有提升就提前终止。核心循环如下best_val float(inf) patience 10 bad_epochs 0 for epoch in range(num_epochs): model.train() train_loss 0.0 for batch_x, batch_y in train_loader: batch_x batch_x.to(device) batch_y batch_y.to(device) optimizer.zero_grad() pred model(batch_x) loss multi_task_loss(pred, batch_y, weights) loss.backward() # 梯度裁剪防止 GRU 训练后期梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() train_loss loss.item() * batch_x.size(0) train_loss / len(train_loader.dataset) # 验证阶段不更新梯度只算指标 model.eval() val_loss 0.0 with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x batch_x.to(device) batch_y batch_y.to(device) pred model(batch_x) val_loss multi_task_loss(pred, batch_y, weights).item() * batch_x.size(0) val_loss / len(val_loader.dataset) if val_loss best_val: best_val val_loss torch.save(model.state_dict(), models/best_checkpoint.pt) bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: print(fEarly stop at epoch {epoch}) breakclip_grad_norm_是长序列训练里的保命配置初始值设为 5.0 通常不会有副作用。学习率建议从 1e-3 开始用 Adam 优化器。如果验证损失在早期就剧烈震荡先怀疑学习率而不是模型结构如果训练损失下降正常但验证损失不降先检查归一化和数据切分再考虑加大 dropout。设备方面device torch.device(cuda if torch.cuda.is_available() else cpu)CPU 上跑小数据集也能接受但 24 小时滑窗加 GRU 还是建议有 GPU 环境。4. 让模型真正收敛的四个细节归一化、滑窗边界、随机种子与超参表4.1 归一化必须只 fit 训练集否则验证指标是假的空气质量预测里最隐蔽的翻车现场是整段数据一起做标准化。假设你用StandardScaler直接 fit 全部数据再切分训练统计量里混入了测试集的信息等于考试时偷看了答案。验证集指标会虚高而且换一段新时间数据就崩盘。正确做法是先切分再在训练集上.fit()然后分别 transform 训练集、验证集和测试集from sklearn.preprocessing import StandardScaler scaler StandardScaler() # 只在训练时间段上学习均值和方差 scaler.fit(train_features) train_features_scaled scaler.transform(train_features) val_features_scaled scaler.transform(val_features) test_features_scaled scaler.transform(test_features)标签的执行策略分两种如果损失函数是 MAE标签可以不做标准化因为 MAE 是绝对值误差解释更直观如果损失函数想用 MSE 或权重差异化标签建议也标准化否则数值大、方差大的污染物会主导梯度。标签标准化后预测值要逆变换回原始浓度范围再算 RMSE 和 R2不能拿标准化后的值直接报指标否则答辩老师问起单位会很尴尬。4.2 滑窗边界坑预测起点必须严格落在划分时间线内时间序列数据按比例切分后滑窗本身还会引入一个额外坑。假设训练数据最后一条记录时间戳是 2024 年 5 月 30 日 22 点验证数据从 2024 年 5 月 31 日 0 点开始。滑窗构造训练样本时如果窗口最后一步跨过了 22 点到 24 点之间样本就同时包含了训练和验证时间段的记录模型在训练时已经偷看过验证期的开头数据。前文代码里的time_tags就是用来拦截这个情况的mask time_tags train_cut_time - output_steps X_train, Y_train X[mask], Y[mask] mask_val (time_tags val_start_time) (time_tags val_start_time buffer)训练样本的预测起点必须早于训练截止时间再减掉 output_steps否则它的标签会越过切分边界相当于用未来数据做训练标签。验证和测试样本的起点也必须和目标区间的起始时间保持一定缓冲。这个 buffer 我一般取 output_steps即如果预测未来 12 小时验证集的第一个样本起点要比切分时间晚至少 12 个小时。4.3 随机种子与可复现性不只是设一次 seed 就完事神经网络训练的随机性来自多个来源PyTorch 的权重初始化、数据加载器的乱序、CUDA 上的卷积实现。只设np.random.seed()远远不够标准做法是同时设三层import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 强制 cuDNN 使用确定性算法结果可复现 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False毕设场景里这个设置的意义不是追求理论上的可复现而是为了调参时能公平比对。如果你改了一个超参数结果变了但不确定是超参数引起的还是随机初始化引起的单纯靠跑多次取平均来消除随机性会浪费大量时间。固定种子后一次实验就能确认改动效果。不过cudnn.benchmark False会让训练变慢一点这是可以接受的代价。4.4 一组能跑起来的初始超参数首次训练不追求最优要追求稳定收敛。我给出一组我常用的初始值适合小时级空气质量数据、样本量在几万级的数据集超参数初始值说明滑窗长度24 小时覆盖一个完整的日变化周期足够抓到静稳天气积累过程预测步长12 小时半天的预报尺度浓度序列还不至于完全发散hidden_size128GRU 隐层维度显存有限时降到 64num_layers2超过 2 层在小时级气象数据上容易过拟合batch_size64数值在 32 到 128 之间按显存和训练时间调整学习率1e-3Adam 默认配置配合 ReduceLROnPlateau 下降dropout0.2对多任务共享层是有效的正则化手段patience10早停的验证损失不提升次数这套参数跑出来的结果不一定最好但大概率不会崩。之后的调参顺序是先看训练集和验证集的损失差距若训练损失低但验证损失高说明过拟合加重加 dropout 或增大惩罚若两者都高说明欠拟合先加 hidden_size 或减 dropout。5. 避坑多任务训练里的 5 个翻车现场与排查思路5.1 任务不平衡PM2.5 收敛了O3 还在原地飘现象是训练过程中 PM2.5 的验证损失稳步下降O3 的损失曲线几乎是平的甚至偶尔反弹。原因在于均等权重下PM2.5 浓度数值较大、样本规律明显它主导了共享编码器的梯度O3 的任务头一直得不到有效信号。解决思路是降低 PM2.5 的损失权重、适当提高 O3 的权重让梯度分配更均衡。我一般先按各任务标签标准差的倒数设定初始权重观察一代再微调。另外O3 本身存在日光驱动的强日周期如果特征里没加日照时长或太阳辐射单独调权重也只是掩盖了特征缺失。可以先补辐射特征再调权重顺序不能反。5.2 训练损失不降反升多任务结构整个学不动现象是 loss 在前几十步就冲高并持续震荡或者干脆一路上升。这个翻车最常见原因是学习率太大Adam 的默认 1e-3 在共享编码器加多个任务头的结构里可能偏激进尤其是在卷积层刚初始化的时候。解决思路是先降到 3e-4 试一轮同时检查一下标签里有没有 NaN 或异常极值一个 800 微克/立方米的异常 PM2.5 记录就能让 MAE 损失直接失控。还有一种玄学情况是 Conv1d 的 padding 和 kernel_size 没配对导致时序长度在卷积过程中变化GRU 输入形状对不上损失函数报错或计算结果错乱。kernel_size3 配 padding1kernel_size5 配 padding2这个组合最省心。5.3 验证集 R2 为负模型比直接预测均值还差R2 为负通常不是模型随机性造成的而是三个常见原因之一。第一归一化泄漏之外的另一个泄漏形式是对整个序列做了差分或平滑后才切分测试集信息混入了特征。第二预测目标选择有误如果模型预测的是下一小时浓度但标签被错位成了当前小时浓度序列相关性会让训练损失很低验证时因为相位错位全盘崩掉。检查方法很简单在测试集上随机抽 5 个样本把预测值和真实值的时间戳对齐画出来肉眼看相位是否一致。第三共享编码器容量不足多任务互相拖累所有任务都欠拟合这种情况下加 hidden_size 或换更深的模型才有意义调权重没用。5.4 显存爆掉batch 和滑窗长度挤压出来的 OOM多任务模型本身参数不大但数据预处理时如果把 24 小时滑窗的所有特征直接转成浮点数组塞进显存加上多个任务头的中间激活值OOM 很容易发生。解决的优先级是先减 batch_size从 64 调到 32 或 16还不够就把 GRU 层数从 2 降到 1最后才考虑缩短滑窗长度到 12 小时——但滑窗缩短会直接影响模型对日周期的感知能力属于最后手段。另外检查一下训练循环里是否每次迭代都重新把验证集搬进 GPU如果验证集较大建议只在每个 epoch 结束时搬一次不要重复分配显存。5.5 多任务结果还不如单任务基线任务相关性弱硬共享在拖后腿这是多任务学习里最容易被忽视的边界条件。硬共享编码器的本质是强迫所有任务使用同一套特征表示如果任务之间相关性弱比如 PM2.5 和臭氧的驱动机制差异很大共享层会学到一个“两边都不完全对”的中间表示每个任务都比单独训练时差。排查方法是先跑一个去掉其他任务头、只保留 PM2.5 的单任务模型作为基线对比多任务版在 PM2.5 上的表现。如果单任务明显更好可以考虑改成软共享结构各任务保留一部分私有隐层再做特征融合。毕设里不用追求复杂结构但必须在论文里交代这个对比实验这反而是加分项。6. 答辩和论文里这几点能让你的预测模型站得住多任务模型的完成度不只看训练损失多低还要看验证体系是否经得起推敲。我建议在代码包里准备好三样东西指标报告表、基线对比图、可视化样例。指标报告按任务分别给出 RMSE、MAE、R2不要只报一个平均误差。基线模型至少包括一个单任务 LSTM、一个随机森林回归有条件再加一个线性回归。注意单任务 LSTM 的输入特征和训练数据划分必须完全一致否则对比不公平。# 评估时按任务分别计算指标而不是只算总损失 from sklearn.metrics import mean_absolute_error, r2_score pred model(test_X).detach().cpu().numpy() # [batch, 12, 5] true test_Y.numpy() for task_idx, task_name in enumerate([PM2.5, PM10, NO2, O3, CO]): mae mean_absolute_error(true[..., task_idx].ravel(), pred[..., task_idx].ravel()) r2 r2_score(true[..., task_idx].ravel(), pred[..., task_idx].ravel()) print(f{task_name}: MAE {mae:.2f}, R2 {r2:.2f})可视化方面最有说服力的是“单任务 vs 多任务”的时序对比图。选一段重污染过程画 5 天的小时浓度曲线多任务模型通常能捕捉到 PM2.5 和 PM10 同步上升的趋势这是单任务模型最容易漏掉的。最后再加一张预测误差随预测时长的变化图展示 1 小时、6 小时、12 小时三个时延下的误差增长曲线——多任务模型在短时延段的误差优势明显这个图答辩时很能说明问题。我自己的教训是毕设时间有限不要沉迷调参刷指标。先把单任务基线跑通再多任务改造最后做对比实验。这三步走完论文框架基本就立住了。调参调出来的 0.01 提升远不如一张结构清晰的对比图加分。希望这篇笔记能帮你把这个题目顺利做完答辩时心里有底。本文还有配套的精品资源点击获取