0%

我在学习今年的一篇和人脸生成相关的论文时,看到了一个约束人脸相似度的 loss。这个 loss 基于经典的 ArcFace 人脸识别网络。ArcFace 是 2019年发表的,这么久了还有人用,说明这是一篇适用性很强的工作。于是,我顺手学了一下 ArcFace 的相关背景。在这篇文章中,我将简要分享 ArcFace 人脸识别网络的发展历程,并介绍如何快速利用它的开源 PyTorch 项目计算任意两幅人脸的相似度。

人脸识别与 ArcFace

人脸识别是在深度学习时代取得较大突破的一项任务:给定一个登记了 N 个人的人脸数据库,再输入一幅人脸,输出这个人是否是 N 个人中的某一个。

多数深度学习算法会用一个 CNN 来提取所有人脸图片的特征,如果输入的图片特征和数据库里的某个特征的向量相似度大于某个阈值,就说明识别成功。也就是说,人脸识别的关键在于如何用 CNN 生成一个「好」的特征。特征的「好」体现在两点上:1) 同一个人的人脸特征要尽可能相似;2) 不同人的人脸之间的特征要尽可能不同。

为了达成这个目的,研究者提出了不同的学习特征的方法。最直观的方式是像学习词嵌入一样,用一个具体的任务来学习特征提取。恰好,人脸识别可以天然地被当成一个多分类任务:对于一个有 N 个人的人脸训练集,人脸识别就是一个 N 分类任务。只要在特征提取后面加一个线性层和一个 softmax 就可以做多分类了。训练好多分类器后,扔掉线性层和 softmax 层,就得到了一个特征提取器。

这种基于 softmax 分类器的学习方法确实能够区分训练集中的人脸,但在辨别开放人脸数据集时表现不佳。这是因为 softmax 的学习目标仅仅是区分不同类别的人脸,而没有要求这种区分有多么分明。后续的多篇工作,包括 ArcFace,都是在改进训练目标 softmax,使得每类对象之间有一个较大的间隔。

为了让不同的类别之间存在间隔,研究者们详细分析了 softmax 函数。假设$x \in \mathbb{R}^d$是人脸图片提取出的维度为$d$的特征向量,它属于$N$类里的第$y$类。softmax 前的线性层的参数是$W\in \mathbb{R}^{d\times N}, b\in \mathbb{R}^N$。则基于 softmax 的多分类误差可以写成:

其中,向量内积$W^T_jx$可以展开为:$W^T_jx=||W^T_j|| \ ||x|| \ cos \theta_j$,其中$\theta_j$是两个向量的夹角。如果对向量$W^T_j$和$x$都做归一化的话,再令$b=0$,则新的误差可以写成:

也就是说,对于这种归一化的多分类误差,对误差产生贡献的只有特征向量和$W$的列向量的夹角。那么,我们就可以换一个视角来看待这个误差:$W$其实是$N$个维度为$d$的向量的数组,它们表示了$N$个人脸类别的中心特征向量。误差要求每个特征和它对应的中心向量的夹角更小。

为了让不同的类别之间有更大的间隔,相同类别内部更加聚拢,ArcFace 在误差的$cos$中加了一个常数项。

直观上来看,加这一项就是把角度远离类别中心的惩罚扩大,使得各个类别的数据都更加靠近中心。这一优化是有效的。作者做了一个简单的实验,用8类人脸的训练集训练了一个维度为2的特征。其可视化结果如下(线是各个类别中心的方向向量,点是样本的特征向量):

ArcFace 的核心思想就是其 loss 的设计。ArcFace 的网络架构没有特别的要求,一般使用 ResNet 就行。

使用 ArcFace 开源项目

其实用这个库的时候可以完全不懂 ArcFace 的原理。

可能是由于「ArcFace」这个名字和其他项目撞车了,ArcFace 的官方 GitHub 仓库叫做 insightface。其 PyTorch 实现的网址是 https://github.com/deepinsight/insightface/tree/master/recognition/arcface_torch。

