☰
METEO-DLNet气象预报实战:结构、损失与训练踩坑
2026/10/26 18:47:55 网站建设 项目流程

1. 先把 METEO-DLNet 的定位摆正

METEO-DLNet 这个名字里的 METEO 指的是气象,DLNet 指的是深度学习网络,合起来就是一套面向气象要素预报的深度学习网络结构。我第一次看到这个名字的时候,第一反应是"又是一个把所有东西往里塞的缝合怪",但真正把它的设计逻辑拆开看之后,会发现它其实回答了一个非常具体的问题:在已经有成熟数值预报产品的今天,一个深度学习网络到底应该站在链路的哪个位置,才不至于白费算力。

它做的事情,说白了就是吃进一段历史时段的多源气象格点场,吐出未来若干小时的要素预报场,并且在结构上同时照顾了空间多尺度特征、时间演变趋势和一部分物理合理性约束。用得上这套东西的人,大概是这么几类:做气象数据算法岗、想把模式输出再提一档准确率的工程师;做新能源功率预测、需要更细粒度气象输入的打工人;还有高校里做时空序列预测、想找个真实落地场景验证模型的学生。如果你只是想跑个 demo 看看 loss 能不能降,那其实没必要上这个结构,一个轻量 U-Net 就够了。

我写这篇东西的目的不是复述论文,而是把我在复现和改造这套网络的过程中,那些"文档里不会写、但你不注意就会白跑三天"的细节摊开来讲。包括输入张量该怎么排、归一化为什么不能想当然、频谱损失到底在治什么病、显存怎么估算、指标怎么看才不被自己骗。这些东西靠读结构图是读不出来的,只能靠一次次跑崩了再调。

提示:下文提到的所有数据集、场景、团队一律用泛称,参数和数值是基于常见实践给出的参考量级,实际项目请按自己的数据分辨率和算力重新核算。

2. 网络结构逐层拆开看

2.1 输入张量到底该怎么排

很多人上手这类模型第一个卡点不是网络,而是张量维度。METEO-DLNet 的输入一般是个五维张量,形状是[B, T, C, H, W],其中 B 是批大小,T 是历史时间步数,C 是变量通道数,H 和 W 是格点高宽。这个排布看着简单,但里面有两个隐含决策必须先定死。

第一个是 T 怎么取。常见做法是取过去 6 到 12 个时次,间隔 1 小时。T 太小,模型看不到演变趋势,只能靠单帧做空间外推,遇到快速生消的对流系统基本抓瞎;T 太大,显存和时间成本线性上涨,而且早期帧的信息对短期预报的边际贡献会迅速衰减。我实测下来 6 帧是个性价比拐点,再往上加,RMSE 的改善已经进到小数点后第三位了。

第二个是 C 怎么组。变量不是越多越好,通道膨胀带来的不只是算力问题,还有变量之间尺度差异巨大的问题——位势高度的量级是几千,降水是零点几,风分量是几到几十。如果不做分通道标准化就直接堆进去,网络前期基本在学习怎么补偿这个尺度差。我一般把变量分成三类:动力学量(风、位势高度、气压)、热力学量(温度、露度、湿度)、水凝物量(降水、云量),每类内部做独立标准化,再拼成通道。

2.2 时空编码器:为什么把 3D 卷积拆成 2D 加 1D

这是 METEO-DLNet 结构里我觉得最值得讲的一个取舍。理论上处理[T, C, H, W]最直接的办法是上 3D 卷积,一次同时卷时间维和空间维。但 3D 卷积的参数量和显存占用是按kT × kH × kW三次方级别增长的,一个3×3×3的核比起3×3的核,参数量直接翻三倍,而气象场的空间相关性远强于时间相关性——同一时刻相邻格点的相关性可以到 0.95 以上,而相隔 6 小时同一格点的相关性可能只有 0.6。

所以 METEO-DLNet 采用的是因子化分解:先用 2D 卷积做空间特征提取,再用 1D 卷积沿时间轴做时序融合,两者交替堆叠。这样做的好处很直接,参数量降下来了,而且空间分支和时间分支可以分开调感受野。空间分支用空洞卷积扩感受野到几百公里尺度,时间分支用kernel=3的因果卷积保证不看到未来帧。代价是丢失了一部分时空联合模式,比如"锋面在移动过程中同时变形"这类耦合现象,分解结构表达起来会弱一些。如果你的任务是强对流这类快速演变过程,可以考虑在深层加一两个真正的 3D 卷积块做补偿,浅层还是用分解结构省算力。

