0%

变分自编码器(VAE)是一类常见的生成模型。纯VAE的生成效果不见得是最好的,但VAE还是经常会被用作大模型的子模块。即使是在VAE发明多年的今天,学习VAE还是很有必要的。相比GAN等更符合直觉的模型,彻底理解VAE对数学的要求较高。在这篇文章中,我会从计算机科学的角度出发,简明地讲清楚VAE的核心原理,并附上代码实现的介绍。同时,我会稍微提及VAE是怎么利用数学知识的,以及该怎么去拓展了解这些数学知识。

用自编码器生成图像

在正式开始学习VAE之前,我们先探讨一下内容生成的几种方式,并引入自编码器(Autoencoder, AE)这个概念。为了方面描述,我们仅讨论图像的生成。

在设计生成图像的程序之前,我们要考虑一个问题——程序的输入是什么?如果程序没有任何输入,那么它就应该有一个确定的输出,也就是只能画出一幅图片。而只能画出一幅图片的程序没有任何意义的。因此,一个图像生成模型一定要有输入,用于区分不同的图片。哪怕这种输入仅仅是0, 1, 2这种序号也可以,只要模型能看懂输入,为每个输入生成不同的图片就行了。

可是,我们不仅希望不同的输入能区分不同的图片,还要让相近的输入生成相近的图片。比如1.5号图片应该长得和1号和2号相似。为了让模型满足这种性质,我们可以干脆把模型的输入建模成有意义的高维实数向量。这个向量,可以是看成对图像的一种压缩编码。比如(170, 1)就表示一幅身高为170cm的男性的照片。

绝大多数生成模型都是用这种方式对生成过程建模。所有的输入向量$z$来自于一个标准正态分布$Z$。图像生成,就是把图像的编码向量$z$解码成一幅图像的过程。不同的生成模型,只是对这个过程有着不同的约束方式。

自编码器的约束方式十分巧妙:既然把$z$翻译回图像是一个解码的过程,为什么不可以把编码的过程也加进来,让整个过程自动学习呢?如下图所示,我们可以让一个模型(编码器)学会怎么把图片压缩成一个编码,再让另一个模型(解码器)学会怎么把编码解压缩成一幅图片,最小化生成图片与原图片之间的误差。

最后,解码器就是我们需要的生成模型。只要在标准多元正态分布里采样出$z$,就可生成图片了。另外,理想情况下,$z$之间的插值向量也能代表在语义上插值的图片。

可是,由于自编码器本身的限制,这种理想不一定能实现。

自编码器的问题——过拟合

自编码器的信息压缩能力十分强大。只要编码器和解码器的神经网络足够复杂,所有训练集里的图像都可以被压缩成非常短的编码。这种编码短到什么程度了呢?——只要一个一维向量(实数)就可以描述所有训练集里的图像了。

想做到这一点并不难。还记得我们开头对生成模型的输入的讨论吗?只要让模型把所有图片以数组的形式存到编码器和解码器里,以0, 1, 2这样的序号来表示每一幅训练集里的图片,就能完成最极致的信息压缩。当然,使用这种方式的话,编码$z$就失去了所有的语义信息,编码之间的插值也不能表示图像语义上的插值了。

这是由模型过拟合导致的。如果仅使用自编码器本身的约束方式,而不加入其他正则化方法的话,一定会出现过拟合。

VAE——一种正则化的自编码器

VAE就是一种使用了某种正则化方法的自编码器,它解决了上述的过拟合问题。VAE使用的这种方法来自于概率论的变分推理,不过,我们可以在完全不了解变分推理的前提下看懂VAE。

VAE的想法是这样的:我们最终希望得到一个分布$Z$,或者说一条连续的直线。可是,编码器每次只能把图片编码成一个向量,也就是一个点。很多点是很难重建出一条连续的直线的。既然如此,我们可以把每张图片也编码成一个分布。多条直线,就可以比较容易地拼成我们想要的直线了。

当然,只让模型去拟合分布是不够的。如果各个分布都乱七八糟,相距甚远,那么它们怎么都拼不成一个标准正态分布。因此,我们还需要加上一个约束,让各个分布和标准正态分布尽可能相似。

这样,我们可以总结一下VAE的训练框架。VAE依然使用了编码器-解码器的架构。只不过,编码器的输出是一个可学习的正态分布。对分布是不可能做求导和梯度下降的,但我们可以去分布里采样,对采样出来的编码$z$解码并求导。

另外,VAE的损失函数除了要最小化重建图像与原图像之间的均方误差外,还要最大化每个分布和标准正态分布之间的相似度。

常见的描述分布之间相似度的指标叫做KL散度。只要把KL散度的公式套进损失函数里,整个训练框架就算搭好了。

如果你对KL散度的原理感兴趣,欢迎阅读我的上一篇文章:从零理解熵、交叉熵、KL散度

VAE的原理其实就是这么简单。总结一下,VAE本身是一个编码器-解码器结构的自编码器,只不过编码器的输出是一个分布,而解码器的输入是该分布的一个样本。另外,在损失函数中,除了要让重建图像和原图像更接近以外,还要让输出的分布和标准正态分布更加接近。

VAE 与变分推理

前几段其实只对VAE做了一个直觉上的描述,VAE的损失函数实际上是经严谨的数学推导得到的。如果你对数学知识不感兴趣,完全可以跳过这一节的讲解。当然,这一节也只会简单地描述VAE和变分推理的关系,更详细的数学推导可以去参考网上的其他文章。

让我们从概率论的角度看待生成模型。生成模型中的$z$可以看成是隐变量,它决定了能观测到的变量$x$。比如说,袋子里有黑球和白球,你不断地从袋子里取球出来再放回去,就能够统计出抽到黑球和白球的频率。然而,真正决定这个频率的,是袋子里黑球和白球的数量,这些数量就是观测不到的隐变量。简单来说,隐变量$z$是因,变量$x$是果。

生成模型,其实就是假设$z$来自标准正态分布,想要拟合分布$P(x|z)$(解码器),以得到$x$的分布(图像分布)。为了训练解码器,自编码器架构使用了一个编码器以描述$P(z|x)$。这样,从训练集里采样,等于是采样出了一个$x$。根据$P(z|x)$求出一个$z$,再根据$P(x|z)$试图重建$x$。优化这个过程,就是在优化编码器和解码器,也就是优化$P(z|x)$和$P(x|z)$。

然而,$P(z|x)$和$P(x|z)$之间有一个约束,它们必须满足贝叶斯公式:

假如我们要用一个和$x$有关的关于$z$的分布$Q_x(z)$去拟合$P(z|x)$,就要让$Q_x(z)$和$\frac{P(x|z)P(z)}{P(x)}$这两个分布尽可能相似。如果这个相似度是KL散度,经过一系列的推导,就可以推导出我们在VAE里使用的那个损失函数。

简单来说,拟合一个未知分布的技术就叫做变分推理。VAE利用变分推理,对模型的编码器和解码器加了一个约束,这个约束在化简后就是VAE的损失函数。

VAE和变分推理的关系就是这样。如果还想细究,可以去先学习KL散度相关的知识,再去看一下VAE中KL散度的公式推导。当然,不懂这些概念并不影响VAE的学习。

总结

VAE其实就是一个编码器-解码器架构,和U-Net以及部分NLP模型类似。然而,为了抑制自编码过程中的过拟合,VAE编码器的输出是一个正态分布,而不是一个具体的编码。同时,VAE的损失函数除了约束重建图像外,还约束了生成的分布。在这些改进下,VAE能够顺利地训练出一个解码器,以把来自正态分布的随机变量$z$画成一幅图像。

如果你想通过代码实践进一步加深对VAE的理解,可以阅读附录。

参考资料

  1. 一篇不错的VAE讲解。我是跟着这篇文章学习的。https://towardsdatascience.com/understanding-variational-autoencoders-vaes-f70510919f73
  2. 我的VAE PyTorch实现参考了这个仓库:https://github.com/AntixK/PyTorch-VAE 。开头的人脸生成效果图是从这个项目里摘抄过来的。

VAE PyTorch 实现

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/VAE

数据集

在这个项目中,我使用了CelebA数据集。这个数据集有200k张人脸,裁剪和对齐后的图片只有1个多G,对实验非常友好。

CelebA的下载链接可以在官方网站上找到:https://mmlab.ie.cuhk.edu.hk/projects/CelebA.html。

下载好了图片后,可以用下面的代码创建Dataloader。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import os

import torch
from PIL import Image
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms


class CelebADataset(Dataset):
def __init__(self, root, img_shape=(64, 64)) -> None:
super().__init__()
self.root = root
self.img_shape = img_shape
self.filenames = sorted(os.listdir(root))

def __len__(self) -> int:
return len(self.filenames)

def __getitem__(self, index: int):
path = os.path.join(self.root, self.filenames[index])
img = Image.open(path).convert('RGB')
pipeline = transforms.Compose([
transforms.CenterCrop(168),
transforms.Resize(self.img_shape),
transforms.ToTensor()
])
return pipeline(img)


def get_dataloader(root='data/celebA/img_align_celeba', **kwargs):
dataset = CelebADataset(root, **kwargs)
return DataLoader(dataset, 16, shuffle=True)

这段代码是一段非常常规的根据图片路径读取图片的代码。只有少数地方需要说明:

  • 为了尽快完成demo,所有人脸图片的分辨率都是$64 \times 64$。
  • CelebA里裁剪后的人脸图片是长方形的。要先调用CenterCrop裁剪出正方形人脸,再做Resize。

为了验证Dataloader的正确性,我们可以写一些脚本来查看Dataloader里的一个batch的图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
if __name__ == '__main__':
dataloader = get_dataloader()
img = next(iter(dataloader))
print(img.shape)
# Concat 4x4 images
N, C, H, W = img.shape
assert N == 16
img = torch.permute(img, (1, 0, 2, 3))
img = torch.reshape(img, (C, 4, 4 * H, W))
img = torch.permute(img, (0, 2, 1, 3))
img = torch.reshape(img, (C, 4 * H, 4 * W))
img = transforms.ToPILImage()(img)
img.save('work_dirs/tmp.jpg')

这段代码使用了一些小技巧。首先,next(iter(dataloader))可以访问Dataloader的第一个数据。其次,在把一个batch的图片转换成图片方格的过程中,我使用了比较骚的换维度、换形状操作,看起来很帅。

模型

我的VAE模型使用了类似U-Net的操作:编码器用卷积把图像的边长减半,通道翻倍,解码器用反卷积把图像的边长翻倍,通道减半。

模型结构的定义函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import torch
import torch.nn as nn


class VAE(nn.Module):
'''
VAE for 64x64 face generation. The hidden dimensions can be tuned.
'''
def __init__(self, hiddens=[16, 32, 64, 128, 256], latent_dim=128) -> None:
super().__init__()

# encoder
prev_channels = 3
modules = []
img_length = 64
for cur_channels in hiddens:
modules.append(
nn.Sequential(
nn.Conv2d(prev_channels,
cur_channels,
kernel_size=3,
stride=2,
padding=1), nn.BatchNorm2d(cur_channels),
nn.ReLU()))
prev_channels = cur_channels
img_length //= 2
self.encoder = nn.Sequential(*modules)
self.mean_linear = nn.Linear(prev_channels * img_length * img_length,
latent_dim)
self.var_linear = nn.Linear(prev_channels * img_length * img_length,
latent_dim)
self.latent_dim = latent_dim
# decoder
modules = []
self.decoder_projection = nn.Linear(
latent_dim, prev_channels * img_length * img_length)
self.decoder_input_chw = (prev_channels, img_length, img_length)
for i in range(len(hiddens) - 1, 0, -1):
modules.append(
nn.Sequential(
nn.ConvTranspose2d(hiddens[i],
hiddens[i - 1],
kernel_size=3,
stride=2,
padding=1,
output_padding=1),
nn.BatchNorm2d(hiddens[i - 1]), nn.ReLU()))
modules.append(
nn.Sequential(
nn.ConvTranspose2d(hiddens[0],
hiddens[0],
kernel_size=3,
stride=2,
padding=1,
output_padding=1),
nn.BatchNorm2d(hiddens[0]), nn.ReLU(),
nn.Conv2d(hiddens[0], 3, kernel_size=3, stride=1, padding=1),
nn.ReLU()))
self.decoder = nn.Sequential(*modules)

首先来看编码器的部分。每个卷积模块由卷积、BN、ReLU构成。卷完了再用两个全连接层分别生成正态分布的均值和方差。注意,卷积完成后,图像的形状是[prev_channels, img_length, img_length],为了把它输入到全连接层,我们到时候会做一个flatten操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# encoder
prev_channels = 3
modules = []
img_length = 64
for cur_channels in hiddens:
modules.append(
nn.Sequential(
nn.Conv2d(prev_channels,
cur_channels,
kernel_size=3,
stride=2,
padding=1), nn.BatchNorm2d(cur_channels),
nn.ReLU()))
prev_channels = cur_channels
img_length //= 2
self.encoder = nn.Sequential(*modules)
self.mean_linear = nn.Linear(prev_channels * img_length * img_length,
latent_dim)
self.var_linear = nn.Linear(prev_channels * img_length * img_length,
latent_dim)
self.latent_dim = latent_dim

解码器和编码器的操作基本完全相反。由于隐变量的维度是latent_dim,需要再用一个全连接层把图像的维度投影回[prev_channels, img_length, img_length]。之后就是反卷积放大图像的过程。写这些代码时一定要算好图像的边长,定好反卷积的次数,并且不要忘记最后把图像的通道数转换回3。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# decoder
modules = []
self.decoder_projection = nn.Linear(
latent_dim, prev_channels * img_length * img_length)
self.decoder_input_chw = (prev_channels, img_length, img_length)
for i in range(len(hiddens) - 1, 0, -1):
modules.append(
nn.Sequential(
nn.ConvTranspose2d(hiddens[i],
hiddens[i - 1],
kernel_size=3,
stride=2,
padding=1,
output_padding=1),
nn.BatchNorm2d(hiddens[i - 1]), nn.ReLU()))
modules.append(
nn.Sequential(
nn.ConvTranspose2d(hiddens[0],
hiddens[0],
kernel_size=3,
stride=2,
padding=1,
output_padding=1),
nn.BatchNorm2d(hiddens[0]), nn.ReLU(),
nn.Conv2d(hiddens[0], 3, kernel_size=3, stride=1, padding=1),
nn.ReLU()))
self.decoder = nn.Sequential(*modules)

网络前向传播的过程如正文所述,先是用编码器编码,把图像压平送进全连接层得到均值和方差,再用randn_like随机采样,把采样的z投影、变换成正确的维度,送入解码器,最后输出重建图像以及正态分布的均值和方差。

1
2
3
4
5
6
7
8
9
10
11
12
13
def forward(self, x):
encoded = self.encoder(x)
encoded = torch.flatten(encoded, 1)
mean = self.mean_linear(encoded)
logvar = self.var_linear(encoded)
eps = torch.randn_like(logvar)
std = torch.exp(logvar / 2)
z = eps * std + mean
x = self.decoder_projection(z)
x = torch.reshape(x, (-1, *self.decoder_input_chw))
decoded = self.decoder(x)

return decoded, mean, logvar

用该模型随机生成图像的过程和前向传播的过程十分类似,只不过$z$来自于标准正态分布而已,解码过程是一模一样的。

1
2
3
4
5
6
def sample(self, device='cuda'):
z = torch.randn(1, self.latent_dim).to(device)
x = self.decoder_projection(z)
x = torch.reshape(x, (-1, *self.decoder_input_chw))
decoded = self.decoder(x)
return decoded

主函数

在主函数中,我们要先完成模型训练。在训练前,还有一件重要的事情要做:定义损失函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from time import time

import torch
import torch.nn.functional as F
from torchvision.transforms import ToPILImage

from dldemos.VAE.load_celebA import get_dataloader
from dldemos.VAE.model import VAE

# Hyperparameters
n_epochs = 10
kl_weight = 0.00025
lr = 0.005


def loss_fn(y, y_hat, mean, logvar):
recons_loss = F.mse_loss(y_hat, y)
kl_loss = torch.mean(
-0.5 * torch.sum(1 + logvar - mean**2 - torch.exp(logvar), 1), 0)
loss = recons_loss + kl_loss * kl_weight
return loss

如正文所述,VAE的loss包括两部分:图像的重建误差和分布之间的KL散度。二者的比例可以通过kl_weight来控制。

KL散度的公式直接去网上照抄即可。

这里要解释一下,我们的方差为什么使用其自然对数logvar。经过我的实验,如果让模型输出方差本身的话,就要在损失函数里对齐取一次自然对数。如果方差很小,趋于0的话,方差的对数就趋于无穷。这表现在loss里会出现nan。因此,在神经网络中我们应该避免拟合要取对数的数,而是直接去拟合其对数运算结果。

准备好了损失函数,剩下就是常规的训练操作了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def train(device, dataloader, model):
optimizer = torch.optim.Adam(model.parameters(), lr)
dataset_len = len(dataloader.dataset)

