☰
多任务学习与TCN在空气质量预测中的工程实践指南
2026/10/4 14:29:43 网站建设 项目流程

简介:这是一份基于深度学习的多任务空气质量预测模型完整项目,面向环境科学、数据挖掘方向的开发者与学生,解决PM2.5、PM10、二氧化硫、氮氧化物等多项污染物联合预测问题。项目以Python为主要实现语言,整体涵盖数据预处理、模型构建、训练验证与预测评估全流程。压缩包共46个文件,以36个csv数据文件、7个py脚本和3个md文档为主:csv存放历史监测数据供特征提取,py包含模型定义、训练、配置与评估代码,md给出项目结构和运行说明,包体仅5.08MB,轻量易部署。目前已有142人学习下载。使用者可完整获得多任务学习的设计思路,例如共享底层网络加独立输出分支的结构、批量大小与学习率调整策略、早停法应用,以及MSE、MAE等指标的实现;同时可参考数据清洗、缺失值处理和特征工程方法,便于快速复现实验并迁移到其他空气质量预测场景。

1. 基于深度学习的多任务空气质量预测模型:一个压缩包背后的真实工程场景

一个标着“基于深度学习的多任务空气质量预测模型设计与实现.zip”的压缩包,解压开来的东西往往比想象中多:模型代码、权重文件、训练脚本、数据说明,甚至还有几份改了三版的 README 备份。这类交付物在环境监测、智慧城市和厂区环保项目中越来越常见,背后的诉求只有一个:不想为 PM2.5、PM10、O3、NO2 各训练一个模型,而是用一个多任务网络同时输出多个污染物浓度,省算力、好维护,还能利用任务间的相关性提升精度。这篇笔记不打算替你解析某个现成源码包,而是把这类模型从数据准备、网络设计到训练部署的完整路径重新捋一遍,适合正在复现论文、写毕业设计,或者接了一个“预测模型交付”项目的从业者。

2. 原理先行:多任务学习让污染物预测更省算力、更稳的3个原因

2.1 硬参数共享 vs 软参数共享:哪种结构适合站点预测

多任务学习的结构选型,是所有相关项目里第一个要做的决定。硬参数共享是主流入门方案:底层时序特征提取网络是公用的,顶层再接不同的全连接头,每个任务独占一个输出头。优点是参数量小,训练时梯度叠加,不容易过拟合。缺点也明显:如果两个任务的特征模式差异很大,比如站点紧邻工业排放源,SO2 的突变规律和区域背景 O3 的光化学过程完全不同,硬共享底层可能互相干扰。

软参数共享在空气质量预测里用得相对少一些,它的做法是每个任务保留独立的网络,层与层之间用正则约束(例如 L2 距离或 Kreuz 距离)拉近参数。好处是灵活性高,坏处是参数量和训练开销直接翻倍,对于通常只有 3-5 年小时级监测数据的环境预测项目,很容易过拟合。

我一般会建议:在建模初期用硬参数共享跑通 baseline,把多任务损失调稳之后,再决定要不要引入软共享。空气质量站点数据规模普遍有限,硬共享在多数情况下精度不输软共享,推理时还能省一半显存。

2.2 任务相关性矩阵:怎么判断 PM2.5 和 PM10 该不该共享层

确定任务集合后,需要做一次相关性分析,判断哪些污染物适合放进同一个共享空间。不少团队踩过的坑是:把相关性很弱的任务强行塞进一个模型,结果每个任务的精度都掉。常见做法是计算历史浓度序列的 Spearman 相关矩阵,再加上气象因子与各污染物之间的相关做交叉验证。

以 6 项常规污染物为例,PM2.5 与 PM10、CO、NO2 通常呈明显正相关(相关系数在 0.5 以上),因为同源性(燃烧来源)很高。而 O3 与 NO2 往往呈典型的负相关,因为 NO2 是 O3 的前体物,白天光化学生成 O3 消耗 NO2。这种“互补”关系恰恰是多任务学习最舒服的场景:共享底层捕捉气象与时空背景,上层两个任务学到不同污染物各自的响应强度。

需要注意的边界是:相关性高不等于一定适合共享,还要看时间尺度。PM2.5 的小时级突跳和日均趋势受不同机制控制,共享层对小时级预测有帮助,但对日均预测可能是噪音。

2.3 损失函数与梯度平衡基础:不确定性加权(Homoscedastic Uncertainty)的由来

