Transform详解及代码实践

第 1 阶段:Embedding 和位置编码详解

概述

Transformer 模型的第一步是将离散的 token(例如,单词的索引)转换为密集的向量表示。这个过程包括两个关键部分:

  1. Embedding 层:将整数索引映射到高维向量
  2. 位置编码:添加位置信息,因为自注意力机制是排列不变的

为什么需要 Embedding?

原始输入是离散的 token 索引,例如:

输入句子: "The cat sat on the mat"
Token: [2, 5, 8, 1, 2, 9]  (假设 2 代表 "The")

这些整数索引本身没有任何数值意义。通过 Embedding,我们将其转换为一个连续的向量空间,其中语义相似的词有相似的向量表示。

Embedding 层的工作原理

Embedding 层本质上就是一个查找表:

词汇表大小: 10000
Embedding 维度: 512

┌─────────────────────────────────┐
│  Embedding 矩阵 (10000 × 512)   │
├─────────────────────────────────┤
│ Token 0:  [-0.5, 0.3, ..., 0.2] │  (padding token)
│ Token 1:  [ 0.1, 0.8, ..., 0.5] │  (start token)
│ Token 2:  [-0.2, 0.4, ..., 0.1] │  ("the")
│ Token 3:  [ 0.6, 0.2, ..., 0.3] │  ("cat")
│ ...
│ Token 5:  [ 0.3, 0.9, ..., 0.7] │
└─────────────────────────────────┘

查找过程:
输入: [2, 5]
输出: [
  [-0.2, 0.4, ..., 0.1],  (Token 2 的向量)
  [ 0.3, 0.9, ..., 0.7]   (Token 5 的向量)
]

关键参数

  • 词汇表大小:模型能处理多少个不同的单词
  • Embedding 维度(d_model):向量的维度,通常是 256, 512, 768 等

缩放因子 sqrt(d_model)

在论文中,embedding 被缩放 sqrt(d_model):

embeddings = embedding_matrix(input) * math.sqrt(d_model)

为什么? 这有助于保持 embedding 向量和位置编码的相对大小平衡。

位置编码的必要性

Transformer 的自注意力机制有一个重要的性质:它是排列不变的

例子:
句子1: "The cat sat"
句子2: "sat cat The"

在自注意力中,这两个句子的处理方式是完全相同的!

这对于 NLP 来说是一个问题,因为单词的顺序至关重要。解决方案是在 embedding 中添加位置编码

位置编码的设计

Transformer 论文使用的是固定的三角位置编码

PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

其中:

  • pos 是 token 在序列中的位置(0, 1, 2, ...)
  • i 是维度索引(0, 1, 2, ..., d_model/2 - 1)
  • d_model 是模型维度

位置编码矩阵的样子

对于 d_model=8, max_seq_len=4:

       维度 0  维度 1  维度 2  维度 3  维度 4  维度 5  维度 6  维度 7
位置 0: 0.00  1.00   0.00   1.00   0.00   1.00   0.00   1.00
位置 1: 0.84  0.54   0.07   1.00   0.01   1.00   0.00   1.00
位置 2: 0.91 -0.42   0.14   0.99   0.02   1.00   0.00   1.00
位置 3: 0.14 -0.99   0.21   0.98   0.03   1.00   0.00   1.00

为什么使用 sin 和 cos?

  1. 周期性:不同位置使用不同频率的波,这允许模型学习相对位置
  2. 可扩展性:对于任何序列长度都有定义,不需要为超长序列重新训练
  3. 数值稳定:值总是在 [-1, 1] 范围内

频率随维度变化

第 0 个维度(最低频率):
  位置 0: sin(0)       = 0
  位置 1: sin(1)       ≈ 0.84
  位置 2: sin(2)       ≈ 0.91
  位置 100: sin(100)   ≈ -0.51

第 7 个维度(最高频率):
  位置 0: cos(0 * 10000^14/8)       = 1.0
  位置 1: cos(1 * 10000^14/8)       = 1.0  (几乎没有变化)
  位置 100: cos(100 * 10000^14/8)   = 1.0

所以:

  • 低维数使用低频,在长句子中变化缓慢(捕捉长期位置信息)
  • 高维数使用高频,在短距离内快速变化(捕捉局部位置信息)

完整流程示例

输入: [2, 5, 8]  (Token 索引)

步骤 1: Embedding
输出: [
  [-0.5, 0.3, ..., 0.2],  # Token 2 → 512 维向量
  [ 0.3, 0.9, ..., 0.7],  # Token 5
  [ 0.1,-0.4, ..., 0.5]   # Token 8
]
形状: (3, 512)

步骤 2: 缩放
输出: [
  [-0.5 * sqrt(512), 0.3 * sqrt(512), ..., 0.2 * sqrt(512)],
  ...
]
缩放因子: √512 ≈ 22.6

步骤 3: 加入位置编码
位置 0 的编码: [0.0, 1.0, 0.0, 1.0, ...]
位置 1 的编码: [0.84, 0.54, 0.07, 1.0, ...]
位置 2 的编码: [0.91, -0.42, 0.14, 0.99, ...]

最终输出: [
  Embedding[2] + PositionalEncoding[0],
  Embedding[5] + PositionalEncoding[1],
  Embedding[8] + PositionalEncoding[2]
]
形状: (3, 512)

学习嵌入向量

Embedding 矩阵中的权重在训练过程中会被学习。这意味着:

  1. 语义相似的词汇会学习得到相似的向量
  2. 向量会排列在一个连续空间
  3. 线性关系可能会被学到:
    向量("king") - 向量("man") + 向量("woman") ≈ 向量("queen")
    

位置编码的可视化

在代码中运行 01_embedding.py 时,会生成一个热力图:

黑色区域:值接近 0
白色区域:值接近 1
灰色区域:值在 0 和 1 之间

观察:
- 低频维度(上部)变化缓慢
- 高频维度(下部)快速振荡
- 对角线模式表示不同位置的不同编码

positional_encoding

代码实现详解

完整代码:

点击查看代码
"""
01_embedding.py - Embedding 和位置编码 (Positional Encoding)

Transformer 模型的第一步:将输入的离散 token 转换为密集向量表示
"""

import torch
import torch.nn as nn
import math
import matplotlib.pyplot as plt


class EmbeddingLayer(nn.Module):
    """
    嵌入层:将整数 token 映射到密集向量空间
    
    Args:
        vocab_size: 词汇表大小
        d_model: 模型维度(嵌入向量的维度)
    """
    def __init__(self, vocab_size, d_model):
        super(EmbeddingLayer, self).__init__()
        # 创建一个 vocab_size × d_model 的矩阵
        # 每个单词都对应一个 d_model 维的向量
        self.embedding = nn.Embedding(vocab_size, d_model)
        # 按照 Transformer 论文的建议,缩放嵌入向量
        self.d_model = d_model

    def forward(self, x):
        """
        Args:
            x: (batch_size, seq_len) 的整数 token 索引
        
        Returns:
            (batch_size, seq_len, d_model) 的嵌入向量,缩放因子为 sqrt(d_model)
        """
        # embedding 的输出形状:(batch_size, seq_len, d_model)
        embeddings = self.embedding(x) * math.sqrt(self.d_model)
        return embeddings


