基于Attention的自动标题生成

Attention原理

    在自然语言处理中,最基本的文本生成框架是seq2seq。seq2seq由编码器和解码器组成,编码器把输入文本的词法、句法和语义等特征编码成语义向量,解码器根据语义向量解码成目标文本。框架表示如下:

a.jpg

    但是这个编码过程其实是一个信息有损压缩的过程,编码得到的语义向量其实已经丢失了大量的细节,或语义向量无法完整表示输入文本的信息。这很像常用与视觉任务的CNN网络一样,CNN下采样得到高级特征表示,但是根据多次卷积后的特征却无法准确还原图像,因此恺明大佬提出ResNet,提出残差网络的概念,即将底层特征直接传递至网络的后层,可在一定程度上解决细节丢失和梯度消失的问题。而Attention,就是NLP中的"残差网络"。

    比如:输入的是英文句子:Tom chase Jerry,Encoder-Decoder框架逐步生成中文单词:“汤姆”,“追逐”,“杰瑞”。在没加入Attention Model之前,生成的语义编码C是一致的,而加入之后,对应的语义编码可能如下:

b.png

    其中,f2函数代表Encoder对输入英文单词的某种变换函数,比如如果Encoder是用的RNN模型的话,这个f2函数的结果往往是某个时刻输入xi后隐层节点的状态值;g代表Encoder根据单词的中间表示合成整个句子中间语义表示的变换函数,一般的做法中,g函数就是对构成元素加权求和,也就是常常在论文里看到的下列公式:

c.png

    假设Ci中那个i就是上面的“汤姆”,那么Tx就是3,代表输入句子的长度,h1=f(“Tom”),h2=f(“Chase”),h3=f(“Jerry”),对应的注意力模型权值分别是0.6, 0.2, 0.2,所以g函数就是个加权求和函数。如果形象表示的话,翻译中文单词“汤姆”的时候,数学公式对应的中间语义表示Ci的形成过程类似下图:

d.png

    Attention语义向量的计算公式:

g.png

    上式中,Ci是输出的第i个单词的语义向量,xj是输入的第j个单词词向量,f(xj)是输入单词词向量经过编码后的向量,一般是编码器RNN的隐藏层向量,aij是输入的第j个单词和输出的第i个单词之间的权重系数。Attention机制最重要的就是aij即各个单词权重的计算,计算公式如下:

i.png

    这个公式的意思是eij经过softmax层得到aij,即aij是概率。而eij是输出的第i个单词隐向量和输入的第j个单词隐向量的匹配度,被曾为attention score。好的重点其实是eij怎么计算,计算方法有如下几种:

j.png

    上面的hi是输入第i个单词的隐向量,s是上一词的隐藏状态。由此可得到attention score。Attention计算过程其实可以抽象为查询过程:

    h.png

     Query是decoder上一时刻的隐状态,Key和Value都是encoder的隐状态。即查询过程的描述为:上一时刻的隐状态即输出向量与表示各个单词的encoder隐向量经过F()函数之后得到匹配度s。经过softmax归一化后得到概率或者说权重a,与encoder隐向量加权求和最终得到Attention Value即这个单词的语义向量。

    上面就是Attention的思想,当编解码单元使用LSTM时,框架如下:

f.jpg


自动标题生成

    自动标题生成,即给定文章内容生成标题。这应该算自然语言处理中相对简单的任务。我使用的神经网络结构如下:

attentionTest_model.png

    这个模型基本按照Attention的计算步骤构建。下面详细解释这个模型:

    首先,input_1是文本序列输入层,这里设置文章长度为500。

    各个的单词经过词嵌入层embedding_1后得到300d的词向量。

    接下来用一个双向LSTM即bidirectional_1(lstm)层进行编码,设置lstm返回中间隐层,因此得到(500,256)。注意lstm的cells为128,因为是双向的,故输出向量拼接得到256d。即每个单词都用一个256d的向量表示。

    LSTM的下面是计算attention score,采用下列公式:

