zhangrenchao commited on
Commit
e0a6aa0
·
verified ·
1 Parent(s): a31b7c5

Publish EchoCast-3D reproduction

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
 
 
 
 
 
 
 
 
 
 
 
 
conf/config.yaml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ data:
3
+ format_version: echocast_3d_v1
4
+ frames: 8
5
+ history: 3
6
+ target: 5
7
+ interval_minutes: 6
8
+ elevations_deg: [0.5, 1.5, 2.4, 3.4]
9
+ azimuth_counts: [366, 366, 363, 363]
10
+ range_bins: [180, 180, 120, 120]
11
+ storage_shape: [8, 4, 366, 180]
12
+ missing_ratio: 0.15
13
+ dbz_min: 0.0
14
+ dbz_max: 70.0
15
+ train_samples: 1
16
+ validation_samples: 1
17
+ model:
18
+ patch_size: [3, 3]
19
+ hidden_dim: 32
20
+ depth: 2
21
+ heads: 4
22
+ latent_tokens: 16
23
+ mask_ratio: 0.75
24
+ paper_token_count: 24400
25
+ diffusion:
26
+ steps: 4
27
+ beta_start: 0.0001
28
+ beta_end: 0.02
29
+ ensemble_size: 3
30
+ training:
31
+ epochs: 1
32
+ batch_size: 1
33
+ learning_rate: 0.00001
34
+ weight_decay: 0.01
35
+ reconstruction_weight: 0.1
36
+ runtime:
37
+ device: cpu
38
+ paths:
39
+ checkpoint: result/checkpoints/echocast_3d.pt
40
+ training_metrics: result/training/metrics.json
41
+ predictions: result/output/predictions.npz
42
+ evaluation: result/evaluation/metrics.json
43
+ figure: result/evaluation/comparison.png
44
+ evaluation:
45
+ thresholds_dbz: [20.0, 30.0, 40.0]
46
+ paper_model:
47
+ hidden_dim: 1024
48
+ depth: 12
49
+ heads: 16
50
+ batch_size: 48
51
+ epochs: 200
52
+ learning_rate: 0.00001
53
+ ensemble_size: 30
config.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"model_name":"EchoCast-3D","model_type":"echocast_3d","architectures":["EchoCast3D"],"framework":"PyTorch","domain":"weather","task":"probabilistic-3d-radar-nowcasting","implementation":{"entry_point":"model/echocast_3d.py","scope":"core-method full-radar-geometry reduced-model engineering reproduction","train_script":"scripts/train.py","inference_script":"scripts/inference.py","evaluation_script":"scripts/result.py","synthetic_data_script":"scripts/fake_data.py"},"architecture":{"storage_shape":["B",8,4,366,180],"history_frames":3,"target_frames":5,"patch_size":[3,3],"paper_hidden_dim":1024,"paper_depth":12,"paper_heads":16,"paper_tokens":24400},"data":{"dataset":"CMA volumetric radar","format_version":"echocast_3d_v1","azimuth_counts":[366,366,363,363],"range_bins":[180,180,120,120],"interval_minutes":6,"synthetic":true},"configuration_sources":["conf/config.yaml","model/echocast_3d.py","scripts/fake_data.py","scripts/train.py","scripts/inference.py","scripts/result.py"]}
model/echocast_3d.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MaskDiT-style denoiser for native packed radar wedges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import json
7
+ from pathlib import Path
8
+ import numpy as np
9
+ import yaml
10
+
11
+ import torch
12
+ from torch import nn
13
+ from torch.nn import functional as F
14
+ from torch.utils.data import Dataset
15
+
16
+
17
+ def load_config(path):
18
+ with open(path, encoding="utf-8") as handle:
19
+ return yaml.safe_load(handle)
20
+
21
+
22
+ def geometry_mask(config):
23
+ data = config["data"]
24
+ mask = np.zeros((data["frames"], 4, 366, 180), dtype=bool)
25
+ for elevation, (azimuths, ranges) in enumerate(zip(data["azimuth_counts"], data["range_bins"])):
26
+ mask[:, elevation, :azimuths, :ranges] = True
27
+ return mask
28
+
29
+
30
+ def make_sample(config, seed):
31
+ rng = np.random.default_rng(seed); data = config["data"]
32
+ volume = np.zeros(tuple(data["storage_shape"]), dtype=np.float32)
33
+ phase = rng.uniform(0, 2*np.pi)
34
+ for t in range(data["frames"]):
35
+ for e, (na, nr) in enumerate(zip(data["azimuth_counts"], data["range_bins"])):
36
+ az=np.arange(na,dtype=np.float32)[:,None]; radius=np.arange(nr,dtype=np.float32)[None]
37
+ center=(80+12*t+8*e+7*np.sin(phase+t/3))%na; center_r=min(nr-15,42+4*t-5*e)
38
+ da=np.minimum(abs(az-center),na-abs(az-center)); core=np.exp(-.5*((da/(14+2*e))**2+((radius-center_r)/18)**2))
39
+ volume[t,e,:na,:nr]=np.clip(58*np.exp(-.22*e)*core+2*np.sin(radius/8+az/30+phase+t/2),0,70)
40
+ valid=geometry_mask(config); observed=valid.copy()
41
+ for t in range(data["history"]):
42
+ for e,(na,nr) in enumerate(zip(data["azimuth_counts"],data["range_bins"])):
43
+ width=max(1,int(round(na*data["missing_ratio"]))); start=int(rng.integers(0,na-width+1)); observed[t,e,start:start+width,:nr]=False
44
+ packed=volume.copy();packed[~observed]=0
45
+ return {"values":packed,"truth":volume,"validity":valid,"observed":observed}
46
+
47
+
48
+ class PackedRadarDataset(Dataset):
49
+ def __init__(self,directory,split):
50
+ self.files=sorted(Path(directory).glob(f"{split}_*.npz"))
51
+ if not self.files: raise FileNotFoundError(f"no {split} samples; run fake_data.py")
52
+ def __len__(self): return len(self.files)
53
+ def __getitem__(self,index):
54
+ with np.load(self.files[index]) as sample:
55
+ return {key:torch.from_numpy(sample[key].copy()) for key in ("values","truth","validity","observed")}
56
+
57
+
58
+ class DiffusionSchedule:
59
+ def __init__(self,steps,beta_start,beta_end,device):
60
+ self.steps=steps;self.betas=torch.linspace(beta_start,beta_end,steps,device=device);self.alphas=1-self.betas;self.alpha_bars=torch.cumprod(self.alphas,0)
61
+ def add_noise(self,clean,timestep):
62
+ noise=torch.randn_like(clean);alpha=self.alpha_bars[timestep].view(-1,1,1,1,1);return alpha.sqrt()*clean+(1-alpha).sqrt()*noise,noise
63
+ @torch.no_grad()
64
+ def sample(self,model,history,observed):
65
+ target=torch.randn(history.shape[0],5,*history.shape[2:],device=history.device)
66
+ for step in reversed(range(self.steps)):
67
+ t=torch.full((history.shape[0],),step,device=history.device,dtype=torch.long);clean=model(history,target,observed,t,None)
68
+ target=clean if step==0 else self.alpha_bars[step-1].sqrt()*clean+(1-self.alpha_bars[step-1]).sqrt()*torch.randn_like(clean)
69
+ return target
70
+
71
+
72
+ def evaluate_ensemble(ensemble,truth,valid,thresholds):
73
+ mean=ensemble.mean(0);error=mean-truth;result={"mae":float(abs(error)[valid].mean()),"rmse":float(np.sqrt((error**2)[valid].mean()))}
74
+ result["crps"]=float(abs(ensemble-truth[None])[:,valid].mean()-.5*abs(ensemble[:,None]-ensemble[None,:])[:,:,valid].mean())
75
+ result["categorical"]={}
76
+ for threshold in thresholds:
77
+ forecast,observed=mean>=threshold,truth>=threshold;hits=int((forecast&observed&valid).sum());miss=int((~forecast&observed&valid).sum());false=int((forecast&~observed&valid).sum())
78
+ result["categorical"][str(threshold)]={"csi":hits/max(hits+miss+false,1),"far":false/max(hits+false,1),"pod":hits/max(hits+miss,1)}
79
+ result["coverage_ratio"]=[[float(np.isfinite(ensemble).all(0)[lead,e][valid[lead,e]].mean()) for e in range(4)] for lead in range(5)]
80
+ return result
81
+
82
+
83
+ class DiTBlock(nn.Module):
84
+ """Linear-cost latent attention block suitable for 24k native wedge tokens."""
85
+
86
+ def __init__(self, hidden: int, heads: int, latent_tokens: int):
87
+ super().__init__()
88
+ self.latents = nn.Parameter(torch.randn(1, latent_tokens, hidden) / math.sqrt(hidden))
89
+ self.read = nn.MultiheadAttention(hidden, heads, batch_first=True)
90
+ self.write = nn.MultiheadAttention(hidden, heads, batch_first=True)
91
+ self.norm1 = nn.LayerNorm(hidden)
92
+ self.norm2 = nn.LayerNorm(hidden)
93
+ self.mlp = nn.Sequential(nn.Linear(hidden, hidden * 4), nn.GELU(), nn.Linear(hidden * 4, hidden))
94
+
95
+ def forward(self, tokens: torch.Tensor, conditioning: torch.Tensor) -> torch.Tensor:
96
+ latent = self.latents.expand(tokens.shape[0], -1, -1) + conditioning[:, None]
97
+ latent = latent + self.read(latent, self.norm1(tokens), self.norm1(tokens), need_weights=False)[0]
98
+ tokens = tokens + self.write(self.norm1(tokens), latent, latent, need_weights=False)[0]
99
+ return tokens + self.mlp(self.norm2(tokens + conditioning[:, None]))
100
+
101
+
102
+ class EchoCast3D(nn.Module):
103
+ def __init__(self, config: dict):
104
+ super().__init__()
105
+ self.config = config
106
+ model = config["model"]
107
+ self.patch = tuple(model["patch_size"])
108
+ patch_area = self.patch[0] * self.patch[1]
109
+ self.embed = nn.Linear(config["data"]["frames"] * patch_area, model["hidden_dim"])
110
+ self.time_embed = nn.Embedding(config["diffusion"]["steps"], model["hidden_dim"])
111
+ self.elevation_embed = nn.Embedding(4, model["hidden_dim"])
112
+ self.mask_token = nn.Parameter(torch.zeros(1, 1, model["hidden_dim"]))
113
+ self.encoder = nn.ModuleList(
114
+ [DiTBlock(model["hidden_dim"], model["heads"], model["latent_tokens"]) for _ in range(model["depth"])]
115
+ )
116
+ self.decoder = nn.ModuleList(
117
+ [DiTBlock(model["hidden_dim"], model["heads"], model["latent_tokens"]) for _ in range(model["depth"])]
118
+ )
119
+ self.head = nn.Linear(model["hidden_dim"], config["data"]["target"] * patch_area)
120
+ self.layouts = []
121
+ offset = 0
122
+ for e, (na, nr) in enumerate(zip(config["data"]["azimuth_counts"], config["data"]["range_bins"])):
123
+ rows, cols = na // self.patch[0], nr // self.patch[1]
124
+ count = rows * cols
125
+ self.layouts.append((e, na, nr, rows, cols, offset, offset + count))
126
+ offset += count
127
+ self.token_count = offset
128
+
129
+ def patchify(self, volume: torch.Tensor) -> torch.Tensor:
130
+ parts = []
131
+ for e, na, nr, _, _, _, _ in self.layouts:
132
+ wedge = volume[:, :, e, :na, :nr]
133
+ patches = F.unfold(wedge, kernel_size=self.patch, stride=self.patch).transpose(1, 2)
134
+ parts.append(patches)
135
+ return torch.cat(parts, dim=1)
136
+
137
+ def token_validity(self, validity: torch.Tensor) -> torch.Tensor:
138
+ parts = []
139
+ for e, na, nr, _, _, _, _ in self.layouts:
140
+ wedge = validity[:, :, e, :na, :nr].float()
141
+ patch = F.unfold(wedge, kernel_size=self.patch, stride=self.patch)
142
+ parts.append((patch > 0.5).all(dim=1))
143
+ return torch.cat(parts, dim=1)
144
+
145
+ def unpatchify(self, patches: torch.Tensor) -> torch.Tensor:
146
+ batch = patches.shape[0]
147
+ output = patches.new_zeros((batch, 5, 4, 366, 180))
148
+ for e, na, nr, rows, cols, start, end in self.layouts:
149
+ wedge = F.fold(
150
+ patches[:, start:end].transpose(1, 2),
151
+ output_size=(na, nr),
152
+ kernel_size=self.patch,
153
+ stride=self.patch,
154
+ )
155
+ output[:, :, e, :na, :nr] = wedge
156
+ return output
157
+
158
+ def forward(
159
+ self,
160
+ history: torch.Tensor,
161
+ noisy_target: torch.Tensor,
162
+ observed: torch.Tensor,
163
+ timestep: torch.Tensor,
164
+ token_mask: torch.Tensor | None,
165
+ ) -> torch.Tensor:
166
+ full = torch.cat((history, noisy_target), dim=1)
167
+ tokens = self.embed(self.patchify(full))
168
+ elevation_ids = torch.cat(
169
+ [torch.full((end - start,), e, device=full.device) for e, _, _, _, _, start, end in self.layouts]
170
+ )
171
+ tokens = tokens + self.elevation_embed(elevation_ids)[None]
172
+ if token_mask is not None:
173
+ tokens = torch.where(token_mask[..., None], self.mask_token.expand_as(tokens), tokens)
174
+ condition = self.time_embed(timestep)
175
+ for block in self.encoder:
176
+ tokens = block(tokens, condition)
177
+ for block in self.decoder:
178
+ tokens = block(tokens, condition)
179
+ return self.unpatchify(self.head(tokens))
180
+
181
+
182
+ def masked_objective(
183
+ model: EchoCast3D,
184
+ prediction: torch.Tensor,
185
+ truth: torch.Tensor,
186
+ validity: torch.Tensor,
187
+ token_mask: torch.Tensor,
188
+ reconstruction_weight: float,
189
+ ) -> tuple[torch.Tensor, dict[str, float]]:
190
+ target_valid = validity[:, 3:]
191
+ token_pixels = token_mask[:, :, None].expand(-1, -1, 5 * 9)
192
+ masked_pixels = model.unpatchify(token_pixels.float()).bool()
193
+ squared = (prediction - truth) ** 2
194
+ unmasked = target_valid & ~masked_pixels
195
+ masked = target_valid & masked_pixels
196
+ score = squared[unmasked].mean()
197
+ reconstruction = squared[masked].mean()
198
+ loss = score + reconstruction_weight * reconstruction
199
+ return loss, {"score": score.item(), "reconstruction": reconstruction.item()}
scripts/fake_data.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create realistic packed-wedge synthetic data when licensed observations are unavailable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import sys
10
+ ROOT=Path(__file__).resolve().parents[1];sys.path.insert(0,str(ROOT))
11
+
12
+ from model.echocast_3d import load_config, make_sample
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--config", default="conf/config.yaml")
18
+ parser.add_argument("--output", default="data")
19
+ args = parser.parse_args()
20
+ config = load_config(ROOT / args.config)
21
+ output = ROOT / args.output
22
+ output.mkdir(parents=True, exist_ok=True)
23
+ for split, count in (("train", config["data"]["train_samples"]), ("validation", config["data"]["validation_samples"])):
24
+ for index in range(count):
25
+ sample = make_sample(config, config["seed"] + index + (10000 if split == "validation" else 0))
26
+ path = output / f"{split}_{index:04d}.npz"
27
+ np.savez(path, **sample)
28
+ print(f"wrote {path} ({path.stat().st_size / 1024**2:.2f} MiB)")
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate native packed-wedge ensemble forecasts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import torch
10
+ import sys
11
+ ROOT=Path(__file__).resolve().parents[1];sys.path.insert(0,str(ROOT))
12
+
13
+ from model.echocast_3d import PackedRadarDataset, load_config, DiffusionSchedule, EchoCast3D
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser()
18
+ parser.add_argument("--config", default="conf/config.yaml")
19
+ parser.add_argument("--data", default="data")
20
+ parser.add_argument("--checkpoint", default="result/checkpoints/echocast_3d.pt")
21
+ parser.add_argument("--output", default="result/output/predictions.npz")
22
+ args = parser.parse_args()
23
+ config = load_config(ROOT / args.config)
24
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
25
+ model = EchoCast3D(config).to(device)
26
+ checkpoint = torch.load(ROOT / args.checkpoint, map_location=device, weights_only=False)
27
+ model.load_state_dict(checkpoint["model"])
28
+ model.eval()
29
+ sample = PackedRadarDataset(ROOT / args.data, "validation")[0]
30
+ history = sample["values"][None, :3].float().to(device)
31
+ observed = sample["observed"][None, :3].bool().to(device)
32
+ schedule = DiffusionSchedule(device=device, **{k: config["diffusion"][k] for k in ("steps", "beta_start", "beta_end")})
33
+ ensemble = []
34
+ for member in range(config["diffusion"]["ensemble_size"]):
35
+ torch.manual_seed(config["seed"] + member)
36
+ ensemble.append(schedule.sample(model, history, observed)[0].cpu().numpy())
37
+ output = ROOT / args.output
38
+ output.parent.mkdir(parents=True, exist_ok=True)
39
+ np.savez(
40
+ output,
41
+ ensemble=np.stack(ensemble),
42
+ truth=sample["truth"][3:].numpy(),
43
+ validity=sample["validity"][3:].numpy(),
44
+ observed_history=sample["observed"][:3].numpy(),
45
+ )
46
+ print(f"saved {output}: ensemble={len(ensemble)}, steps={schedule.steps}")
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
scripts/result.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate the ensemble and render a 2D composite-reflectivity summary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import matplotlib.pyplot as plt
10
+ import numpy as np
11
+ import sys
12
+ ROOT=Path(__file__).resolve().parents[1];sys.path.insert(0,str(ROOT))
13
+
14
+ from model.echocast_3d import load_config, evaluate_ensemble
15
+
16
+
17
+ def main() -> None:
18
+ parser = argparse.ArgumentParser()
19
+ parser.add_argument("--config", default="conf/config.yaml")
20
+ parser.add_argument("--predictions", default="result/output/predictions.npz")
21
+ parser.add_argument("--metrics", default="result/evaluation/metrics.json")
22
+ parser.add_argument("--figure", default="result/evaluation/comparison.png")
23
+ args = parser.parse_args()
24
+ config = load_config(ROOT / args.config)
25
+ with np.load(ROOT / args.predictions) as data:
26
+ ensemble = data["ensemble"]
27
+ truth = data["truth"]
28
+ valid = data["validity"].astype(bool)
29
+ result = evaluate_ensemble(ensemble, truth, valid, config["evaluation"]["thresholds_dbz"])
30
+ metrics_path = ROOT / args.metrics
31
+ metrics_path.parent.mkdir(parents=True, exist_ok=True)
32
+ metrics_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
33
+ prediction = ensemble.mean(axis=0)
34
+ fig, axes = plt.subplots(2, 5, figsize=(15, 6), constrained_layout=True)
35
+ for lead in range(5):
36
+ for row, field in enumerate((truth, prediction)):
37
+ composite = np.where(valid[lead], field[lead], np.nan).max(axis=0)
38
+ image = axes[row, lead].imshow(composite.T, origin="lower", vmin=0, vmax=60, cmap="turbo", aspect="auto")
39
+ axes[row, lead].set_title(f"{'Truth' if row == 0 else 'Ensemble mean'} +{(lead + 1) * 6} min")
40
+ axes[row, lead].set_xlabel("azimuth index")
41
+ if lead == 0:
42
+ axes[row, lead].set_ylabel("range bin")
43
+ fig.colorbar(image, ax=axes, label="composite reflectivity (dBZ)", shrink=0.8)
44
+ figure_path=ROOT/args.figure;figure_path.parent.mkdir(parents=True,exist_ok=True);fig.savefig(figure_path,dpi=120)
45
+ plt.close(fig)
46
+ print(json.dumps(result, indent=2))
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
scripts/train.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DDP-capable EchoCast-3D training entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import torch
11
+ import torch.distributed as dist
12
+ from torch.nn.parallel import DistributedDataParallel
13
+ from torch.utils.data import DataLoader, DistributedSampler
14
+ import sys
15
+ ROOT=Path(__file__).resolve().parents[1];sys.path.insert(0,str(ROOT))
16
+
17
+ from model.echocast_3d import PackedRadarDataset, load_config, DiffusionSchedule, EchoCast3D, masked_objective
18
+
19
+
20
+ def main() -> None:
21
+ parser = argparse.ArgumentParser()
22
+ parser.add_argument("--config", default="conf/config.yaml")
23
+ parser.add_argument("--data", default="data")
24
+ parser.add_argument("--output", default="result/checkpoints/echocast_3d.pt")
25
+ args = parser.parse_args()
26
+ config = load_config(ROOT / args.config)
27
+ distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
28
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
29
+ if distributed:
30
+ dist.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
31
+ use_cuda=torch.cuda.is_available() and torch.cuda.device_count()>=int(os.environ.get("WORLD_SIZE","1")) and config["runtime"]["device"]!="cpu"
32
+ device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu")
33
+ if device.type == "cuda":
34
+ torch.cuda.set_device(device)
35
+ torch.manual_seed(config["seed"] + local_rank)
36
+ dataset = PackedRadarDataset(ROOT / args.data, "train")
37
+ sampler = DistributedSampler(dataset, shuffle=True) if distributed else None
38
+ loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], sampler=sampler, shuffle=sampler is None)
39
+ raw_model = EchoCast3D(config).to(device)
40
+ model = DistributedDataParallel(raw_model, device_ids=[local_rank]) if distributed else raw_model
41
+ optimizer = torch.optim.AdamW(
42
+ model.parameters(), lr=config["training"]["learning_rate"], weight_decay=config["training"]["weight_decay"]
43
+ )
44
+ schedule = DiffusionSchedule(device=device, **{k: config["diffusion"][k] for k in ("steps", "beta_start", "beta_end")})
45
+ for epoch in range(config["training"]["epochs"]):
46
+ if sampler:
47
+ sampler.set_epoch(epoch)
48
+ for batch in loader:
49
+ truth = batch["truth"].float().to(device)
50
+ values = batch["values"].float().to(device)
51
+ validity = batch["validity"].bool().to(device)
52
+ observed = batch["observed"].bool().to(device)
53
+ clean_target = truth[:, 3:]
54
+ timestep = torch.randint(schedule.steps, (truth.shape[0],), device=device)
55
+ noisy_target, _ = schedule.add_noise(clean_target, timestep)
56
+ token_mask = torch.rand(truth.shape[0], raw_model.token_count, device=device) < config["model"]["mask_ratio"]
57
+ prediction = model(values[:, :3], noisy_target, observed[:, :3], timestep, token_mask)
58
+ loss, parts = masked_objective(
59
+ raw_model, prediction, clean_target, validity, token_mask, config["training"]["reconstruction_weight"]
60
+ )
61
+ optimizer.zero_grad(set_to_none=True)
62
+ loss.backward()
63
+ optimizer.step()
64
+ if local_rank == 0:
65
+ print(f"epoch={epoch + 1} loss={loss.item():.5f} score={parts['score']:.5f} recon={parts['reconstruction']:.5f}")
66
+ if local_rank == 0:
67
+ output = ROOT / args.output
68
+ output.parent.mkdir(parents=True, exist_ok=True)
69
+ torch.save({"model": raw_model.state_dict(), "model_config": config["model"], "format_version": config["data"]["format_version"], "config": config}, output)
70
+ metrics=ROOT/config["paths"]["training_metrics"];metrics.parent.mkdir(parents=True,exist_ok=True);metrics.write_text(json.dumps({"epochs":config["training"]["epochs"],"loss":float(loss),"world_size":int(os.environ.get("WORLD_SIZE","1"))},indent=2))
71
+ print(f"saved {output}; geometry tokens={raw_model.token_count}, paper states {config['model']['paper_token_count']}")
72
+ if distributed:
73
+ dist.destroy_process_group()
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
weight/.gitkeep ADDED
File without changes