基于seq2seq+attention实现文本摘要

  • 任务描述: 自动摘要是指给出一段文本,我们从中提取出要点,然后再形成一个短的概括性的文本
image.png

image.png https://github.com/pytorch/text/releases/tag/v0.9.0-rc5

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import spacy
 
from torchtext.legacy.datasets import Multi30k
from torchtext.legacy.data import Field,Iterator,BucketIterator,TabularDataset

import pandas as pd
import numpy as np

import random
import math
import time
# 全局初始化配置参数。 固定随机种子, 使得每次运行的结果相同
SEED = 22

random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)

if torch.cuda.is_available():
    torch.cuda.manual_seed_all(SEED)
    torch.backends.cudnn.deterministic = True

数据准备

  • 数据整理
  • 数据说明
  • 数据预处理

数据整理

data_train_path = "./dataset/train.csv"
data_test_path = "./dataset/test.csv"
data_val_path = "./dataset/val.csv"

数据说明

data_train = pd.read_csv(data_train_path,encoding="utf-8")
data_train.head()
document summary
0 jason blake of the islanders will miss the res... blake missing rest of season
1 the u.s. military on wednesday captured a wife... u.s. arrests wife and daughter of saddam deput...
2 craig bellamy 's future at west ham appeared i... west ham drops bellamy amid transfer turmoil
3 cambridge - when barack obama sought advice be... in search for expertise harvard looms large
4 wall street held on to steep gains on monday ,... wall street ends a three-day losing streak

数据预处理

  • 构建分词函数
  • 构建预处理格式
  • 载入数据
  • 构建数据迭代器
  • 构建词表

构建分词函数

# 加载spacy的英文处理包
spacy_en = spacy.load('en_core_web_sm')
# 构建分词函数, 返回文本里包含的所有词组的列表
def tokenize(text):
    return [tok.text for tok in spacy_en.tokenizer(text)]

构建预处理格式

torchtext的Field函数可以构建预处理格式
  • sequential:代表是否需要将数据序列化,大多数自然语言处理任务都是序列计算
  • tokenize:需要传入分词函数,传入之前定义的tokenize函数
  • lower:代表是否转换成小写,为了统一处理,把所有的字符转换成小写
  • include_lengths:代表是否返回序列的长度,在gpu计算中,通常是对矩阵的运算,因此每个batch中,矩阵的长度为该batch中所有数据里最长的长度,其他长度不够的数据通常用pad字符补齐,这就会导致矩阵中有很多pad字符。为了后续的计算中把这些pad字符规避掉,我们需要返回每个数据的真实长度,这里的长度是指分词后每个文本中词组的数量
  • init_token:传入起始符号,自然语言处理的任务中通常需要在文本的开头加入起始符号,作为句子的开始标记
  • eos_token:传入结束符号,自然语言处理的任务中通常需要在文本的加入结束符号,作为句子的结束标记
  • pad_token:传入pad符号,用来补全长度不够的文本,默认为 <pad>
  • unk_token:传入unk符号,默认为 <unk>。自然语言处理任务中,往往有一些词组不在我们构建的词表中,这种现场叫做00V(Out Of Vocabulary),用一个unk字符来表示这些字符。
DOCUMENT = Field(sequential=True, 
                tokenize=tokenize,
                lower=True,
                include_lengths=True,
               init_token='<sos>',
               eos_token='<eos>')
SUMMARY = Field(sequential=True, 
                tokenize=tokenize,
                lower=True,
                include_lengths=True,
               init_token='<sos>',
               eos_token='<eos>')

载入数据

fields = [("document",DOCUMENT),("summary",SUMMARY)]
train = TabularDataset(path=data_train_path, format="csv", fields=fields, skip_header=True)
val = TabularDataset(path=data_val_path, format="csv", fields=fields, skip_header=True)
test = TabularDataset(path=data_test_path, format="csv", fields=fields, skip_header=True)

构建数据迭代器

BucketIterator会自动将长度类似的文本归在一个batch,这样可以减少补全字符pad的数量,易于计算
  • train:传入之前用TabularDataset载入的数据
  • batch_size:传入每个批次包含的数据数量
  • device:代表传入数据的设备,可以选择gpu或者cpu
  • sort_within_batch:代表是否对一个批次内的数据排序
  • sort_key:排序方式,由于要使用到pack_padded_sequence用来规避pad符号,而pack_padded_sequence需要数据以降序的形式排列,所以这里用document的长度进行降序。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
device
device(type='cpu')
BATCH_SIZE = 100 // 20
train_iter = BucketIterator(train, batch_size=BATCH_SIZE, device=device, sort_key = lambda x :len(x.document), sort_within_batch=True)
val_iter = BucketIterator(val,batch_size=BATCH_SIZE, device=device, sort_key = lambda x:len(x.document), sort_within_batch=True)
test_iter = BucketIterator(test,batch_size=BATCH_SIZE, device=device, sort_key = lambda x:len(x.document), sort_within_batch=True)

