零基础-动手学深度学习-9.7. 序列到序列学习(seq2seq)

python 复制代码
import collections
import math
import torch
from torch import nn
from d2l import torch as d2l

9.7.1. 编码器

python 复制代码
#@save
class Seq2SeqEncoder(d2l.Encoder):
    """用于序列到序列学习的循环神经网络编码器"""
    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)#encoder是不用dense来输出的

    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

循环层返回变量的说明可以参考 8.6节

下面,我们实例化上述编码器的实现: 我们使用一个两层门控循环单元编码器,其隐藏单元数为16。 给定一小批量的输入序列X(批量大小为4,时间步为7)。 在完成所有时间步后, 最后一层的隐状态的输出是一个张量(output由编码器的循环层返回), 其形状为(时间步数,批量大小,隐藏单元数)。

python 复制代码
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
                         num_layers=2)
encoder.eval()
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
output.shape

输出:torch.Size([7, 4, 16])

由于这里使用的是门控循环单元, 所以在最后一个时间步的多层隐状态的形状是 (隐藏层的数量,批量大小,隐藏单元的数量)。 如果使用长短期记忆网络,state中还将包含记忆单元信息。

python 复制代码
state.shape

输出:torch.Size([2, 4, 16])

这里的大小我有点蒙,X 的形状经过 permute 后变成 (num_steps, batch_size, embed_size),GRU 的 output 形状为 (num_steps, batch_size, num_hiddens),它保存了 每个时间步最上面一层 的隐藏状态,就是ht沿着t堆叠在一起。state 的形状为 (num_layers, batch_size, num_hiddens),只保存 最后一个时间步每一层 的隐藏状态。所以,当你看到"最后一层的隐状态的输出是一个张量(output由编码器的循环层返回),其形状为(时间步数,批量大小,隐藏单元数)"时,这里的"最后一层的隐状态"实际上指的是 最后一层在每一个时间步上的隐藏状态 ,因此第一个维度是时间步数 num_steps。

我觉得这里就是打破之前的固定长度的一个死板,你输入无论怎么变化,num_hiddens都是预设好的,我去,我这里混淆了一个概念,num_steps 决定"隐藏向量有多少个",而num_hiddens 决定"隐藏向量有多宽"我彻底搞错了sry

9.7.2. 解码器

python 复制代码
class Seq2SeqDecoder(d2l.Decoder):
    """用于序列到序列学习的循环神经网络解码器"""
    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)#有自己法语的embedding,vs,es不一样
        self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
                          dropout=dropout)#保证隐藏层大小一样concat
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, *args):
        return enc_outputs[1]#enc最后时间步的state传到dec里

    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)#state是最后时刻所有隐藏层的状态。-1在最后时刻最后一层的输出,包括了所有浓缩的信息,repeat(X.shape[0], 1, 1)重复成dec的长度
        X_and_context = torch.cat((X, context), 2)#dec的rnn输入是当前embedding的输出加上编码器最后的上下文平在一起输进去
        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

下面,我们用与前面提到的编码器中相同的超参数来实例化解码器。 如我们所见,解码器的输出形状变为(批量大小,时间步数,词表大小), 其中张量的最后一个维度存储预测的词元分布。

python 复制代码
decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16,
                         num_layers=2)
decoder.eval()
state = decoder.init_state(encoder(X))
output, state = decoder(X, state)
output.shape, state.shape

输出:(torch.Size([4, 7, 10]), torch.Size([2, 4, 16]))#16就是num_hiddens

9.7.3. 损失函数

在每个时间步,解码器预测了输出词元的概率分布。 类似于语言模型,可以使用softmax来获得分布, 并通过计算交叉熵损失函数来进行优化。 回想一下 9.5节中, 特定的填充词元被添加到序列的末尾, 因此不同长度的序列可以以相同形状的小批量加载。 但是,我们应该将填充词元的预测排除在损失函数的计算之外。

