TensorFlow 工程实战(四):使用带注意力机制的模型分析评论者是否满意

本文介绍了如何利用 tf.keras 接口搭建一个只带有注意力机制的模型,实现文本分类。

本文摘选自电子工业出版社出版、李金洪编著的 《深度学习之 TensorFlow 工程化项目实战》 一书的实例 41:TensorFlow 用带注意力机制的模型分析评论者是否满意。

实例描述

有一个记录评论语句的数据集,分为正面和负面两种情绪。通过训练模型,让其学会正面与负面两种情绪对应的语义。

注意力机制是解决 NLP 任务的一种方法。其内部的实现方式与卷积操作非常类似。在脱离 RNN 结构的情况下,单独的注意力机制模型也可以很好地完成 NLP 任务。具体做法如下。

一、熟悉样本:了解 tf.keras 接口中的电影评论数据集

IMDB 数据集中含有 25000 条电影评论,从情绪的角度分为正面、负面两类标签。该数据集相当于图片处理领域的 MNIST 数据集,在 NLP 任务中经常被使用。

在 tf.keras 接口中,集成了 IMDB 数据集的下载及使用接口。该接口中的每条样本内容都是以向量形式存在的。

调用 tf.keras.datasets.imdb 模块下的 load_data 函数即可获得数据,该函数的定义如下:

复制代码

def load_data(path='imdb.npz',#默认的数据集文件
num_words=None,#单词数量,即文本转向量后的最大索引
skip_top=0,#跳过前面频度最高的几个词
maxlen=None,#只取小于该长度的样本
seed=113,#乱序样本的随机种子
start_char=1,#每一组序列数据最开始的向量值。
oov_char=2,#在字典中,遇到不存在的字符用该索引来替换
index_from=3,#大于该数的向量将被认为是正常的单词
**kwargs):#为了兼容性而设计的预留参数

该函数会返回两个元组类型的对象:

  • (x_train, y_train):训练数据集。如果指定了 num_words 参数,则最大索引值是 num_words-1。如果指定了 maxlen 参数,则序列长度大于 maxlen 的样本将被过滤掉。
  • (x_test, y_test):测试数据集。

提示:

由于 load_data 函数返回的样本数据没有进行对齐操作,所以还需要将其进行对齐处理(按照指定长度去整理数据集,多了的去掉,少了的补 0)后才可以使用。

二、代码实现:将 tf.keras 接口中的 IMDB 数据集还原成句子

本节代码共分为两部分,具体如下。

  • 加载 IMDB 数据集及字典:用 load_data 函数下载数据集,并用 get_word_index 函数下载字典。
  • 读取数据并还原句子:将数据集加载到内存,并将向量转换成字符。

1. 加载 IMDB 数据集及字典

在调用 tf.keras.datasets.imdb 模块下的 load_data 函数和 get_word_index 函数时,系统会默认去网上下载预处理后的 IMDB 数据集及字典。如果由于网络原因无法成功下载 IMDB 数据集与字典,则可以加载本书的配套资源:IMDB 数据集文件“imdb.npz”与字典“imdb_word_index.json”。

将 IMDB 数据集文件“imdb.npz”与字典文件“imdb_word_index.json”放到本地代码的同级目录下,并对 tf.keras.datasets.imdb 模块的源代码文件中的函数 load_data 进行修改,关闭该函数的下载功能。具体如下所示。

(1)找到 tf.keras.datasets.imdb 模块的源代码文件。以作者本地路径为例,具体如下:

C:\local\Anaconda3\lib\site-packages\tensorflow\python\keras\datasets\ imdb.py

(2)打开该文件,在 load_data 函数中,将代码的第 80~84 行注释掉。具体代码如下:

复制代码

# origin_folder = 'https://storage.googleapis.com/tensorflow/tf-keras-datasets/'
# path = get_file(
# path,
# origin=origin_folder + 'imdb.npz',
# file_hash='599dadb1135973df5b59232a0e9a887c')

(3)在 get_word_index 函数中,将代码第 144~148 行注释掉。具体代码如下:

复制代码