构建词表

往往将字符转换成数字,需要构建词表,用以用数字表示每个词组,并用来训练embedding。 - 在训练集上构建词表,频次低于min_freq的词组会被过滤。 - 构建完词表后会自动将迭代器数据中的字符转换成单词在词表中的序号。

在这里,我们对document和summary分别单独构建了词表,也可以只构建一个词表,使document和summary共享词表。

DOCUMENT.build_vocab(train,min_freq= 2)
SUMMARY.build_vocab(train,min_freq=2)
DOCUMENT.vocab.itos[:100]
['<unk>',
 '<pad>',
 '<sos>',
 '<eos>',
 'the',
 '#',
 '.',
 ',',
 'a',
 'of',
 'to',
 'in',
 'and',
 'on',
 "'s",
 '-',
 'for',
 'said',
 'that',
 'with',
 'at',
 'an',
 '`',
 'as',
 'by',
 'from',
 'has',
 'his',
 'tuesday',
 'wednesday',
 'thursday',
 'its',
 'monday',
 'was',
 '<',
 '>',
 'unk',
 'is',
 'friday',
 'president',
 '-lrb-',
 '-rrb-',
 'after',
 'new',
 'will',
 'it',
 'two',
 'government',
 'their',
 'have',
 'u.s',
 'over',
 "''",
 'minister',
 'year',
 'china',
 'world',
 'first',
 'sunday',
 'he',
 'who',
 'saturday',
 'be',
 'here',
 'were',
 'against',
 'this',
 'people',
 'officials',
 'up',
 'are',
 'more',
 'country',
 'us',
 'united',
 'police',
 'percent',
 'one',
 'state',
 'reported',
 'into',
 'million',
 'last',
 'three',
 'official',
 'been',
 'than',
 'had',
 'not',
 'would',
 'but',
 'years',
 'about',
 'former',
 'prime',
 'states',
 'they',
 'international',
 'day',
 'week']

模型

  • 模型概述
  • 模型结构定义
  • 模型实例化
  • 查看模型

模型该书

  • seq2seq是一个Encoder–Decoder结构的网络,它的输入是一个序列,输出也是一个序列,seq2seq最早应用在翻译模型中,输入原文,输出为翻译后的译文。

  • attention机制的用途是建立生成的译文中的每个单词和原文每个单词的联系,通过这种依赖关系,生成更精准的译文,seq2seq的结构如下图所示: image.png

    • 左边为Encoder,是由rnn组成,顺序输入原文中单词的embedding,对于每个位置都输出一个hidden state \(h_i\) 作为这个状态的表示,这个状态包含了之前所有单词的信息,待序列中所有的单词计算完后,Encoder输出一个变量 \(h\) 作为整个序列的表示,这个变量可以直接是最后一个状态的表示,也可以对所有状态进行融合,将它们变换成一个固定维度的矩阵。
    • 右边是Decoder,它第一个状态的输入为Encoder的输出 \(h\) 和 [单词的embedding; attention],方括号内表示两个变量的连接,输出为 \(s_j\) ,用[s_j; attention; embedding]预测这一步生成的单词,这里为了图像整体的简洁,没有画出attention对后面的状态的连接,实际上每一步生成都要连接attention。
    • Decoder和Encoder之间有一个attention,在最早的seq2seq模型中是没有attention的,Decoder直接接收Encoder的输出 \(h\) ,这在生成译文单词的前期效果不错,但是随着生成单词的增多,rnn会逐渐遗忘掉 \(h\) 的信息,这会导致生成的单词不够精准,而且无法建立原文和译文每个单词对应的关系,而attention由于在生成的每一步都会引入到生产过程中,并且每一步都计算Decoder的状态和Encoder每个状态的相似度用来建立关系,不仅使得生成效果更好,而且具有更强的可解释性,在很多翻译实验中,会把attention保存起来,建立一个attention的词表来观测不同语种单词之间的对应关系。

模型结构定义

