一文搞懂 Token化、Embedding 与位置编码

一文搞懂 Token化、Embedding 与位置编码:原理公式 + Qwen3 分词器实测 + 7 组 PyTorch 实验全验证(附完整代码)

导读 :文本进入 Transformer 前要经过三步核心处理------Token化 → Embedding → 位置编码(PE) 。本文给出每一步的完整公式、原理推导与 PyTorch 实现,并用 7 组真实实验 (RTX 5070 Ti 实测)验证所有结论:Embedding 查表与 one-hot 矩阵乘误差为 0、正弦 PE 的旋转性质误差 ~1e-6、RoPE 内积只依赖相对位置(偏差 4.77e-07)、无 PE 时模型对词序完全无感 (变化 1.19e-07)......分词部分使用 Qwen3 官方分词器(词表 151643,与千问3.8-Max 同源词表体系)实测中文/英文/代码/emoji 五类场景的压缩率。

阅读收获:一张图看懂文本→Transformer 全流程 + 三种位置编码(正弦/可学习/RoPE)公式与选型树 + 8 条实战避坑清单 + 可直接运行的完整实验代码。

目录

  1. 全景图:文本到 Transformer 的旅程
  2. Token化流程详解
    • 2.1 三种分词粒度
    • 2.2 BPE 训练流程
    • 2.3 特殊 token 与批处理
    • 2.4 Qwen3 分词器多场景实测
  3. Embedding 详解
  4. 位置编码(PE)详解
    • 4.1 为什么必须显式编码位置
    • 4.2 正弦位置编码
    • 4.3 可学习位置编码
    • 4.4 RoPE 旋转位置编码
  5. PyTorch 实验设计与代码解读(7 组)
  6. 实验运行结果(RTX 5070 Ti 实测)
  7. 选型与实战避坑
  8. 完整实验脚本(可直接运行)
  9. 参考文献

1. 全景图:文本到 Transformer 的旅程

复制代码
  "我爱深度学习"                       ← 原始文本 (str)
      │ ① Token化: 切分成子词单元
      ▼
  ["我", "爱", "深度", "学习"]          ← tokens (List[str])
      │ ② 查词表: 每个token映射为整数ID
      ▼
  [104, 3877, 9012, 6211]              ← input_ids, 形状 (B, T)
      │ ③ Embedding: ID查嵌入矩阵 → 稠密向量
      ▼
  (B, T, d_model) 连续向量              ← 每个token一个d维向量
      │ ④ + 位置编码 PE (×√d_model 后相加)
      ▼
  (B, T, d_model) 带顺序信息的向量       ← 送入 Transformer

为什么每一步都不可或缺:

步骤 解决的问题 没有它会怎样
① Token化 神经网络只能处理数字 文本无法进入模型
② ID 化 离散符号 → 可索引整数 无法查嵌入表
③ Embedding one-hot 维度=词表大小(15万)且任意两词正交 无语义相似性、参数爆炸
④ PE 自注意力是排列等变的,对顺序完全无感 "猫追狗" = "狗追猫"(实验 6 实证)

2. Token化流程详解

2.1 三种分词粒度

复制代码
字符级 ←──────────────── 子词级(BPE) ────────────────→ 词级
词表小(几百)              词表适中(数万~15万)            词表大(数十万+)
序列长、语义弱            ★ 工业标准 ★                  OOV严重、形态学差
  • 字符级:每个字符一个 token。词表极小、永不 OOV,但序列过长、单字符语义太弱;
  • 词级:按空格/词典切词。语义单元完整,但未登录词(OOV)严重,词表爆炸,无法处理形态变化(playing/played);
  • 子词级(BPE/WordPiece/BBPE) :现代 LLM 的标准选择------高频词保留整词,低频词拆成更小的常见片段,兼顾词表大小与覆盖能力。

2.2 BPE(Byte-Pair Encoding)训练流程

算法(Sennrich et al., 2016):

  1. 初始化词表 = 全部字节/字符;
  2. 统计语料中相邻 token 对的出现频率;
  3. 把最高频的一对合并为新 token,加入词表;
  4. 重复 2~3 直到达到目标词表大小。

符号说明 :合并规则表(merges)决定了分词行为;推理时对任意新文本贪心地应用学到的合并规则。BBPE (Byte-level BPE,Qwen/GPT 使用)以 UTF-8 字节 为初始单元------任何字符(含 emoji、生僻字)都能表示,永不出 <unk>(实验 2 实测:生僻字"龘"被拆成字节 token)。

2.3 特殊 token 与批处理

