ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从数据到部署:PyTorch CNN实现水下声源定位

从数据到部署:PyTorch CNN实现水下声源定位

简介:面向海洋感知与水声工程等场景,这套实战资源基于PyTorch框架的卷积神经网络构建水下声学信号定位模型,能够输出声源方位角与距离预测结果,适合高校本科生毕业设计、研究生课题起步和课程设计等阶段。整套资源打包为zip压缩包,共59个文件,涵盖Python训练与推理脚本、交互式示例、说明文档、结构示意图及预训练权重等,整体约7.24MB,目录规划清晰便于按模块查阅。目前已有37人学习下载,尤其适合人工智能、水声工程、电子信息等方向快速上手。除完整原始声学数据外,还附带数据生成脚本、已收敛模型权重和图文部署教程;实测支持Windows与macOS系统,CPU与GPU环境均可一键运行,配套说明文档覆盖环境配置、数据加载、模型训练、推理演示及常见问题解答。读者可直接运行演示查看预测效果,也可在此基础上替换数据或调整网络层开展二次开发,用于毕设、课设或科研原型验证。

1. 海洋水下声源定位:PyTorch CNN 方案把水声数据做成了一条可复现的完整链路

在海洋水下声源定位这件事上,传统波束成形对阵列标校和声速剖面特别敏感,阵元间距差一厘米、声速剖面测得不准,方位角就偏出好几度。换成 PyTorch CNN 方案后,多路水听器信号切成频谱图丢给网络,让模型自己从阵列相位和幅度差异里学习定位线索,工程实现量反而小了很多。这个资源起到的就是「成套方案」的作用:标注好的水声数据集、CNN 模型代码、预训练权重、部署脚本一应俱全。特别适合正在转型的水声工程师、需要快速搭原型的团队,以及想在自己数据上微调的研究者。下面按数据链路、模型结构、训练迁移、部署排查这条主线拆开讲。

2. 数据链路先行:水声数据集的结构、标签与频谱预处理

2.1 先摸清目录与标签:训练前必须搞定的三件事

拿到资源后别急着跑训练脚本,先花十分钟确认三件事:数据放哪、标签长什么样、每个样本的物理含义是什么。常见做法是 train / val / test 三段式目录,和大部分视觉数据集一致:

uw_dataset/ ├── train/ # 训练切片,参与梯度更新 │ ├── seg_00001.npy │ ├── seg_00002.npy │ └── ... ├── val/ # 验证切片,不参与训练,只用来选模型 ├── test/ # 测试切片,只在最终评估时打开 └── labels.csv # 标签文件,一列 ID、三列数值

labels.csv 是训练的唯一入口,核心列一般是四列:

sample_id,azimuth_deg,distance_m,snr_db seg_00001,37.5,850.0,12.6 seg_00002,118.2,420.0,8.1

这里azimuth_deg是声源相对阵列正横方向的水平方位角,distance_m是斜距,snr_db是该切片平均信噪比。第一段里说清楚标签列名,后面所有代码都按这个 schema 走,如果下载包里的列名不一样,先把读取脚本里的列名对齐再说,这是训练前最容易踩的坑之一。

我一般会额外检查一件事:标签里方位角的取值范围。有些数据集的方位角是 0–180°,有些是 0–360°,还有的是 −90° 到 +90°。范围不同,回归头的激活函数和损失函数都要跟着改,这一步不确认就开训,后面评估结果会非常难定位问题。

2.2 从多通道波形到频谱图:特征提取这一步决定成败

水听器阵列的输出是同步采样的多通道时域波形,npy 文件的 shape 一般是(通道数, 采样点数)。常见配置是 8 元阵、采样率 48kHz、每段 2 秒,那么 shape 就是(8, 96000)。CNN 直接吃时域波形也能工作,但水声场景下多途效应和海洋环境噪声会让时域波形很不稳定,绝对相位信息一旦受噪声污染,卷积核很难学到稳定的规律。

所以这套资源走的路线是转幅度频谱图:每个通道做短时傅里叶变换,把 log 幅度谱当成一个「图像通道」。8 路水听器对应 8 个输入通道,(通道数, 频率点数, 时间帧数)三阶张量直接送进 Conv2d,和彩色图像的三通道在结构上完全同构。

import torch import torchaudio def waveform_to_logspec(waveform: torch.Tensor, n_fft: int = 512, hop_length: int = 256) -> torch.Tensor: # waveform shape: (channels, samples) spec = torch.stft( waveform, n_fft=n_fft, hop_length=hop_length, win_length=n_fft, window=torch.hann_window(n_fft), return_complex=True ) # 取幅度谱,只保留非负频率部分,shape: (channels, 257, time_frames) mag = torch.abs(spec)[:, : n_fft // 2 + 1, :] # 动态范围压缩:先放大低幅度区域,再取 log1p log_mag = torch.log1p(mag * 10.0) return log_mag