2.3 多尺度融合与跳跃连接的门控

编码器下采样一般做 3 到 4 次,每次分辨率减半、通道翻倍。以 512×512 输入为例,四层下采样后特征图变成 32×32,通道从 64 涨到 512。解码器对称上采样回去,中间靠跳跃连接把浅层的高分辨率细节接回来。这部分和标准 U-Net 一样,没什么新鲜的。

真正有区别的是跳跃连接上的门控机制。原始 U-Net 的直接 concat 有个毛病:浅层特征里噪声多、信噪比低,直接拼到解码器会把一堆无效响应也带上去,表现出来就是预报场出现细碎的斑点和毛刺。METEO-DLNet 在每条跳跃连接上加了一个轻量的注意力门控,用解码器当前层的特征作为门信号,对编码器传上来的特征做一次空间加权,权重经过 sigmoid 压到 0 到 1 之间。这个改动参数量增加很少,大概每层几千个参数,但对输出场的平滑度改善很明显。

我自己复现的时候试过把这个门控去掉,结果降水预报场上出现了大量孤立的单点强降水,TS 评分掉了将近 4 个百分点。原因是浅层的高频噪声被无差别地传到了输出端。所以这个门控不是可有可无的装饰,建议保留。

2.4 输出头:确定性回归还是概率分布

输出头有两个流派。第一种是纯确定性回归,最后一层1×1卷积直接把特征映射到目标变量通道数,损失用 MAE 或 MSE。结构简单、训练稳定,但预报出来的是条件均值,天生偏平滑,极端值会被系统性低估。

第二种是概率化输出,每个目标变量输出两个通道,一个是均值 μ,一个是方差 σ²,损失用高斯负对数似然。METEO-DLNet 的完整版走的是这条路,因为气象业务上越来越需要不确定性信息——同样是 20mm 降水,方差大和方差小对下游调度决策的意义完全不同。

不过概率头有个训练陷阱:σ² 很容易塌缩。模型发现把 σ² 调大能让损失下降,于是所有位置的方差都往大里跑,最后预报区间宽得毫无信息量。解决办法是给 σ² 加一个下界约束,并且在损失里加一项对 σ² 的弱正则,惩罚它偏离历史气候态方差太远。我一般把下界设成对应变量气候方差的 0.01 倍,正则系数放在 1e-3 量级。

2.5 参数量与显存的实算

这部分建议每个人上手前自己算一遍,能省掉很多"跑起来才发现爆显存"的时间。

假设输入T=6, C=16, H=W=512,主干通道基数 64。单帧单样本的输入元素数是16 × 512 × 512 = 4,194,304,六个时次就是约 2517 万个浮点数。用 fp32 存储是2517万 × 4B ≈ 100MB。这个还不算什么,真正吃显存的是中间激活值。第一层卷积输出 64 通道、512×512,单样本就是64 × 262144 × 4B ≈ 67MB,batch size 取 4 的话就是 268MB,而这只是一层。整个编码解码链路下来,激活值占用量大概是输入的好几倍。

粗略估算公式可以这么记:激活显存 ≈B × Σ(每层 H×W×C) × 4B × 系数,系数取 1.5 到 2 之间(考虑反向传播要存的中间量)。24GB 显存的卡上,512×512输入、C=16、T=6的配置,batch size 开到 8 基本就是上限,再往上要靠梯度检查点或者混合精度。开了 AMP 之后显存能压到大概 60%,速度还能快 30% 左右,几乎是必开选项。

3. 数据管线:模型效果八成埋在这里

3.1 时空对齐与网格重采样

多源数据最大的坑是时空不一致。雷达是极坐标扫描转成的笛卡尔网格,卫星是卫星投影,地面站是点位观测,再分析资料是等压面经纬网格。这些东西要拼成一个张量,必须统一到同一套网格和时间戳上。

时间上,我一般把所有数据对齐到整点,非整点观测做时间线性插值,插值窗口不超过 10 分钟,超过的直接丢弃不用。空间上,统一重采样到等经纬度网格,分辨率取所有数据源里最粗的那个,避免上采样引入虚假细节。重采样方法上,连续量用双线性,类别量(比如降水等级、云类型)用最近邻,这条如果搞反了,类别场会出现一堆中间值,网络学出来的东西完全没法解释。

还有个容易被忽略的点:目标网格的经纬度范围要比你实际关心的区域往外扩一圈,至少扩 64 个格点。原因是卷积在边界处有padding效应,边缘区域的预报质量天然差一截,把边界留在关心区域外,评估的时候截掉就行。

