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 引入的虚假信息 |
| 输出有 NaN | 1. 查输入是否有 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%,而那个变量的物理意义我当时都没想清楚。
关于后续扩展,这套结构还有几个方向可以继续挖:一是引入集合预报的思想,让模型输出多个成员的预报,用成员离散度刻画不确定性;二是把数值模式的输出作为额外输入通道,做模式订正而不是纯外推,这条路在实际业务里往往更容易出效果;三是往更高分辨率走,配合超分辨率结构,把粗网格预报细化到公里级。
最后分享一个我自己踩过的小细节。模型训完之后我一直觉得输出场"看着不太自然",但又说不上哪里不对,指标也都正常。后来把预报场和实况场并排放大看了半天,发现预报场里所有的小尺度结构都是圆形的,实况里是条带状的。原因是空间分支用了太多圆形感受野的堆叠卷积,对方向性特征捕捉不足。在卷积里加入可变形卷积或者方向性卷积核之后,这个问题明显改善。这件事让我意识到,指标之外还得靠眼睛看场,很多时候图比数字诚实。