简介:这是一份面向通信工程与信号处理学习者的数字调制识别MATLAB实现资源,聚焦利用神经网络从时域/频域特征中识别ASK、FSK、PSK等调制类型,适合课程设计、课题研究或通信系统解码等场景,尤其适合希望快速上手神经网络信号识别的初学者。压缩包内仅含1个.m脚本文件,整体约3KB,代码精简,便于阅读与修改;该资源已有343人学习/下载。通过运行该脚本,读者可以体验从信号预处理、特征提取,到神经网络模型构建、训练优化与测试评估的完整流程,直观理解卷积神经网络和循环神经网络在调制识别任务中的特征映射与分类过程。脚本基于MATLAB编写,可作为数字信号自动识别方向的轻量级参考,方便学习者结合自身数据调整网络结构与参数,复现和对比不同调制方式的识别效果,为后续深入研究提供基础代码与思路。
1. 数字调制识别落到神经网络,并不只是为了省事
做调制识别的人多在频谱监测或认知无线电里遇到过同样的问题:同样一段QPSK,信噪比从15dB掉到3dB,传统特征向量就开始分不清。我第一次用高阶累积量加SVM做盲识别时,白噪声下还说得过去,遇到一点载波频偏,识别率立刻往下掉。换成神经网络直接吃IQ采样,模型把频偏补偿、符号同步这些本该放在前面的环节,隐式塞进了卷积层权重里;这不是省事,而是把信号识别从人工特征工程中解放出来。
这里要讲的“数字调制识别”,是给定一段基带IQ序列,判断它是BPSK、QPSK、8PSK、16QAM还是64QAM。“神经信号”不是脑电,而是数据在网络中流动时各层产生的中间激活值;把它们当信号来观察,就能看出模型在用哪些线索。新手可以按流程复现,熟手可以直接跳到参数表和排错部分。
2. 从IQ采样到调制识别:把信号问题摆成神经网络能吃的形状
数字调制识别的输入通常不是解调后的01比特,而是一段离散复基带信号 (x(n)=I(n)+jQ(n))。无论你打算用CNN、RNN还是Transformer,都要先把这段复信号变成网络能吃的张量。这个环节做不好,后面换什么网络都白搭。
2.1 数字调制识别的问题定义,以及“神经信号”指的是什么
把问题收窄一点:给定一段长度为 (N) 的基带IQ采样,采样点之间按符号速率排列,模型输出它属于哪个调制类别。这里只做粗识别,不做码元级解调;也就是说,模型不关心每个符号具体是0还是1,只关心“这类信号用的是哪种幅度/相位/频率组合”。调制样式差异体现在IQ轨迹的形状上:BPSK只有两个相位簇,QPSK有四个,16QAM则形成方阵。神经网络要学的,就是不同调制样式在IQ平面上的几何分布和时序相关性。
标题里“神经信号”这个词,在中文技术社区里经常和神经网络识别并列出现。它并不是指生物电信号,而是指神经网络内部多个卷积核输出的激活值张量。比如第一层卷积输出形状是[batch, channel, length],每个channel可以看作一路“神经信号”,它们和输入IQ一样有时序结构,可以被可视化、被统计分析。我会在第三章专门讲怎么把这层信号拉出来看,因为很多CSDN上讨论调制识别模型的帖子,最终都会回到“模型到底学到了什么”这个问题上,而“神经信号”就是回答这个问题的最直接抓手。
2.2 输入表示怎么选:IQ序列、星座图还是频谱图
常见做法有三种,各有适用场景。下表是我的选型习惯:
| 输入表示 | 数据形态 | 保留的信息 | 主要缺点 | 推荐网络 |
|---|---|---|---|---|
| IQ原始序列 | [N, 2]或[2, N] | 相位连续性、时序关系 | 对频偏和时间同步敏感 | 1D CNN / LSTM |
| 星座图/密度图 | [H, W]灰度图 | 幅度-相位分布 | 丢失时间先后顺序 | 2D CNN |
| 语谱图/功率谱 | [F, T]二维图 | 频域能量分布 | 相位信息被抹掉 | 2D CNN |
我一般首选IQ原始序列,原因是调制识别的关键特征大量集中在相位跳变上,而IQ序列天然保存了相位连续变化的过程。星座图也没有错,尤其当符号率未知、样本里包含多个符号周期的时,星座图的统计分布反而比原始波形更稳定。
下面这一段用Python做两件事:先把IQ归一化,再把IQ投影成星座密度图。
import numpy as np def normalize_iq(iq): # iq: (N, 2),第0列是I路,第1列是Q路 mu = iq.mean(axis=0, keepdims=True) sd = iq.std(axis=0, keepdims=True) + 1e-6 return (iq - mu) / sd def iq_to_constellation(iq, size=256, scope=3.0): # 把IQ投影到二维直方图上,得到密度图 iq_clip = np.clip(iq, -scope, scope) hist, _, _ = np.histogram2d( iq_clip[:, 0], iq_clip[:, 1], bins=size, range=[[-scope, scope], [-scope, scope]] ) # 用log1p压低高密度区域的数值差异 img = np.log1p(hist) img = img / img.max() * 255.0 return img.astype(np.uint8)这段代码里,normalize_iq把I/Q分量各自减去均值再除以标准差,目的是消除接收机增益差异和直流偏置。scope要按信号幅度经验值设置,通常取3倍标准差,太小会把星座簇截断,太大图上的簇会缩在一起。histogram2d把大量IQ点压缩成固定尺寸的密度图,比直接画散点图更适合作为卷积输入,也避免了一个样本里点数不足导致图上稀疏的问题。
2.3 数据集划分:按信噪比分桶,不要随机切窗口
这里有一个隐蔽的坑:如果一段连续IQ信号被切成多个窗口,再把这些窗口随机分到train和validation,那么训练集和验证集里会存在大量相邻窗口,它们之间的相关性很强,验证准确率会虚高。正确做法是先按“信号记录/文件”分组,再按组划分。
from sklearn.model_selection import train_test_split # file_ids 表示每个样本来自哪个原始记录文件 file_ids = np.array([sample["file_id"] for sample in samples]) train_files, test_files = train_test_split( np.unique(file_ids), test_size=0.2, random_state=0 ) train_mask = np.isin(file_ids, train_files) test_mask = np.isin(file_ids, test_files)train_test_split作用在np.unique(file_ids)上,保证同一个记录文件的全部窗口只进入训练集或只进入测试集。如果数据本身带有信噪比标签,我还会按信噪比分层切分,避免高信噪比样本几乎全在测试集里,导致指标虚低或虚高。
3. 用CNN识别调制方式的网络设计和损失函数
神经网络识别调制类型,核心是把时序信号压缩成一个类别分布。这里给出一个轻量级但能跑通的1D CNN,以及对应的损失函数和中间特征观察方法。这三种代码组合起来,就是一个可以直接套用的调制识别最小系统。
3.1 1D CNN怎么接IQ输入:给出可复现的PyTorch网络
输入形状用[B, 2, L],其中第二个维度是I/Q两个通道,L是采样点数。网络先用一个步长为1的7点卷积核提取局部波形模式,再叠加5点卷积核做二次特征提取;池化层把长度逐步压缩,最后用全局平均池化得到固定维度的向量。
import torch.nn as nn class IQCNN1D(nn.Module): def __init__(self, num_classes=8): super().__init__() self.stem = nn.Sequential( nn.Conv1d(2, 32, kernel_size=7, padding=3), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2) ) self.body = nn.Sequential( nn.Conv1d(32, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2) ) self.head = nn.Sequential( nn.AdaptiveAvgPool1d(1), nn.Flatten(), nn.Linear(64, num_classes) ) def forward(self, x): return self.head(self.body(self.stem(x)))第一层kernel_size取7,而不是常见的3或5,是因为数字调制信号在一个符号周期内通常有4到16个采样点,7个采样点能在不跨越多个符号的前提下覆盖更多波形形态。padding使用padding=3保持时序长度不变,便于后续池化。AdaptiveAvgPool1d(1)把任意长度输入压缩成单一时间点,使网络不要求固定输入长度,但实际训练时还是建议统一长度,否则batch内维度会冲突。
3.2 网络里的“神经信号”:用hook把中间层激活拉出来
调模型时,光看准确率不够,还要看中间层有没有学出和星座结构相关的特征。PyTorch的forward hook可以不打乱网络结构就拿到某层输出,这就是我调试“神经信号”的方式。
feats = {} def make_hook(name): def hook_fn(module, input, output): feats[name] = output.detach() return hook_fn model.stem[0].register_forward_hook(make_hook("stem_conv")) # 推理 with torch.no_grad(): logits = model(iq_batch) print(feats["stem_conv"].shape)输出形状通常是[B, 32, L],可以把它看成32路并行的“神经信号”。如果某一channel的输出几乎全是0,说明对应卷积核可能死掉了;如果所有channel的幅度都集中在同一个尺度上,说明BatchNorm之前可能存在饱和。经验上,第一层卷积输出里应当能看到一部分channel对幅度和相位跳变特别敏感,它们的激活峰值往往和符号切换点对齐。
3.3 损失函数:交叉熵配标签平滑,不推荐直接MSE
调制识别是离散类别分类,最直接的是交叉熵。但当类别数多、类间相似度高时,模型容易对训练集过于自信,logits出现极端值。这种情况下我会开标签平滑。
import torch def label_smooth_loss(logits, labels, smoothing=0.1): n = logits.size(1) with torch.no_grad(): target = torch.zeros_like(logits).scatter_( 1, labels.unsqueeze(1), 1.0 ) target = target * (1.0 - smoothing) + smoothing / n log_probs = torch.log_softmax(logits, dim=1) return -(target * log_probs).sum(dim=1).mean()smoothing=0.1的含义是:正确类别目标概率不再是硬编码的1,而是0.9 + 0.1/n,其余类别分到0.1/n。这么做等于告诉模型“不要过度相信训练标签里完全没有噪声”,在低信噪比样本多的数据集上能减少过拟合。需要特别提醒:标签平滑不是越大越好,我一般从0.05试起,超过0.2会让模型欠拟合。
4. 训练配置、参数表和排错清单
有了网络和损失函数,下一步是把训练跑稳。调制识别任务的训练和图像分类没有本质区别,但因为信号数据带SNR特性、I/Q通道顺序和归一化问题很容易出错,所以排错清单比调参本身更重要。
4.1 训练参数速查表
下面这组参数可以当成起点,先用小模型跑通,再根据设备显存和数据量调整。
| 参数项 | 建议值 | 说明 |
|---|---|---|
| 输入长度 | 128~1024点 | 每个样本至少包含16个符号周期 |
| 优化器 | AdamW | 比SGD在低信噪比任务上收敛更稳 |
| 初始学习率 | 1e-3 | 输入归一化做好的前提下适用 |
| 学习率调度 | Cosine Annealing | 避免训练后期loss震荡 |
| batch size | 64~256 | 取决于单样本长度和显存 |
| 训练轮数 | 30~60 | 数据量小于2万时建议用小轮数 |
| weight decay | 1e-4 | 抑制过拟合,和标签平滑叠加使用 |
| 信噪比范围 | -4dB~18dB | 建议训练时覆盖实际部署的全部SNR |
这里最容易被忽略的是输入长度。长度太短,比如只有32个采样点,可能连一个完整符号都包不住,模型只能看到噪声碎片;长度太长,比如超过4096点,推理时延和显存压力都会变大。我会在数据集中统计符号周期对应的采样点数,再让输入长度覆盖2到8个符号周期。
4.2 用learning curve判断“假收敛”和过拟合
训练调制识别模型时,验证准确率会随SNR呈现阶梯状跳变:高SNR类别先被学会,低SNR类别最后才抬升。这本身正常,但如果验证loss在训练后期不降反升,而准确率还在微增,就要看是不是过拟合到噪声样本上了。我的判断方法是:同时保存每轮训练集loss和验证集loss,如果两者间距超过0.3,就把weight_decay加到5e-4,或把标签平滑从0.1调整到0.15。
另外要留意“假收敛”:模型把所有样本都预测成数量最多的那一类,准确率停在类别占比最高的数值附近。这种情况通常发生在高SNR和低SNR样本数量严重不平衡时。处理办法不是单纯加class_weight,先把数据按SNR分桶,在每个桶内做类别均衡采样,效果更稳定。
4.3 滑动窗推理与多数投票:让识别结果更稳
训练阶段用的是固定长度窗口,但实际部署时接收到的IQ流可能很长,输入长度不能直接适配网络。常见做法是从长信号里滑动截取多个窗口,分别推理再取多数投票。这样不仅能解决长度问题,还能通过窗口间的冗余降低瞬时突发干扰的影响。
def window_vote(model, iq, window_len=256, overlap=0.5): model.eval() stride = int(window_len * (1 - overlap)) preds = [] with torch.no_grad(): for start in range(0, len(iq) - window_len + 1, stride): window = iq[start:start + window_len] logits = model(window.unsqueeze(0)) preds.append(logits.argmax(dim=1).item()) counts = np.bincount(preds) score = counts.max() / len(preds) return int(counts.argmax()), float(score)window_len要和训练时一致,否则模型会看到分布完全不同的输入。overlap取0.5时,相邻窗口重叠一半,投票结果更平滑,但推理次数会翻倍。如果对时延敏感,可以改成overlap=0.8,用更多窗口换稳定性,也可以结合软输出做置信度加权,而不是单纯硬投票。
4.4 排错清单:这五个问题我几乎每次都遇到
按出现频率排序:
- I/Q通道接反,星座图左右镜像,训练结果还在涨但验证集崩掉。检查方法:对已知BPSK信号画星座图,看两个簇是否落在同一直线上。
- 幅度归一化在全数据集上计算
mean/std,导致验证集信息提前进入训练流程。修复:只统计训练集的归一化参数并保存。 - 多个信噪比样本混在一起,模型只学会区分SNR高低,而不是调制类型。修复:每个batch内部强制按SNR类别采样。
- 窗长和符号率不匹配,一个窗口里只有半个符号周期。修复:统计符号速率后调整窗口长度。
- 标签平滑和Dropout同时开太大,导致模型欠拟合。排查时先关掉标签平滑,对比loss曲线。
提示:调试时不要只看验证准确率,把每个SNR档位单独算一遍准确率,才能暴露模型在小样本模上的真实表现。
5. 用一个新技巧收尾:温度缩放修正置信度,让调制识别结果可以直接写进报告
很多部署场景需要的不只是类别标签,还需要一个“可信度”。比如认知无线电的后续决策模块会拿置信度去判断是否切换频点。新手做法是直接取softmax概率最大值当置信度,这个值在模型校准良好时可以用,但CNN训练出的softmax置信度常常偏高,尤其在实际部署时遇到训练里没见过的带外干扰。这时我会先做一次温度缩放。
温度缩放不改变模型参数,只在softmax之前把logits除以一个温度系数 (T):
def temperature_calibrate(logits, T): return torch.softmax(logits / T, dim=1)如果 (T>1),softmax分布变得更平滑,最大值会下降;如果 (T<1),分布会更尖锐。调制识别里,我见过很多模型需要 (T) 落在1.2到2.0之间,这说明模型对低信噪比样本过度自信。用验证集来拟合 (T),而不是用测试集。下面是一段找最佳温度参数的代码:
import torch from torch.optim import Adam def fit_temperature(logits, labels, init_t=1.0): T = torch.tensor(init_t, requires_grad=True) opt = Adam([T], lr=1e-2) for _ in range(500): opt.zero_grad() prob = torch.softmax(logits / T, dim=1) loss = torch.nn.functional.nll_loss(torch.log(prob), labels) loss.backward() opt.step() return T.item()这里保存所有验证集样本的logits,然后只训练一个标量参数,速度非常快。拟合完成后,把温度写进推理管线的配置文件里,之后每个窗口得到的softmax值就可以当作校准后的置信度。最终报告指标时,我还会额外计算每个SNR档位的混淆矩阵,重点看相邻调制阶数之间是否被系统性地混淆;如果16QAM和64QAM之间混淆率超过两成,说明当前特征不足以区分高密度星座图,这比单纯堆参数更值得优先处理。
本文还有配套的精品资源,点击获取