机器翻译与数据集:d2l-en 中英法平行语料的构建、预处理与加载实战
机器翻译与数据集:d2l-en 中英法平行语料的构建、预处理与加载实战
机器翻译(Machine Translation)是推动现代循环神经网络(RNN)与序列到序列(seq2seq)模型走向主流的关键应用:模型接收一种语言(源语言)的句子,输出另一种语言(目标语言)的对应翻译。本篇文章以《动手学深度学习》(d2l-en)仓库中 chapter_recurrent-modern/machine-translation-and-dataset.md 为核心,完整讲解从英法双语平行语料(Tatoeba 项目数据)的下载、清洗、分词,到定长化(截断与填充)、双语文表构建、小批量加载的完整数据流水线。读完本文,你将掌握 d2l-en 中 MTFraEng 数据集类的内部实现原理,并能在 PyTorch、MXNet、TensorFlow 与 JAX 四个框架下复现同样的数据准备流程,为后续 encoder-decoder 与注意力机制章节的模型训练打好数据基础。
机器翻译:从"不对齐"序列到 seq2seq 问题
机器翻译是统计机器学习领域的经典任务:模型被给定一种语言中的一句话,必须预测另一种语言中对应的句子。这里有两个与语言模型(见 chapter_recurrent-neural-networks/language-model.md)截然不同的难点:
- 序列长度不同:源句子与目标句子的长度通常不一致;
- 单词顺序不对齐:由于两种语言语法结构不同,源句中的词在译文中往往不按相同顺序出现。
许多问题都具有这种"在两个不对齐序列之间映射"的性质,例如从对话提示(prompt)到回复、从问题到答案的映射。这类问题统称为序列到序列(sequence-to-sequence,简称 seq2seq)问题,它是本章剩余部分以及注意力机制与 Transformer 章节(见 chapter_attention-mechanisms-and-transformers/index.md)的核心研究对象。历史上,统计机器翻译方法曾长期流行,后来基于神经网络的方法被统称为神经机器翻译(Neural Machine Translation),并催生了现代 RNN 的广泛应用。
在 d2l-en 中,本节的定位是:引入机器翻译问题本身,并构建一个贯穿后续若干章节(encoder-decoder、GRU/LSTM 的 seq2seq 实现、注意力机制等)的英法双语数据集。与语言建模中"每个样本只有一条文本序列"不同,机器翻译的每个样本包含两条独立的文本序列:一条源语言序列与一条目标语言序列(翻译结果)。
下载与预处理数据集:MTFraEng 类
d2l-en 使用一个公开的英法双语平行语料,数据来自 Tatoeba 项目(manythings.org/anki 提供的双语例句对)。数据集中每一行是一个由制表符(tab)分隔的二元组:英文文本序列(源)与对应的法语文本序列(目标)。需要注意,每条文本序列可以只是一句话,也可以是包含多句的段落。
下载逻辑封装在 MTFraEng 类的 _download 方法中,它继承自 d2l.DataModule 基类(定义于 d2l/torch.py,其他三个框架的实现位于 d2l/mxnet.py、d2l/tensorflow.py、d2l/jax.py)。实现如下:
%%tab all
class MTFraEng(d2l.DataModule): #@save
"""The English-French dataset."""
def _download(self):
d2l.extract(d2l.download(
d2l.DATA_URL+'fra-eng.zip', self.root,
'94646ad1522d915e7b0f9296181140edcf86a4f5'))
with open(self.root + '/fra-eng/fra.txt', encoding='utf-8') as f:
return f.read()
这里使用了 d2l 工具函数 d2l.download 与 d2l.extract(定义于 d2l/torch.py):
d2l.download(url, folder, sha1_hash):从d2l.DATA_URL(值为http://d2l-data.s3-accelerate.amazonaws.com/,见 d2l/torch.py)下载fra-eng.zip到数据根目录(DataModule默认root='../data'),并传入文件fra.txt的 SHA-1 哈希值94646ad1522d915e7b0f9296181140edcf86a4f5做完整性校验。若本地已存在且哈希匹配,则直接复用缓存文件,不会重复下载。d2l.extract(filename):将 zip 压缩包解压到数据目录,得到fra-eng/fra.txt文件。- 最后以 UTF-8 编码读取
fra.txt全部内容并作为字符串返回。
拿到原始文本后,需要做若干预处理步骤,封装在 _preprocess 方法中:
%%tab all
@d2l.add_to_class(MTFraEng) #@save
def _preprocess(self, text):
# Replace non-breaking space with space
text = text.replace('\u202f', ' ').replace('\xa0', ' ')
# Insert space between words and punctuation marks
no_space = lambda char, prev_char: char in ',.!?' and prev_char != ' '
out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char
for i, char in enumerate(text.lower())]
return ''.join(out)
具体来说,该预处理完成三件事:
- 统一空白字符:将窄不换行空格
\u202f与不换行空格\xa0都替换为普通空格,避免分词时产生异常 token; - 统一大小写:将整段文本转为小写,缩小词表规模;
- 在单词与标点之间插入空格:借助
no_space判定——当当前字符属于逗号、句点、感叹号、问号(,.!?)且前一字符不是空格时,在该标点前插入一个空格,使标点符号独立成"词",方便后续按空格分词。
分词(Tokenization):词级 token 与 标记
与语言建模章节使用的字符级分词不同,机器翻译在这里采用词级分词(当今最先进的模型会使用更复杂的分词技术,如 BPE、WordPiece 等,本节从简单入手)。_tokenize 方法对前 max_examples 个文本序列对进行分词,其中每个 token 要么是一个单词,要么是一个标点符号:
%%tab all
@d2l.add_to_class(MTFraEng) #@save
def _tokenize(self, text, max_examples=None):
src, tgt = [], []
for i, line in enumerate(text.split('\n')):
if max_examples and i > max_examples: break
parts = line.split('\t')
if len(parts) == 2:
# Skip empty tokens
src.append([t for t in f'{parts[0]} <eos>'.split(' ') if t])
tgt.append([t for t in f'{parts[1]} <eos>'.split(' ') if t])
return src, tgt
该方法的要点:
- 逐行读取,以
\t切分源与目标;只有恰好切出 2 个部分的行才被采纳(跳过异常行); - 在每条序列末尾追加特殊的
<eos>(end-of-sequence)token,用于标记序列结束。当模型逐 token 生成序列时,一旦生成<eos>,即可认为输出序列已经完成; - 按空格切分并过滤空串,得到 token 列表;
- 返回两个 token 列表的列表:
src[i]是第 i 条源语言(英文)序列的 token 列表,tgt[i]是第 i 条目标语言(法文)序列的 token 列表。
调用 data._tokenize(text) 后,可以用直方图直观观察两条序列的长度分布。仓库提供的辅助函数 show_list_len_pair_hist(完整定义见 d2l/torch.py)以斜线阴影区分源与目标序列的长度直方图。在该英法数据集中,大多数文本序列的 token 数少于 20 个,因此后续默认 num_steps=9 的定长设置是合理的。
加载定长序列:截断、填充与三组特殊 token
语言建模中每条样本序列的长度由参数 num_steps(时间步/token 数)固定;而机器翻译中,同一小批量(minibatch)里的源序列与目标序列长度参差不齐。为了计算效率,可以仍然在一个小批量中同时处理多条序列,手段是截断(truncation)与填充(padding):
- 若某条序列的 token 数少于
num_steps,则在末尾不断追加特殊<pad>(padding)token,直到长度达到num_steps; - 若某条序列的 token 数多于
num_steps,则只保留前num_steps个 token,丢弃其余部分。
这样每条序列长度一致,可以组成形状相同的小批量张量。此外,还需要记录剔除填充 token 后的源序列真实长度(src_valid_len),这一信息将在后续某些模型(如注意力机制中的有效长度掩码)中用到。
由于机器翻译数据集由语言对组成,需要分别为源语言与目标语言构建两套词表。词级分词下词表规模会显著大于字符级分词,为了缓解这一问题,这里将出现次数少于 2 次的稀有 token 统一映射为未知 token <unk>。
关于序列的起止标记,这里出现了三组特殊 token,各有分工:
| 特殊 token | 含义 | 用途 |
|---|---|---|
<bos> |
beginning-of-sequence | 作为解码器预测目标序列时的第一个输入 token |
<eos> |
end-of-sequence | 追加到每条序列末尾,标记序列结束 |
<pad> |
padding | 填充短序列到统一长度 num_steps |
<unk> |
unknown | 替代出现次数 < 2 的稀有 token,控制词表规模 |
上述逻辑全部封装在 _build_arrays 方法中(完整实现见 d2l/torch.py):
%%tab all
@d2l.add_to_class(MTFraEng) #@save
def __init__(self, batch_size, num_steps=9, num_train=512, num_val=128):
super(MTFraEng, self).__init__()
self.save_hyperparameters()
self.arrays, self.src_vocab, self.tgt_vocab = self._build_arrays(
self._download())
%%tab all
@d2l.add_to_class(MTFraEng) #@save
def _build_arrays(self, raw_text, src_vocab=None, tgt_vocab=None):
def _build_array(sentences, vocab, is_tgt=False):
pad_or_trim = lambda seq, t: (
seq[:t] if len(seq) > t else seq + ['<pad>'] * (t - len(seq)))
sentences = [pad_or_trim(s, self.num_steps) for s in sentences]
if is_tgt:
sentences = [['<bos>'] + s for s in sentences]
if vocab is None:
vocab = d2l.Vocab(sentences, min_freq=2)
array = d2l.tensor([vocab[s] for s in sentences])
valid_len = d2l.reduce_sum(
d2l.astype(array != vocab['<pad>'], d2l.int32), 1)
return array, vocab, valid_len
src, tgt = self._tokenize(self._preprocess(raw_text),
self.num_train + self.num_val)
src_array, src_vocab, src_valid_len = _build_array(src, src_vocab)
tgt_array, tgt_vocab, _ = _build_array(tgt, tgt_vocab, True)
return ((src_array, tgt_array[:,:-1], src_valid_len, tgt_array[:,1:]),
src_vocab, tgt_vocab)
构造函数的关键参数与默认值:
batch_size:批量大小(必填);num_steps=9:每条序列统一后的长度,同时充当截断与填充的基准;num_train=512:训练集样本对数;num_val=128:验证集样本对数。
_build_arrays 内部 _build_array 的执行流程值得逐行拆解:
- 截断/填充:
pad_or_trim对每条序列做定长化;若序列更长则截取前num_steps个,若更短则补<pad>至num_steps; - 目标序列前置
<bos>:当is_tgt=True时,在每条目标序列最前面拼接<bos>token,作为解码器的起始输入 token(对应 chapter_recurrent-modern/encoder-decoder.md 与 seq2seq 章节中"解码器输出(标签 token)等于解码器输入(目标 token)右移一个 token"的训练范式,详见 chapter_recurrent-modern/seq2seq.md); - 构建词表:若未传入已有词表,则用
d2l.Vocab(sentences, min_freq=2)新建——词表类定义于 d2l/torch.py,其构造逻辑是:统计 token 频率并按降序排列,保留频率 ≥min_freq的 token,并预留<unk>与保留 token 的索引;min_freq=2即上面所说的"出现次数少于 2 次的 token 归为<unk>"; - 张量化:把每条 token 序列映射为索引序列,堆叠成形状为(样本数,
num_steps)的整型张量; - 计算有效长度:
valid_len = d2l.reduce_sum(d2l.astype(array != vocab<a href="https://link.gitcode.com/i/d4f1b3aa618a2b9ac5fb46872cd84688" target="_blank">'<pad>'], d2l.int32), 1)——先将"非<pad>位置"转为 1/0,再沿序列维度求和(reduce_sum与astype在 d2l 中是sum与type的别名,见 [d2l/torch.py),得到每条序列剔除填充后的真实长度。
_build_arrays 最终返回四元组 (src_array, tgt_array[:, :-1], src_valid_len, tgt_array[:, 1:]),这正是后续 seq2seq 训练时一个样本的四个组成部分:
- source:源语言序列(英文);
- decoder input:目标序列去除最后一个 token(去掉
<eos>),并已前置<bos>,作为解码器的输入; - source length excluding pad:剔除填充后的源序列有效长度;
- label:目标序列去除第一个 token(去掉
<bos>),作为解码器要预测的标签,与 decoder input 正好错位一个 token。
读取数据集与自定义构建
定义 get_dataloader 方法即可获得数据迭代器:
%%tab all
@d2l.add_to_class(MTFraEng) #@save
def get_dataloader(self, train):
idx = slice(0, self.num_train) if train else slice(self.num_train, None)
return self.get_tensorloader(self.arrays, train, idx)
train=True时取前num_train=512条,train=False时取从num_train开始的后续num_val=128条(即第 512 条到第 639 条);self.get_tensorloader继承自d2l.DataModule(见 d2l/torch.py):将四元组张量切片封装为TensorDataset并交给框架的DataLoader,训练集启用shuffle=True打乱顺序,验证集不打乱。
读取第一个小批量(batch_size=3)即可观察四个分量的实际形态与语义:
%%tab all
data = MTFraEng(batch_size=3)
src, tgt, src_valid_len, label = next(iter(data.train_dataloader()))
print('source:', d2l.astype(src, d2l.int32))
print('decoder input:', d2l.astype(tgt, d2l.int32))
print('source len excluding pad:', d2l.astype(src_valid_len, d2l.int32))
print('label:', d2l.astype(label, d2l.int32))
最后,build 方法提供了一条便捷的自定义测试通路:把任意手写的源/目标句子对拼回 tab 分隔的原始文本,复用已构建好的两套词表走一遍 _build_arrays,从而方便地在后续章节中快速构造单条样本进行调试:
%%tab all
@d2l.add_to_class(MTFraEng) #@save
def build(self, src_sentences, tgt_sentences):
raw_text = '\n'.join([src + '\t' + tgt for src, tgt in zip(
src_sentences, tgt_sentences)])
arrays, _, _ = self._build_arrays(
raw_text, self.src_vocab, self.tgt_vocab)
return arrays
例如用 data.build(<a href="https://link.gitcode.com/i/aa8b43b2042c06526028f475bf69a39d" target="_blank">'hi .'], ['salut .']) 构建单条样本,再借助词表的 to_tokens 方法([d2l/torch.py)把索引张量还原为 token 字符串,可以直观检查 <bos> 前置、定长填充等处理是否正确——source 序列会被处理为 'hi' '.' '<eos>' '<pad>' ...,target 则为 '<bos>' 'salut' '.' '<eos>' '<pad>' ...。
小结
- 机器翻译是指自动将源语言的文本序列映射为目标语言合理翻译序列的任务。词级分词下词表规模显著大于字符级分词,但序列长度会短得多;
- 为缓解大词表问题,可以把稀有 token 统一视为
<unk>未知 token(本数据集阈值为出现次数 < 2,即min_freq=2); - 通过截断与填充,所有文本序列被统一到
num_steps长度,从而可以按相同形状的小批量加载;src_valid_len记录了剔除<pad>后的源序列真实长度,供后续模型使用; - 现代实现通常会按相似长度对序列分桶(bucketing),避免在大量填充上浪费计算,本节为教学清晰性采用了统一长度方案;
MTFraEng类在 d2l-en 四个框架后端(d2l/mxnet.py、d2l/torch.py、d2l/tensorflow.py、d2l/jax.py)中均有等价的@save实现,本文以 d2l/torch.py 为准展开,其余框架可直接对照阅读。
思考与练习
- 修改
_tokenize中的max_examples参数(如取 64、512、2048 等不同值),观察源语言与目标语言词表规模如何随之变化?这说明了什么? - 中文、日文等语言的文本没有空格这类词边界指示符。对于这类语言,词级分词是否仍是好选择?为什么?请结合
<unk>机制与分词难度展开讨论。