Skip to main content

Show and Tell

Show and Tell用TensorFlow实现
Created on February 2|Last edited on July 12
本报告是作者Aritra Roy GosthipatyDevjyoti Chakraborty所写的"Show and Tell"的翻译

引言

查看Kaggle笔记本

几年前,如果有人说:“我们将会有这样一种虚拟助理,它们能正确无误地描述展示给它们的情景”,应该会一笑而过。但着机器学习逐渐进入深度学习,我们以前做梦都想不到的无尽可能性和想法开始走进现实。奥里奥尔·温亚尔斯(Oriol Vinyals)等人所著的《Show and Tell:神经网络图像描述生成器》Show and Tell: A Neural Image Caption Generator就描述了这样一个想法。在这篇论文中,作者们针对图像描述生成器提出了一个端到端的解决方案。在此论文之前,针对这种任务提出的方案包括独立任务优化(视觉和自然语言)并且随后要手动缝合那些独立任务。

该论文的灵感源于神经网络机器翻译,在神经网络机器翻译中,编码器在给定语言序列上训练,然后生成一个固定长度的表征并用于解码器,解码器就给出另一种语言的序列。该论文的作者们在这一理念的基础上,使用视觉特征提取器作为编码器, 同时使用序列模型作为解码器。



📜阅读那篇论文

好的学术论文,让人读过之后也能想到文中所提出的想法,这着实令人着迷。就在那一刻,你会觉得,这个想法是多么简单,但又多么强大啊。《Show and Tell:神经网络图像描述生成器》就是这样一篇令人惊叹的论文,它为图像描述生成器开启了深度学习研究的大门。

作者们称其自身深受机器翻译的启发。因此我们把这篇文章编排成路线图的形式。跟着路线图走,读者在读这篇论文时应该会和我们一样激动。

路线图:

  • 任务:我们研究手上任务的核心概念。
  • 模块:我们探究所用的不同架构并进一步探讨我们使用它们的原因。
  • 代码:不展示代码的话我们怎么出一篇教程?
  • 损失与结果:用了什么目标函数?表现如何?


👨‍🏫任务

给定一个图像,我们就要给出这个图像的描述。这不是一个人工代理决定图像属于哪一类的简单分类问题。也不是一个人工代理在其所分类的对象上绘制边界框的检测任务。在这里,我们要理解图像的内容,然后形成一个词语序列,这个词语序列要能描述图像内容之间的关系。

说到这里,我们想到的最简单方法就是把那个任务分为两个不同的任务。

  1. 👁️ ️计算机视觉:这部分负责处理提供的图像。它尝试提取图像的特征、从分层特征构建概念并建立数据分布模型。一个简单的卷积神经网络就可以搞定这个任务了。输入一个图像后,卷积神经网络内核以分层形式提取图像特征。这些特征就是给定图像的内容的精简表征。 image.png

Goodfellow等人提供的深度学习资料

  1. 🗣️ ️ 生成词语:在该任务中,提供给我们一个图像以及图像的描述并用于训练。需要对描述建模。描述是一条描述图像的词语序列。需要用自然语言处理器对描述数据分布建模。模型需要理解词语分布以及词语上下文。这里我们可以用一个简单的循环(recurrent)架构,这个循环架构能对描述建模并生成词语,所生成的词语是从提供的描述数据空间严密采样而来的。 image.png

我发现非常有趣的一点就是数字的运用。我们人类创造了一门美妙的沟通语言,叫做数学。我们可以用数字和符号描述概念、创意等很多东西。现在我们暂且把目光转向数字。计算机视觉模型从图像中提取有价值的特征,这些特征实质上就是数字(模型的权值和阈值)。同理,语言和词语也可以用数字来描述(词嵌入)。就是这个想法,利用它就能解决缝合任务中多个领域的难题。我们要把视觉模型数字输入到语言模型,并且输入方式要能够使图像描述生成成功。 image.png

Show and Tell:一个神经网络图像描述生成器

我发现非常有趣的一点领悟就是数字的使用。我们人类创造了一门美妙的沟通语言,叫做数学。我们可以用数字和符号描述概念、创意等很多东西。我们暂且把目光转向数字。计算机视觉模型从图像中提取的有价值的特征实质上就是数字(模型的权值和阈值)。同理,语言和词语也可以用数字来描述(词嵌入)。就是这个想法,利用它就能解决缝合任务中多个领域的难题。我们要把视觉模型数字输入到语言模型,并且输入方式要能够使图像描述生成成功。



数据

对于我们的实验,我们选择使用Flickr30k数据集,该数据集包含30000张图像以及对应每个图像的多个不同描述。该数据已被预处理且托管在Kaggle,方便我们使用。我们将继续进入到Flickr30k的Kaggle数据集。

