ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch LSTM调制识别实战:RML2016-10a数据管线与训练避坑指南

PyTorch LSTM调制识别实战:RML2016-10a数据管线与训练避坑指南

简介:这份资源面向通信信号处理与深度学习方向的开发者、学生及研究人员,提供基于RML2016-10a数据集的调制识别完整实现方案,采用PyTorch框架搭建LSTM网络完成ASK、FSK、PSK、QAM等信号的分类任务。压缩包共14个文件,约11KB,包含3个Python脚本用于数据读取、预处理与模型训练,2个CSV记录训练性能指标,另有xml、iml等工程配置文件及pyc缓存文件,结构紧凑便于快速复现。已有492人学习下载,适合希望入门或对比LSTM在通信信号识别中应用效果的读者。通过源码可了解双层LSTM网络架构、幅度相位特征转换、交叉熵损失与Adam优化器的配置细节,并借助性能CSV分析多轮训练后的准确率变化,为模型调优与混淆矩阵评估提供参考。

1. 从一段 IQ 信号说起:这套 LSTM 调制识别代码到底能跑出什么

如果你手里有一份 RML2016-10a 数据集,又刚好想用 PyTorch 搭一个 LSTM 做通信信号调制识别,那这套代码包大概率能帮你省掉两三天翻文档、对维度、调 batch 的时间。RML2016-10a 本身是通信领域里被反复引用的公开数据集,里面是 11 种调制方式(8PSK、AM-DSB、AM-SSB、BPSK、CPFSK、GFSK、PAM4、QAM16、QAM64、QPSK、WBFM)的 IQ 采样,每种在多个信噪比下各有 1000 条样本,单条是 2×128 的复数序列。很多人第一次拿到它,卡的不是模型,而是「这 2×128 到底怎么喂进 LSTM」「信噪比要不要当特征」「标签怎么从字符串转成 tensor」。这套基于 PyTorch 的 LSTM 实现,把数据加载、模型定义、训练循环、评估可视化都串好了,适合刚接触调制识别、想先跑通 baseline 再改结构的人,也适合已经用过 CNN 版本、想对比 LSTM 时序建模效果的从业者。

2. 数据管线拆解:把 2×128 的 IQ 序列喂进 LSTM 之前要做的四件事

2.1 先看清 RML2016-10a 的真实结构

RML2016-10a 通常以 pickle 格式分发,加载后是一个字典,键是(mod_type, snr)这样的元组,值是一个形状为(1000, 2, 128)的 numpy 数组。注意这里的 2 是 I/Q 两路,128 是时间采样点。很多人第一次读会误以为 2 是 batch,结果后面维度全乱。我一般会先写一段探查代码,把键的数量、每个键下的形状、数据类型打印出来,确认无误再往下走。

import pickle import numpy as np # 常见做法:用 latin-1 编码打开,避免 Python3 读 Python2 生成的 pickle 报错 with open('RML2016.10a_dict.pkl', 'rb') as f: data = pickle.load(f, encoding='latin-1') print('键数量:', len(data)) first_key = list(data.keys())[0] print('示例键:', first_key) print('对应形状:', data[first_key].shape) print('数据类型:', data[first_key].dtype)

这段代码里encoding='latin-1'是关键参数,RML2016-10a 原始文件多由 Python2 生成,直接用默认编码会抛UnicodeDecodeError。打印键数量能帮你确认是否 220 个键(11 调制 × 20 信噪比),如果数量对不上,说明文件可能被截断或版本不同。

2.2 把字典拍平成监督学习需要的 X 和 Y

原始字典不能直接送进 DataLoader,需要拍平成两个数组:X 形状(样本数, 2, 128),Y 是调制类型的整数编码。同时把信噪比单独存一份,后面做分 SNR 评估要用。这里有个容易翻车的地方:标签编码必须固定顺序,否则训练完预测出来的索引对不上调制名。

import numpy as np mods = ['8PSK', 'AM-DSB', 'AM-SSB', 'BPSK', 'CPFSK', 'GFSK', 'PAM4', 'QAM16', 'QAM64', 'QPSK', 'WBFM'] mod_to_idx = {m: i for i, m in enumerate(mods)} X, Y, SNR = [], [], [] for (mod, snr), arr in data.items(): X.append(arr) Y.append(np.full(arr.shape[0], mod_to_idx[mod])) SNR.append(np.full(arr.shape[0], snr)) X = np.concatenate(X, axis=0).astype(np.float32) Y = np.concatenate(Y, axis=0).astype(np.int64) SNR = np.concatenate(SNR, axis=0).astype(np.int32) print(X.shape, Y.shape, SNR.shape) # 期望 (220000, 2, 128)

mods列表的顺序一旦定下就不要改,训练、评估、混淆矩阵都依赖它。astype(np.float32)是为了后续转 tensor 时省一次拷贝,np.int64是 PyTorch 交叉熵对标签的默认要求。如果显存吃紧,可以在这一步就做下采样,比如每个(mod, snr)只取 500 条。

