Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

10.4) (Exercise) Learning to Downscale Coarse Climate Fields Using Latent Diffusion

Open In Colab Open In Kaggle

This notebook builds a hierarchy of three models — a deterministic UNet, a variational autoencoder, and a latent diffusion model — that turns 25 km ERA5 2 m temperature fields into 2.2 km fields over Italy, following Tomasi et al. (2025) in a deliberately simplified form.

Run it on a GPU. Training the whole hierarchy takes roughly 45 minutes, so pretrained checkpoints ship with the data: the inference and scoring sections at the end can be run on their own.

The data

The data and the pretrained checkpoints come as one 1.94 GiB archive: a year (2020) of hourly ERA5 and COSMO-CLM temperature fields over the Italian domain, the three static rasters, and the three sets of checkpoints. pooch downloads it once, checks it against the sha256 below, and unpacks it into a shared cache, so later runs reuse what is already on disk.

The hash is what makes that check worth anything. A share link that has been revoked answers with an HTML sign-in page instead of an error, and without a hash that page would be written to data_LDM.zip and accepted as valid on every run afterwards.

For this exercise we shall be using PyTorch library for training our Latent Diffusion Model, with a lightweight wrapper called PyTorch Lightning to make the code cleaner, organised, and scalable: the essential attributes in all of Machine Learning.

Go ahead and click on the below cell to import all the libraries this notebook needs

Downscaling in Earth System Modeling

Downscaling based on deep learning (DL) is a key application in Earth System Modelling, and involves generating high resolution fields from coarse simulations using data-driven architectures. Probabilistic DL models (like diffusion) can also provide uncertainty quantification due to their capability of generating multiple outputs conditioned on a single coarse input, with or without auxiliary predictor fields. For more on climate downscaling, refer to this beginner-friendly resource: https://www.gfdl.noaa.gov/climate-model-downscaling/

About the paper (Tomasi et al., 2025) and our altered approach

The paper on which we have based this notebook can be accessed at https://gmd.copernicus.org/articles/18/2051/2025/ . The authors use a Latent Diffusion Model (LDM_res) to downscale ERA5 data (25 km) over the Italian domain to a resolution of 2 km, using COSMO-CLM simulations as the ground truth. The original setup from the paper contains 14 ERA5 input channels to predict 2 channels : temperature and wind speed. This is called perfect prognosis downscaling, where large scale predictors are used to predict local high resolution predictands.

However, for the purposes of this notebook, the code from the original repository has been repurposed to use a limited dataset and a leaner architecture. We shall be predicting high res 2m temperature COSMO-CLM target fields conditioned on static inputs along with low res 2m temperature ERA5 fields

Data

We are using hourly data from a single year here, 2020, with random split for training,validation and testing. Random split ensures a uniform distribution of samples across months and hours of the day. Note that random split might not suit all situations, and your choice of split depends on what you want the model to learn, and how you want it to generalise (for eg., decadal variability on climatic timescales)

There are three static (auxiliary) variables provided to the model in addition to 2m ERA5 temperature field. These are as follows :

  1. digital elevation model (DEM),

  2. land cover bands, and

  3. latitude

This is our domain

Topographic map of Italy and the Alps showing elevation from sea level to 4000 m

Figure 1:The domain the high-resolution fields cover: elevation over Italy and the Alpine ridge. Figure 1 of Tomasi et al. (2025), Geoscientific Model Development, CC BY 4.0.

Q1: Preprocessing the ERA5, COSMO-CLM and static datasets

Can you complete the code to preprocess the dataset for training the LDM?

Within the downloaded data_LDM folder, the file “preprocessing.py” (ldm_helpers/preprocessing.py) has been provided with helper functions so as to not clutter this notebook. Go ahead and inspect it for a better understanding of the steps used for preprocessing the dataset.

