67 KiB
67 KiB
BirdCLEF+ 2025
Last updated: 2026-01-23 Source count: 1
Mel-Spectrogram 特征提取(BirdCLEF+ 2025)
基于前排方案,统一的 mel-spectrogram 提取流程:
import torch
import torchaudio
import torch.nn as nn
import numpy as np
class MelSpectrogramExtractor:
"""统一的 Mel-Spectrogram 提取器"""
def __init__(
self,
sample_rate: int = 32000,
n_mels: int = 128,
n_fft: int = 2048,
hop_length: int = 512,
fmin: float = 0.0,
fmax: float = 16000.0,
power: float = 2.0,
normalize: bool = True,
):
self.sample_rate = sample_rate
self.n_mels = n_mels
self.n_fft = n_fft
self.hop_length = hop_length
self.fmin = fmin
self.fmax = fmax
# 使用 torchaudio 的 MelSpectrogram
self.mel_transform = torchaudio.transforms.MelSpectrogram(
sample_rate=sample_rate,
n_fft=n_fft,
hop_length=hop_length,
n_mels=n_mels,
f_min=fmin,
f_max=fmax,
power=power,
normalized=normalize,
)
def extract(self, waveform: torch.Tensor) -> torch.Tensor:
"""
提取 mel-spectrogram
Args:
waveform: (num_samples,) 或 (batch, num_samples)
Returns:
mel_spec: (n_mels, time) 或 (batch, n_mels, time)
"""
if waveform.dim() == 1:
waveform = waveform.unsqueeze(0)
mel_spec = self.mel_transform(waveform)
# 转换为对数尺度
mel_spec = torch.log(mel_spec + 1e-9)
return mel_spec
def extract_fixed_length(
self, waveform: torch.Tensor, target_length: int
) -> torch.Tensor:
"""
提取固定长度的 mel-spectrogram(用于 5 秒音频)
Args:
waveform: (num_samples,)
target_length: 目标时间维度
Returns:
mel_spec: (n_mels, target_length)
"""
mel_spec = self.extract(waveform).squeeze(0)
# 调整到固定长度
if mel_spec.shape[1] < target_length:
# 填充
pad_length = target_length - mel_spec.shape[1]
mel_spec = nn.functional.pad(mel_spec, (0, pad_length))
else:
# 裁剪(从中心)
start = (mel_spec.shape[1] - target_length) // 2
mel_spec = mel_spec[:, start:start + target_length]
return mel_spec
# 常用配置(前排方案)
CONFIGS = {
"config_128": { # tf_efficientnet 系列
"n_mels": 128,
"n_fft": 2048,
"hop_length": 512,
"fmin": 0.0,
"fmax": 16000.0,
},
"config_96": { # 轻量级模型
"n_mels": 96,
"n_fft": 2048,
"hop_length": 512,
"fmin": 0.0,
"fmax": 16000.0,
},
"config_256": { # 高分辨率
"n_mels": 256,
"n_fft": 4096,
"hop_length": 1024,
"fmin": 0.0,
"fmax": 16000.0,
},
}
# 使用示例
extractor = MelSpectrogramExtractor(**CONFIGS["config_128"])
waveform, sr = torchaudio.load("audio.wav")
mel_spec = extractor.extract_fixed_length(waveform.squeeze(0), target_length=313) # 5秒 -> 313帧
伪标签生成(BirdCLEF+ 2025)
import torch
import torch.nn as nn
import numpy as np
from pathlib import Path
class PseudoLabelGenerator:
"""伪标签生成器 - 基于前排方案"""
def __init__(
self,
model: nn.Module,
threshold: float = 0.4,
use_segmentwise: bool = True,
power_transform: float = 1.0,
):
"""
Args:
model: 训练好的模型
threshold: 置信度阈值(前排方案使用 0.3-0.5)
use_segmentwise: 是否使用 segmentwise_logit(更细粒度)
power_transform: 幂次变换参数(1st Place 使用)
"""
self.model = model
self.model.eval()
self.threshold = threshold
self.use_segmentwise = use_segmentwise
self.power_transform = power_transform
@torch.no_grad()
def generate_pseudo_labels(
self,
audio_path: str,
segment_duration: int = 5,
overlap: float = 0.5,
) -> list[dict]:
"""
生成伪标签
Returns:
List of {"start": float, "end": float, "labels": np.ndarray}
"""
# 加载音频
waveform, sr = torchaudio.load(audio_path)
# 分段处理
samples_per_segment = int(segment_duration * sr)
hop_length = int(samples_per_segment * (1 - overlap))
pseudo_labels = []
for start_idx in range(0, len(waveform) - samples_per_segment, hop_length):
end_idx = start_idx + samples_per_segment
segment = waveform[:, start_idx:end_idx]
# 提取特征
mel_spec = self.extract_mel(segment)
# 模型预测
if self.use_segmentwise:
# segmentwise_logit: 更细粒度的预测
logits = self.model(mel_spec, return_segmentwise=True)
# 时间维度平均
logits = logits.mean(dim=1) # (batch, num_classes)
else:
logits = self.model(mel_spec)
# Sigmoid 激活
probs = torch.sigmoid(logits).squeeze(0).cpu().numpy()
# 幂次变换(1st Place 创新)
if self.power_transform != 1.0:
probs = np.power(probs, self.power_transform)
# 高低阈值筛选(10th Place 方法)
mask = self._apply_threshold(probs)
if mask.sum() > 0:
pseudo_labels.append({
"start": start_idx / sr,
"end": end_idx / sr,
"labels": probs,
"mask": mask,
})
return pseudo_labels
def _apply_threshold(self, probs: np.ndarray) -> np.ndarray:
"""应用高低阈值筛选"""
# 高阈值:正样本
high_threshold = 0.7
# 低阈值:负样本
low_threshold = 0.3
mask = np.zeros_like(probs, dtype=bool)
mask[probs >= high_threshold] = True # 高置信度正样本
mask[probs <= low_threshold] = True # 低置信度负样本
return mask
def extract_mel(self, waveform: torch.Tensor) -> torch.Tensor:
"""提取 mel-spectrogram(简化版本)"""
# 实际使用中应该与训练时的提取器一致
pass
# 使用示例(前排方案风格)
generator = PseudoLabelGenerator(
model=model,
threshold=0.4,
use_segmentwise=True, # 6th Place 关键
power_transform=1.5, # 1st Place 幂次变换
)
pseudo_labels = generator.generate_pseudo_labels("train_soundscape_01.wav")
MixUp 数据增强(BirdCLEF+ 2025)
import torch
import torch.nn as nn
import numpy as np
class AudioMixUp:
"""音频 MixUp 增强 - 前排方案风格"""
def __init__(
self,
alpha: float = 0.5,
mixup_type: str = "hard", # "hard" 或 "soft"
probability: float = 0.5,
):
"""
Args:
alpha: Beta 分布参数
mixup_type:
- "soft": 标准混合标签(MixUp)
- "hard": 硬混合标签(8th Place 创新)
probability: 应用 MixUp 的概率
"""
self.alpha = alpha
self.mixup_type = mixup_type
self.probability = probability
def __call__(
self,
batch: dict,
) -> dict:
"""
应用 MixUp
Args:
batch: {"mel": (B, C, H, W), "labels": (B, num_classes)}
Returns:
Mixed batch
"""
if torch.rand(1).item() > self.probability:
return batch
mel = batch["mel"]
labels = batch["labels"]
batch_size = mel.size(0)
# 生成混合权重
lam = np.random.beta(self.alpha, self.alpha)
# 随机排列
index = torch.randperm(batch_size)
# 混合特征
mixed_mel = lam * mel + (1 - lam) * mel[index]
# 混合标签
if self.mixup_type == "soft":
# 标准 MixUp: 软标签混合
mixed_labels = lam * labels + (1 - lam) * labels[index]
elif self.mixup_type == "hard":
# 硬 MixUp (8th Place): 混合标签的最大值
mixed_labels = torch.maximum(labels, labels[index])
else:
raise ValueError(f"Unknown mixup_type: {self.mixup_type}")
return {
"mel": mixed_mel,
"labels": mixed_labels,
"lam": lam, # 可能用于损失调整
}
# Sumix 增强(13th Place 使用)
class Sumix:
"""Sumix: 原始信号上的 MixUp"""
def __init__(self, alpha: float = 0.5, probability: float = 1.0):
self.alpha = alpha
self.probability = probability
def __call__(
self,
waveform: torch.Tensor,
labels: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
在原始波形上应用 Sumix
Args:
waveform: (batch, num_samples)
labels: (batch, num_classes)
Returns:
Mixed waveform and labels
"""
if torch.rand(1).item() > self.probability:
return waveform, labels
batch_size = waveform.size(0)
lam = np.random.beta(self.alpha, self.alpha)
index = torch.randperm(batch_size)
# 混合波形
mixed_waveform = lam * waveform + (1 - lam) * waveform[index]
# 混合标签(最大值)
mixed_labels = torch.maximum(labels, labels[index])
return mixed_waveform, mixed_labels
# 使用示例
mixup = AudioMixUp(alpha=0.5, mixup_type="hard", probability=0.5)
sumix = Sumix(alpha=0.5, probability=1.0)
# 训练循环中
for batch in dataloader:
# Sumix 在原始波形
waveform, labels = sumix(batch["waveform"], batch["labels"])
# 提取 mel-spectrogram
mel = extract_mel(waveform)
# MixUp 在 mel-spectrogram
batch = mixup({"mel": mel, "labels": labels})
Soft AUC Loss(BirdCLEF+ 2025 - 4th Place)
支持软标签的 AUC 损失函数:
import torch
import torch.nn as nn
import torch.nn.functional as F
class SoftAUCLoss(nn.Module):
"""
Soft AUC Loss - 4th Place 创新
支持 soft labels,适用于知识蒸馏和半监督学习
"""
def __init__(self, reduction: str = "mean"):
super().__init__()
self.reduction = reduction
def forward(
self,
predictions: torch.Tensor,
targets: torch.Tensor,
) -> torch.Tensor:
"""
Args:
predictions: (batch, num_classes) - 原始 logits
targets: (batch, num_classes) - 软标签 [0, 1]
Returns:
AUC loss
"""
# Sigmoid 激活
probs = torch.sigmoid(predictions)
# 计算 AUC loss
# 对每个类别独立计算
num_classes = predictions.size(1)
losses = []
for c in range(num_classes):
# 当前类别的预测和目标
prob_c = probs[:, c]
target_c = targets[:, c]
# 按目标值排序(软标签)
sorted_indices = torch.argsort(target_c, descending=True)
# 计算正负样本的得分差异
# 对于软标签,我们需要加权处理
positive_scores = prob_c[sorted_indices[:len(sorted_indices)//2]]
negative_scores = prob_c[sorted_indices[len(sorted_indices)//2:]]
# AUC 近似:正样本得分应该高于负样本
# 使用 sigmoid 差异
diff = positive_scores.unsqueeze(1) - negative_scores.unsqueeze(0)
loss_c = torch.sigmoid(-diff).mean()
losses.append(loss_c)
losses = torch.stack(losses)
if self.reduction == "mean":
return losses.mean()
elif self.reduction == "sum":
return losses.sum()
else:
return losses
# 改进的 AUC Loss(更稳定)
class ImprovedAUCLoss(nn.Module):
"""
改进的 AUC Loss - 更稳定且支持软标签
"""
def __init__(self, margin: float = 1.0):
super().__init__()
self.margin = margin
def forward(
self,
predictions: torch.Tensor,
targets: torch.Tensor,
) -> torch.Tensor:
"""
Args:
predictions: (batch, num_classes)
targets: (batch, num_classes) - 软标签
"""
probs = torch.sigmoid(predictions)
num_classes = predictions.size(1)
losses = []
for c in range(num_classes):
prob_c = probs[:, c]
target_c = targets[:, c]
# 计算成对损失
# 对于每个样本对 (i, j):
# 如果 target_i > target_j,则希望 prob_i > prob_j
n = prob_c.size(0)
if n < 2:
continue
# 创建样本对矩阵
target_diff = target_c.unsqueeze(1) - target_c.unsqueeze(0)
prob_diff = prob_c.unsqueeze(1) - prob_c.unsqueeze(0)
# 只考虑 target_i > target_j 的对
mask = target_diff > 0
if mask.sum() > 0:
# Hinge loss: max(0, margin - (prob_i - prob_j))
loss_c = F.relu(self.margin - prob_diff)[mask].mean()
losses.append(loss_c)
if len(losses) == 0:
return torch.tensor(0.0, device=predictions.device)
return torch.stack(losses).mean()
# 使用示例
criterion = SoftAUCLoss(reduction="mean")
# 训练循环
for batch in dataloader:
predictions = model(batch["mel"])
# 支持软标签
loss = criterion(predictions, batch["labels"])
loss.backward()
optimizer.step()
滑动窗口推理(BirdCLEF+ 2025 - 1st Place)
import torch
import torch.nn as nn
from scipy.ndimage import gaussian_filter1d
class SlidingWindowInference:
"""
滑动窗口推理 - 1st Place 创新
使用帧预测的平均值,而不是仅使用中心窗口的最大值
"""
def __init__(
self,
model: nn.Module,
window_size: int = 5, # 秒
hop_size: int = 5, # 秒(步长)
sample_rate: int = 32000,
smoothing_sigma: float = 1.0,
):
self.model = model
self.model.eval()
self.window_size = window_size
self.hop_size = hop_size
self.sample_rate = sample_rate
self.smoothing_sigma = smoothing_sigma
@torch.no_grad()
def predict(
self,
audio_path: str,
) -> dict[str, float]:
"""
对整个音频进行预测,返回 5 秒窗口的预测
Returns:
Dict of {row_id: {species_id: probability}}
"""
# 加载音频
waveform, sr = torchaudio.load(audio_path)
# 计算窗口参数
samples_per_window = int(self.window_size * sr)
samples_per_hop = int(self.hop_size * sr)
# 存储所有帧预测
all_frame_predictions = []
# 滑动窗口
window_id = 0
for start_idx in range(0, len(waveform) - samples_per_window, samples_per_hop):
end_idx = start_idx + samples_per_window
window = waveform[:, start_idx:end_idx]
# 提取特征
mel_spec = self.extract_mel(window)
# 模型预测
frame_output = self.model(mel_spec)
# 如果是 SED 模型,可能有 clipwise 和 segmentwise 输出
if isinstance(frame_output, dict):
frame_pred = frame_output["clipwise_output"]
else:
frame_pred = frame_output
all_frame_predictions.append(frame_pred.cpu().numpy())
window_id += 1
# 转换为 numpy array
all_frame_predictions = np.array(all_frame_predictions) # (num_windows, num_classes)
# 1st Place 创新: 相邻窗口帧预测平均
# 这是一种 1D 滑动窗口分割,类似于大图像的 2D 滑动窗口分割
smoothed_predictions = self._smooth_predictions(all_frame_predictions)
# 生成最终预测
predictions = {}
for window_id in range(len(smoothed_predictions)):
row_id = f"soundscape_{window_id}_{self.window_size}"
predictions[row_id] = {
f"species_{i}": float(prob)
for i, prob in enumerate(smoothed_predictions[window_id])
}
return predictions
def _smooth_predictions(
self,
predictions: np.ndarray,
) -> np.ndarray:
"""
平滑预测 - 使用高斯滤波和时间平均
Args:
predictions: (num_windows, num_classes)
Returns:
Smoothed predictions
"""
# 1. 时间维度高斯平滑
if self.smoothing_sigma > 0:
smoothed = gaussian_filter1d(
predictions,
sigma=self.smoothing_sigma,
axis=0,
mode="nearest",
)
else:
smoothed = predictions
# 2. 相邻窗口平均(1st Place 创新)
# 使用相邻 3 个窗口的平均
kernel_size = 3
if len(smoothed) >= kernel_size:
# Padding
padded = np.pad(
smoothed,
((kernel_size // 2, kernel_size // 2), (0, 0)),
mode="edge",
)
# 一维卷积平均
kernel = np.ones(kernel_size) / kernel_size
averaged = np.zeros_like(smoothed)
for c in range(smoothed.shape[1]):
averaged[:, c] = np.convolve(
padded[:, c],
kernel,
mode="valid",
)
return averaged
else:
return smoothed
def extract_mel(self, waveform: torch.Tensor) -> torch.Tensor:
"""提取 mel-spectrogram"""
# 实际使用中应该与训练时的提取器一致
pass
# 使用示例
inference = SlidingWindowInference(
model=model,
window_size=5,
hop_size=5,
smoothing_sigma=1.0,
)
predictions = inference.predict("test_soundscape_01.wav")
# 后处理(可选)
# - Delta shift: 调整低置信度类别的概率
# - Min-max 缩放
# - 频率范围调整
SED 模型架构(BirdCLEF+ 2025 标准)
前排方案广泛使用的 SED (Sound Event Detection) 模型架构:
import torch
import torch.nn as nn
import timm
class SEDModel(nn.Module):
"""
Sound Event Detection 模型
参考 BirdCLEF 2023 2nd Place 和 BirdCLEF+ 2025 前排方案
"""
def __init__(
self,
backbone: str = "tf_efficientnetv2_s.in21k",
num_classes: int = 206,
in_channels: int = 1,
pretrained: bool = True,
):
super().__init__()
self.num_classes = num_classes
# 使用 timm 的 EfficientNet 作为 backbone
self.backbone = timm.create_model(
backbone,
pretrained=pretrained,
in_chans=in_channels,
num_classes=0, # 移除分类头
)
# 获取 backbone 输出特征维度
self.features_dim = self.backbone.num_features
# 自定义注意力块 (6th Place AttBlockV2)
self.att_block = AttBlockV2(
self.features_dim,
num_classes,
activation="sigmoid",
)
def forward(self, x, return_segmentwise=False):
"""
Args:
x: (batch, in_channels, n_mels, time)
return_segmentwise: 是否返回 segmentwise_logit
Returns:
如果 return_segmentwise=False:
clipwise_output: (batch, num_classes)
如果 return_segmentwise=True:
dict with:
clipwise_output: (batch, num_classes)
segmentwise_output: (batch, num_classes, time_frames)
"""
# Backbone 特征提取
features = self.backbone(x) # (batch, features_dim, time_frames)
# 全局池化
pooled_features = features.mean(dim=[2]) # (batch, features_dim)
# 片级预测
clipwise_output = self.att_block(pooled_features) # (batch, num_classes)
if not return_segmentwise:
return clipwise_output
# 帧级预测(用于伪标签生成)
segmentwise_output = self.att_block(features) # (batch, num_classes, time_frames)
return {
"clipwise_output": clipwise_output,
"segmentwise_output": segmentwise_output,
}
class AttBlockV2(nn.Module):
"""
自定义注意力块 - 6th Place 创新
使用 softmax 和 tanh 进行归一化,结合非线性变换
"""
def __init__(
self,
in_features: int,
out_features: int,
activation: str = "sigmoid",
):
super().__init__()
self.activation = activation
self.att = nn.Conv1d(in_features, out_features, kernel_size=1)
self.cla = nn.Conv1d(in_features, out_features, kernel_size=1)
# 初始化权重(6th Place 关键)
self.apply(self._init_weights)
def _init_weights(self, m):
if isinstance(m, nn.Conv1d):
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def forward(self, x):
"""
Args:
x: (batch, in_features, time_frames) 或 (batch, in_features)
Returns:
output: (batch, out_features) 或 (batch, out_features, time_frames)
"""
if x.dim() == 2:
# 全局池化特征
x = x.unsqueeze(-1) # (batch, in_features, 1)
# 注意力权重
att = self.att(x)
att = torch.softmax(att, dim=1) # 时间维度归一化
# 分类特征
cla = self.cla(x)
# 加权求和
output = torch.clamp(torch.clamp((cla * att).sum(dim=-1), min=1e-7, max=1-1e-7), min=1e-7)
# 激活函数
if self.activation == "sigmoid":
output = torch.sigmoid(output)
elif self.activation == "none":
pass
else:
raise ValueError(f"Unknown activation: {self.activation}")
return output.squeeze(-1) if output.size(-1) == 1 else output
# 常用 backbone 配置(前排方案)
BACKBONES = {
"tf_efficientnetv2_s.in21k": {
"features_dim": 1280,
"description": "2nd Place 使用,平衡性能和速度",
},
"tf_efficientnetv2_b3.in21k": {
"features_dim": 1536,
"description": "6th Place 使用,更强性能",
},
"tf_efficientnetv2_m.in21k": {
"features_dim": 2048,
"description": "14th Place 使用,更高精度",
},
"eca_nfnet_l0": {
"features_dim": 2304,
"description": "2nd Place 使用,增加多样性",
},
}
# 使用示例
model = SEDModel(
backbone="tf_efficientnetv2_s.in21k",
num_classes=206,
in_channels=1,
pretrained=True,
)
# 训练时:片级预测
clipwise_output = model(mel_spec)
loss = criterion(clipwise_output, labels)
# 伪标签生成时:帧级预测
output = model(mel_spec, return_segmentwise=True)
segmentwise_logits = output["segmentwise_output"] # (batch, 206, time_frames)
segmentwise_probs = torch.sigmoid(segmentwise_logits)
# 时间维度平均得到更稳定的伪标签
avg_segmentwise_probs = segmentwise_probs.mean(dim=-1) # (batch, 206)
前排方案详细技术分析
2nd Place - Xeno-Canto 预训练详细流程
作者: Volodymyr Vialactea 核心创新: 使用外部数据预训练 + 5秒音频片段训练
完整流程:
import torch
import torchaudio
import pandas as pd
from pathlib import Path
class XenoCantoPretraining:
"""
2nd Place 方案:Xeno-Canto 预训练流程
关键点:
1. 下载额外的 Xeno-Canto 数据
2. 数据清洗和预处理
3. 预训练
4. 在主数据集上微调
"""
def __init__(
self,
species_list: list,
target_sample_rate: int = 32000,
segment_duration: int = 5,
):
self.species_list = species_list
self.target_sample_rate = target_sample_rate
self.segment_duration = segment_duration
def download_xeno_canto_data(self, output_dir: str = "data/xeno_canto"):
"""
步骤 1: 从 Xeno-Canto 下载数据
注意事项:
- 过滤掉当年比赛中的物种(避免数据泄漏)
- 只下载高质量录音(评分 ≥ 3.0)
- 限制每个物种的下载量(避免数据不平衡)
"""
# 使用 xeno-canto-api 或手动下载
# 这里提供框架代码
xc_species = [s for s in self.species_list if self._should_download(s)]
for species in xc_species:
# 调用 Xeno-Canto API
# 下载音频文件
# 保存到 output_dir/species_name/
pass
def _should_download(self, species: str) -> bool:
"""检查物种是否应该下载(避免数据泄漏)"""
# 过滤比赛数据集中的物种
competition_species = set(self._get_competition_species())
return species not in competition_species
def preprocess_xeno_canto(self, audio_dir: str):
"""
步骤 2: 数据清洗和预处理
2nd Place 的关键步骤:
1. 去除人声(如果可能)
2. 统一采样率到 32kHz
3. 音频归一化
4. 质量检查(SNR、时长等)
"""
audio_files = list(Path(audio_dir).rglob("*.mp3"))
cleaned_data = []
for audio_file in audio_files:
# 加载音频
waveform, sr = torchaudio.load(audio_file)
# 重采样到 32kHz
if sr != self.target_sample_rate:
resampler = torchaudio.transforms.Resample(sr, self.target_sample_rate)
waveform = resampler(waveform)
# 质量检查
if self._check_quality(waveform):
# 提取 5 秒片段
segments = self._extract_segments(waveform)
for segment in segments:
cleaned_data.append({
"file_path": str(audio_file),
"species": audio_file.parent.name,
"waveform": segment,
})
return cleaned_data
def _check_quality(self, waveform: torch.Tensor) -> bool:
"""质量检查"""
# 检查 1: 时长至少 5 秒
if waveform.shape[1] < self.target_sample_rate * self.segment_duration:
return False
# 检查 2: SNR(信噪比)
# snr = self._calculate_snr(waveform)
# if snr < 10: # 最低 10dB
# return False
# 检查 3: 削波检测
if torch.abs(waveform).max() > 0.99:
return False
return True
def _extract_segments(self, waveform: torch.Tensor) -> list:
"""
提取 5 秒音频片段
2nd Place 使用了多种采样策略:
1. 随机采样
2. 基于能量的采样(RMS)
3. 重叠采样
"""
segment_samples = self.segment_duration * self.target_sample_rate
if waveform.shape[1] <= segment_samples:
# 填充到 5 秒
padding = segment_samples - waveform.shape[1]
waveform = torch.nn.functional.pad(waveform, (0, padding))
return [waveform]
# 方法 1: 随机采样
# 2nd Place 尝试了多种方法,最终发现随机采样效果最好
# 方法 2: 基于能量的采样(RMS)
# 计算每个 5 秒窗口的 RMS 能量
# 选择能量最高的窗口
# 方法 3: 重叠采样
# 滑动窗口,hop_size = 2.5 秒
# 这里实现随机采样
max_start = waveform.shape[1] - segment_samples
start_idx = torch.randint(0, max_start, (1,)).item()
segment = waveform[:, start_idx:start_idx + segment_samples]
return [segment]
def pretrain(self, xc_data, model, save_path: str = "checkpoints/pretrained.pth"):
"""
步骤 3: 预训练
2nd Place 的预训练策略:
- 使用 Xeno-Canto 数据训练
- BCE Loss
- SpecAugment 增强
- 50-100 epochs
"""
# 创建 dataloader
train_loader = self._create_dataloader(xc_data)
# 优化器
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
# 学习率调度器
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=50, eta_min=1e-6
)
# 损失函数
criterion = nn.BCEWithLogitsLoss()
# 训练循环
model.train()
for epoch in range(50): # 50 epochs
for batch in train_loader:
mel_spec = self._extract_mel(batch["waveform"])
labels = batch["labels"]
# 前向传播
logits = model(mel_spec)
loss = criterion(logits, labels)
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step()
print(f"Epoch {epoch+1}/50, Loss: {loss.item():.4f}")
# 保存预训练模型
torch.save(model.state_dict(), save_path)
print(f"Pretrained model saved to {save_path}")
def finetune(self, model, train_data, val_data, pretrained_path: str):
"""
步骤 4: 微调
2nd Place 的微调策略:
- 加载预训练权重
- 使用更小的学习率
- 选择最佳 checkpoint(不是最后一个)
- 关键:AUC 从 0.83-0.84 跳升至 0.86-0.87
"""
# 加载预训练权重
model.load_state_dict(torch.load(pretrained_path))
# 优化器(更小的学习率)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
# 学习率调度器
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=30, eta_min=1e-7
)
# 损失函数
criterion = nn.BCEWithLogitsLoss()
best_val_score = 0
best_epoch = 0
# 微调循环
model.train()
for epoch in range(30): # 30 epochs
# 训练
for batch in train_data:
mel_spec = self._extract_mel(batch["waveform"])
labels = batch["labels"]
logits = model(mel_spec)
loss = criterion(logits, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 验证
val_score = self._validate(model, val_data)
print(f"Epoch {epoch+1}/30, Val AUC: {val_score:.4f}")
# 保存最佳模型
if val_score > best_val_score:
best_val_score = val_score
best_epoch = epoch
torch.save(model.state_dict(), f"checkpoints/best_finetuned_epoch{epoch}.pth")
scheduler.step()
print(f"Best epoch: {best_epoch}, Best Val AUC: {best_val_score:.4f}")
def _extract_mel(self, waveform: torch.Tensor) -> torch.Tensor:
"""提取 mel-spectrogram(应该与训练时一致)"""
# 实现 mel-spectrogram 提取
pass
def _create_dataloader(self, data):
"""创建 dataloader"""
pass
def _validate(self, model, val_data):
"""验证"""
pass
def _get_competition_species(self) -> list:
"""获取竞赛数据集中的物种(避免数据泄漏)"""
pass
def _calculate_snr(self, waveform: torch.Tensor) -> float:
"""计算 SNR"""
pass
# 2nd Place 关键技术总结
"""
关键发现(来自 2nd Place writeup):
1. **预训练效果显著**:
- 无预训练:AUC 0.83-0.84
- 有预训练:AUC 0.86-0.87
- 提升:+0.02-0.03 AUC
2. **Checkpoint 选择很重要**:
- 不是最后一个 epoch 最好
- 需要验证集选择最佳 checkpoint
- 通常在 epoch 10-20 之间
3. **采样策略**:
- 随机采样效果最好
- 基于能量的采样没有明显优势
- 5 秒片段是最佳长度
4. **数据增强**:
- SpecAugment 必须保留
- RandomFiltering 有效
- 即使关闭略微提高 CV,但保留确保 LB 稳定性
"""
5th Place - Self-Distillation 详细实现
作者: Noir 核心创新: 三阶段自蒸馏 + Silero VAD 数据清洗
完整流程:
import torch
import torch.nn as nn
import numpy as np
class SelfDistillationTrainer:
"""
5th Place 方案:Self-Distillation 三阶段训练
核心思想:
1. 使用 Silero VAD 去除人声
2. 三阶段自蒸馏训练
3. 迭代丰富次要标签
"""
def __init__(
self,
model: nn.Module,
num_classes: int = 206,
):
self.model = model
self.num_classes = num_classes
def stage1_initial_training(self, train_loader, val_loader, epochs=30):
"""
阶段 1: 初始训练
使用清洗后的训练音频(train_audio)进行初始训练
"""
print("=== Stage 1: Initial Training ===")
optimizer = torch.optim.AdamW(self.model.parameters(), lr=1e-3)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=epochs, eta_min=1e-6
)
criterion = nn.BCEWithLogitsLoss()
best_val_loss = float('inf')
for epoch in range(epochs):
self.model.train()
train_loss = 0
for batch in train_loader:
mel_spec = batch['mel_spec']
labels = batch['labels']
# 前向传播
logits = self.model(mel_spec)
loss = criterion(logits, labels)
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
train_loss += loss.item()
# 验证
val_loss = self._validate(self.model, val_loader, criterion)
# 学习率更新
scheduler.step()
print(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss/len(train_loader):.4f}, "
f"Val Loss: {val_loss:.4f}")
# 保存最佳模型
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(self.model.state_dict(), "checkpoints/stage1_best.pth")
print(f"Stage 1 complete. Best Val Loss: {best_val_loss:.4f}")
# 加载最佳模型用于下一阶段
self.model.load_state_dict(torch.load("checkpoints/stage1_best.pth"))
def stage2_self_distillation_train_audio(
self,
train_loader,
epochs=20,
temperature=3.0,
alpha=0.7,
):
"""
阶段 2: 使用 train_audio 的自蒸馏
使用 stage 1 模型的预测作为软标签进行蒸馏
"""
print("=== Stage 2: Self-Distillation on train_audio ===")
# stage 1 模型作为教师
teacher_model = type(self.model)(
backbone=self.model.backbone,
num_classes=self.num_classes,
)
teacher_model.load_state_dict(torch.load("checkpoints/stage1_best.pth"))
teacher_model.eval()
# 学生模型(可以重置权重或继续训练)
# 5th Place 选择继续训练
optimizer = torch.optim.AdamW(self.model.parameters(), lr=5e-4) # 更小的学习率
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=epochs, eta_min=1e-7
)
# 蒸馏损失
distillation_criterion = nn.KLDivLoss(reduction="batchmean")
bce_criterion = nn.BCEWithLogitsLoss()
best_val_loss = float('inf')
for epoch in range(epochs):
self.model.train()
train_loss = 0
for batch in train_loader:
mel_spec = batch['mel_spec']
hard_labels = batch['labels']
with torch.no_grad():
# 教师模型预测(软标签)
teacher_logits = teacher_model(mel_spec)
teacher_probs = torch.sigmoid(teacher_logits / temperature)
# 学生模型预测
student_logits = self.model(mel_spec)
student_log_probs = torch.log_softmax(student_logits / temperature, dim=-1)
# 蒸馏损失
distill_loss = distillation_criterion(student_log_probs, teacher_probs)
# 硬标签损失
bce_loss = bce_criterion(student_logits, hard_labels)
# 组合损失
loss = alpha * (temperature ** 2) * distill_loss + (1 - alpha) * bce_loss
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
train_loss += loss.item()
# 验证
val_loss = self._validate(self.model, train_loader, bce_criterion) # 用训练集验证
scheduler.step()
print(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss/len(train_loader):.4f}, "
f"Val Loss: {val_loss:.4f}")
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(self.model.state_dict(), "checkpoints/stage2_best.pth")
print(f"Stage 2 complete. Best Val Loss: {best_val_loss:.4f}")
self.model.load_state_dict(torch.load("checkpoints/stage2_best.pth"))
def stage3_self_distillation_soundscape(
self,
train_audio_loader,
soundscape_files,
epochs=20,
temperature=3.0,
alpha=0.5, # 更重视伪标签
):
"""
阶段 3: 结合 train_audio 和 train_soundscapes 的自蒸馏
关键创新:丰富次要标签
- 许多音频包含未标注的鸟叫声
- 通过自蒸馏发现这些次要标签
"""
print("=== Stage 3: Self-Distillation with soundscape ===")
# stage 2 模型作为教师
teacher_model = type(self.model)(
backbone=self.model.backbone,
num_classes=self.num_classes,
)
teacher_model.load_state_dict(torch.load("checkpoints/stage2_best.pth"))
teacher_model.eval()
optimizer = torch.optim.AdamW(self.model.parameters(), lr=3e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=epochs, eta_min=1e-7
)
distillation_criterion = nn.KLDivLoss(reduction="batchmean")
bce_criterion = nn.BCEWithLogitsLoss()
# 生成 soundscape 的伪标签
soundscape_pseudo_labels = self._generate_pseudo_labels(
teacher_model, soundscape_files
)
# 合并 train_audio 和 soundscape 数据
# 50% train_audio + 50% soundscape
best_val_loss = float('inf')
for epoch in range(epochs):
self.model.train()
train_loss = 0
# 训练 train_audio(带硬标签)
for batch in train_audio_loader:
if np.random.rand() > 0.5:
continue # 50% 概率使用 train_audio
mel_spec = batch['mel_spec']
hard_labels = batch['labels']
with torch.no_grad():
teacher_logits = teacher_model(mel_spec)
teacher_probs = torch.sigmoid(teacher_logits / temperature)
student_logits = self.model(mel_spec)
student_log_probs = torch.log_softmax(student_logits / temperature, dim=-1)
distill_loss = distillation_criterion(student_log_probs, teacher_probs)
bce_loss = bce_criterion(student_logits, hard_labels)
loss = alpha * (temperature ** 2) * distill_loss + (1 - alpha) * bce_loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
train_loss += loss.item()
# 训练 soundscape(伪标签)
for batch in soundscape_pseudo_labels:
if np.random.rand() <= 0.5:
continue # 50% 概率使用 soundscape
mel_spec = batch['mel_spec']
pseudo_labels = batch['labels'] # 软标签
with torch.no_grad():
teacher_logits = teacher_model(mel_spec)
teacher_probs = torch.sigmoid(teacher_logits / temperature)
student_logits = self.model(mel_spec)
student_log_probs = torch.log_softmax(student_logits / temperature, dim=-1)
# 只使用蒸馏损失(没有硬标签)
distill_loss = distillation_criterion(student_log_probs, teacher_probs)
optimizer.zero_grad()
distill_loss.backward()
optimizer.step()
train_loss += distill_loss.item()
scheduler.step()
print(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss:.4f}")
# 保存检查点
if epoch % 5 == 0:
torch.save(self.model.state_dict(), f"checkpoints/stage3_epoch{epoch}.pth")
print("Stage 3 complete")
def _generate_pseudo_labels(
self,
model: nn.Module,
audio_files: list,
) -> list:
"""
生成 soundscape 的伪标签
关键:丰富次要标签
- 使用帧级预测(segmentwise)
- 时间维度平均
"""
model.eval()
pseudo_labels = []
with torch.no_grad():
for audio_file in audio_files:
# 加载音频
waveform, sr = torchaudio.load(audio_file)
# 分段处理(5秒窗口)
segments = self._split_audio(waveform, sr)
for segment in segments:
mel_spec = self._extract_mel(segment)
# 获取帧级预测
output = model(mel_spec, return_segmentwise=True)
segmentwise_logits = output["segmentwise_output"] # (1, 206, time)
segmentwise_probs = torch.sigmoid(segmentwise_logits)
# 时间维度平均(关键:丰富次要标签)
avg_probs = segmentwise_probs.mean(dim=-1).squeeze(0) # (206,)
pseudo_labels.append({
"mel_spec": mel_spec,
"labels": avg_probs,
})
return pseudo_labels
def _split_audio(self, waveform: torch.Tensor, sr: int) -> list:
"""分段处理音频"""
segment_samples = 5 * sr
segments = []
for i in range(0, waveform.shape[1], segment_samples):
segment = waveform[:, i:i+segment_samples]
if segment.shape[1] == segment_samples:
segments.append(segment)
else:
# 填充
padding = segment_samples - segment.shape[1]
segment = torch.nn.functional.pad(segment, (0, padding))
segments.append(segment)
return segments
def _extract_mel(self, waveform: torch.Tensor) -> torch.Tensor:
"""提取 mel-spectrogram"""
pass
def _validate(self, model, val_loader, criterion):
"""验证"""
model.eval()
total_loss = 0
with torch.no_grad():
for batch in val_loader:
mel_spec = batch['mel_spec']
labels = batch['labels']
logits = model(mel_spec)
loss = criterion(logits, labels)
total_loss += loss.item()
return total_loss / len(val_loader)
class SileroVADDataCleaner:
"""
Silero VAD 数据清洗
5th Place 使用 Silero VAD 检测并去除人声片段
"""
def __init__(self):
# 加载 Silero VAD 模型
self.model, utils = torch.hub.load(
repo_or_dir='snakers4/silero-vad',
model='silero_vad',
force_reload=False,
onnx=False
)
self.model.eval()
def clean_audio(self, audio_path: str, output_path: str):
"""
去除包含人声的音频片段
Returns:
清洗后的音频(去除人声部分)
"""
waveform, sr = torchaudio.load(audio_path)
# 转换为单声道
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
# 重采样到 16kHz(Silero VAD 要求)
if sr != 16000:
resampler = torchaudio.transforms.Resample(sr, 16000)
waveform = resampler(waveform)
sr = 16000
# VAD 检测
speech_chunks = self._detect_speech(waveform, sr)
# 如果检测到人声,去除这些片段
if speech_chunks:
cleaned_waveform = self._remove_speech_chunks(waveform, speech_chunks)
else:
cleaned_waveform = waveform
# 保存清洗后的音频
torchaudio.save(output_path, cleaned_waveform, sr)
return cleaned_waveform
def _detect_speech(self, waveform: torch.Tensor, sr: int) -> list:
"""
检测人声片段
Returns:
List of (start_ms, end_ms) tuples
"""
# 获取语音概率
speech_probs = []
window_size = 512 # 32ms at 16kHz
for i in range(0, waveform.shape[1], window_size):
chunk = waveform[:, i:i+window_size]
if chunk.shape[1] < window_size:
continue
with torch.no_grad():
speech_prob = self.model(chunk, sr).item()
speech_probs.append(speech_prob)
# 阈值检测(人声概率 > 0.5)
speech_chunks = []
in_speech = False
start_idx = 0
for i, prob in enumerate(speech_probs):
if prob > 0.5 and not in_speech:
in_speech = True
start_idx = i * window_size
elif prob <= 0.5 and in_speech:
in_speech = False
end_idx = i * window_size
speech_chunks.append((start_idx, end_idx))
# 转换为毫秒
speech_chunks_ms = [(s * 1000 / sr, e * 1000 / sr) for s, e in speech_chunks]
return speech_chunks_ms
def _remove_speech_chunks(
self,
waveform: torch.Tensor,
speech_chunks: list,
) -> torch.Tensor:
"""去除人声片段"""
sr = 16000
# 将时间转换为样本索引
speech_ranges = [(int(s * sr / 1000), int(e * sr / 1000)) for s, e in speech_chunks]
# 创建掩码(True 表示保留)
mask = torch.ones(waveform.shape[1], dtype=torch.bool)
for start, end in speech_ranges:
mask[start:end] = False
# 应用掩码
cleaned_waveform = waveform[:, mask]
return cleaned_waveform
# 5th Place 关键技术总结
"""
关键发现(来自 5th Place writeup):
1. **Silero VAD 有效**:
- 去除人声减少误检
- 清洗后数据质量提升
2. **三阶段自蒸馏**:
- Stage 1: 基础训练
- Stage 2: train_audio 自蒸馏
- Stage 3: 加入 soundscape 伪标签
- 每个阶段都带来提升
3. **丰富次要标签**:
- 许多音频包含未标注的鸟叫声
- 使用帧级预测和时间平均
- 迭代训练发现更多标签
4. **数据平衡重要**:
- 样本 <20 的类别复制到 20
- 样本 <30 的类别手动筛选
- 使用前 30/60 秒数据
"""
1st Place - Multi-Iterative Noisy Student 详细流程
作者: Nikita Babych 核心创新: 多迭代 Noisy Student + MixUp + 幂次变换
完整流程:
import torch
import torch.nn as nn
import numpy as np
class MultiIterativeNoisyStudent:
"""
1st Place 方案:多迭代 Noisy Student 自训练
核心创新:
1. 多迭代自训练,每次使用 MixUp
2. 伪标签幂次变换减少噪声
3. 滑动窗口推理,帧预测平均
"""
def __init__(
self,
model: nn.Module,
num_classes: int = 206,
num_iterations: int = 3,
):
self.model = model
self.num_classes = num_classes
self.num_iterations = num_iterations
# 1st Place 关键参数
self.mixup_alpha = 0.5
self.power_transform = 1.5 # 幂次变换参数(减少伪标签噪声)
def train_iteration(
self,
train_audio_loader,
train_soundscape_files,
iteration: int,
epochs=30,
):
"""
执行一次 Noisy Student 迭代
Args:
iteration: 当前迭代编号(0, 1, 2, ...)
"""
print(f"=== Noisy Student Iteration {iteration + 1} ===")
# 准备数据
# 50% train_audio + 50% 伪标签 soundscape
if iteration == 0:
# 第一次迭代:只使用 train_audio
train_loader = train_audio_loader
else:
# 后续迭代:混合 train_audio 和伪标签
train_loader = self._prepare_mixed_data(
train_audio_loader,
train_soundscape_files,
iteration,
)
# 优化器
optimizer = torch.optim.AdamW(self.model.parameters(), lr=1e-3)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=epochs, eta_min=1e-6
)
criterion = nn.BCEWithLogitsLoss()
best_val_loss = float('inf')
for epoch in range(epochs):
self.model.train()
train_loss = 0
for batch in train_loader:
mel_spec = batch['mel_spec']
labels = batch['labels']
# MixUp 数据增强(1st Place 关键)
if np.random.rand() < 0.5: # 50% 概率应用 MixUp
mel_spec, labels = self._apply_mixup(mel_spec, labels)
# 前向传播
logits = self.model(mel_spec)
loss = criterion(logits, labels)
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
train_loss += loss.item()
# 验证(使用训练集的一个子集)
val_loss = self._quick_validate(train_audio_loader, criterion)
scheduler.step()
print(f"Iteration {iteration+1}, Epoch {epoch+1}/{epochs}, "
f"Train Loss: {train_loss/len(train_loader):.4f}, "
f"Val Loss: {val_loss:.4f}")
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(self.model.state_dict(),
f"checkpoints/noisy_student_iter{iteration}_best.pth")
print(f"Iteration {iteration+1} complete. Best Val Loss: {best_val_loss:.4f}")
def _prepare_mixed_data(
self,
train_audio_loader,
soundscape_files,
iteration: int,
):
"""
准备混合数据:train_audio + 伪标签 soundscape
关键:幂次变换减少伪标签噪声(1st Place 创新)
"""
# 生成伪标签
pseudo_labels = self._generate_pseudo_labels_power_transform(
soundscape_files,
self.power_transform,
)
# 创建混合 dataloader
mixed_data = []
# 添加 train_audio
for batch in train_audio_loader:
mixed_data.append(batch)
# 添加伪标签 soundscape
for item in pseudo_labels:
mixed_data.append(item)
# 打乱顺序
np.random.shuffle(mixed_data)
return mixed_data
def _generate_pseudo_labels_power_transform(
self,
audio_files: list,
power: float = 1.5,
) -> list:
"""
生成伪标签并应用幂次变换
1st Place 关键创新:幂次变换减少噪声
原理:
- 直接对概率进行温度缩放会提高噪声的概率
- 通过幂次变换,防止噪声的放大,并保留重要的标签信号
"""
self.model.eval()
pseudo_labels = []
with torch.no_grad():
for audio_file in audio_files:
waveform, sr = torchaudio.load(audio_file)
# 分段处理(5秒窗口)
segments = self._split_audio(waveform, sr)
for segment in segments:
mel_spec = self._extract_mel(segment)
# 获取预测
logits = self.model(mel_spec)
probs = torch.sigmoid(logits).squeeze(0).cpu().numpy() # (206,)
# 幂次变换(1st Place 创新)
# power > 1: 压缩低概率,扩展高概率
# power < 1: 扩展低概率,压缩高概率
probs_transformed = np.power(probs, power)
pseudo_labels.append({
"mel_spec": mel_spec,
"labels": torch.tensor(probs_transformed, dtype=torch.float32),
})
return pseudo_labels
def _apply_mixup(
self,
mel_spec: torch.Tensor,
labels: torch.Tensor,
) -> tuple:
"""
MixUp 数据增强
1st Place 关键:使用固定混合权重 0.5
"""
batch_size = mel_spec.size(0)
# 生成混合权重
lam = np.random.beta(self.mixup_alpha, self.mixup_alpha)
# 1st Place 发现固定权重 0.5 效果更好
# lam = 0.5
# 随机排列
index = torch.randperm(batch_size)
# 混合特征
mixed_mel = lam * mel_spec + (1 - lam) * mel_spec[index]
# 混合标签(取最大值)
mixed_labels = torch.maximum(labels, labels[index])
return mixed_mel, mixed_labels
def _split_audio(self, waveform: torch.Tensor, sr: int) -> list:
"""分段处理音频"""
segment_samples = 5 * sr
segments = []
for i in range(0, waveform.shape[1], segment_samples):
segment = waveform[:, i:i+segment_samples]
if segment.shape[1] == segment_samples:
segments.append(segment)
else:
padding = segment_samples - segment.shape[1]
segment = torch.nn.functional.pad(segment, (0, padding))
segments.append(segment)
return segments
def _extract_mel(self, waveform: torch.Tensor) -> torch.Tensor:
"""提取 mel-spectrogram"""
pass
def _quick_validate(self, val_loader, criterion):
"""快速验证"""
self.model.eval()
total_loss = 0
count = 0
with torch.no_grad():
for i, batch in enumerate(val_loader):
if i >= 10: # 只验证前 10 个 batch
break
mel_spec = batch['mel_spec']
labels = batch['labels']
logits = self.model(mel_spec)
loss = criterion(logits, labels)
total_loss += loss.item()
count += 1
return total_loss / max(count, 1)
# 1st Place 关键技术总结
"""
关键发现(来自 1st Place writeup):
1. **多迭代 Noisy Student 有效**:
- 每次迭代都带来提升
- 3 次迭代是最优的
- 更多迭代可能导致噪声累积
2. **幂次变换是关键**:
- 直接使用伪标签:性能提升有限
- 幂次变换(power=1.5):显著提升
- 防止噪声放大,保留信号
3. **MixUp 策略**:
- 固定权重 0.5 比随机权重更稳定
- 迫使模型学习更鲁棒的特征
- 减少过拟合
4. **滑动窗口推理**:
- 使用帧预测的平均值
- 避免丢弃有价值的预测数据
- 类似图像的 2D 滑动窗口分割
"""
# 1st Place 完整训练流程示例
def train_noisy_student_full_pipeline():
"""
完整的 Noisy Student 训练流程
"""
# 初始化
model = SEDModel(num_classes=206)
trainer = MultiIterativeNoisyStudent(model, num_iterations=3)
# 准备数据
train_audio_loader = ... # 训练音频 loader
soundscape_files = ... # soundscape 文件列表
# 迭代 0: 只使用 train_audio
print("=== Iteration 0: Training on train_audio only ===")
trainer.train_iteration(train_audio_loader, soundscape_files, iteration=0, epochs=30)
# 迭代 1: 加入伪标签 soundscape
print("=== Iteration 1: Adding pseudo-labeled soundscape ===")
trainer.train_iteration(train_audio_loader, soundscape_files, iteration=1, epochs=30)
# 迭代 2: 使用新的伪标签
print("=== Iteration 2: Refreshing pseudo labels ===")
trainer.train_iteration(train_audio_loader, soundscape_files, iteration=2, epochs=30)
# 最终集成:使用不同迭代的模型
model_iter0 = SEDModel(num_classes=206)
model_iter0.load_state_dict(torch.load("checkpoints/noisy_student_iter0_best.pth"))
model_iter1 = SEDModel(num_classes=206)
model_iter1.load_state_dict(torch.load("checkpoints/noisy_student_iter1_best.pth"))
model_iter2 = SEDModel(num_classes=206)
model_iter2.load_state_dict(torch.load("checkpoints/noisy_student_iter2_best.pth"))
# 集成预测
def ensemble_predict(mel_spec):
pred0 = torch.sigmoid(model_iter0(mel_spec))
pred1 = torch.sigmoid(model_iter1(mel_spec))
pred2 = torch.sigmoid(model_iter2(mel_spec))
# 简单平均
ensemble_pred = (pred0 + pred1 + pred2) / 3
return ensemble_pred
return ensemble_predict
4th Place - Soft AUC Loss 详细分析
作者: dylan.liu 核心创新: 支持软标签的 AUC 损失函数
问题背景:
- 标准 AUC 损失函数不支持软标签(适用于知识蒸馏和半监督学习)
- 4th Place 通过自定义 soft AUC loss 解决这个问题
- 效果:从 11 名跃升至 4 名(LB 从 0.850 → 0.901)
import torch
import torch.nn as nn
import torch.nn.functional as F
class SoftAUCLoss_v4(nn.Module):
"""
4th Place Soft AUC Loss 实现
参考:4th Place writeup
效果:LB 从 0.850 → 0.901(显著提升)
核心思想:
1. 支持 soft labels(适用于知识蒸馏和半监督学习)
2. 通过正负样本对的排序关系优化 AUC
3. 减少 overfitting
"""
def __init__(
self,
margin: float = 1.0,
reduction: str = "mean",
):
super().__init__()
self.margin = margin
self.reduction = reduction
def forward(
self,
predictions: torch.Tensor,
targets: torch.Tensor,
) -> torch.Tensor:
"""
Args:
predictions: (batch, num_classes) - 原始 logits
targets: (batch, num_classes) - 软标签 [0, 1]
Returns:
AUC loss
"""
probs = torch.sigmoid(predictions)
num_classes = predictions.size(1)
losses = []
for c in range(num_classes):
prob_c = probs[:, c] # (batch,)
target_c = targets[:, c] # (batch,)
# 计算所有样本对的差异
# 对于软标签,我们需要加权处理
# 创建样本对矩阵
# target_diff > 0 表示 target_i > target_j
target_diff = target_c.unsqueeze(1) - target_c.unsqueeze(0) # (batch, batch)
prob_diff = prob_c.unsqueeze(1) - prob_c.unsqueeze(0) # (batch, batch)
# 只考虑 target_i > target_j 的对
# 即正样本(高 target)应该有更高的预测概率
mask = target_diff > 0
if mask.sum() > 0:
# Hinge loss: max(0, margin - (prob_i - prob_j))
# 理想情况:prob_i > prob_j(正样本预测高于负样本)
# margin - (prob_i - prob_j) 应该 <= 0
# 如果 > 0,说明违反了排序关系
loss_c = F.relu(self.margin - prob_diff)[mask].mean()
# 4th Place 发现加权版本更有效
# 使用 target_diff 作为权重
# weight = target_diff[mask]
# weighted_loss = F.relu(self.margin - prob_diff)[mask] * weight
# loss_c = weighted_loss.sum() / weight.sum()
losses.append(loss_c)
if len(losses) == 0:
return torch.tensor(0.0, device=predictions.device, requires_grad=True)
losses = torch.stack(losses)
if self.reduction == "mean":
return losses.mean()
elif self.reduction == "sum":
return losses.sum()
else:
return losses
class SoftAUCLoss_Advanced(nn.Module):
"""
改进的 Soft AUC Loss
结合 4th Place 的发现和其他优化:
1. 温度缩放
2. 自适应 margin
3. 类别加权
"""
def __init__(
self,
margin: float = 1.0,
temperature: float = 1.0,
use_class_weighting: bool = True,
):
super().__init__()
self.margin = margin
self.temperature = temperature
self.use_class_weighting = use_class_weighting
def forward(
self,
predictions: torch.Tensor,
targets: torch.Tensor,
) -> torch.Tensor:
"""
Args:
predictions: (batch, num_classes)
targets: (batch, num_classes) - 软标签
"""
# 温度缩放
probs = torch.sigmoid(predictions / self.temperature)
num_classes = predictions.size(1)
losses = []
for c in range(num_classes):
prob_c = probs[:, c]
target_c = targets[:, c]
# 样本对矩阵
target_diff = target_c.unsqueeze(1) - target_c.unsqueeze(0)
prob_diff = prob_c.unsqueeze(1) - prob_c.unsqueeze(0)
# mask: target_i > target_j
mask = target_diff > 0
if mask.sum() > 0:
# Hinge loss
base_loss = F.relu(self.margin - prob_diff)[mask]
# 可选:使用 target_diff 作为权重
# 这给予高 target 差异的样本对更高权重
weights = target_diff[mask]
weighted_loss = base_loss * weights
loss_c = weighted_loss.sum() / weights.sum()
# 可选:类别权重(处理长尾分布)
if self.use_class_weighting:
# 稀有类别更高权重
class_weight = self._get_class_weight(c, num_classes)
loss_c = loss_c * class_weight
losses.append(loss_c)
if len(losses) == 0:
return torch.tensor(0.0, device=predictions.device, requires_grad=True)
return torch.stack(losses).mean()
def _get_class_weight(self, class_idx: int, num_classes: int) -> float:
"""
计算类别权重(处理长尾分布)
简单版本:可以基于样本频率
"""
# 这里使用简单策略:可以替换为实际的类别频率
#稀有类获得更高权重
return 1.0 # 可以自定义
# 4th Place 关键发现总结
"""
关键发现(来自 4th Place writeup):
1. **Soft AUC Loss 显著提升性能**:
- LB 从 0.850 → 0.901
- 排名从 11 名 → 4 名
- +0.05 AUC 提升是巨大的
2. **为什么 Soft AUC Loss 有效**:
- 标准 AUC loss 只支持硬标签(0 或 1)
- Soft AUC Loss 支持软标签(0 到 1 之间)
- 适用于知识蒸馏和半监督学习
- 减少 overfitting
3. **实现细节**:
- 使用样本对的排序关系
- Hinge loss: max(0, margin - (prob_i - prob_j))
- 只考虑 target_i > target_j 的对
- margin 通常设为 1.0
4. **适用场景**:
- 半监督学习(伪标签)
- 知识蒸馏(软标签)
- 长尾分布(稀有类别)
- 标签噪声(软标签更鲁棒)
5. **与其他损失函数对比**:
- BCE Loss: 简单但易过拟合
- Focal Loss: 处理类别不平衡,但不优化 AUC
- Soft AUC Loss: 直接优化 AUC,支持软标签
"""
# 使用示例
def train_with_soft_auc_loss():
"""使用 Soft AUC Loss 训练"""
model = SEDModel(num_classes=206)
# 标准训练:BCE Loss
criterion_bce = nn.BCEWithLogitsLoss()
# 半监督训练:Soft AUC Loss
criterion_soft_auc = SoftAUCLoss_v4(margin=1.0)
# 优化器
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
# 训练循环
for epoch in range(30):
model.train()
for batch in train_loader:
mel_spec = batch['mel_spec']
labels = batch['labels'] # 可能是软标签
# 选择损失函数
if batch.get('is_pseudo', False): # 伪标签数据
# 使用 Soft AUC Loss
loss = criterion_soft_auc(model(mel_spec), labels)
else: # 真实标签
# 可以使用 BCE Loss 或 Soft AUC Loss
loss = criterion_bce(model(mel_spec), labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"Epoch {epoch+1}/30, Loss: {loss.item():.4f}")
Best Practices
时间序列分类竞赛策略
| 策略 | 何时使用 | 说明 |
|---|---|---|
| CWT over STFT | 非平稳信号 | CWT提供更好的时间-频率局部化 |
| Entmax over Softmax | 标签稀疏时 | Entmax产生更稀疏的输出 |
| 非负线性回归集成 | 多模型集成时 | 即使过拟合也能保持相关性 |
| 2-Stage Training | 标签质量不均时 | Stage1全数据,Stage2高质量样本 |
| Group K-Fold | 有重复样本时 | 确保同一patient/EEG不分散 |
| 仅用高质量样本 | 评估时 | 使用votes≥10的样本建立验证集 |
时频分析方法对比
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| STFT | 简单,易实现 | 固定窗口,时频分辨率权衡 | 平稳信号 |
| CWT | 多分辨率分析,捕捉局部特征 | 需要选择小波函数 | 非平稳信号,EEG |
| Superlet | 最高时频分辨率 | 计算成本高 | 复杂脑波模式 |
频率配置经验
| 配置 | 范围 | 说明 |
|---|---|---|
| 标准CWT | 0.5-20 Hz | Kaggle提供的spectrogram默认范围 |
| 扩展CWT | 0.5-40 Hz | 更好的结果 (suguuuuu) |
| 带通滤波 | 0.5-40 Hz | 高频噪声增加'other'投票 |
数据增强策略
时间序列 (1D):
- 随机时间偏移 (±5秒)
- 随机带通滤波 (不同频率范围)
- 通道翻转 (水平/垂直)
- 幅值缩放
Scalogram/Spectrogram (2D):
- XYMasking (随机遮挡)
- Mixup
- 时间方向翻转
Backbone选择
时间序列 (1D):
- 1D CNN + GRU
- Transformer (Time-series Transformer)
- LSTM/GRU
Scalogram (2D):
- SwinV2: swinv2_tiny_window16 (最佳: CV 0.2229)
- MaxVIT: maxvit_base_tf_512
- ConvNeXt: convnextv2_atto
标签处理技巧
| 技巧 | 效果 |
|---|---|
| 标签平滑 (加0.02 offset) | 使低投票数标签获得更强正则化 |
| 仅用votes≥10评估 | CV/LB相关性接近1:1 |
| 投票数归一化 | 多专家投票转换为分布 |
常见误区
| 误区 | 正确做法 |
|---|---|
| STFT不够好就放弃时频分析 | 尝试CWT或Superlet |
| Softmax输出不够稀疏 | 使用Entmax |
| 集成权重手动调参 | 使用非负线性回归 |
| 用全部样本验证 | 仅用高质量样本 (votes≥10) |
| 忽略Group K-Fold | 防止同一patient的数据泄露 |