利用深度学习生成医疗报告

做者|Vysakh Nair
编译|VK
来源|Towards Data Sciencepython

目录

  1. 了解问题git

  2. 要求技能github

  3. 数据web

  4. 获取结构化数据算法

  5. 准备文本数据-天然语言处理编程

  6. 获取图像特征-迁移学习api

  7. 输入管道-数据生成器网络

  8. 编-解码器模型-训练,贪婪搜索,束搜索,BLEU架构

  9. 注意机制-训练,贪婪搜索,束搜索,BLEUapp

  10. 摘要

  11. 将来工做

  12. 引用

1.了解问题

图像字幕是一个具备挑战性的人工智能问题,它是指根据图像内容从图像中生成文本描述的过程。例如,请看下图:

一个常见的答案是“一个弹吉他的女人”。做为人类,咱们能够用适当的语言,看着一幅图画,描述其中的一切。这很简单。我再给你看一个:

好吧,你怎么形容这个?

对于咱们全部的“非放射科医生”,一个常见的答案是“胸部x光”。

对于放射科医生,他们撰写文本报告,叙述在影像学检查中身体各个部位的检查结果,特别是每一个部位是正常、异常仍是潜在异常。他们能够从一张这样的图像中得到有价值的信息并作出医疗报告。

对于经验不足的放射科医生和病理学家,尤为是那些在医疗质量相对较低的农村地区工做的人来讲,撰写医学影像报告是很困难的,而另外一方面,对于有经验的放射科医生和病理学家来讲,写成像报告多是乏味和耗时的。

因此,为了解决全部这些问题,若是一台计算机能够像上面这样的胸部x光片做为输入,并像放射科医生那样以文本形式输出结果,那岂不是很棒?

2.基本技能

本文假设你对神经网络、cnn、RNNs、迁移学习、Python编程和Keras库等主题有必定的了解。下面提到的两个模型将用于咱们的问题,稍后将在本博客中简要解释:

  1. 编解码器模型

  2. 注意机制

对它们有足够的了解会帮助你更好地理解模型。

3.数据

你能够从如下连接获取此问题所需的数据:

图像数据集包含一我的的多个胸部x光片。例如:x光片的侧视图、多个正面视图等。

正如放射科医生使用全部这些图像来编写报告,模型也将使用全部这些图像一块儿生成相应的结果。数据集中有3955个报告,每一个报告都有一个或多个与之关联的图像。

3.1 从XML文件中提取所需的数据

数据集中的报表是XML文件,其中每一个文件对应一个单独的。这些文件中包含了与此人相关的图像id和相应的结果。示例以下:

突出显示的信息是你须要从这些文件中提取的内容。这能够在python的XML库的帮助下完成。

:调查结果也将称为报告。它们将在博客的其余部分互换使用。

import xml.etree.ElementTree as ET
img = []
img_impression = []
img_finding = []
# directory包含报告文件
for filename in tqdm(os.listdir(directory)):
    if filename.endswith(".xml"):
        f = directory + '/' + filename
        tree = ET.parse(f)
        root = tree.getroot()
        for child in root:
            if child.tag == 'MedlineCitation':
                for attr in child:
                    if attr.tag == 'Article':
                        for i in attr:
                            if i.tag == 'Abstract':
                                for name in i:
                                    if name.get('Label') == 'FINDINGS':
                                        finding=name.text   
        for p_image in root.findall('parentImage'):
            img.append(p_image.get('id'))
            img_finding.append(finding)

4.获取结构化数据

从XML文件中提取所需的数据后,数据将转换为结构化格式,以便于理解和访问。

如前所述,有多个图像与单个报表关联。所以,咱们的模型在生成报告时也须要看到这些图像。但有些报告只有1张图片与之相关,而有些报告有2张,最多的只有4张。

因此问题就出现了,咱们一次应该向模型输入多少图像来生成报告?为了使模型输入一致,一次选择一对图像(即两个图像)做为输入。若是一个报表只有一个图像,那么同一个图像将被复制为第二个输入。

如今咱们有了一个合适且可理解的结构化数据。图像按其绝对地址的名称保存。这将有助于加载数据。

5.准备文本数据

从XML文件中得到结果后,在咱们将其输入模型以前,应该对它们进行适当的清理和准备。下面的图片展现了几个例子,展现了清洗前的发现是什么样子。

咱们将按如下方式清理文本:

  1. 将全部字符转换为小写。

  2. 执行基本的解压,即将won’t、can’t等词分别转换为will not、can not等。

  3. 删除文本中的标点符号。注意,句号不会被删除,由于结果包含多个句子,因此咱们须要模型经过识别句子以相似的方式生成报告。

  4. 从文本中删除全部数字。

  5. 删除长度小于或等于2的全部单词。例如,“is”、“to”等被删除。这些词不能提供太多信息。可是“no”这个词不会被删除,由于它增长了语义信息。在句子中加上“no”会彻底改变它的意思。因此咱们在执行这些清理步骤时必须当心。你须要肯定哪些词应该保留,哪些词应该避免。

  6. 还发现一些文本包含多个句号或空格,或“X”重复屡次。这样的字符也会被删除。