该数据集包含一个CSV文件,这一文件所包含的图像记录将图像与其对应的描述关联到一起。

读取数据集如下: image.png caption.png

]在进入下一步之前,我们要向读者说明<start><end>的用法。这对于让模型知道描述的起始和结束尤为重要。它虽然在训练数据期间并不那么必要,但在测试阶段我们需要把标记<start>输入到模型,从而生成描述的第一个词语,模型产生标记<end>后就要停止生成词语。




Run set
1


🏋️️模型

我们已经对任务以及要处理的模型有了相当的了解。在这部分,运用数字将很有帮助。我们首先从作者们提出的模型架构开始,然后再深入探究其工作原理。 image.png

所提出的架构

在这里我们手边有两个不同的模型,分别用于不同的任务。左侧是一个视觉模型,右侧是一个用于生成词语的循环模型。这里的思路是将图像特征当作另一个词,输入到循环模型中。从图像提取的特征是一个数字集合(向量),如果我们在描述的嵌入空间中绘制出该向量,我们肯定会得到词语的表征。图像特征转化成词语是整个过程的美妙之处。在嵌入空间绘制的图像特征可能不代表词典中的一个实际的字,但是让循环模型去学习已经足够了。这个所谓的image-word是循环模型的初始输入。读者在深刻思考之后,必定会发现这个思路是多么简单而有效。具有相同特征的两张图片在嵌入空间中就离得近,把这些特征提供给循环模型后,这一模型就会为两个图像生成相似的描述。image-word这个思路的重要之处还在于,由于现在可以在嵌入空间上计算向量代数,因此仅仅通过增加两个概念,就可以学习新的概念了。IMG_370

image_caption.png

图像-词(image-word)示例

按照该架构,我们用一个卷积神经网络(CNN)作为编码器,用一个门控循环单元(GRU)的堆栈层作为解码器。我们用的是门控循环单元(Gated Recurrent Units),而不用长短期记忆模型(LSTM),因为门控循环单元比长短期记忆模型计算效率高,比简单的循环神经网络(RNN)更有效。要记住,我们处理的是大量数据集,我们选用门控循环单元可提升管道的效率,但有一定的代价,那就是会损失一些计算效率(梯度耗散问题)。

👀 Show(显示)

模型的这一部分充当信息编码器。我们使用一个restnet50模型,该模型已经用Imagenet的数据进行了预训练。这里我们忽略模型的最后一层,从倒数第二层开始提取输出。在resnet输出的顶端,我们堆叠一个全局平均池化(GAP)和一个全连接层。我们利用全局平均池化(GAP)取倒数第二个内核输出的平均值,利用全连接(Dense)层尝试把图像特征塑造成与词嵌入相同的形状。

🗣️️ Tell(说出)

模型的这一部分充当信息解码器。现在,为了解释这些,我们必须探究一下循环神经网络模型(RNN)的根源。如果有人需要温习循环架构的有关知识, Under the Hood of RNN(循环神经网络探秘)将是个不错的参考资料。单个单元格yt​的输出是由其当前输入xt以及来自上一个单元格ht−1h_{t-1}ht−1​的隐藏状态激活共同决定的。现在,如果第一个循环神经网络单元格把编码的图像作为自己的输入,它生成的隐藏状态将被转入下一个单元格。这一隐藏状态也将会充当当前单元格输入和输出的链接,并且对序列中的所有单元格都重复此操作。总而言之,编码图像的效用实质上会传递到整个单元格序列,以便于被预测的每个词语都是在记住给定图像的情况下完成的。

image.png

循环公式的描述



🖥️️代码

查看Kaggle笔记本

现在我们进入最重要的部分——代码。

文本处理

在下列代码块中,我们做基本的文本处理并从可用的描述集创建词汇表。我们还要手动添加一个‘pad’(填补)标记,以便于之后让全部句子长度相同,这对我们有用。

train_df = df.iloc[:train_size,:]
val_df = df.iloc[train_size+1:train_size+val_size,:]
test_df = df.iloc[train_size+val_size+1:,:]

# Choose the top 5000 words from the vocabulary
top_k = 10000
tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=top_k,
                                                  oov_token="<unk>",
                                                  filters='!"#$%&()*+.,-/:;=?@[\]^_`{|}~')

# build the vocabulary
tokenizer.fit_on_texts(train_df['comment'])

tokenizer.word_index['<pad>'] = 0
tokenizer.index_word[0] = '<pad>'
# This is a sanity check function
def check_vocab(word):
    i = tokenizer.word_index[word]
    print(f"The index of the word: {i}")
    print(f"Index {i} is word {tokenizer.index_word[i]}")
    