l.png

    首先是concatenate_1层拼接hi和si,h是lstm的输出,s是解码器上一时刻的输出。然后经过激活函数为tanh的dense_1层,再经过dense_2层相当于乘以VT,得到attention score。attention_weight层是softmax激活层,attention score经过该层后转换为权重向量。

    dot_1层用于计算attention_weight输出的权重a和bidirectional_1层的输出向量h的点积,h包含了各个单词对应的经lstm变换后的向量,因此dot_1实际上进行的是“加权求和”的工作。因此dot_1层的输出是i个单词的语义向量。

    concatenate_2将单词的语义向量和上一时刻的输出拼接起来,得到LSTM解码器的输入向量。

    lstm_1是解码器,他有三个输入,其中c0和s0是LSTM上一时刻的隐层。该层右边的箭头表示隐层迭代的次数,即表示输出的单词次数。

    dense_4层是输出层,输出各个单词的概率。

    reshape_1和dense_3层将输出向量映射为低维向量,作为下一时刻的输入。

    repeat_vector_1层则是将lstm的输出向量复制,用于拼接编码器lstm的隐层,而后用于计算权重。

    上面就是这个网络的的详细解释。


代码实现

    训练集,我使用的训练集是在csdn上找的,但是为了加快训练速度,故我只是用了其中的四千多条体育新闻。我把数据集和代码都放在了我的github上面:https://github.com/chenjianqu/NLP 。数据集存放的位置为百度网盘:链接:https://pan.baidu.com/s/1riEHnI7KW_1alVdXurF95Q 提取码:jhpi 。


1.数据预处理

    首先先把数据读进来

data_file_path=r'D:\NLP\github\中文新闻\cnews.train.txt'
with open(data_file_path,'r',encoding='utf-8') as f:
    lines=f.readlines()
titles=[]
texts=[]
for news in lines[:10000]:
    news=news[3:]
    if('新浪体育讯' in news):
        title=news.split('新浪体育讯')[0]
        text=''.join(news.split('新浪体育讯')[1:])
        titles.append(title.strip())
        texts.append(text.strip())
print(len(titles))
print(len(texts))

    接着看看,这些数据的长度分布情况:

titles_lens=[]
texts_lens=[]
for line in titles:
    titles_lens.append(len(line))
for line in texts:
    texts_lens.append(len(line))
titles_lens.sort()
print('title_len_avg:%f'%(sum(titles_lens)/len(titles)))
print('title_len_middle:%f'%(titles_lens[int(len(titles)/2)]))
print('title_len_min:%f'%(titles_lens[0]))
print('title_len_max:%f'%(titles_lens[len(titles)-1]))
texts_lens.sort()
print('text_len_avg:%f'%(sum(texts_lens)/len(texts)))
print('text_len_middle:%f'%(texts_lens[int(len(texts)/2)]))
print('text_len_min:%f'%(texts_lens[0]))
print('text_len_max:%f'%(texts_lens[len(texts)-1]))

    结果如下:

title_len_avg:23.757654
title_len_middle:23.000000
title_len_min:13.000000
title_len_max:30.000000
text_len_avg:838.758832
text_len_middle:813.000000
text_len_min:48.000000
text_len_max:12395.000000

    接下来先导包

import jieba
from keras.models import Model
from keras.layers import Input, LSTM, Dense,Embedding
from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
import numpy as np

    然后生成映射字典,把单词转换为序列

MAX_WORDS=10000
#将句子分词并用空格隔开
inputTextList=[' '.join([w for w in jieba.cut(text)]) for text in texts]
targetTextList=[' '.join([w for w in jieba.cut(text)]) for text in titles]
#-4的原因是后面会加上4个特殊字符串,值从1开始,故词典里只设置9999个词
tokenizer=Tokenizer(num_words=MAX_WORDS-5)
tokenizer.fit_on_texts(texts=inputTextList+targetTextList)
word_index=tokenizer.word_index
# 增加特殊编码
SPECIAL_CODES = ['<PAD>', '<EOS>', '<UNK>', '<GO>']
for i,w in enumerate(SPECIAL_CODES):
    word_index[w]=MAX_WORDS-i-1
re_word_index = dict([(i, t) for t, i in word_index.items()])
with open('AutoDigestGeneration_Dict.txt','w',encoding='utf-8') as f:
    f.write(str(word_index))
#将文本映射为数字序列
input_sequences=tokenizer.texts_to_sequences(texts=inputTextList)
target_sequences=tokenizer.texts_to_sequences(texts=targetTextList)
print(len(word_index))
print(len(re_word_index))

    解释一下上面的代码:MAX_WORDS是使用字典的大小,也是解码器输出层的维度。tokenizer = Tokenizer ( num_words = MAX_WORDS-  5 )这里的num_words是字典只取训练集中出现词频前num_words的单词,-5的原因是需要加上4个特殊单词,而且字典的序号从1开始。

    再次统计一下文本长度情况:

titles_lens=[]
texts_lens=[]
for line in input_sequences:
    titles_lens.append(len(line))
for line in target_sequences:
    texts_lens.append(len(line))
titles_lens.sort()
print('title_len_avg:%f'%(sum(titles_lens)/len(titles)))
print('title_len_middle:%f'%(titles_lens[int(len(titles)/2)]))
print('title_len_min:%f'%(titles_lens[0]))
print('title_len_max:%f'%(titles_lens[len(titles)-1]))
texts_lens.sort()
print('text_len_avg:%f'%(sum(texts_lens)/len(texts)))
print('text_len_middle:%f'%(texts_lens[int(len(texts)/2)]))
print('text_len_min:%f'%(texts_lens[0]))
print('text_len_max:%f'%(texts_lens[len(texts)-1]))
#输出如下:
'''
title_len_avg:478.837965
title_len_middle:465.000000
title_len_min:26.000000
title_len_max:6457.000000
text_len_avg:10.961611
text_len_middle:11.000000
text_len_min:3.000000
text_len_max:19.000000
'''

    因此可以确定输入长度和输出长度:

Tx=500
Ty=20

    最后padding输入和输出数据,并在输出文本后加上结尾符。

import tqdm
input_arr=[]
target_arr=[]
for line in tqdm.tqdm(input_sequences):
    slen=len(line)
    if(slen<Tx):
        newline=line+[word_index['<PAD>']]*(Tx-slen)
        input_arr.append(newline)
    else:
        input_arr.append(line[:Tx])
for line in tqdm.tqdm(target_sequences):
    slen=len(line)
    if(slen<Ty):
        line.append(word_index['<EOS>'])
        newline=line+[word_index['<PAD>']]*(Ty-slen-1)
        target_arr.append(newline)
    else:
        target_arr.append(line[:Ty])
input_arr=np.array(input_arr)
target_arr=np.array(target_arr)
print(input_arr.shape)
print(target_arr.shape)


2.定义神经网络

    首先先自定义softmax层:

def softmax(x, axis=1):
    ndim = K.ndim(x)
    if ndim == 2:
        return K.softmax(x)
    elif ndim > 2:
        e = K.exp(x - K.max(x, axis=axis, keepdims=True))
        s = K.sum(e, axis=axis, keepdims=True)
        return e / s
    else:
        raise ValueError('Cannot apply softmax to a tensor that is 1D')

    接着定义一些全局网络层对象

from keras.layers import *
# 定义全局网络层对象
repeator = RepeatVector(Tx)
concatenator = Concatenate(axis=-1)
densor_tanh = Dense(32, activation = "tanh")
densor_relu = Dense(1, activation = "relu")
densor_con = Dense(256, activation = "relu")
activator = Activation(softmax, name='attention_weights')
dotor = Dot(axes = 1)

    定义Attention过程:

def one_step_attention(a, s_prev):
    # 将s_prev复制Tx次
    s_prev = repeator(s_prev)
    # 拼接BiRNN隐层状态与s_prev
    concat = concatenator([a, s_prev])
    # 计算energies
    e = densor_tanh(concat)
    energies = densor_relu(e)
    # 计算weights
    alphas = activator(energies)
    # 加权得到Context Vector
    context = dotor([alphas, a])
    return context

    下面读取词向量并设置词嵌入层

# 加载预训练好的知乎词向量
with open(r'D:\NLP\wordvector\sgns.zhihu.word\sgns.zhihu.word', 'r',encoding='utf-8') as f:
    words = set()
    word_to_vec_map = {}
    for line in f:
        line = line.strip().split()
        curr_word = line[0]
        words.add(curr_word)
        word_to_vec_map[curr_word] = np.array(line[1:], dtype=np.float64)
        
def pretrained_embedding_layer(word_to_vec_map, word_index):
    vocab_len = len(word_index) + 1        # Keras Embedding的API要求+1
    emb_dim = 300
    # 初始化embedding矩阵
    emb_matrix = np.zeros((vocab_len, emb_dim))
    # 用词向量填充embedding矩阵
    for word, index in word_index.items():
        word_vector = word_to_vec_map.get(word, np.zeros(emb_dim))
        emb_matrix[index, :] = word_vector
    # 定义Embedding层,并指定不需要训练该层的权重
    embedding_layer = Embedding(vocab_len, emb_dim, trainable=False)
    # build
    embedding_layer.build((None,))
    # set weights
    embedding_layer.set_weights([emb_matrix])
    
    return embedding_layer