咱们将开发的模型将生成一个由两个图像组合而成的报告,该报告将一次生成一个单词。先前生成的单词序列将做为输入提供。

所以,咱们须要一个“第一个词”来启动生成过程,并用“最后一个词”来表示报告的结束。为此,咱们将使用字符串“startseq”和“endseq”。这些字符串被添加到咱们的数据中。如今这样作很重要,由于当咱们对文本进行编码时,须要正确地对这些字符串进行编码。

编码文本的主要步骤是建立从单词到惟一整数值的一致映射,称为标识化。为了让咱们的计算机可以理解任何文本,咱们须要以机器可以理解的方式将单词或句子分解。若是不执行标识化,就没法处理文本数据。

标识化是将一段文本分割成更小的单元(称为标识)的一种方法。标识能够是单词或字符,但在咱们的例子中,它将是单词。Keras为此提供了一个内置库。

from tensorflow.keras.preprocessing.text import Tokenizer
tokenizer = Tokenizer(filters='!"#$%&()*+,-/:;<=>?@[\\]^_`{|}~\t\n')
tokenizer.fit_on_texts(reports)

如今,咱们已经对文本进行了适当的清理和标识,以备未来使用。全部这些的完整代码均可以在个人GitHub账户中找到,这个账户的连接在本文末尾提供。

6.获取图像特征

图像和部分报告是咱们模型的输入。咱们须要将每一个图像转换成一个固定大小的向量,而后将其做为输入传递到模型中。为此,咱们将使用迁移学习。

“在迁移学习中,咱们首先在基本数据集和任务上训练基础网络,而后咱们将学习到的特征从新指定用途,或将其转移到第二个目标网络,以便在目标数据集和任务上进行训练。若是特征是通用的,也就是说既适合基本任务也适合目标任务,而不是特定于基本任务,那此过程将趋于有效。”

VGG1六、VGG19或InceptionV3是用于迁移学习的常见cnn。这些都是在像Imagenets这样的数据集上训练的,这些数据集的图像与胸部x光彻底不一样。因此从逻辑上讲,他们彷佛不是咱们任务的好选择。那么咱们应该使用哪一种网络来解决咱们的问题呢?

若是你不熟悉,让我介绍你认识CheXNet。CheXNet是一个121层的卷积神经网络,训练于胸片X射线14上,目前是最大的公开胸片X射线数据集,包含10万多张正面视图的14种疾病的X射线图像。然而,咱们在这里的目的不是对图像进行分类,而是获取每一个图像的特征。所以,不须要该网络的最后一个分类层。

你能够从这里下载CheXNet的训练权重:https://drive.google.com/file/d/19BllaOvs2x5PLV_vlWMy4i8LapLb2j6b/view。

from tensorflow.keras.applications import densenet

chex = densenet.DenseNet121(include_top=False, weights = None,   input_shape=(224,224,3), pooling="avg")

X = chex.output
X = Dense(14, activation="sigmoid", name="predictions")(X)

model = Model(inputs=chex.input, outputs=X)

model.load_weights('load_the_downloaded_weights.h5')

chexnet = Model(inputs = model.input, outputs = model.layers[-2].output)

若是你忘了,咱们有两个图像做为输入到咱们的模型。下面是如何得到特征:

每一个图像的大小被调整为 (224,224,3),并经过CheXNet传递,获得1024长度的特征向量。随后,将这两个特征向量串联以得到2048特征向量。

若是你注意到,咱们添加了一个平均池层做为最后一层。这是有缘由的。由于咱们要链接两个图像,因此模型可能会学习一些链接顺序。例如,image1老是在image2以后,反之亦然,但这里不是这样。咱们在链接它们时不保持任何顺序。这个问题是经过池来解决的。

代码以下:

def load_image(img_name):
'''加载图片函数'''
    image = Image.open(img_name)
    image_array = np.asarray(image.convert("RGB"))
    image_array = image_array / 255.
    image_array = resize(image_array, (224,224))
    X = np.expand_dims(image_array, axis=0)
    X = np.asarray(X) 
    return X
Xnet_features = {}
for key, img1, img2, finding in tqdm(dataset.values):
    i1 = load_image(img1)
    img1_features = chexnet.predict(i1)    
    i2 = load_image(img2)
    img2_features = chexnet.predict(i2)
    input_ = np.concatenate((img1_features, img2_features), axis=1)
    Xnet_features[key] = input_

这些特征以pickle格式存储在字典中,可供未来使用。

7.输入管道

考虑这样一个场景:你有大量的数据,以致于你不能一次将全部数据都保存在RAM中。购买更多的内存显然不是每一个人均可以进行的选择。

解决方案能够是动态地将小批量的数据输入到模型中。这正是数据生成器所作的。它们能够动态生成模型输入,从而造成从存储器到RAM的管道,以便在须要时加载数据。

这种管道的另外一个优势是,当这些小批量数据准备输入模型时,能够轻松的应用。

