Upload folder using huggingface_hub
Browse files- .gitattributes +1 -34
- README.md +147 -0
- conf/config.yaml +46 -0
- config.json +18 -0
- configuration.json +11 -0
- model/unetdif.py +115 -0
- scripts/fake_data.py +79 -0
- scripts/inference.py +62 -0
- scripts/result.py +86 -0
- scripts/train.py +115 -0
- weight/.gitkeep +0 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,2 @@
|
|
| 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 |
-
*.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: cc-by-nc-nd-4.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- OneScience
|
| 7 |
+
- Earth Science
|
| 8 |
+
- Rainfall Correction
|
| 9 |
+
- UnetDif
|
| 10 |
+
frameworks: PyTorch
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<strong><span style="font-size: 30px;">UnetDif</span></strong>
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
# Model Introduction
|
| 18 |
+
|
| 19 |
+
UnetDif uses the paper's bias-targeted multitask U-Net to correct short-range heavy rainfall forecasts.
|
| 20 |
+
|
| 21 |
+
Paper: Bias-targeted deep learning enhances short-range heavy rainfall forecasts
|
| 22 |
+
https://doi.org/10.1038/s41612-026-01366-z
|
| 23 |
+
|
| 24 |
+
# Model Description
|
| 25 |
+
|
| 26 |
+
The method reproduced by UnetDif was proposed by research teams from Zhejiang Meteorological Observatory, the National Meteorological Center, and Zhejiang University. The paper constructs Yangtze River Delta samples from European Centre for Medium-Range Weather Forecasts (ECMWF) fields, China Meteorological Administration Multi-source Precipitation Analysis (CMPA), and terrain data. The model learns `ECMWF - CMPA` rainfall bias and corrects three-hour precipitation through a shared U-Net backbone with four heads for dry areas, false alarms, positive bias, and negative bias.
|
| 27 |
+
|
| 28 |
+
# Use Cases
|
| 29 |
+
|
| 30 |
+
| Use Case | Description |
|
| 31 |
+
| :---: | :--- |
|
| 32 |
+
| Three-hour rainfall-bias correction | Generate corrected ECMWF rainfall from 39-channel inputs on the fixed Yangtze River Delta grid. |
|
| 33 |
+
| ModelScope/OneCode execution | Validate synthetic-data generation, six-loss training, inference, evaluation, and visualization in ModelScope or OneCode environments. |
|
| 34 |
+
| Hugging Face/OneCode execution | Run the complete engineering workflow in Hugging Face or OneCode environments. |
|
| 35 |
+
| Multi-GPU training | Launch distributed data-parallel training with `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/UnetDif --local-dir ./UnetDif
|
| 49 |
+
cd UnetDif
|
| 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 contain ECMWF weather forecasts, CMPA precipitation observations, and static terrain. Each sample uses 39 input channels, with corresponding CMPA precipitation and ECMWF precipitation bias as targets. The data use a `56×56` Yangtze River Delta grid at `0.125°` resolution. Each rain day contains eight consecutive three-hour periods, with precipitation in `mm/3h`. Synthetic data validate training, inference, and evaluation only and do not represent the distributions of ECMWF, CMPA, real terrain, 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 |
+
Training learns dry-area, false-alarm, and signed precipitation-bias tasks and saves the model checkpoint and training metrics. Results are saved to:
|
| 101 |
+
|
| 102 |
+
```text
|
| 103 |
+
result/checkpoints/unetdif.pt
|
| 104 |
+
result/training/metrics.json
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### Trained Weights
|
| 108 |
+
|
| 109 |
+
This repository does not include weights under `weight/`. The paper does not provide a confirmed official checkpoint, and the engineering checkpoint trained on synthetic data is for workflow validation only and is not claimed to be compatible with external weights.
|
| 110 |
+
|
| 111 |
+
### Inference
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
python scripts/inference.py
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Inference loads the checkpoint and applies all four output heads under `bias = ECMWF - CMPA` to produce nonnegative corrected rainfall. Results are saved to:
|
| 118 |
+
|
| 119 |
+
```text
|
| 120 |
+
result/output/predictions.npz
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
### Evaluation and Visualization
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
python scripts/result.py
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
Evaluation produces categorical and spatial-skill metrics for individual three-hour periods and 24-hour accumulated precipitation and saves structured results. It also generates comparisons of ECMWF, CMPA, corrected precipitation, and errors. Synthetic-data results validate the engineering workflow only and do not represent formal paper performance.
|
| 130 |
+
|
| 131 |
+
```text
|
| 132 |
+
result/evaluation/metrics.json
|
| 133 |
+
result/evaluation/comparison.png
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
# Official OneScience Information
|
| 137 |
+
|
| 138 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 139 |
+
| --- | --- | --- |
|
| 140 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 141 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 142 |
+
|
| 143 |
+
# Citation and License
|
| 144 |
+
|
| 145 |
+
This repository is an independent engineering reproduction of the public UnetDif specifications.
|
| 146 |
+
|
| 147 |
+
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,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
seed: 42
|
| 2 |
+
data:
|
| 3 |
+
root: data
|
| 4 |
+
format_version: biastarget_rainfall_y_rd_v1
|
| 5 |
+
region: YRD
|
| 6 |
+
grid_resolution_degrees: 0.125
|
| 7 |
+
boundary_mode: half_open_engineering_assumption
|
| 8 |
+
channels: 39
|
| 9 |
+
height: 56
|
| 10 |
+
width: 56
|
| 11 |
+
time_steps: 8
|
| 12 |
+
interval_hours: 3
|
| 13 |
+
lead_hours: [15, 18, 21, 24, 27, 30, 33, 36]
|
| 14 |
+
precipitation_channel: 37
|
| 15 |
+
rain_threshold_mm_3h: 0.1
|
| 16 |
+
rain_days: {train: 2, val: 1, test: 1}
|
| 17 |
+
model:
|
| 18 |
+
in_channels: 39
|
| 19 |
+
base_channels: 8
|
| 20 |
+
depth: 3
|
| 21 |
+
paper_model:
|
| 22 |
+
name: UnetDif
|
| 23 |
+
base_channels: null
|
| 24 |
+
depth: null
|
| 25 |
+
train:
|
| 26 |
+
epochs: 2
|
| 27 |
+
batch_size: 16
|
| 28 |
+
learning_rate: 0.0001
|
| 29 |
+
optimizer: Adam
|
| 30 |
+
optimizer_source: engineering_assumption
|
| 31 |
+
num_workers: 0
|
| 32 |
+
loss:
|
| 33 |
+
focal_alpha: 0.25
|
| 34 |
+
focal_gamma: 2.0
|
| 35 |
+
first_epoch_component_normalization: true
|
| 36 |
+
inference:
|
| 37 |
+
batch_size: 16
|
| 38 |
+
probability_threshold: 0.5
|
| 39 |
+
evaluation:
|
| 40 |
+
thresholds_mm_24h: [10, 25, 50]
|
| 41 |
+
fss_windows: [1, 3, 7, 11, 15, 19, 23, 27]
|
| 42 |
+
paths:
|
| 43 |
+
checkpoint: result/checkpoints/unetdif.pt
|
| 44 |
+
training_metrics: result/training/metrics.json
|
| 45 |
+
inference: result/output/predictions.npz
|
| 46 |
+
evaluation_dir: result/evaluation
|
config.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "UnetDif",
|
| 3 |
+
"model_type": "unetdif",
|
| 4 |
+
"architectures": ["UnetDif"],
|
| 5 |
+
"framework": "PyTorch",
|
| 6 |
+
"domain": "atmosphere",
|
| 7 |
+
"task": "ECMWF precipitation bias correction",
|
| 8 |
+
"license": "cc-by-nc-nd-4.0",
|
| 9 |
+
"implementation": {
|
| 10 |
+
"entry_point": "model/unetdif.py",
|
| 11 |
+
"train_script": "scripts/train.py",
|
| 12 |
+
"inference_script": "scripts/inference.py",
|
| 13 |
+
"evaluation_script": "scripts/result.py",
|
| 14 |
+
"synthetic_data_script": "scripts/fake_data.py"
|
| 15 |
+
},
|
| 16 |
+
"architecture": {"input_channels": 39, "output_heads": 4, "base_channels": 8, "depth": 3, "paper_base_channels": null, "paper_depth": null},
|
| 17 |
+
"data": {"region": "YRD", "resolution_degrees": 0.125, "shape": ["rain_day", 8, 39, 56, 56], "lead_hours": [15, 18, 21, 24, 27, 30, 33, 36]}
|
| 18 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "precipitation_bias_correction",
|
| 4 |
+
"model": "UnetDif",
|
| 5 |
+
"input_format": "BCHW",
|
| 6 |
+
"default_config": "conf/config.yaml",
|
| 7 |
+
"training": "scripts/train.py",
|
| 8 |
+
"inference": "scripts/inference.py",
|
| 9 |
+
"evaluation": "scripts/result.py",
|
| 10 |
+
"distributed_training": "torchrun"
|
| 11 |
+
}
|
model/unetdif.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bias-target U-Net (UnetDif) for ECMWF precipitation correction."""
|
| 2 |
+
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch import nn
|
| 7 |
+
from torch.nn import functional as F
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ConvBlock(nn.Sequential):
|
| 11 |
+
def __init__(self, in_channels: int, out_channels: int):
|
| 12 |
+
super().__init__(
|
| 13 |
+
nn.Conv2d(in_channels, out_channels, 3, padding=1, bias=False),
|
| 14 |
+
nn.BatchNorm2d(out_channels),
|
| 15 |
+
nn.ReLU(inplace=True),
|
| 16 |
+
nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False),
|
| 17 |
+
nn.BatchNorm2d(out_channels),
|
| 18 |
+
nn.ReLU(inplace=True),
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class UnetDif(nn.Module):
|
| 23 |
+
"""Paper model name; paper width/depth are unavailable and configurable."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, in_channels: int = 39, base_channels: int = 8, depth: int = 3):
|
| 26 |
+
super().__init__()
|
| 27 |
+
if depth < 1:
|
| 28 |
+
raise ValueError("depth must be at least one")
|
| 29 |
+
widths = [base_channels * (2**level) for level in range(depth + 1)]
|
| 30 |
+
self.encoders = nn.ModuleList()
|
| 31 |
+
current = in_channels
|
| 32 |
+
for width in widths[:-1]:
|
| 33 |
+
self.encoders.append(ConvBlock(current, width))
|
| 34 |
+
current = width
|
| 35 |
+
self.pool = nn.MaxPool2d(2)
|
| 36 |
+
self.bottleneck = ConvBlock(widths[-2], widths[-1])
|
| 37 |
+
self.upconvs = nn.ModuleList()
|
| 38 |
+
self.decoders = nn.ModuleList()
|
| 39 |
+
for level in range(depth - 1, -1, -1):
|
| 40 |
+
self.upconvs.append(nn.ConvTranspose2d(widths[level + 1], widths[level], 2, stride=2))
|
| 41 |
+
self.decoders.append(ConvBlock(widths[level] * 2, widths[level]))
|
| 42 |
+
self.head = nn.Conv2d(widths[0], 4, 1)
|
| 43 |
+
|
| 44 |
+
def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]:
|
| 45 |
+
skips = []
|
| 46 |
+
for encoder in self.encoders:
|
| 47 |
+
x = encoder(x)
|
| 48 |
+
skips.append(x)
|
| 49 |
+
x = self.pool(x)
|
| 50 |
+
x = self.bottleneck(x)
|
| 51 |
+
for upconv, decoder, skip in zip(self.upconvs, self.decoders, reversed(skips)):
|
| 52 |
+
x = upconv(x)
|
| 53 |
+
if x.shape[-2:] != skip.shape[-2:]:
|
| 54 |
+
x = F.interpolate(x, size=skip.shape[-2:], mode="bilinear", align_corners=False)
|
| 55 |
+
x = decoder(torch.cat((skip, x), dim=1))
|
| 56 |
+
output = self.head(x)
|
| 57 |
+
return {
|
| 58 |
+
"dry_logits": output[:, 0],
|
| 59 |
+
"false_alarm_logits": output[:, 1],
|
| 60 |
+
"positive_bias": F.softplus(output[:, 2]),
|
| 61 |
+
"negative_bias": F.softplus(output[:, 3]),
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def make_masks(ecmwf: torch.Tensor, cmpa: torch.Tensor, rain_threshold: float = 0.1) -> Dict[str, torch.Tensor]:
|
| 66 |
+
"""Build four disjoint masks from bias = ECMWF - CMPA."""
|
| 67 |
+
ecmwf_wet = ecmwf >= rain_threshold
|
| 68 |
+
cmpa_wet = cmpa >= rain_threshold
|
| 69 |
+
bias = ecmwf - cmpa
|
| 70 |
+
return {
|
| 71 |
+
"dry": (~ecmwf_wet & ~cmpa_wet).float(),
|
| 72 |
+
"false_alarm": (ecmwf_wet & ~cmpa_wet).float(),
|
| 73 |
+
"positive": (cmpa_wet & (bias >= 0)).float(),
|
| 74 |
+
"negative": (cmpa_wet & (bias < 0)).float(),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def apply_correction(
|
| 79 |
+
ecmwf: torch.Tensor, heads: Dict[str, torch.Tensor], probability_threshold: float = 0.5
|
| 80 |
+
) -> torch.Tensor:
|
| 81 |
+
"""Apply predicted signed bias and dry masks, then enforce non-negativity."""
|
| 82 |
+
corrected = (ecmwf - heads["positive_bias"] + heads["negative_bias"]).clamp_min(0)
|
| 83 |
+
dry = torch.sigmoid(heads["dry_logits"]) >= probability_threshold
|
| 84 |
+
false_alarm = torch.sigmoid(heads["false_alarm_logits"]) >= probability_threshold
|
| 85 |
+
return corrected.masked_fill(dry | false_alarm, 0)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _focal(logits: torch.Tensor, target: torch.Tensor, alpha: float, gamma: float) -> torch.Tensor:
|
| 89 |
+
bce = F.binary_cross_entropy_with_logits(logits, target, reduction="none")
|
| 90 |
+
probability = torch.sigmoid(logits)
|
| 91 |
+
pt = probability * target + (1 - probability) * (1 - target)
|
| 92 |
+
alpha_t = alpha * target + (1 - alpha) * (1 - target)
|
| 93 |
+
return (alpha_t * (1 - pt).pow(gamma) * bce).mean()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _masked_mse(prediction: torch.Tensor, target: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
|
| 97 |
+
return (((prediction - target).square() * mask).sum() / mask.sum().clamp_min(1))
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def loss_components(
|
| 101 |
+
heads: Dict[str, torch.Tensor], ecmwf: torch.Tensor, cmpa: torch.Tensor,
|
| 102 |
+
rain_threshold: float = 0.1, focal_alpha: float = 0.25, focal_gamma: float = 2.0,
|
| 103 |
+
) -> Dict[str, torch.Tensor]:
|
| 104 |
+
masks = make_masks(ecmwf, cmpa, rain_threshold)
|
| 105 |
+
bias = ecmwf - cmpa
|
| 106 |
+
continuous = (ecmwf - heads["positive_bias"] + heads["negative_bias"]).clamp_min(0)
|
| 107 |
+
dry_target = masks["dry"] + masks["false_alarm"]
|
| 108 |
+
return {
|
| 109 |
+
"dry_focal": _focal(heads["dry_logits"], masks["dry"], focal_alpha, focal_gamma),
|
| 110 |
+
"false_alarm_focal": _focal(heads["false_alarm_logits"], masks["false_alarm"], focal_alpha, focal_gamma),
|
| 111 |
+
"positive_mse": _masked_mse(heads["positive_bias"], bias.clamp_min(0), masks["positive"]),
|
| 112 |
+
"negative_mse": _masked_mse(heads["negative_bias"], (-bias).clamp_min(0), masks["negative"]),
|
| 113 |
+
"dry_mae": (continuous.abs() * dry_target).sum() / dry_target.sum().clamp_min(1),
|
| 114 |
+
"all_mae": F.l1_loss(continuous, cmpa),
|
| 115 |
+
}
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate grouped synthetic rain days at the real 39x56x56 input shape."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import yaml
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def smooth(field: np.ndarray, rounds: int = 2) -> np.ndarray:
|
| 13 |
+
for _ in range(rounds):
|
| 14 |
+
field = sum(np.roll(np.roll(field, y, -2), x, -1) for y, x in ((0, 0), (1, 0), (-1, 0), (0, 1), (0, -1))) / 5
|
| 15 |
+
return field
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def make_day(rng: np.random.Generator, steps: int, height: int, width: int):
|
| 19 |
+
yy, xx = np.mgrid[-1:1:complex(height), -1:1:complex(width)].astype(np.float32)
|
| 20 |
+
terrain = np.clip(0.55 + 0.25 * yy + 0.16 * np.sin(2.5 * xx) - 0.12 * np.cos(3 * yy), 0, 1)
|
| 21 |
+
center = rng.uniform(-0.45, 0.45, 2)
|
| 22 |
+
velocity = rng.uniform(-0.10, 0.10, 2)
|
| 23 |
+
width0 = rng.uniform(0.13, 0.27)
|
| 24 |
+
amplitude = rng.uniform(12, 28)
|
| 25 |
+
inputs = np.empty((steps, 39, height, width), np.float32)
|
| 26 |
+
targets = np.empty((steps, height, width), np.float32)
|
| 27 |
+
for step in range(steps):
|
| 28 |
+
cy, cx = center + velocity * step
|
| 29 |
+
rain = amplitude * np.exp(-((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * width0**2))
|
| 30 |
+
rain += 0.35 * amplitude * np.exp(-((xx + cx * 0.5) ** 2 + (yy - cy * 0.6) ** 2) / (3 * width0**2))
|
| 31 |
+
rain = np.clip(rain * (0.8 + 0.3 * terrain) + rng.normal(0, 0.12, rain.shape), 0, None)
|
| 32 |
+
forecast = np.clip(np.roll(rain, shift=(1, -1), axis=(0, 1)) * (1.12 + 0.08 * terrain) - 0.25, 0, None)
|
| 33 |
+
channels = np.empty((39, height, width), np.float32)
|
| 34 |
+
for variable in range(8):
|
| 35 |
+
for level in range(4):
|
| 36 |
+
index = variable * 4 + level
|
| 37 |
+
noise = smooth(rng.normal(0, 0.05, rain.shape).astype(np.float32))
|
| 38 |
+
if variable == 1: # humidity follows rain and terrain
|
| 39 |
+
channels[index] = 0.45 + 0.02 * rain + 0.12 * terrain - 0.025 * level + noise
|
| 40 |
+
elif variable == 4: # vertical velocity is strongest near moving rain
|
| 41 |
+
channels[index] = -0.08 * rain / (level + 1) + 0.04 * (xx * velocity[1] + yy * velocity[0]) + noise
|
| 42 |
+
else:
|
| 43 |
+
channels[index] = (variable + 1) * 0.12 + level * 0.04 + 0.08 * xx - 0.05 * yy + noise
|
| 44 |
+
for index in range(32, 37):
|
| 45 |
+
channels[index] = 0.1 * (index - 31) + 0.03 * rain + smooth(rng.normal(0, 0.04, rain.shape).astype(np.float32))
|
| 46 |
+
channels[37] = forecast
|
| 47 |
+
channels[38] = terrain
|
| 48 |
+
inputs[step], targets[step] = channels, rain
|
| 49 |
+
return inputs, targets, terrain
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def main():
|
| 53 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 54 |
+
data = config["data"]
|
| 55 |
+
expected = (8, 39, 56, 56)
|
| 56 |
+
actual = (int(data["time_steps"]), int(data["channels"]), int(data["height"]), int(data["width"]))
|
| 57 |
+
if actual != expected or len(data["lead_hours"]) != expected[0]:
|
| 58 |
+
raise ValueError(f"fixed data contract is {expected} with eight lead hours, got {actual}")
|
| 59 |
+
counts = data["rain_days"]
|
| 60 |
+
output = ROOT / data["root"]
|
| 61 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
seed = int(config["seed"])
|
| 63 |
+
group_id = 0
|
| 64 |
+
for split_index, split in enumerate(("train", "val", "test")):
|
| 65 |
+
arrays, targets, terrains, groups = [], [], [], []
|
| 66 |
+
for day in range(int(counts[split])):
|
| 67 |
+
x, y, terrain = make_day(np.random.default_rng(seed + group_id), int(data["time_steps"]), int(data["height"]), int(data["width"]))
|
| 68 |
+
arrays.append(x); targets.append(y); terrains.append(terrain); groups.append(group_id)
|
| 69 |
+
group_id += 1
|
| 70 |
+
np.savez_compressed(
|
| 71 |
+
output / f"{split}.npz", inputs=np.stack(arrays), targets=np.stack(targets),
|
| 72 |
+
terrain=np.stack(terrains), group_id=np.asarray(groups), split=np.asarray(split),
|
| 73 |
+
format_version=np.asarray(data["format_version"]), lead_hours=np.asarray(data["lead_hours"]),
|
| 74 |
+
)
|
| 75 |
+
print(f"split={split} rain_days={counts[split]} inputs={np.stack(arrays).shape}")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Apply all four UnetDif heads and save 3-hour and 24-hour products."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
sys.path.insert(0, str(ROOT))
|
| 13 |
+
from model.unetdif import UnetDif, apply_correction
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main():
|
| 17 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 18 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 19 |
+
checkpoint = torch.load(ROOT / config["paths"]["checkpoint"], map_location=device, weights_only=True)
|
| 20 |
+
required_checkpoint = {"epoch", "model", "optimizer", "model_config", "loss_normalizers", "loss_components", "format_version"}
|
| 21 |
+
if not required_checkpoint.issubset(checkpoint):
|
| 22 |
+
raise ValueError(f"checkpoint is missing {sorted(required_checkpoint - checkpoint.keys())}")
|
| 23 |
+
if checkpoint["format_version"] != config["data"]["format_version"]:
|
| 24 |
+
raise ValueError("checkpoint and configured data format versions differ")
|
| 25 |
+
model = UnetDif(**checkpoint["model_config"]).to(device)
|
| 26 |
+
model.load_state_dict(checkpoint["model"]); model.eval()
|
| 27 |
+
archive = np.load(ROOT / config["data"]["root"] / "test.npz", allow_pickle=False)
|
| 28 |
+
inputs = archive["inputs"].astype(np.float32, copy=False)
|
| 29 |
+
expected = (int(config["data"]["time_steps"]), int(config["data"]["channels"]),
|
| 30 |
+
int(config["data"]["height"]), int(config["data"]["width"]))
|
| 31 |
+
if inputs.shape[1:] != expected or archive["targets"].shape[1:] != (expected[0], expected[2], expected[3]):
|
| 32 |
+
raise ValueError(f"test NPZ does not satisfy the fixed day contract {expected}")
|
| 33 |
+
if archive["lead_hours"].tolist() != config["data"]["lead_hours"]:
|
| 34 |
+
raise ValueError("test NPZ lead_hours do not match configuration")
|
| 35 |
+
flat = torch.from_numpy(inputs.reshape(-1, *inputs.shape[2:])).to(device)
|
| 36 |
+
heads_all = {key: [] for key in ("dry_logits", "false_alarm_logits", "positive_bias", "negative_bias")}
|
| 37 |
+
corrected = []
|
| 38 |
+
batch_size = int(config["inference"]["batch_size"])
|
| 39 |
+
with torch.no_grad():
|
| 40 |
+
for start in range(0, len(flat), batch_size):
|
| 41 |
+
batch = flat[start:start + batch_size]
|
| 42 |
+
heads = model(batch)
|
| 43 |
+
corrected.append(apply_correction(batch[:, int(config["data"]["precipitation_channel"])], heads,
|
| 44 |
+
float(config["inference"]["probability_threshold"])).cpu())
|
| 45 |
+
for key in heads_all:
|
| 46 |
+
heads_all[key].append(heads[key].cpu())
|
| 47 |
+
day_shape = inputs.shape[:2] + inputs.shape[-2:]
|
| 48 |
+
corrected_3h = torch.cat(corrected).numpy().reshape(day_shape)
|
| 49 |
+
raw_3h = inputs[:, :, int(config["data"]["precipitation_channel"])]
|
| 50 |
+
output = ROOT / config["paths"]["inference"]
|
| 51 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
payload = {key: torch.cat(value).numpy().reshape(day_shape) for key, value in heads_all.items()}
|
| 53 |
+
np.savez_compressed(
|
| 54 |
+
output, inputs=inputs, group_id=archive["group_id"], lead_hours=archive["lead_hours"],
|
| 55 |
+
raw_3h=raw_3h, target_3h=archive["targets"], corrected_3h=corrected_3h,
|
| 56 |
+
raw_24h=raw_3h.sum(1), target_24h=archive["targets"].sum(1), corrected_24h=corrected_3h.sum(1), **payload,
|
| 57 |
+
)
|
| 58 |
+
print(f"saved={output.relative_to(ROOT)} corrected_3h={corrected_3h.shape}")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate 24-hour rainfall and produce per-step metrics and maps."""
|
| 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 yaml
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def categorical(prediction, target, threshold):
|
| 18 |
+
forecast, observed = prediction >= threshold, target >= threshold
|
| 19 |
+
hits = np.logical_and(forecast, observed).sum(); false = np.logical_and(forecast, ~observed).sum()
|
| 20 |
+
misses = np.logical_and(~forecast, observed).sum(); correct_negative = np.logical_and(~forecast, ~observed).sum()
|
| 21 |
+
total = hits + false + misses + correct_negative; eps = 1e-8
|
| 22 |
+
random_hits = (hits + misses) * (hits + false) / max(total, 1)
|
| 23 |
+
return {
|
| 24 |
+
"ACC": float((hits + correct_negative) / (total + eps)),
|
| 25 |
+
"TS": float(hits / (hits + misses + false + eps)),
|
| 26 |
+
"ETS": float((hits - random_hits) / (hits + misses + false - random_hits + eps)),
|
| 27 |
+
"BS": float((hits + false) / (hits + misses + eps)),
|
| 28 |
+
"FAR": float(false / (hits + false + eps)),
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def fss(prediction, target, threshold, window):
|
| 33 |
+
forecast = torch.from_numpy((prediction >= threshold).astype(np.float32))[:, None]
|
| 34 |
+
observed = torch.from_numpy((target >= threshold).astype(np.float32))[:, None]
|
| 35 |
+
pf = torch.nn.functional.avg_pool2d(forecast, window, stride=1, padding=window // 2)
|
| 36 |
+
po = torch.nn.functional.avg_pool2d(observed, window, stride=1, padding=window // 2)
|
| 37 |
+
return float(1 - (pf - po).square().sum() / (pf.square().sum() + po.square().sum() + 1e-8))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main():
|
| 41 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 42 |
+
archive = np.load(ROOT / config["paths"]["inference"], allow_pickle=False)
|
| 43 |
+
steps = int(config["data"]["time_steps"])
|
| 44 |
+
lead_hours = [int(value) for value in archive["lead_hours"]]
|
| 45 |
+
expected_3h = (steps, int(config["data"]["height"]), int(config["data"]["width"]))
|
| 46 |
+
for key in ("raw_3h", "target_3h", "corrected_3h"):
|
| 47 |
+
if archive[key].shape[1:] != expected_3h:
|
| 48 |
+
raise ValueError(f"{key} must have per-day shape {expected_3h}")
|
| 49 |
+
if lead_hours != config["data"]["lead_hours"]:
|
| 50 |
+
raise ValueError("inference NPZ lead_hours do not match configuration")
|
| 51 |
+
thresholds = [float(value) for value in config["evaluation"]["thresholds_mm_24h"]]
|
| 52 |
+
windows = [int(value) for value in config["evaluation"]["fss_windows"]]
|
| 53 |
+
metrics = {"units_24h": "mm/24h", "units_per_step": "mm/3h", "per_step_length": steps,
|
| 54 |
+
"lead_hours": lead_hours, "raw_24h": {}, "corrected_24h": {}, "per_step": {}}
|
| 55 |
+
for label in ("raw", "corrected"):
|
| 56 |
+
prediction_24h = archive[f"{label}_24h"]
|
| 57 |
+
metrics[f"{label}_24h"] = {str(int(t)): categorical(prediction_24h, archive["target_24h"], t) for t in thresholds}
|
| 58 |
+
metrics[f"{label}_24h"]["FSS@50"] = {str(w): fss(prediction_24h, archive["target_24h"], 50, w) for w in windows}
|
| 59 |
+
per_step = []
|
| 60 |
+
for step in range(steps):
|
| 61 |
+
item = {"step": step, "lead_hour": lead_hours[step]}
|
| 62 |
+
item.update({str(int(t)): categorical(archive[f"{label}_3h"][:, step], archive["target_3h"][:, step], t) for t in thresholds})
|
| 63 |
+
item["FSS@50"] = {str(w): fss(archive[f"{label}_3h"][:, step], archive["target_3h"][:, step], 50, w) for w in windows}
|
| 64 |
+
per_step.append(item)
|
| 65 |
+
metrics["per_step"][label] = per_step
|
| 66 |
+
output = ROOT / config["paths"]["evaluation_dir"]
|
| 67 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
(output / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n")
|
| 69 |
+
figure, axes = plt.subplots(4, steps, figsize=(18, 9), constrained_layout=True)
|
| 70 |
+
rows = (archive["raw_3h"][0], archive["target_3h"][0], archive["corrected_3h"][0], archive["corrected_3h"][0] - archive["target_3h"][0])
|
| 71 |
+
labels = ("ECMWF", "CMPA target", "Corrected", "Error")
|
| 72 |
+
vmax = max(float(rows[0].max()), float(rows[1].max()), 1)
|
| 73 |
+
for row, (fields, label) in enumerate(zip(rows, labels)):
|
| 74 |
+
for step in range(steps):
|
| 75 |
+
cmap = "RdBu_r" if row == 3 else "Blues"
|
| 76 |
+
limit = vmax if row < 3 else max(vmax / 2, 1)
|
| 77 |
+
axes[row, step].imshow(fields[step], cmap=cmap, vmin=-limit if row == 3 else 0, vmax=limit)
|
| 78 |
+
axes[row, step].axis("off")
|
| 79 |
+
if row == 0: axes[row, step].set_title(f"+{archive['lead_hours'][step]} h")
|
| 80 |
+
if step == 0: axes[row, step].set_ylabel(label)
|
| 81 |
+
figure.savefig(output / "comparison.png", dpi=150); plt.close(figure)
|
| 82 |
+
print(f"metrics={output / 'metrics.json'} figure={output / 'comparison.png'}")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train UnetDif with single-process or torchrun DDP execution."""
|
| 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.nn.parallel import DistributedDataParallel as DDP
|
| 12 |
+
from torch.utils.data import DataLoader, Dataset, DistributedSampler
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 16 |
+
sys.path.insert(0, str(ROOT))
|
| 17 |
+
from model.unetdif import UnetDif, loss_components
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class RainfallDataset(Dataset):
|
| 21 |
+
def __init__(self, path: Path, config):
|
| 22 |
+
archive = np.load(path, allow_pickle=False)
|
| 23 |
+
expected = (int(config["time_steps"]), int(config["channels"]), int(config["height"]), int(config["width"]))
|
| 24 |
+
if archive["inputs"].shape[1:] != expected or archive["targets"].shape[1:] != (expected[0], expected[2], expected[3]):
|
| 25 |
+
raise ValueError(f"expected day shapes {expected} and {(expected[0], expected[2], expected[3])}")
|
| 26 |
+
if len(np.unique(archive["group_id"])) != len(archive["group_id"]):
|
| 27 |
+
raise ValueError("each rain day must belong to one split group")
|
| 28 |
+
if archive["lead_hours"].tolist() != config["lead_hours"]:
|
| 29 |
+
raise ValueError("NPZ lead_hours do not match the configured eight 3-hour steps")
|
| 30 |
+
self.inputs = archive["inputs"].astype(np.float32, copy=False).reshape(-1, *expected[1:])
|
| 31 |
+
self.targets = archive["targets"].astype(np.float32, copy=False).reshape(-1, expected[2], expected[3])
|
| 32 |
+
|
| 33 |
+
def __len__(self):
|
| 34 |
+
return len(self.inputs)
|
| 35 |
+
|
| 36 |
+
def __getitem__(self, index):
|
| 37 |
+
return torch.from_numpy(self.inputs[index]), torch.from_numpy(self.targets[index])
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_device(local_rank=0, local_world_size=1):
|
| 41 |
+
use_cuda = torch.cuda.is_available() and torch.cuda.device_count() >= local_world_size
|
| 42 |
+
return torch.device("cuda", local_rank) if use_cuda else torch.device("cpu")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def reduce_values(values, device, distributed):
|
| 46 |
+
tensor = torch.tensor(values, device=device)
|
| 47 |
+
if distributed:
|
| 48 |
+
torch.distributed.all_reduce(tensor)
|
| 49 |
+
return tensor.cpu().tolist()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def main():
|
| 53 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 54 |
+
torch.manual_seed(int(config["seed"]))
|
| 55 |
+
distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
|
| 56 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 57 |
+
local_world_size = int(os.environ.get("LOCAL_WORLD_SIZE", "1"))
|
| 58 |
+
device = get_device(local_rank, local_world_size)
|
| 59 |
+
if distributed:
|
| 60 |
+
torch.distributed.init_process_group("nccl" if device.type == "cuda" else "gloo")
|
| 61 |
+
rank = torch.distributed.get_rank() if distributed else 0
|
| 62 |
+
dataset = RainfallDataset(ROOT / config["data"]["root"] / "train.npz", config["data"])
|
| 63 |
+
if distributed and len(dataset) < torch.distributed.get_world_size():
|
| 64 |
+
raise ValueError("DDP requires at least one training item per process")
|
| 65 |
+
sampler = DistributedSampler(dataset) if distributed else None
|
| 66 |
+
loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]), shuffle=sampler is None,
|
| 67 |
+
sampler=sampler, num_workers=int(config["train"]["num_workers"]))
|
| 68 |
+
model = UnetDif(**config["model"]).to(device)
|
| 69 |
+
if distributed:
|
| 70 |
+
model = DDP(model, device_ids=[local_rank] if device.type == "cuda" else None)
|
| 71 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=float(config["train"]["learning_rate"]))
|
| 72 |
+
names = ["dry_focal", "false_alarm_focal", "positive_mse", "negative_mse", "dry_mae", "all_mae"]
|
| 73 |
+
normalizers = {name: 1.0 for name in names}
|
| 74 |
+
history = []
|
| 75 |
+
for epoch in range(int(config["train"]["epochs"])):
|
| 76 |
+
if sampler is not None:
|
| 77 |
+
sampler.set_epoch(epoch)
|
| 78 |
+
model.train(); sums = {name: 0.0 for name in names}; batches = 0
|
| 79 |
+
for inputs, target in loader:
|
| 80 |
+
inputs, target = inputs.to(device), target.to(device)
|
| 81 |
+
heads = model(inputs)
|
| 82 |
+
raw = loss_components(heads, inputs[:, int(config["data"]["precipitation_channel"])], target,
|
| 83 |
+
float(config["data"]["rain_threshold_mm_3h"]),
|
| 84 |
+
float(config["loss"]["focal_alpha"]), float(config["loss"]["focal_gamma"]))
|
| 85 |
+
loss = sum(raw[name] * normalizers[name] for name in names)
|
| 86 |
+
optimizer.zero_grad(set_to_none=True); loss.backward(); optimizer.step()
|
| 87 |
+
for name in names:
|
| 88 |
+
sums[name] += float(raw[name].detach())
|
| 89 |
+
batches += 1
|
| 90 |
+
reduced = reduce_values([sums[name] for name in names] + [batches], device, distributed)
|
| 91 |
+
total_batches = max(reduced[-1], 1)
|
| 92 |
+
averages = {name: reduced[index] / total_batches for index, name in enumerate(names)}
|
| 93 |
+
if epoch == 0:
|
| 94 |
+
normalizers = {name: 1.0 / max(value, 1e-6) for name, value in averages.items()}
|
| 95 |
+
record = {"epoch": epoch + 1, "components": averages, "normalizers": normalizers}
|
| 96 |
+
history.append(record)
|
| 97 |
+
if rank == 0:
|
| 98 |
+
print(json.dumps(record))
|
| 99 |
+
checkpoint = ROOT / config["paths"]["checkpoint"]
|
| 100 |
+
checkpoint.parent.mkdir(parents=True, exist_ok=True)
|
| 101 |
+
state = model.module.state_dict() if distributed else model.state_dict()
|
| 102 |
+
torch.save({"epoch": epoch + 1, "model": state, "optimizer": optimizer.state_dict(),
|
| 103 |
+
"model_config": config["model"], "loss_normalizers": normalizers,
|
| 104 |
+
"loss_components": names,
|
| 105 |
+
"format_version": config["data"]["format_version"]}, checkpoint)
|
| 106 |
+
if rank == 0:
|
| 107 |
+
metrics = ROOT / config["paths"]["training_metrics"]
|
| 108 |
+
metrics.parent.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
metrics.write_text(json.dumps({"history": history}, indent=2) + "\n")
|
| 110 |
+
if distributed:
|
| 111 |
+
torch.distributed.destroy_process_group()
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|