zhangrenchao commited on
Commit
0fa8141
·
verified ·
1 Parent(s): 7e427ef

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.pt filter=lfs diff=lfs merge=lfs -text
2
+ *.npz filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ tags:
6
+ - OneScience
7
+ - Earth Science
8
+ - Data Assimilation
9
+ - Mass Conservation
10
+ frameworks: PyTorch
11
+ ---
12
+
13
+ <p align="center">
14
+ <strong><span style="font-size: 30px;">MassConservingCNN</span></strong>
15
+ </p>
16
+
17
+ # Model Introduction
18
+
19
+ MassConservingCNN corrects mass-conservation violations introduced by localization in ensemble Kalman filter data assimilation. Given an unconstrained analysis and radar-observation locations, it generates an analysis field with nonnegative rain and improved mass conservation for research on physically constrained data assimilation and analysis postprocessing.
20
+
21
+ Paper: Training a convolutional neural network to conserve mass in data assimilation
22
+ https://doi.org/10.5194/npg-28-111-2021
23
+
24
+ # Model Description
25
+
26
+ MassConservingCNN was proposed by researchers from the Meteorological Institute of Ludwig-Maximilians-Universität München and ClimateAi. The paper trains and validates the model with EnKF unconstrained analyses, QPEns constrained analyses, and radar-observation locations generated by twin experiments with a one-dimensional modified shallow-water model. The model is suitable for mass-conserving data-assimilation correction, rain non-negativity constraints, and physically consistent analysis generation.
27
+
28
+ # Use Cases
29
+
30
+ | Use Case | Description |
31
+ | :---: | :--- |
32
+ | Data-assimilation correction | Predict a QPEns-style analysis from `X^a` and a radar-location indicator. |
33
+ | Mass-aware training | Train with the paper Equation 6 error and Equation 7 mass penalty. |
34
+ | ModelScope/OneCode execution | Validate training, inference, evaluation, visualization, and checkpoint workflows in ModelScope or OneCode. |
35
+ | Multi-GPU training | Launch 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/MassConservingCNN --local-dir ./MassConservingCNN
49
+ cd MassConservingCNN
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 in advance. DTK 25.04.2 or later, or the OneScience-recommended version matching the current 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 generator creates 24 training samples and 12 validation samples while preserving `[B,4,250]` inputs and `[B,3,250]` targets. The data combine periodic waves, smooth convective cells, nonnegative rain related to velocity convergence, rainy-region radar masks, and smooth EnKF-style errors. They validate the engineering workflow only and are not equivalent to the paper's 48,000-sample QPEns datasets.
81
+
82
+ ```bash
83
+ python scripts/fake_data.py --force
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 checks the data version, shapes, dtypes, finite values, binary radar masks, and rain non-negativity. The checkpoint stores model parameters, optimizer state, model configuration, normalization statistics, variable order, data version, `eta`, epoch, and seed. Outputs are written to:
101
+
102
+ ```text
103
+ result/checkpoints/massconservingcnn.pt
104
+ result/training/metrics.json
105
+ ```
106
+
107
+ ### Trained Weights
108
+
109
+ 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.
110
+
111
+ ### Inference
112
+
113
+ ```bash
114
+ python scripts/inference.py
115
+ ```
116
+
117
+ Inference restores the model parameters produced by training and generates mass-corrected analysis fields from unconstrained analyses and radar-location indicators. Results include input analyses, target analyses, model predictions, radar-observation locations, and the corresponding physical and normalization information, and are saved to:
118
+
119
+ ```text
120
+ result/output/predictions.npz
121
+ ```
122
+
123
+ ### Evaluation and Visualization
124
+
125
+ ```bash
126
+ python scripts/result.py
127
+ ```
128
+
129
+ Evaluation results include core prediction-error, mass-conservation-error, and relative-improvement results, together with an input, target, and prediction comparison figure. Structured metrics and the auxiliary figure are saved to the paths below; synthetic-data results validate the engineering workflow only and do not represent paper performance.
130
+
131
+ ```text
132
+ result/evaluation/metrics.json
133
+ result/evaluation/input_target_prediction.png
134
+ ```
135
+
136
+ # Official OneScience Information
137
+
138
+ | Platform | OneScience Main Repository | Skills Repository |
139
+ | --- | --- | --- |
140
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
141
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
142
+
143
+ # Citation and License
144
+
145
+ This repository is an independent engineering reproduction of the public MassConservingCNN specifications.
146
+
147
+ 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,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ data:
3
+ root: data
4
+ format_version: mass_conserving_msw_v1
5
+ grid_points: 250
6
+ domain_km: 125
7
+ variables: [u, h, r]
8
+ input_channels: 4
9
+ train_samples: 24
10
+ validation_samples: 12
11
+ model:
12
+ input_channels: 4
13
+ hidden_channels: 32
14
+ hidden_layers: 4
15
+ kernel_size: 3
16
+ train:
17
+ epochs: 2
18
+ batch_size: 8
19
+ learning_rate: 0.001
20
+ optimizer: Adam
21
+ eta: 2.0
22
+ num_workers: 0
23
+ runtime:
24
+ device: auto
25
+ paths:
26
+ checkpoint: result/checkpoints/massconservingcnn.pt
27
+ training_metrics: result/training/metrics.json
28
+ inference: result/output/predictions.npz
29
+ evaluation_dir: result/evaluation
30
+ paper_model:
31
+ input_channels: 4
32
+ output_channels: 3
33
+ grid_points: 250
34
+ hidden_channels: 32
35
+ hidden_layers: 4
36
+ kernel_size: 3
37
+ hidden_activation: SELU
38
+ rain_activation: ReLU
39
+ padding: circular
40
+ influence_radius: 5
41
+ train_samples: 48000
42
+ validation_samples: 48000
43
+ batch_size: 96
44
+ epochs: 100
45
+ optimizer: Adam
46
+ eta: 2.0
47
+ experiment: dT10_eta2
config.json ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "MassConservingCNN",
3
+ "model_type": "massconservingcnn",
4
+ "architectures": ["MassConservingCNN"],
5
+ "framework": "PyTorch",
6
+ "domain": "earth-science",
7
+ "task": "mass-aware-data-assimilation-analysis",
8
+ "implementation": {
9
+ "entry_point": "model/massconservingcnn.py",
10
+ "train_script": "scripts/train.py",
11
+ "inference_script": "scripts/inference.py",
12
+ "evaluation_script": "scripts/result.py",
13
+ "synthetic_data_script": "scripts/fake_data.py"
14
+ },
15
+ "architecture": {
16
+ "input_shape": ["B", 4, 250],
17
+ "output_shape": ["B", 3, 250],
18
+ "variable_order": ["u", "h", "r"],
19
+ "hidden_layers": 4,
20
+ "filters_per_layer": 32,
21
+ "kernel_size": 3,
22
+ "padding": "circular",
23
+ "influence_radius": 5
24
+ },
25
+ "paper_model": {
26
+ "input_channels": 4,
27
+ "output_channels": 3,
28
+ "grid_points": 250,
29
+ "hidden_channels": 32,
30
+ "hidden_layers": 4,
31
+ "kernel_size": 3,
32
+ "hidden_activation": "SELU",
33
+ "rain_activation": "ReLU",
34
+ "padding": "circular",
35
+ "influence_radius": 5,
36
+ "train_samples": 48000,
37
+ "validation_samples": 48000,
38
+ "batch_size": 96,
39
+ "epochs": 100,
40
+ "optimizer": "Adam",
41
+ "eta": 2.0,
42
+ "experiment": "dT10_eta2"
43
+ },
44
+ "configuration_sources": ["conf/config.yaml", "model/massconservingcnn.py", "scripts/fake_data.py", "scripts/train.py", "scripts/inference.py", "scripts/result.py"]
45
+ }
configuration.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "mass-aware-data-assimilation-analysis",
4
+ "model": "MassConservingCNN",
5
+ "input_format": "BCX",
6
+ "protocol": "1D modified shallow-water X^a plus radar indicator to QPEns analysis",
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/massconservingcnn.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Periodic one-dimensional CNN for mass-aware data-assimilation correction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ from torch import nn
7
+ from torch.nn import functional as F
8
+
9
+
10
+ class PeriodicConv1d(nn.Module):
11
+ """Conv1d with explicit circular padding and unchanged spatial length."""
12
+
13
+ def __init__(self, in_channels: int, out_channels: int, kernel_size: int):
14
+ super().__init__()
15
+ if kernel_size % 2 != 1:
16
+ raise ValueError("kernel_size must be odd")
17
+ self.pad = kernel_size // 2
18
+ self.conv = nn.Conv1d(in_channels, out_channels, kernel_size, padding=0)
19
+
20
+ def forward(self, inputs: torch.Tensor) -> torch.Tensor:
21
+ return self.conv(F.pad(inputs, (self.pad, self.pad), mode="circular"))
22
+
23
+
24
+ class MassConservingCNN(nn.Module):
25
+ """Four hidden SELU convolutions followed by the u/h/r output layer."""
26
+
27
+ def __init__(self, input_channels: int = 4, hidden_channels: int = 32,
28
+ hidden_layers: int = 4, kernel_size: int = 3):
29
+ super().__init__()
30
+ if input_channels != 4 or hidden_layers != 4 or kernel_size != 3:
31
+ raise ValueError("paper architecture requires 4 inputs, 4 hidden layers, kernel size 3")
32
+ layers = []
33
+ channels = input_channels
34
+ for _ in range(hidden_layers):
35
+ layers.extend((PeriodicConv1d(channels, hidden_channels, kernel_size), nn.SELU()))
36
+ channels = hidden_channels
37
+ self.hidden = nn.Sequential(*layers)
38
+ self.output = PeriodicConv1d(hidden_channels, 3, kernel_size)
39
+
40
+ @property
41
+ def influence_radius(self) -> int:
42
+ return 5
43
+
44
+ def forward(self, inputs: torch.Tensor) -> torch.Tensor:
45
+ if inputs.ndim != 3 or inputs.shape[1] != 4 or inputs.shape[2] != 250:
46
+ raise ValueError(f"expected float tensor [B,4,250], got {tuple(inputs.shape)}")
47
+ if not inputs.is_floating_point():
48
+ raise TypeError("inputs must have a floating-point dtype")
49
+ raw = self.output(self.hidden(inputs))
50
+ return torch.cat((raw[:, :2], F.relu(raw[:, 2:3])), dim=1)
scripts/fake_data.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate small, structured modified-shallow-water analysis pairs."""
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 periodic_gaussian(x, center, width):
14
+ distance = np.minimum(np.abs(x - center), 1.0 - np.abs(x - center))
15
+ return np.exp(-0.5 * (distance / width) ** 2)
16
+
17
+
18
+ def make_split(path, count, config, seed):
19
+ rng = np.random.default_rng(seed)
20
+ n = int(config["data"]["grid_points"])
21
+ x = np.arange(n, dtype=np.float32) / n
22
+ xa = np.empty((count, 3, n), dtype=np.float32)
23
+ target = np.empty_like(xa)
24
+ radar = np.empty((count, 1, n), dtype=np.float32)
25
+ for sample in range(count):
26
+ phase = rng.uniform(0.0, 1.0)
27
+ wave = np.sin(2 * np.pi * (x - phase))
28
+ harmonic = np.sin(4 * np.pi * (x - 0.6 * phase))
29
+ convective = periodic_gaussian(x, (phase + 0.23) % 1.0, 0.045)
30
+ secondary = periodic_gaussian(x, (phase + 0.66) % 1.0, 0.07)
31
+ u_true = 0.75 * wave + 0.22 * harmonic - 0.28 * np.gradient(convective)
32
+ h_true = 10.0 + 0.35 * np.cos(2 * np.pi * (x - phase)) + 0.5 * convective
33
+ convergence = np.maximum(-np.gradient(u_true), 0.0)
34
+ r_true = np.maximum(0.0, 0.7 * convective + 0.28 * convergence - 0.09)
35
+ rain_mask = (r_true > 0.08).astype(np.float32)
36
+
37
+ # Smooth EnKF-like errors are tied to convection and dry-region mass drift.
38
+ dry = 1.0 - rain_mask
39
+ u_error = 0.11 * secondary - 0.07 * convective + 0.025 * harmonic
40
+ h_error = 0.16 * dry + 0.08 * secondary - 0.05 * convective
41
+ r_error = 0.13 * secondary * dry - 0.06 * convective
42
+ xa[sample, 0] = u_true + u_error
43
+ xa[sample, 1] = h_true + h_error
44
+ xa[sample, 2] = np.maximum(0.0, r_true + r_error)
45
+ target[sample] = np.stack((u_true, h_true, r_true))
46
+ radar[sample, 0] = rain_mask
47
+
48
+ # Shared synthetic climatology keeps train and validation normalization identical.
49
+ means = np.asarray([0.0, 10.0], dtype=np.float32)
50
+ stds = np.asarray([0.6, 0.4, 0.3], dtype=np.float32)
51
+ normalized_x = xa.copy()
52
+ normalized_y = target.copy()
53
+ normalized_x[:, :2] = (xa[:, :2] - means[None, :, None]) / stds[None, :2, None]
54
+ normalized_y[:, :2] = (target[:, :2] - means[None, :, None]) / stds[None, :2, None]
55
+ normalized_x[:, 2] = xa[:, 2] / stds[2]
56
+ normalized_y[:, 2] = target[:, 2] / stds[2]
57
+ inputs = np.concatenate((normalized_x, radar), axis=1).astype(np.float32)
58
+ np.savez_compressed(
59
+ path, inputs=inputs, targets=normalized_y.astype(np.float32), xa=xa,
60
+ targets_physical=target, radar=radar, climate_mean_uh=means,
61
+ climate_std_uhr=stds, format_version=np.asarray(config["data"]["format_version"]),
62
+ variable_order=np.asarray(["u", "h", "r"]), input_layout=np.asarray("BCX"),
63
+ data_source=np.asarray("structured_synthetic_msw"),
64
+ )
65
+
66
+
67
+ def main():
68
+ parser = argparse.ArgumentParser()
69
+ parser.add_argument("--force", action="store_true")
70
+ args = parser.parse_args()
71
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
72
+ output = ROOT / config["data"]["root"]
73
+ output.mkdir(parents=True, exist_ok=True)
74
+ splits = (("train.npz", int(config["data"]["train_samples"])),
75
+ ("validation.npz", int(config["data"]["validation_samples"])))
76
+ for offset, (name, count) in enumerate(splits):
77
+ path = output / name
78
+ if args.force or not path.exists():
79
+ make_split(path, count, config, int(config["seed"]) + offset)
80
+ print(f"generated={path.relative_to(ROOT)} samples={count} shape=({count},4,250)")
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run validated inference and save normalized and physical fields."""
2
+
3
+ from pathlib import Path
4
+ import sys
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.massconservingcnn import MassConservingCNN
15
+ from train import MSWDataset, 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
+ required = {"model", "optimizer_state_dict", "model_config", "epoch", "eta",
23
+ "format_version", "variable_order", "normalization", "climate_mean_uh", "climate_std_uhr", "seed"}
24
+ if not required.issubset(checkpoint):
25
+ raise ValueError(f"incomplete checkpoint, missing {sorted(required - set(checkpoint))}")
26
+ if checkpoint["format_version"] != config["data"]["format_version"] or checkpoint["variable_order"] != ["u", "h", "r"]:
27
+ raise ValueError("checkpoint protocol mismatch")
28
+ dataset = MSWDataset(ROOT / config["data"]["root"] / "validation.npz", config)
29
+ loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]), shuffle=False)
30
+ model = MassConservingCNN(**checkpoint["model_config"]).to(device)
31
+ model.load_state_dict(checkpoint["model"]); model.eval()
32
+ outputs = []
33
+ with torch.no_grad():
34
+ for inputs, _ in loader:
35
+ outputs.append(model(inputs.to(device)).cpu().numpy())
36
+ predictions = np.concatenate(outputs).astype(np.float32)
37
+ if predictions.shape != dataset.data["targets"].shape or predictions.dtype != np.float32 or not np.isfinite(predictions).all():
38
+ raise ValueError("invalid inference output")
39
+ means = np.asarray(checkpoint["climate_mean_uh"], dtype=np.float32)
40
+ stds = np.asarray(checkpoint["climate_std_uhr"], dtype=np.float32)
41
+ physical = predictions.copy()
42
+ physical[:, :2] = predictions[:, :2] * stds[None, :2, None] + means[None, :, None]
43
+ physical[:, 2] = predictions[:, 2] * stds[2]
44
+ output = ROOT / config["paths"]["inference"]
45
+ output.parent.mkdir(parents=True, exist_ok=True)
46
+ np.savez_compressed(output, predictions=predictions, predictions_physical=physical,
47
+ inputs=dataset.data["inputs"], xa=dataset.data["xa"], targets=dataset.data["targets"],
48
+ targets_physical=dataset.data["targets_physical"], radar=dataset.data["radar"],
49
+ format_version=np.asarray(config["data"]["format_version"]), variable_order=np.asarray(["u", "h", "r"]))
50
+ print(f"predictions={output.relative_to(ROOT)} shape={predictions.shape} dtype={predictions.dtype}")
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
scripts/result.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compute paper-aligned metrics and plot input, target, and prediction."""
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 metrics(candidate_n, candidate_p, target_n, target_p):
17
+ rmse = np.sqrt(np.mean((candidate_n - target_n) ** 2, axis=(0, 2)))
18
+ sample_variable_rmse = np.sqrt(np.mean((candidate_n - target_n) ** 2, axis=2))
19
+ mass_h = np.mean(np.abs(candidate_p[:, 1].sum(1) - target_p[:, 1].sum(1)) / candidate_p.shape[2])
20
+ mass_r = np.mean(np.abs(candidate_p[:, 2].sum(1) - target_p[:, 2].sum(1)) / candidate_p.shape[2])
21
+ h_bias = np.mean(candidate_p[:, 1] - target_p[:, 1])
22
+ return {"J": float(sample_variable_rmse.mean()), "rmse_u": float(rmse[0]),
23
+ "rmse_h": float(rmse[1]), "rmse_r": float(rmse[2]),
24
+ "mass_error_h_per_point": float(mass_h), "mass_error_r_per_point": float(mass_r),
25
+ "h_bias": float(h_bias)}
26
+
27
+
28
+ def main():
29
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
30
+ data = np.load(ROOT / config["paths"]["inference"])
31
+ if str(data["format_version"]) != config["data"]["format_version"]:
32
+ raise ValueError("prediction format mismatch")
33
+ input_n, target_n, prediction_n = data["inputs"][:, :3], data["targets"], data["predictions"]
34
+ input_p, target_p, prediction_p = data["xa"], data["targets_physical"], data["predictions_physical"]
35
+ expected = (len(target_n), 3, 250)
36
+ if any(array.shape != expected for array in (input_n, target_n, prediction_n, input_p, target_p, prediction_p)):
37
+ raise ValueError("evaluation arrays must have shape [B,3,250]")
38
+ baseline = metrics(input_n, input_p, target_n, target_p)
39
+ prediction = metrics(prediction_n, prediction_p, target_n, target_p)
40
+ improvement = {key: float(100 * (baseline[key] - prediction[key]) / baseline[key])
41
+ for key in ("J", "rmse_u", "rmse_h", "rmse_r", "mass_error_h_per_point", "mass_error_r_per_point")
42
+ if baseline[key] != 0}
43
+ if baseline["h_bias"] != 0:
44
+ improvement["absolute_h_bias"] = float(
45
+ 100 * (abs(baseline["h_bias"]) - abs(prediction["h_bias"])) / abs(baseline["h_bias"])
46
+ )
47
+ report = {"samples": len(target_n), "baseline_input": baseline, "prediction": prediction,
48
+ "relative_improvement_percent": improvement,
49
+ "note": "Structured synthetic engineering validation; not paper performance."}
50
+ values = list(baseline.values()) + list(prediction.values()) + list(improvement.values())
51
+ if not np.isfinite(values).all():
52
+ raise FloatingPointError("non-finite evaluation metric")
53
+ output = ROOT / config["paths"]["evaluation_dir"]
54
+ output.mkdir(parents=True, exist_ok=True)
55
+ (output / "metrics.json").write_text(json.dumps(report, indent=2) + "\n")
56
+ x = np.arange(250) * float(config["data"]["domain_km"]) / 250
57
+ figure, axes = plt.subplots(3, 1, figsize=(11, 8), sharex=True)
58
+ for index, (axis, variable) in enumerate(zip(axes, ("u", "h", "r"))):
59
+ axis.plot(x, input_p[0, index], color="steelblue", label="input X^a", linewidth=1.4)
60
+ axis.plot(x, target_p[0, index], color="black", label="QPEns target", linewidth=1.5)
61
+ axis.plot(x, prediction_p[0, index], color="firebrick", label="CNN prediction", linewidth=1.3)
62
+ if variable == "r":
63
+ axis.fill_between(x, 0, data["radar"][0, 0] * max(target_p[0, 2].max(), 1e-6), color="gold", alpha=0.2, label="radar mask")
64
+ axis.set_ylabel(variable); axis.grid(alpha=0.2)
65
+ axes[0].legend(ncol=3); axes[-1].set_xlabel("distance (km)")
66
+ figure.suptitle("MassConservingCNN structured synthetic validation")
67
+ figure.tight_layout(); figure.savefig(output / "input_target_prediction.png", dpi=150); plt.close(figure)
68
+ print(f"evaluation={output.relative_to(ROOT)} J={prediction['J']:.6f} h_mass={prediction['mass_error_h_per_point']:.6f}")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
scripts/train.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train MassConservingCNN with optional torchrun DDP."""
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.massconservingcnn import MassConservingCNN
19
+
20
+
21
+ class MSWDataset(Dataset):
22
+ def __init__(self, path, config):
23
+ self.data = np.load(path)
24
+ if str(self.data["format_version"]) != config["data"]["format_version"]:
25
+ raise ValueError("incompatible data format version")
26
+ count = len(self.data["inputs"])
27
+ if self.data["inputs"].shape != (count, 4, 250):
28
+ raise ValueError("inputs must have shape [B,4,250]")
29
+ if self.data["targets"].shape != (count, 3, 250):
30
+ raise ValueError("targets must have shape [B,3,250]")
31
+ if self.data["inputs"].dtype != np.float32 or self.data["targets"].dtype != np.float32:
32
+ raise TypeError("inputs and targets must be float32")
33
+ if not np.isfinite(self.data["inputs"]).all() or not np.isfinite(self.data["targets"]).all():
34
+ raise ValueError("data must be finite")
35
+ if not np.isin(self.data["radar"], (0.0, 1.0)).all():
36
+ raise ValueError("radar indicator must be binary")
37
+ if (self.data["inputs"][:, 2] < 0).any() or (self.data["targets"][:, 2] < 0).any():
38
+ raise ValueError("normalized rain must remain non-negative")
39
+
40
+ def __len__(self):
41
+ return len(self.data["inputs"])
42
+
43
+ def __getitem__(self, index):
44
+ return torch.from_numpy(self.data["inputs"][index]), torch.from_numpy(self.data["targets"][index])
45
+
46
+
47
+ def paper_j(prediction, target):
48
+ return torch.sqrt(torch.mean((prediction - target) ** 2, dim=2) + 1e-12).mean(dim=1)
49
+
50
+
51
+ def mass_aware_loss(prediction, target, eta):
52
+ base = paper_j(prediction, target)
53
+ mass = eta / prediction.shape[2] * torch.abs(prediction[:, 1].sum(1) - target[:, 1].sum(1))
54
+ return (base + mass).mean(), base.mean(), mass.mean()
55
+
56
+
57
+ def device_from_config(config, local_rank=0):
58
+ requested = config["runtime"]["device"]
59
+ if requested == "auto":
60
+ return torch.device("cuda", local_rank) if torch.cuda.is_available() else torch.device("cpu")
61
+ return torch.device(requested)
62
+
63
+
64
+ def main():
65
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
66
+ seed = int(config["seed"])
67
+ random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
68
+ distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
69
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
70
+ if distributed:
71
+ torch.distributed.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
72
+ rank = torch.distributed.get_rank() if distributed else 0
73
+ device = device_from_config(config, local_rank)
74
+ if device.type == "cuda":
75
+ torch.cuda.set_device(device); torch.cuda.manual_seed_all(seed)
76
+ train_set = MSWDataset(ROOT / config["data"]["root"] / "train.npz", config)
77
+ valid_set = MSWDataset(ROOT / config["data"]["root"] / "validation.npz", config)
78
+ sampler = DistributedSampler(train_set, shuffle=True, seed=seed) if distributed else None
79
+ loader = DataLoader(train_set, batch_size=int(config["train"]["batch_size"]),
80
+ shuffle=sampler is None, sampler=sampler,
81
+ num_workers=int(config["train"]["num_workers"]))
82
+ valid_loader = DataLoader(valid_set, batch_size=int(config["train"]["batch_size"]), shuffle=False)
83
+ model = MassConservingCNN(**config["model"]).to(device)
84
+ wrapped = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None) if distributed else model
85
+ bare = wrapped.module if distributed else wrapped
86
+ optimizer = torch.optim.Adam(wrapped.parameters(), lr=float(config["train"]["learning_rate"]))
87
+ history = []
88
+ for epoch in range(int(config["train"]["epochs"])):
89
+ if sampler is not None:
90
+ sampler.set_epoch(epoch)
91
+ wrapped.train(); total = 0.0; seen = 0
92
+ for inputs, targets in loader:
93
+ prediction = wrapped(inputs.to(device)); loss, _, _ = mass_aware_loss(prediction, targets.to(device), float(config["train"]["eta"]))
94
+ optimizer.zero_grad(set_to_none=True); loss.backward(); optimizer.step()
95
+ total += float(loss.detach()) * len(inputs); seen += len(inputs)
96
+ totals = torch.tensor([total, seen], dtype=torch.float64, device=device)
97
+ if distributed:
98
+ torch.distributed.all_reduce(totals)
99
+ wrapped.eval(); valid_total = valid_j = valid_mass = 0.0; valid_seen = 0
100
+ if rank == 0:
101
+ with torch.no_grad():
102
+ for inputs, targets in valid_loader:
103
+ loss, base, mass = mass_aware_loss(bare(inputs.to(device)), targets.to(device), float(config["train"]["eta"]))
104
+ valid_total += float(loss) * len(inputs); valid_j += float(base) * len(inputs)
105
+ valid_mass += float(mass) * len(inputs); valid_seen += len(inputs)
106
+ history.append({"epoch": epoch + 1, "train_loss": float(totals[0] / totals[1]),
107
+ "validation_loss": valid_total / valid_seen, "validation_J": valid_j / valid_seen,
108
+ "validation_mass_penalty": valid_mass / valid_seen})
109
+ if rank == 0:
110
+ checkpoint_path = ROOT / config["paths"]["checkpoint"]
111
+ metrics_path = ROOT / config["paths"]["training_metrics"]
112
+ checkpoint_path.parent.mkdir(parents=True, exist_ok=True); metrics_path.parent.mkdir(parents=True, exist_ok=True)
113
+ model_state = bare.state_dict()
114
+ torch.save({"model": model_state, "model_state_dict": model_state,
115
+ "optimizer_state_dict": optimizer.state_dict(),
116
+ "model_config": config["model"], "epoch": int(config["train"]["epochs"]),
117
+ "eta": float(config["train"]["eta"]), "format_version": config["data"]["format_version"],
118
+ "variable_order": ["u", "h", "r"], "normalization": "u,h: center/scale; r: scale only",
119
+ "climate_mean_uh": train_set.data["climate_mean_uh"],
120
+ "climate_std_uhr": train_set.data["climate_std_uhr"], "seed": seed}, checkpoint_path)
121
+ metrics_path.write_text(json.dumps({"history": history}, indent=2) + "\n")
122
+ print(f"checkpoint={checkpoint_path.relative_to(ROOT)} validation_loss={history[-1]['validation_loss']:.6f}")
123
+ if distributed:
124
+ torch.distributed.destroy_process_group()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
weight/.gitkeep ADDED
File without changes