token 作用
<pad> 批内短句补齐到等长;配合 attention_mask=0 屏蔽
<unk> 未登录词(BBPE 中不存在)
<bos> / <eos> 句子开始/结束边界(Qwen 用 `<
`< im_start

PyTorch(transformers)

python 复制代码
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("qwen_tokenizer")   # 本地 Qwen3 词表

enc = tok(["你好", "世界真大"], padding=True, return_tensors="pt")
enc["input_ids"]        # (2,3) 补齐后的ID矩阵
enc["attention_mask"]   # (2,3) 1=真实token, 0=补齐位(注意力会屏蔽)
tok.decode(enc["input_ids"][0])   # ID → 文本还原

代码解读

  • padding=True 按批内最长补齐;truncation=True, max_length=N 截断长文本------长文本超上下文窗口时必须截断;
  • attention_mask 必须随 input_ids 一起喂给模型,否则 pad 位会污染注意力;
  • token 数 ≠ 字符数:计费与上下文长度都按 token 算(中文 ≈ 1.5 字/token,英文 ≈ 4 字符/token,见实验 2 实测)。

2.4 Qwen3 分词器多场景实测(词表 151643)

场景 字符数 token数 压缩率 切分详情
中文短句"我爱深度学习" 6 4 1.50 ['我', '爱', '深度', '学习']
英文句子 35 6 5.83 ['Deep', ' learning', ' transforms', ' the', ' world', '.']
中英混合 23 7 3.29 ['用', 'Py', 'T', 'orch', '实现', 'Transformer', '模型']
代码片段 45 12 3.75 ['def', ' forward', '(self', ...](缩进空格也是token)
emoji/生僻字 9 11 0.82 生僻字被拆成多个 UTF-8 字节 token

观察:高频中文词("深度""学习")合并为单 token;英文词带前导空格(BPE 特性);生僻字压缩率 < 1(token 数反超字符数)------这就是"上下文窗口按 token 计费"时生僻文本更贵的原因。


3. Embedding 详解

3.1 公式与本质

嵌入矩阵 W ∈ R V × d W \in \mathbb{R}^{V \times d} W∈RV×d( V V V=词表大小, d d d=嵌入维度),对 token ID t t t:

e t = W t , : ⟺ e t = o n e h o t ( t ) ⋅ W \mathbf{e}_t = Wt, : \quad \Longleftrightarrow \quad \mathbf{e}_t = \mathrm{onehot}(t) \cdot W et=Wt,:⟺et=onehot(t)⋅W

符号说明 :nn.Embedding 的"查表"与"one-hot 向量乘矩阵"数学严格等价(实验 3b 实测误差 = 0.00e+00);实现上用查表(indexing)避免生成 15 万维稀疏向量,节省显存和计算。

3.2 为什么不能用 one-hot

  1. 维度灾难 : V = 151643 V=151643 V=151643 时每个 token 是 15 万维向量,第一层权重矩阵就要 15 万 × d 15万 \times d 15万×d;
  2. 无语义结构:任意两个 one-hot 向量内积 = 0(正交)------"猫"与"狗"的距离 = "猫"与"火箭"的距离;
  3. Embedding 把 token 压到 d d d 维稠密空间,语义相近的词向量距离近 ,且 W W W 随训练更新------词向量是学出来的。

3.3 关键性质:梯度稀疏更新

一个 batch 只触及词表的一小部分 → backward() 后只有用到的行有非零梯度(实验 3d 实测:batch 用到 ID {5,8,17,42,99},梯度非零行恰好就是这 5 行,其余 995 行不更新)。

3.4 PyTorch 实现

python 复制代码
emb = nn.Embedding(num_embeddings=151643,   # 词表大小 V
                   embedding_dim=4096,      # 嵌入维度 d (Qwen3-8B 用 4096)
                   padding_idx=0)           # 该行恒为0向量且永不更新
ids = torch.tensor([[104, 3877, 9012]])    # (B=1, T=3)
x = emb(ids)                               # (1, 3, 4096) ← 形状 (B,T) → (B,T,d)

代码解读

  • padding_idx 指定的行输出恒为零向量、梯度恒为零------pad 位不该有"语义";
  • 权重共享(weight tying) :LLM 常让输入 Embedding 与输出层(logits 投影 W o u t ∈ R d × V W_{out} \in \mathbb{R}^{d \times V} Wout∈Rd×V)共享同一矩阵(lm_head.weight = emb.weight),省 V × d V \times d V×d 参数(Qwen3-8B 上即 6.2 亿参数);
  • Embedding 初始化常用 N ( 0 , 0.02 2 ) \mathcal{N}(0, 0.02^2) N(0,0.022);Transformer 原文中 embedding 后乘 d m o d e l \sqrt{d_{model}} dmodel 再与 PE 相加(见 4.2)。

4. 位置编码(PE)详解

4.1 为什么必须显式编码位置

自注意力对输入是排列等变 的:设 P P P 为置换矩阵,则

A t t e n t i o n ( X P ) = A t t e n t i o n ( X )   P \mathrm{Attention}(XP) = \mathrm{Attention}(X)\,P Attention(XP)=Attention(X)P

打乱输入 token,输出只是被同样打乱,每个 token 的内容向量完全不变(实验 6a 实测误差 4.77e-07)。没有 PE 时句向量对词序完全无感(实验 6 实测变化 1.19e-07 ≈ 0)------"猫追狗"和"狗追猫"是同一个意思。

4.2 正弦位置编码(Vaswani et al., 2017)

公式 ( p o s pos pos = 位置, i i i = 维度下标, d m o d e l d_{model} dmodel = 向量维度):

P E ( p o s , 2 i ) = sin ⁡  ⁣ ( p o s 10000 2 i / d m o d e l ) , P E ( p o s , 2 i + 1 ) = cos ⁡  ⁣ ( p o s 10000 2 i / d m o d e l ) PE(pos, 2i) = \sin\!\left(\frac{pos}{10000^{2i/d_{model}}}\right), \qquad PE(pos, 2i+1) = \cos\!\left(\frac{pos}{10000^{2i/d_{model}}}\right) PE(pos,2i)=sin(100002i/dmodelpos),PE(pos,2i+1)=cos(100002i/dmodelpos)

输入构造(原文公式):

x = E m b e d d i n g ( i d s ) ⋅ d m o d e l + P E 0 : T x = \mathrm{Embedding}(ids)\cdot\sqrt{d_{model}} + PE_{0:T} x=Embedding(ids)⋅dmodel +PE0:T

符号说明与设计思想

  • 每个维度对 ( 2 i , 2 i + 1 ) (2i, 2i+1) (2i,2i+1) 是一个角频率 ω i = 10000 − 2 i / d \omega_i = 10000^{-2i/d} ωi=10000−2i/d 的旋转分量,波长从 2 π 2\pi 2π 到 10000 ⋅ 2 π 10000 \cdot 2\pi 10000⋅2π 几何分布;
  • 低维 = 高频 (区分相邻位置),高维 = 低频(区分远距离位置)------类似二进制计数器的连续版;
  • 性质1 : ∣ P E ∣ ≤ 1 |PE| \le 1 ∣PE∣≤1,不会淹没词向量(乘 d m o d e l \sqrt{d_{model}} dmodel 是让两者数量级对齐);
  • 性质2(核心) : P E ( p o s + k ) PE(pos+k) PE(pos+k) 可由 P E ( p o s ) PE(pos) PE(pos) 线性表出 ------由 sin ⁡ ( α + β ) \sin(\alpha+\beta) sin(α+β) 展开公式,固定偏移 k k k 时每对 ( sin ⁡ , cos ⁡ ) (\sin,\cos) (sin,cos) 分量做一个只依赖 k k k 的 2D 旋转(实验 4b 数值验证误差 ~1e-6)→ 模型容易学到"相对位置"概念;
  • 固定公式无需学习参数,理论上可外推到训练时没见过的长度。

PyTorch 实现

python 复制代码
class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=512, dropout=0.1):
        super().__init__()
        self.dropout = nn.Dropout(dropout)
        pe = torch.zeros(max_len, d_model)
        pos = torch.arange(max_len).unsqueeze(1).float()              # (max_len,1)
        div = torch.exp(torch.arange(0, d_model, 2).float()
                        / d_model * -math.log(10000.0))               # 10000^(-2i/d)
        pe[:, 0::2] = torch.sin(pos * div)                            # 偶数维=sin
        pe[:, 1::2] = torch.cos(pos * div)                            # 奇数维=cos
        self.register_buffer("pe", pe.unsqueeze(0))                   # (1,max_len,d)
        self.scale = math.sqrt(d_model)
    def forward(self, x):                                             # x: (B,T,d)
        x = x * self.scale + self.pe[:, :x.size(1)]                   # 缩放→加PE
        return self.dropout(x)

代码解读

  • register_buffer:PE 存为非参数张量 ------不参与梯度更新(它是固定公式),但随 model.to(device)/state_dict() 一起搬运保存;
  • exp(-log) 技巧:直接算 10000 2 i / d 10000^{2i/d} 100002i/d 在大 d d d 时可能溢出,取对数域计算更稳;
  • 与 embedding 相加而非拼接:保持维度不变,让位置信息"混入"每个语义向量。

4.3 可学习位置编码(BERT/GPT 系)

x = E m b e d d i n g w o r d ( i d s ) + E m b e d d i n g p o s ( 0 , 1 , ... , T − 1 ) x = \mathrm{Embedding}{word}(ids) + \mathrm{Embedding}{pos}(0,1,\\dots,T-1) x=Embeddingword(ids)+Embeddingpos(0,1,...,T−1)

python 复制代码
pos_emb = nn.Embedding(max_len, d_model)          # 位置也是"词",查表得到位置向量
x = word_emb(ids) + pos_emb(torch.arange(T))      # 随训练一起更新
  • 优点:可针对数据学出最优位置表示;缺点:无法外推 超过 max_len 的位置(BERT 512、GPT-2 1024 的硬上限就来自这里)。

4.4 RoPE 旋转位置编码(Su et al., 2021;Qwen3/LLaMA 等现代 LLM 标配)

思想 :不在输入端"加"位置向量,而是在每层注意力计算时 把 q , k q, k q,k 按位置旋转。把 d d d 维向量拆成 d / 2 d/2 d/2 个二维平面,位置 m m m 的向量在第 i i i 个平面旋转角度 m θ i m\theta_i mθi:

θ i = 10000 − 2 i / d , R m ( i ) = ( cos ⁡ m θ i − sin ⁡ m θ i sin ⁡ m θ i cos ⁡ m θ i ) \theta_i = 10000^{-2i/d}, \qquad R_m^{(i)} = \begin{pmatrix} \cos m\theta_i & -\sin m\theta_i \\ \sin m\theta_i & \cos m\theta_i \end{pmatrix} θi=10000−2i/d,Rm(i)=(cosmθisinmθi−sinmθicosmθi)

核心性质(实验 5 数值验证,偏差 ~1e-7):