3.2 归一化踩坑:全局均值方差是陷阱

这是我见过最多人翻车的地方。很多人拿到数据直接算全样本的均值和标准差,然后对所有样本做同一套标准化。这在图像任务里没问题,在气象任务里会出大事。

原因在于气象要素有强烈的季节循环和日变化。夏季降水均值可能是冬季的好几倍,用全年均值去标准化,冬季的降水场全被压到接近零,模型学出来就是"冬天不下雨"。正确做法是按时间段做条件标准化,比如按月份或者按滑动窗口计算统计量,或者干脆用物理上更有意义的变换。

这里给几个我验证过效果不错的变换:

变量类型推荐变换说明
位势高度、温度减气候态 + 除以气候标准差去掉季节循环,保留距平信息
风分量 u/v直接除固定尺度因子尺度因子取 10 m/s,避免极端风速拉偏统计
降水log1p后再标准化降水长尾严重,取对数后分布接近正态
气压减 1000 hPa 后除尺度因子数值本身太大,减掉基准后网络好处理
相对湿度直接除以 100本身有界,简单缩放即可

注意:标准化用的统计量必须只用训练集算,验证集和测试集的统计量绝对不能混进来。这条听着是常识,但我见过至少两个项目在这上面栽跟头,离线指标漂亮得不像话,上线直接打回原形。

3.3 派生物理量的构造清单

原始变量往往不够用,网络需要一些物理上有意义的组合量才能捕捉关键结构。METEO-DLNet 的输入通道里,我一般会额外构造这几类:

  • 风切变:低层和高层风矢量的差,反映大气垂直不稳定度,对强对流预报很关键。计算时用sqrt((u2-u1)² + (v2-v1)²)。
  • 温度平流:-(u·∂T/∂x + v·∂T/∂y),反映冷暖空气的输送,是判断锋面移动的重要依据。
  • 水汽通量:q × V,其中 q 是比湿,V 是风矢量,反映水汽输送强度。
  • 散度和涡度:用有限差分近似计算,虽然分辨率有限会有误差,但对识别辐合辐散区很有用。
  • 对流有效位能的简化近似:可以用低层温度和高层温度、湿度组合算个粗略版本,不必上完整的抬升过程计算。

构造这些量的时候注意一件事:差分计算会引入边界误差,所以要么先把网格往外扩一圈再算,要么在边界处用单边差分。我吃过这个亏,边缘一圈的涡度值异常大,训练前期 loss 一直在抖,后来发现是边界差分的问题。

3.4 数据泄漏的隐蔽来源

气象时序数据做训练集划分,最容易出的问题不是随机划分(这个大家都有意识了),而是一些更隐蔽的泄漏。

一是时间窗口重叠。假设你用过去 6 帧预测未来 12 帧,样本之间的时间跨度是 18 帧。如果训练集切到 6 月 30 日,验证集从 7 月 1 日开始,那 6 月 30 日 23 时的这个样本,它的目标帧已经落到 7 月 1 日了。解决办法是在训练集和验证集之间留一段空白期,长度至少等于输入窗口加输出窗口。

二是统计量泄漏。前面提过的归一化统计量,还有损失函数里的权重系数,如果是在全量数据上统计出来的,那就是泄漏。

三是空间泄漏。如果你的训练集和验证集是按格点随机划分的,相邻格点之间相关性极高,等于模型在训练时已经见过验证集邻域的信息。正确做法是按区域块划分,或者按时间划分。

四是预处理顺序。插值、平滑、去噪这些操作如果在划分数据集之前对全体数据做了,也会带来轻微泄漏,尤其是滑动窗口平滑这种操作。

4. 损失函数与训练策略

4.1 为什么主损失用加权 MAE 而不是 MSE

MSE 对大误差的惩罚是平方级的,这在气象预报里很危险,因为数据里总有那么几个极端样本(台风、强对流),它们的误差量级比普通样本大几十倍。用 MSE 训练,梯度会被这些极端样本主导,模型会牺牲大量普通样本的精度去拟合那几个极端值。

MAE 的梯度是常数级别,对异常值更鲁棒,代价是在最优点附近梯度不连续,收敛会慢一些。实际用法是主损失用 MAE,配合一个小的 MSE 项做平滑,权重比大概是0.8 : 0.2。

