🐱 基于BERT的文本标题生成

一个好标题则是基于文章内容的巧妙提炼,能迅速引起读者的兴趣。

为了快速精准的生成新闻标题,本项目使用经典的BERT模型自动完成新闻标题的生成。


📖 0 项目背景

新闻头条新闻的目的是制作一个简短的句子,以吸引读者阅读新闻。一篇新闻文章通常包含多个不同用户感兴趣的关键词,这些关键词自然可以有多个合理的头条新闻。快速的生成合适的新闻标题能帮助自媒体从业者从热点信息中迅速获得大量流量。此外,该任务也可以应用于公务员考试中的关键信息总结题。因此,将该过程自动化具有广泛的应用前景。

🍌 1 数据集

lcsts摘要数据是哈尔滨工业大学整理,基于新闻媒体在微博上发布的新闻摘要创建了该数据集,每篇短文约100个字符,每篇摘要约20个字符。共计2108915条数据

由于数据量较大,本项目仅使用部分数据训练

🍉 2 论文解读


摘要

BERT(Bidirectional Encoder Representations from Transformers)意为来自Transformer的双向编码器表征。不同于最近的语言表征模型,BERT旨在基于所有层的左、右语境来预训练深度双向表征。因此,预训练的BERT表征可以仅用一个额外的输出层进行微调,进而为很多任务(如问答和语言推理)创建当前最优模型,无需对任务特定架构做出大量修改。

  • Bert提出了一种新的预训练方法,能有效提高预训练模型在下游任务中的表现。

介绍

语言模型预训练已被证明可有效改进许多自然语言处理任务。

将预训练语言表征应用于下游任务有两种现有策略:基于特征feature-based和微调fine-tuning。

基于fine-tuning的方法主要局限是标准语言模型是单向的,极大限制了可以在预训练期间使用的架构类型。

BERT通过提出一个新的预训练目标:遮蔽语言模型”(maskedlanguage model,MLM),来解决目前的单向限制。

该MLM目标允许表征融合左右两侧语境语境。除了该遮蔽语言模型,BERT还引入了一个“下一句预测”(nextsentence prediction)任务用于联合预训练文本对表征。


论文贡献如下:

    1. 证明了双向预训练对语言表征量的重要性。
    1. 展示了预训练表征量能消除许多重型工程任务特定架构的需求。

(BERT是第一个基于微调的表征模型,它在大量的句子级和词块级任务上实现了最先进的性能)

    1. BERT推进了11项NLP任务的最高水平。

模型框架

BERT模型架构是一种多层双向Transformer编码器。

本文设置前馈/过滤器的尺寸为4H,如H=768时为3072,H=1024时为4096。

主要展示在两种模型尺寸上的结果:

  • BERTBASE:L=12,H=768,A=12,总参数=110M

  • BERTLARGE:L=24,H=1024,A=16,总参数=340M


  • 注意:BERT变换器使用双向自注意力机制

输入表征

对于每一个token, 它的表征由对应的token embedding, 段表征(segment embedding)和位置表征(position embedding),其中位置表征支持的序列长度最多为512个词块。
(如果使用bert预训练模型的话不能改,要是自己训练的话可以自己定义,不一定要按照512,这里原文是为了平衡效率和性能)

其中,位置表征(position embedding)是由Transformer提出,目的是为了对不同位置的信息进行编码。

这里有两种实现方法:拼接和求和,详情见Transformer 中的 positional embedding

预训练任务

作者使用两个新型无监督预测任务对BERT进行预训练

  • 遮蔽语言模型

随机遮蔽输入词块的某些部分,然后仅预测那些被遮蔽词块。Bert将这个过程称为“遮蔽LM”(MLM)。

具体实现方式为:
训练数据生成器随机选择15%的词块。然后完成以下过程:

