mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4

传统 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 章 导读:什么是”特化”

0.1 标准 Transformer 的五个默认假设

2017 年的 Transformer 是为机器翻译设计的,它悄悄带着五个默认假设:

#默认假设出处
1token 是离散词元(查表嵌入)嵌入层
2序列是一维的,token 沿时间排开位置编码
3输入长度固定(句子 padding 到统一长度)训练协议
4任务是封闭集分类/生成(词表固定)输出层
5输入是干净的符号,无需物理预处理端到端

“特化”就是把上述假设逐条打破、按新任务重写。你的新任务(信号、语音、图、点云、蛋白质……)每一项都要回答七个问题——这就是本文的”七轴特化地图”。

0.2 七轴特化地图(全文总纲)

轴问题标准做法Meta-TransformerMCDformer
1 Tokenization数据如何变成 token?词嵌入 / 16×16 图像 patch2×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-TransformerMCDformer
全称Meta-learning framework for scalable AMC(仓库名,含多模型对比)
数据集RadioML2018.01A:24 类、IQ (2, 1024)、HDF5RML2016.10a:11 类、IQ (2, 128)、pickle
核心结构ViT 编码器 + 原型网络(ProtoNet)FDDM + Conv stem + 两级转置 Transformer Block
解决的核心挑战新调制类型小样本识别(开放集)低信噪比下的识别(鲁棒性)
关键代码vit.py、proto.py、dataset.pyMCDformer.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 两行,合起来是一个复数序列:

r(t)=I(t)+j Q(t),振幅=I2+Q2,相位=arctan⁡ ⁣(QI)r(t) = I(t) + j\,Q(t), \qquad \text{振幅} = \sqrt{I^2 + Q^2}, \qquad \text{相位} = \arctan\!\left(\frac{Q}{I}\right)

星座图直觉(理解后续一切的关键):把每个采样点 (I, Q) 画在平面上,不同调制方式呈现不同图案——BPSK 是左右两个点、QPSK 是正方形四角、64QAM 是 8×8 网格。识别调制方式 = 识别星座图图案。噪声让点变成”云团”,信噪比(SNR)越低云团越大越难分。

信噪比的定义(全文反复出现,先记住):

SNR=10log⁡10 ⁣(P信号P噪声)    (dB)\mathrm{SNR} = 10 \log_{10}\!\left(\frac{P_{\text{信号}}}{P_{\text{噪声}}}\right) \;\; \mathrm{(dB)}

即”信号功率比噪声功率大多少倍”取对数。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-hot
self.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 维

设计动机(最重要的三行):

  1. patch 第一维 = 2 恰好等于 I/Q 行数:一个 patch 必然同时包含 I 和 Q。相位信息藏在 I/Q 的比值里——如果 patch 只覆盖一行,token 从出生起就”先天失明”看不到相位,必须靠后续层自行配对 I/Q,凭空增加学习难度。用 patch 尺寸编码”哪些原始维度必须绑定”,是 tokenization 特化的第一原则;
  2. patch 第二维 = 16 是时间窗:约等于符号周期量级——每个 token 携带”一个符号”的信息,让注意力在”符号间关系”而非”采样点间关系”上建模;
  3. 与 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 x
  • cls_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-TransformerCTDNN含义
patch 宽16(L=1024 → 64 token)8(L=128 → 16 token)token 数按序列长度等比例缩放,保持 16~64 区间
cls token有无CTDNN 靠位置编码区分位置,最后 flatten 所有 token 接 MLP(第 7 章)
嵌入后归一化无BatchNorm + tanhBN 在 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,时间 128
squeeze(2) (B,256, 128) → 送入 Block(dim=128)

