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

资讯详情

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

基于LSTM的调制识别:Pytorch实现与RML2016-10a实战

基于LSTM的调制识别:Pytorch实现与RML2016-10a实战 简介面向通信信号处理和深度学习研究者这是一份基于RML2016-10a数据集用LSTM实现调制识别的PyTorch工程旨在解决无线通信信号调制类型的自动分类问题。资源包内共14个文件以三个Python脚本为核心分别承担数据读取、信号预处理和模型训练并附带编译后的pyc文件便于快速加载两个CSV文件记录了训练过程准确率随轮次变化的数据另有项目配置文件支持常见IDE直接打开。压缩包仅11KB不含原始数据集需自备RML2016.10a数据该数据集覆盖多种调制方式和不同信噪比为模型训练提供了多样化样本。代码实现中使用了双层LSTM捕捉信号时间动态特性并通过门结构缓解长期依赖问题训练环节采用交叉熵损失、Adam优化器和dropout正则化在验证集上可达到九成以上识别精度。资源已有486人学习下载适合初学者对照论文复现实验也适合研究者在此基础上进行模型改进与对比实验。 第一次接触调制识别这个需求是在一个频谱监测项目里。手里有一段不明信号第一步就是判断它到底是用什么调制方式发出来的——BPSKQPSK还是QAM16只有知道了调制类型后面的解调、分析和干扰判断才有意义。以前的做法是提取信号的高阶累积量、谱特征再喂给SVM或决策树但信噪比一旦掉到0dB以下人工特征就开始“糊”。后来我换成了深度学习的路子用RML2016-10a数据集做训练用LSTM建模IQ序列识别效果明显上了一个台阶。这篇文章就是把这套方案从数据加载到模型评估的完整过程拆开来讲环境基于Pytorch代码可以直接跑。适合刚接触调制识别、或者想在RML2016-10a上快速出结果的朋友。1. 调制识别任务与RML2016-10a数据集的底细1.1 这个任务到底在解决什么问题通信信号调制识别说白了就是给一段接收到的信号“验明正身”。发射端把比特映射成某种调制方式的符号再经过射频和信道传到接收端接收端拿到的是加了噪声、可能还有频偏和衰落影响的波形。要正确解调第一步就得知道对方用的是哪种调制方式。军用频谱监测、民用频谱管理、认知无线电里的频谱感知都要靠调制识别打底。传统方法分两步走先手工设计特征高阶累积量、瞬时幅度/相位统计量、循环谱等再把特征丢给分类器。这套路的问题在于特征设计依赖专家经验而且低信噪比下特征噪声太大鲁棒性很难保证。深度学习把“特征提取分类”合并成一个端到端的网络直接从IQ采样点学习判决依据省掉了手工特征这一步效果普遍比传统方法好尤其在信噪比低的时候。RML2016-10a是调制识别领域最常用的公开数据集出自OShea等人的Deep Learning Based Modulation Classification工作由GNU Radio仿真生成。很多论文都在这个数据集上对比算法用它做基准实验结果之间才可比较。它覆盖的调制类型、信噪比范围和信道损伤都是相对标准的拿它作为入门和对比工具都很合适。1.2 RML2016-10a数据集长什么样这个数据集包含11种调制类型8PSK、AM-DSB、AM-SSB、BPSK、CPFSK、GFSK、PAM4、QAM16、QAM64、QPSK、WBFM。信噪比从-20dB到30dB步进2dB一共26个SNR点。每种调制类型在每个SNR下有大约1000个样本总样本量在28万级别。每个样本是一个2×128的复数基带信号用I/Q两路正交分量表示第一行是I路同相分量第二行是Q路正交分量长度128意味着每条样本包含128个复采样点。这个数据集不是理想信号直接采样中间经过了信道模拟包括加性高斯白噪声AWGN、多径衰落、采样时钟偏移和中心频率偏移等。也就是说模型必须在有各种失真的条件下做识别这和真实场景更接近。也正因为如此低信噪比下样本非常难看人眼都分不清模型能做到的也就是有依据地“猜”。加载方式上网上常见的有两种文件版本一种是RML2016.10a_dict.pkl用pickle读取另一种是RML2016.10a_dict.npy用numpy加载。内容是同一个数据。1.3 序列输入还是图像输入决定了后面怎么搭模型拿到一个样本之后第一个要决定的不是选哪个网络而是怎么理解它的结构。2×128的矩阵有两条路可以走一是把它当灰度图I路和Q路各算一个通道用二维CNN去做图像分类二是把它当长度为128、每个时间步输入2个特征的时间序列用RNN/LSTM去做序列分类。这两条路都有文献支持也都出过好结果。本文标题既然限定在LSTM我就按序列建模的思路走。这里要明确一个容易混淆的点RML2016-10a里的SNR是样本自带的属性不是我们要预测的目标。任务只输出调制类型SNR只用于评估阶段按信噪比分组看模型在不同信噪比下的表现差异。训练时不把SNR作为输入特征也不把它当标签。2. 用LSTM而不是CNN/MLP处理IQ序列的原因2.1 IQ采样首先是时间序列很多人一开始会惯性思维2×128不就是个二维矩阵吗直接压平喂给全连接层或者当图像上CNN不就行了MLP的问题是参数爆炸和结构错位128×2256个输入看着不大但MLP没有权值共享学不到序列的时序依赖也不会自动具备平移不变性所以效果通常不理想。CNN在调制识别上的确可行它通过卷积核提取局部模式I/Q两路可以被看成两个通道但CNN更擅长捕捉局部特征对“信号前后状态在时间上的连续变化”这种长程依赖关系建模能力有限。而调制信号本质上是时间序列。BPSK的相位翻转、QAM的幅度/相位组合变化、CPFSK的连续相位变化这些都是沿着采样点依次出现的时序规律。某一个采样点的I/Q值单独拿出来看不出调制方式但把128个点连起来看相位跳变的节奏和幅度的分布就暴露了调制类型。LSTM天生就是干这个的它按时间步顺序读取序列把前面学到的信息通过隐状态传到后面对时间上的依赖关系建模。2.2 LSTM如何读取一段调制信号LSTM的输入格式是三维张量(batch, seq_len, input_size)。在我们的任务里seq_len128是采样点数量input_size2是每个采样点的(I, Q)两个数值。也就是说网络在每一个时间步吃进去一个IQ点共吃128步。LSTM内部通过输入门、遗忘门、输出门控制信息的写入、保留和读出相比传统RNN它多了一个细胞状态可以更好地缓解长时依赖问题。调制识别中当前时刻的相位变化往往需要参考之前若干个符号周期的状态特别是CPFSK这种连续相位调制或者QAM这种符号之间有一定关联性的调制。LSTM的细胞状态就是用来存这种长期信息的。我用单向LSTM还是双向LSTM单向就够了因为调制信号的识别本质上不依赖“未来”信息——你拿到一段完整的128个采样点从左到右扫一遍信息已经足够。双向LSTM会带来额外的计算量而且在RML2016-10a上提升有限入门阶段先跑通单向结构更合理。2.3 和CNN方案对比各自边界在哪CNN类方案也不是不行。很多实验里用类似ResNet或VGG结构的2D CNN把2×128图像化处理准确率也很高。但要注意CNN把I/Q当成图像通道时实际上把“时间轴”和“特征轴”混在一起卷积卷积核的平移假设在时间维度上成立在I/Q维度上没有实际意义。所以很多经验是把2×128转成1×256一维信号用1D CNN做或者直接用复数CNN处理原始IQ。我个人的选择思路是如果追求快速落地、数据集规模有限LSTM是性价比很高的方案它结构简单对时间序列的假设和信号物理本质一致很多开源复现都能稳定跑到90%左右的整体准确率。如果追求更高准确率可以考虑CNN-LSTM混合网络先用CNN抽取局部特征再用LSTM建模长程依赖这个放到后面改进方向里细说。3. 环境准备与数据加载把数据正确喂进Pytorch3.1 环境与依赖代码基于Pytorch框架版本选2.x即可Python 3.8到3.10都可以。如果机器有NVIDIA显卡先装CUDA对应版本的Pytorch没有显卡的话CPU也能跑只是每个epoch会慢一些RML2016-10a数据量28万CPU训练一轮可能需要较长时间。建议直接这样创建环境conda create -n modulation python3.9 conda activate modulation pip install torch torchvision torchaudio pip install numpy matplotlib scikit-learn如果CUDA版本不匹配去Pytorch官网选对应平台安装命令这里不展开。除了torch还需要numpy做数据加载matplotlib画混淆矩阵和精度曲线scikit-learn算混淆矩阵和分类报告。3.2 加载RML2016-10a数据并构建标签数据文件一般长这样RML2016.10a_dict.pkl或者RML2016.10a_dict.npy。它的结构是一个字典key是(调制类型, SNR)二元组value是numpy数组形状为(样本数, 2, 128)。加载代码如下import pickle import numpy as np with open(RML2016.10a_dict.pkl, rb) as f: data pickle.load(f, encodinglatin1) # 数据key是(mod_type, snr)二元组 mods sorted(set(k[0] for k in data.keys())) snrs sorted(set(k[1] for k in data.keys())) print(调制类型:, mods) print(SNR范围:, snrs)接着把样本和标签组装成两个大数组X_list, y_mod, y_snr [], [], [] for mod in mods: for snr in snrs: samples data[(mod, snr)] X_list.append(samples) y_mod.extend([mod] * samples.shape[0]) y_snr.extend([snr] * samples.shape[0]) X np.concatenate(X_list, axis0).astype(np.float32) y_mod np.array(y_mod) y_snr np.array(y_snr) # 调制类型转成类别索引 mod_to_idx {mod: i for i, mod in enumerate(mods)} y np.array([mod_to_idx[m] for m in y_mod])注意这里有一个细节data字典的key在pickle文件中可能是tuple但不同来源的文件key顺序可能不同用sorted保证顺序稳定。y_mod和y_snr单独保留是为了后面按SNR分精度曲线时用分类器的标签只有y。3.3 训练集/测试集切分的规则RML2016-10a官方给出的标准做法是对于每一种(调制类型, SNR)组合在1000个样本里随机抽一半做训练另一半做测试。这里的关键是不能把所有样本堆在一起然后全局随机划分而是要按组合逐个划分确保训练集和测试集覆盖的调制类型、SNR分布完全一致。我在这一步吃过亏后面踩坑部分会详说。这里先给出正确做法from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test [], [], [], [] for mod in mods: for snr in snrs: idx np.where((y_mod mod) (y_snr snr))[0] sub_X X[idx] sub_y y[idx] tr_idx, te_idx train_test_split( np.arange(len(sub_X)), test_size0.5, random_state42, shuffleTrue ) X_train.append(sub_X[tr_idx]) X_test.append(sub_X[te_idx]) y_train.append(sub_y[tr_idx]) y_test.append(sub_y[te_idx]) X_train np.concatenate(X_train) X_test np.concatenate(X_test) y_train np.concatenate(y_train) y_test np.concatenate(y_test) print(训练集:, X_train.shape, 测试集:, X_test.shape)这样切出来训练集约14万样本测试集约14万样本而且每个(调制, SNR)组合都均匀地分成了两份后面的评估才不会出现“测试集全是高信噪比导致指标虚高”的假象。4. 网络结构与Pytorch实现4.1 输入张量形状变换从(2,128)到(128,2)LSTM期望的输入是(batch, seq_len, input_size)而我们拿到的样本是(2, 128)。这里要做一个permute操作把I/Q维和采样点维对调原来(2, 128)表示第0维是I/Q第1维是128个采样点LSTM需要每个时间步输入一个包含2个特征I和Q的向量所以形状应该变成(128, 2)。在batch维度上一起来就是# x: (batch, 2, 128) x x.permute(0, 2, 1) # 现在 x: (batch, 128, 2)这个permute不是简单的reshape不要搞混。reshape会把数据按内存顺序重新排列两个维度的语义会乱permute才是维度重排。很多新手在这里翻车输入形状不对模型直接报错。4.2 网络结构代码我用的网络结构很简单两层LSTM每个时间步输入I/Q两路输出128维的隐状态取最后一个时间步的隐状态接一个全连接分类头。结构如下import torch import torch.nn as nn class ModLSTM(nn.Module): def __init__(self, input_size2, hidden_size128, num_layers2, num_classes11): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropout0.3, bidirectionalFalse ) self.dropout nn.Dropout(0.3) self.classifier nn.Sequential( nn.Linear(hidden_size, 64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, num_classes) ) def forward(self, x): # x: (batch, 2, 128) - (batch, 128, 2) x x.permute(0, 2, 1) out, (h_n, c_n) self.lstm(x) # 取最后一个时间步的输出: (batch, hidden_size) out out[:, -1, :] out self.dropout(out) out self.classifier(out) return out几个参数的选择理由hidden_size128是一个起点太小拟合不足太大容易过拟合还慢。num_layers2是多层LSTM的常见配置第一层提取基础时序模式第二层在隐状态基础上再抽象比单层效果好。再往上加到3层、4层在RML2016-10a上收益不明显训练还慢。dropout放在LSTM内部层与层之间和分类头之前用来防过拟合。如果num_layers1LSTM内部的dropout参数不生效Pytorch会提示只有多层LSTM才会用dropout。分类头里先降维到64再输出11类比直接128→11效果更稳定相当于给分类器一个压缩瓶颈。注意forward里用out[:, -1, :]取最后时刻输出也有人用h_n[-1]两者是一样的。out维度是(batch, seq_len, hidden_size)取-1就是最后一个时间步的隐状态它已经把整段128个采样点的信息压缩到一个向量里再做分类。4.3 训练配置与超参数解释训练部分标准流程CrossEntropyLoss做损失函数Adam做优化器加上学习率衰减和梯度裁剪。代码如下import torch.optim as optim from torch.utils.data import TensorDataset, DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) model ModLSTM().to(device) train_dataset TensorDataset( torch.tensor(X_train), torch.tensor(y_train) ) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.5) epochs 50 clip_value 1.0 for epoch in range(epochs): model.train() running_loss 0.0 for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() outputs model(xb) loss criterion(outputs, yb) loss.backward() # 梯度裁剪防止LSTM梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), clip_value) optimizer.step() running_loss loss.item() * xb.size(0) epoch_loss running_loss / len(train_dataset) scheduler.step() print(fEpoch {epoch1:02d}/{epochs} | Loss: {epoch_loss:.4f})超参数为什么这么选batch_size128是个均衡点太小的话梯度更新频繁但噪声大太大会占显存128对LSTM来说也够稳。lr1e-3是Adam常用的初始学习率如果发现loss震荡厉害降到3e-4。weight_decay1e-4作为L2正则项对抑制过拟合有帮助。StepLR每20轮把学习率减半让后期收敛更平稳。梯度裁剪很重要。LSTM在反向传播时梯度容易爆炸clip_grad_norm_把梯度的L2范数限制在1.0以内防止训练发散。5. 训练评估整体准确率之外还要看什么5.1 训练过程观察训练50轮左右loss会从初始的2.4附近往下降。前5轮降得很快到后面趋于平缓。我跑这个结构的时候训练集准确率最后能到95%以上测试集整体准确率在88%左右。这里要强调训练准确率和测试准确率之间的gap不要拉得太大如果gap超过10个点就是过拟合了。如果发现测试准确率停滞不前而训练准确率还在涨说明模型在死记训练数据。对策是加大dropout、减小hidden_size、或者再加一点weight_decay。如果训练集准确率都上不去则说明模型容量不够或者学习率不合适可以适当增大hidden_size。评估代码test_dataset TensorDataset( torch.tensor(X_test), torch.tensor(y_test) ) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse) model.eval() all_preds, all_labels, all_snrs [], [], [] with torch.no_grad(): for xb, yb in test_loader: xb xb.to(device) outputs model(xb) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(yb.numpy()) # 这里要把测试样本对应的SNR也存下来 # 如果加载的时候保留了y_snr_test就可以按SNR分组统计5.2 混淆矩阵错误都发生在哪里整体准确率之外一定要看混淆矩阵它会告诉你模型到底在哪些调制类型之间犯糊涂。用scikit-learn一行就能算from sklearn.metrics import confusion_matrix, classification_report cm confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, target_namesmods, digits3))在我跑出来的结果里最容易混的是三类WBFM和AM-DSB、QAM16和QAM64。WBFM是宽带调频AM-DSB是调幅两者都是连续的模拟调制在低信噪比下频谱结构有相似之处模型容易搞混。QAM16和QAM64是数字调制里的“近亲”星座点密度不同但在噪声干扰下部分样本的符号分布特征被抹平模型无法可靠地区分它们。分类报告里还应该关注AM-SSB这个类别在很多模型里是精度最低的。原因是单边带调制本身只用了双边带的一半带宽频谱特征相对稀疏数据集的生成过程中还叠加了信道效应导致它的可分性比其他模拟调制差。如果后续想提升整体准确率重点应该是解决这四类的混淆而不是指望模型在PSK/QAM大类上再涨多少。5.3 SNR-准确率曲线模型能力的真实边界RML2016-10a的价值在SNR维度的评估。一个模型在-20dB下接近随机约9%准确率在0dB以上能到80%以上在10dB以上接近95%这是很正常的现象。只看整体准确率不看SNR分布会掩盖模型在低信噪比下的真实水平。画SNR-准确率曲线的方式是对测试样本按照SNR分组每组单独算准确率import matplotlib.pyplot as plt from collections import defaultdict snr_pred defaultdict(list) snr_true defaultdict(list) # 假设test_snrs是测试集每个样本对应的SNR for pred, true, snr in zip(all_preds, all_labels, test_snrs): snr_pred[snr].append(pred) snr_true[snr].append(true) snr_list sorted(snr_pred.keys()) accs [] for snr in snr_list: preds_snr np.array(snr_pred[snr]) true_snr np.array(snr_true[snr]) acc (preds_snr true_snr).mean() accs.append(acc) plt.plot(snr_list, accs, markero) plt.xlabel(SNR (dB)) plt.ylabel(Accuracy) plt.grid(True) plt.show()这条曲线几乎是调制识别模型的标准交付内容。实际项目中别人问“你的模型能到多少准确率”一定要回答清楚是哪个SNR下的准确率否则没有意义。曲线还有一个用途定位模型性能拐点。通常0dB是一个分水岭0dB以上性能快速上升0dB以下快速下降。如果模型在0dB附近也有不错的准确率说明它的抗噪能力在同级模型里算好的。6. 实操中踩过的坑与后续改进方向6.1 数据切分的细节坑数据切分是最容易出问题的地方而且出的问题很隐蔽。我第一次做的时候图省事把X直接全局随机切分成train_test_split发现测试准确率高得离谱到了94%。后来一查才明白全局随机切分时同一个(调制,SNR)组合的样本被拆到了训练集和测试集虽然样本本身没重叠但它们的分布完全一样相当于测试集泄露了很多信息。正确做法前面已经写了必须是按(调制类型, SNR)组合逐一划分。这也是很多论文复现中强调的“组划分”概念。还有一个相关坑有人按(调制,SNR)组划分之后测试集和训练集的样本数量本来应该一致但代码里如果不小心把数据顺序搞乱后面的SNR标签对不上画出来的SNR-准确率曲线就是错的。我建议把y_mod、y_snr都按同样的index划分保存下来后面评估时按样本索引对齐。6.2 梯度问题与过拟合LSTM训练中gradient explosion比CNN更常见。原因很简单LSTM在时间步方向上展开了128步反向传播路径长梯度经过多次矩阵乘法容易指数级增长或衰减。我在第二次实验时把学习率调到5e-3loss在几个epoch后直接变成nan就是梯度爆炸的典型表现。解决的办法就是加梯度裁剪clip_value设为1.0是我测试下来比较稳的取值太小会拖慢收敛太大约束不住。过拟合在RML2016-10a同样存在尤其hidden_size设到256以上、训练轮数超过80的时候。训练集准确率接近100%测试集反而止步不前。我的经验是torch里LSTM的dropout参数只对多层LSTM的层间生效分类头前面还要单独再加一个Dropout两个一起用效果才好。6.3 改进方向BiLSTM、注意力、CNN-LSTM如果想在现有基础上继续提高准确率可以按下面的优先级尝试。第一把单向LSTM换成双向LSTM。双向结构能让每个时间步同时看到过去和未来的信息对序列整体特征的建模更充分。代价是参数量翻倍训练时间变长但RML2016-10a上一般能涨1-2个点。第二加注意力机制。普通LSTM取最后一个时间步的输出相当于把整段序列的信息硬塞到一个向量里中间某些重要的时序特征可能被稀释。注意力机制可以让网络自动聚焦在判别能力强的采样点上比如相位跳变的瞬间、幅度切换的位置。实现上可以在LSTM输出(batch, seq_len, hidden)之后接一个注意力层对所有时间步的隐状态做加权求和再丢给分类头。第三CNN-LSTM混合结构。先用一维卷积核在IQ序列上滑动提取局部短期特征再把卷积输出作为LSTM的输入序列。CNN相当于做了降维和特征增强LSTM继续负责长程依赖。这种结构在多个调制识别论文里取得过不错的成绩也是RML2016-10a上的常青树方案。另外还有数据层面的玩法比如对信号做幅度归一化、随机加噪做数据增强、把I/Q两路扩展成幅度/相位特征以后再输入网络。但注意扩展特征时一定要在时间维度上逐点计算不能破坏序列结构。我试过把I/Q转换成幅度和相位拼接成4维输入整体准确率没有显著提升反而是额外的计算开销所以目前还是直接使用原始I/Q。最后说一句RML2016-10a虽然是个公开数据集但生成它的信道模型和真实无线环境还是有差距。模型在这个数据集上表现好不代表到了实际采集信号里就一定可靠。真要做工程落地建议在自己的数据上做微调或者用仿真数据预训练、真实数据再训练的方式迁移。这也是我在后续项目里计划继续验证的方向。本文还有配套的精品资源点击获取
返回列表