yx21e commited on
Commit
7170e0e
·
verified ·
1 Parent(s): a4fad25

Add FireWx-FM training and data loader pipeline

Browse files
training/README.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FireWx-FM Training and Data Loader Pipeline
2
+
3
+ This folder contains the original FireWx-FM cache builder and PyTorch training
4
+ pipeline used for the released regional wildfire occupancy checkpoints. The raw
5
+ provider datasets and local cache tensors are not redistributed here. The code
6
+ shows how those sources are converted into the model tensor contract and how the
7
+ training loader samples tiles from that cache.
8
+
9
+ ## Files
10
+
11
+ | Path | Purpose |
12
+ |---|---|
13
+ | `build_phase1_cache_regional_hrrr.py` | Builds the regional HRRR/FIRMS/static cache on the California EPSG:5070 grid. |
14
+ | `train_cold_tiled_mainline.py` | Contains the `ColdFeatureStore`, tile sampler, full-map loader, compact U-Net, losses, and training loop. |
15
+ | `eval_metrics.py` | Metric utilities used by the training script for validation and test summaries. |
16
+ | `cache_helpers.py` | Minimal helper functions used by the regional cache builder. |
17
+ | `train_utils.py` | Reproducibility helper used by the training script. |
18
+ | `configs/stage1_cache_regional_hrrr_ca_5km_l12_template.json` | Template for constructing the 5 km, 12-hour-lead regional cache. |
19
+ | `configs/train_firewx_fm_seed7_template.json` | Template for the seeded FireWx-FM training run. |
20
+
21
+ ## Data boundary
22
+
23
+ The scripts expect local copies of the public source datasets described in
24
+ `../data_sources/DATA_SOURCES.md`: NOAA HRRR fields, NASA FIRMS active-fire
25
+ detections, LANDFIRE fuel and canopy layers, Wildfire Risk to Communities
26
+ housing density, and LandScan population. WFIGS and MTBS support event-level
27
+ experiments, but they are not input channels for the released occupancy
28
+ checkpoint.
29
+
30
+ ## Cache construction
31
+
32
+ The cache builder writes:
33
+
34
+ | File group | Arrays |
35
+ |---|---|
36
+ | `inputs/phase1_regional_*.npz` | `weather`, `firewx`, `firewx_valid`, `y_count`, `y_occ`, `lat`, `lon`, `weather_names`, `firewx_names` |
37
+ | `static/static_regional_phase1_v1.npz` | `static`, `static_valid`, `lat`, `lon`, `coord_crs`, `static_names` |
38
+ | `splits/{train,val,test}.csv` | Sample metadata and paths used by the training loader. |
39
+ | `manifests/phase1_cache_summary.json` | Cache shape, split counts, channel names, and grid metadata. |
40
+
41
+ Run it with a local copy of the template config:
42
+
43
+ ```bash
44
+ python training/build_phase1_cache_regional_hrrr.py \
45
+ --config training/configs/stage1_cache_regional_hrrr_ca_5km_l12_template.json
46
+ ```
47
+
48
+ The released configuration uses a California grid at 5 km resolution in
49
+ `EPSG:5070`, 6-hourly HRRR issue times, and 12-hour occupancy labels derived
50
+ from FIRMS detections.
51
+
52
+ ## Tensor contract
53
+
54
+ `ColdFeatureStore` is the key data-loader class. It assembles each model input
55
+ as:
56
+
57
+ ```python
58
+ x = np.concatenate([weather, firewx, *extra, static_valid, static], axis=0)
59
+ ```
60
+
61
+ For the released regional cache, `firewx` has zero feature channels and
62
+ `extra = [firewx_valid]`. The resulting 16-channel tensor is:
63
+
64
+ | Channels | Names | Source |
65
+ |---:|---|---|
66
+ | 0-9 | `t2m`, `d2m`, `u10`, `v10`, `cape`, `sp`, `blh`, `vis`, `prate`, `tp` | NOAA HRRR |
67
+ | 10 | `firewx_valid` | Dynamic/input validity mask for this regional cache |
68
+ | 11 | `static_valid` | Static reprojection validity mask |
69
+ | 12-15 | `fuel_fbfm40`, `canopy_cover`, `housing_density`, `population` | LANDFIRE, WRC housing density, LandScan |
70
+
71
+ The same contract is available in machine-readable form at
72
+ `../models/wildfire_fm/input_channels.json`.
73
+
74
+ ## Training
75
+
76
+ The training script reads `splits/train.csv`, `splits/val.csv`, and
77
+ `splits/test.csv` from `index_root`. Training uses 32-by-32 tiles sampled from
78
+ the cached time maps. Validation and test use full maps.
79
+
80
+ ```bash
81
+ python training/train_cold_tiled_mainline.py \
82
+ --config training/configs/train_firewx_fm_seed7_template.json \
83
+ --run-name firewx_fm_seed7
84
+ ```
85
+
86
+ Released checkpoints are stored on the Hub under
87
+ `models/wildfire_fm/checkpoints/seed_*/best_firms_prauc.pt`. The released seed
88
+ configs and training summaries are under `models/wildfire_fm/configs/` and
89
+ `models/wildfire_fm/metrics/`.
90
+
91
+ ## Inference adaptation
92
+
93
+ For inference, the important part is to reproduce the same 16-channel tensor
94
+ order. If a downstream environment already constructs `[channel, y, x]` tensors
95
+ in that order, it can use `models/wildfire_fm/modeling_unet.py` directly and
96
+ load one of the released seeded checkpoints.
training/build_phase1_cache_regional_hrrr.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ import warnings
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Dict, List, Tuple
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+ import rasterio
14
+ import xarray as xr
15
+ from rasterio.enums import Resampling
16
+ from rasterio.transform import from_bounds
17
+ from rasterio.warp import reproject, transform, transform_bounds
18
+ from sklearn.neighbors import NearestNeighbors
19
+
20
+ from cache_helpers import ensure_dirs, resampling_from_name, split_for_timestamp
21
+
22
+
23
+ HRRR_RE = re.compile(r"hrrr\.(\d{8})\.t(\d{2})z\.wrfsfcf00\.grib2$")
24
+
25
+ VAR_GROUPS = {
26
+ "level2": {
27
+ "filter_by_keys": {"typeOfLevel": "heightAboveGround", "level": 2},
28
+ "vars": ["t2m", "d2m", "sh2", "r2", "pt"],
29
+ },
30
+ "level10": {
31
+ "filter_by_keys": {"typeOfLevel": "heightAboveGround", "level": 10},
32
+ "vars": ["u10", "v10", "max_10si"],
33
+ },
34
+ "surface_instant": {
35
+ "filter_by_keys": {"typeOfLevel": "surface", "stepType": "instant"},
36
+ "vars": ["cape", "sp", "blh", "vis", "prate", "gust", "t"],
37
+ },
38
+ "surface_accum": {
39
+ "filter_by_keys": {"typeOfLevel": "surface", "stepType": "accum"},
40
+ "vars": ["tp", "ssrun", "bgrun"],
41
+ },
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class SampleRecord:
47
+ issue_ts: pd.Timestamp
48
+ target_ts: pd.Timestamp
49
+ hrrr_path: Path
50
+ label_path: Path
51
+
52
+
53
+ @dataclass
54
+ class RegionalContext:
55
+ config: Dict[str, object]
56
+ cache_root: Path
57
+ firms_dir: Path
58
+ hrrr_roots: List[Path]
59
+ static_layers: Dict[str, Dict[str, str]]
60
+ sample_dir: Path
61
+ static_dir: Path
62
+ manifest_dir: Path
63
+ split_dir: Path
64
+
65
+
66
+ def load_json(path: Path) -> Dict[str, object]:
67
+ return json.loads(path.read_text(encoding="utf-8"))
68
+
69
+
70
+ def build_context(config: Dict[str, object]) -> RegionalContext:
71
+ cache_root = Path(config["cache_root"])
72
+ sample_dir = cache_root / "inputs"
73
+ static_dir = cache_root / "static"
74
+ manifest_dir = cache_root / "manifests"
75
+ split_dir = cache_root / "splits"
76
+ ensure_dirs([sample_dir, static_dir, manifest_dir, split_dir])
77
+ return RegionalContext(
78
+ config=config,
79
+ cache_root=cache_root,
80
+ firms_dir=Path(config["firms_dir"]),
81
+ hrrr_roots=[Path(p) for p in config["hrrr_roots"]],
82
+ static_layers=config["static_layers"],
83
+ sample_dir=sample_dir,
84
+ static_dir=static_dir,
85
+ manifest_dir=manifest_dir,
86
+ split_dir=split_dir,
87
+ )
88
+
89
+
90
+ def build_projected_grid(bounds: Dict[str, float], target_crs: str, resolution_m: float) -> Tuple[np.ndarray, np.ndarray]:
91
+ west, south, east, north = transform_bounds(
92
+ "EPSG:4326",
93
+ target_crs,
94
+ float(bounds["lon_min"]),
95
+ float(bounds["lat_min"]),
96
+ float(bounds["lon_max"]),
97
+ float(bounds["lat_max"]),
98
+ densify_pts=21,
99
+ )
100
+ res = float(resolution_m)
101
+ x_asc = np.arange(west + 0.5 * res, east, res, dtype=np.float32)
102
+ y_asc = np.arange(south + 0.5 * res, north, res, dtype=np.float32)
103
+ if x_asc.size == 0 or y_asc.size == 0:
104
+ raise RuntimeError(f"Empty projected grid for resolution_m={resolution_m}")
105
+ return y_asc[::-1].copy(), x_asc.copy()
106
+
107
+
108
+ def projected_transform(y_desc: np.ndarray, x_asc: np.ndarray) -> rasterio.Affine:
109
+ res_y = float(np.median(np.abs(np.diff(y_desc))))
110
+ res_x = float(np.median(np.diff(x_asc)))
111
+ west = float(x_asc[0] - 0.5 * res_x)
112
+ east = float(x_asc[-1] + 0.5 * res_x)
113
+ south = float(y_desc[-1] - 0.5 * res_y)
114
+ north = float(y_desc[0] + 0.5 * res_y)
115
+ return from_bounds(west, south, east, north, len(x_asc), len(y_desc))
116
+
117
+
118
+ def projected_edges(values_asc: np.ndarray) -> np.ndarray:
119
+ step = float(np.median(np.diff(values_asc)))
120
+ edges = np.empty(values_asc.size + 1, dtype=np.float64)
121
+ edges[1:-1] = 0.5 * (values_asc[:-1] + values_asc[1:])
122
+ edges[0] = values_asc[0] - 0.5 * step
123
+ edges[-1] = values_asc[-1] + 0.5 * step
124
+ return edges
125
+
126
+
127
+ def list_hrrr_issue_paths(roots: List[Path]) -> Dict[pd.Timestamp, Path]:
128
+ out: Dict[pd.Timestamp, Path] = {}
129
+ for root in roots:
130
+ if not root.exists():
131
+ continue
132
+ for path in root.rglob("*.grib2"):
133
+ m = HRRR_RE.match(path.name)
134
+ if not m:
135
+ continue
136
+ date_part, hour_part = m.groups()
137
+ ts = pd.to_datetime(f"{date_part}{hour_part}", format="%Y%m%d%H")
138
+ out.setdefault(ts, path)
139
+ return dict(sorted(out.items()))
140
+
141
+
142
+ def build_sample_records(ctx: RegionalContext) -> List[SampleRecord]:
143
+ hours = {int(v) for v in ctx.config.get("issue_hours", [0, 6, 12, 18])}
144
+ lead_hours = int(ctx.config["target_offset_hours"])
145
+ hrrr_index = list_hrrr_issue_paths(ctx.hrrr_roots)
146
+ records: List[SampleRecord] = []
147
+ for item in ctx.config["input_ranges"]:
148
+ for day in pd.date_range(item["start"], item["end"], freq="D"):
149
+ for hour in sorted(hours):
150
+ issue_ts = pd.Timestamp(year=day.year, month=day.month, day=day.day, hour=hour)
151
+ hrrr_path = hrrr_index.get(issue_ts)
152
+ if hrrr_path is None:
153
+ continue
154
+ target_ts = issue_ts + pd.Timedelta(hours=lead_hours)
155
+ label_path = ctx.firms_dir / f"{target_ts.strftime('%Y%m%d_%H')}.csv"
156
+ if not label_path.exists():
157
+ continue
158
+ records.append(
159
+ SampleRecord(
160
+ issue_ts=issue_ts,
161
+ target_ts=target_ts,
162
+ hrrr_path=hrrr_path,
163
+ label_path=label_path,
164
+ )
165
+ )
166
+ if not records:
167
+ raise RuntimeError("No regional HRRR/FIRMS sample records found.")
168
+ return records
169
+
170
+
171
+ def open_hrrr_dataset(path: Path, group_name: str) -> xr.Dataset:
172
+ warnings.filterwarnings("ignore", category=FutureWarning)
173
+ # Ignore shared cfgrib sidecar indexes under raw HRRR roots. Some existing
174
+ # `.idx` files are truncated/corrupted and can poison concurrent regional
175
+ # cache jobs. Using an empty indexpath forces a fresh in-process scan.
176
+ kwargs = {
177
+ "filter_by_keys": dict(VAR_GROUPS[group_name]["filter_by_keys"]),
178
+ "indexpath": "",
179
+ }
180
+ return xr.open_dataset(path, engine="cfgrib", backend_kwargs=kwargs)
181
+
182
+
183
+ def build_source_resampler(sample_grib: Path, bounds: Dict[str, float], target_crs: str, y_desc: np.ndarray, x_asc: np.ndarray) -> Dict[str, np.ndarray]:
184
+ ds = open_hrrr_dataset(sample_grib, "level2")
185
+ lat2d = np.asarray(ds["latitude"].values, dtype=np.float64)
186
+ lon2d = np.asarray(ds["longitude"].values, dtype=np.float64)
187
+ ds.close()
188
+ lon2d = np.where(lon2d > 180.0, lon2d - 360.0, lon2d)
189
+ buffer_deg = float(bounds.get("buffer_deg", 1.0))
190
+ mask = (
191
+ (lat2d >= float(bounds["lat_min"]) - buffer_deg)
192
+ & (lat2d <= float(bounds["lat_max"]) + buffer_deg)
193
+ & (lon2d >= float(bounds["lon_min"]) - buffer_deg)
194
+ & (lon2d <= float(bounds["lon_max"]) + buffer_deg)
195
+ )
196
+ flat_idx = np.flatnonzero(mask.ravel())
197
+ if flat_idx.size == 0:
198
+ raise RuntimeError("No HRRR source points remain after California bbox crop.")
199
+ src_lat = lat2d.ravel()[flat_idx]
200
+ src_lon = lon2d.ravel()[flat_idx]
201
+ src_x, src_y = transform("EPSG:4326", target_crs, src_lon.tolist(), src_lat.tolist())
202
+ src_xy = np.column_stack([np.asarray(src_x, dtype=np.float32), np.asarray(src_y, dtype=np.float32)])
203
+
204
+ yy, xx = np.meshgrid(y_desc, x_asc, indexing="ij")
205
+ tgt_xy = np.column_stack([xx.ravel().astype(np.float32), yy.ravel().astype(np.float32)])
206
+ nn = NearestNeighbors(n_neighbors=1, algorithm="kd_tree")
207
+ nn.fit(src_xy)
208
+ _, gather = nn.kneighbors(tgt_xy, return_distance=True)
209
+ return {
210
+ "flat_idx": flat_idx.astype(np.int64),
211
+ "gather_idx": gather[:, 0].astype(np.int64),
212
+ "target_h": np.array([len(y_desc)], dtype=np.int64),
213
+ "target_w": np.array([len(x_asc)], dtype=np.int64),
214
+ }
215
+
216
+
217
+ def resample_field(field: np.ndarray, resampler: Dict[str, np.ndarray]) -> np.ndarray:
218
+ flat = np.asarray(field, dtype=np.float32).ravel()[resampler["flat_idx"]]
219
+ gathered = flat[resampler["gather_idx"]]
220
+ h = int(resampler["target_h"][0])
221
+ w = int(resampler["target_w"][0])
222
+ return np.nan_to_num(gathered.reshape(h, w), nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
223
+
224
+
225
+ def load_hrrr_cube(ctx: RegionalContext, records: List[SampleRecord], y_desc: np.ndarray, x_asc: np.ndarray) -> Tuple[np.ndarray, List[str]]:
226
+ weather_names = list(ctx.config["hrrr_vars"])
227
+ weather = np.zeros((len(records), len(weather_names), len(y_desc), len(x_asc)), dtype=np.float32)
228
+ resampler = build_source_resampler(records[0].hrrr_path, ctx.config, str(ctx.config["target_crs"]), y_desc, x_asc)
229
+
230
+ var_to_group = {}
231
+ for group_name, spec in VAR_GROUPS.items():
232
+ for name in spec["vars"]:
233
+ var_to_group[name] = group_name
234
+
235
+ for i, record in enumerate(records):
236
+ opened: Dict[str, xr.Dataset] = {}
237
+ try:
238
+ for j, name in enumerate(weather_names):
239
+ group = var_to_group.get(name)
240
+ if group is None:
241
+ raise KeyError(f"No HRRR group mapping for variable '{name}'")
242
+ ds = opened.get(group)
243
+ if ds is None:
244
+ ds = open_hrrr_dataset(record.hrrr_path, group)
245
+ opened[group] = ds
246
+ if name not in ds.data_vars:
247
+ raise KeyError(f"Variable '{name}' missing from {record.hrrr_path} in group '{group}'")
248
+ weather[i, j] = resample_field(np.asarray(ds[name].values, dtype=np.float32), resampler)
249
+ finally:
250
+ for ds in opened.values():
251
+ ds.close()
252
+ if (i + 1) % 20 == 0 or (i + 1) == len(records):
253
+ print(
254
+ json.dumps(
255
+ {
256
+ "stage": "load_hrrr_cube_progress",
257
+ "loaded_samples": i + 1,
258
+ "total_samples": len(records),
259
+ "latest_issue_timestamp": record.issue_ts.isoformat(),
260
+ }
261
+ ),
262
+ flush=True,
263
+ )
264
+ return weather, weather_names
265
+
266
+
267
+ def load_static_cube_projected(ctx: RegionalContext, y_desc: np.ndarray, x_asc: np.ndarray) -> Tuple[np.ndarray, List[str], np.ndarray]:
268
+ transform_dst = projected_transform(y_desc, x_asc)
269
+ shape = (len(y_desc), len(x_asc))
270
+ res_y = float(np.median(np.abs(np.diff(y_desc))))
271
+ res_x = float(np.median(np.diff(x_asc)))
272
+ west = float(x_asc[0] - 0.5 * res_x)
273
+ east = float(x_asc[-1] + 0.5 * res_x)
274
+ south = float(y_desc[-1] - 0.5 * res_y)
275
+ north = float(y_desc[0] + 0.5 * res_y)
276
+ target_crs = str(ctx.config["target_crs"])
277
+
278
+ arrays: List[np.ndarray] = []
279
+ valid_arrays: List[np.ndarray] = []
280
+ names: List[str] = []
281
+ for name, spec in ctx.static_layers.items():
282
+ names.append(name)
283
+ dst = np.full(shape, np.nan, dtype=np.float32)
284
+ with rasterio.open(spec["path"]) as src:
285
+ src_bounds = transform_bounds(target_crs, src.crs, west, south, east, north, densify_pts=21)
286
+ window = src.window(*src_bounds).round_offsets().round_lengths()
287
+ source = src.read(1, window=window)
288
+ source_transform = src.window_transform(window)
289
+ reproject(
290
+ source=source,
291
+ destination=dst,
292
+ src_transform=source_transform,
293
+ src_crs=src.crs,
294
+ dst_transform=transform_dst,
295
+ dst_crs=target_crs,
296
+ src_nodata=src.nodata,
297
+ dst_nodata=np.nan,
298
+ resampling=resampling_from_name(spec["resampling"]),
299
+ )
300
+ valid = np.isfinite(dst) & (dst > -9000.0)
301
+ valid_arrays.append(valid.astype(np.float32))
302
+ dst = np.where(dst <= -9000.0, np.nan, dst)
303
+ dst = np.nan_to_num(dst, nan=0.0, posinf=0.0, neginf=0.0)
304
+ arrays.append(dst.astype(np.float32))
305
+ valid_fraction = np.stack(valid_arrays, axis=0).mean(axis=0, keepdims=True).astype(np.float32)
306
+ return np.stack(arrays, axis=0), names, valid_fraction
307
+
308
+
309
+ def rasterize_firms_counts_projected(firms_path: Path, y_desc: np.ndarray, x_asc: np.ndarray, target_crs: str, bounds: Dict[str, float]) -> np.ndarray:
310
+ if not firms_path.exists():
311
+ return np.zeros((len(y_desc), len(x_asc)), dtype=np.float32)
312
+ try:
313
+ df = pd.read_csv(firms_path, usecols=["latitude", "longitude", "type"])
314
+ except ValueError:
315
+ df = pd.read_csv(firms_path)
316
+ if "type" in df.columns:
317
+ df = df[df["type"] == 0]
318
+ if df.empty:
319
+ return np.zeros((len(y_desc), len(x_asc)), dtype=np.float32)
320
+ df = df[
321
+ (df["latitude"] >= float(bounds["lat_min"]))
322
+ & (df["latitude"] <= float(bounds["lat_max"]))
323
+ & (df["longitude"] >= float(bounds["lon_min"]))
324
+ & (df["longitude"] <= float(bounds["lon_max"]))
325
+ ]
326
+ if df.empty:
327
+ return np.zeros((len(y_desc), len(x_asc)), dtype=np.float32)
328
+
329
+ xs, ys = transform(
330
+ "EPSG:4326",
331
+ target_crs,
332
+ df["longitude"].astype(float).tolist(),
333
+ df["latitude"].astype(float).tolist(),
334
+ )
335
+ y_asc = y_desc[::-1]
336
+ y_edges = projected_edges(y_asc)
337
+ x_edges = projected_edges(x_asc)
338
+ counts, _, _ = np.histogram2d(np.asarray(ys, dtype=np.float64), np.asarray(xs, dtype=np.float64), bins=[y_edges, x_edges])
339
+ return counts.astype(np.float32)[::-1, :]
340
+
341
+
342
+ def write_cache(
343
+ ctx: RegionalContext,
344
+ records: List[SampleRecord],
345
+ weather: np.ndarray,
346
+ static: np.ndarray,
347
+ static_valid: np.ndarray,
348
+ y_desc: np.ndarray,
349
+ x_asc: np.ndarray,
350
+ weather_names: List[str],
351
+ static_names: List[str],
352
+ ) -> pd.DataFrame:
353
+ static_path = ctx.static_dir / "static_regional_phase1_v1.npz"
354
+ np.savez_compressed(
355
+ static_path,
356
+ static=static,
357
+ static_valid=static_valid,
358
+ lat=y_desc,
359
+ lon=x_asc,
360
+ coord_crs=np.array([str(ctx.config["target_crs"])], dtype=object),
361
+ static_names=np.array(static_names, dtype=object),
362
+ )
363
+
364
+ h = int(len(y_desc))
365
+ w = int(len(x_asc))
366
+ empty_firewx = np.zeros((0, h, w), dtype=np.float32)
367
+ firewx_valid = np.ones((1, h, w), dtype=np.float32)
368
+ rows: List[Dict[str, object]] = []
369
+ target_crs = str(ctx.config["target_crs"])
370
+
371
+ for i, record in enumerate(records):
372
+ y_count = rasterize_firms_counts_projected(record.label_path, y_desc, x_asc, target_crs, ctx.config)
373
+ y_occ = (y_count > 0).astype(np.float32)
374
+ sample_id = record.issue_ts.strftime("%Y%m%d_%H")
375
+ sample_path = ctx.sample_dir / f"phase1_regional_{sample_id}.npz"
376
+ np.savez_compressed(
377
+ sample_path,
378
+ weather=weather[i],
379
+ firewx=empty_firewx,
380
+ firewx_valid=firewx_valid,
381
+ y_count=y_count[None, ...],
382
+ y_occ=y_occ[None, ...],
383
+ lat=y_desc,
384
+ lon=x_asc,
385
+ coord_crs=np.array([target_crs], dtype=object),
386
+ weather_names=np.array(weather_names, dtype=object),
387
+ firewx_names=np.array([], dtype=object),
388
+ )
389
+ split = split_for_timestamp(record.issue_ts, ctx.config)
390
+ rows.append(
391
+ {
392
+ "sample_id": sample_id,
393
+ "input_date": str(record.issue_ts.date()),
394
+ "target_date": str(record.target_ts.date()),
395
+ "input_timestamp": record.issue_ts.isoformat(),
396
+ "target_timestamp": record.target_ts.isoformat(),
397
+ "split": split,
398
+ "sample_path": str(sample_path),
399
+ "static_path": str(static_path),
400
+ "pos_cells": int(y_occ.sum()),
401
+ "fire_points": float(y_count.sum()),
402
+ }
403
+ )
404
+ if (i + 1) % 20 == 0 or (i + 1) == len(records):
405
+ print(
406
+ json.dumps(
407
+ {
408
+ "stage": "write_cache_progress",
409
+ "written_samples": i + 1,
410
+ "total_samples": len(records),
411
+ "latest_sample_id": sample_id,
412
+ }
413
+ ),
414
+ flush=True,
415
+ )
416
+
417
+ manifest = pd.DataFrame(rows).sort_values("input_timestamp").reset_index(drop=True)
418
+ manifest.to_csv(ctx.manifest_dir / "phase1_manifest.csv", index=False)
419
+ for split in ["train", "val", "test"]:
420
+ manifest[manifest["split"] == split].copy().to_csv(ctx.split_dir / f"{split}.csv", index=False)
421
+
422
+ summary = {
423
+ "num_samples": int(len(manifest)),
424
+ "weather_shape": list(weather.shape),
425
+ "firewx_shape": [len(records), 0, h, w],
426
+ "firewx_valid_shape": [len(records), 1, h, w],
427
+ "static_shape": list(static.shape),
428
+ "static_valid_shape": list(static_valid.shape),
429
+ "splits": {k: int((manifest["split"] == k).sum()) for k in ["train", "val", "test"]},
430
+ "total_positive_cells": int(manifest["pos_cells"].sum()),
431
+ "total_fire_points": float(manifest["fire_points"].sum()),
432
+ "grid_height": h,
433
+ "grid_width": w,
434
+ "target_crs": str(ctx.config["target_crs"]),
435
+ "target_resolution_m": int(ctx.config["target_resolution_m"]),
436
+ "target_offset_hours": int(ctx.config["target_offset_hours"]),
437
+ "weather_names": weather_names,
438
+ "static_names": static_names,
439
+ }
440
+ (ctx.manifest_dir / "phase1_cache_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
441
+ return manifest
442
+
443
+
444
+ def main() -> None:
445
+ parser = argparse.ArgumentParser()
446
+ parser.add_argument("--config", type=Path, required=True)
447
+ args = parser.parse_args()
448
+
449
+ config = load_json(args.config)
450
+ ctx = build_context(config)
451
+ records = build_sample_records(ctx)
452
+ y_desc, x_asc = build_projected_grid(ctx.config, str(ctx.config["target_crs"]), float(ctx.config["target_resolution_m"]))
453
+
454
+ print(
455
+ f"[stage] regional_samples={len(records)} first={records[0].issue_ts.isoformat()} last={records[-1].issue_ts.isoformat()}",
456
+ flush=True,
457
+ )
458
+ print(
459
+ f"[stage] grid_crs={ctx.config['target_crs']} resolution_m={ctx.config['target_resolution_m']} "
460
+ f"shape=({len(y_desc)}, {len(x_asc)})",
461
+ flush=True,
462
+ )
463
+ print("[stage] load_hrrr_cube", flush=True)
464
+ weather, weather_names = load_hrrr_cube(ctx, records, y_desc, x_asc)
465
+ print("[stage] load_static_cube", flush=True)
466
+ static, static_names, static_valid = load_static_cube_projected(ctx, y_desc, x_asc)
467
+ print("[stage] write_cache", flush=True)
468
+ manifest = write_cache(
469
+ ctx=ctx,
470
+ records=records,
471
+ weather=weather,
472
+ static=static,
473
+ static_valid=static_valid,
474
+ y_desc=y_desc,
475
+ x_asc=x_asc,
476
+ weather_names=weather_names,
477
+ static_names=static_names,
478
+ )
479
+ print("[stage] done", flush=True)
480
+ print(f"Built regional HRRR cache with {len(manifest)} samples.")
481
+
482
+
483
+ if __name__ == "__main__":
484
+ raise SystemExit(main())
training/cache_helpers.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Dict, List
5
+
6
+ import pandas as pd
7
+ from rasterio.enums import Resampling
8
+
9
+
10
+ def ensure_dirs(paths: List[Path]) -> None:
11
+ for path in paths:
12
+ path.mkdir(parents=True, exist_ok=True)
13
+
14
+
15
+ def resampling_from_name(name: str) -> Resampling:
16
+ mapping = {
17
+ "nearest": Resampling.nearest,
18
+ "bilinear": Resampling.bilinear,
19
+ "average": Resampling.average,
20
+ }
21
+ return mapping[name]
22
+
23
+
24
+ def split_for_timestamp(ts: pd.Timestamp, config: Dict[str, object]) -> str:
25
+ if "split_years" in config:
26
+ for split, years in config["split_years"].items():
27
+ if int(ts.year) in [int(v) for v in years]:
28
+ return split
29
+ raise RuntimeError(f"Year {ts.year} does not map to any split.")
30
+
31
+ split_months = config["split_months"]
32
+ for split, months in split_months.items():
33
+ if int(ts.month) in months:
34
+ return split
35
+ raise RuntimeError(f"Month {ts.month} does not map to any split.")
training/configs/stage1_cache_regional_hrrr_ca_5km_l12_template.json ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cache_root": "/path/to/local/firewx_fm_cache/regional_hrrr_ca_5km_l12",
3
+ "firms_dir": "/path/to/firms_three_hourly_csv",
4
+ "hrrr_roots": [
5
+ "/path/to/hrrr/2024",
6
+ "/path/to/optional/hrrr_backfill"
7
+ ],
8
+ "input_ranges": [
9
+ {
10
+ "start": "2024-06-01",
11
+ "end": "2024-10-30"
12
+ }
13
+ ],
14
+ "issue_hours": [
15
+ 0,
16
+ 6,
17
+ 12,
18
+ 18
19
+ ],
20
+ "target_offset_hours": 12,
21
+ "lat_min": 32.0,
22
+ "lat_max": 42.5,
23
+ "lon_min": -125.0,
24
+ "lon_max": -114.0,
25
+ "buffer_deg": 1.0,
26
+ "target_crs": "EPSG:5070",
27
+ "target_resolution_m": 5000,
28
+ "hrrr_vars": [
29
+ "t2m",
30
+ "d2m",
31
+ "u10",
32
+ "v10",
33
+ "cape",
34
+ "sp",
35
+ "blh",
36
+ "vis",
37
+ "prate",
38
+ "tp"
39
+ ],
40
+ "static_layers": {
41
+ "fuel_fbfm40": {
42
+ "path": "/path/to/LANDFIRE_FBFM40_CONUS.tif",
43
+ "resampling": "nearest"
44
+ },
45
+ "canopy_cover": {
46
+ "path": "/path/to/LANDFIRE_CC_CONUS.tif",
47
+ "resampling": "nearest"
48
+ },
49
+ "housing_density": {
50
+ "path": "/path/to/WRC_HUDen_CONUS.tif",
51
+ "resampling": "bilinear"
52
+ },
53
+ "population": {
54
+ "path": "/path/to/landscan-global-2024.tif",
55
+ "resampling": "bilinear"
56
+ }
57
+ },
58
+ "split_months": {
59
+ "train": [
60
+ 6,
61
+ 7,
62
+ 8
63
+ ],
64
+ "val": [
65
+ 9
66
+ ],
67
+ "test": [
68
+ 10
69
+ ]
70
+ }
71
+ }
training/configs/train_firewx_fm_seed7_template.json ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "index_root": "/path/to/local/firewx_fm_cache/regional_hrrr_ca_5km_l12",
3
+ "run_root": "/path/to/local/firewx_fm_runs",
4
+ "tile_size": 32,
5
+ "max_positive_tiles_per_sample": 64,
6
+ "min_negative_tiles_per_sample": 4,
7
+ "negative_to_positive_ratio": 3.0,
8
+ "positive_rate_source": "tiles",
9
+ "pos_weight_source": "tiles",
10
+ "pos_weight_cap": 50.0,
11
+ "batch_size": 32,
12
+ "eval_batch_size": 2,
13
+ "epochs": 10,
14
+ "learning_rate": 0.0002,
15
+ "weight_decay": 0.0001,
16
+ "base_channels": 32,
17
+ "dropout": 0.1,
18
+ "norm_type": "group",
19
+ "norm_groups": 8,
20
+ "init_head_bias_from_positive_rate": true,
21
+ "augment_flip": true,
22
+ "threshold": 0.5,
23
+ "metric_thresholds": [
24
+ 0.1,
25
+ 0.2,
26
+ 0.3,
27
+ 0.5,
28
+ 0.7,
29
+ 0.9
30
+ ],
31
+ "topk_area_fractions": [
32
+ 0.01,
33
+ 0.05,
34
+ 0.1
35
+ ],
36
+ "fss_radii": [
37
+ 1,
38
+ 2,
39
+ 4,
40
+ 8
41
+ ],
42
+ "reliability_bins": 10,
43
+ "amp": true,
44
+ "seed": 7,
45
+ "boundary_radii": [
46
+ 1,
47
+ 2,
48
+ 4
49
+ ],
50
+ "coarsen_factors": [
51
+ 2,
52
+ 4,
53
+ 8
54
+ ],
55
+ "use_aux_spatial_head": true,
56
+ "aux_spatial_radius": 2,
57
+ "aux_spatial_loss_weight": 0.5,
58
+ "train_target_mode": "dilate_max",
59
+ "train_target_radius": 2,
60
+ "loss_type": "bce",
61
+ "init_checkpoint": ""
62
+ }
training/eval_metrics.py ADDED
@@ -0,0 +1,826 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Dict, Iterable, List
5
+
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn.functional as F
9
+ from sklearn.metrics import average_precision_score, roc_auc_score
10
+
11
+ try:
12
+ from scipy import ndimage as _scipy_ndimage
13
+ except Exception: # pragma: no cover - optional on login nodes, present in Slurm env.
14
+ _scipy_ndimage = None
15
+
16
+
17
+ def _safe_div(num: float, den: float) -> float:
18
+ return float(num / den) if den else 0.0
19
+
20
+
21
+ def threshold_metrics(prob: np.ndarray, target: np.ndarray, threshold: float) -> Dict[str, float]:
22
+ pred = prob >= threshold
23
+ pos = target > 0.5
24
+ tp = int(np.logical_and(pred, pos).sum())
25
+ fp = int(np.logical_and(pred, ~pos).sum())
26
+ fn = int(np.logical_and(~pred, pos).sum())
27
+ precision = _safe_div(tp, tp + fp)
28
+ recall = _safe_div(tp, tp + fn)
29
+ far = _safe_div(fp, tp + fp)
30
+ csi = _safe_div(tp, tp + fp + fn)
31
+ f1 = _safe_div(2.0 * precision * recall, precision + recall)
32
+ beta_sq = 4.0
33
+ f2 = _safe_div((1.0 + beta_sq) * precision * recall, beta_sq * precision + recall)
34
+ freq_bias = _safe_div(tp + fp, tp + fn)
35
+ return {
36
+ "threshold": float(threshold),
37
+ "tp": tp,
38
+ "fp": fp,
39
+ "fn": fn,
40
+ "precision": precision,
41
+ "recall": recall,
42
+ "far": far,
43
+ "csi": csi,
44
+ "f1": f1,
45
+ "f2": f2,
46
+ "frequency_bias": freq_bias,
47
+ "predicted_positive_rate": float(pred.mean()),
48
+ }
49
+
50
+
51
+ def log_score(prob: np.ndarray, target: np.ndarray, eps: float = 1e-6) -> float:
52
+ prob_clip = np.clip(np.asarray(prob, dtype=np.float32), eps, 1.0 - eps)
53
+ target_arr = np.asarray(target, dtype=np.float32)
54
+ loss = -(target_arr * np.log(prob_clip) + (1.0 - target_arr) * np.log(1.0 - prob_clip))
55
+ return float(np.mean(loss))
56
+
57
+
58
+ def _spatial_dilate(mask: torch.Tensor, radius: int) -> torch.Tensor:
59
+ if radius <= 0:
60
+ return mask > 0.5
61
+ pooled = F.max_pool2d(
62
+ mask.float().unsqueeze(1),
63
+ kernel_size=radius * 2 + 1,
64
+ stride=1,
65
+ padding=radius,
66
+ ).squeeze(1)
67
+ return pooled > 0.5
68
+
69
+
70
+ def _spatial_erode(mask: torch.Tensor, radius: int) -> torch.Tensor:
71
+ if radius <= 0:
72
+ return mask > 0.5
73
+ inv = 1.0 - (mask > 0.5).float()
74
+ pooled = F.max_pool2d(
75
+ inv.unsqueeze(1),
76
+ kernel_size=radius * 2 + 1,
77
+ stride=1,
78
+ padding=radius,
79
+ ).squeeze(1)
80
+ return pooled < 0.5
81
+
82
+
83
+ def _boundary_mask(mask: torch.Tensor, width: int = 1) -> torch.Tensor:
84
+ mask_bool = mask > 0.5
85
+ if width <= 0:
86
+ return mask_bool
87
+ eroded = _spatial_erode(mask_bool.float(), width)
88
+ return mask_bool & ~eroded
89
+
90
+
91
+ def _sample_times_to_hours(sample_times: np.ndarray) -> np.ndarray:
92
+ times = np.asarray(sample_times)
93
+ if np.issubdtype(times.dtype, np.datetime64):
94
+ return times.astype("datetime64[h]").astype(np.int64)
95
+ return times.astype(np.int64)
96
+
97
+
98
+ def _build_tolerance_support(
99
+ pred_bool: torch.Tensor,
100
+ target_bool: torch.Tensor,
101
+ sample_times: np.ndarray,
102
+ temporal_tolerance_steps: int,
103
+ spatial_tolerance_radius: int,
104
+ time_step_hours: int,
105
+ ) -> tuple[torch.Tensor, torch.Tensor, int]:
106
+ times_h = _sample_times_to_hours(sample_times)
107
+ target_support = torch.zeros_like(target_bool, dtype=torch.bool)
108
+ pred_support = torch.zeros_like(pred_bool, dtype=torch.bool)
109
+ tolerance_hours = int(temporal_tolerance_steps) * int(time_step_hours)
110
+
111
+ for idx, current_time in enumerate(times_h):
112
+ window = np.abs(times_h - current_time) <= tolerance_hours
113
+ target_union = target_bool[window].float().amax(dim=0, keepdim=True)
114
+ pred_union = pred_bool[window].float().amax(dim=0, keepdim=True)
115
+ target_support[idx] = _spatial_dilate(target_union, spatial_tolerance_radius)[0]
116
+ pred_support[idx] = _spatial_dilate(pred_union, spatial_tolerance_radius)[0]
117
+ return target_support, pred_support, tolerance_hours
118
+
119
+
120
+ def tolerant_threshold_metrics(
121
+ prob_maps: np.ndarray,
122
+ target_maps: np.ndarray,
123
+ sample_times: np.ndarray,
124
+ threshold: float,
125
+ temporal_tolerance_steps: int,
126
+ spatial_tolerance_radius: int,
127
+ region_mask: np.ndarray | None = None,
128
+ time_step_hours: int = 24,
129
+ ) -> Dict[str, float]:
130
+ pred = torch.from_numpy((prob_maps >= threshold).astype(np.float32))
131
+ target = torch.from_numpy((target_maps > 0.5).astype(np.float32))
132
+ pred_bool = pred > 0.5
133
+ target_bool = target > 0.5
134
+ target_support, pred_support, tolerance_hours = _build_tolerance_support(
135
+ pred_bool=pred_bool,
136
+ target_bool=target_bool,
137
+ sample_times=sample_times,
138
+ temporal_tolerance_steps=temporal_tolerance_steps,
139
+ spatial_tolerance_radius=spatial_tolerance_radius,
140
+ time_step_hours=time_step_hours,
141
+ )
142
+
143
+ matched_pred = pred_bool & target_support
144
+ matched_target = target_bool & pred_support
145
+
146
+ if region_mask is not None:
147
+ region = torch.from_numpy(_region_mask_to_bool(region_mask, prob_maps.shape[1:])).to(dtype=torch.bool)
148
+ pred_bool = pred_bool & region.unsqueeze(0)
149
+ target_bool = target_bool & region.unsqueeze(0)
150
+ matched_pred = matched_pred & region.unsqueeze(0)
151
+ matched_target = matched_target & region.unsqueeze(0)
152
+ total_cells = int(region.sum().item()) * int(pred_bool.shape[0])
153
+ else:
154
+ total_cells = int(pred_bool.numel())
155
+
156
+ pred_total = int(pred_bool.sum().item())
157
+ target_total = int(target_bool.sum().item())
158
+ matched_pred_total = int(matched_pred.sum().item())
159
+ matched_target_total = int(matched_target.sum().item())
160
+ precision = _safe_div(matched_pred_total, pred_total)
161
+ recall = _safe_div(matched_target_total, target_total)
162
+ f1 = _safe_div(2.0 * precision * recall, precision + recall)
163
+ return {
164
+ "threshold": float(threshold),
165
+ "temporal_tolerance_steps": int(temporal_tolerance_steps),
166
+ "temporal_tolerance_hours": int(tolerance_hours),
167
+ "time_step_hours": int(time_step_hours),
168
+ "spatial_tolerance_radius": int(spatial_tolerance_radius),
169
+ "predicted_positive_cells": pred_total,
170
+ "target_positive_cells": target_total,
171
+ "matched_predicted_cells": matched_pred_total,
172
+ "matched_target_cells": matched_target_total,
173
+ "precision": precision,
174
+ "recall": recall,
175
+ "f1": f1,
176
+ "predicted_positive_rate": _safe_div(pred_total, total_cells),
177
+ }
178
+
179
+
180
+ def neighborhood_contingency(
181
+ prob_maps: np.ndarray,
182
+ target_maps: np.ndarray,
183
+ sample_times: np.ndarray,
184
+ threshold: float,
185
+ temporal_tolerance_steps: int,
186
+ spatial_tolerance_radius: int,
187
+ region_mask: np.ndarray | None = None,
188
+ time_step_hours: int = 24,
189
+ ) -> Dict[str, float]:
190
+ pred_bool = torch.from_numpy((prob_maps >= threshold).astype(np.float32)) > 0.5
191
+ target_bool = torch.from_numpy((target_maps > 0.5).astype(np.float32)) > 0.5
192
+ target_support, pred_support, tolerance_hours = _build_tolerance_support(
193
+ pred_bool=pred_bool,
194
+ target_bool=target_bool,
195
+ sample_times=sample_times,
196
+ temporal_tolerance_steps=temporal_tolerance_steps,
197
+ spatial_tolerance_radius=spatial_tolerance_radius,
198
+ time_step_hours=time_step_hours,
199
+ )
200
+
201
+ if region_mask is not None:
202
+ region = torch.from_numpy(_region_mask_to_bool(region_mask, prob_maps.shape[1:])).to(dtype=torch.bool)
203
+ pred_bool = pred_bool & region.unsqueeze(0)
204
+ target_bool = target_bool & region.unsqueeze(0)
205
+ target_support = target_support & region.unsqueeze(0)
206
+ pred_support = pred_support & region.unsqueeze(0)
207
+
208
+ hits = 0
209
+ false_alarms = 0
210
+ misses = 0
211
+ true_negatives = 0
212
+ for idx in range(int(pred_bool.shape[0])):
213
+ pred_event = bool(pred_bool[idx].any().item())
214
+ target_event = bool(target_bool[idx].any().item())
215
+ pred_match = bool((pred_bool[idx] & target_support[idx]).any().item()) if pred_event else False
216
+ target_match = bool((target_bool[idx] & pred_support[idx]).any().item()) if target_event else False
217
+ if target_event and target_match:
218
+ hits += 1
219
+ elif target_event:
220
+ misses += 1
221
+ elif not target_event and not pred_event:
222
+ true_negatives += 1
223
+ if pred_event and not pred_match:
224
+ false_alarms += 1
225
+
226
+ precision = _safe_div(hits, hits + false_alarms)
227
+ recall = _safe_div(hits, hits + misses)
228
+ f1 = _safe_div(2.0 * precision * recall, precision + recall)
229
+ far = _safe_div(false_alarms, hits + false_alarms)
230
+ csi = _safe_div(hits, hits + false_alarms + misses)
231
+ return {
232
+ "threshold": float(threshold),
233
+ "temporal_tolerance_steps": int(temporal_tolerance_steps),
234
+ "temporal_tolerance_hours": int(tolerance_hours),
235
+ "time_step_hours": int(time_step_hours),
236
+ "spatial_tolerance_radius": int(spatial_tolerance_radius),
237
+ "hits": int(hits),
238
+ "false_alarms": int(false_alarms),
239
+ "misses": int(misses),
240
+ "true_negatives": int(true_negatives),
241
+ "precision": precision,
242
+ "recall": recall,
243
+ "f1": f1,
244
+ "far": far,
245
+ "csi": csi,
246
+ "predicted_event_rate": _safe_div(hits + false_alarms, pred_bool.shape[0]),
247
+ "target_event_rate": _safe_div(hits + misses, pred_bool.shape[0]),
248
+ }
249
+
250
+
251
+ def neighborhood_contingency_metrics(
252
+ prob_maps: np.ndarray,
253
+ target_maps: np.ndarray,
254
+ sample_times: np.ndarray,
255
+ thresholds: Iterable[float],
256
+ temporal_tolerances_steps: Iterable[int],
257
+ spatial_tolerances_radii: Iterable[int],
258
+ time_step_hours: int = 24,
259
+ ) -> Dict[str, Dict[str, Dict[str, float]]]:
260
+ out: Dict[str, Dict[str, Dict[str, float]]] = {}
261
+ temporal_values = [int(v) for v in temporal_tolerances_steps]
262
+ spatial_values = [int(v) for v in spatial_tolerances_radii]
263
+ for temporal_steps in temporal_values:
264
+ for spatial_radius in spatial_values:
265
+ if temporal_steps == 0 and spatial_radius == 0:
266
+ continue
267
+ combo_key = f"t{temporal_steps}_s{spatial_radius}"
268
+ out[combo_key] = {
269
+ f"{float(t):.4f}": neighborhood_contingency(
270
+ prob_maps=prob_maps,
271
+ target_maps=target_maps,
272
+ sample_times=sample_times,
273
+ threshold=float(t),
274
+ temporal_tolerance_steps=temporal_steps,
275
+ spatial_tolerance_radius=spatial_radius,
276
+ time_step_hours=time_step_hours,
277
+ )
278
+ for t in thresholds
279
+ }
280
+ return out
281
+
282
+
283
+ def reliability_bins(prob: np.ndarray, target: np.ndarray, n_bins: int) -> List[Dict[str, float]]:
284
+ edges = np.linspace(0.0, 1.0, n_bins + 1)
285
+ rows: List[Dict[str, float]] = []
286
+ for idx in range(n_bins):
287
+ lo = edges[idx]
288
+ hi = edges[idx + 1]
289
+ if idx == n_bins - 1:
290
+ mask = (prob >= lo) & (prob <= hi)
291
+ else:
292
+ mask = (prob >= lo) & (prob < hi)
293
+ count = int(mask.sum())
294
+ if count == 0:
295
+ rows.append(
296
+ {
297
+ "bin": idx,
298
+ "lo": float(lo),
299
+ "hi": float(hi),
300
+ "count": 0,
301
+ "mean_confidence": 0.0,
302
+ "empirical_accuracy": 0.0,
303
+ }
304
+ )
305
+ continue
306
+ mean_conf = float(prob[mask].mean())
307
+ acc = float(target[mask].mean())
308
+ rows.append(
309
+ {
310
+ "bin": idx,
311
+ "lo": float(lo),
312
+ "hi": float(hi),
313
+ "count": count,
314
+ "mean_confidence": mean_conf,
315
+ "empirical_accuracy": acc,
316
+ }
317
+ )
318
+ return rows
319
+
320
+
321
+ def expected_calibration_error(prob: np.ndarray, target: np.ndarray, n_bins: int) -> float:
322
+ bins = reliability_bins(prob, target, n_bins)
323
+ total = max(int(prob.size), 1)
324
+ return float(
325
+ sum(abs(row["empirical_accuracy"] - row["mean_confidence"]) * row["count"] for row in bins) / total
326
+ )
327
+
328
+
329
+ def topk_area_metrics(prob: np.ndarray, target: np.ndarray, fractions: Iterable[float]) -> Dict[str, Dict[str, float]]:
330
+ order = np.argsort(prob)[::-1]
331
+ total = prob.size
332
+ positive_rate = float(target.mean())
333
+ pos_total = max(float(target.sum()), 1.0)
334
+ out: Dict[str, Dict[str, float]] = {}
335
+ for frac in fractions:
336
+ frac = float(frac)
337
+ keep = max(int(math.ceil(total * frac)), 1)
338
+ idx = order[:keep]
339
+ top_target = target[idx]
340
+ precision = float(top_target.mean())
341
+ recall = float(top_target.sum() / pos_total)
342
+ lift = float(precision / positive_rate) if positive_rate > 0 else 0.0
343
+ out[f"{frac:.4f}"] = {
344
+ "fraction": frac,
345
+ "cells": keep,
346
+ "precision": precision,
347
+ "recall": recall,
348
+ "lift": lift,
349
+ }
350
+ return out
351
+
352
+
353
+ def _crop_for_factor(arr: np.ndarray, factor: int) -> np.ndarray:
354
+ if factor <= 1:
355
+ return arr
356
+ h = int(arr.shape[-2])
357
+ w = int(arr.shape[-1])
358
+ new_h = (h // factor) * factor
359
+ new_w = (w // factor) * factor
360
+ if new_h <= 0 or new_w <= 0:
361
+ raise ValueError(f"Cannot coarsen shape {(h, w)} by factor={factor}")
362
+ return arr[..., :new_h, :new_w]
363
+
364
+
365
+ def _avg_pool_maps(arr: np.ndarray, factor: int) -> np.ndarray:
366
+ if factor <= 1:
367
+ return arr.astype(np.float32, copy=False)
368
+ cropped = _crop_for_factor(arr, factor).astype(np.float32, copy=False)
369
+ tensor = torch.from_numpy(cropped).unsqueeze(1)
370
+ pooled = F.avg_pool2d(tensor, kernel_size=factor, stride=factor)
371
+ return pooled.squeeze(1).cpu().numpy().astype(np.float32, copy=False)
372
+
373
+
374
+ def _max_pool_binary_maps(arr: np.ndarray, factor: int) -> np.ndarray:
375
+ if factor <= 1:
376
+ return (arr > 0.5).astype(np.float32, copy=False)
377
+ cropped = _crop_for_factor(arr, factor).astype(np.float32, copy=False)
378
+ tensor = torch.from_numpy((cropped > 0.5).astype(np.float32)).unsqueeze(1)
379
+ pooled = F.max_pool2d(tensor, kernel_size=factor, stride=factor)
380
+ return (pooled.squeeze(1).cpu().numpy() > 0.5).astype(np.float32)
381
+
382
+
383
+ def coarsened_metrics(
384
+ prob_maps: np.ndarray,
385
+ target_maps: np.ndarray,
386
+ thresholds: Iterable[float],
387
+ factors: Iterable[int],
388
+ reference_positive_rate: float | None = None,
389
+ ) -> Dict[str, Dict[str, object]]:
390
+ out: Dict[str, Dict[str, object]] = {}
391
+ target_binary = (target_maps > 0.5).astype(np.float32, copy=False)
392
+ for factor in factors:
393
+ factor = int(factor)
394
+ if factor <= 1:
395
+ continue
396
+ prob_coarse = _avg_pool_maps(prob_maps, factor)
397
+ target_fraction = _avg_pool_maps(target_binary, factor)
398
+ target_any = _max_pool_binary_maps(target_binary, factor)
399
+
400
+ prob_flat = prob_coarse.reshape(-1)
401
+ any_flat = target_any.reshape(-1)
402
+ fraction_flat = target_fraction.reshape(-1)
403
+
404
+ any_metrics: Dict[str, object] = {
405
+ "positive_rate": float(any_flat.mean()) if any_flat.size > 0 else 0.0,
406
+ "positive_cells": int(any_flat.sum()) if any_flat.size > 0 else 0,
407
+ "total_cells": int(any_flat.size),
408
+ "pr_auc": float(average_precision_score(any_flat, prob_flat)) if float(any_flat.sum()) > 0 else 0.0,
409
+ "brier": float(np.mean((prob_flat - any_flat) ** 2)) if any_flat.size > 0 else 0.0,
410
+ "log_score": log_score(prob_flat, any_flat) if any_flat.size > 0 else 0.0,
411
+ "threshold_metrics": {
412
+ f"{float(t):.4f}": threshold_metrics(prob_flat, any_flat, float(t)) for t in thresholds
413
+ }
414
+ if any_flat.size > 0
415
+ else {},
416
+ }
417
+ if any_flat.size > 0 and float(np.unique(any_flat).size) > 1:
418
+ any_metrics["auroc"] = float(roc_auc_score(any_flat, prob_flat))
419
+ else:
420
+ any_metrics["auroc"] = 0.0
421
+ ref_rate = reference_positive_rate if reference_positive_rate is not None else float(any_flat.mean())
422
+ brier_ref = float(np.mean((ref_rate - any_flat) ** 2)) if any_flat.size > 0 else 0.0
423
+ any_metrics["reference_positive_rate"] = float(ref_rate)
424
+ any_metrics["brier_skill_score"] = float(1.0 - any_metrics["brier"] / brier_ref) if brier_ref > 0 else 0.0
425
+
426
+ out[f"x{factor}"] = {
427
+ "grid_shape": {"lat": int(prob_coarse.shape[-2]), "lon": int(prob_coarse.shape[-1])},
428
+ "any": any_metrics,
429
+ "fraction": {
430
+ "mean_target_fraction": float(fraction_flat.mean()) if fraction_flat.size > 0 else 0.0,
431
+ "mean_predicted_probability": float(prob_flat.mean()) if prob_flat.size > 0 else 0.0,
432
+ "mae": float(np.mean(np.abs(prob_flat - fraction_flat))) if fraction_flat.size > 0 else 0.0,
433
+ "rmse": float(np.sqrt(np.mean((prob_flat - fraction_flat) ** 2))) if fraction_flat.size > 0 else 0.0,
434
+ "brier": float(np.mean((prob_flat - fraction_flat) ** 2)) if fraction_flat.size > 0 else 0.0,
435
+ },
436
+ }
437
+ return out
438
+
439
+
440
+ def _region_mask_to_bool(mask: np.ndarray, sample_shape: tuple[int, ...]) -> np.ndarray:
441
+ region = np.asarray(mask).astype(bool)
442
+ if region.shape == sample_shape:
443
+ return region
444
+ raise ValueError(f"Region mask shape {region.shape} does not match sample shape {sample_shape}")
445
+
446
+
447
+ def region_metric_bundle(
448
+ prob_maps: np.ndarray,
449
+ target_maps: np.ndarray,
450
+ mask: np.ndarray,
451
+ thresholds: Iterable[float],
452
+ topk_fractions: Iterable[float],
453
+ n_bins: int,
454
+ reference_positive_rate: float | None = None,
455
+ sample_times: np.ndarray | None = None,
456
+ temporal_tolerances_steps: Iterable[int] | None = None,
457
+ spatial_tolerances_radii: Iterable[int] | None = None,
458
+ time_step_hours: int = 24,
459
+ ) -> Dict[str, object]:
460
+ region = _region_mask_to_bool(mask, prob_maps.shape[1:])
461
+ prob = prob_maps[:, region].reshape(-1)
462
+ target = target_maps[:, region].reshape(-1)
463
+ positive_rate = float(target.mean()) if target.size > 0 else 0.0
464
+ metrics: Dict[str, object] = {
465
+ "mask_cells": int(region.sum()),
466
+ "mask_fraction": float(region.mean()),
467
+ "positive_rate": positive_rate,
468
+ "positive_cells": int(target.sum()) if target.size > 0 else 0,
469
+ "total_cells": int(target.size),
470
+ "pr_auc": float(average_precision_score(target, prob)) if float(target.sum()) > 0 else 0.0,
471
+ "brier": float(np.mean((prob - target) ** 2)) if target.size > 0 else 0.0,
472
+ "log_score": log_score(prob, target) if target.size > 0 else 0.0,
473
+ "ece": expected_calibration_error(prob, target, n_bins) if target.size > 0 else 0.0,
474
+ "reliability_bins": reliability_bins(prob, target, n_bins) if target.size > 0 else [],
475
+ "threshold_metrics": {f"{float(t):.4f}": threshold_metrics(prob, target, float(t)) for t in thresholds}
476
+ if target.size > 0
477
+ else {},
478
+ "topk_area_metrics": topk_area_metrics(prob, target, topk_fractions) if target.size > 0 else {},
479
+ }
480
+ if target.size > 0 and float(np.unique(target).size) > 1:
481
+ metrics["auroc"] = float(roc_auc_score(target, prob))
482
+ else:
483
+ metrics["auroc"] = 0.0
484
+ ref_rate = reference_positive_rate if reference_positive_rate is not None else positive_rate
485
+ brier_ref = float(np.mean((ref_rate - target) ** 2)) if target.size > 0 else 0.0
486
+ metrics["reference_positive_rate"] = float(ref_rate)
487
+ metrics["brier_skill_score"] = float(1.0 - metrics["brier"] / brier_ref) if brier_ref > 0 else 0.0
488
+ if sample_times is not None:
489
+ temporal_values = [int(v) for v in (temporal_tolerances_steps or [])]
490
+ spatial_values = [int(v) for v in (spatial_tolerances_radii or [])]
491
+ tolerant_metrics: Dict[str, Dict[str, Dict[str, float]]] = {}
492
+ for temporal_steps in temporal_values:
493
+ for spatial_radius in spatial_values:
494
+ if temporal_steps == 0 and spatial_radius == 0:
495
+ continue
496
+ combo_key = f"t{temporal_steps}_s{spatial_radius}"
497
+ tolerant_metrics[combo_key] = {
498
+ f"{float(t):.4f}": tolerant_threshold_metrics(
499
+ prob_maps=prob_maps,
500
+ target_maps=target_maps,
501
+ sample_times=sample_times,
502
+ threshold=float(t),
503
+ temporal_tolerance_steps=temporal_steps,
504
+ spatial_tolerance_radius=spatial_radius,
505
+ region_mask=region,
506
+ time_step_hours=time_step_hours,
507
+ )
508
+ for t in thresholds
509
+ }
510
+ if tolerant_metrics:
511
+ metrics["tolerant_threshold_metrics"] = tolerant_metrics
512
+ return metrics
513
+
514
+
515
+ def fss_metrics(
516
+ prob_maps: np.ndarray,
517
+ target_maps: np.ndarray,
518
+ thresholds: Iterable[float],
519
+ radii: Iterable[int],
520
+ ) -> Dict[str, Dict[str, float]]:
521
+ prob_t = torch.from_numpy(prob_maps.astype(np.float32))
522
+ tgt_t = torch.from_numpy((target_maps > 0.5).astype(np.float32))
523
+ out: Dict[str, Dict[str, float]] = {}
524
+ for threshold in thresholds:
525
+ pred = (prob_t >= float(threshold)).float()
526
+ row: Dict[str, float] = {}
527
+ for radius in radii:
528
+ radius = int(radius)
529
+ kernel = radius * 2 + 1
530
+ frac_pred = F.avg_pool2d(pred.unsqueeze(1), kernel_size=kernel, stride=1, padding=radius).squeeze(1)
531
+ frac_tgt = F.avg_pool2d(tgt_t.unsqueeze(1), kernel_size=kernel, stride=1, padding=radius).squeeze(1)
532
+ mse = torch.mean((frac_pred - frac_tgt) ** 2).item()
533
+ ref = torch.mean(frac_pred**2 + frac_tgt**2).item()
534
+ score = 1.0 - (mse / ref) if ref > 0 else 1.0
535
+ row[str(radius)] = float(score)
536
+ out[f"{float(threshold):.4f}"] = row
537
+ return out
538
+
539
+
540
+ def boundary_metrics(
541
+ prob_maps: np.ndarray,
542
+ target_maps: np.ndarray,
543
+ thresholds: Iterable[float],
544
+ radii: Iterable[int],
545
+ boundary_width: int = 1,
546
+ ) -> Dict[str, Dict[str, Dict[str, float]]]:
547
+ prob_t = torch.from_numpy(prob_maps.astype(np.float32))
548
+ tgt_boundary = _boundary_mask(torch.from_numpy((target_maps > 0.5).astype(np.float32)), width=boundary_width)
549
+ out: Dict[str, Dict[str, Dict[str, float]]] = {}
550
+ for threshold in thresholds:
551
+ pred_boundary = _boundary_mask((prob_t >= float(threshold)).float(), width=boundary_width)
552
+ row: Dict[str, Dict[str, float]] = {}
553
+ pred_boundary_cells = int(pred_boundary.sum().item())
554
+ tgt_boundary_cells = int(tgt_boundary.sum().item())
555
+ for radius in radii:
556
+ radius = int(radius)
557
+ pred_band = _spatial_dilate(pred_boundary.float(), radius)
558
+ tgt_band = _spatial_dilate(tgt_boundary.float(), radius)
559
+ band_intersection = int((pred_band & tgt_band).sum().item())
560
+ band_union = int((pred_band | tgt_band).sum().item())
561
+ pred_match = int((pred_boundary & tgt_band).sum().item())
562
+ tgt_match = int((tgt_boundary & pred_band).sum().item())
563
+ denom = pred_boundary_cells + tgt_boundary_cells
564
+ row[str(radius)] = {
565
+ "boundary_iou": _safe_div(band_intersection, band_union) if band_union > 0 else 1.0,
566
+ "surface_dice": _safe_div(pred_match + tgt_match, denom) if denom > 0 else 1.0,
567
+ "pred_boundary_cells": pred_boundary_cells,
568
+ "target_boundary_cells": tgt_boundary_cells,
569
+ }
570
+ out[f"{float(threshold):.4f}"] = row
571
+ return out
572
+
573
+
574
+ def buffered_overlap_metrics(
575
+ prob_maps: np.ndarray,
576
+ target_maps: np.ndarray,
577
+ thresholds: Iterable[float],
578
+ radii: Iterable[int],
579
+ ) -> Dict[str, Dict[str, Dict[str, float]]]:
580
+ prob_t = torch.from_numpy(prob_maps.astype(np.float32))
581
+ target_bool = torch.from_numpy((target_maps > 0.5).astype(np.float32)) > 0.5
582
+ target_cells = int(target_bool.sum().item())
583
+ out: Dict[str, Dict[str, Dict[str, float]]] = {}
584
+ for threshold in thresholds:
585
+ pred_bool = prob_t >= float(threshold)
586
+ pred_cells = int(pred_bool.sum().item())
587
+ row: Dict[str, Dict[str, float]] = {}
588
+ for radius in radii:
589
+ radius = int(radius)
590
+ pred_dilated = _spatial_dilate(pred_bool.float(), radius)
591
+ target_dilated = _spatial_dilate(target_bool.float(), radius)
592
+ pred_match = int((pred_bool & target_dilated).sum().item())
593
+ target_match = int((target_bool & pred_dilated).sum().item())
594
+ buffered_precision = _safe_div(pred_match, pred_cells)
595
+ buffered_recall = _safe_div(target_match, target_cells)
596
+ buffered_f1 = _safe_div(
597
+ 2.0 * buffered_precision * buffered_recall,
598
+ buffered_precision + buffered_recall,
599
+ )
600
+ pred_d_target_intersection = int((pred_dilated & target_bool).sum().item())
601
+ pred_d_target_union = int((pred_dilated | target_bool).sum().item())
602
+ pred_target_d_intersection = int((pred_bool & target_dilated).sum().item())
603
+ pred_target_d_union = int((pred_bool | target_dilated).sum().item())
604
+ sym_intersection = int((pred_dilated & target_dilated).sum().item())
605
+ sym_union = int((pred_dilated | target_dilated).sum().item())
606
+ row[str(radius)] = {
607
+ "predicted_positive_cells": pred_cells,
608
+ "target_positive_cells": target_cells,
609
+ "buffered_precision": buffered_precision,
610
+ "buffered_recall": buffered_recall,
611
+ "buffered_f1": buffered_f1,
612
+ "pred_dilated_iou": _safe_div(pred_d_target_intersection, pred_d_target_union),
613
+ "target_dilated_iou": _safe_div(pred_target_d_intersection, pred_target_d_union),
614
+ "symmetric_dilated_iou": _safe_div(sym_intersection, sym_union),
615
+ }
616
+ out[f"{float(threshold):.4f}"] = row
617
+ return out
618
+
619
+
620
+ def distance_transform_metrics(
621
+ prob_maps: np.ndarray,
622
+ target_maps: np.ndarray,
623
+ thresholds: Iterable[float],
624
+ cutoffs: Iterable[int],
625
+ ) -> Dict[str, Dict[str, Dict[str, float]]]:
626
+ if _scipy_ndimage is None:
627
+ return {}
628
+ target_bool = target_maps > 0.5
629
+ cutoff_values = [int(v) for v in cutoffs if int(v) > 0]
630
+ if not cutoff_values:
631
+ return {}
632
+ out: Dict[str, Dict[str, Dict[str, float]]] = {}
633
+ for threshold in thresholds:
634
+ pred_bool = prob_maps >= float(threshold)
635
+ per_cutoff: Dict[int, Dict[str, object]] = {
636
+ cutoff: {
637
+ "pred_to_target": [],
638
+ "target_to_pred": [],
639
+ "baddeley_delta": [],
640
+ "empty_empty": 0,
641
+ "pred_empty_target_nonempty": 0,
642
+ "pred_nonempty_target_empty": 0,
643
+ }
644
+ for cutoff in cutoff_values
645
+ }
646
+ for idx in range(int(pred_bool.shape[0])):
647
+ pred_i = pred_bool[idx]
648
+ target_i = target_bool[idx]
649
+ pred_any = bool(pred_i.any())
650
+ target_any = bool(target_i.any())
651
+ if pred_any:
652
+ dt_pred = _scipy_ndimage.distance_transform_edt(~pred_i)
653
+ else:
654
+ dt_pred = None
655
+ if target_any:
656
+ dt_target = _scipy_ndimage.distance_transform_edt(~target_i)
657
+ else:
658
+ dt_target = None
659
+ for cutoff in cutoff_values:
660
+ bucket = per_cutoff[cutoff]
661
+ pred_to_target: List[float] = bucket["pred_to_target"] # type: ignore[assignment]
662
+ target_to_pred: List[float] = bucket["target_to_pred"] # type: ignore[assignment]
663
+ baddeley_delta: List[float] = bucket["baddeley_delta"] # type: ignore[assignment]
664
+ if pred_any and target_any and dt_pred is not None and dt_target is not None:
665
+ pred_to_target.extend(np.minimum(dt_target[pred_i], cutoff).astype(np.float32).tolist())
666
+ target_to_pred.extend(np.minimum(dt_pred[target_i], cutoff).astype(np.float32).tolist())
667
+ pred_dt_clip = np.minimum(dt_pred, cutoff)
668
+ target_dt_clip = np.minimum(dt_target, cutoff)
669
+ baddeley_delta.append(float(np.sqrt(np.mean((pred_dt_clip - target_dt_clip) ** 2))))
670
+ elif pred_any and not target_any:
671
+ pred_to_target.extend([float(cutoff)] * int(pred_i.sum()))
672
+ baddeley_delta.append(float(cutoff))
673
+ bucket["pred_nonempty_target_empty"] = int(bucket["pred_nonempty_target_empty"]) + 1
674
+ elif target_any and not pred_any:
675
+ target_to_pred.extend([float(cutoff)] * int(target_i.sum()))
676
+ baddeley_delta.append(float(cutoff))
677
+ bucket["pred_empty_target_nonempty"] = int(bucket["pred_empty_target_nonempty"]) + 1
678
+ else:
679
+ baddeley_delta.append(0.0)
680
+ bucket["empty_empty"] = int(bucket["empty_empty"]) + 1
681
+ row: Dict[str, Dict[str, float]] = {}
682
+ for cutoff in cutoff_values:
683
+ bucket = per_cutoff[cutoff]
684
+ pred_to_target_arr = np.asarray(bucket["pred_to_target"], dtype=np.float32)
685
+ target_to_pred_arr = np.asarray(bucket["target_to_pred"], dtype=np.float32)
686
+ if pred_to_target_arr.size and target_to_pred_arr.size:
687
+ symmetric = np.concatenate([pred_to_target_arr, target_to_pred_arr])
688
+ elif pred_to_target_arr.size:
689
+ symmetric = pred_to_target_arr
690
+ else:
691
+ symmetric = target_to_pred_arr
692
+ baddeley_arr = np.asarray(bucket["baddeley_delta"], dtype=np.float32)
693
+ row[str(cutoff)] = {
694
+ "distance_cutoff": float(cutoff),
695
+ "mean_pred_to_target_distance": float(pred_to_target_arr.mean()) if pred_to_target_arr.size else 0.0,
696
+ "mean_target_to_pred_distance": float(target_to_pred_arr.mean()) if target_to_pred_arr.size else 0.0,
697
+ "mean_symmetric_surface_distance": float(symmetric.mean()) if symmetric.size else 0.0,
698
+ "hausdorff95_distance": float(np.percentile(symmetric, 95)) if symmetric.size else 0.0,
699
+ "baddeley_delta_p2": float(baddeley_arr.mean()) if baddeley_arr.size else 0.0,
700
+ "empty_empty_samples": float(bucket["empty_empty"]),
701
+ "pred_empty_target_nonempty_samples": float(bucket["pred_empty_target_nonempty"]),
702
+ "pred_nonempty_target_empty_samples": float(bucket["pred_nonempty_target_empty"]),
703
+ }
704
+ out[f"{float(threshold):.4f}"] = row
705
+ return out
706
+
707
+
708
+ def metric_bundle(
709
+ prob_maps: np.ndarray,
710
+ target_maps: np.ndarray,
711
+ thresholds: Iterable[float],
712
+ topk_fractions: Iterable[float],
713
+ fss_radii: Iterable[int],
714
+ n_bins: int,
715
+ boundary_radii: Iterable[int] | None = None,
716
+ coarsen_factors: Iterable[int] | None = None,
717
+ distance_cutoffs: Iterable[int] | None = None,
718
+ reference_positive_rate: float | None = None,
719
+ sample_times: np.ndarray | None = None,
720
+ temporal_tolerances_steps: Iterable[int] | None = None,
721
+ spatial_tolerances_radii: Iterable[int] | None = None,
722
+ region_masks: Dict[str, np.ndarray] | None = None,
723
+ time_step_hours: int = 24,
724
+ ) -> Dict[str, object]:
725
+ prob = prob_maps.reshape(-1)
726
+ target = target_maps.reshape(-1)
727
+ positive_rate = float(target.mean())
728
+ metrics: Dict[str, object] = {
729
+ "positive_rate": positive_rate,
730
+ "positive_cells": int(target.sum()),
731
+ "total_cells": int(target.size),
732
+ "pr_auc": float(average_precision_score(target, prob)) if float(target.sum()) > 0 else 0.0,
733
+ "brier": float(np.mean((prob - target) ** 2)),
734
+ "log_score": log_score(prob, target),
735
+ "ece": expected_calibration_error(prob, target, n_bins),
736
+ "reliability_bins": reliability_bins(prob, target, n_bins),
737
+ "threshold_metrics": {f"{float(t):.4f}": threshold_metrics(prob, target, float(t)) for t in thresholds},
738
+ "topk_area_metrics": topk_area_metrics(prob, target, topk_fractions),
739
+ "fss": fss_metrics(prob_maps, target_maps, thresholds, fss_radii),
740
+ "boundary_metrics": boundary_metrics(
741
+ prob_maps,
742
+ target_maps,
743
+ thresholds,
744
+ boundary_radii if boundary_radii is not None else [1, 2, 4],
745
+ ),
746
+ "buffered_overlap_metrics": buffered_overlap_metrics(
747
+ prob_maps,
748
+ target_maps,
749
+ thresholds,
750
+ boundary_radii if boundary_radii is not None else [1, 2, 4],
751
+ ),
752
+ "coarsened_metrics": coarsened_metrics(
753
+ prob_maps,
754
+ target_maps,
755
+ thresholds,
756
+ coarsen_factors if coarsen_factors is not None else [2, 4, 8],
757
+ reference_positive_rate=reference_positive_rate,
758
+ ),
759
+ }
760
+ if distance_cutoffs is not None:
761
+ metrics["distance_transform_metrics"] = distance_transform_metrics(
762
+ prob_maps,
763
+ target_maps,
764
+ thresholds,
765
+ distance_cutoffs,
766
+ )
767
+ if float(np.unique(target).size) > 1:
768
+ metrics["auroc"] = float(roc_auc_score(target, prob))
769
+ else:
770
+ metrics["auroc"] = 0.0
771
+ ref_rate = reference_positive_rate if reference_positive_rate is not None else positive_rate
772
+ brier_ref = float(np.mean((ref_rate - target) ** 2))
773
+ metrics["reference_positive_rate"] = float(ref_rate)
774
+ metrics["brier_skill_score"] = float(1.0 - metrics["brier"] / brier_ref) if brier_ref > 0 else 0.0
775
+ if sample_times is not None:
776
+ temporal_values = [int(v) for v in (temporal_tolerances_steps or [])]
777
+ spatial_values = [int(v) for v in (spatial_tolerances_radii or [])]
778
+ tolerant_metrics: Dict[str, Dict[str, Dict[str, float]]] = {}
779
+ for temporal_steps in temporal_values:
780
+ for spatial_radius in spatial_values:
781
+ if temporal_steps == 0 and spatial_radius == 0:
782
+ continue
783
+ combo_key = f"t{temporal_steps}_s{spatial_radius}"
784
+ tolerant_metrics[combo_key] = {
785
+ f"{float(t):.4f}": tolerant_threshold_metrics(
786
+ prob_maps=prob_maps,
787
+ target_maps=target_maps,
788
+ sample_times=sample_times,
789
+ threshold=float(t),
790
+ temporal_tolerance_steps=temporal_steps,
791
+ spatial_tolerance_radius=spatial_radius,
792
+ time_step_hours=time_step_hours,
793
+ )
794
+ for t in thresholds
795
+ }
796
+ if tolerant_metrics:
797
+ metrics["tolerant_threshold_metrics"] = tolerant_metrics
798
+ neighborhood_metrics = neighborhood_contingency_metrics(
799
+ prob_maps=prob_maps,
800
+ target_maps=target_maps,
801
+ sample_times=sample_times,
802
+ thresholds=thresholds,
803
+ temporal_tolerances_steps=temporal_values,
804
+ spatial_tolerances_radii=spatial_values,
805
+ time_step_hours=time_step_hours,
806
+ )
807
+ if neighborhood_metrics:
808
+ metrics["neighborhood_contingency_metrics"] = neighborhood_metrics
809
+ if region_masks:
810
+ metrics["region_metrics"] = {
811
+ name: region_metric_bundle(
812
+ prob_maps=prob_maps,
813
+ target_maps=target_maps,
814
+ mask=mask,
815
+ thresholds=thresholds,
816
+ topk_fractions=topk_fractions,
817
+ n_bins=n_bins,
818
+ reference_positive_rate=reference_positive_rate,
819
+ sample_times=sample_times,
820
+ temporal_tolerances_steps=temporal_tolerances_steps,
821
+ spatial_tolerances_radii=spatial_tolerances_radii,
822
+ time_step_hours=time_step_hours,
823
+ )
824
+ for name, mask in region_masks.items()
825
+ }
826
+ return metrics
training/requirements-training.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ numpy
2
+ pandas
3
+ torch
4
+ scikit-learn
5
+ scipy
6
+ rasterio
7
+ xarray
8
+ cfgrib
9
+ eccodes
training/train_cold_tiled_mainline.py ADDED
@@ -0,0 +1,901 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ import math
7
+ import random
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Dict, Iterable, List, Tuple
11
+
12
+ import numpy as np
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+ from torch.utils.data import DataLoader, Dataset
17
+
18
+ from eval_metrics import metric_bundle
19
+ from train_utils import set_seed
20
+
21
+
22
+ def load_json(path: Path) -> Dict[str, object]:
23
+ return json.loads(path.read_text(encoding="utf-8"))
24
+
25
+
26
+ def read_rows(path: Path) -> List[Dict[str, str]]:
27
+ with path.open("r", encoding="utf-8", newline="") as fh:
28
+ return list(csv.DictReader(fh))
29
+
30
+
31
+ def integral_image(mask: np.ndarray) -> np.ndarray:
32
+ return np.pad(mask.astype(np.int32), ((1, 0), (1, 0)), mode="constant").cumsum(0).cumsum(1)
33
+
34
+
35
+ def rect_sum(ii: np.ndarray, top: int, left: int, height: int, width: int) -> int:
36
+ bottom = top + height
37
+ right = left + width
38
+ return int(ii[bottom, right] - ii[top, right] - ii[bottom, left] + ii[top, left])
39
+
40
+
41
+ def centered_tile_origin(y: int, x: int, tile_h: int, tile_w: int, h: int, w: int) -> Tuple[int, int]:
42
+ top = min(max(y - tile_h // 2, 0), h - tile_h)
43
+ left = min(max(x - tile_w // 2, 0), w - tile_w)
44
+ return int(top), int(left)
45
+
46
+
47
+ def positive_tile_origins(mask: np.ndarray, tile_h: int, tile_w: int, max_tiles: int, rng: random.Random) -> List[Tuple[int, int]]:
48
+ ys, xs = np.where(mask > 0.5)
49
+ if ys.size == 0:
50
+ return []
51
+ origins = list(
52
+ {
53
+ centered_tile_origin(int(y), int(x), tile_h, tile_w, mask.shape[0], mask.shape[1])
54
+ for y, x in zip(ys, xs)
55
+ }
56
+ )
57
+ rng.shuffle(origins)
58
+ if max_tiles > 0:
59
+ origins = origins[:max_tiles]
60
+ return origins
61
+
62
+
63
+ def negative_tile_origins(
64
+ mask: np.ndarray,
65
+ tile_h: int,
66
+ tile_w: int,
67
+ desired: int,
68
+ rng: random.Random,
69
+ forbidden: Iterable[Tuple[int, int]] = (),
70
+ ) -> List[Tuple[int, int]]:
71
+ h, w = mask.shape
72
+ ii = integral_image(mask)
73
+ forbidden_set = set(forbidden)
74
+ out: List[Tuple[int, int]] = []
75
+ seen = set(forbidden_set)
76
+ max_top = max(h - tile_h, 0)
77
+ max_left = max(w - tile_w, 0)
78
+ max_attempts = max(2000, desired * 50)
79
+ attempts = 0
80
+ while len(out) < desired and attempts < max_attempts:
81
+ attempts += 1
82
+ top = rng.randint(0, max_top)
83
+ left = rng.randint(0, max_left)
84
+ origin = (top, left)
85
+ if origin in seen:
86
+ continue
87
+ if rect_sum(ii, top, left, tile_h, tile_w) != 0:
88
+ continue
89
+ seen.add(origin)
90
+ out.append(origin)
91
+ return out
92
+
93
+
94
+ def make_norm(norm_type: str, num_channels: int, norm_groups: int) -> nn.Module:
95
+ if norm_type == "batch":
96
+ return nn.BatchNorm2d(num_channels)
97
+ if norm_type == "group":
98
+ groups = max(1, min(int(norm_groups), num_channels))
99
+ while num_channels % groups != 0 and groups > 1:
100
+ groups -= 1
101
+ return nn.GroupNorm(groups, num_channels)
102
+ if norm_type == "instance":
103
+ return nn.InstanceNorm2d(num_channels, affine=True)
104
+ if norm_type in {"none", "identity"}:
105
+ return nn.Identity()
106
+ raise ValueError(f"Unsupported norm_type: {norm_type}")
107
+
108
+
109
+ class ConvBlock(nn.Module):
110
+ def __init__(self, in_ch: int, out_ch: int, norm_type: str, norm_groups: int):
111
+ super().__init__()
112
+ self.net = nn.Sequential(
113
+ nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
114
+ make_norm(norm_type, out_ch, norm_groups),
115
+ nn.ReLU(inplace=True),
116
+ nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
117
+ make_norm(norm_type, out_ch, norm_groups),
118
+ nn.ReLU(inplace=True),
119
+ )
120
+
121
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
122
+ return self.net(x)
123
+
124
+
125
+ class UNetSmallFlex(nn.Module):
126
+ def __init__(
127
+ self,
128
+ in_ch: int,
129
+ base: int = 32,
130
+ dropout: float = 0.1,
131
+ norm_type: str = "group",
132
+ norm_groups: int = 8,
133
+ prior_prob: float | None = None,
134
+ use_aux_spatial_head: bool = False,
135
+ aux_prior_prob: float | None = None,
136
+ ):
137
+ super().__init__()
138
+ self.enc1 = ConvBlock(in_ch, base, norm_type, norm_groups)
139
+ self.enc2 = ConvBlock(base, base * 2, norm_type, norm_groups)
140
+ self.enc3 = ConvBlock(base * 2, base * 4, norm_type, norm_groups)
141
+ self.enc4 = ConvBlock(base * 4, base * 8, norm_type, norm_groups)
142
+ self.pool = nn.MaxPool2d(2)
143
+ self.bottleneck = ConvBlock(base * 8, base * 16, norm_type, norm_groups)
144
+ self.up4 = nn.ConvTranspose2d(base * 16, base * 8, 2, stride=2)
145
+ self.dec4 = ConvBlock(base * 16, base * 8, norm_type, norm_groups)
146
+ self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2)
147
+ self.dec3 = ConvBlock(base * 8, base * 4, norm_type, norm_groups)
148
+ self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2)
149
+ self.dec2 = ConvBlock(base * 4, base * 2, norm_type, norm_groups)
150
+ self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2)
151
+ self.dec1 = ConvBlock(base * 2, base, norm_type, norm_groups)
152
+ self.drop = nn.Dropout2d(p=dropout)
153
+ self.head = nn.Conv2d(base, 1, kernel_size=1)
154
+ self.use_aux_spatial_head = bool(use_aux_spatial_head)
155
+ self.aux_head = nn.Conv2d(base, 1, kernel_size=1) if self.use_aux_spatial_head else None
156
+ if prior_prob is not None:
157
+ prior_prob = float(min(max(prior_prob, 1e-6), 1.0 - 1e-6))
158
+ nn.init.constant_(self.head.bias, math.log(prior_prob / (1.0 - prior_prob)))
159
+ if self.aux_head is not None and aux_prior_prob is not None:
160
+ aux_prior_prob = float(min(max(aux_prior_prob, 1e-6), 1.0 - 1e-6))
161
+ nn.init.constant_(self.aux_head.bias, math.log(aux_prior_prob / (1.0 - aux_prior_prob)))
162
+
163
+ @staticmethod
164
+ def _match_hw(x: torch.Tensor, ref: torch.Tensor) -> torch.Tensor:
165
+ diff_y = ref.size(2) - x.size(2)
166
+ diff_x = ref.size(3) - x.size(3)
167
+ if diff_y > 0 or diff_x > 0:
168
+ x = F.pad(x, [diff_x // 2, diff_x - diff_x // 2, diff_y // 2, diff_y - diff_y // 2])
169
+ if diff_y < 0:
170
+ y0 = (-diff_y) // 2
171
+ x = x[:, :, y0 : y0 + ref.size(2), :]
172
+ if diff_x < 0:
173
+ x0 = (-diff_x) // 2
174
+ x = x[:, :, :, x0 : x0 + ref.size(3)]
175
+ return x
176
+
177
+ def forward(self, x: torch.Tensor, return_aux: bool = False):
178
+ e1 = self.enc1(x)
179
+ e2 = self.enc2(self.pool(e1))
180
+ e3 = self.enc3(self.pool(e2))
181
+ e4 = self.enc4(self.pool(e3))
182
+ b = self.bottleneck(self.pool(e4))
183
+ d4 = self.dec4(torch.cat([self._match_hw(self.up4(b), e4), e4], dim=1))
184
+ d3 = self.dec3(torch.cat([self._match_hw(self.up3(d4), e3), e3], dim=1))
185
+ d2 = self.dec2(torch.cat([self._match_hw(self.up2(d3), e2), e2], dim=1))
186
+ d1 = self.dec1(torch.cat([self._match_hw(self.up1(d2), e1), e1], dim=1))
187
+ features = self.drop(d1)
188
+ logits = self.head(features)
189
+ if return_aux and self.aux_head is not None:
190
+ return logits, self.aux_head(features)
191
+ return logits
192
+
193
+
194
+ class ColdFeatureStore:
195
+ def __init__(self, rows: List[Dict[str, str]]):
196
+ self.cache: Dict[str, Dict[str, np.ndarray]] = {}
197
+ static_path = Path(rows[0]["static_path"])
198
+ static_npz = np.load(static_path, allow_pickle=True)
199
+ static = self._sanitize(static_npz["static"].astype(np.float32))
200
+ static_valid = static_npz["static_valid"].astype(np.float32) if "static_valid" in static_npz else None
201
+ static_parts = []
202
+ if static_valid is not None:
203
+ static_parts.append(static_valid.astype(np.float32))
204
+ static_parts.append(static)
205
+ static_x = np.concatenate(static_parts, axis=0).astype(np.float32)
206
+ for row in rows:
207
+ sample = np.load(row["sample_path"], allow_pickle=True)
208
+ weather = self._sanitize(sample["weather"].astype(np.float32))
209
+ firewx = self._sanitize(sample["firewx"].astype(np.float32))
210
+ extra = []
211
+ if "firewx_valid" in sample:
212
+ extra.append(sample["firewx_valid"].astype(np.float32))
213
+ x = np.concatenate([weather, firewx, *extra, static_x], axis=0).astype(np.float32)
214
+ y = np.nan_to_num(sample["y_occ"].astype(np.float32), nan=0.0, posinf=0.0, neginf=0.0)
215
+ self.cache[str(row["sample_id"])] = {"x": x, "y": y}
216
+
217
+ @staticmethod
218
+ def _sanitize(x: np.ndarray) -> np.ndarray:
219
+ x = np.where(x <= -9000.0, np.nan, x)
220
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
221
+ return x.astype(np.float32, copy=False)
222
+
223
+ def get(self, sample_id: str) -> Dict[str, np.ndarray]:
224
+ return self.cache[sample_id]
225
+
226
+
227
+ def pooled_positive_rate(rows: List[Dict[str, str]], store: ColdFeatureStore, radius: int) -> float:
228
+ if radius <= 0:
229
+ return float(full_map_stats(rows, store)["positive_rate"])
230
+ pos = 0.0
231
+ total = 0.0
232
+ for row in rows:
233
+ sample = store.get(str(row["sample_id"]))
234
+ y = torch.from_numpy(sample["y"].astype(np.float32)).unsqueeze(0)
235
+ pooled = F.max_pool2d(y, kernel_size=radius * 2 + 1, stride=1, padding=radius)
236
+ pos += float((pooled > 0.5).sum().item())
237
+ total += float(pooled.numel())
238
+ return float(pos / total) if total > 0 else 0.0
239
+
240
+
241
+ def transform_spatial_aux_target(target: torch.Tensor, radius: int) -> torch.Tensor:
242
+ if radius <= 0:
243
+ return target
244
+ return F.max_pool2d(target, kernel_size=radius * 2 + 1, stride=1, padding=radius)
245
+
246
+
247
+ def transform_train_target(target: torch.Tensor, config: Dict[str, object]) -> torch.Tensor:
248
+ mode = str(config.get("train_target_mode", "hard"))
249
+ radius = int(config.get("train_target_radius", 0))
250
+ if mode == "hard" or radius <= 0:
251
+ return target
252
+ kernel = radius * 2 + 1
253
+ if mode == "dilate_max":
254
+ return F.max_pool2d(target, kernel_size=kernel, stride=1, padding=radius)
255
+ if mode == "soft_pool":
256
+ return F.avg_pool2d(target, kernel_size=kernel, stride=1, padding=radius)
257
+ raise ValueError(f"Unsupported train_target_mode: {mode}")
258
+
259
+
260
+ def maybe_unpack_logits(output):
261
+ if isinstance(output, tuple):
262
+ return output
263
+ return output, None
264
+
265
+
266
+ class FocalBCEWithLogitsLoss(nn.Module):
267
+ def __init__(self, gamma: float = 2.0, alpha: float = 0.75):
268
+ super().__init__()
269
+ self.gamma = float(gamma)
270
+ self.alpha = float(alpha)
271
+
272
+ def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
273
+ bce = F.binary_cross_entropy_with_logits(logits, targets, reduction="none")
274
+ probs = torch.sigmoid(logits)
275
+ pt = torch.where(targets > 0.5, probs, 1.0 - probs)
276
+ alpha_t = torch.where(
277
+ targets > 0.5,
278
+ torch.full_like(targets, self.alpha),
279
+ torch.full_like(targets, 1.0 - self.alpha),
280
+ )
281
+ loss = alpha_t * ((1.0 - pt) ** self.gamma) * bce
282
+ return loss.mean()
283
+
284
+
285
+ class OHEMBCEWithLogitsLoss(nn.Module):
286
+ def __init__(self, pos_weight: float, neg_pos_ratio: float = 8.0, min_negatives: int = 64):
287
+ super().__init__()
288
+ self.pos_weight = float(pos_weight)
289
+ self.neg_pos_ratio = float(neg_pos_ratio)
290
+ self.min_negatives = int(min_negatives)
291
+
292
+ def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
293
+ losses = F.binary_cross_entropy_with_logits(
294
+ logits,
295
+ targets,
296
+ reduction="none",
297
+ pos_weight=torch.tensor([self.pos_weight], device=logits.device, dtype=logits.dtype),
298
+ )
299
+ batch_losses: List[torch.Tensor] = []
300
+ batch_size = int(logits.shape[0])
301
+ for i in range(batch_size):
302
+ loss_i = losses[i].reshape(-1)
303
+ tgt_i = targets[i].reshape(-1) > 0.5
304
+ pos_loss = loss_i[tgt_i]
305
+ neg_loss = loss_i[~tgt_i]
306
+ num_pos = int(pos_loss.numel())
307
+ keep_neg = max(self.min_negatives, int(self.neg_pos_ratio * max(num_pos, 1)))
308
+ keep_neg = min(keep_neg, int(neg_loss.numel()))
309
+ if keep_neg > 0:
310
+ neg_loss = torch.topk(neg_loss, k=keep_neg, largest=True).values
311
+ else:
312
+ neg_loss = neg_loss[:0]
313
+ denom = max(num_pos + int(neg_loss.numel()), 1)
314
+ batch_losses.append((pos_loss.sum() + neg_loss.sum()) / denom)
315
+ return torch.stack(batch_losses).mean()
316
+
317
+
318
+ class SoftFbetaBCEWithLogitsLoss(nn.Module):
319
+ def __init__(self, pos_weight: float, beta: float = 2.0, bce_weight: float = 0.4, smooth: float = 1.0):
320
+ super().__init__()
321
+ self.pos_weight = float(pos_weight)
322
+ self.beta = float(beta)
323
+ self.bce_weight = float(bce_weight)
324
+ self.smooth = float(smooth)
325
+
326
+ def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
327
+ targets = targets.clamp(0.0, 1.0)
328
+ bce = F.binary_cross_entropy_with_logits(
329
+ logits,
330
+ targets,
331
+ reduction="mean",
332
+ pos_weight=torch.tensor([self.pos_weight], device=logits.device, dtype=logits.dtype),
333
+ )
334
+ probs = torch.sigmoid(logits.float())
335
+ targets_f = targets.float()
336
+ dims = tuple(range(1, probs.ndim))
337
+ tp = (probs * targets_f).sum(dim=dims)
338
+ fp = (probs * (1.0 - targets_f)).sum(dim=dims)
339
+ fn = ((1.0 - probs) * targets_f).sum(dim=dims)
340
+ beta_sq = self.beta * self.beta
341
+ fbeta = ((1.0 + beta_sq) * tp + self.smooth) / ((1.0 + beta_sq) * tp + beta_sq * fn + fp + self.smooth)
342
+ soft_loss = 1.0 - fbeta.mean()
343
+ return self.bce_weight * bce + (1.0 - self.bce_weight) * soft_loss
344
+
345
+
346
+ class TverskyBCEWithLogitsLoss(nn.Module):
347
+ def __init__(
348
+ self,
349
+ pos_weight: float,
350
+ alpha: float = 0.3,
351
+ beta: float = 0.7,
352
+ bce_weight: float = 0.4,
353
+ smooth: float = 1.0,
354
+ ):
355
+ super().__init__()
356
+ self.pos_weight = float(pos_weight)
357
+ self.alpha = float(alpha)
358
+ self.beta = float(beta)
359
+ self.bce_weight = float(bce_weight)
360
+ self.smooth = float(smooth)
361
+
362
+ def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
363
+ targets = targets.clamp(0.0, 1.0)
364
+ bce = F.binary_cross_entropy_with_logits(
365
+ logits,
366
+ targets,
367
+ reduction="mean",
368
+ pos_weight=torch.tensor([self.pos_weight], device=logits.device, dtype=logits.dtype),
369
+ )
370
+ probs = torch.sigmoid(logits.float())
371
+ targets_f = targets.float()
372
+ dims = tuple(range(1, probs.ndim))
373
+ tp = (probs * targets_f).sum(dim=dims)
374
+ fp = (probs * (1.0 - targets_f)).sum(dim=dims)
375
+ fn = ((1.0 - probs) * targets_f).sum(dim=dims)
376
+ tversky = (tp + self.smooth) / (tp + self.alpha * fp + self.beta * fn + self.smooth)
377
+ return self.bce_weight * bce + (1.0 - self.bce_weight) * (1.0 - tversky.mean())
378
+
379
+
380
+ class FSSBCEWithLogitsLoss(nn.Module):
381
+ def __init__(
382
+ self,
383
+ pos_weight: float,
384
+ radii: Iterable[int] = (1, 2),
385
+ bce_weight: float = 0.5,
386
+ eps: float = 1e-6,
387
+ ):
388
+ super().__init__()
389
+ self.pos_weight = float(pos_weight)
390
+ self.radii = [int(v) for v in radii if int(v) > 0]
391
+ if not self.radii:
392
+ self.radii = [1]
393
+ self.bce_weight = float(bce_weight)
394
+ self.eps = float(eps)
395
+
396
+ def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
397
+ targets = targets.clamp(0.0, 1.0)
398
+ bce = F.binary_cross_entropy_with_logits(
399
+ logits,
400
+ targets,
401
+ reduction="mean",
402
+ pos_weight=torch.tensor([self.pos_weight], device=logits.device, dtype=logits.dtype),
403
+ )
404
+ probs = torch.sigmoid(logits.float())
405
+ targets_f = targets.float()
406
+ fss_losses: List[torch.Tensor] = []
407
+ for radius in self.radii:
408
+ kernel = radius * 2 + 1
409
+ pred_frac = F.avg_pool2d(probs, kernel_size=kernel, stride=1, padding=radius)
410
+ target_frac = F.avg_pool2d(targets_f, kernel_size=kernel, stride=1, padding=radius)
411
+ mse = torch.mean((pred_frac - target_frac) ** 2)
412
+ reference = torch.mean(pred_frac**2 + target_frac**2)
413
+ fss = 1.0 - mse / (reference + self.eps)
414
+ fss_losses.append(1.0 - fss)
415
+ fss_loss = torch.stack(fss_losses).mean()
416
+ return self.bce_weight * bce + (1.0 - self.bce_weight) * fss_loss
417
+
418
+
419
+ def build_loss(config: Dict[str, object], pos_weight: float, device: torch.device) -> nn.Module:
420
+ loss_type = str(config.get("loss_type", "bce"))
421
+ if loss_type == "bce":
422
+ return nn.BCEWithLogitsLoss(pos_weight=torch.tensor([pos_weight], dtype=torch.float32, device=device))
423
+ if loss_type == "focal_bce":
424
+ return FocalBCEWithLogitsLoss(
425
+ gamma=float(config.get("focal_gamma", 2.0)),
426
+ alpha=float(config.get("focal_alpha", 0.75)),
427
+ )
428
+ if loss_type == "ohem_bce":
429
+ return OHEMBCEWithLogitsLoss(
430
+ pos_weight=pos_weight,
431
+ neg_pos_ratio=float(config.get("ohem_neg_pos_ratio", 8.0)),
432
+ min_negatives=int(config.get("ohem_min_negatives", 64)),
433
+ )
434
+ if loss_type == "soft_fbeta_bce":
435
+ return SoftFbetaBCEWithLogitsLoss(
436
+ pos_weight=pos_weight,
437
+ beta=float(config.get("soft_fbeta_beta", 2.0)),
438
+ bce_weight=float(config.get("soft_metric_bce_weight", 0.4)),
439
+ smooth=float(config.get("soft_metric_smooth", 1.0)),
440
+ )
441
+ if loss_type == "tversky_bce":
442
+ return TverskyBCEWithLogitsLoss(
443
+ pos_weight=pos_weight,
444
+ alpha=float(config.get("tversky_alpha", 0.3)),
445
+ beta=float(config.get("tversky_beta", 0.7)),
446
+ bce_weight=float(config.get("soft_metric_bce_weight", 0.4)),
447
+ smooth=float(config.get("soft_metric_smooth", 1.0)),
448
+ )
449
+ if loss_type == "fss_bce":
450
+ return FSSBCEWithLogitsLoss(
451
+ pos_weight=pos_weight,
452
+ radii=[int(v) for v in config.get("fss_loss_radii", [1, 2])],
453
+ bce_weight=float(config.get("soft_metric_bce_weight", 0.5)),
454
+ )
455
+ raise ValueError(f"Unsupported loss_type: {loss_type}")
456
+
457
+
458
+ def full_map_stats(rows: List[Dict[str, str]], store: ColdFeatureStore) -> Dict[str, float]:
459
+ pos = 0.0
460
+ total = 0.0
461
+ for row in rows:
462
+ sample = store.get(str(row["sample_id"]))
463
+ y = sample["y"]
464
+ pos += float((y > 0.5).sum())
465
+ total += float(y.size)
466
+ neg = max(total - pos, 0.0)
467
+ raw_pos_weight = float(max(neg / max(pos, 1.0), 1.0)) if total > 0 else 1.0
468
+ return {
469
+ "source": "full_map",
470
+ "positive_cells": pos,
471
+ "total_cells": total,
472
+ "positive_rate": float(pos / total) if total > 0 else 0.0,
473
+ "raw_pos_weight": raw_pos_weight,
474
+ }
475
+
476
+
477
+ def tile_stats(tile_rows: List[Dict[str, object]], store: ColdFeatureStore) -> Dict[str, float]:
478
+ pos = 0.0
479
+ total = 0.0
480
+ for row in tile_rows:
481
+ sample = store.get(str(row["sample_id"]))
482
+ top = int(row["top"])
483
+ left = int(row["left"])
484
+ tile_size = int(row["tile_size"])
485
+ target = sample["y"][0, top : top + tile_size, left : left + tile_size]
486
+ pos += float((target > 0.5).sum())
487
+ total += float(target.size)
488
+ neg = max(total - pos, 0.0)
489
+ raw_pos_weight = float(max(neg / max(pos, 1.0), 1.0)) if total > 0 else 1.0
490
+ return {
491
+ "source": "tiles",
492
+ "positive_cells": pos,
493
+ "total_cells": total,
494
+ "positive_rate": float(pos / total) if total > 0 else 0.0,
495
+ "raw_pos_weight": raw_pos_weight,
496
+ }
497
+
498
+
499
+ def select_stats(source: str, train_rows: List[Dict[str, str]], tile_rows: List[Dict[str, object]], store: ColdFeatureStore) -> Dict[str, float]:
500
+ if source == "full_map":
501
+ return full_map_stats(train_rows, store)
502
+ if source == "tiles":
503
+ return tile_stats(tile_rows, store)
504
+ raise ValueError(f"Unsupported stats source: {source}")
505
+
506
+
507
+ def build_train_tiles(
508
+ train_rows: List[Dict[str, str]],
509
+ store: ColdFeatureStore,
510
+ tile_size: int,
511
+ max_positive_tiles_per_sample: int,
512
+ min_negative_tiles_per_sample: int,
513
+ neg_pos_ratio: float,
514
+ rng: random.Random,
515
+ ) -> List[Dict[str, object]]:
516
+ tile_rows: List[Dict[str, object]] = []
517
+ for row in train_rows:
518
+ sample_id = str(row["sample_id"])
519
+ mask = store.get(sample_id)["y"][0]
520
+ pos_origins = positive_tile_origins(
521
+ mask=mask,
522
+ tile_h=tile_size,
523
+ tile_w=tile_size,
524
+ max_tiles=max_positive_tiles_per_sample,
525
+ rng=rng,
526
+ )
527
+ neg_count = max(min_negative_tiles_per_sample, int(math.ceil(len(pos_origins) * neg_pos_ratio)))
528
+ neg_origins = negative_tile_origins(
529
+ mask=mask,
530
+ tile_h=tile_size,
531
+ tile_w=tile_size,
532
+ desired=neg_count,
533
+ rng=rng,
534
+ forbidden=pos_origins,
535
+ )
536
+ for top, left in pos_origins:
537
+ tile_rows.append(
538
+ {
539
+ "sample_id": sample_id,
540
+ "tile_type": "positive",
541
+ "top": int(top),
542
+ "left": int(left),
543
+ "tile_size": int(tile_size),
544
+ }
545
+ )
546
+ for top, left in neg_origins:
547
+ tile_rows.append(
548
+ {
549
+ "sample_id": sample_id,
550
+ "tile_type": "negative",
551
+ "top": int(top),
552
+ "left": int(left),
553
+ "tile_size": int(tile_size),
554
+ }
555
+ )
556
+ return tile_rows
557
+
558
+
559
+ class TrainTileDataset(Dataset):
560
+ def __init__(self, tile_rows: List[Dict[str, object]], store: ColdFeatureStore, augment_flip: bool, seed: int):
561
+ self.rows = tile_rows
562
+ self.store = store
563
+ self.augment_flip = bool(augment_flip)
564
+ self.rng = random.Random(seed)
565
+
566
+ def __len__(self) -> int:
567
+ return len(self.rows)
568
+
569
+ def __getitem__(self, idx: int):
570
+ row = self.rows[idx]
571
+ sample = self.store.get(str(row["sample_id"]))
572
+ top = int(row["top"])
573
+ left = int(row["left"])
574
+ tile_size = int(row["tile_size"])
575
+ x = sample["x"][:, top : top + tile_size, left : left + tile_size]
576
+ y = sample["y"][:, top : top + tile_size, left : left + tile_size]
577
+ if self.augment_flip:
578
+ if self.rng.random() < 0.5:
579
+ x = x[:, :, ::-1].copy()
580
+ y = y[:, :, ::-1].copy()
581
+ if self.rng.random() < 0.5:
582
+ x = x[:, ::-1, :].copy()
583
+ y = y[:, ::-1, :].copy()
584
+ return {"x": torch.from_numpy(x), "y": torch.from_numpy(y)}
585
+
586
+
587
+ class FullMapDataset(Dataset):
588
+ def __init__(self, rows: List[Dict[str, str]], store: ColdFeatureStore):
589
+ self.rows = rows
590
+ self.store = store
591
+
592
+ def __len__(self) -> int:
593
+ return len(self.rows)
594
+
595
+ def __getitem__(self, idx: int):
596
+ row = self.rows[idx]
597
+ sample = self.store.get(str(row["sample_id"]))
598
+ return {"x": torch.from_numpy(sample["x"]), "y": torch.from_numpy(sample["y"]), "sample_id": str(row["sample_id"])}
599
+
600
+
601
+ def evaluate(
602
+ model: nn.Module,
603
+ loader: DataLoader,
604
+ device: torch.device,
605
+ thresholds: List[float],
606
+ topk_area_fractions: List[float],
607
+ fss_radii: List[int],
608
+ n_bins: int,
609
+ reference_positive_rate: float,
610
+ criterion: nn.Module,
611
+ amp: bool,
612
+ ) -> Dict[str, object]:
613
+ model.eval()
614
+ total_loss = 0.0
615
+ total_items = 0
616
+ all_prob_maps: List[np.ndarray] = []
617
+ all_target_maps: List[np.ndarray] = []
618
+ with torch.no_grad():
619
+ for batch in loader:
620
+ x = torch.nan_to_num(batch["x"], nan=0.0, posinf=0.0, neginf=0.0).to(device, non_blocking=True)
621
+ y = torch.nan_to_num(batch["y"], nan=0.0, posinf=0.0, neginf=0.0).to(device, non_blocking=True)
622
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=amp and device.type == "cuda"):
623
+ logits, _ = maybe_unpack_logits(model(x))
624
+ loss = criterion(logits, y)
625
+ prob = torch.sigmoid(logits.float()).detach().cpu().numpy()[:, 0, :, :]
626
+ target = y.float().detach().cpu().numpy()[:, 0, :, :]
627
+ all_prob_maps.append(prob)
628
+ all_target_maps.append(target)
629
+ total_loss += float(loss.item()) * x.size(0)
630
+ total_items += int(x.size(0))
631
+ prob_maps = np.concatenate(all_prob_maps, axis=0)
632
+ target_maps = np.concatenate(all_target_maps, axis=0)
633
+ metrics = metric_bundle(
634
+ prob_maps=prob_maps,
635
+ target_maps=target_maps,
636
+ thresholds=thresholds,
637
+ topk_fractions=topk_area_fractions,
638
+ fss_radii=fss_radii,
639
+ n_bins=n_bins,
640
+ reference_positive_rate=reference_positive_rate,
641
+ )
642
+ metrics.update({"loss": total_loss / max(total_items, 1), "num_samples": int(prob_maps.shape[0])})
643
+ return metrics
644
+
645
+
646
+ def save_checkpoint(path: Path, model: nn.Module, optimizer: torch.optim.Optimizer, epoch: int, metrics: Dict[str, float], config: Dict[str, object]) -> None:
647
+ torch.save(
648
+ {
649
+ "epoch": epoch,
650
+ "model": model.state_dict(),
651
+ "optimizer": optimizer.state_dict(),
652
+ "metrics": metrics,
653
+ "config": config,
654
+ },
655
+ path,
656
+ )
657
+
658
+
659
+ def main() -> None:
660
+ parser = argparse.ArgumentParser()
661
+ parser.add_argument("--config", type=Path, required=True)
662
+ parser.add_argument("--run-name", type=str, required=True)
663
+ args = parser.parse_args()
664
+
665
+ config = load_json(args.config)
666
+ set_seed(int(config.get("seed", 7)))
667
+ rng = random.Random(int(config.get("seed", 7)))
668
+ torch.backends.cuda.matmul.allow_tf32 = True
669
+ torch.backends.cudnn.allow_tf32 = True
670
+
671
+ index_root = Path(config["index_root"])
672
+ run_root = Path(config["run_root"])
673
+ ckpt_dir = run_root / "checkpoints" / args.run_name
674
+ metric_dir = run_root / "metrics" / args.run_name
675
+ ckpt_dir.mkdir(parents=True, exist_ok=True)
676
+ metric_dir.mkdir(parents=True, exist_ok=True)
677
+
678
+ train_rows = read_rows(index_root / "splits" / "train.csv")
679
+ val_rows = read_rows(index_root / "splits" / "val.csv")
680
+ test_rows = read_rows(index_root / "splits" / "test.csv")
681
+ store = ColdFeatureStore(train_rows + val_rows + test_rows)
682
+
683
+ tile_rows = build_train_tiles(
684
+ train_rows=train_rows,
685
+ store=store,
686
+ tile_size=int(config.get("tile_size", 16)),
687
+ max_positive_tiles_per_sample=int(config.get("max_positive_tiles_per_sample", 64)),
688
+ min_negative_tiles_per_sample=int(config.get("min_negative_tiles_per_sample", 4)),
689
+ neg_pos_ratio=float(config.get("negative_to_positive_ratio", 2.0)),
690
+ rng=rng,
691
+ )
692
+ train_ds = TrainTileDataset(tile_rows=tile_rows, store=store, augment_flip=bool(config.get("augment_flip", True)), seed=int(config.get("seed", 7)))
693
+ val_ds = FullMapDataset(val_rows, store)
694
+ test_ds = FullMapDataset(test_rows, store)
695
+
696
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
697
+ train_loader = DataLoader(train_ds, batch_size=int(config.get("batch_size", 64)), shuffle=True, num_workers=0, pin_memory=device.type == "cuda")
698
+ val_loader = DataLoader(val_ds, batch_size=int(config.get("eval_batch_size", 8)), shuffle=False, num_workers=0, pin_memory=device.type == "cuda")
699
+ test_loader = DataLoader(test_ds, batch_size=int(config.get("eval_batch_size", 8)), shuffle=False, num_workers=0, pin_memory=device.type == "cuda")
700
+
701
+ positive_rate_source = str(config.get("positive_rate_source", "full_map"))
702
+ pos_weight_source = str(config.get("pos_weight_source", "full_map"))
703
+ metric_reference_positive_rate = full_map_stats(train_rows, store)["positive_rate"]
704
+ positive_rate_stats = select_stats(positive_rate_source, train_rows, tile_rows, store)
705
+ pos_weight_stats = select_stats(pos_weight_source, train_rows, tile_rows, store)
706
+ train_positive_rate = float(positive_rate_stats["positive_rate"])
707
+ raw_pos_weight = float(pos_weight_stats["raw_pos_weight"])
708
+ pos_weight_cap = float(config.get("pos_weight_cap", 300.0))
709
+ pos_weight = float(min(max(raw_pos_weight, 1.0), pos_weight_cap))
710
+ use_aux_spatial_head = bool(config.get("use_aux_spatial_head", False))
711
+ aux_spatial_radius = int(config.get("aux_spatial_radius", 1))
712
+ aux_positive_rate = pooled_positive_rate(train_rows, store, aux_spatial_radius) if use_aux_spatial_head else None
713
+
714
+ in_ch = int(store.get(str(train_rows[0]["sample_id"]))["x"].shape[0])
715
+ model = UNetSmallFlex(
716
+ in_ch=in_ch,
717
+ base=int(config.get("base_channels", 32)),
718
+ dropout=float(config.get("dropout", 0.1)),
719
+ norm_type=str(config.get("norm_type", "group")),
720
+ norm_groups=int(config.get("norm_groups", 8)),
721
+ prior_prob=train_positive_rate if bool(config.get("init_head_bias_from_positive_rate", True)) else None,
722
+ use_aux_spatial_head=use_aux_spatial_head,
723
+ aux_prior_prob=aux_positive_rate if bool(config.get("init_head_bias_from_positive_rate", True)) else None,
724
+ ).to(device)
725
+ init_checkpoint = config.get("init_checkpoint")
726
+ if init_checkpoint:
727
+ checkpoint = torch.load(str(init_checkpoint), map_location="cpu")
728
+ state = checkpoint.get("model", checkpoint)
729
+ if not isinstance(state, dict):
730
+ raise RuntimeError(f"Unexpected init_checkpoint format: {init_checkpoint}")
731
+ model.load_state_dict(state, strict=False)
732
+ criterion = build_loss(config=config, pos_weight=pos_weight, device=device)
733
+ aux_criterion = nn.BCEWithLogitsLoss()
734
+ optimizer = torch.optim.AdamW(model.parameters(), lr=float(config.get("learning_rate", 3e-4)), weight_decay=float(config.get("weight_decay", 1e-4)))
735
+ scaler = torch.amp.GradScaler("cuda", enabled=bool(config.get("amp", True)) and device.type == "cuda")
736
+
737
+ thresholds = [float(v) for v in config.get("metric_thresholds", [0.1, 0.2, 0.3, 0.5])]
738
+ topk_area_fractions = [float(v) for v in config.get("topk_area_fractions", [0.01, 0.05, 0.1])]
739
+ fss_radii = [int(v) for v in config.get("fss_radii", [1, 2, 4, 8])]
740
+ n_bins = int(config.get("reliability_bins", 10))
741
+ default_threshold_key = f"{float(config.get('threshold', 0.3)):.4f}"
742
+
743
+ summary_seed = {
744
+ "index_root": str(index_root),
745
+ "tile_size": int(config.get("tile_size", 16)),
746
+ "num_train_tiles": len(tile_rows),
747
+ "positive_train_tiles": int(sum(1 for row in tile_rows if row["tile_type"] == "positive")),
748
+ "negative_train_tiles": int(sum(1 for row in tile_rows if row["tile_type"] == "negative")),
749
+ "input_channels": in_ch,
750
+ "train_positive_rate": train_positive_rate,
751
+ "metric_reference_positive_rate": metric_reference_positive_rate,
752
+ "pos_weight_source": pos_weight_source,
753
+ "positive_rate_source": positive_rate_source,
754
+ "pos_weight": pos_weight,
755
+ "loss_type": str(config.get("loss_type", "bce")),
756
+ "train_target_mode": str(config.get("train_target_mode", "hard")),
757
+ "train_target_radius": int(config.get("train_target_radius", 0)),
758
+ }
759
+ (metric_dir / "tile_summary.json").write_text(json.dumps(summary_seed, indent=2), encoding="utf-8")
760
+
761
+ best_pr = -1.0
762
+ best_state = None
763
+ history: List[Dict[str, float]] = []
764
+ aux_spatial_loss_weight = float(config.get("aux_spatial_loss_weight", 0.0))
765
+ for epoch in range(1, int(config.get("epochs", 30)) + 1):
766
+ model.train()
767
+ epoch_start = time.time()
768
+ train_loss = 0.0
769
+ train_main_loss = 0.0
770
+ train_aux_spatial_loss = 0.0
771
+ train_items = 0
772
+ for batch in train_loader:
773
+ x = torch.nan_to_num(batch["x"], nan=0.0, posinf=0.0, neginf=0.0).to(device, non_blocking=True)
774
+ y = torch.nan_to_num(batch["y"], nan=0.0, posinf=0.0, neginf=0.0).to(device, non_blocking=True)
775
+ train_target = transform_train_target(y.float(), config)
776
+ optimizer.zero_grad(set_to_none=True)
777
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=bool(config.get("amp", True)) and device.type == "cuda"):
778
+ logits, aux_logits = maybe_unpack_logits(model(x, return_aux=use_aux_spatial_head))
779
+ main_loss = criterion(logits.float(), train_target.float())
780
+ aux_loss = torch.zeros((), device=device, dtype=torch.float32)
781
+ if aux_logits is not None and aux_spatial_loss_weight > 0.0:
782
+ aux_target = transform_spatial_aux_target(y.float(), aux_spatial_radius)
783
+ aux_loss = aux_criterion(aux_logits.float(), aux_target)
784
+ loss = main_loss + aux_spatial_loss_weight * aux_loss
785
+ if not torch.isfinite(loss):
786
+ raise RuntimeError("Non-finite tiled cold-start loss detected.")
787
+ scaler.scale(loss).backward()
788
+ scaler.step(optimizer)
789
+ scaler.update()
790
+ train_loss += float(loss.item()) * x.size(0)
791
+ train_main_loss += float(main_loss.item()) * x.size(0)
792
+ train_aux_spatial_loss += float(aux_loss.item()) * x.size(0)
793
+ train_items += int(x.size(0))
794
+
795
+ metrics = evaluate(
796
+ model=model,
797
+ loader=val_loader,
798
+ device=device,
799
+ thresholds=thresholds,
800
+ topk_area_fractions=topk_area_fractions,
801
+ fss_radii=fss_radii,
802
+ n_bins=n_bins,
803
+ reference_positive_rate=metric_reference_positive_rate,
804
+ criterion=criterion,
805
+ amp=bool(config.get("amp", True)),
806
+ )
807
+ threshold_metrics = metrics["threshold_metrics"][default_threshold_key]
808
+ record = {
809
+ "epoch": epoch,
810
+ "train_loss": train_loss / max(train_items, 1),
811
+ "train_main_loss": train_main_loss / max(train_items, 1),
812
+ "train_aux_spatial_loss": train_aux_spatial_loss / max(train_items, 1),
813
+ "val_loss": metrics["loss"],
814
+ "val_pr_auc": metrics["pr_auc"],
815
+ "val_auroc": metrics["auroc"],
816
+ "val_brier": metrics["brier"],
817
+ "val_brier_skill_score": metrics["brier_skill_score"],
818
+ "val_ece": metrics["ece"],
819
+ "val_positive_rate": metrics["positive_rate"],
820
+ "val_precision": threshold_metrics["precision"],
821
+ "val_positive_recall": threshold_metrics["recall"],
822
+ "val_far": threshold_metrics["far"],
823
+ "val_csi": threshold_metrics["csi"],
824
+ "val_f1": threshold_metrics["f1"],
825
+ "val_f2": threshold_metrics["f2"],
826
+ "val_frequency_bias": threshold_metrics["frequency_bias"],
827
+ "val_threshold_metrics": metrics["threshold_metrics"],
828
+ "val_topk_area_metrics": metrics["topk_area_metrics"],
829
+ "val_fss": metrics["fss"],
830
+ "minutes": (time.time() - epoch_start) / 60.0,
831
+ }
832
+ history.append(record)
833
+ (metric_dir / "history.json").write_text(json.dumps(history, indent=2), encoding="utf-8")
834
+ save_checkpoint(ckpt_dir / "latest.pt", model, optimizer, epoch, record, config)
835
+ if record["val_pr_auc"] > best_pr:
836
+ best_pr = record["val_pr_auc"]
837
+ best_state = {k: v.detach().cpu() for k, v in model.state_dict().items()}
838
+ torch.save({"model": best_state, "epoch": epoch, "metrics": record, "config": config}, ckpt_dir / "best_firms_prauc.pt")
839
+ print(json.dumps(record), flush=True)
840
+
841
+ if best_state is None:
842
+ best_state = {k: v.detach().cpu() for k, v in model.state_dict().items()}
843
+ model.load_state_dict(best_state, strict=True)
844
+ val_best = evaluate(
845
+ model=model,
846
+ loader=val_loader,
847
+ device=device,
848
+ thresholds=thresholds,
849
+ topk_area_fractions=topk_area_fractions,
850
+ fss_radii=fss_radii,
851
+ n_bins=n_bins,
852
+ reference_positive_rate=metric_reference_positive_rate,
853
+ criterion=criterion,
854
+ amp=bool(config.get("amp", True)),
855
+ )
856
+ test_best = evaluate(
857
+ model=model,
858
+ loader=test_loader,
859
+ device=device,
860
+ thresholds=thresholds,
861
+ topk_area_fractions=topk_area_fractions,
862
+ fss_radii=fss_radii,
863
+ n_bins=n_bins,
864
+ reference_positive_rate=metric_reference_positive_rate,
865
+ criterion=criterion,
866
+ amp=bool(config.get("amp", True)),
867
+ )
868
+ summary = {
869
+ "run_name": args.run_name,
870
+ "device": str(device),
871
+ "index_root": str(index_root),
872
+ "input_channels": in_ch,
873
+ "tile_size": int(config.get("tile_size", 16)),
874
+ "num_train_tiles": len(tile_rows),
875
+ "positive_train_tiles": int(sum(1 for row in tile_rows if row["tile_type"] == "positive")),
876
+ "negative_train_tiles": int(sum(1 for row in tile_rows if row["tile_type"] == "negative")),
877
+ "train_positive_rate": train_positive_rate,
878
+ "metric_reference_positive_rate": metric_reference_positive_rate,
879
+ "pos_weight": pos_weight,
880
+ "use_aux_spatial_head": use_aux_spatial_head,
881
+ "aux_spatial_radius": aux_spatial_radius,
882
+ "aux_spatial_loss_weight": aux_spatial_loss_weight,
883
+ "aux_positive_rate": aux_positive_rate,
884
+ "init_checkpoint": str(init_checkpoint) if init_checkpoint else "",
885
+ "best_val_pr_auc": float(val_best["pr_auc"]),
886
+ "best_val_auroc": float(val_best["auroc"]),
887
+ "best_test_pr_auc": float(test_best["pr_auc"]),
888
+ "best_test_auroc": float(test_best["auroc"]),
889
+ "best_test_brier": float(test_best["brier"]),
890
+ "best_test_ece": float(test_best["ece"]),
891
+ "best_test_topk_area_metrics": test_best["topk_area_metrics"],
892
+ "best_test_threshold_metrics": test_best["threshold_metrics"],
893
+ "best_test_fss": test_best["fss"],
894
+ "epochs": int(config.get("epochs", 30)),
895
+ }
896
+ (metric_dir / "run_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
897
+ print(json.dumps(summary, indent=2), flush=True)
898
+
899
+
900
+ if __name__ == "__main__":
901
+ main()
training/train_utils.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+
5
+ import numpy as np
6
+ import torch
7
+
8
+
9
+ def set_seed(seed: int) -> None:
10
+ """Set the RNG state used by the original FireWx-FM training script."""
11
+ random.seed(seed)
12
+ np.random.seed(seed)
13
+ torch.manual_seed(seed)
14
+ torch.cuda.manual_seed_all(seed)