enhc-dl-bys-inf-merge-1

86 阅读1小时+

贝叶斯推理深度学习增强指南(二)

原文:annas-archive.org/md5/3925f12c16b3ab4fff402d1f04c4210b

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章

使用标准工具箱进行贝叶斯深度学习

正如我们在前面的章节中看到的,普通的神经网络往往产生较差的不确定性估计,并且往往会做出过于自信的预测,而有些甚至根本无法生成不确定性估计。相比之下,概率架构提供了获得高质量不确定性估计的原则性方法;然而,在扩展性和适应性方面,它们有一些局限性。

尽管 PBP 和 BBB 都可以通过流行的机器学习框架来实现(正如我们在之前的 TensorFlow 示例中所展示的),但它们非常复杂。正如我们在上一章中看到的,实现一个简单的网络也并非易事。这意味着将它们适应到新架构中是一个笨拙且耗时的过程(特别是 PBP,尽管是可能的——参见完全贝叶斯递归神经网络用于安全强化 学习)。对于一些简单任务,例如第五章,贝叶斯深度学习的原则性方法中的示例,这并不是问题。但在许多现实世界的任务中,例如

机器翻译或物体识别等任务,需要更为复杂的网络架构。

虽然一些学术机构或大型研究组织可能具备足够的时间和资源来将这些复杂的概率方法适应到各种复杂的架构中,但在许多情况下,这并不可行。此外,越来越多的行业研究人员和工程师正在转向基于迁移学习的方法,使用预训练的网络作为模型的骨干。在这些情况下,简单地将概率机制添加到预定义架构中是不可行的。

为了解决这个问题,本章将探讨如何利用深度学习中的常见范式来开发概率模型。这里介绍的方法表明,通过相对较小的调整,您可以轻松地将大型复杂架构适应到高质量的不确定性估计中。我们甚至会介绍一些技术,使您能够从已训练的网络中获取不确定性估计!

本章将涵盖三种关键方法,以便在常见的深度学习框架中轻松进行模型不确定性估计。首先,我们将介绍蒙特卡洛 DropoutMC dropout),一种通过在推理时使用 Dropout 来引入预测方差的方法。其次,我们将介绍深度集成方法,即通过结合多个神经网络来促进不确定性估计和提高模型性能。最后,我们将探索将贝叶斯层添加到模型中的各种方法,使任何模型都能产生不确定性估计。

以下内容将在接下来的章节中讨论:

  • 通过 Dropout 引入近似贝叶斯推断

  • 使用集成方法进行模型不确定性估计

  • 探索通过贝叶斯最后一层方法增强神经网络

6.1 技术要求

要完成本章的实际任务,您需要一个 Python 3.8 环境,并安装 SciPy 堆栈以及以下附加的 Python 包:

  • TensorFlow 2.0

  • TensorFlow 概率

本书的所有代码可以在本书的 GitHub 仓库找到:github.com/PacktPublishing/Enhancing-Deep-Learning-with-Bayesian-Inference

6.2 通过 dropout 引入近似贝叶斯推断

Dropout 传统上用于防止神经网络的过拟合。它最早在 2012 年提出,现在被广泛应用于许多常见的神经网络架构,并且是最简单且最常用的正则化方法之一。Dropout 的核心思想是在训练过程中随机关闭(或丢弃)神经网络的某些单元。因此,模型不能仅依赖某一小部分神经元来解决任务。相反,模型被迫找到不同的方式来完成任务。这提高了模型的鲁棒性,并使其不太可能过拟合。