两个设计决策及其理由:

  1. 用卷积(而不是 patch 线性投影)做 token 化:卷积带局部感受野与平移不变性,是提取”波形局部纹理”的专家;L=128 太短,切 patch 得不偿失。先卷积提取 → 再注意力全局建模是信号领域的主流分工(对比:Meta-Transformer 的 L=1024 足够长,直接 patch 更省);
  2. 核 (2,3) 专门用来融合 I/Q:与 Meta-Transformer “patch 覆盖两行”异曲同工——无论哪种 token 化,I/Q 必须在进入注意力前完成配对。区别只是:patch 在”token 化时”融合,卷积 stem 在”第 2 层”融合。

2.4 手把手:自己实现一个信号 patch 嵌入

把两个项目的思想合并成一个干净模块:

import torch
import 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 数(O(N2)O(N^2) 成本,见教学文档 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) 同构, 特征维 64
x[:, :, -1:] (B, 256, 1) 取时间维末位
classifier (B, 256) → (B, 11)

为什么合理(设计动机三连):

  1. 卷积 stem 已把时间局部结构提取干净,剩下来最需要全局交互的是”哪些通道冗余、哪些互补”——通道注意力 ≈ 升级版 SE 模块(SENet 用两层 MLP 学通道权重,这里直接用完整多头注意力);
  2. 时间维交给 MLP + 卷积池化压缩:mlp_ratio=1 表示作者认为时间维不需要”放大加工”,只是重组;ConvLayer 的 MaxPool 把 128→64 再交给第二个块;
  3. 注意力矩阵 256×256 显存可控(2562=65k256^2 = 65\mathrm{k} 元素),而如果反过来把 128 个时间点当 token,矩阵只有 128×128128\times128 更小——但时间关系已在卷积里建模过,重复建模浪费容量。

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 总结)

目标作用域备注
长程时间依赖(周期、突发)时间 tokentoken 数 = 时间片数,受 O(N2)O(N^2) 约束
通道/特征融合通道 tokentoken 数 = 通道数(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 的方案

思想:与其让一个模型吃所有长度,不如训练两个编码器:

config/model_params.yaml
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 个零点(Xdup(m)=K X(m/K)X_{\mathrm{dup}}(m) = K\,X(m/K) 当 mm 为 KK 的倍数,否则为 0;频谱”内插”)——谱峰位置完全不变,判别信息(载频、带宽、谱形)无损。而补零 = 时域截断加窗,等效于频域与 sinc 函数(sinc(x)=sin⁡(πx)/(πx)\mathrm{sinc}(x) = \sin(\pi x)/(\pi x),矩形窗的傅里叶变换,两侧带”波纹”)卷积,会展宽谱峰、引入旁瓣,污染谱形。填充策略也要懂领域知识——这是本章最值得记住的一句话。

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-TransformerMCDformer
嵌入维度36(9 头,每头仅 4 维)注意力特征维 = 128(=时间长度)
层数82(两个块)
容量来源层数深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 输出每个频率的”含量”(复数:振幅+相位):

X(k)=∑n=0L−1x(n) e−j 2πkn/L,k=0,1,…,L−1X(k) = \sum_{n=0}^{L-1} x(n)\, e^{-j\,2\pi k n / L}, \qquad k = 0, 1, \dots, L-1

其中 X(k)X(k) 表示频率为 k/L×k/L \times 采样率的”含量”(复数:模 = 振幅、辐角 = 相位)。采样率即每秒采样的点数(单位 Hz),由采集设备决定,它把”第 k 根谱线”换算成真实频率。

import numpy as np
import matplotlib.pyplot as plt # 仅本节演示用
t = np.arange(128) / 128
s = 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] ✓

三个直觉(后续全靠它们):

  1. 信号 = 少数谱线的组合:调制信号的判别信息集中在载频附近少数频点;
  2. 噪声 = 全频段均匀散布:高斯白噪声的频谱”到处都有且差不多高”;
  3. 频域加权 = 滤波:给每个频点乘一个增益再 IFFT,等于一个滤波器(卷积定理,y=F−1 ⁣{H(k)⋅F{x}}y = \mathcal{F}^{-1}\!\left\{ H(k)\cdot \mathcal{F}\{x\} \right\})。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 # ⑦ 残差连接

