简介:基于深度学习的多任务空气质量预测模型设计与实现压缩包,是一套面向环境科学与深度学习交叉领域学习者的完整项目资源。项目以Python为开发语言,围绕PM2.5、PM10、二氧化硫、氮氧化物等多指标预测任务,构建了从数据清洗、特征工程到模型训练、验证与预测的端到端流程,适合需要实战多任务学习与时间序列预测的读者。压缩包共46个文件,包含36个CSV格式的空气质量数据集、7个Python脚本(覆盖数据预处理、模型定义、训练、评估与预测等环节)以及3个Markdown说明文档,整体大小约5.08MB,目录结构清晰,便于按模块阅读和扩展。通过该资源,读者可以了解多任务深度学习模型如何共享特征并同时输出多个污染物浓度预测,掌握CNN、RNN或Transformer等架构在空气质量时序数据上的应用,并复用现成的脚本完成自己的实验。已有142人学习,对于正在准备毕业设计或想开展环境智能监测项目的研究者具有直接参考价值。
1. 基于深度学习的多任务空气质量预测模型:从六套单模型到一套共享模型
现实中的空气质量预报不是预测“明天空气好不好”这一句话,而是要同时输出PM2.5、PM10、NO2、SO2、CO和O3六项污染物浓度,每一项都有独立的考核标准和健康指引。早期做法是为每个污染物单独训练一个深度学习时序模型,六套特征工程、六套超参数、六个推理服务,部署和运维成本成倍增加,而且臭氧这类强非线性任务在样本不足时很容易过拟合。基于深度学习的多任务空气质量预测模型,把多个污染物目标放进同一个共享网络,让化学耦合和气象扩散规律在底层特征复用中被模型隐式学习。这篇内容从物理依据、模型设计、损失函数到AQI发布链路,给出一套在PyTorch上可复现的方案。
2. 共享特征与损失加权:多任务空气质量预测模型的三个设计点
2.1 污染物之间的物理耦合为什么能让多任务受益
把六项污染物放进同一个模型训练,很多人第一反应是任务之间互相拖累。实际在空气质量场景里,多任务学习的收益恰恰来自污染物之间真实存在的化学反应和气象耦合。PM2.5在静稳天气和高湿条件下更容易累积,NO2在逆温和低风速时浓度上升,臭氧则依赖NO2在强光照下的光化学反应生成。同一套大气扩散条件,影响着所有污染物的稀释与累积过程。这意味着六项指标不是六个相互独立的回归问题,而是共享同一组气象驱动因子的多输出回归问题。
| 污染物 | 主要生成机制 | 主导气象因子 | 与PM2.5的相关性 |
|---|---|---|---|
| PM2.5 | 燃烧排放、气粒转化 | 静稳、高湿 | 基准 |
| PM10 | 扬尘、沙尘输送 | 强风、地表干燥 | 中高 |
| NO2 | 机动车与工业燃烧 | 逆温、低风速 | 高 |
| O3 | NOx+VOCs光化学反应 | 高温、强辐射 | 负相关 |
| SO2 | 燃煤、工业过程 | 高湿、弱扩散 | 中 |
| CO | 不完全燃烧 | 低风速 | 高 |
把六项污染物放在一起训练,实质是对“气象条件相似则污染物趋势相似”这一先验做参数复用。单独建模时,每个模型都要重新学习温度、湿度、风速对各自目标的差异化影响;共享模型则可以在底层把这些规律表达为一组气象特征向六个任务的多条映射路径,每个任务头只需要学习从共享表征分叉出去的小部分参数。这种结构在单站点小时级数据上尤其重要,一个站点一年只有8760个样本,六项浓度任务合并后,共享层的有效训练样本量大约等于任务数乘以序列长度,小样本污染物的过拟合风险明显下降。
2.2 硬参数共享与软参数共享:空气质量任务选哪种
多任务学习架构首先分成两大类。硬参数共享是底层放一个公共网络,多个任务共享这部分参数;软参数共享是每个任务各自持有一整套网络,通过损失函数里的正则项约束任务间权重保持接近。空气质量预测的数据来自同一组监测站点、同一个时间窗口,任务间的关联有明确的物理基础,我一般选硬参数共享。硬共享结构下,六个任务的参数总量大约是一个单任务模型的一倍半而不是六倍,训练和推理开销都可控。若干扰任务差异性很大,比如把站点A的极端污染数据和站点B的正常数据合并训练,软共享更能避免任务间相互压制,但调参复杂度和训练耗时都会显著上升。
动手做的时候,很多人把多任务和多步预测混为一谈。用同一个模型输出未来24个时刻的浓度,这是多步预测,关注的是误差随时间步累积;同时输出六项污染物浓度,这才是多任务,关注的是任务间关系。实际项目里两者经常组合,但要在模型设计上分开看待。共享编码器先解决“过去24小时的气象和浓度序列如何压缩成表征”的问题,预测头和任务头则分别承担时序展开与污染物映射这两类不同的职责。
2.3 共享层、任务头与损失加权:多任务模型的三个设计点
确定了硬共享架构后,真正影响效果的是三个设计点的取舍,这也是我每次搭建多任务空气质量预测模型时最先确定的三件事。
第一是共享层的容量。共享层决定了特征提取的表达上限,容量太低,任务头拿到的特征区分度不够;容量太高,共享层会把表达能力集中在数值量级最大的那个任务上,其他任务反而退化。我一般让共享层参数占整个模型的六到七成,任务头占三到四成。隐藏单元数可以比单任务单模型小,单任务LSTM需要256单元的时候,共享TCN用128单元配合更深的层数,效果往往更稳。
第二是任务头的复杂度。每个任务头只需要把共享表征映射到单个污染物的浓度区间,一到两层全连接就够。六项浓度在数值范围上比较接近,六个任务头可以共用一个结构。一旦加入AQI等级这样的离散分类目标,分类头与回归头的最后一层必须分开,不要让分类任务的反向传播直接污染回归特征。
第三是损失加权。六项浓度直接相加不是不行,但量级差异会在模型里变成隐式权重。有些站点NO2浓度高,有些站点O3数值波动大,直接相加会让梯度被数值大的任务牵引。固定权重需要人工反复标定,换一个城市就要重新来一轮。通用做法是不确定性加权,把每个任务的可学习噪声项作为权重的一部分,让模型在训练中自适应决定每个任务的梯度比例,公式为:
L = Σ_i (1 / (2σ_i²)) · MSE_i + (1/2) · log σ_i²
其中σ_i是任务i的可学习噪声参数,训练中与网络参数一起更新。公式里后半部分的log正则项,保证某个任务不会因为噪声估计过大而彻底失去梯度,也不会有任务权重归零。经过10个epoch左右的震荡后,权重会稳定在一个合理区间。
| 加权方式 | 适用场景 | 主要问题 |
|---|---|---|
| 直接MSE相加 | 各浓度量级接近且标准化充分 | 大数值任务主导梯度 |
| 固定权重 | 明确知道PM2.5等任务优先级 | 跨城市迁移需要重新手调 |
| 不确定性加权 | 任务间噪声水平差异大 | 训练初期loss波动剧烈 |
| GradNorm | 任务梯度方向冲突明显 | 多一次梯度计算,训练时间增加 |
3. 用PyTorch搭建多任务空气质量预测模型:数据窗口、共享编码器与训练循环
3.1 数据准备:切分、标准化与序列窗口构造
气象和污染物数据通常以小时为粒度。动手之前先把时间序列的切分顺序理清楚,否则后面所有结果都不可信。标准化的正确做法是先按时间切分训练集、验证集和测试集,再在训练集上计算均值和方差,验证集和测试集只做同样的变换。如果先在全量数据上统计再切分,验证集和测试集的数值范围就通过均值方差泄漏进了训练过程。时间序列预测里这是个高频错误,而且很难从指标上发现,因为短期预测在这种泄漏下往往更好,误导性很强。
import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler df = pd.read_csv("station_hourly.csv", parse_dates=["timestamp"]) df = df.sort_values("timestamp").reset_index(drop=True) feature_cols = ["temp", "rh", "wind_speed", "wind_sin", "wind_cos", "pm25", "pm10", "no2", "so2", "co", "o3"] target_cols = ["pm25", "pm10", "no2", "so2", "co", "o3"] n = len(df) train_df = df.iloc[: int(n * 0.8)] val_df = df.iloc[int(n * 0.8) : int(n * 0.9)] test_df = df.iloc[int(n * 0.9) :] # 关键:只用训练集fit,验证/测试集只transform scaler_x = StandardScaler().fit(train_df[feature_cols]) scaler_y = StandardScaler().fit(train_df[target_cols])这里的逻辑是:所有统计量来自训练集,保证验证集和测试集零信息泄漏。feature_cols中同时包含气象特征与滞后浓度特征,这是标准的自回归外生输入结构,wind_sin和wind_cos是风向处理后的分量,稍后说明。
窗口构造函数把连续时间序列切成监督学习样本。每个样本输入过去24小时的特征序列,输出未来24小时的六项浓度序列:
def make_sequences(df, feature_cols, target_cols, scaler_x, scaler_y, in_steps=24, out_steps=24): x = scaler_x.transform(df[feature_cols].values) y = scaler_y.transform(df[target_cols].values) X, Y = [], [] for i in range(len(df) - in_steps - out_steps + 1): X.append(x[i : i + in_steps]) Y.append(y[i + in_steps : i + in_steps + out_steps]) return np.array(X), np.array(Y) X_train, Y_train = make_sequences(train_df, feature_cols, target_cols, scaler_x, scaler_y) X_val, Y_val = make_sequences(val_df, feature_cols, target_cols, scaler_x, scaler_y) X_test, Y_test = make_sequences(test_df, feature_cols, target_cols, scaler_x, scaler_y)in_steps选24小时是因为污染物浓度有明确日周期,少于24就没有覆盖完整的昼夜循环。out_steps取24对应未来一天的中期预报;如果目标改成48小时预报,in_steps建议同步拉长到48,让模型看到与输出长度相当的历史信息,否则预测曲线会出现明显的相位滞后。Y的形状是(num_samples, out_steps, 6),六个任务共享同一个时间输出维度。
3.2 共享编码器用TCN还是Transformer/LSTM
对小时级空气质量数据,时序主干的选择直接影响训练速度和预测误差。LSTM的优点是序列建模通用性强,但逐点递归的结构让输入长度超过48步后计算开销明显增大,反向传播路径也长。TCN用因果膨胀卷积实现时间依赖,训练时并行度高,没有梯度沿时间步逐点衰减的问题,通过指数膨胀的卷积核可以覆盖很长的感受野。
Transformer在空气质量时序预测上也有人用,自注意力机制能捕捉长距离依赖,但数据需求量较大。单站点一小时粒度数据一年只有8760个样本,输入窗口几十个小时,Transformer相对TCN的优势发挥不出来,却要承担更多的参数和训练不稳定风险。我一般优先选TCN,只有拿到多站点多年数据、输入窗口达到96个时刻以上时,才把Transformer加入对比。
import torch import torch.nn as nn class AirQualityMultiTask(nn.Module): def __init__(self, n_features, hidden_size=128, kernel_size=3, out_steps=24, n_tasks=6, dropout=0.2): super().__init__() self.channel_conv = nn.Conv1d(n_features, hidden_size, kernel_size=1) self.tcn_layers = nn.ModuleList([ nn.Conv1d(hidden_size, hidden_size, kernel_size=kernel_size, dilation=2 ** i, padding=(kernel_size - 1) * 2 ** i) for i in range(3) ]) self.norm = nn.LayerNorm(hidden_size) self.dropout = nn.Dropout(dropout) self.gelu = nn.GELU() self.pool = nn.AdaptiveAvgPool1d(1) self.heads = nn.ModuleList([ nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.GELU(), nn.Linear(hidden_size // 2, out_steps), ) for _ in range(n_tasks) ]) def forward(self, x): # x: (batch, in_steps, n_features) x = x.transpose(1, 2) # (B, F, T) x = self.channel_conv(x) # (B, H, T) for conv in self.tcn_layers: res = x x = self.gelu(conv(x)) x = self.dropout(x) x = x[..., : res.size(2)] + res # 残差连接,长度裁剪保证可加 x = x.transpose(1, 2) # (B, T, H) x = self.norm(x) # 对每个时间步的特征维做归一化 x = x.transpose(1, 2) # (B, H, T) x = self.pool(x).squeeze(-1) # (B, H) return [head(x) for head in self.heads] # 6个 (B, out_steps)模型结构上,channel_conv先把输入特征投影到隐藏维度,降低后续卷积感受野计算的负担。三个TCN卷积层的膨胀系数分别为1、2、4,配合3的卷积核大小,输入长度在24以上时感受野可以完整覆盖。LayerNorm放在池化之前,对每个时间步的隐藏特征做归一化,稳定多任务头输入的数据分布。池化用AdaptiveAvgPool1d压缩时间维度,比只取最后一个时间步能保留更多全序列信息。六个任务头各自独立输出未来24小时浓度序列,这一步没有直接输出单一数值,是因为长期预报需要看到浓度演变曲线,而不是一个平均结果。
3.3 不确定性加权损失:六个任务的损失怎么合并
多任务损失的关键是让六个任务的梯度方向合理合并。固定权重的手调成本高,换站点就要重新标定。我在实际项目里多数情况直接用不确定性加权,这是一种噪声自适应加权,六个任务各自维护一个可学习噪声参数log_var,训练中自动寻找平衡点。
import torch.nn.functional as F class UncWeightedLoss(nn.Module): def __init__(self, n_tasks=6): super().__init__() self.log_vars = nn.Parameter(torch.zeros(n_tasks)) def forward(self, preds, targets): total = 0.0 for i, (p, t) in enumerate(zip(preds, targets)): mse = F.mse_loss(p, t) lv = self.log_vars[i] total += 0.5 * torch.exp(-lv) * mse + 0.5 * lv return total公式背后的机制是:如果任务i的MSE一直偏大,log_var会在训练中变大,exp(-log_var)随之变小,任务权重下降,防止这个任务主导整个模型。反过来,一个已经收敛的任务MSE很小,log_var倾向于变小,权重保持在较高水平,但log正则项约束了权重上限,任务不会因为噪声估计过小而失控。训练前5到10个epoch这个权重会明显震荡,这是正常现象。如果某个log_var持续增大且没有收敛迹象,优先检查对应污染物标签的数据质量,而不是急着改损失函数。
3.4 训练循环、梯度裁剪与早停
多任务训练循环里最容易被忽略的是梯度裁剪。六个任务的梯度叠加后,范数很容易超过单任务模型的水平,训练后期出现指标震荡,直接把之前调好的学习率全部打乱。clip_grad_norm_设为1.0能有效控制这一步。优化器用AdamW配合1e-3初始学习率,先在10个epoch内保持,之后验证集指标不下降时把学习率乘0.5。
def train_one_epoch(model, loader, optimizer, loss_fn, device): model.train() total_loss = 0.0 for x_batch, y_batch in loader: x_batch = x_batch.to(device) y_batch = y_batch.to(device) # (B, out_steps, 6) preds = model(x_batch) # 6个 (B, out_steps) y_list = [y_batch[..., i] for i in range(6)] loss = loss_fn(preds, y_list) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() return total_loss / len(loader)y_batch按最后一个维度拆成六列,对应六项污染物的浓度序列。损失函数接收的preds和targets都是列表结构,长度都是6。梯度裁剪放在backward之后、step之前,裁剪的是整个模型的全部参数,不只是共享层。早停判断以验证集六任务平均MAE为准,连续12个epoch不下降就恢复到最佳模型参数。评估时六个任务的MAE和RMSE分开输出,不要只看一个总指标。臭氧的误差如果集中在午后峰值时段,单独看全天平均MAE发现不了,需要按小时分组计算误差分布,这种细致的错误定位在动辄几十轮训练的长周期项目里能省大量时间。
| 参数 | 建议值 | 说明 |
|---|---|---|
| in_steps / out_steps | 24 / 24 | 24小时中期预报;长预报同步加长 |
| hidden_size | 128 | 共享TCN隐藏宽度 |
| TCN层数 | 3 | 膨胀系数1、2、4 |
| 初始学习率 | 1e-3 AdamW | 10 epochs后按需衰减 |
| clip_grad_norm | 1.0 | 防止多任务梯度爆炸 |
| 早停patience | 12 | 基于验证集六任务平均MAE |
| batch_size | 64 | 单站点样本量约8000条,64够稳 |
4. 多任务空气质量预测模型落地:特征对齐、负迁移排查与AQI换算
4.1 风向必须拆成sin和cos,风速特征保留滞后
风向是环形变量,直接拿0到360的数值喂给模型会引入巨大误差。359度与1度在数值上差了358,模型会把东北风和正北风当两个极端方向处理,而实际上它们几乎同一个方向。常见的处理是拆成风向正弦与余弦两个特征,合成风向向量。风速与污染扩散的关系不是同一时刻对应的,上游输送对本地浓度的影响通常滞后2到6小时,特征窗口后部多保留几小时的风场信息,比单纯增加模型深度更有效。
df["wind_sin"] = np.sin(np.deg2rad(df["wind_dir"])) df["wind_cos"] = np.cos(np.deg2rad(df["wind_dir"])) # 风速和风向分量输入滞后2小时的版本 df["wind_speed_lag2"] = df["wind_speed"].shift(2) df["wind_sin_lag2"] = df["wind_sin"].shift(2) df["wind_cos_lag2"] = df["wind_cos"].shift(2)风向分量与风速本可以合并成u、v风矢量,但在多任务共享特征里保留分量形式更直观,任务头可以各自学习不同污染物对风场的差异化响应。shift产生的NaN直接丢弃对应行,避免让模型学习缺失模式。
4.2 负迁移判断与任务分组
多任务不是任务越多越好。六项污染物浓度同时预测通常不会互相干扰,但一旦把AQI等级这类离散分类任务加入同一个硬共享模型,负迁移很容易出现。判断方法是训练一个单任务基线,对比多任务与单任务在同一个污染物目标上的MAE,如果多任务反而差超过5%,说明该任务与其他任务发生了冲突。
处理方式有两种。把冲突任务从共享网络中移出单独建模,损失一定共享信息,但换来稳定性;或者降低冲突任务的初始权重,先让共享层在其他任务上收敛,训练中期再逐步提高该任务权重。空气质量项目里,回归类浓度任务之间的冲突远小于回归与分类的冲突,AQI等级预测用单独的分类头会更省事。损失权重调度本身也是可选手段,前文实现中不确定性加权已经能覆盖大部分冲突情况。
4.3 输出浓度再换算AQI,而不是直接回归AQI
AQI是一个分段线性函数,每个污染物的浓度区间对应不同的AQI分指数IAQI,分段边界是硬阈值。直接用AQI作为回归目标,模型在分段点附近很难学,因为梯度在边界处不连续,边界两侧相同的数值差会产生截然不同的损失。正确做法是让模型先输出六项污染物的浓度值,也就是多任务模型本身的输出口径,在推理阶段再按公式换算成AQI。
def concentration_to_aqi(c, pollutant="pm25"): if pollutant == "pm25": bp = [(0, 35, 0, 50), (35, 75, 50, 100), (75, 115, 100, 150), (115, 150, 150, 200), (150, 250, 200, 300), (250, 350, 300, 400), (350, 500, 400, 500)] # 其他污染物按各自标准填入对应的浓度分段 for c_lo, c_hi, aqi_lo, aqi_hi in bp: if c_lo <= c < c_hi: return (aqi_hi - aqi_lo) / (c_hi - c_lo) * (c - c_lo) + aqi_lo return 500最终发布时取六项IAQI的最大值作为当日AQI,对应污染物作为首要污染物。部署环节还有一处细节值得留心:推理服务加载模型后,标准化参数必须使用训练时保存的scaler,而不是在服务里基于历史数据重新计算。污染物分布逐年变化,重新计算的均值和方差会让输入分布偏移,导致预测浓度整体偏大或偏小,这种错误很隐蔽,排查起来要花不少时间。把scaler和模型权重打包存成同一个版本文件,是成本最低的防错手段。
本文还有配套的精品资源,点击获取