如果我们简化一个网络为 y = Wx,其中 y 是我们网络的输出,x 是输入,W 是我们的模型权重,我们可以将 dropout 理解为:

 ( { wj, p wˆj = ( 0, otherwise

其中 w[j] 是应用 dropout 后的新权重,w[j] 是应用 dropout 前的权重,p 是我们不应用 dropout 的概率。

原始的 dropout 论文建议随机丢弃网络中 50% 的单元,并对所有层应用 dropout。输入层的 dropout 概率不应相同,因为这意味着我们丢弃了 50% 的输入信息,这会使模型更难收敛。实际上,您可以尝试不同的 dropout 概率,找到最适合您的特定数据集和模型的丢弃率;这是另一个您可以优化的超参数。Dropout 通常作为一个独立的层,在所有标准的神经网络库中都可以找到。您通常在激活函数之后添加它:


from tensorflow.keras import Sequential 
from tensorflow.keras.layers import Flatten, Conv2D, MaxPooling2D, Dropout, Dense 

model = Sequential([ 
Conv2D(32, (3,3), activation="relu", input_shape=(28281)), 
MaxPooling2D((2,2)), 
Dropout(0.2), 
Conv2D(64, (3,3), activation="relu"), 
MaxPooling2D((2,2)), 
Dropout(0.5), 
Flatten(), 
Dense(64, activation="relu"), 
Dropout(0.5), 
Dense(10) 
]) 

现在我们已经回顾了 dropout 的基本应用,让我们看看如何将其用于贝叶斯推断。

6.2.1 使用 dropout 进行近似贝叶斯推断

传统的 dropout 方法使得在测试时 dropout 网络的预测是确定性的,因为在推理过程中关闭了 dropout。然而,我们也可以利用 dropout 的随机性来为我们带来优势。这就是所谓的 蒙特卡罗 (MC) dropout,其思想如下:

  1. 我们在测试时使用 dropout。

  2. 我们不是只运行一次推理,而是运行多次(例如,30-100 次)。

  3. 然后我们对预测结果取平均,以获得我们的不确定性估计。

为什么这有益?正如我们之前所说,使用 dropout 可以迫使模型学习解决任务的不同方法。因此,当我们在推理过程中保持启用 dropout 时,我们使用的是稍微不同的网络,这些网络通过模型的不同路径处理输入数据。这种多样性在我们希望获得校准的不确定性评分时非常有用,正如我们在下一节中所看到的,我们将讨论深度集成的概念。我们现在不再为每个输入预测一个点估计(一个单一值),而是让网络生成一组值的分布(由多个前向传递组成)。我们可以使用这个分布来计算每个输入数据点的均值和方差,如 6.1所示。

PIC

图 6.1:MC dropout 示例

我们也可以用贝叶斯的方式来解释 MC dropout。使用这些稍微不同的网络进行 dropout 可以看作是从所有可能模型的分布中进行采样:网络所有参数(或权重)上的后验分布:

𝜃t ∼ P (𝜃|D )

这里,𝜃[t]是一个 dropout 配置,∼表示从我们的后验分布P(𝜃|D)中抽取的单个样本。这样,MC dropout 就相当于一种近似贝叶斯推断的方法,类似于我们在第五章中看到的方法,贝叶斯深度学习的原则性方法

现在我们已经对 MC dropout 的工作原理有所了解,让我们在 TensorFlow 中实现它。

6.2.2 实现 MC dropout

假设我们已经训练了本章第一个实践练习中描述的卷积架构的模型。现在,我们可以通过将training=True来在推理过程中使用 dropout:


def mc_dropout_inference( 
imgs: np.ndarray, 
nb_inference: int, 
model: Sequential 
) -*>* np.ndarray: 
"""" 
Run inference nb_inference times with random dropout enabled 
(training=True) 
""" 
divds = [] 
for _ in range(nb_inference): 
divds.append(model(imgs, training=True)) 
return tf.nn.softmax(divds, axis=-1).numpy() 

Predictions = mc_dropout_inference(test_images, 50, model)

这使得我们能够为模型的每次预测计算均值和方差。我们的Predictions变量的每一行都包含与每个输入相关的预测结果,这些预测是通过连续的前向传递获得的。从这些预测中,我们可以计算均值和方差,如下所示:


predictive_mean = np.mean(predictions, axis=0) 
predictive_variance = np.var(predictions, axis=0)

与所有神经网络一样,贝叶斯神经网络需要通过超参数进行一定程度的微调。以下三个超参数对于 MC dropout 尤为重要:

  • Dropout 层数:在我们的Sequential对象中,使用 dropout 的层数是多少,具体是哪些层。

  • Dropout 率:节点被丢弃的概率。

  • MC dropout 样本数量:这是 MC dropout 特有的一个新超参数。这里表示为nb_inference,它定义了在推理时从 MC dropout 网络中采样的次数。

我们现在已经看到 MC dropout 可以以一种新的方式使用,提供了一种简单直观的方法来利用熟悉的工具计算贝叶斯不确定性。但这并不是我们唯一可以使用的方法。在下一节中,我们将看到如何将集成方法应用于神经网络;这为我们提供了另一种逼近 BNN 的直接方法。

6.3 使用集成方法进行模型不确定性估计

本节将介绍深度集成方法:这是一种通过深度网络集成来获得贝叶斯不确定性估计的流行方法。

6.3.1 介绍集成方法

机器学习中一个常见的策略是将多个单一模型组合成一个模型委员会。学习这种模型组合的过程称为集成学习,而得到的模型委员会则称为集成模型。集成学习包含两个主要部分:首先,多个单一模型需要被训练。有多种策略可以从相同的训练数据中获得不同的模型:可以在不同的数据子集上训练模型,或者训练不同类型的模型或具有不同架构的模型,亦或是使用不同超参数初始化相同类型的模型。其次,需要将不同单一模型的输出进行组合。常见的组合单一模型预测的策略是直接取其平均值,或者对集成模型中的所有成员进行多数投票。更高级的策略包括取加权平均值,或者如果有更多的训练数据,则可以学习一个额外的模型来结合集成成员的不同预测结果。

集成方法在机器学习中非常流行,因为它们通常通过最小化意外选择性能较差模型的风险来提高预测性能。事实上,集成模型至少能够与任何单一模型一样好地执行。更重要的是,如果集成成员的预测存在足够的多样性,集成方法的表现将优于单一模型。这里的多样性意味着不同的集成成员在给定的数据样本上会犯不同的错误。例如,如果一些集成成员将一只狗的图像误分类为“猫”,但大多数集成成员做出了正确的预测(“狗”),那么集成模型的最终输出仍然是正确的(“狗”)。更一般来说,只要每个单一模型的准确率超过 50%,并且模型的错误是独立的,那么随着集成成员数量的增加,集成的预测性能将接近 100%的准确度。

除了提高预测性能外,我们还可以利用集成成员之间的一致性(或不一致性)来获得不确定性估计,并与集成的预测结果一起使用。例如,在图像分类的情况下,如果几乎所有集成成员都预测图像显示的是一只狗,那么我们可以说集成模型以高置信度(或低不确定性)预测为“狗”。相反,如果不同集成成员的预测存在显著的不一致,那么我们将观察到高不确定性,即集成成员输出之间的方差较大,这表明预测的置信度较低。

现在我们已经具备了对集成方法的基本理解,值得指出的是,我们在前一节中探讨的 MC Dropout 也可以看作一种集成方法。当我们在推理过程中启用 Dropout 时,我们实际上每次都在运行一个略有不同的(子)网络。这些不同子网络的组合可以看作是多个模型的委员会,因此也是一种集成方法。这一观察促使谷歌团队研究从深度神经网络(DNN)创建集成的替代方法,最终发现了深度集成(Lakshminarayan 等,2016),这一方法将在接下来的章节中介绍。

6.3.2 引入深度集成

深度集成的主要思想很简单:训练多个不同的深度神经网络(DNN)模型,然后通过平均它们的预测结果来提高模型性能,并利用这些模型预测结果的一致性来估计预测的不确定性。

更正式地说,假设我们有一些训练数据X,其中 X ∈ℝ^(D),以及相应的目标标签y。例如,在图像分类中,训练数据是图像,目标标签是表示图像中显示的是哪一类物体的整数,所以 y ∈{1*,...,K*},其中 K 是类别的总数。训练一个单一的神经网络意味着我们对标签建模概率预测分布 p**𝜃,并优化 𝜃,即神经网络的参数。对于深度集成,我们训练M个神经网络,它们的参数可以表示为{𝜃[m]}[m=1]^(M),其中每个 𝜃[m] 都是使用Xy独立优化的(这意味着我们在相同的数据上独立训练每个神经网络)。深度集成成员的预测通过平均值进行结合,使用 p(y|x) = M^(−1) ∑ [m=1]^(M)p𝜃[m]

6.2 说明了深度集成的思想。在这里,我们训练了 M = 3 个不同的前馈神经网络。请注意,每个网络都有自己独特的网络权重集,正如通过连接网络节点的边缘厚度不同所示。三个网络中的每一个都会输出自己的预测分数,如绿色节点所示,我们通过平均这些分数来进行结合。

图片

图 6.2:深度集成示例。请注意,三个网络在权重上有所不同,正如通过不同厚度的边缘所示。

如果只有一个数据集可供训练,我们如何训练多个不同的神经网络模型?原始论文提出的策略(也是目前最常用的策略)是每次训练都从网络权重的随机初始化开始。如果每次训练都从不同的权重集合开始,那么不同的训练运行可能会产生不同的网络,其训练数据的函数逼近方式也会有所不同。这是因为神经网络往往拥有比训练数据集中的样本数量更多的权重参数。因此,训练数据集中的相同观测值可以通过许多不同的权重参数组合来逼近。在训练过程中,不同的神经网络模型将各自收敛到自己的参数组合,并在损失函数的局部最优点上占据不同位置。因此,不同的神经网络通常会对给定的数据样本(例如,一只狗的图像)有不同的看法。这也意味着不同的神经网络在分类数据样本时可能会犯不同的错误。集成中不同网络之间的共识程度提供了关于集成模型对某一数据点预测的置信度信息:网络越一致,我们对预测的信心就越强。

使用相同训练数据集训练不同神经网络模型的替代方法包括:在训练过程中使用迷你批次的随机排序、为每次训练运行使用不同的超参数,或为每个模型使用不同的网络架构。这些策略也可以结合使用,精确理解哪些策略组合能带来最佳结果(无论是预测性能还是预测不确定性)仍然是一个活跃的研究领域。

6.3.3 实现深度集成

以下代码示例展示了如何使用随机权重初始化策略训练深度集成模型,以获得不同的集成成员。

步骤 1:导入库

我们首先导入相关的包,并将集成数量设置为3,用于本代码示例:


import tensorflow as tf 
import numpy as np 
import matplotlib.pyplot as plt 

ENSEMBLE_MEMBERS = 3
步骤 2:获取数据

然后,我们下载MNIST`` Fashion数据集,这是一个包含十种不同服装项目图像的数据集:


# download data set 
fashion_mnist = tf.keras.datasets.fashion_mnist 
# split in train and test, images and labels 
(train_images, train_labels), (test_images, test_labels) = fashion_mnist.load_data() 

# set class names 
CLASS_NAMES = ['T-shirt''Trouser''Pullover''Dress''Coat', 
               'Sandal''Shirt''Sneaker''Bag''Ankle boot']
步骤 3:构建集成模型

接下来,我们创建一个辅助函数来定义我们的模型。如你所见,我们使用一个简单的图像分类器结构,包含两个卷积层,每个卷积层后跟一个最大池化操作,以及若干全连接层:


def build_model(): 
# we build a forward neural network with tf.keras.Sequential 
model = tf.keras.Sequential([ 
# we define two convolutional layers followed by a max-pooling operation each 
tf.keras.layers.Conv2D(filters=32, kernel_size=(5,5), padding='same', 
activation='relu', input_shape=(28281)), 
tf.keras.layers.MaxPool2D(strides=2), 
tf.keras.layers.Conv2D(filters=48, kernel_size=(5,5), padding='valid', 
activation='relu'), 
tf.keras.layers.MaxPool2D(strides=2), 
# we flatten the matrix output into a vector 
tf.keras.layers.Flatten(), 
# we apply three fully-connected layers 
tf.keras.layers.Dense(256, activation='relu'), 
tf.keras.layers.Dense(84, activation='relu'), 
tf.keras.layers.Dense(10) 
]) 

return model 

我们还创建了另一个辅助函数,使用Adam作为优化器,并采用类别交叉熵损失来编译模型:


def compile_model(model): 
model.compile(optimizer='adam', 
loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True), 
metrics=['accuracy']) 
return model 

步骤 4:训练

然后,我们在相同的数据集上训练三个不同的网络。由于网络权重是随机初始化的,这将导致三个不同的模型。你会看到不同模型的训练准确度略有差异:


deep_ensemble = [] 
for ind in range(ENSEMBLE_MEMBERS): 
model = build_model() 
model = compile_model(model) 
print(f"Train model {ind:02}") 
model.fit(train_images, train_labels, epochs=10) 
    deep_ensemble.append(model)
步骤 5:推理

然后,我们可以执行推理并获得测试集中的每个模型对所有图像的预测结果。我们还可以对三个模型的预测结果取平均值,这样每个图像就会有一个预测向量:


# get logit predictions for all three models for images in the test split 
ensemble_logit_predictions = [model(test_images) for model in deep_ensemble] 
# convert logit predictions to softmax 
ensemble_softmax_predictions = [ 
tf.nn.softmax(logits, axis=-1for logits in ensemble_logit_predictions] 

# take mean across models, this will result in one prediction vector per image 
ensemble_predictions = tf.reduce_mean(ensemble_softmax_predictions, axis=0)

就这样。我们已经训练了一个网络集成并进行了推理。由于现在每个图像都有多个预测,我们还可以查看三个模型预测结果不一致的图像。

比如,我们可以找到预测结果不一致最多的图像并将其可视化:


# calculate variance across model predictions 
ensemble_std = tf.reduce_mean( 
tf.math.reduce_variance(ensemble_softmax_predictions, axis=0), 
axis=1) 
# find index of test image with highest variance across predictions 
ind_disagreement = np.argmax(ensemble_std) 

# get predictions per model for test image with highest variance 
ensemble_disagreement = [] 
for ind in range(ENSEMBLE_MEMBERS): 
model_prediction = np.argmax(ensemble_softmax_predictions[ind][ind_disagreement]) 
ensemble_disagreement.append(model_prediction) 
# get class predictions 
predicted_classes = [CLASS_NAMES[ind] for ind in ensemble_disagreement] 

# define image caption 
image_caption = \ 
f"Network 1: {predicted_classes[0]}\n" + \ 
f"Network 2: {predicted_classes[1]}\n" + \ 
f"Network 3: {predicted_classes[2]}\n" 

# visualise image and predictions 
plt.figure() 
plt.title(f"Correct class: {CLASS_NAMES[test_labels[ind_disagreement]]}") 
plt.imshow(test_images[ind_disagreement], cmap=plt.cm.binary) 
plt.xlabel(image_caption) 
plt.show()

看看 6.3中的图像,甚至对于人类来说,也很难判断图像中是 T 恤、衬衫还是包:

PIC

图 6.3:集成预测中方差最大的图像。正确的真实标签是"t-shirt",但即使是人类也很难判断。

虽然我们已经看到深度集成有几个有利的特性,但它们也不是没有局限性。在下一节中,我们将探讨在考虑深度集成时可能需要注意的事项。

6.3.4 深度集成的实际局限性

从研究环境到大规模生产环境中应用集成模型时,一些实际局限性变得显而易见。我们知道,理论上,随着我们增加更多的集成成员,集成模型的预测性能和不确定性估计会有所提升。然而,增加更多集成成员是有代价的,因为集成模型的内存占用和推理成本会随着集成成员数量的增加而线性增长。这可能使得在生产环境中部署集成模型成为一个高成本的选择。对于我们添加到集成中的每一个神经网络,我们都需要存储一组额外的网络权重,这会显著增加内存需求。同样,对于每个网络,我们还需要在推理过程中进行额外的前向传递。尽管不同网络的推理可以并行进行,因此推理时间的影响可以得到缓解,但这种方法仍然需要比单一模型更多的计算资源。由于更多的计算资源往往意味着更高的成本,使用集成模型与单一模型之间的决策需要在更好的性能和不确定性估计的好处与成本增加之间进行权衡。

最近的研究尝试解决或减轻这些实际限制。例如,在一种叫做 BatchEnsembles([?])的方法中,所有集成成员共享一个基础权重矩阵。每个集成成员的最终权重矩阵是通过将该共享权重矩阵与一个唯一的秩一矩阵按元素相乘得到的,这个秩一矩阵对每个集成成员都是唯一的。这减少了每增加一个集成成员需要存储的参数数量,从而减小了内存占用。BatchEnsembles 的计算成本也得到了降低,因为它们可以利用向量化,并且所有集成成员的输出可以在一次前向传递中计算出来。在另一种方法中,称为多输入/多输出处理(MIMO;[?]),单个网络被鼓励学习多个独立的子网络。在训练过程中,多个输入与多个相应标注的输出一起传递。例如,网络会被呈现三张图片:一张狗的、一张猫的和一张鸡的。相应的输出标签也会传递,网络需要学习在第一个输出节点上预测“狗”,在第二个输出节点上预测“猫”,在第三个输出节点上预测“鸡”。在推理过程中,一张单独的图片会被重复三次,MIMO 集成会产生三个不同的预测(每个输出节点一个)。因此,MIMO 方法的内存占用和计算成本几乎与单一神经网络相当,同时仍能提供集成方法的所有优势。

6.4 探索贝叶斯最后一层方法在神经网络增强中的应用

通过第五章、《贝叶斯深度学习的原则方法》和第六章、《使用标准工具箱进行贝叶斯深度学习》,我们探索了多种用于深度神经网络(DNN)的贝叶斯推理方法。这些方法在每一层中都引入了某种形式的不确定性信息,无论是通过显式的概率方法,还是通过基于集成或丢弃法的近似。这些方法有其独特的优势。它们一致的贝叶斯(或者更准确地说,近似贝叶斯)机制意味着它们是一致的:相同的原理在每一层都得到应用,无论是在网络架构还是更新规则方面。这使得从理论角度解释它们变得更容易,因为我们知道任何理论上的保证都适用于每一层。除此之外,这还意味着我们能够在每一层访问不确定性:我们可以像在标准深度学习模型中利用嵌入一样,利用这些网络中的嵌入,并且我们将能够同时访问这些嵌入的不确定性。

然而,这些网络也有一些缺点。正如我们所看到的,像 PBP 和 BBB 这样的算法具有更复杂的机制,这使得它们更难应用于更复杂的神经网络架构。本章前面讨论的内容表明,我们可以通过使用 MC dropout 或深度集成来绕过这些问题,但它们会增加我们的计算和/或内存开销。此时,贝叶斯最后一层BLL)方法(参见 6.4)便派上用场。这类方法既能让我们灵活地使用任何神经网络架构,同时比 MC dropout 或深度集成方法在计算和内存上更为高效。

图片

图 6.4: Vanilla NN 与 BLL 网络的比较

正如你可能已经猜到的,BLL 方法背后的基本原理是仅在最后一层估计不确定性。但是你可能没有猜到的是,为什么这会成为可能。深度学习的成功归因于神经网络的非线性特性:连续的非线性变换使其能够学习高维数据的丰富低维表示。然而,这种非线性使得模型不确定性估计变得困难。线性模型的模型不确定性估计有现成的封闭形式解,但不幸的是,对于我们高度非线性的 DNN 来说,情况并非如此。那么,我们能做什么呢?

幸运的是,DNN 学到的表示也可以作为更简单线性模型的输入。通过这种方式,我们让 DNN 来承担繁重的工作:将高维输入空间压缩为特定任务的低维表示。因此,神经网络中的倒数第二层要处理起来容易得多;毕竟,在大多数情况下,我们的输出仅仅是该层的某种线性变换。这意味着我们可以将线性模型应用于该层,这也意味着我们可以应用封闭形式解来进行模型不确定性估计。

我们也可以利用其他最后一层方法;最近的研究表明,当仅在最后一层应用时,MC dropout 也很有效。尽管这仍然需要多次前向传播,但这些前向传播只需在单一层中完成,因此在计算上更加高效,尤其是对于较大的模型。

6.4.1 贝叶斯推理的最后一层方法

Jasper Snoek 等人在他们 2015 年的论文《可扩展 贝叶斯优化使用深度神经网络》中提出的方法,引入了使用事后贝叶斯线性回归器来获得 DNN 模型不确定性的概念。该方法被设计为一种实现类似高斯过程的高质量不确定性估计的方式,并且具有更好的可扩展性。

该方法首先涉及在一些数据X和目标y上训练一个神经网络(NN)。这个训练阶段训练一个线性输出层,z[i],结果是一个生成点估计的网络(这在标准的深度神经网络中是典型的)。然后,我们将倒数第二层(或最后一层隐藏层)z[i−1]作为我们的基础函数集。从这里开始,只需要将最后一层替换为贝叶斯线性回归器。现在,我们的网络将生成预测的均值和方差,而不是点估计。关于该方法和自适应基础回归的更多细节,请参阅 Jasper Snoek 等人的论文,以及 Christopher Bishop 的模式识别与机器学习

现在,让我们看看如何通过代码实现这一过程。

步骤 1:创建和训练我们的基础模型

首先,我们设置并训练我们的网络:


from tensorflow.keras import Model, Sequential, layers, optimizers, metrics, losses 
import tensorflow as tf 
import tensorflow_probability as tfp 
from sklearn.datasets import load_boston 
from sklearn.model_selection import train_test_split 
from sklearn.preprocessing import StandardScaler 
from sklearn.metrics import mean_squared_error 
import pandas as pd 
import numpy as np 

seed = 213 
np.random.seed(seed) 
tf.random.set_seed(seed) 
dtype = tf.float32 

boston = load_boston() 
data = boston.data 
targets = boston.target 

X_train, X_test, y_train, y_test = train_test_split(data, targets, test_size=0.2) 

# Scale our inputs 
scaler = StandardScaler() 
X_train = scaler.fit_transform(X_train) 
X_test = scaler.transform(X_test) 

model = Sequential() 
model.add(layers.Dense(20, input_dim=13, activation='relu', name='layer_1')) 
model.add(layers.Dense(8, activation='relu', name='layer_2')) 
model.add(layers.Dense(1, activation='relu', name='layer_3')) 

model.compile(optimizer=optimizers.Adam(), 
loss=losses.MeanSquaredError(), 
metrics=[metrics.RootMeanSquaredError()],) 

num_epochs = 200 
model.fit(X_train, y_train, epochs=num_epochs) 
mse, rmse = model.evaluate(X_test, y_test)
步骤 2:使用神经网络层作为基础函数

现在我们已经有了基础网络,我们只需要访问倒数第二层,这样我们就可以将其作为基础函数传递给我们的贝叶斯回归器。这可以通过使用 TensorFlow 的高级 API 轻松完成,例如:


basis_func = Model(inputs=self.model.input, 
                           outputs=self.model.get_layer('layer_2').output)

这将构建一个模型,允许我们通过简单地调用其predict方法来获得第二个隐藏层的输出:


layer_2_output = basis_func.predict(X_test)

这就是我们为传递给贝叶斯线性回归器准备基础函数所需要做的一切。

步骤 3:为贝叶斯线性回归准备我们的变量

对于贝叶斯回归器,我们假设我们的输出,y[i] ∈ y,根据与输入x[i] ∈ X的线性关系条件地服从正态分布:

yi = 𝒩 (α + x⊺iβ, σ²)

这里,α是我们的偏置项,β是我们的模型系数,σ²是与我们的预测相关的方差。我们还将对这些参数做出一些先验假设,即:

α ≈ 𝒩 (0,1) β ≈ 𝒩 (0,1) σ² ≈ |𝒩 (0,1)|

请注意,公式 6.6 表示的是高斯分布的半正态分布。为了将贝叶斯回归器包装成易于(且实用地)与我们的 Keras 模型集成的形式,我们将创建一个BayesianLastLayer类。这个类将使用 TensorFlow Probability 库,使我们能够实现贝叶斯回归器所需的概率分布和采样函数。让我们逐步了解我们类的各个组件:


class BayesianLastLayer(): 

def __init__(self, 
model, 
basis_layer, 
n_samples=1e4, 
n_burnin=5e3, 
step_size=1e-4, 
n_leapfrog=10, 
adaptive=False): 
# Setting up our model 
self.model = model 
self.basis_layer = basis_layer 
self.initialize_basis_function() 
# HMC Settings 
# number of hmc samples 
self.n_samples = int(n_samples) 
# number of burn-in steps 
self.n_burnin = int(n_burnin) 
# HMC step size 
self.step_size = step_size 
# HMC leapfrog steps 
self.n_leapfrog = n_leapfrog 
# whether to be adaptive or not 
        self.adaptive = adaptive

如我们所见,我们的类在实例化时至少需要两个参数:model,即我们的 Keras 模型;和basis``_layer,即我们希望馈送给贝叶斯回归器的层输出。接下来的参数都是哈密顿蒙特卡罗HMC)采样的参数,我们为其定义了一些默认值。根据输入的不同,这些值可能需要调整。例如,对于更高维度的输入(例如,如果你使用的是layer``_1),你可能希望进一步减小步长并增加燃烧期步骤的数量以及总体样本数。

第 4 步:连接我们的基础函数模型

接下来,我们简单定义几个函数,用于创建我们的基础函数模型并获取其输出:


def initialize_basis_function(self): 
self.basis_func = Model(inputs=self.model.input, 
outputs=self.model.get_layer(self.basis_layer).output) 

def get_basis(self, X): 
        return self.basis_func.predict(X)
第 5 步:创建适配贝叶斯线性回归参数的方法

现在事情变得有些复杂。我们需要定义fit()方法,它将使用 HMC 采样来找到我们的模型参数αβσ²。我们将在这里提供代码做了什么的概述,但关于采样的更多(实践)信息,我们推荐读者参考 Osvaldo Martin 的《Python 贝叶斯分析》。

首先,我们使用方程 4.3-4.5 中描述的先验定义一个联合分布。得益于 TensorFlow Probability 的distributions模块,这非常简单:


def fit(self, X, y): 
X = tf.convert_to_tensor(self.get_basis(X), dtype=dtype) 
y = tf.convert_to_tensor(y, dtype=dtype) 
y = tf.reshape(y, (-11)) 
D = X.shape[1] 

# Define our joint distribution 
distribution = tfp.distributions.JointDistributionNamedAutoBatched( 
dict( 
sigma=tfp.distributions.HalfNormal(scale=tf.ones([1])), 
alpha=tfp.distributions.Normal( 
loc=tf.zeros([1]), 
scale=tf.ones([1]), 
), 
beta=tfp.distributions.Normal( 
loc=tf.zeros([D,1]), 
scale=tf.ones([D,1]), 
), 
y=lambda beta, alpha, sigma: 
tfp.distributions.Normal( 
loc=tf.linalg.matmul(X, beta) + alpha, 
scale=sigma 
) 
) 
) 
. . .

然后,我们使用 TensorFlow Probability 的HamiltonianMonteCarlo采样器类来设置我们的采样器。为此,我们需要定义目标对数概率函数。distributions模块使得这一过程相当简单,但我们仍然需要定义一个函数,将我们的模型参数传递给分布对象的log``_prob()方法(第 28 行)。然后我们将其传递给hmc``_kernel的实例化:


. . . 
# Define the log probability function 
def target_log_prob_fn(beta, alpha, sigma): 
return distribution.log_prob(beta=beta, alpha=alpha, sigma=sigma, y=y) 

# Define the HMC kernel we'll be using for sampling 
hmc_kernel  = tfp.mcmc.HamiltonianMonteCarlo( 
target_log_prob_fn=target_log_prob_fn, 
step_size=self.step_size, 
num_leapfrog_steps=self.n_leapfrog 
) 

# We can use adaptive HMC to automatically adjust the kernel step size 
if self.adaptive: 
adaptive_hmc = tfp.mcmc.SimpleStepSizeAdaptation( 
inner_kernel = hmc_kernel, 
num_adaptation_steps=int(self.n_burnin * 0.8) 
) 
. . .

现在一切已经设置好,我们准备运行采样器了。为此,我们调用mcmc.sample``_chain()函数,传入我们的 HMC 参数、模型参数的初始状态和我们的 HMC 采样器。然后我们运行采样,它会返回states,其中包含我们的参数样本,以及kernel``_results,其中包含一些关于采样过程的信息。我们关心的信息是关于接受样本的比例。如果采样器运行成功,我们将有一个较高比例的接受样本(表示接受率很高)。如果采样器没有成功,接受率会很低(甚至可能是 0%!),这时我们可能需要调整采样器的参数。我们会将这个信息打印到控制台,以便随时监控接受率(我们将对sample``_chain()的调用封装在run``_chain()函数中,这样它可以扩展为多链采样):


. . . 
# If we define a function, we can extend this to multiple chains. 
@tf.function 
def run_chain(): 
states, kernel_results = tfp.mcmc.sample_chain( 
num_results=self.n_samples, 
num_burnin_steps=self.n_burnin, 
current_state=[ 
tf.zeros((X.shape[1],1), name='init_model_coeffs'), 
tf.zeros((1), name='init_bias'), 
tf.ones((1), name='init_noise'), 
], 
kernel=hmc_kernel 
) 
return states, kernel_results 

print(f'Running HMC with {self.n_samples} samples.') 
states, kernel_results = run_chain() 

print('Completed HMC sampling.') 
coeffs, bias, noise_std = states 
accepted_samples = kernel_results.is_accepted[self.n_burnin:] 
acceptance_rate = 100*np.mean(accepted_samples) 
# Print the acceptance rate - if this is low, we need to check our 
# HMC parameters 
        print('Acceptance rate: %0.1f%%' % (acceptance_rate))

一旦我们运行了采样器,我们就可以获取我们的模型参数。我们从后燃烧样本中提取它们,并将其分配给类变量,以便后续推断使用:


# Obtain the post-burnin samples 
self.model_coeffs = coeffs[self.n_burnin:,:,0] 
self.bias = bias[self.n_burnin:] 
        self.noise_std = noise_std[self.n_burnin:]
第 6 步:推断

我们需要做的最后一件事是实现一个函数,利用我们联合分布的学习到的参数来进行预测。为此,我们将定义两个函数:get``_divd``_dist(),它将根据我们的输入获取后验预测分布;以及predict(),它将调用get``_divd``_dist()并计算我们后验分布的均值(μ)和标准差(σ):


def get_divd_dist(self, X): 
predictions = (tf.matmul(X, tf.transpose(self.model_coeffs)) + 
self.bias[:,0]) 
noise = (self.noise_std[:,0] * 
tf.random.normal([self.noise_std.shape[0]])) 
return predictions + noise 

def predict(self, X): 
X = tf.convert_to_tensor(self.get_basis(X), dtype=dtype) 
divd_dist = np.zeros((X.shape[0], self.model_coeffs.shape[0])) 
X = tf.reshape(X, (-11, X.shape[1])) 
for i in range(X.shape[0]): 
divd_dist[i,:] = self.get_divd_dist(X[i,:]) 

y_divd = np.mean(divd_dist, axis=1) 
y_std = np.std(divd_dist, axis=1) 
        return y_divd, y_std

就这样!我们实现了 BLL!通过这个类,我们可以通过使用倒数第二层神经网络作为贝叶斯回归的基函数,获得强大而有原则的贝叶斯不确定性估计。使用它的方法非常简单,只需传入我们的模型并定义我们希望使用哪个层作为基函数:


bll = BayesianLastLayer(model, 'layer_2') 

bll.fit(X_train, y_train) 

y_divd, y_std = bll.predict(X_test)

虽然这是一个强大的工具,但并不总是适合当前任务。你可以自己进行实验:尝试创建一个更大的嵌入层。随着层的大小增加,你应该会看到采样器的接受率下降。一旦它变得足够大,接受率甚至可能下降到 0%。因此,我们需要修改采样器的参数:减少步长,增加样本数,并增加烧入样本数。随着嵌入维度的增加,获取一个代表性样本集来描述分布变得越来越困难。

对于一些应用来说,这不是问题,但在处理复杂的高维数据时,这可能很快成为一个问题。计算机视觉、语音处理和分子建模等领域的应用都依赖于高维嵌入。这里的一个解决方案是进一步降低这些嵌入的维度,例如通过降维。但这样做可能会对这些编码产生不可预测的影响:事实上,通过降低维度,你可能会无意中去除一些不确定性的来源,从而导致更差的质量的不确定性估计。

那么,我们能做些什么呢?幸运的是,我们可以使用一些其他的最后一层选项。接下来,我们将看看如何使用最后一层的丢弃法(dropout)来逼近这里介绍的贝叶斯线性回归方法。

6.4.2 最后一层 MC 丢弃

在本章早些时候,我们看到如何在测试时使用丢弃法获取模型预测的分布。在这里,我们将这个概念与最后一层不确定性概念结合:添加一个 MC 丢弃层,但仅作为我们添加到预训练网络中的一个单一层。

步骤 1:连接到我们的基础模型

与贝叶斯最后一层方法类似,我们首先需要从模型的倒数第二层获取输出:


basis_func = Model(inputs=model.input, 
                   outputs=model.get_layer('layer_2').output)
步骤 2:添加 MC 丢弃层

现在,我们不再实现一个贝叶斯回归器,而是简单地实例化一个新的输出层,应用丢弃法(dropout)到倒数第二层:


ll_dropout = Sequential() 
ll_dropout.add(layers.Dropout(0.25)) 
ll_dropout.add(layers.Dense(1, input_dim=8, activation='relu', name='dropout_layer'))
步骤 3:训练 MC 丢弃的最后一层

因为我们现在增加了一个新的最终层,我们需要进行额外的训练步骤,让它能够学习从倒数第二层到新输出的映射;但由于我们原始模型已经完成了大部分工作,这个训练过程既计算成本低,又运行快速:


ll_dropout.compile(optimizer=optimizers.Adam(), 
loss=losses.MeanSquaredError(), 
metrics=[metrics.RootMeanSquaredError()],) 
num_epochs = 50 
ll_dropout.fit(basis_func.predict(X_train), y_train, epochs=num_epochs)
第 4 步:获取不确定性

现在我们的最后一层已经训练完成,我们可以实现一个函数,通过对 MC dropout 层进行多次前向传递来获取预测的均值和标准差;从第 3 行开始应该和本章前面的内容相似,第 2 行只是获取我们原始模型倒数第二层的输出:


def predict_ll_dropout(X, basis_func, ll_dropout, nb_inference): 
basis_feats = basis_func(X) 
ll_divd = [ll_dropout(basis_feats, training=Truefor _ in range(nb_inference)] 
ll_divd = np.stack(ll_divd) 
    return ll_divd.mean(axis=0), ll_divd.std(axis=0)
第 5 步:推理

剩下的就是调用这个函数,获取我们的新模型输出,并附带不确定性估计:


y_divd, y_std = predict_ll_dropout(X_test, basis_func, ll_dropout, 50)

最后一层 MC dropout 迄今为止是从预训练网络中获得不确定性估计的最简单方法。与标准的 MC dropout 不同,它不需要从头开始训练模型,因此你可以将其应用于你已经训练好的网络。此外,与其他最后一层方法不同,它只需要几个简单的步骤即可实现,并且始终遵循 TensorFlow 的标准 API。

6.4.3 最后一层方法回顾

最后一层方法是当你需要从预训练网络中获取不确定性估计时的一个极好的工具。考虑到神经网络训练的高昂成本和耗时,能够在不从头开始的情况下仅因为需要预测不确定性而避免重新训练,实在是非常便利。此外,随着越来越多的机器学习从业者依赖于最先进的预训练模型,这些技术在事后结合模型不确定性是一个非常实用的方法。

但是,最后一层方法也有其缺点。与其他方法不同,我们依赖的是一个相对有限的方差来源:我们模型的倒数第二层。这限制了我们能够在模型输出上引入的随机性,因此我们有可能会面临过于自信的预测。在使用最后一层方法时请记住这一点,如果你看到过度自信的典型迹象,考虑使用更全面的方法来获取预测的不确定性。

6.5 小结

在这一章中,我们看到了如何利用熟悉的机器学习和深度学习概念开发带有预测不确定性的模型。我们还看到了,通过相对少量的修改,我们可以将不确定性估计添加到预训练模型中。这意味着我们可以超越标准神经网络的点估计方法:利用不确定性获得关于模型性能的宝贵见解,从而使我们能够开发更稳健的应用。

然而,就像第五章贝叶斯深度学习的原则方法中介绍的方法一样,所有技术都有其优点和缺点。例如,最后一层方法可能使我们能够向任何模型添加不确定性,但它们受到模型已经学习到的表示的限制。这可能导致输出的方差非常低,从而产生过于自信的模型。同样,集成方法虽然允许我们捕获网络每一层的方差,但它们需要显著的计算成本,需要我们有多个网络,而不仅仅是单个网络。

在接下来的章节中,我们将更详细地探讨优缺点,并学习如何解决这些方法的一些缺点。

第七章

贝叶斯深度学习的实际考虑

在过去的两章中,第五章贝叶斯深度学习的原则性方法第六章使用标准工具箱进行贝叶斯深度学习,我们介绍了一系列能够促进神经网络贝叶斯推断的方法。第五章贝叶斯深度学习的原则性方法 介绍了专门设计的贝叶斯神经网络近似方法,而 第六章使用标准工具箱进行贝叶斯深度学习 展示了如何使用机器学习的标准工具箱为我们的模型添加不确定性估计。这些方法家族各有其优缺点。在本章中,我们将探讨一些在实际场景中的差异,以帮助您理解如何为当前任务选择最佳方法。

我们还将探讨不同来源的不确定性,这有助于提升您对数据的理解,或根据不确定性的来源帮助您选择不同的异常路径。例如,如果一个模型因输入数据本身的噪声而产生不确定性,您可能需要将数据交给人类进行审查。然而,如果模型因未见过的输入数据而产生不确定性,将该数据添加到模型中可能会有所帮助,这样模型就能减少对这类数据的不确定性。贝叶斯深度学习技术能够帮助您区分这些不确定性的来源。以下部分将详细介绍这些内容:

  • 平衡不确定性质量和计算考虑

  • BDL 与不确定性来源

7.1 技术要求

要完成本章的实际任务,您需要一个 Python 3.8 环境,安装有 SciPy 和 scikit-learn 堆栈,并且还需要安装以下额外的 Python 包:

  • TensorFlow 2.0

  • TensorFlow 概率

本书的所有代码都可以在书籍的 GitHub 仓库中找到:github.com/PacktPublishing/Enhancing-Deep-Learning-with-Bayesian-Inference

7.2 平衡不确定性质量和计算考虑

虽然贝叶斯方法有许多优点,但在内存和计算开销方面也有需要考虑的权衡。这些因素在选择适用于实际应用的最佳方法时起着至关重要的作用。

在本节中,我们将考察不同方法在性能和不确定性质量方面的权衡,并学习如何使用 TensorFlow 的性能分析工具来衡量不同模型的计算成本。

7.2.1 设置实验环境

为了评估不同模型的性能,我们需要一些不同的数据集。其一是加利福尼亚住房数据集,scikit-learn 已经方便地提供了这个数据集。我们将使用的其他数据集是常见于不确定性模型比较论文中的:葡萄酒质量数据集和混凝土抗压强度数据集。让我们来看看这些数据集的详细信息:

  • 加利福尼亚住房:这个数据集包含了从 1990 年加利福尼亚人口普查中得出的不同地区的多个特征。因变量是房屋价值,以每个住宅区的中位房价表示。在较早的论文中,您会看到使用波士顿住房数据集;由于波士顿住房数据集存在伦理问题,现在更倾向于使用加利福尼亚住房数据集。

  • 葡萄酒质量:葡萄酒质量数据集包含与不同葡萄酒的化学成分相关的特征。我们要预测的值是葡萄酒的主观质量。

  • 混凝土抗压强度:混凝土抗压强度数据集的特征描述了用于混合混凝土的成分,每个数据点代表不同的混凝土配方。因变量是混凝土的抗压强度。

以下实验将使用本书 GitHub 仓库中的代码( github.com/PacktPublishing/Bayesian-Deep-Learning),我们在前几章中已经见过不同形式的代码示例。示例假定我们从这个仓库内部运行代码。

导入我们的依赖库

像往常一样,我们将首先导入我们的依赖库:


import tensorflow as tf 
import numpy as np 
import matplotlib.pyplot as plt 
import tensorflow_probability as tfp 
from sklearn.metrics import accuracy_score, mean_squared_error 
from sklearn.datasets import fetch_california_housing, load_diabetes 
from sklearn.model_selection import train_test_split 
import seaborn as sns 
import pandas as pd 
import os 

from bayes_by_backprop import BBBRegressor 
from pbp import PBP 
from mc_dropout import MCDropout 
from ensemble import Ensemble 
from bdl_ablation_data import load_wine_quality, load_concrete 
from bdl_metrics import likelihood

在这里,我们可以看到我们正在使用仓库中定义的多个模型类。虽然这些类支持不同架构的模型,但它们将使用默认结构,该结构在 constants.py 中定义。该结构包含一个 64 个单元的密集连接隐藏层,以及一个密集连接的输出层。BBB 和 PBP 等价物将使用并在各自的类中定义为其默认架构。

准备数据和模型

现在我们需要准备数据和模型以运行实验。首先,我们将设置一个字典,便于我们遍历并访问不同数据集中的数据:


datasets = { 
"california_housing": fetch_california_housing(return_X_y=True, as_frame=True), 
"diabetes": load_diabetes(return_X_y=True, as_frame=True), 
"wine_quality": load_wine_quality(), 
"concrete": load_concrete(), 
}

接下来,我们将创建另一个字典,以便我们可以遍历不同的 BDL 模型:


models = { 
"BBB": BBBRegressor, 
"PBP": PBP, 
"MCDropout": MCDropout, 
"Ensemble": Ensemble, 
}

最后,我们将创建一个字典来保存我们的结果:


results = { 
"LL": [], 
"MSE": [], 
"Method": [], 
"Dataset": [], 
}

在这里,我们看到我们将记录两个结果:对数似然和均方误差。我们使用这些指标是因为我们在处理回归问题,但对于分类问题,您可以选择使用 F 分数或准确度替代均方误差,使用预期校准误差替代(或与)对数似然。我们还将在 Method 字段中存储模型类型,在 Dataset 字段中存储数据集。

运行我们的实验

现在我们准备开始运行实验了。然而,我们不仅对模型性能感兴趣,还对我们各种模型的计算考虑因素感兴趣。因此,我们将在接下来的代码中看到对tf.profiler的调用。首先,我们将设置一些参数:


# Parameters 
epochs = 10 
batch_size = 16 
logdir_base = "profiling"

在这里,我们设置每个模型的训练周期数以及每个模型将使用的批次大小。我们还设置了logdir_base,即所有分析日志的存储位置。

现在我们准备好插入实验代码了。我们将首先遍历数据集:


for dataset_key in datasets.keys(): 
X, y = datasets[dataset_key] 
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33) 
    ...

在这里,我们看到对于每个数据集,我们将数据拆分,使用2 3的数据进行训练,使用1 3的数据进行测试。

接下来,我们遍历模型:


... 
for model_key in models.keys(): 
logdir = os.path.join(logdir_base, model_key + "_train") 
os.makedirs(logdir, exist_ok=True) 
tf.profiler.experimental.start(logdir) 
    ...

对于每个模型,我们实例化一个新的日志目录来记录训练信息。然后我们实例化模型并运行model.fit()


... 
model = models[model_key]() 
        model.fit(X_train, y_train, batch_size=batch_size, n_epochs=epochs)

一旦模型拟合完成,我们停止分析器,并创建一个新目录来记录预测信息,之后我们再次启动分析器:


... 
tf.profiler.experimental.stop() 
logdir = os.path.join(logdir_base, model_key + "_predict") 
os.makedirs(logdir, exist_ok=True) 
tf.profiler.experimental.start(logdir) 
        ...

在分析器运行的情况下,我们进行预测,然后再次停止分析器。手头有了预测后,我们可以计算均方误差和对数似然,并将其存储到results字典中。最后,我们在每次实验结束后运行tf.keras.backend.clear_session()来清理 TensorFlow 图:


... 
y_divd, y_var = model.predict(X_test) 

tf.profiler.experimental.stop() 

y_divd = y_divd.reshape(-1) 
y_var = y_var.reshape(-1) 

mse = mean_squared_error(y_test, y_divd) 
ll = likelihood(y_test, y_divd, y_var) 
results["MSE"].append(mse) 
results["LL"].append(ll) 
results["Method"].append(model_key) 
results["Dataset"].append(dataset_key) 
tf.keras.backend.clear_session() 
...

一旦我们获得了所有模型和所有数据集的结果,我们将结果字典转换为 pandas DataFrame:


... 
results = pd.DataFrame(results)

现在我们准备好分析数据了!

7.2.2 分析模型性能

利用从实验中获得的数据,我们可以绘制图表,查看哪些模型在不同的数据集上表现最佳。为此,我们将使用以下绘图代码:


results['NLL'] = -1*results['LL'] 

i = 1 
for dataset in datasets.keys(): 
for metric in ["NLL""MSE"]: 
df_plot = results[(results['Dataset']==dataset)] 
df_plot = groupedvalues = df_plot.groupby('Method').sum().reset_index() 
plt.subplot(3,2,i) 
ax = sns.barplot(data=df_plot, x="Method", y=metric) 
for index, row in groupedvalues.iterrows(): 
if metric == "NLL": 
ax.text(row.name, 0round(row.NLL, 2), 
color='white', ha='center') 
else: 
ax.text(row.name, 0round(row.MSE, 2), 
color='white', ha='center') 
plt.title(dataset) 
if metric == "NLL" and dataset == "california_housing": 
plt.ylim(0100) 
i+=1 
fig = plt.gcf() 
fig.set_size_inches(108) 
plt.tight_layout()

请注意,最初我们将'NLL'字段添加到 pandas DataFrame 中。这为我们提供了负对数似然。这使得查看图表时不那么混乱,因为对于均方误差和负对数似然,较低的值是更好的。

代码遍历数据集和度量,借助 Seaborn 绘图库生成一些漂亮的条形图。此外,我们还使用ax.text()调用将度量值叠加到条形图上,以便清晰地看到数值。

还要注意,对于加利福尼亚住房数据,我们将负对数似然中的y值限制在 100。这是因为,在这个数据集中,我们的负对数似然值极其高,使得它与其他值一起显示时变得困难。因此,我们叠加了度量值,以便在某些值超过图表限制时,能够更容易地进行比较。

PIC

图 7.1:LL 和 MSE 实验结果的条形图

值得注意的是,为了公平比较,我们在所有模型中使用了等效的架构,使用了相同的批次大小,并进行了相同次数的训练周期。

如我们所见,并没有一种单一的最佳方法:每种模型根据数据的不同表现不同,而且具有较低均方误差的模型并不保证也会有较低的负对数似然分数。一般来说,MC dropout 表现出最差的均方误差分数;然而,它也产生了在我们实验中观察到的最佳负对数似然分数,对于葡萄酒质量数据集,它达到了 2.9 的负对数似然。这是因为,尽管它在误差方面通常表现较差,但它的不确定性非常高。因此,由于在其错误的区域表现出更大的不确定性,它会产生更有利的负对数似然分数。如果我们将误差与不确定性绘制出来,就能看到这一点:

PIC

图 7.2:误差与不确定性估计的散点图

7.2中,我们可以看到左侧图中是 BBB、PBP 和集成方法的结果,而右侧图则是 MC dropout 的结果。原因在于,MC dropout 的不确定性估计比其他方法高出两个数量级,因此它们无法在同一坐标轴上清晰地表示。这些极高的不确定性也是其相对较低负对数似然分数的原因。这是 MC dropout 的一个相当令人惊讶的例子,因为它通常是过于自信的,而在这种情况下,它显然是不自信的

尽管 MC dropout 的低自信可能会导致更好的似然分数,但这些度量需要在具体背景下进行考虑;我们通常希望在似然与误差之间取得良好的平衡。因此,在葡萄酒质量数据集的情况下,PBP 可能是最佳选择,因为它具有最低的误差,同时也有合理的似然;它的负对数似然并不低到让人怀疑,但又足够低,可以知道其不确定性估计将会是合理一致和有原则的。

对于其他数据集,选择会更直接一些:对于加利福尼亚住房数据集,BBB 显然是最优选择,而在混凝土抗压强度数据集的情况下,PBP 再次被证明是最为理智的选择。需要注意的是,这些网络并未针对这些数据集进行专门优化:这只是一个说明性示例。

关键在于,最终决定将取决于具体应用以及强健的不确定性估计有多重要。例如,在安全关键的场景中,你会希望选择具有最强健不确定性估计的方法,因此你可能会偏向于选择低自信而非较低的误差,因为你想确保只有在对模型结果非常有信心时才会使用该模型。在这些情况下,你可能会选择一种不自信但高概率(低负概率)的方法,比如在葡萄酒质量数据集上的 MC dropout。

在其他情况下,也许不需要考虑不确定性,这时你可能会选择一个标准的神经网络。但在大多数关键任务或安全关键型应用中,你会希望找到一个平衡点,利用模型不确定性估计提供的附加信息,同时保持较低的错误率。然而,实际上,在开发机器学习系统时,性能指标并不是唯一需要考虑的因素。我们还关心实际的应用影响。在下一部分,我们将看看这些模型的计算要求如何相互比较。

7.2.3 贝叶斯深度学习模型的计算考虑

对于机器学习的每一个现实世界应用,除了性能之外,还有其他考虑因素:我们还需要理解计算基础设施的实际限制。它们通常由几个因素决定,但现有的基础设施和成本往往反复出现。

现有基础设施通常很重要,因为除非是一个全新的项目,否则需要解决如何将机器学习模型集成的问题,这意味着要么找到现有的计算资源,要么请求额外的硬件或软件资源。成本是一个显著的因素也不足为奇:每个项目都有预算,机器学习解决方案的开销需要与其带来的优势相平衡。预算往往会决定哪些机器学习解决方案是可行的,这取决于训练、部署和推理时所需计算资源的成本。

为了深入了解这些方法在计算要求方面的比较,我们将查看我们实验代码中包含的 TensorFlow 性能分析器的输出。为此,我们只需从命令行运行 TensorBoard,并指向我们感兴趣的特定模型的日志目录:


tensorboard --logdir profiling/BBB_train/

这将启动一个 TensorBoard 实例(通常在 http://localhost:6006/)。将 URL 复制到浏览器中,你将看到 TensorBoard 的图形用户界面(GUI)。TensorBoard 为你提供了一套工具,用于理解 TensorFlow 模型的性能,从执行时间到不同进程的内存分配。你可以通过屏幕左上角的 Tools 选择框浏览可用的工具:

PIC

图 7.3:TensorBoard 图形用户界面的工具选择框

要更详细地了解发生了什么,请查看追踪查看器(Trace Viewer):

PIC

图 7.4:TensorBoard 图形用户界面中的追踪查看器

在这里,我们可以获得一个整体视图,展示运行模型函数所需的时间,以及一个详细的视图,展示哪些进程在后台运行,以及每个进程的运行时间。我们甚至可以通过双击一个模块查看其统计信息。例如,我们可以双击 train 模块:

图片

图 7.5:TensorBoard 图形界面中的追踪查看器,突出显示训练模块

这将在屏幕底部显示一些信息。这使我们能够密切检查此过程的运行时间。如果我们点击 持续时间,则会得到此过程的详细运行时统计数据:

图片

图 7.6:在 TensorBoard 追踪查看器中检查模块的统计数据

在这里,我们看到该过程运行了 10 次(每个 epoch 运行一次),平均持续时间为 144,527,053 纳秒(ns)。让我们使用混凝土抗压强度数据集的性能分析结果,并通过 TensorBoard 收集运行时和内存分配信息。如果我们为每个模型的训练过程都进行此操作,就能得到以下信息:

模型训练的性能分析数据

|


|


|


|


|

模型峰值内存使用量(MiB)持续时间(ms)

|


|


|


|


|

BBB0.094270
PBP0.25310754
MC Dropout0.1262198
集成模型0.21520630

|


|


|


|


|

图 7.7:混凝土抗压强度数据集模型训练的性能分析数据表

在这里,我们看到 MC dropout 是该数据集中训练速度最快的模型,训练时间仅为 BBB 的一半。我们还看到,尽管集成模型只包含 5 个模型,但其训练时间远远是最久的,几乎是 MC dropout 的 10 倍。就内存使用而言,我们看到集成模型的表现较差,而 PBP 是所有模型中内存最消耗的,BBB 则具有最低的峰值内存使用量。

但并非仅仅训练才是关键。我们还需要考虑推理的计算成本。查看我们模型预测函数的性能分析数据,我们看到如下信息:

模型预测的性能分析数据

|


|


|


|


|

模型峰值内存使用量(MiB)持续时间(ms)

|


|


|


|


|

BBB0.116849
PBP1.27176
MC Dropout0.54823
集成模型0.38917

|


|


|


|


|

图 7.8:混凝土抗压强度数据集模型预测的性能分析数据表

有趣的是,在模型推理速度方面,集成方法领先,而在预测时的峰值内存使用上也位居第二。相比之下,PBP 的峰值内存使用最高,而 BBB 推理所需的时间最长。

这里有多种因素导致了我们看到的结果。首先,需要注意的是,这些模型都没有针对计算性能进行优化。例如,我们可以通过并行训练所有集成成员来显著缩短集成方法的训练时长,而在这里我们并没有这么做。类似地,由于 PBP 在实现中使用了大量的高级代码(不同于其他方法,这些方法都是基于优化过的 TensorFlow 或 TensorFlow Probability 代码),因此其性能受到影响。

最关键的是,我们需要确保在选择适合的模型时,既要考虑计算影响,也要考虑典型的性能指标。那么,考虑到这一点,我们如何选择合适的模型呢?

7.2.4 选择合适的模型

有了我们的性能指标和分析信息,我们拥有了选择适合任务的模型所需的所有数据。但模型选择并不容易;正如我们在这里看到的,所有模型都有其优缺点。

如果我们从性能指标开始,那么我们看到 BBB 的均方误差最低,但它的负对数似然值却非常高。所以,仅从性能指标来看,最佳选择是 PBP:它的负对数似然得分最低,且均方误差也远远好于 MC dropout 的误差,这使得 PBP 在综合考虑下成为最佳选择。

然而,如果我们查看 7.77.8中的计算影响,我们会发现 PBP 在内存使用和执行时间方面都是最差的选择。在这里,综合来看,最好的选择是 MC dropout:它的预测时间仅比集成方法稍慢,而且训练时长最短。

归根结底,这完全取决于应用:也许推理不需要实时运行,那么我们可以选择 PBP 实现。或者,也许推理时间和低误差是我们关注的重点,在这种情况下,集成方法是一个不错的选择。正如我们在这里看到的,指标和计算开销需要在具体情况下加以考虑,正如任何一类机器学习模型一样,并没有一个适用于所有应用的最佳选择。一切都取决于选择合适的工具来完成工作。

在本节中,我们介绍了用于全面理解模型性能的工具,并演示了在选择模型时考虑多种因素的重要性。从根本上讲,性能分析和分析配置文件对帮助我们做出正确的实际选择与帮助我们发现进一步改进的机会同样重要。我们可能没有时间进一步优化代码,因此可能需要务实地选择我们手头上最优化的计算方法。或者,业务需求可能决定我们需要选择性能最好的模型,这可能值得花时间优化代码并减少某种方法的计算开销。在下一节中,我们将进一步探讨使用 BDL 方法时的另一个重要实际考虑因素,学习如何使用这些方法更好地理解不确定性的来源。

7.3 BDL 与不确定性来源

在这个案例研究中,我们将探讨如何在回归问题中建模 aleatoric 和 epistemic 不确定性,目标是预测一个连续的结果变量。我们将使用一个现实世界的钻石数据集,该数据集包含了超过 50,000 颗钻石的物理属性以及它们的价格。特别地,我们将关注钻石的重量(以 克拉 测量)和钻石价格之间的关系。

步骤 1:设置环境

为了设置环境,我们导入了几个包。我们导入 tensorflowtensorflow_probability 用于构建和训练传统的和概率神经网络,导入 tensorflow_datasets 用于导入钻石数据集,导入 numpy 用于对数值数组进行计算和操作(如计算均值),导入 pandas 用于处理 DataFrame,导入 matplotlib 用于绘图:


import matplotlib.pyplot as plt 
import numpy as np 
import pandas as pd 
import tensorflow as tf 
import tensorflow_probability as tfp 
import tensorflow_datasets as tfds

首先,我们使用 tensorflow_datasets 提供的 load 函数加载钻石数据集。我们将数据集加载为一个 pandas DataFrame,这对于准备训练和推理数据非常方便。


ds = tfds.load('diamonds', split='train') 
df = tfds.as_dataframe(ds)

数据集包含了钻石的许多不同属性,但在这里我们将重点关注克拉和价格,通过选择 DataFrame 中相应的列:


df = df[["features/carat""price"]]

然后,我们将数据集分为训练集和测试集。我们使用 80% 的数据进行训练,20% 的数据进行测试:


train_df = df.sample(frac=0.8, random_state=0) 
test_df = df.drop(train_df.index)

为了进一步处理,我们将训练和测试 DataFrame 转换为 NumPy 数组:


carat = np.array(train_df['features/carat']) 
price = np.array(train_df['price']) 
carat_test = np.array(test_df['features/carat']) 
price_test = np.array(test_df['price'])

我们还将训练样本的数量保存到一个变量中,因为我们在模型训练过程中需要它:


NUM_TRAIN_SAMPLES = carat.shape[0]

最后,我们定义了一个绘图函数。这个函数将在接下来的案例研究中派上用场。它允许我们绘制数据点以及拟合模型的预测值和标准差:


def plot_scatter(x_data, y_data, x_hat=None, y_hats=None, plot_std=False): 
# Plot the data as scatter points 
plt.scatter(x_data, y_data, color="k", label="Data") 
# Plot x and y values predicted by the model, if provided 
if x_hat is not None and y_hats is not None: 
if not isinstance(y_hats, list): 
y_hats = [y_hats] 
for ind, y_hat in enumerate(y_hats): 
plt.plot( 
x_hat, 
y_hat.mean(), 
color="#e41a1c", 
label="prediction" if ind == 0 else None, 
) 
# Plot standard deviation, if requested 
if plot_std: 
for ind, y_hat in enumerate(y_hats): 
plt.plot( 
x_hat, 
y_hat.mean() + 2 * y_hat.stddev(), 
color="#e41a1c", 
linestyle="dashed", 
label="prediction + stddev" if ind == 0 else None, 
) 
plt.plot( 
x_hat, 
y_hat.mean() - 2 * y_hat.stddev(), 
color="#e41a1c", 
linestyle="dashed", 
label="prediction - stddev" if ind == 0 else None, 
) 
# Plot x- and y-axis labels as well as a legend 
plt.xlabel("carat") 
plt.ylabel("price") 
    plt.legend()

使用这个函数,我们可以通过运行以下代码来首次查看训练数据:


plot_scatter(carat, price)

训练数据分布如 图 7.9 所示。我们观察到,克拉和钻石价格之间的关系是非线性的,随着克拉数的增加,价格的增长速度也更快。

PIC

图 7.9:钻石克拉数与价格之间的关系

步骤 2:拟合一个不带不确定性的模型

完成设置后,我们可以开始为数据拟合回归模型。首先,我们拟合一个神经网络模型,但不对预测中的不确定性进行量化。这使我们能够建立一个基准,并引入一些对本案例研究中所有模型都非常有用的工具(以函数的形式)。

建议对神经网络模型的输入特征进行归一化。在本示例中,这意味着对钻石的克拉重量进行归一化。归一化输入特征将使模型在训练过程中收敛得更快。tensorflow.keras 提供了一个方便的归一化函数,可以帮助我们实现这一点。我们可以按如下方式使用它:


normalizer = tf.keras.layers.Normalization(input_shape=(1,), axis=None) 
normalizer.adapt(carat)

我们还需要一个损失函数,理想情况下,该损失函数可以用于本案例研究中的所有模型。回归模型可以表示为 P(y|x,w),即给定输入 x 和模型参数 w 的标签 y 的概率分布。我们可以通过最小化负对数似然损失 −logP(y|x) 来拟合此类模型。用 Python 代码表示时,可以编写一个函数,该函数将真实结果值 y_true 和预测结果分布 y_divd 作为输入,并返回在预测结果分布下的结果值的负对数似然值,该方法由 tensorflow_probability 中的 distributions 模块提供的 log_prob() 实现:


def negloglik(y_true, y_divd): 
    return -y_divd.log_prob(y_true)

有了这些工具,我们可以构建第一个模型。我们使用刚才定义的归一化函数来归一化模型的输入。然后,我们在其上堆叠两个全连接层。第一个全连接层包含 32 个节点,这使我们能够建模数据中观察到的非线性关系。第二个全连接层包含一个节点,用于将模型的预测压缩为一个单一的值。重要的是,我们不使用第二个全连接层产生的输出作为模型的最终输出。相反,我们使用该全连接层的输出作为正态分布均值的参数化,这意味着我们正在使用正态分布来建模真实标签。我们还将正态分布的方差设为 1。参数化分布的均值并将方差设为固定值,意味着我们正在建模数据的总体趋势,但尚未量化模型预测中的不确定性:


model = tf.keras.Sequential( 
[ 
normalizer, 
tf.keras.layers.Dense(32, activation="relu"), 
tf.keras.layers.Dense(1), 
tfp.layers.DistributionLambda( 
lambda t: tfp.distributions.Normal(loc=t, scale=1) 
), 
] 
)

正如我们在之前的案例研究中看到的,训练模型时,我们使用compile()fit()函数。在模型编译时,我们指定了Adam优化器和之前定义的损失函数。对于fit函数,我们指定了希望在克拉和价格数据上训练模型 100 个周期:


# Compile 
model.compile(optimizer=tf.optimizers.Adam(learning_rate=0.01), loss=negloglik) 
# Fit 
model.fit(carat, price, epochs=100, verbose=0)

然后,我们可以通过我们的plot_scatter()函数获得模型在保留测试数据上的预测,并可视化所有结果:


# Define range for model input 
carat_hat = tf.linspace(carat_test.min(), carat_test.max(), 100) 
# Obtain model's price predictions on test data 
price_hat = model(carat_hat) 
# Plot test data and model predictions 
plot_scatter(carat_test, price_test, carat_hat, price_hat)

这将生成以下图表:

PIC

图 7.10:没有不确定性的钻石测试数据预测

我们可以在 7.10中看到,模型捕捉到了数据的非线性趋势。随着钻石重量的增加,模型预测的价格也随着重量的增加而迅速上升。

然而,数据中还有另一个显而易见的趋势是模型未能捕捉到的。我们可以观察到,随着重量的增加,价格的变异性越来越大。在低重量时,我们仅观察到拟合线周围少量的散布,但在较高重量时,散布增大。我们可以将这种变异性视为问题的固有特性。也就是说,即使我们有更多的训练数据,我们仍然无法完美地预测价格,尤其是在高重量时。这种类型的变异性就是随机不确定性,我们在第四章中首次遇到过,将在下一小节中仔细探讨。

第三步:拟合带有随机不确定性的模型

我们可以通过预测正态分布的标准差来考虑模型中的随机不确定性,除了预测其均值之外。和之前一样,我们构建了一个带有标准化层和两个全连接层的模型。然而,这次第二个全连接层将输出两个值,而不是一个。第一个输出值将再次用于参数化正态分布的均值。但第二个输出值将参数化正态分布的方差,从而使我们能够量化模型预测中的随机不确定性:


model_aleatoric = tf.keras.Sequential( 
[ 
normalizer, 
tf.keras.layers.Dense(32, activation="relu"), 
tf.keras.layers.Dense(2), 
tfp.layers.DistributionLambda( 
lambda t: tfp.distributions.Normal( 
loc=t[..., :1], scale=1e-3 + tf.math.softplus(0.05 * t[..., 1:]) 
) 
), 
] 
)

我们再次在重量和价格数据上编译并拟合模型:


# Compile 
model_aleatoric.compile( 
optimizer=tf.optimizers.Adam(learning_rate=0.05), loss=negloglik 
) 
# Fit 
model_aleatoric.fit(carat, price, epochs=100, verbose=0)

现在,我们可以获得并可视化测试数据的预测结果。请注意,这次我们传递了plot_std=True,以便同时绘制预测输出分布的标准差:


carat_hat = tf.linspace(carat_test.min(), carat_test.max(), 100) 
price_hat = model_aleatoric(carat_hat) 
plot_scatter( 
carat_test, price_test, carat_hat, price_hat, plot_std=True, 
)

我们现在已经训练了一个模型,表示数据固有的变异性。 7.11中的虚线误差条显示了价格作为重量函数的预测变异性。我们可以观察到,模型确实对重量超过 1 克拉时的价格预测不太确定,这反映了我们在较高重量范围内观察到的数据的较大散布。

PIC

图 7.11:包含随机不确定性的钻石测试数据预测

第四步:拟合带有认知不确定性的模型

除了偶然性不确定性,我们还需要处理认知不确定性——这种不确定性来源于我们的模型,而不是数据本身。回顾一下图 7.11,例如,实线代表我们模型预测的均值,它似乎能合理地捕捉数据的趋势。然而,由于训练数据有限,我们无法百分之百确定我们找到了数据分布的真实均值。也许真实均值实际上略大于或略小于我们估计的值。在这一部分,我们将研究如何建模这种不确定性,我们还将看到,通过观察更多的数据,认知不确定性是可以减少的。

模型认知不确定性的技巧再次是,将我们神经网络中的权重表示为分布,而不是点估计。我们可以通过将之前使用的密集层替换为tensorflow_probability中的 DenseVariational 层来实现这一点。在底层,这将实现我们在第五章中首次学习到的 BBB 方法,贝叶斯深度学习的原则方法。简而言之,使用 BBB 时,我们通过变分学习原理来学习网络权重的后验分布。为了实现这一点,我们需要定义先验和后验分布函数。

请注意,在第五章中展示的 BBB 代码示例,贝叶斯深度学习的原则方法使用了预定义的tensorflow_probability模块来处理 2D 卷积和密集层,并应用重参数化技巧,这样我们就隐式地定义了先验和后验函数。在本示例中,我们将自己定义密集层的先验和后验函数。

我们从定义密集层权重的先验开始(包括内核和偏置项)。先验分布描述了我们在观察到任何数据之前,对权重的猜测不确定性。它可以通过一个多元正态分布来定义,其中均值是可训练的,方差固定为 1:


def prior(kernel_size, bias_size=0, dtype=None): 
n = kernel_size + bias_size 
return tf.keras.Sequential( 
[ 
tfp.layers.VariableLayer(n, dtype=dtype), 
tfp.layers.DistributionLambda( 
lambda t: tfp.distributions.Independent( 
tfp.distributions.Normal(loc=t, scale=1), 
reinterpreted_batch_ndims=1, 
) 
), 
] 
    )

我们还定义了变分后验。变分后验是我们观察到训练数据后,密集层权重分布的近似值。我们再次使用多元正态分布:


def posterior(kernel_size, bias_size=0, dtype=None): 
n = kernel_size + bias_size 
c = np.log(np.expm1(1.0)) 
return tf.keras.Sequential( 
[ 
tfp.layers.VariableLayer(2 * n, dtype=dtype), 
tfp.layers.DistributionLambda( 
lambda t: tfp.distributions.Independent( 
tfp.distributions.Normal( 
loc=t[..., :n], 
scale=1e-5 + tf.nn.softplus(c + t[..., n:]), 
), 
reinterpreted_batch_ndims=1, 
) 
), 
] 
    )

有了这些先验和后验函数,我们就能定义我们的模型。和之前一样,我们使用归一化层对输入进行归一化,然后堆叠两个密集层。但这一次,密集层将把它们的参数表示为分布,而不是点估计。我们通过将tensorflow_probability中的 DenseVariational 层与我们的先验和后验函数结合使用来实现这一点。最终的输出层是一个正态分布,其方差设置为 1,均值由前一个 DenseVariational 层的输出来参数化:


def build_epistemic_model(): 
model = tf.keras.Sequential( 
[ 
normalizer, 
tfp.layers.DenseVariational( 
32, 
make_prior_fn=prior, 
make_posterior_fn=posterior, 
kl_weight=1 / NUM_TRAIN_SAMPLES, 
activation="relu", 
), 
tfp.layers.DenseVariational( 
1, 
make_prior_fn=prior, 
make_posterior_fn=posterior, 
kl_weight=1 / NUM_TRAIN_SAMPLES, 
), 
tfp.layers.DistributionLambda( 
lambda t: tfp.distributions.Normal(loc=t, scale=1) 
), 
] 
) 
  return model

为了观察可用训练数据量对表征性不确定性估计的影响,我们首先在一个较小的数据子集上拟合模型,然后再用所有可用的训练数据拟合模型。我们取训练数据集中的前 500 个样本:


carat_subset = carat[:500] 
price_subset = price[:500]

我们按之前的方式构建、编译并拟合模型:


# Build 
model_epistemic = build_epistemic_model() 
# Compile 
model_epistemic.compile( 
optimizer=tf.optimizers.Adam(learning_rate=0.01), loss=negloglik 
) 
# Fit 
model_epistemic.fit(carat_subset, price_subset, epochs=100, verbose=0)

接着我们在测试数据上获得并绘制预测结果。请注意,这里我们从后验分布中抽取了 10 次样本,这使我们能够观察每次样本迭代时预测均值的变化。如果预测均值变化很大,说明表征性不确定性估计较大;如果均值变化非常小,则表示表征性不确定性较小:


carat_hat = tf.linspace(carat_test.min(), carat_test.max(), 100) 
price_hats = [model_epistemic(carat_hat) for _ in range(10)] 
plot_scatter( 
carat_test, price_test, carat_hat, price_hats, 
)

7.12中,我们可以观察到,预测均值在 10 个不同的样本中有所变化。有趣的是,变化(因此表征性不确定性)在较低权重时似乎较低,而随着权重的增加,变化逐渐增大。

PIC

图 7.12:在钻石测试数据上,具有高表征性不确定性的预测

为了验证通过更多数据训练可以减少表征性不确定性,我们在完整的训练数据集上训练我们的模型:


# Build 
model_epistemic_full = build_epistemic_model() 
# Compile 
model_epistemic_full.compile( 
optimizer=tf.optimizers.Adam(learning_rate=0.01), loss=negloglik 
) 
# Fit 
model_epistemic_full.fit(carat, price, epochs=100, verbose=0)

然后绘制完整数据模型的预测结果:


carat_hat = tf.linspace(carat_test.min(), carat_test.max(), 100) 
price_hats = [model_epistemic_full(carat_hat) for _ in range(10)] 
plot_scatter( 
carat_test, price_test, carat_hat, price_hats, 
)

正如预期的那样,我们在 7.13中看到,表征性不确定性现在要低得多,且预测均值在 10 个样本中变化很小(以至于很难看到 10 条红色曲线之间的任何差异):

PIC

图 7.13:在钻石测试数据上,具有低表征性不确定性的预测

第 5 步:拟合具有偶然性和表征性不确定性的模型

作为最后的练习,我们可以将所有构建模块结合起来,构建一个同时建模偶然性和表征性不确定性的神经网络。我们可以通过使用两个 DenseVariational 层(这将使我们能够建模表征性不确定性),然后在其上堆叠一个正态分布层,该层的均值和方差由第二个 DenseVariational 层的输出参数化(这将使我们能够建模偶然性不确定性):


# Build model. 
model_epistemic_aleatoric = tf.keras.Sequential( 
[ 
normalizer, 
tfp.layers.DenseVariational( 
32, 
make_prior_fn=prior, 
make_posterior_fn=posterior, 
kl_weight=1 / NUM_TRAIN_SAMPLES, 
activation="relu", 
), 
tfp.layers.DenseVariational( 
1 + 1, 
make_prior_fn=prior, 
make_posterior_fn=posterior, 
kl_weight=1 / NUM_TRAIN_SAMPLES, 
), 
tfp.layers.DistributionLambda( 
lambda t: tfp.distributions.Normal( 
loc=t[..., :1], scale=1e-3 + tf.math.softplus(0.05 * t[..., 1:]) 
) 
), 
] 
)

我们可以按照之前的相同流程构建和训练该模型。然后我们可以再次在测试数据上进行 10 次推理,这会产生如 7.14所示的预测结果。每一次推理都会产生一个预测均值和标准差。标准差代表每次推理的偶然性不确定性,而在不同推理之间观察到的变化则代表表征性不确定性。

PIC

图 7.14:在钻石测试数据上,具有表征性和偶然性不确定性的预测

7.3.1 不确定性的来源:图像分类案例研究

在前面的案例研究中,我们看到如何在回归问题中建模随机不确定性和认知不确定性。在本节中,我们将再次查看 MNIST 数字数据集,以建模随机不确定性和认知不确定性。我们还将探讨随机不确定性如何难以减少,而认知不确定性则可以通过更多数据来减少。

让我们从数据开始。为了让我们的例子更具启发性,我们不仅使用标准的 MNIST 数据集,还使用一个名为 AmbiguousMNIST 的 MNIST 变体。这个数据集包含生成的图像,显然是固有模糊的。让我们首先加载数据,然后探索 AmbiguousMNIST 数据集。我们从必要的导入开始:


import tensorflow as tf 
import tensorflow_probability as tfp 
import matplotlib.pyplot as plt 
import numpy as np 
from sklearn.utils import shuffle 
from sklearn.metrics import roc_auc_score 
import ddu_dirty_mnist 
from scipy.stats import entropy 
tfd = tfp.distributions

我们可以通过ddu_dirty_mnist库下载 AmbiguousMNIST 数据集:


dirty_mnist_train = ddu_dirty_mnist.DirtyMNIST( 
".", 
train=True, 
download=True, 
normalize=False, 
noise_stddev=0 
) 

# regular MNIST 
train_imgs = dirty_mnist_train.datasets[0].data.numpy() 
train_labels = dirty_mnist_train.datasets[0].targets.numpy() 
# AmbiguousMNIST 
train_imgs_amb = dirty_mnist_train.datasets[1].data.numpy() 
train_labels_amb = dirty_mnist_train.datasets[1].targets.numpy()

然后我们将图像和标签进行拼接和混洗,以便在训练期间两种数据集能有良好的混合。我们还固定数据集的形状,以使其适应我们模型的设置:


train_imgs, train_labels = shuffle( 
np.concatenate([train_imgs, train_imgs_amb]), 
np.concatenate([train_labels, train_labels_amb]) 
) 
train_imgs = np.expand_dims(train_imgs[:, 0, :, :], -1) 
train_labels = tf.one_hot(train_labels, 10)

图 7.157.15)给出了 AmbiguousMNIST 图像的示例。我们可以看到图像处于两类之间:一个 4 也可以被解释为 9,一个 0 可以被解释为 6,反之亦然。这意味着我们的模型很可能难以正确分类这些图像中的至少一部分,因为它们本质上是噪声。

PIC

图 7.15:来自 AmbiguousMNIST 数据集的图像示例

现在我们已经有了训练数据集,让我们也加载我们的测试数据集。我们将仅使用标准的 MNIST 测试数据集:


(test_imgs, test_labels) = tf.keras.datasets.mnist.load_data()[1] 
test_imgs = test_imgs / 255\. 
test_imgs = np.expand_dims(test_imgs, -1) 
test_labels = tf.one_hot(test_labels, 10)

现在我们可以开始定义我们的模型。在这个例子中,我们使用一个小型的贝叶斯神经网络,带有Flipout层。这些层在前向传递过程中从内核和偏置的后验分布中采样,从而为我们的模型增加随机性。我们可以在以后需要计算不确定性值时使用它:


kl_divergence_function = lambda q, p, _: tfd.kl_divergence(q, p) / tf.cast( 
60000, dtype=tf.float32 
) 

model = tf.keras.models.Sequential( 
[ 
*block(5), 
*block(16), 
*block(120, max_pool=False), 
tf.keras.layers.Flatten(), 
tfp.layers.DenseFlipout( 
84, 
kernel_divergence_fn=kl_divergence_function, 
activation=tf.nn.relu, 
), 
tfp.layers.DenseFlipout( 
10, 
kernel_divergence_fn=kl_divergence_function, 
activation=tf.nn.softmax, 
), 
] 
)

我们定义一个块如下:


def block(filters: int, max_pool: bool = True): 
conv_layer =  tfp.layers.Convolution2DFlipout( 
filters, 
kernel_size=5, 
padding="same", 
kernel_divergence_fn=kl_divergence_function, 
activation=tf.nn.relu) 
if not max_pool: 
return (conv_layer,) 
max_pool = tf.keras.layers.MaxPooling2D( 
pool_size=[22], strides=[22], padding="same" 
) 
    return conv_layer, max_pool

我们编译我们的模型并开始训练:


model.compile( 
tf.keras.optimizers.Adam(), 
loss="categorical_crossentropy", 
metrics=["accuracy"], 
experimental_run_tf_function=False, 
) 
model.fit( 
x=train_imgs, 
y=train_labels, 
validation_data=(test_imgs, test_labels), 
epochs=50 
)

现在我们有兴趣通过认知不确定性和随机不确定性来分离图像。认知不确定性应该将我们的分布内图像与分布外图像区分开,因为这些图像可以被视为未知的未知:我们的模型以前从未见过这些图像,因此应该对它们分配较高的认知不确定性(或知识不确定性)。尽管我们的模型是在 AmbiguousMNIST 数据集上训练的,但在测试时,当它看到这个数据集中的图像时,它仍然应该具有较高的随机不确定性:用这些图像进行训练并不会减少随机不确定性(或数据不确定性),因为这些图像本质上是模糊的。

我们使用 FashionMNIST 数据集作为分布外数据集。我们使用 AmbiguousMNIST 测试集作为我们用于测试的模糊数据集:


(_, _), (ood_imgs, _) = tf.keras.datasets.fashion_mnist.load_data() 
ood_imgs = np.expand_dims(ood_imgs / 255., -1) 

ambiguous_mnist_test = ddu_dirty_mnist.AmbiguousMNIST( 
".", 
train=False, 
download=True, 
normalize=False, 
noise_stddev=0 
) 
amb_imgs = ambiguous_mnist_test.data.numpy().reshape(6000028281)[:10000] 
amb_labels = tf.one_hot(ambiguous_mnist_test.targets.numpy(), 10).numpy()

让我们利用模型的随机性来生成多样的模型预测。我们对测试图像进行 50 次迭代:


divds_id = [] 
divds_ood = [] 
divds_amb = [] 
for _ in range(50): 
divds_id.append(model(test_imgs)) 
divds_ood.append(model(ood_imgs)) 
divds_amb.append(model(amb_imgs)) 
# format data such that we have it in shape n_images, n_predictions, n_classes 
divds_id = np.moveaxis(np.stack(divds_id), 01) 
divds_ood = np.moveaxis(np.stack(divds_ood), 01) 
divds_amb = np.moveaxis(np.stack(divds_amb), 01)

然后我们可以定义一些函数来计算不同类型的不确定性:


def total_uncertainty(divds: np.ndarray) -*>* np.ndarray: 
return entropy(np.mean(divds, axis=1), axis=-1) 

def data_uncertainty(divds: np.ndarray) -*>* np.ndarray: 
return np.mean(entropy(divds, axis=2), axis=-1) 

def knowledge_uncertainty(divds: np.ndarray) -*>* np.ndarray: 
    return total_uncertainty(divds) - data_uncertainty(divds)

最后,我们可以看到我们的模型在区分分布内、模糊和分布外图像方面的表现。让我们根据不同的不确定性方法绘制不同分布的直方图:


labels = ["In-distribution""Out-of-distribution""Ambiguous"] 
uncertainty_functions = [total_uncertainty, data_uncertainty, knowledge_uncertainty] 
fig, axes = plt.subplots(13, figsize=(20,5)) 
for ax, uncertainty in zip(axes, uncertainty_functions): 
for scores, label in zip([divds_id, divds_ood, divds_amb], labels): 
ax.hist(uncertainty(scores), bins=20, label=label, alpha=.8) 
ax.title.set_text(uncertainty.__name__.replace("_"" ").capitalize()) 
ax.legend(loc="upper right") 
plt.legend() 
plt.savefig("uncertainty_types.png", dpi=300) 
plt.show()

这会生成以下输出:

PIC

图 7.16:MNIST 上的不同类型不确定性

我们能观察到什么?

  • 总体不确定性和数据不确定性在区分分布内数据、分布外数据和模糊数据方面相对有效。

  • 然而,数据不确定性和总体不确定性无法区分模糊数据与分布外数据。要做到这一点,我们需要知识不确定性。我们可以看到,知识不确定性能够清晰地区分模糊数据和分布外数据。

  • 我们也对模糊样本进行了训练,但这并没有将模糊测试样本的不确定性降低到与原始分布内数据类似的水平。这表明数据不确定性不能轻易降低。无论模型看到多少模糊数据,数据本身就是模糊的。

我们可以通过查看不同分布组合的 AUROC 来验证这些观察结果。

我们可以首先计算 AUROC 分数,以评估我们模型区分分布内和模糊图像与分布外图像的能力:


def auc_id_and_amb_vs_ood(uncertainty): 
scores_id = uncertainty(divds_id) 
scores_ood = uncertainty(divds_ood) 
scores_amb = uncertainty(divds_amb) 
scores_id = np.concatenate([scores_id, scores_amb]) 
labels = np.concatenate([np.zeros_like(scores_id), np.ones_like(scores_ood)]) 
return roc_auc_score(labels, np.concatenate([scores_id, scores_ood])) 

print(f"{auc_id_and_amb_vs_ood(total_uncertainty)=:.2%}") 
print(f"{auc_id_and_amb_vs_ood(knowledge_uncertainty)=:.2%}") 
print(f"{auc_id_and_amb_vs_ood(data_uncertainty)=:.2%}") 
# output: 
# auc_id_and_amb_vs_ood(total_uncertainty)=91.81% 
# auc_id_and_amb_vs_ood(knowledge_uncertainty)=98.87% 
# auc_id_and_amb_vs_ood(data_uncertainty)=84.29%

我们在直方图中看到的结果得到了确认:知识不确定性在区分分布内和模糊数据与分布外数据方面远胜于另外两种不确定性类型。


def auc_id_vs_amb(uncertainty): 
scores_id, scores_amb = uncertainty(divds_id), uncertainty(divds_amb) 
labels = np.concatenate([np.zeros_like(scores_id), np.ones_like(scores_amb)]) 
return roc_auc_score(labels, np.concatenate([scores_id, scores_amb])) 

print(f"{auc_id_vs_amb(total_uncertainty)=:.2%}") 
print(f"{auc_id_vs_amb(knowledge_uncertainty)=:.2%}") 
print(f"{auc_id_vs_amb(data_uncertainty)=:.2%}") 
# output: 
# auc_id_vs_amb(total_uncertainty)=94.71% 
# auc_id_vs_amb(knowledge_uncertainty)=87.06% 
# auc_id_vs_amb(data_uncertainty)=95.21%

我们可以看到,整体不确定性和数据不确定性能够相当好地区分分布内和模糊数据。使用数据不确定性相较于使用总体不确定性有所改善。然而,知识不确定性无法区分分布内数据和模糊数据。

7.4 总结

在这一章中,我们探讨了使用贝叶斯深度学习的一些实际考虑因素:探索模型性能的权衡,并了解如何使用贝叶斯神经网络方法更好地理解不同不确定性来源对数据的影响。

在下一章中,我们将通过各种案例研究进一步探讨应用贝叶斯深度学习,展示这些方法在多种实际环境中的优势。

7.5 进一步阅读

  • 《概率反向传播的实践考虑》,Matt Benatan :在这篇论文中,作者探讨了如何最大限度地利用 PBP,展示了不同的早停方法如何改善训练,探讨了迷你批次的权衡,等等。

  • 《使用 TensorFlow 和 TensorFlow 概率建模阿莱托里克和认知不确定性》,Alexander Molak:在这个 Jupyter 笔记本中,作者展示了如何在回归玩具数据上建模阿莱托里克不确定性和认知不确定性。

  • 神经网络中的权重不确定性,Charles Blundell :在本文中,作者介绍了 BBB,我们在回归案例研究中使用了它,它是贝叶斯深度学习文献中的关键部分。

  • 深度确定性不确定性:一个简单的基准,Jishnu Mukhoti :在这项工作中,作者描述了与不同类型的不确定性相关的几项实验,并介绍了我们在最后一个案例研究中使用的AmbiguousMNIST数据集。

  • 深度学习中的不确定性估计及其在语音语言评估中的应用,Andrey Malinin:本论文通过直观的示例突出不同来源的不确定性。

第八章

应用贝叶斯深度学习

本章将引导你了解贝叶斯深度学习(BDL)的多种应用。这些应用包括 BDL 在标准分类任务中的使用,以及展示如何在异常数据检测、数据选择和强化学习等更复杂的任务中使用 BDL。

我们将在接下来的章节中讨论这些主题:

  • 检测异常数据

  • 提高对数据集漂移的鲁棒性

  • 使用基于不确定性的数据显示选择,保持模型的新鲜度

  • 使用不确定性估计进行更智能的强化学习

  • 对抗性输入的易感性

8.1 技术要求

本书的所有代码都可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Enhancing-Deep-Learning-with-Bayesian-Inference

8.2 检测异常数据

典型的神经网络在处理异常数据时表现不佳。我们在第三章深度学习基础中看到,猫狗分类器将一张降落伞的图像错误地分类为狗,并且置信度超过 99%。在本节中,我们将探讨如何解决神经网络这一弱点。我们将进行以下操作:

  • 通过扰动MNIST数据集中的一个数字,直观地探索这个问题

  • 解释文献中通常如何报告异常数据检测的性能

  • 回顾我们在本章中讨论的几种标准实用贝叶斯深度学习(BDL)方法在异常数据检测中的表现

  • 探索更多专门用于异常数据检测的实用方法

8.2.1 探索异常数据检测的问题

为了更好地帮助你理解异常数据检测的效果,我们将从一个视觉示例开始。以下是我们将要做的事情:

  • 我们将在MNIST数字数据集上训练一个标准网络

  • 然后,我们将扰动一个数字,并逐渐使其变得更加异常

  • 我们将报告标准模型和 MC dropout 的置信度得分

通过这个视觉示例,我们可以看到简单的贝叶斯方法如何在异常数据检测上优于标准的深度学习模型。我们首先在MNIST数据集上训练一个简单的模型。

图片

图 8.1:MNIST 数据集的类别:零到九的 28x28 像素数字图像

我们使用TensorFlow来训练模型,使用numpy让我们的图像更具异常性,使用Matplotlib来可视化数据。


import tensorflow as tf 
from tensorflow.keras import datasets, layers, models 
import numpy as np 
import matplotlib.pyplot as plt

MNIST数据集可以在 TensorFlow 中找到,所以我们可以直接加载它:


(train_images, train_labels), ( 
test_images, 
test_labels, 
) = datasets.mnist.load_data() 
train_images, test_images = train_images / 255.0, test_images / 255.0

MNIST是一个简单的数据集,因此使用简单的模型可以让我们在测试中达到超过 99%的准确率。我们使用一个标准的 CNN,包含三层卷积层:


def get_model(): 
model = models.Sequential() 
model.add( 
layers.Conv2D(32, (33), activation="relu", input_shape=(28281)) 
) 
model.add(layers.MaxPooling2D((22))) 
model.add(layers.Conv2D(64, (33), activation="relu")) 
model.add(layers.MaxPooling2D((22))) 
model.add(layers.Conv2D(64, (33), activation="relu")) 
model.add(layers.Flatten()) 
model.add(layers.Dense(64, activation="relu")) 
model.add(layers.Dense(10)) 
return model 

model = get_model()

然后,我们可以编译并训练我们的模型。经过 5 个 epochs 后,我们的验证准确率超过 99%。


def fit_model(model): 
model.compile( 
optimizer="adam", 
loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True), 
metrics=["accuracy"], 
) 