⟨ R m q , R n k ⟩ = ⟨ q , R n − m   k ⟩ ⇒ 内积只依赖相对位置 ( m − n ) \langle R_m q,\ R_n k \rangle = \langle q,\ R_{n-m}\, k \rangle \quad \Rightarrow \quad \text{内积只依赖相对位置 } (m-n) ⟨Rmq, Rnk⟩=⟨q, Rn−mk⟩⇒内积只依赖相对位置 (m−n)

(因为旋转矩阵满足 R m ⊤ R n = R n − m R_m^{\top} R_n = R_{n-m} Rm⊤Rn=Rn−m------旋转之差 = 角度之差。)

PyTorch 实现 (HF/LLaMA 官方同款 rotate_half 写法)

python 复制代码
def rope_freqs(d_head, max_len, base=10000.0):
    theta = 1.0 / (base ** (torch.arange(0, d_head, 2).float() / d_head))  # (d/2,)
    angles = torch.arange(max_len).float().unsqueeze(1) * theta            # (L, d/2)
    emb = torch.cat((angles, angles), dim=-1)     # ⚠ 拼接(不是interleave):
    return emb.cos(), emb.sin()                   #   让维度 j 与 j+d/2 共享角度θ_j

def apply_rope(x, cos, sin):
    d2 = x.shape[-1] // 2
    rot = torch.cat([-x[..., d2:], x[..., :d2]], dim=-1)   # rotate_half: (-x2, x1)
    return x * cos + rot * sin                              # 二维旋转的向量化写法

代码解读

  • ⚠ 最大的坑------配对方式必须与 rotate_half 一致 :rotate_half 把维度 j j j 与 j + d / 2 j+d/2 j+d/2 配成一个旋转平面,因此角度表要用 cat((angles, angles)) 让这两个维度共享同一角度。若误用 repeat_interleave(2)(相邻维配对),旋转平面错位,相对位置性质被完全破坏(编写本实验时实测:错误配对下"相同相对距离"的内积偏差高达 2.97,修正后 4.77e-07);
  • RoPE 作用于每层的 q/k(v 不加),天然编码相对位置,配合 NTK-aware/YaRN 缩放可扩展上下文长度------这是它取代正弦/可学习 PE 成为 LLM 主流的原因;
  • 与正弦 PE 对比:正弦 PE 加在输入端(只加一次);RoPE 在注意力内部对 q/k 旋转(每层都作用)。

5. PyTorch 实验设计与代码解读(7 组)

环境:Python 3.12 + PyTorch 2.14 + CUDA 13.0(RTX 5070 Ti Laptop)+ transformers 5.17。完整代码见第 8 章。Qwen3 词表文件从 ModelScope 下载到本地 qwen_tokenizer/ 目录(离线可用)。

实验 内容 验证目标
1 手写字符级分词器 token化"文本→tokens→IDs→还原"闭环,无损往返
2 Qwen3 官方分词器(本地词表) BBPE 多场景切分、压缩率、padding/mask
3 Embedding 本质 查表=one-hot矩阵乘(误差=0)、梯度稀疏更新
4 正弦 PE 公式实现、有界性、线性(旋转)性质、热力图
5 RoPE 内积只依赖相对位置 m-n
6 PE 消融 无 PE 时词序打乱输出完全不变(排列等变性)
7 完整流水线 形状全追踪 + 同字不同境的"上下文化"

实验 1~2:Token化

手写 CharTokenizer(itos/stoi 双向映射)走通"文本→tokens→IDs→还原"闭环;再用 Qwen3 官方分词器(词表 151643)测 5 类场景的压缩率与切分细节(结果见 2.4 节表格)。

python 复制代码
class CharTokenizer:
    """最小字符级分词器:演示 token化 的完整闭环"""
    def __init__(self, texts):
        chars = sorted(set("".join(texts)))              # 从语料收集全部字符
        self.itos = {i: c for i, c in enumerate(chars)}  # int → str
        self.stoi = {c: i for i, c in enumerate(chars)}  # str → int
    def encode(self, text):                              # 阶段①切分+②查表
        return [self.stoi[c] for c in text if c in self.stoi]
    def decode(self, ids):                               # 逆过程(必须无损)
        return "".join(self.itos[i] for i in ids)

实验 3:Embedding

三个验证点:查表 = one-hot @ W (F.one_hot(ids) @ emb.weight 与 emb(ids) 逐元素对比);相同 ID 输出相同向量 (词义与位置无关);梯度稀疏性 (backward 后统计 emb.weight.grad 的非零行 == batch 中出现的 ID 集合)。

实验 4:正弦 PE

手工实现公式(exp(-log) 技巧防溢出),验证 |PE| ∈ -1,1;旋转性质 ------对每对 ( sin ⁡ , cos ⁡ ) (\sin,\cos) (sin,cos) 维施加角度 k ω i k\omega_i kωi 的旋转矩阵预测 P E ( p o s + k ) PE(pos+k) PE(pos+k),误差 ~1e-6;绘制热力图(位置×维度的正弦条纹 + 高频/低频维度波形对比)。

python 复制代码
def rot_verify(pe, pos, k):
    """验证 PE[pos+k] 等于对 PE[pos] 的每对(sin,cos)维做固定旋转"""
    pred = pe[pos].clone()
    for i in range(0, pe.shape[1], 2):
        w = 1.0 / (10000 ** (i / pe.shape[1]))           # 该维对的角频率
        c, s = math.cos(k * w), math.sin(k * w)          # 偏移k的旋转角
        x, y = pe[pos, i].item(), pe[pos, i + 1].item()
        # sin(θ+kω) = sinθ·cos(kω) + cosθ·sin(kω)
        # cos(θ+kω) = cosθ·cos(kω) - sinθ·sin(kω)
        pred[i], pred[i + 1] = x * c + y * s, y * c - x * s
    return (pred - pe[pos + k]).abs().max().item()

实验 5:RoPE

rope_freqs 预计算 cos/sin 表 + apply_rope 施加旋转。取同一对 ( q , k ) (q,k) (q,k) 向量,放到多组相同相对距离的位置对上(如 m-n=2 的 (5,3),(20,18),(41,39),(60,58)),验证旋转后内积几乎完全相同 → 内积只依赖 m-n。

实验 6:PE 消融(为什么必须有 PE)

单层自注意力小模型,同一批 token 向量打乱顺序后:

  • 无 PE :输出逆置换还原后与原输出误差 4.77e-07(排列等变);句向量(均值池化)变化 1.19e-07 ------ 对词序完全无感;
  • 加 PE:句向量变化 0.0681 ------ 模型能感知词序。

实验 7:完整流水线

字符分词 → padding + mask → nn.Embedding(含 padding_idx)→ PositionalEncoding(×√d + PE)→ 2 层 nn.TransformerEncoder(src_key_padding_mask 屏蔽 pad 位),全程打印形状;最后对比同一个字"度"在两句不同语境中的输入/输出向量差------展示 Transformer 的"上下文化"能力(同字不同义)。


6. 实验运行结果(RTX 5070 Ti 实测)

text 复制代码
运行设备: cuda
======================================================================

【实验 1】Token化三阶段流程(手写字符级分词器)
----------------------------------------------------------------------
语料: ['我爱深度学习', '深度学习改变世界', '我爱自然语言处理']
词表大小: 16,词表: 世习变处学度我改深然爱理界自言语

原文    : 我爱深度学习
阶段① tokens : ['我', '爱', '深', '度', '学', '习']
阶段② IDs    : [6, 10, 8, 5, 4, 1]
还原    : 我爱深度学习  (无损往返: True)

【实验 2】Qwen3 官方分词器实测(多场景)
----------------------------------------------------------------------
词表大小: 151643 (+26 特殊token)