逻辑说明:torch.stft一次把整段波形切成带重叠的窗并做 FFT,返回复频谱;torch.abs取幅度丢掉相位,避免相位跳变对训练的干扰;log1p(mag * 10.0)里*10.0是把水声中常见的弱信号幅度抬起来,否则 log 之后大量接近 0 的 bin 会把有效信息压到不可分辨的程度。参数说明:n_fft=512对应约 10.7ms 的窗长,hop_length=256是 50% 重叠,频率分辨率约 93.75Hz,时间分辨率约 5.3ms。对 2 秒、48kHz 的输入,输出时间帧数是 374 个左右,完整输入 shape 为(8, 257, 374)。

有个细节值得记住:训练时用的 STFT 参数必须和部署时完全一致,n_fft、hop、win_length、window 函数任何一个变了,输入分布就变了。我见过团队把训练代码里n_fft=512和部署代码里n_fft=1024混着用,模型的精度直接掉到随机水平,查了半天才发现这两个数字不一致。

2.3 数据增强与样本均衡:三个马上能用的小手段

水声数据集的通病是样本量不大,尤其近距离高信噪比样本更稀缺。训练集太小,模型很容易记住特定阵型和特定 SNR 的组合,换一组数据就失灵。下面三个增强手段是这套资源里常见的做法,直接加在 Dataset 的__getitem__里即可。

def augment_batch(spec: torch.Tensor) -> torch.Tensor: # 1) 随机通道增益失配:模拟各阵元灵敏度差异 channel_gain = torch.empty(spec.shape[0], 1, 1).uniform_(0.9, 1.1) spec = spec * channel_gain # 2) 叠加高斯噪声,把信噪比打到随机目标 snr_db = torch.empty(spec.shape[0], 1, 1).uniform_(5.0, 20.0) signal_power = spec.pow(2).mean(dim=(1, 2), keepdim=True) noise_power = signal_power / (10 ** (snr_db / 10)) noise = torch.randn_like(spec) * torch.sqrt(noise_power) spec = spec + noise return spec

逻辑说明:第一项对每个通道乘一个 0.9–1.1 的随机增益,模拟水听器之间的灵敏度不一致;第二项按目标 SNR 生成高斯噪声并叠加到幅度谱上,让模型对信噪比不过度敏感。参数说明:通道增益的范围太小没效果,太大又会让阵列幅度比失真,0.9–1.1 是相对稳的区间;SNR 范围按你实际任务来,近海环境噪声高,可以把下限拉到 0dB,远海安静环境就保持 5dB 起步。如果你有时间,更严格的做法是在时域叠加噪声再转谱,这里为演示简洁直接对幅度谱操作,两者结果差异不大。

样本均衡方面,我的习惯是先按distance_m分桶统计样本量。发现近距离样本明显偏少时,最简单的办法是复制近距离样本并配合不同程度的噪声增强,让每个距离桶的样本量大致持平。方位角本身一般天然近似均匀,重点关注距离维度的不均衡就够了。

3. 模型与损失设计:CNN 结构、回归头和评估指标的选择

3.1 为什么是 CNN:把定位问题重新表述成回归

传统波束成形的痛点在于依赖精确的阵列流型矩阵和声速剖面,而实际海水的声速随温度、盐度、深度实时变化,校准一次只能管一阵子。CNN 的思路是让模型在大规模模拟样本上学会「从多通道频谱的幅度差和相位差反推声源方位」这一映射关系,训练好之后推理只依赖输入信号本身,不需要临时测量声速剖面。

架构选择上主要分两类:分类模型把方位角离散成若干 bin 做多分类,回归模型直接输出连续的角度值。这套资源选的是回归,输出(azimuth_deg, distance_m)两个连续值。经验上如果只关心方位角且角度范围预先固定,分类会更稳;但一旦需要距离,回归的确更灵活,因为距离标签天然连续且分布跨度大,硬分 bin 会让离散误差主导评估结果。

3.2 网络结构:八通道输入、三层卷积、一个回归头

模型整体是「卷积骨干 + 全连接回归头」的经典结构:

