《动手学深度学习》机器翻译与数据集:英法平行语料的全流程构建与加载
《动手学深度学习》机器翻译与数据集:英法平行语料的全流程构建与加载
机器翻译(machine translation)是将文本序列从一种语言自动翻译成另一种语言的任务,也是序列转换(sequence transduction)模型最经典的基准问题。本篇以《动手学深度学习》中文版 machine-translation-and-dataset.md 为核心,完整讲解从 Tatoeba 项目英法双语平行语料下载、预处理、词元化、词表构建,到截断/填充并组装成可训练小批量的全部代码实现,并对照仓库 d2l/torch.py 等后端源码剖析每个函数背后的实现细节。读完本文,你将掌握一套可直接复用的双语平行语料处理流水线,为后续 seq2seq、注意力机制等序列转换模型的训练打好数据基础。
机器翻译问题与数据集的特殊性
语言模型(如 language-models-and-dataset.md 中介绍的任务)使用的语料是单一语言的文本;而机器翻译的数据集则由源语言与目标语言的文本序列对(parallel sentence pairs)组成。以“英语→法语”翻译为例,英语是源语言(source language),法语是目标语言(target language),数据集中的每一行都是制表符(\t)分隔的一对文本序列,每个文本序列可以是一个句子,也可以是包含多个句子的一个段落。
正是因为数据形态不同,机器翻译的数据集不能复用语言模型的预处理程序,需要一套专门的处理流程,包括:
- 下载并预处理原始平行语料;
- 对源语言与目标语言分别做单词级词元化;
- 分别为两种语言构建独立词表;
- 通过截断与填充统一序列长度,组装成可训练的小批量。
下面依次展开每一步。
下载并预处理英法数据集
数据下载与读取
本节使用的“英-法”数据集来自 Tatoeba 项目的双语句子对。仓库中的 d2l/torch.py 将数据集注册到统一的下载中心 DATA_HUB:
d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip',
'94646ad1522d915e7b0f9296181140edcf86a4f5')
其中 DATA_URL 指向 http://d2l-data.s3-accelerate.amazonaws.com/(见 d2l/torch.py),第二个字段是文件的 SHA-1 校验值,用于保证下载内容完整。read_data_nmt 通过 d2l.download_extract('fra-eng') 完成下载与解压,然后读取其中的 fra.txt:
def read_data_nmt():
"""载入“英语-法语”数据集"""
data_dir = d2l.download_extract('fra-eng')
with open(os.path.join(data_dir, 'fra.txt'), 'r',
encoding='utf-8') as f:
return f.read()
raw_text = read_data_nmt()
print(raw_text[:75])
从源码看,download 会先检查本地缓存文件(默认缓存在 ../data 目录)并校验 SHA-1,命中缓存则直接复用,否则重新下载(见 d2l/torch.py);download_extract 支持 .zip 与 .tar/.gz 两类压缩格式(见 d2l/torch.py)。这意味着同样的函数在后文其他章节的数据集加载中也被复用,体现了 d2l 工具库的通用性。
原始文本预处理
原始文本不能直接用于建模,需要经过几个预处理步骤:用普通空格替换不间断空格(non-breaking space,Unicode 中的 \u202f 窄空格与 \xa0 不换行空格)、把所有大写字母转成小写,并在单词与标点符号之间插入空格,使标点符号成为独立的词元。preprocess_nmt 完整实现了这三步:
def preprocess_nmt(text):
"""预处理“英语-法语”数据集"""
def no_space(char, prev_char):
return char in set(',.!?') and prev_char != ' '
# 使用空格替换不间断空格
# 使用小写字母替换大写字母
text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
# 在单词和标点符号之间插入空格
out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char
for i, char in enumerate(text)]
return ''.join(out)
text = preprocess_nmt(raw_text)
print(text[:80])
其中 no_space 内层函数用于判断:当当前字符是 ,、.、!、? 之一,且前一个字符不是空格时,就在该标点前插入空格。这样形如 "Hello,world." 的文本会被规范化为 "hello , world .",便于后续按空格切分成词元。
单词级词元化
与语言模型章节采用的字符级词元化不同,机器翻译这里优先采用单词级词元化(最先进的模型可能使用更高级的子词词元化技术)。tokenize_nmt 对前 num_examples 个文本序列对进行词元化,每个词元要么是一个单词、要么是一个标点符号:
def tokenize_nmt(text, num_examples=None):
"""词元化“英语-法语”数据数据集"""
source, target = [], []
for i, line in enumerate(text.split('\n')):
if num_examples and i > num_examples:
break
parts = line.split('\t')
if len(parts) == 2:
source.append(parts[0].split(' '))
target.append(parts[1].split(' '))
return source, target
source, target = tokenize_nmt(text)
source[:6], target[:6]
函数返回两个词元列表:source 和 target。source[i] 是第 i 个文本序列在源语言(英语)下的词元列表,target[i] 是其在目标语言(法语)下的词元列表。注意两点细节:
line.split('\t')依据制表符拆分;只有len(parts) == 2的行才会被保留,天然过滤了缺少译文或多余字段的脏数据;num_examples用于限制处理的样本对数,默认None表示处理全部数据。
为了直观了解语料形态,可以绘制每个文本序列所包含词元数量的直方图(源语言与目标语言对比):
def show_list_len_pair_hist(legend, xlabel, ylabel, xlist, ylist):
"""绘制列表长度对的直方图"""
d2l.set_figsize()
_, _, patches = d2l.plt.hist(
[[len(l) for l in xlist], [len(l) for l in ylist]])
d2l.plt.xlabel(xlabel)
d2l.plt.ylabel(ylabel)
for patch in patches[1].patches:
patch.set_hatch('/')
d2l.plt.legend(legend)
show_list_len_pair_hist(['source', 'target'], '# tokens per sequence',
'count', source, target);
在这个简单的“英-法”数据集中,大多数文本序列的词元数量少于 20 个。该直方图可以帮你判断后续 num_steps(每个小批量序列的统一长度)应当取多大:既不能太小导致大量序列被截断丢失信息,也不能太大导致填充词元占比过高、浪费算力。
构建源语言与目标语言词表
由于机器翻译数据集由语言对组成,需要分别为源语言和目标语言构建两个词表。单词级词元化下的词表大小会明显大于字符级词元化;为缓解这一问题,这里把出现次数少于 2 次的低频词元统一视为未知词元 "<unk>"。此外还指定了若干特殊词元:
| 特殊词元 | 含义 |
|---|---|
<unk> |
未知词元,索引固定为 0,用于映射未登录的低频词 |
<pad> |
填充词元,用于在小批量内把不同长度的序列补齐到相同长度 |
<bos> |
序列开始(beginning of sequence)标记 |
<eos> |
序列结束(end of sequence)标记 |
这些特殊词元在各类 NLP 任务中都很常用。构建词表的代码如下:
src_vocab = d2l.Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab)
d2l.Vocab 的实现位于 d2l/torch.py(其他后端如 d2l/mxnet.py、d2l/tensorflow.py、d2l/paddle.py 也提供等价实现)。从源码可以看到其内部行为:
- 先用
count_corpus统计所有词元的出现频率(2D 词元列表会被展平成 1D 后统计),再按频率降序排序; idx_to_token以['<unk>'] + reserved_tokens开头,因此<unk>的索引恒为 0,<pad>、<bos>、<eos>依次为 1、2、3;- 随后按频率从高到低遍历词元,仅当频率
>= min_freq且尚未出现在词表中时才追加,遇到第一个低于min_freq的词元即停止——这正是“低频词统一映射为<unk>”的实现机制; __getitem__支持单词元查索引与列表批量查索引,未登录词通过token_to_idx.get(tokens, self.unk)回退到未知词元索引 0;to_tokens则提供反向的索引转词元能力。
理解这些细节对后续调试很有帮助:例如词表长度 len(src_vocab) 会等于 1 + len(reserved_tokens) + 高频词数量,且当你查询一个从未出现在训练语料中的单词时,得到的一定是 0(即 <unk>)。
截断与填充:把小批量序列统一到相同长度
在语言模型中,每个序列样本都有固定长度(由 num_steps 指定);而机器翻译的每个样本是源与目标组成的文本序列对,其中每个文本序列长度可能不同。为了以固定形状的小批量进行高效计算,需要截断(truncation)与填充(padding):
- 若序列词元数少于
num_steps,则在末尾追加"<pad>"词元直到长度达到num_steps; - 若序列词元数多于
num_steps,则只保留前num_steps个词元,丢弃其余部分。
truncate_pad 精确实现了这一逻辑:
def truncate_pad(line, num_steps, padding_token):
"""截断或填充文本序列"""
if len(line) > num_steps:
return line[:num_steps] # 截断
return line + [padding_token] * (num_steps - len(line)) # 填充
truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])
例如,当 source[0] 词元化后不足 10 个词元时,结果会在其词元索引序列末尾补齐若干个 src_vocab['<pad>'](即索引 1)。
把文本序列转换为训练小批量
接下来定义 build_array_nmt,将文本序列转换为可直接喂给模型的张量小批量。它做了三件事:
- 用
vocab[l]把词元列表批量映射为索引列表; - 在每条序列末尾追加
<eos>词元索引,用于标记序列结束——当模型逐词元生成输出序列时,生成<eos>即表示输出完成; - 对统一长度后的序列做填充/截断得到定长张量,同时计算排除填充词元后的有效长度
valid_len,后续部分模型(如注意力机制中的掩码计算)会需要这一信息。
def build_array_nmt(lines, vocab, num_steps):
"""将机器翻译的文本序列转换成小批量"""
lines = [vocab[l] for l in lines]
lines = [l + [vocab['<eos>']] for l in lines]
array = d2l.tensor([truncate_pad(
l, num_steps, vocab['<pad>']) for l in lines])
valid_len = d2l.reduce_sum(
d2l.astype(array != vocab['<pad>'], d2l.int32), 1)
return array, valid_len
其中 valid_len 的计算思路是:先生成布尔掩码 array != vocab['<pad>'],转为 int32 后在序列维度上求和,得到的每个样本的有效长度(不含填充词元)会小于等于 num_steps。需要注意的是,由于 <eos> 也被计入有效长度,valid_len 实际包含结束标记本身。
整合为完整的 load_data_nmt 数据加载函数
最后,load_data_nmt 把上述步骤串成一条完整流水线,返回数据迭代器以及源语言、目标语言两个词表:
def load_data_nmt(batch_size, num_steps, num_examples=600):
"""返回翻译数据集的迭代器和词表"""
text = preprocess_nmt(read_data_nmt())
source, target = tokenize_nmt(text, num_examples)
src_vocab = d2l.Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
tgt_vocab = d2l.Vocab(target, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)
data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)
data_iter = d2l.load_array(data_arrays, batch_size)
return data_iter, src_vocab, tgt_vocab
函数签名与默认值说明如下:
batch_size:每个小批量的样本数;num_steps:序列统一长度(截断/填充后的词元数,含<eos>);num_examples=600:默认只处理前 600 个文本序列对,用于快速演示;设为None则使用全部数据。
d2l.load_array 在 d2l/torch.py 中实现为 TensorDataset + DataLoader(is_train=True 时开启随机打乱),其他后端也提供等价封装。
下面读出“英语-法语”数据集中的第一个小批量,观察张量形状与有效长度:
train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
print('X:', d2l.astype(X, d2l.int32))
print('X的有效长度:', X_valid_len)
print('Y:', d2l.astype(Y, d2l.int32))
print('Y的有效长度:', Y_valid_len)
break
运行后可以看到:X 与 Y 均为形状 (batch_size, num_steps) = (2, 8) 的定长整数张量;X_valid_len、Y_valid_len 分别为每个源/目标序列排除填充词元后的有效长度。这套「四元组 (源序列, 源有效长度, 目标序列, 目标有效长度)」的返回结构,正是后续 seq2seq.md 中编码器—解码器训练循环所依赖的标准数据接口。
小结与练习思考
小结
- 机器翻译指的是将文本序列从一种语言自动翻译成另一种语言,其数据集由源语言与目标语言的文本序列对组成。
- 使用单词级词元化时,词表大小会明显大于字符级词元化;为缓解这一问题,可将低频词元视为同一个未知词元
<unk>。 - 通过截断和填充文本序列,可以保证同一小批量内所有序列长度一致,从而以固定形状批量加载训练数据。
<pad>、<bos>、<eos>等特殊词元在序列转换模型的训练与推理中承担填充、起始、结束标记的作用,是标准 NLP 数据流水线的重要组成部分。
练习思考
- 在
load_data_nmt函数中尝试不同的num_examples参数值(如600、1000、None)。这对源语言和目标语言的词表大小有何影响?——由于词表按频率降序构建且只保留min_freq=2以上的词元,样本数增多通常会引入更多高频词元,使词表增大,但增长速度会随低频词元的饱和而放缓。 - 某些语言(例如中文和日语)的文本没有单词边界指示符(如空格)。对于这种情况,单词级词元化仍然是个好主意吗?——单纯按空格切分无法处理这类语言,通常需要分词工具或改用字符级、子词级(如 BPE/SentencePiece)词元化方案。
延伸阅读指引
- 本节所有函数在四个后端中均有对应实现与文档标注:PyTorch 见 d2l/torch.py,另有 d2l/mxnet.py、d2l/tensorflow.py、d2l/paddle.py 等价的
read_data_nmt到load_data_nmt系列函数,可直接对照学习跨框架实现差异。 - 词表类的完整实现与未知词元回退机制见 d2l/torch.py。
- 语言模型中的固定长度序列抽样与
num_steps概念见 language-models-and-dataset.md。 - 下一步即可基于本节产出的数据迭代器与双词表,在 seq2seq.md 中搭建编码器—解码器模型进行英法翻译训练,并在注意力机制章节(attention-cues.md 等)中继续优化。