Do we really need bigger AI weather forecasting models to improve resolution?

Community Article
Published October 20, 2026

Applying the downscaler we trained on AIFS-ENS's output to improve the forecast resolution from ~ 28km to 5.5km. Ensemble mean of the forecast over France, valid Wednesday 13 August 2025 at 12:00 UTC (+60 h from initialisation). Ocean grid points are masked.

TL;DR

In this blog, we want to share something research and AI have taught us: you don’t always need a bigger model; often you need to adapt the model to your task. The same idea applies to AI weather forecasting. If you need forecasts at a higher resolution, you can take an existing global model and train a small downscaler on top of it to learn the local details, like the effects of orography or coastlines. We trained one in about an hour to downscale AIFS-ENS temperature forecasts over France from ~28 km to 5.5 km. Against Météo-France weather stations, it reduced the error by 14% in RMSE and 21% in CRPS over the first two days, and by 9% and 14% over ten days (as shown in the demo space).

Table of Contents

Intro

AI weather models are very good at forecasting large-scale weather patterns. But they see the world as a grid, often at 0.25° resolution (roughly equivalent to a grid of 28km in central Europe), where each cell holds a single value for temperature, wind, and every other variable. Anything smaller than a cell gets averaged out, so local effects from valleys, mountains, islands, or coastlines are lost.

Big labs are tackling this by building models that work at finer resolution. Google DeepMind's WeatherNext 3 for example, which is unfortunately not open source, forecasts surface temperature and humidity at around 5 km (~0.05°). But building and running such models require a huge amount of compute and storage, and engineering resources that only a few organizations have.

A cheaper way to do this is to train a downscaler (i.e. an AI model that predicts at a local scale starting from data at a larger scale) on top of an existing model. A downscaler learns local details to predict what happens within a cell, and therefore improves the resolution. And it runs in seconds, on top of an existing model forecast. In this post, we explain how a downscaler works, then we show how to run, train and evaluate one on weather-station observations for 2m temperature over France.

What is a downscaler?

A weather forecasting model predicts the weather at a specific resolution. A downscaler is an ML model that takes a low resolution forecast and fills in the local details. The downscaler learns these local details from static information such as elevation and the coastline. For example, it learns that within a coarse cell, a mountaintop is colder than the valley below and the coast is milder than inland. Once trained, it can be plugged on top of a coarse forecast to produce a high-resolution forecast at low cost.

You can try out the downscaler we trained on AIFS-ENS (ECMWF) [1] and GEFS (NOAA) forecasts and see how it compares against Météo-France temperature observations in our demo space below.

Like all ML models, downscalers are not magical. So, before training one, check that what you want to improve is something a downscaler can improve. Below is our summary of what a downscaler can and cannot improve.

What a downscaler can improve What a downscaler can't improve
Add the patterns due to the terrain (valleys, mountains, coastlines) Fix global errors in the coarse model: if the coarse is too warm in one region, the downscaler will not fix it
Produce a single or an ensemble of fine fields from one coarse field Add details that the inputs do not contain (it cannot add the effect of mountains without an input that identifies them, like the elevation)
Work behind any coarse model Reduce the uncertainty of the large-scale forecast
Run in seconds, once trained Exceed the quality of the fine field used during training

Downscaler components

Training

downscaler_training_diagram

During training, we show the model the same weather at two resolutions with some additional details (static data). It learns what the coarse version systematically misses by predicting the error between the coarse and fine weather.

Two datasets: the downscaler needs a coarse dataset and a fine dataset covering the same place and times. We used ERA5 [2] at 0.25° for the coarse dataset and CERRA [3] at 5.5 km for the fine dataset. For each time step, the coarse field is interpolated onto the fine grid. The model learns to predict the difference between this interpolated field and the fine field.

