AI Pulse

小模型仅训练1小时,让天气预报分辨率从28公里降到5.5公里

小模型仅训练1小时,让天气预报分辨率从28公里降到5.5公里

TL;DR

在这篇博客中,我们想分享研究和AI教会我们的一件事:你并不总是需要更大的模型;通常你需要让模型适应你的任务。同样的思路也适用于AI天气预报。如果你需要更高分辨率的预报,你可以拿一个现有的全球模型,在其之上训练一个小型降尺度模型(downscaler)来学习局部细节,比如地形或海岸线的影响。我们花了大约一个小时训练了一个降尺度模型,将AIFS-ENS在法国上空的温度预报从约28 km降至5.5 km。与Météo-France气象站相比,在前两天RMSE降低了14%,CRPS降低了21%;在十天尺度上分别降低了9%和14%(见演示空间)。

引言

AI天气模型非常擅长预测大尺度天气模式。但它们将世界视为网格,通常是0.25°分辨率(约相当于中欧28 km的网格),每个单元格只保存温度、风和所有其他变量的单一值。任何小于单元格的细节都会被平均掉,因此山谷、山脉、岛屿或海岸线的局部效应就丢失了。

大型实验室正在通过构建更高分辨率的模型来解决这个问题。例如Google DeepMind的WeatherNext 3(可惜不是开源的)以约5 km(~0.05°)分辨率预报地表温度和湿度。但构建和运行这类模型需要巨大的计算和存储资源,以及只有少数组织才拥有的工程资源。

一种更便宜的做法是在现有模型之上训练一个降尺度模型(即从较大尺度的数据开始预测局部尺度的AI模型)。降尺度模型学习局部细节,以预测单元格内的情况,从而提高分辨率。它只需几秒钟即可运行,叠加在现有模型的预报之上。在这篇文章中,我们解释了降尺度模型的工作原理,然后展示了如何在法国上空的2米温度气象站观测数据上运行、训练和评估一个降尺度模型。

什么是降尺度模型?

天气预报模型以特定分辨率预测天气。降尺度模型是一个机器学习模型,它接收低分辨率预报并填充局部细节。降尺度模型从静态信息(如海拔和海岸线)中学习这些局部细节。例如,它学习到一个粗网格单元内,山顶比下方的山谷更冷,海岸比内陆更温和。一旦训练完成,它可以叠加在粗预报之上,以低成本产生高分辨率预报。

你可以在下方的演示空间中试用我们在AIFS-ENS(ECMWF)[1]和GEFS(NOAA)预报上训练的降尺度模型,看看它与Météo-France温度观测的比较。

与所有机器学习模型一样,降尺度模型也不是魔法的。所以,在训练之前,请检查你想要改进的内容是否确实是降尺度模型能够改进的。下面是我们对降尺度模型能改善和不能改善之处的总结。

降尺度模型能改善的降尺度模型不能改善的
添加地形(山谷、山脉、海岸线)带来的模式修复粗模型的全局错误:如果粗模型在某区域偏暖,降尺度模型不会修复它
从一个粗场生成单个或集合的细场添加输入中不包含的细节(如果没有识别山脉的输入(如海拔),它无法添加山脉的影响)
可叠加在任何粗模型之后降低大尺度预报的不确定性
训练完成后秒级运行超过训练时所使用细场的质量

降尺度模型的组成部分

训练

在训练过程中,我们向模型展示同一天气的两个分辨率版本,并附带一些额外细节(静态数据)。它通过预测粗场与细场之间的误差,学习粗版本系统性遗漏的内容。

两个数据集:降尺度模型需要一个粗数据集和一个细数据集,覆盖相同的地点和时间。我们使用ERA5 [2](0.25°)作为粗数据集,CERRA [3](5.5 km)作为细数据集。对于每个时间步,粗场被插值到细网格上。模型学习预测这个插值场与细场之间的差值。

静态数据:除了要预测的场之外,降尺度模型还需要额外信息来推断单元格内正在发生什么。这些场是静态的,因此很容易学习。我们添加季节和每天的时间,使模型能够学习校正如何随一天和一年变化。在我们的例子中,我们使用细尺度场的海拔、真实表面与粗模型表面之间的高度差、单元格的陆地比例和到海洋的距离、经纬度以及季节和一天中的时间作为静态数据。

损失函数:在我们的例子中,我们选择为集合模型构建降尺度模型。因此,我们使用fair CRPS [4],它是对集合而不是单个场进行评分。它奖励接近真实值且具有合理离散度。注意,在我们的例子中损失只在陆地上计算,因为目标再分析产物对海面温度的处理与陆地温度不同。