为此,我们可以使用下面的sequence_mask函数 通过零值化屏蔽不相关的项, 以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。 例如,如果两个序列的有效长度(不包括填充词元)分别为1和2, 则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。

这个代码太优雅了啊啊啊啊啊啊啊啊啊啊啊啊

python 复制代码
#@save
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

X = torch.tensor([[1, 2, 3], [4, 5, 6]])
sequence_mask(X, torch.tensor([1, 2]))

输出:tensor([[1, 0, 0],
        [4, 5, 0]])

我们还可以使用此函数屏蔽最后几个轴上的所有项。如果愿意,也可以使用指定的非零值来替换这些项。

python 复制代码
X = torch.ones(2, 3, 4)
sequence_mask(X, torch.tensor([1, 2]), value=-1)

输出:tensor([[[ 1.,  1.,  1.,  1.],
         [-1., -1., -1., -1.],
         [-1., -1., -1., -1.]],

        [[ 1.,  1.,  1.,  1.],
         [ 1.,  1.,  1.,  1.],
         [-1., -1., -1., -1.]]])

现在,我们可以通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。 最初,所有预测词元的掩码都设置为1。 一旦给定了有效长度,与填充词元对应的掩码将被设置为0。 最后,将所有词元的损失乘以掩码,以过滤掉损失中填充词元产生的不相关预测。

还是填充的部分没意义

python 复制代码
#@save
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)#有效的保留下来身下全变成0
        self.reduction='none'
        unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
            pred.permute(0, 2, 1), label)
#调父类的loss函数就行 这里维度需要变换一下
        weighted_loss = (unweighted_loss * weights).mean(dim=1)#dim1对每个句子去平均 就是对每个样本求一个loss
        return weighted_loss

我们可以创建三个相同的序列来进行代码健全性检查, 然后分别指定这些序列的有效长度为4、2和0。 结果就是,第一个序列的损失应为第二个序列的两倍,而第三个序列的损失应为零。

python 复制代码
loss = MaskedSoftmaxCELoss()
loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),
     torch.tensor([4, 2, 0]))
#def forward( pred, label, valid_len):

输出:tensor([2.3026, 1.1513, 0.0000])

9.7.4. 训练

在下面的循环训练过程中,如 图9.7.1所示, 特定的序列开始词元("<bos>")和 原始的输出序列(不包括序列结束词元"<eos>") 拼接在一起作为解码器的输入。 这被称为强制教学 (teacher forcing), 因为原始的输出序列(词元的标签)被送入解码器。 或者,将来自上一个时间步的预测得到的词元作为解码器的当前输入。

python 复制代码
#@save
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
    """训练序列到序列模型"""
    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])

    net.apply(xavier_init_weights)
    net.to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    loss = MaskedSoftmaxCELoss()#这里不一样
    net.train()
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                     xlim=[10, num_epochs])
    for epoch in range(num_epochs):
        timer = d2l.Timer()
        metric = d2l.Accumulator(2)  # 训练损失总和,词元数量
        for batch in data_iter:#真正的精华在下面这一块
            optimizer.zero_grad()
            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)  # 强制教学 加一个开头 这样给定区域到下个词 把最后一个词移掉 怎么构造dec的输入和输出就是这里不一样的地方
            Y_hat, _ = net(X, dec_input, X_valid_len)
            l = loss(Y_hat, Y, Y_valid_len)
            l.sum().backward()      # 损失函数的标量进行"反向传播"
            d2l.grad_clipping(net, 1)
            num_tokens = Y_valid_len.sum()
            optimizer.step()
            with torch.no_grad():
                metric.add(l.sum(), num_tokens)
        if (epoch + 1) % 10 == 0:
            animator.add(epoch + 1, (metric[0] / metric[1],))
    print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
        f'tokens/sec on {str(device)}')