begin_time = time()
# train
for i in range(n_epochs):
loss_sum = 0
for x in dataloader:
x = x.to(device)
y_hat, mean, logvar = model(x)
loss = loss_fn(x, y_hat, mean, logvar)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_sum += loss
loss_sum /= dataset_len
training_time = time() - begin_time
minute = int(training_time // 60)
second = int(training_time % 60)
print(f'epoch {i}: loss {loss_sum} {minute}:{second}')
torch.save(model.state_dict(), 'dldemos/VAE/model.pth')

训练好模型后,想要查看模型重建数据集图片的效果也很简单,去dataloader里采样、跑模型、后处理结果即可。

1
2
3
4
5
6
7
8
9
10
def reconstruct(device, dataloader, model):
model.eval()
batch = next(iter(dataloader))
x = batch[0:1, ...].to(device)
output = model(x)[0]
output = output[0].detach().cpu()
input = batch[0].detach().cpu()
combined = torch.cat((output, input), 1)
img = ToPILImage()(combined)
img.save('work_dirs/tmp.jpg')

想用模型随机生成图片的话,可以利用之前写好的模型采样函数。

1
2
3
4
5
6
def generate(device, model):
model.eval()
output = model.sample(device)
output = output[0].detach().cpu()
img = ToPILImage()(output)
img.save('work_dirs/tmp.jpg')

在3090上跑这个实验,100个epoch需要5个多小时。但是,模型差不多在10多个epoch的时候就收敛了。

最朴素的VAE的重建效果并不是很好,只能大概看出个脸型。这可能也和我的模型参数较少有关。

随机生成的图片也是形状还可以,但非常模糊。

如今,人脸风格迁移的效果越来越惊人。给定一张人脸照片,不管是变成卡通风格、二次元风格,甚至是变成讽刺画风格,AI都能轻轻松松做到。

AI能有这么强的生成效果,都得归功于创作力极强的StyleGAN。StyleGAN本身只是用来随机生成人脸的模型。随着研究的不断推进,StyleGAN被应用到了人脸风格迁移任务中。在这篇文章里,我将简要介绍一下基于StyleGAN的人脸风格迁移的最新pipeline。

知识准备

风格迁移

在深度CNN展示出了强大的图像识别能力后,人们产生了疑问:CNN是感知图像的呢?为此,研究者找出了令深度CNN各层输出值最大的输入图片。这些图片能表明CNN各层“看到了”什么。实验结果显示,浅层网络关注轮廓信息,而深层网络会关注更抽象的信息。

受此启发,有人提出了一种叫做神经网络风格迁移[1]的应用——输入一张内容图像(C)和一张风格图像(S),输出一幅融合内容与风格的图片(G)。这一应用是通过对齐图像在深度CNN中各层的输出结果实现的。

风格迁移的具体介绍可以参见我之前的文章:Neural Style Transfer 风格迁移经典论文讲解

StyleGAN

GAN能够根据一个高维向量$z$生成图像($z$可以看成图像的身份证号)。但是,早期的图像GAN不能对图像生成的过程加以干预。

对此,人们提出了可控的图像生成网络StyleGAN[2]。在这个模型生成完一幅图像后,可以对图片进行由粗至精共18种微调:对于人脸生成而言,既可以调整性别、年龄这种更宏观的属性,也可以调整肤色、发色这种更具体的属性。

StyleGAN 的详细介绍可以参见我之前的文章:用18支画笔作画的AI ~ StyleGAN特点浅析

基于 image-to-image 的风格迁移

在计算机眼中,无论是自然语言,还是图像,都是一堆数据。就像不同的语言之间可以翻译一样,不同风格之间也可以翻译。风格迁移任务,其实就可以看成把来自某一种风格的图像翻译到另一种风格中。早期的人脸风格迁移,都是通过图像翻译(image-to-image translation)框架实现的。

对于语言翻译,需要准备好成对数据的数据集。比如在中文到英文翻译任务中,要给每一个中文句子准备其对应的英文翻译。而在风格迁移任务中,获得成对数据是几乎不可能的:哪有画家会去费心地给上千张人脸绘制出对应的漫画脸呢?因此,在风格迁移中,图像翻译都是通过无监督方式训练的,即不去使用成对的数据。

无监督的图像翻译一般也是通过GAN实现。生成器的输入是普通的人脸照片,输出是其对应的风格人脸照片;判别器的输入是生成的假图或者风格数据集里的真图,输出为该图片是否是风格图片。受到StyleGAN的启发,风格迁移的图像翻译网络还会使用类似的“风格模块”,以提升风格迁移的效果。下图是近期的图像翻译风格迁移工作 U-GAT-IT[3] 的网络示意图。

用图像翻译的想法来做风格迁移确实可行。但是,换一种新风格,就得花费大量精力去训练一个GAN,这一流程十分繁琐。

利用预训练 StyleGAN 的 Toonify

在StyleGAN面世后,热心的研究者们对其展开了各个角度的拓展。其中,有一篇短小精悍的文章用一种十分简单的方法让StyleGAN完成了风格迁移。这篇工作后来被称为Toonify[4]。

先来看一下Toonify的使用效果。Toonify使用了两个StyleGAN,一个能够生成真实的人脸,一个能够生成风格化人脸。对于同一个高维向量输入$z$,只需要对两个StyleGAN的权重进行插值,就能让$z$对应的两幅图像之间发生风格迁移。下图中,a是真实StyleGAN的输出,b是风格化StyleGAN的输出,c, d是把两幅图像的内容和风格交换后的输出,e是精心组合两个StyleGAN权重得到的输出。

Toonify使用的权重插值方法非常直接。StyleGAN生成图像时,会先生成分辨率较小的图片,再逐渐把图片放大。修改小分辨率处的权重,会改变图像的内容;修改大分辨率处的权重,会改变图像的风格。Toonify做的插值,仅仅是交换两个StyleGAN不同分辨率处的权重而已。

以上只是Toonify的核心思想。要利用Toonify做风格迁移,还有两个问题要解决:

  1. 怎么根据真实StyleGAN得到风格化的StyleGAN?
  2. StyleGAN的图片是通过$z$生成出来的。对于任意一幅真实人脸图像,怎么得到它的$z$呢?

对于第一个问题,风格化的StyleGAN是通过微调得到的。作者用预训练的真实StyleGAN在新的风格化数据集上微调出了另一个StyleGAN。

对于第二个问题,任意一张真实人脸图片的$z$可以通过简单的数学优化来实现:给定一张图片,我们要求一个$z$,使得$z$通过真实StyleGAN的输出和输入图片尽可能相似。当然,这种直接优化得到的$z$不一定是最优的。把任何一张图片嵌入进StyleGAN的隐空间是一个大的研究方向,叫做GAN Inversion.

总结下来,Toonify使用的pipeline如下:

  1. 在新风格上微调StyleGAN。
  2. 通过GAN Inversion得到真实人脸的$z$。
  3. 交换新旧StyleGAN的权重,得到插值StyleGAN。$z$在插值StyleGAN中的输出就是风格迁移后的图片。

基于 StyleGAN 的风格迁移

Toonify这篇工作本身非常简短,但它却为风格迁移指出了一条新的路线。相比基于图像翻译的风格迁移,这种新方法有以下好处:

  1. 训练代价大幅减小。新方法可以直接使用预训练好的StyleGAN,在几百幅新风格的图像上微调即可完成主要的模型训练,而无需从头训练一个GAN。
  2. 可以直接利用StyleGAN的强大图像生成能力,甚至是StyleGAN的图像编辑能力(修改低分辨率或高分辨率的输入,以改变图像的内容和风格)。

以发表在图形学顶刊ACM TOG上的AgileGAN[5]为例,我们来简单地看一下前沿的风格迁移框架是怎样的。

模型训练分两部分,先训练编码器(完成GAN Inversion),再训练解码器(生成图像)。作者用VAE来建模编码器的训练过程,此时的解码器是固定权重的预训练StyleGAN。基于VAE的编码器训练好了之后,再固定编码器的权重,把预训练的StyleGAN在新数据集上微调,得到风格化的StyleGAN。

在推理时,输入一张图片,VAE输出的分布的均值即是该图片的编码$z$。把$z$输入进微调后的StyleGAN,即可得到风格迁移的结果。

由于使用了预训练StyleGAN,整个训练过程能在一小时内完成。

作者还精心优化了StyleGAN的结构。StyleGAN的浅层决定了生成图像的抽象属性,比如性别、年龄。通过修改浅层的权重,即可得到不同性别、年龄的人脸图像。作者在StyleGAN的浅层网络中加入了多条路径(多条路径其实等价于多个网络),一条路径固定了一种人脸属性。比如第一条路径只能生成男性,第二条路径只能生成女性。每一条路径都有一个专门的判别器,该判别器集成了StyleGAN本身的预训练判别器和一个判断属性的判别器,保证一条路径生成的图像都满足某属性。

从这个例子中,我们能够直观地认识到微调StyleGAN的风格迁移方法的优势。训练上的便捷性、StyleGAN强大的生成能力与编辑能力使得这一方法在众多风格迁移方法中脱颖而出。

结语

在这篇文章中,我简要介绍了一种前沿的风格迁移pipeline,并讲述了它是如何一步一步诞生的。从本文提及的前沿工作中也能看出,当前风格迁移的效果非常好,训练起来也不难,可以用其轻松地开发出人脸年轻化、性别转换、二次元化等等有趣的应用。如果你对相关的研究或应用感兴趣,欢迎参考这篇文章提及的文献进行学习。

如果你很细心,会发现这篇文章开头展示的图片并不是本文介绍的任何一篇论文的输出结果。实际上,它是一篇更新的工作——DualStyleGAN——的输出结果。这篇工作的原理更加复杂,我会在之后的文章里对其做介绍。

参考文献

[1] Image style transfer using convolutional neural networks

[2] A Style-Based Generator Architecture for Generative Adversarial Networks

[3] U-GAT-IT: Unsupervised Generative Attentional Networks with Adaptive Layer-Instance Normalization for Image-to-Image Translation

[4] Resolution Dependent GAN Interpolation for
Controllable Image Synthesis Between Domains

[5] AgileGAN: Stylizing Portraits by Inversion-Consistent Transfer Learning

Attention Is All You Need (Transformer) 是当今深度学习初学者必读的一篇论文。但是,这篇工作当时主要是用于解决机器翻译问题,有一定的写作背景,对没有相关背景知识的初学者来说十分难读懂。在这篇文章里,我将先补充背景知识,再清晰地解读一下这篇论文,保证让大多数对深度学习仅有少量基础的读者也能彻底读懂这篇论文。

知识准备

机器翻译,就是将某种语言的一段文字翻译成另一段文字。

由于翻译没有唯一的正确答案,用准确率来衡量一个机器翻译算法并不合适。因此,机器翻译的数据集通常会为每一条输入准备若干个参考输出。统计算法输出和参考输出之间的重复程度,就能评价算法输出的好坏了。这种评价指标叫做BLEU Score。这一指标越高越好。

在深度学习时代早期,人们使用RNN(循环神经网络)来处理机器翻译任务。一段输入先是会被预处理成一个token序列。RNN会对每个token逐个做计算,并维护一个表示整段文字整体信息的状态。根据当前时刻的状态,RNN可以输出当前时刻的一个token。

所谓token,既可以是一个单词、一个汉字,也可能是一个表示空白字符、未知字符、句首字符的特殊字符。

具体来说,在第$t$轮计算中,输入是上一轮的状态$a^{< t - 1 >}$以及这一轮的输入token $x ^{< t >}$,输出这一轮的状态$a^{< t >}$以及这一轮的输出token $y ^{< t >}$。

这种简单的RNN架构仅适用于输入和输出等长的任务。然而,大多数情况下,机器翻译的输出和输入都不是等长的。因此,人们使用了一种新的架构。前半部分的RNN只有输入,后半部分的RNN只有输出(上一轮的输出会当作下一轮的输入以补充信息)。两个部分通过一个状态$a^{< T_x >}$来传递信息。把该状态看成输入信息的一种编码的话,前半部分可以叫做“编码器”,后半部分可以叫做“解码器”。这种架构因而被称为“编码器-解码器”架构。

这种架构存在不足:编码器和解码器之间只通过一个隐状态来传递信息。在处理较长的文章时,这种架构的表现不够理想。为此,有人提出了基于注意力的架构。这种架构依然使用了编码器和解码器,只不过解码器的输入是编码器的状态的加权和,而不再是一个简单的中间状态。每一个输出对每一个输入的权重叫做注意力,注意力的大小取决于输出和输入的相关关系。这种架构优化了编码器和解码器之间的信息交流方式,在处理长文章时更加有效。

尽管注意力模型的表现已经足够优秀,但所有基于RNN的模型都面临着同样一个问题:RNN本轮的输入状态取决于上一轮的输出状态,这使RNN的计算必须串行执行。因此,RNN的训练通常比较缓慢。

在这一背景下,抛弃RNN,只使用注意力机制的Transformer横空出世了。

摘要与引言

补充完了背景知识,文章就读起来比较轻松了。

摘要传递的信息非常简练:

  • 当前最好的架构是基于注意力的”encoder-decoder”架构。这些架构都使用了CNN或RNN。这篇文章提出的Transformer架构仅使用了注意力机制,而无需使用CNN和RNN。

  • 两项机器翻译的实验表明,这种架构不仅精度高,而且训练时间大幅缩短。

摘要并没有解释Transformer的设计动机。让我们在引言中一探究竟。

引言的第一段回顾了RNN架构。以LSTM和GRU为代表的RNN在多项序列任务中取得顶尖的成果。许多研究仍在拓宽循环语言模型和”encoder-decoder”架构的能力边界。

第二段就开始讲RNN的不足了。RNN要维护一个隐状态,该隐状态取决于上一时刻的隐状态。这种内在的串行计算特质阻碍了训练时的并行计算(特别是训练序列较长时,每一个句子占用的存储更多,batch size变小,并行度降低)。有许多研究都在尝试解决这一问题,但是,串行计算的本质是无法改变的。

上一段暗示了Transformer的第一个设计动机:提升训练的并行度。第三段讲了Transformer的另一个设计动机:注意力机制。注意力机制是当时最顶尖的模型中不可或缺的组件。这一机制可以让每对输入输出关联起来,而不用像早期使用一个隐状态传递信息的”encoder-decoder”模型一样,受到序列距离的限制。然而,几乎所有的注意力机制都用在RNN上的。

既然注意力机制能够无视序列的先后顺序,捕捉序列间的关系,为什么不只用这种机制来构造一个适用于并行计算的模型呢?因此,在这篇文章中,作者提出了Transformer架构。这一架构规避了RNN的使用,完全使用注意力机制来捕捉输入输出序列之间的依赖关系。这种架构不仅训练得更快了,表现还更强了。

通过阅读摘要和引言,我们基本理解了Transformer架构的设计动机。作者想克服RNN不能并行的缺点,又想充分利用没有串行限制的注意力机制,于是就提出了一个只有注意力机制的模型。模型训练出来了,结果出乎预料地好,不仅训练速度大幅加快,模型的表现也超过了当时所有其他模型。

接下来,我们可以直接跳到第三章学习Tranformer的结构。

注意力机制

文章在介绍Transformer的架构时,是自顶向下介绍的。但是,一开始我们并不了解Transformer的各个模块,理解整体框架时会有不少的阻碍。因此,我们可以自底向上地来学习Transformer架构。

首先,跳到3.2节,这一节介绍了Transformer里最核心的机制——注意力。在阅读这部分的文字之前,我们先抽象地理解一下注意力机制究竟是在做什么。

注意力计算的一个例子

其实,“注意力”这个名字取得非常不易于理解。这个机制应该叫做“全局信息查询”。做一次“注意力”计算,其实就跟去数据库了做了一次查询一样。假设,我们现在有这样一个以人名为key(键),以年龄为value(值)的数据库:

text
1
2
3
4
5
6
{
张三: 18,
张三: 20,
李四: 22,
张伟: 19
}

现在,我们有一个query(查询),问所有叫“张三”的人的年龄平均值是多少。让我们写程序的话,我们会把字符串“张三”和所有key做比较,找出所有“张三”的value,把这些年龄值相加,取一个平均数。这个平均数是(18+20)/2=19。

但是,很多时候,我们的查询并不是那么明确。比如,我们可能想查询一下所有姓张的人的年龄平均值。这次,我们不是去比较key == 张三,而是比较key[0] == 张。这个平均数应该是(18+20+19)/3=19。

或许,我们的查询会更模糊一点,模糊到无法用简单的判断语句来完成。因此,最通用的方法是,把query和key各建模成一个向量。之后,对query和key之间算一个相似度(比如向量内积),以这个相似度为权重,算value的加权和。这样,不管多么抽象的查询,我们都可以把query, key建模成向量,用向量相似度代替查询的判断语句,用加权和代替直接取值再求平均值。“注意力”,其实指的就是这里的权重。

把这种新方法套入刚刚那个例子里。我们先把所有key建模成向量,可能可以得到这样的一个新数据库:

text
1
2
3
4
5
6
{
[1, 2, 0]: 18, # 张三
[1, 2, 0]: 20, # 张三
[0, 0, 2]: 22, # 李四
[1, 4, 0]: 19 # 张伟
}

假设key[0]==1表示姓张。我们的查询“所有姓张的人的年龄平均值”就可以表示成向量[1, 0, 0]。用这个query和所有key算出的权重是:

1
2
3
4
dot([1, 0, 0], [1, 2, 0]) = 1
dot([1, 0, 0], [1, 2, 0]) = 1
dot([1, 0, 0], [0, 0, 2]) = 0
dot([1, 0, 0], [1, 4, 0]) = 1

之后,我们该用这些权重算平均值了。注意,算平均值时,权重的和应该是1。因此,我们可以用softmax把这些权重归一化一下,再算value的加权和。

text
1
2
softmax([1, 1, 0, 1]) = [1/3, 1/3, 0, 1/3]
dot([1/3, 1/3, 0, 1/3], [18, 20, 22, 19]) = 19

这样,我们就用向量运算代替了判断语句,完成了数据库的全局信息查询。那三个1/3,就是query对每个key的注意力。

Scaled Dot-Product Attention (3.2.1节)

我们刚刚完成的计算差不多就是Transformer里的注意力,这种计算在论文里叫做放缩点乘注意力(Scaled Dot-Product Attention)。它的公式是:

我们先来看看$Q, K, V$在刚刚那个例子里究竟是什么。$K$比较好理解,$K$其实就是key向量的数组,也就是

text
1
K = [[1, 2, 0], [1, 2, 0], [0, 0, 2], [1, 4, 0]] 

同样,$V$就是value向量的数组。而在我们刚刚那个例子里,value都是实数。实数其实也就是可以看成长度为1的向量。因此,那个例子的$V$应该是

text
1
V = [[18], [20], [22], [19]]

在刚刚那个例子里,我们只做了一次查询。因此,准确来说,我们的操作应该写成。

其中,query $q$就是[1, 0, 0]了。

实际上,我们可以一次做多组query。把所有$q$打包成矩阵$Q$,就得到了公式

等等,这个$d_k$是什么意思?$d_k$就是query和key向量的长度。由于query和key要做点乘,这两种向量的长度必须一致。value向量的长度倒是可以不一致,论文里把value向量的长度叫做$d_v$。在我们这个例子里,$d_k=3, d_v=1$。

为什么要用一个和$d_k$成比例的项来放缩$QK^T$呢?这是因为,softmax在绝对值较大的区域梯度较小,梯度下降的速度比较慢。因此,我们要让被softmax的点乘数值尽可能小。而一般在$d_k$较大时,也就是向量较长时,点乘的数值会比较大。除以一个和$d_k$相关的量能够防止点乘的值过大。

刚才也提到,$QK^T$其实是在算query和key的相似度。而算相似度并不只有求点乘这一种方式。另一种常用的注意力函数叫做加性注意力,它用一个单层神经网络来计算两个向量的相似度。相比之下,点乘注意力算起来快一些。出于性能上的考量,论文使用了点乘注意力。

自注意力

自注意力是3.2.3节里提及的内容。我认为,学完注意力的原理后,立刻去学自注意力能够更快地理解注意力机制。当然,论文里并没有对自注意力进行过多的引入,初学者学起来会非常困难。因此,这里我参考《深度学习专项》里的介绍方式,用一个更具体的例子介绍了自注意力。

大致明白了注意力机制其实就是“全局信息查询”,并掌握了注意力的公式后,我们来以Transformer的自注意力为例,进一步理解注意力的意义。

自注意力模块的目的是为每一个输入token生成一个向量表示,该表示不仅能反映token本身的性质,还能反映token在句子里特有的性质。比如翻译“简访问非洲”这句话时,第三个字“问”在中文里有很多个意思,比如询问、慰问等。我们想为它生成一个表示,知道它在句子中的具体意思。而在例句中,“问”字组词组成了“访问”,所以它应该取“询问”这个意思,而不是“慰问”。“询问”就是“问”字在这句话里的表示。

让我们看看自注意力模块具体是怎么生成这种表示的。自注意力模块的输入是3个矩阵$Q, K, V$。准确来说,这些矩阵是向量的数组,也就是每一个token的query, key, value向量构成的数组。自注意力模块会为每一个token输出一个向量表示$A$。$A^{< t >}$是第$t$个token在这句话里的向量表示。

我们先别管token的query, key, value究竟是什么算出来的,后文会对此做解释。

让我们还是以刚刚那个句子“简访问非洲”为例,看一下自注意力是怎么计算的。现在,我们想计算$A^{< 3 >}$。$A^{< 3 >}$表示的是“问”字在句子里的确切含义。为了获取$A^{< 3 >}$,我们可以问这样一个可以用数学表达的问题:“和‘问’字组词的字的词嵌入是什么?”。这个问题就是第三个token的query向量$q^{< 3 >}$。

和“问”字组词的字,很可能是一个动词。恰好,每一个token的key $k^{< t >}$就表示这个token的词性;每一个token的value $v^{< t >}$,就是这个token的嵌入。

这样,我们就可以根据每个字的词性(key),尽量去找动词(和query比较相似的key),求出权重(query和key做点乘再做softmax),对所有value求一个加权平均,就差不多能回答问题$q^{< 3 >}$了。

经计算,$q^{< 3 >}, k^{< 2 >}$可能会比较相关,即这两个向量的内积比较大。因此,最终算出来的$A^{< 3 >}$应该约等于$v^{< 2 >}$,即问题“哪个字和‘问’字组词了?”的答案是第二个字“访”。

这是$A^{< 3 >}$的计算过程。准确来说,$A^{< 3 >}=A(q^{< 3 >}, K, V)$。类似地,$A^{< 1 >}到A^{< 5 >}$都是用这个公式来计算。把所有$A$的计算合起来,把$q$合起来,得到的公式就是注意力的公式。

从上一节中,我们知道了注意力其实就是全局信息查询。而在这一节,我们知道了注意力的一种应用:通过让一句话中的每个单词去向其他单词查询信息,我们能为每一个单词生成一个更有意义的向量表示。

可是,我们还留了一个问题没有解决:每个单词的query, key, value是怎么得来的?这就要看Transformer里的另一种机制了——多头注意力。

多头注意力 (3.2.2节)

在自注意力中,每一个单词的query, key, value应该只和该单词本身有关。因此,这三个向量都应该由单词的词嵌入得到。另外,每个单词的query, key, value不应该是人工指定的,而应该是可学习的。因此,我们可以用可学习的参数来描述从词嵌入到query, key, value的变换过程。综上,自注意力的输入$Q, K, V$应该用下面这个公式计算:

其中,$E$是词嵌入矩阵,也就是每个单词的词嵌入的数组;$W^Q, W^K, W^V$是可学习的参数矩阵。在Transformer中,大部分中间向量的长度都用$d_{model}$表示,词嵌入的长度也是$d_{model}$。因此,设输入的句子长度为$n$,则$E$的形状是$n \times d_{model}$,$W^Q, W^K$的形状是$d_{model} \times d_k$,$W^V$的形状是$d_{model} \times d_v$。

就像卷积层能够用多个卷积核生成多个通道的特征一样,我们也用多组$W^Q, W^K, W^V$生成多组自注意力结果。这样,每个单词的自注意力表示会更丰富一点。这种机制就叫做多头注意力。把多头注意力用在自注意力上的公式为:

Transformer似乎默认所有向量都是行向量,参数矩阵都写成了右乘而不是常见的左乘。

其中,$h$是多头自注意力的“头”数,$W^O$是另一个参数矩阵。多头注意力模块的输入输出向量的长度都是$d_{model}$。因此,$W^O$的形状是$hd_v \times d_{model}$(自注意力的输出长度是$d_v$,有$h$个输出)。在论文中,Transfomer的默认参数配置如下:

  • $d_{model} = 512$
  • $h = 8$
  • $d_k = d_v = d_{model}/h = 64$

实际上,多头注意力机制不仅仅可以用在计算自注意力上。推广一下,如果把多头自注意力的输入$E$拆成三个矩阵$Q, K, V$,则多头注意力的公式为:

Transformer 模型架构

看懂了注意力机制,可以回过头阅读3.1节学习Transformer的整体架构了。

论文里的图1是Transformer的架构图。然而,由于我们没读后面的章节,有一些模块还没有见过。因此,我们这轮阅读的时候可以只关注模型主干,搞懂encoder和decoder之间是怎么组织起来的。

我们现在仅知道多头注意力模块的原理,对模型主干中的三个模块还有疑问:

  1. Add & Norm
  2. Feed Forward
  3. 为什么一个多头注意力前面加了Masked

我们来依次看懂这三个模块。

残差连接(3.1节)

Transformer使用了和ResNet类似的残差连接,即设模块本身的映射为$F(x)$,则模块输出为$Normalization(F(x)+x)$。和ResNet不同,Transformer使用的归一化方法是LayerNorm。

另外要注意的是,残差连接有一个要求:输入$x$和输出$F(x)+x$的维度必须等长。在Transformer中,包括所有词嵌入在内的向量长度都是$d_{model}=512$。

前馈网络

架构图中的前馈网络(Feed Forward)其实就是一个全连接网络。具体来说,这个子网络由两个线性层组成,中间用ReLU作为激活函数。

中间的隐藏层的维度数记作$d_{ff}$。默认$d_{ff}=2048$。

整体架构与掩码多头注意力

现在,我们基本能看懂模型的整体架构了。只有读懂了整个模型的运行原理,我们才能搞懂多头注意力前面的masked是哪来的。

论文第3章开头介绍了模型的运行原理。和多数强力的序列转换模型一样,Transformer使用了encoder-decoder的架构。早期基于RNN的序列转换模型在生成序列时一般会输入前$i$个单词,输出第$i+1$个单词。

而Transformer不同。对于输入序列$(x_1, …, x_s)$,它会被编码器编码成中间表示 $\mathbf{z} = (z_1, …, z_s)$。给定$\mathbf{z}$的前提下,解码器输入$(y_1, …, y_t)$,输出$(y_2, …, y_{t+1})$的预测。

Transformer 默认会并行地输出结果。而在推理时,序列必须得串行生成。直接调用Transformer的并行输出逻辑会产生非常多的冗余运算量。推理的代码实现可以进行优化。

具体来说,输入序列$x$会经过$N=6$个结构相同的层。每层由多个子层组成。第一个子层是多头注意力层,准确来说,是多头自注意力。这一层可以为每一个输入单词提取出更有意义的表示。之后数据会经过前馈网络子层。最终,输出编码结果$\mathbf{z}$。

得到了$\mathbf{z}$后,要用解码器输出结果了。解码器的输入是当前已经生成的序列,该序列会经过一个掩码(masked)多头自注意力子层。我们先不管这个掩码是什么意思,暂且把它当成普通的多头自注意力层。它的作用和编码器中的一样,用于提取出更有意义的表示。

接下来,数据还会经过一个多头注意力层。这个层比较特别,它的K,V来自$\mathbf{z}$,Q来自上一层的输出。为什么会有这样的设计呢?这种设计来自于早期的注意力模型。如下图所示,在早期的注意力模型中,每一个输出单词都会与每一个输入单词求一个注意力,以找到每一个输出单词最相关的某几个输入单词。用注意力公式来表达的话,Q就是输出单词,K, V就是输入单词。

经过第二个多头注意力层后,和编码器一样,数据会经过一个前馈网络。最终,网络并行输出各个时刻的下一个单词。

这种并行计算有一个要注意的地方。在输出第$t+1$个单词时,模型不应该提前知道$t+1$时刻之后的信息。因此,应该只保留$t$时刻之前的信息,遮住后面的输入。这可以通过添加掩码实现。添加掩码的一个不严谨的示例如下表所示:

输入 输出
(y1, y2, y3, y4) y2
(y1, y2, y3, y4) y3
(y1, y2, y3, y4) y4

这就是为什么解码器的多头自注意力层前面有一个masked。在论文中,mask是通过令注意力公式的softmax的输入为$- \infty$来实现的(softmax的输入为$- \infty$,注意力权重就几乎为0,被遮住的输出也几乎全部为0)。每个mask都是一个上三角矩阵。

嵌入层

看完了Transformer的主干结构,再来看看输入输出做了哪些前后处理。

和其他大多数序列转换任务一样,Transformer主干结构的输入输出都是词嵌入序列。词嵌入,其实就是一个把one-hot向量转换成有意义的向量的转换矩阵。在Transformer中,解码器的嵌入层和输出线性层是共享权重的——输出线性层表示的线性变换是嵌入层的逆变换,其目的是把网络输出的嵌入再转换回one-hot向量。如果某任务的输入和输出是同一种语言,那么编码器的嵌入层和解码器的嵌入层也可以共享权重。

论文中写道:“输入输出的嵌入层和softmax前的线性层共享权重”。这个描述不够清楚。如果输入和输出的不是同一种语言,比如输入中文输出英文,那么共享一个词嵌入是没有意义的。

嵌入矩阵的权重乘了一个$\sqrt{d_{model}}$。

由于模型要预测一个单词,输出的线性层后面还有一个常规的softmax操作。

位置编码

现在,Transformer的结构图还剩一个模块没有读——位置编码。无论是RNN还是CNN,都能自然地利用到序列的先后顺序这一信息。然而,Transformer的主干网络并不能利用到序列顺序信息。因此,Transformer使用了一种叫做“位置编码”的机制,对编码器和解码器的嵌入输入做了一些修改,以向模型提供序列顺序信息。

嵌入层的输出是一个向量数组,即词嵌入向量的序列。设数组的位置叫$pos$,向量的某一维叫$i$。我们为每一个向量里的每一个数添加一个实数编码,这种编码方式要满足以下性质:

  1. 对于同一个$pos$不同的$i$,即对于一个词嵌入向量的不同元素,它们的编码要各不相同。
  2. 对于向量的同一个维度处,不同$pos$的编码不同。且$pos$间要满足相对关系,即$f(pos+1) - f(pos) = f(pos) - f(pos - 1)$。

要满足这两种性质的话,我们可以轻松地设计一种编码函数:

即对于每一个位置$i$,用小数点后的3个十进制数位来表示不同的$pos$。$pos$之间也满足相对关系。

但是,这种编码不利于网络的学习。我们更希望所有编码都差不多大小,且都位于0~1之间。为此,Transformer使用了三角函数作为编码函数。这种位置编码(Positional Encoding, PE)的公式如下。

$i$不同,则三角函数的周期不同。同$pos$不同周期的三角函数值不重复。这满足上面的性质1。另外,根据三角函数的和角公式:

$f(pos + k)$ 是 $f(pos)$ 的一个线性函数,即不同的pos之间有相对关系。这满足性质2。

本文作者也尝试了用可学习的函数作为位置编码函数。实验表明,二者的表现相当。作者还是使用了三角函数作为最终的编码函数,这是因为三角函数能够外推到任意长度的输入序列,而可学习的位置编码只能适应训练时的序列长度。

为什么用自注意力

在论文的第四章,作者用自注意力层对比了循环层和卷积层,探讨了自注意力的一些优点。

自注意力层是一种和循环层和卷积层等效的计算单元。它们的目的都是把一个向量序列映射成另一个向量序列,比如说编码器把$x$映射成中间表示$z$。论文比较了三个指标:每一层的计算复杂度、串行操作的复杂度、最大路径长度。

前两个指标很容易懂,第三个指标最大路径长度需要解释一下。最大路径长度表示数据从某个位置传递到另一个位置的最大长度。比如对边长为n的图像做普通卷积操作,卷积核大小3x3,要做$n/3$次卷积才能把信息从左上角的像素传播到右下角的像素。设卷积核边长为$k$,则最大路径长度$O(n/k)$。如果是空洞卷积的话,像素第一次卷积的感受野是3x3,第二次是5x5,第三次是9x9,以此类推,感受野会指数级增长。这种卷积的最大路径长度是$O(log_k(n))$。

我们可以从这三个指标分别探讨自注意力的好处。首先看序列操作的复杂度。如引言所写,循环层最大的问题是不能并行训练,序列计算复杂度是$O(n)$。而自注意力层和卷积一样可以完全并行。

再看每一层的复杂度。设$n$是序列长度,$d$是词嵌入向量长度。其他架构的复杂度有$d^2$,而自注意力是$d$。一般模型的$d$会大于$n$,自注意力的计算复杂度也会低一些。

最后是最大路径长度。注意力本来就是全局查询操作,可以在$O(1)$的时间里完成所有元素间信息的传递。它的信息传递速度远胜卷积层和循环层。

为了降低每层的计算复杂度,可以改进自注意力层的查询方式,让每个元素查询最近的$r$个元素。本文仅提出了这一想法,并没有做相关实验。

实验与结果

本工作测试了“英语-德语”和“英语-法语”两项翻译任务。使用论文的默认模型配置,在8张P100上只需12小时就能把模型训练完。本工作使用了Adam优化器,并对学习率调度有一定的优化。模型有两种正则化方式:1)每个子层后面有Dropout,丢弃概率0.1;2)标签平滑(Label Smoothing)。Transformer在翻译任务上胜过了所有其他模型,且训练时间大幅缩短。