数学三行:

r=I+jQ(复信号)r = I + jQ \qquad\qquad \text{(复信号)} R=FFT(r)(频谱)R = \mathrm{FFT}(r) \qquad\qquad \text{(频谱)} x^=IFFT ⁣(h(R)⊙R),h=TinyMLP(频域软加权 → 时域)\hat{x} = \mathrm{IFFT}\!\big(h(R) \odot R\big), \quad h = \mathrm{TinyMLP} \qquad\qquad \text{(频域软加权 → 时域)} 输出=x^+r(残差)\text{输出} = \hat{x} + r \qquad\qquad \text{(残差)}

为什么有效的五层递进:

  1. 频域天然分离信号与噪声(6.1 直觉 1+2):学”哪个频点该保留”比在时域学去噪容易得多;
  2. TinyMLP 把 128 个频点互相混合:能学出比固定带通滤波器更灵活的谱整形(镜像抑制、谱峰锐化、谐波增强);
  3. Tanh 限幅 = 物理约束注入:滤波器增益有物理界限(无源滤波 |H|≤1),无界增益会放大噪声;限幅还防数值爆炸;
  4. 残差连接 = 安全网:最坏情况学到恒等映射(h→1),模块只会帮忙不会添乱——这是插入任何先验模块的安全姿势;
  5. 可微 = 端到端训练:滤波器的”系数”随分类损失一起学,不是手工设计——传统 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
FDDMAdaCorr
复数处理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 tokenx[:, 0]vit.py可学习聚合;cls 全程旁听注意力,等于”会议纪要”
取末位x[:, :, -1:]MCDformer.py省一个 token;因 MLP 已沿时间混合,末位也”看过”全段
Flatten + MLPflatten(1)CTDNN.py16 token × 256 维 = 4096 → MLP;容量最大、参数最多
全局平均池化AdaptiveAvgPool1d(1)AMC_Net.py与 token 数无关,最稳、最便宜

选择建议:数据多选 cls(表达力强);数据少选 GAP(正则好);要省参数选末位;要最大容量选 flatten。对最终精度的影响通常 <1%(教学文档 3.12 消融实验④也验证了这一点),真正影响大局的是下面这一节。

7.2 摘掉分类头:从”分类器”到”嵌入器”(Meta-Transformer 的核心特化)

问题:分类头把模型锁死在”训练时见过的 24 类”。新调制类型来了怎么办?重训整个模型显然不可行。

思路:不让模型输出”类别”,让它输出嵌入向量,分类改用度量(“跟谁像就是谁”)。这就是原型网络(Prototypical Network):

数学推导(三步):

  1. 原型:类 c 的原型 = 该类 support 样本嵌入的均值(“平均脸”):

pc=1ns∑i=1nsfθ ⁣(xic),支持集 5 个样本 → 1 个原型p_c = \frac{1}{n_s} \sum_{i=1}^{n_s} f_\theta\!\left(x_i^c\right), \qquad \text{支持集 5 个样本 → 1 个原型}

  1. 距离分类:查询样本归入最近的原型:

y^=arg⁡min⁡c∥fθ(xq)−pc∥2\hat{y} = \arg\min_c \left\| f_\theta(x_q) - p_c \right\|^2

  1. 概率形式(可训练):对负距离做 softmax 再交叉熵:

P(y=c∣xq)=exp⁡(−dc)∑kexp⁡(−dk),dc=∥fθ(xq)−pc∥2P(y=c \mid x_q) = \frac{\exp(-d_c)}{\sum_k \exp(-d_k)}, \qquad d_c = \left\| f_\theta(x_q) - p_c \right\|^2

为什么这解决开放集:训练时模型只学习”让同类样本在嵌入空间聚拢”——这个目标与”有哪些类”无关。测试时新类只要 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_vit
encoder = 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 类别划分协议:训练/测试类不相交