Encoder
image.png
Decoder
  • 这里为了获得上下文的语义表示,用了双向RNN,包含从sos向eos的前向RNN和从eos向sos的后向RNN,每个状态的表示变为前向RNN的输出和后向RNN的输出的连接 \([\vec{h_i}; \mathop{h_i} \limits ^{\leftarrow}]\),这样每个状态都包含了来自前文和后文的语义信息。

  • Encoder的内部由RNN组成,RNN的形式为: \(h_i = RNN(h_{i-1},e(x_i))\)

  • 输入为前一个状态表示和这一步的单词的embedding。 \(h_0\) 为一个全0矩阵,这里的RNN也可以替换成LSTM或者GRU。待所有的单词输入完毕后,Encoder会计算一个序列整体的表示,这里将前向RNN的最终输出和后向RNN输出的连接 \([\vec{h}; \mathop{h} \limits ^{\leftarrow}]\) 传入到一个全连接层进行变换,转换成Decoder输出的大小: \(hidden = tanh(w[\vec{h}; \mathop{h} \limits ^{\leftarrow}] + b )\)

  • Encoder函数构建一个encoder,内部RNN使用了torch内置的GRU,参数为:

    • input_dim:输入词表的大小
    • emb_dim:embedding的维度
    • enc_hid_dim:隐藏层的大小
    • dropout:dropout的概率
  • forward参数:

    • doc:原文数据,是已经由词通过词表转换成序号的数据
    • doc_len:每个数据的真实长度,在计算RNN时,可以只计算相应长度的状态,不计算pad符号
  • forword输出Encoder整体的输出,以及Encoder每个状态的输出。每个状态的输出用来计算后续的attention。

  • 值得注意的是,为了规避掉后续计算attention时受到序列中存在pad符号的影响,这里应用了nn.utils的pad_paddad_sequence方法,可以去掉doc_len以后的pad符号,这里pad_packed_sequence的输入为单词序列的embedding和序列的真实长度,这样在计算序列时,就不会计算doc_len后的pad符号了。在计算完RNN后,为了形成一个矩阵方便GPU计算,会把每个doc_len < max_len 的序列填充起来,这里使用了pad_packed_sequence方法,输入为RNN计算后的序列packed_outputs,在后续的attention计算时,会把填充的信息规避掉。

# encoder的输入为原文, 输出为hidden_state, size需设置
class Encoder(nn.Module):
    def __init__(self,input_dim,emb_dim,enc_hid_dim, dec_hid_dim,dropout):
        super().__init__()
    
        # 定义embedding层, 直接使用 torch.nn.Embedding函数
        self.embedding = nn.Embedding(input_dim,emb_dim)

        # 定义rnn层, 使用torch.nn.GRU
        self.rnn = nn.GRU(emb_dim,enc_hid_dim,bidirectional=True)

        # 定义一个 全连接层, 用来 将encoder的输出转换成 decoder输入的大小
        self.fc = nn.Linear(enc_hid_dim * 2 ,dec_hid_dim)

        # 定义dropout层, 防止过拟合
        self.dropout = nn.Dropout(dropout)
        
    def forward(self,doc,doc_len):
        embedded = self.dropout(self.embedding(doc))
        
        packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded,doc_len)
        
        # packed_outputs 包含了每个RNN中每个状态的输出,如图中的h1,h2,h3...hn
        # hidden只有最后的输出hn
        packed_outputs, hidden = self.rnn(packed_embedded)
        
        outputs, _ = nn.utils.rnn.pad_packed_sequence(packed_outputs)
        
        hidden = torch.tanh(self.fc(torch.cat((hidden[-2,:,:], hidden[-1,:,:]),dim=1)))
        
        return outputs,hidden
attention
  • attention机制可以建立Decoder的状态 \(s_i\) 和Encoder每个状态 \(h_j\) 的关系,如下图所示: image.png 这里计算 \(s_2\) 和 Encoder中每个状态的关系,需要用到 \(s_1\) 的信息,先计算Decoder中 \(s_{i-1}\) 和 Encodr状态 \(h_{j}\) 的相似度: \(e_{ij} = a(s_{i-1}, hj)\)\([s_{i-1};h_{j}]\) 传入至一个全连接层计算相似度。 然后将\(s_{i-1}\) 和 Encoder中每个状态的相似度做一个softmax变化,得到每个Encoder中每个状态所占的权重,作为attention: \(\alpha_{ij} = \frac{exp(e_{ij})}{\sum^{T}_{k = 1}(exp(e_{ik}))}\) attention中的每个权重会用来计算context vector,即上下文的向量: \(c_i = \sum_{k = 1}^{T} \alpha_{ij} h_j\) 这个context vector会在Decoder中作为一部分输入。

  • 构建Attention类,参数:

    • enc_hid_dim:encoder每个位置输出的维度
    • dec_hid_dim:decoder每个位置输出的维度
  • forward的参数:

    • hidden:decoder里rnn前一个状态的输出
    • encoder_outs:encoder里rnn的输出
    • mask:mask矩阵,里面存储的是0-1矩阵,0代表被规避的pad符号的位置
  • forword的输出为attention中的每个权重,context vector的计算在下面的Decoder类