论文同样展示了不同配置下Transformer的消融实验结果。

实验A表明,计算量不变的前提下,需要谨慎地调节$h$和$d_k, d_v$的比例,太大太小都不好。这些实验也说明,多头注意力比单头是要好的。

实验B表明,$d_k$增加可以提升模型性能。作者认为,这说明计算key, value相关性是比较困难的,如果用更精巧的计算方式来代替点乘,可能可以提升性能。

实验C, D表明,大模型是更优的,且dropout是必要的。

如正文所写,实验E探究了可学习的位置编码。可学习的位置编码的效果和三角函数几乎一致。

总结

为了改进RNN不可并行的问题,这篇工作提出了Transformer这一仅由注意力机制构成的模型。Transformer的效果非常出色,不仅训练速度快了,还在两项翻译任务上胜过其他模型。

作者也很期待Transformer在其他任务上的应用。对于序列长度比较大的任务,如图像、音频、视频,可能要使用文中提到的只关注局部的注意力机制。由于序列输出时仍然避免不了串行,作者也在探究如何减少序列输出的串行度。

现在来看,Transformer是近年来最有影响力的深度学习模型之一。它先是在NLP中发扬光大,再逐渐扩散到了CV等领域。文中的一些预测也成为了现实,现在很多论文都在讨论如何在图像中使用注意力,以及如何使用带限制的注意力以降低长序列导致的计算性能问题。

我认为,对于深度学习的初学者,不管是研究什么领域,都应该仔细学习Transformer。在学Transformer之前,最好先了解一下RNN和经典的encoder-decoder架构,再学习注意力模型。有了这些基础,读Transformer论文就会顺利很多。读论文时,最重要的是看懂注意力公式的原理,再看懂自注意力和多头注意力,最后看一看位置编码。其他一些和机器翻译任务相关的设计可以不用那么关注。

总算,我们把《深度学习专项》的最后一门课《序列模型》学完了。这门课主要讨论了和序列数据相关的深度学习方法,介绍了以RNN为代表的经典序列模型和Transformer自注意力模型这两大类模型。同时,这门课还介绍了一些常见自然语言处理问题的基础知识和建模方法。让我们回顾一下RNN、Transformer、NLP这三个方面的知识。

所有知识按技能树的形式组织,学完了上一层的才能学下一层的。部分知识是派生出来的,学会与否不影响后续知识的学习,这些知识的前置条件会特别标出。

RNN

0. RNN 处理的数据种类:序列数据

常见的序列数据有:语音、文字、DNA、视频。

1.1 RNN 的结构

