Add files using upload-large-folder tool
Browse files- .gitignore.bak +15 -0
- BEATs.py +179 -0
- GAP_SOURCE_TECHNICAL_ANALYSIS.md +628 -0
- README.md +127 -0
- SEARCH_FINDINGS_SUMMARY.md +255 -0
- SPATIAL_AUDIO_FRAMEWORKS_ANALYSIS_COMPREHENSIVE.md +464 -0
- analyze_labels.py +53 -0
- analyze_v7h_epoch8.py +258 -0
- beats_0410.md +509 -0
- debug_v6dc_init.py +115 -0
- eval_spatial_beats.py +340 -0
- eval_v12_per_subset.py +470 -0
- quantizer.py +215 -0
- run_beats_ov1_event_cls_unfreeze_sweep.sh +66 -0
- run_foa_cls_stage23.sh +86 -0
- run_ov123_local_spatial_slot.sh +42 -0
- run_ov123_local_spatial_track.sh +43 -0
- run_ov1_ast_three_stage.sh +75 -0
- run_ov1_local_spatial_bypass.sh +55 -0
- run_ov1_local_spatial_classwarmup.sh +47 -0
- run_ov1_pretrunk_ast_experiment.sh +64 -0
- run_ov1_spatial_atst.sh +83 -0
- run_ov1_unified_v13d.sh +91 -0
- run_ov1_unified_v13e.sh +87 -0
- run_ov1_v10_phase1_cls.sh +76 -0
- run_ov1_v10b_phase1_activity.sh +76 -0
- run_ov1_v11c_ov123_accdoa.sh +71 -0
- run_ov1_v11c_real_balanced_10hz.sh +94 -0
- run_ov1_v3.sh +58 -0
- run_ov1_v3b.sh +56 -0
- run_ov1_v4.sh +61 -0
- run_ov1_v4f.sh +41 -0
- run_ov1_v4g.sh +47 -0
- run_ov1_v5.sh +54 -0
- run_ov1_v5f.sh +96 -0
- run_ov1_v6dc.sh +60 -0
- run_ov1_v7.sh +60 -0
- run_ov1_v7f.sh +50 -0
- run_ov1_v7g_ov123_top4.sh +62 -0
- run_ov1_v7i_ov123_top4.sh +73 -0
- run_ov1_v7j_ov123_top4.sh +67 -0
- run_ov1_v9_ov123_top4.sh +86 -0
- run_ov1_v9_real_balanced_10hz.sh +91 -0
- run_v13f_stage1_trunk.sh +75 -0
- spatial_atst.py +752 -0
- spatial_dataset.py +1657 -0
- spatial_loss.py +0 -0
- train_beats_event_classifier.py +699 -0
- train_spatial_atst.py +815 -0
- visualize_spatial_latents.py +1082 -0
.gitignore.bak
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 模型权重/检查点
|
| 2 |
+
# checkpoints
|
| 3 |
+
pretrain_ckpt
|
| 4 |
+
# *.pt
|
| 5 |
+
*.pth
|
| 6 |
+
*.h5
|
| 7 |
+
|
| 8 |
+
# 大文件
|
| 9 |
+
Spatial-AST
|
| 10 |
+
2212.09058
|
| 11 |
+
|
| 12 |
+
# 杂项
|
| 13 |
+
.claude
|
| 14 |
+
.codex
|
| 15 |
+
__pycache__
|
BEATs.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --------------------------------------------------------
|
| 2 |
+
# BEATs: Audio Pre-Training with Acoustic Tokenizers (https://arxiv.org/abs/2212.09058)
|
| 3 |
+
# Github source: https://github.com/microsoft/unilm/tree/master/beats
|
| 4 |
+
# Copyright (c) 2022 Microsoft
|
| 5 |
+
# Licensed under The MIT License [see LICENSE for details]
|
| 6 |
+
# Based on fairseq code bases
|
| 7 |
+
# https://github.com/pytorch/fairseq
|
| 8 |
+
# --------------------------------------------------------
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
from torch.nn import LayerNorm
|
| 14 |
+
import torchaudio.compliance.kaldi as ta_kaldi
|
| 15 |
+
|
| 16 |
+
from backbone import (
|
| 17 |
+
TransformerEncoder,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BEATsConfig:
|
| 27 |
+
def __init__(self, cfg=None):
|
| 28 |
+
self.input_patch_size: int = -1 # path size of patch embedding
|
| 29 |
+
self.embed_dim: int = 512 # patch embedding dimension
|
| 30 |
+
self.conv_bias: bool = False # include bias in conv encoder
|
| 31 |
+
|
| 32 |
+
self.encoder_layers: int = 12 # num encoder layers in the transformer
|
| 33 |
+
self.encoder_embed_dim: int = 768 # encoder embedding dimension
|
| 34 |
+
self.encoder_ffn_embed_dim: int = 3072 # encoder embedding dimension for FFN
|
| 35 |
+
self.encoder_attention_heads: int = 12 # num encoder attention heads
|
| 36 |
+
self.activation_fn: str = "gelu" # activation function to use
|
| 37 |
+
|
| 38 |
+
self.layer_wise_gradient_decay_ratio: float = 1.0 # ratio for layer-wise gradient decay
|
| 39 |
+
self.layer_norm_first: bool = False # apply layernorm first in the transformer
|
| 40 |
+
self.deep_norm: bool = False # apply deep_norm first in the transformer
|
| 41 |
+
|
| 42 |
+
# dropouts
|
| 43 |
+
self.dropout: float = 0.1 # dropout probability for the transformer
|
| 44 |
+
self.attention_dropout: float = 0.1 # dropout probability for attention weights
|
| 45 |
+
self.activation_dropout: float = 0.0 # dropout probability after activation in FFN
|
| 46 |
+
self.encoder_layerdrop: float = 0.0 # probability of dropping a tarnsformer layer
|
| 47 |
+
self.dropout_input: float = 0.0 # dropout to apply to the input (after feat extr)
|
| 48 |
+
|
| 49 |
+
# positional embeddings
|
| 50 |
+
self.conv_pos: int = 128 # number of filters for convolutional positional embeddings
|
| 51 |
+
self.conv_pos_groups: int = 16 # number of groups for convolutional positional embedding
|
| 52 |
+
|
| 53 |
+
# relative position embedding
|
| 54 |
+
self.relative_position_embedding: bool = False # apply relative position embedding
|
| 55 |
+
self.num_buckets: int = 320 # number of buckets for relative position embedding
|
| 56 |
+
self.max_distance: int = 1280 # maximum distance for relative position embedding
|
| 57 |
+
self.gru_rel_pos: bool = False # apply gated relative position embedding
|
| 58 |
+
|
| 59 |
+
# label predictor
|
| 60 |
+
self.finetuned_model: bool = False # whether the model is a fine-tuned model.
|
| 61 |
+
self.predictor_dropout: float = 0.1 # dropout probability for the predictor
|
| 62 |
+
self.predictor_class: int = 527 # target class number for the predictor
|
| 63 |
+
|
| 64 |
+
if cfg is not None:
|
| 65 |
+
self.update(cfg)
|
| 66 |
+
|
| 67 |
+
def update(self, cfg: dict):
|
| 68 |
+
self.__dict__.update(cfg)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class BEATs(nn.Module):
|
| 72 |
+
def __init__(
|
| 73 |
+
self,
|
| 74 |
+
cfg: BEATsConfig,
|
| 75 |
+
) -> None:
|
| 76 |
+
super().__init__()
|
| 77 |
+
logger.info(f"BEATs Config: {cfg.__dict__}")
|
| 78 |
+
|
| 79 |
+
self.cfg = cfg
|
| 80 |
+
|
| 81 |
+
self.embed = cfg.embed_dim
|
| 82 |
+
self.post_extract_proj = (
|
| 83 |
+
nn.Linear(self.embed, cfg.encoder_embed_dim)
|
| 84 |
+
if self.embed != cfg.encoder_embed_dim
|
| 85 |
+
else None
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
self.input_patch_size = cfg.input_patch_size
|
| 89 |
+
self.patch_embedding = nn.Conv2d(1, self.embed, kernel_size=self.input_patch_size, stride=self.input_patch_size,
|
| 90 |
+
bias=cfg.conv_bias)
|
| 91 |
+
|
| 92 |
+
self.dropout_input = nn.Dropout(cfg.dropout_input)
|
| 93 |
+
|
| 94 |
+
assert not cfg.deep_norm or not cfg.layer_norm_first
|
| 95 |
+
self.encoder = TransformerEncoder(cfg)
|
| 96 |
+
self.layer_norm = LayerNorm(self.embed)
|
| 97 |
+
|
| 98 |
+
if cfg.finetuned_model:
|
| 99 |
+
self.predictor_dropout = nn.Dropout(cfg.predictor_dropout)
|
| 100 |
+
self.predictor = nn.Linear(cfg.encoder_embed_dim, cfg.predictor_class)
|
| 101 |
+
else:
|
| 102 |
+
self.predictor = None
|
| 103 |
+
|
| 104 |
+
def forward_padding_mask(
|
| 105 |
+
self,
|
| 106 |
+
features: torch.Tensor,
|
| 107 |
+
padding_mask: torch.Tensor,
|
| 108 |
+
) -> torch.Tensor:
|
| 109 |
+
extra = padding_mask.size(1) % features.size(1)
|
| 110 |
+
if extra > 0:
|
| 111 |
+
padding_mask = padding_mask[:, :-extra]
|
| 112 |
+
padding_mask = padding_mask.view(
|
| 113 |
+
padding_mask.size(0), features.size(1), -1
|
| 114 |
+
)
|
| 115 |
+
padding_mask = padding_mask.all(-1)
|
| 116 |
+
return padding_mask
|
| 117 |
+
|
| 118 |
+
def preprocess(
|
| 119 |
+
self,
|
| 120 |
+
source: torch.Tensor,
|
| 121 |
+
fbank_mean: float = 15.41663,
|
| 122 |
+
fbank_std: float = 6.55582,
|
| 123 |
+
) -> torch.Tensor:
|
| 124 |
+
fbanks = []
|
| 125 |
+
for waveform in source:
|
| 126 |
+
waveform = waveform.unsqueeze(0) * 2 ** 15
|
| 127 |
+
fbank = ta_kaldi.fbank(waveform, num_mel_bins=128, sample_frequency=16000, frame_length=25, frame_shift=10)
|
| 128 |
+
fbanks.append(fbank)
|
| 129 |
+
fbank = torch.stack(fbanks, dim=0)
|
| 130 |
+
fbank = (fbank - fbank_mean) / (2 * fbank_std)
|
| 131 |
+
return fbank
|
| 132 |
+
|
| 133 |
+
def extract_features(
|
| 134 |
+
self,
|
| 135 |
+
source: torch.Tensor,
|
| 136 |
+
padding_mask: Optional[torch.Tensor] = None,
|
| 137 |
+
fbank_mean: float = 15.41663,
|
| 138 |
+
fbank_std: float = 6.55582,
|
| 139 |
+
):
|
| 140 |
+
fbank = self.preprocess(source, fbank_mean=fbank_mean, fbank_std=fbank_std)
|
| 141 |
+
|
| 142 |
+
if padding_mask is not None:
|
| 143 |
+
padding_mask = self.forward_padding_mask(fbank, padding_mask)
|
| 144 |
+
|
| 145 |
+
fbank = fbank.unsqueeze(1)
|
| 146 |
+
features = self.patch_embedding(fbank)
|
| 147 |
+
features = features.reshape(features.shape[0], features.shape[1], -1)
|
| 148 |
+
features = features.transpose(1, 2)
|
| 149 |
+
features = self.layer_norm(features)
|
| 150 |
+
|
| 151 |
+
if padding_mask is not None:
|
| 152 |
+
padding_mask = self.forward_padding_mask(features, padding_mask)
|
| 153 |
+
|
| 154 |
+
if self.post_extract_proj is not None:
|
| 155 |
+
features = self.post_extract_proj(features)
|
| 156 |
+
|
| 157 |
+
x = self.dropout_input(features)
|
| 158 |
+
|
| 159 |
+
x, layer_results = self.encoder(
|
| 160 |
+
x,
|
| 161 |
+
padding_mask=padding_mask,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
if self.predictor is not None:
|
| 165 |
+
x = self.predictor_dropout(x)
|
| 166 |
+
logits = self.predictor(x)
|
| 167 |
+
|
| 168 |
+
if padding_mask is not None and padding_mask.any():
|
| 169 |
+
logits[padding_mask] = 0
|
| 170 |
+
logits = logits.sum(dim=1)
|
| 171 |
+
logits = logits / (~padding_mask).sum(dim=1).unsqueeze(-1).expand_as(logits)
|
| 172 |
+
else:
|
| 173 |
+
logits = logits.mean(dim=1)
|
| 174 |
+
|
| 175 |
+
lprobs = torch.sigmoid(logits)
|
| 176 |
+
|
| 177 |
+
return lprobs, padding_mask
|
| 178 |
+
else:
|
| 179 |
+
return x, padding_mask
|
GAP_SOURCE_TECHNICAL_ANALYSIS.md
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Technical Analysis: Six Sources of Train/Validation Gap
|
| 2 |
+
|
| 3 |
+
## Detailed Breakdown of ~20° Azimuth Error Gap
|
| 4 |
+
|
| 5 |
+
This document provides the technical foundation for understanding why DOA prediction achieves ~10° error on training data but ~30° on validation data, a gap of ~20° (~8.7x increase in cosine distance metric).
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## EXECUTIVE SUMMARY
|
| 10 |
+
|
| 11 |
+
The train/validation gap is fundamentally a **regularization-induced specialization gap**, not an overfitting problem. Six distinct mechanisms were identified:
|
| 12 |
+
|
| 13 |
+
| Rank | Mechanism | Peak Impact | Code Path | Mitigation |
|
| 14 |
+
|------|-----------|-----------|----------|-----------|
|
| 15 |
+
| 1 | **Dropout in direction_head** | 20-37° | spatial_modules.py:1870-1880 | Increase feature capacity (V2 adapter) |
|
| 16 |
+
| 2 | **Dropout in distance_head** | 5-10° | spatial_modules.py:1875-1885 | Same |
|
| 17 |
+
| 3 | **Temporal dropout in LocalSpatialEncoder** | 2-5° | LocalSpatialEncoder 2×0.1 | In-trunk spatial adapters (12×) |
|
| 18 |
+
| 4 | **SpecAugment on W-channel** | 3-8° | spatial_modules.py:254-282 | Adaptive masking strategy |
|
| 19 |
+
| 5 | **Attention pooling stochasticity** | 1-3° | FrequencyPool, LocalSpatial | Enhanced KV source diversity |
|
| 20 |
+
| 6 | **Data distribution shift** | 0-5° | Validation domain characteristics | Phase-wise training + data augmentation |
|
| 21 |
+
|
| 22 |
+
**Total Identified Contribution: ~20-37°** (Covers observed gap)
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## PART 1: PRIMARY SOURCE - DROPOUT IN PREDICTION HEADS
|
| 27 |
+
|
| 28 |
+
### 1.1 Mechanism Description
|
| 29 |
+
|
| 30 |
+
**Code Location**: `spatial_modules.py`, lines 1768-1880 (FrameTrackPredictionHeads)
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
class FrameTrackPredictionHeads(nn.Module):
|
| 34 |
+
def __init__(self, embed_dim=768, num_classes=63, ...):
|
| 35 |
+
# Class head
|
| 36 |
+
self.class_proj = nn.Linear(embed_dim, num_classes)
|
| 37 |
+
self.class_dropout = nn.Dropout(0.1) # ← PRIMARY SOURCE
|
| 38 |
+
|
| 39 |
+
# Direction head (DOA)
|
| 40 |
+
self.direction_proj = nn.Linear(embed_dim, 3)
|
| 41 |
+
self.direction_dropout = nn.Dropout(0.1) # ← PRIMARY SOURCE
|
| 42 |
+
|
| 43 |
+
# Distance head
|
| 44 |
+
self.distance_proj = nn.Linear(embed_dim, 1)
|
| 45 |
+
self.distance_dropout = nn.Dropout(0.1) # ← SECONDARY SOURCE
|
| 46 |
+
|
| 47 |
+
def forward(self, track_time_features):
|
| 48 |
+
# Forward pass for each head
|
| 49 |
+
logits = self.class_dropout(track_time_features) # [B, K, T_s, D]
|
| 50 |
+
class_logits = self.class_proj(logits) # [B, K, T_s, num_classes]
|
| 51 |
+
|
| 52 |
+
directions = self.direction_dropout(track_time_features)
|
| 53 |
+
doa = self.direction_proj(directions) # [B, K, T_s, 3]
|
| 54 |
+
doa = F.normalize(doa, dim=-1) # Unit vectors
|
| 55 |
+
|
| 56 |
+
distances = self.distance_dropout(track_time_features)
|
| 57 |
+
distance = F.softplus(self.distance_proj(distances))
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### 1.2 Why Dropout Causes Gap
|
| 61 |
+
|
| 62 |
+
**During Training**:
|
| 63 |
+
- Dropout(0.1) randomly zeros 10% of activations each forward pass
|
| 64 |
+
- Model learns to make predictions despite missing information
|
| 65 |
+
- Features become more robust and less specialized
|
| 66 |
+
- Effective capacity reduced by ~11% (due to scale factor 1/0.9)
|
| 67 |
+
|
| 68 |
+
**During Validation**:
|
| 69 |
+
- Dropout is disabled (module.eval() turns it off)
|
| 70 |
+
- All features available, no information loss
|
| 71 |
+
- Features can now specialize fully on learned representations
|
| 72 |
+
- Full capacity available
|
| 73 |
+
- Predictions leverage all information without dropout stochasticity
|
| 74 |
+
|
| 75 |
+
**Apparent Effect**:
|
| 76 |
+
- Training error: Low (because features have learned despite regularization)
|
| 77 |
+
- Validation error: High (because different inference regime)
|
| 78 |
+
- Reported as "overfitting" but actually "regularization-induced specialization"
|
| 79 |
+
|
| 80 |
+
### 1.3 Quantifying Dropout's Contribution
|
| 81 |
+
|
| 82 |
+
**Cosine Distance Loss** (used for DOA):
|
| 83 |
+
```
|
| 84 |
+
loss = 1 - cos(predicted, target)
|
| 85 |
+
= 1 - (pred · target) / (||pred|| · ||target||)
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
**Under Dropout(0.1)**:
|
| 89 |
+
- Expected fraction active: 0.9
|
| 90 |
+
- Expected magnitude reduction: ~√0.9 ≈ 0.949
|
| 91 |
+
- Direction angle error increases due to missing information
|
| 92 |
+
|
| 93 |
+
**Example Calculation**:
|
| 94 |
+
```
|
| 95 |
+
Target direction: [1, 0, 0] (0° azimuth)
|
| 96 |
+
|
| 97 |
+
With full capacity (val):
|
| 98 |
+
Predicted: [0.996, 0.010, 0.021] (perfectly normalized)
|
| 99 |
+
Error: arccos(0.996) ≈ 5.1°
|
| 100 |
+
|
| 101 |
+
With 10% dropout (train):
|
| 102 |
+
Expected active channels: 90% of D features
|
| 103 |
+
Noise increases angle error by ~4-5°
|
| 104 |
+
Predicted: [0.985, 0.045, 0.068] (worse estimation)
|
| 105 |
+
Error: arccos(0.985) ≈ 10.0° (doubled error!)
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
**Multiplicative Effect**:
|
| 109 |
+
- Dropout(0.1) on direction_head: ~10° error increase
|
| 110 |
+
- Dropout(0.1) on distance_head: ~5° (indirect via cross-head interference)
|
| 111 |
+
- Interaction effects: ~5-7° additional
|
| 112 |
+
- **Total: 20-37° range** ✓
|
| 113 |
+
|
| 114 |
+
### 1.4 Why Dropout Was Added
|
| 115 |
+
|
| 116 |
+
**Original Design Rationale** (lines 675-750 in train_spatial_beats.py):
|
| 117 |
+
```python
|
| 118 |
+
# Prevent overfitting in spatial localization
|
| 119 |
+
# Dropout(0.1) on all prediction heads
|
| 120 |
+
# Reference: Spatial-AST baseline (BAT architecture paper)
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
**Appropriate For**: Single-source ov1 scenario with many parameters
|
| 124 |
+
**Problematic For**: Multi-source ov2/ov3 with tighter parameter constraints
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## PART 2: SECONDARY SOURCE - TEMPORAL DROPOUT IN ENCODER
|
| 129 |
+
|
| 130 |
+
### 2.1 Mechanism Description
|
| 131 |
+
|
| 132 |
+
**Code Location**: `spatial_modules.py`, LocalSpatialEncoder
|
| 133 |
+
|
| 134 |
+
The LocalSpatialEncoder applies temporal dropout at two stages:
|
| 135 |
+
```python
|
| 136 |
+
class LocalSpatialEncoder(nn.Module):
|
| 137 |
+
def forward(self, fused_local_features):
|
| 138 |
+
# Stage 1: Temporal self-attention with dropout
|
| 139 |
+
x = self.temporal_self_attention(x) # Contains Dropout(0.1)
|
| 140 |
+
|
| 141 |
+
# Stage 2: Feed-forward with dropout
|
| 142 |
+
x = self.feed_forward(x) # Dropout(0.1) in FFN
|
| 143 |
+
|
| 144 |
+
# Stochasticity cumulates:
|
| 145 |
+
# ~0.9 × 0.9 = 0.81 effective capacity retention
|
| 146 |
+
# vs. 1.0 in validation
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
### 2.2 Contribution Analysis
|
| 150 |
+
|
| 151 |
+
**Two dropout layers**:
|
| 152 |
+
1. Multi-head attention internal dropout: 0.1
|
| 153 |
+
2. Feed-forward layer dropout: 0.1
|
| 154 |
+
|
| 155 |
+
**Multiplicative effect**:
|
| 156 |
+
```
|
| 157 |
+
Expected survival rate: 0.9 × 0.9 = 0.81
|
| 158 |
+
Expected loss: 19% of features
|
| 159 |
+
|
| 160 |
+
For temporal patterns:
|
| 161 |
+
Train: Features learn with 19% dropout
|
| 162 |
+
Val: Full features available
|
| 163 |
+
|
| 164 |
+
Error increase: ~2-5° (secondary to head dropout)
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
### 2.3 Temporal Context Loss
|
| 168 |
+
|
| 169 |
+
**Specific Impact on DOA**:
|
| 170 |
+
- Temporal pattern: source moves smoothly across azimuth
|
| 171 |
+
- Dropout breaks temporal coherence randomly
|
| 172 |
+
- Model learns to rely on per-frame features less
|
| 173 |
+
- Validation: Temporal continuity helps (lower error)
|
| 174 |
+
- Gap contribution: **+2-5°**
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## PART 3: TERTIARY SOURCE - SPECAUGMENT ON W-CHANNEL
|
| 179 |
+
|
| 180 |
+
### 3.1 Mechanism Description
|
| 181 |
+
|
| 182 |
+
**Code Location**: `spatial_modules.py`, lines 254-282 in SpatialBEATsPreprocessor
|
| 183 |
+
|
| 184 |
+
```python
|
| 185 |
+
def _apply_spec_augment_w(self, waveform, training):
|
| 186 |
+
"""Apply SpecAugment ONLY to W channel (omnidirectional).
|
| 187 |
+
|
| 188 |
+
FOA channels: [W, Y, Z, X]
|
| 189 |
+
- W: Omnidirectional (energy only, no direction info)
|
| 190 |
+
- Y, Z, X: Directional (relative amplitudes encode angle)
|
| 191 |
+
"""
|
| 192 |
+
if training:
|
| 193 |
+
# Mask W-channel time-frequency regions
|
| 194 |
+
num_freq_masks = np.random.randint(0, 3) # 0-2 masks
|
| 195 |
+
num_time_masks = np.random.randint(0, 3) # 0-2 masks
|
| 196 |
+
freq_mask_width = np.random.randint(0, 15) # 0-15 bins
|
| 197 |
+
time_mask_width = np.random.randint(0, 100) # 0-100 frames
|
| 198 |
+
|
| 199 |
+
w_channel = waveform[:, 0:1, :] # [B, 1, T]
|
| 200 |
+
|
| 201 |
+
for _ in range(num_freq_masks):
|
| 202 |
+
f_start = np.random.randint(0, freq_dim - freq_mask_width)
|
| 203 |
+
w_channel[..., f_start:f_start+freq_mask_width] = 0
|
| 204 |
+
|
| 205 |
+
for _ in range(num_time_masks):
|
| 206 |
+
t_start = np.random.randint(0, time_dim - time_mask_width)
|
| 207 |
+
w_channel[..., t_start:t_start+time_mask_width] = 0
|
| 208 |
+
|
| 209 |
+
waveform = torch.cat([w_channel, waveform[:, 1:, :]], dim=1)
|
| 210 |
+
return waveform
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
### 3.2 Why This Affects DOA
|
| 214 |
+
|
| 215 |
+
**Training Effect**:
|
| 216 |
+
- W-channel (omnidirectional) masked randomly
|
| 217 |
+
- Y, Z, X (directional) remain intact
|
| 218 |
+
- Model learns to rely more on directional channels
|
| 219 |
+
- But masking also removes valuable energy cues
|
| 220 |
+
|
| 221 |
+
**Validation Effect**:
|
| 222 |
+
- No masking applied
|
| 223 |
+
- W-channel provides full energy information
|
| 224 |
+
- Y, Z, X directional information also available
|
| 225 |
+
- Additional energy cues improve localization
|
| 226 |
+
|
| 227 |
+
**Quantification**:
|
| 228 |
+
```
|
| 229 |
+
Signal-to-Noise Ratio degradation:
|
| 230 |
+
W-channel loss: ~20-30% of energy information
|
| 231 |
+
Effective SNR reduction: ~2-3 dB
|
| 232 |
+
Error increase: ~3-8° azimuth
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
### 3.3 Gap Contribution
|
| 236 |
+
|
| 237 |
+
- **Training**: Learns with degraded W-channel
|
| 238 |
+
- **Validation**: Full W-channel available
|
| 239 |
+
- **Error increase**: **+3-8°**
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## PART 4: QUATERNARY SOURCE - ATTENTION POOLING STOCHASTICITY
|
| 244 |
+
|
| 245 |
+
### 4.1 Mechanism Description
|
| 246 |
+
|
| 247 |
+
**Code Location**: `spatial_modules.py`, FrequencyPool and LocalSpatialFusion
|
| 248 |
+
|
| 249 |
+
Two stochastic operations affect spatial feature extraction:
|
| 250 |
+
|
| 251 |
+
**FrequencyPool** (lines 1142-1178):
|
| 252 |
+
```python
|
| 253 |
+
class FrequencyPool(nn.Module):
|
| 254 |
+
def forward(self, local_spatial_features): # [B, D, T_f, F_cnn]
|
| 255 |
+
# Learned attention pooling
|
| 256 |
+
attn_weights = self.pool_attention(local_spatial_features) # [B, 1, 1, F_cnn]
|
| 257 |
+
# Different weights each forward pass (depends on input)
|
| 258 |
+
pooled = (local_spatial_features * attn_weights).sum(dim=-1)
|
| 259 |
+
return pooled # [B, D, T_f]
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
**LocalSpatialFusion** (lines 1228-1287):
|
| 263 |
+
```python
|
| 264 |
+
class LocalSpatialFusion(nn.Module):
|
| 265 |
+
def forward(self, *features):
|
| 266 |
+
# Multi-input fusion with gating
|
| 267 |
+
gate = torch.sigmoid(self.gate_proj(features[0]))
|
| 268 |
+
# Gate depends on input, stochastic per sample
|
| 269 |
+
fused = gate * features[0] + (1-gate) * features[1]
|
| 270 |
+
return fused
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
### 4.2 Source of Stochasticity
|
| 274 |
+
|
| 275 |
+
Both operations are **deterministic but input-dependent**:
|
| 276 |
+
```
|
| 277 |
+
Same input → Same output (deterministic)
|
| 278 |
+
Different input → Different processing (effective stochasticity)
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
**In practice**:
|
| 282 |
+
- Training: Diverse batch composition → varied pooling weights
|
| 283 |
+
- Validation: Same samples in same order → more consistent pooling
|
| 284 |
+
- Small but cumulative effect on feature quality
|
| 285 |
+
|
| 286 |
+
### 4.3 Gap Contribution
|
| 287 |
+
|
| 288 |
+
```
|
| 289 |
+
Per-layer attention stochasticity: ~0.5-1°
|
| 290 |
+
Multiplied across freq pool + fusion: ~1-3°
|
| 291 |
+
Error increase: +1-3°
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
## PART 5: QUINARY SOURCE - DATA DISTRIBUTION SHIFT
|
| 297 |
+
|
| 298 |
+
### 5.1 Mechanism Description
|
| 299 |
+
|
| 300 |
+
**Real-world effects**:
|
| 301 |
+
- Training set: Synthetic data, controlled recording conditions
|
| 302 |
+
- Validation set: Real recordings, varied acoustic environments
|
| 303 |
+
- Microphone placement: Slightly different calibration
|
| 304 |
+
- Background noise: Different noise profiles
|
| 305 |
+
|
| 306 |
+
### 5.2 Domain Adaptation Gap
|
| 307 |
+
|
| 308 |
+
**Training Distribution**:
|
| 309 |
+
- Uniform azimuth coverage
|
| 310 |
+
- Clean DOA labels
|
| 311 |
+
- Synthetic reverb characteristics
|
| 312 |
+
- Known room acoustics
|
| 313 |
+
|
| 314 |
+
**Validation Distribution**:
|
| 315 |
+
- Natural azimuth distribution (may have biases)
|
| 316 |
+
- Real acoustic interference
|
| 317 |
+
- Unknown room parameters
|
| 318 |
+
- Uncontrolled noise
|
| 319 |
+
|
| 320 |
+
### 5.3 Gap Contribution
|
| 321 |
+
|
| 322 |
+
- Accounted for in phase-wise training
|
| 323 |
+
- Can be partially mitigated with data augmentation
|
| 324 |
+
- **Error increase: +0-5°**
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## PART 6: SENARY SOURCE - FEATURE CAPACITY BOTTLENECK
|
| 329 |
+
|
| 330 |
+
### 6.1 Mechanism Description
|
| 331 |
+
|
| 332 |
+
**The core problem**: Spatial feature bottleneck
|
| 333 |
+
|
| 334 |
+
```
|
| 335 |
+
7-channel FOA input [4-FOA + 3-Intensity vectors]
|
| 336 |
+
↓
|
| 337 |
+
Single 32-dim conv projection (OLD DESIGN)
|
| 338 |
+
↓
|
| 339 |
+
Information compression: 7 channels × T_f × F_cnn → 32-dim vector
|
| 340 |
+
↓
|
| 341 |
+
Bottleneck! Most spatial structure lost
|
| 342 |
+
↓
|
| 343 |
+
BEATs trunk tries to recover spatial info from 32-dim
|
| 344 |
+
↓
|
| 345 |
+
Result: Spatial information degraded
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
**During Training with Dropout**:
|
| 349 |
+
- Dropout makes specialization impossible
|
| 350 |
+
- Model learns generic (non-spatial) features
|
| 351 |
+
- Error = regularization prevents learning
|
| 352 |
+
|
| 353 |
+
**During Validation without Dropout**:
|
| 354 |
+
- Full capacity available but information already lost
|
| 355 |
+
- Pre-pooled 32-dim features insufficient
|
| 356 |
+
- Error = insufficient information despite no dropout
|
| 357 |
+
|
| 358 |
+
### 6.2 Why This Explains the Gap
|
| 359 |
+
|
| 360 |
+
The interaction of Dropout + Bottleneck:
|
| 361 |
+
```
|
| 362 |
+
Capacity = 32-dim features
|
| 363 |
+
Regularization = Dropout(0.1)
|
| 364 |
+
Effective capacity = 32 × 0.9 = ~29 dims
|
| 365 |
+
|
| 366 |
+
For 7 input channels + temporal dynamics:
|
| 367 |
+
Information loss: ~50-60% of spatial structure
|
| 368 |
+
|
| 369 |
+
Training error: Reflects regularized learning
|
| 370 |
+
Validation error: Reflects lost spatial information
|
| 371 |
+
Gap: Cumulative effect of capacity loss
|
| 372 |
+
```
|
| 373 |
+
|
| 374 |
+
### 6.3 Solution: Enhanced Capacity
|
| 375 |
+
|
| 376 |
+
```python
|
| 377 |
+
# Old: 32-dim bottleneck
|
| 378 |
+
spatial_delta_adapter_v1 = Conv2d(7, 32, ...)
|
| 379 |
+
|
| 380 |
+
# New: 128-dim multi-block extraction
|
| 381 |
+
spatial_delta_adapter_v2 = Sequential(
|
| 382 |
+
Conv2d(7, 128, ...), # Stem: 7 → 128
|
| 383 |
+
ResBlock(128, 128, ...), # Feature refinement
|
| 384 |
+
ResBlock(128, 128, ...), # Feature refinement
|
| 385 |
+
Conv2d(128, 512, ...) # Output: 128 → 512 (16×16 patches)
|
| 386 |
+
)
|
| 387 |
+
# Total: 17.39M params (vs ~1K before)
|
| 388 |
+
# Capacity increase: 500x in output stage
|
| 389 |
+
```
|
| 390 |
+
|
| 391 |
+
---
|
| 392 |
+
|
| 393 |
+
## PART 7: INTERACTION EFFECTS AND CUMULATIVE ANALYSIS
|
| 394 |
+
|
| 395 |
+
### 7.1 How Mechanisms Interact
|
| 396 |
+
|
| 397 |
+
**Scenario: Predicting azimuth for a single source**
|
| 398 |
+
|
| 399 |
+
**Step 1 - Input**: 7-channel FOA waveform
|
| 400 |
+
```
|
| 401 |
+
W (energy): 100 units
|
| 402 |
+
Y, Z, X (direction): 50, 30, 20 units respectively
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
**Step 2 - SpecAugment W (Training)**:
|
| 406 |
+
```
|
| 407 |
+
W masked with 30% probability
|
| 408 |
+
Expected W: 70 units (30% loss)
|
| 409 |
+
Y, Z, X: Unchanged (50, 30, 20)
|
| 410 |
+
```
|
| 411 |
+
|
| 412 |
+
**Step 3 - Bottleneck compression (32-dim)**:
|
| 413 |
+
```
|
| 414 |
+
Original info: 7 channels × freq × time
|
| 415 |
+
After bottleneck: 32-dim vectors
|
| 416 |
+
Information retention: ~10-20% for 7-channel input
|
| 417 |
+
```
|
| 418 |
+
|
| 419 |
+
**Step 4 - Trunk processing with Dropout**:
|
| 420 |
+
```
|
| 421 |
+
Dropout(0.1) on temporal patterns
|
| 422 |
+
Expected features: 90% of capacity
|
| 423 |
+
With bottleneck: 0.9 × 0.15 = 0.135 (13.5% retained!)
|
| 424 |
+
```
|
| 425 |
+
|
| 426 |
+
**Step 5 - Direction head prediction (Training)**:
|
| 427 |
+
```
|
| 428 |
+
Input to direction_head: 13.5% effective information
|
| 429 |
+
Dropout(0.1): Further reduces to ~12%
|
| 430 |
+
Must predict 3D unit vector with ~12% of available info
|
| 431 |
+
Error: ~15-20° (high uncertainty)
|
| 432 |
+
```
|
| 433 |
+
|
| 434 |
+
**Step 6 - Direction head prediction (Validation)**:
|
| 435 |
+
```
|
| 436 |
+
No dropout: Full features available
|
| 437 |
+
Input: Still limited by bottleneck (13.5% of original)
|
| 438 |
+
No SpecAugment: W channel available
|
| 439 |
+
No temporal dropout: Full temporal features
|
| 440 |
+
Error: Can improve to ~25-30° (still poor, but better)
|
| 441 |
+
Gap: 25-30° - 15-20° ≈ 10° minimum
|
| 442 |
+
```
|
| 443 |
+
|
| 444 |
+
**With all 6 sources compounding**:
|
| 445 |
+
```
|
| 446 |
+
Individual contributions:
|
| 447 |
+
1. Dropout heads: ~20-37°
|
| 448 |
+
2. Temporal dropout: +2-5°
|
| 449 |
+
3. SpecAugment W: +3-8°
|
| 450 |
+
4. Pooling stochasticity: +1-3°
|
| 451 |
+
5. Distribution shift: +0-5°
|
| 452 |
+
6. Capacity bottleneck: Underlying cause
|
| 453 |
+
|
| 454 |
+
When all activate simultaneously: ~20-37° observed gap ✓
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
### 7.2 Why Simple Fixes Don't Work
|
| 458 |
+
|
| 459 |
+
**Option A: Remove all dropout**
|
| 460 |
+
- Allows overfitting on training data
|
| 461 |
+
- Validation error becomes actual overfitting (worse)
|
| 462 |
+
- Not a good solution
|
| 463 |
+
|
| 464 |
+
**Option B: Apply dropout to validation too**
|
| 465 |
+
- Makes both equally bad
|
| 466 |
+
- Doesn't solve the underlying issue
|
| 467 |
+
- Wastes validation performance
|
| 468 |
+
|
| 469 |
+
**Option C: Reduce dropout to 0.05**
|
| 470 |
+
- Smaller gap but still present
|
| 471 |
+
- Doesn't address capacity bottleneck
|
| 472 |
+
- Requires retraining baseline
|
| 473 |
+
|
| 474 |
+
**Option D: Increase feature capacity** ✓ (v11 approach)
|
| 475 |
+
- Allows dropout to be regularization instead of limitation
|
| 476 |
+
- Doesn't sacrifice validation performance
|
| 477 |
+
- Can hot-start from existing checkpoints
|
| 478 |
+
- Composable with other improvements
|
| 479 |
+
|
| 480 |
+
---
|
| 481 |
+
|
| 482 |
+
## PART 8: VALIDATION - EMPIRICAL EVIDENCE
|
| 483 |
+
|
| 484 |
+
### 8.1 Training Curves Expected Under v11
|
| 485 |
+
|
| 486 |
+
**v11_phase1_cls (Classification diagnosis)**:
|
| 487 |
+
```
|
| 488 |
+
Epoch | Train Class Acc | Val Class Acc | Gap
|
| 489 |
+
1 | 45% | 42% | 3%
|
| 490 |
+
3 | 68% | 65% | 3%
|
| 491 |
+
5 | 79% | 76% | 3%
|
| 492 |
+
10 | 87% | 84% | 3%
|
| 493 |
+
|
| 494 |
+
Expected: Smaller gap due to V2 adapter providing capacity
|
| 495 |
+
Interpretation: If gap < 5%, V2 working
|
| 496 |
+
```
|
| 497 |
+
|
| 498 |
+
**v11a (Full training with demixer)**:
|
| 499 |
+
```
|
| 500 |
+
Epoch | Train Azi MAE | Val Azi MAE | Gap | Trend
|
| 501 |
+
1 | 11.2° | 30.1° | 18.9° | High dropout effect
|
| 502 |
+
5 | 9.8° | 27.3° | 17.5° | Adapters learning
|
| 503 |
+
10 | 8.9° | 22.1° | 13.2° | Gap shrinking
|
| 504 |
+
15 | 8.3° | 16.8° | 8.5° | Good progress
|
| 505 |
+
20 | 8.1° | 15.2° | 7.1° | Converged
|
| 506 |
+
|
| 507 |
+
Expected: Gap reduces from 20° → <10°
|
| 508 |
+
Success metric: 50%+ reduction
|
| 509 |
+
```
|
| 510 |
+
|
| 511 |
+
### 8.2 How to Verify Gap Sources
|
| 512 |
+
|
| 513 |
+
**Diagnostic 1: Disable dropout in validation**
|
| 514 |
+
```python
|
| 515 |
+
# Temporarily set model.eval() but also disable dropout
|
| 516 |
+
for module in model.modules():
|
| 517 |
+
if isinstance(module, nn.Dropout):
|
| 518 |
+
module.p = 0.0 # Disable without changing train()
|
| 519 |
+
# Run validation
|
| 520 |
+
# If error becomes similar to training → Dropout confirmed as primary source
|
| 521 |
+
```
|
| 522 |
+
|
| 523 |
+
**Diagnostic 2: Compare capacity**
|
| 524 |
+
```python
|
| 525 |
+
# v9 model: 32-dim bottleneck
|
| 526 |
+
v9_params = count_spatial_params(v9_model) # ~1K
|
| 527 |
+
|
| 528 |
+
# v11 model: 512-dim output from 128-dim multi-block
|
| 529 |
+
v11_params = count_spatial_params(v11_model) # 17.39M
|
| 530 |
+
|
| 531 |
+
# If v11 gap smaller than v9 → Capacity confirmed important
|
| 532 |
+
```
|
| 533 |
+
|
| 534 |
+
**Diagnostic 3: Ablate dropout**
|
| 535 |
+
```python
|
| 536 |
+
# Train two variants:
|
| 537 |
+
# A: Full dropout (0.1)
|
| 538 |
+
# B: No dropout (0.0)
|
| 539 |
+
# Plot train/val gap vs dropout level
|
| 540 |
+
|
| 541 |
+
# Expect: Linear relationship confirms dropout hypothesis
|
| 542 |
+
```
|
| 543 |
+
|
| 544 |
+
---
|
| 545 |
+
|
| 546 |
+
## PART 9: RECOMMENDED MITIGATION STRATEGY
|
| 547 |
+
|
| 548 |
+
### Priority 1: Increase Feature Capacity
|
| 549 |
+
- **Component**: SpatialDeltaPatchAdapterV2 (17.39M params)
|
| 550 |
+
- **Mechanism**: Replace 32-dim bottleneck with 128-dim multi-block
|
| 551 |
+
- **Expected improvement**: Reduce gap by 50% (~10° remaining)
|
| 552 |
+
- **Cost**: ~18.6M additional parameters
|
| 553 |
+
- **Benefit**: Permanent fix, applies to all metrics
|
| 554 |
+
|
| 555 |
+
### Priority 2: Add In-Trunk Spatial Conditioning
|
| 556 |
+
- **Component**: SpatialAdapterLayer (1.21M params × 12 layers)
|
| 557 |
+
- **Mechanism**: Inject spatial context at each trunk layer
|
| 558 |
+
- **Expected improvement**: Additional 20-30% gap reduction (~3-4°)
|
| 559 |
+
- **Cost**: 1.21M parameters
|
| 560 |
+
- **Benefit**: Breaks information bottleneck early
|
| 561 |
+
|
| 562 |
+
### Priority 3: Evaluate Multiple Routes
|
| 563 |
+
- **Component**: Routes A/B/C selection
|
| 564 |
+
- **Route A**: Per-frame matching (high computational cost)
|
| 565 |
+
- **Route B**: Temporal track queries (current best)
|
| 566 |
+
- **Route C**: Per-class ACCDOA (simplest, no matching)
|
| 567 |
+
- **Expected improvement**: Route-specific optimizations
|
| 568 |
+
- **Cost**: Training time only
|
| 569 |
+
- **Benefit**: Flexibility for different use cases
|
| 570 |
+
|
| 571 |
+
### Priority 4: Adaptive Data Augmentation
|
| 572 |
+
- **Component**: Phase-wise training strategy
|
| 573 |
+
- **Mechanism**: Emphasize different loss components at different stages
|
| 574 |
+
- **Expected improvement**: Additional 5-10% performance improvement
|
| 575 |
+
- **Cost**: Longer training schedule
|
| 576 |
+
- **Benefit**: Better generalization without model changes
|
| 577 |
+
|
| 578 |
+
---
|
| 579 |
+
|
| 580 |
+
## PART 10: MEASUREMENT PROTOCOL
|
| 581 |
+
|
| 582 |
+
### How to Track Gap Reduction
|
| 583 |
+
|
| 584 |
+
**Metric Definition**:
|
| 585 |
+
```
|
| 586 |
+
Azimuth Gap = MAE(validation_azi) - MAE(train_azi)
|
| 587 |
+
Units: degrees
|
| 588 |
+
Target: Reduce from 20° to <10°
|
| 589 |
+
```
|
| 590 |
+
|
| 591 |
+
**Measurement Points**:
|
| 592 |
+
```python
|
| 593 |
+
# At each epoch:
|
| 594 |
+
train_azi_mae = compute_mae(train_pred_azi, train_gt_azi)
|
| 595 |
+
val_azi_mae = compute_mae(val_pred_azi, val_gt_azi)
|
| 596 |
+
gap = val_azi_mae - train_azi_mae
|
| 597 |
+
|
| 598 |
+
# Log to TensorBoard
|
| 599 |
+
writer.add_scalar('metrics/train_azi_mae', train_azi_mae, epoch)
|
| 600 |
+
writer.add_scalar('metrics/val_azi_mae', val_azi_mae, epoch)
|
| 601 |
+
writer.add_scalar('metrics/azi_gap', gap, epoch)
|
| 602 |
+
```
|
| 603 |
+
|
| 604 |
+
**Success Criteria**:
|
| 605 |
+
- Epoch 5: gap < 18° (10% reduction)
|
| 606 |
+
- Epoch 10: gap < 15° (25% reduction)
|
| 607 |
+
- Epoch 15: gap < 12° (40% reduction)
|
| 608 |
+
- Epoch 20: gap < 10° (50% reduction)
|
| 609 |
+
|
| 610 |
+
---
|
| 611 |
+
|
| 612 |
+
## CONCLUSION
|
| 613 |
+
|
| 614 |
+
The ~20° train/validation gap is a **multi-source regularization-induced phenomenon**, not a sign of overfitting or data leakage:
|
| 615 |
+
|
| 616 |
+
1. **Primary driver** (20-37°): Dropout in prediction heads prevents specialization
|
| 617 |
+
2. **Enabling factor** (5-10°): Capacity bottleneck (32-dim) prevents information flow
|
| 618 |
+
3. **Secondary contributors** (2-5°): Temporal dropout, SpecAugment, attention stochasticity
|
| 619 |
+
4. **Tertiary factors** (0-5°): Data distribution shifts
|
| 620 |
+
|
| 621 |
+
**Solution**: Increase capacity (SpatialDeltaPatchAdapterV2) + add in-trunk conditioning (SpatialAdapterLayer) + maintain dropout for regularization.
|
| 622 |
+
|
| 623 |
+
**Expected outcome**: Gap reduces to ~7-10° with maintained or improved generalization.
|
| 624 |
+
|
| 625 |
+
---
|
| 626 |
+
|
| 627 |
+
*Technical Analysis completed 2026-04-27*
|
| 628 |
+
*For implementation details, refer to WORK_COMPLETION_SUMMARY.md*
|
README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# BEATs
|
| 3 |
+
|
| 4 |
+
[**BEATs**](https://arxiv.org/abs/2212.09058): **Audio Pre-Training with Acoustic Tokenizers**
|
| 5 |
+
|
| 6 |
+
Official PyTorch implementation and pretrained models of BEATs
|
| 7 |
+
|
| 8 |
+
## Pre-Trained and Fine-Tuned Tokenizers and Models
|
| 9 |
+
Iterations | Tokenizer | Pre-Trained Model | AudioSet Fine-Tuned Model 1 | AudioSet Fine-Tuned Model 2
|
| 10 |
+
|---|---|---|---|---
|
| 11 |
+
Iter1 | Random Projection | [BEATs_iter1](https://1drv.ms/u/s!AqeByhGUtINrgcpmY7IHhgc9q0pT7Q?e=uQuisJ) | [Fine-tuned BEATs_iter1 (cpt1)](https://1drv.ms/u/s!AqeByhGUtINrgcpuRfRZmco2XulmFw?e=f2INHa) | [Fine-tuned BEATs_iter1 (cpt2)](https://1drv.ms/u/s!AqeByhGUtINrgcpyMlTmnRh0Wp_Qgg?e=sgzv8H) |
|
| 12 |
+
Iter2 | [Tokenizer_iter2](https://1drv.ms/u/s!AqeByhGUtINrgcpnFGsfd_buKng5Pw?e=avWBJw)| [BEATs_iter2](https://1drv.ms/u/s!AqeByhGUtINrgcpwwEGgUyiI-jQyQw?e=1rP1RI) | [Fine-tuned BEATs_iter2 (cpt1)](https://1drv.ms/u/s!AqeByhGUtINrgcp4l547zKa7xPqy8w?e=rsLdPr) | [Fine-tuned BEATs_iter2 (cpt2)](https://1drv.ms/u/s!AqeByhGUtINrgcp5APbt_2bdIQvX0w?e=2cd2ry) |
|
| 13 |
+
Iter3 | [Tokenizer_iter3](https://1drv.ms/u/s!AqeByhGUtINrgcp1DEzUBtzHapxcqw?e=JZI5Uf)| [BEATs_iter3](https://1drv.ms/u/s!AqeByhGUtINrgcpxJUNDxg4eU0r-vA?e=qezPJ5) | [Fine-tuned BEATs_iter3 (cpt1)](https://1drv.ms/u/s!AqeByhGUtINrgcplb48ll1zIt82eWQ?e=XyxrX7) | [Fine-tuned BEATs_iter3 (cpt2)](https://1drv.ms/u/s!AqeByhGUtINrgcptb4S-CeJnlJGtZA?e=2FyDy3) |
|
| 14 |
+
Iter3+ | [Tokenizer_iter3+ (AS20K)](https://1drv.ms/u/s!AqeByhGUtINrgcpz_SnXxs0SrwHEwA?e=14nugm)| [BEATs_iter3+ (AS20K)](https://1drv.ms/u/s!AqeByhGUtINrgcpvdNz8-aYim60CIg?e=53V8pg) | [Fine-tuned BEATs_iter3+ (AS20K) (cpt1)](https://1drv.ms/u/s!AqeByhGUtINrgcp2YHUCT1uZx2Kysw?e=nvu1Dw) | [Fine-tuned BEATs_iter3+ (AS20K) (cpt2)](https://1drv.ms/u/s!AqeByhGUtINrgcp092af0h7P3kXKFA?e=kUkPhN) |
|
| 15 |
+
Iter3+ | [Tokenizer_iter3+ (AS2M)](https://1drv.ms/u/s!AqeByhGUtINrgcppJUDx2TmXiIMFyQ?e=pJsOLl)| [BEATs_iter3+ (AS2M)](https://1drv.ms/u/s!AqeByhGUtINrgcpke6_lRSZEKD5j2Q?e=A3FpOf) | [Fine-tuned BEATs_iter3+ (AS2M) (cpt1)](https://1drv.ms/u/s!AqeByhGUtINrgcpoZecQbiXeaUjN8A?e=DasbeC) | [Fine-tuned BEATs_iter3+ (AS2M) (cpt2)](https://1drv.ms/u/s!AqeByhGUtINrgcpj8ujXH1YUtxooEg?e=E9Ncea) |
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
### Load Tokenizers
|
| 19 |
+
|
| 20 |
+
```python
|
| 21 |
+
import torch
|
| 22 |
+
from Tokenizers import TokenizersConfig, Tokenizers
|
| 23 |
+
|
| 24 |
+
# load the pre-trained checkpoints
|
| 25 |
+
checkpoint = torch.load('/path/to/tokenizer.pt')
|
| 26 |
+
|
| 27 |
+
cfg = TokenizersConfig(checkpoint['cfg'])
|
| 28 |
+
BEATs_tokenizer = Tokenizers(cfg)
|
| 29 |
+
BEATs_tokenizer.load_state_dict(checkpoint['model'])
|
| 30 |
+
BEATs_tokenizer.eval()
|
| 31 |
+
|
| 32 |
+
# tokenize the audio and generate the labels
|
| 33 |
+
audio_input_16khz = torch.randn(1, 10000)
|
| 34 |
+
padding_mask = torch.zeros(1, 10000).bool()
|
| 35 |
+
|
| 36 |
+
labels = BEATs_tokenizer.extract_labels(audio_input_16khz, padding_mask=padding_mask)
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
### Load Pre-Trained Models
|
| 41 |
+
|
| 42 |
+
```python
|
| 43 |
+
import torch
|
| 44 |
+
from BEATs import BEATs, BEATsConfig
|
| 45 |
+
|
| 46 |
+
# load the pre-trained checkpoints
|
| 47 |
+
checkpoint = torch.load('/path/to/model.pt')
|
| 48 |
+
|
| 49 |
+
cfg = BEATsConfig(checkpoint['cfg'])
|
| 50 |
+
BEATs_model = BEATs(cfg)
|
| 51 |
+
BEATs_model.load_state_dict(checkpoint['model'])
|
| 52 |
+
BEATs_model.eval()
|
| 53 |
+
|
| 54 |
+
# extract the the audio representation
|
| 55 |
+
audio_input_16khz = torch.randn(1, 10000)
|
| 56 |
+
padding_mask = torch.zeros(1, 10000).bool()
|
| 57 |
+
|
| 58 |
+
representation = BEATs_model.extract_features(audio_input_16khz, padding_mask=padding_mask)[0]
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
### Load Fine-tuned Models
|
| 63 |
+
|
| 64 |
+
```python
|
| 65 |
+
import torch
|
| 66 |
+
from BEATs import BEATs, BEATsConfig
|
| 67 |
+
|
| 68 |
+
# load the fine-tuned checkpoints
|
| 69 |
+
checkpoint = torch.load('/path/to/model.pt')
|
| 70 |
+
|
| 71 |
+
cfg = BEATsConfig(checkpoint['cfg'])
|
| 72 |
+
BEATs_model = BEATs(cfg)
|
| 73 |
+
BEATs_model.load_state_dict(checkpoint['model'])
|
| 74 |
+
BEATs_model.eval()
|
| 75 |
+
|
| 76 |
+
# predict the classification probability of each class
|
| 77 |
+
audio_input_16khz = torch.randn(3, 10000)
|
| 78 |
+
padding_mask = torch.zeros(3, 10000).bool()
|
| 79 |
+
|
| 80 |
+
probs = BEATs_model.extract_features(audio_input_16khz, padding_mask=padding_mask)[0]
|
| 81 |
+
|
| 82 |
+
for i, (top5_label_prob, top5_label_idx) in enumerate(zip(*probs.topk(k=5))):
|
| 83 |
+
top5_label = [checkpoint['label_dict'][label_idx.item()] for label_idx in top5_label_idx]
|
| 84 |
+
print(f'Top 5 predicted labels of the {i}th audio are {top5_label} with probability of {top5_label_prob}')
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## Evaluation Results
|
| 88 |
+
|
| 89 |
+
### Comparing with the SOTA Single Models
|
| 90 |
+

|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
### Comparing with the SOTA Ensemble Models
|
| 94 |
+

|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
### Comparing Different BEATS Tokenizers
|
| 98 |
+

|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
### Comparing Different Pre-Training Targets
|
| 102 |
+

|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
## License
|
| 106 |
+
This project is licensed under the license found in the LICENSE file in the root directory of this source tree.
|
| 107 |
+
Portions of the source code are based on the [FAIRSEQ](https://github.com/pytorch/fairseq) and [VQGAN](https://github.com/CompVis/taming-transformers) project.
|
| 108 |
+
|
| 109 |
+
[Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
### Reference
|
| 113 |
+
If you find our work is useful in your research, please cite the following paper:
|
| 114 |
+
``` latex
|
| 115 |
+
@article{Chen2022beats,
|
| 116 |
+
title = {BEATs: Audio Pre-Training with Acoustic Tokenizers},
|
| 117 |
+
author = {Sanyuan Chen and Yu Wu and Chengyi Wang and Shujie Liu and Daniel Tompkins and Zhuo Chen and Furu Wei},
|
| 118 |
+
eprint={2212.09058},
|
| 119 |
+
archivePrefix={arXiv},
|
| 120 |
+
year={2022}
|
| 121 |
+
}
|
| 122 |
+
```
|
| 123 |
+
### Contact Information
|
| 124 |
+
|
| 125 |
+
For help or issues using BEATs models, please submit a GitHub issue.
|
| 126 |
+
|
| 127 |
+
For other communications related to BEATs, please contact Yu Wu (`yuwu1@microsoft.com`).
|
SEARCH_FINDINGS_SUMMARY.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Search Findings Summary: Spatial Audio Frameworks in Codebase
|
| 2 |
+
|
| 3 |
+
## Search Requests Fulfilled
|
| 4 |
+
|
| 5 |
+
### ✓ 1. References to Existing Frameworks
|
| 6 |
+
**Found all references to**: BAT, Spatial-AST, SALSA, EINV2, SELDnet, Event Independent Network, CST-former, ACCDOA
|
| 7 |
+
|
| 8 |
+
| Framework | Found | Locations | Implementation |
|
| 9 |
+
|-----------|-------|-----------|-----------------|
|
| 10 |
+
| **Spatial-AST** | ✓ YES | `.gitignore:9`, `docs/`, `spatial_modules.py:48`, `spatial_modules.py:1026` | Direct: `PreTrunkASTPredictionHeads` (lines 1177-1237) |
|
| 11 |
+
| **ACCDOA** | ✓ YES | `spatial_modules.py:1409`, `spatial_loss.py:line 2799+` | Direct: `ACCDOAHeads` (lines 2132-2198) |
|
| 12 |
+
| **EINV2** | ✓ YES | `run_ov123_local_spatial_track.sh:4`, `docs/spatial_beats_ov123_frame_routes.md:6,36` | Adapted: `SourceQueryDecoder` (Route B) |
|
| 13 |
+
| **DCASE SELD** | ✓ YES | `spatial_loss.py:3079-3300` | Full: `OfficialDCASESELDMetrics`, `ACCDOAHeads` |
|
| 14 |
+
| **BAT** | ✓ YES | `train_spatial_beats.py:675`, `docs/spatial_beats_design_guide.md` | Referenced in warmup config |
|
| 15 |
+
| **CST-former, SELDnet, SALSA** | ✗ NOT FOUND | - | Not explicitly referenced in this codebase |
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
### ✓ 2. ACCDOAHeads Class Architecture
|
| 20 |
+
**Location**: `spatial_modules.py` lines 2132-2198
|
| 21 |
+
|
| 22 |
+
```python
|
| 23 |
+
class ACCDOAHeads(nn.Module):
|
| 24 |
+
"""Route C — per-class per-frame ACCDOA head."""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
embed_dim: int = 768,
|
| 28 |
+
num_classes: int = 63,
|
| 29 |
+
hidden_dim: int = 256,
|
| 30 |
+
dropout: float = 0.1,
|
| 31 |
+
):
|
| 32 |
+
# LayerNorm → Linear projection
|
| 33 |
+
# Two MLPs: doa_head and distance_head
|
| 34 |
+
# Zero-initialized output layers
|
| 35 |
+
|
| 36 |
+
def forward(self, fused: [B, T_s, D]) → FrameACCDOAPredictionOutput:
|
| 37 |
+
# Returns: pred_accdoa [B, T_s, num_classes, 3]
|
| 38 |
+
# pred_distance [B, T_s, num_classes]
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
**Key Features**:
|
| 42 |
+
- Per-class decomposition (no matching needed)
|
| 43 |
+
- Activity encoded in vector magnitude (||v||)
|
| 44 |
+
- Direction encoded in unit vector (v/||v||)
|
| 45 |
+
- Distance predicted separately with softplus
|
| 46 |
+
- Zero-initialized for stable startup
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
### ✓ 3. FrameACCDOAPredictionOutput and Alternative Approaches
|
| 51 |
+
**Location**: `spatial_modules.py` lines 1467-2198
|
| 52 |
+
|
| 53 |
+
Found **multiple alternative output datatypes**:
|
| 54 |
+
| Output Class | Lines | Usage | Route |
|
| 55 |
+
|--------------|-------|-------|-------|
|
| 56 |
+
| `SpatialPredictionOutput` | 22-44 | Fixed-slot supervision | Base |
|
| 57 |
+
| `MonoTaskPredictionOutput` | 47-70 | Single-source Spatial-AST | Pre-trunk |
|
| 58 |
+
| `PreTrunkASTPredictionOutput` | 73-91 | Pre-trunk task tokens | Spatial-AST |
|
| 59 |
+
| `FrameSlotPredictionOutput` | 1416-1435 | Per-frame slots | Route A |
|
| 60 |
+
| `FrameTrackPredictionOutput` | 1437-1465 | Per-track temporal | Route B (EINV2) |
|
| 61 |
+
| `FrameACCDOAPredictionOutput` | 1467-1482 | Per-class vector field | Route C (DCASE) |
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
### ✓ 4. spatial_beats_ov123_stage1_config.py
|
| 66 |
+
**Location**: Root directory of codebase
|
| 67 |
+
|
| 68 |
+
**Content**: Exports 8 preset configurations:
|
| 69 |
+
- `OV1_STAGE1_CFG` → make_ov1_stage1_config()
|
| 70 |
+
- `OV123_STAGE1_CFG` → make_ov123_stage1_config()
|
| 71 |
+
- `OV23_STAGE1_CFG` → make_ov23_stage1_config()
|
| 72 |
+
- `OV1_SPATIAL_FINETUNE_CFG` → make_ov1_spatial_finetune_config()
|
| 73 |
+
- `OV1_AST_CFG` → make_ov1_ast_config()
|
| 74 |
+
- `OV1_AST_CLASSWARMUP_CFG` → make_ov1_ast_classwarmup_config()
|
| 75 |
+
- `OV1_AST_SPATIAL_CFG` → make_ov1_ast_spatial_config()
|
| 76 |
+
- `OV1_PRETRUNK_AST_SPATIAL_CFG` → make_ov1_pretrunk_ast_spatial_config()
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
### ✓ 5. PreTrunkASTPredictionHeads Class Architecture
|
| 81 |
+
**Location**: `spatial_modules.py` lines 1177-1237
|
| 82 |
+
|
| 83 |
+
```python
|
| 84 |
+
class PreTrunkASTPredictionHeads(nn.Module):
|
| 85 |
+
"""Spatial-AST-style heads fed by task tokens that passed through trunk.
|
| 86 |
+
|
| 87 |
+
Token order (3 tokens):
|
| 88 |
+
0: distance token
|
| 89 |
+
1: DoA token
|
| 90 |
+
2: class token
|
| 91 |
+
|
| 92 |
+
Outputs (all clip-level):
|
| 93 |
+
- pred_class_logits: [B, num_classes]
|
| 94 |
+
- pred_distance_logits: [B, num_distance_bins=21]
|
| 95 |
+
- pred_azi_logits: [B, num_azi_bins=360]
|
| 96 |
+
- pred_ele_logits: [B, num_ele_bins=180]
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
def __init__(self, embed_dim=768, num_classes=63,
|
| 100 |
+
num_distance_bins=21, num_azi_bins=360, num_ele_bins=180):
|
| 101 |
+
# Separate LayerNorm per token type
|
| 102 |
+
# Linear heads for each prediction task
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
**Key Differences from FrameTrackPredictionHeads (Route B)**:
|
| 106 |
+
| Aspect | PreTrunkAST | Route B (FrameTrack) |
|
| 107 |
+
|--------|-------------|---------------------|
|
| 108 |
+
| **Scope** | Clip-level | Per-frame (K tracks × T_s frames) |
|
| 109 |
+
| **Token timing** | Before trunk | After trunk |
|
| 110 |
+
| **Task tokens** | 3 (dist, doa, cls) | Not used (direct latent-based) |
|
| 111 |
+
| **Outputs** | Classification bins | Regression (continuous) |
|
| 112 |
+
| **Matching** | None (clip-level) | Clip-level Hungarian (K↔N) |
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
### ✓ 6. Training Presets and Loss Weights
|
| 117 |
+
**Location**: `train_spatial_beats.py`
|
| 118 |
+
|
| 119 |
+
**v9 Series (Current Production)**:
|
| 120 |
+
```python
|
| 121 |
+
def make_ov1_local_spatial_v9_ov123_top4_config():
|
| 122 |
+
# Loss weights:
|
| 123 |
+
cfg.loss.lambda_frame_activity = 1.0
|
| 124 |
+
cfg.loss.lambda_frame_class = 1.0
|
| 125 |
+
cfg.loss.lambda_frame_direction = 4.0 # Weighted 4x
|
| 126 |
+
cfg.loss.lambda_frame_distance = 1.0
|
| 127 |
+
cfg.loss.lambda_clip_aux = 0.1
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
**v10 Series (Phase-Wise Training)**:
|
| 131 |
+
```python
|
| 132 |
+
def make_ov1_local_spatial_v10_phase1_cls_config():
|
| 133 |
+
# Phase-1: Classification refinement only
|
| 134 |
+
cfg.loss.lambda_frame_direction = 0.0 # Frozen
|
| 135 |
+
cfg.loss.lambda_frame_distance = 0.0 # Frozen
|
| 136 |
+
cfg.loss.lambda_frame_activity = 0.5 # Weakened
|
| 137 |
+
cfg.loss.lambda_frame_num_active = 0.5 # New head
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
**v11 Series (Architectural Refinements)**:
|
| 141 |
+
|
| 142 |
+
| Variant | Location | Key Changes |
|
| 143 |
+
|---------|----------|-------------|
|
| 144 |
+
| v11a | lines 2281-2326 | + spatial_head_demixer for direction/distance |
|
| 145 |
+
| v11b | lines 2327-2356 | v11a + local_spatial_pre_pool_kv option |
|
| 146 |
+
| v11c | lines 2357-2545 | Paradigm shift to ACCDOA (Route C) |
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
### ✓ 7. Research Paper References and URLs
|
| 151 |
+
**Found in various locations**:
|
| 152 |
+
|
| 153 |
+
| Reference | Location | URL/Details |
|
| 154 |
+
|-----------|----------|-------------|
|
| 155 |
+
| **BEATs** | `spatial_loss.py:3304`, multiple | https://arxiv.org/abs/2212.09058 |
|
| 156 |
+
| **BEATs Code** | `spatial_beats_design_guide.md` | https://github.com/microsoft/unilm/tree/master/beats |
|
| 157 |
+
| **DCASE SELD** | `spatial_loss.py:3304` | https://github.com/sharathadavanne/seld-dcase2023 |
|
| 158 |
+
| **Spatial-AST Ref** | `docs/spatial_beats_design_guide.md` (118+KB) | Extensive comparison documentation |
|
| 159 |
+
| **DCASE Metrics** | `spatial_loss.py:3079+` | Official SELD_score implementation |
|
| 160 |
+
|
| 161 |
+
**Documentation Files**:
|
| 162 |
+
- `docs/spatial_beats_design_guide.md`: Spatial-AST vs Spatial-BEATs architectural comparison
|
| 163 |
+
- `docs/spatial_beats_ov123_frame_routes.md`: Routes A/B/C design specification
|
| 164 |
+
- `docs/0427_v11_series.md`: v11 experiments diagnostic guide
|
| 165 |
+
- `docs/SPATIAL_AUDIO_FRAMEWORKS_ANALYSIS.md`: Existing comprehensive analysis (already in codebase)
|
| 166 |
+
|
| 167 |
+
---
|
| 168 |
+
|
| 169 |
+
## Alternative Spatial Architectures Found in Codebase
|
| 170 |
+
|
| 171 |
+
### All Three Routes Coexist
|
| 172 |
+
```
|
| 173 |
+
Route A (FrameSlotHead):
|
| 174 |
+
├─ Per-frame K-slot assignment
|
| 175 |
+
├─ Per-step Hungarian matching
|
| 176 |
+
└─ Supervision: compute_frame_slot_losses()
|
| 177 |
+
|
| 178 |
+
Route B (SourceQueryDecoder + FrameTrackPredictionHeads):
|
| 179 |
+
├─ K track queries with temporal attention
|
| 180 |
+
├─ Clip-level Hungarian matching
|
| 181 |
+
├─ Supervision: compute_frame_track_losses()
|
| 182 |
+
└─ Enhancement: ClassHeadSpectralDemixer (v9+)
|
| 183 |
+
|
| 184 |
+
Route C (ACCDOAHeads):
|
| 185 |
+
├─ Per-class ACCDOA vector field
|
| 186 |
+
├─ No matching (per-class decomposition)
|
| 187 |
+
└─ Supervision: compute_frame_accdoa_losses()
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
### Shared Preprocessing Stack
|
| 191 |
+
All routes use identical front-end:
|
| 192 |
+
```
|
| 193 |
+
FOA Waveform → SpatialBEATsPreprocessor → BEATs Trunk →
|
| 194 |
+
FrequencyPool → TemporalResampler → LocalSpatialEncoder →
|
| 195 |
+
LocalSpatialFusion → Route-specific Heads
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
## Key Innovation: ClassHeadSpectralDemixer (v9+)
|
| 201 |
+
|
| 202 |
+
**Problem Addressed**: Multiple sources get compressed into single D-vector after frequency pooling
|
| 203 |
+
|
| 204 |
+
**Solution**: Per-track per-frame frequency-axis cross-attention
|
| 205 |
+
- Queries: track latents [B, K, T_s, D]
|
| 206 |
+
- Keys: BEATs trunk before frequency pool [B, N_p, D]
|
| 207 |
+
- Safe initialization: output layer zeros, gate=0.01
|
| 208 |
+
|
| 209 |
+
**Extensions**:
|
| 210 |
+
- v9: Class head only
|
| 211 |
+
- v11a: + Spatial head demixer (direction/distance)
|
| 212 |
+
- v11b: v11a + alternative KV from LocalSpatial pre-pool
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## Summary Table: What Was Found
|
| 217 |
+
|
| 218 |
+
| Search Item | Found | Details |
|
| 219 |
+
|-------------|-------|---------|
|
| 220 |
+
| **BAT** | ✓ | train_spatial_beats.py:675, docs/ |
|
| 221 |
+
| **Spatial-AST** | ✓ | .gitignore:9, PreTrunkASTPredictionHeads |
|
| 222 |
+
| **SALSA** | ✗ | Not found |
|
| 223 |
+
| **EINV2** | ✓ | Route B (SourceQueryDecoder) |
|
| 224 |
+
| **SELDnet** | ✗ | Not found (only DCASE SELD) |
|
| 225 |
+
| **Event Independent Network** | ✓ | EINV2 reference |
|
| 226 |
+
| **CST-former** | ✗ | Not found |
|
| 227 |
+
| **ACCDOA** | ✓ | ACCDOAHeads, Route C |
|
| 228 |
+
| **ACCDOAHeads class** | ✓ | spatial_modules.py:2132 |
|
| 229 |
+
| **FrameACCDOAPredictionOutput** | ✓ | spatial_modules.py:1467 |
|
| 230 |
+
| **PreTrunkASTPredictionHeads** | ✓ | spatial_modules.py:1177 |
|
| 231 |
+
| **Alternative architectures** | ✓ | Routes A/B/C (3 paradigms) |
|
| 232 |
+
| **Config files** | ✓ | spatial_beats_ov123_stage1_config.py |
|
| 233 |
+
| **v9/v10/v11 configs** | ✓ | train_spatial_beats.py (comprehensive) |
|
| 234 |
+
| **Research references** | ✓ | arxiv, github URLs in code |
|
| 235 |
+
|
| 236 |
+
---
|
| 237 |
+
|
| 238 |
+
## Deliverables Generated
|
| 239 |
+
|
| 240 |
+
1. **SPATIAL_AUDIO_FRAMEWORKS_ANALYSIS_COMPREHENSIVE.md** (18KB)
|
| 241 |
+
- Complete analysis of all frameworks and architectures
|
| 242 |
+
- 10-part detailed breakdown
|
| 243 |
+
- Code locations and implementation details
|
| 244 |
+
|
| 245 |
+
2. **FRAMEWORKS_QUICK_REFERENCE.txt**
|
| 246 |
+
- Quick lookup table format
|
| 247 |
+
- Visual boxes for each framework
|
| 248 |
+
- Comparison matrix for Routes A/B/C
|
| 249 |
+
- Practical usage guide
|
| 250 |
+
|
| 251 |
+
3. **This Summary Document**
|
| 252 |
+
- Checklist of all search requests
|
| 253 |
+
- Key findings verified
|
| 254 |
+
- Research references collected
|
| 255 |
+
|
SPATIAL_AUDIO_FRAMEWORKS_ANALYSIS_COMPREHENSIVE.md
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Comprehensive Summary: Spatial Audio Frameworks in Spatial-BEATs Codebase
|
| 2 |
+
|
| 3 |
+
## Executive Overview
|
| 4 |
+
|
| 5 |
+
The Spatial-BEATs codebase is a **richly documented exploration of spatial sound event localization and detection (SELD)** with references to and implementations of multiple established spatial audio frameworks. This document summarizes all findings from thorough codebase analysis.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Part 1: Referenced Spatial Audio Frameworks
|
| 10 |
+
|
| 11 |
+
### 1.1 Spatial-AST (Primary External Reference)
|
| 12 |
+
**Status**: Referenced extensively, foundational inspiration
|
| 13 |
+
|
| 14 |
+
**Key Mentions**:
|
| 15 |
+
- `.gitignore:9` explicitly protects a "Spatial-AST" directory
|
| 16 |
+
- Documentation: `docs/spatial_beats_design_guide.md` (118+KB)
|
| 17 |
+
- Implementation influence: Task-token-based spatial audio processing
|
| 18 |
+
|
| 19 |
+
**Direct Implementation in Code**:
|
| 20 |
+
- `PreTrunkASTPredictionHeads` (lines 1177-1237 in `spatial_modules.py`)
|
| 21 |
+
- Pre-trunk architecture: task tokens injected **before** transformer trunk
|
| 22 |
+
- Output structure: [B, num_classes], [B, num_distance_bins], [B, num_azi_bins], [B, num_ele_bins]
|
| 23 |
+
- 3 task tokens: distance, DoA (direction), class
|
| 24 |
+
- Configuration factory: `make_ov1_ast_config()` in `train_spatial_beats.py`
|
| 25 |
+
|
| 26 |
+
**Characteristics**:
|
| 27 |
+
- Single-source focused
|
| 28 |
+
- Task tokens undergo full trunk transformation
|
| 29 |
+
- Classification logits (num_classes), binned azimuth/elevation (360/180 bins), binned distance (21 bins)
|
| 30 |
+
- Zero-initialized linear layers for stable startup
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
### 1.2 DCASE Challenge SELD Baseline
|
| 35 |
+
**Status**: Fully implemented, official evaluation metrics
|
| 36 |
+
|
| 37 |
+
**Code Locations**:
|
| 38 |
+
- `spatial_loss.py` lines 3079-3300: `SELDMetricsAccumulator` and `OfficialDCASESELDMetrics`
|
| 39 |
+
- DCASE FOA channel ordering conventions documented throughout
|
| 40 |
+
|
| 41 |
+
**Official DCASE Evaluation Metrics Computed**:
|
| 42 |
+
```
|
| 43 |
+
SELD_score = (ER + (1-F) + LE/180 + (1-LR)) / 4
|
| 44 |
+
Where:
|
| 45 |
+
- ER: Error Rate (lower is better)
|
| 46 |
+
- F: F-score (higher is better)
|
| 47 |
+
- LE: Localization Error in degrees (lower is better)
|
| 48 |
+
- LR: Localization Recall (higher is better)
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
**Route C ACCDOA Paradigm** (Direct Adoption):
|
| 52 |
+
- Per-class Activity-Coupled Cartesian Direction of Arrival
|
| 53 |
+
- No explicit source matching needed
|
| 54 |
+
- Simple and stable for ov2/ov3 constraints (same-class overlap ≈ 0)
|
| 55 |
+
- Implementation: `ACCDOAHeads` (lines 2132-2198 in `spatial_modules.py`)
|
| 56 |
+
- Output: [B, T_s, num_classes, 3] ACCDOA vector + [B, T_s, num_classes] distance
|
| 57 |
+
- Activity from vector magnitude: ||v_c|| = how active is class c?
|
| 58 |
+
- Direction from unit vector: v_c/||v_c|| = where is class c pointing?
|
| 59 |
+
|
| 60 |
+
**Official Reference**:
|
| 61 |
+
```python
|
| 62 |
+
# Line 3304 in spatial_loss.py
|
| 63 |
+
# https://github.com/sharathadavanne/seld-dcase2023/blob/master/SELD_evaluation_metrics.py
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
### 1.3 EINV2 (Event Independent Network V2)
|
| 69 |
+
**Status**: Paradigm adapted for Route B
|
| 70 |
+
|
| 71 |
+
**Key References**:
|
| 72 |
+
- `docs/spatial_beats_ov123_frame_routes.md` lines 6, 36
|
| 73 |
+
- `run_ov123_local_spatial_track.sh` line 4: "ov123 local-spatial + K track queries (Route B, EINV2-style)"
|
| 74 |
+
|
| 75 |
+
**EINV2 Paradigm Adapted**:
|
| 76 |
+
- K learnable track queries per clip (K=4 in implementation)
|
| 77 |
+
- **Clip-level Hungarian matching** for track-to-source binding
|
| 78 |
+
- Per-track temporal attention to capture source continuity across frames
|
| 79 |
+
- **Implemented as Route B** (`local_spatial_track`) in this codebase
|
| 80 |
+
|
| 81 |
+
**Route B Architecture**:
|
| 82 |
+
```
|
| 83 |
+
SourceQueryDecoder (lines 1569-1684 in spatial_modules.py):
|
| 84 |
+
├─ Stage 1 (track-level): K learnable queries → TransformerDecoder
|
| 85 |
+
│ Output: [B, K, D] track latents
|
| 86 |
+
├─ Stage 2 (per-frame): Expand with temporal positional embeddings
|
| 87 |
+
│ + TemporalResampler to 2.5 Hz
|
| 88 |
+
│ Output: [B, K, T_s, D] track time features
|
| 89 |
+
↓
|
| 90 |
+
FrameTrackPredictionHeads:
|
| 91 |
+
├─ Activity head: [B, K, T_s, 1]
|
| 92 |
+
├─ Class head: [B, K, T_s, num_classes]
|
| 93 |
+
├─ Direction head (L2-norm): [B, K, T_s, 3]
|
| 94 |
+
└─ Distance head (softplus): [B, K, T_s, 1]
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
**Key Innovation v9→v11a/b**: Added spectral demixer to break bottleneck where multiple sources compressed into single D-dimensional vector after frequency pooling
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## Part 2: Alternative Spatial Architectures in Codebase
|
| 102 |
+
|
| 103 |
+
### 2.1 Route A: Per-Frame K-Slot Assignment
|
| 104 |
+
**Architecture**: `FrameSlotHead` (lines 1484-1568 in `spatial_modules.py`)
|
| 105 |
+
|
| 106 |
+
**Supervision Strategy**:
|
| 107 |
+
- Per-frame independent slot allocation (no temporal continuity assumption)
|
| 108 |
+
- Per-step Hungarian matching to assign ground-truth sources to K slots
|
| 109 |
+
- Loss weights (typical): `[1.0, 1.0, 4.0, 1.0]` for activity/class/direction/distance
|
| 110 |
+
|
| 111 |
+
**Configuration**: `make_ov123_local_spatial_slot_config()`
|
| 112 |
+
**Shell Script**: `run_ov123_local_spatial_slot.sh`
|
| 113 |
+
|
| 114 |
+
**Use Cases**: Frequent source entry/exit, short trajectories, minimal temporal coherence
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
### 2.2 Route B: K Track Queries with Temporal Self-Attention
|
| 119 |
+
**Architecture**: `SourceQueryDecoder` + `FrameTrackPredictionHeads` (EINV2-style)
|
| 120 |
+
|
| 121 |
+
**Key Characteristic**: Clip-level Hungarian matching (once per clip, not per frame)
|
| 122 |
+
**Configuration**: `make_ov1_local_spatial_v9_ov123_top4_config()` (current production baseline)
|
| 123 |
+
**Shell Script**: `run_ov1_v9_ov123_top4.sh`
|
| 124 |
+
|
| 125 |
+
**Use Cases**: Continuous source trajectories, strong temporal coherence required
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
### 2.3 Route C: Per-Class ACCDOA Vector Field (Route C)
|
| 130 |
+
**Architecture**: `ACCDOAHeads` (lines 2132-2198 in `spatial_modules.py`)
|
| 131 |
+
|
| 132 |
+
**Key Advantages**:
|
| 133 |
+
- **No matching** required (no Hungarian, no query assignment)
|
| 134 |
+
- Per-class decomposition: Each class has its own spatial slot
|
| 135 |
+
- Simple and stable for ov2/ov3 (same-class overlap constraint satisfied)
|
| 136 |
+
|
| 137 |
+
**Loss Configuration**:
|
| 138 |
+
```python
|
| 139 |
+
lambda_frame_activity = 4.0 # ACCDOA MSE dominates
|
| 140 |
+
lambda_frame_class = 0.0 # No separate class CE
|
| 141 |
+
lambda_frame_direction = 0.0 # No separate direction
|
| 142 |
+
lambda_frame_distance = 1.0
|
| 143 |
+
frame_accdoa_activity_threshold = 0.5
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
**Configuration**: `make_ov123_local_spatial_accdoa_config()`
|
| 147 |
+
**Shell Script**: `run_ov123_local_spatial_accdoa.sh`
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## Part 3: Experimental Series v7-v11
|
| 152 |
+
|
| 153 |
+
### v7 Series: Early Frame-Level Approaches
|
| 154 |
+
- Clip-level single-source `LocalSpatialPredictionHeads`
|
| 155 |
+
- Works for ov1 only; can't handle multi-source ov2/ov3
|
| 156 |
+
|
| 157 |
+
### v9 Series: Frame-Track with Class-Head Spectral Demixer (Current Baseline)
|
| 158 |
+
|
| 159 |
+
**Core Innovation: ClassHeadSpectralDemixer**
|
| 160 |
+
|
| 161 |
+
**Problem Identified**: Multiple sources compressed into single D-vector after frequency pooling
|
| 162 |
+
|
| 163 |
+
**Solution Architecture** (lines 1768-1790 in `spatial_modules.py`):
|
| 164 |
+
```python
|
| 165 |
+
# For class head specifically:
|
| 166 |
+
class_logits = class_head(x) + gate * demixer_output(x)
|
| 167 |
+
|
| 168 |
+
# Demixer Cross-Attention:
|
| 169 |
+
demixer_input: track_time_features [B, K, T_s, D]
|
| 170 |
+
demixer_kv: pre_pool_features [B, N_p, D] where N_p = T_p * F_p
|
| 171 |
+
demixer_output: frequency-axis cross-attention at aligned trunk time steps
|
| 172 |
+
|
| 173 |
+
# Zero-initialization safety:
|
| 174 |
+
- output_layer: weights=0, bias=0 → epoch-0 identical to non-demixer
|
| 175 |
+
- gate: starts at 0.01 → allows gradient flow even at step 0
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
**v9 Loss Weights** (stable configuration):
|
| 179 |
+
```python
|
| 180 |
+
lambda_frame_activity = 1.0
|
| 181 |
+
lambda_frame_class = 1.0
|
| 182 |
+
lambda_frame_direction = 4.0 # Weighted 4x over activity
|
| 183 |
+
lambda_frame_distance = 1.0
|
| 184 |
+
lambda_clip_aux = 0.1
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
---
|
| 188 |
+
|
| 189 |
+
### v10 Series: Phase-Wise Training (Activity Re-balancing)
|
| 190 |
+
|
| 191 |
+
**v10 Phase-1**: Pure classification refinement
|
| 192 |
+
- Freeze spatial heads (direction_head, distance_head)
|
| 193 |
+
- Train only class head + new `num_active_head`
|
| 194 |
+
- Diagnosis: v9's class recall peaked early (ep3) then dropped
|
| 195 |
+
|
| 196 |
+
**v10b**: Activity re-balancing
|
| 197 |
+
- Re-enable spatial heads with tuned activity weighting
|
| 198 |
+
- Lambda adjustments for ov3-specific imbalances
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
### v11 Series: Architectural Refinements (2026-04-27)
|
| 203 |
+
|
| 204 |
+
#### v11a: Symmetric Spectral Demixer for DOA
|
| 205 |
+
**Problem**: v9 added demixer for class head only
|
| 206 |
+
**Symptom**: 73.9% of real_ov2 predictions have `class_right_angle_wrong`
|
| 207 |
+
**Root Cause**: Direction/distance heads still see only post-pooled vectors
|
| 208 |
+
|
| 209 |
+
**Solution**:
|
| 210 |
+
```python
|
| 211 |
+
# In FrameTrackPredictionHeads.__init__:
|
| 212 |
+
self.spatial_head_demixer = ClassHeadSpectralDemixer(...)
|
| 213 |
+
# Attend to BEATs trunk pre-pool tokens like class_demixer
|
| 214 |
+
```
|
| 215 |
+
**Zero-gated**: Epoch-0 identical to v9
|
| 216 |
+
**Configuration**: `make_ov1_local_spatial_v11a_ov123_top4_config()`
|
| 217 |
+
**Shell Script**: `run_ov1_v11a_ov123_top4.sh`
|
| 218 |
+
|
| 219 |
+
#### v11b: DOA Demixer with LocalSpatial Pre-Pool KV
|
| 220 |
+
**Hypothesis**: If demixer attends to LocalSpatial's 7-channel pre-pool (4-FOA + 3-IV), might be better than BEATs mono fbank
|
| 221 |
+
|
| 222 |
+
**Implementation**:
|
| 223 |
+
```python
|
| 224 |
+
LocalSpatialEncoder.forward(return_pre_pool=True)
|
| 225 |
+
# Returns: [B, D_s, T_f, F_cnn] before frequency pooling
|
| 226 |
+
# Project to [B, T_f*F_cnn, D=768]
|
| 227 |
+
# Pass to spatial_head_demixer as alternative KV source
|
| 228 |
+
```
|
| 229 |
+
**Configuration**: `make_ov1_local_spatial_v11b_ov123_top4_config()`
|
| 230 |
+
**Shell Script**: `run_ov1_v11b_ov123_top4.sh`
|
| 231 |
+
|
| 232 |
+
#### v11c: Paradigm Shift to ACCDOA
|
| 233 |
+
**Problem Addressed**: real_ov3 24.5% raw GT layer without same-class candidates
|
| 234 |
+
**Root Cause**: K-track binding (Hungarian matching) failure for ov3
|
| 235 |
+
**Hypothesis**: Query-binding stage is the actual bottleneck, not head improvements
|
| 236 |
+
|
| 237 |
+
**Solution**: Replace entire Route B topology with Route C (ACCDOA)
|
| 238 |
+
- No queries, no Hungarian matching
|
| 239 |
+
- Per-class vector slots: each class inherently has its own "slot"
|
| 240 |
+
- Binding non-issue by design
|
| 241 |
+
|
| 242 |
+
**Configuration**:
|
| 243 |
+
```python
|
| 244 |
+
lambda_frame_activity = 4.0
|
| 245 |
+
lambda_frame_class = 0.0
|
| 246 |
+
lambda_frame_direction = 0.0
|
| 247 |
+
lambda_frame_distance = 1.0
|
| 248 |
+
num_epochs = 24 (extended for convergence)
|
| 249 |
+
learning_rate = 3e-5
|
| 250 |
+
```
|
| 251 |
+
**Configuration Factory**: `make_ov1_local_spatial_v11c_ov123_accdoa()`
|
| 252 |
+
**Shell Script**: `run_ov1_v11c_ov123_accdoa.sh`
|
| 253 |
+
|
| 254 |
+
#### v11d: Decode-Time Activity Calibration (Post-hoc)
|
| 255 |
+
**Problem**: real_ov1 loses 37% of same-class candidates post-activity-thresholding
|
| 256 |
+
**Root Cause**: Threshold (0.5) vs. actual activity probability distribution mismatch
|
| 257 |
+
|
| 258 |
+
**Solution**: Post-hoc decode recalibration (no retraining)
|
| 259 |
+
```python
|
| 260 |
+
Three decode strategies:
|
| 261 |
+
1. threshold: Fixed thr in {0.3, 0.4, 0.5, 0.6}
|
| 262 |
+
2. topk_hat: Per-frame top-K̂ by activity, K̂ from v10's num_active_head
|
| 263 |
+
3. topk_hat_min: (K̂ membership) AND (thr > min_thr)
|
| 264 |
+
```
|
| 265 |
+
**Tool**: `scripts/calibrate_activity.py` (pure post-processing, no model change)
|
| 266 |
+
**Property**: Reproducible for all checkpoints
|
| 267 |
+
|
| 268 |
+
---
|
| 269 |
+
|
| 270 |
+
## Part 4: ClassHeadSpectralDemixer Deep Dive
|
| 271 |
+
|
| 272 |
+
**Location**: Lines 1895-2080 in `spatial_modules.py`
|
| 273 |
+
|
| 274 |
+
**Purpose**: Break frequency-pooling bottleneck for multi-source discrimination
|
| 275 |
+
|
| 276 |
+
**Architecture**:
|
| 277 |
+
```python
|
| 278 |
+
def forward(
|
| 279 |
+
track_time_features: [B, K, T_s, D], # Per-track per-frame latents
|
| 280 |
+
pre_pool_features: [B, N_p, D], # BEATs trunk before freq pool
|
| 281 |
+
pre_pool_grid_size: (T_p, F_p), # Patch grid geometry
|
| 282 |
+
pre_pool_time_mask: Optional[B, T_p], # Valid trunk time steps
|
| 283 |
+
) -> [B, K, T_s, D]: # Demixer residual
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
**Forward Process**:
|
| 287 |
+
1. Reshape pre_pool_features to [B, T_p, F_p, D] grid
|
| 288 |
+
2. For each track-time (b, k, t), map t to trunk time step t_p = round(t * T_p / T_s)
|
| 289 |
+
3. Gather frequency tokens [B, K, T_s, F_p, D]
|
| 290 |
+
4. Apply LayerNorm to KV and Q separately
|
| 291 |
+
5. Per-track per-frame: 1-query → F_p-token cross-attention
|
| 292 |
+
6. Project output to D and gate with learnable scalar
|
| 293 |
+
|
| 294 |
+
**Key Details**:
|
| 295 |
+
- **Zero-initialized output projection**: out_proj.weight = 0, out_proj.bias = 0
|
| 296 |
+
- **Tiny-positive gate**: gate = 1e-2 (allows gradient flow at step 0)
|
| 297 |
+
- **Safety property**: gate * 0 = 0 → epoch-0 output identical to non-demixer version
|
| 298 |
+
- **Graceful fallback**: If pre_pool_features size mismatch, return zeros (non-destructive)
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## Part 5: Loss Configuration Patterns
|
| 303 |
+
|
| 304 |
+
### Loss Dispatch in spatial_loss.py
|
| 305 |
+
|
| 306 |
+
```python
|
| 307 |
+
if supervision_mode == "local_spatial_slot":
|
| 308 |
+
loss_out = compute_frame_slot_losses(...)
|
| 309 |
+
elif supervision_mode == "local_spatial_track":
|
| 310 |
+
loss_out = compute_frame_track_losses(...)
|
| 311 |
+
elif supervision_mode == "local_spatial_accdoa":
|
| 312 |
+
loss_out = compute_frame_accdoa_losses(...)
|
| 313 |
+
```
|
| 314 |
+
|
| 315 |
+
### Loss Output Fields (Reused Semantically)
|
| 316 |
+
|
| 317 |
+
```python
|
| 318 |
+
@dataclass
|
| 319 |
+
class SpatialLossOutput:
|
| 320 |
+
loss_total: Tensor
|
| 321 |
+
loss_activity: Tensor # BCE / ACCDOA magnitude / etc.
|
| 322 |
+
loss_cls: Tensor # Per-source class CE
|
| 323 |
+
loss_dir: Tensor # 1 - cos(direction)
|
| 324 |
+
loss_dist: Tensor # smooth_l1(distance)
|
| 325 |
+
loss_cls_aux: Tensor # Route B/A matched class
|
| 326 |
+
loss_temp: Tensor # Clip aux × 0.1
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## Part 6: Key Code Reference Points
|
| 332 |
+
|
| 333 |
+
### spatial_modules.py
|
| 334 |
+
- **Lines 22-90**: Data class definitions (SpatialPredictionOutput, MonoTaskPredictionOutput, PreTrunkASTPredictionOutput)
|
| 335 |
+
- **Lines 1177-1237**: `PreTrunkASTPredictionHeads` (Spatial-AST single-source)
|
| 336 |
+
- **Lines 1467-1482**: `FrameACCDOAPredictionOutput` (dataclass, Route C)
|
| 337 |
+
- **Lines 1484-1568**: `FrameSlotHead` (Route A)
|
| 338 |
+
- **Lines 1569-1684**: `SourceQueryDecoder` (Route B, EINV2-style)
|
| 339 |
+
- **Lines 1685-2130**: `FrameTrackPredictionHeads` (Route B heads with optional demixers)
|
| 340 |
+
- **Lines 1895-2080**: `ClassHeadSpectralDemixer` (v9+ spectral demixing)
|
| 341 |
+
- **Lines 2132-2198**: `ACCDOAHeads` (Route C, DCASE SELD style)
|
| 342 |
+
|
| 343 |
+
### spatial_loss.py
|
| 344 |
+
- **Lines 2573-2650**: `compute_frame_slot_losses()` (Route A)
|
| 345 |
+
- **Lines 2682-2750**: `compute_frame_track_losses()` (Route B)
|
| 346 |
+
- **Lines 2803-2854**: `_build_accdoa_targets()` (Route C target construction)
|
| 347 |
+
- **Lines 2857-2945**: `compute_frame_accdoa_losses()` (Route C loss)
|
| 348 |
+
- **Lines 3079-3300**: `SELDMetricsAccumulator` + `OfficialDCASESELDMetrics` (DCASE metrics)
|
| 349 |
+
|
| 350 |
+
### train_spatial_beats.py
|
| 351 |
+
- **Lines 570-650**: `make_ov1_ast_config()` (Spatial-AST factory)
|
| 352 |
+
- **Lines 675-750**: BAT/Spatial-AST-style warmup configuration
|
| 353 |
+
- **Lines 2228-2280**: `make_ov1_local_spatial_v9_ov123_top4_config()` (v9 baseline)
|
| 354 |
+
- **Lines 2281-2326**: `make_ov1_local_spatial_v11a_ov123_top4_config()` (v11a with spatial demixer)
|
| 355 |
+
- **Lines 2327-2356**: `make_ov1_local_spatial_v11b_ov123_top4_config()` (v11b with local spatial KV)
|
| 356 |
+
- **Lines 2357-2545**: `make_ov1_local_spatial_v11c_ov123_accdoa_config()` (Route C ACCDOA paradigm)
|
| 357 |
+
|
| 358 |
+
---
|
| 359 |
+
|
| 360 |
+
## Part 7: Research References
|
| 361 |
+
|
| 362 |
+
### Explicit Code References
|
| 363 |
+
1. **BEATs** (foundational):
|
| 364 |
+
- https://arxiv.org/abs/2212.09058
|
| 365 |
+
- https://github.com/microsoft/unilm/tree/master/beats
|
| 366 |
+
|
| 367 |
+
2. **DCASE SELD Challenge**:
|
| 368 |
+
- https://github.com/sharathadavanne/seld-dcase2023/blob/master/SELD_evaluation_metrics.py
|
| 369 |
+
- Official metrics: ER20, F20, LE_CD, LR_CD, SELD_score
|
| 370 |
+
|
| 371 |
+
3. **Implementation Tech**:
|
| 372 |
+
- scipy.optimize.linear_sum_assignment (Hungarian matching)
|
| 373 |
+
- Great-circle distance for localization error
|
| 374 |
+
|
| 375 |
+
### Implicit References
|
| 376 |
+
- **DETR**: Detection Transformer influences Route A slot-based design
|
| 377 |
+
- **Transformer Decoders**: PyTorch standard modules in Route B
|
| 378 |
+
- **FairSeq**: Referenced in code headers for attribution
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
## Part 8: Evaluation Metrics Across Routes
|
| 383 |
+
|
| 384 |
+
### Per-Epoch Validation Metrics
|
| 385 |
+
- `class_acc`: Matched-source class top-1 accuracy
|
| 386 |
+
- `azi_mae_deg`: Azimuth mean absolute error
|
| 387 |
+
- `ele_mae_deg`: Elevation mean absolute error
|
| 388 |
+
- `dist_mae_m`: Distance mean absolute error
|
| 389 |
+
- `activity_f1`: Per-frame source activity F1-score
|
| 390 |
+
- `num_active_mae`: Mean absolute error in number of active sources
|
| 391 |
+
|
| 392 |
+
### Official DCASE Metrics
|
| 393 |
+
- ER: Error Rate (lower better)
|
| 394 |
+
- F: F-score (higher better)
|
| 395 |
+
- LE_CD: Localization Error in degrees
|
| 396 |
+
- LR_CD: Localization Recall
|
| 397 |
+
- SELD_score: Joint metric
|
| 398 |
+
|
| 399 |
+
### Best Metric Strategy
|
| 400 |
+
- Primary: `class_acc` (maximize)
|
| 401 |
+
- Fallback: Direction error (azimuth MAE)
|
| 402 |
+
|
| 403 |
+
---
|
| 404 |
+
|
| 405 |
+
## Part 9: Checkpoint Management & Initialization
|
| 406 |
+
|
| 407 |
+
### Hot-Start Strategy (Routes A/B/C)
|
| 408 |
+
1. Initialize from `ov1_local_spatial_run1/best.pt`
|
| 409 |
+
- Contains: BEATs trunk, LocalSpatialEncoder, fusion stack
|
| 410 |
+
- Frozen trunk ensures warm-start stability
|
| 411 |
+
2. Load with `strict=False` to skip incompatible heads
|
| 412 |
+
3. New parameters initialized:
|
| 413 |
+
- LayerNorm: default
|
| 414 |
+
- Linear: trunc_normal_(std=2e-5) for light init
|
| 415 |
+
- Spectral demixer gate: 1e-2 (near-zero residual)
|
| 416 |
+
- Spectral demixer output: zeros (bit-equivalent to baseline)
|
| 417 |
+
|
| 418 |
+
### Route C Special Case (v11c)
|
| 419 |
+
- v9 best.pt not compatible (no ACCDOAHeads)
|
| 420 |
+
- Hot-start from ov1 local_spatial instead
|
| 421 |
+
- Trade-off: Less pre-training, but topology matches by default
|
| 422 |
+
|
| 423 |
+
---
|
| 424 |
+
|
| 425 |
+
## Part 10: Practical Usage Guide
|
| 426 |
+
|
| 427 |
+
### When to Use Each Route
|
| 428 |
+
|
| 429 |
+
**Route A (Slot)**:
|
| 430 |
+
- ✓ Frequent source entry/exit
|
| 431 |
+
- ✓ Short trajectories
|
| 432 |
+
- ✗ Higher computational cost (Hungarian per-frame)
|
| 433 |
+
|
| 434 |
+
**Route B (Track - EINV2 style)** [Current Production]:
|
| 435 |
+
- ✓ Continuous source trajectories
|
| 436 |
+
- ✓ Strong temporal coherence
|
| 437 |
+
- ✗ Query binding complexity in crowded scenarios
|
| 438 |
+
|
| 439 |
+
**Route C (ACCDOA - DCASE style)**:
|
| 440 |
+
- ✓ Simple, no matching required
|
| 441 |
+
- ✓ Per-class natural decomposition
|
| 442 |
+
- ✓ Interpretable output
|
| 443 |
+
- ✗ Activity-DOA coupling trade-off
|
| 444 |
+
- ✗ Slightly lower ov1 class accuracy
|
| 445 |
+
|
| 446 |
+
### Recommended Development Progression
|
| 447 |
+
1. **Baseline** → Start with v9 (production)
|
| 448 |
+
2. **Diagnosis** → Run v11a (is DOA bottleneck?)
|
| 449 |
+
3. **Refinement** → Based on v11a, pick v11b or v11c
|
| 450 |
+
4. **Post-hoc** → v11d (activity calibration if needed)
|
| 451 |
+
|
| 452 |
+
---
|
| 453 |
+
|
| 454 |
+
## Summary: What Alternative Frameworks Exist
|
| 455 |
+
|
| 456 |
+
| Framework | Role | Implementation | Configuration |
|
| 457 |
+
|-----------|------|----------------|----|
|
| 458 |
+
| **Spatial-AST** | Inspiration | PreTrunkASTPredictionHeads | make_ov1_ast_config() |
|
| 459 |
+
| **DCASE SELD** | Paradigm | ACCDOAHeads (Route C) | make_ov123_local_spatial_accdoa_config() |
|
| 460 |
+
| **EINV2** | Track paradigm | SourceQueryDecoder (Route B) | make_ov1_local_spatial_v9_ov123_top4_config() |
|
| 461 |
+
| **DETR** (implicit) | Slot design | FrameSlotHead (Route A) | make_ov123_local_spatial_slot_config() |
|
| 462 |
+
|
| 463 |
+
All coexist through conditional compilation with no conflicts.
|
| 464 |
+
|
analyze_labels.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Analyze mono_target_label vs mono_audio_labels in ov1_foa.jsonl"""
|
| 3 |
+
import json
|
| 4 |
+
from collections import Counter
|
| 5 |
+
|
| 6 |
+
JSONL = "/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl"
|
| 7 |
+
|
| 8 |
+
train_samples = []
|
| 9 |
+
with open(JSONL) as f:
|
| 10 |
+
for line in f:
|
| 11 |
+
d = json.loads(line)
|
| 12 |
+
if d["split"] == "train":
|
| 13 |
+
train_samples.append(d)
|
| 14 |
+
|
| 15 |
+
print(f"Total train samples: {len(train_samples)}\n")
|
| 16 |
+
|
| 17 |
+
# Class distribution
|
| 18 |
+
label_counts = Counter(s["mono_target_label"] for s in train_samples)
|
| 19 |
+
print("=== mono_target_label distribution (train) ===")
|
| 20 |
+
for label, cnt in label_counts.most_common():
|
| 21 |
+
print(f" {label}: {cnt}")
|
| 22 |
+
print(f"Total unique labels: {len(label_counts)}\n")
|
| 23 |
+
|
| 24 |
+
# Specific labels
|
| 25 |
+
targets = ["guitar", "string_instrument", "musical_instrument", "singing", "male_singing", "female_singing"]
|
| 26 |
+
for target in targets:
|
| 27 |
+
matches = [s for s in train_samples if s["mono_target_label"] == target]
|
| 28 |
+
print(f'=== mono_target_label = "{target}" ({len(matches)} samples) ===')
|
| 29 |
+
if not matches:
|
| 30 |
+
print(" (no samples found)")
|
| 31 |
+
else:
|
| 32 |
+
combos = Counter(tuple(s["mono_audio_labels"]) for s in matches)
|
| 33 |
+
for combo, cnt in combos.most_common(20):
|
| 34 |
+
print(f" [{cnt}x] {list(combo)}")
|
| 35 |
+
print()
|
| 36 |
+
|
| 37 |
+
# primary labels
|
| 38 |
+
print("=== mono_primary_label for targets of interest ===")
|
| 39 |
+
for target in targets:
|
| 40 |
+
matches = [s for s in train_samples if s["mono_target_label"] == target]
|
| 41 |
+
if matches:
|
| 42 |
+
primaries = Counter(s["mono_primary_label"] for s in matches)
|
| 43 |
+
print(f" {target}: {dict(primaries.most_common(20))}")
|
| 44 |
+
print()
|
| 45 |
+
|
| 46 |
+
# Reverse: what target labels contain Musical_instrument
|
| 47 |
+
print('=== Samples with "Musical_instrument" in mono_audio_labels ===')
|
| 48 |
+
mi_labels = Counter()
|
| 49 |
+
for s in train_samples:
|
| 50 |
+
if "Musical_instrument" in s["mono_audio_labels"]:
|
| 51 |
+
mi_labels[s["mono_target_label"]] += 1
|
| 52 |
+
for label, cnt in mi_labels.most_common():
|
| 53 |
+
print(f" {label}: {cnt}")
|
analyze_v7h_epoch8.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Analyze v7h epoch 8 val CSV files.
|
| 4 |
+
Metrics: track dominance, dead-track rate, overlap coverage,
|
| 5 |
+
per-class recall, activity separation, DOA error.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import glob
|
| 10 |
+
import math
|
| 11 |
+
import collections
|
| 12 |
+
import csv
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
BASE = ("/apdcephfs_cq10/share_1603164/user/schmittzhu/code/unilm/beats/"
|
| 16 |
+
"checkpoints/spatial_beats_ov1_local_spatial_v7h_ov123_exp/"
|
| 17 |
+
"03_ov123_top4/val_predictions/epoch_0008_csv")
|
| 18 |
+
|
| 19 |
+
ACT_THRESH = 0.5 # threshold for "active" prediction
|
| 20 |
+
|
| 21 |
+
# ─────────────────────────── helpers ────────────────────────────
|
| 22 |
+
|
| 23 |
+
def read_csv(path):
|
| 24 |
+
rows = []
|
| 25 |
+
with open(path, newline="") as f:
|
| 26 |
+
reader = csv.DictReader(f)
|
| 27 |
+
for r in reader:
|
| 28 |
+
rows.append(r)
|
| 29 |
+
return rows
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def angular_error_deg(az1, el1, az2, el2):
|
| 33 |
+
"""Great-circle distance in degrees."""
|
| 34 |
+
az1, el1, az2, el2 = map(math.radians, [az1, el1, az2, el2])
|
| 35 |
+
x1 = math.cos(el1) * math.cos(az1)
|
| 36 |
+
y1 = math.cos(el1) * math.sin(az1)
|
| 37 |
+
z1 = math.sin(el1)
|
| 38 |
+
x2 = math.cos(el2) * math.cos(az2)
|
| 39 |
+
y2 = math.cos(el2) * math.sin(az2)
|
| 40 |
+
z2 = math.sin(el2)
|
| 41 |
+
dot = max(-1.0, min(1.0, x1*x2 + y1*y2 + z1*z2))
|
| 42 |
+
return math.degrees(math.acos(dot))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ─────────────────────────── load files ─────────────────────────
|
| 46 |
+
|
| 47 |
+
pred_files = sorted(glob.glob(os.path.join(BASE, "*__pred.csv")))
|
| 48 |
+
print(f"Found {len(pred_files)} pred files\n")
|
| 49 |
+
|
| 50 |
+
# ─────────────────────────── accumulators ───────────────────────
|
| 51 |
+
|
| 52 |
+
# 1. track dominance
|
| 53 |
+
track_dominant_count = collections.Counter() # track_idx → count of (sample,frame) where it dominates
|
| 54 |
+
total_frames_with_any_activity = 0
|
| 55 |
+
|
| 56 |
+
# 2/3. multi-source frames
|
| 57 |
+
multi_src_frames_total = 0
|
| 58 |
+
multi_src_dead_track_count = 0 # only 1 pred track active (>0.5)
|
| 59 |
+
multi_src_overlap_covered = 0 # ≥2 pred tracks active
|
| 60 |
+
|
| 61 |
+
# 4. per-class recall
|
| 62 |
+
class_correct = collections.Counter()
|
| 63 |
+
class_total = collections.Counter()
|
| 64 |
+
|
| 65 |
+
# 5. activity separation
|
| 66 |
+
gt_active_max_acts = []
|
| 67 |
+
gt_inactive_max_acts = []
|
| 68 |
+
|
| 69 |
+
# 6. DOA error
|
| 70 |
+
doa_errors = []
|
| 71 |
+
doa_errors_active_only = [] # only frames where ≥1 GT source exists
|
| 72 |
+
|
| 73 |
+
# debug per-sample
|
| 74 |
+
sample_stats = []
|
| 75 |
+
|
| 76 |
+
for pred_path in pred_files:
|
| 77 |
+
stem = pred_path.replace("__pred.csv", "")
|
| 78 |
+
gt_path = stem + "__gt.csv"
|
| 79 |
+
if not os.path.exists(gt_path):
|
| 80 |
+
print(f" MISSING GT: {gt_path}")
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
pred_rows = read_csv(pred_path)
|
| 84 |
+
gt_rows = read_csv(gt_path)
|
| 85 |
+
|
| 86 |
+
# ── build pred dict: frame → {track_idx: {act, cls, az, el, dist}} ──
|
| 87 |
+
pred_by_frame = collections.defaultdict(dict)
|
| 88 |
+
for r in pred_rows:
|
| 89 |
+
fi = int(r["frame_idx"])
|
| 90 |
+
ti = int(r["src_or_track_idx"])
|
| 91 |
+
act = float(r["activity_prob"])
|
| 92 |
+
pred_by_frame[fi][ti] = dict(
|
| 93 |
+
act=act,
|
| 94 |
+
cls=r["class_name"],
|
| 95 |
+
az=float(r["azimuth_deg"]),
|
| 96 |
+
el=float(r["elevation_deg"]),
|
| 97 |
+
dist=float(r["distance_m"]),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# ── build GT dict: frame → list of {cls, az, el, dist} ──
|
| 101 |
+
gt_by_frame = collections.defaultdict(list)
|
| 102 |
+
for r in gt_rows:
|
| 103 |
+
fi = int(r["frame_idx"])
|
| 104 |
+
gt_by_frame[fi].append(dict(
|
| 105 |
+
cls=r["class_name"],
|
| 106 |
+
az=float(r["azimuth_deg"]),
|
| 107 |
+
el=float(r["elevation_deg"]),
|
| 108 |
+
dist=float(r["distance_m"]),
|
| 109 |
+
))
|
| 110 |
+
|
| 111 |
+
all_frames = sorted(set(list(pred_by_frame.keys()) + list(gt_by_frame.keys())))
|
| 112 |
+
|
| 113 |
+
sample_dominant = collections.Counter()
|
| 114 |
+
sample_doa = []
|
| 115 |
+
sample_gt_active = 0
|
| 116 |
+
sample_gt_inactive = 0
|
| 117 |
+
|
| 118 |
+
for fi in all_frames:
|
| 119 |
+
tracks = pred_by_frame.get(fi, {})
|
| 120 |
+
gt_srcs = gt_by_frame.get(fi, [])
|
| 121 |
+
n_gt_active = len(gt_srcs)
|
| 122 |
+
|
| 123 |
+
if not tracks:
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
acts = {ti: tr["act"] for ti, tr in tracks.items()}
|
| 127 |
+
max_act = max(acts.values())
|
| 128 |
+
dom_track = max(acts, key=acts.get)
|
| 129 |
+
n_pred_active = sum(1 for a in acts.values() if a > ACT_THRESH)
|
| 130 |
+
|
| 131 |
+
# ── 1. track dominance ──
|
| 132 |
+
sample_dominant[dom_track] += 1
|
| 133 |
+
track_dominant_count[dom_track] += 1
|
| 134 |
+
|
| 135 |
+
# ── 5. activity separation ──
|
| 136 |
+
if n_gt_active > 0:
|
| 137 |
+
gt_active_max_acts.append(max_act)
|
| 138 |
+
sample_gt_active += 1
|
| 139 |
+
else:
|
| 140 |
+
gt_inactive_max_acts.append(max_act)
|
| 141 |
+
sample_gt_inactive += 1
|
| 142 |
+
|
| 143 |
+
# ── 2/3. multi-source (≥2 GT active) ──
|
| 144 |
+
if n_gt_active >= 2:
|
| 145 |
+
multi_src_frames_total += 1
|
| 146 |
+
if n_pred_active <= 1:
|
| 147 |
+
multi_src_dead_track_count += 1
|
| 148 |
+
if n_pred_active >= 2:
|
| 149 |
+
multi_src_overlap_covered += 1
|
| 150 |
+
|
| 151 |
+
# ── 4. per-class recall (dominant track vs GT) ──
|
| 152 |
+
if n_gt_active > 0:
|
| 153 |
+
dom_pred = tracks[dom_track]
|
| 154 |
+
for gt_src in gt_srcs:
|
| 155 |
+
class_total[gt_src["cls"]] += 1
|
| 156 |
+
if dom_pred["cls"] == gt_src["cls"]:
|
| 157 |
+
class_correct[gt_src["cls"]] += 1
|
| 158 |
+
break # count once even if multiple GT sources match
|
| 159 |
+
|
| 160 |
+
# ── 6. DOA error: compare dominant pred to closest GT ──
|
| 161 |
+
if n_gt_active > 0:
|
| 162 |
+
dom_pred = tracks[dom_track]
|
| 163 |
+
# find closest GT source angularly
|
| 164 |
+
min_err = min(
|
| 165 |
+
angular_error_deg(
|
| 166 |
+
dom_pred["az"], dom_pred["el"],
|
| 167 |
+
g["az"], g["el"]
|
| 168 |
+
)
|
| 169 |
+
for g in gt_srcs
|
| 170 |
+
)
|
| 171 |
+
doa_errors.append(min_err)
|
| 172 |
+
sample_doa.append(min_err)
|
| 173 |
+
|
| 174 |
+
sample_stats.append(dict(
|
| 175 |
+
name=os.path.basename(stem),
|
| 176 |
+
n_frames=len(all_frames),
|
| 177 |
+
gt_active_frames=sample_gt_active,
|
| 178 |
+
mean_doa=sum(sample_doa)/len(sample_doa) if sample_doa else float("nan"),
|
| 179 |
+
dominant=dict(sample_dominant),
|
| 180 |
+
))
|
| 181 |
+
|
| 182 |
+
# ─────────────────────────── report ─────────────────────────────
|
| 183 |
+
|
| 184 |
+
print("=" * 65)
|
| 185 |
+
print(" ANALYSIS: v7h epoch 8 — all 48 validation samples")
|
| 186 |
+
print("=" * 65)
|
| 187 |
+
|
| 188 |
+
# ── 1. Track dominance ──
|
| 189 |
+
total_dom = sum(track_dominant_count.values())
|
| 190 |
+
print("\n── 1. Track Dominance (by max activity per frame) ──")
|
| 191 |
+
for ti in sorted(track_dominant_count):
|
| 192 |
+
cnt = track_dominant_count[ti]
|
| 193 |
+
print(f" track{ti}: {cnt:6d} frames ({100*cnt/total_dom:.1f}%)")
|
| 194 |
+
print(f" TOTAL: {total_dom} frames evaluated")
|
| 195 |
+
|
| 196 |
+
# ── 2. Dead-track in multi-source frames ──
|
| 197 |
+
print(f"\n── 2. Dead Track in Multi-Source Frames ──")
|
| 198 |
+
print(f" Multi-src GT frames (≥2 active): {multi_src_frames_total}")
|
| 199 |
+
if multi_src_frames_total:
|
| 200 |
+
frac = multi_src_dead_track_count / multi_src_frames_total
|
| 201 |
+
print(f" Frames with ≤1 pred active (>0.5): {multi_src_dead_track_count} ({100*frac:.1f}%)")
|
| 202 |
+
else:
|
| 203 |
+
print(" (no multi-source frames found)")
|
| 204 |
+
|
| 205 |
+
# ── 3. Overlap coverage ──
|
| 206 |
+
print(f"\n── 3. Overlap Coverage ──")
|
| 207 |
+
if multi_src_frames_total:
|
| 208 |
+
cov = multi_src_overlap_covered / multi_src_frames_total
|
| 209 |
+
print(f" Multi-src frames with ≥2 pred active: {multi_src_overlap_covered}/{multi_src_frames_total} ({100*cov:.1f}%)")
|
| 210 |
+
|
| 211 |
+
# ── 4. Per-class recall ──
|
| 212 |
+
print(f"\n── 4. Per-Class Recall (dominant track cls == GT cls) ──")
|
| 213 |
+
all_classes = sorted(set(list(class_total.keys())))
|
| 214 |
+
print(f" {'Class':<30} {'Correct':>8} {'Total':>8} {'Recall%':>9}")
|
| 215 |
+
print(f" {'-'*30} {'-'*8} {'-'*8} {'-'*9}")
|
| 216 |
+
total_correct = 0
|
| 217 |
+
total_cls_total = 0
|
| 218 |
+
for cls in all_classes:
|
| 219 |
+
c = class_correct[cls]
|
| 220 |
+
t = class_total[cls]
|
| 221 |
+
total_correct += c
|
| 222 |
+
total_cls_total += t
|
| 223 |
+
print(f" {cls:<30} {c:>8} {t:>8} {100*c/t:>8.1f}%")
|
| 224 |
+
print(f" {'OVERALL':<30} {total_correct:>8} {total_cls_total:>8} "
|
| 225 |
+
f"{100*total_correct/total_cls_total:>8.1f}%")
|
| 226 |
+
|
| 227 |
+
# ── 5. Activity separation ──
|
| 228 |
+
print(f"\n── 5. Activity Separation ──")
|
| 229 |
+
mean_gt_act = sum(gt_active_max_acts) / len(gt_active_max_acts) if gt_active_max_acts else float("nan")
|
| 230 |
+
mean_no_act = sum(gt_inactive_max_acts) / len(gt_inactive_max_acts) if gt_inactive_max_acts else float("nan")
|
| 231 |
+
sep = mean_gt_act - mean_no_act
|
| 232 |
+
print(f" GT-active frames: n={len(gt_active_max_acts):6d} mean max-act = {mean_gt_act:.4f}")
|
| 233 |
+
print(f" GT-inactive frames:n={len(gt_inactive_max_acts):6d} mean max-act = {mean_no_act:.4f}")
|
| 234 |
+
print(f" Separation (Δ) : {sep:+.4f}")
|
| 235 |
+
|
| 236 |
+
# ── 6. DOA error ──
|
| 237 |
+
print(f"\n── 6. DOA Error (dominant track vs nearest GT source) ──")
|
| 238 |
+
if doa_errors:
|
| 239 |
+
mean_doa = sum(doa_errors) / len(doa_errors)
|
| 240 |
+
doa_sorted = sorted(doa_errors)
|
| 241 |
+
n = len(doa_sorted)
|
| 242 |
+
med_doa = doa_sorted[n // 2]
|
| 243 |
+
le30 = sum(1 for e in doa_errors if e <= 30) / n
|
| 244 |
+
le60 = sum(1 for e in doa_errors if e <= 60) / n
|
| 245 |
+
print(f" n frames = {n}")
|
| 246 |
+
print(f" Mean angular error : {mean_doa:.2f}°")
|
| 247 |
+
print(f" Median angular error: {med_doa:.2f}°")
|
| 248 |
+
print(f" % frames ≤ 30° : {100*le30:.1f}%")
|
| 249 |
+
print(f" % frames ≤ 60° : {100*le60:.1f}%")
|
| 250 |
+
|
| 251 |
+
# ── Per-sample summary ──
|
| 252 |
+
print(f"\n── Per-Sample Summary ──")
|
| 253 |
+
print(f" {'Sample':<55} {'GT-act':>7} {'MeanDOA':>9} Dominant-tracks")
|
| 254 |
+
for s in sample_stats:
|
| 255 |
+
dom_str = " ".join(f"t{k}:{v}" for k, v in sorted(s["dominant"].items()))
|
| 256 |
+
print(f" {s['name'][-52:]:<55} {s['gt_active_frames']:>7} {s['mean_doa']:>8.1f}° {dom_str}")
|
| 257 |
+
|
| 258 |
+
print("\nDone.")
|
beats_0410.md
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BEATs / Spatial-BEATs 0410 工作记录
|
| 2 |
+
|
| 3 |
+
本文档总结 2026-04-10 这轮关于 Spatial-BEATs、FOA 数据、BEATs 事件分类、空间分支设计与训练脚本的全部关键上下文,方便交给另一个模型继续检查。
|
| 4 |
+
|
| 5 |
+
## 1. 总体目标
|
| 6 |
+
|
| 7 |
+
目标是基于 BEATs 构建一个能处理 FOA 音频的 Spatial-BEATs:
|
| 8 |
+
|
| 9 |
+
- 输入是 FOA 四声道音频,模拟数据存储顺序为 `[W, Y, Z, X]`。
|
| 10 |
+
- 最终希望得到可以输给 LLM 的 spatial tokens。
|
| 11 |
+
- 当前优先级不是 LLM,而是先让 encoder 在 ov1 单源数据上同时具备事件分类能力和空间定位能力。
|
| 12 |
+
- 数据来自 `/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl`,后续还有 `ov2_foa.jsonl`、`ov3_foa.jsonl`。
|
| 13 |
+
- 标签最终使用 `/apdcephfs_cq12/share_302080740/user/schmittzhu/data/fsd50k/FSD50K.ground_truth/final_vocabulary.csv`,当前类别数为 65。
|
| 14 |
+
|
| 15 |
+
## 2. 数据与坐标相关结论
|
| 16 |
+
|
| 17 |
+
已确认的关键点:
|
| 18 |
+
|
| 19 |
+
- 模拟 FOA 音频文件存储顺序是 DCASE/FUMA 风格 `[W, Y, Z, X]`。
|
| 20 |
+
- 模型内部计算 spatial cue 时应该转成 `[W, X, Y, Z]`。
|
| 21 |
+
- `SpatialBEATsPreprocessor` 已经加入 `_reorder_dcase_wyzx_to_wxyz()`,把输入 `[W,Y,Z,X]` 转成内部 `[W,X,Y,Z]`。
|
| 22 |
+
- 用 FOA 数据直接算 IV 粗 azimuth 时,实验中 `atan2(+x,+y)` 在 1k 条上表现最好。这个现象之前让人怀疑坐标系,但更核心的训练问题后来集中到模型结构和 BEATs 语义迁移上。
|
| 23 |
+
- 用户的 ov1 数据已经整理好,manifest 内有 `split` 字段。训练、valid、test 通过 `train/valid/test` split 划分。
|
| 24 |
+
- 训练和推理时最长按 20s,超过截断;batch 内可变长度由 collate padding 和 `clip_duration_seconds/target_num_steps` 处理。
|
| 25 |
+
|
| 26 |
+
## 3. 早期 Spatial-BEATs 方案与问题
|
| 27 |
+
|
| 28 |
+
最初方案尝试过:
|
| 29 |
+
|
| 30 |
+
- `fixed_slot`:固定 K 个 slot 的预测头,配 matching 和 activity/class/azi/ele/dist loss。
|
| 31 |
+
- `mono_ast`:后 trunk 的 single-source task-token readout,输出 class / direction / distance。
|
| 32 |
+
- `pretrunk_ast`:把 distance / DoA / class task tokens 放到 BEATs trunk 前,类似 Spatial-AST/BAT 的 task token 思路。
|
| 33 |
+
|
| 34 |
+
主要失败表现:
|
| 35 |
+
|
| 36 |
+
- 多个 run 中 total loss 有时下降,但 `azi/ele` 基本不动。
|
| 37 |
+
- class 能力也不稳定,空间分支很难收敛。
|
| 38 |
+
- `pretrunk_ast` 曾经出现 DDP unused parameter 错误,因为某些分支参数没有参与当前 loss,后续通过冻结未用分支或 `find_unused_parameters` 思路处理。
|
| 39 |
+
|
| 40 |
+
典型日志:
|
| 41 |
+
|
| 42 |
+
```text
|
| 43 |
+
[Epoch 0] train: {'loss_total': 35.4775, 'loss_activity': 0.5503, 'loss_azi': 5.9033, 'loss_ele': 4.5695, 'loss_dist': 0.6497, 'loss_cls_aux': 2.6883, 'loss_temp': 0.0077}
|
| 44 |
+
[Epoch 0] val: {'loss_total': 35.8775, 'loss_activity': 0.5630, 'loss_azi': 5.8994, 'loss_ele': 4.5650, 'loss_dist': 0.6465, 'loss_cls_aux': 2.7140, 'loss_temp': 0.0005}
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
另一个修过 channel 后的 run:
|
| 48 |
+
|
| 49 |
+
```text
|
| 50 |
+
[Epoch 0] val: {'loss_total': 34.8984, 'loss_activity': 0.5585, 'loss_azi': 5.8970, 'loss_ele': 4.5694, 'loss_dist': 0.6417, 'loss_cls_aux': 2.5662, 'loss_temp': 0.0004}
|
| 51 |
+
[Epoch 4] val: {'loss_total': 32.7746, 'loss_activity': 0.5474, 'loss_azi': 5.9003, 'loss_ele': 4.5614, 'loss_dist': 0.6430, 'loss_cls_aux': 2.2496, 'loss_temp': 0.0004}
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
解释:total loss 下降主要来自 class aux 下降,`azi/ele/dist` 没有真正收敛。
|
| 55 |
+
|
| 56 |
+
## 4. 已发现并修过的重要 bug / 设计问题
|
| 57 |
+
|
| 58 |
+
### 4.1 输入值域与 BEATs 不匹配
|
| 59 |
+
|
| 60 |
+
原始 BEATs 的 preprocess 是 Kaldi fbank,并做:
|
| 61 |
+
|
| 62 |
+
```python
|
| 63 |
+
fbank = (fbank - 15.41663) / (2 * 6.55582)
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
早期 `SpatialBEATsPreprocessor` 使用 STFT mel 后直接 `log()`,值域和 BEATs patch embedding 预训练分布不匹配。
|
| 67 |
+
|
| 68 |
+
已改动:
|
| 69 |
+
|
| 70 |
+
- 加入 `waveform_scale = 2**15`。
|
| 71 |
+
- 加入 `fbank_mean = 15.41663`、`fbank_std = 6.55582`。
|
| 72 |
+
- `normalize_logmel=True` 时执行 `(logmel - mean) / (2 * std)`。
|
| 73 |
+
|
| 74 |
+
### 4.2 pretrunk task tokens 被 BEATs pos_conv 污染
|
| 75 |
+
|
| 76 |
+
BEATs encoder 的 `pos_conv` 是 Conv1d,早期把 task tokens 和 audio patches 拼一起送进去,会让 task token 与邻近 audio patch 通过卷积混合。
|
| 77 |
+
|
| 78 |
+
已改动:
|
| 79 |
+
|
| 80 |
+
- `encode_patches_with_pretrunk_task_tokens()` 中只对 audio patch 部分应用 `pos_conv`。
|
| 81 |
+
- task tokens 绕过 `pos_conv` 后再拼回去参与 self-attention layers。
|
| 82 |
+
|
| 83 |
+
### 4.3 FOA channel 顺序
|
| 84 |
+
|
| 85 |
+
早期没有正确处理 `[W,Y,Z,X]` 到 `[W,X,Y,Z]` 的重排。
|
| 86 |
+
|
| 87 |
+
已改动:
|
| 88 |
+
|
| 89 |
+
- `SpatialBEATsPreprocessor._reorder_dcase_wyzx_to_wxyz()`。
|
| 90 |
+
- `train_beats_event_classifier.py` 的 `foa4_fusion` 也按 `[W,X,Y,Z]` 返回。
|
| 91 |
+
|
| 92 |
+
### 4.4 7ch patch adapter / early fusion 问题
|
| 93 |
+
|
| 94 |
+
最早的 7ch 输入方案相当于让随机 adapter 直接扰动 pretrained BEATs patch input,训练很不稳定。
|
| 95 |
+
|
| 96 |
+
后来改成:
|
| 97 |
+
|
| 98 |
+
- base path: `W_logmel -> original single-channel BEATs patch embedding`。
|
| 99 |
+
- spatial delta path: `7ch FOA feature -> spatial adapter -> delta_patch_tokens`。
|
| 100 |
+
- `patch_tokens = base_patch_tokens + delta_patch_tokens`。
|
| 101 |
+
|
| 102 |
+
但这条线在空间监督上仍然不可靠,可能因为空间细节经过 BEATs trunk 后仍然被语义化/平滑掉。
|
| 103 |
+
|
| 104 |
+
## 5. 纯 BEATs 事件分类基线
|
| 105 |
+
|
| 106 |
+
为了确认 BEATs 在用户数据上的事件分类能力,新增了 `train_beats_event_classifier.py`。
|
| 107 |
+
|
| 108 |
+
用途:
|
| 109 |
+
|
| 110 |
+
- 不使用 Spatial-BEATs 的 spatial adapter/readout。
|
| 111 |
+
- 用 FOA 的 W channel 和原始 BEATs preprocess 训练 65 类事件分类。
|
| 112 |
+
- 验证 BEATs semantic branch 在当前数据上是否有效。
|
| 113 |
+
|
| 114 |
+
结构:
|
| 115 |
+
|
| 116 |
+
- `BEATsEventClassifier`
|
| 117 |
+
- `beats.extract_features(waveform, padding_mask)`
|
| 118 |
+
- mean-pool valid tokens
|
| 119 |
+
- `classifier = nn.Linear(cfg.encoder_embed_dim, num_classes)`
|
| 120 |
+
|
| 121 |
+
支持:
|
| 122 |
+
|
| 123 |
+
- `--channel-mode w/y/z/x/mean4/sum4/foa4_fusion`
|
| 124 |
+
- `--unfreeze-top-layers N`
|
| 125 |
+
- `--unfreeze-all-beats`
|
| 126 |
+
- DDP / torchrun
|
| 127 |
+
- checkpoint / resume / best 保存
|
| 128 |
+
|
| 129 |
+
重要 bug 修复:
|
| 130 |
+
|
| 131 |
+
- 4ch waveform 的 `length` 不能用 `wav.numel()`,应该用 `wav.shape[-1]`。
|
| 132 |
+
- `collate_batch` 已经按 `[B,4,T]` 正确 padding。
|
| 133 |
+
|
| 134 |
+
已有 W channel head-only 结果:
|
| 135 |
+
|
| 136 |
+
```text
|
| 137 |
+
[BEATsCls] trainable=49985 total=90361777
|
| 138 |
+
[Epoch 0] train: {'loss_cls': 1.4171, 'class_acc': 0.6443, 'count': 182000.0}
|
| 139 |
+
[Epoch 0] val: {'loss_cls': 1.4618, 'class_acc': 0.5990, 'count': 9600.0}
|
| 140 |
+
[Epoch 1] train: {'loss_cls': 0.9753, 'class_acc': 0.7317, 'count': 182000.0}
|
| 141 |
+
[Epoch 1] val: {'loss_cls': 1.3971, 'class_acc': 0.6150, 'count': 9600.0}
|
| 142 |
+
[Epoch 2] train: {'loss_cls': 0.8813, 'class_acc': 0.7547, 'count': 182000.0}
|
| 143 |
+
[Epoch 2] val: {'loss_cls': 1.3991, 'class_acc': 0.6114, 'count': 9600.0}
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
结论:
|
| 147 |
+
|
| 148 |
+
- W channel + BEATs 原生 preprocess 能很快得到约 60% valid acc。
|
| 149 |
+
- 这说明 BEATs semantic capability 是可用的。
|
| 150 |
+
- 单纯把 FOA4 直接用于随机 fusion 会更慢且初始 acc 很低,因为需要对 4 个声道分别算 fbank,且随机 4ch fusion 会破坏 BEATs pretrained input distribution。
|
| 151 |
+
|
| 152 |
+
## 6. 当前新的架构方向
|
| 153 |
+
|
| 154 |
+
当前判断:
|
| 155 |
+
|
| 156 |
+
- 不应该再强行让 7ch 空间输入直接进 BEATs trunk 学空间。
|
| 157 |
+
- 更稳妥的结构是:
|
| 158 |
+
|
| 159 |
+
```text
|
| 160 |
+
W channel -> BEATs semantic branch -> semantic temporal tokens
|
| 161 |
+
WXYZ + IV/SLASA-lite -> local spatial encoder -> spatial temporal tokens
|
| 162 |
+
semantic tokens + spatial tokens -> fusion -> prediction / LLM tokens
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
设计理由:
|
| 166 |
+
|
| 167 |
+
- BEATs 已经证明可以在 W channel 上提供事件语义。
|
| 168 |
+
- DOA/距离更依赖局部多通道相位/强度差,应该让专门的 spatial encoder 学。
|
| 169 |
+
- 参考 `/apdcephfs_cq10/share_1603164/user/schmittzhu/code/DCASE2024_seld_baseline/paper_3d_seld` 的思路:使用 CNN/ResNet/Conformer/MLP head 分支处理 DOA/SDE,而不是把所有东西压到一个 BEATs trunk 里。
|
| 170 |
+
|
| 171 |
+
## 7. 新增 local spatial 分支代码
|
| 172 |
+
|
| 173 |
+
### 7.1 `spatial_modules.py`
|
| 174 |
+
|
| 175 |
+
新增 `LocalSpatialEncoder`:
|
| 176 |
+
|
| 177 |
+
```text
|
| 178 |
+
Input:
|
| 179 |
+
foa_feat: [B, 7, T_f, F]
|
| 180 |
+
channel: [W, X, Y, Z, IVx, IVy, IVz]
|
| 181 |
+
|
| 182 |
+
CNN:
|
| 183 |
+
Conv2d(7, 64, 3, padding=1)
|
| 184 |
+
GroupNorm + GELU
|
| 185 |
+
Conv2d(64, 128, 3, stride=(1,2), padding=1)
|
| 186 |
+
GroupNorm + GELU
|
| 187 |
+
Conv2d(128, D_s, 3, stride=(1,2), padding=1)
|
| 188 |
+
GroupNorm + GELU
|
| 189 |
+
|
| 190 |
+
Frequency pooling:
|
| 191 |
+
mean over F
|
| 192 |
+
|
| 193 |
+
Temporal attention:
|
| 194 |
+
TransformerEncoder layers
|
| 195 |
+
|
| 196 |
+
Output:
|
| 197 |
+
spatial_tokens: [B, T_f, D_s]
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
新增 `LocalSpatialPredictionHeads`:
|
| 201 |
+
|
| 202 |
+
```text
|
| 203 |
+
Input:
|
| 204 |
+
fused_tokens: [B, T_s_max, D]
|
| 205 |
+
padding_mask: [B, T_s_max]
|
| 206 |
+
active_window_mask: [B, T_s_max]
|
| 207 |
+
|
| 208 |
+
Pooling:
|
| 209 |
+
class attention pool
|
| 210 |
+
spatial attention pool
|
| 211 |
+
|
| 212 |
+
Output:
|
| 213 |
+
task_tokens: [B, 2, D]
|
| 214 |
+
MonoTaskPredictionOutput:
|
| 215 |
+
pred_class_logits: [B, num_classes]
|
| 216 |
+
pred_direction: [B, 3]
|
| 217 |
+
pred_distance: [B, 1]
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
为了保留 finetuned class 能力,做了特殊初始化:
|
| 221 |
+
|
| 222 |
+
- `class_score.weight/bias = 0`
|
| 223 |
+
- `spatial_score.weight/bias = 0`
|
| 224 |
+
- 初始时 attention pooling 等价于均匀 mean-pool。
|
| 225 |
+
- class 分支是 `mean/attention pool -> Linear`,不额外加 LayerNorm,尽量贴近 `train_beats_event_classifier.py` baseline。
|
| 226 |
+
|
| 227 |
+
### 7.2 `spatial_beats.py`
|
| 228 |
+
|
| 229 |
+
新增 config:
|
| 230 |
+
|
| 231 |
+
```python
|
| 232 |
+
self.local_spatial_dim = 256
|
| 233 |
+
self.local_spatial_layers = 2
|
| 234 |
+
self.local_spatial_heads = 4
|
| 235 |
+
self.local_spatial_dropout = 0.1
|
| 236 |
+
self.local_spatial_proj_scale_init = 0.05
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
新增 `readout_scheme="local_spatial"`:
|
| 240 |
+
|
| 241 |
+
```text
|
| 242 |
+
foa_feat [B,7,T_f,F]
|
| 243 |
+
W-only base path -> BEATs trunk -> semantic embeddings [B,T_s,D]
|
| 244 |
+
local spatial encoder -> [B,T_f,D_s]
|
| 245 |
+
TemporalResampler -> [B,T_s,D_s]
|
| 246 |
+
Linear(D_s,D) small-scale init -> local_update [B,T_s,D]
|
| 247 |
+
fused_embeddings = LayerNorm(semantic_embeddings + local_update)
|
| 248 |
+
LocalSpatialPredictionHeads(fused_embeddings) -> mono_prediction_output
|
| 249 |
+
projector(fused_embeddings) -> llm_spatial_tokens
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
新增 `build_local_spatial_fusion()`,集中处理 local spatial tokens 的 resample、projection、fusion 和 prediction。
|
| 253 |
+
|
| 254 |
+
`SpatialBEATsOutput` 新增字段:
|
| 255 |
+
|
| 256 |
+
- `local_spatial_tokens: Optional[Tensor]`
|
| 257 |
+
- `fused_spatial_embeddings: Optional[Tensor]`
|
| 258 |
+
|
| 259 |
+
## 8. 接入 finetuned class checkpoint
|
| 260 |
+
|
| 261 |
+
用户提出希望把之前 finetune 过的 class checkpoint 接进来,让初始 class 能力更好。
|
| 262 |
+
|
| 263 |
+
新增 `SpatialBEATs.load_event_classifier_checkpoint()`:
|
| 264 |
+
|
| 265 |
+
支持加载 `train_beats_event_classifier.py` 保存的 checkpoint:
|
| 266 |
+
|
| 267 |
+
```text
|
| 268 |
+
beats.patch_embedding.weight -> patch_embedding.proj.weight
|
| 269 |
+
beats.patch_embedding.bias -> patch_embedding.proj.bias
|
| 270 |
+
beats.layer_norm.* -> layer_norm.*
|
| 271 |
+
beats.post_extract_proj.* -> post_extract_proj.*
|
| 272 |
+
beats.encoder.* -> encoder.*
|
| 273 |
+
classifier.weight -> local_spatial_prediction_heads.class_head.weight
|
| 274 |
+
classifier.bias -> local_spatial_prediction_heads.class_head.bias
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
加载日志验证:
|
| 278 |
+
|
| 279 |
+
```text
|
| 280 |
+
[SpatialBEATs] Loading event classifier checkpoint from checkpoints/beats_ov1_event_cls_head_only/best.pt
|
| 281 |
+
[SpatialBEATs] Loaded event classifier keys=204 missing_after_partial_load=79 unexpected=0
|
| 282 |
+
loaded checkpoints/beats_ov1_event_cls_head_only/best.pt
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
当前默认 class checkpoint:
|
| 286 |
+
|
| 287 |
+
```text
|
| 288 |
+
checkpoints/beats_ov1_event_cls_head_only/best.pt
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
注意:
|
| 292 |
+
|
| 293 |
+
- 曾尝试读取 `checkpoints/beats_ov1_cls_w_sweep_bs16_v1/01_head_only/best.pt`,出现:
|
| 294 |
+
|
| 295 |
+
```text
|
| 296 |
+
RuntimeError: PytorchStreamReader failed reading file data/15: file read failed
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
所以默认先用可正常读取的 `beats_ov1_event_cls_head_only/best.pt`。
|
| 300 |
+
|
| 301 |
+
## 9. `train_spatial_beats.py` 新增 preset 和参数
|
| 302 |
+
|
| 303 |
+
新增 preset:
|
| 304 |
+
|
| 305 |
+
```text
|
| 306 |
+
ov1_local_spatial
|
| 307 |
+
```
|
| 308 |
+
|
| 309 |
+
默认配置:
|
| 310 |
+
|
| 311 |
+
```python
|
| 312 |
+
batch_size = 8
|
| 313 |
+
num_workers = 4
|
| 314 |
+
num_epochs = 20
|
| 315 |
+
learning_rate = 1e-4
|
| 316 |
+
weight_decay = 0.05
|
| 317 |
+
freeze_trunk_in_stage1 = True
|
| 318 |
+
unfreeze_full_trunk = False
|
| 319 |
+
train_patch_embedding_in_stage1 = False
|
| 320 |
+
train_spatial_adapter_in_stage1 = False
|
| 321 |
+
freeze_projector_by_default = True
|
| 322 |
+
best_metric_name = "azi_mae_deg"
|
| 323 |
+
minimize_best_metric = True
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
loss:
|
| 327 |
+
|
| 328 |
+
```python
|
| 329 |
+
cfg.loss.supervision_mode = "mono_ast"
|
| 330 |
+
cfg.loss.lambda_cls_aux = 1.0
|
| 331 |
+
cfg.loss.lambda_direction = 12.0
|
| 332 |
+
cfg.loss.lambda_dist = 2.0
|
| 333 |
+
cfg.loss.lambda_activity = 0.0
|
| 334 |
+
cfg.loss.lambda_azi = 0.0
|
| 335 |
+
cfg.loss.lambda_ele = 0.0
|
| 336 |
+
cfg.loss.lambda_temp = 0.0
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
新增命令行参数:
|
| 340 |
+
|
| 341 |
+
```text
|
| 342 |
+
--class-finetuned-ckpt
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
默认在 `ov1_local_spatial` 中设置:
|
| 346 |
+
|
| 347 |
+
```python
|
| 348 |
+
cfg.class_finetuned_ckpt = "checkpoints/beats_ov1_event_cls_head_only/best.pt"
|
| 349 |
+
```
|
| 350 |
+
|
| 351 |
+
如果传其他路径,会覆盖默认;如果传空字符串,则不加载 class finetune checkpoint。
|
| 352 |
+
|
| 353 |
+
## 10. 运行 local spatial 实验
|
| 354 |
+
|
| 355 |
+
推荐先跑:
|
| 356 |
+
|
| 357 |
+
```bash
|
| 358 |
+
CUDA_VISIBLE_DEVICES=0,1,2,3 torchrun --nproc_per_node=4 --master_port=29531 \
|
| 359 |
+
train_spatial_beats.py \
|
| 360 |
+
--preset ov1_local_spatial \
|
| 361 |
+
--distributed \
|
| 362 |
+
--batch-size 8 \
|
| 363 |
+
--num-workers 4 \
|
| 364 |
+
--num-epochs 20 \
|
| 365 |
+
--output-dir checkpoints/spatial_beats_ov1_local_spatial_clsinit_run1
|
| 366 |
+
```
|
| 367 |
+
|
| 368 |
+
如果有更好的事件分类 checkpoint:
|
| 369 |
+
|
| 370 |
+
```bash
|
| 371 |
+
CUDA_VISIBLE_DEVICES=0,1,2,3 torchrun --nproc_per_node=4 --master_port=29531 \
|
| 372 |
+
train_spatial_beats.py \
|
| 373 |
+
--preset ov1_local_spatial \
|
| 374 |
+
--distributed \
|
| 375 |
+
--batch-size 8 \
|
| 376 |
+
--num-workers 4 \
|
| 377 |
+
--num-epochs 20 \
|
| 378 |
+
--output-dir checkpoints/spatial_beats_ov1_local_spatial_clsinit_run1 \
|
| 379 |
+
--class-finetuned-ckpt path/to/better/best.pt
|
| 380 |
+
```
|
| 381 |
+
|
| 382 |
+
## 11. 事件分类 unfreeze sweep 脚本修复
|
| 383 |
+
|
| 384 |
+
用户在跑 W channel sweep 时遇到:
|
| 385 |
+
|
| 386 |
+
```text
|
| 387 |
+
./run_beats_ov1_event_cls_unfreeze_sweep.sh: line 53: freeze-top-layers: command not found
|
| 388 |
+
```
|
| 389 |
+
|
| 390 |
+
当前文件中看到的是 `--unfreeze-top-layers`,但报错很像某次运行中的脚本断行或复制导致 `--un` 丢失、参数被 shell 当命令。
|
| 391 |
+
|
| 392 |
+
已重写 `run_beats_ov1_event_cls_unfreeze_sweep.sh`,不再跑 head/top4/top12,只跑:
|
| 393 |
+
|
| 394 |
+
- `01_top8`
|
| 395 |
+
- `02_full`
|
| 396 |
+
|
| 397 |
+
默认参数:
|
| 398 |
+
|
| 399 |
+
```bash
|
| 400 |
+
GPUS=${GPUS:-8}
|
| 401 |
+
BATCH_SIZE=${BATCH_SIZE:-8}
|
| 402 |
+
NUM_WORKERS=${NUM_WORKERS:-4}
|
| 403 |
+
RUN_ROOT=${RUN_ROOT:-checkpoints/beats_ov1_event_cls_top8_full}
|
| 404 |
+
CHANNEL_MODE=${CHANNEL_MODE:-w}
|
| 405 |
+
MASTER_PORT=${MASTER_PORT:-29501}
|
| 406 |
+
BASE_CLASS_CKPT=${BASE_CLASS_CKPT:-checkpoints/beats_ov1_event_cls_head_only/best.pt}
|
| 407 |
+
TOP8_EPOCHS=${TOP8_EPOCHS:-10}
|
| 408 |
+
FULL_EPOCHS=${FULL_EPOCHS:-10}
|
| 409 |
+
TOP8_LR=${TOP8_LR:-5e-5}
|
| 410 |
+
FULL_LR=${FULL_LR:-1e-5}
|
| 411 |
+
```
|
| 412 |
+
|
| 413 |
+
流程:
|
| 414 |
+
|
| 415 |
+
```text
|
| 416 |
+
01_top8:
|
| 417 |
+
resume model-only from BASE_CLASS_CKPT if it exists
|
| 418 |
+
--unfreeze-top-layers 8
|
| 419 |
+
|
| 420 |
+
02_full:
|
| 421 |
+
resume model-only from RUN_ROOT/01_top8/best.pt
|
| 422 |
+
--unfreeze-all-beats
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
已执行:
|
| 426 |
+
|
| 427 |
+
```bash
|
| 428 |
+
bash -n run_beats_ov1_event_cls_unfreeze_sweep.sh
|
| 429 |
+
```
|
| 430 |
+
|
| 431 |
+
语法通过。
|
| 432 |
+
|
| 433 |
+
W channel 运行命令:
|
| 434 |
+
|
| 435 |
+
```bash
|
| 436 |
+
CHANNEL_MODE=w GPUS=8 BATCH_SIZE=8 MASTER_PORT=29541 \
|
| 437 |
+
RUN_ROOT=checkpoints/beats_ov1_cls_w_top8_full_v1 \
|
| 438 |
+
./run_beats_ov1_event_cls_unfreeze_sweep.sh
|
| 439 |
+
```
|
| 440 |
+
|
| 441 |
+
指定其他起点:
|
| 442 |
+
|
| 443 |
+
```bash
|
| 444 |
+
BASE_CLASS_CKPT=path/to/best.pt CHANNEL_MODE=w GPUS=8 BATCH_SIZE=8 MASTER_PORT=29541 \
|
| 445 |
+
RUN_ROOT=checkpoints/beats_ov1_cls_w_top8_full_v1 \
|
| 446 |
+
./run_beats_ov1_event_cls_unfreeze_sweep.sh
|
| 447 |
+
```
|
| 448 |
+
|
| 449 |
+
## 12. 当前可验证状态
|
| 450 |
+
|
| 451 |
+
已做过的检查:
|
| 452 |
+
|
| 453 |
+
```bash
|
| 454 |
+
/usr/bin/python3 -m py_compile spatial_modules.py spatial_beats.py train_spatial_beats.py
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
通过。
|
| 458 |
+
|
| 459 |
+
`train_spatial_beats.py --help` 中已经包含:
|
| 460 |
+
|
| 461 |
+
```text
|
| 462 |
+
--preset ... ov1_local_spatial
|
| 463 |
+
--class-finetuned-ckpt CLASS_FINETUNED_CKPT
|
| 464 |
+
```
|
| 465 |
+
|
| 466 |
+
用 `spur` 环境做过 local spatial forward smoke test,小模型能跑:
|
| 467 |
+
|
| 468 |
+
```text
|
| 469 |
+
spatial_embeddings: torch.Size([2, 2, 64])
|
| 470 |
+
local_spatial_tokens: torch.Size([2, 2, 32])
|
| 471 |
+
pred_direction: torch.Size([2, 3])
|
| 472 |
+
temporal_padding_mask: torch.Size([2, 2])
|
| 473 |
+
```
|
| 474 |
+
|
| 475 |
+
注意:工具 shell 中 `python` 不在 PATH,使用 `/usr/bin/python3` 做语法检查;`/data/home/schmittzhu/miniconda3/envs/spur/bin/python` 有 torch,可用于模型加载测试。
|
| 476 |
+
|
| 477 |
+
## 13. 需要 Claude / 后续检查的重点
|
| 478 |
+
|
| 479 |
+
建议重点检查:
|
| 480 |
+
|
| 481 |
+
1. `LocalSpatialEncoder` 是否足够表达 DOA。当前是 CNN + TransformerEncoder,还不是完整 Conformer/CRNN;如果空间仍不收敛,可以换成 DCASE paper_3d_seld 风格的 ResNetConformerBackbone。
|
| 482 |
+
2. `fused_embeddings = LayerNorm(semantic + local_update)` 是否会轻微破坏 class checkpoint。现在 local projection 小尺度初始化、class pooling 均匀初始化,理论上初始破坏较小。
|
| 483 |
+
3. `SpatialBEATs` 的 W branch 是否和 `train_beats_event_classifier.py` 完全一致。当前 SpatialBEATs 使用自实现 STFT mel + BEATs 常数归一化,不是原始 Kaldi fbank,因此即使加载 class checkpoint,也可能与 classifier baseline 有 front-end 差异。
|
| 484 |
+
4. 是否应该进一步把 `train_beats_event_classifier.py` 的原始 BEATs preprocess 路径嵌入 SpatialBEATs semantic branch,完全复用 W-class checkpoint 的输入分布。
|
| 485 |
+
5. `ov1_local_spatial` 目前冻结 BEATs trunk,只训练 local spatial encoder 和 heads。如果空间 loss 下降但 class 不稳,可以考虑先冻结 class head,或者调低 local spatial update scale;如果空间不动,则需要更强的 spatial-only supervision 或更贴近 SELD 的 spatial branch。
|
| 486 |
+
6. 如果出现 DDP unused parameter,优先确认 `readout_scheme="local_spatial"` 时没有未参与 loss 但仍 `requires_grad=True` 的旧分支参数。
|
| 487 |
+
|
| 488 |
+
## 14. 简短问答记录
|
| 489 |
+
|
| 490 |
+
Q: 为什么不用 Linear,为什么要 decoder / slot query decoder?
|
| 491 |
+
|
| 492 |
+
A: 老师的意思是 BEATs 后面只要提供 decode 作用,不一定要 cross-attention decoder。只要能预测空间监督、让 loss 回传训练前面的 BEATs/encoder,后面可以是 transformer / fixed heads / shallow readout。slot query decoder 不是必须。
|
| 493 |
+
|
| 494 |
+
Q: K=4 多源 slot 怎么聚合?
|
| 495 |
+
|
| 496 |
+
A: 如果最终主要给 LLM spatial tokens,训练时的 K=4 slot 只是辅助监督,不必把 slot 输出作为最终 token。后续转向 ov1 单源后,先抛弃多源 matching,改用 single-source class/direction/distance。
|
| 497 |
+
|
| 498 |
+
Q: 角度直接分类是否合理?
|
| 499 |
+
|
| 500 |
+
A: 角度分类有问题,因为相邻角度惩罚不连续。后来改过 soft/circular angular loss 思路,但实际仍不收敛。现在 local_spatial 分支复用 `mono_ast` 的 Cartesian direction cosine loss,避免 azimuth/elevation bin CE。
|
| 501 |
+
|
| 502 |
+
Q: 为什么 4ch FOA event classification 比 W 慢很多、初始 acc 低?
|
| 503 |
+
|
| 504 |
+
A: `foa4_fusion` 对 4 个声道分别算 BEATs fbank,CPU 前端开销约 4 倍;随机 4ch fusion 还会破坏 BEATs 预训练输入分布,所以初始 acc 很低。更合理的是 W semantic branch + 独立 spatial branch。
|
| 505 |
+
|
| 506 |
+
Q: 现在是不是应该用 W-BEATs class 信息 + WXYZ/IV 空间信息 fusion?
|
| 507 |
+
|
| 508 |
+
A: 是。当前实现就是这个方向:W path 保留 BEATs semantic,local spatial encoder 从 7ch FOA features 学 DOA/距离,再晚期 fusion。
|
| 509 |
+
|
debug_v6dc_init.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
一次性验证 v6dc 初始化是否正确:
|
| 3 |
+
1. direct_cls_head.weight 是否从 foa_cls ckpt 加载
|
| 4 |
+
2. 加载后在随机一批 fake 数据上的 cls 准确率是否合理(随机期望 1/63≈1.6%,初始化好应>20%)
|
| 5 |
+
3. pre_readout_tokens 是否真的传到了 direct_cls_head
|
| 6 |
+
|
| 7 |
+
运行方式:
|
| 8 |
+
python debug_v6dc_init.py
|
| 9 |
+
"""
|
| 10 |
+
import sys
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, "/apdcephfs_cq10/share_1603164/user/schmittzhu/code/unilm/beats")
|
| 14 |
+
|
| 15 |
+
from train_spatial_beats import make_ov1_local_spatial_v6dc_classwarmup_config
|
| 16 |
+
from spatial_beats import SpatialBEATs
|
| 17 |
+
|
| 18 |
+
FOA_CLS_CKPT = "checkpoints/beats_ov1_foa_cls_v1/03_full/best.pt"
|
| 19 |
+
|
| 20 |
+
print("=" * 60)
|
| 21 |
+
print("Step 1: build model config")
|
| 22 |
+
cfg_wrapper = make_ov1_local_spatial_v6dc_classwarmup_config()
|
| 23 |
+
model_cfg = cfg_wrapper.model
|
| 24 |
+
print(f" use_direct_cls={model_cfg.use_direct_cls}")
|
| 25 |
+
print(f" readout_layers={model_cfg.readout_layers}")
|
| 26 |
+
print(f" bypass_spatial_delta={model_cfg.bypass_spatial_delta}")
|
| 27 |
+
print(f" class_finetuned_ckpt={cfg_wrapper.class_finetuned_ckpt}")
|
| 28 |
+
|
| 29 |
+
print("\nStep 2: build model")
|
| 30 |
+
model = SpatialBEATs(model_cfg)
|
| 31 |
+
|
| 32 |
+
print("\nStep 3: check direct_cls_head exists")
|
| 33 |
+
heads = model.local_spatial_prediction_heads
|
| 34 |
+
if heads is None:
|
| 35 |
+
print(" ERROR: local_spatial_prediction_heads is None!")
|
| 36 |
+
sys.exit(1)
|
| 37 |
+
if not hasattr(heads, "direct_cls_head"):
|
| 38 |
+
print(" ERROR: direct_cls_head does not exist in heads!")
|
| 39 |
+
sys.exit(1)
|
| 40 |
+
print(f" direct_cls_head: {heads.direct_cls_head}")
|
| 41 |
+
w_before = heads.direct_cls_head.weight.data.clone()
|
| 42 |
+
print(f" weight norm before load: {w_before.norm().item():.4f}")
|
| 43 |
+
|
| 44 |
+
print("\nStep 4: load foa_cls checkpoint")
|
| 45 |
+
# check ckpt exists
|
| 46 |
+
import os
|
| 47 |
+
if not os.path.exists(FOA_CLS_CKPT):
|
| 48 |
+
print(f" ERROR: ckpt not found: {FOA_CLS_CKPT}")
|
| 49 |
+
sys.exit(1)
|
| 50 |
+
model.load_event_classifier_checkpoint(FOA_CLS_CKPT)
|
| 51 |
+
w_after = heads.direct_cls_head.weight.data.clone()
|
| 52 |
+
print(f" weight norm after load: {w_after.norm().item():.4f}")
|
| 53 |
+
weight_changed = not torch.allclose(w_before, w_after)
|
| 54 |
+
print(f" weight changed: {weight_changed}")
|
| 55 |
+
if not weight_changed:
|
| 56 |
+
print(" *** PROBLEM: direct_cls_head.weight was NOT loaded! ***")
|
| 57 |
+
else:
|
| 58 |
+
print(" OK: direct_cls_head.weight was loaded from foa_cls ckpt")
|
| 59 |
+
|
| 60 |
+
# Also check what foa_cls classifier weight norm is
|
| 61 |
+
print("\nStep 5: compare with foa_cls classifier weight directly")
|
| 62 |
+
ckpt = torch.load(FOA_CLS_CKPT, map_location="cpu", weights_only=False)
|
| 63 |
+
sd = ckpt.get("model", ckpt)
|
| 64 |
+
if "classifier.weight" in sd:
|
| 65 |
+
cls_w = sd["classifier.weight"]
|
| 66 |
+
print(f" foa_cls classifier.weight shape: {cls_w.shape}, norm: {cls_w.norm().item():.4f}")
|
| 67 |
+
print(f" direct_cls_head.weight shape: {w_after.shape}, norm: {w_after.norm().item():.4f}")
|
| 68 |
+
match = torch.allclose(cls_w, w_after, atol=1e-5)
|
| 69 |
+
print(f" weights match exactly: {match}")
|
| 70 |
+
else:
|
| 71 |
+
print(" WARNING: 'classifier.weight' key not found in ckpt!")
|
| 72 |
+
print(f" Keys in ckpt: {list(sd.keys())[:20]}")
|
| 73 |
+
|
| 74 |
+
print("\nStep 6: forward pass with fake data to check cls logits")
|
| 75 |
+
model.eval()
|
| 76 |
+
with torch.no_grad():
|
| 77 |
+
B = 4
|
| 78 |
+
T = 16000 * 5 # 5 seconds
|
| 79 |
+
fake_waveform = torch.randn(B, 4, T)
|
| 80 |
+
out = model(fake_waveform)
|
| 81 |
+
mp = out.mono_prediction_output
|
| 82 |
+
if mp is None:
|
| 83 |
+
print(" ERROR: mono_prediction_output is None!")
|
| 84 |
+
sys.exit(1)
|
| 85 |
+
logits = mp.pred_class_logits # [B, 63]
|
| 86 |
+
probs = logits.softmax(dim=-1)
|
| 87 |
+
max_prob = probs.max(dim=-1).values
|
| 88 |
+
pred_cls = logits.argmax(dim=-1)
|
| 89 |
+
print(f" logits shape: {logits.shape}")
|
| 90 |
+
print(f" logit range: [{logits.min().item():.2f}, {logits.max().item():.2f}]")
|
| 91 |
+
print(f" max prob per sample: {max_prob.tolist()}")
|
| 92 |
+
print(f" pred classes: {pred_cls.tolist()}")
|
| 93 |
+
# If properly initialized, logits should NOT be near-uniform
|
| 94 |
+
# (uniform would be all ~0 logits since random init)
|
| 95 |
+
logit_std = logits.std().item()
|
| 96 |
+
print(f" logit std: {logit_std:.4f} (random init ≈ small, good init >> 0.1)")
|
| 97 |
+
if logit_std < 0.05:
|
| 98 |
+
print(" *** WARNING: logit_std very small — direct_cls_head may be outputting near-uniform ***")
|
| 99 |
+
|
| 100 |
+
print("\nStep 7: verify pre_readout_tokens path")
|
| 101 |
+
# Check readout_layers=0 means ShallowTemporalReadout is identity (only LN)
|
| 102 |
+
from spatial_modules import ShallowTemporalReadout
|
| 103 |
+
tr = model.temporal_readout
|
| 104 |
+
print(f" temporal_readout encoder: {tr.encoder}")
|
| 105 |
+
if tr.encoder is None:
|
| 106 |
+
print(" OK: readout_layers=0, ShallowTemporalReadout is pure LayerNorm")
|
| 107 |
+
else:
|
| 108 |
+
print(" *** WARNING: readout_layers>0, there is still a Transformer in ShallowTemporalReadout ***")
|
| 109 |
+
|
| 110 |
+
print("\n" + "=" * 60)
|
| 111 |
+
print("SUMMARY:")
|
| 112 |
+
print(f" weight_changed (ckpt loaded): {weight_changed}")
|
| 113 |
+
print(f" readout_layers=0 (no Transformer): {tr.encoder is None}")
|
| 114 |
+
print(f" logit_std: {logit_std:.4f}")
|
| 115 |
+
print("=" * 60)
|
eval_spatial_beats.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Evaluate a Spatial-BEATs checkpoint on the ov1 test split.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python eval_spatial_beats.py \
|
| 6 |
+
--checkpoint checkpoints/spatial_beats_ov1_local_spatial_v2_exp/02_spatial/best.pt \
|
| 7 |
+
--preset ov1_local_spatial_v2_spatial \
|
| 8 |
+
--batch-size 8 --num-workers 4
|
| 9 |
+
|
| 10 |
+
Prints per-sample predictions, aggregate SELD metrics, and summary stats.
|
| 11 |
+
"""
|
| 12 |
+
import argparse
|
| 13 |
+
import copy
|
| 14 |
+
import functools
|
| 15 |
+
import json
|
| 16 |
+
import math
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Dict, List, Optional, Tuple
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn.functional as F
|
| 23 |
+
from tqdm import tqdm
|
| 24 |
+
|
| 25 |
+
# ---- project imports ----
|
| 26 |
+
from spatial_beats import SpatialBEATs
|
| 27 |
+
from spatial_dataset import SpatialDataset, SpatialDatasetConfig, collate_spatial_batch
|
| 28 |
+
from spatial_loss import (
|
| 29 |
+
SELDMetricsAccumulator,
|
| 30 |
+
_azi_ele_deg_from_direction_vector,
|
| 31 |
+
_circular_distance_deg,
|
| 32 |
+
_to_dcase_azimuth,
|
| 33 |
+
build_primary_source_window_mask,
|
| 34 |
+
compute_mono_ast_losses,
|
| 35 |
+
compute_mono_ast_validation_metrics,
|
| 36 |
+
accumulate_mono_ast_seld,
|
| 37 |
+
compute_pretrunk_ast_losses,
|
| 38 |
+
compute_pretrunk_ast_validation_metrics,
|
| 39 |
+
SpatialLossConfig,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# ---- re-use config factories from training script ----
|
| 43 |
+
from train_spatial_beats import (
|
| 44 |
+
TrainSpatialBEATsConfig,
|
| 45 |
+
build_dataset_config,
|
| 46 |
+
make_ov1_local_spatial_v2_spatial_config,
|
| 47 |
+
make_ov1_local_spatial_v2_classwarmup_config,
|
| 48 |
+
make_ov1_local_spatial_kaldi_spatial_config,
|
| 49 |
+
make_ov1_local_spatial_kaldi_classwarmup_config,
|
| 50 |
+
make_ov1_local_spatial_bypass_spatial_config,
|
| 51 |
+
make_ov1_local_spatial_purify_spatial_config,
|
| 52 |
+
make_ov1_local_spatial_config,
|
| 53 |
+
make_ov1_local_spatial_v3_classwarmup_config,
|
| 54 |
+
make_ov1_local_spatial_v3_spatial_config,
|
| 55 |
+
make_ov1_local_spatial_v3ws_classwarmup_config,
|
| 56 |
+
make_ov1_local_spatial_v3ws_spatial_config,
|
| 57 |
+
make_ov1_local_spatial_v3b_classwarmup_config,
|
| 58 |
+
make_ov1_local_spatial_v3b_spatial_config,
|
| 59 |
+
make_ov1_local_spatial_v3bws_classwarmup_config,
|
| 60 |
+
make_ov1_local_spatial_v3bws_spatial_config,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
PRESET_MAP = {
|
| 65 |
+
"ov1_local_spatial_v2_spatial": make_ov1_local_spatial_v2_spatial_config,
|
| 66 |
+
"ov1_local_spatial_v2_classwarmup": make_ov1_local_spatial_v2_classwarmup_config,
|
| 67 |
+
"ov1_local_spatial_kaldi_spatial": make_ov1_local_spatial_kaldi_spatial_config,
|
| 68 |
+
"ov1_local_spatial_kaldi_classwarmup": make_ov1_local_spatial_kaldi_classwarmup_config,
|
| 69 |
+
"ov1_local_spatial_bypass_spatial": make_ov1_local_spatial_bypass_spatial_config,
|
| 70 |
+
"ov1_local_spatial_purify_spatial": make_ov1_local_spatial_purify_spatial_config,
|
| 71 |
+
"ov1_local_spatial": make_ov1_local_spatial_config,
|
| 72 |
+
"ov1_local_spatial_v3_classwarmup": make_ov1_local_spatial_v3_classwarmup_config,
|
| 73 |
+
"ov1_local_spatial_v3_spatial": make_ov1_local_spatial_v3_spatial_config,
|
| 74 |
+
"ov1_local_spatial_v3ws_classwarmup": make_ov1_local_spatial_v3ws_classwarmup_config,
|
| 75 |
+
"ov1_local_spatial_v3ws_spatial": make_ov1_local_spatial_v3ws_spatial_config,
|
| 76 |
+
"ov1_local_spatial_v3b_classwarmup": make_ov1_local_spatial_v3b_classwarmup_config,
|
| 77 |
+
"ov1_local_spatial_v3b_spatial": make_ov1_local_spatial_v3b_spatial_config,
|
| 78 |
+
"ov1_local_spatial_v3bws_classwarmup": make_ov1_local_spatial_v3bws_classwarmup_config,
|
| 79 |
+
"ov1_local_spatial_v3bws_spatial": make_ov1_local_spatial_v3bws_spatial_config,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def load_model(checkpoint_path: str, train_cfg: TrainSpatialBEATsConfig, device: torch.device) -> SpatialBEATs:
|
| 84 |
+
"""Load a SpatialBEATs model from a checkpoint file."""
|
| 85 |
+
ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 86 |
+
# Training checkpoints use 'model_state_dict'; BEATs originals use 'model'
|
| 87 |
+
if "model_state_dict" in ckpt:
|
| 88 |
+
state_dict = ckpt["model_state_dict"]
|
| 89 |
+
elif "model" in ckpt:
|
| 90 |
+
state_dict = ckpt["model"]
|
| 91 |
+
else:
|
| 92 |
+
state_dict = ckpt
|
| 93 |
+
|
| 94 |
+
model = SpatialBEATs(train_cfg.model)
|
| 95 |
+
missing, unexpected = model.load_state_dict(state_dict, strict=False)
|
| 96 |
+
if missing:
|
| 97 |
+
print(f"[WARN] Missing keys ({len(missing)}): {missing[:5]}{'...' if len(missing) > 5 else ''}")
|
| 98 |
+
if unexpected:
|
| 99 |
+
print(f"[WARN] Unexpected keys ({len(unexpected)}): {unexpected[:5]}{'...' if len(unexpected) > 5 else ''}")
|
| 100 |
+
|
| 101 |
+
model = model.to(device)
|
| 102 |
+
model.eval()
|
| 103 |
+
return model
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def build_test_loader(
|
| 107 |
+
train_cfg: TrainSpatialBEATsConfig,
|
| 108 |
+
batch_size: int,
|
| 109 |
+
num_workers: int,
|
| 110 |
+
) -> torch.utils.data.DataLoader:
|
| 111 |
+
"""Build a DataLoader for the test split."""
|
| 112 |
+
dataset_cfg = build_dataset_config(train_cfg)
|
| 113 |
+
test_cfg = copy.deepcopy(dataset_cfg)
|
| 114 |
+
test_cfg.allowed_splits = train_cfg.test_splits
|
| 115 |
+
|
| 116 |
+
# Resolve manifest paths: try test > val > train (both plural and singular forms)
|
| 117 |
+
manifest_paths = train_cfg.test_manifest_paths
|
| 118 |
+
if not manifest_paths:
|
| 119 |
+
manifest_paths = train_cfg.val_manifest_paths
|
| 120 |
+
if not manifest_paths:
|
| 121 |
+
manifest_paths = train_cfg.train_manifest_paths
|
| 122 |
+
if not manifest_paths and train_cfg.train_manifest_path:
|
| 123 |
+
manifest_paths = (train_cfg.train_manifest_path,)
|
| 124 |
+
datasets = []
|
| 125 |
+
for path in manifest_paths:
|
| 126 |
+
ds = SpatialDataset(manifest_path=path, config=test_cfg)
|
| 127 |
+
if len(ds) > 0:
|
| 128 |
+
datasets.append(ds)
|
| 129 |
+
|
| 130 |
+
if not datasets:
|
| 131 |
+
raise RuntimeError("No test samples found!")
|
| 132 |
+
|
| 133 |
+
if len(datasets) == 1:
|
| 134 |
+
dataset = datasets[0]
|
| 135 |
+
else:
|
| 136 |
+
dataset = torch.utils.data.ConcatDataset(datasets)
|
| 137 |
+
|
| 138 |
+
print(f"[Eval] Test set: {len(dataset)} samples")
|
| 139 |
+
|
| 140 |
+
collate_fn = functools.partial(collate_spatial_batch, config=test_cfg)
|
| 141 |
+
|
| 142 |
+
return torch.utils.data.DataLoader(
|
| 143 |
+
dataset,
|
| 144 |
+
batch_size=batch_size,
|
| 145 |
+
shuffle=False,
|
| 146 |
+
num_workers=num_workers,
|
| 147 |
+
collate_fn=collate_fn,
|
| 148 |
+
pin_memory=True,
|
| 149 |
+
drop_last=False,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _move_batch_to_device(batch, device):
|
| 154 |
+
"""Move batch tensors to device."""
|
| 155 |
+
import dataclasses
|
| 156 |
+
field_vals = {}
|
| 157 |
+
for f in dataclasses.fields(batch):
|
| 158 |
+
val = getattr(batch, f.name)
|
| 159 |
+
if isinstance(val, torch.Tensor):
|
| 160 |
+
field_vals[f.name] = val.to(device)
|
| 161 |
+
else:
|
| 162 |
+
field_vals[f.name] = val
|
| 163 |
+
return type(batch)(**field_vals)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def evaluate(
|
| 167 |
+
model: SpatialBEATs,
|
| 168 |
+
test_loader: torch.utils.data.DataLoader,
|
| 169 |
+
loss_cfg: SpatialLossConfig,
|
| 170 |
+
device: torch.device,
|
| 171 |
+
output_jsonl: Optional[str] = None,
|
| 172 |
+
) -> Dict[str, float]:
|
| 173 |
+
"""Run full evaluation, return aggregate metrics."""
|
| 174 |
+
supervision_mode = loss_cfg.supervision_mode
|
| 175 |
+
is_mono = supervision_mode in ("mono_ast", "pretrunk_ast")
|
| 176 |
+
seld_acc = SELDMetricsAccumulator() if is_mono else None
|
| 177 |
+
|
| 178 |
+
# Running metric sums
|
| 179 |
+
running = {
|
| 180 |
+
"class_acc": 0.0,
|
| 181 |
+
"azi_mae_deg": 0.0,
|
| 182 |
+
"ele_mae_deg": 0.0,
|
| 183 |
+
"dist_mae": 0.0,
|
| 184 |
+
"matched_count": 0.0,
|
| 185 |
+
}
|
| 186 |
+
num_batches = 0
|
| 187 |
+
all_examples: List[Dict] = []
|
| 188 |
+
|
| 189 |
+
with torch.no_grad():
|
| 190 |
+
for batch in tqdm(test_loader, desc="Evaluating", leave=True):
|
| 191 |
+
batch = _move_batch_to_device(batch, device)
|
| 192 |
+
|
| 193 |
+
# Forward
|
| 194 |
+
mono_window_mask = None
|
| 195 |
+
if supervision_mode == "mono_ast":
|
| 196 |
+
mono_window_mask = build_primary_source_window_mask(
|
| 197 |
+
batch=batch,
|
| 198 |
+
t_s_max=int(batch.target_num_steps.max().item()),
|
| 199 |
+
).to(device)
|
| 200 |
+
|
| 201 |
+
model_output = model(
|
| 202 |
+
waveform=batch.waveform,
|
| 203 |
+
padding_mask=batch.waveform_padding_mask,
|
| 204 |
+
clip_duration_seconds=batch.clip_duration_seconds,
|
| 205 |
+
mono_window_mask=mono_window_mask,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
if supervision_mode == "mono_ast":
|
| 209 |
+
pred_out = model_output.mono_prediction_output
|
| 210 |
+
metric_output = compute_mono_ast_validation_metrics(
|
| 211 |
+
prediction_output=pred_out, batch=batch,
|
| 212 |
+
)
|
| 213 |
+
if seld_acc is not None:
|
| 214 |
+
accumulate_mono_ast_seld(
|
| 215 |
+
prediction_output=pred_out, batch=batch, accumulator=seld_acc,
|
| 216 |
+
)
|
| 217 |
+
# Build per-sample examples
|
| 218 |
+
pred_azi, pred_ele = _azi_ele_deg_from_direction_vector(pred_out.pred_direction)
|
| 219 |
+
pred_cls = pred_out.pred_class_logits.argmax(dim=-1)
|
| 220 |
+
pred_cls_prob = pred_out.pred_class_logits.softmax(dim=-1).amax(dim=-1)
|
| 221 |
+
for idx in range(len(batch.sample_ids)):
|
| 222 |
+
all_examples.append({
|
| 223 |
+
"sample_id": batch.sample_ids[idx],
|
| 224 |
+
"gt_class_index": int(batch.source_class_indices[idx, 0].item()),
|
| 225 |
+
"gt_class_name": batch.source_class_labels[idx][0] if batch.source_class_labels else None,
|
| 226 |
+
"pred_class_index": int(pred_cls[idx].item()),
|
| 227 |
+
"pred_class_confidence": round(float(pred_cls_prob[idx].item()), 4),
|
| 228 |
+
"gt_azimuth_deg": round(float(batch.source_azimuth_deg[idx, 0, 0].item()), 2),
|
| 229 |
+
"pred_azimuth_deg": round(float(pred_azi[idx].item()), 2),
|
| 230 |
+
"gt_elevation_deg": round(float(batch.source_elevation_deg[idx, 0, 0].item()), 2),
|
| 231 |
+
"pred_elevation_deg": round(float(pred_ele[idx].item()), 2),
|
| 232 |
+
"gt_distance_m": round(float(batch.source_distance[idx, 0, 0].item()), 4),
|
| 233 |
+
"pred_distance_m": round(float(pred_out.pred_distance[idx, 0].item()), 4),
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
elif supervision_mode == "pretrunk_ast":
|
| 237 |
+
pred_out = model_output.pretrunk_prediction_output
|
| 238 |
+
metric_output = compute_pretrunk_ast_validation_metrics(
|
| 239 |
+
prediction_output=pred_out, batch=batch, config=loss_cfg,
|
| 240 |
+
)
|
| 241 |
+
pred_cls = pred_out.pred_class_logits.argmax(dim=-1)
|
| 242 |
+
pred_cls_prob = pred_out.pred_class_logits.softmax(dim=-1).amax(dim=-1)
|
| 243 |
+
pred_azi = _to_dcase_azimuth(
|
| 244 |
+
pred_out.pred_azi_logits.argmax(dim=-1).to(dtype=torch.float32)
|
| 245 |
+
)
|
| 246 |
+
pred_ele = pred_out.pred_ele_logits.argmax(dim=-1) - 90
|
| 247 |
+
pred_dist = (
|
| 248 |
+
pred_out.pred_distance_logits.argmax(dim=-1).to(dtype=torch.float32)
|
| 249 |
+
* float(loss_cfg.distance_bin_size_m)
|
| 250 |
+
)
|
| 251 |
+
for idx in range(len(batch.sample_ids)):
|
| 252 |
+
all_examples.append({
|
| 253 |
+
"sample_id": batch.sample_ids[idx],
|
| 254 |
+
"gt_class_index": int(batch.source_class_indices[idx, 0].item()),
|
| 255 |
+
"gt_class_name": batch.source_class_labels[idx][0] if batch.source_class_labels else None,
|
| 256 |
+
"pred_class_index": int(pred_cls[idx].item()),
|
| 257 |
+
"pred_class_confidence": round(float(pred_cls_prob[idx].item()), 4),
|
| 258 |
+
"gt_azimuth_deg": round(float(batch.source_azimuth_deg[idx, 0, 0].item()), 2),
|
| 259 |
+
"pred_azimuth_deg": round(float(pred_azi[idx].item()), 2),
|
| 260 |
+
"gt_elevation_deg": round(float(batch.source_elevation_deg[idx, 0, 0].item()), 2),
|
| 261 |
+
"pred_elevation_deg": round(float(pred_ele[idx].item()), 2),
|
| 262 |
+
"gt_distance_m": round(float(batch.source_distance[idx, 0, 0].item()), 4),
|
| 263 |
+
"pred_distance_m": round(float(pred_dist[idx].item()), 4),
|
| 264 |
+
})
|
| 265 |
+
else:
|
| 266 |
+
raise NotImplementedError(f"Eval for supervision_mode={supervision_mode} not yet supported")
|
| 267 |
+
|
| 268 |
+
running["class_acc"] += float(metric_output.class_acc.item())
|
| 269 |
+
running["azi_mae_deg"] += float(metric_output.azi_mae_deg.item())
|
| 270 |
+
running["ele_mae_deg"] += float(metric_output.ele_mae_deg.item())
|
| 271 |
+
running["dist_mae"] += float(metric_output.dist_mae.item())
|
| 272 |
+
running["matched_count"] += float(metric_output.matched_count.item())
|
| 273 |
+
num_batches += 1
|
| 274 |
+
|
| 275 |
+
# Aggregate
|
| 276 |
+
metrics = {k: v / max(num_batches, 1) for k, v in running.items()}
|
| 277 |
+
if seld_acc is not None:
|
| 278 |
+
metrics.update(seld_acc.compute())
|
| 279 |
+
|
| 280 |
+
# Save all predictions
|
| 281 |
+
if output_jsonl:
|
| 282 |
+
out_path = Path(output_jsonl)
|
| 283 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 284 |
+
with out_path.open("w") as f:
|
| 285 |
+
for ex in all_examples:
|
| 286 |
+
f.write(json.dumps(ex, ensure_ascii=True) + "\n")
|
| 287 |
+
print(f"[Eval] Saved {len(all_examples)} predictions to {out_path}")
|
| 288 |
+
|
| 289 |
+
return metrics
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def main():
|
| 293 |
+
parser = argparse.ArgumentParser(description="Evaluate Spatial-BEATs on ov1 test set")
|
| 294 |
+
parser.add_argument("--checkpoint", required=True, help="Path to checkpoint .pt file")
|
| 295 |
+
parser.add_argument("--preset", required=True, choices=list(PRESET_MAP.keys()),
|
| 296 |
+
help="Config preset name (must match the checkpoint's training config)")
|
| 297 |
+
parser.add_argument("--batch-size", type=int, default=8)
|
| 298 |
+
parser.add_argument("--num-workers", type=int, default=4)
|
| 299 |
+
parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 300 |
+
parser.add_argument("--output-jsonl", default=None,
|
| 301 |
+
help="Path to save per-sample predictions (default: auto)")
|
| 302 |
+
args = parser.parse_args()
|
| 303 |
+
|
| 304 |
+
device = torch.device(args.device)
|
| 305 |
+
print(f"[Eval] Device: {device}")
|
| 306 |
+
|
| 307 |
+
# Build config
|
| 308 |
+
cfg_factory = PRESET_MAP[args.preset]
|
| 309 |
+
train_cfg = cfg_factory()
|
| 310 |
+
|
| 311 |
+
# Auto output path
|
| 312 |
+
if args.output_jsonl is None:
|
| 313 |
+
ckpt_dir = Path(args.checkpoint).parent
|
| 314 |
+
args.output_jsonl = str(ckpt_dir / "test_predictions.jsonl")
|
| 315 |
+
|
| 316 |
+
# Load model
|
| 317 |
+
print(f"[Eval] Loading checkpoint: {args.checkpoint}")
|
| 318 |
+
model = load_model(args.checkpoint, train_cfg, device)
|
| 319 |
+
|
| 320 |
+
# Build test loader
|
| 321 |
+
print(f"[Eval] Building test loader (split={train_cfg.test_splits})")
|
| 322 |
+
test_loader = build_test_loader(train_cfg, args.batch_size, args.num_workers)
|
| 323 |
+
|
| 324 |
+
# Evaluate
|
| 325 |
+
metrics = evaluate(model, test_loader, train_cfg.loss, device, args.output_jsonl)
|
| 326 |
+
|
| 327 |
+
# Print results
|
| 328 |
+
print("\n" + "=" * 60)
|
| 329 |
+
print(" EVALUATION RESULTS (ov1 test set)")
|
| 330 |
+
print("=" * 60)
|
| 331 |
+
for k, v in sorted(metrics.items()):
|
| 332 |
+
if isinstance(v, float):
|
| 333 |
+
print(f" {k:25s}: {v:.4f}")
|
| 334 |
+
else:
|
| 335 |
+
print(f" {k:25s}: {v}")
|
| 336 |
+
print("=" * 60)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
if __name__ == "__main__":
|
| 340 |
+
main()
|
eval_v12_per_subset.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Per-subset evaluation for v12 (ov1_unified_v12).
|
| 3 |
+
|
| 4 |
+
对 v12 训练得到的 checkpoint(unified_spatial_foa_fsd63_all 数据集上训练)
|
| 5 |
+
按子集独立评估,输出每个子集的 F20/ER20/LE_CD/LR_CD/SELD_score
|
| 6 |
+
以及 oracle_class_acc / oracle_azi_mae_deg / oracle_ele_mae_deg / oracle_dist_mae
|
| 7 |
+
等诊断指标,用于分析:
|
| 8 |
+
1) cls vs 空间 谁是瓶颈
|
| 9 |
+
2) 真实 vs 仿真 的 gap
|
| 10 |
+
3) 多源(ov2/ov3)vs 单源 的差距
|
| 11 |
+
|
| 12 |
+
支持的子集:
|
| 13 |
+
- ov1_sim / ov2_sim / ov3_sim (旧仿真 OV 数据,static,所有帧同一 DOA)
|
| 14 |
+
- ov1_real / ov2_real / ov3_real(DCASE real static mapped,更接近真实混响)
|
| 15 |
+
- dcase_starss (DCASE STARSS23 / 22 / TAU 混合,real dynamic)
|
| 16 |
+
- unified_valid / unified_test (新 unified 数据,混合 sim_static + qa_sim + dcase_real)
|
| 17 |
+
|
| 18 |
+
用法:
|
| 19 |
+
python eval_v12_per_subset.py \\
|
| 20 |
+
--checkpoint checkpoints/spatial_beats_ov1_unified_v12_exp/03_ov123_top4/best.pt \\
|
| 21 |
+
--preset ov1_unified_v12 \\
|
| 22 |
+
--split valid \\
|
| 23 |
+
--batch-size 8 --num-workers 8 --amp bf16
|
| 24 |
+
|
| 25 |
+
# 跑 test 集合
|
| 26 |
+
python eval_v12_per_subset.py \\
|
| 27 |
+
--checkpoint checkpoints/spatial_beats_ov1_unified_v12_exp/03_ov123_top4/best.pt \\
|
| 28 |
+
--preset ov1_unified_v12 \\
|
| 29 |
+
--split test \\
|
| 30 |
+
--batch-size 8 --num-workers 8 --amp bf16
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import argparse
|
| 35 |
+
import contextlib
|
| 36 |
+
import copy
|
| 37 |
+
import dataclasses
|
| 38 |
+
import functools
|
| 39 |
+
import json
|
| 40 |
+
from pathlib import Path
|
| 41 |
+
from types import SimpleNamespace
|
| 42 |
+
from typing import Dict, List, Optional, Tuple
|
| 43 |
+
|
| 44 |
+
import torch
|
| 45 |
+
from tqdm.auto import tqdm
|
| 46 |
+
|
| 47 |
+
from spatial_beats import SpatialBEATs
|
| 48 |
+
from spatial_dataset import SpatialDataset, collate_spatial_batch
|
| 49 |
+
from spatial_loss import (
|
| 50 |
+
OfficialDCASEMetricsAccumulator,
|
| 51 |
+
accumulate_frame_track_seld,
|
| 52 |
+
compute_frame_track_validation_metrics,
|
| 53 |
+
)
|
| 54 |
+
from train_spatial_beats import (
|
| 55 |
+
DEFAULT_DCASE_STARSS_VALID_MANIFEST,
|
| 56 |
+
DEFAULT_OV1_MANIFEST,
|
| 57 |
+
DEFAULT_OV2_MANIFEST,
|
| 58 |
+
DEFAULT_OV3_MANIFEST,
|
| 59 |
+
DEFAULT_OV1_REAL_MANIFEST,
|
| 60 |
+
DEFAULT_OV2_REAL_MANIFEST,
|
| 61 |
+
DEFAULT_OV3_REAL_MANIFEST,
|
| 62 |
+
DEFAULT_UNIFIED_TRAIN_MANIFEST,
|
| 63 |
+
DEFAULT_UNIFIED_VALID_MANIFEST,
|
| 64 |
+
TrainSpatialBEATsConfig,
|
| 65 |
+
build_dataset_config,
|
| 66 |
+
build_model_config,
|
| 67 |
+
build_train_config_from_args,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
DEFAULT_DCASE_STARSS_TEST_MANIFEST = (
|
| 72 |
+
"/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/"
|
| 73 |
+
"dcase_starss_foa.test.jsonl"
|
| 74 |
+
)
|
| 75 |
+
DEFAULT_UNIFIED_TEST_MANIFEST = (
|
| 76 |
+
"/apdcephfs_cq12/share_302080740/user/schmittzhu/data/"
|
| 77 |
+
"unified_spatial_foa_fsd63_all/test.jsonl"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def parse_args() -> argparse.Namespace:
|
| 82 |
+
p = argparse.ArgumentParser()
|
| 83 |
+
p.add_argument("--checkpoint", required=True)
|
| 84 |
+
p.add_argument("--preset", required=True)
|
| 85 |
+
p.add_argument(
|
| 86 |
+
"--split",
|
| 87 |
+
choices=("valid", "test"),
|
| 88 |
+
default="valid",
|
| 89 |
+
help="valid uses each dataset's valid split; test uses the test split",
|
| 90 |
+
)
|
| 91 |
+
# sim manifests (ov1/2/3)
|
| 92 |
+
p.add_argument("--ov1-manifest", default=DEFAULT_OV1_MANIFEST)
|
| 93 |
+
p.add_argument("--ov2-manifest", default=DEFAULT_OV2_MANIFEST)
|
| 94 |
+
p.add_argument("--ov3-manifest", default=DEFAULT_OV3_MANIFEST)
|
| 95 |
+
# real (DCASE static mapped) manifests
|
| 96 |
+
p.add_argument("--ov1-real-manifest", default=DEFAULT_OV1_REAL_MANIFEST)
|
| 97 |
+
p.add_argument("--ov2-real-manifest", default=DEFAULT_OV2_REAL_MANIFEST)
|
| 98 |
+
p.add_argument("--ov3-real-manifest", default=DEFAULT_OV3_REAL_MANIFEST)
|
| 99 |
+
# dcase_starss (full real, dynamic)
|
| 100 |
+
p.add_argument(
|
| 101 |
+
"--dcase-starss-valid-manifest",
|
| 102 |
+
default=DEFAULT_DCASE_STARSS_VALID_MANIFEST,
|
| 103 |
+
)
|
| 104 |
+
p.add_argument(
|
| 105 |
+
"--dcase-starss-test-manifest",
|
| 106 |
+
default=DEFAULT_DCASE_STARSS_TEST_MANIFEST,
|
| 107 |
+
)
|
| 108 |
+
# unified
|
| 109 |
+
p.add_argument("--unified-valid-manifest", default=DEFAULT_UNIFIED_VALID_MANIFEST)
|
| 110 |
+
p.add_argument("--unified-test-manifest", default=DEFAULT_UNIFIED_TEST_MANIFEST)
|
| 111 |
+
# runtime
|
| 112 |
+
p.add_argument("--batch-size", type=int, default=8)
|
| 113 |
+
p.add_argument("--num-workers", type=int, default=8)
|
| 114 |
+
p.add_argument("--amp", choices=("fp32", "bf16", "fp16"), default="bf16")
|
| 115 |
+
p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 116 |
+
p.add_argument("--output-json", default=None)
|
| 117 |
+
p.add_argument("--activity-threshold", type=float, default=0.5)
|
| 118 |
+
p.add_argument(
|
| 119 |
+
"--max-samples-per-subset",
|
| 120 |
+
type=int,
|
| 121 |
+
default=-1,
|
| 122 |
+
help="-1 = no cap (evaluate everything)",
|
| 123 |
+
)
|
| 124 |
+
p.add_argument(
|
| 125 |
+
"--only-subsets",
|
| 126 |
+
default="",
|
| 127 |
+
help="comma-separated subset names to evaluate (empty=all). "
|
| 128 |
+
"Options: ov1_sim,ov2_sim,ov3_sim,ov1_real,ov2_real,ov3_real,"
|
| 129 |
+
"dcase_starss,unified",
|
| 130 |
+
)
|
| 131 |
+
return p.parse_args()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def build_cfg(args: argparse.Namespace) -> TrainSpatialBEATsConfig:
|
| 135 |
+
ns = SimpleNamespace(
|
| 136 |
+
preset=args.preset,
|
| 137 |
+
ov1_manifest=args.ov1_manifest,
|
| 138 |
+
ov2_manifest=args.ov2_manifest,
|
| 139 |
+
ov3_manifest=args.ov3_manifest,
|
| 140 |
+
ov1_real_manifest=args.ov1_real_manifest,
|
| 141 |
+
ov2_real_manifest=args.ov2_real_manifest,
|
| 142 |
+
ov3_real_manifest=args.ov3_real_manifest,
|
| 143 |
+
dcase_starss_valid_manifest=args.dcase_starss_valid_manifest,
|
| 144 |
+
unified_train_manifest=DEFAULT_UNIFIED_TRAIN_MANIFEST,
|
| 145 |
+
unified_valid_manifest=args.unified_valid_manifest,
|
| 146 |
+
batch_size=None,
|
| 147 |
+
num_workers=None,
|
| 148 |
+
amp=None,
|
| 149 |
+
num_epochs=None,
|
| 150 |
+
learning_rate=None,
|
| 151 |
+
weight_decay=None,
|
| 152 |
+
output_dir=None,
|
| 153 |
+
class_finetuned_ckpt=None,
|
| 154 |
+
init_from_spatial_ckpt=None,
|
| 155 |
+
resume=None,
|
| 156 |
+
no_resume_optimizer=False,
|
| 157 |
+
reset_epoch_on_resume=False,
|
| 158 |
+
reset_best_on_resume=False,
|
| 159 |
+
crop_mode=None,
|
| 160 |
+
max_clip_duration_seconds=None,
|
| 161 |
+
save_every_n_epochs=None,
|
| 162 |
+
train_projector_in_stage1=False,
|
| 163 |
+
freeze_trunk=False,
|
| 164 |
+
no_progress=False,
|
| 165 |
+
distributed=False,
|
| 166 |
+
local_rank=None,
|
| 167 |
+
distributed_backend=None,
|
| 168 |
+
ddp_find_unused_parameters=False,
|
| 169 |
+
)
|
| 170 |
+
cfg = build_train_config_from_args(ns)
|
| 171 |
+
cfg.batch_size = int(args.batch_size)
|
| 172 |
+
cfg.num_workers = int(args.num_workers)
|
| 173 |
+
cfg.amp_dtype = args.amp
|
| 174 |
+
cfg.distributed = False
|
| 175 |
+
cfg.show_progress_bars = True
|
| 176 |
+
cfg.dump_val_predictions = False
|
| 177 |
+
cfg.num_val_prediction_examples = 0
|
| 178 |
+
return cfg
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def load_model(
|
| 182 |
+
ckpt_path: str, cfg: TrainSpatialBEATsConfig, device: torch.device
|
| 183 |
+
) -> SpatialBEATs:
|
| 184 |
+
model_cfg = build_model_config(cfg)
|
| 185 |
+
model = SpatialBEATs(model_cfg)
|
| 186 |
+
sd = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 187 |
+
state_dict = sd["model_state_dict"] if "model_state_dict" in sd else sd.get("model", sd)
|
| 188 |
+
missing, unexpected = model.load_state_dict(state_dict, strict=False)
|
| 189 |
+
if missing:
|
| 190 |
+
print(f"[Eval] WARN missing({len(missing)}): {missing[:6]}")
|
| 191 |
+
if unexpected:
|
| 192 |
+
print(f"[Eval] WARN unexpected({len(unexpected)}): {unexpected[:6]}")
|
| 193 |
+
model.to(device).eval()
|
| 194 |
+
return model
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _amp_ctx(dtype: str):
|
| 198 |
+
if not torch.cuda.is_available():
|
| 199 |
+
return contextlib.nullcontext()
|
| 200 |
+
if dtype == "bf16":
|
| 201 |
+
return torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16)
|
| 202 |
+
if dtype == "fp16":
|
| 203 |
+
return torch.amp.autocast(device_type="cuda", dtype=torch.float16)
|
| 204 |
+
return contextlib.nullcontext()
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _move_to_device(batch, device):
|
| 208 |
+
field_vals = {}
|
| 209 |
+
for f in dataclasses.fields(batch):
|
| 210 |
+
v = getattr(batch, f.name)
|
| 211 |
+
field_vals[f.name] = v.to(device) if isinstance(v, torch.Tensor) else v
|
| 212 |
+
return type(batch)(**field_vals)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def build_subset_plan(args: argparse.Namespace) -> List[Tuple[str, str, Tuple[str, ...]]]:
|
| 216 |
+
"""返回 [(subset_name, manifest_path, allowed_splits), ...]."""
|
| 217 |
+
if args.split == "valid":
|
| 218 |
+
plan = [
|
| 219 |
+
("ov1_sim", args.ov1_manifest, ("valid",)),
|
| 220 |
+
("ov2_sim", args.ov2_manifest, ("valid",)),
|
| 221 |
+
("ov3_sim", args.ov3_manifest, ("valid",)),
|
| 222 |
+
("ov1_real", args.ov1_real_manifest, ("valid",)),
|
| 223 |
+
("ov2_real", args.ov2_real_manifest, ("valid",)),
|
| 224 |
+
("ov3_real", args.ov3_real_manifest, ("valid",)),
|
| 225 |
+
("dcase_starss", args.dcase_starss_valid_manifest, ("valid",)),
|
| 226 |
+
("unified", args.unified_valid_manifest, ("valid",)),
|
| 227 |
+
]
|
| 228 |
+
else:
|
| 229 |
+
# test:旧 ov 数据集有 test split,dcase 也有
|
| 230 |
+
plan = [
|
| 231 |
+
("ov1_sim", args.ov1_manifest, ("test",)),
|
| 232 |
+
("ov2_sim", args.ov2_manifest, ("test",)),
|
| 233 |
+
("ov3_sim", args.ov3_manifest, ("test",)),
|
| 234 |
+
("ov1_real", args.ov1_real_manifest, ("test",)),
|
| 235 |
+
("ov2_real", args.ov2_real_manifest, ("test",)),
|
| 236 |
+
("ov3_real", args.ov3_real_manifest, ("test",)),
|
| 237 |
+
("dcase_starss", args.dcase_starss_test_manifest, ("test",)),
|
| 238 |
+
("unified", args.unified_test_manifest, ("test",)),
|
| 239 |
+
]
|
| 240 |
+
|
| 241 |
+
filter_set = {s.strip() for s in args.only_subsets.split(",") if s.strip()}
|
| 242 |
+
if filter_set:
|
| 243 |
+
plan = [entry for entry in plan if entry[0] in filter_set]
|
| 244 |
+
# drop entries whose manifest file does not exist
|
| 245 |
+
filtered = []
|
| 246 |
+
for name, path, splits in plan:
|
| 247 |
+
if not Path(path).exists():
|
| 248 |
+
print(f"[Eval] SKIP subset={name} (manifest not found: {path})")
|
| 249 |
+
continue
|
| 250 |
+
filtered.append((name, path, splits))
|
| 251 |
+
return filtered
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _empty_running() -> Dict[str, float]:
|
| 255 |
+
return {
|
| 256 |
+
"oracle_class_acc": 0.0,
|
| 257 |
+
"oracle_azi_mae_deg": 0.0,
|
| 258 |
+
"oracle_ele_mae_deg": 0.0,
|
| 259 |
+
"oracle_dist_mae": 0.0,
|
| 260 |
+
"class_acc": 0.0,
|
| 261 |
+
"azi_mae_deg": 0.0,
|
| 262 |
+
"ele_mae_deg": 0.0,
|
| 263 |
+
"dist_mae": 0.0,
|
| 264 |
+
"activity_precision": 0.0,
|
| 265 |
+
"activity_recall": 0.0,
|
| 266 |
+
"activity_acc": 0.0,
|
| 267 |
+
"matched_count": 0.0,
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def eval_one_subset(
|
| 272 |
+
model: SpatialBEATs,
|
| 273 |
+
cfg: TrainSpatialBEATsConfig,
|
| 274 |
+
subset_name: str,
|
| 275 |
+
manifest_path: str,
|
| 276 |
+
allowed_splits: Tuple[str, ...],
|
| 277 |
+
device: torch.device,
|
| 278 |
+
activity_threshold: float,
|
| 279 |
+
max_samples: int,
|
| 280 |
+
) -> Dict[str, float]:
|
| 281 |
+
ds_cfg = copy.deepcopy(build_dataset_config(cfg))
|
| 282 |
+
ds_cfg.allowed_splits = allowed_splits
|
| 283 |
+
dataset = SpatialDataset(manifest_path=manifest_path, config=ds_cfg)
|
| 284 |
+
total = len(dataset)
|
| 285 |
+
print(f"\n[Eval][{subset_name}] manifest={manifest_path}")
|
| 286 |
+
print(f"[Eval][{subset_name}] split={allowed_splits} size={total}")
|
| 287 |
+
if total == 0:
|
| 288 |
+
print(f"[Eval][{subset_name}] empty — skip")
|
| 289 |
+
return {"subset": subset_name, "manifest": manifest_path, "size": 0}
|
| 290 |
+
if max_samples > 0 and total > max_samples:
|
| 291 |
+
indices = list(range(max_samples))
|
| 292 |
+
dataset = torch.utils.data.Subset(dataset, indices)
|
| 293 |
+
print(f"[Eval][{subset_name}] capped to first {max_samples}")
|
| 294 |
+
|
| 295 |
+
collate = functools.partial(collate_spatial_batch, config=ds_cfg)
|
| 296 |
+
loader = torch.utils.data.DataLoader(
|
| 297 |
+
dataset,
|
| 298 |
+
batch_size=cfg.batch_size,
|
| 299 |
+
shuffle=False,
|
| 300 |
+
num_workers=cfg.num_workers,
|
| 301 |
+
collate_fn=collate,
|
| 302 |
+
pin_memory=True,
|
| 303 |
+
drop_last=False,
|
| 304 |
+
persistent_workers=cfg.num_workers > 0,
|
| 305 |
+
prefetch_factor=4 if cfg.num_workers > 0 else None,
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
running = _empty_running()
|
| 309 |
+
num_batches = 0
|
| 310 |
+
seld_acc = OfficialDCASEMetricsAccumulator()
|
| 311 |
+
with torch.no_grad():
|
| 312 |
+
for batch in tqdm(loader, desc=f"Eval {subset_name}", leave=False):
|
| 313 |
+
batch = _move_to_device(batch, device)
|
| 314 |
+
with _amp_ctx(cfg.amp_dtype):
|
| 315 |
+
model_output = model(
|
| 316 |
+
waveform=batch.waveform,
|
| 317 |
+
padding_mask=batch.waveform_padding_mask,
|
| 318 |
+
clip_duration_seconds=batch.clip_duration_seconds,
|
| 319 |
+
mono_window_mask=None,
|
| 320 |
+
)
|
| 321 |
+
pred_out = model_output.frame_track_prediction_output
|
| 322 |
+
if pred_out is None:
|
| 323 |
+
raise RuntimeError("frame_track_prediction_output is None")
|
| 324 |
+
metric_output = compute_frame_track_validation_metrics(
|
| 325 |
+
prediction_output=pred_out,
|
| 326 |
+
batch=batch,
|
| 327 |
+
temporal_padding_mask=model_output.temporal_padding_mask,
|
| 328 |
+
config=cfg.loss,
|
| 329 |
+
)
|
| 330 |
+
accumulate_frame_track_seld(
|
| 331 |
+
prediction_output=pred_out,
|
| 332 |
+
batch=batch,
|
| 333 |
+
temporal_padding_mask=model_output.temporal_padding_mask,
|
| 334 |
+
accumulator=seld_acc,
|
| 335 |
+
activity_threshold=activity_threshold,
|
| 336 |
+
)
|
| 337 |
+
for key in running:
|
| 338 |
+
v = getattr(metric_output, key, None)
|
| 339 |
+
if v is None:
|
| 340 |
+
continue
|
| 341 |
+
running[key] += float(v.item())
|
| 342 |
+
num_batches += 1
|
| 343 |
+
|
| 344 |
+
metrics = {k: v / max(num_batches, 1) for k, v in running.items()}
|
| 345 |
+
metrics.update(seld_acc.compute())
|
| 346 |
+
metrics["subset"] = subset_name
|
| 347 |
+
metrics["manifest"] = manifest_path
|
| 348 |
+
metrics["size"] = total
|
| 349 |
+
return metrics
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _fmt(v, digits=4):
|
| 353 |
+
try:
|
| 354 |
+
return f"{float(v):.{digits}f}"
|
| 355 |
+
except Exception:
|
| 356 |
+
return "nan"
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def print_summary(all_metrics: List[Dict[str, float]], split: str) -> None:
|
| 360 |
+
if not all_metrics:
|
| 361 |
+
print("[Eval] no metrics")
|
| 362 |
+
return
|
| 363 |
+
print("\n" + "=" * 96)
|
| 364 |
+
print(f" v12 per-subset ({split} split)")
|
| 365 |
+
print("=" * 96)
|
| 366 |
+
hdr = (
|
| 367 |
+
f"{'subset':<16} {'N':>6} "
|
| 368 |
+
f"{'F20':>6} {'ER20':>6} {'LE_CD':>7} {'LR_CD':>6} {'SELD':>6} "
|
| 369 |
+
f"{'o_cls':>6} {'o_azi':>6} {'o_ele':>6} {'o_dst':>6} "
|
| 370 |
+
f"{'a_P':>5} {'a_R':>5}"
|
| 371 |
+
)
|
| 372 |
+
print(hdr)
|
| 373 |
+
print("-" * len(hdr))
|
| 374 |
+
for m in all_metrics:
|
| 375 |
+
if m.get("size", 0) == 0:
|
| 376 |
+
print(f"{m['subset']:<16} {'EMPTY':>6}")
|
| 377 |
+
continue
|
| 378 |
+
print(
|
| 379 |
+
f"{m['subset']:<16} "
|
| 380 |
+
f"{m['size']:>6} "
|
| 381 |
+
f"{_fmt(m.get('F20'), 4):>6} "
|
| 382 |
+
f"{_fmt(m.get('ER20'), 4):>6} "
|
| 383 |
+
f"{_fmt(m.get('LE_CD'), 2):>7} "
|
| 384 |
+
f"{_fmt(m.get('LR_CD'), 4):>6} "
|
| 385 |
+
f"{_fmt(m.get('SELD_score'), 4):>6} "
|
| 386 |
+
f"{_fmt(m.get('oracle_class_acc'), 4):>6} "
|
| 387 |
+
f"{_fmt(m.get('oracle_azi_mae_deg'), 2):>6} "
|
| 388 |
+
f"{_fmt(m.get('oracle_ele_mae_deg'), 2):>6} "
|
| 389 |
+
f"{_fmt(m.get('oracle_dist_mae'), 4):>6} "
|
| 390 |
+
f"{_fmt(m.get('activity_precision'), 3):>5} "
|
| 391 |
+
f"{_fmt(m.get('activity_recall'), 3):>5}"
|
| 392 |
+
)
|
| 393 |
+
print("=" * 96)
|
| 394 |
+
print(" legend: F20↑ ER20↓ LE_CD↓ LR_CD↑ SELD↓ "
|
| 395 |
+
"o_cls=oracle_class_acc o_azi/ele=oracle doa MAE (deg) "
|
| 396 |
+
"a_P/a_R=activity precision/recall")
|
| 397 |
+
print("=" * 96)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def main() -> None:
|
| 401 |
+
args = parse_args()
|
| 402 |
+
device = torch.device(args.device)
|
| 403 |
+
print(f"[Eval] Device: {device}")
|
| 404 |
+
print(f"[Eval] Checkpoint: {args.checkpoint}")
|
| 405 |
+
print(f"[Eval] Preset: {args.preset}")
|
| 406 |
+
print(f"[Eval] Split: {args.split}")
|
| 407 |
+
|
| 408 |
+
cfg = build_cfg(args)
|
| 409 |
+
assert cfg.loss.supervision_mode == "local_spatial_track", (
|
| 410 |
+
f"Expected local_spatial_track, got {cfg.loss.supervision_mode}."
|
| 411 |
+
)
|
| 412 |
+
if device.type != "cuda":
|
| 413 |
+
cfg.amp_dtype = "fp32"
|
| 414 |
+
|
| 415 |
+
model = load_model(args.checkpoint, cfg, device)
|
| 416 |
+
|
| 417 |
+
plan = build_subset_plan(args)
|
| 418 |
+
all_metrics: List[Dict[str, float]] = []
|
| 419 |
+
for subset_name, manifest_path, allowed_splits in plan:
|
| 420 |
+
m = eval_one_subset(
|
| 421 |
+
model=model,
|
| 422 |
+
cfg=cfg,
|
| 423 |
+
subset_name=subset_name,
|
| 424 |
+
manifest_path=manifest_path,
|
| 425 |
+
allowed_splits=allowed_splits,
|
| 426 |
+
device=device,
|
| 427 |
+
activity_threshold=args.activity_threshold,
|
| 428 |
+
max_samples=args.max_samples_per_subset,
|
| 429 |
+
)
|
| 430 |
+
all_metrics.append(m)
|
| 431 |
+
# 打印单子集即时结果
|
| 432 |
+
if m.get("size", 0) > 0:
|
| 433 |
+
print(
|
| 434 |
+
f"[Eval][{subset_name}] F20={_fmt(m.get('F20'))} "
|
| 435 |
+
f"ER20={_fmt(m.get('ER20'))} LE_CD={_fmt(m.get('LE_CD'), 2)} "
|
| 436 |
+
f"LR_CD={_fmt(m.get('LR_CD'))} SELD={_fmt(m.get('SELD_score'))} "
|
| 437 |
+
f"o_cls={_fmt(m.get('oracle_class_acc'))} "
|
| 438 |
+
f"o_azi={_fmt(m.get('oracle_azi_mae_deg'), 2)} "
|
| 439 |
+
f"o_ele={_fmt(m.get('oracle_ele_mae_deg'), 2)} "
|
| 440 |
+
f"o_dst={_fmt(m.get('oracle_dist_mae'))} "
|
| 441 |
+
f"a_P={_fmt(m.get('activity_precision'))} "
|
| 442 |
+
f"a_R={_fmt(m.get('activity_recall'))}"
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
print_summary(all_metrics, args.split)
|
| 446 |
+
|
| 447 |
+
out_path = args.output_json
|
| 448 |
+
if out_path is None:
|
| 449 |
+
out_path = str(
|
| 450 |
+
Path(args.checkpoint).parent
|
| 451 |
+
/ f"eval_v12_per_subset_{args.split}.json"
|
| 452 |
+
)
|
| 453 |
+
with open(out_path, "w") as f:
|
| 454 |
+
json.dump(
|
| 455 |
+
{
|
| 456 |
+
"checkpoint": args.checkpoint,
|
| 457 |
+
"preset": args.preset,
|
| 458 |
+
"split": args.split,
|
| 459 |
+
"activity_threshold": args.activity_threshold,
|
| 460 |
+
"per_subset": all_metrics,
|
| 461 |
+
},
|
| 462 |
+
f,
|
| 463 |
+
indent=2,
|
| 464 |
+
ensure_ascii=True,
|
| 465 |
+
)
|
| 466 |
+
print(f"\n[Eval] Summary saved to {out_path}")
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
if __name__ == "__main__":
|
| 470 |
+
main()
|
quantizer.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --------------------------------------------------------
|
| 2 |
+
# BEATs: Audio Pre-Training with Acoustic Tokenizers (https://arxiv.org/abs/2212.09058)
|
| 3 |
+
# Github source: https://github.com/microsoft/unilm/tree/master/beats
|
| 4 |
+
# Copyright (c) 2022 Microsoft
|
| 5 |
+
# Licensed under The MIT License [see LICENSE for details]
|
| 6 |
+
# Based on VQGAN code bases
|
| 7 |
+
# https://github.com/CompVis/taming-transformers
|
| 8 |
+
# --------------------------------------------------------'
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
import torch.distributed as distributed
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from einops import rearrange, repeat
|
| 17 |
+
except ImportError:
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def l2norm(t):
|
| 22 |
+
return F.normalize(t, p=2, dim=-1)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def ema_inplace(moving_avg, new, decay):
|
| 26 |
+
moving_avg.data.mul_(decay).add_(new, alpha=(1 - decay))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def sample_vectors(samples, num):
|
| 30 |
+
num_samples, device = samples.shape[0], samples.device
|
| 31 |
+
|
| 32 |
+
if num_samples >= num:
|
| 33 |
+
indices = torch.randperm(num_samples, device=device)[:num]
|
| 34 |
+
else:
|
| 35 |
+
indices = torch.randint(0, num_samples, (num,), device=device)
|
| 36 |
+
|
| 37 |
+
return samples[indices]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def kmeans(samples, num_clusters, num_iters=10, use_cosine_sim=False):
|
| 41 |
+
dim, dtype, device = samples.shape[-1], samples.dtype, samples.device
|
| 42 |
+
|
| 43 |
+
means = sample_vectors(samples, num_clusters)
|
| 44 |
+
|
| 45 |
+
for _ in range(num_iters):
|
| 46 |
+
if use_cosine_sim:
|
| 47 |
+
dists = samples @ means.t()
|
| 48 |
+
else:
|
| 49 |
+
diffs = rearrange(samples, 'n d -> n () d') \
|
| 50 |
+
- rearrange(means, 'c d -> () c d')
|
| 51 |
+
dists = -(diffs ** 2).sum(dim=-1)
|
| 52 |
+
|
| 53 |
+
buckets = dists.max(dim=-1).indices
|
| 54 |
+
bins = torch.bincount(buckets, minlength=num_clusters)
|
| 55 |
+
zero_mask = bins == 0
|
| 56 |
+
bins_min_clamped = bins.masked_fill(zero_mask, 1)
|
| 57 |
+
|
| 58 |
+
new_means = buckets.new_zeros(num_clusters, dim, dtype=dtype)
|
| 59 |
+
new_means.scatter_add_(0, repeat(buckets, 'n -> n d', d=dim), samples)
|
| 60 |
+
new_means = new_means / bins_min_clamped[..., None]
|
| 61 |
+
|
| 62 |
+
if use_cosine_sim:
|
| 63 |
+
new_means = l2norm(new_means)
|
| 64 |
+
|
| 65 |
+
means = torch.where(zero_mask[..., None], means, new_means)
|
| 66 |
+
|
| 67 |
+
return means, bins
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class EmbeddingEMA(nn.Module):
|
| 71 |
+
def __init__(self, num_tokens, codebook_dim, decay=0.99, eps=1e-5, kmeans_init=True, codebook_init_path=''):
|
| 72 |
+
super().__init__()
|
| 73 |
+
self.num_tokens = num_tokens
|
| 74 |
+
self.codebook_dim = codebook_dim
|
| 75 |
+
self.decay = decay
|
| 76 |
+
self.eps = eps
|
| 77 |
+
if codebook_init_path == '':
|
| 78 |
+
if not kmeans_init:
|
| 79 |
+
weight = torch.randn(num_tokens, codebook_dim)
|
| 80 |
+
weight = l2norm(weight)
|
| 81 |
+
else:
|
| 82 |
+
weight = torch.zeros(num_tokens, codebook_dim)
|
| 83 |
+
self.register_buffer('initted', torch.Tensor([not kmeans_init]))
|
| 84 |
+
else:
|
| 85 |
+
print(f"load init codebook weight from {codebook_init_path}")
|
| 86 |
+
codebook_ckpt_weight = torch.load(codebook_init_path, map_location='cpu')
|
| 87 |
+
weight = codebook_ckpt_weight.clone()
|
| 88 |
+
self.register_buffer('initted', torch.Tensor([True]))
|
| 89 |
+
|
| 90 |
+
self.weight = nn.Parameter(weight, requires_grad=False)
|
| 91 |
+
self.cluster_size = nn.Parameter(torch.zeros(num_tokens), requires_grad=False)
|
| 92 |
+
self.embed_avg = nn.Parameter(weight.clone(), requires_grad=False)
|
| 93 |
+
# self.register_buffer('initted', torch.Tensor([not kmeans_init]))
|
| 94 |
+
self.update = True
|
| 95 |
+
|
| 96 |
+
@torch.jit.ignore
|
| 97 |
+
def init_embed_(self, data):
|
| 98 |
+
if self.initted:
|
| 99 |
+
return
|
| 100 |
+
print("Performing Kemans init for codebook")
|
| 101 |
+
embed, cluster_size = kmeans(data, self.num_tokens, 10, use_cosine_sim=True)
|
| 102 |
+
self.weight.data.copy_(embed)
|
| 103 |
+
self.cluster_size.data.copy_(cluster_size)
|
| 104 |
+
self.initted.data.copy_(torch.Tensor([True]))
|
| 105 |
+
|
| 106 |
+
def forward(self, embed_id):
|
| 107 |
+
return F.embedding(embed_id, self.weight)
|
| 108 |
+
|
| 109 |
+
def cluster_size_ema_update(self, new_cluster_size):
|
| 110 |
+
self.cluster_size.data.mul_(self.decay).add_(new_cluster_size, alpha=1 - self.decay)
|
| 111 |
+
|
| 112 |
+
def embed_avg_ema_update(self, new_embed_avg):
|
| 113 |
+
self.embed_avg.data.mul_(self.decay).add_(new_embed_avg, alpha=1 - self.decay)
|
| 114 |
+
|
| 115 |
+
def weight_update(self, num_tokens):
|
| 116 |
+
n = self.cluster_size.sum()
|
| 117 |
+
smoothed_cluster_size = (
|
| 118 |
+
(self.cluster_size + self.eps) / (n + num_tokens * self.eps) * n
|
| 119 |
+
)
|
| 120 |
+
# normalize embedding average with smoothed cluster size
|
| 121 |
+
embed_normalized = self.embed_avg / smoothed_cluster_size.unsqueeze(1)
|
| 122 |
+
# embed_normalized = l2norm(self.embed_avg / smoothed_cluster_size.unsqueeze(1))
|
| 123 |
+
self.weight.data.copy_(embed_normalized)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def norm_ema_inplace(moving_avg, new, decay):
|
| 127 |
+
moving_avg.data.mul_(decay).add_(new, alpha=(1 - decay))
|
| 128 |
+
moving_avg.data.copy_(l2norm(moving_avg.data))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class NormEMAVectorQuantizer(nn.Module):
|
| 132 |
+
def __init__(self, n_embed, embedding_dim, beta, decay=0.99, eps=1e-5,
|
| 133 |
+
statistic_code_usage=True, kmeans_init=False, codebook_init_path=''):
|
| 134 |
+
super().__init__()
|
| 135 |
+
self.codebook_dim = embedding_dim
|
| 136 |
+
self.num_tokens = n_embed
|
| 137 |
+
self.beta = beta
|
| 138 |
+
self.decay = decay
|
| 139 |
+
|
| 140 |
+
# learnable = True if orthogonal_reg_weight > 0 else False
|
| 141 |
+
self.embedding = EmbeddingEMA(self.num_tokens, self.codebook_dim, decay, eps, kmeans_init, codebook_init_path)
|
| 142 |
+
|
| 143 |
+
self.statistic_code_usage = statistic_code_usage
|
| 144 |
+
if statistic_code_usage:
|
| 145 |
+
self.register_buffer('cluster_size', torch.zeros(n_embed))
|
| 146 |
+
if distributed.is_available() and distributed.is_initialized():
|
| 147 |
+
print("ddp is enable, so use ddp_reduce to sync the statistic_code_usage for each gpu!")
|
| 148 |
+
self.all_reduce_fn = distributed.all_reduce
|
| 149 |
+
else:
|
| 150 |
+
self.all_reduce_fn = nn.Identity()
|
| 151 |
+
|
| 152 |
+
def reset_cluster_size(self, device):
|
| 153 |
+
if self.statistic_code_usage:
|
| 154 |
+
self.register_buffer('cluster_size', torch.zeros(self.num_tokens))
|
| 155 |
+
self.cluster_size = self.cluster_size.to(device)
|
| 156 |
+
|
| 157 |
+
def forward(self, z):
|
| 158 |
+
# reshape z -> (batch, height, width, channel) and flatten
|
| 159 |
+
# z, 'b c h w -> b h w c'
|
| 160 |
+
# z = rearrange(z, 'b c h w -> b h w c')
|
| 161 |
+
# z = z.transpose(1, 2)
|
| 162 |
+
z = l2norm(z)
|
| 163 |
+
z_flattened = z.reshape(-1, self.codebook_dim)
|
| 164 |
+
|
| 165 |
+
self.embedding.init_embed_(z_flattened)
|
| 166 |
+
|
| 167 |
+
d = z_flattened.pow(2).sum(dim=1, keepdim=True) + \
|
| 168 |
+
self.embedding.weight.pow(2).sum(dim=1) - 2 * \
|
| 169 |
+
torch.einsum('bd,nd->bn', z_flattened, self.embedding.weight) # 'n d -> d n'
|
| 170 |
+
|
| 171 |
+
encoding_indices = torch.argmin(d, dim=1)
|
| 172 |
+
|
| 173 |
+
z_q = self.embedding(encoding_indices).view(z.shape)
|
| 174 |
+
|
| 175 |
+
encodings = F.one_hot(encoding_indices, self.num_tokens).type(z.dtype)
|
| 176 |
+
|
| 177 |
+
if not self.training:
|
| 178 |
+
with torch.no_grad():
|
| 179 |
+
cluster_size = encodings.sum(0)
|
| 180 |
+
self.all_reduce_fn(cluster_size)
|
| 181 |
+
ema_inplace(self.cluster_size, cluster_size, self.decay)
|
| 182 |
+
|
| 183 |
+
if self.training and self.embedding.update:
|
| 184 |
+
# EMA cluster size
|
| 185 |
+
|
| 186 |
+
bins = encodings.sum(0)
|
| 187 |
+
self.all_reduce_fn(bins)
|
| 188 |
+
|
| 189 |
+
# self.embedding.cluster_size_ema_update(bins)
|
| 190 |
+
ema_inplace(self.cluster_size, bins, self.decay)
|
| 191 |
+
|
| 192 |
+
zero_mask = (bins == 0)
|
| 193 |
+
bins = bins.masked_fill(zero_mask, 1.)
|
| 194 |
+
|
| 195 |
+
embed_sum = z_flattened.t() @ encodings
|
| 196 |
+
self.all_reduce_fn(embed_sum)
|
| 197 |
+
|
| 198 |
+
embed_normalized = (embed_sum / bins.unsqueeze(0)).t()
|
| 199 |
+
embed_normalized = l2norm(embed_normalized)
|
| 200 |
+
|
| 201 |
+
embed_normalized = torch.where(zero_mask[..., None], self.embedding.weight,
|
| 202 |
+
embed_normalized)
|
| 203 |
+
norm_ema_inplace(self.embedding.weight, embed_normalized, self.decay)
|
| 204 |
+
|
| 205 |
+
# compute loss for embedding
|
| 206 |
+
loss = self.beta * F.mse_loss(z_q.detach(), z)
|
| 207 |
+
|
| 208 |
+
# preserve gradients
|
| 209 |
+
z_q = z + (z_q - z).detach()
|
| 210 |
+
|
| 211 |
+
# reshape back to match original input shape
|
| 212 |
+
# z_q, 'b h w c -> b c h w'
|
| 213 |
+
# z_q = rearrange(z_q, 'b h w c -> b c h w')
|
| 214 |
+
# z_q = z_q.transpose(1, 2)
|
| 215 |
+
return z_q, loss, encoding_indices
|
run_beats_ov1_event_cls_unfreeze_sweep.sh
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
GPUS=${GPUS:-8}
|
| 5 |
+
BATCH_SIZE=${BATCH_SIZE:-8}
|
| 6 |
+
NUM_WORKERS=${NUM_WORKERS:-4}
|
| 7 |
+
RUN_ROOT=${RUN_ROOT:-checkpoints/beats_ov1_event_cls_top8_full}
|
| 8 |
+
MANIFEST=${MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}
|
| 9 |
+
VOCAB=${VOCAB:-/apdcephfs_cq12/share_302080740/user/schmittzhu/data/fsd50k/FSD50K.ground_truth/final_vocabulary.csv}
|
| 10 |
+
BEATS_CKPT=${BEATS_CKPT:-pretrain_ckpt/BEATs_iter3_plus_AS2M.pt/BEATs_iter3_plus_AS2M.pt}
|
| 11 |
+
CHANNEL_MODE=${CHANNEL_MODE:-w}
|
| 12 |
+
MASTER_PORT=${MASTER_PORT:-29501}
|
| 13 |
+
BASE_CLASS_CKPT=${BASE_CLASS_CKPT:-checkpoints/beats_ov1_event_cls_head_only/best.pt}
|
| 14 |
+
DDP_FIND_UNUSED=${DDP_FIND_UNUSED:-1}
|
| 15 |
+
|
| 16 |
+
TOP8_EPOCHS=${TOP8_EPOCHS:-10}
|
| 17 |
+
FULL_EPOCHS=${FULL_EPOCHS:-10}
|
| 18 |
+
TOP8_LR=${TOP8_LR:-5e-5}
|
| 19 |
+
FULL_LR=${FULL_LR:-1e-5}
|
| 20 |
+
|
| 21 |
+
run_stage() {
|
| 22 |
+
local name=$1
|
| 23 |
+
local epochs=$2
|
| 24 |
+
local lr=$3
|
| 25 |
+
local resume=$4
|
| 26 |
+
shift 4
|
| 27 |
+
|
| 28 |
+
local out_dir="${RUN_ROOT}/${name}"
|
| 29 |
+
local resume_args=()
|
| 30 |
+
if [[ -n "${resume}" ]]; then
|
| 31 |
+
resume_args=(--resume "${resume}" --resume-model-only)
|
| 32 |
+
fi
|
| 33 |
+
local ddp_args=()
|
| 34 |
+
if [[ "${DDP_FIND_UNUSED}" == "1" ]]; then
|
| 35 |
+
ddp_args=(--ddp-find-unused-parameters)
|
| 36 |
+
fi
|
| 37 |
+
|
| 38 |
+
echo "[Run] ${name} -> ${out_dir}"
|
| 39 |
+
torchrun --nproc_per_node="${GPUS}" --master-port "${MASTER_PORT}" train_beats_event_classifier.py \
|
| 40 |
+
--train-manifest "${MANIFEST}" \
|
| 41 |
+
--val-manifest "${MANIFEST}" \
|
| 42 |
+
--vocab "${VOCAB}" \
|
| 43 |
+
--beats-checkpoint "${BEATS_CKPT}" \
|
| 44 |
+
--channel-mode "${CHANNEL_MODE}" \
|
| 45 |
+
--output-dir "${out_dir}" \
|
| 46 |
+
--batch-size "${BATCH_SIZE}" \
|
| 47 |
+
--num-workers "${NUM_WORKERS}" \
|
| 48 |
+
--num-epochs "${epochs}" \
|
| 49 |
+
--learning-rate "${lr}" \
|
| 50 |
+
"${resume_args[@]}" \
|
| 51 |
+
"${ddp_args[@]}" \
|
| 52 |
+
"$@"
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
top8_resume=""
|
| 56 |
+
if [[ -f "${BASE_CLASS_CKPT}" ]]; then
|
| 57 |
+
top8_resume="${BASE_CLASS_CKPT}"
|
| 58 |
+
echo "[Run] top8 resumes model weights from ${BASE_CLASS_CKPT}"
|
| 59 |
+
else
|
| 60 |
+
echo "[Run] BASE_CLASS_CKPT not found: ${BASE_CLASS_CKPT}; top8 starts from pretrained BEATs + random classifier"
|
| 61 |
+
fi
|
| 62 |
+
|
| 63 |
+
run_stage 01_top8 "${TOP8_EPOCHS}" "${TOP8_LR}" "${top8_resume}" --unfreeze-top-layers 8
|
| 64 |
+
run_stage 02_full "${FULL_EPOCHS}" "${FULL_LR}" "${RUN_ROOT}/01_top8/best.pt" --unfreeze-all-beats
|
| 65 |
+
|
| 66 |
+
echo "[Run] Done. Check ${RUN_ROOT}"
|
run_foa_cls_stage23.sh
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# FOA W-channel BEATs cls finetune — Stage 2 & 3 only
|
| 6 |
+
# 前提:Stage 1 (01_head_only) 已完成
|
| 7 |
+
# ============================================================================
|
| 8 |
+
|
| 9 |
+
GPUS="${GPUS:-8}"
|
| 10 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 11 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 12 |
+
MASTER_PORT="${MASTER_PORT:-29540}"
|
| 13 |
+
|
| 14 |
+
MANIFEST="/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl"
|
| 15 |
+
VOCAB="/apdcephfs_cq12/share_302080740/user/schmittzhu/data/fsd50k/FSD50K.ground_truth/final_vocabulary.csv"
|
| 16 |
+
BEATS_CKPT="pretrain_ckpt/BEATs_iter3_plus_AS2M.pt/BEATs_iter3_plus_AS2M.pt"
|
| 17 |
+
|
| 18 |
+
RUN_ROOT="checkpoints/beats_ov1_foa_cls_v1"
|
| 19 |
+
STAGE1_DIR="${RUN_ROOT}/01_head_only"
|
| 20 |
+
STAGE2_DIR="${RUN_ROOT}/02_top8"
|
| 21 |
+
STAGE3_DIR="${RUN_ROOT}/03_full"
|
| 22 |
+
|
| 23 |
+
# bs 从 16→8,lr 线性缩减一半
|
| 24 |
+
TOP8_LR="${TOP8_LR:-2.5e-5}"
|
| 25 |
+
FULL_LR="${FULL_LR:-1e-5}"
|
| 26 |
+
TOP8_EPOCHS="${TOP8_EPOCHS:-15}"
|
| 27 |
+
FULL_EPOCHS="${FULL_EPOCHS:-15}"
|
| 28 |
+
|
| 29 |
+
if [ ! -f "${STAGE1_DIR}/best.pt" ]; then
|
| 30 |
+
echo "ERROR: Stage 1 checkpoint not found: ${STAGE1_DIR}/best.pt"
|
| 31 |
+
echo " Please run ./run_foa_cls_finetune.sh first."
|
| 32 |
+
exit 1
|
| 33 |
+
fi
|
| 34 |
+
|
| 35 |
+
echo "========================================"
|
| 36 |
+
echo " FOA cls finetune — Stage 2 & 3"
|
| 37 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} (bs16→8,lr线性减半)"
|
| 38 |
+
echo " Stage2: top-8 ${TOP8_EPOCHS}ep LR=${TOP8_LR}"
|
| 39 |
+
echo " Stage3: full ${FULL_EPOCHS}ep LR=${FULL_LR}"
|
| 40 |
+
echo "========================================"
|
| 41 |
+
|
| 42 |
+
# ---------- Stage 2: top-8 unfreeze ----------
|
| 43 |
+
echo "[foa_cls] Stage 2: top-8 unfreeze -> ${STAGE2_DIR}"
|
| 44 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT}" \
|
| 45 |
+
train_beats_event_classifier.py \
|
| 46 |
+
--train-manifest "${MANIFEST}" \
|
| 47 |
+
--val-manifest "${MANIFEST}" \
|
| 48 |
+
--vocab "${VOCAB}" \
|
| 49 |
+
--beats-checkpoint "${BEATS_CKPT}" \
|
| 50 |
+
--channel-mode w \
|
| 51 |
+
--output-dir "${STAGE2_DIR}" \
|
| 52 |
+
--batch-size "${BATCH_SIZE}" \
|
| 53 |
+
--num-workers "${NUM_WORKERS}" \
|
| 54 |
+
--num-epochs "${TOP8_EPOCHS}" \
|
| 55 |
+
--learning-rate "${TOP8_LR}" \
|
| 56 |
+
--weight-decay 0.05 \
|
| 57 |
+
--unfreeze-top-layers 8 \
|
| 58 |
+
--resume "${STAGE1_DIR}/best.pt" \
|
| 59 |
+
--resume-model-only \
|
| 60 |
+
--ddp-find-unused-parameters
|
| 61 |
+
|
| 62 |
+
# ---------- Stage 3: full unfreeze ----------
|
| 63 |
+
echo "[foa_cls] Stage 3: full unfreeze -> ${STAGE3_DIR}"
|
| 64 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT}" \
|
| 65 |
+
train_beats_event_classifier.py \
|
| 66 |
+
--train-manifest "${MANIFEST}" \
|
| 67 |
+
--val-manifest "${MANIFEST}" \
|
| 68 |
+
--vocab "${VOCAB}" \
|
| 69 |
+
--beats-checkpoint "${BEATS_CKPT}" \
|
| 70 |
+
--channel-mode w \
|
| 71 |
+
--output-dir "${STAGE3_DIR}" \
|
| 72 |
+
--batch-size "${BATCH_SIZE}" \
|
| 73 |
+
--num-workers "${NUM_WORKERS}" \
|
| 74 |
+
--num-epochs "${FULL_EPOCHS}" \
|
| 75 |
+
--learning-rate "${FULL_LR}" \
|
| 76 |
+
--weight-decay 0.05 \
|
| 77 |
+
--unfreeze-all-beats \
|
| 78 |
+
--resume "${STAGE2_DIR}/best.pt" \
|
| 79 |
+
--resume-model-only \
|
| 80 |
+
--ddp-find-unused-parameters
|
| 81 |
+
|
| 82 |
+
echo "========================================"
|
| 83 |
+
echo "[foa_cls] Stage 2 & 3 Done."
|
| 84 |
+
echo " Final checkpoint: ${STAGE3_DIR}/best.pt"
|
| 85 |
+
echo " Use as v6f class_finetuned_ckpt"
|
| 86 |
+
echo "========================================"
|
run_ov123_local_spatial_slot.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ov123 local-spatial + per-frame K-slot head (Route A).
|
| 5 |
+
# Warm-starts from an existing ov1 local_spatial checkpoint, then learns
|
| 6 |
+
# per-frame multi-source activity / class / direction / distance with
|
| 7 |
+
# per-step Hungarian matching across K=4 slots.
|
| 8 |
+
#
|
| 9 |
+
# Override from shell, for example:
|
| 10 |
+
# GPUS=8 BATCH_SIZE=8 RUN_ROOT=checkpoints/my_run ./run_ov123_local_spatial_slot.sh
|
| 11 |
+
|
| 12 |
+
GPUS="${GPUS:-8}"
|
| 13 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 14 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 15 |
+
NUM_EPOCHS="${NUM_EPOCHS:-20}"
|
| 16 |
+
LEARNING_RATE="${LEARNING_RATE:-1e-4}"
|
| 17 |
+
|
| 18 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 19 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 20 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 21 |
+
|
| 22 |
+
INIT_CKPT="${INIT_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_run1/best.pt}"
|
| 23 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov123_local_spatial_slot}"
|
| 24 |
+
|
| 25 |
+
mkdir -p "${RUN_ROOT}"
|
| 26 |
+
|
| 27 |
+
echo "[ov123 local_spatial slot] init=${INIT_CKPT} -> ${RUN_ROOT}"
|
| 28 |
+
torchrun --nproc_per_node="${GPUS}" train_spatial_beats.py \
|
| 29 |
+
--preset ov123_local_spatial_slot \
|
| 30 |
+
--output-dir "${RUN_ROOT}" \
|
| 31 |
+
--init-from-spatial-ckpt "${INIT_CKPT}" \
|
| 32 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 33 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 34 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 35 |
+
--batch-size "${BATCH_SIZE}" \
|
| 36 |
+
--num-workers "${NUM_WORKERS}" \
|
| 37 |
+
--num-epochs "${NUM_EPOCHS}" \
|
| 38 |
+
--learning-rate "${LEARNING_RATE}" \
|
| 39 |
+
--distributed \
|
| 40 |
+
--ddp-find-unused-parameters
|
| 41 |
+
|
| 42 |
+
echo "[Done] ${RUN_ROOT}/best.pt"
|
run_ov123_local_spatial_track.sh
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ov123 local-spatial + K track queries (Route B, EINV2-style).
|
| 5 |
+
# Warm-starts from an existing ov1 local_spatial checkpoint, then learns
|
| 6 |
+
# K=4 track queries over the 2.5Hz fused-embedding sequence with
|
| 7 |
+
# clip-level Hungarian matching and per-frame activity / class /
|
| 8 |
+
# direction / distance supervision.
|
| 9 |
+
#
|
| 10 |
+
# Override from shell, for example:
|
| 11 |
+
# GPUS=8 BATCH_SIZE=8 RUN_ROOT=checkpoints/my_run ./run_ov123_local_spatial_track.sh
|
| 12 |
+
|
| 13 |
+
GPUS="${GPUS:-8}"
|
| 14 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 15 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 16 |
+
NUM_EPOCHS="${NUM_EPOCHS:-20}"
|
| 17 |
+
LEARNING_RATE="${LEARNING_RATE:-1e-4}"
|
| 18 |
+
|
| 19 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 20 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 21 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 22 |
+
|
| 23 |
+
INIT_CKPT="${INIT_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_run1/best.pt}"
|
| 24 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov123_local_spatial_track}"
|
| 25 |
+
|
| 26 |
+
mkdir -p "${RUN_ROOT}"
|
| 27 |
+
|
| 28 |
+
echo "[ov123 local_spatial track] init=${INIT_CKPT} -> ${RUN_ROOT}"
|
| 29 |
+
torchrun --nproc_per_node="${GPUS}" train_spatial_beats.py \
|
| 30 |
+
--preset ov123_local_spatial_track \
|
| 31 |
+
--output-dir "${RUN_ROOT}" \
|
| 32 |
+
--init-from-spatial-ckpt "${INIT_CKPT}" \
|
| 33 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 34 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 35 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 36 |
+
--batch-size "${BATCH_SIZE}" \
|
| 37 |
+
--num-workers "${NUM_WORKERS}" \
|
| 38 |
+
--num-epochs "${NUM_EPOCHS}" \
|
| 39 |
+
--learning-rate "${LEARNING_RATE}" \
|
| 40 |
+
--distributed \
|
| 41 |
+
--ddp-find-unused-parameters
|
| 42 |
+
|
| 43 |
+
echo "[Done] ${RUN_ROOT}/best.pt"
|
run_ov1_ast_three_stage.sh
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# Three-stage ov1 mono-AST training recipe:
|
| 5 |
+
# 1) class warmup: learn the 65-way source classifier on top of frozen BEATs
|
| 6 |
+
# 2) spatial-first: push direction/distance hard after class head is initialized
|
| 7 |
+
# 3) balanced: recover classification while preserving spatial localization
|
| 8 |
+
#
|
| 9 |
+
# Override knobs from the shell, for example:
|
| 10 |
+
# GPUS=8 BATCH_SIZE=16 RUN_ROOT=checkpoints/my_run ./run_ov1_ast_three_stage.sh
|
| 11 |
+
|
| 12 |
+
GPUS="${GPUS:-4}"
|
| 13 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 14 |
+
NUM_WORKERS="${NUM_WORKERS:-4}"
|
| 15 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 16 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov1_ast_three_stage}"
|
| 17 |
+
|
| 18 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-8}"
|
| 19 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-12}"
|
| 20 |
+
BALANCED_EPOCHS="${BALANCED_EPOCHS:-10}"
|
| 21 |
+
|
| 22 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 23 |
+
SPATIAL_LR="${SPATIAL_LR:-5e-5}"
|
| 24 |
+
BALANCED_LR="${BALANCED_LR:-3e-5}"
|
| 25 |
+
|
| 26 |
+
CLASS_DIR="${RUN_ROOT}/01_classwarmup"
|
| 27 |
+
SPATIAL_DIR="${RUN_ROOT}/02_spatialfirst"
|
| 28 |
+
BALANCED_DIR="${RUN_ROOT}/03_balanced"
|
| 29 |
+
|
| 30 |
+
COMMON_ARGS=(
|
| 31 |
+
--ov1-manifest "${OV1_MANIFEST}"
|
| 32 |
+
--batch-size "${BATCH_SIZE}"
|
| 33 |
+
--num-workers "${NUM_WORKERS}"
|
| 34 |
+
--max-clip-duration-seconds 20.0
|
| 35 |
+
--crop-mode start
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
mkdir -p "${RUN_ROOT}"
|
| 39 |
+
|
| 40 |
+
echo "[Stage 1/3] class warmup -> ${CLASS_DIR}"
|
| 41 |
+
torchrun --nproc_per_node="${GPUS}" train_spatial_beats.py \
|
| 42 |
+
--preset ov1_ast_classwarmup \
|
| 43 |
+
--output-dir "${CLASS_DIR}" \
|
| 44 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 45 |
+
--learning-rate "${CLASS_LR}" \
|
| 46 |
+
"${COMMON_ARGS[@]}"
|
| 47 |
+
|
| 48 |
+
echo "[Stage 2/3] spatial-first -> ${SPATIAL_DIR}"
|
| 49 |
+
torchrun --nproc_per_node="${GPUS}" train_spatial_beats.py \
|
| 50 |
+
--preset ov1_ast \
|
| 51 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 52 |
+
--no-resume-optimizer \
|
| 53 |
+
--reset-epoch-on-resume \
|
| 54 |
+
--reset-best-on-resume \
|
| 55 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 56 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 57 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 58 |
+
"${COMMON_ARGS[@]}"
|
| 59 |
+
|
| 60 |
+
echo "[Stage 3/3] balanced classification + spatial -> ${BALANCED_DIR}"
|
| 61 |
+
torchrun --nproc_per_node="${GPUS}" train_spatial_beats.py \
|
| 62 |
+
--preset ov1_ast_balanced \
|
| 63 |
+
--resume "${SPATIAL_DIR}/best.pt" \
|
| 64 |
+
--no-resume-optimizer \
|
| 65 |
+
--reset-epoch-on-resume \
|
| 66 |
+
--reset-best-on-resume \
|
| 67 |
+
--output-dir "${BALANCED_DIR}" \
|
| 68 |
+
--num-epochs "${BALANCED_EPOCHS}" \
|
| 69 |
+
--learning-rate "${BALANCED_LR}" \
|
| 70 |
+
"${COMMON_ARGS[@]}"
|
| 71 |
+
|
| 72 |
+
echo "[Done] checkpoints:"
|
| 73 |
+
echo " class warmup: ${CLASS_DIR}/best.pt"
|
| 74 |
+
echo " spatial-first: ${SPATIAL_DIR}/best.pt"
|
| 75 |
+
echo " balanced: ${BALANCED_DIR}/best.pt"
|
run_ov1_local_spatial_bypass.sh
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# Bypass two-stage experiment:
|
| 5 |
+
# Stage 1 (classwarmup_bypass):
|
| 6 |
+
# - bypass_local_fusion=True → skip CNN entirely
|
| 7 |
+
# - fused_tokens = LayerNorm(semantic) [NO noise at all]
|
| 8 |
+
# - Architecturally equivalent to W-only BEATs classification
|
| 9 |
+
# - lambda_cls=8, lambda_dir=0
|
| 10 |
+
# - Kaldi fbank + regularization
|
| 11 |
+
# - Expected class_acc: ~58-60% (close to W-only BEATs upper bound)
|
| 12 |
+
# Stage 2 (spatial):
|
| 13 |
+
# - bypass_local_fusion=False → CNN activated
|
| 14 |
+
# - trunk re-frozen, lambda_cls=1, lambda_dir=12
|
| 15 |
+
#
|
| 16 |
+
# Override with env vars:
|
| 17 |
+
# GPUS=8 BATCH_SIZE=4 ./run_ov1_local_spatial_bypass.sh
|
| 18 |
+
|
| 19 |
+
GPUS="${GPUS:-8}"
|
| 20 |
+
BATCH_SIZE="${BATCH_SIZE:-4}"
|
| 21 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 22 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-15}"
|
| 23 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 24 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 25 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 26 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov1_local_spatial_bypass_exp}"
|
| 27 |
+
|
| 28 |
+
CLASS_DIR="${RUN_ROOT}/01_classwarmup"
|
| 29 |
+
SPATIAL_DIR="${RUN_ROOT}/02_spatial"
|
| 30 |
+
|
| 31 |
+
echo "[OV1 Bypass] Stage 1: full bypass classwarmup -> ${CLASS_DIR}"
|
| 32 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29524}" train_spatial_beats.py \
|
| 33 |
+
--preset ov1_local_spatial_bypass_classwarmup \
|
| 34 |
+
--output-dir "${CLASS_DIR}" \
|
| 35 |
+
--batch-size "${BATCH_SIZE}" \
|
| 36 |
+
--num-workers "${NUM_WORKERS}" \
|
| 37 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 38 |
+
--learning-rate "${CLASS_LR}"
|
| 39 |
+
|
| 40 |
+
echo "[OV1 Bypass] Stage 2: spatial finetune (CNN activated) -> ${SPATIAL_DIR}"
|
| 41 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29524}" train_spatial_beats.py \
|
| 42 |
+
--preset ov1_local_spatial_bypass_spatial \
|
| 43 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 44 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 45 |
+
--batch-size "${BATCH_SIZE}" \
|
| 46 |
+
--num-workers "${NUM_WORKERS}" \
|
| 47 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 48 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 49 |
+
--no-resume-optimizer \
|
| 50 |
+
--reset-epoch-on-resume \
|
| 51 |
+
--reset-best-on-resume
|
| 52 |
+
|
| 53 |
+
echo "[OV1 Bypass] Done."
|
| 54 |
+
echo " Stage1 best: ${CLASS_DIR}/best.pt (expect class_acc ~58-60%)"
|
| 55 |
+
echo " Stage2 best: ${SPATIAL_DIR}/best.pt"
|
run_ov1_local_spatial_classwarmup.sh
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# Two-stage ov1 local_spatial class warmup experiment:
|
| 5 |
+
# stage 1: class-dominant warmup with top-2 trunk layers unfrozen
|
| 6 |
+
# stage 2: spatial-focused finetune with trunk re-frozen
|
| 7 |
+
#
|
| 8 |
+
# Override with env vars, for example:
|
| 9 |
+
# GPUS=8 BATCH_SIZE=8 CLASS_EPOCHS=12 SPATIAL_EPOCHS=20 ./run_ov1_local_spatial_classwarmup.sh
|
| 10 |
+
|
| 11 |
+
GPUS="${GPUS:-4}"
|
| 12 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 13 |
+
NUM_WORKERS="${NUM_WORKERS:-4}"
|
| 14 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-12}"
|
| 15 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 16 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 17 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 18 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov1_local_spatial_classwarmup_exp}"
|
| 19 |
+
|
| 20 |
+
CLASS_DIR="${RUN_ROOT}/01_classwarmup"
|
| 21 |
+
SPATIAL_DIR="${RUN_ROOT}/02_spatial"
|
| 22 |
+
|
| 23 |
+
echo "[OV1 LocalSpatial ClassWarmup] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 24 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29520}" train_spatial_beats.py \
|
| 25 |
+
--preset ov1_local_spatial_classwarmup \
|
| 26 |
+
--output-dir "${CLASS_DIR}" \
|
| 27 |
+
--batch-size "${BATCH_SIZE}" \
|
| 28 |
+
--num-workers "${NUM_WORKERS}" \
|
| 29 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 30 |
+
--learning-rate "${CLASS_LR}"
|
| 31 |
+
|
| 32 |
+
echo "[OV1 LocalSpatial ClassWarmup] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 33 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29520}" train_spatial_beats.py \
|
| 34 |
+
--preset ov1_local_spatial_spatial \
|
| 35 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 36 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 37 |
+
--batch-size "${BATCH_SIZE}" \
|
| 38 |
+
--num-workers "${NUM_WORKERS}" \
|
| 39 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 40 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 41 |
+
--no-resume-optimizer \
|
| 42 |
+
--reset-epoch-on-resume \
|
| 43 |
+
--reset-best-on-resume
|
| 44 |
+
|
| 45 |
+
echo "[OV1 LocalSpatial ClassWarmup] Done. Inspect:"
|
| 46 |
+
echo " ${CLASS_DIR}/val_predictions"
|
| 47 |
+
echo " ${SPATIAL_DIR}/val_predictions"
|
run_ov1_pretrunk_ast_experiment.sh
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# BAT / Spatial-AST-style ov1 probe:
|
| 5 |
+
# optional phase 0: strict W-only class probe
|
| 6 |
+
# stage 1: class-only warmup with task tokens inside the BEATs trunk
|
| 7 |
+
# stage 2: distance / azimuth / elevation CE supervision with class retained
|
| 8 |
+
#
|
| 9 |
+
# Defaults are intentionally conservative. Override with env vars, for example:
|
| 10 |
+
# GPUS=4 BATCH_SIZE=8 CLASS_EPOCHS=8 SPATIAL_EPOCHS=12 ./run_ov1_pretrunk_ast_experiment.sh
|
| 11 |
+
|
| 12 |
+
GPUS="${GPUS:-4}"
|
| 13 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 14 |
+
NUM_WORKERS="${NUM_WORKERS:-4}"
|
| 15 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-8}"
|
| 16 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-12}"
|
| 17 |
+
RUN_PHASE0="${RUN_PHASE0:-0}"
|
| 18 |
+
PHASE0_EPOCHS="${PHASE0_EPOCHS:-2}"
|
| 19 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 20 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 21 |
+
PHASE0_LR="${PHASE0_LR:-1e-4}"
|
| 22 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov1_pretrunk_ast}"
|
| 23 |
+
|
| 24 |
+
PHASE0_DIR="${RUN_ROOT}/00_phase0_wonly_class"
|
| 25 |
+
CLASS_DIR="${RUN_ROOT}/01_class"
|
| 26 |
+
SPATIAL_DIR="${RUN_ROOT}/02_spatial"
|
| 27 |
+
|
| 28 |
+
if [[ "${RUN_PHASE0}" == "1" ]]; then
|
| 29 |
+
echo "[OV1 PreTrunk AST] Phase 0: W-only class probe -> ${PHASE0_DIR}"
|
| 30 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29518}" train_spatial_beats.py \
|
| 31 |
+
--preset ov1_pretrunk_ast_phase0 \
|
| 32 |
+
--output-dir "${PHASE0_DIR}" \
|
| 33 |
+
--batch-size "${BATCH_SIZE}" \
|
| 34 |
+
--num-workers "${NUM_WORKERS}" \
|
| 35 |
+
--num-epochs "${PHASE0_EPOCHS}" \
|
| 36 |
+
--learning-rate "${PHASE0_LR}"
|
| 37 |
+
fi
|
| 38 |
+
|
| 39 |
+
echo "[OV1 PreTrunk AST] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 40 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29518}" train_spatial_beats.py \
|
| 41 |
+
--preset ov1_pretrunk_ast_class \
|
| 42 |
+
--output-dir "${CLASS_DIR}" \
|
| 43 |
+
--batch-size "${BATCH_SIZE}" \
|
| 44 |
+
--num-workers "${NUM_WORKERS}" \
|
| 45 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 46 |
+
--learning-rate "${CLASS_LR}"
|
| 47 |
+
|
| 48 |
+
echo "[OV1 PreTrunk AST] Stage 2: spatial CE finetune -> ${SPATIAL_DIR}"
|
| 49 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29518}" train_spatial_beats.py \
|
| 50 |
+
--preset ov1_pretrunk_ast_spatial \
|
| 51 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 52 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 53 |
+
--batch-size "${BATCH_SIZE}" \
|
| 54 |
+
--num-workers "${NUM_WORKERS}" \
|
| 55 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 56 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 57 |
+
--no-resume-optimizer \
|
| 58 |
+
--reset-epoch-on-resume \
|
| 59 |
+
--reset-best-on-resume
|
| 60 |
+
|
| 61 |
+
echo "[OV1 PreTrunk AST] Done. Inspect:"
|
| 62 |
+
echo " ${PHASE0_DIR}/val_predictions"
|
| 63 |
+
echo " ${CLASS_DIR}/val_predictions"
|
| 64 |
+
echo " ${SPATIAL_DIR}/val_predictions"
|
run_ov1_spatial_atst.sh
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# Two-stage Spatial-ATST training on ov1 (single source, FOA).
|
| 5 |
+
#
|
| 6 |
+
# Stage 1 (classwarmup_bypass):
|
| 7 |
+
# - ATST trunk FULLY UNFROZEN
|
| 8 |
+
# - bypass_local_fusion=True → fused = LN(semantic), zero CNN noise
|
| 9 |
+
# - lambda_cls=8, lambda_dir=0
|
| 10 |
+
# - SpecAugment + label smoothing + head dropout
|
| 11 |
+
# - Expected class_acc: ~65-70%
|
| 12 |
+
#
|
| 13 |
+
# Stage 2 (spatial finetune):
|
| 14 |
+
# - ATST trunk re-FROZEN
|
| 15 |
+
# - bypass disabled, CNN activated
|
| 16 |
+
# - lambda_cls=1, lambda_dir=12
|
| 17 |
+
# - Resume from stage 1 best.pt
|
| 18 |
+
#
|
| 19 |
+
# Override via env vars:
|
| 20 |
+
# GPUS=8 BATCH_SIZE=4 NUM_WORKERS=24 ./run_ov1_spatial_atst.sh
|
| 21 |
+
|
| 22 |
+
GPUS="${GPUS:-8}"
|
| 23 |
+
BATCH_SIZE="${BATCH_SIZE:-4}"
|
| 24 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 25 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-15}"
|
| 26 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 27 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 28 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 29 |
+
MASTER_PORT="${MASTER_PORT:-29530}"
|
| 30 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_atst_ov1_exp}"
|
| 31 |
+
|
| 32 |
+
CLASS_DIR="${RUN_ROOT}/01_classwarmup"
|
| 33 |
+
SPATIAL_DIR="${RUN_ROOT}/02_spatial"
|
| 34 |
+
|
| 35 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 36 |
+
cd "${SCRIPT_DIR}"
|
| 37 |
+
|
| 38 |
+
echo "=========================================="
|
| 39 |
+
echo " Spatial-ATST ov1 two-stage training"
|
| 40 |
+
echo " GPUs : ${GPUS}"
|
| 41 |
+
echo " Batch size : ${BATCH_SIZE}"
|
| 42 |
+
echo " Stage 1 dir : ${CLASS_DIR}"
|
| 43 |
+
echo " Stage 2 dir : ${SPATIAL_DIR}"
|
| 44 |
+
echo "=========================================="
|
| 45 |
+
|
| 46 |
+
# ---- Stage 1: classwarmup (full ATST unfreeze + bypass) ----
|
| 47 |
+
echo "[Stage 1] classwarmup → ${CLASS_DIR}"
|
| 48 |
+
torchrun \
|
| 49 |
+
--nproc_per_node="${GPUS}" \
|
| 50 |
+
--master-port="${MASTER_PORT}" \
|
| 51 |
+
train_spatial_atst.py \
|
| 52 |
+
--preset ov1_classwarmup \
|
| 53 |
+
--output-dir "${CLASS_DIR}" \
|
| 54 |
+
--batch-size "${BATCH_SIZE}" \
|
| 55 |
+
--num-workers "${NUM_WORKERS}" \
|
| 56 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 57 |
+
--learning-rate "${CLASS_LR}" \
|
| 58 |
+
--ddp-find-unused-parameters
|
| 59 |
+
|
| 60 |
+
echo "[Stage 1] done. best.pt → ${CLASS_DIR}/best.pt"
|
| 61 |
+
|
| 62 |
+
# ---- Stage 2: spatial finetune (trunk frozen, CNN active) ----
|
| 63 |
+
echo "[Stage 2] spatial finetune → ${SPATIAL_DIR}"
|
| 64 |
+
torchrun \
|
| 65 |
+
--nproc_per_node="${GPUS}" \
|
| 66 |
+
--master-port="${MASTER_PORT}" \
|
| 67 |
+
train_spatial_atst.py \
|
| 68 |
+
--preset ov1_spatial \
|
| 69 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 70 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 71 |
+
--batch-size "${BATCH_SIZE}" \
|
| 72 |
+
--num-workers "${NUM_WORKERS}" \
|
| 73 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 74 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 75 |
+
--no-resume-optimizer \
|
| 76 |
+
--reset-epoch-on-resume \
|
| 77 |
+
--reset-best-on-resume
|
| 78 |
+
|
| 79 |
+
echo "=========================================="
|
| 80 |
+
echo " Spatial-ATST training complete."
|
| 81 |
+
echo " Stage 1 best : ${CLASS_DIR}/best.pt"
|
| 82 |
+
echo " Stage 2 best : ${SPATIAL_DIR}/best.pt"
|
| 83 |
+
echo "=========================================="
|
run_ov1_unified_v13d.sh
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v13_D: Training-mechanism overhaul (no architecture change vs v12)
|
| 6 |
+
# [D-1] cls warmup 8 epochs + cosine LR + 25 total epochs
|
| 7 |
+
# [D-2] Top-K rank activity loss (aligned with DCASE top-K̂ eval)
|
| 8 |
+
# [D-5] resume optimizer from v12 (Adam momentum preserved)
|
| 9 |
+
# [D-6] EMA shadow weights (decay=0.9995) for val / best.pt
|
| 10 |
+
#
|
| 11 |
+
# 训练数据: unified_spatial_foa_fsd63_all/train.jsonl (与 v12 一致)
|
| 12 |
+
# Hot-start: v12 best.pt (不加 --no-resume-optimizer → 继承 Adam momentum)
|
| 13 |
+
# 模型架构: 与 v12 完全一致,0 new parameters → load strict=False gives
|
| 14 |
+
# missing=0, unexpected=0.
|
| 15 |
+
# ============================================================================
|
| 16 |
+
|
| 17 |
+
GPUS="${GPUS:-8}"
|
| 18 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 19 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 20 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-25}"
|
| 21 |
+
SPATIAL_LR="${SPATIAL_LR:-1.5e-5}" # peak LR for cosine; D-5 keeps Adam moments
|
| 22 |
+
AMP="${AMP:-fp32}"
|
| 23 |
+
|
| 24 |
+
# ── 旧数据集路径(用于 valid 多子集评估) ────────────────────────────────────
|
| 25 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 26 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 27 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 28 |
+
|
| 29 |
+
OV1_REAL_MANIFEST="${OV1_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_real_static_foa_mapped.jsonl}"
|
| 30 |
+
OV2_REAL_MANIFEST="${OV2_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_real_static_foa_mapped.jsonl}"
|
| 31 |
+
OV3_REAL_MANIFEST="${OV3_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_real_static_foa_mapped.jsonl}"
|
| 32 |
+
|
| 33 |
+
# ── Unified 数据集路径 ───────────────────────────────────────────────────────
|
| 34 |
+
UNIFIED_ROOT="${UNIFIED_ROOT:-/apdcephfs_cq12/share_302080740/user/schmittzhu/data/unified_spatial_foa_fsd63_all}"
|
| 35 |
+
UNIFIED_TRAIN_MANIFEST="${UNIFIED_TRAIN_MANIFEST:-${UNIFIED_ROOT}/train.jsonl}"
|
| 36 |
+
UNIFIED_VALID_MANIFEST="${UNIFIED_VALID_MANIFEST:-${UNIFIED_ROOT}/valid.jsonl}"
|
| 37 |
+
|
| 38 |
+
# ── Checkpoint 路径 ──────────────────────────────────────────────────────────
|
| 39 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_unified_v12_exp/03_ov123_top4/best.pt}"
|
| 40 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_unified_v13d_exp/03_ov123_top4}"
|
| 41 |
+
|
| 42 |
+
# ── 预检 ────────────────────────────────────────────────────────────────────
|
| 43 |
+
for MANIFEST in "${UNIFIED_TRAIN_MANIFEST}" "${UNIFIED_VALID_MANIFEST}"; do
|
| 44 |
+
if [ ! -f "${MANIFEST}" ]; then
|
| 45 |
+
echo "ERROR: unified manifest not found: ${MANIFEST}"
|
| 46 |
+
exit 1
|
| 47 |
+
fi
|
| 48 |
+
done
|
| 49 |
+
|
| 50 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 51 |
+
echo "ERROR: resume checkpoint not found: ${RESUME_CKPT}"
|
| 52 |
+
echo " Expected v12 best.pt at: ${RESUME_CKPT}"
|
| 53 |
+
exit 1
|
| 54 |
+
fi
|
| 55 |
+
|
| 56 |
+
echo "============================================================"
|
| 57 |
+
echo " v13_D: Training mechanism overhaul"
|
| 58 |
+
echo " [D-1] cls warmup 8ep + cosine LR + 25ep total"
|
| 59 |
+
echo " [D-2] Top-K rank activity loss"
|
| 60 |
+
echo " [D-5] resume optimizer (no --no-resume-optimizer)"
|
| 61 |
+
echo " [D-6] EMA shadow (decay=0.9995, start ep3)"
|
| 62 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} PEAK_LR=${SPATIAL_LR} AMP=${AMP}"
|
| 63 |
+
echo " Unified train: ${UNIFIED_TRAIN_MANIFEST}"
|
| 64 |
+
echo " Resume: ${RESUME_CKPT}"
|
| 65 |
+
echo " Output: ${OUT_DIR}"
|
| 66 |
+
echo "============================================================"
|
| 67 |
+
|
| 68 |
+
# Note: we DO NOT pass --no-resume-optimizer, because [D-5] relies on the
|
| 69 |
+
# v12 Adam state for a smooth continuation. reset-epoch / reset-best are
|
| 70 |
+
# still set so the checkpoint's v12 epoch counter doesn't interfere.
|
| 71 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29576}" train_spatial_beats.py \
|
| 72 |
+
--preset ov1_unified_v13d \
|
| 73 |
+
--resume "${RESUME_CKPT}" \
|
| 74 |
+
--output-dir "${OUT_DIR}" \
|
| 75 |
+
--unified-train-manifest "${UNIFIED_TRAIN_MANIFEST}" \
|
| 76 |
+
--unified-valid-manifest "${UNIFIED_VALID_MANIFEST}" \
|
| 77 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 78 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 79 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 80 |
+
--ov1-real-manifest "${OV1_REAL_MANIFEST}" \
|
| 81 |
+
--ov2-real-manifest "${OV2_REAL_MANIFEST}" \
|
| 82 |
+
--ov3-real-manifest "${OV3_REAL_MANIFEST}" \
|
| 83 |
+
--batch-size "${BATCH_SIZE}" \
|
| 84 |
+
--num-workers "${NUM_WORKERS}" \
|
| 85 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 86 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 87 |
+
--amp "${AMP}" \
|
| 88 |
+
--reset-epoch-on-resume \
|
| 89 |
+
--reset-best-on-resume
|
| 90 |
+
|
| 91 |
+
echo "[v13_D] Done."
|
run_ov1_unified_v13e.sh
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v13_E: MINIMAL intervention on top of v12
|
| 6 |
+
# [E-1] cls warmup 8 epochs + 20 total epochs (v12 was 3 / 15)
|
| 7 |
+
# [E-2] Enable num_active head training + OR top-K̂ into DCASE SELD eval
|
| 8 |
+
#
|
| 9 |
+
# Everything else identical to v12 — no new modules, no new loss functions,
|
| 10 |
+
# no augment, no data reweighting, no EMA, no cosine LR, no resume optimizer
|
| 11 |
+
# tricks. Hot-start from v12 best.pt.
|
| 12 |
+
#
|
| 13 |
+
# 训练数据: unified_spatial_foa_fsd63_all/train.jsonl
|
| 14 |
+
# 预期: F20 ≈ 0.40 ~ 0.43 (基于 v13_D o_cls=0.89 的实测 + top-K̂ gate 预计收益)
|
| 15 |
+
# ============================================================================
|
| 16 |
+
|
| 17 |
+
GPUS="${GPUS:-8}"
|
| 18 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 19 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 20 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 21 |
+
SPATIAL_LR="${SPATIAL_LR:-1e-5}"
|
| 22 |
+
AMP="${AMP:-fp32}"
|
| 23 |
+
|
| 24 |
+
# ── 旧数据集路径(用于 valid 多子集评估) ────────────────────────────────────
|
| 25 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 26 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 27 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 28 |
+
|
| 29 |
+
OV1_REAL_MANIFEST="${OV1_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_real_static_foa_mapped.jsonl}"
|
| 30 |
+
OV2_REAL_MANIFEST="${OV2_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_real_static_foa_mapped.jsonl}"
|
| 31 |
+
OV3_REAL_MANIFEST="${OV3_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_real_static_foa_mapped.jsonl}"
|
| 32 |
+
|
| 33 |
+
# ── Unified 数据集路径 ───────────────────────────────────────────────────────
|
| 34 |
+
UNIFIED_ROOT="${UNIFIED_ROOT:-/apdcephfs_cq12/share_302080740/user/schmittzhu/data/unified_spatial_foa_fsd63_all}"
|
| 35 |
+
UNIFIED_TRAIN_MANIFEST="${UNIFIED_TRAIN_MANIFEST:-${UNIFIED_ROOT}/train.jsonl}"
|
| 36 |
+
UNIFIED_VALID_MANIFEST="${UNIFIED_VALID_MANIFEST:-${UNIFIED_ROOT}/valid.jsonl}"
|
| 37 |
+
|
| 38 |
+
# ── Checkpoint 路径 ──────────────────────────────────────────────────────────
|
| 39 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_unified_v12_exp/03_ov123_top4/best.pt}"
|
| 40 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_unified_v13e_exp/03_ov123_top4}"
|
| 41 |
+
|
| 42 |
+
# ── 预检 ────────────────────────────────────────────────────────────────────
|
| 43 |
+
for MANIFEST in "${UNIFIED_TRAIN_MANIFEST}" "${UNIFIED_VALID_MANIFEST}"; do
|
| 44 |
+
if [ ! -f "${MANIFEST}" ]; then
|
| 45 |
+
echo "ERROR: unified manifest not found: ${MANIFEST}"
|
| 46 |
+
exit 1
|
| 47 |
+
fi
|
| 48 |
+
done
|
| 49 |
+
|
| 50 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 51 |
+
echo "ERROR: resume checkpoint not found: ${RESUME_CKPT}"
|
| 52 |
+
echo " Expected v12 best.pt at: ${RESUME_CKPT}"
|
| 53 |
+
exit 1
|
| 54 |
+
fi
|
| 55 |
+
|
| 56 |
+
echo "============================================================"
|
| 57 |
+
echo " v13_E: minimal v12 enhancement"
|
| 58 |
+
echo " [E-1] cls warmup 8ep + 20ep total"
|
| 59 |
+
echo " [E-2] num_active head training + top-K̂ gate in SELD eval"
|
| 60 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 61 |
+
echo " Unified train: ${UNIFIED_TRAIN_MANIFEST}"
|
| 62 |
+
echo " Resume: ${RESUME_CKPT}"
|
| 63 |
+
echo " Output: ${OUT_DIR}"
|
| 64 |
+
echo "============================================================"
|
| 65 |
+
|
| 66 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29577}" train_spatial_beats.py \
|
| 67 |
+
--preset ov1_unified_v13e \
|
| 68 |
+
--resume "${RESUME_CKPT}" \
|
| 69 |
+
--output-dir "${OUT_DIR}" \
|
| 70 |
+
--unified-train-manifest "${UNIFIED_TRAIN_MANIFEST}" \
|
| 71 |
+
--unified-valid-manifest "${UNIFIED_VALID_MANIFEST}" \
|
| 72 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 73 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 74 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 75 |
+
--ov1-real-manifest "${OV1_REAL_MANIFEST}" \
|
| 76 |
+
--ov2-real-manifest "${OV2_REAL_MANIFEST}" \
|
| 77 |
+
--ov3-real-manifest "${OV3_REAL_MANIFEST}" \
|
| 78 |
+
--batch-size "${BATCH_SIZE}" \
|
| 79 |
+
--num-workers "${NUM_WORKERS}" \
|
| 80 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 81 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 82 |
+
--amp "${AMP}" \
|
| 83 |
+
--no-resume-optimizer \
|
| 84 |
+
--reset-epoch-on-resume \
|
| 85 |
+
--reset-best-on-resume
|
| 86 |
+
|
| 87 |
+
echo "[v13_E] Done."
|
run_ov1_v10_phase1_cls.sh
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v10_phase1_cls: pure classification finetune on top of v9 ep3.
|
| 6 |
+
#
|
| 7 |
+
# Why this exists (post-v9 diagnosis — see docs/0423.md + v9 CSV analysis):
|
| 8 |
+
# * v9 val_cls_ok peaked at ep3 (~53%) and dropped afterwards. Per-ov CSV
|
| 9 |
+
# breakdown showed ov1 spatial already saturated at ep0 (96% DOA, 4° MAE);
|
| 10 |
+
# the remaining headroom is entirely on classification, not spatial.
|
| 11 |
+
# * v9's DOA ramp at ep3 perturbed class binding again → class head never
|
| 12 |
+
# gets a quiet window after demixer / MLP residual unlock.
|
| 13 |
+
# * Class loss is the dominant bottleneck and needs an undisturbed
|
| 14 |
+
# convergence window before spatial can meaningfully improve.
|
| 15 |
+
#
|
| 16 |
+
# What v10 phase-1 does (see make_ov1_local_spatial_v10_phase1_cls_config):
|
| 17 |
+
# - freeze direction_head + distance_head at param level
|
| 18 |
+
# - lambda_direction = lambda_distance = 0.0; matching costs = 0.0
|
| 19 |
+
# - lambda_activity = 0.5 (softened so activity doesn't drag class around)
|
| 20 |
+
# - lambda_num_active = 0.5 (new v10 num-active CE head → top-K̂ gating at eval)
|
| 21 |
+
# - class_head_lr_scale = 1.5 (full class head freedom)
|
| 22 |
+
# - base LR = 7.5e-6 (halved)
|
| 23 |
+
# - best_metric = class_acc (tier-1 gated per-frame, = valid CSV cls_ok)
|
| 24 |
+
#
|
| 25 |
+
# Hot-start:
|
| 26 |
+
# Default RESUME_CKPT = v9 ep3 (its cls_ok peak). strict=False load;
|
| 27 |
+
# num_active_head is zero-init with bias[0]=+4 so the first forward is
|
| 28 |
+
# equivalent to v9 ep3 (argmax = 0 → eval falls back to 0.5 hard threshold
|
| 29 |
+
# until supervision warms it up).
|
| 30 |
+
# ============================================================================
|
| 31 |
+
|
| 32 |
+
GPUS="${GPUS:-8}"
|
| 33 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 34 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 35 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-10}"
|
| 36 |
+
SPATIAL_LR="${SPATIAL_LR:-7.5e-6}"
|
| 37 |
+
AMP="${AMP:-fp32}"
|
| 38 |
+
|
| 39 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 40 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 41 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 42 |
+
|
| 43 |
+
# Default: start from v9 ep3 (cls_ok peak, per v9 CSV analysis).
|
| 44 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v9_ov123_exp/03_ov123_top4/epoch_0003.pt}"
|
| 45 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v10_phase1_cls_exp/ov123_top4}"
|
| 46 |
+
|
| 47 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 48 |
+
echo "ERROR: resume checkpoint not found: ${RESUME_CKPT}"
|
| 49 |
+
echo " Expected v9 ep3 at: ${RESUME_CKPT}"
|
| 50 |
+
exit 1
|
| 51 |
+
fi
|
| 52 |
+
|
| 53 |
+
echo "==============================================="
|
| 54 |
+
echo " v10_phase1_cls: pure cls finetune on v9 ep3"
|
| 55 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 56 |
+
echo " Resume from: ${RESUME_CKPT}"
|
| 57 |
+
echo " Output dir: ${OUT_DIR}"
|
| 58 |
+
echo "==============================================="
|
| 59 |
+
|
| 60 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29558}" train_spatial_beats.py \
|
| 61 |
+
--preset ov1_local_spatial_v10_phase1_cls \
|
| 62 |
+
--resume "${RESUME_CKPT}" \
|
| 63 |
+
--output-dir "${OUT_DIR}" \
|
| 64 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 65 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 66 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 67 |
+
--batch-size "${BATCH_SIZE}" \
|
| 68 |
+
--num-workers "${NUM_WORKERS}" \
|
| 69 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 70 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 71 |
+
--amp "${AMP}" \
|
| 72 |
+
--no-resume-optimizer \
|
| 73 |
+
--reset-epoch-on-resume \
|
| 74 |
+
--reset-best-on-resume
|
| 75 |
+
|
| 76 |
+
echo "[v10_phase1_cls] Done."
|
run_ov1_v10b_phase1_activity.sh
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v10b_phase1_activity: fix ov3 under-report on top of v10 phase-1 best.pt.
|
| 6 |
+
#
|
| 7 |
+
# Why this exists (v10 phase-1 ov3 deep-dive, see /tmp/eval_v10_underreport.py):
|
| 8 |
+
# * n_gt=2 frames (82% of multi-source ov3): top2 activity prob mean=0.67,
|
| 9 |
+
# 77% ≥ 0.5 — the second track hovers around threshold; 23% of 2-source
|
| 10 |
+
# frames fail to bind a 2nd active track.
|
| 11 |
+
# * n_gt=3 frames (12% of ov3): num_active head collapses to K̂=2 on 83%
|
| 12 |
+
# of frames (K̂=3 only 8%), because vanilla CE learns the majority.
|
| 13 |
+
# * v10 phase-1 lambda_frame_activity=0.5 froze activity learning:
|
| 14 |
+
# top2/top3 probs are within 0.02 of v9 ep3.
|
| 15 |
+
#
|
| 16 |
+
# v10b additive fixes:
|
| 17 |
+
# - lambda_frame_activity = 1.0 (restore, was 0.5)
|
| 18 |
+
# - frame_activity_pos_weight = 4.0 (fixed; overrides dynamic)
|
| 19 |
+
# - lambda_frame_num_active = 0.8
|
| 20 |
+
# - frame_num_active_use_focal = True, gamma=2.0
|
| 21 |
+
# + class_weights = [0.5, 1.0, 1.0, 1.5, 2.0]
|
| 22 |
+
# → stop K̂ from collapsing to 2; learn K̂=3 for n_gt=3 frames
|
| 23 |
+
# - frame_accdoa_activity_threshold = 0.35
|
| 24 |
+
# - base LR 5e-6 (reduced) + 6 epochs
|
| 25 |
+
#
|
| 26 |
+
# Hot-start:
|
| 27 |
+
# Default RESUME_CKPT = v10 phase-1 best.pt (ep3, tier-1 cls_acc=0.78).
|
| 28 |
+
# strict=True load; no new parameters (focal toggle is config-only).
|
| 29 |
+
# dir/dist heads remain frozen inherited from phase-1 preset.
|
| 30 |
+
# ============================================================================
|
| 31 |
+
|
| 32 |
+
GPUS="${GPUS:-8}"
|
| 33 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 34 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 35 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-6}"
|
| 36 |
+
SPATIAL_LR="${SPATIAL_LR:-5e-6}"
|
| 37 |
+
AMP="${AMP:-fp32}"
|
| 38 |
+
|
| 39 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 40 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 41 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 42 |
+
|
| 43 |
+
# Default: start from v10 phase-1 best.pt (ep3, cls_acc peak).
|
| 44 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v10_phase1_cls_exp/ov123_top4/best.pt}"
|
| 45 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v10b_phase1_activity_exp/ov123_top4}"
|
| 46 |
+
|
| 47 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 48 |
+
echo "ERROR: resume checkpoint not found: ${RESUME_CKPT}"
|
| 49 |
+
echo " Expected v10 phase-1 best.pt at: ${RESUME_CKPT}"
|
| 50 |
+
exit 1
|
| 51 |
+
fi
|
| 52 |
+
|
| 53 |
+
echo "==============================================="
|
| 54 |
+
echo " v10b_phase1_activity: fix ov3 under-report"
|
| 55 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 56 |
+
echo " Resume from: ${RESUME_CKPT}"
|
| 57 |
+
echo " Output dir: ${OUT_DIR}"
|
| 58 |
+
echo "==============================================="
|
| 59 |
+
|
| 60 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29559}" train_spatial_beats.py \
|
| 61 |
+
--preset ov1_local_spatial_v10b_phase1_activity \
|
| 62 |
+
--resume "${RESUME_CKPT}" \
|
| 63 |
+
--output-dir "${OUT_DIR}" \
|
| 64 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 65 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 66 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 67 |
+
--batch-size "${BATCH_SIZE}" \
|
| 68 |
+
--num-workers "${NUM_WORKERS}" \
|
| 69 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 70 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 71 |
+
--amp "${AMP}" \
|
| 72 |
+
--no-resume-optimizer \
|
| 73 |
+
--reset-epoch-on-resume \
|
| 74 |
+
--reset-best-on-resume
|
| 75 |
+
|
| 76 |
+
echo "[v10b_phase1_activity] Done."
|
run_ov1_v11c_ov123_accdoa.sh
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v11c_ov123_accdoa: ACCDOA paradigm control.
|
| 6 |
+
#
|
| 7 |
+
# Motivation — see docs/0424.md §4.3:
|
| 8 |
+
# real_ov3 fails at the binding layer (24.5% of GTs have no same-class raw
|
| 9 |
+
# candidate) AND at angle accuracy. v9/v11a/v11b all use a K=4 query DETR
|
| 10 |
+
# readout — every fix patches the head, but the binding stage still has to
|
| 11 |
+
# answer "which query owns which source" from a memory whose frequency
|
| 12 |
+
# axis is gone.
|
| 13 |
+
#
|
| 14 |
+
# v11c sidesteps that:
|
| 15 |
+
# readout_scheme = local_spatial_accdoa
|
| 16 |
+
# per-class 3D vector v_c where ||v_c|| = activity_c, v_c/||v_c|| = DOA_c.
|
| 17 |
+
# No queries, no Hungarian matching. ov2/ov3 have zero same-class overlap
|
| 18 |
+
# in a frame, so per-class is unambiguous.
|
| 19 |
+
#
|
| 20 |
+
# Cold start (NOT v9-compatible):
|
| 21 |
+
# The ACCDOA head replaces source_query_decoder + FrameTrackPredictionHeads
|
| 22 |
+
# entirely. Init from the ov1 local_spatial warmup ckpt instead, which
|
| 23 |
+
# carries the BEATs trunk + LocalSpatialEncoder + fuser. Done via
|
| 24 |
+
# --init-from-spatial-ckpt (strict=False), inherited from the base preset.
|
| 25 |
+
# ============================================================================
|
| 26 |
+
|
| 27 |
+
GPUS="${GPUS:-8}"
|
| 28 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 29 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 30 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-24}"
|
| 31 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 32 |
+
AMP="${AMP:-fp32}"
|
| 33 |
+
|
| 34 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 35 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 36 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 37 |
+
|
| 38 |
+
INIT_CKPT="${INIT_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_run1/best.pt}"
|
| 39 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v11c_ov123_accdoa_exp/03_ov123_top4}"
|
| 40 |
+
|
| 41 |
+
if [ ! -f "${INIT_CKPT}" ]; then
|
| 42 |
+
echo "ERROR: init checkpoint not found: ${INIT_CKPT}"
|
| 43 |
+
echo " v11c needs an ov1 local_spatial warmup ckpt (NOT v9 frame-track)."
|
| 44 |
+
exit 1
|
| 45 |
+
fi
|
| 46 |
+
|
| 47 |
+
mkdir -p "${OUT_DIR}"
|
| 48 |
+
|
| 49 |
+
echo "==============================================="
|
| 50 |
+
echo " v11c_ov123_accdoa: per-class ACCDOA paradigm control"
|
| 51 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 52 |
+
echo " Init from: ${INIT_CKPT} (strict=False)"
|
| 53 |
+
echo " Output dir: ${OUT_DIR}"
|
| 54 |
+
echo "==============================================="
|
| 55 |
+
|
| 56 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29563}" train_spatial_beats.py \
|
| 57 |
+
--preset ov1_local_spatial_v11c_ov123_accdoa \
|
| 58 |
+
--output-dir "${OUT_DIR}" \
|
| 59 |
+
--init-from-spatial-ckpt "${INIT_CKPT}" \
|
| 60 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 61 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 62 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 63 |
+
--batch-size "${BATCH_SIZE}" \
|
| 64 |
+
--num-workers "${NUM_WORKERS}" \
|
| 65 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 66 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 67 |
+
--amp "${AMP}" \
|
| 68 |
+
--distributed \
|
| 69 |
+
--ddp-find-unused-parameters
|
| 70 |
+
|
| 71 |
+
echo "[v11c_ov123_accdoa] Done."
|
run_ov1_v11c_real_balanced_10hz.sh
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v11c_real_balanced_10hz: ACCDOA paradigm + fixed spatial front-end.
|
| 6 |
+
#
|
| 7 |
+
# Why this exists (vs legacy v11c_ov123_accdoa):
|
| 8 |
+
# The legacy v11c ran at 2.5 Hz sim-only, without the IV normalization fix
|
| 9 |
+
# and without the LocalSpatial LR fix. It showed ACCDOA successfully
|
| 10 |
+
# removes the DOA train/valid gap (train azi ≈ val azi ≈ 42°), validating
|
| 11 |
+
# the binding-free paradigm — but absolute DOA was bad because the
|
| 12 |
+
# front-end was starving (per-axis-max IV destroyed direction ratios,
|
| 13 |
+
# LocalSpatialEncoder stuck at 4.5e-6 LR).
|
| 14 |
+
#
|
| 15 |
+
# v11c_rb combines:
|
| 16 |
+
# - ACCDOA readout (no matching → no binding problem)
|
| 17 |
+
# - W-power IV normalization (spatial_modules.py fix, automatically
|
| 18 |
+
# picked up — no cfg flag needed)
|
| 19 |
+
# - local_spatial_lr_scale = 1.0 (LocalSpatial at head LR)
|
| 20 |
+
# - 10 Hz real+sim balanced manifests (replication 1,3,3,4,8,8)
|
| 21 |
+
#
|
| 22 |
+
# Hot-start:
|
| 23 |
+
# RESUME_CKPT = v11a_rb best.pt. strict=False; the ACCDOA head is
|
| 24 |
+
# missing and will random-init. Trunk + LocalSpatial + fuser + cls-aux
|
| 25 |
+
# all load cleanly. shape-mismatch filtering in load_checkpoint handles
|
| 26 |
+
# any structural delta.
|
| 27 |
+
#
|
| 28 |
+
# Expected:
|
| 29 |
+
# With binding resolved AND front-end fixed, we expect valid azi to drop
|
| 30 |
+
# from v11a_rb's ~14-16° (ov1+ov2 bounded) / 31° (ov3) into the low-
|
| 31 |
+
# double-digits across all overlaps. F20 target: v11a_rb best 32.7% →
|
| 32 |
+
# v11c_rb 38-42%.
|
| 33 |
+
# ============================================================================
|
| 34 |
+
|
| 35 |
+
GPUS="${GPUS:-8}"
|
| 36 |
+
BATCH_SIZE="${BATCH_SIZE:-4}"
|
| 37 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 38 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-15}"
|
| 39 |
+
SPATIAL_LR="${SPATIAL_LR:-1.5e-5}"
|
| 40 |
+
AMP="${AMP:-fp32}"
|
| 41 |
+
|
| 42 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 43 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 44 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 45 |
+
|
| 46 |
+
OV1_REAL_MANIFEST="${OV1_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_real_static_foa_mapped.jsonl}"
|
| 47 |
+
OV2_REAL_MANIFEST="${OV2_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_real_static_foa_mapped.jsonl}"
|
| 48 |
+
OV3_REAL_MANIFEST="${OV3_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_real_static_foa_mapped.jsonl}"
|
| 49 |
+
|
| 50 |
+
INIT_CKPT="${INIT_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v11a_real_balanced_10hz_exp/03_ov123_top4/best.pt}"
|
| 51 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v11c_real_balanced_10hz_exp/03_ov123_top4}"
|
| 52 |
+
|
| 53 |
+
if [ ! -f "${INIT_CKPT}" ]; then
|
| 54 |
+
echo "ERROR: init checkpoint not found: ${INIT_CKPT}"
|
| 55 |
+
echo " v11c_rb needs v11a_rb best.pt as hot-start."
|
| 56 |
+
echo " (Train it first with run_ov1_v11a_real_balanced_10hz.sh.)"
|
| 57 |
+
exit 1
|
| 58 |
+
fi
|
| 59 |
+
|
| 60 |
+
for MANIFEST in "${OV1_REAL_MANIFEST}" "${OV2_REAL_MANIFEST}" "${OV3_REAL_MANIFEST}"; do
|
| 61 |
+
if [ ! -f "${MANIFEST}" ]; then
|
| 62 |
+
echo "ERROR: real manifest not found: ${MANIFEST}"
|
| 63 |
+
exit 1
|
| 64 |
+
fi
|
| 65 |
+
done
|
| 66 |
+
|
| 67 |
+
mkdir -p "${OUT_DIR}"
|
| 68 |
+
|
| 69 |
+
echo "============================================================"
|
| 70 |
+
echo " v11c_real_balanced_10hz: ACCDOA + IV fix + LR fix + real data"
|
| 71 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 72 |
+
echo " Init from: ${INIT_CKPT} (strict=False)"
|
| 73 |
+
echo " Output: ${OUT_DIR}"
|
| 74 |
+
echo "============================================================"
|
| 75 |
+
|
| 76 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29574}" train_spatial_beats.py \
|
| 77 |
+
--preset ov1_local_spatial_v11c_real_balanced_10hz \
|
| 78 |
+
--init-from-spatial-ckpt "${INIT_CKPT}" \
|
| 79 |
+
--output-dir "${OUT_DIR}" \
|
| 80 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 81 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 82 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 83 |
+
--ov1-real-manifest "${OV1_REAL_MANIFEST}" \
|
| 84 |
+
--ov2-real-manifest "${OV2_REAL_MANIFEST}" \
|
| 85 |
+
--ov3-real-manifest "${OV3_REAL_MANIFEST}" \
|
| 86 |
+
--batch-size "${BATCH_SIZE}" \
|
| 87 |
+
--num-workers "${NUM_WORKERS}" \
|
| 88 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 89 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 90 |
+
--amp "${AMP}" \
|
| 91 |
+
--distributed \
|
| 92 |
+
--ddp-find-unused-parameters
|
| 93 |
+
|
| 94 |
+
echo "[v11c_real_balanced_10hz] Done."
|
run_ov1_v3.sh
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v3 experiment: top-8 unfreeze (cold start from BEATs pretrained)
|
| 6 |
+
# Stage 1: class warmup (zero spatial, bypass CNN, top-8 trunk unfreeze)
|
| 7 |
+
# Target: class_acc ≥ 65% with 63-class fixed vocabulary
|
| 8 |
+
# Stage 2: spatial finetune (trunk re-frozen, semantic anchor λ=0.5)
|
| 9 |
+
#
|
| 10 |
+
# 8-GPU training, bs=16/gpu → effective batch=128
|
| 11 |
+
# Stage 1 LR: 8e-5 (linear scaling from 5e-5 @ bs=8)
|
| 12 |
+
# Stage 2 LR: 5e-5 (linear scaling from 3e-5 @ bs=8)
|
| 13 |
+
# ============================================================================
|
| 14 |
+
|
| 15 |
+
GPUS="${GPUS:-8}"
|
| 16 |
+
BATCH_SIZE="${BATCH_SIZE:-16}"
|
| 17 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 18 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-15}"
|
| 19 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 20 |
+
CLASS_LR="${CLASS_LR:-8e-5}"
|
| 21 |
+
SPATIAL_LR="${SPATIAL_LR:-5e-5}"
|
| 22 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov1_local_spatial_v3_exp}"
|
| 23 |
+
|
| 24 |
+
CLASS_DIR="${RUN_ROOT}/01_classwarmup"
|
| 25 |
+
SPATIAL_DIR="${RUN_ROOT}/02_spatial"
|
| 26 |
+
|
| 27 |
+
echo "========================================"
|
| 28 |
+
echo " v3 experiment (top-8, cold start)"
|
| 29 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 30 |
+
echo " Stage 1: ${CLASS_EPOCHS} epochs, LR=${CLASS_LR}"
|
| 31 |
+
echo " Stage 2: ${SPATIAL_EPOCHS} epochs, LR=${SPATIAL_LR}"
|
| 32 |
+
echo "========================================"
|
| 33 |
+
|
| 34 |
+
echo "[v3] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 35 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29530}" train_spatial_beats.py \
|
| 36 |
+
--preset ov1_local_spatial_v3_classwarmup \
|
| 37 |
+
--output-dir "${CLASS_DIR}" \
|
| 38 |
+
--batch-size "${BATCH_SIZE}" \
|
| 39 |
+
--num-workers "${NUM_WORKERS}" \
|
| 40 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 41 |
+
--learning-rate "${CLASS_LR}"
|
| 42 |
+
|
| 43 |
+
echo "[v3] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 44 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29530}" train_spatial_beats.py \
|
| 45 |
+
--preset ov1_local_spatial_v3_spatial \
|
| 46 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 47 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 48 |
+
--batch-size "${BATCH_SIZE}" \
|
| 49 |
+
--num-workers "${NUM_WORKERS}" \
|
| 50 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 51 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 52 |
+
--no-resume-optimizer \
|
| 53 |
+
--reset-epoch-on-resume \
|
| 54 |
+
--reset-best-on-resume
|
| 55 |
+
|
| 56 |
+
echo "[v3] Done."
|
| 57 |
+
echo " ${CLASS_DIR}/val_predictions"
|
| 58 |
+
echo " ${SPATIAL_DIR}/val_predictions"
|
run_ov1_v3b.sh
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v3b experiment: top-8 unfreeze + freeze-CNN + small spatial (cold start)
|
| 6 |
+
# Stage 1: class warmup (λ_dir=0.5, CNN frozen, top-8 trunk unfreeze)
|
| 7 |
+
# Target: class_acc ≥ 65% with 63-class fixed vocabulary
|
| 8 |
+
# Stage 2: spatial finetune (trunk re-frozen, semantic anchor λ=0.5)
|
| 9 |
+
#
|
| 10 |
+
# 8-GPU training, bs=16/gpu → effective batch=128
|
| 11 |
+
# ============================================================================
|
| 12 |
+
|
| 13 |
+
GPUS="${GPUS:-8}"
|
| 14 |
+
BATCH_SIZE="${BATCH_SIZE:-16}"
|
| 15 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 16 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-15}"
|
| 17 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 18 |
+
CLASS_LR="${CLASS_LR:-8e-5}"
|
| 19 |
+
SPATIAL_LR="${SPATIAL_LR:-5e-5}"
|
| 20 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov1_local_spatial_v3b_exp}"
|
| 21 |
+
|
| 22 |
+
CLASS_DIR="${RUN_ROOT}/01_classwarmup"
|
| 23 |
+
SPATIAL_DIR="${RUN_ROOT}/02_spatial"
|
| 24 |
+
|
| 25 |
+
echo "========================================"
|
| 26 |
+
echo " v3b experiment (top-8, freeze-CNN, cold start)"
|
| 27 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 28 |
+
echo " Stage 1: ${CLASS_EPOCHS} epochs, LR=${CLASS_LR}"
|
| 29 |
+
echo " Stage 2: ${SPATIAL_EPOCHS} epochs, LR=${SPATIAL_LR}"
|
| 30 |
+
echo "========================================"
|
| 31 |
+
|
| 32 |
+
echo "[v3b] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 33 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29532}" train_spatial_beats.py \
|
| 34 |
+
--preset ov1_local_spatial_v3b_classwarmup \
|
| 35 |
+
--output-dir "${CLASS_DIR}" \
|
| 36 |
+
--batch-size "${BATCH_SIZE}" \
|
| 37 |
+
--num-workers "${NUM_WORKERS}" \
|
| 38 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 39 |
+
--learning-rate "${CLASS_LR}"
|
| 40 |
+
|
| 41 |
+
echo "[v3b] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 42 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29532}" train_spatial_beats.py \
|
| 43 |
+
--preset ov1_local_spatial_v3b_spatial \
|
| 44 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 45 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 46 |
+
--batch-size "${BATCH_SIZE}" \
|
| 47 |
+
--num-workers "${NUM_WORKERS}" \
|
| 48 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 49 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 50 |
+
--no-resume-optimizer \
|
| 51 |
+
--reset-epoch-on-resume \
|
| 52 |
+
--reset-best-on-resume
|
| 53 |
+
|
| 54 |
+
echo "[v3b] Done."
|
| 55 |
+
echo " ${CLASS_DIR}/val_predictions"
|
| 56 |
+
echo " ${SPATIAL_DIR}/val_predictions"
|
run_ov1_v4.sh
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v4 experiment: exact v2 replica with 63-class vocabulary
|
| 6 |
+
# ONLY change from v2: 65→63 classes (female_singing/male_singing merged,
|
| 7 |
+
# cymbal bug fixed). Everything else identical:
|
| 8 |
+
# - top-2 trunk unfreeze (layers 10, 11)
|
| 9 |
+
# - semantic anchor (λ=2.0 stage1, λ=0.5 stage2)
|
| 10 |
+
# - local_spatial CNN trainable (not frozen, not bypassed)
|
| 11 |
+
# - Kaldi fbank + SpecAugment + label_smoothing + head_dropout
|
| 12 |
+
#
|
| 13 |
+
# Purpose: isolate whether 63-class vocab change breaks convergence.
|
| 14 |
+
# If v4 reaches ~56% class_acc like v2 → vocab change is safe,
|
| 15 |
+
# problem was in v3b/v3bws architecture changes.
|
| 16 |
+
# ============================================================================
|
| 17 |
+
|
| 18 |
+
GPUS="${GPUS:-8}"
|
| 19 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 20 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 21 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-12}"
|
| 22 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 23 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 24 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 25 |
+
RUN_ROOT="${RUN_ROOT:-checkpoints/spatial_beats_ov1_local_spatial_v4_exp}"
|
| 26 |
+
|
| 27 |
+
CLASS_DIR="${RUN_ROOT}/01_classwarmup"
|
| 28 |
+
SPATIAL_DIR="${RUN_ROOT}/02_spatial"
|
| 29 |
+
|
| 30 |
+
echo "========================================"
|
| 31 |
+
echo " v4 experiment (v2 replica, 63 classes)"
|
| 32 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 33 |
+
echo " Stage 1: ${CLASS_EPOCHS} epochs, LR=${CLASS_LR}"
|
| 34 |
+
echo " Stage 2: ${SPATIAL_EPOCHS} epochs, LR=${SPATIAL_LR}"
|
| 35 |
+
echo "========================================"
|
| 36 |
+
|
| 37 |
+
echo "[v4] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 38 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29534}" train_spatial_beats.py \
|
| 39 |
+
--preset ov1_local_spatial_v4_classwarmup \
|
| 40 |
+
--output-dir "${CLASS_DIR}" \
|
| 41 |
+
--batch-size "${BATCH_SIZE}" \
|
| 42 |
+
--num-workers "${NUM_WORKERS}" \
|
| 43 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 44 |
+
--learning-rate "${CLASS_LR}"
|
| 45 |
+
|
| 46 |
+
echo "[v4] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 47 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29534}" train_spatial_beats.py \
|
| 48 |
+
--preset ov1_local_spatial_v4_spatial \
|
| 49 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 50 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 51 |
+
--batch-size "${BATCH_SIZE}" \
|
| 52 |
+
--num-workers "${NUM_WORKERS}" \
|
| 53 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 54 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 55 |
+
--no-resume-optimizer \
|
| 56 |
+
--reset-epoch-on-resume \
|
| 57 |
+
--reset-best-on-resume
|
| 58 |
+
|
| 59 |
+
echo "[v4] Done."
|
| 60 |
+
echo " ${CLASS_DIR}/val_predictions"
|
| 61 |
+
echo " ${SPATIAL_DIR}/val_predictions"
|
run_ov1_v4f.sh
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v4f spatial stage: v4 + parallel frame-level track supervision
|
| 6 |
+
# Clip-level mono_ast + frame-level FrameTrack run in parallel.
|
| 7 |
+
# Produces DCASE-format per-frame per-track predictions.
|
| 8 |
+
#
|
| 9 |
+
# Prereq: v4 stage1 must be complete (uses its best.pt)
|
| 10 |
+
# ============================================================================
|
| 11 |
+
|
| 12 |
+
GPUS="${GPUS:-8}"
|
| 13 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 14 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 15 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 16 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 17 |
+
|
| 18 |
+
STAGE1_CKPT="${STAGE1_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v4_exp/01_classwarmup/best.pt}"
|
| 19 |
+
OUTPUT_DIR="${OUTPUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v4f_exp/02_spatial}"
|
| 20 |
+
|
| 21 |
+
echo "========================================"
|
| 22 |
+
echo " v4f (frame-level track) experiment"
|
| 23 |
+
echo " clip-level mono_ast + frame-level FrameTrack"
|
| 24 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 25 |
+
echo " Resume from: ${STAGE1_CKPT}"
|
| 26 |
+
echo "========================================"
|
| 27 |
+
|
| 28 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29536}" train_spatial_beats.py \
|
| 29 |
+
--preset ov1_local_spatial_v4f_spatial \
|
| 30 |
+
--resume "${STAGE1_CKPT}" \
|
| 31 |
+
--output-dir "${OUTPUT_DIR}" \
|
| 32 |
+
--batch-size "${BATCH_SIZE}" \
|
| 33 |
+
--num-workers "${NUM_WORKERS}" \
|
| 34 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 35 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 36 |
+
--no-resume-optimizer \
|
| 37 |
+
--reset-epoch-on-resume \
|
| 38 |
+
--reset-best-on-resume
|
| 39 |
+
|
| 40 |
+
echo "[v4f] Done."
|
| 41 |
+
echo " ${OUTPUT_DIR}/val_predictions"
|
run_ov1_v4g.sh
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v4g (gentle) spatial stage: reuses v4 stage1 checkpoint
|
| 6 |
+
# Compared to v4 stage2 (λ_dir=12, λ_anchor=0.5):
|
| 7 |
+
# λ_dir: 12 → 6 (halved spatial pressure)
|
| 8 |
+
# λ_dist: 2 → 1 (halved)
|
| 9 |
+
# λ_cls: 1 → 2 (doubled class retention)
|
| 10 |
+
# λ_anchor: 0.5 → 1.5 (3× stronger semantic protection)
|
| 11 |
+
#
|
| 12 |
+
# Expected: class_acc drops ≤6pts (vs v2's -12pts), spatial slightly worse
|
| 13 |
+
#
|
| 14 |
+
# Prereq: v4 stage1 must be complete (uses its best.pt)
|
| 15 |
+
# ============================================================================
|
| 16 |
+
|
| 17 |
+
GPUS="${GPUS:-8}"
|
| 18 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 19 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 20 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 21 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 22 |
+
|
| 23 |
+
# Reuse v4 stage1 checkpoint
|
| 24 |
+
STAGE1_CKPT="${STAGE1_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v4_exp/01_classwarmup/best.pt}"
|
| 25 |
+
OUTPUT_DIR="${OUTPUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v4g_exp/02_spatial}"
|
| 26 |
+
|
| 27 |
+
echo "========================================"
|
| 28 |
+
echo " v4g (gentle spatial) experiment"
|
| 29 |
+
echo " λ_dir=6, λ_cls=2, λ_anchor=1.5"
|
| 30 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 31 |
+
echo " Resume from: ${STAGE1_CKPT}"
|
| 32 |
+
echo "========================================"
|
| 33 |
+
|
| 34 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29535}" train_spatial_beats.py \
|
| 35 |
+
--preset ov1_local_spatial_v4g_spatial \
|
| 36 |
+
--resume "${STAGE1_CKPT}" \
|
| 37 |
+
--output-dir "${OUTPUT_DIR}" \
|
| 38 |
+
--batch-size "${BATCH_SIZE}" \
|
| 39 |
+
--num-workers "${NUM_WORKERS}" \
|
| 40 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 41 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 42 |
+
--no-resume-optimizer \
|
| 43 |
+
--reset-epoch-on-resume \
|
| 44 |
+
--reset-best-on-resume
|
| 45 |
+
|
| 46 |
+
echo "[v4g] Done."
|
| 47 |
+
echo " ${OUTPUT_DIR}/val_predictions"
|
run_ov1_v5.sh
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v5 experiment: v4 + LLRD (Layer-wise LR Decay)
|
| 6 |
+
# Stage 1 (classwarmup): trunk_lr=0.2×, spatial_lr=0.5×, head_lr=1.0×
|
| 7 |
+
# Stage 2 (spatial): trunk_lr=0.1×, spatial_lr=0.3×, head_lr=1.0×
|
| 8 |
+
#
|
| 9 |
+
# Motivation: val cls_acc 天花板在45%,主要原因是 trunk 和 spatial CNN 的
|
| 10 |
+
# 梯度尺度相同,trunk 被 spatial 任务的梯度过度修改。LLRD 让 trunk 以
|
| 11 |
+
# 较低 LR 缓慢适应 FOA 域,保留 AudioSet 预训练的语义能力。
|
| 12 |
+
# ============================================================================
|
| 13 |
+
|
| 14 |
+
GPUS="${GPUS:-8}"
|
| 15 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 16 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 17 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-24}"
|
| 18 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 19 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 20 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 21 |
+
|
| 22 |
+
CLASS_DIR="checkpoints/spatial_beats_ov1_local_spatial_v5_exp/01_classwarmup"
|
| 23 |
+
SPATIAL_DIR="checkpoints/spatial_beats_ov1_local_spatial_v5_exp/02_spatial"
|
| 24 |
+
|
| 25 |
+
echo "========================================"
|
| 26 |
+
echo " v5: v4 + LLRD"
|
| 27 |
+
echo " Stage1 trunk_lr=${CLASS_LR}×0.2 spatial_lr=${CLASS_LR}×0.5"
|
| 28 |
+
echo " Stage2 trunk_lr=${SPATIAL_LR}×0.1 spatial_lr=${SPATIAL_LR}×0.3"
|
| 29 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 30 |
+
echo "========================================"
|
| 31 |
+
|
| 32 |
+
echo "[v5] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 33 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29538}" train_spatial_beats.py \
|
| 34 |
+
--preset ov1_local_spatial_v5_classwarmup \
|
| 35 |
+
--output-dir "${CLASS_DIR}" \
|
| 36 |
+
--batch-size "${BATCH_SIZE}" \
|
| 37 |
+
--num-workers "${NUM_WORKERS}" \
|
| 38 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 39 |
+
--learning-rate "${CLASS_LR}"
|
| 40 |
+
|
| 41 |
+
echo "[v5] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 42 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29538}" train_spatial_beats.py \
|
| 43 |
+
--preset ov1_local_spatial_v5_spatial \
|
| 44 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 45 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 46 |
+
--batch-size "${BATCH_SIZE}" \
|
| 47 |
+
--num-workers "${NUM_WORKERS}" \
|
| 48 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 49 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 50 |
+
--no-resume-optimizer \
|
| 51 |
+
--reset-epoch-on-resume \
|
| 52 |
+
--reset-best-on-resume
|
| 53 |
+
|
| 54 |
+
echo "[v5] Done."
|
run_ov1_v5f.sh
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v5f experiment: v5 + per-frame cls/direction/distance 预测
|
| 6 |
+
# readout_scheme = local_spatial_framewise
|
| 7 |
+
# supervision_mode = local_spatial_framewise
|
| 8 |
+
#
|
| 9 |
+
# 每个时间帧独立预测 class + direction + distance,
|
| 10 |
+
# 只对声源活跃窗口内的帧计算 loss。
|
| 11 |
+
# Validation 时对有效帧做 mean-pool 输出 clip 级别指标,和 v5 可直接对比。
|
| 12 |
+
#
|
| 13 |
+
# 两阶段:
|
| 14 |
+
# Stage 1: class warmup, LLRD
|
| 15 |
+
# Stage 2: spatial finetune, LLRD, 更强空间 loss
|
| 16 |
+
#
|
| 17 |
+
# BS=4(显存 ~12G/卡),LR 按线性缩放 bs8→bs4 减半:
|
| 18 |
+
# stage1: 5e-5 → 2.5e-5
|
| 19 |
+
# stage2: 3e-5 → 1.5e-5
|
| 20 |
+
# ============================================================================
|
| 21 |
+
|
| 22 |
+
GPUS="${GPUS:-8}"
|
| 23 |
+
BATCH_SIZE="${BATCH_SIZE:-4}"
|
| 24 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 25 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-24}"
|
| 26 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 27 |
+
CLASS_LR="${CLASS_LR:-2.5e-5}"
|
| 28 |
+
SPATIAL_LR="${SPATIAL_LR:-1.5e-5}"
|
| 29 |
+
|
| 30 |
+
CLASS_DIR="checkpoints/spatial_beats_ov1_local_spatial_v5f_exp/01_classwarmup"
|
| 31 |
+
SPATIAL_DIR="checkpoints/spatial_beats_ov1_local_spatial_v5f_exp/02_spatial"
|
| 32 |
+
|
| 33 |
+
echo "========================================"
|
| 34 |
+
echo " v5f: v5 + per-frame supervision"
|
| 35 |
+
echo " readout_scheme=local_spatial_framewise"
|
| 36 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} (12G/卡)"
|
| 37 |
+
echo " Stage1 LR=${CLASS_LR} Stage2 LR=${SPATIAL_LR}"
|
| 38 |
+
echo "========================================"
|
| 39 |
+
|
| 40 |
+
echo "[v5f] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 41 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29539}" train_spatial_beats.py \
|
| 42 |
+
--preset ov1_local_spatial_v5f_classwarmup \
|
| 43 |
+
--output-dir "${CLASS_DIR}" \
|
| 44 |
+
--batch-size "${BATCH_SIZE}" \
|
| 45 |
+
--num-workers "${NUM_WORKERS}" \
|
| 46 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 47 |
+
--learning-rate "${CLASS_LR}"
|
| 48 |
+
|
| 49 |
+
echo "[v5f] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 50 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29539}" train_spatial_beats.py \
|
| 51 |
+
--preset ov1_local_spatial_v5f_spatial \
|
| 52 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 53 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 54 |
+
--batch-size "${BATCH_SIZE}" \
|
| 55 |
+
--num-workers "${NUM_WORKERS}" \
|
| 56 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 57 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 58 |
+
--no-resume-optimizer \
|
| 59 |
+
--reset-epoch-on-resume \
|
| 60 |
+
--reset-best-on-resume
|
| 61 |
+
|
| 62 |
+
echo "[v5f] Done."
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
CLASS_DIR="checkpoints/spatial_beats_ov1_local_spatial_v5f_exp/01_classwarmup"
|
| 66 |
+
SPATIAL_DIR="checkpoints/spatial_beats_ov1_local_spatial_v5f_exp/02_spatial"
|
| 67 |
+
|
| 68 |
+
echo "========================================"
|
| 69 |
+
echo " v5f: v5 + per-frame supervision"
|
| 70 |
+
echo " readout_scheme=local_spatial_framewise"
|
| 71 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 72 |
+
echo "========================================"
|
| 73 |
+
|
| 74 |
+
echo "[v5f] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 75 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29539}" train_spatial_beats.py \
|
| 76 |
+
--preset ov1_local_spatial_v5f_classwarmup \
|
| 77 |
+
--output-dir "${CLASS_DIR}" \
|
| 78 |
+
--batch-size "${BATCH_SIZE}" \
|
| 79 |
+
--num-workers "${NUM_WORKERS}" \
|
| 80 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 81 |
+
--learning-rate "${CLASS_LR}"
|
| 82 |
+
|
| 83 |
+
echo "[v5f] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 84 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29539}" train_spatial_beats.py \
|
| 85 |
+
--preset ov1_local_spatial_v5f_spatial \
|
| 86 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 87 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 88 |
+
--batch-size "${BATCH_SIZE}" \
|
| 89 |
+
--num-workers "${NUM_WORKERS}" \
|
| 90 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 91 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 92 |
+
--no-resume-optimizer \
|
| 93 |
+
--reset-epoch-on-resume \
|
| 94 |
+
--reset-best-on-resume
|
| 95 |
+
|
| 96 |
+
echo "[v5f] Done."
|
run_ov1_v6dc.sh
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v6dc: FOA 域适应 trunk + use_direct_cls(分类路径完全解耦)
|
| 6 |
+
#
|
| 7 |
+
# 核心改动:use_direct_cls=True
|
| 8 |
+
# pred_class_logits 来自 mean-pool(semantic_tokens) 而非 attention-pool(fused)
|
| 9 |
+
# 分类路径 = 纯 BEATs 分类路径(消除 20pt 的 pooling 差距)
|
| 10 |
+
# 空间路径(dir/dist)仍然走 fused_tokens
|
| 11 |
+
#
|
| 12 |
+
# 预期:val cls 接近 foa_cls 的 70%,空间指标仍然学习
|
| 13 |
+
# ============================================================================
|
| 14 |
+
|
| 15 |
+
GPUS="${GPUS:-8}"
|
| 16 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 17 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 18 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-24}"
|
| 19 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 20 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 21 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 22 |
+
|
| 23 |
+
CLASS_DIR="checkpoints/spatial_beats_ov1_local_spatial_v6dc_exp/01_classwarmup"
|
| 24 |
+
SPATIAL_DIR="checkpoints/spatial_beats_ov1_local_spatial_v6dc_exp/02_spatial"
|
| 25 |
+
FOA_CLS_CKPT="checkpoints/beats_ov1_foa_cls_v1/03_full/best.pt"
|
| 26 |
+
|
| 27 |
+
if [ ! -f "${FOA_CLS_CKPT}" ]; then
|
| 28 |
+
echo "ERROR: FOA cls checkpoint not found: ${FOA_CLS_CKPT}"
|
| 29 |
+
exit 1
|
| 30 |
+
fi
|
| 31 |
+
|
| 32 |
+
echo "========================================"
|
| 33 |
+
echo " v6dc: FOA trunk + direct cls (解耦)"
|
| 34 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 35 |
+
echo " Stage1 LR=${CLASS_LR} Stage2 LR=${SPATIAL_LR}"
|
| 36 |
+
echo "========================================"
|
| 37 |
+
|
| 38 |
+
echo "[v6dc] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 39 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29543}" train_spatial_beats.py \
|
| 40 |
+
--preset ov1_local_spatial_v6dc_classwarmup \
|
| 41 |
+
--output-dir "${CLASS_DIR}" \
|
| 42 |
+
--batch-size "${BATCH_SIZE}" \
|
| 43 |
+
--num-workers "${NUM_WORKERS}" \
|
| 44 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 45 |
+
--learning-rate "${CLASS_LR}"
|
| 46 |
+
|
| 47 |
+
echo "[v6dc] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 48 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29543}" train_spatial_beats.py \
|
| 49 |
+
--preset ov1_local_spatial_v6dc_spatial \
|
| 50 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 51 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 52 |
+
--batch-size "${BATCH_SIZE}" \
|
| 53 |
+
--num-workers "${NUM_WORKERS}" \
|
| 54 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 55 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 56 |
+
--no-resume-optimizer \
|
| 57 |
+
--reset-epoch-on-resume \
|
| 58 |
+
--reset-best-on-resume
|
| 59 |
+
|
| 60 |
+
echo "[v6dc] Done."
|
run_ov1_v7.sh
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v7: 修复版 v6(deep_norm=True / relative_position_embedding=True / gru_rel_pos=True)
|
| 6 |
+
#
|
| 7 |
+
# v6 的 40% 天花板根本原因:SpatialBEATsConfig 默认 deep_norm=False,
|
| 8 |
+
# 与 BEATs_iter3_plus_AS2M 实际训练配置不符,导致 encoder forward 路径错误,
|
| 9 |
+
# 权重加载后特征 cosine sim = -0.04,特征空间完全走样。
|
| 10 |
+
#
|
| 11 |
+
# 修复后 cosine sim = 1.0,v7 在正确的特征空间上重跑 v6。
|
| 12 |
+
# 所有训练超参与 v6 完全相同,只是输出到新目录。
|
| 13 |
+
# ============================================================================
|
| 14 |
+
|
| 15 |
+
GPUS="${GPUS:-8}"
|
| 16 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 17 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 18 |
+
CLASS_EPOCHS="${CLASS_EPOCHS:-24}"
|
| 19 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 20 |
+
CLASS_LR="${CLASS_LR:-5e-5}"
|
| 21 |
+
SPATIAL_LR="${SPATIAL_LR:-3e-5}"
|
| 22 |
+
|
| 23 |
+
CLASS_DIR="checkpoints/spatial_beats_ov1_local_spatial_v7_exp/01_classwarmup"
|
| 24 |
+
SPATIAL_DIR="checkpoints/spatial_beats_ov1_local_spatial_v7_exp/02_spatial"
|
| 25 |
+
FOA_CLS_CKPT="checkpoints/beats_ov1_foa_cls_v1/03_full/best.pt"
|
| 26 |
+
|
| 27 |
+
if [ ! -f "${FOA_CLS_CKPT}" ]; then
|
| 28 |
+
echo "ERROR: FOA cls checkpoint not found: ${FOA_CLS_CKPT}"
|
| 29 |
+
exit 1
|
| 30 |
+
fi
|
| 31 |
+
|
| 32 |
+
echo "========================================"
|
| 33 |
+
echo " v7: 修复版 v6(deep_norm=True)"
|
| 34 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 35 |
+
echo " Stage1 LR=${CLASS_LR} Stage2 LR=${SPATIAL_LR}"
|
| 36 |
+
echo "========================================"
|
| 37 |
+
|
| 38 |
+
echo "[v7] Stage 1: class warmup -> ${CLASS_DIR}"
|
| 39 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29545}" train_spatial_beats.py \
|
| 40 |
+
--preset ov1_local_spatial_v7_classwarmup \
|
| 41 |
+
--output-dir "${CLASS_DIR}" \
|
| 42 |
+
--batch-size "${BATCH_SIZE}" \
|
| 43 |
+
--num-workers "${NUM_WORKERS}" \
|
| 44 |
+
--num-epochs "${CLASS_EPOCHS}" \
|
| 45 |
+
--learning-rate "${CLASS_LR}"
|
| 46 |
+
|
| 47 |
+
echo "[v7] Stage 2: spatial finetune -> ${SPATIAL_DIR}"
|
| 48 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29545}" train_spatial_beats.py \
|
| 49 |
+
--preset ov1_local_spatial_v7_spatial \
|
| 50 |
+
--resume "${CLASS_DIR}/best.pt" \
|
| 51 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 52 |
+
--batch-size "${BATCH_SIZE}" \
|
| 53 |
+
--num-workers "${NUM_WORKERS}" \
|
| 54 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 55 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 56 |
+
--no-resume-optimizer \
|
| 57 |
+
--reset-epoch-on-resume \
|
| 58 |
+
--reset-best-on-resume
|
| 59 |
+
|
| 60 |
+
echo "[v7] Done."
|
run_ov1_v7f.sh
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v7f: v7(deep_norm 修复版)stage2 + 并行逐帧 FrameTrack 监督
|
| 6 |
+
#
|
| 7 |
+
# 解决 v7 的核心遗留问题:activity_acc = 0.0
|
| 8 |
+
# FrameTrackPredictionHeads 在 fused_spatial_embeddings 上做逐帧预测,
|
| 9 |
+
# 与 clip-level mono_ast head 并行运行,不破坏已有监督。
|
| 10 |
+
#
|
| 11 |
+
# 热启动:从 v7 stage1(classwarmup)的 best.pt 开始
|
| 12 |
+
# 输出:checkpoints/spatial_beats_ov1_local_spatial_v7f_exp/02_spatial
|
| 13 |
+
# ============================================================================
|
| 14 |
+
|
| 15 |
+
GPUS="${GPUS:-8}"
|
| 16 |
+
BATCH_SIZE="${BATCH_SIZE:-4}"
|
| 17 |
+
NUM_WORKERS="${NUM_WORKERS:-24}"
|
| 18 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 19 |
+
SPATIAL_LR="${SPATIAL_LR:-1.5e-5}"
|
| 20 |
+
|
| 21 |
+
V7_CLASS_BEST="checkpoints/spatial_beats_ov1_local_spatial_v7_exp/01_classwarmup/best.pt"
|
| 22 |
+
SPATIAL_DIR="checkpoints/spatial_beats_ov1_local_spatial_v7f_exp/02_spatial"
|
| 23 |
+
|
| 24 |
+
if [ ! -f "${V7_CLASS_BEST}" ]; then
|
| 25 |
+
echo "ERROR: v7 classwarmup best.pt not found: ${V7_CLASS_BEST}"
|
| 26 |
+
echo " Please run ./run_ov1_v7.sh first (stage1 must complete)."
|
| 27 |
+
exit 1
|
| 28 |
+
fi
|
| 29 |
+
|
| 30 |
+
echo "========================================"
|
| 31 |
+
echo " v7f: v7 + frame-level track 监督"
|
| 32 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE}"
|
| 33 |
+
echo " Resume from: ${V7_CLASS_BEST}"
|
| 34 |
+
echo " LR=${SPATIAL_LR}"
|
| 35 |
+
echo "========================================"
|
| 36 |
+
|
| 37 |
+
echo "[v7f] Stage 2 (spatial + frame track) -> ${SPATIAL_DIR}"
|
| 38 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29547}" train_spatial_beats.py \
|
| 39 |
+
--preset ov1_local_spatial_v7f_spatial \
|
| 40 |
+
--resume "${V7_CLASS_BEST}" \
|
| 41 |
+
--output-dir "${SPATIAL_DIR}" \
|
| 42 |
+
--batch-size "${BATCH_SIZE}" \
|
| 43 |
+
--num-workers "${NUM_WORKERS}" \
|
| 44 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 45 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 46 |
+
--no-resume-optimizer \
|
| 47 |
+
--reset-epoch-on-resume \
|
| 48 |
+
--reset-best-on-resume
|
| 49 |
+
|
| 50 |
+
echo "[v7f] Done."
|
run_ov1_v7g_ov123_top4.sh
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v7g_ov123_top4: v7f_ov123_top4 + 三个正交修复
|
| 6 |
+
#
|
| 7 |
+
# 1) train_manifest_replication = (1, 3, 3) → ov1:ov2:ov3 = 1:3:3,
|
| 8 |
+
# 让 K-1 个 track 在多源 batch 吃到正梯度。
|
| 9 |
+
# 2) Focal BCE for activity(γ=2, α=0.25, pos_weight=5.0),
|
| 10 |
+
# 专门对付 duplicate(confidently-wrong negative)。
|
| 11 |
+
# 3) Hungarian class-cost warmup:epoch 0-2 class 权重 0.0,
|
| 12 |
+
# 3-5 linear ramp 到 1.0,6+ = 1.0。
|
| 13 |
+
#
|
| 14 |
+
# 完全不动 v7f 代码路径;所有新行为由新的 cfg 字段 opt-in。
|
| 15 |
+
# 热启动复用 v7 stage1 classwarmup best.pt(与 v7f 一致)。
|
| 16 |
+
# 输出独立目录:03_ov123_top4(在 v7g 实验路径下)。
|
| 17 |
+
# ============================================================================
|
| 18 |
+
|
| 19 |
+
GPUS="${GPUS:-8}"
|
| 20 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 21 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 22 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-20}"
|
| 23 |
+
SPATIAL_LR="${SPATIAL_LR:-2.1e-5}"
|
| 24 |
+
AMP="${AMP:-fp32}" # fp32 | bf16 | fp16
|
| 25 |
+
|
| 26 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 27 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 28 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 29 |
+
|
| 30 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v7_exp/01_classwarmup/best.pt}"
|
| 31 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v7g_ov123_exp/03_ov123_top4}"
|
| 32 |
+
|
| 33 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 34 |
+
echo "ERROR: resume checkpoint not found: ${RESUME_CKPT}"
|
| 35 |
+
echo " Please run ./run_ov1_v7.sh first (stage1 must complete)."
|
| 36 |
+
exit 1
|
| 37 |
+
fi
|
| 38 |
+
|
| 39 |
+
echo "========================================"
|
| 40 |
+
echo " v7g_ov123_top4: v7f + sampler(1:3:3) + focal BCE + class-cost warmup"
|
| 41 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 42 |
+
echo " Resume from: ${RESUME_CKPT}"
|
| 43 |
+
echo " Output dir: ${OUT_DIR}"
|
| 44 |
+
echo "========================================"
|
| 45 |
+
|
| 46 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29550}" train_spatial_beats.py \
|
| 47 |
+
--preset ov1_local_spatial_v7g_ov123_top4 \
|
| 48 |
+
--resume "${RESUME_CKPT}" \
|
| 49 |
+
--output-dir "${OUT_DIR}" \
|
| 50 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 51 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 52 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 53 |
+
--batch-size "${BATCH_SIZE}" \
|
| 54 |
+
--num-workers "${NUM_WORKERS}" \
|
| 55 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 56 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 57 |
+
--amp "${AMP}" \
|
| 58 |
+
--no-resume-optimizer \
|
| 59 |
+
--reset-epoch-on-resume \
|
| 60 |
+
--reset-best-on-resume
|
| 61 |
+
|
| 62 |
+
echo "[v7g_ov123_top4] Done."
|
run_ov1_v7i_ov123_top4.sh
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v7i_ov123_top4: 四项改动,从 v7f best.pt 热启动
|
| 6 |
+
#
|
| 7 |
+
# 1. Segment-level matching (use_segment_matching=True)
|
| 8 |
+
# 活跃集不变的时间段内保持 track 固定,消除逐帧 Hungarian 的 track 翻转。
|
| 9 |
+
#
|
| 10 |
+
# 2. Two-stage loss schedule
|
| 11 |
+
# Stage 1 (ep 0-4): lambda_dir=0, lambda_dist=0, dir/dist cost weight=0
|
| 12 |
+
# → class head 在干净信号下充分学习
|
| 13 |
+
# Stage 2 (ep 5+): lambda_dir=4.0, lambda_dist=1.0 完全恢复
|
| 14 |
+
#
|
| 15 |
+
# 3. 1:5:5 采样 (ov1:ov2:ov3 = 1:5:5)
|
| 16 |
+
# 更多多源 batch,K-1 个 track 吃到更多正梯度
|
| 17 |
+
#
|
| 18 |
+
# 4. Class-weighted CE
|
| 19 |
+
# aircraft/insect/vehicle (v7f class_acc=0%) 权重 4×
|
| 20 |
+
# singing/train/printer (v7f FP 主力) 权重 0.5×
|
| 21 |
+
#
|
| 22 |
+
# 热启动:从 v7f best.pt(activity/DOA 已收敛),跑 15 epoch
|
| 23 |
+
# ep 0-4: class warmup(stage 1)
|
| 24 |
+
# ep 5-14: class+DOA 联合优化(stage 2)
|
| 25 |
+
# ============================================================================
|
| 26 |
+
|
| 27 |
+
GPUS="${GPUS:-8}"
|
| 28 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 29 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 30 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-15}"
|
| 31 |
+
SPATIAL_LR="${SPATIAL_LR:-1.5e-5}"
|
| 32 |
+
AMP="${AMP:-fp32}" # fp32 | bf16 | fp16
|
| 33 |
+
|
| 34 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 35 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 36 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 37 |
+
|
| 38 |
+
# 从 v7f best.pt 热启动
|
| 39 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v7f_ov123_exp/03_ov123_top4/best.pt}"
|
| 40 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v7i_ov123_exp/03_ov123_top4}"
|
| 41 |
+
|
| 42 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 43 |
+
echo "ERROR: resume checkpoint not found: ${RESUME_CKPT}"
|
| 44 |
+
echo " Expected v7f best.pt at: ${RESUME_CKPT}"
|
| 45 |
+
exit 1
|
| 46 |
+
fi
|
| 47 |
+
|
| 48 |
+
echo "========================================"
|
| 49 |
+
echo " v7i_ov123_top4: segment matching + 2-stage loss + 1:5:5 + cls-weight"
|
| 50 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 51 |
+
echo " Resume: ${RESUME_CKPT}"
|
| 52 |
+
echo " Output: ${OUT_DIR}"
|
| 53 |
+
echo " Stage 1 (ep 0-4): class only (dir/dist lambda=0)"
|
| 54 |
+
echo " Stage 2 (ep 5+): class + DOA full lambda"
|
| 55 |
+
echo "========================================"
|
| 56 |
+
|
| 57 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29552}" train_spatial_beats.py \
|
| 58 |
+
--preset ov1_local_spatial_v7i_ov123_top4 \
|
| 59 |
+
--resume "${RESUME_CKPT}" \
|
| 60 |
+
--output-dir "${OUT_DIR}" \
|
| 61 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 62 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 63 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 64 |
+
--batch-size "${BATCH_SIZE}" \
|
| 65 |
+
--num-workers "${NUM_WORKERS}" \
|
| 66 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 67 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 68 |
+
--amp "${AMP}" \
|
| 69 |
+
--no-resume-optimizer \
|
| 70 |
+
--reset-epoch-on-resume \
|
| 71 |
+
--reset-best-on-resume
|
| 72 |
+
|
| 73 |
+
echo "[v7i_ov123_top4] Done."
|
run_ov1_v7j_ov123_top4.sh
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v7j_ov123_top4: v7h + A0-A0-A0 duplicate + class-weighted CE + dynamic pos_weight
|
| 6 |
+
#
|
| 7 |
+
# 在 v7h(F20=0.246) 基础上精准加三个修复:
|
| 8 |
+
#
|
| 9 |
+
# 1. A0-A0-A0 duplicate (use_adpit_duplicate=True)
|
| 10 |
+
# 单源帧时把唯一 GT 广播给所有 K=4 个 track,解决 track dead 问题。
|
| 11 |
+
# 来自 DCASE baseline 的 ADPIT A0-A0-A0 设计。
|
| 12 |
+
#
|
| 13 |
+
# 2. Class-weighted CE
|
| 14 |
+
# aircraft/insect/vehicle (v7f class_acc=0%) 权重 4×
|
| 15 |
+
# singing/train/printer 降至 0.5×
|
| 16 |
+
#
|
| 17 |
+
# 3. 动态 pos_weight (use_dynamic_pos_weight=True)
|
| 18 |
+
# 每 batch 实时 sqrt(neg/pos),cap=20,随 A0-A0-A0 改变的正样本比例自动适应。
|
| 19 |
+
#
|
| 20 |
+
# 不变:lambda_dir=4.0, lambda_dist=1.0, 1:3:3 采样,10 epoch
|
| 21 |
+
# 热启动:从 v7f best.pt(DOA 已收敛在 ~10°)
|
| 22 |
+
# ============================================================================
|
| 23 |
+
|
| 24 |
+
GPUS="${GPUS:-8}"
|
| 25 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 26 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 27 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-10}"
|
| 28 |
+
SPATIAL_LR="${SPATIAL_LR:-1.5e-5}"
|
| 29 |
+
AMP="${AMP:-fp32}"
|
| 30 |
+
|
| 31 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 32 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 33 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 34 |
+
|
| 35 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v7f_ov123_exp/03_ov123_top4/best.pt}"
|
| 36 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v7j_ov123_exp/03_ov123_top4}"
|
| 37 |
+
|
| 38 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 39 |
+
echo "ERROR: resume checkpoint not found: ${RESUME_CKPT}"
|
| 40 |
+
echo " Expected v7f best.pt at: ${RESUME_CKPT}"
|
| 41 |
+
exit 1
|
| 42 |
+
fi
|
| 43 |
+
|
| 44 |
+
echo "========================================"
|
| 45 |
+
echo " v7j: v7h + A0-A0-A0 + cls-weight + dynamic-pw"
|
| 46 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 47 |
+
echo " Resume: ${RESUME_CKPT}"
|
| 48 |
+
echo " Output: ${OUT_DIR}"
|
| 49 |
+
echo "========================================"
|
| 50 |
+
|
| 51 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29553}" train_spatial_beats.py \
|
| 52 |
+
--preset ov1_local_spatial_v7j_ov123_top4 \
|
| 53 |
+
--resume "${RESUME_CKPT}" \
|
| 54 |
+
--output-dir "${OUT_DIR}" \
|
| 55 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 56 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 57 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 58 |
+
--batch-size "${BATCH_SIZE}" \
|
| 59 |
+
--num-workers "${NUM_WORKERS}" \
|
| 60 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 61 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 62 |
+
--amp "${AMP}" \
|
| 63 |
+
--no-resume-optimizer \
|
| 64 |
+
--reset-epoch-on-resume \
|
| 65 |
+
--reset-best-on-resume
|
| 66 |
+
|
| 67 |
+
echo "[v7j_ov123_top4] Done."
|
run_ov1_v9_ov123_top4.sh
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v9_ov123_top4: v8a + class-first cleanup (Fix A..F from docs/0423.md analysis)
|
| 6 |
+
#
|
| 7 |
+
# Purely frame-level. All six fixes are additive and zero-initialised so this
|
| 8 |
+
# script hot-starts from v8a best.pt with identical epoch-0 forward output:
|
| 9 |
+
#
|
| 10 |
+
# (A) Confirmed frog→bird is pure imbalance (bird:frog≈29:1 in ov1 train),
|
| 11 |
+
# not a label bug. Folded into (D): bird 0.6×, frog 3.0×.
|
| 12 |
+
# (D) _V9_CLASS_WEIGHTS — revert aircraft/vehicle to ≤1.5× (v7I's 4× hurt
|
| 13 |
+
# their siblings), suppress catch-all classes
|
| 14 |
+
# (bird, machine, human_vocalization, rain, breathing), boost confusable
|
| 15 |
+
# rare classes (frog, crackle, tape, knock, drawer_cabinet).
|
| 16 |
+
# (E) class_head_lr_scale=0.3 baseline + freeze during the 4-epoch DOA ramp
|
| 17 |
+
# (class_head_lr_scale_during_ramp=0.0). Prevents printer 100%→59%
|
| 18 |
+
# style regressions when dir/dist supervision unlocks.
|
| 19 |
+
# (B) frame_class_ontology_smoothing=0.1 with 8 sibling groups (transport,
|
| 20 |
+
# human voice, animal vocal, indoor mechanical, percussive, weather,
|
| 21 |
+
# musical, alarms) — cross-group errors stay full-penalty, sibling
|
| 22 |
+
# collapse (aircraft→speech, frog→bird, …) loses most of its loss.
|
| 23 |
+
# (C) use_class_head_demixer=True (1-layer cross-attn: track_latent queries
|
| 24 |
+
# attend to pre-frequency-pool BEATs trunk tokens at the mapped trunk
|
| 25 |
+
# time step) — gives the class head a freq-axis demixing path for ov3
|
| 26 |
+
# multi-source frames. Zero-gated on load.
|
| 27 |
+
# (F) use_class_head_mlp_residual=True — zero-gated 2-layer MLP residual
|
| 28 |
+
# on top of the legacy Linear class_head for strictly more capacity.
|
| 29 |
+
#
|
| 30 |
+
# What v9 does NOT change relative to v8a:
|
| 31 |
+
# - BEATs trunk / frequency_pool / temporal_resampler
|
| 32 |
+
# - local_spatial_encoder / local_spatial_fuser
|
| 33 |
+
# - source_query_decoder
|
| 34 |
+
# - activity / direction / distance heads
|
| 35 |
+
# - segment matching + 4-epoch DOA ramp schedule
|
| 36 |
+
#
|
| 37 |
+
# Hot-start:
|
| 38 |
+
# Default RESUME_CKPT = v8a best.pt. strict=False load; new v9 parameters
|
| 39 |
+
# (class_head_mlp.*, class_head_demixer.*, gates) are 0-init so the first
|
| 40 |
+
# forward matches v8a exactly (verified with max abs diff = 0).
|
| 41 |
+
# ============================================================================
|
| 42 |
+
|
| 43 |
+
GPUS="${GPUS:-8}"
|
| 44 |
+
BATCH_SIZE="${BATCH_SIZE:-8}"
|
| 45 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 46 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-12}"
|
| 47 |
+
SPATIAL_LR="${SPATIAL_LR:-1.5e-5}"
|
| 48 |
+
AMP="${AMP:-fp32}"
|
| 49 |
+
|
| 50 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 51 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 52 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 53 |
+
|
| 54 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v8a_ov123_exp/03_ov123_top4/best.pt}"
|
| 55 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v9_ov123_exp/03_ov123_top4}"
|
| 56 |
+
|
| 57 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 58 |
+
echo "ERROR: resume checkpoint not found: ${RESUME_CKPT}"
|
| 59 |
+
echo " Expected v8a best.pt at: ${RESUME_CKPT}"
|
| 60 |
+
exit 1
|
| 61 |
+
fi
|
| 62 |
+
|
| 63 |
+
echo "==============================================="
|
| 64 |
+
echo " v9_ov123_top4: v8a + class-first fixes (A..F)"
|
| 65 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 66 |
+
echo " Resume from: ${RESUME_CKPT}"
|
| 67 |
+
echo " Output dir: ${OUT_DIR}"
|
| 68 |
+
echo "==============================================="
|
| 69 |
+
|
| 70 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29557}" train_spatial_beats.py \
|
| 71 |
+
--preset ov1_local_spatial_v9_ov123_top4 \
|
| 72 |
+
--resume "${RESUME_CKPT}" \
|
| 73 |
+
--output-dir "${OUT_DIR}" \
|
| 74 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 75 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 76 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 77 |
+
--batch-size "${BATCH_SIZE}" \
|
| 78 |
+
--num-workers "${NUM_WORKERS}" \
|
| 79 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 80 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 81 |
+
--amp "${AMP}" \
|
| 82 |
+
--no-resume-optimizer \
|
| 83 |
+
--reset-epoch-on-resume \
|
| 84 |
+
--reset-best-on-resume
|
| 85 |
+
|
| 86 |
+
echo "[v9_ov123_top4] Done."
|
run_ov1_v9_real_balanced_10hz.sh
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v9_real_balanced_10hz: v9 + 10 Hz supervision + balanced sim/real mix
|
| 6 |
+
#
|
| 7 |
+
# Why this exists:
|
| 8 |
+
# - The 5 Hz mixed run exposed a real_ov3 edge case where floor/ceil frame
|
| 9 |
+
# quantization can produce >4 GT-active sources in a single discrete frame,
|
| 10 |
+
# even though continuous-time concurrency stays <=3.
|
| 11 |
+
# - A full-manifest scan shows that this issue disappears at 10 Hz under the
|
| 12 |
+
# current quantization rule.
|
| 13 |
+
#
|
| 14 |
+
# Relative to the 5 Hz variant:
|
| 15 |
+
# - target_token_rate = 10.0
|
| 16 |
+
# - same balanced sim/real replication
|
| 17 |
+
# - same v9 architecture / class-side fixes / segment matching / DOA ramp
|
| 18 |
+
#
|
| 19 |
+
# Recommended hot-start:
|
| 20 |
+
# - Prefer v8a best.pt for the cleanest ablation.
|
| 21 |
+
# ============================================================================
|
| 22 |
+
|
| 23 |
+
GPUS="${GPUS:-8}"
|
| 24 |
+
BATCH_SIZE="${BATCH_SIZE:-4}"
|
| 25 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 26 |
+
SPATIAL_EPOCHS="${SPATIAL_EPOCHS:-15}"
|
| 27 |
+
SPATIAL_LR="${SPATIAL_LR:-1.5e-5}"
|
| 28 |
+
AMP="${AMP:-fp32}"
|
| 29 |
+
|
| 30 |
+
OV1_MANIFEST="${OV1_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl}"
|
| 31 |
+
OV2_MANIFEST="${OV2_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_foa.jsonl}"
|
| 32 |
+
OV3_MANIFEST="${OV3_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_foa.jsonl}"
|
| 33 |
+
|
| 34 |
+
OV1_REAL_MANIFEST="${OV1_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_real_static_foa_mapped.jsonl}"
|
| 35 |
+
OV2_REAL_MANIFEST="${OV2_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov2_real_static_foa_mapped.jsonl}"
|
| 36 |
+
OV3_REAL_MANIFEST="${OV3_REAL_MANIFEST:-/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov3_real_static_foa_mapped.jsonl}"
|
| 37 |
+
|
| 38 |
+
RESUME_CKPT="${RESUME_CKPT:-checkpoints/spatial_beats_ov1_local_spatial_v8a_ov123_exp/03_ov123_top4/best.pt}"
|
| 39 |
+
OUT_DIR="${OUT_DIR:-checkpoints/spatial_beats_ov1_local_spatial_v9_real_balanced_10hz_exp/03_ov123_top4}"
|
| 40 |
+
|
| 41 |
+
if [ ! -f "${RESUME_CKPT}" ]; then
|
| 42 |
+
echo "WARN: preferred v8a best.pt not found at ${RESUME_CKPT}"
|
| 43 |
+
FALLBACK_V8="checkpoints/spatial_beats_ov1_local_spatial_v8_ov123_exp/03_ov123_top4/best.pt"
|
| 44 |
+
FALLBACK_V7H="checkpoints/spatial_beats_ov1_local_spatial_v7h_ov123_exp/03_ov123_top4/best.pt"
|
| 45 |
+
if [ -f "${FALLBACK_V8}" ]; then
|
| 46 |
+
echo " Falling back to v8 best.pt: ${FALLBACK_V8}"
|
| 47 |
+
RESUME_CKPT="${FALLBACK_V8}"
|
| 48 |
+
elif [ -f "${FALLBACK_V7H}" ]; then
|
| 49 |
+
echo " Falling back to v7h best.pt: ${FALLBACK_V7H}"
|
| 50 |
+
RESUME_CKPT="${FALLBACK_V7H}"
|
| 51 |
+
else
|
| 52 |
+
echo "ERROR: none of v8a / v8 / v7h best.pt were found."
|
| 53 |
+
exit 1
|
| 54 |
+
fi
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
for MANIFEST in "${OV1_REAL_MANIFEST}" "${OV2_REAL_MANIFEST}" "${OV3_REAL_MANIFEST}"; do
|
| 58 |
+
if [ ! -f "${MANIFEST}" ]; then
|
| 59 |
+
echo "ERROR: real manifest not found: ${MANIFEST}"
|
| 60 |
+
echo " Run: python scripts/map_real_manifest.py"
|
| 61 |
+
exit 1
|
| 62 |
+
fi
|
| 63 |
+
done
|
| 64 |
+
|
| 65 |
+
echo "============================================================"
|
| 66 |
+
echo " v9_real_balanced_10hz: v9 + 10 Hz + sim/real balanced mix"
|
| 67 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${SPATIAL_LR} AMP=${AMP}"
|
| 68 |
+
echo " Resume: ${RESUME_CKPT}"
|
| 69 |
+
echo " Output: ${OUT_DIR}"
|
| 70 |
+
echo "============================================================"
|
| 71 |
+
|
| 72 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29559}" train_spatial_beats.py \
|
| 73 |
+
--preset ov1_local_spatial_v9_real_balanced_10hz \
|
| 74 |
+
--resume "${RESUME_CKPT}" \
|
| 75 |
+
--output-dir "${OUT_DIR}" \
|
| 76 |
+
--ov1-manifest "${OV1_MANIFEST}" \
|
| 77 |
+
--ov2-manifest "${OV2_MANIFEST}" \
|
| 78 |
+
--ov3-manifest "${OV3_MANIFEST}" \
|
| 79 |
+
--ov1-real-manifest "${OV1_REAL_MANIFEST}" \
|
| 80 |
+
--ov2-real-manifest "${OV2_REAL_MANIFEST}" \
|
| 81 |
+
--ov3-real-manifest "${OV3_REAL_MANIFEST}" \
|
| 82 |
+
--batch-size "${BATCH_SIZE}" \
|
| 83 |
+
--num-workers "${NUM_WORKERS}" \
|
| 84 |
+
--num-epochs "${SPATIAL_EPOCHS}" \
|
| 85 |
+
--learning-rate "${SPATIAL_LR}" \
|
| 86 |
+
--amp "${AMP}" \
|
| 87 |
+
--no-resume-optimizer \
|
| 88 |
+
--reset-epoch-on-resume \
|
| 89 |
+
--reset-best-on-resume
|
| 90 |
+
|
| 91 |
+
echo "[v9_real_balanced_10hz] Done."
|
run_v13f_stage1_trunk.sh
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# ============================================================================
|
| 5 |
+
# v13_F STAGE 1: Multi-label BEATs trunk fine-tune on ALL spatial training data
|
| 6 |
+
#
|
| 7 |
+
# Purpose:
|
| 8 |
+
# v13_D's oracle_class_acc is stuck at 0.77. That's the trunk representation
|
| 9 |
+
# ceiling — no head change can break it. This script fine-tunes BEATs on
|
| 10 |
+
# every available training manifest as a multi-label classification task,
|
| 11 |
+
# extracting all source labels from each clip.
|
| 12 |
+
#
|
| 13 |
+
# Data (all sources of labels):
|
| 14 |
+
# - unified_spatial_foa_fsd63_all/train.jsonl (~330K clips, FSD63 labels)
|
| 15 |
+
# - ov1_foa.jsonl / ov2_foa.jsonl / ov3_foa.jsonl (sim static, mono_target_label)
|
| 16 |
+
# - ov{1,2,3}_real_static_foa_mapped.jsonl (real static)
|
| 17 |
+
# - dcase_starss_foa.train.jsonl (DCASE STARSS real dynamic)
|
| 18 |
+
#
|
| 19 |
+
# Target: macro-mAP > 0.6 and top1_in_gt_acc > 0.85
|
| 20 |
+
# Output: checkpoints/beats_trunk_multilabel_v13f/best.pt
|
| 21 |
+
# best.pt['beats_only'] can be loaded into any downstream SpatialBEATs
|
| 22 |
+
# trunk via SpatialBEATs.beats.load_state_dict(..., strict=False)
|
| 23 |
+
# ============================================================================
|
| 24 |
+
|
| 25 |
+
GPUS="${GPUS:-8}"
|
| 26 |
+
BATCH_SIZE="${BATCH_SIZE:-16}"
|
| 27 |
+
NUM_WORKERS="${NUM_WORKERS:-8}"
|
| 28 |
+
NUM_EPOCHS="${NUM_EPOCHS:-15}"
|
| 29 |
+
LR="${LR:-1e-4}"
|
| 30 |
+
UNFREEZE_TOP="${UNFREEZE_TOP:-0}" # 0 = freeze all BEATs; only train classifier
|
| 31 |
+
UNFREEZE_ALL="${UNFREEZE_ALL:-1}" # 1 = unfreeze full BEATs for trunk fine-tune
|
| 32 |
+
CHANNEL_MODE="${CHANNEL_MODE:-w}"
|
| 33 |
+
MAX_DURATION="${MAX_DURATION:-10.0}" # shorter clips speed up trunk warmup
|
| 34 |
+
|
| 35 |
+
BEATS_CKPT="${BEATS_CKPT:-pretrain_ckpt/BEATs_iter3_plus_AS2M.pt/BEATs_iter3_plus_AS2M.pt}"
|
| 36 |
+
OUT_DIR="${OUT_DIR:-checkpoints/beats_trunk_multilabel_v13f/stage1_all_data}"
|
| 37 |
+
|
| 38 |
+
# Args flag
|
| 39 |
+
UNFREEZE_FLAGS=""
|
| 40 |
+
if [ "${UNFREEZE_ALL}" = "1" ]; then
|
| 41 |
+
UNFREEZE_FLAGS="${UNFREEZE_FLAGS} --unfreeze-all-beats"
|
| 42 |
+
elif [ "${UNFREEZE_TOP}" -gt 0 ]; then
|
| 43 |
+
UNFREEZE_FLAGS="${UNFREEZE_FLAGS} --unfreeze-top-layers ${UNFREEZE_TOP}"
|
| 44 |
+
fi
|
| 45 |
+
|
| 46 |
+
if [ ! -f "${BEATS_CKPT}" ]; then
|
| 47 |
+
echo "ERROR: BEATs ckpt not found: ${BEATS_CKPT}"
|
| 48 |
+
exit 1
|
| 49 |
+
fi
|
| 50 |
+
|
| 51 |
+
echo "============================================================"
|
| 52 |
+
echo " v13_F STAGE 1: BEATs trunk multi-label fine-tune"
|
| 53 |
+
echo " GPUs=${GPUS} BS=${BATCH_SIZE} LR=${LR} epochs=${NUM_EPOCHS}"
|
| 54 |
+
echo " channel=${CHANNEL_MODE} max_dur=${MAX_DURATION}s"
|
| 55 |
+
echo " unfreeze_flags: ${UNFREEZE_FLAGS:-classifier-only}"
|
| 56 |
+
echo " BEATs ckpt: ${BEATS_CKPT}"
|
| 57 |
+
echo " Output: ${OUT_DIR}"
|
| 58 |
+
echo "============================================================"
|
| 59 |
+
|
| 60 |
+
torchrun --nproc_per_node="${GPUS}" --master-port="${MASTER_PORT:-29580}" \
|
| 61 |
+
train_beats_multilabel_trunk.py \
|
| 62 |
+
--use-default-manifests \
|
| 63 |
+
--beats-checkpoint "${BEATS_CKPT}" \
|
| 64 |
+
--output-dir "${OUT_DIR}" \
|
| 65 |
+
--batch-size "${BATCH_SIZE}" \
|
| 66 |
+
--num-workers "${NUM_WORKERS}" \
|
| 67 |
+
--num-epochs "${NUM_EPOCHS}" \
|
| 68 |
+
--learning-rate "${LR}" \
|
| 69 |
+
--channel-mode "${CHANNEL_MODE}" \
|
| 70 |
+
--max-duration "${MAX_DURATION}" \
|
| 71 |
+
--best-metric mAP \
|
| 72 |
+
--ddp-find-unused-parameters \
|
| 73 |
+
${UNFREEZE_FLAGS}
|
| 74 |
+
|
| 75 |
+
echo "[v13_F stage1] Done. Best trunk saved in ${OUT_DIR}/best.pt"
|
spatial_atst.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Spatial-ATST: FOA spatial audio encoder using ATST-Frame as backbone.
|
| 2 |
+
|
| 3 |
+
Replaces the BEATs trunk in SpatialBEATs with ATST-Frame (FrameAST), which
|
| 4 |
+
produces per-frame tokens at 25 Hz (patch_w=4, hop=160, 100fps / 4 = 25fps).
|
| 5 |
+
This gives 4× better temporal resolution than BEATs (6.2 Hz) for tracking
|
| 6 |
+
moving sound sources in ov23 SELD tasks.
|
| 7 |
+
|
| 8 |
+
Architecture:
|
| 9 |
+
FOA [B, 4, T] (DCASE order [W,Y,Z,X])
|
| 10 |
+
↓ SpatialATSTPreprocessor
|
| 11 |
+
[B, 7, T_f, 64] (7ch: W/X/Y/Z logmel + IVx/IVy/IVz, 64 mel bins, 100Hz)
|
| 12 |
+
↓ W channel → ATSTEncoder (FrameAST)
|
| 13 |
+
semantic_embeddings [B, T_patches=250, 768] (25 Hz for 10s clip)
|
| 14 |
+
↓ LocalSpatialEncoder (CNN + attn on full 7ch FOA)
|
| 15 |
+
local_spatial_tokens [B, T_f=1000, 256] (100 Hz)
|
| 16 |
+
↓ TemporalResampler to target_token_rate (default 2.5 Hz)
|
| 17 |
+
fused_tokens [B, T_s, 768]
|
| 18 |
+
↓ LocalSpatialPredictionHeads
|
| 19 |
+
class_logits [B, C], direction [B, 3], distance [B, 1]
|
| 20 |
+
↓ SpatialTokenProjector
|
| 21 |
+
llm_spatial_tokens [B, T_s, d_llm]
|
| 22 |
+
|
| 23 |
+
Key differences from SpatialBEATs:
|
| 24 |
+
- No patch_embedding / spatial_patch_adapter — ATST has its own PatchEmbed_v2
|
| 25 |
+
- ATSTEncoder wraps FrameAST directly (no BEATs-style Conv patch embedding)
|
| 26 |
+
- ATST mel input: 64 bins (not 128), hop=160, Hann window same as BEATs
|
| 27 |
+
- Temporal tokens @ 25 Hz before resampling to target_token_rate
|
| 28 |
+
- No pretrunk_task_tokens / fixed_slot / mono_task_readout variants
|
| 29 |
+
(only local_spatial readout_scheme supported; extend later if needed)
|
| 30 |
+
- Reuses LocalSpatialEncoder, LocalSpatialPredictionHeads, TemporalResampler,
|
| 31 |
+
SpatialTokenProjector from spatial_modules.py unchanged
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
import os
|
| 35 |
+
import sys
|
| 36 |
+
from dataclasses import dataclass
|
| 37 |
+
from functools import partial
|
| 38 |
+
from typing import Dict, Optional, Tuple
|
| 39 |
+
|
| 40 |
+
import torch
|
| 41 |
+
import torch.distributed as dist
|
| 42 |
+
import torch.nn as nn
|
| 43 |
+
import torch.nn.functional as F
|
| 44 |
+
from torch import Tensor
|
| 45 |
+
from torch.nn import LayerNorm
|
| 46 |
+
from tqdm.auto import tqdm
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Import ATST-Frame components. Resolve path relative to this file so the
|
| 50 |
+
# import works regardless of the working directory.
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
_ATST_SED_ROOT = os.path.abspath(
|
| 53 |
+
os.path.join(os.path.dirname(__file__), "..", "ATST-SED")
|
| 54 |
+
)
|
| 55 |
+
if _ATST_SED_ROOT not in sys.path:
|
| 56 |
+
sys.path.insert(0, _ATST_SED_ROOT)
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
from desed_task.nnet.atst.audio_transformer import FrameASTModel, FrameAST # type: ignore
|
| 60 |
+
_ATST_AVAILABLE = True
|
| 61 |
+
except ImportError as _e:
|
| 62 |
+
_ATST_AVAILABLE = False
|
| 63 |
+
_ATST_IMPORT_ERROR = str(_e)
|
| 64 |
+
FrameASTModel = None # type: ignore
|
| 65 |
+
FrameAST = None # type: ignore
|
| 66 |
+
|
| 67 |
+
# Reuse spatial modules from SpatialBEATs unchanged
|
| 68 |
+
from spatial_modules import (
|
| 69 |
+
FrequencyPool,
|
| 70 |
+
LocalSpatialEncoder,
|
| 71 |
+
LocalSpatialPredictionHeads,
|
| 72 |
+
MonoTaskPredictionOutput,
|
| 73 |
+
ShallowTemporalReadout,
|
| 74 |
+
SpatialTokenProjector,
|
| 75 |
+
TemporalResampler,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Optional Kaldi fbank (same as SpatialBEATs)
|
| 79 |
+
try:
|
| 80 |
+
import torchaudio.compliance.kaldi as ta_kaldi # type: ignore
|
| 81 |
+
except ImportError:
|
| 82 |
+
ta_kaldi = None # type: ignore
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Config
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
@dataclass
|
| 90 |
+
class SpatialATSTConfig:
|
| 91 |
+
"""Configuration for the Spatial-ATST encoder.
|
| 92 |
+
|
| 93 |
+
Inherits the same spatial/prediction-head conventions as SpatialBEATsConfig
|
| 94 |
+
but targets the ATST-Frame backbone instead of BEATs.
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
# --- audio front-end ---
|
| 98 |
+
sample_rate: int = 16000
|
| 99 |
+
# ATST uses 64 mel bins (not 128) with narrower frequency range
|
| 100 |
+
num_mel_bins: int = 64
|
| 101 |
+
hop_length: int = 160 # 10ms per STFT frame → 100 Hz frame rate
|
| 102 |
+
win_length: int = 1024
|
| 103 |
+
n_fft: int = 1024
|
| 104 |
+
fbank_mean: float = -4.2677393 # ATST normalisation (log-mel, no kaldi scale)
|
| 105 |
+
fbank_std: float = 4.5689974
|
| 106 |
+
normalize_logmel: bool = True
|
| 107 |
+
waveform_scale: float = 1.0 # ATST does NOT multiply by 2^15
|
| 108 |
+
dither: float = 0.0
|
| 109 |
+
padding_value: float = 0.0
|
| 110 |
+
foa_feature_channels: int = 7 # W/X/Y/Z logmel + IVx/IVy/IVz
|
| 111 |
+
|
| 112 |
+
# --- ATST trunk ---
|
| 113 |
+
atst_checkpoint_path: str = (
|
| 114 |
+
"/apdcephfs_cq10/share_1603164/user/schmittzhu/code/ATST-SED"
|
| 115 |
+
"/desed_task/nnet/ckpts/atst_as2M.ckpt"
|
| 116 |
+
)
|
| 117 |
+
atst_embed_dim: int = 768
|
| 118 |
+
atst_patch_h: int = 64 # full freq dimension
|
| 119 |
+
atst_patch_w: int = 4 # 4 STFT frames per patch → 25 Hz
|
| 120 |
+
atst_dropout: float = 0.0
|
| 121 |
+
|
| 122 |
+
# --- SpecAugment on W channel (training only) ---
|
| 123 |
+
spec_augment_freq_masks: int = 0
|
| 124 |
+
spec_augment_freq_width: int = 0
|
| 125 |
+
spec_augment_time_masks: int = 0
|
| 126 |
+
spec_augment_time_width: int = 0
|
| 127 |
+
|
| 128 |
+
# --- local spatial CNN branch ---
|
| 129 |
+
local_spatial_dim: int = 256
|
| 130 |
+
local_spatial_layers: int = 2
|
| 131 |
+
local_spatial_heads: int = 4
|
| 132 |
+
local_spatial_dropout: float = 0.1
|
| 133 |
+
local_spatial_proj_scale_init: float = 0.05
|
| 134 |
+
|
| 135 |
+
# --- bypass / purify options (same semantics as SpatialBEATs) ---
|
| 136 |
+
bypass_local_fusion: bool = False
|
| 137 |
+
|
| 138 |
+
# --- temporal tokenisation ---
|
| 139 |
+
target_token_rate: float = 2.5
|
| 140 |
+
readout_layers: int = 1
|
| 141 |
+
|
| 142 |
+
# --- prediction heads ---
|
| 143 |
+
head_dropout: float = 0.0
|
| 144 |
+
use_semantic_anchor: bool = False
|
| 145 |
+
source_num_classes: int = 63
|
| 146 |
+
source_vocab_path: str = (
|
| 147 |
+
"/apdcephfs_cq12/share_302080740/user/schmittzhu/data/fsd50k/"
|
| 148 |
+
"FSD50K.ground_truth/final_vocabulary.csv"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# --- LLM projection ---
|
| 152 |
+
llm_hidden_dim: int = 4096
|
| 153 |
+
projector_hidden_dim: int = 768
|
| 154 |
+
|
| 155 |
+
def update(self, cfg: Dict) -> None:
|
| 156 |
+
self.__dict__.update(cfg)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
# Output dataclass
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
|
| 163 |
+
@dataclass
|
| 164 |
+
class SpatialATSTOutput:
|
| 165 |
+
"""Structured forward-pass outputs from Spatial-ATST.
|
| 166 |
+
|
| 167 |
+
Mirrors SpatialBEATsOutput for compatibility with the training loop,
|
| 168 |
+
but omits fields that do not apply to the ATST path
|
| 169 |
+
(no patch_tokens / grid_size / delta_patch_tokens / pretrunk_* / slot_*).
|
| 170 |
+
"""
|
| 171 |
+
|
| 172 |
+
foa_feat: Tensor
|
| 173 |
+
"""[B, 7, T_f, 64] multi-channel FOA spatial feature map."""
|
| 174 |
+
|
| 175 |
+
w_feat: Tensor
|
| 176 |
+
"""[B, 1, T_f, 64] W-channel log-mel fed into ATST."""
|
| 177 |
+
|
| 178 |
+
encoder_memory: Tensor
|
| 179 |
+
"""[B, T_atst, 768] ATST trunk output (25 Hz, 250 tokens for 10s)."""
|
| 180 |
+
|
| 181 |
+
temporal_tokens: Tensor
|
| 182 |
+
"""[B, T_s_max, 768] after resampling encoder_memory to target_token_rate."""
|
| 183 |
+
|
| 184 |
+
spatial_embeddings: Tensor
|
| 185 |
+
"""[B, T_s_max, 768] after ShallowTemporalReadout (= fused when local_spatial)."""
|
| 186 |
+
|
| 187 |
+
local_spatial_tokens: Optional[Tensor]
|
| 188 |
+
"""[B, T_s_max, D_s] local spatial branch output (None if bypass)."""
|
| 189 |
+
|
| 190 |
+
fused_spatial_embeddings: Optional[Tensor]
|
| 191 |
+
"""[B, T_s_max, 768] semantic + local spatial fused (None if not local_spatial)."""
|
| 192 |
+
|
| 193 |
+
mono_task_tokens: Optional[Tensor]
|
| 194 |
+
"""[B, 2, 768] attention-pooled class / spatial task tokens."""
|
| 195 |
+
|
| 196 |
+
mono_prediction_output: Optional[MonoTaskPredictionOutput]
|
| 197 |
+
"""Single-source class / direction / distance predictions."""
|
| 198 |
+
|
| 199 |
+
llm_spatial_tokens: Tensor
|
| 200 |
+
"""[B, T_s_max, d_llm] projected tokens for the LLM."""
|
| 201 |
+
|
| 202 |
+
temporal_padding_mask: Optional[Tensor]
|
| 203 |
+
"""[B, T_s_max] True = padded step."""
|
| 204 |
+
|
| 205 |
+
target_num_steps: Optional[Tensor]
|
| 206 |
+
"""[B] valid temporal lengths before padding."""
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ---------------------------------------------------------------------------
|
| 210 |
+
# Preprocessor
|
| 211 |
+
# ---------------------------------------------------------------------------
|
| 212 |
+
|
| 213 |
+
class SpatialATSTPreprocessor(nn.Module):
|
| 214 |
+
"""Convert FOA waveform to ATST-compatible 7-channel spatial feature maps.
|
| 215 |
+
|
| 216 |
+
ATST uses 64 mel bins (not 128) with no waveform scaling (amplitude
|
| 217 |
+
in [-1, 1]). The normalisation statistics differ from BEATs/Kaldi.
|
| 218 |
+
|
| 219 |
+
Channel layout (same as SpatialBEATsPreprocessor):
|
| 220 |
+
0: W_logmel
|
| 221 |
+
1: X_logmel
|
| 222 |
+
2: Y_logmel
|
| 223 |
+
3: Z_logmel
|
| 224 |
+
4: IVx
|
| 225 |
+
5: IVy
|
| 226 |
+
6: IVz
|
| 227 |
+
|
| 228 |
+
Input FOA order: DCASE [W, Y, Z, X] → reordered to [W, X, Y, Z] internally.
|
| 229 |
+
"""
|
| 230 |
+
|
| 231 |
+
def __init__(
|
| 232 |
+
self,
|
| 233 |
+
sample_rate: int = 16000,
|
| 234 |
+
num_mel_bins: int = 64,
|
| 235 |
+
n_fft: int = 1024,
|
| 236 |
+
hop_length: int = 160,
|
| 237 |
+
win_length: int = 1024,
|
| 238 |
+
fbank_mean: float = -4.2677393,
|
| 239 |
+
fbank_std: float = 4.5689974,
|
| 240 |
+
normalize_logmel: bool = True,
|
| 241 |
+
waveform_scale: float = 1.0,
|
| 242 |
+
dither: float = 0.0,
|
| 243 |
+
spec_augment_freq_masks: int = 0,
|
| 244 |
+
spec_augment_freq_width: int = 0,
|
| 245 |
+
spec_augment_time_masks: int = 0,
|
| 246 |
+
spec_augment_time_width: int = 0,
|
| 247 |
+
) -> None:
|
| 248 |
+
super().__init__()
|
| 249 |
+
self.sample_rate = sample_rate
|
| 250 |
+
self.num_mel_bins = num_mel_bins
|
| 251 |
+
self.n_fft = n_fft
|
| 252 |
+
self.hop_length = hop_length
|
| 253 |
+
self.win_length = win_length
|
| 254 |
+
self.fbank_mean = float(fbank_mean)
|
| 255 |
+
self.fbank_std = float(fbank_std)
|
| 256 |
+
self.normalize_logmel = bool(normalize_logmel)
|
| 257 |
+
self.waveform_scale = float(waveform_scale)
|
| 258 |
+
self.dither = float(dither)
|
| 259 |
+
self.spec_augment_freq_masks = spec_augment_freq_masks
|
| 260 |
+
self.spec_augment_freq_width = spec_augment_freq_width
|
| 261 |
+
self.spec_augment_time_masks = spec_augment_time_masks
|
| 262 |
+
self.spec_augment_time_width = spec_augment_time_width
|
| 263 |
+
|
| 264 |
+
self.register_buffer("window", torch.hann_window(win_length), persistent=False)
|
| 265 |
+
self.register_buffer(
|
| 266 |
+
"mel_filterbank",
|
| 267 |
+
self._build_mel_filterbank(sample_rate, n_fft, num_mel_bins),
|
| 268 |
+
persistent=False,
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
@staticmethod
|
| 272 |
+
def _reorder_dcase_wyzx_to_wxyz(waveform: Tensor) -> Tensor:
|
| 273 |
+
return waveform[:, [0, 3, 1, 2], :]
|
| 274 |
+
|
| 275 |
+
@staticmethod
|
| 276 |
+
def _hz_to_mel(f: Tensor) -> Tensor:
|
| 277 |
+
return 2595.0 * torch.log10(1.0 + f / 700.0)
|
| 278 |
+
|
| 279 |
+
@staticmethod
|
| 280 |
+
def _mel_to_hz(m: Tensor) -> Tensor:
|
| 281 |
+
return 700.0 * (10.0 ** (m / 2595.0) - 1.0)
|
| 282 |
+
|
| 283 |
+
def _build_mel_filterbank(self, sample_rate: int, n_fft: int, num_mel_bins: int) -> Tensor:
|
| 284 |
+
num_freqs = n_fft // 2 + 1
|
| 285 |
+
min_mel = self._hz_to_mel(torch.tensor(0.0))
|
| 286 |
+
max_mel = self._hz_to_mel(torch.tensor(float(sample_rate) / 2.0))
|
| 287 |
+
mel_points = torch.linspace(min_mel, max_mel, num_mel_bins + 2)
|
| 288 |
+
hz_points = self._mel_to_hz(mel_points)
|
| 289 |
+
fft_freqs = torch.linspace(0.0, float(sample_rate) / 2.0, num_freqs)
|
| 290 |
+
fbanks = torch.zeros(num_freqs, num_mel_bins)
|
| 291 |
+
for i in range(num_mel_bins):
|
| 292 |
+
left, center, right = hz_points[i], hz_points[i + 1], hz_points[i + 2]
|
| 293 |
+
up = (fft_freqs - left) / (center - left + 1e-8)
|
| 294 |
+
down = (right - fft_freqs) / (right - center + 1e-8)
|
| 295 |
+
fbanks[:, i] = torch.clamp(torch.minimum(up, down), min=0.0)
|
| 296 |
+
return fbanks
|
| 297 |
+
|
| 298 |
+
def _compute_channel_logmel(self, waveform: Tensor) -> Tuple[Tensor, Tensor]:
|
| 299 |
+
"""Compute log-mel spectrogram for all 4 FOA channels simultaneously."""
|
| 300 |
+
B, C, T = waveform.shape
|
| 301 |
+
x = waveform.reshape(B * C, T) * self.waveform_scale
|
| 302 |
+
if self.dither > 0.0 and self.training:
|
| 303 |
+
x = x + self.dither * torch.randn_like(x)
|
| 304 |
+
window = self.window.to(dtype=x.dtype, device=x.device)
|
| 305 |
+
stft = torch.stft(
|
| 306 |
+
x,
|
| 307 |
+
n_fft=self.n_fft,
|
| 308 |
+
hop_length=self.hop_length,
|
| 309 |
+
win_length=self.win_length,
|
| 310 |
+
window=window,
|
| 311 |
+
center=True,
|
| 312 |
+
pad_mode="reflect",
|
| 313 |
+
return_complex=True,
|
| 314 |
+
) # [B*C, F_bins, T_f]
|
| 315 |
+
power = stft.abs().pow(2.0)
|
| 316 |
+
mel_fb = self.mel_filterbank.to(dtype=power.dtype, device=power.device)
|
| 317 |
+
mel = torch.matmul(power.transpose(1, 2), mel_fb).transpose(1, 2)
|
| 318 |
+
logmel = torch.log(torch.clamp(mel, min=1e-10))
|
| 319 |
+
if self.normalize_logmel:
|
| 320 |
+
logmel = (logmel - self.fbank_mean) / (2.0 * self.fbank_std)
|
| 321 |
+
# [B, C, num_mel_bins, T_f] → [B, C, T_f, num_mel_bins]
|
| 322 |
+
logmel = logmel.reshape(B, C, self.num_mel_bins, -1).transpose(2, 3)
|
| 323 |
+
return logmel, stft.reshape(B, C, stft.shape[-2], stft.shape[-1])
|
| 324 |
+
|
| 325 |
+
def _compute_intensity_features(self, stft: Tensor) -> Tensor:
|
| 326 |
+
"""Mel-band intensity vector from FOA STFT: [B, 3, T_f, num_mel_bins]."""
|
| 327 |
+
B = stft.shape[0]
|
| 328 |
+
w = stft[:, 0] # [B, F_bins, T_f]
|
| 329 |
+
xyz = stft[:, 1:] # [B, 3, F_bins, T_f]
|
| 330 |
+
iv = torch.real(w.unsqueeze(1) * torch.conj(xyz)) # [B, 3, F_bins, T_f]
|
| 331 |
+
mel_fb = self.mel_filterbank.to(dtype=iv.dtype, device=iv.device)
|
| 332 |
+
iv = torch.matmul(iv.transpose(-2, -1), mel_fb).transpose(-2, -1) # [B, 3, T_f, mel]
|
| 333 |
+
denom = iv.abs().amax(dim=-1, keepdim=True).clamp_min(1e-6)
|
| 334 |
+
return iv / denom
|
| 335 |
+
|
| 336 |
+
def _apply_spec_augment_w(self, w_logmel: Tensor) -> Tensor:
|
| 337 |
+
"""SpecAugment on [B, 1, T_f, F] W channel (training only)."""
|
| 338 |
+
if not self.training:
|
| 339 |
+
return w_logmel
|
| 340 |
+
x = w_logmel.clone()
|
| 341 |
+
_, _, T, F = x.shape
|
| 342 |
+
for _ in range(self.spec_augment_freq_masks):
|
| 343 |
+
f = torch.randint(0, max(1, self.spec_augment_freq_width + 1), ()).item()
|
| 344 |
+
f0 = torch.randint(0, max(1, F - f), ()).item()
|
| 345 |
+
x[:, :, :, f0: f0 + f] = 0.0
|
| 346 |
+
for _ in range(self.spec_augment_time_masks):
|
| 347 |
+
t = torch.randint(0, max(1, self.spec_augment_time_width + 1), ()).item()
|
| 348 |
+
t0 = torch.randint(0, max(1, T - t), ()).item()
|
| 349 |
+
x[:, :, t0: t0 + t, :] = 0.0
|
| 350 |
+
return x
|
| 351 |
+
|
| 352 |
+
def forward(self, waveform: Tensor) -> Tensor:
|
| 353 |
+
"""Build 7-channel FOA spatial features.
|
| 354 |
+
|
| 355 |
+
Args:
|
| 356 |
+
waveform: [B, 4, T] FOA waveform in DCASE order [W, Y, Z, X].
|
| 357 |
+
|
| 358 |
+
Returns:
|
| 359 |
+
[B, 7, T_f, num_mel_bins] feature map at 100 Hz.
|
| 360 |
+
"""
|
| 361 |
+
if waveform.ndim != 3 or waveform.size(1) != 4:
|
| 362 |
+
raise ValueError(f"Expected [B, 4, T], got {tuple(waveform.shape)}")
|
| 363 |
+
waveform = self._reorder_dcase_wyzx_to_wxyz(waveform)
|
| 364 |
+
logmel, stft = self._compute_channel_logmel(waveform)
|
| 365 |
+
iv = self._compute_intensity_features(stft)
|
| 366 |
+
|
| 367 |
+
w_channel = logmel[:, 0:1] # [B, 1, T_f, F]
|
| 368 |
+
if self.spec_augment_freq_masks > 0 or self.spec_augment_time_masks > 0:
|
| 369 |
+
w_channel = self._apply_spec_augment_w(w_channel)
|
| 370 |
+
|
| 371 |
+
foa_feat = torch.cat(
|
| 372 |
+
[w_channel, logmel[:, 1:2], logmel[:, 2:3], logmel[:, 3:4],
|
| 373 |
+
iv[:, 0:1], iv[:, 1:2], iv[:, 2:3]],
|
| 374 |
+
dim=1,
|
| 375 |
+
)
|
| 376 |
+
return foa_feat.contiguous()
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ---------------------------------------------------------------------------
|
| 380 |
+
# ATST encoder wrapper
|
| 381 |
+
# ---------------------------------------------------------------------------
|
| 382 |
+
|
| 383 |
+
class ATSTEncoder(nn.Module):
|
| 384 |
+
"""Thin wrapper around FrameAST that handles checkpoint loading.
|
| 385 |
+
|
| 386 |
+
Exposes ``encode(w_feat)`` which returns per-frame token embeddings
|
| 387 |
+
at 25 Hz (for 10-second, 64-bin, hop=160 input).
|
| 388 |
+
|
| 389 |
+
Args:
|
| 390 |
+
checkpoint_path: Path to atst_as2M.ckpt (or compatible format).
|
| 391 |
+
atst_dropout: Drop-path / drop rate in FrameAST.
|
| 392 |
+
embed_dim: FrameAST embedding dimension (always 768).
|
| 393 |
+
"""
|
| 394 |
+
|
| 395 |
+
FAKE_LENGTH: int = 1001 # matches ATST-SED training spec_w
|
| 396 |
+
|
| 397 |
+
def __init__(
|
| 398 |
+
self,
|
| 399 |
+
checkpoint_path: str,
|
| 400 |
+
atst_dropout: float = 0.0,
|
| 401 |
+
embed_dim: int = 768,
|
| 402 |
+
) -> None:
|
| 403 |
+
super().__init__()
|
| 404 |
+
if not _ATST_AVAILABLE:
|
| 405 |
+
raise ImportError(
|
| 406 |
+
f"Cannot import ATST-Frame from {_ATST_SED_ROOT}: {_ATST_IMPORT_ERROR}\n"
|
| 407 |
+
"Ensure ATST-SED is installed or the path is correct."
|
| 408 |
+
)
|
| 409 |
+
self.embed_dim = embed_dim
|
| 410 |
+
self.atst: FrameAST = FrameASTModel(atst_dropout=atst_dropout)
|
| 411 |
+
self._load_checkpoint(checkpoint_path)
|
| 412 |
+
|
| 413 |
+
def _load_checkpoint(self, path: str) -> None:
|
| 414 |
+
is_main = (not dist.is_available()) or (not dist.is_initialized()) or dist.get_rank() == 0
|
| 415 |
+
if is_main:
|
| 416 |
+
tqdm.write(f"[SpatialATST] Loading ATST checkpoint from {path}")
|
| 417 |
+
|
| 418 |
+
state_dict = torch.load(path, map_location="cpu")["state_dict"]
|
| 419 |
+
atst_sd: Dict[str, Tensor] = {}
|
| 420 |
+
for k, v in state_dict.items():
|
| 421 |
+
if "model.teacher.encoder." in k:
|
| 422 |
+
if "encoder.norm." in k:
|
| 423 |
+
new_k = k.replace("model.teacher.encoder.norm", "norm_frame")
|
| 424 |
+
elif "cls_token" in k:
|
| 425 |
+
continue
|
| 426 |
+
else:
|
| 427 |
+
new_k = k.replace("model.teacher.encoder.", "")
|
| 428 |
+
atst_sd[new_k] = v
|
| 429 |
+
elif "encoder.encoder.frame_encoder." in k:
|
| 430 |
+
new_k = k.replace("encoder.encoder.frame_encoder.", "")
|
| 431 |
+
atst_sd[new_k] = v
|
| 432 |
+
elif "encoder.encoder." in k:
|
| 433 |
+
new_k = k.replace("encoder.encoder.", "")
|
| 434 |
+
atst_sd[new_k] = v
|
| 435 |
+
|
| 436 |
+
missing, unexpected = self.atst.load_state_dict(atst_sd, strict=True)
|
| 437 |
+
if is_main:
|
| 438 |
+
tqdm.write(
|
| 439 |
+
f"[SpatialATST] ATST loaded: missing={len(missing)} unexpected={len(unexpected)}"
|
| 440 |
+
)
|
| 441 |
+
# Freeze by default — caller can unfreeze selected layers later
|
| 442 |
+
for param in self.atst.parameters():
|
| 443 |
+
param.requires_grad = False
|
| 444 |
+
|
| 445 |
+
def encode(self, w_feat: Tensor) -> Tensor:
|
| 446 |
+
"""Extract per-frame ATST embeddings from W-channel log-mel.
|
| 447 |
+
|
| 448 |
+
Args:
|
| 449 |
+
w_feat: [B, 1, T_f, num_mel_bins] W-channel log-mel feature map.
|
| 450 |
+
Expected T_f ≈ 1000 (10s × 100Hz), num_mel_bins = 64.
|
| 451 |
+
|
| 452 |
+
Returns:
|
| 453 |
+
[B, T_patches, 768] frame-level embeddings at 25 Hz.
|
| 454 |
+
For a 10-second clip: T_patches = T_f // 4 = 250.
|
| 455 |
+
"""
|
| 456 |
+
# FrameAST expects [B, 1, num_mel_bins, T_f] — freq first, then time
|
| 457 |
+
# our w_feat is [B, 1, T_f, F] — need to transpose last two dims
|
| 458 |
+
x = w_feat.transpose(2, 3) # → [B, 1, F=64, T_f]
|
| 459 |
+
B = x.size(0)
|
| 460 |
+
fake_length = torch.full(
|
| 461 |
+
(B,), self.FAKE_LENGTH, dtype=x.dtype, device=x.device
|
| 462 |
+
)
|
| 463 |
+
out = self.atst.get_intermediate_layers(
|
| 464 |
+
x, fake_length, n=1, scene=False
|
| 465 |
+
) # [B, T_patches, 768]
|
| 466 |
+
return out
|
| 467 |
+
|
| 468 |
+
def forward(self, w_feat: Tensor) -> Tensor:
|
| 469 |
+
return self.encode(w_feat)
|
| 470 |
+
|
| 471 |
+
def named_trunk_layers(self):
|
| 472 |
+
"""Yield (name, module) for ATST transformer blocks (for unfreezing)."""
|
| 473 |
+
for name, module in self.atst.named_children():
|
| 474 |
+
yield name, module
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
# ---------------------------------------------------------------------------
|
| 478 |
+
# Main model
|
| 479 |
+
# ---------------------------------------------------------------------------
|
| 480 |
+
|
| 481 |
+
class SpatialATST(nn.Module):
|
| 482 |
+
"""Spatial-ATST encoder.
|
| 483 |
+
|
| 484 |
+
Uses ATST-Frame as the semantic backbone instead of BEATs.
|
| 485 |
+
|
| 486 |
+
Shape flow for a 10-second FOA clip:
|
| 487 |
+
waveform: [B, 4, 160000]
|
| 488 |
+
foa_feat: [B, 7, 1000, 64] (100 Hz)
|
| 489 |
+
w_feat: [B, 1, 1000, 64]
|
| 490 |
+
encoder_memory: [B, 250, 768] (25 Hz, ATST patches)
|
| 491 |
+
temporal_tokens: [B, T_s, 768] (2.5 Hz after resampling)
|
| 492 |
+
local_spatial_tokens: [B, T_s, 256]
|
| 493 |
+
fused_tokens: [B, T_s, 768]
|
| 494 |
+
llm_spatial_tokens: [B, T_s, d_llm]
|
| 495 |
+
"""
|
| 496 |
+
|
| 497 |
+
def __init__(self, cfg: SpatialATSTConfig) -> None:
|
| 498 |
+
super().__init__()
|
| 499 |
+
self.cfg = cfg
|
| 500 |
+
|
| 501 |
+
# --- front-end ---
|
| 502 |
+
self.preprocessor = SpatialATSTPreprocessor(
|
| 503 |
+
sample_rate=cfg.sample_rate,
|
| 504 |
+
num_mel_bins=cfg.num_mel_bins,
|
| 505 |
+
n_fft=cfg.n_fft,
|
| 506 |
+
hop_length=cfg.hop_length,
|
| 507 |
+
win_length=cfg.win_length,
|
| 508 |
+
fbank_mean=cfg.fbank_mean,
|
| 509 |
+
fbank_std=cfg.fbank_std,
|
| 510 |
+
normalize_logmel=cfg.normalize_logmel,
|
| 511 |
+
waveform_scale=cfg.waveform_scale,
|
| 512 |
+
dither=cfg.dither,
|
| 513 |
+
spec_augment_freq_masks=cfg.spec_augment_freq_masks,
|
| 514 |
+
spec_augment_freq_width=cfg.spec_augment_freq_width,
|
| 515 |
+
spec_augment_time_masks=cfg.spec_augment_time_masks,
|
| 516 |
+
spec_augment_time_width=cfg.spec_augment_time_width,
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
# --- ATST trunk ---
|
| 520 |
+
self.atst_encoder = ATSTEncoder(
|
| 521 |
+
checkpoint_path=cfg.atst_checkpoint_path,
|
| 522 |
+
atst_dropout=cfg.atst_dropout,
|
| 523 |
+
embed_dim=cfg.atst_embed_dim,
|
| 524 |
+
)
|
| 525 |
+
D = cfg.atst_embed_dim # 768
|
| 526 |
+
|
| 527 |
+
# --- temporal neck (resample 25Hz → target_token_rate=2.5Hz) ---
|
| 528 |
+
self.temporal_resampler = TemporalResampler(
|
| 529 |
+
target_token_rate=cfg.target_token_rate,
|
| 530 |
+
mode="linear",
|
| 531 |
+
)
|
| 532 |
+
self.temporal_readout = ShallowTemporalReadout(
|
| 533 |
+
embed_dim=D,
|
| 534 |
+
num_layers=cfg.readout_layers,
|
| 535 |
+
num_heads=12,
|
| 536 |
+
dropout=0.1,
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
# --- local spatial CNN branch ---
|
| 540 |
+
self.local_spatial_encoder = LocalSpatialEncoder(
|
| 541 |
+
in_channels=cfg.foa_feature_channels,
|
| 542 |
+
hidden_dim=cfg.local_spatial_dim,
|
| 543 |
+
num_layers=cfg.local_spatial_layers,
|
| 544 |
+
num_heads=cfg.local_spatial_heads,
|
| 545 |
+
dropout=cfg.local_spatial_dropout,
|
| 546 |
+
)
|
| 547 |
+
self.local_spatial_resampler = TemporalResampler(
|
| 548 |
+
target_token_rate=cfg.target_token_rate,
|
| 549 |
+
mode="linear",
|
| 550 |
+
)
|
| 551 |
+
self.local_spatial_proj = nn.Linear(cfg.local_spatial_dim, D)
|
| 552 |
+
nn.init.xavier_uniform_(self.local_spatial_proj.weight)
|
| 553 |
+
self.local_spatial_proj.weight.data.mul_(cfg.local_spatial_proj_scale_init)
|
| 554 |
+
nn.init.zeros_(self.local_spatial_proj.bias)
|
| 555 |
+
|
| 556 |
+
self.local_spatial_fusion_norm = nn.LayerNorm(D)
|
| 557 |
+
|
| 558 |
+
# --- prediction heads ---
|
| 559 |
+
self.local_spatial_prediction_heads = LocalSpatialPredictionHeads(
|
| 560 |
+
embed_dim=D,
|
| 561 |
+
num_classes=cfg.source_num_classes,
|
| 562 |
+
head_dropout=cfg.head_dropout,
|
| 563 |
+
use_semantic_anchor=cfg.use_semantic_anchor,
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
# --- LLM projector ---
|
| 567 |
+
self.projector = SpatialTokenProjector(
|
| 568 |
+
input_dim=D,
|
| 569 |
+
llm_hidden_dim=cfg.llm_hidden_dim,
|
| 570 |
+
hidden_dim=cfg.projector_hidden_dim,
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
# ------------------------------------------------------------------
|
| 574 |
+
# Helpers
|
| 575 |
+
# ------------------------------------------------------------------
|
| 576 |
+
|
| 577 |
+
def _infer_clip_duration_seconds(
|
| 578 |
+
self,
|
| 579 |
+
waveform: Tensor,
|
| 580 |
+
clip_duration_seconds: Optional[Tensor] = None,
|
| 581 |
+
) -> Tensor:
|
| 582 |
+
if clip_duration_seconds is not None:
|
| 583 |
+
return clip_duration_seconds.to(device=waveform.device, dtype=waveform.dtype)
|
| 584 |
+
return torch.full(
|
| 585 |
+
(waveform.size(0),),
|
| 586 |
+
float(waveform.size(-1)) / float(self.cfg.sample_rate),
|
| 587 |
+
device=waveform.device,
|
| 588 |
+
dtype=waveform.dtype,
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
def compute_target_num_steps(
|
| 592 |
+
self,
|
| 593 |
+
waveform: Tensor,
|
| 594 |
+
clip_duration_seconds: Optional[Tensor] = None,
|
| 595 |
+
) -> Tensor:
|
| 596 |
+
duration = self._infer_clip_duration_seconds(waveform, clip_duration_seconds)
|
| 597 |
+
steps = torch.round(duration * self.cfg.target_token_rate).long()
|
| 598 |
+
return torch.clamp(steps, min=1)
|
| 599 |
+
|
| 600 |
+
# ------------------------------------------------------------------
|
| 601 |
+
# Forward
|
| 602 |
+
# ------------------------------------------------------------------
|
| 603 |
+
|
| 604 |
+
def forward(
|
| 605 |
+
self,
|
| 606 |
+
waveform: Tensor,
|
| 607 |
+
padding_mask: Optional[Tensor] = None,
|
| 608 |
+
clip_duration_seconds: Optional[Tensor] = None,
|
| 609 |
+
mono_window_mask: Optional[Tensor] = None,
|
| 610 |
+
) -> SpatialATSTOutput:
|
| 611 |
+
"""Forward pass.
|
| 612 |
+
|
| 613 |
+
Args:
|
| 614 |
+
waveform: [B, 4, T] FOA waveform in DCASE order [W, Y, Z, X].
|
| 615 |
+
padding_mask: optional, currently unused (kept for API compat).
|
| 616 |
+
clip_duration_seconds: [B] optional clip durations.
|
| 617 |
+
mono_window_mask: [B, T_s] optional weak active-time mask.
|
| 618 |
+
|
| 619 |
+
Returns:
|
| 620 |
+
SpatialATSTOutput with all intermediate and final tensors.
|
| 621 |
+
"""
|
| 622 |
+
duration = self._infer_clip_duration_seconds(waveform, clip_duration_seconds)
|
| 623 |
+
target_num_steps = compute_target_num_steps_from_duration(
|
| 624 |
+
duration, self.cfg.target_token_rate
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
# --- 1. FOA preprocessing → [B, 7, T_f, 64] ---
|
| 628 |
+
foa_feat = self.preprocessor(waveform)
|
| 629 |
+
w_feat = foa_feat[:, 0:1] # [B, 1, T_f, 64]
|
| 630 |
+
|
| 631 |
+
# --- 2. ATST trunk → [B, T_atst, 768] (25 Hz) ---
|
| 632 |
+
encoder_memory = self.atst_encoder.encode(w_feat) # [B, T_atst, 768]
|
| 633 |
+
|
| 634 |
+
# --- 3. Resample ATST tokens to target_token_rate (2.5 Hz) ---
|
| 635 |
+
temporal_tokens, temporal_padding_mask = self.temporal_resampler(
|
| 636 |
+
encoder_memory, target_num_steps=target_num_steps
|
| 637 |
+
)
|
| 638 |
+
semantic_embeddings = self.temporal_readout(
|
| 639 |
+
temporal_tokens, padding_mask=temporal_padding_mask
|
| 640 |
+
) # [B, T_s, 768]
|
| 641 |
+
|
| 642 |
+
# --- 4. Local spatial fusion ---
|
| 643 |
+
if self.cfg.bypass_local_fusion:
|
| 644 |
+
fused_embeddings = self.local_spatial_fusion_norm(semantic_embeddings)
|
| 645 |
+
local_spatial_tokens = torch.zeros(
|
| 646 |
+
semantic_embeddings.size(0),
|
| 647 |
+
semantic_embeddings.size(1),
|
| 648 |
+
self.cfg.local_spatial_dim,
|
| 649 |
+
device=semantic_embeddings.device,
|
| 650 |
+
dtype=semantic_embeddings.dtype,
|
| 651 |
+
)
|
| 652 |
+
effective_padding_mask = temporal_padding_mask
|
| 653 |
+
else:
|
| 654 |
+
local_patch_tokens = self.local_spatial_encoder(foa_feat) # [B, T_f, D_s]
|
| 655 |
+
local_spatial_tokens, local_padding_mask = self.local_spatial_resampler(
|
| 656 |
+
local_patch_tokens, target_num_steps=target_num_steps
|
| 657 |
+
)
|
| 658 |
+
if local_spatial_tokens.shape[:2] != semantic_embeddings.shape[:2]:
|
| 659 |
+
raise ValueError(
|
| 660 |
+
f"Local spatial shape mismatch: "
|
| 661 |
+
f"{tuple(local_spatial_tokens.shape[:2])} vs "
|
| 662 |
+
f"{tuple(semantic_embeddings.shape[:2])}"
|
| 663 |
+
)
|
| 664 |
+
local_update = self.local_spatial_proj(local_spatial_tokens)
|
| 665 |
+
fused_embeddings = self.local_spatial_fusion_norm(
|
| 666 |
+
semantic_embeddings + local_update
|
| 667 |
+
)
|
| 668 |
+
effective_padding_mask = temporal_padding_mask
|
| 669 |
+
if effective_padding_mask is None:
|
| 670 |
+
effective_padding_mask = local_padding_mask
|
| 671 |
+
|
| 672 |
+
# --- 5. Prediction heads ---
|
| 673 |
+
mono_task_tokens, mono_prediction_output = self.local_spatial_prediction_heads(
|
| 674 |
+
fused_tokens=fused_embeddings,
|
| 675 |
+
padding_mask=effective_padding_mask,
|
| 676 |
+
active_window_mask=mono_window_mask,
|
| 677 |
+
semantic_tokens=semantic_embeddings,
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
# --- 6. LLM projection ---
|
| 681 |
+
llm_spatial_tokens = self.projector(fused_embeddings)
|
| 682 |
+
|
| 683 |
+
return SpatialATSTOutput(
|
| 684 |
+
foa_feat=foa_feat,
|
| 685 |
+
w_feat=w_feat,
|
| 686 |
+
encoder_memory=encoder_memory,
|
| 687 |
+
temporal_tokens=temporal_tokens,
|
| 688 |
+
spatial_embeddings=fused_embeddings,
|
| 689 |
+
local_spatial_tokens=local_spatial_tokens,
|
| 690 |
+
fused_spatial_embeddings=fused_embeddings,
|
| 691 |
+
mono_task_tokens=mono_task_tokens,
|
| 692 |
+
mono_prediction_output=mono_prediction_output,
|
| 693 |
+
llm_spatial_tokens=llm_spatial_tokens,
|
| 694 |
+
temporal_padding_mask=effective_padding_mask,
|
| 695 |
+
target_num_steps=target_num_steps,
|
| 696 |
+
)
|
| 697 |
+
|
| 698 |
+
def extract_features(
|
| 699 |
+
self,
|
| 700 |
+
waveform: Tensor,
|
| 701 |
+
padding_mask: Optional[Tensor] = None,
|
| 702 |
+
clip_duration_seconds: Optional[Tensor] = None,
|
| 703 |
+
) -> Tuple[Tensor, Optional[Tensor]]:
|
| 704 |
+
"""Compatibility entry point matching SpatialBEATs.extract_features."""
|
| 705 |
+
out = self.forward(waveform, padding_mask, clip_duration_seconds)
|
| 706 |
+
return out.spatial_embeddings, out.temporal_padding_mask
|
| 707 |
+
|
| 708 |
+
def load_event_classifier_checkpoint(
|
| 709 |
+
self,
|
| 710 |
+
checkpoint_path: str,
|
| 711 |
+
map_location: str = "cpu",
|
| 712 |
+
) -> None:
|
| 713 |
+
"""Load class head weights from a W-channel BEATs event-classifier ckpt.
|
| 714 |
+
|
| 715 |
+
The classifier head (Linear(768, 65)) trained on the BEATs event
|
| 716 |
+
classifier is reused to initialise the ATST class head. The trunk
|
| 717 |
+
weights are intentionally NOT loaded — ATST and BEATs are different
|
| 718 |
+
architectures.
|
| 719 |
+
"""
|
| 720 |
+
is_main = (not dist.is_available()) or (not dist.is_initialized()) or dist.get_rank() == 0
|
| 721 |
+
if is_main:
|
| 722 |
+
tqdm.write(f"[SpatialATST] Loading class head from {checkpoint_path}")
|
| 723 |
+
ckpt = torch.load(checkpoint_path, map_location=map_location, weights_only=False)
|
| 724 |
+
sd = ckpt.get("model", ckpt.get("state_dict", ckpt))
|
| 725 |
+
current = self.state_dict()
|
| 726 |
+
loaded = 0
|
| 727 |
+
for src_key, target_key in [
|
| 728 |
+
("classifier.weight", "local_spatial_prediction_heads.class_head.weight"),
|
| 729 |
+
("classifier.bias", "local_spatial_prediction_heads.class_head.bias"),
|
| 730 |
+
]:
|
| 731 |
+
if src_key in sd and target_key in current:
|
| 732 |
+
if current[target_key].shape == sd[src_key].shape:
|
| 733 |
+
current[target_key].copy_(
|
| 734 |
+
sd[src_key].to(dtype=current[target_key].dtype,
|
| 735 |
+
device=current[target_key].device)
|
| 736 |
+
)
|
| 737 |
+
loaded += 1
|
| 738 |
+
self.load_state_dict(current, strict=False)
|
| 739 |
+
if is_main:
|
| 740 |
+
tqdm.write(f"[SpatialATST] Class head keys loaded: {loaded}/2")
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
# ---------------------------------------------------------------------------
|
| 744 |
+
# Helper used inside SpatialATST.forward (avoids self. reference before init)
|
| 745 |
+
# ---------------------------------------------------------------------------
|
| 746 |
+
|
| 747 |
+
def compute_target_num_steps_from_duration(
|
| 748 |
+
duration: Tensor,
|
| 749 |
+
target_token_rate: float,
|
| 750 |
+
) -> Tensor:
|
| 751 |
+
steps = torch.round(duration * target_token_rate).long()
|
| 752 |
+
return torch.clamp(steps, min=1)
|
spatial_dataset.py
ADDED
|
@@ -0,0 +1,1657 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset skeleton for the simplified Spatial-BEATs training pipeline.
|
| 2 |
+
|
| 3 |
+
This file defines the data interfaces, vocabulary contracts, and batch tensor
|
| 4 |
+
shapes for Spatial-BEATs. The actual data loading logic is intentionally left
|
| 5 |
+
unimplemented so the I/O structure can be reviewed first.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
import csv
|
| 10 |
+
import json
|
| 11 |
+
import math
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
from torch import Tensor
|
| 17 |
+
from torch.utils.data import Dataset
|
| 18 |
+
from tqdm.auto import tqdm
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class SourceVocabularyConfig:
|
| 23 |
+
"""Source vocabulary settings for auxiliary class supervision.
|
| 24 |
+
|
| 25 |
+
Default values follow the local FSD50K-derived source vocabulary selected
|
| 26 |
+
for this project.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
vocab_path: str = (
|
| 30 |
+
"/apdcephfs_cq12/share_302080740/user/schmittzhu/data/fsd50k/"
|
| 31 |
+
"FSD50K.ground_truth/final_vocabulary.csv"
|
| 32 |
+
)
|
| 33 |
+
label_id_field: str = "label_id"
|
| 34 |
+
label_name_field: str = "final_label"
|
| 35 |
+
num_classes: int = 63
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class QwenLikeMelConfig:
|
| 40 |
+
"""Low-level mel parameter contract aligned with Qwen-2.5-Omni.
|
| 41 |
+
|
| 42 |
+
Only the front-end acoustic parameters are aligned:
|
| 43 |
+
- sample_rate = 16000
|
| 44 |
+
- num_mel_bins = 128
|
| 45 |
+
- n_fft = 400
|
| 46 |
+
- win_length = 400
|
| 47 |
+
- hop_length = 160
|
| 48 |
+
- dither = 0.0
|
| 49 |
+
|
| 50 |
+
The downstream encoder architecture remains Spatial-BEATs, not Qwen.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
sample_rate: int = 16000
|
| 54 |
+
num_mel_bins: int = 128
|
| 55 |
+
n_fft: int = 400
|
| 56 |
+
win_length: int = 400
|
| 57 |
+
hop_length: int = 160
|
| 58 |
+
dither: float = 0.0
|
| 59 |
+
waveform_scale: float = float(2**15)
|
| 60 |
+
fbank_mean: float = 15.41663
|
| 61 |
+
fbank_std: float = 6.55582
|
| 62 |
+
normalize_logmel: bool = True
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class SpatialDatasetConfig:
|
| 67 |
+
"""Configuration for dataset loading and collation."""
|
| 68 |
+
|
| 69 |
+
source_vocab: SourceVocabularyConfig = field(default_factory=SourceVocabularyConfig)
|
| 70 |
+
mel_config: QwenLikeMelConfig = field(default_factory=QwenLikeMelConfig)
|
| 71 |
+
target_token_rate: float = 2.5
|
| 72 |
+
max_sources: int = 4
|
| 73 |
+
padding_side: str = "right"
|
| 74 |
+
padding_value: float = 0.0
|
| 75 |
+
max_clip_duration_seconds: Optional[float] = None
|
| 76 |
+
min_crop_duration_seconds: Optional[float] = None # random duration range lower bound
|
| 77 |
+
crop_mode: str = "none"
|
| 78 |
+
allowed_splits: Optional[tuple[str, ...]] = None
|
| 79 |
+
show_progress: bool = True
|
| 80 |
+
|
| 81 |
+
# === v13_B [B-5] Real-distribution augment ==============================
|
| 82 |
+
# All augment flags default to "off" so existing presets are unaffected.
|
| 83 |
+
# Augments are ONLY applied when the dataset's allowed_splits contains
|
| 84 |
+
# "train" (i.e. training data), never on valid/test.
|
| 85 |
+
use_spec_augment: bool = False
|
| 86 |
+
spec_augment_time_mask_ratio: float = 0.0 # max fraction of T masked per stripe
|
| 87 |
+
spec_augment_freq_mask_ratio: float = 0.0 # max fraction of F masked per stripe
|
| 88 |
+
spec_augment_num_time_stripes: int = 2
|
| 89 |
+
spec_augment_num_freq_stripes: int = 2
|
| 90 |
+
# Waveform-level augment
|
| 91 |
+
random_gain_db: float = 0.0 # sample gain from U[-x, +x] dB
|
| 92 |
+
channel_dropout_prob: float = 0.0 # P(mask 1 FOA channel, per sample)
|
| 93 |
+
lowpass_sim_real_prob: float = 0.0 # P(apply lowpass, per sample)
|
| 94 |
+
lowpass_cutoff_min_hz: float = 4000.0
|
| 95 |
+
lowpass_cutoff_max_hz: float = 8000.0
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@dataclass
|
| 99 |
+
class SourceEvent:
|
| 100 |
+
"""One source-level annotation inside a clip.
|
| 101 |
+
|
| 102 |
+
Attributes:
|
| 103 |
+
class_index:
|
| 104 |
+
Integer class index in the final_vocabulary.csv space.
|
| 105 |
+
class_label:
|
| 106 |
+
Human-readable label name from final_vocabulary.csv.
|
| 107 |
+
azimuth_deg:
|
| 108 |
+
Source azimuth in degrees. For dynamic sources this is the
|
| 109 |
+
per-clip fallback (e.g. first frame's DOA); per-frame targets
|
| 110 |
+
live in ``frame_azi_deg``.
|
| 111 |
+
elevation_deg:
|
| 112 |
+
Source elevation in degrees. Per-frame targets live in
|
| 113 |
+
``frame_ele_deg`` for dynamic sources.
|
| 114 |
+
distance:
|
| 115 |
+
Continuous source distance (metres). Per-frame targets live in
|
| 116 |
+
``frame_distance_m`` for dynamic sources.
|
| 117 |
+
distance_valid:
|
| 118 |
+
False when the manifest marks distance as unknown (STARSS real,
|
| 119 |
+
DCASE). Suppresses distance loss at the clip level.
|
| 120 |
+
start_time_seconds:
|
| 121 |
+
Weak start time for the source inside the clip.
|
| 122 |
+
end_time_seconds:
|
| 123 |
+
Weak end time for the source inside the clip.
|
| 124 |
+
frame_times_s:
|
| 125 |
+
Optional [N_frames] tensor of frame timestamps in seconds,
|
| 126 |
+
relative to the start of the (un-cropped) clip. When present,
|
| 127 |
+
the loader builds per-frame DOA targets by linear interpolation;
|
| 128 |
+
when None, static scalar DOA is broadcast to every time step.
|
| 129 |
+
frame_azi_deg:
|
| 130 |
+
Optional [N_frames] per-frame azimuth in degrees, aligned with
|
| 131 |
+
``frame_times_s``. Continuous (not wrapped).
|
| 132 |
+
frame_ele_deg:
|
| 133 |
+
Optional [N_frames] per-frame elevation in degrees.
|
| 134 |
+
frame_distance_m:
|
| 135 |
+
Optional [N_frames] per-frame distance in metres. Values are
|
| 136 |
+
only consumed where ``frame_distance_valid`` is True.
|
| 137 |
+
frame_distance_valid:
|
| 138 |
+
Optional [N_frames] boolean mask. True when that frame carries
|
| 139 |
+
a reliable distance (e.g. sim_moving), False for DCASE/STARSS
|
| 140 |
+
where distance is unknown.
|
| 141 |
+
frame_ele_sign_only:
|
| 142 |
+
Optional [N_frames] boolean mask. True when the elevation for
|
| 143 |
+
that frame is only known as a sign (upper/lower hemisphere),
|
| 144 |
+
not as an exact angle. Derived from ±inf elevation in the new
|
| 145 |
+
unified dataset. When True, the loss uses a 2-class
|
| 146 |
+
upper/lower BCE instead of the full 180-bin Gaussian CE.
|
| 147 |
+
"""
|
| 148 |
+
|
| 149 |
+
class_index: int
|
| 150 |
+
class_label: str
|
| 151 |
+
azimuth_deg: float
|
| 152 |
+
elevation_deg: float
|
| 153 |
+
distance: float
|
| 154 |
+
distance_valid: bool
|
| 155 |
+
start_time_seconds: float
|
| 156 |
+
end_time_seconds: float
|
| 157 |
+
frame_times_s: Optional[Tensor] = None
|
| 158 |
+
frame_azi_deg: Optional[Tensor] = None
|
| 159 |
+
frame_ele_deg: Optional[Tensor] = None
|
| 160 |
+
frame_distance_m: Optional[Tensor] = None
|
| 161 |
+
frame_distance_valid: Optional[Tensor] = None
|
| 162 |
+
frame_ele_sign_only: Optional[Tensor] = None
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@dataclass
|
| 166 |
+
class SpatialSample:
|
| 167 |
+
"""One training sample before collation.
|
| 168 |
+
|
| 169 |
+
Attributes:
|
| 170 |
+
sample_id:
|
| 171 |
+
Stable identifier for debugging and loss analysis.
|
| 172 |
+
waveform:
|
| 173 |
+
[4, T] FOA waveform ordered as W / X / Y / Z.
|
| 174 |
+
clip_duration_seconds:
|
| 175 |
+
Scalar duration of the valid clip before any batch padding.
|
| 176 |
+
sources:
|
| 177 |
+
Variable-length list of source-level annotations.
|
| 178 |
+
"""
|
| 179 |
+
|
| 180 |
+
sample_id: str
|
| 181 |
+
waveform: Tensor
|
| 182 |
+
clip_duration_seconds: float
|
| 183 |
+
sources: List[SourceEvent]
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@dataclass
|
| 187 |
+
class SpatialBatch:
|
| 188 |
+
"""Collated batch contract used by Spatial-BEATs.
|
| 189 |
+
|
| 190 |
+
Tensor fields:
|
| 191 |
+
waveform:
|
| 192 |
+
[B, 4, T_max_wave] FOA waveform padded across the batch.
|
| 193 |
+
waveform_padding_mask:
|
| 194 |
+
[B, T_max_wave] boolean mask where True marks padded waveform samples.
|
| 195 |
+
clip_duration_seconds:
|
| 196 |
+
[B] valid clip durations in seconds.
|
| 197 |
+
target_num_steps:
|
| 198 |
+
[B] valid temporal token counts after resampling:
|
| 199 |
+
T_s_i = round(duration_i * target_token_rate)
|
| 200 |
+
source_class_indices:
|
| 201 |
+
[B, N_gt_max] class indices in the final_vocabulary.csv space.
|
| 202 |
+
source_azimuth_deg:
|
| 203 |
+
[B, N_gt_max, T_s_max] per-frame azimuth targets in degrees. For
|
| 204 |
+
static sources the scalar is broadcast along the T_s axis; for
|
| 205 |
+
dynamic sources the values come from linear interpolation of
|
| 206 |
+
``SourceEvent.frame_azi_deg`` onto the model's 10 Hz grid.
|
| 207 |
+
source_elevation_deg:
|
| 208 |
+
[B, N_gt_max, T_s_max] per-frame elevation targets in degrees.
|
| 209 |
+
source_distance:
|
| 210 |
+
[B, N_gt_max, T_s_max] per-frame distance targets in metres.
|
| 211 |
+
source_distance_valid:
|
| 212 |
+
[B, N_gt_max, T_s_max] boolean mask where True means the distance
|
| 213 |
+
target at that time step is reliable. False for sources whose
|
| 214 |
+
distance is null (STARSS real, DCASE) or for frames outside a
|
| 215 |
+
dynamic source's known-distance range.
|
| 216 |
+
source_ele_sign_only:
|
| 217 |
+
[B, N_gt_max, T_s_max] boolean mask where True means the
|
| 218 |
+
elevation for that (source, frame) is only known as a
|
| 219 |
+
sign/hemisphere (±inf in the new unified dataset), not as an
|
| 220 |
+
exact angle. Loss code uses a 2-class upper/lower CE for these
|
| 221 |
+
frames instead of the full 180-bin Gaussian CE.
|
| 222 |
+
source_start_time_seconds:
|
| 223 |
+
[B, N_gt_max] weak start times.
|
| 224 |
+
source_end_time_seconds:
|
| 225 |
+
[B, N_gt_max] weak end times.
|
| 226 |
+
source_valid_mask:
|
| 227 |
+
[B, N_gt_max] boolean mask where True marks real sources.
|
| 228 |
+
|
| 229 |
+
Non-tensor fields:
|
| 230 |
+
sample_ids:
|
| 231 |
+
List[str] of length B.
|
| 232 |
+
source_class_labels:
|
| 233 |
+
Optional nested label names aligned with source_class_indices.
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
waveform: Tensor
|
| 237 |
+
waveform_padding_mask: Optional[Tensor]
|
| 238 |
+
clip_duration_seconds: Tensor
|
| 239 |
+
target_num_steps: Tensor
|
| 240 |
+
source_class_indices: Tensor
|
| 241 |
+
source_azimuth_deg: Tensor
|
| 242 |
+
source_elevation_deg: Tensor
|
| 243 |
+
source_distance: Tensor
|
| 244 |
+
source_distance_valid: Tensor
|
| 245 |
+
source_ele_sign_only: Tensor
|
| 246 |
+
source_start_time_seconds: Tensor
|
| 247 |
+
source_end_time_seconds: Tensor
|
| 248 |
+
source_valid_mask: Tensor
|
| 249 |
+
sample_ids: List[str]
|
| 250 |
+
source_class_labels: Optional[List[List[str]]]
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def load_source_vocabulary(
|
| 254 |
+
config: SourceVocabularyConfig,
|
| 255 |
+
show_progress: bool = True,
|
| 256 |
+
) -> Dict[str, Any]:
|
| 257 |
+
"""Load the source vocabulary metadata from final_vocabulary.csv.
|
| 258 |
+
|
| 259 |
+
Expected CSV fields:
|
| 260 |
+
- label_id
|
| 261 |
+
- final_label
|
| 262 |
+
|
| 263 |
+
Returns:
|
| 264 |
+
Dict[str, Any]:
|
| 265 |
+
A structured vocabulary object. Suggested keys:
|
| 266 |
+
- index_to_label
|
| 267 |
+
- label_to_index
|
| 268 |
+
- label_id_to_index
|
| 269 |
+
- raw_rows
|
| 270 |
+
"""
|
| 271 |
+
rows: List[Dict[str, Any]] = []
|
| 272 |
+
vocab_path = Path(config.vocab_path)
|
| 273 |
+
with open(vocab_path, "r", encoding="utf-8") as handle:
|
| 274 |
+
total_rows = max(sum(1 for _ in handle) - 1, 0)
|
| 275 |
+
with open(vocab_path, "r", encoding="utf-8") as handle:
|
| 276 |
+
reader = csv.DictReader(handle)
|
| 277 |
+
for row in tqdm(
|
| 278 |
+
reader,
|
| 279 |
+
total=total_rows,
|
| 280 |
+
desc=f"Load vocabulary {vocab_path.name}",
|
| 281 |
+
leave=False,
|
| 282 |
+
disable=not show_progress,
|
| 283 |
+
):
|
| 284 |
+
rows.append(row)
|
| 285 |
+
|
| 286 |
+
rows = sorted(rows, key=lambda row: int(row[config.label_id_field]))
|
| 287 |
+
index_to_label: List[str] = []
|
| 288 |
+
label_to_index: Dict[str, int] = {}
|
| 289 |
+
label_id_to_index: Dict[int, int] = {}
|
| 290 |
+
|
| 291 |
+
for index, row in enumerate(rows):
|
| 292 |
+
label_name = str(row[config.label_name_field])
|
| 293 |
+
label_id = int(row[config.label_id_field])
|
| 294 |
+
index_to_label.append(label_name)
|
| 295 |
+
label_to_index[label_name] = index
|
| 296 |
+
label_id_to_index[label_id] = index
|
| 297 |
+
|
| 298 |
+
if config.num_classes != len(index_to_label):
|
| 299 |
+
raise ValueError(
|
| 300 |
+
f"Configured source_num_classes={config.num_classes}, "
|
| 301 |
+
f"but vocabulary contains {len(index_to_label)} rows."
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
return {
|
| 305 |
+
"index_to_label": index_to_label,
|
| 306 |
+
"label_to_index": label_to_index,
|
| 307 |
+
"label_id_to_index": label_id_to_index,
|
| 308 |
+
"raw_rows": rows,
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def compute_target_num_steps(
|
| 313 |
+
clip_duration_seconds: Tensor,
|
| 314 |
+
target_token_rate: float,
|
| 315 |
+
) -> Tensor:
|
| 316 |
+
"""Convert clip durations into valid temporal token counts.
|
| 317 |
+
|
| 318 |
+
Args:
|
| 319 |
+
clip_duration_seconds:
|
| 320 |
+
[B] clip durations in seconds.
|
| 321 |
+
target_token_rate:
|
| 322 |
+
Final spatial token rate, e.g. 2.5 Hz.
|
| 323 |
+
|
| 324 |
+
Returns:
|
| 325 |
+
Tensor:
|
| 326 |
+
[B] valid number of temporal steps for each sample:
|
| 327 |
+
T_s_i = round(duration_i * target_token_rate)
|
| 328 |
+
"""
|
| 329 |
+
target_num_steps = torch.round(clip_duration_seconds * target_token_rate).long()
|
| 330 |
+
return torch.clamp(target_num_steps, min=1)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _load_manifest_entries(
|
| 334 |
+
manifest_path: Path,
|
| 335 |
+
show_progress: bool = True,
|
| 336 |
+
) -> List[Dict[str, Any]]:
|
| 337 |
+
suffix = manifest_path.suffix.lower()
|
| 338 |
+
if suffix == ".jsonl":
|
| 339 |
+
entries = []
|
| 340 |
+
with open(manifest_path, "r", encoding="utf-8") as handle:
|
| 341 |
+
total_lines = sum(1 for _ in handle)
|
| 342 |
+
with open(manifest_path, "r", encoding="utf-8") as handle:
|
| 343 |
+
for line in tqdm(
|
| 344 |
+
handle,
|
| 345 |
+
total=total_lines,
|
| 346 |
+
desc=f"Load manifest {manifest_path.name}",
|
| 347 |
+
leave=False,
|
| 348 |
+
disable=not show_progress,
|
| 349 |
+
):
|
| 350 |
+
line = line.strip()
|
| 351 |
+
if not line:
|
| 352 |
+
continue
|
| 353 |
+
entries.append(json.loads(line))
|
| 354 |
+
return entries
|
| 355 |
+
if suffix == ".json":
|
| 356 |
+
with open(manifest_path, "r", encoding="utf-8") as handle:
|
| 357 |
+
data = json.load(handle)
|
| 358 |
+
if isinstance(data, dict) and "data" in data:
|
| 359 |
+
data = data["data"]
|
| 360 |
+
if not isinstance(data, list):
|
| 361 |
+
raise ValueError("JSON manifest must be a list or a dict with a 'data' list.")
|
| 362 |
+
return list(
|
| 363 |
+
tqdm(
|
| 364 |
+
data,
|
| 365 |
+
total=len(data),
|
| 366 |
+
desc=f"Load manifest {manifest_path.name}",
|
| 367 |
+
leave=False,
|
| 368 |
+
disable=not show_progress,
|
| 369 |
+
)
|
| 370 |
+
)
|
| 371 |
+
raise ValueError(f"Unsupported manifest format: {manifest_path}")
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def _load_audio_file(path: str, expected_sample_rate: int) -> Tensor:
|
| 375 |
+
try:
|
| 376 |
+
import soundfile as sf # type: ignore
|
| 377 |
+
|
| 378 |
+
waveform, sample_rate = sf.read(path, always_2d=True)
|
| 379 |
+
waveform = torch.from_numpy(waveform.T).float()
|
| 380 |
+
except Exception:
|
| 381 |
+
try:
|
| 382 |
+
from scipy.io import wavfile # type: ignore
|
| 383 |
+
|
| 384 |
+
sample_rate, waveform_np = wavfile.read(path)
|
| 385 |
+
if waveform_np.ndim == 1:
|
| 386 |
+
waveform_np = waveform_np[:, None]
|
| 387 |
+
if not str(waveform_np.dtype).startswith("float"):
|
| 388 |
+
max_val = max(float(torch.iinfo(torch.from_numpy(waveform_np).dtype).max), 1.0)
|
| 389 |
+
waveform = torch.from_numpy(waveform_np.astype("float32")) / max_val
|
| 390 |
+
else:
|
| 391 |
+
waveform = torch.from_numpy(waveform_np.astype("float32"))
|
| 392 |
+
waveform = waveform.transpose(0, 1)
|
| 393 |
+
except Exception as exc:
|
| 394 |
+
try:
|
| 395 |
+
import wave
|
| 396 |
+
|
| 397 |
+
with wave.open(path, "rb") as handle:
|
| 398 |
+
sample_rate = handle.getframerate()
|
| 399 |
+
num_channels = handle.getnchannels()
|
| 400 |
+
sample_width = handle.getsampwidth()
|
| 401 |
+
num_frames = handle.getnframes()
|
| 402 |
+
raw_bytes = handle.readframes(num_frames)
|
| 403 |
+
|
| 404 |
+
if sample_width == 2:
|
| 405 |
+
dtype = torch.int16
|
| 406 |
+
max_val = float(torch.iinfo(dtype).max)
|
| 407 |
+
elif sample_width == 4:
|
| 408 |
+
dtype = torch.int32
|
| 409 |
+
max_val = float(torch.iinfo(dtype).max)
|
| 410 |
+
else:
|
| 411 |
+
raise RuntimeError(f"Unsupported PCM sample width: {sample_width} bytes")
|
| 412 |
+
|
| 413 |
+
waveform = torch.frombuffer(raw_bytes, dtype=dtype).reshape(num_frames, num_channels)
|
| 414 |
+
waveform = waveform.float().transpose(0, 1) / max_val
|
| 415 |
+
except Exception as wave_exc:
|
| 416 |
+
raise RuntimeError(
|
| 417 |
+
f"Failed to load audio file '{path}'. Install soundfile/scipy or provide PCM wav."
|
| 418 |
+
) from wave_exc
|
| 419 |
+
|
| 420 |
+
if int(sample_rate) != int(expected_sample_rate):
|
| 421 |
+
raise ValueError(
|
| 422 |
+
f"Expected sample_rate={expected_sample_rate}, but got {sample_rate} for {path}"
|
| 423 |
+
)
|
| 424 |
+
if waveform.ndim != 2:
|
| 425 |
+
raise ValueError(f"Expected audio with shape [C, T], got {tuple(waveform.shape)}")
|
| 426 |
+
if waveform.size(0) != 4 and waveform.size(1) == 4:
|
| 427 |
+
waveform = waveform.transpose(0, 1)
|
| 428 |
+
if waveform.size(0) != 4:
|
| 429 |
+
raise ValueError(f"Expected FOA waveform with 4 channels, got {tuple(waveform.shape)}")
|
| 430 |
+
return waveform.contiguous()
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
# ---------------------------------------------------------------------------
|
| 434 |
+
# Label aliases — fine-grained labels collapsed to the 63-class FSD50K vocab.
|
| 435 |
+
# Some of our generated manifests (qa_moving, qa_counting, ...) carry the
|
| 436 |
+
# original gendered/specialised labels from FSD50K's raw ontology (e.g.
|
| 437 |
+
# "male_singing" / "female_singing"), but the Spatial-BEATs vocabulary used in
|
| 438 |
+
# training only covers the 63 collapsed classes (e.g. "singing"). Rather
|
| 439 |
+
# than regenerating every manifest we normalise the raw label string here
|
| 440 |
+
# before vocabulary lookup.
|
| 441 |
+
# ---------------------------------------------------------------------------
|
| 442 |
+
_LABEL_ALIASES: Dict[str, str] = {
|
| 443 |
+
"male_singing": "singing",
|
| 444 |
+
"female_singing": "singing",
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def _apply_label_alias(label: str) -> str:
|
| 449 |
+
return _LABEL_ALIASES.get(label, label)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _resolve_class_index(
|
| 453 |
+
source_entry: Dict[str, Any],
|
| 454 |
+
vocabulary: Dict[str, Any],
|
| 455 |
+
) -> int:
|
| 456 |
+
# Explicit numeric index fields (highest priority, always unambiguous).
|
| 457 |
+
if "class_index" in source_entry:
|
| 458 |
+
return int(source_entry["class_index"])
|
| 459 |
+
if "label_index" in source_entry:
|
| 460 |
+
return int(source_entry["label_index"])
|
| 461 |
+
if "source_label_index" in source_entry:
|
| 462 |
+
return int(source_entry["source_label_index"])
|
| 463 |
+
# String label fields (second priority). This correctly handles datasets
|
| 464 |
+
# whose numeric label_id uses a different numbering scheme from the
|
| 465 |
+
# vocabulary (e.g. the unified_spatial_foa_fsd63_all dataset uses 0-based
|
| 466 |
+
# label_ids that don't match the 1-based ids in final_vocabulary.csv, but
|
| 467 |
+
# always supplies a human-readable 'label' string).
|
| 468 |
+
for key in (
|
| 469 |
+
"class_label",
|
| 470 |
+
"final_label",
|
| 471 |
+
"label",
|
| 472 |
+
"source_label",
|
| 473 |
+
"mono_target_label",
|
| 474 |
+
"mono_primary_label",
|
| 475 |
+
):
|
| 476 |
+
if key in source_entry:
|
| 477 |
+
raw = str(source_entry[key])
|
| 478 |
+
canonical = _apply_label_alias(raw)
|
| 479 |
+
return int(vocabulary["label_to_index"][canonical])
|
| 480 |
+
# Numeric label_id / class_id fallback (legacy datasets that don't carry a
|
| 481 |
+
# string label). Look up in the vocabulary map; the map was built from the
|
| 482 |
+
# CSV and handles 1-based IDs correctly.
|
| 483 |
+
for id_key in ("label_id", "class_id"):
|
| 484 |
+
if id_key in source_entry:
|
| 485 |
+
lid = int(source_entry[id_key])
|
| 486 |
+
if lid in vocabulary["label_id_to_index"]:
|
| 487 |
+
return int(vocabulary["label_id_to_index"][lid])
|
| 488 |
+
raise KeyError("Unable to resolve source class index from source entry.")
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _resolve_class_label(
|
| 492 |
+
source_entry: Dict[str, Any],
|
| 493 |
+
vocabulary: Dict[str, Any],
|
| 494 |
+
class_index: int,
|
| 495 |
+
) -> str:
|
| 496 |
+
for key in (
|
| 497 |
+
"class_label",
|
| 498 |
+
"final_label",
|
| 499 |
+
"label",
|
| 500 |
+
"source_label",
|
| 501 |
+
"mono_target_label",
|
| 502 |
+
"mono_primary_label",
|
| 503 |
+
):
|
| 504 |
+
if key in source_entry:
|
| 505 |
+
return _apply_label_alias(str(source_entry[key]))
|
| 506 |
+
return str(vocabulary["index_to_label"][class_index])
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _maybe_get_float(source_entry: Dict[str, Any], keys: Sequence[str]) -> Optional[float]:
|
| 510 |
+
for key in keys:
|
| 511 |
+
if key in source_entry:
|
| 512 |
+
value = source_entry[key]
|
| 513 |
+
if value is None:
|
| 514 |
+
continue
|
| 515 |
+
if isinstance(value, str) and not value.strip():
|
| 516 |
+
continue
|
| 517 |
+
try:
|
| 518 |
+
return float(value)
|
| 519 |
+
except (TypeError, ValueError):
|
| 520 |
+
continue
|
| 521 |
+
return None
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def _get_float(source_entry: Dict[str, Any], keys: Sequence[str], default: Optional[float] = None) -> float:
|
| 525 |
+
value = _maybe_get_float(source_entry, keys)
|
| 526 |
+
if value is not None:
|
| 527 |
+
return value
|
| 528 |
+
if default is not None:
|
| 529 |
+
return float(default)
|
| 530 |
+
raise KeyError(f"Missing required keys {keys} in source entry.")
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def _resolve_distance_m(source_entry: Dict[str, Any], default: float = 1.0) -> float:
|
| 534 |
+
value = _maybe_get_float(source_entry, ("distance", "distance_m"))
|
| 535 |
+
if value is not None:
|
| 536 |
+
return value
|
| 537 |
+
|
| 538 |
+
value = _maybe_get_float(
|
| 539 |
+
source_entry,
|
| 540 |
+
("distance_cm", "rir_distance_cm", "horizontal_distance_cm", "rir_horizontal_distance_cm"),
|
| 541 |
+
)
|
| 542 |
+
if value is not None:
|
| 543 |
+
return value / 100.0
|
| 544 |
+
|
| 545 |
+
listener_position = source_entry.get("listener_position_cm", source_entry.get("rir_listener_position_cm"))
|
| 546 |
+
source_position = source_entry.get("source_position_cm", source_entry.get("rir_source_position_cm"))
|
| 547 |
+
if (
|
| 548 |
+
isinstance(listener_position, list | tuple)
|
| 549 |
+
and isinstance(source_position, list | tuple)
|
| 550 |
+
and len(listener_position) >= 3
|
| 551 |
+
and len(source_position) >= 3
|
| 552 |
+
):
|
| 553 |
+
try:
|
| 554 |
+
return math.sqrt(
|
| 555 |
+
sum(
|
| 556 |
+
(float(source_position[idx]) - float(listener_position[idx])) ** 2
|
| 557 |
+
for idx in range(3)
|
| 558 |
+
)
|
| 559 |
+
) / 100.0
|
| 560 |
+
except (TypeError, ValueError):
|
| 561 |
+
pass
|
| 562 |
+
|
| 563 |
+
return float(default)
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def _is_distance_valid(source_entry: Dict[str, Any]) -> bool:
|
| 567 |
+
"""Return False when the manifest explicitly marks distance as unknown/null.
|
| 568 |
+
|
| 569 |
+
A source is considered to have a *valid* distance when:
|
| 570 |
+
- the manifest entry contains ``"distance_valid": true`` (generated by
|
| 571 |
+
map_real_manifest.py for STARSS real data), OR
|
| 572 |
+
- ``"distance_valid"`` is absent (legacy sim manifests — all have real
|
| 573 |
+
distances), OR
|
| 574 |
+
- any numeric distance field is present and non-null.
|
| 575 |
+
|
| 576 |
+
A source is *invalid* only when ``"distance_valid": false`` is explicitly
|
| 577 |
+
set, or when all numeric fields resolve to None.
|
| 578 |
+
"""
|
| 579 |
+
explicit = source_entry.get("distance_valid")
|
| 580 |
+
if explicit is not None:
|
| 581 |
+
return bool(explicit)
|
| 582 |
+
|
| 583 |
+
# Legacy: if any numeric distance field is non-null, treat as valid.
|
| 584 |
+
for key in ("distance", "distance_m", "distance_cm", "rir_distance_cm",
|
| 585 |
+
"horizontal_distance_cm", "rir_horizontal_distance_cm"):
|
| 586 |
+
v = source_entry.get(key)
|
| 587 |
+
if v is not None:
|
| 588 |
+
return True
|
| 589 |
+
|
| 590 |
+
# Has 3-D positions → can compute distance
|
| 591 |
+
lp = source_entry.get("listener_position_cm", source_entry.get("rir_listener_position_cm"))
|
| 592 |
+
sp = source_entry.get("source_position_cm", source_entry.get("rir_source_position_cm"))
|
| 593 |
+
if isinstance(lp, (list, tuple)) and isinstance(sp, (list, tuple)):
|
| 594 |
+
return True
|
| 595 |
+
|
| 596 |
+
return False
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def _merge_doa_fields(source_entry: Dict[str, Any]) -> Dict[str, Any]:
|
| 600 |
+
doa = source_entry.get("doa")
|
| 601 |
+
if not isinstance(doa, dict):
|
| 602 |
+
return dict(source_entry)
|
| 603 |
+
merged = dict(source_entry)
|
| 604 |
+
if "azimuth_deg" in doa:
|
| 605 |
+
merged["azimuth_deg"] = doa.get("azimuth_deg")
|
| 606 |
+
if "elevation_deg" in doa:
|
| 607 |
+
merged["elevation_deg"] = doa.get("elevation_deg")
|
| 608 |
+
return merged
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def _source_has_valid_doa(source_entry: Dict[str, Any]) -> bool:
|
| 612 |
+
merged = _merge_doa_fields(source_entry)
|
| 613 |
+
azimuth = _maybe_get_float(merged, ("azimuth_deg", "azimuth"))
|
| 614 |
+
elevation = _maybe_get_float(merged, ("elevation_deg", "elevation"))
|
| 615 |
+
if azimuth is not None and elevation is not None:
|
| 616 |
+
return True
|
| 617 |
+
# New unified dataset: trajectory lives in an external CSV file.
|
| 618 |
+
# The CSV always contains per-frame azi/ele, so treat as valid.
|
| 619 |
+
if source_entry.get("source_trajectory_csv_path"):
|
| 620 |
+
return True
|
| 621 |
+
# Dynamic sources (qa_moving / DCASE) carry per-frame trajectories under
|
| 622 |
+
# "frames" and may have null top-level doa. Treat them as valid when any
|
| 623 |
+
# frame provides azi+ele.
|
| 624 |
+
frames = source_entry.get("frames")
|
| 625 |
+
if isinstance(frames, (list, tuple)) and frames:
|
| 626 |
+
for fr in frames:
|
| 627 |
+
if not isinstance(fr, dict):
|
| 628 |
+
continue
|
| 629 |
+
fdoa = fr.get("doa") or {}
|
| 630 |
+
az = fr.get("azimuth_deg", fdoa.get("azimuth_deg"))
|
| 631 |
+
el = fr.get("elevation_deg", fdoa.get("elevation_deg"))
|
| 632 |
+
if az is not None and el is not None:
|
| 633 |
+
return True
|
| 634 |
+
return False
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
def _entry_has_valid_geometry(entry: Dict[str, Any]) -> bool:
|
| 638 |
+
if "sources" in entry and isinstance(entry["sources"], list):
|
| 639 |
+
if not entry["sources"]:
|
| 640 |
+
return False
|
| 641 |
+
return all(isinstance(source_entry, dict) and _source_has_valid_doa(source_entry) for source_entry in entry["sources"])
|
| 642 |
+
|
| 643 |
+
source_like = {
|
| 644 |
+
"azimuth_deg": entry.get("rir_doa_azimuth_deg"),
|
| 645 |
+
"elevation_deg": entry.get("rir_doa_elevation_deg"),
|
| 646 |
+
}
|
| 647 |
+
return _source_has_valid_doa(source_like)
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
def _build_source_event_from_top_level_entry(
|
| 651 |
+
entry: Dict[str, Any],
|
| 652 |
+
vocabulary: Dict[str, Any],
|
| 653 |
+
) -> SourceEvent:
|
| 654 |
+
source_like = {
|
| 655 |
+
"final_label": entry.get("mono_target_label", entry.get("mono_primary_label")),
|
| 656 |
+
"azimuth_deg": entry.get("rir_doa_azimuth_deg"),
|
| 657 |
+
"elevation_deg": entry.get("rir_doa_elevation_deg"),
|
| 658 |
+
"distance_cm": entry.get("rir_distance_cm"),
|
| 659 |
+
"horizontal_distance_cm": entry.get("rir_horizontal_distance_cm"),
|
| 660 |
+
"listener_position_cm": entry.get("rir_listener_position_cm"),
|
| 661 |
+
"source_position_cm": entry.get("rir_source_position_cm"),
|
| 662 |
+
"start_time_seconds": 0.0,
|
| 663 |
+
"end_time_seconds": entry.get("output_duration_seconds"),
|
| 664 |
+
}
|
| 665 |
+
class_index = _resolve_class_index(source_like, vocabulary)
|
| 666 |
+
class_label = _resolve_class_label(source_like, vocabulary, class_index)
|
| 667 |
+
return SourceEvent(
|
| 668 |
+
class_index=class_index,
|
| 669 |
+
class_label=class_label,
|
| 670 |
+
azimuth_deg=_get_float(source_like, ("azimuth_deg", "azimuth")),
|
| 671 |
+
elevation_deg=_get_float(source_like, ("elevation_deg", "elevation")),
|
| 672 |
+
distance=_resolve_distance_m(source_like),
|
| 673 |
+
distance_valid=_is_distance_valid(source_like),
|
| 674 |
+
start_time_seconds=float(source_like["start_time_seconds"]),
|
| 675 |
+
end_time_seconds=float(source_like["end_time_seconds"]),
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
def _load_csv_trajectory(
|
| 680 |
+
csv_path: str,
|
| 681 |
+
clip_duration_seconds: float,
|
| 682 |
+
frame_rate: float = 10.0,
|
| 683 |
+
) -> Dict[str, Optional[Tensor]]:
|
| 684 |
+
"""Parse a 6-column per-frame CSV trajectory file from the unified dataset.
|
| 685 |
+
|
| 686 |
+
Expected columns (no header):
|
| 687 |
+
frame_idx, class_id, track_id, azimuth_deg, elevation_deg, distance_cm
|
| 688 |
+
|
| 689 |
+
Special values:
|
| 690 |
+
distance_cm == -1 → distance unknown, mark frame invalid
|
| 691 |
+
elevation_deg == inf or -inf → hemisphere known, exact angle unknown
|
| 692 |
+
→ set frame_ele_sign_only = True
|
| 693 |
+
→ clamp stored elevation to ±90 sentinel
|
| 694 |
+
|
| 695 |
+
Returns the same dict shape as ``_parse_frame_trajectory``:
|
| 696 |
+
frame_times_s, frame_azi_deg, frame_ele_deg, frame_distance_m,
|
| 697 |
+
frame_distance_valid, frame_ele_sign_only
|
| 698 |
+
All tensors are 1-D float32 / bool.
|
| 699 |
+
"""
|
| 700 |
+
_empty: Dict[str, Optional[Tensor]] = {
|
| 701 |
+
"frame_times_s": None,
|
| 702 |
+
"frame_azi_deg": None,
|
| 703 |
+
"frame_ele_deg": None,
|
| 704 |
+
"frame_distance_m": None,
|
| 705 |
+
"frame_distance_valid": None,
|
| 706 |
+
"frame_ele_sign_only": None,
|
| 707 |
+
}
|
| 708 |
+
try:
|
| 709 |
+
times: List[float] = []
|
| 710 |
+
azi: List[float] = []
|
| 711 |
+
ele: List[float] = []
|
| 712 |
+
dist_m: List[float] = []
|
| 713 |
+
dist_valid: List[bool] = []
|
| 714 |
+
ele_sign_only: List[bool] = []
|
| 715 |
+
|
| 716 |
+
with open(csv_path, "r", encoding="utf-8") as fh:
|
| 717 |
+
for line in fh:
|
| 718 |
+
line = line.strip()
|
| 719 |
+
if not line:
|
| 720 |
+
continue
|
| 721 |
+
parts = line.split(",")
|
| 722 |
+
if len(parts) < 6:
|
| 723 |
+
continue
|
| 724 |
+
try:
|
| 725 |
+
frame_idx = int(parts[0])
|
| 726 |
+
az_val = float(parts[3])
|
| 727 |
+
el_str = parts[4].strip()
|
| 728 |
+
d_cm_str = parts[5].strip()
|
| 729 |
+
except (ValueError, IndexError):
|
| 730 |
+
continue
|
| 731 |
+
|
| 732 |
+
t = frame_idx / frame_rate
|
| 733 |
+
times.append(t)
|
| 734 |
+
azi.append(az_val)
|
| 735 |
+
|
| 736 |
+
# Elevation: handle ±inf (sign-only frames)
|
| 737 |
+
if el_str in ("inf", "+inf", "Inf", "+Inf"):
|
| 738 |
+
ele.append(90.0) # sentinel: upper hemisphere
|
| 739 |
+
ele_sign_only.append(True)
|
| 740 |
+
elif el_str in ("-inf", "-Inf"):
|
| 741 |
+
ele.append(-90.0) # sentinel: lower hemisphere
|
| 742 |
+
ele_sign_only.append(True)
|
| 743 |
+
else:
|
| 744 |
+
try:
|
| 745 |
+
el_val = float(el_str)
|
| 746 |
+
except ValueError:
|
| 747 |
+
el_val = 0.0
|
| 748 |
+
# Clamp finite values to valid range
|
| 749 |
+
el_val = max(-90.0, min(90.0, el_val))
|
| 750 |
+
ele.append(el_val)
|
| 751 |
+
ele_sign_only.append(False)
|
| 752 |
+
|
| 753 |
+
# Distance: -1 → unknown
|
| 754 |
+
try:
|
| 755 |
+
d_cm = float(d_cm_str)
|
| 756 |
+
except ValueError:
|
| 757 |
+
d_cm = -1.0
|
| 758 |
+
if d_cm >= 0:
|
| 759 |
+
dist_m.append(d_cm / 100.0)
|
| 760 |
+
dist_valid.append(True)
|
| 761 |
+
else:
|
| 762 |
+
dist_m.append(0.0)
|
| 763 |
+
dist_valid.append(False)
|
| 764 |
+
|
| 765 |
+
if not times:
|
| 766 |
+
return _empty
|
| 767 |
+
|
| 768 |
+
return {
|
| 769 |
+
"frame_times_s": torch.tensor(times, dtype=torch.float32),
|
| 770 |
+
"frame_azi_deg": torch.tensor(azi, dtype=torch.float32),
|
| 771 |
+
"frame_ele_deg": torch.tensor(ele, dtype=torch.float32),
|
| 772 |
+
"frame_distance_m": torch.tensor(dist_m, dtype=torch.float32),
|
| 773 |
+
"frame_distance_valid": torch.tensor(dist_valid, dtype=torch.bool),
|
| 774 |
+
"frame_ele_sign_only": torch.tensor(ele_sign_only, dtype=torch.bool),
|
| 775 |
+
}
|
| 776 |
+
except OSError:
|
| 777 |
+
return _empty
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
def _parse_frame_trajectory(
|
| 781 |
+
source_entry: Dict[str, Any],
|
| 782 |
+
clip_duration_seconds: float,
|
| 783 |
+
) -> Dict[str, Optional[Tensor]]:
|
| 784 |
+
"""Extract per-frame DOA trajectory from a manifest source entry.
|
| 785 |
+
|
| 786 |
+
Supports three manifest layouts:
|
| 787 |
+
|
| 788 |
+
1. New unified dataset (spatial_foa_scene_v1): ``source_trajectory_csv_path``
|
| 789 |
+
points to an external 6-column CSV (frame_idx,class_id,track_id,azi,ele,dist_cm).
|
| 790 |
+
2. qa_moving.jsonl style: ``frames`` is a list of dicts with keys
|
| 791 |
+
``frame_idx`` and ``doa.azimuth_deg/elevation_deg`` and optional
|
| 792 |
+
``distance_cm``; clip-level ``num_frames`` / ``frame_rate`` / a top-level
|
| 793 |
+
``duration_sec`` determine the time axis.
|
| 794 |
+
3. DCASE-style converter output (see tools/dcase_starss_to_jsonl.py):
|
| 795 |
+
``frames`` rows carry ``time_s`` directly.
|
| 796 |
+
|
| 797 |
+
Returns a dict with keys ``frame_times_s``, ``frame_azi_deg``,
|
| 798 |
+
``frame_ele_deg``, ``frame_distance_m``, ``frame_distance_valid``,
|
| 799 |
+
``frame_ele_sign_only`` — each either a 1-D Tensor or ``None`` when the
|
| 800 |
+
source has no trajectory.
|
| 801 |
+
Returned distances are in metres; frames with missing distance are
|
| 802 |
+
marked invalid.
|
| 803 |
+
"""
|
| 804 |
+
# --- Branch 1: external CSV file (new unified dataset) ---
|
| 805 |
+
csv_path = source_entry.get("source_trajectory_csv_path")
|
| 806 |
+
if csv_path:
|
| 807 |
+
clip_frame_rate = float(source_entry.get("frame_rate", 10.0))
|
| 808 |
+
return _load_csv_trajectory(
|
| 809 |
+
csv_path=str(csv_path),
|
| 810 |
+
clip_duration_seconds=clip_duration_seconds,
|
| 811 |
+
frame_rate=clip_frame_rate,
|
| 812 |
+
)
|
| 813 |
+
|
| 814 |
+
# --- Branch 2 & 3: inline frames[] list ---
|
| 815 |
+
frames = source_entry.get("frames")
|
| 816 |
+
if not isinstance(frames, (list, tuple)) or len(frames) == 0:
|
| 817 |
+
return {
|
| 818 |
+
"frame_times_s": None,
|
| 819 |
+
"frame_azi_deg": None,
|
| 820 |
+
"frame_ele_deg": None,
|
| 821 |
+
"frame_distance_m": None,
|
| 822 |
+
"frame_distance_valid": None,
|
| 823 |
+
"frame_ele_sign_only": None,
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
# Determine time axis. Prefer explicit time_s per row, then fall back to
|
| 827 |
+
# frame_idx / frame_rate, finally to linspace over clip_duration.
|
| 828 |
+
n = len(frames)
|
| 829 |
+
explicit_time = all(isinstance(f, dict) and ("time_s" in f or "time" in f) for f in frames)
|
| 830 |
+
frame_rate = source_entry.get("frame_rate")
|
| 831 |
+
# Fall back: parent manifest sometimes sets frame_rate on the clip record,
|
| 832 |
+
# not per source. Caller can pre-fill source_entry["frame_rate"].
|
| 833 |
+
if frame_rate is not None:
|
| 834 |
+
try:
|
| 835 |
+
frame_rate = float(frame_rate)
|
| 836 |
+
except (TypeError, ValueError):
|
| 837 |
+
frame_rate = None
|
| 838 |
+
if frame_rate is None or frame_rate <= 0:
|
| 839 |
+
frame_rate = None
|
| 840 |
+
|
| 841 |
+
times: List[float] = []
|
| 842 |
+
azi: List[float] = []
|
| 843 |
+
ele: List[float] = []
|
| 844 |
+
dist_m: List[float] = []
|
| 845 |
+
dist_valid: List[bool] = []
|
| 846 |
+
ele_sign_only: List[bool] = []
|
| 847 |
+
|
| 848 |
+
for i, fr in enumerate(frames):
|
| 849 |
+
if not isinstance(fr, dict):
|
| 850 |
+
continue
|
| 851 |
+
# time axis
|
| 852 |
+
if explicit_time:
|
| 853 |
+
t = float(fr.get("time_s", fr.get("time", i)))
|
| 854 |
+
else:
|
| 855 |
+
fi = fr.get("frame_idx", i)
|
| 856 |
+
if frame_rate is not None:
|
| 857 |
+
t = float(fi) / float(frame_rate)
|
| 858 |
+
elif clip_duration_seconds > 0.0 and n > 1:
|
| 859 |
+
t = float(i) * float(clip_duration_seconds) / float(n - 1)
|
| 860 |
+
else:
|
| 861 |
+
t = float(i)
|
| 862 |
+
times.append(t)
|
| 863 |
+
|
| 864 |
+
# DOA can live either nested under "doa": {...} or flat
|
| 865 |
+
doa = fr.get("doa") or {}
|
| 866 |
+
az = fr.get("azimuth_deg", doa.get("azimuth_deg"))
|
| 867 |
+
el = fr.get("elevation_deg", doa.get("elevation_deg"))
|
| 868 |
+
azi.append(float(az) if az is not None else 0.0)
|
| 869 |
+
if el is not None:
|
| 870 |
+
try:
|
| 871 |
+
el_f = float(el)
|
| 872 |
+
except (TypeError, ValueError):
|
| 873 |
+
el_f = 0.0
|
| 874 |
+
# inline frames[] are not expected to carry ±inf in practice,
|
| 875 |
+
# but handle gracefully just in case
|
| 876 |
+
if math.isinf(el_f):
|
| 877 |
+
ele.append(90.0 if el_f > 0 else -90.0)
|
| 878 |
+
ele_sign_only.append(True)
|
| 879 |
+
else:
|
| 880 |
+
ele.append(max(-90.0, min(90.0, el_f)))
|
| 881 |
+
ele_sign_only.append(False)
|
| 882 |
+
else:
|
| 883 |
+
ele.append(0.0)
|
| 884 |
+
ele_sign_only.append(False)
|
| 885 |
+
|
| 886 |
+
# distance: accept distance_cm (preferred) or distance_m; -1 → invalid
|
| 887 |
+
d_cm = fr.get("distance_cm")
|
| 888 |
+
d_m = fr.get("distance_m")
|
| 889 |
+
if d_cm is not None and d_cm != -1 and d_cm >= 0:
|
| 890 |
+
dist_m.append(float(d_cm) / 100.0)
|
| 891 |
+
dist_valid.append(True)
|
| 892 |
+
elif d_m is not None and d_m >= 0:
|
| 893 |
+
dist_m.append(float(d_m))
|
| 894 |
+
dist_valid.append(True)
|
| 895 |
+
else:
|
| 896 |
+
dist_m.append(0.0)
|
| 897 |
+
dist_valid.append(False)
|
| 898 |
+
|
| 899 |
+
if not times:
|
| 900 |
+
return {
|
| 901 |
+
"frame_times_s": None,
|
| 902 |
+
"frame_azi_deg": None,
|
| 903 |
+
"frame_ele_deg": None,
|
| 904 |
+
"frame_distance_m": None,
|
| 905 |
+
"frame_distance_valid": None,
|
| 906 |
+
"frame_ele_sign_only": None,
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
return {
|
| 910 |
+
"frame_times_s": torch.tensor(times, dtype=torch.float32),
|
| 911 |
+
"frame_azi_deg": torch.tensor(azi, dtype=torch.float32),
|
| 912 |
+
"frame_ele_deg": torch.tensor(ele, dtype=torch.float32),
|
| 913 |
+
"frame_distance_m": torch.tensor(dist_m, dtype=torch.float32),
|
| 914 |
+
"frame_distance_valid": torch.tensor(dist_valid, dtype=torch.bool),
|
| 915 |
+
"frame_ele_sign_only": torch.tensor(ele_sign_only, dtype=torch.bool),
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
|
| 919 |
+
def _build_source_event_from_nested_entry(
|
| 920 |
+
source_entry: Dict[str, Any],
|
| 921 |
+
vocabulary: Dict[str, Any],
|
| 922 |
+
clip_duration_seconds: float,
|
| 923 |
+
) -> SourceEvent:
|
| 924 |
+
class_index = _resolve_class_index(source_entry, vocabulary)
|
| 925 |
+
class_label = _resolve_class_label(source_entry, vocabulary, class_index)
|
| 926 |
+
|
| 927 |
+
# "active_times" is a list-of-intervals in the new unified dataset
|
| 928 |
+
# (e.g. [[1.0, 5.05]]). Use the first interval as the clip-level window.
|
| 929 |
+
# Legacy fields: "active_time" (singular list [start, end]) or "full_time".
|
| 930 |
+
active_times = source_entry.get("active_times")
|
| 931 |
+
active_time = source_entry.get("active_time")
|
| 932 |
+
full_time = source_entry.get("full_time")
|
| 933 |
+
if active_times is not None and isinstance(active_times, (list, tuple)) and len(active_times) > 0:
|
| 934 |
+
# take the first interval
|
| 935 |
+
first_interval = active_times[0]
|
| 936 |
+
start_time_seconds = float(first_interval[0])
|
| 937 |
+
end_time_seconds = float(first_interval[-1])
|
| 938 |
+
elif active_time is not None:
|
| 939 |
+
start_time_seconds, end_time_seconds = float(active_time[0]), float(active_time[1])
|
| 940 |
+
elif full_time is not None:
|
| 941 |
+
start_time_seconds, end_time_seconds = float(full_time[0]), float(full_time[1])
|
| 942 |
+
else:
|
| 943 |
+
start_time_seconds, end_time_seconds = 0.0, float(clip_duration_seconds)
|
| 944 |
+
|
| 945 |
+
doa = source_entry.get("doa") or {}
|
| 946 |
+
# Use _maybe_get_float — dynamic sources may carry doa=None at the top
|
| 947 |
+
# level, with the real DOA living inside frames[]. Missing here is OK;
|
| 948 |
+
# trajectory-based fallback fills the scalar below.
|
| 949 |
+
azimuth_deg = _maybe_get_float(
|
| 950 |
+
{**source_entry, **({"azimuth_deg": doa.get("azimuth_deg")} if isinstance(doa, dict) and "azimuth_deg" in doa else {})},
|
| 951 |
+
("azimuth_deg", "azimuth"),
|
| 952 |
+
)
|
| 953 |
+
elevation_deg = _maybe_get_float(
|
| 954 |
+
{**source_entry, **({"elevation_deg": doa.get("elevation_deg")} if isinstance(doa, dict) and "elevation_deg" in doa else {})},
|
| 955 |
+
("elevation_deg", "elevation"),
|
| 956 |
+
)
|
| 957 |
+
|
| 958 |
+
# Extract per-frame trajectory for dynamic sources (qa_moving, DCASE, ...).
|
| 959 |
+
traj = _parse_frame_trajectory(source_entry, clip_duration_seconds)
|
| 960 |
+
|
| 961 |
+
# For dynamic sources the top-level ``doa`` may be null; fall back to the
|
| 962 |
+
# first trajectory frame so the scalar fields stay usable as a default.
|
| 963 |
+
if azimuth_deg is None and traj["frame_azi_deg"] is not None:
|
| 964 |
+
azimuth_deg = float(traj["frame_azi_deg"][0].item())
|
| 965 |
+
if elevation_deg is None and traj["frame_ele_deg"] is not None:
|
| 966 |
+
elevation_deg = float(traj["frame_ele_deg"][0].item())
|
| 967 |
+
|
| 968 |
+
# Distance: prefer explicit source-level distance, else first valid frame.
|
| 969 |
+
distance_m = _resolve_distance_m(source_entry)
|
| 970 |
+
distance_valid = _is_distance_valid(source_entry)
|
| 971 |
+
if not distance_valid and traj["frame_distance_valid"] is not None:
|
| 972 |
+
valid_mask = traj["frame_distance_valid"]
|
| 973 |
+
if bool(valid_mask.any().item()):
|
| 974 |
+
first_valid = int(torch.nonzero(valid_mask, as_tuple=False)[0].item())
|
| 975 |
+
distance_m = float(traj["frame_distance_m"][first_valid].item())
|
| 976 |
+
distance_valid = True
|
| 977 |
+
|
| 978 |
+
return SourceEvent(
|
| 979 |
+
class_index=class_index,
|
| 980 |
+
class_label=class_label,
|
| 981 |
+
azimuth_deg=float(azimuth_deg) if azimuth_deg is not None else 0.0,
|
| 982 |
+
elevation_deg=float(elevation_deg) if elevation_deg is not None else 0.0,
|
| 983 |
+
distance=distance_m,
|
| 984 |
+
distance_valid=distance_valid,
|
| 985 |
+
start_time_seconds=start_time_seconds,
|
| 986 |
+
end_time_seconds=end_time_seconds,
|
| 987 |
+
frame_times_s=traj["frame_times_s"],
|
| 988 |
+
frame_azi_deg=traj["frame_azi_deg"],
|
| 989 |
+
frame_ele_deg=traj["frame_ele_deg"],
|
| 990 |
+
frame_distance_m=traj["frame_distance_m"],
|
| 991 |
+
frame_distance_valid=traj["frame_distance_valid"],
|
| 992 |
+
frame_ele_sign_only=traj.get("frame_ele_sign_only"),
|
| 993 |
+
)
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
def _maybe_crop_sample(
|
| 997 |
+
waveform: Tensor,
|
| 998 |
+
clip_duration_seconds: float,
|
| 999 |
+
sources: List[SourceEvent],
|
| 1000 |
+
sample_rate: int,
|
| 1001 |
+
max_clip_duration_seconds: Optional[float],
|
| 1002 |
+
crop_mode: str,
|
| 1003 |
+
min_crop_duration_seconds: Optional[float] = None,
|
| 1004 |
+
) -> tuple[Tensor, float, List[SourceEvent]]:
|
| 1005 |
+
if max_clip_duration_seconds is None:
|
| 1006 |
+
return waveform, clip_duration_seconds, sources
|
| 1007 |
+
|
| 1008 |
+
total_num_samples = waveform.size(-1)
|
| 1009 |
+
|
| 1010 |
+
# Random duration crop: sample duration from [min, min(max, actual_length)]
|
| 1011 |
+
if min_crop_duration_seconds is not None and crop_mode == "random":
|
| 1012 |
+
min_samples = max(int(round(min_crop_duration_seconds * sample_rate)), 1)
|
| 1013 |
+
max_samples = min(int(round(max_clip_duration_seconds * sample_rate)), total_num_samples)
|
| 1014 |
+
min_samples = min(min_samples, max_samples)
|
| 1015 |
+
if min_samples >= total_num_samples:
|
| 1016 |
+
# Audio shorter than min_crop_duration — use as-is
|
| 1017 |
+
return waveform, clip_duration_seconds, sources
|
| 1018 |
+
crop_num_samples = int(torch.randint(min_samples, max_samples + 1, (1,)).item())
|
| 1019 |
+
else:
|
| 1020 |
+
if clip_duration_seconds <= max_clip_duration_seconds:
|
| 1021 |
+
return waveform, clip_duration_seconds, sources
|
| 1022 |
+
crop_num_samples = int(round(max_clip_duration_seconds * sample_rate))
|
| 1023 |
+
|
| 1024 |
+
if crop_num_samples >= total_num_samples:
|
| 1025 |
+
return waveform, clip_duration_seconds, sources
|
| 1026 |
+
|
| 1027 |
+
if crop_mode == "random":
|
| 1028 |
+
# Constrain random start so the crop window covers at least one source.
|
| 1029 |
+
# Use the first source as anchor (ov1 = single source per clip).
|
| 1030 |
+
lo = 0
|
| 1031 |
+
hi = total_num_samples - crop_num_samples
|
| 1032 |
+
if sources:
|
| 1033 |
+
anchor = sources[0]
|
| 1034 |
+
src_start_sample = int(round(anchor.start_time_seconds * sample_rate))
|
| 1035 |
+
src_end_sample = int(round(anchor.end_time_seconds * sample_rate))
|
| 1036 |
+
# Window must start before src_end and end after src_start:
|
| 1037 |
+
# start_sample + crop_num_samples > src_start_sample
|
| 1038 |
+
# start_sample < src_end_sample
|
| 1039 |
+
lo = max(lo, src_start_sample - crop_num_samples + 1)
|
| 1040 |
+
hi = min(hi, src_end_sample - 1)
|
| 1041 |
+
lo = max(lo, 0)
|
| 1042 |
+
hi = min(hi, total_num_samples - crop_num_samples)
|
| 1043 |
+
if lo > hi:
|
| 1044 |
+
# Source is shorter than one sample inside any window — use full clip
|
| 1045 |
+
return waveform, clip_duration_seconds, sources
|
| 1046 |
+
start_sample = int(torch.randint(lo, hi + 1, (1,)).item())
|
| 1047 |
+
elif crop_mode == "start":
|
| 1048 |
+
start_sample = 0
|
| 1049 |
+
elif crop_mode == "center":
|
| 1050 |
+
start_sample = max((total_num_samples - crop_num_samples) // 2, 0)
|
| 1051 |
+
elif crop_mode == "none":
|
| 1052 |
+
return waveform, clip_duration_seconds, sources
|
| 1053 |
+
else:
|
| 1054 |
+
raise ValueError(f"Unsupported crop_mode: {crop_mode}")
|
| 1055 |
+
|
| 1056 |
+
end_sample = start_sample + crop_num_samples
|
| 1057 |
+
crop_start_seconds = start_sample / float(sample_rate)
|
| 1058 |
+
crop_end_seconds = end_sample / float(sample_rate)
|
| 1059 |
+
cropped_waveform = waveform[:, start_sample:end_sample]
|
| 1060 |
+
|
| 1061 |
+
cropped_sources: List[SourceEvent] = []
|
| 1062 |
+
for source in sources:
|
| 1063 |
+
new_start = max(source.start_time_seconds, crop_start_seconds)
|
| 1064 |
+
new_end = min(source.end_time_seconds, crop_end_seconds)
|
| 1065 |
+
if new_end <= new_start:
|
| 1066 |
+
continue
|
| 1067 |
+
|
| 1068 |
+
# Crop the per-frame trajectory to the new time window and re-base the
|
| 1069 |
+
# timestamps to the cropped clip's start.
|
| 1070 |
+
frame_times_s = source.frame_times_s
|
| 1071 |
+
frame_azi_deg = source.frame_azi_deg
|
| 1072 |
+
frame_ele_deg = source.frame_ele_deg
|
| 1073 |
+
frame_distance_m = source.frame_distance_m
|
| 1074 |
+
frame_distance_valid = source.frame_distance_valid
|
| 1075 |
+
frame_ele_sign_only = source.frame_ele_sign_only
|
| 1076 |
+
if frame_times_s is not None and frame_times_s.numel() > 0:
|
| 1077 |
+
# Keep frames whose timestamp falls inside [crop_start, crop_end].
|
| 1078 |
+
mask = (frame_times_s >= crop_start_seconds) & (frame_times_s <= crop_end_seconds)
|
| 1079 |
+
if bool(mask.any().item()):
|
| 1080 |
+
frame_times_s = frame_times_s[mask] - crop_start_seconds
|
| 1081 |
+
frame_azi_deg = frame_azi_deg[mask] if frame_azi_deg is not None else None
|
| 1082 |
+
frame_ele_deg = frame_ele_deg[mask] if frame_ele_deg is not None else None
|
| 1083 |
+
frame_distance_m = frame_distance_m[mask] if frame_distance_m is not None else None
|
| 1084 |
+
frame_distance_valid = (
|
| 1085 |
+
frame_distance_valid[mask] if frame_distance_valid is not None else None
|
| 1086 |
+
)
|
| 1087 |
+
frame_ele_sign_only = (
|
| 1088 |
+
frame_ele_sign_only[mask] if frame_ele_sign_only is not None else None
|
| 1089 |
+
)
|
| 1090 |
+
else:
|
| 1091 |
+
# No frames survive the crop — drop trajectory and keep scalar.
|
| 1092 |
+
frame_times_s = None
|
| 1093 |
+
frame_azi_deg = None
|
| 1094 |
+
frame_ele_deg = None
|
| 1095 |
+
frame_distance_m = None
|
| 1096 |
+
frame_distance_valid = None
|
| 1097 |
+
frame_ele_sign_only = None
|
| 1098 |
+
|
| 1099 |
+
cropped_sources.append(
|
| 1100 |
+
SourceEvent(
|
| 1101 |
+
class_index=source.class_index,
|
| 1102 |
+
class_label=source.class_label,
|
| 1103 |
+
azimuth_deg=source.azimuth_deg,
|
| 1104 |
+
elevation_deg=source.elevation_deg,
|
| 1105 |
+
distance=source.distance,
|
| 1106 |
+
distance_valid=source.distance_valid,
|
| 1107 |
+
start_time_seconds=new_start - crop_start_seconds,
|
| 1108 |
+
end_time_seconds=new_end - crop_start_seconds,
|
| 1109 |
+
frame_times_s=frame_times_s,
|
| 1110 |
+
frame_azi_deg=frame_azi_deg,
|
| 1111 |
+
frame_ele_deg=frame_ele_deg,
|
| 1112 |
+
frame_distance_m=frame_distance_m,
|
| 1113 |
+
frame_distance_valid=frame_distance_valid,
|
| 1114 |
+
frame_ele_sign_only=frame_ele_sign_only,
|
| 1115 |
+
)
|
| 1116 |
+
)
|
| 1117 |
+
|
| 1118 |
+
return cropped_waveform, crop_num_samples / float(sample_rate), cropped_sources
|
| 1119 |
+
|
| 1120 |
+
|
| 1121 |
+
# =============================================================================
|
| 1122 |
+
# v13_B [B-5] Waveform-level augmentation
|
| 1123 |
+
# =============================================================================
|
| 1124 |
+
|
| 1125 |
+
|
| 1126 |
+
def _apply_waveform_augment(
|
| 1127 |
+
waveform: Tensor,
|
| 1128 |
+
config: "SpatialDatasetConfig",
|
| 1129 |
+
) -> Tensor:
|
| 1130 |
+
"""Apply waveform-level augments in-place-free style.
|
| 1131 |
+
|
| 1132 |
+
Augments (each independently sampled, all training-only):
|
| 1133 |
+
- random gain: multiply by 10^(g/20), g ~ U[-x, +x] dB
|
| 1134 |
+
- channel dropout: zero one FOA channel with prob p
|
| 1135 |
+
- time mask: zero a contiguous waveform chunk (SpecAugment-like)
|
| 1136 |
+
- lowpass: first-order IIR lowpass with random cutoff
|
| 1137 |
+
|
| 1138 |
+
Args:
|
| 1139 |
+
waveform: [C, N] tensor (C=4 for FOA, N=time samples)
|
| 1140 |
+
config: SpatialDatasetConfig with augment flags
|
| 1141 |
+
|
| 1142 |
+
Returns:
|
| 1143 |
+
Augmented waveform, same shape/dtype/device as input.
|
| 1144 |
+
"""
|
| 1145 |
+
if waveform.ndim != 2:
|
| 1146 |
+
return waveform
|
| 1147 |
+
import random
|
| 1148 |
+
|
| 1149 |
+
# --- random gain ---
|
| 1150 |
+
if config.random_gain_db > 0.0:
|
| 1151 |
+
g_db = (random.random() * 2.0 - 1.0) * float(config.random_gain_db)
|
| 1152 |
+
gain = 10.0 ** (g_db / 20.0)
|
| 1153 |
+
waveform = waveform * gain
|
| 1154 |
+
|
| 1155 |
+
# --- channel dropout ---
|
| 1156 |
+
if config.channel_dropout_prob > 0.0 and random.random() < config.channel_dropout_prob:
|
| 1157 |
+
C = waveform.size(0)
|
| 1158 |
+
if C > 1:
|
| 1159 |
+
idx = random.randint(0, C - 1)
|
| 1160 |
+
mask = torch.ones(C, 1, dtype=waveform.dtype, device=waveform.device)
|
| 1161 |
+
mask[idx] = 0.0
|
| 1162 |
+
waveform = waveform * mask
|
| 1163 |
+
|
| 1164 |
+
# --- time mask (SpecAugment-equivalent on waveform) ---
|
| 1165 |
+
if config.use_spec_augment and config.spec_augment_time_mask_ratio > 0.0:
|
| 1166 |
+
N = waveform.size(-1)
|
| 1167 |
+
for _ in range(max(1, int(config.spec_augment_num_time_stripes))):
|
| 1168 |
+
max_len = int(float(config.spec_augment_time_mask_ratio) * N)
|
| 1169 |
+
if max_len < 1:
|
| 1170 |
+
break
|
| 1171 |
+
mask_len = random.randint(1, max_len)
|
| 1172 |
+
if mask_len >= N:
|
| 1173 |
+
continue
|
| 1174 |
+
start = random.randint(0, N - mask_len)
|
| 1175 |
+
waveform = waveform.clone()
|
| 1176 |
+
waveform[:, start : start + mask_len] = 0.0
|
| 1177 |
+
|
| 1178 |
+
# --- lowpass (first-order IIR) ---
|
| 1179 |
+
if config.lowpass_sim_real_prob > 0.0 and random.random() < config.lowpass_sim_real_prob:
|
| 1180 |
+
cutoff = random.uniform(
|
| 1181 |
+
float(config.lowpass_cutoff_min_hz),
|
| 1182 |
+
float(config.lowpass_cutoff_max_hz),
|
| 1183 |
+
)
|
| 1184 |
+
# First-order IIR: y[n] = a * y[n-1] + (1-a) * x[n]
|
| 1185 |
+
# a = exp(-2*pi*cutoff/sr). Assume sr=16000.
|
| 1186 |
+
import math
|
| 1187 |
+
sr = 16000.0
|
| 1188 |
+
a = math.exp(-2.0 * math.pi * cutoff / sr)
|
| 1189 |
+
# Apply per-channel via torch.cumulative IIR using torch.lfilter-like.
|
| 1190 |
+
# Fallback: simple loop is OK for short clips (<=10s, 160k samples).
|
| 1191 |
+
# For speed we use torchaudio.functional.lfilter when available.
|
| 1192 |
+
try:
|
| 1193 |
+
import torchaudio.functional as TAF
|
| 1194 |
+
b_coeffs = torch.tensor([1.0 - a, 0.0], dtype=waveform.dtype, device=waveform.device)
|
| 1195 |
+
a_coeffs = torch.tensor([1.0, -a], dtype=waveform.dtype, device=waveform.device)
|
| 1196 |
+
waveform = TAF.lfilter(waveform, a_coeffs, b_coeffs, clamp=False)
|
| 1197 |
+
except Exception:
|
| 1198 |
+
# Skip silently if torchaudio not available / lfilter fails
|
| 1199 |
+
pass
|
| 1200 |
+
|
| 1201 |
+
return waveform
|
| 1202 |
+
|
| 1203 |
+
|
| 1204 |
+
class SpatialDataset(Dataset):
|
| 1205 |
+
"""Base dataset interface for Spatial-BEATs.
|
| 1206 |
+
|
| 1207 |
+
A concrete implementation is expected to read manifest-style metadata and
|
| 1208 |
+
return SpatialSample objects with FOA waveform plus source-level labels.
|
| 1209 |
+
"""
|
| 1210 |
+
|
| 1211 |
+
def __init__(
|
| 1212 |
+
self,
|
| 1213 |
+
manifest_path: str,
|
| 1214 |
+
config: SpatialDatasetConfig,
|
| 1215 |
+
) -> None:
|
| 1216 |
+
super().__init__()
|
| 1217 |
+
self.manifest_path = Path(manifest_path)
|
| 1218 |
+
self.config = config
|
| 1219 |
+
if self.config.show_progress:
|
| 1220 |
+
tqdm.write(f"[SpatialDataset] Initialize from {self.manifest_path}")
|
| 1221 |
+
self.vocabulary = load_source_vocabulary(
|
| 1222 |
+
config.source_vocab,
|
| 1223 |
+
show_progress=self.config.show_progress,
|
| 1224 |
+
)
|
| 1225 |
+
entries = _load_manifest_entries(
|
| 1226 |
+
self.manifest_path,
|
| 1227 |
+
show_progress=self.config.show_progress,
|
| 1228 |
+
)
|
| 1229 |
+
if config.allowed_splits is not None:
|
| 1230 |
+
allowed = set(config.allowed_splits)
|
| 1231 |
+
entries = [
|
| 1232 |
+
entry
|
| 1233 |
+
for entry in tqdm(
|
| 1234 |
+
entries,
|
| 1235 |
+
total=len(entries),
|
| 1236 |
+
desc=f"Filter splits {self.manifest_path.name}",
|
| 1237 |
+
leave=False,
|
| 1238 |
+
disable=not self.config.show_progress,
|
| 1239 |
+
)
|
| 1240 |
+
if entry.get("split") in allowed
|
| 1241 |
+
]
|
| 1242 |
+
valid_entries = []
|
| 1243 |
+
dropped_invalid_geometry = 0
|
| 1244 |
+
for entry in tqdm(
|
| 1245 |
+
entries,
|
| 1246 |
+
total=len(entries),
|
| 1247 |
+
desc=f"Validate geometry {self.manifest_path.name}",
|
| 1248 |
+
leave=False,
|
| 1249 |
+
disable=not self.config.show_progress,
|
| 1250 |
+
):
|
| 1251 |
+
if _entry_has_valid_geometry(entry):
|
| 1252 |
+
valid_entries.append(entry)
|
| 1253 |
+
else:
|
| 1254 |
+
dropped_invalid_geometry += 1
|
| 1255 |
+
entries = valid_entries
|
| 1256 |
+
self.entries = entries
|
| 1257 |
+
if self.config.show_progress:
|
| 1258 |
+
tqdm.write(
|
| 1259 |
+
f"[SpatialDataset] {self.manifest_path.name}: "
|
| 1260 |
+
f"{len(self.entries)} entries after split/geometry filtering"
|
| 1261 |
+
)
|
| 1262 |
+
if dropped_invalid_geometry:
|
| 1263 |
+
tqdm.write(
|
| 1264 |
+
f"[SpatialDataset] {self.manifest_path.name}: "
|
| 1265 |
+
f"dropped {dropped_invalid_geometry} entries with missing DOA geometry"
|
| 1266 |
+
)
|
| 1267 |
+
|
| 1268 |
+
# v13_B [B-5]: enable augment only on training splits.
|
| 1269 |
+
_splits = config.allowed_splits or ()
|
| 1270 |
+
self._is_train_split = ("train" in set(_splits))
|
| 1271 |
+
self._augment_enabled = bool(
|
| 1272 |
+
self._is_train_split
|
| 1273 |
+
and (
|
| 1274 |
+
config.use_spec_augment
|
| 1275 |
+
or config.random_gain_db > 0.0
|
| 1276 |
+
or config.channel_dropout_prob > 0.0
|
| 1277 |
+
or config.lowpass_sim_real_prob > 0.0
|
| 1278 |
+
)
|
| 1279 |
+
)
|
| 1280 |
+
|
| 1281 |
+
def __len__(self) -> int:
|
| 1282 |
+
"""Return the number of samples in the dataset."""
|
| 1283 |
+
return len(self.entries)
|
| 1284 |
+
|
| 1285 |
+
def __getitem__(self, index: int) -> SpatialSample:
|
| 1286 |
+
"""Load one FOA clip and its source-level annotations.
|
| 1287 |
+
|
| 1288 |
+
Returns:
|
| 1289 |
+
SpatialSample:
|
| 1290 |
+
The uncollated sample object described above.
|
| 1291 |
+
"""
|
| 1292 |
+
entry = self.entries[index]
|
| 1293 |
+
# Resolve waveform path: support top-level fields and the new
|
| 1294 |
+
# spatial_foa_scene_v1 layout where it lives under entry["audio"]["foa_path"].
|
| 1295 |
+
waveform_path = (
|
| 1296 |
+
entry.get("output_foa_path")
|
| 1297 |
+
or entry.get("waveform_path")
|
| 1298 |
+
or entry.get("audio_path")
|
| 1299 |
+
or entry.get("foa_path")
|
| 1300 |
+
)
|
| 1301 |
+
if waveform_path is None:
|
| 1302 |
+
audio_meta = entry.get("audio")
|
| 1303 |
+
if isinstance(audio_meta, dict):
|
| 1304 |
+
waveform_path = audio_meta.get("foa_path") or audio_meta.get("path")
|
| 1305 |
+
if waveform_path is None:
|
| 1306 |
+
raise KeyError(
|
| 1307 |
+
"Manifest entry must contain output_foa_path/waveform_path/audio_path/"
|
| 1308 |
+
"foa_path or audio.foa_path."
|
| 1309 |
+
)
|
| 1310 |
+
waveform = _load_audio_file(str(waveform_path), self.config.mel_config.sample_rate)
|
| 1311 |
+
|
| 1312 |
+
clip_duration_seconds = entry.get("clip_duration_seconds")
|
| 1313 |
+
if clip_duration_seconds is None:
|
| 1314 |
+
clip_duration_seconds = entry.get("output_duration_seconds")
|
| 1315 |
+
if clip_duration_seconds is None:
|
| 1316 |
+
clip_duration_seconds = entry.get("duration")
|
| 1317 |
+
# New unified dataset: duration under audio.duration_seconds
|
| 1318 |
+
if clip_duration_seconds is None:
|
| 1319 |
+
audio_meta = entry.get("audio")
|
| 1320 |
+
if isinstance(audio_meta, dict):
|
| 1321 |
+
clip_duration_seconds = audio_meta.get("duration_seconds")
|
| 1322 |
+
if clip_duration_seconds is None:
|
| 1323 |
+
clip_duration_seconds = float(waveform.size(-1)) / float(self.config.mel_config.sample_rate)
|
| 1324 |
+
|
| 1325 |
+
sources: List[SourceEvent] = []
|
| 1326 |
+
if "sources" in entry and isinstance(entry["sources"], list):
|
| 1327 |
+
# Clip-level frame_rate (e.g. qa_moving.jsonl has 25) is carried on
|
| 1328 |
+
# the top-level record, not per source. Inject it into the source
|
| 1329 |
+
# entry so ``_parse_frame_trajectory`` can recover the time axis.
|
| 1330 |
+
clip_frame_rate = entry.get("frame_rate")
|
| 1331 |
+
for source_entry in entry["sources"]:
|
| 1332 |
+
source_entry_with_rate = source_entry
|
| 1333 |
+
if clip_frame_rate is not None and isinstance(source_entry, dict):
|
| 1334 |
+
source_entry_with_rate = {**source_entry, "frame_rate": source_entry.get("frame_rate", clip_frame_rate)}
|
| 1335 |
+
sources.append(
|
| 1336 |
+
_build_source_event_from_nested_entry(
|
| 1337 |
+
source_entry=source_entry_with_rate,
|
| 1338 |
+
vocabulary=self.vocabulary,
|
| 1339 |
+
clip_duration_seconds=float(clip_duration_seconds),
|
| 1340 |
+
)
|
| 1341 |
+
)
|
| 1342 |
+
else:
|
| 1343 |
+
sources.append(
|
| 1344 |
+
_build_source_event_from_top_level_entry(
|
| 1345 |
+
entry=entry,
|
| 1346 |
+
vocabulary=self.vocabulary,
|
| 1347 |
+
)
|
| 1348 |
+
)
|
| 1349 |
+
|
| 1350 |
+
waveform, clip_duration_seconds, sources = _maybe_crop_sample(
|
| 1351 |
+
waveform=waveform,
|
| 1352 |
+
clip_duration_seconds=float(clip_duration_seconds),
|
| 1353 |
+
sources=sources,
|
| 1354 |
+
sample_rate=self.config.mel_config.sample_rate,
|
| 1355 |
+
max_clip_duration_seconds=self.config.max_clip_duration_seconds,
|
| 1356 |
+
crop_mode=self.config.crop_mode,
|
| 1357 |
+
min_crop_duration_seconds=self.config.min_crop_duration_seconds,
|
| 1358 |
+
)
|
| 1359 |
+
|
| 1360 |
+
# v13_B [B-5]: waveform-level augment (training only)
|
| 1361 |
+
if self._augment_enabled:
|
| 1362 |
+
waveform = _apply_waveform_augment(waveform, self.config)
|
| 1363 |
+
|
| 1364 |
+
sample_id = str(entry.get("scene_id", entry.get("pair_id", entry.get("sample_id", entry.get("id", index)))))
|
| 1365 |
+
return SpatialSample(
|
| 1366 |
+
sample_id=sample_id,
|
| 1367 |
+
waveform=waveform,
|
| 1368 |
+
clip_duration_seconds=float(clip_duration_seconds),
|
| 1369 |
+
sources=sources,
|
| 1370 |
+
)
|
| 1371 |
+
|
| 1372 |
+
|
| 1373 |
+
def _linear_interp_1d(
|
| 1374 |
+
query_t: Tensor,
|
| 1375 |
+
keys_t: Tensor,
|
| 1376 |
+
values: Tensor,
|
| 1377 |
+
) -> Tensor:
|
| 1378 |
+
"""Linear interpolation of ``values`` sampled at ``keys_t`` onto ``query_t``.
|
| 1379 |
+
|
| 1380 |
+
Edge handling: queries before/after the keyframe range clamp to the nearest
|
| 1381 |
+
endpoint (common for dynamic sources whose active_time is a superset of
|
| 1382 |
+
their trajectory support — e.g. qa_moving records cover the full 4 s even
|
| 1383 |
+
when the wav is longer after padding).
|
| 1384 |
+
|
| 1385 |
+
Args:
|
| 1386 |
+
query_t: [Q] query timestamps in seconds, expected sorted ascending.
|
| 1387 |
+
keys_t: [K] key timestamps in seconds, sorted ascending (K >= 1).
|
| 1388 |
+
values: [K] values aligned with ``keys_t``.
|
| 1389 |
+
|
| 1390 |
+
Returns:
|
| 1391 |
+
Tensor of shape [Q] containing interpolated values.
|
| 1392 |
+
"""
|
| 1393 |
+
if keys_t.numel() == 1:
|
| 1394 |
+
return values[0].expand_as(query_t).clone()
|
| 1395 |
+
# torch.searchsorted returns indices such that keys_t[idx-1] <= q < keys_t[idx].
|
| 1396 |
+
idx_right = torch.searchsorted(keys_t, query_t, right=True).clamp(1, keys_t.numel() - 1)
|
| 1397 |
+
idx_left = idx_right - 1
|
| 1398 |
+
kl = keys_t[idx_left]
|
| 1399 |
+
kr = keys_t[idx_right]
|
| 1400 |
+
# Avoid divide-by-zero for repeated keys (degenerate).
|
| 1401 |
+
span = (kr - kl).clamp_min(1e-9)
|
| 1402 |
+
w = ((query_t - kl) / span).clamp(0.0, 1.0)
|
| 1403 |
+
vl = values[idx_left]
|
| 1404 |
+
vr = values[idx_right]
|
| 1405 |
+
return vl + (vr - vl) * w
|
| 1406 |
+
|
| 1407 |
+
|
| 1408 |
+
def _linear_interp_valid_mask(
|
| 1409 |
+
query_t: Tensor,
|
| 1410 |
+
keys_t: Tensor,
|
| 1411 |
+
valid_mask: Tensor,
|
| 1412 |
+
) -> Tensor:
|
| 1413 |
+
"""Piecewise-constant resampling of a boolean validity mask.
|
| 1414 |
+
|
| 1415 |
+
For interpolation points, a query is valid only if both of its surrounding
|
| 1416 |
+
keyframes are valid — this prevents inferring a "valid" distance in a
|
| 1417 |
+
segment where at least one endpoint was unknown.
|
| 1418 |
+
"""
|
| 1419 |
+
if keys_t.numel() == 1:
|
| 1420 |
+
return valid_mask[0].expand_as(query_t).clone()
|
| 1421 |
+
idx_right = torch.searchsorted(keys_t, query_t, right=True).clamp(1, keys_t.numel() - 1)
|
| 1422 |
+
idx_left = idx_right - 1
|
| 1423 |
+
return valid_mask[idx_left] & valid_mask[idx_right]
|
| 1424 |
+
|
| 1425 |
+
|
| 1426 |
+
def _build_per_frame_targets(
|
| 1427 |
+
source: "SourceEvent",
|
| 1428 |
+
t_axis: Tensor,
|
| 1429 |
+
t_s_max: int,
|
| 1430 |
+
) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
|
| 1431 |
+
"""Build [T_s_max] per-frame (azi, ele, dist, dist_valid, ele_sign_only) rows for one source.
|
| 1432 |
+
|
| 1433 |
+
- Indices [0, T_s_i) hold the real per-step targets:
|
| 1434 |
+
* Static source (no ``frame_times_s``): scalar broadcast.
|
| 1435 |
+
* Dynamic source: linear interpolation of the trajectory onto ``t_axis``.
|
| 1436 |
+
- Indices [T_s_i, T_s_max) hold zeros (padding beyond the sample's valid
|
| 1437 |
+
time range; loss masks out these steps via ``source_valid_mask`` combined
|
| 1438 |
+
with ``window_mask``).
|
| 1439 |
+
|
| 1440 |
+
The source's ``active_time`` window is **not** applied here — the window
|
| 1441 |
+
mask is applied separately by the loss layer. That keeps this function
|
| 1442 |
+
purely a per-frame target builder.
|
| 1443 |
+
|
| 1444 |
+
Returns (azi_row, ele_row, dist_row, dist_valid_row, ele_sign_only_row).
|
| 1445 |
+
"""
|
| 1446 |
+
t_s_i = t_axis.numel()
|
| 1447 |
+
azi_row = torch.zeros(t_s_max, dtype=torch.float32)
|
| 1448 |
+
ele_row = torch.zeros(t_s_max, dtype=torch.float32)
|
| 1449 |
+
dist_row = torch.zeros(t_s_max, dtype=torch.float32)
|
| 1450 |
+
dist_valid_row = torch.zeros(t_s_max, dtype=torch.bool)
|
| 1451 |
+
ele_sign_only_row = torch.zeros(t_s_max, dtype=torch.bool)
|
| 1452 |
+
|
| 1453 |
+
if source.frame_times_s is None or source.frame_times_s.numel() == 0:
|
| 1454 |
+
# Static fallback: broadcast the scalar over the sample's valid axis.
|
| 1455 |
+
azi_row[:t_s_i] = float(source.azimuth_deg)
|
| 1456 |
+
ele_row[:t_s_i] = float(source.elevation_deg)
|
| 1457 |
+
dist_row[:t_s_i] = float(source.distance)
|
| 1458 |
+
dist_valid_row[:t_s_i] = bool(source.distance_valid)
|
| 1459 |
+
# Static sources never carry sign-only elevation
|
| 1460 |
+
# ele_sign_only_row stays False
|
| 1461 |
+
return azi_row, ele_row, dist_row, dist_valid_row, ele_sign_only_row
|
| 1462 |
+
|
| 1463 |
+
# Dynamic: interpolate frames to the model time axis.
|
| 1464 |
+
keys_t = source.frame_times_s
|
| 1465 |
+
azi_vals = source.frame_azi_deg
|
| 1466 |
+
ele_vals = source.frame_ele_deg
|
| 1467 |
+
# Azimuth lives on a circle; linear interp can wrap badly across the
|
| 1468 |
+
# ±180° boundary. Unwrap keyframes first so we interpolate on the
|
| 1469 |
+
# continuous axis, then wrap the output back to [-180, 180].
|
| 1470 |
+
azi_unwrapped = _unwrap_deg(azi_vals)
|
| 1471 |
+
azi_interp = _linear_interp_1d(t_axis, keys_t, azi_unwrapped)
|
| 1472 |
+
# Wrap to [-180, 180] to match GT convention everywhere else.
|
| 1473 |
+
azi_interp = ((azi_interp + 180.0) % 360.0) - 180.0
|
| 1474 |
+
ele_interp = _linear_interp_1d(t_axis, keys_t, ele_vals)
|
| 1475 |
+
azi_row[:t_s_i] = azi_interp
|
| 1476 |
+
ele_row[:t_s_i] = ele_interp
|
| 1477 |
+
|
| 1478 |
+
if source.frame_distance_m is not None and source.frame_distance_valid is not None:
|
| 1479 |
+
dist_interp = _linear_interp_1d(t_axis, keys_t, source.frame_distance_m)
|
| 1480 |
+
dist_valid_interp = _linear_interp_valid_mask(t_axis, keys_t, source.frame_distance_valid)
|
| 1481 |
+
dist_row[:t_s_i] = dist_interp
|
| 1482 |
+
dist_valid_row[:t_s_i] = dist_valid_interp
|
| 1483 |
+
else:
|
| 1484 |
+
dist_row[:t_s_i] = float(source.distance)
|
| 1485 |
+
dist_valid_row[:t_s_i] = bool(source.distance_valid)
|
| 1486 |
+
|
| 1487 |
+
# ele_sign_only: nearest-neighbour resample of per-frame boolean mask.
|
| 1488 |
+
# A query inherits the sign-only flag of the nearest keyframe.
|
| 1489 |
+
if source.frame_ele_sign_only is not None:
|
| 1490 |
+
sign_only_interp = _linear_interp_nearest_bool(t_axis, keys_t, source.frame_ele_sign_only)
|
| 1491 |
+
ele_sign_only_row[:t_s_i] = sign_only_interp
|
| 1492 |
+
|
| 1493 |
+
return azi_row, ele_row, dist_row, dist_valid_row, ele_sign_only_row
|
| 1494 |
+
|
| 1495 |
+
|
| 1496 |
+
def _linear_interp_nearest_bool(
|
| 1497 |
+
query_t: Tensor,
|
| 1498 |
+
keys_t: Tensor,
|
| 1499 |
+
bool_values: Tensor,
|
| 1500 |
+
) -> Tensor:
|
| 1501 |
+
"""Nearest-neighbour resampling of a boolean 1-D mask.
|
| 1502 |
+
|
| 1503 |
+
Each query timestamp inherits the value of the nearest keyframe.
|
| 1504 |
+
"""
|
| 1505 |
+
if keys_t.numel() == 1:
|
| 1506 |
+
return bool_values[0].expand_as(query_t).clone()
|
| 1507 |
+
# Use searchsorted to find bracket, then pick the closer neighbour.
|
| 1508 |
+
idx_right = torch.searchsorted(keys_t, query_t, right=True).clamp(0, keys_t.numel() - 1)
|
| 1509 |
+
idx_left = (idx_right - 1).clamp(0)
|
| 1510 |
+
# For each query pick the nearer key.
|
| 1511 |
+
kl = keys_t[idx_left]
|
| 1512 |
+
kr = keys_t[idx_right]
|
| 1513 |
+
# If left is closer (or equal), use left; otherwise right.
|
| 1514 |
+
use_left = (query_t - kl) <= (kr - query_t)
|
| 1515 |
+
idx = torch.where(use_left, idx_left, idx_right)
|
| 1516 |
+
return bool_values[idx]
|
| 1517 |
+
|
| 1518 |
+
|
| 1519 |
+
def _unwrap_deg(azi_deg: Tensor) -> Tensor:
|
| 1520 |
+
"""Unwrap a [N] azimuth trajectory so linear interp doesn't jump ±180°.
|
| 1521 |
+
|
| 1522 |
+
Trajectories in qa_moving and DCASE are continuous but labels are wrapped
|
| 1523 |
+
to [-180, 180]; a source moving from 170° to -170° actually swept 20°
|
| 1524 |
+
across the back, not -340°. Detect and remove the 360° discontinuities.
|
| 1525 |
+
"""
|
| 1526 |
+
if azi_deg.numel() <= 1:
|
| 1527 |
+
return azi_deg
|
| 1528 |
+
d = torch.diff(azi_deg)
|
| 1529 |
+
# Jumps > 180° are unwrap artifacts; add multiples of 360° to cancel them.
|
| 1530 |
+
jumps = torch.zeros_like(d)
|
| 1531 |
+
jumps[d > 180.0] = -360.0
|
| 1532 |
+
jumps[d < -180.0] = 360.0
|
| 1533 |
+
offsets = torch.cat([torch.zeros(1), torch.cumsum(jumps, dim=0)])
|
| 1534 |
+
return azi_deg + offsets
|
| 1535 |
+
|
| 1536 |
+
|
| 1537 |
+
def collate_spatial_batch(
|
| 1538 |
+
samples: Sequence[SpatialSample],
|
| 1539 |
+
config: SpatialDatasetConfig,
|
| 1540 |
+
) -> SpatialBatch:
|
| 1541 |
+
"""Collate variable-length SpatialSample objects into a padded batch.
|
| 1542 |
+
|
| 1543 |
+
Responsibilities:
|
| 1544 |
+
- Pad raw FOA waveforms to T_max_wave
|
| 1545 |
+
- Build waveform_padding_mask
|
| 1546 |
+
- Pad source annotations to N_gt_max
|
| 1547 |
+
- Compute target_num_steps = round(duration_i * target_token_rate)
|
| 1548 |
+
- Keep sample_ids and optional class label strings for debugging
|
| 1549 |
+
|
| 1550 |
+
Args:
|
| 1551 |
+
samples:
|
| 1552 |
+
Sequence of SpatialSample objects.
|
| 1553 |
+
config:
|
| 1554 |
+
Dataset configuration with vocabulary and token-rate settings.
|
| 1555 |
+
|
| 1556 |
+
Returns:
|
| 1557 |
+
SpatialBatch:
|
| 1558 |
+
Batch object consumed by the model, dataset utilities, and loss code.
|
| 1559 |
+
"""
|
| 1560 |
+
if len(samples) == 0:
|
| 1561 |
+
raise ValueError("collate_spatial_batch received an empty sample list.")
|
| 1562 |
+
|
| 1563 |
+
batch_size = len(samples)
|
| 1564 |
+
max_wave_len = max(sample.waveform.size(-1) for sample in samples)
|
| 1565 |
+
waveform = torch.full(
|
| 1566 |
+
(batch_size, 4, max_wave_len),
|
| 1567 |
+
fill_value=float(config.padding_value),
|
| 1568 |
+
dtype=torch.float32,
|
| 1569 |
+
)
|
| 1570 |
+
waveform_padding_mask = torch.ones(batch_size, max_wave_len, dtype=torch.bool)
|
| 1571 |
+
|
| 1572 |
+
clip_duration_seconds = torch.tensor(
|
| 1573 |
+
[sample.clip_duration_seconds for sample in samples],
|
| 1574 |
+
dtype=torch.float32,
|
| 1575 |
+
)
|
| 1576 |
+
target_num_steps = compute_target_num_steps(
|
| 1577 |
+
clip_duration_seconds=clip_duration_seconds,
|
| 1578 |
+
target_token_rate=config.target_token_rate,
|
| 1579 |
+
)
|
| 1580 |
+
|
| 1581 |
+
max_num_sources = max(max(len(sample.sources), 1) for sample in samples)
|
| 1582 |
+
# Per-frame target tensors: shape [B, N_gt_max, T_s_max]. For static
|
| 1583 |
+
# sources, the scalar is broadcast along the T_s axis (identical behaviour
|
| 1584 |
+
# to the legacy [B, N_gt_max] tensors). For dynamic sources (frames[] in
|
| 1585 |
+
# the manifest), values are resampled onto the model's token-rate grid by
|
| 1586 |
+
# linear interpolation.
|
| 1587 |
+
t_s_max = int(target_num_steps.max().item()) if batch_size > 0 else 1
|
| 1588 |
+
t_s_max = max(t_s_max, 1)
|
| 1589 |
+
target_token_rate = float(config.target_token_rate)
|
| 1590 |
+
|
| 1591 |
+
source_class_indices = torch.zeros(batch_size, max_num_sources, dtype=torch.long)
|
| 1592 |
+
source_azimuth_deg = torch.zeros(batch_size, max_num_sources, t_s_max, dtype=torch.float32)
|
| 1593 |
+
source_elevation_deg = torch.zeros(batch_size, max_num_sources, t_s_max, dtype=torch.float32)
|
| 1594 |
+
source_distance = torch.zeros(batch_size, max_num_sources, t_s_max, dtype=torch.float32)
|
| 1595 |
+
# Default True; flipped to False for null-distance sources or frames.
|
| 1596 |
+
source_distance_valid = torch.ones(batch_size, max_num_sources, t_s_max, dtype=torch.bool)
|
| 1597 |
+
# Default False; True for frames where only the hemisphere (sign) is known.
|
| 1598 |
+
source_ele_sign_only = torch.zeros(batch_size, max_num_sources, t_s_max, dtype=torch.bool)
|
| 1599 |
+
source_start_time_seconds = torch.zeros(batch_size, max_num_sources, dtype=torch.float32)
|
| 1600 |
+
source_end_time_seconds = torch.zeros(batch_size, max_num_sources, dtype=torch.float32)
|
| 1601 |
+
source_valid_mask = torch.zeros(batch_size, max_num_sources, dtype=torch.bool)
|
| 1602 |
+
|
| 1603 |
+
sample_ids: List[str] = []
|
| 1604 |
+
source_class_labels: List[List[str]] = []
|
| 1605 |
+
|
| 1606 |
+
for batch_index, sample in enumerate(samples):
|
| 1607 |
+
length = sample.waveform.size(-1)
|
| 1608 |
+
waveform[batch_index, :, :length] = sample.waveform
|
| 1609 |
+
waveform_padding_mask[batch_index, :length] = False
|
| 1610 |
+
sample_ids.append(sample.sample_id)
|
| 1611 |
+
|
| 1612 |
+
# Per-sample model time axis: first T_s_i time steps at target_token_rate.
|
| 1613 |
+
t_s_i = int(target_num_steps[batch_index].item())
|
| 1614 |
+
t_s_i = max(t_s_i, 1)
|
| 1615 |
+
if target_token_rate > 0:
|
| 1616 |
+
t_axis = torch.arange(t_s_i, dtype=torch.float32) / float(target_token_rate)
|
| 1617 |
+
else:
|
| 1618 |
+
t_axis = torch.zeros(t_s_i, dtype=torch.float32)
|
| 1619 |
+
|
| 1620 |
+
label_names: List[str] = []
|
| 1621 |
+
for source_index, source in enumerate(sample.sources):
|
| 1622 |
+
source_class_indices[batch_index, source_index] = int(source.class_index)
|
| 1623 |
+
source_start_time_seconds[batch_index, source_index] = float(source.start_time_seconds)
|
| 1624 |
+
source_end_time_seconds[batch_index, source_index] = float(source.end_time_seconds)
|
| 1625 |
+
source_valid_mask[batch_index, source_index] = True
|
| 1626 |
+
label_names.append(source.class_label)
|
| 1627 |
+
|
| 1628 |
+
# Fill per-frame DOA/distance targets.
|
| 1629 |
+
azi_row, ele_row, dist_row, dist_valid_row, ele_sign_only_row = _build_per_frame_targets(
|
| 1630 |
+
source=source,
|
| 1631 |
+
t_axis=t_axis,
|
| 1632 |
+
t_s_max=t_s_max,
|
| 1633 |
+
)
|
| 1634 |
+
source_azimuth_deg[batch_index, source_index] = azi_row
|
| 1635 |
+
source_elevation_deg[batch_index, source_index] = ele_row
|
| 1636 |
+
source_distance[batch_index, source_index] = dist_row
|
| 1637 |
+
source_distance_valid[batch_index, source_index] = dist_valid_row
|
| 1638 |
+
source_ele_sign_only[batch_index, source_index] = ele_sign_only_row
|
| 1639 |
+
source_class_labels.append(label_names)
|
| 1640 |
+
|
| 1641 |
+
return SpatialBatch(
|
| 1642 |
+
waveform=waveform,
|
| 1643 |
+
waveform_padding_mask=waveform_padding_mask,
|
| 1644 |
+
clip_duration_seconds=clip_duration_seconds,
|
| 1645 |
+
target_num_steps=target_num_steps,
|
| 1646 |
+
source_class_indices=source_class_indices,
|
| 1647 |
+
source_azimuth_deg=source_azimuth_deg,
|
| 1648 |
+
source_elevation_deg=source_elevation_deg,
|
| 1649 |
+
source_distance=source_distance,
|
| 1650 |
+
source_distance_valid=source_distance_valid,
|
| 1651 |
+
source_ele_sign_only=source_ele_sign_only,
|
| 1652 |
+
source_start_time_seconds=source_start_time_seconds,
|
| 1653 |
+
source_end_time_seconds=source_end_time_seconds,
|
| 1654 |
+
source_valid_mask=source_valid_mask,
|
| 1655 |
+
sample_ids=sample_ids,
|
| 1656 |
+
source_class_labels=source_class_labels,
|
| 1657 |
+
)
|
spatial_loss.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
train_beats_event_classifier.py
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""W-channel BEATs event-classification baseline for simulated FOA manifests.
|
| 3 |
+
|
| 4 |
+
This script intentionally avoids the Spatial-BEATs spatial adapters/readouts. It
|
| 5 |
+
answers one question: can pretrained BEATs classify the event label on this data
|
| 6 |
+
when using only the FOA W channel and BEATs' original Kaldi fbank front-end?
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import csv
|
| 13 |
+
import json
|
| 14 |
+
import math
|
| 15 |
+
import os
|
| 16 |
+
import random
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Iterable
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
import torch.distributed as dist
|
| 23 |
+
import torch.nn as nn
|
| 24 |
+
import torch.nn.functional as F
|
| 25 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 26 |
+
from torch.utils.data import DataLoader, Dataset
|
| 27 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
from tqdm.auto import tqdm
|
| 31 |
+
except Exception: # pragma: no cover
|
| 32 |
+
tqdm = None
|
| 33 |
+
|
| 34 |
+
from BEATs import BEATs, BEATsConfig
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
DEFAULT_OV1_MANIFEST = (
|
| 38 |
+
"/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl"
|
| 39 |
+
)
|
| 40 |
+
DEFAULT_VOCAB = (
|
| 41 |
+
"/apdcephfs_cq12/share_302080740/user/schmittzhu/data/fsd50k/"
|
| 42 |
+
"FSD50K.ground_truth/final_vocabulary.csv"
|
| 43 |
+
)
|
| 44 |
+
DEFAULT_BEATS_CKPT = (
|
| 45 |
+
"pretrain_ckpt/BEATs_iter3_plus_AS2M.pt/BEATs_iter3_plus_AS2M.pt"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def is_dist() -> bool:
|
| 50 |
+
return dist.is_available() and dist.is_initialized()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def get_rank() -> int:
|
| 54 |
+
return dist.get_rank() if is_dist() else 0
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_world_size() -> int:
|
| 58 |
+
return dist.get_world_size() if is_dist() else 1
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def is_main() -> bool:
|
| 62 |
+
return get_rank() == 0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def log(msg: str) -> None:
|
| 66 |
+
if is_main():
|
| 67 |
+
print(msg, flush=True)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def init_distributed() -> tuple[bool, int]:
|
| 71 |
+
if "RANK" not in os.environ or "WORLD_SIZE" not in os.environ:
|
| 72 |
+
return False, 0
|
| 73 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 74 |
+
torch.cuda.set_device(local_rank)
|
| 75 |
+
dist.init_process_group(backend="nccl")
|
| 76 |
+
return True, local_rank
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def slugify(text: Any) -> str:
|
| 80 |
+
return str(text).strip().lower().replace(" ", "_").replace("-", "_")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def load_vocab(vocab_path: str | Path) -> dict[str, int]:
|
| 84 |
+
"""Build a permissive label-name -> class-index map from final_vocabulary.csv."""
|
| 85 |
+
|
| 86 |
+
vocab_path = Path(vocab_path)
|
| 87 |
+
mapping: dict[str, int] = {}
|
| 88 |
+
with vocab_path.open(newline="", encoding="utf-8") as f:
|
| 89 |
+
reader = csv.DictReader(f)
|
| 90 |
+
for row_idx, row in enumerate(reader):
|
| 91 |
+
idx = row_idx
|
| 92 |
+
for key in ("index", "idx", "class_index", "id"):
|
| 93 |
+
val = row.get(key)
|
| 94 |
+
if val is not None and str(val).strip().isdigit():
|
| 95 |
+
idx = int(val)
|
| 96 |
+
break
|
| 97 |
+
for val in row.values():
|
| 98 |
+
if val is None:
|
| 99 |
+
continue
|
| 100 |
+
text = str(val).strip()
|
| 101 |
+
if not text:
|
| 102 |
+
continue
|
| 103 |
+
mapping[text] = idx
|
| 104 |
+
mapping[slugify(text)] = idx
|
| 105 |
+
if not mapping:
|
| 106 |
+
raise RuntimeError(f"Empty vocabulary mapping from {vocab_path}")
|
| 107 |
+
return mapping
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def infer_num_classes(vocab_path: str | Path) -> int:
|
| 111 |
+
max_idx = -1
|
| 112 |
+
with Path(vocab_path).open(newline="", encoding="utf-8") as f:
|
| 113 |
+
reader = csv.DictReader(f)
|
| 114 |
+
for row_idx, row in enumerate(reader):
|
| 115 |
+
idx = row_idx
|
| 116 |
+
for key in ("index", "idx", "class_index", "id"):
|
| 117 |
+
val = row.get(key)
|
| 118 |
+
if val is not None and str(val).strip().isdigit():
|
| 119 |
+
idx = int(val)
|
| 120 |
+
break
|
| 121 |
+
max_idx = max(max_idx, idx)
|
| 122 |
+
return max_idx + 1
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def first_present(entry: dict[str, Any], keys: Iterable[str]) -> Any | None:
|
| 126 |
+
for key in keys:
|
| 127 |
+
if key in entry and entry[key] not in (None, ""):
|
| 128 |
+
return entry[key]
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def extract_audio_path(entry: dict[str, Any]) -> str:
|
| 133 |
+
value = first_present(
|
| 134 |
+
entry,
|
| 135 |
+
(
|
| 136 |
+
"output_foa_path",
|
| 137 |
+
"foa_path",
|
| 138 |
+
"waveform_path",
|
| 139 |
+
"audio_path",
|
| 140 |
+
"path",
|
| 141 |
+
"wav_path",
|
| 142 |
+
),
|
| 143 |
+
)
|
| 144 |
+
if value is None:
|
| 145 |
+
raise KeyError(f"Cannot find FOA path field in entry keys={sorted(entry.keys())}")
|
| 146 |
+
return str(value)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def extract_label(entry: dict[str, Any]) -> Any:
|
| 150 |
+
value = first_present(
|
| 151 |
+
entry,
|
| 152 |
+
(
|
| 153 |
+
"mono_target_label",
|
| 154 |
+
"target_label",
|
| 155 |
+
"label",
|
| 156 |
+
"class_label",
|
| 157 |
+
"event_label",
|
| 158 |
+
"category",
|
| 159 |
+
),
|
| 160 |
+
)
|
| 161 |
+
if value is not None:
|
| 162 |
+
return value
|
| 163 |
+
sources = entry.get("sources")
|
| 164 |
+
if isinstance(sources, list) and sources:
|
| 165 |
+
return first_present(
|
| 166 |
+
sources[0],
|
| 167 |
+
(
|
| 168 |
+
"mono_target_label",
|
| 169 |
+
"target_label",
|
| 170 |
+
"label",
|
| 171 |
+
"class_label",
|
| 172 |
+
"event_label",
|
| 173 |
+
"category",
|
| 174 |
+
),
|
| 175 |
+
)
|
| 176 |
+
raise KeyError(f"Cannot find label field in entry keys={sorted(entry.keys())}")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def resolve_label_id(label: Any, vocab: dict[str, int]) -> int:
|
| 180 |
+
if isinstance(label, int):
|
| 181 |
+
return int(label)
|
| 182 |
+
if isinstance(label, float) and label.is_integer():
|
| 183 |
+
return int(label)
|
| 184 |
+
text = str(label).strip()
|
| 185 |
+
if text.isdigit():
|
| 186 |
+
return int(text)
|
| 187 |
+
for key in (text, slugify(text)):
|
| 188 |
+
if key in vocab:
|
| 189 |
+
return vocab[key]
|
| 190 |
+
raise KeyError(f"Label {label!r} not found in vocabulary")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def select_foa_input(data: torch.Tensor, channel_mode: str) -> torch.Tensor:
|
| 194 |
+
"""Select FOA input for the BEATs classification baseline.
|
| 195 |
+
|
| 196 |
+
The simulated files use DCASE/FUMA-style storage order [W, Y, Z, X].
|
| 197 |
+
Mono modes return [T]. ``foa4_fusion`` returns [4, T] in [W, X, Y, Z]
|
| 198 |
+
order for a learned fbank-level fusion layer.
|
| 199 |
+
"""
|
| 200 |
+
|
| 201 |
+
if data.ndim != 2:
|
| 202 |
+
raise RuntimeError(f"Expected [T, C] audio, got {tuple(data.shape)}")
|
| 203 |
+
if data.shape[1] == 1:
|
| 204 |
+
return data[:, 0]
|
| 205 |
+
if data.shape[1] < 4:
|
| 206 |
+
raise RuntimeError(f"Expected FOA [W,Y,Z,X], got shape {tuple(data.shape)}")
|
| 207 |
+
|
| 208 |
+
mode = channel_mode.lower()
|
| 209 |
+
if mode == "w":
|
| 210 |
+
return data[:, 0]
|
| 211 |
+
if mode == "y":
|
| 212 |
+
return data[:, 1]
|
| 213 |
+
if mode == "z":
|
| 214 |
+
return data[:, 2]
|
| 215 |
+
if mode == "x":
|
| 216 |
+
return data[:, 3]
|
| 217 |
+
if mode == "mean4":
|
| 218 |
+
return data[:, :4].mean(dim=1)
|
| 219 |
+
if mode == "sum4":
|
| 220 |
+
return data[:, :4].sum(dim=1) / 2.0
|
| 221 |
+
if mode == "foa4_fusion":
|
| 222 |
+
return torch.stack((data[:, 0], data[:, 3], data[:, 1], data[:, 2]), dim=0)
|
| 223 |
+
raise ValueError(
|
| 224 |
+
f"Unsupported channel_mode={channel_mode!r}; "
|
| 225 |
+
"expected one of w/y/z/x/mean4/sum4/foa4_fusion"
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def read_foa_as_mono(path: str | Path, channel_mode: str) -> tuple[torch.Tensor, int]:
|
| 230 |
+
"""Return one mono waveform in float32 [-1, 1]-style range."""
|
| 231 |
+
|
| 232 |
+
path = str(path)
|
| 233 |
+
try:
|
| 234 |
+
import soundfile as sf
|
| 235 |
+
|
| 236 |
+
data, sr = sf.read(path, dtype="float32", always_2d=True)
|
| 237 |
+
waveform = select_foa_input(torch.from_numpy(data.copy()), channel_mode)
|
| 238 |
+
return waveform, int(sr)
|
| 239 |
+
except Exception:
|
| 240 |
+
import torchaudio
|
| 241 |
+
|
| 242 |
+
wav, sr = torchaudio.load(path)
|
| 243 |
+
if wav.ndim != 2:
|
| 244 |
+
raise RuntimeError(f"Unexpected waveform shape from {path}: {tuple(wav.shape)}")
|
| 245 |
+
waveform = select_foa_input(wav.transpose(0, 1).float(), channel_mode)
|
| 246 |
+
return waveform, int(sr)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@dataclass
|
| 250 |
+
class EventSample:
|
| 251 |
+
audio_path: str
|
| 252 |
+
label_id: int
|
| 253 |
+
sample_id: str
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class FOAEventDataset(Dataset):
|
| 257 |
+
def __init__(
|
| 258 |
+
self,
|
| 259 |
+
manifest_paths: list[str],
|
| 260 |
+
vocab_path: str,
|
| 261 |
+
split: str,
|
| 262 |
+
max_duration: float = 20.0,
|
| 263 |
+
sample_rate: int = 16000,
|
| 264 |
+
channel_mode: str = "w",
|
| 265 |
+
seed: int = 0,
|
| 266 |
+
) -> None:
|
| 267 |
+
self.vocab = load_vocab(vocab_path)
|
| 268 |
+
self.sample_rate = sample_rate
|
| 269 |
+
self.channel_mode = channel_mode
|
| 270 |
+
self.max_samples = int(round(max_duration * sample_rate)) if max_duration else None
|
| 271 |
+
self.samples: list[EventSample] = []
|
| 272 |
+
rng = random.Random(seed)
|
| 273 |
+
|
| 274 |
+
for manifest_path in manifest_paths:
|
| 275 |
+
manifest_path = str(manifest_path)
|
| 276 |
+
with open(manifest_path, encoding="utf-8") as f:
|
| 277 |
+
for line_idx, line in enumerate(f):
|
| 278 |
+
if not line.strip():
|
| 279 |
+
continue
|
| 280 |
+
entry = json.loads(line)
|
| 281 |
+
entry_split = entry.get("split")
|
| 282 |
+
if split and entry_split != split:
|
| 283 |
+
continue
|
| 284 |
+
label = extract_label(entry)
|
| 285 |
+
label_id = resolve_label_id(label, self.vocab)
|
| 286 |
+
audio_path = extract_audio_path(entry)
|
| 287 |
+
sample_id = str(
|
| 288 |
+
entry.get("sample_id")
|
| 289 |
+
or entry.get("id")
|
| 290 |
+
or f"{Path(manifest_path).stem}:{line_idx}"
|
| 291 |
+
)
|
| 292 |
+
self.samples.append(EventSample(audio_path, label_id, sample_id))
|
| 293 |
+
rng.shuffle(self.samples)
|
| 294 |
+
if not self.samples:
|
| 295 |
+
raise RuntimeError(f"No samples found for split={split} in {manifest_paths}")
|
| 296 |
+
|
| 297 |
+
def __len__(self) -> int:
|
| 298 |
+
return len(self.samples)
|
| 299 |
+
|
| 300 |
+
def __getitem__(self, idx: int) -> dict[str, Any]:
|
| 301 |
+
item = self.samples[idx]
|
| 302 |
+
wav, sr = read_foa_as_mono(item.audio_path, self.channel_mode)
|
| 303 |
+
if sr != self.sample_rate:
|
| 304 |
+
raise RuntimeError(f"Expected {self.sample_rate} Hz, got {sr} Hz: {item.audio_path}")
|
| 305 |
+
length = wav.shape[-1]
|
| 306 |
+
if self.max_samples is not None and length > self.max_samples:
|
| 307 |
+
wav = wav[..., : self.max_samples]
|
| 308 |
+
length = self.max_samples
|
| 309 |
+
return {
|
| 310 |
+
"waveform": wav,
|
| 311 |
+
"label": torch.tensor(item.label_id, dtype=torch.long),
|
| 312 |
+
"length": torch.tensor(length, dtype=torch.long),
|
| 313 |
+
"sample_id": item.sample_id,
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def collate_batch(samples: list[dict[str, Any]]) -> dict[str, Any]:
|
| 318 |
+
max_len = max(int(s["length"]) for s in samples)
|
| 319 |
+
bsz = len(samples)
|
| 320 |
+
first_waveform = samples[0]["waveform"]
|
| 321 |
+
if first_waveform.ndim == 1:
|
| 322 |
+
waveforms = torch.zeros(bsz, max_len, dtype=torch.float32)
|
| 323 |
+
elif first_waveform.ndim == 2:
|
| 324 |
+
waveforms = torch.zeros(bsz, first_waveform.shape[0], max_len, dtype=torch.float32)
|
| 325 |
+
else:
|
| 326 |
+
raise RuntimeError(f"Unexpected waveform dim: {first_waveform.ndim}")
|
| 327 |
+
padding_mask = torch.ones(bsz, max_len, dtype=torch.bool)
|
| 328 |
+
labels = torch.empty(bsz, dtype=torch.long)
|
| 329 |
+
sample_ids: list[str] = []
|
| 330 |
+
for i, sample in enumerate(samples):
|
| 331 |
+
length = int(sample["length"])
|
| 332 |
+
if sample["waveform"].ndim == 1:
|
| 333 |
+
waveforms[i, :length] = sample["waveform"]
|
| 334 |
+
else:
|
| 335 |
+
waveforms[i, :, :length] = sample["waveform"]
|
| 336 |
+
padding_mask[i, :length] = False
|
| 337 |
+
labels[i] = sample["label"]
|
| 338 |
+
sample_ids.append(str(sample["sample_id"]))
|
| 339 |
+
return {
|
| 340 |
+
"waveform": waveforms,
|
| 341 |
+
"padding_mask": padding_mask,
|
| 342 |
+
"labels": labels,
|
| 343 |
+
"sample_ids": sample_ids,
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
class BEATsEventClassifier(nn.Module):
|
| 348 |
+
def __init__(
|
| 349 |
+
self,
|
| 350 |
+
checkpoint_path: str,
|
| 351 |
+
num_classes: int,
|
| 352 |
+
dropout: float = 0.1,
|
| 353 |
+
channel_mode: str = "w",
|
| 354 |
+
) -> None:
|
| 355 |
+
super().__init__()
|
| 356 |
+
self.channel_mode = channel_mode
|
| 357 |
+
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 358 |
+
cfg = BEATsConfig(checkpoint["cfg"])
|
| 359 |
+
self.beats = BEATs(cfg)
|
| 360 |
+
missing, unexpected = self.beats.load_state_dict(checkpoint["model"], strict=False)
|
| 361 |
+
if missing:
|
| 362 |
+
log(f"[BEATsCls] missing keys while loading BEATs: {len(missing)}")
|
| 363 |
+
if unexpected:
|
| 364 |
+
log(f"[BEATsCls] unexpected keys while loading BEATs: {len(unexpected)}")
|
| 365 |
+
self.beats.predictor = None
|
| 366 |
+
self.foa_fusion = None
|
| 367 |
+
if channel_mode == "foa4_fusion":
|
| 368 |
+
self.foa_fusion = nn.Sequential(
|
| 369 |
+
nn.Conv2d(4, 1, kernel_size=3, padding=1, bias=False),
|
| 370 |
+
nn.BatchNorm2d(1),
|
| 371 |
+
nn.GELU(),
|
| 372 |
+
)
|
| 373 |
+
self.dropout = nn.Dropout(dropout)
|
| 374 |
+
self.classifier = nn.Linear(cfg.encoder_embed_dim, num_classes)
|
| 375 |
+
|
| 376 |
+
def _encode_normalized_fbank(
|
| 377 |
+
self,
|
| 378 |
+
fbank: torch.Tensor,
|
| 379 |
+
padding_mask: torch.Tensor | None,
|
| 380 |
+
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
| 381 |
+
if padding_mask is not None:
|
| 382 |
+
padding_mask = self.beats.forward_padding_mask(fbank, padding_mask)
|
| 383 |
+
|
| 384 |
+
features = self.beats.patch_embedding(fbank.unsqueeze(1))
|
| 385 |
+
features = features.reshape(features.shape[0], features.shape[1], -1)
|
| 386 |
+
features = features.transpose(1, 2)
|
| 387 |
+
features = self.beats.layer_norm(features)
|
| 388 |
+
|
| 389 |
+
if padding_mask is not None:
|
| 390 |
+
padding_mask = self.beats.forward_padding_mask(features, padding_mask)
|
| 391 |
+
|
| 392 |
+
if self.beats.post_extract_proj is not None:
|
| 393 |
+
features = self.beats.post_extract_proj(features)
|
| 394 |
+
|
| 395 |
+
x = self.beats.dropout_input(features)
|
| 396 |
+
x, _ = self.beats.encoder(x, padding_mask=padding_mask)
|
| 397 |
+
return x, padding_mask
|
| 398 |
+
|
| 399 |
+
def forward(self, waveform: torch.Tensor, padding_mask: torch.Tensor | None) -> torch.Tensor:
|
| 400 |
+
if waveform.ndim == 3:
|
| 401 |
+
if self.foa_fusion is None:
|
| 402 |
+
raise RuntimeError("Received 4-channel input but foa_fusion is disabled")
|
| 403 |
+
batch_size, num_channels, num_samples = waveform.shape
|
| 404 |
+
flat = waveform.reshape(batch_size * num_channels, num_samples)
|
| 405 |
+
fbank = self.beats.preprocess(flat)
|
| 406 |
+
fbank = fbank.reshape(batch_size, num_channels, fbank.shape[1], fbank.shape[2])
|
| 407 |
+
fused_fbank = self.foa_fusion(fbank).squeeze(1)
|
| 408 |
+
features, feature_padding_mask = self._encode_normalized_fbank(
|
| 409 |
+
fused_fbank,
|
| 410 |
+
padding_mask=padding_mask,
|
| 411 |
+
)
|
| 412 |
+
else:
|
| 413 |
+
features, feature_padding_mask = self.beats.extract_features(
|
| 414 |
+
waveform,
|
| 415 |
+
padding_mask=padding_mask,
|
| 416 |
+
)
|
| 417 |
+
if feature_padding_mask is not None:
|
| 418 |
+
valid = ~feature_padding_mask
|
| 419 |
+
denom = valid.sum(dim=1).clamp_min(1).unsqueeze(-1)
|
| 420 |
+
pooled = (features * valid.unsqueeze(-1).to(features.dtype)).sum(dim=1) / denom
|
| 421 |
+
else:
|
| 422 |
+
pooled = features.mean(dim=1)
|
| 423 |
+
return self.classifier(self.dropout(pooled))
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def set_trainable(
|
| 427 |
+
model: BEATsEventClassifier,
|
| 428 |
+
unfreeze_top_layers: int,
|
| 429 |
+
unfreeze_all_beats: bool = False,
|
| 430 |
+
) -> None:
|
| 431 |
+
for param in model.parameters():
|
| 432 |
+
param.requires_grad = False
|
| 433 |
+
for param in model.classifier.parameters():
|
| 434 |
+
param.requires_grad = True
|
| 435 |
+
if model.foa_fusion is not None:
|
| 436 |
+
for param in model.foa_fusion.parameters():
|
| 437 |
+
param.requires_grad = True
|
| 438 |
+
if unfreeze_all_beats:
|
| 439 |
+
for param in model.beats.parameters():
|
| 440 |
+
param.requires_grad = True
|
| 441 |
+
return
|
| 442 |
+
if unfreeze_top_layers <= 0:
|
| 443 |
+
return
|
| 444 |
+
layers = getattr(model.beats.encoder, "layers", None)
|
| 445 |
+
if layers is None:
|
| 446 |
+
raise RuntimeError("Cannot locate BEATs encoder layers")
|
| 447 |
+
for layer in list(layers)[-unfreeze_top_layers:]:
|
| 448 |
+
for param in layer.parameters():
|
| 449 |
+
param.requires_grad = True
|
| 450 |
+
for name in ("layer_norm",):
|
| 451 |
+
module = getattr(model.beats.encoder, name, None)
|
| 452 |
+
if module is not None:
|
| 453 |
+
for param in module.parameters():
|
| 454 |
+
param.requires_grad = True
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def move_batch(batch: dict[str, Any], device: torch.device) -> dict[str, Any]:
|
| 458 |
+
return {
|
| 459 |
+
"waveform": batch["waveform"].to(device, non_blocking=True),
|
| 460 |
+
"padding_mask": batch["padding_mask"].to(device, non_blocking=True),
|
| 461 |
+
"labels": batch["labels"].to(device, non_blocking=True),
|
| 462 |
+
"sample_ids": batch["sample_ids"],
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
@torch.no_grad()
|
| 467 |
+
def reduce_metrics(metrics: dict[str, float]) -> dict[str, float]:
|
| 468 |
+
if not is_dist():
|
| 469 |
+
return metrics
|
| 470 |
+
keys = sorted(metrics.keys())
|
| 471 |
+
values = torch.tensor([metrics[k] for k in keys], dtype=torch.float64, device="cuda")
|
| 472 |
+
dist.all_reduce(values, op=dist.ReduceOp.SUM)
|
| 473 |
+
return {k: float(v.item()) for k, v in zip(keys, values)}
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def run_epoch(
|
| 477 |
+
model: nn.Module,
|
| 478 |
+
loader: DataLoader,
|
| 479 |
+
optimizer: torch.optim.Optimizer | None,
|
| 480 |
+
device: torch.device,
|
| 481 |
+
desc: str,
|
| 482 |
+
show_progress: bool,
|
| 483 |
+
) -> dict[str, float]:
|
| 484 |
+
train = optimizer is not None
|
| 485 |
+
model.train(train)
|
| 486 |
+
local = {"loss_sum": 0.0, "correct": 0.0, "count": 0.0}
|
| 487 |
+
iterator = loader
|
| 488 |
+
if show_progress and tqdm is not None:
|
| 489 |
+
iterator = tqdm(loader, desc=desc, disable=not is_main())
|
| 490 |
+
for batch in iterator:
|
| 491 |
+
batch = move_batch(batch, device)
|
| 492 |
+
with torch.set_grad_enabled(train):
|
| 493 |
+
logits = model(batch["waveform"], batch["padding_mask"])
|
| 494 |
+
loss = F.cross_entropy(logits, batch["labels"])
|
| 495 |
+
if train:
|
| 496 |
+
optimizer.zero_grad(set_to_none=True)
|
| 497 |
+
loss.backward()
|
| 498 |
+
torch.nn.utils.clip_grad_norm_(
|
| 499 |
+
[p for p in model.parameters() if p.requires_grad],
|
| 500 |
+
max_norm=5.0,
|
| 501 |
+
)
|
| 502 |
+
optimizer.step()
|
| 503 |
+
pred = logits.argmax(dim=-1)
|
| 504 |
+
correct = (pred == batch["labels"]).sum().item()
|
| 505 |
+
count = batch["labels"].numel()
|
| 506 |
+
local["loss_sum"] += float(loss.item()) * count
|
| 507 |
+
local["correct"] += float(correct)
|
| 508 |
+
local["count"] += float(count)
|
| 509 |
+
if show_progress and tqdm is not None and is_main():
|
| 510 |
+
iterator.set_postfix(loss=float(loss.item()), acc=correct / max(count, 1))
|
| 511 |
+
reduced = reduce_metrics(local)
|
| 512 |
+
return {
|
| 513 |
+
"loss_cls": reduced["loss_sum"] / max(reduced["count"], 1.0),
|
| 514 |
+
"class_acc": reduced["correct"] / max(reduced["count"], 1.0),
|
| 515 |
+
"count": reduced["count"],
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def save_checkpoint(
|
| 520 |
+
path: Path,
|
| 521 |
+
model: nn.Module,
|
| 522 |
+
optimizer: torch.optim.Optimizer,
|
| 523 |
+
epoch: int,
|
| 524 |
+
best_acc: float,
|
| 525 |
+
args: argparse.Namespace,
|
| 526 |
+
) -> None:
|
| 527 |
+
raw_model = model.module if isinstance(model, DistributedDataParallel) else model
|
| 528 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 529 |
+
torch.save(
|
| 530 |
+
{
|
| 531 |
+
"model": raw_model.state_dict(),
|
| 532 |
+
"optimizer": optimizer.state_dict(),
|
| 533 |
+
"epoch": epoch,
|
| 534 |
+
"best_acc": best_acc,
|
| 535 |
+
"args": vars(args),
|
| 536 |
+
},
|
| 537 |
+
path,
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def parse_args() -> argparse.Namespace:
|
| 542 |
+
parser = argparse.ArgumentParser()
|
| 543 |
+
parser.add_argument("--train-manifest", action="append", default=[DEFAULT_OV1_MANIFEST])
|
| 544 |
+
parser.add_argument("--val-manifest", action="append", default=[DEFAULT_OV1_MANIFEST])
|
| 545 |
+
parser.add_argument("--vocab", default=DEFAULT_VOCAB)
|
| 546 |
+
parser.add_argument("--beats-checkpoint", default=DEFAULT_BEATS_CKPT)
|
| 547 |
+
parser.add_argument("--output-dir", default="checkpoints/beats_ov1_event_cls_baseline")
|
| 548 |
+
parser.add_argument("--train-split", default="train")
|
| 549 |
+
parser.add_argument("--val-split", default="valid")
|
| 550 |
+
parser.add_argument("--batch-size", type=int, default=8)
|
| 551 |
+
parser.add_argument("--num-workers", type=int, default=4)
|
| 552 |
+
parser.add_argument("--num-epochs", type=int, default=5)
|
| 553 |
+
parser.add_argument("--learning-rate", type=float, default=1e-3)
|
| 554 |
+
parser.add_argument("--weight-decay", type=float, default=0.01)
|
| 555 |
+
parser.add_argument("--unfreeze-top-layers", type=int, default=0)
|
| 556 |
+
parser.add_argument("--unfreeze-all-beats", action="store_true")
|
| 557 |
+
parser.add_argument(
|
| 558 |
+
"--channel-mode",
|
| 559 |
+
default="w",
|
| 560 |
+
choices=("w", "y", "z", "x", "mean4", "sum4", "foa4_fusion"),
|
| 561 |
+
help=(
|
| 562 |
+
"Input mode. Files are stored as [W,Y,Z,X]. foa4_fusion uses "
|
| 563 |
+
"[W,X,Y,Z] per-channel fbank plus Conv3x3(4,1) before BEATs patching."
|
| 564 |
+
),
|
| 565 |
+
)
|
| 566 |
+
parser.add_argument("--max-duration", type=float, default=20.0)
|
| 567 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 568 |
+
parser.add_argument("--resume", default="")
|
| 569 |
+
parser.add_argument("--resume-model-only", action="store_true")
|
| 570 |
+
parser.add_argument("--no-progress", action="store_true")
|
| 571 |
+
parser.add_argument("--ddp-find-unused-parameters", action="store_true")
|
| 572 |
+
return parser.parse_args()
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def main() -> None:
|
| 576 |
+
args = parse_args()
|
| 577 |
+
distributed, local_rank = init_distributed()
|
| 578 |
+
random.seed(args.seed)
|
| 579 |
+
torch.manual_seed(args.seed)
|
| 580 |
+
device = torch.device(f"cuda:{local_rank}" if torch.cuda.is_available() else "cpu")
|
| 581 |
+
|
| 582 |
+
num_classes = infer_num_classes(args.vocab)
|
| 583 |
+
if is_main():
|
| 584 |
+
log(f"[BEATsCls] num_classes={num_classes}")
|
| 585 |
+
log(f"[BEATsCls] train_manifest={args.train_manifest}")
|
| 586 |
+
log(f"[BEATsCls] val_manifest={args.val_manifest}")
|
| 587 |
+
|
| 588 |
+
train_dataset = FOAEventDataset(
|
| 589 |
+
args.train_manifest,
|
| 590 |
+
args.vocab,
|
| 591 |
+
split=args.train_split,
|
| 592 |
+
max_duration=args.max_duration,
|
| 593 |
+
channel_mode=args.channel_mode,
|
| 594 |
+
seed=args.seed,
|
| 595 |
+
)
|
| 596 |
+
val_dataset = FOAEventDataset(
|
| 597 |
+
args.val_manifest,
|
| 598 |
+
args.vocab,
|
| 599 |
+
split=args.val_split,
|
| 600 |
+
max_duration=args.max_duration,
|
| 601 |
+
channel_mode=args.channel_mode,
|
| 602 |
+
seed=args.seed,
|
| 603 |
+
)
|
| 604 |
+
train_sampler = (
|
| 605 |
+
DistributedSampler(train_dataset, shuffle=True, seed=args.seed)
|
| 606 |
+
if distributed
|
| 607 |
+
else None
|
| 608 |
+
)
|
| 609 |
+
val_sampler = (
|
| 610 |
+
DistributedSampler(val_dataset, shuffle=False, seed=args.seed)
|
| 611 |
+
if distributed
|
| 612 |
+
else None
|
| 613 |
+
)
|
| 614 |
+
train_loader = DataLoader(
|
| 615 |
+
train_dataset,
|
| 616 |
+
batch_size=args.batch_size,
|
| 617 |
+
shuffle=train_sampler is None,
|
| 618 |
+
sampler=train_sampler,
|
| 619 |
+
num_workers=args.num_workers,
|
| 620 |
+
pin_memory=True,
|
| 621 |
+
collate_fn=collate_batch,
|
| 622 |
+
drop_last=False,
|
| 623 |
+
)
|
| 624 |
+
val_loader = DataLoader(
|
| 625 |
+
val_dataset,
|
| 626 |
+
batch_size=args.batch_size,
|
| 627 |
+
shuffle=False,
|
| 628 |
+
sampler=val_sampler,
|
| 629 |
+
num_workers=args.num_workers,
|
| 630 |
+
pin_memory=True,
|
| 631 |
+
collate_fn=collate_batch,
|
| 632 |
+
drop_last=False,
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
model = BEATsEventClassifier(
|
| 636 |
+
args.beats_checkpoint,
|
| 637 |
+
num_classes=num_classes,
|
| 638 |
+
channel_mode=args.channel_mode,
|
| 639 |
+
)
|
| 640 |
+
set_trainable(model, args.unfreeze_top_layers, args.unfreeze_all_beats)
|
| 641 |
+
model.to(device)
|
| 642 |
+
if distributed:
|
| 643 |
+
model = DistributedDataParallel(
|
| 644 |
+
model,
|
| 645 |
+
device_ids=[local_rank],
|
| 646 |
+
find_unused_parameters=args.ddp_find_unused_parameters,
|
| 647 |
+
)
|
| 648 |
+
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 649 |
+
total = sum(p.numel() for p in model.parameters())
|
| 650 |
+
log(f"[BEATsCls] trainable={trainable} total={total}")
|
| 651 |
+
|
| 652 |
+
optimizer = torch.optim.AdamW(
|
| 653 |
+
[p for p in model.parameters() if p.requires_grad],
|
| 654 |
+
lr=args.learning_rate,
|
| 655 |
+
weight_decay=args.weight_decay,
|
| 656 |
+
)
|
| 657 |
+
start_epoch = 0
|
| 658 |
+
best_acc = -math.inf
|
| 659 |
+
if args.resume:
|
| 660 |
+
ckpt = torch.load(args.resume, map_location="cpu", weights_only=False)
|
| 661 |
+
raw_model = model.module if isinstance(model, DistributedDataParallel) else model
|
| 662 |
+
raw_model.load_state_dict(ckpt["model"], strict=True)
|
| 663 |
+
if not args.resume_model_only:
|
| 664 |
+
optimizer.load_state_dict(ckpt["optimizer"])
|
| 665 |
+
start_epoch = int(ckpt["epoch"]) + 1
|
| 666 |
+
best_acc = float(ckpt.get("best_acc", best_acc))
|
| 667 |
+
log(
|
| 668 |
+
f"[BEATsCls] resumed from {args.resume}, "
|
| 669 |
+
f"model_only={args.resume_model_only}, start_epoch={start_epoch}"
|
| 670 |
+
)
|
| 671 |
+
|
| 672 |
+
out_dir = Path(args.output_dir)
|
| 673 |
+
show_progress = not args.no_progress
|
| 674 |
+
for epoch in range(start_epoch, args.num_epochs):
|
| 675 |
+
if train_sampler is not None:
|
| 676 |
+
train_sampler.set_epoch(epoch)
|
| 677 |
+
log(f"[Epoch {epoch}] start")
|
| 678 |
+
train_metrics = run_epoch(
|
| 679 |
+
model, train_loader, optimizer, device, f"Train {epoch}", show_progress
|
| 680 |
+
)
|
| 681 |
+
val_metrics = run_epoch(
|
| 682 |
+
model, val_loader, None, device, f"Valid {epoch}", show_progress
|
| 683 |
+
)
|
| 684 |
+
log(f"[Epoch {epoch}] train: {train_metrics}")
|
| 685 |
+
log(f"[Epoch {epoch}] val: {val_metrics}")
|
| 686 |
+
if is_main():
|
| 687 |
+
save_checkpoint(out_dir / "last.pt", model, optimizer, epoch, best_acc, args)
|
| 688 |
+
save_checkpoint(out_dir / f"epoch_{epoch:04d}.pt", model, optimizer, epoch, best_acc, args)
|
| 689 |
+
if val_metrics["class_acc"] > best_acc:
|
| 690 |
+
best_acc = val_metrics["class_acc"]
|
| 691 |
+
save_checkpoint(out_dir / "best.pt", model, optimizer, epoch, best_acc, args)
|
| 692 |
+
log(f"[BEATsCls] new best class_acc={best_acc:.4f}")
|
| 693 |
+
if is_dist():
|
| 694 |
+
dist.barrier()
|
| 695 |
+
dist.destroy_process_group()
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
if __name__ == "__main__":
|
| 699 |
+
main()
|
train_spatial_atst.py
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training script for Spatial-ATST (ATST-Frame backbone + spatial fusion).
|
| 2 |
+
|
| 3 |
+
Mirrors train_spatial_beats.py but substitutes the BEATs backbone with
|
| 4 |
+
ATST-Frame. All dataset, loss, and metric infrastructure is shared from
|
| 5 |
+
the existing spatial_* modules; only the model-building and parameter-freeze
|
| 6 |
+
logic differs.
|
| 7 |
+
|
| 8 |
+
Usage (single-source ov1, two-stage):
|
| 9 |
+
# Stage 1 — classwarmup (bypass, full trunk unfreeze)
|
| 10 |
+
torchrun --nproc_per_node=8 train_spatial_atst.py \\
|
| 11 |
+
--preset ov1_classwarmup \\
|
| 12 |
+
--output-dir checkpoints/spatial_atst_ov1_classwarmup_run1 \\
|
| 13 |
+
--batch-size 4 --num-workers 24
|
| 14 |
+
|
| 15 |
+
# Stage 2 — spatial finetune (trunk re-frozen)
|
| 16 |
+
torchrun --nproc_per_node=8 train_spatial_atst.py \\
|
| 17 |
+
--preset ov1_spatial \\
|
| 18 |
+
--resume checkpoints/spatial_atst_ov1_classwarmup_run1/best.pt \\
|
| 19 |
+
--output-dir checkpoints/spatial_atst_ov1_spatial_run1 \\
|
| 20 |
+
--batch-size 4 --num-workers 24 \\
|
| 21 |
+
--no-resume-optimizer --reset-epoch-on-resume --reset-best-on-resume
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import copy
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
from dataclasses import asdict, dataclass, field
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 31 |
+
|
| 32 |
+
import torch
|
| 33 |
+
import torch.distributed as dist
|
| 34 |
+
import torch.nn as nn
|
| 35 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 36 |
+
from torch.optim import AdamW, Optimizer
|
| 37 |
+
from torch.utils.data import ConcatDataset, DataLoader
|
| 38 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 39 |
+
from tqdm.auto import tqdm
|
| 40 |
+
|
| 41 |
+
from spatial_atst import SpatialATST, SpatialATSTConfig
|
| 42 |
+
from spatial_dataset import (
|
| 43 |
+
SpatialBatch,
|
| 44 |
+
SpatialDataset,
|
| 45 |
+
SpatialDatasetConfig,
|
| 46 |
+
collate_spatial_batch,
|
| 47 |
+
)
|
| 48 |
+
from spatial_loss import (
|
| 49 |
+
SpatialLossConfig,
|
| 50 |
+
SpatialLossOutput,
|
| 51 |
+
accumulate_mono_ast_seld,
|
| 52 |
+
build_mono_ast_validation_examples,
|
| 53 |
+
build_primary_source_window_mask,
|
| 54 |
+
compute_mono_ast_losses,
|
| 55 |
+
compute_mono_ast_validation_metrics,
|
| 56 |
+
SELDMetricsAccumulator,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Re-use shared utilities from the BEATs training script
|
| 60 |
+
from train_spatial_beats import (
|
| 61 |
+
_format_metrics,
|
| 62 |
+
_init_running_metrics,
|
| 63 |
+
_is_better_metric,
|
| 64 |
+
_is_dist_initialized,
|
| 65 |
+
_is_main_process,
|
| 66 |
+
_log,
|
| 67 |
+
_move_batch_to_device,
|
| 68 |
+
_reduce_metric_sums,
|
| 69 |
+
_resolve_manifest_paths,
|
| 70 |
+
_unwrap_model,
|
| 71 |
+
cleanup_distributed,
|
| 72 |
+
dump_validation_examples,
|
| 73 |
+
initialize_distributed_mode,
|
| 74 |
+
load_checkpoint,
|
| 75 |
+
save_checkpoint,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
DEFAULT_OV1_MANIFEST = (
|
| 79 |
+
"/apdcephfs_cq10/share_1603164/user/schmittzhu/data/metadata/ov1_foa.jsonl"
|
| 80 |
+
)
|
| 81 |
+
DEFAULT_ATST_CKPT = (
|
| 82 |
+
"/apdcephfs_cq10/share_1603164/user/schmittzhu/code/ATST-SED"
|
| 83 |
+
"/desed_task/nnet/ckpts/atst_as2M.ckpt"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# Training config
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
@dataclass
|
| 91 |
+
class TrainSpatialATSTConfig:
|
| 92 |
+
"""High-level training configuration for Spatial-ATST."""
|
| 93 |
+
|
| 94 |
+
train_manifest_paths: Tuple[str, ...] = ()
|
| 95 |
+
val_manifest_paths: Tuple[str, ...] = ()
|
| 96 |
+
train_splits: Tuple[str, ...] = ("train",)
|
| 97 |
+
val_splits: Tuple[str, ...] = ("valid",)
|
| 98 |
+
|
| 99 |
+
# Checkpoint init
|
| 100 |
+
class_finetuned_ckpt: str = "" # optional class head warm-start
|
| 101 |
+
resume_from_checkpoint: Optional[str] = None
|
| 102 |
+
|
| 103 |
+
# Training schedule
|
| 104 |
+
batch_size: int = 8
|
| 105 |
+
num_workers: int = 4
|
| 106 |
+
num_epochs: int = 15
|
| 107 |
+
learning_rate: float = 5e-5
|
| 108 |
+
weight_decay: float = 0.05
|
| 109 |
+
|
| 110 |
+
# Trunk freeze policy
|
| 111 |
+
freeze_atst_trunk: bool = False # True = keep ATST fully frozen
|
| 112 |
+
unfreeze_atst_full: bool = False # True = unfreeze all ATST layers
|
| 113 |
+
unfreeze_atst_top_n_blocks: int = 0 # >0 = unfreeze last N transformer blocks
|
| 114 |
+
freeze_local_spatial_in_classwarmup: bool = False
|
| 115 |
+
train_projector: bool = False
|
| 116 |
+
ddp_find_unused_parameters: bool = False
|
| 117 |
+
|
| 118 |
+
# Checkpoint saving / selection
|
| 119 |
+
output_dir: str = "checkpoints/spatial_atst_ov1"
|
| 120 |
+
save_every_n_epochs: int = 1
|
| 121 |
+
save_last_checkpoint: bool = True
|
| 122 |
+
save_best_checkpoint: bool = True
|
| 123 |
+
best_metric_name: str = "class_acc"
|
| 124 |
+
minimize_best_metric: bool = False
|
| 125 |
+
save_optimizer_state: bool = True
|
| 126 |
+
load_optimizer_state_on_resume: bool = True
|
| 127 |
+
reset_epoch_on_resume: bool = False
|
| 128 |
+
reset_best_metric_on_resume: bool = False
|
| 129 |
+
|
| 130 |
+
# Logging
|
| 131 |
+
show_progress_bars: bool = True
|
| 132 |
+
dump_val_predictions: bool = True
|
| 133 |
+
num_val_prediction_examples: int = 16
|
| 134 |
+
|
| 135 |
+
# Distributed
|
| 136 |
+
distributed: bool = False
|
| 137 |
+
local_rank: int = 0
|
| 138 |
+
distributed_backend: str = "nccl"
|
| 139 |
+
|
| 140 |
+
# Sub-configs
|
| 141 |
+
model: SpatialATSTConfig = field(default_factory=SpatialATSTConfig)
|
| 142 |
+
dataset: SpatialDatasetConfig = field(default_factory=SpatialDatasetConfig)
|
| 143 |
+
loss: SpatialLossConfig = field(default_factory=SpatialLossConfig)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
# Model building
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
|
| 150 |
+
def build_atst_model(train_cfg: TrainSpatialATSTConfig) -> SpatialATST:
|
| 151 |
+
"""Instantiate SpatialATST, load ATST checkpoint, configure trainable params."""
|
| 152 |
+
model_cfg = copy.deepcopy(train_cfg.model)
|
| 153 |
+
# Sync dataset-level settings into model config
|
| 154 |
+
model_cfg.sample_rate = train_cfg.dataset.mel_config.sample_rate
|
| 155 |
+
model_cfg.num_mel_bins = train_cfg.dataset.mel_config.num_mel_bins
|
| 156 |
+
model_cfg.hop_length = train_cfg.dataset.mel_config.hop_length
|
| 157 |
+
model_cfg.win_length = train_cfg.dataset.mel_config.win_length
|
| 158 |
+
model_cfg.n_fft = train_cfg.dataset.mel_config.n_fft
|
| 159 |
+
model_cfg.dither = train_cfg.dataset.mel_config.dither
|
| 160 |
+
model_cfg.target_token_rate = train_cfg.dataset.target_token_rate
|
| 161 |
+
model_cfg.source_num_classes = train_cfg.dataset.source_vocab.num_classes
|
| 162 |
+
model_cfg.source_vocab_path = train_cfg.dataset.source_vocab.vocab_path
|
| 163 |
+
|
| 164 |
+
_log("[SpatialATST] Building model")
|
| 165 |
+
model = SpatialATST(model_cfg)
|
| 166 |
+
|
| 167 |
+
if train_cfg.class_finetuned_ckpt:
|
| 168 |
+
model.load_event_classifier_checkpoint(train_cfg.class_finetuned_ckpt)
|
| 169 |
+
|
| 170 |
+
configure_atst_trainable_parameters(model, train_cfg)
|
| 171 |
+
n_train = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 172 |
+
_log(f"[SpatialATST] Trainable parameters: {n_train:,}")
|
| 173 |
+
return model
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def configure_atst_trainable_parameters(
|
| 177 |
+
model: SpatialATST,
|
| 178 |
+
train_cfg: TrainSpatialATSTConfig,
|
| 179 |
+
) -> None:
|
| 180 |
+
"""Set requires_grad for Spatial-ATST stage-1 training.
|
| 181 |
+
|
| 182 |
+
Default policy (no flags set):
|
| 183 |
+
Frozen : atst_encoder (ATST trunk, frozen by ATSTEncoder.__init__)
|
| 184 |
+
Trained : preprocessor, temporal_resampler, temporal_readout,
|
| 185 |
+
local_spatial_*, local_spatial_prediction_heads, projector
|
| 186 |
+
"""
|
| 187 |
+
# Start: everything frozen
|
| 188 |
+
for param in model.parameters():
|
| 189 |
+
param.requires_grad = False
|
| 190 |
+
|
| 191 |
+
# Always train the spatial / neck / head modules
|
| 192 |
+
always_train_prefixes = (
|
| 193 |
+
"preprocessor",
|
| 194 |
+
"temporal_resampler",
|
| 195 |
+
"temporal_readout",
|
| 196 |
+
"local_spatial_encoder",
|
| 197 |
+
"local_spatial_resampler",
|
| 198 |
+
"local_spatial_proj",
|
| 199 |
+
"local_spatial_fusion_norm",
|
| 200 |
+
"local_spatial_prediction_heads",
|
| 201 |
+
)
|
| 202 |
+
for name, param in model.named_parameters():
|
| 203 |
+
if name.startswith(always_train_prefixes):
|
| 204 |
+
param.requires_grad = True
|
| 205 |
+
|
| 206 |
+
# Optionally train projector
|
| 207 |
+
if train_cfg.train_projector:
|
| 208 |
+
for name, param in model.named_parameters():
|
| 209 |
+
if name.startswith("projector"):
|
| 210 |
+
param.requires_grad = True
|
| 211 |
+
|
| 212 |
+
# ATST trunk unfreezing
|
| 213 |
+
if train_cfg.freeze_atst_trunk:
|
| 214 |
+
pass # keep all atst_encoder frozen
|
| 215 |
+
elif train_cfg.unfreeze_atst_full:
|
| 216 |
+
for name, param in model.named_parameters():
|
| 217 |
+
if name.startswith("atst_encoder"):
|
| 218 |
+
param.requires_grad = True
|
| 219 |
+
elif train_cfg.unfreeze_atst_top_n_blocks > 0:
|
| 220 |
+
n = train_cfg.unfreeze_atst_top_n_blocks
|
| 221 |
+
total_blocks = 12 # FrameAST depth
|
| 222 |
+
unfreeze_from = total_blocks - n
|
| 223 |
+
for name, param in model.named_parameters():
|
| 224 |
+
if not name.startswith("atst_encoder"):
|
| 225 |
+
continue
|
| 226 |
+
# Unfreeze last N blocks + norm_frame
|
| 227 |
+
if name.startswith("atst_encoder.atst.norm_frame"):
|
| 228 |
+
param.requires_grad = True
|
| 229 |
+
else:
|
| 230 |
+
for block_idx in range(unfreeze_from, total_blocks):
|
| 231 |
+
if name.startswith(f"atst_encoder.atst.blocks.{block_idx}"):
|
| 232 |
+
param.requires_grad = True
|
| 233 |
+
break
|
| 234 |
+
|
| 235 |
+
# Optionally freeze local spatial CNN (classwarmup purify mode)
|
| 236 |
+
if train_cfg.freeze_local_spatial_in_classwarmup:
|
| 237 |
+
for name, param in model.named_parameters():
|
| 238 |
+
if name.startswith(("local_spatial_encoder", "local_spatial_proj")):
|
| 239 |
+
param.requires_grad = False
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def build_atst_optimizer(
|
| 243 |
+
model: SpatialATST,
|
| 244 |
+
train_cfg: TrainSpatialATSTConfig,
|
| 245 |
+
) -> Optimizer:
|
| 246 |
+
params = [p for p in model.parameters() if p.requires_grad]
|
| 247 |
+
if not params:
|
| 248 |
+
raise ValueError("No trainable parameters found.")
|
| 249 |
+
return AdamW(params, lr=train_cfg.learning_rate, weight_decay=train_cfg.weight_decay)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ---------------------------------------------------------------------------
|
| 253 |
+
# Dataset / dataloader (reuses SpatialDataset unchanged)
|
| 254 |
+
# ---------------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
def build_atst_dataloaders(
|
| 257 |
+
train_cfg: TrainSpatialATSTConfig,
|
| 258 |
+
) -> Tuple[DataLoader, Optional[DataLoader]]:
|
| 259 |
+
dataset_cfg = copy.deepcopy(train_cfg.dataset)
|
| 260 |
+
|
| 261 |
+
# Override mel params to match ATST frontend (64 bins, no kaldi scale)
|
| 262 |
+
dataset_cfg.mel_config.num_mel_bins = train_cfg.model.num_mel_bins
|
| 263 |
+
dataset_cfg.mel_config.hop_length = train_cfg.model.hop_length
|
| 264 |
+
dataset_cfg.mel_config.win_length = train_cfg.model.win_length
|
| 265 |
+
dataset_cfg.mel_config.n_fft = train_cfg.model.n_fft
|
| 266 |
+
dataset_cfg.mel_config.fbank_mean = train_cfg.model.fbank_mean
|
| 267 |
+
dataset_cfg.mel_config.fbank_std = train_cfg.model.fbank_std
|
| 268 |
+
dataset_cfg.mel_config.normalize_logmel = train_cfg.model.normalize_logmel
|
| 269 |
+
dataset_cfg.mel_config.waveform_scale = train_cfg.model.waveform_scale
|
| 270 |
+
dataset_cfg.target_token_rate = train_cfg.model.target_token_rate
|
| 271 |
+
|
| 272 |
+
train_paths = _resolve_manifest_paths("", train_cfg.train_manifest_paths)
|
| 273 |
+
if not train_paths:
|
| 274 |
+
raise ValueError("At least one training manifest is required.")
|
| 275 |
+
|
| 276 |
+
train_ds_cfg = copy.deepcopy(dataset_cfg)
|
| 277 |
+
train_ds_cfg.allowed_splits = train_cfg.train_splits
|
| 278 |
+
train_datasets = [SpatialDataset(p, train_ds_cfg) for p in train_paths]
|
| 279 |
+
train_dataset = train_datasets[0] if len(train_datasets) == 1 else ConcatDataset(train_datasets)
|
| 280 |
+
train_sampler = DistributedSampler(train_dataset, shuffle=True) if train_cfg.distributed else None
|
| 281 |
+
train_loader = DataLoader(
|
| 282 |
+
train_dataset,
|
| 283 |
+
batch_size=train_cfg.batch_size,
|
| 284 |
+
shuffle=train_sampler is None,
|
| 285 |
+
sampler=train_sampler,
|
| 286 |
+
num_workers=train_cfg.num_workers,
|
| 287 |
+
collate_fn=lambda s: collate_spatial_batch(s, train_ds_cfg),
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
val_loader = None
|
| 291 |
+
val_paths = _resolve_manifest_paths("", train_cfg.val_manifest_paths)
|
| 292 |
+
if val_paths:
|
| 293 |
+
val_ds_cfg = copy.deepcopy(dataset_cfg)
|
| 294 |
+
val_ds_cfg.allowed_splits = train_cfg.val_splits
|
| 295 |
+
val_datasets = [SpatialDataset(p, val_ds_cfg) for p in val_paths]
|
| 296 |
+
val_dataset = val_datasets[0] if len(val_datasets) == 1 else ConcatDataset(val_datasets)
|
| 297 |
+
val_sampler = DistributedSampler(val_dataset, shuffle=False) if train_cfg.distributed else None
|
| 298 |
+
val_loader = DataLoader(
|
| 299 |
+
val_dataset,
|
| 300 |
+
batch_size=train_cfg.batch_size,
|
| 301 |
+
shuffle=False,
|
| 302 |
+
sampler=val_sampler,
|
| 303 |
+
num_workers=train_cfg.num_workers,
|
| 304 |
+
collate_fn=lambda s: collate_spatial_batch(s, val_ds_cfg),
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
return train_loader, val_loader
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ---------------------------------------------------------------------------
|
| 311 |
+
# Train / eval step (mono_ast only for ov1)
|
| 312 |
+
# ---------------------------------------------------------------------------
|
| 313 |
+
|
| 314 |
+
def run_atst_train_step(
|
| 315 |
+
model: nn.Module,
|
| 316 |
+
batch: SpatialBatch,
|
| 317 |
+
loss_cfg: SpatialLossConfig,
|
| 318 |
+
) -> Tuple[object, object, SpatialLossOutput]:
|
| 319 |
+
"""Single forward + loss pass. Currently supports mono_ast only."""
|
| 320 |
+
if loss_cfg.supervision_mode != "mono_ast":
|
| 321 |
+
raise NotImplementedError(
|
| 322 |
+
f"SpatialATST train script currently supports supervision_mode='mono_ast'. "
|
| 323 |
+
f"Got: {loss_cfg.supervision_mode}"
|
| 324 |
+
)
|
| 325 |
+
mono_window_mask = build_primary_source_window_mask(
|
| 326 |
+
batch=batch,
|
| 327 |
+
t_s_max=int(batch.target_num_steps.max().item()),
|
| 328 |
+
).to(batch.waveform.device)
|
| 329 |
+
model_output = model(
|
| 330 |
+
waveform=batch.waveform,
|
| 331 |
+
padding_mask=batch.waveform_padding_mask,
|
| 332 |
+
clip_duration_seconds=batch.clip_duration_seconds,
|
| 333 |
+
mono_window_mask=mono_window_mask,
|
| 334 |
+
)
|
| 335 |
+
if model_output.mono_prediction_output is None:
|
| 336 |
+
raise RuntimeError("mono_prediction_output is None — check model forward.")
|
| 337 |
+
loss_output = compute_mono_ast_losses(
|
| 338 |
+
prediction_output=model_output.mono_prediction_output,
|
| 339 |
+
batch=batch,
|
| 340 |
+
config=loss_cfg,
|
| 341 |
+
)
|
| 342 |
+
return model_output, None, loss_output
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def train_one_epoch_atst(
|
| 346 |
+
model: nn.Module,
|
| 347 |
+
train_loader: DataLoader,
|
| 348 |
+
optimizer: Optimizer,
|
| 349 |
+
train_cfg: TrainSpatialATSTConfig,
|
| 350 |
+
) -> Dict[str, float]:
|
| 351 |
+
model.train()
|
| 352 |
+
device = next(model.parameters()).device
|
| 353 |
+
running = _init_running_metrics()
|
| 354 |
+
num_batches = 0
|
| 355 |
+
progress = tqdm(
|
| 356 |
+
train_loader,
|
| 357 |
+
total=len(train_loader),
|
| 358 |
+
desc="Train",
|
| 359 |
+
leave=False,
|
| 360 |
+
disable=not (train_cfg.show_progress_bars and _is_main_process()),
|
| 361 |
+
)
|
| 362 |
+
for batch in progress:
|
| 363 |
+
batch = _move_batch_to_device(batch, device)
|
| 364 |
+
optimizer.zero_grad(set_to_none=True)
|
| 365 |
+
model_output, _, loss_output = run_atst_train_step(model, batch, train_cfg.loss)
|
| 366 |
+
metric_output = compute_mono_ast_validation_metrics(
|
| 367 |
+
prediction_output=model_output.mono_prediction_output,
|
| 368 |
+
batch=batch,
|
| 369 |
+
)
|
| 370 |
+
loss_output.loss_total.backward()
|
| 371 |
+
optimizer.step()
|
| 372 |
+
|
| 373 |
+
running["loss_total"] += float(loss_output.loss_total.item())
|
| 374 |
+
running["loss_cls_aux"] += float(loss_output.loss_cls_aux.item())
|
| 375 |
+
running["loss_direction"] += float(loss_output.loss_direction.item())
|
| 376 |
+
running["loss_dist"] += float(loss_output.loss_dist.item())
|
| 377 |
+
running["loss_temp"] += float(loss_output.loss_temp.item())
|
| 378 |
+
running["class_acc"] += float(metric_output.class_acc.item())
|
| 379 |
+
running["azi_mae_deg"] += float(metric_output.azi_mae_deg.item())
|
| 380 |
+
running["ele_mae_deg"] += float(metric_output.ele_mae_deg.item())
|
| 381 |
+
running["dist_mae"] += float(metric_output.dist_mae.item())
|
| 382 |
+
num_batches += 1
|
| 383 |
+
postfix: Dict[str, str] = {
|
| 384 |
+
"loss": f"{loss_output.loss_total.item():.4f}",
|
| 385 |
+
"cls": f"{metric_output.class_acc.item():.3f}",
|
| 386 |
+
"azi": f"{metric_output.azi_mae_deg.item():.1f}°",
|
| 387 |
+
"dist": f"{metric_output.dist_mae.item():.3f}",
|
| 388 |
+
}
|
| 389 |
+
if float(loss_output.loss_temp.item()) > 1e-6:
|
| 390 |
+
postfix["anc"] = f"{loss_output.loss_temp.item():.4f}"
|
| 391 |
+
progress.set_postfix(postfix)
|
| 392 |
+
|
| 393 |
+
running, num_batches = _reduce_metric_sums(running, num_batches, device)
|
| 394 |
+
if num_batches == 0:
|
| 395 |
+
return running
|
| 396 |
+
return {k: v / num_batches for k, v in running.items()}
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def evaluate_one_epoch_atst(
|
| 400 |
+
model: nn.Module,
|
| 401 |
+
val_loader: DataLoader,
|
| 402 |
+
train_cfg: TrainSpatialATSTConfig,
|
| 403 |
+
) -> Tuple[Dict[str, float], List[Dict[str, object]]]:
|
| 404 |
+
model.eval()
|
| 405 |
+
device = next(model.parameters()).device
|
| 406 |
+
running = _init_running_metrics()
|
| 407 |
+
num_batches = 0
|
| 408 |
+
examples: List[Dict[str, object]] = []
|
| 409 |
+
seld_acc = SELDMetricsAccumulator()
|
| 410 |
+
|
| 411 |
+
with torch.no_grad():
|
| 412 |
+
progress = tqdm(
|
| 413 |
+
val_loader,
|
| 414 |
+
total=len(val_loader),
|
| 415 |
+
desc="Valid",
|
| 416 |
+
leave=False,
|
| 417 |
+
disable=not (train_cfg.show_progress_bars and _is_main_process()),
|
| 418 |
+
)
|
| 419 |
+
for batch in progress:
|
| 420 |
+
batch = _move_batch_to_device(batch, device)
|
| 421 |
+
model_output, _, loss_output = run_atst_train_step(model, batch, train_cfg.loss)
|
| 422 |
+
metric_output = compute_mono_ast_validation_metrics(
|
| 423 |
+
prediction_output=model_output.mono_prediction_output,
|
| 424 |
+
batch=batch,
|
| 425 |
+
)
|
| 426 |
+
if _is_main_process():
|
| 427 |
+
accumulate_mono_ast_seld(
|
| 428 |
+
prediction_output=model_output.mono_prediction_output,
|
| 429 |
+
batch=batch,
|
| 430 |
+
accumulator=seld_acc,
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
running["loss_total"] += float(loss_output.loss_total.item())
|
| 434 |
+
running["loss_cls_aux"] += float(loss_output.loss_cls_aux.item())
|
| 435 |
+
running["loss_direction"] += float(loss_output.loss_direction.item())
|
| 436 |
+
running["loss_dist"] += float(loss_output.loss_dist.item())
|
| 437 |
+
running["loss_temp"] += float(loss_output.loss_temp.item())
|
| 438 |
+
running["class_acc"] += float(metric_output.class_acc.item())
|
| 439 |
+
running["azi_mae_deg"] += float(metric_output.azi_mae_deg.item())
|
| 440 |
+
running["ele_mae_deg"] += float(metric_output.ele_mae_deg.item())
|
| 441 |
+
running["dist_mae"] += float(metric_output.dist_mae.item())
|
| 442 |
+
num_batches += 1
|
| 443 |
+
|
| 444 |
+
if _is_main_process() and len(examples) < train_cfg.num_val_prediction_examples:
|
| 445 |
+
remaining = train_cfg.num_val_prediction_examples - len(examples)
|
| 446 |
+
examples.extend(
|
| 447 |
+
build_mono_ast_validation_examples(
|
| 448 |
+
prediction_output=model_output.mono_prediction_output,
|
| 449 |
+
batch=batch,
|
| 450 |
+
max_examples=remaining,
|
| 451 |
+
)
|
| 452 |
+
)
|
| 453 |
+
progress.set_postfix(
|
| 454 |
+
loss=f"{loss_output.loss_total.item():.4f}",
|
| 455 |
+
cls=f"{metric_output.class_acc.item():.3f}",
|
| 456 |
+
azi=f"{metric_output.azi_mae_deg.item():.1f}",
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
running, num_batches = _reduce_metric_sums(running, num_batches, device)
|
| 460 |
+
if num_batches == 0:
|
| 461 |
+
return running, examples
|
| 462 |
+
metrics = {k: v / num_batches for k, v in running.items()}
|
| 463 |
+
if _is_main_process():
|
| 464 |
+
metrics.update(seld_acc.compute())
|
| 465 |
+
return metrics, examples
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
# ---------------------------------------------------------------------------
|
| 469 |
+
# Checkpoint helpers
|
| 470 |
+
# ---------------------------------------------------------------------------
|
| 471 |
+
|
| 472 |
+
def _save_epoch_checkpoints_atst(
|
| 473 |
+
model: nn.Module,
|
| 474 |
+
optimizer: Optimizer,
|
| 475 |
+
train_cfg: TrainSpatialATSTConfig,
|
| 476 |
+
epoch: int,
|
| 477 |
+
best_metric_value: Optional[float],
|
| 478 |
+
train_metrics: Dict[str, float],
|
| 479 |
+
val_metrics: Optional[Dict[str, float]],
|
| 480 |
+
is_best: bool,
|
| 481 |
+
) -> None:
|
| 482 |
+
output_dir = Path(train_cfg.output_dir)
|
| 483 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 484 |
+
|
| 485 |
+
state = {
|
| 486 |
+
"epoch": epoch,
|
| 487 |
+
"model_state_dict": _unwrap_model(model).state_dict(),
|
| 488 |
+
"optimizer_state_dict": optimizer.state_dict() if train_cfg.save_optimizer_state else None,
|
| 489 |
+
"best_metric_name": train_cfg.best_metric_name,
|
| 490 |
+
"best_metric_value": best_metric_value,
|
| 491 |
+
"train_metrics": train_metrics,
|
| 492 |
+
"val_metrics": val_metrics,
|
| 493 |
+
"train_cfg": asdict(train_cfg),
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
if train_cfg.save_every_n_epochs > 0 and (epoch + 1) % train_cfg.save_every_n_epochs == 0:
|
| 497 |
+
save_checkpoint(
|
| 498 |
+
str(output_dir / f"epoch_{epoch:04d}.pt"),
|
| 499 |
+
model, optimizer, None, epoch, best_metric_value, train_metrics, val_metrics,
|
| 500 |
+
)
|
| 501 |
+
if train_cfg.save_last_checkpoint:
|
| 502 |
+
if not _is_main_process():
|
| 503 |
+
return
|
| 504 |
+
torch.save(state, str(output_dir / "last.pt"))
|
| 505 |
+
if is_best and train_cfg.save_best_checkpoint:
|
| 506 |
+
if not _is_main_process():
|
| 507 |
+
return
|
| 508 |
+
torch.save(state, str(output_dir / "best.pt"))
|
| 509 |
+
_log(f"[Checkpoint] Saved best.pt {train_cfg.best_metric_name}={best_metric_value:.4f}")
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
# ---------------------------------------------------------------------------
|
| 513 |
+
# Presets
|
| 514 |
+
# ---------------------------------------------------------------------------
|
| 515 |
+
|
| 516 |
+
def _base_ov1_config() -> TrainSpatialATSTConfig:
|
| 517 |
+
"""Base ov1 single-source config for Spatial-ATST."""
|
| 518 |
+
cfg = TrainSpatialATSTConfig(
|
| 519 |
+
train_manifest_paths=(DEFAULT_OV1_MANIFEST,),
|
| 520 |
+
val_manifest_paths=(DEFAULT_OV1_MANIFEST,),
|
| 521 |
+
train_splits=("train",),
|
| 522 |
+
val_splits=("valid",),
|
| 523 |
+
batch_size=8,
|
| 524 |
+
num_workers=4,
|
| 525 |
+
num_epochs=20,
|
| 526 |
+
learning_rate=5e-5,
|
| 527 |
+
weight_decay=0.05,
|
| 528 |
+
freeze_atst_trunk=True,
|
| 529 |
+
unfreeze_atst_full=False,
|
| 530 |
+
unfreeze_atst_top_n_blocks=0,
|
| 531 |
+
output_dir="checkpoints/spatial_atst_ov1",
|
| 532 |
+
best_metric_name="azi_mae_deg",
|
| 533 |
+
minimize_best_metric=True,
|
| 534 |
+
)
|
| 535 |
+
cfg.model.atst_checkpoint_path = DEFAULT_ATST_CKPT
|
| 536 |
+
cfg.model.bypass_local_fusion = False
|
| 537 |
+
cfg.model.target_token_rate = 2.5
|
| 538 |
+
cfg.model.local_spatial_dim = 256
|
| 539 |
+
cfg.model.local_spatial_layers = 2
|
| 540 |
+
cfg.model.local_spatial_heads = 4
|
| 541 |
+
cfg.dataset.max_clip_duration_seconds = 20.0
|
| 542 |
+
cfg.dataset.crop_mode = "start"
|
| 543 |
+
cfg.loss.supervision_mode = "mono_ast"
|
| 544 |
+
cfg.loss.lambda_cls_aux = 1.0
|
| 545 |
+
cfg.loss.lambda_direction = 12.0
|
| 546 |
+
cfg.loss.lambda_dist = 2.0
|
| 547 |
+
cfg.loss.lambda_activity = 0.0
|
| 548 |
+
cfg.loss.lambda_azi = 0.0
|
| 549 |
+
cfg.loss.lambda_ele = 0.0
|
| 550 |
+
cfg.loss.lambda_temp = 0.0
|
| 551 |
+
return cfg
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
def make_ov1_atst_classwarmup_config() -> TrainSpatialATSTConfig:
|
| 555 |
+
"""Stage 1: full-trunk classwarmup with bypass fusion.
|
| 556 |
+
|
| 557 |
+
ATST trunk fully unfrozen + bypass_local_fusion=True → fused = LN(semantic),
|
| 558 |
+
no CNN noise at all. Optimised purely for class_acc.
|
| 559 |
+
|
| 560 |
+
W-only ATST ablation suggests ~65-70% class_acc is achievable.
|
| 561 |
+
"""
|
| 562 |
+
cfg = _base_ov1_config()
|
| 563 |
+
cfg.num_epochs = 15
|
| 564 |
+
cfg.learning_rate = 5e-5
|
| 565 |
+
cfg.freeze_atst_trunk = False
|
| 566 |
+
cfg.unfreeze_atst_full = True # unfreeze all ATST layers
|
| 567 |
+
cfg.model.bypass_local_fusion = True # no CNN noise
|
| 568 |
+
cfg.model.spec_augment_freq_masks = 2
|
| 569 |
+
cfg.model.spec_augment_freq_width = 16
|
| 570 |
+
cfg.model.spec_augment_time_masks = 2
|
| 571 |
+
cfg.model.spec_augment_time_width = 100
|
| 572 |
+
cfg.model.head_dropout = 0.3
|
| 573 |
+
cfg.loss.label_smoothing = 0.1
|
| 574 |
+
cfg.loss.lambda_cls_aux = 8.0
|
| 575 |
+
cfg.loss.lambda_direction = 0.0
|
| 576 |
+
cfg.loss.lambda_dist = 0.0
|
| 577 |
+
cfg.best_metric_name = "class_acc"
|
| 578 |
+
cfg.minimize_best_metric = False
|
| 579 |
+
cfg.ddp_find_unused_parameters = True # local_spatial_encoder unused
|
| 580 |
+
cfg.output_dir = "checkpoints/spatial_atst_ov1_classwarmup"
|
| 581 |
+
return cfg
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def make_ov1_atst_spatial_config() -> TrainSpatialATSTConfig:
|
| 585 |
+
"""Stage 2: spatial finetune after classwarmup.
|
| 586 |
+
|
| 587 |
+
Trunk re-frozen; bypass disabled; CNN activated; spatial loss dominant.
|
| 588 |
+
Resume from classwarmup best.pt.
|
| 589 |
+
"""
|
| 590 |
+
cfg = _base_ov1_config()
|
| 591 |
+
cfg.num_epochs = 20
|
| 592 |
+
cfg.learning_rate = 3e-5
|
| 593 |
+
cfg.freeze_atst_trunk = True # re-freeze trunk
|
| 594 |
+
cfg.unfreeze_atst_full = False
|
| 595 |
+
cfg.model.bypass_local_fusion = False
|
| 596 |
+
cfg.model.spec_augment_freq_masks = 2
|
| 597 |
+
cfg.model.spec_augment_freq_width = 16
|
| 598 |
+
cfg.model.spec_augment_time_masks = 2
|
| 599 |
+
cfg.model.spec_augment_time_width = 100
|
| 600 |
+
cfg.model.head_dropout = 0.3
|
| 601 |
+
cfg.loss.label_smoothing = 0.1
|
| 602 |
+
cfg.loss.lambda_cls_aux = 1.0
|
| 603 |
+
cfg.loss.lambda_direction = 12.0
|
| 604 |
+
cfg.loss.lambda_dist = 2.0
|
| 605 |
+
cfg.best_metric_name = "azi_mae_deg"
|
| 606 |
+
cfg.minimize_best_metric = True
|
| 607 |
+
cfg.output_dir = "checkpoints/spatial_atst_ov1_spatial"
|
| 608 |
+
return cfg
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def make_ov1_atst_joint_config() -> TrainSpatialATSTConfig:
|
| 612 |
+
"""Joint class + spatial training from scratch (baseline, no two-stage).
|
| 613 |
+
|
| 614 |
+
Trunk frozen; both class and spatial losses active from the start.
|
| 615 |
+
Useful as baseline to compare against two-stage approach.
|
| 616 |
+
"""
|
| 617 |
+
cfg = _base_ov1_config()
|
| 618 |
+
cfg.num_epochs = 30
|
| 619 |
+
cfg.learning_rate = 1e-4
|
| 620 |
+
cfg.freeze_atst_trunk = True
|
| 621 |
+
cfg.model.bypass_local_fusion = False
|
| 622 |
+
cfg.loss.lambda_cls_aux = 1.0
|
| 623 |
+
cfg.loss.lambda_direction = 12.0
|
| 624 |
+
cfg.loss.lambda_dist = 2.0
|
| 625 |
+
cfg.best_metric_name = "azi_mae_deg"
|
| 626 |
+
cfg.minimize_best_metric = True
|
| 627 |
+
cfg.output_dir = "checkpoints/spatial_atst_ov1_joint"
|
| 628 |
+
return cfg
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
def make_ov1_atst_purify_classwarmup_config() -> TrainSpatialATSTConfig:
|
| 632 |
+
"""Stage 1: classwarmup with CNN frozen (purify variant).
|
| 633 |
+
|
| 634 |
+
Local spatial CNN frozen (local_update≈0); ATST trunk fully unfrozen.
|
| 635 |
+
Slightly less clean than bypass but preserves CNN in compute graph.
|
| 636 |
+
"""
|
| 637 |
+
cfg = make_ov1_atst_classwarmup_config()
|
| 638 |
+
cfg.model.bypass_local_fusion = False
|
| 639 |
+
cfg.freeze_local_spatial_in_classwarmup = True
|
| 640 |
+
cfg.ddp_find_unused_parameters = True
|
| 641 |
+
cfg.output_dir = "checkpoints/spatial_atst_ov1_purify_classwarmup"
|
| 642 |
+
return cfg
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
# ---------------------------------------------------------------------------
|
| 646 |
+
# Config builder from CLI args
|
| 647 |
+
# ---------------------------------------------------------------------------
|
| 648 |
+
|
| 649 |
+
def build_atst_config_from_args(args: argparse.Namespace) -> TrainSpatialATSTConfig:
|
| 650 |
+
preset_map = {
|
| 651 |
+
"ov1_classwarmup": make_ov1_atst_classwarmup_config,
|
| 652 |
+
"ov1_spatial": make_ov1_atst_spatial_config,
|
| 653 |
+
"ov1_joint": make_ov1_atst_joint_config,
|
| 654 |
+
"ov1_purify_classwarmup": make_ov1_atst_purify_classwarmup_config,
|
| 655 |
+
}
|
| 656 |
+
if args.preset not in preset_map:
|
| 657 |
+
raise ValueError(f"Unknown preset: {args.preset}. Available: {list(preset_map)}")
|
| 658 |
+
cfg = preset_map[args.preset]()
|
| 659 |
+
|
| 660 |
+
if args.ov1_manifest:
|
| 661 |
+
cfg.train_manifest_paths = (args.ov1_manifest,)
|
| 662 |
+
cfg.val_manifest_paths = (args.ov1_manifest,)
|
| 663 |
+
if args.batch_size is not None:
|
| 664 |
+
cfg.batch_size = args.batch_size
|
| 665 |
+
if args.num_workers is not None:
|
| 666 |
+
cfg.num_workers = args.num_workers
|
| 667 |
+
if args.num_epochs is not None:
|
| 668 |
+
cfg.num_epochs = args.num_epochs
|
| 669 |
+
if args.learning_rate is not None:
|
| 670 |
+
cfg.learning_rate = args.learning_rate
|
| 671 |
+
if args.output_dir is not None:
|
| 672 |
+
cfg.output_dir = args.output_dir
|
| 673 |
+
if args.atst_ckpt is not None:
|
| 674 |
+
cfg.model.atst_checkpoint_path = args.atst_ckpt
|
| 675 |
+
if args.class_finetuned_ckpt is not None:
|
| 676 |
+
cfg.class_finetuned_ckpt = args.class_finetuned_ckpt
|
| 677 |
+
if args.resume is not None:
|
| 678 |
+
cfg.resume_from_checkpoint = args.resume
|
| 679 |
+
if args.no_resume_optimizer:
|
| 680 |
+
cfg.load_optimizer_state_on_resume = False
|
| 681 |
+
if args.reset_epoch_on_resume:
|
| 682 |
+
cfg.reset_epoch_on_resume = True
|
| 683 |
+
if args.reset_best_on_resume:
|
| 684 |
+
cfg.reset_best_metric_on_resume = True
|
| 685 |
+
if args.ddp_find_unused_parameters:
|
| 686 |
+
cfg.ddp_find_unused_parameters = True
|
| 687 |
+
if args.distributed:
|
| 688 |
+
cfg.distributed = True
|
| 689 |
+
if args.local_rank is not None:
|
| 690 |
+
cfg.local_rank = args.local_rank
|
| 691 |
+
|
| 692 |
+
return cfg
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
def parse_atst_args() -> argparse.Namespace:
|
| 696 |
+
parser = argparse.ArgumentParser(description="Train Spatial-ATST (ov1 single-source).")
|
| 697 |
+
parser.add_argument(
|
| 698 |
+
"--preset",
|
| 699 |
+
choices=["ov1_classwarmup", "ov1_spatial", "ov1_joint", "ov1_purify_classwarmup"],
|
| 700 |
+
required=True,
|
| 701 |
+
)
|
| 702 |
+
parser.add_argument("--ov1-manifest", default=DEFAULT_OV1_MANIFEST)
|
| 703 |
+
parser.add_argument("--batch-size", type=int, default=None)
|
| 704 |
+
parser.add_argument("--num-workers", type=int, default=None)
|
| 705 |
+
parser.add_argument("--num-epochs", type=int, default=None)
|
| 706 |
+
parser.add_argument("--learning-rate", type=float, default=None)
|
| 707 |
+
parser.add_argument("--output-dir", type=str, default=None)
|
| 708 |
+
parser.add_argument("--atst-ckpt", type=str, default=None)
|
| 709 |
+
parser.add_argument("--class-finetuned-ckpt", type=str, default=None)
|
| 710 |
+
parser.add_argument("--resume", type=str, default=None)
|
| 711 |
+
parser.add_argument("--no-resume-optimizer", action="store_true")
|
| 712 |
+
parser.add_argument("--reset-epoch-on-resume", action="store_true")
|
| 713 |
+
parser.add_argument("--reset-best-on-resume", action="store_true")
|
| 714 |
+
parser.add_argument("--ddp-find-unused-parameters", action="store_true")
|
| 715 |
+
parser.add_argument("--distributed", action="store_true")
|
| 716 |
+
parser.add_argument("--local-rank", "--local_rank", dest="local_rank", type=int, default=None)
|
| 717 |
+
return parser.parse_args()
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
# ---------------------------------------------------------------------------
|
| 721 |
+
# Main training loop
|
| 722 |
+
# ---------------------------------------------------------------------------
|
| 723 |
+
|
| 724 |
+
def main_atst(train_cfg: Optional[TrainSpatialATSTConfig] = None) -> None:
|
| 725 |
+
"""Entry point for Spatial-ATST training."""
|
| 726 |
+
train_cfg = train_cfg or TrainSpatialATSTConfig()
|
| 727 |
+
|
| 728 |
+
device = initialize_distributed_mode(train_cfg)
|
| 729 |
+
|
| 730 |
+
try:
|
| 731 |
+
train_loader, val_loader = build_atst_dataloaders(train_cfg)
|
| 732 |
+
model = build_atst_model(train_cfg)
|
| 733 |
+
_log(f"[SpatialATST] Device: {device}")
|
| 734 |
+
model.to(device)
|
| 735 |
+
|
| 736 |
+
if train_cfg.distributed:
|
| 737 |
+
ddp_ids = [device.index] if device.type == "cuda" else None
|
| 738 |
+
model = DDP(
|
| 739 |
+
model,
|
| 740 |
+
device_ids=ddp_ids,
|
| 741 |
+
output_device=device.index if device.type == "cuda" else None,
|
| 742 |
+
find_unused_parameters=train_cfg.ddp_find_unused_parameters,
|
| 743 |
+
)
|
| 744 |
+
|
| 745 |
+
optimizer = build_atst_optimizer(_unwrap_model(model), train_cfg)
|
| 746 |
+
start_epoch = 0
|
| 747 |
+
best_metric_value: Optional[float] = None
|
| 748 |
+
|
| 749 |
+
if train_cfg.resume_from_checkpoint:
|
| 750 |
+
start_epoch, best_metric_value, loaded_name = load_checkpoint(
|
| 751 |
+
checkpoint_path=train_cfg.resume_from_checkpoint,
|
| 752 |
+
model=model,
|
| 753 |
+
optimizer=optimizer,
|
| 754 |
+
load_optimizer_state=train_cfg.load_optimizer_state_on_resume,
|
| 755 |
+
)
|
| 756 |
+
if train_cfg.reset_epoch_on_resume:
|
| 757 |
+
start_epoch = 0
|
| 758 |
+
if train_cfg.reset_best_metric_on_resume:
|
| 759 |
+
best_metric_value = None
|
| 760 |
+
elif loaded_name is not None and loaded_name != train_cfg.best_metric_name:
|
| 761 |
+
_log(
|
| 762 |
+
f"[Checkpoint] Reset best: ckpt used {loaded_name}, "
|
| 763 |
+
f"now tracking {train_cfg.best_metric_name}"
|
| 764 |
+
)
|
| 765 |
+
best_metric_value = None
|
| 766 |
+
_log(
|
| 767 |
+
f"[SpatialATST] Resumed epoch={start_epoch} "
|
| 768 |
+
f"best_{train_cfg.best_metric_name}={best_metric_value}"
|
| 769 |
+
)
|
| 770 |
+
|
| 771 |
+
for epoch in range(start_epoch, train_cfg.num_epochs):
|
| 772 |
+
if isinstance(train_loader.sampler, DistributedSampler):
|
| 773 |
+
train_loader.sampler.set_epoch(epoch)
|
| 774 |
+
if val_loader is not None and isinstance(val_loader.sampler, DistributedSampler):
|
| 775 |
+
val_loader.sampler.set_epoch(epoch)
|
| 776 |
+
|
| 777 |
+
_log(f"[Epoch {epoch}] start")
|
| 778 |
+
train_metrics = train_one_epoch_atst(model, train_loader, optimizer, train_cfg)
|
| 779 |
+
_log(f"[Epoch {epoch}] train: {_format_metrics(train_metrics, 'mono_ast')}")
|
| 780 |
+
|
| 781 |
+
val_metrics = None
|
| 782 |
+
val_examples: List[Dict[str, object]] = []
|
| 783 |
+
if val_loader is not None:
|
| 784 |
+
val_metrics, val_examples = evaluate_one_epoch_atst(model, val_loader, train_cfg)
|
| 785 |
+
_log(f"[Epoch {epoch}] val: {_format_metrics(val_metrics, 'mono_ast')}")
|
| 786 |
+
if train_cfg.dump_val_predictions:
|
| 787 |
+
dump_validation_examples(train_cfg.output_dir, epoch, val_examples)
|
| 788 |
+
|
| 789 |
+
ref_metrics = val_metrics if val_metrics is not None else train_metrics
|
| 790 |
+
if train_cfg.best_metric_name not in ref_metrics:
|
| 791 |
+
raise KeyError(
|
| 792 |
+
f"best_metric_name='{train_cfg.best_metric_name}' not in metrics: "
|
| 793 |
+
f"{sorted(ref_metrics.keys())}"
|
| 794 |
+
)
|
| 795 |
+
cur = float(ref_metrics[train_cfg.best_metric_name])
|
| 796 |
+
is_best = _is_better_metric(cur, best_metric_value, train_cfg.minimize_best_metric)
|
| 797 |
+
if is_best:
|
| 798 |
+
best_metric_value = cur
|
| 799 |
+
|
| 800 |
+
_save_epoch_checkpoints_atst(
|
| 801 |
+
model=model,
|
| 802 |
+
optimizer=optimizer,
|
| 803 |
+
train_cfg=train_cfg,
|
| 804 |
+
epoch=epoch,
|
| 805 |
+
best_metric_value=best_metric_value,
|
| 806 |
+
train_metrics=train_metrics,
|
| 807 |
+
val_metrics=val_metrics,
|
| 808 |
+
is_best=is_best,
|
| 809 |
+
)
|
| 810 |
+
finally:
|
| 811 |
+
cleanup_distributed()
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
if __name__ == "__main__":
|
| 815 |
+
main_atst(build_atst_config_from_args(parse_atst_args()))
|
visualize_spatial_latents.py
ADDED
|
@@ -0,0 +1,1082 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Export and visualize Spatial-BEATs test latents.
|
| 3 |
+
|
| 4 |
+
This script is intended for qualitative + lightweight quantitative inspection
|
| 5 |
+
of a trained Spatial-BEATs checkpoint on the test split.
|
| 6 |
+
|
| 7 |
+
Outputs:
|
| 8 |
+
- full latent export (.npz)
|
| 9 |
+
- per-sample metadata (.jsonl)
|
| 10 |
+
- class-balanced and azimuth-balanced subsets (.jsonl)
|
| 11 |
+
- PCA / UMAP / t-SNE plots
|
| 12 |
+
- kNN probe summary + latent-vs-angle correlation (.json)
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python visualize_spatial_latents.py \
|
| 16 |
+
--checkpoint checkpoints/spatial_beats_ov1_local_spatial_v7dc_exp/01_classwarmup/best.pt \
|
| 17 |
+
--preset ov1_local_spatial_v7dc_classwarmup \
|
| 18 |
+
--batch-size 8 --num-workers 8
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import contextlib
|
| 25 |
+
import copy
|
| 26 |
+
import functools
|
| 27 |
+
import importlib.util
|
| 28 |
+
import json
|
| 29 |
+
import math
|
| 30 |
+
import random
|
| 31 |
+
from collections import Counter, defaultdict
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from types import SimpleNamespace
|
| 34 |
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
| 35 |
+
|
| 36 |
+
import matplotlib
|
| 37 |
+
|
| 38 |
+
matplotlib.use("Agg")
|
| 39 |
+
import matplotlib.pyplot as plt
|
| 40 |
+
import numpy as np
|
| 41 |
+
import torch
|
| 42 |
+
from sklearn.decomposition import PCA
|
| 43 |
+
from sklearn.manifold import TSNE
|
| 44 |
+
from sklearn.neighbors import NearestNeighbors
|
| 45 |
+
from tqdm.auto import tqdm
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
from sklearn.preprocessing import StandardScaler
|
| 49 |
+
except ImportError: # pragma: no cover - sklearn is expected in the target env
|
| 50 |
+
StandardScaler = None # type: ignore[assignment]
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
import umap.umap_ as umap
|
| 54 |
+
except ImportError: # pragma: no cover - UMAP is optional
|
| 55 |
+
umap = None # type: ignore[assignment]
|
| 56 |
+
|
| 57 |
+
from spatial_beats import SpatialBEATs, SpatialBEATsOutput
|
| 58 |
+
from spatial_dataset import SpatialBatch, SpatialDataset, collate_spatial_batch, load_source_vocabulary
|
| 59 |
+
from spatial_loss import (
|
| 60 |
+
SELDMetricsAccumulator,
|
| 61 |
+
_azi_ele_deg_from_direction_vector,
|
| 62 |
+
accumulate_mono_ast_seld,
|
| 63 |
+
build_primary_source_window_mask,
|
| 64 |
+
compute_mono_ast_validation_metrics,
|
| 65 |
+
)
|
| 66 |
+
from train_spatial_beats import (
|
| 67 |
+
DEFAULT_OV1_MANIFEST,
|
| 68 |
+
DEFAULT_OV2_MANIFEST,
|
| 69 |
+
DEFAULT_OV3_MANIFEST,
|
| 70 |
+
DEFAULT_OV1_REAL_MANIFEST,
|
| 71 |
+
DEFAULT_OV2_REAL_MANIFEST,
|
| 72 |
+
DEFAULT_OV3_REAL_MANIFEST,
|
| 73 |
+
TrainSpatialBEATsConfig,
|
| 74 |
+
_format_metrics,
|
| 75 |
+
build_dataset_config,
|
| 76 |
+
build_model_config,
|
| 77 |
+
build_train_config_from_args,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def parse_args() -> argparse.Namespace:
|
| 82 |
+
parser = argparse.ArgumentParser(description="Visualize Spatial-BEATs test latents.")
|
| 83 |
+
parser.add_argument("--checkpoint", required=True, type=str)
|
| 84 |
+
parser.add_argument("--preset", required=True, type=str)
|
| 85 |
+
parser.add_argument("--output-dir", type=str, default=None)
|
| 86 |
+
parser.add_argument("--ov1-manifest", default=DEFAULT_OV1_MANIFEST)
|
| 87 |
+
parser.add_argument("--ov2-manifest", default=DEFAULT_OV2_MANIFEST)
|
| 88 |
+
parser.add_argument("--ov3-manifest", default=DEFAULT_OV3_MANIFEST)
|
| 89 |
+
parser.add_argument("--ov1-real-manifest", default=DEFAULT_OV1_REAL_MANIFEST)
|
| 90 |
+
parser.add_argument("--ov2-real-manifest", default=DEFAULT_OV2_REAL_MANIFEST)
|
| 91 |
+
parser.add_argument("--ov3-real-manifest", default=DEFAULT_OV3_REAL_MANIFEST)
|
| 92 |
+
parser.add_argument("--batch-size", type=int, default=8)
|
| 93 |
+
parser.add_argument("--num-workers", type=int, default=8)
|
| 94 |
+
parser.add_argument("--device", type=str, default="cuda")
|
| 95 |
+
parser.add_argument("--amp", choices=("fp32", "bf16", "fp16"), default="fp32")
|
| 96 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 97 |
+
parser.add_argument(
|
| 98 |
+
"--max-test-samples",
|
| 99 |
+
type=int,
|
| 100 |
+
default=0,
|
| 101 |
+
help="Optional hard cap on exported test samples. 0 = use full test split.",
|
| 102 |
+
)
|
| 103 |
+
parser.add_argument(
|
| 104 |
+
"--class-plot-num-classes",
|
| 105 |
+
type=int,
|
| 106 |
+
default=12,
|
| 107 |
+
help="Number of classes to visualize in class-colored plots.",
|
| 108 |
+
)
|
| 109 |
+
parser.add_argument(
|
| 110 |
+
"--class-plot-samples-per-class",
|
| 111 |
+
type=int,
|
| 112 |
+
default=12,
|
| 113 |
+
help="Max number of samples per selected class in class-colored plots.",
|
| 114 |
+
)
|
| 115 |
+
parser.add_argument(
|
| 116 |
+
"--azimuth-bin-size-deg",
|
| 117 |
+
type=float,
|
| 118 |
+
default=30.0,
|
| 119 |
+
help="Azimuth bin width for spatial-color plots.",
|
| 120 |
+
)
|
| 121 |
+
parser.add_argument(
|
| 122 |
+
"--azimuth-plot-samples-per-bin",
|
| 123 |
+
type=int,
|
| 124 |
+
default=40,
|
| 125 |
+
help="Max number of samples per azimuth bin in azimuth-colored plots.",
|
| 126 |
+
)
|
| 127 |
+
parser.add_argument(
|
| 128 |
+
"--num-pairs",
|
| 129 |
+
type=int,
|
| 130 |
+
default=50000,
|
| 131 |
+
help="Number of random sample pairs for latent-vs-angle correlation.",
|
| 132 |
+
)
|
| 133 |
+
parser.add_argument("--knn-k", type=int, default=5)
|
| 134 |
+
parser.add_argument("--tsne-perplexity", type=float, default=30.0)
|
| 135 |
+
parser.add_argument("--skip-umap", action="store_true")
|
| 136 |
+
return parser.parse_args()
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _build_config_namespace(args: argparse.Namespace) -> SimpleNamespace:
|
| 140 |
+
return SimpleNamespace(
|
| 141 |
+
preset=args.preset,
|
| 142 |
+
ov1_manifest=args.ov1_manifest,
|
| 143 |
+
ov2_manifest=args.ov2_manifest,
|
| 144 |
+
ov3_manifest=args.ov3_manifest,
|
| 145 |
+
ov1_real_manifest=args.ov1_real_manifest,
|
| 146 |
+
ov2_real_manifest=args.ov2_real_manifest,
|
| 147 |
+
ov3_real_manifest=args.ov3_real_manifest,
|
| 148 |
+
batch_size=None,
|
| 149 |
+
num_workers=None,
|
| 150 |
+
amp=None,
|
| 151 |
+
num_epochs=None,
|
| 152 |
+
learning_rate=None,
|
| 153 |
+
weight_decay=None,
|
| 154 |
+
output_dir=None,
|
| 155 |
+
class_finetuned_ckpt=None,
|
| 156 |
+
init_from_spatial_ckpt=None,
|
| 157 |
+
resume=None,
|
| 158 |
+
no_resume_optimizer=False,
|
| 159 |
+
reset_epoch_on_resume=False,
|
| 160 |
+
reset_best_on_resume=False,
|
| 161 |
+
crop_mode=None,
|
| 162 |
+
max_clip_duration_seconds=None,
|
| 163 |
+
save_every_n_epochs=None,
|
| 164 |
+
train_projector_in_stage1=False,
|
| 165 |
+
freeze_trunk=False,
|
| 166 |
+
no_progress=False,
|
| 167 |
+
distributed=False,
|
| 168 |
+
local_rank=None,
|
| 169 |
+
distributed_backend=None,
|
| 170 |
+
ddp_find_unused_parameters=False,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _resolve_preset_alias(preset: str, checkpoint_path: str) -> str:
|
| 175 |
+
"""Map shorthand eval presets to concrete training presets.
|
| 176 |
+
|
| 177 |
+
Common convenience form:
|
| 178 |
+
ov1_local_spatial_v7dc
|
| 179 |
+
|
| 180 |
+
will be resolved to either
|
| 181 |
+
ov1_local_spatial_v7dc_classwarmup
|
| 182 |
+
or
|
| 183 |
+
ov1_local_spatial_v7dc_spatial
|
| 184 |
+
|
| 185 |
+
depending on the checkpoint path.
|
| 186 |
+
"""
|
| 187 |
+
checkpoint_str = str(checkpoint_path)
|
| 188 |
+
if preset == "ov1_local_spatial_v7dc":
|
| 189 |
+
if "/02_spatial/" in checkpoint_str or checkpoint_str.endswith("02_spatial/best.pt"):
|
| 190 |
+
return "ov1_local_spatial_v7dc_spatial"
|
| 191 |
+
return "ov1_local_spatial_v7dc_classwarmup"
|
| 192 |
+
if preset == "ov1_local_spatial_v7":
|
| 193 |
+
if "/02_spatial/" in checkpoint_str or checkpoint_str.endswith("02_spatial/best.pt"):
|
| 194 |
+
return "ov1_local_spatial_v7_spatial"
|
| 195 |
+
return "ov1_local_spatial_v7_classwarmup"
|
| 196 |
+
return preset
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _overlay_dataclass_from_dict(target: object, source: Dict[str, object]) -> None:
|
| 200 |
+
for key, value in source.items():
|
| 201 |
+
if not hasattr(target, key):
|
| 202 |
+
continue
|
| 203 |
+
current = getattr(target, key)
|
| 204 |
+
if hasattr(current, "__dataclass_fields__") and isinstance(value, dict):
|
| 205 |
+
_overlay_dataclass_from_dict(current, value)
|
| 206 |
+
continue
|
| 207 |
+
if isinstance(current, tuple) and isinstance(value, list):
|
| 208 |
+
value = tuple(value)
|
| 209 |
+
setattr(target, key, value)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def load_eval_config(args: argparse.Namespace) -> TrainSpatialBEATsConfig:
|
| 213 |
+
resolved_preset = _resolve_preset_alias(args.preset, args.checkpoint)
|
| 214 |
+
if resolved_preset != args.preset:
|
| 215 |
+
print(f"[LatentViz] Resolve preset alias: {args.preset} -> {resolved_preset}")
|
| 216 |
+
args = copy.copy(args)
|
| 217 |
+
args.preset = resolved_preset
|
| 218 |
+
cfg = build_train_config_from_args(_build_config_namespace(args))
|
| 219 |
+
ckpt = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
|
| 220 |
+
if isinstance(ckpt, dict) and isinstance(ckpt.get("train_cfg"), dict):
|
| 221 |
+
_overlay_dataclass_from_dict(cfg, ckpt["train_cfg"])
|
| 222 |
+
|
| 223 |
+
cfg.batch_size = int(args.batch_size)
|
| 224 |
+
cfg.num_workers = int(args.num_workers)
|
| 225 |
+
cfg.amp_dtype = args.amp
|
| 226 |
+
cfg.distributed = False
|
| 227 |
+
cfg.show_progress_bars = True
|
| 228 |
+
cfg.dump_val_predictions = False
|
| 229 |
+
cfg.num_val_prediction_examples = 0
|
| 230 |
+
cfg.train_splits = ()
|
| 231 |
+
cfg.val_splits = ()
|
| 232 |
+
cfg.test_splits = tuple(cfg.test_splits) if cfg.test_splits else ("test",)
|
| 233 |
+
return cfg
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _module_available(module_name: str) -> bool:
|
| 237 |
+
return importlib.util.find_spec(module_name) is not None
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def preflight_runtime_checks(
|
| 241 |
+
train_cfg: TrainSpatialBEATsConfig,
|
| 242 |
+
args: argparse.Namespace,
|
| 243 |
+
) -> None:
|
| 244 |
+
missing: List[str] = []
|
| 245 |
+
notes: List[str] = []
|
| 246 |
+
|
| 247 |
+
# Audio loading path in spatial_dataset.py:
|
| 248 |
+
# prefer soundfile; fallback scipy.io.wavfile; last resort stdlib wave only
|
| 249 |
+
# for PCM wav. To keep behavior predictable, require at least one of
|
| 250 |
+
# soundfile / scipy up front.
|
| 251 |
+
if not (_module_available("soundfile") or _module_available("scipy")):
|
| 252 |
+
missing.append("soundfile or scipy")
|
| 253 |
+
|
| 254 |
+
if not args.skip_umap and umap is None:
|
| 255 |
+
missing.append("umap-learn")
|
| 256 |
+
|
| 257 |
+
if getattr(train_cfg.model, "use_kaldi_w_channel", False) and not _module_available("torchaudio"):
|
| 258 |
+
missing.append("torchaudio")
|
| 259 |
+
|
| 260 |
+
if missing:
|
| 261 |
+
raise RuntimeError(
|
| 262 |
+
"Missing runtime dependencies before extraction: "
|
| 263 |
+
+ ", ".join(missing)
|
| 264 |
+
+ ". Install them first or adjust flags/preset."
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
notes.append("torch")
|
| 268 |
+
notes.append("numpy")
|
| 269 |
+
notes.append("matplotlib")
|
| 270 |
+
notes.append("scikit-learn")
|
| 271 |
+
notes.append("tqdm")
|
| 272 |
+
notes.append("soundfile" if _module_available("soundfile") else "scipy")
|
| 273 |
+
if not args.skip_umap:
|
| 274 |
+
notes.append("umap-learn")
|
| 275 |
+
if getattr(train_cfg.model, "use_kaldi_w_channel", False):
|
| 276 |
+
notes.append("torchaudio")
|
| 277 |
+
print(f"[LatentViz] Dependency check passed: {', '.join(notes)}")
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def load_model(
|
| 281 |
+
checkpoint_path: str,
|
| 282 |
+
train_cfg: TrainSpatialBEATsConfig,
|
| 283 |
+
device: torch.device,
|
| 284 |
+
) -> SpatialBEATs:
|
| 285 |
+
model_cfg = build_model_config(train_cfg)
|
| 286 |
+
model = SpatialBEATs(model_cfg)
|
| 287 |
+
ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 288 |
+
state_dict = ckpt["model_state_dict"] if "model_state_dict" in ckpt else ckpt
|
| 289 |
+
missing, unexpected = model.load_state_dict(state_dict, strict=False)
|
| 290 |
+
if missing:
|
| 291 |
+
print(
|
| 292 |
+
f"[LatentViz] WARNING: missing keys ({len(missing)}): "
|
| 293 |
+
f"{missing[:8]}{' ...' if len(missing) > 8 else ''}"
|
| 294 |
+
)
|
| 295 |
+
if unexpected:
|
| 296 |
+
print(
|
| 297 |
+
f"[LatentViz] WARNING: unexpected keys ({len(unexpected)}): "
|
| 298 |
+
f"{unexpected[:8]}{' ...' if len(unexpected) > 8 else ''}"
|
| 299 |
+
)
|
| 300 |
+
model = model.to(device)
|
| 301 |
+
model.eval()
|
| 302 |
+
return model
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def build_test_loader(
|
| 306 |
+
train_cfg: TrainSpatialBEATsConfig,
|
| 307 |
+
) -> torch.utils.data.DataLoader:
|
| 308 |
+
dataset_cfg = build_dataset_config(train_cfg)
|
| 309 |
+
test_cfg = copy.deepcopy(dataset_cfg)
|
| 310 |
+
test_cfg.allowed_splits = train_cfg.test_splits
|
| 311 |
+
|
| 312 |
+
manifest_paths = train_cfg.test_manifest_paths
|
| 313 |
+
if not manifest_paths:
|
| 314 |
+
manifest_paths = train_cfg.val_manifest_paths
|
| 315 |
+
if not manifest_paths:
|
| 316 |
+
manifest_paths = train_cfg.train_manifest_paths
|
| 317 |
+
if not manifest_paths and train_cfg.train_manifest_path:
|
| 318 |
+
manifest_paths = (train_cfg.train_manifest_path,)
|
| 319 |
+
if not manifest_paths:
|
| 320 |
+
raise RuntimeError("No manifest paths available for test split evaluation.")
|
| 321 |
+
|
| 322 |
+
datasets = []
|
| 323 |
+
for path in manifest_paths:
|
| 324 |
+
ds = SpatialDataset(manifest_path=path, config=test_cfg)
|
| 325 |
+
if len(ds) > 0:
|
| 326 |
+
datasets.append(ds)
|
| 327 |
+
if not datasets:
|
| 328 |
+
raise RuntimeError("No test samples found after split filtering.")
|
| 329 |
+
|
| 330 |
+
dataset = datasets[0] if len(datasets) == 1 else torch.utils.data.ConcatDataset(datasets)
|
| 331 |
+
print(f"[LatentViz] Test set size: {len(dataset)}")
|
| 332 |
+
collate_fn = functools.partial(collate_spatial_batch, config=test_cfg)
|
| 333 |
+
return torch.utils.data.DataLoader(
|
| 334 |
+
dataset,
|
| 335 |
+
batch_size=train_cfg.batch_size,
|
| 336 |
+
shuffle=False,
|
| 337 |
+
num_workers=train_cfg.num_workers,
|
| 338 |
+
collate_fn=collate_fn,
|
| 339 |
+
pin_memory=True,
|
| 340 |
+
drop_last=False,
|
| 341 |
+
persistent_workers=train_cfg.num_workers > 0,
|
| 342 |
+
prefetch_factor=4 if train_cfg.num_workers > 0 else None,
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _move_batch_to_device(batch: SpatialBatch, device: torch.device) -> SpatialBatch:
|
| 347 |
+
return SpatialBatch(
|
| 348 |
+
waveform=batch.waveform.to(device),
|
| 349 |
+
waveform_padding_mask=batch.waveform_padding_mask.to(device)
|
| 350 |
+
if batch.waveform_padding_mask is not None
|
| 351 |
+
else None,
|
| 352 |
+
clip_duration_seconds=batch.clip_duration_seconds.to(device),
|
| 353 |
+
target_num_steps=batch.target_num_steps.to(device),
|
| 354 |
+
source_class_indices=batch.source_class_indices.to(device),
|
| 355 |
+
source_azimuth_deg=batch.source_azimuth_deg.to(device),
|
| 356 |
+
source_elevation_deg=batch.source_elevation_deg.to(device),
|
| 357 |
+
source_distance=batch.source_distance.to(device),
|
| 358 |
+
source_distance_valid=batch.source_distance_valid.to(device),
|
| 359 |
+
source_start_time_seconds=batch.source_start_time_seconds.to(device),
|
| 360 |
+
source_end_time_seconds=batch.source_end_time_seconds.to(device),
|
| 361 |
+
source_valid_mask=batch.source_valid_mask.to(device),
|
| 362 |
+
sample_ids=batch.sample_ids,
|
| 363 |
+
source_class_labels=batch.source_class_labels,
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def _amp_context(amp_dtype: str):
|
| 368 |
+
if not torch.cuda.is_available():
|
| 369 |
+
return contextlib.nullcontext()
|
| 370 |
+
if amp_dtype == "bf16":
|
| 371 |
+
return torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16)
|
| 372 |
+
if amp_dtype == "fp16":
|
| 373 |
+
return torch.amp.autocast(device_type="cuda", dtype=torch.float16)
|
| 374 |
+
return contextlib.nullcontext()
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def _masked_mean(sequence: torch.Tensor, padding_mask: Optional[torch.Tensor]) -> torch.Tensor:
|
| 378 |
+
if padding_mask is None:
|
| 379 |
+
return sequence.mean(dim=1)
|
| 380 |
+
valid = (~padding_mask).to(dtype=sequence.dtype).unsqueeze(-1)
|
| 381 |
+
denom = valid.sum(dim=1).clamp_min(1.0)
|
| 382 |
+
return (sequence * valid).sum(dim=1) / denom
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _build_direction_vectors(azimuth_deg: np.ndarray, elevation_deg: np.ndarray) -> np.ndarray:
|
| 386 |
+
azi = np.deg2rad(azimuth_deg.astype(np.float64))
|
| 387 |
+
ele = np.deg2rad(elevation_deg.astype(np.float64))
|
| 388 |
+
x = np.cos(ele) * np.cos(azi)
|
| 389 |
+
y = np.cos(ele) * np.sin(azi)
|
| 390 |
+
z = np.sin(ele)
|
| 391 |
+
return np.stack([x, y, z], axis=1)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _angular_distance_deg_matrix(v1: np.ndarray, v2: np.ndarray) -> np.ndarray:
|
| 395 |
+
dot = np.clip(np.sum(v1 * v2, axis=-1), -1.0, 1.0)
|
| 396 |
+
return np.degrees(np.arccos(dot))
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _cosine_distance_matrix(x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
| 400 |
+
x = _l2_normalize(x)
|
| 401 |
+
y = _l2_normalize(y)
|
| 402 |
+
return 1.0 - x @ y.T
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _l2_normalize(x: np.ndarray) -> np.ndarray:
|
| 406 |
+
norm = np.linalg.norm(x, axis=1, keepdims=True)
|
| 407 |
+
norm = np.clip(norm, 1e-12, None)
|
| 408 |
+
return x / norm
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def _majority_vote(labels: np.ndarray) -> int:
|
| 412 |
+
values, counts = np.unique(labels, return_counts=True)
|
| 413 |
+
return int(values[np.argmax(counts)])
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _rankdata(values: np.ndarray) -> np.ndarray:
|
| 417 |
+
order = np.argsort(values, kind="mergesort")
|
| 418 |
+
ranks = np.empty_like(order, dtype=np.float64)
|
| 419 |
+
ranks[order] = np.arange(len(values), dtype=np.float64)
|
| 420 |
+
sorted_vals = values[order]
|
| 421 |
+
unique_vals, first_idx, counts = np.unique(sorted_vals, return_index=True, return_counts=True)
|
| 422 |
+
_ = unique_vals # unused but keeps the shape logic clear
|
| 423 |
+
for start, count in zip(first_idx, counts):
|
| 424 |
+
if count <= 1:
|
| 425 |
+
continue
|
| 426 |
+
mean_rank = (2 * start + count - 1) / 2.0
|
| 427 |
+
ranks[order[start : start + count]] = mean_rank
|
| 428 |
+
return ranks
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _pearsonr(x: np.ndarray, y: np.ndarray) -> float:
|
| 432 |
+
x_centered = x - x.mean()
|
| 433 |
+
y_centered = y - y.mean()
|
| 434 |
+
denom = np.linalg.norm(x_centered) * np.linalg.norm(y_centered)
|
| 435 |
+
if denom < 1e-12:
|
| 436 |
+
return 0.0
|
| 437 |
+
return float(np.dot(x_centered, y_centered) / denom)
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def _spearmanr(x: np.ndarray, y: np.ndarray) -> float:
|
| 441 |
+
return _pearsonr(_rankdata(x), _rankdata(y))
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _safe_perplexity(num_samples: int, requested: float) -> float:
|
| 445 |
+
if num_samples <= 3:
|
| 446 |
+
return 2.0
|
| 447 |
+
upper = max(5.0, (num_samples - 1) / 3.0)
|
| 448 |
+
return float(min(requested, upper))
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _fit_reducer(method: str, x: np.ndarray, seed: int, tsne_perplexity: float) -> np.ndarray:
|
| 452 |
+
if StandardScaler is None:
|
| 453 |
+
raise ImportError("scikit-learn is required for PCA/t-SNE visualization.")
|
| 454 |
+
x = _l2_normalize(x)
|
| 455 |
+
x = StandardScaler(with_mean=True, with_std=True).fit_transform(x)
|
| 456 |
+
if method == "pca":
|
| 457 |
+
reducer = PCA(n_components=2, random_state=seed)
|
| 458 |
+
return reducer.fit_transform(x)
|
| 459 |
+
if method == "tsne":
|
| 460 |
+
reducer = TSNE(
|
| 461 |
+
n_components=2,
|
| 462 |
+
perplexity=_safe_perplexity(len(x), tsne_perplexity),
|
| 463 |
+
learning_rate="auto",
|
| 464 |
+
init="pca",
|
| 465 |
+
random_state=seed,
|
| 466 |
+
)
|
| 467 |
+
return reducer.fit_transform(x)
|
| 468 |
+
if method == "umap":
|
| 469 |
+
if umap is None:
|
| 470 |
+
raise ImportError("umap-learn is not installed; rerun with --skip-umap or install it.")
|
| 471 |
+
reducer = umap.UMAP(
|
| 472 |
+
n_components=2,
|
| 473 |
+
metric="cosine",
|
| 474 |
+
n_neighbors=min(15, max(5, len(x) - 1)),
|
| 475 |
+
min_dist=0.1,
|
| 476 |
+
random_state=seed,
|
| 477 |
+
)
|
| 478 |
+
return reducer.fit_transform(x)
|
| 479 |
+
raise ValueError(f"Unsupported reducer: {method}")
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def _plot_categorical_scatter(
|
| 483 |
+
coords: np.ndarray,
|
| 484 |
+
labels: Sequence[int],
|
| 485 |
+
label_names: Dict[int, str],
|
| 486 |
+
title: str,
|
| 487 |
+
output_path: Path,
|
| 488 |
+
) -> None:
|
| 489 |
+
unique_labels = list(dict.fromkeys(int(v) for v in labels))
|
| 490 |
+
cmap = plt.get_cmap("tab20")
|
| 491 |
+
plt.figure(figsize=(8.5, 7.0))
|
| 492 |
+
for idx, class_idx in enumerate(unique_labels):
|
| 493 |
+
mask = np.asarray(labels) == class_idx
|
| 494 |
+
plt.scatter(
|
| 495 |
+
coords[mask, 0],
|
| 496 |
+
coords[mask, 1],
|
| 497 |
+
s=24,
|
| 498 |
+
alpha=0.82,
|
| 499 |
+
color=cmap(idx % 20),
|
| 500 |
+
label=f"{class_idx}: {label_names.get(class_idx, str(class_idx))}",
|
| 501 |
+
)
|
| 502 |
+
plt.title(title)
|
| 503 |
+
plt.xlabel("dim-1")
|
| 504 |
+
plt.ylabel("dim-2")
|
| 505 |
+
plt.legend(
|
| 506 |
+
fontsize=7,
|
| 507 |
+
loc="center left",
|
| 508 |
+
bbox_to_anchor=(1.02, 0.5),
|
| 509 |
+
frameon=False,
|
| 510 |
+
)
|
| 511 |
+
plt.tight_layout()
|
| 512 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 513 |
+
plt.savefig(output_path, dpi=220, bbox_inches="tight")
|
| 514 |
+
plt.close()
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def _plot_continuous_scatter(
|
| 518 |
+
coords: np.ndarray,
|
| 519 |
+
values: np.ndarray,
|
| 520 |
+
title: str,
|
| 521 |
+
output_path: Path,
|
| 522 |
+
cmap: str,
|
| 523 |
+
colorbar_label: str,
|
| 524 |
+
) -> None:
|
| 525 |
+
plt.figure(figsize=(8.5, 7.0))
|
| 526 |
+
scatter = plt.scatter(
|
| 527 |
+
coords[:, 0],
|
| 528 |
+
coords[:, 1],
|
| 529 |
+
c=values,
|
| 530 |
+
cmap=cmap,
|
| 531 |
+
s=24,
|
| 532 |
+
alpha=0.82,
|
| 533 |
+
)
|
| 534 |
+
plt.title(title)
|
| 535 |
+
plt.xlabel("dim-1")
|
| 536 |
+
plt.ylabel("dim-2")
|
| 537 |
+
colorbar = plt.colorbar(scatter)
|
| 538 |
+
colorbar.set_label(colorbar_label)
|
| 539 |
+
plt.tight_layout()
|
| 540 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 541 |
+
plt.savefig(output_path, dpi=220, bbox_inches="tight")
|
| 542 |
+
plt.close()
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def _write_jsonl(path: Path, rows: Sequence[Dict[str, object]]) -> None:
|
| 546 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 547 |
+
with path.open("w", encoding="utf-8") as handle:
|
| 548 |
+
for row in rows:
|
| 549 |
+
handle.write(json.dumps(row, ensure_ascii=True) + "\n")
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def _to_python_rows(rows: Sequence[Dict[str, object]]) -> List[Dict[str, object]]:
|
| 553 |
+
result: List[Dict[str, object]] = []
|
| 554 |
+
for row in rows:
|
| 555 |
+
converted: Dict[str, object] = {}
|
| 556 |
+
for key, value in row.items():
|
| 557 |
+
if isinstance(value, (np.integer, np.int64, np.int32)):
|
| 558 |
+
converted[key] = int(value)
|
| 559 |
+
elif isinstance(value, (np.floating, np.float32, np.float64)):
|
| 560 |
+
converted[key] = float(value)
|
| 561 |
+
else:
|
| 562 |
+
converted[key] = value
|
| 563 |
+
result.append(converted)
|
| 564 |
+
return result
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def extract_test_latents(
|
| 568 |
+
model: SpatialBEATs,
|
| 569 |
+
test_loader: torch.utils.data.DataLoader,
|
| 570 |
+
train_cfg: TrainSpatialBEATsConfig,
|
| 571 |
+
device: torch.device,
|
| 572 |
+
max_test_samples: int,
|
| 573 |
+
) -> Tuple[Dict[str, np.ndarray], List[Dict[str, object]], Dict[str, float]]:
|
| 574 |
+
vocab = load_source_vocabulary(train_cfg.dataset.source_vocab, show_progress=False)
|
| 575 |
+
index_to_label = vocab["index_to_label"]
|
| 576 |
+
|
| 577 |
+
class_tokens: List[np.ndarray] = []
|
| 578 |
+
semantic_mean_tokens: List[np.ndarray] = []
|
| 579 |
+
spatial_tokens: List[np.ndarray] = []
|
| 580 |
+
fused_tokens: List[np.ndarray] = []
|
| 581 |
+
llm_tokens: List[np.ndarray] = []
|
| 582 |
+
records: List[Dict[str, object]] = []
|
| 583 |
+
|
| 584 |
+
running = {
|
| 585 |
+
"class_acc": 0.0,
|
| 586 |
+
"azi_mae_deg": 0.0,
|
| 587 |
+
"ele_mae_deg": 0.0,
|
| 588 |
+
"dist_mae": 0.0,
|
| 589 |
+
"matched_count": 0.0,
|
| 590 |
+
}
|
| 591 |
+
num_batches = 0
|
| 592 |
+
seld_acc = SELDMetricsAccumulator() if train_cfg.loss.supervision_mode == "mono_ast" else None
|
| 593 |
+
seen = 0
|
| 594 |
+
|
| 595 |
+
with torch.no_grad():
|
| 596 |
+
progress = tqdm(test_loader, desc="Extract test latents", leave=False)
|
| 597 |
+
for batch in progress:
|
| 598 |
+
if max_test_samples > 0 and seen >= max_test_samples:
|
| 599 |
+
break
|
| 600 |
+
batch = _move_batch_to_device(batch, device)
|
| 601 |
+
mono_window_mask = None
|
| 602 |
+
if train_cfg.loss.supervision_mode == "mono_ast":
|
| 603 |
+
mono_window_mask = build_primary_source_window_mask(
|
| 604 |
+
batch=batch,
|
| 605 |
+
t_s_max=int(batch.target_num_steps.max().item()),
|
| 606 |
+
).to(device)
|
| 607 |
+
|
| 608 |
+
with _amp_context(train_cfg.amp_dtype):
|
| 609 |
+
output: SpatialBEATsOutput = model(
|
| 610 |
+
waveform=batch.waveform,
|
| 611 |
+
padding_mask=batch.waveform_padding_mask,
|
| 612 |
+
clip_duration_seconds=batch.clip_duration_seconds,
|
| 613 |
+
mono_window_mask=mono_window_mask,
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
if output.mono_task_tokens is None or output.mono_task_tokens.size(1) < 2:
|
| 617 |
+
raise RuntimeError(
|
| 618 |
+
"This checkpoint does not expose mono_task_tokens [B,2,D]; "
|
| 619 |
+
"class/spatial token visualization is unavailable."
|
| 620 |
+
)
|
| 621 |
+
fused_seq = output.fused_spatial_embeddings
|
| 622 |
+
if fused_seq is None:
|
| 623 |
+
fused_seq = output.spatial_embeddings
|
| 624 |
+
fused_mean = _masked_mean(fused_seq, output.temporal_padding_mask)
|
| 625 |
+
llm_mean = _masked_mean(output.llm_spatial_tokens, output.temporal_padding_mask)
|
| 626 |
+
semantic_mean = output.encoder_memory.mean(dim=1)
|
| 627 |
+
|
| 628 |
+
class_token_batch = output.mono_task_tokens[:, 0, :].detach().float().cpu().numpy()
|
| 629 |
+
spatial_token_batch = output.mono_task_tokens[:, 1, :].detach().float().cpu().numpy()
|
| 630 |
+
semantic_mean_batch = semantic_mean.detach().float().cpu().numpy()
|
| 631 |
+
fused_token_batch = fused_mean.detach().float().cpu().numpy()
|
| 632 |
+
llm_token_batch = llm_mean.detach().float().cpu().numpy()
|
| 633 |
+
|
| 634 |
+
pred_cls = None
|
| 635 |
+
pred_cls_conf = None
|
| 636 |
+
pred_azi = None
|
| 637 |
+
pred_ele = None
|
| 638 |
+
pred_dist = None
|
| 639 |
+
if train_cfg.loss.supervision_mode == "mono_ast" and output.mono_prediction_output is not None:
|
| 640 |
+
metric_output = compute_mono_ast_validation_metrics(
|
| 641 |
+
prediction_output=output.mono_prediction_output,
|
| 642 |
+
batch=batch,
|
| 643 |
+
)
|
| 644 |
+
running["class_acc"] += float(metric_output.class_acc.item())
|
| 645 |
+
running["azi_mae_deg"] += float(metric_output.azi_mae_deg.item())
|
| 646 |
+
running["ele_mae_deg"] += float(metric_output.ele_mae_deg.item())
|
| 647 |
+
running["dist_mae"] += float(metric_output.dist_mae.item())
|
| 648 |
+
running["matched_count"] += float(metric_output.matched_count.item())
|
| 649 |
+
num_batches += 1
|
| 650 |
+
if seld_acc is not None:
|
| 651 |
+
accumulate_mono_ast_seld(
|
| 652 |
+
prediction_output=output.mono_prediction_output,
|
| 653 |
+
batch=batch,
|
| 654 |
+
accumulator=seld_acc,
|
| 655 |
+
)
|
| 656 |
+
pred_cls = output.mono_prediction_output.pred_class_logits.argmax(dim=-1).detach().cpu().numpy()
|
| 657 |
+
pred_cls_conf = (
|
| 658 |
+
output.mono_prediction_output.pred_class_logits.softmax(dim=-1).amax(dim=-1).detach().cpu().numpy()
|
| 659 |
+
)
|
| 660 |
+
pred_azi_t, pred_ele_t = _azi_ele_deg_from_direction_vector(output.mono_prediction_output.pred_direction)
|
| 661 |
+
pred_azi = pred_azi_t.detach().cpu().numpy()
|
| 662 |
+
pred_ele = pred_ele_t.detach().cpu().numpy()
|
| 663 |
+
pred_dist = output.mono_prediction_output.pred_distance[:, 0].detach().cpu().numpy()
|
| 664 |
+
|
| 665 |
+
batch_size = class_token_batch.shape[0]
|
| 666 |
+
for idx in range(batch_size):
|
| 667 |
+
if max_test_samples > 0 and seen >= max_test_samples:
|
| 668 |
+
break
|
| 669 |
+
valid_indices = torch.nonzero(batch.source_valid_mask[idx], as_tuple=False).flatten()
|
| 670 |
+
if len(valid_indices) == 0:
|
| 671 |
+
continue
|
| 672 |
+
primary = int(valid_indices[0].item())
|
| 673 |
+
class_idx = int(batch.source_class_indices[idx, primary].item())
|
| 674 |
+
class_name = (
|
| 675 |
+
batch.source_class_labels[idx][primary]
|
| 676 |
+
if batch.source_class_labels is not None
|
| 677 |
+
else index_to_label[class_idx]
|
| 678 |
+
)
|
| 679 |
+
record = {
|
| 680 |
+
"sample_id": batch.sample_ids[idx],
|
| 681 |
+
"class_index": class_idx,
|
| 682 |
+
"class_name": class_name,
|
| 683 |
+
"azimuth_deg": float(batch.source_azimuth_deg[idx, primary, 0].item()),
|
| 684 |
+
"elevation_deg": float(batch.source_elevation_deg[idx, primary, 0].item()),
|
| 685 |
+
"distance_m": float(batch.source_distance[idx, primary, 0].item()),
|
| 686 |
+
}
|
| 687 |
+
if pred_cls is not None:
|
| 688 |
+
record.update(
|
| 689 |
+
{
|
| 690 |
+
"pred_class_index": int(pred_cls[idx]),
|
| 691 |
+
"pred_class_name": index_to_label[int(pred_cls[idx])],
|
| 692 |
+
"pred_class_confidence": float(pred_cls_conf[idx]),
|
| 693 |
+
"pred_azimuth_deg": float(pred_azi[idx]),
|
| 694 |
+
"pred_elevation_deg": float(pred_ele[idx]),
|
| 695 |
+
"pred_distance_m": float(pred_dist[idx]),
|
| 696 |
+
}
|
| 697 |
+
)
|
| 698 |
+
records.append(record)
|
| 699 |
+
class_tokens.append(class_token_batch[idx])
|
| 700 |
+
spatial_tokens.append(spatial_token_batch[idx])
|
| 701 |
+
semantic_mean_tokens.append(semantic_mean_batch[idx])
|
| 702 |
+
fused_tokens.append(fused_token_batch[idx])
|
| 703 |
+
llm_tokens.append(llm_token_batch[idx])
|
| 704 |
+
seen += 1
|
| 705 |
+
|
| 706 |
+
if max_test_samples > 0 and seen >= max_test_samples:
|
| 707 |
+
break
|
| 708 |
+
|
| 709 |
+
summary_metrics: Dict[str, float] = {}
|
| 710 |
+
if num_batches > 0:
|
| 711 |
+
summary_metrics.update({key: value / num_batches for key, value in running.items()})
|
| 712 |
+
if seld_acc is not None:
|
| 713 |
+
summary_metrics.update(seld_acc.compute())
|
| 714 |
+
|
| 715 |
+
features = {
|
| 716 |
+
"class_token": np.stack(class_tokens, axis=0),
|
| 717 |
+
"spatial_token": np.stack(spatial_tokens, axis=0),
|
| 718 |
+
"semantic_mean_token": np.stack(semantic_mean_tokens, axis=0),
|
| 719 |
+
"fused_token": np.stack(fused_tokens, axis=0),
|
| 720 |
+
"llm_token": np.stack(llm_tokens, axis=0),
|
| 721 |
+
}
|
| 722 |
+
return features, records, summary_metrics
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def _select_evenly_spaced_indices(sorted_indices: Sequence[int], k: int) -> List[int]:
|
| 726 |
+
if len(sorted_indices) <= k:
|
| 727 |
+
return list(sorted_indices)
|
| 728 |
+
positions = np.linspace(0, len(sorted_indices) - 1, num=k)
|
| 729 |
+
chosen = sorted({int(round(pos)) for pos in positions})
|
| 730 |
+
return [int(sorted_indices[pos]) for pos in chosen][:k]
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def select_class_balanced_subset(
|
| 734 |
+
records: Sequence[Dict[str, object]],
|
| 735 |
+
num_classes: int,
|
| 736 |
+
samples_per_class: int,
|
| 737 |
+
seed: int,
|
| 738 |
+
) -> List[int]:
|
| 739 |
+
grouped: Dict[int, List[int]] = defaultdict(list)
|
| 740 |
+
for idx, row in enumerate(records):
|
| 741 |
+
grouped[int(row["class_index"])].append(idx)
|
| 742 |
+
eligible = [(cls, len(indices)) for cls, indices in grouped.items() if len(indices) > 0]
|
| 743 |
+
eligible.sort(key=lambda item: (-item[1], item[0]))
|
| 744 |
+
if not eligible:
|
| 745 |
+
return []
|
| 746 |
+
rng = random.Random(seed)
|
| 747 |
+
if len(eligible) > num_classes:
|
| 748 |
+
top = eligible[: max(num_classes * 3, num_classes)]
|
| 749 |
+
selected_classes = sorted(cls for cls, _ in rng.sample(top, k=num_classes))
|
| 750 |
+
else:
|
| 751 |
+
selected_classes = sorted(cls for cls, _ in eligible)
|
| 752 |
+
chosen: List[int] = []
|
| 753 |
+
for class_idx in selected_classes:
|
| 754 |
+
indices = grouped[class_idx]
|
| 755 |
+
indices = sorted(indices, key=lambda idx: float(records[idx]["azimuth_deg"]))
|
| 756 |
+
chosen.extend(_select_evenly_spaced_indices(indices, samples_per_class))
|
| 757 |
+
return chosen
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
def _azimuth_bin_index(azimuth_deg: float, bin_size_deg: float) -> int:
|
| 761 |
+
wrapped = (azimuth_deg + 180.0) % 360.0
|
| 762 |
+
return int(math.floor(wrapped / bin_size_deg))
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
def select_azimuth_balanced_subset(
|
| 766 |
+
records: Sequence[Dict[str, object]],
|
| 767 |
+
bin_size_deg: float,
|
| 768 |
+
samples_per_bin: int,
|
| 769 |
+
seed: int,
|
| 770 |
+
) -> List[int]:
|
| 771 |
+
grouped: Dict[int, List[int]] = defaultdict(list)
|
| 772 |
+
for idx, row in enumerate(records):
|
| 773 |
+
grouped[_azimuth_bin_index(float(row["azimuth_deg"]), bin_size_deg)].append(idx)
|
| 774 |
+
rng = random.Random(seed)
|
| 775 |
+
chosen: List[int] = []
|
| 776 |
+
for bin_idx in sorted(grouped):
|
| 777 |
+
bin_records = grouped[bin_idx]
|
| 778 |
+
by_class: Dict[int, List[int]] = defaultdict(list)
|
| 779 |
+
for idx in bin_records:
|
| 780 |
+
by_class[int(records[idx]["class_index"])].append(idx)
|
| 781 |
+
class_order = list(by_class)
|
| 782 |
+
rng.shuffle(class_order)
|
| 783 |
+
selected: List[int] = []
|
| 784 |
+
round_id = 0
|
| 785 |
+
while len(selected) < min(samples_per_bin, len(bin_records)):
|
| 786 |
+
added = False
|
| 787 |
+
for class_idx in class_order:
|
| 788 |
+
if round_id < len(by_class[class_idx]):
|
| 789 |
+
selected.append(by_class[class_idx][round_id])
|
| 790 |
+
added = True
|
| 791 |
+
if len(selected) >= samples_per_bin:
|
| 792 |
+
break
|
| 793 |
+
if not added:
|
| 794 |
+
break
|
| 795 |
+
round_id += 1
|
| 796 |
+
chosen.extend(selected)
|
| 797 |
+
return sorted(chosen)
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
def compute_knn_class_accuracy(features: np.ndarray, labels: np.ndarray, k: int) -> float:
|
| 801 |
+
if len(features) <= 1:
|
| 802 |
+
return 0.0
|
| 803 |
+
x = _l2_normalize(features)
|
| 804 |
+
n_neighbors = min(len(x), k + 1)
|
| 805 |
+
nbrs = NearestNeighbors(metric="cosine", n_neighbors=n_neighbors)
|
| 806 |
+
nbrs.fit(x)
|
| 807 |
+
indices = nbrs.kneighbors(x, return_distance=False)
|
| 808 |
+
preds = []
|
| 809 |
+
for row in indices:
|
| 810 |
+
neighbors = row[1:n_neighbors]
|
| 811 |
+
preds.append(_majority_vote(labels[neighbors]))
|
| 812 |
+
preds = np.asarray(preds, dtype=np.int64)
|
| 813 |
+
return float((preds == labels).mean())
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
def compute_nearest_neighbor_angle(features: np.ndarray, direction_vectors: np.ndarray) -> float:
|
| 817 |
+
if len(features) <= 1:
|
| 818 |
+
return 0.0
|
| 819 |
+
x = _l2_normalize(features)
|
| 820 |
+
n_neighbors = min(len(x), 2)
|
| 821 |
+
nbrs = NearestNeighbors(metric="cosine", n_neighbors=n_neighbors)
|
| 822 |
+
nbrs.fit(x)
|
| 823 |
+
indices = nbrs.kneighbors(x, return_distance=False)
|
| 824 |
+
nearest = indices[:, 1]
|
| 825 |
+
ang = _angular_distance_deg_matrix(direction_vectors, direction_vectors[nearest])
|
| 826 |
+
return float(ang.mean())
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def compute_pairwise_angle_correlation(
|
| 830 |
+
features: np.ndarray,
|
| 831 |
+
direction_vectors: np.ndarray,
|
| 832 |
+
num_pairs: int,
|
| 833 |
+
seed: int,
|
| 834 |
+
) -> Dict[str, float]:
|
| 835 |
+
n = len(features)
|
| 836 |
+
if n <= 1:
|
| 837 |
+
return {
|
| 838 |
+
"pearson": 0.0,
|
| 839 |
+
"spearman": 0.0,
|
| 840 |
+
"num_pairs": 0,
|
| 841 |
+
}
|
| 842 |
+
rng = np.random.default_rng(seed)
|
| 843 |
+
pair_count = min(num_pairs, n * (n - 1) // 2)
|
| 844 |
+
i = rng.integers(0, n, size=pair_count, endpoint=False)
|
| 845 |
+
j = rng.integers(0, n - 1, size=pair_count, endpoint=False)
|
| 846 |
+
j = np.where(j >= i, j + 1, j)
|
| 847 |
+
x = _l2_normalize(features)
|
| 848 |
+
latent_dist = 1.0 - np.sum(x[i] * x[j], axis=1)
|
| 849 |
+
angle_deg = _angular_distance_deg_matrix(direction_vectors[i], direction_vectors[j])
|
| 850 |
+
return {
|
| 851 |
+
"pearson": _pearsonr(latent_dist, angle_deg),
|
| 852 |
+
"spearman": _spearmanr(latent_dist, angle_deg),
|
| 853 |
+
"num_pairs": int(pair_count),
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
def summarize_probes(
|
| 858 |
+
features: Dict[str, np.ndarray],
|
| 859 |
+
records: Sequence[Dict[str, object]],
|
| 860 |
+
k: int,
|
| 861 |
+
num_pairs: int,
|
| 862 |
+
seed: int,
|
| 863 |
+
) -> Dict[str, object]:
|
| 864 |
+
labels = np.asarray([int(row["class_index"]) for row in records], dtype=np.int64)
|
| 865 |
+
azi = np.asarray([float(row["azimuth_deg"]) for row in records], dtype=np.float64)
|
| 866 |
+
ele = np.asarray([float(row["elevation_deg"]) for row in records], dtype=np.float64)
|
| 867 |
+
direction_vectors = _build_direction_vectors(azi, ele)
|
| 868 |
+
|
| 869 |
+
summary: Dict[str, object] = {}
|
| 870 |
+
for name, feat in features.items():
|
| 871 |
+
summary[name] = {
|
| 872 |
+
"knn_class_acc_top1": compute_knn_class_accuracy(feat, labels, k=k),
|
| 873 |
+
"nearest_neighbor_angle_deg": compute_nearest_neighbor_angle(feat, direction_vectors),
|
| 874 |
+
"pairwise_angle_correlation": compute_pairwise_angle_correlation(
|
| 875 |
+
feat,
|
| 876 |
+
direction_vectors,
|
| 877 |
+
num_pairs=num_pairs,
|
| 878 |
+
seed=seed,
|
| 879 |
+
),
|
| 880 |
+
}
|
| 881 |
+
return summary
|
| 882 |
+
|
| 883 |
+
|
| 884 |
+
def render_plots(
|
| 885 |
+
features: Dict[str, np.ndarray],
|
| 886 |
+
records: Sequence[Dict[str, object]],
|
| 887 |
+
output_dir: Path,
|
| 888 |
+
class_subset: Sequence[int],
|
| 889 |
+
azimuth_subset: Sequence[int],
|
| 890 |
+
class_feature_name: str,
|
| 891 |
+
seed: int,
|
| 892 |
+
tsne_perplexity: float,
|
| 893 |
+
skip_umap: bool,
|
| 894 |
+
) -> None:
|
| 895 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 896 |
+
reducers = ["pca", "tsne"] + ([] if skip_umap else ["umap"])
|
| 897 |
+
label_names = {int(row["class_index"]): str(row["class_name"]) for row in records}
|
| 898 |
+
|
| 899 |
+
class_indices = np.asarray(class_subset, dtype=np.int64)
|
| 900 |
+
azimuth_indices = np.asarray(azimuth_subset, dtype=np.int64)
|
| 901 |
+
|
| 902 |
+
class_labels = np.asarray([int(records[idx]["class_index"]) for idx in class_indices], dtype=np.int64)
|
| 903 |
+
class_azi = np.asarray([float(records[idx]["azimuth_deg"]) for idx in class_indices], dtype=np.float64)
|
| 904 |
+
azimuth_values = np.asarray([float(records[idx]["azimuth_deg"]) for idx in azimuth_indices], dtype=np.float64)
|
| 905 |
+
|
| 906 |
+
plot_specs = [
|
| 907 |
+
(class_feature_name, "class", class_indices, class_labels, label_names),
|
| 908 |
+
("spatial_token", "azimuth", azimuth_indices, azimuth_values, None),
|
| 909 |
+
("fused_token", "class", class_indices, class_labels, label_names),
|
| 910 |
+
("fused_token", "azimuth", azimuth_indices, azimuth_values, None),
|
| 911 |
+
]
|
| 912 |
+
|
| 913 |
+
for feature_name, color_mode, subset_indices, color_values, names in plot_specs:
|
| 914 |
+
subset_feat = features[feature_name][subset_indices]
|
| 915 |
+
for reducer_name in reducers:
|
| 916 |
+
coords = _fit_reducer(
|
| 917 |
+
method=reducer_name,
|
| 918 |
+
x=subset_feat,
|
| 919 |
+
seed=seed,
|
| 920 |
+
tsne_perplexity=tsne_perplexity,
|
| 921 |
+
)
|
| 922 |
+
title = f"{feature_name} | {color_mode} | {reducer_name.upper()} | n={len(subset_indices)}"
|
| 923 |
+
output_path = output_dir / f"{feature_name}__{color_mode}__{reducer_name}.png"
|
| 924 |
+
if color_mode == "class":
|
| 925 |
+
_plot_categorical_scatter(
|
| 926 |
+
coords=coords,
|
| 927 |
+
labels=color_values,
|
| 928 |
+
label_names=names or {},
|
| 929 |
+
title=title,
|
| 930 |
+
output_path=output_path,
|
| 931 |
+
)
|
| 932 |
+
else:
|
| 933 |
+
_plot_continuous_scatter(
|
| 934 |
+
coords=coords,
|
| 935 |
+
values=color_values.astype(np.float64),
|
| 936 |
+
title=title,
|
| 937 |
+
output_path=output_path,
|
| 938 |
+
cmap="twilight",
|
| 939 |
+
colorbar_label="azimuth (deg)",
|
| 940 |
+
)
|
| 941 |
+
|
| 942 |
+
# Extra class-feature azimuth view: useful for checking whether the feature
|
| 943 |
+
# actually used for class plots is overly entangled with position.
|
| 944 |
+
extra_feat = features[class_feature_name][class_indices]
|
| 945 |
+
for reducer_name in reducers:
|
| 946 |
+
coords = _fit_reducer(
|
| 947 |
+
method=reducer_name,
|
| 948 |
+
x=extra_feat,
|
| 949 |
+
seed=seed,
|
| 950 |
+
tsne_perplexity=tsne_perplexity,
|
| 951 |
+
)
|
| 952 |
+
_plot_continuous_scatter(
|
| 953 |
+
coords=coords,
|
| 954 |
+
values=class_azi,
|
| 955 |
+
title=f"{class_feature_name} | azimuth | {reducer_name.upper()} | n={len(class_indices)}",
|
| 956 |
+
output_path=output_dir / f"{class_feature_name}__azimuth__{reducer_name}.png",
|
| 957 |
+
cmap="twilight",
|
| 958 |
+
colorbar_label="azimuth (deg)",
|
| 959 |
+
)
|
| 960 |
+
|
| 961 |
+
|
| 962 |
+
def main() -> None:
|
| 963 |
+
args = parse_args()
|
| 964 |
+
random.seed(args.seed)
|
| 965 |
+
np.random.seed(args.seed)
|
| 966 |
+
torch.manual_seed(args.seed)
|
| 967 |
+
|
| 968 |
+
output_dir = (
|
| 969 |
+
Path(args.output_dir)
|
| 970 |
+
if args.output_dir is not None
|
| 971 |
+
else Path(args.checkpoint).parent / "test_latent_viz"
|
| 972 |
+
)
|
| 973 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 974 |
+
|
| 975 |
+
device = torch.device(
|
| 976 |
+
args.device if args.device == "cpu" or torch.cuda.is_available() else "cpu"
|
| 977 |
+
)
|
| 978 |
+
print(f"[LatentViz] Device: {device}")
|
| 979 |
+
print(f"[LatentViz] Checkpoint: {args.checkpoint}")
|
| 980 |
+
print(f"[LatentViz] Preset: {args.preset}")
|
| 981 |
+
|
| 982 |
+
train_cfg = load_eval_config(args)
|
| 983 |
+
if device.type != "cuda":
|
| 984 |
+
train_cfg.amp_dtype = "fp32"
|
| 985 |
+
preflight_runtime_checks(train_cfg, args)
|
| 986 |
+
model = load_model(args.checkpoint, train_cfg, device)
|
| 987 |
+
test_loader = build_test_loader(train_cfg)
|
| 988 |
+
|
| 989 |
+
features, records, test_metrics = extract_test_latents(
|
| 990 |
+
model=model,
|
| 991 |
+
test_loader=test_loader,
|
| 992 |
+
train_cfg=train_cfg,
|
| 993 |
+
device=device,
|
| 994 |
+
max_test_samples=int(args.max_test_samples),
|
| 995 |
+
)
|
| 996 |
+
if not records:
|
| 997 |
+
raise RuntimeError("No test samples were exported.")
|
| 998 |
+
|
| 999 |
+
class_subset = select_class_balanced_subset(
|
| 1000 |
+
records=records,
|
| 1001 |
+
num_classes=int(args.class_plot_num_classes),
|
| 1002 |
+
samples_per_class=int(args.class_plot_samples_per_class),
|
| 1003 |
+
seed=args.seed,
|
| 1004 |
+
)
|
| 1005 |
+
azimuth_subset = select_azimuth_balanced_subset(
|
| 1006 |
+
records=records,
|
| 1007 |
+
bin_size_deg=float(args.azimuth_bin_size_deg),
|
| 1008 |
+
samples_per_bin=int(args.azimuth_plot_samples_per_bin),
|
| 1009 |
+
seed=args.seed,
|
| 1010 |
+
)
|
| 1011 |
+
if not class_subset:
|
| 1012 |
+
raise RuntimeError("Class-balanced subset selection returned no samples.")
|
| 1013 |
+
if not azimuth_subset:
|
| 1014 |
+
raise RuntimeError("Azimuth-balanced subset selection returned no samples.")
|
| 1015 |
+
|
| 1016 |
+
probe_summary = summarize_probes(
|
| 1017 |
+
features=features,
|
| 1018 |
+
records=records,
|
| 1019 |
+
k=int(args.knn_k),
|
| 1020 |
+
num_pairs=int(args.num_pairs),
|
| 1021 |
+
seed=args.seed,
|
| 1022 |
+
)
|
| 1023 |
+
|
| 1024 |
+
np.savez_compressed(
|
| 1025 |
+
output_dir / "latents_all.npz",
|
| 1026 |
+
class_token=features["class_token"],
|
| 1027 |
+
spatial_token=features["spatial_token"],
|
| 1028 |
+
semantic_mean_token=features["semantic_mean_token"],
|
| 1029 |
+
fused_token=features["fused_token"],
|
| 1030 |
+
llm_token=features["llm_token"],
|
| 1031 |
+
class_index=np.asarray([int(row["class_index"]) for row in records], dtype=np.int64),
|
| 1032 |
+
azimuth_deg=np.asarray([float(row["azimuth_deg"]) for row in records], dtype=np.float32),
|
| 1033 |
+
elevation_deg=np.asarray([float(row["elevation_deg"]) for row in records], dtype=np.float32),
|
| 1034 |
+
distance_m=np.asarray([float(row["distance_m"]) for row in records], dtype=np.float32),
|
| 1035 |
+
)
|
| 1036 |
+
_write_jsonl(output_dir / "metadata_all.jsonl", _to_python_rows(records))
|
| 1037 |
+
_write_jsonl(
|
| 1038 |
+
output_dir / "class_balanced_subset.jsonl",
|
| 1039 |
+
_to_python_rows([records[idx] for idx in class_subset]),
|
| 1040 |
+
)
|
| 1041 |
+
_write_jsonl(
|
| 1042 |
+
output_dir / "azimuth_balanced_subset.jsonl",
|
| 1043 |
+
_to_python_rows([records[idx] for idx in azimuth_subset]),
|
| 1044 |
+
)
|
| 1045 |
+
|
| 1046 |
+
class_feature_name = "semantic_mean_token" if getattr(train_cfg.model, "use_direct_cls", False) else "class_token"
|
| 1047 |
+
|
| 1048 |
+
render_plots(
|
| 1049 |
+
features=features,
|
| 1050 |
+
records=records,
|
| 1051 |
+
output_dir=output_dir / "plots",
|
| 1052 |
+
class_subset=class_subset,
|
| 1053 |
+
azimuth_subset=azimuth_subset,
|
| 1054 |
+
class_feature_name=class_feature_name,
|
| 1055 |
+
seed=args.seed,
|
| 1056 |
+
tsne_perplexity=float(args.tsne_perplexity),
|
| 1057 |
+
skip_umap=bool(args.skip_umap),
|
| 1058 |
+
)
|
| 1059 |
+
|
| 1060 |
+
summary = {
|
| 1061 |
+
"checkpoint": str(args.checkpoint),
|
| 1062 |
+
"preset": args.preset,
|
| 1063 |
+
"class_plot_feature": class_feature_name,
|
| 1064 |
+
"num_test_samples": len(records),
|
| 1065 |
+
"class_plot_subset_size": len(class_subset),
|
| 1066 |
+
"azimuth_plot_subset_size": len(azimuth_subset),
|
| 1067 |
+
"test_metrics": test_metrics,
|
| 1068 |
+
"probe_summary": probe_summary,
|
| 1069 |
+
"class_distribution_top20": Counter(int(row["class_index"]) for row in records).most_common(20),
|
| 1070 |
+
}
|
| 1071 |
+
with (output_dir / "summary.json").open("w", encoding="utf-8") as handle:
|
| 1072 |
+
json.dump(summary, handle, ensure_ascii=True, indent=2)
|
| 1073 |
+
|
| 1074 |
+
if test_metrics:
|
| 1075 |
+
print(f"[LatentViz] Test metrics: {_format_metrics(test_metrics, train_cfg.loss.supervision_mode)}")
|
| 1076 |
+
print(f"[LatentViz] Exported {len(records)} samples to {output_dir}")
|
| 1077 |
+
print("[LatentViz] kNN / correlation summary:")
|
| 1078 |
+
print(json.dumps(probe_summary, ensure_ascii=True, indent=2))
|
| 1079 |
+
|
| 1080 |
+
|
| 1081 |
+
if __name__ == "__main__":
|
| 1082 |
+
main()
|