class Attention(nn.Module):
    def __init__(self,enc_hid_dim,dec_hid_dim):
        super().__init__()
        self.attn = nn.Linear((enc_hid_dim * 2) + dec_hid_dim, dec_hid_dim)
        self.v = nn.Linear(dec_hid_dim, 1, bias= False)
        
    def forward(self,hidden, encoder_outputs, mask):
        batch_size = encoder_outputs.shape[1]
        doc_len = encoder_outputs.shape[0]
        
        # 对decoder的状态重复doc_len次,用来计算和每个encoder状态的相似度
        hidden = hidden.unsqueeze(1).repeat(1,doc_len,1)
        
        encoder_outputs = encoder_outputs.permute(1,0,2)
        # 使用全连接层计算相似度
        energy = torch.tanh(self.attn(torch.cat((hidden,encoder_outputs), dim=2)))
        
        # 转换尺寸 [batch, doc_len]的形式作为 和每个encoder状态的相似度
        attention = self.v(energy).squeeze(2)
        
        # 规避encoder里的 pad符号, 将这些位置的权重值降到最低
        attention = attention.masked_fill(mask ==0, -1e10)
        
        # 返回权重
        return F.softmax(attention,dim=1)

decoder

  • Decoder接收之前的状态信息、输入的单词和context vector,预测生成摘要的单词,结构如下所示: image.png
  • Decoder的RNN与Encoder中的RNN有所不同,输入为[前一步生成单词的embedding;context vector]和前一步的状态 hi−1hi−1h_{i-1},
  • 目的是引入attention的信息:si=RNN([e(yi−1);c],si−1)si=RNN([e(yi−1);c],si−1)s_i = RNN([e(y_{i-1});c],s_{i-1})
  • 在预测生成的单词时,将context vector、 RNN的输出状态、前一步生成单词的embedding连接起来输入至全连接层预测:yi=softmax(w[c;si;e(yi−1)]+b)yi=softmax(w[c;si;e(yi−1)]+b)y_i = softmax(w[c;s_i;e(y_{i-1})] + b)
  • 构建Decoder类,参数为:
    • output_dim:输出的维度,为词表的长度
    • emb_dim:embedding的维度
    • enc_hid_dim:encoder每个位置输出的维度
    • dec_hid_dim:decoder每个位置输出的维度
    • dropout:dropout的概率
    • attention:需要传入attention类,用来计算decoder每个位置的输出和encoder每个位置的输出的关系
  • forword参数:
    • input:输入单词的序号
    • hidden:上一步Decoder输出的状态
    • encoder_outputs:Encoder每个状态的输出,用来计算attention
    • mask:mask矩阵,用来在计算attention时,规避pad符号的影响
  • forword输出为全连接层的输出、这一步Decoder的输出和attention的权重。这里输出的是预测时全连接层的输出,目的是计算后续的损失。
class Decoder(nn.Module):
    def __init__(self,output_dim,emb_dim,enc_hid_dim,dec_hid_dim,dropout, attention):
        super().__init__()
        self.output_dim = output_dim
        self.attention = attention
        
        self.embedding = nn.Embedding(output_dim,emb_dim)
        
        self.rnn = nn.GRU((enc_hid_dim *2 )+ emb_dim, dec_hid_dim)
        
        self.fc_out = nn.Linear((enc_hid_dim * 2)+ dec_hid_dim + emb_dim , output_dim)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self,input,hidden,encoder_outputs,mask):
        input = input.unsqueeze(0)
        
        embedded = self.dropout(self.embedding(input))
        
        a = self.attention(hidden,encoder_outputs,mask)
        
        a = a.unsqueeze(1)
        
        encoder_outputs = encoder_outputs.permute(1,0,2)
        
        weighted = torch.bmm(a,encoder_outputs)
        
        weighted = weighted.permute(1,0,2)
        
        rnn_input = torch.cat((embedded, weighted), dim=2)
        
        output,hidden = self.rnn(rnn_input, hidden.unsqueeze(0))
        
        assert (output == hidden).all()
        
        embedded = embedded.squeeze(0)
        output = output.squeeze(0)
        weighted = weighted.squeeze(0)
        
        prediction = self.fc_out(torch.cat((output,weighted,embedded), dim=1))
        
        return prediction,hidden.squeeze(0),a.squeeze(1)