使用这种结构的原因:如果用全连接网络,既无法处理任意长度的句子,又不能共享每一处数据的知识。而 RNN 既能用同样的方法处理每一处数据,又能用一个隐变量表示上下文信息以描述序列关系。

1.2 RNN 的梯度问题

RNN 的梯度可能爆炸也可能消失。梯度爆炸可以通过设置一个梯度最大值来解决。而梯度消失要靠结构改进解决。

下面是 PyTorch 的梯度裁剪函数的使用方法:

1
2
3
4
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()

1.3 多层 RNN

竖着堆叠 RNN。由于 RNN 计算较慢,一般叠 3 层就算很多了。

想要进一步提高网络的拟合能力,可以在输出模块里多加一些非时序的神经网络。

GRU (需学习 RNN)

序列一长,靠前的数据的梯度就容易消失。GRU 解决了这一问题。

GRU 改进了普通的 RNN,每次更新隐变量时会使用一个0~1之间的更新比例,防止之前的信息被过快地忘记。

LSTM (需学习 RNN, GRU)

LSTM 的作用和 GRU 类似,效果更好,但更费时。

双向 RNN (需学习 RNN)

等价于正着跑一遍 RNN,倒着跑一遍 RNN,输出结果由前向和后向的两个隐变量决定。

碰到一个新的序列任务时,双向 LSTM 依然是首选。

2. 序列问题的 RNN 建模方法

RNN 只提供了N对N的序列问题的解决方法。对于其他输入输出格式的问题,需要进行巧妙的建模,才能使用 RNN 来解决问题。

其中,最重要的是不等长多对多数据的建模方法。前半段只输入不输出的部分叫做编码器,后半段只输出不输入的部分叫做解码器。这种“编码器-解码器”架构是一种经典的 RNN 架构。

RNN 可以轻松地完成序列生成任务:输出下一个单词的 softmax 的预测结果,挑选概率较高的一个作为下一次的输入,循环执行。找到总体较好的一个输出的算法叫做 Beam Search。

3. 注意力模型

一种加强版 RNN 架构。它依然是一种“编码器-解码器”架构。编码器是双向 RNN,解码器是单向 RNN。每个输出的 RNN 对每个输入都有一个权重,该权重叫做注意力。每个输出从每个输入接收的数据是一个加权和。

Transformer

Transformer 是一类重要的模型。学习此模型之前最好学完 RNN。

Transformer 从多个角度改进了原序列模型。学习 Transformer 时,主要关注以下几方面的知识:

  1. 注意力和多头注意力
  2. 输出是怎样并行计算的
  3. 位置编码

1. 注意力

注意力的本质是全局信息查询。给出问题 Q,去现有的数据库 (K, V) 里查出一个 V 的加权和,权重由 Q, K 的相似度决定。

2.1 自注意力

自注意力是注意力的一个应用。当 Q, K, V 都是同一个张量时,注意力计算起到是特征提取的作用。相对于 CNN,自注意力更能提取全局信息。

2.2 输出对输入的注意力

注意力机制在早期的注意力模型中是用来计算每个输出对每个输入的权重的。Q 来自输出,K, V 来自输入。

2.3 多头注意力

多头注意力是普通注意力计算的一个加强版。首先,它引入了可学习参数,能够让注意力模块学习要提取哪些特征;其次,它可以像卷积层一样提取多个特征。

3. 并行解码

对于输入序列$(x_1, …, x_s)$,它会被编码器编码成中间表示 $\mathbf{z} = (z_1, …, z_s)$。给定$\mathbf{z}$的前提下,解码器输入$(y_1, …, y_t)$,输出$(y_2, …, y_{t+1})$的预测。

为了模拟解码器的串行执行,其第一个多头注意力有掩码机制,盖住了此时刻之后的数据。

4. 位置编码

由于 Transformer 没有利用到数据的先后顺序这一信息,包含位置信息的位置编码会作为额外输入加到输入的张量上。

NLP 知识

为了更好地介绍 RNN,《深度学习专项》补充了很多 NLP知识。

词汇表

为了表示单词,要实现准备一个包含所有要用到的单词的词汇表。每个单词用一个 one-hot 向量表示。若一个单词在词汇表里的序号是 i,则其 one-hot 向量的第 i 个元素为1。

词嵌入 (需学习 RNN 结构、RNN 建模方法)

one-hot 编码是没有任何含义的。词嵌入是每个单词的一种有意义的表示,向量间的差能够表示语义上的差距。

词嵌入要在某些任务中学习得到。词嵌入的学习方法有:语言模型、Word2Vec、负采样、GloVe。

学习好了词嵌入后,词嵌入应该放在其他任务的神经网络的第一层,作为输入数据的一种预处理方法。

BLEU Score

BLEU Score 是为了评价机器翻译而提出的指标。如果模型输出的翻译和参考译文有越多相同的单词、连续2个相同单词、连续3个相同单词……,则得分越高。

总结

《深度学习专项》的第五门课主要介绍了这三个方面的知识。如果你以后不从事 NLP 的研究,可以略过 NLP 的知识。而 RNN 和 Transformer 的知识是必须要学的。学 RNN 时,主要关注各个问题是怎么用 RNN 建模的;而学 Transformer 时,要更加关注模型的结构和原理。

深度学习的技术日新月异。上完了课后,最好去深入阅读一下经典论文,再去跟进最新的论文,尤其是 Transformer 这种较新的领域。

在看这篇总结时,如果你看到了不懂的地方,欢迎回顾我之前的笔记。

《卷积神经网络》总结

前几周,我们学完了《深度学习专项》第四门课《卷积神经网络》。这门课的知识点太多了,想详细地整理出来并不容易。我打算较为宏观地概括一下这门课的主要内容。

《卷积神经网络》先从细节入手,介绍了卷积神经网络(CNN)的基础构件与运算方式。之后,这门课展示了一些图像分类中的经典网络,通过示例介绍了构筑CNN的一般方法。最后,这门课拓展介绍了CNN在目标检测、图像分割、人类识别、风格迁移等具体任务中的应用。在学习时,我们应该着重关注前两部分的内容,应用部分以了解为主。

卷积层

  • 相比全连接层的优势:参数量变少、平移不变性
  • 卷积的运算方法(灰度图、多通道图)
  • 卷积的参数(填充、步幅)
  • 卷积层的运算方法(如何加上bias并送入激活函数)

CNN

  • 三大构件:卷积层、池化层、全连接层
  • 构建方式:卷积接池化,最后全连接
  • 图像尺寸变小,通道数变大

经典网络(LeNet, AlexNet, VGG)

  • 卷积接池化,最后全连接;图像尺寸变小,通道数变大
  • 平均池化进化成最大池化
  • tanh进化到ReLU
  • 逐渐使用更简单的3x3卷积,2x2池化

ResNet

  • 待解决的问题:网络深度增加而性能退化
  • 解决问题的方法:学习一个靠近全等映射的残差
  • 2层残差块的结构、计算公式
  • 常用的ResNet的结构(ResNet-18, ResNet-50)

一般的网络只要学习其有关知识就行了。而ResNet不同,它已经被使用得太广泛了,必须要认真学习一下它的结构。

Inception

  • 1x1卷积的用法
  • 如果利用1x1卷积降低运算量(bottleneck)
  • Inception模块

MobileNet

  • 逐深度可分卷积
  • MobileNetv2降低运算量的bottleneck结构

CNN实现使用技巧

  • 使用开源代码
  • 利用迁移学习(获取预训练模型,在数据集上微调)
  • 数据增强
  • 打比赛时,用多个模型的平均输出提高精度

目标检测

  • 任务定义:从目标定位到目标检测
  • 从滑动窗口算法到基于卷积的滑动窗口算法
  • YOLO
  • NMS
  • 区域提案(region proposal)

语义分割

  • 任务定义
  • 反卷积
  • U-Net架构

人脸识别

  • 任务定义:人脸验证与人脸识别
  • 单样本学习
  • 孪生网络
  • 三元组误差

神经网络风格迁移

  • CNN的“可视化”
  • 任务定义
  • 内容误差与风格误差

入门基于深度学习的计算机视觉相关研究,一定要把图像分类的经典网络学扎实。这些网络是其他任务的基石,英文用backbone来称呼它们。学会了通用的知识后,再去学习某任务特有的知识。

《序列模型》预览

深度学习的第五门课——也是最后一门课——的标题是《序列模型》。更确切地来说,这门课主要围绕人工智能中另一大领域——自然语言处理(NLP),介绍如何用RNN, Transformer等模型处理以序列形式输入输出的数据。

这门课的主要内容有:

RNN

  • 序列任务的输入输出形式
  • RNN的计算方式
  • GRU
  • LSTM
  • 双向RNN
  • 深层RNN

词嵌入

  • 问题定义
  • Word2Vec
  • 负采样
  • GloVe

RNN的应用

  • 语言模型
  • 机器翻译
  • Beam Search
  • Bleu Score

注意力

  • 注意力模型
  • 自注意力
  • Transformer

不同于CNN,序列模型能够用另一种方式对问题建模,尤其擅长于处理和序列数据相关的问题。如果你的研究方向是CV,也不要错过这一门课的学习。很多序列模型都在CV中得到了应用。比如光流模型RAFT就使用到了GRU,很多主流CV backbone都使用到了transformer。

未来的几周我也会继续分享这方面的笔记。不过,我的研究方向不是NLP,代码项目会稍微潦草一些。我打算只用PyTorch复现课堂上讲过的一些简单项目。

大家或许在课本或故事里接触过“熵”这个概念:“熵表示混乱程度。熵只会越变越多,熵增会让宇宙最终走向灭亡”。“熵”也常常出现在机器学习的概念中,比如分类任务会使用到一种叫做“交叉熵”的公式。

那么,“熵”究竟是什么呢?熵的数学单位和意义并没有生活中的其他事物那么直观。我们可以说一元一元的钱、一吨一吨的铁、一升一升的水,却很难说出一单位的熵表示什么。在这篇文章里,我会从非常易懂的例子中引入这个概念,并介绍和熵相关的交叉熵相对熵(KL散度) 等概念,使大家一次性理解熵的原理。

熵的提出——尽可能节省电报费

假设现在是一个世纪以前,人们用电报来传输信息。为了刊登天气预报,报社每天会从气象中心接收电报,获知第二天的天气。这样的电报应该怎么写呢?

我们先假设明天的天气只有“好”和“坏”两种情况。好天气,就是晴天或多云;坏天气,就是刮风或下雨。这样,电报就可以写成“明天天气很好”或者“明天天气不好”。

这种电报的描述确实十分清楚。但是,电报是要收费的——电报里的字越多,花费的钱越多。为了节约成本,我们可以只在电报里写一个字。天气好,就发一个“好”;天气差,就发一个“坏”。由于报社和气象中心已经商量好了,这一条电报线只用来发送明天的天气这一种信息,所以“好”和“坏”只是指代明天的天气,不会有任何歧义。通过这种方式,发电报的成本大大降低了。

但是,这种方式还不是最省钱的。通讯其实使用的是二进制编码。比如如果用两个二进制位,可以表示00, 01, 10, 11这$2^2$种不同的信息。而汉字有上万个,为了区分每一个汉字,一般会使用16个二进制位,以表示$2^{16}=65536$个不同的汉字。要传输一个汉字,就要传输16个0或1的数字。而要表示明天的天气,最节省的情况下,只要一个二进制位就好了——0表示天气不好,1表示天气很好。

这样,我们得出了结论:最节省的情况下,用1位二进制数表示明天的天气就行了。1位二进制数有单位,叫做比特(bit)。因此,我们还可以说:明天天气的熵是1比特。没错,的意思就是最少花几比特的二进制数去传递一种信息。发明出“熵”的人,很可能正在思考怎么用更短的编码去节省一点电报费。

熵与平均编码长度

明天的天气只有两种可能:好、坏。这一情况太简单了,以至于我们可以脱口而出:这个问题的熵是1。

可当问题更复杂、更贴近实际时,熵的计算就没那么简单了。明天的天气,可能有4种情况:

这个问题的熵是多少呢?

刚刚我们也看到了,1个二进制数表示2种情况,2个二进制数表示4种情况。现在有4种情况,只要两位编码就行了:

因此,这个问题的熵是2。

但有人可能对这种编码方式感到不满:“这种编码太乱了,每个二进制位都没有意义。我有一种更好的编码方式。”

确实如此。要表示四种情况,不是非得用2位的编码,而可以用1-3位的编码分别表示不同的信息。可是,这两种方案哪个更节省一些呢?我们不能轻易做出判断。假如大部分时候都是晴天,那么用第二种方案的话很多时候只需要1位编码就行了;而假如大部分时候都是下雨或下雪,则用第一种方案会更好一些。为了判断哪种方案更好,我们还缺了一个重要的信息——每种天气的出现概率。

假如我们知道了每种天气的出现概率,就可以算出某种编码的平均编码长度,进而选择一种更优的编码。让我们看一个例子。假设四种天气的出现概率是80%/10%/5%/5%,则两种编码的平均长度为:

用每种天气的出现概率,乘上每种天气的编码的长度,求和,就可以算出该编码下表示一种天气的平均长度。从结果来看,第二种编码更好一些。事实上,第二种编码是这种概率分布下最短的编码。

只有四种天气,我们还可以通过尝试与猜测,找出最好的编码。可是,如果天气再多一点,编码方式就更复杂了。想找出最好的编码就很困难了。有没有一种能够快速知道最优平均编码长度的方式呢?

让我们从简单的例子出发,一点一点找出规律。假如四种天气的出现概率都是25%,那问题就很简单了,直接令每种天气的编码都是2位就行了。由于每种天气的出现概率都相等,让哪种天气的编码长一点短一点都是不合理的。类似地,假如有八种天气,每种天气的概率都是12.5%,那么应该让每种天气都用3位的编码。

这里的“2位”、“3位”是怎么算出来的呢?很简单,对总的天气数取对数即可。有4种天气,就是$log_24=2$;有8种天气,就是$log_28=3$。

现在,让问题再稍微复杂一点。假如还是晴天、刮风、下雨、下雪这四种天气,每种天气的概率都是25%。现在,报社觉得区分下雨和下雪太麻烦了,想把下雨或下雪当成一种出现概率为50%的天气来看待。这种情况下的最优编码是什么呢?

原来下雨的编码是10,下雪的编码是11。既然它们合并了,把编码也合并一下就行。10和11,共同点是第一位都是1。因此,合并后的编码就是1。这样,我们可以得到新问题下的编码方案。刚刚的四种天气均分的编码方案是最优的,那么这种把两种编码合并后的方案也是最优的。

晴天和刮风的编码长度是2。这个很好理解。25%是4种天气平均分的概率,因此编码长度是$log_24=2$。下雨/下雪的编码长度是1,这个又是怎么得到的呢?50%,可以认为是2种天气平均分的概率,这时的编码长度是$log_22=1$。

由于一般我们只知道每种天气的概率,而不太好算出每种天气被平均分成了几份。因此,我们可以把上面两个对数运算用概率来表达。

更一般地,假设某种天气的出现概率是$P$。我们可以根据规律猜测,当这种天气的编码长度是$-log_2P$时,整个编码方案是最优的。事实上,这个猜测是正确的。

我们来算一下最优编码方案下的平均编码长度,看看每次接收天气预报的电报平均下来要花几个比特。假设第$i$种天气的出现概率是$P(i)$,那么最优平均编码长度就是:

这就是我们经常见到的熵的计算公式了。没错,上一节我们对于熵的定义并不是那么准确。熵其实表示的是理想情况下,某个信息系统的最短平均编码长度

在这个公式里,由于被累加的情况是离散的,我们使用了求和符号。对于连续的情况,要把求和变成求积分。

这个定义有几个要注意的地方。首先,熵是针对某个信息系统的平均情况而言的。这说明,用熵来描述的信息必须要能够分成许多种,就像很多种天气一样。同时,每种具体的信息都得有一个出现概率。我们不是希望让某一次传输信息的编码长度最短,而是希望在大量实验的情况下,令平均编码长度最短。

另外,为什么要说“理想情况下”呢?试想一下,假如有6种天气,每种天气的出现概率相等。这样,每种天气都应该用$log_26$位编码来表示。可是,$log_26$并不是一个整数。实际情况中,我们只能用2~3位编码来表示6种天气。由于编码长度必须是整数,这种最优的编码长度只是说理论上成立,可能实际上并不能实现。

求解最优整数编码的算法叫做“哈夫曼编码”,这是一个经典的算法题。感兴趣的话可以查阅有关资料。

推而广之,熵除了指编码长度这种比较具体的事物以外,还可以表示一些其他的量。这个时候,“编码长度”就不一定是一个整数了,算出来的熵也就更加有实际意义。因此,与其用“无序度”、“信息量”这些笼统的词汇来概括熵,我们可以用一些更具体的话来描述熵:

对于一个信息系统,如果它的熵很低,就说明奇奇怪怪的信息较少,用少量词汇就可以概括;如果它的熵很高,就说它包括了各式各样的信息,需要用更精确的词汇来表达。

最后,再对熵的公式做一个补充。一般情况下,算熵时,对数的底不是2,而是自然常数e。由换底公式

可知,对数用不同的底只是差了一个常数系数而已,使用什么数为底并不影响其相对大小。在使用熵时,我们也只关心多个熵的相对大小,而不怎么会关注绝对数值。

以2为底时,熵的单位是比特。以e为底时,熵的单位是奈特(nat)。

用交叉熵算其他方案的平均编码长度

理解了熵的概念后,像交叉熵这种衍生概念就非常好理解了。在学习所有和熵有关的概念时,都可以把问题转换成“怎么用最节省的编码方式来描述天气”。

假设电报员正在和气象中心商量天气的编码方式。可是,这个电报员刚来这个城市不久,不知道这里是晴天比较多,还是下雨比较多。于是,对于晴天、刮风、下雨、下雪这四种天气,他采用了最朴素的平均法,让每种天气的编码都占2位。