并非始终用[MASK]替换所选单词,数据生成器将执行以下操作:

  1. 80%的训练数据:用[MASK]词块替换单词,例如,【我的狗是毛茸茸的!】【我的狗是[MASK]】
  2. 10%的训练数据:用随机词替换遮蔽词,例如,【我的狗是毛茸茸的!】【我的狗是苹果】
  3. 10%的训练数据:保持单词不变,例如,【我的狗毛茸茸的!】【我的狗毛茸茸的!】这样做的目的是将该表征偏向于实际观察到的单词。

  • 下一句预测模型

为了训练一个理解句子关系的模型,我们预训练了一个二值化下一句预测任务,该任务可以从任何单语语料库中轻松生成。

具体实现方式为:选择句子A和B作为预训练样本:B有50%的可能是A的下一句,也有50%的可能是来自语料库的随机句子

输入=[CLS]男子去[MASK]商店[SEP]他买了一加仑[MASK]牛奶[SEP]

Label= IsNext

输入=[CLS]男人[面具]到商店[SEP]企鹅[面具]是飞行##少鸟[SEP]

Label= NotNext

总结

BERT使用了更加高效的Transformer结构,高效获取了训练数据的双向表征信息。

文章做了非常详细的消融实验,建议感兴趣的同学可以详细的品读一下原文。


参考资料

[1] https://www.cnblogs.com/guoyaohua/p/bert.html

[2] https://zhuanlan.zhihu.com/p/171363363

[3] https://zhuanlan.zhihu.com/p/360539748

[4] https://arxiv.org/pdf/1810.04805.pdf

🥝 3 模型训练

# 安装需要的库函数
!pip install sumeval
# 解压数据集
!unzip  -o  data/data127041/lcsts_data.zip -d /home/aistudio/work/
Archive:  data/data127041/lcsts_data.zip
  inflating: /home/aistudio/work/lcsts_data.json  
import json
f = open('work/lcsts_data.json')
data = json.load(f)
f.close()
# 查看数据
item = data[520]
print('标题:',item['title'],'\n内容:',item['content'])
标题: 海口市民可凭银行卡刷卡坐公交 
内容: 海南宝岛通公司推出“工银宝岛通联名,市民可凭银行卡刷卡坐车。该卡不仅具有工行借记卡全方位的金融服务功能,同时还具有宝岛通卡的电子付费等功能。持卡人凭此联名卡既可刷卡乘坐海口市公交车,又能进行刷卡消费及存取款等业务操作
from paddlenlp.transformers import BertModel, BertForTokenClassification
import paddle.nn as nn
from paddle.nn import Layer, Linear, Softmax
import paddle

class TitleBertModel(paddle.nn.Layer):
    """
    基于BERT预训练模型的生成模型
    """
    def __init__(self, pretrained_bert_model: str, input_length: int):
        super(TitleBertModel, self).__init__()
        bert_model = BertModel.from_pretrained(pretrained_bert_model)
        self.vocab_size, self.hidden_size = bert_model.embeddings.word_embeddings.parameters()[0].shape
        self.bert_for_class = BertForTokenClassification(bert_model, self.vocab_size)
        # 生成下三角矩阵,用来mask句子后边的信息
        self.sequence_length = input_length
        # lower_triangle_mask为input_length * input_length的下三角矩阵(包含主对角线),该掩码作为注意力掩码的一部分(在forward的
        # 处理中为0的部分会被处理成无穷小量,以方便在计算注意力权重的时候保证被掩盖的部分权重约等于0)。而之所以写为下三角矩阵的形式,与
        # transformer的多头注意力计算的机制有关,细节可以了解相关论文获悉。
        self.lower_triangle_mask = paddle.tril(paddle.tensor.full((input_length, input_length), 1, 'float32'))

    def forward(self, token, token_type, input_mask, input_length=None):
        # 计算attention mask
        mask_left = paddle.reshape(input_mask, input_mask.shape + [1])
        mask_right = paddle.reshape(input_mask, [input_mask.shape[0], 1, input_mask.shape[1]])
        # 输入句子中有效的位置
        mask_left = paddle.cast(mask_left, 'float32')
        mask_right = paddle.cast(mask_right, 'float32')
        attention_mask = paddle.matmul(mask_left, mask_right)
        # 注意力机制计算中有效的位置
        if input_length is not None:
            # 之所以要再计算一次,是因为用于推理预测时,可能输入的长度不为实例化时设置的长度。这里的模型在训练时假设输入的
            # 长度是被填充成一致的——这一步不是必须的,但是处理成一致长度比较方便处理(对应地,增加了显存的用度)。
            lower_triangle_mask = paddle.tril(paddle.tensor.full((input_length, input_length), 1, 'float32'))
        else:
            lower_triangle_mask = self.lower_triangle_mask

        attention_mask = attention_mask * lower_triangle_mask

        # 无效的位置设为极小值
        attention_mask = (1 - paddle.unsqueeze(attention_mask, axis=[1])) * -1e10
        attention_mask = paddle.cast(attention_mask, self.bert_for_class.parameters()[0].dtype)

        output_logits = self.bert_for_class(token, token_type_ids=token_type, attention_mask=attention_mask)
        
        return output_logits
