"""Train independent PPNN replicas with Gaussian CRPS and optional rank allocation.""" import json import os import sys from pathlib import Path import numpy as np import torch import yaml from torch.utils.data import DataLoader, TensorDataset ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from model.ppnn import FORMAT_VERSION, PPNN, ensemble_features, gaussian_crps def load_data(config): data = np.load(ROOT / config["data"]["file"]) expected = (len(data["target"]), 50, 18) if str(data["format_version"]) != FORMAT_VERSION or data["ensemble"].shape != expected: raise ValueError(f"dataset version/shape mismatch: expected {FORMAT_VERSION} and {expected}") if len(data["station_id"]) != 537 or int(data["lead_hours"]) != 48: raise ValueError("dataset must retain 537 stations and 48h lead") arrays = [data["ensemble"], data["auxiliary"], data["target"]] if not all(np.isfinite(x).all() for x in arrays): raise ValueError("dataset contains non-finite values") return data def main(): config = yaml.safe_load((ROOT / "conf/config.yaml").read_text()) data = load_data(config) distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1 if distributed: torch.distributed.init_process_group("gloo") rank = torch.distributed.get_rank() if distributed else 0 world_size = torch.distributed.get_world_size() if distributed else 1 device_name = config["runtime"]["device"] local_rank = int(os.environ.get("LOCAL_RANK", "0")) enough_accelerators = torch.cuda.is_available() and torch.cuda.device_count() >= world_size device = torch.device("cuda", local_rank) if device_name == "auto" and enough_accelerators else torch.device("cpu") ensemble = torch.from_numpy(data["ensemble"]) auxiliary = torch.from_numpy(data["auxiliary"]) station = torch.from_numpy(data["station_index"]) target = torch.from_numpy(data["target"]) continuous_raw = ensemble_features(ensemble, auxiliary) feature_mean = continuous_raw.mean(0) feature_std = continuous_raw.std(0, correction=1).clamp_min(1e-6) target_mean, target_std = target.mean(), target.std(correction=1).clamp_min(1e-6) continuous = (continuous_raw - feature_mean) / feature_std target_scaled = (target - target_mean) / target_std dataset = TensorDataset(continuous, station, target_scaled) local = [] replicas = int(config["train"]["replicas"]) for replica in range(replicas): if replica % world_size != rank: continue seed = int(config["seed"]) + replica torch.manual_seed(seed) generator = torch.Generator().manual_seed(seed) loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]), shuffle=True, generator=generator) model = PPNN(int(config["model"]["hidden_size"]), eps=float(config["model"]["sigma_epsilon"])).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=float(config["train"]["learning_rate"])) history = [] for epoch in range(int(config["train"]["epochs"])): total = 0.0 for x, s, y in loader: mu, sigma = model(x.to(device), s.to(device)) loss = gaussian_crps(mu, sigma, y.to(device)).mean() if not torch.isfinite(loss): raise RuntimeError("non-finite Gaussian CRPS loss") optimizer.zero_grad(set_to_none=True) loss.backward() optimizer.step() total += float(loss.detach()) * len(y) history.append(total / len(dataset)) local.append({"replica": replica, "state_dict": {k: v.cpu() for k, v in model.state_dict().items()}, "loss": history}) print(f"rank={rank} replica={replica} final_scaled_crps={history[-1]:.6f}") if distributed: gathered = [None] * world_size torch.distributed.all_gather_object(gathered, local) trained = [item for rank_items in gathered for item in rank_items] else: trained = local if rank == 0: trained.sort(key=lambda x: x["replica"]) if [x["replica"] for x in trained] != list(range(replicas)): raise RuntimeError("distributed ranks did not return every replica") checkpoint = ROOT / config["paths"]["checkpoint"] metrics = ROOT / config["paths"]["training_metrics"] checkpoint.parent.mkdir(parents=True, exist_ok=True) metrics.parent.mkdir(parents=True, exist_ok=True) scaling = {"feature_mean": feature_mean, "feature_std": feature_std, "target_mean": target_mean, "target_std": target_std} states = [x["state_dict"] for x in trained] torch.save({"format_version": FORMAT_VERSION, "model": states, "model_config": config["model"], "config": config, "scaling": scaling}, checkpoint) metrics.write_text(json.dumps({"replicas": [{"id": x["replica"], "loss": x["loss"]} for x in trained], "world_size": world_size}, indent=2) + "\n") if distributed: torch.distributed.destroy_process_group() if __name__ == "__main__": main()