# origin_folder = 'https://storage.googleapis.com/tensorflow/tf-keras-datasets/'
# path = get_file(
# path,
# origin=origin_folder + 'imdb_word_index.json',
# file_hash='bfafd718b763782e994055a2d397834f')

2. 读取数据并还原其中的句子

从数据集中取出一条样本,并用字典将该样本中的向量转成句子,然后输出结果。具体代码如下:

代码 1 用 keras 注意力机制模型分析评论者的情绪

复制代码

from __future__ import print_function
import tensorflow as tf
import numpy as np
attention_keras = __import__("8-10 keras 注意力机制模型 ")

#定义参数
num_words =20000
maxlen =80
batch_size =32

#加载数据
print('Loading data...')
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.imdb.load_data(path='./imdb.npz',num_words=num_words)
print(len(x_train),'train sequences')
print(len(x_test),'test sequences')
print(x_train[:2])
print(y_train[:10])
word_index = tf.keras.datasets.imdb.get_word_index('./imdb_word_index.json')#生成字典:单词与下标对应
reverse_word_index = dict([(value, key)for(key, value)inword_index.items()])#生成反向字典:下标与单词对应

decoded_newswire =' '.join([reverse_word_index.get(i-3,'?')foriinx_train[0]])
print(decoded_newswire)

代码第 21 行,将样本中的向量转化成单词。在转化过程中,将每个向量向前偏移了 3 个位置。这是由于在调用 load_data 函数时使用了参数 index_from 的默认值 3(见代码第 13 行),表示数据集中的向量值,从 3 以后才是字典中的内容。

在调用 load_data 函数时,如果所有的参数都使用默认值,则所生成的数据集会比字典中多 3 个字符“padding”(代表填充值)、“start of sequence”(代表起始位置)和“unknown”(代表未知单词)分别对应于数据集中的向量 0、1、2。

代码运行后,输出以下结果:

(1)数据集大小为 25000 条样本。具体内容如下:

复制代码

25000train sequences
25000test sequences

(2)数据集中第 1 条样本的内容。具体内容如下:

复制代码

[1,14,22,16,43,530,973,1622,1385,65,458,4468,66,3941,4,173,36,256,5,25,100, ……15,297,98,32,2071,56,26,141,6,194,7486,18,4,226,22,21,134,476,26,480,5,144,30,5535,18,51,36,28,224,92,25,104,4,226,65,16,38,1334,88,12,16,283,5,16,4472,113,103,32,15,16,5345,19,178,32]

结果中第一个向量为 1,代表句子的起始标志。可以看出,tf.keras 接口中的 IMDB 数据集为每个句子都添加了起始标志。这是因为调用函数 load_data 时用参数 start_char 的默认值 1(见代码第 13 行)。

(3)前 10 条样本的分类信息。具体内容如下:

[1 0 0 1 0 0 1 0 1 0]

(4)第 1 条样本数据的还原语句。具体内容如下:

? this film was just brilliant casting location scenery story direction everyone’s really suited the part they played and you could just imagine being there robert ? is an amazing actor and now the …… someone’s life after all that was shared with us all

结果中的第一个字符为“?”,表示该向量在字典中不存在。这是因为该向量值为 1,代表句子的起始信息。而字典中的内容是从向量 3 开始的。在将向量转换成单词的过程中,将字典中不存在的字符替换成了“?”(见代码第 21 行)。

三、代码实现:用 tf.keras 接口开发带有位置向量的词嵌入层

在 tf.keras 接口中实现自定义网络层,需要以下几个步骤。

(1)将自己的层定义成类,并继承 tf.keras.layers.Layer 类。

(2)在类中实现 __init__ 方法,用来对该层进行初始化。

(3)在类中实现 build 方法,用于定义该层所使用的权重。

(4)在类中实现 call 方法,用来相应调用事件。对输入的数据做自定义处理,同时还可以支持 masking(根据实际的长度进行运算)。

(5)在类中实现 compute_output_shape 方法,指定该层最终输出的 shape。

按照以上步骤,实现带有位置向量的词嵌入层。

具体代码如下:

代码 2 keras 注意力机制模型