Static data: In addition to the field to predict, the downscaler needs additional information to infer what is happening within the cell. These fields are static and therefore easy to learn from. We add the season and the time of day, so the model can also learn how that correction varies through the day and through the year. In our example, we’re using the elevation of the fine-scale field, the height difference between the real surface and the coarse model’s surface, the land fraction and the distance to the sea of the cells, the longitude/latitude and the season and time of day as static data.

Loss function: We made the choice in our example of building a downscaler for an ensemble model. So, we use fair CRPS[4], which scores an ensemble rather than a single field. It rewards being close to the truth and having realistic spread. Note that the loss in our example is computed over land only, since the target reanalysis produces sea surface temperature differently from the land temperature.

Noise: For one large-scale state, there are several possible fine-scale states. So instead of asking for one prediction, we train the downscaler to predict multiple outputs. Each forward pass draws fresh noise and produces a different fine field. Therefore, the downscaler produces an ensemble of forecasts from one coarse field.

Downscaler: The model that learns to predict the difference between the coarse field and the fine field from the coarse field and the static data. In our example, we train a U-Net with about 2M parameters, which took around an hour to train on a GPU.

Inference

Once trained, the downscaler can be added on top of any forecast model (AI-based or physics-based) which outputs the same variable at the same resolution and covers the same region as the training data. In our example, that means that our downscaler can be used with any model producing 2m temperature at 0.25° over France. In this blog post, we apply it to AIFS-ENS. It could equally be applied to other 0.25° models such as Microsoft’s Aurora, GEFS (NOAA) or NVIDIA’s Atlas. Note that one of our static inputs (the height difference) is frozen against ERA5’s terrain. That is right for AIFS-ENS, which is initialised from ERA5, but not GEFS, which runs on its own. This may be part of why the downscaler performs less well on GEFS (see the demo space).

downscaler inference diagram

Input data: the downscaler takes the coarse ensemble forecast as input, as well as the same static data used during training, which is stored inside the model. Each coarse member is interpolated onto the fine grid before the forward pass. In our example, ten members from AIFS-ENS at 0.25° are used as input.

Downscaler: it takes the interpolated coarse field, the static data and a draw of noise, then predicts the correction to add to the coarse field. Since the noise is drawn on each pass, running the same member several times gives multiple fine fields.

Output: the model outputs multiple members at fine resolution. In our example, each of the ten coarse members yields four samples, giving forty members at 5.5 km. Note that in our example, running the downscaler takes only a few seconds per forecast.

Running a downscaler for temperature over France

This section walks you through how to use the downscaler we trained to improve the resolution of the temperature predicted by a model (here AIFS-ENS 0.25°)) over France. You can follow the steps below or run this tutorial.

  1. Set up Clone the repo and install dependencies.
git clone https://github.com/EmmaScharfmann/weather-forecast-model-downscaler
cd weather-forecast-model-downscaler
uv sync

The forecasts are fetched from the Earthmover Marketplace, which needs a free account and a free subscription to the AIFS-ENS dataset. Subscribing creates a repository under your organisation. Put the repo name in FORECASTS below, then authenticate once. We also recommend having a free Hugging Face account (sign up here) to download the model's weights and data.

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

Then, import the packages and set the config.

See packages and config
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"   # your Earthmover subscription
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 members to downscale  (out of 51)
MAX_LEAD_HOURS = 240
SAMPLES = 4           # members out of the downscaler (total 4 x 10  = 40)
EVAL_SAMPLES = 1     # both ensembles keep 10 members, and downscaling is 4x faster
BOX = forecast_box(parse_region("france"), COARSE_CROP_MARGIN_DEG)
OUT = Path("outputs/tutorial")   # every forecast is fetched and downscaled once, then read from here
  1. Fetch the coarse forecast
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)
  1. Run the downscaler

The model needs a fine grid as input, so the coarse field has to be interpolated first. The static data are part of the model's checkpoint on the Hub. Here, we choose to predict 4 fine members from the coarse field.

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 is the output of the downscaler: its shape is (time, lead_time, member, y, x), with 40 members (10 coarse members × 4 samples), and it holds the temperature in kelvin over France at a resolution of 5.5 km. On a GPU, the downscaler takes seconds to run.