class PositionalEncoding(nn.Module):
    """
    位置编码 (Positional Encoding)
    
    原理:由于 Transformer 模型中的自注意力机制是permutation-invariant的
    (即不考虑单词顺序),为了保留位置信息,需要添加位置编码。
    
    公式:
    PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
    PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
    
    其中:
    - pos 是单词在句子中的位置(0, 1, 2, ...)
    - i 是维度的索引(0, 1, 2, ..., d_model//2 - 1)
    - d_model 是模型的维度
    """
    def __init__(self, d_model, max_seq_len=5000, dropout=0.1):
        super(PositionalEncoding, self).__init__()
        self.d_model = d_model
        self.dropout = nn.Dropout(p=dropout)
        
        # 创建位置编码矩阵 (max_seq_len, d_model)
        pe = torch.zeros(max_seq_len, d_model)
        
        # 位置索引 (0, 1, 2, ..., max_seq_len-1)
        position = torch.arange(0, max_seq_len, dtype=torch.float).unsqueeze(1)
        
        # 计算分母:10000^(2i/d_model)
        # 这里我们先计算指数部分:2i/d_model
        div_term = torch.exp(torch.arange(0, d_model, 2, dtype=torch.float) * 
                            -(math.log(10000.0) / d_model))
        
        # 应用正弦和余弦函数
        # 偶数位置:使用 sin
        pe[:, 0::2] = torch.sin(position * div_term)
        # 奇数位置:使用 cos(当 d_model 是奇数时,最后一个位置可能只有 sin)
        if d_model % 2 == 0:
            pe[:, 1::2] = torch.cos(position * div_term)
        else:
            pe[:, 1::2] = torch.cos(position * div_term[:, :-1])
        
        # 将位置编码注册为 buffer(不是参数,不需要训练)
        # unsqueeze(0) 添加 batch 维度
        self.register_buffer('pe', pe.unsqueeze(0))
        
        print(f"✓ 位置编码初始化完成")
        print(f"  - 最大序列长度: {max_seq_len}")
        print(f"  - 模型维度: {d_model}")
        print(f"  - 位置编码矩阵形状: {pe.shape}")

    def forward(self, x):
        """
        Args:
            x: (batch_size, seq_len, d_model) 的嵌入向量
        
        Returns:
            (batch_size, seq_len, d_model) 的向量 = 嵌入向量 + 位置编码
        """
        # 获取序列长度
        seq_len = x.size(1)
        
        # 取出对应长度的位置编码 (1, seq_len, d_model)
        pos_enc = self.pe[:, :seq_len, :]
        
        # 将位置编码添加到嵌入向量中
        x = x + pos_enc
        
        # 应用 dropout
        return self.dropout(x)


def demo_embedding_and_positional_encoding():
    """演示 Embedding 和 Positional Encoding 的效果"""
    
    print("\n" + "="*80)
    print("演示 1: Embedding 层")
    print("="*80)
    
    # 参数设置
    vocab_size = 10000  # 词汇表大小
    d_model = 512       # 模型维度
    batch_size = 2      # 批次大小
    seq_len = 4         # 序列长度
    
    # 创建 embedding 层
    embedding_layer = EmbeddingLayer(vocab_size, d_model)
    
    # 创建随机的输入(token 索引)
    input_tokens = torch.randint(0, vocab_size, (batch_size, seq_len))
    print(f"\n输入 tokens (shape: {input_tokens.shape}):")
    print(f"  Batch 0: {input_tokens[0].tolist()}")
    print(f"  Batch 1: {input_tokens[1].tolist()}")
    
    # 通过 embedding 层
    embeddings = embedding_layer(input_tokens)
    print(f"\n嵌入向量 (shape: {embeddings.shape}):")
    print(f"  Batch 0, Token 0 的前 10 个维度: {embeddings[0, 0, :10].detach().tolist()}")
    print(f"  向量范数: {embeddings[0, 0].norm().item():.4f}")
    
    print("\n" + "="*80)
    print("演示 2: Positional Encoding")
    print("="*80)
    
    # 创建位置编码层
    pos_encoding = PositionalEncoding(d_model=d_model, max_seq_len=5000, dropout=0.0)
    
    # 添加位置编码
    output = pos_encoding(embeddings)
    print(f"\n添加位置编码后的输出 (shape: {output.shape}):")
    print(f"  Batch 0, Token 0 的前 10 个维度: {output[0, 0, :10].detach().tolist()}")
    print(f"  向量范数: {output[0, 0].norm().item():.4f}")
    
    # 展示位置编码的特性
    print("\n位置编码的特性分析:")
    print(f"  不同位置的编码向量是不同的:")
    for i in range(seq_len):
        print(f"    位置 {i} 的前 5 个维度: {output[0, i, :5].detach().tolist()}")
    
    print(f"\n  相同维度在不同位置的变化:")
    for dim_idx in [0, 1, 2]:
        values = [output[0, pos, dim_idx].item() for pos in range(seq_len)]
        print(f"    维度 {dim_idx}: {[f'{v:.4f}' for v in values]}")
    
    # 可视化位置编码
    print("\n" + "="*80)
    print("演示 3: 位置编码可视化")
    print("="*80)
    
    # 生成位置编码矩阵用于可视化
    pe_matrix = pos_encoding.pe[0, :200, :].detach().numpy()
    # 设置中文字体
    plt.rcParams['font.sans-serif'] = ['SimHei']  # 黑体
    plt.rcParams['axes.unicode_minus'] = False
    # 绘制热力图
    plt.figure(figsize=(12, 6))
    plt.imshow(pe_matrix.T, cmap='viridis', aspect='auto')
    plt.colorbar(label='编码值')
    plt.xlabel('位置 (Position)')
    plt.ylabel('维度 (Dimension)')
    plt.title('位置编码热力图 (前 200 个位置, 所有维度)')
    plt.tight_layout()
    
    # 保存图表
    save_path = r'c:\Users\13098\Desktop\Study\transformer\positional_encoding.png'
    plt.savefig(save_path, dpi=100, bbox_inches='tight')
    print(f"\n✓ 热力图已保存到: {save_path}")
    plt.close()


if __name__ == "__main__":
    demo_embedding_and_positional_encoding()

Embedding 层

class EmbeddingLayer(nn.Module):
    def __init__(self, vocab_size, d_model):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.d_model = d_model

    def forward(self, x):
        # x: (batch_size, seq_len)
        # output: (batch_size, seq_len, d_model)
        return self.embedding(x) * math.sqrt(self.d_model)

关键点:

  • nn.Embedding 是 PyTorch 内置的查找表
  • 乘以 sqrt(d_model) 是论文中的设计

位置编码层

class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_seq_len=5000):
        super().__init__()
        pe = torch.zeros(max_seq_len, d_model)
      
        # 位置索引:0, 1, 2, ..., max_seq_len-1
        position = torch.arange(0, max_seq_len).unsqueeze(1)
      
        # 频率:10000^(2i/d_model)
        div_term = torch.exp(torch.arange(0, d_model, 2) * 
                            -(math.log(10000.0) / d_model))
      
        # 应用 sin 和 cos
        pe[:, 0::2] = torch.sin(position * div_term)      # 偶数维
        pe[:, 1::2] = torch.cos(position * div_term)      # 奇数维
      
        self.register_buffer('pe', pe.unsqueeze(0))

    def forward(self, x):
        # x: (batch_size, seq_len, d_model)
        seq_len = x.size(1)
        # 加上相应长度的位置编码
        return x + self.pe[:, :seq_len, :]

常见问题

Q: 为什么位置编码是加法而不是拼接?
A: 加法保持维度不变,允许模型灵活地混合内容和位置信息。

Q: 位置编码是否可以学习?
A: 原始论文使用固定的三角编码。后来的工作(如 RoPE)提出了相对位置编码,但固定编码仍然有效。

Q: 如果序列超过 max_seq_len 怎么办?
A: 模型可能会遇到问题。实际应用中可以:

  1. 增加 max_seq_len
  2. 使用相对位置编码
  3. 对长序列进行分割

下一步

现在你理解了如何将输入转换为向量表示。下一阶段我们将学习自注意力机制,这是 Transformer 的核心。

在自注意力中,这些嵌入向量将被用来计算 Query, Key, Value,从而学习 token 之间的关系。

第 2 阶段:自注意力机制详解

核心概念

自注意力(Self-Attention)是 Transformer 最核心的机制。它允许模型在处理每个 token 时,查看序列中的其他 token,学习它们之间的关系。

为什么需要注意力?

传统的序列模型(如 RNN)处理序列是逐步的:

输入: [a, b, c, d]
处理:
  步骤1: 处理 a,隐藏状态 h1
  步骤2: 处理 b,基于 h1,得到 h2
  步骤3: 处理 c,基于 h2,得到 h3
  步骤4: 处理 d,基于 h3,得到 h4

问题:

  • 后面的 token 只能间接地看到前面的信息
  • 处理很长的序列时,初始信息可能会被遗忘

