简介:这份深度学习资源面向水下声学研究者和船舶识别相关工程师,提供一套融合卷积操作与注意力机制的双分支网络分类方案。模型以5通道水下声学特征为输入,覆盖低频段lf0/lf1、高频段hf、梅尔频谱mel与常数Q变换cq,可有效区分不同水下航行器声音模式,适用于海洋监测、资源勘探及军事防御等场景。资源包共8个文件,包含4个Python脚本、说明txt、附赠docx、README及gitignore,压缩包仅53KB,便于快速下载与复现。脚本覆盖从特征转换到双分支注意力模型训练、EfficientNet训练等环节,结构清晰。目前已吸引55人浏览学习。通过说明文档与附赠资料,使用者可掌握数据处理、模型构建与训练调参的关键思路,适合希望在水声信号处理中引入深度学习方法的入门至进阶研究者,也可作为相关课程或课题的参考实现。
1. 水下船舶声音分类:5通道特征与双分支网络为什么能压住海上噪声
水下船舶声音分类这个方向,最容易被低估的是特征设计。我最早做被动声呐目标识别时,直接拿Mel频谱图丢给CNN,结果在低信噪比录音上完全翻车:螺旋桨空化噪声把中高频段糊成一片,模型学到的是环境噪声纹理,而不是船的特征。后来换成这套思路——把声学信号拆成低频线谱特征lf0/lf1、高频调制谱特征hf、梅尔频谱Mel、常数Q变换CQT这5个通道,再用融合卷积与注意力机制的双分支网络做分类,效果才稳定下来。这套方案的适用场景很明确:被动声呐目标识别、水下目标分类、海洋环境监测,以及任何信噪比低、样本量不大但需要稳定判别的水声任务。下面从特征提取到网络落地,按可复现的方式完整拆解。
2. 5通道声学特征提取:lf0、lf1、hf、Mel与CQT各自负责什么
2.1 先看物理声源,再看特征通道
水下船声信号的频谱构成大致可以分为三类。第一类是螺旋桨低频线谱,由轴频和叶频产生,通常在几Hz到几百Hz,不同船只的区别主要体现在这些离散谱线的位置和数量上。第二类是宽带噪声,来自流体动力噪声和螺旋桨空化,能量分布在中高频段,上限经常超过20kHz。第三类是包络调制成分,空化噪声强度随螺旋桨叶片转动周期性变化,在包络上形成低频调制峰,这个特征容易被人忽略,但对船型识别非常关键。
对应到5个通道上:lf0和lf1负责低频线谱区,一个贴近基频,一个负责谐波延展;hf负责高频空化区的包络调制谱;Mel和CQT则把整段信号按两种不同频率分辨率映射成时频图,给网络提供整体上下文。5个特征不是简单堆叠,而是按物理来源分工再融合。在实际项目里,这种设计在低信噪比数据上的表现比单独使用任何一种特征都要稳定。
这里的实现前提是时间对齐。5个通道的帧长和帧移必须统一,否则进网络时同一个时间点对应的是不同位置的信号,网络再怎么调都学不到联合特征。建议把所有特征统一用hop_length=512或1024来计算,后续堆叠成多通道张量时才不会出现帧数对不上的问题。
2.2 lf0与lf1:低频子带统计特征提取方法
低频段特征的常见做法是先STFT再按频率子带聚合,而不是直接用原始谱线。原因在于水下实录数据的低频段经常被船舶自身辐射和海洋环境噪声污染,子带统计能平滑掉一部分干扰。lf0和lf1的频段设置并不唯一,我通常这样定:lf0覆盖20-300Hz,对应轴频和叶频主线谱;lf1覆盖300-1500Hz,对应线谱谐波拖尾。如果目标包含超低噪声的安静型船只,lf0下边界要压到5Hz,此时帧长必须同步加长,否则FFT频率分辨率不够。
import numpy as np import librosa def extract_lf_subband(y, sr, f_lo, f_hi, n_fft=4096, hop=1024, n_sub=16): """ 提取低频子带统计特征, 用作 lf0/lf1 通道 y : 单通道时域波形, 浮点型 sr : 采样率, 常见 48k 或 96k f_lo/f_hi: 子带频率范围, 单位 Hz n_sub : 将频带内平均切分成几个子带 返回 (帧数, n_sub) 的对数能量矩阵 """ S = np.abs(librosa.stft(y, n_fft=n_fft, hop_length=hop)) ** 2 freqs = librosa.fft_frequencies(sr=sr, n_fft=n_fft) band_idx = np.where((freqs >= f_lo) & (freqs <= f_hi))[0] if len(band_idx) < n_sub: # 频带内bin不足时降级处理 n_sub = max(1, len(band_idx)) S_band = S[band_idx, :] # (频带bin数, 帧数) sub_bands = np.array_split(S_band, n_sub, axis=0) feat = np.stack([np.mean(sb, axis=0) for sb in sub_bands], axis=0) return np.log(feat + 1e-9).T参数上有几个点需要特别注意。n_fft=4096在48k采样率下频率分辨率约11.7Hz,如果目标船轴频低于10Hz,这个分辨率不够,需要把n_fft提到8192甚至更大。hop_length决定了时间帧数,5个特征通道必须统一用同一个hop_length才能保证最终对齐。输出是(帧数, n_sub)结构,lf0和lf1分别调用一次函数,两者的n_sub可以设置成不同的值,但在最终组成5通道输入时,建议统一拉伸到相同维度。
2.3 hf高频特征:包络调制谱提取
高频段直接用频谱图容易被环境噪声淹没,多数项目的有效做法是先对高频段做带通滤波,再提取Hilbert包络,最后对包络做FFT得到调制谱。螺旋桨叶片通过频率会周期性地调制空化噪声强度,因此包络谱上会在叶片通过频率及其谐波处出现明显峰值。这个特征在安静型船只识别上尤其好用,因为它的物理来源非常特定。
from scipy.signal import butter, lfilter, hilbert import numpy as np def extract_hf_modulation(y, sr, f_lo=10000, f_hi=24000, mod_lo=2, mod_hi=80, n_sub=16): """ 提取高频段包络调制谱, 用作 hf 通道 f_lo/f_hi : 高频带通范围, 单位 Hz mod_lo/mod_hi: 调制频率分析范围, 单位 Hz 返回 (帧数, n_sub) 调制谱特征 """ nyq = sr / 2.0 if f_hi > nyq: # 防止截止频率超出奈奎斯特 f_hi = nyq - 1 b, a = butter(4, [f_lo / nyq, f_hi / nyq], btype='band') y_hf = lfilter(b, a, y) env = np.abs(hilbert(y_hf)) env = env - env.mean() # 去掉直流分量再分析调制 mod_spec = np.abs(np.fft.rfft(env, n=8192)) mod_freqs = np.fft.rfftfreq(8192, 1.0 / sr) mask = (mod_freqs >= mod_lo) & (mod_freqs <= mod_hi) if mask.sum() < n_sub: n_sub = max(1, int(mask.sum())) mod_band = np.array_split(mod_spec[mask], n_sub) feat = np.stack([np.mean(mb) for mb in mod_band], axis=0) return np.log(feat + 1e-9)注意调制频率通常在几Hz到几十Hz,这个频段与lf0/lf1的线谱频段不重叠,因此hf通道与低频特征不会冗余。实现时如果数据是短样本(比如只有2秒一条),调制频率最低只能分辨到0.5Hz,勉强够用。样本最短建议10秒以上,否则2Hz附近的调制峰根本看不见。这一点决定了数据切片的时长,和普通语音分类里“2到3秒足够”完全不同。
2.4 Mel与CQT:两套时频网格互补,组成5通道的后两路
Mel频谱在低频段分辨率高、高频段粗,符合人耳听觉特性;CQT的带宽随中心频率等比变化,在低频段频率分辨率高,在高频段时间分辨率高,正适合谐波结构明显的船舶线谱特征。两者都保留时频图结构,但维度往往不同。常见做法是Mel取64或128个滤波器,CQT取每八度24个bin。
import librosa def extract_mel_cq(y, sr, fmin=20, fmax=20000, n_mels=64, n_cq_bins=96): """ 提取 Mel 和 CQT 两个通道 n_mels : mel滤波器组个数 n_cq_bins : 常数Q变换总bin数 统一使用 hop_length=1024 与前面特征对齐 """ mel = librosa.feature.melspectrogram( y=y, sr=sr, n_fft=2048, hop_length=1024, fmin=fmin, fmax=fmax, n_mels=n_mels ) mel_db = librosa.power_to_db(mel) cqt = np.abs(librosa.cqt( y=y, sr=sr, hop_length=1024, fmin=fmin, n_bins=n_cq_bins, bins_per_octave=24 )) cqt_db = librosa.amplitude_to_db(cqt) return mel_db, cqt_db, mel.shape[1], cqt.shape[1]这里有一个容易忽略的细节:librosa.stft的输出帧数与melspectrogram一致,但librosa.cqt如果不指定分帧参数,内部会做补零处理,帧数可能和另外两个不一致。返回的mel.shape[1]和cqt.shape[1]就是为了检查这个,最终统一取最短帧数或裁剪到相同帧数。组成5通道输入时,把lf0、lf1、hf、mel、cq按通道维堆叠成形状(N, 5, T, F),其中F对mel和cq分别取64,对lf0/lf1/hf的子带统计结果也统一成64维。网络第一层Conv2d的输入通道数就是5。
实际堆叠代码可以这样写,帧数裁剪逻辑放进Dataset里,避免在特征提取阶段反复改全局变量:
def stack_features(lf0, lf1, hf, mel, cq, target_frames=128): """ 将5个特征通道按时间帧对齐并裁剪到固定长度 """ min_frames = min(lf0.shape[0], lf1.shape[0], hf.shape[0], mel.shape[1], cq.shape[1]) used = min(min_frames, target_frames) lf0 = lf0[:used] lf1 = lf1[:used] hf = hf[:used] mel = mel[:, :used].T cq = cq[:, :used].T return np.stack([lf0, lf1, hf, mel, cq], axis=1) # (used, 5, 64)返回维度是(used, 5, 64),在PyTorch里再permute成(5, used, 64)作为Conv2d输入。注意mel和cq要转置成帧数在前、频带在后,与lf0等保持一致的行是帧的约定。到这里5通道输入就完全确定了。
3. 双分支网络:卷积提取谱型,注意力机制重标定有效频带
3.1 为什么用双分支而不是一个共享主干
如果5个通道直接拼在一起用一个CNN,低频线谱和高频调制谱的数值尺度差异会让低频通道很快被高能量通道淹没。卷积核在局部区域做加权求和,对数值范围大的通道天然更敏感,而水下信号里高频宽带能量往往比低频线谱高很多。双分支的本意是分开处理两类不同物理特性的输入,在各自的表示空间里先做归一化,再在融合层合并。
常见做法是把5个通道拆成两组。分支A拿lf0、lf1、CQT,它们都是与线谱谐波相关的窄带信息;分支B拿hf、Mel,它们是宽带包络和整体时频分布。这里5个通道,所以把CQT归到线谱分支、Mel归到宽带分支是常见选择。两个分支的结构可以相同也可以不同,建议先做两个相同的卷积主干,等基线稳定后再分别调整感受野,不要一开始就设计不对称结构,否则出了问题很难定位到具体分支。
3.2 分支内部的卷积块:小卷积核配合BN
卷积部分用窄Conv2d就够。输入是(5, T, F)形状,注意把通道维当成卷积的输入通道,T和F当成长和宽。第一个卷积用3×3小核,步长设为(1, 2)只在频率方向压缩,时间方向保持分辨率,这样后面的注意力模块能看到完整的时变过程。
import torch.nn as nn class ConvBlock(nn.Module): """卷积块: Conv2d -> BN -> ReLU -> 可选Dropout""" def __init__(self, in_ch, out_ch, kernel=(3, 3), stride=(1, 2)): super().__init__() self.conv = nn.Conv2d(in_ch, out_ch, kernel_size=kernel, stride=stride, padding=(1, 1)) self.bn = nn.BatchNorm2d(out_ch) self.act = nn.ReLU(inplace=True) self.drop = nn.Dropout2d(0.1) def forward(self, x): return self.drop(self.act(self.bn(self.conv(x))))stride设成(1, 2)而不是(2, 2),是因为水下信号的时间动态本来就不长,时间方向再减半容易把短促的调制特征丢掉。kernel选择3×3,两三个卷积块叠加后等效感受野接近7×7,对频带局部结构已经足够。Dropout2d在训练阶段使用,推理时自动关闭,此处设0.1起步即可。
3.3 注意力机制:SE通道重标定放在哪里收益最大
注意力机制在这个任务里承担的是通道重标定,不是定位。SE模块通过全局平均池化把每个通道压成一个标量,再过两个全连接层产生一组权重,对原有特征通道重新加权。这样网络可以自动放大那些判别力强的频带,抑制环境噪声主导的通道。
class SEBlock(nn.Module): """Squeeze-and-Excitation 通道注意力""" def __init__(self, in_ch, reduction=8): super().__init__() self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(in_ch, in_ch // reduction), nn.ReLU(inplace=True), nn.Linear(in_ch // reduction, in_ch), nn.Sigmoid() ) def forward(self, x): b, c, _, _ = x.size() w = self.pool(x).view(b, c) w = self.fc(w).view(b, c, 1, 1) return x * w放置位置上我实际试过两种。第一种每个卷积块之后都接SE;第二种只在分支尾部、融合之前加一次SE。每个块都加会明显增加参数量,收益在多数水声数据集上并不可观;只在分支尾部加一次,训练更稳,显存占用也更温和。所以推荐先把SE放在每个分支最后。若分支内部通道数已经很少,比如32到64,reduction设8或16都行。如果训练出现震荡,把reduction调大一些,比如16,因为SE第一层全连接会把通道压到1/16,权重更新幅度更温和。
SE之外,CBAM还引入了空间注意力分支,对时频图的空间位置做加权。船声分类里空间注意力通常作用于频率方向,可以让网络学到“只有某几个低频离散频率带真正重要”。如果显存有余量,可以在分支尾部同时挂SE和CBAM的空间注意力,但要注意过拟合,毕竟水下声学样本量通常不大。
3.4 融合层与分类头:拼接比相加更适合异源特征
两个分支输出形状一般是(N, C1, H1, W1)和(N, C2, H2, W2),融合前要把空间尺寸统一。常见做法是各自做一次AdaptiveAvgPool2d,把H和W压到1×1,然后两个分支的特征在通道维拼接,再用全连接层分类。
class DualBranchNet(nn.Module): """ 双分支网络: 分支A接入lf0/lf1/CQT, 分支B接入hf/Mel x_a: (N, 3, T, F) x_b: (N, 2, T, F) """ def __init__(self, num_classes, in_ch=1): super().__init__() # 分支A: 线谱相关通道 self.branch_a = nn.Sequential( ConvBlock(in_ch, 32, stride=(1, 2)), ConvBlock(32, 64, stride=(1, 2)), ConvBlock(64, 128, stride=(1, 2)), ) # 分支B: 宽带相关通道 self.branch_b = nn.Sequential( ConvBlock(in_ch, 32, stride=(1, 2)), ConvBlock(32, 64, stride=(1, 2)), ConvBlock(64, 128, stride=(1, 2)), ) self.se_a = SEBlock(128) self.se_b = SEBlock(128) self.fuse = nn.Sequential( nn.Linear(256, 128), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(128, num_classes) ) def forward(self, x_a, x_b): feat_a = self.branch_a(x_a) feat_b = self.branch_b(x_b) feat_a = self.se_a(feat_a).mean(dim=(2, 3)) feat_b = self.se_b(feat_b).mean(dim=(2, 3)) fused = torch.cat([feat_a, feat_b], dim=1) return self.fuse(fused)融合用拼接而不是相加,这个选择很关键。两个分支的特征来自不同物理信号,拼接让分类头自己学权重;相加则强制两组特征在同一维度上做对应,互相干扰。如果你实验中发现相加的效果更好,大概率是数据分布太简单,换到低信噪比测试集就会露馅。mean(dim=(2,3))就是AdaptiveAvgPool2d的等价实现,把每个分支的时频图压成一个128维向量。
代码里in_ch=1是为了演示简洁。实际实现时,从5通道张量里切片给两个分支:x_a = feats[:, :3, :, :],x_b = feats[:, 3:5, :, :],因此两个分支的输入通道数分别是3和2,把in_ch改成对应数值即可。
4. 数据切分与训练配置:用特征缓存和L2正则跑出稳定模型
4.1 按船切分数据,避免同类样本泄漏
音频分类任务里常见错误是随机切分train/val。水下船声数据往往是同一条船录了多段,每段声音相近,随机切分会让模型记住船的条件而不是船的类型,测试成绩虚高,部署到新船数据就崩。正确做法是按船ID分组,同一艘船所有片段只能出现在训练集或测试集。如果数据没有船ID,至少按录制航次切分,避免同一个航次跨数据集。这一步同时决定了后续所有评估结论是否可信。
4.2 离线特征缓存加速训练
水下音频长,特征提取耗时。推荐先把5通道特征离线算好存成npy文件,训练时直接从文件读取,而不是在DataLoader里重新算STFT和CQT。这样数据加载瓶颈完全消失,还能避免每个epoch重复提取特征带来的额外开销。
import os import numpy as np import torch from torch.utils.data import Dataset class ShipEchoDataset(Dataset): """读取离线缓存特征, 每个样本是 (T, 5, 64) 的npy文件""" def __init__(self, file_list, labels, cache_dir, target_frames=128): self.file_list = file_list self.labels = labels self.cache_dir = cache_dir self.target_frames = target_frames def __len__(self): return len(self.file_list) def __getitem__(self, idx): path = os.path.join(self.cache_dir, self.file_list[idx] + ".npy") feat = np.load(path).astype(np.float32) # 长度不够的样本用循环填充, 不用零填充 if feat.shape[0] < self.target_frames: reps = int(np.ceil(self.target_frames / feat.shape[0])) feat = np.tile(feat, (reps, 1, 1)) feat = feat[:self.target_frames] feat = feat.transpose(1, 0, 2) # (5, T, F) label = torch.tensor(self.labels[idx], dtype=torch.long) return torch.from_numpy(feat), label短样本用循环填充而不是零填充,这个决定来源于实际踩坑。零填充会把一段能量为0的特征塞到尾部,卷积会学到“末尾没信号”这种假规律,推理时遇到正常长度音频反而分布偏移;循环填充保持了信号连续性,至少在语义上更接近真实声学信号。代价是样本拼接点可能有跳变,但相比整段零填充要好得多。注意循环填充只适用于时间维度,不要在频带上用,频带之间没有周期性,循环会破坏谱结构。
4.3 训练循环与L2正则配置
训练主体代码是标准流程,但有两个细节值得单列:weight decay就是L2正则,直接作用在卷积核和全连接层参数上,不要作用到BN的gamma和beta;学习率调度用余弦退火比StepLR更稳,水声数据集的验证集loss曲线本身就不平滑,StepLR容易让模型卡在局部震荡点。
import torch import torch.nn as nn from torch.optim.lr_scheduler import CosineAnnealingLR def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for feats, labels in loader: feats, labels = feats.to(device), labels.to(device) # 切出双分支输入: 前3个通道给分支A, 后2个通道给分支B x_a = feats[:, :3, :, :] x_b = feats[:, 3:5, :, :] logits = model(x_a, x_b) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * feats.size(0) return total_loss / len(loader.dataset) model = DualBranchNet(num_classes=5, in_ch=1) # 按自己类别数改 optimizer = torch.optim.AdamW( model.parameters(), lr=1e-3, weight_decay=1e-4 ) criterion = nn.CrossEntropyLoss() scheduler = CosineAnnealingLR(optimizer, T_max=60) for epoch in range(60): loss = train_one_epoch(model, train_loader, optimizer, criterion, device) scheduler.step() if epoch % 10 == 0: print(f"epoch {epoch}, loss {loss:.4f}")这里的weight_decay=1e-4是相对保守的起步值。水下声学数据集通常只有几千条,模型参数规模不大,1e-3会让主干学不动,1e-5又几乎不起作用。假如用了CBAM这类带空间注意力的模块,建议把注意力部分全连接层的weight_decay单独设成1e-4,主干沿用默认。做法是构造两个参数组传给optimizer,一组带weight_decay,一组不带,调起来更细腻。
4.4 超参表:直接抄作业的起步配置
| 参数 | 建议值 | 说明 |
|---|---|---|
| 输入张量 | (N, 5, 128, 64) | 5通道、128帧、64频带 |
| 采样率 | 48kHz或96kHz | 统一降采样到48k更省内存 |
| 每段样本时长 | 10秒以上 | 短于10s时hf调制谱分辨率不足 |
| 批量大小 | 32 | 显存不够就16,不要用8以下 |
| 初始学习率 | 1e-3 | AdamW配合CosineAnnealingLR |
| weight_decay | 1e-4 | 只对Conv/FC生效,不作用到BN |
| 训练轮数 | 60-100 | 以验证集最优loss为准,不硬跑满 |
| 类别不均衡 | 加权CrossEntropy | 权重按1/类别频率归一化 |
批量大小低于8时,BN统计量会很不稳定,尤其输入特征里还包含大量恒定背景噪声时,训练集和验证集的BN统计会明显偏移。如果显存实在不够,用梯度累积两步再更新,而不是把batch压到8以下。
5. 水下声学分类避坑实录:特征错位、分支失衡和评估幻觉
5.1 训练loss下降但验证集频繁波动
现象:训练集准确率快速到95%,验证集在70%和90%之间来回弹。
原因:最典型的是数据集按文件随机切分,同一条船的多段录音同时出现在训练集和验证集,验证集里出现“半熟脸”样本。另一个可能原因是CQT和Mel的帧数没对齐,训练时模型见过很多短尾样本,验证时遇到更长样本导致全局池化特征偏移。
解决:先按船ID做分组切分,确保同船全部进同一侧;再做一次特征帧数直方图,确认训练集和验证集的样本时长分布一致。两个问题修完,通常能消掉一半以上的验证集波动。
5.2 低频通道能量为0,模型根本没看到线谱
现象:训练结束后打印第一层卷积权重,发现lf0通道对应的权重几乎全是0;CAM可视化时低频区域也没有响应。
原因:特征提取时带通滤波上限设太低,或者采样率设置错误。比如原数据是16kHz采样,f_hi却设到20kHz,整个频带为空。低频段能量本身很弱,如果没做对数压缩直接输入,浮点精度也会让低能量值被后续卷积忽略。
解决:在特征离线缓存阶段打印每个通道的均值和方差。均值低于0.1就需要检查滤波器参数;低频通道先做log1p处理,再对每个通道单独做min-max归一化到0到1之间。进入网络前各通道单独norm,不要5个通道一起归一化,否则会再次压低低频通道的存在感。
5.3 双分支中宽带分支梯度远大于线谱分支
现象:训练日志里两个分支最后一个卷积层的梯度范数相差10倍以上,融合层的参数几乎全由宽带分支主导。
原因:hf和Mel的能量范围大,卷积输出特征尺度也大;学习率对两个分支是同一个,大尺度分支对应的梯度自然更大,线谱分支被压制。
解决:在融合层之前对两个分支的输出各接一个LayerNorm,把尺度拉齐。也可以在optimizer里给分支B单独设更小的学习率,但麻烦,效果也不如LayerNorm直接。两个方案都试过以后,LayerNorm方案训练更稳,我留在默认配置里。
5.4 同一模型换到另一条船的数据就掉点
现象:在A船录制数据上训练准确率95%,换到没见过的B船数据,准确率掉到60%。
原因:特征归一化是在A船全局统计上做的。B船能量分布和调制频率范围不同,归一化后特征被推离模型见过的分布。水下信道变化大,船速、水深、海浪状态都会影响绝对能量。
解决:不要用全局固定min-max,改在每条样本内部独立做通道归一化。这样模型学到的不是绝对能量而是相对谱形。另一个实用做法是在训练时对特征做随机增益扰动:把5个通道统一乘一个0.8到1.2的随机系数,模拟同一船在不同距离下收到信号的强弱变化,能显著提升跨船泛化。
5.5 准确率指标失真,只看准确率会误判模型
现象:5类船里有一类占比极低,模型全部输出到多数类,准确率仍有90%,但实际部署没有一点用。
原因:这是类别不均衡下的典型评估幻觉。准确率对多数类太友好,少数类完全没被模型学到。
解决:改看加权F1和每类的Recall/Precision。如果少数类只有几十条样本,直接用Focal Loss代替CrossEntropy更合适,gamma设1到2,alpha按类别频率反比设置。类别样本数不足时,数据增强比调模型参数更有用,其中对时间方向和频率方向做随机拉伸的增强方法,对水声特征最稳定。
6. 模型验证与上线前的压力测试:CAM看注意力,混淆矩阵查短板
模型训练完,不能只看测试集准确率就宣布完成。水下声学分类特别容易过拟合到数据集特有的噪声上,验证需要分三步走。
第一步是类激活映射(CAM)可视化。取训练好的模型最后一层卷积输出的特征图,对类别概率求梯度或直接做加权求和,叠加回原始时频图,能看到模型到底在哪个频段做了判别。线谱分支的注意力应该稳定出现在低频几个离散频率带附近。如果可视化结果显示注意力散落在全频带,说明模型没有学到有效的线谱结构,只是记住了全局能量纹理。
第二步是混淆矩阵逐类分析。把测试集预测结果画出来,重点看哪两类最容易互相混淆。经常出问题的是同系列船上不同型号之间的线谱几乎一样,只能靠高频包络调制峰值区分。如果hf通道对应类别的识别率低,回查特征提取阶段调制谱是否被噪声污染,必要时把调制分析范围从2-80Hz收窄到与实际船速匹配的区间。
第三步是信噪比压力测试。把测试音频叠加不同程度的环境噪声,从20dB SNR到0dB SNR逐级降下去,看模型准确率曲线掉到什么位置开始崩。理想模型在10dB到5dB区间曲线平滑下降,而不是断崖式垮掉。如果5dB就开始崩,优先怀疑特征里环境噪声成分过多,回到第二章把hf带通范围收紧,或者训练时加随机噪声样本做增强。这个方法比单纯调模型参数有效得多。
我自己的习惯是这三个步骤走完才允许模型出实验环境。水下声学数据集攒起来贵,标注更贵,一个能用但说不清在哪个频段做判别的模型,部署之后出了问题连排查入口都没有。在这套5通道加双分支的方案里,最有价值的不是最终准确率数字,而是CAM可视化和SNR曲线这两样东西。它们是你项目验收时能拿得出手的说服证据,也是模型后续迭代时唯一可靠的对照基准。希望帮到你。
本文还有配套的精品资源,点击获取