We can plot the coarse forecast and the downscaled forecast:

LEAD = 60   # hours: 12 UTC on 13 August
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"])

Output of the code above: Predictions of the temperature over France: a) AIFS, b) AIFS + downscaler, c) downscaler, d) orography over France

We see on panel b) that the resolution is improved everywhere but it is especially obvious in the mountains. On panel c) and d), we see that the main corrections are added where the orography is (Massif Central, Alps, Pyrenees) and on the coast (especially in Brittany).

  1. Compare forecasts against CERRA

CERRA is what the model learned to reproduce during the training. Comparing against it shows how much the model actually learned.

Expand to see the details of the evaluation code
def run(archive, init):
    """The coarse and downscaled forecasts of one initialisation, computed once and saved."""
    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):
    """Score one downscaled run against the stations and, when given, against CERRA."""
    downscaled = downscaled.isel(sample=slice(0, EVAL_SAMPLES))   # the run from section c has more
    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)                  # same valid times: the floor
        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)   # about 30 MB, read once rather than streamed
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}")

Output of the code above: AIFS, CERRA, AIFS + downscaler temperature forecasts over France

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")

Output of the code above: AIFS and AIFS + downscaler's errors against CERRA

Above, we plot the coarse forecast (AIFS-ENS), the fine field (CERRA) and AIFS-ENS + downscaler on summer 2020 (which was not used for training). We see that the error of AIFS-ENS against CERRA is very high in the mountains, which is pretty well corrected by our downscaler.

  1. Compare forecasts against observations

We used observations from Météo-France weather stations to evaluate the model against an independent ground truth. Downloading the station data takes around 1 hour for 1 month, so we cached the stations' data on the Hub (EmmaScharfmann/france-stations) to save some time: load_published_stations in the evaluation code above reads them from there.

# Ensemble means and observations at every station, first 48 h of every run
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")

Output of the code above: AIFS and AIFS + downscaler errors against weather stations

Again, we see that the downscaler corrects pretty well the error in the mountains (Alps, Massif Central, Pyrenees) and on the coast (Brittany, Normandy), as we expect it to do.

You can run more forecast and evaluation against observations on our demo space.

Training the downscaler

You can train your own downscaler (you can change the data, resolution, variable, region...) following what we did to train our own downscaler. We will walk you through the main steps in this blog, but you can find a complete tutorial here.

  1. Set up

You need to clone the github repo and log into your Earthmover free account to fetch the coarse field (ERA5 in the tutorial). Then, you need to adapt the configuration for the training (number of epochs, steps per epochs, starting and ending dates of the data for training and validation).

  1. Building the training pair

You need to build pairs of coarse field - fine field. In our tutorial, we're using ERA5 for the coarse field and CERRA for the fine field from 2014 to 2019 for the training and 2020 for the evaluation. ERA5 is fetched from Earthmover while CERRA is cached on the Hub. Then, the static data (at the resolution of the fine field) are added to the pairs: orography, longitude/latitude, land-sea mask, time of the year/day and distance to the coast. The model has to learn to predict the difference between the two maps below:

ERA5 and CERRA 2m temperature

  1. Training the model

We train a small U-net to learn to predict the difference between the coarse and the fine field. Random noise is added to predict a probabilistic correction. A CRPS loss is used to train the ensemble downscaler. 2014-2019 is used for training. We train the model for 20 epochs.

training and testing loss

AIFS, AIFS + downscaler, CERRA 2m temperature

  1. Evaluating the model

We evaluate the model against CERRA (year 2020). We plot the difference between the coarse model (ERA5) and ERA5 + downscaler. As expected, we see that local errors in the mountains or the coast are corrected by the downscaler while global errors of the coarse model are not corrected by the downscaler (error in Normandy).

