# v13_D 设计文档 — Loss 机制 + 训练细节双重改进 > 日期:2026-05-02 > 起源:v13_B / v13_C 实验 ep3 结果证明 "loss/arch 改动在 cls warmup 结束时贡献微弱",需要重新从**训练机制**角度切入 > 目标:F20 从 v12 的 0.378 推到 **0.43+** > 设计原则:延续 v13_B/C 的"增量式、不破坏现有实验",所有新 flag 默认 False --- ## 0. 为什么 v13_B / v13_C 都不 work ep3(cls warmup 结束)对比: | | v12 ep3 | v13_B ep3 | v13_C ep3 | |---|---|---|---| | F20 | 0.3529 | 0.3565 (+0.004) | 0.3849 (+0.032) | | o_cls | 0.7793 | 0.7757 | 0.7779 | | aR | 0.120 | 0.194 | 0.090 | | aP | 0.866 | 0.860 | 0.859 | **三个实验的 oracle_class_acc 基本一致** → 三组实验的表征学的是同一个东西,新改动都没触达表征。loss/head decision 层面的改动(ASL / gate / soft-F1)在 cls warmup 结束时**基本还没起作用**,因为: 1. **zero-init 的新模块需要 3-5 ep 才能让 layer_scale 爬起来**,而 cls warmup 只有 3 ep 2. **ASL γ-=4 让 loss_activity 绝对值从 0.29 降到 0.07**,实际上是把 activity head 的梯度信号弄弱了 3. **augment 反而降低 activity_precision**(0.95 → 0.86) 4. **v12 自身从 ep3→ep12 只涨了 +0.025**,外推 v13_B/C 的 best 也就 0.38~0.41 ## 1. v13_D 的切入点:**训练机制本身** 不碰架构、不碰表征,只改三件事: - **D-1**: 扩大 cls warmup(3 ep → 8 ep)+ cosine LR + 总 25 ep,让表征学得更稳 - **D-2**: 用 Top-K rank activity loss 替换 BCE,直接针对 activity_recall=0.13 的根本瓶颈 - **D-5**: resume optimizer,从 v12 最后的 Adam momentum 继续,避免前 2-3 ep 梯度方向混乱 - **D-6**: EMA 权重,validate 时用 EMA 模型,ep 间震荡降低 1-2 个点 D-2 是核心,D-1 / D-5 / D-6 是辅助。 ## 2. 具体设计 ### D-1: cls warmup 拉长 + cosine LR + 25 epochs #### 诊断 v12 `frame_spatial_loss_warmup_epochs = 3`,ep0-2 只训练 cls + activity,ep3 起放开 spatial loss。但: - v12 曲线:cls_acc ep0=0.65 → ep1=0.81 → ep2=0.85 → ep3=0.84 → ... → ep12=0.83 - **ep2 已 0.85 但 ep3 反而微降 0.84**,说明 **ep3 放开 spatial loss 的瞬间干扰了 cls** - cls 没有再涨的机会 —— 之后一直在 0.83 附近波动 - FSD63 的 oracle_cls 卡在 0.78 是 class head 学的,不是 trunk 表征的上限 #### 改动 ```python cfg.frame_spatial_loss_warmup_epochs = 8 # 3 → 8 cfg.frame_spatial_loss_ramp_epochs = 2 # 1 → 2(ramp 更平滑) cfg.num_epochs = 25 # 15 → 25 # LR: cosine schedule,峰值在 ep 5-8 之间(warmup 结束附近) cfg.use_cosine_lr = True # 新 flag cfg.cosine_lr_warmup_epochs = 3 # 前 3 ep linear warmup 到 peak cfg.cosine_lr_min_ratio = 0.05 # 最后降到 peak * 0.05 ``` **训练循环改动**: ```python # ep 0-2: linear warmup LR 0 → peak_lr # ep 3-24: cosine decay peak_lr → peak_lr * min_ratio # ep 0-7: spatial loss weight = warmup_scale (0.0 or 0.1) # ep 8-9: linear ramp to 1.0 # ep 10-24: full spatial loss ``` ### D-2: Top-K rank activity loss(核心) #### 诊断 当前 BCE(或 ASL)对每个 `(b, k, t)` 位置独立判断"该 slot 是否 active"。问题: - K=4 slots 在 ov1 数据上永远 3/4 inactive,类别极不平衡 - BCE 的最优解是 `sigmoid(act_logit) ≈ 0.25`(平均 prior),不会敢预测 active - SELD 评估实际用 **sorted by activity logit, take top-K̂** 的方式决策 - 训练目标和评估目标不对齐 #### 设计 **Top-K rank loss**:在每一帧 `(b, t)`,强制 top-`N_active_gt` 个 slot 的 activity logit **必须比其他 slot 至少高 margin**,而不是独立回归 0/1。 ```python def topk_rank_activity_loss( activity_logit: Tensor, # [B, K, T] target_active: Tensor, # [B, K, T], 0/1 valid_time: Tensor, # [B, T] margin: float = 2.0, ) -> Tensor: """ Per-frame marginal ranking loss: For each active slot i (target=1) and each inactive slot j (target=0), enforce logit[i] > logit[j] + margin. Equivalent to: loss = Σ_{i in A, j in I} max(0, margin + logit[j] - logit[i]) This gives direct gradient that "ranks" active slots above inactive ones, which aligns with the DCASE eval pipeline (take top-K̂ per frame). Plus a weak binary regularizer to anchor logit magnitude: + 0.1 * BCE(activity_logit, target_active) """ # [B, T] active_count per frame n_active = target_active.sum(dim=1) # [B, T] # Loop-free formulation using broadcasting: # logit_i: [B, K, T] (active side) # logit_j: [B, K, T] (inactive side) # pairwise diff: [B, K_i, K_j, T] # mask: target_active[i] * (1 - target_active[j]) [B, K_i, K_j, T] act = target_active.unsqueeze(2) # [B, K, 1, T] ina = (1.0 - target_active).unsqueeze(1) # [B, 1, K, T] pair_mask = act * ina # [B, K_i, K_j, T] logit_i = activity_logit.unsqueeze(2) # [B, K, 1, T] logit_j = activity_logit.unsqueeze(1) # [B, 1, K, T] diff = logit_j - logit_i + margin # want this <= 0 # hinge loss, masked hinge = F.relu(diff) * pair_mask # [B, K, K, T] # normalize by valid pairs count pair_valid = pair_mask.sum(dim=(1, 2)) # [B, T] time_mask = valid_time.float() * (pair_valid > 0).float() loss_rank = (hinge.sum(dim=(1, 2)) * time_mask).sum() / time_mask.sum().clamp(min=1.0) # Anchor term: prevents logits from drifting to ±inf loss_bce = F.binary_cross_entropy_with_logits( activity_logit, target_active, reduction='none' ) loss_bce = (loss_bce * valid_time.unsqueeze(1)).mean() return loss_rank + 0.1 * loss_bce ``` #### 为什么比 ASL 好 | 特性 | BCE | ASL | **Top-K rank** | |---|---|---|---| | 优化目标 | per-element logprob | per-element logprob (γ-weighted) | **pairwise ranking** | | 受 K 不平衡影响 | 严重 | 缓解 | 无(只看 rank) | | 与 DCASE 评估对齐 | ❌ | ❌ | **✓** (top-K̂) | | 训练稳定性 | 好 | 中 (γ 过大会崩) | **好**(hinge + 小 BCE anchor) | | 已知效果 | v12 aR=0.13 | v13_B aR=0.19,aP 降 | **未验证**,但机制对 | #### Config flag ```python # spatial_loss.py frame_activity_loss_type: str = "bce" # + "topk_rank" topk_rank_margin: float = 2.0 topk_rank_bce_weight: float = 0.1 # anchor 的 BCE 权重 ``` ### D-5: Resume optimizer(低成本改进) #### 诊断 v13_B/C 都用 `--no-resume-optimizer`,Adam 的 m/v moment buffer 从零重建。前 2-3 个 epoch 梯度方向不稳,尤其在"换 loss 函数"后更明显。 #### 改动 ```bash # run_ov1_unified_v13d.sh 不加 --no-resume-optimizer # 但 LR 设为 v12 最后 LR 的 1/3(避免 resume 后太激进) SPATIAL_LR="${SPATIAL_LR:-7e-6}" # v12 是 2e-5,这里是 2e-5/3 ≈ 7e-6 ``` **注意**:Optimizer state 包含 LR scheduler 状态,如果我们切 cosine schedule 需要 reset schedule 但保留 Adam moments。实现时: ```python # 加载 optimizer_state_dict optimizer.load_state_dict(ckpt['optimizer_state_dict']) # 但把所有 param_group 的 LR 重设为新 LR(cosine scheduler 会从这开始) for pg in optimizer.param_groups: pg['lr'] = new_peak_lr # 删掉 step count(avoid schedule confusion) # scheduler 从 epoch=0 重新开始 ``` ### D-6: EMA 权重 #### 诊断 v12 ep10-14 F20 在 0.367-0.378 震荡,SGD 困在鞍点。EMA = 取最近 N 个权重的平滑平均,能稳定在鞍点中间而非某一端。 #### 改动 **新加 `EMAModel` helper**: ```python class EMAModel: def __init__(self, model: nn.Module, decay: float = 0.9995): self.decay = decay self.shadow: Dict[str, Tensor] = { name: p.detach().clone() for name, p in model.named_parameters() if p.requires_grad } @torch.no_grad() def update(self, model: nn.Module): for name, p in model.named_parameters(): if not p.requires_grad: continue self.shadow[name].mul_(self.decay).add_(p.detach(), alpha=1 - self.decay) def apply_to(self, model: nn.Module) -> Dict[str, Tensor]: """Swap model params with EMA shadow, returns backup for restoration.""" backup = {} for name, p in model.named_parameters(): if name in self.shadow: backup[name] = p.data.clone() p.data.copy_(self.shadow[name]) return backup def restore(self, model: nn.Module, backup: Dict[str, Tensor]): for name, p in model.named_parameters(): if name in backup: p.data.copy_(backup[name]) ``` **训练循环**: ```python # 每 step 后: if ema_model is not None: ema_model.update(model) # validate 前: if ema_model is not None: backup = ema_model.apply_to(model) val_metrics = evaluate_one_epoch(model, ...) if ema_model is not None: ema_model.restore(model, backup) # save best.pt 时,保存 EMA 权重而非原始权重 if ema_model is not None: backup = ema_model.apply_to(model) torch.save({'model_state_dict': model.state_dict(), ...}) ema_model.restore(model, backup) ``` #### Config flag ```python # TrainSpatialBEATsConfig use_ema: bool = False ema_decay: float = 0.9995 ema_start_epoch: int = 3 # 前 3 ep 不 EMA(避免 warmup 噪声) ``` ## 3. v13_D preset ```python def make_ov1_unified_v13d_config(...): cfg = make_ov1_unified_v12_config(...) # v12 为基础 # D-1: 扩大 cls warmup,cosine schedule cfg.frame_spatial_loss_warmup_epochs = 8 cfg.frame_spatial_loss_ramp_epochs = 2 cfg.num_epochs = 25 cfg.use_cosine_lr = True cfg.cosine_lr_warmup_epochs = 3 cfg.cosine_lr_min_ratio = 0.05 cfg.learning_rate = 1.5e-5 # peak LR # D-2: Top-K rank activity loss cfg.loss.frame_activity_loss_type = "topk_rank" cfg.loss.topk_rank_margin = 2.0 cfg.loss.topk_rank_bce_weight = 0.1 # D-5: resume optimizer (在 run script 里,不写 --no-resume-optimizer) # D-6: EMA cfg.use_ema = True cfg.ema_decay = 0.9995 cfg.ema_start_epoch = 3 cfg.output_dir = "checkpoints/spatial_beats_ov1_unified_v13d_exp/03_ov123_top4" return cfg ``` ## 4. 实现步骤 | 文件 | 改动 | 对应 D-* | |---|---|---| | `spatial_loss.py` | 加 `_topk_rank_activity_loss` + config 字段 + 分支 | D-2 | | `train_spatial_beats.py` | 加 `EMAModel` class + cosine LR scheduler + 训练循环 hook | D-1, D-6 | | `train_spatial_beats.py` | 加 `make_ov1_unified_v13d_config` + CLI + choices | - | | `run_ov1_unified_v13d.sh` | 新脚本,不带 `--no-resume-optimizer` | D-5 | | `docs/v13d_spatial_beats_design.md` | 本文档 | - | 所有改动都通过 cfg flag 控制,默认 False → v12/v13_B/v13_C 不受影响。 ## 5. 预期结果 | 指标 | v12 best | v13_D 预期 | 机制 | |---|---|---|---| | F20 | 0.378 | **0.42 ~ 0.46** | Top-K rank + EMA + 长 warmup | | aR | 0.126 | **0.25 ~ 0.40** | Top-K 强制拉高活跃 slot | | aP | 0.855 | **0.80 ~ 0.85** | 可能小降(recall ↑ 的代价),但 hinge 保留 rank 信号 | | class_acc | 0.834 | **0.86 ~ 0.88** | 长 warmup 让 cls 真的学完 | | azi_mae | 19.7° | **18~20°** | 不变,不是目标 | ## 6. 风险和兜底 | 风险 | 概率 | 兜底 | |---|---|---| | Top-K rank 的 hinge 梯度饱和(margin 太大) | 中 | 降 margin 到 1.0 | | margin=2.0 导致 logit 分布爆炸(两端拉开) | 低 | anchor BCE 权重从 0.1 升到 0.3 | | EMA 反而降 F(热启动 EMA 初始化问题) | 低 | ema_start_epoch 提到 5 | | Cosine LR 峰值太高毁掉 v12 表征 | 中 | peak_lr 降到 1e-5(v12 也是这个) | | resume optimizer 把 v12 的 Adam moment 固化在错方向 | 低 | 如果 ep0-2 loss 爆炸,退回 --no-resume-optimizer | | 总体不涨(所有改动都没用) | 中 | 写 ablation,跑 v13_D_noema / v13_D_nocos 诊断 | ## 7. 和 v13_B/C 的关系 - **v13_D 不依赖 v13_B/C 的改动**,直接从 v12 best.pt 热启动 - v13_B/C 可以看作"改模块结构"的尝试,v13_D 是"改训练机制"的尝试 - 如果 v13_D 成功(F ≥ 0.42),**可以在它之上加回 v13_C 的 refinement 2-layer**,那才是真正的 v14 ## 8. 验证步骤 1. `python -c "import ast; ast.parse(open('spatial_loss.py').read())"` 语法 2. Top-K rank loss 单测:给已知 activity_logit + target 手算验证 3. 模型构造 + hot-start v12 best.pt:确认 missing=0, unexpected=0 4. 单 batch 前向 + backward:确认 loss 是 scalar、梯度非 NaN 5. EMA 单测:update 后 shadow 权重正确 6. 1 epoch dry-run:看 cosine LR 曲线 + EMA shadow 随 step 变化 --- ## 附:为什么不加更多改动(D-7 per-class expert 等) 诊断:v13_B/C 失败的主因不是"改动不够多",而是"改动不对靶"。v13_D 只碰 loss 机制 + 训练 schedule,属于**最小必要改动**: - D-2 Top-K 直接针对 activity_recall 瓶颈 - D-1 扩大 warmup 给表征更多学习时间 - D-6 EMA 降低末期震荡 - D-5 resume optimizer 让 LR 轨迹连续 加更多改动(per-class expert、class-conditional gate v2)会重复 v13_B 的错误——改了 head 但没触达瓶颈,而且同时改太多东西无法 ablation。