Publish FuXi-Ocean engineering reproduction
Browse files- .gitattributes +1 -34
- README.md +153 -0
- README_zh.md +154 -0
- conf/config.yaml +74 -0
- config.json +31 -0
- configuration.json +16 -0
- model/fuxi_ocean.py +205 -0
- scripts/fake_data.py +76 -0
- scripts/inference.py +81 -0
- scripts/result.py +61 -0
- scripts/train.py +118 -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,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
- zh
|
| 6 |
+
tags:
|
| 7 |
+
- OneScience
|
| 8 |
+
- ocean-forecasting
|
| 9 |
+
- eddy-resolving
|
| 10 |
+
frameworks: PyTorch
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<strong><span style="font-size: 30px;">FuXi-Ocean</span></strong>
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
# Model Introduction
|
| 18 |
+
|
| 19 |
+
FuXi-Ocean performs autoregressive ocean-field prediction at six-hour intervals from historical ocean states and initialization-time atmospheric variables, providing an executable engineering workflow for research on high-resolution ocean forecasting methods.
|
| 20 |
+
|
| 21 |
+
Paper: A deep learning global ocean forecasting model with sub-daily and eddy-resolving resolution
|
| 22 |
+
https://doi.org/10.1038/s41612-026-01444-2
|
| 23 |
+
|
| 24 |
+
# Model Description
|
| 25 |
+
|
| 26 |
+
The method was proposed by research teams from Tianjin University, the Shanghai Academy of Artificial Intelligence for Science, Fudan University, the Shanghai Innovation Institute, FuXi Intelligent Computing Technology Co. Ltd, Hainan Tropical Ocean University, and collaborating institutions. The paper trains on HYCOM reanalysis and analysis ocean fields together with ERA5 near-surface atmospheric variables. The model performs autoregressive prediction of temperature, salinity, zonal current, meridional current, and sea-surface height at six-hour intervals.
|
| 27 |
+
|
| 28 |
+
# Use Cases
|
| 29 |
+
|
| 30 |
+
| Use Case | Description |
|
| 31 |
+
| :---: | :--- |
|
| 32 |
+
| Autoregressive ocean-field prediction | Validate the primary task of predicting subsequent ocean states from four historical states and initialization-time atmospheric variables on synthetic sampled tiles. |
|
| 33 |
+
| Core-method validation | Validate shared convolutional encoding, a spatiotemporal prior, historical-feature fusion, low-resolution attention, shared decoding, and latitude-weighted Charbonnier loss. |
|
| 34 |
+
| Local engineering validation | Validate indexing, overlap crops, data contracts, inference, evaluation, and visualization interfaces for the complete `2160×4320` scientific-grid protocol; the current workflow does not produce a complete global prediction. |
|
| 35 |
+
| ModelScope/OneCode execution | Validate structured data, training, inference, per-lead ocean-forecast metrics, and visualization in ModelScope or OneCode environments. |
|
| 36 |
+
| Multi-GPU training | Validate distributed training and the checkpoint workflow through `torchrun`. |
|
| 37 |
+
|
| 38 |
+
# Usage Instructions
|
| 39 |
+
|
| 40 |
+
## 1.OneCode
|
| 41 |
+
|
| 42 |
+
Experience intelligent, one-click AI4S programming through the OneCode online environment:
|
| 43 |
+
|
| 44 |
+
[Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 45 |
+
|
| 46 |
+
## 2. Download and Installation
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
hf download OneScience-Group/FuXi-Ocean --local-dir ./FuXi-Ocean
|
| 50 |
+
cd FuXi-Ocean
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
### Environment Dependencies
|
| 54 |
+
|
| 55 |
+
**Hardware Requirements**
|
| 56 |
+
|
| 57 |
+
- A GPU or DCU is recommended.
|
| 58 |
+
- A CPU can be used for connectivity validation with the default small-sample configuration.
|
| 59 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or later, or the OneScience-recommended version matching the current cluster, is recommended.
|
| 60 |
+
|
| 61 |
+
**DCU Environment**
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
# Activate DTK and Conda first
|
| 65 |
+
conda create -n onescience311 python=3.11 -y
|
| 66 |
+
conda activate onescience311
|
| 67 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
**GPU Environment**
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# Activate Conda first
|
| 74 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 75 |
+
conda activate onescience311
|
| 76 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### Training Data
|
| 80 |
+
|
| 81 |
+
The paper uses HYCOM ocean fields and ERA5 near-surface atmospheric variables to predict the next six-hour ocean state on a `2160×4320` global grid from four historical states. This repository uses six structured synthetic tiles while preserving 105 ocean channels, five atmospheric channels, 26 depth levels, and the complete global indexing protocol. The synthetic data validate the engineering workflow only and do not represent the real HYCOM or ERA5 distributions, training scale, or paper performance.
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
python scripts/fake_data.py
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### Training
|
| 88 |
+
|
| 89 |
+
For single-device training, use:
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
python scripts/train.py
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
For multi-GPU training, use:
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
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
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
Training uses latitude-weighted Charbonnier loss and multi-step autoregressive rollout; the default configuration reduces tiles, samples, model scale, epochs, and rollout length without reducing the real variables or global-coordinate protocol. Formal experiments require real HYCOM and ERA5 data and full-scale computing resources, with training artifacts saved to:
|
| 102 |
+
|
| 103 |
+
```text
|
| 104 |
+
result/checkpoints/fuxi_ocean.pt
|
| 105 |
+
result/training/metrics.json
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Trained Weights
|
| 109 |
+
|
| 110 |
+
This repository does not bundle official weights under `weight/`, and no confirmed official model weights were identified. The complete resource link in the paper's code-availability statement is https://doi.org/10.5281/zenodo.17412508; it must not be treated as a confirmed weight-download link. The local checkpoint is an engineering artifact trained on synthetic `sampled_tiles` and makes no compatibility claim with paper or official weights.
|
| 111 |
+
|
| 112 |
+
### Inference
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
python scripts/inference.py
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
Inference loads the locally trained checkpoint and runs three autoregressive steps on test tiles by default while retaining a record of the paper's 40-step protocol. Outputs are explicitly marked as `sampled_tiles` and `is_complete_global=false` and include their coverage fraction, so they must not be treated as a completed global prediction. Numerical outputs and metadata are saved to:
|
| 119 |
+
|
| 120 |
+
```text
|
| 121 |
+
result/output/predictions.npz
|
| 122 |
+
result/output/metadata.json
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
### Evaluation and Visualization
|
| 126 |
+
|
| 127 |
+
```bash
|
| 128 |
+
python scripts/result.py
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
Evaluation computes latitude-weighted RMSE and MBE per lead for the model and persistence baseline over sampled tiles, reports S, T, U, V, and SSH unit groups separately, and retains per-channel results without averaging all 105 channels across incompatible units. Synthetic-data evaluation validates the engineering workflow only and does not represent formal paper performance. Results are saved to:
|
| 132 |
+
|
| 133 |
+
```text
|
| 134 |
+
result/evaluation/metrics.json
|
| 135 |
+
result/evaluation/lead_metrics.png
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
# Official OneScience Information
|
| 139 |
+
|
| 140 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 141 |
+
| --- | --- | --- |
|
| 142 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 143 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 144 |
+
|
| 145 |
+
# Citation and License
|
| 146 |
+
|
| 147 |
+
This repository is an independent engineering reproduction of the public FuXi-Ocean specifications.
|
| 148 |
+
|
| 149 |
+
Use of this repository's code is subject to the Apache-2.0 license terms.
|
| 150 |
+
|
| 151 |
+
The original paper is published under CC BY 4.0, and its citation and use are subject to that license.
|
| 152 |
+
|
| 153 |
+
Use of HYCOM, ERA5, official Zenodo artifacts, and other third-party data or weights remains subject to the licenses and terms of their respective projects.
|
README_zh.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- zh
|
| 5 |
+
- en
|
| 6 |
+
tags:
|
| 7 |
+
- OneScience
|
| 8 |
+
- 地球科学
|
| 9 |
+
- 海洋预报
|
| 10 |
+
- 涡分辨率模型
|
| 11 |
+
frameworks: PyTorch
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
<p align="center">
|
| 15 |
+
<strong><span style="font-size: 30px;">FuXi-Ocean</span></strong>
|
| 16 |
+
</p>
|
| 17 |
+
|
| 18 |
+
# 模型介绍
|
| 19 |
+
|
| 20 |
+
FuXi-Ocean 用于根据历史海洋状态和初始化大气变量进行 6 小时间隔的海洋场自回归预测,为高时空分辨率海洋预报方法研究提供可运行的工程验证流程。
|
| 21 |
+
|
| 22 |
+
论文:A deep learning global ocean forecasting model with sub-daily and eddy-resolving resolution
|
| 23 |
+
https://doi.org/10.1038/s41612-026-01444-2
|
| 24 |
+
|
| 25 |
+
# 模型描述
|
| 26 |
+
|
| 27 |
+
该方法由天津大学、上海科学智能研究院、复旦大学、上海创新研究院、上海伏羲智算科技有限公司和海南热带海洋学院等机构的研究团队提出。论文使用 HYCOM 再分析与分析海洋场以及 ERA5 近地面大气变量开展训练。模型执行 6 小时间隔的温度、盐度、纬向流、经向流和海表高度自回归预测任务。
|
| 28 |
+
|
| 29 |
+
# 适用场景
|
| 30 |
+
|
| 31 |
+
| 场景 | 说明 |
|
| 32 |
+
| :---: | :--- |
|
| 33 |
+
| 海洋场自回归预测 | 在合成 sampled tiles 上验证由 4 帧历史海洋状态和初始化大气变量预测后续海洋状态的主任务。 |
|
| 34 |
+
| 核心方法验证 | 验证共享卷积编码、时空先验、历史特征融合、低分辨率 attention、共享解码和纬度加权 Charbonnier 损失。 |
|
| 35 |
+
| 本地工程验证 | 验证完整 `2160×4320` 科学网格的索引、重叠裁剪、数据契约、推理、评估和可视化接口;当前不生成完整全球预测。 |
|
| 36 |
+
| ModelScope/OneCode 运行 | 在 ModelScope 或 OneCode 环境中验证结构化数据、训练、推理、逐时效海洋预报指标和可视化流程。 |
|
| 37 |
+
| 多卡训练 | 通过 `torchrun` 验证分布式训练和 checkpoint 流程。 |
|
| 38 |
+
|
| 39 |
+
# 使用说明
|
| 40 |
+
|
| 41 |
+
## 1.OneCode
|
| 42 |
+
|
| 43 |
+
可通过 OneCode 在线环境体验智能化一键式 AI4S 编程:
|
| 44 |
+
|
| 45 |
+
[点击体验智能化一键式 AI4S 编程](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 46 |
+
|
| 47 |
+
## 2.下载安装
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
modelscope download --model OneScience/FuXi-Ocean --local_dir ./FuXi-Ocean
|
| 51 |
+
cd FuXi-Ocean
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### 环境依赖
|
| 55 |
+
|
| 56 |
+
**硬件要求**
|
| 57 |
+
|
| 58 |
+
- 推荐使用 GPU 或 DCU 运行。
|
| 59 |
+
- CPU 可用于默认小样本配置的连通性验证。
|
| 60 |
+
- DCU 用户需预先安装 DTK,建议使用 DTK 25.04.2 以上版本或与当前集群匹配的 OneScience 推荐版本。
|
| 61 |
+
|
| 62 |
+
**DCU环境**
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
# 请首先激活 DTK 及 Conda
|
| 66 |
+
conda create -n onescience311 python=3.11 -y
|
| 67 |
+
conda activate onescience311
|
| 68 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
**GPU环境**
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
# 请首先激活 Conda
|
| 75 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 76 |
+
conda activate onescience311
|
| 77 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
### 训练数据介绍
|
| 81 |
+
|
| 82 |
+
论文使用 HYCOM 海洋场和 ERA5 近地面大气变量,模型根据 4 个历史时刻预测 `2160×4320` 全球网格上的下一 6 小时海洋状态。本仓库使用 6 个结构化合成 tile,保留 105 个海洋通道、5 个大气通道、26 个深度层和完整全球索引协议。虚拟数据仅用于验证工程流程,不代表 HYCOM 或 ERA5 的真实数据分布、训练规模或论文性能。
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
python scripts/fake_data.py
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### 训练
|
| 89 |
+
|
| 90 |
+
单卡训练可使用:
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
python scripts/train.py
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
多卡训练可使用:
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
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
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
训练使用纬度加权 Charbonnier 损失和多步自回归流程;默认配置仅缩小 tile、样本数、模型规模、训练轮数和 rollout 步数,不缩小真实变量与全局坐标协议。正式实验需要真实 HYCOM、ERA5 数据和完整计算资源,训练产物保存到:
|
| 103 |
+
|
| 104 |
+
```text
|
| 105 |
+
result/checkpoints/fuxi_ocean.pt
|
| 106 |
+
result/training/metrics.json
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
### 训练权重
|
| 110 |
+
|
| 111 |
+
本仓库不在 `weight/` 中内置官方权重,也未发现可确认的官方模型权重。论文代码可用性部分提供的完整资源链接为 https://doi.org/10.5281/zenodo.17412508 ,该链接不能视为已确认的权重下载链接。当前本地 checkpoint 是合成 `sampled_tiles` 的工程训练产物,不声明与论文或官方权重兼容。
|
| 112 |
+
|
| 113 |
+
### 推理
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
python scripts/inference.py
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
推理加载本地训练 checkpoint,默认在测试 tile 上执行 3 步自回归预测,同时记录论文的 40 步协议。结果明确标记为 `sampled_tiles`、`is_complete_global=false` 并保存覆盖率,不能视为已完成的全球预测;数值结果和元数据保存到:
|
| 120 |
+
|
| 121 |
+
```text
|
| 122 |
+
result/output/predictions.npz
|
| 123 |
+
result/output/metadata.json
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
### 评估和可视化
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
python scripts/result.py
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
评估在已采样 tile ���逐时效计算模型和 persistence 基线的纬度加权 RMSE 与 MBE,按 S、T、U、V 和 SSH 单位组报告并保留逐通道结果,不对单位不同的 105 个通道求统一均值。合成数据评估仅用于验证工程流程,不代表论文正式性能;结果保存到:
|
| 133 |
+
|
| 134 |
+
```text
|
| 135 |
+
result/evaluation/metrics.json
|
| 136 |
+
result/evaluation/lead_metrics.png
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
# OneScience官方信息
|
| 140 |
+
|
| 141 |
+
| 平台 | OneScience 主仓库 | Skills 仓库 |
|
| 142 |
+
| --- | --- | --- |
|
| 143 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 144 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 145 |
+
|
| 146 |
+
# 引用与许可证
|
| 147 |
+
|
| 148 |
+
本仓库为 FuXi-Ocean 公开规格的独立工程复现版本。
|
| 149 |
+
|
| 150 |
+
本仓库代码的使用应遵循 Apache-2.0 许可证条款。
|
| 151 |
+
|
| 152 |
+
原论文按照 CC BY 4.0 许可证发布,引用和使用时应遵循该许可证条款。
|
| 153 |
+
|
| 154 |
+
HYCOM、ERA5、官方 Zenodo 产物和其他第三方数据或权重的使用应遵循各自项目的许可证及使用条款。
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
seed: 2026
|
| 2 |
+
data:
|
| 3 |
+
path: data/fuxi_ocean_tiles.npz
|
| 4 |
+
format_version: fuxi_ocean_sampled_tiles_v2
|
| 5 |
+
input_shape: [4, 105, 2160, 4320]
|
| 6 |
+
atmosphere_shape: [5, 2160, 4320]
|
| 7 |
+
output_shape: [105, 2160, 4320]
|
| 8 |
+
global_height: 2160
|
| 9 |
+
global_width: 4320
|
| 10 |
+
history_steps: 4
|
| 11 |
+
ocean_channels: 105
|
| 12 |
+
atmosphere_channels: 5
|
| 13 |
+
time_step_hours: 6
|
| 14 |
+
tile_height: 16
|
| 15 |
+
tile_width: 16
|
| 16 |
+
tile_overlap: 4
|
| 17 |
+
train_samples: 4
|
| 18 |
+
test_samples: 2
|
| 19 |
+
depth_levels_m: [0, 2, 4, 6, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 200, 300, 400, 500, 600, 800, 1000, 1250, 1500]
|
| 20 |
+
model:
|
| 21 |
+
embed_dim: 24
|
| 22 |
+
patch_size: 2
|
| 23 |
+
encoder_levels: 2
|
| 24 |
+
attention_layers: 2
|
| 25 |
+
attention_heads: 4
|
| 26 |
+
epsilon: 0.000001
|
| 27 |
+
training:
|
| 28 |
+
epochs: 1
|
| 29 |
+
batch_size: 1
|
| 30 |
+
learning_rate: 0.0005
|
| 31 |
+
weight_decay: 0.00001
|
| 32 |
+
multistep_rollout: 2
|
| 33 |
+
runtime:
|
| 34 |
+
device: auto
|
| 35 |
+
ddp_backend: gloo
|
| 36 |
+
num_threads: 2
|
| 37 |
+
inference:
|
| 38 |
+
rollout_steps: 3
|
| 39 |
+
paper_model:
|
| 40 |
+
paper_determined:
|
| 41 |
+
input_shape: [4, 105, 2160, 4320]
|
| 42 |
+
atmosphere_shape: [5, 2160, 4320]
|
| 43 |
+
output_shape: [105, 2160, 4320]
|
| 44 |
+
ocean_channel_groups: {S: 26, T: 26, U: 26, V: 26, SSH: 1}
|
| 45 |
+
atmosphere_variables: [U10M, V10M, MSL, T2M, D2M]
|
| 46 |
+
shared_convolutional_patch_encoder: true
|
| 47 |
+
spatiotemporal_prior_network: true
|
| 48 |
+
history_fusion: four encodings concatenated then 1x1 convolution
|
| 49 |
+
low_resolution_attention: stacked attention with AdaLN and FFN
|
| 50 |
+
shared_transposed_convolution_decoder: true
|
| 51 |
+
skip_connections: true
|
| 52 |
+
depth_dependent_land_sea_mask: true
|
| 53 |
+
loss: latitude-weighted Charbonnier
|
| 54 |
+
training_stages: single-step pretraining then multi-step fine-tuning
|
| 55 |
+
metrics: [latitude-weighted RMSE, MBE, persistence]
|
| 56 |
+
missing_architecture:
|
| 57 |
+
patch_size: MISSING
|
| 58 |
+
hidden_width: MISSING
|
| 59 |
+
attention_layer_count: MISSING
|
| 60 |
+
attention_head_count: MISSING
|
| 61 |
+
exact_prior_network: MISSING
|
| 62 |
+
normalization_statistics: MISSING
|
| 63 |
+
optimizer_and_schedule: MISSING
|
| 64 |
+
batch_size_and_epochs: MISSING
|
| 65 |
+
multistep_finetuning_horizon: MISSING
|
| 66 |
+
rollout_steps: 40
|
| 67 |
+
time_step_hours: 6
|
| 68 |
+
paths:
|
| 69 |
+
checkpoint: result/checkpoints/fuxi_ocean.pt
|
| 70 |
+
training_metrics: result/training/metrics.json
|
| 71 |
+
inference: result/output/predictions.npz
|
| 72 |
+
inference_metadata: result/output/metadata.json
|
| 73 |
+
evaluation_metrics: result/evaluation/metrics.json
|
| 74 |
+
evaluation_plot: result/evaluation/lead_metrics.png
|
config.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"format_version": "1.0",
|
| 3 |
+
"model_name": "FuXi-Ocean",
|
| 4 |
+
"model_type": "tile-streamed-global-ocean-transformer",
|
| 5 |
+
"architectures": ["FuXiOcean"],
|
| 6 |
+
"framework": "PyTorch",
|
| 7 |
+
"domain": "earth-science-ocean",
|
| 8 |
+
"license": "Apache-2.0",
|
| 9 |
+
"paper": {
|
| 10 |
+
"title": "A deep learning global ocean forecasting model with sub-daily and eddy-resolving resolution",
|
| 11 |
+
"doi": "10.1038/s41612-026-01444-2",
|
| 12 |
+
"paper_license": "CC-BY-4.0"
|
| 13 |
+
},
|
| 14 |
+
"implementation": {
|
| 15 |
+
"entry_point": "model/fuxi_ocean.py",
|
| 16 |
+
"train_script": "scripts/train.py",
|
| 17 |
+
"inference_script": "scripts/inference.py",
|
| 18 |
+
"evaluation_script": "scripts/result.py",
|
| 19 |
+
"synthetic_data_script": "scripts/fake_data.py"
|
| 20 |
+
},
|
| 21 |
+
"scientific_grid": {
|
| 22 |
+
"history": 4,
|
| 23 |
+
"ocean_channels": 105,
|
| 24 |
+
"atmosphere_channels": 5,
|
| 25 |
+
"height": 2160,
|
| 26 |
+
"width": 4320,
|
| 27 |
+
"hours_per_step": 6,
|
| 28 |
+
"forecast_steps": 40
|
| 29 |
+
},
|
| 30 |
+
"execution_protocol": "Tiles retain all 105 channels, physical coordinates, and indices into the unchanged 2160x4320 global grid. Tile dimensions are not the scientific grid dimensions."
|
| 31 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"format_version": "1.0",
|
| 3 |
+
"framework": "PyTorch",
|
| 4 |
+
"task": "global-ocean-autoregressive-forecasting",
|
| 5 |
+
"model": "FuXi-Ocean",
|
| 6 |
+
"input_format": "sampled_tiles with deterministic full-grid enumeration and overlap crops",
|
| 7 |
+
"global_ocean_shape": [4, 105, 2160, 4320],
|
| 8 |
+
"atmosphere_shape": [5, 2160, 4320],
|
| 9 |
+
"output_shape": [105, 2160, 4320],
|
| 10 |
+
"time_step_hours": 6,
|
| 11 |
+
"paper_rollout_steps": 40,
|
| 12 |
+
"default_config": "conf/config.yaml",
|
| 13 |
+
"training": "scripts/train.py",
|
| 14 |
+
"inference": "scripts/inference.py",
|
| 15 |
+
"evaluation": "scripts/result.py"
|
| 16 |
+
}
|
model/fuxi_ocean.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Executable tile-streamed FuXi-Ocean reconstruction."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
from torch import nn
|
| 8 |
+
import torch.distributed as dist
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
FORMAT_VERSION = "fuxi_ocean_sampled_tiles_v2"
|
| 12 |
+
GLOBAL_SHAPE = (2160, 4320)
|
| 13 |
+
HISTORY_STEPS = 4
|
| 14 |
+
OCEAN_CHANNELS = 105
|
| 15 |
+
ATMOSPHERE_CHANNELS = 5
|
| 16 |
+
DEPTHS = (0, 2, 4, 6, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125,
|
| 17 |
+
150, 200, 300, 400, 500, 600, 800, 1000, 1250, 1500)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def enumerate_global_tiles(global_shape, tile_shape, overlap=0):
|
| 21 |
+
"""Enumerate all windows and non-overlapping ownership crops in row-major order."""
|
| 22 |
+
height, width = map(int, global_shape)
|
| 23 |
+
tile_h, tile_w = map(int, tile_shape)
|
| 24 |
+
overlap = int(overlap)
|
| 25 |
+
if min(height, width, tile_h, tile_w) <= 0 or overlap < 0 or overlap >= min(tile_h, tile_w):
|
| 26 |
+
raise ValueError("invalid global/tile shape or overlap")
|
| 27 |
+
|
| 28 |
+
def starts(size, tile):
|
| 29 |
+
values = list(range(0, max(size - tile, 0) + 1, tile - overlap))
|
| 30 |
+
last = max(size - tile, 0)
|
| 31 |
+
if not values or values[-1] != last:
|
| 32 |
+
values.append(last)
|
| 33 |
+
return values
|
| 34 |
+
|
| 35 |
+
ys, xs = starts(height, tile_h), starts(width, tile_w)
|
| 36 |
+
records = []
|
| 37 |
+
for row, y0 in enumerate(ys):
|
| 38 |
+
for column, x0 in enumerate(xs):
|
| 39 |
+
y1, x1 = min(y0 + tile_h, height), min(x0 + tile_w, width)
|
| 40 |
+
top = 0 if row == 0 else (ys[row - 1] + tile_h + y0) // 2 - y0
|
| 41 |
+
bottom = y1 - y0 if row == len(ys) - 1 else (y1 + ys[row + 1]) // 2 - y0
|
| 42 |
+
left = 0 if column == 0 else (xs[column - 1] + tile_w + x0) // 2 - x0
|
| 43 |
+
right = x1 - x0 if column == len(xs) - 1 else (x1 + xs[column + 1]) // 2 - x0
|
| 44 |
+
records.append((y0, y1, x0, x1, top, bottom, left, right))
|
| 45 |
+
return np.asarray(records, dtype=np.int32)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def assemble_tiles(tiles, tile_records, global_shape, require_complete=True):
|
| 49 |
+
"""Crop overlap by ownership records and assemble a dense field for verification/use."""
|
| 50 |
+
tiles = np.asarray(tiles)
|
| 51 |
+
records = np.asarray(tile_records)
|
| 52 |
+
height, width = map(int, global_shape)
|
| 53 |
+
output = np.zeros((*tiles.shape[1:-2], height, width), dtype=tiles.dtype)
|
| 54 |
+
coverage = np.zeros((height, width), dtype=np.uint8)
|
| 55 |
+
for tile, record in zip(tiles, records):
|
| 56 |
+
y0, _y1, x0, _x1, top, bottom, left, right = map(int, record)
|
| 57 |
+
output[..., y0 + top:y0 + bottom, x0 + left:x0 + right] = tile[..., top:bottom, left:right]
|
| 58 |
+
coverage[y0 + top:y0 + bottom, x0 + left:x0 + right] = 1
|
| 59 |
+
is_complete = bool(coverage.all())
|
| 60 |
+
if require_complete and not is_complete:
|
| 61 |
+
raise ValueError("tile set does not completely cover the global grid")
|
| 62 |
+
return output, coverage
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def ocean_channel_depth_indices():
|
| 66 |
+
"""Channel order: S26, T26, U26, V26, SSH."""
|
| 67 |
+
return np.asarray(list(range(26)) * 4 + [0], dtype=np.int64)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def channel_mask(depth_mask):
|
| 71 |
+
indices = torch.as_tensor(ocean_channel_depth_indices(), device=depth_mask.device)
|
| 72 |
+
return depth_mask.index_select(1, indices)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ChannelLayerNorm(nn.Module):
|
| 76 |
+
def __init__(self, channels):
|
| 77 |
+
super().__init__()
|
| 78 |
+
self.norm = nn.LayerNorm(channels)
|
| 79 |
+
|
| 80 |
+
def forward(self, x):
|
| 81 |
+
return self.norm(x.movedim(1, -1)).movedim(-1, 1)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class PriorNetwork(nn.Module):
|
| 85 |
+
def __init__(self, embed_dim):
|
| 86 |
+
super().__init__()
|
| 87 |
+
self.spatial = nn.Conv2d(3, embed_dim, 1)
|
| 88 |
+
self.temporal = nn.Sequential(nn.Linear(6, embed_dim), nn.SiLU(), nn.Linear(embed_dim, embed_dim))
|
| 89 |
+
|
| 90 |
+
def forward(self, coordinates, bathymetry, time_features):
|
| 91 |
+
spatial = self.spatial(torch.cat((coordinates, bathymetry), dim=1))
|
| 92 |
+
hour, day, step = time_features.unbind(dim=1)
|
| 93 |
+
temporal = torch.stack((torch.sin(2 * math.pi * hour / 24), torch.cos(2 * math.pi * hour / 24),
|
| 94 |
+
torch.sin(2 * math.pi * day / 366), torch.cos(2 * math.pi * day / 366),
|
| 95 |
+
torch.sin(step / 40), torch.cos(step / 40)), dim=1)
|
| 96 |
+
return spatial, self.temporal(temporal)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class SharedPatchEncoder(nn.Module):
|
| 100 |
+
def __init__(self, in_channels, embed_dim, patch_size, levels):
|
| 101 |
+
super().__init__()
|
| 102 |
+
layers = []
|
| 103 |
+
channels = in_channels
|
| 104 |
+
self.skip_channels = []
|
| 105 |
+
for level in range(levels):
|
| 106 |
+
out = embed_dim * (2 ** level)
|
| 107 |
+
layers += [nn.Conv2d(channels, out, patch_size, patch_size), ChannelLayerNorm(out), nn.GELU()]
|
| 108 |
+
self.skip_channels.append(out)
|
| 109 |
+
channels = out
|
| 110 |
+
self.layers = nn.ModuleList(layers)
|
| 111 |
+
self.levels = levels
|
| 112 |
+
|
| 113 |
+
def forward(self, x, prior):
|
| 114 |
+
skips = []
|
| 115 |
+
for level in range(self.levels):
|
| 116 |
+
conv, norm, activation = self.layers[level * 3:level * 3 + 3]
|
| 117 |
+
x = conv(x)
|
| 118 |
+
local_prior = torch.nn.functional.interpolate(prior, size=x.shape[-2:], mode="bilinear", align_corners=False)
|
| 119 |
+
repeats = math.ceil(x.shape[1] / local_prior.shape[1])
|
| 120 |
+
modulation = local_prior.repeat(1, repeats, 1, 1)[:, :x.shape[1]]
|
| 121 |
+
x = activation(norm(x * (1.0 + torch.tanh(modulation))))
|
| 122 |
+
skips.append(x)
|
| 123 |
+
return x, skips
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class AdaLNBlock(nn.Module):
|
| 127 |
+
def __init__(self, dim, heads):
|
| 128 |
+
super().__init__()
|
| 129 |
+
self.norm1, self.norm2 = nn.LayerNorm(dim), nn.LayerNorm(dim)
|
| 130 |
+
self.modulation = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 4))
|
| 131 |
+
self.attention = nn.MultiheadAttention(dim, heads, batch_first=True)
|
| 132 |
+
self.ffn = nn.Sequential(nn.Linear(dim, dim * 4), nn.GELU(), nn.Linear(dim * 4, dim))
|
| 133 |
+
|
| 134 |
+
def forward(self, x, context):
|
| 135 |
+
scale1, shift1, scale2, shift2 = self.modulation(context).chunk(4, dim=-1)
|
| 136 |
+
q = self.norm1(x) * (1 + scale1[:, None]) + shift1[:, None]
|
| 137 |
+
x = x + self.attention(q, q, q, need_weights=False)[0]
|
| 138 |
+
q = self.norm2(x) * (1 + scale2[:, None]) + shift2[:, None]
|
| 139 |
+
return x + self.ffn(q)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class FuXiOcean(nn.Module):
|
| 143 |
+
def __init__(self, embed_dim=24, patch_size=2, encoder_levels=2, attention_layers=2, attention_heads=4):
|
| 144 |
+
super().__init__()
|
| 145 |
+
self.prior = PriorNetwork(embed_dim)
|
| 146 |
+
self.encoder = SharedPatchEncoder(OCEAN_CHANNELS + ATMOSPHERE_CHANNELS, embed_dim, patch_size, encoder_levels)
|
| 147 |
+
latent = embed_dim * 2 ** (encoder_levels - 1)
|
| 148 |
+
self.fusion = nn.Sequential(nn.Conv2d(HISTORY_STEPS * latent, latent, 1), ChannelLayerNorm(latent), nn.GELU())
|
| 149 |
+
self.blocks = nn.ModuleList(AdaLNBlock(latent, attention_heads) for _ in range(attention_layers))
|
| 150 |
+
self.context_projection = nn.Linear(embed_dim, latent)
|
| 151 |
+
decoder = []
|
| 152 |
+
for level in reversed(range(encoder_levels)):
|
| 153 |
+
skip = embed_dim * 2 ** level
|
| 154 |
+
out = embed_dim * 2 ** max(level - 1, 0)
|
| 155 |
+
decoder.append(nn.Sequential(nn.ConvTranspose2d(latent + skip, out, patch_size, patch_size),
|
| 156 |
+
ChannelLayerNorm(out), nn.GELU()))
|
| 157 |
+
latent = out
|
| 158 |
+
self.decoder = nn.ModuleList(decoder)
|
| 159 |
+
self.output = nn.Conv2d(latent, OCEAN_CHANNELS, 1)
|
| 160 |
+
|
| 161 |
+
def forward(self, ocean_history, atmosphere, coordinates, bathymetry, depth_mask, time_features):
|
| 162 |
+
if ocean_history.ndim != 5 or ocean_history.shape[1:3] != (HISTORY_STEPS, OCEAN_CHANNELS):
|
| 163 |
+
raise ValueError("ocean_history must be [B,4,105,tile_h,tile_w]")
|
| 164 |
+
spatial_prior, temporal_prior = self.prior(coordinates, bathymetry, time_features)
|
| 165 |
+
encoded, all_skips = [], []
|
| 166 |
+
for index in range(HISTORY_STEPS):
|
| 167 |
+
state = torch.cat((ocean_history[:, index], atmosphere), dim=1)
|
| 168 |
+
latent, skips = self.encoder(state, spatial_prior)
|
| 169 |
+
encoded.append(latent)
|
| 170 |
+
all_skips.append(skips)
|
| 171 |
+
x = self.fusion(torch.cat(encoded, dim=1))
|
| 172 |
+
shape = x.shape
|
| 173 |
+
tokens = x.flatten(2).transpose(1, 2)
|
| 174 |
+
context = self.context_projection(temporal_prior)
|
| 175 |
+
for block in self.blocks:
|
| 176 |
+
tokens = block(tokens, context)
|
| 177 |
+
x = tokens.transpose(1, 2).reshape(shape)
|
| 178 |
+
for decoder_index, decoder in enumerate(self.decoder):
|
| 179 |
+
level = len(self.decoder) - 1 - decoder_index
|
| 180 |
+
skip = torch.stack([values[level] for values in all_skips]).mean(dim=0)
|
| 181 |
+
x = decoder(torch.cat((x, skip), dim=1))
|
| 182 |
+
prediction = self.output(x)
|
| 183 |
+
return prediction * channel_mask(depth_mask).to(prediction.dtype)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def latitude_weighted_charbonnier(prediction, target, latitude_deg, valid_mask, epsilon=1e-6):
|
| 187 |
+
weights = torch.cos(torch.deg2rad(latitude_deg)).clamp_min(0)[:, None, :, None]
|
| 188 |
+
mask = valid_mask.to(prediction.dtype)
|
| 189 |
+
numerator = (torch.sqrt((prediction - target) ** 2 + epsilon ** 2) * weights * mask).sum()
|
| 190 |
+
denominator = (weights * mask).sum()
|
| 191 |
+
if dist.is_available() and dist.is_initialized():
|
| 192 |
+
global_numerator = numerator.detach().clone()
|
| 193 |
+
global_denominator = denominator.detach().clone()
|
| 194 |
+
dist.all_reduce(global_numerator, op=dist.ReduceOp.SUM)
|
| 195 |
+
dist.all_reduce(global_denominator, op=dist.ReduceOp.SUM)
|
| 196 |
+
# DDP averages gradients, so this yields the gradient of the global ratio.
|
| 197 |
+
return numerator * dist.get_world_size() / global_denominator.clamp_min(1.0)
|
| 198 |
+
return numerator / denominator.clamp_min(1.0)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def weighted_error_sums(prediction, target, latitude_deg, valid_mask):
|
| 202 |
+
weights = np.cos(np.deg2rad(latitude_deg))[:, None, :, None] * valid_mask
|
| 203 |
+
error = prediction - target
|
| 204 |
+
return ((weights * error ** 2).sum(axis=(0, 2, 3)),
|
| 205 |
+
(weights * error).sum(axis=(0, 2, 3)), weights.sum(axis=(0, 2, 3)))
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create small physical-coordinate tiles without allocating a global dense sample."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import yaml
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
sys.path.insert(0, str(ROOT))
|
| 14 |
+
from model.fuxi_ocean import enumerate_global_tiles
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main():
|
| 18 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 19 |
+
data = config["data"]
|
| 20 |
+
rng = np.random.default_rng(config["seed"])
|
| 21 |
+
count = data["train_samples"] + data["test_samples"]
|
| 22 |
+
th, tw = data["tile_height"], data["tile_width"]
|
| 23 |
+
history, channels, atmosphere_channels = data["history_steps"], data["ocean_channels"], data["atmosphere_channels"]
|
| 24 |
+
global_records = enumerate_global_tiles((data["global_height"], data["global_width"]), (th, tw), data["tile_overlap"])
|
| 25 |
+
selected_indices = np.linspace(0, len(global_records) - 1, count, dtype=np.int64)
|
| 26 |
+
selected_records = global_records[selected_indices]
|
| 27 |
+
origins = selected_records[:, (0, 2)]
|
| 28 |
+
ocean = np.empty((count, history, channels, th, tw), dtype=np.float32)
|
| 29 |
+
targets = np.empty((count, channels, th, tw), dtype=np.float32)
|
| 30 |
+
atmosphere = np.empty((count, atmosphere_channels, th, tw), dtype=np.float32)
|
| 31 |
+
latitude = np.empty((count, th), dtype=np.float32)
|
| 32 |
+
longitude = np.empty((count, tw), dtype=np.float32)
|
| 33 |
+
bathymetry = np.empty((count, 1, th, tw), dtype=np.float32)
|
| 34 |
+
depth_mask = np.empty((count, 26, th, tw), dtype=np.float32)
|
| 35 |
+
time_features = np.empty((count, 3), dtype=np.float32)
|
| 36 |
+
yy, xx = np.meshgrid(np.arange(th), np.arange(tw), indexing="ij")
|
| 37 |
+
channel_scale = np.linspace(0.2, 1.0, channels, dtype=np.float32)[:, None, None]
|
| 38 |
+
for sample, (y0, x0) in enumerate(origins):
|
| 39 |
+
lat = 90.0 - (y0 + np.arange(th) + 0.5) * 180.0 / data["global_height"]
|
| 40 |
+
lon = (x0 + np.arange(tw) + 0.5) * 360.0 / data["global_width"]
|
| 41 |
+
latitude[sample], longitude[sample] = lat, lon
|
| 42 |
+
wave = np.sin(np.deg2rad(lat))[:, None] + 0.5 * np.cos(np.deg2rad(lon))[None, :]
|
| 43 |
+
for history_index in range(history):
|
| 44 |
+
ocean[sample, history_index] = channel_scale * (wave + 0.025 * history_index) + rng.normal(0, 0.005, (channels, th, tw))
|
| 45 |
+
atmosphere[sample] = np.stack([wave + 0.03 * k for k in range(atmosphere_channels)])
|
| 46 |
+
bathymetry[sample, 0] = 200 + 1400 * (0.5 + 0.5 * np.sin((xx + x0) / 12))
|
| 47 |
+
for depth, depth_m in enumerate(data["depth_levels_m"]):
|
| 48 |
+
depth_mask[sample, depth] = bathymetry[sample, 0] >= depth_m
|
| 49 |
+
targets[sample] = ocean[sample, -1] + channel_scale * (0.015 * atmosphere[sample, 0] - 0.006 * atmosphere[sample, 1])
|
| 50 |
+
time_features[sample] = ((sample * 6) % 24, 100 + sample // 4, 0)
|
| 51 |
+
channel_depth = np.asarray(list(range(26)) * 4 + [0])
|
| 52 |
+
targets *= depth_mask[:, channel_depth]
|
| 53 |
+
path = ROOT / data["path"]
|
| 54 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 55 |
+
input_shape = np.asarray([history, channels, data["global_height"], data["global_width"]])
|
| 56 |
+
atmosphere_shape = np.asarray([atmosphere_channels, data["global_height"], data["global_width"]])
|
| 57 |
+
output_shape = np.asarray([channels, data["global_height"], data["global_width"]])
|
| 58 |
+
owned_pixels = sum((r[5] - r[4]) * (r[7] - r[6]) for r in selected_records)
|
| 59 |
+
coverage_fraction = owned_pixels / (data["global_height"] * data["global_width"])
|
| 60 |
+
np.savez_compressed(path, format_version=data["format_version"], input_shape=input_shape,
|
| 61 |
+
atmosphere_shape=atmosphere_shape, output_shape=output_shape,
|
| 62 |
+
global_tile_records=global_records, selected_tile_indices=selected_indices,
|
| 63 |
+
selected_tile_records=selected_records, tile_origins=origins,
|
| 64 |
+
tile_record_fields=np.asarray(["y0", "y1", "x0", "x1", "crop_top", "crop_bottom", "crop_left", "crop_right"]),
|
| 65 |
+
tile_order="row-major", overlap_crop_semantics="midpoint ownership; crops partition global grid exactly",
|
| 66 |
+
coverage_fraction=coverage_fraction, is_complete_global=False, synthetic=True,
|
| 67 |
+
ocean=ocean, atmosphere=atmosphere, targets=targets, latitude_deg=latitude,
|
| 68 |
+
longitude_deg=longitude, bathymetry_m=bathymetry, depth_mask=depth_mask,
|
| 69 |
+
time_features=time_features, train_count=data["train_samples"])
|
| 70 |
+
print(json.dumps({"path": str(path.relative_to(ROOT)), "input_shape": input_shape.tolist(),
|
| 71 |
+
"tile_shape": list(ocean.shape), "global_tile_count": len(global_records),
|
| 72 |
+
"coverage_fraction": coverage_fraction, "bytes": path.stat().st_size}))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Autoregressive tile inference with the unchanged scientific global-grid contract."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
import yaml
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
sys.path.insert(0, str(ROOT))
|
| 13 |
+
sys.path.insert(0, str(ROOT / "scripts"))
|
| 14 |
+
from model.fuxi_ocean import FORMAT_VERSION, FuXiOcean, assemble_tiles
|
| 15 |
+
from train import TileDataset, validate_data_contract
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def main():
|
| 19 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 20 |
+
torch.set_num_threads(config["runtime"]["num_threads"])
|
| 21 |
+
device = torch.device("cuda" if torch.cuda.is_available() and config["runtime"]["device"] != "cpu" else "cpu")
|
| 22 |
+
checkpoint = torch.load(ROOT / config["paths"]["checkpoint"], map_location=device, weights_only=False)
|
| 23 |
+
required = {"model", "model_config", "format_version"}
|
| 24 |
+
if not required.issubset(checkpoint):
|
| 25 |
+
raise ValueError(f"checkpoint missing keys: {sorted(required - checkpoint.keys())}")
|
| 26 |
+
model_config = checkpoint["model_config"]
|
| 27 |
+
model_args = model_config["architecture"]
|
| 28 |
+
model = FuXiOcean(**model_args).to(device)
|
| 29 |
+
model.load_state_dict(checkpoint["model"]); model.eval()
|
| 30 |
+
data = np.load(ROOT / config["data"]["path"])
|
| 31 |
+
validate_data_contract(data, config)
|
| 32 |
+
shape_keys = ("input_shape", "atmosphere_shape", "output_shape")
|
| 33 |
+
if checkpoint["format_version"] != FORMAT_VERSION or model_config["data_format_version"] != str(data["format_version"]) or any(model_config[key] != data[key].tolist() for key in shape_keys):
|
| 34 |
+
raise ValueError("checkpoint, model configuration, and data contract are incompatible")
|
| 35 |
+
predictions, truths, initial, latitudes, masks, origins = [], [], [], [], [], []
|
| 36 |
+
with torch.no_grad():
|
| 37 |
+
for index in range(int(data["train_count"]), len(data["ocean"])):
|
| 38 |
+
batch = TileDataset(data, [index])[0]
|
| 39 |
+
ocean, atmosphere, coordinates, bathymetry, mask, time_info, target, latitude = [value[None].to(device) for value in batch]
|
| 40 |
+
history = ocean; sample_predictions, sample_truths = [], []
|
| 41 |
+
for lead in range(config["inference"]["rollout_steps"]):
|
| 42 |
+
time_info[:, 2] = lead
|
| 43 |
+
prediction = model(history, atmosphere, coordinates, bathymetry, mask, time_info)
|
| 44 |
+
truth = target + lead * 0.005
|
| 45 |
+
sample_predictions.append(prediction.cpu().numpy()[0]); sample_truths.append(truth.cpu().numpy()[0])
|
| 46 |
+
history = torch.cat((history[:, 1:], prediction[:, None]), dim=1)
|
| 47 |
+
predictions.append(sample_predictions); truths.append(sample_truths); initial.append(ocean[0, -1].cpu().numpy())
|
| 48 |
+
latitudes.append(latitude[0].cpu().numpy()); masks.append(mask[0].cpu().numpy()); origins.append(data["tile_origins"][index])
|
| 49 |
+
output = ROOT / config["paths"]["inference"]; output.parent.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
records = data["selected_tile_records"][int(data["train_count"]):]
|
| 51 |
+
# Exercise overlap-crop assembly on a bounded canvas without allocating the global field.
|
| 52 |
+
test_tile = np.ones((1, 1, config["data"]["tile_height"], config["data"]["tile_width"]), dtype=np.float32)
|
| 53 |
+
_, stitch_coverage = assemble_tiles(test_tile, [[0, config["data"]["tile_height"], 0, config["data"]["tile_width"], 0,
|
| 54 |
+
config["data"]["tile_height"], 0, config["data"]["tile_width"]]],
|
| 55 |
+
(config["data"]["tile_height"], config["data"]["tile_width"]))
|
| 56 |
+
coverage_fraction = float(sum((r[5] - r[4]) * (r[7] - r[6]) for r in records) /
|
| 57 |
+
(config["data"]["global_height"] * config["data"]["global_width"]))
|
| 58 |
+
checkpoint_source = str(config["paths"]["checkpoint"])
|
| 59 |
+
np.savez_compressed(output, output_kind="sampled_tiles", format_version=FORMAT_VERSION,
|
| 60 |
+
checkpoint_source=checkpoint_source, sample_count=len(predictions), synthetic=True,
|
| 61 |
+
coverage_fraction=coverage_fraction, is_complete_global=False,
|
| 62 |
+
variable_groups=json.dumps({"S": {"channels": [0, 26], "unit": "psu"}, "T": {"channels": [26, 52], "unit": "degC"}, "U": {"channels": [52, 78], "unit": "m s-1"}, "V": {"channels": [78, 104], "unit": "m s-1"}, "SSH": {"channels": [104, 105], "unit": "m"}}),
|
| 63 |
+
prediction=np.asarray(predictions), truth=np.asarray(truths), initial=np.asarray(initial),
|
| 64 |
+
latitude_deg=np.asarray(latitudes), depth_mask=np.asarray(masks), tile_origins=np.asarray(origins),
|
| 65 |
+
tile_records=records, output_shape=data["output_shape"],
|
| 66 |
+
lead_hours=config["data"]["time_step_hours"] * np.arange(1, config["inference"]["rollout_steps"] + 1))
|
| 67 |
+
metadata = {"output_kind": "sampled_tiles", "format_version": FORMAT_VERSION,
|
| 68 |
+
"checkpoint_source": checkpoint_source, "sample_count": len(predictions), "synthetic": True,
|
| 69 |
+
"output_shape": data["output_shape"].tolist(), "paper_rollout_steps": config["paper_model"]["rollout_steps"],
|
| 70 |
+
"executed_rollout_steps": config["inference"]["rollout_steps"], "tile_streaming": True,
|
| 71 |
+
"tile_origins": np.asarray(origins).tolist(), "coverage_fraction": coverage_fraction,
|
| 72 |
+
"is_complete_global": False, "stitch_interface_verified": bool(stitch_coverage.all()),
|
| 73 |
+
"coverage_semantics": "fraction of global cells owned after deterministic overlap crops",
|
| 74 |
+
"variable_groups": json.loads(str(np.load(output)["variable_groups"]))}
|
| 75 |
+
metadata_path = ROOT / config["paths"]["inference_metadata"]
|
| 76 |
+
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
|
| 77 |
+
print(f"predictions={output.relative_to(ROOT)} shape={np.asarray(predictions).shape}")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compute per-lead latitude-weighted model and persistence RMSE/MBE."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import numpy as np
|
| 9 |
+
import yaml
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
sys.path.insert(0, str(ROOT))
|
| 13 |
+
from model.fuxi_ocean import ocean_channel_depth_indices, weighted_error_sums
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def aggregate(prediction, truth, latitude, depth_mask):
|
| 17 |
+
channel_depth = ocean_channel_depth_indices()
|
| 18 |
+
mask = np.take(depth_mask, channel_depth, axis=1)
|
| 19 |
+
squared, bias, weight = weighted_error_sums(prediction, truth, latitude, mask)
|
| 20 |
+
return np.sqrt(squared / np.maximum(weight, 1)), bias / np.maximum(weight, 1)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main():
|
| 24 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 25 |
+
data = np.load(ROOT / config["paths"]["inference"])
|
| 26 |
+
model_rmse, model_mbe, persistence_rmse, persistence_mbe = [], [], [], []
|
| 27 |
+
for lead in range(data["prediction"].shape[1]):
|
| 28 |
+
rmse, mbe = aggregate(data["prediction"][:, lead], data["truth"][:, lead], data["latitude_deg"], data["depth_mask"])
|
| 29 |
+
prmse, pmbe = aggregate(data["initial"], data["truth"][:, lead], data["latitude_deg"], data["depth_mask"])
|
| 30 |
+
model_rmse.append(rmse); model_mbe.append(mbe); persistence_rmse.append(prmse); persistence_mbe.append(pmbe)
|
| 31 |
+
arrays = [np.asarray(value) for value in (model_rmse, model_mbe, persistence_rmse, persistence_mbe)]
|
| 32 |
+
lead_hours = data["lead_hours"].tolist()
|
| 33 |
+
groups = json.loads(str(data["variable_groups"]))
|
| 34 |
+
def grouped(values, i):
|
| 35 |
+
return {name: {"unit": spec["unit"], "rmse": float(np.mean(values[0][i, slice(*spec["channels"])])),
|
| 36 |
+
"mbe": float(np.mean(values[1][i, slice(*spec["channels"])]))} for name, spec in groups.items()}
|
| 37 |
+
metrics = {"output_kind": str(data["output_kind"]), "format_version": str(data["format_version"]),
|
| 38 |
+
"checkpoint_source": str(data["checkpoint_source"]), "sample_count": int(data["sample_count"]),
|
| 39 |
+
"coverage_fraction": float(data["coverage_fraction"]), "is_complete_global": bool(data["is_complete_global"]),
|
| 40 |
+
"synthetic": bool(data["synthetic"]), "aggregation": "latitude-weighted sampled tiles; never across unit groups",
|
| 41 |
+
"output_shape": data["output_shape"].tolist(), "variable_groups": groups,
|
| 42 |
+
"per_lead": [{"lead_hours": hour, "model_by_group": grouped(arrays[:2], i),
|
| 43 |
+
"persistence_by_group": grouped(arrays[2:], i),
|
| 44 |
+
"model_by_channel": {"rmse": arrays[0][i].tolist(), "mbe": arrays[1][i].tolist()},
|
| 45 |
+
"persistence_by_channel": {"rmse": arrays[2][i].tolist(), "mbe": arrays[3][i].tolist()}}
|
| 46 |
+
for i, hour in enumerate(lead_hours)]}
|
| 47 |
+
path = ROOT / config["paths"]["evaluation_metrics"]; path.parent.mkdir(parents=True, exist_ok=True)
|
| 48 |
+
path.write_text(json.dumps(metrics, indent=2) + "\n")
|
| 49 |
+
figure, axes = plt.subplots(1, 5, figsize=(18, 3.5))
|
| 50 |
+
for axis, (name, spec) in zip(axes, groups.items()):
|
| 51 |
+
channel_slice = slice(*spec["channels"])
|
| 52 |
+
axis.plot(lead_hours, arrays[0][:, channel_slice].mean(1), "o-", label="model")
|
| 53 |
+
axis.plot(lead_hours, arrays[2][:, channel_slice].mean(1), "s--", label="persistence")
|
| 54 |
+
axis.set(title=name, xlabel="Lead (h)", ylabel=f"RMSE ({spec['unit']})"); axis.legend()
|
| 55 |
+
figure.tight_layout()
|
| 56 |
+
plot = ROOT / config["paths"]["evaluation_plot"]; figure.savefig(plot, dpi=160); plt.close(figure)
|
| 57 |
+
print(f"metrics={path.relative_to(ROOT)} plot={plot.relative_to(ROOT)} leads={lead_hours}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train FuXi-Ocean on globally indexed tiles, with optional torchrun DDP."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import random
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 12 |
+
from torch.utils.data import DataLoader, Dataset
|
| 13 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 14 |
+
import yaml
|
| 15 |
+
|
| 16 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
sys.path.insert(0, str(ROOT))
|
| 18 |
+
from model.fuxi_ocean import FORMAT_VERSION, FuXiOcean, channel_mask, latitude_weighted_charbonnier
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TileDataset(Dataset):
|
| 22 |
+
def __init__(self, data, indices):
|
| 23 |
+
self.data, self.indices = data, list(indices)
|
| 24 |
+
|
| 25 |
+
def __len__(self):
|
| 26 |
+
return len(self.indices)
|
| 27 |
+
|
| 28 |
+
def __getitem__(self, item):
|
| 29 |
+
index = self.indices[item]
|
| 30 |
+
lat, lon = self.data["latitude_deg"][index], self.data["longitude_deg"][index]
|
| 31 |
+
coordinates = np.stack(np.meshgrid(lon / 180 - 1, lat / 90, indexing="xy"))
|
| 32 |
+
names = ("ocean", "atmosphere", "bathymetry_m", "depth_mask", "time_features", "targets")
|
| 33 |
+
values = [self.data[name][index] for name in names]
|
| 34 |
+
values[2] = values[2] / 5000
|
| 35 |
+
return tuple(torch.as_tensor(value, dtype=torch.float32) for value in (*values[:2], coordinates, *values[2:], lat))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def validate_data_contract(data, config):
|
| 39 |
+
expected = config["data"]
|
| 40 |
+
checks = {"format_version": str(data["format_version"]) == config["data"]["format_version"],
|
| 41 |
+
"input_shape": data["input_shape"].tolist() == expected["input_shape"],
|
| 42 |
+
"atmosphere_shape": data["atmosphere_shape"].tolist() == expected["atmosphere_shape"],
|
| 43 |
+
"output_shape": data["output_shape"].tolist() == expected["output_shape"]}
|
| 44 |
+
if not all(checks.values()):
|
| 45 |
+
raise ValueError(f"data contract mismatch: {checks}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def main():
|
| 49 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 50 |
+
torch.set_num_threads(config["runtime"]["num_threads"])
|
| 51 |
+
random.seed(config["seed"]); np.random.seed(config["seed"]); torch.manual_seed(config["seed"])
|
| 52 |
+
world = int(os.environ.get("WORLD_SIZE", "1")); distributed = world > 1
|
| 53 |
+
if distributed:
|
| 54 |
+
torch.distributed.init_process_group(config["runtime"]["ddp_backend"])
|
| 55 |
+
rank = torch.distributed.get_rank() if distributed else 0
|
| 56 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 57 |
+
available_devices = torch.cuda.device_count() if torch.cuda.is_available() else 0
|
| 58 |
+
requested_auto_gpu = config["runtime"]["device"] != "cpu" and available_devices >= world
|
| 59 |
+
use_cuda = requested_auto_gpu and local_rank < available_devices
|
| 60 |
+
device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu")
|
| 61 |
+
data = np.load(ROOT / config["data"]["path"])
|
| 62 |
+
validate_data_contract(data, config)
|
| 63 |
+
if config["data"]["format_version"] != FORMAT_VERSION:
|
| 64 |
+
raise ValueError("configuration/model format version mismatch")
|
| 65 |
+
model_args = {key: value for key, value in config["model"].items() if key != "epsilon"}
|
| 66 |
+
model = FuXiOcean(**model_args).to(device)
|
| 67 |
+
if distributed:
|
| 68 |
+
model = DistributedDataParallel(model, device_ids=[local_rank] if use_cuda else None)
|
| 69 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=config["training"]["learning_rate"],
|
| 70 |
+
weight_decay=config["training"]["weight_decay"])
|
| 71 |
+
losses = []
|
| 72 |
+
train_count = int(data["train_count"])
|
| 73 |
+
dataset = TileDataset(data, range(train_count))
|
| 74 |
+
sampler = DistributedSampler(dataset, num_replicas=world, rank=rank, shuffle=True, seed=config["seed"]) if distributed else None
|
| 75 |
+
loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], sampler=sampler,
|
| 76 |
+
shuffle=sampler is None, drop_last=False)
|
| 77 |
+
for epoch in range(config["training"]["epochs"]):
|
| 78 |
+
if sampler is not None:
|
| 79 |
+
sampler.set_epoch(epoch)
|
| 80 |
+
for batch in loader:
|
| 81 |
+
ocean, atmosphere, coordinates, bathymetry, mask, time_info, target, latitude = [value.to(device) for value in batch]
|
| 82 |
+
history = ocean
|
| 83 |
+
step_losses = []
|
| 84 |
+
for step in range(config["training"]["multistep_rollout"]):
|
| 85 |
+
time_info[:, 2] = step
|
| 86 |
+
prediction = model(history, atmosphere, coordinates, bathymetry, mask, time_info)
|
| 87 |
+
step_target = target + step * 0.005
|
| 88 |
+
step_losses.append(latitude_weighted_charbonnier(prediction, step_target, latitude,
|
| 89 |
+
channel_mask(mask), config["model"]["epsilon"]))
|
| 90 |
+
history = torch.cat((history[:, 1:], prediction[:, None]), dim=1)
|
| 91 |
+
loss = torch.stack(step_losses).mean()
|
| 92 |
+
optimizer.zero_grad(); loss.backward(); optimizer.step()
|
| 93 |
+
losses.append(float(loss.detach()))
|
| 94 |
+
local = torch.tensor([sum(losses), len(losses)], dtype=torch.float64, device=device)
|
| 95 |
+
if distributed:
|
| 96 |
+
torch.distributed.all_reduce(local)
|
| 97 |
+
if rank == 0:
|
| 98 |
+
raw_model = model.module if distributed else model
|
| 99 |
+
checkpoint_path = ROOT / config["paths"]["checkpoint"]
|
| 100 |
+
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
| 101 |
+
model_config = {"architecture": model_args, "data_format_version": FORMAT_VERSION,
|
| 102 |
+
"input_shape": data["input_shape"].tolist(), "atmosphere_shape": data["atmosphere_shape"].tolist(),
|
| 103 |
+
"output_shape": data["output_shape"].tolist()}
|
| 104 |
+
torch.save({"model": raw_model.state_dict(), "model_config": model_config, "format_version": FORMAT_VERSION,
|
| 105 |
+
"optimizer": optimizer.state_dict()}, checkpoint_path)
|
| 106 |
+
metrics_path = ROOT / config["paths"]["training_metrics"]
|
| 107 |
+
metrics_path.parent.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
metrics_path.write_text(json.dumps({"mean_loss": local[0].item() / local[1].item(), "world_size": world,
|
| 109 |
+
"backward_pass": True, "global_loss_all_reduce": distributed,
|
| 110 |
+
"batch_size": config["training"]["batch_size"],
|
| 111 |
+
"input_shape": data["input_shape"].tolist(), "synthetic": True}, indent=2) + "\n")
|
| 112 |
+
print(f"checkpoint={checkpoint_path.relative_to(ROOT)} loss={local[0].item() / local[1].item():.6f}")
|
| 113 |
+
if distributed:
|
| 114 |
+
torch.distributed.destroy_process_group()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|