注意力机制:允许每个 token 直接看到序列中的所有其他 token。

处理 c 时:
c 可以直接关注 a, b, d
并学习分别给予它们多少权重

缩放点积注意力(Scaled Dot-Product Attention)

这是 Transformer 中使用的注意力机制。

公式

Attention(Q, K, V) = softmax(Q * K^T / sqrt(d_k)) * V

其中:

  • Q(Query):查询向量,"我想找什么?"
  • K(Key):键向量,"我是什么?"
  • V(Value):值向量,"我的信息是什么?"
  • d_k:Key 的维度,用于缩放

直观理解

例子:句子 "The cat sat on the mat"
      (tokens: 0    1    2   3  4  5)

处理 token 1 ("cat") 时:
1. 计算 Query (q1) - 来自 "cat" 的嵌入
2. 计算所有 Key (k0, k1, k2, ...) - 所有 token 的嵌入
3. 计算相似度:q1 与 k0, k1, k2, ... 的点积
   - q1 · k1 = 8.5  (高) - "cat" 关注自己
   - q1 · k2 = 3.2  (中) - "cat" 关注 "sat"
   - q1 · k5 = 1.1  (低) - "cat" 较少关注 "mat"
4. 归一化相似度(除以 sqrt(64))并应用 softmax
   - softmax([8.5, 3.2, 1.1, ...]) = [0.45, 0.30, 0.15, ...]
5. 加权求和所有值:
   output1 = 0.45 * v1 + 0.30 * v2 + 0.15 * v5 + ...

分步骤详解

步骤 1: 计算注意力分数

Q * K^T

输入:
Q: (seq_len, d_k) = (4, 64)   - 4 个 token,每个 64 维
K: (seq_len, d_k) = (4, 64)

输出:
scores: (seq_len, seq_len) = (4, 4)

例子数值:
     Token 0  Token 1  Token 2  Token 3
T0 [  100.0   45.3    -20.1    12.5  ]
T1 [  45.3    98.5    67.2     15.0  ]  <- Token 1 的查询与所有 Key 的相似度
T2 [ -20.1    67.2    105.0    35.0  ]
T3 [  12.5    15.0    35.0     92.0  ]

步骤 2: 缩放分数

scores / sqrt(d_k)

为什么缩放?
- 当 d_k 很大(如 64)时,点积会很大
- 大的输入值会导致 softmax 的梯度很小(梯度消失)
- 除以 sqrt(d_k) 可以稳定分布

例子:
sqrt(64) = 8

缩放前:[100.0, 45.3, -20.1, 12.5]
缩放后:[12.5, 5.66, -2.51, 1.56]

现在值在合理范围内,softmax 的梯度会更好。

步骤 3: 应用 Softmax

softmax(scores)

Softmax 将分数转换为概率(和为 1)。

缩放后的分数:[12.5, 5.66, -2.51, 1.56]

计算 softmax:
e^12.5 ≈ 270000
e^5.66 ≈ 287
e^-2.51 ≈ 0.08
e^1.56 ≈ 4.76

和 = 270000 + 287 + 0.08 + 4.76 ≈ 270292

Softmax = [270000/270292, 287/270292, 0.08/270292, 4.76/270292]
        ≈ [0.9989, 0.0011, 0.000, 0.000]

哇!Token 1 完全关注自己!这看起来不太对...
在实际 Transformer 中,计算过程是矩阵化的,结果会不同。

步骤 4: 与值相乘

attention_weights * V

输入:
attention_weights: (seq_len, seq_len) = (4, 4)  (概率分布)
V: (seq_len, d_v) = (4, 64)                      (值向量)

输出:
output: (seq_len, d_v) = (4, 64)

例子:
假设 attention_weights[1] = [0.45, 0.30, 0.15, 0.10]
V 的各行是 [v0, v1, v2, v3]

output[1] = 0.45 * v0 + 0.30 * v1 + 0.15 * v2 + 0.10 * v3

多头注意力(Multi-Head Attention)

单一的注意力机制可能不足以捕捉所有类型的关系。解决方案:使用多个注意力"头"。

为什么使用多头?

示意:
单一注意力头:看 "red car",可能专注于颜色
多个注意力头:
  头 1:专注于颜色 (红色)
  头 2:专注于对象 (汽车)
  头 3:专注于动作 (移动的)
  头 4:专注于语法关系

组合所有头的输出,模型获得了丰富的理解。

架构

输入 x: (batch_size, seq_len, d_model)  例如 (2, 4, 512)

线性投影(分割为多头):
  W_q @ x → (batch_size, seq_len, d_model)
  → reshape 和 transpose → (batch_size, num_heads, seq_len, d_k)
  
  其中 d_k = d_model / num_heads
  例如:512 / 8 = 64

并行执行多个注意力:
  对于每个头:
    Attention_i(Q, K, V) → (batch_size, seq_len, d_v)

拼接所有头:
  (batch_size, seq_len, d_k) × 8 → (batch_size, seq_len, 512)

最终线性投影:
  W_o @ concat → (batch_size, seq_len, d_model)

代码流程

# 输入
Q, K, V: (batch_size=2, seq_len=4, d_model=512)

# 线性投影
Q = W_q(Q)  → (2, 4, 512)
K = W_k(K)  → (2, 4, 512)
V = W_v(V)  → (2, 4, 512)

# 分割为多头
num_heads = 8
d_k = 512 / 8 = 64
Q = reshape_and_transpose(Q) → (2, 8, 4, 64)
K = reshape_and_transpose(K) → (2, 8, 4, 64)
V = reshape_and_transpose(V) → (2, 8, 4, 64)

# 执行注意力(广播到所有 8 个头)
attn_output = Attention(Q, K, V)  → (2, 8, 4, 64)

# 拼接
attn_output = reshape_and_transpose(attn_output) → (2, 4, 512)

# 最终投影
output = W_o(attn_output) → (2, 4, 512)

自注意力 vs 交叉注意力

自注意力 (Self-Attention)

Q, K, V 都来自同一个输入:
  Q, K, V = input

用于:编码器中,token 关注同一句子中的所有 token

交叉注意力 (Cross-Attention)

Q 来自一个输入,K 和 V 来自另一个输入:
  Q = decoder_input
  K, V = encoder_output

用于:解码器中,decoder token 关注 encoder 的所有 token

因果掩码(Causal Mask)

在解码器中,我们使用因果掩码防止 token 在生成时看到未来的 token。

为什么需要?

在推理时,我们逐个生成 token:

第 1 步:生成 token 1(只能看到 token 1)
第 2 步:生成 token 2(只能看到 token 1 和 2)
第 3 步:生成 token 3(只能看到 token 1, 2, 3)

但在训练中,我们有完整的目标序列。为了保持一致,我们使用因果掩码:

步骤 1:计算所有 token 的注意力
步骤 2:应用掩码,将 token i 看到的 j > i 的权重设为 0
步骤 3:归一化

因果掩码矩阵

形状:(seq_len, seq_len)
值:0 表示看不到,1 表示能看到

seq_len = 4:
     Token 0  Token 1  Token 2  Token 3
T0 [   1        0        0        0     ]
T1 [   1        1        0        0     ]
T2 [   1        1        1        0     ]
T3 [   1        1        1        1     ]

含义:Token 2 可以看到 Token 0, 1, 2,但看不到 Token 3。

应用掩码

if mask is not None:
    # 将掩码为 0 的位置设为 -inf
    scores = scores.masked_fill(mask == 0, float('-inf'))

# 对 [-inf 值的 softmax] = 0
softmax([-inf]) = 0

梯度流

注意力机制如何帮助梯度流动?

直接路径:
前向:token i 可以直接看到 token j(如果它们距离很远)
反向:token j 的梯度可以直接流向 token i

比较 RNN:
RNN 需要 |i - j| 步,距离越远,梯度越弱(梯度消失)

Transformer:
所有距离都是常数复杂度,梯度流动更均匀

计算复杂度