2.3 维度置换:LSTM 要的是 (batch, seq, feature)

PyTorch 的 LSTM 默认输入形状是(batch, seq_len, input_size),而 RML2016-10a 给的是(batch, 2, 128)。有两种处理思路:一种是把 2 当作 feature、128 当作序列长度,即 permute 成(batch, 128, 2);另一种是把 128 当 feature、2 当序列长度。前者更符合「IQ 两路随时间采样」的物理含义,也是多数 LSTM 方案的选择。

import torch from torch.utils.data import TensorDataset, DataLoader # 置换为 (N, 128, 2),即时间步在前,I/Q 作为每步特征 X_perm = np.transpose(X, (0, 2, 1)) X_tensor = torch.from_numpy(X_perm) Y_tensor = torch.from_numpy(Y) dataset = TensorDataset(X_tensor, Y_tensor) loader = DataLoader(dataset, batch_size=256, shuffle=True, num_workers=0)

np.transpose(X, (0, 2, 1))把原来的第 2 维(128)换到中间,第 1 维(2)换到最后。batch_size=256是我在单卡 8G 显存下比较稳的值,再大容易 OOM,再小训练抖动明显。num_workers在 Windows 上建议设 0,否则容易卡在共享内存。

2.4 训练集/验证集/测试集切分要按 SNR 分层

如果直接随机切分,可能出现某个 SNR 的样本全进了训练集,验证集在该 SNR 上没数据,评估曲线会断。常见做法是按(mod, snr)分组后再切,保证每个组合在三个集合里都有代表。

from sklearn.model_selection import train_test_split idx = np.arange(len(Y)) train_idx, test_idx = train_test_split( idx, test_size=0.2, random_state=42, stratify=Y * 100 + SNR) val_idx, test_idx = train_test_split( test_idx, test_size=0.5, random_state=42, stratify=Y[test_idx] * 100 + SNR[test_idx]) train_ds = TensorDataset(X_tensor[train_idx], Y_tensor[train_idx]) val_ds = TensorDataset(X_tensor[val_idx], Y_tensor[val_idx]) test_ds = TensorDataset(X_tensor[test_idx], Y_tensor[test_idx])

stratify=Y * 100 + SNR是把调制类别和信噪比组合成一个分层标签,这样切出来的每个子集在各 SNR 上分布一致。random_state=42固定后结果可复现,方便你和我对同一份切分做对比。

3. LSTM 模型定义与训练循环:层数、隐藏单元、学习率怎么定

3.1 模型结构:两层 LSTM 加全连接分类头

这套代码的模型主体是nn.LSTM堆两层,取最后时间步的隐藏状态送全连接。输入特征维度是 2(I/Q),隐藏单元常用 128,层数 2,dropout 0.3 左右。为什么不用单向单层?单层 LSTM 在低 SNR 下对 QAM16 和 QAM64 的区分度明显不够,两层能多一层非线性变换,但层数再往上加,参数量涨得快,收益却很小。

import torch.nn as nn class ModLSTM(nn.Module): def __init__(self, input_size=2, hidden_size=128, num_layers=2, num_classes=11, dropout=0.3): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, bidirectional=False) self.classifier = nn.Sequential( nn.Linear(hidden_size, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, num_classes)) def forward(self, x): # x: (batch, 128, 2) out, (h_n, c_n) = self.lstm(x) last = out[:, -1, :] # 取最后时间步 return self.classifier(last)

batch_first=True让输入保持(batch, seq, feature),省去手动转置。dropout只在num_layers > 1时生效,这是 PyTorch 的约定。取out[:, -1, :]而不是h_n[-1],是因为out已经过输出层,语义更直接;两者在单向 LSTM 下等价,但用out更直观。

3.2 训练循环:交叉熵、Adam、学习率衰减

损失用CrossEntropyLoss,优化器 Adam,初始学习率 1e-3,配合ReduceLROnPlateau在验证损失不降时减半。训练轮数一般 50 到 80 轮足够,再往后验证损失基本平了。

import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = ModLSTM().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) def run_epoch(loader, train=True): model.train() if train else model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.set_grad_enabled(train): for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) logits = model(xb) loss = criterion(logits, yb) if train: optimizer.zero_grad() loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * xb.size(0) correct += (logits.argmax(1) == yb).sum().item() total += xb.size(0) return total_loss / total, correct / total

clip_grad_norm_(..., max_norm=5.0)是 LSTM 训练里我必加的一步,不加的话偶尔会出现 loss 突然变 NaN,血泪经验。ReduceLROnPlateau的patience=5表示验证损失连续 5 轮不降才减半,太短会导致学习率过早衰减。

3.3 分 SNR 评估:整体准确率会骗人

RML2016-10a 的评估必须按 SNR 拆开看,因为 -20dB 和 18dB 的准确率能差出 60 个百分点。整体准确率 60% 听起来还行,但如果高 SNR 已经 90%、低 SNR 只有 30%,说明模型在噪声鲁棒性上还有空间。

