zhangrenchao commited on
Commit
6ff9439
·
verified ·
1 Parent(s): 34bf586

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -1,35 +1,3 @@
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
  *.pt filter=lfs diff=lfs merge=lfs -text
2
+ *.npz filter=lfs diff=lfs merge=lfs -text
3
+ *.npy filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ tags:
6
+ - OneScience
7
+ - Earth Science
8
+ - Ensemble Forecast Postprocessing
9
+ - Probabilistic Neural Network
10
+ frameworks: PyTorch
11
+ ---
12
+
13
+ <p align="center">
14
+ <strong><span style="font-size: 30px;">PPNN</span></strong>
15
+ </p>
16
+
17
+ # Model Introduction
18
+
19
+ PPNN converts ensemble weather forecasts and station information into calibrated probability distributions for 2 m temperature, supporting station-level ensemble postprocessing, uncertainty representation, and probabilistic weather forecasting.
20
+
21
+ Paper: Neural Networks for Postprocessing Ensemble Weather Forecasts
22
+ https://doi.org/10.1175/MWR-D-18-0187.1
23
+
24
+ # Model Description
25
+
26
+ The method was proposed by research teams at Ludwig-Maximilians-Universität München, Karlsruhe Institute of Technology, and the Heidelberg Institute for Theoretical Studies. The paper trains on ECMWF TIGGE ensemble forecasts and Deutscher Wetterdienst station observations of 2 m temperature. It is intended for station-temperature probabilistic postprocessing and ensemble calibration at a fixed 48-hour lead.
27
+
28
+ # Use Cases
29
+
30
+ | Use Case | Description |
31
+ | :---: | :--- |
32
+ | Ensemble forecast postprocessing | Generate Gaussian temperature forecasts from ensemble moments and station information. |
33
+ | Probabilistic training | Train mean and scale parameters with Gaussian CRPS. |
34
+ | ModelScope/OneCode execution | Validate feature construction, training, inference, calibration evaluation, and visualization in ModelScope or OneCode environments. |
35
+ | Multi-GPU training | Train independent network replicas through `torchrun`. |
36
+
37
+ # Usage Instructions
38
+
39
+ ## 1.OneCode
40
+
41
+ Experience intelligent, one-click AI4S programming through the OneCode online environment:
42
+
43
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
44
+
45
+ ## 2. Download and Installation
46
+
47
+ ```bash
48
+ hf download OneScience-Group/PPNN --local-dir ./PPNN
49
+ cd PPNN
50
+ ```
51
+
52
+ ### Environment Dependencies
53
+
54
+ **Hardware Requirements**
55
+
56
+ - A GPU or DCU is recommended.
57
+ - A CPU can be used for connectivity validation with the default small-sample configuration.
58
+ - DCU users must install DTK first. DTK 25.04.2 or later, or the OneScience-recommended version matching the cluster, is recommended.
59
+
60
+ **DCU Environment**
61
+
62
+ ```bash
63
+ # Activate DTK and Conda first
64
+ conda create -n onescience311 python=3.11 -y
65
+ conda activate onescience311
66
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
67
+ ```
68
+
69
+ **GPU Environment**
70
+
71
+ ```bash
72
+ # Activate Conda first
73
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
74
+ conda activate onescience311
75
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
76
+ ```
77
+
78
+ ### Training Data
79
+
80
+ Training data consist of ECMWF TIGGE ensemble forecasts and DWD station observations of 2 m temperature. Each sample contains 50 ensemble members, 18 forecast variables, and station location and elevation information, with same-valid-time station temperature as the target. Forecasts are initialized daily at 00 UTC with a fixed 48-hour lead, and temperature is measured in degrees Celsius. This repository uses a small number of synthetic samples to validate training, inference, and probabilistic evaluation; they do not represent the official TIGGE or DWD distribution, training scale, or formal paper performance.
81
+
82
+ ```bash
83
+ python scripts/fake_data.py
84
+ ```
85
+
86
+ ### Training
87
+
88
+ For single-device training, use:
89
+
90
+ ```bash
91
+ python scripts/train.py
92
+ ```
93
+
94
+ For multi-GPU training, use:
95
+
96
+ ```bash
97
+ torchrun --nproc_per_node=8 --nnodes=1 --rdzv_id=1000 --rdzv_backend=c10d --max_restarts=0 --master_addr="localhost" --master_port=29500 scripts/train.py
98
+ ```
99
+
100
+ The default engineering configuration reduces the hidden width from the paper's long-training value of 512 to 32, the independent network count from 10 to 2, and the training epochs and sample count. It does not reduce the 50 members, 18 variables, metadata for 537 stations, two-dimensional station embedding, or 48-hour lead. A formal experiment requires the real TIGGE and DWD data, the paper-scale network ensemble, and the complete training period.
101
+
102
+ Training outputs are saved to:
103
+
104
+ ```text
105
+ result/checkpoints/ppnn.pt
106
+ result/training/metrics.json
107
+ ```
108
+
109
+ ### Trained Weights
110
+
111
+ No weights are bundled under `weight/`, and no directly loadable pretrained checkpoint released by the paper authors was identified. Locally trained engineering checkpoints are written to `result/checkpoints/ppnn.pt` and must not be represented as official pretrained weights.
112
+
113
+ ### Inference
114
+
115
+ ```bash
116
+ python scripts/inference.py
117
+ ```
118
+
119
+ Inference loads the trained checkpoint and generates Gaussian temperature forecasts at the fixed 48-hour lead from 50-member ensemble statistics and station information. Complete numerical results are saved to:
120
+
121
+ ```text
122
+ result/output/predictions.npz
123
+ ```
124
+
125
+ ### Evaluation and Visualization
126
+
127
+ ```bash
128
+ python scripts/result.py
129
+ ```
130
+
131
+ Evaluation follows the paper's probabilistic protocol for Gaussian CRPS, raw-ensemble CRPS, CRPSS, PIT calibration, and spread-error diagnostics, saving overall and station-sample aggregate results; the task has one fixed 48-hour lead and no classes, so no per-lead or per-class metrics are saved. It also generates PIT-calibration and probabilistic-error comparisons. Synthetic-data results validate the engineering workflow only and do not represent formal paper performance.
132
+
133
+ ```text
134
+ result/evaluation/metrics.json
135
+ result/evaluation/ppnn_evaluation.png
136
+ ```
137
+
138
+ # Official OneScience Information
139
+
140
+ | Platform | OneScience Main Repository | Skills Repository |
141
+ | --- | --- | --- |
142
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
143
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
144
+
145
+ # Citation and License
146
+
147
+ This repository is an independent engineering reproduction of the public PPNN specifications.
148
+
149
+ Use of this repository's code, official model weights, and data remains subject to the licenses and terms of their respective projects.
conf/config.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ data:
3
+ root: data
4
+ file: data/ppnn_virtual.npz
5
+ format_version: ppnn_nn_aux_emb_v1
6
+ dates: [2020-01-15, 2020-07-15, 2021-01-15, 2021-07-15]
7
+ active_station_count: 12
8
+ station_count: 537
9
+ member_count: 50
10
+ variable_count: 18
11
+ lead_hours: 48
12
+ target_variable: t2m
13
+ model:
14
+ name: PPNN
15
+ variant: NN-aux-emb
16
+ hidden_size: 32
17
+ paper_hidden_size: 512
18
+ embedding_dim: 2
19
+ continuous_features: 40
20
+ input_features: 42
21
+ sigma_epsilon: 0.0001
22
+ train:
23
+ replicas: 2
24
+ paper_replicas: 10
25
+ epochs: 12
26
+ batch_size: 64
27
+ learning_rate: 0.003
28
+ runtime:
29
+ device: auto
30
+ paths:
31
+ checkpoint: result/checkpoints/ppnn.pt
32
+ training_metrics: result/training/metrics.json
33
+ inference: result/output/predictions.npz
34
+ evaluation_dir: result/evaluation
config.json ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "PPNN",
3
+ "model_type": "ppnn",
4
+ "architectures": [
5
+ "PPNN"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "weather forecasting",
9
+ "task": "probabilistic ensemble forecast postprocessing",
10
+ "implementation": {
11
+ "entry_point": "model/ppnn.py",
12
+ "scope": "Core-method and full-data-dimension reduced-scale end-to-end engineering reproduction",
13
+ "train_script": "scripts/train.py",
14
+ "inference_script": "scripts/inference.py",
15
+ "evaluation_script": "scripts/result.py",
16
+ "synthetic_data_script": "scripts/fake_data.py"
17
+ },
18
+ "architecture": {
19
+ "variant": "NN-aux-emb",
20
+ "continuous_features": 40,
21
+ "station_embedding_dim": 2,
22
+ "network_input_features": 42,
23
+ "engineering_hidden_size": 32,
24
+ "paper_long_training_hidden_size": 512,
25
+ "activation": "ReLU",
26
+ "outputs": [
27
+ "mu",
28
+ "raw_sigma"
29
+ ],
30
+ "scale_transform": "abs(raw_sigma) + epsilon"
31
+ },
32
+ "data": {
33
+ "datasets": [
34
+ "ECMWF TIGGE ensemble forecasts",
35
+ "DWD station observations"
36
+ ],
37
+ "protocol": "Daily 00 UTC initialization at a fixed 48-hour lead; one sample is a valid date-station pair",
38
+ "format_version": "ppnn_nn_aux_emb_v1",
39
+ "raw_ensemble_shape": [
40
+ "N",
41
+ 50,
42
+ 18
43
+ ],
44
+ "continuous_input_shape": [
45
+ "N",
46
+ 40
47
+ ],
48
+ "output_shape": [
49
+ "N",
50
+ 2
51
+ ],
52
+ "ensemble_members": 50,
53
+ "forecast_variables": 18,
54
+ "station_count": 537,
55
+ "lead_time_hours": 48,
56
+ "target": "2 m temperature",
57
+ "target_unit": "degree Celsius",
58
+ "ensemble_statistics": [
59
+ "mean",
60
+ "sample standard deviation (ddof=1)"
61
+ ]
62
+ },
63
+ "configuration_sources": [
64
+ "conf/config.yaml",
65
+ "model/ppnn.py",
66
+ "scripts/fake_data.py",
67
+ "scripts/train.py",
68
+ "scripts/inference.py",
69
+ "scripts/result.py"
70
+ ]
71
+ }
configuration.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "ensemble_weather_forecast_postprocessing",
4
+ "model": "PPNN",
5
+ "input_format": "ensemble [N,50,18], auxiliary [N,4], station_index [N]",
6
+ "protocol": "NN-aux-emb Gaussian postprocessing at a fixed 48-hour lead with 537-station metadata",
7
+ "default_config": "conf/config.yaml",
8
+ "training": "scripts/train.py",
9
+ "inference": "scripts/inference.py",
10
+ "evaluation": "scripts/result.py",
11
+ "visualization": "scripts/result.py"
12
+ }
model/ppnn.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PPNN NN-aux-emb model and proper scoring rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+
11
+ FORMAT_VERSION = "ppnn_nn_aux_emb_v1"
12
+ MEMBER_COUNT = 50
13
+ VARIABLE_COUNT = 18
14
+ STATION_COUNT = 537
15
+ LEAD_HOURS = 48
16
+ AUX_FEATURE_COUNT = 4
17
+ CONTINUOUS_FEATURE_COUNT = VARIABLE_COUNT * 2 + AUX_FEATURE_COUNT
18
+ EMBEDDING_DIM = 2
19
+ INPUT_FEATURE_COUNT = CONTINUOUS_FEATURE_COUNT + EMBEDDING_DIM
20
+
21
+
22
+ class PPNN(nn.Module):
23
+ """NN-aux-emb: ensemble moments, auxiliary predictors, and station embedding."""
24
+
25
+ def __init__(self, hidden_size: int = 32, station_count: int = STATION_COUNT, eps: float = 1e-4):
26
+ super().__init__()
27
+ if station_count != STATION_COUNT:
28
+ raise ValueError(f"station_count must be the paper value {STATION_COUNT}")
29
+ self.station_count = station_count
30
+ self.eps = float(eps)
31
+ self.station_embedding = nn.Embedding(station_count, EMBEDDING_DIM)
32
+ self.hidden = nn.Linear(INPUT_FEATURE_COUNT, hidden_size)
33
+ self.output = nn.Linear(hidden_size, 2)
34
+
35
+ def forward(self, continuous: torch.Tensor, station_index: torch.Tensor):
36
+ if continuous.ndim != 2 or continuous.shape[-1] != CONTINUOUS_FEATURE_COUNT:
37
+ raise ValueError(f"continuous features must have shape [batch,{CONTINUOUS_FEATURE_COUNT}]")
38
+ if station_index.ndim != 1 or station_index.shape[0] != continuous.shape[0]:
39
+ raise ValueError("station_index must have shape [batch]")
40
+ if station_index.numel() and (station_index.min() < 0 or station_index.max() >= self.station_count):
41
+ raise ValueError("station index outside [0, 536]")
42
+ features = torch.cat((continuous, self.station_embedding(station_index)), dim=-1)
43
+ mu, raw_sigma = self.output(torch.relu(self.hidden(features))).unbind(dim=-1)
44
+ return mu, raw_sigma.abs() + self.eps
45
+
46
+
47
+ def ensemble_features(ensemble: torch.Tensor, auxiliary: torch.Tensor) -> torch.Tensor:
48
+ """Reduce [batch, 50, 18] to 36 moments and append four auxiliary features."""
49
+ if ensemble.ndim != 3 or ensemble.shape[1:] != (MEMBER_COUNT, VARIABLE_COUNT):
50
+ raise ValueError(f"ensemble must have shape [batch,{MEMBER_COUNT},{VARIABLE_COUNT}]")
51
+ if auxiliary.ndim != 2 or auxiliary.shape != (ensemble.shape[0], AUX_FEATURE_COUNT):
52
+ raise ValueError(f"auxiliary must have shape [batch,{AUX_FEATURE_COUNT}]")
53
+ moments = torch.cat((ensemble.mean(dim=1), ensemble.std(dim=1, correction=1)), dim=-1)
54
+ return torch.cat((moments, auxiliary), dim=-1)
55
+
56
+
57
+ def gaussian_crps(mu: torch.Tensor, sigma: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
58
+ """Closed-form CRPS for a Gaussian predictive distribution."""
59
+ if torch.any(sigma <= 0):
60
+ raise ValueError("sigma must be strictly positive")
61
+ z = (target - mu) / sigma
62
+ pdf = torch.exp(-0.5 * z.square()) / math.sqrt(2.0 * math.pi)
63
+ cdf = 0.5 * (1.0 + torch.erf(z / math.sqrt(2.0)))
64
+ return sigma * (z * (2.0 * cdf - 1.0) + 2.0 * pdf - 1.0 / math.sqrt(math.pi))
65
+
66
+
67
+ def ensemble_crps(ensemble: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
68
+ """Empirical ensemble CRPS without constructing a member-pair matrix."""
69
+ if ensemble.shape[-1] != MEMBER_COUNT:
70
+ raise ValueError(f"last ensemble dimension must be {MEMBER_COUNT}")
71
+ sorted_members = ensemble.sort(dim=-1).values
72
+ weights = (2 * torch.arange(1, MEMBER_COUNT + 1, device=ensemble.device) - MEMBER_COUNT - 1).to(ensemble.dtype)
73
+ pair_term = (sorted_members * weights).sum(dim=-1) / (MEMBER_COUNT * MEMBER_COUNT)
74
+ return (ensemble - target.unsqueeze(-1)).abs().mean(dim=-1) - pair_term
scripts/fake_data.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a small, structured PPNN dataset while preserving paper dimensions."""
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import yaml
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+
10
+
11
+ def main():
12
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
13
+ data = config["data"]
14
+ if (data["station_count"], data["member_count"], data["variable_count"], data["lead_hours"]) != (537, 50, 18, 48):
15
+ raise ValueError("paper station/member/variable/lead dimensions cannot be reduced")
16
+ rng = np.random.default_rng(int(config["seed"]))
17
+ station_index_all = np.arange(537)
18
+ latitude = (47.0 + 8.0 * ((station_index_all * 0.61803398875) % 1)).astype(np.float32)
19
+ longitude = (5.5 + 9.5 * ((station_index_all * 0.41421356237) % 1)).astype(np.float32)
20
+ elevation = (5.0 + 1450.0 * ((station_index_all * 0.2718281828) % 1)).astype(np.float32)
21
+ station_id = np.asarray([f"DWD-{i:04d}" for i in station_index_all])
22
+ active = np.linspace(0, 536, int(data["active_station_count"]), dtype=np.int64)
23
+ dates = np.asarray(data["dates"], dtype="datetime64[D]")
24
+ date_id, station_id_index = np.meshgrid(np.arange(len(dates)), active, indexing="ij")
25
+ date_id, station_id_index = date_id.ravel(), station_id_index.ravel()
26
+ n = len(date_id)
27
+ day_of_year = (dates[date_id] - dates[date_id].astype("datetime64[Y]")).astype(int) + 1
28
+ seasonal = np.sin(2 * np.pi * (day_of_year - 172) / 365.25).astype(np.float32)
29
+ lat = latitude[station_id_index]
30
+ lon = longitude[station_id_index]
31
+ elev = elevation[station_id_index]
32
+ spatial = (-0.42 * (lat - 51.0) + 0.13 * (lon - 10.0) - 0.0062 * elev).astype(np.float32)
33
+ common_weather = rng.normal(0, 2.2, len(dates)).astype(np.float32)[date_id]
34
+ target = (11.0 + 9.0 * seasonal + spatial + common_weather + 0.18 * np.sin(station_id_index * 0.17)).astype(np.float32)
35
+ variable_offset = np.linspace(-4.0, 5.0, 18, dtype=np.float32)
36
+ variable_scale = np.linspace(0.72, 1.25, 18, dtype=np.float32)
37
+ centre = target[:, None] * variable_scale[None] + variable_offset[None] + 0.3 * common_weather[:, None]
38
+ spread = (0.45 + 0.025 * np.abs(common_weather) + 0.00045 * elev + 0.08 * (1.0 + seasonal)).astype(np.float32)
39
+ member_axis = np.linspace(-2.1, 2.1, 50, dtype=np.float32)
40
+ wave = np.sin((station_id_index[:, None, None] + 1) * 0.13 + np.arange(18)[None, None] * 0.31 + member_axis[None, :, None] * 1.7)
41
+ ensemble = centre[:, None, :] + spread[:, None, None] * variable_scale[None, None, :] * (member_axis[None, :, None] + 0.22 * wave)
42
+ ensemble[:, :, 0] += (0.35 + 0.14 * seasonal)[:, None]
43
+ auxiliary = np.stack((latitude[station_id_index], longitude[station_id_index], elevation[station_id_index], day_of_year / 366.0), axis=1).astype(np.float32)
44
+ output = ROOT / data["file"]
45
+ output.parent.mkdir(parents=True, exist_ok=True)
46
+ np.savez_compressed(
47
+ output, format_version=np.asarray(data["format_version"]), ensemble=ensemble.astype(np.float32),
48
+ auxiliary=auxiliary, target=target, station_index=station_id_index.astype(np.int64), date_index=date_id.astype(np.int64),
49
+ dates=dates.astype("U10"), lead_hours=np.asarray(data["lead_hours"], np.int64), variables=np.asarray(["t2m"] + [f"var_{i:02d}" for i in range(1, 18)]),
50
+ station_id=station_id, station_latitude=latitude, station_longitude=longitude, station_elevation_m=elevation,
51
+ )
52
+ if ensemble.shape != (n, 50, 18) or len(station_id) != 537 or not np.isfinite(ensemble).all():
53
+ raise RuntimeError("generated data failed shape or finite-value validation")
54
+ print(f"saved={output.relative_to(ROOT)} samples={n} ensemble={ensemble.shape} metadata_stations=537 lead=48h")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run all PPNN replicas and save the complete verification payload."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import torch
8
+ import yaml
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(ROOT))
12
+ from model.ppnn import FORMAT_VERSION, PPNN, ensemble_features
13
+
14
+
15
+ def main():
16
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
17
+ data = np.load(ROOT / config["data"]["file"])
18
+ checkpoint = torch.load(ROOT / config["paths"]["checkpoint"], map_location="cpu", weights_only=False)
19
+ if str(data["format_version"]) != FORMAT_VERSION or checkpoint.get("format_version") != FORMAT_VERSION:
20
+ raise ValueError("data/checkpoint format version mismatch")
21
+ if data["ensemble"].shape[1:] != (50, 18) or len(data["station_id"]) != 537 or int(data["lead_hours"]) != 48:
22
+ raise ValueError("inference requires 50 members, 18 variables, 537 stations, and 48h lead")
23
+ scaling = checkpoint["scaling"]
24
+ continuous = ensemble_features(torch.from_numpy(data["ensemble"]), torch.from_numpy(data["auxiliary"]))
25
+ continuous = (continuous - scaling["feature_mean"]) / scaling["feature_std"]
26
+ station = torch.from_numpy(data["station_index"])
27
+ mus, sigmas = [], []
28
+ for state in checkpoint["model"]:
29
+ model = PPNN(int(checkpoint["model_config"]["hidden_size"]), eps=float(checkpoint["model_config"]["sigma_epsilon"]))
30
+ model.load_state_dict(state)
31
+ model.eval()
32
+ with torch.no_grad():
33
+ mu_scaled, sigma_scaled = model(continuous, station)
34
+ mus.append(mu_scaled * scaling["target_std"] + scaling["target_mean"])
35
+ sigmas.append(sigma_scaled * scaling["target_std"])
36
+ # The paper averages Gaussian parameters from random networks; this is not a mixture distribution.
37
+ mu = torch.stack(mus).mean(0).numpy()
38
+ sigma = torch.stack(sigmas).mean(0).numpy()
39
+ target = data["target"]
40
+ raw_t2m = data["ensemble"][:, :, 0]
41
+ if not all(np.isfinite(x).all() for x in (mu, sigma, target, raw_t2m)) or not np.all(sigma > 0):
42
+ raise RuntimeError("inference output is non-finite or has non-positive sigma")
43
+ output = ROOT / config["paths"]["inference"]
44
+ output.parent.mkdir(parents=True, exist_ok=True)
45
+ np.savez_compressed(output, mu=mu, sigma=sigma, targets=target, raw_ensemble_t2m=raw_t2m,
46
+ station_index=data["station_index"], station_id=data["station_id"], date_index=data["date_index"],
47
+ dates=data["dates"], lead_hours=data["lead_hours"], format_version=np.asarray(FORMAT_VERSION))
48
+ print(f"saved={output.relative_to(ROOT)} samples={len(mu)} replicas={len(mus)} raw_ensemble={raw_t2m.shape}")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
scripts/result.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate Gaussian and raw-ensemble forecasts and create calibration plots."""
2
+
3
+ import json
4
+ import math
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import matplotlib
9
+ matplotlib.use("Agg")
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+ import torch
13
+ import yaml
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(ROOT))
17
+ from model.ppnn import FORMAT_VERSION, ensemble_crps, gaussian_crps
18
+
19
+
20
+ def main():
21
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
22
+ data = np.load(ROOT / config["paths"]["inference"])
23
+ if str(data["format_version"]) != FORMAT_VERSION or data["raw_ensemble_t2m"].shape != (len(data["mu"]), 50):
24
+ raise ValueError("prediction version/shape mismatch")
25
+ mu = torch.from_numpy(data["mu"])
26
+ sigma = torch.from_numpy(data["sigma"])
27
+ target = torch.from_numpy(data["targets"])
28
+ raw = torch.from_numpy(data["raw_ensemble_t2m"])
29
+ model_scores = gaussian_crps(mu, sigma, target)
30
+ raw_scores = ensemble_crps(raw, target)
31
+ model_mean, raw_mean = float(model_scores.mean()), float(raw_scores.mean())
32
+ pit = 0.5 * (1.0 + torch.erf((target - mu) / sigma / math.sqrt(2.0))).numpy()
33
+ bins = np.linspace(0.0, 1.0, 11)
34
+ counts, _ = np.histogram(pit, bins=bins)
35
+ observed = np.asarray([(pit <= edge).mean() for edge in bins[1:-1]])
36
+ nominal = bins[1:-1]
37
+ calibration_mae = float(np.mean(np.abs(observed - nominal)))
38
+ rmse = float(torch.sqrt(torch.mean((mu - target).square())))
39
+ spread_error_ratio = float(sigma.mean()) / rmse
40
+ metrics = {
41
+ "note": "structured virtual-data engineering validation, not paper performance",
42
+ "samples": len(mu), "station_metadata_count": len(data["station_id"]), "lead_hours": int(data["lead_hours"]),
43
+ "mean_gaussian_crps": model_mean, "mean_raw_ensemble_crps": raw_mean,
44
+ "crpss": 1.0 - model_mean / raw_mean, "pit_histogram_counts": counts.tolist(),
45
+ "pit_bin_edges": bins.tolist(), "calibration_mae": calibration_mae,
46
+ "rmse_mu": rmse, "mean_sigma": float(sigma.mean()), "spread_error_ratio": spread_error_ratio,
47
+ }
48
+ numeric = [model_mean, raw_mean, metrics["crpss"], calibration_mae, rmse, metrics["mean_sigma"], spread_error_ratio]
49
+ if not np.isfinite(numeric).all() or raw_mean <= 0 or rmse <= 0:
50
+ raise RuntimeError("evaluation metrics are not finite and valid")
51
+ output = ROOT / config["paths"]["evaluation_dir"]
52
+ output.mkdir(parents=True, exist_ok=True)
53
+ (output / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n")
54
+ fig, axes = plt.subplots(1, 3, figsize=(13, 4), constrained_layout=True)
55
+ axes[0].bar((bins[:-1] + bins[1:]) / 2, counts / counts.sum(), width=0.085)
56
+ axes[0].axhline(0.1, color="black", linestyle="--", linewidth=1)
57
+ axes[0].set(xlabel="PIT", ylabel="relative frequency", title="PIT histogram")
58
+ axes[1].plot(nominal, observed, marker="o"); axes[1].plot([0, 1], [0, 1], "k--")
59
+ axes[1].set(xlabel="nominal CDF", ylabel="observed frequency", title="Calibration")
60
+ axes[2].scatter(sigma.numpy(), np.abs(mu.numpy() - target.numpy()), c=data["date_index"], cmap="viridis", s=22)
61
+ axes[2].set(xlabel="predictive sigma", ylabel="absolute error", title=f"Spread/error ratio = {spread_error_ratio:.2f}")
62
+ fig.savefig(output / "ppnn_evaluation.png", dpi=150)
63
+ plt.close(fig)
64
+ print(f"gaussian_crps={model_mean:.6f} raw_crps={raw_mean:.6f} crpss={metrics['crpss']:.6f} spread_error_ratio={spread_error_ratio:.6f}")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
scripts/train.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train independent PPNN replicas with Gaussian CRPS and optional rank allocation."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import torch
10
+ import yaml
11
+ from torch.utils.data import DataLoader, TensorDataset
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(ROOT))
15
+ from model.ppnn import FORMAT_VERSION, PPNN, ensemble_features, gaussian_crps
16
+
17
+
18
+ def load_data(config):
19
+ data = np.load(ROOT / config["data"]["file"])
20
+ expected = (len(data["target"]), 50, 18)
21
+ if str(data["format_version"]) != FORMAT_VERSION or data["ensemble"].shape != expected:
22
+ raise ValueError(f"dataset version/shape mismatch: expected {FORMAT_VERSION} and {expected}")
23
+ if len(data["station_id"]) != 537 or int(data["lead_hours"]) != 48:
24
+ raise ValueError("dataset must retain 537 stations and 48h lead")
25
+ arrays = [data["ensemble"], data["auxiliary"], data["target"]]
26
+ if not all(np.isfinite(x).all() for x in arrays):
27
+ raise ValueError("dataset contains non-finite values")
28
+ return data
29
+
30
+
31
+ def main():
32
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
33
+ data = load_data(config)
34
+ distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
35
+ if distributed:
36
+ torch.distributed.init_process_group("gloo")
37
+ rank = torch.distributed.get_rank() if distributed else 0
38
+ world_size = torch.distributed.get_world_size() if distributed else 1
39
+ device_name = config["runtime"]["device"]
40
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
41
+ enough_accelerators = torch.cuda.is_available() and torch.cuda.device_count() >= world_size
42
+ device = torch.device("cuda", local_rank) if device_name == "auto" and enough_accelerators else torch.device("cpu")
43
+ ensemble = torch.from_numpy(data["ensemble"])
44
+ auxiliary = torch.from_numpy(data["auxiliary"])
45
+ station = torch.from_numpy(data["station_index"])
46
+ target = torch.from_numpy(data["target"])
47
+ continuous_raw = ensemble_features(ensemble, auxiliary)
48
+ feature_mean = continuous_raw.mean(0)
49
+ feature_std = continuous_raw.std(0, correction=1).clamp_min(1e-6)
50
+ target_mean, target_std = target.mean(), target.std(correction=1).clamp_min(1e-6)
51
+ continuous = (continuous_raw - feature_mean) / feature_std
52
+ target_scaled = (target - target_mean) / target_std
53
+ dataset = TensorDataset(continuous, station, target_scaled)
54
+ local = []
55
+ replicas = int(config["train"]["replicas"])
56
+ for replica in range(replicas):
57
+ if replica % world_size != rank:
58
+ continue
59
+ seed = int(config["seed"]) + replica
60
+ torch.manual_seed(seed)
61
+ generator = torch.Generator().manual_seed(seed)
62
+ loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]), shuffle=True, generator=generator)
63
+ model = PPNN(int(config["model"]["hidden_size"]), eps=float(config["model"]["sigma_epsilon"])).to(device)
64
+ optimizer = torch.optim.Adam(model.parameters(), lr=float(config["train"]["learning_rate"]))
65
+ history = []
66
+ for epoch in range(int(config["train"]["epochs"])):
67
+ total = 0.0
68
+ for x, s, y in loader:
69
+ mu, sigma = model(x.to(device), s.to(device))
70
+ loss = gaussian_crps(mu, sigma, y.to(device)).mean()
71
+ if not torch.isfinite(loss):
72
+ raise RuntimeError("non-finite Gaussian CRPS loss")
73
+ optimizer.zero_grad(set_to_none=True)
74
+ loss.backward()
75
+ optimizer.step()
76
+ total += float(loss.detach()) * len(y)
77
+ history.append(total / len(dataset))
78
+ local.append({"replica": replica, "state_dict": {k: v.cpu() for k, v in model.state_dict().items()}, "loss": history})
79
+ print(f"rank={rank} replica={replica} final_scaled_crps={history[-1]:.6f}")
80
+ if distributed:
81
+ gathered = [None] * world_size
82
+ torch.distributed.all_gather_object(gathered, local)
83
+ trained = [item for rank_items in gathered for item in rank_items]
84
+ else:
85
+ trained = local
86
+ if rank == 0:
87
+ trained.sort(key=lambda x: x["replica"])
88
+ if [x["replica"] for x in trained] != list(range(replicas)):
89
+ raise RuntimeError("distributed ranks did not return every replica")
90
+ checkpoint = ROOT / config["paths"]["checkpoint"]
91
+ metrics = ROOT / config["paths"]["training_metrics"]
92
+ checkpoint.parent.mkdir(parents=True, exist_ok=True)
93
+ metrics.parent.mkdir(parents=True, exist_ok=True)
94
+ scaling = {"feature_mean": feature_mean, "feature_std": feature_std, "target_mean": target_mean, "target_std": target_std}
95
+ states = [x["state_dict"] for x in trained]
96
+ torch.save({"format_version": FORMAT_VERSION, "model": states,
97
+ "model_config": config["model"], "config": config, "scaling": scaling}, checkpoint)
98
+ metrics.write_text(json.dumps({"replicas": [{"id": x["replica"], "loss": x["loss"]} for x in trained], "world_size": world_size}, indent=2) + "\n")
99
+ if distributed:
100
+ torch.distributed.destroy_process_group()
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
weight/.gitkeep ADDED
File without changes