早期该论文没有官方的 PyTorch 实现。有人用 PyTorch 复现了该论文(https://github.com/TreB1eN/InsightFace_Pytorch )。我使用的项目是这个复现版,它更完整一点,包含了人脸预处理代码。

人脸识别任务最简单的例子是输出两幅人脸的相似度。接下来我将介绍如何安装这个项目,并用它来编写一个简单的计算人脸相似度的 demo。

安装这个库很方便,只要一键 git clone 就行。这个项目基本不依赖什么第三方库,环境里有 PyTorch,NumPy 等常见库就行了。

1
git clone git@github.com:TreB1eN/InsightFace_Pytorch.git

获取仓库后,在 README 的链接里下载 IR-SE50 模型 model_ir_se50.pth。随便放在哪个目录里。比如下载 IR-SE50 @ Onedrive

最后,还要准备一下测试图片。我很机智地使用了吴恩达《深度学习专项》里讲人脸识别时用到的图片,分别命名为face1.jpg, face2.jpg, face3.jpg

准备就绪后,就可以编写 demo 了。在做一个人脸相关的项目时,一般会先对人脸做预处理,让所有的人脸图片都有相同的分辨率,且五官的位置对齐。我们的 demo 也是先利用了项目中的人脸预处理库对齐人脸,再调用模型计算相似度。这三张图片的对齐结果如下:

这个demo会输出第一张人脸和第二、第三张人脸之间的相似度。完整的 demo 代码如下(注意修改其中的路径):

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
from model import Backbone
import torch
from PIL import Image
from mtcnn import MTCNN

from torchvision.transforms import Compose, ToTensor, Normalize

mtcnn = MTCNN()


def get_img(img_path, device):
img = Image.open(img_path)
face = mtcnn.align(img)
transfroms = Compose(
[ToTensor(), Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])])
return transfroms(face).to(device).unsqueeze(0)


device = 'cuda'
img1 = get_img('face1.jpg', device)
img2 = get_img('face2.jpg', device)
img3 = get_img('face3.jpg', device)

print(img1.shape)

model = Backbone(num_layers=50, drop_ratio=0.6, mode='ir_se')
model.load_state_dict(torch.load('model_ir_se50.pth'))
model.eval()
model.to(device)

emb1 = model(img1)[0]
emb2 = model(img2)[0]
emb3 = model(img3)[0]
print(emb1.shape)

sim_12 = emb1.dot(emb2).item()
sim_13 = emb1.dot(emb3).item()

print(sim_12)
print(sim_13)

可能是 NumPy 更新的原因,代码在读取.npy时少了allow_pickle=True,会报错。要在mtcnn_pytorch/src/get_nets.py的几个np.load的地方加上allow_pickle=True。不知道在哪加也没关系,报错了再补上即可。

1
2
3
4
5
6
7
8
9
10
# mtcnn_pytorch/src/get_nets.py
# 约55行
weights = np.load('mtcnn_pytorch/src/weights/pnet.npy',
allow_pickle=True)[()]
# 约100行
weights = np.load('mtcnn_pytorch/src/weights/rnet.npy',
allow_pickle=True)[()]
# 约151行
weights = np.load('mtcnn_pytorch/src/weights/rnet.npy',
allow_pickle=True)[()]

修改完毕后,直接运行脚本就行了。其输出大致为:

text
1
2
3
4
torch.Size([1, 3, 112, 112])
torch.Size([512])
0.5305924415588379
0.03854911029338837

第一个输出是模型输入张量的形状。第二个输出是模型输出的特征的性质。了解这两个形状信息有助于我们调用此库。

第三个输出是第一、第二张人脸的cos相似度,第四个输出是第二、第三张人脸的cos相似度。我们已经事先知道了,第一和第二张人脸图片是同一个人,第一和第三张人脸图片是两个人。所以说,这个输出结果非常正确。

在我们自己的人脸项目中,一般都会准备好人脸预处理的代码。因此,在调用这个库时,可以只把 model.Backbone 里的代码复制过去,只使用该项目的模型即可。使用时注意输入输出的形状要求。

参考资料

