简介:面向本科毕业设计场景的深度学习多任务空气质量预测项目,完整解决从多站点监测数据整理、特征工程到模型构建、训练评估的闭环需求。压缩包共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_steps=24, output_steps=12, step=1): """ 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_size=128, num_layers=2, num_tasks=5, output_steps=12): super().__init__() # 共享编码器:Conv1d 抓局部模式,GRU 抓长程依赖 self.conv = nn.Sequential( nn.Conv1d(num_features, 64, kernel_size=3, padding=1), nn.ReLU(), nn.Conv1d(64, 32, kernel_size=5, padding=2), nn.ReLU(), ) self.gru = nn.GRU( input_size=32, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=0.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_size=128是 GRU 的隐层维度,决定共享表示的表达能力;如果数据量只有几千个样本,128 足够,再大会明显过拟合。output_steps=12是每个任务头的输出长度,对应未来 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_fn=nn.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_norm=5.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(f"Early 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(seed=42): 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_size | 128 | GRU 隐层维度,显存有限时降到 64 |
| num_layers | 2 | 超过 2 层在小时级气象数据上容易过拟合 |
| batch_size | 64 | 数值在 32 到 128 之间,按显存和训练时间调整 |
| 学习率 | 1e-3 | Adam 默认配置,配合 ReduceLROnPlateau 下降 |
| dropout | 0.2 | 对多任务共享层是有效的正则化手段 |
| patience | 10 | 早停的验证损失不提升次数 |
这套参数跑出来的结果不一定最好,但大概率不会崩。之后的调参顺序是:先看训练集和验证集的损失差距,若训练损失低但验证损失高,说明过拟合加重,加 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_size=3 配 padding=1,kernel_size=5 配 padding=2,这个组合最省心。
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 提升,远不如一张结构清晰的对比图加分。希望这篇笔记能帮你把这个题目顺利做完,答辩时心里有底。
本文还有配套的精品资源,点击获取