多任务学习第二个核心问题是损失平衡。直接把每个任务的 MSE 加起来训练,数值量级大的任务会主导梯度。污染物浓度差异很大:NO2 的小时均值可能到 80 μg/m³,PM2.5 均值只有 40 μg/m³,O3 又可能到 120 μg/m³,谁大谁就带偏网络。

固定权重也是一种处理,但需要人工经验去调,不同站点效果还不一样,非常磁。更可靠的做法是让网络自己学任务权重,也就是不确定性加权(Kendall et al., 2018)。基本思路是:每个任务引入一个可学习的噪声参数 sigma,损失项变成 L_i / (2 * sigma^2) + log(sigma),sigma 大表示这个任务噪声高,自动降低它对总损失的贡献。

这个方案在空气质量预测里落地效果好,训练稳定,几乎没有需要额外手动调的损失参数,推荐优先实现。

3. 数据准备:把站点历史监测记录变成多任务训练样本的完整流程

3.1 字段设计:污染物、气象、时间特征如何拼接成一张宽表

无论模型多复杂,数据喂不对都是白搭。空气质量预测最常见的数据源是国控站点小时数据加气象站观测数据,时间分辨率以小时和日为主。原始数据通常是多张表:逐小时污染物浓度、逐小时气象要素、站点经纬度信息。第一步要把它们对齐成同一时间索引的数据宽表。

代码上常见做法是用 pandas 做时间戳对齐:

import pandas as pd # 污染物表:魔鬼时间、pm25、pm10、no2、o3 pollution = pd.read_csv('pollution_hour.csv', parse_dates=['time']) # 气象表:魔鬼时间、温度、湿度、风速、风向、气压、降雨量 meteo = pd.read_csv('meteo_hour.csv', parse_dates=['time']) # 按站点编码与魔鬼时间对齐两张表 df = pd.merge(pollution, meteo, on=['station_id', 'time'], how='left') # 构造时间特征:小时、星期、是否工作日 df['hour'] = df['time'].dt.hour df['weekday'] = df['time'].dt.weekday df['is_weekend'] = (df['weekday'] >= 5).astype(int) # 风向做周期编码,避免0度和360度的断裂问题 df['sin_wd'] = np.sin(np.deg2rad(df['wind_direction'])) df['cos_wd'] = np.cos(np.deg2rad(df['wind_direction']))

这段代码的关键点有两个。第一,用time和station_id双键对齐,可以解决不同来源数据时间戳的微小偏移问题;第二,风向如果用原始度数直接进模型,350 度和 10 度会被算成大差异,但实际风向只差 20 度,周期编码能消掉这个伪差异。时间特征里还要注意时区统一,国内站点基本是北京时间,如果混入 UTC 时间需要提前转换。

3.2 缺失值、异常值处理:避免时序插值陷阱

原始监测数据的一个烦人问题是缺失和异常。环境监测仪器在零点校准、设备故障、极端天气下经常丢数或输出离谱值,比如 PM2.5 出现负值或甲烷燃烧导致某小时 CO 飙升到十几个 mg/m³。处理不当会让模型学到错误的波动模式。

常见的处理逻辑是分层处理:先过滤明显异常值,再做时序插值。过滤规则要保守,通常把负值、超过仪器量程上限的值直接置为 NaN。插值时,优先用该站点前后时间点的线性插值,而不是全序列均值填充,因为污染物浓度有很强的局部自相关性。

另一个细节是夜间臭氧的插值。夜间 O3 浓度普遍偏低,如果某天夜间数据连续缺失,用线性插值会抹平夜间谷值,导致白天预测偏高。常见做法是对 O3 这类有明显日循环特征的气体,以相同时间的近 7 天均值来做填补。

3.3 滑窗构造与归一化代码实现

模型输入需要构造时序滑窗样本。常规配置是用过去 24 小时预测未来 24 小时,即输入长度 lookback=24,预测长度 horizon=24,每个滑动步长 1 小时。这样从 8760 小时一年数据里可以构造出约 8500 个样本,多站点合起来就够训练了。

归一化是此处最容易翻车的一步。正确做法是仅用训练集拟合均值方差,然后应用到训练、验证、测试三部分。很多项目直接对整个数据集做fit_transform,本质上是数据泄露。