model.fit( 
train_images, 
train_labels, 
epochs=5, 
validation_data=(test_images, test_labels), 
) 
return model 

model = fit_model(model)

现在,让我们看看这个模型如何处理分布外数据。假设我们部署这个模型来识别数字,但用户有时无法写下完整的数字。当用户没有写下完整的数字时会发生什么?我们可以通过逐渐移除数字中的信息,观察模型如何处理这些扰动输入,来回答这个问题。我们可以这样定义移除signal的函数:


def remove_signal(img: np.ndarray, num_lines: int) -*>* np.ndarray: 
img = img.copy() 
img[:num_lines] = 0 
   return img

然后我们对图像进行扰动:


imgs = [] 
for i in range(28): 
img_perturbed = remove_signal(img, i) 
if np.array_equal(img, img_perturbed): 
continue 
imgs.append(img_perturbed) 
if img_perturbed.sum() == 0: 
     break

我们只有在将某一行设为 0 确实改变了原始图像时,才将扰动后的图像添加到我们的图像列表中(if np.array_equal(img, img_perturbed))),并且一旦图像完全变黑,即仅包含值为 0 的像素,我们就停止。我们对这些图像进行推理:


softmax_predictions = tf.nn.softmax(model(np.expand_dims(imgs, -1)), axis=1)