单个注意力头:
  Q @ K^T: (seq_len, d_k) × (d_k, seq_len) = O(seq_len^2 * d_k)
  Softmax: O(seq_len^2)
  @ V: (seq_len, seq_len) × (seq_len, d_v) = O(seq_len^2 * d_v)

总体:O(seq_len^2 * d_model)

这比 RNN 的 O(seq_len * d_model^2) 对于长序列更好。
但也意味着对于非常长的序列,注意力会变得昂贵(平方复杂度)。

调试和理解注意力

查看注意力权重

output, attention_weights = attention(Q, K, V)
# attention_weights: (batch_size, num_heads, seq_len, seq_len)

# 查看第一个样本,第一个头
weights_first_head = attention_weights[0, 0]
print(weights_first_head)
# 输出:(4, 4) 矩阵,行和为 1

常见模式

  1. 对角线集中:token 主要关注自己

    [[0.9, 0.05, 0.03, 0.02],
     [0.05, 0.85, 0.07, 0.03],
     [0.02, 0.08, 0.85, 0.05],
     [0.01, 0.03, 0.05, 0.91]]
    
  2. 行为:前面的 token 关注后面的(例如,处理短语)

    [[0.3, 0.7, 0.0, 0.0],
     [0.2, 0.4, 0.4, 0.0],
     [0.1, 0.2, 0.3, 0.4],
     [0.1, 0.1, 0.2, 0.6]]
    
  3. 宽分布:均匀关注所有 token

    [[0.25, 0.25, 0.25, 0.25],
     [0.25, 0.25, 0.25, 0.25],
     [0.25, 0.25, 0.25, 0.25],
     [0.25, 0.25, 0.25, 0.25]]
    

下一步

现在你理解了自注意力如何工作。在下一个阶段,我们将学习如何将注意力与其他组件(前馈网络、残差连接、层归一化)组合成编码器层。

第 3 阶段:架构与编码器详解

编码器层的组件

Transformer 编码器层不仅仅是注意力机制。它是一个精心设计的组件组合:

┌─────────────────────────────────────────────────────┐
│             编码器层 (EncoderLayer)                  │
├─────────────────────────────────────────────────────┤
│  1. 多头自注意力 (Multi-Head Self-Attention)       │
│  2. 残差连接 (Residual Connection)                 │
│  3. 层归一化 (Layer Normalization)                 │
│  4. 前馈网络 (Feed-Forward Network)                │
│  5. 残差连接 (Residual Connection)                 │
│  6. 层归一化 (Layer Normalization)                 │
└─────────────────────────────────────────────────────┘

前馈网络(Feed-Forward Network)

结构

位置级前馈网络在序列的每个位置独立应用相同的两层网络:

FFN(x) = max(0, x * W1 + b1) * W2 + b2

或用现代的 GELU 激活函数:

FFN(x) = GELU(x * W1 + b1) * W2 + b2

架构图

输入:(batch_size, seq_len, d_model)

    │
    ▼
  [线性层]  d_model → d_ff (扩张)
    W1: (d_model, d_ff)
    │
    ▼
  [激活函数] ReLU 或 GELU
    │
    ▼
  [Dropout]
    │
    ▼
  [线性层]  d_ff → d_model (压缩)
    W2: (d_ff, d_model)
    │
    ▼
输出:(batch_size, seq_len, d_model)

维度示例

d_model = 512
d_ff = 2048 (通常是 4 * d_model)

输入:(batch_size=2, seq_len=4, d_model=512)

线性 1:(2, 4, 512) × (512, 2048) → (2, 4, 2048)
激活:(2, 4, 2048) → (2, 4, 2048)  (应用 GELU)
线性 2:(2, 4, 2048) × (2048, 512) → (2, 4, 512)

输出:(2, 4, 512)

为什么这样设计?

  1. 维度扩张-压缩

    • 中间的宽层允许非线性变换在更大的空间中进行
    • 压缩回 d_model 保持与其他层的兼容性
  2. 参数数量

    注意力:O(d_model^2) 参数
    前馈:O(d_model * d_ff) ≈ 4 * d_model^2 参数
    
    总体来看,前馈网络是主要的参数源。
    
  3. 激活函数的选择

    ReLU: max(0, x)  - 简单但有 dead ReLU 问题
    GELU: x * Φ(x)  - 光滑,允许负值通过,效果更好
    

残差连接(Residual Connection)

概念

残差连接(也叫跳跃连接)允许信息绕过一个子层直接流向下一层:

不带残差:
输入 → [子层] → 输出

带残差:
输入 ───────────┐
        │       │
        ▼       │
      [子层]    │
        │       │
        └──(+)──┘
          │
          ▼
        输出

输出 = 输入 + 子层(输入)

为什么需要残差连接?

  1. 梯度流动

    不带残差(深层网络):
    梯度 = ∂Loss/∂输入 = ∂Loss/∂输出 × ∂输出/∂layer_n × ... × ∂layer_2/∂layer_1
    
    问题:如果有 100 层,这是 100 个梯度的乘积。
          如果每个梯度都 < 1,可能会导致梯度消失。
    
    带残差:
    梯度 = ∂Loss/∂输出 × (1 + ∂子层/∂输入)
    
    由于有 "+1",即使 ∂子层 很小,梯度也不会完全消失。
    
  2. 网络深度

    不带残差:很难训练超过 50-100 层的网络
    带残差:可以训练 1000+ 层的网络
    
  3. 初始化

    残差允许网络首先学习恒等函数 (输出 = 输入)
    然后逐渐学习输出与输入之间的偏差。
    这提供了一个好的初始点。
    

层归一化(Layer Normalization)

为什么需要归一化?

输入值的分布可能会随着网络的深度变化而改变。
这种现象叫做"内部协变量偏移" (Internal Covariate Shift)。

问题:
- 激活函数的有效范围被浪费
- 学习变得不稳定
- 需要更小的学习率

解决方案:在每一层之后进行归一化

批量归一化 vs 层归一化

批量归一化 (Batch Normalization):
  沿着批次维度进行
  E[x] = (1/batch_size) * Σ(x)
  问题:对 NLP 不友好,会模糊句子中的位置信息

层归一化 (Layer Normalization):
  沿着特征维度进行
  E[x] = (1/d_model) * Σ(x)
  优点:不依赖批次大小,适合变长序列

层归一化的计算

输入 x: (batch_size, seq_len, d_model)

对于每个 (batch_i, seq_j):
  1. 计算该位置的 d_model 维向量的均值和方差
  2. 归一化
  3. 缩放和移位

数学:
LayerNorm(x) = γ * (x - μ) / √(σ² + ε) + β

其中:
  μ = (1/d_model) * Σ(x)           (均值)
  σ² = (1/d_model) * Σ(x - μ)²    (方差)
  γ (gamma) 和 β (beta) 是可学习的参数
  ε (epsilon) 是一个小值防止除以零

例子:
x = [2, 5, 8, 1, 3] (d_model = 5)
μ = (2 + 5 + 8 + 1 + 3) / 5 = 3.8
σ² = [(2-3.8)² + (5-3.8)² + ... ] / 5 ≈ 5.76
σ = 2.4

LayerNorm(x):
  (2 - 3.8) / 2.4 = -0.75
  (5 - 3.8) / 2.4 = 0.5
  (8 - 3.8) / 2.4 = 1.75
  (1 - 3.8) / 2.4 = -1.17
  (3 - 3.8) / 2.4 = -0.33

结果:[-0.75, 0.5, 1.75, -1.17, -0.33]

Pre-norm vs Post-norm

论文使用的是 Post-norm:

[子层] → [LayerNorm] → 输出

更新的做法是 Pre-norm:

[LayerNorm] → [子层] → 输出

Pre-norm 优点:

  • 更容易训练深层网络
  • 不需要学习率预热

编码器层的完整流程

输入:x (batch_size, seq_len, d_model)

─────────────────────────────────────────
│  子层 1: 多头自注意力                   │
─────────────────────────────────────────

  (1) 应用多头自注意力
      attn_out = MultiHeadAttention(x, x, x)
      形状: (batch_size, seq_len, d_model)

  (2) 残差连接 + Dropout
      x = x + Dropout(attn_out)
    
  (3) 层归一化
      x = LayerNorm(x)