复制代码

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import backend as K#载入 keras 的后端实现

classPosition_Embedding(keras.layers.Layer):#定义位置向量类
def__init__(self, size=None, mode='sum', **kwargs):
self.size = size#定义位置向量的大小,必须为偶数,一半是 cos,一半是 sin
self.mode = mode
super(Position_Embedding,self).__init__(**kwargs)

defcall(self, x):#实现调用方法
if(self.size == None)or(self.mode =='sum'):
self.size = int(x.shape[-1])
position_j =1. / K.pow(10000.,2* K.arange(self.size /2, dtype='float32') /self.size )
position_j = K.expand_dims(position_j,0)
#按照 x 的 1 维数值累计求和,生成序列。
position_i = tf.cumsum(K.ones_like(x[:,:,0]),1)-1
position_i = K.expand_dims(position_i,2)
position_ij = K.dot(position_i, position_j)
position_ij = K.concatenate([K.cos(position_ij), K.sin(position_ij)],2)
ifself.mode =='sum':
returnposition_ij + x
elifself.mode =='concat':
returnK.concatenate([position_ij, x],2)

defcompute_output_shape(self, input_shape):#设置输出形状
ifself.mode =='sum':
returninput_shape
elifself.mode =='concat':
return(input_shape[0], input_shape[1], input_shape[2]+self.size)

代码第 3 行是原生 Keras 框架的内部语法。由于 Keras 框架是一个前端的代码框架,它通过 backend 接口来调用后端框架的实现,以保证后端框架的无关性。

代码第 5 行定义了类 Position_Embedding,用于实现带有位置向量的词嵌入层。它是用 tf.keras 接口实现的,同时也提供了位置向量的两种合入方式。

  • 加和方式:通过 sum 运算,直接把位置向量加到原有的词嵌入中。这种方式不会改变原有的维度。
  • 连接方式:通过 concat 函数将位置向量与词嵌入连接到一起。这种方式会在原有的词嵌入维度之上扩展出位置向量的维度。

代码第 11 行是 Position_Embedding 类 call 方法的实现。当调用 Position_Embedding 类进行位置向量生成时,系统会调用该方法。

在 Position_Embedding 类的 call 方法中,先对位置向量的合入方式进行判断,如果是 sum 方式,则将生成的位置向量维度设置成输入的词嵌入向量维度。这样就保证了生成的结果与输入的结果维度统一,在最终的 sum 操作时不会出现错误。

四、代码实现:用 tf.keras 接口开发注意力层

下面用 tf.keras 接口开发基于内部注意力的多头注意力机制 Attention 类。

在 Attention 类中用更优化的方法来实现多头注意力机制的计算。该方法直接将多头注意力机制中最后的全连接网络中的权重提取出来,并将原有的输入 Q、K、V 按照指定的计算次数展开,使它们彼此以直接矩阵的方式进行计算。

这种方法采用了空间换时间的思想,省去了循环处理,提升了运算效率。

具体代码如下:

代码 2 keras 注意力机制模型(续)

复制代码

classAttention(keras.layers.Layer):#定义注意力机制的模型类
def__init__(self, nb_head, size_per_head, **kwargs):
self.nb_head = nb_head#设置注意力的计算次数 nb_head
#设置每次线性变化为 size_per_head 维度
self.size_per_head = size_per_head
self.output_dim = nb_head*size_per_head#计算输出的总维度
super(Attention,self).__init__(**kwargs)

defbuild(self, input_shape):#实现 build 方法,定义权重
self.WQ =self.add_weight(name='WQ',
shape=(int(input_shape[0][-1]),self.output_dim),
initializer='glorot_uniform',
trainable=True)
self.WK =self.add_weight(name='WK',
shape=(int(input_shape[1][-1]),self.output_dim),
initializer='glorot_uniform',
trainable=True)
self.WV =self.add_weight(name='WV',
shape=(int(input_shape[2][-1]),self.output_dim),
initializer='glorot_uniform',
trainable=True)
super(Attention,self).build(input_shape)
#定义 Mask 方法,按照 seq_len 的实际长度对 inputs 进行计算
defMask(self, inputs, seq_len, mode='mul'):
ifseq_len ==None:
returninputs
else:
mask = K.one_hot(seq_len[:,0], K.shape(inputs)[1])
mask =1- K.cumsum(mask,1)
for_inrange(len(inputs.shape)-2):
mask = K.expand_dims(mask,2)
ifmode =='mul':
returninputs * mask
ifmode =='add':
returninputs - (1- mask) *1e12

