Upload folder using huggingface_hub
Browse files- .gitattributes +1 -34
- README.md +148 -0
- conf/config.yaml +70 -0
- config.json +33 -0
- configuration.json +15 -0
- model/improver_aifs.py +138 -0
- scripts/fake_data.py +70 -0
- scripts/inference.py +70 -0
- scripts/result.py +43 -0
- scripts/train.py +119 -0
- weight/.gitkeep +0 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,2 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- OneScience
|
| 7 |
+
- Earth Science
|
| 8 |
+
- Weather Postprocessing
|
| 9 |
+
- Probabilistic Forecasting
|
| 10 |
+
frameworks: PyTorch
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<strong><span style="font-size: 30px;">IMPROVER-AIFS</span></strong>
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
# Model Introduction
|
| 18 |
+
|
| 19 |
+
IMPROVER-AIFS corrects systematic biases and improves the reliability of artificial-intelligence weather forecasts. It converts deterministic AIFS forecasts into spatially processed and statistically calibrated deterministic and probabilistic products and can blend them with conventional numerical weather predictions for near-surface weather postprocessing and operational product generation.
|
| 20 |
+
|
| 21 |
+
Paper: Statistical Postprocessing Yields Accurate Probabilistic Forecasts from Artificial Intelligence Weather Models
|
| 22 |
+
https://doi.org/10.1175/AIES-D-25-0037.1
|
| 23 |
+
|
| 24 |
+
# Model Description
|
| 25 |
+
|
| 26 |
+
The method reproduced by IMPROVER-AIFS was proposed by a research team at the Australian Bureau of Meteorology and uses the IMPROVER postprocessing system led by the Met Office. The paper performs calibration and evaluation with ECMWF AIFS, HRES, and ENS forecasts, MSAS gridded analyses, and Bureau Jive automatic weather-station observations. The model is suitable for deterministic correction, probabilistic calibration, and multi-model blended forecasting of surface temperature, surface dewpoint temperature, and 10 m wind speed.
|
| 27 |
+
|
| 28 |
+
# Use Cases
|
| 29 |
+
|
| 30 |
+
| Use Case | Description |
|
| 31 |
+
| :---: | :--- |
|
| 32 |
+
| Deterministic forecast postprocessing | Apply elevation and historical bias correction to AIFS-like weather forecasts. |
|
| 33 |
+
| Probabilistic forecasting and calibration | Produce threshold probabilities for three variables with spatial smoothing and reliability calibration. |
|
| 34 |
+
| Multi-model blending | Smoothly blend deterministic and probabilistic outputs from AIFS-, HRES-, and ENS-like inputs. |
|
| 35 |
+
| ModelScope/OneCode execution | Validate training, inference, evaluation, visualization, and checkpoint workflows in ModelScope or OneCode. |
|
| 36 |
+
| Multi-GPU training | Launch distributed data-parallel training with `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/IMPROVER-AIFS --local-dir ./IMPROVER-AIFS
|
| 50 |
+
cd IMPROVER-AIFS
|
| 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 |
+
This repository uses a small number of structured synthetic samples to validate the engineering workflow, with AIFS-, HRES-, and ENS-like forecasts and corresponding MSAS-analysis and Bureau Jive station-observation semantics. Each sample retains 30 history days, 241 hourly leads, three variables, `61/47/49` thresholds, all 569 stations, and a `3x3` neighborhood for each station; only the number of valid dates and station samples used for parameter fitting is reduced. The paper does not publish the complete target Albers grid dimensions, so this implementation does not infer them; synthetic data validate postprocessing, training, inference, and evaluation only and do not represent the paper data distribution or performance.
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
python scripts/fake_data.py
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### Training
|
| 88 |
+
|
| 89 |
+
For single-device training, use:
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
python scripts/train.py
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
For multi-GPU training, use:
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
torchrun --nproc_per_node=8 --nnodes=1 --rdzv_id=1000 --rdzv_backend=c10d --max_restarts=0 --master_addr="localhost" --master_port=29500 scripts/train.py
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
Training outputs are saved to:
|
| 102 |
+
|
| 103 |
+
```text
|
| 104 |
+
result/checkpoints/improver_aifs.pt
|
| 105 |
+
result/training/metrics.json
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Trained Weights
|
| 109 |
+
|
| 110 |
+
This repository does not include weights under `weight/`. The paper does not provide a confirmed official checkpoint, and the current engineering checkpoint is not claimed to be compatible with external weights.
|
| 111 |
+
|
| 112 |
+
### Inference
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
python scripts/inference.py
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
Inference loads a checkpoint containing both valid-time folds and generates chunked deterministic and probabilistic blends for two valid dates, 241 leads, and all 569 stations. Numerical results and station metadata are saved to:
|
| 119 |
+
|
| 120 |
+
```text
|
| 121 |
+
result/output/predictions.npz
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### Evaluation and Visualization
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
python scripts/result.py
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
Evaluation results include errors for the raw, postprocessed, and blended forecasts and probabilistic forecast quality, together with target, prediction, and error comparisons at representative lead times. Synthetic-data results validate the engineering workflow only and do not represent paper performance.
|
| 131 |
+
|
| 132 |
+
```text
|
| 133 |
+
result/evaluation/metrics.json
|
| 134 |
+
result/evaluation/multi_lead_temperature.png
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
# Official OneScience Information
|
| 138 |
+
|
| 139 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 140 |
+
| --- | --- | --- |
|
| 141 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 142 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 143 |
+
|
| 144 |
+
# Citation and License
|
| 145 |
+
|
| 146 |
+
This repository is an independent engineering reproduction of the public IMPROVER-AIFS specifications.
|
| 147 |
+
|
| 148 |
+
Use of this repository's code, official model weights, and data remains subject to the licenses and terms of their respective projects.
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
seed: 42
|
| 2 |
+
paper_model:
|
| 3 |
+
name: ECMWF deterministic AIFS
|
| 4 |
+
role: deterministic forecast input
|
| 5 |
+
native_grid: N320_reduced_Gaussian
|
| 6 |
+
native_grid_degrees_approx: 0.25
|
| 7 |
+
preprocessed_grid: regular_latlon_0.25_degree
|
| 8 |
+
native_output_interval_hours: 6
|
| 9 |
+
protocol:
|
| 10 |
+
forecast_date_start: 2024-03-01
|
| 11 |
+
forecast_date_end: 2024-07-23
|
| 12 |
+
initialization_utc: "1200"
|
| 13 |
+
history_days: 30
|
| 14 |
+
lead_hours: 240
|
| 15 |
+
output_interval_hours: 1
|
| 16 |
+
valid_time_folds: 2
|
| 17 |
+
blend_knots: 11
|
| 18 |
+
probability_bins: 7
|
| 19 |
+
sources:
|
| 20 |
+
AIFS: {native_grid: N320_reduced_Gaussian, native_interval_hours: 6, preprocessed_grid: regular_latlon_0.25_degree}
|
| 21 |
+
HRES: {accepted_grid_degrees: 0.1}
|
| 22 |
+
ENS: {accepted_grid_degrees: 0.2}
|
| 23 |
+
analysis: {name: MSAS, resolution_arcminutes: 2.5}
|
| 24 |
+
verification: {name: Bureau_Jive, station_count: 569, interval_hours: 1}
|
| 25 |
+
data:
|
| 26 |
+
root: data
|
| 27 |
+
format_version: aiweather_station_patch_v2
|
| 28 |
+
valid_dates: [2024-03-01, 2024-07-23]
|
| 29 |
+
history_days: 30
|
| 30 |
+
lead_hours: 240
|
| 31 |
+
output_interval_hours: 1
|
| 32 |
+
station_count: 569
|
| 33 |
+
station_chunk_size: 128
|
| 34 |
+
variables: [temperature, dewpoint, wind_speed]
|
| 35 |
+
units: [degC, degC, m_s-1]
|
| 36 |
+
sources: [AIFS, HRES, ENS]
|
| 37 |
+
target_representation: station_center_with_preextracted_target_grid_neighborhood
|
| 38 |
+
neighborhood_shape: [3, 3]
|
| 39 |
+
target_grid_adapter:
|
| 40 |
+
status: required_for_real_data
|
| 41 |
+
projection: Albers
|
| 42 |
+
nx: null
|
| 43 |
+
ny: null
|
| 44 |
+
coordinates: null
|
| 45 |
+
note: Full target Albers dimensions are not public and must be supplied by an authoritative data adapter.
|
| 46 |
+
model:
|
| 47 |
+
variables: [temperature, dewpoint, wind_speed]
|
| 48 |
+
lead_hours: 240
|
| 49 |
+
station_count: 569
|
| 50 |
+
fuzzy_widths: [2.0, 2.0, 1.5]
|
| 51 |
+
thresholds:
|
| 52 |
+
- [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40]
|
| 53 |
+
- [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]
|
| 54 |
+
- [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 15.5, 16, 16.5, 17, 17.5, 18, 18.5, 19, 19.5, 20, 20.5, 21, 21.5, 22, 22.5, 23, 23.5, 24]
|
| 55 |
+
neighborhood_cells: 3
|
| 56 |
+
recursive_coefficient: 0.18
|
| 57 |
+
recursive_iterations: 1
|
| 58 |
+
train:
|
| 59 |
+
epochs: 2
|
| 60 |
+
learning_rate: 0.08
|
| 61 |
+
station_chunk_size: 128
|
| 62 |
+
calibration_stations: 8
|
| 63 |
+
blend_stations: 8
|
| 64 |
+
runtime:
|
| 65 |
+
device: auto
|
| 66 |
+
paths:
|
| 67 |
+
checkpoint: result/checkpoints/improver_aifs.pt
|
| 68 |
+
training_metrics: result/training/metrics.json
|
| 69 |
+
inference: result/output/predictions.npz
|
| 70 |
+
evaluation_dir: result/evaluation
|
config.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"format_version": "aiweather_station_patch_v2",
|
| 3 |
+
"model": "IMPROVER-AIFS",
|
| 4 |
+
"model_type": "statistical-weather-postprocessing",
|
| 5 |
+
"architectures": ["ImproverAIFS"],
|
| 6 |
+
"framework": "PyTorch",
|
| 7 |
+
"domain": "atmosphere",
|
| 8 |
+
"task": "deterministic-to-probabilistic-weather-postprocessing",
|
| 9 |
+
"implementation": {
|
| 10 |
+
"entry_point": "model/improver_aifs.py",
|
| 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 |
+
"paper": {"arxiv": "2504.12672", "doi": "10.1175/AIES-D-25-0037.1", "method": "Bureau of Meteorology IMPROVER applied to ECMWF deterministic AIFS"},
|
| 17 |
+
"scientific_dimensions": {
|
| 18 |
+
"forecast_dates": ["2024-03-01", "2024-07-23"],
|
| 19 |
+
"conceptual_date_range": ["2024-03-01", "2024-07-23"],
|
| 20 |
+
"initialization_utc": "1200",
|
| 21 |
+
"history_days": 30,
|
| 22 |
+
"lead_hours": [0, 240, 1],
|
| 23 |
+
"stations": 569,
|
| 24 |
+
"sources": ["AIFS", "HRES", "ENS"],
|
| 25 |
+
"variables": ["temperature", "dewpoint", "10 m wind speed"],
|
| 26 |
+
"threshold_counts": [61, 47, 49],
|
| 27 |
+
"probability_bins": 7,
|
| 28 |
+
"valid_time_folds": 2,
|
| 29 |
+
"blend_knots": 11,
|
| 30 |
+
"station_neighborhood": [3, 3]
|
| 31 |
+
},
|
| 32 |
+
"target_grid_adapter": {"projection": "Albers", "nx": null, "ny": null, "status": "not_public_do_not_infer"}
|
| 33 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"format_version": "aiweather_station_patch_v2",
|
| 3 |
+
"framework": "PyTorch",
|
| 4 |
+
"task": "weather_forecast_statistical_postprocessing",
|
| 5 |
+
"model": "IMPROVER-AIFS",
|
| 6 |
+
"input_format": "lazy [valid_date, history_day=30, lead=241, variable=3, source=3, station=569, patch_y=3, patch_x=3]",
|
| 7 |
+
"output_format": "[valid_date, lead=241, variable=3, station=569] plus threshold probabilities",
|
| 8 |
+
"protocol": "improver_aifs_trotta_2025_station_validation",
|
| 9 |
+
"fidelity_scope": "paper-confirmed 569-station validation object and pre-extracted 3x3 target-grid neighborhoods; not the unpublished full operational Albers grid",
|
| 10 |
+
"target_grid_adapter": {"projection": "Albers", "nx": null, "ny": null, "required": true},
|
| 11 |
+
"default_config": "conf/config.yaml",
|
| 12 |
+
"training": "scripts/train.py",
|
| 13 |
+
"inference": "scripts/inference.py",
|
| 14 |
+
"evaluation": "scripts/result.py"
|
| 15 |
+
}
|
model/improver_aifs.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Station-space IMPROVER-style post-processing with explicit local grid patches."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
from torch import nn
|
| 8 |
+
from torch.nn import functional as F
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
VARIABLES = ("temperature", "dewpoint", "wind_speed")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ImproverAIFS(nn.Module):
|
| 15 |
+
"""Calibrate three sources at 569 station-centred target-grid neighborhoods."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, config: dict):
|
| 18 |
+
super().__init__()
|
| 19 |
+
self.variables = tuple(config.get("variables", VARIABLES))
|
| 20 |
+
self.lead_hours = int(config.get("lead_hours", 240))
|
| 21 |
+
self.station_count = int(config.get("station_count", 569))
|
| 22 |
+
self.fuzzy_widths = torch.tensor(config["fuzzy_widths"], dtype=torch.float32)
|
| 23 |
+
self.recursive_coefficient = float(config.get("recursive_coefficient", 0.18))
|
| 24 |
+
self.recursive_iterations = int(config.get("recursive_iterations", 1))
|
| 25 |
+
self.register_buffer("knot_hours", torch.linspace(0, self.lead_hours, 11))
|
| 26 |
+
self.blend_logits = nn.Parameter(torch.zeros(len(self.variables), 11, 3))
|
| 27 |
+
self.register_buffer("bias", torch.zeros(3, self.lead_hours + 1, len(self.variables), self.station_count))
|
| 28 |
+
max_thresholds = max(map(len, config["thresholds"]))
|
| 29 |
+
knots = torch.zeros(3, len(self.variables), max_thresholds, 7, 2)
|
| 30 |
+
knots[..., 0] = torch.linspace(0, 1, 7)
|
| 31 |
+
knots[..., 1] = torch.linspace(0, 1, 7)
|
| 32 |
+
self.register_buffer("calibration_knots", knots)
|
| 33 |
+
self.register_buffer("threshold_counts", torch.tensor([len(v) for v in config["thresholds"]]))
|
| 34 |
+
|
| 35 |
+
@torch.no_grad()
|
| 36 |
+
def fit_bias_chunk(self, forecasts: torch.Tensor, analyses: torch.Tensor, elevation_delta: torch.Tensor, station_slice: slice) -> None:
|
| 37 |
+
"""Fit each source from all 30 preceding daily 1200 UTC histories."""
|
| 38 |
+
adjusted = forecasts.clone() # history, lead, variable, source, station
|
| 39 |
+
adjusted[:, :, 0] += -0.0098 * elevation_delta.view(1, 1, 1, -1)
|
| 40 |
+
self.bias[..., station_slice] = (adjusted - analyses.unsqueeze(3)).mean(0).permute(2, 0, 1, 3)
|
| 41 |
+
|
| 42 |
+
def correct_expected(self, forecasts: torch.Tensor, elevation_delta: torch.Tensor, source: int, station_slice: slice) -> torch.Tensor:
|
| 43 |
+
"""Correct station-centre forecasts shaped [date/history, lead, variable, station]."""
|
| 44 |
+
corrected = forecasts.clone()
|
| 45 |
+
corrected[:, :, 0] += -0.0098 * elevation_delta.view(1, 1, -1)
|
| 46 |
+
return corrected - self.bias[source, :, :, station_slice].unsqueeze(0)
|
| 47 |
+
|
| 48 |
+
def fuzzy_threshold(self, expected: torch.Tensor, thresholds: list[torch.Tensor]) -> list[torch.Tensor]:
|
| 49 |
+
outputs = []
|
| 50 |
+
widths = self.fuzzy_widths.to(expected.device)
|
| 51 |
+
for variable, values in enumerate(thresholds):
|
| 52 |
+
forecast = expected[:, :, variable].unsqueeze(2)
|
| 53 |
+
threshold = values.to(expected.device).view(1, 1, -1, 1)
|
| 54 |
+
outputs.append(((forecast - threshold + widths[variable]) / (2 * widths[variable])).clamp(0, 1))
|
| 55 |
+
return outputs
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
def neighborhood(patch_probabilities: torch.Tensor) -> torch.Tensor:
|
| 59 |
+
"""Reduce pre-extracted real-semantic 3x3 target-grid patches to stations."""
|
| 60 |
+
if patch_probabilities.shape[-2:] != (3, 3):
|
| 61 |
+
raise ValueError("station neighborhood must be an authoritative 3x3 target-grid patch")
|
| 62 |
+
return patch_probabilities.mean(dim=(-2, -1))
|
| 63 |
+
|
| 64 |
+
def recursive_filter(self, patch_probabilities: torch.Tensor) -> torch.Tensor:
|
| 65 |
+
"""Apply separable filtering only within each station's extracted 3x3 patch."""
|
| 66 |
+
output = patch_probabilities.clone()
|
| 67 |
+
coefficient = self.recursive_coefficient
|
| 68 |
+
for _ in range(self.recursive_iterations):
|
| 69 |
+
for axis in (-2, -1):
|
| 70 |
+
for index in range(1, 3):
|
| 71 |
+
current, previous = [slice(None)] * output.ndim, [slice(None)] * output.ndim
|
| 72 |
+
current[axis], previous[axis] = index, index - 1
|
| 73 |
+
output[tuple(current)] = (1 - coefficient) * output[tuple(current)] + coefficient * output[tuple(previous)]
|
| 74 |
+
for index in range(1, -1, -1):
|
| 75 |
+
current, following = [slice(None)] * output.ndim, [slice(None)] * output.ndim
|
| 76 |
+
current[axis], following[axis] = index, index + 1
|
| 77 |
+
output[tuple(current)] = (1 - coefficient) * output[tuple(current)] + coefficient * output[tuple(following)]
|
| 78 |
+
return output
|
| 79 |
+
|
| 80 |
+
@torch.no_grad()
|
| 81 |
+
def fit_reliability(self, probabilities: list[torch.Tensor], analyses: torch.Tensor, thresholds: list[torch.Tensor], source: int) -> None:
|
| 82 |
+
"""Fit seven-bin mappings on configured station samples with full time axes."""
|
| 83 |
+
defaults = torch.linspace(0, 1, 7, device=analyses.device)
|
| 84 |
+
for variable, probability in enumerate(probabilities):
|
| 85 |
+
truth = analyses[:, :, variable].unsqueeze(2) > thresholds[variable].view(1, 1, -1, 1)
|
| 86 |
+
count = probability.shape[2]
|
| 87 |
+
p = probability.permute(2, 0, 1, 3).reshape(count, -1)
|
| 88 |
+
y = truth.permute(2, 0, 1, 3).reshape(count, -1).float()
|
| 89 |
+
ids = torch.bucketize(p, torch.linspace(1 / 7, 6 / 7, 6, device=p.device))
|
| 90 |
+
xs, ys = [], []
|
| 91 |
+
for bin_index in range(7):
|
| 92 |
+
mask = ids == bin_index
|
| 93 |
+
samples = mask.sum(1)
|
| 94 |
+
denominator = samples.clamp_min(1)
|
| 95 |
+
xs.append(torch.where(samples > 0, (p * mask).sum(1) / denominator, defaults[bin_index]))
|
| 96 |
+
ys.append(torch.where(samples > 0, (y * mask).sum(1) / denominator, defaults[bin_index]))
|
| 97 |
+
xs = torch.stack(xs, dim=1).cummax(1).values
|
| 98 |
+
ys = torch.stack(ys, dim=1).cummax(1).values.clamp(0, 1)
|
| 99 |
+
self.calibration_knots[source, variable, :count, :, 0] = xs
|
| 100 |
+
self.calibration_knots[source, variable, :count, :, 1] = ys
|
| 101 |
+
|
| 102 |
+
def calibrate(self, probabilities: list[torch.Tensor], source: int) -> list[torch.Tensor]:
|
| 103 |
+
outputs = []
|
| 104 |
+
for variable, probability in enumerate(probabilities):
|
| 105 |
+
count = probability.shape[2]
|
| 106 |
+
knots = self.calibration_knots[source, variable, :count]
|
| 107 |
+
x, y = knots[..., 0].contiguous(), knots[..., 1].contiguous()
|
| 108 |
+
values = probability.permute(2, 0, 1, 3).reshape(count, -1).contiguous()
|
| 109 |
+
ids = torch.searchsorted(x, values).clamp(1, 6)
|
| 110 |
+
x0, x1 = x.gather(1, ids - 1), x.gather(1, ids)
|
| 111 |
+
y0, y1 = y.gather(1, ids - 1), y.gather(1, ids)
|
| 112 |
+
calibrated = y0 + (values - x0) * (y1 - y0) / (x1 - x0).clamp_min(1e-6)
|
| 113 |
+
calibrated = calibrated.reshape(count, probability.shape[0], probability.shape[1], probability.shape[3]).permute(1, 2, 0, 3)
|
| 114 |
+
outputs.append(calibrated.clamp(0, 1).cummin(dim=2).values)
|
| 115 |
+
return outputs
|
| 116 |
+
|
| 117 |
+
def blend_weights(self) -> torch.Tensor:
|
| 118 |
+
"""Interpolate the paper's 11 valid-time knots to 241 hourly leads."""
|
| 119 |
+
return F.interpolate(self.blend_logits.softmax(-1).permute(0, 2, 1), size=241, mode="linear", align_corners=True).permute(0, 2, 1)
|
| 120 |
+
|
| 121 |
+
def blend_expected(self, model_expected: torch.Tensor) -> torch.Tensor:
|
| 122 |
+
weights = self.blend_weights().permute(1, 0, 2).view(1, 241, 3, 3, 1)
|
| 123 |
+
return (model_expected * weights).sum(3)
|
| 124 |
+
|
| 125 |
+
def blend_probabilities(self, source_probabilities: list[list[torch.Tensor]]) -> list[torch.Tensor]:
|
| 126 |
+
weights = self.blend_weights()
|
| 127 |
+
return [sum(source_probabilities[s][v] * weights[v, :, s].view(1, 241, 1, 1) for s in range(3)).clamp(0, 1)
|
| 128 |
+
for v in range(3)]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def crps_from_thresholds(probabilities: np.ndarray, thresholds: np.ndarray, truth: np.ndarray) -> float:
|
| 132 |
+
total = np.zeros_like(truth, dtype=np.float32)
|
| 133 |
+
previous = (1.0 - probabilities[:, :, 0] - (thresholds[0] >= truth)) ** 2
|
| 134 |
+
for index in range(1, len(thresholds)):
|
| 135 |
+
current = (1.0 - probabilities[:, :, index] - (thresholds[index] >= truth)) ** 2
|
| 136 |
+
total += 0.5 * (previous + current) * (thresholds[index] - thresholds[index - 1])
|
| 137 |
+
previous = current
|
| 138 |
+
return float(total.mean())
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Write a lazy 569-station protocol manifest; fields are generated per chunk."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
import yaml
|
| 6 |
+
|
| 7 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def station_metadata(count: int):
|
| 11 |
+
index = np.arange(count, dtype=np.float32)
|
| 12 |
+
latitude = -43.5 + 33.0 * ((index * 0.61803398875) % 1)
|
| 13 |
+
longitude = 113.0 + 40.0 * ((index * 0.41421356237) % 1)
|
| 14 |
+
elevation = 20 + 1450 * ((index * 0.2718281828) % 1)
|
| 15 |
+
return latitude, longitude, elevation.astype(np.float32)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def make_split(path: Path, config: dict):
|
| 19 |
+
data = config["data"]
|
| 20 |
+
dates = np.asarray(data["valid_dates"], dtype="U10")
|
| 21 |
+
lat, lon, elevation = station_metadata(data["station_count"])
|
| 22 |
+
history_dates = np.empty((len(dates), data["history_days"]), dtype="U10")
|
| 23 |
+
for i, date in enumerate(dates.astype("datetime64[D]")):
|
| 24 |
+
history_dates[i] = (date - np.arange(data["history_days"], 0, -1)).astype("U10")
|
| 25 |
+
np.savez(path, format_version=np.asarray(data["format_version"]), valid_dates=dates,
|
| 26 |
+
history_dates=history_dates, initialization_utc=np.asarray("1200"), lead_hours=np.arange(241),
|
| 27 |
+
station_id=np.asarray([f"JIVE-{i:04d}" for i in range(data["station_count"])]),
|
| 28 |
+
station_latitude=lat, station_longitude=lon, station_elevation_m=elevation,
|
| 29 |
+
variables=np.asarray(data["variables"]), units=np.asarray(data["units"]), sources=np.asarray(data["sources"]),
|
| 30 |
+
neighborhood_shape=np.asarray([3, 3]), target_grid_projection=np.asarray("Albers"),
|
| 31 |
+
target_grid_nx=np.asarray(None), target_grid_ny=np.asarray(None),
|
| 32 |
+
representation=np.asarray(data["target_representation"]))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def generate_chunk(meta, date_index: int, station_selector, history: bool, seed: int, include_patch: bool = True):
|
| 36 |
+
"""Emulate lazy reads after authoritative station patches have been extracted."""
|
| 37 |
+
station = np.arange(len(meta["station_id"]))[station_selector]
|
| 38 |
+
days = 30 if history else 1
|
| 39 |
+
lead = np.arange(241, dtype=np.float32)[None, :, None, None]
|
| 40 |
+
day = np.arange(days, dtype=np.float32)[:, None, None, None]
|
| 41 |
+
lat = meta["station_latitude"][station][None, None, None, :]
|
| 42 |
+
lon = meta["station_longitude"][station][None, None, None, :]
|
| 43 |
+
phase = date_index * 0.7 + day * 0.11
|
| 44 |
+
temp = 20 - 0.35 * (lat + 25) + 4 * np.sin(2 * np.pi * (lead + 12) / 24 + phase)
|
| 45 |
+
dew = temp - 5 - 1.5 * np.cos(np.deg2rad(lon) + lead / 48)
|
| 46 |
+
wind = 5 + 1.2 * np.abs(np.sin(np.deg2rad(lon) + lead / 18 + phase))
|
| 47 |
+
truth = np.concatenate((temp, dew, wind), axis=2).astype(np.float32)
|
| 48 |
+
source = np.arange(3, dtype=np.float32)[None, None, None, :, None]
|
| 49 |
+
forecast = truth[:, :, :, None] + (source - 0.6) * np.asarray([1.1, 0.8, 0.5], np.float32)[None, None, :, None, None]
|
| 50 |
+
forecast += (lead[..., None] / 240) * np.asarray([0.8, -0.5, 0.6], np.float32)[None, None, :, None, None]
|
| 51 |
+
rng = np.random.default_rng(seed + date_index * 1000 + int(station[0]))
|
| 52 |
+
forecast += rng.normal(0, 0.08, forecast.shape).astype(np.float32)
|
| 53 |
+
offsets = np.asarray([[-0.18, -0.10, -0.04], [-0.08, 0.0, 0.09], [0.03, 0.12, 0.20]], np.float32)
|
| 54 |
+
elevation_delta = meta["station_elevation_m"][station] - np.mean(meta["station_elevation_m"])
|
| 55 |
+
if not include_patch:
|
| 56 |
+
return forecast.astype(np.float32), truth, elevation_delta.astype(np.float32)
|
| 57 |
+
patches = forecast[..., None, None] + offsets
|
| 58 |
+
return patches.astype(np.float32), truth, elevation_delta.astype(np.float32)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def main():
|
| 62 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 63 |
+
output = ROOT / config["data"]["root"]
|
| 64 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 65 |
+
make_split(output / "protocol.npz", config)
|
| 66 |
+
print("generated=data/protocol.npz dates=2 history_days=30 leads=241 stations=569 patch=3x3 nx=unknown ny=unknown")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run fold-held-out inference for every valid date, lead, variable, and station."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
import yaml
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
sys.path.insert(0, str(ROOT))
|
| 11 |
+
from model.improver_aifs import ImproverAIFS
|
| 12 |
+
from fake_data import generate_chunk
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 17 |
+
meta = np.load(ROOT / config["data"]["root"] / "protocol.npz", allow_pickle=True)
|
| 18 |
+
checkpoint = torch.load(ROOT / config["paths"]["checkpoint"], map_location="cpu", weights_only=False)
|
| 19 |
+
fold_states = checkpoint.get("model", {}).get("fold_states", [])
|
| 20 |
+
if checkpoint["format_version"] != config["data"]["format_version"] or len(fold_states) != 2:
|
| 21 |
+
raise ValueError("checkpoint must contain two valid-time folds")
|
| 22 |
+
dates, leads, stations = 2, 241, config["data"]["station_count"]
|
| 23 |
+
raw = np.empty((dates, leads, 3, stations), np.float32)
|
| 24 |
+
truth = np.empty_like(raw); expected = np.empty_like(raw)
|
| 25 |
+
probabilities = [np.empty((dates, leads, len(v), stations), np.float32) for v in config["model"]["thresholds"]]
|
| 26 |
+
thresholds = [torch.tensor(v, dtype=torch.float32) for v in config["model"]["thresholds"]]
|
| 27 |
+
chunk = config["data"]["station_chunk_size"]
|
| 28 |
+
for fold in range(2):
|
| 29 |
+
model = ImproverAIFS(checkpoint["model_config"]); model.load_state_dict(fold_states[fold]); model.eval()
|
| 30 |
+
for start in range(0, stations, chunk):
|
| 31 |
+
section = slice(start, min(start + chunk, stations))
|
| 32 |
+
patches, analyses, elevation = generate_chunk(meta, fold, section, False, int(config["seed"]) + 100)
|
| 33 |
+
source_expected, source_probabilities = [], []
|
| 34 |
+
with torch.no_grad():
|
| 35 |
+
for source in range(3):
|
| 36 |
+
patch = torch.from_numpy(patches[:, :, :, source])
|
| 37 |
+
patch[:, :, 0] += (-0.0098 * torch.from_numpy(elevation)).view(1, 1, -1, 1, 1)
|
| 38 |
+
patch -= model.bias[source, :, :, section].unsqueeze(0).unsqueeze(-1).unsqueeze(-1)
|
| 39 |
+
centre = patch[..., 1, 1]
|
| 40 |
+
source_expected.append(centre)
|
| 41 |
+
raw_probabilities = []
|
| 42 |
+
for variable, values in enumerate(thresholds):
|
| 43 |
+
width = model.fuzzy_widths[variable]
|
| 44 |
+
p = ((patch[:, :, variable].unsqueeze(2) - values.view(1, 1, -1, 1, 1, 1) + width) / (2 * width)).clamp(0, 1)
|
| 45 |
+
raw_probabilities.append(model.neighborhood(model.recursive_filter(p)))
|
| 46 |
+
source_probabilities.append(model.calibrate(raw_probabilities, source))
|
| 47 |
+
blend = model.blend_expected(torch.stack(source_expected, dim=3))
|
| 48 |
+
blend_probability = model.blend_probabilities(source_probabilities)
|
| 49 |
+
raw[fold, ..., section] = patches[0, :, :, 0, :, 1, 1]
|
| 50 |
+
truth[fold, ..., section] = analyses[0]
|
| 51 |
+
expected[fold, ..., section] = blend[0].numpy()
|
| 52 |
+
for variable in range(3): probabilities[variable][fold, ..., section] = blend_probability[variable][0].numpy()
|
| 53 |
+
arrays = [raw, truth, expected, *probabilities]
|
| 54 |
+
if not all(np.isfinite(array).all() for array in arrays):
|
| 55 |
+
raise ValueError("inference produced non-finite values")
|
| 56 |
+
if not all(((array >= 0) & (array <= 1)).all() for array in probabilities):
|
| 57 |
+
raise ValueError("probabilities must be within [0, 1]")
|
| 58 |
+
output = ROOT / config["paths"]["inference"]
|
| 59 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
payload = {"raw_aifs": raw, "analyses": truth, "blend_expected": expected, "valid_dates": meta["valid_dates"], "lead_hours": meta["lead_hours"],
|
| 61 |
+
"station_id": meta["station_id"], "station_latitude": meta["station_latitude"], "station_longitude": meta["station_longitude"],
|
| 62 |
+
"variables": meta["variables"], "units": meta["units"], "fold_id": np.arange(2)}
|
| 63 |
+
for variable, name in enumerate(meta["variables"]):
|
| 64 |
+
payload[f"thresholds_{name}"] = thresholds[variable].numpy(); payload[f"probability_blend_{name}"] = probabilities[variable]
|
| 65 |
+
np.savez_compressed(output, **payload)
|
| 66 |
+
print(f"saved={output.relative_to(ROOT)} expected_shape={expected.shape} folds=2 stations=569 thresholds=61/47/49")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate all 569 Jive stations hourly and plot station summaries."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import matplotlib.pyplot as plt
|
| 7 |
+
import numpy as np
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
sys.path.insert(0, str(ROOT))
|
| 12 |
+
from model.improver_aifs import crps_from_thresholds
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 17 |
+
data = np.load(ROOT / config["paths"]["inference"])
|
| 18 |
+
raw, blend, truth = data["raw_aifs"], data["blend_expected"], data["analyses"]
|
| 19 |
+
if raw.shape != (2, 241, 3, 569):
|
| 20 |
+
raise ValueError(f"evaluation requires [2,241,3,569], received {raw.shape}")
|
| 21 |
+
metrics = {"note": "engineering validation, not paper performance", "verification_stations": 569, "hourly_leads": 241, "valid_time_folds": 2, "by_variable": {}}
|
| 22 |
+
for variable, name in enumerate(data["variables"]):
|
| 23 |
+
probability, thresholds = data[f"probability_blend_{name}"], data[f"thresholds_{name}"]
|
| 24 |
+
metrics["by_variable"][str(name)] = {
|
| 25 |
+
"raw_mse": float(np.mean((raw[:, :, variable] - truth[:, :, variable]) ** 2)),
|
| 26 |
+
"blend_mse": float(np.mean((blend[:, :, variable] - truth[:, :, variable]) ** 2)),
|
| 27 |
+
"blend_bias": float(np.mean(blend[:, :, variable] - truth[:, :, variable])),
|
| 28 |
+
"blend_crps": crps_from_thresholds(probability, thresholds, truth[:, :, variable]),
|
| 29 |
+
}
|
| 30 |
+
output = ROOT / config["paths"]["evaluation_dir"]
|
| 31 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
(output / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n")
|
| 33 |
+
fig, axes = plt.subplots(2, 2, figsize=(11, 7), constrained_layout=True)
|
| 34 |
+
for axis, lead in zip(axes.flat, [0, 24, 120, 240]):
|
| 35 |
+
axis.scatter(data["station_longitude"], data["station_latitude"], c=np.abs(blend[0, lead, 0] - truth[0, lead, 0]), s=8, cmap="viridis")
|
| 36 |
+
axis.set_title(f"569-station temperature absolute error | {lead} h")
|
| 37 |
+
axis.set_xlabel("longitude"); axis.set_ylabel("latitude")
|
| 38 |
+
fig.savefig(output / "multi_lead_temperature.png", dpi=150); plt.close(fig)
|
| 39 |
+
print("saved=result/evaluation stations=569 hourly_leads=241 folds=2 mse_crps_bias=ok")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
if __name__ == "__main__":
|
| 43 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fit two valid-time folds without caching full-station threshold probabilities."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
import yaml
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
sys.path.insert(0, str(ROOT))
|
| 13 |
+
from model.improver_aifs import ImproverAIFS
|
| 14 |
+
from fake_data import generate_chunk
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def fit_fold(config, meta, fold, rank):
|
| 18 |
+
model = ImproverAIFS(config["model"])
|
| 19 |
+
stations = int(config["data"]["station_count"])
|
| 20 |
+
chunk_size = int(config["train"]["station_chunk_size"])
|
| 21 |
+
|
| 22 |
+
# Bias uses every station but only station-centre values, so no full-grid cache exists.
|
| 23 |
+
for start in range(0, stations, chunk_size):
|
| 24 |
+
section = slice(start, min(start + chunk_size, stations))
|
| 25 |
+
centres, analyses, elevation = generate_chunk(
|
| 26 |
+
meta, fold, section, True, int(config["seed"]), include_patch=False
|
| 27 |
+
)
|
| 28 |
+
model.fit_bias_chunk(
|
| 29 |
+
torch.from_numpy(centres), torch.from_numpy(analyses),
|
| 30 |
+
torch.from_numpy(elevation), section,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
sample_count = max(int(config["train"]["calibration_stations"]), int(config["train"]["blend_stations"]))
|
| 34 |
+
sample_stations = np.linspace(0, stations - 1, sample_count, dtype=np.int64)
|
| 35 |
+
patches, analyses, elevation = generate_chunk(
|
| 36 |
+
meta, fold, sample_stations, True, int(config["seed"]), include_patch=True
|
| 37 |
+
)
|
| 38 |
+
truth = torch.from_numpy(analyses)
|
| 39 |
+
thresholds = [torch.tensor(values, dtype=torch.float32) for values in config["model"]["thresholds"]]
|
| 40 |
+
source_expected = []
|
| 41 |
+
for source in range(3):
|
| 42 |
+
patch = torch.from_numpy(patches[:, :, :, source])
|
| 43 |
+
patch[:, :, 0] += (-0.0098 * torch.from_numpy(elevation)).view(1, 1, -1, 1, 1)
|
| 44 |
+
patch -= model.bias[source, :, :, sample_stations].unsqueeze(0).unsqueeze(-1).unsqueeze(-1)
|
| 45 |
+
source_expected.append(patch[..., 1, 1])
|
| 46 |
+
probabilities = []
|
| 47 |
+
for variable, values in enumerate(thresholds):
|
| 48 |
+
width = model.fuzzy_widths[variable]
|
| 49 |
+
probability_patch = ((patch[:, :, variable].unsqueeze(2) - values.view(1, 1, -1, 1, 1, 1) + width) / (2 * width)).clamp(0, 1)
|
| 50 |
+
probabilities.append(model.neighborhood(model.recursive_filter(probability_patch)))
|
| 51 |
+
model.fit_reliability(probabilities, truth, thresholds, source)
|
| 52 |
+
del probabilities
|
| 53 |
+
|
| 54 |
+
blend_count = int(config["train"]["blend_stations"])
|
| 55 |
+
blend_input = torch.stack(source_expected, dim=3)[..., :blend_count]
|
| 56 |
+
blend_truth = truth[..., :blend_count]
|
| 57 |
+
optimizer = torch.optim.Adam([model.blend_logits], lr=float(config["train"]["learning_rate"]))
|
| 58 |
+
history = []
|
| 59 |
+
for epoch in range(int(config["train"]["epochs"])):
|
| 60 |
+
loss = torch.mean((model.blend_expected(blend_input) - blend_truth) ** 2)
|
| 61 |
+
if not torch.isfinite(loss):
|
| 62 |
+
raise ValueError("non-finite blend loss")
|
| 63 |
+
optimizer.zero_grad(set_to_none=True)
|
| 64 |
+
loss.backward()
|
| 65 |
+
optimizer.step()
|
| 66 |
+
history.append({"epoch": epoch + 1, "blend_mse_loss": float(loss.detach())})
|
| 67 |
+
record = {
|
| 68 |
+
"fold": fold, "held_out_valid_date": str(meta["valid_dates"][fold]),
|
| 69 |
+
"history_days": 30, "bias_stations": stations,
|
| 70 |
+
"calibration_stations": int(config["train"]["calibration_stations"]),
|
| 71 |
+
"blend_stations": blend_count, "rank": rank, "history": history,
|
| 72 |
+
}
|
| 73 |
+
print(f"rank={rank} fold={fold} history_days=30 bias_stations=569 calibration_stations={sample_count} loss={history[-1]['blend_mse_loss']:.6f}")
|
| 74 |
+
return model.state_dict(), record
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main():
|
| 78 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 79 |
+
meta = np.load(ROOT / config["data"]["root"] / "protocol.npz", allow_pickle=True)
|
| 80 |
+
if str(meta["format_version"]) != config["data"]["format_version"] or meta["history_dates"].shape != (2, 30):
|
| 81 |
+
raise ValueError("protocol requires two valid dates and complete 30-day histories")
|
| 82 |
+
distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
|
| 83 |
+
if distributed:
|
| 84 |
+
torch.distributed.init_process_group("gloo")
|
| 85 |
+
rank = torch.distributed.get_rank() if distributed else 0
|
| 86 |
+
world_size = torch.distributed.get_world_size() if distributed else 1
|
| 87 |
+
local = []
|
| 88 |
+
for fold in range(2):
|
| 89 |
+
if fold % world_size == rank:
|
| 90 |
+
state, record = fit_fold(config, meta, fold, rank)
|
| 91 |
+
local.append((fold, state, record))
|
| 92 |
+
if distributed:
|
| 93 |
+
gathered = [None] * world_size
|
| 94 |
+
torch.distributed.all_gather_object(gathered, local)
|
| 95 |
+
combined = [item for rank_items in gathered for item in rank_items]
|
| 96 |
+
else:
|
| 97 |
+
combined = local
|
| 98 |
+
if rank == 0:
|
| 99 |
+
combined.sort(key=lambda item: item[0])
|
| 100 |
+
if [item[0] for item in combined] != [0, 1]:
|
| 101 |
+
raise RuntimeError("DDP ranks did not produce both valid-time folds")
|
| 102 |
+
fold_states = [item[1] for item in combined]
|
| 103 |
+
records = [item[2] for item in combined]
|
| 104 |
+
checkpoint = ROOT / config["paths"]["checkpoint"]
|
| 105 |
+
metrics = ROOT / config["paths"]["training_metrics"]
|
| 106 |
+
checkpoint.parent.mkdir(parents=True, exist_ok=True)
|
| 107 |
+
metrics.parent.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
torch.save({
|
| 109 |
+
"model": {"fold_states": fold_states, "fold_semantics": "valid-time 2-fold"},
|
| 110 |
+
"model_config": config["model"],
|
| 111 |
+
"format_version": config["data"]["format_version"],
|
| 112 |
+
}, checkpoint)
|
| 113 |
+
metrics.write_text(json.dumps({"folds": records, "distributed_world_size": world_size}, indent=2) + "\n")
|
| 114 |
+
if distributed:
|
| 115 |
+
torch.distributed.destroy_process_group()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|