大概100天后,电报员统计出了每种天气的出现频率。他猛然发现,这个城市大部分时间都在下雨。如果让下雨的编码只占1位,会节省得多。于是,他连忙修改了编码的方式。

这时,他开始后悔了:“要是早点用新的编码就好了。两种方案的平均编码长度差了多少呢?”假设各种天气的出现概率如下,我们可以计算出新旧方案的平均编码长度。

就像计算熵一样,我们来用公式表示一下这个不那么好的平均编码长度。之前,电报员为什么会都用2位来表示每一种天气?这是因为,他估计每种天气的出现概率都是25%。也就是说,在算某一个实际概率分布为$P$的信息系统时,我们或许用了概率分布$Q$去估计$P$。在刚刚那个问题里,$Q$就表示所有天气的出现概率相等,$P$就是雨天较多的那个实际概率分布。这样,较差的平均编码长度计算如下:

这是不是和熵的公式

很像?没错,这就是交叉熵的公式。交叉熵表示当你不知道某个系统的概率分布时,用一个估计的概率分布去编码得到的平均编码长度。$-log_2Q(i)$表示的是每一条信息的编码长度,由于编码长度是由我们自己决定的,只能用估计的分布来算,所以它里面用的是$Q$这个分布。而算期望时,得用真实的概率分布$P$。因此,这个式子外面乘上的是$P(i)$。

交叉熵有一个很重要的性质:交叉熵一定不大于熵。熵是最优的编码长度,你估计出来的编码方案,一定不会比最优的更好。所有交叉熵的应用基本上都是利用了这一性质。

在机器学习中,我们会为分类任务使用交叉熵作为损失函数。这正是利用了交叉熵不比熵更大这一性质。让我们以猫狗分类为例看看这是怎么回事。

在猫狗分类中,我们会给每张图片标一个one-hot编码。如果图片里是猫,编码就是[1, 0];如果是狗,编码就是[0, 1]。one-hot编码,其实就是一个概率分布,只不过某种事件的出现概率是100%而已。可以轻松地算出,one-hot编码的熵是0。

机器学习模型做分类任务,其实就是在估计的one-hot编码表示的概率分布。模型输出的概率分布就是交叉熵公式里的$Q$,实际的概率分布,也就是one-hot编码,就是交叉熵公式里的$P$。由于熵是0,交叉熵的值一定大于等于0。因此,交叉熵的值可以表示它和熵——最优的概率分布的信息量——之间的差距。

用KL散度算方案亏了多少

用交叉熵算出旧方案的平均编码长度后,电报员打算统计一下旧编码浪费了多少编码量。既然熵是最优编码的编码长度,那么交叉熵减去熵就能反映出旧编码平均浪费了多少编码长度。这个计算过程如下:

可以总结出,如果我们还是用分布$Q$去估计分布的$P$的话,则这其中损失的编码量可以用下面的公式直接计算。

这个公式描述的量叫做相对熵,又叫做KL散度。KL散度的定义非常易懂,它只不过是交叉熵和熵的差而已,反映了一个分布与另一个分布的差异程度。最理想情况下,$P=Q$,则KL散度为0。

当然,KL散度不是一个距离指标。从公式中能够看出,$D_{KL}(P||Q)\neq D_{KL}(Q||P)$,这个指标并不满足交换律。

KL散度常用来描述正态分布之间的差异。比如VAE(变分自编码器)中,就用了KL散度来比较估计的分布和标准正态分布。计算正态分布间的KL散度时,我们不用从头推导,可以直接去套一个公式。

设一维正态分布$P, Q$的公式如下:

则KL散度为:

在看计算机方向的论文时,如果碰到了KL散度,我们不需要深究其背后的数学公式,只需要理解KL散度的原理,知道它是用来做什么的即可。

总结

在各个学科中,都能见到“熵”的身影。其实,大部分和熵有关的量都是在最基本的熵公式之上的拓展。只要理解了熵最核心的意义,理解其他和熵相关的量会非常轻松。希望大家能够通过这篇文章里的例子,直观地理解熵的意义。

参考资料

我的这篇文章基本照抄了三篇介绍熵的文章。这三篇文章写得非常易懂,强烈推荐阅读。

Entropy Demystified

Cross-Entropy Demystified

KL Divergence Demystified

有关KL散度的推导可以参见这篇博文:

KL散度(Kullback-Leibler Divergence)介绍及详细公式推导

我在今年6月15日开通了美股账户。而到了9月21日,我的总收益率达到了100.27%。

第一个月的时候,我还是以投资为主,小赚。第二个月,我开始做短线投机,第一周的亏损超过了30%。但当我冷静下来,“开启认真模式”之后,我在整整两个月的时间里获得了超过150%的收益。

看到这个收益率,不同的人可能会有不同的评论。

大部分人会说:银行定期存款的年化收益也就3-5%,你的收益率这么高,太厉害了。

有些对金融新闻比较感兴趣的人会说:你这很一般啊。我见过炒期货的人,几万做到了几千万啊。还有炒币的,几天就翻几十倍。你这也就是普通水平。

金融知识扎实的人会说:你的收益曲线看上去很好看,但是你的回撤太大,收益不稳定。

具有怀疑精神的人会说:你这个从来没有学过金融的人,刚入市没多久,凭什么能赚这么多钱?是不是在P图?

不管别人怎么说,我来客观评价一下自己的战绩:我的收益率已经足够可观了。如果这个收益率真的能保持下去,几年后我就是世界首富了。当然,我现在使用了低杠杆,风险适中,回撤偶尔会比较大。如果我真的更激进一点,像炒币的那些人一样追求短期翻几十倍,那么也很容易在一天内资金归零。没有金融知识,没学多久就开始稳定赚钱,是因为在炒股经验的积累上,我一天抵别人一百天。为什么这么说呢?因为我天赋异禀。

不管怎么说,我认为我已经掌握了一些投机交易的技巧。在赚到一些钱后,我才发现,市面上大多数介绍投资、交易的文章都是在胡说,甚至是别有用心地在损人利己。我很看不起这些人。在这篇文章里,我想分享一下自己对于投机交易本质的思考,而不是信誓旦旦地说“我这样做一定能赚钱”、“你要跟着我这样做,你要买我的产品”、“价值投资没用/短线投机没用”。

当然,我写这篇文章最主要的目的还是炫耀一下自己的成就。有了成就不去炫耀,那可太亏了,哈哈哈。

投机——期望为正的赌博

在正式介绍交易赚钱的原理之前,我先规范地描述一下交易的游戏规则。为了方便描述,所有的交易产品都将以股票为例。当然,期货、外汇、加密货币等其他金融衍生品的原理是一模一样的。

投机,即利用股票不同时期的差价来赚钱。既可以买入股票,在股价上涨后卖出获利,也可以向他人借取股票,提前卖出,等股价下跌后买回更便宜的股票获利。前者称为“做多”,后者称为“做空”。也就是说,不管股票是涨是跌,只要有价格波动,就有赚钱的空间。

很多炒过股的人会说,炒股就是赌博。在我看来,这句话说不对也不对,说对也对。

有人把炒股称为赌博,是因为他们觉得炒股虽然有几率赚大钱,但是这个几率很低。长期来看,收益的期望一定是负数。这些特征与赌博相符。这个看法肯定不是完全正确的。相比胜负完全随机的赌博,股票的价格变化显然有一定的规律。

说炒股是赌博,其实是从游戏规则的角度来看的。赌博是一局一局进行的,有明确的开始与结束。而股票交易不同,买入股票后,你可以一直持有。只要你不卖出,不管股票跌了多少,都是浮亏,你都可以说自己没有真正亏钱。当然,抱有这种心态的话,是很难挣钱的。人们反倒更希望把股票交易转换成一局一局的赌博游戏。这样,就可以用数学工具分析每局赌博的收益期望,实现稳定的盈利。

这样来想的话,我们可以把投机转换成以下这种可以重复进行的赌博游戏:根据某些利多或者利空信号,在某一时刻开始对股票做多或者做空,满足结束条件后立刻平仓离场,结算盈亏。这个定义有两大重点:

  1. 信号,指的是你发现股价大概率会往某个方向走;或者股价一旦往某个方向走,会出现剧烈的变化。信号的存在,保证收益的期望是正数。
  2. 结束条件的存在,意味着交易可以被分成相互独立的一局一局赌博。

再具体来看一下结束条件的概念。最常见的结束条件是时限。比如说,昨天股市大跌。根据这一信号,我猜测今天股市也要跌下去。所以,我在今天开盘的时候做空,接近收盘的时候平仓。

当然,只有时限是不太充分的。可能这一天确实会出现下跌,但尾盘的时候价格又抬了上去。所以,我可以多加一些结束条件:如果这个股票今天跌了2%,我就平仓止盈。这样,不管后面再发生什么变化,我都稳稳地赚到了钱。

这样做还是有一点不充分:万一今天股市大涨了怎么办?为了避免亏损,我可以再设置一个结束条件:如果这个股票涨了2%,我就承认我猜错了,立刻止损。

这样下来,这局交易就可以被描述成这样的赌博游戏:今天开盘时做空某只股票,在下跌2%或者上涨2%的时候平仓,或者接近收盘的时候平仓。如果我们的信号是正确的,从统计上来看股票在这种情况下大概率会下跌,那么我们持续地做这种交易,一定可以赚到钱。

很多人在“投资”的时候——不管是持有股票还是基金——都会亏钱,大概率是因为没有建立这种“赌博”的意识。他们不知道为什么要买这个股票,也不知道什么时候该离场了。他们买入股票的信号,仅仅是因为别人的建议:“我看好这个股票”、“我这样做挣钱了”、“长期投资总没错”。但是,别人不会告诉你他这么做的原因。如果不明确自己为何而建仓,为何而平仓,那么你的收益只能由市场被动地决定,而无法用自己的主观判断提升收益。对于有主观判断的人来说,所有的亏损都是自己的信号看错了,而市场永远不会有错。

投机的时效性与周期

上一节讲到了投机的基本模型。为了在这个模型下尽可能盈利,有一个概念是必须要了解的——信号的时效性。

我们在上一节定义过一局交易,它有三个结束条件:

  1. 当天之内必须平仓。
  2. 赚2%止盈。
  3. 亏2%止损。

然而,这些条件都不是绝对正确的。万一今天没赚多少,明天的走势却能让你赚大钱呢?万一今天股价往你的方向变动了不止2%,而是变动了10%呢?万一今天股市先让你亏了2%,后来又能让你赚2%呢?

因此,所有结束条件都不是绝对正确的,都有一定概率会降低你的收益。我认为,只有唯一一个结束条件是绝对正确的:

当你觉得自己的信号失效后,结束交易。

仔细想一下,你为什么会做这一轮交易?不就是你找到了股价会往某个方向运动的原因吗?在这个原因消失前,不管是赚是亏,你都不应该平仓;在这个原因消失时,不管是赚是亏,你都应该立刻平仓。

所有常见的结束条件,都只是这一原则的延申。比如说止损,止损的本质原因是股价的运动和你期望的大相径庭,你认为自己的信号看错了,所以止损;再比如止盈,止盈是因为按照你的信号,股价就应该变动这么多,之后是不是会继续变动,你判断不了。

从这个角度来考虑,做投机的人不应该关注自己是赚钱还是亏钱,而应该只关注自己得出来的信号。一方面,要提高信号的准确度;另一方面,要时刻监控自己的信号,在发现信号失效的瞬间停止交易。做到前者并不难,对股价变动稍有观察的人都能总结出一些信号。难的是后者,如何公正而勇敢地宣布自己的信号失效。

因此,在投机时,最重要的判断信号何时结束。说是要“监控”信号,其实并不是说真的去监控,去时时刻刻看着股价的变化。事实上,每个信号都有时效性。在一轮交易开始前,你是可以大致判断出这个信号的有效期的。在有效期尚未结束时,不用过多地关注股价变动。我们来看几个例子。

这一天,纳斯达克指数一直在下跌,已经持续几个小时了。中途股价虽然有几波反弹,但依然没有改变下跌的趋势。突然,股价猛地反弹,下跌趋势也稳住了。

这个时候,可以猜测股价不会再跌下去,而是会有所反弹。如果股价再继续跌下去,就说明猜错了,信号失效;如果股价有所反弹,就说明猜对了,赚到了钱,信号也失效了。

这轮交易的逻辑非常简单:股价不可能一直跌下去,总会发生一点变动。判断信号失效的方法也很简单,只要价格变动过大,就能说明判断是对还是错,信号也随之失效了。

这一轮,交易的原因是价格在短期内不会一直往一个方向变动。这个“短期”,指的是几个小时。那么,价格会出现反弹的时间,也差不多就在一两个小时以内。过了这段时间,这个信号一定就失效了。这是一个时效性较短的信号。

再来看一个时效性中等的信号。今年,由于美国改变了货币政策,美股一直在下跌。如果你交易的依据是货币政策会压低股价,那么你在这段时间里随时都可以去做空纳斯达克指数。只要美国的货币政策不变,不管中途出现了几波反弹,不管是赚是亏,都不应该结束交易。

最后,从长期来看,美股是上涨的。如果你觉得美股具有投资价值,最终一定会涨起来,那么你随时都可以去做多纳斯达克指数。只要美国的经济地位不变,你就可以放心地把钱投资进去。

从这些例子可以看出,在同一个时间点,你不管是做多美股还是做空美股,都可以是有理由的。做多和做空,赚钱和亏钱,其实都没有对错。有对错的,只有自己的信号。问题的关键,在于如何选择正确率更高的信号,以及注意信号的时效性。

上面三个例子其实也对应了常见的三种交易周期,它们所需的信号各不相同。

在周期为几分钟、几小时的短线交易中,股票的基本价值是不会发生变化的。这时,股价总会发生有规律的变动。所有信号都是由过往的价格变化趋势产生的。

在周期为几天、几周的中线交易中,股票的基本价值会随着新闻、经济政策而发生改变,只看过往的价格变化是不够的。这时,可以多关注新闻、经济评论,捕捉投资者的情绪,并结合近期的价格变化做出预测。

而在周期更长的长线交易中,任何短期的波动都显得微不足道了。这个时候,公司的本身价值才能决定股票的长期走势。买入某公司股票的信号,可能仅仅是你在某一天发现了这个股票很有投资价值而已,而与其近期走势毫无关系。

总结一下,每轮交易的信号是有时效性的。信号的依据不同,其有效周期也不同。仅在信号失效时结束交易。

散户战胜富豪的秘诀:高流动性、短周期

知道了交易的信号存在时效性还不够,如何选择周期恰当的信号依然是一个难题。在介绍如何选择与发现信号之前,我先来讲另一件事——为什么很多散户的收益率能超过巴菲特?

秉承价格投资理念的人,会对短期内获取暴利的技术分析者大加批判:“巴菲特的年化收益才是20%,你凭什么能赚那么多钱?你这些靠技术分析得来的钱迟早要亏掉的。”

这一判断在逻辑上是有问题的,因为它暗含了一个错误的前提:散户和巴菲特只有交易水平上的差距,其他条件一概相同。

的确,个人交易者与专业的投资机构在实力上有着不可逾越的鸿沟。在机构里,有经验丰富的经理,有无数金融高材生提供市场分析,有无数数学系和计算机系的博士提供数学模型。你一个人,凭什么比他们这个团队还要厉害?

但是,仅从短期收益率这个角度来看,确实有散户能战胜专业机构。根据田忌赛马的原理,整体实力落后的人获胜,说明实力落后的人在某些方面超过了实力较强的人。而在投机中,散户相较机构有着一个巨大的优势:资金量较少。这一个优势就足以覆盖其他所有的劣势。

资金量大起来之后,很多短线的交易方式就失效了。比如你觉得这个股票最近跌了很多,明天很可能会反弹。于是,你用你的大资金去买这个股票。结果,恭喜你,股价确实反弹了一阵子。只不过,股价是被你的买入带起来的。你的持有成本特别高,根本没有买在低位。过了一会儿,股价又跌回去了。

因为这些原因,大资金只能去做长线交易。他们无法应对短期内的价格波动,甚至难以应对很明确的会令股价下跌的经济政策。比长期投资,个人是绝对比不过机构的;但是,短期内的价格波动,正是散户收割富豪财富的良机。

回到之前那个问题,如何选择周期恰当的交易。我的回答很明确:避开长期交易,利用流动性,做周期较短的交易。长线交易是用来让大资金稳定获利的,它的难度更高,且收益率较低,市场不好的时候不可避免地要面对收益回撤。

当然,周期特别短也是不行的。一分钟以内基本是量化交易的天下。许多毫秒级的信号是十分明确的,这时要比拼的是反应速度。显然,人是比不过低延迟的电脑命令的。

我认为,一分钟到两周的周期都是可行的,哪怕缺乏金融知识的人也能通过不断学习在这些周期的交易中获利。注意,越是短期的交易,越依赖短期的价格信号,也越容易碰到毫无理由的价格波动。超短线潜在的利润多,交易操作难度也大。很多人为了放大超短线的收益,会加大杠杆。但正是超短线交易中不可避免的价格波动,会让人瞬间坠入深渊。

对于初学者,我建议去做一日到一周的交易。这种交易周期参考的信号既包括短期的价格趋势,又包含新闻、政策。当所有信号都指向同一个方向时,就可以大胆出手交易了,基本就可以稳定地赚到一小笔钱。

来看一个例子。9月13号这一天,美股已经陷入熊市大半年了,三周前又一次进入了下跌趋势,前三个交易日超涨了,盘前出现了大利空新闻。在所有这些信号的加持下,根本找不出股市不大跌的理由。只要在开盘时做空纳斯达克指数,尾盘时平仓,就能稳定赚一笔钱。

到目前为止,我已经揭示了没有金融基础的散户在市场投机赚钱的本质方法。其流程总结如下:

原理:把投机交易当成一局一局赌博。根据成功率高的信号入场,在信号失效时立场。尽可能去选择周期较短而稳定的信号。