ArcFace 论文:ArcFace: Additive Angular Margin Loss for Deep Face Recognition

官方仓库:https://github.com/deepinsight/insightface

PyTorch 复现仓库:https://github.com/TreB1eN/InsightFace_Pytorch

《人脸识别的 loss》:https://zhuanlan.zhihu.com/p/34404607 https://zhuanlan.zhihu.com/p/34436551

刚刚学完了PyTorch的并行训练写法,我来分享一份非常简单的PyTorch并行训练代码。希望没有学过的读者能够在接触尽可能少的新知识的前提下学会写并行训练。

完整代码 main.py

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import os
import torch
import torch.distributed as dist
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torch.utils.data.distributed import DistributedSampler
from torch.nn.parallel import DistributedDataParallel

def setup():
dist.init_process_group('nccl')


def cleanup():
dist.destroy_process_group()


class ToyModel(nn.Module):

def __init__(self) -> None:
super().__init__()
self.layer = nn.Linear(1, 1)

def forward(self, x):
return self.layer(x)


class MyDataset(Dataset):

def __init__(self):
super().__init__()
self.data = torch.tensor([1, 2, 3, 4], dtype=torch.float32)

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

def __getitem__(self, index):
return self.data[index:index + 1]


ckpt_path = 'tmp.pth'


def main():
setup()
rank = dist.get_rank()
pid = os.getpid()
print(f'current pid: {pid}')
print(f'Current rank {rank}')
device_id = rank % torch.cuda.device_count()

dataset = MyDataset()
sampler = DistributedSampler(dataset)
dataloader = DataLoader(dataset, batch_size=2, sampler=sampler)

model = ToyModel().to(device_id)
ddp_model = DistributedDataParallel(model, device_ids=[device_id])
loss_fn = nn.MSELoss()
optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)

if rank == 0:
torch.save(ddp_model.state_dict(), ckpt_path)

dist.barrier()

map_location = {'cuda:0': f'cuda:{device_id}'}
state_dict = torch.load(ckpt_path, map_location=map_location)
print(f'rank {rank}: {state_dict}')
ddp_model.load_state_dict(state_dict)

for epoch in range(2):
sampler.set_epoch(epoch)
for x in dataloader:
print(f'epoch {epoch}, rank {rank} data: {x}')
x = x.to(device_id)
y = ddp_model(x)
optimizer.zero_grad()
loss = loss_fn(x, y)
loss.backward()
optimizer.step()

cleanup()


if __name__ == '__main__':
main()

假设有4张卡,使用第三和第四张卡的并行运行命令(torch v1.10 以上):

1
2
export CUDA_VISIBLE_VERSION=2,3
torchrun --nproc_per_node=2 dldemos/PyTorchDistributed/main.py

较老版本的PyTorch应使用下面这条命令(这种方法在新版本中也能用,但是会报Warning):

1
2
export CUDA_VISIBLE_VERSION=2,3
python -m torch.distributed.launch --nproc_per_node=2 dldemos/PyTorchDistributed/main.py

程序输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
current pid: 3592707
Current rank 1
current pid: 3592706
Current rank 0
rank 0: OrderedDict([('module.layer.weight', tensor([[0.3840]], device='cuda:0')), ('module.layer.bias', tensor([0.6403], device='cuda:0'))])
rank 1: OrderedDict([('module.layer.weight', tensor([[0.3840]], device='cuda:1')), ('module.layer.bias', tensor([0.6403], device='cuda:1'))])
epoch 0, rank 0 data: tensor([[1.],
[4.]])
epoch 0, rank 1 data: tensor([[2.],
[3.]])
epoch 1, rank 0 data: tensor([[2.],
[3.]])
epoch 1, rank 1 data: tensor([[4.],
[1.]])

下面来稍微讲解一下代码。这份代码演示了一种较为常见的PyTorch并行训练方式:一台机器,多GPU。一个进程管理一个GPU。每个进程共享模型参数,但是使用不同的数据,即batch size扩大了GPU个数倍。

为了实现这种并行训练:需要解决以下几个问题:

  • 怎么开启多进程?
  • 模型怎么同步参数与梯度?
  • 数据怎么划分到多个进程中?