config/config.yaml
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"] # 5
self.num_query = self.config["num_query"] # 10
self.num_episode = len(self.snr) // ((self.num_support + self.num_query) * len(self.labels))
# __getitem__(节选): idx = 第几个 episode
for 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 特别适合元学习(设计动机收尾)

  1. 归纳偏置弱:卷积假设平移不变、RNN 假设时间递推——Transformer 只有”位置编码 + 全连接交互”,学到的表示任务通用性更强,迁移到新类不掉链子;
  2. 序列长度弹性:注意力天然处理变长(配合第 4 章的填充/裁剪),不同帧长都能编码进同一个度量空间;
  3. 嵌入质量高: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 math
import torch
import torch.nn as nn
import 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 math
import torch
import torch.nn as nn
import 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 两个复刻的对照总结

MiniMetaTransformerMiniMCDformer
数据长信号 L=1024、噪声 0.3短信号 L=128、强噪声 0.8
token 化2×16 patch(一步卷积)三层卷积 stem + I/Q 融合
注意力作用域时间 patch特征通道(转置)
前端无FDDM 频域去噪
任务头无头(原型度量)MLP 分类头
训练episode 元学习监督学习
演示的核心概念开放集小样本识别域先验 + 转置注意力

两个迷你模型 = 七轴地图的两种极端配置。你的任务大概率落在中间——这正是第 11 章决策树的用途。

第 11 章 方法论总结:checklist、反模式与决策树

前面十章已经把”两个项目怎么做”和”你自己怎么做”讲透了。本章把所有内容收敛成三件可以直接使用的工具:

  1. 八步 checklist——拿到一个新任务,按顺序过一遍,就能得到一份完整的设计方案;
  2. 反模式清单——九条前人(包括这两个项目自己)踩过的坑,写代码前先对照自查;
  3. 特化决策树——在几个关键分叉点上给出选择依据。

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 代表什么物理量?

  1. 数出输入的维度和语义。IQ 是 (2, L):第一维只有 2 且语义绑定(I 与 Q 是同一个时刻的投影)。
  2. 决定 token 的粒度:
    • 序列短(< 512)→ 逐点 token,nn.Embedding 或线性投影即可;
    • 序列长(≥ 1024)→ patch 化,patch 尺寸要覆盖一个”语义完整”的单元(AMC 里约一个符号周期 8~16 点);
    • 有自然分组 → 分组就是 patch(图像 16×16、IQ 的 2×16)。
  3. 把不可拆分的维度塞进单个 patch 的第一维(IQ patch 的第一维是 2,而不是 1——第 2.4 节的 IQPatchEmbed)。
  4. 决定 stem 的深度:一步卷积(Meta-Transformer)还是多层卷积 stem(MCDformer 四层)。判据见 11.3 决策树。

验收:画出 (输入形状) → (token 数 N, token 维度 C) 的完整流程,并写一个 5 行代码的 shape 测试(第 2.5 节的 test_patch_embed)。

第 3 步:选择注意力作用域(第 3 章)

核心问题:注意力矩阵的 N×N 里,N 是谁?

  1. 默认:N = 时间/位置,token = 每个时刻(标准用法)。
  2. 转置用法:N = 特征通道,token = 每个通道的整条时间曲线(MCDformer,第 3.2 节)。
  3. 何时考虑转置:
    • 时间维度很长但通道数中等(256 通道 vs 128 时间 → N=256 更便宜且语义明确);
    • 通道之间有物理意义的依赖(特征图通道是不同滤波器的响应,注意力 = 让通道互相协商);
    • MLP 会自动交换角色:转置后 MLP 沿时间混合(第 3.3 节的对照表)。
  4. 注意力矩阵大小 = N2N^2,先算内存再定架构(第 5 章容量设计)。

验收:写出注意力矩阵的形状 (B, H, N, N),并说明”第 i 行第 j 列的分数代表什么物理含义”。

