Source code for neural_lam.datastore.npyfilesmeps.compute_standardization_stats

"""Utilities for computing MEPS datastore standardization statistics."""

# Standard library
import os
import subprocess
from argparse import ArgumentParser
from datetime import timedelta
from pathlib import Path
from typing import Any, cast

# Third-party
import torch
import torch.distributed as dist
from torch.utils.data.distributed import DistributedSampler
from tqdm import tqdm

# First-party
from neural_lam import WeatherDataset
from neural_lam.datastore import init_datastore
from neural_lam.utils import get_integer_time


[docs] class PaddedWeatherDataset(torch.utils.data.Dataset): """Wrap :class:`WeatherDataset` to pad samples for distributed runners.""" def __init__( self, base_dataset: WeatherDataset, world_size: int, batch_size: int, ) -> None: """ Parameters ---------- base_dataset : WeatherDataset Dataset to pad. world_size : int Total number of distributed ranks participating. batch_size : int Per-rank batch size. """ super().__init__() self.base_dataset = base_dataset self.world_size = world_size self.batch_size = batch_size self.total_samples = len(base_dataset) self.padded_samples = ( (self.world_size * self.batch_size) - self.total_samples ) % self.world_size def __getitem__( # ty: ignore[invalid-method-override] self, idx: int ) -> tuple[ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor ]: """Return an item, repeating the final sample for padded indices. Parameters ---------- idx : int Index into the padded dataset. Returns ------- tuple of torch.Tensor The sample, as returned by `WeatherDataset.__getitem__`. """ return self.base_dataset[min(idx, self.total_samples - 1)] def __len__(self) -> int: """Return the padded dataset length.""" return self.total_samples + self.padded_samples
[docs] def real_sample_masks( self, sampler: DistributedSampler ) -> list[torch.Tensor]: """ Return one boolean mask per minibatch, ``True`` for non-padded rows. ``DistributedSampler`` stripes indices across ranks, so the padded tail of this dataset lands on several ranks and cannot be sliced off the rank-major gathered tensor. Each rank drops its padded rows with these masks instead; the sampler order chunked by ``batch_size`` is the order a ``DataLoader(drop_last=False)`` yields batches in. Parameters ---------- sampler : DistributedSampler Sampler of this rank's ``DataLoader`` over this dataset. Returns ------- list[torch.Tensor] Boolean mask per minibatch. """ is_real = torch.tensor(list(sampler)) < self.total_samples return list(is_real.split(self.batch_size))
[docs] def get_rank() -> int: """ Return the rank inferred from SLURM or default to 0. Returns ------- int The current process rank. """ return int(os.environ.get("SLURM_PROCID", 0))
[docs] def get_world_size() -> int: """ Return the world size inferred from SLURM or default to 1. Returns ------- int The total number of processes in the distributed group. """ return int(os.environ.get("SLURM_NTASKS", 1))
[docs] def setup(rank: int, world_size: int) -> None: # pylint: disable=redefined-outer-name """ Initialize the distributed group. Parameters ---------- rank : int The rank of the current process. world_size : int The total number of processes. Raises ------ RuntimeError If ``SLURM_JOB_NODELIST`` is set but no hostnames can be retrieved. """ if "SLURM_JOB_NODELIST" in os.environ: nodelist = os.environ["SLURM_JOB_NODELIST"] hostnames = subprocess.check_output( ["scontrol", "show", "hostnames", nodelist], ) hostname_lines = hostnames.decode("utf-8").splitlines() if not hostname_lines: raise RuntimeError( f"SLURM_JOB_NODELIST is set to {repr(nodelist)}, but " "'scontrol show hostnames' returned no hostnames. " "Please check your SLURM job configuration." ) master_node = hostname_lines[0].strip() else: print( "\033[91mCareful, you are running this script with --distributed " "without any scheduler. In most cases this will result in slower " "execution and the --distributed flag should be removed.\033[0m" ) master_node = "localhost" os.environ["MASTER_ADDR"] = master_node os.environ["MASTER_PORT"] = "12355" dist.init_process_group( "nccl" if torch.cuda.is_available() else "gloo", rank=rank, world_size=world_size, ) if rank == 0: print( f"Initialized {dist.get_backend()} " f"process group with world size {world_size}." )
[docs] def save_stats( static_dir_path: str | Path, means: list[torch.Tensor], squares: list[torch.Tensor], flux_means: list[torch.Tensor], flux_squares: list[torch.Tensor], filename_prefix: str, ) -> None: """ Aggregate running statistics and persist them to ``static_dir_path``. Parameters ---------- static_dir_path : str or pathlib.Path Directory where ``*.pt`` files should be written. means : Sequence[torch.Tensor] Shape ``(B, num_state_vars)``. Batch-wise means. Sequence of one or more tensors each of shape ``(B_i, num_state_vars)``; concatenated / stacked along the batch dim before reduction. Typical callers pass a single already-gathered tensor. The list can have length 0 to skip saving. squares : Sequence[torch.Tensor] Shape ``(B, num_state_vars)``. Batch-wise second moments. Sequence of one or more tensors each of shape ``(B_i, num_state_vars)``; concatenated / stacked along the batch dim before reduction. Typical callers pass a single already-gathered tensor. The list can have length 0 to skip saving. flux_means : Sequence[torch.Tensor] Shape ``(B,)``. Flux means. Sequence of one or more tensors each of shape ``(B_i,)``; concatenated / stacked along the batch dim before reduction. Typical callers pass a single already-gathered tensor. The list can have length 0 to skip saving. flux_squares : Sequence[torch.Tensor] Shape ``(B,)``. Flux second moments. Sequence of one or more tensors each of shape ``(B_i,)``; concatenated / stacked along the batch dim before reduction. Typical callers pass a single already-gathered tensor. The list can have length 0 to skip saving. filename_prefix : str Prefix (e.g., ``"parameter"`` or ``"diff"``) for saved tensors. """ means_tensor = ( torch.stack(means) if len(means) > 1 else means[0] ) # (B, num_state_vars,) squares_tensor = ( torch.stack(squares) if len(squares) > 1 else squares[0] ) # (B, num_state_vars,) mean = torch.mean(means_tensor, dim=0) # (num_state_vars,) second_moment = torch.mean(squares_tensor, dim=0) # (num_state_vars,) std = torch.sqrt(second_moment - mean**2) # (num_state_vars,) print( f"Saving {filename_prefix} mean and std.-dev. to " f"{filename_prefix}_mean.pt and {filename_prefix}_std.pt" ) torch.save( mean.cpu(), os.path.join(static_dir_path, f"{filename_prefix}_mean.pt") ) torch.save( std.cpu(), os.path.join(static_dir_path, f"{filename_prefix}_std.pt") ) if len(flux_means) == 0: return flux_means_tensor = ( torch.stack(flux_means) if len(flux_means) > 1 else flux_means[0] ) # (B,) flux_squares_tensor = ( torch.stack(flux_squares) if len(flux_squares) > 1 else flux_squares[0] ) # (B,) flux_mean = torch.mean(flux_means_tensor) # (,) flux_second_moment = torch.mean(flux_squares_tensor) # (,) flux_std = torch.sqrt(flux_second_moment - flux_mean**2) # (,) print("Saving flux mean and std.-dev. to flux_stats.pt") torch.save( torch.stack((flux_mean, flux_std)).cpu(), os.path.join(static_dir_path, "flux_stats.pt"), )
[docs] def main( datastore_config_path: str | Path, batch_size: int, step_length: timedelta, n_workers: int, distributed: bool, ) -> None: """ Pre-compute and persist standardization statistics from the datastore. Parameters ---------- datastore_config_path : str or pathlib.Path Path to the MEPS datastore configuration file. batch_size : int Batch size used while iterating through the dataset. step_length : datetime.timedelta Temporal sampling interval for the difference statistics. n_workers : int Number of dataloader workers. distributed : bool If ``True``, run using torch.distributed with SLURM settings. """ rank = get_rank() world_size = get_world_size() datastore = init_datastore( datastore_kind="npyfilesmeps", config_path=datastore_config_path ) static_dir_path = Path(datastore_config_path).parent / "static" os.makedirs(static_dir_path, exist_ok=True) if distributed: setup(rank, world_size) device = torch.device( f"cuda:{rank}" if torch.cuda.is_available() else "cpu" ) torch.cuda.set_device(device) if torch.cuda.is_available() else None # Setting this to the original value of the Oskarsson et al. paper (2023) # 65 forecast steps - 2 initial steps = 63 ar_steps = 63 # Raw (non-standardized) data for computing mean/std ds_base = WeatherDataset( datastore=datastore, split="train", ar_steps=ar_steps, num_past_forcing_steps=0, num_future_forcing_steps=0, ) sampler: DistributedSampler | None ds: torch.utils.data.Dataset if distributed: ds = PaddedWeatherDataset( ds_base, world_size, batch_size, ) sampler = DistributedSampler( ds, num_replicas=world_size, rank=rank, shuffle=False ) else: ds = ds_base sampler = None loader = torch.utils.data.DataLoader( ds, batch_size, shuffle=False, num_workers=n_workers, sampler=sampler, ) if rank == 0: print("Computing mean and std.-dev. for parameters...") means, squares, flux_means, flux_squares = [], [], [], [] real_masks = None if distributed: assert isinstance(ds, PaddedWeatherDataset) assert sampler is not None real_masks = ds.real_sample_masks(sampler) for batch_i, (init_batch, target_batch, forcing_batch, _, _) in enumerate( tqdm(loader) ): if distributed: init_batch, target_batch, forcing_batch = ( init_batch.to(device), target_batch.to(device), forcing_batch.to(device), ) # (B, num_times, num_grid_nodes, num_state_vars) batch = torch.cat((init_batch, target_batch), dim=1) # Flux at 1st windowed position is index 0 in forcing flux_batch = forcing_batch[:, :, :, 0] # (B, num_state_vars,) batch_means = torch.mean(batch, dim=(1, 2)).cpu() batch_squares = torch.mean(batch**2, dim=(1, 2)).cpu() # (B,) batch_flux_means = torch.mean(flux_batch, dim=(1, 2)).cpu() batch_flux_squares = torch.mean(flux_batch**2, dim=(1, 2)).cpu() if real_masks is not None: mask = real_masks[batch_i] batch_means = batch_means[mask] batch_squares = batch_squares[mask] batch_flux_means = batch_flux_means[mask] batch_flux_squares = batch_flux_squares[mask] means.append(batch_means) squares.append(batch_squares) flux_means.append(batch_flux_means) flux_squares.append(batch_flux_squares) if distributed and world_size > 1: means_gathered: list[Any] = [None] * world_size squares_gathered: list[Any] = [None] * world_size flux_means_gathered: list[Any] = [None] * world_size flux_squares_gathered: list[Any] = [None] * world_size dist.all_gather_object(means_gathered, torch.cat(means, dim=0)) dist.all_gather_object(squares_gathered, torch.cat(squares, dim=0)) dist.all_gather_object( flux_means_gathered, torch.cat(flux_means, dim=0) ) dist.all_gather_object( flux_squares_gathered, torch.cat(flux_squares, dim=0) ) if rank == 0: means = [ torch.cat(cast("list[torch.Tensor]", means_gathered), dim=0) ] squares = [ torch.cat(cast("list[torch.Tensor]", squares_gathered), dim=0) ] flux_means = [ torch.cat( cast("list[torch.Tensor]", flux_means_gathered), dim=0 ) ] flux_squares = [ torch.cat( cast("list[torch.Tensor]", flux_squares_gathered), dim=0 ) ] else: means = [torch.cat(means, dim=0)] # (B, num_state_vars,) squares = [torch.cat(squares, dim=0)] # (B, num_state_vars,) flux_means = [torch.cat(flux_means, dim=0)] # (B,) flux_squares = [torch.cat(flux_squares, dim=0)] # (B,) if rank == 0: save_stats( static_dir_path, means, squares, flux_means, flux_squares, "parameter", ) if distributed: dist.barrier() if rank == 0: print("Computing mean and std.-dev. for one-step differences...") ds_standard_base = WeatherDataset( datastore=datastore, split="train", ar_steps=ar_steps, num_past_forcing_steps=0, num_future_forcing_steps=0, ) sampler_standard: DistributedSampler | None ds_standard: torch.utils.data.Dataset if distributed: ds_standard = PaddedWeatherDataset( ds_standard_base, world_size, batch_size, ) sampler_standard = DistributedSampler( ds_standard, num_replicas=world_size, rank=rank, shuffle=False ) else: ds_standard = ds_standard_base sampler_standard = None loader_standard = torch.utils.data.DataLoader( ds_standard, batch_size, shuffle=False, num_workers=n_workers, sampler=sampler_standard, ) # WeatherDataset no longer standardizes, so load the state mean/std saved # in the first pass above and apply them inline to compute diff stats. state_mean = torch.load( os.path.join(static_dir_path, "parameter_mean.pt"), weights_only=True ) state_std = torch.load( os.path.join(static_dir_path, "parameter_std.pt"), weights_only=True ) if distributed: state_mean = state_mean.to(device) state_std = state_std.to(device) time_step_int, time_step_unit = get_integer_time(step_length) assert time_step_unit == "hours", ( "Only 'hours' time unit is supported by meps datastore." ) used_subsample_len = (65 // time_step_int) * time_step_int diff_means, diff_squares = [], [] real_masks_standard = None if distributed: assert isinstance(ds_standard, PaddedWeatherDataset) assert sampler_standard is not None real_masks_standard = ds_standard.real_sample_masks(sampler_standard) for batch_i, (init_batch, target_batch, _, _, _) in enumerate( tqdm(loader_standard, disable=rank != 0) ): if distributed: init_batch, target_batch = ( init_batch.to(device), target_batch.to(device), ) init_batch = (init_batch - state_mean) / state_std target_batch = (target_batch - state_mean) / state_std # (B, num_times', num_grid_nodes, num_state_vars) batch = torch.cat((init_batch, target_batch), dim=1) # Note: batch contains only 1h-steps stepped_batch = torch.cat( [ batch[:, ss_i:used_subsample_len:time_step_int] for ss_i in range(time_step_int) ], dim=0, ) # (B', num_times, num_grid_nodes, num_state_vars), # B' = step_length*B batch_diffs = stepped_batch[:, 1:] - stepped_batch[:, :-1] # (B', num_times-1, num_grid_nodes, num_state_vars) batch_diff_means = torch.mean(batch_diffs, dim=(1, 2)).cpu() # (B', num_state_vars,) batch_diff_squares = torch.mean(batch_diffs**2, dim=(1, 2)).cpu() # (B', num_state_vars,) if real_masks_standard is not None: # stepped_batch stacks the time_step_int ss_i blocks along dim 0, # so tile the mask (repeat, not repeat_interleave) mask = real_masks_standard[batch_i].repeat(time_step_int) batch_diff_means = batch_diff_means[mask] batch_diff_squares = batch_diff_squares[mask] diff_means.append(batch_diff_means) diff_squares.append(batch_diff_squares) if distributed and world_size > 1: dist.barrier() diff_means_gathered: list[Any] = [None] * world_size diff_squares_gathered: list[Any] = [None] * world_size dist.all_gather_object( diff_means_gathered, torch.cat(diff_means, dim=0) ) dist.all_gather_object( diff_squares_gathered, torch.cat(diff_squares, dim=0) ) if rank == 0: diff_means = [ torch.cat( cast("list[torch.Tensor]", diff_means_gathered), dim=0 ) ] diff_squares = [ torch.cat( cast("list[torch.Tensor]", diff_squares_gathered), dim=0 ) ] diff_means = [torch.cat(diff_means, dim=0)] # (B', num_state_vars,) diff_squares = [torch.cat(diff_squares, dim=0)] # (B', num_state_vars,) if rank == 0: save_stats(static_dir_path, diff_means, diff_squares, [], [], "diff") if distributed: dist.destroy_process_group()
[docs] def cli() -> None: """Parse CLI arguments and trigger :func:`main`.""" parser = ArgumentParser(description="Training arguments") parser.add_argument( "--datastore_config_path", type=str, help="Path to data config file", ) parser.add_argument( "--batch_size", type=int, default=32, help="Batch size when iterating over the dataset", ) parser.add_argument( "--step_length", type=int, default=3, help="Step length in hours to consider single time step (default: 3)", ) parser.add_argument( "--n_workers", type=int, default=4, help="Number of workers in data loader (default: 4)", ) parser.add_argument( "--distributed", action="store_true", help="Run the script in distributed mode (default: False)", ) args = parser.parse_args() distributed = bool(args.distributed) main( datastore_config_path=args.datastore_config_path, batch_size=args.batch_size, step_length=timedelta(hours=args.step_length), n_workers=args.n_workers, distributed=distributed, )
if __name__ == "__main__": cli()