更关键的是加权。不同变量的可预报性差异很大,位势高度这种大尺度场,模型很容易学到接近完美的程度,误差早早就降到很低;降水这种局地性强、随机性大的量,误差怎么都降不下去。如果不加权,总损失会被位势高度主导,模型实际上是在优化一个不重要的目标。我的做法是按变量给权重,权重和"该变量在气候态上的相对误差水平"成反比,让各变量对总损失的贡献大致持平。

还有一层是空间加权。降水在空间上极度稀疏,可能 95% 的格点都是零。如果均匀加权,模型很快学会了"全输出零"这个策略,损失能降到一个看起来还行的水平,但实际毫无用处。解决办法是给有降水的格点更高权重,权重可以取降水值的某个变换,比如1 + α·log1p(rain),α 控制在 5 以内,太大又会让极端格点主导。

4.2 频谱损失:对付"预报糊成一片"

做过降水外推的人都知道那个经典现象:训练到后期,RMSE 一直在降,但把预报场画出来一看,全是糊的,雷达回波上的精细纹理全没了。这不是模型没训好,这是像素级损失函数的固有缺陷——当模型对未来不确定时,最小化期望误差的最优策略就是输出均值,而均值场天生就是平滑的。

频谱损失就是治这个病的。做法是把预报场和真值场各做一次二维傅里叶变换,取幅度谱,算两者的差异。这样一来,高频分量(对应小尺度纹理)的缺失就会被直接惩罚。实现上可以用现成的 FFT,注意做fftshift把低频挪到中心,然后对幅度谱取对数压缩动态范围(不然低频项会完全主导)。

权重上,频谱损失不能给太大。我试过给到和 MAE 同量级,结果模型开始生成虚假的高频噪声,场面上全是棋盘格。合适的比例大概是频谱损失占总损失的 5% 到 10%。

除了频谱损失,还有两个替代方案可以考虑:一是梯度损失,直接算预报场和真值场的空间梯度差异,实现更简单;二是对抗损失,加个判别器逼模型生成真实纹理,但训练稳定性差,调参成本高。我一般先上梯度损失,不够再考虑频谱。

4.3 物理约束项怎么加才不拖后腿

METEO-DLNet 里有个物理约束分支,本意是让预测结果满足一些基本的物理规律。常见的约束有两类:

一类是非负约束,降水、湿度这类量不能为负。实现上有两种方式,一是在输出层套个 ReLU 或者 softplus,直接保证非负;二是在损失里加惩罚项,对负值做惩罚。我倾向于前者,简单可靠,不需要调惩罚系数。

另一类是动力学约束,比如地表气压场和风场之间的平衡关系,或者连续方程。这类约束实现起来复杂,而且网络输出本身是离散格点上的近似,硬套连续方程会引入额外误差。我的经验是只在损失里加一个弱约束项,权重放在 1e-3 到 1e-4 量级,作用更多是正则化而不是硬性限制。

有个坑要提醒:物理约束项如果权重给大了,训练会变得非常不稳定,loss 会出现周期性震荡。原因是约束项的梯度方向和主损失经常冲突。缓解办法是用梯度归一化,或者对约束项做梯度裁剪。

4.4 学习率、批大小、混合精度的配比

这几个超参是联动的,不能单独调。

批大小首先受显存限制。在前面算过的配置下(512×512、C=16、T=6),24GB 卡上 batch 8 是相对稳妥的选择。批大小定了之后,学习率按线性缩放规则给个初值:lr = base_lr × batch / 256。如果 base_lr 取 3e-4,batch 8 对应就是 9.4e-6,这个值偏小,实际训练会太慢。所以实践中更多是用固定学习率加 warmup,从 1e-6 线性升到 1e-4,用 500 到 1000 步完成 warmup,然后接余弦退火到 1e-6。

优化器用 AdamW,权重衰减给 1e-2。这里要注意,权重衰减不应该作用在归一化层的参数和偏置项上,需要分组设置。

混合精度基本必开。fp16 在气象场上有个隐患:位势高度的数值范围大,某些中间激活值可能溢出。用 bfloat16 更安全,动态范围和 fp32 一致,如果硬件支持优先选 bf16。用 fp16 的话记得开梯度缩放,初始缩放因子给 2^16,如果连续 2000 步没有出现 inf 就翻倍,出现 inf 就减半。

梯度裁剪也要开,max_norm设 1.0。气象数据的极端样本容易产生大梯度,不裁剪的话偶尔会炸一次,前面的训练全白费。

5. 从零跑通一次完整训练

5.1 环境与目录约定

我习惯把项目结构固定下来,换机器、换人接手都不用重新理解。