─────────────────────────────────────────
│  子层 2: 前馈网络                       │
─────────────────────────────────────────

  (1) 应用前馈网络
      ffn_out = FFN(x)
      形状: (batch_size, seq_len, d_model)

  (2) 残差连接 + Dropout
      x = x + Dropout(ffn_out)
    
  (3) 层归一化
      x = LayerNorm(x)

输出:x (batch_size, seq_len, d_model)

代码实现

class EncoderLayer(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
      
        self.mha = MultiHeadAttention(d_model, num_heads, dropout)
        self.ffn = PositionwiseFeedForward(d_model, d_ff, dropout)
      
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
      
        self.dropout = nn.Dropout(p=dropout)

    def forward(self, x, mask=None):
        # 子层 1: 自注意力
        x_norm = self.norm1(x)
        attn_out, _ = self.mha(x_norm, x_norm, x_norm, mask=mask)
        x = x + self.dropout(attn_out)
      
        # 子层 2: 前馈
        x_norm = self.norm2(x)
        ffn_out = self.ffn(x_norm)
        x = x + self.dropout(ffn_out)
      
        return x

完整编码器(堆叠层)

┌──────────────────────────────────┐
│  输入 (batch_size, seq_len, 512) │
└─────────────────────────────────┬┘
                                  │
                    ┌─────────────▼─────────────┐
                    │  嵌入 + 位置编码          │
                    │  Output: (b, seq, 512)    │
                    └─────────────┬─────────────┘
                                  │
                    ┌─────────────▼─────────────┐
                    │  编码器层 1               │
                    │  (多头注意力 + 前馈)     │
                    │  Output: (b, seq, 512)    │
                    └─────────────┬─────────────┘
                                  │
                    ┌─────────────▼─────────────┐
                    │  编码器层 2               │
                    │  (多头注意力 + 前馈)     │
                    │  Output: (b, seq, 512)    │
                    └─────────────┬─────────────┘
                                  │
                                 ...
                                  │
                    ┌─────────────▼─────────────┐
                    │  编码器层 6               │
                    │  (多头注意力 + 前馈)     │
                    │  Output: (b, seq, 512)    │
                    └─────────────┬─────────────┘
                                  │
                    ┌─────────────▼─────────────┐
                    │  最终层归一化             │
                    │  Output: (b, seq, 512)    │
                    └─────────────┬─────────────┘
                                  │
                    ┌─────────────▼─────────────┐
                    │  输出 (batch_size, seq, 512)
                    └──────────────────────────┘

为什么堆叠多层?

浅层(第 1-2 层):学习低层特征
  - 句法(词性、时态等)
  - 局部语义

中层(第 3-4 层):学习中层特征
  - 短语和子句的含义
  - 句子结构

深层(第 5-6 层):学习高层特征
  - 全局语义
  - 完整的句子含义

编码器的应用

用于 NLP 任务:

1. 分类任务
   输入:句子
   输出:[cls_token_representation]
   用途:分类,情感分析
   例如:BERT

2. 提取任务
   输入:句子
   输出:所有 token 的表示
   用途:命名实体识别,POS 标注
   例如:BioBERT

3. 序列到序列的编码部分
   输入:源语言句子
   输出:源语言的编码表示
   用途:机器翻译(编码器部分)
   例如:mBART

调试和理解

打印中间值

def forward(self, x):
    print(f"输入形状: {x.shape}, 值范围: [{x.min():.2f}, {x.max():.2f}]")
  
    x_norm = self.norm1(x)
    print(f"Norm 后: 值范围: [{x_norm.min():.2f}, {x_norm.max():.2f}]")
  
    attn_out, _ = self.mha(x_norm, x_norm, x_norm)
    print(f"注意力输出: [{attn_out.min():.2f}, {attn_out.max():.2f}]")
  
    x = x + self.dropout(attn_out)
    print(f"残差后: [{x.min():.2f}, {x.max():.2f}]")
  
    return x

常见问题和解决方案

问题 1: 损失不下降
  原因:学习率太高或太低
  解决:调整学习率或使用学习率调度

问题 2: 梯度爆炸/消失
  原因:网络太深或初始化不当
  解决:使用梯度裁剪、层归一化、残差连接

问题 3: 出现 NaN
  原因:除以零或数值不稳定
  解决:检查 softmax(可能有大的正/负值)、添加 epsilon

问题 4: 验证精度不如训练精度
  原因:过拟合
  解决:增加 dropout、添加正则化、数据增强

下一步

现在你理解了单个编码器层的结构。下一个阶段,我们将添加解码器,形成完整的 Transformer 模型,可以处理序列到序列的任务。

第 4 阶段:完整 Transformer 模型详解

编码器-解码器架构

完整的 Transformer 是一个编码器-解码器模型,适用于序列到序列任务:

源语言                              目标语言
(Source)                           (Target)

"Hello"    ┐
"world"    ├──→  [编码器]  ──→  编码表示  ──→  [解码器]  ──→  "Bonjour"
           │                                                      "monde"
           └──────────────────────────────────────────→ (交叉注意力)

编码器(Encoder)

编码器的工作是将源语言序列转换为一个丰富的表示。

输入:源语言句子 (整数 token)
  [2, 5, 8, 1]  (表示 "Hello world <pad>")

步骤 1: Embedding
  形状: (batch_size, seq_len) → (batch_size, seq_len, d_model)
  
步骤 2: 位置编码
  添加位置信息
  形状: (batch_size, seq_len, d_model)

步骤 3: 6 层编码器层
  每层包含:
    - 多头自注意力
    - 前馈网络
  
  输入到第 1 层: (batch_size, seq_len, d_model)
  输出:(batch_size, seq_len, d_model)

输出:编码表示
  形状: (batch_size, seq_len, d_model)
  包含了源语言句子的完整上下文

编码器的作用

编码器不进行任何翻译,只是进行编码

"Hello" 的多个头的表示:
  头 1(语法):[verb, singular, present, ...]
  头 2(语义):[greeting, polite, ...]
  头 3(实体):[person, singular, ...]
  ...

编码器确保这些信息被提取和融合到向量中。

解码器(Decoder)

解码器的工作是逐步生成目标语言句子。

解码器的特殊之处

解码器层包含三个子层,不是两个:

┌─────────────────────────────────────────────────────┐
│             解码器层 (DecoderLayer)                  │
├─────────────────────────────────────────────────────┤
│  1. 掩码多头自注意力 (Masked Multi-Head Attention)  │
│     ↓ 残差 + 层归一化                               │
│  2. 交叉注意力 (Cross-Attention)                    │
│     ↓ 残差 + 层归一化                               │
│  3. 前馈网络 (Feed-Forward Network)                 │
│     ↓ 残差 + 层归一化                               │
└─────────────────────────────────────────────────────┘

掩码自注意力 (Masked Self-Attention)

为什么需要掩码?

训练时:
  有完整的目标序列 [token1, token2, token3]
  但我们模拟推理时的情况:
    - 生成 token1 时,只能看到 token1
    - 生成 token2 时,只能看到 token1 和 token2
    - 生成 token3 时,只能看到 token1, token2, token3

掩码(因果掩码):
  ┌───┬───┬───┐
  │ 1 │ 0 │ 0 │
  ├───┼───┼───┤
  │ 1 │ 1 │ 0 │
  ├───┼───┼───┤
  │ 1 │ 1 │ 1 │
  └───┴───┴───┘

应用掩码后:
  token 3 对 token 1 的注意力权重 = 0
  token 3 对 token 2 的注意力权重 = 0
  token 3 对 token 3 的注意力权重 = 任意

交叉注意力 (Cross-Attention)

交叉注意力让解码器看到编码器的输出:

Query (Q): 来自解码器
  "我现在在生成什么?"
  (来自目标序列的表示)

Key (K) 和 Value (V): 来自编码器
  "源语言是什么?"
  (来自源语言的表示)

过程:
  Q = 解码器当前位置的向量
  K, V = 编码器的所有输出
  
  Attention(Q, K, V) = softmax(Q * K^T / sqrt(d_k)) * V
  
  这使解码器可以"查看"源语言的每个部分的相关性。

交叉注意力的可视化

源语言:The cat sat on the mat
目标语言:Le chat était assis sur le tapis (生成中)

当生成 "chat" (cat) 时:
  Query (Q): "我在生成什么(关于 cat)"
  Attention 权重:
    The:   0.1   (不太相关)
    cat:   0.7   (高度相关!)
    sat:   0.1
    on:    0.05
    the:   0.03
    mat:   0.02

加权组合所有源语言表示,得到一个包含相关信息的向量。

完整的前向传播

输入:
  src: [2, 5, 8, 1]  (源语言句子)
  tgt: [1, 4, 7]     (目标语言句子前缀,用于教师强制)

─────────────────────────────────────
│          编码器部分                 │
─────────────────────────────────────

1. 源语言 Embedding + 位置编码
   输入:(batch=1, seq_len=4)
   输出:(batch=1, seq_len=4, d_model=512)

2. 6 层编码器
   输入:(1, 4, 512)
   输出:(1, 4, 512)  ← 保存以供解码器使用

─────────────────────────────────────
│          解码器部分                 │
─────────────────────────────────────

1. 目标语言 Embedding + 位置编码
   输入:(batch=1, seq_len=3)
   输出:(batch=1, seq_len=3, d_model=512)

2. 6 层解码器
   对于每一层:
     a. 掩码自注意力
        Q, K, V = 解码器上一层的输出
      
     b. 交叉注意力
        Q = 解码器当前层的输出
        K, V = 编码器的输出
      
     c. 前馈网络

   输入:(1, 3, 512) 和编码器输出 (1, 4, 512)
   输出:(1, 3, 512)

─────────────────────────────────────
│          输出层                     │
─────────────────────────────────────

线性投影到词汇表
  输入:(1, 3, 512)
  权重:(512, vocab_size)
  输出:(1, 3, vocab_size)

其中 vocab_size = 10000(例如)

每个位置得到一个长度为 vocab_size 的向量,
表示该位置生成每个 token 的概率。

生成过程(推理)

在训练时,我们有完整的目标序列。但推理时,我们需要逐个生成 token。

自回归生成

步骤 1:初始化
  tgt_sequence = [<start>]
  
步骤 2:生成 token 1
  编码源语言
  encoder_out = encoder(src)
  
  输入到解码器:tgt_sequence = [<start>]
  解码器输出:logits for 位置 0
  logits[0] = [0.2, 0.5, 0.1, 0.05, ...]  (概率分布)
  
  选择:argmax(logits[0]) = token 4
  tgt_sequence = [<start>, 4]
  
步骤 3:生成 token 2
  输入到解码器:tgt_sequence = [<start>, 4]
  解码器输出:logits for 位置 0, 1
  我们只关注位置 1:logits[1] = [0.1, 0.05, 0.8, 0.03, ...]
  
  选择:argmax(logits[1]) = token 2
  tgt_sequence = [<start>, 4, 2]
  
步骤 4:生成 token 3
  继续...
  
步骤 5:停止条件
  如果生成了 <end> token 或达到最大长度,停止

解码策略

不同的生成方式会得到不同的结果:

  1. 贪心解码 (Greedy Decoding)

    next_token = argmax(logits)
    

    最快,但可能不是最优(局部最优)

  2. 束搜索 (Beam Search)

    # 保持 k 个最有可能的候选序列
    # 扩展每个候选,保留最好的 k 个
    k = 3  # beam width
    

    更好的结果,但更慢

  3. 核采样 (Nucleus Sampling)

    # 从高概率的 token 中随机采样
    # 更多样的输出
    

    多样性更好,可能更自然

损失函数和训练

交叉熵损失

损失函数:CrossEntropyLoss
用于:分类问题(预测每个位置的 token)

计算:
  logits: (batch_size, seq_len, vocab_size)  例如 (2, 5, 10000)
  targets: (batch_size, seq_len)  例如 (2, 5)  (真实 token)
  
  对于每个位置,计算:
    loss_i = -log(P(target_i))
  
  其中 P(target_i) = softmax(logits_i)[target_i]
  
  最终损失 = 平均所有位置和样本的损失

教师强制 (Teacher Forcing)

训练时:
  我们有完整的目标序列
  解码器在每一步都能"看到"正确的前缀
  
  示例:
    步骤 1:输入 [<start>],目标是 token 4
            解码器看到 [<start>],学习生成 token 4
  
    步骤 2:输入 [<start>, 4],目标是 token 2
            解码器看到正确的历史,学习生成 token 2
  
    步骤 3:...

推理时:
  解码器看不到真实的目标序列
  必须使用自己生成的 token
  这会导致"曝露偏差" (exposure bias)
  
  解决方案:
    - Scheduled Sampling:逐渐减少教师强制
    - 在训练中也使用生成的 token

超参数和模型大小

标准配置

| 模型大小 | d_model | num_heads | d_ff  | num_layers | 参数数 |
|---------|---------|-----------|-------|-----------|--------|
| 小型    | 256     | 4         | 1024  | 2         | ~3M    |
| 中型    | 512     | 8         | 2048  | 6         | ~50M   |
| 大型    | 768     | 12        | 3072  | 12        | ~340M  |
| Base    | 768     | 12        | 3072  | 12        | ~110M  |

原始论文(大型模型):

d_model = 512
num_heads = 8
d_ff = 2048
num_layers = 6
参数数 ≈ 65M

模型大小影响

参数更多:
  ✓ 更强的表达能力
  ✓ 更好的性能
  ✗ 更慢的训练和推理
  ✗ 需要更多数据
  ✗ 需要更多内存

实践建议:
  - 从小型模型开始
  - 逐步增加大小
  - 根据验证性能调整

常见应用

1. 机器翻译 (Neural Machine Translation)
   输入:英文句子
   输出:中文句子
   例如:Google Translate 的基础

2. 文本摘要 (Text Summarization)
   输入:长文本
   输出:简短摘要
   编码器处理输入文本,解码器生成摘要

3. 对话系统 (Dialogue System)
   输入:用户消息
   输出:系统回复
   单个 Transformer,输入和输出都是文本

4. 问答系统 (Question Answering)
   输入:问题 + 文本
   输出:答案范围
   可以修改为分类问题

调试技巧

验证梯度流

# 检查梯度是否正常流动
for name, param in model.named_parameters():
    if param.grad is None:
        print(f"警告:{name} 没有梯度")
    elif param.grad.abs().max() > 1:
        print(f"警告:{name} 的梯度很大 ({param.grad.abs().max()})")

保存和加载模型

# 保存
torch.save(model.state_dict(), 'model.pt')

# 加载
model = Transformer(...)
model.load_state_dict(torch.load('model.pt'))

推理

model.eval()  # 切换到评估模式
with torch.no_grad():  # 不计算梯度
    output = model(src, tgt)

下一步

现在你理解了完整的 Transformer 模型。下一个阶段是实际应用:训练翻译模型并生成序列。

第 5 阶段:训练和应用指南

完整的训练流程

流程概览

┌──────────────────────────────────────────────────────┐
│                                                      │
│                    数据准备                          │
│                                                      │
└──────────┬───────────────────────────────────────────┘
           │
           ▼
┌──────────────────────────────────────────────────────┐
│                                                      │
│                 模型初始化                          │
│                                                      │
└──────────┬───────────────────────────────────────────┘
           │
           ▼
┌──────────────────────────────────────────────────────┐
│                                                      │
│              训练循环(多个 Epoch)                  │
│                                                      │
│  ┌────────────────────────────────────────────┐   │
│  │  Epoch 1, Epoch 2, ..., Epoch N            │   │
│  │                                            │   │
│  │  对于每个 Batch:                          │   │
│  │    1. 前向传播                             │   │
│  │    2. 计算损失                             │   │
│  │    3. 反向传播                             │   │
│  │    4. 优化器更新参数                       │   │
│  │    5. 更新指标                             │   │
│  │                                            │   │
│  │  验证:                                     │   │
│  │    检查验证损失                             │   │
│  │    保存最好的模型                           │   │
│  └────────────────────────────────────────────┘   │
│                                                      │
└──────────┬───────────────────────────────────────────┘
           │
           ▼
┌──────────────────────────────────────────────────────┐
│                                                      │
│                  推理和评估                         │
│                                                      │
└──────────────────────────────────────────────────────┘

数据准备

数据加载

# 1. 定义数据集类
class TranslationDataset(Dataset):
    def __init__(self, source_file, target_file, vocab):
        # 读取源和目标文件
        self.src_sentences = read_file(source_file)
        self.tgt_sentences = read_file(target_file)
        self.vocab = vocab
  
    def __getitem__(self, idx):
        # 返回一对句子的 token 索引
        src_tokens = self.vocab.encode(self.src_sentences[idx])
        tgt_tokens = self.vocab.encode(self.tgt_sentences[idx])
        return torch.tensor(src_tokens), torch.tensor(tgt_tokens)

# 2. 创建数据加载器
dataset = TranslationDataset(...)
dataloader = DataLoader(dataset, batch_size=32, collate_fn=collate_fn)

# 3. Collate 函数处理可变长度
def collate_fn(batch):
    srcs, tgts = zip(*batch)
  
    # 填充到相同长度
    max_src_len = max(len(s) for s in srcs)
    max_tgt_len = max(len(t) for t in tgts)
  
    src_padded = torch.full((len(batch), max_src_len), pad_idx, dtype=torch.long)
    tgt_padded = torch.full((len(batch), max_tgt_len), pad_idx, dtype=torch.long)
  
    for i, (src, tgt) in enumerate(batch):
        src_padded[i, :len(src)] = src
        tgt_padded[i, :len(tgt)] = tgt
  
    return src_padded, tgt_padded

词汇表

class Vocabulary:
    def __init__(self):
        self.word2idx = {'<pad>': 0, '<start>': 1, '<end>': 2, '<unk>': 3}
        self.idx2word = {v: k for k, v in self.word2idx.items()}
        self.idx = 4  # 下一个新 token 的索引
  
    def add_word(self, word):
        if word not in self.word2idx:
            self.word2idx[word] = self.idx
            self.idx2word[self.idx] = word
            self.idx += 1
  
    def encode(self, sentence):
        # "hello world" → [5, 6]
        tokens = []
        for word in sentence.split():
            if word in self.word2idx:
                tokens.append(self.word2idx[word])
            else:
                tokens.append(self.word2idx['<unk>'])
        return tokens
  
    def decode(self, indices):
        # [5, 6] → "hello world"
        words = []
        for idx in indices:
            if idx in self.idx2word:
                word = self.idx2word[idx]
                if word not in ['<pad>', '<start>', '<end>']:
                    words.append(word)
            else:
                words.append('<unk>')
        return ' '.join(words)

训练详解

前向和反向传播

def train_epoch(model, dataloader, optimizer, criterion, device):
    model.train()  # 启用 dropout 等
    total_loss = 0
  
    for batch_idx, (src, tgt) in enumerate(dataloader):
        src = src.to(device)      # (batch_size, src_seq_len)
        tgt = tgt.to(device)      # (batch_size, tgt_seq_len)
      
        # 创建因果掩码(防止看到未来的 token)
        tgt_len = tgt.size(1)
        tgt_mask = create_causal_mask(tgt_len, device)
        # tgt_mask: (1, 1, tgt_len, tgt_len)
        #          [[1, 0, 0, ...],
        #           [1, 1, 0, ...],
        #           [1, 1, 1, ...]]
      
        # 清除梯度
        optimizer.zero_grad()
      
        # 前向传播
        # 输入:src 和 tgt[:-1](排除最后一个 token)
        # 目标:tgt[1:](排除第一个 token <start>)
        logits = model(src, tgt[:, :-1], tgt_mask=tgt_mask[:, :, :-1, :-1])
        # logits: (batch_size, tgt_seq_len-1, vocab_size)
      
        # 计算损失
        # 将二维变为一维便于计算交叉熵
        loss = criterion(
            logits.reshape(-1, logits.size(-1)),  # (-1, vocab_size)
            tgt[:, 1:].reshape(-1)                 # (-1,)
        )
      
        # 反向传播
        loss.backward()
      
        # 梯度裁剪(防止梯度爆炸)
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
      
        # 优化器更新
        optimizer.step()
      
        # 记录损失
        total_loss += loss.item()
      
        if batch_idx % 100 == 0:
            print(f"Batch {batch_idx}, Loss: {loss.item():.4f}")
  
    return total_loss / len(dataloader)

学习率调度

# 选项 1:固定学习率
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)