# 读取预训练模型
from paddlenlp.transformers import BertTokenizer
bert_tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
[2022-02-08 15:54:15,141] [    INFO] - Already cached /home/aistudio/.paddlenlp/models/bert-base-chinese/bert-base-chinese-vocab.txt
import paddle
import numpy as np
# 自定义数据读取类
class TitleGenerateData(paddle.io.Dataset):
    """
    构造数据集,继承paddle.io.Dataset
    Parameters:
        data(dict):标题和对应的正文,均未经编码
            title(str):标题
            content(str):正文

        max_len: 接收的最大长度
    """
    def __init__(self, data, tokenizer,max_len = 128,mode='train'):
        super(TitleGenerateData, self).__init__()
        self.data_ = data
        self.tokenizer = tokenizer
        self.max_len = max_len
        scale = 0.8 # 80%训练
        if mode=='train':
            self.data = self.data_[:int(scale*len(self.data_))]
        else:
            self.data = self.data_[int(scale*len(self.data_)):]

    def __getitem__(self, idx):
        item = self.data[idx]
        content = item['content']
        title = item['title']

        token_content = self.tokenizer.encode(content)
        token_title = self.tokenizer.encode(title)

        token_c, token_typec_c = token_content['input_ids'], token_content['token_type_ids']
        token_t, token_typec_t = token_title['input_ids'], token_title['token_type_ids']

        if len(token_c) > self.max_len + 1:
            token_c = token_c[:self.max_len] + token_c[-1:]
            token_typec_c = token_typec_c[:self.max_len] + token_typec_c[-1:]

        if len(token_t) > self.max_len + 1:
            token_t = token_t[:self.max_len] + token_t[-1:]
            token_typec_t = token_typec_t[:self.max_len] + token_typec_t[-1:]

        input_token, input_token_type = token_c, token_typec_c
        label_token = np.array((token_t + [0] * self.max_len)[:self.max_len], dtype='int64')

        # 输入填充
        input_token = np.array((input_token + [0] * self.max_len)[:self.max_len], dtype='int64')
        input_token_type = np.array((input_token_type + [0] * self.max_len)[:self.max_len], dtype='int64')
        input_pad_mask = (input_token != 0).astype('float32')
        label_pad_mask = (label_token != 0).astype('float32')
        return input_token, input_token_type, input_pad_mask, label_token, label_pad_mask
    
    def __len__(self):
        return len(self.data)
# 定义损失函数
class Cross_entropy_loss(Layer):
    def forward(self, pred_logits, label, label_pad_mask):
        loss = paddle.nn.functional.cross_entropy(pred_logits, label, ignore_index=0, reduction='none')
        masked_loss = paddle.mean(loss * label_pad_mask, axis=0)
        return paddle.sum(masked_loss)
