anshchkse commited on
Commit
91325df
·
verified ·
1 Parent(s): 0489eab

Upload 4 files

Browse files
unified_graph_weather_dataloader.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # graph_weather_dataloader.py
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+ from typing import Optional, Tuple, List, Dict
5
+
6
+ import re
7
+ import json
8
+ import math
9
+ import numpy as np
10
+ import torch
11
+ from torch.utils.data import Dataset, DataLoader
12
+ import pytorch_lightning as pl
13
+
14
+
15
+ # ==========================
16
+ # Utils
17
+ # ==========================
18
+ def _parse_ym_from_name(p: Path):
19
+ m = re.search(r"(\d{4})-(\d{2})\.pt$", p.name)
20
+ if not m:
21
+ return None, None
22
+ return int(m.group(1)), int(m.group(2))
23
+
24
+
25
+ def _sincos_pos2d_nodes(lat_deg: np.ndarray, lon_deg: np.ndarray) -> np.ndarray:
26
+ """
27
+ По вузлах графа -> [4, N]: sinφ, cosφ, sin(λ·cosφ̄), cos(λ·cosφ̄).
28
+ φ̄ — середня широта домену (стабільно, як у твоєму build-скрипті).
29
+ """
30
+ lat = np.deg2rad(lat_deg.astype(np.float32, copy=False))
31
+ lon = np.deg2rad(lon_deg.astype(np.float32, copy=False))
32
+ lat0 = float(lat.mean())
33
+ lon_adj = lon * np.cos(lat0)
34
+ pos = np.stack([np.sin(lat), np.cos(lat), np.sin(lon_adj), np.cos(lon_adj)], axis=0)
35
+ return pos.astype(np.float32, copy=False)
36
+
37
+
38
+ def _load_graph_static(npz_path: Path):
39
+ """
40
+ Зчитує статичний мультискейл граф:
41
+ обов'язково: coord_lon[N], coord_lat[N], static_geo_mesh[N,4]
42
+ опційно: edge_index_horiz_base[2,Eh] або edge_index_horiz[2, E_total] + horiz_edge_ptr[L+1]
43
+ (fallback на edges_h_src/edges_h_dst або edges_horiz)
44
+ """
45
+ d = np.load(npz_path, allow_pickle=True)
46
+
47
+ # --- coords
48
+ lon = d["coord_lon"].astype(np.float32, copy=False)
49
+ lat = d["coord_lat"].astype(np.float32, copy=False)
50
+ N = int(lon.shape[0])
51
+
52
+ # --- static geo (4 канали, вже нормалізовані по зонах)
53
+ if "static_geo_mesh" not in d:
54
+ raise KeyError(f"{npz_path} must contain 'static_geo_mesh'")
55
+ static_geo = d["static_geo_mesh"].astype(np.float32, copy=False) # [N,4]
56
+ if static_geo.shape[0] != N:
57
+ raise ValueError("static_geo_mesh first dim must match coord arrays")
58
+
59
+ # --- pos2d: беремо з файла, якщо є; інакше — будуємо
60
+ if "static_pos2d_mesh" in d:
61
+ pos2d = d["static_pos2d_mesh"].astype(np.float32, copy=False).T # [4,N]
62
+ else:
63
+ pos2d = _sincos_pos2d_nodes(lat, lon) # [4,N]
64
+
65
+ # --- levels/meta (не обов'язково, але корисно)
66
+ levels = [str(x) for x in (d["levels"].tolist() if "levels" in d else [])]
67
+ level_offsets = d["level_offsets"].astype(np.int32, copy=False) if "level_offsets" in d else None
68
+ mesh_hash = None
69
+ if "mesh_hash" in d:
70
+ mh = d["mesh_hash"].item()
71
+ mesh_hash = (mh.decode() if isinstance(mh, (bytes, np.bytes_)) else str(mh))
72
+
73
+ # --- edge_index_base: кілька шляхів
74
+ edge_index = None
75
+
76
+ # 1) ідеально — вже готове поле
77
+ if "edge_index_horiz_base" in d:
78
+ ei = d["edge_index_horiz_base"].astype(np.int64, copy=False)
79
+ edge_index = torch.from_numpy(ei)
80
+
81
+ # 2) зібране по рівнях: edge_index_horiz + horiz_edge_ptr → беремо рівень 0
82
+ elif "edge_index_horiz" in d and "horiz_edge_ptr" in d:
83
+ e_all = d["edge_index_horiz"] # [2, E_total] або [E_total, 2]
84
+ if e_all.ndim == 2 and e_all.shape[0] == 2:
85
+ e_all = e_all
86
+ elif e_all.ndim == 2 and e_all.shape[1] == 2:
87
+ e_all = e_all.T
88
+ else:
89
+ raise ValueError("edge_index_horiz must be [2, E] or [E, 2]")
90
+
91
+ ptr = d["horiz_edge_ptr"].astype(np.int64, copy=False) # [L+1]
92
+ if ptr.size < 2:
93
+ raise ValueError("horiz_edge_ptr must have at least 2 entries")
94
+ e0 = e_all[:, int(ptr[0]):int(ptr[1])].astype(np.int64, copy=False) # slice рівня 0
95
+
96
+ # якщо чомусь індекси зсунуто — прибираємо офсет рівня 0
97
+ if level_offsets is not None and int(level_offsets[0]) != 0:
98
+ e0 = e0 - int(level_offsets[0])
99
+
100
+ if e0.min() < 0 or e0.max() >= N:
101
+ raise RuntimeError(f"edge_index_base out of range after slice: min={e0.min()}, max={e0.max()}, N={N}")
102
+
103
+ edge_index = torch.from_numpy(e0.copy())
104
+
105
+ # 3) старі ключі: edges_h_src/edges_h_dst або edges_horiz
106
+ elif "edges_h_src" in d and "edges_h_dst" in d:
107
+ src = torch.from_numpy(d["edges_h_src"].astype(np.int64, copy=False))
108
+ dst = torch.from_numpy(d["edges_h_dst"].astype(np.int64, copy=False))
109
+ edge_index = torch.stack([src, dst], dim=0)
110
+ elif "edges_horiz" in d:
111
+ eh = d["edges_horiz"]
112
+ if eh.ndim == 2 and eh.shape[1] == 2:
113
+ edge_index = torch.from_numpy(eh.astype(np.int64, copy=False)).t().contiguous()
114
+ elif eh.ndim == 2 and eh.shape[0] == 2:
115
+ edge_index = torch.from_numpy(eh.astype(np.int64, copy=False))
116
+
117
+ # --- упакуємо відповіді
118
+ meta = {
119
+ "N_mesh": N,
120
+ "levels": levels,
121
+ "level_offsets": level_offsets,
122
+ "mesh_hash": mesh_hash,
123
+ }
124
+ geo = static_geo.T # [4,N]
125
+ return {
126
+ "lon": lon, "lat": lat, "geo": geo, "pos2d": pos2d, # [4,N] і [4,N]
127
+ "edge_index_base": edge_index, # torch.Long[2,Eh] або None
128
+ "meta": meta,
129
+ }
130
+
131
+
132
+ # ==========================
133
+ # Dataset
134
+ # ==========================
135
+ class GraphShardDataset(Dataset):
136
+ """
137
+ Зчитує графові шарди і віддає семпли для GAT-моделі.
138
+
139
+ __getitem__ -> ((x_all, geo, pos2d), y_all)
140
+ x_all: [Tin, C, N]
141
+ y_all: [Tout, C, N]
142
+ geo: [G, N] (4)
143
+ pos2d: [P, N] (4)
144
+ """
145
+ def __init__(self,
146
+ shards_root: str,
147
+ years: range,
148
+ static_graph_npz: str = "./data/graph/graph_static_from_stats_vgeo_aligned.npz",
149
+ allowed_months: Optional[List[int]] = None, # None → всі 1..12
150
+ force_tin_tout: Optional[Tuple[int, int]] = None, # None → брати з шару
151
+ dtype_out: str = "float32",
152
+ shuffle_within_file: bool = True):
153
+ super().__init__()
154
+ self.root = Path(shards_root)
155
+ self.years = set(int(y) for y in years)
156
+ self.allowed_months = None if allowed_months is None else {int(m) for m in allowed_months}
157
+ self.force_tin_tout = force_tin_tout
158
+ assert dtype_out in {"float16", "float32"}
159
+ self.dtype_out = torch.float16 if dtype_out == "float16" else torch.float32
160
+ self.shuffle_within_file = bool(shuffle_within_file)
161
+
162
+ # ---- зчитуємо статичні фічі графа один раз ----
163
+ sg = _load_graph_static(Path(static_graph_npz))
164
+ self.geo = torch.from_numpy(sg["geo"].copy()) # [4,N]
165
+ self.pos2d = torch.from_numpy(sg["pos2d"].copy()) # [4,N]
166
+ self.N = int(sg["geo"].shape[1])
167
+ self.edge_index_base = sg["edge_index_base"] # може бути None
168
+ self.static_meta = sg["meta"]
169
+
170
+ # ---- зібрати список файлів і entries ----
171
+ files = []
172
+ for p in sorted(self.root.glob("graph_*.pt")):
173
+ y, m = _parse_ym_from_name(p)
174
+ if y is None:
175
+ continue
176
+ if (y in self.years) and (self.allowed_months is None or m in self.allowed_months):
177
+ files.append(p)
178
+ if not files:
179
+ raise FileNotFoundError(f"No graph_YYYY-MM.pt shards in {self.root}")
180
+
181
+ self.entries: List[Tuple[str, int]] = [] # (path, s_start)
182
+ self._file_meta: Dict[str, Dict] = {} # path -> {T,C,N,Tin,Tout,stride,S}
183
+ self._file_counts: Dict[str, int] = {}
184
+
185
+ for p in files:
186
+ d = torch.load(p, map_location="cpu")
187
+ values = d["values"] # [T,C,N]
188
+ T, C, N = values.shape
189
+ if N != self.N:
190
+ raise ValueError(f"{p.name}: N({N}) != static N({self.N})")
191
+
192
+ Tin = int(d["Tin"])
193
+ Tout = int(d["Tout"])
194
+ stride = int(d["stride"])
195
+ starts = d["sample_starts"].cpu().numpy().astype(np.int64) # [S]
196
+ if self.force_tin_tout is not None:
197
+ # Перерахувати sample_starts під інші Tin/Tout (залишимо stride=1)
198
+ fTin, fTout = self.force_tin_tout
199
+ max_start = T - (fTin + fTout)
200
+ if max_start < 0:
201
+ continue
202
+ starts = np.arange(0, max_start + 1, 1, dtype=np.int64)
203
+ Tin, Tout = int(fTin), int(fTout)
204
+
205
+ if self.shuffle_within_file:
206
+ rng = np.random.default_rng(int(y) * 100 + int(m))
207
+ rng.shuffle(starts)
208
+
209
+ for s in starts:
210
+ self.entries.append((str(p), int(s)))
211
+
212
+ self._file_meta[str(p)] = {
213
+ "T": T, "C": C, "N": N, "Tin": Tin, "Tout": Tout, "stride": stride, "S": int(len(starts))
214
+ }
215
+ self._file_counts[str(p)] = int(len(starts))
216
+
217
+ # cache last shard
218
+ self._last_path: Optional[str] = None
219
+ self._last_data = None
220
+
221
+ # зручні поля
222
+ self.C = next(iter(self._file_meta.values()))["C"]
223
+ self.Tin = next(iter(self._file_meta.values()))["Tin"]
224
+ self.Tout = next(iter(self._file_meta.values()))["Tout"]
225
+
226
+ def __len__(self) -> int:
227
+ return len(self.entries)
228
+
229
+ def _load_shard(self, path: str):
230
+ if self._last_path != path:
231
+ self._last_data = torch.load(path, map_location="cpu")
232
+ self._last_path = path
233
+ return self._last_data
234
+
235
+ def __getitem__(self, i: int):
236
+ path, s = self.entries[i]
237
+ meta = self._file_meta[path]
238
+ Tin, Tout = meta["Tin"], meta["Tout"]
239
+
240
+ d = self._load_shard(path)
241
+ vals = d["values"] # torch Tensor [T,C,N], може бути float16
242
+ # збираємо x/y як float32 (рекомендовано для стабільності)
243
+ x_all = vals[s:s+Tin].to(dtype=self.dtype_out) # [Tin,C,N]
244
+ y_all = vals[s+Tin:s+Tin+Tout].to(dtype=self.dtype_out) # [Tout,C,N]
245
+
246
+ # статичні — копії не потрібні, вони read-only, але distinct тензори бажані
247
+ geo = self.geo.to(dtype=self.dtype_out) # [4,N]
248
+ pos2d = self.pos2d.to(dtype=self.dtype_out) # [4,N]
249
+
250
+ return (x_all, geo, pos2d), y_all
251
+
252
+ # ---------- diagnostics ----------
253
+ @property
254
+ def file_counts(self) -> Dict[str, int]:
255
+ return dict(self._file_counts)
256
+
257
+ @property
258
+ def N_mesh(self) -> int:
259
+ return self.N
260
+
261
+
262
+ # ==========================
263
+ # Prefetch (GPU)
264
+ # ==========================
265
+ class PrefetchLoader:
266
+ """GPU-prefetch з відомим __len__."""
267
+ def __init__(self, loader: DataLoader, device: str = 'cuda'):
268
+ self.loader = loader
269
+ self.device = device
270
+
271
+ def __len__(self):
272
+ return len(self.loader)
273
+
274
+ def __iter__(self):
275
+ if self.device.startswith("cuda"):
276
+ stream = torch.cuda.Stream()
277
+ first = True
278
+ for (x_all, geo, pos2d), y_all in self.loader:
279
+ with torch.cuda.stream(stream):
280
+ x_all = x_all.to(self.device, non_blocking=True)
281
+ geo = geo.to(self.device, non_blocking=True)
282
+ pos2d = pos2d.to(self.device, non_blocking=True)
283
+ y_all = y_all.to(self.device, non_blocking=True)
284
+ next_in, next_tgt = (x_all, geo, pos2d), y_all
285
+ if not first:
286
+ yield cur_in, cur_tgt
287
+ else:
288
+ first = False
289
+ torch.cuda.current_stream().wait_stream(stream)
290
+ cur_in, cur_tgt = next_in, next_tgt
291
+ yield cur_in, cur_tgt
292
+ else:
293
+ # CPU → прозора прокладка
294
+ for batch in self.loader:
295
+ yield batch
296
+
297
+
298
+ # ==========================
299
+ # Lightning DataModule
300
+ # ==========================
301
+ class GraphWeatherDataModule(pl.LightningDataModule):
302
+ """
303
+ Створює train/val лоадери для графових шард-файлів.
304
+ Виносить edge_index_base і N_mesh як властивості, щоб інітити модель.
305
+
306
+ Batch: ((x_all, geo, pos2d), y_all)
307
+ x_all: [B, Tin, C, N]
308
+ y_all: [B, Tout, C, N]
309
+ geo: [B, 4, N]
310
+ pos2d: [B, 4, N]
311
+ """
312
+ def __init__(self,
313
+ shards_root_train: str = "./data/shards_graph",
314
+ shards_root_val: str = "./data/shards_graph",
315
+ years_train: range = range(1980, 2011),
316
+ years_val: range = range(2011, 2013),
317
+ static_graph_npz: str = "./data/graph/graph_static_from_stats_vgeo_aligned.npz",
318
+ allowed_months: Optional[List[int]] = None,
319
+ force_tin_tout: Optional[Tuple[int,int]] = None,
320
+ dtype_out: str = "float32",
321
+ shuffle_within_file_train: bool = True,
322
+ shuffle_within_file_val: bool = False,
323
+ batch_size_train: int = 1,
324
+ batch_size_val: int = 1,
325
+ num_workers: Optional[int] = None,
326
+ pin_memory: bool = True,
327
+ persistent_workers: bool = True,
328
+ device: str = "cuda"):
329
+ super().__init__()
330
+ self.paths = {"train": shards_root_train, "val": shards_root_val}
331
+ self.years = {"train": years_train, "val": years_val}
332
+ self.kw_ds = dict(
333
+ static_graph_npz=static_graph_npz,
334
+ allowed_months=allowed_months,
335
+ force_tin_tout=force_tin_tout,
336
+ dtype_out=dtype_out,
337
+ )
338
+ self.shuffle = {"train": shuffle_within_file_train, "val": shuffle_within_file_val}
339
+ self.bs = {"train": batch_size_train, "val": batch_size_val}
340
+ self.num_workers = num_workers if num_workers is not None else max((torch.get_num_threads() or 1) - 1, 0)
341
+ self.pin_memory = pin_memory
342
+ self.persistent_workers = persistent_workers
343
+ self.device = device
344
+
345
+ self._train_ds = None
346
+ self._val_ds = None
347
+ self._train_loader = None
348
+ self._val_loader = None
349
+
350
+ # public: для ініціалізації моделі
351
+ self.edge_index_base: Optional[torch.Tensor] = None # Long[2,Eh] або None
352
+ self.N_mesh: Optional[int] = None
353
+ self.C: Optional[int] = None
354
+ self.Tin: Optional[int] = None
355
+ self.Tout: Optional[int] = None
356
+
357
+ def setup(self, stage=None):
358
+ self._train_ds = GraphShardDataset(
359
+ shards_root=self.paths["train"],
360
+ years=self.years["train"],
361
+ shuffle_within_file=self.shuffle["train"],
362
+ **self.kw_ds
363
+ )
364
+ self._val_ds = GraphShardDataset(
365
+ shards_root=self.paths["val"],
366
+ years=self.years["val"],
367
+ shuffle_within_file=self.shuffle["val"],
368
+ **self.kw_ds
369
+ )
370
+
371
+ # заповнимо публічні поля із train DS (ідентичні у val при тих самих даних)
372
+ self.N_mesh = self._train_ds.N_mesh
373
+ self.edge_index_base = self._train_ds.edge_index_base # може бути None
374
+ self.C = self._train_ds.C
375
+ self.Tin = self._train_ds.Tin
376
+ self.Tout = self._train_ds.Tout
377
+
378
+ def _make_loader(self, ds: Dataset, bs: int, shuffle: bool):
379
+ base = DataLoader(
380
+ ds, batch_size=bs, shuffle=shuffle,
381
+ num_workers=self.num_workers,
382
+ pin_memory=self.pin_memory,
383
+ persistent_workers=self.persistent_workers,
384
+ drop_last=False,
385
+ )
386
+ return PrefetchLoader(base, device=self.device)
387
+
388
+ def train_dataloader(self):
389
+ self._train_loader = self._make_loader(self._train_ds, self.bs["train"], shuffle=True)
390
+ return self._train_loader
391
+
392
+ def val_dataloader(self):
393
+ self._val_loader = self._make_loader(self._val_ds, self.bs["val"], shuffle=False)
394
+ return self._val_loader
395
+
396
+ # handy stats
397
+ @property
398
+ def train_samples(self):
399
+ return len(self._train_ds) if self._train_ds is not None else None
400
+
401
+ @property
402
+ def val_samples(self):
403
+ return len(self._val_ds) if self._val_ds is not None else None
404
+
405
+ @property
406
+ def train_steps_per_epoch(self):
407
+ if self._train_ds is None:
408
+ return None
409
+ return math.ceil(len(self._train_ds) / self.bs["train"])
410
+
411
+ @property
412
+ def val_steps(self):
413
+ if self._val_ds is None:
414
+ return None
415
+ return math.ceil(len(self._val_ds) / self.bs["val"])
unified_graph_weather_model.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # unified_graph_weather_model_gat.py
2
+ # Графова версія: Vertical (C) attention + Horizontal GAT over edges
3
+ from __future__ import annotations
4
+ from typing import Tuple, Optional, Literal
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.nn.functional as F
9
+ import pytorch_lightning as pl
10
+
11
+
12
+ # -----------------------------
13
+ # SDPA helpers (без Flash)
14
+ # -----------------------------
15
+ def _sdpa_no_flash_ctx():
16
+ try:
17
+ from torch.nn.attention import sdpa_kernel, SDPBackend
18
+ return sdpa_kernel(backends=[SDPBackend.MATH, SDPBackend.EFFICIENT_ATTENTION])
19
+ except Exception:
20
+ return torch.backends.cuda.sdp_kernel(
21
+ enable_flash=False, enable_math=True, enable_mem_efficient=True
22
+ )
23
+
24
+
25
+ class EfficientScaledDotProductAttention(nn.Module):
26
+ """Self-attention по послідовності (torch SDPA)."""
27
+ def __init__(self, embed_dim: int, num_heads: int):
28
+ super().__init__()
29
+ assert embed_dim % num_heads == 0
30
+ self.num_heads = num_heads
31
+ self.embed_dim = embed_dim
32
+ self.head_dim = embed_dim // num_heads
33
+ self.q_proj = nn.Linear(embed_dim, embed_dim)
34
+ self.k_proj = nn.Linear(embed_dim, embed_dim)
35
+ self.v_proj = nn.Linear(embed_dim, embed_dim)
36
+ self.out_proj = nn.Linear(embed_dim, embed_dim)
37
+ self.norm = nn.LayerNorm(embed_dim)
38
+
39
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
40
+ # x: [B, N, E]
41
+ B, N, _ = x.shape
42
+ q = self.q_proj(x).view(B, N, self.num_heads, self.head_dim).transpose(1, 2) # [B,h,N,d]
43
+ k = self.k_proj(x).view(B, N, self.num_heads, self.head_dim).transpose(1, 2)
44
+ v = self.v_proj(x).view(B, N, self.num_heads, self.head_dim).transpose(1, 2)
45
+ with _sdpa_no_flash_ctx():
46
+ attn = F.scaled_dot_product_attention(q, k, v, dropout_p=0.0, is_causal=False)
47
+ attn = attn.transpose(1, 2).contiguous().view(B, N, self.embed_dim) # [B,N,E]
48
+ out = self.out_proj(attn)
49
+ return self.norm(out + x)
50
+
51
+
52
+ # -----------------------------
53
+ # GAT mixer (multi-head, без сторонніх бібліотек)
54
+ # -----------------------------
55
+ class GraphGATMixer(nn.Module):
56
+ """
57
+ Multi-head GAT по горизонтальних ребрах.
58
+ Працює окремо для кожного вертикального токена (каналу) і кожного елемента батчу.
59
+
60
+ Вхід: H [B, C, N, E]
61
+ Вихід: H' того ж розміру
62
+
63
+ edge_index: Long[2, Eh] (індекси 0..N-1)
64
+ Порада: бажано додати самопетлі в edge_index поза моделлю (i->i), або використовується skip-резідюал.
65
+ """
66
+ def __init__(self, embed_dim: int, num_heads: int = 4, attn_drop: float = 0.0, neg_slope: float = 0.2):
67
+ super().__init__()
68
+ assert embed_dim % num_heads == 0, "embed_dim must be divisible by num_heads"
69
+ self.E = embed_dim
70
+ self.H = num_heads
71
+ self.Dh = embed_dim // num_heads
72
+ self.attn_drop = attn_drop
73
+ self.leaky = nn.LeakyReLU(neg_slope)
74
+
75
+ # Лінійна проєкція у head-простір
76
+ self.lin = nn.Linear(self.E, self.E, bias=False)
77
+
78
+ # a_src / a_dst параметри на кожну голову: [H, Dh]
79
+ self.a_src = nn.Parameter(torch.empty(self.H, self.Dh))
80
+ self.a_dst = nn.Parameter(torch.empty(self.H, self.Dh))
81
+
82
+ # вихідна проєкція + нормалізація
83
+ self.out_proj = nn.Linear(self.E, self.E, bias=True)
84
+ self.norm = nn.LayerNorm(self.E)
85
+ self.act = nn.GELU()
86
+ self.reset_parameters()
87
+
88
+ def reset_parameters(self):
89
+ nn.init.xavier_uniform_(self.lin.weight)
90
+ nn.init.xavier_uniform_(self.out_proj.weight)
91
+ nn.init.zeros_(self.out_proj.bias)
92
+ nn.init.xavier_uniform_(self.a_src.unsqueeze(-1))
93
+ nn.init.xavier_uniform_(self.a_dst.unsqueeze(-1))
94
+
95
+ def forward(self, H: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor:
96
+ """
97
+ H: [B, C, N, E]
98
+ edge_index: [2, Eh]
99
+ """
100
+ B, C, N, E = H.shape
101
+ src = edge_index[0].to(H.device, non_blocking=True)
102
+ dst = edge_index[1].to(H.device, non_blocking=True)
103
+ Eh = src.numel()
104
+
105
+ # Перетворення у [B*C, N, H, Dh]
106
+ X = H.view(B * C, N, E)
107
+ X = self.lin(X) # [BC, N, E]
108
+ X = X.view(B * C, N, self.H, self.Dh) # [BC, N, H, Dh]
109
+
110
+ # За один прохід пройдемо BC-семпли
111
+ out = torch.zeros(B * C, N, self.H, self.Dh, device=H.device, dtype=H.dtype)
112
+
113
+ # Попередньо підготуємо a_src/dst у потрібному dtype/пристрої
114
+ a_src = self.a_src.to(device=H.device, dtype=H.dtype)
115
+ a_dst = self.a_dst.to(device=H.device, dtype=H.dtype)
116
+
117
+ for bc in range(B * C):
118
+ Xbc = X[bc] # [N, H, Dh]
119
+ # Витягуємо ознаки джерел і приймачів по ребрах
120
+ x_i = Xbc.index_select(0, src) # [Eh, H, Dh] (source features)
121
+ x_j = Xbc.index_select(0, dst) # [Eh, H, Dh] (target features)
122
+
123
+ # e_ij = Leaky(a_src·x_i + a_dst·x_j), по головах
124
+ e_src = torch.einsum("ehd,hd->eh", x_i, a_src) # [Eh, H]
125
+ e_dst = torch.einsum("ehd,hd->eh", x_j, a_dst) # [Eh, H]
126
+ e = self.leaky(e_src + e_dst) # [Eh, H]
127
+
128
+ # стабілізуємо softmax: обмежимо значення
129
+ e = torch.clamp(e, -10.0, 10.0)
130
+ exp_e = torch.exp(e) # [Eh, H]
131
+ if self.attn_drop > 0:
132
+ exp_e = F.dropout(exp_e, p=self.attn_drop, training=self.training)
133
+
134
+ # ∑_{k in N(dst)} exp_e; нормалізація по вхідних ребрах кожного dst (per head)
135
+ # Використовуємо index_add по другій осі (уздовж вузлів)
136
+ sum_dst = torch.zeros(self.H, N, device=H.device, dtype=H.dtype) # [H, N]
137
+ # transpose -> [H, Eh], add along dim=1 at indices 'dst'
138
+ sum_dst.index_add_(1, dst, exp_e.transpose(0, 1))
139
+ # вибрати суму для кожного ребра/голови
140
+ denom = sum_dst[:, dst].transpose(0, 1) + 1e-12 # [Eh, H]
141
+ alpha = exp_e / denom # [Eh, H]
142
+
143
+ # повідомлення від джерела до приймача: alpha * x_i (можна x_j; беремо x_i як у GAT v1)
144
+ m = alpha.unsqueeze(-1) * x_i # [Eh, H, Dh]
145
+
146
+ # агрегуємо у вузли-приймачі (per head)
147
+ # робимо head-цикл — H невелике, зате пам’ять економна
148
+ for h in range(self.H):
149
+ out[bc, :, h, :].index_add_(0, dst, m[:, h, :]) # [N, Dh]
150
+
151
+ # concat голови → [BC, N, E]
152
+ out = out.reshape(B * C, N, self.H * self.Dh)
153
+ out = self.out_proj(out) # [BC, N, E]
154
+ out = self.act(out)
155
+ # назад у [B, E, C, N]
156
+ out = out.view(B, C, N, E).permute(0, 3, 1, 2).contiguous() # [B, E, C, N]
157
+ # skip connection + norm опрацьовуємо у блоці
158
+ return out
159
+
160
+
161
+ # -----------------------------
162
+ # Один блок: Vertical SDPA + GAT + FFN
163
+ # -----------------------------
164
+ class GraphAxialGATBlock(nn.Module):
165
+ """
166
+ Працює у представленні [B, E, C, N].
167
+ 1) Vertical attention (по C у кожній вершині)
168
+ 2) GAT по графу (по горизонталі) окремо для кожного каналу
169
+ 3) FFN (1x1 Conv2d) + residual + norm
170
+ """
171
+ def __init__(self, E: int, heads_v: int = 4, heads_xy: int = 4, attn_drop_xy: float = 0.0):
172
+ super().__init__()
173
+ self.attn_v = EfficientScaledDotProductAttention(E, heads_v)
174
+ self.gat = GraphGATMixer(E, num_heads=heads_xy, attn_drop=attn_drop_xy)
175
+ self.ffn = nn.Sequential(
176
+ nn.Conv2d(E, 2 * E, kernel_size=1), nn.GELU(),
177
+ nn.Conv2d(2 * E, E, kernel_size=1),
178
+ )
179
+ self.norm = nn.GroupNorm(8, E)
180
+
181
+ def forward(self, F: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor:
182
+ # (1) Vertical attention
183
+ B, E, C, N = F.shape
184
+ V = F.permute(0, 3, 2, 1).reshape(B * N, C, E) # [B*N, C, E]
185
+ V = self.attn_v(V) # [B*N, C, E]
186
+ V = V.reshape(B, N, C, E).permute(0, 3, 2, 1) # [B, E, C, N]
187
+
188
+ # (2) GAT mixing (per channel)
189
+ G_in = V.permute(0, 2, 3, 1).contiguous() # [B, C, N, E]
190
+ G_out = self.gat(G_in, edge_index=edge_index) # [B, E, C, N]
191
+
192
+ S = V + G_out # residual після GAT
193
+
194
+ # (3) FFN + residual + norm
195
+ U = self.ffn(S)
196
+ return self.norm(U + S)
197
+
198
+
199
+ # -----------------------------
200
+ # Core network
201
+ # -----------------------------
202
+ class UnifiedGraphSpatioVerticalGATNet(nn.Module):
203
+ """
204
+ input: (x_all, geo, pos2d), diffusion_step(optional)
205
+ x_all: [B, Tin, C, N]
206
+ geo: [B, G, N]
207
+ pos2d: [B, P, N]
208
+ output: [B, Tout, C, N]
209
+
210
+ Потрібно подати edge_index_base: Long[2, Eh] (горизонтальні ребра базового рівня).
211
+ """
212
+ def __init__(self,
213
+ C: int,
214
+ Tin: int,
215
+ Tout: int,
216
+ edge_index_base: torch.Tensor,
217
+ N_mesh: int,
218
+ static_geo_ch: int = 4,
219
+ static_pos2d_ch: int = 4,
220
+ embed_dim: int = 192,
221
+ blocks: int = 4,
222
+ heads_v: int = 4,
223
+ heads_xy: int = 4,
224
+ attn_drop_xy: float = 0.0):
225
+ super().__init__()
226
+ self.C = int(C)
227
+ self.Tin = int(Tin)
228
+ self.Tout = int(Tout)
229
+ self.N = int(N_mesh)
230
+ self.static_in = int(static_geo_ch + static_pos2d_ch)
231
+ E = int(embed_dim)
232
+
233
+ # зафіксуємо ребра як буфери
234
+ if edge_index_base.dtype != torch.long:
235
+ edge_index_base = edge_index_base.long()
236
+ self.register_buffer("edge_src", edge_index_base[0].contiguous())
237
+ self.register_buffer("edge_dst", edge_index_base[1].contiguous())
238
+
239
+ # diffusion-step embedding (опц.)
240
+ self.diffusion_embed = nn.Sequential(
241
+ nn.Linear(1, E), nn.ReLU(inplace=True),
242
+ nn.Linear(E, E)
243
+ )
244
+
245
+ # Temporal projection: [B, Tin, C, N] -> [B, E, C, N]
246
+ self.temporal_proj = nn.Conv2d(self.Tin, E, kernel_size=1)
247
+
248
+ # Static encoder: [B, static_in, 1, N] -> [B, E, 1, N] -> broadcast по C
249
+ self.static_encoder = nn.Sequential(
250
+ nn.Conv2d(self.static_in, 64, kernel_size=1),
251
+ nn.GroupNorm(8, 64), nn.ReLU(inplace=True),
252
+ nn.Conv2d(64, E, kernel_size=1),
253
+ nn.GroupNorm(8, E), nn.ReLU(inplace=True),
254
+ )
255
+
256
+ # Blocks
257
+ self.blocks = nn.ModuleList([
258
+ GraphAxialGATBlock(E, heads_v=heads_v, heads_xy=heads_xy, attn_drop_xy=attn_drop_xy)
259
+ for _ in range(blocks)
260
+ ])
261
+
262
+ # Decoder: [B, E, C, N] -> [B, Tout, C, N]
263
+ self.decoder = nn.Sequential(
264
+ nn.Conv2d(E, E, kernel_size=1), nn.GELU(),
265
+ nn.Conv2d(E, self.Tout, kernel_size=1)
266
+ )
267
+
268
+ def forward(self,
269
+ inputs: Tuple[torch.Tensor, torch.Tensor, torch.Tensor],
270
+ diffusion_step: Optional[torch.Tensor] = None) -> torch.Tensor:
271
+ x_all, geo, pos2d = inputs
272
+ x_all = x_all.float() # [B, Tin, C, N]
273
+ geo = geo.float() # [B, G, N]
274
+ pos2d = pos2d.float() # [B, P, N]
275
+
276
+ B, Tin, C, N = x_all.shape
277
+ assert Tin == self.Tin and C == self.C and N == self.N, \
278
+ f"Shape mismatch: x_all={x_all.shape} expected Tin={self.Tin}, C={self.C}, N={self.N}"
279
+
280
+ # (1) Temporal projection
281
+ F = self.temporal_proj(x_all) # [B, E, C, N]
282
+
283
+ # (2) Diffusion step embedding (optional)
284
+ if diffusion_step is None:
285
+ diffusion_step = torch.zeros(B, device=x_all.device, dtype=x_all.dtype)
286
+ d_emb = self.diffusion_embed(diffusion_step.unsqueeze(-1)) # [B, E]
287
+ d_map = d_emb.view(B, -1, 1, 1).expand(-1, -1, C, N) # [B, E, C, N]
288
+ F = F + d_map
289
+
290
+ # (3) Static features (broadcast per channel)
291
+ static = torch.cat([geo, pos2d], dim=1) # [B, static_in, N]
292
+ static_e = self.static_encoder(static.unsqueeze(2)) # [B, E, 1, N]
293
+ F = F + static_e.expand(-1, -1, C, -1) # [B, E, C, N]
294
+
295
+ # (4) Axial GAT blocks
296
+ edge_index = torch.stack([self.edge_src, self.edge_dst], dim=0)
297
+ for blk in self.blocks:
298
+ F = blk(F, edge_index=edge_index)
299
+
300
+ # (5) Decode to Tout
301
+ y = self.decoder(F) # [B, Tout, C, N]
302
+ return y
303
+
304
+
305
+ # -----------------------------
306
+ # Lightning wrapper
307
+ # -----------------------------
308
+ class UnifiedGraphWeatherGATLightning(pl.LightningModule):
309
+ """
310
+ Очікує batches у форматі:
311
+ ((x_all, geo, pos2d), y_all)
312
+ де:
313
+ x_all: [B, Tin, C, N]
314
+ y_all: [B, Tout, C, N]
315
+ """
316
+ def __init__(self,
317
+ C: int,
318
+ Tin: int,
319
+ Tout: int,
320
+ edge_index_base: torch.Tensor,
321
+ N_mesh: int,
322
+ embed_dim: int = 192,
323
+ blocks: int = 4,
324
+ heads_v: int = 4,
325
+ heads_xy: int = 4,
326
+ attn_drop_xy: float = 0.0,
327
+ static_geo_ch: int = 4,
328
+ static_pos2d_ch: int = 4,
329
+ lr: float = 3e-4,
330
+ weight_decay: float = 1e-4,
331
+ loss: Literal["mse", "huber"] = "mse"):
332
+ super().__init__()
333
+ self.save_hyperparameters(ignore=["edge_index_base"])
334
+ self.net = UnifiedGraphSpatioVerticalGATNet(
335
+ C=C, Tin=Tin, Tout=Tout, N_mesh=N_mesh,
336
+ edge_index_base=edge_index_base,
337
+ static_geo_ch=static_geo_ch, static_pos2d_ch=static_pos2d_ch,
338
+ embed_dim=embed_dim, blocks=blocks,
339
+ heads_v=heads_v, heads_xy=heads_xy, attn_drop_xy=attn_drop_xy,
340
+ )
341
+ self.criterion = nn.MSELoss() if loss == "mse" else nn.HuberLoss()
342
+ self.lr = lr
343
+ self.weight_decay = weight_decay
344
+
345
+ def forward(self, x_tuple, diffusion_step: Optional[torch.Tensor] = None):
346
+ return self.net(x_tuple, diffusion_step=diffusion_step)
347
+
348
+ def _step(self, batch, stage: str):
349
+ (x_all, geo, pos2d), y_true = batch
350
+ B = x_all.size(0)
351
+ t = torch.zeros(B, device=x_all.device, dtype=x_all.dtype) # нульовий diffusion-step
352
+ y_pred = self.forward((x_all, geo, pos2d), diffusion_step=t)
353
+ loss = self.criterion(y_pred, y_true)
354
+ self.log(f"{stage}_loss", loss, prog_bar=True, on_step=(stage=="train"), on_epoch=True, batch_size=B)
355
+ return loss
356
+
357
+ def training_step(self, batch, batch_idx):
358
+ return self._step(batch, "train")
359
+
360
+ def validation_step(self, batch, batch_idx):
361
+ self._step(batch, "val")
362
+
363
+ def configure_optimizers(self):
364
+ opt = torch.optim.AdamW(self.parameters(), lr=self.lr, weight_decay=self.weight_decay)
365
+ sch = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=self.trainer.max_epochs)
366
+ return {"optimizer": opt, "lr_scheduler": {"scheduler": sch, "interval": "epoch"}}
unified_graph_weather_model_tp.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # model/unified_graph_weather_model_tp.py
2
+ import math
3
+ from typing import Optional, Sequence, Tuple
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ import pytorch_lightning as pl
8
+
9
+ # базова загальна мережа
10
+ from model.unified_graph_weather_model import UnifiedGraphSpatioVerticalGATNet
11
+
12
+
13
+ # ----------------------- допоміжні утиліти -----------------------
14
+ def _coerce_list_str(x) -> list[str]:
15
+ if isinstance(x, (list, tuple)):
16
+ return [str(v.decode("utf-8")) if isinstance(v, (bytes, bytearray)) else str(v) for v in x]
17
+ if isinstance(x, str):
18
+ return [x]
19
+ return [str(x)]
20
+
21
+
22
+ def _find_tp_index(order: Sequence[str]) -> int:
23
+ """
24
+ Знаходимо індекс каналу опадів у shard order.
25
+ Пробуємо 'tp_log@sfc', 'tp@sfc', 'tp_log', 'tp' (в порядку пріоритету).
26
+ """
27
+ order = [str(v) for v in order]
28
+ low = [s.lower() for s in order]
29
+ for cand in ("tp_log@sfc", "tp@sfc", "tp_log", "tp"):
30
+ if cand.lower() in low:
31
+ return low.index(cand.lower())
32
+ raise KeyError(f"Не знайшов 'tp' каналу у channel_order. Приклади: {order[:6]} ...")
33
+
34
+
35
+ def _stats_key_for_tp(name: str) -> str:
36
+ # у статистиці ми використовуємо лог-опади
37
+ return "tp_log"
38
+
39
+
40
+ # ---------- зручні перетворення (нормалізований лог ↔ фізичні мм) ----------
41
+ class TPDenormHelper:
42
+ """
43
+ Конвертації між нормалізованим 'tp_log' і фізичними мм.
44
+ Використовує ROI-статистику: mean/std для 'tp_log'.
45
+ """
46
+ def __init__(self, stats_order: Sequence[str], mu: torch.Tensor, sd: torch.Tensor):
47
+ if isinstance(mu, (list, tuple)):
48
+ mu = torch.tensor(mu, dtype=torch.float32)
49
+ if isinstance(sd, (list, tuple)):
50
+ sd = torch.tensor(sd, dtype=torch.float32)
51
+
52
+ key = _stats_key_for_tp("tp")
53
+ idx = _coerce_list_str(stats_order).index(key)
54
+ self.mu = float(mu[idx])
55
+ self.sd = float(sd[idx])
56
+
57
+ def lognorm_to_mm(self, arr_norm: torch.Tensor) -> torch.Tensor:
58
+ # arr_norm → arr_log → мм
59
+ arr_log = arr_norm * self.sd + self.mu
60
+ mm = torch.expm1(arr_log)
61
+ return torch.clamp(mm, min=0.0)
62
+
63
+ def mm_to_lognorm(self, mm: torch.Tensor) -> torch.Tensor:
64
+ arr_log = torch.log1p(torch.clamp(mm, min=0.0))
65
+ return (arr_log - self.mu) / max(self.sd, 1e-6)
66
+
67
+
68
+ # ----------------------- лосс під опади -----------------------
69
+ class SparsePrecipLoss(nn.Module):
70
+ """
71
+ Комбінований лосс для опадів:
72
+ L = huber(log_norm) + α * w * |mm_pred - mm_true|
73
+ де w підсилює "мокрі" пікселі (mm_true >= wet_thr_mm), а для дуже малих опадів
74
+ можна тримати меншу вагу.
75
+
76
+ Параметри:
77
+ huber_delta: δ для Huber в нормалізованому лог-просторі
78
+ wet_thr_mm: поріг "мокро" в міліметрах
79
+ wet_weight: множник ваги для мокрих пікселів
80
+ dry_weight: вага для сухих/майже сухих пікселів
81
+ alpha_mm: коефіцієнт при терміні в мм
82
+ gamma_focal: опційно фокалізація для мокрих (підсилює великі значення)
83
+ """
84
+ def __init__(self,
85
+ huber_delta: float = 0.5,
86
+ wet_thr_mm: float = 0.1,
87
+ wet_weight: float = 5.0,
88
+ dry_weight: float = 0.5,
89
+ alpha_mm: float = 0.5,
90
+ gamma_focal: float = 0.0):
91
+ super().__init__()
92
+ self.delta = float(huber_delta)
93
+ self.wet_thr = float(wet_thr_mm)
94
+ self.w_wet = float(wet_weight)
95
+ self.w_dry = float(dry_weight)
96
+ self.alpha = float(alpha_mm)
97
+ self.gamma = float(gamma_focal)
98
+ self.huber = nn.SmoothL1Loss(beta=self.delta, reduction="none") # PyTorch Huber v2
99
+
100
+ def forward(self,
101
+ y_pred_norm: torch.Tensor, # [B, N] (tp_log нормалізований)
102
+ y_true_norm: torch.Tensor, # [B, N]
103
+ denorm_helper: TPDenormHelper) -> torch.Tensor:
104
+
105
+ # 1) Huber у нормалізованому лог-просторі
106
+ L_h = self.huber(y_pred_norm, y_true_norm) # [B, N]
107
+
108
+ if self.alpha <= 0.0:
109
+ return L_h.mean()
110
+
111
+ # 2) Додатковий термін у фізичних мм
112
+ mm_pred = denorm_helper.lognorm_to_mm(y_pred_norm) # [B,N]
113
+ mm_true = denorm_helper.lognorm_to_mm(y_true_norm) # [B,N]
114
+ L_mm = torch.abs(mm_pred - mm_true)
115
+
116
+ # Ваги: мокре/сухе
117
+ wet_mask = (mm_true >= self.wet_thr).float()
118
+ dry_mask = 1.0 - wet_mask
119
+ w = self.w_wet * wet_mask + self.w_dry * dry_mask
120
+
121
+ if self.gamma > 0.0:
122
+ # фокалізація: підсилити великі значення (не обов’язково)
123
+ w = w * torch.pow(1.0 + mm_true, self.gamma)
124
+
125
+ L = L_h + self.alpha * (w * L_mm)
126
+ return L.mean()
127
+
128
+
129
+ # ------------------- TP-адаптер над загальною моделлю -------------------
130
+ class UnifiedGraphSpatioVerticalGATNetTP(nn.Module):
131
+ """
132
+ Обгортка над загальною моделлю, що повертає тільки 1 канал (tp_log@sfc).
133
+ Внутрішня вартість майже як у повної, але head узятий з бази (ми лише обрізаємо вихід).
134
+ """
135
+ def __init__(self,
136
+ C: int, Tin: int, Tout: int,
137
+ edge_index_base: torch.Tensor,
138
+ N_mesh: Optional[int] = None,
139
+ embed_dim: int = 192, blocks: int = 4, heads_v: int = 4, heads_xy: int = 4,
140
+ attn_drop_xy: float = 0.0,
141
+ channel_order: Optional[Sequence[str]] = None):
142
+ super().__init__()
143
+ self.base = UnifiedGraphSpatioVerticalGATNet(
144
+ C=C, Tin=Tin, Tout=Tout,
145
+ edge_index_base=edge_index_base, N_mesh=N_mesh,
146
+ embed_dim=embed_dim, blocks=blocks, heads_v=heads_v, heads_xy=heads_xy,
147
+ attn_drop_xy=attn_drop_xy,
148
+ )
149
+ self.channel_order = list(channel_order) if channel_order is not None else None
150
+ self.idx_tp = None
151
+ if self.channel_order is not None:
152
+ self.idx_tp = _find_tp_index(self.channel_order)
153
+
154
+ def set_channel_order(self, order: Sequence[str]):
155
+ self.channel_order = list(order)
156
+ self.idx_tp = _find_tp_index(self.channel_order)
157
+
158
+ def forward(self, x_tuple, diffusion_step: Optional[torch.Tensor] = None,
159
+ channel_order: Optional[Sequence[str]] = None):
160
+ """
161
+ x_tuple: (x_all, geo, pos2d)
162
+ x_all: [B, Tin, C, N]
163
+ geo: [B, 4, N]
164
+ pos2d: [B, 4, N]
165
+ return: [B, Tout, 1, N] (tp_log нормалізований)
166
+ """
167
+ if (self.idx_tp is None) and (channel_order is not None):
168
+ self.set_channel_order(channel_order)
169
+
170
+ y_full = self.base(x_tuple, diffusion_step=diffusion_step) # [B,Tout,C,N]
171
+ if self.idx_tp is None:
172
+ raise RuntimeError("Не визначено індекс tp у channel_order. Передай order у forward або set_channel_order().")
173
+ tp = y_full[:, :, self.idx_tp].unsqueeze(2) # [B, Tout, 1, N]
174
+ return tp
175
+
176
+
177
+ # ------------------------- Lightning під опади -------------------------
178
+ class PrecipTPLightning(pl.LightningModule):
179
+ """
180
+ 1-кроковий TP (tp_log@sfc), лосс адаптований до розрідженості.
181
+ Підтримує файнтюн з загального чекпойнта (через .net.base.load_state_dict()).
182
+ """
183
+ def __init__(self,
184
+ C: int, Tin: int, Tout: int,
185
+ edge_index_base: torch.Tensor,
186
+ N_mesh: Optional[int] = None,
187
+ embed_dim: int = 192, blocks: int = 4, heads_v: int = 4, heads_xy: int = 4,
188
+ attn_drop_xy: float = 0.0,
189
+ lr: float = 3e-4, weight_decay: float = 1e-4,
190
+ # лосс-параметри
191
+ huber_delta: float = 0.5,
192
+ wet_thr_mm: float = 0.1,
193
+ wet_weight: float = 5.0,
194
+ dry_weight: float = 0.5,
195
+ alpha_mm: float = 0.5,
196
+ gamma_focal: float = 0.0,
197
+ diffusion_max_step: int = 0,
198
+ channel_order: Optional[Sequence[str]] = None,
199
+ stats_order: Optional[Sequence[str]] = None,
200
+ stats_mu: Optional[Sequence[float]] = None,
201
+ stats_sd: Optional[Sequence[float]] = None):
202
+ super().__init__()
203
+ self.save_hyperparameters(ignore=["edge_index_base", "channel_order", "stats_order", "stats_mu", "stats_sd"])
204
+ self.net = UnifiedGraphSpatioVerticalGATNetTP(
205
+ C=C, Tin=Tin, Tout=Tout,
206
+ edge_index_base=edge_index_base, N_mesh=N_mesh,
207
+ embed_dim=embed_dim, blocks=blocks, heads_v=heads_v, heads_xy=heads_xy,
208
+ attn_drop_xy=attn_drop_xy,
209
+ channel_order=channel_order,
210
+ )
211
+ self.lr = lr
212
+ self.weight_decay = weight_decay
213
+ self.diffusion_max_step = int(diffusion_max_step)
214
+
215
+ self.crit = SparsePrecipLoss(
216
+ huber_delta=huber_delta,
217
+ wet_thr_mm=wet_thr_mm,
218
+ wet_weight=wet_weight,
219
+ dry_weight=dry_weight,
220
+ alpha_mm=alpha_mm,
221
+ gamma_focal=gamma_focal,
222
+ )
223
+
224
+ # для другого терміну лоссу потрібні ROI-статистики
225
+ if (stats_order is None) or (stats_mu is None) or (stats_sd is None):
226
+ self.denorm_helper = None
227
+ else:
228
+ # збережемо в torch.Tensors для зручності на GPU
229
+ self.register_buffer("_stats_mu", torch.tensor(stats_mu, dtype=torch.float32))
230
+ self.register_buffer("_stats_sd", torch.tensor(stats_sd, dtype=torch.float32))
231
+ self.stats_order = list(stats_order)
232
+ self.denorm_helper = TPDenormHelper(self.stats_order, self._stats_mu, self._stats_sd)
233
+
234
+ # ----------- shared step -----------
235
+ def _shared_step(self, batch, stage: str):
236
+ (x_all, geo, pos2d), y_full = batch # y_full: [B, Tout, C, N]
237
+ B = x_all.size(0)
238
+ # дифузійний крок як у загальній
239
+ steps = torch.zeros((B,), device=x_all.device, dtype=torch.float32)
240
+ if self.diffusion_max_step > 0:
241
+ steps = torch.randint(0, self.diffusion_max_step, (B,), device=x_all.device, dtype=torch.float32)
242
+
243
+ # канал-ордер беремо з DM
244
+ order = getattr(self.trainer.datamodule, "channel_order", None)
245
+ if order is None:
246
+ raise RuntimeError("DataModule не заповнив channel_order.")
247
+ # прогноз [B,Tout,1,N]
248
+ y_hat = self.net((x_all, geo, pos2d), diffusion_step=steps, channel_order=order)
249
+ y_true_tp = y_full[:, :, self.net.idx_tp].unsqueeze(2) # [B,Tout,1,N]
250
+
251
+ y_pred = y_hat[:, 0, 0] # [B,N] нормалізований tp_log
252
+ y_true = y_true_tp[:, 0, 0]
253
+
254
+ if self.denorm_helper is None:
255
+ loss = nn.SmoothL1Loss(beta=0.5)(y_pred, y_true) # fallback, якщо нема стат
256
+ else:
257
+ loss = self.crit(y_pred, y_true, self.denorm_helper)
258
+
259
+ self.log_dict({f"{stage}/loss": loss}, prog_bar=True, on_step=(stage == "train"), on_epoch=True)
260
+ return loss
261
+
262
+ def training_step(self, batch, batch_idx):
263
+ return self._shared_step(batch, "train")
264
+
265
+ def validation_step(self, batch, batch_idx):
266
+ self._shared_step(batch, "val")
267
+
268
+ # ----------- optim -----------
269
+ def configure_optimizers(self):
270
+ opt = torch.optim.AdamW(self.parameters(), lr=self.lr, weight_decay=self.weight_decay)
271
+ sch = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(self.trainer.max_epochs, 1))
272
+ return {"optimizer": opt, "lr_scheduler": {"scheduler": sch, "interval": "epoch"}}
unified_graph_weather_model_uv10.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # model_unified_graph_uv10.py
2
+ import math
3
+ from typing import Optional, Tuple, Sequence
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ import pytorch_lightning as pl
8
+
9
+ # ВАЖЛИВО: імпортуємо вашу загальну модель
10
+ from model.unified_graph_weather_model import UnifiedGraphSpatioVerticalGATNet
11
+
12
+
13
+ # ------------------------- Допоміжний векторний лосс -------------------------
14
+ class WindVectorLoss(nn.Module):
15
+ """
16
+ Лосс для вітру: компоненти + (опційно) модуль і напрям.
17
+ y_pred, y_true: [B, 2, N] (u, v) у НОРМАЛІЗОВАНОМУ масштабі (як у тренінгу).
18
+ Зазвичай достатньо component MSE. Якщо хочеться трохи "фізики", вмикаємо модуль/кут.
19
+ """
20
+ def __init__(self,
21
+ w_components: float = 1.0,
22
+ w_magnitude: float = 0.0,
23
+ w_direction: float = 0.0,
24
+ robust: bool = False,
25
+ eps: float = 1e-6):
26
+ super().__init__()
27
+ self.wc = float(w_components)
28
+ self.wm = float(w_magnitude)
29
+ self.wd = float(w_direction)
30
+ self.eps = float(eps)
31
+ self.comp_loss = nn.SmoothL1Loss(reduction="mean") if robust else nn.MSELoss(reduction="mean")
32
+ self.mag_loss = nn.SmoothL1Loss(reduction="mean") if robust else nn.MSELoss(reduction="mean")
33
+
34
+ def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor:
35
+ # компоненти
36
+ Lc = self.comp_loss(y_pred, y_true)
37
+
38
+ if (self.wm == 0.0) and (self.wd == 0.0):
39
+ return self.wc * Lc
40
+
41
+ # модуль
42
+ mag_p = torch.sqrt(y_pred[:, 0]**2 + y_pred[:, 1]**2 + self.eps) # [B, N]
43
+ mag_t = torch.sqrt(y_true[:, 0]**2 + y_true[:, 1]**2 + self.eps)
44
+ Lm = self.mag_loss(mag_p, mag_t)
45
+
46
+ # напрям (1 - cos(Δθ)) через косинусну схожість
47
+ dot = (y_pred[:, 0]*y_true[:, 0] + y_pred[:, 1]*y_true[:, 1]) # [B, N]
48
+ cos_sim = dot / (mag_p * mag_t + self.eps)
49
+ # clamp щоб уникнути NaN при рідкісних виродженнях
50
+ cos_sim = torch.clamp(cos_sim, -1.0, 1.0)
51
+ Ld = (1.0 - cos_sim).mean()
52
+
53
+ return self.wc * Lc + self.wm * Lm + self.wd * Ld
54
+
55
+
56
+ # ----------------- UV-адаптер над загальною Graph GAT моделлю ----------------
57
+ class UnifiedGraphSpatioVerticalGATNetUV(nn.Module):
58
+ """
59
+ Тонкий адаптер: всередині — Ваша загальна мережа.
60
+ На виході повертаємо лише 2 канали (u10, v10).
61
+ Обчислювальні витрати майже ті самі (всі внутрішні блоки працюють як і раніше),
62
+ економія — лише на невеликій «голові». Це компроміс заради сумісності з чекпойнтом.
63
+
64
+ Параметри:
65
+ - chan_u_name, chan_v_name: як канали називаються у shard'ах (звично "u10@sfc", "v10@sfc")
66
+ - channel_order: список з шарду для пошуку індексів
67
+ """
68
+ def __init__(self,
69
+ C: int, Tin: int, Tout: int,
70
+ edge_index_base: torch.Tensor,
71
+ N_mesh: Optional[int] = None,
72
+ embed_dim: int = 192, blocks: int = 4, heads_v: int = 4, heads_xy: int = 4,
73
+ attn_drop_xy: float = 0.0,
74
+ chan_u_name: str = "u10@sfc",
75
+ chan_v_name: str = "v10@sfc",
76
+ channel_order: Optional[Sequence[str]] = None):
77
+ super().__init__()
78
+ self.chan_u_name = chan_u_name
79
+ self.chan_v_name = chan_v_name
80
+ self.channel_order = list(channel_order) if channel_order is not None else None
81
+
82
+ # базова мережа як у загальній моделі (C_out = C)
83
+ self.base = UnifiedGraphSpatioVerticalGATNet(
84
+ C=C, Tin=Tin, Tout=Tout,
85
+ edge_index_base=edge_index_base, N_mesh=N_mesh,
86
+ embed_dim=embed_dim, blocks=blocks, heads_v=heads_v, heads_xy=heads_xy,
87
+ attn_drop_xy=attn_drop_xy,
88
+ )
89
+
90
+ # індекси каналів у повному виході
91
+ if self.channel_order is not None:
92
+ self.idx_u, self.idx_v = self._find_uv_indices(self.channel_order)
93
+ else:
94
+ # якщо не дали під час ініт — довизначимо при першому forward (по наданому channel_order)
95
+ self.idx_u, self.idx_v = None, None
96
+
97
+ @staticmethod
98
+ def _find_uv_indices(order: Sequence[str],
99
+ u_candidates=("u10@sfc", "u10"),
100
+ v_candidates=("v10@sfc", "v10")) -> Tuple[int, int]:
101
+ lower = [str(x).lower() for x in order]
102
+ iu, iv = None, None
103
+ for cand in u_candidates:
104
+ if cand.lower() in lower:
105
+ iu = lower.index(cand.lower()); break
106
+ for cand in v_candidates:
107
+ if cand.lower() in lower:
108
+ iv = lower.index(cand.lower()); break
109
+ if iu is None or iv is None:
110
+ raise KeyError(f"Не знайшов індекси u/v у channel_order. order приклад: {order[:5]} ...")
111
+ return int(iu), int(iv)
112
+
113
+ def set_channel_order(self, order: Sequence[str]):
114
+ self.channel_order = list(order)
115
+ self.idx_u, self.idx_v = self._find_uv_indices(self.channel_order)
116
+
117
+ @torch.no_grad()
118
+ def _lazy_indices_from_order(self, order: Sequence[str]):
119
+ if (self.idx_u is None) or (self.idx_v is None):
120
+ self.set_channel_order(order)
121
+
122
+ def forward(self, x_tuple, diffusion_step: Optional[torch.Tensor] = None,
123
+ channel_order: Optional[Sequence[str]] = None):
124
+ """
125
+ x_tuple: (x_all, geo, pos2d)
126
+ x_all: [B, Tin, C, N]
127
+ geo: [B, 4, N]
128
+ pos2d: [B, 4, N]
129
+ return: [B, Tout, 2, N] (u, v)
130
+ """
131
+ if (self.idx_u is None or self.idx_v is None) and (channel_order is not None):
132
+ self._lazy_indices_from_order(channel_order)
133
+
134
+ y_full = self.base(x_tuple, diffusion_step=diffusion_step) # [B, Tout, C, N]
135
+ if self.idx_u is None or self.idx_v is None:
136
+ raise RuntimeError("UV індекси не визначені (передайте channel_order у forward або set_channel_order()).")
137
+ uv = torch.stack([y_full[:, :, self.idx_u], y_full[:, :, self.idx_v]], dim=2) # [B, Tout, 2, N]
138
+ return uv
139
+
140
+
141
+ # ----------------------- Lightning: UV-файнтюнінг ----------------------------
142
+ class WindUV10Lightning(pl.LightningModule):
143
+ """
144
+ Lightning-клас для файнтюнінгу під (u10, v10).
145
+ - Повертає лише 2-канальний вихід
146
+ - Лосс: компоненти + (опц.) модуль/напрям
147
+ - Може підвантажувати «знання» з загальної моделі (ckpt)
148
+ """
149
+ def __init__(self,
150
+ C: int, Tin: int, Tout: int,
151
+ edge_index_base: torch.Tensor,
152
+ N_mesh: Optional[int] = None,
153
+ embed_dim: int = 192, blocks: int = 4, heads_v: int = 4, heads_xy: int = 4,
154
+ attn_drop_xy: float = 0.0,
155
+ lr: float = 3e-4, weight_decay: float = 1e-4,
156
+ loss_components: float = 1.0,
157
+ loss_magnitude: float = 0.0,
158
+ loss_direction: float = 0.0,
159
+ robust_loss: bool = False,
160
+ chan_u_name: str = "u10@sfc",
161
+ chan_v_name: str = "v10@sfc",
162
+ channel_order: Optional[Sequence[str]] = None,
163
+ diffusion_max_step: int = 100):
164
+ super().__init__()
165
+ self.save_hyperparameters(ignore=["edge_index_base", "channel_order"])
166
+ self.net = UnifiedGraphSpatioVerticalGATNetUV(
167
+ C=C, Tin=Tin, Tout=Tout,
168
+ edge_index_base=edge_index_base, N_mesh=N_mesh,
169
+ embed_dim=embed_dim, blocks=blocks, heads_v=heads_v, heads_xy=heads_xy,
170
+ attn_drop_xy=attn_drop_xy,
171
+ chan_u_name=chan_u_name, chan_v_name=chan_v_name,
172
+ channel_order=channel_order,
173
+ )
174
+ self.lr = lr
175
+ self.weight_decay = weight_decay
176
+ self.diffusion_max_step = int(diffusion_max_step)
177
+ self.criterion = WindVectorLoss(
178
+ w_components=loss_components,
179
+ w_magnitude=loss_magnitude,
180
+ w_direction=loss_direction,
181
+ robust=robust_loss,
182
+ )
183
+
184
+ # -------- helper: вирізати GT (u,v) із повного y ----------
185
+ def _select_uv_from_full(self, y_full: torch.Tensor, order: Sequence[str]) -> torch.Tensor:
186
+ """
187
+ y_full: [B, Tout, C, N] -> [B, Tout, 2, N]
188
+ """
189
+ self.net._lazy_indices_from_order(order)
190
+ iu, iv = self.net.idx_u, self.net.idx_v
191
+ return torch.stack([y_full[:, :, iu], y_full[:, :, iv]], dim=2)
192
+
193
+ # ----------------------- train/val step -----------------------
194
+ def _shared_step(self, batch, stage: str):
195
+ (x_all, geo, pos2d), y_full = batch # y_full: [B, Tout, C, N]
196
+ # дифузійний крок (як у загальній моделі)
197
+ steps = torch.randint(
198
+ low=0, high=max(self.diffusion_max_step, 1),
199
+ size=(x_all.size(0),), device=x_all.device, dtype=torch.float32
200
+ )
201
+ # forward -> [B, Tout, 2, N]
202
+ y_hat = self.net((x_all, geo, pos2d), diffusion_step=steps,
203
+ channel_order=getattr(self.trainer.datamodule, "channel_order", None))
204
+
205
+ # GT тільки UV
206
+ order = getattr(self.trainer.datamodule, "channel_order", None)
207
+ if order is None:
208
+ raise RuntimeError("DataModule не надав channel_order.")
209
+ y_uv = self._select_uv_from_full(y_full, order) # [B, Tout, 2, N]
210
+
211
+ # lead-1 (Tout=1)
212
+ y_pred = y_hat[:, 0] # [B, 2, N]
213
+ y_true = y_uv[:, 0] # [B, 2, N]
214
+
215
+ loss = self.criterion(y_pred, y_true)
216
+ self.log_dict({f"{stage}/loss": loss}, prog_bar=True, on_step=(stage=="train"), on_epoch=True)
217
+ return loss
218
+
219
+ def training_step(self, batch, batch_idx):
220
+ return self._shared_step(batch, "train")
221
+
222
+ def validation_step(self, batch, batch_idx):
223
+ self._shared_step(batch, "val")
224
+
225
+ # ----------------------- optim -----------------------
226
+ def configure_optimizers(self):
227
+ opt = torch.optim.AdamW(self.parameters(), lr=self.lr, weight_decay=self.weight_decay)
228
+ sch = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(self.trainer.max_epochs, 1))
229
+ return {"optimizer": opt, "lr_scheduler": {"scheduler": sch, "interval": "epoch"}}