seq2seq

  • 构建一个seq2seq类将encoder、decoder和attention整合起来,参数:
    • encoder:encoder类
    • decoder:decoder类
    • doc_pad_idx:原文词典中pad符号的序号
    • device:需要传入的设备
  • create_mask的参数:
    • doc:原文数据,create_mask会根据原文中pad符号的位置构建mask矩阵,这个mask矩阵会传入decoder,可以在计算attention时规避到pad符号的影响
  • forward的参数:
    • doc:传入的一个批次的原文数据,是已经由词转换成序号的数据
    • doc_len:一个批次里每个数据的长度,用来生成mask矩阵
    • sum:摘要数据,同样已被转换成序号
    • teacher_forcing_ratio:teacher_forcing的概率,teacher_forcing是文本生成技术常用的技术,在训练时,如果一个词生成有误差,可能会影响到后面所有的词,所以以一定的概率选择生成的词还是标注的训练数据中相应位置的词,在验证测试时,不会用到teacher_forcing
class Seq2Seq(nn.Module):
    def __init__(self,encoder,decoder,doc_pad_idx,device):
        super().__init__()
        
        self.encoder = encoder
        self.decoder = decoder
        self.doc_pad_idx = doc_pad_idx
        self.device = device
        
    def create_mask(self,doc):
        mask = (doc != self.doc_pad_idx).permute(1,0)
        return mask
    
    def forward(self,doc,doc_len,sum, teacher_forcing_ratio=0.5):
        batch_size = doc.shape[1]
        
#         print(type(sum))
#         print(sum)
        sum_len = sum[0].shape[0]
        sum_vocab_size= self.decoder.output_dim
        
        # 定义一个tensor来存储每一个生成的单词序号
        outputs = torch.zeros(sum_len,batch_size,sum_vocab_size).to(self.device)
        
        # encoder_outputs 是 encoder所有的输出状态
        # hidder是 encoder整体的输出
        encoder_outputs , hidden = self.encoder(doc,doc_len)
        
        # 输入的第一个字符为<sos>
        input = sum[0][0,:]
        
        # 构建一个mask矩阵, 包含训练数据原文中 pad符号的位置
        mask = self.create_mask(doc)
        
        for t in range(1, sum_len):
            try:
                # decoder 输入 前一步生成的单词embedding, 前一步状态hidden, encoder所有状态以及mask矩阵
                # 返回预测全连接层的输出和这一步的状态
                output,hidden,_ = self.decoder(input,hidden,encoder_outputs,mask)

                # 把output的信息存储在之前定义的 outputs里
                outputs[t] = output

                # 生成一个随机数, 来决定是否使用 teacher forcing
                teacher_force = random.random() < teacher_forcing_ratio

                # 获得可能性最高的单词序号 作为生成的单词
                top1 = output.argmax(1)

                # 如果使用teacher forcing则用训练数据相应位置的单词
                # 否则使用生成的单词 作为下一步的输入单词
                input = sum[t] if teacher_force else top1
            except Exception as e:
                pass
        return outputs      

模型实例化

利用定义好的模型结构实例化encoder和decoder。

INPUT_DIM = len(DOCUMENT.vocab)
OUTPUT_DIM = len(SUMMARY.vocab)
ENC_EMB_DIM = 256//64
DEC_EMB_DIM = 256//64
ENC_HID_DIM = 512//64
DEC_HID_DIM = 512//64
ENC_DROPOUT = 0.5
DEC_DROPOUT = 0.5
DOC_PAD_IDX = DOCUMENT.vocab.stoi[DOCUMENT.pad_token]

attn = Attention(ENC_HID_DIM,DEC_HID_DIM)
enc = Encoder(INPUT_DIM,ENC_EMB_DIM,ENC_HID_DIM,DEC_HID_DIM,ENC_DROPOUT)
dec = Decoder(OUTPUT_DIM,DEC_EMB_DIM,ENC_HID_DIM,DEC_HID_DIM,DEC_DROPOUT,attn)

model = Seq2Seq(enc,dec,DOC_PAD_IDX,device).to(device)

查看模型

model
Seq2Seq(
  (encoder): Encoder(
    (embedding): Embedding(16555, 4)
    (rnn): GRU(4, 8, bidirectional=True)
    (fc): Linear(in_features=16, out_features=8, bias=True)
    (dropout): Dropout(p=0.5, inplace=False)
  )
  (decoder): Decoder(
    (attention): Attention(
      (attn): Linear(in_features=24, out_features=8, bias=True)
      (v): Linear(in_features=8, out_features=1, bias=False)
    )
    (embedding): Embedding(9267, 4)
    (rnn): GRU(20, 8)
    (fc_out): Linear(in_features=28, out_features=9267, bias=True)
    (dropout): Dropout(p=0.5, inplace=False)
  )
)

模型训练

  • 使用之前处理的训练数据对模型训练,主要包括
    • 定义训练函数
    • 定义验证函数
    • 定义时间显示函数
    • 训练过程
    • 模型保存

定义训练函数

