zhangrenchao commited on
Commit
73ddb67
·
verified ·
1 Parent(s): a349ff4

Publish ML-MODIS engineering reproduction

Browse files
.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
- *.pth filter=lfs diff=lfs merge=lfs -text
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