import torch.nn as nn class UnderwaterLocalizationCNN(nn.Module): def __init__(self, in_channels: int = 8, freq_bins: int = 257): super().__init__() self.backbone = nn.Sequential( nn.Conv2d(in_channels, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.head = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(128, 64), nn.ReLU(inplace=True), nn.Linear(64, 2), # 输出 [azimuth_deg, distance_m] ) def forward(self, x): return self.head(self.backbone(x))

逻辑说明:in_channels=8对应 8 路水听器通道,和上一章频谱图的通道维度严格对应;三次 3x3 卷积配合MaxPool2d(2)把频率维和时间维逐级压缩,同时把通道数从 8 提升到 128;AdaptiveAvgPool2d((1, 1))把空间维度压成 1x1,保证不管输入时间帧数是多少,全连接层的输入维度恒定。参数说明:卷积核都选 3x3,是因为两层 3x3 堆叠等效感受野等于一层 5x5,参数量却更小;BatchNorm2d在训练 batch size 不小于 8 时效果稳定,部署时记得把模型切到 eval 模式,否则 BN 会继续用 batch 统计量,导致单样本推理结果漂移。

对应到实际输入尺寸,2 秒、48kHz、hop=256 的频谱图是(8, 257, 374)。三次池化后时间帧从 374 缩到 46,频率维从 257 缩到 32,最后的全局平均池化把这 32x46 的空间信息汇总成 128 维向量,再经过两层全连接得到方位角和距离。整体参数量在千万量级以下,单张常见显卡可以很轻松地跑起来,CPU 上推理也不会有太大压力。

参数值说明
in_channels8水听器数,改阵型时同步调整并重新训练
freq_bins257n_fft=512 时的非负频点数
池化因子8x三次 MaxPool2d(2),频率和时间维各缩 8 倍
骨干输出通道128最后一层卷积通道数,控制模型容量

3.3 损失函数和评估指标:回归任务的三个关键点

方位角和距离量纲差很远,距离是几百到几千米的尺度,角度是 0–360 的尺度,直接用单个 MSE 会让距离项主导梯度:

选项适用场景实际表现
MSE所有样本误差成本均等离群切片(如强多途干扰)会把梯度拉爆
SmoothL1有离群样本、训练初期震荡梯度有界,对角度和距离分开计算时更稳
分项加权角度和距离误差权重不同通常角度权重调高,距离权重调低

我一般把角度和距离拆成两个损失,分别取 SmoothL1 再相加。角度误差还有个隐藏细节:359° 和 0° 之间的差值按普通abs(pred - true)算会得到 359,但真实角度差只有 1°,必须做环形修正。

def azimuth_mae(pred: torch.Tensor, true: torch.Tensor) -> torch.Tensor: diff = torch.abs(pred - true) % 360.0 return torch.minimum(diff, 360.0 - diff).mean()

逻辑说明:% 360.0先把任意差值映射到 0–360,再和它的补角取最小值,得到的就是「最短角距离」。参数说明:如果数据集定义的是 0–180° 半圆范围,取模基准要跟着改成 180,否则 179° 和 1° 会被错误地算成 178° 误差。

4. 训练与预训练权重:从零训练、直接推理到微调的完整配置

4.1 训练脚本主干:参数怎么设、监控什么指标

训练配置在资源里有现成脚本,核心参数段大概是这样的:

config = { "batch_size": 16, "epochs": 60, "lr": 1e-3, "lr_scheduler": "cosine_annealing", "optimizer": "AdamW", "weight_decay": 1e-4, "loss": "smooth_l1", "early_stopping_patience": 12, }

逻辑说明:AdamW配合weight_decay=1e-4比裸 Adam 更不容易过拟合;cosine_annealing让学习率从 1e-3 平滑衰减到接近 0,适合 60 个 epoch 的中等规模训练;early_stopping_patience=12表示验证 loss 连续 12 个 epoch 不下降就停,避免无效等待。参数说明:batch_size=16对应 8 通道 257x374 的输入,单卡 8–11GB 显存足够;显存紧张就降到 8,同时把 BN 的momentum默认值保留,不要手动调高,否则小 batch 下 batch 统计量抖动会直接影响训练稳定性。

训练循环里每个 epoch 结束都要做一次验证,同时记录学习率:

train_loss = run_epoch(model, train_loader, optimizer, is_train=True) val_loss = run_epoch(model, val_loader, None, is_train=False) lr = scheduler.get_last_lr()[0] print(f"epoch={epoch:02d} train={train_loss:.4f} val={val_loss:.4f} lr={lr:.2e}")

这段代码的价值在于把三样东西同时打印到一行:训练 loss、验证 loss、学习率。我靠这个输出判断过很多次训练状态——学习率衰减到位但验证 loss 还高,问题基本在数据侧;训练 loss 和验证 loss 同时高,问题基本在模型容量或特征提取。

4.2 加载预训练权重:直接推理还是微调

这套资源自带预训练权重,加载方式按场景分两种。第一种是直接推理,适合和训练集同分布的数据:

model = UnderwaterLocalizationCNN(in_channels=8) ckpt = torch.load("weights/underwater_cnn_best.pth", map_location="cpu") model.load_state_dict(ckpt["model_state_dict"], strict=False) model.eval() with torch.no_grad(): pred = model(spec_batch)

逻辑说明:map_location="cpu"先把权重拉到 CPU,再在真正用 GPU 推理时拷过去,避免显存瞬时占用抖动;strict=False允许两个 dictionary 的 key 不完全一致,加载后最好跑一次model.load_state_dict看返回值里 missing keys 的列表,确认到底少了哪些层。参数说明:如果你改了in_channels=16这类结构参数,第一层卷积的权重 shape 就对不上,strict=False会把这层跳过,需要手动重建。

第二种是微调,适合手头有少量实测数据、想快速迁移到新海域的场景:

for name, param in model.named_parameters(): param.requires_grad = name.startswith("head") optimizer = torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=5e-4 )

逻辑说明:只让head开头的层参与梯度更新,骨干卷积层完全冻结,目的是保留预训练权重里已经学到的频谱定位特征,只重新拟合回归头去适配新数据的角度尺度。参数说明:微调学习率设 5e-4,比从头训练低一半,epoch 数按实测样本量定,样本在几百段这个量级时 15–20 个 epoch 就够,跑太久反而会把已经学好的骨干特征带偏。

4.3 训练监控与调参:三个容易翻车的细节

第一个细节是 loss 曲线完全不降。先看学习率是否过大,1e-3 对 CNN 回归任务是经验安全区,超过 5e-3 会出现 loss 震荡不收敛;再看 STFT 输出里是否包含大量 NaN——浮点溢出在 log 压缩前不常见,但一旦输入波形里有坏道数据,幅度谱会出现异常大值。

第二个细节是验证 loss 在 20 个 epoch 左右就开始回升。这是过拟合信号,优先调大weight_decay到 5e-4,而不是急着上早停。数据增强也在同一时间检查:如果增强强度太大,训练 loss 会比不增强时高一大截,这时调小噪声 SNR 上限比调模型更有效。

第三个细节是训练集与验证集的分布割裂。水声数据经常按海域或航次组织,如果直接随机切分,同一段连续记录会同时出现在两个集合里,验证 loss 虚低;等模型上了真实数据立刻打回原形。正确做法是按航次或按位置分组切分,保证验证集里的样本和训练集没有时空重叠,这是评估可信度的底线。

5. 部署避坑清单:五个登记在案的实测教训

5.1 部署推理环境:三件事优先做

训练完的模型要落地,先解决三个基础问题。一是特征提取代码统一,把waveform_to_logspec抽成独立模块,训练和部署都直接 import 同一份,杜绝复制粘贴后参数不一致。二是固定输入长度,部署时按 2 秒窗口滑动切片,相邻窗口重叠 50%,每个窗口独立推理,再对连续输出做中值滤波。三是导出为 ONNX 加速推理,尤其在嵌入式平台上,PyTorch 动态图的开销不可忽略。

model.eval() dummy = torch.randn(1, 8, 257, 374) torch.onnx.export( model, dummy, "uw_cnn.onnx", input_names=["spectrogram"], output_names=["azimuth", "distance"], dynamic_axes={"spectrogram": {0: "batch_size"}}, )

逻辑说明:dynamic_axes只把 batch 维度设为动态,频率和时间维度保持固定,这样 ONNX 推理时输入 shape 校验更严格,尺寸不匹配错误会在早期暴露。参数说明:导出前一定要model.eval(),否则 BN 和 Dropout 的行为会内嵌到计算图里,导致导出的模型在推理时仍依赖训练模式的状态。导出后再用一个固定样本对拍 ONNX Runtime 和 PyTorch 的输出,两个结果差异超过 1e-4 就得停下来检查算子兼容性。

5.2 高频踩坑记录与排查方法

现象 1:训练时验证集 loss 很漂亮,部署后同一段数据推理结果对不上。原因:推理脚本里特征提取的 log 缩放系数和训练时不一样,比如训练时是log1p(mag * 10.0),部署脚本里写成了log1p(mag),输入分布整体偏移,模型输出自然全偏。 解决:把特征提取函数放在一个独立 python 文件里,训练和部署从同一个模块导入;部署前用固定随机种子生成一段波形,分别跑训练侧和部署侧的特征提取,比对输出数组是否完全一致。

现象 2:加载预训练权重报 size mismatch,模型起不来。原因:修改了输入通道数或 freq_bins 后,第一层卷积或最后一层全连接的参数维度对不上。 解决:先打印 checkpoint 里各 key 的 shape,再和当前模型逐层核对;只改通道数时,可以保留原权重的前 8 个通道、随机初始化新增通道,而不是整个重置。

现象 3:推理延迟超标,显存被吃满。原因:部署脚本没控制 batch size,把一整段几十秒的音频一次性送进模型,中间过程张量全部驻留显存。 解决:按 2 秒滑动窗口切分,推理时 batch 固定为 1,既不爆显存也便于逐窗口调试;CPU 部署时还要控制线程数,torch.set_num_threads(4)在多数推理机上比默认全核更快更稳。

现象 4:近距离目标方位经常跳变,远距离反而稳定。原因:训练数据里近距离样本占比低,近距离声场多途效应强,模型对这部分特征欠拟合。 解决:统计标签里距离的分布,对近距离样本做重采样复制,配合叠加多途副本的增强方式重新训练;验证时单独统计近距离段的角度误差,不要只看全局均值。

现象 5:模拟数据上测试精度很高,换到实际海洋数据掉一半。原因:训练数据全部由单一声速剖面或单一海底地形的模拟环境生成,真实海域的声速梯度、底质反射系数和训练假设差别太大。 解决:用少量实测切片做第 4.2 节说的微调;如果实测数据量不足,可以给训练数据加入声速剖面随机化——在生成阶段随机扰动声速梯度,让模型对传播条件变化更鲁棒。

6. 验证技巧:合成信号给模型做一次独立体检

预训练权重到手后,别急着上真实数据,先用合成信号给模型做一次独立体检。所谓独立,是指合成信号完全不经过训练数据的生成管线,只用物理原理构造,这样能检验出模型是否真的学到了「阵列时延差 → 方位角」这个映射,而不是背下了训练样本里的某些模式。

做法是模拟一个平面波到达 8 元均匀线阵的场景。平面波假设下,第 i 个阵元的相对时延等于阵元位置乘方位角正弦除以水中声速,只需要生成一组已知方位角的信号,按理论时延错开叠加,再送进和训练一致的 STFT 与模型推理:

import numpy as np import torch def synthesize_plane_wave(azimuth_deg: float, sensor_x: np.ndarray, c: float = 1500.0, sr: int = 48000, duration: float = 1.0): """按平面波理论时延生成 8 阵元模拟信号,用于独立验证。""" t = np.arange(int(sr * duration)) / sr source = np.sin(2 * np.pi * 300 * t) # 300Hz 单频信号 delays = sensor_x * np.sin(np.deg2rad(azimuth_deg)) / c delay_min = delays.min() out = np.zeros((len(sensor_x), len(t))) for i, delay in enumerate(delays): n_delay = int(round((delay - delay_min) * sr)) out[i, n_delay:n_delay + len(source)] = source return out

逻辑说明:先计算各阵元相对参考点的理论时延,再整体平移保证最小延迟为 0;source用 300Hz 单频是便于目视检查时延差是否符合预期。参数说明:c 默认 1500m/s 是海水声速的近似值,如果你的目标海域声速剖面差异大,可以改用 1480 或 1520,敏感度分析时可以扫几个值对比模型输出变化。

然后对 0° 到 180° 每隔 10° 合成一组信号,逐个推理并记录误差:

errors = [] for az in range(0, 181, 10): wav = synthesize_plane_wave(az, sensor_x=np.arange(8) * 0.15) spec = waveform_to_logspec(torch.from_numpy(wav).float()) with torch.no_grad(): pred = model(spec.unsqueeze(0)) err = abs(pred[0, 0].item() - az) % 360 err = min(err, 360 - err) errors.append(err) print("max_azimuth_error:", max(errors))

如果最大角度误差超过 5°,说明模型对基本物理量的拟合并不可靠,先别急着部署,回到数据或模型层面排查。如果误差在 2° 以内,说明模型确实学到了平面波假设下的线性规律。做完这轮体检,我对模型输出的态度从「黑匣子」变成「可信但有边界」:合成信号验证的是理想传播条件,真实海域还有多途、起伏和阵型误差,所以规划里永远给实际数据预留比对环节。

从那次之后,我每次拿到新权重,都强制自己先跑一遍 0° 到 180° 的合成扫描,把误差曲线存档再谈部署。这个习惯帮我筛掉了三版看似精度很高、实则背样本的权重。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进