| # 2026-04-21 — v7f → v7f_ov123 多源逐帧扩展 |
| |
| ## 背景 |
| |
| `run_ov1_v7f.sh` 在 ov1(每条 clip 1 个静止源)上跑出了可接受的 cls 与空间指标, |
| 但用户需要更强的 per-frame 预测能力,并希望加入 ov2/ov3(2-3 源重叠)继续训练。 |
| 本次工作回答了 4 个问题,并落地了相应代码改动。 |
| |
| --- |
| |
| ## 调研问答 |
| |
| ### Q1:现有框架是否原生支持 ov2/ov3 多源训练? |
| |
| **支持,无需改模型代码。** |
| |
| - `spatial_dataset.py:827` `max_num_sources` 按 batch 动态算(1–4),并产出 `source_valid_mask`。 |
| - `FrameTrackPredictionHeads`(`spatial_modules.py:1664-1720`)每个 K=4 query 独立输出 `[B, K, T_s, ·]` 的 activity / class / direction / distance,**多 slot 可在同一帧并发激活**。 |
| - `_match_frame_tracks_per_clip` + `compute_frame_track_losses`(`spatial_loss.py:1583-1730`)做 clip 级 Hungarian,把 GT N 个 source 匹配到 K 个 query,再在每个匹配 source 的 active 窗口内做逐帧监督。 |
| - 多 manifest 接口已就绪:`train_manifest_paths` (`train_spatial_beats.py:177-179`) + CLI `--ov1-manifest --ov2-manifest --ov3-manifest`。 |
|
|
| ### Q2:v7f 在 ov1 上的 perframe 在预测什么?监督怎么算?用了 active_time 吗? |
| |
| **预测对象**(每帧、每 query 4 个量): |
| - `pred_activity`:sigmoid 后是"该 query 在该帧是否有事件" |
| - `pred_class_logits`:是什么事件 |
| - `pred_direction`:3D 单位向量 DOA |
| - `pred_distance`:softplus 后的距离 |
|
|
| 颗粒度 ~2.5Hz(fused embedding,约 400ms / 帧)。 |
|
|
| **监督链路(确实用了 `active_time`)**: |
| 1. manifest `"active_time":[start,end]` → `spatial_dataset.py:534` 读出 `source_start_time_seconds` / `source_end_time_seconds` |
| 2. `build_time_window_mask`(`spatial_loss.py:129-179`)按 clip_duration 比例换算成 T_s 上的 `window_mask[B, N_gt, T_s]` |
| 3. clip 级 Hungarian 匹配 GT source ↔ K 个 query |
| 4. `activity_target[b,k,t]=1` ⇔ 存在匹配到 k 的 GT 在 t 帧的 window_mask 内 |
| 5. activity 在所有 valid 帧 × K 上做 BCE(**真正的逐帧对比**),class/dir/dist 只在匹配的 source 的 active 帧上做 CE / `1-cos` / smooth-L1 |
| |
| ### Q3:ov1-only 训练会让 perframe 退化为"重复同一个声源"吗? |
| |
| **会。** ov1 每条 clip 只有 1 个 source,Hungarian 总把它路由到某个固定 query k\*: |
| - 1 个 query 学会"复制"输入声源 |
| - 其余 K-1 个 query 的 activity 永远是负样本,class/dir/dist 通路无正梯度 |
| - 在 ov2/ov3 上推理时,多个并发源会被堆到 k\*,表现为"重复预测同一个声源" |
|
|
| 解决:在 ov1+ov2+ov3 混训上继续训练,让 K-1 个 query 也吃到正样本梯度。 |
|
|
| ### Q4:ov1 现在的随机裁剪策略是否需要在 ov123 调整? |
|
|
| **ov1 实际上没在裁剪。** v7 链用 `crop_mode="start"` + `max_clip_duration=20s`,ov1 音频本身 ≤20s,`_maybe_crop_sample` 在长度 ≤ max 时直接 return 原音频。"active_time 接近 [0, clip_dur]"是数据本身的特征:前 200 条 ov1 中 26% 的 source 占 ≥80% 的 clip。 |
|
|
| **ov123 也不需要随机裁剪**:ov2/ov3 是 20s 固定长度,start crop 也是 no-op;多源 active_time 错开本身就提供了 perframe 时序变化信号。`_maybe_crop_sample` 的随机起点只锚定 `sources[0]`,对 ov2/ov3 反而可能切掉 source[1]/[2]。 |
|
|
| ### Q5:v7f 的 val_predictions 为什么是 clip 级的? |
| |
| **当前 v7f 的设计漏洞。** v7f 设了 `enable_frame_track=True`(并行加 frame-track head),但 `supervision_mode` 仍是 `mono_ast`,example dump 走 `spatial_loss.py:820` 的 `_collect_mono_ast_examples`——它读的是 clip-level mono_ast head 输出,每条 sample 只输出 1 行 `(gt_class_index, pred_class_index, pred_azimuth_deg, ...)`。frame-track 的 collector 在 1850 行但没被触发。 |
| |
| 切换 `supervision_mode="local_spatial_track"` 后会走 1850 collector,但**仍然只 dump 每个 (clip, track) 的第 1 个 active frame**(`first_t = nonzero(active_t)[0]`),不是真正的逐帧 dump。 |
|
|
| ### Q6:per-frame 训练为什么还要保留 clip-level 监督? |
|
|
| **不该保留。** mono_ast head 监督目标只读 `source_class_indices[idx, 0]` 等 source 0 的字段(`spatial_loss.py:820`),完全忽略 source 1/2/3。在 ov2/ov3 上等于明确要求 trunk "压制 source 1/2 的信号、只表征 source 0",与 frame-track 想要的"K 个 query 各自表征不同源"**直接对抗**。 |
|
|
| ### Q7:dump 的 activity 阈值为什么要预设? |
|
|
| **不该预设。** sigmoid 输出本身是连续概率,BCE 训练让"有事件"的帧 → 1、"无事件"→ 0,"有几个源"是 K 个 query 的 prob 自然体现。Dump 时阈值化 = 丢失信息。 |
| **正确做法**:dump 全部 K × T_s 行,每行带 `activity_prob`,事后任意截。 |
|
|
| --- |
|
|
| ## 代码改动 |
|
|
| ### 1. 新 preset:`ov1_local_spatial_v7f_ov123` (`train_spatial_beats.py`) |
|
|
| 继承 `v7f_spatial`,但切换到纯 per-frame track 监督,弃掉 clip-level mono_ast head: |
| |
| ```python |
| cfg.model.readout_scheme = "local_spatial_track" |
| cfg.loss.supervision_mode = "local_spatial_track" |
| cfg.loss.enable_frame_track_loss = False # supervision_mode 直接驱动 |
| cfg.loss.lambda_frame_activity = 1.0 |
| cfg.loss.lambda_frame_class = 1.0 |
| cfg.loss.lambda_frame_direction = 4.0 |
| cfg.loss.lambda_frame_distance = 1.0 |
| cfg.loss.lambda_clip_aux = 0.0 |
| cfg.train_manifest_paths = (ov1, ov2, ov3) |
| cfg.val_manifest_paths = (ov1, ov2, ov3) |
| cfg.test_manifest_paths = (ov1, ov2, ov3) |
| cfg.dump_frame_track_csv = True |
| cfg.frame_track_csv_max_samples_per_epoch = 32 |
| cfg.output_dir = "checkpoints/spatial_beats_ov1_local_spatial_v7f_ov123_exp/03_ov123" |
| ``` |
| |
| 注册到 `--preset` 选项与 dispatcher。 |
| |
| ### 2. DCASE 逐帧 CSV dump |
| |
| **`spatial_loss.py` 新增 `collect_frame_track_csv_rows()`**:对每条 sample 产出 |
| - `gt_rows`:每个有效 source × 在 `window_mask & valid_time` 内的每一帧 → 一行(`activity_prob=1.0`) |
| - `pred_rows`:**全部 K=4 track × 全部 valid_time 帧 → 一行**,无任何阈值过滤,连续 `activity_prob` |
| |
| **`train_spatial_beats.py`**: |
| - `TrainSpatialBEATsConfig` 加 `dump_frame_track_csv: bool = False`、`frame_track_csv_max_samples_per_epoch: int = 32` |
| - `evaluate_one_epoch` 返回值 2-tuple → 3-tuple,多带 `csv_samples`,仅 `local_spatial_track` + dump 开启时累积(capped at 32) |
| - 新增 `dump_frame_track_csvs()`:用 `final_vocabulary.csv` 做 `class_idx → class_name` 映射,每条样本写到 |
| ``` |
| <output_dir>/val_predictions/epoch_XXXX_csv/<sample_id>__gt.csv |
| <output_dir>/val_predictions/epoch_XXXX_csv/<sample_id>__pred.csv |
| ``` |
| - 主循环在 epoch 结束时调用 |
|
|
| **CSV schema**(GT/pred 一致,方便 diff / pandas merge): |
| ``` |
| frame_idx, frame_time_s, src_or_track_idx, class_idx, class_name, |
| azimuth_deg, elevation_deg, distance_m, activity_prob |
| ``` |
|
|
| ### 3. shell 入口:`run_ov1_v7f_ov123.sh` |
| |
| - `--preset ov1_local_spatial_v7f_ov123` |
| - `--resume checkpoints/spatial_beats_ov1_local_spatial_v7_exp/01_classwarmup/best.pt`(v7 stage1,trunk 已收敛 cls=71.6%) |
| - `--ov1/2/3-manifest` 三个 manifest 都传 |
| - `MASTER_PORT=29548` 避免与 v7f 并跑冲突 |
| - 默认 `GPUS=8 BATCH_SIZE=4 SPATIAL_EPOCHS=20 SPATIAL_LR=1.5e-5` |
|
|
| 热启动注意: |
| - trunk / local_spatial_encoder / local_spatial_prediction_heads 参数名在 `local_spatial` 与 `local_spatial_track` 两个 scheme 下一致,可直接加载 |
| - SourceQueryDecoder / FrameTrackPredictionHeads 是新模块,从 checkpoint 缺失 → 随机初始化(属预期行为) |
|
|
| --- |
|
|
| ## 关键文件参考 |
|
|
| | 文件 | 行 | 用途 | |
| | --- | --- | --- | |
| | `spatial_modules.py` | 1664-1720 | `FrameTrackPredictionHeads`,K=4 query × per-frame 输出 | |
| | `spatial_loss.py` | 129-179 | `build_time_window_mask`,active_time → 帧 mask | |
| | `spatial_loss.py` | 1583-1636 | `_match_frame_tracks_per_clip`,clip 级 Hungarian | |
| | `spatial_loss.py` | 1639-1740 | `compute_frame_track_losses`,per-frame BCE/CE/cos/smoothL1 | |
| | `spatial_loss.py` | (新) | `collect_frame_track_csv_rows`,DCASE dump | |
| | `spatial_dataset.py` | 534, 827-867 | `active_time` 读取 + 多源 collation | |
| | `train_spatial_beats.py` | 1262-1300 | `make_ov1_local_spatial_v7f_ov123_config` | |
| | `train_spatial_beats.py` | (新) | `dump_frame_track_csvs` | |
| | `run_ov1_v7f_ov123.sh` | — | shell 入口 | |
|
|
| --- |
|
|
| ## 验证清单 |
|
|
| 1. `python3 -c "import ast; ast.parse(open('train_spatial_beats.py').read()); ast.parse(open('spatial_loss.py').read())"` 已通过 |
| 2. Smoke test(远端跑): |
| ```bash |
| GPUS=2 BATCH_SIZE=2 SPATIAL_EPOCHS=1 ./run_ov1_v7f_ov123.sh |
| ``` |
| 关注: |
| - `frame_activity_loss > 0` 且多个 query 都收到非零梯度 |
| - `val_predictions/epoch_0000_csv/` 下出现 32 对 CSV |
| - 同一 `frame_idx` 下 K=4 个 track 的 `activity_prob` 应当呈现差异(而非 4 个全等) |
| 3. **退化诊断**:训练 1-2 epoch 后,统计同帧 K query 输出的 DOA 两两余弦相似度——若仍接近 1,说明 query 间没差异化,需加 query 多样性正则;若开始分散,说明 ov123 混训奏效。 |
| 4. **指标对比** vs v7f baseline: |
| - ov1 子集:cls / DOA / dist 不显著下降 |
| - ov2/ov3 子集:frame_activity F1 与 DOA error 明显改善 |
| |