check_vocab("pajama")

我们将得到这样一个输出:

index.png

接下来,我们必须要根据分词器(tokenizer)生成的词汇表创建训练数据。我们手动补齐所有句子,使其长度相同。接着我们把数据集成到管道 tf.data

# Create the tokenized vectors
train_seqs = tokenizer.texts_to_sequences(train_df['comment'])
val_seqs = tokenizer.texts_to_sequences(val_df['comment'])
test_seqs = tokenizer.texts_to_sequences(test_df['comment'])

# If you do not provide a max_length value, pad_sequences calculates it automatically
train_cap_vector = tf.keras.preprocessing.sequence.pad_sequences(train_seqs, padding='post')
val_cap_vector = tf.keras.preprocessing.sequence.pad_sequences(val_seqs, padding='post')
test_cap_vector = tf.keras.preprocessing.sequence.pad_sequences(test_seqs, padding='post')

train_cap_ds = tf.data.Dataset.from_tensor_slices(train_cap_vector)
val_cap_ds = tf.data.Dataset.from_tensor_slices(val_cap_vector)
test_cap_ds = tf.data.Dataset.from_tensor_slices(test_cap_vector)

图像处理

现在我们要为数据集flicker30K 中的图像创建一个管道tf.data。我们完成基本的操作,比如载入图像、解码图像、数据类型转换以及改变大小。

@tf.function
def load_img(image_path):
    img = tf.io.read_file(image_path)
    img = tf.image.decode_jpeg(img)
    img = tf.image.convert_image_dtype(img, tf.float32)
    img = tf.image.resize(img, (224, 224))
    return img

train_img_name = train_df['image_name'].values
val_img_name = val_df['image_name'].values
test_img_name = test_df['image_name'].values

train_img_ds = tf.data.Dataset.from_tensor_slices(train_img_name).map(load_img)
val_img_ds = tf.data.Dataset.from_tensor_slices(val_img_name).map(load_img)
test_img_ds = tf.data.Dataset.from_tensor_slices(test_img_name).map(load_img)

合并数据

我们的意图是把创建的两个数据管道合并在一起,这样我们就能将其直接输入至我们的网络中。我们分批取数据,因为整个数据集太大了。

# prefecth and batch the dataset
AUTOTUNE = tf.data.experimental.AUTOTUNE
BATCH_SIZE = 512

train_ds = tf.data.Dataset.zip((train_img_ds, train_cap_ds)).shuffle(42).batch(BATCH_SIZE).prefetch(buffer_size=AUTOTUNE)
val_ds = tf.data.Dataset.zip((val_img_ds, val_cap_ds)).shuffle(42).batch(BATCH_SIZE).prefetch(buffer_size=AUTOTUNE)
test_ds = tf.data.Dataset.zip((test_img_ds, test_cap_ds)).shuffle(42).batch(BATCH_SIZE).prefetch(buffer_size=AUTOTUNE)

模型

Show(显示)

如前所述,Show指的是架构的编码部分,用来压缩图像。在ImageNet 上训练的一个ResNet50模型作为特征提取器,随后就是全局平均池化(GAP)。最后,我们用一个完全连接的层把架构的这一部分收集起来。

class CNN_Encoder(tf.keras.Model):
    
    def __init__(self, embedding_dim):
        super(CNN_Encoder, self).__init__()
        self.embedding_dim = embedding_dim
        
    def build(self, input_shape):
        self.resnet = tf.keras.applications.ResNet50(include_top=False,
                                                     weights='imagenet')
        self.resnet.trainable=False
        self.gap = GlobalAveragePooling2D()
        self.fc = Dense(units=self.embedding_dim,
                        activation='sigmoid')
        
    def call(self, x):
        x = self.resnet(x)
        x = self.gap(x)
        x = self.fc(x)
        return x

Tell(说出)

Tell是包含门控循环单元(GRU)的解码器,它利用从编码器得来的信息,在学到的描述和原始输入之间建立关联。