# 选项 2:学习率衰减
scheduler = torch.optim.lr_scheduler.StepLR(
    optimizer, 
    step_size=10,  # 每 10 个 epoch 衰减一次
    gamma=0.1      # 乘以 0.1
)

# 选项 3:预热 + 衰减(推荐用于 Transformer)
def get_linear_schedule_with_warmup(
    optimizer,
    num_warmup_steps,
    num_training_steps
):
    def lr_lambda(current_step):
        if current_step < num_warmup_steps:
            return float(current_step) / float(max(1, num_warmup_steps))
        return max(0.0, float(num_training_steps - current_step) / 
                       float(max(1, num_training_steps - num_warmup_steps)))
  
    return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)

# 使用
num_epochs = 100
num_training_steps = len(dataloader) * num_epochs
num_warmup_steps = len(dataloader) * 5  # 前 5 个 epoch 预热

scheduler = get_linear_schedule_with_warmup(
    optimizer, num_warmup_steps, num_training_steps
)

# 在每个 batch 后更新
for epoch in range(num_epochs):
    for batch in dataloader:
        ...
        scheduler.step()

验证

def evaluate(model, dataloader, criterion, device):
    model.eval()  # 禁用 dropout
    total_loss = 0
  
    with torch.no_grad():  # 不计算梯度
        for src, tgt in dataloader:
            src = src.to(device)
            tgt = tgt.to(device)
          
            tgt_len = tgt.size(1)
            tgt_mask = create_causal_mask(tgt_len, device)
          
            logits = model(src, tgt[:, :-1], tgt_mask=tgt_mask[:, :, :-1, :-1])
          
            loss = criterion(
                logits.reshape(-1, logits.size(-1)),
                tgt[:, 1:].reshape(-1)
            )
          
            total_loss += loss.item()
  
    return total_loss / len(dataloader)