为了咱们的问题咱们将使用tf.data。

咱们首先将数据集分为两部分,一个训练数据集和一个验证数据集。在进行划分时,要确保你有足够的数据点用于训练,而且有足够数量的数据用于验证。我选择的比例容许我在训练集中有2560个数据点,在验证集中有1147个数据点。

如今是时候为咱们的数据集建立生成器了。

X_train_img, X_cv_img, y_train_rep, y_cv_rep = train_test_split(dataset['Person_id'], dataset['Report'],
                                                                test_size = split_size, random_state=97)
def load_image(id_, report):
    '''加载具备相应id的图像特征'''
    img_feature = Xnet_Features[id_.decode('utf-8')][0]
    return img_feature, report
def create_dataset(img_name_train, report_train):
    dataset = tf.data.Dataset.from_tensor_slices((img_name_train, report_train))
  # 使用map并行加载numpy文件
    dataset = dataset.map(lambda item1, item2: tf.numpy_function(load_image, [item1, item2],
                          [tf.float32, tf.string]),
                          num_parallel_calls=tf.data.experimental.AUTOTUNE)
  # 随机并batch化
    dataset = dataset.shuffle(500).batch(BATCH_SIZE).prefetch(buffer_size=tf.data.experimental.AUTOTUNE)
    return dataset
train_dataset = create_dataset(X_train_img, y_train_rep)
cv_dataset = create_dataset(X_cv_img, y_cv_rep)

在这里,咱们建立了两个数据生成器,用于训练的train_dataset和用于验证的cv_dataset 。create_dataset函数获取id(对于前面建立的特征,这是字典的键)和预处理的报告,并建立生成器。生成器一次生成batch大小的数据点数量。

如前所述,咱们要建立的模型将是一个逐字的模型。该模型以图像特征和部分序列为输入,生成序列中的下一个单词。

例如:让“图像特征”对应的报告为“startseq the cardiac silhouette and mediastinum size are within normal limits endseq”。

而后将输入序列分红11个输入输出对来训练模型:

注意,咱们不是经过生成器建立这些输入输出对。生成器一次只向咱们提供图像特征的batch处理大小数量及其相应的完整报告。输入输出对在训练过程当中稍后生成,稍后将对此进行解释。

8.编解码器模型

sequence-to-sequence模型是一个深度学习模型,它接受一个序列(在咱们的例子中,是图像的特征)并输出另外一个序列(报告)。

编码器处理输入序列中的每一项,它将捕获的信息编译成一个称为上下文的向量。在处理完整个输入序列后,编码器将上下文发送到解码器,解码器开始逐项生成输出序列。

本例中的编码器是一个CNN,它经过获取图像特征来生成上下文向量。译码器是一个循环神经网络。

Marc Tanti在他的论文Where to put the Image in an Image Caption Generator, 中介绍了init-inject、par-inject、pre-inject和merge等多种体系结构。在建立一个图像标题生成器时,指定了图像应该注入的位置。咱们将使用他论文中指定的架构来解决咱们的问题。

在“Merge”架构中,RNN在任什么时候候都不暴露于图像向量(或从图像向量派生的向量)。取而代之的是,在RNN进行了总体编码以后,图像被引入到语言模型中。这是一种后期绑定体系结构,它不会随每一个时间步修改图像表示。

他的论文中的一些重要结论被用于咱们实现的体系结构中。他们是:

  1. RNN输出须要正则化,并带有丢失。

  2. 图像向量不该该有一个非线性的激活函数,或者使用dropout进行正则化。

  3. 从CheXNet中提取特征时,图像输入向量在输入到神经网络以前必须进行归一化处理。

嵌入层

词嵌入是一类使用密集向量表示来表示单词和文档的方法。Keras提供了一个嵌入层,能够用于文本数据上的神经网络。它也可使用在别处学过的词嵌入。在天然语言处理领域,学习、保存词嵌入是很常见的。

在咱们的模型中,嵌入层使用预训练的GLOVE模型将每一个单词映射到300维表示中。使用预训练的嵌入时,请记住,应该经过设置参数“trainable=False”冻结层的权重,这样权重在训练时不会更新。

模型代码:

input1 = Input(shape=(2048), name='Image_1')
dense1 = Dense(256, kernel_initializer=tf.keras.initializers.glorot_uniform(seed = 56),
               name='dense_encoder')(input1)

input2 = Input(shape=(155), name='Text_Input')
emb_layer = Embedding(input_dim = vocab_size, output_dim = 300, input_length=155, mask_zero=True,
                      trainable=False, weights=[embedding_matrix], name="Embedding_layer")
emb = emb_layer(input2)
LSTM2 = LSTM(units=256, activation='tanh', recurrent_activation='sigmoid', use_bias=True, 
            kernel_initializer=tf.keras.initializers.glorot_uniform(seed=23),
            recurrent_initializer=tf.keras.initializers.orthogonal(seed=7),
            bias_initializer=tf.keras.initializers.zeros(), name="LSTM2")