场景          字符数 token数    压缩率  token切分详情
中文短句            6        4     1.50  ['我', '爱', '深度', '学习']
英文句子           35        6     5.83  ['Deep', ' learning', ' transforms', ' the', ' world', '.']
中英混合           23        7     3.29  ['用', 'Py', 'T', 'orch', '实现', 'Transformer', '模型']
代码片段           45       12     3.75  ['def', ' forward', '(self', ',', ' x', '):\n', '   ', ' return', ' self', '.norm', '(x', ')']
emoji/生僻        9       11     0.82  ['😀', '🎉', ' ', '', '', '', '', '齉', '爨', ' ', 'ㅤ']
  注: '' 是 BBPE 的字节级 token ------ 生僻字(如'龘')被拆成多个 UTF-8 字节,
      单个字节不构成合法字符所以显示为替换符;连起来 decode 才能还原原文。

批处理(自动padding): input_ids 形状 = (2, 3)
  attention_mask = [[1, 0, 0], [1, 1, 1]]  (0=补齐位,注意力会屏蔽)
  pad_token = '<|fim_middle|>' (id=151643)

【实验 3】Embedding 本质验证
----------------------------------------------------------------------
3a. 形状变换: input_ids (2, 3) → embedding (2, 3, 8)
    (B,T) 的整数ID → (B,T,d) 的浮点向量,每个ID变成一个8维向量
3b. 查表 vs one-hot矩阵乘: 最大误差 = 0.00e+00 → 数学等价 ✓
3c. 相同ID(17)在不同样本中的向量相同? True ✓(这正是'词义与位置无关'的体现)
3d. backward 后梯度非零的行: [5, 8, 17, 42, 99]
    本 batch 用到的ID: [5, 8, 17, 42, 99] → 完全一致 ✓(其余 995 行梯度为0,不更新)

【实验 4】正弦位置编码:实现与性质验证
----------------------------------------------------------------------
4a. PE 矩阵形状: (128, 64)  (每个位置一行 64 维向量)
    |PE| 取值范围: [-1.000, 1.000] ⊂ [-1,1] ✓ (性质1)
4b. 线性性质: PE(10+7) 由 PE(10) 旋转得到,误差 = 6.56e-07
    PE(77+23) 由 PE(77) 旋转得到,误差 = 2.94e-06 → 相对位置可线性表达 ✓
4c. PE(0) 与 PE(i) 的欧氏距离 (i=1,2,4,8,16,32): ['1.47', '2.72', '4.02', '4.38', '5.03', '4.99']
4d. PE 热力图已保存: figures/token_pe_heatmap.png

【实验 5】RoPE 旋转位置编码:相对位置性质验证
----------------------------------------------------------------------
5a. m-n=2 的四组位置对 (m,n): [(5, 3), (20, 18), (41, 39), (60, 58)]
    旋转后内积: ['-3.171002', '-3.171002', '-3.171003', '-3.171002']
    最大偏差 = 4.77e-07 → 内积只依赖相对位置 ✓
5b. m-n=6 的三组: ['-2.593786', '-2.593785', '-2.593785']  最大偏差 = 2.38e-07 ✓
5c. 对比: 差值2的内积(-3.1710) ≠ 差值6的内积(-2.5938) → 模型能区分不同相对距离

【实验 6】PE 消融:无位置编码时模型对词序完全无感
----------------------------------------------------------------------
6a. 无PE: 排列等变性误差 = 4.77e-07 (≈0 证明注意力本身无位置概念)
    无PE: 打乱词序后句向量最大变化 = 1.19e-07 → 完全无感!
6b. 加PE: 打乱词序后句向量最大变化 = 0.0681 → 模型能感知词序 ✓
    结论: '猫追狗'≠'狗追猫' 的能力完全来自位置编码

【实验 7】完整流水线:文本 → Transformer(形状全追踪)
----------------------------------------------------------------------
7a. 文本: ['我爱深度学习', '深度学习改变世界']
7b. input_ids  : (2, 8)  ← padding 对齐到 T=8
    mask       : [[1.0, ..., 0.0, 0.0], [1.0, ..., 1.0]]  ← 补齐位不参与注意力/损失
7c. Embedding  : (2, 8, 64)  ← 每个ID查成64维向量
7d. +PE        : (2, 8, 64)  ← 已注入位置信息
7e. Transformer: (2, 8, 64)  ← 2层encoder输出,形状不变,语义已上下文化
7f. 同一个字'度'(句1位置3 vs 句2位置1):
    输入向量差 = 2.7189  ← 同一字embedding相同,差异仅来自PE(位置不同)
    输出向量差 = 3.8612  ← 注意力融入不同语境后差异被重塑(上下文化)

======================================================================
全部实验完成。

PE 热力图 (发布时请上传 figures/token_pe_heatmap.png ):左图为位置×维度热力图(正弦条纹清晰可见),右图为高频维(i=0)与低频维(i=48)的波形对比。

结果解读

实验 关键数字 印证结论
1 无损往返 True token化闭环:encode/decode 必须互逆
2 压缩率 0.82~5.83 中英/代码/生僻字的 token 消耗差异巨大,按 token 计费
3 查表误差 = 0.00e+00;梯度非零行 == 用到的ID Embedding = one-hot 矩阵乘;梯度稀疏更新
4 旋转预测误差 ~1e-6 PE(pos+k) 可由 PE(pos) 线性(旋转)表出
5 相同 m-n 的内积偏差 4.77e-07 RoPE 内积只依赖相对位置
6 无 PE 句向量变化 1.19e-07 vs 有 PE 0.0681 词序感知能力完全来自 PE
7 同字"度"输出差 3.86 Transformer 上下文化(同字不同义)

7. 选型与实战避坑

PE 选型决策树

复制代码
你的模型是什么?
├─ 现代 LLM(decoder-only)...... RoPE(事实标准,Qwen3/LLaMA/Gemma)
├─ 经典 Transformer encoder .... 正弦PE(无需学习、可外推)或可学习PE
├─ BERT 系复现 ................. 可学习PE(max_position_embeddings)
└─ 超长上下文(>32K)........... RoPE + NTK-aware/YaRN 缩放

避坑清单

  1. token ≠ 字符:上下文长度与 API 计费都按 token 算;生僻字/emoji 的 token 消耗可能反超字符数(实验 2 实测压缩率 0.82);
  2. padding 必须配 attention_mask :只补 mask 不喂给模型 = pad 位污染注意力;计算 loss 时同样要屏蔽 pad 位(ignore_index=pad_id);
  3. RoPE 的角度配对方式 :rotate_half(前后半配对)与 interleaved(相邻配对)两种实现的 cos/sin 表不能混用,混用后相对位置性质直接失效(实验 5 实测偏差从 4.77e-07 恶化到 2.97);
  4. PE 用 register_buffer 而非 nn.Parameter:正弦 PE 是固定公式,不该参与梯度更新,但要随模型搬设备/保存;
  5. 可学习 PE 的长度硬上限 :超过 max_len 的位置没有向量可查,直接崩溃;需要外推选正弦 PE 或 RoPE;
  6. embedding 后乘 √d_model:Transformer 原文细节,让词向量与幅值 ~1 的 PE 数量级对齐,漏乘会让 PE 相对过强/过弱;
  7. 分词器与模型必须配套 :用 A 家的 tokenizer 配 B 家的模型 = ID 对不上词表,输出乱码;chat 场景要用 apply_chat_template 加角色标记,裸拼 prompt 效果显著变差;
  8. 别用 decode 单个字节 token 判断乱码:BBPE 的字节 token 单独 decode 显示 `` 是正常现象,整句 decode 才能还原(实验 2 注释)。

8. 完整实验脚本(可直接运行)

环境要求:PyTorch ≥ 2.0;实验 2 另需 pip install transformers 及本地 Qwen3 词表目录(可从 ModelScope 下载 Qwen/Qwen3-0.6B 的 tokenizer.json / tokenizer_config.json / vocab.json / merges.txt 四个文件到 qwen_tokenizer/)。无 Qwen 词表时实验 2 自动跳过,不影响其余实验。

