meta_cal/PI_code_test_FNO.py

1041 lines
37 KiB
Python

# train_ddp_fno_encoderfno_ddpsafe_split_save_all_fields.py
# =========================================================
# ✅ DDP-safe split + training
# ✅ After training:
# - Load best model
# - Infer ALL train/test samples (DDP)
# - Save per-sample errors + ALL E_pred/E_true + coords to MAT
# - Also save an example sample MAT + PNG plot (optional)
#
# ✅ Adapted for NEW dataset: metalens_dataset.mat (NO standardization / NO denorm)
# - Rimg: (N,1,512,512)
# - coords: (M,2) in [-1,1]
# - Ere_flat: (N,M) normalized
# - Eim_flat: (N,M) normalized
# =========================================================
import os
import math
import datetime
import numpy as np
import torch
from tqdm.auto import tqdm
from scipy.io import savemat
import torch.nn as nn
from torch.utils.data import DataLoader, Subset, ConcatDataset
import matplotlib.pyplot as plt
from scipy.interpolate import griddata
from pathlib import Path
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data.distributed import DistributedSampler
# Existing API (must be updated to read metalens_dataset.mat)
from getdata import build_datasets
# =========================================================
# DDP helpers
# =========================================================
def ddp_setup():
dist.init_process_group(backend="nccl")
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
device = torch.device(f"cuda:{local_rank}")
return device, local_rank
def ddp_cleanup():
if dist.is_available() and dist.is_initialized():
dist.destroy_process_group()
def is_main_process():
return (not dist.is_available()) or (not dist.is_initialized()) or dist.get_rank() == 0
def world_size():
return dist.get_world_size() if dist.is_available() and dist.is_initialized() else 1
def broadcast_tensor_(t: torch.Tensor, src: int = 0):
if world_size() > 1:
dist.broadcast(t, src=src)
return t
def all_gather_object(obj):
"""Gather python objects to every rank."""
if world_size() == 1:
return [obj]
out = [None for _ in range(world_size())]
dist.all_gather_object(out, obj)
return out
# =========================================================
# Norm
# =========================================================
def make_gn(num_channels: int, max_groups: int = 8) -> nn.GroupNorm:
for g in [max_groups, 4, 2, 1]:
if num_channels % g == 0:
return nn.GroupNorm(num_groups=g, num_channels=num_channels)
return nn.GroupNorm(num_groups=1, num_channels=num_channels)
# =========================================================
# Loss: Relative L2 (on flattened M points)
# =========================================================
class RelL2Loss(nn.Module):
def __init__(self, eps=1e-12):
super().__init__()
self.eps = eps
def forward(self, pred: torch.Tensor, true: torch.Tensor) -> torch.Tensor:
# pred/true: (B,M,2)
num = torch.sum((pred - true) ** 2, dim=(1, 2))
den = torch.sum(true ** 2, dim=(1, 2)) + self.eps
return torch.mean(num / den)
def rel_l2_per_sample(pred: torch.Tensor, true: torch.Tensor, eps=1e-12) -> torch.Tensor:
num = torch.sum((pred - true) ** 2, dim=(1, 2))
den = torch.sum(true ** 2, dim=(1, 2)) + eps
return num / den # (B,)
# =========================================================
# FNO core (self-contained) -- FIXED FFTConv2d (modes clamp)
# =========================================================
class FFTConv2d(nn.Module):
def __init__(self, in_channels: int, out_channels: int, modes: int):
super().__init__()
self.out_channels = out_channels
self.modes = int(modes)
self.w = nn.Parameter(
torch.rand(in_channels, out_channels, 2 * self.modes, self.modes, 2)
/ (in_channels * out_channels)
)
@staticmethod
def cmul2d(a, b):
return torch.einsum("bixy,ioxy->boxy", a, b)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, C, H, W = x.shape
device = x.device
x_ft = torch.fft.rfft2(x) # (B,C,H,Wf)
Wf = x_ft.size(-1)
my = min(self.modes, H // 2)
mx = min(self.modes, Wf)
out_ft = torch.zeros(B, self.out_channels, H, Wf, dtype=torch.cfloat, device=device)
x_ft = torch.fft.fftshift(x_ft, dim=-2)
cy = H // 2
a = x_ft[..., cy - my: cy + my, :mx] # (B,inC,2*my,mx)
w_c = torch.view_as_complex(self.w) # (inC,outC,2*modes,modes)
b = w_c[..., self.modes - my: self.modes + my, :mx]
out_ft[..., cy - my: cy + my, :mx] = self.cmul2d(a, b)
out_ft = torch.fft.ifftshift(out_ft, dim=-2)
x = torch.fft.irfft2(out_ft, s=(H, W))
return x
class FNOBlock2d(nn.Module):
def __init__(self, modes: int, width: int, norm: str = "gn", dropout: float = 0.10, layerscale=0.10):
super().__init__()
self.fftconv = FFTConv2d(width, width, modes)
self.conv = nn.Conv2d(width, width, 1, bias=False)
self.norm = nn.BatchNorm2d(width) if norm == "bn" else make_gn(width)
self.act = nn.GELU()
self.drop = nn.Dropout2d(p=float(dropout)) if dropout and dropout > 0 else nn.Identity()
self.gamma = nn.Parameter(layerscale * torch.ones(1, width, 1, 1)) if layerscale and layerscale > 0 else None
def forward(self, x):
y = self.fftconv(x) + self.conv(x)
x = x + (self.gamma * y if self.gamma is not None else y)
x = self.norm(x)
x = self.act(x)
x = self.drop(x)
return x
class FNOModel2d(nn.Module):
def __init__(
self,
modes=6,
width=24,
blocks=4,
padding=0,
in_channels=3,
out_channels=2,
norm="gn",
dropout=0.10,
layerscale=0.10,
):
super().__init__()
self.conv_in = nn.Conv2d(in_channels, width, 1, bias=True)
self.padding = int(padding)
self.pad_in = nn.ConstantPad2d(self.padding, 0.0) if self.padding > 0 else nn.Identity()
self.pad_out = nn.ConstantPad2d(-self.padding, 0.0) if self.padding > 0 else nn.Identity()
self.fno_blocks = nn.Sequential(
*[FNOBlock2d(modes, width, norm=norm, dropout=dropout, layerscale=layerscale) for _ in range(blocks)]
)
self.conv_out = nn.Sequential(
nn.Conv2d(width, width, 1),
nn.GELU(),
nn.Dropout2d(dropout),
nn.Conv2d(width, out_channels, 1),
)
def forward(self, x):
x = self.conv_in(x)
x = self.pad_in(x)
x = self.fno_blocks(x)
x = self.pad_out(x)
x = self.conv_out(x)
return x
# =========================================================
# Anti-alias Geometry Encoder
# =========================================================
class GeomEncoder(nn.Module):
def __init__(self, out_ch=8, drop=0.10):
super().__init__()
self.prepool = nn.AvgPool2d(kernel_size=6, stride=6) # 512 -> ~85
self.net = nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1, bias=False),
make_gn(16), nn.GELU(), nn.Dropout2d(drop),
nn.Conv2d(16, 24, 3, stride=2, padding=1, bias=False), # ~85 -> ~43
make_gn(24), nn.GELU(), nn.Dropout2d(drop),
nn.Conv2d(24, 32, 3, stride=2, padding=1, bias=False), # ~43 -> ~22
make_gn(32), nn.GELU(), nn.Dropout2d(drop),
nn.Conv2d(32, out_ch, 1, bias=True),
make_gn(out_ch), nn.GELU(),
)
self.pool_to = None
def forward(self, x_img, Ny, Nx):
h = self.prepool(x_img)
h = self.net(h)
if (self.pool_to is None) or (self.pool_to.output_size != (Ny, Nx)):
self.pool_to = nn.AdaptiveAvgPool2d((Ny, Nx))
return self.pool_to(h)
# =========================================================
# Optional Fourier features (DEFAULT OFF)
# =========================================================
class FourierFeatures2D(nn.Module):
def __init__(self, K=2, scale=1.0):
super().__init__()
self.K = int(K)
self.scale = float(scale)
freqs = torch.linspace(1.0, self.K, steps=self.K) * self.scale
self.register_buffer("freqs", freqs, persistent=False)
def forward(self, xy):
x = xy[:, 0:1]
y = xy[:, 1:2]
f = self.freqs.view(1, self.K, 1, 1)
x_proj = 2 * math.pi * x * f
y_proj = 2 * math.pi * y * f
return torch.cat(
[torch.sin(x_proj), torch.cos(x_proj), torch.sin(y_proj), torch.cos(y_proj)],
dim=1,
)
# =========================================================
# Regressor: Encoder + (xy [+ optional Fourier]) + FNO
# =========================================================
class FNOFieldRegressor_EncoderFNO(nn.Module):
def __init__(
self,
modes=6,
width=24,
blocks=4,
padding=0,
norm="gn",
dropout=0.10,
layerscale=0.10,
geom_ch=8,
use_fourier=False,
fourier_K=2,
fourier_scale=1.0,
dropout_xy=0.05,
):
super().__init__()
self.encoder = GeomEncoder(out_ch=geom_ch, drop=0.10)
self.use_fourier = bool(use_fourier)
self.fourier = FourierFeatures2D(K=fourier_K, scale=fourier_scale) if self.use_fourier else None
self.drop_xy = nn.Dropout2d(dropout_xy) if dropout_xy and dropout_xy > 0 else nn.Identity()
in_ch = geom_ch + 2 + (4 * fourier_K if self.use_fourier else 0)
self.fno = FNOModel2d(
modes=modes,
width=width,
blocks=blocks,
padding=padding,
in_channels=in_ch,
out_channels=2,
norm=norm,
dropout=dropout,
layerscale=layerscale,
)
self._cached_key = None
self._cached_xy = None # (1,2,Ny,Nx)
@staticmethod
def _sanitize_coords(coords: torch.Tensor) -> torch.Tensor:
# accept (B,M,2) or (B,1,M,2)
if coords.dim() == 4:
coords = coords[:, 0, :, :]
return coords
@staticmethod
def _coords_to_xy_grid(coords: torch.Tensor):
"""
coords: (B,M,2) or (M,2)
Build xy grid (1,2,Ny,Nx) using unique sorted x and y.
"""
coords0 = coords[0] if coords.dim() == 3 else coords # (M,2)
x = coords0[:, 0]
y = coords0[:, 1]
xs = torch.unique(x)
ys = torch.unique(y)
xs, _ = torch.sort(xs)
ys, _ = torch.sort(ys)
Ny, Nx = ys.numel(), xs.numel()
yy, xx = torch.meshgrid(ys, xs, indexing="ij")
xy = torch.stack([xx, yy], dim=0).unsqueeze(0) # (1,2,Ny,Nx)
return xy, int(Ny), int(Nx)
def forward(self, x_img: torch.Tensor, coords: torch.Tensor):
B = x_img.shape[0]
device = x_img.device
dtype = x_img.dtype
coords = self._sanitize_coords(coords).to(device=device)
xy, Ny, Nx = self._coords_to_xy_grid(coords)
key = (Ny, Nx, device.type, device.index, str(dtype))
if self._cached_key != key:
self._cached_key = key
self._cached_xy = xy.to(device=device, dtype=dtype)
xy_b = self._cached_xy.expand(B, -1, -1, -1)
xy_b = self.drop_xy(xy_b)
geom_feat = self.encoder(x_img, Ny, Nx)
if self.use_fourier:
ff = self.fourier(xy_b)
inp = torch.cat([geom_feat, xy_b, ff], dim=1)
else:
inp = torch.cat([geom_feat, xy_b], dim=1)
out = self.fno(inp) # (B,2,Ny,Nx)
out = out.permute(0, 2, 3, 1).contiguous().view(B, Ny * Nx, 2)
return out
# =========================================================
# Dataset loader: prefer build_dataset_full; else fallback concat
# =========================================================
def load_full_dataset_and_stats(matpath: str):
"""
For the new dataset we do NOT need norm_stats.
If build_dataset_full exists, it should return (full_ds, {}).
Otherwise we concat train/test from build_datasets.
"""
try:
from getdata import build_dataset_full # optional
full_ds, norm_stats = build_dataset_full(matpath)
if is_main_process():
print("[Dataset] Using build_dataset_full().")
return full_ds, norm_stats if norm_stats is not None else {}
except Exception as e:
if is_main_process():
print("[Dataset INFO] build_dataset_full() not found or failed. Using build_datasets() and concat.")
print(f" Reason: {repr(e)}")
train_ds, test_ds, _ = build_datasets(matpath)
full_ds = ConcatDataset([train_ds, test_ds])
return full_ds, {}
# =========================================================
# Indexed dataset wrapper (for per-sample saving)
# =========================================================
class IndexedDataset(torch.utils.data.Dataset):
"""
returns: (i, x_img, coords, E_true)
i is index within this ds (0..len(ds)-1)
"""
def __init__(self, ds):
self.ds = ds
def __len__(self):
return len(self.ds)
def __getitem__(self, i):
x_img, coords, E_true = self.ds[i]
return i, x_img, coords, E_true
# =========================================================
# Trainer
# =========================================================
class FNOOnly_DDP_Trainer:
def __init__(
self,
model,
device,
batch_size=32,
matpath="metalens_dataset.mat",
lr=2e-4,
weight_decay=3e-4,
num_workers=4,
pin_memory=True,
warmup_epochs=5,
min_lr_ratio=1e-3,
early_stop_patience=30,
grad_clip=1.0,
split_seed=20260303,
test_ratio=0.2,
):
self.device = device
self.matpath = matpath
self.warmup_epochs = int(warmup_epochs)
self.min_lr_ratio = float(min_lr_ratio)
self.early_stop_patience = int(early_stop_patience)
self.grad_clip = float(grad_clip)
self.split_seed = int(split_seed)
self.test_ratio = float(test_ratio)
self.model = model.to(self.device)
self.ddp_model = DDP(
self.model,
device_ids=[self.device.index],
output_device=self.device.index,
find_unused_parameters=False,
broadcast_buffers=False,
)
self.loss_fn = RelL2Loss()
self.optimizer = torch.optim.AdamW(self.ddp_model.parameters(), lr=lr, weight_decay=weight_decay)
self.scheduler = None
self.total_epochs = None
self.file_name = "fno_ddpsafe_final.pth"
self.best_name = "fno_ddpsafe_best.pth"
self.losses = []
self.train_set, self.test_set, self.norm_stats = self.load_dataset_ddpsafe()
self.train_sampler = DistributedSampler(self.train_set, shuffle=True, drop_last=False)
self.test_sampler = DistributedSampler(self.test_set, shuffle=False, drop_last=False)
self.batch_size = batch_size
self.num_workers = num_workers
self.pin_memory = pin_memory
self.train_loader = DataLoader(
self.train_set,
batch_size=batch_size,
sampler=self.train_sampler,
shuffle=False,
drop_last=False,
num_workers=num_workers,
pin_memory=pin_memory,
persistent_workers=(num_workers > 0),
)
self.test_loader = DataLoader(
self.test_set,
batch_size=batch_size,
sampler=self.test_sampler,
shuffle=False,
drop_last=False,
num_workers=num_workers,
pin_memory=pin_memory,
persistent_workers=(num_workers > 0),
)
if is_main_process():
Path("results").mkdir(parents=True, exist_ok=True)
def load_dataset_ddpsafe(self):
full_ds, norm_stats = load_full_dataset_and_stats(self.matpath)
N = len(full_ds)
n_test = int(round(N * self.test_ratio))
n_test = max(1, min(N - 1, n_test))
if is_main_process():
g = torch.Generator()
g.manual_seed(self.split_seed)
perm = torch.randperm(N, generator=g, dtype=torch.int64)
else:
perm = torch.empty(N, dtype=torch.int64)
perm = perm.to(self.device)
broadcast_tensor_(perm, src=0)
perm = perm.cpu()
test_idx = perm[:n_test].tolist()
train_idx = perm[n_test:].tolist()
train_ds = Subset(full_ds, train_idx)
test_ds = Subset(full_ds, test_idx)
if is_main_process():
print(
f"[DDP Split] Full={N}, Train={len(train_ds)}, Test={len(test_ds)}, "
f"seed={self.split_seed}, test_ratio={self.test_ratio}"
)
return train_ds, test_ds, norm_stats
def load_model(self, path):
try:
state = torch.load(path, map_location="cpu", weights_only=True)
except TypeError:
state = torch.load(path, map_location="cpu")
self.model.load_state_dict(state, strict=True)
@staticmethod
def _to_img_tensor(x_img: torch.Tensor) -> torch.Tensor:
# dataset gives (1,512,512); dataloader gives (B,1,512,512) already,
# but keep robust.
if x_img.dim() == 2:
x_img = x_img.unsqueeze(0).unsqueeze(0)
elif x_img.dim() == 3:
x_img = x_img.unsqueeze(0)
if x_img.size(1) != 1:
x_img = x_img[:, :1]
elif x_img.dim() == 4:
if x_img.size(1) != 1:
x_img = x_img[:, :1]
else:
raise ValueError(f"Unexpected x_img dim: {x_img.shape}")
return x_img
@staticmethod
def _sanitize_coords(coords: torch.Tensor) -> torch.Tensor:
# coords may become (B,M,2) from dataloader stacking; ok.
if coords.dim() == 4:
coords = coords[:, 0, :, :]
return coords
@staticmethod
def _sanitize_field(E_true: torch.Tensor) -> torch.Tensor:
# Want (B,M,2)
if E_true.dim() == 2:
E_true = E_true.unsqueeze(0)
if E_true.dim() == 4:
E_true = E_true[:, 0, :, :]
return E_true
def _build_scheduler(self):
assert self.total_epochs is not None
def lr_lambda(epoch: int):
if epoch < self.warmup_epochs:
return float(epoch + 1) / float(self.warmup_epochs)
t = (epoch - self.warmup_epochs) / max(1, (self.total_epochs - self.warmup_epochs))
return self.min_lr_ratio + (1.0 - self.min_lr_ratio) * 0.5 * (1.0 + np.cos(np.pi * t))
self.scheduler = torch.optim.lr_scheduler.LambdaLR(self.optimizer, lr_lambda=lr_lambda)
def get_data_loss(self, x_img, coords, E_true):
E_pred = self.ddp_model(x_img, coords)
return self.loss_fn(E_pred, E_true)
@torch.no_grad()
def test_loss(self):
self.ddp_model.eval()
loss_sum = torch.zeros((), device=self.device)
count = torch.zeros((), device=self.device)
for x_img, coords, E_true in self.test_loader:
x_img = self._to_img_tensor(x_img).to(self.device, non_blocking=True)
coords = self._sanitize_coords(coords).to(self.device, non_blocking=True)
E_true = self._sanitize_field(E_true).to(self.device, non_blocking=True)
loss = self.get_data_loss(x_img, coords, E_true)
bsz = x_img.shape[0]
loss_sum += loss.detach() * bsz
count += bsz
if world_size() > 1:
dist.all_reduce(loss_sum, op=dist.ReduceOp.SUM)
dist.all_reduce(count, op=dist.ReduceOp.SUM)
avg = (loss_sum / (count + 1e-12)).detach()
return float(avg.item())
def train(self, epochs=400):
self.total_epochs = int(epochs)
self._build_scheduler()
start_time = datetime.datetime.now()
best_test_loss = float("inf")
bad = 0
epoch_iter = range(self.total_epochs)
if is_main_process():
epoch_iter = tqdm(epoch_iter, desc="Training (DDP)")
for epoch in epoch_iter:
self.train_sampler.set_epoch(epoch)
self.ddp_model.train()
loss_sum = torch.zeros((), device=self.device)
count = torch.zeros((), device=self.device)
for x_img, coords, E_true in self.train_loader:
x_img = self._to_img_tensor(x_img).to(self.device, non_blocking=True)
coords = self._sanitize_coords(coords).to(self.device, non_blocking=True)
E_true = self._sanitize_field(E_true).to(self.device, non_blocking=True)
self.optimizer.zero_grad(set_to_none=True)
loss = self.get_data_loss(x_img, coords, E_true)
loss.backward()
if self.grad_clip and self.grad_clip > 0:
torch.nn.utils.clip_grad_norm_(self.ddp_model.parameters(), self.grad_clip)
self.optimizer.step()
bsz = x_img.shape[0]
loss_sum += loss.detach() * bsz
count += bsz
if world_size() > 1:
dist.all_reduce(loss_sum, op=dist.ReduceOp.SUM)
dist.all_reduce(count, op=dist.ReduceOp.SUM)
avg_train_loss = float((loss_sum / (count + 1e-12)).item())
avg_test_loss = self.test_loss()
self.scheduler.step()
stop_flag = torch.zeros((), device=self.device, dtype=torch.int32)
if is_main_process():
self.losses.append([epoch, avg_train_loss, avg_test_loss])
lr = self.optimizer.param_groups[0]["lr"]
improved = avg_test_loss < best_test_loss
if improved:
best_test_loss = avg_test_loss
bad = 0
torch.save(self.model.state_dict(), self.best_name)
else:
bad += 1
print(
f"Epoch {epoch} | train_loss={avg_train_loss:.6e} | "
f"test_loss={avg_test_loss:.6e} | best={best_test_loss:.6e} | "
f"bad={bad}/{self.early_stop_patience} | lr={lr:.3e}"
)
if bad >= self.early_stop_patience:
print(f"[Early stop] epoch={epoch}, best_test_loss={best_test_loss:.6e}")
stop_flag.fill_(1)
if world_size() > 1:
dist.broadcast(stop_flag, src=0)
if int(stop_flag.item()) == 1:
break
if world_size() > 1:
dist.barrier()
if is_main_process():
torch.save(self.model.state_dict(), self.file_name)
print("Best model saved to:", self.best_name)
print("Final model saved to:", self.file_name)
print("Training Time:", (datetime.datetime.now() - start_time).total_seconds(), "s")
# =========================================================
# Save one example (NO denorm for new dataset)
# =========================================================
def save_example_pred_true_mat_and_plot(
self,
idx: int = 0,
use_best: bool = True,
mat_save: str = "results/fno_ddpsafe_pred_true_example.mat",
fig_save: str = "results/fno_ddpsafe_field_plot_example.png",
interp_method: str = "cubic",
):
if not is_main_process():
return
if use_best and Path(self.best_name).exists():
self.load_model(self.best_name)
self.ddp_model.eval()
x_img, coords, E_true = self.test_set[idx]
if torch.is_tensor(coords) and coords.dim() == 3:
coords = coords[0, :]
coords_np = coords.detach().cpu().numpy()
if torch.is_tensor(E_true) and E_true.dim() == 3:
E_true = E_true[0, :, :]
E_true_np = E_true.detach().cpu().numpy()[None, ...] # (1,M,2)
with torch.no_grad():
x_img_t = self._to_img_tensor(x_img).to(self.device)
coords_t = self._sanitize_coords(coords.unsqueeze(0) if coords.dim() == 2 else coords).to(self.device)
E_pred_np = self.ddp_model(x_img_t, coords_t).detach().cpu().numpy() # (1,M,2)
Ere_pred, Eim_pred = E_pred_np[:, :, 0], E_pred_np[:, :, 1]
Ere_true, Eim_true = E_true_np[:, :, 0], E_true_np[:, :, 1]
savemat(
mat_save,
{
"Ere_pred": Ere_pred.astype(np.float32),
"Eim_pred": Eim_pred.astype(np.float32),
"Ere_true": Ere_true.astype(np.float32),
"Eim_true": Eim_true.astype(np.float32),
"coords_xy": coords_np.astype(np.float32),
},
)
print("Saved example mat:", mat_save)
# plot
X = coords_np[:, 0]
Y = coords_np[:, 1]
Xi, Yi = np.meshgrid(np.unique(X), np.unique(Y))
def interp(z):
Zi = griddata(points=(X, Y), values=z, xi=(Xi, Yi), method=interp_method)
from scipy.ndimage import gaussian_filter
return gaussian_filter(Zi, sigma=0.8)
re_err = np.abs(Ere_pred[0] - Ere_true[0])
im_err = np.abs(Eim_pred[0] - Eim_true[0])
fields = [
(interp(Ere_pred[0]), "E_real_pred"),
(interp(Ere_true[0]), "E_real_true"),
(interp(re_err), "E_real_error"),
(interp(Eim_pred[0]), "E_imag_pred"),
(interp(Eim_true[0]), "E_imag_true"),
(interp(im_err), "E_imag_error"),
]
fig, axes = plt.subplots(2, 3, figsize=(18, 9), sharex=True, sharey=True)
for ax, (Zi, title) in zip(axes.ravel(), fields):
pcm = ax.pcolormesh(Xi, Yi, Zi, shading="gouraud")
ax.set_title(title)
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.set_aspect("equal")
fig.colorbar(pcm, ax=ax, shrink=0.8)
plt.tight_layout()
plt.savefig(fig_save, dpi=200)
plt.close(fig)
print("Saved example plot:", fig_save)
# =========================================================
# Infer ALL train/test, save errors + ALL fields + coords
# =========================================================
@torch.no_grad()
def infer_all_and_save_errors_and_fields(
self,
use_best: bool = True,
save_path: str = "results/fno_ddpsafe_all_errors_and_fields.mat",
compute_mse_mae: bool = True,
save_coords_per_sample: bool = False,
dtype_save=np.float32,
):
if use_best and Path(self.best_name).exists():
if is_main_process():
print(f"[Infer] Loading best model: {self.best_name}")
self.load_model(self.best_name)
self.ddp_model.eval()
common_coords = None
if is_main_process():
_, coords0, _ = self.train_set[0]
if torch.is_tensor(coords0) and coords0.dim() == 3:
coords0 = coords0[0, :]
common_coords = coords0.detach().cpu().numpy().astype(dtype_save)
def build_infer_loader(ds):
ds_idx = IndexedDataset(ds)
sampler = DistributedSampler(ds_idx, shuffle=False, drop_last=False) # will pad -> we dedup by idx later
loader = DataLoader(
ds_idx,
batch_size=self.batch_size,
sampler=sampler,
shuffle=False,
drop_last=False,
num_workers=self.num_workers,
pin_memory=self.pin_memory,
persistent_workers=(self.num_workers > 0),
)
return loader, len(ds_idx)
def run_one_split(tag: str, ds):
loader, N = build_infer_loader(ds)
local_idx, local_rel = [], []
local_mse, local_mae = [], []
local_pred, local_true = [], []
local_coords = []
for idx, x_img, coords, E_true in loader:
x_img = self._to_img_tensor(x_img).to(self.device, non_blocking=True)
coords = self._sanitize_coords(coords).to(self.device, non_blocking=True)
E_true = self._sanitize_field(E_true).to(self.device, non_blocking=True)
E_pred = self.ddp_model(x_img, coords) # (B,M,2)
rel = rel_l2_per_sample(E_pred, E_true).detach().cpu().numpy()
if compute_mse_mae:
diff = (E_pred - E_true).detach()
mse = torch.mean(diff * diff, dim=(1, 2)).cpu().numpy()
mae = torch.mean(torch.abs(diff), dim=(1, 2)).cpu().numpy()
local_idx.append(idx.cpu().numpy().astype(np.int64))
local_rel.append(rel.astype(np.float64))
if compute_mse_mae:
local_mse.append(mse.astype(np.float64))
local_mae.append(mae.astype(np.float64))
local_pred.append(E_pred.detach().cpu().numpy().astype(dtype_save))
local_true.append(E_true.detach().cpu().numpy().astype(dtype_save))
if save_coords_per_sample:
local_coords.append(coords.detach().cpu().numpy().astype(dtype_save))
if len(local_idx) == 0:
pack = {
"idx": np.zeros((0,), np.int64),
"rel": np.zeros((0,), np.float64),
"mse": np.zeros((0,), np.float64) if compute_mse_mae else None,
"mae": np.zeros((0,), np.float64) if compute_mse_mae else None,
"pred": np.zeros((0, 0, 2), dtype_save),
"true": np.zeros((0, 0, 2), dtype_save),
"coords": np.zeros((0, 0, 2), dtype_save) if save_coords_per_sample else None,
"N": N,
}
else:
pack = {
"idx": np.concatenate(local_idx, axis=0),
"rel": np.concatenate(local_rel, axis=0),
"mse": np.concatenate(local_mse, axis=0) if compute_mse_mae else None,
"mae": np.concatenate(local_mae, axis=0) if compute_mse_mae else None,
"pred": np.concatenate(local_pred, axis=0),
"true": np.concatenate(local_true, axis=0),
"coords": np.concatenate(local_coords, axis=0) if save_coords_per_sample else None,
"N": N,
}
packs = all_gather_object(pack)
if not is_main_process():
return None
idx_all = np.concatenate([p["idx"] for p in packs], axis=0)
rel_all = np.concatenate([p["rel"] for p in packs], axis=0)
pred_all = np.concatenate([p["pred"] for p in packs], axis=0)
true_all = np.concatenate([p["true"] for p in packs], axis=0)
if compute_mse_mae:
mse_all = np.concatenate([p["mse"] for p in packs], axis=0)
mae_all = np.concatenate([p["mae"] for p in packs], axis=0)
else:
mse_all, mae_all = None, None
if save_coords_per_sample:
coords_all = np.concatenate([p["coords"] for p in packs if p["coords"] is not None], axis=0)
else:
coords_all = None
order = np.argsort(idx_all)
idx_all = idx_all[order]
rel_all = rel_all[order]
pred_all = pred_all[order]
true_all = true_all[order]
if compute_mse_mae:
mse_all = mse_all[order]
mae_all = mae_all[order]
if save_coords_per_sample:
coords_all = coords_all[order]
# dedup padded indices from DistributedSampler
_, first_pos = np.unique(idx_all, return_index=True)
idx_all = idx_all[first_pos]
rel_all = rel_all[first_pos]
pred_all = pred_all[first_pos]
true_all = true_all[first_pos]
if compute_mse_mae:
mse_all = mse_all[first_pos]
mae_all = mae_all[first_pos]
if save_coords_per_sample:
coords_all = coords_all[first_pos]
if idx_all.shape[0] != N:
missing = set(range(N)) - set(idx_all.tolist())
raise RuntimeError(
f"[{tag}] after dedup: got {idx_all.shape[0]} unique != N={N}, missing={len(missing)}"
)
out = {
f"{tag}_idx": idx_all.astype(np.int64),
f"{tag}_relL2": rel_all.astype(np.float64),
f"{tag}_E_pred": pred_all.astype(dtype_save),
f"{tag}_E_true": true_all.astype(dtype_save),
f"{tag}_relL2_mean": float(np.mean(rel_all)),
f"{tag}_relL2_std": float(np.std(rel_all)),
}
if compute_mse_mae:
out[f"{tag}_mse"] = mse_all.astype(np.float64)
out[f"{tag}_mae"] = mae_all.astype(np.float64)
out[f"{tag}_mse_mean"] = float(np.mean(mse_all))
out[f"{tag}_mae_mean"] = float(np.mean(mae_all))
if save_coords_per_sample:
out[f"{tag}_coords_xy_all"] = coords_all.astype(dtype_save)
return out
train_out = run_one_split("train", self.train_set)
test_out = run_one_split("test", self.test_set)
if is_main_process():
mdict = {}
mdict.update(train_out)
mdict.update(test_out)
if common_coords is not None:
mdict["coords_xy"] = common_coords # (M,2)
mdict["meta_matpath"] = self.matpath
mdict["meta_best_ckpt"] = self.best_name if use_best else ""
mdict["meta_world_size"] = int(world_size())
mdict["meta_compute_mse_mae"] = int(compute_mse_mae)
mdict["meta_save_coords_per_sample"] = int(save_coords_per_sample)
mdict["meta_dtype_save"] = str(dtype_save)
savemat(save_path, mdict)
print(f"[Infer] Saved ALL errors + ALL fields to: {save_path}")
print(f"[Infer] train_relL2_mean={mdict['train_relL2_mean']:.6e} | test_relL2_mean={mdict['test_relL2_mean']:.6e}")
# =========================================================
# Main
# =========================================================
def main():
device, local_rank = ddp_setup()
torch.backends.cudnn.benchmark = True
try:
# -------- config --------
epochs = 1000
batch_size = 32
# model
modes = 6
width = 24
blocks = 4
padding = 0
dropout = 0.10
layerscale = 0.10
geom_ch = 8
use_fourier = False
fourier_K = 2
fourier_scale = 1.0
dropout_xy = 0.05
# optim
lr = 2e-4
weight_decay = 3e-4
trainer = FNOOnly_DDP_Trainer(
model=FNOFieldRegressor_EncoderFNO(
modes=modes,
width=width,
blocks=blocks,
padding=padding,
norm="gn",
dropout=dropout,
layerscale=layerscale,
geom_ch=geom_ch,
use_fourier=use_fourier,
fourier_K=fourier_K,
fourier_scale=fourier_scale,
dropout_xy=dropout_xy,
),
device=device,
batch_size=batch_size,
matpath="metalens_dataset.mat", # ✅ NEW DATASET
lr=lr,
weight_decay=weight_decay,
num_workers=4,
pin_memory=True,
warmup_epochs=5,
min_lr_ratio=1e-3,
early_stop_patience=30,
grad_clip=1.0,
split_seed=20260303,
test_ratio=0.2,
)
trainer.train(epochs=epochs)
# ✅ 1) Save one example (NO denorm)
trainer.save_example_pred_true_mat_and_plot(
idx=0,
use_best=True,
mat_save="results/fno_ddpsafe_pred_true_example.mat",
fig_save="results/fno_ddpsafe_field_plot_example.png",
)
# ✅ 2) Save ALL train/test: errors + E_pred/E_true (+ coords)
trainer.infer_all_and_save_errors_and_fields(
use_best=True,
save_path="results/fno_ddpsafe_all_errors_and_fields.mat",
compute_mse_mae=True,
save_coords_per_sample=False, # coords are shared
dtype_save=np.float32,
)
finally:
ddp_cleanup()
if __name__ == "__main__":
main()