# 测试数据读取类
dataset = TitleGenerateData(data,bert_tokenizer,mode='train')
print('=============train dataset=============')

input_token, input_token_type, input_pad_mask, label_token, label_pad_mask = dataset[1]
print(input_token, input_token_type, input_pad_mask, label_token, label_pad_mask)
# 查看模型结构,定义Perplexity评价指标,及参数
from paddle.static import InputSpec
from paddlenlp.metrics import Perplexity
from paddle.optimizer import AdamW

net = TitleBertModel('bert-base-chinese', 128)

token_ids = InputSpec((-1, 128), 'int64', 'token')
token_type_ids = InputSpec((-1, 128), 'int64', 'token_type')
input_mask = InputSpec((-1, 128), 'float32', 'input_mask')

label = InputSpec((-1, 128), 'int64', 'label')
label_mask = InputSpec((-1, 128), 'int64', 'label')

inputs = [token_ids, token_type_ids, input_mask]
labels = [label,label_mask]

model = paddle.Model(net, inputs, labels)
model.summary(inputs, [input.dtype for input in inputs])
# 训练参数
epochs = 20
context_length = 128
lr = 1e-3
# 模型训练
from paddle.io import DataLoader
from tqdm import tqdm
from paddlenlp.metrics import Perplexity

train_dataset = TitleGenerateData(data,bert_tokenizer,mode='train')
dev_dataset = TitleGenerateData(data,bert_tokenizer,mode='dev') 

train_loader = paddle.io.DataLoader(train_dataset, batch_size=128, shuffle=True)
dev_loader = paddle.io.DataLoader(dev_dataset, batch_size=64, shuffle=True)

model = TitleBertModel('bert-base-chinese', context_length)


# 设置优化器
optimizer=paddle.optimizer.AdamW(learning_rate=lr,parameters=model.parameters())
# 设置损失函数
loss_fn = Cross_entropy_loss()

perplexity = Perplexity()

model.train()
for epoch in range(epochs):
    for data in tqdm(train_loader(),desc='epoch:'+str(epoch+1)):

        input_token, input_token_type, input_pad_mask, label_token, label_pad_mask = data[0],data[1],data[2],data[3], data[4]  # 数据

        predicts = model(input_token, input_token_type, input_pad_mask)    # 预测结果

        # 计算损失 等价于 prepare 中loss的设置
        loss = loss_fn(predicts, label_token , label_pad_mask)

        
        predicts = paddle.to_tensor(predicts)
        label =  paddle.to_tensor(label_token)

        # 计算困惑度 等价于 prepare 中metrics的设置
        correct = perplexity.compute(predicts, label)
        perplexity.update(correct.numpy())
        ppl = perplexity.accumulate()
        
        # 反向传播
        loss.backward()

        # 更新参数
        optimizer.step()

        # 梯度清零
        optimizer.clear_grad()

    print("epoch: {}, loss is: {}, Perplexity is:{}".format(epoch+1, loss.item(),ppl))

    # 保存模型参数,文件名为Unet_model.pdparams
    paddle.save(model.state_dict(), 'work/model.pdparams')



🎖️ 4 模型测试

import numpy as np