defcall(self, x):
iflen(x) ==3:#解析传入的 Q_seq、K_seq、V_seq
Q_seq,K_seq,V_seq = x
Q_len,V_len = None,None#Q_len、V_len 是 mask 的长度
elif len(x) ==5:
Q_seq,K_seq,V_seq,Q_len,V_len = x

#对 Q、K、V 做线性变换,一共做 nb_head 次,每次都将维度转化成 size_per_head
Q_seq = K.dot(Q_seq,self.WQ)
Q_seq = K.reshape(Q_seq, (-1, K.shape(Q_seq)[1],self.nb_head,self.size_per_head))
Q_seq = K.permute_dimensions(Q_seq, (0,2,1,3))#排列各维度的顺序。
K_seq = K.dot(K_seq,self.WK)
K_seq = K.reshape(K_seq, (-1, K.shape(K_seq)[1],self.nb_head,self.size_per_head))
K_seq = K.permute_dimensions(K_seq, (0,2,1,3))
V_seq = K.dot(V_seq,self.WV)
V_seq = K.reshape(V_seq, (-1, K.shape(V_seq)[1],self.nb_head,self.size_per_head))
V_seq = K.permute_dimensions(V_seq, (0,2,1,3))
#计算内积,然后计算 mask,再计算 softmax
A = K.batch_dot(Q_seq, K_seq, axes=[3,3]) /self.size_per_head**0.5
A = K.permute_dimensions(A, (0,3,2,1))
A =self.Mask(A, V_len,'add')
A = K.permute_dimensions(A, (0,3,2,1))
A = K.softmax(A)
#将 A 再与 V 进行内积计算
O_seq = K.batch_dot(A, V_seq, axes=[3,2])
O_seq = K.permute_dimensions(O_seq, (0,2,1,3))
O_seq = K.reshape(O_seq, (-1, K.shape(O_seq)[1],self.output_dim))
O_seq =self.Mask(O_seq, Q_len,'mul')
returnO_seq

defcompute_output_shape(self, input_shape):
return(input_shape[0][0], input_shape[0][1],self.output_dim)

在代码第 9 行(书中第 39 行)的 build 方法中,为注意力机制中的三个角色 Q、K、V 分别定义了对应的权重。该权重的形状为 [input_shape,output_dim]。其中:

  • input_shape 是 Q、K、V 中对应角色的输入维度。
  • output_dim 是输出的总维度,即注意力的运算次数与每次输出的维度乘积(见代码 36 行)。

提示:

多头注意力机制在多次计算时权重是不共享的,这相当于做了多少次注意力计算,就定义多少个全连接网络。所以在代码第 9~21 行(书中第 39~51 行),将权重的输出维度定义成注意力的运算次数与每次输出的维度乘积。

代码第 47 行(书中第 77 行)调用了 K.permute_dimensions 函数,该函数实现对输入维度的顺序调整,相当于 transpose 函数的作用。

代码第 37 行(书中第 67 行)是 Attention 类的 call 函数,其中实现了注意力机制的具体计算方式,步骤如下:

(1)对注意力机制中的三个角色的输入 Q、K、V 做线性变化(见代码第 45~53 行,书中第 75~83 行)。

(2)调用 batch_dot 函数,对第(1)步线性变化后的 Q 和 K 做基于矩阵的相乘计算(见代码第 55~59 行,书中第 85~89 行)。

(3)调用 batch_dot 函数,对第(2)步的结果与第(1)步线性变化后的 V 做基于矩阵的相乘计算(见代码第 55~59 行,书中第 85~89 行)。

提示:

这里的全连接网络是不带偏置权重 b 的。没有偏置权重的全连接网络在对数据处理时,本质上与矩阵相乘运算是一样的。