class RNN_Decoder(tf.keras.Model):
    def __init__(self, embedding_dim, units, vocab_size):
        super(RNN_Decoder, self).__init__()
        self.units = units
        self.embedding_dim = embedding_dim
        self.vocab_size = vocab_size
        self.embedding = Embedding(input_dim=self.vocab_size,
                                   output_dim=self.embedding_dim)
    
    def build(self, input_shape):
        self.gru1 = GRU(units=self.units,
                       return_sequences=True,
                       return_state=True)
        self.gru2 = GRU(units=self.units,
                       return_sequences=True,
                       return_state=True)
        self.gru3 = GRU(units=self.units,
                       return_sequences=True,
                       return_state=True)
        self.gru4 = GRU(units=self.units,
                       return_sequences=True,
                       return_state=True)
        self.fc1 = Dense(self.units)
        self.fc2 = Dense(self.vocab_size)

    def call(self, x, initial_zero=False):
        # x, (batch, 512)
        # hidden, (batch, 256)
        if initial_zero:
            initial_state = decoder.reset_state(batch_size=x.shape[0])
            output, state = self.gru1(inputs=x,
                                      initial_state=initial_state)
            output, state = self.gru2(inputs=output,
                                      initial_state=initial_state)
            output, state = self.gru3(inputs=output,
                                      initial_state=initial_state)
            output, state = self.gru4(inputs=output,
                                      initial_state=initial_state)
        else:
            output, state = self.gru1(inputs=x)
            output, state = self.gru2(inputs=output)
            output, state = self.gru3(inputs=output)
            output, state = self.gru4(inputs=output)
        # output, (batch, 256)
        x = self.fc1(output)
        x = self.fc2(x)
        
        return x, state
    
    def embed(self, x):
        return self.embedding(x)
    
    def reset_state(self, batch_size):
        return tf.zeros((batch_size, self.units))

训练

我们创建类对象并把我们的优化器(optimizer)赋值为Adam(自适应矩估计)。损失就是稀疏分类交叉熵(Sparse Categorical Cross entropy),因为这里使用独热编码(one-hot-encoders)的话效率不高,实际情况就是这样。我们还将会使用掩码以协助掩蔽,这样就不会让序列模型在相同情况下学习过度拟合。

encoder = CNN_Encoder(EMBEDDIN_DIM)
decoder = RNN_Decoder(embedding_dim=EMBEDDIN_DIM,
                      units=UNITS_RNN,
                      vocab_size=VOCAB_SIZE)

optimizer = tf.keras.optimizers.Adam()
loss_object = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True, reduction='none')

def loss_function(real, pred):
    mask = tf.math.logical_not(tf.math.equal(real, 0))
    loss_ = loss_object(real, pred)

    mask = tf.cast(mask, dtype=loss_.dtype)
    loss_ *= mask

    return tf.reduce_mean(loss_)

接下来,我们编写训练步长函数,该函数将通过反向传播算法计算梯度。

@tf.function
def train_step(img_tensor, target):
    # img_tensor (batch, 224,224,3)
    # target     (batch, 80)
    loss = 0
    with tf.GradientTape() as tape:
        features = tf.expand_dims(encoder(img_tensor),1) # (batch, 1, 128)
        em_words = decoder.embed(target)
        x = tf.concat([features,em_words],axis=1)
        predictions, _ = decoder(x, True)

        loss = loss_function(target[:,1:], predictions[:,1:-1,:])

    trainable_variables = encoder.trainable_variables + decoder.trainable_variables

    gradients = tape.gradient(loss, trainable_variables)

    optimizer.apply_gradients(zip(gradients, trainable_variables))

    return loss

@tf.function
def val_step(img_tensor, target):
    # img_tensor (batch, 224,224,3)
    # target     (batch, 80)
    loss = 0
    features = tf.expand_dims(encoder(img_tensor),1) # (batch, 1, 128)
    em_words = decoder.embed(target)
    x = tf.concat([features,em_words],axis=1)
    predictions, _ = decoder(x, True)
    loss = loss_function(target[:,1:], predictions[:,1:-1,:])
    return loss


📉损失与结果

查看Kaggle笔记本

目标函数是生成的词语的负对数似然函数。为了让这更直观一点,我们在提供的架构中做一次前馈运行。当把一个图像输入到卷积神经网络,同时会提供对应的图像特征。然后图像特征被编码为一个与提供的词嵌入形状相同的张量。图像特征在第一个时间步被输入到门控循环单元(GRU)。然后,该单元格就生成整个词汇表的柔性最大值传输函数(softmax)。我们的任务目标是提高词语描述图像的贴切程度。我们用负对数似然函数,就是为了使该指标最小化并训练模型。在下一个时间步,我们把描述中的词语作为输入,并尝试最大程度地增加最接近的下一个词语的概率。




Run set
1


结论

通过利用从机器翻译中学到的简单概念,作者们真的想出了一个绝妙的方法来自动生成给定图像的描述。自动生成描述领域后续几篇论文的灵感就源自此篇论文,其中最著名的就是Show, Attend and Tell,顾名思义,该论文涉及了注意力机制以及本报告所探讨的这些概念。

与作者交流:

姓名TwitterGitHub
Devjyoti Chakrobarty@Cr0wley_zz@cr0wley-zz
Aritra Roy Gosthipaty@ariG23498@ariG23498

Iterate on AI agents and models faster. Try Weights & Biases today.