Transformer - HappyLLM

Encoder-Decoder

Seq2Seq模型

Seq2Seq 模型输入的是一个自然语言序列input=(x1,x2,x3...xn)input = (x_1, x_2, x_3...x_n) ,输出的是一个可能不等长的自然语言序列output=(y1,y2,y3...ym)output = (y_1, y_2, y_3...y_m), Transformer 是一个典型的 Seq2Seq 模型,序列进入 Encoder 进行编码,到 Encoder Layer 的最顶层再将编码结果输出给 Decoder Layer 的每一层,通过 Decoder 解码后就可以得到输出目标序列了。

  • 编码:将输入的自然语言序列通过隐藏层编码成复杂的词向量表示。

  • 解码:对输入的自然语言序列编码得到的向量通过隐藏层输出,再解码成对应的自然语言目标序列。

  • 几乎所有的 NLP 任务都可以视为 Seq2Seq 任务。例如文本分类任务,可以视为输出长度为 1 的目标序列(如在上式中mm = 1);词性标注任务,可以视为输出与输入序列等长的目标序列(如在上式中mm =nn )。

编码器解码器架构

前馈神经网络(Feed Forward Neural Network,FFN)

**定义:**每一层的神经元都和上下两层的神经元完全连接的网络结构。

每一个 Encoder Layer 都包含一个注意力机制和一个前馈神经网络。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch
import torch.nn as nn
import torch.nn.functional as F

class MLP(nn.Module):
def __init__(self, dim: int, hidden_dim:int, dropout: float):
super().__init__()
# 定义第一层线性变化,从输入维度到隐藏层维度
self.fc1 = nn.Linear(dim, hidden_dim, bias = False)
# 定义第二层线性变化,从隐藏层维度到输入维度
self.fc2 = nn.Linear(dim, hidden_dim, bias = False)
# 定义 dropout 层,防止过拟合
self.dropout = nn.Dropout(dropout)

def foward(self, x):
# 前向传播
# 首先,输入 x 经过第一层线性变换和 RELU 激活函数
# 最后,通过最后一层线性变换和 dropout 层
return self.dropout(self.fc2(F.relu(self.fc1(x))))

注: Transformer 的 FFN 由两个线性层中间加一个 RELU 激活函数组成,引入 Dropout 防止过拟合。

Dropout 层仅在训练时开启,推理/测试阶段关闭,所以大多 Transformer 结构示意图不会画出该层

层归一化(Layer Norm)

由于深度神经网络中每一层的输入都是上一层的输出,随着神经网络参数的更新,各层的输出分布是不相同的,且差异会随着网络深度的增大而增大。

但需要预测的条件分布始终是相同的,也就造成了预测的误差。

归一化的作用: 将每一层的输入归一化成标准正态分布,从而使不同层输入的取值范围或者分布一致;

神经网络主流的归一化一般有两种:批归一化(Batch Norm)和层归一化(Layer Norm)。

批归一化: 在一个 mini-batch 上进行归一化(batch 是样本拆分出来的一部分)

  • 首先计算 mini-batch 样本的均值:$ \mu_j = \frac{1}{m}\sum^{m}_{i=1}Z_j^{i}$ , 其中,ZjiZ_j^{i} 是样本 i 在第 j 个维度上的值,m 就是 mini-batch 的大小。

  • 然后计算 mini-batch 样本的方差:$\sigma^2 = \frac{1}{m}\sum^{m}_{i=1}(Z_j^i - \mu_j)^2 $

  • 最后对每个样本的值减去均值再除以标准差来将这一个 mini-batch 的样本的分布转化为标准正态分布:Zj~=Zjμjσ2+ϵ\widetilde{Z_j} = \frac{Z_j - \mu_j}{\sqrt{\sigma^2 + \epsilon}} ,此处加上ϵ\epsilon 这一极小量是为了避免分母为0。

  • 局限:

    • 当显存有限,mini-batch 较小时,Batch Norm 取的样本的均值和方差不能反映全局的统计分布信息,从而导致效果变差;
    • 对于在时间维度展开的 RNN,不同句子的同一分布大概率不同,所以 Batch Norm 的归一化会失去意义;
    • 在训练时,Batch Norm 需要保存每个 step 的统计信息(均值和方差)。在测试时,由于变长句子的特性,测试集可能出现比训练集更长的句子,所以对于后面位置的 step,是没有训练的统计量使用的;
    • 应用 Batch Norm,每个 step 都需要去保存和计算 batch 统计量,耗时又耗力