第 4 步:定位置编码与长度策略(第 4 章)

  1. 可学习 or 正弦:
    • 长度固定、数据集够大 → 可学习(两个项目都如此);
    • 长度变化大、需要外推 → 正弦(理论可外推);
    • 干脆不要:模型含卷积层时卷积自带位置感(MCDformer 无位置编码)。
  2. 处理变长:
    • 裁剪:x = x[..., :max_len](元学习子编码器用 1024→128 裁剪);
    • 插值:F.interpolate 后接裁剪/填充(主编码器,第 4.3 节);
    • 填充:self_duplicate(时域重复)或补零(频域插值),频域分箱场景优先补零(第 4.2 节的频域物理解释)。
  3. cls token 用不用:分类任务用 cls(读出简单、注意力全局),无头度量学习也必须有一个”整条序列的表示”。

验收:同一个模型能处理你数据集中最短和最长的样本,不报错且位置编码维度对齐。

第 5 步:设计块结构与容量(第 5 章)

  1. Pre-LN or Post-LN:
    • 现代默认 Pre-LN(norm_first=True / x = norm(x + attn(norm(x))));
    • Post-LN 只在非常浅(≤ 3 层)时用,且必须 warmup。
  2. mlp_ratio 的实际含义先确认:是比例(hidden = dim × ratio,如 36×32=1152,vit.py 的用法)还是绝对值(hidden = 256,MCDformer 的用法)。这是两个项目之间最容易抄错的参数(第 5.2 节)。
  3. 容量设计公式(第 5.3 节):
    注意力内存 ≈ H × N × N × 4 字节
    MLP 参数量 ≈ 2 × C × hidden × 层数
    先算内存是否放得下,再调层数。
  4. dropout 不是默认 0.1:数据难、模型易过拟合 → 调到 0.3~0.5(MCDformer 用 0.5);数据充足 → 0.1。
  5. 归一化种类:CV 风格块可用 BatchNorm(CTDNN 用 BN+tanh 替代 LN+GELU),序列任务默认 LayerNorm。

验收:打印 sum(p.numel() for p in model.parameters()) 与注意力矩阵大小,确认都在预算内。

第 6 步:插入域先验前端(第 6 章)

这是 AMC 特化与 NLP 特化的最大分野。 问自己:我的领域里有没有”模型不易学、但数学上明确”的结构?

  1. AMC:频域去噪(FDDM)——“频域加权 = 滤波”(卷积定理),TinyMLP 学增益曲线;
  2. 候选清单:STFT 幅度谱、小波分解(AWN)、循环相关(AdaCorr)、差分特征……
  3. 实现模板(第 6.4 节的通用范式):
    变换域 T → 可学习增益 g(x)(有界激活)→ 逐元素乘 → 逆变换 T⁻¹ → 残差连接
  4. 验证方式:消融实验(带/不带前端,第 10.2 节的 train_mini_mcd(use_fddm=...))。域先验在低 SNR / 小数据时收益最大。

验收:前端模块单独测试时,输出与输入的物理量纲一致(FDDM 输出仍是 IQ 信号)。

第 7 步:定读出与任务头(第 7 章)

  1. 读出方式四选一(第 7.1 节):
    • cls token(ViT 标准)→ 分类头 nn.Linear(dim, num_classes);
    • 平均池化(Encoder-only 常用);
    • 末元素(MCDformer x[:, :, -1:]——与 stem 的 circular padding 配套);
    • 无头(度量学习,第 7.2 节)。
  2. 分类 vs 度量:
    • 闭集、类别固定 → fc 分类头 + 交叉熵;
    • 开放集 / 测试有未见类 → 摘掉 fc 头,输出 embedding + 原型网络(第 7.3 节)。
  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 章)

  1. 监督学习:固定 train/val/test 划分,早停(patience 10~60),阶梯或余弦学习率。
  2. 元学习:
    • 类别划分:训练类 / 测试类不相交(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 的写法)。
  3. 报告指标: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 → 注意力矩阵 10242=1M1024^2 = 1\mathrm{M} 元素 × 4 字节 × H 头 = 内存爆炸,batch 只能开到 1。
  • 为什么错:复杂度 O(N2)O(N^2)(第 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 式)