# 获取Embedding layer
embedding_layer = pretrained_embedding_layer(word_to_vec_map, word_index)

    再定义一些网络层对象

n_a = 128 # The hidden size of Bi-LSTM
n_s = 128 # The hidden size of LSTM in Decoder
decoder_LSTM_cell = LSTM(n_s, return_state=True)
output_layer = Dense(MAX_WORDS, activation=softmax)
reshapor = Reshape((1, MAX_WORDS))
concator = Concatenate(axis=-1)

    最后定义整个网络模型

def model(Tx, Ty, n_encoder, n_decoder):
    """
    构造模型
    @param Tx: 输入序列的长度
    @param Ty: 输出序列的长度
    @param n_encoder: Encoder端Bi-LSTM隐层结点数
    @param n_decoder: Decoder端LSTM隐层结点数
    """
    
    # 定义输入层
    X = Input(shape=(Tx,))
    # Embedding层
    embed = embedding_layer(X)
    
    # 定义Bi-LSTM
    a = Bidirectional(LSTM(n_decoder, return_sequences=True))(embed)
    
    # Decoder端LSTM的初始状态
    s0 = Input(shape=(n_decoder,), name='s0')
    c0 = Input(shape=(n_decoder,), name='c0')
    
    # Decoder端LSTM的初始输入
    out0 = Input(shape=(MAX_WORDS, ), name='out0')
    out = reshapor(out0)
    
    s = s0
    c = c0
    
    # 模型输出列表,用来存储翻译的结果
    outputs = []
    
    # Decoder端,迭代Ty轮,每轮生成一个翻译结果
    for t in range(Ty):
        # 获取Context Vector
        context = one_step_attention(a, s)
        # 将Context Vector与上一轮的翻译结果进行concat
        con_out=densor_con(reshapor(out))
        context = concator([context,con_out])
        s, _, c = decoder_LSTM_cell(context, initial_state=[s, c])
        # 将LSTM的输出结果与全连接层链接
        out = output_layer(s)
        # 存储输出结果
        outputs.append(out)
    model = Model([X, s0, c0, out0], outputs) 
    return model

model = model(Tx, Ty, n_a, n_s)

    画出网络结构

from keras.utils import plot_model
plot_model(model,to_file='attentionTest_model.png',show_shapes=True)


3.训练模型

    定义一个数据生成器是必要的

from keras.utils import to_categorical
def train_gen(X,Y,Ty,n_s,batch_size=64):
    xlen=X.shape[0]
    permutation = np.random.permutation(xlen)
    x = X[permutation]
    y = Y[permutation]
    num_batches = int(xlen/batch_size)
    
    while 1:
        for i in range(num_batches):
            x_batch=x[i*batch_size:(i+1)*batch_size]
            y_batch=y[i*batch_size:(i+1)*batch_size]
            s0=np.zeros((batch_size,n_s))
            c0=np.zeros((batch_size,n_s))
            out0=np.zeros((batch_size,MAX_WORDS))
            outputs=np.zeros((batch_size,Ty,MAX_WORDS))
            for i,line in enumerate(y_batch):
                for j,index in enumerate(line):
                    outputs[i,j,index]=1
                    
            #outputs=np.array(list(map(lambda x: to_categorical(x, num_classes=MAX_WORDS), y_batch)))
            outputs = list(outputs.swapaxes(0,1))
            yield [x_batch,s0,c0,out0],outputs

    编译模型,使用默认的优化器

from keras.optimizers import Adam
from keras.models import load_model
import keras.backend as K
out = model.compile(optimizer='rmsprop',
                    metrics=['accuracy'],
                    loss='categorical_crossentropy')

    训练模型

xlen=len(input_arr)
model.fit_generator(train_gen(input_arr,target_arr,Ty,n_s,batch_size=8),
                   steps_per_epoch=int(xlen/4),
                   epochs=3)

    时间原因,只训练了3轮,loss从143降到78,说明这个网络是可行的。


4.预测

    使用某个训练数据预测一下