LSTM2_output = LSTM2(emb)
dropout1 = Dropout(0.5, name='dropout1')(LSTM2_output)

dec =  tf.keras.layers.Add()([dense1, dropout1])

fc1 = Dense(256, activation='relu', kernel_initializer=tf.keras.initializers.he_normal(seed = 63),
            name='fc1')
fc1_output = fc1(dec)
output_layer = Dense(vocab_size, activation='softmax', name='Output_layer')
output = output_layer(fc1_output)

encoder_decoder = Model(inputs = [input1, input2], outputs = output)

模型摘要:

8.1 训练

损失函数

为此问题创建了一个掩蔽损失函数。例如:

若是咱们有一系列标识[3],[10],[7],[0],[0],[0],[0],[0]

咱们在这个序列中只有3个单词,0对应于填充,实际上这不是报告的一部分。可是模型会认为零也是序列的一部分,并开始学习它们。

当模型开始正确预测零时,损失将减小,由于对于模型来讲,它是正确学习的。但对于咱们来讲,只有当模型正确地预测实际单词(非零)时,损失才应该减小。

所以,咱们应该屏蔽序列中的零,这样模型就不会关注它们,而只学习报告中须要的单词。

loss_function = tf.keras.losses.CategoricalCrossentropy(from_logits=False, reduction='auto')

def maskedLoss(y_true, y_pred):
    #获取掩码
    mask = tf.math.logical_not(tf.math.equal(y_true, 0))
    
    #计算loss
    loss_ = loss_function(y_true, y_pred)
    
    #转换为loss_ dtype类型
    mask = tf.cast(mask, dtype=loss_.dtype)
    
    #给损失函数应用掩码
    loss_ = loss_*mask
    
    #获取均值
    loss_ = tf.reduce_mean(loss_)
    return loss_

输出词是一个one-hot编码,所以分类交叉熵将是咱们的损失函数。

optimizer = tf.keras.optimizers.Adam(0.001)
encoder_decoder.compile(optimizer, loss = maskedLoss)

还记得咱们的数据生成器吗?如今是时候使用它们了。

这里,生成器提供的batch不是咱们用于训练的实际数据batch。请记住,它们不是逐字输入输出对。它们只返回图像及其相应的整个报告。

咱们将从生成器中检索每一个batch,并将从该batch中手动建立输入输出序列,也就是说,咱们将建立咱们本身的定制的batch数据以供训练。因此在这里,batch处理大小逻辑上是模型在一个batch中看到的图像对的数量。咱们能够根据咱们的系统能力改变它。我发现这种方法比其余博客中提到的传统定制生成器要快得多。

因为咱们正在建立本身的batch数据用于训练,所以咱们将使用“train_on_batch”来训练咱们的模型。

epoch_train_loss = []
epoch_val_loss = []