然后,我们可以绘制所有图像及其预测标签和置信度分数:


plt.figure(figsize=(1010)) 
bbox_dict = dict( 
fill=True, facecolor="white", alpha=0.5, edgecolor="white", linewidth=0 
) 
for i in range(len(imgs)): 
plt.subplot(55, i + 1) 
plt.xticks([]) 
plt.yticks([]) 
plt.grid(False) 
plt.imshow(imgs[i], cmap="gray") 
prediction = softmax_predictions[i].numpy().max() 
label = np.argmax(softmax_predictions[i]) 
plt.xlabel(f"{label} - {prediction:.2%}") 
plt.text(03f" {i+1}", bbox=bbox_dict) 
plt.show()

这生成了如下的图:

PIC

图 8.2:标准神经网络对于逐渐偏离分布的图像所预测的标签及相应的 softmax 分数

我们可以在 8.2中看到,最初,我们的模型非常自信地将图像分类为2。值得注意的是,即使在这种分类显得不合理时,这种自信依然存在。例如,模型仍然以 97.83%的置信度将图像 14 分类为2。此外,模型还预测完全水平的线条是1,置信度为 92.32%,正如我们在图像 17 中所见。这看起来我们的模型在预测时过于自信。

让我们看看一个稍有不同的模型会如何对这些图像做出预测。我们现在将使用 MC Dropout 作为我们的模型。通过采样,我们应该能够提高模型的不确定性,相较于标准的神经网络。我们先定义我们的模型:


def get_dropout_model(): 
model = models.Sequential() 
model.add( 
layers.Conv2D(32, (33), activation="relu", input_shape=(28281)) 
) 
model.add(layers.Dropout(0.2)) 
model.add(layers.MaxPooling2D((22))) 
model.add(layers.Conv2D(64, (33), activation="relu")) 
model.add(layers.MaxPooling2D((22))) 
model.add(layers.Dropout(0.5)) 
model.add(layers.Conv2D(64, (33), activation="relu")) 
model.add(layers.Dropout(0.5)) 
model.add(layers.Flatten()) 
model.add(layers.Dense(64, activation="relu")) 
model.add(layers.Dropout(0.5)) 
model.add(layers.Dense(10)) 
    return model

那么我们来实例化它:


dropout_model = get_dropout_model() 
dropout_model = fit_model(dropout_model)

使用 dropout 的模型将实现与原始模型类似的准确性。现在,我们使用 dropout 进行推理,并绘制 MC Dropout 的平均置信度分数:


Predictions = np.array( 
[ 
tf.nn.softmax(dropout_model(imgs_np, training=True), axis=1) 
for _ in range(100) 
] 
) 
Predictions_mean = np.mean(predictions, axis=0) 
plot_predictions(predictions_mean)

这再次生成了一个图,显示了预测标签及其相关的置信度分数:

PIC

图 8.3:MC Dropout 网络对于逐渐偏离分布的图像所预测的标签及相应的 softmax 分数

我们可以在 8.3中看到,模型的自信度平均来说较低。当我们从图像中移除行时,模型的置信度大幅下降。这是期望的行为:当模型不知道输入时,它应该表现出不确定性。然而,我们也能看到模型并不完美:

  • 对于那些看起来并不像2的图像,模型仍然保持较高的置信度。

  • 当我们从图像中删除一行时,模型的置信度变化很大。例如,模型的置信度在图像 14 和图像 15 之间从 61.72%跃升至 37.20%。

  • 模型似乎更有信心将没有任何白色像素的图像 20 分类为1

在这种情况下,MC Dropout 是一个朝着正确方向迈出的步骤,但它并没有完美地处理分布外数据。

8.2.2 系统地评估 OOD 检测性能

上述示例表明,MC dropout 通常会给出分布外图像较低的置信度分数。但我们仅评估了 20 张图像,且变化有限——我们只是删除了一行。这一变化使得图像更加分布外,但前一部分展示的所有图像与MNIST的训练分布相比,还是相对相似的,如果拿它和自然物体图像比较。例如,飞机、汽车或鸟类的图像肯定比带有几行黑色的MNIST图像更具分布外特征。因此,似乎合理的是,如果我们想评估模型的 OOD 检测性能,我们应该在更加分布外的图像上进行测试,也就是说,来自完全不同数据集的图像。这正是文献中通常用于评估分布外检测性能的方法。具体步骤如下:

  1. 我们在内部分布(ID)图像上训练模型。

  2. 我们选取一个或多个完全不同的 OOD 数据集,并将这些数据喂给我们的模型。

  3. 我们现在将模型在 ID 和 OOD 测试数据集上的预测视为一个二进制问题,并为每个图像计算一个单一的得分。

    • 在评估 softmax 分数的情况下,这意味着我们为每个 ID 和 OOD 图像取模型的最大 softmax 分数。
  4. 使用这些得分,我们可以计算二进制指标,如接收者操作特征曲线下面积(AUROC)。

模型在这些二进制指标上的表现越好,模型的 OOD 检测性能就越好。

8.2.3 无需重新训练的简单分布外检测