import jieba
with open('AutoDigestGeneration_Dict.txt','r',encoding='utf-8') as f:
    word_index_str=f.read()
    
    
word_index=eval(word_index_str)
re_word_index = dict([(i, t) for t, i in word_index.items()])
Tx=500
n_decoder=128
def make_prediction(sentence):
    # 将句子分词后转化为数字编码
    input_seq=[]
    for w in jieba.cut(sentence):
        if w in word_index:
            input_seq.append(word_index[w])
            
    if(len(input_seq)<=Tx):
        input_seq = np.array(input_seq + [word_index['PAD']] * (Tx - len(input_seq)))
    else:
        input_seq = np.array(input_seq[:Tx])
    s0=np.zeros((1,n_decoder))
    c0=np.zeros((1,n_decoder))
    out0=np.zeros((1,MAX_WORDS))
    
    print(input_seq)
    
    # 翻译结果
    preds = model.predict([input_seq.reshape(-1,Tx), s0, c0, out0])
    predictions = np.argmax(preds, axis=-1)
    
    print(predictions)
    
    # 转换为单词
    idx = [re_word_index.get(idx[0], "<UNK>") for idx in predictions]
    
    # 返回句子
    return " ".join(idx)
text='''
尽管拜伦-戴维斯至今还处在伤病当中,但他距离复出的日子已经不远了。据洛杉矶媒体的消息,戴维斯现在已经恢复了和全队的合练,并且将在下周重新回到赛场。到时候,人们又能看到场边那个性感女神一般的女人为他在现场加油助威了。戴维斯和杰西卡还有他的丈夫都是非常要好的朋友,杰西卡一有空都会去给戴维斯和他的球队捧场,她也很快成为了戴维斯的钟情粉丝。因此,她也是NBA中除了帕克的妻子伊娃之外,知名度最高的女球迷。杰西卡-阿尔芭年轻时就拥有很高的表演天赋,她13岁开始拍片,2005年通过主演《罪恶之城》、《神奇四侠》达到事业顶峰。虽然大多以青春靓丽的“花瓶”形象出现,但她的美丽身影通常是影片中最迷人的风景。连续三年入选了《男人帮》性感女星评选的前十名,是美国“最性感的女星”之一。有球迷甚至把她称作是甜美的巧克力美人,她1981年4月28日出生于加利福尼亚的波莫纳市,拥有5国混血血统。妈妈拥有加拿大、法国、和丹麦人的混血,爸爸是墨西哥和美国的混血儿。这就解释了杰西卡棕色的肌肤和漂亮的深棕色眼睛。金牛座的女人天生就是一个精力充沛,生活欲望强烈且楚楚动人的女生。美国的著名演员杰西卡-阿尔芭就是这样的,她很会按照女人的特点无忧无虑地生活。她魅力无限,渴望经历爱情生活的全过程:恋爱、结婚、家庭、孩子和美味佳肴对她来说都是生活中不可错过的事情。令人一直感到意外的是身在好莱坞这个大染缸,杰西卡依然能坚守住自己的那片处女地。她曾连续三年被评为“全球最性感的女人”前五名,可是她享受性爱却拒绝裸戏。她甚至把《花花公子》告上法庭,原因是《花花公子》在没有征求她允许的情况下擅自“盗用”了她的比基尼剧照作了杂志封面。虽然一直在娱乐圈里留下了圣洁的名声,但最近网站上曝光的一组露点照片却让杰西卡-阿尔芭陷入了窘境。在美国网站《egotastic》曝光的这组照片中,有多张杰西卡-阿尔芭大着肚子的露点照片,这让人们完全改变了以往她在人们心中留下的印象。当初和花花公子闹上公堂的时候,杰西卡曾经对媒体说:“你可能不会相信,我拒绝裸戏!在众人面前不穿衣服我会不知所措!登上《花花公子》封面这会给很多人误解,以为里面会有我的裸照。”可现在,她的半裸照片却清晰的被挂在了网站上。只是不知道戴维斯看了这些照片之后会作何感想。(乳娃娃)
'''
print(text)
result=make_prediction(text)
print(result)

    结果可想而知。


参考文献

[1]天雨粟.基于Keras框架实现加入Attention与BiRNN的机器翻译模型.https://zhuanlan.zhihu.com/p/37290775. 2018-05-25


首页 所有文章 机器人 计算机视觉 自然语言处理 机器学习 编程随笔 关于