For a quick overview, here are the different functions present in the preprocessing file:

  1. decompress_zst_pt : this function is used for decompressing the .zst format dataset for 2020 that is stored in data_LDM directory. The library we use for such files is called “zstandard”. The job of this function is to load the file, decompress it and return it as a PyTorch object

  2. load_static_dif : This function returns a PyTorch tensor of static variables. we shall use this to process two single band static variables namely DEM and latitude.

  3. load_land_cover : Land cover is composed of multiple bands, consisting of 16 land cover categories, hence this function is dedicated to processing the land cover raster.

  4. collate_fn : This function is used for dataloader batching, meaning that it stacks up all variables as a batch. This is also the function where we “upsample” the ERA5 field to be at 2.2 km using bilinear interpolation for feeding into the UNet. As a result, all images (features and targets) will be at 672x576 resolution as a result, and inputs/outputs are concatenated. combined_input will as a result be of shape [batch, channels, 672,576] and high res output will be of shape [batch, 1, 672, 576]

  5. load_and_normalise: Says what it does. Normalises the variables after loading the files for 2020. Further, calculates the train, val and test splits according to the specifications (here, train:test:val = 0.7:0.15:0.15)

  6. get_file_list: This is a function which returns the paired high res and low res files as a tuple of shape (high_file_path, low_file_path, hour). Note that you can further reduce the size of the dataset with this function (HOW?)

Go ahead and run the below cell to import these functions into your notebook for the DownscalingDataset and DownscalingDataModule

Import the helper functions for preprocessing from the preprocessing.py file

We now see the implementation of the DownscalingDataset class in PyTorch, which determines how each sample is loaded (that is why it takes Dataset as the argument). This actually defines how to load a single sample with a batch containing channels with static and dynamical target (ground truth) and feature (input) fields.

Feel free to check out the preprocessing.py to get an idea of how the dataset was processed

Just to assert the importance of above code cell, the above class will be used by the PyTorch Lightning DataLoader in due course to obtain samples during the train/val/test process.

Q2: What is PyTorch Lightning and how does it differ from PyTorch?

PyTorch Lightning is an elegant lightweight wrapper that removes the hassle of writing and rewriting boilerplate code by “wrapping” (duh) the standard code such as data splitting and training. This helps with scaling the code, reproducing the experiments and also clean the code up by avoiding repetitions. In a nutshell, you are still using PyTorch. Lightning just is your organisation buddy!

A great resource to learn about the differences between PyTorch and PyTorch Lightning and how PyTorch Lightning makes your life easier: https://www.geeksforgeeks.org/deep-learning/pytorch-vs-pytorch-lightning/ (the table below summarises the differences drawn from that comparison)

PyTorchPyTorch Lightning
Training loopWritten out by hand, including moving data to the GPU, computing gradients and stepping the optimizerWritten once in the Trainer; you define hooks and callbacks instead
Model setupModel, loss, optimizer and the rest are defined and wired up explicitlyStandardised methods on the LightningModule
GPU and distributed trainingDevices and multi-GPU strategies are managed manuallySet by configuration; the number of devices is an argument
Logging and experiment trackingImplemented by hand against TensorBoard or another loggerBuilt-in loggers (TensorBoard, CSV, WandB, Comet)
CheckpointingImplemented by handModelCheckpoint, out of the box
Code structureLeft to the author, so it varies between projectsEnforced by the LightningModule interface

The trade-off is the usual one for a wrapper: less boilerplate and a structure other people can read, in exchange for having to learn where the library expects each piece of your code to go.

Now, for organising the process of loading the data, we use the PyTorch Lightning wrapper to wrap the previously coded DownscalingDataset class in a lightning friendly interface, the significance/handiness of which will be realised later. (short answer: it wraps dataloaders for the lightning TRAINER! wait for the long procedural answer which will be provided in due course ).

Just remember : while the DownscalingDataset class dictates how we load a single sample, the DownscalingDataModule below dictates the entire organisation, splitting of datasets and batchification for the entire pipeline !

Can you complete the code below for the DownscalingDataModule?

Please note that in the following sections, we will sequentially go through the code for the entire pipeline, culminating in running the said pipeline in a single line of code with the suitable configurations!

Q3: UNet for Deterministic (Mean) Prediction

Now we start with the “residual learning” model hierarchy, starting with training our deterministic UNet. For this, we need two complementary classes. 1) A UNet class which defines the architecture of a simple UNet for the prediction of high resolution 2m temperature field based and 2) Again,the PyTorch Lightning wrapper that wraps around this UNet class and adds all the logical steps of training the model.

Can you complete the code for the Lightning Module below and recognise how different it is from the aforementioned UNet class?

