zhangrenchao commited on
Commit
5b3329b
·
verified ·
1 Parent(s): 0face05

Update CorrDiff model package

Browse files
conf/config.yaml CHANGED
@@ -1,21 +1,43 @@
1
- # Lightweight OneScience configuration for the official CorrDiff example.
2
- model:
3
- regression_config: conf/config_training_taiwan_regression.yaml
4
- diffusion_config: conf/config_training_taiwan_diffusion.yaml
5
- generation_config: conf/config_generate_taiwan.yaml
6
- input_channels: 12
7
- output_channels: 4
8
- input_shape: [36, 36]
9
- output_shape: [448, 448]
10
- ensemble_size: 32
11
-
12
  data:
13
- type: cwb_zarr
14
- path: ./data/era5_corrdiff.npz
 
 
 
 
15
  input_variables: [tcwv, t2m, u10m, v10m, t500, z500, u500, v500, t850, z850, u850, v850]
16
- output_variables: [t2m, u10m, v10m, maximum_radar_reflectivity]
17
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  runtime:
19
  device: auto
20
- output_dir: ./result/output
21
- seed: 42
 
 
 
 
1
+ seed: 42
 
 
 
 
 
 
 
 
 
 
2
  data:
3
+ path: data/corrdiff.npz
4
+ protocol: corrdiff_npz_v1
5
+ input_key: input
6
+ target_key: target
7
+ input_shape: [12, 36, 36]
8
+ target_shape: [4, 448, 448]
9
  input_variables: [tcwv, t2m, u10m, v10m, t500, z500, u500, v500, t850, z850, u850, v850]
10
+ target_variables: [t2m, u10m, v10m, maximum_radar_reflectivity]
11
+ fake_samples: 2
12
+ model:
13
+ in_channels: 12
14
+ out_channels: 4
15
+ base_channels: 8
16
+ feature_size: 28
17
+ output_size: 448
18
+ sigma_data: 0.5
19
+ training:
20
+ regression_epochs: 1
21
+ diffusion_epochs: 1
22
+ batch_size: 1
23
+ max_batches_per_epoch: 1
24
+ learning_rate: 0.0002
25
+ num_workers: 0
26
+ amp: true
27
+ p_mean: -1.2
28
+ p_std: 1.2
29
+ sampling:
30
+ batch_size: 1
31
+ ensemble_size: 2
32
+ steps: 3
33
+ sigma_min: 0.002
34
+ sigma_max: 5.0
35
+ rho: 7.0
36
+ solver: heun
37
  runtime:
38
  device: auto
39
+ paths:
40
+ checkpoint: result/checkpoints/corrdiff.pt
41
+ training_metrics: result/training/metrics.json
42
+ predictions: result/output/predictions.npz
43
+ evaluation_dir: result/evaluation
config.json CHANGED
@@ -2,37 +2,95 @@
2
  "model_name": "CorrDiff",
3
  "model_type": "corrdiff",
4
  "architectures": [
5
- "CorrDiff"
 
 
6
  ],
7
  "framework": "PyTorch",
8
  "domain": "atmosphere",
9
- "task": "regional-weather-downscaling",
10
  "implementation": {
11
  "entry_point": "model/corrdiff.py",
12
- "scope": "compact two-stage CorrDiff reproduction"
 
 
 
 
13
  },
14
  "architecture": {
15
- "family": "residual corrective diffusion model",
16
- "input_grid_shape": [36, 36],
17
- "output_grid_shape": [448, 448],
18
- "input_channels": 12,
19
- "output_channels": 4,
20
- "base_channels": 32,
21
- "mean_model": "RegressionUNet",
22
- "residual_model": "ResidualDiffusionUNet",
23
- "normalization": "group_norm",
24
- "activation": "silu",
25
- "sampling_steps": 4
 
 
 
 
 
 
 
 
26
  },
27
  "data": {
28
- "dataset": "ERA5 and CWA-WRF",
29
- "input_spatial_resolution_km": 25,
30
- "output_spatial_resolution_km": 2,
31
- "time_step_hours": 1,
32
- "protocol": "paired-downscaling"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  },
34
  "configuration_sources": [
35
  "conf/config.yaml",
36
- "model/corrdiff.py"
 
 
 
 
37
  ]
38
  }
 
2
  "model_name": "CorrDiff",
3
  "model_type": "corrdiff",
4
  "architectures": [
5
+ "CorrDiff",
6
+ "RegressionUNet",
7
+ "ResidualDenoiser"
8
  ],
9
  "framework": "PyTorch",
10
  "domain": "atmosphere",
11
+ "task": "probabilistic-weather-downscaling",
12
  "implementation": {
13
  "entry_point": "model/corrdiff.py",
14
+ "scope": "conditional-regression mean prediction followed by residual EDM ensemble generation for kilometer-scale atmospheric downscaling",
15
+ "train_script": "scripts/train.py",
16
+ "inference_script": "scripts/inference.py",
17
+ "evaluation_script": "scripts/result.py",
18
+ "synthetic_data_script": "scripts/fake_data.py"
19
  },
20
  "architecture": {
21
+ "family": "conditional regression plus residual corrective diffusion",
22
+ "in_channels": 12,
23
+ "out_channels": 4,
24
+ "base_channels": 8,
25
+ "feature_size": 28,
26
+ "output_size": 448,
27
+ "sigma_data": 0.5,
28
+ "regression_component": "RegressionUNet",
29
+ "diffusion_component": "ResidualDenoiser",
30
+ "diffusion_preconditioning": "EDM",
31
+ "noise_schedule": "Karras",
32
+ "sampling": {
33
+ "ensemble_size": 2,
34
+ "steps": 3,
35
+ "sigma_min": 0.002,
36
+ "sigma_max": 5.0,
37
+ "rho": 7.0,
38
+ "solver": "heun"
39
+ }
40
  },