层归一化: 相较于 Batch Norm 在每一层统计所有样本的均值和方差,Layer Norm 在每个样本上计算其所有层的均值和方差,从而使每个样本的分布达到稳定。

  • BN 和 LN 公式两者公式一样,只是计算方式不一样

xE[x]Var[x]+ϵγ+β\frac{x - \mathbb{E}[x]}{\sqrt{\operatorname{Var}[x] + \epsilon}} * \gamma + \beta

γ 和 β 是可学习参数,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch
import torch.nn as nn
class LayerNorm(nn.Module):
def __init__(self, features, eps = 1e-6):
super.__init__()
# 通过训练中调整 a_2 的值来更改归一化结果的缩放程度,以适应特定任务的需求
# nn.Parameter() 允许将普通的张量转换为可学习的参数
self.a_2 = nn.Parameter(torch.ones(features))
# 通过训练调整 b_2 的值来更改归一化结果的偏移量,以便更好地适应数据分布
self.b_2 = nn.Parameter(torch.zeros(features))
self.eps = eps

def forward(self,x):
# 统计每个样本所有维度的均值,求均值和方差
mean = x.mean(-1,keepdim = True) # mean: [bsz, max_seq, 1]
std = x.std(-1,keepdim = True) # std: [bsz,max_len, 1]

return self.a_2 * (x-mean) / (std + self.eps) + self.b_2

残差连接

由于 Transformer 模型结构较复杂、层数较深,为了避免模型退化,Transformer 采用残差连接来连接每一个子层。

定义: 下一层的输入不仅是上一层的输出,还包括上一层的输入。残差连接允许最底层信息直接传到最高层,让高层专注于残差的学习;

在 Encoder 的第一个子层中,输入会先进行归一化(Layer Norm),然后输入多头自注意力层,其输出会与原输入相加,后续的子层采用同样操作,即:

x=x+MultiHeadSelfAttention(LayerNorm(x))x = x+MultiHeadSelfAttention(LayerNorm(x))

output=x+FNN(LayerNorm(x))output = x+FNN(LayerNorm(x))

在代码实现中,通过在层的 forward 计算中加上原输入值即可实现残差连接:

1
2
3
4
# 注意力计算
x = x + self.attention.forward(self.attention_norm(x))
# 前馈神经网络
out = h + self.feed_froward.forward(self.fnn_norm(x))

注:self.attention_normself.fnn_norm 都是 LayerNorm 层,self.attntion 是注意力层,而 self.feed_forward 是 前馈神经网络。

Encoder

在实现上述基础组建后,可以搭建 Transformer 的 Encoder。

Encoder 由 N 个 Encoder Layer 组成,每个 Encoder Layer 包括一个注意力层和一个前向神经网络。

首先,我们先实现 Encoder Layer 层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class EncoderLayer(nn.Module):
def __init__(self, args):
super().__init__()
# 每个层前面都有一个 LayerNorm, 分别在 Attention之前和 MLP之前
self.attention_norm = LayerNorm(args.n_embd)
# Encoder 不需要掩码,传入 is_causal = Fasle
self.attention = MultiHeadAttention(args, is_causal = False)
self.ffn_norm = LayerNorm(args.n_embd)
self.ffn = MLP(args.dim, args.hidden_dim, args.dropout)

def forward(self, x):
# LayerNorm 层
norm_x = self.attention_norm(x)
# 注意力计算
h = h + self.attention.forward(norm_x,norm_x,norm_x)
# LayerNorm层
norm_h =self.ffn_norm(h)
# 前馈神经网络
out = h + self.ffn.forward(norm_h)
return out

之后我们可以搭建 Encoder,其由 N 个 Encoder Layer 组成,在最后会加上一个 LayerNorm 进行归一化

