Download scripts/train.py from OneScience-Group/StormCast: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/OneScience-Group/StormCast/resolve/main/scripts/train.py
- Command line
-
hf download hf://OneScience-Group/StormCast/scripts/train.py
-
curl -L -o train.py https://huggingface.co/OneScience-Group/StormCast/resolve/main/scripts/train.py
15.9 kB
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import random | |
| import sys | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| MODEL_ROOT = PROJECT_ROOT / "model" | |
| SCRIPT_ROOT = PROJECT_ROOT / "scripts" | |
| for path in (PROJECT_ROOT, MODEL_ROOT, SCRIPT_ROOT): | |
| if str(path) not in sys.path: | |
| sys.path.insert(0, str(path)) | |
| import torch | |
| import torch.distributed as dist | |
| import torch.nn.functional as F | |
| import yaml | |
| from torch import nn | |
| from torch.nn.parallel import DistributedDataParallel | |
| from torch.utils.data import DataLoader, DistributedSampler | |
| from stormer import build_diffusion_model, build_regression_model | |
| from data_loader import StormCastDataset | |
| class DistributedContext: | |
| device: torch.device | |
| rank: int | |
| local_rank: int | |
| world_size: int | |
| def distributed(self) -> bool: | |
| return self.world_size > 1 | |
| def is_main(self) -> bool: | |
| return self.rank == 0 | |
| def regression_loss( | |
| model: nn.Module, | |
| condition: torch.Tensor, | |
| target: torch.Tensor, | |
| ) -> torch.Tensor: | |
| return F.mse_loss(model(condition), target) | |
| def edm_residual_loss( | |
| model: nn.Module, | |
| residual: torch.Tensor, | |
| condition: torch.Tensor, | |
| sigma_data: float = 0.5, | |
| p_mean: float = -1.2, | |
| p_std: float = 1.2, | |
| ) -> torch.Tensor: | |
| sigma = torch.exp( | |
| torch.randn(residual.shape[0], device=residual.device) * p_std + p_mean | |
| ) | |
| noise = torch.randn_like(residual) * sigma[:, None, None, None] | |
| denoised = model(residual + noise, sigma, condition=condition) | |
| weight = (sigma.square() + sigma_data**2) / (sigma * sigma_data).square() | |
| return (weight[:, None, None, None] * (denoised - residual).square()).mean() | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="Train the two-stage StormCast model") | |
| parser.add_argument("--config", type=Path, default=Path("conf/config.yaml")) | |
| parser.add_argument("--stage", choices=("regression", "diffusion")) | |
| parser.add_argument("--resume", type=Path) | |
| parser.add_argument("--initial-weights", type=Path) | |
| parser.add_argument("--regression-weights", type=Path) | |
| parser.add_argument("--max-steps", type=int) | |
| return parser.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| config_path = args.config.resolve() | |
| with config_path.open("r", encoding="utf-8") as handle: | |
| config = yaml.safe_load(handle) | |
| _resolve_config_paths(config, config_path.parent.parent) | |
| training = config["training"] | |
| stage = args.stage or training["stage"] | |
| resume = args.resume or _optional_path(training.get("resume_checkpoint")) | |
| initial_weights = args.initial_weights or _optional_path(training.get("initial_weights")) | |
| regression_weights = args.regression_weights or _optional_path( | |
| training.get("regression_weights") | |
| ) | |
| max_steps = args.max_steps if args.max_steps is not None else training["max_steps"] | |
| if resume is None and initial_weights is None and not training["from_scratch"]: | |
| checkpoint_key = f"{stage}_checkpoint" | |
| initial_weights = _optional_path(config["model"].get(checkpoint_key)) | |
| if initial_weights is None: | |
| raise ValueError( | |
| f"training.from_scratch is false but model.{checkpoint_key} is not set" | |
| ) | |
| context = initialize_distributed() | |
| _seed_everything(config["project"]["seed"], context.rank) | |
| try: | |
| train( | |
| config=config, | |
| stage=stage, | |
| context=context, | |
| resume=resume, | |
| initial_weights=initial_weights, | |
| regression_weights=regression_weights, | |
| max_steps=max_steps, | |
| ) | |
| finally: | |
| if dist.is_initialized(): | |
| dist.destroy_process_group() | |
| def train( | |
| config: dict[str, Any], | |
| stage: str, | |
| context: DistributedContext, | |
| resume: Path | None, | |
| initial_weights: Path | None, | |
| regression_weights: Path | None, | |
| max_steps: int | None, | |
| ) -> None: | |
| if stage not in ("regression", "diffusion"): | |
| raise ValueError("training.stage must be 'regression' or 'diffusion'") | |
| data_config = config["data"] | |
| loader_config = config["dataloader"] | |
| training_config = config["training"] | |
| if list(data_config["image_size"]) != list(config["model"]["image_size"]): | |
| raise ValueError("Data and model image sizes must match") | |
| if list(data_config["era5_image_size"]) != [721, 1440]: | |
| raise ValueError("ERA5 grid must be 721 x 1440") | |
| dataset = StormCastDataset( | |
| data_root=data_config["root_dir"], | |
| years=data_config["train_years"], | |
| era5_variables=data_config["era5_variables"], | |
| state_variables=data_config["state_variables"], | |
| invariant_variables=data_config["invariant_variables"], | |
| image_size=data_config["image_size"], | |
| input_steps=data_config["input_steps"], | |
| output_steps=data_config["output_steps"], | |
| normalize=data_config["normalize"], | |
| ) | |
| sampler = ( | |
| DistributedSampler( | |
| dataset, | |
| num_replicas=context.world_size, | |
| rank=context.rank, | |
| shuffle=True, | |
| ) | |
| if context.distributed | |
| else None | |
| ) | |
| loader = DataLoader( | |
| dataset, | |
| batch_size=loader_config["batch_size"], | |
| shuffle=sampler is None, | |
| sampler=sampler, | |
| num_workers=loader_config["num_workers"], | |
| pin_memory=loader_config["pin_memory"], | |
| drop_last=False, | |
| ) | |
| regression, model = _build_stage_models( | |
| config, stage, regression_weights, context.device | |
| ) | |
| if initial_weights is not None and resume is None: | |
| _load_initial_weights(model, initial_weights, stage) | |
| model.to(context.device) | |
| optimizer = torch.optim.Adam( | |
| model.parameters(), | |
| lr=training_config["learning_rate"], | |
| betas=tuple(training_config["betas"]), | |
| weight_decay=training_config["weight_decay"], | |
| ) | |
| start_epoch = 0 | |
| start_batch = 0 | |
| global_step = 0 | |
| if resume is not None: | |
| start_epoch, start_batch, global_step = load_training_checkpoint( | |
| resume, model, optimizer, stage, context.device | |
| ) | |
| if context.distributed: | |
| model = DistributedDataParallel( | |
| model, | |
| device_ids=[context.local_rank], | |
| output_device=context.local_rank, | |
| ) | |
| checkpoint_dir = Path(training_config["checkpoint_dir"]) / stage | |
| if context.is_main: | |
| checkpoint_dir.mkdir(parents=True, exist_ok=True) | |
| print( | |
| f"stage={stage} world_size={context.world_size} " | |
| f"parameters={sum(parameter.numel() for parameter in model.parameters())}" | |
| ) | |
| stop = False | |
| if max_steps is not None and global_step >= max_steps: | |
| stop = True | |
| for epoch in range(start_epoch, training_config["epochs"]): | |
| if stop: | |
| break | |
| if sampler is not None: | |
| sampler.set_epoch(epoch) | |
| model.train() | |
| for batch_index, batch in enumerate(loader): | |
| if epoch == start_epoch and batch_index < start_batch: | |
| continue | |
| loss = _training_step( | |
| stage, | |
| model, | |
| regression, | |
| batch, | |
| context.device, | |
| training_config, | |
| ) | |
| optimizer.zero_grad(set_to_none=True) | |
| loss.backward() | |
| clip_norm = training_config.get("gradient_clip_norm") | |
| if clip_norm is not None: | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), clip_norm) | |
| optimizer.step() | |
| global_step += 1 | |
| reduced_loss = _mean_across_ranks(loss.detach(), context.world_size) | |
| if context.is_main and global_step % training_config["log_interval"] == 0: | |
| print( | |
| f"stage={stage} epoch={epoch + 1} step={global_step} " | |
| f"loss={reduced_loss.item():.8f}" | |
| ) | |
| if ( | |
| context.is_main | |
| and global_step % training_config["checkpoint_interval"] == 0 | |
| ): | |
| save_training_checkpoint( | |
| checkpoint_dir / "model_bak.pt", | |
| model, | |
| optimizer, | |
| stage, | |
| epoch, | |
| batch_index + 1, | |
| global_step, | |
| config, | |
| ) | |
| if max_steps is not None and global_step >= max_steps: | |
| stop = True | |
| break | |
| if stop: | |
| break | |
| if context.is_main: | |
| checkpoint = checkpoint_dir / "model_bak.pt" | |
| save_training_checkpoint( | |
| checkpoint, | |
| model, | |
| optimizer, | |
| stage, | |
| epoch if "epoch" in locals() else start_epoch, | |
| batch_index + 1 if "batch_index" in locals() else start_batch, | |
| global_step, | |
| config, | |
| ) | |
| print(f"checkpoint={checkpoint} steps={global_step}") | |
| def _build_stage_models( | |
| config: dict[str, Any], | |
| stage: str, | |
| regression_weights: Path | None, | |
| device: torch.device, | |
| ) -> tuple[nn.Module | None, nn.Module]: | |
| data_config = config["data"] | |
| model_config = config["model"] | |
| common = { | |
| "image_size": model_config["image_size"], | |
| "state_channels": len(data_config["state_variables"]), | |
| "invariant_channels": len(data_config["invariant_variables"]), | |
| "model_channels": model_config["model_channels"], | |
| "channel_mult": model_config["channel_mult"], | |
| "num_blocks": model_config["num_blocks"], | |
| "attn_resolutions": model_config["attention_resolutions"], | |
| } | |
| if stage == "regression": | |
| model = build_regression_model( | |
| **common, | |
| background_channels=len(data_config["era5_variables"]), | |
| ) | |
| return None, model | |
| if regression_weights is None: | |
| raise ValueError("Diffusion training requires --regression-weights") | |
| regression = _load_model_weights( | |
| build_regression_model( | |
| **common, | |
| background_channels=len(data_config["era5_variables"]), | |
| ), | |
| regression_weights, | |
| "regression", | |
| ).to(device) | |
| regression.eval() | |
| regression.requires_grad_(False) | |
| return regression, build_diffusion_model(**common) | |
| def _training_step( | |
| stage: str, | |
| model: nn.Module, | |
| regression: nn.Module | None, | |
| batch: dict[str, Any], | |
| device: torch.device, | |
| training_config: dict[str, Any], | |
| ) -> torch.Tensor: | |
| background = batch["background"].to(device, dtype=torch.float32) | |
| state, target = ( | |
| tensor.to(device, dtype=torch.float32) for tensor in batch["state"] | |
| ) | |
| invariant = batch["invariant"].to(device, dtype=torch.float32) | |
| if invariant.ndim == 3: | |
| invariant = invariant.unsqueeze(0) | |
| if invariant.shape[0] == 1 and state.shape[0] > 1: | |
| invariant = invariant.expand(state.shape[0], -1, -1, -1) | |
| if stage == "regression": | |
| condition = torch.cat((state, background, invariant), dim=1) | |
| return regression_loss(model, condition, target) | |
| if regression is None: | |
| raise RuntimeError("Regression model is required for diffusion training") | |
| with torch.no_grad(): | |
| regression_condition = torch.cat((state, background, invariant), dim=1) | |
| regression_prediction = regression(regression_condition) | |
| residual = target - regression_prediction | |
| condition = torch.cat((state, regression_prediction, invariant), dim=1) | |
| return edm_residual_loss( | |
| model, | |
| residual, | |
| condition, | |
| sigma_data=training_config["sigma_data"], | |
| p_mean=training_config["P_mean"], | |
| p_std=training_config["P_std"], | |
| ) | |
| def save_training_checkpoint( | |
| path: Path, | |
| model: nn.Module, | |
| optimizer: torch.optim.Optimizer, | |
| stage: str, | |
| epoch: int, | |
| batch_in_epoch: int, | |
| global_step: int, | |
| config: dict[str, Any], | |
| ) -> None: | |
| model = model.module if isinstance(model, DistributedDataParallel) else model | |
| temporary = path.with_suffix(path.suffix + ".tmp") | |
| torch.save( | |
| { | |
| "stage": stage, | |
| "epoch": epoch, | |
| "batch_in_epoch": batch_in_epoch, | |
| "global_step": global_step, | |
| "model_state_dict": model.state_dict(), | |
| "optimizer_state_dict": optimizer.state_dict(), | |
| "config": config, | |
| }, | |
| temporary, | |
| ) | |
| temporary.replace(path) | |
| def load_training_checkpoint( | |
| path: Path, | |
| model: nn.Module, | |
| optimizer: torch.optim.Optimizer, | |
| stage: str, | |
| device: torch.device, | |
| ) -> tuple[int, int, int]: | |
| checkpoint = torch.load(path, map_location=device, weights_only=False) | |
| if checkpoint["stage"] != stage: | |
| raise ValueError( | |
| f"Checkpoint stage is {checkpoint['stage']}, requested stage is {stage}" | |
| ) | |
| model.load_state_dict(checkpoint["model_state_dict"], strict=True) | |
| optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) | |
| return ( | |
| int(checkpoint["epoch"]), | |
| int(checkpoint.get("batch_in_epoch", 0)), | |
| int(checkpoint["global_step"]), | |
| ) | |
| def initialize_distributed() -> DistributedContext: | |
| world_size = int(os.environ.get("WORLD_SIZE", "1")) | |
| local_rank = int(os.environ.get("LOCAL_RANK", "0")) | |
| if not torch.cuda.is_available(): | |
| raise RuntimeError("StormCast training requires a CUDA/HIP device") | |
| torch.cuda.set_device(local_rank) | |
| if world_size > 1: | |
| dist.init_process_group(backend="nccl", init_method="env://") | |
| rank = dist.get_rank() | |
| else: | |
| rank = 0 | |
| return DistributedContext( | |
| device=torch.device("cuda", local_rank), | |
| rank=rank, | |
| local_rank=local_rank, | |
| world_size=world_size, | |
| ) | |
| def _load_initial_weights(model: nn.Module, path: Path, stage: str) -> None: | |
| loaded = _load_model_weights(model, path, stage) | |
| model.load_state_dict(loaded.state_dict(), strict=True) | |
| def _load_model_weights(model: nn.Module, path: Path, stage: str) -> nn.Module: | |
| checkpoint = torch.load(path, map_location="cpu", weights_only=False) | |
| state = checkpoint.get("model_state_dict", checkpoint) | |
| model.load_state_dict(state, strict=True) | |
| return model | |
| def _mean_across_ranks(value: torch.Tensor, world_size: int) -> torch.Tensor: | |
| if world_size > 1: | |
| dist.all_reduce(value, op=dist.ReduceOp.SUM) | |
| value /= world_size | |
| return value | |
| def _resolve_config_paths(config: dict[str, Any], project_root: Path) -> None: | |
| for section, key in ( | |
| ("data", "root_dir"), | |
| ("training", "checkpoint_dir"), | |
| ): | |
| path = Path(config[section][key]) | |
| if not path.is_absolute(): | |
| config[section][key] = str((project_root / path).resolve()) | |
| for key in ("regression_weights", "diffusion_weights"): | |
| value = config["model"].get(key) | |
| if value: | |
| path = Path(value) | |
| if not path.is_absolute(): | |
| config["model"][key] = str((project_root / path).resolve()) | |
| for key in ("initial_weights", "resume_checkpoint", "regression_weights"): | |
| value = config["training"].get(key) | |
| if value: | |
| path = Path(value) | |
| if not path.is_absolute(): | |
| config["training"][key] = str((project_root / path).resolve()) | |
| def _optional_path(value: str | Path | None) -> Path | None: | |
| return None if value is None else Path(value) | |
| def _seed_everything(seed: int, rank: int) -> None: | |
| seed += rank | |
| random.seed(seed) | |
| torch.manual_seed(seed) | |
| torch.cuda.manual_seed(seed) | |
| if __name__ == "__main__": | |
| main() | |