噪声:对于一个给定的大尺度状态,可能存在几种可能的细尺度状态。所以我们不是要求一个预测,而是训练降尺度模型预测多个输出。每次前向传播都会抽取新的噪声,并产生不同的细场。因此,降尺度模型从一个粗场生成一个集合预报。

降尺度模型:该模型学习从粗场和静态数据预测粗场与细场之间的差值。在我们的例子中,我们训练了一个约200万参数的U-Net,在GPU上训练了大约一个小时。

推断

一旦训练完成,降尺度模型可以叠加在任何输出相同变量、相同分辨率并覆盖与训练数据相同区域的预报模型(基于AI或基于物理)之后。在我们的例子中,这意味着我们的降尺度模型可以用于任何在法国上空生成0.25° 2米温度的模型。在这篇博客中,我们将其应用于AIFS-ENS。它同样可以应用于其他0.25°模型,如Microsoft的Aurora、GEFS(NOAA)或NVIDIA的Atlas。

输入数据:降尺度模型以粗集合预报作为输入,以及训练时使用的相同静态数据(存储在模型内)。每次前向传播前,每个粗成员被插值到细网格上。在我们的例子中,使用AIFS-ENS的10个成员(0.25°)作为输入。

降尺度模型:它接收插值后的粗场、静态数据和一次噪声抽取,然后预测要加到粗场上的校正。由于噪声在每次传播时都会抽取,同一个成员运行多次会得到多个细场。

输出:模型输出多个细分辨率成员。在我们的例子中,10个粗成员每个产生4个样本,总共在5.5 km分辨率下得到40个成员。注意,在我们的例子中,运行降尺度模型每次预报只需几秒。

运行降尺度模型:法国温度

本节带你逐步了解如何使用我们训练的降尺度模型来改善模型(这里用AIFS-ENS 0.25°)在法国上空预测的温度分辨率。你可以按下面的步骤操作,或运行这个教程。

设置

克隆代码库并安装依赖。

git clone https://github.com/EmmaScharfmann/weather-forecast-model-downscaler
cd weather-forecast-model-downscaler
uv sync

预报从Earthmover Marketplace获取,需要免费账户并免费订阅AIFS-ENS数据集。订阅后会在你的组织下创建一个仓库。把仓库名称填入下面的FORECASTS,然后认证一次。
我们也建议有一个免费的Hugging Face账户(在此注册)以下载模型权重和数据。

uv run arraylake auth login # 或 export ARRAYLAKE_TOKEN=ema_xxxxxxxxxxxx
uv run hf auth login # 或 export HF_TOKEN=hf_xxxxxxxxxxxx

然后导入包并设置配置。

# 查看包和配置
from pathlib import Path

import numpy as np
import pandas as pd
import xarray as xr
from huggingface_hub import hf_hub_download

from downscale.constants import COARSE_CROP_MARGIN_DEG, ZERO_C
from downscale.downscaler import Downscaler
from downscale.evaluation import at_valid_times, lead_scores, stack_members, station_rmse
from downscale.fetch import get_target
from downscale.inference import downscale_forecast, save_downscaled
from downscale.plots import map_grid, maps, native_cells, station_maps
from downscale.regions import parse_region
from downscale.sources import (
    available_inits, fetch_forecast, forecast_box, open_forecasts, save_forecast, store_name,
)
from downscale.station_interp import curvilinear_for
from downscale.stations import load_published_stations

MODEL_REPO = "EmmaScharfmann/france-temperature-downscaler"
FORECASTS = "<earthmover-org>/ecmwf-aifs-ens-forecast-subscription"   # 你的 Earthmover 订阅
FORECASTS_2020 = "hf://datasets/EmmaScharfmann/aifs-ens-france-aug2020"
CERRA_REPO = "EmmaScharfmann/cerra-france-t2m-2014-2020"
STATIONS = "EmmaScharfmann/france-stations"   # Météo-France, Aug 2020, Aug 2025, Jan-Jun-Jul 2026

MEMBERS = 10          # 要降尺度的 AIFS-ENS 成员数(共51个)
MAX_LEAD_HOURS = 240
SAMPLES = 4           # 降尺度模型的成员数(总共 4 x 10 = 40)
EVAL_SAMPLES = 1     # 两个集合都保留10个成员,降尺度速度快4倍
BOX = forecast_box(parse_region("france"), COARSE_CROP_MARGIN_DEG)
OUT = Path("outputs/tutorial")   # 每个预报只获取和降尺度一次,然后从这里读取

