# v13_B + v13_C 设计文档 > 日期:2026-05-01 > 作者:Claude + user > 目标:在 v12(F20=0.378)基础上,分两个正交实验把 F20 推到 0.45+,合并实验 v14 目标 0.55~0.62 > 设计原则:**所有改动增量式**,通过 cfg flag 控制,默认全关 → 不破坏任何现有实验(v7/v11/v12 全可正常复现) --- ## 0. 背景与瓶颈诊断(摘自 v12 per-subset 分析) v12 best.pt (F20=0.3779 聚合) 按子集拆分: ``` 子集 N F20 ER20 LE_CD LR_CD o_cls o_azi aP aR ov1_sim 4800 0.386 0.686 26.8° 0.640 0.796 25.5° 0.950 0.046 ov2_sim 1718 0.299 0.926 29.6° 0.546 0.653 30.0° 0.916 0.151 ov3_sim 1612 0.270 0.917 30.5° 0.499 0.599 31.8° 0.888 0.165 ov1_real 3374 0.140 0.924 117.7° 0.232 0.809 27.4° 0.767 0.144 ov2_real 2230 0.098 0.866 121.1° 0.198 0.747 38.3° 0.655 0.227 ov3_real 740 0.052 0.766 146.8° 0.125 0.624 51.5° 0.612 0.232 dcase_starss 4560 0.071 1.171 130.3° 0.185 0.698 36.0° 0.625 0.176 unified 35021 (~0.40, 推算) ``` **三大诊断**: 1. **瓶颈是 activity_recall (0.13),不是 cls 也不是 spatial。** oracle_class_acc 在 real 上仍有 0.80,oracle_azi_mae_deg 27-51°,说明表征是健康的,被 activity gate 挡住了。 2. **Real/Sim gap 极大**:同 ov 级别下 real F20 比 sim 低 64~81%。原因是 train 里 dcase_real 只占 6%。 3. **Overlap 惩罚显著**:sim ov3 F20 比 ov1 低 30%(0.27 vs 0.39),K=4 track head 在 overlap 下 slot 分配混乱。 --- ## 1. 实验切分:B 和 C 是正交维度 | 维度 | v13_B | v13_C | |---|---|---| | 改 loss / head 输出接口 | ✅ | ❌(沿用 v12 loss) | | 改数据比例 / augment | 仅 augment | ✅ replication | | 改主干架构 / 容量 | ❌ | ✅ refinement + adapter V3 | | 热启动 | v12 best.pt (strict=False) | v12 best.pt (strict=False) | | 能解的子集 | sim + real 的 activity 瓶颈 | ov2/ov3 overlap + real gap | 理由:让 B 的收益和 C 的收益互不污染,便于 ablation。v14 = B + C 合并。 --- ## 2. 共同约束(关键:不破坏现有实验) 所有 B/C 改动都要遵守: ### 2.1 Cfg flag 默认全关 每条新改动对应一个 `SpatialBEATsConfig` / `SpatialLossConfig` / `TrainSpatialBEATsConfig` 字段,**默认值就是"这条改动不启用"**。 ```python # spatial_modules 侧 self.use_class_activity_bias: bool = False # [B-1] self.use_class_conditional_gate: bool = False # [B-3] self.use_track_refinement: bool = False # [C-2] self.track_refinement_layers: int = 2 self.patch_adapter_version: str = "v1" # "v1"/"v2"/"v3" [C-3] 加 v3 self.use_log_distance_head: bool = False # [C-4] # spatial_loss 侧 self.activity_loss_type: str = "bce" # "bce"/"asymmetric" [B-2] self.asl_gamma_neg: float = 4.0 self.asl_gamma_pos: float = 0.0 self.asl_probability_margin: float = 0.05 self.soft_f1_weight: float = 0.0 # 0 = disabled [B-4] self.distance_loss_type: str = "l1" # "l1"/"laplace_nll" [C-4] # dataset 侧 self.use_spec_augment: bool = False # [B-5] self.spec_augment_time_mask_ratio: float = 0.0 self.spec_augment_freq_mask_ratio: float = 0.0 self.random_gain_db: float = 0.0 self.channel_dropout_prob: float = 0.0 self.lowpass_sim_real_prob: float = 0.0 ``` `ov1_unified_v12` preset 和之前所有 preset 都**不动**,因为它们没设置这些 flag → 新代码分支不走。 ### 2.2 热启动安全 新加的 `nn.Module` / `nn.Parameter` 全部**零初始化**或**identity 等价初始化**: - `class_activity_bias`:`torch.zeros(num_classes)` → logit 不变 - `GatingMLP` 最后一层 bias/weight zero-init → gate_logit = 0 - `TrackRefinementDecoder`:`layer_scale = zeros(num_layers)` → 残差 x + 0 = x - `log_distance_head`:bias 初始化为 `log(mean_distance_v12) ≈ log(1.5)` 从 v12 best.pt `strict=False` 加载时: - 不存在的 key → 走零初始化 - 存在的 key(所有 v12 组件)→ 正常加载 - ep0 forward 输出应与 v12 best.pt 完全一致(或数值上差 < 1e-5) ### 2.3 完全向后兼容 任何旧脚本跑起来(比如 `run_ov1_unified_v12.sh`)**不需要改一行**,因为所有新字段都有默认值且默认 disabled。 ### 2.4 Preset 命名 - `ov1_unified_v13b`:启用所有 B 相关 flag - `ov1_unified_v13c`:启用所有 C 相关 flag - `ov1_unified_v14`:B + C 全开,热启动 max(v13b.best, v13c.best) --- ## 3. v13_B 详细设计:Loss + Decision 全面重写 ### [B-1] Per-class learnable logit bias **为什么**:全局阈值 0.5 对所有类别一视同仁不合理。稀有类(jackhammer)和常见类(singing)的 activity 先验差异巨大,应当让模型自己学各类的 logit bias(等价于 per-class threshold)。 **实现点**: - 文件:`spatial_modules.py` - 类:`FrameTrackPredictionHeads`(定位:`class_logits` 和 `activity_logits` 的出口处) - 新增 parameter:`self.class_activity_bias = nn.Parameter(torch.zeros(num_classes))` - 新增 buffer:`self.use_class_activity_bias: bool` - Forward 改动: ```python # 原: activity_logit = self.activity_head(token) # [B, K, T, 1] # 新: activity_logit_raw = self.activity_head(token) # [B, K, T, 1] if self.use_class_activity_bias: class_probs = F.softmax(class_logits, dim=-1) # [B, K, T, C] # 用 class_probs 作为加权软分配(避免 argmax 阻断梯度) expected_bias = torch.einsum('bktc,c->bkt', class_probs, self.class_activity_bias) activity_logit = activity_logit_raw + expected_bias.unsqueeze(-1) else: activity_logit = activity_logit_raw ``` **训练/推理一致性**:bias 在训练的 BCE loss 和推理的 sigmoid 里都是**同一个量**,因此不需要 threshold sweep。推理时 threshold 始终 = 0.0(logit 空间)或 0.5(prob 空间),完全等价。 **参数量**:63 个标量,忽略不计。 ### [B-2] Asymmetric Loss 替换 BCE **为什么**:BCE 把 FN 和 FP 等权重。当前 activity_recall=0.13 说明 FN 惩罚严重不够。ASL 对 easy negatives 用 `(1-p)^γ-` 下压,对 positives 用弱 `γ+=0`,正负不均衡下表现显著好于 BCE。 **实现点**: - 文件:`spatial_loss.py` - 新增函数:`asymmetric_loss_with_logits(logits, targets, gamma_neg=4, gamma_pos=0, margin=0.05)` - 在 `compute_frame_track_losses`(或同名函数)里根据 `config.activity_loss_type` 分支: ```python if config.activity_loss_type == "asymmetric": loss_act = asymmetric_loss_with_logits( activity_logit, target_active, gamma_neg=config.asl_gamma_neg, gamma_pos=config.asl_gamma_pos, margin=config.asl_probability_margin, ) else: # "bce" loss_act = F.binary_cross_entropy_with_logits(activity_logit, target_active, ...) ``` **数学**: ``` p = sigmoid(logit) positive: -( (1-p)**γ+ ) * log(p) negative: p_shifted = max(p - m, 0) -( p_shifted**γ- ) * log(1 - p_shifted) ``` **参数**:`γ+ = 0`, `γ- = 4`, `margin = 0.05`(ASL paper 推荐起点) ### [B-3] Class-conditional gating MLP **为什么**:activity 当前只看 token embedding。应该让 activity 也依赖 class/DOA 的确信度 —— class softmax 尖锐、DOA 稳定时更大胆判 active。 **实现点**: - 文件:`spatial_modules.py` - 新增类:`ClassConditionalGate(embed_dim, num_classes, hidden_dim=128)` - 输入:`fused_token [B, K, T, D]`, `class_logits [B, K, T, C]`, `pred_dir [B, K, T, 3]` - 融合:`gate_input = concat(token, class_emb_avg, dir_vec)` → MLP → `gate_logit [B, K, T, 1]` - class_emb 用 `class_logits.softmax()` 加权的 class embedding(新增 `nn.Embedding(C, 32)`) - 在 FrameTrackPredictionHeads 里: ```python if self.use_class_conditional_gate: gate_logit = self.class_conditional_gate(token, class_logits, pred_dir) activity_logit = activity_logit + self.gate_scale * gate_logit ``` **初始化**:MLP 最后一层 `weight=zero, bias=zero` → gate_logit = 0 → ep0 等价 v12。 **参数量**:~80K。 ### [B-4] Soft-F1 auxiliary loss **为什么**:BCE/ASL 仍是 per-sample 损失,优化目标和 macro-F20 评测有 gap。Soft-F1 直接按类聚合,和 DCASE 评估同构。 **实现点**: - 文件:`spatial_loss.py` - 新增函数:`soft_macro_f1_loss(activity_logits, class_logits, target_active, target_class)` - 对每个类 `c`: - `p_c = sigmoid(act_logit) * softmax(class)[c]` (class-c 的软 activity) - `y_c = (target_active and target_class==c)` - `tp_c = sum(p_c * y_c)`, `fp_c = sum(p_c * (1-y_c))`, `fn_c = sum((1-p_c) * y_c)` - `f1_c = 2 tp_c / (2 tp_c + fp_c + fn_c + eps)` - `loss = 1 - mean(f1_c)` - 在总 loss 里: ```python if config.soft_f1_weight > 0: total_loss = total_loss + config.soft_f1_weight * soft_macro_f1_loss(...) ``` **warmup**(已确认采用):前 3 ep `soft_f1_weight=0.1`,第 3 ep 起硬切到 `0.3`。 实现方式:在 `train_spatial_beats.py` 的 epoch 循环里根据 `epoch >= soft_f1_warmup_epochs` 动态设置 `train_cfg.loss.soft_f1_weight`,新增 config 字段: ```python cfg.loss.soft_f1_weight_warmup: float = 0.1 # ep < warmup_epochs 时使用 cfg.loss.soft_f1_weight: float = 0.3 # ep >= warmup_epochs cfg.loss.soft_f1_warmup_epochs: int = 3 ``` ### [B-5] Real-distribution augment **为什么**:sim_static 混响干净,模型学到的 activity 判据在低 SNR 下崩溃。augment 让模型见到各种"污染"的 spec,对 real 数据更鲁棒。 **实现点**: - 文件:`spatial_dataset.py` - 在 `SpatialDataset.__getitem__` 或 collate 里加 augment pipeline - 只在训练集(`split='train'`)启用,valid/test 不启用 - 新增 config flag: - `use_spec_augment`(默认 False) - `spec_augment_time_mask_ratio`(0.2 = 20% time 长度) - `spec_augment_freq_mask_ratio`(0.15) - `random_gain_db`(±8) - `channel_dropout_prob`(0.1) - `lowpass_sim_real_prob`(0.1,cutoff ∈ U[4000, 8000] Hz) **顺序**:waveform-level augment(gain, lowpass, channel_dropout)→ feature-level augment(SpecAugment)。 **重要**:augment 只作用在 FOA 4 通道 waveform / delta feature 上,**target labels 不变**。 ### B 实验 preset: `make_ov1_unified_v13b_config` 热启动:`v12_best.pt` (strict=False) 开关: ```python cfg.model.use_class_activity_bias = True # [B-1] cfg.model.use_class_conditional_gate = True # [B-3] cfg.loss.activity_loss_type = "asymmetric" # [B-2] cfg.loss.asl_gamma_neg = 4.0 cfg.loss.asl_probability_margin = 0.05 cfg.loss.soft_f1_weight = 0.3 # [B-4] cfg.dataset.use_spec_augment = True # [B-5] cfg.dataset.spec_augment_time_mask_ratio = 0.2 cfg.dataset.spec_augment_freq_mask_ratio = 0.15 cfg.dataset.random_gain_db = 8.0 cfg.dataset.channel_dropout_prob = 0.1 cfg.dataset.lowpass_sim_real_prob = 0.1 cfg.learning_rate = 1e-5 cfg.num_epochs = 15 cfg.output_dir = "checkpoints/spatial_beats_ov1_unified_v13b_exp/03_ov123_top4" ``` **数据 manifest 完全复用 v12**:unified train/valid + old ov1/2/3 sim/real + dcase_starss 作为 val。 --- ## 4. v13_C 详细设计:Data + Architecture 全面重写 ### [C-1] Real data upsampling (replication) **为什么**:real (dcase_real) 在 train 里占 6%,梯度感受不到。DCASE 社区标准做法是 20-30% real。 **实现点**: - 预处理脚本:`scripts/split_unified_train_by_source.py` - 读 `unified_spatial_foa_fsd63_all/train.jsonl` - 按 `data_source` 字段拆成三份: - `train_sim_static.jsonl` - `train_qa_sim.jsonl` - `train_dcase_real.jsonl` - 写到 `unified_spatial_foa_fsd63_all/` 同目录下 - Preset: ```python cfg.train_manifest_paths = ( unified_root / "train_sim_static.jsonl", unified_root / "train_qa_sim.jsonl", unified_root / "train_dcase_real.jsonl", ) cfg.train_manifest_replication = (1, 1, 6) ``` **影响估算**(基于 v12 已知分布): - sim_static 304K × 1 = 304K - qa_sim ~? × 1 = ~? - dcase_real 20K × 6 = 120K - real 占比从 6% → ~25% (取决于 qa_sim 规模) **兼容性**:`train_manifest_replication` 机制在 `train_spatial_beats.py` 已经存在(v7j 用过),不需要新加框架代码。只改 preset。 ### [C-2] Track-wise Refinement Transformer(2 layers) **为什么**:K=4 track slots 之间互相不知道对方在干嘛,overlap 时同一源被多个 slot 抢,或同一 slot 被多个源抢。引入 self-attention 让 slot 互相"排斥"。 **实现点**: - 文件:`spatial_modules.py` - 新增类: ```python class TrackRefinementDecoder(nn.Module): def __init__(self, num_tracks=4, embed_dim=768, num_layers=2, num_heads=8, dim_feedforward=2048, dropout=0.0): self.track_queries = nn.Parameter(torch.randn(num_tracks, embed_dim) * 0.02) self.layers = nn.ModuleList([ nn.TransformerDecoderLayer( d_model=embed_dim, nhead=num_heads, dim_feedforward=dim_feedforward, dropout=dropout, activation='gelu', norm_first=True, batch_first=True, ) for _ in range(num_layers) ]) # Zero-init layer scale: ep0 refinement = identity self.layer_scale = nn.Parameter(torch.zeros(num_layers)) def forward(self, memory): # memory: fused_spatial_embeddings [B, T, D] # 输出:refined track tokens [B, K, T, D] B, T, D = memory.shape K = self.track_queries.size(0) # 复制 K queries 到时间维度:[B, K, T, D] q = self.track_queries[None, :, None, :].expand(B, K, T, D) # 每个时间步独立做 decoder # 为简化,把 T 维 flatten 进 batch:[B*T, K, D] cross-attn with [B*T, 1, D] q_flat = q.permute(0, 2, 1, 3).reshape(B * T, K, D) mem_flat = memory.reshape(B * T, 1, D) for i, layer in enumerate(self.layers): out = layer(q_flat, mem_flat) q_flat = q_flat + self.layer_scale[i] * (out - q_flat) # reshape 回 [B, K, T, D] refined = q_flat.reshape(B, T, K, D).permute(0, 2, 1, 3).contiguous() return refined ``` - 在 `SpatialBEATs` 里: ```python if cfg.use_track_refinement: self.track_refinement = TrackRefinementDecoder( num_tracks=cfg.num_tracks, embed_dim=cfg.encoder_embed_dim, num_layers=cfg.track_refinement_layers, ) # encode_patches 之后、送入 head 之前: if self.track_refinement is not None: track_tokens = self.track_refinement(encoder_memory) # [B, K, T, D] # 传给 FrameTrackPredictionHeads 的输入从 [B, T, D] 改成 [B, K, T, D] else: track_tokens = None # head 沿用旧 expand 逻辑 ``` - `FrameTrackPredictionHeads` 的 forward 加个 `track_tokens: Optional[Tensor]` 参数: - 传入 None → 沿用现有的"[B,T,D] 复制到 K slots" - 传入 `[B,K,T,D]` → 用 refined tokens 走 head **参数量**:2 layer × (self_attn + cross_attn + FFN) ≈ 2 × 2M = 4M。 **Zero-init 校验**:`layer_scale = zeros(2)` + 残差公式 `q + scale * (out - q)` → ep0 输出 = `track_queries`(静态,和 memory 无关)。但这会丢掉时间信息 —— **修正**:改用 `q + scale * layer_out`,并且把 track_queries 初始化成 `memory` 投影: 实际更安全的等价初始化: ```python # Zero-init 方案:layer 不改 query,query 本身先吸收 memory 信息 # 思路:在 refine 前先做一次 "identity fallback":如果 scale=0,输出 = memory 广播到 K def forward(self, memory): B, T, D = memory.shape K = ... # 初始 track_tokens = memory 广播到 K(+ 一个很小的 query 偏移) track_tokens = memory[:, None, :, :].expand(B, K, T, D).contiguous() track_tokens = track_tokens + 0.02 * self.track_queries[None, :, None, :] # refine for i, layer in enumerate(self.layers): ... track_tokens = track_tokens + self.layer_scale[i] * delta return track_tokens ``` 这样 `layer_scale=0` 时 refinement 输出 ≈ `memory` 广播到 K,和 v12 "把 [B,T,D] 复制到 [B,K,T,D]" 等价。热启动安全。 ### [C-3] Multi-scale Patch Adapter V3 **为什么**:v12 用的 V2 adapter 只看 3 个时间 bin(30 ms),抓不到房间冲激响应的 early reflection (50-150ms)。V3 加多尺度 + dilated conv。 **实现点**: - 文件:`spatial_modules.py` - 新增类:`SpatialDeltaPatchAdapterV3` - 三路 branch: - branch_3x3: `Conv2d(C, H, kernel=3, padding=1)` (同 V2) - branch_5x5: `Conv2d(C, H, kernel=5, padding=2)` (中尺度) - branch_dilated: `Conv2d(C, H, kernel=3, padding=2, dilation=2)` (长时) - fuse: `torch.cat` along channel → `Conv2d(3H, H, kernel=1)` - 接现有 V2 的 SE block + residual + patchify - cfg:`patch_adapter_version: str = "v1"` 增加选项 `"v3"` **参数量**:比 V2 多 ~1M。 ### [C-4] Log-distance head + Laplace NLL loss **为什么**:dist_mae=0.57 很差。距离分布长尾,log 后近似高斯。加 uncertainty 头允许模型对不确信的距离给大 variance,减少高 bias 样本的损失。 **实现点**: - 文件:`spatial_modules.py`, 类 `FrameTrackPredictionHeads` - 把现有 `distance_head: Linear(D, 1)` 升级为 `distance_head: Linear(D, 2)` 输出 `[log_dist, log_var]` - 初始化:`bias[0] = log(1.5)`(v12 平均距离附近),`bias[1] = log(0.2^2)`(var=0.04 起点) - cfg:`use_log_distance_head: bool = False`, `distance_loss_type: str = "l1" / "laplace_nll"` - 文件:`spatial_loss.py`, Laplace NLL: ```python def laplace_nll_loss(pred_log_dist, pred_log_var, target_dist, mask): # target_dist > 0 的位置才算 loss pred_dist = pred_log_dist.exp() pred_b = (0.5 * pred_log_var).exp() # Laplace scale nll = (target_dist - pred_dist).abs() / pred_b + pred_log_var * 0.5 return (nll * mask).sum() / mask.sum().clamp(min=1) ``` 推理时 `pred_distance = exp(pred_log_dist)`。 **初期稳定性**(已确认):v13c 从第 0 ep 就切 Laplace NLL(不做 L1 warmup)。如果训练前期 loss_dist 不稳,再回来调。 ### C 实验 preset: `make_ov1_unified_v13c_config` 热启动:`v12_best.pt` (strict=False) 开关: ```python cfg.model.use_track_refinement = True # [C-2] cfg.model.track_refinement_layers = 2 cfg.model.patch_adapter_version = "v3" # [C-3] cfg.model.use_log_distance_head = True # [C-4] cfg.loss.distance_loss_type = "laplace_nll" # [C-4] cfg.train_manifest_paths = (sim_static, qa_sim, dcase_real) # [C-1] cfg.train_manifest_replication = (1, 1, 6) cfg.learning_rate = 1e-5 cfg.num_epochs = 20 cfg.output_dir = "checkpoints/spatial_beats_ov1_unified_v13c_exp/03_ov123_top4" ``` **loss 完全沿用 v12 默认**(BCE + L1 → Laplace),`soft_f1_weight=0`, `activity_loss_type="bce"`。 --- ## 5. v14 合并实验(后续) 在 B 和 C 都验证有效(F20 > 0.42)后启动: - 热启动:`max(v13b.best, v13c.best).pt` - 所有 B 和 C 的 flag 全开 - LR = 5e-6(更保守,防止双改动发散) - epochs = 20 预期 F20 = 0.55~0.62。 --- ## 6. 预期结果 & 风险矩阵 ### B 预期 - 聚合 F20: 0.378 → **0.45~0.52** - sim ov1: 0.386 → 0.50~0.55 - real ov1: 0.140 → 0.22~0.28 - dcase: 0.071 → 0.15~0.20 - activity_recall: 0.13 → 0.40~0.55 ### C 预期 - 聚合 F20: 0.378 → **0.44~0.50** - ov3_sim: 0.270 → 0.38~0.42 - real ov1: 0.140 → 0.20~0.26 - dcase: 0.071 → 0.14~0.19 - dist_mae: 0.566 → 0.38~0.42 ### 风险 | 风险 | 发生概率 | 兜底 | |---|---|---| | B-2 ASL γ- 太大发散 | 中 | 先 γ-=2 跑 1 ep 验证,再拉到 4 | | B-3 gate 挡掉好样本 | 低 | gate_scale 从 0.5 改 0.2 重跑 | | B-4 soft-F1 和 ASL 冲突 | 中 | soft_f1_weight 从 0.3 降到 0.1 | | B-5 augment 太强 sim 掉点 | 中 | 减半 augment 比例重跑 | | C-1 real 6× 导致 sim 掉点 | 中 | 降到 4× | | C-2 refinement 不学 | 中 | 手动设 layer_scale warmup | | C-3 多尺度显存爆 | 低 | 去掉 dilated branch | | C-4 log-dist 初期不稳 | 中 | 前 3 ep 用 L1 再切 | | v14 合并发散 | 高 | 降 LR 到 3e-6,freeze trunk 前半段 | --- ## 7. 落地文件清单 | 文件 | 改动类型 | B/C | |---|---|---| | `spatial_modules.py` | 新增 3 个类 + 现有类加 forward 分支 | B+C | | `spatial_loss.py` | 新增 2 个 loss 函数 + config 分支 | B+C | | `spatial_dataset.py` | 新增 augment 逻辑 + config 字段 | B | | `spatial_beats.py` | config 字段 + 可选模块构造 + forward 分支 | B+C | | `train_spatial_beats.py` | 新增 2 个 preset 工厂 + CLI dispatch + choices | B+C | | `scripts/split_unified_train_by_source.py` | 新文件,预处理脚本 | C | | `run_ov1_unified_v13b.sh` | 新文件 | B | | `run_ov1_unified_v13c.sh` | 新文件 | C | | `docs/v13_spatial_beats_design.md` | 本文档 | — | 所有现有文件的改动都是**新增分支**,不删除/修改任何现有逻辑。 --- ## 8. 验证步骤 每完成一步,按顺序验证: 1. **语法检查**:`python -c "import ast; ast.parse(open(path).read())"` 2. **旧 preset 回归**:`python train_spatial_beats.py --preset ov1_unified_v12 --dry-run`(或者 ep=1 跑到第一个 batch),确认 F20 和 v12 一致 3. **新模块零初始化等价**:在 `v13b_config` / `v13c_config` 下跑 ep=0 valid,确认和 v12 best.pt 的 valid 指标差异 < 1% 4. **B/C 训练**:完整跑 15/20 ep,观察 F20 曲线 5. **per-subset eval**:用 `eval_v12_per_subset.py --preset ov1_unified_v13b --checkpoint ...` 看每个子集涨点 6. **test eval**:用同脚本加 `--split test` 跑最终测试