error against CERRA for ERA5 and ERA5 + downscaler

We can also plot the power spectrum of the forecasts. The power spectrum shows how much detail a field has at each scale. The right panel shows the same, but divided by CERRA, so 1 means “as detailed as CERRA”.

ERA5 runs out of detail below ~100 km. A downscaled member follows CERRA, so the model adds the right amount of detail at the right scales. The ensemble mean is slightly smoother than the single member as it averages the details that are uncertain.

Power spectrum of the 2m temperature for ERA5, ERA5 + downscaler and CERRA.

Conclusion and some notes worth sharing

Training a downscaler takes a GPU and a couple of hours. It does not make a forecast better globally, but it makes it (way) better locally. Comparing against CERRA tells us whether the downscaler learned what it was trained to learn, comparing against stations tells us whether the final forecast is good. Scoring CERRA itself against stations gives an upper bound on what the downscaler can achieve. And our downscaler gets surprisingly close to that bound (as shown in the figure below)!

RMSE by station elevation and by time of day for AIFS-ENS, AIFS-ENS + downscaler and CERRA, August 2020, all lead times. From the demo space.

Notes worth sharing

  • This is a demonstration, not a robust evaluation. We chose temperature because it is a smooth variable, summer somewhat arbitrarily (we started out looking at heatwaves and drifted towards training a downscaler), and AIFS-ENS because its forecasts were available on Earthmover and it runs fast (0.5 s per 6-hour step on a GPU).
  • The power spectrum was our most useful diagnostic. It showed that noise drawn at a single scale piles up spread at that wavelength, and changing the scale only moves the excess. Drawing noise at several scales reduces the problem but doesn't fully solve it.
  • All errors have a strong daily cycle, because of effects that CERRA, AIFS-ENS and the downscaler all struggle to predict, such as inversions at night and surface heating in the afternoon.
  • Only about 10–30% of the difference between the coarse and fine fields is predictable from our inputs. The rest would need more inputs, such as wind.

Next steps: we'd like to try precipitation and wind, temporal conditioning instead of treating each time step independently, and a GNN trained directly on station observations instead of a reanalysis. We'd love your feedback on what was useful and what we should do next, in the comments or by email. The model, code and demo are all available.

Acknowledgements: thanks a lot to Quentin Nicolas for sharing his climate expertise and for the many comments that made this post clearer and more accurate.

References

  1. Lang, S., Alexe, M., Clare, M.C.A. et al. AIFS-CRPS: ensemble forecasting using a model trained with a loss function based on the continuous ranked probability score. npj Artif. Intell. 2, 18 (2026). https://doi.org/10.1038/s44387-026-00073-7
  2. Hersbach, H., Bell, B., Berrisford, P., et al. (2020). The ERA5 global reanalysis. Q. J. R. Meteorol. Soc., 146(730), 1999–2049. https://doi.org/10.1002/qj.3803
  3. Ridal, M., Bazile, E., Le Moigne, P., et al. (2024). CERRA, the Copernicus European Regional Reanalysis system. Q. J. R. Meteorol. Soc., 150. https://doi.org/10.1002/qj.4764
  4. Ferro, C. A. T. (2014). Fair scores for ensemble forecasts. Q. J. R. Meteorol. Soc., 140, 1917–1923. https://doi.org/10.1002/qj.2270

Community

Cool! For what to do next, I'd be interested in:

  • solar radiation (ssrd)
  • reliability diagrams
  • temporal downscaling (e.g., hourly outputs from the 6-hourly AIFS inputs)
    • it would be extra interesting to see multiple possible downscaled outputs per each ensemble member input (i.e., because there would like be a lot of uncertainty in the in between timesteps, make something like 3 or 5 possible outputs, for a 150 or 250 member hourly downscaled ensemble)
Article author

thanks Will for the feedback!

Sign up or log in to comment