Publish FireCubeNet engineering reproduction
Browse files- .gitattributes +1 -24
- README.md +138 -0
- README_zh.md +141 -0
- conf/config.yaml +36 -0
- config.json +49 -0
- configuration.json +12 -0
- model/firecubenet.py +57 -0
- scripts/fake_data.py +89 -0
- scripts/inference.py +49 -0
- scripts/result.py +76 -0
- scripts/train.py +130 -0
- weight/.gitkeep +0 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,12 @@
|
|
| 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 |
-
*
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
| 1 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 2 |
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
| 3 |
*.joblib filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 4 |
*.model filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 5 |
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.npz filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 9 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 10 |
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
weight/* filter=lfs diff=lfs merge=lfs -text
|
|
|
README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- OneScience
|
| 7 |
+
- Earth Science
|
| 8 |
+
- Wildfire Danger Prediction
|
| 9 |
+
- ConvLSTM
|
| 10 |
+
frameworks: PyTorch
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<strong><span style="font-size: 30px;">FireCubeNet</span></strong>
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
# Model Introduction
|
| 18 |
+
|
| 19 |
+
FireCubeNet addresses next-day danger prediction for large wildfires by combining meteorological, vegetation, soil-moisture, human-activity, terrain, and land-cover conditions to identify areas at risk of severe fire. It is primarily intended to generate wildfire-danger probabilities, identify important drivers of fire ignition and spread, and support wildfire warning, risk assessment, and emergency-resource planning.
|
| 20 |
+
|
| 21 |
+
Paper: Wildfire Danger Prediction and Understanding With Deep Learning
|
| 22 |
+
https://doi.org/10.1029/2022GL099368
|
| 23 |
+
|
| 24 |
+
# Model Description
|
| 25 |
+
|
| 26 |
+
FireCubeNet was proposed by research teams from the National Observatory of Athens, Universitat de València, Max Planck Institute for Biogeochemistry, and Universidade Nova de Lisboa. The paper trained and validated the models with ERA5-Land, MODIS, European Drought Observatory, WorldPop, Copernicus EU-DEM, CORINE Land Cover, EFFIS, and MODIS active-fire data. The model supports next-day wildfire-danger prediction, spatiotemporal fire-driver modeling, and large-wildfire risk assessment.
|
| 27 |
+
|
| 28 |
+
# Use Cases
|
| 29 |
+
|
| 30 |
+
| Use Case | Description |
|
| 31 |
+
| :---: | :--- |
|
| 32 |
+
| Next-day wildfire danger | Estimate next-day large-fire danger from ten days and a 25 km neighborhood around a center pixel. |
|
| 33 |
+
| Spatiotemporal drivers | Combine weather, vegetation, wetness, socioeconomic, terrain, and land-cover variables. |
|
| 34 |
+
| ModelScope/OneCode execution | Validate training, inference, evaluation, visualization, and checkpoint workflows in ModelScope or OneCode. |
|
| 35 |
+
| Multi-GPU training | Run distributed data-parallel training with `torchrun`. |
|
| 36 |
+
|
| 37 |
+
# Usage Instructions
|
| 38 |
+
|
| 39 |
+
## 1.OneCode
|
| 40 |
+
|
| 41 |
+
Experience intelligent, one-click AI4S programming through the OneCode online environment:
|
| 42 |
+
|
| 43 |
+
[Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 44 |
+
|
| 45 |
+
## 2. Download and Installation
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
hf download OneScience-Group/FireCubeNet --local-dir ./FireCubeNet
|
| 49 |
+
cd FireCubeNet
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### Environment Dependencies
|
| 53 |
+
|
| 54 |
+
**Hardware Requirements**
|
| 55 |
+
|
| 56 |
+
- A GPU or DCU is recommended.
|
| 57 |
+
- A CPU can be used for connectivity validation with the default small-sample configuration.
|
| 58 |
+
- DCU users must install DTK first. DTK 25.04.2 or later, or the OneScience-recommended version matching the cluster, is recommended.
|
| 59 |
+
|
| 60 |
+
**DCU Environment**
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
# Activate DTK and Conda first
|
| 64 |
+
conda create -n onescience311 python=3.11 -y
|
| 65 |
+
conda activate onescience311
|
| 66 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
**GPU Environment**
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# Activate Conda first
|
| 73 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 74 |
+
conda activate onescience311
|
| 75 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### Training Data
|
| 79 |
+
|
| 80 |
+
The default synthetic dataset has few samples but preserves the real `10×25×25×25` input dimensions. It includes persistent weather, progressive drying, spatial hotspots, and physically correlated variables; static fields repeat through time, and the ten land-cover fractions sum to exactly one at every pixel. Synthetic labels validate engineering connectivity only and do not represent the official data distribution or paper performance.
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
python scripts/fake_data.py
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### Training
|
| 87 |
+
|
| 88 |
+
For single-device training, use:
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
python scripts/train.py
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
For multi-GPU training, use:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
torchrun --nproc_per_node=8 --nnodes=1 --rdzv_id=1000 --rdzv_backend=c10d --max_restarts=0 --master_addr="localhost" --master_port=29500 scripts/train.py
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
Training results include model parameters for inference and loss metrics for each training epoch. The results are saved to:
|
| 101 |
+
|
| 102 |
+
```text
|
| 103 |
+
result/checkpoints/firecubenet.pt
|
| 104 |
+
result/training/metrics.json
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### Trained Weights
|
| 108 |
+
|
| 109 |
+
No trained weights are bundled under `weight/`. The paper provides no confirmed official checkpoint, and the engineering checkpoint is not claimed to be compatible with external weights.
|
| 110 |
+
|
| 111 |
+
### Inference
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
python scripts/inference.py
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Inference results contain next-day wildfire-danger probabilities, reference labels, and the corresponding temporal and spatial location information, and are saved to `result/output/predictions.npz`.
|
| 118 |
+
|
| 119 |
+
### Evaluation and Visualization
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
python scripts/result.py
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
Evaluation reports Precision, Recall, F1, AUROC, and the confusion matrix in `result/evaluation/metrics.json`. It also generates the wildfire-danger probability and ROC comparison figure `result/evaluation/wildfire_danger.png`. Synthetic-data results are only for engineering-workflow validation and do not represent paper performance on the real test set.
|
| 126 |
+
|
| 127 |
+
# Official OneScience Information
|
| 128 |
+
|
| 129 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 130 |
+
| --- | --- | --- |
|
| 131 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 132 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 133 |
+
|
| 134 |
+
# Citation and License
|
| 135 |
+
|
| 136 |
+
This repository is an independent engineering reproduction of the public FireCubeNet paper specifications.
|
| 137 |
+
|
| 138 |
+
Use of this repository's code, official model weights, and data remains subject to the licenses and terms of their respective projects.
|
README_zh.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
- zh
|
| 6 |
+
tags:
|
| 7 |
+
- OneScience
|
| 8 |
+
- 地球科学
|
| 9 |
+
- 野火危险预测
|
| 10 |
+
- ConvLSTM
|
| 11 |
+
frameworks: PyTorch
|
| 12 |
+
datasets:
|
| 13 |
+
- Wildfire Danger Dataset
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
<p align="center">
|
| 17 |
+
<strong><span style="font-size: 30px;">FireCubeNet</span></strong>
|
| 18 |
+
</p>
|
| 19 |
+
|
| 20 |
+
# 模型介绍
|
| 21 |
+
|
| 22 |
+
FireCubeNet 用于解决大面积野火的次日危险预测问题,综合气象、植被、土壤湿度、人类活动、地形和土地覆盖条件判断可能发生严重野火的区域。模型主要用于生成野火危险概率、识别影响火灾发生与传播的重要因素,并为野火预警、风险评估和应急资源部署提供数据驱动支持。
|
| 23 |
+
|
| 24 |
+
论文:Wildfire Danger Prediction and Understanding With Deep Learning
|
| 25 |
+
https://doi.org/10.1029/2022GL099368
|
| 26 |
+
|
| 27 |
+
# 模型描述
|
| 28 |
+
|
| 29 |
+
FireCubeNet 由 National Observatory of Athens、Universitat de València、Max Planck Institute for Biogeochemistry 和 Universidade Nova de Lisboa 的研究团队提出。论文使用 ERA5-Land、MODIS、European Drought Observatory、WorldPop、Copernicus EU-DEM、CORINE Land Cover、EFFIS 和 MODIS active fire 数据训练与验证。模型适用于次日野火危险预测、时空火灾驱动建模和大面积野火风险评估。
|
| 30 |
+
|
| 31 |
+
# 适用场景
|
| 32 |
+
|
| 33 |
+
| 场景 | 说明 |
|
| 34 |
+
| :---: | :--- |
|
| 35 |
+
| 次日野火危险 | 从中心像素过去 10 天及其 25 km 邻域估计次日大火危险概率。 |
|
| 36 |
+
| 时空驱动建模 | 联合使用天气、植被、湿度、社会经济、地形与土地覆盖变量。 |
|
| 37 |
+
| ModelScope/OneCode 运行 | 在 ModelScope 或 OneCode 环境中验证训练、推理、评估、可视化和 checkpoint 流程。 |
|
| 38 |
+
| 多卡训练 | 通过 `torchrun` 运行分布式数据并行训练。 |
|
| 39 |
+
|
| 40 |
+
# 使用说明
|
| 41 |
+
|
| 42 |
+
## 1.OneCode
|
| 43 |
+
|
| 44 |
+
可通过 OneCode 在线环境体验智能化一键式 AI4S 编程:
|
| 45 |
+
|
| 46 |
+
[点击体验智能化一键式 AI4S 编程](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 47 |
+
|
| 48 |
+
## 2.下载安装
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
modelscope download --model OneScience/FireCubeNet --local_dir ./FireCubeNet
|
| 52 |
+
cd FireCubeNet
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
### 环境依赖
|
| 56 |
+
|
| 57 |
+
**硬件要求**
|
| 58 |
+
|
| 59 |
+
- 推荐使用 GPU 或 DCU 运行。
|
| 60 |
+
- CPU 可用于默认小样本配置的连通性验证。
|
| 61 |
+
- DCU 用户需预先安装 DTK,建议使用 DTK 25.04.2 以上版本或与当前集群匹配的 OneScience 推荐版本。
|
| 62 |
+
|
| 63 |
+
**DCU环境**
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
# 请首先激活DTK及CONDA
|
| 67 |
+
conda create -n onescience311 python=3.11 -y
|
| 68 |
+
conda activate onescience311
|
| 69 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
**GPU环境**
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
# 请首先激活CONDA
|
| 76 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 77 |
+
conda activate onescience311
|
| 78 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
### 训练数据介绍
|
| 82 |
+
|
| 83 |
+
默认虚拟数据仅含少量样本,但保持 `10×25×25×25` 的真实维度。生成器包含持续天气、逐日干燥、空间热点及变量间物理相关;静态空间变量在时间上重复,10 个土地覆盖 fraction 在每个像素严格归一化为 1。虚拟标签只用于工程连通性验证,不代表官方数据分布和论文性能。
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
python scripts/fake_data.py
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### 训练
|
| 90 |
+
|
| 91 |
+
单卡训练可使用:
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
python scripts/train.py
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
多卡训练可使用:
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
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
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
训练结果包含用于推理的模型参数,以及各训练轮次的损失指标。训练结果保存到:
|
| 104 |
+
|
| 105 |
+
```text
|
| 106 |
+
result/checkpoints/firecubenet.pt
|
| 107 |
+
result/training/metrics.json
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### 训练权重
|
| 111 |
+
|
| 112 |
+
本仓库不在 `weight/` 内置权重。论文未提供可确认的官方 checkpoint,当前工程 checkpoint 不声明兼容外部权重。
|
| 113 |
+
|
| 114 |
+
### 推理
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
python scripts/inference.py
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
推理结果包含次日野火危险概率、真实标签以及对应的时间和空间位置信息,并保存到 `result/output/predictions.npz`。
|
| 121 |
+
|
| 122 |
+
### 评估和可视化
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
python scripts/result.py
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
评估结果包含 Precision、Recall、F1、AUROC 和混淆矩阵,并保存到 `result/evaluation/metrics.json`。脚本同时生成野火危险概率与 ROC 对比图 `result/evaluation/wildfire_danger.png`。虚拟数据结果仅用于验证工程流程,不代表论文真实测试集性能。
|
| 129 |
+
|
| 130 |
+
# OneScience官方信息
|
| 131 |
+
|
| 132 |
+
| 平台 | OneScience 主仓库 | Skills 仓库 |
|
| 133 |
+
| --- | --- | --- |
|
| 134 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 135 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 136 |
+
|
| 137 |
+
# 引用与许可证
|
| 138 |
+
|
| 139 |
+
本仓库为 FireCubeNet 论文公开规格的独立工程复现版本。
|
| 140 |
+
|
| 141 |
+
本仓库代码、官方模型权重和数据的使用仍应以各自项目中的许可证及使用条��为准。
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
seed: 42
|
| 2 |
+
data:
|
| 3 |
+
root: data
|
| 4 |
+
format_version: wildfire_danger_spatiotemporal_v1
|
| 5 |
+
train_samples: 12
|
| 6 |
+
test_samples: 8
|
| 7 |
+
sequence_days: 10
|
| 8 |
+
channels: 25
|
| 9 |
+
patch_height: 25
|
| 10 |
+
patch_width: 25
|
| 11 |
+
resolution_km: 1
|
| 12 |
+
input_layout: BTCHW
|
| 13 |
+
target_layout: B1
|
| 14 |
+
model:
|
| 15 |
+
input_channels: 25
|
| 16 |
+
hidden_channels: 4
|
| 17 |
+
kernel_size: 3
|
| 18 |
+
dropout: 0.1
|
| 19 |
+
train:
|
| 20 |
+
epochs: 2
|
| 21 |
+
batch_size: 4
|
| 22 |
+
learning_rate: 0.003
|
| 23 |
+
optimizer: Adam
|
| 24 |
+
loss: BCEWithLogitsLoss
|
| 25 |
+
weight_decay: 0.0001
|
| 26 |
+
gradient_clip_norm: 5.0
|
| 27 |
+
num_workers: 0
|
| 28 |
+
runtime:
|
| 29 |
+
device: auto
|
| 30 |
+
paths:
|
| 31 |
+
checkpoint: result/checkpoints/firecubenet.pt
|
| 32 |
+
training_metrics: result/training/metrics.json
|
| 33 |
+
inference_dir: result/output
|
| 34 |
+
evaluation_dir: result/evaluation
|
| 35 |
+
evaluation:
|
| 36 |
+
threshold: 0.5
|
config.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "FireCubeNet",
|
| 3 |
+
"model_type": "firecubenet",
|
| 4 |
+
"architectures": ["FireCubeNet"],
|
| 5 |
+
"framework": "PyTorch",
|
| 6 |
+
"domain": "earth-science",
|
| 7 |
+
"task": "next-day-wildfire-danger-classification",
|
| 8 |
+
"implementation": {
|
| 9 |
+
"entry_point": "model/firecubenet.py",
|
| 10 |
+
"scope": "paper-task and real-input-dimension scaled 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": "ConvLSTM center-pixel binary classifier",
|
| 18 |
+
"input_layout": "BTCHW",
|
| 19 |
+
"input_channels": 25,
|
| 20 |
+
"sequence_days": 10,
|
| 21 |
+
"patch_height": 25,
|
| 22 |
+
"patch_width": 25,
|
| 23 |
+
"engineering_hidden_channels": 4,
|
| 24 |
+
"loss": "BCEWithLogitsLoss"
|
| 25 |
+
},
|
| 26 |
+
"data": {
|
| 27 |
+
"protocol": "wildfire_danger_spatiotemporal_v1",
|
| 28 |
+
"format": "NPZ",
|
| 29 |
+
"resolution_km": 1,
|
| 30 |
+
"sample_interval_days": 1,
|
| 31 |
+
"input_shape": ["B", 10, 25, 25, 25],
|
| 32 |
+
"target_shape": ["B", 1],
|
| 33 |
+
"channels": [
|
| 34 |
+
"maximum_2m_temperature", "maximum_wind_speed", "minimum_relative_humidity",
|
| 35 |
+
"total_precipitation", "maximum_2m_dewpoint_temperature", "maximum_surface_pressure",
|
| 36 |
+
"ndvi", "day_lst", "night_lst", "soil_moisture_index", "road_distance",
|
| 37 |
+
"waterway_distance", "population_density", "elevation", "slope",
|
| 38 |
+
"land_cover_fraction_1", "land_cover_fraction_2", "land_cover_fraction_3",
|
| 39 |
+
"land_cover_fraction_4", "land_cover_fraction_5", "land_cover_fraction_6",
|
| 40 |
+
"land_cover_fraction_7", "land_cover_fraction_8", "land_cover_fraction_9",
|
| 41 |
+
"land_cover_fraction_10"
|
| 42 |
+
],
|
| 43 |
+
"target": "center pixel belongs to a greater-than-30-ha fire starting next day"
|
| 44 |
+
},
|
| 45 |
+
"configuration_sources": [
|
| 46 |
+
"conf/config.yaml", "model/firecubenet.py", "scripts/fake_data.py",
|
| 47 |
+
"scripts/train.py", "scripts/inference.py", "scripts/result.py"
|
| 48 |
+
]
|
| 49 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "next-day-wildfire-danger-classification",
|
| 4 |
+
"model": "FireCubeNet",
|
| 5 |
+
"input_format": "BTCHW",
|
| 6 |
+
"protocol": "10-day 25-channel 25-km ConvLSTM center-pixel binary classification",
|
| 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/firecubenet.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ConvLSTM model for center-pixel next-day wildfire danger."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch import nn
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ConvLSTMCell(nn.Module):
|
| 10 |
+
"""Standard ConvLSTM cell with input, forget, output, and candidate gates."""
|
| 11 |
+
|
| 12 |
+
def __init__(self, input_channels: int, hidden_channels: int, kernel_size: int = 3):
|
| 13 |
+
super().__init__()
|
| 14 |
+
self.hidden_channels = int(hidden_channels)
|
| 15 |
+
padding = kernel_size // 2
|
| 16 |
+
self.gates = nn.Conv2d(
|
| 17 |
+
input_channels + hidden_channels, 4 * hidden_channels,
|
| 18 |
+
kernel_size=kernel_size, padding=padding,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
def forward(self, inputs: torch.Tensor, state: tuple[torch.Tensor, torch.Tensor]):
|
| 22 |
+
hidden, cell = state
|
| 23 |
+
input_gate, forget_gate, output_gate, candidate = self.gates(
|
| 24 |
+
torch.cat((inputs, hidden), dim=1)
|
| 25 |
+
).chunk(4, dim=1)
|
| 26 |
+
input_gate = torch.sigmoid(input_gate)
|
| 27 |
+
forget_gate = torch.sigmoid(forget_gate)
|
| 28 |
+
output_gate = torch.sigmoid(output_gate)
|
| 29 |
+
candidate = torch.tanh(candidate)
|
| 30 |
+
next_cell = forget_gate * cell + input_gate * candidate
|
| 31 |
+
next_hidden = output_gate * torch.tanh(next_cell)
|
| 32 |
+
return next_hidden, next_cell
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class FireCubeNet(nn.Module):
|
| 36 |
+
"""Propagate ConvLSTM state over ten days and classify the center pixel."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, input_channels: int = 25, hidden_channels: int = 4,
|
| 39 |
+
kernel_size: int = 3, dropout: float = 0.1):
|
| 40 |
+
super().__init__()
|
| 41 |
+
self.input_channels = int(input_channels)
|
| 42 |
+
self.hidden_channels = int(hidden_channels)
|
| 43 |
+
self.cell = ConvLSTMCell(input_channels, hidden_channels, kernel_size)
|
| 44 |
+
self.head = nn.Sequential(nn.Dropout(dropout), nn.Linear(hidden_channels, 1))
|
| 45 |
+
|
| 46 |
+
def forward(self, inputs: torch.Tensor) -> torch.Tensor:
|
| 47 |
+
if inputs.ndim != 5 or inputs.shape[2] != self.input_channels:
|
| 48 |
+
raise ValueError(
|
| 49 |
+
f"expected BTCHW with C={self.input_channels}, got {tuple(inputs.shape)}"
|
| 50 |
+
)
|
| 51 |
+
batch, _, _, height, width = inputs.shape
|
| 52 |
+
hidden = inputs.new_zeros(batch, self.hidden_channels, height, width)
|
| 53 |
+
cell = inputs.new_zeros(batch, self.hidden_channels, height, width)
|
| 54 |
+
for time_index in range(inputs.shape[1]):
|
| 55 |
+
hidden, cell = self.cell(inputs[:, time_index], (hidden, cell))
|
| 56 |
+
center_features = hidden[:, :, height // 2, width // 2]
|
| 57 |
+
return self.head(center_features)
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate small structured wildfire sequences with physical correlations."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import yaml
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def make_split(path, count, config, seed, day_offset):
|
| 14 |
+
rng = np.random.default_rng(seed)
|
| 15 |
+
data_config = config["data"]
|
| 16 |
+
time = int(data_config["sequence_days"])
|
| 17 |
+
height = int(data_config["patch_height"])
|
| 18 |
+
width = int(data_config["patch_width"])
|
| 19 |
+
yy, xx = np.mgrid[:height, :width].astype(np.float32)
|
| 20 |
+
inputs = np.empty((count, time, 25, height, width), dtype=np.float32)
|
| 21 |
+
danger_scores = np.empty(count, dtype=np.float32)
|
| 22 |
+
for sample in range(count):
|
| 23 |
+
center_y = height / 2 + rng.uniform(-3, 3)
|
| 24 |
+
center_x = width / 2 + rng.uniform(-3, 3)
|
| 25 |
+
hotspot = np.exp(-((yy - center_y) ** 2 + (xx - center_x) ** 2) / (2 * rng.uniform(4, 7) ** 2))
|
| 26 |
+
elevation = np.clip(0.25 + 0.018 * yy + 0.012 * xx + rng.normal(0, 0.02, (height, width)), 0, 1)
|
| 27 |
+
slope = np.clip(np.hypot(*np.gradient(elevation)) * 15, 0, 1)
|
| 28 |
+
road = np.clip(np.abs(xx - rng.uniform(5, 20)) / 20, 0, 1)
|
| 29 |
+
water = np.clip(np.abs(yy - (height / 2 + 2 * np.sin(xx / 4))) / 18, 0, 1)
|
| 30 |
+
population = np.exp(-((xx - rng.uniform(5, 20)) ** 2 + (yy - rng.uniform(5, 20)) ** 2) / 60)
|
| 31 |
+
cover_logits = rng.normal(0, 0.8, (10, height, width))
|
| 32 |
+
cover_logits += np.stack([np.sin((xx + index) / (3 + index / 3)) for index in range(10)])
|
| 33 |
+
cover = np.exp(cover_logits - cover_logits.max(axis=0, keepdims=True))
|
| 34 |
+
cover /= cover.sum(axis=0, keepdims=True)
|
| 35 |
+
weather = rng.normal(0, 0.45)
|
| 36 |
+
for day in range(time):
|
| 37 |
+
weather = 0.82 * weather + rng.normal(0, 0.25)
|
| 38 |
+
drying = day / max(time - 1, 1)
|
| 39 |
+
spatial_noise = rng.normal(0, 0.025, (height, width))
|
| 40 |
+
temperature = 0.50 + 0.16 * weather + 0.20 * drying + 0.16 * hotspot + spatial_noise
|
| 41 |
+
wind = 0.32 + 0.12 * weather + 0.10 * hotspot + rng.normal(0, 0.035, (height, width))
|
| 42 |
+
humidity = 0.62 - 0.19 * weather - 0.20 * drying - 0.14 * hotspot + spatial_noise
|
| 43 |
+
precipitation = np.clip(0.30 - 0.13 * weather - 0.18 * drying - 0.10 * hotspot + spatial_noise, 0, 1)
|
| 44 |
+
dewpoint = 0.55 * temperature + 0.40 * humidity
|
| 45 |
+
pressure = 0.55 - 0.06 * weather + 0.02 * hotspot + spatial_noise
|
| 46 |
+
ndvi = np.clip(0.62 - 0.14 * drying - 0.08 * hotspot + 0.08 * cover[2], 0, 1)
|
| 47 |
+
day_lst = np.clip(temperature + 0.10 * hotspot, 0, 1)
|
| 48 |
+
night_lst = np.clip(temperature - 0.16 + 0.04 * hotspot, 0, 1)
|
| 49 |
+
soil_moisture = np.clip(0.58 * humidity + 0.42 * precipitation - 0.10 * drying, 0, 1)
|
| 50 |
+
dynamic = [temperature, wind, humidity, precipitation, dewpoint, pressure,
|
| 51 |
+
ndvi, day_lst, night_lst, soil_moisture]
|
| 52 |
+
static = [road, water, population, elevation, slope, *cover]
|
| 53 |
+
inputs[sample, day] = np.stack(dynamic + static).astype(np.float32)
|
| 54 |
+
cy, cx = height // 2, width // 2
|
| 55 |
+
latest = inputs[sample, -1, :, cy, cx]
|
| 56 |
+
danger_scores[sample] = (1.7 * latest[0] + 1.1 * latest[1] - 1.5 * latest[2]
|
| 57 |
+
- 1.2 * latest[9] - 0.35 * latest[10]
|
| 58 |
+
+ 0.25 * latest[12] + rng.normal(0, 0.12))
|
| 59 |
+
labels = (danger_scores >= np.median(danger_scores)).astype(np.float32)[:, None]
|
| 60 |
+
timestamps = (np.datetime64("2018-06-01") + (np.arange(count) + day_offset).astype("timedelta64[D]"))
|
| 61 |
+
timestamps = timestamps.astype("datetime64[s]").astype(np.int64)
|
| 62 |
+
latitude = rng.uniform(34.0, 43.0, count).astype(np.float32)
|
| 63 |
+
longitude = rng.uniform(19.0, 30.0, count).astype(np.float32)
|
| 64 |
+
np.savez_compressed(
|
| 65 |
+
path, inputs=inputs, labels=labels, timestamps_unix_s=timestamps,
|
| 66 |
+
coords=np.column_stack((latitude, longitude)).astype(np.float32),
|
| 67 |
+
format_version=np.asarray(data_config["format_version"]),
|
| 68 |
+
data_source=np.asarray("structured_synthetic"), input_layout=np.asarray("BTCHW"),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
parser = argparse.ArgumentParser()
|
| 74 |
+
parser.add_argument("--force", action="store_true")
|
| 75 |
+
args = parser.parse_args()
|
| 76 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 77 |
+
output = ROOT / config["data"]["root"]
|
| 78 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 79 |
+
splits = (("train.npz", int(config["data"]["train_samples"]), 0),
|
| 80 |
+
("test.npz", int(config["data"]["test_samples"]), 1000))
|
| 81 |
+
for offset, (name, count, day_offset) in enumerate(splits):
|
| 82 |
+
path = output / name
|
| 83 |
+
if args.force or not path.exists():
|
| 84 |
+
make_split(path, count, config, int(config["seed"]) + offset, day_offset)
|
| 85 |
+
print(f"generated={path.relative_to(ROOT)} samples={count} shape={count},10,25,25,25")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Infer next-day center-pixel wildfire probabilities."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
import yaml
|
| 9 |
+
from torch.utils.data import DataLoader
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
sys.path.insert(0, str(ROOT))
|
| 14 |
+
from model.firecubenet import FireCubeNet
|
| 15 |
+
from train import WildfireDataset, device_from_config
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def main():
|
| 19 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 20 |
+
device = device_from_config(config)
|
| 21 |
+
checkpoint = torch.load(ROOT / config["paths"]["checkpoint"], map_location=device, weights_only=False)
|
| 22 |
+
if checkpoint["format_version"] != config["data"]["format_version"]:
|
| 23 |
+
raise ValueError("checkpoint and data format versions differ")
|
| 24 |
+
dataset = WildfireDataset(ROOT / config["data"]["root"] / "test.npz", config)
|
| 25 |
+
loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]), shuffle=False)
|
| 26 |
+
model = FireCubeNet(**checkpoint["model_config"]).to(device)
|
| 27 |
+
model.load_state_dict(checkpoint["model_state_dict"])
|
| 28 |
+
model.eval()
|
| 29 |
+
mean = torch.from_numpy(checkpoint["channel_mean"]).to(device).view(1, 1, -1, 1, 1)
|
| 30 |
+
std = torch.from_numpy(checkpoint["channel_std"]).to(device).view(1, 1, -1, 1, 1)
|
| 31 |
+
probabilities = []
|
| 32 |
+
with torch.no_grad():
|
| 33 |
+
for inputs, _ in loader:
|
| 34 |
+
probabilities.append(torch.sigmoid(model((inputs.to(device) - mean) / std)).cpu().numpy())
|
| 35 |
+
probabilities = np.concatenate(probabilities).astype(np.float32)
|
| 36 |
+
if probabilities.shape != dataset.data["labels"].shape or not np.isfinite(probabilities).all():
|
| 37 |
+
raise FloatingPointError("invalid inference probabilities")
|
| 38 |
+
output = ROOT / config["paths"]["inference_dir"] / "predictions.npz"
|
| 39 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
np.savez_compressed(
|
| 41 |
+
output, probabilities=probabilities, labels=dataset.data["labels"],
|
| 42 |
+
timestamps=dataset.data["timestamps_unix_s"], coords=dataset.data["coords"],
|
| 43 |
+
format_version=np.asarray(config["data"]["format_version"]),
|
| 44 |
+
)
|
| 45 |
+
print(f"predictions={output.relative_to(ROOT)} shape={probabilities.shape}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate binary wildfire danger predictions and create task-specific plots."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import matplotlib
|
| 7 |
+
matplotlib.use("Agg")
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import numpy as np
|
| 10 |
+
import yaml
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def roc_curve_and_auc(labels, probabilities):
|
| 17 |
+
order = np.argsort(-probabilities, kind="stable")
|
| 18 |
+
sorted_labels = labels[order]
|
| 19 |
+
positives = max(int(labels.sum()), 1)
|
| 20 |
+
negatives = max(int((1 - labels).sum()), 1)
|
| 21 |
+
true_positive_rate = np.r_[0.0, np.cumsum(sorted_labels) / positives, 1.0]
|
| 22 |
+
false_positive_rate = np.r_[0.0, np.cumsum(1 - sorted_labels) / negatives, 1.0]
|
| 23 |
+
return false_positive_rate, true_positive_rate, float(np.trapz(true_positive_rate, false_positive_rate))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 28 |
+
data = np.load(ROOT / config["paths"]["inference_dir"] / "predictions.npz")
|
| 29 |
+
if str(data["format_version"]) != config["data"]["format_version"]:
|
| 30 |
+
raise ValueError("incompatible prediction format")
|
| 31 |
+
probabilities = data["probabilities"].reshape(-1)
|
| 32 |
+
labels = data["labels"].reshape(-1).astype(np.int64)
|
| 33 |
+
if probabilities.shape != labels.shape or not np.isfinite(probabilities).all() or not np.isin(labels, (0, 1)).all():
|
| 34 |
+
raise ValueError("probabilities/labels are invalid")
|
| 35 |
+
threshold = float(config["evaluation"]["threshold"])
|
| 36 |
+
predictions = (probabilities >= threshold).astype(np.int64)
|
| 37 |
+
tp = int(((predictions == 1) & (labels == 1)).sum())
|
| 38 |
+
fp = int(((predictions == 1) & (labels == 0)).sum())
|
| 39 |
+
tn = int(((predictions == 0) & (labels == 0)).sum())
|
| 40 |
+
fn = int(((predictions == 0) & (labels == 1)).sum())
|
| 41 |
+
precision = tp / (tp + fp) if tp + fp else 0.0
|
| 42 |
+
recall = tp / (tp + fn) if tp + fn else 0.0
|
| 43 |
+
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
|
| 44 |
+
fpr, tpr, auroc = roc_curve_and_auc(labels, probabilities)
|
| 45 |
+
report = {
|
| 46 |
+
"samples": int(len(labels)), "threshold": threshold,
|
| 47 |
+
"precision": precision, "recall": recall, "f1": f1, "auroc": auroc,
|
| 48 |
+
"confusion_matrix": {"true_negative": tn, "false_positive": fp,
|
| 49 |
+
"false_negative": fn, "true_positive": tp},
|
| 50 |
+
"note": "Synthetic engineering validation; not paper test-set performance."
|
| 51 |
+
}
|
| 52 |
+
if not np.isfinite([precision, recall, f1, auroc]).all():
|
| 53 |
+
raise FloatingPointError("evaluation contains non-finite metrics")
|
| 54 |
+
output = ROOT / config["paths"]["evaluation_dir"]
|
| 55 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
(output / "metrics.json").write_text(json.dumps(report, indent=2) + "\n")
|
| 57 |
+
order = np.argsort(data["timestamps"])
|
| 58 |
+
figure, axes = plt.subplots(1, 2, figsize=(11, 4.2))
|
| 59 |
+
axes[0].plot(fpr, tpr, color="firebrick", linewidth=2, label=f"ConvLSTM (AUROC={auroc:.3f})")
|
| 60 |
+
axes[0].plot([0, 1], [0, 1], "k--", linewidth=1)
|
| 61 |
+
axes[0].set(xlabel="False positive rate", ylabel="True positive rate", title="Next-day wildfire ROC")
|
| 62 |
+
axes[0].legend()
|
| 63 |
+
colors = np.where(labels[order] == 1, "firebrick", "steelblue")
|
| 64 |
+
axes[1].scatter(np.arange(len(labels)), probabilities[order], c=colors, s=45)
|
| 65 |
+
axes[1].axhline(threshold, color="black", linestyle="--", linewidth=1, label="threshold=0.5")
|
| 66 |
+
axes[1].set(xlabel="Chronological sample", ylabel="Wildfire danger probability",
|
| 67 |
+
title="Center-pixel next-day danger", ylim=(0, 1))
|
| 68 |
+
axes[1].legend()
|
| 69 |
+
figure.tight_layout()
|
| 70 |
+
figure.savefig(output / "wildfire_danger.png", dpi=150)
|
| 71 |
+
plt.close(figure)
|
| 72 |
+
print(f"evaluation={output.relative_to(ROOT)} f1={f1:.3f} auroc={auroc:.3f}")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train the ConvLSTM classifier with optional distributed data parallelism."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
import yaml
|
| 12 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 13 |
+
from torch.utils.data import DataLoader, Dataset, DistributedSampler
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
sys.path.insert(0, str(ROOT))
|
| 18 |
+
from model.firecubenet import FireCubeNet
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class WildfireDataset(Dataset):
|
| 22 |
+
def __init__(self, path, config):
|
| 23 |
+
self.data = np.load(path)
|
| 24 |
+
expected = config["data"]
|
| 25 |
+
if str(self.data["format_version"]) != expected["format_version"]:
|
| 26 |
+
raise ValueError("incompatible wildfire data format")
|
| 27 |
+
expected_shape = (int(expected["sequence_days"]), int(expected["channels"]),
|
| 28 |
+
int(expected["patch_height"]), int(expected["patch_width"]))
|
| 29 |
+
if self.data["inputs"].ndim != 5 or self.data["inputs"].shape[1:] != expected_shape:
|
| 30 |
+
raise ValueError(f"inputs must have shape [B,{','.join(map(str, expected_shape))}]")
|
| 31 |
+
count = len(self.data["inputs"])
|
| 32 |
+
if self.data["labels"].shape != (count, 1):
|
| 33 |
+
raise ValueError("labels must have shape [B,1]")
|
| 34 |
+
if self.data["coords"].shape != (count, 2) or self.data["timestamps_unix_s"].shape != (count,):
|
| 35 |
+
raise ValueError("coords/timestamps shape mismatch")
|
| 36 |
+
if not np.isfinite(self.data["inputs"]).all() or not np.isfinite(self.data["labels"]).all():
|
| 37 |
+
raise ValueError("inputs and labels must be finite")
|
| 38 |
+
if not np.isin(self.data["labels"], (0, 1)).all():
|
| 39 |
+
raise ValueError("labels must be binary")
|
| 40 |
+
cover_sum = self.data["inputs"][:, :, 15:25].sum(axis=2)
|
| 41 |
+
if not np.allclose(cover_sum, 1.0, atol=1e-5):
|
| 42 |
+
raise ValueError("land-cover fractions must sum to one")
|
| 43 |
+
|
| 44 |
+
def __len__(self):
|
| 45 |
+
return len(self.data["labels"])
|
| 46 |
+
|
| 47 |
+
def __getitem__(self, index):
|
| 48 |
+
return (torch.from_numpy(self.data["inputs"][index]).float(),
|
| 49 |
+
torch.from_numpy(self.data["labels"][index]).float())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def device_from_config(config, local_rank=0):
|
| 53 |
+
requested = config["runtime"]["device"]
|
| 54 |
+
if requested == "auto":
|
| 55 |
+
return torch.device("cuda", local_rank) if torch.cuda.is_available() else torch.device("cpu")
|
| 56 |
+
return torch.device(requested)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def main():
|
| 60 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 61 |
+
seed = int(config["seed"])
|
| 62 |
+
random.seed(seed)
|
| 63 |
+
np.random.seed(seed)
|
| 64 |
+
torch.manual_seed(seed)
|
| 65 |
+
if torch.cuda.is_available():
|
| 66 |
+
torch.cuda.manual_seed_all(seed)
|
| 67 |
+
distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
|
| 68 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 69 |
+
if distributed:
|
| 70 |
+
torch.distributed.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
|
| 71 |
+
rank = torch.distributed.get_rank() if distributed else 0
|
| 72 |
+
device = device_from_config(config, local_rank)
|
| 73 |
+
if device.type == "cuda":
|
| 74 |
+
torch.cuda.set_device(device)
|
| 75 |
+
dataset = WildfireDataset(ROOT / config["data"]["root"] / "train.npz", config)
|
| 76 |
+
sampler = DistributedSampler(dataset, shuffle=True, seed=seed) if distributed else None
|
| 77 |
+
loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]),
|
| 78 |
+
shuffle=sampler is None, sampler=sampler,
|
| 79 |
+
num_workers=int(config["train"]["num_workers"]))
|
| 80 |
+
channel_mean = dataset.data["inputs"].mean(axis=(0, 1, 3, 4)).astype(np.float32)
|
| 81 |
+
channel_std = dataset.data["inputs"].std(axis=(0, 1, 3, 4)).clip(1e-6).astype(np.float32)
|
| 82 |
+
model = FireCubeNet(**config["model"]).to(device)
|
| 83 |
+
wrapped = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None) if distributed else model
|
| 84 |
+
optimizer = torch.optim.Adam(wrapped.parameters(), lr=float(config["train"]["learning_rate"]),
|
| 85 |
+
weight_decay=float(config["train"]["weight_decay"]))
|
| 86 |
+
criterion = torch.nn.BCEWithLogitsLoss()
|
| 87 |
+
mean = torch.from_numpy(channel_mean).to(device).view(1, 1, -1, 1, 1)
|
| 88 |
+
std = torch.from_numpy(channel_std).to(device).view(1, 1, -1, 1, 1)
|
| 89 |
+
history = []
|
| 90 |
+
for epoch in range(int(config["train"]["epochs"])):
|
| 91 |
+
if sampler is not None:
|
| 92 |
+
sampler.set_epoch(epoch)
|
| 93 |
+
total, samples = 0.0, 0
|
| 94 |
+
wrapped.train()
|
| 95 |
+
for inputs, labels in loader:
|
| 96 |
+
inputs, labels = inputs.to(device), labels.to(device)
|
| 97 |
+
logits = wrapped((inputs - mean) / std)
|
| 98 |
+
loss = criterion(logits, labels)
|
| 99 |
+
optimizer.zero_grad(set_to_none=True)
|
| 100 |
+
loss.backward()
|
| 101 |
+
torch.nn.utils.clip_grad_norm_(wrapped.parameters(), float(config["train"]["gradient_clip_norm"]))
|
| 102 |
+
optimizer.step()
|
| 103 |
+
total += float(loss.detach()) * len(inputs)
|
| 104 |
+
samples += len(inputs)
|
| 105 |
+
loss_sum = torch.tensor([total, samples], dtype=torch.float64, device=device)
|
| 106 |
+
if distributed:
|
| 107 |
+
torch.distributed.all_reduce(loss_sum)
|
| 108 |
+
if rank == 0:
|
| 109 |
+
history.append({"epoch": epoch + 1, "bce_with_logits": float(loss_sum[0] / loss_sum[1])})
|
| 110 |
+
if rank == 0:
|
| 111 |
+
checkpoint_path = ROOT / config["paths"]["checkpoint"]
|
| 112 |
+
metrics_path = ROOT / config["paths"]["training_metrics"]
|
| 113 |
+
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
| 114 |
+
metrics_path.parent.mkdir(parents=True, exist_ok=True)
|
| 115 |
+
bare_model = wrapped.module if distributed else wrapped
|
| 116 |
+
torch.save({
|
| 117 |
+
"model_state_dict": bare_model.state_dict(),
|
| 118 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 119 |
+
"model_config": config["model"], "epoch": int(config["train"]["epochs"]),
|
| 120 |
+
"channel_mean": channel_mean, "channel_std": channel_std,
|
| 121 |
+
"format_version": config["data"]["format_version"], "seed": seed,
|
| 122 |
+
}, checkpoint_path)
|
| 123 |
+
metrics_path.write_text(json.dumps({"history": history}, indent=2) + "\n")
|
| 124 |
+
print(f"checkpoint={checkpoint_path.relative_to(ROOT)} final_loss={history[-1]['bce_with_logits']:.6f}")
|
| 125 |
+
if distributed:
|
| 126 |
+
torch.distributed.destroy_process_group()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|