41
  "data": {
42
+ "datasets": [
43
+ "ERA5",
44
+ "CWA-WRF"
45
+ ],
46
+ "protocol": "corrdiff_npz_v1",
47
+ "format": "NPZ",
48
+ "default_file": "data/corrdiff.npz",
49
+ "input_key": "input",
50
+ "input_shape": [
51
+ "N",
52
+ 12,
53
+ 36,
54
+ 36
55
+ ],
56
+ "target_key": "target",
57
+ "target_shape": [
58
+ "N",
59
+ 4,
60
+ 448,
61
+ 448
62
+ ],
63
+ "input_variables": [
64
+ "tcwv",
65
+ "t2m",
66
+ "u10m",
67
+ "v10m",
68
+ "t500",
69
+ "z500",
70
+ "u500",
71
+ "v500",
72
+ "t850",
73
+ "z850",
74
+ "u850",
75
+ "v850"
76
+ ],
77
+ "target_variables": [
78
+ "t2m",
79
+ "u10m",
80
+ "v10m",
81
+ "maximum_radar_reflectivity"
82
+ ],
83
+ "required_metadata": [
84
+ "protocol",
85
+ "data_source"
86
+ ]
87
  },
88
  "configuration_sources": [
89
  "conf/config.yaml",
90
+ "model/corrdiff.py",
91
+ "scripts/fake_data.py",
92
+ "scripts/train.py",
93
+ "scripts/inference.py",
94
+ "scripts/result.py"
95
  ]
96
  }
configuration.json CHANGED
@@ -1 +1,11 @@
1
- {"framework":"Pytorch","task":"weather_downscaling"}
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "probabilistic_weather_downscaling",
4
+ "model": "CorrDiff",
5
+ "license": "Apache-2.0",
6
+ "protocol": "corrdiff_npz_v1",
7
+ "default_config": "conf/config.yaml",
8
+ "train": "scripts/train.py",
9
+ "inference": "scripts/inference.py",
10
+ "evaluation": "scripts/result.py"
11
+ }
model/corrdiff.py CHANGED
@@ -1,6 +1,7 @@
1
- """Compact CorrDiff implementation for the OneScience model package."""
2
 
3
  import math
 
4
  import torch
5
  from torch import nn
6
  from torch.nn import functional as F
@@ -9,12 +10,13 @@ from torch.nn import functional as F
9
  class ConvBlock(nn.Module):
10
  def __init__(self, in_channels, out_channels):
11
  super().__init__()
 
12
  self.block = nn.Sequential(
13
  nn.Conv2d(in_channels, out_channels, 3, padding=1),
14
- nn.GroupNorm(8, out_channels),
15
  nn.SiLU(),
16
  nn.Conv2d(out_channels, out_channels, 3, padding=1),
17
- nn.GroupNorm(8, out_channels),
18
  nn.SiLU(),
19
  )
20
  self.skip = nn.Conv2d(in_channels, out_channels, 1)
@@ -24,79 +26,118 @@ class ConvBlock(nn.Module):
24
 
25
 
26
  class RegressionUNet(nn.Module):
27
- """Mean predictor: 12x36x36 coarse fields to 4x448x448 fields."""
28
 
29
- def __init__(self, in_channels=12, out_channels=4, base_channels=32):
 
30
  super().__init__()
31
- self.encoder = nn.Sequential(
 
 
32
  ConvBlock(in_channels, base_channels),
33
  ConvBlock(base_channels, base_channels * 2),
34
- ConvBlock(base_channels * 2, base_channels * 4),
35
- )
36
- self.head = nn.Sequential(
37
- ConvBlock(base_channels * 4, base_channels * 2),
38
- nn.Conv2d(base_channels * 2, out_channels, 1),
39
  )
40
 
41
- def forward(self, x):
42
- x = self.encoder[0](x)
43
- x = F.avg_pool2d(x, 2)
44
- x = self.encoder[1](x)
45
- x = F.avg_pool2d(x, 2)
46
- x = self.encoder[2](x)
47
- x = self.head[0](x)
48
- x = F.interpolate(x, size=(448, 448), mode="bilinear", align_corners=False)
49
- return self.head[1](x)
50
 
51
 
52
- class ResidualDiffusionUNet(nn.Module):
53
- """Conditional residual denoiser used by the compact EDM-style sampler."""
54
 
55
- def __init__(self, condition_channels=16, out_channels=4, base_channels=32):
 
56
  super().__init__()
57
- self.time = nn.Sequential(
 
58
  nn.Linear(1, base_channels), nn.SiLU(), nn.Linear(base_channels, base_channels)
59
  )
60
- self.net = nn.Sequential(
61
- ConvBlock(condition_channels, base_channels),
62
  ConvBlock(base_channels, base_channels * 2),
63
  ConvBlock(base_channels * 2, base_channels),
64
  nn.Conv2d(base_channels, out_channels, 1),
65
  )
66
 
67
- def forward(self, noisy_residual, condition, sigma):
68
- x = torch.cat((noisy_residual, condition), dim=1)
69
- embedding = self.time(torch.log(sigma).reshape(-1, 1).float())
70
- x = self.net[0](x)
71
- x = x + embedding[:, :, None, None]
72
- for layer in self.net[1:]:
73
- x = layer(x)
74
- return x
 
75
 
76
 
77
  class CorrDiff(nn.Module):
78
- """Two-stage mean plus stochastic residual atmospheric downscaler."""
79
-
80
- def __init__(self, in_channels=12, out_channels=4, base_channels=32):
81
  super().__init__()
82
- self.regression = RegressionUNet(in_channels, out_channels, base_channels)
83
- self.diffusion = ResidualDiffusionUNet(in_channels + 2 * out_channels, out_channels, base_channels)
 
 
 
 
84
 
85
  def mean(self, coarse):
86
  return self.regression(coarse)
87
 
88
- def denoise(self, noisy_residual, coarse, mean, sigma):
89
- coarse = F.interpolate(coarse, size=mean.shape[-2:], mode="bilinear", align_corners=False)
90
- return self.diffusion(noisy_residual, torch.cat((coarse, mean), dim=1), sigma)
91
 
