| """Train UnetDif with single-process or torchrun DDP execution.""" |
|
|
| import json |
| import os |
| import sys |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| import yaml |
| from torch.nn.parallel import DistributedDataParallel as DDP |
| from torch.utils.data import DataLoader, Dataset, DistributedSampler |
|
|
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| sys.path.insert(0, str(ROOT)) |
| from model.unetdif import UnetDif, loss_components |
|
|
|
|
| class RainfallDataset(Dataset): |
| def __init__(self, path: Path, config): |
| archive = np.load(path, allow_pickle=False) |
| expected = (int(config["time_steps"]), int(config["channels"]), int(config["height"]), int(config["width"])) |
| if archive["inputs"].shape[1:] != expected or archive["targets"].shape[1:] != (expected[0], expected[2], expected[3]): |
| raise ValueError(f"expected day shapes {expected} and {(expected[0], expected[2], expected[3])}") |
| if len(np.unique(archive["group_id"])) != len(archive["group_id"]): |
| raise ValueError("each rain day must belong to one split group") |
| if archive["lead_hours"].tolist() != config["lead_hours"]: |
| raise ValueError("NPZ lead_hours do not match the configured eight 3-hour steps") |
| self.inputs = archive["inputs"].astype(np.float32, copy=False).reshape(-1, *expected[1:]) |
| self.targets = archive["targets"].astype(np.float32, copy=False).reshape(-1, expected[2], expected[3]) |
|
|
| def __len__(self): |
| return len(self.inputs) |
|
|
| def __getitem__(self, index): |
| return torch.from_numpy(self.inputs[index]), torch.from_numpy(self.targets[index]) |
|
|
|
|
| def get_device(local_rank=0, local_world_size=1): |
| use_cuda = torch.cuda.is_available() and torch.cuda.device_count() >= local_world_size |
| return torch.device("cuda", local_rank) if use_cuda else torch.device("cpu") |
|
|
|
|
| def reduce_values(values, device, distributed): |
| tensor = torch.tensor(values, device=device) |
| if distributed: |
| torch.distributed.all_reduce(tensor) |
| return tensor.cpu().tolist() |
|
|
|
|
| def main(): |
| config = yaml.safe_load((ROOT / "conf/config.yaml").read_text()) |
| torch.manual_seed(int(config["seed"])) |
| distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1 |
| local_rank = int(os.environ.get("LOCAL_RANK", "0")) |
| local_world_size = int(os.environ.get("LOCAL_WORLD_SIZE", "1")) |
| device = get_device(local_rank, local_world_size) |
| if distributed: |
| torch.distributed.init_process_group("nccl" if device.type == "cuda" else "gloo") |
| rank = torch.distributed.get_rank() if distributed else 0 |
| dataset = RainfallDataset(ROOT / config["data"]["root"] / "train.npz", config["data"]) |
| if distributed and len(dataset) < torch.distributed.get_world_size(): |
| raise ValueError("DDP requires at least one training item per process") |
| sampler = DistributedSampler(dataset) if distributed else None |
| loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]), shuffle=sampler is None, |
| sampler=sampler, num_workers=int(config["train"]["num_workers"])) |
| model = UnetDif(**config["model"]).to(device) |
| if distributed: |
| model = DDP(model, device_ids=[local_rank] if device.type == "cuda" else None) |
| optimizer = torch.optim.Adam(model.parameters(), lr=float(config["train"]["learning_rate"])) |
| names = ["dry_focal", "false_alarm_focal", "positive_mse", "negative_mse", "dry_mae", "all_mae"] |
| normalizers = {name: 1.0 for name in names} |
| history = [] |
| for epoch in range(int(config["train"]["epochs"])): |
| if sampler is not None: |
| sampler.set_epoch(epoch) |
| model.train(); sums = {name: 0.0 for name in names}; batches = 0 |
| for inputs, target in loader: |
| inputs, target = inputs.to(device), target.to(device) |
| heads = model(inputs) |
| raw = loss_components(heads, inputs[:, int(config["data"]["precipitation_channel"])], target, |
| float(config["data"]["rain_threshold_mm_3h"]), |
| float(config["loss"]["focal_alpha"]), float(config["loss"]["focal_gamma"])) |
| loss = sum(raw[name] * normalizers[name] for name in names) |
| optimizer.zero_grad(set_to_none=True); loss.backward(); optimizer.step() |
| for name in names: |
| sums[name] += float(raw[name].detach()) |
| batches += 1 |
| reduced = reduce_values([sums[name] for name in names] + [batches], device, distributed) |
| total_batches = max(reduced[-1], 1) |
| averages = {name: reduced[index] / total_batches for index, name in enumerate(names)} |
| if epoch == 0: |
| normalizers = {name: 1.0 / max(value, 1e-6) for name, value in averages.items()} |
| record = {"epoch": epoch + 1, "components": averages, "normalizers": normalizers} |
| history.append(record) |
| if rank == 0: |
| print(json.dumps(record)) |
| checkpoint = ROOT / config["paths"]["checkpoint"] |
| checkpoint.parent.mkdir(parents=True, exist_ok=True) |
| state = model.module.state_dict() if distributed else model.state_dict() |
| torch.save({"epoch": epoch + 1, "model": state, "optimizer": optimizer.state_dict(), |
| "model_config": config["model"], "loss_normalizers": normalizers, |
| "loss_components": names, |
| "format_version": config["data"]["format_version"]}, checkpoint) |
| if rank == 0: |
| metrics = ROOT / config["paths"]["training_metrics"] |
| metrics.parent.mkdir(parents=True, exist_ok=True) |
| metrics.write_text(json.dumps({"history": history}, indent=2) + "\n") |
| if distributed: |
| torch.distributed.destroy_process_group() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|