操作方法:1) 观察股票价格的变化,总结规律,尝试根据过往价格变化和新闻预测未来的价格变化,得出自己能把握的信号。2)严格执行交易纪律,仅在信号失效时离场(包括设置止损止盈降低风险)。不优化收益率,只优化信号的准确性。

赚钱的秘诀,其实就这么简单,几句话就可以概括了。很多文章会讲一些更具体的操作策略,比如看到什么指标超过什么值就买入之类的。这些策略肯定有用,但它们其实属于信号的一种,不能保证一定赚钱。归根结底,学具体的策略,不如学会最本质的思考方法。如果搞懂了投机的原理,你就能自己不断地发现信号,不断优化自己的策略,而不仅仅是人云亦云,把成败归咎于外界。

新手入市指南

讲完了原理,我来具体讲一下新手应该如何从零开始学习交易。

目标

不是每一个人都适合通过交易赚钱。我不建议大家以立刻赚到一笔钱为交易的目标,而是以学习,以拓宽视野为目标。等过了一两个月,你可以去审视一下自己,看看自己是不是适合交易。如果你交易时患得患失,倍感压力,那就不用做下去了。如果你不管赚了还是亏了都很开心,你感觉自己总能学到东西,你更在乎判断的准确性而不在乎收益,那就可以做下去。

资金

建议把两到三个月的工资投入交易。这笔钱必须是生活用不到的闲钱。只要你把钱放进去了,就当作是花光了这笔钱,哪怕亏完了也不要有压力。

选择两三个月的工资是有原因的。如果亏了钱,你可以说:“反正过一个月就赚回来了”;如果赚了钱,你可以说:“我赚到的钱能丰富一下月收入了。”人改变不了随机事件的结果,但能够改变自己对事件结果的看法。如果能永远保持乐观的心态,不管你怎么亏钱,你都是不亏的。

选择交易品种

建议选择一个T+0交易、交易量大、能够做多做空的品种。比如期货、外汇、美股指数。

T+0和做多做空是交易的标准配置。没了它们,操作空间会小很多。

我其实不太懂股票交易,也不懂国内A股的规律。A股不能做空,操作空间太小,我不太愿意去学习。我在A股亏掉的钱估计一辈子也赚不回来了。

交易量大,其实意味着交易品不容易受到小部分人的操控,价格更容易按照规律运动。

我做的是纳斯达克指数,我认为它是最简单的交易品了。首先,纳斯达克指数是世界上交易最活跃的品种之一,难以受到少数人的干扰。其次,指数比股票更容易掌控,因为股票不仅受股市整体影响,还要受股票自身影响,要考虑的因素更多。而纳斯达克指数只需要关注美国经济新闻即可,其他的品种除了要关注其本身的新闻外,也需要关注美国经济新闻。最后,很多专业的经济学家会对美国股市的走势做出预测,你哪怕什么都不懂,跟着多数经济学家预测的去做就行了。

另外,学习使用低倍杠杆是有必要的。比如美股允许券商提供至多2倍杠杆,这个幅度绝对不会让你瞬间爆仓,还能有效放大收益。

可以在网上多搜索一点信息,以确认交易品种。开户的流程也可以在网上搜到。

学习与交易

开户后,就可以交易了。按照前文的描述,学会观测信号,正确地执行交易即可。一开始最好是做模拟交易,或者自己看几天盘,尝试预测价格变化。当然,做模拟盘和做实盘还是有区别的。不是实实在在的赚钱和亏钱,你的操作方法肯定会更乱来。

交易结束后,一定要复盘。复盘主要是针对亏损的交易,分析错误的原因,要知道错误是信号看错了,还是交易执行得不好。交易中,有非常多要注意的细节。如果你的复盘是有效的,你每犯一次错,就能学到一项细节,从而不会再次犯错。

在交易中,你的资产会不断地发生变化。而资产是和生活挂钩的,所以资产的变动会对你的心态产生很大的影响。心态好,并不能帮你挣钱,而只能保证你按照计划赚到该赚的钱;而心态不好,则会让你亏掉超出计划外的钱。交易不能像其他事一样马马虎虎,要完全地用理性的眼光去看待。任何不理性的想法都会招致亏损。学习交易时,学习控制自己的心态也是很重要的。

结语

在这篇文章中,我分享了自己在这个几个月交易中领悟到的精华思想。我相信,根据这篇文章的指引,用正确的态度去接触交易,一定能够从中有所收获。金钱上的收获倒另说,从中学到的道理是能够受用终生的。希望大家能够开辟出除了加班工作外另一种人生的可能性。

谨记,赚钱绝对不是唯一的目的。如果想着暴富,想着要定目标赚多少钱,大亏后非得立刻赚回来,那肯定赚不到钱。如果把投机当成电子游戏,每次去最优化自己的操作,去学习,去享受快乐,而不只是在乎金钱,那反而能赚到钱。这些话看上去很矛盾,却富有哲理。如果你某一天凭借着自己的经历得出了和我一样的结论,你就知道我说的都是对的了。

我留了不少复盘记录。之后我会不定期发布自己做每笔交易的心路历程,详细描述我是怎样一步一步学习的。

如果你觉得这篇文章讲得很有道理,欢迎转发,把好的想法传播给更多的人。

在最后一课中,我们要学习Transformer模型。Transformer是深度学习发展史上的一次重大突破,它在多个领域中取得了傲人的成绩。Transformer最早用于解决NLP任务,它在CV任务上的潜力也在近几年里被挖掘出来。

RNN有一个致命的缺陷:计算必须按照时序执行,无法并行。为了改进这一点,Transformer借用了CNN的想法,并行地用注意力机制处理所有输入,抛弃了经典RNN的组件。

为了理解Transformer,我们将主要学习两个概念:自注意力和多头注意力。

  • 自注意力:Transformer会为序列里的每一个元素用注意力生成一个新的表示,就和CNN里卷积层能为每个像素生成高维特征向量一样。这个表示和词嵌入不同,词嵌入只能表示一个单词本身的意义,而「自注意力」生成的表示是和句子里其他单词相关的。
  • 多头注意力:「多头注意力」表示多次利用自注意力机制,生成多个表示,就和CNN里N个卷积核能生成N个特征一样。

这节课是《深度学习专项》新增的课程,内容比较简短。估计是因为Transformer太火了,不得不在教材里加上这些新内容。这节课讲得并不是很清楚,我会用更易懂的逻辑把这节课讲一遍。

Transformer

自注意力

词嵌入只能反映一个词本身的意思,而不能反映一个词在句子中的意思。比如我们要把”简访问非洲“翻译成英文,其中第三个字“问”有很多意思,比如询问、慰问等。自注意力的目的就是为每个词生成一个新的表示$A$,反映它在句子中的意思,比如我们为“问”字生成的表示$A^{< 3 >}$是“访问”这个具体的意思。

自注意力,顾名思义,就是对句子自己使用注意力机制。我们先回忆一下最初的注意力机制。

在翻译句子时,我们要算每个输出单词对每个输入单词的注意力$\alpha$,以这个注意力为权重,我们可以算所有输入状态$a$的加权平均数$c$(公式1)。这个$c$用于输出每一个单词。

注意力$\alpha$是权重$e$的归一化结果(如公式2所示,因为是求平均数,得保证权重$\alpha$的和为1),这里的$e^{< t, t’ >}$表示第$t’$个输入和第$t$个输出的相关量。由于$e^{< t, t’ >}$是用于计算$s^{< t >}$的,$s^{< t >}$还获取不了,我们只能用$s^{< t - 1 >}$来表示第$t$个输出。另外,我们用$a^{< t’ >}$表示第$t’$个输入。这样,$e^{< t, t’ >}$就应该由$s^{< t - 1 >}$和$a^{< t’ >}$决定。

Transformer用一种通用的公式表示了这种注意力的计算。

我们先不管这里的$q, K, V$是什么意思,暂且把它们当成标记。用它来表示最初的注意力机制的话,$q$就相当于输出状态$s$,$k$就相当于输入状态$a$,二者的相关关系$e$就是两个向量的内积$qk$。$v$也是输入状态$a$。

现在,我们来看看如何用这个公式计算自注意力。我们将以第三个输入$x ^ {< 3 >}$的注意力表示$A^{< 3 >}$为例。

假设我们为每个输入单词都已经维护好了3个变量$q^{< t >}, k^{< t >}, v^{< t >}$。q, k, v是英文query, key, value的缩写,这一概念来自于数据库。假如数据库里存了学生的年龄,第一条记录的key-value是("张三", 18),第二条记录的key-value是("李四", 19)。现在,有一条query,询问"张三"的年龄。我们把这一条query和所有key比对,发现第一条记录是我们需要的。因此,我们取出第一个value,即18

每个单词的$q, k, v$也可以有类似的解释。比如第三个字“问”的query $q^{< 3 >}$是:哪个字和“问”字组词了?当然,在这个句子里,我们人类可以很轻松地知道答案,“问”和“访”组成了“访问”这个词。每个字的key可以认为是字的固有属性,比如是名词还是动词。每个字的value就可以认为是这个字的词嵌入。

让计算机去查询$q^{< 3 >}$的话,我们要用这个query去句子的其他字里查出我们要的答案。和数据库的查询一样,我们也要把这个query和所有字的key进行比对。根据注意力的公式,我们用$q^{< 3 >}$和所有$k$相乘再做softmax,得到注意力权重。再用这个权重乘上每个$v$,加起来,得到所有$v$的加权平均数。既然是查询“问”字和谁组词了,那么这个要找的字肯定是一个动词。因此,计算机会发现$q^{< 3 >}, k^{< 2 >}$比较相关,即这两个向量的内积比较大。一切顺利的话,这个$A^{< 3 >}$应该和$v^{< 2 >}$很接近,即问题“哪个字和‘问’字组词了?”的答案是第二个字“访”。

这是$A^{< 3 >}$的计算过程。准确来说,$A^{< 3 >}=A(q^{< 3 >}, K, V)$。类似地,$A^{< 1 >}-A^{< 5 >}$都是用这个公式来计算。把所有$A$的计算合起来,把$q$合起来,得到的就是下面这个公式。

其中,$d_k$是一个常量,$\sqrt{d_k}$这一项是用来防止点乘结果的数值过大的。它属于实现细节,对整个式子的意义不影响。抛去这一项的话,上式不过是之前那个公式的矩阵版本。这个公式在原论文里叫做Scaled Dot-Product Attention。

现在,我们已经理解了注意力的公式。自注意力,其实就是令$Q=K=V=E$,对$E$这个向量自己做注意力。但是,我们还有一个重要的问题没有解决:$Q, K, V$在Transformer里是怎么获得的?别急,后几节我们会把所有的概念串起来,学习Transformer的结构。现在,让我们再看一看Transformer里的另一个概念——多头注意力。

多头注意力

在CNN中,我们会在一个卷积层里使用多个卷积核,以提取出不同的特征。比如第一个卷积核提取出图像水平方向的边缘,第二个卷积核提取出图像垂直方向的边缘。类似地,一次自注意力也只能得到部分的信息,我们可以多次使用自注意力得到多个信息。

上一节的$A^{< 3 >}$是问题“哪个字和‘问’字组词了?”的答案。我们可以多问几个问题,得到有关“问”字的更多信息,比如“‘问’的主语是谁?”、“‘问’的宾语是谁?”。为了描述这些不同的问题,我们要准备一些可学习的矩阵$W^Q_i, W^K_i, W^V_i$。$Attention(W^Q_1Q, W^K_1K, W^V_1V)$就是第一组注意力结果,$Attention(W^Q_2Q, W^K_2K, W^V_2V)$就是第二组注意力结果,以此类推。这种机制叫做多头注意力。

每次Attention的结果是一个向量,所有Attention的结果concat起来就是多头注意力的结果。

Transformer 网络结构

搞懂了自注意力、多头注意力这两个核心机制后,我们就能够看懂Transformer的整体结构了。

让我们看看Transformer是怎么翻译一句话的。给定一个单词序列(为了方便,我们认为输入句子已经被token化,且输入的是每个token的嵌入),我们要先用encoder提取特征,再用decoder逐个输出翻译出来的token。

在encoder中,输入会经过一个多头注意力层。这一层的Q, K, V都是输入的词嵌入。多头注意力层会接到一个前馈神经网络上,这个网络就是一个简单的全连接网络。

别忘了,多头注意力层的可学习参数是$W^Q_i, W^K_i, W^V_i$,Q, K, V可以是预训练好的词嵌入。

再看decoder。先看一下decoder的输出流程。第一轮,decoder的输入是<SOS>,输出Jane;第二轮,decoder的输入是<SOS> Jane,输出是visits。也就是说,decoder的输入是现有的翻译序列,输出是下一个翻译出来的单词。

在decoder中,输入同样要经过一个多头注意力层。这一层的Q, K, V全是输入的词嵌入。之后,数据会经过另一个多头注意力层,它的Q是上一层的输出,K, V来自于Encoder。为什么这一层有这样的设计呢?大家不妨翻回到前几节,回顾一下经典注意力模型中注意力是怎么计算的。其实,经典注意力模型就是以decoder的隐状态为Q,以encoder的隐状态为K, V的注意力。Transformer不过是用一套全新的公式把之前的机制搬过来了而已。做完这次多头注意力后,数据也是会经过一个全连接网络,输出结果。

encoder和decoder的大模块一共重复了N次,原论文中N=6。

总结一下注意力在Transformer里的使用。多头注意力其实有两个版本:第一个版本是自注意力,用于进一步提取输入的特征;第二个版本和经典注意力模型一样,把输出序列和输入序列关联了起来。

刚才我们学习的是Transformer的主要结构。实际上,Transformer的结构里还有很多细节。

  1. Positional Encoding (位置编码): 和RNN不同,Transformer的多头注意力无法区分每一个输入的顺序。为了告诉模型每一个token的先后顺序,词嵌入在输入模型之前还要加上一个Positional Encoding。这个编码会给每一个词嵌入的每一维加上一个三角函数值。三角函数之间的差具有周期性,模型能够从这个值里认识到输入序列的顺序信息。

我会在之后的文章详细介绍位置编码,这里只需要明白位置编码的意义即可。

  1. Add & Norm 层:参考深度CNN的结构,Transformer给每个模块的输出都做了一次归一化,并且使用了残差连接。

  2. 输出前的线性层和Softmax:为了输出一个单词,我们肯定要做一个softmax。这些层是输出单词的常规操作,和RNN结构里的相同。

最后,在训练Tranformer时,还有一个重要的模块:Masked Multi-head Attention。刚刚我们学到,在输出翻译的句子时,我们要输出一个单词,更新一次decoder的输入;输出一个单词,更新一次decoder的输入。然而,在训练时,我们实际上已经有了正确的输出。因此,我们可以同时给decoder看前1个单词,看前2个单词……,并行地训练所有的这些情况。为了只让decoder看到前几个单词,我们可以给输入加一个mask,把后面那些不应该看到的单词“蒙上”。

Masked Multi-head Attention 其实只是一个实现上的小技巧,不能算作一个新模块。具体的实现方式其实有很多,我们只要重点理解这一设计的原因即可。总之,这种设计让Transformer能够并行地训练翻译进度不同的句子。因此,Transfomer的训练效率会比RNN快很多。

总结

这节课我们主要学习了Transformer模型。在学习时,我们主要应该关注两个核心机制:自注意力、多头注意力。搞懂Transformer的注意力机制后,我们基本就理解了Transformer的原理了。剩下的一些细节知识可以去看原论文,这些细节不干扰论文主体思想的理解。

《深度学习专项》的课就到此结束了。不过,之后我还会围绕这门课发两篇文章。第一篇文章会详细地解读“Attention Is All You Need”这篇论文,第二篇文章会详细介绍Transformer的复现过程,作为这节课的大作业。

在前两周的课中,我们分别学习了RNN的基础结构、词嵌入的知识。学完这些东西后,我们已经能够开发大多数NLP应用了。

正如我们第一周所学,序列任务的本质区别是输入和输出的映射关系不同。我们曾经把任务按映射关系分成一对一、一对多、多对一、等长多对多、不等长多对多。其中,不等长的多对多任务是最常见、最通用的。这周,我们就要以机器翻译任务为例,学习解决多对多问题的更多技巧。同时,我们也会学习机器翻译任务中比较出名的一种模型架构——注意力模型。

学完了机器翻译任务的一些常见架构后,我们还会把这些方法拓展到语音识别问题上,了解一些语音识别中的常见任务。

序列到序列模型

基础模型

有一些问题,它们的输入和输出都是一个序列。比如说机器翻译,输入是某种语言的一句话,输出是另一种语言的一句话。这种序列到序列的问题可以简单地套用RNN解决。

如我们第一周所学,不等长的序列到序列问题可以用如下的RNN模型解决。前面只有输入的部分叫做编码器(encoder),后面输出的部分叫做解码器(decoder)。

再举另一个任务——看图说话的例子。看图说话,就是输入一幅图片,输出该图片的文字描述。比如给定下图的图片,我们可以说“小猫在椅子上”。这个任务的输入看上去并不是一个序列,但我们可以用某种CNN架构把图片转换成一个向量。这个向量就可以看成图片的编码,和刚刚那个RNN编码器的输出一样。利用这个编码,我们可以用RNN解码器生成一句话。

挑选最好的句子

但是,这种基础的架构类似于我们第一周学的语言模型。它可能生成多个新句子,而不能保证生成最好的句子。为了按要求生成最好的句子,我们要使用一些其他的方法。

比如把“Jane九月要访问非洲”翻译成英文,可以翻译成”Jane is visiting Africa in September.”,也可以翻译成”Jane is going to be visiting Africa in September.”。上节提到的那个类似于语言模型的RNN架构可以生成出这两个句子中的任何一个。

从语言的角度,”Jane is visiting Africa in September.”这个翻译比”Jane is going to be visiting Africa in September.”更好。或者说,前面那个句子的概率更高一点。用数学公式来表达,给定被翻译的句子$x$, 我们希望求一个使$P(y^{< 1 >}, y^{< 2 >}, …y^{< T_y >},|x)$最大的序列$y$。