from sklearn.preprocessing import StandardScaler # feat_cols 为输入特征列名列表,target_cols 为预测污染物列名列表 feat_cols = ['pm25', 'pm10', 'no2', 'o3', 'temp', 'rh', 'sin_wd', 'cos_wd', 'hour', 'is_weekend'] target_cols = ['pm25', 'pm10', 'no2', 'o3', 'so2'] # 按时间索引划分训练集:前80%时间作为训练 train_df = df[df['time'] < '2022-01-01'] scaler_x = StandardScaler().fit(train_df[feat_cols]) scaler_y = StandardScaler().fit(train_df[target_cols]) # 构造滑窗样本 def make_samples(df, lookback=24, horizon=24): x_list, y_list = [], [] for i in range(len(df) - (lookback + horizon - 1)): x = scaler_x.transform(df.iloc[i:i+lookback][feat_cols].values) y = scaler_y.transform(df.iloc[i+lookback:i+lookback+horizon][target_cols].values) x_list.append(x) y_list.append(y) return np.array(x_list), np.array(y_list)

make_samples生成的 X 形状是(样本数,24,特征数),Y 形状是(样本数,24,任务数)。注意 Y 里每个时间步包含 5 个污染物输出,这就是多任务预测的典型形态,后续模型直接对这个三维 Y 做输出。归一化器的保存也要跟着模型走,部署时重新构建输入同样要用scaler_x和scaler_y,丢了它们等于模型报废。

4. 模型实现与训练:从网络骨架到多任务损失的关键代码

4.1 基础骨架:TCN + 注意力提取时序特征

空气质量数据本质是多变量时序,选择特征提取器时,LSTM 是常见 baseline,但训练慢、难以并行。我一般会用时序卷积(TCN)加注意力,精度和 LSTM 相当或略好,训练速度快一倍。

TCN 用的是膨胀因果卷积,感受野随层数指数增长,能覆盖 24 小时输入序列。注意力层放在 TCN 之后,让模型自动关注临近时刻的突变和未来趋势。

import torch import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, channels, kernel_size=3, dilation=1, dropout=0.2): super().__init__() self.conv1 = nn.Conv1d(channels, channels, kernel_size, dilation=dilation, padding='same') self.relu1 = nn.ReLU() self.drop1 = nn.Dropout(dropout) self.conv2 = nn.Conv1d(channels, channels, kernel_size, dilation=dilation, padding='same') self.relu2 = nn.ReLU() self.drop2 = nn.Dropout(dropout) def forward(self, x): out = self.drop1(self.relu1(self.conv1(x))) out = self.drop2(self.relu2(self.conv2(out))) return x + out # 残差连接 class TCNEncoder(nn.Module): def __init__(self, in_dim, hidden_dim, num_layers=4): super().__init__() self.in_proj = nn.Conv1d(in_dim, hidden_dim, 1) self.blocks = nn.ModuleList([ ResidualBlock(hidden_dim, dilation=2 ** i) for i in range(num_layers) ]) def forward(self, x): # x: (batch, seq_len, in_dim) x = x.permute(0, 2, 1) # (batch, in_dim, seq_len) x = self.in_proj(x) for block in self.blocks: x = block(x) return x # (batch, hidden_dim, seq_len)

TCN 部分的关键改动有两个。一是padding='same'配合膨胀卷积,保证输入输出长度一致,这让后续残差连接维度完全匹配。二是膨胀率以 2 的幂次递增,第 4 层感受野覆盖约 3 个 24 小时窗口,对空气质量这种 2-3 天的污染过程识别足够用。dropout设置在 0.1-0.2 之间,只在层间作用,不会破坏残差结构。

4.2 多任务输出头与不确定性加权损失函数

TCN 编码器得到的特征序列,需要映射到多个预测任务。做法很简单:取最后时间步的特征向量,再过 N 个并列的线性层,每个线性层负责一种污染物的 24 小时预测。

class MultiTaskHead(nn.Module): def __init__(self, hidden_dim, horizon, tasks): super().__init__() self.heads = nn.ModuleList([ nn.Linear(hidden_dim, horizon) for _ in tasks ]) def forward(self, feat): # feat: (batch, hidden_dim, seq_len) last_feat = feat[:, :, -1] # (batch, hidden_dim) return [head(last_feat) for head in self.heads] class AirQualityMTL(nn.Module): def __init__(self, in_dim, hidden_dim, horizon, tasks): super().__init__() self.encoder = TCNEncoder(in_dim, hidden_dim) self.head = MultiTaskHead(hidden_dim, horizon, tasks) # 不确定性加权参数,每个任务一个 log_sigma self.log_sigma = nn.Parameter(torch.zeros(len(tasks))) def forward(self, x): feat = self.encoder(x) preds = self.head(feat) return preds

