ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

多任务学习与TCN在空气质量预测中的工程实践指南

多任务学习与TCN在空气质量预测中的工程实践指南 简介这是一份基于深度学习的多任务空气质量预测模型完整项目面向环境科学、数据挖掘方向的开发者与学生解决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], howleft) # 构造时间特征小时、星期、是否工作日 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 小时即输入长度 lookback24预测长度 horizon24每个滑动步长 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, lookback24, horizon24): x_list, y_list [], [] for i in range(len(df) - (lookback horizon - 1)): x scaler_x.transform(df.iloc[i:ilookback][feat_cols].values) y scaler_y.transform(df.iloc[ilookback:ilookbackhorizon][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_size3, dilation1, dropout0.2): super().__init__() self.conv1 nn.Conv1d(channels, channels, kernel_size, dilationdilation, paddingsame) self.relu1 nn.ReLU() self.drop1 nn.Dropout(dropout) self.conv2 nn.Conv1d(channels, channels, kernel_size, dilationdilation, paddingsame) 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_layers4): super().__init__() self.in_proj nn.Conv1d(in_dim, hidden_dim, 1) self.blocks nn.ModuleList([ ResidualBlock(hidden_dim, dilation2 ** 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 部分的关键改动有两个。一是paddingsame配合膨胀卷积保证输入输出长度一致这让后续残差连接维度完全匹配。二是膨胀率以 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(), lr1e-3, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max64) 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(fearly 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_state42)随机划分导致验证集里包含训练集前后时间段的数据。污染物浓度一天之内变化平缓所以相邻时刻样本高度相似验证集被“记忆”了。解决严格按时序比例截断。例如前 80% 时间做训练后 10% 做验证最后 10% 做测试并且只用shuffleTrue在批次内部打乱跨窗口边界保持严格先后顺序。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 模型转出后的推理时间一般能从几毫秒降到几百微秒对于一天两次的预报任务其实差别不大但如果模型要嵌入边缘设备或小程序后端还是值得做的。注意导出时把horizon24和任务列表顺序写死否则下游解析输出很容易对错列。做一个简单的预测探针脚本也很重要输入一个站点过去 24 小时的特征数组输出 5 种污染物未来 24 小时的预测曲线后台直接拿这份结果做报警阈值判断比让人看训练报告直观得多。我做过的同类项目里最有价值的维护动作是保留每个模型训练时的 k-fold 验证记录包括各任务的 RMSE、MAE 和预测曲线截图。三个月后别人拿着你的模型说“这个预测不准”时你能快速判断是数据分布变化了还是模型本身出了问题。这比争论网络结构更高效也是我踩过多次坑之后养成的习惯。希望这篇笔记能帮你把这个项目方向做扎实少走我当初走过的弯路祝顺利。本文还有配套的精品资源点击获取
返回列表