Wildfire-FM / training /train_cold_tiled_mainline.py
yx21e's picture
Add serving-oriented tiled inference and jittered training support
9ac0772 verified
Raw
History Blame Contribute Delete
44.9 kB
from __future__ import annotations
import argparse
import csv
import json
import math
import random
import time
from pathlib import Path
from typing import Dict, Iterable, List, Tuple
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
from eval_metrics import metric_bundle
from train_utils import set_seed
def load_json(path: Path) -> Dict[str, object]:
return json.loads(path.read_text(encoding="utf-8"))
def read_rows(path: Path) -> List[Dict[str, str]]:
with path.open("r", encoding="utf-8", newline="") as fh:
return list(csv.DictReader(fh))
def write_json(path: Path, data: Dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def integral_image(mask: np.ndarray) -> np.ndarray:
return np.pad(mask.astype(np.int32), ((1, 0), (1, 0)), mode="constant").cumsum(0).cumsum(1)
def rect_sum(ii: np.ndarray, top: int, left: int, height: int, width: int) -> int:
bottom = top + height
right = left + width
return int(ii[bottom, right] - ii[top, right] - ii[bottom, left] + ii[top, left])
def centered_tile_origin(y: int, x: int, tile_h: int, tile_w: int, h: int, w: int) -> Tuple[int, int]:
top = min(max(y - tile_h // 2, 0), h - tile_h)
left = min(max(x - tile_w // 2, 0), w - tile_w)
return int(top), int(left)
def containing_tile_origin(y: int, x: int, tile_h: int, tile_w: int, h: int, w: int, rng: random.Random) -> Tuple[int, int]:
max_top = max(h - tile_h, 0)
max_left = max(w - tile_w, 0)
top_min = max(0, y - tile_h + 1)
top_max = min(y, max_top)
left_min = max(0, x - tile_w + 1)
left_max = min(x, max_left)
if top_min > top_max or left_min > left_max:
return centered_tile_origin(y, x, tile_h, tile_w, h, w)
return int(rng.randint(top_min, top_max)), int(rng.randint(left_min, left_max))
def positive_tile_origins(
mask: np.ndarray,
tile_h: int,
tile_w: int,
max_tiles: int,
rng: random.Random,
placement: str = "center",
) -> List[Tuple[int, int]]:
ys, xs = np.where(mask > 0.5)
if ys.size == 0:
return []
origin_fn = containing_tile_origin if placement == "random_containing" else centered_tile_origin
origins = []
seen = set()
indices = list(range(int(ys.size)))
rng.shuffle(indices)
for i in indices:
y = int(ys[i])
x = int(xs[i])
if placement == "random_containing":
origin = origin_fn(y, x, tile_h, tile_w, mask.shape[0], mask.shape[1], rng)
else:
origin = origin_fn(y, x, tile_h, tile_w, mask.shape[0], mask.shape[1])
if origin in seen:
continue
seen.add(origin)
origins.append(origin)
if max_tiles > 0 and len(origins) >= max_tiles:
break
rng.shuffle(origins)
if max_tiles > 0:
origins = origins[:max_tiles]
return origins
def negative_tile_origins(
mask: np.ndarray,
tile_h: int,
tile_w: int,
desired: int,
rng: random.Random,
forbidden: Iterable[Tuple[int, int]] = (),
) -> List[Tuple[int, int]]:
h, w = mask.shape
ii = integral_image(mask)
forbidden_set = set(forbidden)
out: List[Tuple[int, int]] = []
seen = set(forbidden_set)
max_top = max(h - tile_h, 0)
max_left = max(w - tile_w, 0)
max_attempts = max(2000, desired * 50)
attempts = 0
while len(out) < desired and attempts < max_attempts:
attempts += 1
top = rng.randint(0, max_top)
left = rng.randint(0, max_left)
origin = (top, left)
if origin in seen:
continue
if rect_sum(ii, top, left, tile_h, tile_w) != 0:
continue
seen.add(origin)
out.append(origin)
return out
def make_norm(norm_type: str, num_channels: int, norm_groups: int) -> nn.Module:
if norm_type == "batch":
return nn.BatchNorm2d(num_channels)
if norm_type == "group":
groups = max(1, min(int(norm_groups), num_channels))
while num_channels % groups != 0 and groups > 1:
groups -= 1
return nn.GroupNorm(groups, num_channels)
if norm_type == "instance":
return nn.InstanceNorm2d(num_channels, affine=True)
if norm_type in {"none", "identity"}:
return nn.Identity()
raise ValueError(f"Unsupported norm_type: {norm_type}")
class ConvBlock(nn.Module):
def __init__(self, in_ch: int, out_ch: int, norm_type: str, norm_groups: int):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
make_norm(norm_type, out_ch, norm_groups),
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
make_norm(norm_type, out_ch, norm_groups),
nn.ReLU(inplace=True),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x)
class UNetSmallFlex(nn.Module):
def __init__(
self,
in_ch: int,
base: int = 32,
dropout: float = 0.1,
norm_type: str = "group",
norm_groups: int = 8,
prior_prob: float | None = None,
use_aux_spatial_head: bool = False,
aux_prior_prob: float | None = None,
):
super().__init__()
self.enc1 = ConvBlock(in_ch, base, norm_type, norm_groups)
self.enc2 = ConvBlock(base, base * 2, norm_type, norm_groups)
self.enc3 = ConvBlock(base * 2, base * 4, norm_type, norm_groups)
self.enc4 = ConvBlock(base * 4, base * 8, norm_type, norm_groups)
self.pool = nn.MaxPool2d(2)
self.bottleneck = ConvBlock(base * 8, base * 16, norm_type, norm_groups)
self.up4 = nn.ConvTranspose2d(base * 16, base * 8, 2, stride=2)
self.dec4 = ConvBlock(base * 16, base * 8, norm_type, norm_groups)
self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2)
self.dec3 = ConvBlock(base * 8, base * 4, norm_type, norm_groups)
self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2)
self.dec2 = ConvBlock(base * 4, base * 2, norm_type, norm_groups)
self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2)
self.dec1 = ConvBlock(base * 2, base, norm_type, norm_groups)
self.drop = nn.Dropout2d(p=dropout)
self.head = nn.Conv2d(base, 1, kernel_size=1)
self.use_aux_spatial_head = bool(use_aux_spatial_head)
self.aux_head = nn.Conv2d(base, 1, kernel_size=1) if self.use_aux_spatial_head else None
if prior_prob is not None:
prior_prob = float(min(max(prior_prob, 1e-6), 1.0 - 1e-6))
nn.init.constant_(self.head.bias, math.log(prior_prob / (1.0 - prior_prob)))
if self.aux_head is not None and aux_prior_prob is not None:
aux_prior_prob = float(min(max(aux_prior_prob, 1e-6), 1.0 - 1e-6))
nn.init.constant_(self.aux_head.bias, math.log(aux_prior_prob / (1.0 - aux_prior_prob)))
@staticmethod
def _match_hw(x: torch.Tensor, ref: torch.Tensor) -> torch.Tensor:
diff_y = ref.size(2) - x.size(2)
diff_x = ref.size(3) - x.size(3)
if diff_y > 0 or diff_x > 0:
x = F.pad(x, [diff_x // 2, diff_x - diff_x // 2, diff_y // 2, diff_y - diff_y // 2])
if diff_y < 0:
y0 = (-diff_y) // 2
x = x[:, :, y0 : y0 + ref.size(2), :]
if diff_x < 0:
x0 = (-diff_x) // 2
x = x[:, :, :, x0 : x0 + ref.size(3)]
return x
def forward(self, x: torch.Tensor, return_aux: bool = False):
e1 = self.enc1(x)
e2 = self.enc2(self.pool(e1))
e3 = self.enc3(self.pool(e2))
e4 = self.enc4(self.pool(e3))
b = self.bottleneck(self.pool(e4))
d4 = self.dec4(torch.cat([self._match_hw(self.up4(b), e4), e4], dim=1))
d3 = self.dec3(torch.cat([self._match_hw(self.up3(d4), e3), e3], dim=1))
d2 = self.dec2(torch.cat([self._match_hw(self.up2(d3), e2), e2], dim=1))
d1 = self.dec1(torch.cat([self._match_hw(self.up1(d2), e1), e1], dim=1))
features = self.drop(d1)
logits = self.head(features)
if return_aux and self.aux_head is not None:
return logits, self.aux_head(features)
return logits
def normalization_config(config: Dict[str, object]) -> Dict[str, object]:
raw = config.get("input_normalization", {})
if raw is True:
return {"enabled": True}
if isinstance(raw, dict):
return raw
return {"enabled": False}
def compute_input_normalization_stats(
rows: List[Dict[str, str]],
continuous_channel_indices: List[int],
eps: float,
) -> Dict[str, object]:
count: Dict[int, int] = {int(idx): 0 for idx in continuous_channel_indices}
sum_x: Dict[int, float] = {int(idx): 0.0 for idx in continuous_channel_indices}
sum_x2: Dict[int, float] = {int(idx): 0.0 for idx in continuous_channel_indices}
channel_names: List[str] | None = None
static_names: List[str] | None = None
for row in rows:
sample = np.load(row["sample_path"], allow_pickle=True)
weather = ColdFeatureStore._sanitize(sample["weather"].astype(np.float32))
firewx = ColdFeatureStore._sanitize(sample["firewx"].astype(np.float32))
extra = []
if "firewx_valid" in sample:
extra.append(sample["firewx_valid"].astype(np.float32))
static_npz = np.load(row["static_path"], allow_pickle=True)
static = ColdFeatureStore._sanitize(static_npz["static"].astype(np.float32))
static_parts = []
if "static_valid" in static_npz:
static_parts.append(static_npz["static_valid"].astype(np.float32))
static_parts.append(static)
if channel_names is None:
weather_names = [str(v) for v in sample.get("weather_names", np.array([], dtype=object)).tolist()]
firewx_names = [str(v) for v in sample.get("firewx_names", np.array([], dtype=object)).tolist()]
extra_names = ["firewx_valid"] if "firewx_valid" in sample else []
static_names = [str(v) for v in static_npz.get("static_names", np.array([], dtype=object)).tolist()]
static_valid_names = ["static_valid"] if "static_valid" in static_npz else []
channel_names = weather_names + firewx_names + extra_names + static_valid_names + static_names
x = np.concatenate([weather, firewx, *extra, *static_parts], axis=0).astype(np.float32)
for idx in continuous_channel_indices:
arr = x[int(idx)].astype(np.float64, copy=False).ravel()
count[int(idx)] += int(arr.size)
sum_x[int(idx)] += float(arr.sum())
sum_x2[int(idx)] += float(np.square(arr).sum())
channels = []
for idx in continuous_channel_indices:
n = max(int(count[int(idx)]), 1)
mean = float(sum_x[int(idx)] / n)
variance = max(float(sum_x2[int(idx)] / n - mean * mean), 0.0)
std = float(max(math.sqrt(variance), eps))
channels.append(
{
"index": int(idx),
"name": channel_names[int(idx)] if channel_names and int(idx) < len(channel_names) else str(idx),
"mean": mean,
"std": std,
"count": int(count[int(idx)]),
}
)
return {
"enabled": True,
"method": "per-channel z-score",
"stats_source": "train split full maps",
"continuous_channel_indices": [int(idx) for idx in continuous_channel_indices],
"eps": float(eps),
"channels": channels,
"channel_names": channel_names or [],
"static_names": static_names or [],
}
def load_or_compute_input_normalization_stats(
config: Dict[str, object],
train_rows: List[Dict[str, str]],
metric_dir: Path,
) -> Dict[str, object] | None:
norm_cfg = normalization_config(config)
if not bool(norm_cfg.get("enabled", False)):
return None
stats_path_value = str(norm_cfg.get("stats_path", "")).strip()
if stats_path_value:
stats = load_json(Path(stats_path_value))
else:
stats = compute_input_normalization_stats(
rows=train_rows,
continuous_channel_indices=[int(v) for v in norm_cfg.get("continuous_channel_indices", list(range(10)))],
eps=float(norm_cfg.get("eps", 1e-6)),
)
write_json(metric_dir / "input_normalization_stats.json", stats)
return stats
class ColdFeatureStore:
def __init__(self, rows: List[Dict[str, str]], normalization_stats: Dict[str, object] | None = None):
self.cache: Dict[str, Dict[str, np.ndarray]] = {}
self.normalization_stats = normalization_stats
static_path = Path(rows[0]["static_path"])
static_npz = np.load(static_path, allow_pickle=True)
static = self._sanitize(static_npz["static"].astype(np.float32))
static_valid = static_npz["static_valid"].astype(np.float32) if "static_valid" in static_npz else None
static_parts = []
if static_valid is not None:
static_parts.append(static_valid.astype(np.float32))
static_parts.append(static)
static_x = np.concatenate(static_parts, axis=0).astype(np.float32)
for row in rows:
sample = np.load(row["sample_path"], allow_pickle=True)
weather = self._sanitize(sample["weather"].astype(np.float32))
firewx = self._sanitize(sample["firewx"].astype(np.float32))
extra = []
if "firewx_valid" in sample:
extra.append(sample["firewx_valid"].astype(np.float32))
x = np.concatenate([weather, firewx, *extra, static_x], axis=0).astype(np.float32)
x = self._apply_normalization(x)
y = np.nan_to_num(sample["y_occ"].astype(np.float32), nan=0.0, posinf=0.0, neginf=0.0)
self.cache[str(row["sample_id"])] = {"x": x, "y": y}
@staticmethod
def _sanitize(x: np.ndarray) -> np.ndarray:
x = np.where(x <= -9000.0, np.nan, x)
x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
return x.astype(np.float32, copy=False)
def _apply_normalization(self, x: np.ndarray) -> np.ndarray:
if not self.normalization_stats:
return x
for item in self.normalization_stats.get("channels", []):
idx = int(item["index"])
if idx < 0 or idx >= x.shape[0]:
continue
mean = float(item["mean"])
std = max(float(item["std"]), float(self.normalization_stats.get("eps", 1e-6)))
x[idx] = (x[idx] - mean) / std
return x.astype(np.float32, copy=False)
def get(self, sample_id: str) -> Dict[str, np.ndarray]:
return self.cache[sample_id]
def pooled_positive_rate(rows: List[Dict[str, str]], store: ColdFeatureStore, radius: int) -> float:
if radius <= 0:
return float(full_map_stats(rows, store)["positive_rate"])
pos = 0.0
total = 0.0
for row in rows:
sample = store.get(str(row["sample_id"]))
y = torch.from_numpy(sample["y"].astype(np.float32)).unsqueeze(0)
pooled = F.max_pool2d(y, kernel_size=radius * 2 + 1, stride=1, padding=radius)
pos += float((pooled > 0.5).sum().item())
total += float(pooled.numel())
return float(pos / total) if total > 0 else 0.0
def transform_spatial_aux_target(target: torch.Tensor, radius: int) -> torch.Tensor:
if radius <= 0:
return target
return F.max_pool2d(target, kernel_size=radius * 2 + 1, stride=1, padding=radius)
def transform_train_target(target: torch.Tensor, config: Dict[str, object]) -> torch.Tensor:
mode = str(config.get("train_target_mode", "hard"))
radius = int(config.get("train_target_radius", 0))
if mode == "hard" or radius <= 0:
return target
kernel = radius * 2 + 1
if mode == "dilate_max":
return F.max_pool2d(target, kernel_size=kernel, stride=1, padding=radius)
if mode == "soft_pool":
return F.avg_pool2d(target, kernel_size=kernel, stride=1, padding=radius)
raise ValueError(f"Unsupported train_target_mode: {mode}")
def maybe_unpack_logits(output):
if isinstance(output, tuple):
return output
return output, None
class FocalBCEWithLogitsLoss(nn.Module):
def __init__(self, gamma: float = 2.0, alpha: float = 0.75):
super().__init__()
self.gamma = float(gamma)
self.alpha = float(alpha)
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
bce = F.binary_cross_entropy_with_logits(logits, targets, reduction="none")
probs = torch.sigmoid(logits)
pt = torch.where(targets > 0.5, probs, 1.0 - probs)
alpha_t = torch.where(
targets > 0.5,
torch.full_like(targets, self.alpha),
torch.full_like(targets, 1.0 - self.alpha),
)
loss = alpha_t * ((1.0 - pt) ** self.gamma) * bce
return loss.mean()
class OHEMBCEWithLogitsLoss(nn.Module):
def __init__(self, pos_weight: float, neg_pos_ratio: float = 8.0, min_negatives: int = 64):
super().__init__()
self.pos_weight = float(pos_weight)
self.neg_pos_ratio = float(neg_pos_ratio)
self.min_negatives = int(min_negatives)
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
losses = F.binary_cross_entropy_with_logits(
logits,
targets,
reduction="none",
pos_weight=torch.tensor([self.pos_weight], device=logits.device, dtype=logits.dtype),
)
batch_losses: List[torch.Tensor] = []
batch_size = int(logits.shape[0])
for i in range(batch_size):
loss_i = losses[i].reshape(-1)
tgt_i = targets[i].reshape(-1) > 0.5
pos_loss = loss_i[tgt_i]
neg_loss = loss_i[~tgt_i]
num_pos = int(pos_loss.numel())
keep_neg = max(self.min_negatives, int(self.neg_pos_ratio * max(num_pos, 1)))
keep_neg = min(keep_neg, int(neg_loss.numel()))
if keep_neg > 0:
neg_loss = torch.topk(neg_loss, k=keep_neg, largest=True).values
else:
neg_loss = neg_loss[:0]
denom = max(num_pos + int(neg_loss.numel()), 1)
batch_losses.append((pos_loss.sum() + neg_loss.sum()) / denom)
return torch.stack(batch_losses).mean()
class SoftFbetaBCEWithLogitsLoss(nn.Module):
def __init__(self, pos_weight: float, beta: float = 2.0, bce_weight: float = 0.4, smooth: float = 1.0):
super().__init__()
self.pos_weight = float(pos_weight)
self.beta = float(beta)
self.bce_weight = float(bce_weight)
self.smooth = float(smooth)
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
targets = targets.clamp(0.0, 1.0)
bce = F.binary_cross_entropy_with_logits(
logits,
targets,
reduction="mean",
pos_weight=torch.tensor([self.pos_weight], device=logits.device, dtype=logits.dtype),
)
probs = torch.sigmoid(logits.float())
targets_f = targets.float()
dims = tuple(range(1, probs.ndim))
tp = (probs * targets_f).sum(dim=dims)
fp = (probs * (1.0 - targets_f)).sum(dim=dims)
fn = ((1.0 - probs) * targets_f).sum(dim=dims)
beta_sq = self.beta * self.beta
fbeta = ((1.0 + beta_sq) * tp + self.smooth) / ((1.0 + beta_sq) * tp + beta_sq * fn + fp + self.smooth)
soft_loss = 1.0 - fbeta.mean()
return self.bce_weight * bce + (1.0 - self.bce_weight) * soft_loss
class TverskyBCEWithLogitsLoss(nn.Module):
def __init__(
self,
pos_weight: float,
alpha: float = 0.3,
beta: float = 0.7,
bce_weight: float = 0.4,
smooth: float = 1.0,
):
super().__init__()
self.pos_weight = float(pos_weight)
self.alpha = float(alpha)
self.beta = float(beta)
self.bce_weight = float(bce_weight)
self.smooth = float(smooth)
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
targets = targets.clamp(0.0, 1.0)
bce = F.binary_cross_entropy_with_logits(
logits,
targets,
reduction="mean",
pos_weight=torch.tensor([self.pos_weight], device=logits.device, dtype=logits.dtype),
)
probs = torch.sigmoid(logits.float())
targets_f = targets.float()
dims = tuple(range(1, probs.ndim))
tp = (probs * targets_f).sum(dim=dims)
fp = (probs * (1.0 - targets_f)).sum(dim=dims)
fn = ((1.0 - probs) * targets_f).sum(dim=dims)
tversky = (tp + self.smooth) / (tp + self.alpha * fp + self.beta * fn + self.smooth)
return self.bce_weight * bce + (1.0 - self.bce_weight) * (1.0 - tversky.mean())
class FSSBCEWithLogitsLoss(nn.Module):
def __init__(
self,
pos_weight: float,
radii: Iterable[int] = (1, 2),
bce_weight: float = 0.5,
eps: float = 1e-6,
):
super().__init__()
self.pos_weight = float(pos_weight)
self.radii = [int(v) for v in radii if int(v) > 0]
if not self.radii:
self.radii = [1]
self.bce_weight = float(bce_weight)
self.eps = float(eps)
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
targets = targets.clamp(0.0, 1.0)
bce = F.binary_cross_entropy_with_logits(
logits,
targets,
reduction="mean",
pos_weight=torch.tensor([self.pos_weight], device=logits.device, dtype=logits.dtype),
)
probs = torch.sigmoid(logits.float())
targets_f = targets.float()
fss_losses: List[torch.Tensor] = []
for radius in self.radii:
kernel = radius * 2 + 1
pred_frac = F.avg_pool2d(probs, kernel_size=kernel, stride=1, padding=radius)
target_frac = F.avg_pool2d(targets_f, kernel_size=kernel, stride=1, padding=radius)
mse = torch.mean((pred_frac - target_frac) ** 2)
reference = torch.mean(pred_frac**2 + target_frac**2)
fss = 1.0 - mse / (reference + self.eps)
fss_losses.append(1.0 - fss)
fss_loss = torch.stack(fss_losses).mean()
return self.bce_weight * bce + (1.0 - self.bce_weight) * fss_loss
def build_loss(config: Dict[str, object], pos_weight: float, device: torch.device) -> nn.Module:
loss_type = str(config.get("loss_type", "bce"))
if loss_type == "bce":
return nn.BCEWithLogitsLoss(pos_weight=torch.tensor([pos_weight], dtype=torch.float32, device=device))
if loss_type == "focal_bce":
return FocalBCEWithLogitsLoss(
gamma=float(config.get("focal_gamma", 2.0)),
alpha=float(config.get("focal_alpha", 0.75)),
)
if loss_type == "ohem_bce":
return OHEMBCEWithLogitsLoss(
pos_weight=pos_weight,
neg_pos_ratio=float(config.get("ohem_neg_pos_ratio", 8.0)),
min_negatives=int(config.get("ohem_min_negatives", 64)),
)
if loss_type == "soft_fbeta_bce":
return SoftFbetaBCEWithLogitsLoss(
pos_weight=pos_weight,
beta=float(config.get("soft_fbeta_beta", 2.0)),
bce_weight=float(config.get("soft_metric_bce_weight", 0.4)),
smooth=float(config.get("soft_metric_smooth", 1.0)),
)
if loss_type == "tversky_bce":
return TverskyBCEWithLogitsLoss(
pos_weight=pos_weight,
alpha=float(config.get("tversky_alpha", 0.3)),
beta=float(config.get("tversky_beta", 0.7)),
bce_weight=float(config.get("soft_metric_bce_weight", 0.4)),
smooth=float(config.get("soft_metric_smooth", 1.0)),
)
if loss_type == "fss_bce":
return FSSBCEWithLogitsLoss(
pos_weight=pos_weight,
radii=[int(v) for v in config.get("fss_loss_radii", [1, 2])],
bce_weight=float(config.get("soft_metric_bce_weight", 0.5)),
)
raise ValueError(f"Unsupported loss_type: {loss_type}")
def full_map_stats(rows: List[Dict[str, str]], store: ColdFeatureStore) -> Dict[str, float]:
pos = 0.0
total = 0.0
for row in rows:
sample = store.get(str(row["sample_id"]))
y = sample["y"]
pos += float((y > 0.5).sum())
total += float(y.size)
neg = max(total - pos, 0.0)
raw_pos_weight = float(max(neg / max(pos, 1.0), 1.0)) if total > 0 else 1.0
return {
"source": "full_map",
"positive_cells": pos,
"total_cells": total,
"positive_rate": float(pos / total) if total > 0 else 0.0,
"raw_pos_weight": raw_pos_weight,
}
def tile_stats(tile_rows: List[Dict[str, object]], store: ColdFeatureStore) -> Dict[str, float]:
pos = 0.0
total = 0.0
for row in tile_rows:
sample = store.get(str(row["sample_id"]))
top = int(row["top"])
left = int(row["left"])
tile_size = int(row["tile_size"])
target = sample["y"][0, top : top + tile_size, left : left + tile_size]
pos += float((target > 0.5).sum())
total += float(target.size)
neg = max(total - pos, 0.0)
raw_pos_weight = float(max(neg / max(pos, 1.0), 1.0)) if total > 0 else 1.0
return {
"source": "tiles",
"positive_cells": pos,
"total_cells": total,
"positive_rate": float(pos / total) if total > 0 else 0.0,
"raw_pos_weight": raw_pos_weight,
}
def select_stats(source: str, train_rows: List[Dict[str, str]], tile_rows: List[Dict[str, object]], store: ColdFeatureStore) -> Dict[str, float]:
if source == "full_map":
return full_map_stats(train_rows, store)
if source == "tiles":
return tile_stats(tile_rows, store)
raise ValueError(f"Unsupported stats source: {source}")
def build_train_tiles(
train_rows: List[Dict[str, str]],
store: ColdFeatureStore,
tile_size: int,
max_positive_tiles_per_sample: int,
min_negative_tiles_per_sample: int,
neg_pos_ratio: float,
positive_tile_placement: str,
rng: random.Random,
) -> List[Dict[str, object]]:
tile_rows: List[Dict[str, object]] = []
for row in train_rows:
sample_id = str(row["sample_id"])
mask = store.get(sample_id)["y"][0]
pos_origins = positive_tile_origins(
mask=mask,
tile_h=tile_size,
tile_w=tile_size,
max_tiles=max_positive_tiles_per_sample,
rng=rng,
placement=positive_tile_placement,
)
neg_count = max(min_negative_tiles_per_sample, int(math.ceil(len(pos_origins) * neg_pos_ratio)))
neg_origins = negative_tile_origins(
mask=mask,
tile_h=tile_size,
tile_w=tile_size,
desired=neg_count,
rng=rng,
forbidden=pos_origins,
)
for top, left in pos_origins:
tile_rows.append(
{
"sample_id": sample_id,
"tile_type": "positive",
"top": int(top),
"left": int(left),
"tile_size": int(tile_size),
}
)
for top, left in neg_origins:
tile_rows.append(
{
"sample_id": sample_id,
"tile_type": "negative",
"top": int(top),
"left": int(left),
"tile_size": int(tile_size),
}
)
return tile_rows
class TrainTileDataset(Dataset):
def __init__(self, tile_rows: List[Dict[str, object]], store: ColdFeatureStore, augment_flip: bool, seed: int):
self.rows = tile_rows
self.store = store
self.augment_flip = bool(augment_flip)
self.rng = random.Random(seed)
def __len__(self) -> int:
return len(self.rows)
def __getitem__(self, idx: int):
row = self.rows[idx]
sample = self.store.get(str(row["sample_id"]))
top = int(row["top"])
left = int(row["left"])
tile_size = int(row["tile_size"])
x = sample["x"][:, top : top + tile_size, left : left + tile_size]
y = sample["y"][:, top : top + tile_size, left : left + tile_size]
if self.augment_flip:
if self.rng.random() < 0.5:
x = x[:, :, ::-1].copy()
y = y[:, :, ::-1].copy()
if self.rng.random() < 0.5:
x = x[:, ::-1, :].copy()
y = y[:, ::-1, :].copy()
return {"x": torch.from_numpy(x), "y": torch.from_numpy(y)}
class FullMapDataset(Dataset):
def __init__(self, rows: List[Dict[str, str]], store: ColdFeatureStore):
self.rows = rows
self.store = store
def __len__(self) -> int:
return len(self.rows)
def __getitem__(self, idx: int):
row = self.rows[idx]
sample = self.store.get(str(row["sample_id"]))
return {"x": torch.from_numpy(sample["x"]), "y": torch.from_numpy(sample["y"]), "sample_id": str(row["sample_id"])}
def evaluate(
model: nn.Module,
loader: DataLoader,
device: torch.device,
thresholds: List[float],
topk_area_fractions: List[float],
fss_radii: List[int],
n_bins: int,
reference_positive_rate: float,
criterion: nn.Module,
amp: bool,
) -> Dict[str, object]:
model.eval()
total_loss = 0.0
total_items = 0
all_prob_maps: List[np.ndarray] = []
all_target_maps: List[np.ndarray] = []
with torch.no_grad():
for batch in loader:
x = torch.nan_to_num(batch["x"], nan=0.0, posinf=0.0, neginf=0.0).to(device, non_blocking=True)
y = torch.nan_to_num(batch["y"], nan=0.0, posinf=0.0, neginf=0.0).to(device, non_blocking=True)
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=amp and device.type == "cuda"):
logits, _ = maybe_unpack_logits(model(x))
loss = criterion(logits, y)
prob = torch.sigmoid(logits.float()).detach().cpu().numpy()[:, 0, :, :]
target = y.float().detach().cpu().numpy()[:, 0, :, :]
all_prob_maps.append(prob)
all_target_maps.append(target)
total_loss += float(loss.item()) * x.size(0)
total_items += int(x.size(0))
prob_maps = np.concatenate(all_prob_maps, axis=0)
target_maps = np.concatenate(all_target_maps, axis=0)
metrics = metric_bundle(
prob_maps=prob_maps,
target_maps=target_maps,
thresholds=thresholds,
topk_fractions=topk_area_fractions,
fss_radii=fss_radii,
n_bins=n_bins,
reference_positive_rate=reference_positive_rate,
)
metrics.update({"loss": total_loss / max(total_items, 1), "num_samples": int(prob_maps.shape[0])})
return metrics
def save_checkpoint(path: Path, model: nn.Module, optimizer: torch.optim.Optimizer, epoch: int, metrics: Dict[str, float], config: Dict[str, object]) -> None:
torch.save(
{
"epoch": epoch,
"model": model.state_dict(),
"optimizer": optimizer.state_dict(),
"metrics": metrics,
"config": config,
},
path,
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--run-name", type=str, required=True)
args = parser.parse_args()
config = load_json(args.config)
set_seed(int(config.get("seed", 7)))
rng = random.Random(int(config.get("seed", 7)))
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
index_root = Path(config["index_root"])
run_root = Path(config["run_root"])
ckpt_dir = run_root / "checkpoints" / args.run_name
metric_dir = run_root / "metrics" / args.run_name
ckpt_dir.mkdir(parents=True, exist_ok=True)
metric_dir.mkdir(parents=True, exist_ok=True)
train_rows = read_rows(index_root / "splits" / "train.csv")
val_rows = read_rows(index_root / "splits" / "val.csv")
test_rows = read_rows(index_root / "splits" / "test.csv")
normalization_stats = load_or_compute_input_normalization_stats(config, train_rows, metric_dir)
store = ColdFeatureStore(train_rows + val_rows + test_rows, normalization_stats=normalization_stats)
tile_rows = build_train_tiles(
train_rows=train_rows,
store=store,
tile_size=int(config.get("tile_size", 16)),
max_positive_tiles_per_sample=int(config.get("max_positive_tiles_per_sample", 64)),
min_negative_tiles_per_sample=int(config.get("min_negative_tiles_per_sample", 4)),
neg_pos_ratio=float(config.get("negative_to_positive_ratio", 2.0)),
positive_tile_placement=str(config.get("positive_tile_placement", "center")),
rng=rng,
)
train_ds = TrainTileDataset(tile_rows=tile_rows, store=store, augment_flip=bool(config.get("augment_flip", True)), seed=int(config.get("seed", 7)))
val_ds = FullMapDataset(val_rows, store)
test_ds = FullMapDataset(test_rows, store)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_loader = DataLoader(train_ds, batch_size=int(config.get("batch_size", 64)), shuffle=True, num_workers=0, pin_memory=device.type == "cuda")
val_loader = DataLoader(val_ds, batch_size=int(config.get("eval_batch_size", 8)), shuffle=False, num_workers=0, pin_memory=device.type == "cuda")
test_loader = DataLoader(test_ds, batch_size=int(config.get("eval_batch_size", 8)), shuffle=False, num_workers=0, pin_memory=device.type == "cuda")
positive_rate_source = str(config.get("positive_rate_source", "full_map"))
pos_weight_source = str(config.get("pos_weight_source", "full_map"))
metric_reference_positive_rate = full_map_stats(train_rows, store)["positive_rate"]
positive_rate_stats = select_stats(positive_rate_source, train_rows, tile_rows, store)
pos_weight_stats = select_stats(pos_weight_source, train_rows, tile_rows, store)
train_positive_rate = float(positive_rate_stats["positive_rate"])
raw_pos_weight = float(pos_weight_stats["raw_pos_weight"])
pos_weight_cap = float(config.get("pos_weight_cap", 300.0))
pos_weight = float(min(max(raw_pos_weight, 1.0), pos_weight_cap))
use_aux_spatial_head = bool(config.get("use_aux_spatial_head", False))
aux_spatial_radius = int(config.get("aux_spatial_radius", 1))
aux_positive_rate = pooled_positive_rate(train_rows, store, aux_spatial_radius) if use_aux_spatial_head else None
in_ch = int(store.get(str(train_rows[0]["sample_id"]))["x"].shape[0])
model = UNetSmallFlex(
in_ch=in_ch,
base=int(config.get("base_channels", 32)),
dropout=float(config.get("dropout", 0.1)),
norm_type=str(config.get("norm_type", "group")),
norm_groups=int(config.get("norm_groups", 8)),
prior_prob=train_positive_rate if bool(config.get("init_head_bias_from_positive_rate", True)) else None,
use_aux_spatial_head=use_aux_spatial_head,
aux_prior_prob=aux_positive_rate if bool(config.get("init_head_bias_from_positive_rate", True)) else None,
).to(device)
init_checkpoint = config.get("init_checkpoint")
if init_checkpoint:
checkpoint = torch.load(str(init_checkpoint), map_location="cpu")
state = checkpoint.get("model", checkpoint)
if not isinstance(state, dict):
raise RuntimeError(f"Unexpected init_checkpoint format: {init_checkpoint}")
model.load_state_dict(state, strict=False)
criterion = build_loss(config=config, pos_weight=pos_weight, device=device)
aux_criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=float(config.get("learning_rate", 3e-4)), weight_decay=float(config.get("weight_decay", 1e-4)))
scaler = torch.amp.GradScaler("cuda", enabled=bool(config.get("amp", True)) and device.type == "cuda")
thresholds = [float(v) for v in config.get("metric_thresholds", [0.1, 0.2, 0.3, 0.5])]
topk_area_fractions = [float(v) for v in config.get("topk_area_fractions", [0.01, 0.05, 0.1])]
fss_radii = [int(v) for v in config.get("fss_radii", [1, 2, 4, 8])]
n_bins = int(config.get("reliability_bins", 10))
default_threshold_key = f"{float(config.get('threshold', 0.3)):.4f}"
summary_seed = {
"index_root": str(index_root),
"tile_size": int(config.get("tile_size", 16)),
"num_train_tiles": len(tile_rows),
"positive_train_tiles": int(sum(1 for row in tile_rows if row["tile_type"] == "positive")),
"negative_train_tiles": int(sum(1 for row in tile_rows if row["tile_type"] == "negative")),
"input_channels": in_ch,
"positive_tile_placement": str(config.get("positive_tile_placement", "center")),
"train_positive_rate": train_positive_rate,
"metric_reference_positive_rate": metric_reference_positive_rate,
"pos_weight_source": pos_weight_source,
"positive_rate_source": positive_rate_source,
"pos_weight": pos_weight,
"loss_type": str(config.get("loss_type", "bce")),
"train_target_mode": str(config.get("train_target_mode", "hard")),
"train_target_radius": int(config.get("train_target_radius", 0)),
"input_normalization": normalization_stats if normalization_stats else {"enabled": False},
}
(metric_dir / "tile_summary.json").write_text(json.dumps(summary_seed, indent=2), encoding="utf-8")
best_pr = -1.0
best_state = None
history: List[Dict[str, float]] = []
aux_spatial_loss_weight = float(config.get("aux_spatial_loss_weight", 0.0))
for epoch in range(1, int(config.get("epochs", 30)) + 1):
model.train()
epoch_start = time.time()
train_loss = 0.0
train_main_loss = 0.0
train_aux_spatial_loss = 0.0
train_items = 0
for batch in train_loader:
x = torch.nan_to_num(batch["x"], nan=0.0, posinf=0.0, neginf=0.0).to(device, non_blocking=True)
y = torch.nan_to_num(batch["y"], nan=0.0, posinf=0.0, neginf=0.0).to(device, non_blocking=True)
train_target = transform_train_target(y.float(), config)
optimizer.zero_grad(set_to_none=True)
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=bool(config.get("amp", True)) and device.type == "cuda"):
logits, aux_logits = maybe_unpack_logits(model(x, return_aux=use_aux_spatial_head))
main_loss = criterion(logits.float(), train_target.float())
aux_loss = torch.zeros((), device=device, dtype=torch.float32)
if aux_logits is not None and aux_spatial_loss_weight > 0.0:
aux_target = transform_spatial_aux_target(y.float(), aux_spatial_radius)
aux_loss = aux_criterion(aux_logits.float(), aux_target)
loss = main_loss + aux_spatial_loss_weight * aux_loss
if not torch.isfinite(loss):
raise RuntimeError("Non-finite tiled cold-start loss detected.")
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
train_loss += float(loss.item()) * x.size(0)
train_main_loss += float(main_loss.item()) * x.size(0)
train_aux_spatial_loss += float(aux_loss.item()) * x.size(0)
train_items += int(x.size(0))
metrics = evaluate(
model=model,
loader=val_loader,
device=device,
thresholds=thresholds,
topk_area_fractions=topk_area_fractions,
fss_radii=fss_radii,
n_bins=n_bins,
reference_positive_rate=metric_reference_positive_rate,
criterion=criterion,
amp=bool(config.get("amp", True)),
)
threshold_metrics = metrics["threshold_metrics"][default_threshold_key]
record = {
"epoch": epoch,
"train_loss": train_loss / max(train_items, 1),
"train_main_loss": train_main_loss / max(train_items, 1),
"train_aux_spatial_loss": train_aux_spatial_loss / max(train_items, 1),
"val_loss": metrics["loss"],
"val_pr_auc": metrics["pr_auc"],
"val_auroc": metrics["auroc"],
"val_brier": metrics["brier"],
"val_brier_skill_score": metrics["brier_skill_score"],
"val_ece": metrics["ece"],
"val_positive_rate": metrics["positive_rate"],
"val_precision": threshold_metrics["precision"],
"val_positive_recall": threshold_metrics["recall"],
"val_far": threshold_metrics["far"],
"val_csi": threshold_metrics["csi"],
"val_f1": threshold_metrics["f1"],
"val_f2": threshold_metrics["f2"],
"val_frequency_bias": threshold_metrics["frequency_bias"],
"val_threshold_metrics": metrics["threshold_metrics"],
"val_topk_area_metrics": metrics["topk_area_metrics"],
"val_fss": metrics["fss"],
"minutes": (time.time() - epoch_start) / 60.0,
}
history.append(record)
(metric_dir / "history.json").write_text(json.dumps(history, indent=2), encoding="utf-8")
save_checkpoint(ckpt_dir / "latest.pt", model, optimizer, epoch, record, config)
if record["val_pr_auc"] > best_pr:
best_pr = record["val_pr_auc"]
best_state = {k: v.detach().cpu() for k, v in model.state_dict().items()}
torch.save({"model": best_state, "epoch": epoch, "metrics": record, "config": config}, ckpt_dir / "best_firms_prauc.pt")
print(json.dumps(record), flush=True)
if best_state is None:
best_state = {k: v.detach().cpu() for k, v in model.state_dict().items()}
model.load_state_dict(best_state, strict=True)
val_best = evaluate(
model=model,
loader=val_loader,
device=device,
thresholds=thresholds,
topk_area_fractions=topk_area_fractions,
fss_radii=fss_radii,
n_bins=n_bins,
reference_positive_rate=metric_reference_positive_rate,
criterion=criterion,
amp=bool(config.get("amp", True)),
)
test_best = evaluate(
model=model,
loader=test_loader,
device=device,
thresholds=thresholds,
topk_area_fractions=topk_area_fractions,
fss_radii=fss_radii,
n_bins=n_bins,
reference_positive_rate=metric_reference_positive_rate,
criterion=criterion,
amp=bool(config.get("amp", True)),
)
summary = {
"run_name": args.run_name,
"device": str(device),
"index_root": str(index_root),
"input_channels": in_ch,
"tile_size": int(config.get("tile_size", 16)),
"num_train_tiles": len(tile_rows),
"positive_train_tiles": int(sum(1 for row in tile_rows if row["tile_type"] == "positive")),
"negative_train_tiles": int(sum(1 for row in tile_rows if row["tile_type"] == "negative")),
"positive_tile_placement": str(config.get("positive_tile_placement", "center")),
"train_positive_rate": train_positive_rate,
"metric_reference_positive_rate": metric_reference_positive_rate,
"pos_weight": pos_weight,
"use_aux_spatial_head": use_aux_spatial_head,
"aux_spatial_radius": aux_spatial_radius,
"aux_spatial_loss_weight": aux_spatial_loss_weight,
"aux_positive_rate": aux_positive_rate,
"init_checkpoint": str(init_checkpoint) if init_checkpoint else "",
"input_normalization": normalization_stats if normalization_stats else {"enabled": False},
"best_val_pr_auc": float(val_best["pr_auc"]),
"best_val_auroc": float(val_best["auroc"]),
"best_test_pr_auc": float(test_best["pr_auc"]),
"best_test_auroc": float(test_best["auroc"]),
"best_test_brier": float(test_best["brier"]),
"best_test_ece": float(test_best["ece"]),
"best_test_topk_area_metrics": test_best["topk_area_metrics"],
"best_test_threshold_metrics": test_best["threshold_metrics"],
"best_test_fss": test_best["fss"],
"epochs": int(config.get("epochs", 30)),
}
(metric_dir / "run_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(json.dumps(summary, indent=2), flush=True)
if __name__ == "__main__":
main()