python 复制代码
# -*- coding: utf-8 -*-
"""
Token化 → Embedding → 位置编码(PE) 全流程实验
=============================================
配套文档:《Token化与Embedding与PE详解-飞书版.md》

--------------------------------------------------------------------------
背景知识(读懂本脚本需要的全部概念)
--------------------------------------------------------------------------
文本进入 Transformer 前要经过三步处理,形状变化如下:

  "我爱深度学习"                     ← 原始文本 (str)
      │ ① Token化: 切分成子词单元
      ▼
  ["我", "爱", "深度", "学习"]         ← tokens (List[str])
      │ ② 查词表: 每个token映射为整数ID
      ▼
  [104, 3877, 9012, 6211]             ← input_ids, 形状 (B, T)
      │ ③ Embedding: ID查嵌入矩阵 → 稠密向量
      ▼
  (B, T, d_model) 的连续向量           ← 每个token一个d维向量
      │ ④ 加位置编码 PE
      ▼
  (B, T, d_model) 带顺序信息的向量      ← 送入 Transformer

为什么每一步都不可或缺:
  ① 神经网络只能处理数字,token化是文本→数字的桥梁;
  ② one-hot 维度=词表大小(15万)且任意两词正交(无相似性),
     必须压缩成低维稠密向量 → Embedding;
  ③ 自注意力是"排列等变"的:打乱输入token顺序,输出只是同样打乱,
     模型本身对顺序完全无感 → 必须显式注入位置信息 → PE。

三种主流位置编码:
  正弦PE (Vaswani 2017) : 固定公式 sin/cos,无需学习,可外推
  可学习PE (GPT/BERT)   : nn.Embedding(max_len, d),随训练更新
  RoPE (Qwen3/LLaMA)    : 旋转位置编码,把位置变成向量旋转角度,
                          使 q·k 内积只依赖相对距离 (m-n)
--------------------------------------------------------------------------
实验清单
--------------------------------------------------------------------------
  实验1  Token化三阶段流程演示(字符级分词器手工实现,理解原理)
  实验2  Qwen3 官方分词器实测(与千问3.8-Max 同源词表;多场景压缩率)
  实验3  Embedding 本质验证(查表 = one-hot 矩阵乘法;梯度只更新用到的行)
  实验4  正弦位置编码:手工实现 + 两大数学性质数值验证 + 热力图
  实验5  RoPE 旋转位置编码:相对位置性质验证(q·k 只依赖 m-n)
  实验6  PE 消融实验:无 PE 时打乱词序输出完全相同(排列等变性实证)
  实验7  完整流水线:文本 → token → ID → Embedding → +PE → Transformer

运行环境:conda env `dl`(Python 3.12 + PyTorch 2.14 + transformers 5.x)
运行方式:python token_embedding_pe_experiments.py
"""

import math
import time
import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(42)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"运行设备: {DEVICE}")
print("=" * 70)

# ======================================================================
# 实验 1:Token化三阶段流程演示(字符级分词器手工实现)
# ----------------------------------------------------------------------
# 目的:不依赖任何库,手工实现一个最小可用的分词器,把
#   "文本 → tokens → IDs → 还原文本" 的完整闭环走一遍。
# 用字符级(每个汉字/字母一个token)是因为它最简单,
#   能清晰展示三个阶段的接口;工业级用 BPE(实验2 的 Qwen 分词器)。
# 关键概念:
#   vocab(词表): token字符串 ↔ 整数ID 的双向映射
#   特殊token  : <pad>(补齐) <unk>(未登录) <bos>/<eos>(句首/句尾)
# ======================================================================
print("\n【实验 1】Token化三阶段流程(手写字符级分词器)")
print("-" * 70)

class CharTokenizer:
    """最小字符级分词器:演示 token化 的完整闭环"""
    def __init__(self, texts):
        # 从语料收集全部字符 → 排序保证确定性 → 构建双向映射
        chars = sorted(set("".join(texts)))
        self.itos = {i: c for i, c in enumerate(chars)}   # int → str
        self.stoi = {c: i for i, c in enumerate(chars)}   # str → int
        self.vocab_size = len(chars)

    def encode(self, text):
        """阶段①切分 + 阶段②查表:str → List[int]"""
        return [self.stoi[c] for c in text if c in self.stoi]

    def decode(self, ids):
        """逆过程:List[int] → str(还原必须无损,否则信息丢失)"""
        return "".join(self.itos[i] for i in ids)

corpus = ["我爱深度学习", "深度学习改变世界", "我爱自然语言处理"]
tok = CharTokenizer(corpus)
print(f"语料: {corpus}")
print(f"词表大小: {tok.vocab_size},词表: {''.join(tok.itos[i] for i in range(tok.vocab_size))}")

text = "我爱深度学习"
tokens = [c for c in text]                 # 阶段①: 文本 → token序列
ids = tok.encode(text)                     # 阶段②: token → ID序列
restored = tok.decode(ids)                 # 逆过程: ID → 文本
print(f"\n原文    : {text}")
print(f"阶段① tokens : {tokens}")
print(f"阶段② IDs    : {ids}")
print(f"还原    : {restored}  (无损往返: {restored == text})")

# ======================================================================
# 实验 2:Qwen3 官方分词器实测(BPE,与千问3.8-Max 同源词表体系)
# ----------------------------------------------------------------------
# Qwen 系列使用 BBPE(Byte-level BPE,字节级字节对编码):
#   * 词表约 15 万;任何字节序列都能编码 → 永远不会 <unk>;
#   * 高频词/词组合并为单 token("学习"一个token),低频字拆成字节;
#   * 中文常见字词 1 字≈1 token,英文约 4 字符≈1 token。
# 本实验测 5 类场景的 token 数与"压缩率"(字符数/token数):
#   压缩率越高 = 同样上下文窗口能装越多内容 = 推理越省钱。
# 若 transformers/Qwen 词表不可用则跳过(不影响其他实验)。
# ======================================================================
print("\n【实验 2】Qwen3 官方分词器实测(多场景)")
print("-" * 70)

try:
    from transformers import AutoTokenizer
    # Qwen3-0.6B 的分词器与整个 Qwen3 系列(含闭源 Max 模型)同源
    # 优先从本地 qwen_tokenizer/ 目录加载(已从 ModelScope 下载好,离线可用)
    try:
        qtok = AutoTokenizer.from_pretrained("qwen_tokenizer")
    except OSError:
        qtok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
    print(f"词表大小: {qtok.vocab_size} (+{len(qtok)-qtok.vocab_size} 特殊token)")

    scenarios = {
        "中文短句": "我爱深度学习",
        "英文句子": "Deep learning transforms the world.",
        "中英混合": "用PyTorch实现Transformer模型",
        "代码片段": "def forward(self, x):\n    return self.norm(x)",
        "emoji/生僻": "😀🎉 龘靐齉爨 ㅤ",
    }
    print(f"\n{'场景':<10s} {'字符数':>4s} {'token数':>6s} {'压缩率':>6s}  token切分详情")
    for name, s in scenarios.items():
        enc = qtok(s, add_special_tokens=False)
        ids = enc["input_ids"]
        pieces = [qtok.decode([i]) for i in ids]      # 每个ID单独解码看切分
        ratio = len(s) / len(ids)
        print(f"{name:<10s} {len(s):>6d} {len(ids):>8d} {ratio:>8.2f}  {pieces}")
    print("  注: '' 是 BBPE 的字节级 token ------ 生僻字(如'龘')被拆成多个 UTF-8 字节,")
    print("      单个字节不构成合法字符所以显示为替换符;连起来 decode 才能还原原文。")

    # 特殊token与批处理:chat模板会自动加 <|im_start|> 等角色标记
    batch = qtok(["你好", "世界真大"], padding=True, return_tensors="pt")
    print(f"\n批处理(自动padding): input_ids 形状 = {tuple(batch['input_ids'].shape)}")
    print(f"  attention_mask = {batch['attention_mask'].tolist()}  (0=补齐位,注意力会屏蔽)")
    print(f"  pad_token = {qtok.pad_token!r} (id={qtok.pad_token_id})")
    QWEN_OK = True