尽管 MC dropout 可以有效检测出分布外数据,但它在推理时存在一个主要缺点:我们需要进行五次,甚至一百次推理,而不是仅仅一次。对于某些其他贝叶斯深度学习方法也可以说类似:虽然它们有理论依据,但并不总是获得良好 OOD 检测性能的最实际方法。主要的缺点是,它们通常需要重新训练网络,如果数据量很大,这可能会非常昂贵。这就是为什么有一整套不显式依赖贝叶斯理论的 OOD 检测方法,但能提供良好、简单,甚至是优秀的基线。这些方法通常不需要任何重新训练,可以直接在标准神经网络上应用。文献中经常使用的两种方法值得一提:

  • ODIN:使用预处理和缩放进行 OOD 检测

  • 马哈拉诺比斯:使用中间特征进行 OOD 检测

ODIN:使用预处理和缩放进行 OOD 检测

Out-of-DIstribution 检测器(ODIN)是实际应用中常用的标准分布外检测方法之一,因为它简单有效。尽管该方法在 2017 年被提出,但它仍然经常作为提出分布外检测方法的论文中的对比方法。

ODIN 包含两个关键思想:

  • 对 logit 分数进行温度缩放,然后再应用 softmax 操作,以提高 softmax 分数区分在分布内和分布外图像的能力。

  • 输入预处理 使分布内图像更符合分布内

让我们更详细地看看这两个思想。

温度缩放 ODIN 适用于分类模型。给定我们计算的 softmax 分数如下:

pi(x) = ∑--exp(fi(x))--- Nj=1 exp(fj(x))

在这里,f**i 是单个 logit 输出,f**j 是单个示例中所有类别的 logits,温度缩放意味着我们将这些 logit 输出除以常数 T

 exp(f (x)∕T) pi(x; T) = ∑N------i---------- j=1 exp (fj(x)∕T)

对于较大的 T 值,温度缩放使得 softmax 分数更接近均匀分布,从而有助于减少过于自信的预测。

我们可以在 Python 中应用温度缩放,假设有一个简单的模型输出 logits:


logits = model.predict(images) 
logits_scaled = logits / temperature 
softmax = tf.nn.softmax(logits, axis=1)

输入预处理 我们在 第三章深度学习基础 中看到,快速梯度 符号方法FGSM)使我们能够欺骗神经网络。通过稍微改变一张猫的图像,我们可以让模型以 99.41% 的置信度预测为“狗”。这里的想法是,我们可以获取损失相对于输入的梯度符号,将其乘以一个小值,并将该噪声添加到图像中——这将把我们的图像从分布内类别中移动。通过做相反的事情,即从图像中减去噪声,我们使得图像更接近分布内。ODIN 论文的作者表明,这导致分布内图像的 softmax 分数比分布外图像更高。这意味着我们增加了 OOD 和 ID softmax 分数之间的差异,从而提高了 OOD 检测性能。

˜x = x − 𝜀sign(− ∇x log Sˆy(x;T))

其中 x 是输入图像,我们从中减去扰动幅度 𝜖 乘以交叉熵损失相对于输入的梯度符号。有关该技术的 TensorFlow 实现,请参见 第三章深度学习基础

尽管输入预处理和温度缩放易于实现,ODIN 现在还需要调节两个超参数:用于缩放 logits 的温度和 𝜖(快速梯度符号法的逆)。ODIN 使用一个单独的分布外数据集来调节这些超参数(iSUN 数据集的验证集:8925 张图像)。

马氏距离:使用中间特征进行 OOD 检测

在《一种简单统一的框架用于检测分布外样本和 对抗攻击》一文中,Kimin Lee 等人提出了一种检测 OOD 输入的不同方法。该方法的核心思想是每个类别的分类器在网络的特征空间中遵循多元高斯分布。基于这一思想,我们可以定义C个类别条件高斯分布,并且具有共享的协方差 σ

P(f(x) | y = c) = 𝒩 (f(x) | μc,σ )

其中 μ[c] 是每个类别 c 的多元高斯分布的均值。这使得我们能够计算给定中间层输出的每个类别的经验均值和协方差。基于均值和协方差,我们可以计算单个测试图像与分布内数据的马氏距离。我们对与输入图像最接近的类别计算该距离:

M (x) = max − (f(x)− ^μc)⊤ ^σ− 1(f(x)− ^μc) c

对于分布内的图像,这个距离应该较小,而对于分布外的图像,这个距离应该较大。

numpy 提供了方便的函数来计算数组的均值和协方差:


mean = np.mean(features_of_class, axis=0) 
covariance = np.cov(features_of_class.T)

基于这些,我们可以按如下方式计算马氏距离:


covariance_inverse = np.linalg.pinv(covariance) 
x_minus_mu = features_of_class - mean 
mahalanobis = np.dot(x_minus_mu, covariance_inverse).dot(x_minus_mu.T) 
mahalanobis = np.sqrt(mahalanobis).diagonal()

马氏距离计算不需要任何重新训练,一旦你存储了网络某一层特征的均值和(协方差的逆矩阵),这是一项相对廉价的操作。

为了提高方法的性能,作者表明我们还可以应用 ODIN 论文中提到的输入预处理,或者计算并平均从网络多个层提取的马氏距离。

8.3 抵抗数据集偏移

我们在第三章深度学习基础》中已经遇到过数据集偏移。提醒一下,数据集偏移是机器学习中的一个常见问题,发生在模型训练阶段和模型推理阶段(例如,在测试模型或在生产环境中运行时)输入 X 和输出 Y 的联合分布 P(X,Y) 不同的情况下。协变量偏移是数据集偏移的一个特定案例,其中只有输入的分布发生变化,而条件分布 P(Y |X) 保持不变。

数据集偏移在大多数生产环境中普遍存在,因为在训练过程中很难包含所有可能的推理条件,而且大多数数据不是静态的,而是随着时间发生变化。在生产环境中,输入数据可能沿着许多不同的维度发生偏移。地理和时间数据集偏移是两种常见的偏移形式。例如,假设你已在一个地理区域(例如欧洲)获得的数据上训练了模型,然后将模型应用于另一个地理区域(例如拉丁美洲)。类似地,模型可能是在 2010 到 2020 年间的数据上训练的,然后应用于今天的生产数据。

我们将看到,在这样的数据偏移场景中,模型在新的偏移数据上的表现通常比在原始训练分布上的表现差。我们还将看到,普通神经网络通常无法指示输入数据何时偏离训练分布。最后,我们将探讨本书中介绍的各种方法如何通过不确定性估计来指示数据集偏移,以及这些方法如何增强模型的鲁棒性。以下代码示例将集中在图像分类问题上。然而,值得注意的是,这些见解通常可以推广到其他领域(如自然语言处理)和任务(如回归)。

8.3.1 测量模型对数据集偏移的响应

假设我们有一个训练数据集和一个单独的测试集,我们如何衡量模型在数据发生偏移时是否能及时反应?为了做到这一点,我们需要一个额外的测试集,其中数据已经发生偏移,以检查模型如何响应数据集偏移。一个常用的创建数据偏移测试集的方法最初由 Dan Hendrycks、Thomas Dietterich 及其他人于 2019 年提出。这个方法很简单:从你的初始测试集中取出图像,然后对其应用不同程度的图像质量损坏。Hendrycks 和 Dietterich 提出了一套包含 15 种不同类型图像质量损坏的方法,涵盖了图像噪声、模糊、天气损坏(如雾霾和雪)以及数字损坏等类型。每种损坏类型都有五个严重程度级别,从 1(轻度损坏)到 5(严重损坏)。 8.4展示了一只小猫的图像最初样子(左侧)以及在图像上施加噪声损坏后的效果,分别是严重程度为 1(中间)和 5(右侧)的情况。

PIC

图 8.4:通过在不同损坏严重程度下应用图像质量损坏来生成人工数据集偏移

所有这些图像质量损坏可以方便地使用imgaug Python 包生成。以下代码假设我们磁盘上有一个名为"kitty.png"的图像。我们使用 PIL 包加载图像。然后,我们通过损坏函数的名称指定损坏类型(例如,ShotNoise),并使用通过传递相应整数给关键字参数severity来应用损坏函数,选择严重性等级 1 或 5。


from PIL import Image 
import numpy as np 
import imgaug.augmenters.imgcorruptlike as icl 

image = np.asarray(Image.open("./kitty.png").convert("RGB")) 
corruption_function = icl.ShotNoise 
image_noise_level_01 = corruption_function(severity=1, seed=0)(image=image) 
image_noise_level_05 = corruption_function(severity=5, seed=0)(image=image)

通过这种方式生成数据偏移的优势在于,它可以应用于广泛的计算机视觉问题和数据集。应用这种方法的少数前提条件是数据由图像组成,并且在训练过程中没有使用过这些图像质量损坏(例如,用于数据增强)。此外,通过设置图像质量损坏的严重性,我们可以控制数据集偏移的程度。这使我们能够衡量模型对不同程度的数据集偏移的反应。我们可以衡量性能如何随着数据集偏移而变化,以及校准(在第二章贝叶斯推断基础中引入)如何变化。我们预计使用贝叶斯方法或扩展方法训练的模型会有更好的校准,这意味着它们能够告诉我们数据相较于训练时已经发生了偏移,因此它们对输出的信心较低。

8.3.2 使用贝叶斯方法揭示数据集偏移

在以下的代码示例中,我们将查看书中到目前为止遇到的两种 BDL 方法(基于反向传播的贝叶斯方法和深度集成),并观察它们在前面描述的人工数据集偏移下的表现。我们将它们的表现与普通的神经网络进行比较。

步骤 1:准备环境

我们通过导入一系列包来开始这个示例。这些包包括用于构建和训练神经网络的 TensorFlow 和 TensorFlow Probability;用于处理数值数组(如计算均值)的numpy;用于绘图的SeabornMatplotlibpandas;用于加载和处理图像的cv2imgaug;以及用于计算模型准确度的scikit-learn


import cv2 
import imgaug.augmenters as iaa 
import imgaug.augmenters.imgcorruptlike as icl 
import matplotlib.pyplot as plt 
import numpy as np 
import pandas as pd 
import seaborn as sns 
import tensorflow as tf 
import tensorflow_probability as tfp 
from sklearn.metrics import accuracy_score

在训练之前,我们将加载CIFAR10数据集,这是一个图像分类数据集,并指定不同类别的名称。该数据集包含 10 个不同的类别,我们将在以下代码中指定这些类别的名称,并提供 50,000 个训练图像和 10,000 个测试图像。我们还将保存训练图像的数量,这将在稍后使用重参数化技巧训练模型时用到。


cifar = tf.keras.datasets.cifar10 
(train_images, train_labels), (test_images, test_labels) = cifar.load_data() 

CLASS_NAMES = [ 
"airplane","automobile""bird""cat""deer", 
"dog""frog""horse""ship""truck" 
] 

NUM_TRAIN_EXAMPLES = train_images.shape[0]
步骤 2:定义和训练模型

在这项准备工作完成后,我们可以定义并训练我们的模型。我们首先创建两个函数来定义和构建 CNN。我们将使用这两个函数来构建普通神经网络和深度集成网络。第一个函数简单地将卷积层与最大池化层结合起来——这是一种常见的做法,我们在第三章《深度学习基础》中介绍过。


def cnn_building_block(num_filters): 
return tf.keras.Sequential( 
[ 
tf.keras.layers.Conv2D( 
filters=num_filters, kernel_size=(33), activation="relu" 
), 
tf.keras.layers.MaxPool2D(strides=2), 
] 
    )

第二个函数则依次使用多个卷积/最大池化块,并在此序列后面跟着一个最终的密集层:


def build_and_compile_model(): 
model = tf.keras.Sequential( 
[ 
tf.keras.layers.Rescaling(1.0 / 255, input_shape=(32323)), 
cnn_building_block(16), 
cnn_building_block(32), 
cnn_building_block(64), 
tf.keras.layers.MaxPool2D(strides=2), 
tf.keras.layers.Flatten(), 
tf.keras.layers.Dense(64, activation="relu"), 
tf.keras.layers.Dense(10, activation="softmax"), 
] 
) 
model.compile( 
optimizer="adam", 
loss="sparse_categorical_crossentropy", 
metrics=["accuracy"], 
) 
    return model

我们还创建了两个类似的函数,用于基于重新参数化技巧定义和构建使用 Bayes By Backprop(BBB)的网络。策略与普通神经网络相同,只不过我们现在将使用来自 TensorFlow Probability 包的卷积层和密集层,而不是 TensorFlow 包。卷积/最大池化块定义如下:


def cnn_building_block_bbb(num_filters, kl_divergence_function): 
return tf.keras.Sequential( 
[ 
tfp.layers.Convolution2DReparameterization( 
num_filters, 
kernel_size=(33), 
kernel_divergence_fn=kl_divergence_function, 
activation=tf.nn.relu, 
), 
tf.keras.layers.MaxPool2D(strides=2), 
] 
    )

最终的网络定义如下:


def build_and_compile_model_bbb(): 

kl_divergence_function = lambda q, p, _: tfp.distributions.kl_divergence( 
q, p 
) / tf.cast(NUM_TRAIN_EXAMPLES, dtype=tf.float32) 

model = tf.keras.models.Sequential( 
[ 
tf.keras.layers.Rescaling(1.0 / 255, input_shape=(32323)), 
cnn_building_block_bbb(16, kl_divergence_function), 
cnn_building_block_bbb(32, kl_divergence_function), 
cnn_building_block_bbb(64, kl_divergence_function), 
tf.keras.layers.Flatten(), 
tfp.layers.DenseReparameterization( 
64, 
kernel_divergence_fn=kl_divergence_function, 
activation=tf.nn.relu, 
), 
tfp.layers.DenseReparameterization( 
10, 
kernel_divergence_fn=kl_divergence_function, 
activation=tf.nn.softmax, 
), 
] 
) 

model.compile( 
optimizer="adam", 
loss="sparse_categorical_crossentropy", 
metrics=["accuracy"], 
experimental_run_tf_function=False, 
) 

model.build(input_shape=[None32323]) 
    return model

然后我们可以训练普通神经网络:


vanilla_model = build_and_compile_model() 
vanilla_model.fit(train_images, train_labels, epochs=10)

我们还可以训练一个五成员的集成模型:


NUM_ENSEMBLE_MEMBERS = 5 
ensemble_model = [] 
for ind in range(NUM_ENSEMBLE_MEMBERS): 
member = build_and_compile_model() 
print(f"Train model {ind:02}") 
member.fit(train_images, train_labels, epochs=10) 
    ensemble_model.append(member)

最后,我们训练 BBB 模型。注意,我们将训练 BBB 模型 15 个 epoch,而不是 10 个 epoch,因为它收敛的时间稍长。


bbb_model = build_and_compile_model_bbb() 
bbb_model.fit(train_images, train_labels, epochs=15)
步骤 3:获取预测结果

现在我们已经有了三个训练好的模型,可以使用它们对保留的测试集进行预测。为了保持计算的可控性,在这个例子中,我们将专注于测试集中的前 1000 张图像:


NUM_SUBSET = 1000 
test_images_subset = test_images[:NUM_SUBSET] 
test_labels_subset = test_labels[:NUM_SUBSET]

如果我们想要衡量数据集偏移的响应,首先需要对数据集应用人工图像损坏。为此,我们首先指定一组来自imgaug包的函数。从这些函数的名称中,我们可以推断出每个函数实现的损坏类型:例如,函数icl.GaussianNoise通过向图像应用高斯噪声来损坏图像。我们还通过函数的数量推断出损坏类型的数量,并将其保存在NUM_TYPES变量中。最后,我们将损坏级别设置为 5。


corruption_functions = [ 
icl.GaussianNoise, 
icl.ShotNoise, 
icl.ImpulseNoise, 
icl.DefocusBlur, 
icl.GlassBlur, 
icl.MotionBlur, 
icl.ZoomBlur, 
icl.Snow, 
icl.Frost, 
icl.Fog, 
icl.Brightness, 
icl.Contrast, 
icl.ElasticTransform, 
icl.Pixelate, 
icl.JpegComdivssion, 
] 
NUM_TYPES = len(corruption_functions) 
NUM_LEVELS = 5

配备了这些函数后,我们现在可以开始损坏图像了。在下一个代码块中,我们遍历不同的损坏级别和类型,并将所有损坏的图像收集到名为corrupted_images的变量中。


corrupted_images = [] 
# loop over different corruption severities 
for corruption_severity in range(1, NUM_LEVELS+1): 
corruption_type_batch = [] 
# loop over different corruption types 
for corruption_type in corruption_functions: 
corrupted_image_batch = corruption_type( 
severity=corruption_severity, seed=0 
)(images=test_images_subset) 
corruption_type_batch.append(corrupted_image_batch) 
corruption_type_batch = np.stack(corruption_type_batch, axis=0) 
corrupted_images.append(corruption_type_batch) 
corrupted_images = np.stack(corrupted_images, axis=0)

在训练完三个模型并获得损坏图像后,我们现在可以看到模型对不同级别数据集偏移的反应。我们将首先获取三个模型对损坏图像的预测结果。为了进行推理,我们需要将损坏的图像调整为模型接受的输入形状。目前,这些图像仍然存储在针对损坏类型和级别的不同轴上。我们通过重新调整corrupted_images数组来改变这一点:


corrupted_images = corrupted_images.reshape((-132323))

然后,我们可以使用普通 CNN 模型对原始图像和腐蚀图像进行推理。在推理模型预测后,我们将预测结果重塑,以便分离腐蚀类型和级别的预测:


# Get predictions on original images 
vanilla_predictions = vanilla_model.predict(test_images_subset) 
# Get predictions on corrupted images 
vanilla_predictions_on_corrupted = vanilla_model.predict(corrupted_images) 
vanilla_predictions_on_corrupted = vanilla_predictions_on_corrupted.reshape( 
(NUM_LEVELS, NUM_TYPES, NUM_SUBSET, -1) 
)

为了使用集成模型进行推理,我们首先定义一个预测函数以避免代码重复。此函数处理对集成中不同成员模型的循环,并最终通过平均将不同的预测结果结合起来:


def get_ensemble_predictions(images, num_inferences): 
ensemble_predictions = tf.stack( 
[ 
ensemble_model[ensemble_ind].predict(images) 
for ensemble_ind in range(num_inferences) 
], 
axis=0, 
) 
    return np.mean(ensemble_predictions, axis=0)

配备了这个函数后,我们可以对原始图像和腐蚀图像使用集成模型进行推理:


# Get predictions on original images 
ensemble_predictions = get_ensemble_predictions( 
test_images_subset, NUM_ENSEMBLE_MEMBERS 
) 
# Get predictions on corrupted images 
ensemble_predictions_on_corrupted = get_ensemble_predictions( 
corrupted_images, NUM_ENSEMBLE_MEMBERS 
) 
ensemble_predictions_on_corrupted = ensemble_predictions_on_corrupted.reshape( 
(NUM_LEVELS, NUM_TYPES, NUM_SUBSET, -1) 
)

就像对于集成模型一样,我们为 BBB 模型编写了一个推理函数,该函数处理不同采样循环的迭代,并收集并结合结果:


def get_bbb_predictions(images, num_inferences): 
bbb_predictions = tf.stack( 
[bbb_model.predict(images) for _ in range(num_inferences)], 
axis=0, 
) 
    return np.mean(bbb_predictions, axis=0)

然后,我们利用这个函数获取 BBB 模型在原始图像和腐蚀图像上的预测。我们从 BBB 模型中采样 20 次:


NUM_INFERENCES_BBB = 20 
# Get predictions on original images 
bbb_predictions = get_bbb_predictions( 
test_images_subset, NUM_INFERENCES_BBB 
) 
# Get predictions on corrupted images 
bbb_predictions_on_corrupted = get_bbb_predictions( 
corrupted_images, NUM_INFERENCES_BBB 
) 
bbb_predictions_on_corrupted = bbb_predictions_on_corrupted.reshape( 
(NUM_LEVELS, NUM_TYPES, NUM_SUBSET, -1) 
)

我们可以通过返回具有最大 softmax 得分的类别索引和最大 softmax 得分,分别将三个模型的预测转换为预测类别及其相关的置信度得分:


def get_classes_and_scores(model_predictions): 
model_predicted_classes = np.argmax(model_predictions, axis=-1) 
model_scores = np.max(model_predictions, axis=-1) 
    return model_predicted_classes, model_scores

然后可以应用此函数来获取我们三个模型的预测类别和置信度得分:


# Vanilla model 
vanilla_predicted_classes, vanilla_scores = get_classes_and_scores( 
vanilla_predictions 
) 
( 
vanilla_predicted_classes_on_corrupted, 
vanilla_scores_on_corrupted, 
) = get_classes_and_scores(vanilla_predictions_on_corrupted) 

# Ensemble model 
( 
ensemble_predicted_classes, 
ensemble_scores, 
) = get_classes_and_scores(ensemble_predictions) 
( 
ensemble_predicted_classes_on_corrupted, 
ensemble_scores_on_corrupted, 
) = get_classes_and_scores(ensemble_predictions_on_corrupted) 

# BBB model 
( 
bbb_predicted_classes, 
bbb_scores, 
) = get_classes_and_scores(bbb_predictions) 
( 
bbb_predicted_classes_on_corrupted, 
bbb_scores_on_corrupted, 
) = get_classes_and_scores(bbb_predictions_on_corrupted)

让我们可视化这三个模型在一张展示汽车的选定图像上预测的类别和置信度得分。为了绘图,我们首先将包含腐蚀图像的数组重塑为更方便的格式:


plot_images = corrupted_images.reshape( 
(NUM_LEVELS, NUM_TYPES, NUM_SUBSET, 32323) 
)

然后,我们绘制了列表中前三种腐蚀类型的选定汽车图像,涵盖所有五个腐蚀级别。对于每种组合,我们在图像标题中显示每个模型的预测得分,并在方括号中显示预测类别。该图如图**8.5所示。

PIC

图 8.5:一张汽车图像已经被不同的腐蚀类型(行)和级别(列,严重程度从左到右增加)腐蚀

代码继续:


# Index of the selected images 
ind_image = 9 
# Define figure 
fig, axes = plt.subplots(nrows=3, ncols=5, figsize=(1610)) 
# Loop over corruption levels 
for ind_level in range(NUM_LEVELS): 
# Loop over corruption types 
for ind_type in range(3): 
# Plot slightly upscaled image for easier inspection 
image = plot_images[ind_level, ind_type, ind_image, ...] 
image_upscaled = cv2.resize( 
image, dsize=(150150), interpolation=cv2.INTER_CUBIC 
) 
axes[ind_type, ind_level].imshow(image_upscaled) 
# Get score and class predicted by vanilla model 
vanilla_score = vanilla_scores_on_corrupted[ 
ind_level, ind_type, ind_image, ... 
] 
vanilla_prediction = vanilla_predicted_classes_on_corrupted[ 
ind_level, ind_type, ind_image, ... 
] 
# Get score and class predicted by ensemble model 
ensemble_score = ensemble_scores_on_corrupted[ 
ind_level, ind_type, ind_image, ... 
] 
ensemble_prediction = ensemble_predicted_classes_on_corrupted[ 
ind_level, ind_type, ind_image, ... 
] 
# Get score and class predicted by BBB model 
bbb_score = bbb_scores_on_corrupted[ind_level, ind_type, ind_image, ...] 
bbb_prediction = bbb_predicted_classes_on_corrupted[ 
ind_level, ind_type, ind_image, ... 
] 
# Plot prediction info in title 
title_text = ( 
f"Vanilla: {vanilla_score:.3f} "f"[{CLASS_NAMES[vanilla_prediction]}] \n"f"Ensemble: {ensemble_score:.3f} "f"[{CLASS_NAMES[ensemble_prediction]}] \n"f"BBB: {bbb_score:.3f} "f"[{CLASS_NAMES[bbb_prediction]}]" 
) 
axes[ind_type, ind_level].set_title(title_text, fontsize=14) 
# Remove axes ticks and labels 
axes[ind_type, ind_level].axis("off") 
fig.tight_layout() 
plt.show()

图**8.5只显示了单张图像的结果,因此我们不应过度解读这些结果。然而,我们已经可以观察到,两个贝叶斯方法(尤其是集成方法)的预测得分通常比普通神经网络更不极端,后者的预测得分高达 0.95。此外,我们看到,对于所有三个模型,预测得分通常随着腐蚀级别的增加而降低。这是预期的:由于图像中的汽车在腐蚀越严重时变得越难以辨认,我们希望模型的置信度也会随之降低。特别是,集成方法在增加腐蚀级别时显示出了预测得分的明显且一致的下降。

第 4 步:衡量准确性

