简介:面向通信信号处理和深度学习研究者,这是一份基于RML2016-10a数据集用LSTM实现调制识别的PyTorch工程,旨在解决无线通信信号调制类型的自动分类问题。资源包内共14个文件,以三个Python脚本为核心,分别承担数据读取、信号预处理和模型训练,并附带编译后的pyc文件便于快速加载;两个CSV文件记录了训练过程准确率随轮次变化的数据,另有项目配置文件支持常见IDE直接打开。压缩包仅11KB,不含原始数据集,需自备RML2016.10a数据,该数据集覆盖多种调制方式和不同信噪比,为模型训练提供了多样化样本。代码实现中使用了双层LSTM捕捉信号时间动态特性,并通过门结构缓解长期依赖问题;训练环节采用交叉熵损失、Adam优化器和dropout正则化,在验证集上可达到九成以上识别精度。资源已有486人学习下载,适合初学者对照论文复现实验,也适合研究者在此基础上进行模型改进与对比实验。 第一次接触调制识别这个需求,是在一个频谱监测项目里。手里有一段不明信号,第一步就是判断它到底是用什么调制方式发出来的——BPSK?QPSK?还是QAM16?只有知道了调制类型,后面的解调、分析和干扰判断才有意义。以前的做法是提取信号的高阶累积量、谱特征,再喂给SVM或决策树,但信噪比一旦掉到0dB以下,人工特征就开始“糊”。后来我换成了深度学习的路子,用RML2016-10a数据集做训练,用LSTM建模IQ序列,识别效果明显上了一个台阶。这篇文章就是把这套方案从数据加载到模型评估的完整过程拆开来讲,环境基于Pytorch,代码可以直接跑。适合刚接触调制识别、或者想在RML2016-10a上快速出结果的朋友。
1. 调制识别任务与RML2016-10a数据集的底细
1.1 这个任务到底在解决什么问题
通信信号调制识别,说白了就是给一段接收到的信号“验明正身”。发射端把比特映射成某种调制方式的符号,再经过射频和信道传到接收端,接收端拿到的是加了噪声、可能还有频偏和衰落影响的波形。要正确解调,第一步就得知道对方用的是哪种调制方式。军用频谱监测、民用频谱管理、认知无线电里的频谱感知,都要靠调制识别打底。
传统方法分两步走:先手工设计特征(高阶累积量、瞬时幅度/相位统计量、循环谱等),再把特征丢给分类器。这套路的问题在于,特征设计依赖专家经验,而且低信噪比下特征噪声太大,鲁棒性很难保证。深度学习把“特征提取+分类”合并成一个端到端的网络,直接从IQ采样点学习判决依据,省掉了手工特征这一步,效果普遍比传统方法好,尤其在信噪比低的时候。
RML2016-10a是调制识别领域最常用的公开数据集,出自O'Shea等人的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×2=256个输入看着不大,但MLP没有权值共享,学不到序列的时序依赖,也不会自动具备平移不变性,所以效果通常不理想。CNN在调制识别上的确可行,它通过卷积核提取局部模式,I/Q两路可以被看成两个通道,但CNN更擅长捕捉局部特征,对“信号前后状态在时间上的连续变化”这种长程依赖关系建模能力有限。
而调制信号本质上是时间序列。BPSK的相位翻转、QAM的幅度/相位组合变化、CPFSK的连续相位变化,这些都是沿着采样点依次出现的时序规律。某一个采样点的I/Q值单独拿出来看不出调制方式,但把128个点连起来看,相位跳变的节奏和幅度的分布就暴露了调制类型。LSTM天生就是干这个的:它按时间步顺序读取序列,把前面学到的信息通过隐状态传到后面,对时间上的依赖关系建模。
2.2 LSTM如何读取一段调制信号
LSTM的输入格式是三维张量(batch, seq_len, input_size)。在我们的任务里,seq_len=128是采样点数量,input_size=2是每个采样点的(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. 环境准备与数据加载:把数据正确喂进Pytorch
3.1 环境与依赖
代码基于Pytorch框架,版本选2.x即可,Python 3.8到3.10都可以。如果机器有NVIDIA显卡,先装CUDA对应版本的Pytorch;没有显卡的话CPU也能跑,只是每个epoch会慢一些,RML2016-10a数据量28万,CPU训练一轮可能需要较长时间。
建议直接这样创建环境:
conda create -n modulation python=3.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, encoding='latin1') # 数据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, axis=0).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_size=0.5, random_state=42, shuffle=True ) 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_size=2, hidden_size=128, num_layers=2, num_classes=11): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=0.3, bidirectional=False ) 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_size=128是一个起点,太小拟合不足,太大容易过拟合还慢。
- num_layers=2是多层LSTM的常见配置,第一层提取基础时序模式,第二层在隐状态基础上再抽象,比单层效果好。再往上加到3层、4层,在RML2016-10a上收益不明显,训练还慢。
- dropout放在LSTM内部(层与层之间)和分类头之前,用来防过拟合。如果num_layers=1,LSTM内部的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_size=128, shuffle=True) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.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(f'Epoch {epoch+1:02d}/{epochs} | Loss: {epoch_loss:.4f}')超参数为什么这么选:
- batch_size=128是个均衡点,太小的话梯度更新频繁但噪声大,太大会占显存,128对LSTM来说也够稳。
- lr=1e-3是Adam常用的初始学习率,如果发现loss震荡厉害,降到3e-4。
- weight_decay=1e-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_size=256, shuffle=False) 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_names=mods, digits=3))在我跑出来的结果里,最容易混的是三类: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, marker='o') 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-3,loss在几个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虽然是个公开数据集,但生成它的信道模型和真实无线环境还是有差距。模型在这个数据集上表现好,不代表到了实际采集信号里就一定可靠。真要做工程落地,建议在自己的数据上做微调,或者用仿真数据预训练、真实数据再训练的方式迁移。这也是我在后续项目里计划继续验证的方向。
本文还有配套的精品资源,点击获取