损失函数采用不确定性加权。原文公式是每个任务的损失除以 sigma 平方的一半,再加上一个 log(sigma) 正则项。这里需要特别注意:log_sigma的初始化最好从 0 开始,而不是随机初始化,这样训练初期的总损失和各任务的权重相对均衡。

def mtl_loss(preds, targets, log_sigma): total_loss = 0.0 for pred, target, ls in zip(preds, targets, log_sigma): sigma = torch.exp(ls) mse = torch.mean((pred - target) ** 2) total_loss += mse / (2 * sigma * sigma) + ls return total_loss

这段代码的效果是:某个任务如果持续难以学习,模型会把它的 sigma 调大,自动降低这个任务的损失占比,把更多容量让给可学习性强的任务。这种机制特别适合 PM2.5 与 O3 任务并存的情况,避免大数值任务拖垮小数值任务。实际训练中,当 O3 率先收敛时,PM2.5 任务的损失权重会逐渐上升,整体训练曲线更平滑。

4.3 训练超参数设置:学习率、批量大小、早停与模型保存

多任务模型训练的稳定性很大程度取决于超参数。我做这类项目的常用配置是:batch size 128,学习率 1e-3,优化器 AdamW,训练 60-80 epoch,早停 patience 10。学习率调度用 CosineAnnealing,相比 StepLR 在最后一阶段能更平滑地降低 loss。批次大小不要太大,时序样本之间本来就存在时间关联,batch 超过 256 会加剧梯度振荡。

训练循环里有个容易忽略的细节:多任务预测的三个维度分别是 batch、horizon、task,在计算 loss 时要保持 target 与 pred 的形状一致。下面这段代码是比较稳妥的训练循环框架:

# preds_train: list of (batch, horizon),tasks 顺序与 target 列顺序一致 optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=64) best_loss = float('inf') patience_cnt = 0 for epoch in range(80): model.train() train_loss_sum = 0.0 for x_batch, y_batch in train_loader: x_batch = x_batch.float().to(device) # (B, 24, F) y_batch = y_batch.float().to(device) # (B, 24, tasks) preds = model(x_batch) # list of (B, 24) loss = mtl_loss(preds, [y_batch[:, :, i] for i in range(len(preds))], model.log_sigma) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() # 验证与早停 val_loss = evaluate(model, val_loader) if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), 'best_mtl_model.pt') patience_cnt = 0 else: patience_cnt += 1 if patience_cnt >= 10: print(f"early stop at epoch {epoch}") break

梯度裁剪clip_grad_norm_是时序模型最常被忽略的一步。污染物浓度在重污染时段会出现短时尖峰,这种尖峰数据经过 TCN 的残差结构后,梯度可能爆炸,导致权重变成 NaN。裁剪阈值 1.0 就能消掉绝大多数问题,同时不拖慢收敛。模型保存用state_dict即可,但公开部署时更建议把 scaler 和模型一起打包,光有权重文件没有标准化参数,任何外部调用都会得到错误结果。

5. 避坑与排障:做多任务空气质量预测最容易踩的 5 个坑

5.1 数据泄露:归一化放错了位置

现象:训练集 loss 下降到 0,验证集 loss 也很低,但测试集预测效果差得离谱,RMSE 比随机预测还高。

原因:最常见的是在划分训练集和测试集之前就对整个数据集做了 StandardScaler 的fit_transform。这样测试集的均值方差信息已经被模型看到了,相当于考试前偷看了试卷答案。空气质量的季节性很强,夏天和冬天浓度分布差异明显,全样本归一化会抹平这种差异,造成“伪高精度”。

解决:严格按时间切分,只允许在训练集上调用fit,验证集和测试集直接transform。代码参照上文make_samples中的写法,且时刻把scaler保存成单独文件。

5.2 任务梯度失衡:PM2.5 和 O3 数值量纲不一致

现象:训练总损失在下降,但单独看每个任务的 RMSE,发现 O3 已经收敛,PM2.5 却基本是常数平均值。

