zhangrenchao commited on
Commit
950fc23
·
verified ·
1 Parent(s): a31b7c5

Publish PrecipDD reproduction

Browse files
.gitattributes CHANGED
@@ -1,35 +1,7 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
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