定义训练一个 epoch的函数,并返回损失,参数 - model: 用以训练的模型 - iteration: 用以训练的数据迭代器 - optimizer: 训练模型使用的优化器 - criterion: 训练模型使用的损失函数 - clip: 梯度截断的值, 传入torch.nn.utils.clip_grad_norm_中,如果梯度超过这个clip,会使用clip对梯度进行截断,可以预防训练初期的梯度爆炸现象。

定义验证函数

返回测试/验证数据的损失, 参数: - model: 用以验证的模型 - iteration: 用以验证/测试的数据迭代器 - criterion: 验证/测试模型的损失函数

def train(model,iterator,optimizer,criterion,clip):
    model.train()
    
    epoch_loss = 0
    
    for i,batch in enumerate(iterator):
        doc,doc_len = batch.document
        sum = batch.summary
#         print("****************这是train************************")
#         print(type(sum))
#         print(sum)
        optimizer.zero_grad()
        
        output = model(doc,doc_len,sum)
        
        output_dim = output.shape[-1]
        
        output = output[1:].view(-1,output_dim)
        sum = sum[0][1:].view(-1)
        
        loss = criterion(output,sum)
        
        loss.backward()
        
        torch.nn.utils.clip_grad_norm_(model.parameters(),clip)
        
        optimizer.step()
        
        epoch_loss += loss.item()
        if i>20:break
    return epoch_loss / len(iterator)
def evaluate(model,iterator,criterion):
    model.eval()
    
    epoch_loss = 0
    with torch.no_grad():
        for i,batch in enumerate(iterator):
            doc,doc_len = batch.document
            sum = batch.summary
#             print("****************这是evaluate************************")
#             print(type(sum))
#             print(sum)
            output = model(doc,doc_len,sum,0)
            
            output_dim = output.shape[-1]
            
            output = output[1:].view(-1,output_dim)
            sum = sum[0][1:].view(-1)
            
            loss = criterion(output,sum)
            
            epoch_loss += loss.item()
            if i>20:break
    return epoch_loss / len(iterator)

定义时间显示的函数

def epoch_time(start_time,end_time):
    elapsed_time = end_time -start_time
    elapsed_mins = int(elapsed_time / 60)
    elapsed_secs = int(elapsed_time -(elapsed_mins * 60))
    return elapsed_mins,elapsed_secs

训练过程

对整体的数据训练,分多个批次训练。 - 训练过程中,每个epoch后输出耗时、训练损失和验证损失。 - 这里只训练了5个epoch,训练使用adam学习器,的学习率lr设置为0.001,weight decay设置为0.0001,CLIP设置为1。 - 训练使用CrossEntropyLoss交叉熵损失,代表生成摘要每个位置单词和训练数据中相应位置的差异,如果训练数据中某个位置为pad符号,则计算损失时不计算生成摘要该位置的单词的损失。

N_EPOCHS = 1
CLIP = 1
lr= 0.001
weight_decay = 0.0001
SUM_PAD_IDX = SUMMARY.vocab.stoi[SUMMARY.pad_token]

criterion = nn.CrossEntropyLoss(ignore_index=SUM_PAD_IDX)
optimizer = optim.Adam(model.parameters(),lr=lr,weight_decay=weight_decay)

# 训练
for epoch in range(N_EPOCHS):
    start_time = time.time()
    
    train_loss = train(model,train_iter, optimizer, criterion,CLIP)
    valid_loss = evaluate(model,val_iter,criterion)
    
    end_time = time.time()
    
    epoch_mins,epoch_secs = epoch_time(start_time,end_time)
    
    print(f'Epoch: {epoch + 1:02} | Time:{epoch_mins}m {epoch_secs}s')
    print(f'\tTrain Loss: {train_loss :.3f} | Tain PPL:{math.exp(train_loss):7.3f}')
    print(f'\t Val. Loss: {valid_loss:.3f} |  Val. PPL: {math.exp(valid_loss):7.3f}')
Epoch: 01 | Time:0m 1s
    Train Loss: 0.050 | Tain PPL:  1.052
     Val. Loss: 0.999 |  Val. PPL:   2.716

模型保存

torch.save(model.state_dict(),'model.pt')

模型预测

  • 模型加载
  • 构建预测函数
  • 读取数据
  • 预测

模型加载

model.load_state_dict(torch.load('model.pt'))
<All keys matched successfully>

构建预测函数

构建生成的函数,输入原文的字符串,输出生成摘要的字符串,参数为: - doc_sentence:摘要的字符串 - doc_field:之前定义的针对document的预处理格式DOCUMENT - sum_field:之前定义的针对summary的预处理格式SUMMARY - model:训练的seq2seq模型 - device:数据存放的设备 - max_len:生成摘要的最长长度