except Exception as e:
    print(f"[跳过: Qwen 分词器不可用 ------ {type(e).__name__}: {e}]")
    QWEN_OK = False

# ======================================================================
# 实验 3:Embedding 本质验证
# ----------------------------------------------------------------------
# nn.Embedding(vocab_size, d) 本质是一张可学习的查找表 W ∈ R^(V×d):
#   embedding(id) = W[id]  (取第 id 行)
# 数学上等价于 one-hot(id) @ W ------ 用矩阵乘法验证"查表=乘法"。
# 为什么不用 one-hot 直接进网络?
#   V=15万时 one-hot 是 15万维稀疏向量,且任意两词内积=0(正交),
#   "猫"和"狗"与"猫"和"火箭"距离一样 ------ 没有任何语义信息。
#   Embedding 把它压到 d 维稠密空间,语义相近的词向量距离近,
#   且 W 随训练更新(词向量是学出来的,不是人工设定的)。
# 梯度稀疏性:一个 batch 只用到部分 ID → backward 只更新 W 的对应行,
#   其余行梯度为 0(用实验证明)。
# ======================================================================
print("\n【实验 3】Embedding 本质验证")
print("-" * 70)

V, d = 1000, 8                               # 词表1000,嵌入维度8(便于演示)
emb = nn.Embedding(V, d)
ids = torch.tensor([[5, 17, 42],             # (B=2, T=3) 的ID矩阵
                    [8, 17, 99]])            # 注意两行都含17 → 输出向量应相同

out = emb(ids)
print(f"3a. 形状变换: input_ids {tuple(ids.shape)} → embedding {tuple(out.shape)}")
print(f"    (B,T) 的整数ID → (B,T,d) 的浮点向量,每个ID变成一个{d}维向量")

# 验证① 查表 == one-hot 矩阵乘法
onehot = F.one_hot(ids, num_classes=V).float()   # (B,T,V) one-hot
out_matmul = onehot @ emb.weight                 # (B,T,V)@(V,d) = (B,T,d)
err = (out - out_matmul).abs().max().item()
print(f"3b. 查表 vs one-hot矩阵乘: 最大误差 = {err:.2e} → 数学等价 ✓")

# 验证② 相同ID → 相同向量(查表的确定性;17在两个样本中都出现)
same = torch.allclose(out[0, 1], out[1, 1])
print(f"3c. 相同ID(17)在不同样本中的向量相同? {same} ✓(这正是'词义与位置无关'的体现)")

# 验证③ 梯度稀疏性:只有本 batch 用到的 5 个ID对应的行有梯度
loss = emb(ids).sum()
loss.backward()
touched = {5, 17, 42, 8, 99}                 # batch 中出现的ID集合
grad_rows_nonzero = (emb.weight.grad.abs().sum(dim=1) > 0).nonzero().flatten().tolist()
print(f"3d. backward 后梯度非零的行: {grad_rows_nonzero}")
print(f"    本 batch 用到的ID: {sorted(touched)} → 完全一致 ✓(其余 {V-len(touched)} 行梯度为0,不更新)")

# ======================================================================
# 实验 4:正弦位置编码(Vaswani et al., 2017 "Attention Is All You Need")
# ----------------------------------------------------------------------
# 公式(pos=位置, i=维度下标, d_model=向量维度):
#   PE(pos, 2i)   = sin( pos / 10000^(2i/d_model) )
#   PE(pos, 2i+1) = cos( pos / 10000^(2i/d_model) )
# 设计思想:
#   * 每个维度是一个不同频率的正弦波(波长从 2π 到 10000·2π 几何级数);
#   * 低维=高频(区分相邻位置),高维=低频(区分远距离位置);
#   * 性质1: 任意位置的 PE 都是单位界内的(|PE|≤1),不会淹没词向量;
#   * 性质2(核心): PE(pos+k) 可由 PE(pos) 线性表出 ------ 因为
#       sin(α+β)=sinαcosβ+cosαsinβ,固定偏移 k 时 (sin,cos) 对的
#       变换是一个只依赖 k 的旋转矩阵 → 模型容易学会"相对位置"。
# 使用方式: x = Embedding(ids)·√d_model + PE   (相加,不是拼接)
# ======================================================================
print("\n【实验 4】正弦位置编码:实现与性质验证")
print("-" * 70)

def sinusoidal_pe(max_len, d_model):
    """手工实现正弦PE,返回 (max_len, d_model) 矩阵"""
    pe = torch.zeros(max_len, d_model)
    pos = torch.arange(max_len).unsqueeze(1).float()          # (max_len,1) 位置
    # div_term = 1/10000^(2i/d) ,用 exp(log) 形式计算避免大数溢出:
    #   10000^(2i/d) = exp(2i/d · ln10000)
    i2 = torch.arange(0, d_model, 2).float()                  # 偶数维下标 2i
    div_term = torch.exp(i2 / d_model * -math.log(10000.0))   # (d/2,)
    pe[:, 0::2] = torch.sin(pos * div_term)                   # 偶数维 = sin
    pe[:, 1::2] = torch.cos(pos * div_term)                   # 奇数维 = cos
    return pe

MAX_LEN, D_MODEL = 128, 64
pe = sinusoidal_pe(MAX_LEN, D_MODEL)
print(f"4a. PE 矩阵形状: {tuple(pe.shape)}  (每个位置一行 {D_MODEL} 维向量)")
print(f"    |PE| 取值范围: [{pe.min():.3f}, {pe.max():.3f}] ⊂ [-1,1] ✓ (性质1)")

# 性质2验证:PE(pos+k) = M_k · PE(pos),M_k 是只依赖 k 的线性变换。
# 做法:解线性方程组找 M_k,再在未参与求解的位置上检验预测误差。
pos0, k = 10, 7
# 每个(sin,cos)维度对是独立的2D旋转: [sin(θ+φ),cos(θ+φ)]ᵀ = R(φ)·[sinθ,cosθ]ᵀ
# 这里直接构造旋转矩阵验证(比数值解方程更能说明结构):
def rot_verify(pe, pos, k):
    """验证 PE[pos+k] 是否等于对 PE[pos] 的每对(sin,cos)维做固定旋转"""
    pred = pe[pos].clone()
    for i in range(0, pe.shape[1], 2):
        w = 1.0 / (10000 ** (i / pe.shape[1]))               # 该维对的角频率
        c, s = math.cos(k * w), math.sin(k * w)              # 偏移k的旋转角
        x, y = pe[pos, i].item(), pe[pos, i + 1].item()
        # 旋转矩阵 R = [[cos, sin], [-sin, cos]] 作用于 (sinθ, cosθ):
        #   sin(θ+kω) = sinθ·cos(kω) + cosθ·sin(kω)
        #   cos(θ+kω) = cosθ·cos(kω) - sinθ·sin(kω)
        pred[i], pred[i + 1] = x * c + y * s, y * c - x * s
    return (pred - pe[pos + k]).abs().max().item()

err1, err2 = rot_verify(pe, pos0, k), rot_verify(pe, 77, 23)
print(f"4b. 线性性质: PE({pos0}+{k}) 由 PE({pos0}) 旋转得到,误差 = {err1:.2e}")
print(f"    PE(77+23) 由 PE(77) 旋转得到,误差 = {err2:.2e} → 相对位置可线性表达 ✓")

# 性质3:不同位置的PE互不相同,且距离随位置差单调趋势
dists = [ (pe[0] - pe[i]).norm().item() for i in [1, 2, 4, 8, 16, 32] ]
print(f"4c. PE(0) 与 PE(i) 的欧氏距离 (i=1,2,4,8,16,32): {[f'{x:.2f}' for x in dists]}")