带着这三个问题,我们来从头看一遍这份代码。

这份代码要拟合一个恒等映射y=x。使用的数据集非常简单,只有[1, 2, 3, 4]四个数字。

1
2
3
4
5
6
7
8
9
10
11
class MyDataset(Dataset):

def __init__(self):
super().__init__()
self.data = torch.tensor([1, 2, 3, 4], dtype=torch.float32)

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

def __getitem__(self, index):
return self.data[index:index + 1]

模型也只有一个线性函数:

1
2
3
4
5
6
7
8
class ToyModel(nn.Module):

def __init__(self) -> None:
super().__init__()
self.layer = nn.Linear(1, 1)

def forward(self, x):
return self.layer(x)

为了并行训练这个模型,我们要开启多进程。PyTorch提供的torchrun命令以及一些API封装了多进程的实现。我们只要在普通单进程程序前后加入以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
def setup():
dist.init_process_group('nccl')

def main():
setup()

...

cleanup()

def cleanup():
dist.destroy_process_group()

再用torchrun --nproc_per_node=GPU_COUNT main.py去跑这个脚本,就能用GPU_COUNT个进程来运行这个程序,每个进程分配一个GPU。我们可以用dist.get_rank()来查看当前进程的GPU号。同时,我们也可以验证,不同的GPU号对应了不同的进程id。

1
2
3
4
5
6
7
def main():
setup()
rank = dist.get_rank()
pid = os.getpid()
print(f'current pid: {pid}')
print(f'Current rank {rank}')
device_id = rank % torch.cuda.device_count()
text
1
2
3
4
5
Output:
current pid: 3592707
Current rank 1
current pid: 3592706
Current rank 0

接下来,我们来解决数据并行的问题。我们要确保一个epoch的数据被分配到了不同的进程上,以实现batch size的扩大。在PyTorch中,只要在生成Dataloader时把DistributedSampler的实例传入sampler参数就行了。DistributedSampler会自动对数据采样,并放到不同的进程中。我们稍后可以看到数据的采样结果。

1
2
3
dataset = MyDataset()
sampler = DistributedSampler(dataset)
dataloader = DataLoader(dataset, batch_size=2, sampler=sampler)

接下来来看模型并行是怎么实现的。在这种并行训练方式下,每个模型使用同一份参数。在训练时,各个进程并行;在梯度下降时,各个进程会同步一次,保证每个进程的模型都更新相同的梯度。PyTorch又帮我们封装好了这些细节。我们只需要在现有模型上套一层DistributedDataParallel,就可以让模型在后续backward的时候自动同步梯度了。其他的操作都照旧,把新模型ddp_model当成旧模型model调用就行。

1
2
3
4
model = ToyModel().to(device_id)
ddp_model = DistributedDataParallel(model, device_ids=[device_id])
loss_fn = nn.MSELoss()
optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)

准备好了一切后,就可以开始训练了:

1
2
3
4
5
6
7
8
9
10
for epoch in range(2):
sampler.set_epoch(epoch)
for x in dataloader:
print(f'epoch {epoch}, rank {rank} data: {x}')
x = x.to(device_id)
y = ddp_model(x)
optimizer.zero_grad()
loss = loss_fn(x, y)
loss.backward()
optimizer.step()

sampler自动完成了打乱数据集的作用。因此,在定义DataLoader时,不用开启shuffle选项。而在每个新epoch中,要用sampler.set_epoch(epoch)更新sampler,重新打乱数据集。通过输出也可以看出,数据集确实被打乱了。

text
1
2
3
4
5
6
7
8
9
Output:
epoch 0, rank 0 data: tensor([[1.],
[4.]])
epoch 0, rank 1 data: tensor([[2.],
[3.]])
epoch 1, rank 0 data: tensor([[2.],
[3.]])
epoch 1, rank 1 data: tensor([[4.],
[1.]])

大家可以去掉这行代码,跑一遍脚本,看看这行代码的作用。如果没有这行代码,每轮的数据分配情况都是一样的。

