透过机器翻译理解Transformer(二): 师傅引进门,修行在个人-建立输入管道

编者按:年初疫情在家期间开始大量阅读NLP领域的经典论文,在学习《Attention Is All You Need》时发现了一位现居日本的数据科学家LeeMeng写的Transformer详解博客,理论讲解+代码实操+动画演示的写作风格,在众多文章中独树一帜,实为新手学习Transformer的上乘资料,在通读以及实操多遍之后,现在将其编辑整理成简体中文分享给大家。由于原文实在太长,为了便于阅读学习,这里将其分为四个部分:

  • 透过机器翻译理解Transformer(一):关于机器翻译
  • 透过机器翻译理解Transformer(二):师傅引进门,修行在个人—建立输入管道
  • 透过机器翻译理解Transformer(三):理解 Transformer 之旅:跟着多维向量去冒险
  • 透过机器翻译理解Transformer(四):打造 Transformer:叠叠乐时间

在涉及代码部分,强烈推荐大家在Google的Colab Notebooks中实际操作一遍,之所以推荐Colab Notebooks是因为1).这里有免费可以使用的GPU资源;2). 可以避免很多安装包出错的问题

本文用到的数据:
链接:https://pan.baidu.com/s/1Ku1GH8a_NqHUxYs-uf0htg
提取码:tbor

本节目录

    1. 师傅引进门,修行在个人
    1. Transformer 11 个重要概念回顾
    1. 安装Python库并设置环境
    1. 建立输入管道
      • 4.1 下载并准备数据集
      • 4.2 切割数据集
      • 4.3 建立中文与英文字典
      • 4.4 数据预处理

1. 师傅引进门,修行在个人

你回来了吗?还是等不及待地想继续往下阅读?

接下来我们会进入实际的代码实现。但跟前半段相比难度呈指数型上升,因此我只推荐符合以下条件的读者阅读:

  • 想透过实现 Transformer 来彻底了解其内部运作原理的人
  • 愿意先花 1 小时了解 Transformer 的细节概念与理论的人

你马上就会知道 1 个小时代表什么意思。如果你觉得这听起来很 ok,那可以继续阅读。

在机器翻译近代史一章我们已经花了不少篇幅讲解了许多在实现 Transformer 时会有帮助的重要概念,其中包含:

  • Seq2Seq 模型的运作原理
  • 注意力机制的概念与计算过程
  • 自注意力机制与 Transformer 的精神

坏消息是,深度学习里头理论跟实现的差异常常是很大的。尽管这些背景知识对理解Transformer 的精神非常有帮助,对从来没有用过RNN 实现文本生成或是以Seq2Seq 模型+ 注意力机制实现过NMT 的人来说,要在第一次就正确实现Transformer 仍是一个巨大的挑战。

就算不说理论跟实现的差异,让我们看看 TensorFlow 官方释出的最新 Transformer 教学里头有多少内容:

TensorFlow 官方的 Transformer 教学

上面是我用这辈子最快的速度卷动该页面再加速后的结果,可以看出内容还真不少。尽管中文化很重要,我在这篇文章里不会帮你把其中的叙述翻成中文(毕竟你的英文可能比我好)