求解这个最优化问题时,我们可能会想到贪心算法。由于RNN解码器每步的softmax可以输出下一个词的概率(还是和语言模型一样的道理),我们可以求出$argmax_{y^{< 1 >}}P(y^{< 1 >}|x)$, $argmax_{y^{< 2 >}}P(y^{< 2 >}|y^{< 1 >}, x)$, …,即用贪心算法每次求出一个概率最大的单词。

然而,每次选一个概率最大的单词,不能保证整句话概率最大。比如,模型可能有”Jane is visiting Africa in September.”和”Jane is going to be visiting Africa in September.”这两个潜在的候选翻译结果。选第三个单词时,”going”的概率可能比”visiting”要高,按贪心算法,我们最后会生成出第二个句子。可是,从翻译质量来看,第一个句子显然更好,它的概率更高。

为了求出概率最大的输出序列,我们要使用一种较好的搜索算法。

贪心算法每次只求出能令当前句子概率最大的下一个单词。这种算法太容易遗漏更优的输出了。而如果真的想求出最优的句子,即求出$argmax_{y}P(y|x)$,需要遍历所有可能的$y$。假如每个单词有$N$种选择,句子长度$T_y$,则搜索算法的复杂度是$O(N^{T_y})$。这个指数增长的复杂度是不能接受的。

Beam Search是这样一种折中的启发式搜算算法。它不能保证求出最优解,却能比贪心算法找出更多更优的解。

Beam Search的核心思想可以用一句话概括:相比于只维护一个概率最优句子的贪心算法,Beam Search每次维护$B$个概率最优的句子。

还是拿开始那句话的翻译为例,并假设$B=3$,词汇表大小为10000。生成第一个单词时,概率最高的三个单词可能是in, Jane, September。生成第二个单词时,我们要遍历第一个单词是in, Jane, September时的所有30000种两个单词组合的可能。最终,我们可能发现in September, Jane is, Jane visits这三个句子的概率最高。依次类推,我们继续遍历下去,直到生成句子里的所有单词。

这个算法用伪代码表示如下:

这份伪代码不是用来是解读的,而是用来对齐想法的。看懂了算法的流程后,读者应该能够用自己的伪代码把这个算法写出来。如果读者自己能写出伪代码,就也能顺着思路读懂我的这份伪代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Input x, B

# encoder
a = 0
for i in range(tx):
a, _ = RNN(embed(x[i]), a)
# decoder
# step 1
a, p = RNN(0, a)
a_arr = [a] * B
words, prob = get_max_words_from_softmax(p, B)
sentences = copy(words)
# step 2 - ty
for i in range(ty - 1):
all_words = []
all_prob = []
all_a = []
for j in range(B):
new_a, p = RNN(embed(words[j]), a_arr[j])
tmp_words, tmp_prob = get_max_words_from_softmax(p, B)
tmp_a = [new_a] * B
# Accmulative multiply the probablity
for k in range(B):
tmp_prob[k] *= prob[j]
all_words += tmp_words
all_parob += tmp_prob
all_a += tmp_a
words, prob, a_arr = get_max_B(all_words, all_prob, all_a)

# Cancatenate output
for j in range(B):
sentences[j].append(words[B])

y = get_max_sentences(sentences[j], prob)
Output y

Beam Search 还有一些优化的手段。

在Beam Search中,我们用了语言模型中求句子的概率的方法,即:

这个概率是我们的优化目标。但是,使用累乘会碰到计算机硬件精度不够的问题。为此,我们可以把优化目标由累乘取log变成累加:

此外,大多情况下,输出的句子越短,句子可能越不准确。因此,我们可以给优化目标添加一个和长度有关的归一化项:

这里的$\alpha(\alpha< 1)$是一个超参数,它用于让长度惩罚更平滑一点,即模型更容易生成长句子。一般$\alpha=0.7$。

为了生成不同长度的句子,在让decoder输出句子时,我们可以记录下$T_y=1, 2…$时概率最大的句子。得到了这些不同长度的候选句子后,把它们的概率乘上归一化项,找出整体概率最大的句子。

Beam Search是一种启发式算法,它的效果取决于超参数B。一般情况下,为了保证速度,B取10就挺不错了。只有在某些不考虑速度的应用或研究中才会令B=100或更高。

加入Beam Search会让我们调试机器翻译算法时更加困难。还是对于开始那个翻译示例,假如人类给出了翻译$y^{\ast}$:Jane visits Africa in September,算法给出了翻译$\hat{y}$:Jane visited Africa last September。这个算法的翻译不够好,我们想利用第三门课学的错误分析方法来分析错误的来源。这究竟是Beam Search出了问题,还是RNN神经网络出了问题呢?

对此,我们可以把训练好的RNN当成一个语言模型,输入$y^{\ast}$和$\hat{y}$,求出这两个句子的概率。如果$y^{\ast} \gt \hat{y}$,那说明RNN的判断是准确的,是Beam Search漏搜了;如果$y^{\ast} \leq \hat{y}$,那说明RNN判断得不准,是RNN出了问题。

Bleu Score

在图像分类中,我们可以用识别准确率来轻松地评价一个模型。但是,在机器翻译任务中,最优的翻译可能不只一个。比如把“小猫在垫子上”翻译成英文,既可以说”The cat is on the mat”,也可以说”There is a cat on the mat”。我们不太好去评价每句话的翻译质量。Bleu Score就是一种衡量翻译质量的指标。

为了评价一句话的翻译质量,我们还是需要专业翻译者给出的参考翻译。比如把刚刚那两句英文翻译作为参考译句:

  • The cat is on the mat
  • There is a cat on the mat

我们可以把机器的翻译结果和这两句参考结果做对比。对比的第一想法是看看机器翻译的句子里的单词有多少个在参考句子里出现过。但是,这种比较方法有问题。假如机器输出了”the the the the the the the”,the在参考句子里出现过,输出的7个单词全部都出现过。因此,翻译准确率是$\frac{7}{7}$。这显然不是一个好的评价指标。

一种更公平的比较方法是,每个单词重复计分的次数是原句中该单词出现的最大次数。比如,the在第一个参考句子里出现2次,在第二个参考句子里出现1次。因此,我们认为the最多计分两次。这样,这句话的翻译准确率就是$\frac{2}{7}$。这个评价结果好多了。

我们不仅可以对单个单词计分,还可以对相邻两个单词构成的单词对计分。比如机器翻译输出了句子The cat the cat on the cat。我们统计每一个词对在输出里出现的次数和计分的次数。

count score
the cat 2 1
cat the 1 0
cat on 1 1
on the 1 1
the mat 1 1

这样,这个句子的准确率是$\frac{4}{6}$。

这种打分方式就叫做bleu score。刚刚我们只讨论了考虑一个单词、两个单词时的打分结果。实际上,我们可以用$p_n$表示连续考虑$n$个单词的bleu score。最终评价一个翻译出来的句子时,我们会考虑到$n$取不同值的情况,比如考虑$n=1, 2, 3, 4$的情况。最终使用的这种指标叫做组合bleu score,它的计算公式为:

其中,BP的全称是brevity penalty(简短惩罚)。这是一个系数,用于防止输出的句子过短。它的计算公式是:

其中,$L_o$是输出句子的长度,$L_r$是参考句子的长度。

bleu score是一个能十分合理地评价机器翻译句子的指标。在评估机器翻译模型时,我们只要使用这一种指标就行了,这符合我们在第三门课中学习的单一优化目标原则。bleu score最早是在机器翻译任务中提出的,后续很多和句子生成相关的任务都使用了此评估指标。

注意力模型

我们刚刚学习的这种“编码器-解码器”架构的RNN确实能在机器翻译上取得不错的效果。但是,这种架构存在一定的限制:模型的编码(输入)和解码(输出)这两步都是一步完成的,模型一次性输入所有的句子,一次性输出所有的句子。这种做法在句子较短的时候还比较可行,但输入句子较长时,模型就“记不住”之前的信息了。而我们这一节学习的注意力模型能够很好地处理任意长度的句子。

让我们看看人类在翻译长句的时候是怎么做的。比如把“简访问非洲”翻译成”Jane visits Africa”时,我们一般不会把整句话一次性翻译,而是会对单词(或文字)逐个翻译。我们会把“简”翻译成”Jane”,“访问”翻译成”visits”,“非洲”翻译成”Africa”。在输出每一个单词时,我们往往只需要关心输入里的某几个单词就行了,而不需要关注所有单词。

注意力模型就使用了类似的原理。在注意力模型中,我们先把输入喂给一个BRNN(双向RNN)。这个BRNN不用来输出句子,而是用于提取每一个输入单词的特征。我们会用另一个单向RNN来输出句子。每一个输出单词的RNN会去查看输入特征,看看它需要“关注”哪些输入。比如,”Jane”的RNN会关注“简”的特征,”visits”的RNN会关注“访”和“问”的特征。这一过程如下图所示(线条表示输出对输入的关注,线条越粗关注度越高)。

这样,不管输入的序列有多长,每一个输出都能找到它需要关注的部分单词,仅根据这些输入来完成翻译,就和我们人类的做法一样。这就是注意力模型的思想。

让我们看一下具体的计算过程。为了区分上下两个RNN,我们用$a$表示编码RNN的状态,$t’$表示输入序号;$s$表示解码RNN的状态,$t$表示输出序号。刚才提到的那种关注每个输入单词的注意力机制会给每个输出一个上下文向量$c^{< t >}$。这个向量和上一轮输出$\hat{y}^{< t-1 >}$拼在一起作为这轮解码RNN的输入。

注意,从逻辑上来讲,解码RNN有两个输入。第一个输入和我们之前见过的解码RNN一样,是上一轮的输出$\hat{y}^{< t-1 >}$。第二个输入是注意力上下文$c^{< t >}$。这两个输入通过拼接(concatenate)的方式一起输入解码RNN。我在学到这里的时候一直很疑惑,两个输入该怎么输入进RNN。原视频并没有强调两个输入是拼接在一起的。

如果输出的单词不是很依赖于上一个单词,解码RNN也可以不输入上一个单词,只输入注意力上下文。这门课的编程作业就采用了这种只有一个输入的更为简单的结构。

接下来,我们来详细看看注意力机制是怎么工作的。如前文所述,注意力机制就是要算一个对每个输入的关注度,根据这个关注度以不同的权重去输入里取值。这个过程的表示如下。

设输入状态$a^{< t’ >}=(\overrightarrow{a}^{< t’ >}, \overleftarrow{a}^{< t’ >})$(把BRNN前后的状态拼接到一起)。假如我们得到了权重$\alpha^{< t, t’ >}$,它表示第$t$个输出对第$t’$个输入的关注度,则注意力上下文$c^{< t >}$的计算方法为:

对于每一个$t$, 所有$t’$的$\alpha^{< t, t’ >}$和为1。也就是说,上式其实就是一个加权平均数,其中$\alpha^{< t, t’ >}$是权重。“注意力”这个名词看上去很高端,其实就是一个中学生都会的概念而已。

现在,我们还不知道关注度$\alpha^{< t, t’ >}$是怎么算的。让我们思考一下,第$t$个输出单词和第$t’$个输入单词的关注度取决于谁的信息呢?答案很简单,取决于第$t$个输出单词的信息和第$t’$个输入单词的信息。第$t$个输出单词的信息,可以用其上一层的状态$s^{< t-1 >}$表达;第$t’$个输入单词的信息,可以用$a^{< t’ >}$表达。怎么用它们算一个关注度出来呢?谁也给不出一个具体的公式,干脆就用一个神经网络来拟合就好了。

具体的计算过程如下图所示:

我们用一个小全连接网络算出一个输出$e^{< t, t’ >}$。由于$\alpha^{< t, t’ >}$最后的和要为1,我们用对$e^{< t, t’ >}$做一个softmax,得到归一化的$\alpha^{< t, t’ >}$。

整理一下,注意力模型的计算步骤如下:

  1. 用一个编码RNN(比如Bi-LSTM)算出输入的特征$a^{< t’ >}$。
  2. 用一个解码RNN(比如LSTM)的状态$s^{< t-1 >}$和所有$a^{< t’ >}$算一个对每个输入的关注度$\alpha^{< t, t’ >}$ 。
  3. 以关注度为权重,以$a^{< t’ >}$为值,算一个加权平均数$c^{< t >}$作为第$t$个输出的注意力上下文。
  4. 以上一轮输出$y^{< t-1 >}$和$c^{< t >}$为输入,以$s^{< t-1 >}$为上一轮状态,计算解码RNN这一轮的输出$y^{< t >}$。

注意力模型的效果不错,但它的计算复杂度是平方级别的(输入长度乘输出长度)。不过,机器翻译任务的输入输出都不会太大,这一性能弊端没有那么明显。

语音任务

语音识别

到目前为止,我们主要用序列模型完成NLP任务。其实序列模型也很适合用在语音数据上。让我们来快速认识一下语音识别任务的解决方法。

语音数据是一维数据,表示每一时刻的声音强度。而我们人脑在在接受声音时,会自动对声音处理,感知到声音的音调(频率)和响度。

语音识别任务的输入是语音数据,输出是一个句子。我们可以直接用注意力模型解决这个问题(令输出元素为字母而不是单词),也可以用一种叫做CTC(connectionist temporal classification)的算法解决。

CTC算法用于把语音识别的输入输出长度对齐。这样,我们用一个简单的等长多对多RNN就可以了。在语音识别中,输入的长度远大于输出的长度,我们可以想办法扩大输出长度。这种扩充方式如下:

比如,对于句子”the quick brown fox”,我们可以把”the q”它扩充成ttt_h_eee____< space >____qqq__。这个和输入等长的序列表示每一个时刻发音者正在说哪个字母。序列中有一些特殊标记,下划线表示没有识别出任何东西,空格< space >表示英语里的空格。

通过这种方法,我们可以把所有训练数据的标签预处理成和输入等长的序列,进而用普通RNN解决语音识别问题。

触发词检测

触发词检测也是一类常见的语音问题,我们可以用序列模型轻松解决它。

触发词检测在生活中比较常见,比如苹果设备的”Hey Siri”就可以唤醒苹果语音助手。我们的任务,就是给定一段语音序列,输出何时有人说出了某个触发词。

我们可以用一张图快速地学会如何解决这个问题。如下图所示,对于每一个输入,我们可以构造一个等长的输出,表示每一时刻是否说完了触发词。每当一个触发词说完,我们就往它后面几个时刻标上1。用一个普通RNN就可以解决这个问题了。

从这两个示例中,我们能看出,学会了序列模型后,我们掌握了很多武器。而在解决实际问题时,关键在于如何建模,把问题的输入输出对应到RNN的输入输出上,把我们的武器用出去。

总结

这堂课以机器翻译任务为例,介绍了序列到序列问题的一些解决方法。特别地,这堂课介绍了注意力模型。注意力模型在序列到序列问题上有着极佳的表现,并催生了后续纯粹由注意力机制构成的更加强大的Transformer模型。利用这些学到的知识,我们可以轻松地解决大多数序列到序列问题,比如和语音相关的语音识别与触发词检测问题。

这堂课的知识点有:

  • Beam Search
    • 序列到序列问题的建模方法:解码器与生成器,生成一个概率尽可能大的输出序列
    • 为什么需要搜索算法,为什么贪心算法不好
    • Beam Search 的过程
  • Bleu Score 的计算思想:公平地根据参考序列评价生成序列的质量
  • 注意力模型
    • 新的编码器-解码器架构
    • 注意力机制的动机
    • 解码器怎么利用注意力权重
    • 注意力权重怎么生成
  • 语音问题
    • 建模思想:对齐输入输出序列,用简单的RNN解决问题
    • 语音识别CTC算法:输出每一时刻正在发音的字符
    • 触发词检测:用01表示是否有触发词

本来,深度学习专项的课程就到此为止了。但是,后来Transformer太火了,课程专门加了一节介绍Transformer的内容。让我们在下周详细学习一下Transformer这一功能强大的模型架构。

Transformer中的“注意力”最早来自于NLP里的注意力模型。通过动手实现一遍注意力模型,我们能够更深刻地理解注意力的原理,以便于学习Transformer等后续那些基于注意力的模型。在这篇文章中,我将分享如何用PyTorch的基本API实现注意力模型,完成一个简单的机器翻译项目——把各种格式的日期“翻译”成统一格式的日期。

有关机器翻译、注意力模型相关知识请参考我之前的文章。如序列模型与注意力机制

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/attention

知识背景

注意力模型发源自机器翻译任务。最早,基于RNN的机器翻译模型都采用如下的架构:

前半部分的RNN只有输入,后半部分的RNN只有输出。两个部分通过一个简单的隐状态来传递信息。把隐状态看成输入信息的一种编码的话,前半部分可以叫做“编码器”,后半部分可以叫做“解码器”。这种架构因而被称为“编码器-解码器”架构。

这种架构在翻译短句子时确实有效,但面对长文章时就捉襟见肘了。使用“编码器-解码器”架构时,无论输入有多长,输入都会被压缩成一个简短的编码。也就是说,模型要一次性阅读完所有输入,再一次性输出所有翻译。这显然不是一种好的方法。联想一下,我们人类在翻译时,一般会读一句话,翻译一句话,读一句话,翻译一句话。基于这种思想,有人提出了注意力模型。注意力模型能够有效地翻译长文章。

在注意力模型中,编码器和解码器以另一种方式连接在一起。在完成编码后,解码器会以不同的权重去各个编码输出中取出相关信息,也就是以不同的“注意力”去关注输入信息。

具体来说,注意力模型的结构如下。