获取粗预报

archive = open_forecasts(FORECASTS)
inits = available_inits(archive)

INIT = pd.Timestamp("2025-08-11 00:00")
coarse = fetch_forecast(archive, INIT, BOX, members=MEMBERS, max_lead_hours=MAX_LEAD_HOURS)

运行降尺度模型

模型需要一个细网格作为输入,所以必须先插值粗场。静态数据是模型在Hub上检查点的一部分。这里,我们选择从粗场预测4个细成员。

model = Downscaler.load(hf_hub_download(MODEL_REPO, "model.pt"))
LAND = xr.DataArray(model.static["land_sea_mask"] > 0.5, dims=("y", "x"))

downscaled = downscale_forecast(model, coarse, samples=SAMPLES)
fine = stack_members(downscaled)  

save_forecast(coarse, OUT / "forecasts")
save_downscaled(downscaled, OUT / "downscaled" / store_name(INIT))

fine是降尺度模型的输出:形状为(time, lead_time, member, y, x),有40个成员(10个粗成员×4个样本),保存的是法国上空5.5 km分辨率下的温度(开尔文)。在GPU上,降尺度模型只需几秒即可运行。

我们可以绘制粗预报和降尺度预报:

LEAD = 60   # 小时:8月13日12 UTC
k = int(np.flatnonzero(coarse.lead_time.values == np.timedelta64(LEAD, "h"))[0])
valid = pd.Timestamp(downscaled.valid_time.values[0, k])

raw_mean = native_cells(coarse.isel(time=0, lead_time=k).mean("ensemble") - ZERO_C, model.lat, model.lon, LAND.values)
fine_mean = fine.isel(time=0, lead_time=k).mean("member").where(LAND).values - ZERO_C
interp_mean = downscaled.t2m_interp.isel(time=0, lead_time=k).mean("ensemble").where(LAND).values - ZERO_C
elevation = np.where(LAND, model.static["cerra_orography"], np.nan)

LABELS = {
    "title": f"Ensemble mean 2m temperature, valid {valid:%d %B %Y %H} UTC (+{LEAD} h)",
    "coarse": "(a) AIFS-ENS, 0.25° (~28 km)",
    "downscaled": "(b) AIFS-ENS + downscaler, ~5.5 km (~0.05°)",
    "added": "(c) Correction added by the downscaler: (b) minus (a)",
    "elevation": "(d) Elevation of the 5.5 km grid",
    "temperature_unit": "2m temperature (°C)",
    "correction_unit": "correction (K)",
    "elevation_unit": "elevation (m)",
}
low, high = np.nanpercentile(np.concatenate([raw_mean.ravel(), fine_mean.ravel()]), [1, 99])
fig = map_grid([
    dict(title=LABELS["coarse"], lon=model.lon, lat=model.lat, values=raw_mean,
         vmin=low, vmax=high, label=LABELS["temperature_unit"]),
    dict(title=LABELS["downscaled"], lon=model.lon, lat=model.lat, values=fine_mean,
         vmin=low, vmax=high, label=LABELS["temperature_unit"]),
    dict(title=LABELS["added"], lon=model.lon, lat=model.lat, values=fine_mean - interp_mean,
         cmap="RdBu_r", vmin=-6, vmax=6, label=LABELS["correction_unit"]),
    dict(title=LABELS["elevation"], lon=model.lon, lat=model.lat, values=elevation,
         cmap="terrain", vmin=0, vmax=3000, label=LABELS["elevation_unit"]),
], title=LABELS["title"])

在面板b)中,我们看到分辨率在所有地方都有改善,但在山区尤其明显。在面板c)和d)中,我们看到主要的校正在地形(中央高原、阿尔卑斯山、比利牛斯山)和海岸(尤其是布列塔尼)处添加。

与CERRA比较预报

CERRA是模型在训练期间学习复现的对象。与CERRA比较可以显示模型真正学到了多少。

# 展开查看评估代码的详细信息
def run(archive, init):
    """一次初始化的粗预报和降尺度预报,计算一次并保存。"""
    name = store_name(init)
    path = OUT / "downscaled" / name
    if not path.exists():
        coarse = fetch_forecast(archive, init, BOX, members=MEMBERS, max_lead_hours=MAX_LEAD_HOURS)
        save_forecast(coarse, OUT / "forecasts")
        save_downscaled(downscale_forecast(model, coarse, samples=EVAL_SAMPLES), path)
    return xr.open_zarr(OUT / "forecasts" / name)["t2m"], xr.open_zarr(path)