1
2
3
4
5
6
7
8
9
10
11
12
13
class Encoder(nn.Module):
def __init__(self, args):
super(Encoder, self).__init__()
# 一个 Encoder 由 N 层 Encoder
self.layers = nn.MoudleList([EncoderLayer(args) for _ in range(args.n_layer)])
self.norm = LayerNorm(args.embd)

def forward(self, x):
# 分别通过 N 层 Encoder layer
for layer in self.layers:
x = layer(x)

return self.norm(x)

Decoder

类似的,我们也可以先搭建 Decoder Layer,再将 N 个 Decoder Layer 构建为 Decoder;

但和 Encoder 不同的是,Decoder 由两个注意力层和一个前馈神经网络组成。

  • 第一个注意力层是一个掩码自注意力层,即使用 Mask 的注意力计算,保证每一个 token 只能使用该 token 之前的注意力分数;
  • 第二个注意力层是一个多头注意力层,使用第一个注意力层的输出作为 query,使用 Encoder 的输出作为 key 和 value,计算注意力分数。
  • 最后,再经过前馈神经网络;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class DecoderLayer(nn.Module):
def __init__(self, args):
super().__init__()
# 需要有三个 LayerNorm, 分别在 MaskAttention, Self Attention, FFN 之前
self.atten_norm_1 = LayerNorm(args.n_embd)
# Decoder 的第一部分是 MaskAttention,传入 is_casual=True
self.mask_attention = MultiHeadAttention(args, is_causal=True)
self.atten_norm_2 = LayerNorm(args.n_embd)
# Decoder 的第二部分是类似 Encoder 的 Attention, 传入 is_casual=False
self.attention = MultiHeadAttention(args, is_causal = False)
self.ffn_norm = LayerNorm(args.n_embd)
self.ffn = MLP(args.dim, args.hidden_dim, args.dropout)

def forward(self, x, encoder_out):
# 掩码自注意力
norm_x = self.atten_norm_1(x)
x = x + self.mask_attention.forward(norm_x, norm_x, norm_x)
# 多头自注意力
norm_x = self.atten_norm_2(x)
h = x + self.attention.forward(norm_x, encoder_out, encoder_out)
# 前馈神经网络
out = h + self.ffn.forward(self.ffn_norm(h))
return out

同样的,我们搭建一个 Decoder 块:

1
2
3
4
5
6
7
8
9
10
11
class Decoder(nn.Module):
def __init__(self,args):
super(Decoder, self).__init__()
# 一个 Decoder 由 N 个 Decoder Layer组成
self.layers = nn.ModuleList([DecoderLayer(args) for _ in range(args.n_layers)])
self.norm = LayerNorm(args.n_embd)

def forward(self, x, encoder_out):
for layer in self.layers:
x = layer(x, encoder_out)
return self.norm(x)

完成上述 Encoder、Decoder 的搭建,就完成了 Transformer 的核心部分,接下来将 Encoder、Decoder 拼接起来再加入 Embedding 层就可以搭建出完整的 Transformer 模型。

Embedding 层

在 NLP 任务中,往往需要将自然语言的输入转化为机器可以处理的向量。在深度学习中,承担这个任务的组件就是 Embedding 层。

Embedding 层其实是一个存储固定大小的词典的嵌入向量查找表。

在输入神经网络之前,往往会先让自然语言输入通过分词器 tokenizer,分词器的作用是把自然语言输入切分成 token 并转化成一个固定的 index。

例如,如果我们将词表大小设为 4,输入“我喜欢你”,那么,分词器可以将输入转化成:

1
2
3
4
5
6
7
8
input:
output: 0

input: 喜欢
output: 1

input:
output: 2

因此,Embedding 层的输入往往是一个形状为 (batch_size,seq_len,1)的矩阵:

  • 第一个维度是一次批处理的数量;
  • 第二个维度是自然语言序列的长度;
  • 第三个维度则是 token 经过 tokenizer 转化成的 index 值。

例如,对上述输入,Embedding 层的输入会是:

1
[[[0],[1],[2]]] # batch_size = 1, seq_len = 3

