Niulh commited on
Commit
9f29df6
·
verified ·
1 Parent(s): 40810e4

Publish RainNet model package

Browse files
.gitattributes CHANGED
@@ -1,35 +1,14 @@
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
  *.bin filter=lfs diff=lfs merge=lfs -text
 
 
 
 
2
  *.h5 filter=lfs diff=lfs merge=lfs -text
3
+ *.hdf5 filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
4
  *.onnx filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
5
  *.pt filter=lfs diff=lfs merge=lfs -text
6
  *.pth filter=lfs diff=lfs merge=lfs -text
 
7
  *.safetensors filter=lfs diff=lfs merge=lfs -text
8
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
9
+ *.npy filter=lfs diff=lfs merge=lfs -text
10
+ *.npz filter=lfs diff=lfs merge=lfs -text
11
+ *.pickle filter=lfs diff=lfs merge=lfs -text
12
+ *.pkl filter=lfs diff=lfs merge=lfs -text
13
  *.tar filter=lfs diff=lfs merge=lfs -text
 
 
 
 
14
  *.zip filter=lfs diff=lfs merge=lfs -text
 
 
README.md ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ - zh
6
+ tags:
7
+ - OneScience
8
+ - Earth Science
9
+ - precipitation nowcasting
10
+ - weather radar
11
+ - RYDL
12
+ frameworks: PyTorch
13
+ datasets:
14
+ - RYDL
15
+ ---
16
+
17
+ <p align="center">
18
+ <strong>
19
+ <span style="font-size: 30px;">RainNet</span>
20
+ </strong>
21
+ </p>
22
+
23
+ # Model Introduction
24
+
25
+ Paper: RainNet v1.0: a convolutional neural network for radar-based precipitation nowcasting
26
+ https://doi.org/10.5194/gmd-13-2631-2020
27
+
28
+ RainNet is designed for radar-based precipitation nowcasting. It takes four consecutive radar precipitation fields at 5 min intervals as input, predicts the precipitation field for the next 5 min, and can be extended recursively to a lead time of approximately 60 min.
29
+
30
+ # Model Description
31
+
32
+ RainNet was proposed by the authors of the original paper and trained on the German Weather Service (DWD) RY radar precipitation product. It performs radar nowcasting as a regression task for continuous precipitation intensity.
33
+
34
+ The current implementation takes four consecutive historical frames as input and uses the immediately following time step, `i+4`, as the target. Precipitation values are transformed with `x -> log(x + 0.01)` before entering the model. Each raw `900x900` radar field is expanded to `928x928` with reflect/mirror padding and cropped back to `900x900` after prediction. RainNet has approximately 31.4M parameters; the validated parameter count is 31,380,613. The decoder uses nearest-neighbor upsampling.
35
+
36
+ # Applicable Scenarios
37
+
38
+ | Scenario | Description |
39
+ | :---: | :--- |
40
+ | Continuous precipitation regression training | Train RainNet with consecutive radar precipitation fields. |
41
+ | Radar precipitation nowcasting | Predict future precipitation from consecutive historical radar fields. |
42
+ | Local pipeline validation | Use Fake Data to validate data loading, training, inference, evaluation, and visualization. |
43
+ | ModelScope / OneCode execution | Run the project as a standalone model package. |
44
+ | Multi-GPU training | Launch distributed training processes with `torchrun`. |
45
+
46
+ # Usage
47
+
48
+ Run the following commands from the root of the model package. The default smoke-test configuration preserves the full `900x900` spatial grid and uses 1 epoch with at most 1 batch per stage to validate the engineering pipeline.
49
+
50
+ ## 1. OneCode
51
+
52
+ Use the OneCode online environment for intelligent one-click AI4S programming:
53
+
54
+ [Try intelligent one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
55
+
56
+ ## 2. Manual Installation and Usage
57
+
58
+ Python 3.11, PyTorch, NumPy, h5py, PyYAML, and Matplotlib are required. After installing the environment, run Fake Data generation, training, inference, and evaluation in sequence.
59
+
60
+ ### Hardware Requirements
61
+
62
+ The model has approximately 31.4M parameters. Training with full `928x928` internal tensors requires substantial accelerator memory, so a CUDA/HIP-compatible GPU or DCU with sufficient memory is recommended. CPU execution is supported but substantially slower.
63
+
64
+ ### Download the Model Package
65
+
66
+ ```bash
67
+ modelscope download --model OneScience/RainNet --local_dir ./RainNet
68
+ cd RainNet
69
+ ```
70
+
71
+ ### Install the Runtime Environment
72
+
73
+ #### DCU Environment
74
+
75
+ ```bash
76
+ # Activate DTK and CONDA first
77
+ conda create -n onescience311 python=3.11 -y
78
+ conda activate onescience311
79
+
80
+ pip install onescience[earth-dcu] \
81
+ -i http://mirrors.onescience.ai:3141/pypi/simple/ \
82
+ --trusted-host mirrors.onescience.ai
83
+ ```
84
+
85
+ #### GPU Environment
86
+
87
+ ```bash
88
+ # Activate CONDA first
89
+ conda create -n onescience311 python=3.11 -y \
90
+ libstdcxx-ng=12 \
91
+ libgcc-ng=12 \
92
+ gcc_linux-64=12 \
93
+ gxx_linux-64=12
94
+
95
+ conda activate onescience311
96
+
97
+ pip install onescience[earth-gpu] \
98
+ -i http://mirrors.onescience.ai:3141/pypi/simple/ \
99
+ --trusted-host mirrors.onescience.ai
100
+ ```
101
+
102
+ ### Training Data
103
+
104
+ #### Real Data
105
+
106
+ The real dataset is RYDL, available from https://doi.org/10.5281/zenodo.3629951. It uses HDF5, with a raw frame size of `900x900`, a spatial resolution of 1 km, and a temporal resolution of 5 min. Each top-level HDF5 timestamp key corresponds to one two-dimensional precipitation field. This repository does not include or automatically download the complete real dataset.
107
+
108
+ #### Fake Data
109
+
110
+ ```bash
111
+ python scripts/fake_data.py
112
+ ```
113
+
114
+ This command generates `data/rainnet_fake.hdf5`. Fake Data preserves the real `900x900` frame size, simulates the RYDL HDF5 timestamp key-value organization, maintains a continuous sequence at 5 min intervals, and only reduces the number of time frames.
115
+
116
+ Fake Data is only used to validate the engineering pipeline. It does not represent real precipitation forecasting performance and does not reproduce the accuracy reported in the paper.
117
+
118
+ ### Training
119
+
120
+ This reproduction uses Log-Cosh Loss and the Adam optimizer, with a default learning rate of `1e-4`.
121
+
122
+ #### Single-Accelerator Training
123
+
124
+ ```bash
125
+ python scripts/fake_data.py
126
+ python scripts/train.py
127
+ ```
128
+
129
+ #### Distributed Training
130
+
131
+ ```bash
132
+ torchrun \
133
+ --nproc_per_node=8 \
134
+ --nnodes=1 \
135
+ --rdzv_id=1000 \
136
+ --rdzv_backend=c10d \
137
+ --max_restarts=0 \
138
+ --master_addr="localhost" \
139
+ --master_port=29500 \
140
+ scripts/train.py
141
+ ```
142
+
143
+ ### Training Weights
144
+
145
+ This repository plans to provide weights trained on DWD RY/RYDL radar precipitation data under `weight/`. The weight files will be uploaded in a future update.
146
+
147
+ ### Inference
148
+
149
+ ```bash
150
+ python scripts/inference.py
151
+ ```
152
+
153
+ Inference results are saved under `result/output/`. The script loads a training checkpoint, performs one-step inference, and runs a 12-step autoregressive rollout while updating the four-frame sliding window.
154
+
155
+ ### Evaluation and Visualization
156
+
157
+ ```bash
158
+ python scripts/result.py
159
+ ```
160
+
161
+ The script computes MAE, CSI, FSS, and the Persistence baseline from actual inference outputs, and generates forecast comparisons, a training-loss curve, and metric plots.
162
+
163
+ # OneScience Official Information
164
+
165
+ | Platform | OneScience Main Repository | Skills Repository |
166
+ | --- | --- | --- |
167
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
168
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
169
+
170
+ # Citation and License
171
+
172
+ - The OneScience RainNet model package is licensed under Apache License 2.0.
173
+ - The original RainNet source code is licensed under the MIT License.
174
+ - The RainNet paper was published by Copernicus Publications under the Creative Commons Attribution 4.0 License (CC BY 4.0).
175
+ - Attribution to the original paper and authors must be retained when using or redistributing this package.
176
+
177
+ ```bibtex
178
+ @article{ayzel2020rainnet,
179
+ title={RainNet v1.0: a convolutional neural network for radar-based precipitation nowcasting},
180
+ author={Ayzel, Georgy and Scheffer, Tobias and Heistermann, Maik},
181
+ journal={Geoscientific Model Development},
182
+ volume={13},
183
+ pages={2631--2644},
184
+ year={2020},
185
+ doi={10.5194/gmd-13-2631-2020}
186
+ }
187
+ ```
conf/config.yaml ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ device: auto
3
+
4
+ data:
5
+ mode: synthetic
6
+ path: ./data/rainnet_fake.hdf5
7
+ input_steps: 4
8
+ forecast_steps: 1
9
+ interval_minutes: 5
10
+ raw_height: 900
11
+ raw_width: 900
12
+ padded_height: 928
13
+ padded_width: 928
14
+ pixel_size_km: 1
15
+ train_frames: 8
16
+ val_frames: 6
17
+ test_frames: 16
18
+ num_workers: 0
19
+
20
+ model:
21
+ in_channels: 4
22
+ out_channels: 1
23
+ dropout: 0.5
24
+ mode: regression
25
+
26
+ train:
27
+ epochs: 1
28
+ batch_size: 1
29
+ learning_rate: 0.0001
30
+ max_train_batches: 1
31
+ max_valid_batches: 1
32
+ checkpoint_last: ./weight/rainnet_last.pth
33
+ checkpoint_best: ./weight/rainnet_best.pth
34
+
35
+ paper_reference:
36
+ epochs: 10
37
+ batch_size: 2
38
+ learning_rate: 0.0001
39
+ raw_size: 900
40
+ padded_size: 928
41
+
42
+ inference:
43
+ checkpoint: ./weight/rainnet_best.pth
44
+ rollout_steps: 12
45
+ output_dir: ./result/output
46
+
47
+ evaluation:
48
+ thresholds_mm_h:
49
+ - 0.125
50
+ - 1.0
51
+ - 5.0
52
+ - 10.0
53
+ - 15.0
54
+ fss_windows_km:
55
+ - 1
56
+ - 5
57
+ - 10
58
+ - 20
59
+ result_dir: ./result
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"Pytorch","task":"other"}
model/rainnet.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyTorch implementation of RainNet v1.0."""
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class RainNet(nn.Module):
8
+ """The 20-convolution encoder-decoder from Ayzel et al. (2020)."""
9
+
10
+ def __init__(self, in_channels=4, out_channels=1, dropout=0.5, mode="regression"):
11
+ super().__init__()
12
+ if mode != "regression":
13
+ raise ValueError("This package implements RainNet regression mode only")
14
+ self.in_channels = in_channels
15
+ self.pool = nn.MaxPool2d(2)
16
+ self.upsample = nn.Upsample(scale_factor=2, mode="nearest")
17
+ self.relu = nn.ReLU(inplace=True)
18
+
19
+ self.conv1f = nn.Conv2d(in_channels, 64, 3, padding=1)
20
+ self.conv1s = nn.Conv2d(64, 64, 3, padding=1)
21
+ self.conv2f = nn.Conv2d(64, 128, 3, padding=1)
22
+ self.conv2s = nn.Conv2d(128, 128, 3, padding=1)
23
+ self.conv3f = nn.Conv2d(128, 256, 3, padding=1)
24
+ self.conv3s = nn.Conv2d(256, 256, 3, padding=1)
25
+ self.conv4f = nn.Conv2d(256, 512, 3, padding=1)
26
+ self.conv4s = nn.Conv2d(512, 512, 3, padding=1)
27
+ self.drop4 = nn.Dropout(dropout)
28
+ self.conv5f = nn.Conv2d(512, 1024, 3, padding=1)
29
+ self.conv5s = nn.Conv2d(1024, 1024, 3, padding=1)
30
+ self.drop5 = nn.Dropout(dropout)
31
+
32
+ self.conv6f = nn.Conv2d(1536, 512, 3, padding=1)
33
+ self.conv6s = nn.Conv2d(512, 512, 3, padding=1)
34
+ self.conv7f = nn.Conv2d(768, 256, 3, padding=1)
35
+ self.conv7s = nn.Conv2d(256, 256, 3, padding=1)
36
+ self.conv8f = nn.Conv2d(384, 128, 3, padding=1)
37
+ self.conv8s = nn.Conv2d(128, 128, 3, padding=1)
38
+ self.conv9f = nn.Conv2d(192, 64, 3, padding=1)
39
+ self.conv9s = nn.Conv2d(64, 64, 3, padding=1)
40
+ self.conv9out = nn.Conv2d(64, 2, 3, padding=1)
41
+ self.output = nn.Conv2d(2, out_channels, 1)
42
+ self.apply(self._initialize)
43
+
44
+ @staticmethod
45
+ def _initialize(module):
46
+ if isinstance(module, nn.Conv2d):
47
+ nn.init.kaiming_normal_(module.weight, mode="fan_in", nonlinearity="relu")
48
+ if module.bias is not None:
49
+ nn.init.zeros_(module.bias)
50
+
51
+ def forward(self, x):
52
+ if x.ndim != 4:
53
+ raise ValueError(f"RainNet expects BCHW input, got shape {tuple(x.shape)}")
54
+ if x.shape[1] != self.in_channels:
55
+ raise ValueError(f"RainNet expects {self.in_channels} channels, got {x.shape[1]}")
56
+ if x.shape[-2] % 16 or x.shape[-1] % 16:
57
+ raise ValueError(
58
+ f"Spatial dimensions must be divisible by 16, got {tuple(x.shape[-2:])}"
59
+ )
60
+
61
+ c1 = self.relu(self.conv1s(self.relu(self.conv1f(x))))
62
+ c2 = self.relu(self.conv2s(self.relu(self.conv2f(self.pool(c1)))))
63
+ c3 = self.relu(self.conv3s(self.relu(self.conv3f(self.pool(c2)))))
64
+ c4 = self.relu(self.conv4s(self.relu(self.conv4f(self.pool(c3)))))
65
+ c5 = self.relu(self.conv5s(self.relu(self.conv5f(self.pool(self.drop4(c4))))))
66
+ c5 = self.drop5(c5)
67
+
68
+ c6 = torch.cat((self.upsample(c5), c4), dim=1)
69
+ c6 = self.relu(self.conv6s(self.relu(self.conv6f(c6))))
70
+ c7 = torch.cat((self.upsample(c6), c3), dim=1)
71
+ c7 = self.relu(self.conv7s(self.relu(self.conv7f(c7))))
72
+ c8 = torch.cat((self.upsample(c7), c2), dim=1)
73
+ c8 = self.relu(self.conv8s(self.relu(self.conv8f(c8))))
74
+ c9 = torch.cat((self.upsample(c8), c1), dim=1)
75
+ c9 = self.relu(self.conv9s(self.relu(self.conv9f(c9))))
76
+ return self.output(self.relu(self.conv9out(c9)))
77
+
78
+
79
+ def build_rainnet(in_channels=4, out_channels=1, dropout=0.5, mode="regression"):
80
+ return RainNet(in_channels, out_channels, dropout, mode)
scripts/fake_data.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a compact, full-resolution synthetic RYDL-like sequence."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ import h5py
8
+ import numpy as np
9
+ import yaml
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+
13
+
14
+ def load_config():
15
+ with (ROOT / "conf/config.yaml").open(encoding="utf-8") as handle:
16
+ return yaml.safe_load(handle)
17
+
18
+
19
+ def precipitation_frame(y, x, step, rng):
20
+ cells = (
21
+ (220 + 4.2 * step, 260 + 6.0 * step, 70 + 0.5 * step, 45, 1.25),
22
+ (610 - 3.2 * step, 590 + 2.7 * step, 58, 82 - 0.4 * step, 0.85),
23
+ (430 + 1.5 * step, 710 - 4.0 * step, 42 + 0.6 * step, 55, 0.55),
24
+ )
25
+ field = np.zeros_like(x, dtype=np.float32)
26
+ for cy, cx, sy, sx, amplitude in cells:
27
+ evolution = 1.0 + 0.12 * np.sin((step + amplitude) / 3.0)
28
+ field += amplitude * evolution * np.exp(
29
+ -0.5 * (((x - cx) / sx) ** 2 + ((y - cy) / sy) ** 2)
30
+ )
31
+ # A smooth perturbation evolves with the cells without decorrelating frames.
32
+ phase = rng.uniform(-0.03, 0.03)
33
+ field *= 1.0 + 0.025 * np.sin(x / 35.0 + step / 4.0 + phase) * np.cos(y / 47.0)
34
+ return np.maximum(field, 0).astype(np.float32)
35
+
36
+
37
+ def main():
38
+ config = load_config()
39
+ data = config["data"]
40
+ np.random.seed(config["seed"])
41
+ rng = np.random.default_rng(config["seed"])
42
+ total = data["train_frames"] + data["val_frames"] + data["test_frames"]
43
+ height, width = data["raw_height"], data["raw_width"]
44
+ yy, xx = np.mgrid[:height, :width].astype(np.float32)
45
+ path = ROOT / data["path"]
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ start = datetime(2017, 1, 1)
48
+ keys = []
49
+ with h5py.File(path, "w") as handle:
50
+ handle.attrs["units"] = "mm/5min"
51
+ handle.attrs["interval_minutes"] = data["interval_minutes"]
52
+ for index in range(total):
53
+ key = (start + timedelta(minutes=index * data["interval_minutes"])).strftime(
54
+ "%Y%m%d%H%M"
55
+ )
56
+ keys.append(key)
57
+ handle.create_dataset(key, data=precipitation_frame(yy, xx, index, rng))
58
+ with h5py.File(path, "r") as handle:
59
+ first = handle[keys[0]][...]
60
+ print(f"Number of frames: {len(keys)}")
61
+ print(f"First key: {keys[0]}")
62
+ print(f"Last key: {keys[-1]}")
63
+ print(f"Frame shape: {first.shape}")
64
+ print(f"dtype: {first.dtype}")
65
+ print(f"min: {first.min():.8f}")
66
+ print(f"max: {first.max():.8f}")
67
+ print(f"mean: {first.mean():.8f}")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Checkpoint-backed one-step and autoregressive RainNet inference."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ import h5py
8
+ import numpy as np
9
+ import torch
10
+ import torch.nn.functional as F
11
+ import yaml
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(ROOT))
15
+ from model.rainnet import build_rainnet
16
+
17
+
18
+ def load_config():
19
+ with (ROOT / "conf/config.yaml").open(encoding="utf-8") as handle:
20
+ return yaml.safe_load(handle)
21
+
22
+
23
+ def inverse_transform(tensor):
24
+ return torch.clamp(torch.exp(tensor) - 0.01, min=0.0)
25
+
26
+
27
+ def describe(name, array):
28
+ print(
29
+ f"{name}: shape={array.shape}, dtype={array.dtype}, "
30
+ f"min={array.min():.8f}, max={array.max():.8f}, mean={array.mean():.8f}"
31
+ )
32
+
33
+
34
+ def main():
35
+ config = load_config()
36
+ torch.manual_seed(config["seed"])
37
+ requested_device = config["device"]
38
+ if requested_device == "auto":
39
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
40
+ else:
41
+ device = torch.device(requested_device)
42
+ checkpoint_path = ROOT / config["inference"]["checkpoint"]
43
+ if not checkpoint_path.exists():
44
+ raise FileNotFoundError(f"Required checkpoint does not exist: {checkpoint_path}")
45
+ model = build_rainnet(**config["model"]).to(device)
46
+ checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
47
+ model.load_state_dict(checkpoint["model_state_dict"])
48
+ model.eval()
49
+ print("checkpoint_loaded: True")
50
+ print(f"checkpoint path: {checkpoint_path}")
51
+
52
+ data = config["data"]
53
+ data_path = ROOT / data["path"]
54
+ with h5py.File(data_path, "r") as handle:
55
+ keys = sorted(handle.keys())
56
+ test_start = data["train_frames"] + data["val_frames"]
57
+ test_keys = keys[test_start : test_start + data["test_frames"]]
58
+ raw = np.stack([handle[key][...] for key in test_keys]).astype(np.float32)
59
+ needed = data["input_steps"] + config["inference"]["rollout_steps"]
60
+ if len(raw) < needed:
61
+ raise ValueError(f"Inference needs {needed} test frames, found {len(raw)}")
62
+
63
+ inputs = torch.from_numpy(raw[: data["input_steps"]]).unsqueeze(0).to(device)
64
+ targets = raw[data["input_steps"] : needed]
65
+ log_window = torch.log(inputs + 0.01)
66
+ pad_h = data["padded_height"] - data["raw_height"]
67
+ pad_w = data["padded_width"] - data["raw_width"]
68
+ pad = (pad_w // 2, pad_w - pad_w // 2, pad_h // 2, pad_h - pad_h // 2)
69
+ crop = (pad_h // 2, pad_w // 2)
70
+ predictions = []
71
+ with torch.inference_mode():
72
+ for _ in range(config["inference"]["rollout_steps"]):
73
+ padded = F.pad(log_window, pad, mode="reflect")
74
+ padded_prediction = model(padded)
75
+ prediction = padded_prediction[
76
+ :, :, crop[0] : crop[0] + data["raw_height"], crop[1] : crop[1] + data["raw_width"]
77
+ ]
78
+ predictions.append(inverse_transform(prediction).squeeze(0).squeeze(0).cpu().numpy())
79
+ log_window = torch.cat((log_window[:, 1:], prediction), dim=1)
80
+ predictions = np.stack(predictions).astype(np.float32)
81
+ persistence = np.repeat(raw[data["input_steps"] - 1][None], len(predictions), axis=0).astype(np.float32)
82
+ output_dir = ROOT / config["inference"]["output_dir"]
83
+ output_dir.mkdir(parents=True, exist_ok=True)
84
+ np.save(output_dir / "inputs.npy", raw[: data["input_steps"]])
85
+ np.save(output_dir / "predictions.npy", predictions)
86
+ np.save(output_dir / "targets.npy", targets)
87
+ np.save(output_dir / "persistence.npy", persistence)
88
+ metadata = {
89
+ "units": "mm/5min",
90
+ "interval_minutes": data["interval_minutes"],
91
+ "rollout_steps": len(predictions),
92
+ "input_keys": test_keys[: data["input_steps"]],
93
+ "target_keys": test_keys[data["input_steps"] : needed],
94
+ "checkpoint": str(checkpoint_path),
95
+ }
96
+ with (output_dir / "metadata.json").open("w", encoding="utf-8") as handle:
97
+ json.dump(metadata, handle, indent=2)
98
+ describe("inputs", raw[: data["input_steps"]])
99
+ describe("predictions", predictions)
100
+ describe("targets", targets)
101
+ describe("persistence", persistence)
102
+ print(f"One-step inference shape: {predictions[:1].shape}")
103
+ print(f"Autoregressive rollout steps: {len(predictions)}")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
scripts/result.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate actual RainNet rollout outputs and create diagnostic figures."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import matplotlib
7
+ matplotlib.use("Agg")
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import torch
11
+ import torch.nn.functional as F
12
+ import yaml
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+
16
+
17
+ def load_config():
18
+ with (ROOT / "conf/config.yaml").open(encoding="utf-8") as handle:
19
+ return yaml.safe_load(handle)
20
+
21
+
22
+ def csi(prediction, target, threshold):
23
+ pred_event, target_event = prediction >= threshold, target >= threshold
24
+ hits = np.logical_and(pred_event, target_event).sum(dtype=np.float64)
25
+ false_alarms = np.logical_and(pred_event, ~target_event).sum(dtype=np.float64)
26
+ misses = np.logical_and(~pred_event, target_event).sum(dtype=np.float64)
27
+ denominator = hits + false_alarms + misses
28
+ return float(hits / denominator) if denominator else 0.0
29
+
30
+
31
+ def fss(prediction, target, threshold, window):
32
+ pred = torch.from_numpy((prediction >= threshold).astype(np.float32))[None, None]
33
+ obs = torch.from_numpy((target >= threshold).astype(np.float32))[None, None]
34
+ if window > 1:
35
+ padding = window // 2
36
+ pred = F.avg_pool2d(pred, window, stride=1, padding=padding)
37
+ obs = F.avg_pool2d(obs, window, stride=1, padding=padding)
38
+ pred = pred[..., : prediction.shape[0], : prediction.shape[1]]
39
+ obs = obs[..., : target.shape[0], : target.shape[1]]
40
+ numerator = torch.sum((pred - obs) ** 2)
41
+ denominator = torch.sum(pred**2) + torch.sum(obs**2)
42
+ return float(1.0 - numerator / denominator) if denominator > 0 else 0.0
43
+
44
+
45
+ def metric_series(predictions, targets, thresholds, windows):
46
+ output = []
47
+ for index, (prediction, target) in enumerate(zip(predictions, targets)):
48
+ pred_rate, target_rate = prediction * 12.0, target * 12.0
49
+ output.append(
50
+ {
51
+ "lead_minutes": (index + 1) * 5,
52
+ "mae_mm_h": float(np.mean(np.abs(pred_rate - target_rate), dtype=np.float64)),
53
+ "csi": {str(t): csi(pred_rate, target_rate, t) for t in thresholds},
54
+ "fss": {
55
+ str(t): {str(w): fss(pred_rate, target_rate, t, w) for w in windows}
56
+ for t in thresholds
57
+ },
58
+ }
59
+ )
60
+ return output
61
+
62
+
63
+ def main():
64
+ config = load_config()
65
+ output_dir = ROOT / config["inference"]["output_dir"]
66
+ required = ["inputs.npy", "predictions.npy", "targets.npy", "persistence.npy"]
67
+ missing = [name for name in required if not (output_dir / name).exists()]
68
+ if missing:
69
+ raise FileNotFoundError(f"Missing inference outputs: {missing}")
70
+ inputs = np.load(output_dir / "inputs.npy")
71
+ predictions = np.load(output_dir / "predictions.npy")
72
+ targets = np.load(output_dir / "targets.npy")
73
+ persistence = np.load(output_dir / "persistence.npy")
74
+ for name, array in (("prediction", predictions), ("target", targets), ("persistence", persistence)):
75
+ if array.shape != predictions.shape or not np.isfinite(array).all():
76
+ raise ValueError(f"Invalid {name}: shape={array.shape}, finite={np.isfinite(array).all()}")
77
+ thresholds = config["evaluation"]["thresholds_mm_h"]
78
+ windows = config["evaluation"]["fss_windows_km"]
79
+ rainnet_metrics = metric_series(predictions, targets, thresholds, windows)
80
+ persistence_metrics = metric_series(persistence, targets, thresholds, windows)
81
+ metrics = {
82
+ "units": "mm/h",
83
+ "rainnet": rainnet_metrics,
84
+ "persistence": persistence_metrics,
85
+ }
86
+ result_dir = ROOT / config["evaluation"]["result_dir"]
87
+ result_dir.mkdir(parents=True, exist_ok=True)
88
+ with (result_dir / "metrics.json").open("w", encoding="utf-8") as handle:
89
+ json.dump(metrics, handle, indent=2)
90
+
91
+ history_path = result_dir / "train_history.json"
92
+ with history_path.open(encoding="utf-8") as handle:
93
+ history = json.load(handle)
94
+ fig, ax = plt.subplots(figsize=(6, 4))
95
+ ax.plot(history["train_loss"], marker="o", label="Train")
96
+ ax.plot(history["validation_loss"], marker="o", label="Validation")
97
+ ax.set(xlabel="Epoch", ylabel="Log-Cosh loss", title="RainNet smoke training")
98
+ ax.legend()
99
+ fig.tight_layout()
100
+ fig.savefig(result_dir / "loss.png", dpi=150)
101
+ plt.close(fig)
102
+
103
+ selected = [0, 5, 11]
104
+ fig, axes = plt.subplots(3, 5, figsize=(16, 10))
105
+ for row, index in enumerate(selected):
106
+ panels = [inputs[-1], targets[index], predictions[index], persistence[index], predictions[index] - targets[index]]
107
+ titles = ["Last Input", "Truth", "RainNet Prediction", "Persistence", "Prediction Error"]
108
+ for axis, panel, title in zip(axes[row], panels, titles):
109
+ image = axis.imshow(panel, cmap="RdBu_r" if title == "Prediction Error" else "Blues")
110
+ axis.set_title(f"{title}\n{(index + 1) * 5} min")
111
+ axis.axis("off")
112
+ fig.colorbar(image, ax=axis, fraction=0.046)
113
+ fig.tight_layout()
114
+ fig.savefig(result_dir / "forecast_comparison.png", dpi=120)
115
+ plt.close(fig)
116
+
117
+ leads = [item["lead_minutes"] for item in rainnet_metrics]
118
+ fig, axes = plt.subplots(1, 3, figsize=(15, 4))
119
+ axes[0].plot(leads, [item["mae_mm_h"] for item in rainnet_metrics], label="RainNet")
120
+ axes[0].plot(leads, [item["mae_mm_h"] for item in persistence_metrics], label="Persistence")
121
+ axes[0].set(title="MAE", xlabel="Lead time (min)", ylabel="mm/h")
122
+ for threshold in thresholds:
123
+ axes[1].plot(leads, [item["csi"][str(threshold)] for item in rainnet_metrics], label=str(threshold))
124
+ axes[2].plot(leads, [item["fss"][str(threshold)]["20"] for item in rainnet_metrics], label=str(threshold))
125
+ axes[1].set(title="RainNet CSI", xlabel="Lead time (min)", ylabel="CSI")
126
+ axes[2].set(title="RainNet FSS (20 km)", xlabel="Lead time (min)", ylabel="FSS")
127
+ axes[0].legend()
128
+ axes[1].legend(title="mm/h", fontsize=7)
129
+ axes[2].legend(title="mm/h", fontsize=7)
130
+ fig.tight_layout()
131
+ fig.savefig(result_dir / "metrics.png", dpi=150)
132
+ plt.close(fig)
133
+
134
+ print(f"Prediction shape: {predictions.shape}")
135
+ print(f"Target shape: {targets.shape}")
136
+ print(f"Persistence shape: {persistence.shape}")
137
+ for index in selected:
138
+ item = rainnet_metrics[index]
139
+ print(f"Lead {item['lead_minutes']} min MAE: {item['mae_mm_h']:.8f} mm/h")
140
+ print(f"Lead {item['lead_minutes']} min CSI: {item['csi']}")
141
+ print(f"Lead {item['lead_minutes']} min FSS: {item['fss']}")
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
scripts/train.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train RainNet on contiguous windows from an RYDL-style HDF5 file."""
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ from pathlib import Path
7
+ import random
8
+ import sys
9
+
10
+ import h5py
11
+ import numpy as np
12
+ import torch
13
+ from torch import nn
14
+ import torch.nn.functional as F
15
+ from torch.nn.parallel import DistributedDataParallel
16
+ from torch.utils.data import DataLoader, Dataset, DistributedSampler
17
+ import yaml
18
+
19
+ ROOT = Path(__file__).resolve().parents[1]
20
+ sys.path.insert(0, str(ROOT))
21
+ from model.rainnet import build_rainnet
22
+
23
+
24
+ def load_config():
25
+ with (ROOT / "conf/config.yaml").open(encoding="utf-8") as handle:
26
+ return yaml.safe_load(handle)
27
+
28
+
29
+ def seed_everything(seed):
30
+ random.seed(seed)
31
+ np.random.seed(seed)
32
+ torch.manual_seed(seed)
33
+ if torch.cuda.is_available():
34
+ torch.cuda.manual_seed_all(seed)
35
+
36
+
37
+ def setup_device(config):
38
+ distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
39
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
40
+ if distributed:
41
+ torch.distributed.init_process_group(backend="nccl" if torch.cuda.is_available() else "gloo")
42
+ if torch.cuda.is_available() and config["device"] in ("auto", "cuda"):
43
+ device = torch.device("cuda", local_rank)
44
+ torch.cuda.set_device(device)
45
+ else:
46
+ device = torch.device("cpu")
47
+ return device, distributed, local_rank
48
+
49
+
50
+ class RainNetDataset(Dataset):
51
+ def __init__(self, path, keys, input_steps=4):
52
+ self.path = path
53
+ self.keys = list(keys)
54
+ self.input_steps = input_steps
55
+ if len(self.keys) <= input_steps:
56
+ raise ValueError("A split needs at least input_steps + 1 frames")
57
+
58
+ def __len__(self):
59
+ return len(self.keys) - self.input_steps
60
+
61
+ def __getitem__(self, index):
62
+ with h5py.File(self.path, "r") as handle:
63
+ inputs = np.stack(
64
+ [handle[key][...] for key in self.keys[index : index + self.input_steps]]
65
+ )
66
+ target_key = self.keys[index + self.input_steps]
67
+ target = handle[target_key][...][None]
68
+ return torch.from_numpy(inputs), torch.from_numpy(target), target_key
69
+
70
+
71
+ class LogCoshLoss(nn.Module):
72
+ def forward(self, prediction, target):
73
+ error = torch.abs(prediction - target)
74
+ return (error + F.softplus(-2.0 * error) - math.log(2.0)).mean()
75
+
76
+
77
+ def transform_and_pad(tensor, pad):
78
+ return F.pad(torch.log(tensor + 0.01), pad, mode="reflect")
79
+
80
+
81
+ def run_epoch(model, loader, criterion, device, pad, max_batches, optimizer=None):
82
+ training = optimizer is not None
83
+ model.train(training)
84
+ losses = []
85
+ parameter_updated = False
86
+ first_shapes = None
87
+ context = torch.enable_grad() if training else torch.no_grad()
88
+ with context:
89
+ for batch_index, (inputs, targets, target_keys) in enumerate(loader):
90
+ if batch_index >= max_batches:
91
+ break
92
+ inputs, targets = inputs.to(device), targets.to(device)
93
+ padded_inputs = transform_and_pad(inputs, pad)
94
+ padded_targets = transform_and_pad(targets, pad)
95
+ if training:
96
+ optimizer.zero_grad(set_to_none=True)
97
+ output = model(padded_inputs)
98
+ loss = criterion(output, padded_targets)
99
+ if not torch.isfinite(loss):
100
+ raise RuntimeError(f"Non-finite loss: {loss.item()}")
101
+ if first_shapes is None:
102
+ first_shapes = (inputs.shape, targets.shape, padded_inputs.shape, output.shape, target_keys[0])
103
+ if training:
104
+ tracked = next(model.parameters()).detach().clone()
105
+ loss.backward()
106
+ optimizer.step()
107
+ parameter_updated = parameter_updated or not torch.equal(tracked, next(model.parameters()).detach())
108
+ losses.append(loss.item())
109
+ return float(np.mean(losses)), parameter_updated, first_shapes
110
+
111
+
112
+ def save_checkpoint(path, model, optimizer, epoch, val_loss, config):
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ state_model = model.module if isinstance(model, DistributedDataParallel) else model
115
+ torch.save(
116
+ {
117
+ "model_state_dict": state_model.state_dict(),
118
+ "optimizer_state_dict": optimizer.state_dict(),
119
+ "epoch": epoch,
120
+ "validation_loss": val_loss,
121
+ "config": config,
122
+ },
123
+ path,
124
+ )
125
+
126
+
127
+ def main():
128
+ config = load_config()
129
+ seed_everything(config["seed"])
130
+ device, distributed, local_rank = setup_device(config)
131
+ is_main = local_rank == 0
132
+ data = config["data"]
133
+ path = ROOT / data["path"]
134
+ if not path.exists():
135
+ raise FileNotFoundError(f"Fake data not found: {path}; run scripts/fake_data.py")
136
+ with h5py.File(path, "r") as handle:
137
+ keys = sorted(handle.keys())
138
+ train_end = data["train_frames"]
139
+ val_end = train_end + data["val_frames"]
140
+ train_set = RainNetDataset(path, keys[:train_end], data["input_steps"])
141
+ val_set = RainNetDataset(path, keys[train_end:val_end], data["input_steps"])
142
+ train_sampler = DistributedSampler(train_set, shuffle=True) if distributed else None
143
+ train_loader = DataLoader(
144
+ train_set,
145
+ batch_size=config["train"]["batch_size"],
146
+ shuffle=train_sampler is None,
147
+ sampler=train_sampler,
148
+ num_workers=data["num_workers"],
149
+ )
150
+ val_loader = DataLoader(val_set, batch_size=1, shuffle=False, num_workers=data["num_workers"])
151
+ model = build_rainnet(**config["model"]).to(device)
152
+ parameter_count = sum(parameter.numel() for parameter in model.parameters())
153
+ if distributed:
154
+ model = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None)
155
+ criterion = LogCoshLoss()
156
+ optimizer = torch.optim.Adam(model.parameters(), lr=config["train"]["learning_rate"])
157
+ pad_h = data["padded_height"] - data["raw_height"]
158
+ pad_w = data["padded_width"] - data["raw_width"]
159
+ pad = (pad_w // 2, pad_w - pad_w // 2, pad_h // 2, pad_h - pad_h // 2)
160
+ history = {"train_loss": [], "validation_loss": [], "learning_rate": []}
161
+ best_loss = float("inf")
162
+ any_update = False
163
+ for epoch in range(config["train"]["epochs"]):
164
+ if train_sampler:
165
+ train_sampler.set_epoch(epoch)
166
+ train_loss, updated, shapes = run_epoch(
167
+ model, train_loader, criterion, device, pad, config["train"]["max_train_batches"], optimizer
168
+ )
169
+ val_loss, _, _ = run_epoch(
170
+ model, val_loader, criterion, device, pad, config["train"]["max_valid_batches"]
171
+ )
172
+ any_update = any_update or updated
173
+ history["train_loss"].append(train_loss)
174
+ history["validation_loss"].append(val_loss)
175
+ history["learning_rate"].append(optimizer.param_groups[0]["lr"])
176
+ if is_main:
177
+ last_path = ROOT / config["train"]["checkpoint_last"]
178
+ best_path = ROOT / config["train"]["checkpoint_best"]
179
+ save_checkpoint(last_path, model, optimizer, epoch + 1, val_loss, config)
180
+ if val_loss < best_loss:
181
+ best_loss = val_loss
182
+ save_checkpoint(best_path, model, optimizer, epoch + 1, val_loss, config)
183
+ result_dir = ROOT / config["evaluation"]["result_dir"]
184
+ result_dir.mkdir(parents=True, exist_ok=True)
185
+ with (result_dir / "train_history.json").open("w", encoding="utf-8") as handle:
186
+ json.dump(history, handle, indent=2)
187
+ print(f"Device: {device}")
188
+ print(f"Input shape: {tuple(shapes[0])}")
189
+ print(f"Target shape: {tuple(shapes[1])}")
190
+ print(f"Target key (i+4): {shapes[4]}")
191
+ print(f"Padded input shape: {tuple(shapes[2])}")
192
+ print(f"Model output shape: {tuple(shapes[3])}")
193
+ print(f"Parameter count: {parameter_count}")
194
+ print(f"Epoch: {epoch + 1}")
195
+ print(f"Train loss: {train_loss:.8f}")
196
+ print(f"Validation loss: {val_loss:.8f}")
197
+ print(f"Learning rate: {optimizer.param_groups[0]['lr']}")
198
+ print(f"parameter_update_detected: {any_update}")
199
+ print(f"Checkpoint path: {best_path}")
200
+ if not any_update:
201
+ raise RuntimeError("No model parameter changed after optimizer.step()")
202
+ if is_main:
203
+ reload_model = build_rainnet(**config["model"])
204
+ checkpoint = torch.load(
205
+ ROOT / config["train"]["checkpoint_best"], map_location="cpu", weights_only=False
206
+ )
207
+ reload_model.load_state_dict(checkpoint["model_state_dict"])
208
+ print("checkpoint_reload_after_training: True")
209
+ if distributed:
210
+ torch.distributed.destroy_process_group()
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
weight/.gitkeep ADDED
File without changes