#!/usr/bin/env python3 """Per-subset evaluation for v12 (ov1_unified_v12). 对 v12 训练得到的 checkpoint(unified_spatial_foa_fsd63_all 数据集上训练) 按子集独立评估,输出每个子集的 F20/ER20/LE_CD/LR_CD/SELD_score 以及 oracle_class_acc / oracle_azi_mae_deg / oracle_ele_mae_deg / oracle_dist_mae 等诊断指标,用于分析: 1) cls vs 空间 谁是瓶颈 2) 真实 vs 仿真 的 gap 3) 多源(ov2/ov3)vs 单源 的差距 支持的子集: - ov1_sim / ov2_sim / ov3_sim (旧仿真 OV 数据,static,所有帧同一 DOA) - ov1_real / ov2_real / ov3_real(DCASE real static mapped,更接近真实混响) - dcase_starss (DCASE STARSS23 / 22 / TAU 混合,real dynamic) - unified_valid / unified_test (新 unified 数据,混合 sim_static + qa_sim + dcase_real) 用法: python eval_v12_per_subset.py \\ --checkpoint checkpoints/spatial_beats_ov1_unified_v12_exp/03_ov123_top4/best.pt \\ --preset ov1_unified_v12 \\ --split valid \\ --batch-size 8 --num-workers 8 --amp bf16 # 跑 test 集合 python eval_v12_per_subset.py \\ --checkpoint checkpoints/spatial_beats_ov1_unified_v12_exp/03_ov123_top4/best.pt \\ --preset ov1_unified_v12 \\ --split test \\ --batch-size 8 --num-workers 8 --amp bf16 """ from __future__ import annotations import argparse import contextlib import copy import dataclasses import functools import json from pathlib import Path from types import SimpleNamespace from typing import Dict, List, Optional, Tuple import torch from tqdm.auto import tqdm from spatial_beats import SpatialBEATs from spatial_dataset import SpatialDataset, collate_spatial_batch from spatial_loss import ( OfficialDCASEMetricsAccumulator, accumulate_frame_track_seld, compute_frame_track_validation_metrics, ) from train_spatial_beats import ( DEFAULT_DCASE_STARSS_VALID_MANIFEST, DEFAULT_OV1_MANIFEST, DEFAULT_OV2_MANIFEST, DEFAULT_OV3_MANIFEST, DEFAULT_OV1_REAL_MANIFEST, DEFAULT_OV2_REAL_MANIFEST, DEFAULT_OV3_REAL_MANIFEST, DEFAULT_UNIFIED_TRAIN_MANIFEST, DEFAULT_UNIFIED_VALID_MANIFEST, TrainSpatialBEATsConfig, build_dataset_config, build_model_config, build_train_config_from_args, ) DEFAULT_DCASE_STARSS_TEST_MANIFEST = ( "/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/" "dcase_starss_foa.test.jsonl" ) DEFAULT_UNIFIED_TEST_MANIFEST = ( "/apdcephfs_cq12/share_302080740/user/schmittzhu/data/" "unified_spatial_foa_fsd63_all/test.jsonl" ) def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser() p.add_argument("--checkpoint", required=True) p.add_argument("--preset", required=True) p.add_argument( "--split", choices=("valid", "test"), default="valid", help="valid uses each dataset's valid split; test uses the test split", ) # sim manifests (ov1/2/3) p.add_argument("--ov1-manifest", default=DEFAULT_OV1_MANIFEST) p.add_argument("--ov2-manifest", default=DEFAULT_OV2_MANIFEST) p.add_argument("--ov3-manifest", default=DEFAULT_OV3_MANIFEST) # real (DCASE static mapped) manifests p.add_argument("--ov1-real-manifest", default=DEFAULT_OV1_REAL_MANIFEST) p.add_argument("--ov2-real-manifest", default=DEFAULT_OV2_REAL_MANIFEST) p.add_argument("--ov3-real-manifest", default=DEFAULT_OV3_REAL_MANIFEST) # dcase_starss (full real, dynamic) p.add_argument( "--dcase-starss-valid-manifest", default=DEFAULT_DCASE_STARSS_VALID_MANIFEST, ) p.add_argument( "--dcase-starss-test-manifest", default=DEFAULT_DCASE_STARSS_TEST_MANIFEST, ) # unified p.add_argument("--unified-valid-manifest", default=DEFAULT_UNIFIED_VALID_MANIFEST) p.add_argument("--unified-test-manifest", default=DEFAULT_UNIFIED_TEST_MANIFEST) # runtime p.add_argument("--batch-size", type=int, default=8) p.add_argument("--num-workers", type=int, default=8) p.add_argument("--amp", choices=("fp32", "bf16", "fp16"), default="bf16") p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") p.add_argument("--output-json", default=None) p.add_argument("--activity-threshold", type=float, default=0.5) p.add_argument( "--max-samples-per-subset", type=int, default=-1, help="-1 = no cap (evaluate everything)", ) p.add_argument( "--only-subsets", default="", help="comma-separated subset names to evaluate (empty=all). " "Options: ov1_sim,ov2_sim,ov3_sim,ov1_real,ov2_real,ov3_real," "dcase_starss,unified", ) return p.parse_args() def build_cfg(args: argparse.Namespace) -> TrainSpatialBEATsConfig: ns = SimpleNamespace( preset=args.preset, ov1_manifest=args.ov1_manifest, ov2_manifest=args.ov2_manifest, ov3_manifest=args.ov3_manifest, ov1_real_manifest=args.ov1_real_manifest, ov2_real_manifest=args.ov2_real_manifest, ov3_real_manifest=args.ov3_real_manifest, dcase_starss_valid_manifest=args.dcase_starss_valid_manifest, unified_train_manifest=DEFAULT_UNIFIED_TRAIN_MANIFEST, unified_valid_manifest=args.unified_valid_manifest, batch_size=None, num_workers=None, amp=None, num_epochs=None, learning_rate=None, weight_decay=None, output_dir=None, class_finetuned_ckpt=None, init_from_spatial_ckpt=None, resume=None, no_resume_optimizer=False, reset_epoch_on_resume=False, reset_best_on_resume=False, crop_mode=None, max_clip_duration_seconds=None, save_every_n_epochs=None, train_projector_in_stage1=False, freeze_trunk=False, no_progress=False, distributed=False, local_rank=None, distributed_backend=None, ddp_find_unused_parameters=False, ) cfg = build_train_config_from_args(ns) cfg.batch_size = int(args.batch_size) cfg.num_workers = int(args.num_workers) cfg.amp_dtype = args.amp cfg.distributed = False cfg.show_progress_bars = True cfg.dump_val_predictions = False cfg.num_val_prediction_examples = 0 return cfg def load_model( ckpt_path: str, cfg: TrainSpatialBEATsConfig, device: torch.device ) -> SpatialBEATs: model_cfg = build_model_config(cfg) model = SpatialBEATs(model_cfg) sd = torch.load(ckpt_path, map_location="cpu", weights_only=False) state_dict = sd["model_state_dict"] if "model_state_dict" in sd else sd.get("model", sd) missing, unexpected = model.load_state_dict(state_dict, strict=False) if missing: print(f"[Eval] WARN missing({len(missing)}): {missing[:6]}") if unexpected: print(f"[Eval] WARN unexpected({len(unexpected)}): {unexpected[:6]}") model.to(device).eval() return model def _amp_ctx(dtype: str): if not torch.cuda.is_available(): return contextlib.nullcontext() if dtype == "bf16": return torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16) if dtype == "fp16": return torch.amp.autocast(device_type="cuda", dtype=torch.float16) return contextlib.nullcontext() def _move_to_device(batch, device): field_vals = {} for f in dataclasses.fields(batch): v = getattr(batch, f.name) field_vals[f.name] = v.to(device) if isinstance(v, torch.Tensor) else v return type(batch)(**field_vals) def build_subset_plan(args: argparse.Namespace) -> List[Tuple[str, str, Tuple[str, ...]]]: """返回 [(subset_name, manifest_path, allowed_splits), ...].""" if args.split == "valid": plan = [ ("ov1_sim", args.ov1_manifest, ("valid",)), ("ov2_sim", args.ov2_manifest, ("valid",)), ("ov3_sim", args.ov3_manifest, ("valid",)), ("ov1_real", args.ov1_real_manifest, ("valid",)), ("ov2_real", args.ov2_real_manifest, ("valid",)), ("ov3_real", args.ov3_real_manifest, ("valid",)), ("dcase_starss", args.dcase_starss_valid_manifest, ("valid",)), ("unified", args.unified_valid_manifest, ("valid",)), ] else: # test:旧 ov 数据集有 test split,dcase 也有 plan = [ ("ov1_sim", args.ov1_manifest, ("test",)), ("ov2_sim", args.ov2_manifest, ("test",)), ("ov3_sim", args.ov3_manifest, ("test",)), ("ov1_real", args.ov1_real_manifest, ("test",)), ("ov2_real", args.ov2_real_manifest, ("test",)), ("ov3_real", args.ov3_real_manifest, ("test",)), ("dcase_starss", args.dcase_starss_test_manifest, ("test",)), ("unified", args.unified_test_manifest, ("test",)), ] filter_set = {s.strip() for s in args.only_subsets.split(",") if s.strip()} if filter_set: plan = [entry for entry in plan if entry[0] in filter_set] # drop entries whose manifest file does not exist filtered = [] for name, path, splits in plan: if not Path(path).exists(): print(f"[Eval] SKIP subset={name} (manifest not found: {path})") continue filtered.append((name, path, splits)) return filtered def _empty_running() -> Dict[str, float]: return { "oracle_class_acc": 0.0, "oracle_azi_mae_deg": 0.0, "oracle_ele_mae_deg": 0.0, "oracle_dist_mae": 0.0, "class_acc": 0.0, "azi_mae_deg": 0.0, "ele_mae_deg": 0.0, "dist_mae": 0.0, "activity_precision": 0.0, "activity_recall": 0.0, "activity_acc": 0.0, "matched_count": 0.0, } def eval_one_subset( model: SpatialBEATs, cfg: TrainSpatialBEATsConfig, subset_name: str, manifest_path: str, allowed_splits: Tuple[str, ...], device: torch.device, activity_threshold: float, max_samples: int, ) -> Dict[str, float]: ds_cfg = copy.deepcopy(build_dataset_config(cfg)) ds_cfg.allowed_splits = allowed_splits dataset = SpatialDataset(manifest_path=manifest_path, config=ds_cfg) total = len(dataset) print(f"\n[Eval][{subset_name}] manifest={manifest_path}") print(f"[Eval][{subset_name}] split={allowed_splits} size={total}") if total == 0: print(f"[Eval][{subset_name}] empty — skip") return {"subset": subset_name, "manifest": manifest_path, "size": 0} if max_samples > 0 and total > max_samples: indices = list(range(max_samples)) dataset = torch.utils.data.Subset(dataset, indices) print(f"[Eval][{subset_name}] capped to first {max_samples}") collate = functools.partial(collate_spatial_batch, config=ds_cfg) loader = torch.utils.data.DataLoader( dataset, batch_size=cfg.batch_size, shuffle=False, num_workers=cfg.num_workers, collate_fn=collate, pin_memory=True, drop_last=False, persistent_workers=cfg.num_workers > 0, prefetch_factor=4 if cfg.num_workers > 0 else None, ) running = _empty_running() num_batches = 0 seld_acc = OfficialDCASEMetricsAccumulator() with torch.no_grad(): for batch in tqdm(loader, desc=f"Eval {subset_name}", leave=False): batch = _move_to_device(batch, device) with _amp_ctx(cfg.amp_dtype): model_output = model( waveform=batch.waveform, padding_mask=batch.waveform_padding_mask, clip_duration_seconds=batch.clip_duration_seconds, mono_window_mask=None, ) pred_out = model_output.frame_track_prediction_output if pred_out is None: raise RuntimeError("frame_track_prediction_output is None") metric_output = compute_frame_track_validation_metrics( prediction_output=pred_out, batch=batch, temporal_padding_mask=model_output.temporal_padding_mask, config=cfg.loss, ) accumulate_frame_track_seld( prediction_output=pred_out, batch=batch, temporal_padding_mask=model_output.temporal_padding_mask, accumulator=seld_acc, activity_threshold=activity_threshold, ) for key in running: v = getattr(metric_output, key, None) if v is None: continue running[key] += float(v.item()) num_batches += 1 metrics = {k: v / max(num_batches, 1) for k, v in running.items()} metrics.update(seld_acc.compute()) metrics["subset"] = subset_name metrics["manifest"] = manifest_path metrics["size"] = total return metrics def _fmt(v, digits=4): try: return f"{float(v):.{digits}f}" except Exception: return "nan" def print_summary(all_metrics: List[Dict[str, float]], split: str) -> None: if not all_metrics: print("[Eval] no metrics") return print("\n" + "=" * 96) print(f" v12 per-subset ({split} split)") print("=" * 96) hdr = ( f"{'subset':<16} {'N':>6} " f"{'F20':>6} {'ER20':>6} {'LE_CD':>7} {'LR_CD':>6} {'SELD':>6} " f"{'o_cls':>6} {'o_azi':>6} {'o_ele':>6} {'o_dst':>6} " f"{'a_P':>5} {'a_R':>5}" ) print(hdr) print("-" * len(hdr)) for m in all_metrics: if m.get("size", 0) == 0: print(f"{m['subset']:<16} {'EMPTY':>6}") continue print( f"{m['subset']:<16} " f"{m['size']:>6} " f"{_fmt(m.get('F20'), 4):>6} " f"{_fmt(m.get('ER20'), 4):>6} " f"{_fmt(m.get('LE_CD'), 2):>7} " f"{_fmt(m.get('LR_CD'), 4):>6} " f"{_fmt(m.get('SELD_score'), 4):>6} " f"{_fmt(m.get('oracle_class_acc'), 4):>6} " f"{_fmt(m.get('oracle_azi_mae_deg'), 2):>6} " f"{_fmt(m.get('oracle_ele_mae_deg'), 2):>6} " f"{_fmt(m.get('oracle_dist_mae'), 4):>6} " f"{_fmt(m.get('activity_precision'), 3):>5} " f"{_fmt(m.get('activity_recall'), 3):>5}" ) print("=" * 96) print(" legend: F20↑ ER20↓ LE_CD↓ LR_CD↑ SELD↓ " "o_cls=oracle_class_acc o_azi/ele=oracle doa MAE (deg) " "a_P/a_R=activity precision/recall") print("=" * 96) def main() -> None: args = parse_args() device = torch.device(args.device) print(f"[Eval] Device: {device}") print(f"[Eval] Checkpoint: {args.checkpoint}") print(f"[Eval] Preset: {args.preset}") print(f"[Eval] Split: {args.split}") cfg = build_cfg(args) assert cfg.loss.supervision_mode == "local_spatial_track", ( f"Expected local_spatial_track, got {cfg.loss.supervision_mode}." ) if device.type != "cuda": cfg.amp_dtype = "fp32" model = load_model(args.checkpoint, cfg, device) plan = build_subset_plan(args) all_metrics: List[Dict[str, float]] = [] for subset_name, manifest_path, allowed_splits in plan: m = eval_one_subset( model=model, cfg=cfg, subset_name=subset_name, manifest_path=manifest_path, allowed_splits=allowed_splits, device=device, activity_threshold=args.activity_threshold, max_samples=args.max_samples_per_subset, ) all_metrics.append(m) # 打印单子集即时结果 if m.get("size", 0) > 0: print( f"[Eval][{subset_name}] F20={_fmt(m.get('F20'))} " f"ER20={_fmt(m.get('ER20'))} LE_CD={_fmt(m.get('LE_CD'), 2)} " f"LR_CD={_fmt(m.get('LR_CD'))} SELD={_fmt(m.get('SELD_score'))} " f"o_cls={_fmt(m.get('oracle_class_acc'))} " f"o_azi={_fmt(m.get('oracle_azi_mae_deg'), 2)} " f"o_ele={_fmt(m.get('oracle_ele_mae_deg'), 2)} " f"o_dst={_fmt(m.get('oracle_dist_mae'))} " f"a_P={_fmt(m.get('activity_precision'))} " f"a_R={_fmt(m.get('activity_recall'))}" ) print_summary(all_metrics, args.split) out_path = args.output_json if out_path is None: out_path = str( Path(args.checkpoint).parent / f"eval_v12_per_subset_{args.split}.json" ) with open(out_path, "w") as f: json.dump( { "checkpoint": args.checkpoint, "preset": args.preset, "split": args.split, "activity_threshold": args.activity_threshold, "per_subset": all_metrics, }, f, indent=2, ensure_ascii=True, ) print(f"\n[Eval] Summary saved to {out_path}") if __name__ == "__main__": main()