1 总体结构

seq2seq-details.svg

不论是编码器还是解码器,它们都会首先通过嵌入层(Embedding层)处理外部输入,以便转换成相应的词向量。

2 编码器结构

  • vocab_size:词汇表的大小,用于定义嵌入层。
  • embed_size:嵌入层输出的维度,即每个词汇的向量表示大小。
  • num_hiddens:循环神经网络(RNN)隐藏层的大小。
  • num_layers:RNN的层数。
  • dropout:dropout概率,用于防止过拟合。
class Seq2SeqEncoder(nn.Module):
    """用于序列到序列学习的循环神经网络编码器"""
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs):
        super(Seq2SeqEncoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=dropout)

    def forward(self, X, *args):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        X = self.embedding(X)
        # 在循环神经网络模型中,第一个轴对应于时间步
        X = X.permute(1, 0, 2)
        # 如果未提及状态,则默认为0
        output, state = self.rnn(X)
        # output的形状:(num_steps,batch_size,num_hiddens)
        # state的形状:(num_layers,batch_size,num_hiddens)
        return output, state

3 解码器结构

  • 前向传播方法​:
    • 输入X是一个形状为(batch_size, num_steps)的张量,代表一批目标输入序列,通过嵌入层将输入序列转换为形状为(batch_size, num_steps, embed_size)的张量,并重新排列维度为(num_steps, batch_size, embed_size)
    • 然后,将编码器的最后一个隐藏状态(context)广播到与输入序列相同的步数,以便在每个时间步上都可以使用。
    • 接着,将嵌入后的输入序列和广播后的context在特征维度上连接,形成新的输入X_and_context
    • X_and_context和初始状态传递给GRU层,得到输出output和新的隐藏状态state
    • 最后,通过全连接层dense将GRU层的输出转换为形状为(num_steps, batch_size, vocab_size)的张量,并再次重新排列维度为(batch_size, num_steps, vocab_size)
  • 注意
    • 在前向传播中,context即上下文,在这种非注意力解码器中实际上并不存在上下文的概念,它仅使用最新的隐藏状态作为context
class Seq2SeqDecoder(nn.Module):
    """用于序列到序列学习的循环神经网络解码器"""
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs):
        super(Seq2SeqDecoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers, dropout=dropout)
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, *args):
        return enc_outputs[1]

    def forward(self, X, state):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        X = self.embedding(X).permute(1, 0, 2)
        # 广播context,使其具有与X相同的num_steps
        context = state[-1].repeat(X.shape[0], 1, 1)
        X_and_context = torch.cat((X, context), 2)
        output, state = self.rnn(X_and_context, state)
        output = self.dense(output).permute(1, 0, 2)
        # output的形状:(batch_size,num_steps,vocab_size)
        # state的形状:(num_layers,batch_size,num_hiddens)
        return output, state

4 组合

将编码器解码器组合起来,形成整体结构

  • 注意:
    • enc_outputs包含了enc_output以及隐藏状态state
    • 而解码器的隐藏状态初始化只使用了编码器的隐藏状态state
    • enc_X一般是输入的序列,dec_X一般是输出的目标序列
class EncoderDecoder(nn.Module):
def __init__(self, encoder, decoder, **kwargs):
super(EncoderDecoder, self).__init__(**kwargs)
self.encoder = encoder
self.decoder = decoder
def forward(self, enc_X, dec_X, *args):
    enc_outputs = self.encoder(enc_X, *args)
    dec_state = self.decoder.init_state(enc_outputs, *args)
    return self.decoder(dec_X, dec_state)

5 损失函数

其中sequence_mask的valid_len(可以是熟组)表示的是每一个序列保留的长度

def sequence_mask(X, valid_len, value=0):
    """在序列中屏蔽不相关的项"""
    maxlen = X.size(1)
    mask = torch.arange((maxlen), dtype=torch.float32, device=X.device)[None, :] < valid_len[:, None]
    X[~mask] = value
    return X

在损失函数中,weights实际上起到了遮罩的作用,它遮盖了交叉熵损失中不需要计算的部分,以防止计算出错误的损失值

class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
    """带遮蔽的softmax交叉熵损失函数"""
    # pred的形状:(batch_size,num_steps,vocab_size)
    # label的形状:(batch_size,num_steps)
    # valid_len的形状:(batch_size,)
    def forward(self, pred, label, valid_len):
        weights = torch.ones_like(label)
        weights = sequence_mask(weights, valid_len)
        self.reduction='none'
        unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(pred.permute(0, 2, 1), label)
        weighted_loss = (unweighted_loss * weights).mean(dim=1)
        return weighted_loss

6 训练(只展示关键部分)

  • 权重初始化
def xavier_init_weights(m):
        if type(m) == nn.Linear:
            nn.init.xavier_uniform_(m.weight)
        if type(m) == nn.GRU:
            for param in m._flat_weights_names:
                if "weight" in param:
                    nn.init.xavier_uniform_(m._parameters[param])
  • 梯度裁剪函数
def grad_clipping(net, theta):
    if isinstance(net, nn.Module):
        params = [p for p in net.parameters() if p.requires_grad]
    else:
        params = net.params
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm
  • 训练的关键部分
X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0], device=device).reshape(-1, 1)
dec_input = torch.cat([bos, Y[:, :-1]], 1)  # 使用 <bos> 作为每一个解码器输入的起始词元

optimizer.zero_grad()
Y_hat, _ = net(X, dec_input, X_valid_len)
l = loss(Y_hat, Y, Y_valid_len)
l.sum().backward()      # 计算梯度
grad_clipping(net, 1)   # 梯度裁剪
optimizer.step()

7 评估

计算BLEU来作为评估依据

def bleu(pred_seq, label_seq, k):
    """计算BLEU"""
    pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
    len_pred, len_label = len(pred_tokens), len(label_tokens)
    score = math.exp(min(0, 1 - len_label / len_pred))
    for n in range(1, k + 1):
        num_matches, label_subs = 0, collections.defaultdict(int)
        for i in range(len_label - n + 1):
            label_subs[' '.join(label_tokens[i: i + n])] += 1
        for i in range(len_pred - n + 1):
            if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
                num_matches += 1
                label_subs[' '.join(pred_tokens[i: i + n])] -= 1
        score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
    return score