【自然语言处理(NLP)】seq2seq、编解码器原理及代码实现

个人主页:道友老李
欢迎加入社区:道友老李的学习社区

介绍

**自然语言处理(Natural Language Processing,NLP)**是计算机科学领域与人工智能领域中的一个重要方向。它研究的是人类(自然)语言与计算机之间的交互。NLP的目标是让计算机能够理解、解析、生成人类语言,并且能够以有意义的方式回应和操作这些信息。

NLP的任务可以分为多个层次,包括但不限于:

  1. 词法分析:将文本分解成单词或标记(token),并识别它们的词性(如名词、动词等)。
  2. 句法分析:分析句子结构,理解句子中词语的关系,比如主语、谓语、宾语等。
  3. 语义分析:试图理解句子的实际含义,超越字面意义,捕捉隐含的信息。
  4. 语用分析:考虑上下文和对话背景,理解话语在特定情境下的使用目的。
  5. 情感分析:检测文本中表达的情感倾向,例如正面、负面或中立。
  6. 机器翻译:将一种自然语言转换为另一种自然语言。
  7. 问答系统:构建可以回答用户问题的系统。
  8. 文本摘要:从大量文本中提取关键信息,生成简短的摘要。
  9. 命名实体识别(NER):识别文本中提到的特定实体,如人名、地名、组织名等。
  10. 语音识别:将人类的语音转换为计算机可读的文字格式。

NLP技术的发展依赖于算法的进步、计算能力的提升以及大规模标注数据集的可用性。近年来,深度学习方法,特别是基于神经网络的语言模型,如BERT、GPT系列等,在许多NLP任务上取得了显著的成功。随着技术的进步,NLP正在被应用到越来越多的领域,包括客户服务、智能搜索、内容推荐、医疗健康等。

编码器和解码器架构

在自然语言处理(NLP)中,编码器和解码器架构是非常重要的组成部分,常见的有基于循环神经网络(RNN)及其变体的架构和基于 Transformer 的架构。
在这里插入图片描述

基于循环神经网络(RNN)及其变体的编码器-解码器架构

  • 编码器
    • 工作原理:RNN编码器按顺序处理输入序列中的每个元素,将当前输入和上一时刻的隐藏状态结合起来,通过非线性变换更新隐藏状态。对于长序列输入,长短期记忆网络(LSTM)和门控循环单元(GRU)等RNN变体能够更好地处理长期依赖问题。以LSTM为例,它通过输入门、遗忘门和输出门来控制信息的流动,决定哪些信息需要保留、哪些需要遗忘,从而更有效地捕捉输入序列中的长期依赖关系。
    • 输出:编码器最后一个时间步的隐藏状态通常被视为整个输入序列的固定长度向量表示,即上下文向量,它包含了输入序列的全局信息。
  • 解码器
    • 工作原理:解码器以编码器输出的上下文向量作为初始状态,开始生成输出序列。在每个时间步,解码器根据当前的隐藏状态、上一时刻的输出以及上下文向量来预测当前位置的输出单词。它也是一个循环神经网络结构,通过不断更新隐藏状态来生成下一个输出。
    • 训练方式:在训练过程中,使用教师强制(teacher forcing)方法,即强制解码器在每个时间步输入正确的目标单词作为下一个输入,以引导解码器学习正确的输出序列模式。在推理阶段,则根据上一时刻的预测结果作为当前时刻的输入来生成序列。

代码示例

# 编码器
from torch import nn

# 构建编码器的基类
class Encoder(nn.Module):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        
    def forward(self, X, *args):
        raise NotImplementedError

# 解码器
class Decoder(nn.Module):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        
    def init_state(self, enc_outputs, *args):
        raise NotImplementedError
        
    def forward(self, X, state):
        raise NotImplementedError

# 合并编码器和解码器
class EncoderDecoder(nn.Module):
    def __init__(self, encoder, decoder, **kwargs):
        super().__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)

序列到序列学习(seq2seq)

Seq2Seq(Sequence to Sequence)模型是自然语言处理中一种重要的基于编码器-解码器架构的模型。

基本原理

使用循环神经网络编码器和循环神经网络解码器的序列到序列学习:
在这里插入图片描述

编码器

通常采用RNN、LSTM、GRU等结构,将输入序列(如一句话)逐个时间步地进行处理,把输入序列中的信息压缩编码成一个固定长度的向量表示,这个向量可以看作是对整个输入序列的一个总结性特征向量,包含了输入序列中的语义等信息。

