#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ dispnet.v2.3.py 残差保持 1D CNN 版本: - 无 VAE / KL / NLL - 不输入震中距、period 等额外条件 - 只输入 waveform - 模型内部保存训练集平均频散 reference_disp - 网络预测 delta_v = v - reference_disp,再还原得到 disp_mu - loss 额外加入 batch 内 pairwise 差异保持和方差保持,抑制回归均值化 设计目标: v2.2 的点误差较低,但反演结果过平滑。v2.3 不再只奖励逐点接近标签, 而是显式要求预测频散保留样本间的横向差异和速度分布宽度。 """ import os import random import time from dataclasses import dataclass from typing import Dict, Optional import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import DataLoader def set_seed(seed: int = 42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) def exists(x): return x is not None def default_device() -> str: if torch.cuda.is_available(): return "cuda" if torch.backends.mps.is_available(): return "mps" return "cpu" def _gn_groups(channels: int, max_groups: int = 8) -> int: for g in range(min(max_groups, channels), 0, -1): if channels % g == 0: return g return 1 class ConvGNAct(nn.Module): def __init__(self, in_ch: int, out_ch: int, kernel_size: int = 7, stride: int = 1, dilation: int = 1): super().__init__() padding = dilation * (kernel_size - 1) // 2 self.net = nn.Sequential( nn.Conv1d( in_ch, out_ch, kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation, bias=False, ), nn.GroupNorm(_gn_groups(out_ch), out_ch), nn.SiLU(inplace=True), ) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.net(x) class ResidualBlock(nn.Module): def __init__(self, in_ch: int, out_ch: int, stride: int = 1, kernel_size: int = 7, dilation: int = 1): super().__init__() self.conv1 = ConvGNAct(in_ch, out_ch, kernel_size=kernel_size, stride=stride, dilation=dilation) self.conv2 = nn.Sequential( nn.Conv1d( out_ch, out_ch, kernel_size=kernel_size, stride=1, padding=dilation * (kernel_size - 1) // 2, dilation=dilation, bias=False, ), nn.GroupNorm(_gn_groups(out_ch), out_ch), ) if stride != 1 or in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv1d(in_ch, out_ch, kernel_size=1, stride=stride, bias=False), nn.GroupNorm(_gn_groups(out_ch), out_ch), ) else: self.shortcut = nn.Identity() self.act = nn.SiLU(inplace=True) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.act(self.conv2(self.conv1(x)) + self.shortcut(x)) class MultiScaleStem(nn.Module): def __init__(self, out_ch: int = 24): super().__init__() branch_ch = out_ch // 3 rest = out_ch - branch_ch * 2 self.b1 = ConvGNAct(1, branch_ch, kernel_size=7, stride=1) self.b2 = ConvGNAct(1, branch_ch, kernel_size=15, stride=1) self.b3 = ConvGNAct(1, rest, kernel_size=31, stride=1) self.mix = ConvGNAct(out_ch, out_ch, kernel_size=1, stride=1) def forward(self, x: torch.Tensor) -> torch.Tensor: x = torch.cat([self.b1(x), self.b2(x), self.b3(x)], dim=1) return self.mix(x) class DispNetCNNV23(nn.Module): def __init__( self, input_length: int = 1536, base_channels: int = 24, output_dim: int = 49, dropout: float = 0.1, reference_disp: Optional[torch.Tensor] = None, ): super().__init__() self.input_length = input_length self.output_dim = output_dim if reference_disp is None: reference_disp = torch.zeros(output_dim, dtype=torch.float32) reference_disp = torch.as_tensor(reference_disp, dtype=torch.float32).reshape(output_dim) self.register_buffer("reference_disp", reference_disp) ch1 = base_channels ch2 = base_channels * 2 ch3 = base_channels * 3 ch4 = base_channels * 4 ch5 = base_channels * 6 self.stem = MultiScaleStem(ch1) self.backbone = nn.Sequential( ResidualBlock(ch1, ch1, stride=1, kernel_size=7), ResidualBlock(ch1, ch2, stride=2, kernel_size=9), ResidualBlock(ch2, ch2, stride=1, kernel_size=7, dilation=2), ResidualBlock(ch2, ch3, stride=2, kernel_size=9), ResidualBlock(ch3, ch3, stride=1, kernel_size=7, dilation=2), ResidualBlock(ch3, ch4, stride=2, kernel_size=9), ResidualBlock(ch4, ch4, stride=1, kernel_size=7, dilation=2), ResidualBlock(ch4, ch5, stride=2, kernel_size=9), ResidualBlock(ch5, ch5, stride=1, kernel_size=7, dilation=2), ResidualBlock(ch5, ch5, stride=2, kernel_size=7), ) feat_dim = ch5 * 2 hidden = max(base_channels * 8, 128) self.head = nn.Sequential( nn.Linear(feat_dim, hidden), nn.LayerNorm(hidden), nn.SiLU(inplace=True), nn.Dropout(dropout), nn.Linear(hidden, hidden), nn.SiLU(inplace=True), nn.Dropout(dropout), ) self.to_disp_delta = nn.Linear(hidden, output_dim) self.to_certainty_logits = nn.Linear(hidden, output_dim) nn.init.normal_(self.to_disp_delta.weight, mean=0.0, std=1e-3) nn.init.zeros_(self.to_disp_delta.bias) def set_reference_disp(self, reference_disp: torch.Tensor): reference_disp = torch.as_tensor(reference_disp, dtype=self.reference_disp.dtype, device=self.reference_disp.device) reference_disp = reference_disp.reshape(self.output_dim) self.reference_disp.copy_(reference_disp) @staticmethod def normalize_waveform(waveform: torch.Tensor, eps: float = 1e-6) -> torch.Tensor: mean = waveform.mean(dim=-1, keepdim=True) std = waveform.std(dim=-1, keepdim=True).clamp_min(eps) return (waveform - mean) / std def forward(self, waveform: torch.Tensor) -> Dict[str, torch.Tensor]: if waveform.ndim == 2: waveform = waveform.unsqueeze(1) elif waveform.ndim == 3 and waveform.size(1) != 1: raise ValueError(f"Expected waveform shape [B, T] or [B, 1, T], got {tuple(waveform.shape)}") waveform = self.normalize_waveform(waveform) h = self.backbone(self.stem(waveform)) avg_pool = h.mean(dim=-1) max_pool = h.amax(dim=-1) feat = torch.cat([avg_pool, max_pool], dim=1) feat = self.head(feat) disp_delta = self.to_disp_delta(feat) disp_mu = self.reference_disp.view(1, -1) + disp_delta certainty_logits = self.to_certainty_logits(feat) return { "disp_mu": disp_mu, "disp_delta": disp_delta, "reference_disp": self.reference_disp.view(1, -1).expand_as(disp_mu), "certainty_logits": certainty_logits, "certainty": torch.sigmoid(certainty_logits), } def masked_huber_loss(pred, target, mask, delta: float = 0.05, eps: float = 1e-8): abs_err = (pred - target).abs() huber = torch.where(abs_err <= delta, 0.5 * abs_err.pow(2) / delta, abs_err - 0.5 * delta) huber = huber * mask valid_count = mask.sum(dim=1) sample_valid = (valid_count > 0).float() per_sample = huber.sum(dim=1) / valid_count.clamp_min(eps) per_sample = per_sample * sample_valid return per_sample.sum() / sample_valid.sum().clamp_min(eps) def masked_slope_loss(pred, target, mask, eps: float = 1e-8): pair_mask = mask[:, 1:] * mask[:, :-1] pred_slope = pred[:, 1:] - pred[:, :-1] target_slope = target[:, 1:] - target[:, :-1] per_elem = F.smooth_l1_loss(pred_slope, target_slope, reduction="none") * pair_mask valid_count = pair_mask.sum(dim=1) sample_valid = (valid_count > 0).float() per_sample = per_elem.sum(dim=1) / valid_count.clamp_min(eps) per_sample = per_sample * sample_valid return per_sample.sum() / sample_valid.sum().clamp_min(eps) def masked_curvature_loss(pred, target, mask, eps: float = 1e-8): if pred.size(1) < 3: return pred.new_tensor(0.0) triplet_mask = mask[:, 2:] * mask[:, 1:-1] * mask[:, :-2] pred_d2 = pred[:, 2:] - 2.0 * pred[:, 1:-1] + pred[:, :-2] target_d2 = target[:, 2:] - 2.0 * target[:, 1:-1] + target[:, :-2] per_elem = F.smooth_l1_loss(pred_d2, target_d2, reduction="none") * triplet_mask valid_count = triplet_mask.sum(dim=1) sample_valid = (valid_count > 0).float() per_sample = per_elem.sum(dim=1) / valid_count.clamp_min(eps) per_sample = per_sample * sample_valid return per_sample.sum() / sample_valid.sum().clamp_min(eps) def masked_pairwise_delta_loss(pred_delta, target_delta, mask, delta: float = 0.05, eps: float = 1e-8): if pred_delta.size(0) < 2: return pred_delta.new_tensor(0.0) pred_shift = torch.roll(pred_delta, shifts=1, dims=0) target_shift = torch.roll(target_delta, shifts=1, dims=0) mask_shift = torch.roll(mask, shifts=1, dims=0) pair_mask = mask * mask_shift if pair_mask.sum() <= 0: return pred_delta.new_tensor(0.0) pred_pair = pred_delta - pred_shift target_pair = target_delta - target_shift abs_err = (pred_pair - target_pair).abs() huber = torch.where(abs_err <= delta, 0.5 * abs_err.pow(2) / delta, abs_err - 0.5 * delta) return (huber * pair_mask).sum() / pair_mask.sum().clamp_min(eps) def masked_period_std_loss(pred_delta, target_delta, mask, eps: float = 1e-8): valid_count = mask.sum(dim=0) usable = valid_count >= 2 if not torch.any(usable): return pred_delta.new_tensor(0.0) count = valid_count.clamp_min(1.0) pred_mean = (pred_delta * mask).sum(dim=0) / count target_mean = (target_delta * mask).sum(dim=0) / count pred_var = (((pred_delta - pred_mean.view(1, -1)) * mask).pow(2).sum(dim=0) / count).clamp_min(0.0) target_var = (((target_delta - target_mean.view(1, -1)) * mask).pow(2).sum(dim=0) / count).clamp_min(0.0) pred_std = torch.sqrt(pred_var + eps) target_std = torch.sqrt(target_var + eps) return (pred_std[usable] - target_std[usable]).abs().mean() def certainty_bce_loss(certainty_logits, target_mask, pos_weight=None): return F.binary_cross_entropy_with_logits( certainty_logits, target_mask, pos_weight=pos_weight, reduction="mean", ) def compute_total_loss( outputs: Dict[str, torch.Tensor], target_disp: torch.Tensor, target_mask: torch.Tensor, lambda_certainty: float = 0.2, lambda_slope: float = 0.25, lambda_curvature: float = 0.05, lambda_pairwise: float = 0.35, lambda_std: float = 0.75, huber_delta: float = 0.05, certainty_pos_weight: Optional[torch.Tensor] = None, ): pred = outputs["disp_mu"] pred_delta = outputs["disp_delta"] target_delta = target_disp - outputs["reference_disp"] rec_loss = masked_huber_loss(pred, target_disp, target_mask, delta=huber_delta) slope_loss = masked_slope_loss(pred_delta, target_delta, target_mask) curvature_loss = masked_curvature_loss(pred_delta, target_delta, target_mask) pairwise_loss = masked_pairwise_delta_loss(pred_delta, target_delta, target_mask, delta=huber_delta) std_loss = masked_period_std_loss(pred_delta, target_delta, target_mask) certainty_loss = certainty_bce_loss(outputs["certainty_logits"], target_mask, certainty_pos_weight) total = ( rec_loss + lambda_certainty * certainty_loss + lambda_slope * slope_loss + lambda_curvature * curvature_loss + lambda_pairwise * pairwise_loss + lambda_std * std_loss ) return { "loss": total, "rec_loss": rec_loss, "certainty_loss": certainty_loss, "slope_loss": slope_loss, "curvature_loss": curvature_loss, "pairwise_loss": pairwise_loss, "std_loss": std_loss, } @torch.no_grad() def masked_mae(pred, target, mask, eps: float = 1e-8): err = (pred - target).abs() * mask return err.sum() / mask.sum().clamp_min(eps) @torch.no_grad() def masked_rmse(pred, target, mask, eps: float = 1e-8): err2 = (pred - target).pow(2) * mask return torch.sqrt(err2.sum() / mask.sum().clamp_min(eps)) @torch.no_grad() def certainty_f1_from_logits(certainty_logits, target_mask, threshold: float = 0.5, eps: float = 1e-8): pred = (torch.sigmoid(certainty_logits) > threshold).float() tp = (pred * target_mask).sum() fp = (pred * (1.0 - target_mask)).sum() fn = ((1.0 - pred) * target_mask).sum() precision = tp / (tp + fp + eps) recall = tp / (tp + fn + eps) return 2.0 * precision * recall / (precision + recall + eps) def move_batch_to_device(batch: Dict[str, torch.Tensor], device: torch.device): return {k: (v.to(device, non_blocking=True) if torch.is_tensor(v) else v) for k, v in batch.items()} def unwrap_waveform(waveform: torch.Tensor) -> torch.Tensor: if waveform.ndim == 3 and waveform.size(1) == 1: return waveform.squeeze(1) return waveform def run_one_epoch( model: nn.Module, loader: DataLoader, device: torch.device, optimizer: Optional[torch.optim.Optimizer] = None, scaler: Optional[torch.cuda.amp.GradScaler] = None, use_amp: bool = True, grad_clip: Optional[float] = 1.0, lambda_certainty: float = 0.2, lambda_slope: float = 0.25, lambda_curvature: float = 0.05, lambda_pairwise: float = 0.35, lambda_std: float = 0.75, huber_delta: float = 0.05, certainty_pos_weight: Optional[torch.Tensor] = None, ): is_train = optimizer is not None model.train(is_train) totals = { "loss": 0.0, "rec_loss": 0.0, "certainty_loss": 0.0, "slope_loss": 0.0, "curvature_loss": 0.0, "pairwise_loss": 0.0, "std_loss": 0.0, "mae": 0.0, "rmse": 0.0, "certainty_f1": 0.0, } n_batches = 0 for batch in loader: batch = move_batch_to_device(batch, device) waveform = unwrap_waveform(batch["waveform"].float()) disp = batch["disp"].float() mask = batch["mask"].float() if is_train: optimizer.zero_grad(set_to_none=True) with torch.set_grad_enabled(is_train): with torch.cuda.amp.autocast(enabled=(use_amp and device.type == "cuda")): outputs = model(waveform) loss_dict = compute_total_loss( outputs=outputs, target_disp=disp, target_mask=mask, lambda_certainty=lambda_certainty, lambda_slope=lambda_slope, lambda_curvature=lambda_curvature, lambda_pairwise=lambda_pairwise, lambda_std=lambda_std, huber_delta=huber_delta, certainty_pos_weight=certainty_pos_weight, ) loss = loss_dict["loss"] if is_train: if use_amp and device.type == "cuda": scaler.scale(loss).backward() if exists(grad_clip): scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) scaler.step(optimizer) scaler.update() else: loss.backward() if exists(grad_clip): torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() with torch.no_grad(): totals["mae"] += masked_mae(outputs["disp_mu"], disp, mask).item() totals["rmse"] += masked_rmse(outputs["disp_mu"], disp, mask).item() totals["certainty_f1"] += certainty_f1_from_logits(outputs["certainty_logits"], mask).item() for key in ["loss", "rec_loss", "certainty_loss", "slope_loss", "curvature_loss", "pairwise_loss", "std_loss"]: totals[key] += loss_dict[key].item() n_batches += 1 denom = max(n_batches, 1) return {k: v / denom for k, v in totals.items()} @torch.no_grad() def estimate_reference_disp(loader: DataLoader, output_dim: int, device: torch.device): sums = torch.zeros(output_dim, device=device) counts = torch.zeros(output_dim, device=device) period_sum = torch.zeros(output_dim, device=device) for batch in loader: disp = batch["disp"].to(device).float() mask = batch["mask"].to(device).float() periods = batch["periods"].to(device).float() sums += (disp * mask).sum(dim=0) period_sum += (periods * mask).sum(dim=0) counts += mask.sum(dim=0) reference_disp = sums / counts.clamp_min(1.0) reference_disp = torch.where(counts > 0, reference_disp, torch.zeros_like(reference_disp)) reference_periods = period_sum / counts.clamp_min(1.0) return reference_disp.detach().cpu(), reference_periods.detach().cpu(), counts.detach().cpu() @dataclass class TrainConfig: h5_path: str = "data/ncf_disp_dataset_with_disp_image.h5" save_dir: str = "ckpt_large/checkpoints_dispnet_v2.3_residual_cnn" waveform_length: int = 1536 batch_size: int = 32 num_workers: int = 4 base_channels: int = 24 output_dim: int = 49 dropout: float = 0.1 epochs: int = 80 lr: float = 2e-4 weight_decay: float = 1e-4 lambda_certainty: float = 0.2 lambda_slope: float = 0.25 lambda_curvature: float = 0.05 lambda_pairwise: float = 0.35 lambda_std: float = 0.75 huber_delta: float = 0.05 grad_clip: float = 1.0 use_amp: bool = True seed: int = 42 min_lr: float = 1e-6 use_certainty_pos_weight: bool = False device: str = default_device() def save_checkpoint(path, model, optimizer, scheduler, epoch, best_val_loss, cfg: TrainConfig): torch.save( { "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict() if scheduler is not None else None, "epoch": epoch, "best_val_loss": best_val_loss, "config": cfg.__dict__, "model_name": "DispNetCNNV23", "reference_disp": model.reference_disp.detach().cpu(), }, path, ) def main(): from utils.dispdataset1d import build_dataloader cfg = TrainConfig() os.makedirs(cfg.save_dir, exist_ok=True) set_seed(cfg.seed) device = torch.device(cfg.device) train_loader = build_dataloader( h5_path=cfg.h5_path, split="train", batch_size=cfg.batch_size, num_workers=cfg.num_workers, waveform_length=cfg.waveform_length, random_ncf=True, pin_memory=(device.type == "cuda"), drop_last=False, seed=cfg.seed, ) val_loader = build_dataloader( h5_path=cfg.h5_path, split="test", batch_size=cfg.batch_size, num_workers=cfg.num_workers, waveform_length=cfg.waveform_length, random_ncf=False, pin_memory=(device.type == "cuda"), drop_last=False, seed=cfg.seed, ) print("[Info] estimating train-set reference dispersion...") reference_disp, reference_periods, reference_counts = estimate_reference_disp( train_loader, output_dim=cfg.output_dim, device=device, ) print( f"[Info] reference dispersion: " f"valid_periods={(reference_counts > 0).sum().item()}/{cfg.output_dim}, " f"v_min={reference_disp[reference_counts > 0].min().item():.4f}, " f"v_max={reference_disp[reference_counts > 0].max().item():.4f}" ) model = DispNetCNNV23( input_length=cfg.waveform_length, base_channels=cfg.base_channels, output_dim=cfg.output_dim, dropout=cfg.dropout, reference_disp=reference_disp, ).to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=cfg.lr, weight_decay=cfg.weight_decay) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=cfg.epochs, eta_min=cfg.min_lr) scaler = torch.cuda.amp.GradScaler(enabled=(cfg.use_amp and device.type == "cuda")) certainty_pos_weight = None if cfg.use_certainty_pos_weight: certainty_pos_weight = torch.tensor([2.0], device=device) best_val_loss = float("inf") print("========== DispNet v2.3 Residual CNN Training Start ==========") print(f"device : {device}") print(f"params : {sum(p.numel() for p in model.parameters())}") print(f"train batches : {len(train_loader)}") print(f"val batches : {len(val_loader)}") print(f"save_dir : {cfg.save_dir}") for epoch in range(1, cfg.epochs + 1): t0 = time.time() train_stats = run_one_epoch( model=model, loader=train_loader, device=device, optimizer=optimizer, scaler=scaler, use_amp=cfg.use_amp, grad_clip=cfg.grad_clip, lambda_certainty=cfg.lambda_certainty, lambda_slope=cfg.lambda_slope, lambda_curvature=cfg.lambda_curvature, lambda_pairwise=cfg.lambda_pairwise, lambda_std=cfg.lambda_std, huber_delta=cfg.huber_delta, certainty_pos_weight=certainty_pos_weight, ) val_stats = run_one_epoch( model=model, loader=val_loader, device=device, optimizer=None, scaler=None, use_amp=cfg.use_amp, grad_clip=None, lambda_certainty=cfg.lambda_certainty, lambda_slope=cfg.lambda_slope, lambda_curvature=cfg.lambda_curvature, lambda_pairwise=cfg.lambda_pairwise, lambda_std=cfg.lambda_std, huber_delta=cfg.huber_delta, certainty_pos_weight=certainty_pos_weight, ) scheduler.step() dt = time.time() - t0 lr_now = optimizer.param_groups[0]["lr"] print( f"[Epoch {epoch:03d}/{cfg.epochs:03d}] " f"time={dt:.1f}s lr={lr_now:.2e} | " f"train: loss={train_stats['loss']:.5f}, rec={train_stats['rec_loss']:.5f}, " f"cert={train_stats['certainty_loss']:.5f}, slope={train_stats['slope_loss']:.5f}, " f"curv={train_stats['curvature_loss']:.5f}, pair={train_stats['pairwise_loss']:.5f}, " f"std={train_stats['std_loss']:.5f}, mae={train_stats['mae']:.5f}, " f"rmse={train_stats['rmse']:.5f}, cert_f1={train_stats['certainty_f1']:.5f} | " f"val: loss={val_stats['loss']:.5f}, rec={val_stats['rec_loss']:.5f}, " f"cert={val_stats['certainty_loss']:.5f}, slope={val_stats['slope_loss']:.5f}, " f"curv={val_stats['curvature_loss']:.5f}, pair={val_stats['pairwise_loss']:.5f}, " f"std={val_stats['std_loss']:.5f}, mae={val_stats['mae']:.5f}, " f"rmse={val_stats['rmse']:.5f}, cert_f1={val_stats['certainty_f1']:.5f}" ) latest_path = os.path.join(cfg.save_dir, "latest.pt") save_checkpoint(latest_path, model, optimizer, scheduler, epoch, best_val_loss, cfg) if val_stats["loss"] < best_val_loss: best_val_loss = val_stats["loss"] best_path = os.path.join(cfg.save_dir, "best.pt") save_checkpoint(best_path, model, optimizer, scheduler, epoch, best_val_loss, cfg) print(f" -> Best model saved to {best_path}") print("========== DispNet v2.3 Residual CNN Training Done ==========") print(f"Best val loss: {best_val_loss:.6f}") if __name__ == "__main__": main()