# 在每个 epoch 后调用
best_val_loss = float('inf')
for epoch in range(num_epochs):
    train_loss = train_epoch(...)
    val_loss = evaluate(...)
  
    print(f"Epoch {epoch}: Train Loss {train_loss:.4f}, Val Loss {val_loss:.4f}")
  
    if val_loss < best_val_loss:
        best_val_loss = val_loss
        torch.save(model.state_dict(), 'best_model.pt')
        print(f"模型已保存,验证损失降低到 {val_loss:.4f}")

推理(生成翻译)

自回归生成

def generate_translation(model, src, max_len=30, device='cpu'):
    """
    自回归地生成目标语言序列
    """
    model.eval()
  
    with torch.no_grad():
        # 编码源语言
        src = src.unsqueeze(0).to(device)  # (1, src_len)
      
        # 前向传播编码器
        src_emb = model.src_embedding(src)
        src_emb = model.src_pos_encoding(src_emb)
        encoder_out = model.encoder(src_emb)
      
        # 初始化目标序列(开始 token)
        start_token = 1  # <start>
        tgt = torch.full((1, 1), start_token, dtype=torch.long, device=device)
      
        # 逐个生成 token
        for step in range(max_len):
            # 创建因果掩码
            tgt_len = tgt.size(1)
            tgt_mask = create_causal_mask(tgt_len, device)
          
            # 解码器前向传播
            tgt_emb = model.tgt_embedding(tgt)
            tgt_emb = model.tgt_pos_encoding(tgt_emb)
            decoder_out = model.decoder(
                tgt_emb, encoder_out, 
                self_attn_mask=tgt_mask
            )
          
            # 输出层
            logits = model.output_layer(decoder_out)
          
            # 取最后一个位置的 logits
            last_logits = logits[0, -1, :]  # (vocab_size,)
          
            # 生成下一个 token
            # 方法 1: 贪心(选择概率最高的)
            next_token = torch.argmax(last_logits, dim=-1, keepdim=True)
          
            # 方法 2: 采样
            # next_token = torch.multinomial(
            #     torch.softmax(last_logits, dim=-1),
            #     num_samples=1
            # )
          
            # 添加到序列
            tgt = torch.cat([tgt, next_token.unsqueeze(0)], dim=1)
          
            # 如果生成了结束 token,停止
            if next_token.item() == 2:  # <end>
                break
      
        return tgt[0]

