关于模型 VistaNet 的原理,我已在之前的文章 基于多模态数据的情感分析 中进行了详细介绍。本文是其姊妹篇,主要以搭建模型的代码为主,对算法原理不清楚的小伙伴建议先熟悉一下原理。
鉴于有很多小伙伴评论和私信问我有没有此模型的代码,最近两天我对 VistaNet 进行了复现,本文会结合算法的原理进行代码的讲解,代码中加入充分注释以易理解。
Tips: 文本代码使用 TF2.x 实现。
下面进入正题…
3.1 Word Encoder + Attention
以下是自注意力的计算流程,表示对 GRU 层每时刻的输出 h 进行行加权求和。
下面是自注意力层的实现代码,建议结合公式理解代码,并且注意每次运算后张量 shape 的变化。
import tensorflow as tf
from tensorflow.keras.layers import Layer
from tensorflow.keras.layers import Dense, Conv2D, MaxPool2D, Dropout, Flatten
import tensorflow.keras.backend as K
# 自注意力层
class Self_Attention(Layer):
# input: [None, n, k]输入为n个维度为k的词向量
# mask: [None, n]表示填充词位置的mask
# output: [None, k]输出n个词向量的加权和
def __init__(self, dropout_rate=0.0):
super(Self_Attention, self).__init__()
self.dropout_layer = Dropout(dropout_rate)
def build(self, input_shape):
self.k = input_shape[0][-1] #词向量维度
self.W_layer = Dense(self.k, activation='tanh', use_bias=True) #对h的映射
self.U_weight = self.add_weight(name='U', shape=(self.k, 1), #U记忆矩阵
initializer=tf.keras.initializers.glorot_uniform(),
trainable=True)
def call(self, inputs, **kwargs):
input, mask = inputs #输入有两部分[input, mask]
if K.ndim(input) != 3:
raise ValueError("The dim of inputs is required 3 but get {}".format(K.ndim(input)))
# 计算score
x = self.W_layer(input) # [None, n, k]
score = tf.matmul(x, self.U_weight) # [None, n, 1]
score = self.dropout_layer(score) # 随机dropout(也可不要)
# softmax之前进行mask
mask = tf.expand_dims(mask, axis=-1) # [None, n, 1]
padding = tf.cast(tf.ones_like(mask)*(-2**31+1), tf.float32) #mask的位置填充很小的负数
score = tf.where(tf.equal(mask, 0), padding, score)
score = tf.nn.softmax(score, axis=1) # [None, n, 1] mask之后计算softmax
# 向量加权和
output = tf.matmul(input, score, transpose_a=True) # [None, k, 1]
output /= self.k**0.5 # 归一化
output = tf.squeeze(output, axis=-1) # [None, k]
return output
3.2 Sentence Encoder + Attention
下面是图像与句向量之间的注意力计算公式,首先是分别对图像向量与句向量的非线性转换,然后计算两者的内积,再乘上记忆矩阵 V,经过 softmax 得到对应的权重。
class Image_Text_Attention(Layer):
# 该层的输入有三部分image_emb、seq_emb、mask
# image_emb: [None, M, 4096]对应M个4096维的图像向量(由vgg16提取得到),每条评论的M可以不一致
# seq_emb: [None, L, k]表示L个维度为k的句向量
# mask: [None, L]表示L个句子的mask(因为存在句子数不足L的文档,有被padding的句子)
# output: [None, M, k]输出为M个图像对应的文档向量表示
def __init__(self, dropout_rate=0.0):
super(Image_Text_Attention, self).__init__()
self.dropout_layer = Dropout(dropout_rate)
def build(self, input_shape):
self.l = input_shape[1][1] # 句子个数
self.k = input_shape[1][-1] # 句向量维度
self.img_layer = Dense(1, activation='tanh', use_bias=True) # 将image_emb映射到1维
self.seq_layer = Dense(1, activation='tanh', use_bias=True) # 将seq_emb也映射到1维(方便内积)
self.V_weight = self.add_weight(name='V', shape=(self.l, self.l),
initializer=tf.keras.initializers.glorot_uniform(),
trainable=True)
def call(self, inputs, **kwargs):
image_emb, seq_emb, mask = inputs # 输入为三部分[image_emb, seq_emb, mask]
# 线性映射
p = self.img_layer(image_emb) # [None, M, 1]
q = self.seq_layer(seq_emb) # [None, L, 1]
# 内积+映射(计算score)
emb = tf.matmul(p, q, transpose_b=True) # [None, M, L]
emb = emb + tf.transpose(q, [0, 2, 1]) # [None, M, L]
emb = tf.matmul(emb, self.V_weight) # [None, M, L]
score = self.dropout_layer(emb) # 随机dropout(也可不要)
# mask
mask = tf.tile(tf.expand_dims(mask, axis=1), [1, score.shape[1], 1]) # [None, M, L],将mask矩阵复制到与score相同的形状
padding = tf.cast(tf.ones_like(mask) * (-2 ** 31 + 1), tf.float32)
score = tf.where(tf.equal(mask, 0), padding, score)
score = tf.nn.softmax(score, axis=-1) # [None, M, L]
# 向量加权和
output = tf.matmul(score, seq_emb) # [None, M, k]
output /= self.k**0.5 # 归一化
return output
3.3 Document Encoder + Attention
该部分的注意力计算公式如下,同第一层的自注意力层,是将 M 个文档向量加权求和得到一个文档向量,该层直接使用之前的 Self_Attention 层即可。
3.4 VGG-16
VGG16 的原理这里不再赘述,可自行查找其原理,并结合起来理解以下代码。
class VggNet(Layer):
def __init__(self, block_nums, out_dim=1000, dropout_rate=0.0):
# block_nums: [list],表示每个模块中连续卷积的个数,vgg16为[2,2,3,3,3]
# out_dim: 该层最终的输出维度
super(VggNet, self).__init__()
self.cnn_block1 = self.get_cnn_block(64, block_nums[0])
self.cnn_block2 = self.get_cnn_block(128, block_nums[1])
self.cnn_block3 = self.get_cnn_block(256, block_nums[2])
self.cnn_block4 = self.get_cnn_block(512, block_nums[3])
self.cnn_block5 = self.get_cnn_block(512, block_nums[4])
self.out_block = self.get_out_block([4096, 4096], out_dim, dropout_rate)
self.flatten = Flatten()
# 单个卷积模块的搭建(layer_num个连续卷积加一个池化)
def get_cnn_block(self, out_channel, layer_num):
layer = []
for i in range(layer_num):
layer.append(Conv2D(filters=out_channel,
kernel_size=3,
padding='same',
activation='relu'))
layer.append(MaxPool2D(pool_size=(2,2), strides=2))
return tf.keras.models.Sequential(layer) #封装成一个模块
# 输出模块的搭建(连续的全连接层)
def get_out_block(self, hidden_units, outdim, dropout_rate):
layer = []
for i in range(len(hidden_units)-1):
layer.append(Dense(hidden_units[i], activation='relu'))
layer.append(Dropout(dropout_rate))
layer.append(Dense(outdim, activation='softmax'))
return tf.keras.models.Sequential(layer) #封装成一个模块
def call(self, inputs, **kwargs):
# 标准输入:[batchsize, 224, 224, 3]
if K.ndim(inputs) != 4:
raise ValueError("The dim of inputs is required 4 but get {}".format(K.ndim(inputs)))
x = inputs
cnn_block_list = [self.cnn_block1, self.cnn_block2, self.cnn_block3, self.cnn_block4, self.cnn_block5]
# 卷积层
for cnn_block in cnn_block_list:
x = cnn_block(x)
x = self.flatten(x)
# 输出层
output = self.out_block(x)
return output
搭建好了所有需要使用的 Layer 后,下面开始整体模型的搭建。
import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Dense, GRU, Bidirectional
class VistaNet(Model):
def __init__(self, block_nums=[2,2,3,3,3], out_dim=4096, vgg_dropout=0.0, attention_dropout=0.0, gru_units=[64, 128], class_num=5):
# block_nums: vgg16各层卷积的个数
# out_dim: vgg16输出维度
# dropout: 各层的dropout系数
# gru_units: 两个单层双向GRU的输出维度
# class_num: 模型最终输出维度
super(VistaNet, self).__init__()
self.vgg16 = VggNet(block_nums, out_dim, vgg_dropout) # VGG-16
self.word_self_attention = Self_Attention(attention_dropout)# 第一层中的自注意力
self.img_seq_attention = Image_Text_Attention(attention_dropout) # 第二层中的Image-Text注意力
self.doc_self_attention = Self_Attention(attention_dropout) # 第三层中的自注意力
# 两个单层双向GRU层
self.BiGRU_layer1 = Bidirectional(GRU(units=gru_units[0],
kernel_regularizer=tf.keras.regularizers.l2(1e-5),
recurrent_regularizer=tf.keras.regularizers.l2(1e-5),
return_sequences=True),
merge_mode='concat')
self.BiGRU_layer2 = Bidirectional(GRU(units=gru_units[1],
kernel_regularizer=tf.keras.regularizers.l2(1e-5),
recurrent_regularizer=tf.keras.regularizers.l2(1e-5),
return_sequences=True),
merge_mode='concat')
self.output_layer = Dense(class_num, activation='softmax') # 任务层
def call(self, inputs, training=None, mask=None):
# 输入inputs包含三部分:(假设batchsize为1,省略掉第一维None)
# image_inputs: [M, 227, 227, 3]一条评论样本包含的M个图像
# text_inputs: [L, T, k]一条样本表示一个文档,所以输入张量为3维:[最大句子数,最大单词数, 词向量维度]
# mask: [L, T]每句话中mask词的位置
image_inputs, text_inputs, mask = inputs
# 获取图像emb向量
image_emb = self.vgg16(image_inputs) # [M, 224, 224, 3] -> [M, 4096]
# 经过GRU层获取词向量word_emb
word_emb = self.BiGRU_layer1(text_inputs) # [L, T, k] -> [L, T, 2k]
# 经过self_attention得到句向量seq_emb
input = [word_emb, mask] # [L, T, 2k] & [L, T]
seq_emb = self.word_self_attention(input) # [L, T, 2k] -> [L, 2k]
# 经过GRU层提取语义
input = tf.expand_dims(seq_emb, axis=0) # [1, L, 2k]
seq_emb = self.BiGRU_layer2(input) # [1, L, 2k] -> [1, L, 4k]
# 经过img_seq_attention得到M个文档向量doc_emb
image_emb = tf.expand_dims(image_emb, axis=0) # [1, M, 4096]
mask = tf.argmax(mask, axis=1) # [L, ]
mask = tf.expand_dims(mask, axis=0) # [1, L]
input = [image_emb, seq_emb, mask]
doc_emb = self.img_seq_attention(input) # [1, M, 4k] M个文档向量表示
# 经过self_attention得到最终的文档向量
mask = tf.ones(shape=[1, doc_emb.shape[1]]) # [1, M],全为非0值,因为该注意力无需mask
input = [doc_emb, mask]
D_emb = self.doc_self_attention(input) # [1, 4k]
# output layer
output = self.output_layer(D_emb) # [1, class_num]
return output
到此,VistaNet 模型的整体搭建就结束了。
番外篇:
本没打算对该模型进行复现,因为一直没有找到对应的数据集,搭好了也没法调试。但应广大小伙伴的需求,还是复现了一下。然后自己生成虚拟样本调试了一番,顺利跑通了该模型。
model = VistaNet()
# 随机生成一条样本
image_input = np.random.rand(6, 224, 224, 3) #6个评论图像
text_input = np.random.rand(50, 128, 256) #包含50句话,每句话128个词的文档
mask = np.random.rand(50, 128) #50句话中每个词的padding位置
input = [image_input, text_input, mask]
pre = model(input) # [1,class_num] class_num个类别的输出
输入数据格式说明: (一条样本)
需要复现的小伙伴可参考这份代码。希望看完此文的你,能够有所收获~
有问题欢迎评论or私信,也可以去我的知乎,我在那更活跃一些。