现在,在机器翻译数据集上,我们可以 创建和训练一个循环神经网络"编码器-解码器"模型用于序列到序列的学习。

python 复制代码
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, d2l.try_gpu()

train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,
                        dropout)
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,
                        dropout)
net = d2l.EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

输出:loss 0.019, 12745.1 tokens/sec on cuda:0

9.7.5. 预测

python 复制代码
#@save
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
                    device, save_attention_weights=False):
    """序列到序列模型的预测"""
    # 在预测时将net设置为评估模式 先不用看这一段 都是attention要用到的东西
    net.eval()
    src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
        src_vocab['<eos>']]
    enc_valid_len = torch.tensor([len(src_tokens)], device=device)
    src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
    # 添加批量轴 
    enc_X = torch.unsqueeze(
        torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
    enc_outputs = net.encoder(enc_X, enc_valid_len)
    dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)#把enc的state拿出来
    # 添加批量轴 接下来就不扔标签了
    dec_X = torch.unsqueeze(torch.tensor(
        [tgt_vocab['<bos>']], dtype=torch.long, device=device), dim=0)#加个bos开始
    output_seq, attention_weight_seq = [], []
    for _ in range(num_steps):#预测n步
        Y, dec_state = net.decoder(dec_X, dec_state)
        # 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
        dec_X = Y.argmax(dim=2)#预测作为下一步的输入
        pred = dec_X.squeeze(dim=0).type(torch.int32).item()#去掉高纬度变成float
        # 保存注意力权重(稍后讨论)
        if save_attention_weights:
            attention_weight_seq.append(net.decoder.attention_weights)
        # 一旦序列结束词元被预测,输出序列的生成就完成了
        if pred == tgt_vocab['<eos>']:
            break
        output_seq.append(pred)
    return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq

9.7.6. 预测序列的评估

python 复制代码
def bleu(pred_seq, label_seq, k):  #@save
    """计算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):#n------gram
        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

最后,利用训练好的循环神经网络"编码器-解码器"模型, 将几个英语句子翻译成法语,并计算BLEU的最终结果。

python 复制代码
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
    translation, attention_weight_seq = predict_seq2seq(
        net, eng, src_vocab, tgt_vocab, num_steps, device)
    print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')

输出:go . => va !, bleu 1.000
i lost . => j'ai perdu ., bleu 1.000
he's calm . => il est riche ., bleu 0.658
i'm home . => je suis en retard ?, bleu 0.447

9.7.7. 小结

  • 根据"编码器-解码器"架构的设计, 我们可以使用两个循环神经网络来设计一个序列到序列学习的模型。

  • 在实现编码器和解码器时,我们可以使用多层循环神经网络。

  • 我们可以使用遮蔽来过滤不相关的计算,例如在计算损失时。

  • 在"编码器-解码器"训练中,强制教学方法将原始输出序列(而非预测结果)输入解码器。

  • BLEU是一种常用的评估方法,它通过测量预测序列和标签序列之间的n元语法的匹配度来评估预测。

相关推荐
西岸行者6 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
悠哉悠哉愿意6 天前
【单片机学习笔记】串口、超声波、NE555的同时使用
笔记·单片机·学习
别催小唐敲代码6 天前
嵌入式学习路线
学习
毛小茛6 天前
计算机系统概论——校验码
学习
babe小鑫6 天前
大专经济信息管理专业学习数据分析的必要性
学习·数据挖掘·数据分析
winfreedoms6 天前
ROS2知识大白话
笔记·学习·ros2
在这habit之下6 天前
Linux Virtual Server(LVS)学习总结
linux·学习·lvs
我想我不够好。6 天前
2026.2.25监控学习
学习
im_AMBER6 天前
Leetcode 127 删除有序数组中的重复项 | 删除有序数组中的重复项 II
数据结构·学习·算法·leetcode
CodeJourney_J6 天前
从“Hello World“ 开始 C++
c语言·c++·学习