在这里插入图片描述
第一个公式 h t = f ( x t , h t − 1 ) h_t = f(x_t, h_{t - 1}) ht=f(xt,ht1)

  • 解析:在序列处理中, t t t 表示时间步。 x t x_t xt 是在时间步 t t t 的输入, h t − 1 h_{t - 1} ht1 是上一个时间步 t − 1 t - 1 t1 的隐藏状态 , h t h_t ht 是当前时间步 t t t 更新后的隐藏状态。函数 f f f 是一个非线性变换函数,它将当前输入 x t x_t xt 和上一时刻隐藏状态 h t − 1 h_{t - 1} ht1 进行融合,通过激活函数等操作产生新的隐藏状态 h t h_t ht,从而使得模型能够捕捉序列中的时间依赖信息。

第二个公式 c = q ( h 1 , . . . , h T ) c = q(h_1, ..., h_T) c=q(h1,...,hT)

  • 解析:这里 h 1 , . . . , h T h_1, ..., h_T h1,...,hT 是从时间步 1 1 1 T T T 的隐藏状态序列, c c c 是通过函数 q q q 对这些隐藏状态进行处理后得到的上下文向量。函数 q q q 可以是多种形式,比如简单的加权求和,或者在注意力机制中是更为复杂的计算,用于整合整个序列的隐藏状态信息,将序列的信息浓缩到一个向量 c c c 中,这个上下文向量后续可能会被用于解码器生成输出等操作。

解码器

同样基于RNN等结构,以编码器输出的向量作为初始状态,逐步生成目标序列。在每个时间步,解码器根据当前的隐藏状态、上一时刻的输出以及编码器的输出向量来预测当前位置的输出,不断地生成下一个单词或符号,直到达到预设的结束条件,如生成特定数量的单词、遇到结束标记等。

在这里插入图片描述

第一个公式 s t ′ = g ( y t ′ − 1 , c , s t ′ − 1 ) s_{t'} = g(y_{t' - 1}, c, s_{t' - 1}) st=g(yt1,c,st1)

  • 解析:在解码器中, t ′ t' t 表示解码过程中的时间步。 y t ′ − 1 y_{t' - 1} yt1 是上一个时间步 t ′ − 1 t' - 1 t1 生成的输出(比如一个单词); c c c 是编码器输出的上下文向量,包含了输入序列的整体信息; s t ′ − 1 s_{t' - 1} st1 是上一个时间步 t ′ − 1 t' - 1 t1 的解码器隐藏状态。函数 g g g 是一个非线性变换函数,它将上一时刻生成的单词 y t ′ − 1 y_{t' - 1} yt1、上下文向量 c c c 以及上一时刻的隐藏状态 s t ′ − 1 s_{t' - 1} st1 进行融合,更新得到当前时间步 t ′ t' t 的解码器隐藏状态 s t ′ s_{t'} st ,以便捕捉生成序列的信息以及结合输入序列信息来生成后续内容。

第二个公式 P ( y t ′ ∣ y 1 , . . . , y t ′ − 1 , c ) P(y_{t'}|y_1, ..., y_{t' - 1}, c) P(yty1,...,yt1,c)

  • 解析:这是在给定已经生成的输出序列 y 1 , . . . , y t ′ − 1 y_1, ..., y_{t' - 1} y1,...,yt1 以及上下文向量 c c c 的条件下,预测当前时间步 t ′ t' t 生成单词 y t ′ y_{t'} yt 的概率。它反映了解码器在生成过程中,根据之前生成的内容和输入序列的整体信息,对下一个单词的预测能力。在实际应用中,通常会通过计算这个概率分布,并选择概率最高的单词作为当前时间步的输出。

编码器-解码器

基于循环神经网络(RNN)的编码器 - 解码器架构图:
在这里插入图片描述

编码器部分

  • 嵌入层(Embedding Layer):将源序列(如源语言句子中的单词)从离散的符号表示转换为低维、连续的向量表示,即词嵌入。这样能让模型更好地捕捉单词之间的语义关系,同时降低数据维度,提高计算效率。
  • 循环层(Recurrent Layer):通常由RNN、LSTM或GRU等单元构成。它按顺序处理嵌入层输出的向量序列,每个时间步的输入结合上一时刻的隐藏状态进行计算,更新隐藏状态,从而将源序列的信息逐步编码到隐藏状态中,最终输出一个固定长度的向量,该向量包含了源序列的语义信息。

解码器部分

  • 嵌入层(Embedding Layer):和编码器的嵌入层作用类似,将目标序列(如目标语言句子中的单词)的离散符号转换为向量表示。不过这里的嵌入层是针对目标语言的。
  • 循环层(Recurrent Layer):接收编码器输出的向量作为初始隐藏状态,然后按顺序处理目标序列的嵌入向量。在每个时间步,根据当前输入、上一时刻隐藏状态以及编码器的输出,更新隐藏状态,生成目标序列的下一个元素的预测。
  • 全连接层(Fully - Connected Layer):循环层的输出经过全连接层,将隐藏状态映射到目标词汇表的维度,通过softmax函数计算出每个单词在词汇表中的概率分布,从而预测出当前时间步最可能的输出单词。

这种架构常用于机器翻译、文本摘要等序列到序列(Seq2Seq)的自然语言处理任务。

代码实现

导包

import collections
import math
import torch
import dltools

编码器

# seq2seq encoder
class Seq2SeqEncoder(Encoder):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs):
        super().__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的形状. 在进行embedding之前, X的形状是多少? 一般都是(batch_size, num_steps, vocab_size)
        # X经过embedding处理, X的形状: (batch_size, num_steps, embed_size)
        X = self.embedding(X)
        #  根据文档说明, 默认是: (num_steps, batch_size, embed_size), 所以要调换一下维度. 
        X = X.permute(1, 0, 2)
        
        # 没有手动传入state, 这时, pytorch会帮我们完成隐藏状态的初始化, 即0. 
        output, state = self.rnn(X)
        # output的形状: (num_steps, batch_size, num_hiddens)
        # state[0]的形状: (num_layers, batch_size, num_hiddens)
        return output, state
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
encoder.eval()
# batch_size=4, num_steps=7
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
output.shape
torch.Size([7, 4, 16])

解码器

class Seq2SeqDecoder(Decoder):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs):
        super().__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经过embedding处理, X的形状: (batch_size, num_steps, embed_size)
        X = self.embedding(X)
        # 调整维度顺序
        X = X.permute(1, 0, 2)
        
        # 把X和state拼接到一起. 方便计算. 
        # X现在的形状(num_steps, batch_size, embed_size) 最后一层state的形状(batch_size, num_hiddens)
        # 要把state的形状扩充成三维. 变成(num_steps, batch_size, embed_size)
        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
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]))

