本文经机器之心(微信公众号:almosthuman2014)授权转载,禁止二次转载。
图像描述是一个有挑战性的人工智能问题,涉及为给定图像生成文本描述。
字幕生成是一个有挑战性的人工智能问题,涉及为给定图像生成文本描述。
一般图像描述或字幕生成需要使用计算机视觉方法来了解图像内容,也需要自然语言处理模型将对图像的理解转换成正确顺序的文字。近期,深度学习方法在该问题的多个示例上获得了顶尖结果。
深度学习方法在字幕生成问题上展现了顶尖的结果。这些方法最令人印象深刻的地方:给定一个图像,我们无需复杂的数据准备和特殊设计的流程,就可以使用端到端的方式预测字幕。
本教程将介绍如何从头开发能生成图像字幕的深度学习模型。
完成本教程,你将学会:
教程概览
该教程共分为 6 部分:
Python 环境
本教程假设你已经安装了 Python SciPy 环境,该环境完美适合 Python 3。你必须安装 Keras(2.0 版本或更高),TensorFlow 或 Theano 后端。本教程还假设你已经安装了 scikit-learn、Pandas、NumPy 和 Matplotlib 等科学计算与绘图软件库。
我推荐在 GPU 系统上运行代码。你可以在 Amazon Web Services 上用廉价的方式获取 GPU: 如何在 AWS GPU 上运行 Jupyter noterbook ?
图像和字幕数据集
图像字幕生成可使用的优秀数据集有 Flickr8K 数据集。原因在于它逼真且相对较小,即使你的工作站使用的是 CPU 也可以下载它,并用于构建模型。
对该数据集的明确描述见 2013 年的论文《Framing Image Description as a Ranking Task: Data, Models and Evaluation Metrics》。
作者对该数据集的描述如下:
我们介绍了一种用于基于句子的图像描述和搜索的新型基准集合,包括 8000 张图像,每个图像有五个不同的字幕描述对突出实体和事件提供清晰描述。
图像选自六个不同的 Flickr 组,往往不包含名人或有名的地点,而是手动选择多种场景和情形。
该数据集可免费获取。你必须填写一份申请表,然后就可以通过电子邮箱收到数据集。申请表链接: https://illinois.edu/fb/sec/1713398 。
很快,你会收到电子邮件,包含以下两个文件的链接:
下载数据集,并在当前工作文件夹里进行解压缩。你将得到两个目录:
该数据集包含一个预制训练数据集(6000 张图像)、开发数据集(1000 张图像)和测试数据集(1000 张图像)。
用于评估模型技能的一个指标是 BLEU 值。对于推断,下面是一些精巧的模型在测试数据集上进行评估时获得的大概 BLEU 值(来源:2017 年论文《Where to put the Image in an Image Caption Generator》):
稍后在评估模型部分将详细介绍 BLEU 值。下面,我们来看一下如何加载图像。
准备图像数据
我们将使用预训练模型解析图像内容,且目前有很多可选模型。在这种情况下,我们将使用 Oxford Visual Geometry Group 或 VGG(该模型赢得了 2014 年 ImageNet 竞赛冠军)。
Keras 可直接提供该预训练模型。注意,第一次使用该模型时,Keras 将从互联网上下载模型权重,大概 500Megabytes。这可能需要一段时间(时间长度取决于你的网络连接)。
我们可以将该模型作为更大的图像字幕生成模型的一部分。问题在于模型太大,每次我们想测试新语言模型配置(下行)时在该网络中运行每张图像非常冗余。
我们可以使用预训练模型对「图像特征」进行预计算,并保存至文件中。然后加载这些特征,将其馈送至模型中作为数据集中给定图像的描述。在完整的 VGG 模型中运行图像也是这样,我们需要提前运行该步骤。
优化可以加快模型训练过程,消耗更少内存。我们可以使用 VGG class 在 Keras 中运行 VGG 模型。我们将移除加载模型的最后一层,因为该层用于预测图像的分类。我们对图像分类不感兴趣,我们感兴趣的是分类之前图像的内部表征。这些就是模型从图像中提取出的「特征」。
Keras 还提供工具将加载图像改造成模型的偏好大小(如 3 通道 224 x 224 像素图像)。
下面是 extract_features() 函数,即给出一个目录名,该函数将加载每个图像、为 VGG 准备图像数据,并从 VGG 模型中收集预测到的特征。图像特征是包含 4096 个元素的向量,该函数向图像特征返回一个图像标识符(identifier)词典。
- # extract features from each photo in the directory
- def extract_features(directory):
- # load the model
- model = VGG16()
- # re-structure the model
- model.layers.pop()
- model = Model(inputs=model.inputs, outputs=model.layers[-1].output)
- # summarize
- print(model.summary())
- # extract features from each photo
- features = dict()
- for name in listdir(directory):
- # load an image from file
- filename = directory + '/' + name
- image = load_img(filename, target_size=(224, 224))
- # convert the image pixels to a numpy array
- image = img_to_array(image)
- # reshape data for the model
- image = image.reshape((1, image.shape[0], image.shape[1], image.shape[2]))
- # prepare the image for the VGG model
- image = preprocess_input(image)
- # get features
- feature = model.predict(image, verbose=0)
- # get image id
- image_id = name.split('.')[0]
- # store feature
- features[image_id] = feature
- print('>%s' % name)
- return features
我们调用该函数为模型测试准备图像数据,然后将词典保存至 features.pkl 文件。
完整示例如下:
- from os import listdir
- from pickle import dump
- from keras.applications.vgg16 import VGG16
- from keras.preprocessing.image import load_img
- from keras.preprocessing.image import img_to_array
- from keras.applications.vgg16 import preprocess_input
- from keras.models import Model
- # extract features from each photo in the directory
- def extract_features(directory):
- # load the model
- model = VGG16()
- # re-structure the model
- model.layers.pop()
- model = Model(inputs=model.inputs, outputs=model.layers[-1].output)
- # summarize
- print(model.summary())
- # extract features from each photo
- features = dict()
- for name in listdir(directory):
- # load an image from file
- filename = directory + '/' + name
- image = load_img(filename, target_size=(224, 224))
- # convert the image pixels to a numpy array
- image = img_to_array(image)
- # reshape data for the model
- image = image.reshape((1, image.shape[0], image.shape[1], image.shape[2]))
- # prepare the image for the VGG model
- image = preprocess_input(image)
- # get features
- feature = model.predict(image, verbose=0)
- # get image id
- image_id = name.split('.')[0]
- # store feature
- features[image_id] = feature
- print('>%s' % name)
- return features
- # extract features from all images
- directory = 'Flicker8k_Dataset'
- features = extract_features(directory)
- print('Extracted Features: %d' % len(features))
- # save to file
- dump(features, open('features.pkl', 'wb'))
运行该数据准备步骤可能需要一点时间,时间长度取决于你的硬件,带有 CPU 的现代工作站可能需要一个小时。
运行结束时,提取出的特征将存储在 features.pkl 文件中以备后用。该文件大概 127 Megabytes 大小。
准备文本数据
该数据集中每个图像有多个描述,文本描述需要进行最低限度的清洗。首先,加载包含所有文本描述的文件。
- # load doc into memory
- def load_doc(filename):
- # open the file as read only
- file = open(filename, 'r')
- # read all text
- text = file.read()
- # close the file
- file.close()
- return text
- filename = 'Flickr8k_text/Flickr8k.token.txt'
- # load descriptions
- doc = load_doc(filename)
每个图像有一个独有的标识符,该标识符出现在文件名和文本描述文件中。
接下来,我们将逐步对图像描述进行操作。下面定义一个 load_descriptions() 函数:给出一个需要加载的文本文档,该函数将返回图像标识符词典。每个图像标识符映射到一或多个文本描述。
- # extract descriptions for images
- def load_descriptions(doc):
- mapping = dict()
- # process lines
- for line in doc.split('\n'):
- # split line by white space
- tokens = line.split()
- if len(line) < 2:
- continue
- # take the first token as the image id, the rest as the description
- image_id, image_desc = tokens[0], tokens[1:]
- # remove filename from image id
- image_id = image_id.split('.')[0]
- # convert description tokens back to string
- image_desc = ' '.join(image_desc)
- # create the list if needed
- if image_id not in mapping:
- mapping[image_id] = list()
- # store description
- mapping[image_id].append(image_desc)
- return mapping
- # parse descriptions
- descriptions = load_descriptions(doc)
- print('Loaded: %d ' % len(descriptions))
下面,我们需要清洗描述文本。因为描述已经经过符号化,所以它十分易于处理。
我们将用以下方式清洗文本,以减少需要处理的词汇量:
下面定义了 clean_descriptions() 函数:给出描述的图像标识符词典,遍历每个描述,清洗文本。
- import string
- def clean_descriptions(descriptions):
- # prepare translation table for removing punctuation
- table = str.maketrans('', '', string.punctuation)
- for key, desc_list in descriptions.items():
- for i in range(len(desc_list)):
- desc = desc_list[i]
- # tokenize
- desc = desc.split()
- # convert to lower case
- desc = [word.lower() for word in desc]
- # remove punctuation from each token
- desc = [w.translate(table) for w in desc]
- # remove hanging 's' and 'a'
- desc = [word for word in desc if len(word)>1]
- # remove tokens with numbers in them
- desc = [word for word in desc if word.isalpha()]
- # store as string
- desc_list[i] = ' '.join(desc)
- # clean descriptions
- clean_descriptions(descriptions)
清洗后,我们可以总结词汇量。
理想情况下,我们希望使用尽可能少的词汇而得到强大的表达性。词汇越少则模型越小、训练速度越快。
对于推断,我们可以将干净的描述转换成一个集,将它的规模打印出来,这样就可以了解我们的数据集词汇量的大小了。
- # convert the loaded descriptions into a vocabulary of words
- def to_vocabulary(descriptions):
- # build a list of all description strings
- all_desc = set()
- for key in descriptions.keys():
- [all_desc.update(d.split()) for d in descriptions[key]]
- return all_desc
- # summarize vocabulary
- vocabulary = to_vocabulary(descriptions)
- print('Vocabulary Size: %d' % len(vocabulary))
最后,我们保存图像标识符词典和描述至一个新文本 descriptions.txt,该文件中每行只有一个图像和一个描述。
下面我们定义了 save_doc() 函数,即给出一个包含标识符和描述之间映射的词典和文件名,将该映射保存至文件中。
- # save descriptions to file, one per line
- def save_descriptions(descriptions, filename):
- lines = list()
- for key, desc_list in descriptions.items():
- for desc in desc_list:
- lines.append(key + ' ' + desc)
- data = '\n'.join(lines)
- file = open(filename, 'w')
- file.write(data)
- file.close()
- # save descriptions
- save_doc(descriptions, 'descriptions.txt')
汇总起来,完整的函数定义如下所示:
- import string
- # load doc into memory
- def load_doc(filename):
- # open the file as read only
- file = open(filename, 'r')
- # read all text
- text = file.read()
- # close the file
- file.close()
- return text
- # extract descriptions for images
- def load_descriptions(doc):
- mapping = dict()
- # process lines
- for line in doc.split('\n'):
- # split line by white space
- tokens = line.split()
- if len(line) < 2:
- continue
- # take the first token as the image id, the rest as the description
- image_id, image_desc = tokens[0], tokens[1:]
- # remove filename from image id
- image_id = image_id.split('.')[0]
- # convert description tokens back to string
- image_desc = ' '.join(image_desc)
- # create the list if needed
- if image_id not in mapping:
- mapping[image_id] = list()
- # store description
- mapping[image_id].append(image_desc)
- return mapping
- def clean_descriptions(descriptions):
- # prepare translation table for removing punctuation
- table = str.maketrans('', '', string.punctuation)
- for key, desc_list in descriptions.items():
- for i in range(len(desc_list)):
- desc = desc_list[i]
- # tokenize
- desc = desc.split()
- # convert to lower case
- desc = [word.lower() for word in desc]
- # remove punctuation from each token
- desc = [w.translate(table) for w in desc]
- # remove hanging 's' and 'a'
- desc = [word for word in desc if len(word)>1]
- # remove tokens with numbers in them
- desc = [word for word in desc if word.isalpha()]
- # store as string
- desc_list[i] = ' '.join(desc)
- # convert the loaded descriptions into a vocabulary of words
- def to_vocabulary(descriptions):
- # build a list of all description strings
- all_desc = set()
- for key in descriptions.keys():
- [all_desc.update(d.split()) for d in descriptions[key]]
- return all_desc
- # save descriptions to file, one per line
- def save_descriptions(descriptions, filename):
- lines = list()
- for key, desc_list in descriptions.items():
- for desc in desc_list:
- lines.append(key + ' ' + desc)
- data = '\n'.join(lines)
- file = open(filename, 'w')
- file.write(data)
- file.close()
- filename = 'Flickr8k_text/Flickr8k.token.txt'
- # load descriptions
- doc = load_doc(filename)
- # parse descriptions
- descriptions = load_descriptions(doc)
- print('Loaded: %d ' % len(descriptions))
- # clean descriptions
- clean_descriptions(descriptions)
- # summarize vocabulary
- vocabulary = to_vocabulary(descriptions)
- print('Vocabulary Size: %d' % len(vocabulary))
- # save to file
- save_descriptions(descriptions, 'descriptions.txt')
运行示例首先打印出加载图像描述的数量(8092)和干净词汇量的规模(8763 个单词)。
- Loaded: 8,092
- Vocabulary Size: 8,763
最后,把干净的描述写入 descriptions.txt。
查看文件,我们能够看到该描述可用于建模。文件中描述的顺序可能会发生改变。
- 2252123185_487f21e336 bunch on people are seated in stadium
- 2252123185_487f21e336 crowded stadium is full of people watching an event
- 2252123185_487f21e336 crowd of people fill up packed stadium
- 2252123185_487f21e336 crowd sitting in an indoor stadium
- 2252123185_487f21e336 stadium full of people watch game
- ...
开发深度学习模型
本节我们将定义深度学习模型,在训练数据集上进行拟合。本节分为以下几部分:
加载数据
首先,我们必须加载准备好的图像和文本数据来拟合模型。
我们将在训练数据集中的所有图像和描述上训练数据。训练过程中,我们计划在开发数据集上监控模型性能,使用该性能确定什么时候保存模型至文件。
训练和开发数据集已经预制好,并分别保存在 Flickr_8k.trainImages.txt 和 Flickr_8k.devImages.txt 文件中,二者均包含图像文件名列表。从这些文件名中,我们可以提取图像标识符,并使用它们为每个集过滤图像和描述。
如下所示,load_set() 函数将根据训练或开发集文件名加载一个预定义标识符集。
- # load doc into memory
- def load_doc(filename):
- # open the file as read only
- file = open(filename, 'r')
- # read all text
- text = file.read()
- # close the file
- file.close()
- return text
- # load a pre-defined list of photo identifiers
- def load_set(filename):
- doc = load_doc(filename)
- dataset = list()
- # process line by line
- for line in doc.split('\n'):
- # skip empty lines
- if len(line) < 1:
- continue
- # get the image identifier
- identifier = line.split('.')[0]
- dataset.append(identifier)
- return set(dataset)
现在,我们可以使用预定义训练或开发标识符集加载图像和描述了。
下面是 load_clean_descriptions() 函数,该函数从给定标识符集的 descriptions.txt 中加载干净的文本描述,并向文本描述列表返回标识符词典。
我们将要开发的模型能够生成给定图像的字幕,一次生成一个单词。先前生成的单词序列作为输入。因此,我们需要一个 first word 来开启生成步骤和一个 last word 来表示字幕生成结束。
我们将使用字符串 startseq 和 endseq 完成该目的。这些标记被添加至加载描述,像它们本身就是加载出的那样。在对文本进行编码之前进行该操作非常重要,这样这些标记才能得到正确编码。
- # load clean descriptions into memory
- def load_clean_descriptions(filename, dataset):
- # load document
- doc = load_doc(filename)
- descriptions = dict()
- for line in doc.split('\n'):
- # split line by white space
- tokens = line.split()
- # split id from description
- image_id, image_desc = tokens[0], tokens[1:]
- # skip images not in the set
- if image_id in dataset:
- # create list
- if image_id not in descriptions:
- descriptions[image_id] = list()
- # wrap description in tokens
- desc = 'startseq ' + ' '.join(image_desc) + ' endseq'
- # store
- descriptions[image_id].append(desc)
- return descriptions
接下来,我们可以为给定数据集加载图像特征。
下面定义了 load_photo_features() 函数,该函数加载了整个图像描述集,然后返回给定图像标识符集你感兴趣的子集。
这不是很高效,但是,这可以帮助我们启动,快速运行。
- # load photo features
- def load_photo_features(filename, dataset):
- # load all features
- all_features = load(open(filename, 'rb'))
- # filter features
- features = {k: all_features[k] for k in dataset}
- return features
我们可以在这里暂停一下,测试目前开发的所有内容。
完整的代码示例如下:
- # load doc into memory
- def load_doc(filename):
- # open the file as read only
- file = open(filename, 'r')
- # read all text
- text = file.read()
- # close the file
- file.close()
- return text
- # load a pre-defined list of photo identifiers
- def load_set(filename):
- doc = load_doc(filename)
- dataset = list()
- # process line by line
- for line in doc.split('\n'):
- # skip empty lines
- if len(line) < 1:
- continue
- # get the image identifier
- identifier = line.split('.')[0]
- dataset.append(identifier)
- return set(dataset)
- # load clean descriptions into memory
- def load_clean_descriptions(filename, dataset):
- # load document
- doc = load_doc(filename)
- descriptions = dict()
- for line in doc.split('\n'):
- # split line by white space
- tokens = line.split()
- # split id from description
- image_id, image_desc = tokens[0], tokens[1:]
- # skip images not in the set
- if image_id in dataset:
- # create list
- if image_id not in descriptions:
- descriptions[image_id] = list()
- # wrap description in tokens
- desc = 'startseq ' + ' '.join(image_desc) + ' endseq'
- # store
- descriptions[image_id].append(desc)
- return descriptions
- # load photo features
- def load_photo_features(filename, dataset):
- # load all features
- all_features = load(open(filename, 'rb'))
- # filter features
- features = {k: all_features[k] for k in dataset}
- return features
- # load training dataset (6K)
- filename = 'Flickr8k_text/Flickr_8k.trainImages.txt'
- train = load_set(filename)
- print('Dataset: %d' % len(train))
- # descriptions
- train_descriptions = load_clean_descriptions('descriptions.txt', train)
- print('Descriptions: train=%d' % len(train_descriptions))
- # photo features
- train_features = load_photo_features('features.pkl', train)
- print('Photos: train=%d' % len(train_features))
运行该示例首先在测试数据集中加载 6000 张图像标识符。这些特征之后将用于加载干净描述文本和预计算的图像特征。
- Dataset: 6,000
- Descriptions: train=6,000
- Photos: train=6,000
描述文本在作为输入馈送至模型或与模型预测进行对比之前需要先编码成数值。
编码数据的第一步是创建单词到唯一整数值之间的持续映射。Keras 提供 Tokenizer class,可根据加载的描述数据学习该映射。
下面定义了用于将描述词典转换成字符串列表的 to_lines() 函数,和对加载图像描述文本拟合 Tokenizer 的 create_tokenizer() 函数。
- # convert a dictionary of clean descriptions to a list of descriptions
- def to_lines(descriptions):
- all_desc = list()
- for key in descriptions.keys():
- [all_desc.append(d) for d in descriptions[key]]
- return all_desc
- # fit a tokenizer given caption descriptions
- def create_tokenizer(descriptions):
- lines = to_lines(descriptions)
- tokenizer = Tokenizer()
- tokenizer.fit_on_texts(lines)
- return tokenizer
- # prepare tokenizer
- tokenizer = create_tokenizer(train_descriptions)
- vocab_size = len(tokenizer.word_index) + 1
- print('Vocabulary Size: %d' % vocab_size)
我们现在对文本进行编码。
每个描述将被分割成单词。我们向该模型提供一个单词和图像,然后模型生成下一个单词。描述的前两个单词和图像将作为模型输入以生成下一个单词,这就是该模型的训练方式。
例如,输入序列「a little girl running in field」将被分割成 6 个输入 - 输出对来训练该模型:
- X1, X2 (text sequence), y (word)
- photo startseq, little
- photo startseq, little, girl
- photo startseq, little, girl, running
- photo startseq, little, girl, running, in
- photo startseq, little, girl, running, in, field
- photo startseq, little, girl, running, in, field, endseq
稍后,当模型用于生成描述时,生成的单词将被连结起来,递归地作为输入以生成图像字幕。
下面是 create_sequences() 函数,给出 tokenizer、最大序列长度和所有描述和图像的词典,该函数将这些数据转换成输入 - 输出对来训练模型。该模型有两个输入数组:一个用于图像特征,一个用于编码文
原 文: ow to Develop a Deep Learning Caption Generation Model in Keras from Scratch
译 文: 机器之心
作 者:Jason Brownlee
来源: https://sdk.cn/news/7848