因为在整个计算过程中,需要将注意力中的三个角色 Q、K、V 进行矩阵相乘,并且在最后还要与全连接中的矩阵相乘,所以可以将这个过程理解为是 Q、K、V 与各自的全连接权重进行矩阵相乘。因为乘数与被乘数的顺序是与结果无关的,所以在代码第 37 行(书中第 67 行)的 call 方法中,全连接权重最先参与了运算,并不会影响实际结果。

五、代码实现:用 tf.keras 接口训练模型

用定义好的词嵌入层与注意力层搭建模型,进行训练。具体步骤如下:

(1)用 Model 类定义一个模型,并设置好输入 / 输出的节点。

(2)用 Model 类中的 compile 方法设置反向优化的参数。

(3)用 Model 类的 fit 方法进行训练。

具体代码如下:

代码 1 用 keras 注意力机制模型分析评论者的情绪(续)

复制代码

#数据对齐
x_train = tf.keras.preprocessing.sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = tf.keras.preprocessing.sequence.pad_sequences(x_test, maxlen=maxlen)
print('Pad sequences x_train shape:', x_train.shape)

#定义输入节点
S_inputs = tf.keras.layers.Input(shape=(None,), dtype='int32')

#生成词向量
embeddings = tf.keras.layers.Embedding(num_words,128)(S_inputs)
embeddings = attention_keras.Position_Embedding()(embeddings) #默认使用同等维度的位置向量

#用内部注意力机制模型处理
O_seq = attention_keras.Attention(8,16)([embeddings,embeddings,embeddings])

#将结果进行全局池化
O_seq = tf.keras.layers.GlobalAveragePooling1D()(O_seq)
#添加 dropout
O_seq = tf.keras.layers.Dropout(0.5)(O_seq)
#输出最终节点
outputs = tf.keras.layers.Dense(1, activation='sigmoid')(O_seq)
print(outputs)
#将网络结构组合到一起
model = tf.keras.models.Model(inputs=S_inputs, outputs=outputs)

#添加反向传播节点
model.compile(loss='binary_crossentropy',optimizer='adam', metrics=['accuracy'])

#开始训练
print('Train...')
model.fit(x_train, y_train, batch_size=batch_size,epochs=5, validation_data=(x_test, y_test))

代码第 14 行(书中第 36 行)构造了一个列表对象作为输入参数。该列表对象里含有 3 个同样的元素——embeddings,表示使用的是内部注意力机制。

代码第 17~22 行(书中第 39~44 行),将内部注意力机制的结果 O_seq 经过全局池化和一个全连接层处理得到了最终的输出节点 outputs。节点 outputs 是一个 1 维向量。

代码第 27 行(书中第 49 行),用 model.compile 方法,构建模型的反向传播部分,使用的损失函数是 binary_crossentropy,优化器是 adam。

六、运行程序

代码运行后,生成以下结果:

复制代码

Epoch1/5
25000/25000[==============================] -42s2ms/step - loss:0.5357- acc:0.7160- val_loss:0.5096- val_acc:0.7533
Epoch2/5
25000/25000[==============================] -36s1ms/step - loss:0.3852- acc:0.8260- val_loss:0.3956- val_acc:0.8195
Epoch3/5
25000/25000[==============================] -36s1ms/step - loss:0.3087- acc:0.8710- val_loss:0.4135- val_acc:0.8184
Epoch4/5
25000/25000[==============================] -36s1ms/step - loss:0.2404- acc:0.9011- val_loss:0.4501- val_acc:0.8094
Epoch5/5
25000/25000[==============================] -35s1ms/step - loss:0.1838- acc:0.9289- val_loss:0.5303- val_acc:0.8007

可以看到,整个数据集迭代 5 次后,准确率达到了 80% 以上。

提示:

本节实例代码可以直接在 TensorFlow 1.x 与 2.x 两个版本中运行,不需要任何改动。

本文摘选自电子工业出版社出版、李金洪编著的 《深度学习之 TensorFlow 工程化项目实战》 一书 ,更多实战内容 点此 查看。

本文经授权发布,转载请联系电子工业出版社。