def score(downscaled, stations, cerra=None, short_leads=48):
    """对一次降尺度运行相对站点评分,如果给定CERRA也评分。"""
    downscaled = downscaled.isel(sample=slice(0, EVAL_SAMPLES))   # 第c节的运行有更多样本
    forecasts = {
        "AIFS-ENS": downscaled.t2m_interp.rename(ensemble="member").load(),
        "AIFS-ENS + downscaler": stack_members(downscaled).load(),
    }
    read = curvilinear_for(model.lat, model.lon, stations).apply
    observed = at_valid_times(stations.t2m, downscaled.valid_time)          # (time, lead_time, station)
    out = {"stations": {}, "means": {}}
    short = downscaled.lead_time <= np.timedelta64(short_leads, "h")
    for name, forecast in forecasts.items():
        at = read(forecast)
        out["stations"][name] = lead_scores(at, observed)
        out["means"][name] = at.mean("member").sel(lead_time=short).values
    out["observed"] = observed.sel(lead_time=short).values
    if cerra is not None:
        truth = at_valid_times(cerra.t2m, downscaled.valid_time)             # (time, lead_time, y, x)
        out["cerra"] = {name: lead_scores(f, truth.where(LAND)) for name, f in forecasts.items()}
        at = read(truth)                                                     # CERRA at the stations
        out["stations"]["CERRA"] = lead_scores(at, observed)                  # 同一有效时间:下限
        out["means"]["CERRA"] = at.sel(lead_time=short).values
    return out

archive_2020 = open_forecasts(FORECASTS_2020)
inits_2020 = available_inits(archive_2020)
cerra, _ = get_target(inits_2020[0], inits_2020[-1] + pd.Timedelta(hours=MAX_LEAD_HOURS), repo=CERRA_REPO,
                      load=True)   # 约30 MB,读取一次而不是流式读取
stations_2020 = load_published_stations(STATIONS, start="2020-08-01", end="2020-08-31")
print(f"{len(inits_2020)} initialisations, {inits_2020[0]:%d %b} to {inits_2020[-1]:%d %b %Y}; "
      f"{stations_2020.sizes['station']} stations")

runs_2020 = {init: run(archive_2020, init) for init in inits_2020}
results_2020 = [score(downscaled_2020, stations_2020, cerra) for _, downscaled_2020 in runs_2020.values()]

coarse_first, first = runs_2020[inits_2020[0]]
k = int(np.flatnonzero(first.lead_time.values == np.timedelta64(36, "h"))[0])
valid_2020 = pd.Timestamp(first.valid_time.values[0, k])

raw = native_cells(coarse_first.isel(time=0, lead_time=k).mean("ensemble").load() - ZERO_C,
                   model.lat, model.lon, LAND.values)
interp = first.t2m_interp.isel(time=0, lead_time=k).mean("ensemble").where(LAND).values - ZERO_C
down = stack_members(first).isel(time=0, lead_time=k).mean("member").where(LAND).values - ZERO_C
truth = cerra.t2m.sel(time=valid_2020).where(LAND).values - ZERO_C

when = f"valid {valid_2020:%d %b %Y %H} UTC (+36 h)"

fig = maps([
    ("AIFS-ENS - coarse dataset, resolution 0.25° (~28 km)", model.lon, model.lat, raw),
    ("CERRA - fine dataset, resolution: 5.5 km (~0.05°)", model.lon, model.lat, truth),
    ("AIFS-ENS + downscaler - resolution: 5.5 km (~0.05°)", model.lon, model.lat, down),
], width=4.4, title=f"Ensemble mean 2m temperature, {when}")

fig = maps([
    ("AIFS-ENS minus CERRA", model.lon, model.lat, interp - truth),
    ("AIFS-ENS + downscaler minus CERRA", model.lon, model.lat, down - truth),
], cmap="RdBu_r", vmin=-5, vmax=5, label="K", title=f"Error against CERRA, {when}")
for name, error in [("AIFS-ENS", interp - truth), ("AIFS-ENS + downscaler", down - truth)]:
    print(f"{name:24s} RMSE against CERRA over land {np.sqrt(np.nanmean(error ** 2)):.2f} K")

上面我们绘制了2020年夏季(未用于训练)的粗预报(AIFS-ENS)、细场(CERRA)以及AIFS-ENS + 降尺度模型。我们看到AIFS-ENS相对于CERRA在山区的误差非常高,而我们的降尺度模型很好地修正了这一点。

与观测比较预报