meteo-dlnet/ ├── configs/ │ ├── base.yaml │ └── exp_512.yaml ├── data/ │ ├── raw/ │ ├── processed/ │ └── stats/ # 归一化统计量,只从训练集算 ├── src/ │ ├── dataset.py │ ├── model.py │ ├── losses.py │ ├── metrics.py │ └── train.py ├── scripts/ │ ├── prepare_data.py │ └── evaluate.py └── runs/ # 每次实验一个子目录

关键点是stats/目录和runs/目录的隔离。统计量是数据的一部分,跟着数据走;实验产物跟着实验走,每次训练生成一个带时间戳的子目录,里面存配置快照、日志、检查点。这样回头复现任何一次实验都能精确还原。

配置文件用 YAML,把数据路径、模型超参、训练超参、损失权重全部外置。不要把这些硬编码在代码里,不然跑三次实验之后就分不清哪次用的什么配置了。

5.2 数据集与 DataLoader 的关键代码

数据加载这块,核心难点是既要随机采样又要保证时间连续性。下面是我常用的骨架:

import numpy as np import torch from torch.utils.data import Dataset class MeteoWindowDataset(Dataset): def __init__(self, files, in_len=6, out_len=12, stats=None): self.files = files self.in_len = in_len self.out_len = out_len self.stats = stats self.window = in_len + out_len # 预计算所有合法起始索引,避免运行时判断 self.index = [] for fi, f in enumerate(files): n = f["length"] for s in range(0, n - self.window + 1): self.index.append((fi, s)) def __len__(self): return len(self.index) def __getitem__(self, i): fi, s = self.index[i] arr = self.files[fi]["array"] # 内存映射,不实际读盘 seq = arr[s : s + self.window] # [T_total, C, H, W] seq = self.normalize(seq) x = seq[: self.in_len] y = seq[self.in_len :] return torch.from_numpy(x).float(), torch.from_numpy(y).float() def normalize(self, seq): out = np.empty_like(seq, dtype=np.float32) for c, (mu, sd, tf) in enumerate(self.stats): v = seq[:, c] if tf == "log1p": v = np.log1p(np.maximum(v, 0)) out[:, c] = (v - mu) / sd return out

几个实现要点值得说:

  • 用np.load的mmap_mode='r'做内存映射,不要把整个数据集读进内存。气象数据动辄几十 GB,全读进来机器直接卡死。内存映射让操作系统帮你管理缓存,实际效率反而更高。
  • 索引表在初始化时一次性算好,__getitem__里只做切片。这个优化能让数据加载速度提升好几倍,尤其是样本量大、窗口滑动密集的时候。
  • 归一化放在__getitem__里做而不是预处理阶段做,是为了保持原始数据的可逆性,方便后续反归一化算物理量误差。代价是每个 epoch 都要重复计算,如果 CPU 成为瓶颈,可以考虑用num_workers开多进程,或者把归一化后的数据落盘。

DataLoader 配置上,num_workers设成 CPU 核数的一半左右,pin_memory=True,persistent_workers=True避免每个 epoch 重建进程。drop_last=True在训练时开,避免最后一个不完整批次影响批归一化的统计。

5.3 模型与损失的组装

模型的简化骨架大概是这样:

