简介:面向通信信号调制识别任务的Python实现与项目详解,可作为深度学习方法应用于通信信号处理的教学案例、算法基准或毕业设计参考。项目将信号映射为二维星座图,利用卷积神经网络自动区分MPSK/MQAM等多种调制格式,涵盖数据生成、网络搭建、训练验证与混淆矩阵分析等环节。压缩包共11个文件,体积约18KB,以5个py源码为核心,另有MATLAB数据生成脚本、说明文档与备份文件,便于对照调试和二次开发。已有137人学习浏览,适合具备一定Python与深度学习基础的研究者快速上手。通过源码与文档可掌握从星座图样本库构建、CNN特征提取到超参数调优的完整思路。
1. 基于CNN的通信信号调制识别:为什么把IQ数据当成图来分类
传统调制识别靠专家特征,比如瞬时幅度、相位、频谱对称性,再加上决策树或SVM。这套方法在信噪比高于10dB时还能用,一旦信号沉到噪声里,人工设计的特征就开始失效。CNN的做法完全不同:它把I/Q两路采样点直接作为输入,让卷积核在时域波形上自动找规律,从能量分布、相位旋转、脉冲形状里学出一组判别特征。同样的数据集上,CNN往往比传统特征方法高出十几个百分点的识别率,这也是这个方向近几年变成主流的原因。
这篇文章面向的是要做完整项目的工程师:既要知道数据怎么构造、网络怎么写,也要知道训练完怎么验证、部署时有哪些暗坑。我会按实际做项目的顺序,把整个流程拆成可复现的最小闭环,并给出参数的选取理由和排查思路,让新手能跟步骤走,熟手能直接拿走经验。
2. 数据集与标签体系:调制识别到底在识别什么
2.1 公开数据集与信号模型:IQ样本长什么样
通信信号调制识别最常见的公开数据集是RML2016.10a。它由DeepSig发布,包含11种调制方式:8种数字调制(BPSK、QPSK、8PSK、QAM16、QAM64、CPFSK、GFSK、PAM4)和3种模拟调制(WB-FM、AM-SSB、AM-DSB)。每个样本是一段复基带信号的同相分量I和正交分量Q,采样点数为128,保存成2×128的数组。样本还附带一个信噪比标签,范围在-20dB到30dB之间,间隔2dB,用来区分不同噪声强度下的样本。
IQ数据本质上是一段复数波形,I路代表实部,Q路代表虚部。拿到一个样本后,第一步是把它变成模型能吃的张量。常见做法有两种:保持2×128的原始结构,或者把I和Q堆叠成128×2。这两种方式对网络结构的影响很大,因为前者适合做2D卷积,把它当成“2通道、128个时间点的波形图”;后者则可以直接当作128×2的图像。我更偏向保留2×128,因为它保留了I/Q两路在时间轴上的对应关系。
数据标签不是简单的整数,而是一个复合标签:调制类型+信噪比。训练时要区分清楚:模型学习的核心任务是调制类型分类,信噪比只用于分组评测。如果把信噪比也放进损失函数,多任务学习会增加额外复杂度,对初学者来说不建议。
2.2 Python实现:从文件到可训练张量的完整管线
import numpy as np import torch from torch.utils.data import Dataset, DataLoader # 载入RML2016.10a字典格式数据 def load_rml2016(path): data = np.load(path, allow_pickle=True)['data'] mods = list(data[0][0].keys()) # 调制类型列表 snrs = list(data[0][0][mods[0]].keys()) # 信噪比列表 samples = [] labels = [] for mod_idx, mod in enumerate(mods): for snr_idx, snr in enumerate(snrs): block = data[0][0][mod][snr] for i in range(block.shape[0]): samples.append(block[i]) labels.append((mod_idx, snr)) return np.array(samples), labels, mods, snrs class SignalDataset(Dataset): def __init__(self, samples, labels, snr_idx_list=None): self.samples = samples.astype(np.float32) # (N, 2, 128) self.labels = labels self.snr_idx_list = snr_idx_list def __len__(self): return len(self.samples) def __getitem__(self, idx): x = torch.from_numpy(self.samples[idx]) mod_idx, snr = self.labels[idx] y = torch.tensor(mod_idx, dtype=torch.long) return x, y, snr这段代码解决了三个关键问题:一是把原始字典结构转成Numpy数组,方便后续切片和打乱;二是把调制类型和信噪比拆开存储,训练时只取调制类型作为目标;三是通过snr_idx_list控制训练集和测试集按信噪比切分,避免泄漏。
参数说明里最需要注意的是astype(np.float32)。原始数据可能是float64,如果直接送进PyTorch会报类型错误,但转成float32后精度依然足够。另一个容易忽略的点是数据顺序:RML2016.10a原始样本是(128, 2)结构,需要先判断你的数据保存格式,再决定是否用.transpose(0, 2, 1)转成(2, 128)。很多初学者的第一个翻车点就在这里,输出的shape不对,后面卷积层直接报维度错误。
2.3 训练集/测试集划分:按信噪比切,不能随机切
调制识别任务里最容易犯的错误是随机划分数据集。如果同一个调制类型、同一个信噪比的多个样本同时出现在训练集和测试集里,模型相当于“见过”测试样本的相似版本,识别率虚高。正确做法是先把不同信噪比的数据完全分开,比如把-20到-10dB划为测试区间,其余作为训练,或者按调制类型的样本总量做分层划分。
# 按信噪比划分:测试集只保留指定SNR test_snrs = [-2, 0, 2, 4, 6, 8, 10] train_idx = [] test_idx = [] for i, (mod_idx, snr) in enumerate(labels): if snr in test_snrs: test_idx.append(i) else: train_idx.append(i) train_dataset = SignalDataset(samples[train_idx], [labels[i] for i in train_idx]) test_dataset = SignalDataset(samples[test_idx], [labels[i] for i in test_idx])这个划分逻辑保证测试集信噪比与训练集不完全重叠。实际项目里,我一般会把低信噪比区间和高信噪比区间各抽一部分进测试集,让测试结果能画出“准确率-信噪比”曲线。如果只拿高信噪比数据测试,模型的真实能力会被严重高估。
3. 网络结构怎么选:从2D CNN到轻量残差CNN的核心代码
3.1 为什么卷积核要沿时间轴滑动
IQ信号是时间序列,卷积核的作用是在相邻采样点之间提取局部模式。比如BPSK只有两个相位状态,QPSK有四个,它们的相邻采样点跳变规律完全不同。卷积核在时间轴上滑动时,能捕捉到符号切换时的相位突变和幅度起伏,这些恰恰是传统特征最难定义的。
通道维代表I路和Q路,卷积核会同时加权两路的信息。比如某个核可能学到“I路从正变负、Q路保持不动”的模式,这对应一种特定的相位跳变。多个卷积核叠加后,低层学到的是短时波形片段,高层组合成调制方式的整体判别特征。
3.2 一个能跑到85%的基线模型:卷积+池化+全连接
import torch.nn as nn class CNNModRec(nn.Module): def __init__(self, num_classes=11): super().__init__() # 第一段:2->32通道,卷积核3,沿时间轴提取局部特征 self.conv1 = nn.Conv2d(2, 32, kernel_size=(1, 3), padding=(0, 1)) self.bn1 = nn.BatchNorm2d(32) self.pool1 = nn.MaxPool2d((1, 2)) # 128->64 # 第二段:32->64通道 self.conv2 = nn.Conv2d(32, 64, kernel_size=(1, 3), padding=(0, 1)) self.bn2 = nn.BatchNorm2d(64) self.pool2 = nn.MaxPool2d((1, 2)) # 64->32 # 第三段:64->128通道 self.conv3 = nn.Conv2d(64, 128, kernel_size=(1, 3), padding=(0, 1)) self.bn3 = nn.BatchNorm2d(128) self.pool3 = nn.MaxPool2d((1, 2)) # 32->16 # 全局平均池化替代Flatten,减少参数 self.gap = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(128, num_classes) def forward(self, x): # x shape: (batch, 2, 1, 128) x = torch.relu(self.bn1(self.conv1(x))) x = self.pool1(x) x = torch.relu(self.bn2(self.conv2(x))) x = self.pool2(x) x = torch.relu(self.bn3(self.conv3(x))) x = self.pool3(x) x = self.gap(x) x = x.view(x.size(0), -1) return self.fc(x)这个模型的关键设计是卷积核的kernel_size=(1, 3),第一个维度是通道方向的核大小,固定为1表示不在I/Q之间做卷积,只沿时间轴滑。这样做的好处是保留I/Q两路的独立信息,让卷积核自行学习它们的组合方式。如果你把核设为(2, 3),相当于是把两路先混合再卷积,初期会损失一部分相位细节。
池化层的步长设计把序列长度从128逐步压缩到16,相当于把8个采样点合并成一个高层特征,既减少计算量,又扩大感受野。全局平均池化替代Flatten,可以把特征图直接映射成128维向量,大幅减少全连接层的参数量,降低过拟合风险。
3.3 残差连接与Dropout:提升两个点的关键改动
基线模型在-2dB以上的信噪比时表现尚可,但低信噪比下容易过拟合训练集中的噪声模式。一个有效改动是加入残差连接:把第一段卷积的输入直接加到第三段输出上,让网络在需要时可以退化为恒等映射,避免深层网络在噪声数据上学到过于复杂的假规律。
class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, (1, 3), padding=(0, 1)) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, (1, 3), padding=(0, 1)) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Conv2d(in_channels, out_channels, 1) def forward(self, x): out = torch.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) return torch.relu(out)加入残差块后,网络可以加深到4-6层而不出现梯度消失。另一个必须加的是Dropout,放在全连接层之前,参数设为0.5,训练时随机丢弃一半神经元,测试时自动补全。这一步虽然简单,却能把测试准确率稳定提升1-2个百分点,尤其在样本量不够大的时候效果更明显。
4. 训练与验证:信噪比对准确率的影响和收敛曲线怎么看
4.1 损失函数与优化器选择:交叉熵+AdamW是默认组合
调制识别是标准的单标签多分类问题,损失函数用交叉熵即可。优化器我一般用AdamW,权重衰减设为1e-4,初始学习率1e-3,配合余弦退火调度器。相比SGD,AdamW在信号数据上收敛更快,而且权重衰减的实现方式修正了Adam的L2正则化偏差,不容易在训练后期出现损失震荡。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-5) for epoch in range(60): model.train() total_loss = 0 for x, y, _ in train_loader: optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() print(f"Epoch {epoch}, Loss: {total_loss / len(train_loader):.4f}")T_max=50表示余弦周期为50个epoch,学习率从1e-3平滑降到1e-5。这个配置在RML2016.10a上一般40个epoch以内就能收敛,如果超过60个epoch损失还在下降,说明数据量或模型容量不匹配,需要检查是否过拟合。
Batch size我习惯设128,显存足够的情况下可以到256。过大的batch size虽然训练更快,但会让BatchNorm的统计量不够稳定,在低信噪比数据上反而容易震荡。过小则收敛慢,且每个batch的信噪比分布差异大,损失曲线很难平滑。
4.2 分信噪比统计准确率:评估模型真实能力的唯一标准
训练完成后,光看总体准确率不够。一个样本集中如果高信噪比样本占多数,总体准确率可能是85%,但低信噪比段可能只有40%。正确做法是固定信噪比维度,逐个计算每个SNR下的准确率,画出一条曲线。
def evaluate_by_snr(model, test_loader, snr_list): model.eval() correct = {snr: 0 for snr in snr_list} total = {snr: 0 for snr in snr_list} with torch.no_grad(): for x, y, snr in test_loader: out = model(x) pred = out.argmax(dim=1) for i in range(len(snr)): s = snr[i].item() correct[s] += (pred[i] == y[i]).item() total[s] += 1 return {s: correct[s] / max(total[s], 1) for s in snr_list}这段代码把每个batch里的样本按snr字段拆开统计,而不是整批计算。观察这条曲线时,有两个关键区间:信噪比高于10dB时应该接近100%,低于-10dB时掉到30%以下都是正常的。如果模型在低信噪比下表现异常高,比如-10dB就有70%的准确率,那大概率是数据划分出了问题——同信噪比样本泄漏到了训练集。
4.3 混淆矩阵:看哪些调制方式被混淆
准确率曲线只能告诉你“差多少”,混淆矩阵能告诉你“错在哪里”。QPSK和8PSK经常互相混,原因是8PSK在低信噪比下相位点会被噪声糊成一团,看起来像QPSK。QAM16和QAM64也容易混,因为它们的星座点分布都是方形网格,幅度差异在高噪声下不明显。
from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt def plot_confusion(model, test_loader, mods, snr_threshold=0): all_pred = [] all_true = [] model.eval() with torch.no_grad(): for x, y, snr in test_loader: mask = snr >= snr_threshold if mask.sum() == 0: continue out = model(x[mask]) all_pred.extend(out.argmax(dim=1).tolist()) all_true.extend(y[mask].tolist()) cm = confusion_matrix(all_true, all_pred) plt.figure(figsize=(8, 6)) plt.imshow(cm, cmap="Blues") plt.colorbar() plt.xticks(range(len(mods)), mods, rotation=45) plt.yticks(range(len(mods)), mods) plt.tight_layout() plt.savefig("confusion_matrix.png")只看-2dB以上样本画矩阵,能帮助你判断模型是否学到了足够的调制方式差异。如果QAM64那一行几乎全部分布在QAM16列上,可以尝试把卷积核数翻倍,或者把输入序列切分成长短不同的多尺度片段分别提取特征后融合,这个改动往往能改善幅度调制类别的区分度。
5. 避坑与排查:模型在训练集上90%,真实信号却全错
5.1 复数数据被截断成实数:I/Q相位信息丢失
现象:训练时准确率正常,但用真实采集的IQ信号测试时,识别结果接近随机猜测。原因:很多采集设备输出的是复数数组,而Numpy或PyTorch的某些接口会自动把复数转成float64,丢弃虚部。I路和Q路之间一旦只剩一路,相位信息完全消失,BPSK和QPSK根本无法区分。解决:读取数据时用.view(np.float32)或np.stack([real, imag])显式拼接两路,并且全程检查数组shape是否保持(2, 128)。
5.2 信噪比标签类型不匹配导致的数据泄漏
现象:测试准确率高达92%,但换到新采集的数据上直接掉到50%。原因:数据划分时用了samples.sample()随机划分,没有按信噪比分组。同一个SNR下面的样本高度相似,测试集中混入了训练样本的近亲。解决:按信噪比或按时间块划分数据,确保同一时刻、同一频段的数据只出现在一个集合里。这块属于数据划分的玄学问题,但绝大多数性能虚高都出在这里。
5.3 归一化把信号幅度压平了
现象:添加了StandardScaler后模型反而比不归一化更差。原因:调制识别中信号幅度本身是有效的判别特征,比如QAM16和QAM64的幅度层级不同,幅度的绝对大小其实不重要,但幅度分布的形状很重要。如果对整条序列做零均值单位方差归一化,会把不同调制方式的幅度差异抹掉。解决:只对I/Q两路做逐样本的功率归一化,即把每段信号的能量缩放到1,保留幅度相对分布。
# 正确的归一化:按样本能量缩放 def normalize_power(x): # x shape: (2, 128) power = np.mean(x ** 2) return x / np.sqrt(power + 1e-10)5.4 训练集和测试集信噪比分布不一致
现象:训练时用的全是-20到0dB的数据,测试时却拿0到20dB的数据来评估,发现准确率忽高忽低。原因:模型在低信噪比下学到的是“噪声中找微弱规律”,到了高信噪比下,这些特征依然有效,但全连接层的判别边界没有针对高信噪比优化。解决:训练数据里均匀包含各信噪比区间,如果实际部署环境是特定信噪比,就针对该区间做微调。更稳妥的做法是训练时采用信噪比增强,随机对样本叠加额外噪声,让模型见过更宽的SNR范围。
5.5 训练不收敛:先查数据读取,再查网络结构
现象:损失值一开始下降很快,到某个点后开始剧烈震荡,甚至出现NaN。原因:学习率过高或数据里有异常样本。先打印一个batch的数据范围,如果出现非数值值(NaN或无穷),多半是数据文件里有坏样本或归一化除零。解决:把batch size调小到32,学习率降到1e-4,同时用torch.isnan(x).any()排查输入数据。如果还不行,检查标签有没有错位——标签索引和数据读取顺序不一致是最隐蔽的坑。
6. 从帧同步到实时输出:ONNX导出与部署的最后一公里
把训练好的PyTorch模型导出成ONNX,可以摆脱Python推理环境,直接跑在ONNX Runtime或TensorRT上。导出的关键点是输入输出张量的shape要固定,且要保留I/Q两路的排列方式。下面是一个可用的导出流程:
python -m onnxruntime_tools.convert_onnx_models --input model.onnx --output_dir ./deploy转换前先用torch.onnx.export生成onnx文件,注意设置opset_version=12以上,否则某些算子在TensorRT上不支持。导出后要验证输出的数值一致性:比较原模型和ONNX Runtime的推理结果,误差应在1e-5以内。
实时部署比离线推理多几个步骤:需要自己实现滑动窗口取帧,从连续I/Q流中切出128点一段;还要考虑采样率漂移,持续的采样频率偏差会导致星座图旋转,通常靠锁相环或定时恢复来修正。
我踩过的教训是:模型在PC上识别正常,换到边缘端的FP32推理后,某些调制方式的判别分数全部偏低。原因是我把输入按float32传给模型,而边缘端推理库默认用的CPU算子对内存对齐更敏感,改按批处理固定大小输入后恢复正常。现在我都会先在部署环境上用100条真实信号过一遍,再调帧同步参数。这个流程比任何训练技巧都重要,希望帮到你。
如果你打算做实时频谱监测,建议先把离线准确率稳定在-6dB以上,再考虑部署。否则低信噪比下的误判会淹没真实告警,这是我在多个项目里反复遇到的教训。调制识别不该追求单个模型的极限,把帧同步、功率归一化和SNR估计串成一条流水线,比单独调一个CNN参数更值得投入。
本文还有配套的精品资源,点击获取