| from __future__ import annotations |
|
|
| import json |
| import math |
| import os |
| import random |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| import torch |
| from torch import nn |
| from torch.nn.parallel import DistributedDataParallel |
| from torch.utils.data import DataLoader, TensorDataset |
|
|
|
|
| INPUT_NAMES = ["Q2", "T2", "U10", "V10", "GRDFLX", "SWDOWN", "GLW", "LH", "HFX", "PBLH", "UST", "TSK", "TSLB", "SMOIS", "Ug", "Vg"] |
| OUTPUT_NAMES = ["U", "V", "W", "tk", "QVAPOR"] |
| PARAMETER_COUNTS = {"FFN": 10693, "HPC": 16597, "HAC": 26197} |
| CHECKPOINT_FORMAT_VERSION = "1.0" |
|
|
|
|
| def load_yaml(path: str | Path) -> dict[str, Any]: |
| try: |
| import yaml |
| except ImportError as exc: |
| raise RuntimeError("PyYAML is required to read conf/config.yaml") from exc |
| with open(path, "r", encoding="utf-8") as handle: |
| return yaml.safe_load(handle) |
|
|
|
|
| class FFN(nn.Module): |
| def __init__(self, width: int = 16, levels: int = 17, variables: int = 5): |
| super().__init__() |
| layers: list[nn.Module] = [] |
| in_features = 16 |
| for _ in range(34): |
| layers.extend((nn.Linear(in_features, width), nn.ReLU())) |
| in_features = width |
| self.hidden = nn.Sequential(*layers) |
| self.output = nn.Linear(width, levels * variables) |
| self.levels, self.variables = levels, variables |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| return self.output(self.hidden(x)).reshape(-1, self.levels, self.variables) |
|
|
|
|
| class HierarchicalNetwork(nn.Module): |
| def __init__(self, mode: str, width: int = 16, levels: int = 17, variables: int = 5): |
| super().__init__() |
| self.mode, self.levels, self.variables = mode, levels, variables |
| blocks = [] |
| for level in range(levels): |
| conditioned_outputs = variables * (level if mode == "HAC" else min(level, 1)) |
| blocks.append(nn.Sequential( |
| nn.Linear(16 + conditioned_outputs, width), nn.ReLU(), |
| nn.Linear(width, width), nn.ReLU(), |
| nn.Linear(width, width), nn.ReLU(), |
| nn.Linear(width, variables), |
| )) |
| self.blocks = nn.ModuleList(blocks) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| outputs = [] |
| for block in self.blocks: |
| if not outputs: |
| conditioned = x |
| elif self.mode == "HPC": |
| conditioned = torch.cat((x, outputs[-1]), dim=-1) |
| else: |
| conditioned = torch.cat((x, *outputs), dim=-1) |
| outputs.append(block(conditioned)) |
| return torch.stack(outputs, dim=1) |
|
|
|
|
| class HAC(HierarchicalNetwork): |
| def __init__(self, width: int = 16, levels: int = 17, variables: int = 5): |
| super().__init__("HAC", width, levels, variables) |
|
|
|
|
| class PBLEmulator(nn.Module): |
| def __init__(self, architecture: str = "HAC", width: int = 16, levels: int = 17, variables: int = 5): |
| super().__init__() |
| self.architecture = architecture.upper() |
| if self.architecture == "FFN": |
| self.model = FFN(width, levels, variables) |
| elif self.architecture == "HPC": |
| self.model = HierarchicalNetwork(self.architecture, width, levels, variables) |
| elif self.architecture == "HAC": |
| self.model = HAC(width, levels, variables) |
| else: |
| raise ValueError(f"Unknown architecture: {architecture}") |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| return self.model(x) |
|
|
|
|
| def build_model(architecture: str = "HAC", width: int = 16, levels: int = 17, variables: int = 5) -> PBLEmulator: |
| model = PBLEmulator(architecture, width, levels, variables) |
| count = sum(parameter.numel() for parameter in model.parameters()) |
| if width == 16 and levels == 17 and variables == 5: |
| assert count == PARAMETER_COUNTS[model.architecture], f"{model.architecture}: expected {PARAMETER_COUNTS[model.architecture]}, got {count}" |
| return model |
|
|
|
|
| @dataclass |
| class ColumnScaler: |
| mean: np.ndarray |
| scale: np.ndarray |
| minimum: np.ndarray |
| span: np.ndarray |
|
|
| @classmethod |
| def fit(cls, values: np.ndarray) -> "ColumnScaler": |
| flat = np.asarray(values, dtype=np.float64).reshape(len(values), -1) |
| mean = flat.mean(axis=0) |
| scale = flat.std(axis=0) |
| scale[scale < 1e-12] = 1.0 |
| standardized = (flat - mean) / scale |
| minimum = standardized.min(axis=0) |
| span = standardized.max(axis=0) - minimum |
| span[span < 1e-12] = 1.0 |
| return cls(mean, scale, minimum, span) |
|
|
| def transform(self, values: np.ndarray) -> np.ndarray: |
| shape = values.shape |
| flat = np.asarray(values, dtype=np.float64).reshape(len(values), -1) |
| return (((flat - self.mean) / self.scale - self.minimum) / self.span).reshape(shape).astype(np.float32) |
|
|
| def inverse_transform(self, values: np.ndarray) -> np.ndarray: |
| shape = values.shape |
| flat = np.asarray(values, dtype=np.float64).reshape(len(values), -1) |
| return ((flat * self.span + self.minimum) * self.scale + self.mean).reshape(shape).astype(np.float32) |
|
|
| def state_dict(self) -> dict[str, np.ndarray]: |
| return {"mean": self.mean, "scale": self.scale, "minimum": self.minimum, "span": self.span} |
|
|
| @classmethod |
| def from_state_dict(cls, state: dict[str, Any]) -> "ColumnScaler": |
| return cls(*(np.asarray(state[key]) for key in ("mean", "scale", "minimum", "span"))) |
|
|
|
|
| def _distributed() -> tuple[bool, int, int, int]: |
| world_size = int(os.environ.get("WORLD_SIZE", "1")) |
| rank = int(os.environ.get("RANK", "0")) |
| local_rank = int(os.environ.get("LOCAL_RANK", "0")) |
| if world_size > 1 and not torch.distributed.is_initialized(): |
| torch.distributed.init_process_group("nccl" if torch.cuda.is_available() else "gloo") |
| return world_size > 1, rank, local_rank, world_size |
|
|
|
|
| def _reduce_loss(total: float, count: int, device: torch.device) -> float: |
| pair = torch.tensor([total, count], dtype=torch.float64, device=device) |
| if torch.distributed.is_initialized(): |
| torch.distributed.all_reduce(pair, op=torch.distributed.ReduceOp.SUM) |
| return float(pair[0] / pair[1].clamp_min(1)) |
|
|
|
|
| def _epoch(model: nn.Module, loader: DataLoader, device: torch.device, optimizer: torch.optim.Optimizer | None) -> float: |
| model.train(optimizer is not None) |
| total, count = 0.0, 0 |
| context = torch.enable_grad() if optimizer is not None else torch.no_grad() |
| with context: |
| for x_batch, y_batch in loader: |
| x_batch, y_batch = x_batch.to(device), y_batch.to(device) |
| if optimizer is not None: |
| optimizer.zero_grad(set_to_none=True) |
| loss = torch.mean((model(x_batch) - y_batch) ** 2) |
| if optimizer is not None: |
| loss.backward() |
| optimizer.step() |
| total += float(loss.detach()) * len(x_batch) |
| count += len(x_batch) |
| return _reduce_loss(total, count, device) |
|
|
|
|
| def train_model(data_path: str | Path, checkpoint_path: str | Path, metrics_path: str | Path, settings: dict[str, Any]) -> dict[str, Any]: |
| distributed, rank, local_rank, world_size = _distributed() |
| seed = int(settings.get("seed", 19)) |
| random.seed(seed + rank); np.random.seed(seed + rank); torch.manual_seed(seed + rank) |
| if torch.cuda.is_available(): |
| torch.cuda.set_device(local_rank) |
| device = torch.device("cuda", local_rank) |
| else: |
| device = torch.device("cpu") |
| raw = np.load(data_path) |
| x_train, y_train = raw["x_train"], raw["y_train"] |
| x_val, y_val = raw["x_val"], raw["y_val"] |
| assert x_train.shape[1:] == (16,) and y_train.shape[1:] == (17, 5) |
| model_config = { |
| "architecture": settings.get("architecture", "HAC"), |
| "width": int(settings.get("width", 16)), |
| "levels": int(settings.get("levels", 17)), |
| "output_variables": int(settings.get("output_variables", 5)), |
| } |
| start_epoch, history, best_loss = 0, [], math.inf |
| checkpoint_path = Path(checkpoint_path) |
| saved = None |
| if settings.get("resume") and checkpoint_path.exists(): |
| saved = torch.load(checkpoint_path, map_location=device, weights_only=False) |
| if saved.get("format_version") != CHECKPOINT_FORMAT_VERSION: |
| raise ValueError(f"Unsupported checkpoint format_version: {saved.get('format_version')!r}; expected {CHECKPOINT_FORMAT_VERSION!r}") |
| model_config = saved["model_config"] |
| x_scaler = ColumnScaler.from_state_dict(saved["x_scaler"]) |
| y_scaler = ColumnScaler.from_state_dict(saved["y_scaler"]) |
| else: |
| x_scaler, y_scaler = ColumnScaler.fit(x_train), ColumnScaler.fit(y_train) |
| x_train, y_train = x_scaler.transform(x_train), y_scaler.transform(y_train) |
| x_val, y_val = x_scaler.transform(x_val), y_scaler.transform(y_val) |
| model = build_model(model_config["architecture"], model_config["width"], model_config["levels"], model_config["output_variables"]).to(device) |
| optimizer = torch.optim.Adam(model.parameters(), lr=float(settings.get("learning_rate", 0.001))) |
| best_state = None |
| if saved is not None: |
| model.load_state_dict(saved.get("last_model", saved["model"])) |
| optimizer.load_state_dict(saved["optimizer_state"]) |
| start_epoch, history, best_loss = saved["epoch"] + 1, saved["history"], saved["best_val_loss"] |
| best_state = {key: value.detach().cpu().clone() for key, value in saved["model"].items()} |
| sampler = torch.utils.data.distributed.DistributedSampler(TensorDataset(torch.from_numpy(x_train), torch.from_numpy(y_train)), shuffle=True) if distributed else None |
| train_set = sampler.dataset if sampler else TensorDataset(torch.from_numpy(x_train), torch.from_numpy(y_train)) |
| train_loader = DataLoader(train_set, batch_size=int(settings.get("batch_size", 64)), sampler=sampler, shuffle=sampler is None, num_workers=int(settings.get("num_workers", 0))) |
| val_loader = DataLoader(TensorDataset(torch.from_numpy(x_val), torch.from_numpy(y_val)), batch_size=int(settings.get("batch_size", 64)), shuffle=False) |
| if distributed: |
| model = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None) |
| patience, stale = int(settings.get("early_stopping_patience", 10)), 0 |
| for epoch in range(start_epoch, int(settings.get("epochs", 6))): |
| if sampler is not None: |
| sampler.set_epoch(epoch) |
| train_loss = _epoch(model, train_loader, device, optimizer) |
| val_loss = _epoch(model, val_loader, device, None) |
| history.append({"epoch": epoch, "train_mse": train_loss, "val_mse": val_loss}) |
| if val_loss < best_loss: |
| best_loss, stale = val_loss, 0 |
| best_state = {key: value.detach().cpu().clone() for key, value in (model.module if distributed else model).state_dict().items()} |
| else: |
| stale += 1 |
| if stale >= patience: |
| break |
| if rank == 0: |
| checkpoint_path.parent.mkdir(parents=True, exist_ok=True) |
| final_model = model.module if distributed else model |
| last_state = {key: value.detach().cpu().clone() for key, value in final_model.state_dict().items()} |
| if best_state is not None: |
| final_model.load_state_dict(best_state) |
| checkpoint = { |
| "format_version": CHECKPOINT_FORMAT_VERSION, |
| "model_config": model_config, |
| "model": best_state or last_state, |
| "last_model": last_state, |
| "optimizer_state": optimizer.state_dict(), "epoch": history[-1]["epoch"], |
| "best_val_loss": best_loss, "history": history, "settings": settings, "x_scaler": x_scaler.state_dict(), "y_scaler": y_scaler.state_dict(), |
| "random_state": {"python": random.getstate(), "numpy": np.random.get_state(), "torch": torch.get_rng_state()}, |
| "world_size": world_size, |
| } |
| torch.save(checkpoint, checkpoint_path) |
| metrics_path = Path(metrics_path); metrics_path.parent.mkdir(parents=True, exist_ok=True) |
| metrics_path.write_text(json.dumps({"architecture": model_config["architecture"], "parameters": sum(p.numel() for p in final_model.parameters()), "best_val_mse_normalized": best_loss, "epochs_completed": len(history), "history": history, "world_size": world_size}, indent=2), encoding="utf-8") |
| if distributed: |
| torch.distributed.barrier(); torch.distributed.destroy_process_group() |
| return {"best_val_mse_normalized": best_loss, "epochs_completed": len(history)} |
|
|
|
|
| def run_inference(data_path: str | Path, checkpoint_path: str | Path, output_path: str | Path) -> dict[str, Any]: |
| data = np.load(data_path) |
| checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False) |
| if checkpoint.get("format_version") != CHECKPOINT_FORMAT_VERSION: |
| raise ValueError(f"Unsupported checkpoint format_version: {checkpoint.get('format_version')!r}; expected {CHECKPOINT_FORMAT_VERSION!r}") |
| settings = checkpoint["model_config"] |
| model = build_model(settings["architecture"], int(settings["width"]), int(settings["levels"]), int(settings["output_variables"])) |
| model.load_state_dict(checkpoint["model"]); model.eval() |
| x_scaler, y_scaler = ColumnScaler.from_state_dict(checkpoint["x_scaler"]), ColumnScaler.from_state_dict(checkpoint["y_scaler"]) |
| with torch.no_grad(): |
| prediction_scaled = model(torch.from_numpy(x_scaler.transform(data["x_test"]))).numpy() |
| prediction = y_scaler.inverse_transform(prediction_scaled) |
| output_path = Path(output_path); output_path.parent.mkdir(parents=True, exist_ok=True) |
| np.savez_compressed(output_path, inputs=data["x_test"], targets=data["y_test"], predictions=prediction, |
| timestamps=data["time_test"], heights_m=data["heights_m"], pblh_m=data["pblh_test"], |
| input_names=np.asarray(INPUT_NAMES), output_names=np.asarray(OUTPUT_NAMES), architecture=np.asarray(settings["architecture"])) |
| return {"samples": len(prediction), "shape": list(prediction.shape), "architecture": settings["architecture"]} |
|
|
|
|
| def _pearson(a: np.ndarray, b: np.ndarray) -> float: |
| a, b = a.ravel(), b.ravel() |
| if len(a) < 2 or np.std(a) < 1e-12 or np.std(b) < 1e-12: |
| return 0.0 |
| return float(np.corrcoef(a, b)[0, 1]) |
|
|
|
|
| def _scores(target: np.ndarray, prediction: np.ndarray) -> dict[str, float]: |
| return {"rmse": float(np.sqrt(np.mean((prediction - target) ** 2))), "pearson": _pearson(target, prediction)} |
|
|
|
|
| def evaluate(predictions_path: str | Path, metrics_path: str | Path, figure_path: str | Path) -> dict[str, Any]: |
| data = np.load(predictions_path) |
| target, prediction = data["targets"], data["predictions"] |
| heights, pblh = data["heights_m"], data["pblh_m"] |
| mask = heights[None, :] <= pblh[:, None] |
| metrics: dict[str, Any] = { |
| "physical_scale": True, |
| "primary_protocol": "synthetic virtual-level height <= synthetic PBLH mask", |
| "primary": {"standardized_rmse_by_variable": {}}, |
| "full_17_level_diagnostics": {"by_variable": {}, "by_level_and_variable": {}}, |
| } |
| for index, name in enumerate(OUTPUT_NAMES): |
| masked_target, masked_prediction = target[:, :, index][mask], prediction[:, :, index][mask] |
| scale = max(float(np.std(masked_target)), 1e-12) |
| metrics["primary"]["standardized_rmse_by_variable"][name] = float(np.sqrt(np.mean(((masked_prediction - masked_target) / scale) ** 2))) |
| metrics["full_17_level_diagnostics"]["by_variable"][name] = _scores(target[:, :, index], prediction[:, :, index]) |
| primary_values = metrics["primary"]["standardized_rmse_by_variable"].values() |
| metrics["primary"]["macro_mean_standardized_rmse"] = float(np.mean(list(primary_values))) |
| metrics["primary"]["definition"] = "Unweighted mean of per-variable RMSE divided by that variable's target standard deviation within the synthetic PBLH mask; no physical units are mixed." |
| for level, height in enumerate(heights): |
| metrics["full_17_level_diagnostics"]["by_level_and_variable"][str(level)] = { |
| "height_m": float(height), |
| "by_variable": {name: _scores(target[:, level, index], prediction[:, level, index]) for index, name in enumerate(OUTPUT_NAMES)}, |
| } |
| speed_true = np.hypot(target[:, :, 0], target[:, :, 1]); speed_pred = np.hypot(prediction[:, :, 0], prediction[:, :, 1]) |
| direction_true = np.mod(1.5 * np.pi - np.arctan2(target[:, :, 1], target[:, :, 0]), 2 * np.pi) |
| direction_pred = np.mod(1.5 * np.pi - np.arctan2(prediction[:, :, 1], prediction[:, :, 0]), 2 * np.pi) |
| delta = np.arctan2(np.sin(direction_pred - direction_true), np.cos(direction_pred - direction_true)) |
| metrics["wind_speed"] = _scores(speed_true, speed_pred) |
| metrics["wind_direction"] = {"convention": "meteorological direction from: 0 degrees from north, increasing clockwise", "circular_rmse_degrees": float(np.degrees(np.sqrt(np.mean(delta ** 2)))), "mean_absolute_circular_error_degrees": float(np.degrees(np.mean(np.abs(delta)))), "circular_correlation_cosine": float(np.mean(np.cos(delta)))} |
| metrics["synthetic_pblh_mask"] = {"synthetic": True, "definition": "virtual level height <= synthetic PBLH; not a paper or real-WRF PBL mask", "sample_level_pairs": int(mask.sum())} |
| metrics_path = Path(metrics_path); metrics_path.parent.mkdir(parents=True, exist_ok=True) |
| metrics_path.write_text(json.dumps(metrics, indent=2), encoding="utf-8") |
| try: |
| import matplotlib.pyplot as plt |
| figure_path = Path(figure_path); figure_path.parent.mkdir(parents=True, exist_ok=True) |
| fig, axes = plt.subplots(1, 5, figsize=(15, 4), sharey=True) |
| for index, (axis, name) in enumerate(zip(axes, OUTPUT_NAMES)): |
| axis.plot(target[:, :, index].mean(0), heights, label="target") |
| axis.plot(prediction[:, :, index].mean(0), heights, "--", label="prediction") |
| axis.set_title(name); axis.grid(alpha=0.25) |
| axes[0].set_ylabel("synthetic height (m)"); axes[-1].legend() |
| fig.tight_layout(); fig.savefig(figure_path, dpi=150); plt.close(fig) |
| except ImportError: |
| metrics["figure_note"] = "matplotlib unavailable; numerical evaluation completed" |
| return metrics |
|
|