Embedding 内部是一个可训练的(Vocab_size,embedding_dim)的权重矩阵,词表里的每一个值,都对应一行维度为 embedding_dim 的向量, 然后拼接成(batch_size,seq_len,embedding_dim)的矩阵输出。

1
self.token_embeddings = nn.Embedding(args.vocab_size, args.dim)

位置编码

在 RNN、LSTM 中,输入序列会沿着语句本身的顺序被依次递归处理,因此输入序列的顺序提供了极其重要的信息,这也和自然语言的本身特性非常吻合。

注意力机制可以实现并行计算,但在计算过程中,对于序列中的每一个 token,其他各个位置对其来说都是平等的,即“我喜欢你”和“你喜欢我”在注意力机制看来是完全相同的,从而会导致序列中相对位置的丢失

因此,为了保留序列中的相对位置信息,Transformer 采用了位置编码机制,该机制也在之后被多种模型沿用。

位置编码: 即根据序列中 token 的相对位置对其进行编码,再将位置编码加入词向量编码中。

位置编码的方式有很多,Transformer 使用了正余弦函数来进行位置编码(绝对位置编码Sinusoidal),其编码方式为:

PE(pos,2i)=sin(pos/100002i/dmodel)PE(pos,2i+1)=cos(pos/100002i/dmodel)PE(pos, 2i) = sin(pos/10000^{2i/d_{model}})\\ PE(pos, 2i+1) = cos(pos/10000^{2i/d_{model}})

pos 为 token 在句子中的位置,2i 和 2i+1 指示了位置编码向量的维度索引是奇数还是偶数,Transformer 对奇数维度和偶数维度采用了不同的函数进行编码。

优势:

  1. PE 能够适应比训练集里面更长的句子,假设训练集里面最长的句子为 20 个单词,突然来了一个长度为 21 的句子,则使用公式计算的方法可以计算出第 21 位的 Embedding。
  2. 可以让模型容易地计算出相对位置,对于固定长度的间距 k,PE(pos+k) 可以用 PE(pos) 计算得到。因为 Sin(A+B) = Sin(A)Cos(B) + Cos(A)Sin(B), Cos(A+B) = Cos(A)Cos(B) - Sin(A)Sin(B)。

以一个简单的例子来说明位置编码的计算过程:

假如我们输入的是一个长度为 4 的句子"I like to code",可以得到下面的词向量矩阵x\rm x ,其中每一行代表的就是一个词向量,x0=[0.1,0.2,0.3,0.4]\rm x_0=[0.1,0.2,0.3,0.4] 对应的就是“I”的词向量,它的pos就是为0,以此类推,第二行代表的是“like”的词向量,它的pos就是1:

x=[0.10.20.30.40.20.30.40.50.30.40.50.60.40.50.60.7]\rm x = \begin{bmatrix} 0.1 & 0.2 & 0.3 & 0.4 \\ 0.2 & 0.3 & 0.4 & 0.5 \\ 0.3 & 0.4 & 0.5 & 0.6 \\ 0.4 & 0.5 & 0.6 & 0.7 \end{bmatrix}

则经过位置编码后的词向量为:

xPE=[0.10.20.30.40.20.30.40.50.30.40.50.60.40.50.60.7]+[sin(0100000)cos(0100000)sin(0100002/4)cos(0100002/4)sin(1100000)cos(1100000)sin(1100002/4)cos(1100002/4)sin(2100000)cos(2100000)sin(2100002/4)cos(2100002/4)sin(3100000)cos(3100000)sin(3100002/4)cos(3100002/4)]=[0.11.20.31.41.0410.840.411.491.2090.0160.521.590.5410.4890.8951.655]\rm x_{PE} = \begin{bmatrix} 0.1 & 0.2 & 0.3 & 0.4 \\ 0.2 & 0.3 & 0.4 & 0.5 \\ 0.3 & 0.4 & 0.5 & 0.6 \\ 0.4 & 0.5 & 0.6 & 0.7 \end{bmatrix} + \begin{bmatrix} \sin(\frac{0}{10000^0}) & \cos(\frac{0}{10000^0}) & \sin(\frac{0}{10000^{2/4}}) & \cos(\frac{0}{10000^{2/4}}) \\ \sin(\frac{1}{10000^0}) & \cos(\frac{1}{10000^0}) & \sin(\frac{1}{10000^{2/4}}) & \cos(\frac{1}{10000^{2/4}}) \\ \sin(\frac{2}{10000^0}) & \cos(\frac{2}{10000^0}) & \sin(\frac{2}{10000^{2/4}}) & \cos(\frac{2}{10000^{2/4}}) \\ \sin(\frac{3}{10000^0}) & \cos(\frac{3}{10000^0}) & \sin(\frac{3}{10000^{2/4}}) & \cos(\frac{3}{10000^{2/4}}) \end{bmatrix} = \begin{bmatrix} 0.1 & 1.2 & 0.3 & 1.4 \\ 1.041 & 0.84 & 0.41 & 1.49 \\ 1.209 & -0.016 & 0.52 & 1.59 \\ 0.541 & -0.489 & 0.895 & 1.655 \end{bmatrix}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class PositionalEncoding(nn.Module):
def __init__(self,args):
super(PositionalEncoding,self).__init__()
# Dropout层
# self.dropout = nn.Dropout(args.dropout)

# max_len 是序列的最大长度
pe = torch.zeros(args.max_len, args.n_embd)
position = torch.arrange(0, args.max_len).unsqueeze(1)
# 计算theta
div_term = torch.exp(torch.arrange(0, args.n_embd, 2) * - (math.log(10000.0) / args.n_embd))
# 分别计算 sin、cos 结果
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsequeeze(0)
self.register_buffer("pe",pe)

def forward(self, x):
# 将位置编码加到 Embedding 结果上
x = x + self.pe[:, :x.size(1)].requires_grad_(False)
return x

Transformer

将所有组件按照下图拼接就可以搭建一个完整的 Transformer 模型:

但需要注意的是,上图是原论文《Attention is all you need》配图,LayerNorm 层放在了 Attention 层后面,也就是“Post-Norm”结构,但在其发布的源代码中,LayerNorm 层是放在 Attention 层前面的,也就是“Pre Norm”结构。

考虑到目前 LLM 一般采用“Pre-Norm”结构(可以使 loss 更稳定),本文在实现时采用“Pre-Norm”结构。

  • 经过 tokenizer 映射后的输出先经过 Embedding 层和 Positional Embedding 层编码;
  • 然后输入 Encoder 和 Decoder(在 Transformer 原模型中,N 取为 6 );
  • 最后经过一个线性层和一个 Softmax 层得到最终输出。
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
class Transformer(nn.Module):
def __init__(self, args):
super().__init__()
# 必须输入词表大小和 max_len
assert args.vocab_size is not None
assert args.max_len is not None
self.args = args
self.transformer = nn.ModuleList(dict(
wte = nn.Embedding(args.vocab_size, args.n_embd),
wpe = PositionalEncoding(args),
drop = nn.Dropout(args.dropout),
encoder = Encoder(args),
decoder = Decoder(args),
))
# 最后的线性层,输入时 n_embd, 输出是词表的大小
self.lm_head = nn.Linear(args.n_embd, args.vocab_size, bias = False)
# 初始化所有权重
self.apply(self._init_weights)

# 查看所有参数的数量
print("number of parameters: %.2f M" % (self.get_num_params() / 1e6, ))

'''统计所有参数的数量'''
def get_num_params(self, non_embedding = False):
# non_embedding 是否统计 embedding 的参数
n_params = sum(p.numel() for p in self.parameters())
# 如果不统计 embedding 的参数,则减去即可
if non_embedding:
n_params -= self.transformer.wte.weight.numel()
return n_params

'''初始化权重'''
def _init_weights(self, module):
# 线性层和 Embedding 层初始化为正则分布
if isinstance(module, nn.Linear):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
torch.nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
torch.nn.init.normal_(module.weight, mean=0.0, std = 0.02)

def forward(self, idx, targets = None):
# 输入为 idx, 维度为(batch_size, seq_len, 1),
# targets 为目标序列,用以计算loss
device = idx.device
batch_size, seq_len = idx.size()
assert seq_len <= self.args.max_len, f"无法计算该序列,该序列长度为{seq_len}, 最大序列长度只有{self.args.max_len}"

