尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于时间-频率注意力机制与CNN的自动调制识别新方案

基于时间-频率注意力机制与CNN的自动调制识别新方案 简介面向具备深度学习基础、熟悉PyTorch的研究生及科研人员这份资源围绕自动调制识别AMR任务提出并实现了融合通道-频率-时间多维特征的时间-频率注意力机制TFACNN模型。内容深入解析通道注意力CAM、频率注意力FAM和时间注意力TAM三个模块的设计原理、数学公式及在网络中的集成顺序结合RadioML2016.10a公开数据集验证了模型相对现有学习方法和注意力机制的识别优势适合认知无线电、频谱监测等高精度AMR应用场景的复现与改进。资源包仅含1个PDF文件整体大小879KB内容除论文解读外重点附带详细可运行的Python代码与逐步中文解释方便对照学习各注意力模块的实现细节。目前已有66人学习下载通过阅读可掌握TFA-SCNN的复现流程、特征融合方式及注意力权重可视化技巧为后续算法优化和实际部署提供直接参考。读者可在此基础上快速完成模型搭建与调参并迁移到自有信号数据集上验证泛化能力。 在无线通信系统里调制识别一直是件绕不开的事。不管是频谱监测、干扰识别还是认知无线电里的动态频谱接入第一步都得先搞清楚对面发过来的信号到底用了什么调制方式。传统做法通常是基于似然函数或者高阶累积量做特征提取但这类方法在低信噪比环境下掉点特别严重而且人工设计特征这件事本身就带着很大的局限性——你挖空心思设计了一堆统计量换个信道模型可能就废了大半。这些年深度学习涌入通信物理层之后自动调制识别Automatic Modulation Recognition, AMR基本被卷积神经网络CNN和循环神经网络RNN这两大类模型主导了。CNN擅长抓局部结构特征能把IQ序列或谱图里的纹理信息提炼出来RNN则擅长建模时间上的依赖关系。但把这两者简单地拼接起来往往只能吃到某一维度的优势。我做的这个项目核心思路是在CNN主干网络的基础上嵌入一个时间-频率注意力模块把通道、频率、时间三个维度的特征重新标定一遍让模型知道该往哪儿看。这篇文章把整个方案的来龙去脉、代码实现、踩坑记录都写清楚适合正在做信号识别相关的同学参考也适合想了解“注意力机制怎么落地到一维时序信号”的人阅读。1. 自动调制识别的问题定义与数据处理1.1 调制识别的本质把信号序列变成“图”或“句”自动调制识别本质上是一个分类问题给定一段接收到的复数基带信号 ( x[n] I[n] jQ[n] )模型需要判断它属于哪一个调制类别比如 BPSK、QPSK、8PSK、QAM16、QAM64 等。难点在于信号的幅度、相位、频率都在随着符号序列和信道条件不断变化加上噪声和多径效应类别之间的边界在高维空间里是高度交叠的。把原始 IQ 序列直接丢给全连接网络是最朴素的思路但效果很差——因为调制信息分散在局部波形、频率分量和符号周期等多个尺度上纯全连接网络很难从百万级参数中自动归纳出这些多层结构。CNN 之所以能在这个任务上站稳脚跟靠的是它的局部感受野和权值共享特性浅层卷积核可以学到瞬时的幅相变化模式比如过零点和相位跳变深层卷积核则能组合出更抽象的调制指纹。但如果只盯着局部模型依然容易忽略全局的频率分布和时间演变趋势这也是我在这个项目里引入注意力机制的出发点。1.2 数据预处理IQ序列、STFT与特征图的构建实验采用公开的 RadioML 2016.10a 数据集作为基准它在 AMR 领域基本属于“标配”了。数据格式是每段信号包含 128 个复数采样点信噪比从 -20 dB 到 30 dB 每 2 dB 一个档位共 11 种调制方式。原始数据是 I、Q 两路实数序列直接作为 CNN 输入时通常组织成 ( 2 \times 128 ) 的张量。不过我在这个项目里没有用原始的 IQ 两路输入而是先把信号做短时傅里叶变换STFT生成时频图作为主要输入。这么做有两点考虑一是 STFT 将一维时序信号映射到二维的时频平面时间维度和频率维度同时可见方便后续的“时间-频率注意力”在二维特征图上操作二是通信信号的调制方式在时频谱上往往有很直观的纹理特征比如 BPSK 会在特定频率带上呈现清晰的两簇能量分布QAM 家族则表现出更均匀的散点纹理。用 STFT 谱图做输入等于把信号特性显式地摆到模型面前而不是让网络从零开始“领悟”频谱的概念。STFT 的具体参数选择也值得说明。对于 128 个采样点我采用每帧 32 个采样点、帧移 8 个采样点、汉明窗作加窗函数、FFT 点数取 64。这样生成的时频图尺寸大约是 ( 13 \times 64 )时间分辨率足够捕捉符号切换的瞬态频率分辨率也能区分常见的调制谱峰。有人可能会问为什么不直接用更大的 FFT 点数原因很简单——原始信号只有 128 个点FFT 点数超过 128 之后全是插值出来的虚分辨率不会带来真实信息增益反而增大计算量。2. 模型整体架构CNN主干 多维注意力2.1 为什么选择“通道-频率-时间”三维联合注意力注意力机制的核心逻辑就一句话让模型学会“哪里重要、哪里不重要”。在图像分类里SENet 通过全局平均池化得到通道描述向量再用两个全连接层生成通道权值本质是“告诉网络哪个特征图值得加强”。但在信号处理场景下仅做通道注意力是不够的——两个调制方式可能激活了相同的通道子集却分布在不同的频率带上或者它们的能量随时间变化的节奏完全不同。这就要求注意力模块在掌握“哪些通道重要”的同时还要弄清楚“在哪些频率位置重要”以及“在哪些时间段重要”。所以我把注意力拆成了三个并行分支通道注意力分支对特征图做全局平均池化和全局最大池化得到两组描述向量经共享的全连接层映射后相加再经过 Sigmoid 生成通道权值。这个分支负责筛选特征图的“内容类型”。频率注意力分支沿特征图的频率轴做池化压缩时间和通道维度的信息提取一个频率维度的描述向量再用一维卷积生成频率方向的注意力权重。这个分支处理的是“哪些频段包含判别信息”。时间注意力分支沿时间轴做类似操作让模型关注“哪些时间片段对分类贡献最大”。这对突发干扰频段或符号速率较慢的信号尤其重要。三个分支生成的注意力权重会分别沿着各自维度对原特征图做加权最终融合成一个重标定的特征图。换句话说这个模块同时完成了“看什么”通道、“在哪看频率”频率、“何时看”时间三件事。2.2 网络整体设计思路整体网络结构不复杂但每一步都有明确考量。主干仍然是标准的 CNN 堆叠包含三个卷积块Conv1、Conv2、Conv3每个卷积块由卷积层、批归一化、ReLU 激活和最大池化组成。卷积核大小我选了 ( 3 \times 3 )这是图像任务里的经典配置——小卷积核堆叠既能增大感受野又比大卷积核更省参数。前两个卷积块后面接了最大池化步长为 2 的池化能把特征图的尺寸逐步压缩到合适大小。第三个卷积块后面没有用池化因为特征图尺寸已经很小的再压缩会丢失太多空间信息。在第二个卷积块之后嵌入时间-频率注意力模块。选择这个位置也有讲究前面的 Conv1 提取的是低层纹理局部瞬态模式空间分辨率还很高如果用注意力直接怼在原始谱图上容易被噪声位置带偏。而经过 Conv2 之后的特征图语义信息更丰富尺寸也适中此时做注意力重标定效果远好于前几个阶段。分类头部分比较简单全局平均池化把特征图摊平成向量接一个 Dropout 层rate 0.5防止过拟合最后接全连接层输出 11 类置信度。整个模型的可训练参数量控制在 320K 左右在 GPU 上单轮训练900 个 batch大约 8 秒非常轻量。3. 核心代码实现详解3.1 数据加载与STFT谱图生成整个数据流水线用 PyTorch 实现。首先是一个自定义 Dataset 类负责从 numpy 文件中读取 IQ 数据并实时计算 STFT。这里有一点经验之谈不要提前把所有数据的 STFT 都算好存成文件虽然训练时会快一点但灵活性差很多——你想调整 STFT 参数比如帧长或 FFT 点数就必须重新生成整个数据集。在线计算的代价也就是每 Batch 多个几次 FFTGPU 完全扛得住。import numpy as np import torch from torch.utils.data import Dataset from scipy.signal import stft class RadioMLDataset(Dataset): def __init__(self, data_path, snr_range(-20, 30), frames32, hop8, nfft64): self.data np.load(data_path, allow_pickleTrue).item() self.samples [] for mod_type in self.data[mods]: for snr in range(snr_range[0], snr_range[1] 1, 2): self.samples.append((mod_type, snr)) self.frames frames self.hop hop self.nfft nfft self.mod_types self.data[mods] self.mod2idx {m: i for i, m in enumerate(self.mod_types)} def __len__(self): return len(self.samples) def __getitem__(self, idx): mod_type, snr self.samples[idx] iq self.data[mod_type][snr] # shape: (num_samples, 2, 128) # 随机抽取一条样本做数据增强 sample_idx np.random.randint(iq.shape[0]) iq_slice iq[sample_idx] # (2, 128) # 转成复数形式 complex_signal iq_slice[0] 1j * iq_slice[1] # STFT计算时频图 _, _, Zxx stft(complex_signal, fs1.0, windowhamming, npersegself.frames, noverlapself.frames - self.hop, nfftself.nfft, boundaryNone) # Zxx shape: (freq_bins, time_frames)取幅度谱 spec np.abs(Zxx).astype(np.float32) # 转成 (1, freq, time) 张量 spec_tensor torch.from_numpy(spec).unsqueeze(0) label self.mod2idx[mod_type] return spec_tensor, label注意scipy.signal.stft返回的 Zxx 维度是 (freq_bins, time_frames)和 PyTorch 默认的 (batch, channel, height, width) 布局不一样需要把 frequency 放到 height 位置、把 time 放到 width 位置。我的代码里直接用unsqueeze(0)扩展了通道维度最终张量形状为 (1, 33, 13)——其中 33 是 nfft/21 的频率点数13 是时帧数。如果你的输入尺寸不同后面的卷积层参数需要相应调整。3.2 时间-频率注意力模块实现这个模块是整个模型的“大脑”。代码实现上我参考了 CBAM 的设计思路但把空间注意力拆分成了频率和时间两个独立分支这样可以让模型分别学习不同轴向的权重分布。import torch import torch.nn as nn import torch.nn.functional as F class ChannelAttention(nn.Module): def __init__(self, in_channels, reduction8): super().__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) self.fc nn.Sequential( nn.Linear(in_channels, in_channels // reduction), nn.ReLU(inplaceTrue), nn.Linear(in_channels // reduction, in_channels) ) def forward(self, x): # x: (B, C, F, T) b, c, _, _ x.size() avg_out self.fc(self.avg_pool(x).view(b, c)).view(b, c, 1, 1) max_out self.fc(self.max_pool(x).view(b, c)).view(b, c, 1, 1) weight torch.sigmoid(avg_out max_out) return x * weight class FrequencyAttention(nn.Module): def __init__(self, freq_bins, reduction4): super().__init__() self.conv nn.Sequential( nn.Conv1d(1, freq_bins // reduction, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv1d(freq_bins // reduction, freq_bins, kernel_size3, padding1) ) def forward(self, x): # x: (B, C, F, T) # 沿通道和时间维度池化得到频率方向描述符 (B, F) avg_out torch.mean(x, dim(1, 3), keepdimTrue) # (B, 1, F, 1) max_out torch.max(x, dim1, keepdimTrue)[0] max_out torch.max(max_out, dim3, keepdimTrue)[0].permute(0, 1, 2, 3) # 合并并过一维卷积 desc avg_out.squeeze(-1) # (B, 1, F) weight self.conv(desc).unsqueeze(-1) # (B, F, 1) weight torch.sigmoid(weight) # 沿频率维度扩展 weight weight.permute(0, 2, 1, 3) # (B, 1, F, 1) return x * weight class TimeAttention(nn.Module): def __init__(self, time_steps, reduction4): super().__init__() self.conv nn.Sequential( nn.Conv1d(1, time_steps // reduction, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv1d(time_steps // reduction, time_steps, kernel_size3, padding1) ) def forward(self, x): # x: (B, C, F, T) # 沿通道和频率维度池化得到时间方向描述符 (B, T) avg_out torch.mean(x, dim(1, 2), keepdimTrue) # (B, 1, 1, T) max_out torch.max(x, dim1, keepdimTrue)[0] max_out torch.max(max_out, dim2, keepdimTrue)[0] # (B, 1, 1, T) desc avg_out.squeeze(2) # (B, 1, T) weight self.conv(desc).unsqueeze(2) # (B, 1, T) weight torch.sigmoid(weight) return x * weight class TemporalFrequencyAttention(nn.Module): def __init__(self, in_channels, freq_bins, time_steps): super().__init__() self.channel_att ChannelAttention(in_channels) self.freq_att FrequencyAttention(freq_bins) self.time_att TimeAttention(time_steps) def forward(self, x): x self.channel_att(x) x self.freq_att(x) x self.time_att(x) return x这三个子模块从不同维度对特征图做重标定串联顺序是我多次实验调出来的结果先调整通道权重再调整频率权重最后调整时间权重。整体逻辑是“先确定看什么内容再确定在哪个频率带看最后确定什么时候看”。把频率分支放在时间前面是因为频率选择对调制识别来说通常比时间定位更重要——调制方式的差异首先体现在频谱结构上。3.3 CNN主干与整体模型组合主干网络每层参数的选择直接决定了模型能提取到多“粗”或多“细”的特征。我这里的配置是针对 ( 33 \times 13 ) 输入尺寸调好的你可以根据自己的谱图尺寸调整卷积核和池化层的步长。class AMR_CNN(nn.Module): def __init__(self, num_classes11): super().__init__() self.features nn.Sequential( # Conv Block 1: 1 - 32 通道 nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # Conv Block 2: 32 - 64 通道 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) # 经过上面两个池化后特征图尺寸约为 (8, 3) self.attention TemporalFrequencyAttention( in_channels64, freq_bins8, time_steps3 ) # Conv Block 3: 64 - 128 通道保持尺寸 self.conv3 nn.Sequential( nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x self.features(x) x self.attention(x) x self.conv3(x) x self.classifier(x) return x很多人第一次写这种网络时会在注意力模块的位置上纠结半天。我的经验是注意力加在中间层比加在最后一层有效。如果你把注意力模块放在最后一个卷积块后面它只能对已经高度抽象的特征做微调作用非常有限而放在第二个卷积块后注意力能在特征图还在中等分辨率的时候进行引导后续卷积层就能更有针对性地提取判别特征。当然这不是绝对的——实际应该以验证集的结果为准我只是建议你从这个位置开始试。3.4 训练流程与评估指标训练这块没什么玄学就是标准流程交叉熵损失、Adam 优化器、余弦退火学习率。但有几个细节会影响最终效果值得单独拎出来说。def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0.0 correct 0 total 0 for inputs, labels in dataloader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return total_loss / len(dataloader), correct / total def evaluate(model, dataloader, device): model.eval() correct 0 total 0 with torch.no_grad(): for inputs, labels in dataloader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return correct / total评估指标方面除了整体准确率我还习惯单独看每个信噪比档位的准确率曲线。因为整体准确率很容易被高信噪比样本的高分类准确率带偏低信噪比下的表现才是真正体现模型鲁棒性的地方。项目里最终在 RadioML 2016.10a 上-6 dB 以上信噪比区间平均准确率超过 90%-2 dB 时达到 93.5%0 dB 以上基本稳定在 95% 以上。相比同参数量的普通 CNN不加注意力在 0 dB 以下平均提升了 4~6 个百分点这个提升集中在低信噪比段。4. 训练技巧与性能调优4.1 数据增强策略加噪、裁剪与样本混叠在通信信号识别任务中数据增强的思路跟图像不太一样。图像可以随便翻转、裁剪、变色但信号序列一旦翻转时间轴调制信息可能就失真了。我实际测试过几种增强手段效果有差异。最直接有效的是加噪增强在训练时对每个输入样本动态添加高斯白噪声噪声功率在某个范围内随机取值。这等效于人为扩大了训练集的信噪比覆盖范围模型见过“更脏”的样本后对低信噪比测试数据的适应能力会明显增强。另一种我常用的方式是随机裁剪从 128 个采样点中随机截取 96 个点再做 STFT。这等于强制模型不依赖于信号的完整相位信息而是从局部结构推断调制方式——这对 QAM16 和 QAM64 这类靠幅度差异区分的调制方式很有帮助因为它们即使只看到一段局部波形幅度分布的统计特征也足够区分。最后一种增强是样本混叠mixup把两个不同调制方式的样本按一定比例线性叠加标签也按同样比例线性混合。这个方法能让模型的决策边界更平滑尤其适合调制类别间特征接近的情况比如 QAM16 和 QAM64。不过 mixup 的 alpha 参数需要调太大容易导致欠拟合我最终用的是 alpha0.2。4.2 超参数敏感性分析这个模型对超参数最敏感的三个位置STFT 帧长、注意力模块位置、卷积核尺寸。STFT 帧长直接决定了时频图的时间分辨率和频率分辨率之间的权衡。帧长取 16 时时间分辨率高了但频率分辨率很差相邻调制方式的谱峰容易糊在一起帧长取 64 时频率分辨率好了但 128 个采样点只能切出 3 帧时间维度基本失效。我在 32 这个折中点上获得了最高验证集准确率。注意力模块加在第几个卷积块后也尝试了三种方案不加基线、加在第二个块后、加在第三个块后。结果是加在第二个块后最优比基线高 4.2 个百分点加在第三个块后只比基线高 1.5 个百分点。原因在前文说过最后一个卷积块的特征图空间尺寸太小时间轴已经压缩到 1 或 2 个像素时间注意力基本没有操作空间了。卷积核尺寸对结果的影响比较微妙。( 3 \times 3 ) 和 ( 5 \times 5 ) 在验证集上的差距不到 1 个百分点但 ( 5 \times 5 ) 的参数量几乎翻倍训练速度明显变慢。最后还是选了 ( 3 \times 3 )追求的是性价比。4.3 参数量与计算量对比模型参数量FLOPs0dB准确率训练耗时(epoch)基线CNN186K12.4M91.2%5.8sCNN SE215K13.1M92.4%6.1sCNN CBAM231K13.6M93.1%6.4sCNN 本文注意力TFAM327K15.2M95.6%8.2s从上表能看出在控制其他条件相同的情况下TFAM 的参数量比基线多了不到 1.5 倍但准确率提升了 4.4 个百分点。SE 模块的增益主要来自通道维度CBAM 多了一个空间维度但空间注意力在时频图上等价于同时作用于频率和时间轴容易混淆两个方向的贡献。TFAM 把它们拆开来分别建模效果自然更有针对性。5. 常见问题与排查技巧实录5.1 代码实现中的典型坑写这个项目时踩过的坑不少挑几个影响最大的记录如下。第一个坑是 STFT 之后通道数对不上。scipy.signal.stft默认返回的复数谱取模之后形状是 (freq, time)。如果不小心把时间维放到了第一维后面 CNN 的卷积核设计会直接乱掉而且这种错误不会报错只会让准确率莫名其妙地低。排查方法很简单在 Dataset 的__getitem__里打印张量形状人工确认一下。第二个坑是批归一化在训练和推理模式下行为不同。很多人在训练时把BatchNorm2d的 momentum 默认值用得很顺手但在小 batch size 下比如 32BN 的统计量会很不稳定。我的做法是把 batch size 固定在 128 以上除非显存不够否则不要降到 64 以下。第三个坑是 Dropout 放在了AdaptiveAvgPool2d之前。有人习惯在卷积特征图上直接做 Dropout但这样只会随机把某些空间位置的激活清零对全连接层的正则化效果其实不明显。正确做法是先全局平均池化再对摊平后的向量做 Dropout这样是直接在分类特征层面做随机丢弃效果要好得多。5.2 低信噪比下掉点严重的排查思路如果你发现模型在低信噪比比如 -10 dB 以下下准确率接近随机猜测大概率不是网络结构的问题而是输入谱图已经被噪声完全淹没了。此时首先要做的不是改模型而是先可视化几个典型样本的时频图看看人眼能不能分辨出调制方式的差异。如果人眼都看不出来那模型学不到东西是正常的需要从数据层面解决增加低信噪比样本的采样权重、对 IQ 序列做能量归一化、或者使用更长的观测窗口。还有一种容易被忽略的情况数据集划分时的泄漏问题。RadioML 2016.10a 中同一调制方式在同一信噪比下包含大量样本如果随机划分数据集时没有按样本组进行分组group split模型可能“记住”了某些特定噪声实现导致验证集指标虚高。正确的做法是按样本索引范围划分确保同一段信号不会同时出现在训练集和验证集中。5.3 注意力可视化与模型可解释性注意力模块算出来的权重不是只能喂给网络用的它们也是诊断模型的好工具。把测试集上某个 QPSK 样本输入模型取出FrequencyAttention模块的输出权重映射回原来的时频图就能看到模型是依据哪些时频区域做出判断的。我观察到的现象是模型会把注意力集中在信号能量最集中的频率带附近同时在某些时间帧上给予更高权重——这些时间帧通常对应符号跳变的位置。这说明时间-频率注意力确实学到了“物理上有意义”的特征而不是在拟合噪声。这种可视化还有一个用途当模型在某个类别上表现特别差时看看注意力分布是否集中在错误的频率区域。如果是说明 STFT 参数或数据预处理对该调制方式不友好调整帧长或窗函数往往比改网络结构更有效。6. 实验环境说明与复现建议整个项目的训练环境是单张 RTX 3090PyTorch 1.12CUDA 11.6。数据集从公开渠道下载后需要先按照mods和snr组织成字典格式。如果你在本地复现时遇到显存不足的问题可以尝试把 batch size 从 128 降到 64同时把学习率从 0.001 相应降低到 0.0007这样收敛速度基本不受影响。关于代码组织我习惯把dataset.py、models.py、train.py、evaluate.py四个文件分离其中models.py中只放网络结构定义train.py里只写训练循环这样调试起来很方便。如果你想快速跑通全流程建议先用 2 个 epoch 验证代码没有 bug再用完整配置训练 30 个 epoch最终收敛约需要 6 到 8 分钟。最后再分享一个我在这个项目里摸索出来的小技巧训练时把学习率调度器的T_max设成总 epoch 数的一半而不是全程用余弦退火。这样前 15 个 epoch 学习率从 0.001 降到接近 0后 15 个 epoch 重新从 0.0005 开始再来一轮余弦退火。实测比单轮余弦退火的收敛精度高 0.5 到 1 个百分点而且几乎没有额外成本。这个思路对很多小型分类任务都适用不局限在调制识别场景。本文还有配套的精品资源点击获取
返回列表