训练

seq2seq的训练: pytorch中训练代码, 都是一个套路.

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

重写交叉熵损失, 添加屏蔽无效内容的部分

class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
    # 重写forward
    # 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)
        # 先调用原始的交叉熵损失, 就可以计算没有被mask的损失. 
        self.reduction = 'none'
        unweighted_loss = super().forward(pred.permute(0, 2, 1), label)
        weighted_loss = (unweighted_loss * weights).mean(dim=1)
        return weighted_loss

loss = MaskedSoftmaxCELoss()
loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long), torch.tensor([4, 2, 0]))
tensor([2.3026, 1.1513, 0.0000])

训练:

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()
    animator = dltools.Animator(xlabel='epoch', ylabel='loss', xlim=[10, num_epochs])
    
    for epoch in range(num_epochs):
        timer = dltools.Timer()
        metric = dltools.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)
            # 开头加上了bos, 那么Y就要去掉最后一列, 保证序列的长度不变. 
            dec_input = torch.cat([bos, Y[:, :-1]], 1)
            Y_hat, _ = net(X, dec_input, X_valid_len)
            # 计算损失
            l = loss(Y_hat, Y, Y_valid_len)
            # 反向传播
            l.sum().backward()
            # 梯度裁剪
            dltools.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)}')


embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 500, dltools.try_gpu()

train_iter, src_vocab, tgt_vocab = dltools.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 = EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

预测和评估

def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps, device):
    # 预测的时候需要把net设置为评估模式
    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 = dltools.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)
    
    # 给预测结果也提前加一个批次维度. 
    dec_X = torch.unsqueeze(torch.tensor([tgt_vocab['<bos>']], dtype=torch.long, device=device), dim=0)
    
    output_seq = []
    for _ in range(num_steps):
        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()
        if pred == tgt_vocab['<eos>']:
            break
        output_seq.append(pred)
    return ' '.join(tgt_vocab.to_tokens(output_seq))

seq2seq的评估指标 bleu

seq2seq的评估指标: BLEU: bilingual evaluation understudy 双语互译质量评估辅助工具
在这里插入图片描述
label: A、B、C、D、E、F
predict: A、B、B、C、D

def bleu(pred_seq, label_seq, k):
    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

开始预测

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 = 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 a <unk> ., bleu 0.000
i'm home . => je suis chez moi ., bleu 1.000
评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

道友老李

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值