92
- def forward(self, coarse, steps=4):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  mean = self.mean(coarse)
94
- residual = torch.randn_like(mean)
95
- schedule = torch.linspace(1.0, 0.01, steps, device=coarse.device)
96
- for sigma in schedule:
97
- sigma_batch = sigma.expand(coarse.shape[0])
98
- residual = residual - 0.2 * self.denoise(residual, coarse, mean, sigma_batch)
99
- return mean + residual
100
-
101
-
102
- __all__ = ["CorrDiff", "RegressionUNet", "ResidualDiffusionUNet"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compact but faithful conditional-regression and residual-EDM CorrDiff."""
2
 
3
  import math
4
+
5
  import torch
6
  from torch import nn
7
  from torch.nn import functional as F
 
10
  class ConvBlock(nn.Module):
11
  def __init__(self, in_channels, out_channels):
12
  super().__init__()
13
+ groups = min(8, out_channels)
14
  self.block = nn.Sequential(
15
  nn.Conv2d(in_channels, out_channels, 3, padding=1),
16
+ nn.GroupNorm(groups, out_channels),
17
  nn.SiLU(),
18
  nn.Conv2d(out_channels, out_channels, 3, padding=1),
19
+ nn.GroupNorm(groups, out_channels),
20
  nn.SiLU(),
21
  )
22
  self.skip = nn.Conv2d(in_channels, out_channels, 1)
 
26
 
27
 
28
  class RegressionUNet(nn.Module):
29
+ """Deterministic conditional mean, computed cheaply before 448 px recovery."""
30
 
31
+ def __init__(self, in_channels=12, out_channels=4, base_channels=16, feature_size=56,
32
+ output_size=448):
33
  super().__init__()
34
+ self.feature_size = feature_size
35
+ self.output_size = output_size
36
+ self.net = nn.Sequential(
37
  ConvBlock(in_channels, base_channels),
38
  ConvBlock(base_channels, base_channels * 2),
39
+ ConvBlock(base_channels * 2, base_channels),
40
+ nn.Conv2d(base_channels, out_channels, 1),
 
 
 
41
  )
42
 
43
+ def forward(self, coarse):
44
+ x = F.interpolate(coarse, (self.feature_size, self.feature_size), mode="bilinear",
45
+ align_corners=False)
46
+ x = self.net(x)
47
+ return F.interpolate(x, (self.output_size, self.output_size), mode="bilinear",
48
+ align_corners=False)
 
 
 
49
 
50
 
51
+ class ResidualDenoiser(nn.Module):
52
+ """Noise-conditional network used inside EDM preconditioning."""
53
 
54
+ def __init__(self, condition_channels=16, out_channels=4, base_channels=16,
55
+ feature_size=56):
56
  super().__init__()
57
+ self.feature_size = feature_size
58
+ self.noise_mlp = nn.Sequential(
59
  nn.Linear(1, base_channels), nn.SiLU(), nn.Linear(base_channels, base_channels)
60
  )
61
+ self.input = ConvBlock(condition_channels, base_channels)
62
+ self.body = nn.Sequential(
63
  ConvBlock(base_channels, base_channels * 2),
64
  ConvBlock(base_channels * 2, base_channels),
65
  nn.Conv2d(base_channels, out_channels, 1),
66
  )
67
 
68
+ def forward(self, noisy, condition, c_noise):
69
+ size = noisy.shape[-2:]
70
+ x = torch.cat((noisy, condition), dim=1)
71
+ x = F.interpolate(x, (self.feature_size, self.feature_size), mode="bilinear",
72
+ align_corners=False)
73
+ x = self.input(x)
74
+ x = x + self.noise_mlp(c_noise[:, None].float())[:, :, None, None].to(x.dtype)
75
+ x = self.body(x)
76
+ return F.interpolate(x, size, mode="bilinear", align_corners=False)
77
 
78
 
79
  class CorrDiff(nn.Module):
80
+ def __init__(self, in_channels=12, out_channels=4, base_channels=16, feature_size=56,
81
+ output_size=448, sigma_data=0.5):
 
82
  super().__init__()
83
+ self.sigma_data = sigma_data
84
+ self.output_size = output_size
85
+ self.regression = RegressionUNet(in_channels, out_channels, base_channels,
86
+ feature_size, output_size)
87
+ self.diffusion = ResidualDenoiser(in_channels + 2 * out_channels, out_channels,
88
+ base_channels, feature_size)
89
 
90
  def mean(self, coarse):
91
  return self.regression(coarse)
92
 
93
+ def condition(self, coarse, mean):
94
+ coarse = F.interpolate(coarse, mean.shape[-2:], mode="bilinear", align_corners=False)
95
+ return torch.cat((coarse, mean), dim=1)
96
 
97
+ def denoise(self, noisy_residual, coarse, mean, sigma):
98
+ sigma = sigma.reshape(-1, 1, 1, 1).to(noisy_residual.dtype)
99
+ sigma_data = self.sigma_data
100
+ c_skip = sigma_data**2 / (sigma.square() + sigma_data**2)
101
+ c_out = sigma * sigma_data / (sigma.square() + sigma_data**2).sqrt()
102
+ c_in = (sigma.square() + sigma_data**2).rsqrt()
103
+ c_noise = sigma.flatten().log() / 4
104
+ network = self.diffusion(c_in * noisy_residual, self.condition(coarse, mean), c_noise)
105
+ return c_skip * noisy_residual + c_out * network
106
+
107
+ @staticmethod
108
+ def karras_schedule(steps, sigma_min, sigma_max, rho, device):
109
+ ramp = torch.linspace(0, 1, steps, device=device)
110
+ maximum = sigma_max ** (1 / rho)
111
+ minimum = sigma_min ** (1 / rho)
112
+ sigmas = (maximum + ramp * (minimum - maximum)) ** rho
113
+ return torch.cat((sigmas, sigmas.new_zeros(1)))
114
+
115
+ def sample(self, coarse, steps=4, sigma_min=0.002, sigma_max=5.0, rho=7.0,
116
+ solver="heun"):
117
  mean = self.mean(coarse)
118
+ sigmas = self.karras_schedule(steps, sigma_min, sigma_max, rho, coarse.device)
119
+ x = torch.randn_like(mean) * sigmas[0]
120
+ for index, (current, following) in enumerate(zip(sigmas[:-1], sigmas[1:])):
121
+ sigma = current.expand(coarse.shape[0])
122
+ denoised = self.denoise(x, coarse, mean, sigma)
123
+ derivative = (x - denoised) / current
124
+ proposal = x + (following - current) * derivative
125
+ if solver == "heun" and index < len(sigmas) - 2:
126
+ next_sigma = following.expand(coarse.shape[0])
127
+ next_denoised = self.denoise(proposal, coarse, mean, next_sigma)
128
+ next_derivative = (proposal - next_denoised) / following
129
+ x = x + (following - current) * (derivative + next_derivative) / 2
130
+ else:
131
+ x = proposal
132
+ return mean + x
133
+
134
+ def forward(self, coarse, mode="sample", mean=None, noisy=None, sigma=None,
135
+ **sample_options):
136
+ if mode == "mean":
137
+ return self.mean(coarse)
138
+ if mode == "denoise":
139
+ return self.denoise(noisy, coarse, mean, sigma)
140
+ return self.sample(coarse, **sample_options)
141
+
142
+
143
+ __all__ = ["CorrDiff", "RegressionUNet", "ResidualDenoiser"]
scripts/fake_data.py CHANGED
@@ -1,22 +1,41 @@
1
- from pathlib import Path
 
2
  import argparse
 
 
3
  import numpy as np
 
 
 
4
 
5
 
6
  def main():
7
  parser = argparse.ArgumentParser()
8
- parser.add_argument("--output", default="data/fake_corrdiff.npz")
9
- parser.add_argument("--samples", type=int, default=1)
10
  args = parser.parse_args()
11
- path = Path(args.output)
12
- path.parent.mkdir(parents=True, exist_ok=True)
13
- rng = np.random.default_rng(42)
14
- inputs = rng.standard_normal((args.samples, 12, 36, 36)).astype("float32")
15
- targets = rng.standard_normal((args.samples, 4, 448, 448)).astype("float32")
16
- np.savez_compressed(path, input=inputs, target=targets)
17
- print(f"input: {inputs.shape}")
18
- print(f"target: {targets.shape}")
19
- print(f"saved: {path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  if __name__ == "__main__":
 
1
+ """Generate a tiny, structurally realistic CorrDiff NPZ dataset."""
2
+
3
  import argparse
4
+ from pathlib import Path
5
+
6
  import numpy as np
7
+ import yaml
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
 
11
 
12
  def main():
13
  parser = argparse.ArgumentParser()
14
+ parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
15
+ parser.add_argument("--output")
16
  args = parser.parse_args()
17
+ config = yaml.safe_load(Path(args.config).read_text(encoding="utf-8"))
18
+ data = config["data"]
19
+ count = data["fake_samples"]
20
+ rng = np.random.default_rng(config["seed"])
21
+ coarse = rng.normal(size=(count, *data["input_shape"])).astype("float32")
22
+ # Correlated targets make the regression/backward smoke test meaningful.
23
+ y = np.linspace(-1, 1, data["target_shape"][1], dtype="float32")
24
+ x = np.linspace(-1, 1, data["target_shape"][2], dtype="float32")
25
+ yy, xx = np.meshgrid(y, x, indexing="ij")
26
+ target = np.empty((count, *data["target_shape"]), dtype="float32")
27
+ coarse_signal = coarse.mean(axis=(2, 3))
28
+ for sample in range(count):
29
+ for channel in range(data["target_shape"][0]):
30
+ target[sample, channel] = coarse_signal[sample, channel] + 0.3 * np.sin(
31
+ (channel + 1) * np.pi * xx
32
+ ) + 0.2 * np.cos((channel + 1) * np.pi * yy)
33
+ target += rng.normal(0, 0.05, target.shape).astype("float32")
34
+ output = Path(args.output) if args.output else ROOT / data["path"]
35
+ output.parent.mkdir(parents=True, exist_ok=True)
36
+ np.savez_compressed(output, input=coarse, target=target,
37
+ protocol=np.asarray(data["protocol"]), data_source=np.asarray("synthetic"))
38
+ print(f"saved={output} input={coarse.shape} target={target.shape}")
39
 
40
 
41
  if __name__ == "__main__":
scripts/inference.py CHANGED
@@ -1,60 +1,66 @@
 
 
1
  import argparse
2
- from pathlib import Path
3
  import sys
 
4
 
5
  import numpy as np
6
  import torch
 
7
 
8
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
 
9
  from model.corrdiff import CorrDiff
10
 
11
 
 
 
 
 
 
 
12
  def main():
13
- parser = argparse.ArgumentParser(description="Generate a CorrDiff ensemble")
14
- parser.add_argument("--data", default="data/era5_corrdiff.npz")
15
- parser.add_argument("--regression-checkpoint", default="data/checkpoints/regression_model.pth")
16
- parser.add_argument("--diffusion-checkpoint", default="data/checkpoints/diffusion_model.pth")
17
- parser.add_argument("--ensemble-size", type=int, default=32)
18
- parser.add_argument("--output", default="result/output/predictions.npz")
19
- parser.add_argument("--seed", type=int, default=42)
20
  args = parser.parse_args()
21
-
22
- data = np.load(args.data)
23
- coarse = torch.from_numpy(data["input"])
24
- target = data["target"].astype("float32") if "target" in data else None
25
- model = CorrDiff()
26
- regression_checkpoint = Path(args.regression_checkpoint)
27
- diffusion_checkpoint = Path(args.diffusion_checkpoint)
28
- if regression_checkpoint.exists():
29
- state = torch.load(regression_checkpoint, map_location="cpu", weights_only=False)
30
- model.regression.load_state_dict(state["model"])
31
- if diffusion_checkpoint.exists():
32
- state = torch.load(diffusion_checkpoint, map_location="cpu", weights_only=False)
33
- model.diffusion.load_state_dict(state["model"])
34
  model.eval()
35
-
36
- samples = []
37
- with torch.no_grad():
38
- for member in range(args.ensemble_size):
39
- torch.manual_seed(args.seed + member)
40
- samples.append(model(coarse).numpy().astype("float32"))
41
-
42
- ensemble = np.stack(samples)
43
- output = Path(args.output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  output.parent.mkdir(parents=True, exist_ok=True)
45
- payload = {
46
- "ensemble": ensemble,
47
- "ensemble_mean": ensemble.mean(axis=0),
48
- "ensemble_std": ensemble.std(axis=0),
49
- "input": data["input"].astype("float32"),
50
- }
51
- if target is not None:
52
- payload["target"] = target
53
- np.savez_compressed(output, **payload)
54
- print(f"ensemble: {ensemble.shape}")
55
- print(f"ensemble mean: {payload['ensemble_mean'].shape}")
56
- print(f"ensemble std: {payload['ensemble_std'].shape}")
57
- print(f"saved: {output}")
58
 
59
 
60
  if __name__ == "__main__":
 
1
+ """Generate a checkpoint-backed CorrDiff ensemble in device-sized batches."""
2
+
3
  import argparse
 
4
  import sys
5
+ from pathlib import Path
6
 
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.corrdiff import CorrDiff
14
 
15
 
16
+ def scalar(archive, key):
17
+ if key not in archive or archive[key].ndim != 0:
18
+ raise ValueError(f"NPZ metadata {key} must be present as a scalar")
19
+ return str(archive[key].item())
20
+
21
+
22
  def main():
23
+ parser = argparse.ArgumentParser()
24
+ parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
25
+ parser.add_argument("--checkpoint")
 
 
 
 
26
  args = parser.parse_args()
27
+ config = yaml.safe_load(Path(args.config).read_text(encoding="utf-8"))
28
+ checkpoint_path = Path(args.checkpoint) if args.checkpoint else ROOT / config["paths"]["checkpoint"]
29
+ if not checkpoint_path.is_file():
30
+ raise FileNotFoundError(f"Checkpoint is required: {checkpoint_path}")
31
+ device = torch.device("cuda" if torch.cuda.is_available() and config["runtime"]["device"] != "cpu" else "cpu")
32
+ state = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
33
+ model = CorrDiff(**config["model"]).to(device)
34
+ model.load_state_dict(state["model"])
 
 
 
 
 
35
  model.eval()
36
+ archive = np.load(ROOT / config["data"]["path"])
37
+ protocol, data_source = scalar(archive, "protocol"), scalar(archive, "data_source")
38
+ if protocol != config["data"]["protocol"] or state.get("protocol") != protocol:
39
+ raise ValueError("Data, checkpoint, and configured protocols must match")
40
+ if not data_source or state.get("data_source") != data_source:
41
+ raise ValueError("Data and checkpoint data_source metadata must match")
42
+ coarse = archive[config["data"]["input_key"]]
43
+ target = archive[config["data"]["target_key"]]
44
+ if coarse.ndim != 4 or target.ndim != 4 or tuple(coarse.shape[1:]) != tuple(config["data"]["input_shape"]) or tuple(target.shape[1:]) != tuple(config["data"]["target_shape"]) or len(coarse) != len(target):
45
+ raise ValueError("Invalid CorrDiff NPZ tensor contract")
46
+ member_count = config["sampling"]["ensemble_size"]
47
+ ensemble = np.empty((member_count, len(coarse), *config["data"]["target_shape"]), dtype="float32")
48
+ options = {key: config["sampling"][key] for key in ("steps", "sigma_min", "sigma_max", "rho", "solver")}
49
+ batch_size = config["sampling"]["batch_size"]
50
+ with torch.inference_mode():
51
+ for member in range(member_count):
52
+ torch.manual_seed(config["seed"] + member)
53
+ for start in range(0, len(coarse), batch_size):
54
+ stop = min(start + batch_size, len(coarse))
55
+ batch = torch.from_numpy(coarse[start:stop]).to(device)
56
+ ensemble[member, start:stop] = model.sample(batch, **options).cpu().numpy()
57
+ del batch
58
+ output = ROOT / config["paths"]["predictions"]
59
  output.parent.mkdir(parents=True, exist_ok=True)
60
+ np.savez_compressed(output, ensemble=ensemble, ensemble_mean=ensemble.mean(0),
61
+ ensemble_std=ensemble.std(0), target=target, protocol=np.asarray(protocol),
62
+ data_source=np.asarray(data_source), checkpoint=np.asarray(str(checkpoint_path)))
63
+ print(f"saved={output} ensemble={ensemble.shape} batch_size={batch_size}")
 
 
 
 
 
 
 
 
 
64
 
65
 
66
  if __name__ == "__main__":
scripts/result.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import argparse
2
  import json
3
  from pathlib import Path
@@ -6,60 +8,56 @@ import matplotlib
6
  matplotlib.use("Agg")
7
  import matplotlib.pyplot as plt
8
  import numpy as np
 
9
 
10
-
11
- VARIABLES = ["t2m", "u10m", "v10m", "radar_reflectivity"]
12
 
13
 
14
  def crps_ensemble(ensemble, target):
15
- first = np.abs(ensemble - target[None]).mean(axis=0)
16
- sorted_ensemble = np.sort(ensemble, axis=0)
17
- members = ensemble.shape[0]
18
- weights = 2 * np.arange(1, members + 1) - members - 1
19
- second = (sorted_ensemble * weights[:, None, None, None, None]).sum(axis=0)
20
- return first - second / members**2
21
 
22
 
23
  def main():
24
- parser = argparse.ArgumentParser(description="Evaluate CorrDiff predictions")
25
- parser.add_argument("--prediction", default="result/output/predictions.npz")
26
- parser.add_argument("--target", default="data/era5_corrdiff.npz")
27
- parser.add_argument("--output-dir", default="result/output")
28
  args = parser.parse_args()
29
-
30
- predictions = np.load(args.prediction)
31
- ensemble = predictions["ensemble"]
32
- mean = predictions["ensemble_mean"]
33
- std = predictions["ensemble_std"]
34
- target = predictions["target"] if "target" in predictions else np.load(args.target)["target"]
35
-
36
- output = Path(args.output_dir)
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  output.mkdir(parents=True, exist_ok=True)
38
- mae = np.abs(mean - target).mean(axis=(0, 2, 3))
39
- crps = crps_ensemble(ensemble, target).mean(axis=(0, 2, 3))
40
- metrics = {
41
- name: {"mae": float(mae[channel]), "crps": float(crps[channel])}
42
- for channel, name in enumerate(VARIABLES)
43
- }
44
- (output / "metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")
45
-
46
- figure, axes = plt.subplots(4, 3, figsize=(12, 14))
47
- for channel, name in enumerate(VARIABLES):
48
- axes[channel, 0].imshow(mean[0, channel], cmap="viridis")
49
- axes[channel, 0].set_title(f"{name}: ensemble mean")
50
- axes[channel, 1].imshow(std[0, channel], cmap="magma")
51
- axes[channel, 1].set_title(f"{name}: ensemble std")
52
- axes[channel, 2].imshow(target[0, channel], cmap="viridis")
53
- axes[channel, 2].set_title(f"{name}: target")
54
- for axis in axes[channel]:
55
- axis.set_xticks([])
56
- axis.set_yticks([])
57
- figure.tight_layout()
58
- figure.savefig(output / "prediction_comparison.png", dpi=150)
59
- plt.close(figure)
60
-
61
- print(json.dumps(metrics, indent=2))
62
- print(f"saved evaluation outputs to: {output}")
63
 
64
 
65
  if __name__ == "__main__":
 
1
+ """Compute deterministic and probabilistic CorrDiff metrics and plots."""
2
+
3
  import argparse
4
  import json
5
  from pathlib import Path
 
8
  matplotlib.use("Agg")
9
  import matplotlib.pyplot as plt
10
  import numpy as np
11
+ import yaml
12
 
13
+ ROOT = Path(__file__).resolve().parents[1]
 
14
 
15
 
16
  def crps_ensemble(ensemble, target):
17
+ first = np.abs(ensemble - target[None]).mean(0)
18
+ sorted_members = np.sort(ensemble, axis=0)
19
+ m = ensemble.shape[0]
20
+ weights = (2 * np.arange(1, m + 1) - m - 1).reshape(m, 1, 1, 1, 1)
21
+ return first - (sorted_members * weights).sum(0) / m**2
 
22
 
23
 
24
  def main():
25
+ parser = argparse.ArgumentParser()
26
+ parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
 
 
27
  args = parser.parse_args()
28
+ config = yaml.safe_load(Path(args.config).read_text(encoding="utf-8"))
29
+ archive = np.load(ROOT / config["paths"]["predictions"])
30
+ if "protocol" not in archive or archive["protocol"].ndim != 0 or str(archive["protocol"].item()) != config["data"]["protocol"]:
31
+ raise ValueError("Prediction protocol does not match the configured protocol")
32
+ if "data_source" not in archive or archive["data_source"].ndim != 0 or not str(archive["data_source"].item()):
33
+ raise ValueError("Prediction data_source must be a non-empty scalar")
34
+ protocol = str(archive["protocol"].item())
35
+ data_source = str(archive["data_source"].item())
36
+ ensemble, target = archive["ensemble"], archive["target"]
37
+ mean, spread = ensemble.mean(0), ensemble.std(0)
38
+ axes = (0, 2, 3)
39
+ mae = np.abs(mean - target).mean(axis=axes)
40
+ rmse = np.sqrt(((mean - target) ** 2).mean(axis=axes))
41
+ crps = crps_ensemble(ensemble, target).mean(axis=axes)
42
+ spread_value = spread.mean(axis=axes)
43
+ names = config["data"]["target_variables"]
44
+ metrics = {name: {"mae": float(mae[i]), "rmse": float(rmse[i]), "crps": float(crps[i]),
45
+ "ensemble_spread": float(spread_value[i])} for i, name in enumerate(names)}
46
+ metrics["aggregate"] = {key: float(np.mean([metrics[n][key] for n in names]))
47
+ for key in ("mae", "rmse", "crps", "ensemble_spread")}
48
+ output = ROOT / config["paths"]["evaluation_dir"]
49
  output.mkdir(parents=True, exist_ok=True)
50
+ payload = {"metrics": metrics, "protocol": protocol, "data_source": data_source}
51
+ (output / "metrics.json").write_text(json.dumps(payload, indent=2) + "\n")
52
+ figure, plot_axes = plt.subplots(len(names), 4, figsize=(13, 3 * len(names)))
53
+ for channel, name in enumerate(names):
54
+ fields = (target[0, channel], mean[0, channel], spread[0, channel], mean[0, channel] - target[0, channel])
55
+ titles = ("target", "ensemble mean", "ensemble spread", "mean error")
56
+ for axis, field, title in zip(plot_axes[channel], fields, titles):
57
+ axis.imshow(field, cmap="coolwarm" if title == "mean error" else "viridis")
58
+ axis.set_title(f"{name}: {title}"); axis.axis("off")
59
+ figure.tight_layout(); figure.savefig(output / "ensemble_diagnostics.png", dpi=120); plt.close(figure)
60
+ print(json.dumps(payload, indent=2)); print(f"evaluation={output}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
 
63
  if __name__ == "__main__":
scripts/train.py CHANGED
@@ -1,74 +1,164 @@
 
 
1
  import argparse
 
 
 
 
 
2
  from pathlib import Path
 
3
  import numpy as np
4
  import torch
 
 
5
  from torch.nn import functional as F
 
 
6
 
7
- import sys
8
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
9
  from model.corrdiff import CorrDiff
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def main():
13
- parser = argparse.ArgumentParser(description="Train CorrDiff regression and diffusion stages")
14
- parser.add_argument("--data", default="data/era5_corrdiff.npz")
15
- parser.add_argument("--steps", type=int, default=2)
16
- parser.add_argument("--checkpoint-dir", default="data/checkpoints")
17
  args = parser.parse_args()
18
- data = np.load(args.data)
19
- coarse = torch.from_numpy(data["input"])
20
- target = torch.from_numpy(data["target"])
21
- model = CorrDiff()
22
- regression_optimizer = torch.optim.Adam(model.regression.parameters(), lr=2e-4)
23
- diffusion_optimizer = torch.optim.Adam(model.diffusion.parameters(), lr=2e-4)
24
- model.train()
25
- regression_losses = []
26
- diffusion_losses = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- # Stage 1: learn the conditional mean.
29
- for step in range(args.steps):
30
- mean = model.mean(coarse)
31
- regression_loss = F.mse_loss(mean, target)
32
- regression_optimizer.zero_grad()
33
- regression_loss.backward()
34
- regression_optimizer.step()
35
- regression_losses.append(regression_loss.item())
36
- print(f"regression step={step + 1} loss={regression_loss.item():.6f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
- # Stage 2: freeze the mean model and learn its stochastic residual.
39
- model.regression.eval()
40
- for parameter in model.regression.parameters():
41
  parameter.requires_grad_(False)
42
- for step in range(args.steps):
43
- with torch.no_grad():
44
- mean = model.mean(coarse)
45
- residual = target - mean
46
- sigma = torch.rand(coarse.shape[0]).clamp_min(0.01)
47
- noisy = residual + sigma[:, None, None, None] * torch.randn_like(residual)
48
- predicted = model.denoise(noisy, coarse, mean, sigma)
49
- diffusion_loss = F.mse_loss(predicted, residual)
50
- diffusion_optimizer.zero_grad()
51
- diffusion_loss.backward()
52
- diffusion_optimizer.step()
53
- diffusion_losses.append(diffusion_loss.item())
54
- print(f"diffusion step={step + 1} loss={diffusion_loss.item():.6f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
- checkpoint_dir = Path(args.checkpoint_dir)
57
- checkpoint_dir.mkdir(parents=True, exist_ok=True)
58
- torch.save(
59
- {"model": model.regression.state_dict(), "format": "corrdiff-regression-v1"},
60
- checkpoint_dir / "regression_model.pth",
61
- )
62
- torch.save(
63
- {"model": model.diffusion.state_dict(), "format": "corrdiff-diffusion-v1"},
64
- checkpoint_dir / "diffusion_model.pth",
65
- )
66
- np.savez(
67
- checkpoint_dir / "training_history.npz",
68
- regression_loss=np.asarray(regression_losses, dtype="float32"),
69
- diffusion_loss=np.asarray(diffusion_losses, dtype="float32"),
70
- )
71
- print(f"saved checkpoints to: {checkpoint_dir}")
72
 
73
 
74
  if __name__ == "__main__":
 
1
+ """Train CorrDiff's conditional mean, then its frozen-mean residual EDM."""
2
+
3
  import argparse
4
+ import json
5
+ import os
6
+ import random
7
+ import sys
8
+ from contextlib import nullcontext
9
  from pathlib import Path
10
+
11
  import numpy as np
12
  import torch
13
+ import yaml
14
+ from torch import distributed as dist
15
  from torch.nn import functional as F
16
+ from torch.nn.parallel import DistributedDataParallel as DDP
17
+ from torch.utils.data import DataLoader, DistributedSampler, TensorDataset
18
 
19
+ ROOT = Path(__file__).resolve().parents[1]
20
+ sys.path.insert(0, str(ROOT))
21
  from model.corrdiff import CorrDiff
22
 
23
 
24
+ def scalar(archive, key, default=None):
25
+ if key not in archive:
26
+ if default is not None:
27
+ return default
28
+ raise ValueError(f"NPZ is missing required metadata: {key}")
29
+ value = archive[key]
30
+ if value.ndim != 0:
31
+ raise ValueError(f"NPZ metadata {key} must be a scalar")
32
+ return str(value.item())
33
+
34
+
35
+ def reduced_average(total, count, device, distributed):
36
+ values = torch.tensor([total, count], dtype=torch.float64, device=device)
37
+ if distributed:
38
+ dist.all_reduce(values, op=dist.ReduceOp.SUM)
39
+ if values[1].item() == 0:
40
+ raise RuntimeError("Training stage processed no batches")
41
+ return (values[0] / values[1]).item()
42
+
43
+
44
  def main():
45
+ parser = argparse.ArgumentParser()
46
+ parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
 
 
47
  args = parser.parse_args()
48
+ config = yaml.safe_load(Path(args.config).read_text(encoding="utf-8"))
49
+ global_rank = int(os.getenv("RANK", 0))
50
+ local_rank = int(os.getenv("LOCAL_RANK", 0))
51
+ world = int(os.getenv("WORLD_SIZE", 1))
52
+ distributed = world > 1
53
+ if distributed:
54
+ dist.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
55
+ use_cuda = torch.cuda.is_available() and config["runtime"]["device"] != "cpu"
56
+ device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu")
57
+ if use_cuda:
58
+ torch.cuda.set_device(local_rank)
59
+ seed = config["seed"] + global_rank
60
+ random.seed(seed)
61
+ np.random.seed(seed)
62
+ torch.manual_seed(seed)
63
+
64
+ archive = np.load(ROOT / config["data"]["path"])
65
+ protocol = scalar(archive, "protocol")
66
+ data_source = scalar(archive, "data_source")
67
+ if protocol != config["data"]["protocol"]:
68
+ raise ValueError(f"Expected protocol {config['data']['protocol']}, got {protocol}")
69
+ if not data_source:
70
+ raise ValueError("data_source must be a non-empty scalar")
71
+ coarse = torch.from_numpy(archive[config["data"]["input_key"]])
72
+ target = torch.from_numpy(archive[config["data"]["target_key"]])
73
+ if coarse.ndim != 4 or target.ndim != 4:
74
+ raise ValueError("CorrDiff input and target must be NCHW tensors")
75
+ if len(coarse) != len(target) or tuple(coarse.shape[1:]) != tuple(config["data"]["input_shape"]) or tuple(target.shape[1:]) != tuple(config["data"]["target_shape"]):
76
+ raise ValueError("NPZ tensor shapes do not match config")
77
+ dataset = TensorDataset(coarse, target)
78
+ sampler = DistributedSampler(dataset, shuffle=True) if distributed else None
79
+ loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], sampler=sampler,
80
+ shuffle=sampler is None, num_workers=config["training"]["num_workers"])
81
+ model = CorrDiff(**config["model"]).to(device)
82
+ if distributed:
83
+ model = DDP(model, device_ids=[local_rank] if use_cuda else None,
84
+ find_unused_parameters=True)
85
+ base = model.module if distributed else model
86
+ reg_opt = torch.optim.AdamW(base.regression.parameters(), lr=config["training"]["learning_rate"])
87
+ diff_opt = torch.optim.AdamW(base.diffusion.parameters(), lr=config["training"]["learning_rate"])
88
+ amp = bool(config["training"]["amp"] and use_cuda)
89
+ scaler = torch.amp.GradScaler("cuda", enabled=amp)
90
+ autocast = (lambda: torch.amp.autocast("cuda", enabled=True)) if amp else nullcontext
91
+ history = []
92
 
