《动手学深度学习》机器翻译与数据集:英法平行语料的全流程构建与加载

原创2026-10-01 20:32:47671 阅读
文章标签:人工智能深度学习机器学习教程

《动手学深度学习》机器翻译与数据集:英法平行语料的全流程构建与加载

机器翻译(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)分隔的一对文本序列,每个文本序列可以是一个句子,也可以是包含多个句子的一个段落。

正是因为数据形态不同,机器翻译的数据集不能复用语言模型的预处理程序,需要一套专门的处理流程,包括:

  1. 下载并预处理原始平行语料;
  2. 对源语言与目标语言分别做单词级词元化;
  3. 分别为两种语言构建独立词表;
  4. 通过截断与填充统一序列长度,组装成可训练的小批量。

下面依次展开每一步。

下载并预处理英法数据集

数据下载与读取

本节使用的“英-法”数据集来自 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,将文本序列转换为可直接喂给模型的张量小批量。它做了三件事:

  1. 用 vocab[l] 把词元列表批量映射为索引列表;
  2. 在每条序列末尾追加 <eos> 词元索引,用于标记序列结束——当模型逐词元生成输出序列时,生成 <eos> 即表示输出完成;
  3. 对统一长度后的序列做填充/截断得到定长张量,同时计算排除填充词元后的有效长度 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 数据流水线的重要组成部分。

练习思考

  1. 在 load_data_nmt 函数中尝试不同的 num_examples 参数值(如 600、1000、None)。这对源语言和目标语言的词表大小有何影响?——由于词表按频率降序构建且只保留 min_freq=2 以上的词元,样本数增多通常会引入更多高频词元,使词表增大,但增长速度会随低频词元的饱和而放缓。
  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 等)中继续优化。
登录后查看全文
d2l-zh