# 绘制PE热力图(位置×维度),正弦条纹清晰可见
try:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    import numpy as np
    plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"]
    plt.rcParams["axes.unicode_minus"] = False
    fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
    im = axes[0].imshow(pe.numpy(), aspect="auto", cmap="RdBu_r")
    axes[0].set_xlabel("维度 i"); axes[0].set_ylabel("位置 pos")
    axes[0].set_title("正弦位置编码热力图 (128×64)")
    fig.colorbar(im, ax=axes[0], fraction=0.046)
    for i, lbl in [(0, "i=0 (高频)"), (16, "i=16"), (48, "i=48 (低频)")]:
        axes[1].plot(pe[:, i].numpy(), label=lbl)
    axes[1].set_xlabel("位置 pos"); axes[1].set_ylabel("PE 值")
    axes[1].set_title("各维度的正弦波(左=高频辨近邻,右=低频辨远距)")
    axes[1].legend(); axes[1].grid(alpha=0.3)
    fig.tight_layout()
    fig.savefig("figures/token_pe_heatmap.png", dpi=150)
    print("4d. PE 热力图已保存: figures/token_pe_heatmap.png")
except Exception as e:
    print(f"4d. [跳过绘图: {e}]")

# ======================================================================
# 实验 5:RoPE 旋转位置编码(Su et al. 2021,Qwen3/LLaMA 等现代 LLM 标配)
# ----------------------------------------------------------------------
# 思想:不在 embedding 上"加"位置,而是在每层注意力计算时把
#   q、k 向量按位置"旋转":把 d 维拆成 d/2 个二维平面,
#   位置 m 的向量在第 i 个平面旋转角度 mθ_i(θ_i=10000^(-2i/d))。
# 核心性质:旋转后 <RoPE(q,m), RoPE(k,n)> 只依赖相对位置 (m-n)!
#   因为二维旋转满足 R_mᵀR_n = R_(n-m)(旋转差=角度差)。
# 与正弦PE的区别:RoPE 作用于 q/k(注意力内部),天然编码相对位置,
#   长度外推性好(配合 NTK/YaRN 可扩展上下文),是当前 LLM 主流。
# ======================================================================
print("\n【实验 5】RoPE 旋转位置编码:相对位置性质验证")
print("-" * 70)

def rope_freqs(d_head, max_len, base=10000.0):
    """预计算旋转角的 cos/sin 表: (max_len, d_head)
    ⚠ 配对方式必须与 rotate_half 一致:rotate_half 把维度 j 与 j+d/2 配成
    一个旋转平面,所以角度表要用 cat((angles, angles)) 让这两个位置共享
    同一个角度 θ_j(HF/LLaMA 官方实现同款)。若用 repeat_interleave
    (相邻维配对)会与 rotate_half 错位,相对位置性质即被破坏。"""
    theta = 1.0 / (base ** (torch.arange(0, d_head, 2).float() / d_head))  # (d/2,) 频率
    pos = torch.arange(max_len).float().unsqueeze(1)                        # (max_len,1)
    angles = pos * theta                                                    # (max_len, d/2) 角度=mθ
    emb = torch.cat((angles, angles), dim=-1)                               # (max_len, d) 前后半共享角度
    return emb.cos(), emb.sin()

def apply_rope(x, cos, sin):
    """对 x (..., d_head) 施加旋转: (x1,x2) → (x1cos-x2sin, x1sin+x2cos)
    rotate_half 把向量前后对半分并交换(配对方式与实现有关,性质不变)"""
    d2 = x.shape[-1] // 2
    rot = torch.cat([-x[..., d2:], x[..., :d2]], dim=-1)   # (-x2, x1)
    return x * cos + rot * sin

D_HEAD, MAXL = 16, 64
cos_t, sin_t = rope_freqs(D_HEAD, MAXL)
q = torch.randn(D_HEAD)                                    # 一个query向量(与位置无关)
k = torch.randn(D_HEAD)                                    # 一个key向量

def dot_with_rope(m, n):
    """位置m的q 与 位置n的k 旋转后的内积"""
    qm = apply_rope(q, cos_t[m], sin_t[m])
    kn = apply_rope(k, cos_t[n], sin_t[n])
    return (qm * kn).sum().item()

# 验证:<q_m, k_n> 只依赖 (m-n):多组 (m,n),相同差值 → 相同内积
pairs = [(5, 3), (20, 18), (41, 39), (60, 58)]             # 差值都是 2
dots = [dot_with_rope(m, n) for m, n in pairs]
print(f"5a. m-n=2 的四组位置对 (m,n): {pairs}")
print(f"    旋转后内积: {[f'{x:.6f}' for x in dots]}")
print(f"    最大偏差 = {max(dots)-min(dots):.2e} → 内积只依赖相对位置 ✓")
pairs3 = [(10, 4), (30, 24), (50, 44)]                     # 差值都是 6
dots3 = [dot_with_rope(m, n) for m, n in pairs3]
print(f"5b. m-n=6 的三组: {[f'{x:.6f}' for x in dots3]}  最大偏差 = {max(dots3)-min(dots3):.2e} ✓")
print(f"5c. 对比: 差值2的内积({dots[0]:.4f}) ≠ 差值6的内积({dots3[0]:.4f}) → 模型能区分不同相对距离")

# ======================================================================
# 实验 6:PE 消融实验 ------ 为什么必须有位置编码?
# ----------------------------------------------------------------------
# 自注意力是"排列等变"的:Attention(XP) = Attention(X)P(P为置换矩阵),
# 即打乱输入 token 顺序,输出只是被同样打乱,token 之间的"内容"不变。
# 若不做任何位置注入,模型对 "猫追狗" 和 "狗追猫" 的理解完全相同!
# 验证方法:同一批 token,
#   无PE: 打乱顺序后逐token输出的集合不变(对应位置互换)→ 顺序信息为零;
#   加PE: 打乱后每个位置的输出都变 → PE 注入了顺序信息。
# 用池化后的句向量差值量化"模型对词序的敏感度"。
# ======================================================================
print("\n【实验 6】PE 消融:无位置编码时模型对词序完全无感")
print("-" * 70)

class TinyAttention(nn.Module):
    """单层自注意力(无可学习位置信息),用于纯净演示排列等变性"""
    def __init__(self, d):
        super().__init__()
        self.attn = nn.MultiheadAttention(d, num_heads=4, batch_first=True)
        self.ln = nn.LayerNorm(d)
    def forward(self, x):
        h, _ = self.attn(x, x, x)
        return self.ln(x + h)

d6 = 32
model6 = TinyAttention(d6).eval()
x_tokens = torch.randn(1, 6, d6)                             # 6个token的embedding
perm = torch.tensor([0, 3, 1, 5, 2, 4])                      # 打乱顺序(保留第0位)

with torch.no_grad():
    out_noPE = model6(x_tokens)                              # 无PE: 直接进注意力
    out_noPE_shuffled = model6(x_tokens[:, perm])
    # 排列等变性: 把打乱后的输出"逆置换"回来,应与原输出逐位相等 ------
    # 即输出只是被同样打乱,每个token的内容向量完全没变
    equivariant_err = (out_noPE_shuffled[:, torch.argsort(perm)] - out_noPE).abs().max().item()
    # 句向量(均值池化)对比: 打乱前后完全相同 → 顺序信息为零
    pooled_a, pooled_b = out_noPE.mean(1), out_noPE_shuffled.mean(1)
    print(f"6a. 无PE: 排列等变性误差 = {equivariant_err:.2e} (≈0 证明注意力本身无位置概念)")
    print(f"    无PE: 打乱词序后句向量最大变化 = {(pooled_a-pooled_b).abs().max().item():.2e} → 完全无感!")

    pe6 = sinusoidal_pe(6, d6).unsqueeze(0)                  # (1,6,d)
    out_PE = model6(x_tokens + pe6)                          # 有PE: embedding+位置
    out_PE_shuffled = model6(x_tokens[:, perm] + pe6)        # 注意: PE仍按0..5顺序加
    pooled_c, pooled_d = out_PE.mean(1), out_PE_shuffled.mean(1)
    diff = (pooled_c - pooled_d).abs().max().item()
    print(f"6b. 加PE: 打乱词序后句向量最大变化 = {diff:.4f} → 模型能感知词序 ✓")
    print(f"    结论: '猫追狗'≠'狗追猫' 的能力完全来自位置编码")

