| """WiSER Phase trainer for CIR task (Phase 1: restore CIR head, Phase 3: refine CIR). |
| |
| Uses JointRadiomapCIRModel + CsiSetTrainer (from WiSER CIR engine) for loss + matching. |
| Minimal training loop: DDP + bf16 autocast + cosine schedule + best/last ckpt. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import json |
| import math |
| import os |
| import platform |
| import sys |
| import time |
| from contextlib import nullcontext |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| import torch |
| import torch.distributed as dist |
| from torch.nn.parallel import DistributedDataParallel as DDP |
| from torch.utils.data import DataLoader |
| from torch.utils.data.distributed import DistributedSampler |
|
|
| ROOT = Path(__file__).resolve().parent.parent |
| sys.path.insert(0, str(ROOT)) |
|
|
| from wiser.config import ( |
| ModelConfig as SharedModelConfig, |
| TrainConfig, |
| reject_radiomap_artifacts, |
| ) |
| from wiser.data.collate import triple_collate |
| from wiser.data.csi_path_targets import MergedPathTargetConfig |
| from wiser.data.dataset import MultiSceneTripleDataset |
| from wiser.engine.matching import hungarian_match_batch, MatcherConfig |
| from wiser.engine.trainer import CsiSetTrainer, TrainerConfig |
| from wiser.utils.metrics import compute_metric_bundle |
| from wiser.alt_models import JointRadiomapCIRModel, FreezeConfig |
| from wiser.alt_engine.checkpoint import ( |
| detect_csi_head_arch_from_ckpt, |
| load_warm_start_ckpt, |
| load_phase_ckpt_into_model, |
| save_phase_ckpt, |
| ) |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| p = argparse.ArgumentParser() |
| p.add_argument("--manifest", required=True, |
| help="CIR triples manifest (e.g. tiny100_5k_4tx_balanced_manifest.json)") |
| p.add_argument("--val-manifest", default=None, |
| help="Val manifest (e.g. tiny10_triples_manifest.json). Default: same as --manifest.") |
| p.add_argument("--run-root", required=True) |
| p.add_argument("--phase-name", default="P1_restore_csi") |
| p.add_argument("--epochs", type=int, default=30) |
| p.add_argument("--warmup-epochs", type=int, default=2) |
| p.add_argument("--batch-size", type=int, default=2048, |
| help="triples per rank (DataLoader batch_size). D2.2 used 4096.") |
| p.add_argument("--val-batch-size", type=int, default=4096) |
| p.add_argument("--val-every-epochs", type=int, default=5) |
| p.add_argument("--val-max-triples", type=int, default=5000, |
| help="cap val triples to keep val fast (default 5000)") |
| p.add_argument("--num-workers", type=int, default=4) |
| p.add_argument("--lr", type=float, default=5e-5) |
| p.add_argument("--lr-min", type=float, default=1e-7) |
| p.add_argument("--weight-decay", type=float, default=0.0) |
| p.add_argument("--channels", type=int, default=512) |
| p.add_argument("--downsample-stages", type=int, default=3) |
| p.add_argument("--blocks-per-stage", type=int, default=3) |
| p.add_argument("--seed", type=int, default=0) |
| p.add_argument("--backbone-kind", default="trellis2", choices=["trellis2", "dense_fallback"]) |
| |
| p.add_argument("--spatial-channels", type=int, default=256) |
| p.add_argument("--stem-depth", type=int, default=4) |
| p.add_argument("--output-depth", type=int, default=4) |
| p.add_argument("--grid-h", type=int, default=36) |
| p.add_argument("--grid-w", type=int, default=36) |
| p.add_argument("--corridor-budget", type=int, default=192) |
| p.add_argument("--num-cross-layers", type=int, default=4) |
| p.add_argument("--num-self-layers", type=int, default=2) |
| |
| p.add_argument("--load-phase-ckpt", default=None, |
| help="Load prior-phase ckpt (supersedes --load-v22a/--load-d22)") |
| p.add_argument("--load-v22a-ckpt", default=None) |
| p.add_argument("--load-d22-ckpt", default=None, |
| help="For warm start or to provide CIR head init") |
| |
| p.add_argument("--freeze-backbone", action="store_true") |
| p.add_argument("--freeze-tx-proj", action="store_true") |
| p.add_argument("--freeze-radiomap-head", action="store_true") |
| p.add_argument("--freeze-csi-head", action="store_true") |
| |
| p.add_argument("--matcher-backend", default="scipy", choices=["scipy", "sinkhorn"]) |
| p.add_argument("--no-object-exists-weight", type=float, default=5.0) |
| return p.parse_args() |
|
|
|
|
| def setup_ddp() -> tuple[int, int, int, torch.device]: |
| dist.init_process_group(backend="nccl") |
| rank = dist.get_rank() |
| world_size = dist.get_world_size() |
| local_rank = int(os.environ.get("LOCAL_RANK", rank)) |
| torch.cuda.set_device(local_rank) |
| device = torch.device(f"cuda:{local_rank}") |
| return rank, local_rank, world_size, device |
|
|
|
|
| def cleanup_ddp() -> None: |
| if dist.is_initialized(): |
| dist.destroy_process_group() |
|
|
|
|
| def compute_lr(step: int, total_steps: int, warmup_steps: int, lr_max: float, lr_min: float) -> float: |
| if step < warmup_steps: |
| return lr_max * (step + 1) / max(warmup_steps, 1) |
| progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1) |
| progress = min(max(progress, 0.0), 1.0) |
| cos = 0.5 * (1.0 + math.cos(math.pi * progress)) |
| return lr_min + (lr_max - lr_min) * cos |
|
|
|
|
| def composite_csi_score(val: dict) -> float: |
| """CIR composite: higher pdp_cosine, lower delay/peak/fpr = better.""" |
| return ( |
| + 1.0 * val.get("pdp_cosine_nonzero", 0.0) |
| - 0.05 * val.get("peak_db_mae_matched", 0.0) |
| - 0.1 * val.get("delay_mae_matched_ns", 0.0) |
| + 1.5 * val.get("count_acc", 0.0) |
| - 0.5 * val.get("zero_path_fpr", 0.0) |
| ) |
|
|
|
|
| def run_val( |
| model: torch.nn.Module, |
| loader: DataLoader, |
| trainer: CsiSetTrainer, |
| device: torch.device, |
| amp_dtype: torch.dtype | None, |
| world_size: int, |
| max_batches: int = 0, |
| ) -> dict[str, float]: |
| model.eval() |
| total = { |
| "pdp_cosine_nonzero": 0.0, |
| "peak_db_mae_matched": 0.0, |
| "peak_db_bias_matched": 0.0, |
| "delay_mae_matched_ns": 0.0, |
| "count_acc": 0.0, |
| "nonzero_path_count_mae": 0.0, |
| "zero_path_fpr": 0.0, |
| "match_rate_over_all_samples": 0.0, |
| "n": 0, |
| } |
| with torch.no_grad(): |
| for bi, batch in enumerate(loader): |
| if max_batches > 0 and bi >= max_batches: |
| break |
| for k, v in batch.items(): |
| if isinstance(v, torch.Tensor): |
| batch[k] = v.to(device, non_blocking=True) |
| if "scene_voxel_levels" in batch: |
| batch["scene_voxel_levels"] = [ |
| {kk: (tt.to(device, non_blocking=True) if torch.is_tensor(tt) else tt) |
| for kk, tt in sv.items()} if isinstance(sv, dict) else sv |
| for sv in batch.get("scene_voxel_levels", []) |
| ] |
| gt = { |
| "gt_num_paths": batch["gt_num_paths"], |
| "gt_delay_ns": batch["gt_delay_ns"], |
| "gt_peak_db": batch["gt_peak_db"], |
| "gt_path_mask": batch["gt_path_mask"], |
| "gt_truncated": batch["gt_truncated"], |
| } |
| out = trainer.step(batch, gt) |
| mets = compute_metric_bundle( |
| out["predictions"], gt, out["matchings"], |
| query_budget=trainer.cfg.model.query_budget if hasattr(trainer.cfg.model, 'query_budget') else 8, |
| ) |
| B = gt["gt_num_paths"].shape[0] |
| for k in total: |
| if k == "n": |
| continue |
| total[k] += float(mets.get(k, 0.0)) * B |
| total["n"] += B |
| |
| t = torch.tensor([total[k] for k in total], dtype=torch.float64, device=device) |
| dist.all_reduce(t, op=dist.ReduceOp.SUM) |
| vals = t.tolist() |
| model.train() |
| n = vals[-1] |
| if n == 0: |
| return {} |
| keys = list(total.keys()) |
| out = {keys[i]: vals[i] / n for i in range(len(keys) - 1)} |
| out["val_n_samples"] = n |
| return out |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| rank, local_rank, world_size, device = setup_ddp() |
| torch.manual_seed(args.seed + rank) |
| np.random.seed(args.seed + rank) |
|
|
| run_root = Path(args.run_root) |
| reports_dir = run_root / "reports" |
| artifacts_dir = run_root / "artifacts" |
| ckpt_dir = run_root / "checkpoints" |
| for d in (reports_dir, artifacts_dir, ckpt_dir): |
| d.mkdir(parents=True, exist_ok=True) |
|
|
| if rank == 0: |
| print(f"========== {args.phase_name} (CIR phase) ==========") |
| print(f"world_size={world_size} per-rank bs={args.batch_size} global bs={args.batch_size*world_size}") |
|
|
| |
| reject_radiomap_artifacts(args.manifest) |
| manifest = json.loads(Path(args.manifest).read_text()) |
| val_manifest_path = args.val_manifest or args.manifest |
| val_manifest = json.loads(Path(val_manifest_path).read_text()) |
|
|
| |
| shared = SharedModelConfig( |
| backbone_kind=args.backbone_kind, |
| backbone_channels=args.channels, |
| backbone_downsample_stages=args.downsample_stages, |
| backbone_blocks_per_stage=args.blocks_per_stage, |
| ) |
| |
| csi_arch_kwargs = {} |
| ckpt_for_arch = args.load_phase_ckpt or args.load_d22_ckpt |
| if ckpt_for_arch: |
| info = detect_csi_head_arch_from_ckpt(ckpt_for_arch) |
| csi_arch_kwargs = {k: v for k, v in info.items() if k.startswith("csi_")} |
| if "backbone_downsample_stages" in info: |
| shared.backbone_downsample_stages = info["backbone_downsample_stages"] |
| if "backbone_blocks_per_stage" in info: |
| shared.backbone_blocks_per_stage = info["backbone_blocks_per_stage"] |
| if rank == 0: |
| print(f"[arch-detect] from {ckpt_for_arch}: {csi_arch_kwargs}") |
|
|
| model = JointRadiomapCIRModel( |
| shared=shared, |
| channels=args.channels, |
| spatial_channels=args.spatial_channels, |
| stem_depth=args.stem_depth, |
| output_depth=args.output_depth, |
| grid_h=args.grid_h, |
| grid_w=args.grid_w, |
| corridor_budget=args.corridor_budget, |
| num_cross_layers=args.num_cross_layers, |
| num_self_layers=args.num_self_layers, |
| **csi_arch_kwargs, |
| ).to(device) |
|
|
| |
| if args.load_phase_ckpt: |
| meta = load_phase_ckpt_into_model(args.load_phase_ckpt, model) |
| if rank == 0: |
| print(f"[load] phase ckpt from {meta.get('phase_name')} ep={meta.get('epoch')}") |
| elif args.load_v22a_ckpt and args.load_d22_ckpt: |
| report = load_warm_start_ckpt(model, args.load_v22a_ckpt, args.load_d22_ckpt, verify=True) |
| if rank == 0: |
| print(f"[load] warm-start: {report}") |
| elif args.load_d22_ckpt: |
| d22_sd = torch.load(args.load_d22_ckpt, map_location="cpu", weights_only=False)["model_state_dict"] |
| keep = {k: v for k, v in d22_sd.items() if k.startswith("backbone.") or k.startswith("tx_proj.")} |
| csi_remap = {k.replace("head.", "csi_head.", 1): v for k, v in d22_sd.items() if k.startswith("head.")} |
| model.load_state_dict(keep, strict=False) |
| model.load_state_dict(csi_remap, strict=False) |
| if rank == 0: |
| print(f"[load] D2.2 only: {len(keep)} backbone+tx + {len(csi_remap)} csi_head") |
|
|
| |
| freeze_cfg = FreezeConfig( |
| backbone=args.freeze_backbone, |
| tx_proj=args.freeze_tx_proj, |
| radiomap_head=args.freeze_radiomap_head or True, |
| csi_head=args.freeze_csi_head, |
| ) |
| model.set_freeze_state(freeze_cfg) |
|
|
| n_total = sum(p.numel() for p in model.parameters()) |
| n_train = sum(p.numel() for p in model.parameters() if p.requires_grad) |
| if rank == 0: |
| print(f"[phase={args.phase_name}] params: total={n_total/1e6:.2f}M trainable={n_train/1e6:.2f}M frozen={(n_total-n_train)/1e6:.2f}M") |
| print(f"[phase={args.phase_name}] freeze: backbone={freeze_cfg.backbone} tx_proj={freeze_cfg.tx_proj} rm_head={freeze_cfg.radiomap_head} csi_head={freeze_cfg.csi_head}") |
|
|
| |
| any_frozen = any([freeze_cfg.backbone, freeze_cfg.tx_proj, freeze_cfg.radiomap_head, freeze_cfg.csi_head]) |
| ddp = DDP(model, device_ids=[local_rank], |
| find_unused_parameters=any_frozen, gradient_as_bucket_view=True) |
|
|
| |
| trainer_cfg = TrainerConfig() |
| trainer_cfg.matcher = MatcherConfig(backend=args.matcher_backend) |
| from wiser.engine.losses import LossWeights |
| trainer_cfg.loss_weights = LossWeights( |
| no_object_exists=args.no_object_exists_weight, |
| ) |
| trainer_cfg.model = model.config |
| |
| trainer = CsiSetTrainer(model, trainer_cfg) |
|
|
| |
| opt = torch.optim.Adam([p for p in ddp.parameters() if p.requires_grad], |
| lr=args.lr, weight_decay=args.weight_decay) |
|
|
| |
| target_cfg = MergedPathTargetConfig() |
| train_ds = MultiSceneTripleDataset(manifest, target_config=target_cfg, voxel_channels=args.channels) |
| val_ds_full = MultiSceneTripleDataset(val_manifest, target_config=target_cfg, voxel_channels=args.channels) |
| |
| if args.val_max_triples > 0 and len(val_ds_full.triples) > args.val_max_triples: |
| val_ds_full.triples = val_ds_full.triples[:args.val_max_triples] |
|
|
| train_sampler = DistributedSampler(train_ds, num_replicas=world_size, rank=rank, shuffle=True, drop_last=True, seed=args.seed) |
| val_sampler = DistributedSampler(val_ds_full, num_replicas=world_size, rank=rank, shuffle=False, drop_last=False) |
|
|
| train_loader = DataLoader( |
| train_ds, batch_size=args.batch_size, sampler=train_sampler, |
| collate_fn=triple_collate, num_workers=args.num_workers, |
| pin_memory=True, persistent_workers=args.num_workers > 0, drop_last=True, |
| ) |
| val_loader = DataLoader( |
| val_ds_full, batch_size=args.val_batch_size, sampler=val_sampler, |
| collate_fn=triple_collate, num_workers=max(1, args.num_workers // 2), |
| pin_memory=True, persistent_workers=False, drop_last=False, |
| ) |
|
|
| if rank == 0: |
| print(f"[phase={args.phase_name}] train: {len(train_ds)} triples, steps/ep/rank={len(train_loader)}") |
| print(f"[phase={args.phase_name}] val: {len(val_ds_full.triples)} triples") |
|
|
| steps_per_epoch = max(1, len(train_loader)) |
| total_steps = args.epochs * steps_per_epoch |
| warmup_steps = args.warmup_epochs * steps_per_epoch |
| amp_dtype = torch.bfloat16 |
|
|
| csv_path = reports_dir / "train_curves.csv" |
| if rank == 0: |
| with open(csv_path, "w", newline="") as f: |
| w = csv.writer(f) |
| w.writerow([ |
| "epoch", "step", "lr", |
| "train_loss", "train_gain_loss", "train_grad_loss_n/a", "train_peak_mae", |
| "val_peak_db_mae_matched", "val_delay_mae_matched_ns", "val_count_acc", "val_fpr", "val_pdp_cosine", |
| "secs_train", "secs_val", "peak_mem_gb", |
| ]) |
|
|
| |
| snap = {k: (v if not isinstance(v, Path) else str(v)) for k, v in vars(args).items()} |
| snap["world_size"] = world_size |
| with open(artifacts_dir / "config_snapshot.json", "w") as f: |
| json.dump(snap, f, indent=2) |
|
|
| dist.barrier() |
|
|
| best_score = -1e9 |
| history = [] |
| step = 0 |
|
|
| for epoch in range(args.epochs): |
| ddp.train() |
| train_sampler.set_epoch(epoch) |
| epoch_start = time.perf_counter() |
| torch.cuda.reset_peak_memory_stats(device) |
|
|
| ep_loss = 0.0 |
| ep_peak_mae = 0.0 |
| ep_n = 0 |
|
|
| for batch in train_loader: |
| lr = compute_lr(step, total_steps, warmup_steps, args.lr, args.lr_min) |
| for pg in opt.param_groups: |
| pg["lr"] = lr |
|
|
| |
| for k, v in batch.items(): |
| if isinstance(v, torch.Tensor): |
| batch[k] = v.to(device, non_blocking=True) |
| if "scene_voxel_levels" in batch: |
| batch["scene_voxel_levels"] = [ |
| {kk: (tt.to(device, non_blocking=True) if torch.is_tensor(tt) else tt) |
| for kk, tt in sv.items()} if isinstance(sv, dict) else sv |
| for sv in batch.get("scene_voxel_levels", []) |
| ] |
| gt = { |
| "gt_num_paths": batch["gt_num_paths"], |
| "gt_delay_ns": batch["gt_delay_ns"], |
| "gt_peak_db": batch["gt_peak_db"], |
| "gt_path_mask": batch["gt_path_mask"], |
| "gt_truncated": batch["gt_truncated"], |
| } |
|
|
| opt.zero_grad(set_to_none=True) |
| out = trainer.step(batch, gt) |
| loss = out["loss_bundle"]["loss_total"] |
| loss.backward() |
| opt.step() |
|
|
| |
| B = gt["gt_num_paths"].shape[0] |
| ep_loss += float(loss.item()) * B |
| ep_n += B |
| step += 1 |
|
|
| |
| t_train = torch.tensor([ep_loss, float(ep_n)], dtype=torch.float64, device=device) |
| dist.all_reduce(t_train, op=dist.ReduceOp.SUM) |
| tl, tn = t_train.tolist() |
| train_stats = { |
| "train_loss": tl / max(tn, 1), |
| } |
|
|
| train_end = time.perf_counter() |
| secs_train = train_end - epoch_start |
| peak_mem = torch.cuda.max_memory_allocated(device) / 1024**3 |
|
|
| val_stats = {} |
| secs_val = 0.0 |
| if (epoch + 1) % args.val_every_epochs == 0 or epoch == args.epochs - 1: |
| val_start = time.perf_counter() |
| val_stats = run_val(ddp, val_loader, trainer, device, amp_dtype, world_size) |
| dist.barrier() |
| secs_val = time.perf_counter() - val_start |
| secs = time.perf_counter() - epoch_start |
|
|
| if rank == 0: |
| ep_entry = { |
| "epoch": int(epoch), "step": int(step), "lr": float(lr), |
| **train_stats, **val_stats, |
| "secs_train": float(secs_train), "secs_val": float(secs_val), |
| "peak_mem_gb": float(peak_mem), |
| } |
| history.append(ep_entry) |
| with open(csv_path, "a", newline="") as f: |
| w = csv.writer(f) |
| w.writerow([ |
| epoch, step, f"{lr:.6e}", |
| f"{train_stats['train_loss']:.4f}", "", "", |
| f"{val_stats.get('peak_db_mae_matched', ''):.4f}" if val_stats else "", |
| f"{val_stats.get('peak_db_mae_matched', ''):.4f}" if val_stats else "", |
| f"{val_stats.get('delay_mae_matched_ns', ''):.4f}" if val_stats else "", |
| f"{val_stats.get('count_acc', ''):.4f}" if val_stats else "", |
| f"{val_stats.get('zero_path_fpr', ''):.4f}" if val_stats else "", |
| f"{val_stats.get('pdp_cosine_nonzero', ''):.4f}" if val_stats else "", |
| f"{secs_train:.2f}", f"{secs_val:.2f}", f"{peak_mem:.2f}", |
| ]) |
|
|
| msg = f"[ep {epoch:3d}/{args.epochs}] lr={lr:.2e} tr_loss={train_stats['train_loss']:.4f}" |
| if val_stats: |
| msg += f" | val peak_mae={val_stats.get('peak_db_mae_matched',0):.2f} delay_mae={val_stats.get('delay_mae_matched_ns',0):.3f} count_acc={val_stats.get('count_acc',0):.3f} fpr={val_stats.get('zero_path_fpr',0):.3f} pdp={val_stats.get('pdp_cosine_nonzero',0):.4f}" |
| msg += f" | train={secs_train:.1f}s val={secs_val:.1f}s peak={peak_mem:.1f}GB" |
| print(msg, flush=True) |
|
|
| if val_stats: |
| score = composite_csi_score(val_stats) |
| if score > best_score: |
| best_score = score |
| save_phase_ckpt(ckpt_dir / "best.pt", |
| ddp.module, opt, args.phase_name, |
| epoch, step, |
| {**train_stats, **val_stats, "composite_score": score}, |
| vars(args), |
| {"backbone": freeze_cfg.backbone, "tx_proj": freeze_cfg.tx_proj, |
| "radiomap_head": freeze_cfg.radiomap_head, "csi_head": freeze_cfg.csi_head}) |
| print(f" ✓ new best.pt (epoch {epoch}, score {score:.4f})", flush=True) |
|
|
| save_phase_ckpt(ckpt_dir / "last.pt", |
| ddp.module, opt, args.phase_name, epoch, step, |
| {**train_stats, **val_stats}, |
| vars(args), |
| {"backbone": freeze_cfg.backbone, "tx_proj": freeze_cfg.tx_proj, |
| "radiomap_head": freeze_cfg.radiomap_head, "csi_head": freeze_cfg.csi_head}) |
|
|
| dist.barrier() |
|
|
| if rank == 0: |
| print(f"========== {args.phase_name} DONE ==========", flush=True) |
|
|
| cleanup_ddp() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|