In case you are curious about what the configuration object “cfg” does, it contains the list of hyperparameters that are customisable from a YAML configuration file. For now, complete the code below. We promise it would become clearer as you progress through this notebook !

Q4: “Residual Learning” and predictive distributions: multiple equally valid answers?

UNet, being a deterministic regression model, gives us the conditional mean of the distribution of possible high resolution samples conditioned on a low resolution input. Herein lies the problem with regression based point prediction: unless we use a distribution to sample from, we cannot generate new samples.

However, due to the stochastic nature of downscaling, all different possibilities within the conditional distribution are possible realities! For more on conditional generative models, refer to https://visionbook.mit.edu/conditional_generative_models.html. That page works through the same point with a colorization example: a true data distribution, the point prediction a regression gives you (its expected value), and two distributions — Gaussian and softmax — that can be sampled from instead.

For conducting residual latent diffusion, we need an autoencoder to “encode” the residual between UNet prediction and the ground truth into latent space, followed by denoising and “reconstructing”/decoding the residuals back to pixel space to add to the UNet prediction. This way, we add different samples of the residual to the UNet prediction to “sharpen” the UNet prediction.

We now turn to generative modeling section, where we will take you step by step to actually generate samples from a conditional distribution, key to generative modeling.

Q5: What is a Variational Autoencoder?

We define a Variational Autoencoder as an infinite mixture model which makes use of the identity

\begin{aligned} p_{\theta}(\mathbf{x}) = \int_{\mathbf{z}} p_{\theta}(\mathbf{x} \bigm | \mathbf{z})p_{\mathbf{z}}(\mathbf{z})d\mathbf{z} \end{aligned}

Hence a conditional VAE will make use of the identity to encode latent variable z independent of the inputs we are conditioning on,

\begin{aligned} p_{\theta}(\mathbf{y} \bigm | \mathbf{x}) = \int_{\mathbf{z}} p_{\theta}(\mathbf{y} \bigm | \mathbf{z}, \mathbf{x})p_{\mathbf{z}}(\mathbf{z})d\mathbf{z} \quad\quad \triangleleft \end{aligned}

In context of climate downscaling, the simplest explanation for a VAE is that it is an architecture that encodes and decodes samples to/from latent space from the pixel space. The main idea is that the latent variable z should be “encoding” information about the distributional target y which is independent of what the conditional mean has already given us! This encoding/decoding is probabilistic, because encoder maps the input (in this cases, residuals from the UNet as we discussed before) to a distribution (with parameters mean and log_var). The decoder then reconstructs the input from the latent vector back to the pixel space. For the purpose of our present use case, the VAE will be used for encoding residual samples into for the diffusion denoising process.

For making our VAE work, we need a loss function, which is a combination of two types of losses :

  1. KL divergence loss : defined as kl_divergence in the notebook. Computes the Kullback-Leibler Divergence, which is a metric which measures how much the learned latent distribution is different from a standard normal distribution (in this case, as it is temperature). In essence, this pushes the encoder decoder model to produce realistic latents distributed like a standard normal, so it can generalise well. It is also known as relative entropy . For more, https://towardsdatascience.com/understanding-kl-divergence-f3ddc8dff254/.

  1. Reconstruction loss : This can be any loss, although most commonly used are simply L1 or L2 norm, measuring the error between reconstructed outputs and encoded inputs

Can you think of what makes an autoencoder variational? Hint : Conventional autoencoders use one deterministic loss!!! :)

Notes on the reparameterisation trick: fundamental to the training of generative architectures in DL

In generative architectures such as the VAE, reconstruction of inputs does not happen exactly, but it is sampled and generated. In other words, it learns a probabilistic distribution, which it samples and decodes from. However, remember that for backpropagation, which is a cornerstone for gradient based optimisation, this sampling operation has to be expressed mathematically in a differentiable way. Herein lies the genius of the reparameterisation trick. Refer to the code cell below where the VAE class is defined

So what did we do? Instead of sampling z (latent variable) deterministically, we rewrote the sampling step so that the gradients can now flow from the loss back to the encoder. (This explanation follows Understanding the reparameterization trick.)

In a nutshell, the reparameterisation trick allows backpropagation through a stochastic variable z. For a diagonal Gaussian:

z=μ+σ⊙ϵ,ϵ∼N(0,I)z = \mu + \sigma \odot \epsilon, \quad \epsilon \sim \mathcal{N}(0, I)

where:

  • μ\mu and σ\sigma are the encoder outputs for input (x)

  • ϵ\epsilon is random noise sampled from a standard normal

Now, the noise (randomness) is contained in ϵ\epsilon, and μ\mu and σ\sigma are differentiable again!

Back to being hands on, we have to wrap the VAE class above in a PyTorch Lightning wrapper as we did with the UNet

Q6: Denoising UNet for the latent space (Latent Diffusion)

Now that we have the encoded residuals in latent space, we can “denoise” these latent vectors using a UNet backbone for generating residual samples in the latent space, and then decode them back to pixel space to add to the original UNet mean prediction to generate samples of high resolution images conditioned on the coarse ERA5 + static predictors.

In the below LatentDenoiser class, we have coded a neural network that predicts the noise that is added to latent vectors at each diffusion timestep. The VAE is frozen during training the denoiser.

For the Lightning Module of the LDM, following are the steps:

  1. The UNet prediction as well as the VAE are frozen, so that the encoded residuals are denoised in latent space by the UNet

  2. The denoising UNet LatentDenoiser() class above is instantiated, that predicts noise in the latent space

  3. register_noise_schedule creates a schedule of noise levels (how much noise will be added at each step) for each timestep in the process of diffusion

  4. The q_sample method adds noise to a latent at a given timestep t using the schedule prescribed above. It is used to simulate the diffusion forward by corrupting the latent gradually

  5. p_losses samples a noisy latent vector at t and denoiser predicts the noise that was added at t . It then computes losses between predicted noise and the true noise level according to schedule

  6. sample : starts from completely random latent space noise and then performs iterative denoising using UNet denoiser and reverse diffusion. The final denoised latent is returned.

  7. generate_samples : samples new latents using the diffusion process and finally decodes them back to pixel space as images of residuals to be added to UNet.

In the above LatentDenoiser class and LightningModule, we have coded a neural network that predicts the noise that is added to latent vectors at each diffusion timestep. The VAE is frozen during training the denoiser. The noise schedule (which can be either linear or cosine) controls how noise is added at each timestep. The module, in essence, learns to then generate or denoise latent representations using a diffusion process, making it possible to sample new, realistic data from the latent vectors back to pixel space.

In the original paper and repository, the authors condition on a range of variables to provide more context to the model, using AFNO (Attention Fourier Neural Operator) attention blocks. For the sake of simplicity, we use a simple CNN to downsample the UNet prediction and use it as “context” tensor, to be concatenated to the latent variable z. Simply put, the conditioner class is a simple neural network for providing conditional information to the denoising process during diffusion. It serves the purpose of processing context data, eg., outputs a feature map that matches the latent space dimensions

Q7: Training the entire LDM_res hierarchy using a configuration file (YAML)

We have come a long way with extensive amount of code. So in case you wish to conduct sensitivity experiments and hyperparameter optimisation, do you have to make granular changes in each section of this tedious codebase? In case we need to change anything, do we really go back through the entire model and change every single thing we want to change?

The answer is NO! That is why we used PyTorch Lightning :) Now that we have the UNet, VAE and LDM classes along with their respective Lightning Modules, we are ready to train the entire hierarchy using a single configuration file (YAML) with our choice of hyperparameters. YAML is a data serialisation that is human readable and can be used to generate configuration files for conducting a range of experiments without changing the source code. For a primer on YAML : https://circleci.com/blog/what-is-yaml-a-beginner-s-guide/

First, go ahead and complete the training hierarchy function defined below. Then we shall write a YAML file to conduct experiments with the trained models. In case these seem to be a lot of steps, please do not be overwhelmed. Go through the code step by step to understand (and make sure to use a GPU!):

  1. We split our low res high res pairs into training, validation and testing

  2. Then we train the UNet to predict the conditional mean of the high resolution output.

  3. To further sharpen the image that is the UNet prediction, we calculate the residuals (difference between UNet prediction and the ground truth) and encode it into latent space using the encoder of the VAE

  4. Next, we “denoise” these residuals in latent space using the denoising UNet

  5. The “decoder” of the trained VAE decodes (reconstructs) the image back to pixel space (these are residuals)