决策树的三个速记结论:

  1. 长信号 → patch;短信号且通道多 → 转置注意力。 两条路都通向”把 N 控制在一个合理量级”(64~256)。
  2. 开放集 → 摘头 + 原型;闭集 → 留头。 这是第 7 章陷阱一的根源,先想清楚再加载任何预训练权重。
  3. 域先验的收益与数据难度成正比。 低 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 ___;参数量 ≈ ___ M
6. 前端:变换域 ______ → 增益 ______ → 逆变换 ______;消融实验 = 有/无
7. 读出:cls / 平均 / 末元素 / 无头;embedding 维 = ___;fc 头:有 / 无
8. 训练:监督 / 元学习;类别划分 ______;测试协议 ______(n-way n-shot)

填完这张单,剩下的就是照着第 2~10 章的代码模板写实现。


第 12 章 结语

12.1 本书做了什么

这本书用两个真实的 AMC 论文项目作为标本,把”特化一个 Transformer”这件事拆成了七个轴:

轴标准 TransformerMeta-Transformer 的选择MCDformer 的选择
Tokenization词向量 / 展平 patch2×16 IQ patch四层卷积 stem + I/Q 融合
注意力作用域时间步(N=序列)时间 patch(N=64)特征通道(N=256,转置)
位置与长度正弦 / 可学习可学习 + 裁剪/插值无(卷积自带)
块结构Post-LNPre-LNPre-LN + 强 dropout
域先验前端无无FDDM 频域去噪
读出与任务头fc 分类无头(原型度量)末元素 + MLP
训练范式监督episode 元学习监督

每一行都是一个独立的”旋钮”。特化 = 根据你的任务,在每一行上做出有理由的选择——而不是整体照搬某个 SOTA 模型。

12.2 核心心法三句

  1. “token 是什么物理量”是所有设计的起点。 想清楚这一个问题,Tokenization、注意力作用域、位置编码三个轴自动就位。IQ 信号的 token 是”一段时间内的复包络片段”,不是”2×16 像素块”——同一个 patch 尺寸,背后的物理理解完全不同。

  2. 先验放哪里,取决于数据和模型的相对强弱。 数据困难(噪声、少样本)→ 把先验写成显式模块(FDDM、卷积 stem);数据充足 → 让 Transformer 自己学,先验只体现在 patch 设计和位置编码这些”软约束”上。

  3. 形状即契约,验证要趁早。 两个项目里最隐蔽的 bug 全是形状问题(3D vs 4D、65 vs 64 位置、24 维 vs 36 维 embedding)。每写完一个模块就打印一次 x.shape,比你事后用三天调参debug 便宜得多。

12.3 从读懂到做出自己的特化

建议的进阶路径:

  1. 先把第 10 章的两个迷你模型跑起来(MiniMetaTransformer、MiniMCDformer,各约 150 行,CPU 可跑)。它们刻意保留了原项目的核心设计而砍掉了工程噪音,是理解全文最快的入口。
  2. 对迷你模型做第 10 章的”改动实验”:换噪声水平、换注意力作用域、拆掉 FDDM——亲手观察每个旋钮的效应。
  3. 填写 11.4 节的设计单,套用你的真实任务。
  4. 对照第 2~9 章的模板逐模块实现,每完成一个模块跑一次 shape 测试。
  5. 回到原项目源码,这时候再看 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 math
import torch
import torch.nn as nn
import 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 math
import torch
import torch.nn as nn
import 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 许可协议。转载请注明出处。

显示设置
主题色相
壁纸模式
樱花粒子