def generate_summary(doc_sentence,doc_field,sum_field,model,device,max_len=50):
    # 将模型部署为验证模式
    model.eval()
    
    # 对原文分词
    nlp = spacy.load('en_core_web_sm')
    
    tokens = [token.text.lower() for token in nlp(doc_sentence)]
    
    # 为原文加上起始符号<sos> 和结束符号<eos>
    tokens = [doc_field.init_token] + tokens + [doc_field.eos_token]
    
    # 将字符转换为序号
    doc_indexes = [doc_field.vocab.stoi[token] for token in tokens]
    
    # 转换成可以gpu计算的tensor
    doc_tensor = torch.LongTensor(doc_indexes).unsqueeze(1).to(device)
    
    doc_len = torch.LongTensor([len(doc_indexes)]).to(device)
    
    # 计算encoder
    with torch.no_grad():
        encoder_outputs,hidden = model.encoder(doc_tensor,doc_len)
        
    mask = model.create_mask(doc_tensor)
    
    # 生成摘要的一个单词<sos>
    sum_indexes = [sum_field.vocab.stoi[sum_field.init_token]]
    
    # 构建一个attention tensor,存储每一步的attention
    attentions = torch.zeros(max_len,1,len(doc_indexes)).to(device)
    
    for i in range(max_len):
        sum_tensor = torch.LongTensor([sum_indexes[-1]]).to(device)
        
        # 计算每一步的decoder
        with torch.no_grad():
            output,hidden,attention = model.decoder(sum_tensor, hidden, encoder_outputs,mask)
            
        attentions[i] = attention
        
        pred_token = output.argmax(1).item()
        
        # 如果出现了 <eos> 则直接结束计算
        if pred_token == sum_field.vocab.stoi[sum_field.eos_token]:
            break
        
        sum_indexes.append(pred_token)
        
    # 把序号转换成单词
    sum_tokens = [sum_field.vocab.itos[i] for i in sum_indexes]
    
    return sum_tokens[1:], attentions[:len(sum_tokens)-1]

读取数据

data_test = pd.read_csv("dataset/test.csv",encoding='utf-8')
data_test = data_test[:100]

doc_sentence_list = data_test['document'].tolist()
sum_sentence_list = data_test['summary'].tolist()

预测

# 使用generate_summary函数对测试集中所有的document生成摘要, 预测时,不能使用批次的方式预测,所有数据顺序预测,需要一定的时间。
generated_summary = []
for doc_sentence in doc_sentence_list:
    summary_words,attention = generate_summary(doc_sentence,DOCUMENT,SUMMARY,model,device,max_len=50)
    summary_sentence = (' ').join(summary_words)
    generated_summary.append(summary_sentence)
# 输出一个生成的摘要
indices = random.sample(range(0,len(sum_sentence_list)),5)
for index in indices:
    print("******document******")
    print(doc_sentence_list[index])
    
    print("******generated summary:*******")
    print(generated_summary[index])
    
    print("*******reference summary:*******")
    print(sum_sentence_list[index])
    
    print("***************************")
******document******
south african mining giant anglo american said on tuesday that it had agreed to sell the <unk> <unk> group for ### million dollars -lrb- ### million euros -rrb- in cash to private equity group advent international .
******generated summary:*******
community pilgrims organizers qualifiers nt thailand descends number village village village village vie profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile replacement profile
*******reference summary:*******
anglo american sells <unk> <unk> for ### mln dlrs
***************************
******document******
the patriots locked up super bowl hero adam vinatieri friday , removing the `` franchise player '' tag and signing him to a three-year deal , and now they will wait to see if there is any interest in a drew bledsoe deal at the owners ' meetings in orlando this upcoming week .
******generated summary:*******
descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open
*******reference summary:*******
pats sign vinatieri to #-year deal
***************************
******document******
greg oden was on a path to follow lebron james , a high school prodigy leaping directly to the national basketball association , but he now must wait until #### to seek professional riches .
******generated summary:*******
descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open
*******reference summary:*******
nba hot prospect oden now looks to college first
***************************
******document******
pedro astacio did n't allow a hit until geoff jenkins lined a single to left field with one out in the seventh inning saturday , and the new york mets beat the milwaukee brewers #-# .
******generated summary:*******
descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open descends open
*******reference summary:*******
astacio nearly no-hits brewers as mets win #-#
***************************
******document******
fighting spread saturday through the alleys of densely populated west bank refugee camps , where palestinian militants reportedly were handing out explosives-packed belts to residents willing to strap them on and challenge israeli soldiers .
******generated summary:*******
replacement republican performance thailand descends thailand descends number village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village village
*******reference summary:*******
fighting spreads through palestinian refugee camps as death toll
***************************

模型评估

  • 模型加载
  • 损失评估
  • 指标评估

模型加载

model.load_state_dict(torch.load('model.pt'))
<All keys matched successfully>