import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, cin, cout, k=3): super().__init__() self.body = nn.Sequential( nn.Conv2d(cin, cout, k, padding=k // 2), nn.GroupNorm(8, cout), nn.GELU(), nn.Conv2d(cout, cout, k, padding=k // 2), nn.GroupNorm(8, cout), nn.GELU(), ) def forward(self, x): return self.body(x) class TemporalBlock(nn.Module): """因果时间卷积,保证不看未来帧""" def __init__(self, c, k=3): super().__init__() self.pad = k - 1 self.conv = nn.Conv1d(c, c, k) def forward(self, x): # x: [B*H*W, C, T] x = nn.functional.pad(x, (self.pad, 0)) return self.conv(x) class METEO_DLNet(nn.Module): def __init__(self, cin=16, base=64, out_c=8, t_out=12): super().__init__() self.t_out = t_out self.stem = ConvBlock(cin, base) self.enc1 = ConvBlock(base, base * 2) self.enc2 = ConvBlock(base * 2, base * 4) self.pool = nn.MaxPool2d(2) self.mid = ConvBlock(base * 4, base * 4) # 解码段略,结构与标准 U-Net 对称 self.head = nn.Conv2d(base, out_c * t_out, 1) def forward(self, x): B, T, C, H, W = x.shape # 把时间维压进批维,复用 2D 空间分支 x = x.reshape(B * T, C, H, W) h = self.stem(x) h = self.pool(self.enc1(h)) h = self.pool(self.enc2(h)) h = self.mid(h) # ... 解码回原分辨率 ... out = self.head(h) # [B*T, out_c*t_out, H, W] out = out.reshape(B, T, -1, H, W) return out[:, -1] # 只用最后一个时次出预测

这里有个实现细节值得单独说:把B和T压成一个维度去做空间卷积,是省显存的一个常用技巧,但也意味着空间分支完全看不到时间信息。所以时间融合必须靠单独的TemporalBlock插在中间,或者在解码后对[B, T, C, H, W]沿 T 维做一次卷积。我一般是在瓶颈层做时间融合,那里分辨率最小,算力开销可以接受。

损失函数组装:

def total_loss(pred, target, cfg): # 主损失:逐变量加权 MAE w = cfg.var_weights.view(1, -1, 1, 1) l_main = (torch.abs(pred - target) * w).mean() # 平滑项:小幅 MSE,改善最优点附近的梯度行为 l_mse = ((pred - target) ** 2).mean() # 梯度损失:治模糊 dp = torch.diff(pred, dim=-1), torch.diff(pred, dim=-2) dt = torch.diff(target, dim=-1), torch.diff(target, dim=-2) l_grad = sum(torch.abs(a - b).mean() for a, b in zip(dp, dt)) # 频谱损失:治高频缺失 fp = torch.fft.fft2(pred, norm="ortho").abs() ft = torch.fft.fft2(target, norm="ortho").abs() l_spec = torch.abs(torch.log1p(fp) - torch.log1p(ft)).mean() return (cfg.w_main * l_main + cfg.w_mse * l_mse + cfg.w_grad * l_grad + cfg.w_spec * l_spec)

权重配置上,我的常用起点是w_main=1.0, w_mse=0.2, w_grad=0.05, w_spec=0.02。这几个数不用一次调到位,先跑个三五轮看各项损失的量级,如果某一项比主损失大了两个数量级,说明权重给高了。

5.4 训练循环与断点续训

训练循环本身不复杂,关键是断点续训要做扎实。除了模型参数,优化器状态、学习率调度器状态、梯度缩放器的状态、当前 epoch 和 step、随机数种子状态,这些都要存。少存一样,续训之后的结果就和没断过不一样。

def save_ckpt(path, model, opt, sched, scaler, epoch, step, best): torch.save({ "model": model.state_dict(), "opt": opt.state_dict(), "sched": sched.state_dict(), "scaler": scaler.state_dict(), "epoch": epoch, "step": step, "best": best, "rng_torch": torch.get_rng_state(), "rng_numpy": np.random.get_state(), }, path)

检查点保存策略上,我一般存三份:最近一次的last.pt(每轮覆盖)、最优指标的best.pt、以及每 N 轮一份的epoch_{n}.pt(保留最近 3 份)。第一份用于续训,第二份用于最终评估,第三份用于回溯问题。

日志方面,每个 step 记录各项损失分量和学习率,每个 epoch 记录验证集指标。损失分量一定要分开记,只记总损失的话,出问题根本不知道该往哪查。

5.5 训练曲线怎么读

曲线判读是门手艺,我给几个我总结的信号:

曲线形态可能原因处理方向
训练损失降、验证损失平后上升过拟合加 dropout、减模型宽度、加数据增强
两条曲线都平且很高欠拟合或学习率太小加宽模型、提学习率、检查数据管线
损失周期性剧烈震荡学习率太大或物理约束权重过高降学习率、减小约束权重
损失突然变成 NaN梯度爆炸或数值溢出开梯度裁剪、检查是否有 inf 输入
主损失降但梯度损失上升模型在往平滑方向退化提高梯度/频谱损失权重

还有一个隐蔽情况:验证损失一直比训练损失低。这通常不是模型泛化好,而是验证集用了和训练集不同的归一化统计量,或者验证集样本恰好比较简单(比如都在晴好天气)。遇到这种先别高兴,把验证集按天气类型分层看一遍。

6. 评估、推理与落地部署

6.1 指标组合:RMSE 之外必须看的三个

只盯 RMSE 是会被骗的。RMSE 对平滑的预报场特别友好,一个全输出气候平均值的模型,RMSE 可能还不错,但业务上完全没用。我一般看这四个指标的组合:

RMSE 和 MAE是基础,反映整体误差水平,分开看能判断误差分布是均匀的还是被少数大误差拉起来的。

ACC(距平相关系数)反映预报场和实况场在空间形态上的一致性,把气候态扣除后算相关。这个指标对"大尺度形势报得对不对"很敏感,是判断模型有没有真正学到环流结构的关键。

TS 评分(Threat Score)用于分类预报,比如降水是否超过某个阈值。计算方法是对每个阈值统计命中、空报、漏报,TS = 命中 / (命中 + 空报 + 漏报)。这个指标对稀有事件很敏感,是检验极端降水预报能力的主要依据。建议至少看 1mm、10mm、25mm、50mm 四档。

FSS(分数技巧评分)是 TS 的空间版本,在计算前先对预报和实况做邻域平滑,允许一定空间容差。这个指标比 TS 更适合评估高分辨率预报,因为高分辨率预报的"位置偏差"很常见,用 TS 会过度惩罚。

如果模型是概率输出,还要加CRPS(连续 ranked 概率评分),这个指标同时评估了预报的准确性和不确定性刻画的可靠性,越小越好。

6.2 推理加速的几条实际路径

训练完的模型要上线,推理效率是硬约束。几条我试过的路径,按投入产出排序:

第一条是混合精度推理。这个几乎零成本,改几行代码就能拿到 30% 到 50% 的加速。注意推理时用 fp16 可能导致输出的细微差异,如果下游对数值精度敏感,关键变量还是走 fp32。

第二条是算子融合和静态图导出。把模型导出成 ONNX 格式,然后用推理引擎跑,通常能再拿 20% 到 30%。导出时要注意动态维度设置,把 batch 维和空间维设成动态的,不然换个输入尺寸就得重新导出。有些自定义算子(比如前面提到的注意力门控)可能不被支持,需要在导出时替换成等效的标准算子。

第三条是减少重复计算。业务上往往是滚动预报,每小时的输入窗口只滑动一格,但相邻两次推理的输入有 5/6 是重叠的。理论上可以缓存编码器的中间结果,只算新增的那一帧。实际实现比较麻烦,因为时间卷积的感受野会跨帧,但如果是纯 2D 空间编码器加独立时间融合的结构,这个优化是可行的。

第四条是模型蒸馏或者剪枝。如果算力实在紧张,可以用大模型蒸馏一个小模型,或者做通道剪枝。我做过一次通道剪枝,砍掉 40% 的通道,推理速度快了近一倍,RMSE 只涨了 3%,性价比很高。剪枝要注意别一刀切,按通道的重要性排序来剪,重要性可以用 BN 层的缩放因子或者梯度统计来评估。

6.3 业务上线前的对拍流程

模型在离线指标上好看,不等于能上线。上线前我一般做三轮对拍:

第一轮是历史回算对拍。挑一段完整的、包含各种天气类型的时段(至少一个月),把模型预报和现有业务产品逐时次对比,看有没有系统性偏差。重点看偏差的日变化——很多模型在夜间和白天表现差异很大,原因是输入里的辐射相关变量处理不一致。

第二轮是极端个例对拍。专门挑几次强降水、大风、寒潮过程,看模型在这些关键时段的表现。这一步经常能发现问题,比如模型在强降水峰值时刻系统性低估 30% 以上,这种在平均指标里完全看不出来。

第三轮是实时并行对拍。模型和现有系统同时跑,跑一到两周,观察稳定性。重点看推理耗时是否稳定、内存是否泄漏、异常输入是否会导致崩溃。我遇到过一次因为某个时次的数据缺测导致全零输入,模型输出了一堆离谱的值,后来加了输入有效性检查才解决。

7. 常见问题速查与踩坑记录

7.1 问题速查表

现象排查顺序常见根因
训练 loss 不降1. 检查输入归一化 2. 用小学习率单批次过拟合 3. 检查标签对齐归一化统计量错误、标签错位、学习率过大
预报场全零或接近常数1. 看输出层激活 2. 查损失权重 3. 查类别不平衡稀疏目标未加权、输出层饱和
预报场有明显网格状伪影1. 查上采样方式 2. 查卷积 padding 3. 查数据拼接转置卷积棋盘效应、padding 模式不一致
验证指标远好于训练指标1. 查统计量来源 2. 查数据划分 3. 按天气分层统计数据泄漏、验证集偏简单
边界区域误差异常大1. 查差分边界处理 2. 查重采样边界边界效应、padding 引入的虚假信息
输出有 NaN1. 查输入是否有 inf 2. 查 log 操作 3. 查除法log(0)、除零、fp16 溢出
显存溢出1. 降 batch 2. 开梯度检查点 3. 开 AMP激活值占用过大、输入分辨率过高
训练后期指标突然恶化1. 查学习率曲线 2. 查是否有异常批次学习率退火到底后过拟合、脏数据

7.2 几个印象最深的坑

第一个坑是关于log1p的。降水做对数变换时我图省事用了np.log,结果遇到零降水格点直接产生-inf,训练到第二个 epoch loss 就变 NaN 了。后来改成np.log1p(np.maximum(x, 0))才解决。这类问题在气象数据里特别常见,因为零值和缺测值太多了,所有数学变换都得先考虑零值情况。

第二个坑是 GroupNorm 的组数。默认我按经验给了 8 组,但通道数是 64 的时候没问题,通道数降到 12 的时候 8 组除不尽,直接报错。GroupNorm 的组数必须能整除通道数,这个约束写代码时很容易忘。后来我改成动态计算,取通道数的最大公约数里最接近 8 的那个。

第三个坑是关于因果卷积的。时间分支我一开始用的是普通 1D 卷积加对称 padding,训练集上表现完美,验证集一塌糊涂。查了半天才发现,对称 padding 让模型在预测当前时刻时看到了未来帧,等于作弊。改成左 padding 的因果卷积之后,验证指标才正常。这个错误的隐蔽性在于,训练 loss 会降得非常漂亮,让人误以为模型很强。

第四个坑是关于多卡训练的。用分布式训练时,如果 BatchNorm 是默认设置,每张卡上的统计量是独立算的,等效批大小变小了,训练效果会明显下降。解决办法是换成 SyncBN 或者干脆用 GroupNorm 避开这个问题。我现在的习惯是,只要涉及多卡,一律用 GroupNorm 或者 LayerNorm,省心。

第五个坑是关于评估时的反归一化。模型输出的是标准化空间的值,评估前要反归一化回物理量。这一步如果用了错误的统计量(比如用了验证集的统计量),指标会偏乐观。更隐蔽的是降水这种做了对数变换的变量,反变换要用expm1而不是exp,差一个 1,小量级降水的误差会被显著低估。

7.3 一些不太好归类但很实用的经验

关于数据量,这套结构对数据量的胃口不小。我的经验是,单个格点上至少要有几千个有效时次才能训出稳定结果,如果数据年限不够,靠增加空间样本是补不回来的,因为空间样本之间的相关性太高了。

关于训练时长,在单卡 24GB、batch 8 的配置下,跑 50 个 epoch 大概要一两天,具体取决于数据规模。如果超过三天还没收敛,基本可以判断是配置有问题,别硬等。

关于模型选择,不要一上来就堆最大的模型。我一般先用 base=32、两层编码的轻量版跑通全流程,确认数据管线和损失函数没问题,再逐步加宽加深。这个过程能省掉大量"跑了两天才发现数据错了"的时间。

关于版本管理,除了代码用 git 管理,数据和模型检查点也要有版本号。我吃过一次亏,同一个文件名覆盖了三次不同的检查点,最后分不清哪个是最优的了。现在我的习惯是检查点文件名里带上关键超参和验证指标,比如bs8_lr1e-4_rmse0.842_ep37.pt,一眼就能看出是什么。

关于可解释性,模型训好之后建议做一次通道重要性分析,看看哪些输入变量对输出贡献最大。方法很简单,逐个通道置零或者替换成气候平均值,看输出变化多少。这个分析经常能发现意外——我就遇到过一次,模型对某个我随手加进去的派生变量依赖度极高,去掉之后指标掉了 8%,而那个变量的物理意义我当时都没想清楚。

关于后续扩展,这套结构还有几个方向可以继续挖:一是引入集合预报的思想,让模型输出多个成员的预报,用成员离散度刻画不确定性;二是把数值模式的输出作为额外输入通道,做模式订正而不是纯外推,这条路在实际业务里往往更容易出效果;三是往更高分辨率走,配合超分辨率结构,把粗网格预报细化到公里级。

最后分享一个我自己踩过的小细节。模型训完之后我一直觉得输出场"看着不太自然",但又说不上哪里不对,指标也都正常。后来把预报场和实况场并排放大看了半天,发现预报场里所有的小尺度结构都是圆形的,实况里是条带状的。原因是空间分支用了太多圆形感受野的堆叠卷积,对方向性特征捕捉不足。在卷积里加入可变形卷积或者方向性卷积核之后,这个问题明显改善。这件事让我意识到,指标之外还得靠眼睛看场,很多时候图比数字诚实。

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

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

立即咨询