Upload folder using huggingface_hub
Browse files- .gitattributes +1 -34
- README.md +147 -0
- conf/config.yaml +47 -0
- config.json +45 -0
- configuration.json +12 -0
- model/massconservingcnn.py +50 -0
- scripts/fake_data.py +84 -0
- scripts/inference.py +54 -0
- scripts/result.py +72 -0
- scripts/train.py +128 -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: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- OneScience
|
| 7 |
+
- Earth Science
|
| 8 |
+
- Data Assimilation
|
| 9 |
+
- Mass Conservation
|
| 10 |
+
frameworks: PyTorch
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<strong><span style="font-size: 30px;">MassConservingCNN</span></strong>
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
# Model Introduction
|
| 18 |
+
|
| 19 |
+
MassConservingCNN corrects mass-conservation violations introduced by localization in ensemble Kalman filter data assimilation. Given an unconstrained analysis and radar-observation locations, it generates an analysis field with nonnegative rain and improved mass conservation for research on physically constrained data assimilation and analysis postprocessing.
|
| 20 |
+
|
| 21 |
+
Paper: Training a convolutional neural network to conserve mass in data assimilation
|
| 22 |
+
https://doi.org/10.5194/npg-28-111-2021
|
| 23 |
+
|
| 24 |
+
# Model Description
|
| 25 |
+
|
| 26 |
+
MassConservingCNN was proposed by researchers from the Meteorological Institute of Ludwig-Maximilians-Universität München and ClimateAi. The paper trains and validates the model with EnKF unconstrained analyses, QPEns constrained analyses, and radar-observation locations generated by twin experiments with a one-dimensional modified shallow-water model. The model is suitable for mass-conserving data-assimilation correction, rain non-negativity constraints, and physically consistent analysis generation.
|
| 27 |
+
|
| 28 |
+
# Use Cases
|
| 29 |
+
|
| 30 |
+
| Use Case | Description |
|
| 31 |
+
| :---: | :--- |
|
| 32 |
+
| Data-assimilation correction | Predict a QPEns-style analysis from `X^a` and a radar-location indicator. |
|
| 33 |
+
| Mass-aware training | Train with the paper Equation 6 error and Equation 7 mass penalty. |
|
| 34 |
+
| ModelScope/OneCode execution | Validate training, inference, evaluation, visualization, and checkpoint workflows in ModelScope or OneCode. |
|
| 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/MassConservingCNN --local-dir ./MassConservingCNN
|
| 49 |
+
cd MassConservingCNN
|
| 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 in advance. DTK 25.04.2 or later, or the OneScience-recommended version matching the current 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 |
+
The default generator creates 24 training samples and 12 validation samples while preserving `[B,4,250]` inputs and `[B,3,250]` targets. The data combine periodic waves, smooth convective cells, nonnegative rain related to velocity convergence, rainy-region radar masks, and smooth EnKF-style errors. They validate the engineering workflow only and are not equivalent to the paper's 48,000-sample QPEns datasets.
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
python scripts/fake_data.py --force
|
| 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 checks the data version, shapes, dtypes, finite values, binary radar masks, and rain non-negativity. The checkpoint stores model parameters, optimizer state, model configuration, normalization statistics, variable order, data version, `eta`, epoch, and seed. Outputs are written to:
|
| 101 |
+
|
| 102 |
+
```text
|
| 103 |
+
result/checkpoints/massconservingcnn.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 current engineering checkpoint is not claimed to be compatible with external weights.
|
| 110 |
+
|
| 111 |
+
### Inference
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
python scripts/inference.py
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Inference restores the model parameters produced by training and generates mass-corrected analysis fields from unconstrained analyses and radar-location indicators. Results include input analyses, target analyses, model predictions, radar-observation locations, and the corresponding physical and normalization information, and 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 results include core prediction-error, mass-conservation-error, and relative-improvement results, together with an input, target, and prediction comparison figure. Structured metrics and the auxiliary figure are saved to the paths below; synthetic-data results validate the engineering workflow only and do not represent paper performance.
|
| 130 |
+
|
| 131 |
+
```text
|
| 132 |
+
result/evaluation/metrics.json
|
| 133 |
+
result/evaluation/input_target_prediction.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 MassConservingCNN 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,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
seed: 42
|
| 2 |
+
data:
|
| 3 |
+
root: data
|
| 4 |
+
format_version: mass_conserving_msw_v1
|
| 5 |
+
grid_points: 250
|
| 6 |
+
domain_km: 125
|
| 7 |
+
variables: [u, h, r]
|
| 8 |
+
input_channels: 4
|
| 9 |
+
train_samples: 24
|
| 10 |
+
validation_samples: 12
|
| 11 |
+
model:
|
| 12 |
+
input_channels: 4
|
| 13 |
+
hidden_channels: 32
|
| 14 |
+
hidden_layers: 4
|
| 15 |
+
kernel_size: 3
|
| 16 |
+
train:
|
| 17 |
+
epochs: 2
|
| 18 |
+
batch_size: 8
|
| 19 |
+
learning_rate: 0.001
|
| 20 |
+
optimizer: Adam
|
| 21 |
+
eta: 2.0
|
| 22 |
+
num_workers: 0
|
| 23 |
+
runtime:
|
| 24 |
+
device: auto
|
| 25 |
+
paths:
|
| 26 |
+
checkpoint: result/checkpoints/massconservingcnn.pt
|
| 27 |
+
training_metrics: result/training/metrics.json
|
| 28 |
+
inference: result/output/predictions.npz
|
| 29 |
+
evaluation_dir: result/evaluation
|
| 30 |
+
paper_model:
|
| 31 |
+
input_channels: 4
|
| 32 |
+
output_channels: 3
|
| 33 |
+
grid_points: 250
|
| 34 |
+
hidden_channels: 32
|
| 35 |
+
hidden_layers: 4
|
| 36 |
+
kernel_size: 3
|
| 37 |
+
hidden_activation: SELU
|
| 38 |
+
rain_activation: ReLU
|
| 39 |
+
padding: circular
|
| 40 |
+
influence_radius: 5
|
| 41 |
+
train_samples: 48000
|
| 42 |
+
validation_samples: 48000
|
| 43 |
+
batch_size: 96
|
| 44 |
+
epochs: 100
|
| 45 |
+
optimizer: Adam
|
| 46 |
+
eta: 2.0
|
| 47 |
+
experiment: dT10_eta2
|
config.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "MassConservingCNN",
|
| 3 |
+
"model_type": "massconservingcnn",
|
| 4 |
+
"architectures": ["MassConservingCNN"],
|
| 5 |
+
"framework": "PyTorch",
|
| 6 |
+
"domain": "earth-science",
|
| 7 |
+
"task": "mass-aware-data-assimilation-analysis",
|
| 8 |
+
"implementation": {
|
| 9 |
+
"entry_point": "model/massconservingcnn.py",
|
| 10 |
+
"train_script": "scripts/train.py",
|
| 11 |
+
"inference_script": "scripts/inference.py",
|
| 12 |
+
"evaluation_script": "scripts/result.py",
|
| 13 |
+
"synthetic_data_script": "scripts/fake_data.py"
|
| 14 |
+
},
|
| 15 |
+
"architecture": {
|
| 16 |
+
"input_shape": ["B", 4, 250],
|
| 17 |
+
"output_shape": ["B", 3, 250],
|
| 18 |
+
"variable_order": ["u", "h", "r"],
|
| 19 |
+
"hidden_layers": 4,
|
| 20 |
+
"filters_per_layer": 32,
|
| 21 |
+
"kernel_size": 3,
|
| 22 |
+
"padding": "circular",
|
| 23 |
+
"influence_radius": 5
|
| 24 |
+
},
|
| 25 |
+
"paper_model": {
|
| 26 |
+
"input_channels": 4,
|
| 27 |
+
"output_channels": 3,
|
| 28 |
+
"grid_points": 250,
|
| 29 |
+
"hidden_channels": 32,
|
| 30 |
+
"hidden_layers": 4,
|
| 31 |
+
"kernel_size": 3,
|
| 32 |
+
"hidden_activation": "SELU",
|
| 33 |
+
"rain_activation": "ReLU",
|
| 34 |
+
"padding": "circular",
|
| 35 |
+
"influence_radius": 5,
|
| 36 |
+
"train_samples": 48000,
|
| 37 |
+
"validation_samples": 48000,
|
| 38 |
+
"batch_size": 96,
|
| 39 |
+
"epochs": 100,
|
| 40 |
+
"optimizer": "Adam",
|
| 41 |
+
"eta": 2.0,
|
| 42 |
+
"experiment": "dT10_eta2"
|
| 43 |
+
},
|
| 44 |
+
"configuration_sources": ["conf/config.yaml", "model/massconservingcnn.py", "scripts/fake_data.py", "scripts/train.py", "scripts/inference.py", "scripts/result.py"]
|
| 45 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "mass-aware-data-assimilation-analysis",
|
| 4 |
+
"model": "MassConservingCNN",
|
| 5 |
+
"input_format": "BCX",
|
| 6 |
+
"protocol": "1D modified shallow-water X^a plus radar indicator to QPEns analysis",
|
| 7 |
+
"default_config": "conf/config.yaml",
|
| 8 |
+
"training": "scripts/train.py",
|
| 9 |
+
"inference": "scripts/inference.py",
|
| 10 |
+
"evaluation": "scripts/result.py",
|
| 11 |
+
"visualization": "scripts/result.py"
|
| 12 |
+
}
|
model/massconservingcnn.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Periodic one-dimensional CNN for mass-aware data-assimilation correction."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch import nn
|
| 7 |
+
from torch.nn import functional as F
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PeriodicConv1d(nn.Module):
|
| 11 |
+
"""Conv1d with explicit circular padding and unchanged spatial length."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, in_channels: int, out_channels: int, kernel_size: int):
|
| 14 |
+
super().__init__()
|
| 15 |
+
if kernel_size % 2 != 1:
|
| 16 |
+
raise ValueError("kernel_size must be odd")
|
| 17 |
+
self.pad = kernel_size // 2
|
| 18 |
+
self.conv = nn.Conv1d(in_channels, out_channels, kernel_size, padding=0)
|
| 19 |
+
|
| 20 |
+
def forward(self, inputs: torch.Tensor) -> torch.Tensor:
|
| 21 |
+
return self.conv(F.pad(inputs, (self.pad, self.pad), mode="circular"))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MassConservingCNN(nn.Module):
|
| 25 |
+
"""Four hidden SELU convolutions followed by the u/h/r output layer."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, input_channels: int = 4, hidden_channels: int = 32,
|
| 28 |
+
hidden_layers: int = 4, kernel_size: int = 3):
|
| 29 |
+
super().__init__()
|
| 30 |
+
if input_channels != 4 or hidden_layers != 4 or kernel_size != 3:
|
| 31 |
+
raise ValueError("paper architecture requires 4 inputs, 4 hidden layers, kernel size 3")
|
| 32 |
+
layers = []
|
| 33 |
+
channels = input_channels
|
| 34 |
+
for _ in range(hidden_layers):
|
| 35 |
+
layers.extend((PeriodicConv1d(channels, hidden_channels, kernel_size), nn.SELU()))
|
| 36 |
+
channels = hidden_channels
|
| 37 |
+
self.hidden = nn.Sequential(*layers)
|
| 38 |
+
self.output = PeriodicConv1d(hidden_channels, 3, kernel_size)
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def influence_radius(self) -> int:
|
| 42 |
+
return 5
|
| 43 |
+
|
| 44 |
+
def forward(self, inputs: torch.Tensor) -> torch.Tensor:
|
| 45 |
+
if inputs.ndim != 3 or inputs.shape[1] != 4 or inputs.shape[2] != 250:
|
| 46 |
+
raise ValueError(f"expected float tensor [B,4,250], got {tuple(inputs.shape)}")
|
| 47 |
+
if not inputs.is_floating_point():
|
| 48 |
+
raise TypeError("inputs must have a floating-point dtype")
|
| 49 |
+
raw = self.output(self.hidden(inputs))
|
| 50 |
+
return torch.cat((raw[:, :2], F.relu(raw[:, 2:3])), dim=1)
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate small, structured modified-shallow-water analysis pairs."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import yaml
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def periodic_gaussian(x, center, width):
|
| 14 |
+
distance = np.minimum(np.abs(x - center), 1.0 - np.abs(x - center))
|
| 15 |
+
return np.exp(-0.5 * (distance / width) ** 2)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def make_split(path, count, config, seed):
|
| 19 |
+
rng = np.random.default_rng(seed)
|
| 20 |
+
n = int(config["data"]["grid_points"])
|
| 21 |
+
x = np.arange(n, dtype=np.float32) / n
|
| 22 |
+
xa = np.empty((count, 3, n), dtype=np.float32)
|
| 23 |
+
target = np.empty_like(xa)
|
| 24 |
+
radar = np.empty((count, 1, n), dtype=np.float32)
|
| 25 |
+
for sample in range(count):
|
| 26 |
+
phase = rng.uniform(0.0, 1.0)
|
| 27 |
+
wave = np.sin(2 * np.pi * (x - phase))
|
| 28 |
+
harmonic = np.sin(4 * np.pi * (x - 0.6 * phase))
|
| 29 |
+
convective = periodic_gaussian(x, (phase + 0.23) % 1.0, 0.045)
|
| 30 |
+
secondary = periodic_gaussian(x, (phase + 0.66) % 1.0, 0.07)
|
| 31 |
+
u_true = 0.75 * wave + 0.22 * harmonic - 0.28 * np.gradient(convective)
|
| 32 |
+
h_true = 10.0 + 0.35 * np.cos(2 * np.pi * (x - phase)) + 0.5 * convective
|
| 33 |
+
convergence = np.maximum(-np.gradient(u_true), 0.0)
|
| 34 |
+
r_true = np.maximum(0.0, 0.7 * convective + 0.28 * convergence - 0.09)
|
| 35 |
+
rain_mask = (r_true > 0.08).astype(np.float32)
|
| 36 |
+
|
| 37 |
+
# Smooth EnKF-like errors are tied to convection and dry-region mass drift.
|
| 38 |
+
dry = 1.0 - rain_mask
|
| 39 |
+
u_error = 0.11 * secondary - 0.07 * convective + 0.025 * harmonic
|
| 40 |
+
h_error = 0.16 * dry + 0.08 * secondary - 0.05 * convective
|
| 41 |
+
r_error = 0.13 * secondary * dry - 0.06 * convective
|
| 42 |
+
xa[sample, 0] = u_true + u_error
|
| 43 |
+
xa[sample, 1] = h_true + h_error
|
| 44 |
+
xa[sample, 2] = np.maximum(0.0, r_true + r_error)
|
| 45 |
+
target[sample] = np.stack((u_true, h_true, r_true))
|
| 46 |
+
radar[sample, 0] = rain_mask
|
| 47 |
+
|
| 48 |
+
# Shared synthetic climatology keeps train and validation normalization identical.
|
| 49 |
+
means = np.asarray([0.0, 10.0], dtype=np.float32)
|
| 50 |
+
stds = np.asarray([0.6, 0.4, 0.3], dtype=np.float32)
|
| 51 |
+
normalized_x = xa.copy()
|
| 52 |
+
normalized_y = target.copy()
|
| 53 |
+
normalized_x[:, :2] = (xa[:, :2] - means[None, :, None]) / stds[None, :2, None]
|
| 54 |
+
normalized_y[:, :2] = (target[:, :2] - means[None, :, None]) / stds[None, :2, None]
|
| 55 |
+
normalized_x[:, 2] = xa[:, 2] / stds[2]
|
| 56 |
+
normalized_y[:, 2] = target[:, 2] / stds[2]
|
| 57 |
+
inputs = np.concatenate((normalized_x, radar), axis=1).astype(np.float32)
|
| 58 |
+
np.savez_compressed(
|
| 59 |
+
path, inputs=inputs, targets=normalized_y.astype(np.float32), xa=xa,
|
| 60 |
+
targets_physical=target, radar=radar, climate_mean_uh=means,
|
| 61 |
+
climate_std_uhr=stds, format_version=np.asarray(config["data"]["format_version"]),
|
| 62 |
+
variable_order=np.asarray(["u", "h", "r"]), input_layout=np.asarray("BCX"),
|
| 63 |
+
data_source=np.asarray("structured_synthetic_msw"),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main():
|
| 68 |
+
parser = argparse.ArgumentParser()
|
| 69 |
+
parser.add_argument("--force", action="store_true")
|
| 70 |
+
args = parser.parse_args()
|
| 71 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 72 |
+
output = ROOT / config["data"]["root"]
|
| 73 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 74 |
+
splits = (("train.npz", int(config["data"]["train_samples"])),
|
| 75 |
+
("validation.npz", int(config["data"]["validation_samples"])))
|
| 76 |
+
for offset, (name, count) in enumerate(splits):
|
| 77 |
+
path = output / name
|
| 78 |
+
if args.force or not path.exists():
|
| 79 |
+
make_split(path, count, config, int(config["seed"]) + offset)
|
| 80 |
+
print(f"generated={path.relative_to(ROOT)} samples={count} shape=({count},4,250)")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
if __name__ == "__main__":
|
| 84 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run validated inference and save normalized and physical fields."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
import yaml
|
| 9 |
+
from torch.utils.data import DataLoader
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
sys.path.insert(0, str(ROOT))
|
| 14 |
+
from model.massconservingcnn import MassConservingCNN
|
| 15 |
+
from train import MSWDataset, device_from_config
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def main():
|
| 19 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 20 |
+
device = device_from_config(config)
|
| 21 |
+
checkpoint = torch.load(ROOT / config["paths"]["checkpoint"], map_location=device, weights_only=False)
|
| 22 |
+
required = {"model", "optimizer_state_dict", "model_config", "epoch", "eta",
|
| 23 |
+
"format_version", "variable_order", "normalization", "climate_mean_uh", "climate_std_uhr", "seed"}
|
| 24 |
+
if not required.issubset(checkpoint):
|
| 25 |
+
raise ValueError(f"incomplete checkpoint, missing {sorted(required - set(checkpoint))}")
|
| 26 |
+
if checkpoint["format_version"] != config["data"]["format_version"] or checkpoint["variable_order"] != ["u", "h", "r"]:
|
| 27 |
+
raise ValueError("checkpoint protocol mismatch")
|
| 28 |
+
dataset = MSWDataset(ROOT / config["data"]["root"] / "validation.npz", config)
|
| 29 |
+
loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]), shuffle=False)
|
| 30 |
+
model = MassConservingCNN(**checkpoint["model_config"]).to(device)
|
| 31 |
+
model.load_state_dict(checkpoint["model"]); model.eval()
|
| 32 |
+
outputs = []
|
| 33 |
+
with torch.no_grad():
|
| 34 |
+
for inputs, _ in loader:
|
| 35 |
+
outputs.append(model(inputs.to(device)).cpu().numpy())
|
| 36 |
+
predictions = np.concatenate(outputs).astype(np.float32)
|
| 37 |
+
if predictions.shape != dataset.data["targets"].shape or predictions.dtype != np.float32 or not np.isfinite(predictions).all():
|
| 38 |
+
raise ValueError("invalid inference output")
|
| 39 |
+
means = np.asarray(checkpoint["climate_mean_uh"], dtype=np.float32)
|
| 40 |
+
stds = np.asarray(checkpoint["climate_std_uhr"], dtype=np.float32)
|
| 41 |
+
physical = predictions.copy()
|
| 42 |
+
physical[:, :2] = predictions[:, :2] * stds[None, :2, None] + means[None, :, None]
|
| 43 |
+
physical[:, 2] = predictions[:, 2] * stds[2]
|
| 44 |
+
output = ROOT / config["paths"]["inference"]
|
| 45 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
np.savez_compressed(output, predictions=predictions, predictions_physical=physical,
|
| 47 |
+
inputs=dataset.data["inputs"], xa=dataset.data["xa"], targets=dataset.data["targets"],
|
| 48 |
+
targets_physical=dataset.data["targets_physical"], radar=dataset.data["radar"],
|
| 49 |
+
format_version=np.asarray(config["data"]["format_version"]), variable_order=np.asarray(["u", "h", "r"]))
|
| 50 |
+
print(f"predictions={output.relative_to(ROOT)} shape={predictions.shape} dtype={predictions.dtype}")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compute paper-aligned metrics and plot input, target, and prediction."""
|
| 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 yaml
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def metrics(candidate_n, candidate_p, target_n, target_p):
|
| 17 |
+
rmse = np.sqrt(np.mean((candidate_n - target_n) ** 2, axis=(0, 2)))
|
| 18 |
+
sample_variable_rmse = np.sqrt(np.mean((candidate_n - target_n) ** 2, axis=2))
|
| 19 |
+
mass_h = np.mean(np.abs(candidate_p[:, 1].sum(1) - target_p[:, 1].sum(1)) / candidate_p.shape[2])
|
| 20 |
+
mass_r = np.mean(np.abs(candidate_p[:, 2].sum(1) - target_p[:, 2].sum(1)) / candidate_p.shape[2])
|
| 21 |
+
h_bias = np.mean(candidate_p[:, 1] - target_p[:, 1])
|
| 22 |
+
return {"J": float(sample_variable_rmse.mean()), "rmse_u": float(rmse[0]),
|
| 23 |
+
"rmse_h": float(rmse[1]), "rmse_r": float(rmse[2]),
|
| 24 |
+
"mass_error_h_per_point": float(mass_h), "mass_error_r_per_point": float(mass_r),
|
| 25 |
+
"h_bias": float(h_bias)}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main():
|
| 29 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 30 |
+
data = np.load(ROOT / config["paths"]["inference"])
|
| 31 |
+
if str(data["format_version"]) != config["data"]["format_version"]:
|
| 32 |
+
raise ValueError("prediction format mismatch")
|
| 33 |
+
input_n, target_n, prediction_n = data["inputs"][:, :3], data["targets"], data["predictions"]
|
| 34 |
+
input_p, target_p, prediction_p = data["xa"], data["targets_physical"], data["predictions_physical"]
|
| 35 |
+
expected = (len(target_n), 3, 250)
|
| 36 |
+
if any(array.shape != expected for array in (input_n, target_n, prediction_n, input_p, target_p, prediction_p)):
|
| 37 |
+
raise ValueError("evaluation arrays must have shape [B,3,250]")
|
| 38 |
+
baseline = metrics(input_n, input_p, target_n, target_p)
|
| 39 |
+
prediction = metrics(prediction_n, prediction_p, target_n, target_p)
|
| 40 |
+
improvement = {key: float(100 * (baseline[key] - prediction[key]) / baseline[key])
|
| 41 |
+
for key in ("J", "rmse_u", "rmse_h", "rmse_r", "mass_error_h_per_point", "mass_error_r_per_point")
|
| 42 |
+
if baseline[key] != 0}
|
| 43 |
+
if baseline["h_bias"] != 0:
|
| 44 |
+
improvement["absolute_h_bias"] = float(
|
| 45 |
+
100 * (abs(baseline["h_bias"]) - abs(prediction["h_bias"])) / abs(baseline["h_bias"])
|
| 46 |
+
)
|
| 47 |
+
report = {"samples": len(target_n), "baseline_input": baseline, "prediction": prediction,
|
| 48 |
+
"relative_improvement_percent": improvement,
|
| 49 |
+
"note": "Structured synthetic engineering validation; not paper performance."}
|
| 50 |
+
values = list(baseline.values()) + list(prediction.values()) + list(improvement.values())
|
| 51 |
+
if not np.isfinite(values).all():
|
| 52 |
+
raise FloatingPointError("non-finite evaluation metric")
|
| 53 |
+
output = ROOT / config["paths"]["evaluation_dir"]
|
| 54 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 55 |
+
(output / "metrics.json").write_text(json.dumps(report, indent=2) + "\n")
|
| 56 |
+
x = np.arange(250) * float(config["data"]["domain_km"]) / 250
|
| 57 |
+
figure, axes = plt.subplots(3, 1, figsize=(11, 8), sharex=True)
|
| 58 |
+
for index, (axis, variable) in enumerate(zip(axes, ("u", "h", "r"))):
|
| 59 |
+
axis.plot(x, input_p[0, index], color="steelblue", label="input X^a", linewidth=1.4)
|
| 60 |
+
axis.plot(x, target_p[0, index], color="black", label="QPEns target", linewidth=1.5)
|
| 61 |
+
axis.plot(x, prediction_p[0, index], color="firebrick", label="CNN prediction", linewidth=1.3)
|
| 62 |
+
if variable == "r":
|
| 63 |
+
axis.fill_between(x, 0, data["radar"][0, 0] * max(target_p[0, 2].max(), 1e-6), color="gold", alpha=0.2, label="radar mask")
|
| 64 |
+
axis.set_ylabel(variable); axis.grid(alpha=0.2)
|
| 65 |
+
axes[0].legend(ncol=3); axes[-1].set_xlabel("distance (km)")
|
| 66 |
+
figure.suptitle("MassConservingCNN structured synthetic validation")
|
| 67 |
+
figure.tight_layout(); figure.savefig(output / "input_target_prediction.png", dpi=150); plt.close(figure)
|
| 68 |
+
print(f"evaluation={output.relative_to(ROOT)} J={prediction['J']:.6f} h_mass={prediction['mass_error_h_per_point']:.6f}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train MassConservingCNN with optional torchrun DDP."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
import yaml
|
| 12 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 13 |
+
from torch.utils.data import DataLoader, Dataset, DistributedSampler
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
sys.path.insert(0, str(ROOT))
|
| 18 |
+
from model.massconservingcnn import MassConservingCNN
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MSWDataset(Dataset):
|
| 22 |
+
def __init__(self, path, config):
|
| 23 |
+
self.data = np.load(path)
|
| 24 |
+
if str(self.data["format_version"]) != config["data"]["format_version"]:
|
| 25 |
+
raise ValueError("incompatible data format version")
|
| 26 |
+
count = len(self.data["inputs"])
|
| 27 |
+
if self.data["inputs"].shape != (count, 4, 250):
|
| 28 |
+
raise ValueError("inputs must have shape [B,4,250]")
|
| 29 |
+
if self.data["targets"].shape != (count, 3, 250):
|
| 30 |
+
raise ValueError("targets must have shape [B,3,250]")
|
| 31 |
+
if self.data["inputs"].dtype != np.float32 or self.data["targets"].dtype != np.float32:
|
| 32 |
+
raise TypeError("inputs and targets must be float32")
|
| 33 |
+
if not np.isfinite(self.data["inputs"]).all() or not np.isfinite(self.data["targets"]).all():
|
| 34 |
+
raise ValueError("data must be finite")
|
| 35 |
+
if not np.isin(self.data["radar"], (0.0, 1.0)).all():
|
| 36 |
+
raise ValueError("radar indicator must be binary")
|
| 37 |
+
if (self.data["inputs"][:, 2] < 0).any() or (self.data["targets"][:, 2] < 0).any():
|
| 38 |
+
raise ValueError("normalized rain must remain non-negative")
|
| 39 |
+
|
| 40 |
+
def __len__(self):
|
| 41 |
+
return len(self.data["inputs"])
|
| 42 |
+
|
| 43 |
+
def __getitem__(self, index):
|
| 44 |
+
return torch.from_numpy(self.data["inputs"][index]), torch.from_numpy(self.data["targets"][index])
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def paper_j(prediction, target):
|
| 48 |
+
return torch.sqrt(torch.mean((prediction - target) ** 2, dim=2) + 1e-12).mean(dim=1)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def mass_aware_loss(prediction, target, eta):
|
| 52 |
+
base = paper_j(prediction, target)
|
| 53 |
+
mass = eta / prediction.shape[2] * torch.abs(prediction[:, 1].sum(1) - target[:, 1].sum(1))
|
| 54 |
+
return (base + mass).mean(), base.mean(), mass.mean()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def device_from_config(config, local_rank=0):
|
| 58 |
+
requested = config["runtime"]["device"]
|
| 59 |
+
if requested == "auto":
|
| 60 |
+
return torch.device("cuda", local_rank) if torch.cuda.is_available() else torch.device("cpu")
|
| 61 |
+
return torch.device(requested)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def main():
|
| 65 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 66 |
+
seed = int(config["seed"])
|
| 67 |
+
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
|
| 68 |
+
distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
|
| 69 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 70 |
+
if distributed:
|
| 71 |
+
torch.distributed.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
|
| 72 |
+
rank = torch.distributed.get_rank() if distributed else 0
|
| 73 |
+
device = device_from_config(config, local_rank)
|
| 74 |
+
if device.type == "cuda":
|
| 75 |
+
torch.cuda.set_device(device); torch.cuda.manual_seed_all(seed)
|
| 76 |
+
train_set = MSWDataset(ROOT / config["data"]["root"] / "train.npz", config)
|
| 77 |
+
valid_set = MSWDataset(ROOT / config["data"]["root"] / "validation.npz", config)
|
| 78 |
+
sampler = DistributedSampler(train_set, shuffle=True, seed=seed) if distributed else None
|
| 79 |
+
loader = DataLoader(train_set, batch_size=int(config["train"]["batch_size"]),
|
| 80 |
+
shuffle=sampler is None, sampler=sampler,
|
| 81 |
+
num_workers=int(config["train"]["num_workers"]))
|
| 82 |
+
valid_loader = DataLoader(valid_set, batch_size=int(config["train"]["batch_size"]), shuffle=False)
|
| 83 |
+
model = MassConservingCNN(**config["model"]).to(device)
|
| 84 |
+
wrapped = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None) if distributed else model
|
| 85 |
+
bare = wrapped.module if distributed else wrapped
|
| 86 |
+
optimizer = torch.optim.Adam(wrapped.parameters(), lr=float(config["train"]["learning_rate"]))
|
| 87 |
+
history = []
|
| 88 |
+
for epoch in range(int(config["train"]["epochs"])):
|
| 89 |
+
if sampler is not None:
|
| 90 |
+
sampler.set_epoch(epoch)
|
| 91 |
+
wrapped.train(); total = 0.0; seen = 0
|
| 92 |
+
for inputs, targets in loader:
|
| 93 |
+
prediction = wrapped(inputs.to(device)); loss, _, _ = mass_aware_loss(prediction, targets.to(device), float(config["train"]["eta"]))
|
| 94 |
+
optimizer.zero_grad(set_to_none=True); loss.backward(); optimizer.step()
|
| 95 |
+
total += float(loss.detach()) * len(inputs); seen += len(inputs)
|
| 96 |
+
totals = torch.tensor([total, seen], dtype=torch.float64, device=device)
|
| 97 |
+
if distributed:
|
| 98 |
+
torch.distributed.all_reduce(totals)
|
| 99 |
+
wrapped.eval(); valid_total = valid_j = valid_mass = 0.0; valid_seen = 0
|
| 100 |
+
if rank == 0:
|
| 101 |
+
with torch.no_grad():
|
| 102 |
+
for inputs, targets in valid_loader:
|
| 103 |
+
loss, base, mass = mass_aware_loss(bare(inputs.to(device)), targets.to(device), float(config["train"]["eta"]))
|
| 104 |
+
valid_total += float(loss) * len(inputs); valid_j += float(base) * len(inputs)
|
| 105 |
+
valid_mass += float(mass) * len(inputs); valid_seen += len(inputs)
|
| 106 |
+
history.append({"epoch": epoch + 1, "train_loss": float(totals[0] / totals[1]),
|
| 107 |
+
"validation_loss": valid_total / valid_seen, "validation_J": valid_j / valid_seen,
|
| 108 |
+
"validation_mass_penalty": valid_mass / valid_seen})
|
| 109 |
+
if rank == 0:
|
| 110 |
+
checkpoint_path = ROOT / config["paths"]["checkpoint"]
|
| 111 |
+
metrics_path = ROOT / config["paths"]["training_metrics"]
|
| 112 |
+
checkpoint_path.parent.mkdir(parents=True, exist_ok=True); metrics_path.parent.mkdir(parents=True, exist_ok=True)
|
| 113 |
+
model_state = bare.state_dict()
|
| 114 |
+
torch.save({"model": model_state, "model_state_dict": model_state,
|
| 115 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 116 |
+
"model_config": config["model"], "epoch": int(config["train"]["epochs"]),
|
| 117 |
+
"eta": float(config["train"]["eta"]), "format_version": config["data"]["format_version"],
|
| 118 |
+
"variable_order": ["u", "h", "r"], "normalization": "u,h: center/scale; r: scale only",
|
| 119 |
+
"climate_mean_uh": train_set.data["climate_mean_uh"],
|
| 120 |
+
"climate_std_uhr": train_set.data["climate_std_uhr"], "seed": seed}, checkpoint_path)
|
| 121 |
+
metrics_path.write_text(json.dumps({"history": history}, indent=2) + "\n")
|
| 122 |
+
print(f"checkpoint={checkpoint_path.relative_to(ROOT)} validation_loss={history[-1]['validation_loss']:.6f}")
|
| 123 |
+
if distributed:
|
| 124 |
+
torch.distributed.destroy_process_group()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|