Upload folder using huggingface_hub
Browse files- README.md +150 -0
- conf/config.yaml +53 -0
- config.json +70 -0
- configuration.json +12 -0
- model/samudra.py +198 -0
- scripts/_bootstrap.py +11 -0
- scripts/fake_data.py +52 -0
- scripts/inference.py +109 -0
- scripts/result.py +54 -0
- scripts/train.py +197 -0
- weight/.gitkeep +0 -0
README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
frameworks: PyTorch
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
license: apache-2.0
|
| 6 |
+
tags:
|
| 7 |
+
- OneScience
|
| 8 |
+
- Earth Science
|
| 9 |
+
- Ocean Simulation
|
| 10 |
+
- Global Ocean Forecasting
|
| 11 |
+
- OM4
|
| 12 |
+
- ConvNeXt
|
| 13 |
+
tasks: []
|
| 14 |
+
datasets:
|
| 15 |
+
- M2LInES/Samudra-OM4
|
| 16 |
+
---
|
| 17 |
+
<p align="center">
|
| 18 |
+
<strong>
|
| 19 |
+
<span style="font-size: 30px;">Samudra</span>
|
| 20 |
+
</strong>
|
| 21 |
+
</p>
|
| 22 |
+
|
| 23 |
+
# Model Introduction
|
| 24 |
+
|
| 25 |
+
Samudra is a global ocean emulator developed by the M2LInES team.
|
| 26 |
+
|
| 27 |
+
Paper: Samudra: An AI Global Ocean Emulator for Climate
|
| 28 |
+
|
| 29 |
+
https://doi.org/10.1029/2024GL114318
|
| 30 |
+
|
| 31 |
+
# Model Description
|
| 32 |
+
|
| 33 |
+
Samudra predicts global ocean states on an approximately one-degree grid with a five-day time step. It is designed to emulate the evolution of the OM4 ocean circulation model with a deep neural network.
|
| 34 |
+
|
| 35 |
+
# Use Cases
|
| 36 |
+
|
| 37 |
+
| Scenario | Description |
|
| 38 |
+
| :---: | :--- |
|
| 39 |
+
| Global ocean simulation | Train the model on OM4 data following the 77-state-channel and 4-forcing-channel Samudra protocol. |
|
| 40 |
+
| Local quick validation | Use synthetic NPZ data to check training, inference, and ocean-field visualization. |
|
| 41 |
+
| ModelScope / OneCode execution | Download the standalone model package, install dependencies, and run the scripts directly. |
|
| 42 |
+
| Multi-GPU training | Launch PyTorch DistributedDataParallel with `torchrun`. |
|
| 43 |
+
|
| 44 |
+
# Usage Guide
|
| 45 |
+
|
| 46 |
+
## 1. OneCode Usage
|
| 47 |
+
|
| 48 |
+
Experience intelligent one-click AI4S programming through the OneCode online environment:
|
| 49 |
+
|
| 50 |
+
[Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 51 |
+
|
| 52 |
+
## 2. Manual Installation and Usage
|
| 53 |
+
|
| 54 |
+
**Hardware Requirements**
|
| 55 |
+
|
| 56 |
+
- A GPU or DCU is recommended.
|
| 57 |
+
- CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
|
| 58 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
|
| 59 |
+
|
| 60 |
+
### Download the Model Package
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
hf download OneScience-Group/Samudra --local-dir ./Samudra
|
| 64 |
+
cd Samudra
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### Install the Runtime Environment
|
| 68 |
+
|
| 69 |
+
**DCU Environment**
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# Please activate DTK and CONDA first
|
| 73 |
+
conda create -n onescience311 python=3.11 -y
|
| 74 |
+
conda activate onescience311
|
| 75 |
+
# uv installation is supported
|
| 76 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
**GPU Environment**
|
| 80 |
+
```bash
|
| 81 |
+
# Please activate CONDA first
|
| 82 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 83 |
+
conda activate onescience311
|
| 84 |
+
# uv installation is supported
|
| 85 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Training Data Introduction
|
| 89 |
+
|
| 90 |
+
The official training data is generated by NOAA/GFDL OM4. The M2LInES project provides the dataset and documentation:
|
| 91 |
+
|
| 92 |
+
https://huggingface.co/datasets/M2LInES/Samudra-OM4
|
| 93 |
+
|
| 94 |
+
The dataset must be converted to the native NPZ layout expected by this repository. When real OM4 data is unavailable, generate a synthetic fixture for pipeline validation:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
python scripts/fake_data.py
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
The synthetic fixture contains 77 prognostic state channels and 4 boundary-forcing channels and is not suitable for scientific evaluation.
|
| 101 |
+
|
| 102 |
+
### Training
|
| 103 |
+
|
| 104 |
+
Single GPU:
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
python scripts/train.py
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Multi-GPU:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
torchrun --nproc_per_node=8 scripts/train.py
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
The default checkpoint is saved to `data/checkpoints/model_bak.pth`.
|
| 117 |
+
|
| 118 |
+
### Training Weights
|
| 119 |
+
|
| 120 |
+
This repository provides a `weight/` directory for Samudra checkpoints. The weight files will be uploaded soon and are expected to be available in the near future.
|
| 121 |
+
|
| 122 |
+
### Inference
|
| 123 |
+
|
| 124 |
+
Inference performs an autoregressive rollout from `data/test.npz` and reads `data/checkpoints/model_bak.pth` by default:
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
python scripts/inference.py
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
Predictions are written to `result/output/prediction.npz`.
|
| 131 |
+
|
| 132 |
+
### Evaluation and Visualization
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
python scripts/result.py
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
The default outputs are `result/forecast_maps.png` and `result/temperature_profile.png`.
|
| 139 |
+
|
| 140 |
+
# Official OneScience Resources
|
| 141 |
+
|
| 142 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 143 |
+
| --- | --- | --- |
|
| 144 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 145 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 146 |
+
|
| 147 |
+
# Citation and License
|
| 148 |
+
|
| 149 |
+
- Paper: https://doi.org/10.1029/2024GL114318
|
| 150 |
+
- This repository is an independent reproduction of the original Samudra paper.
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
project:
|
| 2 |
+
name: Samudra
|
| 3 |
+
default_model_name: samudra
|
| 4 |
+
seed: 1
|
| 5 |
+
|
| 6 |
+
fake_data:
|
| 7 |
+
time: 12
|
| 8 |
+
height: 32
|
| 9 |
+
width: 64
|
| 10 |
+
|
| 11 |
+
data:
|
| 12 |
+
source_dir: ./data
|
| 13 |
+
output_dir: ./data
|
| 14 |
+
format: npz
|
| 15 |
+
grid:
|
| 16 |
+
height: 180
|
| 17 |
+
width: 360
|
| 18 |
+
depths_m: [2.5, 10.0, 22.5, 40.0, 65.0, 105.0, 165.0, 250.0, 375.0, 550.0, 775.0, 1050.0, 1400.0, 1850.0, 2400.0, 3100.0, 4000.0, 5000.0, 6000.0]
|
| 19 |
+
time_step_days: 5
|
| 20 |
+
input_state_steps: 2
|
| 21 |
+
output_state_steps: 2
|
| 22 |
+
recurrent_passes: 4
|
| 23 |
+
|
| 24 |
+
model:
|
| 25 |
+
variant: thermo_dynamic
|
| 26 |
+
input_channels: 158
|
| 27 |
+
output_channels: 154
|
| 28 |
+
state_channels: 77
|
| 29 |
+
widths: [200, 250, 300, 400]
|
| 30 |
+
dilations: [1, 2, 4, 8]
|
| 31 |
+
bottleneck_width: 400
|
| 32 |
+
bottleneck_dilation: 8
|
| 33 |
+
checkpoint: null
|
| 34 |
+
load_official_weights: false
|
| 35 |
+
|
| 36 |
+
training:
|
| 37 |
+
batch_size: 2
|
| 38 |
+
epochs: 10
|
| 39 |
+
learning_rate: 0.0006
|
| 40 |
+
weight_decay: 0.0
|
| 41 |
+
num_workers: 0
|
| 42 |
+
scheduler: cosine
|
| 43 |
+
save_frequency: 5
|
| 44 |
+
resume_checkpoint: null
|
| 45 |
+
output_dir: ./data/checkpoints
|
| 46 |
+
|
| 47 |
+
inference:
|
| 48 |
+
checkpoint: null
|
| 49 |
+
rollout_steps: 6
|
| 50 |
+
output_dir: ./result/output
|
| 51 |
+
|
| 52 |
+
visualization:
|
| 53 |
+
output_dir: ./result
|
config.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "Samudra",
|
| 3 |
+
"model_type": "samudra",
|
| 4 |
+
"architectures": [
|
| 5 |
+
"Samudra"
|
| 6 |
+
],
|
| 7 |
+
"framework": "PyTorch",
|
| 8 |
+
"domain": "ocean",
|
| 9 |
+
"task": "global-ocean-emulation",
|
| 10 |
+
"implementation": {
|
| 11 |
+
"entry_point": "model/samudra.py",
|
| 12 |
+
"scope": "thermo-dynamic Samudra v1 ConvNeXt U-Net with periodic longitude handling"
|
| 13 |
+
},
|
| 14 |
+
"architecture": {
|
| 15 |
+
"family": "dilated ConvNeXt U-Net",
|
| 16 |
+
"variant": "thermo_dynamic",
|
| 17 |
+
"input_channels": 158,
|
| 18 |
+
"output_channels": 154,
|
| 19 |
+
"state_channels": 77,
|
| 20 |
+
"widths": [
|
| 21 |
+
200,
|
| 22 |
+
250,
|
| 23 |
+
300,
|
| 24 |
+
400
|
| 25 |
+
],
|
| 26 |
+
"dilations": [
|
| 27 |
+
1,
|
| 28 |
+
2,
|
| 29 |
+
4,
|
| 30 |
+
8
|
| 31 |
+
],
|
| 32 |
+
"bottleneck_width": 400,
|
| 33 |
+
"activation": "capped GELU"
|
| 34 |
+
},
|
| 35 |
+
"data": {
|
| 36 |
+
"dataset": "NOAA/GFDL OM4",
|
| 37 |
+
"spatial_resolution_degrees": 1.0,
|
| 38 |
+
"time_step_days": 5,
|
| 39 |
+
"input_state_steps": 2,
|
| 40 |
+
"output_state_steps": 2,
|
| 41 |
+
"recurrent_passes": 4,
|
| 42 |
+
"depth_levels_m": [
|
| 43 |
+
2.5,
|
| 44 |
+
10.0,
|
| 45 |
+
22.5,
|
| 46 |
+
40.0,
|
| 47 |
+
65.0,
|
| 48 |
+
105.0,
|
| 49 |
+
165.0,
|
| 50 |
+
250.0,
|
| 51 |
+
375.0,
|
| 52 |
+
550.0,
|
| 53 |
+
775.0,
|
| 54 |
+
1050.0,
|
| 55 |
+
1400.0,
|
| 56 |
+
1850.0,
|
| 57 |
+
2400.0,
|
| 58 |
+
3100.0,
|
| 59 |
+
4000.0,
|
| 60 |
+
5000.0,
|
| 61 |
+
6000.0
|
| 62 |
+
],
|
| 63 |
+
"protocol": "samudra_v1_om4"
|
| 64 |
+
},
|
| 65 |
+
"configuration_sources": [
|
| 66 |
+
"conf/config.yaml",
|
| 67 |
+
"model/samudra.py",
|
| 68 |
+
"scripts/train.py"
|
| 69 |
+
]
|
| 70 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "ocean_emulation",
|
| 4 |
+
"model": "Samudra",
|
| 5 |
+
"input_format": "BCHW",
|
| 6 |
+
"protocol": "samudra_v1_om4",
|
| 7 |
+
"default_config": "conf/config.yaml",
|
| 8 |
+
"train": "scripts/train.py",
|
| 9 |
+
"inference": "scripts/inference.py",
|
| 10 |
+
"evaluation": "scripts/result.py",
|
| 11 |
+
"visualization": "scripts/result.py"
|
| 12 |
+
}
|
model/samudra.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paper-version Samudra ConvNeXt U-Net implementation.
|
| 2 |
+
|
| 3 |
+
The layer layout follows the official ``samudra_om4_v1/model.yaml`` and the
|
| 4 |
+
official v1 ``blocks.py`` implementation. Inputs are channel-stacked tensors
|
| 5 |
+
with shape ``[batch, 158, lat, lon]`` for the full thermo-dynamic variant.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Iterable
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from torch import nn
|
| 15 |
+
from torch.nn import functional as F
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def circular_pad_width(x: torch.Tensor, padding: int) -> torch.Tensor:
|
| 19 |
+
"""Apply periodic longitude padding, including widths smaller than padding."""
|
| 20 |
+
if padding == 0:
|
| 21 |
+
return x
|
| 22 |
+
width = x.shape[-1]
|
| 23 |
+
repeats = (padding + width - 1) // width
|
| 24 |
+
tiled = x.repeat(1, 1, 1, repeats)
|
| 25 |
+
left = tiled[..., -padding:]
|
| 26 |
+
right = tiled[..., :padding]
|
| 27 |
+
return torch.cat((left, x, right), dim=-1)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def globe_pad(x: torch.Tensor, padding: int) -> torch.Tensor:
|
| 31 |
+
x = circular_pad_width(x, padding)
|
| 32 |
+
return F.pad(x, (0, 0, padding, padding), mode="constant")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class CappedGELU(nn.Module):
|
| 36 |
+
def __init__(self, cap_value: float = 10.0):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.gelu = nn.GELU()
|
| 39 |
+
# Keep this constant out of DDP buffers. DDP synchronizes buffers at
|
| 40 |
+
# every forward, which would mutate the autograd version counter during
|
| 41 |
+
# a recurrent multi-step rollout.
|
| 42 |
+
self.cap = float(cap_value)
|
| 43 |
+
|
| 44 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 45 |
+
return torch.clamp(self.gelu(x), max=self.cap)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ConvNeXtBlock(nn.Module):
|
| 49 |
+
"""Official v1 residual block with dilated 3x3 convolutions."""
|
| 50 |
+
|
| 51 |
+
def __init__(self, in_channels: int, out_channels: int, dilation: int):
|
| 52 |
+
super().__init__()
|
| 53 |
+
self.in_channels = in_channels
|
| 54 |
+
self.out_channels = out_channels
|
| 55 |
+
self.dilation = dilation
|
| 56 |
+
self.padding = dilation
|
| 57 |
+
self.skip = nn.Identity() if in_channels == out_channels else nn.Conv2d(in_channels, out_channels, 1)
|
| 58 |
+
hidden = in_channels * 4
|
| 59 |
+
self.layers = nn.ModuleList(
|
| 60 |
+
[
|
| 61 |
+
nn.Conv2d(in_channels, hidden, 3, dilation=dilation),
|
| 62 |
+
nn.BatchNorm2d(hidden),
|
| 63 |
+
CappedGELU(),
|
| 64 |
+
nn.Conv2d(hidden, hidden, 3, dilation=dilation),
|
| 65 |
+
nn.BatchNorm2d(hidden),
|
| 66 |
+
CappedGELU(),
|
| 67 |
+
nn.Conv2d(hidden, out_channels, 1),
|
| 68 |
+
]
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 72 |
+
skip = self.skip(x)
|
| 73 |
+
for layer in self.layers:
|
| 74 |
+
if isinstance(layer, nn.Conv2d) and layer.kernel_size == (3, 3):
|
| 75 |
+
x = globe_pad(x, self.padding)
|
| 76 |
+
x = layer(x)
|
| 77 |
+
return skip + x
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class PeriodicBilinearUpsample(nn.Module):
|
| 81 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 82 |
+
width = x.shape[-1]
|
| 83 |
+
padded = F.pad(x, (1, 1, 0, 0), mode="circular")
|
| 84 |
+
result = F.interpolate(padded, scale_factor=2, mode="bilinear", align_corners=False)
|
| 85 |
+
return result[..., 2 : 2 + width * 2]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class SamudraUNet(nn.Module):
|
| 89 |
+
def __init__(self, in_channels: int, widths: Iterable[int], dilations: Iterable[int]):
|
| 90 |
+
super().__init__()
|
| 91 |
+
widths = list(widths)
|
| 92 |
+
dilations = list(dilations)
|
| 93 |
+
if len(widths) != 4 or len(dilations) != 4:
|
| 94 |
+
raise ValueError("paper v1 requires four widths and four dilation rates")
|
| 95 |
+
channels = [in_channels, *widths]
|
| 96 |
+
self.down_blocks = nn.ModuleList(
|
| 97 |
+
ConvNeXtBlock(channels[i], channels[i + 1], dilations[i]) for i in range(4)
|
| 98 |
+
)
|
| 99 |
+
self.pools = nn.ModuleList(nn.AvgPool2d(2) for _ in range(4))
|
| 100 |
+
self.middle = ConvNeXtBlock(widths[-1], widths[-1], dilations[-1])
|
| 101 |
+
self.first_up = PeriodicBilinearUpsample()
|
| 102 |
+
reversed_widths = list(reversed(widths))
|
| 103 |
+
reversed_dilations = list(reversed(dilations))
|
| 104 |
+
self.up_blocks = nn.ModuleList()
|
| 105 |
+
self.upsamples = nn.ModuleList()
|
| 106 |
+
current = widths[-1]
|
| 107 |
+
for index in range(3):
|
| 108 |
+
target = reversed_widths[index + 1]
|
| 109 |
+
self.up_blocks.append(ConvNeXtBlock(current, target, reversed_dilations[index]))
|
| 110 |
+
self.upsamples.append(PeriodicBilinearUpsample())
|
| 111 |
+
current = target
|
| 112 |
+
self.final = ConvNeXtBlock(current, widths[0], reversed_dilations[-1])
|
| 113 |
+
self.out_channels = widths[0]
|
| 114 |
+
|
| 115 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 116 |
+
skips = []
|
| 117 |
+
for block, pool in zip(self.down_blocks, self.pools):
|
| 118 |
+
x = block(x)
|
| 119 |
+
skips.append(x)
|
| 120 |
+
x = pool(x)
|
| 121 |
+
x = self.middle(x)
|
| 122 |
+
x = self.first_up(x)
|
| 123 |
+
x = self._merge(x, skips[-1])
|
| 124 |
+
for block, up, skip in zip(self.up_blocks, self.upsamples, reversed(skips[:-1])):
|
| 125 |
+
x = block(x)
|
| 126 |
+
x = up(x)
|
| 127 |
+
x = self._merge(x, skip)
|
| 128 |
+
return self.final(x)
|
| 129 |
+
|
| 130 |
+
@staticmethod
|
| 131 |
+
def _merge(x: torch.Tensor, skip: torch.Tensor) -> torch.Tensor:
|
| 132 |
+
height = min(x.shape[-2], skip.shape[-2])
|
| 133 |
+
width = min(x.shape[-1], skip.shape[-1])
|
| 134 |
+
x = x[..., :height, :width]
|
| 135 |
+
skip = skip[..., :height, :width]
|
| 136 |
+
if x.shape[1] != skip.shape[1]:
|
| 137 |
+
raise RuntimeError(f"skip channel mismatch: {x.shape[1]} != {skip.shape[1]}")
|
| 138 |
+
return x + skip
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class Samudra(nn.Module):
|
| 142 |
+
"""Samudra v1 single-scale emulator.
|
| 143 |
+
|
| 144 |
+
``variant='thermo_dynamic'`` uses the paper's 158-to-154 interface.
|
| 145 |
+
``variant='thermo'`` uses 78 input channels and 154 output channels.
|
| 146 |
+
"""
|
| 147 |
+
|
| 148 |
+
def __init__(
|
| 149 |
+
self,
|
| 150 |
+
variant: str = "thermo_dynamic",
|
| 151 |
+
input_channels: int | None = None,
|
| 152 |
+
output_channels: int | None = None,
|
| 153 |
+
widths: Iterable[int] = (200, 250, 300, 400),
|
| 154 |
+
dilations: Iterable[int] = (1, 2, 4, 8),
|
| 155 |
+
):
|
| 156 |
+
super().__init__()
|
| 157 |
+
if variant not in {"thermo_dynamic", "thermo"}:
|
| 158 |
+
raise ValueError("variant must be thermo_dynamic or thermo")
|
| 159 |
+
default_input = 158 if variant == "thermo_dynamic" else 78
|
| 160 |
+
default_output = 154
|
| 161 |
+
self.variant = variant
|
| 162 |
+
self.input_channels = default_input if input_channels is None else input_channels
|
| 163 |
+
self.output_channels = default_output if output_channels is None else output_channels
|
| 164 |
+
self.unet = SamudraUNet(self.input_channels, widths, dilations)
|
| 165 |
+
self.decoder = nn.Conv2d(self.unet.out_channels, self.output_channels, 3)
|
| 166 |
+
|
| 167 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 168 |
+
if x.ndim != 4 or x.shape[1] != self.input_channels:
|
| 169 |
+
raise ValueError(f"expected [batch, {self.input_channels}, lat, lon], got {tuple(x.shape)}")
|
| 170 |
+
features = self.unet(x)
|
| 171 |
+
features = globe_pad(features, 1)
|
| 172 |
+
return self.decoder(features)
|
| 173 |
+
|
| 174 |
+
def load_official_checkpoint(self, path: str | Path, strict: bool = True) -> None:
|
| 175 |
+
checkpoint = torch.load(path, map_location="cpu")
|
| 176 |
+
if isinstance(checkpoint, dict) and "state_dict" in checkpoint:
|
| 177 |
+
checkpoint = checkpoint["state_dict"]
|
| 178 |
+
if not isinstance(checkpoint, dict):
|
| 179 |
+
raise TypeError("official checkpoint must contain a state dictionary")
|
| 180 |
+
# Older local checkpoints stored the fixed GELU cap as a buffer.
|
| 181 |
+
checkpoint = {key: value for key, value in checkpoint.items() if not key.endswith(".cap")}
|
| 182 |
+
self.load_state_dict(checkpoint, strict=strict)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def build_model(config: dict) -> Samudra:
|
| 186 |
+
"""Build a model from the project's YAML model section."""
|
| 187 |
+
model_config = config.get("model", config)
|
| 188 |
+
model = Samudra(
|
| 189 |
+
variant=model_config.get("variant", "thermo_dynamic"),
|
| 190 |
+
input_channels=model_config.get("input_channels"),
|
| 191 |
+
output_channels=model_config.get("output_channels"),
|
| 192 |
+
widths=model_config.get("widths", (200, 250, 300, 400)),
|
| 193 |
+
dilations=model_config.get("dilations", (1, 2, 4, 8)),
|
| 194 |
+
)
|
| 195 |
+
checkpoint = model_config.get("checkpoint")
|
| 196 |
+
if checkpoint:
|
| 197 |
+
model.load_official_checkpoint(checkpoint)
|
| 198 |
+
return model
|
scripts/_bootstrap.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Make the project root importable when an entry point is run by path."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 7 |
+
SCRIPT_DIR = str(ROOT / "scripts")
|
| 8 |
+
if SCRIPT_DIR in sys.path:
|
| 9 |
+
sys.path.remove(SCRIPT_DIR)
|
| 10 |
+
if str(ROOT) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(ROOT))
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate virtual data in the native Samudra NPZ layout."""
|
| 2 |
+
|
| 3 |
+
try:
|
| 4 |
+
from ._bootstrap import ROOT
|
| 5 |
+
except ImportError:
|
| 6 |
+
from _bootstrap import ROOT
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import yaml
|
| 13 |
+
|
| 14 |
+
STATE_CHANNELS = 77
|
| 15 |
+
BOUNDARY_CHANNELS = 4
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def generate(path: str | Path, time: int, height: int, width: int, seed: int) -> None:
|
| 19 |
+
if time < 10:
|
| 20 |
+
raise ValueError("time must be at least 10 for four-pass recurrent training")
|
| 21 |
+
rng = np.random.default_rng(seed)
|
| 22 |
+
prognostic = rng.standard_normal((time, STATE_CHANNELS, height, width), dtype=np.float32)
|
| 23 |
+
boundary = rng.standard_normal((time, BOUNDARY_CHANNELS, height, width), dtype=np.float32)
|
| 24 |
+
output = Path(path)
|
| 25 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 26 |
+
np.savez_compressed(output, prognostic=prognostic, boundary=boundary)
|
| 27 |
+
print(f"saved native data: {output} prognostic={prognostic.shape} boundary={boundary.shape}")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def main() -> None:
|
| 31 |
+
parser = argparse.ArgumentParser()
|
| 32 |
+
parser.add_argument("--config", default="./conf/config.yaml")
|
| 33 |
+
parser.add_argument("--train-output", default="./data/train.npz")
|
| 34 |
+
parser.add_argument("--test-output", default="./data/test.npz")
|
| 35 |
+
parser.add_argument("--time", type=int, default=None)
|
| 36 |
+
parser.add_argument("--height", type=int, default=None)
|
| 37 |
+
parser.add_argument("--width", type=int, default=None)
|
| 38 |
+
parser.add_argument("--seed", type=int, default=None)
|
| 39 |
+
args = parser.parse_args()
|
| 40 |
+
with open(args.config, encoding="utf-8") as handle:
|
| 41 |
+
config = yaml.safe_load(handle)
|
| 42 |
+
fake = config.get("fake_data", {})
|
| 43 |
+
time = args.time or int(fake.get("time", 12))
|
| 44 |
+
height = args.height or int(fake.get("height", 32))
|
| 45 |
+
width = args.width or int(fake.get("width", 64))
|
| 46 |
+
seed = args.seed if args.seed is not None else int(config["project"].get("seed", 1))
|
| 47 |
+
generate(args.train_output, time, height, width, seed)
|
| 48 |
+
generate(args.test_output, time, height, width, seed + 1)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Autoregressive inference for the paper-version Samudra model."""
|
| 2 |
+
|
| 3 |
+
try:
|
| 4 |
+
from ._bootstrap import ROOT
|
| 5 |
+
except ImportError:
|
| 6 |
+
from _bootstrap import ROOT
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
import yaml
|
| 14 |
+
|
| 15 |
+
from model.samudra import build_model
|
| 16 |
+
|
| 17 |
+
STATE_CHANNELS = 77
|
| 18 |
+
BOUNDARY_CHANNELS = 4
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_data(path: str | Path) -> tuple[np.ndarray, np.ndarray]:
|
| 22 |
+
"""Load and validate native Samudra time-major arrays."""
|
| 23 |
+
with np.load(path) as data:
|
| 24 |
+
prognostic = np.asarray(data["prognostic"], dtype=np.float32)
|
| 25 |
+
boundary = np.asarray(data["boundary"], dtype=np.float32)
|
| 26 |
+
if prognostic.ndim != 4 or prognostic.shape[1] != STATE_CHANNELS:
|
| 27 |
+
raise ValueError("prognostic must have shape [time, 77, lat, lon]")
|
| 28 |
+
if boundary.ndim != 4 or boundary.shape[1] != BOUNDARY_CHANNELS:
|
| 29 |
+
raise ValueError("boundary must have shape [time, 4, lat, lon]")
|
| 30 |
+
if prognostic.shape[0] != boundary.shape[0] or prognostic.shape[2:] != boundary.shape[2:]:
|
| 31 |
+
raise ValueError("prognostic and boundary time/grid dimensions must match")
|
| 32 |
+
return prognostic, boundary
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_checkpoint(model: torch.nn.Module, path: str, device: torch.device) -> None:
|
| 36 |
+
try:
|
| 37 |
+
checkpoint = torch.load(path, map_location=device, weights_only=True)
|
| 38 |
+
except TypeError:
|
| 39 |
+
checkpoint = torch.load(path, map_location=device)
|
| 40 |
+
state = checkpoint.get("model", checkpoint.get("state_dict", checkpoint))
|
| 41 |
+
model.load_state_dict(state)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@torch.no_grad()
|
| 45 |
+
def rollout(model, prognostic, boundary, steps, device):
|
| 46 |
+
# Training uses one consecutive boundary frame for each recurrent pass.
|
| 47 |
+
available_steps = boundary.shape[0] - 1
|
| 48 |
+
if prognostic.shape[0] < 2 or available_steps < 1:
|
| 49 |
+
raise ValueError(
|
| 50 |
+
"rollout requires at least two states and two boundary time samples"
|
| 51 |
+
)
|
| 52 |
+
if steps > available_steps:
|
| 53 |
+
raise ValueError(
|
| 54 |
+
f"requested {steps} model steps, but input data provides only {available_steps}; "
|
| 55 |
+
f"use --steps <= {available_steps} or provide a longer data file"
|
| 56 |
+
)
|
| 57 |
+
previous = torch.from_numpy(prognostic[0]).to(device)
|
| 58 |
+
current = torch.from_numpy(prognostic[1]).to(device)
|
| 59 |
+
predictions = []
|
| 60 |
+
for step in range(steps):
|
| 61 |
+
forcing = torch.from_numpy(boundary[step + 1]).to(device)
|
| 62 |
+
inputs = torch.cat((previous, current, forcing), dim=0).unsqueeze(0)
|
| 63 |
+
prediction = model(inputs).squeeze(0)
|
| 64 |
+
previous, current = prediction[:STATE_CHANNELS], prediction[STATE_CHANNELS:]
|
| 65 |
+
predictions.extend((previous.cpu().numpy(), current.cpu().numpy()))
|
| 66 |
+
return np.stack(predictions)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def main() -> None:
|
| 70 |
+
parser = argparse.ArgumentParser()
|
| 71 |
+
parser.add_argument("--config", default="./conf/config.yaml")
|
| 72 |
+
parser.add_argument("--data", default="./data/test.npz")
|
| 73 |
+
parser.add_argument("--checkpoint", default=None)
|
| 74 |
+
parser.add_argument("--output", default=None)
|
| 75 |
+
parser.add_argument("--steps", type=int, default=None)
|
| 76 |
+
parser.add_argument("--device", default=None)
|
| 77 |
+
args = parser.parse_args()
|
| 78 |
+
if args.steps is not None and args.steps < 1:
|
| 79 |
+
raise ValueError("--steps must be positive")
|
| 80 |
+
with open(args.config, encoding="utf-8") as handle:
|
| 81 |
+
config = yaml.safe_load(handle)
|
| 82 |
+
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
| 83 |
+
model = build_model(config).to(device)
|
| 84 |
+
checkpoint = args.checkpoint or config["inference"].get("checkpoint") or "./data/checkpoints/model_bak.pth"
|
| 85 |
+
output_path = args.output or config["inference"].get("output_dir", "./result/output")
|
| 86 |
+
if Path(output_path).suffix != ".npz":
|
| 87 |
+
output_path = str(Path(output_path) / "prediction.npz")
|
| 88 |
+
load_checkpoint(model, checkpoint, device)
|
| 89 |
+
model.eval()
|
| 90 |
+
prognostic, boundary = load_data(args.data)
|
| 91 |
+
configured_steps = int(config["inference"].get("rollout_steps", 1))
|
| 92 |
+
if configured_steps < 1:
|
| 93 |
+
raise ValueError("inference.rollout_steps must be positive")
|
| 94 |
+
available_steps = boundary.shape[0] - 1
|
| 95 |
+
steps = args.steps if args.steps is not None else min(configured_steps, available_steps)
|
| 96 |
+
if args.steps is None and steps < configured_steps:
|
| 97 |
+
print(
|
| 98 |
+
f"input data supports {available_steps} model steps; "
|
| 99 |
+
f"using rollout_steps={steps}"
|
| 100 |
+
)
|
| 101 |
+
predictions = rollout(model, prognostic, boundary, steps, device)
|
| 102 |
+
output = Path(output_path)
|
| 103 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
np.savez_compressed(output, predictions=predictions, forcing=boundary[1 : steps + 1])
|
| 105 |
+
print(f"saved predictions: {output} shape={predictions.shape}")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create diagnostic plots from Samudra NPZ rollout outputs."""
|
| 2 |
+
|
| 3 |
+
try:
|
| 4 |
+
from ._bootstrap import ROOT
|
| 5 |
+
except ImportError:
|
| 6 |
+
from _bootstrap import ROOT
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import matplotlib.pyplot as plt
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
DEPTHS = (2.5, 10.0, 22.5, 40.0, 65.0, 105.0, 165.0, 250.0, 375.0, 550.0, 775.0, 1050.0, 1400.0, 1850.0, 2400.0, 3100.0, 4000.0, 5000.0, 6000.0)
|
| 15 |
+
PROGNOSTIC_CHANNELS = tuple(
|
| 16 |
+
f"{name}_{level}"
|
| 17 |
+
for name in ("uo", "vo", "thetao", "so")
|
| 18 |
+
for level in range(len(DEPTHS))
|
| 19 |
+
) + ("zos",)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def plot_rollout(prediction_path: str, output_dir: str) -> None:
|
| 23 |
+
with np.load(prediction_path) as data:
|
| 24 |
+
predictions = np.asarray(data["predictions"])
|
| 25 |
+
output = Path(output_dir)
|
| 26 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 4), constrained_layout=True)
|
| 28 |
+
axes[0].imshow(predictions[0, PROGNOSTIC_CHANNELS.index("zos")], cmap="coolwarm", aspect="auto")
|
| 29 |
+
axes[0].set_title("SSH forecast")
|
| 30 |
+
axes[1].imshow(predictions[0, PROGNOSTIC_CHANNELS.index("thetao_0")], cmap="turbo", aspect="auto")
|
| 31 |
+
axes[1].set_title("Surface potential temperature")
|
| 32 |
+
fig.savefig(output / "forecast_maps.png", dpi=150)
|
| 33 |
+
plt.close(fig)
|
| 34 |
+
indices = [PROGNOSTIC_CHANNELS.index(f"thetao_{i}") for i in range(len(DEPTHS))]
|
| 35 |
+
profile = predictions[:, indices].mean(axis=(0, 2, 3))
|
| 36 |
+
fig, ax = plt.subplots(figsize=(5, 5), constrained_layout=True)
|
| 37 |
+
ax.plot(profile, DEPTHS, marker="o")
|
| 38 |
+
ax.invert_yaxis()
|
| 39 |
+
ax.set(xlabel="potential temperature", ylabel="depth (m)", title="Mean temperature profile")
|
| 40 |
+
fig.savefig(output / "temperature_profile.png", dpi=150)
|
| 41 |
+
plt.close(fig)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main() -> None:
|
| 45 |
+
parser = argparse.ArgumentParser()
|
| 46 |
+
parser.add_argument("--prediction", default="./result/output/prediction.npz")
|
| 47 |
+
parser.add_argument("--output-dir", default="./result")
|
| 48 |
+
args = parser.parse_args()
|
| 49 |
+
plot_rollout(args.prediction, args.output_dir)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
main()
|
| 54 |
+
print("save to ./result/")
|
scripts/train.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-device and torchrun-based distributed training for Samudra v1."""
|
| 2 |
+
|
| 3 |
+
try:
|
| 4 |
+
from ._bootstrap import ROOT
|
| 5 |
+
except ImportError:
|
| 6 |
+
from _bootstrap import ROOT
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import os
|
| 10 |
+
import random
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
import yaml
|
| 16 |
+
from torch import nn
|
| 17 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 18 |
+
from torch.utils.data import DataLoader, Dataset, DistributedSampler
|
| 19 |
+
|
| 20 |
+
from model.samudra import build_model
|
| 21 |
+
|
| 22 |
+
STATE_CHANNELS = 77
|
| 23 |
+
BOUNDARY_CHANNELS = 4
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def load_data(path: str | Path) -> tuple[np.ndarray, np.ndarray]:
|
| 27 |
+
"""Load and validate native Samudra time-major arrays."""
|
| 28 |
+
with np.load(path) as data:
|
| 29 |
+
prognostic = np.asarray(data["prognostic"], dtype=np.float32)
|
| 30 |
+
boundary = np.asarray(data["boundary"], dtype=np.float32)
|
| 31 |
+
if prognostic.ndim != 4 or prognostic.shape[1] != STATE_CHANNELS:
|
| 32 |
+
raise ValueError("prognostic must have shape [time, 77, lat, lon]")
|
| 33 |
+
if boundary.ndim != 4 or boundary.shape[1] != BOUNDARY_CHANNELS:
|
| 34 |
+
raise ValueError("boundary must have shape [time, 4, lat, lon]")
|
| 35 |
+
if prognostic.shape[0] != boundary.shape[0] or prognostic.shape[2:] != boundary.shape[2:]:
|
| 36 |
+
raise ValueError("prognostic and boundary time/grid dimensions must match")
|
| 37 |
+
return prognostic, boundary
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class SamudraDataset(Dataset):
|
| 41 |
+
"""Build recurrent training windows from native Samudra arrays."""
|
| 42 |
+
|
| 43 |
+
def __init__(self, path: str | Path, recurrent_passes: int):
|
| 44 |
+
self.prognostic, self.boundary = load_data(path)
|
| 45 |
+
self.recurrent_passes = recurrent_passes
|
| 46 |
+
self.end = self.prognostic.shape[0] - 2 * recurrent_passes
|
| 47 |
+
if self.end <= 1:
|
| 48 |
+
raise ValueError("dataset does not contain enough samples for recurrent training")
|
| 49 |
+
|
| 50 |
+
def __len__(self) -> int:
|
| 51 |
+
return self.end - 1
|
| 52 |
+
|
| 53 |
+
def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 54 |
+
t = index + 1
|
| 55 |
+
history = np.stack((self.prognostic[t - 1], self.prognostic[t]))
|
| 56 |
+
forcing = self.boundary[t : t + self.recurrent_passes]
|
| 57 |
+
labels = np.stack(
|
| 58 |
+
[
|
| 59 |
+
np.concatenate(
|
| 60 |
+
(self.prognostic[t + 2 * step + 1], self.prognostic[t + 2 * step + 2])
|
| 61 |
+
)
|
| 62 |
+
for step in range(self.recurrent_passes)
|
| 63 |
+
]
|
| 64 |
+
)
|
| 65 |
+
return torch.from_numpy(history), torch.from_numpy(forcing), torch.from_numpy(labels)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def set_seed(seed: int) -> None:
|
| 69 |
+
random.seed(seed)
|
| 70 |
+
np.random.seed(seed)
|
| 71 |
+
torch.manual_seed(seed)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def load_config(path: str) -> dict:
|
| 75 |
+
with open(path, encoding="utf-8") as handle:
|
| 76 |
+
return yaml.safe_load(handle)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def distributed_setup(device: torch.device) -> tuple[int, int, bool]:
|
| 80 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 81 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 82 |
+
distributed = world_size > 1
|
| 83 |
+
if distributed:
|
| 84 |
+
backend = "nccl" if device.type == "cuda" else "gloo"
|
| 85 |
+
torch.distributed.init_process_group(backend=backend)
|
| 86 |
+
return rank, world_size, distributed
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def save_checkpoint(path: Path, model: nn.Module, optimizer: torch.optim.Optimizer, scheduler, epoch: int, loss: float) -> None:
|
| 90 |
+
state = model.module.state_dict() if isinstance(model, DistributedDataParallel) else model.state_dict()
|
| 91 |
+
torch.save({"epoch": epoch, "loss": loss, "model": state, "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict()}, path)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def freeze_batch_norm_stats(module: nn.Module) -> None:
|
| 95 |
+
"""Prevent recurrent forwards from mutating BatchNorm buffers in one graph."""
|
| 96 |
+
for child in module.modules():
|
| 97 |
+
if isinstance(child, nn.modules.batchnorm._BatchNorm):
|
| 98 |
+
child.eval()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def train(
|
| 102 |
+
config_path: str,
|
| 103 |
+
data_path: str,
|
| 104 |
+
device_name: str | None = None,
|
| 105 |
+
epochs_override: int | None = None,
|
| 106 |
+
output_dir_override: str | None = None,
|
| 107 |
+
) -> None:
|
| 108 |
+
config = load_config(config_path)
|
| 109 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 110 |
+
local_rank = int(os.environ.get("LOCAL_RANK", rank))
|
| 111 |
+
if device_name:
|
| 112 |
+
device = torch.device(device_name)
|
| 113 |
+
elif torch.cuda.is_available():
|
| 114 |
+
device = torch.device(f"cuda:{local_rank}")
|
| 115 |
+
else:
|
| 116 |
+
device = torch.device("cpu")
|
| 117 |
+
rank, world_size, distributed = distributed_setup(device)
|
| 118 |
+
set_seed(int(config["project"].get("seed", 1)) + rank)
|
| 119 |
+
if device.type == "cuda":
|
| 120 |
+
torch.cuda.set_device(device)
|
| 121 |
+
|
| 122 |
+
recurrent_passes = int(config["data"].get("recurrent_passes", 1))
|
| 123 |
+
dataset = SamudraDataset(data_path, recurrent_passes)
|
| 124 |
+
sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank, shuffle=True) if distributed else None
|
| 125 |
+
loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], shuffle=sampler is None, sampler=sampler, num_workers=config["training"].get("num_workers", 0), pin_memory=device.type == "cuda")
|
| 126 |
+
model = build_model(config).to(device)
|
| 127 |
+
if distributed:
|
| 128 |
+
model = DistributedDataParallel(
|
| 129 |
+
model,
|
| 130 |
+
device_ids=[device.index] if device.type == "cuda" else None,
|
| 131 |
+
broadcast_buffers=False,
|
| 132 |
+
)
|
| 133 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=config["training"]["learning_rate"], weight_decay=config["training"].get("weight_decay", 0.0))
|
| 134 |
+
epochs = epochs_override if epochs_override is not None else int(config["training"]["epochs"])
|
| 135 |
+
if epochs < 1:
|
| 136 |
+
raise ValueError("epochs must be positive")
|
| 137 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
|
| 138 |
+
resume = config["training"].get("resume_checkpoint")
|
| 139 |
+
start_epoch = 0
|
| 140 |
+
if resume:
|
| 141 |
+
try:
|
| 142 |
+
checkpoint = torch.load(resume, map_location=device, weights_only=True)
|
| 143 |
+
except TypeError:
|
| 144 |
+
checkpoint = torch.load(resume, map_location=device)
|
| 145 |
+
target = model.module if isinstance(model, DistributedDataParallel) else model
|
| 146 |
+
state = {key: value for key, value in checkpoint["model"].items() if not key.endswith(".cap")}
|
| 147 |
+
target.load_state_dict(state)
|
| 148 |
+
optimizer.load_state_dict(checkpoint["optimizer"])
|
| 149 |
+
scheduler.load_state_dict(checkpoint["scheduler"])
|
| 150 |
+
start_epoch = int(checkpoint["epoch"]) + 1
|
| 151 |
+
|
| 152 |
+
output_dir = Path(output_dir_override or config["training"]["output_dir"])
|
| 153 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 154 |
+
for epoch in range(start_epoch, epochs):
|
| 155 |
+
if sampler is not None:
|
| 156 |
+
sampler.set_epoch(epoch)
|
| 157 |
+
model.train()
|
| 158 |
+
freeze_batch_norm_stats(model)
|
| 159 |
+
total_loss = 0.0
|
| 160 |
+
for batch in loader:
|
| 161 |
+
optimizer.zero_grad(set_to_none=True)
|
| 162 |
+
history, forcing, labels = (item.to(device, non_blocking=True) for item in batch)
|
| 163 |
+
previous, current = history[:, 0], history[:, 1]
|
| 164 |
+
losses = []
|
| 165 |
+
for step in range(recurrent_passes):
|
| 166 |
+
prediction = model(torch.cat((previous, current, forcing[:, step]), dim=1))
|
| 167 |
+
losses.append(nn.functional.mse_loss(prediction, labels[:, step]))
|
| 168 |
+
previous, current = prediction[:, :STATE_CHANNELS], prediction[:, STATE_CHANNELS:]
|
| 169 |
+
loss = torch.stack(losses).mean()
|
| 170 |
+
loss.backward()
|
| 171 |
+
optimizer.step()
|
| 172 |
+
total_loss += float(loss.detach())
|
| 173 |
+
scheduler.step()
|
| 174 |
+
mean_loss = total_loss / max(1, len(loader))
|
| 175 |
+
if rank == 0:
|
| 176 |
+
print(f"epoch={epoch + 1} loss={mean_loss:.6e} lr={scheduler.get_last_lr()[0]:.6e}")
|
| 177 |
+
frequency = int(config["training"].get("save_frequency", 5))
|
| 178 |
+
if (epoch + 1) % frequency == 0 or epoch + 1 == epochs:
|
| 179 |
+
save_checkpoint(output_dir / f"epoch_{epoch + 1:04d}.pt", model, optimizer, scheduler, epoch, mean_loss)
|
| 180 |
+
save_checkpoint(output_dir / "model_bak.pth", model, optimizer, scheduler, epoch, mean_loss)
|
| 181 |
+
if distributed:
|
| 182 |
+
torch.distributed.destroy_process_group()
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def main() -> None:
|
| 186 |
+
parser = argparse.ArgumentParser()
|
| 187 |
+
parser.add_argument("--config", default="./conf/config.yaml")
|
| 188 |
+
parser.add_argument("--data", default="./data/train.npz")
|
| 189 |
+
parser.add_argument("--device", default=None)
|
| 190 |
+
parser.add_argument("--epochs", type=int, default=None)
|
| 191 |
+
parser.add_argument("--output-dir", default=None)
|
| 192 |
+
args = parser.parse_args()
|
| 193 |
+
train(args.config, args.data, args.device, args.epochs, args.output_dir)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|