93
+ # Stage 1 is completed in full before any residual-EDM update occurs.
94
+ for epoch in range(config["training"]["regression_epochs"]):
95
+ if sampler is not None:
96
+ sampler.set_epoch(epoch)
97
+ model.train()
98
+ total = count = 0
99
+ for batch_index, (coarse_batch, target_batch) in enumerate(loader):
100
+ coarse_batch, target_batch = coarse_batch.to(device), target_batch.to(device)
101
+ reg_opt.zero_grad(set_to_none=True)
102
+ with autocast():
103
+ loss = F.mse_loss(model(coarse_batch, mode="mean"), target_batch)
104
+ scaler.scale(loss).backward()
105
+ scaler.step(reg_opt)
106
+ scaler.update()
107
+ total += loss.item()
108
+ count += 1
109
+ if batch_index + 1 >= config["training"]["max_batches_per_epoch"]:
110
+ break
111
+ value = reduced_average(total, count, device, distributed)
112
+ record = {"stage": "regression", "epoch": epoch + 1, "regression_mse": value}
113
+ history.append(record)
114
+ if global_rank == 0:
115
+ print(json.dumps(record))
116
 
117
+ base.regression.eval()
118
+ for parameter in base.regression.parameters():
 
119
  parameter.requires_grad_(False)