对于每一轮的输出$\hat{y}^{< t >}$,它的解码RNN的输入由上一轮输出$\hat{y}^{< t - 1>}$和注意力上下文$c^{< t >}$拼接而成。注意力上下文$c^{< t >}$,就是所有输入的编码RNN的隐变量$a^{< t >}$的一个加权平均数。这里加权平均数的权重$\alpha$就是该输出对每一个输入的注意力。每一个$\alpha$由编码RNN本轮状态$a^{< t’ >}$和解码RNN上一轮状态$s^{< t - 1 >}$决定。这两个输入会被送入一个简单的全连接网络,输出权重$e$(一个实数)。所有输入元素的$e$经过一个softmax输出$\alpha$。

日期翻译任务及其数据集

为了简化项目的实现,我们来完成一个简单的日期翻译任务。在这个任务中,输入是各式各样的日期,输出是某一个标准格式的日期。比如:

input output
Nov 23, 1999 1999-11-23
3 April 2005 2005-04-03
14/01/1989 1989-01-14
Thursday, February 7, 1985 1985-02-07

我们可以自己动手用Python生成数据集。在生成数据集时,我们要用到随机生成日期的faker库和格式化日期的babel库。

1
pip install faker babel

运行下面这段代码,我们可以生成不同格式的日期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import random

from babel.dates import format_date
from faker import Faker

faker = Faker()
format_list = [
'short', 'medium', 'long', 'full', 'd MMM YYY', 'd MMMM YYY', 'dd/MM/YYY',
'dd-MM-YYY', 'EE d, MMM YYY', 'EEEE d, MMMM YYY'
]

if __name__ == '__main__':
for format in format_list:
date_obj = faker.date_object()
print(f'{format}:', date_obj,
format_date(date_obj, format=format, locale='en'))
text
1
2
3
4
5
6
7
8
9
10
11
Possible output:
short: 1986-02-25 2/25/86
medium: 1979-08-05 Aug 5, 1979
long: 1971-12-15 December 15, 1971
full: 2017-02-14 Tuesday, February 14, 2017
d MMM YYY: 1984-02-21 21 Feb 1984
d MMMM YYY: 2011-06-22 22 June 2011
dd/MM/YYY: 1991-08-02 02/08/1991
dd-MM-YYY: 1987-06-12 12-06-1987
EE d, MMM YYY: 1986-11-02 Sun 2, Nov 1986
EEEE d, MMMM YYY: 1996-01-26 Friday 26, January 1996

Faker()是生成随机数据的代理类,用它的date_object()方法可以随机生成一个日期字符串date_obj。这个日期就是我们期望的标准格式。而通过使用format_date函数,我们可以通过改变该函数的format参数来得到格式不一样的日期字符串。各种格式的日期示例可以参考上面的输出。

利用这些工具函数,我们可以编写下面这些生成、读取数据集的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def generate_date():
format = random.choice(format_list)
date_obj = faker.date_object()
formated_date = format_date(date_obj, format=format, locale='en')
return formated_date, date_obj


def generate_date_data(count, filename):
with open(filename, 'w') as fp:
for _ in range(count):
formated_date, date_obj = generate_date()
fp.write(f'{formated_date}\t{date_obj}\n')


def load_date_data(filename):
with open(filename, 'r') as fp:
lines = fp.readlines()
return [line.strip('\n').split('\t') for line in lines]


generate_date_data(50000, 'dldemos/attention/train.txt')
generate_date_data(10000, 'dldemos/attention/test.txt')

注意力模型

在这个项目中,最难的部分是注意力模型的实现,即如何把上一节那个结构图用PyTorch描述出来。所有模型实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import torch
import torch.nn as nn
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader, Dataset

from dldemos.attention.dataset import generate_date, load_date_data


EMBEDDING_LENGTH = 128
OUTPUT_LENGTH = 10

class AttentionModel(nn.Module):
def __init__(self,
embeding_dim=32,
encoder_dim=32,
decoder_dim=32,
dropout_rate=0.5):
super().__init__()
self.drop = nn.Dropout(dropout_rate)
self.embedding = nn.Embedding(EMBEDDING_LENGTH, embeding_dim)
self.attention_linear = nn.Linear(2 * encoder_dim + decoder_dim, 1)
self.softmax = nn.Softmax(-1)
self.encoder = nn.LSTM(embeding_dim,
encoder_dim,
1,
batch_first=True,
bidirectional=True)
self.decoder = nn.LSTM(EMBEDDING_LENGTH + 2 * encoder_dim,
decoder_dim,
1,
batch_first=True)
self.output_linear = nn.Linear(decoder_dim, EMBEDDING_LENGTH)
self.decoder_dim = decoder_dim

def forward(self, x: torch.Tensor, n_output: int = OUTPUT_LENGTH):
# x: [batch, n_sequence, EMBEDDING_LENGTH]
batch, n_squence = x.shape[0:2]

# x: [batch, n_sequence, embeding_dim]
x = self.drop(self.embedding(x))

# a: [batch, n_sequence, hidden]
a, _ = self.encoder(x)

# prev_s: [batch, n_squence=1, hidden]
# prev_y: [batch, n_squence=1, EMBEDDING_LENGTH]
# y: [batch, n_output, EMBEDDING_LENGTH]
prev_s = x.new_zeros(batch, 1, self.decoder_dim)
prev_y = x.new_zeros(batch, 1, EMBEDDING_LENGTH)
y = x.new_empty(batch, n_output, EMBEDDING_LENGTH)
tmp_states = None
for i_output in range(n_output):
# repeat_s: [batch, n_squence, hidden]
repeat_s = prev_s.repeat(1, n_squence, 1)
# attention_input: [batch * n_sequence, hidden_s + hidden_a]
attention_input = torch.cat((repeat_s, a),
2).reshape(batch * n_squence, -1)
alpha = self.softmax(self.attention_linear(attention_input))
c = torch.sum(a * alpha.reshape(batch, n_squence, 1), 1)
c = c.unsqueeze(1)
decoder_input = torch.cat((prev_y, c), 2)

if tmp_states is None:
prev_s, tmp_states = self.decoder(decoder_input)
else:
prev_s, tmp_states = self.decoder(decoder_input, tmp_states)

prev_y = self.output_linear(prev_s)
y[:, i_output] = prev_y.squeeze(1)
return y

让我们把这份实现一点一点过一遍。

在实现前,我们要准备一些常量。我们首先要决定“词汇表”的大小。在日期翻译任务中,输入和输出应当看成是字符序列。字符最多有128个,因此我们可以令“词汇表”大小为128。

1
EMBEDDING_LENGTH = 128

在我们这个任务中,输出序列的长度是固定的。对于yyyy-mm-dd这个日期字符串,其长度为10。我们要把这个常量也准备好。

1
OUTPUT_LENGTH = 10

接下来是模型的实现。先看__init__里的结构定义。一开始,按照RNN模型的惯例,我们要让输入过Dropout和嵌入层。对于单词序列,使用预训练的单词嵌入会好一点。然而,我们这个项目用的是字符序列,直接定义一个可学习的嵌入层即可。

1
2
self.drop = nn.Dropout(dropout_rate)
self.embedding = nn.Embedding(EMBEDDING_LENGTH, embeding_dim)

接下来是编码器和解码器。在注意力模型中,编码器和解码器是两个不同的RNN。为了充分利用输入信息,可以把双向RNN当作编码器。而由于机器翻译是一个生成答案的任务,每轮生成元素时需要用到上一轮生成出来的元素,解码器必须是一个单向RNN。在本项目中,我使用的RNN是LSTM。模块定义代码如下:

1
2
3
4
5
6
7
8
9
self.encoder = nn.LSTM(embeding_dim,
encoder_dim,
1,
batch_first=True,
bidirectional=True)
self.decoder = nn.LSTM(EMBEDDING_LENGTH + 2 * encoder_dim,
decoder_dim,
1,
batch_first=True)

这里要注意一下这两个模块的输入通道数。encoder的输入来自嵌入层,因此是embeding_dim,这个很好理解。decoder的输入通道则需要计算一番了。decoder的输入由模型上一轮的输出和注意力输出拼接而成。模型每轮会输出一个字符,字符的通道数是“词汇表”大小,即EMBEDDING_LENGTH。注意力的输出是encoder的隐变量的加权和,因此其通道数和encoder的隐变量一致。encoder是双向RNN,其隐变量的通道数是2 * encoder_dim。最终,decoder的输入通道数应是EMBEDDING_LENGTH + 2 * encoder_dim

在注意力模块中,解码RNN对各编码RNN的注意力由一个线性层计算而得。该线性层的输入由解码RNN和编码RNN的隐变量拼接而成,因此其通道数为2 * encoder_dim + decoder_dim;该线性层的输出是注意力权重——一个实数。

1
self.attention_linear = nn.Linear(2 * encoder_dim + decoder_dim, 1)

解码结束后,还需要经过一个线性层才能输出结果。

1
self.output_linear = nn.Linear(decoder_dim, EMBEDDING_LENGTH)

看完了__init__,来看看forward里各模块是怎么连接起来的。

机器翻译其实是一个生成序列的任务。一般情况下,生成序列的长度是不确定的,需要用一些额外的技巧来选择最佳的输出序列。为了简化实现,在这个项目中,我们生成一个固定长度的输出序列。该长度应该在forward的参数里指定。因此,forward的参数如下:

1
def forward(self, x: torch.Tensor, n_output: int = OUTPUT_LENGTH):

一开始,先获取一些形状信息。

1
2
# x: [batch, n_sequence, EMBEDDING_LENGTH]
batch, n_squence = x.shape[0:2]

输入通过嵌入层和dropout层。

1
2
# x: [batch, n_sequence, embeding_dim]
x = self.drop(self.embedding(x))

再通过编码器,得到编码隐状态a

1
2
# a: [batch, n_sequence, hidden]
a, _ = self.encoder(x)

接下来,要用for循环输出每一轮的结果了。在此之前,我们要准备一些中间变量:用于计算注意力的解码器上一轮状态prev_s,用于解码器输入的上一轮输出prev_y,输出张量y。另外,由于我们要在循环中手动调用decoder完成每一轮的计算,还需要保存decoder的所有中间变量tmp_states

1
2
3
4
5
6
7
# prev_s: [batch, n_squence=1, hidden]
# prev_y: [batch, n_squence=1, EMBEDDING_LENGTH]
# y: [batch, n_output, EMBEDDING_LENGTH]
prev_s = x.new_zeros(batch, 1, self.decoder_dim)
prev_y = x.new_zeros(batch, 1, EMBEDDING_LENGTH)
y = x.new_empty(batch, n_output, EMBEDDING_LENGTH)
tmp_states = None

在每一轮输出中,我们首先要获得当前的解码器对于每一个输入的注意力alpha。每一个alpha由解码器上一轮状态prev_s和编码器本轮状态决定(一个全连接层+softmax)。为了充分利用并行计算,我们可以把所有alpha的计算打包成batch,一步做完。

注意,这里的全连接层+softmax和普通的全连接网络不太一样。这里全连接层的输出通道数是1,会对n组输入做n次计算,得到n个结果,再对n个结果做softmax。我们之所以能一次得到n个结果,是巧妙地把n放到了batch那一维。

1
2
3
4
5
6
7
8
9
10
11
for i_output in range(n_output):
# repeat_s: [batch, n_squence, hidden]
repeat_s = prev_s.repeat(1, n_squence, 1)
# attention_input: [batch * n_sequence, hidden_s + hidden_a]
attention_input = torch.cat((repeat_s, a),
2).reshape(batch * n_squence, -1)
# x: [batch * n_sequence, 1]
x = self.attention_linear(attention_input)
# x: [batch, n_sequence]
x = x.reshape(batch, n_squence)
alpha = self.softmax(x)

求出了注意力alpha后,就可以用它来算出注意力上下文c了。

1
c = torch.sum(a * alpha.reshape(batch, n_squence, 1), 1)

之后,我们把c和上一轮输出prev_y拼一下,作为解码器的输出。

1
2
c = c.unsqueeze(1)
decoder_input = torch.cat((prev_y, c), 2)

再调用解码器即可。这里我利用PyTorch的机制偷了个懒。理论上解码器第一轮的状态应该是全零张量,我们应该初始化两个全零张量作为LSTM的初始状态。但是,在PyTorch里,如果调用RNN时不传入状态,就默认会使用全零状态。因此,在第一轮调用时,我们可以不去传状态参数。

1
2
3
4
if tmp_states is None:
prev_s, tmp_states = self.decoder(decoder_input)
else:
prev_s, tmp_states = self.decoder(decoder_input, tmp_states)

最后,用线性层算出这轮的输出,维护输出变量y。循环结束后,返回y

1
2
3
    prev_y = self.output_linear(prev_s)
y[:, i_output] = prev_y.squeeze(1)
return y

训练、测试、推理

写完了最核心的注意力模型,剩下的代码就比较简单了。

首先,我们要准备一个Dataset类。这个类可以读取输入、输出字符串,并把它们转换成整形数组。字符和整形数字间的映射非常暴力,一个字符的序号就是该字符的ASCII码。这样写比较简洁,但由于很多字符是用不到的,会浪费一些计算性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def stoi(str):
return torch.LongTensor([ord(char) for char in str])


def itos(arr):
return ''.join([chr(x) for x in arr])


class DateDataset(Dataset):
def __init__(self, lines):
self.lines = lines

def __len__(self):
return len(self.lines)

def __getitem__(self, index):
line = self.lines[index]

return stoi(line[0]), stoi(line[1])

准备好DataSet后,就可以生成DataLoader了。在序列任务中,各个样本的序列长度可能是不一致的。我们可以用PyTorch的pad_sequence对长度不足的样本进行0填充,使得一个batch里的所有样本都有着同样的序列长度。

1
2
3
4
5
6
7
8
9
10
11
def get_dataloader(filename):

def collate_fn(batch):
x, y = zip(*batch)
x_pad = pad_sequence(x, batch_first=True)
y_pad = pad_sequence(y, batch_first=True)
return x_pad, y_pad

lines = load_date_data(filename)
dataset = DateDataset(lines)
return DataLoader(dataset, 32, collate_fn=collate_fn)

这里要稍微注意一下,pad_sequence默认会做0填充,0填充在我们的项目里是合理的。在我们定义的“词汇表”里,0对应的是ASCII里的0号字符,这个字符不会和其他字符起冲突。

做好一切准备工作后,可以开始训练模型了。训练模型的代码非常常规,定义好Adam优化器、交叉熵误差,跑完模型后reshape一下算出loss再反向传播即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def main():
device = 'cuda:0'
train_dataloader = get_dataloader('dldemos/attention/train.txt')
test_dataloader = get_dataloader('dldemos/attention/test.txt')

model = AttentionModel().to(device)

# train

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
citerion = torch.nn.CrossEntropyLoss()
for epoch in range(20):

loss_sum = 0
dataset_len = len(train_dataloader.dataset)

for x, y in train_dataloader:
x = x.to(device)
y = y.to(device)
hat_y = model(x)
n, Tx, _ = hat_y.shape
hat_y = torch.reshape(hat_y, (n * Tx, -1))
label_y = torch.reshape(y, (n * Tx, ))
loss = citerion(hat_y, label_y)

optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()

loss_sum += loss * n

print(f'Epoch {epoch}. loss: {loss_sum / dataset_len}')

torch.save(model.state_dict(), 'dldemos/attention/model.pth')

训练完模型后,我们可以测试一下模型在测试集上的正确率。在日期翻译任务中,我们可以把“正确”定义为输出和真值一模一样。比如一条日期的真值是”2000-01-01”,模型的输出必须也是”2000-01-01”才能说这个输出是正确的。编写并行化计算正确率的代码稍有难度。

模型的输出hat_y表示各个字符的出现概率。我们先用prediction = torch.argmax(hat_y, 2)把序列里每个概率最大的字符作为模型预测的字符。现在,我们要用并行化编程判断每对序列(整形标签数组)predition[i]y[i]是否相等(注意,preditiony是带了batch那个维度的)。这里,我们可以让predition[i]y[i]做减法再求和。仅当这个和为0时,我们才能说predition[i]y[i]完全相等。通过这样一种曲折的实现方法,我们可以并行地算出正确率。

也许有更方便的API可以完成这个逻辑判断,但去网上搜索这么复杂的一个需求太麻烦了,我偷了个懒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# test
model.load_state_dict(torch.load('dldemos/attention/model.pth'))

accuracy = 0
dataset_len = len(test_dataloader.dataset)

for x, y in test_dataloader:
x = x.to(device)
y = y.to(device)
hat_y = model(x)
prediction = torch.argmax(hat_y, 2)
score = torch.where(torch.sum(prediction - y, -1) == 0, 1, 0)
accuracy += torch.sum(score)

print(f'Accuracy: {accuracy / dataset_len}')

最后,我们也可以临时生成几个测试用例,输出模型的预测结果。

1
2
3
4
5
6
7
8
9
# inference
for _ in range(5):
x, y = generate_date()
origin_x = x
x = stoi(x).unsqueeze(0).to(device)
hat_y = model(x)
hat_y = hat_y.squeeze(0).argmax(1)
hat_y = itos(hat_y)
print(f'input: {origin_x}, prediction: {hat_y}, gt: {y}')

训练20-30个epoch后,模型差不多就收敛了。我训练的模型在测试集上的正确率约有98%。下面是随机测试用例的推理结果,可以看出模型的判断确实很准确。

text
1
2
3
4
5
input: 4 November 1988, prediction: 1988-11-04, gt: 1988-11-04
input: Friday 26, March 2021, prediction: 2021-03-26, gt: 2021-03-26
input: Saturday 2, December 1989, prediction: 1989-12-02, gt: 1989-12-02
input: 15/10/1971, prediction: 1971-10-15, gt: 1971-10-15
input: Mon 9, Oct 1989, prediction: 1989-10-09, gt: 1989-10-09

总结

在这篇文章中,我展示了一个用PyTorch编写的注意力模型,它用于完成日期翻译任务。在这个项目中,最重要的是注意力模型的编写。如今,注意力模型已经不是功能最强大的模型架构了。不过,通过动手实现这个模型,我们可以对注意力机制有着更深刻的认识,有助于理解那些更先进的模型。