Publish RainNet model package
Browse files- .gitattributes +6 -27
- README.md +187 -0
- conf/config.yaml +59 -0
- configuration.json +1 -0
- model/rainnet.py +80 -0
- scripts/fake_data.py +71 -0
- scripts/inference.py +107 -0
- scripts/result.py +145 -0
- scripts/train.py +214 -0
- weight/.gitkeep +0 -0
.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 |
-
*.
|
| 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 |
-
|
| 27 |
-
*.
|
|
|
|
|
|
|
|
|
|
| 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
|