Publish ML-MODIS engineering reproduction
Browse files- .gitattributes +1 -34
- README.md +141 -0
- README_zh.md +142 -0
- conf/config.yaml +55 -0
- config.json +39 -0
- configuration.json +12 -0
- model/ml_modis.py +247 -0
- scripts/fake_data.py +134 -0
- scripts/inference.py +58 -0
- scripts/result.py +136 -0
- scripts/train.py +101 -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,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- OneScience
|
| 7 |
+
- Earth Science
|
| 8 |
+
- Climate Diagnostics
|
| 9 |
+
- Random Forest
|
| 10 |
+
frameworks: PyTorch
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<strong><span style="font-size: 30px;">ML-MODIS</span></strong>
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
# Model Introduction
|
| 18 |
+
|
| 19 |
+
ML-MODIS reproduces the satellite-based machine-learning workflow proposed by Chen et al. (2022), estimating cloud-property counterfactuals without the 2014 volcanic aerosol perturbation and using them to diagnose aerosol-cloud effects and their shortwave radiative contributions.
|
| 20 |
+
|
| 21 |
+
Paper: Machine learning reveals climate forcing from aerosols is dominated by increased cloud cover
|
| 22 |
+
https://doi.org/10.1038/s41561-022-00991-6
|
| 23 |
+
|
| 24 |
+
# Model Description
|
| 25 |
+
|
| 26 |
+
The method was proposed by a research team from the University of Exeter, the Met Office, ETH Zurich, the University of Cambridge, NASA Goddard Space Flight Center, the University of Leeds, Ludwig Maximilian University of Munich, and collaborating institutions. The paper pairs MODIS Collection 6.1 cloud products with ERA5 meteorological data and trains independent random forests by month and cloud-property target using samples from years other than 2014. The model generates counterfactual cloud properties from meteorological conditions and compares them with 2014 MODIS observations to diagnose volcanic-aerosol cloud responses and their shortwave radiative-forcing contributions.
|
| 27 |
+
|
| 28 |
+
# Use Cases
|
| 29 |
+
|
| 30 |
+
| Use Case | Description |
|
| 31 |
+
| :---: | :--- |
|
| 32 |
+
| Counterfactual cloud prediction | Estimate unperturbed `Nd`, `reff`, `LWP`, and `CF` from ERA5 meteorology and compare them with 2014 observations. |
|
| 33 |
+
| OOB interpretation and radiative diagnostics | Calculate OOB skill and permutation importance, then diagnose relative Twomey, LWP, and CF shortwave radiative contributions. |
|
| 34 |
+
| Local workflow validation | Validate data generation, training, checkpoint restoration, inference, evaluation, and visualization with structured synthetic data. |
|
| 35 |
+
| ModelScope/OneCode execution | Validate structured data, training, inference, counterfactual and radiative-diagnostic 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/ML-MODIS --local-dir ./ML-MODIS
|
| 50 |
+
cd ML-MODIS
|
| 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 MODIS Collection 6.1 cloud products and ERA5 meteorological data aligned by year, month, Terra/Aqua platform, and coordinates, excluding 2014 samples from training. Each sample contains 114 predictors, comprising 90 fields from nine profile variables at ten pressure levels and 24 single-level fields, with `Nd`, `reff`, `LWP`, and `CF` as targets. The synthetic data contain structured meteorological, spatiotemporal, and cloud-property relationships for engineering validation only and do not represent the real MODIS 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-GPU 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 builds separate random forests for four cloud properties in September and October; the default reduces each forest from the paper's 100 trees to 12 without reducing the 114 predictors, months, targets, or multimodal alignment protocol. Formal experiments require real MODIS and ERA5 data and field-level verification against the supplementary material, with training artifacts saved to:
|
| 102 |
+
|
| 103 |
+
```text
|
| 104 |
+
result/checkpoints/ml_modis.pt
|
| 105 |
+
result/training/metrics.json
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Trained Weights
|
| 109 |
+
|
| 110 |
+
No official weights are bundled under `weight/`, and no public model weights or directly downloadable checkpoint from the paper authors were identified. The paper states only that code is available from the corresponding author upon reasonable request; the local checkpoint is an engineering artifact and makes no compatibility claim with unreleased author weights.
|
| 111 |
+
|
| 112 |
+
### Inference
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
python scripts/inference.py
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
Inference loads the training checkpoint and generates counterfactual predictions for all four cloud properties from ERA5 meteorological conditions, saving per-tree predictions, forest means, observations, and alignment metadata. Complete numerical results are saved to `result/output/predictions.npz`.
|
| 119 |
+
|
| 120 |
+
### Evaluation and Visualization
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
python scripts/result.py
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
Evaluation computes OOB MSE, R², Pearson correlation, permutation importance, area-weighted 2014 responses, susceptibilities, and relative shortwave radiative contributions. Results are saved to `result/evaluation/metrics.json` and `result/evaluation/comparison.png`; synthetic-data results validate engineering only and do not represent paper performance or climate-attribution evidence.
|
| 127 |
+
|
| 128 |
+
# Official OneScience Information
|
| 129 |
+
|
| 130 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 131 |
+
| --- | --- | --- |
|
| 132 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 133 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 134 |
+
|
| 135 |
+
# Citation and License
|
| 136 |
+
|
| 137 |
+
This repository is an independent engineering reproduction of the public ML-MODIS specifications.
|
| 138 |
+
|
| 139 |
+
Its code is licensed under Apache-2.0; the paper and author code remain subject to their respective copyright and usage terms.
|
| 140 |
+
|
| 141 |
+
Use of MODIS Collection 6.1 and ERA5 remains subject to the current NASA Earthdata/LAADS DAAC and Copernicus Climate Data Store/ECMWF terms, respectively.
|
README_zh.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
- zh
|
| 6 |
+
tags:
|
| 7 |
+
- OneScience
|
| 8 |
+
- 地球科学
|
| 9 |
+
- 气候诊断
|
| 10 |
+
- Random Forest
|
| 11 |
+
frameworks: PyTorch
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
<p align="center">
|
| 15 |
+
<strong><span style="font-size: 30px;">ML-MODIS</span></strong>
|
| 16 |
+
</p>
|
| 17 |
+
|
| 18 |
+
# 模型介绍
|
| 19 |
+
|
| 20 |
+
ML-MODIS 复现 Chen 等(2022)提出的卫星机器学习流程,依据气象状态估计未受 2014 年火山气溶胶扰动的云属性反事实,并据此诊断气溶胶云效应及其短波辐射贡献。
|
| 21 |
+
|
| 22 |
+
论文:Machine learning reveals climate forcing from aerosols is dominated by increased cloud cover
|
| 23 |
+
https://doi.org/10.1038/s41561-022-00991-6
|
| 24 |
+
|
| 25 |
+
# 模型描述
|
| 26 |
+
|
| 27 |
+
该方法由 University of Exeter、Met Office、ETH Zurich、University of Cambridge、NASA Goddard Space Flight Center、University of Leeds 和 Ludwig Maximilian University of Munich 等机构组成的研究团队提出。论文将 MODIS Collection 6.1 云产品与 ERA5 气象数据配对,使用非 2014 年样本训练月份与云属性目标相互独立的随机森林。模型根据气象条件生成反事实云属性,再将 2014 年 MODIS 观测与反事实比较,以诊断火山气溶胶引起的云响应和短波辐射强迫贡献。
|
| 28 |
+
|
| 29 |
+
# 适用场景
|
| 30 |
+
|
| 31 |
+
| 场景 | 说明 |
|
| 32 |
+
| :---: | :--- |
|
| 33 |
+
| 云属性反事实预测 | 从 ERA5 气象状态估计无火山扰动条件下的 `Nd`、`reff`、`LWP` 和 `CF`,并与 2014 年观测比较。 |
|
| 34 |
+
| OOB 解释与辐射诊断 | 计算 OOB 技能和置换重要性,并诊断 Twomey、LWP 与 CF 的相对短波辐射贡献。 |
|
| 35 |
+
| 本地流程验证 | 使用结构化虚拟数据验证数据生成、训练、checkpoint 恢复、推理、评估和可视化。 |
|
| 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/ML-MODIS --local_dir ./ML-MODIS
|
| 51 |
+
cd ML-MODIS
|
| 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 |
+
论文使用 MODIS Collection 6.1 云产品和 ERA5 气象数据,按年月、Terra/Aqua 平台及经纬度对齐,并排除 2014 年样本进行训练。每个样本包含 114 个输入特征,即 9 个廓线变量在 10 个压力层上的 90 个字段和 24 个单层字段,目标为 `Nd`、`reff`、`LWP` 和 `CF`。虚拟数据包含合理的气象、时空和云属性关系,仅用于验证工程流程,不代表 MODIS 或 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 |
+
训练为 September/October 的四个云属性分别构建随机森林;默认配置将每个森林由论文的 100 棵树缩为 12 棵,但不缩小 114 维输入、月份、目标和多模态对齐协议。正式实验需要真实 MODIS、ERA5 数据并核验补充材料中的字段定义,训练产物保存到:
|
| 103 |
+
|
| 104 |
+
```text
|
| 105 |
+
result/checkpoints/ml_modis.pt
|
| 106 |
+
result/training/metrics.json
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
### 训练权重
|
| 110 |
+
|
| 111 |
+
本仓库不在 `weight/` 中内置权重,也未发现论文作者公开的模型权重或可直接下载的 checkpoint。论文仅说明代码可向通讯作者合理申请,当前本地 checkpoint 是工程训练产物,不声明与作者未公开的权重兼容。
|
| 112 |
+
|
| 113 |
+
### 推理
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
python scripts/inference.py
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
推理加载训练 checkpoint,根据 ERA5 气象条件生成四种云属性的反事实预测,并保存逐树预测、森林均值、观测和对齐信息。完整数值结果保存到 `result/output/predictions.npz`。
|
| 120 |
+
|
| 121 |
+
### 评估和可视化
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
python scripts/result.py
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
评估计算 OOB MSE、R²、Pearson 相关系数、置换重要性、2014 年面积加��响应、susceptibility 和相对短波辐射贡献。结果保存到 `result/evaluation/metrics.json` 和 `result/evaluation/comparison.png`;虚拟数据结果仅用于工程验证,不代表论文正式性能或气候归因结论。
|
| 128 |
+
|
| 129 |
+
# OneScience官方信息
|
| 130 |
+
|
| 131 |
+
| 平台 | OneScience 主仓库 | Skills 仓库 |
|
| 132 |
+
| --- | --- | --- |
|
| 133 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 134 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 135 |
+
|
| 136 |
+
# 引用与许可证
|
| 137 |
+
|
| 138 |
+
本仓库为 ML-MODIS 公开规格的独立工程复现版本。
|
| 139 |
+
|
| 140 |
+
本仓库代码采用 Apache-2.0 许可证;原论文和作者代码仍受其各自版权及使用条款约束。
|
| 141 |
+
|
| 142 |
+
MODIS Collection 6.1 和 ERA5 数据的使用应分别遵循 NASA Earthdata/LAADS DAAC 和 Copernicus Climate Data Store/ECMWF 的现行条款。
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
format_version: ml_modis_npz_v1
|
| 2 |
+
data:
|
| 3 |
+
path: data/ml_modis_fake.npz
|
| 4 |
+
samples: 1400
|
| 5 |
+
years: [2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020]
|
| 6 |
+
months: [9, 10]
|
| 7 |
+
platforms: [Terra, Aqua]
|
| 8 |
+
dimensions:
|
| 9 |
+
predictors: 114
|
| 10 |
+
targets: 4
|
| 11 |
+
variables:
|
| 12 |
+
profile:
|
| 13 |
+
names: [temperature, specific_humidity, relative_humidity, u_wind, v_wind, omega, geopotential, cloud_liquid, cloud_fraction]
|
| 14 |
+
pressure_levels_hpa: [1000, 950, 900, 850, 800, 750, 700, 650, 600, 550]
|
| 15 |
+
count: 90
|
| 16 |
+
single_level:
|
| 17 |
+
names: [sst, surface_pressure, mslp, skin_temperature, t2m, d2m, u10, v10, surface_solar_radiation, surface_thermal_radiation, latent_heat_flux, sensible_heat_flux, boundary_layer_height, total_column_water_vapour, total_column_cloud_liquid, cape, cin, low_cloud_cover, sea_ice_fraction, precipitation, cos_sza, latitude, longitude, platform_hour]
|
| 18 |
+
count: 24
|
| 19 |
+
targets:
|
| 20 |
+
names: [Nd, reff, LWP, CF]
|
| 21 |
+
units: [cm-3, um, g-m-2, fraction]
|
| 22 |
+
coordinates:
|
| 23 |
+
latitude_degrees_north: [45, 75]
|
| 24 |
+
longitude_degrees_east: [-60, 30]
|
| 25 |
+
model:
|
| 26 |
+
trees: 12
|
| 27 |
+
max_depth: 9
|
| 28 |
+
min_leaf: 7
|
| 29 |
+
max_features: 38
|
| 30 |
+
bootstrap_fraction: 0.6
|
| 31 |
+
split_candidates: 12
|
| 32 |
+
paper_model:
|
| 33 |
+
trees: 100
|
| 34 |
+
max_depth: null
|
| 35 |
+
min_leaf: 7
|
| 36 |
+
max_features: 38
|
| 37 |
+
bootstrap_fraction: 0.6
|
| 38 |
+
split_candidates: 12
|
| 39 |
+
train:
|
| 40 |
+
excluded_year: 2014
|
| 41 |
+
independent_models: 8
|
| 42 |
+
runtime:
|
| 43 |
+
seed: 20220908
|
| 44 |
+
device: cpu
|
| 45 |
+
distributed_backend: gloo
|
| 46 |
+
paths:
|
| 47 |
+
checkpoint: result/checkpoints/ml_modis.pt
|
| 48 |
+
training_metrics: result/training/metrics.json
|
| 49 |
+
predictions: result/output/predictions.npz
|
| 50 |
+
evaluation_dir: result/evaluation
|
| 51 |
+
evaluation:
|
| 52 |
+
cloud_albedo: 0.38
|
| 53 |
+
clear_sky_ocean_albedo: 0.07
|
| 54 |
+
importance_top_k: 10
|
| 55 |
+
figure_dpi: 150
|
config.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "ML-MODIS",
|
| 3 |
+
"model_type": "bootstrap-random-forest-regressor",
|
| 4 |
+
"architectures": ["BootstrapRandomForestRegressor"],
|
| 5 |
+
"framework": "PyTorch",
|
| 6 |
+
"domain": "earth-science",
|
| 7 |
+
"task": "counterfactual-cloud-property-regression",
|
| 8 |
+
"implementation": {
|
| 9 |
+
"entry_point": "model/ml_modis.py",
|
| 10 |
+
"scope": "independent paper-method engineering reproduction",
|
| 11 |
+
"train_script": "scripts/train.py",
|
| 12 |
+
"inference_script": "scripts/inference.py",
|
| 13 |
+
"evaluation_script": "scripts/result.py",
|
| 14 |
+
"synthetic_data_script": "scripts/fake_data.py"
|
| 15 |
+
},
|
| 16 |
+
"architecture": {
|
| 17 |
+
"family": "bootstrap random regression forest",
|
| 18 |
+
"input_features": 114,
|
| 19 |
+
"output_targets": 4,
|
| 20 |
+
"independent_models": 8,
|
| 21 |
+
"months": [9, 10],
|
| 22 |
+
"targets": ["Nd", "reff", "LWP", "CF"]
|
| 23 |
+
},
|
| 24 |
+
"data": {
|
| 25 |
+
"format": "NPZ",
|
| 26 |
+
"protocol": "ml_modis_npz_v1",
|
| 27 |
+
"input_shape": ["N", 114],
|
| 28 |
+
"target_shape": ["N", 4],
|
| 29 |
+
"alignment_key": ["year", "month", "platform", "latitude", "longitude"]
|
| 30 |
+
},
|
| 31 |
+
"configuration_sources": [
|
| 32 |
+
"conf/config.yaml",
|
| 33 |
+
"model/ml_modis.py",
|
| 34 |
+
"scripts/fake_data.py",
|
| 35 |
+
"scripts/train.py",
|
| 36 |
+
"scripts/inference.py",
|
| 37 |
+
"scripts/result.py"
|
| 38 |
+
]
|
| 39 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "counterfactual-cloud-property-regression",
|
| 4 |
+
"model": "ML-MODIS",
|
| 5 |
+
"input_format": "NPZ arrays with X[N,114], Y[N,4], and aligned spatiotemporal metadata",
|
| 6 |
+
"protocol": "independent bootstrap random forests by month and cloud-property target",
|
| 7 |
+
"default_config": "conf/config.yaml",
|
| 8 |
+
"training": "scripts/train.py",
|
| 9 |
+
"inference": "scripts/inference.py",
|
| 10 |
+
"evaluation": "scripts/result.py",
|
| 11 |
+
"visualization": "scripts/result.py"
|
| 12 |
+
}
|
model/ml_modis.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure NumPy bootstrap random-forest regression used by ML-MODIS."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
TARGETS = ("Nd", "reff", "LWP", "CF")
|
| 12 |
+
PRESSURE_VARIABLES = ("temperature", "specific_humidity", "relative_humidity", "u_wind", "v_wind", "omega", "geopotential", "cloud_liquid", "cloud_fraction")
|
| 13 |
+
PRESSURE_LEVELS = (1000, 950, 900, 850, 800, 750, 700, 650, 600, 550)
|
| 14 |
+
SINGLE_FEATURES = (
|
| 15 |
+
"sst", "surface_pressure", "mslp", "skin_temperature", "t2m", "d2m",
|
| 16 |
+
"u10", "v10", "surface_solar_radiation", "surface_thermal_radiation",
|
| 17 |
+
"latent_heat_flux", "sensible_heat_flux", "boundary_layer_height",
|
| 18 |
+
"total_column_water_vapour", "total_column_cloud_liquid", "cape", "cin",
|
| 19 |
+
"low_cloud_cover", "sea_ice_fraction", "precipitation", "cos_sza",
|
| 20 |
+
"latitude", "longitude", "platform_hour",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def feature_names() -> List[str]:
|
| 25 |
+
names = [f"{variable}_{level}hPa" for variable in PRESSURE_VARIABLES for level in PRESSURE_LEVELS]
|
| 26 |
+
names.extend(SINGLE_FEATURES)
|
| 27 |
+
if len(names) != 114:
|
| 28 |
+
raise RuntimeError("The ERA5 predictor ledger must contain exactly 114 features")
|
| 29 |
+
return names
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> Dict[str, float]:
|
| 33 |
+
mask = np.isfinite(y_true) & np.isfinite(y_pred)
|
| 34 |
+
if mask.sum() < 2:
|
| 35 |
+
return {"n": int(mask.sum()), "mse": float("nan"), "r2": float("nan"), "pearson": float("nan")}
|
| 36 |
+
y = np.asarray(y_true[mask], dtype=np.float64)
|
| 37 |
+
p = np.asarray(y_pred[mask], dtype=np.float64)
|
| 38 |
+
mse = float(np.mean((y - p) ** 2))
|
| 39 |
+
variance = float(np.sum((y - y.mean()) ** 2))
|
| 40 |
+
r2 = float(1.0 - np.sum((y - p) ** 2) / variance) if variance > 0 else float("nan")
|
| 41 |
+
pearson = float(np.corrcoef(y, p)[0, 1]) if np.std(y) > 0 and np.std(p) > 0 else float("nan")
|
| 42 |
+
return {"n": int(mask.sum()), "mse": mse, "r2": r2, "pearson": pearson}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class TreeConfig:
|
| 47 |
+
min_leaf: int = 7
|
| 48 |
+
max_features: int = 38
|
| 49 |
+
max_depth: Optional[int] = None
|
| 50 |
+
split_candidates: int = 12
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class RandomRegressionTree:
|
| 54 |
+
"""CART regressor with random feature subsets and compact array state."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, config: TreeConfig, seed: int):
|
| 57 |
+
self.config = config
|
| 58 |
+
self.seed = int(seed)
|
| 59 |
+
self.feature: List[int] = []
|
| 60 |
+
self.threshold: List[float] = []
|
| 61 |
+
self.left: List[int] = []
|
| 62 |
+
self.right: List[int] = []
|
| 63 |
+
self.value: List[float] = []
|
| 64 |
+
|
| 65 |
+
def fit(self, x: np.ndarray, y: np.ndarray) -> "RandomRegressionTree":
|
| 66 |
+
x = np.asarray(x, dtype=np.float32)
|
| 67 |
+
y = np.asarray(y, dtype=np.float64)
|
| 68 |
+
rng = np.random.default_rng(self.seed)
|
| 69 |
+
|
| 70 |
+
def build(indices: np.ndarray, depth: int) -> int:
|
| 71 |
+
node = len(self.value)
|
| 72 |
+
self.feature.append(-1)
|
| 73 |
+
self.threshold.append(np.nan)
|
| 74 |
+
self.left.append(-1)
|
| 75 |
+
self.right.append(-1)
|
| 76 |
+
self.value.append(float(y[indices].mean()))
|
| 77 |
+
if indices.size < 2 * self.config.min_leaf:
|
| 78 |
+
return node
|
| 79 |
+
if self.config.max_depth is not None and depth >= self.config.max_depth:
|
| 80 |
+
return node
|
| 81 |
+
parent_sse = float(np.sum((y[indices] - y[indices].mean()) ** 2))
|
| 82 |
+
if parent_sse <= 1e-12:
|
| 83 |
+
return node
|
| 84 |
+
n_features = min(self.config.max_features, x.shape[1])
|
| 85 |
+
candidates = rng.choice(x.shape[1], size=n_features, replace=False)
|
| 86 |
+
best: Optional[Tuple[float, int, float, np.ndarray]] = None
|
| 87 |
+
quantiles = np.linspace(0.05, 0.95, self.config.split_candidates)
|
| 88 |
+
for feature in candidates:
|
| 89 |
+
values = x[indices, feature]
|
| 90 |
+
thresholds = np.unique(np.quantile(values, quantiles))
|
| 91 |
+
for threshold in thresholds:
|
| 92 |
+
is_left = values <= threshold
|
| 93 |
+
nl = int(is_left.sum())
|
| 94 |
+
nr = indices.size - nl
|
| 95 |
+
if nl < self.config.min_leaf or nr < self.config.min_leaf:
|
| 96 |
+
continue
|
| 97 |
+
yl, yr = y[indices[is_left]], y[indices[~is_left]]
|
| 98 |
+
score = float(np.sum((yl - yl.mean()) ** 2) + np.sum((yr - yr.mean()) ** 2))
|
| 99 |
+
if best is None or score < best[0]:
|
| 100 |
+
best = (score, int(feature), float(threshold), is_left.copy())
|
| 101 |
+
if best is None or best[0] >= parent_sse - 1e-12:
|
| 102 |
+
return node
|
| 103 |
+
_, split_feature, split_threshold, is_left = best
|
| 104 |
+
self.feature[node] = split_feature
|
| 105 |
+
self.threshold[node] = split_threshold
|
| 106 |
+
self.left[node] = build(indices[is_left], depth + 1)
|
| 107 |
+
self.right[node] = build(indices[~is_left], depth + 1)
|
| 108 |
+
return node
|
| 109 |
+
|
| 110 |
+
build(np.arange(y.size, dtype=np.int64), 0)
|
| 111 |
+
return self
|
| 112 |
+
|
| 113 |
+
def predict(self, x: np.ndarray) -> np.ndarray:
|
| 114 |
+
x = np.asarray(x, dtype=np.float32)
|
| 115 |
+
output = np.empty(x.shape[0], dtype=np.float32)
|
| 116 |
+
for row in range(x.shape[0]):
|
| 117 |
+
node = 0
|
| 118 |
+
while self.feature[node] >= 0:
|
| 119 |
+
node = self.left[node] if x[row, self.feature[node]] <= self.threshold[node] else self.right[node]
|
| 120 |
+
output[row] = self.value[node]
|
| 121 |
+
return output
|
| 122 |
+
|
| 123 |
+
def state_dict(self) -> Dict[str, Any]:
|
| 124 |
+
return {
|
| 125 |
+
"seed": self.seed,
|
| 126 |
+
"config": self.config.__dict__.copy(),
|
| 127 |
+
"feature": np.asarray(self.feature, dtype=np.int32),
|
| 128 |
+
"threshold": np.asarray(self.threshold, dtype=np.float32),
|
| 129 |
+
"left": np.asarray(self.left, dtype=np.int32),
|
| 130 |
+
"right": np.asarray(self.right, dtype=np.int32),
|
| 131 |
+
"value": np.asarray(self.value, dtype=np.float32),
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
@classmethod
|
| 135 |
+
def from_state_dict(cls, state: Dict[str, Any]) -> "RandomRegressionTree":
|
| 136 |
+
tree = cls(TreeConfig(**state["config"]), int(state["seed"]))
|
| 137 |
+
for name in ("feature", "threshold", "left", "right", "value"):
|
| 138 |
+
setattr(tree, name, np.asarray(state[name]).tolist())
|
| 139 |
+
return tree
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class BootstrapRandomForestRegressor:
|
| 143 |
+
"""Regression forest with explicit approximately 60% bootstrap and OOB state."""
|
| 144 |
+
|
| 145 |
+
def __init__(self, n_trees: int = 100, min_leaf: int = 7, max_features: int = 38,
|
| 146 |
+
bootstrap_fraction: float = 0.6, max_depth: Optional[int] = None,
|
| 147 |
+
split_candidates: int = 12, seed: int = 0):
|
| 148 |
+
if n_trees < 1 or min_leaf < 1 or not 0 < bootstrap_fraction <= 1:
|
| 149 |
+
raise ValueError("Invalid forest configuration")
|
| 150 |
+
self.n_trees = int(n_trees)
|
| 151 |
+
self.bootstrap_fraction = float(bootstrap_fraction)
|
| 152 |
+
self.seed = int(seed)
|
| 153 |
+
self.tree_config = TreeConfig(int(min_leaf), int(max_features), max_depth, int(split_candidates))
|
| 154 |
+
self.trees: List[RandomRegressionTree] = []
|
| 155 |
+
self.oob_indices: List[np.ndarray] = []
|
| 156 |
+
|
| 157 |
+
def fit(self, x: np.ndarray, y: np.ndarray) -> "BootstrapRandomForestRegressor":
|
| 158 |
+
x = np.asarray(x, dtype=np.float32)
|
| 159 |
+
y = np.asarray(y, dtype=np.float32)
|
| 160 |
+
if x.ndim != 2 or x.shape[1] != 114 or y.shape != (x.shape[0],):
|
| 161 |
+
raise ValueError(f"Expected X [N,114] and y [N], got {x.shape} and {y.shape}")
|
| 162 |
+
rng = np.random.default_rng(self.seed)
|
| 163 |
+
draw_size = max(2 * self.tree_config.min_leaf, int(round(self.bootstrap_fraction * x.shape[0])))
|
| 164 |
+
self.trees, self.oob_indices = [], []
|
| 165 |
+
for _ in range(self.n_trees):
|
| 166 |
+
bootstrap = rng.integers(0, x.shape[0], size=draw_size)
|
| 167 |
+
used = np.zeros(x.shape[0], dtype=bool)
|
| 168 |
+
used[np.unique(bootstrap)] = True
|
| 169 |
+
oob = np.flatnonzero(~used)
|
| 170 |
+
tree_seed = int(rng.integers(0, 2**31 - 1))
|
| 171 |
+
self.trees.append(RandomRegressionTree(self.tree_config, tree_seed).fit(x[bootstrap], y[bootstrap]))
|
| 172 |
+
self.oob_indices.append(oob.astype(np.int32))
|
| 173 |
+
return self
|
| 174 |
+
|
| 175 |
+
def predict_trees(self, x: np.ndarray) -> np.ndarray:
|
| 176 |
+
if not self.trees:
|
| 177 |
+
raise RuntimeError("Forest is not fitted")
|
| 178 |
+
return np.stack([tree.predict(x) for tree in self.trees], axis=1)
|
| 179 |
+
|
| 180 |
+
def predict(self, x: np.ndarray) -> np.ndarray:
|
| 181 |
+
return self.predict_trees(x).mean(axis=1)
|
| 182 |
+
|
| 183 |
+
def oob_predict(self, x: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 184 |
+
sums = np.zeros(x.shape[0], dtype=np.float64)
|
| 185 |
+
counts = np.zeros(x.shape[0], dtype=np.int32)
|
| 186 |
+
for tree, indices in zip(self.trees, self.oob_indices):
|
| 187 |
+
if indices.size:
|
| 188 |
+
sums[indices] += tree.predict(x[indices])
|
| 189 |
+
counts[indices] += 1
|
| 190 |
+
prediction = np.full(x.shape[0], np.nan, dtype=np.float32)
|
| 191 |
+
valid = counts > 0
|
| 192 |
+
prediction[valid] = (sums[valid] / counts[valid]).astype(np.float32)
|
| 193 |
+
return prediction, counts
|
| 194 |
+
|
| 195 |
+
def permutation_importance(self, x: np.ndarray, y: np.ndarray, seed: int = 0) -> np.ndarray:
|
| 196 |
+
"""Breiman OOB permuted-predictor delta MSE, averaged over eligible trees."""
|
| 197 |
+
rng = np.random.default_rng(seed)
|
| 198 |
+
deltas = np.zeros(x.shape[1], dtype=np.float64)
|
| 199 |
+
counts = np.zeros(x.shape[1], dtype=np.int32)
|
| 200 |
+
for tree, indices in zip(self.trees, self.oob_indices):
|
| 201 |
+
if indices.size < 2:
|
| 202 |
+
continue
|
| 203 |
+
xo = np.asarray(x[indices], dtype=np.float32)
|
| 204 |
+
yo = np.asarray(y[indices], dtype=np.float32)
|
| 205 |
+
baseline = float(np.mean((yo - tree.predict(xo)) ** 2))
|
| 206 |
+
for feature in range(x.shape[1]):
|
| 207 |
+
changed = xo.copy()
|
| 208 |
+
changed[:, feature] = changed[rng.permutation(indices.size), feature]
|
| 209 |
+
deltas[feature] += float(np.mean((yo - tree.predict(changed)) ** 2)) - baseline
|
| 210 |
+
counts[feature] += 1
|
| 211 |
+
return np.divide(deltas, counts, out=np.zeros_like(deltas), where=counts > 0).astype(np.float32)
|
| 212 |
+
|
| 213 |
+
def state_dict(self) -> Dict[str, Any]:
|
| 214 |
+
return {
|
| 215 |
+
"n_trees": self.n_trees,
|
| 216 |
+
"bootstrap_fraction": self.bootstrap_fraction,
|
| 217 |
+
"seed": self.seed,
|
| 218 |
+
"tree_config": self.tree_config.__dict__.copy(),
|
| 219 |
+
"trees": [tree.state_dict() for tree in self.trees],
|
| 220 |
+
"oob_indices": self.oob_indices,
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
@classmethod
|
| 224 |
+
def from_state_dict(cls, state: Dict[str, Any]) -> "BootstrapRandomForestRegressor":
|
| 225 |
+
config = state["tree_config"]
|
| 226 |
+
forest = cls(state["n_trees"], config["min_leaf"], config["max_features"],
|
| 227 |
+
state["bootstrap_fraction"], config["max_depth"],
|
| 228 |
+
config["split_candidates"], state["seed"])
|
| 229 |
+
forest.trees = [RandomRegressionTree.from_state_dict(item) for item in state["trees"]]
|
| 230 |
+
forest.oob_indices = [np.asarray(item, dtype=np.int32) for item in state["oob_indices"]]
|
| 231 |
+
return forest
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def validate_multimodal_keys(data: Dict[str, np.ndarray]) -> None:
|
| 235 |
+
required = ("year", "month", "platform", "latitude", "longitude", "X", "Y")
|
| 236 |
+
missing = [key for key in required if key not in data]
|
| 237 |
+
if missing:
|
| 238 |
+
raise ValueError(f"Missing aligned arrays: {missing}")
|
| 239 |
+
n = data["X"].shape[0]
|
| 240 |
+
if data["X"].shape[1] != 114 or data["Y"].shape != (n, 4):
|
| 241 |
+
raise ValueError("Predictors must be [N,114] and targets [N,4]")
|
| 242 |
+
if any(np.asarray(data[key]).shape[0] != n for key in required[:-2]):
|
| 243 |
+
raise ValueError("Year/month/platform/coordinates are not row-aligned")
|
| 244 |
+
keys = list(zip(data["year"].tolist(), data["month"].tolist(), data["platform"].tolist(),
|
| 245 |
+
np.round(data["latitude"], 4).tolist(), np.round(data["longitude"], 4).tolist()))
|
| 246 |
+
if len(set(keys)) != n:
|
| 247 |
+
raise ValueError("Multimodal year-month-platform-latitude-longitude keys are not unique")
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Generate structured synthetic ERA5-MODIS monthly pairs for an executable demo."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import yaml
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 15 |
+
from ml_modis import PRESSURE_LEVELS, PRESSURE_VARIABLES, SINGLE_FEATURES, feature_names, validate_multimodal_keys
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_args() -> argparse.Namespace:
|
| 19 |
+
parser = argparse.ArgumentParser()
|
| 20 |
+
parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
|
| 21 |
+
parser.add_argument("--samples", type=int, default=None)
|
| 22 |
+
parser.add_argument("--output", default=None)
|
| 23 |
+
return parser.parse_args()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def ocean_mask(lat: np.ndarray, lon: np.ndarray) -> np.ndarray:
|
| 27 |
+
"""Analytic North Atlantic mask excluding coarse Greenland/Europe land shapes."""
|
| 28 |
+
greenland = (lat > 59) & (lon > -53) & (lon < -20 + 0.55 * (lat - 59))
|
| 29 |
+
europe = (lat > 50) & (lon > -10 + 0.35 * (lat - 50))
|
| 30 |
+
iceland = (lat > 63) & (lat < 67) & (lon > -25) & (lon < -13)
|
| 31 |
+
north_america = (lon < -52 + 0.3 * (lat - 45))
|
| 32 |
+
return ~(greenland | europe | iceland | north_america)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main() -> None:
|
| 36 |
+
args = parse_args()
|
| 37 |
+
config = yaml.safe_load(Path(args.config).read_text())
|
| 38 |
+
n = int(args.samples or config["data"]["samples"])
|
| 39 |
+
rng = np.random.default_rng(config["runtime"]["seed"])
|
| 40 |
+
years = np.asarray(config["data"]["years"], dtype=np.int16)
|
| 41 |
+
months = np.asarray(config["data"]["months"], dtype=np.int8)
|
| 42 |
+
platforms = np.asarray(config["data"]["platforms"], dtype="U5")
|
| 43 |
+
|
| 44 |
+
records = []
|
| 45 |
+
used = set()
|
| 46 |
+
while len(records) < n:
|
| 47 |
+
year = int(rng.choice(years))
|
| 48 |
+
month = int(rng.choice(months))
|
| 49 |
+
platform = str(rng.choice(platforms))
|
| 50 |
+
lat = int(rng.integers(45, 76))
|
| 51 |
+
lon = int(rng.integers(-60, 31))
|
| 52 |
+
key = (year, month, platform, lat, lon)
|
| 53 |
+
if key in used or not ocean_mask(np.array([lat]), np.array([lon]))[0]:
|
| 54 |
+
continue
|
| 55 |
+
used.add(key)
|
| 56 |
+
records.append(key)
|
| 57 |
+
year = np.asarray([r[0] for r in records], dtype=np.int16)
|
| 58 |
+
month = np.asarray([r[1] for r in records], dtype=np.int8)
|
| 59 |
+
platform = np.asarray([r[2] for r in records], dtype="U5")
|
| 60 |
+
lat = np.asarray([r[3] for r in records], dtype=np.float32)
|
| 61 |
+
lon = np.asarray([r[4] for r in records], dtype=np.float32)
|
| 62 |
+
hour = np.where(platform == "Terra", 11.0, 13.0).astype(np.float32)
|
| 63 |
+
|
| 64 |
+
phase = np.deg2rad(lon + 25) + (month - 9) * 0.35
|
| 65 |
+
maritime = np.cos(np.deg2rad(lat - 58)) * np.cos(np.deg2rad(lon + 25))
|
| 66 |
+
synoptic = np.sin(phase * 1.7 + (year - 2001) * 0.43) + 0.45 * np.cos(np.deg2rad(lat * 3))
|
| 67 |
+
sst = 286.0 - 0.42 * (lat - 45) + 1.1 * np.cos(phase) - 0.35 * (month - 9) + 0.025 * (year - 2001)
|
| 68 |
+
surface_pressure = 101300 + 900 * synoptic - 8 * (lat - 55) + rng.normal(0, 160, n)
|
| 69 |
+
humidity_base = np.clip(0.82 - 0.008 * (lat - 45) + 0.08 * maritime + 0.04 * synoptic, 0.35, 0.98)
|
| 70 |
+
stability = 0.7 * (lat - 55) - 1.8 * synoptic + rng.normal(0, 0.7, n)
|
| 71 |
+
x = np.empty((n, 114), dtype=np.float32)
|
| 72 |
+
column = 0
|
| 73 |
+
for variable in PRESSURE_VARIABLES:
|
| 74 |
+
for level in PRESSURE_LEVELS:
|
| 75 |
+
z = (1000 - level) / 50.0
|
| 76 |
+
if variable == "temperature": value = sst - 1.7 - 3.15 * z + 0.15 * stability
|
| 77 |
+
elif variable == "specific_humidity": value = 0.010 * humidity_base * np.exp(-0.23 * z)
|
| 78 |
+
elif variable == "relative_humidity": value = np.clip(humidity_base - 0.025 * z + 0.04 * np.sin(phase + z), 0.05, 1.0)
|
| 79 |
+
elif variable == "u_wind": value = 5 + 0.8 * z + 2.2 * np.sin(phase) + 0.12 * (lat - 55)
|
| 80 |
+
elif variable == "v_wind": value = 1.5 + 1.6 * np.cos(phase * 1.3) - 0.25 * z
|
| 81 |
+
elif variable == "omega": value = -0.025 * synoptic * np.exp(-0.08 * z)
|
| 82 |
+
elif variable == "geopotential": value = z * 50 * 9.81 + 4 * synoptic
|
| 83 |
+
elif variable == "cloud_liquid": value = np.maximum(0, 2.2e-4 * (humidity_base - 0.55) * np.exp(-0.18 * z))
|
| 84 |
+
else: value = np.clip((humidity_base - 0.55) * 1.8 * np.exp(-0.12 * z), 0, 1)
|
| 85 |
+
x[:, column] = value + rng.normal(0, max(float(np.std(value)) * 0.035, 1e-6), n)
|
| 86 |
+
column += 1
|
| 87 |
+
cos_sza = np.clip(np.cos(np.deg2rad(lat - 20)) * (0.97 - 0.01 * (hour - 11)), 0, 1)
|
| 88 |
+
singles = np.column_stack([
|
| 89 |
+
sst, surface_pressure, surface_pressure + 35, sst - 0.4, sst - 1.1,
|
| 90 |
+
sst - (1 - humidity_base) * 12, x[:, 30], x[:, 40], 190 * cos_sza,
|
| 91 |
+
315 - 2.5 * (sst - 278), 65 + 18 * synoptic, 18 + 8 * stability,
|
| 92 |
+
650 + 120 * humidity_base + 20 * synoptic, 16 + 30 * humidity_base,
|
| 93 |
+
0.08 + 0.18 * np.maximum(synoptic, 0), 80 * np.maximum(synoptic, 0),
|
| 94 |
+
-25 * np.maximum(-synoptic, 0), np.clip(0.25 + 0.45 * humidity_base + 0.05 * synoptic, 0, 1),
|
| 95 |
+
np.clip((lat - 68) / 8, 0, 1), np.maximum(0, 1.8 + 1.5 * synoptic),
|
| 96 |
+
cos_sza, lat, lon, hour,
|
| 97 |
+
]).astype(np.float32)
|
| 98 |
+
x[:, 90:] = singles
|
| 99 |
+
|
| 100 |
+
platform_term = np.where(platform == "Aqua", 1.0, -1.0)
|
| 101 |
+
low_cloud = np.clip(0.22 + 0.55 * humidity_base + 0.035 * stability + 0.025 * synoptic, 0.05, 0.9)
|
| 102 |
+
nd = 62 + 48 * humidity_base + 5 * synoptic + 0.32 * (lat - 55) + 1.8 * platform_term
|
| 103 |
+
reff = 18.5 - 0.035 * nd + 0.055 * (sst - 278) - 0.10 * stability
|
| 104 |
+
lwp = 58 + 115 * low_cloud + 10 * synoptic - 2.0 * stability
|
| 105 |
+
cf = np.clip(low_cloud + 0.018 * platform_term, 0.03, 0.95)
|
| 106 |
+
|
| 107 |
+
plume = np.exp(-((lat - 60) / 10) ** 2 - ((lon + 20) / 25) ** 2)
|
| 108 |
+
eruption = (year == 2014).astype(np.float32) * (0.72 + 0.28 * (month == 10)) * plume
|
| 109 |
+
nd *= 1 + 0.28 * eruption
|
| 110 |
+
reff *= 1 - 0.08 * eruption
|
| 111 |
+
lwp *= 1 + 0.008 * eruption
|
| 112 |
+
cf = np.clip(cf * (1 + 0.11 * eruption), 0.01, 0.99)
|
| 113 |
+
y = np.column_stack([
|
| 114 |
+
nd + rng.normal(0, 3.0, n), reff + rng.normal(0, 0.28, n),
|
| 115 |
+
lwp + rng.normal(0, 5.0, n), cf + rng.normal(0, 0.018, n),
|
| 116 |
+
]).astype(np.float32)
|
| 117 |
+
y[:, 0:3] = np.maximum(y[:, 0:3], 1e-3)
|
| 118 |
+
y[:, 3] = np.clip(y[:, 3], 0.001, 0.999)
|
| 119 |
+
|
| 120 |
+
payload = {"X": x, "Y": y, "year": year, "month": month, "platform": platform,
|
| 121 |
+
"platform_hour": hour, "latitude": lat, "longitude": lon,
|
| 122 |
+
"feature_names": np.asarray(feature_names()), "target_names": np.asarray(config["data"]["variables"]["targets"]["names"]),
|
| 123 |
+
"format_version": np.array(config["format_version"]),
|
| 124 |
+
"is_ocean": np.ones(n, dtype=bool), "eruption_strength": eruption.astype(np.float32)}
|
| 125 |
+
validate_multimodal_keys(payload)
|
| 126 |
+
output = ROOT / (args.output or config["data"]["path"])
|
| 127 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 128 |
+
np.savez_compressed(output, **payload)
|
| 129 |
+
print(f"output={output.relative_to(ROOT)} samples={n} shape={list(x.shape)} "
|
| 130 |
+
f"eruption_samples={int((year == 2014).sum())}")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run all serialized trees and retain ensemble and per-tree predictions."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
import yaml
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 15 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 16 |
+
from ml_modis import BootstrapRandomForestRegressor, validate_multimodal_keys
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main() -> None:
|
| 20 |
+
parser = argparse.ArgumentParser()
|
| 21 |
+
parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
|
| 22 |
+
parser.add_argument("--data", default=None)
|
| 23 |
+
parser.add_argument("--checkpoint", default=None)
|
| 24 |
+
parser.add_argument("--output", default=None)
|
| 25 |
+
args = parser.parse_args()
|
| 26 |
+
config = yaml.safe_load(Path(args.config).read_text())
|
| 27 |
+
with np.load(ROOT / (args.data or config["data"]["path"])) as archive:
|
| 28 |
+
data = {key: archive[key] for key in archive.files}
|
| 29 |
+
validate_multimodal_keys(data)
|
| 30 |
+
checkpoint = torch.load(ROOT / (args.checkpoint or config["paths"]["checkpoint"]), map_location="cpu", weights_only=False)
|
| 31 |
+
if checkpoint.get("format_version") != config["format_version"]:
|
| 32 |
+
raise ValueError("Checkpoint format_version does not match configuration")
|
| 33 |
+
targets = list(checkpoint["model_config"]["targets"])
|
| 34 |
+
tree_count = len(next(iter(checkpoint["model"].values()))["state"]["trees"])
|
| 35 |
+
tree_predictions = np.full((data["X"].shape[0], len(targets), tree_count), np.nan, dtype=np.float32)
|
| 36 |
+
for month in checkpoint["model_config"]["months"]:
|
| 37 |
+
mask = data["month"] == month
|
| 38 |
+
for target_index, target in enumerate(targets):
|
| 39 |
+
model = BootstrapRandomForestRegressor.from_state_dict(checkpoint["model"][f"{month}:{target}"]["state"])
|
| 40 |
+
tree_predictions[mask, target_index, :] = model.predict_trees(data["X"][mask])
|
| 41 |
+
prediction = tree_predictions.mean(axis=2)
|
| 42 |
+
safe_prediction = np.where(np.abs(prediction) > 1e-8, prediction, np.nan)
|
| 43 |
+
ratio = data["Y"] / safe_prediction
|
| 44 |
+
if not np.isfinite(prediction).all() or not np.isfinite(ratio).all():
|
| 45 |
+
raise FloatingPointError("Inference produced non-finite values")
|
| 46 |
+
output = ROOT / (args.output or config["paths"]["predictions"])
|
| 47 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 48 |
+
np.savez_compressed(output, pred=prediction, pred_trees=tree_predictions, obs=data["Y"],
|
| 49 |
+
obs_over_pred=ratio, relative_response=ratio - 1.0,
|
| 50 |
+
year=data["year"], month=data["month"], platform=data["platform"],
|
| 51 |
+
latitude=data["latitude"], longitude=data["longitude"],
|
| 52 |
+
target_names=np.asarray(targets))
|
| 53 |
+
print(f"output={output.relative_to(ROOT)} samples={prediction.shape[0]} "
|
| 54 |
+
f"targets={targets} trees_per_prediction={tree_count}")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Evaluate OOB skill, 2014 cloud responses, importance and radiative contributions."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import math
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import yaml
|
| 15 |
+
|
| 16 |
+
import matplotlib
|
| 17 |
+
matplotlib.use("Agg")
|
| 18 |
+
import matplotlib.pyplot as plt
|
| 19 |
+
|
| 20 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 21 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 22 |
+
from ml_modis import BootstrapRandomForestRegressor, feature_names, regression_metrics
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def finite(value: float):
|
| 26 |
+
return float(value) if math.isfinite(float(value)) else None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def weighted_mean(values: np.ndarray, latitude: np.ndarray) -> float:
|
| 30 |
+
valid = np.isfinite(values)
|
| 31 |
+
weights = np.cos(np.deg2rad(latitude[valid]))
|
| 32 |
+
return float(np.sum(values[valid] * weights) / np.sum(weights))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main() -> None:
|
| 36 |
+
parser = argparse.ArgumentParser()
|
| 37 |
+
parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
|
| 38 |
+
parser.add_argument("--data", default=None)
|
| 39 |
+
parser.add_argument("--checkpoint", default=None)
|
| 40 |
+
parser.add_argument("--predictions", default=None)
|
| 41 |
+
parser.add_argument("--output", default=None)
|
| 42 |
+
parser.add_argument("--skip-importance", action="store_true")
|
| 43 |
+
args = parser.parse_args()
|
| 44 |
+
config = yaml.safe_load(Path(args.config).read_text())
|
| 45 |
+
with np.load(ROOT / (args.data or config["data"]["path"])) as archive:
|
| 46 |
+
data = {key: archive[key] for key in archive.files}
|
| 47 |
+
with np.load(ROOT / (args.predictions or config["paths"]["predictions"])) as archive:
|
| 48 |
+
predictions = {key: archive[key] for key in archive.files}
|
| 49 |
+
checkpoint = torch.load(ROOT / (args.checkpoint or config["paths"]["checkpoint"]), map_location="cpu", weights_only=False)
|
| 50 |
+
targets = list(checkpoint["model_config"]["targets"])
|
| 51 |
+
report = {"format_version": config["format_version"],
|
| 52 |
+
"evidence_scope": "Synthetic structured data smoke reproduction; not paper numerical results.",
|
| 53 |
+
"oob": {}, "all_sample_skill": {}, "response_2014": {}, "susceptibility": {},
|
| 54 |
+
"radiative_relative_contribution_percent": {}, "permutation_importance_top10": {}}
|
| 55 |
+
names = feature_names()
|
| 56 |
+
for month in checkpoint["model_config"]["months"]:
|
| 57 |
+
for target_index, target in enumerate(targets):
|
| 58 |
+
key = f"{month}:{target}"
|
| 59 |
+
model_info = checkpoint["model"][key]
|
| 60 |
+
report["oob"][key] = {metric: finite(value) if metric != "n" else int(value)
|
| 61 |
+
for metric, value in model_info["oob_metrics"].items()}
|
| 62 |
+
month_mask = data["month"] == month
|
| 63 |
+
metrics = regression_metrics(predictions["obs"][month_mask, target_index], predictions["pred"][month_mask, target_index])
|
| 64 |
+
report["all_sample_skill"][key] = {metric: finite(value) if metric != "n" else int(value) for metric, value in metrics.items()}
|
| 65 |
+
if not args.skip_importance:
|
| 66 |
+
train_mask = month_mask & (data["year"] != 2014)
|
| 67 |
+
forest = BootstrapRandomForestRegressor.from_state_dict(model_info["state"])
|
| 68 |
+
importance = forest.permutation_importance(data["X"][train_mask], data["Y"][train_mask, target_index], config["runtime"]["seed"] + target_index)
|
| 69 |
+
order = np.argsort(importance)[::-1][:config["evaluation"]["importance_top_k"]]
|
| 70 |
+
report["permutation_importance_top10"][key] = [
|
| 71 |
+
{"feature": names[index], "delta_oob_mse": float(importance[index])} for index in order
|
| 72 |
+
]
|
| 73 |
+
eruption = predictions["year"] == 2014
|
| 74 |
+
monthly_log_response = {target: [] for target in targets}
|
| 75 |
+
for month in checkpoint["model_config"]["months"]:
|
| 76 |
+
mask = eruption & (predictions["month"] == month)
|
| 77 |
+
for target_index, target in enumerate(targets):
|
| 78 |
+
ratio = predictions["obs_over_pred"][mask, target_index]
|
| 79 |
+
mean_ratio = weighted_mean(ratio, predictions["latitude"][mask])
|
| 80 |
+
response = mean_ratio - 1.0
|
| 81 |
+
report["response_2014"][f"{month}:{target}"] = {
|
| 82 |
+
"area_weighted_obs_over_pred": mean_ratio,
|
| 83 |
+
"area_weighted_relative_percent": 100.0 * response,
|
| 84 |
+
"samples": int(mask.sum()),
|
| 85 |
+
}
|
| 86 |
+
monthly_log_response[target].append(math.log(max(mean_ratio, 1e-8)))
|
| 87 |
+
nd_change = float(np.mean(monthly_log_response["Nd"]))
|
| 88 |
+
for target in ("reff", "LWP", "CF"):
|
| 89 |
+
report["susceptibility"][f"dln{target}_dlnNd"] = finite(float(np.mean(monthly_log_response[target])) / nd_change)
|
| 90 |
+
|
| 91 |
+
alpha_cloud = float(config["evaluation"]["cloud_albedo"])
|
| 92 |
+
alpha_clear = float(config["evaluation"]["clear_sky_ocean_albedo"])
|
| 93 |
+
s_lwp = report["susceptibility"]["dlnLWP_dlnNd"] or 0.0
|
| 94 |
+
s_cf = report["susceptibility"]["dlnCF_dlnNd"] or 0.0
|
| 95 |
+
terms = {
|
| 96 |
+
"Twomey": alpha_cloud * (1 - alpha_cloud) / 3.0,
|
| 97 |
+
"LWP": alpha_cloud * (1 - alpha_cloud) * (5.0 / 6.0) * s_lwp,
|
| 98 |
+
"CF": (alpha_cloud - alpha_clear) * s_cf,
|
| 99 |
+
}
|
| 100 |
+
denominator = sum(terms.values())
|
| 101 |
+
report["radiative_relative_contribution_percent"] = {
|
| 102 |
+
key: finite(100.0 * value / denominator) for key, value in terms.items()
|
| 103 |
+
}
|
| 104 |
+
report["radiative_assumptions"] = {
|
| 105 |
+
"cloud_albedo": alpha_cloud, "clear_sky_ocean_albedo": alpha_clear,
|
| 106 |
+
"method": "Paper equations 1-3; common SWdown, CF and dlnNd/dlnAOD factors cancel in relative terms.",
|
| 107 |
+
"twomey_note": "The 1/3 term follows the paper equation; observed dlnreff/dlnNd is reported separately."
|
| 108 |
+
}
|
| 109 |
+
output_dir = ROOT / config["paths"]["evaluation_dir"]
|
| 110 |
+
output = ROOT / args.output if args.output else output_dir / "metrics.json"
|
| 111 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 112 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 113 |
+
serialized = json.dumps(report, indent=2, allow_nan=False) + "\n"
|
| 114 |
+
output.write_text(serialized)
|
| 115 |
+
figure, axes = plt.subplots(1, 2, figsize=(12, 4.5))
|
| 116 |
+
labels = [f"{month}-{target}" for month in checkpoint["model_config"]["months"] for target in targets]
|
| 117 |
+
pearson = [report["all_sample_skill"][label.replace("-", ":")]["pearson"] for label in labels]
|
| 118 |
+
axes[0].bar(labels, pearson, color=["#275d6c", "#d98b3a", "#6b8e23", "#8b5a83"] * 2)
|
| 119 |
+
axes[0].set(ylabel="Pearson correlation", title="All-sample model skill")
|
| 120 |
+
axes[0].tick_params(axis="x", rotation=45, labelsize=8)
|
| 121 |
+
response_labels = [f"{month}-{target}" for month in checkpoint["model_config"]["months"] for target in targets]
|
| 122 |
+
responses = [report["response_2014"][label.replace("-", ":")]["area_weighted_relative_percent"] for label in response_labels]
|
| 123 |
+
axes[1].bar(response_labels, responses, color=["#275d6c", "#d98b3a", "#6b8e23", "#8b5a83"] * 2)
|
| 124 |
+
axes[1].axhline(0, color="black", linewidth=0.7)
|
| 125 |
+
axes[1].set(ylabel="Area-weighted response (%)", title="Observed / counterfactual in 2014")
|
| 126 |
+
axes[1].tick_params(axis="x", rotation=45, labelsize=8)
|
| 127 |
+
figure.tight_layout()
|
| 128 |
+
figure.savefig(output_dir / "comparison.png", dpi=int(config["evaluation"]["figure_dpi"]))
|
| 129 |
+
plt.close(figure)
|
| 130 |
+
print(json.dumps({"output": str(output), "response_2014": report["response_2014"],
|
| 131 |
+
"susceptibility": report["susceptibility"],
|
| 132 |
+
"radiative_percent": report["radiative_relative_contribution_percent"]}, indent=2))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Train month-by-target ML-MODIS forests, optionally task-parallel under torchrun."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import yaml
|
| 15 |
+
|
| 16 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 18 |
+
from ml_modis import BootstrapRandomForestRegressor, feature_names, regression_metrics, validate_multimodal_keys
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def args_parser() -> argparse.Namespace:
|
| 22 |
+
parser = argparse.ArgumentParser()
|
| 23 |
+
parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
|
| 24 |
+
parser.add_argument("--data", default=None)
|
| 25 |
+
parser.add_argument("--checkpoint", default=None)
|
| 26 |
+
parser.add_argument("--paper-model", action="store_true")
|
| 27 |
+
parser.add_argument("--trees", type=int, default=None)
|
| 28 |
+
return parser.parse_args()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def distributed_context() -> tuple[int, int]:
|
| 32 |
+
world = int(os.environ.get("WORLD_SIZE", "1"))
|
| 33 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 34 |
+
if world > 1:
|
| 35 |
+
torch.distributed.init_process_group(backend="gloo")
|
| 36 |
+
return rank, world
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> None:
|
| 40 |
+
args = args_parser()
|
| 41 |
+
config = yaml.safe_load(Path(args.config).read_text())
|
| 42 |
+
settings = dict(config["model"])
|
| 43 |
+
if args.paper_model:
|
| 44 |
+
settings.update(config["paper_model"])
|
| 45 |
+
if args.trees is not None:
|
| 46 |
+
settings["trees"] = args.trees
|
| 47 |
+
data_path = ROOT / (args.data or config["data"]["path"])
|
| 48 |
+
with np.load(data_path) as archive:
|
| 49 |
+
data = {key: archive[key] for key in archive.files}
|
| 50 |
+
validate_multimodal_keys(data)
|
| 51 |
+
rank, world = distributed_context()
|
| 52 |
+
months = config["data"]["months"]
|
| 53 |
+
targets = config["data"]["variables"]["targets"]["names"]
|
| 54 |
+
tasks = [(int(month), target_index, target) for month in months
|
| 55 |
+
for target_index, target in enumerate(targets)]
|
| 56 |
+
local_models = {}
|
| 57 |
+
for task_index, (month, target_index, target) in enumerate(tasks):
|
| 58 |
+
if task_index % world != rank:
|
| 59 |
+
continue
|
| 60 |
+
mask = (data["month"] == month) & (data["year"] != config["train"]["excluded_year"])
|
| 61 |
+
x, y = data["X"][mask], data["Y"][mask, target_index]
|
| 62 |
+
model = BootstrapRandomForestRegressor(
|
| 63 |
+
n_trees=int(settings["trees"]), min_leaf=int(settings["min_leaf"]),
|
| 64 |
+
max_features=int(settings["max_features"]), bootstrap_fraction=float(settings["bootstrap_fraction"]),
|
| 65 |
+
max_depth=settings["max_depth"], split_candidates=int(settings["split_candidates"]),
|
| 66 |
+
seed=int(config["runtime"]["seed"] + task_index * 1009),
|
| 67 |
+
).fit(x, y)
|
| 68 |
+
oob, counts = model.oob_predict(x)
|
| 69 |
+
local_models[f"{month}:{target}"] = {
|
| 70 |
+
"state": model.state_dict(), "oob_metrics": regression_metrics(y[counts > 0], oob[counts > 0]),
|
| 71 |
+
"train_samples": int(mask.sum()), "excluded_year": int(config["train"]["excluded_year"]),
|
| 72 |
+
}
|
| 73 |
+
print(f"rank={rank} trained month={month} target={target} samples={mask.sum()}", flush=True)
|
| 74 |
+
if world > 1:
|
| 75 |
+
gathered = [None] * world if rank == 0 else None
|
| 76 |
+
torch.distributed.gather_object(local_models, gathered, dst=0)
|
| 77 |
+
if rank == 0:
|
| 78 |
+
local_models = {key: value for shard in gathered for key, value in shard.items()}
|
| 79 |
+
if rank == 0:
|
| 80 |
+
checkpoint = ROOT / (args.checkpoint or config["paths"]["checkpoint"])
|
| 81 |
+
checkpoint.parent.mkdir(parents=True, exist_ok=True)
|
| 82 |
+
model_config = {
|
| 83 |
+
"architecture": "BootstrapRandomForestRegressor", "settings": settings,
|
| 84 |
+
"targets": targets, "months": months, "input_features": 114,
|
| 85 |
+
"feature_names": feature_names(), "excluded_year": int(config["train"]["excluded_year"]),
|
| 86 |
+
}
|
| 87 |
+
torch.save({"model": local_models, "model_config": model_config,
|
| 88 |
+
"format_version": config["format_version"],
|
| 89 |
+
"training": {"paper_model": args.paper_model, "distributed_world_size": world}}, checkpoint)
|
| 90 |
+
summary = {key: value["oob_metrics"] for key, value in sorted(local_models.items())}
|
| 91 |
+
metrics_path = ROOT / config["paths"]["training_metrics"]
|
| 92 |
+
metrics_path.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
metrics_path.write_text(json.dumps({"format_version": config["format_version"],
|
| 94 |
+
"models": summary}, indent=2, allow_nan=False) + "\n")
|
| 95 |
+
print(json.dumps({"checkpoint": str(checkpoint), "models": len(local_models), "oob": summary}, indent=2))
|
| 96 |
+
if world > 1:
|
| 97 |
+
torch.distributed.destroy_process_group()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|