传统 Transformer 的特化方法与实践(零基础极致细化版)
如何把标准 Transformer 改造成领域专用架构:以 AMC 信号识别项目为例,讲清七条特化轴(Tokenization、注意力作用域、位置编码、块结构、域先验前端、任务头、训练范式)并实战复刻。
以本仓库两个信号识别(AMC,自动调制分类)项目为例,从零讲清如何把标准 Transformer 改造成领域专用架构:
- Meta-Transformer(meta-transformer-amc-main,IEEE Access 2024):ViT 编码器 + 原型网络元学习,实现”新调制类型只需极少量样本即可识别”;
- MCDformer(MCDformer-main):频域去噪前端 + 转置 token 用法(通道当 token)的 Transformer。
本文对每个特化点给出:标准做法回顾 → 项目代码逐行解读 → 形状推演 → 设计动机 → 手把手实现 → 可复用原则。 基础概念(注意力、多头、位置编码、Pre-LN 等)请先阅读姊妹文档 《Transformer从零实现完整教学.md》。
目录
- 第 0 章 导读:什么是”特化”
- 第 1 章 背景:AMC 任务与 IQ 信号(从零讲起)
- 第 2 章 特化轴 1:Tokenization——数据如何变成 token
- 第 3 章 特化轴 2:注意力作用域——谁和谁交互
- 第 4 章 特化轴 3:位置编码与长度伸缩
- 第 5 章 特化轴 4:块结构与归一化
- 第 6 章 特化轴 5:域先验前端模块(频域去噪)
- 第 7 章 特化轴 6:读出方式与任务头(含摘掉分类头)
- 第 8 章 特化轴 7:训练范式(episode 元学习)
- 第 9 章 端到端走读:两个项目的完整前向流程
- 第 10 章 实战复刻:两个精简完整版
- 第 11 章 方法论总结:checklist、反模式与决策树
- 第 12 章 结语
第 0 章 导读:什么是”特化”
0.1 标准 Transformer 的五个默认假设
2017 年的 Transformer 是为机器翻译设计的,它悄悄带着五个默认假设:
| # | 默认假设 | 出处 |
|---|---|---|
| 1 | token 是离散词元(查表嵌入) | 嵌入层 |
| 2 | 序列是一维的,token 沿时间排开 | 位置编码 |
| 3 | 输入长度固定(句子 padding 到统一长度) | 训练协议 |
| 4 | 任务是封闭集分类/生成(词表固定) | 输出层 |
| 5 | 输入是干净的符号,无需物理预处理 | 端到端 |
“特化”就是把上述假设逐条打破、按新任务重写。你的新任务(信号、语音、图、点云、蛋白质……)每一项都要回答七个问题——这就是本文的”七轴特化地图”。
0.2 七轴特化地图(全文总纲)
| 轴 | 问题 | 标准做法 | Meta-Transformer | MCDformer |
|---|---|---|---|---|
| 1 Tokenization | 数据如何变成 token? | 词嵌入 / 16×16 图像 patch | 2×16 IQ patch(覆盖 I/Q 两行) | Conv stem 压缩后通道即 token |
| 2 注意力作用域 | 谁和谁交互? | 时间步之间 | 时间 patch 之间 | 特征通道之间(转置用法) |
| 3 位置编码 | 如何注入位置? | 正弦 / 可学习 | 可学习 + 主-子编码器 | 可学习 |
| 4 块结构 | 归一化/容量如何调? | Pre-LN,mlp_ratio=4 | 极瘦 ViT(d=36,9 头) | mlp_ratio=1,dropout=0.5 |
| 5 域先验前端 | 是否注入领域知识? | 无 | 无 | FFT 频域去噪模块 FDDM |
| 6 读出与任务头 | 如何从序列得到输出? | 分类头 | cls token → 嵌入(度量学习) | 取末位 → MLP |
| 7 训练范式 | 怎么训练? | 监督学习 | episode 元学习(原型网络) | 监督学习 |
0.3 两个项目速览
| Meta-Transformer | MCDformer | |
|---|---|---|
| 全称 | Meta-learning framework for scalable AMC | (仓库名,含多模型对比) |
| 数据集 | RadioML2018.01A:24 类、IQ (2, 1024)、HDF5 | RML2016.10a:11 类、IQ (2, 128)、pickle |
| 核心结构 | ViT 编码器 + 原型网络(ProtoNet) | FDDM + Conv stem + 两级转置 Transformer Block |
| 解决的核心挑战 | 新调制类型小样本识别(开放集) | 低信噪比下的识别(鲁棒性) |
| 关键代码 | vit.py、proto.py、dataset.py | MCDformer.py、AMC_Net.py、CTDNN.py |
阅读建议:先读第 1 章建立任务直觉,然后沿七个轴各取所需;第 9 章把全部拼起来,第 10 章提供两个可直接运行的迷你复刻。
第 1 章 背景:AMC 任务与 IQ 信号(从零讲起)
1.1 什么是调制:不同的”波形语言”
无线通信把数字比特(0/1)编码进载波(高频正弦波)的三个属性之一:
- 调幅(AM 系):改变振幅表达信息(如 4ASK:4 种振幅 = 2 bit);
- 调相(PSK 系):改变相位表达信息(如 QPSK:4 种相位 = 2 bit);
- 调幅调相(QAM 系):同时改变振幅和相位(如 64QAM:64 种组合 = 6 bit)。
AMC 任务:接收机截获一段波形,判断发送端用的是哪种调制——这是软件无线电、频谱监测、电子对抗的基础能力。
1.2 IQ 信号:一个复数,两个视角
接收机把波形分解为同相分量 I 与正交分量 Q 两行,合起来是一个复数序列:
星座图直觉(理解后续一切的关键):把每个采样点 (I, Q) 画在平面上,不同调制方式呈现不同图案——BPSK 是左右两个点、QPSK 是正方形四角、64QAM 是 8×8 网格。识别调制方式 = 识别星座图图案。噪声让点变成”云团”,信噪比(SNR)越低云团越大越难分。
信噪比的定义(全文反复出现,先记住):
即”信号功率比噪声功率大多少倍”取对数。SNR = 0dB 时两者一样大;-10dB 时噪声功率是信号的 10 倍——识别极其困难,这正是 MCDformer 频域去噪模块要解决的场景。
对 Transformer 而言:IQ 信号是 (2, L) 的二维数值阵列——两行(I/Q)× L 个时间采样。这正是”特化”的原材料:它既不是文本,也不是自然图像。
1.3 两个数据集的具体格式(读代码前必须知道)
RML2016.10a(MCDformer 用)
一个 pickle 字典,键是 (调制名, SNR) 元组,值是 (N, 2, 128) 数组:
# MCDformer-main/exp_config/zhenhua/rml16a.py(load_rawdata 精简)Set = pickle.load(open('RML2016.10a_dict.pkl', 'rb'), encoding='bytes')snrs, mods = ... # 键里取出 SNR 列表与调制名列表for mod in mods: for snr in snrs: Signals.append(Set[(mod, snr)]) # 每格 (N, 2, 128)11 类调制、SNR 从 -20dB 到 18dB 步长 2。信号长度 L=128——很短,这是 MCDformer 一切设计的前提。
RadioML2018.01A(Meta-Transformer 用)
HDF5 文件(科学计算领域常用的分层数据容器格式:一个文件里可以存多个命名数组,像”带目录结构的 NumPy 文件”,由 h5py 库读写),三个数据集:
# meta-transformer-amc-main/data/dataset.py(节选)self.data = h5py.File("GOLD_XYZ_OSC.0001_1024.hdf5", 'r')self.iq = self.data['X'] # (2555904, 1024, 2) 注意: (时间, I/Q) 布局!self.onehot = self.data['Y'] # (2555904, 24) 调制类型 one-hotself.snr = np.squeeze(self.data['Z']) # (2555904,) 每样本的 SNR
x = self.iq[item].transpose() # → (2, 1024):转成 (I/Q, 时间)x = np.expand_dims(x, axis=1) # → (1, 2, 1024):补出"图像通道"维24 类调制、SNR 从 -20dB 到 30dB 步长 2、每格 4096 样本。L=1024 长信号。注意两个细节:① 原始布局是 (时间, I/Q),必须 transpose;② 补出第 1 维变成”单通道图像” (1, 2, 1024)——为 ViT 的 2D 卷积铺路。
1.4 AMC 的三大挑战 = 三个特化动机
| 挑战 | 含义 | 哪个项目解决 |
|---|---|---|
| 类空间开放 | 现实中不断出现新调制类型,封闭集模型每次都要重训 | Meta-Transformer:元学习(第 8 章) |
| 长度可变 | 接收端采样帧长可能变化(1024→64),CNN 固定感受野无法适应 | 两者:主-子编码器 / 填充(第 4 章) |
| 噪声敏感 | 低 SNR 下星座图糊成一片,纯数据驱动学习困难 | MCDformer:频域去噪前端(第 6 章) |
带着这三个动机,进入七轴特化的正文。
第 2 章 特化轴 1:Tokenization——数据如何变成 token
轴问题:我的数据不是词、不是自然图像,切成什么形状的 token 最合理? 本质:token 是注意力的”最小交互单元”,其粒度 = 你希望模型在什么尺度上建模关系。
2.0 标准做法回顾
- NLP:每个词一个 token,查表嵌入(教学文档 3.2 节的
nn.Embedding); - ViT(图像):16×16 像素块一个 token,
Conv2d(kernel=16, stride=16)投影。
两者都把”原始数据”切成同构的小块。对 IQ 信号 (2, 1024),照搬哪个都不对:它不是 1024 个离散词,也不是 224×224 的自然图像。怎么切?三个项目给出三种答案。
2.1 Meta-Transformer:2×16 的 IQ patch(把信号当”矮胖图像”)
代码逐行解读
# meta-transformer-amc-main/models/vit.py(节选,注释为本教程添加)class PatchEmbedding(nn.Module): def __init__(self, in_channels, embed_dim, patch_size): super().__init__() # in_channels=1: IQ 两行合并视为"1 通道图像" # patch_size=(2,16): 高 2 宽 16 —— 恰好覆盖 I/Q 两行 × 16 个时间点 self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size)
def forward(self, x): x = self.proj(x) # (B,1,2,1024) → (B,36,1,64) x = x.flatten(2).transpose(1, 2) # → (B,64,36):64 个 token return x形状推演(输入 (B, 1, 2, 1024)):
Conv2d(1→36, k=(2,16), s=(2,16)): 输出高 = (2-2)/2 + 1 = 1 ← I/Q 两行被"吃"进同一个 patch 输出宽 = (1024-16)/16 + 1 = 64 ← 64 个时间窗 → (B, 36, 1, 64)flatten(2).transpose(1,2): → (B, 64, 36) ← 64 个 token,每个 36 维设计动机(最重要的三行):
- patch 第一维 = 2 恰好等于 I/Q 行数:一个 patch 必然同时包含 I 和 Q。相位信息藏在 I/Q 的比值里——如果 patch 只覆盖一行,token 从出生起就”先天失明”看不到相位,必须靠后续层自行配对 I/Q,凭空增加学习难度。用 patch 尺寸编码”哪些原始维度必须绑定”,是 tokenization 特化的第一原则;
- patch 第二维 = 16 是时间窗:约等于符号周期量级——每个 token 携带”一个符号”的信息,让注意力在”符号间关系”而非”采样点间关系”上建模;
- 与 ViT 完全同构(
Conv2d投影 + flatten + transpose),一个模块都不改,只改patch_size两个数字。这说明:Tokenization 特化的最小成本,往往就是改 patch 尺寸。
完整 ViT 的配套(cls token + 位置编码)
# vit.py 节选self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))self.pos_embed = nn.Parameter( torch.zeros(1, 1 + (2 * 1024) // (2 * 16), embed_dim)) # 1+64=65 个位置
def forward(self, x): B, _, _, _ = x.shape x = self.patch_embed(x) # (B,64,36) x = torch.cat((self.cls_token.expand(B, -1, -1), x), dim=1) # (B,65,36) x = x + self.pos_embed # 可学习位置编码 x = self.pos_drop(x) x = self.blocks(x) # 8 层 ViTBlock x = self.norm(x[:, 0]) # 取 cls token x = self.fc(x) # 分类头 return xcls_token占位置 0,参与全部注意力(它”旁听”整场会议),最后取它作为序列级表示(第 7 章详解);pos_embed长度 = 64+1 = 65,可学习且长度固定——伏笔:第 4 章的长度伸缩问题就源于这里。
2.2 CTDNN:更标准的变体(对照着看)
同仓库 CTDNN.py(该仓库的 ViT 基线):
class PatchEmbed(nn.Module): def __init__(self, patch_size=(2,8), in_chans=1, embed_dim=256, ...): self.num_patches = 128 // patch_size[1] # 16 个 patch(L=128, patch 宽 8) self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size, bias=True) self.norm = nn.BatchNorm2d(embed_dim) # ★ 注意: 用 BatchNorm 而非 LayerNorm
def forward(self, x): x = self.proj(x) # (B,1,2,128) → (B,256,1,16) x = torch.tanh(self.norm(x)) # ★ BN + tanh 压幅 x = x.flatten(2).transpose(1, 2) # → (B,16,256) return x与 Meta-Transformer 的三处差异及含义:
| 差异 | Meta-Transformer | CTDNN | 含义 |
|---|---|---|---|
| patch 宽 | 16(L=1024 → 64 token) | 8(L=128 → 16 token) | token 数按序列长度等比例缩放,保持 16~64 区间 |
| cls token | 有 | 无 | CTDNN 靠位置编码区分位置,最后 flatten 所有 token 接 MLP(第 7 章) |
| 嵌入后归一化 | 无 | BatchNorm + tanh | BN 在 batch 上统计、tanh 限幅——防数值爆炸的工程手段 |
注意 CTDNN 的 pos_embed[:, : x.size(1), :](按实际长度裁剪)——第 4.4 节讲它的意义。
2.3 MCDformer:不用 patch——Conv stem 压缩 + “通道即 token”
MCDformer 的答案是先卷积后转置:
# MCDformer-main/models/MCDformer.py(节选,注释为本教程添加)self.kernel_size_list = [(1,3), (2,3), (1,3), (1,3)]self.conv_chan_list = [1, 36, 64, 128, 256] # 通道数逐层翻倍
class Conv_Block(nn.Module): def __init__(self, in_channel, out_channel, kernel_size): super().__init__() self.conv_block = nn.Sequential( nn.ZeroPad2d((1, 1, 0, 0)), # 只补时间维(左右各 1),保持长度 nn.Conv2d(in_channel, out_channel, kernel_size=kernel_size), nn.ReLU(inplace=True), nn.BatchNorm2d(out_channel))
def forward(self, x): x = x.unsqueeze(1) # (B,2,128) → (B,1,2,128) x = self.FDDM(x) # 频域去噪(第 6 章) x = self.Conv_stem(x) # 4 层 Conv_Block x = x.squeeze(2) # (B,256,1,128) → (B,256,128) x = self.block(x) # Transformer Block(dim=128) ← 通道当 token!形状推演(核心,务必跟一遍):
输入 (B, 1, 2, 128) 1 通道: I/Q 两行Conv(1,3) (B, 36, 2, 128) pad(1,1) 保持宽 128;高不变Conv(2,3) (B, 64, 1, 128) ★ 核高=2: I/Q 两行融合成 1 行!Conv(1,3) (B,128, 1, 128)Conv(1,3) (B,256, 1, 128) 通道 256,时间 128squeeze(2) (B,256, 128) → 送入 Block(dim=128)两个设计决策及其理由:
- 用卷积(而不是 patch 线性投影)做 token 化:卷积带局部感受野与平移不变性,是提取”波形局部纹理”的专家;L=128 太短,切 patch 得不偿失。先卷积提取 → 再注意力全局建模是信号领域的主流分工(对比:Meta-Transformer 的 L=1024 足够长,直接 patch 更省);
- 核
(2,3)专门用来融合 I/Q:与 Meta-Transformer “patch 覆盖两行”异曲同工——无论哪种 token 化,I/Q 必须在进入注意力前完成配对。区别只是:patch 在”token 化时”融合,卷积 stem 在”第 2 层”融合。
2.4 手把手:自己实现一个信号 patch 嵌入
把两个项目的思想合并成一个干净模块:
import torchimport torch.nn as nn
class IQPatchEmbed(nn.Module): """可复用的 IQ 信号 patch 嵌入(Meta-Transformer 风格,补上位置编码裁剪)。 用法: emb = IQPatchEmbed(patch_len=16, embed_dim=36) x = torch.randn(4, 2, 1024) # (B, 2, L) 裸 IQ tokens = emb(x) # (B, 65, 36) 64 patch + cls """
def __init__(self, patch_len: int = 16, embed_dim: int = 36, use_cls: bool = True, dropout: float = 0.1): super().__init__() self.use_cls = use_cls self.patch_len = patch_len # 核高固定 2:一个 patch 必然覆盖 I/Q 两行(2.1 节设计动机 1) self.proj = nn.Conv2d(1, embed_dim, kernel_size=(2, patch_len), stride=(2, patch_len)) self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) \ if use_cls else None # 位置编码惰性创建,长度按实际输入自适应(4.4 节思想) self.pos_embed = nn.Parameter(torch.zeros(1, 1, embed_dim)) self.drop = nn.Dropout(dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (B, 2, L) —— 裸 IQ(调用方不必预先 unsqueeze) B, _, L = x.shape x = self.proj(x.unsqueeze(1)) # (B, E, 1, N) x = x.flatten(2).transpose(1, 2) # (B, N, E) if self.use_cls: x = torch.cat([self.cls_token.expand(B, -1, -1), x], dim=1) # 位置编码按 token 数裁剪/扩展(对比 CTDNN 的 pos_embed[:, :N]) if self.pos_embed.size(1) != x.size(1): if self.pos_embed.size(1) < x.size(1): # 不够长:线性插值扩展(ViT 处理不同分辨率的官方做法) pos = self.pos_embed.transpose(1, 2) # (1,E,T) pos = torch.nn.functional.interpolate(pos, size=x.size(1), mode="linear") self.pos_embed = nn.Parameter(pos.transpose(1, 2)) else: self.pos_embed = nn.Parameter(self.pos_embed[:, : x.size(1)]) return self.drop(x + self.pos_embed)
# ---- 冒烟测试 ----emb = IQPatchEmbed()print(emb(torch.randn(4, 2, 1024)).shape) # (4, 65, 36) 1024 长print(emb(torch.randn(4, 2, 256)).shape) # (4, 17, 36) 256 长自动适配!这个模块吸收了三个项目的全部 tokenization 智慧:patch 覆盖 I/Q(Meta-Transformer)、长度自适应(CTDNN 的裁剪 + ViT 的插值)、调用方免 unsqueeze(修掉 7.4 节的形状契约坑)。
2.5 选择原则(轴 1 总结)
| 情形 | 推荐 | 理由 |
|---|---|---|
| 数据有明显二维结构(图像、IQ、谱图) | 2D patch 卷积 | patch 尺寸编码结构先验(2.1 动机 1) |
| 序列长(≥512) | 大 patch / 卷积 stem 下采样 | 控制 token 数( 成本,见教学文档 5.3) |
| 序列短(<256) | 小 patch 或卷积 stem | 保 token 粒度,别切太碎 |
| 数据有局部纹理(波形、语音) | 卷积 stem + 转置 | 卷积先提取局部,注意力后全局(2.3 决策 1) |
| token 数 > 512 | 必须下采样/池化/稀疏注意力 | 显存与速度 |
第 3 章 特化轴 2:注意力作用域——谁和谁交互
轴问题:token 化之后,注意力矩阵的 N×N 到底在哪些实体之间建立联系? 本质:注意力与 FFN 只是”跨轴混合”与”轴内混合”两种抽象运算,作用在哪个轴上完全由你决定。
3.0 标准做法回顾
标准 Transformer:N = 序列长度,注意力在时间步之间交互(第 i 个词关注第 j 个词)。Meta-Transformer 与 CTDNN 沿袭此设定:N = 64(或 16)个时间 patch。
3.1 时间 token(Meta-Transformer / CTDNN)
注意力建模”信号前段与后段的关系”——周期性、前缀结构、突发等。这是最接近标准 ViT 的用法,无需赘述。真正的特化发生在下面。
3.2 MCDformer:把注意力搬到通道轴(转置 token 用法)
回顾 2.3 节的形状:进入 Block(dim=128) 的张量是 (B, 256, 128)。MCDformer 没有转置它,于是:
N = 256 ← "token" = 256 个卷积通道(每种通道 = 一种时域滤波器输出)C = 128 ← 每个 token 的"特征" = 128 长的时域响应逐部件确认(MCDformer.py 的 Attention):
def forward(self, x): B, N, C = x.shape # (B, 256, 128):N=通道数, C=时间长度 q = self.q(x).reshape(B, N, self.num_heads, C // self.num_heads).permute(0, 2, 1, 3) # ... q/k/v 各 (B, H, N, d_h) = (B, 2, 256, 64) attn = (q @ k.transpose(-2, -1)) * self.scale # (B, 2, 256, 256) ★ 通道×通道! attn = attn.softmax(dim=-1) x = (attn @ v).transpose(1, 2).reshape(B, N, C)注意力矩阵是 256×256 的”通道关系图”:第 (i,j) 项 = “第 i 个通道的特征该采信第 j 个通道多少”。而 FFN(mlp_ratio=1,隐层=128)沿时间维混合每个通道内部的模式。
形状总览:
stem 输出 (B, 256, 1, 128)squeeze(2) (B, 256, 128) 256 token × 128 维Block(dim=128) QKV 投影: 128→128 每个"通道 token"的时间响应被投影 attn (B, H, 256, 256) ★ 通道间交互 MLP (B, 256, 128) 沿 128 时间维逐 token 混合ConvLayer(256) Conv1d + MaxPool(3,2,1): (B, 256, 64) 时间维减半Block2(dim=64) 同构, 特征维 64x[:, :, -1:] (B, 256, 1) 取时间维末位classifier (B, 256) → (B, 11)为什么合理(设计动机三连):
- 卷积 stem 已把时间局部结构提取干净,剩下来最需要全局交互的是”哪些通道冗余、哪些互补”——通道注意力 ≈ 升级版 SE 模块(SENet 用两层 MLP 学通道权重,这里直接用完整多头注意力);
- 时间维交给 MLP + 卷积池化压缩:
mlp_ratio=1表示作者认为时间维不需要”放大加工”,只是重组;ConvLayer的 MaxPool 把 128→64 再交给第二个块; - 注意力矩阵 256×256 显存可控( 元素),而如果反过来把 128 个时间点当 token,矩阵只有 更小——但时间关系已在卷积里建模过,重复建模浪费容量。
3.3 深层含义:注意力与 MLP 的角色互换
标准 Transformer:注意力混 token,MLP 混通道(教学文档 2.8 节)。MCDformer:注意力混通道,MLP 混时间。
┌──────────────┬──────────────────┐ │ 标准用法 │ MCDformer 用法 │──────────────┼──────────────┼──────────────────┤注意力(跨轴) │ 时间步 ↔ 时间步 │ 通道 ↔ 通道 │MLP(轴内) │ 通道内 │ 时间轴内 │记住这张表:Transformer 的两个子层只是”跨轴混合”与”轴内混合”的抽象,作用在哪个轴上是完全自由的。拿到任何高维数据(时间×频率×通道×传感器……),先问:哪个轴上的交互最稀疏、最需要注意力?哪个轴上的混合只是逐点变换、交给 MLP 就够?
3.4 AMC_Net 的 FeaFusionModule:只要注意力、其余全删
同仓库 AMC_Net.py(论文主体模型)给出极简特化——多头注意力的最小形态:
class FeaFusionModule(nn.Module): def __init__(self, num_attention_heads, input_size, hidden_size): ... self.key_layer = nn.Linear(input_size, hidden_size) self.query_layer = nn.Linear(input_size, hidden_size) self.value_layer = nn.Linear(input_size, hidden_size) self.dropout = nn.Dropout(0.5)
def trans_to_multiple_heads(self, x): new_size = x.size()[:-1] + (self.num_attention_heads, self.attention_head_size) x = x.view(new_size) return x.permute(0, 2, 1, 3) # (B, H, N, d_h)
def forward(self, x): key = self.key_layer(x); query = self.query_layer(x); value = self.value_layer(x) key_heads = self.trans_to_multiple_heads(key) # (B,H,256,64) # ... attention_scores = torch.matmul(query_heads, key_heads.permute(0, 1, 3, 2)) attention_scores = attention_scores / math.sqrt(self.attention_head_size) attention_probs = F.softmax(attention_scores, dim=-1) context = torch.matmul(attention_probs, value_heads) # (B,H,256,64) context = context.contiguous().view(shape[0], -1, shape[-1]) # (B,512,64)! return context与标准注意力的差异清单(逐条对照):
| 部件 | 标准实现 | FeaFusionModule |
|---|---|---|
| QKV 投影 | 合并或三个 Linear | 三个 Linear ✓ |
| 输出投影 W_O | 有 | 无——头输出直接拼接 |
| FFN | 有 | 无 |
| 残差 / LayerNorm | 有 | 无 |
| 头的合并方式 | 拼回特征维 (B,N,d) | 沿 token 维堆叠 (B, 2H·N/2? 实为 (B,512,64)) |
view(shape[0], -1, shape[-1]) 把 2 个头的结果沿 token 维叠成 512 行(256 通道 × 2 头),随后 AdaptiveAvgPool1d(1) 全局池化 → (B,512) → 分类(latent_dim=512 正是 256×2,配置自洽)。
这个模块的教学价值:它证明 Transformer 每个部件都可独立增删——研究代码里”只要注意力做特征融合”完全合法。但也暴露风险:删掉 LN 和残差后模块对初始化更敏感(教学文档 5.2 节的 NaN 教训),这是学术实现常见的”能用但脆”形态。
3.5 手把手:实现一个通道注意力块
class ChannelAttnBlock(nn.Module): """MCDformer 风格转置块:输入 (B, C, L),token = 通道 C,特征维 = 时间 L。 与教学文档 3.6 节 EncoderBlock 的唯一区别:不转置、直接吃 (B,C,L)。"""
def __init__(self, sig_len: int, n_heads: int = 2, mlp_ratio: float = 1.0, dropout: float = 0.3): super().__init__() assert sig_len % n_heads == 0 self.norm1 = nn.LayerNorm(sig_len) # 沿时间维归一化! self.attn = nn.MultiheadAttention(sig_len, n_heads, # embed_dim = 时间长度 dropout=dropout, batch_first=True) self.norm2 = nn.LayerNorm(sig_len) hidden = int(sig_len * mlp_ratio) self.ffn = nn.Sequential(nn.Linear(sig_len, hidden), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden, sig_len)) self.drop = nn.Dropout(dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (B, C, L) —— 卷积 stem 的输出天然是这个布局,直接吃 x = x + self.drop(self.attn(self.norm1(x), self.norm1(x), self.norm1(x))[0]) x = x + self.drop(self.ffn(self.norm2(x))) return x
# ---- 冒烟测试 ----blk = ChannelAttnBlock(sig_len=128)x = torch.randn(4, 256, 128) # 256 通道 × 128 时间y = blk(x)print(y.shape) # (4, 256, 128)# 注意力在 256 个通道间交互;LayerNorm 沿 128 维(时间)归一化要点:nn.MultiheadAttention(embed_dim=sig_len)——embed_dim 是”每个 token 的向量长度”,这里 token 是通道、向量是时间响应,所以 embed_dim = 时间长度。第一次写转置注意力最容易在这里写反。
3.6 作用域选择原则(轴 2 总结)
| 目标 | 作用域 | 备注 |
|---|---|---|
| 长程时间依赖(周期、突发) | 时间 token | token 数 = 时间片数,受 约束 |
| 通道/特征融合 | 通道 token | token 数 = 通道数(64~512),成本低 |
| 两者都要 | 交替块 | MCDformer:通道注意力 ×2 + 卷积做时间压缩 |
| 极简融合 | 裸多头(FeaFusion 式) | 风险:无 LN/残差,初始化要小心 |
第 4 章 特化轴 3:位置编码与长度伸缩
轴问题:我的序列长度不固定(接收机帧长会变),而可学习位置编码长度是写死的,怎么办?
4.0 标准做法回顾与它的死穴
- 正弦编码:长度任意,但公式固定、不学(教学文档 2.7.1);
- 可学习编码:
nn.Parameter((1, N, d)),N 写死——训练 1024 帧,测试来 256 帧直接维度对不上。
三个项目全部用可学习位置编码(信号任务中位置含义任务相关,交给数据学更好)。于是”变长”问题必须显式解决。
4.1 主-子编码器:Meta-Transformer 的方案
思想:与其让一个模型吃所有长度,不如训练两个编码器:
vit_main: { patch_size: [2,16], embed_dim: 36, in_size: [2,1024] } # 主编码器: 1024 帧vit_sub: { patch_size: [2,16], embed_dim: 108, in_size: [2,128] } # 子编码器: 短帧- 主编码器:标准帧长 1024,64+1 个位置;
- 子编码器:短帧 128→256(README 描述),8+1 个位置,embed_dim 加到 108(token 少了,每个 token 多带点信息)。
测试时按接收帧长选择:长帧走 vit_main,短帧走 vit_sub。用”多模型”换”单模型改结构”——工程上粗暴但有效,且每个模型都训得充分。
4.2 输入填充:self_duplicate 与 zero(频域视角)
若只有主编码器,短帧(L=256)必须凑成 1024。dataset.py 提供两种填充:
# meta-transformer-amc-main/data/dataset.py(FewShotDataset 节选)if self.padding == 'self_duplicate': num_dup = 1024 // L query_set = [np.concatenate([self.iq[i].transpose()[:, :L] for _ in range(num_dup)], axis=1) ...] # 重复拼接 4 次elif self.padding == 'zero': query_set = [np.concatenate((sig, np.zeros((2, 1024-L))), axis=1) ...] # 补零为什么 self_duplicate 更好(信号处理视角):把 L 帧重复 K 次 ≈ 把采样间隔缩小 K 倍(过采样)。离散傅里叶变换的性质:时域重复 K 次 = 频域在原谱线之间插入 K-1 个零点( 当 为 的倍数,否则为 0;频谱”内插”)——谱峰位置完全不变,判别信息(载频、带宽、谱形)无损。而补零 = 时域截断加窗,等效于频域与 sinc 函数(,矩形窗的傅里叶变换,两侧带”波纹”)卷积,会展宽谱峰、引入旁瓣,污染谱形。填充策略也要懂领域知识——这是本章最值得记住的一句话。
4.3 位置编码裁剪:CTDNN 的做法
# CTDNN.py 节选x = x + self.pos_embed[:, : x.size(1), :] # 按实际 token 数裁剪声明 max_len 足够长的位置编码,测试时裁掉多余部分。简单、零成本,适用于”长度变化不大”的场景。
4.4 插值扩展:ViT 的官方做法(2.4 节模板已实现)
长度变长超出训练范围时,把位置编码当一维信号做线性插值:
pos = self.pos_embed.transpose(1, 2) # (1, E, T)pos = F.interpolate(pos, size=new_len, mode="linear") # 插值到新长度4.5 手把手:变长信号适配的三种方案代码
def adapt_length(x, target_len, mode="duplicate"): """把 (B, 2, L) 的短帧适配到 target_len。三种策略: 'duplicate': 重复拼接(频谱内插,无损)—— Meta-Transformer 默认 'zero' : 补零(谱峰展宽) —— 不推荐但实现简单 'crop' : 只取前 target_len(L > target_len 时用) """ B, _, L = x.shape if L == target_len: return x if L < target_len and mode == "duplicate": k = target_len // L rest = x[:, :, : target_len - k * L] return torch.cat([x.repeat(1, 1, k), rest], dim=2) if L < target_len and mode == "zero": return F.pad(x, (0, target_len - L)) return x[:, :, :target_len]
x = torch.randn(4, 2, 256)print(adapt_length(x, 1024, "duplicate").shape) # (4, 2, 1024)print(adapt_length(x, 1024, "zero").shape) # (4, 2, 1024)print(adapt_length(x, 128, "crop").shape) # (4, 2, 128)4.6 原则(轴 3 总结)
| 长度变化幅度 | 方案 | 代价 |
|---|---|---|
| 变化小(±30%) | 裁剪 + 插值(4.3/4.4) | 几乎为零 |
| 变化大且域内样本足 | 主-子编码器(4.1) | 多训一个模型 |
| 只有变短 | 重复填充(4.2) | 计算量不变,效果最好 |
| 完全任意 | 正弦编码 / 无位置编码 / 相对位置(RoPE) | 表达力略降 |
第 5 章 特化轴 4:块结构与归一化
轴问题:标准块(Pre-LN、mlp_ratio=4、dropout=0.1)对我的小数据、小模型合适吗?
5.1 三种归一化排布对比(含 CTDNN 变体)
# A. Pre-LN(vit.py、MCDformer.py 同款;教学文档 3.6 节)—— 主流推荐x = x + attn(norm1(x))x = x + ffn(norm2(x))
# B. Post-LN(原论文)—— 现代实现已少用x = norm1(x + attn(x))x = norm2(x + ffn(x))
# C. CTDNN 变体(CTDNN.py 实际代码)—— 混合形态x = norm1(x + attn(x)) # 残差后归一,但 attn 的输入没有归一化x = norm2(x + dropout(ffn(x)))稳定性排序:A > B > C。C 的注意力输入缺少归一化,深层时数值漂移风险大。CTDNN 只有 depth=2 所以无碍;复现论文时务必看清作者用的是哪种——三者在代码上只差一行,论文里往往不写。
5.2 mlp_ratio 的语义与 vit.py 的命名陷阱
FFN 隐层 = d × mlp_ratio:
- MCDformer:
mlp_ratio=1——它的 MLP 任务是”沿时间维重组”(3.2 节),不需要放大容量; - Meta-Transformer:
mlp_dim=32,但代码里当倍率用:
# vit.py:ViTBlock(embed_dim, num_heads, mlp_dim) → MLP(embed_dim, int(embed_dim * mlp_ratio), ...)# 即隐层 = 36 × 32 = 1152(而不是字面意义的 32)!教训:读研究代码时,参数名和参数语义可能不符——先看它被怎么用,再看它叫什么。改参前务必验证 model.ffn.fc1.weight.shape。
5.3 容量设计:瘦 ViT vs 胖 stem(两个极端都有道理)
| Meta-Transformer | MCDformer | |
|---|---|---|
| 嵌入维度 | 36(9 头,每头仅 4 维) | 注意力特征维 = 128(=时间长度) |
| 层数 | 8 | 2(两个块) |
| 容量来源 | 层数深 | stem 通道多(256) |
两种容量哲学:Meta-Transformer 把容量放在深度(8 层瘦块,泛化好——元学习正需要任务无关的表示);MCDformer 把容量放在卷积 stem 的宽度(256 通道),Transformer 只做轻量关系建模。没有标准答案,只有”容量总量 ≈ 数据复杂度”的匹配。24 类长信号 → 8 层;11 类短信号 → 2 层 + 胖 stem。
5.4 强 Dropout:小数据信号任务的特化手段
MCDformer 的 Block:drop=0.5, attn_drop=0.5, drop_path=0.5——远超图像 ViT 的 0.1。三个 dropout 各管一摊:drop 随机丢神经元激活、attn_drop 随机丢注意力权重、drop_path 随机丢整条残差分支(即随机深度 Stochastic Depth:该层的输出按概率整体置零,只留下恒等映射,相当于随机”跳过一层”)。原因:RML2016.10a 每类样本有限、低 SNR 噪声大,小模型极易记忆噪声。准则:数据越少、噪声越大,dropout 越强。反过来,若你的任务数据百万级,0.5 会欠拟合,降到 0.1。
5.5 手把手:设计自己的块(参数化模板)
class ConfigurableBlock(nn.Module): """把本章全部自由度做成参数:归一化排布、mlp 倍率、dropout 强度。"""
def __init__(self, d, n_heads, mlp_ratio=4.0, dropout=0.1, norm_style="pre", act=nn.GELU): super().__init__() assert norm_style in ("pre", "post", "ctdnn") self.style = norm_style self.n1 = nn.LayerNorm(d) self.attn = nn.MultiheadAttention(d, n_heads, dropout=dropout, batch_first=True) self.n2 = nn.LayerNorm(d) self.ffn = nn.Sequential(nn.Linear(d, int(d * mlp_ratio)), act(), nn.Dropout(dropout), nn.Linear(int(d * mlp_ratio), d)) self.drop = nn.Dropout(dropout)
def forward(self, x): a = lambda t: self.drop(self.attn(t, t, t)[0]) # 自注意力支路 f = lambda t: self.drop(self.ffn(t)) if self.style == "pre": x = x + a(self.n1(x)); x = x + f(self.n2(x)) elif self.style == "post": x = self.n1(x + a(x)); x = self.n2(x + f(x)) else: # ctdnn x = self.n1(x + a(x)); x = self.n2(x + f(x)) x = self.n2(x) # CTDNN 变体里 MLP 输入未归一——此简化仅示意 return x注:CTDNN 变体(5.1 C 式)的精确复刻为
x = self.n1(x + a(x)); x = self.n2(x + f(x))且a、f内部不再归一——上面模板的 “ctdnn” 分支是示意,真实语义见 CTDNN.py 原文。
第 6 章 特化轴 5:域先验前端模块(频域去噪)
轴问题:任务有明确的物理先验(噪声在频域、信号在频带内),如何把它注入网络? 答案:把物理操作做成可微模块,接在 Transformer 前面。这是 MCDformer 最有教学价值的部分。
6.1 FFT 速览:三行代码建立频域直觉
离散傅里叶变换(DFT/FFT):任意 L 点信号可分解为 L 个不同频率正弦波之和;FFT 输出每个频率的”含量”(复数:振幅+相位):
其中 表示频率为 采样率的”含量”(复数:模 = 振幅、辐角 = 相位)。采样率即每秒采样的点数(单位 Hz),由采集设备决定,它把”第 k 根谱线”换算成真实频率。
import numpy as npimport matplotlib.pyplot as plt # 仅本节演示用
t = np.arange(128) / 128s = np.sin(2 * np.pi * 5 * t) + 0.5 * np.sin(2 * np.pi * 20 * t) # 5Hz + 20Hz 混合S = np.fft.fft(s)freqs = np.fft.fftfreq(128, 1 / 128)print("频谱峰值位置:", freqs[np.argsort(-np.abs(S))[:4]]) # [5, -5, 20, -20] ✓三个直觉(后续全靠它们):
- 信号 = 少数谱线的组合:调制信号的判别信息集中在载频附近少数频点;
- 噪声 = 全频段均匀散布:高斯白噪声的频谱”到处都有且差不多高”;
- 频域加权 = 滤波:给每个频点乘一个增益再 IFFT,等于一个滤波器(卷积定理,)。FDDM 的全部思想就是”学一个增益函数”。
6.2 FDDM:频域去噪模块逐行解读
# MCDformer-main/models/MCDformer.py(按实际张量形状整理,注释为本教程添加)class TinyMLP(nn.Module): """学习到的频域滤波器:L → L/4 → L,Tanh 把增益限幅在 (-1, 1)。""" def __init__(self, N): super().__init__() self.mlp = nn.Sequential( nn.Linear(N, N // 4), nn.ReLU(inplace=True), nn.Linear(N // 4, N), nn.Tanh()) # ★ Tanh: 增益有界
class FrequencyDomainDenoisingModule(nn.Module): def __init__(self, N): # N = 信号长度 128 super().__init__() self.mlp = TinyMLP(N)
def forward(self, x): # x: (B, 1, 2, L) —— 行 0 = I,行 1 = Q x_init = x.clone() # 留底做残差 r = x[:, :, 0, :] + 1j * x[:, :, 1, :] # ① I + jQ = 复信号 (B,1,L) R = torch.fft.fft(r, dim=-1) # ② 频谱 (B,1,L) R_stack = torch.stack([R.real, R.imag], dim=2) # ③ (B,1,2,L) 实虚分两行 h = self.mlp(R_stack) # ④ 学习频域增益 (B,1,2,L) R_hat = h[:, :, 0, :] * R_stack[:, :, 0, :] \ + 1j * (h[:, :, 1, :] * R_stack[:, :, 1, :]) # ⑤ 实/虚谱分别加权 x_hat = torch.fft.ifft(R_hat, dim=-1).real.unsqueeze(2) # ⑥ 回时域 (B,1,1,L) return x_hat + x_init # ⑦ 残差连接数学三行:
为什么有效的五层递进:
- 频域天然分离信号与噪声(6.1 直觉 1+2):学”哪个频点该保留”比在时域学去噪容易得多;
- TinyMLP 把 128 个频点互相混合:能学出比固定带通滤波器更灵活的谱整形(镜像抑制、谱峰锐化、谐波增强);
- Tanh 限幅 = 物理约束注入:滤波器增益有物理界限(无源滤波 |H|≤1),无界增益会放大噪声;限幅还防数值爆炸;
- 残差连接 = 安全网:最坏情况学到恒等映射(h→1),模块只会帮忙不会添乱——这是插入任何先验模块的安全姿势;
- 可微 = 端到端训练:滤波器的”系数”随分类损失一起学,不是手工设计——传统 DSP 与深度学习的结合点正在于此。
6.3 对比:AMC_Net 的 AdaCorrModule(同一思想的另一实现)
# AMC_Net.py 节选class AdaCorrModule(nn.Module): def __init__(self, N): self.Im = TinyMLP(N) # 虚部谱专用滤波器 self.Re = TinyMLP(N) # 实部谱专用滤波器
def forward(self, x): x_init = copy.deepcopy(x) x = torch.fft.fft(x, dim=-1) # 对两行分别 FFT X_re, X_im = torch.real(x), torch.imag(x) h_re = self.Re(X_re); h_im = self.Im(X_im) # 实虚分治! x = torch.mul(h_re, X_re) + 1j * torch.mul(h_im, X_im) x = torch.real(torch.fft.ifft(x, dim=-1)) # 只取实部 x = x + x_init return x| FDDM | AdaCorr | |
|---|---|---|
| 复数处理 | I+jQ 合成复数 FFT | 两行分别 FFT,实虚分治 |
| 滤波器 | 共享一个 TinyMLP | 实部/虚部各一个 |
| 输出 | 实数 + 残差 | 只取实部 + 残差 |
| 参数/容量 | 小 | 大(两倍) |
同一个物理先验(频域自适应滤波)可以有两种神经实现——取舍在”共享权重(省参数、正则好)“与”独立建模(容量大、更灵活)“。你的任务数据少选前者,数据多选后者。
6.4 手把手:自己写一个频域去噪模块(含消融开关)
class FDDM(nn.Module): """独立可复用的频域去噪模块。use_complex=False 时退化为实数 FFT 版本 (对实值信号任务可直接用)。"""
def __init__(self, sig_len: int, hidden_ratio: int = 4): super().__init__() self.filter = nn.Sequential( nn.Linear(sig_len, sig_len // hidden_ratio), nn.ReLU(), nn.Linear(sig_len // hidden_ratio, sig_len), nn.Tanh())
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (B, 1, 2, L) —— I/Q 两行 r = x[:, :, 0, :] + 1j * x[:, :, 1, :] R = torch.fft.fft(r, dim=-1) R_stack = torch.stack([R.real, R.imag], dim=2) h = self.filter(R_stack) R_hat = h[:, :, 0, :] * R_stack[:, :, 0, :] \ + 1j * (h[:, :, 1, :] * R_stack[:, :, 1, :]) return torch.fft.ifft(R_hat, dim=-1).real.unsqueeze(2) + x
fddm = FDDM(128)x = torch.randn(4, 1, 2, 128)print(fddm(x).shape) # (4, 1, 2, 128) —— 形状不变# 残差保证: 最坏情况模块学成恒等(把 filter 权重冻结为 0 即可验证)6.5 通用范式(轴 5 总结)
输入 → [物理先验模块(FFT/小波/谱图/均衡…),必须带残差] → [卷积 stem] → [Transformer] → 任务头- 任何可微的物理操作都可以这样插入:小波分解(同仓库 AWN.py 基线)、STFT 谱图、循环平稳分析、信道均衡;
- 三条铁律:① 带残差(安全网);② 输出形状不变或可控(好拼接);③ 增益有界(Tanh/Sigmoid,防爆炸);
- 判断标准:如果模块的输出物理可解释(如”去噪后的波形”),就值得做——可解释 = 可调试。
第 7 章 特化轴 6:读出方式与任务头(含摘掉分类头)
轴问题:Transformer 输出的是 N 个 token 的序列,怎么变成最终预测? 深层问题:我的任务真的需要”分类头”吗?
7.0 标准做法回顾
Encoder-only 标准流程:取 cls token(或池化)→ nn.Linear(d, K) → K 类 logits → softmax。K 固定 = 封闭集假设。
7.1 四种读出方式(两个项目恰好集齐)
| 读出 | 代码 | 出处 | 特点 |
|---|---|---|---|
| cls token | x[:, 0] | vit.py | 可学习聚合;cls 全程旁听注意力,等于”会议纪要” |
| 取末位 | x[:, :, -1:] | MCDformer.py | 省一个 token;因 MLP 已沿时间混合,末位也”看过”全段 |
| Flatten + MLP | flatten(1) | CTDNN.py | 16 token × 256 维 = 4096 → MLP;容量最大、参数最多 |
| 全局平均池化 | AdaptiveAvgPool1d(1) | AMC_Net.py | 与 token 数无关,最稳、最便宜 |
选择建议:数据多选 cls(表达力强);数据少选 GAP(正则好);要省参数选末位;要最大容量选 flatten。对最终精度的影响通常 <1%(教学文档 3.12 消融实验④也验证了这一点),真正影响大局的是下面这一节。
7.2 摘掉分类头:从”分类器”到”嵌入器”(Meta-Transformer 的核心特化)
问题:分类头把模型锁死在”训练时见过的 24 类”。新调制类型来了怎么办?重训整个模型显然不可行。
思路:不让模型输出”类别”,让它输出嵌入向量,分类改用度量(“跟谁像就是谁”)。这就是原型网络(Prototypical Network):
数学推导(三步):
- 原型:类 c 的原型 = 该类 support 样本嵌入的均值(“平均脸”):
- 距离分类:查询样本归入最近的原型:
- 概率形式(可训练):对负距离做 softmax 再交叉熵:
为什么这解决开放集:训练时模型只学习”让同类样本在嵌入空间聚拢”——这个目标与”有哪些类”无关。测试时新类只要 1~5 个样本算出原型即可分类,网络一个参数都不用改。这正是论文标题 “scalable”(可扩展)的含义。
7.3 proto.py 逐行走读
# meta-transformer-amc-main/models/proto.py(proto_train 核心,注释为本教程添加)def proto_train(self, sample): n_way = len(sample.keys()) # 本 episode 有 5 个类 n_support = self.config['num_support'] # 每类 5 个有标签样本 n_query = self.config['num_query'] # 每类 10 个查询样本
# ---- ① 把 episode 字典摊平成两个张量 ---- # support: (n_way×n_support, 2, L);query: (n_way×n_query, 2, L) x_support = np.vstack([np.array([np.array(iq) for iq in sample[label]['support']]) for label in sample.keys()]) x_query = np.vstack([... 同理 ...]) x_support = torch.from_numpy(x_support).cuda(0) x_query = torch.from_numpy(x_query).cuda(0)
# ---- ② 编码:同一个 encoder 前向两次 ---- z_support = self.encoder.forward(x_support) # (75, d) z_query = self.encoder.forward(x_query) # (50, d)
# ---- ③ 原型 = support 嵌入按类求均值 ---- z_proto = z_support.view(n_way, n_support, -1).mean(1) # (5, d)
# ---- ④ 距离 → 概率 ---- dists = torch.cdist(z_query, z_proto) # (50, 5) 欧氏距离 log_p_y = F.log_softmax(-dists, dim=1) # 负距离 softmax = 概率
# ---- ⑤ 交叉熵 + 准确率 ---- target_inds = torch.arange(0, n_way).view(n_way, 1, 1) \ .expand(n_way, n_query, 1).long() # 每类的真值下标 loss = -log_p_y.gather(2, target_inds).squeeze().view(-1).mean() _, y_hat = log_p_y.max(2) acc = torch.eq(y_hat, target_inds.squeeze()).float().mean() return loss, {'loss': ..., 'acc': ...}逐行要点:
- ① episode 的组织:
sample是字典{类别 → {support: [...], query: [...]}},来自第 8.2 节的 episode 采样器;np.vstack把所有类的样本纵向堆叠; - ② 关键:
encoder.forward输出的是嵌入(第 7.2 节)。注意这里没有softmax、没有分类头——encoder 扮演”嵌入器”角色; - ③
view(n_way, n_support, -1).mean(1):把 75 行按类重排成 (5, 5, d),对 support 维求均值 → 5 个原型。原型 = 平均脸; - ④
torch.cdist:PyTorch 内置的成对欧氏距离。-dists取负号(距离越小概率越大); - ⑤ 交叉熵与标准分类完全同构——只是”logits”被换成了”负距离”。训练目标:让查询样本离本类原型最近。
7.4 两个真实代码坑(本仓库实拍,特化时必查)
坑 1:复用旧分类模型时忘了摘 fc 头。 load_protonet_vit 直接复用完整 ViT(含 self.fc),于是 z_support 实际是 24 维 logits(num_classes=24),而非 36 维 cls 嵌入:
# proto.py load_protonet_vitencoder = ViT(..., num_classes=model_params["num_classes"]) # fc 还在!# proto_train 里: z_support = self.encoder.forward(x_support) # 含 fc 输出更标准的做法是取分类头之前的 norm(x[:, 0])。这个坑的教训:换任务头时,先确认”嵌入”到底从哪一层取。
坑 2:数据集与模型之间的形状契约不一致。 FewShotDataset 产出的样本是 (2, L)(3 维 batch 化后),而 ViT.forward 期望 4D (B, 1, 2, L):
# dataset.py 监督路径做了 expand_dims:x = np.expand_dims(x, axis=1) # (2,1024) → (1,2,1024) ✓# 但 FewShotDataset 的 support/query 没有这一步 ✗修复三选一:数据集出口 np.expand_dims(x, axis=1);模型入口 x.unsqueeze(1);或像本文 2.4 节模板那样封装成 IQPatchEmbed(调用方永远传裸 (B,2,L))。特化时用一行形状断言守住契约:
assert x.dim() == 4 and x.size(1) == 1, f"期望 (B,1,2,L),实际 {x.shape}"7.5 手把手:度量学习头(proto loss 的干净实现)
def proto_loss(encoder, x_support, y_support, x_query, y_query): """原型网络损失(7.2 节公式的完整实现)。 encoder: 任意"输入 → (B,d) 嵌入"的模型(Transformer/CNN 均可) x_support: (n_way×n_sup, ...) y_support: 类标签(可非 0 起始) 返回 (loss, acc)。""" z_s = encoder(x_support) z_q = encoder(x_query) classes = torch.unique(y_support) # 本 episode 实际出现的类 protos = torch.stack([z_s[y_support == c].mean(0) for c in classes]) # (n_way, d) remap = {c.item(): i for i, c in enumerate(classes)} # 类 id → 0..n_way-1 y_q = torch.tensor([remap[v] for v in y_query.tolist()]) dists = torch.cdist(z_q, protos) loss = F.cross_entropy(-dists, y_q) acc = (-dists).argmax(1).eq(y_q).float().mean() return loss, acc与 7.3 节官方实现相比多了 remap(处理测试 episode 类 id 不从 0 开始的情形)——第 10.1 节的迷你复刻会用到。
第 8 章 特化轴 7:训练范式(episode 元学习)
轴问题:损失函数定义在”封闭集分类”上,但我要的是”给 5 个新类样本就能分类”的能力,怎么训练?
8.0 监督学习 vs 元学习
监督学习: [数据 → 模型 → 24 类 logits] 每 batch 学"这 24 类怎么分"元学习: [随机抽 5 类 → 每类 5 样本算原型 → 10 查询样本分类] 每个 episode 都是一个新的 5 类小任务 → 学"如何从小样本分类"监督学习学知识(这 24 类长什么样);元学习学技能(给我几个例子,我就能分类)——“学会学习”(learning to learn)。测试时技能直接套用到没见过的类上。
8.1 类别划分协议:训练/测试类不相交
total_class: ['OOK','4ASK',...,'OQPSK'] # 24 类train_class_indices: [1,3,5,7,9,10,11,14,17,19,21,22] # 12 类只用于元训练test_class_indices: [0,2,4,6,12] # 5 类从未见过!评估”识别从未见过的调制类型”的能力——监督学习在此场景准确率为 0(分类头里根本没有这些类),而元学习能做到 70%+(论文结果)。协议设计 = 特化的一部分:你的评估必须贴合真实部署场景(新类、变长、低 SNR),否则指标再高也是自欺。
8.2 episode 采样器逐行走读
# data/dataset.py FewShotDataset.__init__(节选)self.label_indices = {label: [i for i, x in enumerate(self.label_list) if x == label] for label in self.labels} # 每个类 → 它所有样本的下标self.num_support = self.config["num_support"] # 5self.num_query = self.config["num_query"] # 10self.num_episode = len(self.snr) // ((self.num_support + self.num_query) * len(self.labels))
# __getitem__(节选): idx = 第几个 episodefor label in self.labels: # 遍历本数据集的所有类 label_indices = self.label_indices[label] support_indices = random.sample(label_indices, self.num_support) # 抽 5 个 support sample[label]['support'] = [self.iq[i].transpose()[:, :L] for i in support_indices] query_indices = list(set(label_indices) - set(support_indices)) # 剩余里抽 query query_indices = random.sample(query_indices, self.num_query) # 10 个 sample[label]['query'] = [...]逐行要点:
label_indices:预处理时把”每个类有哪些样本下标”做成索引表——episode 采样变成 O(1) 查表 + 随机抽样;- support 与 query 互斥(
set 差集):查询样本不能出现在支持集里,否则原型”见过”它,评估作弊; - 每个 episode 都重新随机抽样——模型永远面对新组合,防止死记;
num_episode:一个 epoch = 全部样本正好被覆盖一次的 episode 数。
8.3 meta_train 循环
# runner/train.py meta_train(节选)train_dataloader = DATA.DataLoader(train_data, batch_size=1, shuffle=True) # ★ batch=1!for epoch in range(self.model_params["epoch"]): for episode, sample in enumerate(tqdm.tqdm(train_dataloader)): self.optimizer.zero_grad() loss, output = self.net.proto_train(sample) # 一个 episode = 一个"任务" loss.backward() self.optimizer.step()注意 batch_size=1:一个 episode(5 类 × 15 样本 = 75 个信号)就是一个 batch。因为每个 episode 结构不同(类组合不同),无法按普通方式堆 batch——这是元学习数据加载器的典型形态。
8.4 为什么 Transformer 特别适合元学习(设计动机收尾)
- 归纳偏置弱:卷积假设平移不变、RNN 假设时间递推——Transformer 只有”位置编码 + 全连接交互”,学到的表示任务通用性更强,迁移到新类不掉链子;
- 序列长度弹性:注意力天然处理变长(配合第 4 章的填充/裁剪),不同帧长都能编码进同一个度量空间;
- 嵌入质量高:cls token 的”会议纪要”式聚合(7.1 节)产出稳定的序列级嵌入,正是原型网络要的。
三者相乘 = “Transformer 提供通用嵌入,原型网络提供零重训协议”——Meta-Transformer 的全部设计逻辑闭环。
第 9 章 端到端走读:两个项目的完整前向流程
把前七章的知识点串成两条完整流水线。每个形状都标出来,可以随时回头核对。
9.1 Meta-Transformer:从 HDF5 到小样本准确率
① HDF5 原始样本 X[i]: (1024, 2) 数据集布局: 时间 × I/Q② dataset transpose → (2, 1024) 转成 I/Q × 时间③ FewShotDataset 抽 episode sample = {类0: {support:[5 个 (2,1024)], query:[10 个]}, ... 共 5 类}④ proto_train 摊平 x_support: (75, 2, 1024) x_query: (50, 2, 1024)⑤ [设计意图] 补通道维 (75, 1, 2, 1024) ViT 入口期望 4D(7.4 坑 2)⑥ PatchEmbedding(2×16) (75, 36, 1, 64) → (75, 64, 36) 64 个 IQ patch token⑦ + cls_token (75, 65, 36) cls 占位 0⑧ + pos_embed(可学习) (75, 65, 36) 长度 65 固定⑨ 8 × ViTBlock (75, 65, 36) Pre-LN 自注意力 ×8 层⑩ norm(x[:,0]) (75, 36) cls 嵌入⑪ fc(repo 实际含此层) (75, 24) ★ 7.4 坑 1: 嵌入实为 24 维 logits⑫ 原型 (5, 24) support 按类取均值⑬ cdist (50, 5) 查询 × 原型距离矩阵⑭ log_softmax(-dists) (50, 5) → 概率⑮ 交叉熵 + 准确率 标量 与真值 (50,) 比较9.2 MCDformer:从 (B,2,128) 到 11 类
① 输入 IQ (B, 2, 128)② unsqueeze(1) (B, 1, 2, 128) 视作 1 通道"矮图像"③ FDDM 频域去噪 (B, 1, 2, 128) I+jQ → FFT → MLP 增益 → IFFT → +残差④ Conv_Block(1→36) (B, 36, 2, 128) 核(1,3) 只沿时间⑤ Conv_Block(36→64) (B, 64, 1, 128) ★ 核(2,3): I/Q 融合成 1 行⑥ Conv_Block(64→128) (B, 128, 1, 128)⑦ Conv_Block(128→256) (B, 256, 1, 128) 通道 256⑧ squeeze(2) (B, 256, 128) 256 token × 128 维 ← 转置语义!⑨ Block(dim=128) (B, 256, 128) 注意力矩阵 (B,H,256,256): 通道交互⑩ ConvLayer(256) (B, 256, 64) Conv1d(循环填充)+ELU+MaxPool: 时间减半⑪ Block2(dim=64) (B, 256, 64) 第二级通道注意力⑫ x[:, :, -1:] (B, 256, 1) 末位读出⑬ classifier (B, 11) Linear(256→256)→PReLU→Linear(256→11)对照两条流水线的分工:Meta-Transformer 的”卷积”只有一步(patch 投影),深度全在 8 层 Transformer;MCDformer 的”卷积”有四层(stem)+ 两级池化,Transformer 只有 2 层。同样的任务,先验放在哪里(卷积 vs 注意力)是特化设计里最大的结构性决策——第 11 章的决策树会给出选择依据。
第 10 章 实战复刻:两个精简完整版
完整可运行的精简复刻,吸收两个项目的全部设计思想(本教程整理版,非逐行复刻)。 每个代码块自包含,默认 GPU 运行,几分钟内跑完。强烈建议亲手跑一遍并改动参数观察。
10.1 MiniMetaTransformer:小样本识别未见调制(完整可运行)
import mathimport torchimport torch.nn as nnimport torch.nn.functional as F
torch.manual_seed(0)
# ---- 设备选择:默认 GPU ----# set_default_device 让之后创建的所有张量/模块自动落在 GPU 上,正文代码无需再写 .to()/.cuda()torch.set_default_device('cuda')print("默认设备: cuda ——", torch.cuda.get_device_name(0))
# ================= 迷你 ViT 编码器(Meta-Transformer 风格) =================class IQPatchEmbed(nn.Module): """2×16 patch:一个 token 同时含 I/Q 两行(第 2.1 节)。""" def __init__(self, patch_len=16, embed_dim=36): super().__init__() self.proj = nn.Conv2d(1, embed_dim, kernel_size=(2, patch_len), stride=(2, patch_len))
def forward(self, x): # (B,1,2,L) → (B,N,E) return self.proj(x).flatten(2).transpose(1, 2)
class MHA(nn.Module): """多头注意力(教学文档 3.4 节精简版)。""" def __init__(self, d, H): super().__init__() self.H, self.dh = H, d // H self.qkv = nn.Linear(d, 3 * d) self.proj = nn.Linear(d, d)
def forward(self, x): B, N, d = x.shape qkv = self.qkv(x).reshape(B, N, 3, self.H, self.dh).permute(2, 0, 3, 1, 4) q, k, v = qkv[0], qkv[1], qkv[2] a = (q @ k.transpose(-2, -1)) * (self.dh ** -0.5) a = a.softmax(-1) return self.proj((a @ v).transpose(1, 2).reshape(B, N, d))
class Block(nn.Module): """Pre-LN 块(第 5.1 节 A 式)。""" def __init__(self, d, H): super().__init__() self.n1 = nn.LayerNorm(d); self.attn = MHA(d, H) self.n2 = nn.LayerNorm(d) self.ffn = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))
def forward(self, x): x = x + self.attn(self.n1(x)) x = x + self.ffn(self.n2(x)) return x
class MiniViTEncoder(nn.Module): """(B,2,L) → (B,E) 嵌入。★ 没有分类头——度量学习专用(第 7.2 节)。"""
def __init__(self, L=1024, patch_len=16, embed_dim=36, n_heads=4, n_layers=2): super().__init__() n_patches = L // patch_len self.patch = IQPatchEmbed(patch_len, embed_dim) self.cls = nn.Parameter(torch.zeros(1, 1, embed_dim)) # cls token self.pos = nn.Parameter(torch.zeros(1, n_patches + 1, embed_dim)) self.blocks = nn.ModuleList([Block(embed_dim, n_heads) for _ in range(n_layers)]) self.norm = nn.LayerNorm(embed_dim)
def encode(self, x): x = self.patch(x.unsqueeze(1)) # (B,N,E) x = torch.cat([self.cls.expand(x.size(0), -1, -1), x], dim=1) x = x + self.pos for blk in self.blocks: x = blk(x) return self.norm(x[:, 0]) # cls 嵌入 (B,E)
# ================= 数据: 5 类复指数 IQ 信号(模拟不同"调制") =================def make_iq(n_per_class=200, L=1024, spec=None, noise=0.3): """类别由 (频率周期数, 振幅) 区分,加复高斯噪声。""" if spec is None: spec = [(4, 1.0), (8, 1.0), (16, 1.0), # 训练类 (12, 1.0), (24, 1.0)] # ★ 测试类: 元训练时从未见过 t = torch.arange(L).float() / L * 2 * math.pi xs, ys = [], [] for c, (f, amp) in enumerate(spec): phase = torch.rand(n_per_class, 1) * 2 * math.pi s = amp * torch.exp(1j * (f * t + phase)) # (n, L) s = s + noise * (torch.randn(n_per_class, L) + 1j * torch.randn(n_per_class, L)) # 噪声 iq = torch.stack([s.real, s.imag], dim=1) # (n, 2, L) xs.append(iq) ys.append(torch.full((n_per_class,), c, dtype=torch.long)) return torch.cat(xs), torch.cat(ys)
# ================= 元学习组件(第 7.5 / 8.2 节) =================def make_episode(xs, ys, class_ids, n_sup=5, n_q=10): """从指定类别抽一个 episode: support 与 query 互斥。""" Sx, Sy, Qx, Qy = [], [], [], [] for c in class_ids: idx = (ys == c).nonzero().flatten() idx = idx[torch.randperm(len(idx))] Sx.append(xs[idx[:n_sup]]); Sy += [c] * n_sup Qx.append(xs[idx[n_sup:n_sup + n_q]]); Qy += [c] * n_q return (torch.cat(Sx), torch.tensor(Sy), torch.cat(Qx), torch.tensor(Qy))
def proto_loss(encoder, x_s, y_s, x_q, y_q): """原型网络损失(第 7.2 节公式):原型 = support 嵌入均值,按最近原型分类。""" z_s, z_q = encoder.encode(x_s), encoder.encode(x_q) classes = torch.unique(y_s) protos = torch.stack([z_s[y_s == c].mean(0) for c in classes]) # (n_way, E) remap = {c.item(): i for i, c in enumerate(classes)} # 类 id 重排 y_q_r = torch.tensor([remap[v] for v in y_q.tolist()]) dists = torch.cdist(z_q, protos) loss = F.cross_entropy(-dists, y_q_r) acc = (-dists).argmax(1).eq(y_q_r).float().mean() return loss, acc
# ================= 主流程 =================def run_meta_demo(): xs, ys = make_iq() train_classes = [0, 1, 2] # 元训练只见过这 3 类 test_classes = [3, 4] # ★ 这 2 类整个训练过程从未出现
enc = MiniViTEncoder(L=1024) opt = torch.optim.Adam(enc.parameters(), lr=1e-3)
# ---- 元训练: 每个 episode 都是一个新的 3-way 小任务 ---- for ep in range(200): loss_acc = 0.0 for _ in range(4): # 每步 4 个 episode cids = torch.randperm(len(train_classes))[:3].tolist() xs_s, ys_s, xs_q, ys_q = make_episode(xs, ys, cids) loss, acc = proto_loss(enc, xs_s, ys_s, xs_q, ys_q) loss.backward() loss_acc += acc.item() / 4 opt.step(); opt.zero_grad() if ep % 40 == 0 or ep == 199: print(f"episode {ep:3d} 训练 acc = {loss_acc:.3f}")
# ---- 元测试: 对从未见过的 2 类做 2-way 5-shot(随机猜测 = 0.500)---- accs = [] enc.eval() for _ in range(100): xs_s, ys_s, xs_q, ys_q = make_episode(xs, ys, test_classes) with torch.no_grad(): _, acc = proto_loss(enc, xs_s, ys_s, xs_q, ys_q) accs.append(acc.item()) print(f"\n★ 未见类 2-way 5-shot 准确率: {sum(accs)/len(accs):.3f} " f"(随机 = 0.500,监督学习 = 0.000)")
if __name__ == "__main__": run_meta_demo()预期现象:训练 acc 从 0.4 爬升到 0.9+;测试准确率显著高于随机 0.5(典型 0.65~0.85,随种子浮动)。关键点:测试的 2 个类从未参与训练,模型却能从 5 个 support 样本里”现场学会”区分它们——这就是元学习 + 原型网络的核心价值。
改动实验(每个都值得做):① n_sup=1 观察 1-shot 准确率;② noise=0.6 低信噪比下的表现;③ 给 MiniViTEncoder 加回分类头改成监督学习,比较两类任务的训练曲线。
10.2 MiniMCDformer:频域去噪 + 通道注意力(完整可运行,含消融)
import mathimport torchimport torch.nn as nnimport torch.nn.functional as F
torch.manual_seed(0)
# ---- 设备选择:默认 GPU ----# set_default_device 让之后创建的所有张量/模块自动落在 GPU 上,正文代码无需再写 .to()/.cuda()torch.set_default_device('cuda')print("默认设备: cuda ——", torch.cuda.get_device_name(0))
# ================= MiniMCDformer: FDDM + Conv stem + 通道注意力 =================class FDDM(nn.Module): """频域去噪(第 6.2 节):FFT → 学习频域增益(Tanh 限幅) → IFFT → 残差。"""
def __init__(self, sig_len: int): super().__init__() self.filter = nn.Sequential( nn.Linear(sig_len, sig_len // 4), nn.ReLU(), nn.Linear(sig_len // 4, sig_len), nn.Tanh())
def forward(self, x): # (B,1,2,L) r = x[:, :, 0, :] + 1j * x[:, :, 1, :] # 复信号 R = torch.fft.fft(r, dim=-1) R_stack = torch.stack([R.real, R.imag], dim=2) # (B,1,2,L) h = self.filter(R_stack) # 频域增益 R_hat = h[:, :, 0, :] * R_stack[:, :, 0, :] \ + 1j * (h[:, :, 1, :] * R_stack[:, :, 1, :]) x_hat = torch.fft.ifft(R_hat, dim=-1).real.unsqueeze(2) return x_hat + x # 残差安全网
class ConvBlock(nn.Module): """stem 卷积块(第 2.3 节):只补时间维,保持长度。"""
def __init__(self, cin, cout, k): super().__init__() self.net = nn.Sequential( nn.ZeroPad2d((1, 1, 0, 0)), nn.Conv2d(cin, cout, kernel_size=k), nn.ReLU(), nn.BatchNorm2d(cout))
def forward(self, x): return self.net(x)
class ChannelAttnBlock(nn.Module): """通道注意力块(第 3.5 节):输入 (B,C,L),token=通道,特征维=时间。"""
def __init__(self, sig_len: int, n_heads: int = 2, dropout: float = 0.2): super().__init__() self.n1 = nn.LayerNorm(sig_len) self.attn = nn.MultiheadAttention(sig_len, n_heads, # embed_dim = 时间长度! dropout=dropout, batch_first=True) self.n2 = nn.LayerNorm(sig_len) self.ffn = nn.Sequential(nn.Linear(sig_len, sig_len), nn.GELU(), nn.Dropout(dropout), nn.Linear(sig_len, sig_len))
def forward(self, x): x = x + self.attn(self.n1(x), self.n1(x), self.n1(x))[0] # 通道间交互 x = x + self.ffn(self.n2(x)) # 时间维混合 return x
class MiniMCDformer(nn.Module): """FDDM → Conv stem → 通道注意力 → 时间池化 → 通道注意力 → 末位读出。"""
def __init__(self, L=128, num_classes=4, use_fddm=True): super().__init__() self.use_fddm = use_fddm self.fddm = FDDM(L) self.stem = nn.Sequential( ConvBlock(1, 32, (1, 3)), # (B,32,2,L) ConvBlock(32, 64, (2, 3)), # ★ 核高 2: I/Q 融合 → (B,64,1,L) ConvBlock(64, 128, (1, 3)), # (B,128,1,L) ) self.blk1 = ChannelAttnBlock(L) # 128 通道交互 self.down = nn.Sequential( # 时间维减半 nn.Conv1d(128, 128, 3, padding=1, padding_mode="circular"), nn.BatchNorm1d(128), nn.ELU(), nn.MaxPool1d(3, 2, 1)) self.blk2 = ChannelAttnBlock(L // 2) self.head = nn.Sequential(nn.Linear(128, 64), nn.PReLU(), nn.Linear(64, num_classes))
def forward(self, x): # (B,2,L) x = x.unsqueeze(1) # (B,1,2,L) if self.use_fddm: x = self.fddm(x) # 频域去噪(可关 = 消融) x = self.stem(x) # (B,128,1,L) x = x.squeeze(2) # (B,128,L) ← 通道即 token x = self.blk1(x) x = self.down(x) # (B,128,L/2) x = self.blk2(x) x = x[:, :, -1] # 末位读出 (B,128) return self.head(x) # (B,num_classes)
def make_iq_short(n_per_class=400, L=128, freqs=(4, 8, 16, 32), noise=0.8): """4 类不同频率复指数 + 强噪声(低 SNR 场景,考验去噪能力)。""" t = torch.arange(L).float() / L * 2 * math.pi xs, ys = [], [] for c, f in enumerate(freqs): phase = torch.rand(n_per_class, 1) * 2 * math.pi s = torch.exp(1j * (f * t + phase)) s = s + noise * (torch.randn(n_per_class, L) + 1j * torch.randn(n_per_class, L)) iq = torch.stack([s.real, s.imag], dim=1) # (n,2,L) xs.append(iq); ys.append(torch.full((n_per_class,), c, dtype=torch.long)) return torch.cat(xs), torch.cat(ys)
def train_mini_mcd(use_fddm=True, steps=400, seed=0): torch.manual_seed(seed) xs, ys = make_iq_short() n_tr = int(len(ys) * 0.8) x_tr, y_tr = xs[:n_tr], ys[:n_tr] x_te, y_te = xs[n_tr:], ys[n_tr:]
model = MiniMCDformer(L=128, num_classes=4, use_fddm=use_fddm) opt = torch.optim.Adam(model.parameters(), lr=1e-3) for step in range(steps): idx = torch.randint(0, len(x_tr), (64,)) opt.zero_grad() loss = F.cross_entropy(model(x_tr[idx]), y_tr[idx]) loss.backward(); opt.step()
model.eval() with torch.no_grad(): acc = (model(x_te).argmax(1) == y_te).float().mean() return acc.item()
if __name__ == "__main__": acc_on = train_mini_mcd(use_fddm=True) acc_off = train_mini_mcd(use_fddm=False) print(f"低 SNR 4 类识别: 带 FDDM acc = {acc_on:.3f} 无 FDDM acc = {acc_off:.3f}") print("(随机猜测 = 0.250;FDDM 差距随噪声增大而扩大)")预期现象:默认参数下带/不带 FDDM 都能学满 1.0——任务对纯卷积已经足够简单(四类靠频率区分,卷积核天然会挑频率),此时域先验没有用武之地;把 noise 加大或把类别改成噪声敏感的结构(如相位差异、谐波组合)后再对比,两种配置的差距才会显现。这本身就是域先验的第一课:它的价值随任务难度上升而上升,任务简单时先验模块可能毫无帮助甚至添乱。
改动实验:① noise 从 0.2 调到 1.5,观察两种模型的退化速度;② 把 ChannelAttnBlock 换成普通时间注意力(转置一次),比较”通道 token”与”时间 token”的差异;③ 把 Tanh 换成无界输出,观察训练数值问题。
10.3 两个复刻的对照总结
| MiniMetaTransformer | MiniMCDformer | |
|---|---|---|
| 数据 | 长信号 L=1024、噪声 0.3 | 短信号 L=128、强噪声 0.8 |
| token 化 | 2×16 patch(一步卷积) | 三层卷积 stem + I/Q 融合 |
| 注意力作用域 | 时间 patch | 特征通道(转置) |
| 前端 | 无 | FDDM 频域去噪 |
| 任务头 | 无头(原型度量) | MLP 分类头 |
| 训练 | episode 元学习 | 监督学习 |
| 演示的核心概念 | 开放集小样本识别 | 域先验 + 转置注意力 |
两个迷你模型 = 七轴地图的两种极端配置。你的任务大概率落在中间——这正是第 11 章决策树的用途。
第 11 章 方法论总结:checklist、反模式与决策树
前面十章已经把”两个项目怎么做”和”你自己怎么做”讲透了。本章把所有内容收敛成三件可以直接使用的工具:
- 八步 checklist——拿到一个新任务,按顺序过一遍,就能得到一份完整的设计方案;
- 反模式清单——九条前人(包括这两个项目自己)踩过的坑,写代码前先对照自查;
- 特化决策树——在几个关键分叉点上给出选择依据。
11.1 特化设计八步 checklist
每一步都对应前面某章的详细内容。建议按顺序执行,因为后一步的决策依赖前一步的输出。
第 1 步:明确任务与数据模态
在写任何代码之前,先用一段话回答四个问题:
| 问题 | 示例(AMC) | 示例(一般序列) |
|---|---|---|
| 输入是什么? | IQ 采样 (2, L),两行永远成对 | 词序列、传感器多通道 |
| 输出是什么? | 11/24 类调制类型 | 类别 / 数值 / 序列 |
| 评估时会出现训练没见过的类吗? | 会(5 个 hold-out 类) | 开放集 / 闭集 |
| 数据的难点在哪里? | 低 SNR、类别混淆 | 长序列、数据量小 |
这一步的产出直接决定第 2、7、8 步:输出开放 → 走元学习(第 8 章);难点在噪声 → 走域先验前端(第 6 章);难点在长序列 → 走 patch + 层级设计(第 2、4 章)。
判断标准:如果你无法一句话说清”输入长什么样、输出长什么样”,后面的任何设计都是猜。
第 2 步:设计 Tokenization(第 2 章)
核心问题:一个 token 代表什么物理量?
- 数出输入的维度和语义。IQ 是 (2, L):第一维只有 2 且语义绑定(I 与 Q 是同一个时刻的投影)。
- 决定 token 的粒度:
- 序列短(< 512)→ 逐点 token,
nn.Embedding或线性投影即可; - 序列长(≥ 1024)→ patch 化,patch 尺寸要覆盖一个”语义完整”的单元(AMC 里约一个符号周期 8~16 点);
- 有自然分组 → 分组就是 patch(图像 16×16、IQ 的 2×16)。
- 序列短(< 512)→ 逐点 token,
- 把不可拆分的维度塞进单个 patch 的第一维(IQ patch 的第一维是 2,而不是 1——第 2.4 节的
IQPatchEmbed)。 - 决定 stem 的深度:一步卷积(Meta-Transformer)还是多层卷积 stem(MCDformer 四层)。判据见 11.3 决策树。
验收:画出 (输入形状) → (token 数 N, token 维度 C) 的完整流程,并写一个 5 行代码的 shape 测试(第 2.5 节的 test_patch_embed)。
第 3 步:选择注意力作用域(第 3 章)
核心问题:注意力矩阵的 N×N 里,N 是谁?
- 默认:N = 时间/位置,token = 每个时刻(标准用法)。
- 转置用法:N = 特征通道,token = 每个通道的整条时间曲线(MCDformer,第 3.2 节)。
- 何时考虑转置:
- 时间维度很长但通道数中等(256 通道 vs 128 时间 → N=256 更便宜且语义明确);
- 通道之间有物理意义的依赖(特征图通道是不同滤波器的响应,注意力 = 让通道互相协商);
- MLP 会自动交换角色:转置后 MLP 沿时间混合(第 3.3 节的对照表)。
- 注意力矩阵大小 = ,先算内存再定架构(第 5 章容量设计)。
验收:写出注意力矩阵的形状 (B, H, N, N),并说明”第 i 行第 j 列的分数代表什么物理含义”。
第 4 步:定位置编码与长度策略(第 4 章)
- 可学习 or 正弦:
- 长度固定、数据集够大 → 可学习(两个项目都如此);
- 长度变化大、需要外推 → 正弦(理论可外推);
- 干脆不要:模型含卷积层时卷积自带位置感(MCDformer 无位置编码)。
- 处理变长:
- 裁剪:
x = x[..., :max_len](元学习子编码器用 1024→128 裁剪); - 插值:
F.interpolate后接裁剪/填充(主编码器,第 4.3 节); - 填充:
self_duplicate(时域重复)或补零(频域插值),频域分箱场景优先补零(第 4.2 节的频域物理解释)。
- 裁剪:
- cls token 用不用:分类任务用 cls(读出简单、注意力全局),无头度量学习也必须有一个”整条序列的表示”。
验收:同一个模型能处理你数据集中最短和最长的样本,不报错且位置编码维度对齐。
第 5 步:设计块结构与容量(第 5 章)
- Pre-LN or Post-LN:
- 现代默认 Pre-LN(
norm_first=True/x = norm(x + attn(norm(x)))); - Post-LN 只在非常浅(≤ 3 层)时用,且必须 warmup。
- 现代默认 Pre-LN(
- mlp_ratio 的实际含义先确认:是比例(hidden = dim × ratio,如 36×32=1152,vit.py 的用法)还是绝对值(hidden = 256,MCDformer 的用法)。这是两个项目之间最容易抄错的参数(第 5.2 节)。
- 容量设计公式(第 5.3 节):
先算内存是否放得下,再调层数。注意力内存 ≈ H × N × N × 4 字节MLP 参数量 ≈ 2 × C × hidden × 层数
- dropout 不是默认 0.1:数据难、模型易过拟合 → 调到 0.3~0.5(MCDformer 用 0.5);数据充足 → 0.1。
- 归一化种类:CV 风格块可用 BatchNorm(CTDNN 用 BN+tanh 替代 LN+GELU),序列任务默认 LayerNorm。
验收:打印 sum(p.numel() for p in model.parameters()) 与注意力矩阵大小,确认都在预算内。
第 6 步:插入域先验前端(第 6 章)
这是 AMC 特化与 NLP 特化的最大分野。 问自己:我的领域里有没有”模型不易学、但数学上明确”的结构?
- AMC:频域去噪(FDDM)——“频域加权 = 滤波”(卷积定理),TinyMLP 学增益曲线;
- 候选清单:STFT 幅度谱、小波分解(AWN)、循环相关(AdaCorr)、差分特征……
- 实现模板(第 6.4 节的通用范式):
变换域 T → 可学习增益 g(x)(有界激活)→ 逐元素乘 → 逆变换 T⁻¹ → 残差连接
- 验证方式:消融实验(带/不带前端,第 10.2 节的
train_mini_mcd(use_fddm=...))。域先验在低 SNR / 小数据时收益最大。
验收:前端模块单独测试时,输出与输入的物理量纲一致(FDDM 输出仍是 IQ 信号)。
第 7 步:定读出与任务头(第 7 章)
- 读出方式四选一(第 7.1 节):
- cls token(ViT 标准)→ 分类头
nn.Linear(dim, num_classes); - 平均池化(Encoder-only 常用);
- 末元素(MCDformer
x[:, :, -1:]——与 stem 的 circular padding 配套); - 无头(度量学习,第 7.2 节)。
- cls token(ViT 标准)→ 分类头
- 分类 vs 度量:
- 闭集、类别固定 → fc 分类头 + 交叉熵;
- 开放集 / 测试有未见类 → 摘掉 fc 头,输出 embedding + 原型网络(第 7.3 节)。
- 两个代码陷阱必须自查(第 7.4 节):
- 陷阱一:复用带 fc 头的预训练模型当特征提取器 → 输出维度是 num_classes 而不是 embedding 维度(proto.py 的教训);
- 陷阱二:形状契约——监督路径 4D (B,1,2,L)、少样本路径 3D (2,L),数据管线两侧必须一致。
验收:embedding 维度与你设计的 latent_dim 一致(不是 num_classes);全流程一个 batch 跑通。
第 8 步:定训练范式与评估协议(第 8 章)
- 监督学习:固定 train/val/test 划分,早停(patience 10~60),阶梯或余弦学习率。
- 元学习:
- 类别划分:训练类 / 测试类不相交(12/5),否则测试无意义;
- episode 采样:每个 episode 随机 n_way 类 × (n_support + n_query) 样本;
- 测试协议:固定 n_way(如 5-way-5-shot),多次采样取平均 ± 方差(第 8.4 节);
- 主循环 batch_size=1(一个 episode 即一个 batch,train.py 的写法)。
- 报告指标:AMC 论文惯例——总体准确率 + 混淆矩阵 + 按 SNR 分解的准确率曲线(低 SNR 才是区分模型好坏的地方)。混淆矩阵是”真实类别 × 预测类别”的统计表:第 i 行第 j 列 = 真实第 i 类被判成第 j 类的样本数,对角线越亮分类越好,哪两类容易混淆(如 QAM16 混 QAM64)一眼可见。
验收:测试集上的评估协议在论文附录里能用两句话描述清楚,且可复现。
11.2 反模式清单
以下九条全部来自真实代码(包括这两个项目自己的代码)。写代码前对照一遍,等于提前排掉 90% 的雷。
反模式 1:照搬图像的 16×16 patch
- 症状:对 IQ 信号用
patch_size=(16, 16)或把 (2, L) 展平成 (1, 2L) 后按 16 分块。 - 为什么错:IQ 第一维只有 2 且语义绑定(第 2.2 节);16×16 会把 I/Q 强行拆开,token 不再是”一个时刻的完整信息”。
- 正确做法:patch 第一维 = 2(绑定 I/Q),第二维 = 8~16(一个符号周期),如 2×16。
反模式 2:拆分 I/Q 双分支
- 症状:I 走一条网络、Q 走另一条,最后 concat。
- 为什么错:I 和 Q 只是同一个复包络的投影,分开处理会丢失相位关系(星座图是二维的,分开看就是两根无关的一维曲线)。注意区分:双分支 ≠ 在 patch 内保持 I/Q 成对;MCDformer 的 (2,3) 卷积是”融合”不是”拆分”。
- 正确做法:I/Q 从输入到 patch 始终保持相邻两行,融合发生在卷积或注意力内部。
反模式 3:位置编码写死长度
- 症状:
self.pos = nn.Parameter(torch.randn(1, 65, dim)),x = x + self.pos——一旦输入 token 数 ≠ 65 直接维度报错。 - 为什么错:下游任务或数据增强会改变序列长度(meta 里 128→1024 的差异)。
- 正确做法:像 vit.py 那样
pos_embed[:, :N]裁剪,或F.interpolate插值(第 4.3 节)。写一个变长输入的单测。
反模式 4:复用模型却忘摘 fc 头
- 症状:加载预训练 ViT(含
fc)当特征提取器,embedding 变成 24 维 logits(proto.py 的真实代码)。 - 为什么错:度量学习要的是”位置语义”,24 维 logits 已经被类别绑定,open-set 测试类根本没有对应 logit(第 7.4 节陷阱一)。
- 正确做法:
load_protonet时明确删掉/旁路fc,输出latent_dim维 embedding,并打印形状验证。
反模式 5:形状契约两边不一致
- 症状:监督训练用 4D
(B, 1, 2, L),少样本评估用 3D(2, L),模型forward只接受其一。 - 为什么错:两条数据路径各自”能用”(因为各写各的),但端到端一拼就炸(第 7.4 节陷阱二)。
- 正确做法:数据管线的输出形状在文档里写成契约,两条路径共享同一个
__getitem__的变换函数。
反模式 6:dropout 永远 0.1
- 症状:小数据 + 高噪声任务上模型严重过拟合,验证曲线训练后期翘起。
- 为什么错:0.1 是 ImageNet 级别的正则量;AMC 数据(每类几百样本)需要 0.3~0.5(MCDformer 的配置)。
- 正确做法:把 dropout 当作超参调,或直接按数据量设定:< 1 万样本 → 0.3 起步。
反模式 7:转置 token 却沿用时间维的归一化假设
- 症状:转置后 N=通道,却在 token 维(=通道维)上用 BatchNorm,或在时间维(特征维)上用 LayerNorm 且没意识到两者换了位置。
- 为什么错:转置用法里特征维是时间(第 3.3 节)。沿 token 维的 BN 会把”每个通道的平均响应”归零,破坏通道间可比较性;LN 归一化的是时间维,恰好符合”每个通道整条曲线独立归一化”的物理直觉。
- 正确做法:转置后归一化选 LayerNorm(沿 C=时间维),写注释标明”此处 C 是时间”。
反模式 8:mlp_ratio 含义混淆
- 症状:从 vit.py 抄来
mlp_dim=32,以为 hidden 是 32,实际 hidden = 36×32 = 1152(第 5.2 节)。 - 为什么错:不同代码库的
mlp_dim有的是绝对值(MCDformer dim=128、mlp_ratio=1 → hidden=128),有的是比例(vit.py → 1152)。 - 正确做法:抄任何配置前先读
MLP.__init__的乘法语句,确认hidden = dim * ratio还是hidden = ratio。
反模式 9:在长序列上无节制地全局注意力
- 症状:L=1024 的逐点 token → N=1024 → 注意力矩阵 元素 × 4 字节 × H 头 = 内存爆炸,batch 只能开到 1。
- 为什么错:复杂度 (第 5.3 节容量公式)。
- 正确做法:patch 化压缩 N(1024 → 64),或转置注意力把 N 换成通道数(256 也是大 N,需配合层级降维),或两级编码器(第 4.1 节)。
11.3 特化决策树
把 checklist 里最关键的几个分叉抽出来,画成一棵决策树。每个叶子都指向前面章节的具体实现。
拿到新任务│├─[输入序列长度 > 512?]│ ├─ 否 ──► 逐点 token(第 2 章基础投影)│ │ ││ │ └─[通道数 >> 时间点数?] ── 是 ──► 转置通道注意力(第 3.2 节)│ ││ └─ 是 ──► patch 化(第 2.4 节 IQPatchEmbed)│ ││ └─[单个 patch 覆盖一个语义单元?]│ ├─ 否 ──► 多层卷积 stem 先压缩(MCDformer 式,第 2.6 节)│ └─ 是 ──► 一步 patch 投影(ViT 式)│├─[训练样本数 < 每类 500?]│ ├─ 是 ──► dropout ≥ 0.3(反模式 6)+ 考虑预训练│ └─ 否 ──► dropout 0.1,常规训练│├─[评估时出现训练未见类?]│ ├─ 是 ──► 元学习 + 原型网络(第 7、8 章)│ │ ├─ 摘掉 fc 头(反模式 4)│ │ └─ episode 协议:测试类与训练类不相交│ └─ 否 ──► 监督学习│ └─[读出] cls(ViT 式)/ 末元素(MCDformer 式)/ 平均池化│├─[领域有"数学明确但模型难学"的结构?]│ ├─ 是 ──► 域先验前端(第 6 章通用范式:变换→增益→乘→逆变换→残差)│ │ └─ 必做消融:带/不带前端对比(第 10.2 节)│ └─ 否 ──► 纯 Transformer,先跑通再谈特化│└─[先验该放在哪里?](结构性大决策,第 9 章对照) ├─ 数据难、噪声大、样本少 ──► 先验放卷积/频域前端(MCDformer 式) └─ 数据足、靠长程依赖 ──► 深度留给 Transformer(Meta-Transformer 式)决策树的三个速记结论:
- 长信号 → patch;短信号且通道多 → 转置注意力。 两条路都通向”把 N 控制在一个合理量级”(64~256)。
- 开放集 → 摘头 + 原型;闭集 → 留头。 这是第 7 章陷阱一的根源,先想清楚再加载任何预训练权重。
- 域先验的收益与数据难度成正比。 低 SNR、小样本时 FDDM 这类模块价值最大;数据又大又干净时,纯 Transformer 自己就能学到等价的东西。
11.4 一份可直接套用的设计模板
把八步 checklist 压缩成一份”填空题”模板。拿到新任务,30 分钟填完,你的特化设计就成型了:
## 特化设计单(任务名:________)
1. 数据:输入 (___, ___) = (通道, 长度),输出 ___ 类,难点 = ______2. Token:patch 尺寸 (___ , ___);N = ___,C = ___;stem = 一步投影 / 多层卷积3. 注意力:N 是 ______(时间/通道),注意力矩阵 (B, H, ___, ___), 第 i 行第 j 列的含义 = ________________4. 位置:可学习 / 正弦 / 无(卷积自带);变长处理 = 裁剪 / 插值 / 填充5. 块:Pre-LN / Post-LN;mlp hidden = ___(确认是比例还是绝对值); 层数 ___;dropout ___;参数量 ≈ ___ M6. 前端:变换域 ______ → 增益 ______ → 逆变换 ______;消融实验 = 有/无7. 读出:cls / 平均 / 末元素 / 无头;embedding 维 = ___;fc 头:有 / 无8. 训练:监督 / 元学习;类别划分 ______;测试协议 ______(n-way n-shot)填完这张单,剩下的就是照着第 2~10 章的代码模板写实现。
第 12 章 结语
12.1 本书做了什么
这本书用两个真实的 AMC 论文项目作为标本,把”特化一个 Transformer”这件事拆成了七个轴:
| 轴 | 标准 Transformer | Meta-Transformer 的选择 | MCDformer 的选择 |
|---|---|---|---|
| Tokenization | 词向量 / 展平 patch | 2×16 IQ patch | 四层卷积 stem + I/Q 融合 |
| 注意力作用域 | 时间步(N=序列) | 时间 patch(N=64) | 特征通道(N=256,转置) |
| 位置与长度 | 正弦 / 可学习 | 可学习 + 裁剪/插值 | 无(卷积自带) |
| 块结构 | Post-LN | Pre-LN | Pre-LN + 强 dropout |
| 域先验前端 | 无 | 无 | FDDM 频域去噪 |
| 读出与任务头 | fc 分类 | 无头(原型度量) | 末元素 + MLP |
| 训练范式 | 监督 | episode 元学习 | 监督 |
每一行都是一个独立的”旋钮”。特化 = 根据你的任务,在每一行上做出有理由的选择——而不是整体照搬某个 SOTA 模型。
12.2 核心心法三句
-
“token 是什么物理量”是所有设计的起点。 想清楚这一个问题,Tokenization、注意力作用域、位置编码三个轴自动就位。IQ 信号的 token 是”一段时间内的复包络片段”,不是”2×16 像素块”——同一个 patch 尺寸,背后的物理理解完全不同。
-
先验放哪里,取决于数据和模型的相对强弱。 数据困难(噪声、少样本)→ 把先验写成显式模块(FDDM、卷积 stem);数据充足 → 让 Transformer 自己学,先验只体现在 patch 设计和位置编码这些”软约束”上。
-
形状即契约,验证要趁早。 两个项目里最隐蔽的 bug 全是形状问题(3D vs 4D、65 vs 64 位置、24 维 vs 36 维 embedding)。每写完一个模块就打印一次
x.shape,比你事后用三天调参debug 便宜得多。
12.3 从读懂到做出自己的特化
建议的进阶路径:
- 先把第 10 章的两个迷你模型跑起来(MiniMetaTransformer、MiniMCDformer,各约 150 行,CPU 可跑)。它们刻意保留了原项目的核心设计而砍掉了工程噪音,是理解全文最快的入口。
- 对迷你模型做第 10 章的”改动实验”:换噪声水平、换注意力作用域、拆掉 FDDM——亲手观察每个旋钮的效应。
- 填写 11.4 节的设计单,套用你的真实任务。
- 对照第 2~9 章的模板逐模块实现,每完成一个模块跑一次 shape 测试。
- 回到原项目源码,这时候再看
vit.py、MCDformer.py,你会发现自己能逐行说出每个设计决策的动机——这就是”看懂论文代码”和”能自己设计”之间的那一步。
12.4 与姊妹篇的关系
本书假设你已经读过《Transformer从零实现完整教学.md》(同一目录)。那本书负责**“从零到一”:注意力为什么这样算、位置编码怎么手算、每个模块的逐行实现;本书负责”从一到多”**:当标准 Transformer 不满足你的任务时,在哪些地方、用什么方式改造它。
两本书配合使用的方式:遇到不理解的算子 → 查第一本;遇到”我该改哪里”→ 查第二本(七轴地图 + 决策树)。
12.5 最后的建议
Transformer 特化研究里最稀缺的能力不是会搭积木,而是能说出每一块积木为什么放在那里。这两个项目给出了很好的示范:
- Meta-Transformer 的每个选择都能追溯到”open-set 小样本”这个任务约束;
- MCDformer 的每个选择都能追溯到”低 SNR + 频域可解释”这个数据约束。
你论文里的每个模块,也应当能回答同一句话:“如果没有它,在什么输入上、什么指标下会变差?“——答得出来,就是合格的特化;答不出来,就只是堆砌。
祝你的下一个特化设计,从一开始就走在对的轴上。
全文完。姊妹篇:《Transformer从零实现完整教学.md》
附录:两个迷你模型的完整可运行代码(第 10 章,已在 GPU 上验证)
两个文件相互独立,分别复制保存后单独运行(默认 GPU,几分钟内跑完)。
附录 A:mini_meta_transformer.py(元学习识别未见调制)
预期输出:episode 训练准确率收敛到 1.0;未见类 2-way 5-shot 准确率约 0.99(随机猜测 = 0.5,监督学习在此任务上 = 0)。
import mathimport torchimport torch.nn as nnimport torch.nn.functional as F
torch.manual_seed(0)
# ---- 设备选择:默认 GPU ----# set_default_device 让之后创建的所有张量/模块自动落在 GPU 上,正文代码无需再写 .to()/.cuda()torch.set_default_device('cuda')print("默认设备: cuda ——", torch.cuda.get_device_name(0))
# ================= 迷你 ViT 编码器(Meta-Transformer 风格) =================class IQPatchEmbed(nn.Module): """2×16 patch:一个 token 同时含 I/Q 两行(第 2.1 节)。""" def __init__(self, patch_len=16, embed_dim=36): super().__init__() self.proj = nn.Conv2d(1, embed_dim, kernel_size=(2, patch_len), stride=(2, patch_len))
def forward(self, x): # (B,1,2,L) → (B,N,E) return self.proj(x).flatten(2).transpose(1, 2)
class MHA(nn.Module): """多头注意力(教学文档 3.4 节精简版)。""" def __init__(self, d, H): super().__init__() self.H, self.dh = H, d // H self.qkv = nn.Linear(d, 3 * d) self.proj = nn.Linear(d, d)
def forward(self, x): B, N, d = x.shape qkv = self.qkv(x).reshape(B, N, 3, self.H, self.dh).permute(2, 0, 3, 1, 4) q, k, v = qkv[0], qkv[1], qkv[2] a = (q @ k.transpose(-2, -1)) * (self.dh ** -0.5) a = a.softmax(-1) return self.proj((a @ v).transpose(1, 2).reshape(B, N, d))
class Block(nn.Module): """Pre-LN 块(第 5.1 节 A 式)。""" def __init__(self, d, H): super().__init__() self.n1 = nn.LayerNorm(d); self.attn = MHA(d, H) self.n2 = nn.LayerNorm(d) self.ffn = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))
def forward(self, x): x = x + self.attn(self.n1(x)) x = x + self.ffn(self.n2(x)) return x
class MiniViTEncoder(nn.Module): """(B,2,L) → (B,E) 嵌入。★ 没有分类头——度量学习专用(第 7.2 节)。"""
def __init__(self, L=1024, patch_len=16, embed_dim=36, n_heads=4, n_layers=2): super().__init__() n_patches = L // patch_len self.patch = IQPatchEmbed(patch_len, embed_dim) self.cls = nn.Parameter(torch.zeros(1, 1, embed_dim)) # cls token self.pos = nn.Parameter(torch.zeros(1, n_patches + 1, embed_dim)) self.blocks = nn.ModuleList([Block(embed_dim, n_heads) for _ in range(n_layers)]) self.norm = nn.LayerNorm(embed_dim)
def encode(self, x): x = self.patch(x.unsqueeze(1)) # (B,N,E) x = torch.cat([self.cls.expand(x.size(0), -1, -1), x], dim=1) x = x + self.pos for blk in self.blocks: x = blk(x) return self.norm(x[:, 0]) # cls 嵌入 (B,E)
# ================= 数据: 5 类复指数 IQ 信号(模拟不同"调制") =================def make_iq(n_per_class=200, L=1024, spec=None, noise=0.3): """类别由 (频率周期数, 振幅) 区分,加复高斯噪声。""" if spec is None: spec = [(4, 1.0), (8, 1.0), (16, 1.0), # 训练类 (12, 1.0), (24, 1.0)] # ★ 测试类: 元训练时从未见过 t = torch.arange(L).float() / L * 2 * math.pi xs, ys = [], [] for c, (f, amp) in enumerate(spec): phase = torch.rand(n_per_class, 1) * 2 * math.pi s = amp * torch.exp(1j * (f * t + phase)) # (n, L) s = s + noise * (torch.randn(n_per_class, L) + 1j * torch.randn(n_per_class, L)) # 噪声 iq = torch.stack([s.real, s.imag], dim=1) # (n, 2, L) xs.append(iq) ys.append(torch.full((n_per_class,), c, dtype=torch.long)) return torch.cat(xs), torch.cat(ys)
# ================= 元学习组件(第 7.5 / 8.2 节) =================def make_episode(xs, ys, class_ids, n_sup=5, n_q=10): """从指定类别抽一个 episode: support 与 query 互斥。""" Sx, Sy, Qx, Qy = [], [], [], [] for c in class_ids: idx = (ys == c).nonzero().flatten() idx = idx[torch.randperm(len(idx))] Sx.append(xs[idx[:n_sup]]); Sy += [c] * n_sup Qx.append(xs[idx[n_sup:n_sup + n_q]]); Qy += [c] * n_q return (torch.cat(Sx), torch.tensor(Sy), torch.cat(Qx), torch.tensor(Qy))
def proto_loss(encoder, x_s, y_s, x_q, y_q): """原型网络损失(第 7.2 节公式):原型 = support 嵌入均值,按最近原型分类。""" z_s, z_q = encoder.encode(x_s), encoder.encode(x_q) classes = torch.unique(y_s) protos = torch.stack([z_s[y_s == c].mean(0) for c in classes]) # (n_way, E) remap = {c.item(): i for i, c in enumerate(classes)} # 类 id 重排 y_q_r = torch.tensor([remap[v] for v in y_q.tolist()]) dists = torch.cdist(z_q, protos) loss = F.cross_entropy(-dists, y_q_r) acc = (-dists).argmax(1).eq(y_q_r).float().mean() return loss, acc
# ================= 主流程 =================def run_meta_demo(): xs, ys = make_iq() train_classes = [0, 1, 2] # 元训练只见过这 3 类 test_classes = [3, 4] # ★ 这 2 类整个训练过程从未出现
enc = MiniViTEncoder(L=1024) opt = torch.optim.Adam(enc.parameters(), lr=1e-3)
# ---- 元训练: 每个 episode 都是一个新的 3-way 小任务 ---- for ep in range(200): loss_acc = 0.0 for _ in range(4): # 每步 4 个 episode cids = torch.randperm(len(train_classes))[:3].tolist() xs_s, ys_s, xs_q, ys_q = make_episode(xs, ys, cids) loss, acc = proto_loss(enc, xs_s, ys_s, xs_q, ys_q) loss.backward() loss_acc += acc.item() / 4 opt.step(); opt.zero_grad() if ep % 40 == 0 or ep == 199: print(f"episode {ep:3d} 训练 acc = {loss_acc:.3f}")
# ---- 元测试: 对从未见过的 2 类做 2-way 5-shot(随机猜测 = 0.500)---- accs = [] enc.eval() for _ in range(100): xs_s, ys_s, xs_q, ys_q = make_episode(xs, ys, test_classes) with torch.no_grad(): _, acc = proto_loss(enc, xs_s, ys_s, xs_q, ys_q) accs.append(acc.item()) print(f"\n★ 未见类 2-way 5-shot 准确率: {sum(accs)/len(accs):.3f} " f"(随机 = 0.500,监督学习 = 0.000)")
if __name__ == "__main__": run_meta_demo()附录 B:mini_mcdformer.py(频域去噪 + 通道注意力,含消融开关)
预期输出:默认参数下带/不带 FDDM 均约 1.0(任务对纯卷积足够简单)。按 10.2 节”改动实验”调大
noise或更换更难区分的类别结构后,再对比use_fddm=True/False两种配置。
import mathimport torchimport torch.nn as nnimport torch.nn.functional as F
torch.manual_seed(0)
# ---- 设备选择:默认 GPU ----# set_default_device 让之后创建的所有张量/模块自动落在 GPU 上,正文代码无需再写 .to()/.cuda()torch.set_default_device('cuda')print("默认设备: cuda ——", torch.cuda.get_device_name(0))
# ================= MiniMCDformer: FDDM + Conv stem + 通道注意力 =================class FDDM(nn.Module): """频域去噪(第 6.2 节):FFT → 学习频域增益(Tanh 限幅) → IFFT → 残差。"""
def __init__(self, sig_len: int): super().__init__() self.filter = nn.Sequential( nn.Linear(sig_len, sig_len // 4), nn.ReLU(), nn.Linear(sig_len // 4, sig_len), nn.Tanh())
def forward(self, x): # (B,1,2,L) r = x[:, :, 0, :] + 1j * x[:, :, 1, :] # 复信号 R = torch.fft.fft(r, dim=-1) R_stack = torch.stack([R.real, R.imag], dim=2) # (B,1,2,L) h = self.filter(R_stack) # 频域增益 R_hat = h[:, :, 0, :] * R_stack[:, :, 0, :] \ + 1j * (h[:, :, 1, :] * R_stack[:, :, 1, :]) x_hat = torch.fft.ifft(R_hat, dim=-1).real.unsqueeze(2) return x_hat + x # 残差安全网
class ConvBlock(nn.Module): """stem 卷积块(第 2.3 节):只补时间维,保持长度。"""
def __init__(self, cin, cout, k): super().__init__() self.net = nn.Sequential( nn.ZeroPad2d((1, 1, 0, 0)), nn.Conv2d(cin, cout, kernel_size=k), nn.ReLU(), nn.BatchNorm2d(cout))
def forward(self, x): return self.net(x)
class ChannelAttnBlock(nn.Module): """通道注意力块(第 3.5 节):输入 (B,C,L),token=通道,特征维=时间。"""
def __init__(self, sig_len: int, n_heads: int = 2, dropout: float = 0.2): super().__init__() self.n1 = nn.LayerNorm(sig_len) self.attn = nn.MultiheadAttention(sig_len, n_heads, # embed_dim = 时间长度! dropout=dropout, batch_first=True) self.n2 = nn.LayerNorm(sig_len) self.ffn = nn.Sequential(nn.Linear(sig_len, sig_len), nn.GELU(), nn.Dropout(dropout), nn.Linear(sig_len, sig_len))
def forward(self, x): x = x + self.attn(self.n1(x), self.n1(x), self.n1(x))[0] # 通道间交互 x = x + self.ffn(self.n2(x)) # 时间维混合 return x
class MiniMCDformer(nn.Module): """FDDM → Conv stem → 通道注意力 → 时间池化 → 通道注意力 → 末位读出。"""
def __init__(self, L=128, num_classes=4, use_fddm=True): super().__init__() self.use_fddm = use_fddm self.fddm = FDDM(L) self.stem = nn.Sequential( ConvBlock(1, 32, (1, 3)), # (B,32,2,L) ConvBlock(32, 64, (2, 3)), # ★ 核高 2: I/Q 融合 → (B,64,1,L) ConvBlock(64, 128, (1, 3)), # (B,128,1,L) ) self.blk1 = ChannelAttnBlock(L) # 128 通道交互 self.down = nn.Sequential( # 时间维减半 nn.Conv1d(128, 128, 3, padding=1, padding_mode="circular"), nn.BatchNorm1d(128), nn.ELU(), nn.MaxPool1d(3, 2, 1)) self.blk2 = ChannelAttnBlock(L // 2) self.head = nn.Sequential(nn.Linear(128, 64), nn.PReLU(), nn.Linear(64, num_classes))
def forward(self, x): # (B,2,L) x = x.unsqueeze(1) # (B,1,2,L) if self.use_fddm: x = self.fddm(x) # 频域去噪(可关 = 消融) x = self.stem(x) # (B,128,1,L) x = x.squeeze(2) # (B,128,L) ← 通道即 token x = self.blk1(x) x = self.down(x) # (B,128,L/2) x = self.blk2(x) x = x[:, :, -1] # 末位读出 (B,128) return self.head(x) # (B,num_classes)
def make_iq_short(n_per_class=400, L=128, freqs=(4, 8, 16, 32), noise=0.8): """4 类不同频率复指数 + 强噪声(低 SNR 场景,考验去噪能力)。""" t = torch.arange(L).float() / L * 2 * math.pi xs, ys = [], [] for c, f in enumerate(freqs): phase = torch.rand(n_per_class, 1) * 2 * math.pi s = torch.exp(1j * (f * t + phase)) s = s + noise * (torch.randn(n_per_class, L) + 1j * torch.randn(n_per_class, L)) iq = torch.stack([s.real, s.imag], dim=1) # (n,2,L) xs.append(iq); ys.append(torch.full((n_per_class,), c, dtype=torch.long)) return torch.cat(xs), torch.cat(ys)
def train_mini_mcd(use_fddm=True, steps=400, seed=0): torch.manual_seed(seed) xs, ys = make_iq_short() n_tr = int(len(ys) * 0.8) x_tr, y_tr = xs[:n_tr], ys[:n_tr] x_te, y_te = xs[n_tr:], ys[n_tr:]
model = MiniMCDformer(L=128, num_classes=4, use_fddm=use_fddm) opt = torch.optim.Adam(model.parameters(), lr=1e-3) for step in range(steps): idx = torch.randint(0, len(x_tr), (64,)) opt.zero_grad() loss = F.cross_entropy(model(x_tr[idx]), y_tr[idx]) loss.backward(); opt.step()
model.eval() with torch.no_grad(): acc = (model(x_te).argmax(1) == y_te).float().mean() return acc.item()
if __name__ == "__main__": acc_on = train_mini_mcd(use_fddm=True) acc_off = train_mini_mcd(use_fddm=False) print(f"低 SNR 4 类识别: 带 FDDM acc = {acc_on:.3f} 无 FDDM acc = {acc_off:.3f}") print("(随机猜测 = 0.250;FDDM 差距随噪声增大而扩大)")版权声明
本文采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处。