def eval_by_snr(model, X, Y, SNR, device): model.eval() results = {} with torch.no_grad(): for snr in sorted(set(SNR.tolist())): mask = SNR == snr xb = X[mask].to(device) yb = Y[mask].to(device) pred = model(xb).argmax(1) acc = (pred == yb).float().mean().item() results[snr] = acc return results

mask = SNR == snr生成布尔索引,逐 SNR 取子集评估。返回的字典可以直接喂给 matplotlib 画准确率-SNR 曲线,横轴 SNR、纵轴准确率,这是这类项目最常被要求的一张图。

4. 避坑与排查:这套代码最容易翻车的五个地方

4.1 现象:loss 一直是 NaN,训练几轮就崩

原因通常是学习率过大或梯度爆炸,LSTM 在长序列上对这两点特别敏感。解决:先把学习率降到 1e-4 试一轮,确认 loss 能正常下降后再逐步调回;同时确认clip_grad_norm_已经加上,max_norm从 5.0 开始试,不要超过 10。

4.2 现象:验证集准确率远高于测试集,或者反过来

原因多半是切分时没按 SNR 分层,某个 SNR 的样本集中到了某一个集合。解决:回到 2.4 节,用stratify=Y * 100 + SNR重新切,切完打印三个集合的 SNR 分布对比一下。

4.3 现象:模型把所有样本都预测成 WBFM 或 QPSK

这是类别不平衡的典型表现。RML2016-10a 虽然每个(mod, snr)样本数相同,但低 SNR 下部分调制类几乎不可分,模型会倾向于预测先验概率高的类。解决:在CrossEntropyLoss里加weight参数,按类别频率的倒数加权;或者先只用 SNR > 0 的样本训练,确认模型能区分后再加低 SNR 数据。

4.4 现象:DataLoader 在 Windows 上卡死不动

num_workers > 0时 Windows 的共享内存机制容易出问题。解决:把num_workers设为 0,或者把主训练逻辑包进if __name__ == '__main__':。我一般直接在 Windows 上设 0,省事。

4.5 现象:换用双向 LSTM 后准确率没涨反而降了

双向 LSTM 会同时看过去和未来,但调制识别是因果任务,未来时间步的信息在真实接收场景里拿不到。解决:如果只是做离线分类可以试双向,但如果要贴近实际部署,保持单向。另外双向会让参数量翻倍,小数据集上更容易过拟合。

5. 进阶技巧:把 LSTM 和 CNN 拼起来,以及一个验证习惯

5.1 CNN 前置提取局部特征,再送 LSTM 做时序建模

纯 LSTM 在 RML2016-10a 上的高 SNR 准确率通常能到 85% 到 90%,但低 SNR 段提升有限。一个常见改进是在 LSTM 前加一层一维卷积,先提取 IQ 序列的局部相关特征,再交给 LSTM 建模长程依赖。结构上就是把nn.Conv1d接在输入后面,卷积核大小 3 或 5,输出通道 64,然后接 LSTM。

class CNNLSTM(nn.Module): def __init__(self, hidden_size=128, num_classes=11): super().__init__() self.conv = nn.Sequential( nn.Conv1d(in_channels=2, out_channels=64, kernel_size=3, padding=1), nn.BatchNorm1d(64), nn.ReLU()) self.lstm = nn.LSTM(input_size=64, hidden_size=hidden_size, num_layers=2, batch_first=True, dropout=0.3) self.fc = nn.Linear(hidden_size, num_classes) def forward(self, x): # x: (batch, 128, 2) -> conv1d 需要 (batch, 2, 128) x = x.transpose(1, 2) x = self.conv(x) # (batch, 64, 128) x = x.transpose(1, 2) # (batch, 128, 64) out, _ = self.lstm(x) return self.fc(out[:, -1, :])

Conv1d的输入要求通道在前,所以 forward 里做了两次 transpose。BatchNorm1d放在卷积后、ReLU 前是常规顺序。这个结构参数量比纯 LSTM 大,训练时可以把学习率降到 5e-4。

5.2 一个我每次都会走的验证流程

改完模型结构后,我不会直接跑完整训练,而是先做三件事:第一,用 100 条样本过一遍 forward,确认输出形状是(100, 11);第二,用随机标签跑 5 轮,确认 loss 能从 2.4 左右降到 2.0 以下,说明反向传播通了;第三,固定随机种子跑一次完整训练,把准确率-SNR 曲线存成图,作为后续改动的基线。这三步走完,再动超参或换结构,心里有底。

检查项期望结果不通过时先看哪
forward 输出形状(batch, 11)分类头最后一层维度
5 轮随机标签 loss降到 2.0 以下学习率、梯度裁剪
基线准确率-SNR 曲线高 SNR > 80%数据切分、标签编码

从那以后我每次换模型结构,都强制走一遍这个三步验证,省得跑了几小时才发现维度对不上。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进