120
+ for epoch in range(config["training"]["diffusion_epochs"]):
121
+ if sampler is not None:
122
+ sampler.set_epoch(config["training"]["regression_epochs"] + epoch)
123
+ base.diffusion.train()
124
+ total = count = 0
125
+ for batch_index, (coarse_batch, target_batch) in enumerate(loader):
126
+ coarse_batch, target_batch = coarse_batch.to(device), target_batch.to(device)
127
+ with torch.no_grad():
128
+ mean = base.mean(coarse_batch)
129
+ residual = target_batch - mean
130
+ sigma = (torch.randn(len(coarse_batch), device=device) * config["training"]["p_std"] + config["training"]["p_mean"]).exp()
131
+ noisy = residual + sigma[:, None, None, None] * torch.randn_like(residual)
132
+ diff_opt.zero_grad(set_to_none=True)
133
+ with autocast():
134
+ denoised = model(coarse_batch, mode="denoise", mean=mean, noisy=noisy, sigma=sigma)
135
+ weight = (sigma.square() + base.sigma_data**2) / (sigma * base.sigma_data).square()
136
+ loss = (weight[:, None, None, None] * (denoised - residual).square()).mean()
137
+ scaler.scale(loss).backward()
138
+ scaler.step(diff_opt)
139
+ scaler.update()
140
+ total += loss.item()
141
+ count += 1
142
+ if batch_index + 1 >= config["training"]["max_batches_per_epoch"]:
143
+ break
144
+ value = reduced_average(total, count, device, distributed)
145
+ record = {"stage": "diffusion", "epoch": epoch + 1, "edm_loss": value}
146
+ history.append(record)
147
+ if global_rank == 0:
148
+ print(json.dumps(record))
149
 
150
+ if global_rank == 0:
151
+ checkpoint = ROOT / config["paths"]["checkpoint"]
152
+ checkpoint.parent.mkdir(parents=True, exist_ok=True)
153
+ torch.save({"model": base.state_dict(), "config": config, "format": "corrdiff-edm-v3",
154
+ "protocol": protocol, "data_source": data_source}, checkpoint)
155
+ metrics = ROOT / config["paths"]["training_metrics"]
156
+ metrics.parent.mkdir(parents=True, exist_ok=True)
157
+ metrics.write_text(json.dumps({"history": history, "protocol": protocol,
158
+ "data_source": data_source}, indent=2) + "\n")
159
+ print(f"checkpoint={checkpoint}")
160
+ if distributed:
161
+ dist.destroy_process_group()
 
 
 
 
162
 
163
 
164
  if __name__ == "__main__":