损失评估

# 评估测试集的损失,输出损失。直接调用之前定义的evaluate函数,输入为测试数据的迭代器。
test_loss= evaluate(model,test_iter,criterion)
print(f'| Test Loss:{test_loss:.3f} | Test PPL:{math.exp(test_loss):7.3f} |')
| Test Loss:0.998 | Test PPL:  2.713 |

指标评估

文本自动摘要的指标通常为ROUGE(Recall-Oriented Understudy for Gisting Evaluation),在2004年由Chin-Yew Lin提出。 image.png

  • 分母是人工摘要(也就是数据中标注的摘要)中n-gram的个数,分子是人工摘要和机器生成的自动摘要共现(重合)的n-gram的个数。
  • 可以看出,ROUGE与召回率(recall)的定义很相似。分母也可以是机器生成的摘要,这样就是准确率(precision),同时也可以计算F1指数。
  • 通常情况下只用召回率,展示1-gram和2-gram的结果ROUGE-1和ROUGE-2。
  • 除了ROUGE-1和ROUGE-2之外,还可以用人工摘要和机器生成的摘要的最长公共子序列(Longest Common Sequence)的长度和生成摘要或者标注摘要的长度之间的比例来评估摘要模型
image.png
  • 第一个公式分母为标注的摘要长度,计算召回率。第二个公式分母为生成的摘要长度,计算准确率。第三个公式求 \(F_\beta\)\(\beta\) 的值通常大于1。 和ROUGE-1、ROUGE-2不同的是,ROUGE-L主要关注F指数。
  • 目前用python计算评估ROUGE指标通常使用pyrouge,但是pyrouge的安装需要预先安装ROUGE工具,较为麻烦,我们这里直接使用rouge工具包进行ROUGE的评估,rouge可以直接使用pip install rouge安装。rouge输入生成的摘要和人工摘要,输出ROUGE-1、ROUGE-2、ROUGE-L的f指数、准确率和召回率。
from rouge import Rouge
rouge = Rouge()
scores = rouge.get_scores(generated_summary,sum_sentence_list,avg=True)
print(scores)
{'rouge-1': {'f': 0.0003508771714373667, 'p': 0.0002, 'r': 0.0014285714285714286}, 'rouge-2': {'f': 0.0, 'p': 0.0, 'r': 0.0}, 'rouge-l': {'f': 0.0024999999625000004, 'p': 0.005, 'r': 0.0016666666666666666}}

存在如下问题

目前普遍使用seq2seq解决文本摘要问题,会出现以下问题:

1.OOV问题

源文档语料中的词的数量级通常会很大,但是经常使用的词数量则相对比较固定。因此通常会根据词的频率过滤掉一些词做成词表。这样的做法会导致生成摘要时会遇到UNK的词。

2.摘要的可读性。

通常使用贪心算法或者beamsearch方法来做decoding。这些方法生成的句子有时候会存在不通顺的问题。

3.摘要的重复性。

这个问题出现的频次很高。与2的原因类似,由于一些decoding的方法的自身缺陷,导致模型会在某一段连续timesteps生成重复的词。

4.长文本摘要生成难度大。

对于机器翻译来说,NLG的输入和输出的语素长度大致都在一个量级上,因此NLG在其之上的效果较好。但是对摘要来说,源文本的长度与目标文本的长度通常相差很大,此时就需要encoder很好的将文档的信息总结归纳并传递给decoder,decoder需要完全理解并生成句子。可想而知,这是一个很难的事情。

5.模型的训练目标与最终的评测指标不太一致。

这里牵扯到两个问题,一个是seq2seq的训练模式中,通常会使用teacher-forcing的方式,即在decoder上,将真实target的输入和模型在前一时刻生成的词一起送到下一个时刻的神经元中计算。但是在inference时,是不会有真实target的,因此存在一个gap;另一个问题就是通常模型训练的目标函数都是交叉熵损失函数。但是摘要的评测却不是以交叉熵来判断的,目前一些榜单通常以ROUGE、BLEU等方式评测,虽然这些评测也不是很完美,但是与交叉熵的评测角度均在较大差异。

优化思路 可以尝试如何利用深度无监督模型去做生成式摘要任务。

例如:以自编码器为主体架构,对其进行不同程度的改造,从压缩或者生成两个角度去无监督生成摘要文本, 同时为了提升效果,也会利用GPT,XLNET等预训练语言模型做finetune。


About ME

👋 读书城南,🤔 在未来面前,我们都是孩子~
  • 📙 一个热衷于探索学习新方向、新事物的智能产品经理,闲暇时间喜欢coding💻、画图🎨、音乐🎵、学习ing~
👋 Social Media
👋 加入小组~

👋 感谢打赏~