# ======================================================================
# 实验 7:完整流水线 ------ 文本 → Transformer 输入的全链路形状追踪
# ----------------------------------------------------------------------
# 把实验1~4串成工业标准流程(Transformer 原文的 encoder 输入构造):
#   ids (B,T) → Embedding → ×√d_model → +PE(0..T-1) → dropout → encoder
# 其中 ×√d_model 是原文细节:embedding 初始化为 N(0,1/d)量级,
#   乘 √d 让词向量与 PE(幅值~1)在同一数量级,避免 PE 淹没词义。
# ======================================================================
print("\n【实验 7】完整流水线:文本 → Transformer(形状全追踪)")
print("-" * 70)

class PositionalEncoding(nn.Module):
    """工业写法:register_buffer 把PE存为非参数张量(不参与梯度、随模型搬运设备)"""
    def __init__(self, d_model, max_len=512, dropout=0.1):
        super().__init__()
        self.dropout = nn.Dropout(dropout)
        pe = sinusoidal_pe(max_len, d_model)                 # 复用实验4的实现
        self.register_buffer("pe", pe.unsqueeze(0))          # (1, max_len, d)
        self.scale = math.sqrt(d_model)

    def forward(self, x_ids, emb):
        # x_ids: (B,T) 仅用于取形状; emb: (B,T,d) 词向量
        T = x_ids.shape[1]
        return self.dropout(emb * self.scale + self.pe[:, :T])  # 缩放→加PE→dropout

# 用实验1的字符分词器构造两条句子(batch=2,长度不齐 → padding)
sents = ["我爱深度学习", "深度学习改变世界"]
ids_list = [tok.encode(s) for s in sents]
T_max = max(len(i) for i in ids_list)
# padding: 短句补 -1... 这里用词表内字符'世'的ID作pad演示(正式场景用专用pad_id)
PAD = 0
ids_batch = torch.tensor([i + [PAD] * (T_max - len(i)) for i in ids_list])   # (B,T)
mask = torch.tensor([[1.0]*len(i) + [0.0]*(T_max-len(i)) for i in ids_list]) # 1=真实 0=补齐
print(f"7a. 文本: {sents}")
print(f"7b. input_ids  : {tuple(ids_batch.shape)}  ← padding 对齐到 T={T_max}")
print(f"    mask       : {mask.tolist()}  ← 补齐位不参与注意力/损失")

D7 = 64
emb7 = nn.Embedding(tok.vocab_size, D7, padding_idx=PAD)     # padding_idx: 该行恒为0且不更新
pe7 = PositionalEncoding(D7, max_len=64, dropout=0.0)        # 演示用 dropout=0 保证确定性
encoder = nn.TransformerEncoder(
    nn.TransformerEncoderLayer(D7, nhead=4, dim_feedforward=128,
                               batch_first=True, dropout=0.0),
    num_layers=2)

x = emb7(ids_batch)                       # (B,T) → (B,T,d)
print(f"7c. Embedding  : {tuple(x.shape)}  ← 每个ID查成{D7}维向量")
x = pe7(ids_batch, x)                     # +PE
print(f"7d. +PE        : {tuple(x.shape)}  ← 已注入位置信息")
out7 = encoder(x, src_key_padding_mask=(mask == 0))           # padding位屏蔽
print(f"7e. Transformer: {tuple(out7.shape)}  ← 2层encoder输出,形状不变,语义已上下文化")
# 上下文化验证:同一个字"度"------句1"我爱深度学习"中在位置3,
# 句2"深度学习改变世界"中在位置1。
# 进入 encoder 前:同一字 embedding 相同,仅 PE 不同(位置3 vs 位置1);
# 经过 encoder 后:语境完全不同(句1语境是"深+度",句2也是"深+度"但上下文不同),
# 输出向量差异被注意力进一步放大 → 这就是"同字不同义"的上下文化。
emb_diff = (x[0, 3] - x[1, 1]).norm().item()                 # 输入差(同字,仅PE不同)
ctx_diff = (out7[0, 3] - out7[1, 1]).norm().item()           # 输出差(语境不同)
print(f"7f. 同一个字'度'(句1位置3 vs 句2位置1):")
print(f"    输入向量差 = {emb_diff:.4f}  ← 同一字embedding相同,差异仅来自PE(位置不同)")
print(f"    输出向量差 = {ctx_diff:.4f}  ← 注意力融入不同语境后差异被重塑(上下文化)")

print("\n" + "=" * 70)
print("全部实验完成。")

9. 参考文献

  1. Vaswani A, et al. Attention Is All You Need . NeurIPS 2017. arXiv:1706.03762(正弦位置编码出处)
  2. Sennrich R, et al. Neural Machine Translation of Rare Words with Subword Units . ACL 2016. arXiv:1508.07909(BPE 原始论文)
  3. Su J, et al. RoFormer: Enhanced Transformer with Rotary Position Embedding . 2021. arXiv:2104.09864(RoPE 原始论文)
  4. Devlin J, et al. BERT: Pre-training of Deep Bidirectional Transformers . NAACL 2019. arXiv:1810.04805(可学习位置编码)
  5. Press O, et al. Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation . ICLR 2022. arXiv:2108.12409(ALiBi,另一种相对位置方案)
  6. Hugging Face Summary of the tokenizers (token化方案综述文档). 链接

本文所有实验数字均为 RTX 5070 Ti Laptop / PyTorch 2.14+cu130 / transformers 5.17 实际运行输出,随机种子已固定(torch.manual_seed(42)),可直接复现。Qwen3 分词器词表来自 ModelScope 开源仓库 Qwen/Qwen3-0.6B(Apache 2.0 许可)。

如果本文对你有帮助,欢迎 点赞 👍 收藏 ⭐ 关注 三连支持!有问题评论区交流。

标签 :深度学习 PyTorch Transformer 自然语言处理 大语言模型 位置编码 Tokenizer

相关推荐
XLYcmy8 天前
大语言模型(LLM)核心技术梳理:从词元化到 RAG 的工程实践
自然语言处理·prompt·embedding·token·cot·tokenization·上下文工程
geminigoth9 天前
从零开始学习RAG——02 项目架构
embedding·rag架构·rag学习·faiss 索引·rag清洗提取·rag切片·向量化与索引
书源丶10 天前
Qwen3-Embedding-0.6B 纯 CPU
网络·语言模型·embedding·llama
牛油果子哥q15 天前
多模态大模型工程入门:图文Embedding、图文RAG、图片解析、C++多模态接口封装实战
开发语言·c++·embedding
好菇娘の当自强16 天前
安装 Embedding 模型所需环境(完整指南)
embedding
ZYJCSZKJ16 天前
基于向量检索重排序与查询扩展的GEO优化:从Embedding空间视角提升区域实体引用率
embedding
richard_first17 天前
Transformer与大语言模型:第18章 向量数据库
数据库·人工智能·自然语言处理·transformer·embedding
今天AI了吗17 天前
去中心化 AI 反馈系统:数据不上链,凭证与激励分开管
人工智能·windows·python·数据分析·去中心化·区块链·embedding
CIO_Alliance17 天前
AI微调系列(1)| 全量微调、LoRA、QLoRA 三种方案怎么选
前端·人工智能·神经网络·机器学习·embedding·企业ai转型