简介:MCLDNN 是发表于自动调制识别领域的时空多通道学习框架官方实现,面向从事无线通信信号处理、深度学习研究的高校师生与算法工程师。该框架创新性地融合一维卷积、二维卷积与 LSTM 三层结构,从单个及组合同相/正交符号中提取时空特征,在 RadioML2016 基准数据集上收敛更快,对 16-QAM、64-QAM 等高阶调制信号的识别精度提升尤为明显。资源包共 7 个文件,包含 4 个 Python 脚本、1 个 Markdown 说明、1 个文本结果文件与 1 张精度曲线图,整体约 1.05MB,其中训练、测试与模型定义脚本分工明确,便于直接复现论文实验。目前已有 1642 人学习下载。读者可据此获得完整的 AMR 模型代码、数据集加载逻辑与预测结果示例,快速搭建实验环境并对照论文验证性能,适合作为调制识别方向的入门复现或二次开发基础。
1. 从一段 IQ 信号说起:这个框架到底在解决什么问题
如果你手上有 RadioML2016 这类数据集,想做一个自动调制识别(AMR)的模型,大概率会经历这样一个过程:先拿 CNN 试一版,发现低信噪比下 16QAM 和 64QAM 老是分不清;再换 LSTM 试试时序建模,参数量上去了,准确率却没涨多少。问题出在哪?单一维度的特征提取很难同时抓住信号的时域相关性和空间通道间的差异。MCLDNN 这个框架就是冲着这个痛点来的——它把多通道(Multi-Channel)和轻量级深度网络(Lightweight DNN)结合起来,在 RadioML2016.10b 上把高信噪比段的准确率拉到了一个比较能看的位置,同时参数量控制得还算克制。这篇笔记面向的是手里已经拿到源码、想跑通并做二次开发的开发者,我会从目录结构一路拆到核心模块的代码实现,再把训练和改造的坑一个个标出来。Python 环境下用 PyTorch 或 Keras 都能复现,下面以 PyTorch 版本为主线来讲。
2. 项目结构与环境搭建:从零把工程跑起来
2.1 目录布局与文件职责
拿到源码包后,先别急着python train.py。花两分钟把目录扫一遍,后面改代码时能省很多时间。典型的 MCLDNN 工程目录大致长这样:
MCLDNN/ ├── configs/ │ └── default.yaml # 超参数、路径、训练轮次配置 ├── data/ │ ├── __init__.py │ └── radioml_loader.py # RadioML2016 数据加载与预处理 ├── models/ │ ├── __init__.py │ ├── mcldnn.py # 核心网络定义 │ └── blocks.py # 卷积块、注意力块等基础组件 ├── utils/ │ ├── train_utils.py # 训练循环、评估指标 │ └── logger.py # 日志与 checkpoint 管理 ├── train.py # 训练入口 ├── eval.py # 评估入口 └── requirements.txtconfigs/default.yaml是整个工程的“控制面板”,学习率、batch size、SNR 范围、模型通道数都在这里改。models/mcldnn.py是核心,网络的前向传播逻辑全在这个文件里。data/radioml_loader.py负责把.pkl格式的 RadioML 数据转成模型能吃的 tensor,里面通常包含归一化和 one-hot 编码。
2.2 环境依赖与安装步骤
依赖不多,但版本要对齐。我一般会先建一个干净的虚拟环境,避免和系统里的老版本 PyTorch 打架:
# 创建虚拟环境 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安装依赖,建议按 requirements.txt 锁版本 pip install torch==1.13.1 torchvision==0.14.1 numpy scipy scikit-learn pyyaml tqdm提示:如果你用的是 CUDA 11.7 以上的显卡,PyTorch 版本可以往上走,但要注意
torch.nn.Conv1d在 2.x 版本里对padding='same'的支持有变化,后面改模型时会提到。
requirements.txt里如果写了torch>=1.10,建议手动锁到具体版本。我踩过一次坑:用 PyTorch 2.0 跑原始代码,Conv1d的padding参数行为变了,导致输出维度对不上,报了一个很隐蔽的 shape mismatch。
2.3 数据准备与配置检查
RadioML2016.10b 的原始文件是RML2016.10b.dat,大约 1.5GB,用 pickle 序列化。加载时注意encoding='latin-1',否则在 Python 3 下会报UnicodeDecodeError:
import pickle import numpy as np def load_radioml(path): with open(path, 'rb') as f: data = pickle.load(f, encoding='latin-1') # data 的 key 是 (mod_type, snr),value 是 (1000, 2, 128) 的数组 X, Y, Z = [], [], [] for (mod, snr), samples in data.items(): for s in samples: X.append(s) Y.append(mod) Z.append(snr) return np.array(X), np.array(Y), np.array(Z)这段代码把字典结构拍平成三个数组:X是信号样本,形状(N, 2, 128),两个通道分别对应 I 和 Q;Y是调制类型标签;Z是信噪比。拍平之后记得做归一化,通常按样本减均值除标准差,不然训练初期 loss 会炸。
配置文件里重点看三个参数:snr_range决定用哪些信噪比的数据训练,num_classes要和你的调制类型数量一致,input_length默认 128,如果你换了数据集要同步改。检查完这些,python train.py --config configs/default.yaml就能跑起来了。
3. 核心模块拆解:多通道卷积和时空特征是怎么融合的
3.1 多通道输入分支的设计思路
MCLDNN 的核心在于“多通道”三个字。它不是简单地把 I/Q 两路堆在一起送进网络,而是分了多个分支,每个分支用不同尺度的卷积核去提取特征,最后再融合。这种设计借鉴了 Inception 的思路,但针对一维信号做了简化。
先看models/mcldnn.py里的主干定义:
import torch import torch.nn as nn class MultiChannelBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() # 三个分支:不同卷积核尺寸捕捉不同尺度的时序模式 self.branch1 = nn.Conv1d(in_ch, out_ch, kernel_size=3, padding=1) self.branch2 = nn.Conv1d(in_ch, out_ch, kernel_size=5, padding=2) self.branch3 = nn.Conv1d(in_ch, out_ch, kernel_size=7, padding=3) self.bn = nn.BatchNorm1d(out_ch * 3) self.relu = nn.ReLU() def forward(self, x): b1 = self.branch1(x) b2 = self.branch2(x) b3 = self.branch3(x) out = torch.cat([b1, b2, b3], dim=1) # 通道维度拼接 return self.relu(self.bn(out))in_ch是输入通道数,第一层通常是 2(I 和 Q),后面会逐层扩大。out_ch是每个分支的输出通道数,拼接后总通道数是out_ch * 3。padding的设置保证输出长度和输入一致,这样多个 block 堆叠时不用反复算尺寸。三个分支的 kernel size 分别是 3、5、7,对应捕捉短时突变、中等周期和较长趋势。
3.2 时空特征融合与分类头
多通道 block 堆叠几层之后,特征图送到一个时序池化层,再进全连接分类头。这里有个细节:池化方式选的是自适应平均池化而不是最大池化,原因是信号里每个时间步都有信息,平均池化保留的更全面。
class MCLDNN(nn.Module): def __init__(self, num_classes=11, base_ch=32): super().__init__() self.block1 = MultiChannelBlock(2, base_ch) self.block2 = MultiChannelBlock(base_ch * 3, base_ch * 2) self.pool = nn.AdaptiveAvgPool1d(1) # 把时间维度压成 1 self.fc = nn.Sequential( nn.Linear(base_ch * 2 * 3, 128), nn.ReLU(), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x = self.block1(x) # (B, 96, 128) x = self.block2(x) # (B, 192, 128) x = self.pool(x) # (B, 192, 1) x = x.squeeze(-1) # (B, 192) return self.fc(x)base_ch控制模型宽度,默认 32 时参数量大约在 50 万左右,适合单卡训练。Dropout(0.5)放在全连接层之前,防止过拟合——RadioML 数据量不算大,不加 dropout 的话训练集准确率能到 99%,验证集卡在 60% 上不去。AdaptiveAvgPool1d(1)把(B, C, L)压成(B, C, 1),再 squeeze 掉最后一维,送进全连接。
3.3 前向传播的完整链路
把上面的模块串起来,一个 batch 的数据从进入到返回 logits 的完整路径是:
- 输入
(B, 2, 128),经过block1的三个分支卷积,拼接后变成(B, 96, 128); - 再经过
block2,输入通道 96,每个分支输出 64,拼接后(B, 192, 128); - 自适应平均池化压掉时间维度,得到
(B, 192); - 全连接层先降到 128 维,再映射到
num_classes。
整个前向过程没有用到循环结构,全是卷积和池化,所以训练速度比 LSTM 快不少。在单张 1080Ti 上,一个 epoch 大概 40 秒左右。如果你想把模型搬到边缘设备上,可以把base_ch降到 16,参数量能压到 15 万以内,准确率掉 2 到 3 个百分点,看你能不能接受。
4. 训练与评估:从 loss 曲线判断模型有没有学到东西
4.1 训练循环的关键参数
train.py里的训练循环不复杂,但有几个参数值得单独拎出来说。优化器默认用 Adam,学习率 1e-3,配合StepLR每 20 个 epoch 衰减 0.5。损失函数是交叉熵,标签做 one-hot 还是直接传类别索引取决于你用的版本,PyTorch 的CrossEntropyLoss接收类别索引就行。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5) criterion = nn.CrossEntropyLoss() for epoch in range(num_epochs): model.train() for x, y in train_loader: x, y = x.cuda(), y.cuda() optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() optimizer.step() scheduler.step() # 每个 epoch 后在验证集上算准确率 acc = evaluate(model, val_loader) print(f"Epoch {epoch}, Loss {loss.item():.4f}, Val Acc {acc:.4f}")step_size=20和gamma=0.5是经验值。如果你发现 loss 在前 10 个 epoch 降得很慢,可以把初始学习率提到 2e-3;如果 loss 震荡得厉害,降到 5e-4。验证集准确率在 15 个 epoch 左右应该能到 55% 以上(全 SNR 范围),如果卡在 40% 以下,大概率是数据归一化没做对。
4.2 评估指标与 SNR 分层分析
自动调制识别不能只看整体准确率,必须按 SNR 分层看。高 SNR(>10dB)下准确率应该接近 90%,低 SNR(<0dB)下能到 40% 就算不错。评估脚本里通常会画一条准确率-SNR 曲线:
def evaluate_by_snr(model, data_loader): results = {} model.eval() with torch.no_grad(): for x, y, snr in data_loader: x, y = x.cuda(), y.cuda() pred = model(x).argmax(dim=1) for s, p, t in zip(snr, pred, y): s = int(s) if s not in results: results[s] = {'correct': 0, 'total': 0} results[s]['total'] += 1 if p == t: results[s]['correct'] += 1 return {s: v['correct'] / v['total'] for s, v in sorted(results.items())}这段代码按 SNR 分组统计准确率。snr从 data loader 里返回,所以你的 Dataset 类要把 SNR 也吐出来。画出来的曲线如果在高 SNR 段平坦、低 SNR 段陡降,说明模型学到了东西;如果整条线都在 10% 附近晃,那就是没收敛,回去检查学习率和数据。
4.3 模型保存与恢复训练
Checkpoint 保存建议同时存model.state_dict()、optimizer.state_dict()和当前 epoch,方便断点续训:
torch.save({ 'epoch': epoch, 'model_state': model.state_dict(), 'optimizer_state': optimizer.state_dict(), 'best_acc': best_acc }, f"checkpoints/mcldnn_epoch{epoch}.pth")恢复时先加载字典,再分别load_state_dict。注意map_location要设对,不然在 CPU 上加载 GPU 保存的模型会报错。我一般会保留验证集准确率最高的那个 checkpoint,而不是最后一个 epoch 的,因为过拟合之后准确率会往下掉。
5. 避坑与排查:训练不收敛、显存爆炸、维度对不上
5.1 Loss 不下降,准确率卡在随机水平
现象:训练 10 个 epoch 后 loss 还在 2.3 附近,验证集准确率 9% 左右(11 分类的随机水平)。
原因:最常见的是数据标签没对齐。RadioML 的 pickle 字典里 key 是(mod_type, snr),如果你用data.keys()的顺序去映射标签,不同 Python 版本的字典顺序可能不一样,导致标签和样本错位。
解决:显式构建mod_to_idx映射表,遍历时按固定的调制类型列表取数据,不要依赖字典顺序。另外检查一下输入数据是否做了归一化,未归一化的 IQ 信号幅值范围在 ±0.01 到 ±1 之间波动,不归一化的话第一层卷积的输出会非常小,梯度传不回去。
5.2 显存溢出(CUDA out of memory)
现象:batch size 设到 256 时显存爆了,报RuntimeError: CUDA out of memory。
原因:base_ch=32时模型参数量不大,但中间特征图的通道数到了 192,时间长度 128,一个样本的激活值占用不小。batch size 256 时中间激活值可能超过 4GB。
解决:把 batch size 降到 64 或 128,或者用梯度累积模拟大 batch。另一个办法是在MultiChannelBlock里加nn.MaxPool1d(2)把时间维度逐步降下来,但会损失一些时序分辨率。我一般优先降 batch size,实在不行再改网络结构。
5.3 卷积输出维度不匹配
现象:RuntimeError: Given groups=1, weight of size [32, 2, 3], expected input[64, 1, 128] to have 2 channels。
原因:输入数据的通道维度丢了。RadioML 加载后形状是(N, 2, 128),但如果你在 Dataset 里用了np.squeeze()或者transpose搞错了维度顺序,送进模型的可能是(N, 128, 2)或(N, 1, 128)。
解决:在 Dataset 的__getitem__里打印一次x.shape,确认是(2, 128)。如果是(128, 2),用x.transpose(1, 0)换过来。这个坑很隐蔽,因为 numpy 的 transpose 不报错,只有到卷积层才炸。
5.4 验证集准确率波动大
现象:相邻两个 epoch 的验证集准确率相差 10 个百分点以上。
原因:验证集太小,或者没有做 shuffle。RadioML 每个调制类型每个 SNR 有 1000 个样本,如果验证集只切了 10%,某些 SNR 段可能只有几十个样本,统计噪声很大。
解决:把验证集比例提到 20%,并且在 DataLoader 里设shuffle=True。另外可以在评估时多跑几次取平均,或者用model.eval()配合torch.no_grad()确保 dropout 和 batchnorm 处于推理模式。
6. 二次开发:换数据集、改网络、调参的实操建议
6.1 替换数据集时要注意什么
如果你想用自己的信号数据替换 RadioML,核心改动在data/radioml_loader.py。新数据的形状必须是(N, 2, L),两个通道对应 I 和 Q,L是采样点数。如果只有单通道,把in_ch改成 1,同时MultiChannelBlock的第一个参数也要改。标签映射要重新建,num_classes在 config 里同步改掉。
采样率不同的话,input_length要跟着变。比如你的数据是 256 个点,那AdaptiveAvgPool1d(1)不受影响,但卷积层的padding要重新算,保证输出长度一致。我一般会写一个 shape 检查函数,在 Dataset 初始化时 assert 一下,省得训练到一半才报错。
6.2 网络结构改造的两个方向
加深分支:把MultiChannelBlock从 3 个分支扩到 4 个,加一个kernel_size=9的分支。适合你的信号里有较长周期的调制模式。代价是参数量增加约 30%,训练时间变长。
引入注意力:在block2后面加一个 SE(Squeeze-and-Excitation)模块,对通道做重加权。代码大概长这样:
class SEBlock(nn.Module): def __init__(self, ch, reduction=16): super().__init__() self.fc = nn.Sequential( nn.Linear(ch, ch // reduction), nn.ReLU(), nn.Linear(ch // reduction, ch), nn.Sigmoid() ) def forward(self, x): w = x.mean(dim=-1) # (B, C) w = self.fc(w).unsqueeze(-1) # (B, C, 1) return x * wSE 模块的reduction控制压缩比例,16 是常用值。加在block2之后、池化之前,对低 SNR 段的准确率通常有 1 到 2 个百分点的提升。但要注意,SE 模块本身有参数量,如果base_ch很小(比如 16),ch // reduction可能变成 0,需要把 reduction 调小。
6.3 超参数搜索的实用策略
不要一上来就网格搜索,先固定一组能跑通的参数(lr=1e-3, batch=64, base_ch=32),然后按优先级调:
| 参数 | 推荐范围 | 影响 |
|---|---|---|
| 学习率 | 5e-4 ~ 2e-3 | 太大不收敛,太小收敛慢 |
| batch size | 32 ~ 128 | 影响梯度噪声和显存 |
| base_ch | 16 ~ 64 | 模型容量,太大过拟合 |
| dropout | 0.3 ~ 0.6 | 正则化强度 |
| kernel sizes | 3/5/7 或 5/7/9 | 感受野大小 |
我一般先调学习率和 dropout,这两个对结果影响最大。base_ch 放到最后调,因为改它要重新平衡显存和训练时间。每次只改一个参数,记录验证集准确率,跑 5 组就能大致摸清方向。
6.4 一个容易忽略的验证技巧
改完模型后,别只看最终准确率。我习惯在验证集上按 SNR 分层画曲线,同时打印混淆矩阵。有一次我把base_ch从 32 降到 16,整体准确率只掉了 1.5 个百分点,但看混淆矩阵发现 16QAM 和 64QAM 的误判率翻了一倍——这种细节只看总体指标是发现不了的。
从那以后我每次改完网络结构,都强制走一遍「分层准确率 + 混淆矩阵 + 参数量统计」三件套,确认没有某个调制类型被牺牲掉。希望帮到你。
本文还有配套的精品资源,点击获取