反之,我将利用 TensorFlow 官方的代码,以最适合「初学者」理解的实现顺序来讲述 Transformer 的重要技术细节及概念。在阅读本文之后,你将有能力自行理解 TensorFlow 官方教学以及其他网络上的实现(比方说 HarvardNLP 以 Pytorch 实现的 The Annotated Transformer。

李宏毅教授前阵子才在他 2019 年的台大机器学习课程发布了 Transformer 的教学影片,而这可以说是世界上最好的中文教学影片。如果你真的想要深入理解 Transformer,在实现前至少把上面的影片看完吧!你可以少走很多弯路。

实现时我会尽量重述关键概念,但如果有先看影片你会比较容易理解我在碎碎念什么。如果看完影片你的小宇宙开始发光发热,也可以先读读 Transformer 的原始论文,跟很多学术论文比起来相当好读,真心不骗。

重申一次,除非你已经了解基本注意力机制的运算以及 Transformer 的整体架构,否则我不建议继续阅读。

2. Transformer 11 个重要概念回顾

怎么样?你应该已经从教授的课程中学到不少重要概念了吧?我不知道你还记得多少,但让我非常简单地帮你复习一下。

  1. 自注意力层(Self-Attention Layer)跟 RNN 一样,输入是一个序列,输出一个序列。但是该层可以并行计算,且输出序列中的每个向量都已经看了整个序列的信息。

  2. 自注意力层将输入序列I 里头的每个位置的向量i 透过3 个线性转换分别变成3 个向量:qkv,并将每个位置的q 拿去跟序列中其他位置的k 做匹配,算出匹配程度后利用softmax 层取得介于0 到1 之间的权重值,并以此权重跟每个位置的v 作加权平均,最后取得该位置的输出向量o。全部位置的输出向量可以同时并行计算,最后输出序列 O

  3. 计算匹配程度(注意)的方法不只一种,只要能吃进 2 个向量并吐出一个数值即可。但在 Transformer 论文原文是将 2 向量做 dot product 算匹配程度。

  4. 我们可以透过大量矩阵运算以及 GPU 将概念 2 提到的注意力机制的计算全部并行化,加快训练效率(也是本文实现的重点)。

  5. 多头注意力机制(Multi-head Attention)是将输入序列中的每个位置的 qkv 切割成多个 qikivi 再分别各自进行注意力机制。各自处理完以后把所有结果串接并视情况降维。这样的好处是能让各个 head 各司其职,学会关注序列中不同位置在不同 representaton spaces 的信息。

  6. 自注意力机制这样的计算的好处是「天涯若比邻」:序列中每个位置都可以在 O(1) 的距离内关注任一其他位置的信息,运算效率较双向 RNN 优秀。

  7. 自注意力层可以取代 Seq2Seq 模型里头以 RNN 为基础的 Encoder / Decoder,而实际上全部替换掉后就(大致上)是 Transformer。

  8. 自注意力机制预设没有「先后顺序」的概念,而这也是为何其可以快速并行运算的原因。在进行如机器翻译等序列生成任务时,我们需要额外加入位置编码(Positioning Encoding)来加入顺序信息。而在 Transformer 原论文中此值为手设而非训练出来的模型权重。

  9. Transformer 是一个 Seq2Seq 模型,自然包含了 Encoder / Decoder,而 Encoder 及 Decoder 可以包含多层结构相同的 blocks,里头每层都会有 multi-head attention 以及 Feed Forward Network。

  10. 在每个 Encoder / Decoder block 里头,我们还会使用残差连结(Residual Connection)以及 Layer Normalization。这些能帮助模型稳定训练。

  11. Decoder 在关注 Encoder 输出时会需要遮罩(mask)来避免看到未来信息。我们后面会看到,事实上还会需要其他遮罩。

这些应该是你在看完影片后学到的东西。如果你想要快速复习,这里则是教授课程的 PDF 。

另外你之后也可以随时透过左侧导览的图片 icon 来快速回顾 Transformer 的整体架构以及教授添加的注解。我相信在实现的时候它可以帮得上点忙:

有了这些背景知识以后,在理解代码时会轻松许多。你也可以一边执行 TensorFlow 官方的 Colab 笔记本一边参考底下实现。

好戏登场!

3. 安装Python库并设置环境

在这边我们导入一些常用的 Python 库,这应该不需要特别说明。

from google.colab import drive
drive.mount('/content/drive/')
Mounted at /content/drive/
import os
import time
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from pprint import pprint
from IPython.display import clear_output

比较值得注意的是我们将以最新的 TensorFlow 2 Beta 版本(编者注:在这里我使用的是TF==2.3)来实现本文的 Transformer。另外也会透过 TensorFlow Datasets 来使用前人帮我们准备好的英中翻译资料集:

!pip install tensorflow  # stable
clear_output()
!pip show tensorflow
Name: tensorflow
Version: 2.3.0
Summary: TensorFlow is an open source machine learning framework for everyone.
Home-page: https://www.tensorflow.org/
Author: Google Inc.
Author-email: [email protected]
License: Apache 2.0
Location: /usr/local/lib/python3.6/dist-packages
Requires: google-pasta, scipy, gast, absl-py, astunparse, numpy, protobuf, termcolor, wrapt, grpcio, wheel, tensorboard, six, tensorflow-estimator, opt-einsum, h5py, keras-preprocessing
Required-by: fancyimpute
!pip install -q tensorflow-datasets 
import matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf

import tensorflow_datasets as tfds
tfds.disable_progress_bar()
print(tf.__version__)
2.3.0

另外为了避免 TensorFlow 吐给我们太多不必要的信息,在此文中我也将改变 logging 等级。在 TensorFlow 2 里头因为 tf.logging 被 deprecated,我们可以直接用 logging 模组来做到这件事情:

import logging
# logging.basicConfig(level="error")
np.set_printoptions(suppress=True)

我们同时也让 numpy 不要显示科学记号。这样可以让我们之后在做一些 Tensor 运算的时候版面能干净一点。

接着定义一些之后在储存档案时会用到的路径变量:

output_dir = "nmt"
en_vocab_file = os.path.join(output_dir, "en_vocab")
zh_vocab_file = os.path.join(output_dir, "zh_vocab")
checkpoint_path = os.path.join(output_dir, "checkpoints")
log_dir = os.path.join(output_dir, 'logs')
download_dir = "tensorflow-datasets/downloads"

if not os.path.exists(output_dir):
  os.makedirs(output_dir)

4. 建立输入管道

现行的 GPU 以及 TPU 能透过并行运算帮我们显著地缩短训练一个 step 所需的时间。而为了让并行计算能发挥最佳性能,我们需要最佳化输入管道(Input pipeline),以在当前训练步骤完成之前就准备好下一个时间点 GPU 要用的数据。

而我们将透过 tf.data API以及前面导入的 TensorFlow Datasets 来建立高效的输入管道,并将机器翻译竞赛 WMT 2019 的中英数据集准备好。

4.1 下载并准备数据集

首先看看 tfds 里头 WMT 2019 的中英翻译有哪些资料来源:

tmp_builder = tfds.builder("wmt19_translate/zh-en")
pprint(tmp_builder.subsets)
{NamedSplit('train'): ['newscommentary_v14',
                       'wikititles_v1',
                       'uncorpus_v1',
                       'casia2015',
                       'casict2011',
                       'casict2015',
                       'datum2015',
                       'datum2017',
                       'neu2017'],
 NamedSplit('validation'): ['newstest2018']}

可以看到在 WMT 2019 里中英对照的数据来源还算不少。其中几个很好猜到其性质:

  • 联合国数据:uncorpus_v1
  • 维基百科标题:wikititles_v1
  • 新闻评论:newscommentary_v14

虽然大量数据对训练神经网路很有帮助,本文为了节省训练 Transformer 所需的时间,在这里我们就只选择一个数据来源当作数据集。至于要选哪个数据来源呢?

联合国的数据非常庞大,而维基百科标题通常内容很短,新闻评论感觉是一个相对适合的选择。我们可以在设定档 config 里头指定新闻评论这个数据来源并请 TensorFlow Datasets 下载:

config = tfds.translate.wmt.WmtConfig(
  version=tfds.core.Version("1.0.0"),
  language_pair=("zh", "en"),
  subsets={
    tfds.Split.TRAIN: ["newscommentary_v14"]
  }
)

builder = tfds.builder("wmt_translate", config=config)
builder.download_and_prepare(download_dir=download_dir)
clear_output()

上面的指令约需 2 分钟完成,而在过程中tfds 帮我们完成不少工作:

  • 下载包含原始数据的压缩文档
  • 解压缩得到 CSV 文件
  • 逐行读取该 CSV 里头所有中英句子
  • 将不符合格式的 row 自动过滤
  • Shuffle 数据
  • 将原数据转换成 TFRecord 数据以加速读取

多花点时间把相关 API 文件看熟,你就能把清理、准备数据的时间花在建构模型以及跑实验上面。

4.2 切割数据集

虽然我们只下载了一个新闻评论的数据集,里头还是有超过 30 万笔的中英平行句子。为了减少训练所需的时间,将刚刚处理好的新闻评论数据集再进一步切成 3 个部分,数据量分布如下:

  • Split 1:20% 数据
  • Split 2:1% 数据
  • Split 3:79% 数据

我们将前两个 splits 拿来当作训练以及验证集,剩余的部分(第 3 个 split)舍弃不用:

train_examples = builder.as_dataset(split='train[:20%]', as_supervised=True)
val_examples = builder.as_dataset(split='train[20%:21%]', as_supervised=True)
print(train_examples)
print(val_examples)


你可以在这里找到更多跟 [split] 相关的用法。

这时候 train_examplesval_examples 都已经是 tf.data.Dataset。我们在数据预处理一节会看到这些数据在被丢入神经网络前需要经过什么转换,不过现在先让我们简单读几笔数据出来看看:

for en, zh in train_examples.take(3):
  print(en)
  print(zh)
  print('-' * 10)
tf.Tensor(b'The fear is real and visceral, and politicians ignore it at their peril.', shape=(), dtype=string)
tf.Tensor(b'\xe8\xbf\x99\xe7\xa7\x8d\xe6\x81\x90\xe6\x83\xa7\xe6\x98\xaf\xe7\x9c\x9f\xe5\xae\x9e\xe8\x80\x8c\xe5\x86\x85\xe5\x9c\xa8\xe7\x9a\x84\xe3\x80\x82 \xe5\xbf\xbd\xe8\xa7\x86\xe5\xae\x83\xe7\x9a\x84\xe6\x94\xbf\xe6\xb2\xbb\xe5\xae\xb6\xe4\xbb\xac\xe5\x89\x8d\xe9\x80\x94\xe5\xa0\xaa\xe5\xbf\xa7\xe3\x80\x82', shape=(), dtype=string)
----------
tf.Tensor(b'In fact, the German political landscape needs nothing more than a truly liberal party, in the US sense of the word \xe2\x80\x9cliberal\xe2\x80\x9d \xe2\x80\x93 a champion of the cause of individual freedom.', shape=(), dtype=string)
tf.Tensor(b'\xe4\xba\x8b\xe5\xae\x9e\xe4\xb8\x8a\xef\xbc\x8c\xe5\xbe\xb7\xe5\x9b\xbd\xe6\x94\xbf\xe6\xb2\xbb\xe5\xb1\x80\xe5\x8a\xbf\xe9\x9c\x80\xe8\xa6\x81\xe7\x9a\x84\xe4\xb8\x8d\xe8\xbf\x87\xe6\x98\xaf\xe4\xb8\x80\xe4\xb8\xaa\xe7\xac\xa6\xe5\x90\x88\xe7\xbe\x8e\xe5\x9b\xbd\xe6\x89\x80\xe8\xb0\x93\xe2\x80\x9c\xe8\x87\xaa\xe7\x94\xb1\xe2\x80\x9d\xe5\xae\x9a\xe4\xb9\x89\xe7\x9a\x84\xe7\x9c\x9f\xe6\xad\xa3\xe7\x9a\x84\xe8\x87\xaa\xe7\x94\xb1\xe5\x85\x9a\xe6\xb4\xbe\xef\xbc\x8c\xe4\xb9\x9f\xe5\xb0\xb1\xe6\x98\xaf\xe4\xb8\xaa\xe4\xba\xba\xe8\x87\xaa\xe7\x94\xb1\xe4\xba\x8b\xe4\xb8\x9a\xe7\x9a\x84\xe5\x80\xa1\xe5\xaf\xbc\xe8\x80\x85\xe3\x80\x82', shape=(), dtype=string)
----------
tf.Tensor(b'Shifting to renewable-energy sources will require enormous effort and major infrastructure investment.', shape=(), dtype=string)
tf.Tensor(b'\xe5\xbf\x85\xe9\xa1\xbb\xe4\xbb\x98\xe5\x87\xba\xe5\xb7\xa8\xe5\xa4\xa7\xe7\x9a\x84\xe5\x8a\xaa\xe5\x8a\x9b\xe5\x92\x8c\xe5\x9f\xba\xe7\xa1\x80\xe8\xae\xbe\xe6\x96\xbd\xe6\x8a\x95\xe8\xb5\x84\xe6\x89\x8d\xe8\x83\xbd\xe5\xae\x8c\xe6\x88\x90\xe5\x90\x91\xe5\x8f\xaf\xe5\x86\x8d\xe7\x94\x9f\xe8\x83\xbd\xe6\xba\x90\xe7\x9a\x84\xe8\xbf\x87\xe6\xb8\xa1\xe3\x80\x82', shape=(), dtype=string)
----------

跟预期一样,每一个例子(每一次的 take)都包含了 2 个以 unicode 呈现的tf.Tensor。它们有一样的语义,只是一个是英文,一个是中文。

让我们将这些 Tensors 实际储存的字串利用 numpy() 取出并解码看看:

sample_examples = []
num_samples = 10

for en_t, zh_t in train_examples.take(num_samples):
  en = en_t.numpy().decode("utf-8")
  zh = zh_t.numpy().decode("utf-8")
  
  print(en)
  print(zh)
  print('-' * 10)
  
  # 之後用來簡單評估模型的訓練情況
  sample_examples.append((en, zh))
The fear is real and visceral, and politicians ignore it at their peril.
这种恐惧是真实而内在的。 忽视它的政治家们前途堪忧。
----------
In fact, the German political landscape needs nothing more than a truly liberal party, in the US sense of the word “liberal” – a champion of the cause of individual freedom.
事实上,德国政治局势需要的不过是一个符合美国所谓“自由”定义的真正的自由党派,也就是个人自由事业的倡导者。
----------
Shifting to renewable-energy sources will require enormous effort and major infrastructure investment.
必须付出巨大的努力和基础设施投资才能完成向可再生能源的过渡。
----------
In this sense, it is critical to recognize the fundamental difference between “urban villages” and their rural counterparts.
在这方面,关键在于认识到“城市村落”和农村村落之间的根本区别。
----------
A strong European voice, such as Nicolas Sarkozy’s during the French presidency of the EU, may make a difference, but only for six months, and at the cost of reinforcing other European countries’ nationalist feelings in reaction to the expression of “Gallic pride.”
法国担任轮值主席国期间尼古拉·萨科奇统一的欧洲声音可能让人耳目一新,但这种声音却只持续了短短六个月,而且付出了让其他欧洲国家在面对“高卢人的骄傲”时民族主义情感进一步被激发的代价。
----------
Most of Japan’s bondholders are nationals (if not the central bank) and have an interest in political stability.
日本债券持有人大多为本国国民(甚至中央银行 ) , 政治稳定符合他们的利益。
----------
Paul Romer, one of the originators of new growth theory, has accused some leading names, including the Nobel laureate Robert Lucas, of what he calls “mathiness” – using math to obfuscate rather than clarify.
新增长理论创始人之一的保罗·罗默(Paul Romer)也批评一些著名经济学家,包括诺贝尔奖获得者罗伯特·卢卡斯(Robert Lucas)在内,说他们“数学性 ” ( 罗默的用语)太重,结果是让问题变得更加模糊而不是更加清晰。
----------
It is, in fact, a capsule depiction of the United States Federal Reserve and the European Central Bank.
事实上,这就是对美联储和欧洲央行的简略描述。
----------
Given these variables, the degree to which migration is affected by asylum-seekers will not be easy to predict or control.
考虑到这些变量,移民受寻求庇护者的影响程度很难预测或控制。
----------
WASHINGTON, DC – In the 2016 American presidential election, Hillary Clinton and Donald Trump agreed that the US economy is suffering from dilapidated infrastructure, and both called for greater investment in renovating and upgrading the country’s public capital stock.
华盛顿—在2016年美国总统选举中,希拉里·克林顿和唐纳德·特朗普都认为美国经济饱受基础设施陈旧的拖累,两人都要求加大投资用于修缮和升级美国公共资本存量。
----------

想像一下没有对应的中文,要阅读这些英文得花多少时间。你可以试着消化其中几句中文与其对应的英文句子,并比较一下所需要的时间差异。

虽然只是随意列出的 10 个中英句子,你应该跟我一样也能感受到机器翻译研究的重要以及其能带给我们的价值。

4.3 建立中文与英文字典

就跟大多数 NLP项目相同,有了原始的中英句子以后我们得分别为其建立字典来将每个词汇转成索引(Index)。 tfds.features.text 底下的 SubwordTextEncoder 提供非常方便的 API 让我们扫过整个训练资料集并建立字典。

首先为英文语料建立字典:

%%time
try:
  subword_encoder_en =tfds.deprecated.text.SubwordTextEncoder.load_from_file(en_vocab_file)
  print(f"载入已建立的字典: {en_vocab_file}")
except:
  print("沒有已建立的字典,从头建立。")
  subword_encoder_en = tfds.deprecated.text.SubwordTextEncoder.build_from_corpus(
      (en.numpy() for en, _ in train_examples), 
      target_vocab_size=2**13) # 有需要可以调整字典大小
  
  # 将创建的字典存下以方便下次 warmstart
  subword_encoder_en.save_to_file(en_vocab_file)
  

print(f"字典大小:{subword_encoder_en.vocab_size}")
print(f"前 10 個 subwords:{subword_encoder_en.subwords[:10]}")
print()
沒有已建立的字典,從頭建立。
字典大小:8113
前 10 個 subwords:[', ', 'the_', 'of_', 'to_', 'and_', 's_', 'in_', 'a_', 'is_', 'that_']

CPU times: user 1min 11s, sys: 3.32 s, total: 1min 14s
Wall time: 1min 6s

如果你的语料库(corpus) 不小,要扫过整数据集并建立一个字典得花不少时间。因此实现上我们会先使用load_from_file函式尝试读取之前已经建好的字典档案,失败才 build_from_corpus

这招很基本,但在你需要重复处理巨大语料库时非常重要。

subword_encoder_en 则是利用 GNMT 当初推出的 wordpieces 来进行分词,而简单来说其产生的子词(subword)介于这两者之间:

  • 用英文字母分隔的断词(character-delimited)
  • 用空白分隔的断词(word-delimited)

在扫过所有英文句子以后,subword_encoder_en 建立一个有 8135 个子词的字典。我们可以用该字典来帮我们将一个英文句子转成对应的索引序列(index sequence):

sample_string = 'Taiwan is beautiful.'
indices = subword_encoder_en.encode(sample_string)
indices
[3461, 7889, 9, 3502, 4379, 1134, 7903]

这样的索引序列你应该已经见怪不怪了。我们在以前的 NLP 入门文章也使用 tf.keras里头的 Tokenizer 做过类似的事情。

接着让我们将这些索引还原,看看它们的长相:

print("{0:10}{1:6}".format("Index", "Subword"))
print("-" * 15)
for idx in indices:
  subword = subword_encoder_en.decode([idx])
  print('{0:5}{1:6}'.format(idx, ' ' * 5 + subword))
Index     Subword
---------------
 3461     Taiwan
 7889      
    9     is 
 3502     bea
 4379     uti
 1134     ful
 7903     .

当 subword tokenizer 遇到从没出现过在字典里的词汇,会将该词拆成多个子词(subwords)。比方说上面句中的beautiful就被拆成bea uti ful。这也是为何这种分词方法比较不怕没有出现过在字典里的字(out-of-vocabulary words)。

另外别在意我为了对齐写的 print 语法。重点是我们可以用 subword_encoder_endecode 函数再度将索引数字转回其对应的子词。编码与解码是 2 个完全可逆(invertable)的操作:

sample_string = 'Beijing is beautiful.'
indices = subword_encoder_en.encode(sample_string)
decoded_string = subword_encoder_en.decode(indices)
assert decoded_string == sample_string
pprint((sample_string, decoded_string))
('Beijing is beautiful.', 'Beijing is beautiful.')

接着让我们如法炮制,为中文也建立一个字典:

%%time
try:
  subword_encoder_zh = tfds.deprecated.text.SubwordTextEncoder.load_from_file(zh_vocab_file)
  print(f"载入已建立的字典: {zh_vocab_file}")
except:
  print("没有已建立的字典,从头建立。")
  subword_encoder_zh = tfds.deprecated.text.SubwordTextEncoder.build_from_corpus(
      (zh.numpy() for _, zh in train_examples), 
      target_vocab_size=2**13, # 有需要可以调整字典大小
      max_subword_length=1) # 每一个中文字就是字典里的一个单位
  
  # 将字典档案存下以方便下次 warmstart
  subword_encoder_zh.save_to_file(zh_vocab_file)

print(f"字典大小:{subword_encoder_zh.vocab_size}")
print(f"前 10 个 subwords:{subword_encoder_zh.subwords[:10]}")
print()
没有已建立的字典,从头建立。
字典大小:4205
前 10 个 subwords:['的', ',', '。', '国', '在', '是', '一', '和', '不', '这']

CPU times: user 6min 10s, sys: 1.6 s, total: 6min 12s
Wall time: 6min 9s

在使用 build_from_corpus 函数扫过整个中文数据集时,我们将 max_subword_length参数设置为 1。这样可以让每个汉字都会被视为字典里头的一个单位。毕竟跟英文的 abc 字母不同,一个汉字代表的意思可多得多了。而且如果使用 n-gram 的话可能的词汇组合太多,在小数据集的情况非常容易遇到不存在字典里头的字。

另外所有汉字也就大约 4000 ~ 5000 个可能,作为一个分类问题(classification problem)还是可以接受的。

让我们挑个中文句子来测试看看:

sample_string = sample_examples[0][1]
indices = subword_encoder_zh.encode(sample_string)
print(sample_string)
print(indices)
这种恐惧是真实而内在的。 忽视它的政治家们前途堪忧。
[10, 151, 574, 1298, 6, 374, 55, 29, 193, 5, 1, 3, 3981, 931, 431, 125, 1, 17, 124, 33, 20, 97, 1089, 1247, 861, 3]

好的,我们把中英文分词及字典的部分都搞定了。现在给定一个例子(example,在这边以及后文指的都是一组包含同语义的中英平行句子),我们都能将其转换成对应的索引序列了:

en = "The eurozone’s collapse forces a major realignment of European politics."
zh = "欧元区的瓦解强迫欧洲政治进行一次重大改组。"

# 將文字转成为 subword indices
en_indices = subword_encoder_en.encode(en)
zh_indices = subword_encoder_zh.encode(zh)

print("[英中原文](转换前)")
print(en)
print(zh)
print()
print('-' * 20)
print()
print("[英中序列](转换后)")
print(en_indices)
print(zh_indices)
[英中原文](转换前)
The eurozone’s collapse forces a major realignment of European politics.
欧元区的瓦解强迫欧洲政治进行一次重大改组。

--------------------

[英中序列](转换后)
[16, 900, 11, 6, 1527, 874, 8, 230, 2259, 2728, 239, 3, 89, 1236, 7903]
[44, 202, 168, 1, 852, 201, 231, 592, 44, 87, 17, 124, 106, 38, 7, 279, 86, 18, 212, 265, 3]

接着让我们针对这些索引序列(index sequence)做一些预处理。

4.4 数据预处理

在处理序列数据时我们时常会在一个序列的前后各加入一个特殊的 token,以标记该序列的开始与完结,而它们常有许多不同的称呼:

  • 开始 token、Begin of Sentence、BOS、
  • 结束 token、End of Sentence、EOS、

这边我们定义了一个将被 tf.data.Dataset 使用的 encode函数,它的输入是一笔包含 2 个string Tensors 的例子,输出则是 2 个包含 BOS / EOS 的索引序列:

def encode(en_t, zh_t):
  # 因为字典的索引从 0 开始,
  # 我们可以使用 subword_encoder_en.vocab_size 这个值作为 BOS 的索引值
  # 用 subword_encoder_en.vocab_size + 1 作为 EOS 的索引值
  en_indices = [subword_encoder_en.vocab_size] + subword_encoder_en.encode(
      en_t.numpy()) + [subword_encoder_en.vocab_size + 1]
  # 作为 EOS 的索引值
  zh_indices = [subword_encoder_zh.vocab_size] + subword_encoder_zh.encode(
      zh_t.numpy()) + [subword_encoder_zh.vocab_size + 1]
  
  return en_indices, zh_indices

因为 tf.data.Dataset里头都是在操作 Tensors(而非 Python 字串),所以这个encode函数预期的输入也是 TensorFlow 里的 Eager Tensors。但只要我们使用 numpy() 将 Tensor 里的实际字串取出以后,做的事情就跟上一节完全相同。

让我们从训练集里随意取一组中英的 Tensors 来看看这个函数的实际输出:

en_t, zh_t = next(iter(train_examples))
en_indices, zh_indices = encode(en_t, zh_t)
print('英文 BOS 的 index:', subword_encoder_en.vocab_size)
print('英文 EOS 的 index:', subword_encoder_en.vocab_size + 1)
print('中文 BOS 的 index:', subword_encoder_zh.vocab_size)
print('中文 EOS 的 index:', subword_encoder_zh.vocab_size + 1)

print('\n输入为 2 个 Tensors:')
pprint((en_t, zh_t))
print('-' * 15)
print('输出为 2 个索引序列:')
print((en_indices))
print((zh_indices))
英文 BOS 的 index: 8113
英文 EOS 的 index: 8114
中文 BOS 的 index: 4205
中文 EOS 的 index: 4206

输入为 2 个 Tensors:
(,
 )
---------------
输出为 2 个索引序列:
[8113, 16, 1284, 9, 243, 5, 1275, 1756, 156, 1, 5, 1016, 5566, 21, 38, 33, 2982, 7965, 7903, 8114]
[4205, 10, 151, 574, 1298, 6, 374, 55, 29, 193, 5, 1, 3, 3981, 931, 431, 125, 1, 17, 124, 33, 20, 97, 1089, 1247, 861, 3, 4206]

你可以看到不管是英文还是中文的索引序列,前面都加了一个代表 BOS 的索引(分别为 8113 与 4205),最后一个索引则代表 EOS(分别为 8114 与 4206)

但如果我们将encode函数直接套用到整个训练资料集时会产生以下的错误信息:

train_dataset = train_examples.map(encode)
---------------------------------------------------------------------------

AttributeError                            Traceback (most recent call last)

 in ()
----> 1 train_dataset = train_examples.map(encode)


 ......

/usr/local/lib/python3.6/dist-packages/tensorflow/python/autograph/impl/api.py in wrapper(*args, **kwargs)
    263       except Exception as e:  # pylint:disable=broad-except
    264         if hasattr(e, 'ag_error_metadata'):
--> 265           raise e.ag_error_metadata.to_exception(e)
    266         else:
    267           raise


AttributeError: in user code:

    :5 encode  *
        en_indices = [subword_encoder_en.vocab_size] + subword_encoder_en.encode(

    AttributeError: 'Tensor' object has no attribute 'numpy'

这是因为目前 tf.data.Dataset.map 函数里头的计算是在计算图模式(Graph mode)下执行,所以里头的 Tensors 并不会有 Eager Execution 下才有的 numpy 属性。

解法是使用 tf.py_function将我们刚刚定义的 encode函数包成一个以 eager 模式执行的 TensorFlow Op:

def tf_encode(en_t, zh_t):
  # 在 `tf_encode` 函数里头的 `en_t` 与 `zh_t` 都不是 Eager Tensors
  # 要到 `tf.py_funtion` 里头才是
  # 另外因为索引都是整数,所以使用 `tf.int64`
  return tf.py_function(encode, [en_t, zh_t], [tf.int64, tf.int64])

# `tmp_dataset` 为说明用资料集,说明完所有重要的 func,
# 我们会从头建立一个正式的 `train_dataset`
tmp_dataset = train_examples.map(tf_encode)
en_indices, zh_indices = next(iter(tmp_dataset))
print(en_indices)
print(zh_indices)
tf.Tensor(
[8113   16 1284    9  243    5 1275 1756  156    1    5 1016 5566   21
   38   33 2982 7965 7903 8114], shape=(20,), dtype=int64)
tf.Tensor(
[4205   10  151  574 1298    6  374   55   29  193    5    1    3 3981
  931  431  125    1   17  124   33   20   97 1089 1247  861    3 4206], shape=(28,), dtype=int64)

有点 tricky 但任务完成!注意在套用map函数以后,tmp_dataset 的输出已经是两个索引序列,而非原文字串。

为了让 Transformer 快点完成训练,让我们将长度超过 40 个 tokens 的序列都去掉吧!我们在底下定义了一个布林(boolean)函数,其输入为一个包含两个英中序列en, zh 的例子,并在只有这2 个序列的长度都小于40 的时候回传真值(True) :

MAX_LENGTH = 40

def filter_max_length(en, zh, max_length=MAX_LENGTH):
  # en, zh 分别代表英文与中文的索引序列
  return tf.logical_and(tf.size(en) <= max_length,
                        tf.size(zh) <= max_length)

# tf.data.Dataset.filter(func) 只会回传 func 为真的例子
tmp_dataset = tmp_dataset.filter(filter_max_length)

简单检查是否有序列超过我们指定的长度,顺便计算过滤掉过长序列后剩余的训练集笔数:

# 因为我们数据量小可以这样 count
num_examples = 0
for en_indices, zh_indices in tmp_dataset:
  cond1 = len(en_indices) <= MAX_LENGTH
  cond2 = len(zh_indices) <= MAX_LENGTH
  assert cond1 and cond2
  num_examples += 1

print(f"所有英文与中文序列长度都不超过 {MAX_LENGTH} 个 tokens")
print(f"训练资料集里总共有 {num_examples} 笔数据")
所有英文与中文序列长度都不超过 40 个 tokens
训练资料集里总共有 29784 笔数据

过滤掉较长句子后还有接近 3 万笔的训练例子,看来不用担心数据太少。

最后值得注意的是每个例子里的索引序列长度不一,这在建立 batch 时可能会发生问题。不过别担心,轮到padded_batch 函数出场了:

BATCH_SIZE = 64
# 将 batch 里的所有序列都 pad 到同样长度
tmp_dataset = tmp_dataset.padded_batch(BATCH_SIZE, padded_shapes=([-1], [-1]))
en_batch, zh_batch = next(iter(tmp_dataset))
print("英文索引序列的 batch")
print(en_batch)
print('-' * 20)
print("中文索引序列的 batch")
print(zh_batch)
英文索引序列的 batch
tf.Tensor(
[[8113   16 1284 ...    0    0    0]
 [8113 1894 1302 ...    0    0    0]
 [8113   44   40 ...    0    0    0]
 ...
 [8113  122  506 ...    0    0    0]
 [8113   16  215 ...    0    0    0]
 [8113 7443 7889 ...    0    0    0]], shape=(64, 39), dtype=int64)
--------------------
中文索引序列的 batch
tf.Tensor(
[[4205   10  151 ...    0    0    0]
 [4205  206  275 ...    0    0    0]
 [4205    5   10 ...    0    0    0]
 ...
 [4205   34    6 ...    0    0    0]
 [4205  317  256 ...    0    0    0]
 [4205  167  326 ...    0    0    0]], shape=(64, 40), dtype=int64)

padded_batch 函数能帮我们将每个 batch 里头的序列都补 0 到跟当下 batch 里头最长的序列一样长。

比方说英文 batch 里最长的序列为 34;而中文 batch 里最长的序列则长达 40 个 tokens,刚好是我们前面设定过的序列长度上限。

好啦,现在让我们从头建立训练集与验证集,顺便看看这些中英句子是如何被转换成它们的最终形态的:

MAX_LENGTH = 40
BATCH_SIZE = 128
BUFFER_SIZE = 15000

# 训练集
train_dataset = (train_examples  # 输出:(英文句子, 中文句子)
                 .map(tf_encode) # 输出:(英文索引序列, 中文索引序列)
                 .filter(filter_max_length) # 同上,且序列长度都不超过 40
                 .cache() # 加快读取数据
                 .shuffle(BUFFER_SIZE) # 将例子洗牌确保随机性
                 .padded_batch(BATCH_SIZE, # 将 batch 里的序列都 pad 到一样长度
                               padded_shapes=([-1], [-1]))
                 .prefetch(tf.data.experimental.AUTOTUNE)) # 加速
# 验证集
val_dataset = (val_examples
               .map(tf_encode)
               .filter(filter_max_length)
               .padded_batch(BATCH_SIZE, 
                             padded_shapes=([-1], [-1])))

建构训练数据集时我们还添加了些没提过的函数。它们的用途大都是用来提高输入效率,并不会影响到输出格式。如果你想深入了解这些函数的运作方式,可以参考 tf.data 的官方教学。

现在让我们看看最后建立出来的资料集长什么样子:

en_batch, zh_batch = next(iter(train_dataset))
print("英文索引序列的 batch")
print(en_batch)
print('-' * 20)
print("中文索引序列的 batch")
print(zh_batch)
英文索引序列的 batch
tf.Tensor(
[[8113  571   91 ...    0    0    0]
 [8113  246 4266 ...    0    0    0]
 [8113 4077 3168 ...    0    0    0]
 ...
 [8113  367  693 ...    0    0    0]
 [8113  435 1062 ...    0    0    0]
 [8113  122    2 ...    0    0    0]], shape=(128, 37), dtype=int64)
--------------------
中文索引序列的 batch
tf.Tensor(
[[4205  378  100 ...    0    0    0]
 [4205  826   97 ...    0    0    0]
 [4205 1275  154 ...    0    0    0]
 ...
 [4205    7   28 ... 4206    0    0]
 [4205   52   11 ...    0    0    0]
 [4205   29  305 ...    0    0    0]], shape=(128, 40), dtype=int64)

我们建立了一个可供训练的输入管道(Input pipeline)!

你会发现训练集:

  • 一次回传大小为 128 的 2 个 batch,分别包含 128 个英文、中文的索引序列
  • 每个序列开头皆为 BOS,英文的 BOS 索引是 8113;中文的 BOS 索引则为 4205
  • 两语言 batch 里的序列都被「拉长」到我们先前定义的最长序列长度:40
  • 验证集也是相同的输出形式。

现在你应该可以想像我们在每个训练步骤会拿出来的数据长什么样子了:2 个shape 为(batch_size, seq_len) 的Tensors,而里头的每一个索引数字都代表着一个中/ 英文子词(或是BOS / EOS)。

在这一节我们建立了一个通用数据集。 「通用」代表不限于 Transformer,你也能用一般搭配注意力机制的 Seq2Seq 模型来处理这个数据集并做中英翻译。

但从下节开始让我们把这个数据集先摆一边,将注意力全部放到 Transformer 身上并逐一实现其架构里头的各个元件。

你可能感兴趣的:(透过机器翻译理解Transformer(二): 师傅引进门,修行在个人-建立输入管道)