1
2
3
4
5
6
7
8
epoch 0, rank 1 data: tensor([[2.],
[3.]])
epoch 0, rank 0 data: tensor([[1.],
[4.]])
epoch 1, rank 1 data: tensor([[2.],
[3.]])
epoch 1, rank 0 data: tensor([[1.],
[4.]])

其他的训练代码和单进程代码一模一样,我们不需要做任何修改。

训练完模型后,应该保存模型。由于每个进程的模型都是一样的,我们只需要让一个进程来保存模型即可。注意,在保存模型时,其他进程不要去修改模型参数。这里最好加上一行dist.barrier(),它可以用来同步进程的运行状态。只有0号GPU的进程存完了模型,所有模型再进行下一步操作。

1
2
3
4
if rank == 0:
torch.save(ddp_model.state_dict(), ckpt_path)

dist.barrier()

读取时需要注意一下。模型存储参数时会保存参数所在设备。由于我们只用了0号GPU的进程存模型,所有参数的device都是cuda:0。而读取模型时,每个设备上的模型都要去读一次模型,参数的位置要做一个调整。

1
2
3
4
map_location = {'cuda:0': f'cuda:{device_id}'}
state_dict = torch.load(ckpt_path, map_location=map_location)
print(f'rank {rank}: {state_dict}')
ddp_model.load_state_dict(state_dict)

从输出中可以看出,在不同的进程中,参数字典是不一样的:

1
2
rank 0: OrderedDict([('module.layer.weight', tensor([[0.3840]], device='cuda:0')), ('module.layer.bias', tensor([0.6403], device='cuda:0'))])
rank 1: OrderedDict([('module.layer.weight', tensor([[0.3840]], device='cuda:1')), ('module.layer.bias', tensor([0.6403], device='cuda:1'))])

这里还有一个重要的细节。使用DistributedDataParallelmodel封装成ddp_model后,模型的参数名里多了一个module。这是因为原来的模型model被保存到了ddp_model.module这个成员变量中(model == ddp_model.module)。在混用单GPU和多GPU的训练代码时,要注意这个参数名不兼容的问题。最好的写法是每次存取ddp_model.module,这样单GPU和多GPU的checkpoint可以轻松兼容。

到此,我们完成了一个极简的PyTorch并行训练Demo。从代码中能看出,PyTorch的封装非常到位,我们只需要在单进程代码上稍作修改,就能开启并行训练。最后,我再来总结一下单卡训练转换成并行训练的修改处:

  1. 程序开始时执行dist.init_process_group('nccl'),结束时执行dist.destroy_process_group()
  2. torchrun --nproc_per_node=GPU_COUNT main.py运行脚本。
  3. 进程初始化后用rank = dist.get_rank()获取当前的GPU ID,把模型和数据都放到这个GPU上。
  4. 封装一下模型
    1
    ddp_model = DistributedDataParallel(model, device_ids=[device_id])
  5. 封装一下DataLoader
    1
    2
    3
    dataset = MyDataset()
    sampler = DistributedSampler(dataset)
    dataloader = DataLoader(dataset, batch_size=2, sampler=sampler)
  6. 训练时打乱数据。sampler.set_epoch(epoch)
  7. 保存只在单卡上进行。
    1
    2
    3
    if rank == 0:
    torch.save(ddp_model.state_dict(), ckpt_path)
    dist.barrier()
  8. 读取数据时注意map_location,也要注意参数名里的module
    1
    2
    3
    map_location = {'cuda:0': f'cuda:{device_id}'}
    state_dict = torch.load(ckpt_path, map_location=map_location)
    ddp_model.load_state_dict(state_dict)

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

参考资料:

  1. 官方教程:https://pytorch.org/tutorials/intermediate/ddp_tutorial.html
  2. 另一个展示简单Demo的文章:https://zhuanlan.zhihu.com/p/350301395

变分自编码器(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 dk$,$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$
  • $dk = 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不同。对于输入序列$(x1, …, 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. 并行解码

对于输入序列$(x1, …, 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的复现过程,作为这节课的大作业。