有些模型比其他模型更能适应数据集的偏移吗?我们可以通过查看三种模型在不同损坏水平下的准确性来回答这个问题。预计所有模型在输入图像逐渐损坏时准确性会降低。然而,更鲁棒的模型在损坏变得更严重时,准确性下降应该较少。

首先,我们可以计算三种模型在原始测试图像上的准确性:


vanilla_acc = accuracy_score( 
test_labels_subset.flatten(), vanilla_predicted_classes 
) 
ensemble_acc = accuracy_score( 
test_labels_subset.flatten(), ensemble_predicted_classes 
) 
bbb_acc = accuracy_score( 
test_labels_subset.flatten(), bbb_predicted_classes 
)

我们可以将这些准确性存储在字典列表中,这将使我们更容易系统地绘制它们。我们传递相应的模型名称。对于损坏的类型级别,我们传递0,因为这些是原始图像上的准确性。


accuracies = [ 
{"model_name""vanilla""type"0"level"0"accuracy": vanilla_acc}, 
{"model_name""ensemble""type"0"level"0"accuracy": ensemble_acc}, 
{"model_name""bbb""type"0"level"0"accuracy": bbb_acc}, 
]

接下来,我们计算三种模型在不同损坏类型和损坏级别组合下的准确性。我们还将结果附加到之前开始的准确性列表中:


for ind_type in range(NUM_TYPES): 
for ind_level in range(NUM_LEVELS): 
# Calculate accuracy for vanilla model 
vanilla_acc_on_corrupted = accuracy_score( 
test_labels_subset.flatten(), 
vanilla_predicted_classes_on_corrupted[ind_level, ind_type, :], 
) 
accuracies.append( 
{ 
"model_name""vanilla", 
"type": ind_type + 1, 
"level": ind_level + 1, 
"accuracy": vanilla_acc_on_corrupted, 
} 
) 

# Calculate accuracy for ensemble model 
ensemble_acc_on_corrupted = accuracy_score( 
test_labels_subset.flatten(), 
ensemble_predicted_classes_on_corrupted[ind_level, ind_type, :], 
) 
accuracies.append( 
{ 
"model_name""ensemble", 
"type": ind_type + 1, 
"level": ind_level + 1, 
"accuracy": ensemble_acc_on_corrupted, 
} 
) 

# Calculate accuracy for BBB model 
bbb_acc_on_corrupted = accuracy_score( 
test_labels_subset.flatten(), 
bbb_predicted_classes_on_corrupted[ind_level, ind_type, :], 
) 
accuracies.append( 
{ 
"model_name""bbb", 
"type": ind_type + 1, 
"level": ind_level + 1, 
"accuracy": bbb_acc_on_corrupted, 
} 
        )

然后,我们可以绘制原始图像和逐渐损坏图像的准确性分布。我们首先将字典列表转换为 pandas dataframe。这有一个优势,即 dataframe 可以直接传递给绘图库seaborn,这样我们可以指定不同模型的结果以不同色调进行绘制。


df = pd.DataFrame(accuracies) 
plt.figure(dpi=100) 
sns.boxplot(data=df, x="level", y="accuracy", hue="model_name") 
plt.legend(loc="center left", bbox_to_anchor=(10.5)) 
plt.tight_layout 
plt.show()

这会生成以下输出:

图片

图 8.6:三种不同模型(不同色调)在原始测试图像(级别 0)以及不同程度的损坏(级别 1-5)上的准确性

结果图如图**8.6所示。我们可以看到,在原始测试图像上,普通模型和 BBB 模型的准确性相当,而集成模型的准确性稍高。随着损坏的引入,我们看到普通神经网络的表现比集成模型或 BBB 模型更差(通常是显著差)。BDL 模型性能的相对提升展示了贝叶斯方法的正则化效应:这些方法能更有效地捕捉数据的分布,使其对扰动更加鲁棒。BBB 模型特别能抵御数据损坏的增加,展示了变分学习的一个关键优势。

步骤 5:衡量校准

查看准确度是确定模型在数据集变化下的鲁棒性的一种好方法。但它并没有真正告诉我们模型是否能够通过较低的置信度分数(当数据集发生变化时)提醒我们,并且模型在输出时变得不那么自信。这个问题可以通过观察模型在数据集变化下的校准表现来回答。我们在第三章的《深度学习基础》中已经介绍了校准和期望校准误差的概念。现在,我们将把这些概念付诸实践,以理解当图像变得越来越受损且难以预测时,模型是否适当地调整了它们的置信度。

首先,我们将实现第三章《深度学习基础》中介绍的期望校准误差(ECE),作为校准的标量衡量标准:


def expected_calibration_error( 
divd_correct, 
divd_score, 
n_bins=5, 
): 
"""Compute expected calibration error. 
---------- 
divd_correct : np.ndarray (n_samples,) 
Whether the prediction is correct or not 
divd_score : np.ndarray (n_samples,) 
Confidence in the prediction 
n_bins : int, default=5 
Number of bins to discretize the [0, 1] interval. 
""" 
# Convert from bool to integer (makes counting easier) 
divd_correct = divd_correct.astype(np.int32) 

# Create bins and assign prediction scores to bins 
bins = np.linspace(0.01.0, n_bins + 1) 
binids = np.searchsorted(bins[1:-1], divd_score) 

# Count number of samples and correct predictions per bin 
bin_true_counts = np.bincount( 
binids, weights=divd_correct, minlength=len(bins) 
) 
bin_counts = np.bincount(binids, minlength=len(bins)) 

# Calculate sum of confidence scores per bin 
bin_probs = np.bincount(binids, weights=divd_score, minlength=len(bins)) 

# Identify bins that contain samples 
nonzero = bin_counts != 0 
# Calculate accuracy for every bin 
bin_acc = bin_true_counts[nonzero] / bin_counts[nonzero] 
# Calculate average confidence scores per bin 
bin_conf = bin_probs[nonzero] / bin_counts[nonzero] 

    return np.average(np.abs(bin_acc - bin_conf), weights=bin_counts[nonzero])

然后,我们可以计算三个模型在原始测试图像上的期望校准误差(ECE)。我们将箱子的数量设置为10,这是计算 ECE 时常用的选择:


NUM_BINS = 10 

vanilla_cal = expected_calibration_error( 
test_labels_subset.flatten() == vanilla_predicted_classes, 
vanilla_scores, 
n_bins=NUM_BINS, 
) 

ensemble_cal = expected_calibration_error( 
test_labels_subset.flatten() == ensemble_predicted_classes, 
ensemble_scores, 
n_bins=NUM_BINS, 
) 

bbb_cal = expected_calibration_error( 
test_labels_subset.flatten() == bbb_predicted_classes, 
bbb_scores, 
n_bins=NUM_BINS, 
)

就像我们之前处理准确度一样,我们将把校准结果存储在一个字典列表中,这样就更容易绘制它们:


calibration = [ 
{ 
"model_name""vanilla", 
"type"0, 
"level"0, 
"calibration_error": vanilla_cal, 
}, 
{ 
"model_name""ensemble", 
"type"0, 
"level"0, 
"calibration_error": ensemble_cal, 
}, 
{ 
"model_name""bbb", 
"type"0, 
"level"0, 
"calibration_error": bbb_cal, 
}, 
]

接下来,我们根据不同的腐蚀类型和腐蚀级别组合,计算三个模型的期望校准误差。我们还将结果附加到之前开始的校准结果列表中:


for ind_type in range(NUM_TYPES): 
for ind_level in range(NUM_LEVELS): 
# Calculate calibration error for vanilla model 
vanilla_cal_on_corrupted = expected_calibration_error( 
test_labels_subset.flatten() 
== vanilla_predicted_classes_on_corrupted[ind_level, ind_type, :], 
vanilla_scores_on_corrupted[ind_level, ind_type, :], 
) 
calibration.append( 
{ 
"model_name""vanilla", 
"type": ind_type + 1, 
"level": ind_level + 1, 
"calibration_error": vanilla_cal_on_corrupted, 
} 
) 

# Calculate calibration error for ensemble model 
ensemble_cal_on_corrupted = expected_calibration_error( 
test_labels_subset.flatten() 
== ensemble_predicted_classes_on_corrupted[ind_level, ind_type, :], 
ensemble_scores_on_corrupted[ind_level, ind_type, :], 
) 
calibration.append( 
{ 
"model_name""ensemble", 
"type": ind_type + 1, 
"level": ind_level + 1, 
"calibration_error": ensemble_cal_on_corrupted, 
} 
) 

# Calculate calibration error for BBB model 
bbb_cal_on_corrupted = expected_calibration_error( 
test_labels_subset.flatten() 
== bbb_predicted_classes_on_corrupted[ind_level, ind_type, :], 
bbb_scores_on_corrupted[ind_level, ind_type, :], 
) 
calibration.append( 
{ 
"model_name""bbb", 
"type": ind_type + 1, 
"level": ind_level + 1, 
"calibration_error": bbb_cal_on_corrupted, 
} 
        )

最后,我们将使用pandasseaborn再次绘制校准结果的箱形图:


df = pd.DataFrame(calibration) 
plt.figure(dpi=100) 
sns.boxplot(data=df, x="level", y="calibration_error", hue="model_name") 
plt.legend(loc="center left", bbox_to_anchor=(10.5)) 
plt.tight_layout 
plt.show()

校准结果显示在图**8.7中。我们可以看到,在原始测试图像上,所有三个模型的校准误差都比较低,集成模型的表现略逊色于另外两个模型。随着数据集变化程度的增加,我们可以看到,传统模型的校准误差大幅增加。对于两种贝叶斯方法,校准误差也增加了,但比传统模型要少得多。这意味着贝叶斯方法在数据集发生变化时能够更好地通过较低的置信度分数来指示(即模型在输出时变得相对不那么自信,随着腐蚀程度的增加,表现出这种特征)。

PIC

图 8.7:三种不同模型在原始测试图像(级别 0)和不同腐蚀级别(级别 1-5)上的期望校准误差

在下一节中,我们将讨论数据选择。

8.4 使用通过不确定性进行的数据选择来保持模型的更新

我们在本章开头看到,能够使用不确定性来判断数据是否是训练数据的一部分。在主动学习这一机器学习领域的背景下,我们可以进一步扩展这个想法。主动学习的承诺是,如果我们能够控制模型训练的数据类型,模型可以在更少的数据上更有效地学习。从概念上讲,这是有道理的:如果我们在质量不足的数据上训练模型,它的表现也不会很好。主动学习是一种通过提供可以从不属于训练数据的数据池中获取数据的函数,来引导模型学习过程和训练数据的方法。通过反复从数据池中选择正确的数据,我们可以训练出比随机选择数据时表现更好的模型。

主动学习可以应用于许多现代系统,在这些系统中有大量未标记的数据可供使用,我们需要仔细选择想要标记的数据量。一个例子是自动驾驶系统:车上的摄像头记录了大量数据,但通常没有预算标记所有数据。通过仔细选择最具信息量的数据点,我们可以以比随机选择数据标记时更低的成本提高模型性能。在主动学习的背景下,估计不确定性发挥着重要作用。模型通常会从数据分布中那些低置信度预测的区域学到更多。让我们通过一个案例研究来看看如何在主动学习的背景下使用不确定性。

在这个案例研究中,我们将重现一篇基础性主动学习论文的结果:基于图像数据的深度贝叶斯主动学习(2017)。我们将使用MNIST数据集,并在越来越多的数据上训练模型,通过不确定性方法选择要添加到训练集中的数据点。在这种情况下,我们将使用认知不确定性来选择最具信息量的数据点。具有高认知不确定性的图像应该是模型之前没有见过的图像;通过增加更多这样的图像,可以减少不确定性。作为对比,我们还将随机选择数据点。

第一步:准备数据集

我们将首先创建加载数据集的函数。数据集函数需要以下库导入:


import dataclasses 
from pathlib import Path 
import uuid 
from typing import OptionalTuple 

import numpy as np 
import tensorflow as tf 
from sklearn.utils import shuffle

由于我们的总数据集将包含相当多的组件,我们将创建一个小的dataclass,以便轻松访问数据集的不同部分。我们还将修改__repr__函数,使其能够以更易读的格式打印数据集内容。


@dataclasses.dataclass 
class Data: 
x_train: np.ndarray 
y_train: np.ndarray 
x_test: np.ndarray 
y_test: np.ndarray 
x_train_al: Optional[np.ndarray] = None 
y_train_al: Optional[np.ndarray] = None 

def __repr__(self) -*>* str: 
repr_str = "" 
for field in dataclasses.fields(self): 
repr_str += f"{field.name}{getattr(self, field.name).shape} \n" 
        return repr_str

然后我们可以定义函数来加载标准数据集。


def get_data() -*>* Data: 
num_classes = 10 
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data() 
# Scale images to the [0, 1] range 
x_train = x_train.astype("float32") / 255 
x_test = x_test.astype("float32") / 255 
# Make sure images have shape (28, 28, 1) 
x_train = np.expand_dims(x_train, -1) 
x_test = np.expand_dims(x_test, -1) 
y_train = tf.keras.utils.to_categorical(y_train, num_classes) 
y_test = tf.keras.utils.to_categorical(y_test, num_classes) 
    return Data(x_train, y_train, x_test, y_test)

最初,我们将从MNIST数据集中仅使用 20 个样本进行训练。然后我们每次获取 10 个数据点,并重新训练我们的模型。为了在开始时帮助我们的模型,我们将确保这 20 个数据点在数据集的不同类别之间是平衡的。以下函数给出了我们可以使用的索引,用于创建初始的 20 个样本,每个类别 2 个样本:


def get_random_balanced_indices( 
data: Data, initial_n_samples: int 
) -*>* np.ndarray: 
labels = np.argmax(data.y_train, axis=1) 
indices = [] 
label_list = np.unique(labels) 
for label in label_list: 
indices_label = np.random.choice( 
np.argwhere(labels == label).flatten(), 
size=initial_n_samples // len(label_list), 
replace=False 
) 
indices.extend(indices_label) 
indices = np.array(indices) 
np.random.shuffle(indices) 
    return indices

然后我们可以定义一个小函数,实际获取我们的初始数据集:


def get_initial_ds(data: Data, initial_n_samples: int) -*>* Data: 
indices = get_random_balanced_indices(data, initial_n_samples) 
x_train_al, y_train_al = data.x_train[indices], data.y_train[indices] 
x_train = np.delete(data.x_train, indices, axis=0) 
y_train = np.delete(data.y_train, indices, axis=0) 
return Data( 
x_train, y_train, data.x_test, data.y_test, x_train_al, y_train_al 
    )
步骤 2:设置配置

在我们开始构建模型并创建主动学习循环之前,我们定义一个小的配置dataclass来存储一些在运行主动学习脚本时可能想要调整的主要变量。创建这样的配置类使你可以灵活调整不同的参数。


@dataclasses.dataclass 
class Config: 
initial_n_samples: int 
n_total_samples: int 
n_epochs: int 
n_samples_per_iter: int 
# string representation of the acquisition function 
acquisition_type: str 
# number of mc_dropout iterations 
    n_iter: int
步骤 3:定义模型

我们现在可以定义我们的模型。我们将使用一个简单的小型 CNN 并加入 Dropout。


def build_model(): 
model = tf.keras.models.Sequential([ 
Input(shape=(28281)), 
layers.Conv2D(32, kernel_size=(44), activation="relu"), 
layers.Conv2D(32, kernel_size=(44), activation="relu"), 
layers.MaxPooling2D(pool_size=(22)), 
layers.Dropout(0.25), 
layers.Flatten(), 
layers.Dense(128, activation="relu"), 
layers.Dropout(0.5), 
layers.Dense(10, activation="softmax"), 
]) 
model.compile( 
tf.keras.optimizers.Adam(), 
loss="categorical_crossentropy", 
metrics=["accuracy"], 
experimental_run_tf_function=False, 
) 
    return model
步骤 4:定义不确定性函数

如前所述,我们将使用认知不确定性(也称为知识不确定性)作为我们主要的不确定性函数来获取新样本。让我们定义一个函数来计算我们预测的认知不确定性。我们假设输入的预测(divds)的形状为n_imagesn_predictionsn_classes。我们首先定义一个函数来计算总不确定性。给定一个集成模型的预测,它可以定义为集成平均预测的熵。


def total_uncertainty( 
divds: np.ndarray, epsilon: float = 1e-10 
) -*>* np.ndarray: 
mean_divds = np.mean(divds, axis=1) 
log_divds = -np.log(mean_divds + epsilon) 
    return np.sum(mean_divds * log_divds, axis=1)

然后我们定义数据不确定性(或称为随机不确定性),对于一个集成模型来说,它是每个集成成员的熵的平均值。


def data_uncertainty(divds: np.ndarray, epsilon: float = 1e-10) -*>* np.ndarray: 
log_divds = -np.log(divds + epsilon) 
    return np.mean(np.sum(divds * log_divds, axis=2), axis=1)

最终,我们得到了我们的知识(或认知)不确定性,这就是通过从预测的总不确定性中减去数据不确定性来得到的。


def knowledge_uncertainty( 
divds: np.ndarray, epsilon: float = 1e-10 
) -*>* np.ndarray: 
    return total_uncertainty(divds, epsilon) - data_uncertainty(divds, epsilon)

定义了这些不确定性函数后,我们可以定义实际的获取函数,它们的主要输入是我们的训练数据和模型。为了通过知识不确定性来获取样本,我们进行以下操作:

  1. 通过 MC Dropout 获取我们的集成预测。

  2. 计算这个集成模型的知识不确定性值。

  3. 对不确定性值进行排序,获取它们的索引,并返回我们训练数据中具有最高认知不确定性的索引。

然后,稍后我们可以重复使用这些索引来索引我们的训练数据,实际上获取我们想要添加的训练样本。


from typing import Callable 
from keras import Model 
from tqdm import tqdm 

import numpy as np 

def acquire_knowledge_uncertainty( 
x_train: np.ndarray, 
n_samples: int, 
model: Model, 
n_iter: int, 
*args, 
**kwargs 
): 
divds = get_mc_predictions(model, n_iter, x_train) 
ku = knowledge_uncertainty(divds) 
    return np.argsort(ku, axis=-1)[-n_samples:]

我们通过以下方式获得 MC Dropout 预测:


def get_mc_predictions( 
model: Model, n_iter: int, x_train: np.ndarray 
) -*>* np.ndarray: 
divds = [] 
for _ in tqdm(range(n_iter)): 
divds_iter = [ 
model(batch, training=True) 
for batch in np.array_split(x_train, 6) 
] 
divds.append(np.concatenate(divds_iter)) 
# format data such that we have n_images, n_predictions, n_classes 
divds = np.moveaxis(np.stack(divds), 01) 
    return divds

为了避免内存溢出,我们将训练数据分批处理,每批 6 个样本,对于每一批,我们将计算n_iter次预测。为了确保我们的预测具有多样性,我们将模型的training参数设置为True

对于我们的比较,我们还定义了一个获取函数,该函数返回一个随机的索引列表:


def acquire_random(x_train: np.ndarray, n_samples: int, *args, **kwargs): 
    return np.random.randint(low=0, high=len(x_train), size=n_samples)

最后,我们根据工厂方法模式定义一个小函数,以确保我们可以在循环中使用相同的函数,使用随机采集函数或知识不确定性。像这样的工厂小函数有助于在你想用不同配置运行相同代码时保持代码模块化。


def acquisition_factory(acquisition_type: str) -*>* Callable: 
if acquisition_type == "knowledge_uncertainty": 
return acquire_knowledge_uncertainty 
if acquisition_type == "random": 
        return acquire_random

现在我们已经定义了采集函数,我们已经准备好实际定义运行我们主动学习迭代的循环。

第 5 步:定义循环

首先,我们定义我们的配置。在这种情况下,我们使用知识不确定性作为我们的不确定性函数。在另一个循环中,我们将使用一个随机采集函数来比较我们即将定义的循环结果。我们将从 20 个样本开始我们的数据集,直到我们达到 1,000 个样本。每个模型将训练 50 个 epoch,每次迭代我们获取 10 个样本。为了获得我们的 MC dropout 预测,我们将在整个训练集(减去已获取的样本)上运行 100 次。


cfg = Config( 
initial_n_samples=20, 
n_total_samples=1000, 
n_epochs=50, 
n_samples_per_iteration=10, 
acquisition_type="knowledge_uncertainty", 
n_iter=100, 
)

然后我们可以获取数据,并定义一个空字典来跟踪每次迭代的测试准确率。我们还创建一个空列表,用于跟踪我们添加到训练数据中的所有索引。


data: Data = get_initial_ds(get_data(), cfg.initial_n_samples) 
accuracies = {} 
added_indices = []

我们还为我们的运行分配了一个全球唯一标识符UUID),以确保我们可以轻松找到它,并且不会覆盖我们作为循环一部分保存的结果。我们创建一个目录来保存我们的数据,并将配置保存到该目录,以确保我们始终知道model_dir中的数据是使用何种配置创建的。


run_uuid = str(uuid.uuid4()) 
model_dir = Path("./models") / cfg.acquisition_type / run_uuid 
model_dir.mkdir(parents=True, exist_ok=True)