for epoch in range(EPOCH):
    print('EPOCH : ',epoch+1)
    start = time.time()
    batch_loss_tr = 0
    batch_loss_vl = 0
    
    for img, report in train_dataset:
       
        r1 = bytes_to_string(report.numpy())
        img_input, rep_input, output_word = convert(img.numpy(), r1)
        rep_input = pad_sequences(rep_input, maxlen=MAX_INPUT_LEN, padding='post')
        results = encoder_decoder.train_on_batch([img_input, rep_input], output_word)
        
        batch_loss_tr += results
    train_loss = batch_loss_tr/(X_train_img.shape[0]//BATCH_SIZE)
    with train_summary_writer.as_default():
        tf.summary.scalar('loss', train_loss, step = epoch)
    
    for img, report in cv_dataset:
        
        r1 = bytes_to_string(report.numpy())
        img_input, rep_input, output_word = convert(img.numpy(), r1)
        rep_input = pad_sequences(rep_input, maxlen=MAX_INPUT_LEN, padding='post')
        results = encoder_decoder.test_on_batch([img_input, rep_input], output_word)
        batch_loss_vl += results
    
    val_loss = batch_loss_vl/(X_cv_img.shape[0]//BATCH_SIZE)
    with val_summary_writer.as_default():
        tf.summary.scalar('loss', val_loss, step = epoch)

    epoch_train_loss.append(train_loss)
    epoch_val_loss.append(val_loss)
    
    print('Training Loss: {},  Val Loss: {}'.format(train_loss, val_loss))
    print('Time Taken for this Epoch : {} sec'.format(time.time()-start))   
    encoder_decoder.save_weights('Weights/BM7_new_model1_epoch_'+ str(epoch+1) + '.h5')

代码中提到的convert函数将生成器中的数据转换为逐字输入输出对表示。而后将剩余报告填充到报告的最大长度。

Convert 函数:

def convert(images, reports):
    '''此函数接受batch数据并将其转换为新数据集'''
    imgs = []
    in_reports = []
    out_reports = []
    for i in range(len(images)):
        sequence = [tokenizer.word_index[e] for e in reports[i].split() if e in tokenizer.word_index.keys()]
        for j in range(1,len(sequence)):
            
            in_seq = sequence[:j]
            out_seq = sequence[j]
            out_seq = tf.keras.utils.to_categorical(out_seq, num_classes=vocab_size)

            imgs.append(images[i])
            in_reports.append(in_seq)
            out_reports.append(out_seq)
    return np.array(imgs), np.array(in_reports), np.array(out_reports)

Adam优化器的学习率为0.001。该模型训练了40个epoch,但在第35个epoch获得了最好的结果。因为随机性,你获得的结果可能会有所不一样。

:以上训练在Tensorflow 2.1中实现。

8.2 推理

如今咱们已经训练了咱们的模型,是时候准备咱们的模型来预测报告了。

为此,咱们必须对咱们的模型做一些调整。这将在测试期间节省一些时间。

首先,咱们将从模型中分离出编码器和解码器部分。由编码器预测的特征将被用做咱们的解码器的输入。

# 编码器
encoder_input = encoder_decoder.input[0]
encoder_output = encoder_decoder.get_layer('dense_encoder').output
encoder_model = Model(encoder_input, encoder_output)


# 解码器
text_input = encoder_decoder.input[1]
enc_output = Input(shape=(256,), name='Enc_Output')
text_output = encoder_decoder.get_layer('LSTM2').output
add1 = tf.keras.layers.Add()([text_output, enc_output])
fc_1 = fc1(add1)
decoder_output = output_layer(fc_1)
decoder_model = Model(inputs = [text_input, enc_output], outputs = decoder_output)

经过这样作,咱们只须要预测一次编码器的特征,而咱们将其用于贪婪搜索和束(beam)搜索算法。

咱们将实现这两种生成文本的算法,并看看哪种算法最有效。

8.3 贪婪搜索算法

贪婪搜索是一种算法范式,它逐块构建解决方案,每次老是选择最好的。

贪婪搜索步骤

  1. 编码器输出图像的特征。编码器的工做到此结束。一旦咱们有了咱们须要的特征,咱们就不须要关注编码器了。

  2. 这个特征向量和起始标识“startseq”(咱们的初始输入序列)被做为解码器的第一个输入。

  3. 译码器预测整个词汇表的几率分布,几率最大的单词将被选为下一个单词。

  4. 这个预测获得的单词和前一个输入序列将是咱们下一个输入序列,而且传递到解码器。

  5. 继续执行步骤3-4,直到遇到结束标识,即“endseq”。

def greedysearch(img):
    image = Xnet_Features[img] # 提取图像的初始chexnet特征
    input_ = 'startseq'  # 报告的起始标识
    image_features = encoder_model.predict(image) # 编码输出
    
    result = [] 
    for i in range(MAX_REP_LEN):
        input_tok = [tokenizer.word_index[w] for w in input_.split()]
        input_padded = pad_sequences([input_tok], 155, padding='post')
        predictions = decoder_model.predict([input_padded, image_features])
        arg = np.argmax(predictions)
        if arg != tokenizer.word_index['endseq']:   # endseq 标识
            result.append(tokenizer.index_word[arg])
            input_ = input_ + ' ' + tokenizer.index_word[arg]
        else:
            break
    rep = ' '.join(e for e in result)
    return rep

让咱们检查一下在使用greedysearch生成报告后,咱们的模型的性能如何。

BLEU分数-贪婪搜索:

双语评估替补分数,简称BLEU,是衡量生成句到参考句的一个指标。

完美匹配的结果是1.0分,而彻底不匹配的结果是0.0分。该方法经过计算候选文本中匹配的n个单词到参考文本中的n个单词,其中uni-gram是每一个标识,bigram比较是每一个单词对。

在实践中不可能获得完美的分数,由于译文必须与参考文献彻底匹配。这甚至连人类的翻译人员都不可能作到。

要了解有关BLEU的更多信息,请单击此处:https://machinelearningmastery.com/calculate-bleu-score-for-text-python/

8.4 束搜索

Beam search(束搜索)是一种在贪婪搜索的基础上扩展并返回最有可能的输出序列列表的算法。每一个序列都有一个与之相关的分数。以得分最高的顺序做为最终结果。

在构建序列时,束搜索不是贪婪地选择最有可能的下一步,而是扩展全部可能的下一步并保持k个最有可能的结果,其中k(即束宽度)是用户指定的参数,并经过几率序列控制束数或并行搜索。

束宽度为1的束搜索就是贪婪搜索。常见的束宽度值为5-10,但研究中甚至使用了高达1000或2000以上的值,以从模型中挤出最佳性能。要了解更多有关束搜索的信息,请单击此处。

但请记住,随着束宽度的增长,时间复杂度也会增长。所以,这些比贪婪搜索慢得多。

def beamsearch(image, beam_width):
    
    start = [tokenizer.word_index['startseq']]

    sequences = [[start, 0]]
    
    img_features = Xnet_Features[image]
    img_features = encoder_model.predict(img_features)
    finished_seq = []
    
    for i in range(max_rep_length):
        all_candidates = []
        new_seq = []
        for s in sequences:

            text_input = pad_sequences([s[0]], 155, padding='post')
            predictions = decoder_model.predict([img_features, text_input])
            top_words = np.argsort(predictions[0])[-beam_width:]
            seq, score = s
            
            for t in top_words:
                candidates = [seq + [t], score - log(predictions[0][t])]
                all_candidates.append(candidates)
                
        sequences = sorted(all_candidates, key = lambda l: l[1])[:beam_width]
        # 检查波束中每一个序列中的'endseq'
        count = 0
        for seq,score in sequences:
            if seq[len(seq)-1] == tokenizer.word_index['endseq']:
                score = score/len(seq)   # 标准化
                finished_seq.append([seq, score])
                count+=1
            else:
                new_seq.append([seq, score])
        beam_width -= count
        sequences = new_seq
        
        # 若是全部序列在155个时间步以前结束
        if not sequences:
            break
        else:
            continue
        
    sequences = finished_seq[-1] 
    rep = sequences[0]
    score = sequences[1]
    temp = []
    rep.pop(0)
    for word in rep:
        if word != tokenizer.word_index['endseq']:
            temp.append(tokenizer.index_word[word])
        else:
            break    
    rep = ' '.join(e for e in temp)        
    
    return rep, score

束搜索并不老是能保证更好的结果,但在大多数状况下,它会给你一个更好的结果。

你可使用上面给出的函数检查束搜索的BLEU分数。但请记住,评估它们须要一段时间(几个小时)。

8.5 示例

如今让咱们看看胸部X光片的预测报告:

图像对1的原始报告:“心脏正常大小。纵隔不明显。肺部很干净。”

图像对1的预测报告:“心脏正常大小。纵隔不明显。肺部很干净。”

对于这个例子,模型预测的是彻底相同的报告。

图像对2的原始报告:“心脏大小和肺血管在正常范围内。未发现局灶性浸润性气胸胸腔积液

图像对2的预测报告:“心脏大小和肺血管在正常范围内出现。肺为游离灶性空域病变。未见胸腔积液气胸

虽然不彻底相同,但预测结果与最初的报告几乎类似。

图像对3的原始报告:“肺过分膨胀但清晰。无局灶性浸润性渗出。心脏和纵隔轮廓在正常范围内。发现有钙化的纵隔

图像对3的预测报告:“心脏大小正常。纵隔轮廓在正常范围内。肺部没有任何病灶浸润。没有结节肿块。无明显气胸。无可见胸膜液。这是很是正常的。横膈膜下没有可见的游离腹腔内空气。”

你没想到这个模型能完美地工做,是吗?没有一个模型是完美的,这个也不是完美的。尽管存在从图像对3正确识别的一些细节,可是产生的许多额外细节多是正确的,也多是不正确的。

咱们建立的模型并非一个完美的模型,但它确实为咱们的图像生成了体面的报告。

如今让咱们来看看一个高级模型,看看它是否提升了当前的性能!!

9.注意机制

注意机制是对编解码模型的改进。事实证实,上下文向量是这些类型模型的瓶颈。这使他们很难处理长句。Bahdanau et al.,2014和Luong et al.,2015提出了解决方案。

这些论文介绍并改进了一种叫作“注意机制”的技术,它极大地提升了机器翻译系统的质量。注意容许模型根据须要关注输入序列的相关部分。后来,这一思想被应用于图像标题。

那么,咱们如何为图像创建注意力机制呢?

对于文本,咱们对输入序列的每一个位置都有一个表示。可是对于图像,咱们一般使用网络中一个全链接层表示,可是这种表示不包含任何位置信息(想一想看,它们是全链接的)。

咱们须要查看图像的特定部分(位置)来描述其中的内容。例如,要从x光片上描述一我的的心脏大小,咱们只须要观察他的心脏区域,而不是他的手臂或任何其余部位。那么,注意力机制的输入应该是什么呢?

咱们使用卷积层(迁移学习)的输出,而不是全链接的表示,由于卷积层的输出具备空间信息。

例如,让最后一个卷积层的输出是(7×14×1024)大小的特征。这里,“7×14”是与图像中某些部分相对应的实际位置,1024个是通道。咱们关注的不是通道而是图像的位置。所以,这里咱们有7*14=98个这样的位置。咱们能够把它看做是98个位置,每一个位置都有1024维表示。

如今咱们有98个时间步,每一个时间步有1024个维表示。咱们如今须要决定模型应该如何关注这98个时间点或位置。

一个简单的方法是给每一个位置分配一些权重,而后获得全部这98个位置的加权和。若是一个特定的时间步长对于预测一个输出很是重要,那么这个时间步长将具备更高的权重。让这些重量用字母表示。

如今咱们知道了,alpha决定了一个特定地点的重要性。alpha值越高,重要性越高。可是咱们如何找到alpha的值呢?没有人会给咱们这些值,模型自己应该从数据中学习这些值。为此,咱们定义了一个函数:

这个量表示第j个输入对于解码第t个输出的重要性。h_j是第j个位置表示,s_t-1是解码器到该点的状态。咱们须要这两个量来肯定e_jt。f_ATT只是一个函数,咱们将在后面定义。

在全部输入中,咱们但愿这个量(e_jt)的总和为1。这就像是用几率分布来表示输入的重要性。利用softmax将e_jt转换为几率分布。

如今咱们有了alphas!alphas是咱们的权重。alpha_jt表示聚焦于第j个输入以产生第t个输出的几率。

如今是时候定义咱们的函数f_ATT了。如下是许多可能的选择之一:

V、 U和W是在训练过程当中学习的参数,用于肯定e_jt的值。

咱们有alphas,咱们有输入,如今咱们只须要获得加权和,产生新的上下文向量,它将被输入解码器。在实践中,这些模型比编解码器模型工做得更好。

模型实现:

和上面提到的编解码器模型同样,这个模型也将由两部分组成,一个编码器和一个解码器,但此次解码器中会有一个额外的注意力成分,即注意力解码器。为了更好地理解,如今让咱们用代码编写:

# 计算e_jts
score = self.Vattn(tf.nn.tanh(self.Uattn(features) + self.Wattn(hidden_with_time_axis)))

# 使用softmax将分数转换为几率分布
attention_weights = tf.nn.softmax(score, axis=1)

# 计算上下文向量(加权和)
context_vector = attention_weights * features

在构建模型时,咱们没必要从头开始编写这些代码行。keras库已经为这个目的内置了一个注意层。咱们将直接使用添加层或其余称为Bahdanau的注意力。你能够从文档自己了解有关该层的更多信息。连接:https://www.tensorflow.org/api_docs/python/tf/keras/layers/AdditiveAttention

这个模型的文本输入将保持不变,可是对于图像特征,此次咱们将从CheXNet网络的最后一个conv层获取特征。

合并两幅图像后的最终输出形状为(None,7,14,1024)。因此整形后编码器的输入将是(None,981024)。为何要重塑图像?好吧,这已经在注意力介绍中解释过了,若是你有任何疑问,必定要把解释再读一遍。

模型

input1 = Input(shape=(98,1024), name='Image_1')
maxpool1 = tf.keras.layers.MaxPool1D()(input1)
dense1 = Dense(256, kernel_initializer=tf.keras.initializers.glorot_uniform(seed = 56), name='dense_encoder')(maxpool1)

input2 = Input(shape=(155), name='Text_Input')
emb_layer = Embedding(input_dim = vocab_size, output_dim = 300, input_length=155, mask_zero=True, trainable=False, 
                      weights=[embedding_matrix], name="Embedding_layer")
emb = emb_layer(input2)

LSTM1 = LSTM(units=256, activation='tanh', recurrent_activation='sigmoid', use_bias=True, 
            kernel_initializer=tf.keras.initializers.glorot_uniform(seed=23),
            recurrent_initializer=tf.keras.initializers.orthogonal(seed=7),
            bias_initializer=tf.keras.initializers.zeros(), return_sequences=True, return_state=True, name="LSTM1")
lstm_output, h_state, c_state = LSTM1(emb)

LSTM2 = LSTM(units=256, activation='tanh', recurrent_activation='sigmoid', use_bias=True, 
            kernel_initializer=tf.keras.initializers.glorot_uniform(seed=23),
            recurrent_initializer=tf.keras.initializers.orthogonal(seed=7),
            bias_initializer=tf.keras.initializers.zeros(), return_sequences=True, return_state=True, name="LSTM2")

lstm_output, h_state, c_state = LSTM2(lstm_output)

dropout1 = Dropout(0.5)(lstm_output)

attention_layer = tf.keras.layers.AdditiveAttention(name='Attention')
attention_output = attention_layer([dense1, dropout1], training=True)

dense_glob = tf.keras.layers.GlobalAveragePooling1D()(dense1)
att_glob = tf.keras.layers.GlobalAveragePooling1D()(attention_output)

concat = Concatenate()([dense_glob, att_glob])
dropout2 = Dropout(0.5)(concat)
FC1 = Dense(256, activation='relu', kernel_initializer=tf.keras.initializers.he_normal(seed = 56), name='fc1')
fc1 = FC1(dropout2)
OUTPUT_LAYER = Dense(vocab_size, activation='softmax', name='Output_Layer')
output = OUTPUT_LAYER(fc1)

attention_model = Model(inputs=[input1, input2], outputs = output)

该模型相似于咱们以前看到的编解码器模型,但有注意组件和一些小的更新。若是你愿意,你能够尝试本身的改变,它们可能会产生更好的结果。

模型架构

模型摘要

9.1 训练

训练步骤将与咱们的编解码器模型彻底相同。咱们将使用相同的“convert”函数生成批处理,从而得到逐字输入输出序列,并使用train_on_batch对其进行训练。

与编解码器模型相比,注意力模型须要更多的内存和计算能力。所以,你可能须要减少这个batch的大小。全过程请参考编解码器模型的训练部分。

为了注意机制,使用了adam优化器,学习率为0.0001。这个模型被训练了20个epoch。因为随机性,你获得的结果可能会有所不一样。

全部代码均可以从个人GitHub访问。它的连接已经在这个博客的末尾提供了。

9.2 推理

与以前中同样,咱们将从模型中分离编码器和解码器部分。

# 编码器
encoder_input = attention_model.input[0]
encoder_output = attention_model.get_layer('dense_encoder').output
encoder_model = Model(encoder_input, encoder_output)

# 有注意力机制的解码器
text_input = attention_model.input[1]
cnn_input = Input(shape=(49,256))
lstm, h_s, c_s = attention_model.get_layer('LSTM2').output
att = attention_layer([cnn_input, lstm])
d_g = tf.keras.layers.GlobalAveragePooling1D()(cnn_input)
a_g = tf.keras.layers.GlobalAveragePooling1D()(att)
con = Concatenate()([d_g, a_g])
fc_1 = FC1(con)
out = OUTPUT_LAYER(fc_1)
decoder_model = Model([cnn_input, text_input], out)

这为咱们节省了一些测试时间。

9.3 贪婪搜索

如今,咱们已经构建了模型,让咱们检查得到的BLEU分数是否确实比之前的模型有所改进:

咱们能够看出它比贪婪搜索的编解码模型有更好的性能。所以,它绝对是比前一个改进。

9.4 束搜索

如今让咱们看看束搜索的一些分数:

BLEU得分低于贪婪算法,但差距并不大。但值得注意的是,随着束宽度的增长,分数实际上在增长。所以,可能存在束宽度的某个值,其中分数与贪婪算法的分数交叉。

9.5 示例

如下是模型使用贪婪搜索生成的一些报告:

图像对1的原始报告:“心脏大小和肺血管在正常范围内。未发现局灶性浸润性气胸胸腔积液

图像对1的预测报告:“心脏大小和纵隔轮廓在正常范围内。肺是干净的。没有气胸胸腔积液。没有急性骨性发现。”

这些预测与最初的报告几乎类似。

图像对2的原始报告:“心脏大小和肺血管在正常范围内出现。肺为游离灶性空域病变。未见胸腔积液气胸

图像对2的预测报告:“心脏大小和肺血管在正常范围内出现。肺为游离灶性空域病变。未见胸腔积液气胸

预测的报告彻底同样!!

图像对3的原始报告:“心脏正常大小。纵隔不明显。肺部很干净。”

图像对3的预测报告:“心脏正常大小。纵隔不明显。肺部很干净。”

在这个例子中,模型也作得很好。

图像对4的原始报告:“双侧肺清晰。明确无病灶实变气胸胸腔积液。心肺纵隔轮廓不明显。可见骨结构胸部无急性异常

图像对4的预测报告:“心脏大小和纵隔轮廓在正常范围内。肺是干净的。没有气胸胸腔积液

你能够看到这个预测并不真正使人信服。

“可是,这个例子的束搜索预测的是彻底相同的报告,即便它产生的BLEU分数比整个测试数据的总和要低!!!”

那么,选择哪个呢?好吧,这取决于咱们。只需选择一个通用性好的方法。

在这里,即便咱们的注意力模型也不能准确地预测每一幅图像。若是咱们查看原始报告中的单词,则会发现一些复杂的单词,经过一些EDA能够发现它并不常常出现。这些多是咱们在某些状况下没有很好的预测的一些缘由。

请记住,咱们只是在2560个数据点上训练这个模型。为了学习更复杂的特征,模型须要更多的数据。

10.摘要

如今咱们已经结束了这个项目,让咱们总结一下咱们所作的:

  • 咱们刚刚看到了图像字幕在医学领域的应用。咱们理解这个问题,也理解这种应用的必要性。

  • 咱们了解了如何为输入管道使用数据生成器。

  • 建立了一个编解码器模型,给了咱们不错的结果。

  • 经过创建一个注意模型来改进基本结果。

11.从此的工做

正如咱们提到的,咱们没有大的数据集来完成这个任务。较大的数据集将产生更好的结果。

没有对任何模型进行超参数调整。所以,一个更好的超参数调整可能会产生更好的结果。

利用一些更先进的技术,如transformers 或Bert,可能会产生更好的结果。

12.引用

  1. https://www.appliedaicourse.com/
  2. https://arxiv.org/abs/1502.03044
  3. https://www.aclweb.org/anthology/P18-1240/
  4. https://arxiv.org/abs/1703.09137
  5. https://arxiv.org/abs/1409.0473
  6. https://machinelearningmastery.com/develop-a-deep-learning-caption-generation-model-in-python/

这个项目的整个代码能够从个人GitHub访问:https://github.com/vysakh10/Image-Captioning

原文连接:https://towardsdatascience.com/image-captioning-using-deep-learning-fe0d929cf337

欢迎关注磐创AI博客站:
http://panchuang.net/

sklearn机器学习中文官方文档:
http://sklearn123.com/

欢迎关注磐创博客资源汇总站:
http://docs.panchuang.net/

相关文章
相关标签/搜索