Please note that in the interest of time, we have already trained and placed some model checkpoints in the data_LDM/checkpoints directory. In any case, the train_hierarchy() function makes sure that you have both options : training your own models (if your memory and compute allow) as well as also load pretrained weights to have fun with visualisation and analysis!

For your first experiment with the above training hierarchy function, go ahead and write your first YAML configuration file. In case you do not specify anything, the default setting in the source code is used by the model. Feel free to play with different configurations! Below is the config file using which we have trained the pretrained models provided in data_LDM/checkpoints. Please note that you can train your own models and save them IN A SEPARATE DIRECTORY.

YAML files can be created using the template below (only an example!)

We convert the above code block into a YAML config file as

You should now see the configuration file in your present working directory. Open it and inspect whether it is the same as what we wrote above!

Create this .yaml file and load it later in the cfg variable (as that is how it is referred to in the source code for all the PyTorch and Lightning wrappers). Now you know what the object “cfg” is in all the LightningModules in this notebook!Can you go back and inspect in what places we have used cfg objects? Does this explain the purpose of using config objects alongside default values modularised code and easy experimentation?

From now on, it should be clear that PyTorch Lightning in conjunction with a configuration manager such as OmegaConf or Hydra (built on OmegaConf, with even more advanced functionalities for configuring experiments) are indispensable for performing and logging experiments. We have barely scratched the surface of their functionalities in this notebook! For a primer on OmegaConf and Hydra: https://medium.com/data-science/keeping-up-with-pytorch-lightning-and-hydra-2nd-edition-34f88e9d5c90 and https://majianglin2003.medium.com/python-omegaconf-a33be1b748ab#567f.

For the purposes of running this pipeline, we stick to OmegaConf

You can see that the UNet and VAE are being loaded from a pretrained checkpoint. This is for saving time ! As the entire hierarchy of sequential models will take very long to train (approx 45 minutes from our experience of training it), we have ensured that you have pretrained models at your disposal. But feel free to try training them on your own (do not forget to change the checkpoint directory to avoid overwriting pretrained models!). See if you can train and compare configurations of your own.

Q8: Inference pipeline for visualising a single frame and generated samples

Fill out the code cell below to load the three pretrained architectures, and visualising a single frame from coarse input all the way to high resolution samples .

The below code:

  1. Loads standardisation stats from the .json file saved before

  2. Initialises the data module and loads the test set

  3. Loads the checkpointed models (UNet, VAE and LDM) and sets all models to eval mode

  4. Selects a random test frame for inference and prepares as input for the pixel UNet

  5. Runs the frame through the hierarchy (UNet, encoding of residuals, VAE, LDM and decoding)

  6. Samples three samples (latent vectors) from VAE, decodes them to generate three possible residuals, and adds these to the UNet to predict three different reconstructions

  7. Finally destandardises all outputs for visualising the frames and plots the six images : ground truth, ERA5 input field, UNet prediction, three generated samples

Start by setting up the test dataloader:

The below inference_random_frame() function now takes in an input sample from ERA5 test set, feeds it into the model hierarchy, and generates 3 samples. All samples are then plotted against ground truth.

To run the above inference pipeline, just run the below cell with the previously created configuration file!

Q9: Can you establish the added value of generative downscaling?

No matter how wonderful and close to the ground truth (target) your frame looks, we have to quantify the overall added value of UNet and the VAE+LDM over simple bilinear interpolation to truly gauge whether all these lines of code were worth the effort! As you have already seen the CRPS score in previous notebooks, let us go ahead and calculate it (Using crps from the properscoring library https://github.com/properscoring/properscoring )

Now we write the loop to generate 3 samples for each frame in the test set

Clearly, the model requires further training and optimisation. But it is surely getting there! You can optimise the hierarchy further.

How does your CRPS look for UNet and LDM as compared to coarse (bilinearly interpolated) inputs from ERA5? Did it improve? (Remember : for CRPS, lower means better). Can you think of two major ways you can improve the LDM scores over the UNet?

Through this illustrative exercise, we find the potential of LDMs as cost-effective, robust alternatives for downscaling applications (e.g., downscaling of climate projections), where computational resources are limited but high-resolution data are critical. Happy learning!