现在,我们可以实际运行我们的主动学习循环。我们将把这个循环分成三个部分:

  1. 我们定义循环,并在已获取的样本上拟合模型:

    
    for i in range(cfg.n_total_samples // cfg.n_samples_per_iter): 
    iter_dir = model_dir / str(i) 
    model = build_model() 
    model.fit( 
    x=data.x_train_al, 
    y=data.y_train_al, 
    validation_data=(data.x_test, data.y_test), 
    epochs=cfg.n_epochs, 
    callbacks=[get_callback(iter_dir)], 
    verbose=2, 
        )
    
  2. 然后,我们加载具有最佳验证准确率的模型,并根据采集函数更新我们的数据集:

    
    model = tf.keras.models.load_model(iter_dir) 
    indices_to_add = acquisition_factory(cfg.acquisition_type)( 
    data.x_train, 
    cfg.n_samples_per_iter, 
    n_iter=cfg.n_iter, 
    model=model, 
    ) 
    added_indices.append(indices_to_add) 
        data, (iter_x, iter_y) = update_ds(data, indices_to_add)
    
  3. 最后,我们保存已添加的图片,计算测试准确率,并保存结果:

    
    save_images_and_labels_added(iter_dir, iter_x, iter_y) 
    divds = model(data.x_test) 
    accuracy = get_accuracy(data.y_test, divds) 
    accuracies[i] = accuracy 
        save_results(accuracies, added_indices, model_dir)
    

在这个循环中,我们定义了一些小的辅助函数。首先,我们为我们的模型定义了一个回调,以将具有最高验证准确率的模型保存到我们的模型目录:


def get_callback(model_dir: Path): 
model_checkpoint_callback = tf.keras.callbacks.ModelCheckpoint( 
str(model_dir), 
monitor="val_accuracy", 
verbose=0, 
save_best_only=True, 
) 
    return model_checkpoint_callback

我们还定义了一个函数来计算测试集的准确率:


def get_accuracy(y_test: np.ndarray, divds: np.ndarray) -*>* float: 
acc = tf.keras.metrics.CategoricalAccuracy() 
acc.update_state(divds, y_test) 
    return acc.result().numpy() * 100

我们还定义了两个小函数,用于每次迭代保存结果:


def save_images_and_labels_added( 
output_path: Path, iter_x: np.ndarray, iter_y: np.ndarray 
): 
df = pd.DataFrame() 
df["label"] = np.argmax(iter_y, axis=1) 
iter_x_normalised = (np.squeeze(iter_x, axis=-1) * 255).astype(np.uint8) 
df["image"] = iter_x_normalised.reshape(1028*28).tolist() 
df.to_parquet(output_path / "added.parquet", index=False) 

def save_results( 
accuracies: Dict[intfloat], added_indices: List[int], model_dir: Path 
): 
df = pd.DataFrame(accuracies.items(), columns=["i""accuracy"]) 
df["added"] = added_indices 
    df.to_parquet(f"{model_dir}/results.parquet", index=False)

请注意,运行主动学习循环需要相当长的时间:每次迭代,我们训练并评估模型 50 个 epoch,然后在我们的池集(完整的训练数据集减去已获取的样本)上运行 100 次。使用随机采集函数时,我们跳过最后一步,但仍然每次迭代将验证数据运行 50 次,以确保使用具有最佳验证准确率的模型。这需要时间,但仅仅选择具有最佳训练准确率的模型是有风险的:我们的模型在训练过程中多次看到相同的几张图片,因此很可能会过拟合训练数据。

第 6 步:检查结果

现在,我们有了循环,可以检查这个过程的结果。我们将使用seabornmatplotlib来可视化我们的结果:


import seaborn as sns 
import matplotlib.pyplot as plt 
import pandas as pd 
import numpy as np 
sns.set_style("darkgrid") 
sns.set_context("paper")

我们最感兴趣的主要结果是两种模型的测试准确率随时间的变化,这些模型分别是基于随机获取函数训练的模型和通过知识不确定性获取数据训练的模型。为了可视化这个结果,我们定义一个函数,加载结果并返回一个图表,显示每个主动学习迭代周期的准确率:


def plot(uuid: str, acquisition: str, ax=None): 
acq_name = acquisition.replace("_"" ") 
df = pd.read_parquet(f"./models/{acquisition}/{uuid}/results.parquet")[:-1] 
df = df.rename(columns={"accuracy": acq_name}) 
df["n_samples"] = df["i"].apply(lambda x: x*10 + 20) 
return df.plot.line( 
x="n_samples", y=acq_name, style='.-', figsize=(8,5), ax=ax 
    )

然后,我们可以使用这个函数绘制两个获取函数的结果:


ax = plot("bc1adec5-bc34-44a6-a0eb-fa7cb67854e4""random") 
ax = plot( 
"5c8d6001-a5fb-45d3-a7cb-2a8a46b93d18""knowledge_uncertainty", ax=ax 
) 
plt.xticks(np.arange(0105050)) 
plt.yticks(np.arange(541022)) 
plt.ylabel("Accuracy") 
plt.xlabel("Number of acquired samples") 
plt.show()

这将产生以下输出:

图片

图 8.8:主动学习结果

8.8 显示,通过知识不确定性获取样本开始显著提高模型的准确性,尤其是在大约获取了 300 个样本之后。该模型的最终准确率比随机样本训练的模型高出大约两个百分点。虽然这看起来不多,但我们也可以从另一个角度来分析数据:为了实现特定的准确率,需要多少样本?如果我们检查图表,可以看到,知识不确定性线在 400 个训练样本下达到了 96%的准确率。而随机样本训练的模型则至少需要 750 个样本才能达到相同的准确率。这意味着,在相同准确率下,知识不确定性方法只需要几乎一半的数据量。这表明,采用正确的获取函数进行主动学习非常有用,特别是在计算资源充足但标注成本昂贵的情况下:通过正确选择样本,我们可能能够将标注成本降低一倍,从而实现相同的准确率。

因为我们保存了每次迭代获取的样本,所以我们也可以检查两种模型选择的图像类型。为了使我们的可视化更易于解释,我们将可视化每种方法对于每个标签所选的最后五个图像。为此,我们首先定义一个函数,返回每个标签的图像集,对于一组模型目录:


def get_imgs_per_label(model_dirs) -*>* Dict[int, np.ndarray]: 
imgs_per_label = {i: [] for i in range(10)} 
for model_dir in model_dirs: 
df = pd.read_parquet(model_dir / "images_added.parquet") 
df.image = df.image.apply( 
lambda x: x.reshape(2828).astype(np.uint8) 
) 
for label in df.label.unique(): 
dff = df[df.label == label] 
if len(dff) == 0: 
continue 
imgs_per_label[label].append(np.hstack(dff.image)) 
    return imgs_per_label

然后,我们定义一个函数,创建一个PIL 图像,其中按标签将图像进行拼接,以便用于特定的获取函数:


from PIL import Image 
from pathlib import Path 

def get_added_images( 
acquisition: str, uuid: str, n_iter: int = 5 
) -*>* Image: 
base_dir = Path("./models") / acquisition / uuid 
model_dirs = filter(lambda x: x.is_dir(), base_dir.iterdir()) 
model_dirs = sorted(model_dirs, key=lambda x: int(x.stem)) 
imgs_per_label = get_imgs_per_label(model_dirs) 
imgs = [] 
for i in range(10): 
label_img = np.hstack(imgs_per_label[i])[:, -(28 * n_iter):] 
imgs.append(label_img) 
    return Image.fromarray(np.vstack(imgs))

然后,我们可以调用这些函数,在我们的案例中使用以下设置和UUID


uuid = "bc1adec5-bc34-44a6-a0eb-fa7cb67854e4" 
img_random = get_added_images("random", uuid) 
uuid = "5c8d6001-a5fb-45d3-a7cb-2a8a46b93d18" 
img_ku = get_added_images("knowledge_uncertainty", uuid)

让我们比较一下输出。

图片图片

图 8.9:随机选择的图像(左)与通过知识不确定性和 MC 丢弃法选择的图像(右)。每一行显示每个标签所选的最后五个图像

我们可以在 8.9中看到,通过知识不确定性获取函数选取的图像相比随机选择的图像可能更难以分类。这个不确定性获取函数选择了数据集中一些不寻常的数字表示。由于我们的获取函数能够选取这些图像,模型能够更好地理解数据集的整体分布,从而随着时间的推移提高了准确率。

8.5 使用不确定性估计实现更智能的强化学习

强化学习旨在开发能够从环境中学习的机器学习技术。强化学习背后的基本原则在其名称中有一丝线索:目标是加强成功的行为。一般来说,在强化学习中,我们有一个智能体能够在环境中执行一系列的动作。在这些动作之后,智能体从环境中获得反馈,而这些反馈被用来帮助智能体更好地理解哪些动作更可能导致在当前环境状态下获得积极的结果。

从形式上讲,我们可以使用一组状态 S、一组动作 A 来描述它们如何从当前状态 s 转换到新的状态 s^′,以及奖励函数 R(s,s^′),描述当前状态 s 和新状态 s^′ 之间的过渡奖励。状态集由环境状态集 S[e] 和智能体状态集 S[a] 组成,两者共同描述整个系统的状态。

我们可以将此类比为一场马可·波罗的游戏,其中一个玩家通过“喊叫”与“回应”的方式来找到另一个玩家。当寻找的玩家喊“Marco”时,另一个玩家回应“Polo”,根据声音的方向和幅度给出寻找者其位置的估计。如果我们将此简化为考虑距离,那么较近的状态是距离减少的状态,例如δ = dd^′ > 0,其中 d 是状态 s 的距离,d^′ 是状态 s^′ 的距离。相反,较远的状态是δ = dd^′ < 0。因此,在这个例子中,我们可以使用我们的δ值作为模型的反馈,使得我们的奖励函数为δ = R(s,s^′) = dd^′。

PIC

图 8.10:马可·波罗强化学习场景的插图

让我们把智能体视为寻找玩家,把目标视为隐藏玩家。在每一步,智能体会收集更多关于环境的信息,从而更好地建模其行动 A(s) 和奖励函数 R(s,s^′) 之间的关系(换句话说,它在学习需要朝哪个方向移动,以便更接近目标)。在每一步,我们需要预测奖励函数,给定当前状态下的可能行动集 A[s],以便选择最有可能最大化该奖励函数的行动。在这种情况下,行动集可以是我们可以移动的方向集,例如:前进、后退、左转和右转。

传统的强化学习使用一种叫做Q 学习的方法来学习状态、行动和奖励之间的关系。Q 学习不涉及神经网络模型,而是将状态、行动和奖励信息存储在一个表格中——Q 表格——然后用来确定在当前状态下最有可能产生最高奖励的行动。虽然 Q 学习非常强大,但对于大量的状态和行动,它的计算成本变得不可承受。为了解决这个问题,研究人员引入了深度 Q 学习的概念,其中 Q 表格被神经网络所替代。在通常经过大量迭代后,神经网络会学习在给定当前状态的情况下,哪些行动更有可能产生更高的奖励。

为了预测哪种行动可能产生最高的奖励值,我们使用一个经过训练的模型,该模型基于所有历史行动 A[h]、状态 S[h] 和奖励 R[h]。我们的训练输入 X 包含行动 A[h] 和状态 S[h],而目标输出 y 包含奖励值 R[h]。然后,我们可以将该模型作为模型预测控制器MPC)的一部分,选择行动,依据是哪个行动与最高预测奖励相关:

anext = argmax yi∀ai ∈ As

这里,y[i] 是我们的模型产生的奖励预测,f(a[i],s),它将当前状态 s 和可能的动作 a[i] ∈ A[s] 映射到奖励值。然而,在我们的模型有任何用处之前,我们需要收集数据进行训练。我们将在多个回合中积累数据,每个回合包括代理采取的一系列动作,直到满足某些终止标准。理想的终止标准是代理找到目标,但我们也可以设置其他标准,例如代理遇到障碍物或代理用尽最大动作数。由于模型开始时没有任何信息,我们使用一种在强化学习中常见的贪婪策略,叫做 𝜖greedy 策略,允许代理通过从环境中随机采样开始。这里的想法是,我们的代理以 𝜖 的概率执行随机动作,否则使用模型预测来选择动作。在每个回合之后,我们会减少 𝜖,使得代理最终仅根据模型来选择动作。让我们构建一个简单的强化学习示例,看看这一切是如何运作的。

第一步:初始化我们的环境

我们的强化学习示例将围绕我们的环境展开:这定义了所有事件发生的空间。我们将使用Environment类来处理这个问题。首先,我们设置环境参数:


import numpy as np 
import tensorflow as tf 
from scipy.spatial.distance import euclidean 
from tensorflow.keras import ( 
Model, 
Sequential, 
layers, 
optimizers, 
metrics, 
losses, 
) 
import pandas as pd 
from sklearn.preprocessing import StandardScaler 
import copy 

class Environment: 
def __init__(self, env_size=8, max_steps=2000): 
self.env_size = env_size 
self.max_steps = max_steps 
self.agent_location = np.zeros(2) 
self.target_location = np.random.randint(0, self.env_size, 2) 
self.action_space = { 
0: np.array([01]), 
1: np.array([0, -1]), 
2: np.array([10]), 
3: np.array([-10]), 
} 
self.delta = self.compute_distance() 
self.is_done = False 
self.total_steps = 0 
self.ideal_steps = self.calculate_ideal_steps() 
    ...

在这里,注意我们的环境大小,用env_size表示,它定义了环境中的行数和列数——在这个例子中,我们将使用 8 × 8 的环境,结果是 64 个位置(为了简便,我们将使用一个方形环境)。我们还将设置一个max_steps限制,以确保在代理随机选择动作时,回合不会进行得太长。

我们还设置了agent_locationtarget_location变量——代理总是从 [0, 0] 点开始,而目标位置则是随机分配的。

接下来,我们创建一个字典,将整数值映射到一个动作。从 0 到 3,这些动作分别是:向前、向后、向右、向左。我们还设置了delta变量——这是代理与目标之间的初始距离(稍后我们将看到compute_distance()是如何实现的)。

最后,我们初始化一些变量,用于跟踪终止标准是否已满足(is_done)、总步骤数(total_steps)和理想步骤数(ideal_steps)。后者是代理从起始位置到达目标所需的最小步骤数。我们将用它来计算遗憾,这是强化学习和优化算法中一个有用的性能指标。为了计算遗憾,我们将向我们的类中添加以下两个函数:


... 

def calculate_ideal_action(self, agent_location, target_location): 
min_delta = 1e1000 
ideal_action = -1 
for k in self.action_space.keys(): 
delta = euclidean( 
agent_location + self.action_space[k], target_location 
) 
if delta *<*= min_delta: 
min_delta = delta 
ideal_action = k 
return ideal_action, min_delta 

def calculate_ideal_steps(self): 
agent_location = copy.deepcopy(self.agent_location) 
target_location = copy.deepcopy(self.target_location) 
delta = 1e1000 
i = 0 
while delta *>* 0: 
ideal_action, delta = self.calculate_ideal_action( 
agent_location, target_location 
) 
agent_location += self.action_space[ideal_action] 
i += 1 
return i 
    ...

在这里,calculate_ideal_steps()将一直运行,直到代理与目标之间的距离(delta)为零。在每次迭代中,它使用calculate_ideal_action()来选择能使代理尽可能接近目标的动作。

第二步:更新我们环境的状态

现在我们已经初始化了我们的环境,我们需要添加我们类中最关键的一个部分:update方法。这控制了当代理采取新动作时环境的变化:


... 
def update(self, action_int): 
self.agent_location = ( 
self.agent_location + self.action_space[action_int] 
) 
# prevent the agent from moving outside the bounds of the environment 
self.agent_location[self.agent_location *>* (self.env_size - 1)] = ( 
self.env_size - 1 
) 
self.compute_reward() 
self.total_steps += 1 
self.is_done = (self.delta == 0or (self.total_steps *>*= self.max_steps) 
return self.reward 
    ...

该方法接收一个动作整数,并使用它来访问我们之前定义的action_space字典中对应的动作。然后更新代理位置。因为代理位置和动作都是向量,所以我们可以简单地使用向量加法来完成这一点。接下来,我们检查代理是否移出了环境的边界 – 如果是,则调整其位置使其仍然保持在我们的环境边界内。

下一行是另一个关键的代码片段:使用compute_reward()计算奖励 – 我们马上就会看到这个。一旦计算出奖励,我们增加total_steps计数器,检查终止条件,并返回动作的奖励值。

我们使用以下函数来确定奖励。如果代理与目标之间的距离增加,则返回低奖励(1),如果减少,则返回高奖励(10):


... 
def compute_reward(self): 
d1 = self.delta 
self.delta = self.compute_distance() 
if self.delta *<* d1: 
self.reward = 10 
else: 
self.reward = 1 
    ...

这里使用了compute_distance()函数,计算代理与目标之间的欧氏距离:


... 
def compute_distance(self): 
return euclidean(self.agent_location, self.target_location) 
    ...

最后,我们需要一个函数来允许我们获取环境的状态,以便将其与奖励值关联起来。我们将其定义如下:


... 
def get_state(self): 
return np.concatenate([self.agent_location, self.target_location]) 
    ...
第三步:定义我们的模型

现在我们已经设置好了环境,我们将创建一个模型类。这个类将处理模型训练和推断,以及根据模型预测选择最佳动作。和往常一样,我们从__init__()方法开始:


class RLModel: 
def __init__(self, state_size, n_actions, num_epochs=500): 
self.state_size = state_size 
self.n_actions = n_actions 
self.num_epochs = 200 
self.model = Sequential() 
self.model.add( 
layers.Dense( 
20, input_dim=self.state_size, activation="relu", name="layer_1" 
) 
) 
self.model.add(layers.Dense(8, activation="relu", name="layer_2")) 
self.model.add(layers.Dense(1, activation="relu", name="layer_3")) 
self.model.compile( 
optimizer=optimizers.Adam(), 
loss=losses.Huber(), 
metrics=[metrics.RootMeanSquaredError()], 
) 
    ...

在这里,我们传递了一些与我们的环境相关的变量,如状态大小和动作数量。与模型定义相关的代码应该很熟悉 – 我们只是使用 Keras 实例化了一个神经网络。需要注意的一点是,我们在这里使用 Huber 损失,而不是更常见的均方误差。这是在强化学习和健壮回归任务中常见的选择。Huber 损失动态地在均方误差和平均绝对误差之间切换。前者非常擅长惩罚小误差,而后者对异常值更为健壮。通过 Huber 损失,我们得到了一个既对异常值健壮又惩罚小误差的损失函数。

这在强化学习中特别重要,因为算法具有探索性特征:我们经常会遇到一些极具探索性的样本,它们与其他数据相比偏离较大,从而在训练过程中导致较大的误差。

在完成类的初始化后,我们继续处理 fit()predict() 函数:


... 
def fit(self, X_train, y_train, batch_size=16): 
self.scaler = StandardScaler() 
X_train = self.scaler.fit_transform(X_train) 
self.model.fit( 
X_train, 
y_train, 
epochs=self.num_epochs, 
verbose=0, 
batch_size=batch_size, 
) 

def predict(self, state): 
rewards = [] 
X = np.zeros((self.n_actions, self.state_size)) 
for i in range(self.n_actions): 
X[i] = np.concatenate([state, [i]]) 
X = self.scaler.transform(X) 
rewards = self.model.predict(X) 
        return np.argmax(rewards)

fit() 函数应该非常熟悉——我们只是对输入进行缩放,然后再拟合我们的 Keras 模型。predict() 函数则稍微复杂一点。因为我们需要对每个可能的动作(前进、后退、右转、左转)进行预测,所以我们需要为这些动作生成输入。我们通过将与动作相关的整数值与状态进行拼接,来生成完整的状态-动作向量,正如第 11 行所示。对所有动作执行此操作后,我们得到输入矩阵 X,其中每一行都对应一个特定的动作。然后,我们对 X 进行缩放,并在其上运行推理,以获得预测的奖励值。为了选择一个动作,我们简单地使用 np.argmax() 来获取与最高预测奖励相关的索引。

第 4 步:运行我们的强化学习

现在,我们已经定义了 EnvironmentRLModel 类,准备开始强化学习了!首先,我们设置一些重要的变量并实例化我们的模型:


env_size = 8 
state_size = 5 
n_actions = 4 
epsilon = 1.0 
history = {"state": [], "reward": []} 
n_samples = 1000 
max_steps = 500 
regrets = [] 

model = RLModel(state_size, n_actions)

这些内容现在应该已经很熟悉了,但我们还是会再回顾一些尚未覆盖的部分。history 字典是我们存储状态和奖励信息的地方,在每一轮的每个步骤中,我们会更新这些信息。然后,我们会利用这些信息来训练我们的模型。另一个不太熟悉的变量是 n_samples——我们设置这个变量是因为每次训练模型时,并不是使用所有可用的数据,而是从数据中随机抽取 1,000 个数据点。这样可以避免随着数据量的不断增加,我们的训练时间也不断暴增。这里的最后一个新变量是 regrets。这个列表将存储每一轮的遗憾值。在我们的案例中,遗憾被简单地定义为模型所采取的步骤数与智能体到达目标所需的最小步骤数之间的差值:

regret = steps − steps model ideal

因此,遗憾为零 steps[model] == steps[ideal]。遗憾值对于衡量模型学习过程中的表现非常有用,正如我们稍后将看到的那样。接下来就是强化学习过程的主循环:


for i in range(100): 
env = Environment(env_size, max_steps=max_steps) 
while not env.is_done: 
state = env.get_state() 
if np.random.rand() *<* epsilon: 
action = np.random.randint(n_actions) 
else: 
action = model.predict(state) 
reward = env.update(action) 
history["state"].append(np.concatenate([state, [action]])) 
history["reward"].append(reward) 
print( 
f"Completed episode {i} in {env.total_steps} steps." 
f"Ideal steps: {env.ideal_steps}." 
f"Epsilon: {epsilon}" 
) 
regrets.append(np.abs(env.total_steps-env.ideal_steps)) 
idxs = np.random.choice(len(history["state"]), n_samples) 
model.fit( 
np.array(history["state"])[idxs], 
np.array(history["reward"])[idxs] 
) 
    epsilon-=epsilon/10

在这里,我们的强化学习过程会运行 100 轮,每次都重新初始化环境。通过内部的 while 循环可以看到,我们会不断地迭代——更新智能体并衡量奖励——直到满足其中一个终止条件(无论是智能体达到目标,还是我们达到最大允许的迭代次数)。

每一轮结束后,print语句会告诉我们该轮是否没有错误完成,并告诉我们我们的智能体与理想步数的对比结果。接着,我们计算遗憾值,并将其附加到regrets列表中,从history中的数据进行采样,并在这些样本数据上拟合我们的模型。最后,每次外循环迭代结束时,我们会减少 epsilon 值。

运行完之后,我们还可以绘制遗憾值图,以查看我们的表现:


import matplotlib.pyplot as plt 
import seaborn as sns 

df_plot = pd.DataFrame({"regret": regrets, "episode": np.arange(len(regrets))}) 
sns.lineplot(x="episode", y="regret", data=df_plot) 
fig = plt.gcf() 
fig.set_size_inches(510) 
plt.show()

这将生成以下图表,展示我们模型在 100 轮训练中的表现:

PIC

图 8.11:强化学习 100 轮后的遗憾值图

正如我们在这里看到的,它一开始表现得很差,但模型很快学会了预测奖励值,从而能够预测最优动作,将遗憾减少到 0。

到目前为止,事情还算简单。事实上,你可能会想,为什么我们需要模型呢——为什么不直接计算目标和拟议位置之间的距离,然后选择相应的动作呢?首先,强化学习的目标是让智能体在没有任何先验知识的情况下发现如何在给定环境中进行交互——所以,尽管我们的智能体可以执行动作,但它没有距离的概念。这是通过与环境的互动来学习的。其次,情况可能没有那么简单:如果环境中有障碍物呢?在这种情况下,我们的智能体需要比简单地朝声音源移动更聪明。

尽管这只是一个示范性的例子,但强化学习在现实世界中的应用涉及一些我们知识非常有限的情境,因此,设计一个能够探索环境并学习如何最优互动的智能体,使我们能够为那些无法使用监督学习方法的应用开发模型。

另一个在现实世界情境中需要考虑的因素是风险:我们希望我们的智能体做出明智的决策,而不仅仅是最大化奖励的决策:我们需要它能够理解风险/回报的权衡。这就是不确定性估计的作用所在。

8.5.1 带有不确定性的障碍物导航

通过不确定性估计,我们可以在奖励和模型对其预测的信心之间找到平衡。如果模型的信心较低(意味着不确定性较高),那么我们可能希望对如何整合模型的预测保持谨慎。例如,假设我们刚刚探讨的强化学习场景。在每一轮中,我们的模型预测哪个动作将获得最高的奖励,然后我们的智能体选择该动作。在现实世界中,事情并不是那么可预测——我们的环境可能会发生变化,导致意外的后果。如果我们的环境中出现了障碍物,并且与障碍物发生碰撞会阻止我们的智能体完成任务,那么显然,如果我们的智能体还没有遇到过这个障碍物,它注定会失败。幸运的是,在贝叶斯深度学习的情况下,情况并非如此。只要我们有某种方式来感知障碍物,我们的智能体就能够检测到障碍物并选择不同的路径——即使该障碍物在之前的回合中没有出现。

PIC

图 8.12:不确定性如何影响强化学习智能体行动的示意图

这一切之所以可能,得益于我们的不确定性估计。当模型遇到不寻常的情况时,它对该预测的不确定性估计将会较高。因此,如果我们将其融入到我们的 MPC 方程中,我们就能在奖励和不确定性之间找到平衡,确保我们优先考虑较低的风险,而非较高的奖励。为了做到这一点,我们修改了我们的 MPC 方程,具体如下:

anext = argmax (yi − λσi)∀ai ∈ As

在这里,我们看到我们正在从我们的奖励预测 y[i] 中减去一个值,λσ[i]。这是因为 σ[i] 是与第 i 次预测相关的不确定性。我们使用 λ 来缩放不确定性,以便适当惩罚不确定的动作;这是一个可以根据应用进行调整的参数。通过一个经过良好校准的方法,我们将看到在模型对预测不确定时,σ[i] 的值会较大。让我们在之前的代码示例的基础上,看看这一过程如何实现。

第一步:引入障碍物

为了给我们的智能体制造挑战,我们将向环境中引入障碍物。为了测试智能体如何应对不熟悉的输入,我们将改变障碍物的策略——它将根据我们的环境设置,选择遵循静态策略或动态策略。我们将修改 Environment 类的 __init__() 函数,以便整合这些更改:


def __init__(self, env_size=8, max_steps=2000, dynamic_obstacle=False, lambda_val=2): 
self.env_size = env_size 
self.max_steps = max_steps 
self.agent_location = np.zeros(2) 
self.dynamic_obstacle = dynamic_obstacle 
self.lambda_val = lambda_val 
self.target_location = np.random.randint(0, self.env_size, 2) 
while euclidean(self.agent_location, self.target_location) *<* 4: 
self.target_location = np.random.randint(0, self.env_size, 2) 
self.action_space = { 
0: np.array([01]), 
1: np.array([0, -1]), 
2: np.array([10]), 
3: np.array([-10]), 
} 
self.delta = self.compute_distance() 
self.is_done = False 
self.total_steps = 0 
self.obstacle_location = np.array( 
[self.env_size / 2, self.env_size / 2], dtype=int 
) 
self.ideal_steps = self.calculate_ideal_steps() 
self.collision = False 

这里涉及的内容比较复杂,所以我们将逐一讲解每个更改。首先,为了确定障碍物是静态的还是动态的,我们设置了 dynamic_obstacle 变量。如果该值为 True,我们将随机设置障碍物的位置。如果该值为 False,则我们的物体将停留在环境的中央。我们还在此设置了我们的 lambda (λ) 参数,默认值为 2。

我们还在设置 target_location 时引入了一个 while 循环:我们这么做是为了确保智能体和目标之间有一定的距离。我们需要这么做是为了确保在智能体和目标之间留有足够的空间,以便放置动态障碍物——否则,智能体可能永远无法遇到这个障碍物(这将稍微违背本示例的意义)。

最后,我们在第 17 行计算障碍物的位置:你会注意到这只是将它设置在环境的中央。这是因为我们稍后会使用 dynamic_obstacle 标志将障碍物放置在智能体和目标之间——我们在 calculate_ideal_steps() 函数中这么做,因为这样我们就知道障碍物将位于智能体的理想路径上(因此更有可能被遇到)。

步骤 2:放置动态障碍物

dynamic_obstacleTrue 时,我们希望在每个回合将障碍物放置在不同的位置,从而为我们的智能体带来更多挑战。为此,我们在之前提到的 calculate_ideal_steps() 函数中进行了一些修改:


def calculate_ideal_steps(self): 
agent_location = copy.deepcopy(self.agent_location) 
target_location = copy.deepcopy(self.target_location) 
delta = 1e1000 
i = 0 
while delta *>* 0: 
ideal_action, delta = self.calculate_ideal_action( 
agent_location, target_location 
) 
agent_location += self.action_space[ideal_action] 
if np.random.randint(02and self.dynamic_obstacle: 
self.obstacle_location = copy.deepcopy(agent_location) 
i += 1 
        return i

在这里,我们看到我们在每次执行 while 循环时都调用了 np.random.randint(0, 2)。这是为了随机化障碍物沿理想路径的放置位置。

步骤 3:添加感知功能

如果我们的智能体无法感知环境中引入的物体,那么它将没有任何希望避免这个物体。因此,我们将添加一个函数来模拟传感器:get_obstacle_proximity()。该传感器将为我们的智能体提供关于如果它执行某个特定动作时,它会接近物体的距离信息。根据给定动作将我们的智能体靠近物体的距离,我们将返回逐渐增大的数值。如果动作将智能体置于足够远的位置(在这种情况下,至少 4.5 个空间),则我们的传感器将返回零。这个感知功能使得我们的智能体能够有效地看到一步之遥,因此我们可以将该传感器视为具有一步的感知范围。


def get_obstacle_proximity(self): 
obstacle_action_dists = np.array( 
[ 
euclidean( 
self.agent_location + self.action_space[k], 
self.obstacle_location, 
) 
for k in self.action_space.keys() 
] 
) 
return self.lambda_val * ( 
np.array(obstacle_action_dists *<* 2.5, dtype=float) 
+ np.array(obstacle_action_dists *<* 3.5, dtype=float) 
+ np.array(obstacle_action_dists *<* 4.5, dtype=float) 
        )

在这里,我们首先计算每个动作后智能体的未来接近度,然后计算整数“接近度”值。这些值是通过首先构造每个接近度条件的布尔数组来计算的,在这种情况下分别为 δ[o] < 2*.5, δ[o] < 3.5 和 δ[o] < 4.*5,其中 δ[o] 是与障碍物的距离。然后,我们将这些条件求和,使得接近度得分具有 3、2 或 1 的整数值,具体取决于满足多少个条件。这为我们提供了一个传感器,它会根据每个提议的动作返回有关障碍物未来接近度的基本信息。

步骤 4:修改奖励函数

准备环境的最后一件事是更新我们的奖励函数:


def compute_reward(self): 
d1 = self.delta 
self.delta = self.compute_distance() 
if euclidean(self.agent_location, self.obstacle_location) == 0: 
self.reward = 0 
self.collision = True 
self.is_done = True 
elif self.delta *<* d1: 
self.reward = 10 
else: 
            self.reward = 1

在这里,我们添加了一条语句来检查代理与障碍物是否发生碰撞(检查两者之间的距离是否为零)。如果发生碰撞,我们将返回奖励值 0,并将collisionis_done变量设置为True。这引入了新的终止标准——碰撞,并将允许我们的代理学习到碰撞是有害的,因为这些情况会得到最低的奖励。

第 5 步:初始化我们的不确定性感知模型

现在我们的环境已经准备好,我们需要一个新的模型——一个能够生成不确定性估计的模型。对于这个模型,我们将使用一个带有单个隐藏层的 MC dropout 网络:


class RLModelDropout: 
def __init__(self, state_size, n_actions, num_epochs=200, nb_inference=10): 
self.state_size = state_size 
self.n_actions = n_actions 
self.num_epochs = num_epochs 
self.nb_inference = nb_inference 
self.model = Sequential() 
self.model.add( 
layers.Dense( 
10, input_dim=self.state_size, activation="relu", name="layer_1" 
) 
) 
# self.model.add(layers.Dropout(0.15)) 
# self.model.add(layers.Dense(8, activation='relu', name='layer_2')) 
self.model.add(layers.Dropout(0.15)) 
self.model.add(layers.Dense(1, activation="relu", name="layer_2")) 
self.model.compile( 
optimizer=optimizers.Adam(), 
loss=losses.Huber(), 
metrics=[metrics.RootMeanSquaredError()], 
) 

self.proximity_dict = {"proximity sensor value": [], "uncertainty": []} 
    ...

这看起来应该很熟悉,但你会注意到几个关键的不同之处。首先,我们再次使用 Huber 损失函数。其次,我们引入了一个字典proximity_dict,它将记录从传感器接收到的邻近值和相关的模型不确定性。这将使我们能够稍后评估模型对异常邻近值的敏感性。

第 6 步:拟合我们的 MC Dropout 网络

接下来,我们需要以下几行代码:


... 
def fit(self, X_train, y_train, batch_size=16): 
self.scaler = StandardScaler() 
X_train = self.scaler.fit_transform(X_train) 
self.model.fit( 
X_train, 
y_train, 
epochs=self.num_epochs, 
verbose=0, 
batch_size=batch_size, 
) 
    ...

这应该再次看起来很熟悉——我们只是通过首先对输入进行缩放来准备数据,然后拟合我们的模型。

第 7 步:进行预测

在这里,我们看到我们稍微修改了predict()函数:


... 
def predict(self, state, obstacle_proximity, dynamic_obstacle=False): 
rewards = [] 
X = np.zeros((self.n_actions, self.state_size)) 
for i in range(self.n_actions): 
X[i] = np.concatenate([state, [i], [obstacle_proximity[i]]]) 
X = self.scaler.transform(X) 
rewards, y_std = self.predict_ll_dropout(X) 
# we subtract our standard deviations from our predicted reward values, 
# this way uncertain predictions are penalised 
rewards = rewards - (y_std * 2) 
best_action = np.argmax(rewards) 
if dynamic_obstacle: 
self.proximity_dict["proximity sensor value"].append( 
obstacle_proximity[best_action] 
) 
self.proximity_dict["uncertainty"].append(y_std[best_action][0]) 
return best_action 
    ...

更具体地说,我们添加了obstacle_proximitydynamic_obstacle变量。前者允许我们接收传感器信息,并将其纳入传递给模型的输入中。后者是一个标志,告诉我们是否进入了动态障碍物阶段——如果是,我们希望在proximity_dict字典中记录传感器值和不确定性的相关信息。

下一段预测代码应该再次看起来很熟悉:


... 
def predict_ll_dropout(self, X): 
ll_divd = [ 
self.model(X, training=Truefor _ in range(self.nb_inference) 
] 
ll_divd = np.stack(ll_divd) 
        return ll_divd.mean(axis=0), ll_divd.std(axis=0)

该函数简单地实现了 MC dropout 推断,通过nb_inference次前向传递获得预测,并返回与我们的预测分布相关的均值和标准差。

第 8 步:调整我们的标准模型

为了理解我们的贝叶斯模型带来的差异,我们需要将其与非贝叶斯模型进行比较。因此,我们将更新之前的RLModel类,添加从邻近传感器获取邻近信息的功能:


class RLModel: 
def __init__(self, state_size, n_actions, num_epochs=500): 
self.state_size = state_size 
self.n_actions = n_actions 
self.num_epochs = 200 
self.model = Sequential() 
self.model.add( 
layers.Dense( 
20, input_dim=self.state_size, activation="relu", name="layer_1" 
) 
) 
self.model.add(layers.Dense(8, activation="relu", name="layer_2")) 
self.model.add(layers.Dense(1, activation="relu", name="layer_3")) 
self.model.compile( 
optimizer=optimizers.Adam(), 
loss=losses.Huber(), 
metrics=[metrics.RootMeanSquaredError()], 
) 

def fit(self, X_train, y_train, batch_size=16): 
self.scaler = StandardScaler() 
X_train = self.scaler.fit_transform(X_train) 
self.model.fit( 
X_train, 
y_train, 
epochs=self.num_epochs, 
verbose=0, 
batch_size=batch_size, 
) 

def predict(self, state, obstacle_proximity, obstacle=False): 
rewards = [] 
X = np.zeros((self.n_actions, self.state_size)) 
for i in range(self.n_actions): 
X[i] = np.concatenate([state, [i], [obstacle_proximity[i]]]) 
X = self.scaler.transform(X) 
rewards = self.model.predict(X) 
return np.argmax(rewards) 

至关重要的是,我们在这里看到我们的决策函数并没有变化:因为我们没有模型不确定性,我们的模型的predict()函数仅基于预测的奖励来选择动作。

第 9 步:准备运行我们的新强化学习实验

现在我们准备好设置我们的新实验了。我们将初始化之前使用的变量,并引入几个新的变量:


env_size = 8 
state_size = 6 
n_actions = 4 
epsilon = 1.0 
history = {"state": [], "reward": []} 
model = RLModelDropout(state_size, n_actions, num_epochs=400) 
n_samples = 1000 
max_steps = 500 
regrets = [] 
collisions = 0 
failed = 0

在这里,我们看到我们引入了一个collisions变量和一个failed变量。这些变量将追踪碰撞次数和失败的回合次数,以便我们可以将贝叶斯模型的表现与非贝叶斯模型的表现进行比较。现在我们准备好运行实验了!

第 10 步:运行我们的 BDL 强化学习实验

如前所述,我们将对实验进行 100 回合的运行。然而,这次,我们只会在前 50 回合进行模型训练。之后,我们将停止训练,评估模型在找到安全路径到达目标方面的表现。在这最后 50 回合中,我们将dynamic_obstacle设置为True,意味着我们的环境将为每一回合随机选择一个新的障碍物位置。重要的是,这些随机位置将会位于代理与目标之间的理想路径上。

让我们来看一下代码:


for i in range(100): 
if i *<* 50: 
env = Environment(env_size, max_steps=max_steps) 
dynamic_obstacle = False 
else: 
dynamic_obstacle = True 
epsilon = 0 
env = Environment( 
env_size, max_steps=max_steps, dynamic_obstacle=True 
) 
    ...

首先,我们检查回合是否在前 50 回合之内。如果是,我们通过设置dynamic_obstacle=False实例化环境,并将全局变量dynamic_obstacle设置为False

如果回合是最后 50 回合之一,我们创建一个带有随机障碍物的环境,并将epsilon设置为 0,以确保我们在选择动作时总是使用模型预测。

接下来,我们进入while循环,使我们的代理开始移动。这与我们在上一个示例中看到的循环非常相似,只不过这次我们调用了env.get_obstacle_proximity(),并将返回的障碍物接近信息用于我们的预测,同时也将此信息存储在回合历史中:


... 
while not env.is_done: 
state = env.get_state() 
obstacle_proximity = env.get_obstacle_proximity() 
if np.random.rand() *<* epsilon: 
action = np.random.randint(n_actions) 
else: 
action = model.predict(state, obstacle_proximity, dynamic_obstacle) 
reward = env.update(action) 
history["state"].append( 
np.concatenate([state, [action], 
[obstacle_proximity[action]]]) 
) 
history["reward"].append(reward) 
    ...

最后,我们将记录一些已完成回合的信息,并将最新回合的结果打印到终端。我们更新failedcollisions变量,并打印回合是否成功完成,代理是否未能找到目标,或代理是否与障碍物发生碰撞:


if env.total_steps == max_steps: 
print(f"Failed to find target for episode {i}. Epsilon: {epsilon}") 
failed += 1 
elif env.total_steps *<* env.ideal_steps: 
print(f"Collided with obstacle during episode {i}. Epsilon: {epsilon}") 
collisions += 1 
else: 
print( 
f"Completed episode {i} in {env.total_steps} steps." 
f"Ideal steps: {env.ideal_steps}." 
f"Epsilon: {epsilon}" 
) 
regrets.append(np.abs(env.total_steps-env.ideal_steps)) 
if not dynamic_obstacle: 
idxs = np.random.choice(len(history["state"]), n_samples) 
model.fit( 
np.array(history["state"])[idxs], 
np.array(history["reward"])[idxs] 
) 
        epsilon-=epsilon/10

这里的最后一条语句还检查我们是否处于动态障碍物阶段,如果不是,则进行一次训练,并减少我们的 epsilon 值(如同上一个示例)。

那么,我们的表现如何?重复进行上述 100 回合的实验,对于RLModelRLModelDropout模型,我们得到了以下结果:

|


|


|


|


|

模型失败的回合数碰撞次数成功的回合数

|


|


|


|


|

RLModelDropout19331

|


|


|


|


|

RLModel161034

|


|


|


|


|

图 8.13:一张显示碰撞预测的表格

如我们所见,在选择使用标准神经网络还是贝叶斯神经网络时,都有其优缺点——标准神经网络完成了更多的成功回合。然而,关键是,使用贝叶斯神经网络的代理仅与障碍物发生了 3 次碰撞,而标准方法发生了 10 次碰撞——这意味着碰撞减少了 70%!

请注意,由于实验是随机的,您的结果可能会有所不同,但在 GitHub 仓库中,我们已包括完整的实验以及用于生成这些结果的种子。

我们可以通过查看在RLModelDropoutproximity_dict字典中记录的数据,更好地理解为什么会这样:


import matplotlib.pyplot as plt 
import seaborn as sns 

df_plot = pd.DataFrame(model.proximity_dict) 
sns.boxplot(x="proximity sensor value", y="uncertainty", data=df_plot)

这将产生以下图表:

PIC

图 8.14:与增加的接近传感器值相关的不确定性估计分布

如我们所见,模型的不确定性估计随着传感器值的增加而增加。这是因为,在前 50 个回合中,我们的智能体学会了避开环境的中心(因为障碍物就在这里)——因此,它习惯了较低(或为零)的接近传感器值。这意味着较高的传感器值是异常的,因此能够被模型的不确定性估计所捕捉到。然后,我们的智能体通过使用不确定性感知 MPC 方程,成功地解决了这种不确定性。

在这个示例中,我们看到了如何将 BDL 应用于强化学习,以促进强化学习智能体更谨慎的行为。尽管这里的示例相对基础,但其含义却相当深远:想象一下将其应用于安全关键的应用场景。在这些环境中,如果满足更好的安全要求,我们往往愿意接受模型性能较差。因此,BDL 在安全强化学习领域中占有重要地位,能够开发出适用于安全关键场景的强化学习方法。

在下一节中,我们将看到如何使用 BDL 创建对抗性输入具有鲁棒性的模型,这是现实世界应用中的另一个关键考虑因素。

8.6 对抗性输入的易感性

第三章深度学习基础中,我们看到通过稍微扰动图像的输入像素,可以欺骗 CNN。原本清晰看起来像猫的图片,被高置信度地预测为狗。我们创建的对抗性攻击(FSGM)是许多对抗性攻击之一,BDL 可能提供一定的防护作用。让我们看看这在实践中是如何运作的。

第一步:模型训练

我们不是使用预训练模型,如在第三章深度学习基础中所做的那样,而是从零开始训练一个模型。我们使用与第三章深度学习基础中相同的训练和测试数据——有关如何加载数据集,请参见该章节。提醒一下,数据集是一个相对较小的猫狗数据集。我们首先定义我们的模型。我们使用类似 VGG 的架构,但在每个MaxPooling2D层之后加入了 dropout:


def conv_block(filters): 
return [ 
tf.keras.layers.Conv2D( 
filters, 
(33), 
activation="relu", 
kernel_initializer="he_uniform", 
), 
tf.keras.layers.MaxPooling2D((22)), 
tf.keras.layers.Dropout(0.5), 
] 

model = tf.keras.models.Sequential( 
[ 
tf.keras.layers.Conv2D( 
32, 
(33), 
activation="relu", 
input_shape=(1601603), 
kernel_initializer="he_uniform", 
), 
tf.keras.layers.MaxPooling2D((22)), 
tf.keras.layers.Dropout(0.2), 
*conv_block(64), 
*conv_block(128), 
*conv_block(256), 
*conv_block(128), 
tf.keras.layers.Conv2D( 
64, 
(33), 
activation="relu", 
kernel_initializer="he_uniform", 
), 
tf.keras.layers.Flatten(), 
tf.keras.layers.Dense(64, activation="relu"), 
tf.keras.layers.Dropout(0.5), 
tf.keras.layers.Dense(2), 
] 
) 

然后,我们对数据进行归一化,并编译和训练模型:


train_dataset_divprocessed = train_dataset.map(lambda x, y: (x / 255., y)) 
val_dataset_divprocessed = validation_dataset.map(lambda x, y: (x / 255., y)) 

model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), 
loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True), 
metrics=['accuracy']) 
model.fit( 
train_dataset_divprocessed, 
epochs=200, 
validation_data=val_dataset_divprocessed, 
)

这将使我们的模型准确率达到大约 85%。

第二步:运行推理并评估我们的标准模型

现在我们已经训练好了我们的模型,让我们看看它对抗对抗攻击的保护效果有多好。在第三章**深度学习基础中,我们从头开始创建了一个对抗攻击。在本章中,我们将使用cleverhans库来为多个图像一次性创建相同的攻击:


from cleverhans.tf2.attacks.fast_gradient_method import ( 
fast_gradient_method as fgsm, 
)

首先,让我们衡量我们确定性模型在原始图像和对抗图像上的准确率:


Predictions_standard, predictions_fgsm, labels = [], [], [] 
for imgs, labels_batch in test_dataset: 
imgs /= 255\. 
predictions_standard.extend(model.predict(imgs)) 
imgs_adv = fgsm(model, imgs, 0.01, np.inf) 
predictions_fgsm.extend(model.predict(imgs_adv)) 
  labels.extend(labels_batch)

现在我们有了我们的预测结果,我们可以打印出准确率:


accuracy_standard = CategoricalAccuracy()( 
labels, predictions_standard 
).numpy() 
accuracy_fgsm = CategoricalAccuracy()( 
labels, predictions_fgsm 
).numpy() 
print(f"{accuracy_standard=.2%}{accuracy_fsgm=:.2%}") 
# accuracy_standard=83.67%, accuracy_fsgm=30.70%

我们可以看到,我们的标准模型对这种对抗攻击几乎没有提供任何保护。尽管它在标准图像上的表现相当不错,但它在对抗图像上的准确率仅为 30.70%!让我们看看一个贝叶斯模型能否做得更好。因为我们训练了带 dropout 的模型,我们可以很容易地将其转变为 MC dropout 模型。我们创建一个推理函数,在推理过程中保持 dropout,如training=True参数所示:


import numpy as np 

def mc_dropout(model, images, n_inference: int = 50): 
return np.swapaxes(np.stack([ 
model(images, training=Truefor _ in range(n_inference) 
  ]), 01)

有了这个函数,我们可以用 MC dropout 推理替代标准的循环。我们再次跟踪所有的预测,并对标准图像和对抗图像进行推理:


Predictions_standard_mc, predictions_fgsm_mc, labels = [], [], [] 
for imgs, labels_batch in test_dataset: 
imgs /= 255\. 
predictions_standard_mc.extend( 
mc_dropout(model, imgs, 50) 
) 
imgs_adv = fgsm(model, imgs, 0.01, np.inf) 
predictions_fgsm_mc.extend( 
mc_dropout(model, imgs_adv, 50) 
) 
  labels.extend(labels_batch)

我们可以再次打印出我们的准确率:


accuracy_standard_mc = CategoricalAccuracy()( 
labels, np.stack(predictions_standard_mc).mean(axis=1) 
).numpy() 
accuracy_fgsm_mc = CategoricalAccuracy()( 
labels, np.stack(predictions_fgsm_mc).mean(axis=1) 
).numpy() 
print(f"{accuracy_standard_mc=.2%}{accuracy_fgsm_mc=:.2%}") 
# accuracy_standard_mc=86.60%, accuracy_fgsm_mc=80.75%

我们可以看到,简单的修改使得模型设置在面对对抗样本时更加稳健。准确率从约 30%提高到了 80%以上,接近于确定性模型在未扰动图像上的 83%的准确率。此外,我们还可以看到,MC dropout 也使得我们的标准图像准确率提高了几个百分点,从 83%提升到了 86%。几乎没有任何方法能够完美地对抗对抗样本,因此能够接近我们模型在标准图像上的准确率是一个伟大的成就。

因为我们的模型之前没有见过对抗图像,所以一个具有良好不确定性值的模型应该在对抗图像上相对于标准模型表现出更低的平均信心。让我们看看是否是这样。我们创建一个函数来计算我们确定性模型预测的平均 softmax 值,并为 MC dropout 预测创建一个类似的函数:


def get_mean_softmax_value(predictions) -*>* float: 
mean_softmax = tf.nn.softmax(predictions, axis=1) 
max_softmax = np.max(mean_softmax, axis=1) 
mean_max_softmax = max_softmax.mean() 
return mean_max_softmax 

def get_mean_softmax_value_mc(predictions) -*>* float: 
predictions_np = np.stack(predictions) 
predictions_np_mean = predictions_np.mean(axis=1) 
  return get_mean_softmax_value(predictions_np_mean)

然后,我们可以打印出两个模型的平均 softmax 分数:


mean_standard = get_mean_softmax_value(predictions_standard) 
mean_fgsm = get_mean_softmax_value(predictions_fgsm) 
mean_standard_mc = get_mean_softmax_value_mc(predictions_standard_mc) 
mean_fgsm_mc = get_mean_softmax_value_mc(predictions_fgsm_mc) 
print(f"{mean_standard=:.2%}{mean_fgsm=:.2%}") 
print(f"{mean_standard_mc=:.2%}{mean_fgsm_mc=:.2%}") 
# mean_standard=89.58%, mean_fgsm=89.91% 
# mean_standard_mc=89.48%, mean_fgsm_mc=85.25%

我们可以看到,与标准图像相比,我们的标准模型在对抗图像上的信心实际上稍微更高,尽管准确率显著下降。然而,我们的 MC dropout 模型在对抗图像上的信心低于标准图像。虽然信心的下降幅度不大,但我们很高兴看到,尽管准确率保持合理,模型在对抗图像上的平均信心下降了。

8.7 总结

在本章中,我们通过五个不同的案例研究展示了现代 BDL 的各种应用。每个案例研究都使用了代码示例,突出了 BDL 在应对应用机器学习实践中的各种常见问题时的特定优势。首先,我们看到如何使用 BDL 在分类任务中检测分布外图像。接着,我们探讨了 BDL 方法如何用于使模型更加鲁棒,以应对数据集偏移,这是生产环境中一个非常常见的问题。然后,我们学习了 BDL 如何帮助我们选择最有信息量的数据点,以训练和更新我们的机器学习模型。接着,我们转向强化学习,看到 BDL 如何帮助强化学习代理实现更加谨慎的行为。最后,我们看到了 BDL 在面对对抗性攻击时的应用。

在下一章中,我们将通过回顾当前趋势和最新方法来展望 BDL 的未来。

8.8 进一步阅读

以下阅读清单将帮助你更好地理解我们在本章中涉及的一些主题:

  • 基准测试神经网络对常见损坏和 扰动的鲁棒性,Dan Hendrycks 和 Thomas Dietterich,2019 年:这篇论文介绍了图像质量扰动,以基准测试模型的鲁棒性,正如我们在鲁棒性案例研究中看到的那样。

  • 你能信任模型的不确定性吗?评估数据集偏移下的 预测不确定性,Yaniv Ovadia、Emily Fertig 等,2019 年:这篇比较论文使用图像质量扰动,在不同的严重程度下引入人工数据集偏移,并衡量不同的深度神经网络在准确性和校准方面如何响应数据集偏移。

  • 用于检测神经网络中误分类和分布外 样本的基准,Dan Hendrycks 和 Kevin Gimpel,2016 年:这篇基础性的分布外检测论文介绍了该概念,并表明当涉及到分布外(OOD)检测时,softmax 值并不完美。

  • 提高神经网络中分布外图像检测的可靠性,Shiyu Liang、Yixuan Li 和 R. Srikant,2017 年:表明输入扰动和温度缩放可以改善用于分布外检测的 softmax 基准。

  • 用于检测分布外 样本和对抗性攻击的简单统一框架,Kimin Lee、Kibok Lee、Honglak Lee 和 Jinwoo Shin,2018 年:表明使用马哈拉诺比斯距离在分布外检测中可能是有效的。