Add FireWx-FM training and data loader pipeline
Browse files- training/README.md +96 -0
- training/build_phase1_cache_regional_hrrr.py +484 -0
- training/cache_helpers.py +35 -0
- training/configs/stage1_cache_regional_hrrr_ca_5km_l12_template.json +71 -0
- training/configs/train_firewx_fm_seed7_template.json +62 -0
- training/eval_metrics.py +826 -0
- training/requirements-training.txt +9 -0
- training/train_cold_tiled_mainline.py +901 -0
- training/train_utils.py +14 -0
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)
|