# 通过 self.transformer
# 首先将输入 idx 通过 Embedding 层,得到维度为(batch_size, seq_len, n_embd)
print("idx", idx.size())
# 通过 Embedding 层
tok_emb = self.transformer.wte(idx)
print("tok_emb", tok_emb.size())
# 然后通过位置编码
pos_emb = self.transformer.wpe(tok_emb)
# 进行 Dropout
x = self.transformer.drop(pos_emb)
# 输入 Encoder
enc_out = self.transformer.encoder(x)
print("enc_out", enc_out.size())
# 输入 Decoder
x = self.transformer.decoder(x, enc_out)
print("x after decoder:", x.size())

if targets is not None:
# 训练阶段,如果给了 targets, 则计算 loss
# 先通过最后的 Linear 层, 得到的维度为 (batch_size, seq_len, vocab_size)
logits = self.lm_head(x)
# 再跟 targets 计算交叉熵
# logits.view(-1, logits.size(-1)) 将 logits 展平为 (batch * seq_len, vocab)
# ignore_index = -1,这里的“-1”是填充标记,不计算损失,通常是 padding 部分
loss = F.cross_entropy(logits.view(-1, logits.size(-1)),targets.view(-1), ignore_index = -1)
else:
# 推理阶段,只需要 logits, loss 为 None
# 取 -1 是只取序列中的最后一个作为输出(即下一个要预测的字)
logits = self.lm_head(x[:, [-1], :])
loss = None

return logits, loss

上述代码除搭建整个 Transformer 结构外,还额外实现了三个函数:

  • get_num_params:用于统计模型的参数量
  • _init_weights:用于对模型所有参数进行随机初始化
  • forward:前向计算函数

在前向计算函数中,对模型使用 pytorch 的交叉熵函数来计算损失。

疑问?

为什么归一化要加可学习参数?有什么作用?

归一化会强行把数据变成均值 0、方差 1,但有些特征本来就不应该是标准正态分布,强行拉成 0 均值 1 方差,会丢失信息、限制模型表达能力,加入 γ(缩放)和 β(偏移),就是让模型自己决定:要不要恢复一部分原来的分布。

  • γ=1,β=0 → 完全使用归一化

  • γ=σ,β=μ → 完全取消归一化,恢复原始 x

  • 中间值 → 部分归一化,保留部分原始特征

比如:假设两个词的特征向量(简化成 3 维):

“极其重要”:[10, 12, 11] ,数值大 → 代表语义强度高、权重高

“一般”:[1, 1.2, 0.9]`,数值小 → 代表语义弱、不重要

做 LayerNorm(不带 γ、β)之后,两者都会被强行拉成:

  • 均值 0,方差 1
  • 输出都变成类似 [-0.8, 0.9, 0.1] 这种分布

“极其重要” 和 “一般” 变得几乎一模一样! ,强度信息、重要度信息彻底丢失

生活中的例子:

把所有衣服全部洗干净、熨平、叠成一样大小(强制归一化)。

但每个人穿衣风格不同,有的人喜欢宽松,有的人喜欢紧身。

γ 和 β 就是让模型自己 “重新拉扯、调整版型”,恢复适合自己的风格。

super().init()和super(类名, self).init()有什么区别?

在 Python 3 里,这俩功能完全一样,super().__init__() 是简化写法,更推荐这种写法;

Python 2 必须写完整: 因为 Python 2 不会自动推断类和实例

Python 3 做了智能推断: 你在哪个类里写 super(),它就自动绑定到 这个类 + self

为什么logits = self.lm_head(x[:, [-1], :])取最后一个词去预测下一个词?

假设序列长度 = 3, 索引:0, 1, 2 ,词:A, B, C

模型训练的目标就是:用 C 预测下一个字 D!

给模型看 A B C ,模型的任务是:

1
2
3
A → 预测 B  # 第 0 位 A 的输出 → 学的是预测 B
B → 预测 C # 第 1 位 B 的输出 → 学的是预测 C
C → 预测 D # 第 2 位 C 的输出 → 学的是预测 D