"""Train all 21 LOSO folds; DDP ranks independently shard outer folds.""" import json import argparse import os import random import sys from pathlib import Path import numpy as np import torch import yaml ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from model.climemu_s2l import (FEATURE_COUNT, FORMAT_VERSION, GRID_SHAPE, MODEL_NAME, DualRidge, SharedKernelGPR, area_weights, select_ridge_alpha, weighted_rmse) def load_data(path): data = np.load(path) short, long = data["short_response"], data["long_response"] if str(data["format_version"]) != FORMAT_VERSION: raise ValueError("data format version mismatch") if short.shape != (21, *GRID_SHAPE) or long.shape != short.shape: raise ValueError("responses must both have shape [21,145,192]") ids = [str(value) for value in data["scenario_ids"]] if len(set(ids)) != 21 or not np.isfinite(short).all() or not np.isfinite(long).all(): raise ValueError("scenario IDs must be unique and fields finite") return data, short.reshape(21, FEATURE_COUNT), long.reshape(21, FEATURE_COUNT), ids def train_fold(fold, x, y, scenario_ids, config, weights): training = np.asarray([index for index in range(21) if index != fold]) alpha, cv_scores = select_ridge_alpha( x[training], y[training], config["model"]["ridge"]["alphas"], int(config["model"]["ridge"]["inner_folds"]), weights) ridge = DualRidge(alpha).fit(x[training], y[training]) ridge_prediction = ridge.predict(x[fold:fold + 1]).detach().cpu().numpy()[0] gpr_config = config["model"]["gpr"] gpr = SharedKernelGPR(gpr_config["kernel_mode"], gpr_config["jitter"]) optimizer_trace = gpr.fit(x[training], y[training], gpr_config["optimizer_steps"], gpr_config["learning_rate"]) gpr_prediction = gpr.predict(x[fold:fold + 1]).detach().cpu().numpy()[0] state = {"fold": fold, "held_out_scenario_id": scenario_ids[fold], "train_indices": training.tolist(), "ridge_alpha": alpha, "ridge_cv_scores": cv_scores, "gpr_kernel_mode": gpr.kernel_mode, "gpr_hyperparameters": gpr.hyperparameters(), "gpr_optimizer": optimizer_trace} metrics = {"scenario_id": scenario_ids[fold], "ridge_weighted_rmse": weighted_rmse(y[fold], ridge_prediction, weights), "gpr_weighted_rmse": weighted_rmse(y[fold], gpr_prediction, weights), "selected_alpha": alpha, "gpr_final_loss": optimizer_trace["loss"][-1], "gpr_backward": optimizer_trace["gradient_seen"], "gpr_parameter_update": optimizer_trace["parameter_updated"]} return state, metrics def main(): parser = argparse.ArgumentParser() parser.add_argument("--resume", action="store_true", help="resume completed LOSO folds from the checkpoint") args = parser.parse_args() config = yaml.safe_load((ROOT / "conf/config.yaml").read_text()) seed = int(config["seed"]) random.seed(seed); np.random.seed(seed); torch.manual_seed(seed) torch.set_default_dtype(torch.float64) distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1 if distributed: torch.distributed.init_process_group(config["runtime"]["ddp_backend"]) rank = torch.distributed.get_rank() if distributed else 0 world = torch.distributed.get_world_size() if distributed else 1 data, x_numpy, y_numpy, scenario_ids = load_data(ROOT / config["data"]["path"]) x, y = torch.from_numpy(x_numpy).double(), torch.from_numpy(y_numpy).double() weights = area_weights(data["latitude_deg"], GRID_SHAPE[1]) checkpoint_path = ROOT / config["paths"]["checkpoint"] resumed_states, resumed_metrics = {}, {} if args.resume and checkpoint_path.exists(): try: previous = torch.load(checkpoint_path, map_location="cpu", weights_only=False) except TypeError: previous = torch.load(checkpoint_path, map_location="cpu") if previous.get("format_version") != FORMAT_VERSION or tuple(previous.get("grid_shape", ())) != GRID_SHAPE: raise ValueError("checkpoint protocol mismatch") restored_folds = previous.get("model", {}).get("folds", previous.get("folds", [])) resumed_states = {state["fold"]: state for state in restored_folds} resumed_metrics = {index: value for index, value in enumerate(previous.get("training_metrics", []))} local_states, local_metrics = {}, {} for fold in range(rank, 21, world): if fold in resumed_states and fold in resumed_metrics: continue state, metrics = train_fold(fold, x, y, scenario_ids, config, weights) local_states[fold], local_metrics[fold] = state, metrics print(f"rank={rank} fold={fold:02d} scenario={scenario_ids[fold]} alpha={state['ridge_alpha']}") if distributed: gathered_states, gathered_metrics = [None] * world, [None] * world torch.distributed.all_gather_object(gathered_states, local_states) torch.distributed.all_gather_object(gathered_metrics, local_metrics) states = {**resumed_states, **{key: value for item in gathered_states for key, value in item.items()}} metrics = {**resumed_metrics, **{key: value for item in gathered_metrics for key, value in item.items()}} else: states, metrics = {**resumed_states, **local_states}, {**resumed_metrics, **local_metrics} if rank == 0: if sorted(states) != list(range(21)): raise RuntimeError("all 21 LOSO folds must be trained") fold_states = [states[index] for index in range(21)] fold_metrics = [metrics[index] for index in range(21)] checkpoint = {"epoch": 1, "model": {"folds": fold_states}, "model_config": config["model"], "optimizer_state_dict": {"folds": [state["gpr_optimizer"] for state in fold_states]}, "loss": float(np.mean([item["gpr_final_loss"] for item in fold_metrics])), "config": config, "model_name": MODEL_NAME, "format_version": FORMAT_VERSION, "grid_shape": GRID_SHAPE, "scenario_ids": scenario_ids, "short_response": torch.from_numpy(x_numpy), "long_response": torch.from_numpy(y_numpy), "training_metrics": fold_metrics, "gpr_ard": False, "gpr_ard_gap": config["model"]["gpr"]["ard_gap"]} checkpoint_path.parent.mkdir(parents=True, exist_ok=True) torch.save(checkpoint, checkpoint_path) metrics_path = ROOT / config["paths"]["training_metrics"] metrics_path.parent.mkdir(parents=True, exist_ok=True) metrics_path.write_text(json.dumps({"fold_count": 21, "world_size": world, "folds": fold_metrics}, indent=2) + "\n") print(f"checkpoint={checkpoint_path.relative_to(ROOT)} folds=21 world_size={world} resumed={len(resumed_states)}") if distributed: torch.distributed.barrier(); torch.distributed.destroy_process_group() if __name__ == "__main__": main()