Publish EchoCast-3D reproduction
Browse files- .gitattributes +1 -34
- conf/config.yaml +53 -0
- config.json +1 -0
- model/echocast_3d.py +199 -0
- scripts/fake_data.py +32 -0
- scripts/inference.py +50 -0
- scripts/result.py +50 -0
- scripts/train.py +77 -0
- weight/.gitkeep +0 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,2 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|