束搜索(更高质量)

def beam_search(model, src, beam_width=3, max_len=30, device='cpu'):
    """
    使用束搜索生成翻译(比贪心更好)
    """
    model.eval()
  
    with torch.no_grad():
        # 编码
        src = src.unsqueeze(0).to(device)
        src_emb = model.src_embedding(src)
        src_emb = model.src_pos_encoding(src_emb)
        encoder_out = model.encoder(src_emb)
      
        # 初始化候选序列
        # 每个候选:(序列, 对数概率)
        candidates = [
            (torch.tensor([1], device=device), 0.0)  # (序列, 对数概率)
        ]
      
        for step in range(max_len):
            next_candidates = []
          
            for seq, log_prob in candidates:
                # 跳过已结束的序列
                if seq[-1].item() == 2:  # <end>
                    next_candidates.append((seq, log_prob))
                    continue
              
                # 扩展序列
                seq = seq.unsqueeze(0).to(device)
                tgt_len = seq.size(1)
                tgt_mask = create_causal_mask(tgt_len, device)
              
                tgt_emb = model.tgt_embedding(seq)
                tgt_emb = model.tgt_pos_encoding(tgt_emb)
                decoder_out = model.decoder(
                    tgt_emb, encoder_out,
                    self_attn_mask=tgt_mask
                )
              
                logits = model.output_layer(decoder_out)
                last_logits = logits[0, -1, :]  # (vocab_size,)
              
                # 获取前 beam_width 个概率最高的 token
                log_probs = torch.log_softmax(last_logits, dim=-1)
                top_log_probs, top_tokens = torch.topk(
                    log_probs, beam_width
                )
              
                for top_log_prob, top_token in zip(top_log_probs, top_tokens):
                    new_seq = torch.cat([
                        seq.squeeze(0),
                        top_token.unsqueeze(0)
                    ])
                    new_log_prob = log_prob + top_log_prob.item()
                    next_candidates.append((new_seq, new_log_prob))
          
            # 选择概率最高的 beam_width 个序列
            next_candidates.sort(key=lambda x: x[1], reverse=True)
            candidates = next_candidates[:beam_width]
          
            # 停止条件
            if all(seq[-1].item() == 2 for seq, _ in candidates):
                break
      
        # 返回最好的候选
        best_seq, _ = candidates[0]
        return best_seq

评估指标

BLEU 分数(机器翻译)

from nltk.translate.bleu_score import corpus_bleu, sentence_bleu

def calculate_bleu(references, hypotheses, max_n=4):
    """
    计算 BLEU 分数
  
    references: 参考翻译列表 [["the", "cat"], ["a", "dog"], ...]
    hypotheses: 生成的翻译列表 [["the", "cat"], ["a", "dog"], ...]
    """
    score = corpus_bleu(
        [[ref] for ref in references],
        hypotheses,
        weights=[1/max_n] * max_n
    )
    return score

# 使用
for src, ref in zip(src_sentences, ref_sentences):
    hyp = generate_translation(model, src)
    hyp_tokens = vocabulary.decode(hyp).split()
    ref_tokens = ref.split()
  
    bleu = sentence_bleu([ref_tokens], hyp_tokens)

其他指标

# 损失(来自验证集)
val_loss = evaluate(model, val_dataloader, criterion, device)

# 困惑度 (Perplexity)
perplexity = math.exp(val_loss)

# 准确率(token 级别)
def calculate_accuracy(model, dataloader, device):
    correct = 0
    total = 0
  
    with torch.no_grad():
        for src, tgt in dataloader:
            logits = model(src.to(device), tgt[:, :-1].to(device))
            predictions = torch.argmax(logits, dim=-1)
            targets = tgt[:, 1:].to(device)
          
            correct += (predictions == targets).sum().item()
            total += targets.numel()
  
    return correct / total

完整训练脚本示例

import torch
import torch.nn as nn
from torch.optim import Adam
from torch.utils.data import DataLoader

# 1. 加载数据
train_loader = DataLoader(train_dataset, batch_size=32, collate_fn=collate_fn)
val_loader = DataLoader(val_dataset, batch_size=32, collate_fn=collate_fn)

# 2. 创建模型
model = Transformer(
    src_vocab_size=10000,
    tgt_vocab_size=10000,
    d_model=512,
    num_layers=6,
    num_heads=8,
    d_ff=2048
).to(device)

# 3. 创建优化器和损失函数
optimizer = Adam(model.parameters(), lr=1e-4)
criterion = nn.CrossEntropyLoss(ignore_index=0)  # padding token

# 4. 训练循环
num_epochs = 10
for epoch in range(num_epochs):
    # 训练
    train_loss = train_epoch(model, train_loader, optimizer, criterion, device)
  
    # 验证
    val_loss = evaluate(model, val_loader, criterion, device)
  
    print(f"Epoch {epoch+1}/{num_epochs}")
    print(f"  Train Loss: {train_loss:.4f}")
    print(f"  Val Loss: {val_loss:.4f}")
  
    # 保存最好的模型
    if val_loss < best_val_loss:
        best_val_loss = val_loss
        torch.save(model.state_dict(), 'best_model.pt')

# 5. 评估
model.load_state_dict(torch.load('best_model.pt'))
test_loss = evaluate(model, test_loader, criterion, device)
print(f"Test Loss: {test_loss:.4f}")

# 6. 推理
model.eval()
with torch.no_grad():
    for src in test_sources:
        translation = generate_translation(model, src, device=device)
        print(f"Source: {vocabulary.decode(src)}")
        print(f"Translation: {vocabulary.decode(translation)}")

常见问题和解决方案

问题 原因 解决方案
训练损失不下降 学习率太小 增加学习率 (1e-3) 或使用学习率预热
梯度爆炸 初始化或学习率太高 梯度裁剪,减小学习率
梯度消失 网络太深或激活函数 检查激活函数,使用残差连接
验证损失增加 过拟合 增加 dropout,减小模型,数据增强
NaN 损失 数值不稳定 检查 softmax,添加 epsilon,梯度裁剪
生成输出重复 模型学习了错误的模式 调整生成策略(束搜索),增加多样性

部署和优化

模型保存和加载

# 保存检查点
torch.save({
    'epoch': epoch,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'loss': loss,
}, 'checkpoint.tar')

# 恢复训练
checkpoint = torch.load('checkpoint.tar')
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
epoch = checkpoint['epoch']

推理优化

# 使用 torch.jit 编译模型(加速)
scripted_model = torch.jit.script(model)

# 量化(减少模型大小)
quantized_model = torch.quantization.quantize_dynamic(
    model, {torch.nn.Linear}, dtype=torch.qint8
)

# 导出到 ONNX(跨框架兼容)
torch.onnx.export(
    model, (src, tgt), "model.onnx",
    input_names=['src', 'tgt'],
    output_names=['output']
)

结论

现在你已经学会了:

  1. ✓ Transformer 的每个组件
  2. ✓ 完整的模型架构
  3. ✓ 训练和评估流程
  4. ✓ 推理和生成方法

下一步可以:

  • 在真实数据集上训练(WMT, Europarl)
  • 尝试不同的架构变体(Marian, Transformer-XL)
  • 应用于其他任务(分类、摘要、QA)
  • 探索多语言模型

祝学习愉快!🚀

posted @ 2025-12-11 10:06  云岛夜川川  阅读(2)  评论(0)    收藏  举报