zhangrenchao commited on
Commit
ef2ae28
·
verified ·
1 Parent(s): c067b56

Publish FireCubeNet engineering reproduction

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