从原理到代码逐行解析,看完你就能自己写一个能用的翻译机器人
1. 序言:Seq2Seq 到底是干嘛的?
Seq2Seq(Sequence-to-Sequence,序列到序列)模型,顾名思义,它的核心任务就是把一个序列映射成另一个序列。这类模型广泛应用于机器翻译、文本摘要、对话生成等任务。
想象一个场景:你打开谷歌翻译,输入“我喜欢你”,点击翻译,输出“I like you”。这背后就是 Seq2Seq 模型在工作。
那么,Seq2Seq 模型和传统的分类模型有什么区别?
- 文本分类:输入一段话,输出一个固定的标签(比如“正面”或“负面”)。输出只有几种可能。
- 序列标注:输入一句话,给每个词打一个标签(比如“人名”、“地名”)。输出长度等于输入长度。
- Seq2Seq:输入一个序列,输出另一个序列,输入和输出的长度可以不同。这才符合现实世界的需求——中文“我喜欢你”是3个字,英文“I like you”是3个词,但有些句子的长度比例并不是1:1。
1.1 什么是“序列”?
在 NLP 领域,“序列”通常指一个由单词、字符或子词组成的有序列表。一句话就是一个序列,每个字/词按顺序排列。Seq2Seq 模型的核心就是理解输入序列的顺序结构,并生成与之对应的输出序列。
1.2 Seq2Seq 的历史与重要性
2014年,Google 的研究团队发表了论文《Sequence to Sequence Learning with Neural Networks》,首次提出了基于 LSTM 的 Seq2Seq 架构,并在 WMT-14 英德翻译任务上达到了 20.6 的 BLEU 分数,大幅超越当时的统计机器翻译系统。这一突破标志着神经机器翻译(NMT)时代的正式到来。
从技术演进的角度看,Seq2Seq 模型的意义不仅在于它解决了翻译问题,更在于它确立了一个通用的端到端学习框架——不需要手工设计复杂的特征工程和对齐规则,模型能够自动学习从输入到输出的映射关系。
2. 核心架构:编码器-解码器框架
2.1 整体结构概览
Seq2Seq 模型的核心由两个主要部分组成:编码器(Encoder) 和解码器(Decoder),二者通过隐藏状态(Hidden State) 进行信息传递。这两个组件通常由 RNN 及其变体(LSTM、GRU)构成。
2.2 编码器(Encoder):听懂输入
编码器的任务:逐个处理输入序列中的元素,并将整个序列的信息压缩成一个固定长度的上下文向量(Context Vector,也叫语义向量或“思想向量”)。
编码器在输入序列上的处理过程可以这样理解:
-
初始化一个隐藏状态 h0(通常为零向量)
-
输入序列的第一个词 x1 送入 RNN,产生隐藏状态 h1
-
输入序列的第二个词 x2 和 h1 一起送入 RNN,产生隐藏状态 h2
-
重复此过程,直到处理完最后一个词 xn,得到最终隐藏状态 hn
-
这个 hn 就是上下文向量 c,它包含了整个输入序列的信息
用数学语言来表达:
ht=f(xt,ht−1)(t=1,2,...,n)ht=f(xt,ht−1)(t=1,2,...,n)
c=hnc=hn
其中 ht 是 t 时刻的隐藏状态,f 是 RNN 单元(如 GRU)的函数,c 就是编码器输出的上下文向量。
2.3 解码器(Decoder):生成输出
解码器的任务:接收编码器传递过来的上下文向量 c,并基于它逐步生成目标序列。
解码器的初始隐藏状态 s0 被设置为编码器输出的上下文向量 c:
s0=cs0=c
然后,解码器在每个时间步执行以下操作:
-
接收当前步的输入 yt−1(第一步输入是特殊的起始标记 )
-
结合当前隐藏状态 st−1,生成新的隐藏状态 st
-
将 st 通过一个线性层映射到词表大小,得到每个词的概率分布
-
从概率分布中选择一个词作为输出 yt
-
将这个输出作为下一步的输入,重复直到生成结束标记
用数学语言表达解码过程:
st=g(yt−1,st−1,c)st=g(yt−1,st−1,c)
yt=argmax(Wst+b)yt=argmax(Wst+b)
其中 g 是解码器的 RNN 单元,yt−1 是上一个时间步输出的词,st 是当前隐藏状态,c 是来自编码器的上下文向量。
2.4 一个完整例子的流程
以翻译“我喜欢你” → “I like you”为例:
编码器阶段:
-
输入:"我" → 隐藏状态 h1
-
输入:"喜" → 结合 h1 → 隐藏状态 h2
-
输入:"欢" → 结合 h2 → 隐藏状态 h3
-
输入:"你" → 结合 h3 → 最终隐藏状态 h4
解码器阶段(推理时):
-
初始隐藏状态 = h4,输入 = → 输出 "I"
-
隐藏状态更新,输入 = "I" → 输出 "like"
-
隐藏状态更新,输入 = "like" → 输出 "you"
-
隐藏状态更新,输入 = "you" → 输出 (停止)
3. 模型训练的核心技巧:Teacher Forcing
3.1 什么是 Teacher Forcing?
在训练 Seq2Seq 模型时,我们使用一种名为Teacher Forcing(教师强制) 的巧妙技术。
为什么需要 Teacher Forcing?
回想一下,解码器在生成过程中,每一步的输入是上一步的输出。在训练初期,模型对什么都一窍不通,它的预测结果基本是随机的。如果让模型用自己的错误预测作为下一步的输入,那么错误会不断累积放大——前面猜错了一个词,后面的整个句子可能都变得乱七八糟。这就像一个学骑自行车的人,没人扶着,一开始就疯狂摔跤,很难进步。
Teacher Forcing 的核心思想很简单:在训练过程中,不喂给解码器它自己(可能错误)的预测,而是直接喂给它真实的目标词。
具体来说,假设目标句子是 I like you :
-
第一步:输入 ,真实输出应该是 "I"。模型输出 "I"(可能猜对,也可能猜错)
-
第二步:不管模型第一步猜的是什么,第二步的输入直接用真实词 "I",而不是用模型猜的词
-
第三步:输入用真实词 "like",而不是模型第二步猜的词
-
...以此类推
这种训练方式被称为“教师强制”,因为每一步都有一个“教师”(真实目标词)强制告诉模型“下一步应该输入什么”,将模型拉回正确的轨道上。
3.2 Teacher Forcing 为什么有效?
Teacher Forcing 带来了两个明显的好处:
① 训练更快更稳定
因为每一步输入的都是正确词,模型不会因为前面的错误而“跑偏”。梯度传播更平滑,模型收敛速度显著提升。
② 误差不会累积
传统的自回归训练(用预测作输入)存在严重的误差累积问题——一个时间步的小错误会在后续时间步被不断放大。Teacher Forcing 从根本上避免了这个问题。
3.3 训练 vs 推理:两种完全不同的模式
训练阶段:使用 Teacher Forcing
解码器输入: <sos> → 真实词 I → 真实词 like → 真实词 you → ...
解码器输出: 预测 I → 预测 like → 预测 you → 预测 <eos> → ...
损失计算: 对比预测值和真实值
推理阶段:使用自回归生成
解码器输入: <sos> → 预测 I → 预测 like → 预测 you → ...
解码器输出: 预测 I → 预测 like → 预测 you → 预测 <eos> → ...
这种“训练时老师扶着,推理时自己走”的设计,是 Seq2Seq 模型能够成功训练的关键。
4. 数据预处理:从原始文本到模型能吃的数字
计算机不认识“我”、“你”这些汉字,也不认识“I”、“like”这些英文单词。模型能理解的只有数字。因此,在训练之前,我们需要把所有文字转换成数字——这个过程就是数据预处理。
4.1 分词:把句子切成最小单位
分词(Tokenization) 是把一个句子切分成一个个“最小语义单元”的过程。
对于中文和英文,分词策略是不同的:
-
中文
:按字粒度切分。“我喜欢你。” → ["我","喜","欢","你","。"]。为什么按字分?因为中文的词之间没有空格,自动分词容易出错,而按字分简单可靠。
-
英文
:按词粒度切分,使用 NLTK 库。“I like you.” → ["I","like","you","."]。英文天然有空格分隔,按词分可以减少序列长度。
项目使用的数据集来自阿里云天池,共 29,155 对中英文平行语句,TSV 格式,每行包含英文和中文两列。
数据集下载地址:pan.baidu.com/s/1As2fpzjO…
4.2 词表构建:给每个词一个编号
我们把训练集中所有出现过的字/词收集起来,每个分配一个唯一的整数编号(索引)。例如:
"我" → 5
"喜" → 6
"欢" → 7
"你" → 8
"。" → 9
...
中英文的词表是分开构建的,因为两种语言的词汇完全不同。词表大小可以通过 max_size 参数限制,取频率最高的那些词,超过限制的丢弃(这被称为“截断”),可以控制模型参数量和训练速度。
4.3 特殊标记:为什么需要它们?
除了普通词汇,词表中还必须包含四个特殊标记:
标记 | 全称 | 用途 |
<pad> | Padding | 填充符:把所有句子统一到相同长度,填充在句子末尾。不参与损失计算。 |
<unk> | Unknown | 未知词:当遇到词表中不存在的词时(如生僻字、拼写错误),用它代替。 |
<sos> | Start of Sentence | 开始标记:告诉解码器“现在开始生成新句子”。只加在目标句子的开头。 |
<eos> | End of Sentence | 结束标记:告诉解码器“生成结束”。模型学会在输出这个标记时停止生成。 |
为什么需要 和 ?因为解码器的生成过程必须有明确的起点和终点。没有 ,解码器不知道什么时候开始生成;没有 ,模型会无限地生成下去,不知道什么时候停止。
4.4 统一长度:填充与截断
RNN 要求一个 batch 内的所有序列长度相同(才能以矩阵形式并行计算)。因此我们需要设定一个最大长度 SEQ_LEN(本项目设为 30)。
-
截断
:长度超过 SEQ_LEN 的句子,直接截取前 SEQ_LEN 个词。
-
填充
:长度不足 SEQ_LEN 的句子,在末尾补充 符号。
对于编码器的输入(中文) :不加 和 ,直接编码后填充。
对于解码器的目标(英文) :需要先添加 和 ,再编码和填充。
4.5 完整代码实现
config.py:配置文件
所有超参数集中管理,方便调整。
# config.py
from pathlib import Path
# ---------- 路径配置 ----------
BASE_DIR = Path(__file__).parent.parent
RAW_DATA_DIR = BASE_DIR / 'data' / 'raw' # 原始数据存放位置
PROCESSED_DATA_DIR = BASE_DIR / 'data' / 'processed' # 预处理后的数据
MODELS_DIR = BASE_DIR / 'models' # 保存模型参数
LOGS_DIR = BASE_DIR / 'logs' # TensorBoard 日志
# ---------- 数据参数 ----------
MAX_VOCAB_SIZE = 10000 # 词表最大大小(保留频率最高的词)
SEQ_LEN = 30 # 序列统一长度(短则补,长则截)
# ---------- 模型参数 ----------
EMBEDDING_DIM = 128 # 词向量维度(每个词用128个浮点数表示)
ENCODER_HIDDEN_DIM = 512 # 编码器 GRU 隐藏维度(单向)
DECODER_HIDDEN_DIM = 1024 # 解码器隐藏维度 = ENCODER_HIDDEN_DIM * 2
ENCODER_LAYERS = 1 # 编码器层数(可以加深,1层足够演示)
# ---------- 训练参数 ----------
BATCH_SIZE = 128
LEARNING_RATE = 0.001
EPOCHS = 30
tokenizer.py:分词器实现
分词器负责文本到数字的转换,是整个预处理的核心。
# tokenizer.py
import json
from collections import Counter
from typing import List, Optional
import nltk
from nltk.tokenize import word_tokenize
from nltk.tokenize.treebank import TreebankWordDetokenizer
# 确保 nltk 的分词数据已下载
try:
nltk.data.find('tokenizers/punkt')
except LookupError:
nltk.download('punkt')
class BaseTokenizer:
"""
分词器基类,提供通用的编码/解码方法和词表构建逻辑。
中文分词器和英文分词器都继承自此类。
"""
# 四个特殊标记
PAD_TOKEN = '<pad>' # 填充符
UNK_TOKEN = '<unk>' # 未知词
SOS_TOKEN = '<sos>' # 句子开始标记
EOS_TOKEN = '<eos>' # 句子结束标记
def __init__(self, vocab: List[str]):
"""
初始化分词器
参数:
vocab: 词表列表,索引顺序就是词表中词的顺序
"""
self.vocab = vocab
self.vocab_size = len(vocab)
# 建立双向映射:词→索引 和 索引→词
self.stoi = {word: idx for idx, word in enumerate(vocab)}
self.itos = {idx: word for word, idx in self.stoi.items()}
# 缓存特殊标记的索引,方便使用
self.pad_idx = self.stoi[self.PAD_TOKEN]
self.unk_idx = self.stoi[self.UNK_TOKEN]
self.sos_idx = self.stoi[self.SOS_TOKEN]
self.eos_idx = self.stoi[self.EOS_TOKEN]
@classmethod
def build_vocab(cls, sentences: List[str], max_size: Optional[int] = None, min_freq: int = 1):
"""
从句子列表构建词表(特殊标记自动添加)
参数:
sentences: 句子列表
max_size: 词表最大大小(保留频率最高的词)
min_freq: 最小出现频率,低于此频率的词被丢弃
返回:
词表列表,前4个位置是特殊标记
"""
# 统计所有词的出现频率
counter = Counter()
for sent in sentences:
tokens = cls.tokenize(sent)
counter.update(tokens)
# 按频率从高到低排序,取前 max_size 个
most_common = counter.most_common(max_size)
# 构建词表:先加特殊标记,再加普通词
vocab = [cls.PAD_TOKEN, cls.UNK_TOKEN, cls.SOS_TOKEN, cls.EOS_TOKEN]
for word, freq in most_common:
if freq >= min_freq:
vocab.append(word)
return vocab
@staticmethod
def tokenize(sentence: str) -> List[str]:
"""分词方法,子类必须实现"""
raise NotImplementedError
@staticmethod
def detokenize(tokens: List[str]) -> str:
"""去分词方法(把 token 列表还原成字符串),子类必须实现"""
raise NotImplementedError
def encode(self, sentence: str, max_len: int, add_sos_eos: bool = False) -> List[int]:
"""
把原始句子转换成索引列表,并统一长度
参数:
sentence: 原始字符串
max_len: 目标长度
add_sos_eos: 是否添加 <sos> 和 <eos> 标记
返回:
长度为 max_len 的整数列表
"""
# 1. 分词
tokens = self.tokenize(sentence)
# 2. 转索引,遇到不在词表中的词用 unk_idx
indices = [self.stoi.get(token, self.unk_idx) for token in tokens]
# 3. 如果需要加开始/结束标记
if add_sos_eos:
indices = [self.sos_idx] + indices + [self.eos_idx]
# 4. 截断或填充到 max_len
if len(indices) > max_len:
indices = indices[:max_len]
else:
indices = indices + [self.pad_idx] * (max_len - len(indices))
return indices
def decode(self, indices: List[int], skip_special: bool = True) -> str:
"""
把索引列表变回字符串
参数:
indices: 索引列表
skip_special: 是否跳过特殊标记(<pad>, <sos>, <eos>)
返回:
还原后的字符串
"""
if skip_special:
special_set = {self.pad_idx, self.sos_idx, self.eos_idx}
tokens = [self.itos[i] for i in indices if i not in special_set]
else:
tokens = [self.itos[i] for i in indices]
return self.detokenize(tokens)
class ChineseTokenizer(BaseTokenizer):
"""中文分词器:按字符切分"""
@staticmethod
def tokenize(sentence: str) -> List[str]:
# 去除空格,然后按字符拆分
return [ch for ch in sentence.strip() if ch != ' ']
@staticmethod
def detokenize(tokens: List[str]) -> str:
return ''.join(tokens)
class EnglishTokenizer(BaseTokenizer):
"""英文分词器:使用 NLTK 的 word_tokenize 按词切分"""
@staticmethod
def tokenize(sentence: str) -> List[str]:
return word_tokenize(sentence.strip())
@staticmethod
def detokenize(tokens: List[str]) -> str:
return TreebankWordDetokenizer().detokenize(tokens)
process.py:数据预处理主程序
# process.py
import pandas as pd
from sklearn.model_selection import train_test_split
from pathlib import Path
import json
import config
from tokenizer import ChineseTokenizer, EnglishTokenizer
def main():
# 设置路径
raw_path = Path(config.RAW_DATA_DIR) / 'cmn.txt'
processed_dir = Path(config.PROCESSED_DATA_DIR)
processed_dir.mkdir(parents=True, exist_ok=True)
# 1. 读取原始数据(TSV 格式,两列:英文、中文)
print("读取原始数据...")
df = pd.read_csv(raw_path, sep='\t', header=None, names=['en', 'zh'])
df = df.dropna() # 去掉空行
df = df[df['en'].str.strip().ne('') & df['zh'].str.strip().ne('')]
print(f"总共 {len(df)} 对句子")
# 2. 划分训练集和测试集(8:2 比例)
train_df, test_df = train_test_split(df, test_size=0.2, random_state=42)
print(f"训练集 {len(train_df)} 对,测试集 {len(test_df)} 对")
# 3. 构建词表(只用训练集)
print("构建中文词表...")
zh_vocab = ChineseTokenizer.build_vocab(
train_df['zh'].tolist(),
max_size=config.MAX_VOCAB_SIZE
)
print(f"中文词表大小: {len(zh_vocab)}")
print("构建英文词表...")
en_vocab = EnglishTokenizer.build_vocab(
train_df['en'].tolist(),
max_size=config.MAX_VOCAB_SIZE
)
print(f"英文词表大小: {len(en_vocab)}")
# 保存词表到文件(方便以后加载,不用重复构建)
with open(processed_dir / 'zh_vocab.json', 'w', encoding='utf-8') as f:
json.dump(zh_vocab, f, ensure_ascii=False)
with open(processed_dir / 'en_vocab.json', 'w', encoding='utf-8') as f:
json.dump(en_vocab, f, ensure_ascii=False)
# 初始化分词器
zh_tokenizer = ChineseTokenizer(zh_vocab)
en_tokenizer = EnglishTokenizer(en_vocab)
# 4. 编码训练集并保存
print("处理训练集...")
train_records = []
for _, row in train_df.iterrows():
# 中文:不加 sos/eos,只编码后填充
zh_ids = zh_tokenizer.encode(row['zh'], max_len=config.SEQ_LEN, add_sos_eos=False)
# 英文:需要加 sos/eos,因为解码器需要它们来开始和结束生成
en_ids = en_tokenizer.encode(row['en'], max_len=config.SEQ_LEN, add_sos_eos=True)
train_records.append({'zh': zh_ids, 'en': en_ids})
with open(processed_dir / 'train.json', 'w', encoding='utf-8') as f:
for rec in train_records:
f.write(json.dumps(rec, ensure_ascii=False) + '\n')
# 5. 编码测试集并保存
print("处理测试集...")
test_records = []
for _, row in test_df.iterrows():
zh_ids = zh_tokenizer.encode(row['zh'], max_len=config.SEQ_LEN, add_sos_eos=False)
en_ids = en_tokenizer.encode(row['en'], max_len=config.SEQ_LEN, add_sos_eos=True)
test_records.append({'zh': zh_ids, 'en': en_ids})
with open(processed_dir / 'test.json', 'w', encoding='utf-8') as f:
for rec in test_records:
f.write(json.dumps(rec, ensure_ascii=False) + '\n')
print("预处理完成!")
if __name__ == '__main__':
main()
4.6 自定义 Dataset 与 DataLoader
预处理后的数据保存为 JSON Lines 格式(每行一个 JSON 对象),我们需要一个 PyTorch Dataset 来加载它们。
# dataset.py
import torch
from torch.utils.data import Dataset, DataLoader
import json
from pathlib import Path
import config
class TranslationDataset(Dataset):
"""
翻译数据集类
加载预处理后的 JSON 文件,返回中文输入和英文目标
"""
def __init__(self, json_path):
self.data = []
with open(json_path, 'r', encoding='utf-8') as f:
for line in f:
self.data.append(json.loads(line))
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
item = self.data[idx]
# 中文输入(不加 sos/eos)
src = torch.tensor(item['zh'], dtype=torch.long)
# 英文目标(已加 sos 和 eos)
tgt = torch.tensor(item['en'], dtype=torch.long)
return src, tgt
def get_dataloader(train=True, batch_size=None):
"""
获取 DataLoader
参数:
train: True 返回训练集 DataLoader,False 返回测试集 DataLoader
batch_size: 批次大小,默认使用 config.BATCH_SIZE
"""
if batch_size is None:
batch_size = config.BATCH_SIZE
data_dir = Path(config.PROCESSED_DATA_DIR)
if train:
path = data_dir / 'train.json'
else:
path = data_dir / 'test.json'
dataset = TranslationDataset(path)
return DataLoader(dataset, batch_size=batch_size, shuffle=train, drop_last=train)
提示:drop_last=True 是为了确保每个 batch 大小一致,避免最后一个不完整的 batch 导致训练出错。如果训练集大小不是 batch_size 的整数倍,最后一个不完整的 batch 会被丢弃。
5. 模型实现:逐行代码详解
5.1 编码器(Encoder)完整代码
编码器包含两个主要部分:嵌入层(Embedding Layer) 和双向 GRU 层。
# model.py
import torch
import torch.nn as nn
import config
class Encoder(nn.Module):
"""
编码器:将输入序列(中文)编码成上下文向量
"""
def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers=1):
super().__init__()
self.hidden_dim = hidden_dim
self.num_layers = num_layers
# ---------- 嵌入层 ----------
# 作用:把每个字的编号(如 12)变成一个稠密向量(如 128 个浮点数)
# 为什么需要嵌入层?因为整数编号没有语义信息,"我"=12 和 "你"=13 之间的数值差 1 没有任何意义。
# 嵌入层让模型自己学习每个词的向量表示,意思相近的词向量也会相近。
self.embedding = nn.Embedding(vocab_size, embedding_dim)
# ---------- 双向 GRU ----------
# GRU(Gated Recurrent Unit)是 RNN 的改进版,比基本 RNN 效果好,比 LSTM 简单。
# bidirectional=True:同时从左到右和从右到左处理序列,让每个位置都能看到完整的上下文。
# batch_first=True:输入形状为 (batch, seq, feature) 而不是 (seq, batch, feature)
self.gru = nn.GRU(
input_size=embedding_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
bidirectional=True
)
def forward(self, x):
"""
前向传播
参数:
x: 输入张量,形状 (batch_size, seq_len),里面是每个字的编号
返回:
hidden: 最终隐藏状态,形状 (num_layers*2, batch_size, hidden_dim)
因为是双向,num_layers*2 = 2
"""
# 步骤1:嵌入层转换
# 输入 x: (batch_size, seq_len)
# 输出 embedded: (batch_size, seq_len, embedding_dim)
embedded = self.embedding(x)
# 步骤2:双向 GRU 处理
# output: 每个时间步的输出,形状 (batch_size, seq_len, hidden_dim*2)
# hidden: 最后一个时间步的隐藏状态(双向),形状 (2, batch_size, hidden_dim)
output, hidden = self.gru(embedded)
# 返回 hidden,这就是编码器的输出(上下文向量)
# 注意:这里的 hidden 包含前向和后向两个方向的最后状态
return hidden
编码器的关键点:
-
嵌入层
:nn.Embedding(vocab_size, embedding_dim) 创建了一个可训练的查找表。给定一个整数索引(词的编号),它返回对应的向量。这些向量一开始是随机的,在训练过程中不断调整,最终使得语义相近的词向量在向量空间中距离更近。
-
双向 GRU
:bidirectional=True 让 GRU 同时从两个方向处理序列。输出维度会翻倍(hidden_dim*2),因为前向和后向的隐藏状态拼接在一起。这样做的好处是:每个位置的隐藏状态都能同时看到左边和右边的上下文信息,理解更准确。
-
为什么返回的是 hidden 而不是 output
:在传统的 Seq2Seq 中,编码器的最后一个隐藏状态被当作整个输入序列的语义摘要。这个摘要向量就是传递给解码器的上下文向量。不过严格来说,在双向 GRU 中,我们需要把前向和后向的最后状态拼接起来才是完整的上下文向量——这个拼接操作在训练循环中完成。
5.2 解码器(Decoder)完整代码
解码器包含三层:嵌入层、单向 GRU、全连接层(线性层)。
class Decoder(nn.Module):
"""
解码器:根据上下文向量逐步生成目标序列(英文)
"""
def __init__(self, vocab_size, embedding_dim, hidden_dim):
super().__init__()
# ---------- 嵌入层 ----------
# 和编码器的嵌入层作用相同,把英文词的编号转成向量
self.embedding = nn.Embedding(vocab_size, embedding_dim)
# ---------- 单向 GRU ----------
# 注意:解码器是单向的,因为生成时只能看到已经生成的词,不能看到未来的词
# 这是自回归生成的自然要求
self.gru = nn.GRU(
input_size=embedding_dim,
hidden_size=hidden_dim,
batch_first=True
)
# ---------- 全连接层 ----------
# 把 GRU 输出的向量映射成词表大小的概率分布
# 例如 hidden_dim=1024,vocab_size=10000,则线性层将 1024 维向量映射成 10000 维
# 这 10000 个数字经过 softmax 后就是每个词的概率
self.fc = nn.Linear(hidden_dim, vocab_size)
def forward(self, x, hidden):
"""
单步前向传播(一个时间步)
参数:
x: 当前步的输入,形状 (batch_size, 1),是一个词的编号
hidden: 上一步的隐藏状态,形状 (1, batch_size, hidden_dim)
初始时来自编码器的上下文向量
返回:
output: 当前步的输出,形状 (batch_size, 1, vocab_size)
代表每个候选词的概率(logits,未经过 softmax)
hidden: 新的隐藏状态,用于下一步
"""
# 步骤1:嵌入
# x: (batch_size, 1) → embedded: (batch_size, 1, embedding_dim)
embedded = self.embedding(x)
# 步骤2:GRU 一步
# 输入 embedded 和上一步的 hidden,输出当前步的 output 和新的 hidden
# output: (batch_size, 1, hidden_dim)
# hidden: (1, batch_size, hidden_dim)
output, hidden = self.gru(embedded, hidden)
# 步骤3:全连接层,映射到词表大小
# output: (batch_size, 1, hidden_dim) → (batch_size, 1, vocab_size)
output = self.fc(output)
return output, hidden
解码器的关键点:
-
单向 GRU
:与编码器不同,解码器只能用单向。因为在生成过程中,我们只能利用已经生成的信息来预测下一个词,不能“偷看”未来的词。这是自回归生成的核心约束。
-
自回归机制
:解码器在生成一个词之后,把它作为输入去生成下一个词。这种“把自己上一时刻的输出当作当前时刻的输入”的方式,就是自回归生成。推理阶段是这样,但训练阶段我们用了 Teacher Forcing。
-
全连接层
:GRU 的输出是一个隐藏向量(维度 hidden_dim),这个向量包含了当前生成步骤的语义信息。全连接层把这个向量映射到词表大小,得到一个分布。分布中概率最大的词就是我们预测的下一个词。
5.3 训练循环完整代码
训练时需要把编码器和解码器串联起来,并实现 Teacher Forcing 策略。
# train.py
import torch
import torch.nn as nn
import torch.optim as optim
from tqdm import tqdm
import config
from dataset import get_dataloader
from model import Encoder, Decoder
from tokenizer import ChineseTokenizer, EnglishTokenizer
import json
from pathlib import Path
def load_tokenizers():
"""从保存的 json 文件加载中英文词表和分词器"""
with open(Path(config.PROCESSED_DATA_DIR) / 'zh_vocab.json', 'r', encoding='utf-8') as f:
zh_vocab = json.load(f)
with open(Path(config.PROCESSED_DATA_DIR) / 'en_vocab.json', 'r', encoding='utf-8') as f:
en_vocab = json.load(f)
zh_tokenizer = ChineseTokenizer(zh_vocab)
en_tokenizer = EnglishTokenizer(en_vocab)
return zh_tokenizer, en_tokenizer
def train_one_epoch(encoder, decoder, dataloader, criterion, optimizer, device):
"""
训练一个 epoch(所有训练数据过一遍)
参数:
encoder: 编码器模型
decoder: 解码器模型
dataloader: 训练数据加载器
criterion: 损失函数
optimizer: 优化器
device: 设备(cuda/cpu)
返回:
这个 epoch 的平均损失值
"""
encoder.train() # 设置为训练模式(启用 dropout 等)
decoder.train()
total_loss = 0
# tqdm 显示进度条
for src, tgt in tqdm(dataloader, desc="Training"):
# src: (batch_size, SEQ_LEN) 中文输入
# tgt: (batch_size, SEQ_LEN) 英文目标(已包含 <sos> 和 <eos>)
src = src.to(device)
tgt = tgt.to(device)
optimizer.zero_grad() # 梯度清零(否则会累加)
# ---------- 编码器 ----------
encoder_hidden = encoder(src)
# encoder_hidden: (2, batch_size, ENCODER_HIDDEN_DIM)
# 2 代表两个方向(前向和后向)
# 将双向的隐藏状态拼接成上下文向量
# encoder_hidden[-2]: 前向最后一层的隐藏状态 (batch_size, ENCODER_HIDDEN_DIM)
# encoder_hidden[-1]: 后向最后一层的隐藏状态 (batch_size, ENCODER_HIDDEN_DIM)
# context: (batch_size, ENCODER_HIDDEN_DIM*2) = (batch_size, 1024)
forward_hidden = encoder_hidden[-2]
backward_hidden = encoder_hidden[-1]
context = torch.cat([forward_hidden, backward_hidden], dim=1)
# ---------- 解码器(Teacher Forcing)----------
# 初始化解码器的隐藏状态(需要添加一层维度)
decoder_hidden = context.unsqueeze(0) # (1, batch_size, 1024)
# 解码器的第一步输入:<sos>,取目标句子的第一个词
decoder_input = tgt[:, 0:1] # (batch_size, 1)
decoder_outputs = [] # 存放每个时间步的输出
# 从第1步到第 SEQ_LEN-1 步
# 因为第0步是 <sos>,最后一步是 <eos>,我们需要预测从第1步到最后一步
for step in range(1, config.SEQ_LEN):
# 解码器前向传播
decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden)
# decoder_output: (batch_size, 1, vocab_size)
decoder_outputs.append(decoder_output)
# Teacher Forcing:下一步的输入直接使用真实的目标词
# 不管 decoder 上一步预测了什么,我们都喂给它正确的词
decoder_input = tgt[:, step:step+1] # (batch_size, 1)
# 将所有时间步的输出拼接在一起
# decoder_outputs 列表中有 SEQ_LEN-1 个元素,每个形状 (batch, 1, vocab)
# cat 后形状: (batch, SEQ_LEN-1, vocab)
decoder_outputs = torch.cat(decoder_outputs, dim=1)
# 目标序列:去掉 <sos>,从第1个词到最后一个词(包括 <eos>)
targets = tgt[:, 1:] # (batch_size, SEQ_LEN-1)
# ---------- 计算损失 ----------
# CrossEntropyLoss 要求输入形状 (N, C) 和 (N,)
# 其中 N 是所有 token 的总数,C 是词表大小
loss = criterion(
decoder_outputs.reshape(-1, decoder_outputs.shape[-1]), # (batch*(SEQ_LEN-1), vocab)
targets.reshape(-1) # (batch*(SEQ_LEN-1))
)
# 反向传播
loss.backward()
optimizer.step()
total_loss += loss.item()
return total_loss / len(dataloader)
def main():
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Using device: {device}")
# 加载分词器和词表
zh_tokenizer, en_tokenizer = load_tokenizers()
# 创建模型
encoder = Encoder(
vocab_size=zh_tokenizer.vocab_size,
embedding_dim=config.EMBEDDING_DIM,
hidden_dim=config.ENCODER_HIDDEN_DIM,
num_layers=config.ENCODER_LAYERS
).to(device)
decoder = Decoder(
vocab_size=en_tokenizer.vocab_size,
embedding_dim=config.EMBEDDING_DIM,
hidden_dim=config.DECODER_HIDDEN_DIM
).to(device)
# 损失函数:交叉熵,忽略 <pad> 位置的损失
criterion = nn.CrossEntropyLoss(ignore_index=en_tokenizer.pad_idx)
# 优化器:Adam,学习率 0.001
optimizer = optim.Adam(
list(encoder.parameters()) + list(decoder.parameters()),
lr=config.LEARNING_RATE
)
# 加载数据
train_loader = get_dataloader(train=True)
# 训练循环
for epoch in range(1, config.EPOCHS + 1):
print(f"\n========== Epoch {epoch} / {config.EPOCHS} ==========")
avg_loss = train_one_epoch(encoder, decoder, train_loader, criterion, optimizer, device)
print(f"Average Loss: {avg_loss:.4f}")
# 每 5 个 epoch 保存一次模型
if epoch % 5 == 0:
torch.save(encoder.state_dict(), config.MODELS_DIR / f'encoder_epoch{epoch}.pt')
torch.save(decoder.state_dict(), config.MODELS_DIR / f'decoder_epoch{epoch}.pt')
print(f"Model saved at epoch {epoch}")
# 保存最终模型
torch.save(encoder.state_dict(), config.MODELS_DIR / 'encoder_final.pt')
torch.save(decoder.state_dict(), config.MODELS_DIR / 'decoder_final.pt')
print("Training completed!")
if __name__ == '__main__':
main()
输出结果:
========== 前面45轮省略。。。 ==========
========== EPOCH:45 ==========
训练:: 100%|██████████| 365/365 [00:01<00:00, 193.33it/s]
本轮训练损失: 0.06847477024548675
模型保存成功!
========== EPOCH:46 ==========
训练:: 100%|██████████| 365/365 [00:01<00:00, 191.92it/s]
本轮训练损失: 0.06932613608261494
========== EPOCH:47 ==========
训练:: 100%|██████████| 365/365 [00:02<00:00, 179.68it/s]
本轮训练损失: 0.07184863890176767
========== EPOCH:48 ==========
训练:: 100%|██████████| 365/365 [00:02<00:00, 178.81it/s]
训练:: 0%| | 0/365 [00:00<?, ?it/s]本轮训练损失: 0.09403942644800226
========== EPOCH:49 ==========
训练:: 100%|██████████| 365/365 [00:01<00:00, 190.23it/s]
本轮训练损失: 0.12599861240142013
========== EPOCH:50 ==========
训练:: 100%|██████████| 365/365 [00:02<00:00, 180.61it/s]
本轮训练损失: 0.10180483621685472
训练循环的关键点:
-
上下文向量的拼接
:双向 GRU 输出的 hidden 形状是 (2, batch, hidden_dim)。我们需要把前向(索引 -2)和后向(索引 -1)的最后状态拼接起来,形成一个 (batch, hidden_dim*2) 的向量,这才是完整的上下文向量。
-
Teacher Forcing 的实现
:在 for step in range(1, SEQ_LEN) 循环中,decoder_input 始终从真实目标 tgt 中取,而不是用上一步的预测。这是 Teacher Forcing 的核心。
-
损失函数的 ignore_index
:设置 ignore_index=en_tokenizer.pad_idx 后,损失函数会自动忽略所有 位置,不会因为这些位置而惩罚模型。这是非常关键的,因为我们不希望模型去学习预测那些填充的无意义符号。
-
梯度清零
:每个 batch 开始前需要 optimizer.zero_grad(),否则梯度会累加到下一个 batch。
5.4 推理与翻译完整代码
训练完成后,我们需要一个推理函数来实际翻译新句子。推理时必须使用自回归生成,不能使用 Teacher Forcing。
# predict.py
import torch
import config
from model import Encoder, Decoder
from tokenizer import ChineseTokenizer, EnglishTokenizer
import json
from pathlib import Path
def load_models(device):
"""加载训练好的模型和分词器"""
# 加载词表
with open(Path(config.PROCESSED_DATA_DIR) / 'zh_vocab.json', 'r', encoding='utf-8') as f:
zh_vocab = json.load(f)
with open(Path(config.PROCESSED_DATA_DIR) / 'en_vocab.json', 'r', encoding='utf-8') as f:
en_vocab = json.load(f)
zh_tokenizer = ChineseTokenizer(zh_vocab)
en_tokenizer = EnglishTokenizer(en_vocab)
# 创建模型
encoder = Encoder(
vocab_size=zh_tokenizer.vocab_size,
embedding_dim=config.EMBEDDING_DIM,
hidden_dim=config.ENCODER_HIDDEN_DIM,
num_layers=config.ENCODER_LAYERS
).to(device)
decoder = Decoder(
vocab_size=en_tokenizer.vocab_size,
embedding_dim=config.EMBEDDING_DIM,
hidden_dim=config.DECODER_HIDDEN_DIM
).to(device)
# 加载训练好的权重
encoder.load_state_dict(torch.load(config.MODELS_DIR / 'encoder_final.pt', map_location=device))
decoder.load_state_dict(torch.load(config.MODELS_DIR / 'decoder_final.pt', map_location=device))
encoder.eval()
decoder.eval()
return encoder, decoder, zh_tokenizer, en_tokenizer
def translate(sentence, encoder, decoder, zh_tokenizer, en_tokenizer, device, max_len=None):
"""
翻译单个中文句子
参数:
sentence: 原始中文句子,例如 "我喜欢你"
encoder: 编码器模型
decoder: 解码器模型
zh_tokenizer: 中文分词器
en_tokenizer: 英文分词器
device: 设备
max_len: 最大生成长度,默认使用 config.SEQ_LEN
返回:
英文翻译句子
"""
if max_len is None:
max_len = config.SEQ_LEN
with torch.no_grad(): # 推理时不需要计算梯度,节省内存
# ---------- 编码 ----------
# 将中文句子编码成索引序列(不加 sos/eos)
input_ids = zh_tokenizer.encode(sentence, max_len=config.SEQ_LEN, add_sos_eos=False)
src_tensor = torch.tensor([input_ids], device=device) # (1, SEQ_LEN)
encoder_hidden = encoder(src_tensor)
# encoder_hidden: (2, 1, ENCODER_HIDDEN_DIM)
# 拼接上下文向量
forward_hidden = encoder_hidden[-2] # (1, ENCODER_HIDDEN_DIM)
backward_hidden = encoder_hidden[-1] # (1, ENCODER_HIDDEN_DIM)
context = torch.cat([forward_hidden, backward_hidden], dim=1) # (1, 1024)
decoder_hidden = context.unsqueeze(0) # (1, 1, 1024)
# ---------- 解码(自回归生成)----------
# 初始输入:<sos> 标记
decoder_input = torch.tensor([[en_tokenizer.sos_idx]], device=device) # (1, 1)
generated_indices = [] # 存放生成的 token 索引
for _ in range(max_len - 1): # 最多生成 max_len-1 个词
decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden)
# decoder_output: (1, 1, vocab_size)
# 贪心解码:选择概率最高的词(argmax 找到最大值索引)
next_token = decoder_output.argmax(dim=-1) # (1, 1)
token_id = next_token.item()
# 如果遇到结束符,停止生成
if token_id == en_tokenizer.eos_idx:
break
generated_indices.append(token_id)
# 自回归:把当前输出作为下一步的输入
decoder_input = next_token
# 将索引序列解码成英文句子
english_sentence = en_tokenizer.decode(generated_indices, skip_special=True)
return english_sentence
def interactive_translate():
"""交互式翻译程序"""
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Loading models on {device}...")
encoder, decoder, zh_tokenizer, en_tokenizer = load_models(device)
print("Models loaded! Enter Chinese sentences to translate (type 'quit' to exit).")
while True:
user_input = input("\n中文: ").strip()
if user_input.lower() in ['quit', 'exit', 'q']:
print("Goodbye!")
break
if not user_input:
print("请输入内容")
continue
try:
result = translate(user_input, encoder, decoder, zh_tokenizer, en_tokenizer, device)
print(f"英文: {result}")
except Exception as e:
print(f"翻译出错: {e}")
if __name__ == '__main__':
interactive_translate()
推理代码的关键点:
-
torch.no_grad()
:推理时不需要计算梯度,用这个上下文管理器可以节省大量内存和计算。
-
贪心解码(Greedy Decoding)
:每个时间步选择概率最高的词(argmax)。这是最简单的解码策略,优点是速度快,缺点是可能不是全局最优。
-
自回归生成
:decoder_input = next_token 将当前预测作为下一步的输入,这是与训练阶段最大的不同。
-
结束条件
:当生成 标记或达到最大长度时停止生成。
6. 模型评估:BLEU 分数
训练完模型,我们怎么知道它翻译得好不好?总不能人工看几千条翻译结果吧。BLEU 分数就是解决这个问题而设计的。
6.1 BLEU 是什么?
BLEU(Bilingual Evaluation Understudy,双语评估替补) 是一种用于自动评估机器翻译文本质量的算法,由 IBM 的研究团队于 2002 年提出。它的核心思想很简单:机器翻译越接近专业人工翻译,质量就越好。
BLEU 通过计算候选翻译(模型生成)与参考翻译(人工标准答案)之间的 n-gram 匹配数量来评估翻译质量。n-gram 是指由 n 个词组成的连续序列:
6.2 BLEU 的计算方法
BLEU 的计算包含三个步骤:
① 改进的 n-gram 精度(Modified n-gram Precision)
对于 1-4 的每个 n,计算候选翻译中出现在参考翻译中的 n-gram 所占的比例。为避免重复词被过度奖励,每个 n-gram 的匹配次数被限制为它在参考翻译中出现的最大次数。
② 几何平均(Geometric Mean)
将 1-gram 到 4-gram 的精度取几何平均。这样做是为了确保单一类型 n-gram 的低精度会显著拉低最终分数。
③ 简短惩罚(Brevity Penalty)
如果候选翻译的长度(c)显著短于参考翻译的长度(r),BLEU 会引入一个惩罚因子,防止模型通过生成过短的句子来获得高精度。
最终 BLEU 分数范围:0 到 1,通常乘以 100 以百分比表示。分数越接近 1(100%),翻译质量越高。
6.3 评估代码实现
# evaluate.py
import torch
from nltk.translate.bleu_score import corpus_bleu
from tqdm import tqdm
import config
from dataset import get_dataloader
from predict import load_models, translate
import json
def evaluate(encoder, decoder, test_loader, zh_tokenizer, en_tokenizer, device):
"""
在测试集上评估模型
返回:
BLEU-4 分数(0-1 之间)
"""
references = [] # 参考翻译列表
hypotheses = [] # 模型生成的翻译列表
special_indices = {en_tokenizer.pad_idx, en_tokenizer.sos_idx, en_tokenizer.eos_idx}
for src, tgt in tqdm(test_loader, desc="Evaluating"):
src = src.to(device)
# tgt: (batch_size, SEQ_LEN) 参考翻译的索引
# 批量翻译(可以优化为批量处理,这里为清晰起见逐句处理)
for i in range(src.size(0)):
# 获取单句中文字符串(这里需要解码 src,实际项目中可以在 dataset 中保留原始句子)
# 为简化,这里假设我们直接使用 translate 函数处理
# 实际项目中建议实现批量推理
pass
# 收集参考翻译(去掉特殊标记)
for seq in tgt.tolist():
ref = [idx for idx in seq if idx not in special_indices]
references.append([ref]) # corpus_bleu 要求每个参考是列表的列表
# 计算 BLEU-4 分数
bleu_score = corpus_bleu(references, hypotheses)
return bleu_score
def main():
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 加载模型和分词器
encoder, decoder, zh_tokenizer, en_tokenizer = load_models(device)
# 加载测试数据
test_loader = get_dataloader(train=False)
# 评估
bleu = evaluate(encoder, decoder, test_loader, zh_tokenizer, en_tokenizer, device)
print(f"\n========== Evaluation Results ==========")
print(f"BLEU-4 Score: {bleu:.4f} ({bleu*100:.2f}%)")
print(f"========================================")
if __name__ == '__main__':
main()
提示:BLEU 分数虽然被广泛使用,但也有一些局限性。例如,它不善于评估语义相似性——如果模型用了不同的词表达了相同的意思(“苹果”vs“这种水果”),BLEU 可能给出低分,而实际上翻译是对的。因此在做实际项目时,可以结合人工评估或其他指标一起使用。
输出结果:
词表加载成功!
模型加载成功!
评估结果:
BLEU 评分: 0.18761972609375802
7. 传统 Seq2Seq 的局限性与进化之路
7.1 信息瓶颈问题
传统 Seq2Seq 模型的核心问题是信息瓶颈:编码器必须把整个源句子压缩成一个固定长度的上下文向量。对于短句子,这没问题;但对于长句子,信息很容易在压缩过程中丢失。
这就像让你听完一个 1000 字的演讲后,只用一句话概括核心内容。你可以概括大意,但细节一定会丢失。
7.2 注意力机制的引入
2015 年,Bahdanau 等人在 Seq2Seq 模型中引入了注意力机制(Attention Mechanism) ,解决了上述问题。注意力机制的核心思想是:允许解码器在生成每个输出词时,动态地关注输入序列的不同部分。
注意力机制的工作原理:
-
计算注意力分数
:解码器当前的隐藏状态 st 与编码器在所有时间步的隐藏状态 h1,h2,...,hn 进行比较,计算相关性分数 eti=a(st−1,hi)。
-
归一化得到权重
:使用 Softmax 将分数归一化,得到一组权重 αti=exp(eti)∑k=1nexp(etk),权重之和为 1,代表了每个源词对当前目标词的重要性。
-
计算动态上下文向量
:用权重对编码器的所有隐藏状态进行加权求和,得到新的动态上下文向量 ct=∑i=1nαtihi。
-
结合预测
:将解码器当前隐藏状态 st 和动态上下文向量 ct 结合起来,预测下一个词。
注意力机制带来约 10% 的 BLEU 分数提升,尤其在处理长句和复杂语法结构时效果显著。
注意力机制有多种实现变体,最著名的是 Bahdanau 注意力(加性注意力,使用多层感知机计算分数)和 Luong 注意力(乘性注意力,使用点积或双线性变换)。两者主要区别在于评分函数和对齐方式的不同,Luong 注意力在计算效率上更有优势。
7.3 从 Seq2Seq 到 Transformer
2017 年,Google 发表了著名的论文《Attention Is All You Need》,提出了 Transformer 架构。Transformer 在 Seq2Seq 的编码器-解码器框架基础上,完全抛弃了 RNN,只使用自注意力机制(Self-Attention)来捕捉序列中的依赖关系。
Transformer 相比传统 Seq2Seq 的优势:
-
并行计算
:RNN 必须按顺序处理序列,而 Transformer 可以同时处理整个序列,训练速度大幅提升。
-
长距离依赖
:自注意力机制允许序列中的每个元素直接关注所有其他元素,不受距离限制,解决了 RNN 的梯度消失问题。
尽管 Transformer 已经成为主流,但理解 Seq2Seq 的核心思想(编码-解码框架、Teacher Forcing、自回归生成)仍然是掌握现代 NLP 生成模型的重要基础。
8. 项目完整结构与运行指南
translation-seq2seq/
│
├── data/
│ ├── raw/
│ │ └── cmn.txt # 原始数据集(TSV 格式,英-中对照)
│ └── processed/ # 预处理后的文件
│ ├── train.jsonl # 训练集(JSON Lines)
│ └── test.jsonl # 测试集(JSON Lines)
│
├── models/ # 保存训练好的模型权重
│ ├── best_model.pt
│ ├── cn_vocab.txt
│ └── en_vocab.txt
│
├── logs/ # TensorBoard 日志
│
├── src/ # 源代码
│ ├── config.py # 配置文件(所有超参数)
│ ├── tokenizer.py # 中英文分词器
│ ├── dataset.py # PyTorch Dataset
│ ├── process.py # 数据预处理脚本
│ ├── model.py # Encoder 和 Decoder 定义
│ ├── train.py # 训练脚本
│ ├── predict.py # 交互式翻译脚本
│ └── evaluate.py # BLEU 评估脚本
运行步骤:
-
安装依赖
:
-
bash
-
pip install torch pandas nltk scikit-learn tqdm tensorboard
-
准备数据
:下载 cmn.txt 放到 data/raw/ 目录下
-
数据预处理
:
-
bash
-
python src/process.py
-
训练模型
:
-
bash
-
python src/train.py
-
交互翻译
:
-
bash
-
python src/predict.py
-
评估模型
:
-
bash
-
python src/evaluate.py
9. 总结
经过一整篇文章的学习,我们把 Seq2Seq 模型从原理到代码完整过了一遍。现在回顾一下核心知识点:
9.1 核心概念速查表
概念 | 一句话解释 |
Seq2Seq | 把一个序列映射成另一个序列的神经网络架构,输入输出长度可以不同 |
编码器(Encoder) | 用双向 RNN 把输入句子压缩成语义向量 |
解码器(Decoder) | 用单向 RNN 从语义向量逐步生成目标句子 |
上下文向量 | 编码器输出的最后一个隐藏状态,代表整个输入句子的语义 |
Teacher Forcing | 训练时用真实目标词作为解码器输入,而不是模型自己的预测 |
自回归生成 | 推理时用自己的上一步输出作为下一步输入 |
贪心解码 | 每一步选概率最高的词,简单但可能不是全局最优 |
BLEU | 通过 n-gram 匹配度评估翻译质量的自动指标 |
注意力机制 | 允许解码器在生成时动态关注源句的不同部分,解决信息瓶颈 |
Transformer | 在 Seq2Seq 框架上用自注意力取代 RNN,实现并行计算 |
9.2 关于本文的代码
本文提供的代码是完整可运行的,你可以直接复制使用。需要注意几点:
-
确保所有文件放在正确的目录结构下
-
训练可能需要一些时间(取决于硬件和 EPOCHS 设置)
-
如果显存不足,可以减小 BATCH_SIZE
**完整代码下载:**pan.baidu.com/s/1-de-mxzj…
9.3 进阶学习建议
如果你已经掌握了本文的内容,可以尝试以下扩展:
-
添加注意力机制
:在解码器中实现 Bahdanau 或 Luong 注意力,观察 BLEU 分数的提升。
-
换成 LSTM
:用 LSTM 替换 GRU,对比两种 RNN 变体的效果。
-
增加编码器层数
:设置 ENCODER_LAYERS = 2 或 3,观察模型性能变化。
-
使用更大的数据集
:尝试 WMT 等大规模机器翻译数据集,体验真正的工业级翻译。
-
学习 Transformer
:基于本文的编码器-解码器框架理解,进一步学习 Transformer 的自注意力机制和位置编码。
希望这篇文章能帮你真正理解 Seq2Seq。如果有任何问题或建议,欢迎交流讨论!