Publish PrecipDD reproduction
Browse files- .gitattributes +7 -35
- conf/config.yaml +64 -0
- config.json +12 -0
- model/precipdd.py +134 -0
- scripts/fake_data.py +72 -0
- scripts/inference.py +34 -0
- scripts/result.py +83 -0
- scripts/train.py +93 -0
- weight/.gitkeep +0 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,7 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.
|
| 3 |
-
*.
|
| 4 |
-
*.
|
| 5 |
-
*.
|
| 6 |
-
*.
|
| 7 |
-
*.
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.py text eol=lf
|
| 2 |
+
*.md text eol=lf
|
| 3 |
+
*.json text eol=lf
|
| 4 |
+
*.yaml text eol=lf
|
| 5 |
+
*.npz binary
|
| 6 |
+
*.pt binary
|
| 7 |
+
*.png binary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
project:
|
| 2 |
+
name: PrecipDD
|
| 3 |
+
format_version: precipdd_v1
|
| 4 |
+
seed: 23
|
| 5 |
+
license: Apache-2.0
|
| 6 |
+
|
| 7 |
+
paths:
|
| 8 |
+
data: data/daily_precip_anomaly.npz
|
| 9 |
+
checkpoint: result/checkpoints/precipdd.pt
|
| 10 |
+
training_metrics: result/training/metrics.json
|
| 11 |
+
predictions: result/output/predictions.npz
|
| 12 |
+
evaluation_metrics: result/evaluation/metrics.json
|
| 13 |
+
comparison_figure: result/evaluation/comparison.png
|
| 14 |
+
|
| 15 |
+
data:
|
| 16 |
+
layout: NCHW
|
| 17 |
+
input_shape: [1, 55, 160]
|
| 18 |
+
latitude_range: [-60.0, 75.0]
|
| 19 |
+
latitude_note: 55 engineering grid centers at 2.5 degree spacing; the paper text reports inconsistent endpoints alongside 55 points
|
| 20 |
+
longitude_range: [0.0, 397.5]
|
| 21 |
+
longitude_note: 0-360 E plus repeated 0-40 E extension
|
| 22 |
+
normalization: daily climatology anomaly divided by zonal-mean anomaly standard deviation at each latitude
|
| 23 |
+
train_samples: 96
|
| 24 |
+
validation_samples: 32
|
| 25 |
+
test_days_per_year: 6
|
| 26 |
+
test_years: [1980, 2020]
|
| 27 |
+
synthetic_members: 8
|
| 28 |
+
paper_members: 80
|
| 29 |
+
paper_period: [1850, 2100]
|
| 30 |
+
paper_train_days: 730000
|
| 31 |
+
paper_validation_days: 146000
|
| 32 |
+
|
| 33 |
+
model:
|
| 34 |
+
filters: [8, 8, 16, 16, 16]
|
| 35 |
+
kernel_size: 3
|
| 36 |
+
dense_units: 32
|
| 37 |
+
conv_activation: tanh
|
| 38 |
+
dense_activation: sigmoid
|
| 39 |
+
output_activation: linear
|
| 40 |
+
expected_feature_shape: [16, 14, 40]
|
| 41 |
+
flattened_features: 8960
|
| 42 |
+
|
| 43 |
+
training:
|
| 44 |
+
ensemble_members: 2
|
| 45 |
+
paper_ensemble_members: 5
|
| 46 |
+
epochs: 3
|
| 47 |
+
batch_size: 8
|
| 48 |
+
learning_rate: 0.001
|
| 49 |
+
l2_weight_decay: 0.0001
|
| 50 |
+
loss: MAE
|
| 51 |
+
optimizer: Adam
|
| 52 |
+
initialization: Xavier-uniform
|
| 53 |
+
num_workers: 0
|
| 54 |
+
|
| 55 |
+
runtime:
|
| 56 |
+
device: auto
|
| 57 |
+
ddp_backend_gpu: nccl
|
| 58 |
+
ddp_backend_cpu: gloo
|
| 59 |
+
|
| 60 |
+
evaluation:
|
| 61 |
+
emergence_threshold_c: 0.42
|
| 62 |
+
occlusion_patch: 7
|
| 63 |
+
occlusion_stride: 8
|
| 64 |
+
occlusion_max_days: 12
|
config.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "PrecipDD",
|
| 3 |
+
"model_type": "precipdd",
|
| 4 |
+
"architectures": ["PrecipDD"],
|
| 5 |
+
"framework": "PyTorch",
|
| 6 |
+
"domain": "climate",
|
| 7 |
+
"task": "daily-precipitation-anomaly-to-agmt-regression",
|
| 8 |
+
"implementation": {"entry_point": "model/precipdd.py", "scope": "dimension-faithful reduced-sample engineering reproduction", "train_script": "scripts/train.py", "inference_script": "scripts/inference.py", "evaluation_script": "scripts/result.py", "synthetic_data_script": "scripts/fake_data.py"},
|
| 9 |
+
"architecture": {"input_shape": [1,55,160], "conv_layers": 5, "filters": [8,8,16,16,16], "pooled_feature_shape": [16,14,40], "flattened_features": 8960, "dense": [32,1], "output_activation": "linear"},
|
| 10 |
+
"data": {"datasets": ["CESM2 Large Ensemble"], "format_version": "precipdd_v1", "layout": "NCHW", "paper_members": 80, "paper_period": [1850,2100], "synthetic": true},
|
| 11 |
+
"configuration_sources": ["conf/config.yaml","model/precipdd.py","scripts/fake_data.py","scripts/train.py","scripts/inference.py","scripts/result.py"]
|
| 12 |
+
}
|
model/precipdd.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dimension-faithful PyTorch implementation of the precipitation DD CNN."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import math
|
| 7 |
+
import random
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Iterable
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
import yaml
|
| 14 |
+
from torch import Tensor, nn
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
DATA_FORMAT_VERSION = "precipdd_v1"
|
| 18 |
+
INPUT_SHAPE = (1, 55, 160)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_config(path: str | Path) -> dict:
|
| 22 |
+
with open(path, "r", encoding="utf-8") as handle:
|
| 23 |
+
return yaml.safe_load(handle)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def seed_all(seed: int) -> None:
|
| 27 |
+
random.seed(seed)
|
| 28 |
+
np.random.seed(seed)
|
| 29 |
+
torch.manual_seed(seed)
|
| 30 |
+
if torch.cuda.is_available():
|
| 31 |
+
torch.cuda.manual_seed_all(seed)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class PrecipDD(nn.Module):
|
| 35 |
+
"""Five-convolution scalar regressor with the paper's 8,960 features."""
|
| 36 |
+
|
| 37 |
+
def __init__(self, filters: Iterable[int] = (8, 8, 16, 16, 16), dense_units: int = 32):
|
| 38 |
+
super().__init__()
|
| 39 |
+
filters = tuple(int(value) for value in filters)
|
| 40 |
+
if len(filters) != 5 or filters[-1] != 16:
|
| 41 |
+
raise ValueError("DD requires five convolution layers and 16 final filters")
|
| 42 |
+
layers: list[nn.Module] = []
|
| 43 |
+
channels = 1
|
| 44 |
+
for index, width in enumerate(filters):
|
| 45 |
+
layers.extend((nn.Conv2d(channels, width, 3, padding=1), nn.Tanh()))
|
| 46 |
+
if index < 2:
|
| 47 |
+
# TensorFlow SAME pooling is required for 55 -> 28 -> 14 latitude points.
|
| 48 |
+
layers.append(nn.MaxPool2d(2, stride=2, ceil_mode=True))
|
| 49 |
+
channels = width
|
| 50 |
+
self.features = nn.Sequential(*layers)
|
| 51 |
+
self.hidden = nn.Linear(16 * 14 * 40, dense_units)
|
| 52 |
+
self.output = nn.Linear(dense_units, 1)
|
| 53 |
+
self.reset_parameters()
|
| 54 |
+
|
| 55 |
+
def reset_parameters(self) -> None:
|
| 56 |
+
for module in self.modules():
|
| 57 |
+
if isinstance(module, (nn.Conv2d, nn.Linear)):
|
| 58 |
+
nn.init.xavier_uniform_(module.weight)
|
| 59 |
+
fan_in, fan_out = nn.init._calculate_fan_in_and_fan_out(module.weight)
|
| 60 |
+
bound = math.sqrt(6.0 / (fan_in + fan_out))
|
| 61 |
+
nn.init.uniform_(module.bias, -bound, bound)
|
| 62 |
+
|
| 63 |
+
def forward_features(self, precipitation: Tensor) -> Tensor:
|
| 64 |
+
if precipitation.ndim != 4 or tuple(precipitation.shape[1:]) != INPUT_SHAPE:
|
| 65 |
+
raise ValueError(f"input must have shape [B,1,55,160], got {tuple(precipitation.shape)}")
|
| 66 |
+
features = self.features(precipitation)
|
| 67 |
+
if tuple(features.shape[1:]) != (16, 14, 40):
|
| 68 |
+
raise RuntimeError(f"feature shape must be [B,16,14,40], got {tuple(features.shape)}")
|
| 69 |
+
return features
|
| 70 |
+
|
| 71 |
+
def forward(self, precipitation: Tensor) -> Tensor:
|
| 72 |
+
features = self.forward_features(precipitation).flatten(1)
|
| 73 |
+
return self.output(torch.sigmoid(self.hidden(features))).squeeze(-1)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def validate_archive(archive: np.lib.npyio.NpzFile) -> None:
|
| 77 |
+
required = {"precipitation", "agmt", "split", "year", "day_of_year", "latitude", "longitude", "format_version"}
|
| 78 |
+
if missing := required.difference(archive.files):
|
| 79 |
+
raise ValueError(f"dataset missing fields: {sorted(missing)}")
|
| 80 |
+
if str(archive["format_version"]) != DATA_FORMAT_VERSION:
|
| 81 |
+
raise ValueError("dataset format_version mismatch")
|
| 82 |
+
if archive["precipitation"].ndim != 4 or tuple(archive["precipitation"].shape[1:]) != INPUT_SHAPE:
|
| 83 |
+
raise ValueError("precipitation must be float data with shape [N,1,55,160]")
|
| 84 |
+
if archive["agmt"].shape != (len(archive["precipitation"]),):
|
| 85 |
+
raise ValueError("AGMT must contain one scalar for every daily map")
|
| 86 |
+
if archive["latitude"].shape != (55,) or archive["longitude"].shape != (160,):
|
| 87 |
+
raise ValueError("coordinates must contain 55 latitudes and 160 extended longitudes")
|
| 88 |
+
if not np.all(np.isfinite(archive["precipitation"])) or not np.all(np.isfinite(archive["agmt"])):
|
| 89 |
+
raise ValueError("dataset contains non-finite values")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def load_ensemble(checkpoint_path: str | Path, device: torch.device) -> tuple[list[PrecipDD], dict]:
|
| 93 |
+
try:
|
| 94 |
+
checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 95 |
+
except TypeError:
|
| 96 |
+
checkpoint = torch.load(checkpoint_path, map_location=device)
|
| 97 |
+
if checkpoint.get("format_version") != DATA_FORMAT_VERSION:
|
| 98 |
+
raise ValueError("checkpoint format_version mismatch")
|
| 99 |
+
settings = checkpoint["model_config"]
|
| 100 |
+
models = []
|
| 101 |
+
for state in checkpoint["ensemble_states"]:
|
| 102 |
+
model = PrecipDD(settings["filters"], settings["dense_units"]).to(device)
|
| 103 |
+
model.load_state_dict(state)
|
| 104 |
+
model.eval()
|
| 105 |
+
models.append(model)
|
| 106 |
+
return models, checkpoint
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@torch.no_grad()
|
| 110 |
+
def ensemble_predict(models: list[PrecipDD], values: Tensor, batch_size: int = 32) -> Tensor:
|
| 111 |
+
predictions = []
|
| 112 |
+
for start in range(0, len(values), batch_size):
|
| 113 |
+
batch = values[start:start + batch_size]
|
| 114 |
+
predictions.append(torch.stack([model(batch) for model in models]).mean(0))
|
| 115 |
+
return torch.cat(predictions) if predictions else torch.empty(0, device=values.device)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def linear_trend(values: np.ndarray, years: np.ndarray) -> float:
|
| 119 |
+
valid = np.isfinite(values) & np.isfinite(years)
|
| 120 |
+
if valid.sum() < 2 or np.ptp(years[valid]) == 0:
|
| 121 |
+
return float("nan")
|
| 122 |
+
return float(np.polyfit(years[valid], values[valid], 1)[0] * 10.0)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def correlation(target: np.ndarray, prediction: np.ndarray) -> float:
|
| 126 |
+
if len(target) < 2 or np.std(target) == 0 or np.std(prediction) == 0:
|
| 127 |
+
return float("nan")
|
| 128 |
+
return float(np.corrcoef(target, prediction)[0, 1])
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def write_json(path: str | Path, payload: dict) -> None:
|
| 132 |
+
path = Path(path)
|
| 133 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 134 |
+
path.write_text(json.dumps(payload, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create spatially coherent daily anomalies with weather and warming signals."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
sys.path.insert(0, str(ROOT))
|
| 14 |
+
from model.precipdd import DATA_FORMAT_VERSION, load_config
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def agmt_for_year(year, member, rng):
|
| 18 |
+
forced = -0.45 + 0.0042 * (year - 1850) + 0.000030 * max(year - 1970, 0) ** 2
|
| 19 |
+
return forced + 0.08 * np.sin((year - 1850) / 8.0 + member) + rng.normal(0, 0.045)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def main():
|
| 23 |
+
config = load_config(ROOT / "conf/config.yaml")
|
| 24 |
+
settings = config["data"]
|
| 25 |
+
rng = np.random.default_rng(config["project"]["seed"])
|
| 26 |
+
train_n, val_n = settings["train_samples"], settings["validation_samples"]
|
| 27 |
+
first, last = settings["test_years"]
|
| 28 |
+
test_year = np.repeat(np.arange(first, last + 1), settings["test_days_per_year"])
|
| 29 |
+
train_year = rng.integers(1850, 2101, train_n)
|
| 30 |
+
val_year = rng.integers(1850, 2101, val_n)
|
| 31 |
+
year = np.concatenate((train_year, val_year, test_year)).astype(np.int16)
|
| 32 |
+
split = np.concatenate((np.zeros(train_n, np.int8), np.ones(val_n, np.int8), np.full(len(test_year), 2, np.int8)))
|
| 33 |
+
day = np.concatenate((rng.integers(1, 366, train_n + val_n), np.tile(np.linspace(15, 350, settings["test_days_per_year"], dtype=int), last - first + 1))).astype(np.int16)
|
| 34 |
+
member = rng.integers(0, settings["synthetic_members"], len(year), dtype=np.int16)
|
| 35 |
+
agmt = np.array([agmt_for_year(int(y), int(m), rng) for y, m in zip(year, member)], dtype=np.float32)
|
| 36 |
+
|
| 37 |
+
latitude = np.arange(-60.0, 77.5, 2.5, dtype=np.float32)
|
| 38 |
+
base_longitude = np.arange(0.0, 360.0, 2.5, dtype=np.float32)
|
| 39 |
+
longitude = np.arange(0.0, 400.0, 2.5, dtype=np.float32)
|
| 40 |
+
lat2d, lon2d = np.meshgrid(latitude, base_longitude, indexing="ij")
|
| 41 |
+
east_pacific = np.exp(-((lat2d / 16) ** 2 + ((lon2d - 245) / 35) ** 2))
|
| 42 |
+
storm_tracks = np.exp(-((np.abs(lat2d) - 45) / 12) ** 2) * (0.65 + 0.35 * np.cos(np.deg2rad(lon2d * 2)))
|
| 43 |
+
fingerprint = (east_pacific + storm_tracks).astype(np.float32)
|
| 44 |
+
fingerprint /= fingerprint.max()
|
| 45 |
+
coarse = torch.from_numpy(rng.normal(size=(len(year), 1, 14, 36)).astype(np.float32))
|
| 46 |
+
weather = F.interpolate(coarse, size=(55, 144), mode="bilinear", align_corners=False).numpy()
|
| 47 |
+
synoptic = np.sin(2 * np.pi * day[:, None, None] / 7.0 + np.deg2rad(lon2d)[None])
|
| 48 |
+
noise = rng.normal(0, 0.28, size=weather.shape).astype(np.float32)
|
| 49 |
+
amplitude = 1.0 + 0.34 * np.maximum(agmt, -0.5)[:, None, None] * fingerprint[None]
|
| 50 |
+
fields = weather[:, 0] * amplitude + 0.32 * synoptic * (0.3 + fingerprint[None]) + noise[:, 0]
|
| 51 |
+
fields += 0.22 * agmt[:, None, None] * (fingerprint[None] - 0.35)
|
| 52 |
+
fields = fields.astype(np.float32)
|
| 53 |
+
fields = np.concatenate((fields, fields[:, :, :16]), axis=2)
|
| 54 |
+
fingerprint = np.concatenate((fingerprint, fingerprint[:, :16]), axis=1)
|
| 55 |
+
fields -= fields.mean(axis=2, keepdims=True)
|
| 56 |
+
zonal_std = fields.std(axis=2, keepdims=True).mean(axis=0, keepdims=True)
|
| 57 |
+
fields /= np.maximum(zonal_std, 1e-5)
|
| 58 |
+
|
| 59 |
+
output = ROOT / config["paths"]["data"]
|
| 60 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
np.savez_compressed(output, format_version=np.array(DATA_FORMAT_VERSION), precipitation=fields[:, None], agmt=agmt,
|
| 62 |
+
split=split, year=year, day_of_year=day, member=member, latitude=latitude,
|
| 63 |
+
longitude=longitude, synthetic=np.array(True), fingerprint=fingerprint)
|
| 64 |
+
metadata = {"synthetic": True, "samples": len(year), "splits": {"train": train_n, "validation": val_n, "test": len(test_year)},
|
| 65 |
+
"precipitation_shape": [len(year), 1, 55, 160], "target_shape": [len(year)],
|
| 66 |
+
"science_note": "Structured engineering data with daily weather variability and an AGMT-dependent spatial variance signal; not CESM2 LE."}
|
| 67 |
+
(output.parent / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
|
| 68 |
+
print(f"data={output.relative_to(ROOT)} shape={fields[:, None].shape} target={agmt.shape}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run ensemble inference for all held-out synthetic daily maps."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
sys.path.insert(0, str(ROOT))
|
| 12 |
+
from model.precipdd import ensemble_predict, load_config, load_ensemble, validate_archive
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
config = load_config(ROOT / "conf/config.yaml")
|
| 17 |
+
device = torch.device("cuda" if torch.cuda.is_available() and config["runtime"]["device"] != "cpu" else "cpu")
|
| 18 |
+
data = np.load(ROOT / config["paths"]["data"])
|
| 19 |
+
validate_archive(data)
|
| 20 |
+
models, checkpoint = load_ensemble(ROOT / config["paths"]["checkpoint"], device)
|
| 21 |
+
mask = data["split"] == 2
|
| 22 |
+
fields = torch.from_numpy(data["precipitation"][mask]).float().to(device)
|
| 23 |
+
prediction = ensemble_predict(models, fields, config["training"]["batch_size"]).cpu().numpy()
|
| 24 |
+
output = ROOT / config["paths"]["predictions"]
|
| 25 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 26 |
+
np.savez_compressed(output, format_version=data["format_version"], prediction=prediction, target=data["agmt"][mask],
|
| 27 |
+
precipitation=data["precipitation"][mask], year=data["year"][mask], day_of_year=data["day_of_year"][mask],
|
| 28 |
+
latitude=data["latitude"], longitude=data["longitude"], ensemble_members=np.array(len(models)),
|
| 29 |
+
checkpoint_world_size=np.array(checkpoint["world_size"]))
|
| 30 |
+
print(f"predictions={output.relative_to(ROOT)} days={len(prediction)} members={len(models)}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
if __name__ == "__main__":
|
| 34 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compute daily/annual detection metrics and a 7x7 occlusion trend map."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
sys.path.insert(0, str(ROOT))
|
| 12 |
+
from model.precipdd import correlation, ensemble_predict, linear_trend, load_config, load_ensemble, write_json
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
import matplotlib
|
| 17 |
+
matplotlib.use("Agg")
|
| 18 |
+
import matplotlib.pyplot as plt
|
| 19 |
+
|
| 20 |
+
config = load_config(ROOT / "conf/config.yaml")
|
| 21 |
+
values = np.load(ROOT / config["paths"]["predictions"])
|
| 22 |
+
prediction, target, year = values["prediction"], values["target"], values["year"].astype(float)
|
| 23 |
+
if prediction.shape != target.shape or values["precipitation"].shape[1:] != (1, 55, 160):
|
| 24 |
+
raise ValueError("inference output violates scalar target or [N,1,55,160] input contract")
|
| 25 |
+
years = np.unique(year).astype(int)
|
| 26 |
+
annual_prediction = np.array([prediction[year == current].mean() for current in years])
|
| 27 |
+
annual_target = np.array([target[year == current].mean() for current in years])
|
| 28 |
+
threshold = config["evaluation"]["emergence_threshold_c"]
|
| 29 |
+
em_fraction = np.array([(prediction[year == current] > threshold).mean() for current in years])
|
| 30 |
+
metrics = {
|
| 31 |
+
"daily": {"correlation": correlation(target, prediction), "rmse_c": float(np.sqrt(np.mean((prediction - target) ** 2)))},
|
| 32 |
+
"annual": {"correlation": correlation(annual_target, annual_prediction), "rmse_c": float(np.sqrt(np.mean((annual_prediction - annual_target) ** 2)))},
|
| 33 |
+
"emergence": {"threshold_c": threshold, "fraction_all_days": float((prediction > threshold).mean()),
|
| 34 |
+
"fraction_trend_per_decade": linear_trend(em_fraction, years.astype(float))},
|
| 35 |
+
"trend_c_per_decade": {"daily_prediction": linear_trend(prediction, year), "annual_prediction": linear_trend(annual_prediction, years.astype(float)),
|
| 36 |
+
"annual_target": linear_trend(annual_target, years.astype(float))},
|
| 37 |
+
"samples": {"daily": len(prediction), "years": len(years)}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
device = torch.device("cuda" if torch.cuda.is_available() and config["runtime"]["device"] != "cpu" else "cpu")
|
| 41 |
+
models, _ = load_ensemble(ROOT / config["paths"]["checkpoint"], device)
|
| 42 |
+
count = min(config["evaluation"]["occlusion_max_days"], len(prediction))
|
| 43 |
+
indices = np.linspace(0, len(prediction) - 1, count, dtype=int)
|
| 44 |
+
selected = torch.from_numpy(values["precipitation"][indices]).float().to(device)
|
| 45 |
+
baseline = ensemble_predict(models, selected).cpu().numpy()
|
| 46 |
+
selected_year = year[indices]
|
| 47 |
+
patch, stride = config["evaluation"]["occlusion_patch"], config["evaluation"]["occlusion_stride"]
|
| 48 |
+
half = patch // 2
|
| 49 |
+
sensitivity = np.zeros((55, 160), dtype=np.float32)
|
| 50 |
+
for lat_start in range(0, 55, stride):
|
| 51 |
+
for lon_start in range(0, 160, stride):
|
| 52 |
+
masked = selected.clone()
|
| 53 |
+
lat_stop, lon_stop = min(lat_start + patch, 55), min(lon_start + patch, 160)
|
| 54 |
+
masked[:, :, lat_start:lat_stop, lon_start:lon_stop] = 0.0
|
| 55 |
+
delta = baseline - ensemble_predict(models, masked).cpu().numpy()
|
| 56 |
+
score = linear_trend(delta, selected_year) if np.ptp(selected_year) else float(delta.mean())
|
| 57 |
+
sensitivity[lat_start:min(lat_start + stride, 55), lon_start:min(lon_start + stride, 160)] = score
|
| 58 |
+
metrics["occlusion"] = {"patch": [patch, patch], "map_shape": [55, 160], "stride": stride, "sampled_days": count,
|
| 59 |
+
"quantity": "AGMT occlusion-sensitivity trend in degC per decade"}
|
| 60 |
+
write_json(ROOT / config["paths"]["evaluation_metrics"], metrics)
|
| 61 |
+
|
| 62 |
+
fig, axes = plt.subplots(3, 1, figsize=(11, 11), constrained_layout=True)
|
| 63 |
+
axes[0].plot(years, annual_target, color="#202020", label="target AGMT")
|
| 64 |
+
axes[0].plot(years, annual_prediction, color="#c84c32", label="DD estimate")
|
| 65 |
+
axes[0].axhline(threshold, color="#777777", linestyle="--", label="0.42 C EM threshold")
|
| 66 |
+
axes[0].set(ylabel="AGMT anomaly (C)", title="Annual mean of daily estimates")
|
| 67 |
+
axes[0].legend(ncol=3)
|
| 68 |
+
axes[1].plot(years, em_fraction, color="#196f82")
|
| 69 |
+
axes[1].set(xlabel="Year", ylabel="Fraction", ylim=(-0.03, 1.03), title="Emergence days (estimated AGMT > 0.42 C)")
|
| 70 |
+
limit = float(np.max(np.abs(sensitivity))) or 1e-6
|
| 71 |
+
image = axes[2].imshow(sensitivity, origin="lower", aspect="auto", extent=(0, 400, values["latitude"][0], values["latitude"][-1]),
|
| 72 |
+
cmap="RdBu_r", vmin=-limit, vmax=limit)
|
| 73 |
+
axes[2].set(xlabel="Longitude (degrees E, extended)", ylabel="Latitude", title="7x7 occlusion-sensitivity trend")
|
| 74 |
+
fig.colorbar(image, ax=axes[2], label="C decade-1")
|
| 75 |
+
figure = ROOT / config["paths"]["comparison_figure"]
|
| 76 |
+
figure.parent.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
fig.savefig(figure, dpi=160)
|
| 78 |
+
plt.close(fig)
|
| 79 |
+
print(f"metrics={config['paths']['evaluation_metrics']} figure={config['paths']['comparison_figure']}")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train independently initialized DD members with optional DDP."""
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
from torch import nn
|
| 12 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 13 |
+
from torch.utils.data import DataLoader, DistributedSampler, TensorDataset
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
sys.path.insert(0, str(ROOT))
|
| 18 |
+
from model.precipdd import DATA_FORMAT_VERSION, PrecipDD, load_config, seed_all, validate_archive
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def main():
|
| 22 |
+
config = load_config(ROOT / "conf/config.yaml")
|
| 23 |
+
runtime, settings = config["runtime"], config["training"]
|
| 24 |
+
distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
|
| 25 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 26 |
+
local_world_size = int(os.environ.get("LOCAL_WORLD_SIZE", "1"))
|
| 27 |
+
use_cuda = (torch.cuda.is_available() and runtime["device"] != "cpu"
|
| 28 |
+
and torch.cuda.device_count() >= local_world_size)
|
| 29 |
+
if distributed:
|
| 30 |
+
torch.distributed.init_process_group(runtime["ddp_backend_gpu"] if use_cuda else runtime["ddp_backend_cpu"])
|
| 31 |
+
rank = torch.distributed.get_rank() if distributed else 0
|
| 32 |
+
device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu")
|
| 33 |
+
if use_cuda:
|
| 34 |
+
torch.cuda.set_device(device)
|
| 35 |
+
data = np.load(ROOT / config["paths"]["data"])
|
| 36 |
+
validate_archive(data)
|
| 37 |
+
train_mask, val_mask = data["split"] == 0, data["split"] == 1
|
| 38 |
+
dataset = TensorDataset(torch.from_numpy(data["precipitation"][train_mask]).float(), torch.from_numpy(data["agmt"][train_mask]).float())
|
| 39 |
+
val_x = torch.from_numpy(data["precipitation"][val_mask]).float().to(device)
|
| 40 |
+
val_y = torch.from_numpy(data["agmt"][val_mask]).float().to(device)
|
| 41 |
+
ensemble_states, histories = [], []
|
| 42 |
+
for member in range(settings["ensemble_members"]):
|
| 43 |
+
seed_all(config["project"]["seed"] + member)
|
| 44 |
+
sampler = DistributedSampler(dataset, shuffle=True, seed=member) if distributed else None
|
| 45 |
+
loader = DataLoader(dataset, batch_size=settings["batch_size"], shuffle=sampler is None, sampler=sampler,
|
| 46 |
+
num_workers=settings["num_workers"])
|
| 47 |
+
model = PrecipDD(config["model"]["filters"], config["model"]["dense_units"]).to(device)
|
| 48 |
+
wrapped = DistributedDataParallel(model, device_ids=[local_rank] if use_cuda else None) if distributed else model
|
| 49 |
+
optimizer = torch.optim.Adam(wrapped.parameters(), lr=settings["learning_rate"], weight_decay=settings["l2_weight_decay"])
|
| 50 |
+
history, best_loss, best_state = [], float("inf"), None
|
| 51 |
+
for epoch in range(settings["epochs"]):
|
| 52 |
+
if sampler is not None:
|
| 53 |
+
sampler.set_epoch(member * settings["epochs"] + epoch)
|
| 54 |
+
wrapped.train()
|
| 55 |
+
total, count = 0.0, 0
|
| 56 |
+
for fields, target in loader:
|
| 57 |
+
fields, target = fields.to(device), target.to(device)
|
| 58 |
+
optimizer.zero_grad(set_to_none=True)
|
| 59 |
+
loss = nn.functional.l1_loss(wrapped(fields), target)
|
| 60 |
+
loss.backward()
|
| 61 |
+
optimizer.step()
|
| 62 |
+
total += float(loss.detach()) * len(fields)
|
| 63 |
+
count += len(fields)
|
| 64 |
+
summary = torch.tensor([total, count], dtype=torch.float64, device=device)
|
| 65 |
+
if distributed:
|
| 66 |
+
torch.distributed.all_reduce(summary)
|
| 67 |
+
wrapped.eval()
|
| 68 |
+
with torch.no_grad():
|
| 69 |
+
val_loss = float(nn.functional.l1_loss(model(val_x), val_y))
|
| 70 |
+
history.append({"epoch": epoch + 1, "train_mae": float(summary[0] / summary[1]), "validation_mae": val_loss})
|
| 71 |
+
if val_loss < best_loss:
|
| 72 |
+
best_loss = val_loss
|
| 73 |
+
best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()}
|
| 74 |
+
ensemble_states.append(best_state)
|
| 75 |
+
histories.append(history)
|
| 76 |
+
if rank == 0:
|
| 77 |
+
checkpoint_path = ROOT / config["paths"]["checkpoint"]
|
| 78 |
+
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
| 79 |
+
torch.save({"format_version": DATA_FORMAT_VERSION, "ensemble_states": ensemble_states,
|
| 80 |
+
"model_config": {"filters": config["model"]["filters"], "dense_units": config["model"]["dense_units"],
|
| 81 |
+
"input_shape": [1, 55, 160], "feature_shape": [16, 14, 40], "flattened_features": 8960},
|
| 82 |
+
"training_config": settings, "histories": histories, "world_size": torch.distributed.get_world_size() if distributed else 1}, checkpoint_path)
|
| 83 |
+
metrics_path = ROOT / config["paths"]["training_metrics"]
|
| 84 |
+
metrics_path.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
metrics_path.write_text(json.dumps({"ensemble_members": len(histories), "history": histories}, indent=2) + "\n", encoding="utf-8")
|
| 86 |
+
print(f"checkpoint={checkpoint_path.relative_to(ROOT)} members={len(ensemble_states)}")
|
| 87 |
+
if distributed:
|
| 88 |
+
torch.distributed.barrier()
|
| 89 |
+
torch.distributed.destroy_process_group()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|