Download model/pbl_emulator.py from OneScience-Group/PBL-Emulator: direct link, hf CLI and curl.
- Browser
- Download file 18.3 kB
-
https://huggingface.co/OneScience-Group/PBL-Emulator/resolve/main/model/pbl_emulator.py
- Command line
-
hf download hf://OneScience-Group/PBL-Emulator/model/pbl_emulator.py
-
curl -L -o pbl_emulator.py https://huggingface.co/OneScience-Group/PBL-Emulator/resolve/main/model/pbl_emulator.py
18.3 kB
| 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 | |
| class ColumnScaler: | |
| mean: np.ndarray | |
| scale: np.ndarray | |
| minimum: np.ndarray | |
| span: np.ndarray | |
| 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} | |
| 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 | |