class TitleGen(object):
    """
    定义一个自动生成文本的类,按照要求生成文本
    model: 训练得到的预测模型
    tokenizer: 分词编码工具
    max_length: 生成文本的最大长度,需小于等于model所允许的最大长度
    """
    def __init__(self, model, tokenizer, max_length=128):
        self.model = model
        self.tokenizer = tokenizer
        self.puncs = [',', '。', '?', ';']
        self.max_length = max_length

    def generate(self, head='', topk=2):
        """
        根据要求生成标题
        head (str, list): 输入的文本
        topk (int): 从预测的topk中选取结果
        """
        poetry_ids = self.tokenizer.encode(head)['input_ids']

        # 去掉开始和结束标记
        poetry_ids = poetry_ids[1:-1]
        break_flag = False
        while len(poetry_ids) <= self.max_length:
            next_word = self._gen_next_word(poetry_ids, topk)
            # 对于一些符号,如[UNK], [PAD], [CLS]等,其产生后对诗句无意义,直接跳过
            if next_word in self.tokenizer.convert_tokens_to_ids(['[UNK]', '[PAD]', '[CLS]']):
                continue

                new_ids = [next_word]
            if next_word == self.tokenizer.convert_tokens_to_ids(['[SEP]'])[0]:
                break
            poetry_ids += new_ids
            if break_flag:
                break
        return ''.join(self.tokenizer.convert_ids_to_tokens(poetry_ids))

    def _gen_next_word(self, known_ids, topk):
        type_token = [0] * len(known_ids)
        mask = [1] * len(known_ids)
        sequence_length = len(known_ids)
        known_ids = paddle.to_tensor([known_ids], dtype='int64')
        type_token = paddle.to_tensor([type_token], dtype='int64')
        mask = paddle.to_tensor([mask], dtype='float32')
        logits = self.model.network.forward(known_ids, type_token, mask, sequence_length)
        # logits中对应最后一个词的输出即为下一个词的概率
        words_prob = logits[0, -1, :].numpy()
        # 依概率倒序排列后,选取前topk个词
        words_to_be_choosen = words_prob.argsort()[::-1][:topk]
        probs_to_be_choosen = words_prob[words_to_be_choosen]
        # 归一化
        probs_to_be_choosen = probs_to_be_choosen / sum(probs_to_be_choosen)
        word_choosen = np.random.choice(words_to_be_choosen, p=probs_to_be_choosen)
        return word_choosen
# 载入已经训练好的模型
net = TitleBertModel('bert-base-chinese', 128)
model = paddle.Model(net)
model.load('./work/model')
title_gen = TitleGen(model, bert_tokenizer)
# 使用rouge评价指标
import json
from sumeval.metrics.rouge import RougeCalculator


text_ = '昨晚6点,一架直升机坠入合肥董铺水库'
ref_content = title_gen.generate(head=text_)

print(ref_content)

summary_content = '直升机坠入安徽合肥一水库 '

rouge = RougeCalculator(lang="zh")

# 输出rouge-1, rouge-2, rouge-l指标
sum_rouge_1 = 0
sum_rouge_2 = 0
sum_rouge_l = 0
for i, (summary, ref) in enumerate(zip(summary_content, ref_content)):
    summary = summary.lower().replace(" ", "")
    rouge_1 = rouge.rouge_n(
                summary=summary,
                references=ref,
                n=1)
    rouge_2 = rouge.rouge_n(
                summary=summary,
                references=ref,
                n=2)
    rouge_l = rouge.rouge_l(
                summary=summary,
                references=ref)

    sum_rouge_1 += rouge_1
    sum_rouge_2 += rouge_2
    sum_rouge_l += rouge_l
    print(i, rouge_1, rouge_2, rouge_l, summary, ref)

print(f"avg rouge-1: {sum_rouge_1/len(summary_content)}\n"
      f"avg rouge-2: {sum_rouge_2/len(summary_content)}\n"
      f"avg rouge-l: {sum_rouge_l/len(summary_content)}")

🥑 5 总结

由于数据量太大,本项目仅使用少量数据进行效果演示以保证项目成功运行。

本项目的工作主要体现在:

  • 项目对BERT原文进行了详细解读。

  • 项目对lcsts摘要数据集进行了演示和使用

  • 项目使用了rouge评价方法

  • 修正参考项目Bug:当源数据和目标数据使用不同MASK的处理


有任何问题,欢迎评论区留言交流。
Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