我们使用Météo-France气象站观测来评估模型与独立地面真实值。下载一个月的气象站数据大约需要1小时,所以我们将气象站数据缓存在Hub上(EmmaScharfmann/france-stations)以节省时间:上面评估代码中的load_published_stations会从那里读取。

# 每个站点、每次运行前48小时的集合平均和观测
names = ("AIFS-ENS", "AIFS-ENS + downscaler")
fields = {name: np.concatenate([r["means"][name].reshape(-1, r["means"][name].shape[-1]) for r in results_2020])
          for name in names}
observed = np.concatenate([r["observed"].reshape(-1, r["observed"].shape[-1]) for r in results_2020])
rmse_2020 = {name: station_rmse(fields[name], observed) for name in names}
fig = station_maps(stations_2020.longitude.values, stations_2020.latitude.values, rmse_2020,
                   title="RMSE against each station, first 48 h, August 2020")

我们再次看到,降尺度模型很好地修正了山区(阿尔卑斯山、中央高原、比利牛斯山)和海岸(布列塔尼、诺曼底)的误差,正如我们所预期。你可以在我们的演示空间运行更多的预报和观测评估。

训练降尺度模型

你可以按照我们训练自己降尺度模型的方式,训练自己的降尺度模型(你可以更改数据、分辨率、变量、区域等)。我们将在博客中介绍主要步骤,你也可以在这里找到完整教程。

设置

你需要克隆GitHub仓库并登录你的Earthmover免费账户以获取粗场(教程中为ERA5)。然后,你需要调整训练的配置(epoch数、每epoch步数、训练和验证数据的起止日期)。

构建训练对

你需要构建粗场-细场的配对。在我们的教程中,我们使用ERA5作为粗场,CERRA作为细场,2014到2019年用于训练,2020年用于评估。ERA5从Earthmover获取,而CERRA缓存在Hub上。然后,静态数据(细场分辨率)被添加到配对中:地形、经度/纬度、陆海掩膜、一年中的时间/一天中的时间以及到海岸的距离。模型必须学会预测下面两幅图之间的差值:

训练模型

我们训练一个小型U-net来学习预测粗场和细场之间的差。添加随机噪声以预测概率性校正。使用CRPS损失来训练集合降尺度模型。使用2014-2019年进行训练。我们训练模型20个epoch。

评估模型

我们对照CERRA(2020年)评估模型。我们绘制粗模型(ERA5)与ERA5 + 降尺度模型之间的差异。正如预期,我们看到山区或海岸的局部误差被降尺度模型校正,而粗模型的全局误差没有被降尺度模型校正(诺曼底的误差)。

我们还可以绘制预报的功率谱。功率谱显示一个场在每个尺度上有多少细节。右侧面板显示相同内容,但除以CERRA,因此1表示“细节与CERRA一样多”。ERA5在约100 km以下失去细节。降尺度成员跟随CERRA,因此模型在正确的尺度上添加了适量的细节。集合平均比单一成员略平滑,因为它平均了不确定的细节。

结论和一些值得分享的说明

训练一个降尺度模型需要GPU和几个小时。它不会在全球范围内让预报变得更好,但会让它在局部(好得多)。与CERRA比较能告诉我们降尺度模型是否学会了它被训练去学习的内容;与站点比较能告诉我们最终预报是否好。将CERRA本身与站点评分,给出了降尺度模型可以实现的上限。而我们的降尺度模型出人意料地接近这个上限(见下图)!

值得分享的说明

这是一个演示,不是稳健的评估。我们选择温度,因为它是一个平滑变量;夏天有些任意(我们最初研究热浪,后来转向训练降尺度模型);选择AIFS-ENS是因为它的预报在Earthmover上可用且运行快(GPU上每6小时步骤0.5秒)。

功率谱是我们最有用的诊断工具。它显示单一尺度的噪声会在该波长处堆积离散度,而改变尺度只是移动多余部分。在多个尺度上抽取噪声可以减轻问题,但不能完全解决。

所有误差都有很强的日循环,原因是CERRA、AIFS-ENS和降尺度模型都很难预测的效果,例如夜间逆温和午后地表加热。

粗场和细场之间只有大约10-30%的差异可以从我们的输入中预测。其余的将需要更多输入,例如风。

下一步:我们想尝试降水、风、时间条件处理(而不是独立处理每个时间步),以及直接在站点观测上训练的GNN(而不是再分析)。我们很想知道哪些内容有用,以及下一步应该做什么,请在评论中或通过电子邮件告诉我们。模型、代码和演示都已可用。

阅读原文
📚 相关主题 AI气象开源

订阅 AI Pulse

每天 08:00 · 12:30 · 18:30 · 23:50 更新