原因:固定权重相加的 MSE 损失里,O3 的数值跨度可能比 PM2.5 大 3 倍,因此 O3 的梯度一骑绝尘,PM2.5 被“无视”。

解决:优先采用不确定性加权(见 4.2 节代码)。如果确实要用固定权重,至少调整各任务损失权重为量纲的倒数比。经验公式:权重 w_i = 1 / 该任务训练集标准差。我项目中用不确定性加权后,PM2.5 的 RMSE 平均改善 12%-18%,效果立竿见影。

5.3 时序样本随机打乱:验证集数据穿越

现象:训练 loss 极低,但画预测曲线时发现整体形态对,但每次“突变”都差一拍。

原因:训练时把滑窗样本用train_test_split(random_state=42)随机划分,导致验证集里包含训练集前后时间段的数据。污染物浓度一天之内变化平缓,所以相邻时刻样本高度相似,验证集被“记忆”了。

解决:严格按时序比例截断。例如前 80% 时间做训练,后 10% 做验证,最后 10% 做测试,并且只用shuffle=True在批次内部打乱,跨窗口边界保持严格先后顺序。

5.4 zip 包环境不一致:解压后路径错乱与依赖缺失

现象:把项目压缩包解压到新机器后,运行训练脚本直接报 ModuleNotFoundError 或FileNotFoundError,路径全是绝对路径。

原因:原始作者在 Windows 上用D:\models\...路径训练,交付时没把路径改成相对路径,也没把 requirements.txt 锁定版本。或者压缩包内嵌套一层目录,用户解压到根目录后找不到文件。这类问题本身与模型无关,却消耗大量时间。

解决:拿到别人项目包,先看目录结构,确认脚本的 root 路径是不是当前目录。我一般会先建一个check_env.py,脚本检查 Python 版本、关键依赖和模型权重文件是否存在,减少人工排错。训练脚本中的路径统一改用pathlib.Path或os.path.join,保证跨平台可运行。

5.5 部署后预测恒为常数:检查 BN 与 Dropout 的推理模式

现象:模型训练效果不错,导出部署后,接口每次返回的数值基本不变,几乎等于训练集均值。

原因:部署脚本在加载模型后,没有明确调用model.eval()。因为模型里有 Dropout 和 LayerNorm,推理时如果仍处于训练模式,Dropout 会随机丢掉一部分特征,导致输出不稳定;还有一种情况是 LayerNorm 的 moving_mean 没有被正确加载,使用了常量。

解决:部署脚本里模型加载后强制调用model.eval(),并用一条测试数据验证输出是否合理。最好在导出模型时直接冻结整个模型参数,或用torch.jit.trace转成静态图,这样推理模式就不会被误触发了。

6. 收尾技巧:验证模型泛化性、导出断点与写一个预测探针

模型训练完,真正的工程挑战才开始。我建议先做一个“滑动重预测”验证:用训练好的模型,对测试期最后 10 天逐一滚动预测,每次输入过去 24 小时真实观测,输出未来 24 小时的预测值,然后按时间对齐画在一张图上。这个做法的好处是能直观看到浓度变化时模型的跟随速度和滞后情况,尤其是 PM2.5 的污染累积过程在预测曲线上有没有被抹平。如果滚动预测的高峰总是比真实值晚 3 小时左右,说明模型过度依赖近期趋势,通常可以增加 TCN 层数或扩大lookback到 48 小时。

导出环节我会直接用 ONNX,方便和上层系统集成。PyTorch 模型转出后的推理时间一般能从几毫秒降到几百微秒,对于一天两次的预报任务其实差别不大,但如果模型要嵌入边缘设备或小程序后端,还是值得做的。注意导出时把horizon=24和任务列表顺序写死,否则下游解析输出很容易对错列。做一个简单的预测探针脚本也很重要,输入一个站点过去 24 小时的特征数组,输出 5 种污染物未来 24 小时的预测曲线,后台直接拿这份结果做报警阈值判断,比让人看训练报告直观得多。

我做过的同类项目里,最有价值的维护动作是保留每个模型训练时的 k-fold 验证记录,包括各任务的 RMSE、MAE 和预测曲线截图。三个月后别人拿着你的模型说“这个预测不准”时,你能快速判断是数据分布变化了,还是模型本身出了问题。这比争论网络结构更高效,也是我踩过多次坑之后养成的习惯。希望这篇笔记能帮你把这个项目方向做扎实,少走我当初走过的弯路,祝顺利。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询