Python-可解释的机器学习-全-

Python 可解释的机器学习(全)

原文:annas-archive.org/md5/9b99a159e8340372894ac9bde8bbd5d9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书的标题暗示了其核心主题:解释机器学习Python,其中第一个主题是最关键的。

那么,为什么解释如此重要呢?

可解释机器学习,通常被称为可解释人工智能XAI),包含了一系列不断增长的技术,帮助我们从模型中获取洞察力,旨在确保模型是安全、公平和可靠的——我相信这是我们所有人都对模型共同追求的目标。

随着人工智能超越传统软件甚至人类任务,机器学习模型被视为一种更高级的软件形式。虽然它们在二进制数据上运行,但它们并不典型;它们的逻辑不是由开发者明确编写的,而是从数据模式中产生的。这就是解释介入的地方,帮助我们理解这些模型,定位它们的错误,并在任何潜在的事故发生之前纠正它们。因此,解释对于在这些模型中培养信任和道德考量至关重要。值得注意的是,在不远的将来,模型训练可能会从编码转向更直观的拖放界面。在这种情况下,理解机器学习模型成为一项宝贵的技能。

目前,在数据预处理、探索、模型训练和部署过程中,仍然涉及大量的编码工作。尽管这本书中充满了 Python 示例,但它并不仅仅是一本与实际应用或更广泛图景脱节的编码指南。这本书的精髓在于,在可解释机器学习方面,优先考虑为什么(why)而不是如何(how),因为解释围绕着为什么这个问题。

因此,本书的大部分章节都是通过概述一个使命(即为什么)然后深入探讨方法论(即如何)来开始的。目标是使用章节中讨论的技术来实现这个使命,并强调理解结果。章节结束时,会思考从练习中获得的实际见解。

这本书的结构是渐进式的,从基础知识开始,逐步过渡到更复杂的话题。本书中使用的工具都是开源的,并且是微软、谷歌和 IBM 等领先研究机构的产品。尽管可解释性是一个庞大的研究领域,其中许多方面仍处于发展阶段,但这本书并不旨在涵盖所有内容。其主要目标是深入探讨一系列可解释性工具,对那些在机器学习领域工作的人来说大有裨益。

书籍的前几部分介绍了可解释性,强调了其在商业环境中的重要性,并讨论了其核心组件和挑战。接下来的部分提供了各种解释技术及其应用的详细概述,无论是用于分类、回归、表格数据、时间序列、图像还是文本。在最后一部分,读者将参与针对可解释性的模型调整和数据训练的实践练习,重点关注简化模型、解决偏差、设置约束和确保可靠性。

到本书结束时,读者将熟练使用可解释性技术来深入了解机器学习模型。

本书面向的对象

本书面向多样化的读者群体,包括:

  • 面临解释他们创建和管理的人工智能系统功能日益增长挑战的数据专业人士,并寻求提高它们的方法。

  • 致力于通过学习模型解释技术和策略来克服从公平性到鲁棒性的模型挑战的数据科学家和机器学习专业人士。

  • 对机器学习有基本了解并精通 Python 的潜在数据科学家。

  • 致力于深化他们对角色实际方面的知识,以更有效地指导他们倡议的 AI 伦理官员。

  • 渴望将可解释的机器学习整合到他们的运营中,与公平、责任和透明度价值观保持一致的 AI 项目监督者和商业领袖。

本书涵盖的内容

第一章解释、可解释性和可解释性;以及为什么这一切都如此重要?介绍了机器学习解释和相关概念,如可解释性、可解释性、黑盒模型和透明度,为这些术语提供定义以避免歧义。然后,我们强调了机器学习可解释性对企业的价值。

第二章可解释性的关键概念,通过心血管疾病预测示例介绍了两个基本概念(特征重要性和决策区域)以及用于分类解释方法的最重要的分类法。我们还详细说明了哪些元素阻碍了机器学习可解释性,作为对未来的初步介绍。

第三章解释挑战,讨论了用于机器学习解释的传统方法,这些方法用于回归和分类,并以航班延误预测问题为例。然后,我们将检查这些传统方法的局限性,并解释“白盒”模型为什么本质上可解释,以及为什么我们并不总是可以使用白盒模型。为了回答这个问题,我们考虑了预测性能和模型可解释性之间的权衡。最后,我们将发现一些新的“玻璃盒”模型,这些模型试图不在这个权衡中妥协。

第四章全局模型无关解释方法,探讨了部分依赖图PDP)和基于博弈论的SHapley Additive exPlanationsSHAP),使用二手车定价回归模型,然后可视化条件边际分布累积局部效应ALE)图。

第五章局部模型无关解释方法,涵盖了局部解释方法,解释单个或一组预测。为此,本章介绍了如何利用 SHAP 和局部可解释模型无关解释LIME)通过巧克力评分示例进行局部解释,包括表格和文本数据。

第六章锚点和反事实解释,继续讨论局部模型解释,但仅限于分类问题。我们使用再犯风险预测示例来了解我们如何以人类可解释的方式解释不公平的预测。本章涵盖了锚点、反事实和假设-if-工具WIT)。

第七章可视化卷积神经网络,探讨了与卷积神经网络CNN)模型一起工作的解释方法,以垃圾分类器模型为例。一旦我们掌握了 CNN 如何通过激活来学习,我们将研究几种基于梯度的归因方法,如显著性图、Grad-CAM 和集成梯度,以调试类别归因。最后,我们将通过基于扰动的归因方法,如特征消除、遮挡敏感性、Shapley 值采样和 KernelSHAP,扩展我们的归因调试知识。

第八章解释 NLP Transformer,讨论了如何在餐厅评论情感分类 Transformer 模型中可视化注意力机制,随后解释了集成梯度归因,并探索了学习可解释性工具LIT)。

第九章多元预测和敏感性分析的解释方法,使用交通预测问题和长短期记忆LSTM)模型来展示如何使用集成梯度和 SHAP 来处理此类用例。最后,本章探讨了预测和不确定性是如何内在联系的,以及敏感性分析——一种旨在衡量模型输出相对于其输入的不确定性的方法。我们研究了两种方法:Morris 用于因素优先级排序和 Sobol 用于因素固定。

第十章可解释性特征选择和工程,通过一个具有挑战性的非营利性直接邮寄优化问题来回顾基于过滤器的特征选择方法,例如斯皮尔曼相关系数,并了解嵌入方法,例如 Lasso。然后,你将发现包装方法,如顺序特征选择和混合方法,如递归特征消除,以及更高级的方法,如遗传算法。最后,尽管特征工程通常在选择之前进行,但在尘埃落定之后探索特征工程仍有其价值。

第十一章偏差缓解和因果推断方法,通过信用卡违约问题来展示利用公平性指标和可视化来检测不希望的偏差。然后,本章探讨如何通过预处理方法,如重新加权和不偏见的移除器,以及后处理的均衡机会来减少它。然后,我们测试降低信用卡违约的处理方法,并利用因果模型来确定它们的平均处理效应ATE)和条件平均处理效应CATE)。最后,我们测试因果假设和估计的稳健性。

第十二章单调约束和模型调优以实现可解释性,继续从第七章中的再犯风险预测问题。我们将学习如何在数据侧通过特征工程设置护栏,以及在模型上的单调性和交互约束,以确保公平性,同时学习在存在多个目标时如何调整模型。

第十三章对抗鲁棒性,使用面部口罩检测问题来涵盖端到端对抗解决方案。对手可以通过多种方式故意破坏模型,我们专注于规避攻击,如 Carlini 和 Wagner 无穷范数和对抗补丁,并简要解释其他形式的攻击。我们解释了两种防御方法:空间平滑预处理和对抗训练。最后,我们演示了一种鲁棒性评估方法。

第十四章机器学习可解释性的未来是什么?,总结了在机器学习可解释性方法生态系统中的所学内容。然后,对接下来可能发生的事情进行推测!

为了充分利用这本书

  • 你需要一个带有 Python 3.9+的 Jupyter 环境。你可以做以下任何一项:

    • 通过Anaconda Navigator或使用pip从头开始在你的机器上安装。

    • 使用基于云的,例如Google ColaboratoryKaggle NotebooksAzure NotebooksAmazon Sagemaker

  • 如何开始操作的说明将相应地有所不同,所以我们强烈建议你在网上搜索设置它们的最新说明。

  • 关于安装书中使用的许多包的说明,请访问 GitHub 仓库,其中README.MD文件将包含更新的说明。鉴于包经常更改,我们预计这些说明会随着时间的推移而变化。我们还使用README.MD中详细说明的特定版本测试了代码,因此如果后续版本有任何问题,请安装特定版本。

  • 各个章节都有关于如何检查是否正确安装了正确包的说明。

  • 但根据Jupyter的设置方式,安装包可能最好通过命令行或使用conda来完成,所以我们建议你根据需要调整这些安装说明。

  • 如果你使用的是这本书的数字版,请自己输入代码或通过 GitHub 仓库(下一节中提供链接)获取代码。这样做可以帮助你避免与代码复制粘贴相关的任何潜在错误。

  • 如果你不是机器学习从业者或是一个初学者,最好按顺序阅读这本书,因为许多概念仅在早期章节中进行了详细解释。对于在机器学习方面有经验但不太熟悉可解释性的从业者,可以快速浏览前三章以获取必要的伦理背景和概念定义,以便理解其余内容,但其余章节应按顺序阅读。对于有可解释性基础的资深从业者,按任何顺序阅读本书都应没问题。

  • 关于代码,你可以边读边运行代码,也可以只为了理论而阅读书籍。但如果你打算运行代码,最好是以书籍为指导,帮助解释结果并加强你对理论的理解。

  • 在阅读本书时,思考你可以如何使用学到的工具,希望到书的结尾,你将受到启发,将新获得的知识付诸实践!

下载示例代码文件

本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/。如果代码有更新,它将在现有的 GitHub 仓库中更新。你还可以在README.MD文件中找到硬件和软件的要求列表。

我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。你可以从这里下载:packt.link/gbp/9781803235424

使用的约定

本书使用了几个文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter/X 用户名。例如:“接下来,让我们定义一个device变量,因为如果您有一个支持 CUDA 的 GPU 型号,推理将执行得更快。”

代码块设置如下:

def predict(self, dataset):
    self.model.eval() 
    device = torch.device("cuda" if torch.cuda.is_available()\
                          else "cpu")
    with torch.no_grad(): 
        loader = torch.utils.data.DataLoader(dataset, batch_size = 32) 

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

def predict(self, dataset):
    self.model.eval()
    **device** = torch.device("cuda" if torch.cuda.is_available()\
                          else "cpu")
    with torch.no_grad(): 
        loader = torch.utils.data.DataLoader(dataset, batch_size = 32) 

任何命令行输入或输出都应如下编写:

pip install torch 

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“已选择预测选项卡,此选项卡左侧有一个数据表,您可以在其中选择和固定单个数据点,以及一个左侧带有分类结果的面板。”

警告或重要提示看起来是这样的。

小贴士和技巧看起来是这样的。

联系我们

我们欢迎读者的反馈。

一般反馈:请将邮件发送至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com与我们联系。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告此错误。请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。

盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《使用 Python 2e 进行可解释机器学习》,我们很乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您购买的电子书是否与您选择的设备不兼容?

不要担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781803235424

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱

第一章:解释、可解释性和可解释性;这一切为什么都如此重要?

我们生活在一个规则和程序越来越多地由数据和算法治理的世界。

例如,有关于谁可以获得信用批准或获得保释的规则,以及哪些社交媒体帖子可能会被审查。还有确定哪种营销策略最有效以及哪些胸部 X 光特征可能诊断出肺炎阳性病例的程序。

我们期望如此,因为这并不是什么新鲜事!

但不久前,像这样的规则和程序通常会被硬编码到软件、教科书和纸质表格中,而人类是最终的决策者。通常,这完全取决于人类的判断。决策依赖于人类的判断,因为规则和程序是僵化的,因此并不总是适用。总有例外,所以需要人类来做出这些决策。

例如,如果你申请抵押贷款,你的批准取决于可接受且合理的信用历史。这些数据反过来会使用评分算法生成信用评分。然后,银行有规则来决定你想要的抵押贷款所需的评分是否足够好。你的贷款官员可以遵循它或不遵循。

现在,金融机构在数千个抵押贷款结果上训练模型,包含数十个变量。这些模型可以用来确定你违约抵押贷款的可能性,具有假设的高准确性。如果有一个贷款官员来盖章批准或拒绝,那就不再仅仅是指导方针,而是一个算法决策。它怎么会错呢?它怎么会对呢?它是如何以及为什么做出这个决定的?

请记住这个想法,因为在这本书的整个过程中,我们将学习这些问题的答案以及更多的问题!

机器学习模型解释使你能够理解决策背后的逻辑,并追溯逻辑背后的详细步骤。本章介绍了机器学习解释和相关概念,如可解释性、可解释性、黑盒模型和透明度。本章为这些术语提供了定义,以避免歧义,并支持机器学习可解释性的价值。这是我们将要讨论的主要主题:

  • 什么是机器学习解释?

  • 理解解释和可解释性之间的区别

  • 可解释性的商业案例

让我们开始吧!

技术要求

为了跟随本章的示例,你需要 Python 3,无论是运行在 Jupyter 环境中还是在你最喜欢的集成开发环境IDE)中,如 PyCharm、Atom、VSCode、PyDev 或 Idle。示例还需要pandassklearnmatplotlibscipyPython 库。

本章的代码位于这里:packt.link/Lzryo

什么是机器学习解释?

解释某物就是解释其含义。在机器学习的背景下,那“某物”是一个算法。更具体地说,那是一个数学算法,它接受输入数据并产生输出,就像任何公式一样。

让我们检查最基础的模型,即简单线性回归,如下公式所示:

图片

一旦拟合到数据,这个模型的意义在于!图片预测是x特征与β系数的加权总和。在这种情况下,只有一个x特征预测变量,而y变量通常被称为响应目标变量。简单的线性回归公式单独解释了在输入数据 x[1]上执行以产生输出!图片的转换。以下示例可以更详细地说明这一概念。

理解简单的体重预测模型

如果您访问加州大学维护的此网页wiki.stat.ucla.edu/socr/index.php/SOCR_Data_Dinov_020108_HeightsWeights,您可以找到一个链接下载包含 18 岁青少年体重和身高 25,000 条合成记录的数据集。我们不会使用整个数据集,而只使用网页本身上的样本表,其中包含 200 条记录。我们从网页上抓取表格,并将线性回归模型拟合到数据中。该模型使用身高来预测体重。

换句话说,x[1]= heighty= weight,因此线性回归模型的公式如下:

图片

您可以在此处找到此示例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/blob/main/01/WeightPrediction.ipynb

要运行这个示例,您需要安装以下库:

  • 使用pandas将表格加载到 DataFrame 中

  • 使用sklearnscikit-learn)来拟合线性回归模型并计算其误差

  • 使用matplotlib来可视化模型

  • 使用scipy来测试相关性

您应该首先加载所有这些库,如下所示:

import math
import requests
from bs4 import BeautifulSoup
import pandas as pd
from sklearn import linear_model
from sklearn.metrics import mean_absolute_error
import matplotlib.pyplot as plt
from scipy.stats import pearsonr 

一旦所有库都已加载,您可以使用pandas获取网页的内容,如下所示:

url = \
'http://wiki.stat.ucla.edu/socr/index.php/SOCR_Data_Dinov_020108_HeightsWeights'
page = requests.get(url)
height_weight_df = pd.read_html(url)[1][['Height(Inches)','Weight(Pounds)']] 

pandas可以将表格的原始超文本标记语言(HTML)内容转换为 DataFrame,我们将子集仅包含两个列。哇!我们现在有一个包含Heights(Inches)在一列和Weights(Pounds)在另一列的 DataFrame。

现在我们有了数据,我们必须将其转换,使其符合模型的规格。sklearn需要它作为(200,1)维度的numpy数组,因此我们首先提取Height(Inches)Weight(Pounds)pandas系列。然后,我们将它们转换为(200,)维度的numpy数组,最后将它们重塑为(200,1)维度。以下命令执行所有必要的转换操作:

x = height_weight_df['Height(Inches)'].values.reshape(num_records, 1)
y = height_weight_df['Weight(Pounds)'].values.reshape(num_records, 1) 

然后,我们初始化 scikit-learn 的LinearRegression模型,并使用训练数据对其进行fit操作,如下所示:

model = linear_model.LinearRegression().fit(x,y) 

要在 scikit-learn 中输出拟合的线性回归模型公式,你必须提取截距和系数。这是公式,它解释了它是如何进行预测的:

print("ŷ =" + str(model.intercept_[0]) + " + " +\
      str(model.coef_.T[0][0]) + " x₁") 

以下为输出结果:

ŷ = -106.02770644878132 + 3.432676129271629 x1 

这告诉我们,平均而言,每增加 1 磅体重,身高就会增加 3.4 英寸。

然而,解释模型的工作原理只是解释这个线性回归模型的一种方式,这只是故事的一方面。模型并不完美,因为训练数据中的实际结果和预测结果并不相同。它们之间的差异是误差残差

有许多方式可以理解模型中的误差。你可以使用如mean_absolute_error这样的误差函数来衡量预测值和实际值之间的偏差,如下面的代码片段所示:

y_pred = model.predict(x)
print(mean_absolute_error(y, y_pred)) 

7.8 的平均绝对误差意味着,平均而言,预测值与实际值相差 7.8 磅,但这可能并不直观或具有信息量。可视化线性回归模型可以让我们了解这些预测的准确性。

这可以通过使用matplotlib散点图并叠加线性模型(蓝色)和平均绝对误差(灰色中的两条平行带)来实现,如下面的代码片段所示:

plt.scatter(x, y, color='black')
plt.plot(x, y_pred, color='blue', linewidth=3)
plt.plot(x, y_pred + mae, color='lightgray')
plt.plot(x, y_pred - mae, color='lightgray') 
Figure 1.1 is what you get as the output:

图 1.1 – 基于身高的线性回归模型预测体重

图 1.1:基于身高的线性回归模型预测体重

如您从图 1.1中的图表中可以看出,实际值与预测值相差 20-25 磅的情况有很多。然而,平均绝对误差可能会让您误以为误差始终接近 8。这就是为什么可视化模型的误差以了解其分布是至关重要的。从这张图中,我们可以看出,没有明显的红旗突出这个分布,比如残差在某个身高范围内比其他范围更分散。由于它大致均匀分布,我们说它是同方差的。在线性回归的情况下,这是您应该测试的许多模型假设之一,包括线性、正态性、独立性和多重共线性(如果有多个特征)。这些假设确保您正在使用适合工作的正确模型。换句话说,身高和体重可以用线性关系来解释,从统计学的角度来看,这样做是个好主意。

使用这个模型,我们试图在x身高和y体重之间建立线性关系。这种关联被称为线性相关性。衡量这种关系强度的一种方法是用皮尔逊相关系数。这种统计方法通过将两个变量的协方差除以它们的标准差来衡量两个变量之间的关联。它是一个介于-1 和 1 之间的数字,其中数字越接近 0,关联越弱。如果数字是正的,则存在正相关,如果是负的,则存在负相关。在 Python 中,您可以使用scipy中的pearsonr函数计算皮尔逊相关系数,如下所示:

corr, pval = pearsonr(x[:,0], y[:,0])
print(corr) 

以下是其输出:

0.5568647346122992 

这个数字是正的,这并不令人惊讶,因为随着身高的增加,体重也倾向于增加,但它更接近于 1 而不是 0,这表明它具有很强的相关性。pearsonr函数产生的第二个数字是测试非相关性的p值。如果我们测试这个值是否小于 5%的阈值,我们就可以说有足够的证据证明这种相关性,如下所示:

print(pval < 0.05) 

它通过一个True确认其统计显著性。

理解模型在不同情况下如何表现可以帮助我们解释为什么它做出某些预测,以及当它不能时。让我们想象一下,我们被要求解释为什么一个身高 71 英寸的人被预测体重为 134 磅,但实际上重了 18 磅。根据我们对模型所知,这个误差范围并不罕见,尽管它并不理想。然而,有许多情况下我们无法期望这个模型是可靠的。如果我们被要求使用这个模型预测一个身高 56 英寸的人的体重呢?我们能否保证同样的准确性?绝对不能,因为我们是在身高不低于 63 英寸的受试者数据上拟合这个模型的。如果我们被要求预测一个 9 岁孩子的体重,情况也是如此,因为训练数据是为 18 岁的人准备的。

尽管结果是可以接受的,但这个重量预测模型并不是一个现实的例子。如果你想更准确——更重要的是,更忠实于真正影响个人体重的因素——你需要添加更多变量。你可以添加——比如说——出生性别、年龄、饮食和活动水平。这很有趣,因为你必须确保包括它们或排除它们是公平的。例如,如果包括性别,而我们的大部分数据集由男性组成,你如何确保对女性的准确性?这就是所谓的选择偏差。那么,如果体重更多地与生活方式选择和诸如贫困和怀孕等环境因素有关,而不是性别呢?如果这些变量没有被包括在内,这被称为遗漏变量偏差。那么,在冒着给模型增加偏差的风险下,包括敏感的性别变量是否合理?

一旦你验证了多个特征并排除了偏差,你就可以找出并解释哪些特征影响模型性能。我们称之为特征重要性。然而,随着我们添加更多变量,我们增加了模型的复杂性。矛盾的是,这给解释带来了问题,我们将在接下来的章节中进一步探讨。现在,关键要点应该是模型解释与以下内容有很大关系:

  • 我们能否解释预测是如何做出的,以及模型是如何工作的?

  • 我们能否确保它们是可靠和安全的?

  • 我们能否解释预测是在没有偏差的情况下做出的?

最终,我们试图回答的问题是:

我们能否相信模型?

可解释机器学习的三个主要概念直接关联到前三个问题,并具有FAT的缩写,代表公平性可问责性透明度。如果你能解释预测是在没有可识别偏见的情况下做出的,那么就存在公平性。如果你能解释为什么它做出某些预测,那么就有可问责性。如果你能解释预测是如何做出的以及模型是如何工作的,那么就有透明度。这些概念与许多伦理问题相关,如图 1.2 所示。它被描绘成一个三角形,因为每一层都依赖于前一层。

图 1.2 – 可解释机器学习的三个主要概念

图 1.2:可解释机器学习的三个主要概念

一些研究人员和公司已经将 FAT 扩展到更广泛的伦理人工智能范畴之下,从而将 FAT 转变为 FATE。然而,这两个概念在很大程度上是重叠的,因为可解释机器学习是实现 FAT 原则和伦理关注点在机器学习中的实施方式。在这本书中,我们将讨论这个背景下的伦理问题。例如,第十三章对抗鲁棒性,讨论了可靠性、安全性和安全性。第十一章偏差缓解和因果推理方法,讨论了公平性。尽管如此,可解释机器学习可以在没有伦理目标的情况下被利用,也可以出于不道德的原因。

理解可解释性和可解释性之间的区别

当你阅读这本书的前几页时,你可能已经注意到了,动词interpretexplain,以及名词interpretationexplanation,已经被交替使用。考虑到解释就是解释某物含义,这并不奇怪。尽管如此,相关的术语interpretabilityexplainability不应互换使用,尽管它们经常被误认为是同义词。大多数从业者不做任何区分,许多学者甚至颠倒了本书中提供的定义。

什么是可解释性?

可解释性是指人类(包括非领域专家)理解机器学习模型因果关系和输入输出的程度。说一个模型具有高度的可解释性意味着你可以用人类可理解的方式描述其推理。换句话说,为什么一个模型的输入会产生特定的输出?输入数据的要求和约束是什么?预测的置信区间是什么?或者,为什么一个变量对预测的影响比另一个变量更大?对于可解释性来说,详细说明模型的工作原理只与其能够解释其预测并证明它是用例的正确模型相关。

在本章的例子中,我们可以解释说,人类身高和体重之间存在线性关系,因此使用线性回归而不是非线性模型是有意义的。我们可以通过统计来证明这一点,因为涉及的变量没有违反线性回归的假设。即使统计数据支持你的解释,我们仍然应该咨询涉及用例的领域知识。在这种情况下,我们可以放心,从生物学的角度来看,因为我们对人类生理学的了解并不与身高和体重之间的关系相矛盾。

警惕复杂性

许多机器学习模型之所以难以理解,主要是因为模型内部运作或特定模型架构中涉及的数学。除此之外,从数据集选择到特征选择和工程,再到模型训练和调整的选择,许多决策都会增加复杂性,使模型的可解释性降低。这种复杂性使得解释机器学习模型的工作原理成为一个挑战。机器学习可解释性是一个非常活跃的研究领域,因此对其精确定义的争论仍然很多。争论包括是否需要完全透明度才能使机器学习模型被视为足够可解释。

本书倾向于认为,可解释性的定义不应该必然排除不透明模型,只要所做的选择不会损害其可信度,这些模型在大多数情况下都是复杂的。这种妥协通常被称为事后可解释性。毕竟,就像复杂的机器学习模型一样,我们无法确切解释人类大脑是如何做出选择的,但我们经常信任其决策,因为我们可以向人类询问他们的推理。事后机器学习解释与此类似,只是它是人类代表模型解释推理。使用这种特定的可解释性概念是有利的,因为我们可以在不牺牲预测准确性的情况下解释不透明模型。我们将在第三章,解释挑战中进一步讨论这一点。

可解释性何时重要?

决策系统并不总是需要可解释性。有两种情况被视为例外,如下所述:

  • 当错误结果没有重大后果时。例如,如果一个机器学习模型被训练来查找和读取包裹上的邮政编码,偶尔读错,并将其发送到别处,那么歧视偏见的机会很小,误分类的成本相对较低。这种情况并不常见,不足以放大成本超过可接受的阈值。

  • 当有后果时,但这些后果已经在现实世界中得到了充分的研究和验证,以至于可以在没有人类参与的情况下做出决策。这种情况适用于交通警报和避撞系统TCAS),它会警告飞行员另一架飞机可能发生空中碰撞的威胁。

另一方面,为了使这些系统具备以下属性,可解释性是必要的:

  • 可挖掘科学知识:例如,气象学家可以从气候模型中学到很多,但前提是模型易于解释。

  • 可靠和安全:自动驾驶车辆所做的决策必须是可调试的,以便其开发者能够理解和纠正故障点。

  • 道德伦理:一个翻译模型可能会使用性别歧视的词嵌入,导致歧视性翻译,例如将医生与男性代词配对,但你必须能够轻松地找到这些实例并纠正它们。然而,系统必须设计得让你在将其发布给公众之前就能意识到问题。

  • 结论性和一致性:有时,机器学习模型可能具有不完整且相互排斥的目标——例如,胆固醇控制系统可能不会考虑患者遵守饮食或药物方案的可能性,或者可能存在一个目标与另一个目标之间的权衡,例如安全性和非歧视性。

通过解释模型的决策,我们可以填补我们对问题理解上的空白——其不完整性。最显著的问题之一是,鉴于我们机器学习解决方案的高准确性,我们往往会提高我们的信心水平,以至于我们认为我们完全理解了问题。然后,我们被误导,认为我们的解决方案涵盖了一切

在本书的开头,我们讨论了利用数据产生算法规则并不是什么新鲜事。然而,我们过去常常对这些规则进行猜测,而现在我们不再这样做。因此,过去通常是人为负责,而现在则是算法。在这种情况下,算法是一个机器学习模型,它对所有由此产生的伦理后果负责。这种转变与准确性有很大关系。问题是,尽管一个模型在总体上可能超越人类的准确性,但机器学习模型还没有像人类那样解释其结果。因此,它不会对其决策进行二次猜测,所以作为一个解决方案,它缺乏理想程度的完整性。这就是为什么我们需要解释模型,以便我们至少可以填补一些这个差距。那么,为什么机器学习解释还不是数据科学流程中的标准部分呢?除了我们只关注准确性的偏见之外,最大的障碍之一就是令人望而生畏的黑盒模型概念。

什么是黑盒模型?

这只是对不透明模型的一个术语。黑盒指的是一个系统中,只有输入和输出是可观察的,你不能理解是什么将输入转换成输出。在机器学习的情况下,黑盒模型可以打开,但其机制并不容易理解。

什么是白盒模型?

这些是与黑盒模型相反的模型(参见图 1.3)。它们也被称为透明,因为它们实现了完全或几乎完全的解释透明度。在这本书中,我们称它们为内在可解释的,并在第三章,解释挑战中更详细地介绍它们。

看一下这里模型的比较:

图 1.3 – 白盒和黑盒模型之间的视觉比较

图 1.3:白盒和黑盒模型之间的视觉比较

接下来,我们将检查区分白盒和黑盒模型的特征:可解释性。

什么是可解释性?

可解释性包括可解释性的所有内容。区别在于,它在透明度要求上比可解释性更深,因为它要求对模型内部工作方式和模型训练过程提供人类友好的解释,而不仅仅是模型推理。根据应用的不同,这一要求可能扩展到模型、设计和算法透明度的各种程度。这里概述了三种透明度类型:

  • 模型透明度:能够解释模型是如何一步一步训练的。在我们的简单权重预测模型的情况下,我们可以解释被称为普通最小二乘法的优化方法是如何找到最小化模型误差的β系数的。

  • 设计透明度:能够解释所做的选择,例如模型架构和超参数。例如,我们可以根据训练数据的大小或性质来证明这些选择是合理的。如果我们正在进行销售预测,并且我们知道我们的销售在一年中是季节性的,这可以是一个合理的参数选择。如果我们有疑问,我们总是可以使用一些成熟的统计方法来找到季节性模式。

  • 算法透明度:能够解释自动优化,如超参数的网格搜索,但请注意,由于它们的随机性质无法重现的优化(如超参数优化的随机搜索、早停和随机梯度下降)使得算法不透明。

黑盒模型被称为不透明,仅仅是因为它们缺乏模型透明度,但对于许多模型来说,这是不可避免的,无论模型选择多么合理。在许多情况下,即使你输出了例如训练神经网络或随机森林所涉及的数学,这也可能引起更多的怀疑而不是信任。这里至少有几个原因,概述如下:

  • “没有统计学依据”:不透明模型训练过程将输入映射到最佳输出,留下看似任意的参数轨迹。这些参数被优化到成本函数,但并未基于统计理论,这使得预测难以在统计术语中证明和解释。

  • 不确定性和不可重复性:许多不透明的模型同样可重复,因为它们使用随机数初始化它们的权重,正则化或优化它们的超参数,或者使用随机判别(例如,随机森林算法就是这样)。

  • 过拟合与维度灾难:许多这些模型在高维空间中运行。这不会引起信任,因为在更多维度上进行泛化更困难。毕竟,维度越多,过拟合模型的机会就越大。

  • 人类认知的局限性:透明模型通常用于具有较少维度的小数据集。它们也倾向于不会使这些维度之间的交互比必要的更复杂。这种缺乏复杂性使得可视化模型正在做什么及其结果变得更容易。人类在理解许多维度方面并不擅长,因此使用透明模型往往使这更容易理解。尽管如此,即使这些模型也可能变得非常复杂,以至于它们可能变得不透明。例如,如果一个决策树模型有 100 层深度,或者一个线性回归模型有 100 个特征,那么对于我们来说就不再容易理解了。

  • 奥卡姆剃刀:这就是所谓的简单性或简约性原则。它指出,最简单的解决方案通常是正确的。无论是否如此,人类也有对简单性的偏好,透明模型以——如果有什么的话——它们的简单性而闻名。

为什么以及何时可解释性很重要?

可信和道德的决策是可解释性的主要动机。可解释性还有因果性、可迁移性和信息性等其他动机。因此,在许多情况下,完全或几乎完全的透明度被重视,这是理所当然的。其中一些将在以下内容中概述:

  • 科学研究:可重复性对于科学研究至关重要。此外,当需要建立因果关系时,使用基于统计学的优化方法特别可取。

  • 临床试验:这些试验也必须产生可重复的结果,并且有统计学依据。此外,考虑到过拟合的可能性,它们必须使用尽可能少的维度和不会使它们复杂化的模型。

  • 消费品安全测试:与临床试验一样,当生命和死亡安全成为问题时,尽可能选择简单性。

  • 公共政策和法律:这是一个更为微妙的讨论,因为它是法律学者所说的算法治理的一部分,他们区分了鱼缸透明度合理透明度。鱼缸透明度寻求完全可解释性,它更接近消费者产品安全测试所需的严谨性,而合理透明度则是指后验可解释性,如前所述。有一天,政府可能会完全由算法运行。当这种情况发生时,很难说哪些政策将与哪种透明度形式一致,但有许多公共政策领域,如刑事司法,绝对透明度是必要的。然而,当完全透明度与隐私或安全目标相矛盾时,可能更倾向于选择一种不那么严谨的透明度形式。

  • 犯罪调查和监管合规审计:如果发生意外,例如化工厂事故由机器人故障引起或自动驾驶车辆发生碰撞,调查人员将需要一个决策轨迹。这是为了便于分配责任和法律责任。即使没有发生事故,这种审计也可以在当局要求时进行。合规审计适用于受监管的行业,如金融服务、公用事业、交通和医疗保健。在许多情况下,鱼缸透明度更受欢迎。

可解释性的商业案例

本节描述了机器学习可解释性的几个实际商业好处,例如做出更好的决策,以及更受信任、更符合道德和更有利可图。

更好的决策

通常,机器学习模型在训练后会对期望的指标进行评估。如果它们在保留数据集上通过质量控制,就会被部署。然而,一旦在现实世界中测试,事情可能会变得很糟糕,如下面的假设场景所示:

  • 一个高频交易算法可能会单独引发股市崩溃。

  • 数百个智能家居设备可能会无缘无故地突然发出笑声,吓到用户。

  • 车牌识别系统可能会错误地读取一种新的车牌,并处罚错误的司机。

  • 一个存在种族歧视的监控系统可能会错误地检测到入侵者,因此保安会射杀无辜的办公室工作人员。

  • 一辆自动驾驶汽车可能会将雪误认为是路面,撞上悬崖,并伤害乘客。

任何系统都容易出错,所以这并不是说可解释性是万能的。然而,仅仅关注优化指标可能会招致灾难。在实验室中,模型可能表现良好,但如果你不知道模型做出决策的原因,那么你可能会错过改进的机会。例如,知道自动驾驶汽车识别为道路的“是什么”是不够的,但知道“为什么”可能有助于改进模型。如果,比如说,其中一个原因是道路颜色像雪一样浅,这可能很危险。检查模型的假设和结论可以通过将冬季道路图像引入数据集或向模型输入实时天气数据来提高模型。此外,如果这不起作用,也许算法安全机制可以阻止它做出不完全自信的决策。

专注于机器学习可解释性导致更好决策的一个主要原因是,在我们讨论完整性时已经提到过。如果我们认为模型是完整的,那么改进它的意义何在?此外,如果我们不质疑模型的推理,那么我们对问题的理解必须完整。如果是这样,也许我们一开始就不应该使用机器学习来解决这个问题!机器学习创建了一个算法,否则将太复杂而无法用“if-else”语句编程,正是为了用于我们理解问题不完整的情况!

事实证明,当我们预测或估计某事,尤其是以高精度时,我们认为自己控制了它。这就是所谓的控制错觉偏差。我们不能低估问题的复杂性,仅仅因为从整体上看,模型几乎总是正确的。即使是人类,雪和混凝土路面的区别也可能模糊且难以解释。你将如何开始描述这种区别,使其始终准确?模型可以学习这些区别,但这并不使它变得简单。检查模型的故障点并持续警惕异常需要不同的视角,即我们承认我们无法控制模型,但我们可以通过解释来尝试理解它。

以下是一些可能对模型产生不利影响的决策偏差,以及为什么可解释性可以导致更好的决策:

  • 保守主义偏差:当我们获得新信息时,我们不会改变我们之前的信念。这种偏差下,根深蒂固的先验信息会压倒新信息,但模型应该进化。因此,重视质疑先验假设的态度是一种健康的做法。

  • 显著性偏差:一些突出或更明显的事物可能比其他事物更引人注目,但从统计学的角度来看,它们应该得到与其他事物相同的关注。这种偏差可能会影响我们选择特征的方式,因此可解释性思维可以扩大我们对问题的理解,包括其他不太被察觉的特征。

  • 基本归因错误:这种偏差导致我们将结果归因于行为而不是环境,归因于性格而不是情况,归因于自然而不是养育。可解释性要求我们深入探索,寻找我们变量之间不太明显的关系,或者那些可能缺失的关系。

模型解释的一个关键好处是定位异常值。这些异常值可能是潜在的新收入来源或即将发生的责任。了解这一点可以帮助我们做好准备并相应地制定策略。

更受信任的品牌

信任被定义为对某物或某人的可靠性、能力或可信度的信念。在组织的背景下,信任是他们的声誉;在不可饶恕的公众舆论法庭上,只需要一次事故、争议或灾难,就可能失去公众的信心。这反过来又可能导致投资者信心下降。

让我们考虑一下波音在 737 MAX 灾难或 Facebook 在剑桥分析选举丑闻之后发生了什么。在这两种情况下,技术故障都笼罩在神秘之中,导致公众对技术产生了巨大的不信任。

这些例子大多是人们做出的决策。如果完全由机器学习模型做出决策,情况可能会变得更糟,因为很容易出错,而将责任推给模型。例如,如果你在 Facebook 动态中开始看到令人反感的材料,Facebook 可能会说这是因为它使用的是你的数据,比如你的评论和点赞,所以这实际上是你想看到的反映。这不是他们的错——是你的错。如果警方因为使用 PredPol 算法(一种预测犯罪发生地点和时间的算法)而针对我们的社区进行激进执法,他们可能会责怪算法。另一方面,该算法的制作者可能会责怪警方,因为软件是在他们的警察报告中训练的。这可能会产生一个潜在的麻烦的反馈循环,更不用说责任差距了。如果一些恶作剧者或黑客在高速公路上物理放置奇异的纹理网格(见arxiv.org/pdf/2101.06784.pdf),这可能会导致特斯拉自动驾驶汽车驶入错误的车道。这是特斯拉没有预料到这种可能性,还是黑客在他们的模型中扔进了一个“猴子 wrench”(比喻意外因素)的错?这被称为对抗性攻击,我们在第十三章对抗鲁棒性中讨论了这一点。

毫无疑问,机器学习可解释性的一个目标就是使模型在做出决策方面更加出色。但即使它们失败了,你也可以表明你已经尽力了。信任的丧失并不是因为失败本身,而是因为缺乏问责制,甚至在那些接受所有责备并不公平的情况下,一些问责制总比没有好。例如,在先前的例子集中,Facebook 可以寻找为什么攻击性材料出现得更频繁的原因,并承诺找到减少这种情况发生的方法,即使这意味着减少收入。PredPol 可以寻找其他潜在的犯罪率数据集,即使它们规模较小。他们还可以使用技术来减轻现有数据集中的偏差(这些内容在第十一章偏差缓解和因果推断方法中有所涉及)。特斯拉可以对其系统进行对抗性攻击的审计,即使这会延迟其汽车的发货。所有这些都是可解释性解决方案。一旦它们成为常规做法,它们不仅可以提高公众信任——无论是来自用户和客户,还可以提高内部利益相关者,如员工和投资者的信任。

在过去几年中,发生了许多公共关系 AI 失误。由于信任问题,许多由 AI 驱动的技术正在失去公众支持,这对那些从 AI 中获利的企业和可能从中受益的用户都是不利的。这在一定程度上需要国家或全球层面的法律框架,以及对于部署这些技术的组织,更多的问责制。

更具道德性

在道德方面,存在三种思想流派:功利主义者关注后果,义务论者关注责任,目的论者更感兴趣的是整体道德品质。因此,这意味着有不同方式来审视道德问题。例如,从他们那里可以吸取有用的教训。有些情况下,你希望产生尽可能多的“善”,尽管在过程中会产生一些伤害。在其他时候,道德界限必须被视为你不能跨越的沙线。而在其他时候,这是关于培养正义的品质,就像许多宗教所追求的那样。无论我们与哪种伦理学派一致,我们对它的理解都会随着时间的推移而演变,因为它反映了我们当前的价值。在这个时刻,在西方文化中,这些价值观包括以下内容:

  • 人类福祉

  • 所有权和财产

  • 隐私

  • 免于偏见

  • 通用可用性

  • 信任

  • 自主

  • 知情同意

  • 问责制

  • 礼貌

  • 环境可持续性

道德越轨是指你跨越了这些价值观试图维护的道德界限,无论是通过歧视某人还是污染他们的环境,无论这是否违法。当你面临导致越轨的选择时,道德困境就会发生,因此你必须在这两者之间做出选择。

机器学习与伦理相关联的第一个原因是技术和伦理困境有着内在联系的历史。

即使是人类最初广泛采用的工具也带来了进步,但也造成了伤害,例如事故、战争和失业。这并不是说技术总是坏的,而是我们缺乏远见来衡量和控制其长期后果。在人工智能的情况下,不清楚有害的长期影响是什么。我们可以预见的是,将会有大量工作机会的丧失,以及对我们数据中心供电的巨大能源需求,这可能会对环境造成压力。有人猜测人工智能可能会创造一个由算法运行的“算法统治”的监控国家,侵犯诸如隐私、自主权和所有权等价值观。一些读者可能会指出已经发生的此类例子。

第二个原因比第一个原因更为严重。这是因为预测建模是人类技术的第一次飞跃:机器学习是一种可以为我们做决策的技术,这些决策可以产生难以追踪的个人道德违规行为。这个问题在于,问责制对于道德至关重要,因为你必须知道谁应该为人类尊严、赎罪、了结或刑事起诉负责。然而,许多技术从一开始就存在问责制问题,因为道德责任通常在任何情况下都是共享的。例如,汽车事故的原因可能部分归咎于司机、机械师和汽车制造商。同样的事情也可能发生在机器学习模型上,但情况会更复杂。毕竟,模型的编程没有程序员,因为“编程”是从数据中学习的,而且模型可以从数据中学习到可能导致道德违规的事情。其中最重要的是以下偏见:

  • 样本偏差:当你的数据,即样本,不能准确代表环境,也称为总体

  • 排除偏差:当你省略了可能用数据解释关键现象的特征或群体

  • 偏见偏差:当刻板印象直接影响或间接影响你的数据

  • 测量偏差:当错误的测量扭曲了你的数据

可解释性在减轻偏见方面很有用,如第十一章中所述,偏见减轻和因果推断方法,或者甚至对正确的特征设置护栏,这些特征可能是偏见的一个来源。这在第十二章中有所涉及,单调约束和模型调优以实现可解释性。正如本章所解释的,解释对于建立问责制至关重要,这是一个道德 imperative。此外,通过解释模型背后的推理,你可以在它们造成任何伤害之前发现道德问题。但还有更多方法可以控制模型潜在的令人担忧的道德后果,这与可解释性关系不大,而更多与设计有关。有如以人为中心的设计、价值观敏感的设计技术道德美德伦理等框架,可以将道德考虑纳入每个技术设计选择中。Kirsten Martin 的一篇文章(doi.org/10.1007/s10551-018-3921-3)也提出了一种针对算法的具体框架。本书不会过多深入算法设计方面,但对于对更广泛的伦理人工智能领域感兴趣的读者来说,这篇文章是一个很好的起点。

组织应认真对待算法决策的伦理问题,因为道德违规有货币和声誉成本。但更重要的是,如果人工智能被放任自流,可能会破坏维持民主和经济、使企业能够繁荣发展的价值观。

更具盈利性

如本节中已看到的,可解释性提高了算法决策,增强了信任并减轻了道德违规。

当我们利用以前未知的机会,并通过更好的决策减轻威胁,如意外故障,我们很可能会提高底线;如果我们增加对人工智能技术的信任,我们很可能会增加其使用并提升整体品牌声誉,这对利润也有积极影响。另一方面,道德违规可能是有意为之或意外发生,一旦被发现,它们会对利润和声誉产生不利影响。

当企业将可解释性融入其机器学习工作流程时,这是一个良性循环,并导致更高的盈利能力。在非营利组织或政府的情况下,利润可能不是动机。然而,财务无疑是涉及的,因为诉讼、糟糕的决策和声誉受损都是昂贵的。最终,技术进步不仅取决于使其成为可能的工程和科学技能和材料,还取决于公众的自愿采用。

摘要

本章向我们展示了机器学习解释是什么,不是什么,以及可解释性的重要性。在下一章中,我们将学习是什么使得机器学习模型如此难以解释,以及如何对解释方法进行类别和范围的分类。

图片来源

数据集来源

  • 南加州大学在线计算资源,统计学。 (1993). 从母婴健康中心招募的 25,000 名 0 至 18 岁儿童的生长调查。最初从 www.socr.ucla.edu/ 获取。

进一步阅读

  • Lipton, Zachary (2017). 模型可解释性的神话. ICML 2016 机器学习中的可解释性研讨会: doi.org/10.1145/3236386.3241340

  • Roscher, R., Bohn, B., Duarte, M.F. & Garcke, J. (2020). 可解释机器学习在科学洞察和发现中的应用. IEEE Access, 8, 42200-42216: dx.doi.org/10.1109/ACCESS.2020.2976199

  • Doshi-Velez, F. & Kim, B. (2017). 迈向可解释机器学习的严谨科学. arxiv.org/abs/1702.08608

  • Arrieta, A.B., Diaz-Rodriguez, N., Ser, J.D., Bennetot, A., Tabik, S., Barbado, A., Garc’ia, S., Gil-L’opez, S., Molina, D., Benjamins, R., Chatila, R., & Herrera, F. (2020). 可解释人工智能(XAI):概念、分类、机遇与挑战,迈向负责任的 AI: arxiv.org/abs/1910.10045

  • Coglianese, C. & Lehr, D. (2019). 透明度与算法治理. 行政法评论, 71, 1-4: ssrn.com/abstract=3293008

  • Weller, Adrian. (2019) 透明度:动机与挑战. arXiv:1708.01870 [Cs]: arxiv.org/abs/1708.01870

在 Discord 上了解更多信息

要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

第二章:可解释性的关键概念

本书涵盖了多种模型解释方法。有些产生指标,有些创建可视化,有些两者都有;有些广泛描述模型,有些则细致描述。在本章中,我们将学习两种方法:特征重要性和决策区域,以及用于描述这些方法的分类法。我们还将详细说明阻碍机器学习可解释性的因素,作为对接下来内容的入门。

以下是我们将在本章中讨论的主要主题:

  • 了解解释方法类型和范围

  • 欣赏阻碍机器学习可解释性的因素

让我们从我们的技术要求开始。

技术要求

尽管我们以一个“玩具示例”开始了本书,但我们将利用本书中的真实数据集,用于特定的解释用例。这些数据来自许多不同的来源,并且通常只使用一次。

为了避免这种情况,读者需要花费大量时间下载、加载和准备数据集以供单个示例使用;有一个名为 mldatasets 的库可以处理大部分这些工作。关于如何安装此库的说明位于序言中。除了 mldatasets,本章的示例还使用了 pandasnumpystatsmodelsklearnseabornmatplotlib 库。

本章的代码位于此处:packt.link/DgnVj

任务

想象你是一名国家卫生部的分析师,那里爆发了一场心血管疾病CVDs)疫情。部长已将其列为优先事项,以扭转增长趋势并降低病例数至 20 年来的最低水平。为此,已成立一个特别工作组,以在数据中寻找线索,以确定以下内容:

  • 可以解决哪些风险因素。

  • 如果可以预测未来的案例,则可以逐个案例解释预测。

你是这支任务小组的一员!

关于心血管疾病的详细信息

在我们深入数据之前,我们必须收集一些关于心血管疾病的重要细节,以便完成以下工作:

  • 理解问题的背景和相关性。

  • 提取可以告知我们的数据分析及模型解释的领域知识信息。

  • 将专家背景与数据集的特征联系起来。

心血管疾病(CVDs)是一组疾病,其中最常见的是冠心病(也称为缺血性心脏病)。根据世界卫生组织的数据,心血管疾病是全球死亡的主要原因,每年导致近 1800 万人死亡。冠心病和中风(大部分是心血管疾病的副产品)是导致这一情况的最重要因素。据估计,80%的心血管疾病是由可改变的风险因素引起的。换句话说,一些可以预防导致心血管疾病的风险因素包括以下内容:

  • 饮食不良

  • 吸烟和饮酒习惯

  • 肥胖

  • 缺乏体育锻炼

  • 睡眠质量差

此外,许多风险因素是不可改变的,因此被认为是不可避免的,包括以下内容:

  • 遗传易感性

  • 老龄

  • 男性(随年龄变化)

我们不会深入探讨更多关于 CVD 的特定领域细节,因为这不是理解示例所必需的。然而,强调领域知识对于模型解释的重要性是至关重要的。因此,如果这个例子是你的工作,并且许多生命依赖于你的分析,那么阅读有关该主题的最新科学研究和咨询领域专家来指导你的解释是明智的。

方法

逻辑回归是医学用例中排名风险因素的常见方法。与线性回归不同,它不试图预测每个观察值的一个连续值,而是预测一个概率分数,表示观察值属于特定类别的概率。在这种情况下,我们试图预测的是,给定每个患者的 x 数据,他们患有 CVD 的 y 概率是多少,范围从 0 到 1?

准备工作

我们将在这里找到这个例子的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/tree/main/02/CVD.ipynb

加载库

要运行此示例,我们需要安装以下库:

  • 使用 mldatasets 加载数据集

  • pandasnumpy 来操作它

  • 使用 statsmodels 来拟合逻辑回归模型

  • 使用 sklearn (scikit-learn) 来分割数据

  • 使用 matplotlibseaborn 来可视化解释

我们首先应该加载所有这些:

import math
import mldatasets
import pandas as pd
import numpy as np
import statsmodels.api as sm
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
import seaborn as sns 

理解和准备数据

在此示例中使用的此数据应加载到我们称为 cvd_df 的 DataFrame 中:

cvd_df = mldatasets.load("cardiovascular-disease") 

从这个例子中,我们应该得到 70,000 条记录和 12 列。我们可以通过 info() 函数查看加载了什么:

cvd_df.info() 

前面的命令将输出每列的名称、其类型以及包含的非空记录数:

RangeIndex: 70000 entries, 0 to 69999
Data columns (total 12 columns):
age            70000 non-null int64
gender         70000 non-null int64
height         70000 non-null int64
weight         70000 non-null float64
ap_hi          70000 non-null int64
ap_lo          70000 non-null int64
cholesterol    70000 non-null int64
gluc           70000 non-null int64
smoke          70000 non-null int64
alco           70000 non-null int64
active         70000 non-null int64
cardio         70000 non-null int64
dtypes: float64(1), int64(11) 

数据字典

要了解加载的内容,以下是根据源描述的数据字典:

  • age:患者的天数(客观特征)

  • height:以厘米为单位(客观特征)

  • weight:以千克为单位(客观特征)

  • gender:一个二进制值,其中 1 表示女性,2 表示男性(客观特征)

  • ap_hi:收缩压,即在心室收缩时血液被喷射出的动脉压力。正常值:小于 120 毫米汞柱(客观特征)

  • ap_lo:舒张压,即心跳之间的动脉压力。正常值:小于 80 毫米汞柱(客观特征)

  • cholesterol:一个有序值,其中 1 表示正常,2 表示高于正常,3 表示远高于正常(客观特征)

  • gluc:一个有序值,其中 1 表示正常,2 表示高于正常,3 表示远高于正常(客观特征)

  • smoke:一个二进制值,其中 0 表示不吸烟者,1 表示吸烟者(主观特征)

  • alco:一个二进制值,其中 0 表示不饮酒者,1 表示饮酒者(主观特征)

  • active: 这是一个二进制值,其中 0 表示非活跃,1 表示活跃(主观特征)

  • cardio: 这是一个二进制值,其中 0 表示没有心血管疾病,1 表示有心血管疾病(客观和目标特征)

理解数据集的数据生成过程至关重要,这就是为什么特征被分为两类:

  • 客观: 是官方文件或临床检查的结果。由于文书或机器错误,预计其误差范围相当小。

  • 主观: 由患者报告且未经验证(或无法验证)。在这种情况下,由于记忆失误、理解差异或不诚实,预计其可靠性不如客观特征。

最后,信任模型通常意味着信任用于训练它的数据,因此患者关于吸烟的谎言程度可能会产生影响。

数据准备

为了可解释性和模型性能,我们可以执行几个数据准备任务,但目前最突出的是age。年龄不是我们通常按天数衡量的东西。实际上,对于像这样的健康相关预测,我们甚至可能希望将它们分入年龄组,因为观察到的个体出生年份群体之间的健康差异不如代际群体之间明显,尤其是在与其他特征(如生活方式差异)交叉制表时。目前,我们将所有年龄转换为年份:

cvd_df['age'] = cvd_df['age'] / 365.24 

结果是一个更易于理解的列,因为我们期望年龄值在 0 到 120 之间。我们使用了现有数据并对其进行了转换。这是一个特征工程的例子,即我们利用数据的领域知识来创建更好地代表我们问题的特征,从而提高我们的模型。我们将在第十一章偏差缓解和因果推断方法中进一步讨论这一点。只要这不会显著损害模型性能,仅仅为了使模型结果更可解释而进行特征工程是有价值的。实际上,它可能会提高预测性能。请注意,在年龄列上进行的特征工程没有损失数据,因为年份的小数值得到了保留。

现在我们将使用describe()方法查看每个特征的摘要统计信息:

cvd_df.describe(percentiles=[.01,.99]).transpose() 

图 2.1显示了前述代码输出的摘要统计信息。它包括 1%和 99%的分位数,这些分位数告诉我们每个特征的最高和最低值:

图片

图 2.1:数据集的摘要统计

图 2.1中,age值有效,因为它介于 29 至 65 岁之间,这并不罕见,但ap_hiap_lo存在一些异常的异常值。血压不能为负,最高记录值为370。保留这些异常值可能导致模型性能和可解释性变差。根据图 2.1,1%和 99%的分位数仍然显示正常范围内的值,因此大约有 2%的记录具有无效值。如果你进一步挖掘,你会发现这个比例更接近 1.8%。

incorrect_l = cvd_df[
    (cvd_df['ap_hi']>370)
    | (cvd_df['ap_hi']<=40)
    | (cvd_df['ap_lo'] > 370)
    | (cvd_df['ap_lo'] <= 40)
].index
print(len(incorrect_l) / cvd_df.shape[0]) 

我们有处理这些错误值的方法,但由于这些记录相对较少,并且我们缺乏领域专业知识来猜测它们是否被误输入(并相应地更正),我们将删除它们:

cvd_df.drop(incorrect_l, inplace=True) 

为了确保万无一失,我们应该确保ap_hi始终高于ap_lo,因此任何存在这种差异的记录也应被删除:

cvd_df = cvd_df[cvd_df['ap_hi'] >=\
                cvd_df['ap_lo']].reset_index(drop=True) 

现在,为了拟合逻辑回归模型,我们必须将所有客观、考试和主观特征组合在一起作为X,并将目标特征单独作为y。之后,我们将Xy分为训练和测试数据集,但请确保包括random_state以实现可重复性:

y = cvd_df['cardio']
X = cvd_df.drop(['cardio'], axis=1).copy()
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.15, random_state=9
) 

scikit-learn 的train_test_split函数将 15%的观测值放入测试数据集,其余的放入训练数据集,因此你最终会得到Xy对。

现在我们已经准备好了用于训练的数据,让我们训练一个模型并对其进行解释。

解释方法类型和范围

现在我们已经准备好了数据,并将其分为训练/测试数据集,我们可以使用训练数据来拟合模型,并打印结果摘要:

log_model = sm.Logit(y_train, sm.add_constant(X_train))
log_result = log_model.fit()
print(log_result.summary2()) 

在拟合的模型上打印summary2会产生以下输出:

Optimization terminated successfully.
         Current function value: 0.561557
         Iterations 6
                         Results: Logit
=================================================================
Model:              Logit            Pseudo R-squared: 0.190     
Dependent Variable: cardio           AIC:              65618.3485
Date:               2020-06-10 09:10 BIC:              65726.0502
No. Observations:   58404            Log-Likelihood:   -32797\.   
Df Model:           11               LL-Null:          -40481\.   
Df Residuals:       58392            LLR p-value:      0.0000    
Converged:          1.0000           Scale:            1.0000    
No. Iterations:     6.0000                                       
-----------------------------------------------------------------
               Coef.   Std.Err.    z     P>|z|   [0.025   0.975]
-----------------------------------------------------------------
const         -11.1730   0.2504 -44.6182 0.0000 -11.6638 -10.6822
age             0.0510   0.0015  34.7971 0.0000   0.0482   0.0539
gender         -0.0227   0.0238  -0.9568 0.3387  -0.0693   0.0238
height         -0.0036   0.0014  -2.6028 0.0092  -0.0063  -0.0009
weight          0.0111   0.0007  14.8567 0.0000   0.0096   0.0125
ap_hi           0.0561   0.0010  56.2824 0.0000   0.0541   0.0580
ap_lo           0.0105   0.0016   6.7670 0.0000   0.0075   0.0136
cholesterol     0.4931   0.0169  29.1612 0.0000   0.4600   0.5262
gluc           -0.1155   0.0192  -6.0138 0.0000  -0.1532  -0.0779
smoke          -0.1306   0.0376  -3.4717 0.0005  -0.2043  -0.0569
alco           -0.2050   0.0457  -4.4907 0.0000  -0.2945  -0.1155
active         -0.2151   0.0237  -9.0574 0.0000  -0.2616  -0.1685
================================================================= 

上述摘要帮助我们理解哪些X特征对模型系数(在表中标记为Coef.)对y CVD 诊断的贡献最大。与线性回归类似,系数是应用于预测器的权重。然而,线性组合指数是一个逻辑函数。这使得解释变得更加困难。我们将在第三章解释挑战中进一步解释这个函数。

通过观察我们可以看出,具有绝对最高值的特征是cholesterolactive,但在理解这意味着什么方面并不直观。一旦我们计算了这些系数的指数,就会揭示一种更可解释的方式来查看这些值:

np.exp(log_result.params).sort_values(ascending=False) 

上述代码输出以下内容:

cholesterol    1.637374
ap_hi          1.057676
age            1.052357
weight         1.011129
ap_lo          1.010573
height         0.996389
gender         0.977519
gluc           0.890913
smoke          0.877576
alco           0.814627
active         0.806471
const          0.000014
dtype: float64 

为什么是指数函数?系数是对数概率,即概率的对数。概率是指正例发生的概率与负例发生的概率之比,其中正例是我们试图预测的标签。它并不一定表明任何人的偏好。例如,如果我们试图预测一个竞争对手今天赢得冠军的概率,正例就是他们赢了,无论我们是否支持他们。概率通常以比率的形式表示。新闻可能会说他们今天赢得比赛的概率是 60%,或者可能会说赔率是 3:2 或 3/2 = 1.5。在对数概率形式中,这将等于 0.176,即 1.5 的对数。它们基本上是同一件事,但表达方式不同。指数函数是对数函数的逆函数,因此它可以接受任何对数概率并返回概率,正如我们所做的那样。

回到我们的 CVD 案例。现在我们有了概率,我们可以解释它意味着什么。例如,胆固醇的情况下的概率意味着什么?这意味着,在所有其他特征保持不变的情况下,每增加一个单位的胆固醇,CVD 的概率会增加 1.64 倍。能够用如此具体的方式来解释一个特征对模型的影响,是像逻辑回归这样的内在可解释模型的一个优点。

虽然概率为我们提供了有用的信息,但它们并没有告诉我们什么最重要,因此,仅凭它们自身,不能用来衡量特征的重要性。但怎么会这样呢?如果某物的概率更高,那么它肯定更重要,对吧?好吧,首先,它们都有不同的尺度,这造成了巨大的差异。这是因为如果我们测量某物增加的概率,我们必须知道它通常增加多少,因为这提供了上下文。例如,我们可以说,在蝴蝶的第一批卵孵化后,这种特定蝴蝶多活一天的几率是 0.66。除非我们知道这个物种的寿命和繁殖周期,否则这个陈述是没有意义的。

为了为我们提供的概率提供上下文,我们可以轻松地使用np.std函数计算特征的方差:

np.std(X_train, 0) 

以下是由np.std函数输出的序列:

age             6.757537
gender          0.476697
height          8.186987
weight         14.335173
ap_hi          16.703572
ap_lo           9.547583
cholesterol     0.678878
gluc            0.571231
smoke           0.283629
alco            0.225483
active          0.397215
dtype: float64 

如我们从输出中可以看出,二元和有序特征通常最多只变化一次,但连续特征,如weightap_hi,可以变化 10-20 倍,正如特征的方差所证明的那样。

另一个原因是不可以用几率来衡量特征重要性,是因为尽管几率有利,但有时特征并不具有统计学意义。它们与其他特征纠缠在一起,以至于它们可能看起来很重要,但我们能证明它们并不重要。这可以在模型的摘要表中看到,在P>|z|列下。这个值被称为p 值,当它小于 0.05 时,我们拒绝系数等于零的零假设。换句话说,相应的特征具有统计学意义。然而,当它高于这个数值,尤其是大幅高于时,没有统计证据表明它会影响预测分数。至少在这个数据集中,性别就是这种情况。

如果我们试图获得最重要的特征,一种近似方法是将系数乘以特征的标准差。引入标准差考虑了特征之间方差的不同。因此,如果我们在这个过程中把性别排除在外会更好:

coefs = log_result.params.drop(labels=['const','gender'])
stdv = np.std(X_train, 0).drop(labels='gender')
abs(coefs * stdv).sort_values(ascending=False) 

前面的代码生成了以下输出:

ap_hi          0.936632
age            0.344855
cholesterol    0.334750
weight         0.158651
ap_lo          0.100419
active         0.085436
gluc           0.065982
alco           0.046230
smoke          0.037040
height         0.029620 

前面的表格可以解释为根据模型从高到低的风险因素近似。它也是一个特定模型特征重要性方法,换句话说,是一个全局模型模块化解释方法。这里有很多新的概念需要解释,所以让我们逐一分析。

模型可解释性方法类型

模型可解释性方法类型有两种:

  • 特定模型:当方法只能用于特定模型类别时,那么它就是特定模型的。前一个例子中详细说明的方法只能与逻辑回归一起工作,因为它使用了其系数。

  • 模型无关:这些是可以与任何模型类别一起工作的方法。我们在第四章全局模型无关解释方法以及接下来的两章中介绍了这些方法。

模型可解释性范围

模型可解释性的几个范围:

  • 全局整体解释:我们可以解释模型是如何做出预测的,因为我们能够一次性完全理解数据并理解整个模型,并且这是一个训练好的模型。例如,第一章中的简单线性回归示例,在解释、可解释性和可解释性;以及为什么这一切都很重要?中,可以在二维图中可视化。我们可以在记忆中构想这一点,但这仅因为模型的简单性允许我们这样做,而且这种情况并不常见,也不被期望。

  • 全局模块化解释:正如我们可以解释内燃机中部分在将燃料转化为运动的全过程中的作用一样,我们也可以用模型来做这样的解释。例如,在 CVD 风险因素示例中,我们的特征重要性方法告诉我们ap_hi(收缩压)、age(年龄)、cholesterol(胆固醇)和weight(体重)是影响整体最多的部分。特征重要性只是众多全局模块化解释方法中的一种,但可以说是最重要的方法之一。第四章全局模型无关解释方法,将更详细地介绍特征重要性。

  • 局部单预测解释:我们可以解释为什么做出了单个预测。下一个例子将说明这个概念,而第五章局部模型无关解释方法,将更详细地介绍。

  • 局部组预测解释:与单预测相同,但适用于预测组。

恭喜!你已经使用全局模型解释方法确定了风险因素,但卫生部长还想知道模型是否可以用来解释单个案例。所以,让我们来看看这一点。

使用逻辑回归解释单个预测

如果我们使用模型来预测整个测试数据集的 CVD,我们可以这样做:

y_pred = log_result.predict(sm.add_constant(X_test)).to_numpy()
print(y_pred) 

结果数组是每个测试用例对 CVD 呈阳性的概率:

[0.40629892 0.17003609 0.13405939 ... 0.95575283 0.94095239 0.91455717] 

让我们以一个阳性案例为例;测试用例 #2872:

print(y_pred[2872]) 

我们知道它预测 CVD 为阳性,因为得分超过了 0.5。

这是测试用例 #2872 的详细信息:

print(X_test.iloc[2872]) 

以下为输出结果:

age             60.521849
gender           1.000000
height         158.000000
weight          62.000000
ap_hi          130.000000
ap_lo           80.000000
cholesterol      1.000000
gluc             1.000000
smoke            0.000000
alco             0.000000
active           1.000000
Name: 46965, dtype: float64 

因此,根据前面的序列,我们知道以下适用于这个个体:

  • 临界高的ap_hi(收缩压),因为根据美国心脏协会AHA)的标准,任何等于或高于 130 的都是高的。

  • 正常的ap_lo(舒张压)也符合 AHA(美国心脏协会)的标准。收缩压高而舒张压正常的情况被称为孤立性收缩压。这可能导致预测结果为阳性,但ap_hi处于临界值;因此,孤立性收缩压的状态是临界性的。

  • age(年龄)不算太老,但在数据集中是最老的。

  • cholesterol(胆固醇)是正常的。

  • weight(体重)似乎也在健康范围内。

没有其他风险因素:葡萄糖正常,个体不吸烟也不喝酒,并且不采取久坐的生活方式,因为个体很活跃。不清楚为什么它是阳性的。年龄和临界性的孤立性收缩压是否足以使结果为阳性?没有将所有预测放入上下文中,很难理解预测的原因,所以让我们尝试这样做!

但我们如何同时将所有内容置于上下文中呢?我们不可能可视化每个特征及其相应的预测心血管疾病诊断与另外 10,000 个预测的比较。不幸的是,即使可能可视化一个十维的超平面,人类也无法处理这种维度的水平!

然而,我们可以一次处理两个特征,从而得到一个图形,显示模型对于这些特征的决策边界在哪里。在此基础上,我们还可以叠加基于所有特征的测试数据集的预测。这是为了可视化两个特征与所有其他 11 个特征之间的差异。

这种图形解释方法被称为决策边界。它为类别绘制边界,留下属于一个或另一个类别的区域。这些区域被称为决策区域。在这种情况下,我们有两个类别,所以我们将看到一个只有一个边界的图形,在cardio=0cardio=1之间,只涉及我们比较的两个特征。

我们已经能够一次可视化两个基于决策的特征,前提是如果所有其他特征都保持不变,我们只能单独观察两个。这也被称为其他条件不变假设,在科学研究中至关重要,它允许我们控制一些变量以观察其他变量。一种方法是使用我们生成的几率表,我们可以判断一个特征是否会增加,从而增加心血管疾病的几率。因此,总的来说,较低的价值对心血管疾病的风险较低。

例如,age=30是数据集中age的最小风险值。它也可以朝相反的方向发展,所以active=1已知比active=0风险更低。我们可以为剩余的特征找到最优值:

  • height=165

  • weight=57(对于那个height来说是最优的)。

  • ap_hi=110

  • ap_lo=70

  • smoke=0

  • cholesterol=1(这意味着正常)。

  • gender可以编码为男性或女性,这无关紧要,因为性别的几率(0.977519)非常接近 1。

以下filler_feature_values字典示例说明了如何将特征与其索引匹配到它们的最小风险值:

filler_feature_values = {
    "age": 30,
    "gender": 1,
    "height": 165,
    "weight": 57,
    "ap_hi": 110,
    "ap_lo": 70,
    "cholesterol": 1,
    "gluc": 1,
    "smoke": 0,
    "alco":0,
    "active":1
} 

接下来要做的是创建一个(1,12)形状的 NumPy 数组,用于测试案例#2872,以便绘图函数可以突出显示它。为此,我们首先将其转换为 NumPy 数组,然后在前面添加一个*constant*1,这必须是第一个特征,然后将其重塑以满足(1,12)维度。添加常量的原因是,在statsmodels中,我们必须明确定义截距。因此,逻辑模型有一个额外的0特征,它始终等于1

X_highlight = np.reshape(
    np.concatenate(([1], X_test.iloc[2872].to_numpy())), (1, 12))
print(X_highlight) 

以下是输出:

[[  1\.       60.52184865   1\.       158\.        62\.       130\.          
   80\.        1\.           1\.         0\.         0\.         1\.     ]] 

现在我们准备就绪了!让我们可视化一些决策区域图表!我们将比较被认为是最高的风险因素ap_hi与以下四个最重要的风险因素:agecholesterolweightap_lo

以下代码将生成图 2.2中的图表:

plt.rcParams.update({'font.size': 14})
fig, axarr = plt.subplots(2, 2, figsize=(12,8), sharex=True,
                          sharey=False)
mldatasets.create_decision_plot(
    X_test,
    y_test,
    log_result,
    ["ap_hi", "age"],
    None,
    X_highlight,
    filler_feature_values,
    ax=axarr.flat[0]
)
mldatasets.create_decision_plot(
    X_test,
    y_test,
    log_result,
    ["ap_hi", "cholesterol"],
     None,
    X_highlight,
    filler_feature_values,
    ax=axarr.flat[1]
)
mldatasets.create_decision_plot(
    X_test,
    y_test,
    log_result,
    ["ap_hi", "ap_lo"],
    None,
    X_highlight,
    filler_feature_values,
    ax=axarr.flat[2],
)
mldatasets.create_decision_plot(
    X_test,
    y_test,
    log_result,
    ["ap_hi", "weight"],
    None,
    X_highlight,
    filler_feature_values,
    ax=axarr.flat[3],
)
plt.subplots_adjust(top=1, bottom=0, hspace=0.2, wspace=0.2)
plt.show() 

图 2.2的图表中,圆圈代表测试案例#2872。在所有图表中,除了一个,这个测试案例位于负(左侧)决策区域,代表cardio=0分类。边缘高的ap_hi(收缩压)和相对较高的age在左上角的图表中几乎足以做出积极的预测。然而,在任何情况下,对于测试案例#2872,我们预测了 57%的 CVD 评分,这可以很好地解释大部分原因。

并非意外,根据模型,仅凭ap_hi和健康的胆固醇值不足以使 CVD 诊断偏向肯定,因为它明显位于负决策区域,正常的ap_lo(舒张压)也是如此。从这三个图表中可以看出,尽管正方形和三角形的分布存在一些重叠,但随着 y 轴的增加,三角形更有可能向正方向聚集,而正方形在这个区域中则较少:

日历描述自动生成

图 2.2:ap_hi 和其他顶级风险因素的决策区域,测试案例#2872

决策边界上的重叠是预期的,因为毕竟,这些正方形和三角形是基于所有特征的效果。尽管如此,您还是期望找到一个大致一致的图案。ap_hiweight的图表在weight增加时没有这种垂直模式,这表明这个故事中缺少了一些东西……保留这个想法,因为我们将在下一节中调查这一点!

恭喜!您已经完成了部长请求的第二部分。

决策区域绘图,一种局部模型解释方法,为卫生部门提供了一个解释个别案例预测的工具。现在您可以一次解释几个案例,或者绘制所有重要的特征组合,以找到圆点明显位于正决策区域的那些。您还可以逐个更改一些填充变量,看看它们如何产生影响。例如,如果您将填充年龄增加到中位数 54 岁,甚至增加到测试案例#2872 的年龄?那么边缘高的ap_hi和健康的胆固醇现在是否足以使天平倾斜?我们将在稍后回答这个问题,但首先,让我们了解是什么使得机器学习解释如此困难。

欣赏阻碍机器学习可解释性的因素

在最后一节中,我们想知道为什么ap_hiweight的图表没有明显的模式。完全有可能的是,尽管weight是一个风险因素,但还有其他关键的中介变量可以解释 CVD 增加的风险。一个中介变量是影响自变量和目标(因变量)之间强度的一个变量。我们可能不需要太费劲就能找到缺失的部分。在第一章解释,可解释性和可解释性;以及这一切为什么都重要?中,我们对weightheight进行了线性回归,因为这些变量之间存在线性关系。在人类健康的情况下,没有heightweight几乎没有任何意义,因此你需要查看两者。

也许如果我们绘制这两个变量的决策区域,我们会得到一些线索。我们可以用以下代码绘制它们:

fig, ax = plt.subplots(1,1, figsize=(12,8))
mldatasets.create_decision_plot(
    X_test,
    y_test,
    log_result,
    [3, 4],
    ['height [cm]',
    'weight [kg]'],
    X_highlight,
    filler_feature_values,
    filler_feature_ranges,
    ax=ax
)
plt.show() 
Figure 2.3:

图表,散点图  自动生成的描述

图 2.3:重量和高度的决策区域,测试案例#2872

图 2.3中没有确定决策边界,因为如果所有其他变量都保持不变(在较低风险值),没有heightweight的组合足以预测 CVD。然而,我们可以看出,橙色三角形有一个模式,主要位于一个椭圆形区域。这提供了令人兴奋的见解,即使我们预计当height增加时weight也会增加,但本质上不健康的weight值并不是与height线性增加的概念。

事实上,近两百年以来,这种关系已经通过名为体质指数BMI)的名称在数学上得到理解:

图表,散点图  自动生成的描述

在进一步讨论 BMI 之前,你必须考虑复杂性。除了维度之外,主要有三个因素引入了复杂性,使得解释变得困难:

  1. 非线性

  2. 交互性

  3. 非单调性

非线性

线性方程,例如 线性方程图,易于理解。它们是可加的,因此很容易将每个项(abx,和 cz)的效果从模型的结果(y)中分离和量化。许多模型类在数学中都包含了线性方程。这些方程既可以用来拟合数据到模型,也可以描述模型。

然而,有些模型类本质上是非线性的,因为它们在训练中引入了非线性。这种情况对于深度学习模型来说就是这样,因为它们具有非线性的激活函数,如sigmoid。然而,逻辑回归被认为是一个广义线性模型GLM),因为它具有可加性。换句话说,结果是加权输入和参数的总和。我们将在第三章解释挑战中进一步讨论 GLMs。

然而,即使你的模型是线性的,变量之间的关系可能不是线性的,这可能导致性能和可解释性差。在这些情况下,你可以采取以下两种方法之一:

  • 使用非线性模型类,这将更好地拟合这些非线性特征关系,可能提高模型性能。然而,正如我们将在下一章中更详细地探讨的那样,这可能会使模型的可解释性降低。

  • 利用领域知识构建一个有助于“线性化”的特征。例如,如果你有一个相对于另一个特征呈指数增长的特性,你可以通过该特性的对数构建一个新的变量。在我们的 CVD 预测中,我们知道 BMI 是理解身高伴随下的体重的一个更好的方式。最好的是,它不是一个任意编造的特征,因此更容易解释。我们可以通过复制数据集,在其中构建 BMI 特征,用这个额外特征训练模型,并执行局部模型解释来证明这一点。下面的代码片段正是如此:

    X2 = cvd_df.drop(['cardio'], axis=1).copy()
    X2["bmi"] = X2["weight"] / (X2["height"]/100)**2 
    

为了说明这个新特征,让我们使用以下代码绘制bmiweightheight的关系图:

fig, (ax1, ax2, ax3) = plt.subplots(1,3, figsize=(15,4))
sns.regplot(x="weight", y="bmi", data=X2, ax=ax1)
sns.regplot(x="height", y="bmi", data=X2, ax=ax2)
sns.regplot(x="height", y="weight", data=X2, ax=ax3)
plt.subplots_adjust(top = 1, bottom=0, hspace=0.2, wspace=0.3)
plt.show() 

图 2.4是用前面的代码生成的:

图 2.4:体重、身高和 BMI 的双变量比较

图 2.4中的图表所示,bmiweight之间的线性关系比heightweight之间的线性关系更明确,甚至比bmiheight之间的线性关系更明确。

让我们使用以下代码片段用额外特征拟合新模型:

X2 = X2.drop(['weight','height'], axis=1)
X2_train, X2_test,__,_ = train_test_split(
  X2, y, test_size=0.15, random_state=9)
log_model2 = sm.Logit(y_train, sm.add_constant(X2_train))
log_result2 = log_model2.fit() 

现在,让我们看看在保持age恒定为60的情况下,测试用例#2872 在比较ap_hibmi时是否位于正决策区域:

filler_feature_values2 = {
    "age": 60, "gender": 1, "ap_hi": 110,
    "ap_lo": 70, "cholesterol": 1, "gluc": 1,
    "smoke": 0, "alco":0, "active":1, "bmi":20 
}
X2_highlight = np.reshape(
    np.concatenate(([1],X2_test.iloc[2872].to_numpy())), (1, 11)
)
fig, ax = plt.subplots(1,1, figsize=(12,8))
mldatasets.create_decision_plot(
    X2_test, y_test, log_result2,
    ["ap_hi", "bmi"], None, X2_highlight,
    filler_feature_values2, ax=ax)
plt.show() 

前面的代码在图 2.5中绘制了决策区域:

图表,散点图  自动生成的描述

图 2.5:ap_hi 和 bmi 的决策区域,包含测试用例#2872

图 2.5显示,在控制ageap_hibmi的情况下,可以解释 CVD 的正预测,因为圆圈位于正决策区域。请注意,有一些可能是异常的bmi异常值(记录的最高 BMI 为 204),因此数据集中可能有一些错误的体重或身高。

异常值有什么问题?

异常值可以是有影响力的高杠杆的,因此当它们被包含在训练模型中时,会影响模型。即使它们不影响,它们也会使解释更加困难。如果它们是异常的,那么你应该移除它们,就像我们在本章开头处理血压那样。有时,它们可能隐藏在明显的地方,因为它们在其他特征的情况下才被视为异常。无论如何,异常值存在实际问题的原因,例如使前面的图表“放大”以适应它们,同时又不让你欣赏到重要的决策边界。还有更深刻的原因,例如对数据的信任度降低,从而损害了基于该数据训练的模型的信任度,或者使模型的表现更差。这种问题在现实世界的数据中是可以预见的。尽管为了方便我们没有在本章中这样做,但每个项目开始时彻底探索数据、处理缺失值和异常值以及执行其他数据整理任务是至关重要的。

交互性

当我们创建bmi时,我们不仅线性化了非线性关系,还创建了两个特征之间的交互。因此,bmi是一个交互特征,但这是由领域知识所决定的。然而,许多模型类通过在特征之间进行所有可能的操作排列来自动执行此操作。毕竟,特征之间存在着潜在的关系,就像heightwidth,以及ap_hiap_lo一样。因此,自动化寻找这些关系的流程并不总是坏事。事实上,这可能是绝对必要的。这在许多深度学习问题中是如此,因为数据是无结构的,因此训练模型的任务之一就是寻找潜在的关系来理解它。

然而,对于结构化数据,尽管交互对于模型性能可能很重要,但它们可能会通过向模型添加可能不必要的复杂性以及找到没有意义的潜在关系(这被称为虚假关系相关性)来损害可解释性。

非单调性

通常,一个变量与目标变量之间存在有意义的和一致的关系。因此,我们知道随着年龄的增长,心血管疾病(cardio)的风险必须增加。不存在达到某个年龄后风险下降的情况。风险可能放缓,但不会下降。我们称之为单调性,而单调函数在其整个定义域内要么始终增加,要么始终减少。

请注意,所有线性关系都是单调的,但并非所有单调关系都是必然线性的。这是因为它们不必是直线。机器学习中一个常见的问题是,模型不知道由于我们的领域专业知识,我们期望的单调关系。然后,由于数据中的噪声和遗漏,模型以这种方式训练,其中在不期望的地方有起伏。

让我们提出一个假设的例子。让我们想象,由于 57-60 岁年龄段数据不可用,以及我们拥有的这个范围的一些案例对 CVD 是负面的,模型可以学习到这正是你预期 CVD 风险下降的地方。一些模型类本质上是单调的,例如逻辑回归,因此它们不会出现这个问题,但许多其他模型类会出现。我们将在第十二章单调约束和模型调优以实现可解释性中更详细地研究这个问题:

图 2.6 – 目标变量(yhat)与单调和非单调模型预测因子之间的部分依赖图

图 2.6:目标变量(yhat)与单调和非单调模型预测因子之间的部分依赖图

图 2.6 被称为部分依赖图PDP),来自一个无关的示例。PDPs 是我们将在第四章全局模型无关解释方法中进一步详细研究的概念,但重要的是要理解的是,预测yhat应该随着特征quantity_indexes_for_real_gdp_by_state的增加而减少。正如线条所示,在单调模型中,它持续减少,但在非单调模型中,它在减少时出现锯齿状峰值,然后在最后又增加。

任务完成

任务的第一个部分是理解心血管疾病的风险因素,你已经确定根据逻辑回归模型,最高的四个风险因素是收缩压(ap_hi)、年龄胆固醇体重,其中只有年龄是不可改变的。然而,你也意识到收缩压(ap_hi)本身并不那么有意义,因为它依赖于舒张压(ap_lo)进行解释。同样适用于体重身高。我们了解到特征之间的相互作用在解释中起着至关重要的作用,它们与彼此以及目标变量的关系,无论是线性还是单调,也是如此。此外,数据只是对真相的一种表示,可能是错误的。毕竟,我们发现了一些异常,如果不加检查,可能会使我们的模型产生偏差。

另一个偏差来源是数据是如何收集的。毕竟,你可以质疑为什么模型的顶级特征都是客观和考试特征。为什么吸烟或饮酒不是一个更大的因素?为了验证是否存在样本 偏差,你需要与其他更可靠的数据库进行比较,以检查你的数据库是否未能充分代表吸烟者和饮酒者。或者,也许偏差是由询问他们现在是否吸烟的问题引入的,而不是他们是否曾经长时间吸烟。

我们可以解决的另一种类型的偏差是排除偏差——我们的数据可能缺少解释模型试图描绘的真相的信息。例如,通过医学研究我们知道,如孤立性收缩期高血压等问题,它增加了心血管疾病的风险,是由潜在条件如糖尿病、甲状腺功能亢进、动脉硬化和肥胖等引起的。我们只能从数据中推导出肥胖,而不是其他条件。如果我们想要能够很好地解释模型的预测,我们需要拥有所有相关特征。否则,将会有我们无法解释的差距。也许一旦我们添加了它们,它们不会产生太大的影响,但这正是我们将在第十章特征选择和可解释性工程中学习的方法。

任务的第二部分是能够解释个别模型的预测。我们可以通过绘制决策区域来做得足够好。这是一个简单的方法,但它有许多局限性,特别是在有超过几个特征,并且它们之间相互大量交互的情况下。第五章局部模型无关解释方法,和第六章锚点和反事实解释,将更详细地介绍局部解释方法。然而,决策区域图方法有助于说明决策边界周围的概念,这些概念将在那些章节中讨论。

摘要

在本章中,我们介绍了两种模型解释方法:特征重要性和决策边界。我们还了解了模型解释方法的类型和范围以及影响机器学习可解释性的三个要素。我们将在后续章节中继续提及这些基本概念。对于机器学习从业者来说,能够识别这些概念至关重要,这样我们就可以知道要利用哪些工具来克服解释挑战。在下一章中,我们将更深入地探讨这个主题。

进一步阅读

在 Discord 上了解更多信息

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

第三章:可解释性挑战

在本章中,我们将讨论用于机器学习解释的传统方法,包括回归和分类。这包括模型性能评估方法,如 RMSE、R-squared、AUC、ROC 曲线以及从混淆矩阵派生出的许多指标。然后,我们将检查这些性能指标的局限性,并解释“白盒”模型本质上可解释的原因以及为什么我们并不总是可以使用白盒模型。为了回答这些问题,我们将考虑预测性能和模型可解释性之间的权衡。最后,我们将发现一些新的“玻璃盒”模型,如可解释提升机EBMs)和 GAMI-Net,它们试图不在这两种性能之间做出妥协。

本章将涵盖以下主要主题:

  • 回顾传统的模型解释方法

  • 理解传统模型解释方法的局限性

  • 研究本质上可解释的(白盒)模型

  • 认识到性能和可解释性之间的权衡

  • 发现新的可解释(玻璃盒)模型

技术要求

第二章可解释性关键概念开始,我们使用自定义的mldatasets库来加载我们的数据集。有关如何安装此库的说明可以在前言中找到。除了mldatasets,本章的示例还使用了pandasnumpysklearnrulefitinterpretstatsmodelsmatplotlibgaminet库。

本章的代码位于此处:packt.link/swCyB

任务

想象一下,你是一位数据科学顾问,在 2019 年 1 月初的德克萨斯州沃斯堡的一个会议室里。在这个会议室里,世界上最大航空公司之一的美国航空公司AA)的行政人员正在向您介绍他们的准时性能OTP)。OTP 是衡量航班准时的一个广泛接受的关键绩效指标KPI)。它被定义为在预定到达时间前后 15 分钟内到达的航班百分比。结果发现,AA 连续 3 年实现了略高于 80%的 OTP,这是可以接受的,并且是一个显著的改进,但他们仍然在全球排名第九,在北美排名第五。为了在明年的广告中炫耀,他们渴望至少在 2019 年成为北美第一,超越他们最大的竞争对手。

在财务方面,预计延误将使航空公司损失近 20 亿美元,因此减少 25-35%以与竞争对手持平可以产生可观的节省。而且,由于数千万小时的损失,乘客的损失也大致相同。减少延误将导致更满意的客户,这可能导致机票销售额的增加。

您的任务是创建可以准确预测国内航班延误的模型。他们希望从这些模型中获得以下信息:

  • 了解哪些因素在 2018 年对国内到达延误影响最大

  • 为了在 2019 年足够准确地预测空中由航空公司造成的延误,以减轻这些因素中的一些

但并非所有延误都是平等的。国际航空运输协会IATA)有超过 80 个延误代码,从 14(超额预订错误)到 75(飞机除冰、清除冰雪、防霜)。有些是可以预防的,而有些则是不可避免的。

航空公司高管们告诉您,目前航空公司对预测由他们无法控制的事件(如极端天气、安全事件和空中交通管制问题)造成的延误不感兴趣。他们也不对由于使用同一架飞机的先前航班延误造成的延误感兴趣,因为这不是根本原因。尽管如此,他们仍然希望了解繁忙枢纽对可避免延误的影响,即使这与拥堵有关,因为毕竟,他们可能可以通过航班调度或航班速度,甚至登机口选择来做些什么。而且,虽然他们理解国际航班偶尔会影响国内航班,但他们希望首先解决庞大的本地市场。

高管们向您提供了美国交通部运输统计局的所有 2018 年 AA 国内航班的数据库。

方法

经过仔细考虑,您决定将此问题同时视为回归问题和分类问题。因此,您将创建预测延误分钟的模型以及分类航班是否延误超过 15 分钟的模型。为了解释,使用这两种方法将使您能够使用更广泛的方法,并相应地扩展解释。因此,我们将通过以下步骤来处理此示例:

  1. 使用各种回归方法预测延误的分钟数

  2. 使用各种分类方法将航班分类为延误或未延误

在“回顾传统模型解释方法”部分中,这些步骤后面是本章其余部分分散的结论。

准备工作

您可以在以下链接中找到此示例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/blob/main/03/FlightDelays.ipynb

加载库

要运行此示例,您需要安装以下库:

  • 使用mldatasets来加载数据集

  • pandasnumpy来操作它

  • sklearn(scikit-learn)、rulefitstatsmodelsinterprettfgaminet来拟合模型和计算性能指标

  • matplotlib来创建可视化

按照以下代码片段加载这些库:

import math
import mldatasets
import pandas as pd
import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler,\
                                  MinMaxScaler
from sklearn.model_selection import train_test_split
from sklearn import metrics, linear_model, tree, naive_bayes,\
                    neighbors, ensemble, neural_network, svm
from rulefit import RuleFit
import statsmodels.api as sm
from interpret.glassbox import ExplainableBoostingClassifier
from interpret import show
from interpret.perf import ROC
import tensorflow as tf
from gaminet import GAMINet
from gaminet.utils import plot_trajectory, plot_regularization,\
                    local_visualize, global_visualize_density,\
                    feature_importance_visualize
import matplotlib.pyplot as plt 

理解和准备数据

我们随后按如下方式加载数据:

aad18_df = mldatasets.load("aa-domestic-delays-2018") 

应该有近 90 万条记录和 23 列。我们可以这样查看加载的内容:

print(aad18_df.info()) 

以下为输出结果:

RangeIndex: 899527 entries, 0 to 899526
Data columns (total 23 columns):
FL_NUM                  899527 non-null int64
ORIGIN                  899527 non-null object
DEST                    899527 non-null object
PLANNED_DEP_DATETIME    899527 non-null object
CRS_DEP_TIME            899527 non-null int64
DEP_TIME                899527 non-null float64
DEP_DELAY               899527 non-null float64
DEP_AFPH                899527 non-null float64
DEP_RFPH                899527 non-null float64
TAXI_OUT                899527 non-null float64
WHEELS_OFF              899527 non-null float64
    :          :  :    :
WEATHER_DELAY           899527 non-null float64
NAS_DELAY               899527 non-null float64
SECURITY_DELAY          899527 non-null float64
LATE_AIRCRAFT_DELAY     899527 non-null float64
dtypes: float64(17), int64(3), object(3) 

一切似乎都井然有序,因为所有列都在那里,并且没有null值。

数据字典

让我们检查数据字典。

一般特征如下:

  • FL_NUM: 航班号。

  • ORIGIN: 起始机场代码(IATA)。

  • DEST: 目的地机场代码(IATA)。

离开特征如下:

  • PLANNED_DEP_DATETIME: 航班的计划日期和时间。

  • CRS_DEP_TIME: 计划离开时间。

  • DEP_TIME: 实际离开时间。

  • DEP_AFPH: 在计划出发和实际出发之间的间隔内实际每小时航班数(考虑了 30 分钟的填充时间)。该特征告诉你出发机场在起飞时的繁忙程度。

  • DEP_RFPH: 离开相对航班每小时数是实际每小时航班数与当天、星期和月份在出发机场发生的平均每小时航班数的比率。该特征告诉你出发机场在起飞时的相对繁忙程度。

  • TAXI_OUT: 从出发机场登机口出发到飞机轮子离地的持续时间。

  • WHEELS_OFF: 飞机轮子离地的时间点。

飞行中的特征如下:

  • CRS_ELAPSED_TIME: 飞行行程计划所需的时间。

  • PCT_ELAPSED_TIME: 实际飞行时间与计划飞行时间的比率,以衡量飞机的相对速度。

  • DISTANCE: 两个机场之间的距离。

到达特征如下:

  • CRS_ARR_TIME: 计划到达时间。

  • ARR_AFPH: 在计划到达和实际到达时间之间的间隔内实际每小时航班数(考虑了 30 分钟的填充时间)。该特征告诉你目的地机场在着陆时的繁忙程度。

  • ARR_RFPH: 到达相对航班每小时数是实际每小时航班数与当天、星期和月份在该目的地机场发生的平均每小时航班数的比率。该特征告诉你目的地机场在着陆时的相对繁忙程度。

延误特征如下:

  • DEP_DELAY: 离开延误的总分钟数。

  • ARR_DELAY: 到达延误的总分钟数可以细分为以下任何一个或所有:

    1. CARRIER_DELAY: 由航空公司控制因素(例如,维护或机组人员问题、飞机清洁、行李装载、加油等)造成的延误分钟数。

    2. WEATHER_DELAY: 由重大气象条件(实际或预报)造成的延误分钟数。

    3. NAS_DELAY:由国家航空系统(如非极端天气条件、机场运营、交通量过大和空中交通管制)规定的延误分钟数。

    4. SECURITY_DELAY:由于疏散机场或候机楼、因安全漏洞重新登机、检查设备故障或安检区域超过 29 分钟的长时间排队等原因造成的延误分钟数。

    5. LATE_AIRCRAFT_DELAY:由于同一架飞机的先前航班延误而造成的延误分钟数。

数据准备

首先,PLANNED_DEP_DATETIME必须是日期时间数据类型:

aad18_df['PLANNED_DEP_DATETIME'] =\
pd.to_datetime(aad18_df['PLANNED_DEP_DATETIME']) 

飞行的确切日期和时间并不重要,但也许月份和星期几很重要,因为天气和季节性模式只能在这个粒度级别上得到欣赏。此外,提到的管理人员表示周末和冬季的延误尤其严重。因此,我们将为月份和星期几创建特征:

aad18_df['DEP_MONTH'] = aad18_df['PLANNED_DEP_DATETIME'].dt.month
aad18_df['DEP_DOW'] = aad18_df['PLANNED_DEP_DATETIME'].dt.dayofweek 

我们不需要PLANNED_DEP_DATETIME列,所以让我们像这样删除它:

aad18_df = aad18_df.**drop**(['PLANNED_DEP_DATETIME'], axis=1) 

记录到达或目的地机场是否为枢纽机场是至关重要的。美国航空公司(AA)在 2019 年有 10 个枢纽机场:夏洛特、芝加哥-奥黑尔、达拉斯/沃斯堡、洛杉矶、迈阿密、纽约-肯尼迪、纽约-拉瓜迪亚、费城、凤凰城-天港和华盛顿-国家机场。因此,我们可以使用它们的 IATA 代码来编码哪些ORIGINDEST机场是 AA 的枢纽机场,并删除包含代码的列,因为它们过于具体(FL_NUMORIGINDEST):

#Create list with 10 hubs (with their IATA codes)
hubs = ['CLT', 'ORD', 'DFW', 'LAX', 'MIA', 'JFK', 'LGA', 'PHL',
        'PHX', 'DCA']
#Boolean series for if ORIGIN or DEST are hubsis_origin_hub =
aad18_df['ORIGIN'].isin(hubs)
is_dest_hub = aad18_df['DEST'].isin(hubs)
#Use boolean series to set ORIGIN_HUB and DEST_HUB
aad18_df['ORIGIN_HUB'] = 0
aad18_df.loc[is_origin_hub, 'ORIGIN_HUB'] = 1
aad18_df['DEST_HUB'] = 0
aad18_df.loc[is_dest_hub, 'DEST_HUB'] = 1
#Drop columns with codes
aad18_df = aad18_df.drop(['FL_NUM', 'ORIGIN', 'DEST'], axis=1) 

在进行所有这些操作之后,我们拥有相当数量的有用特征,但我们尚未确定目标特征。有两列可以用于此目的。我们有ARR_DELAY,这是无论原因如何延迟的总分钟数,然后是CARRIER_DELAY,这只是可以归因于航空公司的那些分钟数的总和。例如,看看以下样本,这些航班延误超过 15 分钟(根据航空公司的定义,这被认为是延误):

aad18_df.loc[aad18_df['ARR_DELAY'] > 15,\
['ARR_DELAY','CARRIER_DELAY']].head(10) 

上述代码输出了图 3.1

表格描述自动生成

图 3.1:样本观察值,到达延误超过 15 分钟

图 3.1的所有延误中,其中之一(#26)根本不是航空公司的责任,因为只有 0 分钟可以归因于航空公司。其中四个部分是航空公司的责任(#8、#16、#33 和#40),其中两个由于航空公司而晚于 15 分钟(#8 和#40)。其余的完全是航空公司的责任。我们可以看出,尽管总延误是有用的信息,但航空公司的高管们只对由航空公司造成的延误感兴趣,因此ARR_DELAY可以被丢弃。此外,还有一个更重要的原因应该丢弃它,那就是如果当前的任务是预测延误,我们不能使用几乎完全相同的延误(减去不是由于航空公司的部分)来预测它。出于这个原因,最好移除ARR_DELAY

aad18_df = aad18_df.drop(['ARR_DELAY'], axis=1) 

最后,我们可以将目标特征单独作为y,其余所有特征作为X。之后,我们将yX分为训练集和测试集。请注意,对于回归,目标特征(y)保持不变,因此我们将其分为y_train_regy_test_reg。然而,对于分类,我们必须将这些标签的二进制版本表示为是否晚于 15 分钟以上,称为y_train_classy_test_class。请注意,我们正在设置一个固定的random_state以确保可重复性:

rand = 9 
np.random.seed(rand)
y = aad18_df['CARRIER_DELAY']
X = aad18_df.drop(['CARRIER_DELAY'], axis=1).copy()
X_train, X_test, y_train_reg, y_test_reg = **train_test_split**(
    X, y, test_size=0.15, **random_state**=rand
)
y_train_class = y_train_reg.apply(lambda x: 1 if x > 15 else 0)
y_test_class = y_test_reg.apply(lambda x: 1 if x > 15 else 0) 

为了检查特征与目标CARRIER_DELAY的线性相关性,我们可以计算皮尔逊相关系数,将系数转换为绝对值(因为我们不感兴趣它们是正相关还是负相关),并按降序排序:

corr = aad18_df.**corr**()
abs(corr['CARRIER_DELAY']).**sort_values**(ascending=False) 

如您从输出中可以看出,只有一个特征(DEP_DELAY)高度相关。其他特征则不然:

CARRIER_DELAY         1.000000
DEP_DELAY             0.703935
ARR_RFPH              0.101742
LATE_AIRCRAFT_DELAY   0.083166
DEP_RFPH              0.058659
ARR_AFPH              0.035135
DEP_TIME              0.030941
NAS_DELAY             0.026792
:          :
WEATHER_DELAY         0.003002
SECURITY_DELAY        0.000460 

然而,这仅仅是线性相关的,并且是逐个比较的。这并不意味着它们没有非线性关系,或者几个特征相互作用不会影响目标。在下一节中,我们将进一步讨论这个问题。

审查传统的模型解释方法

为了尽可能探索多种模型类别和解释方法,我们将数据拟合到回归和分类模型中。

使用各种回归方法预测延误的分钟数

为了比较和对比回归方法,我们首先创建一个名为reg_models的字典。每个模型都是其自己的字典,创建它的函数是model属性。这种结构将在以后用来整洁地存储拟合的模型及其指标。这个字典中的模型类别已被选择来代表几个模型家族,并展示我们将要讨论的重要概念:

reg_models = {
    #Generalized Linear Models (GLMs)
    'linear':{'model': linear_model.LinearRegression()}, 
    'linear_poly':{
        'model':make_pipeline(
            PolynomialFeatures(degree=2),
            linear_model.LinearRegression(fit_intercept=False)
        )
    },
    'linear_interact':{
        'model':make_pipeline(
            PolynomialFeatures(interaction_only=True),
            linear_model.LinearRegression(fit_intercept=False)
        )
    },
    'ridge':{
        'model': linear_model.RidgeCV(
            alphas=[1e-3, 1e-2, 1e-1, 1])
    },
    #Trees  
    'decision_tree':{
        'model': tree.DecisionTreeRegressor(
             max_depth=7, random_state=rand
        )
    },
    #RuleFit
    'rulefit':{
        'model': RuleFit(
             max_rules=150,
             rfmode='regress',
             random_state=rand
        )
    },
    #Nearest Neighbors
    'knn':{'model': neighbors.KNeighborsRegressor(n_neighbors=7)},
    #Ensemble Methods
    'random_forest':{
        'model':ensemble.RandomForestRegressor(
            max_depth=7, random_state=rand)
    },
    #Neural Networks
    'mlp':{
        'model':neural_network.MLPRegressor(
            hidden_layer_sizes=(21,),
            max_iter=500, 
            early_stopping=True,
            random_state=rand
        )
    }
} 

在我们将数据拟合到这些模型之前,我们将逐一简要解释它们:

  • linear: 线性回归是我们讨论的第一个模型类别。不管好坏,它对数据做出了一些假设。其中最重要的是假设预测必须是X特征的线性组合。这自然限制了发现特征之间非线性关系和交互的能力。

  • linear_poly: 多项式回归通过添加多项式特征扩展了线性回归。在这种情况下,如degree=2所示,多项式度数为二,因此它是二次的。这意味着除了所有特征都以单变量形式(例如,DEP_FPH)存在之外,它们还以二次形式存在(例如,DEP_FPH²),以及所有 21 个特征的许多交互项。换句话说,对于DEP_FPH,会有如DEP_FPH DISTANCEDEP_FPH DELAY等交互项,以及其他所有特征的类似项。

  • linear_interact: 这就像多项式回归模型,但没有二次项——换句话说,只有交互项,正如interaction_only=True所暗示的那样。它是有用的,因为我们没有理由相信我们的任何特征与二次项有更好的拟合关系。然而,也许正是与其他特征的交互产生了影响。

  • ridge: 岭回归是线性回归的一种变体。然而,尽管线性回归背后的方法,称为普通最小二乘法OLS),在减少误差和将模型拟合到特征方面做得相当不错,但它并没有考虑过拟合。问题在于 OLS 平等地对待所有特征,因此随着每个变量的增加,模型变得更加复杂。正如单词过拟合所暗示的,结果模型对训练数据拟合得太好,导致最低的偏差但最高的方差。在这个偏差和方差之间的权衡中有一个甜蜜点,而到达这个点的一种方法是通过减少引入过多特征所增加的复杂性。线性回归本身并没有装备去做到这一点。

正是在这里,岭回归伴随着我们的朋友正则化出现。它是通过引入一个称为L2 范数的惩罚项来缩小对结果没有贡献的系数来做到这一点的。它惩罚复杂性,从而约束算法不过拟合。在这个例子中,我们使用了一个交叉验证版本的ridgeRidgeCV),它测试了几个正则化强度(alphas)。

  • decision_tree: 决策树正如其名所示。想象一个树状结构,在每一个分支点,数据集被细分以形成更多的分支,都会对某个特征进行“测试”,将数据集划分到每个分支。当分支停止细分时,它们变成叶子节点,在每一个叶子节点,都会做出一个“决策”,无论是为分类分配一个类别还是为回归提供一个固定值。我们将此树限制在 max_depth=7 以防止过拟合,因为树越大,它将更好地拟合我们的训练数据,并且越不可能将树泛化到非训练数据。

  • rule_fit: RuleFit 是一种正则化线性回归,扩展到包括以规则形式出现的特征交互。这些规则是通过遍历决策树形成的,除了它丢弃了叶子节点并保留了在向这些叶子节点分支过程中发现的特征交互。它使用 LASSO 回归,与岭回归类似,使用正则化,但它使用的是 L1 范数而不是 L2 范数。结果是,无用的特征最终会得到零系数,并且不会像 L2 那样简单地收敛到零,这使得算法可以轻松地将它们过滤掉。我们将规则限制在 150 条(max_rules=150),属性 rfmode='regress' 告诉 RuleFit 这是一个回归问题,因为它也可以用于分类。与这里使用的所有其他模型不同,这不是一个 scikit-learn 模型,而是由 Christoph Molnar 创建的,他改编了一篇名为 Predictive learning via rule ensembles 的论文。

  • knn: k-Nearest Neighbors (k-NN) 是一种基于局部性假设的简单方法,该假设认为彼此靠近的数据点相似。换句话说,它们必须具有相似的预测值,而在实践中,这并不是一个糟糕的猜测,因此它选取离你想要预测的点最近的数据点,并根据这些点进行预测。在这种情况下,n_neighbors=7,所以 k = 7。它是一个基于实例的机器学习模型,也称为懒惰学习器,因为它只是存储训练数据。在推理过程中,它使用训练数据来计算与点的相似性,并根据这些相似性生成预测。这与基于模型的机器学习技术,或急切学习器所做的方法相反,后者使用训练数据来学习公式、参数、系数或偏差/权重,然后利用这些信息在推理过程中进行预测。

  • 随机森林:想象一下,不是一棵,而是成百上千棵决策树,这些决策树是在特征随机组合和数据随机样本上训练的。随机森林通过平均这些随机生成的决策树来创建最佳树。这种在并行训练较少有效模型并使用平均过程将它们组合起来的概念被称为袋装法。它是一种集成方法,因为它将多个模型(通常称为弱学习器)组合成一个强学习器。除了袋装法之外,还有两种其他集成技术,称为提升法堆叠法。对于更深的袋装,树更好,因为它们减少了方差,这就是为什么我们使用max_depth=7的原因。

  • mlp多层感知器是一个“普通”的前馈(顺序)神经网络,因此它使用非线性激活函数(MLPRegressor默认使用ReLU),随机梯度下降和反向传播。在这种情况下,我们在第一个也是唯一的隐藏层中使用 21 个神经元,因此hidden_layer_sizes=(21,),运行 500 个训练周期(max_iter=500),并在验证分数不再提高时终止训练(early_stopping=True)。

如果你对这些模型中的某些不熟悉,不要担心!我们将在本章和书中更详细地介绍它们。此外,请注意,这些模型中的某些模型在某个地方有一个随机过程。为了确保可重复性,我们已经设置了random_state。最好总是设置它;否则,它将每次随机设置,这将使你的结果难以重复。

现在,让我们遍历我们的模型字典(reg_models),将它们拟合到训练数据上,并根据这些预测的质量计算两个指标。然后我们将保存拟合的模型、测试预测和指标到字典中,以供以后使用。请注意,rulefit只接受numpy数组,所以我们不能以同样的方式fit它。另外,请注意,rulefitmlp的训练时间比其他模型长,所以这可能需要几分钟才能运行:

for model_name in reg_models.keys():
    if model_name != 'rulefit':
        fitted_model = reg_models[model_name]\
        ['model'].fit(X_train, y_train_reg)
    else :
        fitted_model = reg_models[model_name]['model'].\
        fit(X_train.values, y_train_reg.values, X_test.columns
        )
        y_train_pred = fitted_model.predict(X_train.values)
        y_test_pred = fitted_model.predict(X_test.values)
    reg_models[model_name]['fitted'] = fitted_model
    reg_models[model_name]['preds'] = y_test_pred
    reg_models[model_name]['RMSE_train'] =\
    math.sqrt(
        metrics.mean_squared_error(y_train_reg, y_train_pred)
    )
    reg_models[model_name]['RMSE_test'] =\
    math.sqrt(metrics.mean_squared_error(y_test_reg, y_test_pred)
    )
    reg_models[model_name]['R2_test'] =\
    metrics.r2_score(y_test_reg, y_test_pred) 

我们现在可以将字典转换为DataFrame,并以排序和彩色编码的方式显示指标:

reg_metrics = pd.**DataFrame.from_dict**(
    reg_models, 'index')[['RMSE_train', 'RMSE_test', 'R2_test']
]
reg_metrics.**sort_values**(by=**'RMSE_test'**).style.format(
    {'RMSE_train': '{:.2f}', 'RMSE_test': '{:.2f}', 
     'R2_test': '{:.3f}'}
).background_gradient(
    cmap='viridis_r', low=0.1, high=1,
    subset=['RMSE_train', 'RMSE_test']
).background_gradient(
    cmap='plasma', low=0.3, high=1, subset=['R2_test']
) 

上述代码输出了图 3.2。请注意,彩色编码并不适用于所有 Jupyter Notebook 实现:

表格描述自动生成

图 3.2:我们模型的回归指标

为了解释图 3.2中的指标,我们首先应该了解它们在一般和回归练习的上下文中的含义:

  • RMSE均方根误差定义为残差的均方差。它是平方残差除以观测数(在这种情况下,航班)的平方根。它告诉你,平均而言,预测值与实际值之间的差距有多大,正如你可能从颜色编码中看出,差距越小越好,因为你想让你的预测值尽可能接近实际值在 测试保留)数据集中的实际值。我们还包含了该指标用于 训练 数据集,以查看其泛化能力如何。你预计测试误差将高于训练误差,但不会高很多。如果是这样,就像 random_forest 的情况一样,你需要调整一些参数以减少过拟合。在这种情况下,减少树的最大深度,增加树的数量(也称为 估计器),以及减少可使用特征的最大数量应该会有效。另一方面,对于 knn,你可以调整邻居的数量,但由于其 懒惰学习器 的特性,预计在训练数据上表现良好。

    无论如何,这些数字相当不错,因为即使是我们表现最差的模型,其测试 RMSE 也低于 10 分钟,大约一半的模型测试 RMSE 低于 7.5,很可能在平均意义上有效地预测了延误,因为延误的阈值是 15 分钟。

    注意,linear_poly 是第二高效模型,而 linear_interact 是第四高效模型,显著优于 linear,这表明非线性交互是产生更好预测性能的重要因素。

  • R²:R 平方也称为确定系数。它定义为模型中 y(结果)目标中可以由 X(预测器)特征解释的方差比例。它回答了模型变异性中有多少比例是可解释的问题?正如你可能从颜色编码中看出,越多越好。我们的模型似乎包括重要的 X 特征,正如我们的 皮尔逊相关系数 所证明的那样。所以如果这个 R² 值很低,也许添加额外的特征会有所帮助,例如航班日志、终端条件,甚至那些航空公司高管表示他们现在不感兴趣探索的事情,比如 仿制品 影响,以及国际航班。这些可以填补未解释方差中的空白。

让我们看看我们是否能通过分类获得良好的指标。

使用各种分类方法将航班分类为延误或未延误

就像我们在回归中做的那样,为了比较和对比分类方法,我们首先为它们创建一个名为 class_models 的字典。每个模型都是一个自己的字典,创建它的函数是 model 属性。这种结构将用于稍后存储拟合的模型及其指标。这个字典中的模型类被选择来代表几个模型家族,并展示我们将要讨论的重要概念。其中一些可能看起来很熟悉,因为它们是回归中使用的相同方法,但应用于分类:

class_models = {
    #Generalized Linear Models (GLMs)
    'logistic':{'model': linear_model.LogisticRegression()},
    'ridge':{
        'model': linear_model.RidgeClassifierCV(
            cv=5, alphas=[1e-3, 1e-2, 1e-1, 1],
            class_weight='balanced'
        )
    },
    #Tree
    'decision_tree':{
        'model': tree. DecisionTreeClassifier(max_depth=7,
                                              random_state=rand)
    },
    #Nearest Neighbors
    'knn':{'model': neighbors.KNeighborsClassifier(n_neighbors=7)},
    #Naive Bayes
    'naive_bayes':{'model': naive_bayes.GaussianNB()},
    #Ensemble Methods
    'gradient_boosting':{
        'model':ensemble.
        GradientBoostingClassifier(n_estimators=210)
    },
    'random_forest':{
        'model':ensemble.RandomForestClassifier(
            max_depth=11,class_weight='balanced', random_state=rand
        )
    },
    #Neural Networks
    'mlp':{
        'model': make_pipeline(
            StandardScaler(),
            neural_network.MLPClassifier(
                hidden_layer_sizes=(7,),
                max_iter=500,
                early_stopping=True,
                random_state=rand
            )
        )
    }
} 

在我们开始将这些模型拟合到数据之前,我们将逐一简要解释它们:

  • logistic逻辑回归第二章可解释性的关键概念中介绍。它具有与线性回归许多相同的优缺点。例如,必须手动添加特征交互。与其他分类模型一样,它返回一个介于 0 和 1 之间的概率,当接近 1 时,表示与正类的匹配可能性较大,而当接近 0 时,表示与正类的匹配可能性较小,因此更可能是负类。自然地,0.5 是用于决定类别的阈值,但不必如此。正如我们将在本书稍后考察的那样,调整阈值有解释和性能的原因。请注意,这是一个二分类问题,所以我们只是在延迟(正类)和未延迟(负类)之间进行选择,但这种方法可以扩展到多分类。那时它将被称为多项式分类

  • ridge岭分类利用与岭回归中使用的相同正则化技术,但应用于分类。它是通过将目标值转换为 -1(对于负类)并保留 1(对于正类)来实现的,然后执行岭回归。在其核心,这种伪装的回归将预测介于 -1 和 1 之间的值,然后将它们转换回 0–1 的尺度。与回归中的 RidgeCV 类似,RidgeClassifierCV 使用留一法交叉验证,这意味着它首先将数据分割成不同大小相等的集合——在这种情况下,我们使用五个集合(cv=5)——然后逐个移除特征,以查看模型在没有这些特征的情况下表现如何,平均在所有五个集合上。那些没有太大差别的特征会通过测试几个正则化强度(alphas)来受到惩罚,以找到最佳强度。与所有正则化技术一样,目的是阻止从不必要的复杂性中学习,最小化不太显著的特征的影响。

  • decision_tree:这种标准的决策树,如这个例子,也被称为CART分类和回归树),因为它可以用于回归或分类任务。它对这两个任务都有相同的算法,但功能略有不同,比如用于决定在哪里“分裂”分支的算法。在这种情况下,我们只允许我们的树具有 7 的深度。

  • knnk-NN也可以应用于分类任务,除了不平均最近邻的目标特征(或标签)之外,它选择最频繁的一个(也称为众数)。我们还在分类中使用 7 作为 k 值(n_neighbors)。

  • naive_bayes高斯朴素贝叶斯朴素贝叶斯分类器家族的一部分,之所以称为朴素,是因为它们假设特征之间相互独立,这通常不是情况。除非这个假设是正确的,否则这会极大地限制其预测能力。它被称为贝叶斯,因为它基于贝叶斯条件概率定理,即类的条件概率是类的概率乘以给定类的特征概率。高斯朴素贝叶斯还做出了一个额外的假设,即连续值具有正态分布,也称为高斯分布

  • gradient_boosting:与随机森林类似,梯度提升树也是一种集成方法,但它使用提升而不是袋装提升不是并行工作,而是按顺序,迭代地训练弱学习器,并将它们的优势纳入更强的学习器中,同时调整另一个弱学习器来克服它们的弱点。尽管集成和提升,尤其是提升,可以用模型类来完成,但这种方法使用的是决策树。我们将树的数量限制为 210(n_estimators=210)。

  • random_forest:与回归相同的随机森林,除了它生成分类决策树而不是回归树。

  • mlp:与回归相同的多层感知器,但默认情况下,输出层使用对数函数来产生概率,然后根据 0.5 阈值将其转换为 1 或 0。另一个区别是我们使用七个神经元在第一个也是唯一的隐藏层中(hidden_layer_sizes=(7,)),因为二分类通常需要较少的神经元来实现最佳结果。

请注意,这些模型中的一些使用平衡权重进行类别分配(class_weight='balanced'),这一点非常重要,因为这是一个不平衡分类任务。换句话说,负类远远多于正类。我们可以查看我们的训练数据是什么样的:

print(y_train_class[y_train_class==1].shape[0] y_train_class.shape[0]) 

如您所见,训练数据中正类的输出只占总数的 6%。考虑到这一点,模型将实现更平衡的结果。有几种方法可以处理类别不平衡,我们将在第十一章偏差缓解和因果推断方法中详细讨论,但class_weight='balanced'通过类频率的反比应用权重,给数量较少的正类一个优势。

训练和评估分类模型

gradient_boosting of sklearn takes longer than the rest to train, so this can take a few minutes to run:
for model_name in class_models.keys():
    fitted_model = class_models[model_name]
    ['model'].fit(X_train,y_train_class)
    y_train_pred = fitted_model.predict(X_train.values)
    if model_name == 'ridge':
        y_test_pred = fitted_model.predict(X_test.values)
    else:
        y_test_prob = fitted_model.predict_proba(X_test.values)[:,1]
        y_test_pred = np.where(y_test_prob > 0.5, 1, 0)
     class_models[model_name]['fitted'] = fitted_model
    class_models[model_name]['probs'] = y_test_prob
    class_models[model_name]['preds'] = y_test_pred
    class_models[model_name]['Accuracy_train'] =\
    metrics.**accuracy_score**(y_train_class, y_train_pred
    )
    class_models[model_name]['Accuracy_test'] =\
    metrics.**accuracy_score**(y_test_class, y_test_pred
    )
    class_models[model_name]['Recall_train'] =\
    metrics.**recall_score**(y_train_class, y_train_pred
    )
    class_models[model_name]['Recall_test'] =\
    metrics.**recall_score**(y_test_class, y_test_pred
    )
    if model_name != 'ridge':
        class_models[model_name]['ROC_AUC_test'] =\
        metrics.**roc_auc_score**(y_test_class, y_test_prob)
    else:
        class_models[model_name]['ROC_AUC_test'] = np.nan
    class_models[model_name]['F1_test'] =\
    metrics.**f1_score**(y_test_class, y_test_pred
    )
    class_models[model_name]['MCC_test'] =\
    metrics.**matthews_corrcoef**(y_test_class, y_test_pred
    ) 

我们现在可以将字典转换为DataFrame,并以排序和彩色编码的方式显示指标:

class_metrics = pd.**DataFrame.from_dict**(
    class_models,'index')[['Accuracy_train', 'Accuracy_test',
                           'Recall_train', 'Recall_test',
                           'ROC_AUC_test', 'F1_test', 'MCC_test']
]
class_metrics.**sort_values**(
    by=**'ROC_AUC_test'**, ascending=False).
    style.format(dict(zip(class_metrics.columns, ['{:.3f}']*7))
).background_gradient(
cmap='plasma', low=1, high=0.1, subset=['Accuracy_train',
                                        'Accuracy_test']
).background_gradient(
    cmap='viridis',
    low=1,
    high=0.1,
    subset=['Recall_train', 'Recall_test',
            'ROC_AUC_test', 'F1_test', 'MCC_test']
) 

上述代码输出了图 3.3

表格描述自动生成

图 3.3:我们模型的分类指标

为了解释图 3.3中的指标,我们首先应该了解它们在一般意义上的含义,以及在这个分类练习中的具体含义:

  • 准确率:准确率是衡量分类任务有效性的最简单方法,它是所有预测中正确预测的百分比。换句话说,在二元分类任务中,你可以通过将真正例(TPs)和真负例(TNs)的数量相加,然后除以所有预测的总数来计算这个值。与回归指标一样,你可以测量训练和测试的准确率来评估过拟合。

  • 回忆率:尽管准确率听起来像是一个很好的指标,但在这种情况下,召回率要好得多,原因是你可能会有 94%的准确率,这听起来相当不错,但结果却是你总是预测没有延迟!换句话说,即使你得到了很高的准确率,除非你对最不常见的类别,即延迟,进行准确预测,否则它就没有意义。我们可以通过召回率(也称为灵敏度真正例率)来找到这个数字,它表示为 ,它可以解释为有多少相关结果被返回——换句话说,在这种情况下,实际延迟的百分比是多少。

    另一个涉及真正例的好指标是精确率,它表示我们的预测样本的相关性,表示为 。在这种情况下,这将是预测的延迟中有多少是实际延迟。对于不平衡的类别,建议同时使用两者,但根据你对假正例(FP)的偏好,你可能更喜欢召回率而不是精确率,反之亦然。

  • ROC-AUC: ROC代表接收者操作特征,它被设计用来区分信号和噪声。它所做的是在x轴上绘制真正例率召回率)的比例,在y轴上绘制假正例率。AUC代表曲线下面积,这是一个介于 0 和 1 之间的数字,用于评估分类器的预测能力:1 表示完美,0.5 表示与随机抛硬币一样好,任何低于这个值的都意味着如果我们反转预测结果,我们会得到更好的预测。为了说明这一点,让我们根据 AUC 指标为我们的表现最差的模型,朴素贝叶斯,生成一个 ROC 曲线:

    plt.tick_params(axis = 'both', which = 'major')
    fpr, tpr, _ = metrics.**roc_curve**(
      y_test_class, class_models['naive_bayes']['probs'])
    plt.plot(
        fpr,
        tpr,
        label='ROC curve (area = %0.2f)'
        % class_models['naive_bayes']['ROC_AUC_test']
    )
    plt.plot([0, 1], [0, 1], 'k–') #random coin toss line
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.xlim([0.0, 1.0])
    plt.ylim([0.0, 1.0])
    plt.legend(loc="lower right") 
    

    上述代码输出了图 3.4。注意,对角线表示一半的面积。换句话说,它具有类似抛硬币预测质量的点:

    包含多边形的图片 自动生成描述

    图 3.4:朴素贝叶斯的 ROC 曲线

  • F1: F1 分数也称为精确率和召回率的调和平均,因为它是这样计算的:。由于它包括了精确率和召回率指标,这些指标与真正例的比例有关,因此当你的数据集不平衡,你既不偏好精确率也不偏好召回率时,它是一个很好的指标选择。

  • MCC: 马修斯相关系数是从生物统计学中提取的一个指标。它在更广泛的数据科学社区中越来越受欢迎,因为它能够公平地考虑TPFNTNFP,因为它考虑了类别的比例。这使得它非常适合不平衡的分类任务。与迄今为止使用的所有其他指标不同,它的范围不是从 0 到 1,而是从-1(完全不一致)到 1(预测与实际完全一致)。中间点,0,相当于随机预测:

我们的分类指标大多非常好,准确率超过 96%,召回率超过 75%。然而,召回率并不是一切。例如,RandomForest由于使用了带权重的类别平衡,召回率最高,但在 F1 和 MCC 上表现不佳,这表明精确度不是很好。

岭分类也有相同的设置,并且 F1 分数非常低,以至于精确度肯定很糟糕。这并不意味着这种加权技术本质上就是错误的,但它通常需要更多的控制。这本书将涵盖实现公平性和准确性、准确性可靠性、可靠性有效性之间正确平衡的技术。这是一个需要许多指标和可视化的平衡行为。从这个练习中,我们应该得到的一个关键教训是单一指标并不能告诉你全部的故事,而解释就是讲述最相关且足够完整的故事

理解传统模型解释方法的局限性

简而言之,传统的解释方法仅涵盖关于您模型的高级问题,例如以下内容:

  • 总体而言,它们的性能如何?

  • 哪些超参数的变化可能会影响预测性能?

  • 您可以在特征和它们的预测性能之间找到哪些潜在模式

如果您试图了解的不仅是模型是否工作,而是它为什么如何工作,这些问题就非常有限了?

这种理解上的差距可能导致模型出现意想不到的问题,这些问题可能不会立即显现。让我们考虑一下,一旦部署,模型不是静态的,而是动态的。它们面临与您在训练它们时的“实验室”中不同的挑战。它们可能不仅面临性能问题,还可能面临偏差问题,例如代表性不足的类别的失衡,或者对抗攻击的安全漏洞。意识到特征在现实世界环境中的变化,我们可能需要添加新特征,而不仅仅是使用相同的特征集重新训练。如果您的模型做出了某些令人不安的假设,您可能需要重新审查整个流程。但您如何识别这些问题最初存在呢?这就是您需要一套全新的解释工具的时候了,这些工具可以帮助您深入了解并回答关于您模型更具体的问题。这些工具提供的解释可以真正考虑到公平性、责任和透明度FAT),这是我们讨论过的第一章解释、可解释性和可解释性;以及为什么这一切都很重要?

研究本质上可解释(白盒)模型

到目前为止,在本章中,我们已经将我们的训练数据拟合到代表每个这些“白盒”模型家族的模型类别。本节的目的就是向您展示它们为什么是本质上可解释的。我们将通过使用之前拟合的模型来实现这一点。

广义线性模型(GLMs)

GLMs(广义线性模型)是一个包含各种模型类的大家族,每个统计分布都有一个对应的模型。就像线性回归假设目标特征和残差具有正态分布一样,逻辑回归假设伯努利分布。对于每个分布都有相应的 GLM,例如泊松回归用于泊松分布和多项式响应用于多项式分布。您可以根据目标变量的分布以及数据是否满足 GLM 的其他假设(这些假设各不相同)来选择使用哪种 GLM。除了基础分布之外,将 GLMs 联系在一起成为一个单一家族的事实是它们都有一个线性预测器。换句话说,目标变量(或预测器)可以用数学方式表示为X特征的加权总和,其中权重被称为b系数。这是所有 GLM 共有的简单公式,即线性预测函数:

然而,尽管它们有相同的公式,但每个都有不同的链接函数,它提供了线性预测函数和 GLM 的统计分布的均值之间的联系。这可以在保留b系数和X输入数据之间的线性组合的同时,给结果模型公式添加一些非线性,这可能会引起混淆。尽管如此,由于线性组合,它仍然是线性的。

对于特定的 GLM 也有许多变体。例如,多项式回归是其特征的多项式形式的线性回归,而岭回归是带有 L2 正则化的线性回归。在本节的例子中,我们不会涵盖所有 GLM,因为它们对于本章的例子不是必需的,但它们都有合理的应用场景。

偶然的是,还有一个类似的概念叫做广义加性模型GAMs),这些是 GLM,它们不需要特征和系数的线性组合,而是保留了加法部分,但应用于特征的任意函数。GAMs 也是可解释的,但它们并不常见,通常是为特定用例专门定制的ad hoc

线性回归

第一章解释、可解释性和可解释性,以及为什么这一切都很重要?中,我们介绍了简单线性回归的公式,它只有一个X特征。多元线性回归扩展到任何数量的特征,所以不再是:

它可以是:

n个特征,其中是截距,多亏了线性代数,这可以是一个简单的矩阵乘法:

用于得到最优b系数的方法,OLS,已经被深入研究并理解。此外,除了系数之外,你还可以为每个系数提取置信区间。模型的正确性取决于输入数据是否满足假设:线性正态性独立性多重共线性同方差性。我们已经讨论了线性,所以到目前为止,我们将简要解释其余部分:

  • 正态性是每个特征都是正态分布的性质。这可以通过Q-Q 图、直方图或Kolmogorov-Smirnov测试来测试,非正态性可以通过非线性变换来纠正。如果一个特征不是正态分布的,它将使其系数置信区间无效。

  • 独立性是指你的观测值(数据集中的行)彼此独立,就像不同且无关的事件一样。如果你的观测值不独立,可能会影响你对结果的理解。在本章的例子中,如果你有关于同一航班的多个行,可能会违反这个假设,使结果难以理解。这可以通过查找重复的航班号来测试。

  • 当特征之间高度相关时,会发生多重共线性。缺乏多重共线性是可取的,因为否则你会得到不准确的系数。这可以通过相关矩阵容忍度度量方差膨胀因子VIF)来测试,并且可以通过删除每个高度相关特征中的一个来纠正。

  • 同方差性第一章解释、可解释性和可解释性;以及这一切为什么都重要?中简要讨论过,这是指残差(误差)在回归线上大致相等。这可以通过Goldfeld-Quandt 检验来测试,而异方差性(同方差性的缺乏)可以通过非线性变换来纠正。在实践中,这个假设通常被违反。

尽管我们在这个章节的示例中没有这样做,但如果你将大量依赖线性回归,那么在开始将数据拟合到线性回归模型之前测试这些假设总是好的。本书不会详细介绍如何这样做,因为它更多地关注模型无关和深度学习解释方法,而不是深入探讨如何满足特定模型类(如正态性同方差性)的假设。然而,我们在第二章可解释性的关键概念中涵盖了最影响解释的特征,我们将继续寻找这些特征:非线性非单调性交互性。我们将这样做主要是因为无论使用哪种建模类进行预测,特征之间的线性和相关性仍然是相关的。而且,这些特征可以通过线性回归中使用的方法轻松测试。

解释

那么,我们如何解释线性回归模型呢?很简单!只需获取系数和截距。我们的 scikit-learn 模型具有这些属性,它们嵌入在拟合的模型中:

coefs_lm = reg_models['linear']['fitted'].coef_
intercept_lm = reg_models['linear']['fitted'].intercept_
print('coefficients:%s' % coefs_lm)
print('intercept:%s' % intercept_lm) 

上述代码输出了以下内容:

coefficients:   [ 0.0045 -0.0053 0.8941 0.0152 ...]
intercept: -37.86 

现在你已经知道了公式,看起来可能像这样:

这个公式应该可以提供一些关于模型如何全局解释的直觉。在多元线性回归中,解释模型中的每个系数可以像我们在第一章解释、可解释性和可解释性;以及这一切为什么都重要?中的简单线性回归示例中所做的那样进行。系数充当权重,但它们也根据特征类型讲述不同的故事。为了使解释更易于管理,让我们将我们的系数放在一个DataFrame中,并附带每个特征的名称:

pd.DataFrame({'feature': X_train.columns.tolist(),\
              'coef': coefs_lm.tolist()}) 

上述代码生成了图 3.5中的 DataFrame:

表格描述自动生成

图 3.5:线性回归特征系数

这是如何使用图 3.5中的系数来解释一个特征的示例:

  • 连续型: 就像 ARR_RFPH 一样,你知道对于每增加一个单位(相对每小时航班数),如果其他所有特征保持不变,它将使预测延误增加 0.373844 分钟。

  • 二元型: 就像 ORIGIN_HUB 一样,你知道出发机场是否是枢纽通过系数 -1.029088 来表达。换句话说,由于它是一个负数,出发机场是枢纽。如果其他所有特征保持不变,它将减少超过 1 分钟的延误。

  • 类别型: 我们没有类别特征,但我们有可能是类别特征的有序特征。实际上,它们应该是类别特征。例如,DEP_MONTHDEP_DOW 分别是 1–12 和 0–6 的整数。如果将它们视为有序特征,我们假设由于线性回归的线性性质,月份的增加或减少会对结果产生影响。对一周中的某一天来说也是如此。但影响非常小。如果我们将它们视为虚拟变量或独热编码的特征,我们就可以测量周五是否比周六和周三更容易出现承运人延误,或者七月是否比十月和六月更容易出现延误。这些特征按顺序排列是无法进行建模的,因为它们与这种顺序没有关系(是的——它是非线性的!)。所以,假设我们有一个名为 DEP_FRIDAY 的特征和另一个名为 DEP_JULY 的特征。它们被视为二元特征,可以精确地告诉你周五或七月出发对模型的影响。一些特征被有意保留为有序或连续的,尽管它们是类别特征的合适候选,以展示如果不正确调整特征,可能会影响模型解释的表达能力。本可以更好地告诉航空公司关于出发日和时刻如何影响延误的信息。此外,在某些情况下——不是在这个例子中——这种疏忽可能会极大地影响线性回归模型的表现。

截距(-37.86)不是一个特征,但它确实有含义,即如果所有特征都是 0,预测会是什么?在实践中,除非你的特征都有合理的理由是 0,否则这种情况不会发生。就像在 第一章解释、可解释性和可解释性;以及为什么这一切都很重要? 中你不会期望任何人的身高是 0 一样,在这个例子中,你不会期望航班距离是 0。然而,如果你将特征标准化,使它们的平均值是 0,那么你会改变截距的解释,使其成为所有特征都是其平均值时的预测值。

特征重要性

系数也可以用来计算特征重要性。不幸的是,scikit-learn 的线性回归器不适合做这件事,因为它不输出系数的标准误差。根据它们的重要性,只需要将除以它们对应的标准误差来对特征进行排序。这个结果被称为t 统计量

然后你取这个值的绝对值,并按从高到低的顺序排序。这很容易计算,但你需要标准误差。你可以通过逆向工程涉及的线性代数来使用截距和 scikit-learn 返回的系数来检索它。然而,可能更容易再次拟合线性回归模型,但这次使用包含所有统计信息的statsmodels库!顺便说一句,statsmodels将其线性回归器命名为OLS,这是有道理的,因为OLS是拟合数据的数学方法名称:

linreg_mdl = sm.**OLS**(y_train_reg, sm.add_constant(X_train))
linreg_mdl = linreg_mdl.fit()
print(linreg_mdl.summary()) 

在回归总结中有很多内容需要解析。本书不会涵盖所有内容,除了 t 统计量可以告诉你特征之间的重要性。还有一个更相关的统计解释,即如果你假设b系数为 0——换句话说,即特征对模型没有影响——t 统计量与 0 的距离有助于拒绝这个零假设。这就是 t 统计量右侧的p 值所做的事情。最接近 0 的t(对于ARR_AFPH)只有一个 p 值大于 0.05 并不是巧合。这表明这个特征在统计上不显著,因为根据这种方法进行假设检验,所有低于 0.05 的都是统计显著的。

因此,为了对特征进行排序,让我们从statsmodels总结中提取 DataFrame。然后,我们删除const(截距),因为这不是特征。然后,我们创建一个新列,包含 t 统计量的绝对值,并相应地排序。为了展示 t 统计量的绝对值和 p 值是反向相关的,我们还对这些列进行了着色:

summary_df = linreg_mdl.**summary2**().tables[1]
summary_df = summary_df.**drop**(
    ['const']).reset_index().rename(columns={'index':'feature'}
)
summary_df['t_abs'] = abs(summary_df['t'])
summary_df.**sort_values**(by=**'t_abs'**, ascending=False).
    style.format(
        dict(zip(summary_df.columns[1:], ['{:.4f}']*7)
    )
).background_gradient(cmap='plasma_r', low=0, high=0.1,\
                      subset=['P>|t|', 't_abs']) 

之前的代码输出了图 3.6

表格,Excel 描述自动生成

图 3.6:按 t 统计量的绝对值排序的线性回归总结表

图 3.6中关于特征重要性的一个特别有趣的现象是,不同类型的延迟占据了前六位中的五位。当然,这可能是因为线性回归混淆了这些不同的非线性效应,或者可能这里有一些我们应该进一步研究的东西——特别是由于“警告”部分中的statsmodels总结警告说:

[2] The condition number is large, 5.69e+04\. This might indicate that there are strong multicollinearity or other numerical problems. 

这很奇怪。记住这个想法。我们稍后会进一步探讨。

岭回归

岭回归是惩罚正则化回归的一个子族,与 LASSO 和 ElasticNet 等类似,因为,正如本章前面所解释的,它惩罚使用L2 范数。这个子族也被称为稀疏线性模型,因为,多亏了正则化,它通过使不相关的特征变得不那么相关来消除一些噪声。在这个上下文中,“稀疏”意味着少即是多,因为降低复杂性将导致方差降低并提高泛化能力。

为了说明这个概念,看看我们为线性回归输出的特征重要性表(图 3.6)。应该立即明显的是,t_abs列的每一行都以不同的颜色开始,然后一大堆都是相同的黄色。由于置信区间的变化,绝对 t 值不是你可以按比例取的,说你最顶部的特征比你的底部 10 个特征中的每一个都要相关数百倍。然而,它应该表明,有一些特征比其他特征显著更重要,以至于到了无关紧要的程度,甚至可能造成混淆,从而产生噪声。关于一小部分特征对模型结果有最大影响的倾向,有大量研究。这被称为稀疏性原则的赌注。无论对于你的数据是否真实,通过应用正则化来测试这个理论总是好的,尤其是在数据非常宽(许多特征)或表现出多重共线性时。这些正则化回归技术可以纳入特征选择过程,或用来了解哪些特征是必不可少的。

有一种技术可以将岭回归应用于分类问题。这之前已经简要讨论过。它将标签转换为-1 到 1 的尺度进行训练,以预测-1 和 1 之间的值,然后将其转换回 0-1 尺度。然而,它使用正则化线性回归来拟合数据,可以以相同的方式进行解释。

解释

岭回归可以像线性回归一样进行解释,无论是全局还是局部,因为一旦模型被拟合,就没有区别。公式是相同的:

除了系数不同,因为它们被一个参数惩罚,该参数控制应用收缩的程度。

我们可以通过从拟合模型中提取岭回归系数并将它们并排放置在一个DataFrame中,与线性回归的系数并排放置来快速比较系数:

coefs_ridge = reg_models['ridge']['fitted'].coef_
coef_ridge_df =pd.DataFrame(
    {
        'feature':X_train.columns.values.tolist(),
        'coef_linear': coefs_lm,\
        'coef_ridge': coefs_ridge
    }
)
coef_ridge_df['coef_regularization'] =\
    coef_ridge_df['coef_linear'] - coef_ridge_df['coef_ridge']
coef_ridge_df.style.background_gradient(
    cmap='plasma_r', low=0, high=0.1 , subset=['coef_regularization']
) 

如您从前面代码的图 3.7输出中可以看出,系数总是略有不同,但有时它们较低,有时较高:

表格描述自动生成

图 3.7:线性回归系数与岭回归系数的比较

我们没有保存岭回归交叉验证认为最优的参数(scikit-learn 称为alpha)。然而,我们可以进行一个小实验来找出哪个参数是最好的。我们通过在 100(1)和 10¹³(100,000,000,000,000)之间迭代 100 个可能的 alpha 值,将数据拟合到岭回归模型,然后将系数追加到一个数组中。我们排除了数组中的第八个系数,因为它比其他系数大得多,这将使可视化收缩效应更困难:

num_alphas = 100
alphas = np.**logspace**(0, 13, num_alphas)
alphas_coefs = []
for alpha in alphas:
    ridge = linear_model.**Ridge**(alpha=alpha).fit(
      X_train, y_train_reg)
    alphas_coefs.**append**(np.concatenate((ridge.coef_[:8],
                                        ridge.coef_[9:]))) 

现在我们有了系数数组,我们可以绘制系数的进展图:

plt.gca().invert_xaxis()
plt.tick_params(axis = 'both', which = 'major')
plt.plot(alphas, alphas_coefs)
plt.xscale("log")
plt.xlabel('Alpha')
plt.ylabel('Ridge coefficients')
plt.grid()
plt.show() 

上述代码生成了图 3.8

图表,折线图  自动生成的描述

图 3.8:alpha 超参数值与岭回归系数值的关系

图 3.8中需要注意的一点是,alpha 值越高,正则化程度越高。这就是为什么当 alpha 为 10¹² 时,所有系数都收敛到 0,而当 alpha 变小时,它们达到一个点,所有系数都发散并大致稳定。在这种情况下,这个点大约在 10² 时达到。另一种看待它的方法是,当所有系数都接近 0 时,这意味着正则化非常强,以至于所有特征都无关紧要。当它们足够发散并稳定后,正则化使它们都变得相关,这违背了目的。

现在回到我们的代码,我们会发现这是我们为RidgeCV中的 alpha 选择的:alphas=[1e-3, 1e-2, 1e-1, 1]。正如你可以从前面的图中看到的,当 alpha 达到 1 及以下时,系数已经稳定,尽管它们仍在轻微波动。这可以解释为什么我们的岭回归没有比线性回归表现更好。通常,你会期望正则化模型的表现比未正则化的模型好——除非你的超参数设置不正确。

解释和超参数

调整得当的正则化可以帮助去除噪声,从而提高可解释性,但为RidgeCV选择的 alpha 是故意选定的,以便传达这个观点:正则化只有在正确选择超参数的情况下才能起作用,或者,当正则化超参数调整是自动进行时,该方法必须针对你的数据集是最优的。

特征重要性

这与线性回归完全相同,但我们需要系数的标准误差,这是无法从 scikit-learn 模型中提取出来的。你可以使用statsmodels fit_regularized方法来实现这一点。

多项式回归

多项式回归是线性或逻辑回归的一种特殊情况,其中特征被扩展为具有更高次项。我们只在本章的练习中进行了多项式线性回归,因此我们只会讨论这种变化。然而,它的应用方式是相似的。

一个双特征多重线性回归看起来是这样的:

图片

然而,在多项式回归中,每个特征都被扩展为具有更高次项,并且所有特征之间都有相互作用。因此,如果这个双特征示例扩展为二次多项式,线性回归公式将看起来像这样:

图片

它在各个方面仍然是线性回归,除了它有额外的特征、更高次项和相互作用。虽然你可以将多项式扩展限制在只有一个或几个特征上,但我们使用了PolynomialFeatures,它对所有特征都这样做。因此,21 个特征可能被多次相乘。我们可以从我们的拟合模型中提取系数,并使用numpy数组的shape属性来返回生成的系数数量。这个数量对应于生成的特征数量:

reg_models['linear_poly']['fitted'].\
get_params()['linearregression'].coef_.shape[0] 

它输出253。我们可以用多项式回归的版本做同样的事情,这个版本只有交互项:

reg_models['linear_interact']['fitted'].\
get_params()['linearregression'].coef_.shape[0] 

上述代码输出232。实际上,这种生成的多项式中的大多数项都是所有特征之间的相互作用。

解释和特征重要性

多项式回归可以像线性回归一样,在全局和局部进行精确的解释。在这种情况下,理解一个由 253 个线性组合项构成的公式并不实际,因此它失去了我们在第二章中定义的,即可解释性的关键概念中的全局整体解释。然而,它仍然可以在所有其他范围内进行解释,并保留线性回归的许多属性。例如,由于模型是加性的,因此很容易分离特征的影响。你还可以使用与线性回归相同的许多同行评审的经过验证的统计方法。例如,你可以使用 t 统计量、p 值、置信区间、R 平方,以及用于评估拟合优度、残差分析、线性相关性和方差分析的许多测试。这些丰富的经过统计验证的方法来测试和解释模型并不是大多数模型类别都能依赖的。不幸的是,其中许多都是针对线性回归及其特殊情况的特定模型。

此外,我们在这里不会这样做,因为有很多项。尽管如此,你无疑可以用与线性回归相同的方式使用 statsmodels 库对多项式回归进行特征排序。挑战在于确定由 PolynomialFeatures 生成的特征的顺序,以便在特征名称列中相应地命名。一旦完成这项工作,你就可以判断某些二次项或交互项是否重要。这可能会告诉你这些特征是否具有非线性性质或高度依赖于其他特征。

逻辑回归

我们在 第二章可解释性的关键概念 中讨论了逻辑回归及其解释和特征重要性。我们将在本章的分类练习的背景下对此进行一些扩展,并阐述为什么逻辑回归是可解释的。拟合的逻辑回归模型具有系数和截距,就像线性回归模型一样:

coefs_log = class_models['logistic']['fitted'].**coef_**
intercept_log = class_models['logistic']['fitted'].**intercept_**
print('coefficients:%s' % coefs_log)
print('intercept:%s' % intercept_log) 

上述代码输出如下:

coefficients: [[-6.31114061e-04 -1.48979793e-04  2.01484473e-01  1.32897749e-01 1.31740116e-05 -3.83761619e-04 -7.60281290e-02  ..]]
intercept: [-0.20139626] 

然而,这些系数在特定预测公式中的出现方式与 完全不同:

换句话说,(是阳性案例)的概率由一个涉及 系数和 x 特征的线性组合的指数的 逻辑函数 表示。指数的存在解释了为什么从模型中提取的系数是对数似然,因为为了隔离系数,你应该在方程式的两边应用对数。

解释

解释每个系数的方法与线性回归完全相同,只是当特征增加一个单位时,通过系数的指数因子增加获得阳性案例的概率(所有其他条件相同,记住在 第二章 中讨论的 ceteris paribus 假设)。必须对每个系数应用指数 (),因为它们表示对数似然的增加,而不是概率。除了将对数似然纳入解释之外,关于线性回归中连续、二元和分类的解释同样适用于逻辑回归。

特征重要性

尽管如此,统计界在如何最好地获取逻辑回归的特征重要性方面还没有达成共识。有标准化所有特征的方法,伪 R² 方法,一次一个特征的 ROC AUC 方法,部分卡方统计方法,然后最简单的一个,即乘以每个特征的方差和系数。我们不会涵盖所有这些方法,但必须指出,对于大多数模型类别,包括白盒模型,计算特征重要性的一致性和可靠性是一个问题。我们将在 第四章全局模型无关解释方法 中更深入地探讨这个问题。对于逻辑回归,可能最流行的方法是在训练之前标准化所有特征——也就是说,确保它们以零为中心,并除以它们的方差。但我们没有这样做,因为尽管它有其他好处,但它使得系数的解释更加困难,因此在这里我们使用的是 第二章可解释性关键概念 中提到的相当粗糙的方法,即乘以每个特征的方差和系数:

stdv = np.std(X_train, 0)
abs(coefs_log.reshape(21,) * stdv).sort_values(ascending=False) 

前面的代码生成了以下输出:

DEP_DELAY              8.92
CRS_ELAPSED_TIME       6.03
DISTANCE               5.31
LATE_AIRCRAFT_DELAY    4.99
NAS_DELAY              2.39
WEATHER_DELAY          2.16
TAXI_OUT               1.31
SECURITY_DELAY         0.38
ARR_AFPH               0.32
WHEELS_OFF        0.01
PCT_ELAPSED_TIME    0.003 

它仍然可以相当好地近似特征的重要性。就像线性回归一样,你可以看出延迟特征排名相当高。这五个特征都在前八个特征之中。事实上,这是我们应当关注的。当我们讨论其他白盒方法时,我们将对此进行更多讨论。

决策树

决策树被使用了很长时间,甚至在它们被转化为算法之前。它们几乎不需要任何数学能力就能理解,这种低门槛的可理解性使得它们在最简单的表示中具有极高的可解释性。然而,在实践中,有许多类型的决策树学习方法,其中大多数都不是非常可解释的,因为它们使用了 集成方法(提升、袋装和堆叠),或者甚至利用 PCA 或其他嵌入器。即使是非集成决策树,随着它们的深度增加,也可能变得极其复杂。无论决策树的复杂性如何,它们总能挖掘出关于你的数据和预期预测的重要见解,并且它们可以拟合回归和分类任务。

CART 决策树

分类和回归树CART)算法是在大多数用例中选择的“纯朴”无附加功能的决策树。正如所注,大多数决策树不是白盒模型,但这个是,因为它被表达为一个数学公式,可视化,并以一组规则的形式打印出来,这些规则将树细分为分支,最终变为叶子。

数学公式:

图片

这意味着,如果根据恒等函数 Ix 在子集 R[m] 中,则返回 1,否则返回 0。这个二元项与子集 R[m] 中所有元素的均值相乘,表示为 。所以如果 x[i] 在属于叶节点 R[k] 的子集中,那么预测 。换句话说,预测是子集 R[k] 中所有元素的均值。这就是回归任务发生的情况,在二分类中,简单地没有 来乘以 I 识别函数。

每个决策树算法的核心都有一个生成 R[m] 子集的方法。对于 CART,这是通过使用所谓的基尼指数来实现的,通过递归地在两个分支尽可能不同的情况下进行分割。这个概念将在第四章全局模型无关解释方法中做更详细的解释。

解释

决策树可以全局和局部地通过视觉进行解释。在这里,我们设定了最大深度为 2(max_depth=2),因为我们本可以生成所有 7 层,但文本太小,无法欣赏。这种方法的一个局限性是,当深度超过 3 或 4 时,可视化会变得复杂。然而,你总是可以通过编程遍历树的分支,并一次可视化一些分支:

fig, axes = plt.subplots(
    nrows = 1, ncols = 1, figsize = (16,8), dpi=600)
tree.plot_tree(
    class_models['decision_tree']['fitted'],
    feature_names=X_train.columns.values.tolist(),
    filled = True, max_depth=2
)
fig.show() 

前面的代码打印出了图 3.9中的树。从树中,你可以看出第一个分支是根据 DEP_DELAY 的值等于或小于 20.5 来分割决策树的。它告诉你做出这个决策的基尼指数和存在的 samples(这只是说观察、数据点或行的一种方式)的数量。你可以遍历这些分支,直到它们达到叶节点。这个树中有一个叶节点,它在最左边。这是一个分类树,所以你可以通过值=[629167, 0]来判断这个节点中剩余的所有 629,167 个样本都被分类为 0(未延迟):

图片

图 3.9:我们的模型绘制的决策树

树的另一种可视化方式是打印出每个分支所做的决策和每个节点中的类别,但细节较少,例如基尼指数和样本大小:

text_tree = tree.export_text(
    class_models['decision_tree']['fitted'],
    feature_names=X_train.columns.values.tolist()
)
print(text_tree) 

前面的代码输出了以下内容:

文档的特写  描述自动生成,中等置信度

图 3.10:我们的决策树结构

决策树还有很多可以做的事情,scikit-learn 提供了一个 API 来探索树。

特征重要性

在 CART 决策树中计算特征重要性相对直接。正如你可以从可视化中欣赏到的,一些特征在决策中出现的频率更高,但它们的出现是按照它们对 Gini 指数整体降低的贡献相对于前一个节点来加权的。整个树中所有相对降低的 Gini 指数总和被计算出来,每个特征的贡献是这个降低的百分比:

dt_imp_df = pd.DataFrame(
    {
        'feature':X_train.columns.values.tolist(),
        'importance': class_models['decision_tree']['fitted'].\
                      **feature_importances_**
    }
).sort_values(by='importance', ascending=False)
dt_imp_df 

前面代码输出的dt_imp_df DataFrame 可以在图 3.11中欣赏到:

表格描述自动生成

图 3.11:我们的决策树特征重要性

这个最后的功能重要性表,图 3.11,增加了对延迟特征的怀疑。它们再次占据了前六个位置中的五个。这五者是否可能都对模型产生了如此巨大的影响?

解释和领域专业知识

目标CARRIER_DELAY也被称为因变量,因为它依赖于所有其他特征,即自变量。尽管统计关系并不一定意味着因果关系,但我们希望根据我们对哪些自变量可能影响因变量的理解来指导我们的特征选择。

离港延误(DEPARTURE_DELAY)会影响到达延误(我们已移除),因此也会影响CARRIER_DELAY,这是有道理的。同样,LATE_AIRCRAFT_DELAY作为一个预测因子也是有道理的,因为它在飞机起飞前就已经知道如果之前的飞机晚了几分钟,那么这次航班就有可能晚到,但这并不是当前航班的原因(排除这个选项)。然而,尽管运输统计局网站将延误定义为似乎是有序类别的,但有些可能是在航班起飞后才确定的。例如,在预测中途延误时,如果恶劣天气还没有发生,我们能否使用WEATHER_DELAY?如果安全漏洞还没有发生,我们能否使用SECURITY_DELAY?对这些问题的回答是,我们可能不应该这样做,因为包括它们的理由是它们可以用来排除CARRIER_DELAY,但这只适用于它们是先于因变量存在的有序类别!如果它们不是,它们就会产生所谓的数据泄露。在得出进一步结论之前,你需要做的是与航空公司高管交谈,以确定每个延误类别被一致设置的时间表,以及(假设性地)从驾驶舱或航空公司的指挥中心可以访问的时间表。即使你被迫从模型中删除它们,也许其他数据可以在有意义的方式上填补空白,例如飞行记录的前 30 分钟和/或历史天气模式。

解释并不总是直接从数据和机器学习模型中推断出来,而是通过与领域专家紧密合作。但有时领域专家也可能误导你。事实上,另一个见解是,在本章开头我们构建的所有基于时间的指标和分类特征(如DEP_DOWDEST_HUBORIGIN_HUB等)。结果证明,它们对模型的影响微乎其微。尽管航空公司高管暗示了星期几、枢纽和拥堵的重要性,但我们本应该进一步探索数据,在构建数据之前寻找相关性。但即使我们构建了一些无用的特征,使用白盒模型来评估它们的影响也是有帮助的,就像我们做的那样。在数据科学中,从业者通常会以最有效的机器学习模型的方式学习——通过试错!

RuleFit

RuleFit是一个模型类家族,它是 LASSO 线性回归和决策规则之间的混合体,为每个特征获取正则化系数,并且也使用 LASSO 进行正则化。这些决策****规则通过遍历决策树,找到特征之间的交互效应,并根据它们对模型的影响分配系数来提取。本章使用的实现使用梯度提升决策树来完成这项任务。

我们在本章中并未明确介绍决策规则,但它们是另一类本质上可解释的模型。它们未被包含在内,是因为在撰写本书时,唯一支持决策规则的 Python 库,由 Skater 开发的名为贝叶斯规则列表BRL),仍处于实验阶段。无论如何,决策规则背后的概念非常相似。它们从决策树中提取特征交互,但不会丢弃叶节点,而不是分配系数,而是使用叶节点中的预测来构建规则。最后的规则是一个通配符,就像一个ELSE语句。与 RuleFit 不同,它只能按顺序理解,因为它与任何IF-THEN-ELSE语句如此相似,但这正是它的主要优势。

解释和特征重要性

你可以将关于 RuleFit 所需了解的所有内容放入一个单独的 DataFrame(rulefit_df)中。然后你移除那些系数为0的规则。这是因为与岭回归不同,在 LASSO 中,系数估计会收敛到零。你可以按重要性降序对 DataFrame 进行排序,以查看哪些特征或特征交互(以规则的形式)最为重要:

rulefit_df = reg_models['rulefit']['fitted'].get_rules()
rulefit_df = rulefit_df[rulefit_df.coef !=0].\
sort_values(
    by="importance", ascending=False
)
rulefit_df 

rulefit_df DataFrame 中的规则可以在图 3.12中看到:

表格描述自动生成

图 3.12:RuleFit 的规则

图 3.12中,每个 RuleFit 特征都有一个type。那些是linear的,就像解释任何线性回归系数一样。那些是type=rule的,也像线性回归模型中的二元特征一样处理。例如,如果规则LATE_AIRCRAFT_DELAY <= 333.5 & DEP_DELAY > 477.5为真,则将系数172.103034应用于预测。规则捕捉交互效应,因此你不必手动添加交互项到模型或使用某些非线性方法来找到它们。此外,它以易于理解的方式进行。即使你选择生产化其他模型,你也可以使用 RuleFit 来指导你对特征交互的理解。

最近邻

最近邻是一系列模型,甚至包括无监督方法。所有这些方法都使用数据点之间的接近性来提供预测信息。在这些方法中,只有监督 k-NN 及其近亲半径最近邻是可解释的。

k-最近邻

k-NN背后的思想很简单。它选取训练数据中与数据点最近的k个点,并使用它们的标签(y_train)来提供预测信息。如果是分类任务,则是所有标签的众数,如果是回归任务,则是均值。它是一个懒惰学习器,因为“拟合模型”并不比训练数据和参数(如k和类别列表,如果是分类)多多少。它在推理之前不做太多。那时,它利用训练数据,直接从中提取,而不是像急切学习器那样提取模型学到的参数、权重/偏差或系数。

解释

k-NN只有局部可解释性,因为没有拟合模型,所以没有全局模块化或全局整体可解释性。对于分类任务,你可以尝试使用我们在第二章可解释性关键概念中研究的决策边界和区域来获得这种感觉。然而,这始终基于局部实例。

为了解释测试数据集中的局部点,我们使用其索引查询pandas DataFrame。我们将使用航班#721043:

print(X_test.loc[721043,:]) 

之前的代码输出了以下pandas序列:

CRS_DEP_TIME         655.00
DEP_TIME            1055.00
DEP_DELAY            240.00
TAXI_OUT            35.00
WHEELS_OFF            1130.00
CRS_ARR_TIME        914.00
CRS_ELAPSED_TIME        259.00
DISTANCE            1660.00WEATHER_DELAY        0.00
NAS_DELAY            22.00
SECURITY_DELAY        0.00
LATE_AIRCRAFT_DELAY    221.00
DEP_AFPH             90.80
ARR_AFPH             40.43
DEP_MONTH            10.00
DEP_DOW            4.00
DEP_RFPH            0.89
ARR_RFPH            1.06
ORIGIN_HUB            1.0
DEST_HUB            0.00
PCT_ELAPSED_TIME        1.084942
Name: 721043, dtype: float64 

y_test_class标签中,对于航班#721043,我们可以看出它延误了,因为这段代码输出了 1:

print(y_test_class[721043]) 

然而,我们的 k-NN 模型预测它不是延误,因为这段代码输出了 0:

print(class_models['knn']['preds'][X_test.index.get_loc(721043)]) 

请注意,预测以numpy数组的形式输出,因此我们无法使用航班#721043 的pandas索引(721043)来访问预测。我们必须使用测试数据集中此索引的顺序位置,通过get_loc来检索它。

要找出为什么会出现这种情况,我们可以使用模型中的kneighbors来找到这个点的七个最近邻。为此,我们必须reshape我们的数据,因为kneighbors只接受与训练集中相同的形状,即(n,21),其中 n 是观察数(行数)。在这种情况下,n=1,因为我们只想为单个数据点找到最近邻。而且正如你可以从X_test.loc[721043,:]输出的内容中看出,pandas序列的形状为(21,1),因此我们必须反转这个形状:

print(class_models['knn']['fitted'].\
      **kneighbors**(X_test.loc[721043,:].values.reshape(1,21), 7)) 

kneighbors输出两个数组:

(array([[143.3160128 , 173.90740076, 192.66705727, 211.57109221,
         243.57211853, 259.61593993, 259.77507391]]),
array([[105172, 571912,  73409,  89450,  77474, 705972, 706911]])) 

第一个是七个最近训练点到我们的测试数据点的距离。第二个是这些数据点在训练数据中的位置:

print(y_train_class.iloc[[105172, 571912, 73409, 89450, 77474,\
                          705972, 706911]]) 

上述代码输出了以下pandas序列:

3813      0
229062    1
283316    0
385831    0
581905    1
726784    1
179364    0
Name: CARRIER_DELAY, dtype: int64 

我们可以看出预测反映了众数,因为在七个最近邻点中最常见的类别是 0(未延迟)。你可以增加或减少k来查看这是否成立。顺便说一下,当使用二分类时,建议选择奇数k,这样就没有平局。另一个重要方面是用于选择最近数据点的距离度量。你可以很容易地找出它是哪一个:

print(class_models['knn']['fitted'].**effective_metric_**) 

输出是欧几里得距离,这对于这个例子是有意义的。毕竟,欧几里得距离对于实值向量空间是最优的,因为大多数特征是连续的。你也可以测试其他距离度量,如minkowskiseuclideanmahalanobis。当你的大多数特征是二元和分类时,你有一个整数值向量空间。因此,你的距离应该使用适合此空间的算法来计算,如hammingcanberra

特征重要性

特征重要性毕竟是一种全局模型解释方法,而 k-NN 具有超局部性质,因此无法从 k-NN 模型中推导出特征重要性。

朴素贝叶斯

与 GLMs 一样,朴素贝叶斯是一系列针对不同统计分布定制的模型类。然而,与 GLMs 假设目标y特征具有所选分布不同,所有朴素贝叶斯模型都假设你的x特征具有这种分布。更重要的是,它们基于贝叶斯定理的条件概率,因此输出一个概率,因此是专门的分类器。但它们独立地处理每个特征对模型的影响的概率,这是一个强烈的假设。这就是为什么它们被称为朴素。有一个伯努利朴素贝叶斯,一个多项式朴素贝叶斯,称为多项式朴素贝叶斯,当然还有一个高斯朴素贝叶斯,这是最常见的一种。

高斯朴素贝叶斯

贝叶斯定理由以下公式定义:

换句话说,要找到在B为真的条件下A发生的概率,你需要取在A为真的条件下B的条件概率乘以A发生的概率,然后除以B的概率。在机器学习分类器的上下文中,这个公式可以重写如下:

图片

这是因为我们想要的是在X为真的条件下Y的概率。但是我们的X有多个特征,所以这可以展开如下:

图片

为了计算图片预测,我们必须考虑我们必须计算和比较每个C[k]类(延迟的概率与无延迟的概率)的概率,并选择概率最高的类别:

图片

计算每个类别的概率图片(也称为类先验)相对简单。事实上,拟合的模型已经将此存储在一个名为class_prior_的属性中:

print(class_models['naive_bayes']['fitted'].class_prior_) 

这将输出以下内容:

array([0.93871674, 0.06128326]) 

自然地,由于由运营商引起的延迟只发生在 6%的时间内,因此这种情况发生的边缘概率很小。

然后,公式有一个条件概率的乘积图片,每个特征属于一个类图片。由于这是二元的,因此不需要计算多个类的概率,因为它们是成反比的。因此,我们可以省略C[k]并替换为 1,如下所示:

图片

这是因为我们试图预测的是延迟的概率。此外,图片是其自己的公式,它根据模型的假设分布而有所不同——在这种情况下,高斯:

图片

这个公式被称为高斯分布的概率密度。

解释和特征重要性

因此,公式中的这些sigma(图片)和theta(图片)是什么?它们分别是当图片x[i]特征的方差和均值。这个概念背后的想法是,特征在一个类别与另一个类别中具有不同的方差和均值,这可以提供分类信息。这是一个二元分类任务,但你也可以为两个类别计算图片图片。幸运的是,拟合的模型已经存储了这些:

print(class_models['naive_bayes']['fitted'].**sigma_**) 

有两个数组输出,第一个对应于负类,第二个对应于正类。数组包含给定类别的每个 21 个特征的 sigma(方差):

array([[2.50123026e+05, 2.61324730e+05, ..., 1.13475535e-02],
       [2.60629652e+05, 2.96009867e+05, ..., 1.38936741e-02]]) 

您还可以从模型中提取 theta(均值):

print(class_models['naive_bayes']['fitted'].**theta_**) 

之前的代码也输出了两个数组,每个类别一个:

array([[1.30740577e+03, 1.31006271e+03, ..., 9.71131781e-01],
       [1.41305545e+03, 1.48087887e+03, ..., 9.83974416e-01]]) 

这两个数组是你调试和解释朴素贝叶斯结果所需的所有内容,因为你可以使用它们来计算给定一个正类别的 x[i] 特征的条件概率 img/B18406_03_047.png。你可以使用这个概率来按全局重要性对特征进行排序,或者在局部级别上解释一个特定的预测。

朴素贝叶斯是一个快速算法,有一些很好的用例,例如垃圾邮件过滤和推荐系统,但独立性假设阻碍了它在大多数情况下的性能。说到性能,让我们在可解释性的背景下讨论这个话题。

认识到性能和可解释性之间的权衡

我们之前已经简要地涉及过这个话题,但高性能往往需要复杂性,而复杂性会阻碍可解释性。正如在第二章可解释性的关键概念中研究的那样,这种复杂性主要来自三个来源:非线性、非单调性和交互性。如果模型增加了任何复杂性,它将由你数据集中特征的数量和性质复合,这本身就是一个复杂性的来源。

特殊模型特性

这些特殊属性可以帮助使模型更具可解释性。

关键特性:可解释性

第一章解释、可解释性和可解释性;以及为什么这一切都很重要?中,我们讨论了为什么能够查看模型的内部并直观地理解所有移动部件如何以一致的方式推导出其预测,这主要是将可解释性可解释性区分开来的原因。这个特性也被称为透明度半透明度。一个模型可以没有这个特性仍然具有可解释性,但就像我们因为无法理解“内部”发生的事情而解释一个人的决定一样。这通常被称为事后可解释性,这正是本书主要关注的一种可解释性,尽管有一些例外。话虽如此,我们应该认识到,如果一个模型可以通过利用其数学公式(基于统计和概率理论)来理解,就像我们在线性回归和朴素贝叶斯中所做的那样,或者通过可视化一个可由人类解释的结构,就像决策树或一组规则(如 RuleFit)那样,那么它比那些在实际上不可能做到这一点的机器学习模型类别要容易解释得多。

白盒模型在这方面始终具有优势,如第一章中所述,解释、可解释性和可解释性;以及为什么这一切都很重要?,有许多用例中白盒模型是必不可少的。但即使您不将白盒模型投入生产,只要数据维度允许,它们也可以在辅助解释方面发挥作用。透明性是一个关键属性,因为即使它不符合其他属性,只要它具有可解释性,它仍然比没有它的模型更具可解释性。

补救性质:正则化

在本章中,我们已经了解到正则化限制了过多特征引入所增加的复杂性,这可以使模型更具可解释性,更不用说性能更佳。一些模型将正则化纳入训练算法中,例如 RuleFit 和梯度提升树;其他模型具有集成正则化的能力,例如多层感知器或线性回归,而有些模型则不能包含它,例如 k-NN。正则化有多种形式。决策树有一种称为剪枝的方法,可以通过删除非显著分支来帮助降低复杂性。神经网络有一种称为 dropout 的技术,在训练过程中会随机从层中丢弃神经网络节点。正则化是一种补救性质,因为它可以帮助即使是可解释性最差的模型减少复杂性,从而提高可解释性。

评估性能

到目前为止,在本章中,您已经评估了上一节中审查的所有白盒模型以及一些黑盒模型的性能。也许您已经注意到,黑盒模型在大多数指标上都位居前列,对于大多数用例来说,这通常是情况。

确定哪些模型类别更具可解释性并不是一门精确的科学,但以下表格(图 3.17)是根据具有最理想属性的模型排序的——也就是说,它们不引入非线性、非单调性和交互性。当然,可解释性本身就是一个颠覆性的属性,无论如何,正则化都能帮助。也有一些情况下很难评估属性。例如,多项式(线性)回归实现了一个线性模型,但它拟合了非线性关系,这就是为什么它被用不同的颜色编码。正如您将在第十二章中学习到的,单调约束和模型调优以提高可解释性,一些库支持向梯度提升树和神经网络添加单调约束,这意味着可以使这些模型单调。然而,本章中使用的黑盒方法不支持单调约束。

任务列告诉你它们是否可用于回归或分类。而性能排名列显示了这些模型在 RMSE(回归)和 ROC AUC(分类)中的排名情况,排名越低越好。请注意,尽管为了简化起见,我们只使用了一个指标来评估此图表的性能,但关于性能的讨论应该比这更细致。另一件需要注意的事情是,岭回归表现不佳,但这是因为我们使用了错误的超参数,如前节所述:

包含图表的图片,自动生成描述

图 3.13:评估我们在本章中探索的几个白盒和黑盒模型的可解释性和性能的表格

因为它符合所有五个属性,所以很容易理解为什么线性回归是可解释性的黄金标准。此外,虽然认识到这只是一个轶事证据,但应该立即明显的是,大多数最佳排名都是黑盒模型。这不是偶然!神经网络和梯度提升树背后的数学在实现最佳指标方面非常高效。然而,正如红色圆点所暗示的,它们都具有使模型不太可解释的特性,使它们的最大优势(复杂性)成为潜在的弱点。

这正是为什么在本书中,尽管我们将学习许多应用于白盒模型的方法,但黑盒模型是我们的主要兴趣所在。在第二部分,包括第四章第九章,我们将学习模型无关和深度学习特定方法,这些方法有助于解释。而在第三部分,包括第十章第十四章,我们将学习如何调整模型和数据集以增加可解释性:

图表,漏斗图,自动生成描述

图 3.14:比较白盒、黑盒和玻璃盒模型,或者至少是关于它们的已知信息表格

发现新的可解释(玻璃盒)模型

在过去十年中,无论是工业界还是学术界,都做出了重大努力,创造新的模型,这些模型具有足够的复杂性,可以在欠拟合和过拟合之间找到最佳平衡点,即所谓的偏差-方差权衡,同时保持足够的可解释性水平。

许多模型符合这种描述,但其中大多数是为特定用例设计的,尚未经过适当测试,或者已经发布了一个库或开源代码。然而,已经有两种通用模型开始受到关注,我们现在将探讨它们。

可解释提升机(EBM)

EBM是微软的 InterpretML 框架的一部分,该框架包括我们在本书后面将使用的许多模型无关方法。

EBM 利用了我们之前提到的GAMs,它们类似于线性模型,但看起来是这样的:

使用样条函数将单个函数 f[1] 到 f[p] 分别拟合到每个特征。然后,一个链接函数 g 适应 GAM 执行不同的任务,如分类或回归,或将预测调整到不同的统计分布。GAM 是白盒模型,那么是什么让 EBM 成为玻璃盒模型呢?它结合了袋装和梯度提升,这往往会使模型性能更佳。提升是逐个特征进行的,使用低学习率以避免混淆。它还可以自动找到实用的交互项,这提高了性能同时保持了可解释性:

一旦拟合,这个公式就由复杂的非线性公式组成,因此全球整体解释不太可能可行。然而,由于每个特征或成对交互项的效果是可加的,它们很容易分离,全球模块化解释是完全可能的。局部解释同样简单,因为数学公式可以帮助调试任何预测。

一个缺点是,由于 逐个特征 的方法、低学习率不影响特征顺序以及样条拟合方法,EBM 可能比梯度提升树和神经网络慢得多。然而,它是可并行的,所以在拥有充足资源和多个核心或机器的环境中,它会快得多。为了避免等待结果花费一两个小时,最好是创建 X_trainX_test 的简略版本——也就是说,只有较少的列代表白盒模型发现的最重要八个特征:DEP_DELAY(出发延误)、LATE_AIRCRAFT_DELAY(晚点飞机延误)、PCT_ELAPSED_TIME(已过时间百分比)、WEATHER_DELAY(天气延误)、NAS_DELAY(机场延误)、SECURITY_DELAY(安全延误)、DISTANCE(距离)、CRS_ELAPSED_TIME(航班已过时间)和 TAXI_OUT(滑出)。这些被放置在 feature_samp 数组中,然后 X_trainX_test DataFrame 被子集化,只包括这些特征。我们将 sample2_size 设置为 10%,但如果你觉得你有足够的资源来处理它,相应地调整:

#Make new abbreviated versions of datasets
feature_samp = [
    'DEP_DELAY',
    'LATE_AIRCRAFT_DELAY',
    'PCT_ELAPSED_TIME',
    'DISTANCE',
    'WEATHER_DELAY',
    'NAS_DELAY',
    'SECURITY_DELAY',
    'CRS_ELAPSED_TIME'
]
X_train_abbrev2 = X_train[feature_samp]
X_test_abbrev2 = X_test[feature_samp]
#For sampling among observations
np.random.seed(rand)
sample2_size = 0.1
sample2_idx = np.random.choice(
    X_train.shape[0], math.ceil(
      X_train.shape[0]*sample2_size), replace=False
) 

要训练你的 EBM,你只需要实例化一个 ExplainableBoostingClassifier(),然后将你的模型拟合到你的训练数据。注意,我们正在使用 sample_idx 来采样数据的一部分,这样它花费的时间会更少:

ebm_mdl = **ExplainableBoostingClassifier**()
ebm_mdl.fit(X_train_abbrev2.iloc[sample2_idx], y_train_class.iloc[sample2_idx]) 

全球解释

全球解释非常简单。它包含一个你可以探索的 explain_global 仪表板。它首先加载特征重要性图,你可以选择单个特征来绘制从每个特征中学到的内容:

show(ebm_mdl.**explain_global**()) 

上一段代码生成一个看起来像 图 3.15 的仪表板:

图表,条形图  自动生成的描述

图 3.15:EBM 的全球解释仪表板

局部解释

局部解释使用与全局相同的仪表板,除了你选择使用explain_local解释的具体预测。在这种情况下,我们选择了#76,正如你可以看到的,这是一个错误的预测。但我们在第五章“局部模型无关解释方法”中将要研究的类似 LIME 的图可以帮助我们理解它:

ebm_lcl = ebm_mdl.**explain_local**(
    X_test_abbrev2.iloc[76:77], y_test_class[76:77], name='EBM'
)
show(ebm_lcl) 

与全局仪表板类似,前面的代码生成了另一个,如图 3.16 所示:

图表,柱状图  自动生成的描述

图 3.16:EBM 的局部解释仪表板

性能

EBM 性能,至少用 ROC AUC 来衡量,并不远低于前两个分类模型所取得的成果,我们只能期待在 10 倍更多训练和测试数据的情况下它会变得更好!

ebm_perf = **ROC**(ebm_mdl.predict_proba).**explain_perf**(
    X_test_abbrev2.iloc[sample_idx],y_test_class.iloc[sample_idx],
    name='EBM'
)
show(ebm_perf) 

你可以在图 3.17 中欣赏到前面代码生成的性能仪表板。

图表,折线图  自动生成的描述

图 3.17:EBM 的一个性能仪表板

由于其解释器是模型无关的,性能仪表板可以同时比较多个模型。还有一个第四个仪表板,可以用于数据探索。接下来,我们将介绍另一个基于 GAM 的模型。

GAMI-Net

也有一种新的基于 GAM 的方法,与 EBM 具有相似的性质,但使用神经网络进行训练。在撰写本文时,这种方法尚未获得商业上的吸引力,但提供了良好的可解释性和性能。

正如我们之前讨论的,可解释性会因每个额外特征而降低,尤其是那些对模型性能没有显著影响的特征。除了过多的特征外,它还受到非线性、非单调性和交互增加的复杂性的影响。GAMI-Net 通过首先为主效应网络中的每个特征拟合非线性子网络来解决所有这些问题。然后,为每个特征的组合拟合一个成对交互网络。用户提供要保留的最大交互数,然后将其拟合到主效应网络的残差。请参见图 3.18 以获取图解。

图片

图 3.18:GAMI-Net 模型图

GAMI-Net 内置了三个可解释性约束:

  • 稀疏性:只保留顶级特征和交互。

  • 遗传性:如果至少包含其父特征中的一个,则可以包含成对交互。

  • 边际清晰度:交互中的非正交性会受到惩罚,以更好地近似边际清晰度。

GAMI-Net 的实现还可以强制执行单调约束,我们将在第十二章“单调约束和模型调优以提高可解释性”中更详细地介绍。

在我们开始之前,我们必须创建一个名为 meta_info 的字典,其中包含每个特征和目标的相关细节,例如类型(连续、分类和目标)以及用于缩放每个特征的缩放器——因为库期望每个特征都独立缩放。由于简化的数据集中的所有特征都是连续的,我们可以利用字典推导来轻松实现这一点:

meta_info = {col:{"type":"continuous"} for col in\
                                         X_train_abbrev.columns} 

接下来,我们将创建 X_train_abbrevX_train_abbrev 的副本,然后对它们进行缩放并将缩放器存储在字典中。然后,我们将有关目标变量的信息添加到字典中。最后,我们将所有数据转换为 numpy 格式:

X_train_abbrev2 = X_train_abbrev.copy()
X_test_abbrev2 = X_test_abbrev.copy()
for key in meta_info.keys():
    scaler = MinMaxScaler()
    X_train_abbrev2[[key]] =scaler.fit_transform(
      X_train_abbrev2[[key]])
    X_test_abbrev2[[key]] = scaler.transform(X_test_abbrev2[[key]])
    meta_info[key]["scaler"] = scaler
meta_info["CARRIER_DELAY"] = {"type":"target", "values":["no", "yes"]}
X_train_abbrev2 = X_train_abbrev2.to_numpy().astype(np.float32)
X_test_abbrev2 = X_test_abbrev2.to_numpy().astype(np.float32)
y_train_class2 = y_train_class.to_numpy().reshape(-1,1)
y_test_class2 = y_test_class.to_numpy().reshape(-1,1) 

现在我们有了 meta_info 字典,数据集也已准备就绪,我们可以初始化并拟合 GAMI-Net 到训练数据。除了 meta_info,它还有很多参数:interact_num 定义了它应该考虑多少个顶级交互,而 task_type 定义了它是一个分类任务还是回归任务。请注意,GAMI-Net 训练三个神经网络,因此有三个 epoch 参数需要填写(main_effect_epochsinteraction_epochstuning_epochs)。学习率 (lr_bp) 和早期停止阈值 (early_stop_thres) 作为每个 epoch 参数的列表输入。你还会找到网络架构的列表,其中每个条目对应于每层的节点数(interact_archsubnet_arch)。此外,还有批大小、激活函数、是否强制执行遗传约束、早期停止的损失阈值以及用于验证的训练数据百分比(val_ratio)等附加参数。最后,还有两个用于单调约束的可选参数(mono_increasing_listmono_decreasing_list):

gami_mdl = GAMINet(
    meta_info=meta_info,
    interact_num=8,
    task_type="Classification",
    main_effect_epochs=80,
    interaction_epochs=60,
    tuning_epochs=40,
    lr_bp=[0.0001] * 3,
    early_stop_thres=[10] * 3,
    interact_arch=[20] * 5,
    subnet_arch=[20] * 5,
    batch_size=200,
    activation_func=tf.nn.relu,
    heredity=True, 
    loss_threshold=0.01,
    val_ratio=0.2,
    verbose=True,
    reg_clarity=1,
    random_state=rand
)
gami_mdl.fit(X_train_abbrev2, y_train_class2) 

我们可以使用 plot_trajectory 绘制所有三个训练的每个 epoch 的训练损失。然后,使用 plot_regularization,我们可以绘制主效应网络和交互网络正则化的结果。这两个绘图函数都可以将图像保存到文件夹中,但默认情况下会保存到名为 results 的文件夹中,除非你使用 folder 参数更改路径:

data_dict_logs = gami_mdl.summary_logs(save_dict=False)
plot_trajectory(data_dict_logs)
plot_regularization(data_dict_logs) 
Figure 3.19:

图形用户界面 自动生成的描述图 3.19:GAMI-net 训练过程的轨迹和正则化图

图 3.19 讲述了三个阶段如何依次减少损失,同时在正则化过程中尽可能保留最少的特征和交互。

全局解释

可以使用 global_explain 函数从字典中提取全局解释,然后使用 feature_importance_visualize 将其转换为特征重要性图,如下面的代码片段所示:

data_dict_global = gami_mdl.global_explain(save_dict=True)
feature_importance_visualize(data_dict_global)
plt.show() 

前面的代码片段输出以下图表:

直方图 使用中等置信度自动生成的描述

图 3.20:全局解释图

图 3.20 所示,最重要的特征无疑是 DEP_DELAY,其中一个交互作用在图表中排名前六。我们还可以使用 global_visualize_density 图表来输出部分依赖图,这将在 第四章全局模型无关解释方法 中介绍。

局部解释

让我们使用 local_explain 来解释单个预测,然后是 local_visualize。我们选择了测试案例 #73:

data_dict_local = gami_mdl.local_explain(
    X_test_abbrev2[[73]], y_test_class2[[73]], save_dict=False
)
local_visualize(data_dict_local[0])
plt.tight_layout()
plt.show() 

之前的代码生成了 图 3.21 的图表:

图形用户界面,应用程序,团队 描述自动生成

图 3.21:测试案例 #73 的局部解释图

图 3.21 讲述了每个特征在结果中的权重。注意 DEP_DELAY 超过 50,但有一个截距几乎抵消了它。截距是一种平衡因素——毕竟,数据集在 CARRIER_DELAY 不太可能的情况下是不平衡的。但截距之后的所有后续特征都不足以推动结果向积极方向变化。

性能

为了确定 GAMI-Net 模型的预测性能,我们只需要获取测试数据集的分数 (y_test_prob) 和预测 (y_test_pred),然后使用 scikit-learn 的 metrics 函数来计算它们:

y_test_prob = gami_mdl.predict(X_test_abbrev2)
y_test_pred = np.where(y_test_prob > 0.5, 1, 0)
print(
    'accuracy: %.3g, recall: %.3g, roc auc: %.3g, f1: %.3g,
      mcc: %.3g'
    % (
        metrics.accuracy_score(y_test_class2, y_test_pred),
        metrics.recall_score(y_test_class2, y_test_pred),
        metrics.roc_auc_score(y_test_class2, y_test_prob),
        metrics.f1_score(y_test_class2, y_test_pred),
        metrics.matthews_corrcoef(y_test_class2, y_test_pred)
    )
) 

之前的代码产生了以下指标:

accuracy: 0.991, recall: 0.934, roc auc: 0.998,
f1: 0.924, mcc: 0.919 

考虑到它在 10% 的训练数据上训练,并在仅 10% 的测试数据上评估,性能还不错——特别是召回率,它排名前三。

任务完成

任务是训练模型,能够以足够的准确性预测可预防的延误,然后,根据这些模型,了解影响这些延误的因素,以改善 OTP。结果回归模型平均预测的延误都低于 15 分钟的阈值,根据 RMSE 来看。而且,大多数分类模型达到了 50% 以上的 F1 分数——其中一个达到了 98.8%!我们还设法找到了影响所有白盒模型延误的因素,其中一些表现相当不错。所以,这似乎是一次巨大的成功!

还不要庆祝!尽管指标很高,但这次任务失败了。通过解释方法,我们发现模型之所以准确,主要是因为错误的原因。这一认识有助于巩固一个关键的教训,即模型可以轻易地因为错误的原因而正确,所以 “为什么?”这个问题不仅仅是在模型表现不佳时才需要问,而是一直都应该问。而使用解释方法正是我们提出这个问题的途径。

但如果任务失败了,为什么这个部分还被称为 Mission accomplished? 好问题!

事实上,有一个秘密任务。提示:这是本章的标题。它的目的是通过公开任务的失败来了解常见的解释挑战。以防你错过了,以下是我们在探索中遇到的解释挑战:

  • 传统的模型解释方法仅涵盖关于你模型的表面级问题。请注意,我们不得不求助于特定模型的全球解释方法来发现模型之所以正确,是因为错误的原因。

  • 假设可能会使任何机器学习项目脱轨,因为这是你没有证据就假设的信息。请注意,与领域专家密切合作,在整个机器学习工作流程中做出决策至关重要,但有时他们也可能误导你。确保你检查数据与你对数据的假设之间的不一致性。发现和纠正这些问题是可解释性的核心。

  • 许多模型类别,即使是白盒模型,在一致和可靠地计算特征重要性方面都有问题。

  • 模型调优不当可能导致模型性能足够好,但可解释性较差。请注意,正则化模型过拟合较少,但可解释性也更强。我们将在第十二章中介绍解决这一挑战的方法,即单调约束和模型调优以提高可解释性。特征选择和工程也可能产生相同的效果,你可以在第十章中阅读有关内容,即特征选择和工程以提高可解释性

  • 预测性能与可解释性之间存在权衡。这种权衡还扩展到执行速度。因此,本书主要关注黑盒模型,它们具有我们想要的预测性能和合理的执行速度,但在可解释性方面可能需要一些帮助。

如果你了解了这些挑战,那么恭喜你!任务完成!

摘要

在阅读本章之后,我们了解了某些传统的可解释性方法及其局限性。我们学习了内在可解释模型以及如何使用和解释这些模型,无论是回归还是分类。我们还研究了性能与可解释性之间的权衡,以及一些试图不在这场权衡中妥协的模型。我们还发现了许多涉及特征选择和工程、超参数、领域专家和执行速度的实际解释挑战。

在下一章中,我们将学习更多关于不同解释方法来衡量特征对模型影响的内容。

数据集来源

美国交通部运输统计局。(2018)。航空公司准时性能数据。最初从www.transtats.bts.gov检索。

进一步阅读

  • Friedman, J. 和 Popescu, B, 2008, 通过规则集预测学习. 应用统计年鉴,2(3),916-954. doi.org/10.1214/07-AOAS148

  • Hastie, T., Tibshirani, R., 和 Wainwright, M., 2015, 稀疏性统计学习:Lasso 及其推广. 奇普曼 & 哈尔/统计学与应用概率系列专著,泰勒 & 弗朗西斯

  • Thomas, D.R., Hughes, E., 和 Zumbo, B.D., 1998, 关于线性回归中的变量重要性. 社会指标研究 45,253–275: doi.org/10.1023/A:1006954016433

  • Nori, H., Jenkins, S., Koch, P., 和 Caruana, R., 2019, InterpretML:机器学习可解释性的统一框架. arXiv 预印本:arxiv.org/pdf/1909.09223.pdf

  • Hastie, T. 和 Tibshirani, R., 1987, 广义加性模型:一些应用. 美国统计学会会刊,82(398):371–386: doi.org/10.2307%2F2289439

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

第四章:全球模型无关解释方法

在本书的第一部分,我们介绍了机器学习解释的概念、挑战和目的。本章开启了第二部分,深入探讨用于诊断模型和理解其底层数据的各种方法。解释方法回答的最大问题之一是:模型最关心的是什么,以及它是如何关心的? 解释方法可以揭示特征的整体重要性以及它们如何——单独或结合——影响模型的输出。本章将为回答这些问题提供理论和实践基础。

首先,我们将通过检查模型的内在参数来探讨特征重要性的概念。随后,我们将研究如何以模型无关的方式使用置换特征重要性来有效地、可靠地、自主地对特征进行排序。最后,我们将概述SHapley Additive exPlanationsSHAP)如何纠正置换特征重要性的某些不足。

本章将探讨几种可视化全局解释的方法,例如 SHAP 的条形图和蜂群图,然后深入到特征特定的可视化,如部分依赖图PDP)和累积局部效应ALE)图。最后,特征交互可以丰富解释,因为特征通常会结成团队,所以我们将讨论二维 PDP 和 ALE 图。

本章我们将涵盖的主要内容包括:

  • 什么是特征重要性?

  • 使用模型无关方法衡量特征重要性

  • 使用 SHAP、PDP 和 ALE 图可视化:

    • 全局解释

    • 特征总结说明

    • 特征交互

技术要求

本章的示例使用了pandasnumpysklearncatboostseabornmatplotlibshappdpboxpyale库。如何安装所有这些库的说明可以在 GitHub 仓库的README.md文件中找到。

本章的代码位于此处:packt.link/Ty0Ev

任务

美国的二手车市场是一个繁荣且规模庞大的行业,对经济有着显著的影响。近年来,每年大约有 4000 万辆二手车被售出,占汽车行业年度总销售的超过三分之二。此外,该市场一直保持着持续的增长,这得益于新车成本的上升、汽车使用寿命的延长以及消费者对性价比的感知不断增强。因此,这个市场细分对企业和消费者来说变得越来越重要。

鉴于市场机会,一家技术初创公司目前正在开发一个基于机器学习的二手车销售双边市场。它计划与电子商务网站 eBay 类似,但专注于汽车。例如,卖家可以以固定价格列出他们的汽车或将其拍卖,买家可以选择支付更高的固定价格或参与拍卖,但你是如何确定价格起点的呢?通常,卖家定义价格,但网站可以生成一个最优价格,以最大化所有参与者的整体价值,确保平台在长期内保持有吸引力和可持续性。

最佳定价并非简单解决方案,因为它需要在平台上的买家和卖家数量之间保持健康平衡,同时确保买家和卖家都认为它是公平的。它需要与其他平台保持竞争力,同时实现盈利。然而,这一切都始于一个能够估算公平价值的定价预测模型,然后,在此基础上,它可以结合其他模型和约束条件进行调整。为此,这家初创公司的一位数据科学家已经从 Craigslist 获取了二手车列表数据集,并将其与其他来源合并,例如美国人口普查局的人口数据以及环境保护署EPA)的排放数据。想法是使用这个数据集训练一个模型,但我们不确定哪些特征是有帮助的。这正是你需要介入的地方!

你被雇佣来解释哪些特征对机器学习模型有用以及为什么。这是至关重要的,因为初创公司只想要求卖家在获得价格估算之前提供关于他们汽车的最基本信息。当然,有一些细节,如汽车的制造商和型号,甚至颜色,另一个机器学习模型可以从图片中自动猜测。然而,一些特征,如变速器或汽缸数,可能在汽车型号中有所不同,卖家可能不知道或不愿意透露这些信息。限制提问可以减少摩擦,从而使得更多卖家能够成功完成他们的列表。

方法

你已经决定采取以下步骤:

  1. 训练几个模型。

  2. 评估它们。

  3. 使用多种方法创建特征重要性值,包括模型特定的和非模型特定的。

  4. 绘制全局摘要图、特征摘要图和特征交互图,以了解这些特征如何与结果以及彼此相关。

图表将帮助你向技术初创公司的管理层和数据科学同事传达发现。

准备工作

你可以在这里找到此示例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/tree/main/04/UsedCars.ipynb

加载库

要运行此示例,您需要安装以下库:

  • 使用 mldatasets 加载数据集

  • 使用 pandasnumpy 进行操作

  • 使用 sklearn(scikit-learn)和 catboost 加载和配置模型

  • 使用 matplotlibseabornshappdpboxpyale 生成和可视化模型解释

您应该首先加载所有这些:

import math
import os, random
import numpy as np
import pandas as pd
import mldatasets
from sklearn import metrics, ensemble, tree, inspection,\
                    model_selection
import catboost as cb
import matplotlib.pyplot as plt
import seaborn as sns
import shap
from pdpbox import pdp, info_plots
from PyALE import ale
from lime.lime_tabular import LimeTabularExplainer 
usedcars dataset:
usedcars_df = mldatasets.load("usedcars", prepare=True) 

您可以使用 usedcars_df.info() 函数进行检查,并验证确实有超过 256,000 行和 29 列,没有空值。其中一些是 object 数据类型,因为它们是分类数据。

该数据集的数据字典如下:

  • 与列表相关的变量:

    • price:目标,连续型,表示车辆发布的价格

    • region:分类,表示列表的地区——通常这是一个城市、大都市区或(对于更乡村的地区)州的一部分或人口最少的州(共 402 个)

    • posting_date:日期时间,表示发布日期和时间(所有发布都是 2021 年单月期间的,因此您无法通过它观察到季节性模式)

    • lat:连续型,表示十进制度纬度

    • long:连续型,表示十进制度经度

    • state:分类,表示两位州代码(共 51 个,包括[DC])

    • city:分类,表示城市名称(共超过 6,700 个)

    • zip:名义型,表示邮政编码(共超过 13,100 个)

  • 与车辆相关的变量:

    • make:分类,表示车辆的品牌或制造商(共 37 个)

    • make_cat:分类,表示制造商的类别(共 5 个)。对于不再生产的制造商,如“Saturn”,[豪华运动型]适用于“法拉利”等品牌,[豪华型]适用于“奔驰”等品牌。其他所有都是[常规型]或[高级型]。唯一的区别是[高级型]包括像“凯迪拉克”和“Acura”这样的品牌,它们是[常规型]类别中汽车制造商的高端品牌。

    • make_pop:连续型,表示制造商的相对流行度(百分比,0-1)

    • model:分类,表示车型(共超过 17,000 个)

    • model_premier:二元型,表示该车型是否是豪华版本/修剪(如果该车型本身不是高端,例如豪华、豪华运动型或高级类别)

    • year:序数型,表示车型的年份(从 1984-2022)

    • make_yr_pop:连续型,表示制造商在其制造年份的相对流行度(百分比,0-1)

    • model_yr_pop:连续型,表示该车型在其制造年份的相对流行度(百分比,0-1)

    • odometer:连续型,表示车辆里程表上的读数

    • auto_trans:二元型,表示汽车是否有自动变速器——否则为手动变速

    • fuel:分类,表示使用的燃料类型(共 5 个:[gas]、[diesel]、[hybrid]、[electric] 和 [other])

    • model_type:分类(共 13 个:[sedan]、[SUV] 和 [pickup] 是最受欢迎的三个,远远超过其他)

    • drive:分类,表示是四轮驱动、前轮驱动还是后轮驱动(共 3 个:[4wd]、[fwd] 和 [rwd])

    • cylinders:名义,发动机的汽缸数(从 2 到 16)。一般来说,汽缸越多,马力越高

    • title_status:分类,标题对车辆状态(在 7 个类别中,如[clean]、[rebuilt]、[unknown]、[salvage]和[lien])的描述

    • condition:分类,车主报告的车辆状况(在 7 个类别中,如[good]、[unknown]、[excellent]和[like new])

  • 与车辆排放相关的变量:

    • epa_co2:连续,尾气排放的 CO2(以克/英里计)。对于 2013 年之后的模型,它基于 EPA 测试。对于之前的年份,CO2 使用 EPA 排放因子(-1 =不可用)进行估算

    • epa_displ:连续,发动机排量(以升计,0.6-8.4)

  • 与列表的 ZIP 代码相关的变量:

    • zip_population:连续,人口

    • zip_density:连续,密度(每平方英里的居民数)

    • est_households_medianincome_usd:连续,家庭中位数收入

数据准备

我们应该对这些变量应用分类编码,以便每个类别有一个列,但只有当类别至少有 500 条记录时才这样做。我们可以使用make_dummies_with_limits实用函数来完成此操作。但首先,让我们备份原始数据集:

usedcars_orig_df = usedcars_df.copy()
usedcars_df = mldatasets.make_dummies_with_limits(
    usedcars_df,
    'fuel',
    min_recs=500,
    defcatname='other'
)
usedcars_df = mldatasets.make_dummies_with_limits(
    usedcars_df,
    'make_cat'
)
usedcars_df = mldatasets.make_dummies_with_limits(
    usedcars_df,
    'model_type',
    min_recs=500,
    defcatname='other'
)
usedcars_df = mldatasets.make_dummies_with_limits(
    usedcars_df,
    'condition',
    min_recs=200,
    defcatname='other'
)
usedcars_df = mldatasets.make_dummies_with_limits(
    usedcars_df,
    'drive'
)
usedcars_df = mldatasets.make_dummies_with_limits(
    usedcars_df,
    'title_status',
    min_recs=500,
    defcatname='other'
) 
object columns by turning them into a series of binary columns (of the uint8 type). However, there are still a few object columns left. We can find out which ones like this:
usedcars_df.dtypes[lambda x: x == object].index.tolist() 

我们不需要这些列,因为我们已经有了latitudelongitude以及一些人口统计特征,这些特征为模型提供了一些关于汽车销售地点的信息。至于makemodel,我们有makemodel的流行度和类别特征。我们可以通过仅选择数值特征来简单地删除非数值特征,如下所示:

usedcars_df = usedcars_df.select_dtypes(
    include=(int,float,np.uint8)
) 

最终的数据准备步骤是:

  1. 定义我们的随机种子(rand)以确保可重复性。

  2. 将数据分为X(特征)和y(标签)。前者包含所有列,除了目标变量(target_col)。后者仅包含目标。

  3. 最后,使用 scikit-learn 的train_test_split函数随机将Xy分为训练和测试组件:

rand = 42
os.environ['PYTHONHASHSEED']=str(rand)
np.random.seed(rand)
random.seed(rand)
target_col = 'price'
X = usedcars_df.drop([target_col], axis=1)
y = usedcars_df[target_col]
X_train, X_test, y_train, y_test = model_selection.train_test_split(
    X, y, test_size=0.25, random_state=rand
) 

现在我们已经拥有了继续前进所需的一切,所以我们将继续进行一些模型训练!

模型训练和评估

 two classifiers, CatBoost and Random Forest:
cb_mdl = cb.CatBoostRegressor(
    depth=7, learning_rate=0.2, random_state=rand, verbose=False
)
cb_mdl = cb_mdl.fit(X_train, y_train)
rf_mdl =ensemble.RandomForestRegressor(n_jobs=-1,random_state=rand)
rf_mdl = rf_mdl.fit(X_train.to_numpy(), y_train.to_numpy()) 

接下来,我们可以使用回归图和一些指标来评估 CatBoost 模型。运行以下代码,将输出图 4.1

mdl = cb_mdl
y_train_pred, y_test_pred = mldatasets.evaluate_reg_mdl(
    mdl, X_train, X_test, y_train, y_test
) 

CatBoost 模型产生了高达 0.94 的 R-squared 和近 3,100 的测试 RMSE。图 4.1中的回归图告诉我们,尽管有相当多的案例具有极高的误差,但 64,000 个测试样本中的绝大多数都被相当好地预测了。你可以通过运行以下代码来确认这一点:

thresh = 4000
pct_under = np.where(
    np.abs(y_test - y_test_pred) < thresh, 1, 0
).sum() / len(y_test)
print(f"Percentage of test samples under ${thresh:,.0f} in absolute
      error {pct_under:.1%}") 

它表示在$4,000 范围内的绝对误差的测试样本百分比接近 90%。当然,对于价值几千美元的汽车来说,这是一个很大的误差范围,但我们只是想了解模型的准确性。我们可能需要对其进行改进以用于生产,但现在它将满足技术初创公司要求的练习:

图 4.1:CatBoost 模型预测性能

cb_mdl with rf_mdl in the first line.

随机森林对测试样本的表现同样出色,其指标与 CatBoost 非常相似,但在这个案例中它过度拟合得更多。这很重要,因为我们现在将在两者上比较一些特征重要性方法,并且不希望预测性能的差异成为怀疑任何发现的原因。

特征重要性是什么?

特征重要性指的是每个特征对模型最终输出的贡献程度。对于线性模型,由于系数明确指示了每个特征的贡献,因此确定其重要性较为容易。然而,对于非线性模型来说,情况并不总是如此。

为了简化这个概念,让我们将模型类别与各种团队运动进行比较。在一些运动中,很容易识别出对比赛结果影响最大的球员,而在另一些运动中则不然。让我们以两项运动为例:

  • 接力赛跑:在这项运动中,每位运动员跑的距离相等,比赛的结局很大程度上取决于他们完成自己部分的速度。因此,很容易分离和量化每位运动员的贡献。接力赛跑与线性模型相似,因为比赛的结局是独立组件的线性组合。

  • 篮球:在这项游戏中,球员们有各自不同的角色,使用相同的指标来比较他们是不可能的。此外,不断变化的比赛条件和球员之间的互动可能会显著影响他们对比赛结果的影响。那么,我们如何衡量和排名每位球员的贡献呢?

模型具有固有的参数,有时可以帮助揭示每个特征的贡献。我们训练了两个模型。它们的内在参数是如何用来计算特征重要性的?

让我们从随机森林开始。如果你用以下代码绘制其估计器之一,它将生成图 4.2。因为每个估计器最深可达六层,所以我们只绘制到第二层(max_depth=2),因为否则文本会太小。但你可以自由地增加max_depth

tree.plot_tree(rf_mdl.estimators_[0], filled=True, max_depth=2,\
              feature_names=X_train.columns) 

注意图 4.2中每个节点估计器的squared_errorsamples。通过将这些数字相除,你可以计算出均方误差MSE)。虽然对于分类器来说,它是基尼系数,但对于回归器来说,MSE 是不纯度度量。随着你深入树中,它预计会降低,因此计算每个特征在整个树中的加权不纯度之和。一个特征降低节点不纯度的程度表明它对模型结果的贡献有多大。这将是一个特定于模型的方法,因为它不能用于线性模型或神经网络,但这正是基于树的模型计算特征重要性的方式。随机森林也不例外。然而,它是一个集成,因此它有一系列估计器,所以特征重要性是所有估计器之间不纯度减少的平均值。

图片

图 4.2:随机森林模型第一个估计器的第一级

我们可以使用以下代码片段获取并打印随机森林模型的特征重要性值:

rf_feat_imp = rf_mdl.feature_importances_
print(rf_feat_imp) 

注意,由于它们已经被归一化,它们的总和为 1(sum(rf_feat_imp))。

CatBoost 默认使用一种不同的方法来计算特征重要性,称为PredictionValuesChange。它显示了如果特征值发生变化,模型结果平均变化了多少。它在树中遍历,根据每个分支(左或右)中的节点数量对特征贡献进行加权平均。如果它在节点中遇到特征组合,则将贡献平均分配给每个特征。因此,它可能会为通常相互作用的特征产生误导性的特征重要性值。

您也可以使用feature_importances_像这样检索 CatBoost 特征重要性,并且与随机森林不同,它们的总和为 100 而不是 1:

cb_feat_imp = cb_mdl.feature_importances_
print(cb_feat_imp) 
Figure 4.3:
feat_imp_df = pd.DataFrame(
    {
        'feature':X_train.columns,
        'cb_feat_imp':cb_feat_imp,
        'rf_feat_imp':rf_feat_imp*100
    }
)
feat_imp_df = feat_imp_df.sort_values('cb_feat_imp', ascending=False)
feat_imp_df.style.format(
    '{:.2f}%', subset=['cb_feat_imp', 'rf_feat_imp'])
    .bar(subset=['cb_feat_imp', 'rf_feat_imp'], color='#4EF', width=60
) 

注意,在图 4.3中,两个模型都有相同的最重要特征。前十位特征大多相同,但排名不同。特别是,odometer对于 CatBoost 似乎比对于随机森林更重要。此外,除了最不重要的特征外,其他所有特征在排名上都不匹配,而对于最不重要的特征,人们普遍认为它们确实是最后的:

图片

图 4.3:比较两个模型的特征重要性值

我们如何解决这些差异并采用一种始终如一地表示特征重要性的技术?我们将使用模型无关的方法来探讨这个问题。

使用模型无关方法评估特征重要性

模型无关的方法意味着我们将不会依赖于模型参数来计算特征重要性。相反,我们将把模型视为一个黑盒,只有输入和输出是可见的。那么,我们如何确定哪些输入产生了影响?

如果我们随机改变输入呢?确实,评估特征重要性最有效的方法之一是通过设计用于衡量特征影响或缺乏影响的模拟。换句话说,让我们从游戏中随机移除一个玩家并观察结果!在本节中,我们将讨论两种实现方式:排列特征重要性和 SHAP。

排列特征重要性

一旦我们有一个训练好的模型,我们就不能移除一个特征来评估不使用它的影响。然而,我们可以:

  • 将特征替换为静态值,如平均值或中位数,使其失去有用的信息。

  • 打乱(排列)特征值以破坏特征与结果之间的关系。

置换特征重要性(permutation_importance)使用测试数据集的第二种策略。然后它测量分数(MSE,r2,f1,准确度等)的变化。在这种情况下,当特征被打乱时,负平均绝对误差MAE)的显著下降表明该特征对预测有很高的影响。它必须重复打乱几次(n_repeats),通过平均减少准确度来得出结论性的结果。请注意,随机森林回归器的默认评分器是 R-squared,而 CatBoost 是 RMSE,所以我们通过设置scoring参数确保它们都使用相同的评分器。以下代码为两个模型执行所有这些操作:

cb_perm_imp = inspection.permutation_importance(
    cb_mdl, X_test, y_test, n_repeats=10, random_state=rand,\
    scoring='neg_mean_absolute_error'
)
rf_perm_imp = inspection.permutation_importance(
    rf_mdl, X_test.to_numpy(), y_test.to_numpy(), n_repeats=10,\
    random_state=rand, scoring='neg_mean_absolute_error'
) 

该方法输出每个模型的平均分数(importances_mean)和标准差(importances_std),这些分数跨越所有重复,我们可以将其放入pandas DataFrame 中,排序和格式化,就像我们之前对特征重要性所做的那样。以下代码生成了图 4.4中的表格:

perm_imp_df = pd.DataFrame(
    {
        'feature':X_train.columns,
        'cb_perm_mean':cb_perm_imp.importances_mean,
        'cb_perm_std':cb_perm_imp.importances_std,
        'rf_perm_mean':rf_perm_imp.importances_mean,
        'rf_perm_std':rf_perm_imp.importances_std
    }
)
perm_imp_df = perm_imp_df.sort_values(
    'cb_perm_mean', ascending=False
)
perm_imp_df.style.format(
    '{:.4f}', subset=['cb_perm_mean', 'cb_perm_std', 'rf_perm_mean',
    'rf_perm_std']).bar(subset=['cb_perm_mean', 'rf_perm_mean'],\
    color='#4EF', width=60
) 

图 4.4中,两个模型的前四个特征与图 4.3相比要一致得多——并不是因为它们必须一致,因为它们是不同的模型!然而,考虑到它们是从相同的方法中得出的,这还是有意义的。也存在相当大的差异。随机森林似乎更依赖于少数特征,但如果它们达到与 CatBoost 非常相似的预测性能,这些特征可能并不那么必要:

图片

图 4.4:比较两个模型的置换特征重要性值

置换特征重要性可以理解为当特征变得无关紧要时,模型误差的平均增加,以及它与其他特征的交互。由于模型不需要重新训练,它相对快速地计算,但它的具体值应该谨慎对待,因为这种打乱技术有一些缺点:

  • 将高度相关的特征与另一个未打乱的特征进行打乱可能不会显著影响预测性能,因为未打乱的特征保留了从打乱的特征中的一些信息。这意味着它不能准确评估多重共线性特征的重要性。

  • 打乱可能导致不切实际的观察结果,例如用天气预测车辆流量,结果夏天预测出冬季温度。这将导致从未遇到过此类示例的模型预测误差更高,夸大了重要性评分的实际意义。

因此,这些重要性值仅适用于对特征进行排序,并衡量它们在特定模型中相对于其他特征的相对重要性。我们现在将探讨 Shapley 值如何帮助解决这些问题。

SHAP 值

在深入研究 SHAP 值之前,我们应该讨论Shapley 值。SHAP 是 Shapley 值的实现,它采取了一些自由,但保持了许多相同的属性。

我们在游戏的背景下讨论特征重要性是合适的,因为 Shapley 值植根于合作博弈论。在这种情况下,玩家形成不同的集合,称为联盟,当他们玩游戏时,他们会得到不同的分数,称为边际贡献。Shapley 值是这些贡献在多次模拟中的平均值。在特征重要性的方面,玩家代表特征,玩家的集合代表特征的集合,边际贡献与预测误差的减少有关。

这种方法可能看起来与置换特征重要性相似,但它的重点是特征组合而不是单个特征,这有助于解决多重共线性问题。此外,通过这种方法获得的价值满足几个有利的数学属性,例如:

  • 可加性:部分的总和等于总价值

  • 对称性:对等贡献的一致值

  • 效率:等于预测值与期望值之间的差异

  • 虚拟值:对不影响结果的特征的零值

在实践中,这种方法需要大量的计算资源。例如,五个特征会产生img/B18406_04_001.png个可能的联盟,而 15 个特征则会产生 32,768 个。因此,大多数 Shapley 实现使用像蒙特卡洛采样或利用模型内在参数(这使得它们具有模型特定性)这样的捷径。SHAP 库采用各种策略来减少计算负担,同时不会过多牺牲 Shapley 属性。

使用 KernelExplainer 的综合解释

在 SHAP 中,最普遍的模型无关方法是KernelExplainer,它基于局部可解释模型无关解释LIME)。如果你不理解具体细节,不要担心,我们将在第五章“局部模型无关解释方法”中详细讲解。为了减少计算量,它采用了样本联盟。此外,它遵循与 LIME 相同的程序,例如拟合加权线性模型,但使用 Shapley 样本联盟和不同的核函数,该核函数返回 SHAP 值作为系数。

KernelExplainer可以用训练数据集的kmeans背景样本(X_train_summary)初始化,这有助于它定义核函数。它可能仍然很慢。因此,最好不要使用大型数据集来计算shap_values。相反,在下面的代码中,我们只使用了测试数据集的 2%(X_test_sample):

rf_fn = lambda x: rf_mdl.predict(x)
X_train_summary = shap.kmeans(X_train.to_numpy(), 50)
X_test_sample = X_test.sample(frac=0.02)
rf_kernel_explainer = shap.KernelExplainer(rf_fn, X_train_summary)
rf_shap_values = rf_kernel_explainer.shap_values(
    X_test_sample.to_numpy()
) 

运行整个过程可能需要一些时间。如果时间过长,请随意将样本大小从0.02减少到0.005。SHAP 值将不太可靠,但这只是一个示例,你可以尝尝KernelExplainer的滋味。

一旦完成,请运行print(rf_shap_values.shape)来了解我们将要处理的维度。注意,它是二维的!每个观察值和特征都有一个 SHAP 值。因此,SHAP 值可以用于全局和局部解释。记住这一点!我们将在下一章中介绍局部解释。现在,我们将查看另一个 SHAP 解释器。

使用 TreeExplainer 加速解释

TreeExplainer被设计用来高效地估计基于树的模型(如 XGBoost、随机森林和 CART 决策树)的 SHAP 值。因为它使用条件期望值函数而不是边缘期望值,所以它可以给非影响特征分配非零值,这违反了 Shapley 虚拟属性。当特征共线性时,这可能会使解释变得不那么可靠。然而,它遵循其他属性。

您可以使用TreeExplainer获取 SHAP 值,如下所示:

cb_tree_explainer = shap.TreeExplainer(cb_mdl)
cb_shap_values = cb_tree_explainer.shap_values(X_test) 

如您所见,这更容易,也更快。它还输出一个类似于KernelExplainer的两维数组。您可以使用print(cb_shap_values.shape)进行检查。

对于特征重要性值,我们可以将两个维度合并为一个。我们只需要像这样对每个特征的平均绝对值进行操作:

cb_shap_imp = np.mean(np.abs(cb_shap_values),0) 

对于随机森林,只需将cb_替换为rf_相同的代码。

我们现在可以使用格式化和排序的pandas DataFrame 并排比较两个模型的 SHAP 特征重要性。以下代码将在图 4.5中生成表格。

shap_imp_df = pd.DataFrame(
    {
        'feature':X_train.columns,
        'cb_shap_imp':cb_shap_imp,
        'rf_shap_imp':rf_shap_imp
    }
)
shap_imp_df = shap_imp_df.sort_values('cb_shap_imp', ascending=False)
shap_imp_df.style.format(
    '{:.4f}', subset=['cb_shap_imp', 'rf_shap_imp']).bar(
    subset=['cb_shap_imp', 'rf_shap_imp'], color='#4EF', width=60
) 

图 4.5不仅比较了两个不同模型的特征重要性,还比较了两个不同的 SHAP 解释器。它们不一定都是完美的描述,但它们都比排列特征重要性更值得信赖:

图 4.5:比较两个模型的 SHAP 特征重要性值

对于两个模型,SHAP 分析表明loan_to_value_ratiomake_cat_va的重要性之前被低估了。这很有道理,因为loan_to_value_ratio与多个顶级特征高度相关,而make_cat_va与所有其他产品类型特征相关。

可视化全局解释

之前,我们介绍了全局解释和 SHAP 值的概念。但我们没有展示我们可以用许多方式可视化它们。正如您将学到的,SHAP 值非常灵活,可以用来检查比特征重要性更多的事情!

但首先,我们必须初始化一个 SHAP 解释器。在前一章中,我们使用shap.TreeExplainershap.KernelExplainer生成 SHAP 值。这次,我们将使用 SHAP 的新接口,它通过将 SHAP 值和对应数据保存在单个对象中以及更多来简化过程!我们不需要显式定义解释器的类型,而是使用shap.Explainer(model)初始化它,这将返回可调用的对象。然后,您将测试数据集(X_test)加载到可调用的Explainer中,它将返回一个Explanation对象:

cb_explainer = shap.Explainer(cb_mdl)
cb_shap = cb_explainer(X_test) 

如果你正在想,它是如何知道要创建哪种解释器的?很高兴你问了!初始化函数中有一个可选参数叫做 algorithm,你可以明确地定义 treelinearadditivekernel 等等。但默认情况下,它设置为 auto,这意味着它会猜测模型需要哪种解释器。在这种情况下,CatBoost 是一个树集成,所以 tree 是有意义的。我们可以很容易地通过 cb_explainer.[dict]{custom-style="P - Italics"}[ 或 ]print(type(cb_explainer)) 来检查 SHAP 是否选择了正确的解释器。它将返回 <class 'shap.explainers._tree.Tree'>,这是正确的!至于存储在 cb_shap 中的解释,它究竟是什么呢?它是一个包含几乎用于绘制解释所需的一切的对象,例如 SHAP 值 (cb_shap.values) 和相应的数据集 (cb_shap.data)。它们的维度应该完全相同,因为每个数据点都有一个 SHAP 值。我们可以通过使用 shape 属性来检查它们的维度来验证这一点:

print("Values dimensions: %s" % (cb_shap.values.shape,))
print("Data dimensions: %s" % (cb_shap.data.shape,)) 

现在,让我们来使用这些值吧!

SHAP 条形图

让我们从最直接的全球解释可视化开始,那就是特征重要性。你可以用条形图 (shap.plots.bar) 来做这件事。它只需要解释对象 (cb_shap),但默认情况下,它只会显示 10 个条形。幸运的是,我们可以用 max_display 来改变这个:

shap.plots.bar(cb_shap, max_display=15) 
Figure 4.6:

图 4.6:CatBoost 模型的 SHAP 特征重要性

图 4.6 如果你阅读了上一章,应该看起来非常熟悉。事实上,它应该与 图 4.5 中的 cb_shap_imp 列相匹配。

SHAP 特征重要性提供了相当大的灵活性,因为它只是每个特征 SHAP 值绝对值的平均值。有了 SHAP 值的粒度,你可以像测试数据集一样剖析它们,从而在各个维度上获得洞察。这比每个特征的单一平均值揭示了更多关于特征重要性的信息。

例如,你可以比较不同组之间的特征重要性。假设我们想探索 year 群体之间特征重要性的差异。首先,我们需要一个阈值来定义。让我们用 2014 年来定义,因为它是我们数据集中的中位数 year。高于该值的可以设置为“新车”群体,而 2014 年之前的值设置为“旧车”。然后,使用 np.where 创建一个数组,将群体分配给每个观测值。为了创建条形图,重复前面的过程,但使用群体函数来拆分解释,对每个群体应用绝对值 (abs) 和 mean 操作。

yr_cohort = np.where(
    X_test.year > 2014, "Newer Car", "Older Car"
)
shap.plots.bar(cb_shap.cohorts(yr_cohort).abs.mean(0)) 
Figure 4.7:

图 4.7:按属性值群体拆分的 CatBoost 模型 SHAP 特征重要性

如你在 图 4.7 中所见,对于“旧车”来说,所有顶级特征的重要性都较小。最大的不同之一是 year 本身。当一辆车变旧时,它变得有多旧并不像它在“新车”群体中那样重要。

SHAP 蜜蜂群图

柱状图可能会掩盖某些方面特征如何影响模型结果的情况。不仅不同的特征值有不同的影响,而且它们在所有观测值中的分布也可能表现出相当大的变化。蜜蜂群图通过使用点来表示每个个体特征的所有观测值,旨在提供更多洞察,尽管特征是按全局特征重要性排序的:

  • 点根据它们在每个特征低到高值范围内的位置进行着色。

  • 点根据它们对结果的影响水平横向排列,以 SHAP 值=0 的线为中心,左侧为负面影响,右侧为正面影响。

  • 点垂直累积,创建类似直方图的可视化,以显示每个特征在每个影响水平上影响结果观测值的数量。

为了更好地理解这一点,我们将创建一个蜜蜂群图。使用shap.plots.beeswarm函数生成图表很容易。它只需要解释对象(cb_shap),并且,与柱状图一样,我们将覆盖默认的max_display以仅显示 15 个特征:

shap.plots.beeswarm(cb_shap, max_display=15) 

前述代码的结果在 图 4.8 中:

图 4.8:CatBoost 模型的 SHAP 蜜蜂群图

图 4.8 可以这样阅读:

  • 从上到下,最重要的特征(15 个中的)是year,最不重要的是经度(long)。它应该与柱状图中的顺序相同,或者如果你取每个特征的 SHAP 值的平均绝对值。

  • year的较低值对模型结果有负面影响,而较高值则产生正面影响。中间有一个干净的梯度,表明year与预测的price之间存在递增的单调关系。也就是说,根据 CatBoost 模型,年份越高,价格越高。

  • odometer对相当一部分观测值有负面影响或可忽略的影响。然而,它对有显著影响的观测值有一个长长的尾巴。你可以通过查看垂直密度来识别这一点。

  • 如果你扫描图表的其余部分寻找其他连续特征,你不会在其他地方找到像yearodometer那样的干净梯度,但你将找到一些趋势,例如make_yr_popmodel_yr_pop的较高值主要产生负面影响。

  • 对于二元特征,很容易判断,因为你只有两种颜色,它们有时会整齐地分开,例如model_premiermodel_type_pickupdrive_fwdmake_cat_regularfuel_diesel,这展示了某种类型的车辆可能是模型高价的标志。在这种情况下,皮卡模型会增加价格,而具有常规制造的车辆(即非豪华品牌)会降低价格。

虽然蜂群图提供了许多发现的出色总结,但它有时可能难以解释,并且无法捕捉到所有内容。颜色编码对于说明特征值与模型输出之间的关系很有用,但如果你想要更多细节呢?这就是部分依赖图发挥作用的地方。它是许多特征摘要说明之一,提供了针对特征的全局解释方法。

特征摘要说明

本节将介绍用于可视化单个特征如何影响结果的一些方法。

部分依赖图

部分依赖图PDPs)根据模型显示特征与结果之间的关系。本质上,PDP 说明了特征对模型预测输出的边际效应,该效应考虑了该特征的所有可能值。

计算涉及两个步骤:

  1. 初始时,进行一个模拟,其中每个观察值的特征值被改变为一系列不同的值,并使用这些值预测模型。例如,如果year在 1984 年和 2022 年之间变化,则创建每个观察值的year值介于这两个数字之间的副本。然后,使用这些值运行模型。这一步可以绘制为个体条件期望ICE)图,其中模拟的year值位于 X 轴上,模型输出位于 Y 轴上,每个模拟观察值对应一条线。

  2. 在第二步中,只需简单地将 ICE 线平均,以获得一条总体趋势线。这条线代表 PDP!

可以使用 scikit-learn 创建 PDPs,但这些仅适用于 scikit-learn 模型。它们还可以使用 SHAP 库以及另一个名为 PDPBox 的库生成。每个都有其优点和缺点,我们将在本节中介绍。

SHAP 的partial_dependence图函数接受一个特征名称(year)、一个predict函数(cb_mdl.predict)和一个数据集(X_test)。还有一些可选参数,例如是否显示 ICE 线(ice)、一个水平模型期望值线(model_expected_value)和一个垂直特征期望值线(feature_expected_value)。默认情况下,它显示 ICE 线,但由于测试数据集中有如此多的观察值,生成该图将花费很长时间,并且会“过于繁忙”,无法欣赏趋势。SHAP PDP 还可以包含 SHAP 值(shap_values=True),但考虑到数据集的大小,这将花费非常长的时间。最好对您的数据集进行采样,使其更适合绘图:

shap.plots.partial_dependence(
    "year", cb_mdl.predict, X_test, ice=False, model_expected_value=True,\
    feature_expected_value=True
) 

上述代码将在图 4.9中生成该图:

图 4.9:SHAP 的year部分依赖图

正如你在图 4.9中可以欣赏到的,year有一个上升趋势。考虑到图 4.8year的整洁梯度,这个发现并不令人惊讶。通过直方图,你可以看出大部分观察到的year值在 2011 年及以上,这是它开始对模型产生超过平均水平影响的地方。一旦你将直方图的位置与 beeswarm 图(图 4.8)中突起的位置进行比较,这一点就变得有意义了。

使用 PDPBox,我们将制作几种 PDP 图表的变体。这个库将使用PDPIsolate函数进行模拟的耗时过程与使用plot函数进行绘图的过程分开。我们只需要运行一次PDPIsolate,但需要运行三次plot

对于第一个图表,我们使用plot_pts_dist=True来显示地毯图。地毯图是传达分布比直方图更简洁的方式。

对于第二个示例,我们使用plot_lines=True来绘制 ICE 线,但我们只能绘制其中的一部分,因此frac_to_plot=0.01随机选择其中的 1%。

对于第三个示例,我们不是显示地毯图,而是可以用分位数构建 X 轴(to_bins=True):

pdp_single_feature = pdp.PDPIsolate(
    model=cb_mdl, df=X_test, model_features=X_test.columns,\
    feature='year', feature_name='year', n_classes=0,\
    n_jobs=-1)
fig, axes = pdp_single_feature.plot(plot_pts_dist=True)
fig.show()
    fig, axes = pdp_single_feature.plot(
    plot_pts_dist=True, plot_lines=True, frac_to_plot=0.01
)
fig.show() 
    fig, axes = pdp_single_feature.plot(
    to_bins=True, plot_lines=True, frac_to_plot=0.01
)
fig.show() 
Figure 4.10:

图片

图 4.10:PDPBox 的 PDP 对于年份的三种变化

ICE 线通过展示方差潜力丰富了 PDP 图表。图 4.10中的最后一个图表也展示了即使地毯图或直方图是有用的指南,将轴组织在分位数上更能帮助可视化分布。在这种情况下,三分之二的year分布在 2017 年之前。1984 年至 2005 年之间的二十年只占 11%。它们在图表中应该得到相应的一部分。

我们现在可以创建几个列表,我们将使用这些列表遍历不同类型的特征,无论是连续的(cont_feature_l)、二元的(bin_feature_l)还是分类的(cat_feature_l):

cont_feature_l = [
    'year', 'odometer', 'cylinders', 'epa_displ', 'make_pop',\
    'make_yr_pop', 'model_yr_pop'
]
make_cat_feature_l = [
    'make_cat_obsolete', 'make_cat_regular', 'make_cat_premium',\
    'make_cat_luxury', 'make_cat_luxury_sports'
]
bin_feature_l = ['model_premier', 'auto_trans']
cat_feature_l = make_cat_feature_l + bin_feature_l 

为了快速了解每个特征的 PDP 看起来像什么,我们可以遍历一个特征列表,为每个特征生成 PDP 图表。我们将做连续特征(cont_feature_l),因为它最容易可视化,但你也可以尝试其他列表之一:

for feature in cont_feature_l:
    pdp_single_feature = pdp.PDPIsolate(
        model=cb_mdl, df=X_test, model_features=X_test.columns,\
        feature=feature, feature_name=feature, n_classes=0,\
        n_jobs=-1
    )
    fig, axes = pdp_single_feature.plot(
        to_bins=True, plot_lines=True, frac_to_plot=0.01,\
        show_percentile=True, engine='matplotlib'
    ) 

上述代码将输出八个图表,包括图 4.11中的那个:

图片

图 4.11:PDPBox 的里程表 PDP

图 4.8中的蜂群图显示,较低的里程表值与模型输出较高的价格相关。在图 4.11中,它描绘了价格主要单调递减,除了极端情况。有趣的是,在极端情况下存在里程表值为零和一千万的情况。虽然模型学习到当里程表为零时,里程表对价格没有影响是有道理的,但一千万的值是一个异常值,因此你可以看出 ICE 线朝不同方向延伸,因为模型不确定如何处理这样的值。我们还必须记住,ICE 图和因此 PDP 是通过模拟生成的,这些模拟可能会创建出在现实生活中不存在的例子,例如一个里程表值极高的全新车辆。

make_cat is a categorical feature that was one-hot encoded, so it’s not natural to create PDP plots as if each category were an independent binary feature.

幸运的是,你可以创建一个 PDP,其中每个类别的一元编码特征并排显示。你需要做的就是将产品类型特征的列表插入到feature属性中。PDPBox 还有一个“预测绘图”功能,可以通过显示特征值范围内的预测分布来提供上下文。PredictPlot易于绘图,具有与plot许多相同的属性。

pdp_multi_feature = pdp.PDPIsolate(
    model=cb_mdl, df=X_test, model_features=X_test.columns,\
    feature=make_cat_feature_l, feature_name="make_cat", n_classes=0, n_jobs=-1
)
fig, axes = pdp_multi_feature.plot(
    plot_lines=True, frac_to_plot=0.01, show_percentile=True
)
fig.show()
predict_plot = info_plots.PredictPlot(
    model=cb_mdl, df=X_test, feature_name="make_cat", n_classes=0,
    model_features=X_test.columns, feature=make_cat_feature_l
)
fig, _, _ = predict_plot.plot()
fig.show() 
Figure 4.12:

图片

图 4.12: PDPBox 针对品牌类别的实际绘图

图 4.12中的make_cat PDP 显示了“豪华”和“高级”类别倾向于导致更高的价格,但平均而言仅高出约 3,000 美元,ICE 线图中显示了大量的变异性。然而,记得之前提到的模拟并不一定代表现实场景吗?

如果我们将 PDP 与图 4.12中实际预测的箱线和须线进行比较,我们可以发现,在常规车型与“豪华”和“高级”任何一类车型之间的平均预测差异至少有七千美元。当然,平均数并不能说明全部情况,因为即使是里程数过多或非常旧的豪华车,其价格也可能低于常规车辆。价格取决于许多因素,而不仅仅是品牌的声誉。当然,仅从图 4.12中箱线和须线的排列来看,“豪华运动”和“过时”类别分别更强烈地表明了价格的高低。

PDP 通常易于解释且相对快速生成。然而,它所采用的模拟策略没有考虑特征分布,并且高度依赖于特征独立性的假设,这可能导致反直觉的例子。我们现在将探讨两种替代方案。

SHAP 散点图

SHAP 值对每个数据点都可用,使您可以将它们与特征值进行绘图,从而在y轴上得到模型影响(SHAP 值)和x轴上的特征值的 PDP-like 可视化。作为一个类似的概念,SHAP 库最初将其称为dependence_plot,但现在它被称为散点图。尽管有相似之处,PDP 和 SHAP 值的计算方式不同。

创建 SHAP 散点图很简单,只需要解释对象。可选地,你可以根据另一个特征对点进行颜色编码,以了解潜在的交互。你还可以使用xminxmax属性从x轴剪除异常值,并将点设置为 20%不透明(alpha),以便更容易地识别稀疏区域:

shap.plots.scatter(
    cb_shap[:,"odometer"], color=cb_shap[:,"year"], xmin="percentile(1)",\
    xmax="percentile(99)", alpha=0.2
)
shap.plots.scatter(
    cb_shap[:,"long"], color=cb_shap[:,"epa_displ"],\
    xmin="percentile(0.5)", xmax="percentile(99.5)", alpha=0.2, x_jitter=0.5
) 
Figure 4.13:

图 4.13:SHAP 的里程表和长度的散点图,分别用颜色编码年份和 epa_displ

图 4.13中的第一个图显示了较高的里程表读数如何对模型结果产生负面影响。此外,颜色编码显示,当里程表读数超过九万时,较高年份的 SHAP 值甚至更低。换句话说,一辆旧车有高里程表读数是可以预期的,但如果是新车,那就是一个红旗!

图 4.13中的第二个图非常有趣;它显示了该国西海岸(大约在-120°)与较高的 SHAP 值相关联,并且越往东走,SHAP 值越低。夏威夷、安克雷奇和阿拉斯加(大约在-150°)也高于美国的东海岸(大约在-75°)。颜色编码显示了燃料排量越多,SHAP 值越高,但当你越往东走,这种差异就越不明显。

散点图适用于连续特征,但你能否用它来表示离散特征?是的!让我们为make_cat_luxury创建一个散点图。由于x轴上只有两个可能的值,0 和 1,因此有意义的做法是使它们产生抖动,这样所有的点就不会重叠在一起。例如,x_jitter=0.4意味着它们将在水平方向上抖动最多 0.4,或者原始值的每侧 0.2。我们可以结合alpha来确保我们可以欣赏到密度:

shap.plots.scatter(
    cb_shap[:,"make_cat_luxury"], color=cb_shap[:,"year"], x_jitter=0.4,\
    alpha=0.2, hist=False
) 
Figure 4.14:

图 4.14:SHAP 的make_cat_luxury散点图,用颜色编码年份

图 4.14显示,根据 SHAP 值,make_cat_fha=1对模型结果有积极影响,而make_cat_fha=1则有一个轻微的负面影响。颜色编码表明,较低的年份会减弱影响,使其变小。这很有道理,因为旧款豪华车已经贬值。

虽然 SHAP 散点图可能比 PDP 图有所改进,但 SHAP 的树解释器以速度换取精确度,导致对模型没有影响的特征可能具有大于零的 SHAP 值。精确度指的是解释在表示模型行为方面的准确性。为了获得更高的精确度,您需要使用一种在理解模型做什么时采取较少捷径的方法,即使如此,对参数(如使用更大的样本量)的一些调整也会增加精确度,因为您正在使用更多数据来创建解释。在这种情况下,解决方案是使用 KernelExplainer,因为我们之前讨论过的,它更全面,但存在特征依赖问题。所以没有免费的午餐!接下来,我们将介绍 ALE 图作为这些问题的部分解决方案。

ALE 图

ALE 图相对于 PDP 图的优势在于它们是无偏的且速度更快。ALE 在计算特征效应时考虑数据分布,从而实现无偏表示。该算法将特征空间划分为等大小的窗口,并计算这些窗口内的预测变化,从而产生 局部效应。将所有窗口的效果相加,使它们成为 累积的

您可以使用 ale 函数轻松生成 ALE 图。您需要的只是一些数据(X_test_no_outliers)、模型(cb_mdl)以及要绘制的特征和特征类型。可选地,您可以输入 grid_size,这将定义局部效应计算的窗口大小。默认值为 20,但如果我们有足够的数据,我们可以将其增加以提高精确度。如前所述,一些参数的调整可能会影响精确度。对于窗口大小,它将数据分割成更小的区间以计算值,从而使它们更加细化。此外,默认情况下显示置信区间。顺便提一下,在这种情况下最好移除异常值,因为当最大贷款几乎达到 800 万美元时,很难欣赏到图表。您可以通过尝试使用 X_test 而不是 X_test_no_outliers 来了解我的意思。

默认情况下显示置信区间。在这种情况下,最好排除异常值,因为当只有极少数车辆代表 1994 年之前和 2021 年之后的年份,并且具有非常低和非常高的里程表读数时,很难欣赏到图表。您可以在 ale 函数中使用 X_test 而不是 X_test_no_outliers 来查看差异:

X_test_no_outliers = X_test[
    (X_test.year.quantile(.01) <= X_test.year) &\
    (X_test.year <= X_test.year.quantile(.99)) &\
    (X_test.odometer.quantile(.01) <= X_test.odometer) &\
    (X_test.odometer <= X_test.odometer.quantile(.99))
]
ale_effect = ale(
    X=X_test_no_outliers, model=cb_mdl, feature=['odometer'],\
    feature_type='continuous', grid_size=80
)
plt.show()
ale_effect = ale(
    X=X_test_no_outliers, model=cb_mdl,\
    feature=['make_cat_luxury'],feature_type='discrete'
)
plt.show() 
Figure 4.15:

图片

图 4.15:里程表和 make_cat_luxury 的 ALE 图

图 4.15中的第一个图是odometer的 ALE 图。正如图 4.13所示,随着里程表的读数增加,模型的影响从正面变为负面。然而,与 SHAP 不同,在 ALE 图中,它会在里程表达到 90,000 之前就变为负值。请注意,置信区间非常窄,只在 10,000 以下某处可见。第二个 ALE 图显示了“豪华”类别对结果有显著的正向影响,并且豪华车辆不像其他车辆那样有代表性。

到目前为止,我们只讨论了单特征解释。但我们也可以观察到特征之间的交互作用,这将在下一部分进行介绍。

特征交互

特征可能不会独立地影响预测。例如,如第二章中讨论的,仅根据体重确定肥胖是不可能的。一个人的身高或体脂、肌肉和其他百分比都是需要的。模型通过相关性理解数据,而特征通常是相关的,即使它们不是线性相关的。交互作用是模型可能对相关特征所做的。例如,决策树可能将它们放在同一个分支上,或者神经网络可能以某种方式安排其参数,从而产生交互效应。这种情况也出现在我们的案例中。让我们通过几个特征交互可视化来探讨这一点。

带有聚类的 SHAP 条形图

SHAP 附带一个分层聚类方法(shap.utils.hclust),可以根据任何给定特征对之间的“冗余”对训练特征进行分组。这指的是它们相互依赖的程度,从完全冗余(0)到完全独立(1)的范围内。我们不会使用整个数据集来完成这项任务,因为这会花费很长时间,所以我们将使用 10%的sample

X_samp = X.sample(frac=0.1)
y_samp = y.loc[X_samp.index]
clustering = shap.utils.hclust(X_samp, y_samp) 

我们可以使用与图 4.6相同的条形图,但这次,我们输入clustering和聚类截止值,voilá!我们可以可视化哪些特征最为冗余。我们的目标是确定小于 0.7 独立性的关系(clustering_cutoff):

shap.plots.bar(cb_shap, clustering=clustering, clustering_cutoff=0.7) 
Figure 4.16:

图片

图 4.16:带有聚类的 SHAP 条形图

图 4.16的右侧树状图中,揭示了哪些特征之间相互依赖最多,以及三个层级。例如,year依赖于odometer,当它们结合在一起时,它们都依赖于epa_co2,而epa_co2又间接依赖于包括燃油排量(epa_displ)和cylinders在内的多个特征。此外,请注意,所有制造商类别特征不仅相互依赖,还依赖于制造商的相对普及度(make_pop)。这一发现是有意义的,因为有些类别比其他类别更受欢迎。这些见解可以作为后续调查的指南。

2D ALE 图

通过二维 ALE 图来直观地检查两个变量对预测的影响是最优的方式,主要是因为它在处理相关特征时是无偏的。

让我们仔细审查前几个特征,即年份里程表,它们也有明显的依赖关系。我们将使用去除异常值的测试数据集,以便图表专注于数据的核心——即包含大约 98%的数据点的部分。这次,我们不会插入单个特征,而是一个包含两个特征的列表:

features_l = ["year", "odometer"]
ale_effect = ale(
    X=X_test_no_outliers, model=cb_mdl, feature=features_l,\
    feature_type='continuous', grid_size=50, include_CI=False
)
plt.show() 

上述代码在图 4.17中产生了 ALE 图:

图 4.17:里程表和年份的二维 ALE 图

图 4.17所示,除了极少数极端高的里程表读数和老旧汽车与低里程表读数重叠的区域外,大部分图表的效果都很适度。大多数情况下,它们似乎只在角落处有更大的负效应。

需要记住的一个重要观点是,SHAP 的聚类距离范围从冗余到独立。高度相关特征的问题在于,在某个点上,它们停止相互依赖,变得完全冗余。因此,让我们通过使用shap.utils.hclust创建的聚类数组来检查这两个特征接近冗余的程度:

np.set_printoptions(suppress=True)
print(clustering) 

前面的代码片段应该输出以下数组:

[[  2\.     21\.      0\.      2\.   ]
 [ 22\.     52\.      0\.      3\.   ]
 [ 23\.     53\.      0\.      4\.   ]
 [ 24\.     54\.      0\.      5\.   ]
 [ 20\.     55\.      0\.      6\.   ]
 [ 12\.     14\.      0.235   2\.   ]
 [  0\.     57\.      0.253   3\.   ]
 [  1\.     58\.      0.262   4\.   ]
 [ 13\.     59\.      0.289   5\.   ]
 [  4\.      7\.      0.383   2\.   ]
 [  9\.     11\.      0.394   2\.   ]
 [ 43\.     44\.      0.429   2\.   ]
 [ 15\.     17\.      0.462   2\.   ]
 [  5\.     56\.      0.477   7\.   ]
 [ 10\.     61\.      0.605   3\.   ]
 [ 46\.     49\.      0.647   2\.   ]
 [ 62\.     66\.      0.654   5\.   ]
 [ 51\.     67\.      0.665   3\.   ]
 [  6\.     68\.      0.67    6\.   ]
 [ 31\.     70\.      0.673   7\.   ]
 [ 65\.     71\.      0.694  14\.   ]
 [ 25\.     32\.      0.717   2\.   ]
[ 41\.    101\.      0.997  52\.   ]] 

该数组由边缘的行组成。列代表节点编号 1、节点编号 2、它们的距离和父节点编号。在顶部,有一些完全冗余的配对,例如make_pop(特征 2)和make_cat_luxury_sports(特征 21)。考虑到豪华跑车,如法拉利,在数据集中是最不受欢迎的车辆,因为它们的销售频率不如福特等车型,所以这不是一个奇怪的结果:

features_l = ["cylinders", "epa_displ"]
ale_effect = ale(X=X_test, model=cb_mdl, feature=features_l,\
    feature_type='discrete', grid_size=50
) 
Figure 4.18:

图 4.18:epa_displ 和汽缸数的二维 ALE 图

图 4.18清楚地说明了当汽缸数大于 8 且epa_displ小于 4 时的高交互效应。考虑到拥有大量汽缸的车辆不太可能有低发动机排量,这个发现是反直觉的。另一方面,当汽缸数超过 4 且发动机排量至少为 6 升时,更高的交互效应更有意义。请注意,还有其他因素,如年份型号类型,与这两个特征相关,但 ALE 擅长将汽缸数epa_displ的效果与其他高度相关的特征分开。

PDP 交互图

我相信你一定在想:鉴于 PDP 的众多局限性,我们应该在何时何地考虑使用二维 PDP?

只有当两个特征之间存在已证实的关联关系,但它们并不完全冗余或独立,并且理想情况下,它们完美互补时,才建议使用 ALE 图,因为它可以揭示更高阶的效应。

然而,为了说明目的,我们将生成一个 2D PDP,其中包含longlat,它们是间接连接的。2D 的代码与 1D 非常相似,只是我们使用PDPInteract而不是PDPIsolate,然后对于绘图函数,指定plot_typecontour,但也可以使用grid

features_l = ["long", "lat"]
pdp_interaction_feature = pdp.PDPInteract(
    model=cb_mdl, df=X_test, model_features=X_test.columns,\
    features=features_l, feature_names=features_l, n_classes=0,\
    num_grid_points=15, n_jobs=-1
)
fig, _ = pdp_interaction_feature.plot(
    plot_type='contour', plot_pdp=False
)
fig.show() 
Figure 4.19:

图 4.19:长和纬度的 PDP 交互等高线图

图 4.19证明了特征与结果之间的关系:long似乎对大部分影响负责,但在某些地区,lat似乎有所影响,尤其是在左上角的阿拉斯加。我们可以通过 2D 预测图(InteractPredictPlot)来检查这个结果的可靠性。与图 4.12一样,目标是显示数据的分布和该数据的预测分数,但这次,它以网格的形式显示,网格的颜色编码表示平均分数,大小编码表示测试观察的数量。我们可以将其与相应的 2D 目标图(InteractTargetPlot)进行对比,后者执行相同的操作,但针对的是标签(target)而不是预测分数:

fig, axes, summary_df = info_plots.InteractPredictPlot(
    model=cb_mdl, X=X_test, features=features_l,\
    feature_names=features_l, num_grid_points=(15,12)
)
fig, axes, summary_df = info_plots.InteractTargetPlot(
    df=X_train.join(y_train), target='price', features=features_l,\
    feature_names=features_l, num_grid_points=(15,12)
) 
Figure 4.20:

图 4.20:长和纬度的 PDP 实际图

从初始图中可以看出,中值预测在西部(右侧)最大,尤其是西南部(右下角),但变化不大。第二个图证实了在这些图的这些部分,标签更有可能标价较高。因此,模型以这种精确度学习这种分布并不令人惊讶。这些图有助于证实这两个特征之间的关系。

在接下来的章节中,我们将更深入地探讨局部解释!

任务完成

我们着手揭示哪些特征有助于预测双边市场的二手车价格。使用决策树的内禀参数、排列特征重要性和 SHAP,我们发现至少有 15 个特征对任何模型的影响可以忽略不计。此外,大约相等数量的特征占据了大部分影响。一些最重要的特征,如发动机排量(epa_displ)和汽缸数,是技术性的,并且对于同一款车型的不同版本可能会有所不同,因此用户必须知道并输入它们。

我们还发现了不同特征之间有趣且完全有效的关联,例如yearodometer,这有助于我们了解它们在模型中的相互作用。我们可以将这些所有发现与科技初创公司分享。

摘要

在阅读本章之后,你应该理解了计算特征重要性的模型特定方法及其不足。然后,你应该学习了模型无关方法中的排列特征重要性和 SHAP 值的计算和解释方法。你还学习了可视化模型解释的最常见方式。你应该熟悉全局解释方法,如全局摘要、特征摘要和特征交互图及其优缺点。

在下一章中,我们将深入探讨局部解释。

进一步阅读

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,以及了解新书发布——请扫描下面的二维码:

packt.link/inml

第五章:局部模型无关解释方法

在前两章中,我们专门讨论了全局解释方法。本章将探讨局部解释方法,这些方法旨在解释为什么做出了单个预测或一系列预测。它将涵盖如何利用SHapley Additive exPlanationsSHAP)的KernelExplainer以及另一种称为Local Interpretable Model-agnostic ExplanationsLIME)的局部解释方法。我们还将探讨如何使用这些方法处理表格和文本数据。

这些是我们将在本章中讨论的主要主题:

  • 利用 SHAP 的KernelExplainer进行局部解释的 SHAP 值

  • 使用 LIME

  • 使用 LIME 进行自然语言处理NLP

  • 尝试 SHAP 进行 NLP

  • 比较 SHAP 与 LIME

技术要求

本章的示例使用了mldatasetspandasnumpysklearnnltklightgbmrulefitmatplotlibseabornshaplime库。如何安装所有这些库的说明在书的前言中。

本章的代码位于此处:packt.link/SRqJp.

任务

谁不喜欢巧克力?!它是全球最受欢迎的食品,大约有九成的人喜欢它,每天大约有十亿人食用它。它的一种流行形式是巧克力棒。然而,即使是普遍受欢迎的成分也可以以不普遍吸引人的方式使用——因此,巧克力棒的质量可以从极好到平庸,甚至到令人不快的程度。

通常,这完全取决于可可豆或额外成分的质量,有时一旦与异国风味结合,它就会变成一种习得的口味。

一家法国巧克力制造商对卓越的追求,向你伸出了援手。他们遇到了一个问题。他们的所有巧克力棒都得到了评论家的好评,但评论家的口味非常独特。有些他们喜欢的巧克力棒销售却出奇地平庸,但非评论家在焦点小组和品鉴中似乎都喜欢它们,所以他们困惑为什么销售没有与他们的市场调研相符。他们找到了一组由巧克力爱好者评级的巧克力棒数据集,这些评级恰好与他们的销售情况相符。为了获得无偏见的意见,他们寻求了你的专业知识。

关于数据集,曼哈顿巧克力协会的成员自 2007 年以来一直在聚会,唯一的目的就是品尝和评判优质巧克力,以教育消费者并激励巧克力制造商生产更高品质的巧克力。从那时起,他们已经汇编了一个包含 2200 多块巧克力棒的数据集,其成员按照以下标准进行评级:

  • 4.0–5.00 = 杰出

  • 3.5–3.99 = 强烈推荐

  • 3.0–3.49 = 推荐

  • 2.0–2.99 = 令人失望

  • 1.0–1.90 = 不愉快

这些评分是根据一个考虑了香气、外观、质地、风味、回味和整体意见的评分标准得出的,而且被评为的巧克力条大多是较深的巧克力条,因为目的是欣赏可可的风味。除了评分外,曼哈顿巧克力协会数据集还包括许多特征,例如可可豆种植的国家、巧克力条有多少种成分、是否包含盐以及描述它的单词。

目标是理解为什么某个巧克力制造商的巧克力条被评为杰出但销量不佳,而另一个销量令人印象深刻,但被评为令人失望的巧克力条。

方法

您已经决定使用本地模型解释来解释为什么每块巧克力条被评为这样。为此,您将准备数据集,然后训练分类模型来预测巧克力条评分是否高于或等于强烈推荐,因为客户希望所有巧克力条都高于这个阈值。您需要训练两个模型:一个用于表格数据,另一个用于描述巧克力条的单词的 NLP 模型。我们将分别使用支持向量机SVMs)和轻梯度提升机LightGBM)来完成这些任务。如果您还没有使用这些黑盒模型,请不要担心——我们将简要解释它们。一旦您训练了模型,接下来就是有趣的部分:利用两种本地模型无关的解释方法来了解是什么让特定的巧克力条被评为强烈推荐或不是。

这些解释方法包括 SHAP 和 LIME,当它们结合使用时,将为您的客户提供更丰富的解释。然后,我们将比较这两种方法,以了解它们的优点和局限性。

准备工作

加载库

要运行此示例,您需要安装以下库:

  • mldatasets来加载数据集

  • pandasnumpynltk来操作它

  • sklearn(scikit-learn)和lightgbm来分割数据和拟合模型

  • matplotlibseabornshaplime来可视化解释

您应该首先加载所有这些库,如下所示:

import math
import mldatasets
import pandas as pd
import numpy as np
import re
import nltk
from nltk.probability import FreqDist
from nltk.tokenize import word_tokenize
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn import metrics, svm
from sklearn.feature_extraction.text import TfidfVectorizer
import lightgbm as lgb
import matplotlib.pyplot as plt
import seaborn as sns
import shap
import lime
import lime.lime_tabular
from lime.lime_text import LimeTextExplainer 

理解和准备数据

我们将数据加载到我们称之为chocolateratings_df的 DataFrame 中,如下所示:

chocolateratings_df = mldatasets.load("chocolate-bar-ratings_v2") 

应该有超过 2,200 条记录和 18 列。我们可以简单地通过检查 DataFrame 的内容来验证这一点,如下所示:

chocolateratings_df 

这里在图 5.1中显示的输出与我们预期的相符:

表格描述自动生成

图 5.1:巧克力条数据集的内容

数据字典

数据字典包括以下内容:

  • company: 分类型;巧克力条的制造商(超过 500 种不同类型)

  • company_location: 分类型;制造商所在的国家(66 个不同的国家)

  • review_date: 连续型;评价巧克力条的那一年(从 2006 年到 2020 年)

  • country_of_bean_origin: 分类型;可可豆收获的国家(62 个不同的国家)

  • cocoa_percent: 分类型;巧克力条中可可的百分比

  • rating: 连续型;由曼哈顿巧克力协会(Manhattan Chocolate Society)给出的评分(可能值为 1–5)

  • counts_of_ingredients: 连续型;巧克力条中成分的量

  • cocoa_butter: 二元型;是否使用了可可脂?

  • vanilla: 二元型;是否使用了香草?

  • lecithin: 二元型;是否使用了卵磷脂?

  • salt: 二元型;是否使用了盐?

  • sugar: 二元型;是否使用了糖?

  • sweetener_without_sugar: 二元型;是否使用了无糖甜味剂?

  • first_taste: 文本;用于描述第一次品尝的词语

  • second_taste: 文本;用于描述第二次品尝的词语

  • third_taste: 文本;用于描述第三次品尝的词语

  • fourth_taste: 文本;用于描述第四次品尝的词语

现在我们已经浏览了数据,我们可以快速准备它,然后进行建模和解释!

数据准备

我们首先应该做的是将文本特征留出,这样我们就可以单独处理它们。我们可以通过创建一个名为tastes_df的数据框来包含文本特征,然后从chocolateratings_df中删除它们。然后,我们可以使用headtail来探索tastes_df,如下面的代码片段所示:

tastes_df = chocolateratings_df[
    ['first_taste', 'second_taste', 'third_taste', 'fourth_taste']
]
chocolateratings_df = chocolateratings_df.drop(
    ['first_taste', 'second_taste',
     'third_taste', 'fourth_taste'],axis=1
)
tastes_df.head(90).tail(90) 

上述代码生成了这里在图 5.2中显示的数据框:

表格描述自动生成

图 5.2:品尝列中有许多空值

现在,让我们对分类型特征进行分类编码。company_locationcountry_of_bean_origin中有太多的国家,因此我们设定一个阈值。例如,如果任何国家的数量少于 3.333%(或 74 行),我们就将它们归入一个Other类别,然后对类别进行编码。我们可以使用make_dummies_with_limits函数轻松完成此操作,以下代码片段展示了该过程:

chocolateratings_df = mldatasets.make_dummies_with_limits(
    chocolateratings_df, 'company_location', 0.03333
)
chocolateratings_df = mldatasets.make_dummies_with_limits(
    chocolateratings_df, 'country_of_bean_origin', 0.03333
) 

现在,为了处理tastes_df的内容,以下代码将所有空值替换为空字符串,然后将tastes_df中的所有列连接起来,形成一个单一的序列。然后,它删除了前导和尾随空格。以下代码片段展示了该过程:

tastes_s = tastes_df.replace(
    np.nan, '', regex=True).agg(' '.join, axis=1).str.strip() 

哇!你可以验证结果是一个pandas系列(tastes_s),其中包含(主要是)与品尝相关的形容词,通过打印它来验证。正如预期的那样,这个系列与chocolateratings_df数据框的长度相同,如下面的输出所示:

0          cocoa blackberry robust
1             cocoa vegetal savory
2                rich fatty bready
3              fruity melon roasty
4                    vegetal nutty
                   ...            
2221       muted roasty accessible
2222    fatty mild nuts mild fruit
2223            fatty earthy cocoa
Length: 2224, dtype: object 

但让我们先找出它的短语中有多少是唯一的,使用print(np.unique(tastes_s).shape)。由于输出是(2178,),这意味着少于 50 个短语是重复的,所以按短语进行分词会是一个糟糕的主意,因为其中很少重复。毕竟,在分词时,我们希望元素重复足够多次,这样才值得。

在这里,你可以采取许多方法,例如按二元组(两个词的序列)或甚至子词(将词分成逻辑部分)进行分词。然而,尽管顺序略微重要(因为第一个词与第一个口味有关,依此类推),但我们的数据集太小,有太多的空值(特别是在第三口味第四口味中),无法从顺序中提取意义。这就是为什么将所有“口味”连接起来是一个好的选择,从而消除了它们可辨别的分隔。

另一点需要注意的是,我们的词(大多是)形容词,如“fruity”和“nutty”。我们做了一些努力来移除副词,如“sweetly”,但仍然有一些名词存在,如“fruit”和“nuts”,与形容词“fruity”和“nutty”相对。我们无法确定评鉴巧克力条的品酒家使用“fruit”而不是“fruity”是否意味着不同的东西。然而,如果我们确定这一点,我们可以执行词干提取词形还原,将“fruit”、“fruity”和“fruitiness”的所有实例转换为一致的“fru”(词干)或“fruiti”(词形)。我们不会关注这一点,因为我们的许多形容词的变化在短语中并不常见。

让我们先通过word_tokenize对它们进行分词,并使用FreqDist来计算它们的频率,找出最常见的词。然后,我们可以将结果tastewords_fdist字典放入 DataFrame(tastewords_df)。我们可以将出现次数超过 74 次的单词保存为列表(commontastes_l)。代码如下所示:

tastewords_fdist = FreqDist(
    word for word in word_tokenize(tastes_s.str.cat(sep=' '))
)
tastewords_df = pd.DataFrame.from_dict(
    tastewords_fdist, orient='index').rename(columns={0:'freq'}
)
commontastes_l = tastewords_df[
    tastewords_df.freq > 74].index.to_list()
print(commontastes_l) 

如您从以下commontastes_l的输出中可以看出,最常见的词大多不同(除了spicespicy):

['cocoa', 'rich', 'fatty', 'roasty', 'nutty', 'sweet', 'sandy', 'sour', 'intense', 'mild', 'fruit', 'sticky', 'earthy', 'spice', 'molasses', 'floral', 'spicy', 'woody', 'coffee', 'berry', 'vanilla', 'creamy'] 

我们可以用这个列表来增强我们的表格数据集,方法是将这些常用词转换为二元特征。换句话说,将会有一个列代表这些“常见口味”中的每一个(commontastes_l),如果巧克力条的“口味”包括它,则该列将包含一个 1,否则为 0。幸运的是,我们可以用两行代码轻松完成这个操作。首先,我们创建一个新的列,包含我们的文本口味序列(tastes_s)。然后,我们使用上一章中使用的make_dummies_from_dict函数,通过在新列的内容中查找每个“常见口味”来生成虚拟特征,如下所示:

chocolateratings_df['tastes'] = tastes_s
chocolateratings_df = mldatasets.make_dummies_from_dict(
    chocolateratings_df, 'tastes', commontastes_l) 

现在我们已经完成了特征工程,我们可以使用info()来检查我们的 DataFrame。输出包含所有数值非空特征,除了company。有超过 500 家公司,所以对这个特征的分类编码将会很复杂,并且由于建议将大多数公司归入Other类别,这可能会引入对最常出现的少数公司的偏差。因此,最好完全删除这个列。输出如下所示:

RangeIndex: 2224 entries, 0 to 2223
Data columns (total 46 columns):
#   Column                      Non-Null Count  Dtype  
---  ------                      --------------  -----  
0   company                     2224 non-null   object
1   review_date                 2224 non-null   int64  
2   cocoa_percent               2224 non-null   float64
:        :                         :     :        :
43  tastes_berry                2224 non-null   int64  
44  tastes_vanilla              2224 non-null   int64  
45  tastes_creamy               2224 non-null   int64  
dtypes: float64(2), int64(30), object(1), uint8(13) 

我们准备数据以进行建模的最后一步是从初始化rand,一个在整个练习中作为我们的“随机状态”的常量。然后,我们将y定义为如果大于或等于3.5则转换为1rating列,否则为0X是其他所有内容(不包括company)。然后,我们使用train_test_splitXy分割成训练集和测试集,如下代码片段所示:

rand = 9
y = chocolateratings_df['rating'].\
apply(lambda x: 1 if x >= 3.5 else 0)
X = chocolateratings_df.drop(['rating','company'], axis=1).copy()
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.33, random_state=rand) 

除了表格形式的测试集和训练集之外,对于我们的 NLP 模型,我们还需要与我们的train_test_split一致的纯文本特征数据集,以便我们可以使用相同的y标签。为此,我们可以通过使用X_trainX_test集合的index来子集化我们的tastes_s系列,从而得到 NLP 特定的系列版本,如下所示:

X_train_nlp = tastes_s[X_train.index]
X_test_nlp = tastes_s[X_test.index] 

好的!我们现在已经准备好了。让我们开始建模并解释我们的模型!

利用 SHAP 的 KernelExplainer 进行局部解释,并使用 SHAP 值

对于本节以及随后的使用,我们将首先训练一个支持向量分类器SVC)模型。

训练一个 C-SVC 模型

SVM 是一系列在多维空间中操作的模型类,它们试图找到一个最优的超平面,其中它们试图通过它们之间的最大间隔来分离类别。支持向量是距离决策边界(分割超平面)最近的点,如果移除它们,将改变该边界。为了找到最佳超平面,它们使用一个称为hinge loss的代价函数,以及一个在多维空间中操作的计算上便宜的方法,称为核技巧。尽管超平面暗示了线性可分性,但它并不总是限于线性核。

我们将使用的 scikit-learn 实现称为 C-SVC。SVC 使用一个名为C的 L2 正则化参数,并且默认使用一个称为径向基函数RBF)的核,这是一个决定性的非线性核。对于 RBF,一个gamma超参数定义了核中每个训练示例的影响半径,但以相反的比例。因此,低值增加半径,而高值减小半径。

SVM 家族包括用于分类和甚至回归类的几种变体,通过支持向量回归SVR)。SVM 模型最显著的优势是,与观察值相比,当有大量特征时,它们往往能够有效地工作,甚至在特征超过观察值的情况下!它还倾向于在数据中找到潜在的非线性关系,而不会过拟合或变得不稳定。然而,SVM 模型并不容易扩展到更大的数据集,并且很难调整它们的超参数。

由于我们将使用seaborn绘图样式,该样式通过set()激活,用于本章的一些图表,我们首先保存原始的matplotlib设置(rcParams),以便我们可以在以后恢复它们。关于SVC的一个需要注意的事项是,它本身不产生概率,因为它涉及线性代数。然而,如果probability=True,scikit-learn 实现使用交叉验证,然后拟合一个逻辑回归模型到 SVC 的分数以产生概率。我们还使用gamma=auto,这意味着它设置为 1/#特征——所以,1/44。始终建议设置你的random_state参数以实现可重复性。一旦我们将模型拟合到训练数据,我们就可以使用evaluate_class_mdl来评估我们的模型预测性能,如下面的代码片段所示:

svm_mdl = svm.SVC(probability=True, gamma='auto', random_state=rand)
fitted_svm_mdl = svm_mdl.fit(X_train, y_train)
y_train_svc_pred, y_test_svc_prob, y_test_svc_pred =\
    mldatasets.evaluate_class_mdl(
    fitted_svm_mdl, X_train, X_test, y_train, y_test
) 

前面的代码生成了这里显示的图 5.3中的输出:

图表,折线图  描述自动生成

图 5.3:我们的 SVC 模型的预测性能

图 5.3显示,考虑到这是一个在小不平衡数据集上,对于机器学习模型用户评分已经是一个具有挑战性的领域,所取得的性能还不错。无论如何,曲线下面积AUC)曲线高于虚线抛硬币线,马修斯相关系数MCC)安全地高于 0。更重要的是,精确度远高于召回率,考虑到将一块糟糕的巧克力误分类为强烈推荐的假设成本,这是非常好的。我们更倾向于精确度而不是召回率,因为我们更愿意有较少的误报而不是误判。

使用 KernelExplainer 计算 SHAP 值

由于通过暴力计算 SHAP 值可能非常计算密集,SHAP 库采用了许多统计上有效的捷径。正如我们在第四章中学到的,全局模型无关解释方法,这些捷径从利用决策树的结构(TreeExplainer)到神经网络激活的差异,一个基线(DeepExplainer)到一个神经网络的梯度(GradientExplainer)。这些捷径使得解释器模型特定,因为它们局限于一系列模型类。然而,SHAP 中有一个模型无关的解释器,称为KernelExplainer

KernelExplainer 有两个快捷方式;它为联盟采样所有特征排列的子集,并使用根据联盟大小计算的加权方案来计算 SHAP 值。第一个快捷方式是减少计算时间的推荐技术。第二个快捷方式来自 LIME 的加权方案,我们将在本章的下一部分介绍,SHAP 的作者这样做是为了保持与 Shapley 的一致性。然而,对于联盟中的“缺失”特征,它从背景训练数据集中随机采样特征值,这违反了 Shapley 值的 虚拟 属性。更重要的是,与 排列特征重要性 一样,如果存在多重共线性,它会过分重视不太可能的情况。尽管存在这个几乎致命的缺陷,KernelExplainer 仍然具有 Shapley 值的所有其他优点,以及 LIME 至少一个主要优点。

在我们与 KernelExplainer 交互之前,需要注意的是,对于分类模型,它会产生多个 SHAP 值的列表。我们可以通过索引访问每个类的值列表。如果这个索引不是我们预期的顺序,可能会引起混淆,因为它是按照模型提供的顺序排列的。因此,确保我们模型中类的顺序非常重要,可以通过运行 print(svm_mdl.classes_) 来实现。

输出 array([0, 1]) 告诉我们,不推荐 的索引为 0,正如我们所预期,而 强烈推荐 的索引为 1。我们对后者的 SHAP 值感兴趣,因为这正是我们试图预测的内容。

KernelExplainer 接受一个模型的 predict 函数(fitted_svm_mdl.predict_proba)和一些背景训练数据(X_train_summary)。KernelExplainer 利用额外的措施来最小化计算。其中之一是使用 k-means 对背景训练数据进行总结,而不是使用整个数据。另一种方法可能是使用训练数据的一个样本。在这种情况下,我们选择了将数据聚类到 10 个质心。一旦我们初始化了我们的解释器,我们就可以使用测试数据集的样本(nsamples=200)来得出 SHAP 值。它在拟合过程中使用 L1 正则化(l1_reg)。我们在这里告诉它的是正则化到一个点,它只有 20 个相关特征。最后,我们可以使用 summary_plot 来绘制类别 1 的 SHAP 值。代码在下面的片段中展示:

np.random.seed(rand)
X_train_summary = shap.kmeans(X_train, 10)
shap_svm_explainer = shap.KernelExplainer(
    fitted_svm_mdl.predict_proba, X_train_summary
)
shap_svm_values_test = shap_svm_explainer.shap_values(
    X_test, nsamples=200, l1_reg="num_features(20)"
)
shap.summary_plot(shap_svm_values_test[1], X_test, plot_type="dot") 

上述代码生成了 图 5.4 中所示的输出。尽管本章的重点是局部模型解释,但重要的是要从全局形式开始,以确保结果直观。如果结果不直观,可能存在问题:

包含图形用户界面的图片 描述自动生成

图 5.4:使用 SHAP 的全局模型解释与总结图

图 5.4中,我们可以看出最高的(红色)可可百分比(cocoa_percent)往往与高度推荐可能性的降低相关,而中间值(紫色)往往增加它。这个发现从直观上是有意义的,因为最深的巧克力比不太深的巧克力更是一种习得的味道。低值(蓝色)散布在整个图表中,因此没有显示出趋势,但这可能是因为数量不多。

另一方面,review_date表明,在早期年份很可能被高度推荐。在 0 的两侧都有显著的红色和紫色阴影,因此很难在这里识别出趋势。像第四章中使用的依赖图将更适合这个目的。然而,对于二元特征来说,可视化高值和低值、一和零如何影响模型是非常容易的。例如,我们可以知道可可、奶油、丰富和浆果味道的存在增加了巧克力被推荐的可能性,而甜、土质、酸和油腻的味道则相反。同样,如果巧克力是在美国制造的,那么高度****推荐的几率就会降低!抱歉,是美国。

使用决策图对一组预测进行局部解释

对于局部解释,你不必一次可视化一个点——你可以同时解释几个点。关键是提供一些上下文来充分比较这些点,而且不能有太多以至于你无法区分它们。通常,你会找到异常值或者只满足特定标准的那些点。对于这个练习,我们将只选择那些由你的客户生产的条形,如下所示:

sample_test_idx = X_test.index.get_indexer_for(
    [5,6,7,18,19,21,24,25,27]
) 

Shapley 的一个优点是其可加性属性,这一点很容易证明。如果你将所有 SHAP 值加到计算它们所用的期望值上,你就能得到一个预测。当然,这是一个分类问题,所以预测是一个概率;因此,为了得到一个布尔数组,我们必须检查这个概率是否大于 0.5。我们可以通过运行以下代码来检查这个布尔数组是否与我们的模型测试数据集预测(y_test_svc_pred)相匹配:

expected_value = shap_svm_explainer.expected_value[1]
y_test_shap_pred =\
    (shap_svm_values_test[1].sum(1) + expected_value) > 0.5
print(np.array_equal(y_test_shap_pred, y_test_svc_pred)) 

它应该,并且确实如此!你可以通过一个True值看到它得到了证实。

SHAP 的决策图自带一个高亮功能,我们可以使用它来使假阴性(FN)突出。现在,让我们找出我们的样本观测值中哪些是FN,如下所示:

FN = (~y_test_shap_pred[sample_test_idx]) &
    (y_test.iloc[sample_test_idx] == 1).to_numpy() 

我们现在可以快速重置并绘制一个decision_plot。它需要expected_value、SHAP 值以及我们希望绘制的那些项目的实际值。可选地,我们可以提供一个布尔数组,表示我们想要高亮的项,用虚线表示——在这个例子中,是假阴性(FN),如下面的代码片段所示:

shap.decision_plot(
    expected_value, shap_svm_values_test[1][sample_test_idx],\
    X_test.iloc[sample_test_idx], highlight=FN) 

图 5.5中产生的图表为每个观测值提供了一个单色编码的线条。

图表,雷达图  自动生成的描述

图 5.5:使用 SHAP 对预测样本的局部模型解释,突出显示假阴性

每条线的颜色代表的不是任何特征的值,而是模型输出。由于我们在KernelExplainer中使用了predict_proba,这是一个概率,但否则它将显示 SHAP 值,并且当它们击中顶部的x轴时,它们的值是预测值。特征是根据重要性排序的,但仅限于绘制的观察值,你可以看出线条根据每个特征水平增加和减少。它们的变异程度和方向取决于特征对结果的影响。灰色线代表该类别的期望值,类似于线性模型中的截距。事实上,所有线条都是从这个值开始的,因此最好从下往上阅读这个图。

你可以知道图 5.5中有三个假阴性,因为它们有虚线。使用这个图,我们可以轻松地可视化哪些特征使它们向左偏转最多,因为这是使它们成为负预测的原因。

例如,我们知道最左侧的假阴性出现在期望值线右侧,直到lecithin,然后继续下降直到company_location_France,而review_date增加了其成为高度推荐的可能性,但这还不够。你可以看出county_of_bean_origin_Other降低了两种误分类的可能性。这个决定可能是不公平的,因为国家可能是超过 50 个没有自己特征的国家之一。很可能,这些国家聚集在一起的豆子之间存在很多差异。

决策图也可以隔离单个观察值。当它这样做时,它会将每个特征值打印在虚线旁边。让我们为同一公司的决策图(真阳性观察值#696)绘制一个,如下所示:

shap.decision_plot(
    expected_value, shap_svm_values_test[1][696],
    X_test.iloc[696],highlight=0
) 

图 5.6在这里是由前面的代码生成的:

图形用户界面,表格  自动生成的描述

图 5.6:预测样本中单个真阳性的 SHAP 决策图

图 5.6中,你可以看到lecithincounts_of_ingredients高度推荐的可能性降低到可能危及它的程度。幸运的是,所有高于这些特征的值都使线条明显向右偏转,因为company_location_France=1cocoa_percent=70tastes_berry=1都是有利因素。

使用力图对单个预测进行局部解释

你的客户,巧克力制造商,有两块巧克力希望让你比较。第 5 块是杰出,第 24 块是令人失望。它们都在你的测试数据集中。比较它们的一种方法是将它们的值并排放置在 DataFrame 中,以了解它们究竟有何不同。我们将以下列的评分、实际标签y和预测标签y_pred合并到这些观察值的旁边,如下所示:

eval_idxs = (X_test.index==5) | (X_test.index==24)
X_test_eval = X_test[eval_idxs]
eval_compare_df = pd.concat([
    chocolateratings_df.iloc[X_test[eval_idxs].index].rating,
    pd.DataFrame({'y':y_test[eval_idxs]}, index=[5,24]),
    pd.DataFrame({'y_pred':y_test_svc_pred[eval_idxs]},
    index=[24,5]), X_test_eval], axis=1).transpose()
eval_compare_df 

上述代码生成了图 5.7中显示的 DataFrame:

表格 描述自动生成

图 5.7:观察#5 和#24 并排,特征差异以黄色突出显示

使用这个 DataFrame,你可以确认它们不是误分类,因为y=y_pred。误分类可能会使模型解释不可靠,难以理解为什么人们倾向于喜欢一块巧克力而不是另一块。然后,你可以检查特征以发现差异——例如,你可以知道review_date相差 2 年。此外,杰出巧克力中的豆子来自委内瑞拉,而令人失望的豆子来自另一个代表性较小的国家。杰出的巧克力有浆果的味道,而令人失望的则是土质的。

力图可以告诉我们模型决策(以及,推测,审评者)中考虑了哪些因素,并为我们提供了关于消费者可能偏好的线索。绘制force_plot需要你感兴趣类别的期望值(expected_value),你感兴趣观察的 SHAP 值,以及这个观察的实际值。我们将从以下代码片段中的观察#5 开始:

shap.force_plot(
    expected_value,
    shap_svm_values_test[1][X_test.index==5],\
    X_test[X_test.index==5],
    matplotlib=True
) 

上述代码生成了图 5.8中显示的图表。这个力图描绘了review_datecocoa_percenttastes_berry在预测中的权重,而唯一似乎在相反方向起作用的特征是counts_of_ingredients

时间线 描述自动生成

图 5.8:观察#5 的力图(杰出)

让我们将其与观察#24 的力图进行比较,如下所示:

shap.force_plot(expected_value,\  
                shap_svm_values_test[1][X_test.index==24],\
                X_test[X_test.index==24], matplotlib=True) 

上述代码生成了图 5.9中显示的图表。我们可以很容易地看出,tastes_earthycountry_of_bean_origin_Other在我们的模型中被认为是高度负面的属性。结果可以主要由巧克力品尝中“浆果”与“土质”的差异来解释。尽管我们有这些发现,但豆子的原产国需要进一步调查。毕竟,实际的原产国可能与低评分不相关。

时间线 描述自动生成

图 5.9:观察#24 的力图(令人失望)

在本节中,我们介绍了KernelExplainer,它从 LIME 学到了一些技巧。但 LIME 是什么?我们将在下一节找到答案!

使用 LIME

一直以来,我们介绍的模型无关解释方法试图将模型的输出整体与其输入相协调。为了使这些方法能够很好地理解X如何变成y_pred以及原因,我们首先需要一些数据。然后,我们使用这些数据进行模拟,将数据的变体推入模型并评估模型输出的结果。有时,它们甚至利用全局代理来连接这些点。通过使用在这个过程中学到的知识,我们得到特征重要性值,这些值量化了特征对全局预测的影响、交互或决策。对于 SHAP 等许多方法,这些值也可以在局部观察到。然而,即使它们可以在局部观察到,全局量化的结果可能并不适用于局部。因此,应该有另一种方法,仅针对局部解释量化特征对局部的影响——LIME 就是这样一种方法!

什么是 LIME?

LIME通过训练局部代理来解释单个预测。为此,它首先询问我们想要解释哪个数据点。你还提供你的黑盒模型和样本数据集。然后,它使用模型对数据集的扰动版本进行预测,创建一个方案,其中它根据点与所选数据点的接近程度来采样和加权点。这个点周围的区域被称为邻域。然后,使用这个邻域中的采样点和黑盒预测,它训练一个带权的内在可解释的代理模型。最后,它解释这个代理模型。

这里有很多关键词需要解释,让我们如下定义它们:

  • Chosen data point: LIME 将你想要解释的数据点、行或观察称为一个实例。这只是这个概念的另一种说法。

  • Perturbation: LIME 通过向每个样本添加噪声来模拟新的样本。换句话说,它创建了接近每个实例的随机样本。

  • Weighting scheme: LIME 使用指数平滑核来定义局部实例邻域半径,并确定如何权衡远离实例的点与靠近实例的点。

  • Closer: LIME 使用欧几里得距离来处理表格和图像数据,而对于文本数据则使用余弦相似度。这在高维特征空间中难以想象,但你仍然可以计算任意维度的点之间的距离,并找出与目标点最近的点。

  • Intrinsically interpretable surrogate model: LIME 使用带权重的岭回归的正则化稀疏线性模型。然而,只要数据点可以被加权,它就可以使用任何内在可解释的模型。这个想法有两方面。它需要一个可以产生可靠内在可解释参数的模型,例如指示特征对预测影响的系数。它还需要更多地考虑与所选点最接近的数据点,因为这些点更相关。

k-邻近邻居k-NN)类似,LIME 背后的直觉是,邻域中的点具有共性,因为我们期望彼此靠近的点具有相似,如果不是相同的标签。对于分类器有决策边界,所以当接近的点被一个决策边界分开时,这可能会是一个非常天真的假设。

与邻近邻居家族中的另一个模型类半径邻近邻居相似,LIME 考虑了沿着半径的距离并相应地权衡点,尽管它是以指数方式进行的。然而,LIME 不是一个模型类,而是一种解释方法,所以相似之处到此为止。它不是通过在邻居之间“投票”预测,而是通过拟合一个加权的代理稀疏线性模型,因为它假设每个复杂模型在局部都是线性的,而且因为它不是一个模型类,所以代理模型所做的预测并不重要。实际上,代理模型甚至不需要像手套一样拟合数据,因为你从它那里需要的只是系数。当然,话虽如此,如果它能很好地拟合,那么在解释上就会有更高的保真度。

LIME 适用于表格、图像和文本数据,并且通常具有高局部保真度,这意味着它可以在局部水平上很好地近似模型预测。然而,这取决于邻域是否被正确定义,这源于选择合适的核宽度和局部线性假设的成立。

使用 LimeTabularExplainer 对单个预测进行局部解释

要解释单个预测,你首先需要通过提供你的样本数据集(以 numpy 2D 数组的形式 X_test.values)、一个包含特征名称的列表(X_test.columns)、一个包含分类特征索引的列表(只有前三个特征不是分类特征),以及类名称来实例化一个 LimeTabularExplainer。尽管只需要样本数据集,但建议你为你的特征和类提供名称,以便解释有意义。对于表格数据,告诉 LIME 哪些特征是分类的(categorical_features)很重要,因为它将分类特征与连续特征区分对待,不指定这一点可能会使局部代理拟合不良。另一个可以极大地影响局部代理的参数是 kernel_width。它定义了邻域的直径,从而回答了什么是局部的问题。它有一个默认值,这个值可能或可能不会产生对你实例有意义的解释。你可以根据实例调整此参数,以确保每次生成解释时都是一致的。请注意,当应用于大邻域时,扰动的随机性质可能会导致结果不一致。因此,当你使这个邻域更小的时候,你会减少运行之间的变异性。以下代码片段展示了代码:

lime_svm_explainer = lime.lime_tabular.LimeTabularExplainer(
    X_test.values,
    feature_names=X_test.columns,
    categorical_features=list(range(3,44)),
    class_names=['Not Highly Recomm.', 'Highly Recomm.']
) 

使用实例化的解释器,你现在可以使用 explain_instance 为观察 #5 拟合一个局部代理模型。我们还将使用我们模型的分类函数(predict_proba)并将特征数量限制为八个(num_features=8)。我们可以获取“解释”并立即使用 show_in_notebook 进行可视化。同时,predict_proba 参数确保它还包括一个图表,显示根据局部代理模型,哪个类是最可能的。以下代码片段展示了代码:

lime_svm_explainer.explain_instance(
    X_test[X_test.index==5].values[0],
    fitted_svm_mdl.predict_proba,
    num_features=8
).show_in_notebook(predict_proba=True) 

上述代码提供了 图 5.10 中所示的输出。这个图可以像其他特征重要性图一样阅读,最有影响力的特征具有最高的系数——反之亦然。然而,它具有在每个方向上都有权重的特征。根据局部代理,cocoa_percent 值小于或等于 70 是一个有利属性,同样,浆果味道也是。缺乏酸味、甜味和糖浆味也有利于这个模型。然而,缺乏丰富、奶油和可可味的缺乏则相反,但不足以将天平推向 不推荐

图形用户界面,应用程序描述自动生成

图 5.10:LIME 对观察 #5(杰出)的表格解释

通过对生成 图 5.10 的代码进行微小调整,我们可以为观察 #24 生成相同的图表,如下所示:

lime_svm_explainer.explain_instance(
    X_test[X_test.index==24].values[0],
    fitted_svm_mdl.predict_proba,
    num_features=8
).show_in_notebook(predict_proba=True) 

在这里,在图 5.11中,我们可以清楚地看到为什么局部代理认为观察#24 是不强烈推荐

图形用户界面,应用程序描述自动生成

图 5.11:LIME 对观察#24(令人失望)的表格解释

一旦你比较了#24(图 5.11)和#5(图 5.10)的解释,问题就变得明显了。一个单一的特征,tastes_berry,区分了这两个解释。当然,我们将其限制在前八个特征中,所以可能还有更多。然而,你可能会期望前八个特征包括那些造成最大差异的特征。

根据 SHAP(SHapley Additive exPlanations),知道tastes_earthy=1是解释#24 号巧克力棒令人失望性质的全局原因,但这看起来似乎与直觉相反。那么,发生了什么?结果发现,观察#5 和#24 相对相似,因此它们位于同一个邻域。这个邻域还包括许多带有浆果口味的巧克力棒,而带有泥土口味的则非常少。然而,泥土口味的数量不足以将其视为一个显著特征,因此它将“强烈推荐”和“不强烈推荐”之间的差异归因于其他似乎更频繁区分的特征。这种原因有两方面:局部邻域可能太小,而且由于线性模型的简单性,它们在偏差-方差权衡的偏差端。这种偏差由于一些特征(如tastes_berry)相对于tastes_earthy出现频率较高而加剧。我们可以使用一种方法来解决这个问题,我们将在下一节中介绍。

使用 LIME 进行 NLP

在本章的开头,我们为 NLP(自然语言处理)设置了清洗后的所有“口味”列的内容的训练集和测试集。我们可以先看看 NLP 的测试集,如下所示:

print(X_test_nlp) 

这会输出以下内容:

1194                 roasty nutty rich
77      roasty oddly sweet marshmallow
121              balanced cherry choco
411                sweet floral yogurt
1259           creamy burnt nuts woody
                     ...              
327          sweet mild molasses bland
1832          intense fruity mild sour
464              roasty sour milk note
2013           nutty fruit sour floral
1190           rich roasty nutty smoke
Length: 734, dtype: object 

没有机器学习模型能够直接处理文本数据,因此我们需要将其转换为数值格式——换句话说,就是进行向量化。我们可以使用许多技术来完成这项工作。在我们的案例中,我们并不关心每个短语中单词的位置,也不关心语义。然而,我们对其相对出现频率感兴趣——毕竟,这是我们上一节中遇到的问题。

由于这些原因,词频-逆文档频率TF-IDF)是理想的方法,因为它旨在评估一个术语(每个单词)在文档(每个短语)中出现的频率。然而,它的权重是根据其在整个语料库(所有短语)中的频率来确定的。我们可以使用 scikit-learn 中的TfidfVectorizer方法轻松地将我们的数据集向量化。然而,当你需要制作 TF-IDF 分数时,这些分数仅适用于训练集,因为这样,转换后的训练集和测试集对每个术语的评分是一致的。请看以下代码片段:

vectorizer = TfidfVectorizer(lowercase=False)
X_train_nlp_fit = vectorizer.fit_transform(X_train_nlp)
X_test_nlp_fit = vectorizer.transform(X_test_nlp) 

要了解 TF-IDF 分数的样子,我们可以将所有特征名称放在 DataFrame 的一列中,并将单个观察到的相应分数放在另一列中。请注意,由于向量器生成的是scipy稀疏矩阵,我们必须使用todense()将其转换为numpy矩阵,然后使用asarray()转换为numpy数组。我们可以按 TD-IDF 分数降序排序这个 DataFrame。代码如下所示:

pd.DataFrame(
    {
        'taste':vectorizer.get_feature_names_out(),
        'tf-idf': np.asarray(
            X_test_nlp_fit[X_test_nlp.index==5].todense())[0]
    }
).sort_values(by='tf-idf', ascending=False) 

前面的代码生成了以下图 5.12中所示的结果:

表格描述自动生成

图 5.12:观察#5 中出现的单词的 TF-IDF 分数

如您从图 5.12中可以看出,TD-IDF 分数是介于 0 和 1 之间的归一化值,而在语料库中最常见的分数较低。有趣的是,我们意识到我们表格数据集中的观察#5 中berry=1是因为覆盆子。我们使用的分类编码方法搜索了berry的出现,无论它们是否匹配整个单词。这不是问题,因为覆盆子是一种浆果,覆盆子并不是我们具有自己二进制列的常见口味之一。

现在我们已经将 NLP 数据集向量化,我们可以继续进行建模。

训练 LightGBM 模型

LightGBM,就像XGBoost一样,是另一个非常流行且性能出色的梯度提升框架,它利用了提升树集成和基于直方图的分割查找。主要区别在于分割方法的算法,LightGBM 使用基于梯度的单侧采样GOSS)和将稀疏特征捆绑在一起使用独家特征捆绑EFB),而 XGBoost 则使用更严格的加权分位数草图稀疏感知分割查找。另一个区别在于构建树的方式,XGBoost 是深度优先(自上而下),而 LightGBM 是广度优先(跨树叶)。我们不会深入探讨这些算法的工作原理,因为这会偏离当前的主题。然而,重要的是要注意,由于 GOSS,LightGBM 通常比 XGBoost 还要快,尽管它可能会因为 GOSS 分割近似而损失一些预测性能,但它通过最佳优先的方法弥补了一些。另一方面,可解释提升机EBM)使 LightGBM 在高效且有效地训练稀疏特征方面变得理想,例如我们X_train_nlp_fit稀疏矩阵中的那些特征!这基本上概括了我们为什么在这个练习中使用 LightGBM 的原因。

要训练 LightGBM 模型,我们首先通过设置最大树深度(max_depth)、学习率(learning_rate)、要拟合的增强树数量(n_estimators)、objective(二分类目标)以及最后但同样重要的是random_state(用于可重复性)来初始化模型。使用fit,我们使用我们的向量化 NLP 训练数据集(X_train_nlp_fit)和与 SVM 模型相同的标签(y_train)来训练模型。一旦训练完成,我们可以使用与 SVM 相同的evaluate_class_mdl来评估。代码如下所示:

lgb_mdl = lgb.LGBMClassifier(
    max_depth=13,
    learning_rate=0.05,
    n_estimators=100,
    objective='binary',
    random_state=rand
)
fitted_lgb_mdl = lgb_mdl.fit(X_train_nlp_fit, y_train)
y_train_lgb_pred, y_test_lgb_prob, y_test_lgb_pred =\
    mldatasets.evaluate_class_mdl(
        fitted_lgb_mdl, X_train_nlp_fit, X_test_nlp_fit, y_train, y_test
    ) 

上述代码生成了图 5.13,如下所示:

图表,折线图  自动生成的描述

图 5.13:我们 LightGBM 模型的预测性能

图 5.13显示 LightGBM 达到的性能略低于 SVM(图 5.3),但仍然相当不错,安全地高于抛硬币的线。关于 SVM 的注释,即在此模型中优先考虑精确度而非召回率,也适用于此处。

使用 LimeTextExplainer 对单个预测进行局部解释

要使用 LIME 解释任何黑盒模型预测,我们需要指定一个分类器函数,例如模型的predict_proba,它将使用此函数在实例邻域内使用扰动数据做出预测,然后使用它训练一个线性模型。实例必须是数值形式——换句话说,是向量化的。然而,如果你能提供任何任意文本,并且它可以在飞行中将其向量化,那就更容易了。这正是管道为我们所做的事情。使用 scikit-learn 的make_pipeline函数,你可以定义一系列转换数据的估计器,然后是一个可以拟合数据的估计器。在这种情况下,我们只需要vectorizer转换我们的数据,然后是我们的 LightGBM 模型(lgb_mdl),它接受转换后的数据,如下面的代码片段所示:

lgb_pipeline = make_pipeline(vectorizer, lgb_mdl) 

初始化一个LimeTextExplainer相当简单。所有参数都是可选的,但建议指定类名。就像LimeTabularExplainer一样,一个可选的kernel_width参数可能非常关键,因为它定义了邻域的大小,默认值可能不是最优的,但可以根据实例进行调整。代码如下所示:

lime_lgb_explainer = LimeTextExplainer(
    class_names=['Not Highly Recomm.', 'Highly Recomm.']
) 

使用LimeTextExplainer解释实例与对LimeTabularExplainer进行操作类似。区别在于我们使用了一个管道(lgb_pipeline),而我们提供的数据(第一个参数)是文本,因为管道可以为我们转换它。代码如下所示:

lime_lgb_explainer.explain_instance(
    X_test_nlp[X_test_nlp.index==5].values[0],
    lgb_pipeline.predict_proba, num_features=4
).show_in_notebook(text=True) 

根据 LIME 文本解释器(见图 5.14),模型因为焦糖这个词预测观察#5 为强烈推荐。至少根据局部邻域,覆盆子不是一个因素。在这种情况下,局部代理模型做出了与 LightGBM 模型不同的预测。在某些情况下,LIME 的预测与模型预测不一致。如果不一致率太高,我们将称之为“低保真度”。

图表,瀑布图 描述自动生成

图 5.14:LIME 对观察#5(杰出)的文本解释

现在,让我们将观察#5 的解释与之前所做的观察#24 的解释进行对比。我们可以使用相同的代码,但只需将5替换为24,如下所示:

lime_lgb_explainer.explain_instance(
    X_test_nlp[X_test_nlp.index==24].values[0],
    lgb_pipeline.predict_proba, num_features=4
).show_in_notebook(text=True) 

根据图 5.15,你可以看出观察#24,描述为尝起来像烧焦的木头土质巧克力,因为土质烧焦这两个词而被标记为不推荐

图表,瀑布图 描述自动生成

图 5.15:LIME 对观察#24(令人失望)的表格解释

既然我们使用的是一个可以将任何任意文本矢量化的小管道,那么让我们来点乐趣吧!我们首先尝试一个由我们怀疑模型偏好的形容词组成的短语,然后尝试一个由不受欢迎的形容词组成的短语,最后尝试使用模型不应该熟悉的词,如下所示:

lime_lgb_explainer.explain_instance(
    'creamy rich complex fruity',
    lgb_pipeline.predict_proba, num_features=4
).show_in_notebook(text=True)
lime_lgb_explainer.explain_instance(
    'sour bitter roasty molasses',
    lgb_pipeline.predict_proba, num_features=4
).show_in_notebook(text=True)
lime_lgb_explainer.explain_instance(
    'nasty disgusting gross stuff',
    lgb_pipeline.predict_proba, num_features=4
).show_in_notebook(text=True) 

在图 5.16 中,解释对于奶油丰富复杂果味酸苦烤焦糖浆非常准确,因为模型知道这些词要么非常受欢迎,要么不受欢迎。这些词也足够常见,可以在局部层面上得到欣赏。

你可以在这里看到输出:

图形用户界面 描述自动生成

图 5.16:LIME 可以轻松解释不在训练或测试数据集中的任意短语,只要这些词在语料库中

然而,认为对令人厌恶的恶心恶心的东西预测为不推荐与这些词有任何关系的想法是错误的。LightGBM 模型之前从未见过这些词,因此预测更多与不推荐是多数类有关,这是一个不错的猜测,并且这个短语的稀疏矩阵全是零。因此,LIME 可能在其邻域中找到了很少的远点——如果有的话,所以 LIME 的局部代理模型的零系数反映了这一点。

尝试 SHAP 用于 NLP

大多数 SHAP 的解释器都可以与表格数据一起工作。DeepExplainer可以处理文本,但仅限于深度学习模型,正如我们将在第七章中讨论的,可视化卷积神经网络,其中三个可以处理图像,包括KernelExplainer。事实上,SHAP 的KernelExplainer被设计成一种通用、真正模型无关的方法,但它并不被推广为 NLP 的选项。很容易理解为什么:它很慢,NLP 模型通常非常复杂,并且具有数百——如果不是数千——个特征。在这种情况下,单词顺序不是因素,您有几百个特征,但前 100 个在您的多数观察中都是现成的,KernelExplainer可以工作。

除了克服高计算成本外,您还需要克服几个技术难题。其中之一是KernelExplainer与管道兼容,但它期望返回一个单一的预测集。但 LightGBM 返回两个集合,每个类别一个:不高度推荐高度推荐。为了克服这个问题,我们可以创建一个lambda函数(predict_fn),它包含一个predict_proba函数,该函数仅返回高度推荐的预测。这将在以下代码片段中说明:

predict_fn = lambda X: lgb_mdl.predict_proba(X)[:,1] 

第二个技术难题与 SHAP 与 SciPy 稀疏矩阵的不兼容性有关,并且为了我们的解释器,我们需要样本向量化的测试数据,其格式如下。为了克服这个问题,我们可以将我们的 SciPy 稀疏矩阵格式数据转换为numpy矩阵,然后再转换为pandas DataFrame(X_test_nlp_samp_df)。为了克服任何缓慢的问题,我们可以使用上次使用过的相同的kmeans技巧。除了为了克服障碍所做的调整外,以下代码与使用 SVM 模型执行的 SHAP 完全相同:

X_test_nlp_samp_df = pd.DataFrame(
    shap.sample(X_test_nlp_fit, 50).todense()
)
shap_lgb_explainer = shap.KernelExplainer(
    predict_fn, shap.kmeans(X_train_nlp_fit.todense(), 10)
)
shap_lgb_values_test = shap_lgb_explainer.shap_values(
    X_test_nlp_samp_df, l1_reg="num_features(20)"
)
shap.summary_plot(
    shap_lgb_values_test,
    X_test_nlp_samp_df,
    plot_type="dot",
    feature_names=vectorizer.get_feature_names_out()
) 

通过使用 SHAP 的总结图图 5.17,您可以知道,从全局来看,单词奶油丰富可可水果辛辣坚果浆果对模型预测高度推荐有积极影响。另一方面,火腿味脂肪有相反的影响。考虑到我们之前使用 SVM 模型、表格数据和局部 LIME 解释所学到的东西,这些结果并不完全令人意外。话虽如此,SHAP 值是从稀疏矩阵的样本中得出的,它们可能缺少细节,甚至可能是部分错误的,特别是对于代表性不足的特征。因此,我们应该带着怀疑的态度接受结论,特别是对于图表下半部分。为了提高解释的准确性,最好增加样本大小,但鉴于KernelExplainer的缓慢,需要考虑权衡。

您可以在此查看输出:

计算机屏幕截图  描述自动生成,置信度中等

图 5.17:LightGBM NLP 模型的 SHAP 摘要图

现在我们已经全局验证了我们的 SHAP 值,我们可以使用它们进行局部解释,使用力图。与 LIME 不同,我们无法使用任意数据。使用 SHAP,我们仅限于我们之前已生成 SHAP 值的数据点。例如,让我们以我们的测试数据集样本中的第 18 次观察为例,如下所示:

print(shap.sample(X_test_nlp, 50).to_list()[18]) 

上述代码输出此短语:

woody earthy medicinal 

重要的是要注意哪些词在第 18 次观察中得到了表示,因为 X_test_nlp_samp_df DataFrame 包含了向量化表示。此 DataFrame 中第 18 次观察的行是用于生成力图的,以及此观察的 SHAP 值和类别的预期值,如下代码片段所示:

shap.force_plot(
    shap_lgb_explainer.expected_value,
    shap_lgb_values_test[18,:],
    X_test_nlp_samp_df.iloc[18,:], 
    feature_names=vectorizer.get_feature_names_out()
) 

图 5.18木质土质药性 的力图。正如你所见,土质木质 在与 强烈推荐 的预测中占重要地位。词 药性 在力图中没有出现,而是得到的是 奶油可可 的缺乏作为负面因素。正如你所想象,药性 并非常用来描述巧克力棒的词,因此在样本数据集中只有一次包含它的观察。因此,它在可能联盟中的平均边际贡献将大大减少:

图形用户界面,时间线 描述自动生成

图 5.18:样本测试数据集第 18 次观察的 SHAP 力图

让我们再试一个,如下所示:

print(shap.sample(X_test_nlp, 50).to_list()[9]) 

第 9 次观察的短语如下:

intense spicy floral 

为此观察生成 force_plot 与之前相同,只是将 18 替换为 9。如果你运行此代码,你将产生如下 图 5.19 中所示的输出:

时间线 描述自动生成

图 5.19:样本测试数据集第 9 次观察的 SHAP 力图

如你在 图 5.19 中所欣赏的,短语中的所有词都在力图中得到了表示:花香辛辣 推向 强烈推荐,而 强烈 则指向 不强烈推荐。所以,现在你知道如何使用 SHAP 进行表格和 NLP 解释,它与 LIME 相比如何?

比较 SHAP 与 LIME

如您现在所注意到的,SHAP 和 LIME 都有局限性,但它们也有优势。SHAP 基于博弈论和近似的 Shapley 值,因此其 SHAP 值有理论支持。这些值具有如加法性、效率和可替代性等优秀特性,使它们保持一致性但违反了虚拟属性。它总是能够累加,并且不需要参数调整来实现这一点。然而,它更适合全局解释,它最不依赖于模型的解释器之一,KernelExplainer却非常慢。KernelExplainer还通过使用随机值来处理缺失值,这可能会过分强调不太可能观察到的现象。

LIME 速度快,非常不依赖于模型,并且适用于各种数据。然而,它不是基于严格和一致的原则,而是有邻居相似的直觉。因此,它可能需要复杂的参数调整来最优地定义邻域大小,即使如此,它也仅适用于局部解释。

任务完成

任务是理解为什么您的一位客户的酒吧是杰出的,而另一个则是令人失望的。您采用的方法是使用机器学习模型的解释来得出以下结论:

  • 根据表格模型上的 SHAP,杰出的酒吧之所以得到这个评价,是因为它的浆果味道和 70%的可可含量。另一方面,令人失望的酒吧的不利评价主要归因于它的土质风味和豆类产地(其他)。审查日期的作用较小,但似乎在该时期(2013-15)审查的巧克力棒有优势。

  • LIME 确认cocoa_percent<=70是一个理想属性,并且除了浆果奶油可可丰富是受欢迎的味道外,糖蜜是不受欢迎的。

  • 使用表格模型的方法之间的共同之处在于,尽管有许多与味道无关的属性,但味道特征是最显著的。因此,通过 NLP 模型解释描述每个巧克力棒的词语是合适的。

  • 杰出的酒吧被描述为“油润坚果焦糖浆果”,根据LIMETextExplainer焦糖是积极的,油润是消极的。其他两个词是中性的。另一方面,令人失望的酒吧被描述为“烧焦的木头土质巧克力”,其中烧焦土质是不受欢迎的,其他两个是受欢迎的。

  • 表格解释和 NLP 解释中味道的不一致性是由于存在较少出现的味道,包括浆果,它不像浆果那样常见。

  • 根据 SHAP 对 NLP 模型的全球解释,奶油丰富可可水果辛辣坚果浆果对模型预测高度推荐有积极影响。另一方面,甜味酸味土味火腿味沙沙声油腻有相反的影响。

基于这些关于哪些巧克力棒特征和口味被认为对曼哈顿巧克力协会成员来说不那么吸引人的观点,客户可以对他们的巧克力棒配方进行修改,以吸引更广泛的受众——前提是关于该群体代表目标受众的假设是正确的。

可以认为,像土味烧焦味这样的词与巧克力棒不搭配,而焦糖则相反。因此,我们可能已经得出这个结论,而不需要机器学习!但首先,一个没有数据支持的观点只是一个观点,其次,环境是关键。此外,人类并不总是能够客观地将一个点放在其环境中——特别是在有成千上万条记录的情况下!

此外,局部模型解释不仅关乎对一个预测的解释,因为它与模型如何做出所有预测有关,更重要的是,它与模型如何对相似点进行预测有关——换句话说,在局部邻域!在下一章中,我们将通过观察我们可以在那里找到的共性(锚点)和不一致性(反事实)来进一步探讨在局部邻域意味着什么。

摘要

在本章中,我们学习了如何使用 SHAP 的KernelExplainer,以及它的决策和力图进行局部解释。我们使用 LIME 的实例解释器对表格和文本数据进行了类似的分析。最后,我们探讨了 SHAP 的KernelExplainer和 LIME 的优缺点。在下一章中,我们将学习如何创建更易于人类理解的模型决策解释,例如如果满足 X 条件,则 Y 是结果

数据集来源

进一步阅读

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

第六章:锚定和反事实解释

在前面的章节中,我们学习了如何使用最先进的全局和局部模型解释方法将模型决策归因于特征及其与状态的交互。然而,决策边界并不总是容易定义或解释。如果能够从模型解释方法中推导出人类可解释的规则,那岂不是很好?在本章中,我们将介绍一些人类可解释的、局部的、仅分类的模型解释方法。我们首先将学习如何使用称为锚定的限定规则来解释复杂模型,例如使用如下语句如果满足 X 条件,则 Y 是结果。然后,我们将探讨遵循如果 Z 条件不满足,则 Y 不是结果形式的反事实解释。

这些是我们将在本章中要涵盖的主要主题:

  • 理解锚定解释

  • 探索反事实解释

让我们开始吧!

技术要求

本章的示例使用了mldatasetspandasnumpysklearncatboostmatplotlibseabornalibitensorflowshapwitwidget库。如何安装所有这些库的说明在序言中。

本章的代码位于此处:packt.link/tH0y7

任务

在美国,在过去二十年里,私营公司和非营利组织开发了刑事风险评估工具/仪器RAIs),其中大部分采用统计模型。由于许多州无法承担其庞大的监狱人口,这些方法越来越受欢迎,引导法官和假释委员会通过监狱系统的每一步。

这些是影响重大的决策,可能决定一个人是否被释放出监狱。我们能否承担这些决策出错的风险?我们能否在不理解为什么这些系统会做出这样的推荐的情况下接受这些系统的建议?最糟糕的是,我们并不确切知道评估是如何进行的。风险通常使用白盒模型进行计算,但在实践中,由于是专有的,通常使用黑盒模型。根据论文《美国矫正环境中再犯风险评估工具的性能》的数据,预测性能也相对较低,九个工具样本的中位 AUC 分数在 0.57 到 0.74 之间。

尽管传统的统计方法仍然是刑事司法模型的标准,但为了提高性能,一些研究人员提出了利用更复杂的模型,例如具有更大数据集的随机森林。这并非来自《少数派报告》或《黑镜》的科学幻想,在一些国家,基于大数据和机器学习对人们参与反社会,甚至反爱国行为的可能性进行评分已经成为现实。

随着越来越多的 AI 解决方案试图用我们的数据做出改变我们生活的预测,公平性必须得到适当的评估,并且所有其伦理和实际影响都必须得到充分的讨论。第一章解释、可解释性和可解释性;以及这一切为什么都如此重要?,讨论了公平性是机器学习解释的一个基本概念。你可以在任何模型中评估公平性,但涉及人类行为时,公平性尤其困难。人类心理、神经和社会因素之间的动态极其复杂。在预测犯罪行为的背景下,这归结为哪些因素可能对犯罪负有责任,因为将其他任何东西包含在模型中都是不公平的,以及这些因素如何相互作用。

定量犯罪学家仍在争论犯罪性的最佳预测因素及其根本原因。他们也在争论是否应该从一开始就责怪罪犯这些因素。幸运的是,人口统计特征如种族、性别和国籍不再用于犯罪风险评估。但这并不意味着这些方法不再有偏见。学者们认识到这个问题,并提出了解决方案。

本章将探讨在广泛使用的风险评估工具中存在的种族偏见。鉴于这一主题的敏感性和相关性,提供一些关于犯罪风险评估工具以及机器学习和公平性如何与它们所有方面相关联的背景信息是至关重要的。我们不会深入更多细节,但理解背景对于欣赏机器学习如何持续加剧结构性不平等和不公平偏见是很重要的。

现在,让我们向您介绍本章的任务!

再犯风险评估中的不公平偏见

想象一个调查记者正在撰写一篇关于一名非洲裔美国被告的文章,该被告在审判前被拘留。一个名为矫正犯人管理配置文件以替代制裁COMPAS)的工具认为他有再犯的风险。再犯是指某人重新陷入犯罪行为。这个分数让法官相信被告必须被拘留,而不考虑任何其他论点或证词。他被关押了数月,在审判中被判无罪。自审判以来已经过去了五年,他没有被指控犯有任何罪行。可以说,对再犯的预测是一个假阳性。

记者联系你是因为她希望用数据科学来确定这个案例中是否存在不公平的偏见。COMPAS 风险评估是通过 137 个问题计算的(www.documentcloud.org/documents/2702103-Sample-Risk-Assessment-COMPAS-CORE.html)。它包括以下问题:

  • “根据筛查者的观察,这个人是不是被怀疑或承认的帮派成员?”

  • “在过去 12 个月内,你多久搬过一次家?”

  • “你多久才有足够的钱维持生计?”

  • 心理测量学李克特量表问题,例如“我从未因为生活中的事情感到悲伤。”

尽管种族不是问题之一,但许多这些问题可能与种族相关。更不用说,在某些情况下,它们可能更多的是主观意见的问题而不是事实,因此容易产生偏见。

记者无法提供您 137 个已回答的问题或 COMPAS 模型,因为这些数据不是公开可用的。然而,幸运的是,佛罗里达州同一县的所有被告的人口统计和再犯数据是可用的。

方法

您已经决定做以下事情:

  • 训练代理模型:您没有原始特征或模型,但并非一切尽失,因为您有 COMPAS 分数——标签。我们还有与这些问题相关的特征,我们可以通过模型将这些标签连接起来。通过通过代理近似 COMPAS 模型,您可以评估 COMPAS 决策的公平性。在本章中,我们将训练一个 CatBoost 模型。

  • Anchor 解释:使用这种方法将揭示代理模型为何使用一系列称为锚点的规则做出特定预测的见解,这些规则告诉您决策边界在哪里。边界对我们任务的相关性在于我们想知道为什么被告被错误地预测会再犯。这是一个对原始模型的近似边界,但其中仍有一些真实性。

  • 反事实解释:虽然 Anchor 解释了决策为何被做出,但反事实可以用来检查决策为何没有被做出。这在检查决策的公平性方面特别有用。我们将使用无偏方法来找到反事实,然后使用 What-If Tool (WIT) 来进一步探索反事实和公平性。

准备工作

您将在这里找到这个示例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/blob/main/06/Recidivism.ipynb

加载库

要运行此示例,您需要安装以下库:

  • mldatasets 用于加载数据集

  • 使用 pandasnumpy 来操作数据集

  • 使用 sklearn (scikit-learn) 和 catboost 来分割数据并拟合模型

  • 使用 matplotlibseabornalibitensorflowshapwitwidget 来可视化解释

您应该首先加载所有这些:

import math
import mldatasets
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn import metrics
from catboost import CatBoostClassifier
import matplotlib.pyplot as plt
import seaborn as sns
from alibi.utils.mapping import ohe_to_ord, ord_to_ohe
import tensorflow as tf
from alibi.explainers import AnchorTabular, CounterFactualProto
import shap
import witwidget
from witwidget.notebook.visualization import WitWidget, \ WitConfigBuilder 

让我们用 print(tf.__version__) 检查 TensorFlow 是否加载了正确的版本。它应该是 2.0 或更高版本。我们还应该禁用 eager 执行并验证它是否通过此命令完成。输出应该显示为 False

tf.compat.v1.disable_eager_execution()
print('Eager execution enabled:', tf.executing_eagerly()) 

理解和准备数据

我们将数据以这种方式加载到名为 recidivism_df 的 DataFrame 中:

recidivism_df = mldatasets.load("recidivism-risk", prepare=True) 

应该有大约 15,000 条记录和 23 列。我们可以使用info()来验证这一点:

recidivism_df.info() 

以下输出是正确的。所有特征都是数值型,没有缺失值,并且分类特征已经为我们进行了独热编码:

Int64Index: 14788 entries, 0 to 18315
Data columns (total 23 columns):
#   Column                 Non-Null Count  Dtype
--  ------                 --------------  -----
0   age                    14788 non-null  int8
1   juv_fel_count          14788 non-null  int8
2   juv_misd_count         14788 non-null  int8
3   juv_other_count        14788 non-null  int64
4   priors_count           14788 non-null  int8
5   is_recid               14788 non-null  int8
6   sex_Female             14788 non-null  uint8
7   sex_Male               14788 non-null  uint8
8   race_African-American  14788 non-null  uint8
9   race_Asian             14788 non-null  uint8
13  race_Other             14788 non-null  uint8
14  c_charge_degree_(F1)   14788 non-null  uint8
15  c_charge_degree_(F2)   14788 non-null  uint8
21  c_charge_degree_Other  14788 non-null  uint8
22  compas_score           14788 non-null  int64 

数据字典

虽然只有九个特征,但由于分类编码,它们变成了 22 列:

  • age: 连续;被告的年龄(介于 18 和 96 之间)。

  • juv_fel_count: 序数;少年重罪的次数(介于 0 和 2 之间)。

  • juv_misd_count: 序数;少年轻罪的次数(介于 0 和 1 之间)。

  • juv_other_count: 序数;既不是重罪也不是轻罪的少年定罪的次数(介于 0 和 1 之间)。

  • priors_count: 序数;已犯前科的数量(介于 0 和 13 之间)。

  • is_recid: 二进制;被告在 2 年内是否再犯(1 表示是,0 表示否)?

  • sex: 分类;被告的性别。

  • race: 分类;被告的种族。

  • c_charge_degree: 分类;被告目前被指控的程度的分类。美国将犯罪行为分为重罪、轻罪和违章,从最严重到最轻微的顺序排列。这些以级别的形式进一步分类,从 1^(st)(最严重的罪行)到 3^(rd)或 5^(th)(最轻微的罪行)。然而,尽管这是联邦罪行的标准,但它是根据州法律定制的。对于重罪,佛罗里达州(www.dc.state.fl.us/pub/scoresheet/cpc_manual.pdf)有一个级别制度,它根据犯罪的程度来确定犯罪的严重性,而不考虑级别,从 10(最严重)到 1(最轻微)。

    该特征的类别以F开头表示重罪,以M开头表示轻罪。它们后面跟着一个数字,这是重罪的级别和轻罪的度数。

  • compas_score: 二进制;COMPAS 将被告的风险评估为“低”、“中”或“高”。在实践中,“中”通常被决策者视为“高”,因此该特征已被转换为二进制以反映这种行为:1:高风险/中风险,0:低风险。

使用混淆矩阵检查预测偏差

数据集中有两个二元特征。第一个是由 COMPAS 做出的再犯风险预测(compas_score)。第二个(is_recid)是真实情况,因为它是在被告被捕后的两年内发生的事情。就像你会用任何模型的预测与其训练标签进行比较一样,你可以用这两个特征构建混淆矩阵。scikit-learn 可以使用confusion_matrix函数(cf_matrix)生成一个,然后我们可以用 Seaborn 的heatmap创建它。我们不是通过简单的除法(cf_matrix/np.sum(cf_matrix))来绘制真阴性TNs)、假阳性FPs)、假阴性FNs)和真阳性TPs)的数量,而是绘制百分比。heatmap的其他参数仅用于格式化:

cf_matrix = metrics.confusion_matrix(
    recidivism_df.is_recid,
    recidivism_df.compas_score
)
sns.heatmap(
    cf_matrix/np.sum(cf_matrix),
    annot=True,
    fmt='.2%',
    cmap='Blues',
    annot_kws={'size':16}
) 

上述代码输出了图 6.1。右上角是假阳性(FP),占所有预测的五分之一左右,与左下角的假阴性(FN)一起,占超过三分之二:

图 6.1:预测再犯风险(compas_score)和真实情况(is_recid)之间的混淆矩阵

图 6.1 显示,COMPAS 模型的预测性能并不很好,尤其是如果我们假设刑事司法决策者正在将中等或高风险评估视为表面价值。它还告诉我们,假阳性(FP)和假阴性(FN)的发生率相似。尽管如此,像混淆矩阵这样的简单可视化会掩盖人口子群体之间的预测差异。我们可以快速比较两个历史上一直受到美国刑事司法系统不同对待的子群体之间的差异。为此,我们首先将我们的 DataFrame 细分为两个 DataFrame:一个用于白人(recidivism_c_df)和另一个用于非裔美国人(recidivism_aa_df)。然后我们可以为每个 DataFrame 生成混淆矩阵,并使用以下代码将它们并排绘制:

recidivism_c_df =\
recidivism_df[recidivism_df['race_Caucasian'] == 1]
recidivism_aa_df =\
recidivism_df[recidivism_df['race_African-American'] == 1]
_ = mldatasets.compare_confusion_matrices(
    recidivism_c_df.is_recid,
    recidivism_c_df.compas_score,
    recidivism_aa_df.is_recid,
    recidivism_aa_df.compas_score,
    'Caucasian',
    'African-American',
    compare_fpr=True
) 
Figure 6.2. At a glance, you can tell that it’s like the confusion matrix for Caucasians has been flipped 90 degrees to form the African American confusion matrix, and even then, it is still less unfair. Pay close attention to the difference between FPs and TNs. As a Caucasian defendant, a result is more than half as likely to be an FP than a TN, but as an African American, it is a few percentage points more likely. In other words, an African American defendant who doesn’t re-offend is predicted to be at risk of recidivating more than half of the time:

图 6.2:数据集中非洲裔美国人和白人在预测再犯风险(compas_score)和真实情况(is_recid)之间的混淆矩阵比较

我们不是通过查看图表来直观地评估它,而是可以测量假阳性率FPR),这是两个度量之间的比率(FP / (FP + TN)),在再犯风险较高的情况下更为常见。

数据准备

在我们继续进行建模和解释之前,我们还有最后一步。

由于数据加载时prepare=True,我们现在所做的一切就是将数据分为训练集和测试集。像往常一样,设置随机状态以确保所有发现都是可重复的至关重要。然后我们将y设置为我们的目标变量(compas_score),将X设置为除is_recid之外的所有其他特征,因为这是真实情况。最后,我们将yX分为训练集和测试集,就像我们之前做的那样:

rand = 9
np.random.seed(rand)
y = recidivism_df['compas_score']
X = recidivism_df.drop(
    ['compas_score', 'is_recid'],
    axis=1).copy()
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=rand
) 

现在,让我们开始吧!

模型

首先,让我们快速训练本章将使用的模型。

代理模型是一种模拟黑盒模型输出的方法,就像我们在第四章中提到的全局代理模型一样。那么,它们是同一回事吗?在机器学习中,代理和代理是经常可以互换使用的术语。然而,从语义上讲,代理与替代相关,而代理更多地与表示相关。因此,我们称这些代理模型是为了区分我们没有确切的训练数据。因此,你只能代表原始模型,因为你不能替代它。出于同样的原因,与代理的解读不同,后者最适合简单的模型,而代理最适合复杂的模型,这些模型可以用复杂性来弥补训练数据中的差异。

我们将训练一个CatBoost分类器。对于那些不熟悉 CatBoost 的人来说,它是一种高效的提升集成树方法。它与LightGBM类似,但使用了一种称为最小方差抽样MVS)的新技术,而不是基于梯度的单侧抽样GOSS)。与 LightGBM 不同,它以平衡的方式生长树。它被称为 CatBoost,因为它可以自动编码分类特征,并且特别擅长处理过拟合,对分类特征和类别不平衡的处理是无偏的。我们不会过多地详细介绍,但出于这些原因,它被选为这次练习。

作为基于树的模型类别,你可以为CatBoostClassifier指定一个最大深度值。我们设置了一个相对较高的学习率值和一个较低的迭代值(默认为 1,000)。一旦我们对模型使用了fit,我们就可以用evaluate_class_mdl来评估结果:

cb_mdl = CatBoostClassifier(
    iterations=500,
    learning_rate=0.5,
    depth=8
)
fitted_cb_mdl = cb_mdl.fit(
    X_train,
    y_train,
    verbose=False
)
y_train_cb_pred, y_test_cb_prob, y_test_cb_pred = \
mldatasets.evaluate_class_mdl(
    fitted_cb_mdl, X_train, X_test, y_train, y_test
) 

你可以在图 6.3中欣赏到我们的 CatBoost 模型的evaluate_class_mdl输出:

图表,折线图  自动生成的描述

图 6.3:CatBoost 模型的预测性能

从公平性的角度出发,我们更关心假阳性(FPs)而不是假阴性(FNs),因为将一个无辜的人关进监狱比让一个有罪的人留在街上更不公平。因此,我们应该追求比召回率更高的精确度图 6.3证实了这一点,以及一个健康的 ROC 曲线、ROC-AUC 和 MCC。

考虑到它是一个仅用不同但相关的数据近似真实事物的代理模型,该模型的预测性能是相当准确的。

熟悉我们的“实例”

记者带着一个案例找到你:一个被错误预测有高再犯风险的非洲裔美国被告。这个案例是#5231,是你的主要实例。由于我们的重点是种族偏见,我们想将其与不同种族的类似案例进行比较。为此,我们找到了案例#10127(白人)和#2726(西班牙裔)。

我们可以查看这三个实例的数据。由于我们将在本章中多次引用这些实例,让我们首先保存非洲裔美国人(idx_aa)、西班牙裔(idx_h)和白色人种(idx_c)案例的索引。然后,我们可以通过这些索引对测试数据集进行子集划分。由于我们必须确保我们的预测匹配,我们将这个子集化的测试数据集连接到真实标签(y_test)和 CatBoost 预测(y_test_cb_pred):

idx_aa = 5231
idx_h = 2726
idx_c = 10127
eval_idxs = X_test.index.isin([idx_aa, idx_h, idx_c])
X_test_evals = X_test[eval_idxs]
eval_compare_df = pd.concat(
    [
        pd.DataFrame(
            {'y':y_test[eval_idxs]},
            index=[idx_c, idx_h, idx_aa]
        ),
        pd.DataFrame(
            {'y_pred':y_test_cb_pred[eval_idxs]},
            index=[idx_c, idx_h, idx_aa]
        ),
        X_test_evals
    ],
    axis=1
).transpose()
eval_compare_df 

上一段代码生成了图 6.4中的 DataFrame。你可以看出预测与真实标签相匹配,我们主要关注的实例是唯一一个被预测为中等或高风险再犯的实例。除了种族之外,唯一的其他差异是与c_charge_degree以及一个微小的年龄差异:

图形用户界面,应用程序描述自动生成

图 6.4:观察#5231、#10127 和#2726 并排展示,特征差异被突出显示

在本章中,我们将密切关注这些差异,以了解它们是否在产生预测差异中发挥了重要作用。我们将涵盖的所有方法都将完善决定或改变代理模型决策,以及可能通过扩展的 COMPAS 模型的决定的图景。现在我们已经完成了设置,我们将继续使用解释方法。

理解锚点解释

第五章局部模型无关解释方法中,我们了解到LIME在数据集的扰动版本上训练一个局部代理模型(具体来说是一个加权稀疏线性模型),在实例邻域中。结果是,你近似了一个局部决策边界,这可以帮助你解释该模型对其的预测。

与 LIME 一样,锚点也是从一个模型无关的基于扰动的策略中派生出来的。然而,它们不是关于决策边界,而是关于决策区域。锚点也被称为范围规则,因为它们列出了一些适用于你的实例及其扰动邻域的决策规则。这个邻域也被称为扰动空间。一个重要的细节是规则在多大程度上适用于它,这被称为精确度

想象一下你实例周围的社区。你会期望当你离你的实例越近时,点之间的预测越相似,对吧?所以,如果你有定义这些预测的决策规则,你实例周围的区域越小,你的规则就越精确。这个概念被称为覆盖率,它是指你的扰动空间中产生特定精确度的百分比。

与 LIME 不同,锚点不拟合局部代理模型来解释你选择的实例的预测。相反,它们使用称为Kullback-Leibler 散度下界和上界置信度KL-LUCB)的算法探索可能的候选决策规则,该算法源于多臂老丨虎丨机MAB)算法。

MABs 是一种关于在资源有限的情况下探索所有未知可能性的最大回报的强化学习算法家族。该算法起源于理解赌场老丨虎丨机玩家如何通过玩多个机器来最大化他们的回报。它被称为多臂老丨虎丨机,因为老丨虎丨机玩家被称为单臂老丨虎丨机。然而,玩家不知道哪台机器会产生最高的回报,不能一次性尝试所有机器,而且资金有限。技巧在于学习如何平衡探索(尝试未知的老丨虎丨机)与利用(使用那些你已经有一定理由偏好的机器)。

在锚点的情况下,每个老丨虎丨机都是一个潜在的决策规则,回报是它产生的精确度。KL-LUCB 算法使用基于分布之间的Kullback-Leibler 散度的置信区域,依次找到具有最高精确度的决策规则,同时效率很高。

使用证据准备锚点和反事实解释

需要执行几个小步骤来帮助 alibi 库生成人类友好的解释。第一步与预测有关,因为模型可能输出 1 或 0,但通过名称理解预测更容易。为了帮助我们,我们需要一个包含类名的列表,其中 0 位置匹配我们的负类名,1 匹配正类名:

class_names = ['Low Risk', 'Medium/High Risk'] 

接下来,让我们创建一个包含我们主要感兴趣实例numpy 数组并将其打印出来。请注意,单维数组需要扩展(np.expand_dims),以便 alibi 能够理解:

X_test_eval = np.expand_dims(
    X_test.values[
        X_test.index.get_loc(idx_aa)
    ],
     axis=0
)
print(X_test_eval) 

上述代码输出一个包含 21 个特征的数组,其中 12 个是独热编码OHE)的结果:

[[23  0  0  0  2  0  1  1  0  ... 0  1  0  0  0  0]] 

当你有独热编码类别时,制作人类友好的解释会出现问题。对于机器学习模型和解释器来说,每个独热特征都是独立的。然而,对于解释结果的人类来说,它们会聚集成它们原始特征的类别。

alibi库有几个实用函数来处理这个问题,例如ohe_to_ord,它将一个独热编码的实例转换为序数格式。要使用此函数,我们首先定义一个字典(cat_vars_ohe),告诉alibi我们的特征中分类变量的位置以及每个变量有多少个类别。例如,在我们的数据中,性别从第 5 个索引开始,有两个类别,这就是为什么我们的cat_vars_ohe字典从5: 2开始。一旦你有了这个字典,ohe_to_ord就可以将你的实例(X_test_eval)转换为序数格式,其中每个分类变量占据一个单独的特征。这个实用函数对于 Alibi 的逆事实解释非常有用,因为解释器需要这个字典来映射分类特征:

cat_vars_ohe = {5: 2, 7: 6, 13: 8}
print(ohe_to_ord(X_test_eval, cat_vars_ohe)[0]) 

上述代码输出了以下数组:

[[23  0  0  0  2  1  0  3]] 

当数据处于序数格式时,Alibi 需要一个字典,为每个类别提供名称以及特征名称列表:

category_map = {
    5: ['Female', 'Male'],
    6: [
        'African-American',
        'Asian',
        'Caucasian',
        'Hispanic',
        'Native American',
        'Other'
    ],
    7: [
        'Felony 1st Degree',
        'Felony 2nd Degree',
        'Felony 3rd Degree',
        'Felony 7th Degree',
        'Misdemeanor 1st Degree',
        'Misdemeanor 2nd Degree',
        'Misdemeanor 3rd Degree',
        'Other Charge Degree'
    ]
}
feature_names = [
    'age',
    'juv_fel_count',
    'juv_misd_count',
    'juv_other_count',
    'priors_count',
    'sex',
    'race',
    'c_charge_degree'
] 

然而,Alibi 的锚点解释使用的是提供给我们的模型的数据。我们正在使用 OHE 数据,因此我们需要一个针对该格式的类别映射。当然,OHE 特征都是二进制的,因此它们每个只有两个“类别”:

category_map_ohe = {
    5: ['Not Female', 'Female'],
    6: ['Not Male', 'Male'],
    7:['Not African American', 'African American'],
    8:['Not Asian', 'Asian'], 9:['Not Caucasian', 'Caucasian'],
    10:['Not Hispanic', 'Hispanic'],
    11:['Not Native American', 'Native American'],
    12:['Not Other Race', 'Other Race'],
    19:['Not Misdemeanor 3rd Deg', 'Misdemeanor 3rd Deg'],
    20:['Not Other Charge Degree', 'Other Charge Degree']
} 

锚点解释的局部解释

所有 Alibi 解释器都需要一个predict函数,因此我们为我们的 CatBoost 模型创建了一个名为predict_cb_fnlambda函数。请注意,我们正在使用predict_proba来获取分类器的概率。然后,为了初始化AnchorTabular,我们还提供了我们的特征名称,这些名称与我们的 OHE 数据集和类别映射(category_map_ohe)中的名称一致。一旦初始化完成,我们就用我们的训练数据来拟合它:

predict_cb_fn = lambda x: fitted_cb_mdl.predict_proba(x)
anchor_cb_explainer = AnchorTabular(
    predict_cb_fn,
    X_train.columns,
    categorical_names=category_map_ohe
)
anchor_cb_explainer.fit(X_train.values) 

在我们利用解释器之前,检查锚点“是否稳固”是一个好的实践。换句话说,我们应该检查 MAB 算法是否找到了有助于解释预测的决策规则。为了验证这一点,你使用predictor函数来检查预测结果是否与这个实例预期的结果相同。目前,我们使用idx_aa,这是非洲裔美国被告的情况:

print(
    'Prediction: %s' %  class_names[anchor_cb_explainer.
    predictor(X_test.loc[idx_aa].values)[0]]
) 

上述代码输出了以下内容:

Prediction: Medium/High Risk 

我们可以继续使用explain函数来为我们实例生成解释。我们可以将我们的精确度阈值设置为0.85,这意味着我们期望在锚定观察到的预测至少 85%的时间与我们的实例相同。一旦我们有了解释,我们还可以打印锚点以及它们的精确度和覆盖率:

anchor_cb_explanation = anchor_cb_explainer.explain(
    X_test.loc[idx_aa].values,threshold=0.85, seed=rand
)
print('Anchor: %s' % (' AND'.join(anchor_cb_explanation.anchor)))
print('Precision: %.3f' % anchor_cb_explanation.precision)
print('Coverage: %.3f' % anchor_cb_explanation.coverage) 

以下输出是由上述代码生成的。你可以看出agepriors_countrace_African-American是精确度为 86%的因素。令人印象深刻的是,这条规则适用于几乎三分之一的扰动空间实例,覆盖率为 0.29:

Anchor: age <= 25.00 AND
    priors_count > 0.00 AND
    race_African-American = African American
Precision: 0.863
Coverage: 0.290 

我们可以尝试相同的代码,但将精确度阈值提高 5%,设置为 0.9。我们观察到与上一个示例中生成的相同三个锚点,以及另外三个额外的锚点:

Anchor: age <= 25.00 AND
    priors_count > 0.00 AND
    race_African-American = African American AND
    c_charge_degree_(M1) = Not Misdemeanor 1st Deg AND
    c_charge_degree_(F3) = Not Felony 3rd Level AND
    race_Caucasian = Not Caucasian
Precision: 0.903
Coverage: 0.290 

令人惊讶的是,尽管精确度提高了几个百分点,但覆盖率保持不变。在这个精确度水平上,我们可以确认种族是一个重要因素,因为非洲裔美国人是一个锚点,但非白人也是如此。另一个因素是c_charge_degree。解释表明,被指控为一级轻罪或三级重罪会更好。可以理解的是,七级重罪比这两个罪更严重。

另一种理解模型为何做出特定预测的方法是寻找具有相反预测的相似数据点,并检查为什么做出了替代决策。决策边界跨越了这两个点,因此对比边界两边的决策解释是有帮助的。这次,我们将使用idx_c,这是白人被告的案例,阈值为 85%,并输出以下锚点:

Anchor: priors_count <= 2.00 AND
    race_African-American = Not African American AND
    c_charge_degree_(M1) = Misdemeanor 1st Deg
Precision: 0.891 
Coverage: 0.578 

第一个锚点是priors_count <= 2.00,但在边界另一侧,前两个锚点是age <= 25.00priors_count > 0.00。换句话说,对于一个 25 岁或以下的非洲裔美国人,任何数量的先例都足以将他们归类为有中等/高风险的再犯可能性(86%的时间)。另一方面,如果一个白人没有超过两个先例,并且没有被指控为一级轻罪,那么他们将被预测为低风险(89%的时间和 58%的覆盖率)。这些决策规则表明,基于种族的偏见决策,对不同种族群体应用了不同的标准。双重标准是在原则上情况相同的情况下,应用不同的规则。在这种情况下,priors_count的不同规则和缺乏将age作为白人的因素构成了双重标准。

我们现在可以尝试一个西班牙裔被告(idx_h),以观察是否在这个实例中也发现了双重标准。我们只需运行之前相同的代码,但将idx_c替换为idx_h

Anchor: priors_count <= 2.00 AND 
    race_African-American = Not African American AND
    juv_fel_count <= 0.00 AND 
    sex_Male = Male
Precision: 0.851
Coverage: 0.578 

对于西班牙裔被告的解释证实了priors_count的不同标准,并且由于有一个锚点用于不是非洲裔美国人,另一个锚点用于西班牙裔,所以“种族”继续是一个强有力的因素。

对于具体的模型决策,锚点解释回答了“为什么?”这个问题。然而,通过比较只有细微差别但预测结果不同的相似实例,我们已经探讨了“如果...会怎样?”这个问题。在下一节中,我们将进一步探讨这个问题。

探索反事实解释

反事实是人类推理的一个基本组成部分。我们中有多少人低声说过“如果我做了X而不是这样,我的结果y就会不同”?总有一些事情,如果我们做得不同,可能会导致我们更喜欢的结果!

在机器学习的结果中,你可以利用这种推理方式来制作极其人性化的解释,我们可以用需要改变什么才能得到相反结果(反事实类别)来解释决策。毕竟,我们通常对了解如何使负面结果变得更好感兴趣。例如,你如何让你的贷款申请被批准,或者将你的心血管疾病风险从高降低到低?然而,希望这些问题的答案不是一大串需要改变的事情。你更喜欢最小的改变数量来改变你的结果。

关于公平性,反事实是一种重要的解释方法,特别是在涉及我们无法改变或不应改变的因素时。例如,如果你和你同事做完全相同的工作,并且拥有相同的工作经验水平,你期望得到相同的薪水,对吧?如果你和你的配偶拥有相同的资产和信用记录,但信用评分不同,你不得不怀疑这是为什么。这和性别、种族、年龄,甚至是政治派别有关吗?无论是薪酬、信用评级还是再犯风险模型,你希望类似的人得到类似对待。

找到反事实并不特别困难。我们只需要稍微改变我们的“目标实例”直到它改变结果。也许数据集中已经有一个类似的实例!

事实上,可以说我们在上一节中用锚点考察的三个实例彼此之间非常接近,可以视为对方的反事实,除了白人和西班牙裔案例,它们有相同的结局。但白人和西班牙裔实例是通过寻找与目标实例具有相同犯罪历史但不同种族的数据点来“挑选”的。也许通过比较相似点,除了种族之外,我们这样限制了范围,从而证实了我们希望证实的事情,那就是种族对模型的决策有影响。

这是一个选择偏差的例子。毕竟,反事实本质上是具有选择性的,因为它们关注几个特征的变化。即使只有几个特征,也有许多可能的排列组合会改变结果,这意味着一个点可能有数百个反事实。而且,并非所有这些都会讲述一个一致的故事。这种现象被称为罗生门效应。它是以一部著名的日本谋杀悬疑电影命名的。正如我们期待谋杀悬疑电影中的情况一样,目击者对所发生的事情有不同的回忆。但同样,正如伟大的侦探被训练去寻找与犯罪现场相关的线索(即使这与他们的直觉相矛盾)一样,反事实不应该“挑选”,因为它们方便地讲述了我们希望它们讲述的故事。

幸运的是,有一些算法方法可以在无偏的方式下寻找反事实实例。通常,这些方法涉及找到具有不同结果的最近点,但测量点之间距离的方法有很多。首先,有L1距离(也称为曼哈顿距离)和L2距离(也称为欧几里得距离),以及其他许多方法。但还有关于距离归一化的一个问题,因为并非所有特征都具有相同的尺度。否则,它们会对尺度较小的特征(如独热编码的特征)产生偏见。也有许多归一化方案可供选择。你可以使用标准差最小-最大缩放,甚至中值绝对偏差[9]。

在本节中,我们将解释并使用一种高级的反事实寻找方法。然后,我们将探索谷歌的假设工具WIT)。它有一个简单的基于 L1 和 L2 的反事实寻找器,虽然它在数据集上有限制,但通过其他有用的解释功能来弥补这一点。

由原型引导的反事实解释

最复杂反事实寻找算法做以下事情:

  • 损失: 这些方法利用一个损失函数来帮助我们优化寻找与我们的感兴趣实例最接近的反事实。

  • 扰动: 这些通常在类似于锚点的扰动空间中操作,尽可能少地改变特征。请注意,反事实不必是数据集中的真实点。那会限制得太厉害。反事实存在于可能性的领域,而不是必然已知的领域。

  • 分布:然而,反事实必须是现实的,因此可解释的。例如,损失函数可以帮助确定年龄 < 0本身就足以使任何中/高风险实例变为低风险。这就是为什么反事实应该靠近你的数据的统计分布,特别是类特定分布。它们也不应该对较小规模的特性,即分类变量有偏见。

  • 速度:这些运行足够快,可以在现实场景中有用。

Alibi 的原型引导的反事实(CounterFactualProto)具有所有这些特性。它有一个损失函数,包括 L1 (Lasso) 和 L2 (Ridge) 正则化作为线性组合,就像朴素弹性网络一样 ![img/B18406_06_001.png],但只在 L1 项上有权重()。这个算法的巧妙之处在于它可以(可选地)使用一个自动编码器来理解分布。我们将在第七章可视化卷积神经网络中利用它。然而,这里重要的是要注意,自动编码器通常是一类神经网络,它学习训练数据的压缩表示。这种方法结合了来自自动编码器的损失项,例如最近的原型。原型是反事实类降维后的表示。

如果没有自动编码器可用,算法将使用一个常用于多维搜索的树(k-d trees)。使用这棵树,算法可以有效地捕获类分布并选择最近的原型。一旦它有了原型,扰动就会由它引导。在损失函数中引入原型损失项确保产生的扰动足够接近反事实类的分布中的原型。许多建模类和解释方法忽略了处理连续和分类特征不同的重要性。

CounterFactualProto可以使用两种不同的距离度量来计算分类变量的类别之间的成对距离:修改后的值差异度量(MVDM)和基于关联的距离度量(ABDM),甚至可以结合两者。CounterFactualProto确保有意义的反事实的另一种方式是通过限制排列特征到预定义的范围内。我们可以使用特征的最小值和最大值来生成一个数组元组(feature_range):

feature_range = (
    X_train.values.min(axis=0).reshape(1,21).astype(np.float32),\
    X_train.values.max(axis=0).reshape(1,21).astype(np.float32)
)
print(feature_range) 

上述代码输出两个数组——第一个包含所有特征的最小值,第二个包含所有特征的最大值:

(array([[18.,  0.,  ... , 0.,  0.,  0.]], dtype=float32), array([[96., 20., ... ,  1.,  1.,  1.]], dtype=float32)) 

我们现在可以使用CounterFactualProto实例化一个解释器。作为参数,它需要模型的predict函数(predict_cb_fn)、你想要解释的实例的形状(X_test_eval.shape)、要执行的优化迭代次数的最大值(max_iterations)以及扰动实例的特征范围(feature_range)。可以选择许多超参数,包括应用于 L1 损失的权重( beta)和应用于原型损失的θ权重(theta)。此外,当自动编码器模型未提供时,你必须指定是否使用k-d 树(use_kdtree)。一旦实例化了解释器,你将其拟合到测试数据集。我们指定分类特征的距离度量(d_type)为 ABDM 和 MVDM 的组合:

cf_cb_explainer = CounterFactualProto(
    predict_cb_fn,
    c_init=1,
    X_test_eval.shape,
    max_iterations=500,
    feature_range=feature_range,
    beta=.01,
    theta=5,
    use_kdtree=True
)
cf_cb_explainer.fit(X_test.values, d_type='abdm-mvdm') 

使用解释器创建解释与使用主播类似。只需将实例(X_test_eval)传递给explain函数即可。然而,输出结果并不那么直接,主要是因为特征在 one-hot 编码和序数之间的转换,以及特征之间的交互。Alibi(docs.seldon.io/projects/alibi/)的文档中有一个详细的示例,说明了如何进行此操作。

我们将使用一个名为describe_cf_instance的实用函数来完成这项工作,该函数使用感兴趣的实例(X_test_eval)、解释(cf_cb_explanation)、类名(class_names)、one-hot 编码的类别位置(cat_vars_ohe)、类别映射(category_map)和特征名称(feature_names):

cf_cb_explanation = cf_cb_explainer.explain(X_test_eval) mldatasets.describe_cf_instance(
    X_test_eval,
    cf_cb_explanation,
    class_names,
    cat_vars_ohe,
    category_map,
    feature_names
) 

下面的输出是由前面的代码生成的:

Instance Outcomes and Probabilities
-----------------------------------------------
       original:  Medium/High Risk
                  [0.46732193 0.53267807]
counterfactual:  Low Risk
                  [0.50025815 0.49974185]
Categorical Feature Counterfactual Perturbations
------------------------------------------------
                sex:  Male  -->  Female
               race:  African-American  -->  Asian
    c_charge_degree:  Felony 7th Degree  -->  Felony 1st Degree
Numerical Feature Counterfactual Perturbations
------------------------------------------------
       priors_count:  2.00  -->  1.90 

你可以从输出中看出,感兴趣的实例(“原始”)有 53.26%的概率属于中/高风险,但反事实几乎在低风险一边,只有 50.03%!我们希望看到的是稍微偏向另一边的反事实,因为这可能意味着它与我们感兴趣的实例尽可能接近。它们之间有四个特征差异,其中三个是分类的(sexracec_charge_degree)。第四个差异是priors_count数值特征,由于解释器不知道它是离散的,因此将其视为连续的。无论如何,关系应该是单调的,这意味着一个变量的增加与另一个变量的减少或增加一致。在这种情况下,较少的先验应该总是意味着较低的风险,这意味着我们可以将 1.90 解释为 1,因为如果少 0.1 个先验有助于降低风险,那么整个先验也应该如此。

CounterFactualProto 的输出中得出的更有力的见解是,两个人口统计特征出现在与该特征最接近的反事实中。其中一个是通过一种旨在遵循我们类别统计分布的方法发现的,这种方法不会对特定类型的特征产生偏见或偏袒。尽管看到亚洲女性在我们的反事实中很令人惊讶,因为它不符合白人男性得到优先待遇的叙述,但意识到 种族 在反事实中出现的任何情况都令人担忧。

Alibi 库有几种反事实发现方法,包括一种利用强化学习的方法。Alibi 还使用 k-d 树 来计算其信任分数,我也强烈推荐!信任分数衡量任何分类器与修改后的最近邻分类器之间的协议。背后的推理是,一个模型的预测应该在局部层面上保持一致,才能被认为是可信的。换句话说,如果你和你的邻居在各个方面几乎都一样,为什么你会被不同对待?

WIT 中的反事实实例以及更多

Google 的 WIT 是一个非常通用的工具。它需要的输入或准备非常少,并在您的 Jupyter 或 Colab 笔记本中以交互式仪表板的形式打开,有三个选项卡:

  • 数据点编辑器:为了可视化您的数据点,编辑它们,并解释它们的预测。

  • 性能:要查看高级模型性能指标(适用于所有回归和分类模型)。对于二元分类,此选项卡称为 性能和公平性,因为除了高级指标外,还可以比较您数据集基于特征的切片之间的预测公平性。

  • 特征:查看一般特征统计。

由于 特征 选项卡与模型解释无关,我们将只在本节中探索前两个。

配置 WIT

可选地,我们可以在 WIT 中通过创建归因来丰富我们的解释,这些归因是解释每个特征对每个预测贡献多少的值。您可以使用任何方法生成归因,但我们将使用 SHAP。我们在 第四章全局模型无关解释方法 中首先介绍了 SHAP。由于我们将在 WIT 仪表板中解释我们的 CatBoost 模型,因此最合适的 SHAP 解释器是 TreeExplainer,但 DeepExplainer 适用于神经网络(KernelExplainer 适用于两者)。要初始化 TreeExplainer,我们需要传递拟合的模型(fitted_cb_mdl):

shap_cb_explainer = shap.TreeExplainer(fitted_cb_mdl) 

WIT 需要数据集中的所有特征(包括标签)。我们将使用测试数据集,因此您可以连接 X_testy_test,但即使这两个也排除了地面真相特征(is_recid)。获取所有这些特征的一种方法是从 recidivism_df 中提取测试数据集的索引(y_test.index)。WIT 还需要我们的数据和列的列表格式,以便我们可以将它们保存为变量以供以后使用(test_npcols_l)。

最后,对于预测和归因,我们需要移除我们的真实情况(is_recid)和分类标签(compas_score),所以让我们保存这些列的索引(delcol_idx):

test_df = recidivism_df.loc[y_test.index]
test_np = test_df.values
cols_l = test_df.columns
delcol_idx = [
    cols_l.get_loc("is_recid"),
    cols_l.get_loc("compas_score")
] 

WIT 有几个用于自定义仪表板的有用函数,例如设置自定义距离度量(set_custom_distance_fn)、显示类名而不是数字(set_label_vocab)、设置自定义predict函数(set_custom_predict_fn)以及第二个用于比较两个模型的predict函数(compare_custom_predict_fn)。

除了set_label_vocab之外,我们还将只使用自定义的predict函数(custom_predict_with_shap)。它只需要一个包含你的examples_np数据集的数组并产生一些预测(preds)。然而,我们首先必须移除那些我们想在仪表板中显示但未用于训练的特征(delcol_idx)。这个函数的输出是一个字典,其中预测存储在predictions键中。但我们还希望有一些归因,这就是为什么我们需要在字典中有一个attributions键。因此,我们使用 SHAP 解释器生成shap_values,这是一个numpy数组。然而,归因需要是一个字典列表,以便 WIT 仪表板能够理解。为此,我们迭代shap_output并将每个观察的 SHAP 值数组转换为字典(attrs),然后将这个字典追加到一个列表(attributions)中:

def custom_predict_with_shap(examples_np):
    #For shap values, we only need the same features
    #that were used for training
    inputs_np = np.delete(np.array(examples_np),delcol_idx,axis=1)
    #Get the model's class predictions
    preds = predict_cb_fn(inputs_np)
    #With test data, generate SHAP values which converted
    #to a list of dictionaries format
    keepcols_l = [c for i, c in enumerate(cols_l)\
                  if i not in delcol_idx]
    shap_output = shap_cb_explainer.shap_values(inputs_np)
    attributions = []
    for shap in shap_output:
        attrs = {}
        for i, col in enumerate(keepcols_l):
            attrs[col] = shap[i]
        attributions.append(attrs)  
    #Prediction function must output predictions/attributions
    #in dictionary
    output = {'predictions': preds, 'attributions': attributions} 
    return output 

在我们构建 WIT 仪表板之前,重要的是要注意,为了在仪表板中找到我们的感兴趣实例,我们需要知道它在 WIT 提供的numpy数组中的位置,因为这些没有像pandas数据框那样的索引。为了找到位置,我们只需要向get_loc函数提供索引:

print(y_test.index.get_loc(5231)) 

前面的代码输出为2910,因此我们可以注意这个数字。现在构建 WIT 仪表板相当直接。我们首先使用测试数据集(以numpy格式test_np)和我们的特征列表(cols_l)初始化一个配置(WitConfigBuilder)。这两个都通过tolist()转换为列表。然后,我们使用set_custom_predict_fn设置我们的自定义predict函数以及我们的目标特征(is_recid)并提供我们的类名。这次我们将使用真实情况来评估公平性。一旦配置初始化,小部件(WitWidget)就会用它来构建仪表板。你可以选择提供高度(默认为 1,000 像素):

wit_config_builder = WitConfigBuilder(
    test_np.tolist(),
    feature_names=cols_l.tolist()
).set_custom_predict_fn(custom_predict_with_shap
).set_target_feature("is_recid").set_label_vocab(class_names)
WitWidget(wit_config_builder, height=800) 

数据点编辑器

图 6.5中,你可以看到带有三个标签页的 WIT 仪表板。我们将首先探索第一个标签页(数据点编辑器)。它左侧有可视化编辑面板,右侧可以显示数据点部分依赖图。当你选择数据点时,你可以使用右上角的控制(高亮区域A)以多种方式可视化数据点。我们在图 6.5中设置如下:

  • 分箱 | X 轴c_charge_degree_(F7)

  • 分箱 | Y 轴compas_score

  • race_African-American

其他所有内容保持不变。

这些设置导致我们的所有数据点都被整齐地组织成两行两列,并按非洲裔美国人或非非洲裔美国人进行着色编码。右侧列是为那些具有 7 级电荷程度的个体,而上方行是为那些具有中等/高风险 COMPAS 分数的个体。我们可以通过点击最右上方的项目来查找这个子组(B)中的数据点2910。它应该出现在编辑面板(C)中。有趣的是,这个数据点的 SHAP 归因对于agerace_African-American的三倍。然而,种族整体在重要性上仍然排在年龄之后。此外,请注意,在推断面板中,您可以看到中等/高风险的预测概率约为 83%:

图形用户界面,树状图图表  自动生成的描述

图 6.5:WIT 仪表板以及我们感兴趣的实例

WIT 可以使用 L1 或 L2 距离找到最近的反事实。它可以使用特征值或归因来计算距离。如前所述,如果您将其添加到配置中,WIT 还可以包含一个自定义的距离查找函数。目前,我们将选择L2特征值。在图 6.6中,这些选项出现在高亮的A区域。一旦您选择了一个距离度量并启用了最近的反事实,它就会与我们的实例实例(区域B)并排显示,并比较它们的预测,如图图 6.6(区域C)所示。您可以通过绝对归因对特征进行排序,以便更清晰地了解局部层面的特征重要性。反事实仅比我们感兴趣的实例大 3 年,但没有先验值,而是有两个,但这足以将中等/高风险降低到近 5%:

图形用户界面  自动生成的描述

图 6.6:如何在 WIT 中找到最近的反事实

当我们的实例实例和反事实都保持选中状态时,我们可以将它们与其他所有点一起可视化。通过这样做,您可以从局部解释中获得见解,并为全局理解创造足够的内容。例如,让我们将我们的可视化设置更改为以下内容:

  • 分箱 | X 轴推理标签

  • 分箱 | Y 轴(无)

  • 散点图 | X 轴age

  • 散点图 | Y 轴priors_count

其他所有内容保持不变。

这种可视化的结果是图 6.7所展示的。您可以看到,低风险箱中的点倾向于在priors_count的较低端徘徊。两个箱体都表明prior_count年龄有轻微的相关性,尽管这在中/高风险箱中更为明显。然而,最有趣的是,与低风险箱中的数据点相比,在年龄为 18-25 岁且priors_count低于三的非洲裔美国人数据点的纯粹密度被认为是中/高风险。这表明,对于非洲裔美国人来说,较低的年龄和较高的priors_count比其他人更容易增加风险:

图形用户界面,图表,散点图描述自动生成

图 6.7:在 WIT 中可视化年龄与 priors_count 的关系

我们可以通过编辑数据点来尝试创建自己的反事实。当我们把priors_count减少到一的时候会发生什么?这个问题的答案在图 6.8中展示。一旦您做出更改并点击推断面板中的预测按钮,它将在推断面板的最后预测历史中添加一个条目。您可以在运行#2中看到,风险几乎降低到 33.5%,下降了近 50%!

图形用户界面,应用程序描述自动生成

图 6.8:在 WIT 中编辑数据点以减少 priors_count

现在,如果年龄只比之前多 2 年,但有两次先验,会发生什么?在图 6.9中,运行#3告诉你它几乎达到了低风险评分:

图形用户界面,应用程序描述自动生成

图 6.9:在 WIT 中编辑数据点以增加年龄

数据点编辑器标签页还具有另一个功能,即部分依赖图,这在第四章全局模型无关解释方法中有所介绍。如果您点击此单选按钮,它将修改右侧面板以类似于图 6.10的外观。默认情况下,如果选择了一个数据点,PDPs 是局部的,意味着它们与所选数据点相关。但您可以切换到全局。无论如何,最好按变化排序图表,就像在图 6.10中那样,其中年龄priors_count变化最大。有趣的是,这两个变量都不是单调的,这没有意义。模型应该学会priors_count的增加应始终增加风险。当年龄减少时,情况应该相同。毕竟,学术研究表明,犯罪倾向于在 20 多岁时达到顶峰,而较高的先验会增加再犯的可能性。这两个变量之间的关系也已被充分理解,因此,也许一些数据工程和单调约束可以确保模型与已知现象一致,而不是学习导致不公平的数据不一致性。我们将在第十二章单调约束和模型调优以提高可解释性中介绍这一点:

图表,折线图  自动生成的描述

图 6.10:年龄和先验计数局部部分依赖图

对于已经训练好的模型,有没有什么方法可以提高其公平性?确实有。性能与公平性选项卡可以帮助你做到这一点。

性能与公平性

当你点击性能与公平性选项卡时,你会看到它左侧有配置公平性面板。在右侧,你可以探索模型的总体性能(见图 6.11)。在这个面板的上部,有误报率(%)漏报率(%)准确率(%)F1字段。如果你展开面板,它会显示 ROC 曲线、PR 曲线、混淆矩阵和平均归因——Shapley 值的平均值。我们在本书的前几章中直接或间接地介绍了这些术语,除了 PR 曲线。精确率-召回率PR)与 ROC 曲线非常相似,但它绘制的是精确率对召回率,而不是 TPR 对 FPR。在这个图中,预期精确率会随着召回率的降低而降低。与 ROC 不同,当线接近x轴时,它被认为比抛硬币还差,它最适合不平衡分类问题:

图形用户界面,图表  自动生成的描述

图 6.11:性能与公平性选项卡初始视图

一个分类模型将输出一个观察值属于某个类别标签的概率。我们通常将所有大于或等于 0.5 的观察值归为正类。否则,我们预测它属于负类。这个阈值被称为分类阈值,你不必总是使用标准的 0.5。

在许多情况下,进行阈值调整是合适的。其中一个最令人信服的原因是不平衡的分类问题,因为通常模型只优化准确率,但最终召回率或精确度不佳。调整阈值将提高你最关心的指标:

图形用户界面,应用程序  自动生成的描述

图 6.12:按种族划分的性能指标 _African-American

调整阈值的另一个主要原因是公平性。为此,你需要检查你最关心的指标在不同数据切片中的表现。在我们的案例中,假阳性百分比是我们最能感受到不公平的地方。例如,看看图 6.12。在配置面板中,我们可以通过race_African-American来切片数据,在其右侧,我们可以看到本章开头观察到的结果,即非洲裔美国人的 FPs 显著高于其他群体。解决这个问题的一种方法是通过自动优化方法,如人口统计平衡平等机会。如果你要使用其中之一,最好调整成本比率(FP/FN)来告诉优化器 FP 比 FN 更有价值:

图表描述自动生成,置信度中等

图 6.13:调整按种族 _African-American 划分的数据集的分类阈值

我们还可以使用默认的自定义阈值设置手动调整阈值(参见图 6.13)。对于这些切片,如果我们想要与我们的 FPs(假阳性)近似相等,我们应该将race_African-American=1时的阈值设为 0.78。缺点是对于这个群体,FNs(假阴性)会增加,无法在那一端实现平衡。成本比率可以帮助确定 14.7%的 FPs 是否足以证明 24.4%的 FNs 是合理的,但要做到这一点,我们必须了解平均成本。我们将在第十一章偏差缓解和因果推断方法中进一步探讨概率校准方法。

任务完成

本章的任务是查看在预测特定被告是否会再犯时是否存在不公平的偏见。我们证明了非洲裔美国被告的 FPR(假阳性率)是白人被告的 1.87 倍。这种差异通过 WIT(加权集成测试)得到证实,表明所讨论的模型更有可能基于种族错误地将正类分类。然而,这是一个全局解释方法,所以它没有回答我们关于特定被告的问题。顺便提一下,在第十一章偏差缓解和因果推断方法中,我们将介绍其他用于不公平性的全局解释方法。

为了确定该模型是否对所涉及的被告存在种族偏见,我们利用了锚定和反事实解释——它们都在解释中将种族作为主要特征。锚定以相对较高的精确度和覆盖率完成,而原型引导的反事实发现最接近的决定具有不同的种族。尽管如此,在这两种情况下,种族都不是解释中唯一的特征。通常包括以下任何或所有特征:priors_countagecharge_degreesex。涉及前三个与race相关的不一致规则暗示了双重标准,而涉及sex则暗示了交叉性。双重标准是指规则对不同群体不公平地应用。交叉性是指重叠的身份如何创建不同的相互关联的歧视模式。然而,根据学术研究,我们知道所有种族的女性再犯的可能性较低。尽管如此,我们仍需问自己,女性是否具有结构性优势,使她们在这个背景下享有特权。适量的怀疑态度可能会有所帮助,因为当涉及到偏见时,通常存在比表面现象更复杂的动态。底线是,尽管所有其他与种族相关的因素都在相互作用,并且假设我们没有遗漏相关的犯罪学信息,是的——在这个特定的预测中存在种族偏见。

摘要

在阅读本章之后,你应该知道如何利用锚定来理解影响分类的决定规则,以及如何使用反事实来掌握预测类别需要改变的内容。你还学习了如何使用混淆矩阵和谷歌的 WIT 来评估公平性。在下一章中,我们将研究卷积神经网络CNNs)的解释方法。

数据集来源

进一步阅读

在 Discord 上了解更多

要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

二维码

第七章:可视化卷积神经网络

到目前为止,我们只处理了表格数据,以及在第五章局部模型无关解释方法中简要提到的文本数据。本章将专门探讨适用于图像的解释方法,特别是训练图像分类器的卷积神经网络CNN)模型。通常,深度学习模型被视为黑盒模型的典范。然而,CNN 的一个优点是它很容易进行可视化,因此我们不仅可以可视化结果,还可以通过激活来可视化学习过程中的每一步。在所谓的黑盒模型中,解释这些步骤的可能性是罕见的。一旦我们掌握了 CNN 的学习方式,我们将研究如何使用最先进的基于梯度的属性方法,如显著性图Grad-CAM来调试类别属性。最后,我们将通过基于扰动的属性方法,如遮挡敏感性KernelSHAP来扩展我们的属性调试知识。

这些是我们将要讨论的主要主题:

  • 使用传统解释方法评估 CNN 分类器

  • 使用基于激活的方法可视化学习过程

  • 使用基于梯度的属性方法评估误分类

  • 使用基于扰动的属性方法理解分类

技术要求

本章的示例使用了mldatasetspandasnumpysklearntqdmtorchtorchvisionpytorch-lightningefficientnet-pytorchtorchinfomatplotlibseaborncaptum库。如何安装所有这些库的说明在前言中。

本章的代码位于此处:packt.link/qzUvD

任务

全球每年产生超过 20 亿吨垃圾,预计到 2050 年将增长到超过 35 亿吨。近年来,全球垃圾产量急剧上升和有效废物管理系统需求日益迫切。在高收入国家,超过一半的家庭垃圾是可回收的,在低收入国家为 20%,并且还在上升。目前,大多数垃圾最终都堆放在垃圾填埋场或焚烧,导致环境污染和气候变化。考虑到全球范围内,很大一部分可回收材料没有得到回收,这是可以避免的。

假设可回收垃圾被收集,但仍可能很难且成本高昂地进行分类。以前,废物分类技术包括:

  • 通过旋转圆柱形筛网(“摇筛”)按尺寸分离材料

  • 通过磁力和磁场分离铁和非铁金属(“涡流分离器”)

  • 通过空气按重量分离

  • 通过水按密度分离(“沉浮分离”)

  • 由人工执行的手动分类

即使对于大型、富裕的城市市政府,有效地实施所有这些技术也可能具有挑战性。为了应对这一挑战,智能回收系统应运而生,利用计算机视觉和人工智能高效、准确地分类废物。

智能回收系统的发展可以追溯到 2010 年代初,当时研究人员和革新者开始探索计算机视觉和人工智能改善废物管理流程的潜力。他们首先开发了基本的图像识别算法,利用颜色、形状和纹理等特征来识别废物材料。这些系统主要用于研究环境,商业应用有限。随着机器学习和人工智能的进步,智能回收系统经历了显著的改进。卷积神经网络(CNN)和其他深度学习技术使这些系统能够从大量数据中学习并提高其废物分类的准确性。此外,人工智能驱动的机器人集成使得废物材料的自动化分拣和处理成为可能,从而提高了回收工厂的效率。

摄像头、机器人和用于低延迟、高容量场景运行深度学习模型的芯片等成本与十年前相比显著降低,这使得最先进的智能回收系统对甚至更小、更贫穷的城市废物管理部门也变得可负担。巴西的一个城市正在考虑翻新他们 20 年前建成的一个由各种机器拼凑而成的回收厂,这些机器的集体分拣准确率仅为 70%。人工分拣只能部分弥补这一差距,导致不可避免的污染和污染问题。该巴西市政府希望用一条单条传送带替换当前系统,这条传送带由一系列机器人高效地将 12 种不同类别的废物分拣到垃圾桶中。

他们购买了传送带、工业机器人和摄像头。然后,他们支付了一家人工智能咨询公司开发一个用于分类可回收物的模型。然而,他们想要不同大小的模型,因为他们不确定这些模型在他们的硬件上运行的速度有多快。

如请求,咨询公司带回了 4 到 6400 万参数之间各种大小的模型。最大的模型(b7)比最小的模型(b0)慢六倍以上。然而,最大的模型在验证 F1 分数上显著更高,达到 96%(F1 val),而最小的模型大约为 90%:

图表,折线图  自动生成描述

图 7.1:由人工智能咨询公司提供的模型 F1 分数

市政领导对结果感到非常满意,但也感到惊讶,因为顾问们要求不要提供任何领域知识或用于训练模型的数据,这使得他们非常怀疑。他们要求回收厂的工人用一批可回收物测试这些模型。他们用这一批次的模型得到了 25%的错误分类率。

为了寻求第二意见和模型的诚实评估,市政厅联系了另一家 AI 咨询公司——你的公司!

第一项任务是组装一个更符合回收工厂工人在误分类中发现的边缘情况的测试数据集。你的同事使用测试数据集获得了 62%到 66%的 F1 分数(F1 测试)。接下来,他们要求你理解导致这些误分类的原因。

方法

没有一种解释方法完美无缺,即使是最好的情况也只能告诉你故事的一部分。因此,你决定首先使用传统的解释方法来评估模型的预测性能,包括以下方法:

  • ROC 曲线和 ROC-AUC

  • 混淆矩阵以及由此派生的一些指标,如准确率、精确率、召回率和 F1

然后,你将使用基于激活的方法检查模型:

  • 中间激活

这之后是使用三种基于梯度的方法评估决策:

  • 显著性图

  • Grad-CAM

  • 集成梯度

以及一个基于反向传播的方法:

  • DeepLIFT

这之后是三种基于扰动的算法:

  • 遮蔽敏感性

  • 特征消除

  • Shapley 值采样

我希望你在这一过程中理解为什么模型的表现不符合预期,以及如何修复它。你还可以利用你将生成的许多图表和可视化来向市政厅的行政人员传达这个故事。

准备工作

你会发现这个示例的大部分代码都在这里:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/blob/main/07/GarbageClassifier.ipynb

加载库

要运行这个示例,你需要安装以下库:

  • 使用torchvision加载数据集

  • 使用mldatasetspandasnumpysklearn(scikit-learn)来操作数据集

  • 使用torchpytorch-lightningefficientnet-pytorchtorchinfo模型进行预测并显示模型信息

  • 使用matplotlibseaborncv2tqdmcaptum来制作和可视化解释

你首先应该加载所有这些库:

import math
import os, gc
import random
import mldatasets
import numpy as np
import pandas as pd
from sklearn.preprocessing import OneHotEncoder
import torchvision
import torch
import pytorch_lightning as pl
import efficientnet_pytorch
from torchinfo import summary
import matplotlib.pyplot as plt
from matplotlib.cm import ScalarMappable 
from matplotlib.colors import LinearSegmentedColormap
import seaborn as sns
import cv2
from tqdm.notebook import tqdm
from captum import attr 

接下来,我们将加载和准备数据。

理解和准备数据

训练模型所使用的数据在 Kaggle 上公开可用(www.kaggle.com/datasets/mostafaabla/garbage-classification)。它被称为“垃圾分类”,是几个不同在线资源的汇编,包括网络爬取。它已经被分割成训练集和测试集,还附带了一个额外的较小的测试数据集,这是你的同事用来测试模型的。这些测试图像的分辨率也略高。

我们像这样从 ZIP 文件中下载数据:

dataset_file = "garbage_dataset_sample"
dataset_url = f"https://github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/raw/main/datasets/{dataset_file}.zip"
torchvision.datasets.utils.download_url(dataset_url, ".")
torchvision.datasets.utils.extract_archive(f"{dataset_file}.zip",\
                                           remove_finished=True) 

它还会将 ZIP 文件提取到四个文件夹中,分别对应三个数据集和更高分辨率的测试数据集。请注意,garbage_dataset_sample只包含训练和验证数据集的一小部分。如果你想下载完整的数据集,请使用dataset_file = "garbage_dataset"。无论哪种方式,都不会影响测试数据集的大小。接下来,我们可以这样初始化数据集的转换和加载:

X_train, norm_mean = (0.485, 0.456, 0.406)
norm_std  = (0.229, 0.224, 0.225)
transform = torchvision.transforms.Compose(
    [
        torchvision.transforms.ToTensor(),
        torchvision.transforms.Normalize(norm_mean, norm_std),
    ]
)
train_data = torchvision.datasets.ImageFolder(
    f"{dataset_file}/train", transform
)
val_data = torchvision.datasets.ImageFolder(
    f"{dataset_file}/validation", transform
)
test_data = torchvision.datasets.ImageFolder(
    f"{dataset_file}/test", transform
)
test_400_data = torchvision.datasets.ImageFolder(
    f"{dataset_file}/test_400", transform
) 

上述代码所做的就是组合一系列标准转换,如归一化和将图像转换为张量。然后,它实例化与每个文件夹对应的 PyTorch 数据集——即一个用于训练、验证和测试数据集,以及更高分辨率的测试数据集(test_400_data)。这些数据集也包括转换。这样,每次从数据集中加载图像时,它都会自动进行转换。我们可以使用以下代码来验证数据集的形状是否符合我们的预期:

print(f"# Training Samples:    \t{len(train_data)}")
print(f"# Validation Samples:  \t{len(val_data)}")
print(f"# Test Samples:        \t{len(test_data)}")
print(f"Sample Dimension:      \t{test_data[0][0].shape}")
print("="*50)
print(f"# Test 400 Samples:    \t{len(test_400_data)}")
print(f"# 400 Sample Dimension:\t{test_400_data[0][0].shape}") 

上述代码输出了每个数据集中的图像数量和图像的维度。你可以看出,有超过 3,700 张训练图像,900 张验证图像和 120 张测试图像,它们的维度为 3 x 224 x 224。第一个数字对应于通道(红色、绿色和蓝色),接下来的两个数字对应于像素的宽度和高度,这是模型用于推理的。Test 400 数据集与 Test 数据集相同,只是图像的高度和宽度更大。我们不需要 Test 400 数据集进行推理,所以它不符合模型的维度要求也是可以的:

# Training Samples:        3724
# Validation Samples:      931
# Test Samples:            120
Sample Dimension:          torch.Size([3, 224, 224])
==================================================
# Test 400 Samples:        120 
# 400 Sample Dimension:    torch.Size([3, 400, 400]) 

数据准备

如果你打印(test_data[0]),你会注意到它首先会输出一个包含图像的张量,然后是一个单独的整数,我们称之为标量。这个整数是一个介于 0 到 11 之间的数字,对应于使用的标签。为了快速参考,以下是 12 个标签:

labels_l = ['battery', 'biological', 'brown-glass', 'cardboard',\
            'clothes', 'green-glass', 'metal', 'paper', 'plastic',\
            'shoes', 'trash', 'white-glass'] 

解释通常涉及从数据集中提取单个样本,以便稍后使用模型进行推理。为此,熟悉从数据集中提取任何图像,比如测试数据集的第一个样本是很重要的:

tensor, label = test_400_data[0]
img = mldatasets.tensor_to_img(tensor, norm_std, norm_mean)
plt.figure(figsize=(5,5))
plt.title(labels_l[label], fontsize=16)
plt.imshow(img)
plt.show() 
0) from the higher resolution version of the test dataset (test_400_data) and extracting the tensor and label portion from it. Then, we are using the convenience function tensor_to_img to convert the PyTorch tensor to a numpy array but also reversing the standardization that had been previously performed on the tensor. Then, we plot the image with matplotlib's imshow and use the labels_l list to convert the label into a string, which we print in the title. The result can be seen in *Figure 7.2*:

包含图表的图片 自动生成描述

图 7.2:一个可回收碱性电池的测试样本

我们还需要执行的一个预处理步骤是对y标签进行独热编码OHE),因为我们需要 OHE 形式来评估模型的预测性能。一旦我们初始化了OneHotEncoder,我们需要将其fit到测试标签(y_test)的数组格式中。但首先,我们需要将测试标签放入一个列表(y_test)。我们也可以用同样的方法处理验证标签,因为这些标签也便于评估:

y_test = np.array([l for _, l in test_data])
y_val = np.array([l for _, l in val_data])
ohe = OneHotEncoder(sparse=False).\
              fit(np.array(y_test).reshape(-1, 1)) 

此外,为了确保可重复性,始终这样初始化你的随机种子:

rand = 42
os.environ['PYTHONHASHSEED']=str(rand)
np.random.seed(rand)
random.seed(rand)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if device == 'cuda':
    torch.cuda.manual_seed(rand)
else:
    torch.manual_seed(rand) 

深度学习中确定性的实现非常困难,并且通常依赖于会话、平台和架构。如果你使用NVIDIA GPU,你可以尝试使用 PyTorch 通过命令torch.use_deterministic_algorithms(True)来避免非确定性算法。这并不保证,但如果尝试的操作无法以确定性完成,它将引发错误。如果成功,它将运行得慢得多。只有在你需要使模型结果一致时才值得这样做——例如,用于科学研究或合规性。有关可重复性和 PyTorch 的更多详细信息,请查看此处:pytorch.org/docs/stable/notes/randomness.html

检查数据

现在,让我们看看我们的数据集中有哪些图像。我们知道训练和验证数据集非常相似,所以我们从验证数据集开始。我们可以迭代labels_l中的每个类别,并使用np.random.choice从验证数据集中随机选择一个。我们将每个图像放置在一个 4×3 的网格中,类别标签位于其上方:

plt.subplots(figsize=(14,10))
for c, category in enumerate(labels_l):
    plt.subplot(3, 4, c+1)
    plt.title(labels_l[c], fontsize=12)
    idx = np.random.choice(np.where(y_test==c)[0], 1)[0]
    im = mldatasets.tensor_to_img(test_data[idx][0], norm_std,\
                                  norm_mean)
    plt.imshow(im, interpolation='spline16')
    plt.axis("off")
plt.show() 

上一段代码生成了图 7.3。你可以看出,物品的边缘存在明显的像素化;有些物品比其他物品暗得多,而且有些图片是从奇怪的角度拍摄的:

图形用户界面 描述自动生成,置信度中等

图 7.3:验证数据集的随机样本

现在我们对测试数据集做同样的处理,以便与验证/训练数据集进行比较。我们可以使用之前的相同代码,只需将y_val替换为y_test,将val_data替换为test_data。生成的代码生成了图 7.4。你可以看出,测试集的像素化较少,物品的照明更一致,主要是从正面和侧面角度拍摄的:

包含文本的图片,不同,各种 描述自动生成

图 7.4:测试数据集的随机样本

在本章中,我们不需要训练 CNN。幸运的是,客户已经为我们提供了它。

CNN 模型

其他咨询公司训练的模型是微调后的 EfficientNet 模型。换句话说,AI 咨询公司使用 EfficientNet 架构的先前训练模型,并使用垃圾分类数据集进一步训练它。这种技术被称为迁移学习,因为它允许模型利用从大型数据集(在这种情况下,来自 ImageNet 数据库的百万张图片)中学习到的先前知识,并将其应用于具有较小数据集的新任务。其优势是显著减少了训练时间和计算资源,同时保持高性能,因为它已经学会了从图像中提取有用的特征,这可以成为新任务的宝贵起点,并且只需要适应手头的特定任务。

选择 EfficientNet 是有道理的。毕竟,EfficientNet 是由 Google AI 研究人员在 2019 年引入的一组 CNN。EfficientNet 的关键创新是其复合缩放方法,这使得模型能够比其他 CNN 实现更高的准确性和效率。此外,它基于这样的观察:模型的各个维度,如宽度、深度和分辨率,以平衡的方式对整体性能做出贡献。EfficientNet 架构建立在称为 EfficientNet-B0 的基线模型之上。采用复合缩放方法创建基线模型更大、更强大的版本,同时提高网络的宽度、深度和分辨率。这产生了一系列模型,从 EfficientNet-B1 到 EfficientNet-B7,容量和性能逐渐提高。最大的模型 EfficientNet-B7 在多个基准测试中实现了最先进的性能,例如 ImageNet。

加载 CNN 模型

在我们能够加载模型之前,我们必须定义 EfficientLite 类的类——一个继承自 PyTorch Lightning 的 pl.LightningModule 的类。这个类旨在创建基于 EfficientNet 架构的定制模型,对其进行训练并执行推理。我们只需要它来执行后者,这就是为什么我们还将其修改为包含一个 predict() 函数——类似于 scikit-learn 模型,以便能够使用类似的评估函数:

class **EfficientLite**(pl.LightningModule):
    def __init__(self, lr: float, num_class: int,\
                 pretrained="efficientnet-b0", *args, **kwargs):
        super().__init__()
        self.save_hyperparameters()
        self.model = efficientnet_pytorch.EfficientNet.\
                                      from_pretrained(pretrained)
        in_features = self.model._fc.in_features
        self.model._fc = torch.nn.Linear(in_features, num_class)
    def forward(self, x):
        return self.model(x)
    def predict(self, dataset):
        self.model.eval()
        device = torch.device("cuda" if torch.cuda.is_available()\
                              else "cpu")
        with torch.no_grad():
            if isinstance(dataset, np.ndarray):
                if len(dataset.shape) == 3:
                    dataset = np.expand_dims(dataset, axis=0)
                dataset = [(x,0) for x in dataset]
            loader = torch.utils.data.DataLoader(dataset,\
                                                 batch_size=32)
            probs = None
        for X_batch, _ in tqdm(loader):
            X_batch = X_batch.to(device, dtype=torch.float32)
            logits_batch =  self.model(X_batch)
            probs_batch = torch.nn.functional.softmax(logits_batch,\
                                       dim=1).cpu().detach().numpy()
            if probs is not None:
                probs = np.concatenate((probs, probs_batch))
            else:
                probs = probs_batch
            clear_gpu_cache()
        return probs 

你会注意到这个类有三个函数:

  • __init__: 这是 EfficientLite 类的构造函数。它通过使用 efficientnet_pytorch.EfficientNet.from_pretrained() 方法加载预训练的 EfficientNet 模型来初始化模型。然后,它将最后一个全连接层 (_fc) 替换为一个新创建的 torch.nn.Linear 层,该层具有相同数量的输入特征,但输出特征的数量不同,等于类别的数量 (num_class)。

  • forward: 此方法定义了模型的正向传播。它接收一个输入张量 x 并将其通过模型传递,返回输出。

  • predict: 此方法接收一个数据集并使用训练好的模型进行推理。它首先将模型设置为评估模式 (self.model.eval())。输入数据集被转换为具有 32 个批次的 DataLoader 对象。该方法遍历 DataLoader,处理每个数据批次,并使用 softmax 函数计算概率。在每个迭代之后调用 clear_gpu_cache() 函数以释放未使用的 GPU 内存。最后,该方法返回计算出的概率作为 numpy 数组。

如果你正在使用支持 CUDA 的 GPU,有一个名为clear_gpu_cache()的实用函数,每次进行 GPU 密集型操作时都会运行。根据你的 GPU 性能如何,你可能需要更频繁地运行它。你可以自由地使用另一个便利函数print_gpu_mem_used()来检查在任何给定时刻 GPU 内存的使用情况,或者使用print(torch.cuda.memory_summary())来打印整个摘要。接下来的代码下载预训练的 EfficientNet 模型,将模型权重加载到 EfficientLite 中,并准备模型进行推理。最后,它打印了一个摘要:

model_weights_file = **"garbage-finetuned-efficientnet-b4"**
model_url = f"https://github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/raw/main/models/{model_weights_file}.ckpt"
torchvision.datasets.utils.download_url(model_url, ".")
garbage_mdl = EfficientLite.load_from_checkpoint(
    f"{model_weights_file}.ckpt"
)
garbage_mdl = garbage_mdl.to(device).eval()
print(summary(garbage_mdl)) 

代码相当直接,但重要的是要注意,我们在这个章节选择了 b4 模型,它在大小、速度和准确性方面介于 b0 和 b7 之间。你可以根据你的硬件能力更改最后一位数字,但这可能会改变本章代码的一些结果。前面的代码片段输出了以下摘要:

=======================================================================
Layer (type:depth-idx) Param # =======================================================================
EfficientLite -- 
├─EfficientNet: 1-1 – 
│ └─Conv2dStaticSamePadding: 2-1 1,296 
│ │ └─ZeroPad2d: 3-1 – 
│ └─BatchNorm2d: 2-2 96 
│ └─ModuleList: 2-3 – 
│ │ └─MBConvBlock: 3-2 2,940 
│ │ └─MBConvBlock: 3-3 1,206 
│ │ └─MBConvBlock: 3-4 11,878 
│ │ └─MBConvBlock: 3-5 18,120 
│ │ └─MBConvBlock: 3-6 18,120 
│ │ └─MBConvBlock: 3-7 18,120 
│ │ └─MBConvBlock: 3-8 25,848 
│ │ └─MBConvBlock: 3-9 57,246 
│ │ └─MBConvBlock: 3-10 57,246 
│ │ └─MBConvBlock: 3-11 57,246 
│ │ └─MBConvBlock: 3-12 70,798 
│ │ └─MBConvBlock: 3-13 197,820 
│ │ └─MBConvBlock: 3-14 197,820 
│ │ └─MBConvBlock: 3-15 197,820 
│ │ └─MBConvBlock: 3-16 197,820 
│ │ └─MBConvBlock: 3-17 197,820 
│ │ └─MBConvBlock: 3-18 240,924 
│ │ └─MBConvBlock: 3-19 413,160 
│ │ └─MBConvBlock: 3-20 413,160 
│ │ └─MBConvBlock: 3-21 413,160 
│ │ └─MBConvBlock: 3-22 413,160 
│ │ └─MBConvBlock: 3-23 413,160 
│ │ └─MBConvBlock: 3-24 520,904 
│ │ └─MBConvBlock: 3-25 1,159,332 
│ │ └─MBConvBlock: 3-26 1,159,332 
│ │ └─MBConvBlock: 3-27 1,159,332 
│ │ └─MBConvBlock: 3-28 1,159,332 
│ │ └─MBConvBlock: 3-29 1,159,332 
│ │ └─MBConvBlock: 3-30 1,159,332 
│ │ └─MBConvBlock: 3-31 1,159,332 
│ │ └─MBConvBlock: 3-32 1,420,804 
│ │ └─MBConvBlock: 3-33 3,049,200 
│ └─Conv2dStaticSamePadding: 2-4 802,816 
│ │ └─Identity: 3-34 – 
│ └─BatchNorm2d: 2-5 3,584 
│ └─AdaptiveAvgPool2d: 2-6 – 
│ └─Dropout: 2-7 – 
│ └─Linear: 2-8 21,516 
│ └─MemoryEfficientSwish: 2-9 
======================================================================
Total params: 17,570,132 
Trainable params: 17,570,132 
Non-trainable params: 0 ====================================================================== 

它几乎包含了我们需要的关于模型的所有信息。它有两个自定义卷积层(Conv2dStaticSamePadding),每个卷积层后面跟着一个批归一化层(BatchNorm2d)和 32 个MBConvBlock模块。

网络还有一个 Swish 激活函数的内存高效实现(MemoryEfficientSwish),就像所有激活函数一样,它将非线性引入模型。它是平滑且非单调的,有助于它更快地收敛,同时学习更复杂和细微的模式。它还有一个全局平均池化操作(AdaptiveAvgPool2d),它减少了特征图的空间维度。然后有一个用于正则化的第一个Dropout层,后面跟着一个将节点数从 1792 减少到 12 的完全连接层(Linear)。Dropout 通过在每个更新周期中使一部分神经元不活跃来防止过拟合。如果你想知道每个层之间的输出形状是如何减少的,可以将input_size输入到摘要中——例如summary(garbage_mdl, input_size=(64, 3, 224, 224))——因为网络是针对 64 个批次的尺寸设计的。如果你对这些术语不熟悉,不要担心。我们稍后会重新讨论它们。

使用传统解释方法评估 CNN 分类器

我们将首先使用evaluate_multiclass_mdl函数和验证数据集来评估模型。参数包括模型(garbage_mdl)、我们的验证数据(val_data)、类别名称(labels_l)以及编码器(ohe)。最后,我们不会绘制 ROC 曲线(plot_roc=False)。此函数返回预测标签和概率,我们可以将它们存储在变量中以供以后使用:

y_val_pred, y_val_prob = mldatasets.evaluate_multiclass_mdl(
    garbage_mdl, val_data,\
    class_l=labels_l, ohe=ohe, plot_roc=False
) 

前面的代码生成了带有混淆矩阵的图 7.5和每个类别的性能指标的图 7.6

图形用户界面、文本、应用程序、电子邮件  自动生成的描述

图 7.5:验证数据集的混淆矩阵

尽管图 7.5中的混淆矩阵似乎表明分类完美,但一旦你看到图 7.6中的精确率和召回率分解,你就可以知道模型在金属、塑料和白色玻璃方面存在问题:

包含文本的图片,收据  自动生成的描述

图 7.6:验证数据集的分类报告

如果你使用最优的超参数对模型进行足够的轮次训练,你可以期望模型总是达到100%的训练准确率。接近完美的验证准确率更难实现,这取决于这两个值之间的差异。我们知道验证数据集只是来自同一集合的图像样本,所以达到 94.7%并不特别令人惊讶。

plot_roc=True) but only the averages, and not on a class-by-class basis (plot_roc_class=False) because there are only four pictures per class. Given the small number of samples, we can display the numbers in the confusion matrix rather than percentages (pct_matrix=False):
y_test_pred, y_test_prob = mldatasets.evaluate_multiclass_mdl(
    garbage_mdl, test_data,\
    class_l=labels_l, ohe=ohe,\
    plot_roc=True, plot_roc_class=False, pct_matrix=False
) 
generated the ROC curve in *Figure 7.7*, the confusion matrix in *Figure 7.8*, and the classification report in *Figure 7.9*:

图表,折线图  自动生成的描述

图 7.7:测试数据集的 ROC 曲线

测试 ROC 图(图 7.7)显示了宏平均和微平均的 ROC 曲线。这两者的区别在于它们的计算方式。宏度量是独立地对每个类别进行计算然后平均,对待每个类别不同,而微平均则考虑了每个类别的贡献或代表性;一般来说,微平均更可靠。

图表,散点图  自动生成的描述

图 7.8:测试数据集的混淆矩阵

如果我们查看图 7.8中的混淆矩阵,我们可以看出只有生物、绿色玻璃和鞋子得到了 10/10 的分类。然而,很多物品被错误地分类为生物和鞋子。另一方面,很多物品经常被错误分类,比如金属、纸张和塑料。许多物品在形状或颜色上相似,所以你可以理解为什么会这样,但金属怎么会和白色玻璃混淆,或者纸张会和电池混淆呢?

表格  自动生成的描述

图 7.9:测试数据集的预测性能指标

在商业环境中讨论分类模型时,利益相关者通常只对一个数字感兴趣:准确率。很容易让这个数字驱动讨论,但其中有很多细微差别。例如,令人失望的测试准确率(68.3%)可能意味着很多事情。这可能意味着六个类别得到了完美的分类,而其他所有类别都没有,或者 12 个类别只有一半被错误分类。可能发生的事情有很多。

在任何情况下,处理多类分类问题时,即使准确率低于 50%也可能不像看起来那么糟糕。考虑到无信息率代表了在数据集中总是预测最频繁类别的朴素模型所能达到的准确率。它作为一个基准,确保开发出的模型提供了超越这种简单方法的见解。并且,如果数据集被平均分成 12 类,那么无信息率可能大约是 8.33%(100%/12 类),所以 68%仍然比这高得多。实际上,距离 100%的差距还要小!对于一个机器学习从业者来说,这意味着如果我们仅仅根据测试准确率结果来判断,模型仍在学习一些有价值的东西,这些是可以进一步改进的。

在任何情况下,测试数据集在图 7.9中的预测性能指标与我们在混淆矩阵中看到的一致。生物类别召回率高但精确率低,而金属、纸张、塑料和垃圾的召回率都很低。

确定要关注的错误分类

我们已经注意到一些有趣的错误分类,我们可以集中关注:

  • 金属的假阳性:测试数据集中有 120 个样本中的 16 个被错误分类为金属。这是所有错误分类的 42%!模型为什么如此容易将金属与其他垃圾混淆,这是怎么回事?

  • 塑料的假阴性:70%的所有真实塑料样本都被错误分类。因此,塑料在所有材料中除了垃圾之外,召回率最低。很容易理解为什么垃圾分类如此困难,因为它极其多样,但不是塑料。

我们还应该检查一些真实阳性,以对比这些错误分类。特别是电池,因为它们作为金属和塑料有很多假阳性,以及白色玻璃,因为它 30%的时间作为金属有假阴性。由于金属的假阳性很多,我们应该将它们缩小到仅仅是电池的。

为了可视化前面的任务,我们可以创建一个 DataFrame(preds_df),其中包含一个列的真实标签(y_true)和另一个列的预测标签。为了了解模型对这些预测的确定性,我们可以创建另一个包含概率的 DataFrame(probs_df)。我们可以为这些概率生成列总计,以便根据模型在所有样本中最确定哪个类别来排序列。然后,我们可以将我们的预测 DataFrame 与概率 DataFrame 的前 12 列连接起来:

preds_df = pd.DataFrame({'y_true':[labels_l[o] for o in y_test],\
                         'y_pred':y_test_pred})
probs_df = pd.DataFrame(y_test_prob*100).round(1)
probs_df.loc['Total']= probs_df.sum().round(1)
probs_df.columns = labels_l
probs_df = probs_df.sort_values('Total', axis=1, ascending=False)
probs_df.drop(['Total'], axis=0, inplace=True)
probs_final_df = probs_df.iloc[:,0:12]
preds_probs_df = pd.concat([preds_df, probs_final_df], axis=1) 

现在我们输出感兴趣的预测实例的 DataFrame,并对其进行颜色编码。一方面,我们有金属的假阳性,另一方面,我们有塑料的假阴性。但我们还有电池和白色玻璃的真实阳性。最后,我们将所有超过 50%的概率加粗,并将所有 0%的概率隐藏起来,这样更容易发现任何高概率的预测:

num_cols_l = list(preds_probs_df.columns[2:])
num_fmt_dict = dict(zip(num_cols_l, ["{:,.1f}%"]*len(num_cols_l)))
preds_probs_df[
    (preds_probs_df.y_true!=preds_probs_df.y_pred)
    | (preds_probs_df.y_true.isin(['battery', 'white-glass']))
].style.format(num_fmt_dict).apply(
    lambda x: ['background: lightgreen' if (x[0] == x[1])\
                else '' for i in x], axis=1
).apply(
    lambda x: ['background: orange' if (x[0] != x[1] and\
                x[1] == 'metal' and x[0] == 'battery')\
                else '' for i in x], axis=1
).apply(
    lambda x: ['background: yellow' if (x[0] != x[1] and\
                                        x[0] == 'plastic')\
                else '' for i in x], axis=1
).apply(
    lambda x: ['font-weight: bold' if isinstance(i, float)\
                                                 and i >= 50\
                else '' for i in x], axis=1
).apply(
    lambda x: ['color:transparent' if i == 0.0\
                else '' for i in x], axis=1) 
Figure 7.10. We can tell by the highlights which are the metal false positives and the plastic false negatives, as well as which would be the true positives: #0-6 for battery, and #110-113 and #117-119 for white glass:

表格描述自动生成

图 7.10:测试数据集中所有 38 个错误分类、选定的真实正例及其真实和预测标签,以及它们的预测概率的表格

我们可以使用以下代码轻松地将这些实例的索引存储在列表中。这样,为了未来的参考,我们可以遍历这些列表来评估单个预测,或者用它们来对整个组执行解释任务。正如你所看到的,我们有所有四个组的列表:

plastic_FN_idxs = preds_df[
    (preds_df['y_true'] !=preds_df['y_pred'])
    & (preds_df['y_true'] == 'plastic')
].index.to_list()
metal_FP_idxs = preds_df[
    (preds_df['y_true'] != preds_df['y_pred'])
    & (preds_df['y_pred'] == 'metal')
    & (preds_df['y_true'] == 'battery')
].index.to_list()
battery_TP_idxs = preds_df[
    (preds_df['y_true'] ==preds_df['y_pred'])
    & (preds_df['y_true'] == 'battery')
].index.to_list()
wglass_TP_idxs = preds_df[
    (preds_df['y_true'] == preds_df['y_pred'])
    & (preds_df['y_true'] == 'white-glass')
].index.to_list() 

现在我们已经预处理了所有数据,模型已完全加载并列出要调试的预测组。现在我们可以继续前进。让我们开始解释!

使用基于激活的方法可视化学习过程

在我们开始讨论激活、层、过滤器、神经元、梯度、卷积、核以及构成卷积神经网络(CNN)的所有神奇元素之前,让我们首先简要回顾一下 CNN 的机制,特别是其中一个机制。

卷积层是 CNN 的基本构建块,它是一个顺序神经网络。它通过可学习的过滤器对输入进行卷积,这些过滤器相对较小,但会在特定的距离或步长上应用于整个宽度、高度和深度。每个过滤器产生一个二维的激活图(也称为特征图)。之所以称为激活图,是因为它表示图像中激活的位置——换句话说,特定“特征”所在的位置。在这个上下文中,特征是一个抽象的空间表示,在处理过程的下游,它反映在完全连接(线性)层的所学权重中。例如,在垃圾 CNN 案例中,第一个卷积层有 48 个过滤器,3 × 3 的核,2 × 2 的步长和静态填充,这确保输出图保持与输入相同的大小。过滤器是模板匹配的,因为当在输入图像中找到某些模式时,它们最终会在激活图中激活区域。

但在我们到达完全连接层之前,我们必须减小过滤器的尺寸,直到它们达到可工作的尺寸。例如,如果我们展平第一个卷积的输出(48 × 112 × 112),我们就会有超过 602,000 个特征。我想我们都可以同意,这会太多以至于无法输入到完全连接层中。即使我们使用了足够的神经元来处理这项工作负载,我们可能也没有捕捉到足够的空间表示,以便神经网络能够理解图像。因此,卷积层通常与池化层配对,池化层对输入进行下采样——换句话说,它们减少了数据的维度。在这种情况下,有一个自适应平均池化层(AdaptiveAvgPool2d),它在所有通道上执行平均,以及许多在Mobile Inverted Bottleneck Convolution BlocksMBConvBlock)内的池化层。

顺便提一下,MBConvBlockConv2dStaticSamePaddingBatchNorm2d是 EfficientNet 架构的构建块。这些组件共同工作,创建了一个高度高效且准确的卷积神经网络:

  • MBConvBlock: 形成 EfficientNet 架构核心的移动倒置瓶颈卷积块。在传统的卷积层中,过滤器同时应用于所有输入通道,导致计算量很大,但MBConvBlocks将这个过程分为两个步骤:首先,它们应用深度卷积,分别处理每个输入通道,然后使用点卷积(1 x 1)来结合来自不同通道的信息。因此,在 B0 的MBConvBlock模块中,有三个卷积层:一个深度卷积,一个点卷积(称为项目卷积),以及在某些块中的另一个点卷积(称为扩展卷积)。然而,第一个块只包含两个卷积层(深度卷积和项目卷积),因为它没有扩展卷积。对于 B4,架构类似,但每个块中堆叠的卷积更多,MBConvBlocks的数量也翻倍。自然地,B7 有更多的块和卷积层。对于 B4,总共有 158 次卷积操作分布在 32 个MBConvBlocks之间。

  • Conv2dStaticSamePadding: 与传统的卷积层(如Conv2d)不同,这些层不会减少维度。它确保输入和输出特征图具有相同的空间维度。

  • BatchNorm2d: 批标准化层,通过归一化输入特征来帮助稳定和加速训练,这有助于在训练过程中保持输入特征的分布一致性。

一旦执行了超过 230 次的卷积和池化操作,我们得到一个更易于处理的扁平化输出:1,792 个特征,全连接层将这些特征转换为 12 个,利用softmax激活函数,为每个类别输出介于 0 和 1 之间的概率。在垃圾 CNN 中,有一个dropout层用于帮助正则化训练。我们可以完全忽略这一点,因为在推理过程中,它们是被忽略的。

如果这还不够清晰,不要担心!接下来的部分将通过激活、梯度和扰动直观地展示网络可能学习或未学习的图像表示方式。

中间激活

对于推理,图像通过网络的输入,预测通过输出穿过每个层。然而,具有顺序和分层架构的一个优点是我们可以提取任何层的输出,而不仅仅是最终层。中间激活是任何卷积或池化层的输出。它们是激活图,因为激活函数应用后,亮度较高的点映射到图像的特征。在这种情况下,模型在所有卷积层上使用了 ReLU,这就是激活点的原理。我们只对卷积层的中间激活感兴趣,因为池化层只是这些层的下采样版本。为什么不去看更高分辨率的版本呢?

随着滤波器宽度和高度的减小,学习到的表示将会更大。换句话说,第一个卷积层可能关于细节,如纹理,下一个关于边缘,最后一个关于形状。然后我们必须将卷积层的输出展平,以便将其输入到从那时起接管的多层感知器。

我们现在要做的是提取一些卷积层的激活。在 B4 中,有 158 个,所以我们不能全部做!为此,我们将使用model.children()获取第一层的层,并遍历它们。我们将从这个顶层将两个Conv2dStaticSamePadding层添加到conv_layers列表中。但我们会更深入,将ModuleList层中的前六个MBConvBlock层的第一个卷积层也添加进去。最后,我们应该有八个卷积层——中间的六个属于 Mobile Inverted Bottleneck Convolution 块:

conv_layers = []
model_children = list(garbage_mdl.model.children())
for model_child in model_children:
    if (type(model_child) ==\
                efficientnet_pytorch.utils.Conv2dStaticSamePadding):
        conv_layers.append(model_child)
    elif (type(model_child) == torch.nn.modules.container.ModuleList):
        module_children = list(model_child.children())
        module_convs = []
        for module_child in module_children:
            module_convs.append(list(module_child.children())[0])
        conv_layers.extend(module_convs[:6])
print(conv_layers) 

在我们遍历所有它们,为每个卷积层生成激活图之前,让我们为单个滤波器和层做一下:

idx = battery_TP_idxs[0]
tensor = test_data[idx][0][None, :].to(device)
label = y_test[idx]
method = attr.LayerActivation(garbage_mdl, conv_layers[layer]
attribution = method.attribute(tensor).detach().cpu().numpy()
print(attribution.shape) 
tensor for the first battery true positive (battery_TP_idxs[0]). Then, it initializes the LayerActivation attribution method with the model (garbage_mdl) and the first convolutional layer (conv_layers[0]). Using the attribute function, it creates an attribution with this method. For the shape of the attribution, we should get (1, 48, 112, 112). The tensor was for a single image, so it makes sense that the first number is a one. The next number corresponds to the number of filters, followed by the width and height dimensions of each filter. Regardless of the kind of attribution, the numbers inside each attribution relate to how a pixel in the input is seen by the model. Interpretation varies according to the method. However, generally, it is interpreted that higher numbers mean more of an impact on the outcome, but attributions may also have negative numbers, which mean the opposite.

让我们可视化第一个滤波器,但在我们这样做之前,我们必须决定使用什么颜色图。颜色图将决定将不同数字分配给哪些颜色作为渐变。例如,以下颜色图将白色分配给0(十六进制中的#ffffff),中等灰色分配给0.25,黑色(十六进制中的#000000)分配给1,这些颜色之间有一个渐变:

cbinary_cmap = LinearSegmentedColormap.from_list('custom binary',
                                                 [(0, '#ffffff'),
                                                  (0.25, '#777777'),
                                                  (1, '#000000')]) 

你也可以使用matplotlib.org/stable/tutorials/colors/colormaps.html上的任何命名颜色图,而不是使用你自己的。接下来,让我们像这样绘制第一个滤波器的属性图:

filter = 0
filter_attr = attribution[0,filter]
filter_attr = mldatasets.apply_cmap(filter_attr, cbinary_cmap, 'positive')
y_true = labels_l[label]
y_pred = y_test_pred[idx]
fig, ax = plt.subplots(1, 1, figsize=(8, 6))
fig.suptitle(f"Actual label: {y_true}, Predicted: {y_pred}", fontsize=16)
ax.set_title(
    f"({method.get_name()} Attribution for Filter #{filter+1} for\
        Convolutional Layer #{layer+1})",
    fontsize=12
)
ax.imshow(filter_attr)
ax.grid(False)
fig.colorbar(
    ScalarMappable(norm='linear', cmap=cbinary_cmap),
    ax=ax,
    orientation="vertical"
)
plt.show() 
Figure 7.11:

图描述自动生成,置信度低

图 7.11:第一个真实正样本电池样本的第一个卷积层的第一个滤波器的中间激活图

如你在图 7.11中可以看到,第一个滤波器的中间激活似乎在寻找电池的边缘和最突出的文本。

接下来,我们将遍历所有计算层和每个电池,并可视化每个的归因。现在,一些这些归因操作可能计算成本很高,因此在这些操作之间清除 GPU 缓存(clear_gpu_cache())是很重要的:

for l, layer in enumerate(conv_layers):
    layer = conv_layers[l]
    method = attr.LayerActivation(garbage_mdl, layer)
    for idx in battery_TP_idxs:
        orig_img = mldatasets.tensor_to_img(test_400_data[idx][0],\
                                            norm_std, norm_mean,\
                                            to_numpy=True)
        tensor = test_data[idx][0][None, :].to(device)
        label = int(y_test[idx])
        attribution = method.attribute(tensor).detach().cpu().numpy()
        viz_img =  mldatasets.**create_attribution_grid**(attribution,\
                            cmap='copper', cmap_norm='positive')
        y_true = labels_l[label]
        y_pred = y_test_pred[idx]
        probs_s = probs_df.loc[idx]
        name = method.get_name()
        title = f'CNN Layer #{l+1} {name} Attributions for Sample #{idx}'
        mldatasets.**compare_img_pred_viz**(orig_img, viz_img, y_true,\
                                        y_pred, probs_s, title=title)
    clear_gpu_cache() 
look fairly familiar. Where it’s different is that it’s placing every attribution map for every filter in a grid (viz_img) with create_attribition_grid. It could just then display it with plt.imshow as before, but instead, we will leverage a utility function called compare_img_pred_viz to visualize the attribution(s) side by side with the original image (orig_img). It also takes the sample’s actual label (y_true) and predicted label (y_pred). Optionally, we can provide a pandas series with the probabilities for this prediction (probs_s) and a title. It generates 56 images in total, including *Figures 7.12*, *7.13*, and *7.14*.

如您从图 7.12中可以看出,第一层卷积似乎在捕捉电池的字母以及其轮廓:

图形用户界面  自动生成的描述,置信度低

图 7.12:电池#4 的第一卷积层的中间激活

然而,图 7.13显示了网络如何通过第四层卷积更好地理解电池的轮廓:

图形用户界面  自动生成的描述,置信度低

图 7.13:电池#4 的第四卷积层的中间激活

图 7.14中,最后一层卷积层难以解释,因为这里有 1,792 个 7 像素宽和高的过滤器,但请放心,那些微小的图中编码了一些非常高级的特征:

包含文本的图片  自动生成的描述

图 7.14:电池#4 的最后一层卷积层的中间激活

提取中间激活可以为你提供基于样本的某些洞察。换句话说,它是一种局部模型解释方法。这绝对不是唯一的逐层归因方法。Captum 有超过十种层归因方法:github.com/pytorch/captum#about-captum

使用基于梯度的归因方法评估误分类

基于梯度的方法通过 CNN 的前向和反向传递计算每个分类的归因图。正如其名所示,这些方法利用反向传递中的梯度来计算归因图。所有这些方法都是局部解释方法,因为它们只为每个样本推导出一个解释。顺便提一下,在这个上下文中,归因意味着我们将预测标签归因于图像的某些区域。在学术文献中,它们也常被称为敏感性图

要开始,我们首先需要创建一个数组,包含测试数据集(test_data)中所有我们的误分类样本(X_misclass),使用所有我们感兴趣的误分类的合并索引(misclass_idxs)。由于误分类并不多,我们正在加载它们的一个批次(next):

misclass_idxs = metal_FP_idxs + plastic_FN_idxs[-4:]
misclass_data = torch.utils.data.Subset(test_data, misclass_idxs)
misclass_loader = torch.utils.data.DataLoader(misclass_data,\
                                              batch_size = 32)
X_misclass, y_misclass = next(iter(misclass_loader))
X_misclass, y_misclass = X_misclass.to(device), y_misclass.to(device) 

下一步是创建一个我们可以重用的实用函数来获取任何方法的归因图。可选地,我们可以使用名为NoiseTunnel的方法(github.com/pytorch/captum#getting-started)来平滑地图。我们将在稍后更详细地介绍这种方法:

def get_attribution_maps(**method**, model, device,X,y=None,\
                         init_args={}, nt_type=None, nt_samples=10,\
                         stdevs=0.2, **kwargs):
    attr_maps_size = tuple([0] + list(X.shape[1:]))
    attr_maps = torch.empty(attr_maps_size).to(device)
    **attr_method** = **method**(model, **init_args)
    if nt_type is not None:
        noise_tunnel = attr.NoiseTunnel(attr_method)
        nt_attr_maps = torch.empty(attr_maps_size).to(device)
    for i in tqdm(range(len(X))):
        X_i = X[i].unsqueeze(0).requires_grad_()
        model.zero_grad()
        extra_args = {**kwargs}
        if y is not None:
            y_i = y[i].squeeze_()
            extra_args.update({"target":y_i})

        attr_map = **attr_method.attribute**(X_i, **extra_args)
        attr_maps = torch.cat([attr_maps, attr_map])
        if nt_type is not None:
            model.zero_grad()
            nt_attr_map = noise_tunnel.attribute(
                X_i, nt_type=nt_type, nt_samples=nt_samples,\
                stdevs=stdevs, nt_samples_batch_size=1, **extra_args)
            nt_attr_maps = torch.cat([nt_attr_maps, nt_attr_map])
        clear_gpu_cache()
    if nt_type is not None:
        return attr_maps, nt_attr_maps
    return attr_maps 

上述代码可以为给定模型和设备的任何 Captum 方法创建归因图。为此,它需要图像的张量X及其相应的标签y。标签是可选的,只有在归因方法是针对特定目标时才需要 - 大多数方法都是。大多数归因方法(attr_method)仅使用模型初始化,但一些需要一些额外的参数(init_args)。它们通常在用attribute函数生成归因时具有最多的参数,这就是为什么我们在get_attribution_maps函数中收集额外的参数(**kwargs),并将它们放在这个调用中。

需要注意的一个重要事项是,在这个函数中,我们遍历X张量中的所有样本,并为每个样本独立创建属性图。这通常是不必要的,因为属性方法都配备了同时处理一批数据的能力。然而,存在硬件无法处理整个批次的风险,在撰写本文时,非常少的方法带有internal_batch_size参数,这可能会限制一次可以处理的样本数量。我们在这里所做的是本质上等同于每次都将这个数字设置为1,以努力确保我们不会遇到内存问题。然而,如果你有强大的硬件,你可以重写函数以直接处理Xy张量。

接下来,我们将执行我们的第一个基于梯度的归因方法。

显著性图

显著性图依赖于梯度的绝对值。直觉上,它会找到图像中可以扰动最少且输出变化最大的像素。它不执行扰动,因此不验证假设,而绝对值的使用阻止它找到相反的证据。

这种首次提出的显著性图方法在当时具有开创性,并激发了许多不同的方法。它通常被昵称为“vanilla”,以区别于其他显著性图。

使用我们的get_attribution_maps函数为所有误分类的样本生成显著性图相对简单。你所需要的是 Captum 归因方法(attr.Saliency)、模型(garbage_mdl)、设备以及误分类样本的张量(X_misclassy_misclass):

saliency_maps = get_attribution_maps(attr.Saliency, garbage_mdl,\
                                     device, X_misclass, y_misclass) 

我们可以绘制其中一个显著性图的输出,第五个,与样本图像并排显示以提供上下文。Matplotlib 可以通过subplots网格轻松完成此操作。我们将创建一个 1 × 3 的网格,并将样本图像放在第一个位置,其显著性热图放在第二个位置,第三个位置是叠加在一起的。就像我们之前对归因图所做的那样,我们可以使用tensor_to_img将图像转换为numpy数组,同时应用归因的调色板。它默认使用 jet 调色板(cmap='jet')使显著的区域看起来更加突出:

pos = 4
orig_img = mldatasets.tensor_to_img(X_misclass[pos], norm_std,\
                                    norm_mean, to_numpy=True)
attr_map = mldatasets.tensor_to_img(
    saliency_maps[pos], to_numpy=True,\
    cmap_norm='positive'
)
fig, axs = plt.subplots(1, 3, figsize=(15,5))
axs[0].imshow(orig_img)
axs[0].grid(None)
axs[0].set_title("Original Image")
axs[1].imshow(attr_map)
axs[1].grid(None)
axs[1].set_title("Saliency Heatmap")
axs[2].imshow(np.mean(orig_img, axis=2), cmap="gray")
axs[2].imshow(attr_map, alpha=0.6)
axs[2].grid(None)
axs[2].set_title("Saliency Overlayed")
idx = misclass_idxs[pos]
y_true = labels_l[int(y_test[idx])]
y_pred = y_test_pred[idx]
plt.suptitle(f"Actual label: {y_true}, Predicted: {y_pred}")
plt.show() 

上述代码生成了图 7.15中的图表:

图表描述自动生成

图 7.15:将塑料误分类为生物废物的显著性图

图 7.15中的样本图像看起来像是被撕碎的塑料,但预测结果是生物废物。标准的显著性图将这个预测主要归因于塑料上较平滑、较暗的区域。看起来是缺乏镜面高光让模型产生了偏差,但通常,较旧的破损塑料会失去光泽。

镜面高光是在物体表面反射光线时出现的明亮光点。它们通常是光源的直接反射,并且在光滑或光亮的表面上更为明显,例如金属、玻璃或水。

引导 Grad-CAM

要讨论引导 Grad-CAM,我们首先应该讨论CAM,它代表类别激活图。CAM 的工作方式是移除除了最后一层全连接层之外的所有层,并用全局平均池化(GAP)层替换最后一个最大池化层。GAP 层计算每个特征图的平均值,将其减少到每个图的单个值,而最大池化层通过从图的一个局部区域中的值集中选择最大值来减小特征图的大小。例如,在这个案例中:

  1. 最后一个卷积层输出一个1792 × 7 × 7的张量。

  2. GAP 通过仅平均这个张量的最后两个维度来减少维度,产生一个1792 × 1 × 1的张量。

  3. 然后,它将这个结果输入到一个有 12 个神经元的全连接层中,每个神经元对应一个类别。

  4. 一旦重新训练了一个 CAM 模型并通过样本图像通过 CAM 模型,它将从最后一层(一个1792 × 12的张量)中提取与预测类别相对应的值(一个1792 × 1的张量)。

  5. 然后,你计算最后一个卷积层输出(1792 × 7 × 7)与权重张量(1792 x 1)的点积。

  6. 这个加权的总和将结束于一个1 × 7 × 7的张量。

  7. 通过双线性插值将其拉伸到1 × 224 × 224,这变成了一个上采样后的激活图。当你上采样数据时,你增加了其维度。

CAM 背后的直觉是,CNN 在卷积层中本质上保留了空间细节,但遗憾的是,这些细节在全连接层中丢失了。实际上,最后一个卷积层中的每个滤波器代表不同空间位置上的视觉模式。一旦加权,它们就代表了整个图像中最显著的区域。然而,要应用 CAM,你必须彻底修改模型并重新训练它,而且有些模型并不容易适应这种修改。

如其名称所示,Grad-CAM 是一个类似的概念,但避免了修改和重新训练的麻烦,并使用梯度代替——具体来说,是关于卷积层激活图的类别分数(在 softmax 之前)的梯度。对这些梯度执行 GAP 操作以获得神经元重要性权重。然后,我们使用这些权重计算激活图的加权线性组合,随后是 ReLU。ReLU 非常重要,因为它确保定位只对结果产生正面影响的特征。像 CAM 一样,它通过双线性插值上采样以匹配图像的尺寸。

Grad-CAM 也有一些缺点,例如无法识别多个发生或由预测类别表示的物体的全部。像 CAM 一样,激活图的分辨率可能受到最终卷积层维度的限制,因此需要上采样。

因此,我们使用引导 Grad-CAM。引导 Grad-CAM 是 Grad-CAM 和引导反向传播的结合。引导反向传播是另一种可视化方法,它计算目标类别相对于输入图像的梯度,但它修改了反向传播过程,只传播正激活的正梯度。这导致了一个更高分辨率、更详细的可视化。这是通过将 Grad-CAM 热图(上采样到输入图像分辨率)与引导反向传播结果进行逐元素乘法来实现的。输出是一个可视化,强调给定类别在图像中最相关的特征,比单独的 Grad-CAM 具有更高的空间细节。

使用我们的get_attribution_maps函数为所有误分类样本生成 Grad-CAM 归因图。你需要的是 Captum 归因方法(attr.GuidedGradCam)、模型(garbage_mdl)、设备以及误分类样本的张量(X_misclassy_misclass),并在方法初始化参数中,一个用于计算 Grad-CAM 归因的层:

gradcam_maps = get_attribution_maps(
    attr.GuidedGradCam, garbage_mdl, device, X_misclass,\
    y_misclass, init_args={'layer':conv_layers[3]}
) 

注意,我们并没有使用最后一层(可以用7-1索引)而是第四层(3)。这样做只是为了保持事情有趣,但我们也可以更改它。接下来,让我们像之前一样绘制归因图。代码几乎相同,只是将saliency_maps替换为gradcam_maps。输出结果如图7.16所示。

图表描述自动生成

图 7.16:将塑料误分类为生物废物的引导 Grad-CAM 热图

如你在图 7.16中观察到的,与显著性归因图一样,类似的平滑哑光区域被突出显示,除了引导 Grad-CAM 产生一些亮区和边缘。

对所有这些内容都要持保留态度。在 CNN 解释领域,仍然存在许多持续的争论。研究人员仍在提出新的和更好的方法,甚至对于大多数用例几乎完美的技术仍然存在缺陷。关于类似 CAM 的方法,有许多新的方法,例如Score-CAMAblation-CAMEigen-CAM,它们提供了类似的功能,但不需要依赖梯度,而梯度可能是不稳定的,因此有时是不可靠的。我们在这里不会讨论它们,因为当然,它们不是基于梯度的!但是,尝试不同的方法以查看哪些适用于您的用例是有益的。

集成梯度

集成梯度IG),也称为路径积分梯度,是一种不限于 CNN 的技术。您可以将它应用于任何神经网络架构,因为它计算了输出相对于输入的梯度,这些梯度是在从基线到实际输入之间的路径上平均计算的。它对卷积层的存在不敏感。然而,它需要定义一个基线,这个基线应该传达信号缺失的概念,比如一个均匀着色的图像。在实践中,特别是对于 CNN 来说,这表示零基线,对于每个像素来说,通常意味着一个完全黑色的图像。尽管名称暗示了使用路径积分,但积分并不是计算的,而是用足够小的区间内的求和来近似,对于一定数量的步骤。对于 CNN 来说,这意味着它使输入图像的变体逐渐变暗或变亮,直到它成为对应于预定义步骤数的基线。然后它将这些变体输入 CNN,为每个变体计算梯度,并取平均值。IG 是图像与梯度平均值之间的点积。

与 Shapley 值一样,IG 建立在坚实的数学理论基础上。在这种情况下,它是线积分的基本定理。IG 方法的数学证明确保了所有特征的归因之和等于模型在输入数据上的预测与在基线输入上的预测之间的差异。除了他们称之为完备性的这种属性之外,还有线性保持、对称保持和敏感性。我们在这里不会描述这些属性中的每一个。然而,重要的是要注意,一些解释方法满足显著的数学属性,而其他方法则从实际应用中证明了它们的有效性。

除了 IG 之外,我们还将利用NoiseTunnel对样本图像进行小的随机扰动——换句话说,就是添加噪声。它多次创建相同样本图像的不同噪声版本,然后计算每个版本的归因方法。然后它对这些归因进行平均,这可能是使归因图更加平滑的原因,这就是为什么这种方法被称为SmoothGrad

但等等,你可能要问:那它不应该是一种基于扰动的算法吗?!在这本书中,我们之前已经处理了几种基于扰动的算法,从 SHAP 到锚点,它们共有的特点是它们扰动输入以测量对输出的影响。SmoothGrad 并不测量对输出的影响。它只帮助生成一个更鲁棒的归因图,因为扰动输入的平均归因应该会生成更可靠的归因图。我们进行交叉验证来评估机器学习模型也是出于同样的原因:在不同分布的测试数据集上执行的平均指标会生成更好的指标。

对于 IG,我们将使用与 Saliency 相同的非常相似的代码,除了我们将添加几个与NoiseTunnel相关的参数,例如噪声隧道的类型(nt_type='smoothgrad')、用于生成的样本变化(nt_samples=20)以及添加到每个样本中的随机噪声的量(以标准差计stdevs=0.2)。我们会发现,生成的置换样本越多,效果越好,但达到一定程度后,效果就不会有太大变化。然而,噪声过多也是一种情况,如果你使用得太少,就不会有任何效果:

ig_maps, smooth_ig_maps = get_attribution_maps(
    attr.IntegratedGradients, garbage_mdl, device, X_misclass,\
    y_misclass, nt_type='smoothgrad', nt_samples=20, stdevs=0.2
) 

我们还可以选择性地定义 IG 的步数(n_steps)。默认设置为50,我们还可以修改基线,默认情况下是一个全零的张量。正如我们使用 Grad-CAM 所做的那样,我们可以将第一个样本图像与 IG 图并排显示,但这次,我们将修改代码以在第三个位置绘制 SmoothGrad 集成梯度(smooth_ig_maps),如下所示:

nt_attr_map = mldatasets.tensor_to_img(
    smooth_ig_maps[pos], to_numpy=True, cmap_norm='positive'
)
axs[2].imshow(nt_attr_map)
axs[2].grid(None)
axs[2].set_title("SmoothGrad Integrated Gradients") 
Figure 7.17:

图表描述自动生成

图 7.17:将塑料误分类为生物垃圾的集成梯度热图

在图 7.17 的 IG 热图中的区域与显著性图和引导 Grad-CAM 图检测到的许多区域相吻合。然而,在明亮的黄色区域以及棕色的阴影区域中,有更多的强归因簇,这与某些食物被丢弃时的外观(如香蕉皮和腐烂的叶状蔬菜)一致。另一方面,明亮的橙色和绿色区域则不是这样。

至于 SmoothGrad IG 热图,与不平滑的 IG 热图相比,这张图非常不同。这并不总是如此;通常,它只是更平滑的版本。可能发生的情况是0.2噪声对归因的影响过大,或者 20 个扰动样本不够。然而,很难说,因为也有可能 SmoothGrad 更准确地描绘了真实的故事。

我们现在不会做这件事,但你可以直观地“调整”stdevsnt_samples参数。你可以尝试使用更少的噪声和更多的样本,使用一系列组合,例如0.180,以及0.1540,试图找出它们之间是否存在共性。你所选择的那个最能清楚地描绘出这个一致的故事。SmoothGrad 的一个缺点是必须定义最优参数。顺便提一下,IG 在定义基线和步数(n_steps)方面也存在相同的问题。默认的基线在输入图像太大或太小时将不起作用,因此必须更改,IG 论文的作者建议 20-300 步将使积分在 5%以内。

奖励方法:DeepLIFT

IG 有一些批评者,他们已经创建了避免使用梯度的类似方法,例如DeepLIFT。IG 对零值梯度和梯度的不连续性可能很敏感,这可能导致误导性的归因。但这些指向的是所有基于梯度的方法共有的缺点。因此,我们引入了深度学习重要特征算法(DeepLIFT)。它既不是基于梯度的,也不是基于扰动的。它是一种基于反向传播的方法!

在本节中,我们将将其与 IG 进行对比。像 IG 和 Shapley 值一样,DeepLIFT 是为了完整性而设计的,因此符合显著的数学性质。除此之外,像 IG 一样,DeepLIFT 也可以应用于各种深度学习架构,包括 CNN 和循环神经网络RNN),使其适用于不同的用例。

DeepLIFT 通过使用“参考差异”的概念,将模型的输出预测分解为每个输入特征的贡献。它通过网络层反向传播这些贡献,为每个输入特征分配一个重要性分数。

更具体地说,像 IG 一样,它使用一个基线,该基线代表关于任何类别的信息。然而,它随后计算输入和基线之间每个神经元的激活差异,并通过网络反向传播这些差异,计算每个神经元对输出预测的贡献。然后我们为每个输入特征求和其贡献,以获得其重要性分数(归因)。

它相对于 IG 的优势如下:

  • 基于参考的:与 IG 等基于梯度的方法不同,DeepLIFT 明确地将输入与参考输入进行比较,这使得归因更加可解释和有意义。

  • 非线性交互:DeepLIFT 在计算归因时考虑了神经元之间的非线性交互。它通过考虑神经网络每一层的乘数(由于输入的变化而导致的输出的变化)来捕捉这些交互。

  • 稳定性:DeepLIFT 比基于梯度的方法更稳定,因为它对输入的小变化不太敏感,提供了更一致的归因。因此,在 DeepLIFT 归因上使用 SmoothGrad 是不必要的,尽管对于基于梯度的方法来说强烈推荐。

总体而言,DeepLIFT 提供了一种更可解释、更稳定和更全面的归因方法,使其成为理解和解释深度学习模型的有价值工具。

接下来,我们将以类似的方式创建 DeepLIFT 归因图:

deeplift_maps = get_attribution_maps(attr.DeepLift, garbage_mdl,\
                                     device, X_misclass, y_misclass) 

要绘制一个归因图,使用的代码几乎与 Grad-CAM 相同,只是将gradcam_maps替换为deeplift_maps。输出在图 7.18中展示。

图表描述自动生成

图 7.18:将塑料误分类为生物废物的 DeepLIFT 热图

图 7.18的归因不如 IG 那样嘈杂。但它们似乎也聚集在阴影中的一些单调的黄色和深色区域;它还指向右上角附近的一些单调的绿色。

将所有这些结合起来

现在,我们将运用我们所学到的关于基于梯度的归因方法的一切知识,来理解所有选择的错误分类(塑料的假阴性金属的假阳性)的原因。正如我们处理中间激活图一样,我们可以利用compare_img_pred_viz函数将高分辨率的样本图像与四个归因图并排显示:显著性、Grad-CAM、SmoothGrad IG 和 DeepLift。为此,我们首先必须迭代所有错误分类的位置和索引,并提取所有图。请注意,我们正在使用tensor_to_img函数中的overlay_bg来生成一个新的图像,每个图像都叠加了原始图像和热图。最后,我们将四个归因输出连接成一个单独的图像(viz_img)。正如我们之前所做的那样,我们提取实际的标签(y_true)、预测标签(y_pred)和带有概率的pandas系列(probs_s),以便为我们将生成的图表添加一些上下文。for循环将生成六个图表,但为了简洁起见,我们只将讨论其中的三个:

for pos, idx in enumerate(misclass_idxs):
    orig_img = mldatasets.tensor_to_img(test_400_data[idx][0],\
                                   norm_std, norm_mean, to_numpy=True)
    bg_img = mldatasets.tensor_to_img(test_data[idx][0],\
                                   norm_std, norm_mean, to_numpy=True)
    map1 = mldatasets.tensor_to_img(
        saliency_maps[pos], to_numpy=True,\
        cmap_norm='positive', overlay_bg=bg_img
    )
    map2 = mldatasets.tensor_to_img(
        smooth_ig_maps[pos],to_numpy=True,\
        cmap_norm='positive', overlay_bg=bg_img
    )
    map3 = mldatasets.tensor_to_img(
        gradcam_maps[pos], to_numpy=True,\
        cmap_norm='positive', overlay_bg=bg_img
    )
    map4 = mldatasets.tensor_to_img(
        deeplift_maps[pos], to_numpy=True,\
        cmap_norm='positive', overlay_bg=bg_img
    )
    viz_img = cv2.vconcat([
        cv2.hconcat([map1, map2]),
        cv2.hconcat([map3, map4])
    ])
    label = int(y_test[idx])
    y_true = labels_l[label]
    y_pred = y_test_pred[idx]
    probs_s = probs_df.loc[idx]
    title = 'Gradient-Based Attr for Misclassification Sample #{}'.\
                                                           format(idx)
    mldatasets.compare_img_pred_viz(orig_img, viz_img, y_true,\
                                    y_pred, probs_s, title=title) 

之前的代码生成了图 7.19图 7.21。重要的是要注意,在所有生成的图表中,我们都可以观察到左上角的显著性归因、右上角的 SmoothGrad IG、左下角的引导 Grad-CAM 和右下角的 DeepLIFT:

图形用户界面  自动生成描述

图 7.19:金属误分类为电池的基于梯度的归因 #8

图 7.19中,所有四种归因方法之间缺乏一致性。显著性归因图显示,所有电池的中心部分都被视为金属表面,除了纸箱的白色部分。另一方面,SmoothGrad IG 主要聚焦于白色纸箱,而 Grad-CAM 几乎完全聚焦于蓝色纸箱。最后,DeepLIFT 的归因非常稀疏,仅指向白色纸箱的一些部分。

图 7.20中,归因比图 7.19中的一致性要好得多。哑光白色区域明显让模型感到困惑。考虑到训练数据中的塑料主要是空塑料容器的单个部件——包括白色牛奶壶——这是有道理的。然而,人们确实回收玩具、塑料工具如勺子和其他塑料物品。有趣的是,尽管所有归因方法都在白色和浅黄色表面上都很显著,SmoothGrad IG 还突出了某些边缘,如一只鸭子的帽子和另一只的领子:

图形用户界面,应用  自动生成描述

图 7.20:塑料误分类的基于梯度的归因 #86

继续探讨回收玩具的主题,乐高积木是如何被错误分类为电池的?参见图 7.21以获取解释:

图表  自动生成描述

图 7.21:塑料误分类的基于梯度的归因 #89

图 7.21展示了在所有归因方法中,主要是黄色和绿色的积木(以及较少的浅蓝色)是误分类的罪魁祸首,因为这些颜色在电池制造商中很受欢迎,正如训练数据所证明的那样。此外, studs 之间的平面表面获得了最多的归因,因为这些表面与电池的接触相似,尤其是 9 伏方形电池。与其他示例一样,显著性是最嘈杂的方法。然而,这次,引导 Grad-CAM 是最不嘈杂的。它也比其他方法在边缘上的显著性更强,而不是在表面上。

我们接下来将尝试通过在真实正例上执行的基于扰动的归因方法,来发现模型关于电池(除了白色玻璃之外)学到了什么。

通过扰动归因方法理解分类

到目前为止,这本书已经对基于扰动的方 法进行了大量的介绍。因此,我们介绍的大多数方法,包括 SHAP、LIME、锚点,甚至排列特征重要性,都采用了基于扰动的策略。这些策略背后的直觉是,如果你从你的输入数据中删除、更改或屏蔽特征,然后使用它们进行预测,你将能够将新预测与原始预测之间的差异归因于你在输入中做出的更改。这些策略可以在全局和局部解释方法中加以利用。

我们现在将像对错误分类样本所做的那样做,但针对选定的真阳性,并在单个张量(X_correctcls)中收集每个类别的四个样本:

correctcls_idxs = wglass_TP_idxs[:4] + battery_TP_idxs[:4] 
correctcls_data = torch.utils.data.Subset(test_data, correctcls_idxs)
correctcls_loader = torch.utils.data.DataLoader(correctcls_data,\
                                                batch_size = 32)
X_correctcls, y_correctcls = next(iter(correctcls_loader))
X_correctcls, y_correctcls = X_correctcls.to(device),\
                             y_correctcls.to(device) 

在图像上执行排列方法的一个更复杂方面是,不仅有几十个特征,而是有成千上万个特征需要排列。想象一下:224 x 224 等于 50,176 像素,如果我们想测量每个像素独立变化对结果的影响,我们至少需要为每个像素制作 20 个排列样本。所以,超过一百万!因此,几个排列方法接受掩码来确定一次要排列哪些像素块。如果我们将它们分成 32 x 32 像素的块,这意味着我们总共只有 49 个块需要排列。然而,尽管这会加快归因方法的速度,但如果我们块越大,就会错过对较小像素集的影响。

我们可以使用许多方法来创建掩码,例如使用分割算法根据表面和边缘将图像分割成直观的块。分割是按图像进行的,因此段的数量和位置将在图像之间变化。scikit-learn 的图像分割库(skimage.segmentation)有许多方法:scikit-image.org/docs/stable/api/skimage.segmentation.html。然而,我们将保持简单,并使用以下代码为所有 224 x 224 图像创建一个掩码:

feature_mask = torch.zeros(3, 224, 224).int().to(device)
counter = 0
strides = 16
for row in range(0, 224, strides):
    for col in range(0, 224, strides):
        feature_mask[:, row:row+strides, col:col+strides] = counter
        counter += 1 

前面的代码所做的初始化一个与模型输入大小相同的零张量。将这个张量概念化为一个空图像会更简单。然后它沿着 16 像素宽和高的步长移动,从图像的左上角到右下角。在移动过程中,它使用counter设置连续数字的值。最终你得到一个所有值都填充了 0 到 195 之间数字的张量,如果你将其可视化为一幅图像,它将是一个从左上角的黑色到右下角浅灰色的对角渐变。重要的是要注意,具有相同值的每个块都被归因方法视为相同的像素。

在我们继续前进之前,让我们讨论一下基线。在 Captum 归因方法中,正如其他库的情况一样,默认基线是一个全零张量,当图像由介于 0 和 1 之间的浮点数组成时,这通常等同于一个黑色图像。然而,在我们的情况下,我们正在标准化我们的输入张量,这样模型就不会看到最小值为 0 但平均值为 0 的张量!因此,对于我们的垃圾模型,全零张量对应于中等灰色图像,而不是黑色图像。对于基于梯度的方法,灰色图像基线本身并没有固有的错误,因为很可能存在许多步骤介于它和输入图像之间。然而,基于扰动的方 法可能对基线过于接近输入图像特别敏感,因为如果你用基线替换输入图像的部分,模型将无法区分出来!

对于我们的垃圾模型的情况,一个黑色图像由张量-2.1179组成,因为我们对输入张量执行标准化操作之一是(x-0.485)/0.229,当x=0时,这恰好等于大约-2.1179。你还可以计算当x=1时的张量;它转换为白色图像的2.64。话虽如此,假设在我们的真实阳性样本中,至少有一个像素具有最低值,另一个具有最高值,是没有害处的,因此我们将只使用max()min()来创建亮暗基线:

baseline_light = float(X_correctcls.max().detach().cpu())
baseline_dark = float(X_correctcls.min().detach().cpu()) 

我们将只对除了一个扰动方法之外的所有方法使用一个基线,但请随意切换它们。现在,让我们继续为每种方法创建归因图!

特征消除

特征消除是一种相对简单的方法。它所做的是通过用基线替换它来遮挡样本输入图像的一部分,默认情况下,基线为零。目标是通过对改变它的效果进行观察,了解每个输入特征(或特征组)在做出预测中的重要性。

这就是特征消除是如何工作的:

  1. 获取原始预测:首先,获取模型对原始输入的预测。这作为比较扰动输入特征效果的基准。

  2. 扰动输入特征:接下来,对于每个输入特征(或由特征掩码设置的特征组),它被替换为基线值。这创建了一个“消除”版本的输入。

  3. 获取扰动输入的预测:计算消除输入的模型预测。

  4. 计算归因:计算原始输入和消除输入之间模型预测的差异。这个差异归因于改变的特征,表明它在预测中的重要性。

特征消除是一种简单直观的方法,用于理解模型预测中输入特征的重要性。然而,它也有一些局限性。它假设特征是独立的,可能无法准确捕捉特征之间交互的影响。此外,对于具有大量输入特征或复杂输入结构的模型,它可能计算成本高昂。尽管存在这些局限性,特征消除仍然是理解和解释模型行为的一个有价值的工具。

要生成归因图,我们将使用之前使用的get_attribution_maps函数,并输入额外的feature_maskbaselines参数:

ablation_maps = get_attribution_maps(
    attr.FeatureAblation,garbage_mdl,\
    device,X_correctcls,y_correctcls,\
    feature_mask=feature_mask,\
    baselines=baseline_dark
) 

要绘制归因图的示例,你可以复制我们用于显著性的相同代码,只是将saliency_maps替换为ablation_maps,并且我们使用occlusion_maps数组中的第二个图像,如下所示:

pos = 2
orig_img = mldatasets.tensor_to_img(X_correctcls[pos], norm_std,\
                                    norm_mean, to_numpy=True)
attr_map = mldatasets.tensor_to_img(occlusion_maps[pos],to_numpy=True,\
                         cmap_norm='positive') 
Figure 7.22:

图形用户界面 描述自动生成,置信度中等

图 7.22:测试数据集中白色玻璃真阳性的特征消除图

图 7.22中,酒杯底部的特征组似乎是最重要的,因为它们的缺失对结果的影响最大,但酒杯的其他部分也有一定程度的显著性,除了酒杯的茎。这是有道理的,因为没有茎的酒杯仍然是一个类似玻璃的容器。

接下来,我们将讨论一种类似的方法,它将能够以更详细的方式展示归因。

遮挡敏感性

遮挡敏感性与特征消除非常相似,因为它也用基线替换了图像的部分。然而,与特征消除不同,它不使用特征掩码来分组像素。相反,它使用滑动窗口和步长自动将连续特征分组,在这个过程中,它创建了多个重叠区域。当这种情况发生时,它会对输出差异进行平均,以计算每个像素的归因。

在这个场景中,除了重叠区域及其对应平均值之外,遮挡敏感性和特征消除是相同的。事实上,如果我们使用滑动窗口和 3 x 16 x 16 的步长,就不会有任何重叠区域,特征分组将与由 16 x 16 块组成的feature_mask定义的特征分组相同。

那么,你可能想知道,熟悉这两种方法有什么意义?意义在于遮挡敏感性仅在固定分组连续特征很重要时才适用,比如图像和可能的其他空间数据。由于其使用步长,它可以捕捉特征之间的局部依赖性和空间关系。然而,尽管我们使用了连续的特征块,特征消融不必如此,因为feature_mask可以以任何对输入进行分段最有意义的方式排列。这个细节使其非常适用于其他数据类型。因此,特征消融是一种更通用的方法,可以处理各种输入类型和模型架构,而遮挡敏感性则是专门针对图像数据和卷积神经网络定制的,重点关注特征之间的空间关系。

要生成遮挡的归因图,我们将像以前一样操作,并输入额外的参数baselinessliding_window_shapesstrides

occlusion_maps = get_attribution_maps(
    attr.Occlusion, garbage_mdl,\
    device,X_correctcls,y_correctcls,\
    baselines=baseline_dark,\
    sliding_window_shapes=(3,16,16),\
    strides=(3,8,8)
) 

请注意,我们通过将步长设置为仅 8 像素,而滑动窗口为 16 像素,创建了充足的重叠区域。要绘制归因图,你可以复制我们用于特征消融的相同代码,只是将ablation_maps替换为occlusion_maps。输出如图图 7.23所示:

图形用户界面 描述自动生成

图 7.23:测试数据集中白色玻璃真阳性的遮挡敏感性图

通过图 7.23,我们可以看出遮挡的归因与消融的归因惊人地相似,只是分辨率更高。考虑到前者的特征掩码与后者的滑动窗口对齐,这种相似性并不令人惊讶。

无论我们使用 16 x 16 像素的非重叠块还是 8 x 8 像素的重叠块,它们的缺失影响都是独立测量的,以创建归因。因此,消融和遮挡方法都没有装备来测量非连续特征组之间的交互。当两个非连续特征组的缺失导致分类发生变化时,这可能会成为一个问题。例如,没有把手或底座的酒杯还能被认为是酒杯吗?当然可以被认为是玻璃,人们希望如此,但也许模型学到了错误的关系。

说到关系,接下来,我们将回顾一个老朋友:Shapley!

Shapley 值采样

如果你还记得第四章全局模型无关解释方法,Shapley 提供了一种非常擅长衡量和归因特征联盟对结果影响的方法。Shapley 通过一次对整个特征联盟进行排列,而不是像前两种方法那样一次排列一个特征,来实现这一点。这样,它可以揭示多个特征或特征组如何相互作用。

创建归因图的代码现在应该非常熟悉了。这种方法使用feature_maskbaselines,但也测试了特征排列的数量(n_samples)。这个最后的属性对方法的保真度有巨大影响。然而,它可能会使计算成本变得非常昂贵,所以我们不会使用默认的每个排列 25 个样本来运行它。相反,我们将使用 5 个样本来使事情更易于管理。然而,如果你的硬件能够处理,请随意调整它:

svs_maps = get_attribution_maps(
    attr.ShapleyValueSampling,garbage_mdl,\
    device, X_correctcls, y_correctcls,\
    baselines=baseline_dark,\
    n_samples=5, feature_mask=feature_mask
) 
occlusion_maps is replaced by svs_maps. The output is shown in *Figure 7.24*:

图片

图 7.24:测试数据集中白色玻璃真阳性的 Shapley 值采样图

图 7.24显示了一些一致的归因,例如最显著的区域位于酒杯碗的左下角。此外,底部似乎比遮挡和消融方法更重要。

然而,这些归因比之前的要嘈杂得多。这部分的理由是因为我们没有使用足够数量的样本来覆盖所有特征和交互的组合,部分原因是因为交互的混乱性质。对于单个独立特征的归因集中在几个区域是有意义的,例如酒杯的碗。然而,交互可能依赖于图像的几个部分,例如酒杯的底部和边缘。它们可能只有在它们一起出现时才变得重要。更有趣的是背景的影响。例如,如果你移除背景的一部分,酒杯是否不再像酒杯?也许背景比你想象的更重要,尤其是在处理半透明材料时。

KernelSHAP

既然我们谈论到了 Shapley 值,那么让我们尝试一下来自第四章全局模型无关解释方法中的KernelSHAP。它利用 LIME 来更高效地计算 Shapley 值。Captum 的实现与 SHAP 类似,但它使用的是线性回归而不是 Lasso,并且计算核的方式也不同。此外,对于 LIME 图像解释器,最好使用有意义的特征组(称为超像素)而不是我们在特征掩码中使用的连续块。同样的建议也适用于KernelSHAP。然而,为了这个练习的简单性,我们也将保持一致性,并与其他三种基于排列的方法进行比较。

我们现在将创建归因图,但这次我们将使用浅色基线和深色基线各做一个。因为KernelSHAP是对 Shapley 采样值的近似,并且计算成本不是很高,所以我们可以将n_samples设置为 300。然而,这并不一定能保证高保真度,因为KernelSHAP需要大量的样本来近似相对较少的样本可以用 Shapley 彻底做到的事情:

kshap_light_maps = get_attribution_maps(attr.KernelShap, garbage_mdl,\
                                  device, X_correctcls, y_correctcls,\
                                  baselines=baseline_light,\
                                  n_samples=300,\
                                  feature_mask=feature_mask)
kshap_dark_maps = get_attribution_maps(attr.KernelShap, garbage_mdl,\
                                  device, X_correctcls, y_correctcls,\
                                  baselines=baseline_dark,\
                                  n_samples=300,\
                                  feature_mask=feature_mask) 
svs_maps is replaced by kshap_light_maps, and we modify the code to plot the attributions with the dark baselines in the third position, like this:
axs[2].imshow(attr_dark_map)
axs[2].grid(None)
axs[2].set_title("Kernel Shap Dark Baseline Heatmap") 
Figure 7.25:

包含图形用户界面的图片,自动生成描述

图 7.25:测试数据集中白色玻璃真正阳性的 KernelSHAP 图

图 7.25中的两个归因图在大多数情况下并不一致,更重要的是,与之前的归因不一致。有时,某些方法比其他方法更难,或者需要一些调整才能按预期工作。

将所有这些结合起来

现在,我们将利用关于基于扰动归因方法的所有知识,来理解所有选择的真正阳性分类(无论是白色玻璃还是电池)的原因。正如我们之前所做的那样,我们可以利用compare_img_pred_viz函数将高分辨率样本图像与四个归因图并排放置:特征消融、遮挡敏感性、Shapley 和KernelSHAP。首先,我们必须迭代所有分类的位置和索引,并提取所有图。请注意,我们正在使用overlay_bg来生成一个新的图像,该图像将每个归因的热图叠加到原始图像上,就像我们在基于梯度的部分所做的那样。最后,我们将四个归因输出连接成一个单独的图像(viz_img)。正如我们之前所做的那样,我们提取实际的标签(y_true)、预测标签(y_pred)和包含概率的pandas系列(probs_s),以便为我们将生成的图表添加一些上下文。for循环将生成六个图表,但我们只会讨论其中的两个:

for pos, idx in enumerate(correctcls_idxs):
    orig_img = mldatasets.tensor_to_img(test_400_data[idx][0],\
                                        norm_std, norm_mean,\
                                        to_numpy=True)
    bg_img = mldatasets.tensor_to_img(test_data[idx][0],\
                                      norm_std, norm_mean,\
                                      to_numpy=True)
    map1 = mldatasets.tensor_to_img(ablation_maps[pos],\
                                    to_numpy=True,\
                                    cmap_norm='positive',\
                                    overlay_bg=bg_img)
    map2 = mldatasets.tensor_to_img(svs_maps[pos], to_numpy=True,\
                                    cmap_norm='positive',\
                                    overlay_bg=bg_img)
    map3 = mldatasets.tensor_to_img(occlusion_maps[pos],\
                                    to_numpy=True,\
                                    cmap_norm='positive',\
                                    overlay_bg=bg_img)
    map4 = mldatasets.tensor_to_img(kshap_dark_maps[pos],\
                                    to_numpy=True,\
                                    cmap_norm='positive',\
                                    overlay_bg=bg_img)
    viz_img = cv2.vconcat([
            cv2.hconcat([map1, map2]),
            cv2.hconcat([map3, map4])
        ])
    label = int(y_test[idx])
    y_true = labels_l[label]
    y_pred = y_test_pred[idx]
    probs_s = probs_df.loc[idx]
    title = 'Pertubation-Based Attr for Correct classification #{}'.\
                format(idx)
    mldatasets.compare_img_pred_viz(orig_img, viz_img, y_true,\
                                    y_pred, probs_s, title=title) 
Figures 7.26 to *7.28*. For your reference, ablation is in the top-left corner and occlusion is at the bottom left. Then, Shapley is at the top right and KernelSHAP is at the bottom right.

总体来说,你可以看出消融和遮挡非常一致,而 Shapley 和KernelSHAP则不太一致。然而,Shapley 和KernelSHAP的共同之处在于归因更加分散。

图 7.26中,所有归因方法都突出了文本,以及至少电池的左侧接触。这与图 7.28相似,那里的文本也被大量突出显示,以及顶部接触。这表明,对于电池,模型已经学会了文本和接触都很重要。至于白色玻璃,则不太清楚。图 7.27中的所有归因方法都指向破碎花瓶的一些边缘,但并不总是相同的边缘(除了消融和遮挡,它们是一致的):

图形用户界面,应用程序,自动生成描述

图 7.26:电池分类的第 1 个基于扰动的归因

白色玻璃是三种玻璃中最难分类的,原因也不难理解:

图形用户界面  自动生成的描述

图 7.27:基于扰动的白色玻璃分类#113 的归因

图 7.27和其他测试示例中所述,模型很难区分白色玻璃和浅色背景。它设法用这些例子正确分类。然而,这并不意味着它在其他例子中也能很好地泛化,例如玻璃碎片和照明不足的情况。只要归因显示背景有显著的影响,就很难相信它仅凭镜面高光、纹理和边缘就能识别玻璃。

图形用户界面  自动生成的描述

图 7.28:基于扰动的电池分类#2 的归因

对于图 7.28,在所有归因图中背景也被显著突出。但也许这是因为基线是暗的,整个物体也是如此。如果你用黑色方块替换电池边缘外的区域,模型会感到困惑是有道理的。因此,在使用基于排列的方法时,选择一个合适的基线非常重要。

任务完成

任务是提供一个对市政回收厂垃圾分类模型的客观评估。在样本外验证图像上的预测性能非常糟糕!你本可以就此停止,但那样你就不知道如何制作一个更好的模型。

然而,预测性能评估对于推导出特定的误分类以及正确的分类,以评估使用其他解释方法至关重要。为此,你运行了一系列的解释方法,包括激活、梯度、扰动和基于反向传播的方法。所有方法的一致意见是模型存在以下问题:

  • 区分背景和物体

  • 理解不同物体共享相似的颜色色调

  • 混乱的照明条件,例如像酒杯那样的特定材料特性产生的镜面高光

  • 无法区分每个物体的独特特征,例如乐高砖块中的塑料凸起与电池接触

  • 被由多种材料组成的物体所困惑,例如塑料包装和纸盒包装中的电池

为了解决这些问题,模型需要用更多样化的数据集进行训练——希望是一个反映回收厂真实世界条件的数据集;例如,预期的背景(在输送带上)、不同的照明条件,甚至被手、手套、袋子等部分遮挡的物体。此外,他们应该为由多种材料组成的杂项物体添加一个类别。

一旦这个数据集被编译,利用数据增强使模型对各种变化(角度、亮度、对比度、饱和度和色调变化)更加鲁棒是至关重要的。他们甚至不需要从头开始重新训练模型!他们甚至可以微调 EfficientNet!

摘要

阅读本章后,你应该了解如何利用传统的解释方法来更全面地评估 CNN 分类器的预测性能,并使用基于激活的方法可视化 CNN 的学习过程。你还应该了解如何使用基于梯度和扰动的方法比较和对比误分类和真实正例。在下一章中,我们将研究 NLP 变换器的解释方法。

进一步阅读

  • Smilkov, D., Thorat, N., Kim, B., Viégas, F., and Wattenberg, M., 2017, SmoothGrad: 通过添加噪声去除噪声. ArXiv, abs/1706.03825: arxiv.org/abs/1706.03825

  • Sundararajan, M., Taly, A., and Yan, Q., 2017, 深度网络的公理化归因. 机器学习研究会议论文集,第 3319–3328 页,国际会议中心,悉尼,澳大利亚: arxiv.org/abs/1703.01365

  • Zeiler, M.D., and Fergus, R., 2014, 视觉化和理解卷积网络. 在欧洲计算机视觉会议,第 818–833 页: arxiv.org/abs/1311.2901

  • Shrikumar, A., Greenside, P., and Kundaje, A., 2017, 通过传播激活差异学习重要特征: arxiv.org/abs/1704.02685

在 Discord 上了解更多

要加入本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/inml

第八章:解释 NLP Transformer

在上一章中,我们学习了如何将解释方法应用于特定类型的深度学习模型架构,即卷积神经网络。在本章中,我们将提供一些工具来对 Transformer 模型架构执行相同的操作。Transformer 模型越来越受欢迎,它们最常见的使用案例是自然语言处理NLP)。我们在第五章局部模型无关解释方法中提到了 NLP。在本章中,我们也将这样做,但使用 Transformer 特定的方法和工具。首先,我们将讨论如何可视化注意力机制,然后是解释集成梯度属性,最后是探索瑞士军刀般的学习可解释性工具LIT)。

我们将涵盖的主要主题包括:

  • 使用 BertViz 可视化注意力

  • 使用集成梯度解释标记属性

  • LIME、反事实和其他 LIT 的可能性

技术要求

本章的示例使用了mldatasetspandasnumpytorchtransformersbertvizcaptumlit-nlp库。如何安装所有这些库的说明在序言中。

本章的代码位于此处:packt.link/Yzf2L

使命

你是一名在纽约市一家即将推出的初创公司工作的数据科学家。这家初创公司旨在成为寻找城市中最佳、最新和最激动人心的美食目的地的首选之地!

目标是超越关于餐厅的典型结构化数据,深入挖掘网络上丰富的文本数据,从社交媒体网站到目录网站。初创公司认为,虽然评分可能提供对体验的简单量化,但评论包含更丰富的细节,可以提供多维度见解,了解什么使餐厅变得特别。

评论表达详细的情感,捕捉多样化的用户体验,与提供单一、非比较视角的评分不同。通过利用评论中的粒度,初创公司可以更精确地定制其推荐,以满足各种受众群体。

你们团队一直在讨论如何利用评论中的情感分析来确定如何最好地寻找体现用户在推荐系统中寻求体验感受的情感。二元情感分析(正面/负面)无法提供区分通常和独特体验,或针对特定群体(如旅行者、家庭或情侣)所需的细微差别。此外,初创公司的创始人认为用餐体验是多方面的。一种可能被视为“正面”的体验可能从“温馨而怀旧”到“刺激而冒险”。区分这些细微差别将使推荐系统能够更加个性化且有效。

你的经理遇到了一个情感分类模型,该模型使用名为 GoEmotions 的数据集进行训练,该数据集由谷歌发布。GoEmotions 提供了一种更详细的情感分类,比二元分类模型更有效地捕捉人类情感的丰富性。然而,首席策略师决定分类太多,决定将它们分组到另一个名为埃克曼的不同情绪分类法中(参见图 8.1):

图片

图 8.1:情绪分类法

埃克曼的情绪分类法是由心理学家保罗·埃克曼开发的一种分类系统,它识别出六种基本情绪,他认为这些情绪在所有人类文化中都是普遍体验到的。这六种情绪是快乐、悲伤、恐惧、愤怒、厌恶和惊讶。埃克曼提出,这些是基本情绪,它们被硬编码在我们的大脑中,并且世界各地的人们都以相同的方式表达,无论他们的文化如何。这些情绪可以通过特定的面部表情来识别,理解它们可以帮助心理学、沟通和社会学等领域。埃克曼的分类法提供了一套更简洁的情绪类别,同时仍然保留了细微差别。这使得对情绪的解释对开发团队和其他利益相关者来说更加可管理和可操作。然而,我们不得不保持中立,这不能归类到任何埃克曼类别中。

现在,下一步是使用 Tripadvisor 评论数据集来解释 GoEmotions Ekman 分类器模型,以了解模型学到了什么,并揭示可能对推荐系统开发有用的模式。这是一个开放性的任务。一般目标是理解模型在评论中识别出的模式以及这些模式如何与埃克曼的分类相对应。然而,这条路径可能导致许多发现或陷入死胡同。领导层强调,像你这样的数据科学家必须运用他们的判断力,在数据探索和模型解释中寻找机会。

通过揭示这些模式,初创公司可以微调其算法,寻找与这些情绪产生共鸣的评论。这些见解还可以指导餐厅合作、营销策略和平台功能增强。

方法

你决定采取三管齐下的方法:

  1. 你将深入到转换器模型的内部,使用 BertViz 可视化注意力权重,以寻找这些机制中的相关模式。

  2. 然后,你将生成显著性图,其中每个感兴趣的评论中的每个标记的归因都使用集成梯度法进行着色编码。

  3. 最后,你将使用 LIT 来检验反事实情况。

你希望这些步骤能够向领导团队提供一些可操作的见解。

准备工作

你可以在这里找到这个示例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/tree/master/08/ReviewSentiment.ipynb

加载库

要运行此示例,您需要安装以下库:

  • mldatasets 用于加载数据集

  • pandasnumpy 用于操作

  • torch(PyTorch)和 transformers 用于加载和配置模型

  • bertvizcaptumlit-nlp 用于生成和可视化模型解释

您应该首先加载所有这些库:

import math
import os, random, re, gc
import warnings
warnings.filterwarnings("ignore")
import mldatasets
import numpy as np
import pandas as pd
import torch
from transformers import AutoTokenizer,\
                         AutoModelForSequenceClassification, pipeline
from bertviz import head_view, model_view
from captum.attr import LayerIntegratedGradients,\
                        TokenReferenceBase, visualization
from lit_nlp import notebook
from lit_nlp.api import dataset as lit_dataset
from lit_nlp.api import model as lit_model
from lit_nlp.api import types as lit_types 

接下来,我们进行数据处理和理解。

理解和准备数据

我们将数据这样加载到我们称为 reviews_df 的 DataFrame 中:

reviews_df = mldatasets.load("nyc-reviews", prepare=True) 

应该有超过 380,000 条记录和 12 列。我们可以使用 info() 来验证这一点:

reviews_df.info() 

输出检查无误。没有缺失值。然而,只有三个数值特征,一个日期,其余的都是对象数据类型,因为它们大多是文本。鉴于本章重点介绍 NLP,这并不令人惊讶。让我们检查数据字典,以了解我们将从该 DataFrame 中使用什么。

数据字典

这些是 DataFrame 中的 12 列,其中大部分是为了参考:

  • review_id: ID – 评论的唯一标识符(仅作参考)

  • author_id: ID – 作者的唯一标识符(仅作参考)

  • restaurant_name: 文本 – 餐厅的名称(仅作参考)

  • url_restaurant: URL统一资源标识符,用于定位包含餐厅评论的网页(仅作参考)

  • review_date: 日期 – 评论制作的日期(仅作参考)

  • review_title: 文本 – 作者为评论写的标题

  • review_preview: 文本 – 为审查生成的预览

  • review_full: 文本 – 作者撰写的完整评论

  • rating: 序数 – 作者对场所给出的评级(1–5 级别)

  • positive_sentiment: 二进制 – 根据二元情感模型,评论是否有积极情感(积极/消极)

  • label: 分类 – GoEmotions 分类器预测的情绪(根据 Ekman 七类分类:快乐、中性、悲伤、厌恶、恐惧、愤怒和惊讶)

  • score: 连续 – 预测评论属于预测类别的概率

这是一个多分类模型,因此它为每个类别预测了分数。然而,我们只存储了最可能类别的 score(标签)。因此,最后两列代表模型的输出。至于输入,让我们检查前三行来说明:

reviews_df[["review_title","review_full","label","score"]].head(3) 
Figure 8.2:

图 8.2:数据集的前三个评论

图 8.2的前两列中,review_titlereview_full代表模型的输入。它将它们作为一段单独的文本,因此当我们讨论评论时,我们指的是将这两个字符串通过冒号和空格连接起来的字符串,如下所示:令人失望:食物最多只是平庸。羊排是他们网站上首页展示的图片

但这些并不是分析中唯一可能重要的列。我们当然可以按作者、餐厅、日期等分析评论,甚至将餐厅与地图上的特定坐标连接起来,以了解情感在地理上的变化。这一切都非常有趣。然而,我们不会在这里深入探讨,因为尽管这可能对情感分析的一般任务相关,但它会偏离本章的技术主题,即解释 Transformer 模型。

尽管如此,我们将探索一些与模型结果高度相关的特征,即作者提供的评分和二元情感分析模型的正面情感结果。你肯定期望这些会匹配,因为评论通常与评分一致——也就是说,正面评论的评分会比负面评论高。同样,一些情绪比负面情绪更积极。

为了更好地理解这些相关性,让我们将评论汇总起来,为每种情绪计算平均评分正面情感,如下所示:

sum_cols_l = ["score","positive_sentiment","rating"]
summary_df = reviews_df.groupby("label")[sum_cols_l].agg(
    {"score":["count","mean"], "positive_sentiment":"mean", "rating":"mean"}
)
summary_df.columns = ["count", "avg. score", "% positive", "avg. rating"]
summary_df.sort_values(by="avg. rating", ascending=False).style.format(
    {
        "count":"{:,}",
        "avg. score":"{:.1%}",
        "% positive":"{:.1%}" ,
        "avg. rating":"{:.2f}"
    }
).bar(subset=["avg. score", "% positive", "avg. rating"],\
              color="#4EF", width=60) 

上述代码将在图 8.3中生成输出:

图 8.3:预测的评论数据集情绪的汇总表

正如你在图 8.3中可以看到的,380,000 条评论中的大多数都被放在了“喜悦”标签或类别中。喜悦是一种积极的情绪,因此我们的二元情感分类器将超过 90%的它们分类为正面,并且喜悦评论的平均评分几乎为 4.5。DataFrame 按平均评分排序,因为它不是模型(可能出错)的结果,所以它可能给我们提供最清晰的预测情绪的指示,即最终用户认为最积极的情绪。随着你向下查看列表,首先是积极的情绪,然后是中性的,最后是负面的。请注意,二元分类器认为正面评论的百分比与平均评分提供的相同顺序大致一致。

另一方面,每个标签的平均分数告诉我们平均来说预测有多自信它属于该标签。快乐、悲伤和惊讶是最自信的。由于多类预测是七个加起来等于 1 的数字,愤怒的平均分数为 56.6%表明,在愤怒是最可能情绪的许多预测中,其他情绪也可能有很大的概率——甚至可能是看似不兼容的情绪。我们将对此进行标记,因为这将会很有趣,以后可以探索这个问题。

你可以对图 8.3做出另一个有趣的解释,即尽管惊讶通常被认为是一种积极的情绪,但其中很大一部分是负面的。此外,平均评分低于 4,可能有很多负面评分拖累了它们。我们不会在本章中探索数据,但确实有很多表达惊讶情绪的负面评论。鉴于这一发现,为了适应任务,假设你向老板展示了这一点,他们决定关注惊讶是有道理的,因为市场研究显示人们喜欢发现和被“隐藏的宝藏”所惊喜。因此,一个推荐引擎能够帮助挖掘任何令人惊喜的餐厅,同时抑制那些持续令人失望的餐厅,这是至关重要的。

加载模型

之后,我们将从我们的数据集中随机选择,为了保持一致性,最好设置一个随机种子。在所有相关的库中初始化种子总是好的做法,尽管在这种情况下,它对 PyTorch 推理操作没有影响:

rand = 42
os.environ["PYTHONHASHSEED"]=str(rand)
random.seed(rand)
np.random.seed(rand)
torch.manual_seed(rand) 

接下来,让我们定义一个device变量,因为如果你有一个 CUDA 支持的 GPU,模型推理将执行得更快。然后,我们将使用from_pretrained函数从 Hugging Face 加载分词器(goemotions_tok)和模型(goemotions_mdl)。最后,我们将使用model.to(device)函数将所有权重和偏差移动到你的设备上,并使用model.eval()将模型设置为评估模式:

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
goemotions_mdl_path = "monologg/bert-base-cased-goemotions-ekman"
goemotions_tok = AutoTokenizer.from_pretrained(goemotions_mdl_path)
goemotions_mdl = AutoModelForSequenceClassification.from_pretrained(
    goemotions_mdl_path, output_attentions=True
)
goemotions_mdl.to(device)
goemotions_mdl.eval() 

模型加载后,我们总是使用print(goemotions_mdl)检查其架构。然而,在解释 Transformer 模型时,从广义上讲,最重要的是它们有多少层和注意力头。我们可以使用以下代码片段轻松检查:

num_layers = goemotions_mdl.config.num_hidden_layers
num_attention_heads = goemotions_mdl.config.num_attention_heads
print(f"The model has {num_layers} layers.")
print(f"Each layer has {num_attention_heads} attention heads.") 

应该说明的是,有 12 层和 12 个注意力头。在下一节中,我们将深入了解注意力机制的工作原理,以及如何使用 BertViz 可视化层和头:

使用 BertViz 可视化注意力

注意力是什么?让我们想象你正在阅读一本书,遇到一个提到你之前读过的角色的句子,但你忘记了关于他们的某些细节。你不太可能从头开始重新阅读,而是可能会快速浏览前面的页面,专注于具体讨论这个角色的部分。你的大脑会给予相关信息“注意力”,同时过滤掉不那么相关的部分。

变换器模型(如 BERT)中的注意力机制以类似的方式工作。在处理信息时,它不会平等地对待所有数据片段。相反,它“关注”最相关的部分,在当前任务的上下文中给予它们更多的重视。这种选择性地关注特定部分的能力有助于模型理解数据中的复杂模式和关系。

变换器由两个主要组件组成:编码器和解码器。每个组件都利用注意力机制,但它们以不同的方式使用:

  • 编码器: 编码器的任务是理解输入数据。它通过使用注意力机制来确定输入的每个部分(如句子中的一个单词)如何与其他所有部分相关联。这使得编码器能够创建一个丰富的输入表示,捕捉其中的关系和上下文。这就像阅读一个句子并理解在这个句子中每个单词的含义。

  • 解码器: 一旦编码器创建了这种表示,解码器就会使用它来生成输出。解码器还使用注意力机制,但以两种方式使用。首先,它关注编码器的表示来理解输入是什么。其次,它关注自己的先前输出,以确保当前输出与其迄今为止产生的输出一致。这就像根据你所读的和已经写下的内容来写一个有意义的句子。

然而,并非所有变换器模型都具有这两个组件。本质上,用例决定了变换器需要哪些部分:

  • 编码器模型(如 BERT):这些模型只使用变换器的编码器组件。它们通常用于涉及理解输入数据的任务,如情感分析(确定文本是积极的还是消极的)、命名实体识别(在文本中识别人、组织和地点)或其他分类任务。这是因为编码器的任务是创建一个表示输入数据的表示,捕捉其中的关系和上下文。

  • 解码器模型(如 GPT、LLaMa 等):这些用于涉及生成新数据的任务,如文本生成。变换器的解码器部分确保生成的输出与迄今为止产生的输出一致。

  • 编码器-解码器模型(如 FLAN):这些用于涉及将一块数据转换为另一块数据的任务,如翻译。编码器理解输入,解码器生成输出。

既然我们已经涵盖了注意力模型,那么让我们深入探讨 BERT。

BERT,代表来自转换器的双向编码器表示,是由 Google 开发的一种转换器模型。它用于理解和分析各种语言的文本数据。BERT 是一种读取文本双向以更好地理解单词上下文的转换器模型。它只使用转换器的编码部分,因为它的任务是理解文本,而不是生成文本。这使得 BERT 对于涉及理解文本的广泛任务非常有效。

因此,我们的 BERT 转换器模型有 12 层和 12 个注意力头。但这些是什么,它们是如何工作的呢?

  • BERT 层:12 个隐藏层是 BERT 层,这些层是构成这个编码器转换器的其他层的堆叠。与我们在第七章中检查的 CNN 中的卷积层类似,可视化卷积神经网络,BERT 层代表抽象层。随着输入数据通过层传递,模型学习数据的越来越抽象的表示。在文本的上下文中,底层可能捕获基本句法信息,如单词在句子中的作用。随着你向上移动到层,它们倾向于捕获更高级别的语义,如整体句子意义或主题。层的数量,称为模型的深度,通常与其理解上下文和表示复杂关系的能力相关。然而,更多的层也需要更多的计算资源,并且如果没有足够的数据,可能会更容易过拟合。

  • 注意力头:自注意力机制是任何转换器模型的核心。注意力头包含几个并行工作的自注意力机制。在多头自注意力机制内部,有多个独立的注意力头并行工作。每个注意力头都学会关注输入数据的不同部分(如不同标记之间的关系,这些通常是指单词)。拥有多个注意力头允许模型同时捕捉各种类型的关系。例如,一个头可能关注形容词和名词之间的关系,而另一个头可能捕捉动词-主语关系。在每个头计算其自己的注意力加权值表示之后,所有头的输出被连接起来并线性转换,以产生下一层的最终值表示。

让我们使用一些真实的评论来检查 GoEmotions 模型的内部工作原理。为此,我们将取四个样本评论,并使用以下代码打印它们的详细信息。在此过程中,我们将样本评论保存到字典(sample_reviews_dict)中,以便以后参考:

surprise_sample_reviews_l = [174067, 284154, 480395, 47659]
line_pattern = r"(?<=[.!?])\s+"
sample_reviews_dict = {}
for i, review_idx in enumerate(surprise_sample_reviews_l):
    review_s = reviews_df.loc[review_idx, :]
    sentiment = "Positive" if review_s["positive_sentiment"]\
                            else "Negative"
    review_lines_l = re.split(
        line_pattern, review_s["review_full"], maxsplit=1
    )
    review_txt = "\r\n\t\t".join(review_lines_l)
    print(f"{review_s["restaurant_name"]}") 
    print(f"\tSentiment:\t\t{sentiment}")
    print(f"\tRating:\t\t\t{review_s["rating"]}")
    print(f"\tGoEmotions Label:\t{review_s["label"]}")
    print(f"\tGoEmotions Score:\t{review_s["score"]:.1%}")
    print(f"\tTitle:\t{review_s["review_title"]}")
    print(f"\tReview:\t {review_txt}")
    sample_reviews_dict[i] = review_lines_l 
Figure 8.4:

图片

图 8.4:数据集中的一些样本惊喜评论

正如你在图 8.4中看到的,所有评论样本的共同点是惊喜,无论是积极的还是消极的。

接下来,我们将利用 BertViz,尽管名字叫这个名字,但它可以可视化仅编码器 transformer 模型(如 BERT 及其所有变体)、仅解码器 transformer(如 GPT 及其所有变体)以及编码器-解码器 transformer(如 T5)的注意力。它非常灵活,但重要的是要注意,它是一个交互式工具,所以本节中用图表示的打印屏幕并不能完全体现其功能。

接下来,我们将创建一个函数,该函数使用分词器、模型和句子元组,可以创建两种不同的 BertViz 可视化:

def view_attention(tokenizer, model, sentences, view="model"):
    sentence_a, sentence_b = sentences
    # Encode sentences with tokenizer
    inputs = tokenizer.encode_plus(
        sentence_a, sentence_b, return_tensors="pt"
    )
    # Extract components from inputs
    input_ids = inputs["input_ids"]
    token_type_ids = inputs["token_type_ids"]
    # Get attention weights from model given the inputs
    attention = model(input_ids, token_type_ids=token_type_ids)[-1]
    # Get 2nd sentence start and tokens
    sentence_b_start = token_type_ids[0].tolist().index(1)
    input_id_list = input_ids[0].tolist()
    tokens = tokenizer.convert_ids_to_tokens(input_id_list)
    # BertViz visualizers
    if view=="head":
        head_view(attention, tokens, sentence_b_start)
    elif view=="model":
        model_view(attention, tokens, sentence_b_start) 

为了可视化注意力,我们需要取一对输入句子并使用我们的分词器(inputs)对它们进行编码。然后,我们提取这些输入的标记 ID(input_ids)和值,这些值表示每个标记属于哪个句子(token_type_ids)——换句话说,0代表第一句话,1代表第二句话。然后,我们将输入(input_idstoken_type_ids)传递给模型并提取attention权重。最后,有两个 BertViz 可视化器,head_viewmodel_view,为了使它们工作,我们只需要我们的输入产生的attention权重,将标记 ID 转换为tokens,以及第二句话开始的positionsentence_b_start)。

接下来,我们将使用model view在整个模型中可视化注意力。

使用模型视图绘制所有注意力

 sentences in the 1st sample review:
view_attention(
    goemotions_tok, goemotions_mdl, sample_reviews_dict[0], view="model"
) 

上述代码创建了一个大图,就像图 8.5中描绘的那样:

图片

图 8.5:2nd Avenue Deli 的第一个样本评论的模型视图

图 8.5是一个 12 x 12 的网格,其中包含了 BERT 模型中的每个注意力头。我们可以点击任何注意力头来查看 BERT 输入中的两个句子,它们之间有线条相连,代表从左边的标记(一个句子)到右边的标记(另一个句子)的注意力权重。我们可以选择“Sentence A -> Sentence A”、“Sentence A -> Sentence B”以及所有介于两者之间的组合,以查看所有注意力权重的一个子集。权重接近一的线条看起来非常不透明,而接近零的权重则显示为透明,以至于几乎看不见。

一眼就能看出,有些注意力头有更多的线条、更粗的线条,或者看起来比另一个方向更明显的线条。我们可以点击单个注意力头来单独检查它们——例如:

  • 层 1 头部 11 主要关注,从同一句子中的一个标记移动到下一个标记。这是一个非常常见的模式,并且完全符合逻辑,因为我们从左到右阅读英语,并且主要按照这个顺序理解它,尽管当然还有其他无疑在注意力头部中的模式。我们还看到了另一个常见模式的证据,即一个或多个标记对 [CLS] 标记具有注意力权重。[CLS] 标记是一个特殊的标记,在为分类任务使用类似 BERT 的模型时,会添加到每个输入序列的开头。它通常用于获取整个序列的聚合表示,以便进行分类。这意味着对于这个特定的注意力头部,标记在分类决策中起着作用。当注意力从分隔符标记 [SEP] 转移到分类标记 [CLS] 时,这可以被视为模型识别上下文或句子的结束,并反映其语义结论,从而可能影响分类决策。

  • 层 9 头部 似乎执行一个更复杂的任务,即关联单词与“伟大”和“惊讶”,甚至跨越句子。这是另一个常见模式,其中连接的单词预测一个单词。

注意注意力头部中的模式,并注意一些其他模式,比如句子中的注意力向后移动,或者连接同义词。然后,将 sample_reviews_dict[0] 中的零改为一、二或三,看看注意力头部是否显示相同的模式。样本相当不同,但如果注意力头部没有做相同的事情,那么它们可能正在做非常相似的事情。然而,对于更大的图景,最好眯起眼睛,看看不同层中明显的模式。

接下来,我们将通过头部视图使这个任务变得更简单。

通过头部视图深入层注意力

我们可以通过以下代码开始选择第一个样本:

view_attention(
    goemotions_tok, goemotions_mdl, sample_reviews_dict[0], view="head"
) 

我们可以选择 12 层中的任何一层(0-11)。线条透明度意味着与模型视图相同的意思。然而,区别在于我们可以通过点击它们来隔离单个标记的注意力权重,因此当我们选择左侧的任何标记时,我们会看到线条与右侧的标记相连。此外,右侧将出现彩色编码的方框,表示每个 12 个注意力头部中每个的注意力权重。

如果我们恰好选择右侧的任何标记,我们将获得来自左侧标记的所有指向它的注意力。在 图 8.6 中,我们可以看到几个样本句子如何出现:

图片

图 8.6:样本 1、2 和 4 的头部视图

图 8.6中,看看我们如何像处理模型视图一样深入挖掘模式。例如,我们可以检查不同的注意力模式,从相邻的相关的单词(“伟大的三明治”和“尽管相当昂贵”)到仅在句子上下文中存在关系的那些(“三明治”和“昂贵”),甚至跨句子(“坏”和“被忽视的”,“厨师”和“菜单”)。

通过这样做,我们可以意识到可视化注意力头不仅仅是出于好奇,还可以帮助我们理解模型如何将单词之间的点连接起来,从而完成下游任务,如分类。也许这可以帮助我们了解下一步该做什么,无论是进一步微调模型以包含代表性不足的单词和情况,还是以不同的方式准备数据,以防止模型被特定的单词或一组单词所困惑。然而,考虑到注意力头的复杂性,寻找模型问题就像在 haystack 中找针一样。我们之所以在本章中从层和注意力头开始,是因为它提供了一种直观的方式来理解变压器如何编码标记之间的关系。

一个更好的开始方式是使用归因。归因方法是一种将计算输入的一部分对模型预测的贡献程度的方法。在第七章中,关于可视化卷积神经网络的图像中,我们计算归因的输入部分是像素。对于文本,等效的部分是标记,在这种情况下,由(主要是)单词组成,所以接下来,我们将生成标记归因。

使用集成梯度解释标记归因

集成梯度是一种流行的方法,在第七章中,我们解释并利用它为图像中的每个像素生成归因。该方法具有相同的步骤:

  1. 选择一个基线输入:基线代表没有信息。对于图像,通常是一个纯黑色图像。对于文本,这可能是一个所有单词都被占位符如[PAD]替换的句子,或者只是一个空句子。

  2. 逐步将这个基线输入变为实际的输入句子(例如,评论),一步一步地。在每一步中,你将基线稍微改变一点,使其接近实际输入。

  3. 计算输出变化:对于每一步,计算模型预测的变化量。

  4. 总结句子中每个单词的所有变化。这将为每个单词提供一个分数,表示它对模型最终预测的贡献程度。

然而,在我们能够使用集成梯度之前,最好定义一个将输入分词并在一步中执行模型推理的变压器管道:

goemotions = pipeline(
    model=goemotions_mdl,
    tokenizer=goemotions_tok,
    task="text-classification",
    function_to_apply="softmax",
    device=device,
    top_k=None
) 

你可以这样测试goemotions管道:

goemotions(["this restaurant was unexpectedly disgusting!",
            "this restaurant was shockingly amazing!"]) 

应该输出以下列表的列表字典:

[[{"label": "disgust", "score": 0.961812436580658},
  {"label": "surprise", "score": 0.022211072966456413},
  {"label": "sadness", "score": 0.004870257806032896},
  {"label": "anger", "score": 0.0034139526542276144},
  {"label": "joy", "score": 0.003016095608472824},
  {"label": "fear", "score": 0.0027414397336542606},
  {"label": "neutral", "score": 0.0019347501220181584}],
 [{"label": "joy", "score": 0.6631762385368347},
  {"label": "surprise", "score": 0.3326122760772705},
  {"label": "neutral", "score": 0.001732577453367412},
  {"label": "anger", "score": 0.0011324150254949927},
  {"label": "sadness", "score": 0.0010195496724918485},
  {"label": "fear", "score": 0.00021178492170292884},
  {"label": "disgust", "score": 0.00011514205834828317}]] 

如您所见,在第一个列表中,有两个预测(每个文本一个),每个预测都有一个包含七个字典的列表,其中一个字典包含每个类别的分数。由于字典是按最高分数到最低分数排序的,您可以判断第一个餐厅评论主要被预测为厌恶,第二个评论为喜悦,占 66%,但也有相当数量的惊讶。

接下来,我们将创建一个函数,该函数可以接受任何包含我们的评论和我们的转换器的 DataFrame 行,并为每个超过 10%概率的预测生成和输出归因:

def visualize_ig_review(interpret_s:pd.Series,
                          pline:pipeline,
                          max_prob_thresh:float=0.1,
                          max_classes=np.PINF,
                          concat_title=True,
                          summary_df=None
) -> pd.DataFrame:
    print(f"{interpret_s.name}: {interpret_s['restaurant_name']}")
    # Init some variables
    if concat_title:
        text = interpret_s["review_title"] + ":" + interpret_s["review_full"]
    else:
        text = interpret_s["review_full"]
    true_label = "Positive" if interpret_s["positive_sentiment"]\
                            else "Negative"
    rating = interpret_s["rating"]
    # Get predictions
    prediction = pline(text)[0]
    prediction_df = pd.DataFrame(prediction)
    if summary_df is not None:
        prediction_df["label_avg_rating"] = prediction_df.label.\
            replace(summary_df["avg. rating"].to_dict())
        prediction_df = prediction_df.sort_values("label_avg_rating",\
           ascending=False).reset_index(drop=True)
    # Process predictions
    prediction_tuples = [(p["label"], p["score"]) for p in prediction]
    sorted_prediction_tuples = sorted(prediction_tuples,\
        key=lambda x: x[1], reverse=True)
    pred_class, pred_prob = sorted_prediction_tuples[0]
    # Initialize Integrated Gradients
    forward_func = lambda inputs, position=0: pline.model(
        inputs, attention_mask=torch.ones_like(inputs)
    )[position]
    layer = getattr(pline.model, "bert").embeddings
    lig = LayerIntegratedGradients(forward_func, layer)
    # Prepare tokens and baseline
    device = torch.device("cuda:0" if torch.cuda.is_available()\
                            else "cpu")
    inputs = torch.tensor(pline.tokenizer.encode(text,\
        add_special_tokens=False), device = device).unsqueeze(0)
    tokens = pline.tokenizer.convert_ids_to_tokens(
        inputs.detach().numpy()[0]
    )
    sequence_len = inputs.shape[1]
    baseline = torch.tensor(
        [pline.tokenizer.cls_token_id]\
        + [pline.tokenizer.pad_token_id] * (sequence_len - 2)\
        + [pline.tokenizer.sep_token_id],\
        device=device
    ).unsqueeze(0)
    # Iterate over every prediction
    vis_record_l = []
    for i, (attr_class, attr_score) in\ 
        enumerate(sorted_prediction_tuples):
        if (attr_score > max_prob_thresh) and (i < max_classes):
            # Sets the target class
            target = pline.model.config.label2id[attr_class]
            # Get attributions
            with torch.no_grad():
                attributes, delta = lig.attribute(
                    inputs=inputs,
                    baselines=baseline,
                    target=target,
                    return_convergence_delta = True
                )
            # Post-processing attributions
            attr = attributes.sum(dim=2).squeeze(0)
            attr = attr / torch.norm(attr)
            attr = attr.cpu().detach().numpy()
            # Generate & Append Visualization Data Record
            vis_record = visualization.VisualizationDataRecord(
                    word_attributions=attr,
                    pred_prob=pred_prob,
                    pred_class=pred_class,
                    true_class=f"{true_label} ({rating})",
                    attr_class=attr_class,
                    attr_score=attr_score,
                    raw_input_ids=tokens,
                    convergence_score=delta
            )
            vis_record_l.append(vis_record)
    # Display list of visualization data records
    _ = visualization.visualize_text(vis_record_l)
    return prediction_df 

虽然代码量看起来很复杂,但单独解释时,有很多步骤相对简单。我们将从模型推理开始,逐步进行:

  1. 获取预测结果:这是一个非常直接的步骤。它只需将text输入到管道(pline)中。它只取返回的第一个项目([0]),因为它只预期输入并由此通过管道返回一个预测。接下来的几行显示了如果函数接收sample_df,模型会做什么,它实际上只需要按平均最佳评分的顺序对预测进行排序。

  2. 处理预测:在这里,代码确保预测被排序并放入元组中,以便在后续的for循环中更容易迭代。

  3. 初始化集成梯度:定义了一个正向函数,它接受输入并返回给定位置的模型输出,以及一个用于计算归因的层,在这个例子中是嵌入层。然后,使用正向函数和指定的层初始化一个LayerIntegratedGradientslig)实例。

  4. 准备标记和基线:首先,对文本进行标记并转换为张量,然后将其移动到指定的设备。然后,将标记 ID 转换回tokens,以供潜在的视觉化或分析使用。为集成梯度方法创建一个baseline。它由[CLS][ token at the start, ]{custom-style="P - Code"}[SEP][ token at the end, and ]{custom-style="P - Code"}[PAD][ tokens in the middle matching the length of the input ]{custom-style="P - Code"}text组成。

  5. 遍历每个预测:这是一个for循环,它遍历每个预测,只要概率超过由max_prob_threshold定义的 10%,我们将在循环中进行以下操作:

    1. 设置目标类别:集成梯度是一种有向归因方法,因此我们需要知道为哪个target类别生成归因;因此,我们需要模型内部用于预测类别的 ID。

    2. 获取归因:使用与第七章中使用的相同的 Captum attribute方法,我们为我们的文本(inputs)、基线、目标和决定是否返回 IG 方法的增量(一个近似误差的度量)生成 IG 归因。

    3. 后处理归因:IG 方法返回的归因形状为 (num_inputs, sequence_length, embedding_dim),其中对于此模型,embedding_dim=768sequence_length对应输入中的标记数量,而num_inputs=1,因为我们一次只执行一个归因。因此,每个标记的嵌入都有一个归因分数,但我们需要的每个标记一个归因。因此,这些分数在嵌入维度上求和,以得到序列中每个标记的单个归因值。然后,对归因进行归一化,确保归因的幅度在 0 到 1 之间,并且处于可比较的尺度。最后,将归因从计算图中分离出来,移动到 CPU,并转换为numpy数组以进行进一步处理或可视化。

    4. 生成并附加可视化数据记录:Captum 有一个名为VisualizationDataRecord的方法,用于创建每个归因的记录以供可视化,因此在这一步中,我们使用这些归因、增量、标记和与预测相关的元数据创建这些记录。然后,将此数据记录附加到列表中。

  6. 显示可视化数据记录列表:利用visualize_text显示记录列表。

现在,让我们创建一些样本以执行集成梯度归因:

neg_surprise_df = reviews_df[
    (reviews_df["label"]=="surprise")
    & (reviews_df["score"]>0.9)
    & (reviews_df["positive_sentiment"]==0)
    & (reviews_df["rating"]<3)
] #43
neg_surprise_samp_df = neg_surprise_df.sample(
    n=10, random_state=rand
) 

在上述代码片段中,我们选取所有概率超过 90%的惊喜评论,但为了确保它们是负面的,我们将选择一个负面情感和低于三的评分。然后,我们将从这些评论中随机抽取 10 条。

接下来,我们将遍历列表中的每个评论并生成一些可视化。其他一些可视化显示在图 8.7的屏幕截图上:

for i in range(10):
    sample_to_interpret = neg_surprise_samp_df.iloc[i]
    _ = visualize_ig_review(
        sample_to_interpret, goemotions, concat_title=True, summary_df=summary_df
) 
Figure 8.7:

图 8.7:IG 可视化对于负面惊喜

图 8.7所示,惊喜预测归因于“震惊”、“意识到”和“神秘”等单词,以及“不确定如何”等短语。这些都很有意义,因为它们表明某件事是未知的。自然地,也有一些情况,单词“surprise”或“surprised”就足以得到惊喜预测。然而,有时事情并不那么简单。在最后一个例子中,并不是一个单词似乎表明了惊喜,而是许多单词,大意是某件事不协调。更具体地说,这些来自伦敦的游客对纽约市的一家熟食店如此昂贵感到非常惊讶。请注意,“负面”和“正面”的颜色编码并不意味着一个单词是负面的或正面的,而是它对(负面)或有利于(正面)归因标签的权重。

接下来,我们将重复运行与生成样本惊喜负面评论的 IG 解释相似的代码,但这次是为了正面评论。为了确保它们是正面的,我们将使用 4 分以上的评分。这次,我们将确保从样本中移除包含“惊喜”一词的任何评论,以使事情更有趣:

pos_surprise_df = reviews_df[
    (reviews_df["label"]=="surprise")
    & (reviews_df["score"]>0.97)
    & (reviews_df["positive_sentiment"]==1)
    & (reviews_df["rating"]>4)
]
pos_surprise_samp_df = pos_surprise_df[
    ~pos_surprise_df["review_full"].str.contains("surprise")
]
for i in range(10):
    sample_to_interpret = pos_surprise_samp_df.iloc[i]
    _ = visualize_ig_review(
        sample_to_interpret, goemotions,\
        concat_title=False, summary_df=summary_df
) 

上一段代码将在图 8.8中生成可视化。

图 8.8

图 8.8:积极惊喜的 IG 可视化

图 8.8展示了“困惑”和“难以置信”等单词,以及“难以置信的是”这样的短语如何表明惊喜。还有一些情况,标记对惊喜预测有负面影响。例如,对于最后一家餐厅,“适合每个人”并不使其非常令人惊讶。此外,您会注意到中间的日本餐厅被预测为同时体现惊喜和快乐情绪。有趣的是,有些单词与一种情绪相关,但与另一种情绪不太相关,有时它们甚至表示相反的意思,比如“很难找到地方”中的“hard”表示惊喜但不表示快乐。

找到像日本餐厅这样的混合情感评论可能有助于解释为什么有些评论难以完全用单一情感进行分类。因此,现在我们将生成一些正负混合评论样本。我们可以通过确保预测标签的分数永远不会超过 50%来轻松做到这一点:

pos_mixed_samp_df = reviews_df[
    (~reviews_df["label"].isin(["neutral","joy"]))
    & (reviews_df["score"] < 0.5)
    & (reviews_df["positive_sentiment"]==1)
    & (reviews_df["rating"]< 5)
].sample(n=10, random_state=rand)
neg_mixed_samp_df = reviews_df[
    (~reviews_df["label"].isin(["neutral","joy"]))
    & (reviews_df["score"] < 0.5)
    & (reviews_df["positive_sentiment"]==0)
    & (reviews_df["rating"]>2)
].sample(n=10, random_state=rand) 
mldatasets.plot_polar, which plots a polar line chart for the predictions with plotly. You’ll need both plotly and kaleido to make this work:
for i in range(10):
    sample_to_interpret = pos_mixed_samp_df.iloc[i]
    prediction_df = visualize_ig_review(
        sample_to_interpret,\
        goemotions, concat_title=False,\
        summary_df=summary_df
    )
    rest_name = sample_to_interpret["restaurant_name"]
    mldatasets.plot_polar(
    prediction_df, "score", "label", name=rest_name
) 

上一段代码将在图 8.9中生成 IG 可视化和极线图:

图 8.9

图 8.9:混合情感评论的 IG 可视化

图 8.9展示了这家三明治店的评论似乎表现出快乐、恐惧和中性情绪。单词“terrific”和“friendly”与快乐相连,但与中性不相干。然而,奇怪的是,“terrific”这个词也与恐惧相关。也许这与对这个词进行的 WordPiece 分词有关。注意,“terrific”以三个子词标记出现,分别是 te、##rri 和##fic。这很可能是因为用于训练模型的原语料库(Reddit 评论)中“terrific”这个词的频率不够高,无法将其作为独立单词包含,但这些子词却包含了。这种技术的缺点是,可能“te”和“rri”标记经常用于像“terrifying”这样的单词,以及像“horrific”和“mortific”这样的其他可怕单词中的“fic”。另一方面,“fic”出现在“magnificent”和“beneficial”中。所以,尽管有上下文嵌入,子词标记可能会引起一些歧义。

我们现在可以运行与之前相同的代码,但针对neg_mixed_samp_df,以检查其他示例并得出自己的结论。接下来,我们可以使用 LIT 扩展我们的 XAI NLP 特定工具集。

LIT、反事实以及其他可能性

LIT 是一个由People+AI ResearchPAIR)倡议开发的开源平台,用于可视化和理解 NLP 模型。PAIR 开发了第六章中特色展示的“如果工具”(What-If Tool)。

LIT 提供了一个交互式和可视化的界面,以便深入探究 NLP 模型的行为。使用 LIT,用户可以:

  • 识别模型表现不佳的示例类型。

  • 确定特定模型预测背后的原因。

  • 测试模型在文本变化(如风格、动词时态或代词性别)下的一致性。

LIT 提供了各种内置功能,包括显著性图、注意力可视化、指标计算和反事实生成。然而,它也支持定制,允许添加专门的解释性技术、可视化等。

尽管 LIT 的主要关注点是文本语言数据,但它也支持在图像和表格数据上操作的模式。它与包括 TensorFlow 和 PyTorch 在内的多种机器学习框架兼容。该工具可以作为独立服务器运行,也可以在 Colab、Jupyter 和 Google Cloud Vertex AI 笔记本等笔记本环境中运行。

为了与任何自定义数据集一起工作,LIT 提供了一个Dataset子类来创建一个兼容 LIT 的数据集加载器。您必须包含一个__init__,它加载数据集,以及一个 spec 函数,它指定数据集中返回的数据类型,而lit_nlp.api.types提供了一种确保 LIT 识别您数据集中的每个特征的方法。在这种情况下,我们提供了评论(TextSegment),标签(CategoryLabel)以及七个标签,以及两个额外的类别,这些类别可用于切片和分箱:

class GEDataset(lit_dataset.Dataset):
    GE_LABELS = ["anger", "disgust", "fear", "joy",\
                 "neutral", "sadness", "surprise"]
    def __init__(self, df: pd.DataFrame):
        self._examples = [{
            "review": row["review_title"] + ":" + row["review_full"],
            "label": row["label"],
            "rating": row["rating"],
            "positive": row["positive_sentiment"]
        } for _, row in df.iterrows()]
    def spec(self):
        return {
            "review": lit_types.TextSegment(),
            "label": lit_types.CategoryLabel(vocab=self.GE_LABELS),
            "rating": lit_types.CategoryLabel(),
            "positive": lit_types.CategoryLabel()
        } 

为了使 LIT 能够适应任何模型,有一个Model子类来创建一个 LIT 兼容的模型加载器。它还需要__init__函数来初始化模型,以及一个predict_minibatch函数来使用它进行预测。为此,我们还需要为predict函数的输入(input_spec)和输出(output_spec)创建规范。在这种情况下,我们输入一个评论(类型为TextSegment),并返回类型为MulticlassPreds的概率。请注意,模型的输出并不总是一致的,因为每个预测都是从最高分到最低分排列的。注意,为了使predict_minibatch的输出符合MulticlassPreds,我们必须将概率作为与标签(GE_LABELS)相对应的列表排列,与提供给vocab的顺序相同:

class GEModel(lit_model.Model):
    GE_LABELS = ["anger", "disgust", "fear", "joy",\
                 "neutral", "sadness", "surprise"]
    def __init__(self, model, tokenizer, **kw):
        self._model = pipeline(
            model=model,
            tokenizer=tokenizer,
            task="text-classification",
            function_to_apply="softmax",
            device=device,
            top_k=None
        )
    def input_spec(self):
        return {
            "review": lit_types.TextSegment()
        }
    def output_spec(self):
        return {
            "probas": lit_types.MulticlassPreds(vocab=self.GE_LABELS,\
                                                parent="label")
        }
    def predict_minibatch(self, inputs):
        examples = [d["review"] for d in inputs]
        with torch.no_grad():
            preds = self._model(examples)
        preds = [{p["label"]:p["score"] for p in pred_dicts}\
                for pred_dicts in preds]
        preds = [dict(sorted(pred_dict.items())) for pred_dict in preds]
        preds = [{"probas": list(pred_dict.values())} for pred_dict in preds]
        return preds 

好的,现在我们拥有了 LIT 运行所需的两个类。GoEmotions 模型初始化器(GEModel)接收模型(goemotions_mdl)和分词器(goemotions_tok)。我们将这些放入字典中,因为 LIT 可以接受多个模型和多个数据集进行比较。对于数据集,为了使其快速加载,我们将使用 100 个样本(samples100_df),由我们迄今为止创建的四个 10 样本 DataFrame 组成,再加上从整个评论数据集中随机抽取的 60 个额外样本。然后,我们将我们的 100 样本 DataFrame 输入到 GoEmotions 数据集初始化器(GEDataset)中,并将其放置到我们的数据集字典中,命名为NYCRestaurants。最后,我们通过输入我们的模型和数据集字典以及render它来创建小部件(notebook.LitWidget)。请注意,如果您想在笔记本环境之外运行此代码,可以使用Server命令使其在 LIT 服务器上运行:

models = {"GoEmotion":GEModel(goemotions_mdl, goemotions_tok)}
samples100_df = pd.concat(
    [
        neg_surprise_samp_df,
        pos_surprise_samp_df,
        neg_mixed_samp_df,
        pos_mixed_samp_df,
        reviews_df.sample(n=60, random_state=rand)
    ]
)
datasets = {"NYCRestaurants":GEDataset(samples100_df)}
widget = notebook.LitWidget(models, datasets)
widget.render(height=600)
# litserver = lit_nlp.dev_server.Server(models, datasets, port=4321)
# litserver.serve() 
Figure 8.10:

图片

图 8.10:打开预测标签的笔记本视图

正如您在图 8.10中可以看到的,LIT 具有:

  • 一个顶部栏,有一个下拉菜单用于选择模型和数据集(但在这里您不能选择,因为每种只有一个)和三种不同的视图(简单默认笔记本)。笔记本是默认选项。

  • 一个选择栏,用于选择数据点和查看哪些被固定。

  • 一个标签栏,有三个标签(预测解释分析)。默认情况下,预测被选中,此标签页左侧有数据表,您可以在其中选择和固定单个数据点,右侧有分类结果面板。

尽管笔记本视图比简单视图有更多功能,但它缺少默认视图中许多可用的功能。从现在开始,我们将检查图 8.11中描述的默认视图:

图片

图 8.11:打开预测标签的默认视图

如你在图 8.11中看到的,默认视图有两个永久窗格,顶窗格中有数据表数据点编辑器,底窗格中有标签页。这不是一个小笔记本单元格的好布局,但它可以让你轻松固定、选择和编辑数据点,同时在下面的标签页上执行任务。注意,标签页不止三个。我们将简要解释每个标签页:

  • 预测: 这让你可以看到所选和固定的数据点的分类结果。注意,它用“P”表示预测标签,用“T”表示真实标签。然而,由于我们没有用这个数据集训练模型,提供的标签与预测的标签没有区别,但这可以证明在检查错误分类时非常有用。在分类结果的右侧,我们有标量,这允许我们比较固定和所选数据点的分数与数据集中所有其他数据点的分数。

  • 解释: 在这里,我们可以对我们的数据点使用多种解释/归因方法,例如 LIME 和集成梯度。

  • 显著性聚类: 我们对许多数据点进行归因,并将结果聚类以了解标记是如何聚类的。鉴于我们只使用 100 个数据集,我们不会在这里详细介绍。

  • 指标: 如果我们使用带有真实标签的训练数据集,这个标签页将非常有用,因为它可以以多种方式切片和分组性能指标。

  • 反事实: 与第六章类似,这里的反事实概念是相同的,即找出你可以改变的特征(在这种情况下是一个标记),这样就可以修改模型结果(预测标签)。这里提供了几种反事实发现方法。

因此,我们将按顺序处理这个列表,排除显著性聚类预测(我们已经在图 8.11中解释过),所以接下来,我们将查看解释,如图8.12所示:

图 8.12:在“解释”标签页中,比较了固定和选择的评论的 LIME 解释

图 8.12展示了 LIME 解释在固定数据和选定点之间的差异。LIME 之前在第五章局部模型无关解释方法中,以及在 NLP 的背景下都有涉及。这里也是一样。顺便提一下,尽管至少有四种方法可用,包括积分梯度,但只有 LIME 能与这个模型一起工作。这是因为 LIME 是一种模型无关的基于排列的方法,它不需要访问模型的所有内在参数,而其他方法不是模型无关的。如果你还记得,我们的GEModel没有暴露任何内在参数。如果我们想在 LIT 中使用基于梯度的方法如 IG,我们就需要不使用管道,然后以这种方式指定输入和输出,即暴露标记嵌入。LIT 网站上有一些示例可以帮助你完成这项工作。

接下来,我们将查看指标选项卡,如图 8.13 所示:

图 8.13:指标选项卡中的混淆矩阵

图 8.13中的指标面板中,通常会有关于整个数据集、你所做的选择以及你可能定义的任何附加维度的信息性指标。然而,如果你展开选项卡,你总是会看到这个数据集的 100%准确率,因为没有真实标签。也许右侧的混淆矩阵面板在这种情况下更有信息性,因为我们可以看到标签和评分,或标签和正面的交叉表,因为我们定义了评分和正为CategoryLabel。请注意,从技术上讲,这并不是一个混淆矩阵,因为它没有将预测情感标签与相应的真实标签进行比较,但你可以看到预测标签和评分之间有多少一致性。

最后,让我们检查反事实选项卡,如图 8.14 所示:

图 8.14:在反事实选项卡中生成消融翻转反事实

图 8.14中的反事实选项卡提供了几种方法来改变输入,从而修改预测标签。由于一些反事实方法是模型无关的,而其他方法需要内在参数,因此并非所有方法都适用于GEModel。在这里,我们使用模型无关的方法消融翻转,从输入中移除标记。消融翻转简单地尝试从输入中删除标记,以找出哪个标记改变了预测。正如你所看到的,第一个移除的是“Not”从“review”中,第二个移除的是“impressed”。

通过反事实,你可以测试在 te ##rri ##fic(如图 8.9 所示)中添加和删除子词标记是否会在许多不同的上下文中引起歧义。例如,你可以从被认为具有中性或负面情绪的评论中逐个移除 te ##rri ##fic 中的一个标记,看看预测是否向积极方向改变。你也可以用“magnificent”这样的同义词替换所有三个标记。

任务完成

从摘要表(图 8.3)中很明显,并且通过集成梯度和一定程度上的注意力可视化练习得到证实,许多 Ekman 情绪在评论中难以辨别,恐惧、厌恶、愤怒和悲伤产生了许多混合情绪的评论。而这些难以辨别的都是负面情绪。

此外,在 GoEmotions 和 Ekman 分类法中,许多情绪在推荐引擎的上下文中并不重要,因此考虑合并一些负面情绪是有意义的。鉴于惊喜类别有时是积极的,有时是消极的,因此将它们拆分以包括好奇心和困惑是有道理的。

另一个重要的发现是,鉴于你发现的许多一致模式,惊喜并不难分类,但却是预测的关键情绪。然而,有好的惊喜和坏的惊喜。并且,在适当的训练数据下,模型很可能以高精度区分两者。我们可以确保标记化永远不会将传达情感的单词分开。

摘要

阅读本章后,你应该了解如何利用 BertViz 可视化变压器模型、层和注意力头,以及如何使用 Captum 的归因方法,特别是集成梯度,以及可视化数据记录来查看哪些标记负责预测的标签。最后,你应该对如何开始使用 LIT 有一个扎实的掌握。在下一章中,我们将探讨解释多元时间序列模型。

进一步阅读

  • Vig, J., 2019, Transformer 模型中的注意力多尺度可视化。ArXiv:arxiv.org/abs/1906.05714

  • Kokhlikyan, N., Miglani, V., Martin, M., Wang, E., Alsallakh, B., Reynolds, J., Melnikov, A., Kliushkina, N., Araya, C., Yan, S., & Reblitz-Richardson, O., 2020, Captum:PyTorch 的一个统一和通用的模型可解释性库。ArXiv:arxiv.org/abs/2009.07896

  • Tenney, I., Wexler, J., Bastings, J., Bolukbasi, T., Coenen, A., Gehrmann, S., Jiang, E., Pushkarna, M., Radebaugh, C., Reif, E., & Yuan, A., 2020, *《语言可解释性工具:NLP 模型的可扩展、交互式可视化和分析工具》。实证自然语言处理方法会议:arxiv.org/abs/2008.05122

在 Discord 上了解更多信息

要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

第九章:多元预测和敏感性分析的解释方法

在整本书中,我们学习了我们可以用来解释监督学习模型的各种方法。它们在评估模型的同时,也能揭示其最有影响力的预测因子及其隐藏的相互作用。但是,正如“监督学习”这个术语所暗示的,这些方法只能利用已知的样本以及基于这些已知样本分布的排列。然而,当这些样本代表过去时,事情可能会变得复杂!正如诺贝尔物理学奖获得者尼尔斯·玻尔著名地打趣说,“预测是非常困难的,尤其是如果它关乎未来。”

事实上,当你看到时间序列中的数据点波动时,它们可能看起来像是在按照可预测的模式有节奏地跳舞——至少在最佳情况下是这样。就像一个舞者随着节奏移动,每一次重复的动作(或频率)都可以归因于季节性模式,而音量(或振幅)的逐渐变化则可以归因于同样可预测的趋势。这种舞蹈不可避免地具有误导性,因为总会有一些缺失的拼图碎片稍微改变数据点,比如供应商供应链中的延迟导致今天销售数据的意外下降。更糟糕的是,还有不可预见的、十年一遇、一代人一遇或甚至一次性的灾难性事件,这些事件可以彻底改变人们对时间序列运动的一些理解,就像一个舞厅舞者在抽搐。例如,在 2020 年,由于 COVID-19,无论好坏,各地的销售预测都变得毫无用处!

我们可以将这种情况称为极端异常事件,但我们必须认识到,模型并不是为了预测这些重大事件而构建的,因为它们几乎完全基于可能发生的情况进行训练。未能预测这些不太可能但后果严重的意外事件,这就是我们一开始就不应该过度依赖预测模型的原因,尤其是在没有讨论确定性或置信区间的情况下。

本章将探讨一个使用长短期记忆LSTM)模型的多元预测问题。我们将首先使用传统的解释方法评估模型,然后使用我们在第七章“可视化卷积神经网络”中学习的集成梯度方法来生成我们模型的局部属性。

但更重要的是,我们将更好地理解 LSTM 的学习过程和局限性。然后,我们将采用预测近似方法以及 SHAP 的KernelExplainer进行全局和局部解释。最后,预测和不确定性是内在相关的敏感性分析是一系列旨在衡量模型输出不确定性相对于其输入的方法,因此在预测场景中非常有用。我们还将研究两种这样的方法:Morris用于因素优先级排序Sobol用于因素固定,这涉及到成本敏感性。

下面是我们将要讨论的主要主题:

  • 使用传统解释方法评估时间序列模型

  • 使用积分梯度生成 LSTM 属性

  • 使用 SHAP 的KernelExplainer计算全局和局部属性

  • 使用因素优先级识别有影响力的特征

  • 使用因素固定量化不确定性和成本敏感性

让我们开始吧!

技术要求

本章的示例使用了mldatasetspandasnumpysklearntensorflowmatplotlibseabornalibidistythonshapSALib库。如何安装所有这些库的说明可以在本书的序言中找到。

本章的代码位于此处:packt.link/b6118

任务

高速公路交通拥堵是一个影响世界各地的城市的问题。随着发展中国家每千人车辆数量的稳步增加,而道路和停车基础设施不足以跟上这一增长,拥堵水平已经达到了令人担忧的程度。在美国,每千人车辆统计数字是世界上最高的之一(2019 年为每千人 838 辆)。因此,美国城市占全球 381 个城市中至少有 15%拥堵水平的 62 个城市。

明尼阿波利斯就是这样一座城市(参见图 9.1),那里的阈值最近已经超过并持续上升。为了将这个大都市地区置于适当的背景中,拥堵水平在 50%以上时极为严重,但中等程度的拥堵(15-25%)已经是未来可能出现严重拥堵的预警信号。一旦拥堵达到 25%,就很难逆转,因为任何基础设施的改善都将非常昂贵,而且还会进一步扰乱交通。最严重的拥堵点之一是在明尼阿波利斯和圣保罗这对双城之间的 94 号州际公路(I-94),当通勤者试图缩短旅行时间时,这会拥堵替代路线。了解这一点后,这两座城市的市长们已经获得了一些联邦资金来扩建这条公路:

图形用户界面,应用程序描述自动生成

图 9.1:TomTom 的 2019 年明尼阿波利斯交通指数

市长们希望能够吹嘘一个完成的扩建项目作为共同成就,以便在第二任期内再次当选。然而,他们很清楚,一个嘈杂、脏乱和阻碍交通的扩建项目可能会给通勤者带来很大的麻烦,因此,如果扩建项目不是几乎看不见,它可能会在政治上适得其反。因此,他们规定建筑公司尽可能在其他地方预制,并在低流量时段进行组装。这些时段的每小时交通量少于 1,500 辆车。他们一次只能在一个方向的高速公路上工作,并且在他们工作时只能阻挡不超过一半的车道。为了确保遵守这些规定,如果他们在任何交通量超过这个阈值时阻挡超过四分之三的高速公路,他们将对公司处以每辆车 15 美元的罚款。

除了这些,如果施工队伍在每小时交通量超过 1,500 辆车时在现场阻挡一半的高速公路,他们每天将花费 5,000 美元。为了更直观地了解这一点,在典型的交通高峰时段阻挡可能会使建筑公司每小时损失 67,000 美元,再加上每天 5,000 美元的费用!当地当局将使用沿路线的自动交通记录器ATR)站点来监控交通流量,以及当地交通警察来记录施工时车道被阻挡的情况。

该项目已被规划为一个为期 2 年的建设项目;第一年将在 I-94 路线的西行车道进行扩建,而第二年将扩建东行车道。施工现场的建设仅从 5 月到 10 月进行,因为在这几个月里下雪不太可能延误施工。在整个余下的年份,他们将专注于预制。他们将尝试只在工作日工作,因为工人联盟为周末谈判了慷慨的加班费。因此,只有在有重大延误的情况下,周末才会进行施工。然而,工会同意在 5 月至 10 月期间以相同的费率在节假日工作。

建筑公司不想承担任何风险!因此,他们需要一个模型来预测 I-94 路线的交通流量,更重要的是,要了解哪些因素会创造不确定性并可能增加成本。他们已经聘请了一位机器学习专家来完成这项工作:就是你!

建筑公司提供的 ATR 数据包括截至 2018 年 9 月的每小时交通量,以及同一时间尺度的天气数据。它只包括西行车道,因为那部分扩建将首先进行。

方法

您已经使用近四年的数据(2012 年 10 月 – 2016 年 9 月)训练了一个有状态的双向 LSTM模型。您保留了最后一年用于测试(2017 年 9 月–2018 年)和之前一年用于验证(2016 年 9 月 –2017 年)。这样做是有道理的,因为测试和验证数据集与高速公路扩建项目预期的条件(3 月 – 11 月)相吻合。您曾考虑使用仅利用这些条件数据的其他分割方案,但您不想如此大幅度地减少训练数据,也许它们最终还是可能需要用于冬季预测。回望窗口定义了时间序列模型可以访问多少过去数据。您选择了 168 小时(1 周)作为回望窗口大小。鉴于模型的这种有状态性质,随着模型在训练数据中的前进,它可以学习每日和每周的季节性,以及一些只能在几周内观察到的趋势和模式。您还训练了另外两个模型。您概述了以下步骤以满足客户期望:

  1. 使用 RMSE回归图混淆矩阵 等等,您将访问模型的预测性能,更重要的是,了解误差的分布情况。

  2. 使用 集成梯度,您将了解是否采取了最佳建模策略,因为它可以帮助您可视化模型到达决策的每一条路径,并帮助您根据这一点选择模型。

  3. 使用 SHAP 的 KernelExplainer 和预测近似方法,您将推导出对所选模型有重要意义的特征的全局和局部理解。

  4. 使用 Morris 敏感性分析,您将识别 因子优先级,它根据它们可以驱动输出变异性的程度对因素(换句话说,特征)进行排序。

  5. 使用 Sobol 敏感性分析,您将计算 因子固定,这有助于确定哪些因素不具有影响力。它是通过量化输入因素对输出变异性的贡献和相互作用来做到这一点的。有了这个,您可以了解哪些因素可能对潜在的罚款和成本影响最大,从而产生基于变异性的成本敏感性分析。

准备工作

您可以在此处找到此示例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/blob/main/09/Traffic_compact1.ipynb

加载库

要运行此示例,您需要安装以下库:

  • mldatasets 用于加载数据集

  • pandasnumpy 用于操作数据集

  • tensorflow 用于加载模型

  • scikit-learnmatplotlibseabornalibidistythonshapSALib 用于创建和可视化解释

您应该首先加载所有这些内容:

import math
import os
import mldatasets
import pandas as pd
import numpy as np
from sklearn.preprocessing import MinMaxScaler
from sklearn import metrics
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras.preprocessing.sequence import \ TimeseriesGenerator
from keras.utils import get_file
import matplotlib.pyplot as plt
from matplotlib.colors import TwoSlopeNorm
import seaborn as sns
from alibi.explainers import IntegratedGradients
from distython import HEOM
import shap
from SALib.sample import morris as ms
from SALib.analyze import morris as ma
from SALib.plotting import morris as mp
from SALib.sample.saltelli import sample as ss
from SALib.analyze.sobol import analyze as sa
from SALib.plotting.bar import plot as barplot 

让我们通过使用print(tf.__version__)命令来检查 TensorFlow 是否加载了正确的版本。它应该是 2.0 或更高版本。

理解和准备数据

are loading the data into a DataFrame called traffic_df. Please note that the prepare=True parameter is important because it performs necessary tasks such as subsetting the DataFrame to the required timeframe, since October 2015, some interpolation, correcting holidays, and performing one-hot encoding:
traffic_df = mldatasets.load("traffic-volume-v2", prepare=True) 

应该有超过 52,000 条记录和 16 列。我们可以使用traffic_df.info()来验证这一点。输出应该符合预期。所有特征都是数值型的,没有缺失值,并且分类特征已经为我们进行了独热编码。

数据字典

由于分类编码,只有九个特征,但它们变成了 16 列:

  • dow: 序数型;以星期一开始的星期几(介于 0 到 6 之间)

  • hr: 序数型;一天中的小时(介于 0 到 23 之间)

  • temp: 连续型;摄氏度平均温度(介于-30 到 37 之间)

  • rain_1h: 连续型;该小时发生的降雨量(介于 0 到 21 毫米之间)

  • snow_1h: 连续型;该小时发生的雪量(当转换为液体形式时)(介于 0 到 2.5 厘米之间)

  • cloud_coverage: 连续型;云层覆盖率百分比(介于 0 到 100 之间)

  • is_holiday: 二元型;该天是星期一到星期五的国家或州假日吗?(1 表示是,0 表示否)?

  • traffic_volume: 连续型;捕获交通量的目标特征

  • weather: 分类;该小时天气的简短描述(晴朗 | 云层 | 雾 | 薄雾 | 雨 | 雪 | 未知 | 其他)

理解数据

理解时间序列问题的第一步是理解目标变量。这是因为它决定了你如何处理其他所有事情,从数据准备到建模。目标变量可能与时间有特殊的关系,例如季节性变动或趋势。

理解周

首先,我们可以从每个季节中采样一个 168 小时的时间段,以更好地理解一周中每天之间的方差,然后了解它们如何在季节和假日之间变化:

lb = 168
fig, (ax0,ax1,ax2,ax3) = plt.subplots(4,1, figsize=(15,8))
plt.subplots_adjust(top = 0.99, bottom=0.01, hspace=0.4)
traffic_df[(lb*160):(lb*161)].traffic_volume.plot(ax=ax0)
traffic_df[(lb*173):(lb*174)].traffic_volume.plot(ax=ax1)
traffic_df[(lb*186):(lb*187)].traffic_volume.plot(ax=ax2)
traffic_df[(lb*199):(lb*200)].traffic_volume.plot(ax=ax3) 

前面的代码生成了图 9.2中显示的图表。如果你从左到右阅读它们,你会看到它们都是从星期三开始,以下一周的星期二结束。每周的每一天都是从低点开始和结束,中间有一个高点。工作日通常有两个高峰,对应早晨和下午高峰时段,而周末只有一个下午的峰值:

图 9.2:代表每个季节的几个交通量样本周周期

存在一些主要异常值,例如 10 月 31 日星期六,这基本上是万圣节,并不是官方的节假日。还有 2 月 2 日(星期二)是严重的暴风雪的开始,而夏末的时期比其他样本周要混乱得多。结果发现,那一年州博览会发生了。像万圣节一样,它既不是联邦节日也不是地区节日,但重要的是要注意博览会场地位于明尼阿波利斯和圣保罗之间的一半。你还会注意到,在 7 月 29 日星期五午夜时,交通量有所上升,这可以归因于这是明尼阿波利斯音乐会的大日子。

在比较时间序列中的各个时期时,试图解释这些不一致性是一个很好的练习,因为它有助于你确定需要添加到模型中的变量,或者至少知道缺少了什么。在我们的案例中,我们知道我们的is_holiday变量不包括万圣节或整个州博览会周,也没有针对大型音乐或体育赛事的变量。为了构建一个更稳健的模型,寻找可靠的外部数据源并添加更多覆盖所有这些可能性的特征是明智的,更不用说验证现有变量了。目前,我们将使用我们拥有的数据。

理解日子

对于高速公路扩建项目来说,了解平均工作日的交通状况至关重要。施工队伍只在工作日(周一至周五)工作,除非遇到延误,在这种情况下,他们也会在周末工作。我们还必须区分节假日和其他工作日,因为这些可能有所不同。

为了这个目的,我们将创建一个 DataFrame(weekend_df)并创建一个新列(type_of_day),将小时编码为“假日”、“工作日”或“周末”。然后,我们可以按此列和hr列进行分组,并使用mean和标准差(std)进行聚合。然后我们可以进行pivot,以便我们有一个列,其中包含每个type_of_day类别的平均交通量和标准差,其中行代表一天中的小时数(hr)。然后,我们可以绘制结果 DataFrame。我们可以创建包含标准差的区间:

weekend_df = traffic_df[
    ['hr', 'dow', 'is_holiday', 'traffic_volume']].copy()
weekend_df['type_of_day'] = np.where(
    weekend_df.is_holiday == 1,
    'Holiday',
    np.where(weekend_df.dow >= 5, 'Weekend', 'Weekday')
)
weekend_df = weekend_df.groupby(
['type_of_day','hr']) ['traffic_volume']
    .agg(['mean','std'])
    .reset_index()
    .pivot(index='hr', columns='type_of_day', values=['mean', 'std']
)
weekend_df.columns = [
    ''.join(col).strip().replace('mean','')\
    for col in weekend_df.columns.values
]
fig, ax = plt.subplots(figsize=(15,8))
weekend_df[['Holiday','Weekday','Weekend']].plot(ax=ax)
plt.fill_between(
    weekend_df.index,
    np.maximum(weekend_df.Weekday - 2 * weekend_df.std_Weekday, 0),
    weekend_df.Weekday + 2 * weekend_df.std_Weekday,
    color='darkorange',
    alpha=0.2
)
plt.fill_between(
    weekend_df.index,\
    np.maximum(weekend_df.Weekend - 2 * weekend_df.std_Weekend, 0),
    weekend_df.Weekend + 2 * weekend_df.std_Weekend,
    color='green',
    alpha=0.1
)
plt.fill_between(
    weekend_df.index,\
    np.maximum(weekend_df.Holiday - 2 * weekend_df.std_Holiday, 0),
    weekend_df.Holiday + 2 * weekend_df.std_Holiday,
    color='cornflowerblue',
    alpha=0.1
) 

前面的代码片段产生了以下图表。它表示每小时平均交通量,但变化很大,这就是为什么建筑公司正在谨慎行事。图中绘制了代表每个阈值的水平线:

  • 容量满载时为 5,300。

  • 半容量时为 2,650,之后建筑公司将因每日指定金额被罚款。

  • 无施工阈值是 1,500,之后建筑公司将因每小时指定金额被罚款。

他们只想在通常低于 1500 阈值的小时内工作,周一到周五。这五个小时将是晚上 11 点(前一天)到早上 5 点。如果他们必须周末工作,这个时间表通常会推迟到凌晨 1 点,并在早上 6 点结束。在工作日,变化相对较小,所以建筑公司坚持只在工作日工作是可以理解的。在这些小时里,节假日看起来与周末相似,但节假日的变化甚至比周末更大,这可能是更成问题的情况:

图表描述自动生成

图 9.3:节假日、工作日和周末的平均每小时交通量,以及间隔

通常,对于这样的项目,你会探索预测变量,就像我们对目标所做的那样。这本书是关于模型解释的,所以我们将通过解释模型来了解预测变量。但在我们到达模型之前,我们必须为它们准备数据。

数据准备

第一步数据准备是将数据分割成训练集、验证集和测试集。请注意,测试数据集包括最后 52 周(2184小时),而验证数据集包括之前的 52 周,因此它从4368小时开始,到 DataFrame 最后一行之前的2184小时结束:

train = traffic_df[:-4368]
valid = traffic_df[-4368:-2184]
test = traffic_df[-2184:] 

现在 DataFrame 已经被分割,我们可以绘制它以确保其部分是按照预期分割的。我们可以使用以下代码来完成:

plt.plot(train.index.values, train.traffic_volume.values,
          label='train')
plt.plot(valid.index.values, valid.traffic_volume.values,
           label='validation')
plt.plot(test.index.values, test.traffic_volume.values,
          label='test')
plt.ylabel('Traffic Volume')
plt.legend() 

上述代码生成了图 9.4。它显示,训练数据集分配了近 4 年的数据,而验证和测试各分配了一年。在这个练习中,我们将不再引用验证数据集,因为它只是在训练期间作为工具来评估模型在每个 epoch 后的预测性能。

图表描述自动生成,置信度低

图 9.4:时间序列分割为训练集、验证集和测试集

下一步是对数据进行 min-max 归一化。我们这样做是因为较大的值会导致所有神经网络的学习速度变慢,而 LSTM 非常容易发生梯度爆炸和消失。相对均匀且较小的数字可以帮助解决这些问题。我们将在本章后面讨论这个问题,但基本上,网络要么在数值上不稳定,要么在达到全局最小值方面无效。

我们可以使用scikit包中的MinMaxScaler进行 min-max 归一化。目前,我们只会对归一化器进行fit操作,以便我们可以在需要时使用它们。我们将为我们的目标(traffic_volume)创建一个名为y_scaler的归一化器,并为其余变量(X_scaler)创建另一个归一化器,使用整个数据集,以确保无论使用哪个部分(trainvalidtest),转换都是一致的。所有的fit过程只是保存公式,使每个变量适合在零和一之间:

y_scaler = MinMaxScaler()
y_scaler.fit(traffic_df[['traffic_volume']])
X_scaler = MinMaxScaler()
X_scaler.fit(traffic_df.drop(['traffic_volume'], axis=1)) 

现在,我们将使用我们的缩放器 transform 我们的训练和测试数据集,为每个创建 yX 对:

y_train = y_scaler.transform(train[['traffic_volume']])
X_train = X_scaler.transform(train.drop(['traffic_volume'], axis=1))
y_test = y_scaler.transform(test[['traffic_volume']])
X_test = X_scaler.transform(test.drop(['traffic_volume'], axis=1)) 

然而,对于时间序列模型,我们创建的 yX 对并不有用,因为每个观测值都是一个时间步长。每个时间步长不仅仅是该时间步长发生的特征,而且在一定程度上是它之前发生的事情,称为滞后。例如,如果我们根据 168 个滞后观测值预测交通,对于每个标签,我们将需要每个特征的之前 168 小时的数据。因此,你必须为每个时间步长以及其滞后生成一个数组。幸运的是,keras 有一个名为 TimeseriesGenerator 的函数,它接受你的 Xy 并生成一个生成器,该生成器将数据馈送到你的模型。你必须指定一个特定的 length,这是滞后观测值的数量(也称为 lookback window)。默认的 batch_size 是一个,但我们使用 24,因为客户更喜欢一次获取 24 小时的预测,而且使用更大的批次大小进行训练和推理要快得多。

自然地,当你需要预测明天时,你需要明天的天气,但你可以用天气预报来补充时间步长:

gen_train = TimeseriesGenerator(
    X_train,
    y_train,
    length=lb,
    batch_size=24
)
gen_test = TimeseriesGenerator(
    X_test,
    y_test,
    length=lb,
    batch_size=24
)
print(
    "gen_train:%s×%s→%s" % (len(gen_train),
    gen_train[0][0].shape, gen_train[0][1].shape)
)
print(
    "gen_test:%s×%s→%s" % (len(gen_test),
    gen_test[0][0].shape, gen_test[0][1].shape)
) 
gen_train) and the testing generator (gen_test), which use a length of 168 hours and a batch size of 24:
gen_train:  1454 ×   (24, 168, 15)   →   (24, 1)
gen_test:   357  ×   (24, 168, 15)   →   (24, 1) 

任何使用 1 周滞后窗口和 24 小时批次大小训练的模型都需要这个生成器。每个生成器是与每个批次对应的元组的列表。这个元组的索引 0 是 X 特征数组,而索引 1 是 y 标签数组。因此,输出的第一个数字是列表的长度,即批次的数量。Xy 数组的维度随后。

例如,gen_train 有 1,454 个批次,每个批次有 24 个时间步长,长度为 168,有 15 个特征。从这些 24 个时间步长中预期的预测标签的形状是 (24,1)

最后,在继续处理模型和随机解释方法之前,让我们尝试通过初始化我们的随机种子来使事情更具可重复性:

rand = 9
os.environ['PYTHONHASHSEED']=str(rand)
tf.random.set_seed(rand)
np.random.seed(rand) 

加载 LSTM 模型

我们可以快速加载模型并像这样输出其摘要:

model_name = 'LSTM_traffic_168_compact1.hdf5'
model_path = get_file(
    model_name,
    'https://github.com/PacktPublishing/Interpretable-\ 
    Machine-Learning-with-Python-2E/blob/main/models/{}?raw=true'
    .format(model_name)
)
lstm_traffic_mdl = keras.models.load_model(model_path)
lstm_traffic_mdl.summary() 
bidirectional LSTM layer with an output of (24, 168). 24 corresponds to the batch size, while 168 means that there’s not one but two 84-unit LSTMs going in opposite directions and meeting in the middle. It has a dropout of 10%, and then a dense layer with a single ReLu-activated unit. The ReLu ensures that all the predictions are over zero since negative traffic volume makes no sense:
Model: "LSTM_traffic_168_compact1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
Bidir_LSTM (Bidirectional)   (24, 168)                 67200     
_________________________________________________________________
Dropout (Dropout)            (24, 168)                 0         
_________________________________________________________________
Dense (Dense)                (24, 1)                   169       
=================================================================
Total params: 67,369
Trainable params: 67,369
Non-trainable params: 0
_________________________________________________________________ 

现在,让我们使用传统的解释方法评估 LSTM_traffic_168_compact1 模型。

使用传统的解释方法评估时间序列模型

时间序列回归模型可以像评估任何回归模型一样进行评估;也就是说,使用来自 均方误差R-squared 分数的指标。当然,在某些情况下,你可能需要使用具有中位数、对数、偏差或绝对值的指标。这些模型不需要任何这些。

使用标准的回归指标

evaluate_reg_mdl 函数可以评估模型,输出一些标准的回归指标,并绘制它们。此模型的参数是拟合的模型 (lstm_traffic_mdl),X_train (gen_train),X_test (gen_test),y_trainy_test

可选地,我们可以指定一个y_scaler,以便模型使用标签的逆变换进行评估,这使得绘图和均方根误差RMSE)更容易理解。在这种情况下,另一个非常必要的可选参数是y_truncate=True,因为我们的y_trainy_test的维度比预测标签大。这种差异发生是因为由于回望窗口,第一次预测发生在数据集的第一个时间步之后。因此,我们需要从y_train中减去这些时间步,以便与gen_train的长度匹配:

现在我们将使用以下代码评估这两个模型。为了观察预测的进度,我们将使用predopts={"verbose":1}

y_train_pred, y_test_pred, y_train, y_test =\
    mldatasets.evaluate_reg_mdl(lstm_traffic_mdl,
        gen_train,
        gen_test,
        y_train,
        y_test,
        scaler=y_scaler,
        y_truncate=True,
        predopts={"verbose":1}
) 
Figure 9.5. The *regression plot* is, essentially, a scatter plot of the observed versus predicted traffic volumes, fitted to a linear regression model to show how well they match. These plots show that the model tends to predict zero traffic when it’s substantially higher. Besides that, there are a number of extreme outliers, but it fits relatively well with a test RMSE of 430 and only a slightly better train RMSE:

图片

图 9.5:“LSTM_traffic_168_compact1”模型的预测性能评估

我们还可以通过比较观察到的与预测的交通来评估模型。按小时和类型分解错误可能会有所帮助。为此,我们可以创建包含这些值的 DataFrames - 每个模型一个。但首先,我们必须截断 DataFrame(-y_test_pred.shape[0]),以便它与预测数组的长度匹配,我们不需要所有列,所以我们只提供我们感兴趣的索引:traffic_volume是第 7 列,但我们还希望有dow(第 0 列)、hr(第 1 列)和is_holiday(第 6 列)。我们将traffic_volume重命名为actual_traffic,并创建一个名为predicted_traffic的新列,其中包含我们的预测。然后,我们将创建一个type_of_day列,就像我们之前做的那样,它告诉我们是否是节假日、工作日还是周末。最后,我们可以删除dowis_holiday列,因为我们不再需要它们:

evaluate_df = test.iloc[-y_test_pred.shape[0]:,[0,1,6,7]]
    .rename(columns={'traffic_volume':'actual_traffic'}
)
evaluate_df['predicted_traffic'] = y_test_pred
evaluate_df['type_of_day'] = np.where(
    evaluate_df.is_holiday == 1,
    'Holiday',
    np.where(evaluate_df.dow >= 5,
    'Weekend', 'Weekday')
)
evaluate_df.drop(['dow','is_holiday'], axis=1, inplace=True) 

您可以通过简单地运行一个带有evaluate_df的单元格来快速查看 DataFrames 的内容。它应该有 4 列。

预测误差聚合

可能是某些日期和时间更容易出现预测误差。为了更好地了解这些误差如何在时间上分布,我们可以按小时分段绘制type_of_day的 RMSE。为此,我们必须首先定义一个rmse函数,然后按type_of_dayhr对每个模型的评估 DataFrame 进行分组,并使用apply函数通过rmse函数进行聚合。然后我们可以通过转置来确保每个type_of_day都有一个按小时显示 RMSE 的列。然后我们可以平均这些列并将它们存储在一个系列中:

def rmse(g):
    rmse = np.sqrt(
    metrics.mean_squared_error(g['actual_traffic'],
                               g['predicted_traffic'])
    )
    return pd.Series({'rmse': rmse})
evaluate_by_hr_df = evaluate_df.groupby(['type_of_day', 'hr'])
    .apply(rmse).reset_index()
    .pivot(index='hr', columns='type_of_day', values='rmse')
mean_by_daytype_s = evaluate_by_hr_df.mean(axis=0) 

现在我们有了包含节假日、工作日和周末每小时 RMSE 的 DataFrames,以及这些“类型”的日平均数,我们可以使用evaluate_by_hr DataFrame 来绘制它们。我们还将创建带有每个type_of_day平均值的虚线水平线,这些平均值来自mean_by_daytype pandas系列:

evaluate_by_hr_df.plot()
ax = plt.gca()
ax.set_title('Hourly RMSE distribution', fontsize=16)
ax.set_ylim([0,2500])
ax.axhline(
    y=mean_by_daytype_s.Holiday,
    linewidth=2,
    color='cornflowerblue',
    dashes=(2,2)
)
ax.axhline(
    y=mean_by_daytype_s.Weekday,
    linewidth=2,
    color='darkorange',
    dashes=(2,2)
)
ax.axhline(
    y=mean_by_daytype_s.Weekend,
    linewidth=2,
    color='green',
    dashes=(2,2)
) 

上述代码生成了图 9.6中显示的图表。正如我们所见,该模型在假日有很高的 RMSE。然而,该模型可能高估了交通量,而在这种特定情况下,高估不如低估糟糕,因为低估可能导致交通延误和额外的罚款成本:

图表,折线图,描述自动生成图 9.6:“LSTM_traffic_168_compact1”模型按 type_of_day 类型划分的小时 RMSE

将模型评估视为分类问题

的确,就像分类问题可以有假阳性和假阴性,其中一个是比另一个更昂贵的,你可以用诸如低估和过度估计等概念来构建任何回归问题。这种构建特别有用,当其中一个比另一个更昂贵时。如果你有明确定义的阈值,就像我们在这个项目中做的那样,你可以像评估分类问题一样评估任何回归问题。我们将使用半容量和“无施工”阈值混淆矩阵来评估它。为了完成这项任务,我们可以使用np.where来获取实际值和预测值超过每个阈值的二进制数组。然后我们可以使用compare_confusion_matrices函数来比较模型的混淆矩阵:

actual_over_half_cap = np.where(evaluate_df['actual_traffic'] >\
                                2650, 1, 0)
pred_over_half_cap = np.where(evaluate_df['predicted_traffic'] >\
                              2650, 1, 0)
actual_over_nc_thresh = np.where(evaluate_df['actual_traffic'] >\
                                 1500, 1, 0)
pred_over_nc_thresh = np.where(evaluate_df['predicted_traffic'] >\
                               1500, 1, 0)
mldatasets.compare_confusion_matrices(
    actual_over_half_cap,
    pred_over_half_cap,
    actual_over_nc_thresh,
    pred_over_nc_thresh,
    'Over Half-Capacity',
    'Over No-Construction Threshold'
) 
Figure 9.7.

图表描述自动生成

图 9.7:“LSTM_traffic_168_compact1”模型的超过半容量和“无施工”阈值的混淆矩阵

我们最感兴趣的是假阴性(左下象限)的百分比,因为当实际上交通量超过了阈值时,预测没有超过阈值将导致高额罚款。另一方面,假阳性的成本在于在交通量实际上没有超过阈值的情况下提前离开施工现场。尽管如此,安全总是比后悔好!如果你比较“无施工”阈值的假阴性(0.85%),它不到半容量阈值(3.08%)的三分之一。最终,最重要的是无施工阈值,因为目的是在接近半容量之前停止施工。

现在我们已经利用传统方法来理解模型的决策,让我们继续探讨一些更高级的模型无关方法。

使用集成梯度生成 LSTM 归因

我们第一次在第七章可视化卷积神经网络中了解到集成梯度IG)。与该章节中研究的其他基于梯度的归因方法不同,路径集成梯度不依赖于卷积层,也不限于分类问题。

事实上,因为它计算了输出相对于输入沿路径平均的梯度,所以输入和输出可以是任何东西!通常与卷积神经网络CNNs)和循环神经网络RNNs)一起使用整合梯度,就像我们在本章中解释的那样。坦白说,当你在网上看到 IG LSTM 的例子时,它有一个嵌入层,是一个 NLP 分类器,但 IG 对于甚至处理声音或遗传数据的 LSTMs 也非常有效!

整合梯度解释器和我们将继续使用的解释器可以访问交通数据集的任何部分。首先,让我们为所有这些创建一个生成器:

y_all = y_scaler.transform(traffic_df[['traffic_volume']])
X_all = X_scaler.transform(
    traffic_df.drop(['traffic_volume'], axis=1)
)
gen_all = TimeseriesGenerator(
    X_all, y_all, length=lb, batch_size=24
) 

整合梯度是一种局部解释方法。所以,让我们获取一些我们可以解释的“感兴趣的样本实例”。我们知道假日可能需要专门的逻辑,所以让我们看看我们的模型是否注意到了is_holiday在某个例子(holiday_afternoon_s)中的重要性。早晨也是一个问题,尤其是由于天气条件,早晨的拥堵时间比平均水平更长,所以我们有一个例子(peak_morning_s)。最后,一个炎热的日子可能会有更多的交通,尤其是在周末(hot_Saturday_s):

X_df = traffic_df.drop(['traffic_volume'], axis=1 \
    ).reset_index(drop=True)
holiday_afternoon_s = X_df[
    (X_df.index >= 43800) & (X_df.dow==0) &\
    (X_df.hr==16) &(X_df.is_holiday==1)
].tail(1)
peak_morning_s = X_df[
    (X_df.index >= 43800) & (X_df.dow==2) &\
    (X_df.hr==8) & (X_df.weather_Clouds==1) & (X_df.temp<20)
].tail(1)
hot_Saturday_s = X_df[
    (X_df.index >= 43800) & (X_df.dow==5) &\
    (X_df.hr==12) & (X_df.temp>29) & (X_df.weather_Clear==1)
].tail(1) 

现在我们已经创建了一些实例,让我们实例化我们的解释器。来自alibi包的IntegratedGradients只需要一个深度学习模型,但建议为积分近似设置步骤数(n_steps)和内部批次大小。我们将为我们的模型实例化一个解释器:

ig = IntegratedGradients(
    lstm_traffic_mdl, n_steps=25, internal_batch_size=24
) 

在我们对样本和解释器进行迭代之前,重要的是要意识到我们需要如何将样本输入到解释器中,因为它需要一个包含 24 个样本的批次。为此,一旦我们减去回望窗口(nidx),我们就必须获取样本的索引。然后,你可以从生成器(gen_all)中获取该样本的批次。每个批次包含 24 个时间步长,所以你需要将nidx向下取整到 24(nidx//24),以获取该样本的批次位置。一旦你获取了该样本的批次(batch_X)并打印了形状(24, 168, 15),第一个数字是 24 这一点不应该让你感到惊讶。当然,我们还需要获取批次内样本的索引(nidx%24),以获取该样本的数据:

nidx = holiday_afternoon_s.index.tolist()[0] – lb
batch_X = gen_all[nidx//24][0]
print(batch_X.shape) 

for循环将使用之前解释的方法来定位样本的批次(batch_X)。这个batch_X被输入到explain函数中。这是因为这是一个回归问题,没有目标类别;也就是说,target=None。一旦产生了解释,attributions属性将包含整个批次的属性。我们只能获取样本的属性,并将其transpose以产生一个形状为(15, lb)的图像。for循环中的其余代码只是获取用于刻度的标签,然后绘制一个图像,该图像拉伸以适应我们的figure维度,以及其标签:

samples = [holiday_afternoon_s, peak_morning_s, hot_saturday_s]
sample_names = ['Holiday Afternoon', 'Peak Morning' , 'Hot Saturday']
for s in range(len(samples)):
    nidx = samples[s].index.tolist()[0] - lb
    batch_X = gen_all[nidx//24][0]
    explanation = ig.explain(batch_X, target=None)
    attributions = explanation.attributions[0]
    attribution_img = np.transpose(attributions[nidx%24,:,:])
    end_date = traffic_df.iloc[samples[s].index
        ].index.to_pydatetime()[0]
    date_range = pd.date_range(
        end=end_date, periods=8, freq='1D').to_pydatetime().tolist()
    columns = samples[s].columns.tolist()  
    plt.title(
        'Integrated Gradient Attribution Map for "{}"'.\
        format(sample_names[s], lb), fontsize=16
    )
    divnorm = TwoSlopeNorm(
        vmin=attribution_img.min(),
        vcenter=0,
        vmax=attribution_img.max()
    )
    plt.imshow(
        attribution_img,
        interpolation='nearest' ,
        aspect='auto',
        cmap='coolwarm_r',
        norm=divnorm
)
    plt.xticks(np.linspace(0,lb,8).astype(int), labels=date_range)
    plt.yticks([*range(15)], labels=columns)
    plt.colorbar(pad=0.01,fraction=0.02,anchor=(1.0,0.0))
    plt.show() 

上述代码将生成图 9.8中显示的图表。在y轴上,您可以看到变量名称,而在x轴上,您可以看到对应于所讨论样本回望窗口的日期。x轴的最右侧是样本的日期,随着您向左移动,您会向时间后退。例如,假日下午的样本是 9 月 3 日下午 4 点,有一周的回望,所以每个向后的刻度代表该日期前一天。

图形用户界面,应用程序,表格  自动生成的描述

图 9.8:对于“LSTM_traffic_168_compact1”模型所有样本的注释集成梯度归因图

您可以通过查看图 9.8中的归因图强度来判断哪些小时/变量对预测很重要。每个归因图右侧的颜色条可以用作参考。红色中的负数表示负相关,而蓝色中的正数表示正相关。然而,一个相当明显的是,随着每个图向时间后退,强度往往会减弱。由于它是双向的,所以这种情况发生在两端。令人惊讶的是,这个过程发生得有多快。

让我们从底部开始。对于“热周六”,随着您接近预测时间(周六中午),星期几、小时、温度和晴朗的天气在这个预测中扮演的角色越来越重要。天气开始较凉爽,这解释了为什么在温度特征中红色区域出现在蓝色区域之前。

对于“高峰早晨”,归因是有意义的,因为它在之前有雨和多云之后变得晴朗,这导致高峰时段迅速达到顶峰而不是缓慢增加。在一定程度上,LSTM 已经学会了只有最近的天气才重要——不超过两三天。然而,集成梯度减弱的原因不仅仅是这一点。它们也因为梯度消失问题而减弱。这个问题发生在反向传播过程中,因为梯度值在每一步都要乘以权重矩阵,所以梯度可以指数级减少到零。

LSTM 被组织在一个非常长的序列中,这使得网络在长期捕捉依赖关系方面越来越无效。幸运的是,这些 LSTM 是有状态的,这意味着它们通过利用前一个批次的状态将批次按顺序连接起来。状态性确保了从长序列中学习,尽管存在梯度消失问题。这就是为什么当我们观察“假日下午”的归因图时,对于is_holiday有负归因,这是预料到没有高峰时段的合理原因。结果证明,9 月 3 日(劳动节)距离前一个假日(独立日)近两个月,而独立日是一个更盛大的节日。模型是否可能捕捉到这些模式呢?

我们可以尝试根据交通模式对假日进行子分类,看看这是否能帮助模型识别它们。我们还可以对以前的天气条件进行滚动汇总,以便模型更容易地捕捉到最近的天气模式。天气模式跨越数小时,因此汇总是直观的,而且更容易解释。解释方法可以为我们指明如何改进模型的方向,当然,改进的空间很大。

接下来,我们将尝试一种基于排列的方法!

使用 SHAP 的 KernelExplainer 计算全局和局部归因

排列方法通过对输入进行修改来评估它们将对模型输出产生多大的影响。我们首次在第四章全局模型无关解释方法中讨论了这一点,但如果你还记得,有一个联盟框架可以执行这些排列,从而为不同特征的联盟产生每个特征的边际贡献的平均值。这个过程的结果是Shapley ,它具有如加法和对称性等基本数学性质。不幸的是,对于不是特别小的数据集,Shapley 值的计算成本很高,所以 SHAP 库有近似方法。其中一种方法就是KernelExplainer,我们在第四章中也解释了它,并在第五章局部模型无关解释方法中使用它。它使用加权局部线性回归来近似 Shapley 值,就像 LIME 所做的那样。

为什么使用 KernelExplainer?

我们有一个深度学习模型,那么为什么我们不使用 SHAP 的DeepExplainer,就像我们在第七章可视化卷积神经网络中使用的 CNN 一样呢?DeepExplainer 将 DeepLIFT 算法改编来近似 Shapley 值。它与任何用于表格数据、CNN 和具有嵌入层的 RNN(例如用于 NLP 分类器或用于检测基因组序列的 RNN)都配合得非常好。对于多元时间序列,它变得更加复杂,因为 DeepExplainer 不知道如何处理输入的三维数组。即使它知道,它还包括了之前时间步的数据,因此你无法在不考虑之前时间步的情况下对单个时间步进行排列。例如,如果排列规定温度降低五度,这不应该影响之前数小时内的所有温度吗?如果温度降低 20 度呢?这不意味着它可能处于不同的季节,并且天气完全不同——也许还有更多的云和雪?

SHAP 的KernelExplainer可以接收任何任意的黑盒predict函数。它还对输入维度做出了一些假设。幸运的是,我们可以在它排列之前更改输入数据,使其对KernelExplainer来说,就像它正在处理一个表格数据集一样。任意的predict函数不必简单地调用模型的predict函数——它可以在输入和输出过程中更改数据!

定义一个策略使其与多元时间序列模型一起工作

为了模仿基于排列输入数据的可能过去天气模式,我们可以创建一个生成模型或类似的东西。这种策略将帮助我们生成适合排列时间步的多种过去时间步,以及为特定类别生成图像。尽管这可能会导致更准确的预测,但我们不会使用这种策略,因为它非常耗时。

相反,我们将使用gen_all生成器中的现有示例来找到最适合排列输入的时间序列数据。我们可以使用距离度量来找到最接近排列输入的那个。然而,我们必须设置一些限制,因为如果排列是在周六早上 5 点,温度为 27 摄氏度,云量为 90%,那么最接近的观察可能是在周五早上 7 点,但无论天气交通如何,它都会完全不同。因此,我们可以实现一个过滤器函数,确保它只找到相同dowis_holidayhr的最近观察。过滤器函数还可以清理排列样本,删除或修改模型中任何无意义的部分,例如分类特征的连续值:

图片

图 9.9:排列近似策略

图 9.9展示了使用距离函数找到修改后的排列样本最近观察的过程。此函数返回最近的观察索引,但模型不能对单个观察(或时间步)进行预测,因此它需要其过去直到lookback窗口的小时历史。因此,它从生成器中检索正确的批次并对其进行预测,但预测将处于不同的尺度上,因此它们需要使用y_scaler进行逆变换。一旦predict函数迭代了所有样本并对它们进行了预测和缩放,它将它们发送回KernelExplainer,该工具输出它们的 SHAP 值。

为排列近似策略打下基础

您可以定义一个自定义的过滤器函数(filt_fn)。它接受一个包含整个数据集(X_df)的pandas DataFrame,您希望从中过滤,以及用于过滤的排列样本(x)和lookback窗口的长度。

该函数还可以修改排列后的样本。在这种情况下,我们必须这样做,因为模型中有许多特征是离散的,但排列过程使它们变得连续。正如我们之前提到的,所有过滤操作所做的只是通过限制选项来保护距离函数,防止它找到排列样本的非合理最近样本:

def filt_fn(X_df, x, lookback):
    x_ = x.copy()
    x_[0] = round(x_[0]) #round dow
    x_[1] = round(x_[1]) #round hr
    x_[6] = round(x_[6]) #round is_holiday
    if x_[1] < 0:#if hr < 0
        x_[1] = 24 + x_[1]
        x_[0] = x_[0] – 1  #make it previous day
    if x_[0] < 0:#if dow < 0
        x_[0] = 7 + x_[0] #make it previous week
        X_filt_df = X_df[
            (X_df.index >= lookback) & (X_df.dow==x_[0]) &\
            (X_df.hr==x_[1]) & (X_df.is_holiday==x_[6]) &\
            (X_df.temp-5<=x_[2]) & (X_df.temp+5>=x_[2])
        ]
    return X_filt_df, x_ 

如果你参考 图 9.9,在过滤器函数之后,我们接下来应该定义的是距离函数。我们可以使用 scipy.spatial.distance.cdist 接受的任何标准距离函数,例如“欧几里得”、“余弦”或“汉明”。这些标准距离函数的问题在于,它们要么与连续变量很好地工作,要么与离散变量很好地工作,但不能两者都很好地工作。我们在这个数据集中两者都有!

幸运的是,存在一些可以处理这两种情况的替代方案,例如异构欧几里得-重叠度量HEOM)和异构值差异度量HVDM)。这两种方法根据变量的性质应用不同的距离度量。HEOM 使用归一化的欧几里得距离 对连续变量,对离散变量使用“重叠”距离;即如果相同则为零距离,否则为 1。

HVDM 更复杂,因为对于连续变量,它是两个值之间的绝对距离,除以所涉及特征的均方根的四倍 ),这是一个处理异常值的好距离度量。对于离散变量,它使用归一化的值差异度量,这是基于两个值的条件概率之间的差异。

尽管 HVDM 对于具有许多连续值的集合数据集比 HEOM 更好,但在这种情况下却是过度设计。一旦数据集通过星期几 (dow) 和小时 (hr) 过滤,剩余的离散特征都是二进制的,因此“重叠”距离是理想的,而对于剩下的三个连续特征(temprain_1hsnow_1hcloud_coverage),欧几里得距离应该足够。distython 有一个 HEOM 距离方法,它只需要一个背景数据集 (X_df.values) 和分类特征的索引 (cat_idxs)。我们可以使用 np.where 命令编程识别这些特征。

如果你想验证这些是否是正确的,请在单元格中运行 print(cat_idxs)。只有索引 2、3、4 和 5 应该被省略:

cat_idxs = np.where(traffic_df.drop(['traffic_volume'],\
                                    axis=1).dtypes != np.float64)[0]
heom_dist = HEOM(X_df.values, cat_idxs)
print(cat_idxs) 

现在,我们可以创建一个 lambda 函数,将 图 9.9 中描述的所有内容放在一起。它利用一个名为 approx_predict_ts 的函数来处理整个流程。它接受我们的过滤器函数 (filt_fn)、距离函数 (heom_dist.heom)、生成器 (gen_all) 和拟合的模型 (lstm_traffic_mdl),并将它们链接在一起,如 图 9.9 所示。它还使用我们的缩放器 (X_scalery_scaler) 对数据进行缩放。距离是在转换后的特征上计算的,以提高准确性,并且预测在输出过程中进行反向转换:

predict_fn = lambda X: mldatasets.approx_predict_ts(
    X, X_df,
    gen_all,
    lstm_traffic_mdl,
    dist_metric=heom_dist.heom,
    lookback=lookback,
    filt_fn=filt_fn,
    X_scaler=X_scaler,
    y_scaler=y_scaler
) 

我们现在可以使用KernelExplainer的预测函数,但应该在最能代表施工队预期工作条件的样本上进行;也就是说,他们计划在 3 月到 11 月工作,最好是工作日和交通量低的时间。为此,让我们创建一个只包括这些月份的 DataFrame(working_season_df),并使用predict_fn和 DataFrame 的 k-means 作为背景数据初始化一个KernelExplainer

working_season_df =\
    traffic_df[lookback:].drop(['traffic_volume'], axis=1).copy()
working_season_df =\
    working_season_df[(working_season_df.index.month >= 3) &\
                      (working_season_df.index.month <= 11)]
explainer = shap.KernelExplainer(
    predict_fn, shap.kmeans(working_season_df.values, 24)
) 

我们现在可以为working_season_df DataFrame 的随机观测值集生成 SHAP 值。

计算 SHAP 值

我们将从其中采样 48 个观测值。KernelExplainer相当慢,尤其是在使用我们的近似方法时。为了获得最佳的全球解释,最好使用大量的观测值,同时也要使用高nsamples,这是在解释每个预测时需要重新评估模型次数的数量。不幸的是,如果每种都有 50 个,那么解释器运行起来将需要数小时,这取决于你的可用计算资源,所以我们将会使用nsamples=10。你可以查看 SHAP 的进度条并相应地调整。一旦完成,它将生成包含 SHAP 值的特征重要性summary_plot

X_samp_df = working_season_df.sample(80, random_state=rand)
shap_values = explainer.shap_values(X_samp_df, nsamples=10)
shap.summary_plot(shap_values, X_samp_df) 

上述代码绘制了以下图形中显示的摘要。不出所料,hrdow是最重要的特征,其次是某些天气特征。奇怪的是,温度和降雨似乎并没有在预测中起到作用,但晚春到秋季可能不是一个显著因素。或者,也许更多的观测值和更高的nsample将产生更好的全球解释:

包含图形用户界面的图片描述自动生成

图 9.10:基于 48 个采样观测值产生的 SHAP 摘要图

我们可以用上一节中选择的感兴趣实例进行相同的操作,以进行局部解释。让我们遍历所有这些数据点。然后,我们可以生成一个单一的shap_values,但这次使用nsamples=80,然后为每个生成一个force_plot

for s in range(len(samples)):
    print('Local Force Plot for "{}"'.format(sample_names[s]))
    shap_values_single = explainer.shap_values(
        datapoints[i], nsamples=80)
    shap.force_plot(
    explainer.expected_value,
    shap_values_single[0],
    samples[s],
    matplotlib=True
)
    plt.show() 

上述代码生成了图 9.11中显示的图形。“假日午后”的小时数(hr=16)推动预测值升高,而它是星期一(dow=0)和假日(is_holiday=1)的事实则推动预测值向相反方向移动。另一方面,“高峰早晨”主要由于小时数(hr=8.0)而处于高峰状态,但它有高cloud_coverage,肯定的weather_Clouds,而且没有降雨(rain_1h=0.0)。最后,“炎热的周六”由于星期数(dow=5)推动值降低,但异常高的值主要由于它是中午没有降雨和云层。奇怪的是,高于正常温度不是影响因素之一:

时间线描述自动生成

图 9.11:使用 SHAP 值和 nsamples=80 生成的力图,用于假日午后、高峰早晨和炎热周六

使用 SHAP 基于博弈论的方法,我们可以衡量现有观察值的排列如何使预测结果在许多可能特征联盟中边际变化。然而,这种方法可能非常有限,因为我们的背景数据中现有的方差塑造了我们对于结果方差的理解。

在现实世界中,变异性通常由数据中未表示的内容决定——但可能性极小。例如,在明尼阿波利斯夏季凌晨 5 点之前达到 25°C(77°F)并不常见,但随着全球变暖,它可能会变得频繁,因此我们想要模拟它如何影响交通模式。预测模型特别容易受到风险的影响,因此模拟是评估这种不确定性的关键解释组成部分。对不确定性的更好理解可以产生更稳健的模型,并直接指导决策。接下来,我们将讨论我们如何使用敏感性分析方法产生模拟。

使用因素优先级识别有影响力的特征

莫里斯方法是几种全局敏感性分析方法之一,范围从简单的分数因子到复杂的蒙特卡洛过滤。莫里斯位于这个光谱的某个位置,分为两个类别。它使用一次一个采样,这意味着在连续模拟之间只有一个值发生变化。它也是一个基本效应EE)方法,这意味着它不量化模型中因素的确切效应,而是衡量其重要性和与其他因素的关系。顺便说一句,因素只是另一个在应用统计学中常用的特征或变量的名称。为了与相关理论保持一致,我们将在本节和下一节中使用这个词汇。

莫里斯的另一个特性是,它比我们接下来将要研究的基于方差的计算方法更节省计算资源。它可以提供比回归、导数或基于因子的简单且成本较低的方法更多的见解。它不能精确量化效应,但可以识别那些具有可忽略或交互效应的效应,这使得它成为在因素数量较少时筛选因素的理想方法。筛选也被称为因素优先级,因为它可以根据它们的分类来优先考虑因素。

计算莫里斯敏感性指数

莫里斯方法推导出与单个因素相关联的基本效应分布。每个基本效应分布都有一个平均值(µ)和标准差(σ)。这两个统计数据有助于将因素映射到不同的分类中。当模型非单调时,平均值可能是负数,因此莫里斯方法的一个变体通过绝对值(µ^*)进行调整,以便更容易解释。我们在这里将使用这种变体。

现在,让我们将此问题的范围限制在更易于管理的范围内。施工队将面临的道路交通不确定性将持续从 5 月到 10 月,周一至周五,晚上 11 点到凌晨 5 点。因此,我们可以从working_season_df DataFrame 中进一步提取子集,以生成一个工作小时 DataFrame(working_hrs_df),我们可以对其进行describe。我们将包括 1%、50%和 99%的百分位数,以了解中位数和异常值所在的位置:

working_hrs_df = working_season_df[
    (working_season_df.dow < 5)
    & ((working_season_df.hr < 5) | (working_season_df.hr > 22))
]
working_hrs_df.describe(percentiles=[.01,.5,.99]).transpose() 

上述代码生成了图 9.12中的表格。我们可以使用这张表格来提取我们在模拟中使用的特征范围。通常,我们会使用超过现有最大值或最小值的合理值。对于大多数模型,任何特征值都可以在其已知限制之外增加或减少,并且由于模型学习到了单调关系,它可以推断出合理的结局。例如,它可能学习到超过某个点的降雨量将逐渐减少交通。那么,假设你想模拟每小时 30 毫米的严重洪水;它可以准确预测无交通:

图 9.12:施工队计划工作期间的汇总统计

然而,因为我们使用的是从历史值中采样的预测近似方法,所以我们受到如何将边界推到已知范围之外的限制。因此,我们将使用 1%和 99%的百分位数值作为我们的限制。我们应该注意,这对于任何发现来说都是一个重要的注意事项,特别是对于可能超出这些限制的特征,例如temprain_1hsnow_1h

图 9.12的总结中,我们还需要注意的一点是,许多与天气相关的二元特征非常稀疏。你可以通过它们的极低平均值来判断。每个添加到敏感性分析模拟中的因素都会减慢其速度,因此我们只会选择前三个;即weather_Clearweather_Cloudsweather_Rain。这些因素与其他六个因素一起在“问题”字典(morris_problem)中指定,其中包含它们的对应namesboundsgroups。现在,bounds是关键,因为它表示每个因素将模拟哪些值范围。我们将使用[0,4](周一至周五)作为dow的值,以及[-1,4](晚上 11 点到凌晨 4 点)作为hr的值。过滤器函数自动将负小时转换为前一天的小时,因此周二的一 1 相当于周一的 23。其余的界限是由百分位数确定的。请注意,groups中的所有因素都属于同一组,除了三个天气因素:

morris_problem = {
    # There are nine variables
    'num_vars': 10,
    # These are their names
    'names': ['dow', 'hr', 'temp', 'rain_1h', 'snow_1h',\
              'cloud_coverage', 'is_holiday', 'weather_Clear',\
              'weather_Clouds', 'weather_Rain'],
    # Plausible ranges over which we'll move the variables
    'bounds': [
        [0, 4], # dow Monday - Firday
        [-1, 4], # hr
        [-12, 25.], # temp (C)
        [0., 3.1], # rain_1h
        [0., .3], # snow_1h
        [0., 100.], # cloud_coverage
        [0, 1], # is_holiday
        [0, 1], # weather_Clear
        [0, 1], # weather_Clouds
        [0, 1] # weather_Rain
    ],
    # Only weather is grouped together
    'groups': ['dow', 'hr', 'temp', 'rain_1h', 'snow_1h',\
                'cloud_coverage', 'is_holiday', 'weather', 'weather',\
                'weather']
} 

一旦定义了字典,我们就可以使用 SALibsample 方法生成 Morris 方法样本。除了字典外,它还需要轨迹数量(256)和级别(num_levels=4)。该方法使用因素和级别的网格来构建输入随机逐个移动的轨迹(OAT)。这里需要注意的重要一点是,更多的级别会增加这个网格的分辨率,可能使分析更好。然而,这可能会非常耗时。最好从轨迹数量和级别之间的比例 25:1 或更高开始。

然后,你可以逐步降低这个比例。换句话说,如果你有足够的计算能力,你可以让 num_levels 与轨迹数量相匹配,但如果你有这么多可用的计算能力,你可以尝试 optimal_trajectories=True。然而,鉴于我们有组,local_optimization 必须设置为 Falsesample 的输出是一个数组,每个因素一列,(G + 1) × T 行(其中 G 是组数,T 是轨迹数)。我们有八个组,256 个轨迹,所以 print 应该输出一个 2,304 行 10 列的形状:

morris_sample = ms.sample(morris_problem, 256,\
                          num_levels=4, seed=rand)
print(morris_sample.shape) 

由于 predict 函数只与 15 个因素一起工作,我们应该修改样本,用零填充剩余的五个因素。我们使用零,因为这这些特征的中位数。中位数最不可能增加交通量,但你应该根据具体情况调整默认值。如果你还记得我们 第二章心血管疾病CVD)示例,可解释性关键概念,增加 CVD 风险的特性值有时是最小值或最大值。

np.hstack 函数可以将数组水平拼接,使得前八个因素之后跟着三个零因素。然后,有一个孤独的第九个样本因素对应于 weather_Rain,接着是两个零因素。结果数组应该与之前一样行数,但列数为 15:

morris_sample_mod = np.hstack(
    (
        morris_sample[:,0:9],
        np.zeros((morris_sample.shape[0],3)),
        morris_sample[:,9:10],
        np.zeros((morris_sample.shape[0],2))
    )
)
print(morris_sample_mod.shape) 

被称为 morris_sample_modnumpy 数组现在以我们的 predict 函数可以理解的形式包含了 Morris 样本。如果这是一个在表格数据集上训练过的模型,我们就可以直接利用模型的 predict 函数。然而,就像我们使用 SHAP 一样,我们必须使用近似方法。这次,我们不会使用 predict_fn,因为我们想在 approx_predict_ts 中设置一个额外的选项,progress_bar=True。其他一切都将保持不变。进度条将很有用,因为这可能需要一段时间。运行单元格,休息一下喝杯咖啡:

morris_preds = mldatasets.approx_predict_ts(
    morris_sample_mod,
    X_df,
    gen_all,
    lstm_traffic_mdl,
    filt_fn=filt_fn,
    dist_metric=heom_dist.heom,
    lookback=lookback,
    X_scaler=X_scaler,
    y_scaler=y_scaler,
    progress_bar=True
) 

要使用SALibanalyze函数进行敏感性分析,你需要你的问题字典(morris_problem),原始的 Morris 样本(morris_sample),以及我们用这些样本生成的预测(morris_preds)。还有一个可选的置信区间水平参数(conf_level),但默认的 0.95 是好的。它使用重采样来计算这个置信水平,默认为 1,000。这个设置也可以通过可选的num_resamples参数来改变:

morris_sensitivities = ma.analyze(
    morris_problem, morris_sample, morris_preds,\
    print_to_console=False
) 

分析基本影响

analyze将返回一个包含 Morris 敏感性指数的字典,包括平均值(µ)和标准差(σ)的基本影响,以及平均值(µ^)的绝对值。在表格格式中更容易欣赏这些值,这样我们就可以将它们放入 DataFrame 中,并根据µ*排序和着色,*µ*可以解释为因素的整体重要性。另一方面,σ表示因素与其它因素的交互程度:

morris_df = pd.DataFrame(
    {
        'features':morris_sensitivities['names'],
        'μ':morris_sensitivities['mu'],
        'μ*':morris_sensitivities['mu_star'],
        'σ':morris_sensitivities['sigma']
    }
)
morris_df.sort_values('μ*', ascending=False).style\
    .background_gradient(cmap='plasma', subset=['μ*']) 

前面的代码输出了图 9.13中展示的 DataFrame。你可以看出is_holiday是其中最重要的因素之一,至少在问题定义中指定的范围(morris_problem)内是这样。还有一点需要注意,天气确实有绝对的基本影响,但交互效应并不确定。组别很难评估,尤其是当它们是稀疏的二进制因素时:

时间线  自动生成的描述

图 9.13:因素的基本影响分解

前面的图中的 DataFrame 不是可视化基本影响的最佳方式。当因素不多时,更容易绘制它们。SALib提供了两种绘图方法。水平条形图(horizontal_bar_plot)和协方差图(covariance_plot)可以并排放置。协方差图非常好,但它没有注释它所界定的区域。我们将在下一节中了解这些。因此,仅出于教学目的,我们将使用text来放置注释:

fig, (ax0, ax1) = plt.subplots(1,2, figsize=(12,8))
mp.horizontal_bar_plot(ax0, morris_sensitivities, {})
mp.covariance_plot(ax1, morris_sensitivities, {})
ax1.text(
    ax1.get_xlim()[1] * 0.45, ax1.get_ylim()[1] * 0.75,\
    'Non-linear and/or-monotonic', color='gray',\
    horizontalalignment='center'
)
ax1.text(ax1.get_xlim()[1] * 0.75, ax1.get_ylim()[1] * 0.5,\
    'Almost Monotonic', color='gray', horizontalalignment='center')
ax1.text(ax1.get_xlim()[1] * 0.83, ax1.get_ylim()[1] * 0.2,\
    'Monotonic', color='gray', horizontalalignment='center')
ax1.text(ax1.get_xlim()[1] * 0.9, ax1.get_ylim()[1] * 0.025,
    'Linear', color='gray', horizontalalignment='center') 

前面的代码生成了图 9.14中显示的图表。左边的条形图按µ*对因素进行排序,而每根从条形中伸出的线表示相应的置信区间。右边的协方差图是一个散点图,*µ*位于x轴上,σ位于y轴上。因此,点越往右,它就越重要,而它在图中越往上,它与其它因素的交互就越多,就越不单调。自然地,这意味着那些交互不多且主要单调的因素符合线性回归的假设,如线性性和多重共线性。然而,线性与非线性或非单调之间的范围由σµ^的比率对角确定:

图表,散点图  自动生成的描述

图 9.14:表示基本效应的条形图和协方差图

你可以通过前面的协方差图看出,所有因素都是非线性或非单调的。hr无疑是其中最重要的,其次是接下来的两个因素(dowtemp)相对靠近,然后是weatheris_holidayweather组没有在图中显示,因为交互性结果不确定,但cloud_coveragerain_1hsnow_1h的交互性比它们单独重要得多。

基本效应帮助我们理解如何根据它们对模型结果的影响来分类我们的因素。然而,这并不是一个稳健的方法来正确量化它们的影响或由因素相互作用产生的影响。为此,我们必须转向使用概率框架分解输出方差并将其追溯到输入的基于方差的全局方法。这些方法包括傅里叶振幅敏感性测试FAST)和Sobol。我们将在下一节研究后一种方法。

使用因素固定量化不确定性和成本敏感性

使用 Morris 指数,很明显,所有因素都是非线性或非单调的。它们之间有很高的交互性——正如预期的那样!气候因素(temprain_1hsnow_1hcloud_coverage)很可能与hr存在多重共线性。在hris_holidaydow和目标之间也存在一些模式。许多这些因素肯定与目标没有单调关系。我们已经知道了这一点。例如,交通在一天中的小时数增加时并不总是增加。情况对一周中的某一天也是如此!

然而,我们不知道is_holidaytemp对模型的影响程度,尤其是在机组人员的工作时间内,这是一个重要的见解。话虽如此,使用 Morris 指数进行因素优先级排序通常被视为起点或“第一设置”,因为一旦确定存在交互效应,最好是解开它们。为此,有一个“第二设置”,称为因素固定。我们可以量化方差,通过这样做,量化所有因素带来的不确定性。

只有基于方差的方法才能以统计严谨的方式量化这些效应。Sobol 敏感性分析是这些方法之一,这意味着它将模型的输出方差分解成百分比,并将其归因于模型的输入和交互。像 Morris 一样,它有一个采样步骤,以及一个敏感性指数估计步骤。

与 Morris 不同,采样不遵循一系列级别,而是遵循输入数据的分布。它使用准蒙特卡洛方法,在超空间中采样点,这些点遵循输入的概率分布。蒙特卡洛方法是一系列执行随机采样的算法,通常用于优化或模拟。它们寻求在用蛮力或完全确定性的方法无法解决的问题上的捷径。蒙特卡洛方法在敏感性分析中很常见,正是出于这个原因。准蒙特卡洛方法有相同的目标。然而,它们收敛得更快,因为它们使用确定性低偏差序列而不是使用伪随机序列。Sobol 方法使用Sobol 序列,由同一位数学家设计。我们将使用另一种从 Sobol 派生出的采样方案,称为 Saltelli 的。

一旦生成样本,蒙特卡洛估计器就会计算基于方差的敏感性指数。这些指数能够量化非线性非加性效应和第二阶指数,这些指数与两个因素之间的相互作用相关。Morris 可以揭示模型中的交互性,但不能精确地说明它是如何表现的。Sobol 可以告诉你哪些因素在相互作用以及相互作用的程度。

生成和预测 Saltelli 样本

要使用SALib开始 Sobol 敏感性分析,我们必须首先定义一个问题。我们将与 Morris 做同样的事情。这次,我们将减少因素,因为我们意识到weather分组导致了不确定的结果。我们应该包括所有天气因素中最稀疏的;即weather_Clear。由于 Sobol 使用概率框架,将temprain_1hcloud_coverage的范围扩展到它们的最大和最小值是没有害处的,如图9.12所示:

sobol_problem = {
    'num_vars': 8,
    'names': ['dow', 'hr', 'temp', 'rain_1h', 'snow_1h',
              'cloud_coverage', 'is_holiday', 'weather_Clear'],
    'bounds': [
        [0, 4], # dow Monday through Friday
        [-1, 4], # hr
        [-3., 31.], # temp (C)
        [0., 21.], # rain_1h
        [0., 1.6], # snow_1h
        [0., 100.], # cloud_coverage
        [0, 1], # is_holiday
        [0, 1] # weather_Clear
      ],
    'groups': None
} 

生成样本看起来也应该很熟悉。Saltelli 的sample函数需要以下内容:

  • 问题陈述(sobol_problem

  • 每个因素要生成的样本数量(300

  • 第二阶索引以进行计算(calc_second_order=True

由于我们想要交互作用,sample的输出是一个数组,其中每一列代表一个因素,有行(其中N是样本数量,F是因素数量)。我们有八个因素,每个因素有 256 个样本,所以print应该输出 4,608 行和 8 列的形状。首先,我们将像之前一样使用hstack修改它,添加 7 个空因素以进行预测,从而得到 15 列:

saltelli_sample = ss.sample(
    sobol_problem, 256, calc_second_order=True, seed=rand
)
saltelli_sample_mod = np.hstack(
    (saltelli_sample, np.zeros((saltelli_sample.shape[0],7)))
)
print(saltelli_sample_mod.shape) 

现在,让我们对这些样本进行预测。这可能需要一些时间,所以又是咖啡时间:

saltelli_preds = mldatasets.pprox._predict_ts(
    saltelli_sample_mod,
    X_df,
    gen_all,
    lstm_traffic_mdl,
    filt_fn=filt_fn,
    dist_metric=heom_dist.heom,
    lookback=lookback,
    X_scaler=X_scaler,
    y_scaler=y_scaler,
    progress_bar=True
) 

执行 Sobol 敏感性分析

对于 Sobol 敏感性分析(analyze),你所需要的只是一个问题陈述(sobol_problem)和模型输出(saltelli_preds)。但是预测并不能讲述不确定性的故事。当然,预测的交通流量有方差,但只有当交通量超过 1,500 时,这个问题才会出现。不确定性是你想要与风险或回报、成本或收入、损失或利润相关联的东西——一些你可以与你问题相关联的实质性东西。

首先,我们必须评估是否存在任何风险。为了了解样本中的预测交通量是否在工作时间内超过了无建设阈值,我们可以使用print(max(saltelli_preds[:,0]))。最大交通水平应该在 1,800-1,900 左右,这意味着至少存在一些风险,即建筑公司将会支付罚款。我们不必使用预测(saltelli_preds)作为模型的输出,我们可以创建一个简单的二进制数组,当它超过 1,500 时为 1,否则为 0。我们将称之为costs,然后使用它运行analyze函数。注意,这里也设置了calc_second_order=True。如果sampleanalyze没有一致的设置,它将抛出一个错误。与 Morris 一样,有一个可选的置信区间水平参数(conf_level),但默认的 0.95 是好的:

costs = np.where(saltelli_preds > 1500, 1,0)[:,0]
factor_fixing_sa = sa.analyze(
    sobol_problem,
    costs,
    calc_second_order=True,
    print_to_console=False
) 

analyze将返回一个包含 Sobol 敏感性指数的字典,包括一阶(S1)、二阶(S2)和总阶(ST)指数,以及总置信区间(ST_conf)。这些指数对应于百分比,但除非模型是加性的,否则总数不一定相加。在表格格式中更容易欣赏这些值,这样我们可以将它们放入 DataFrame 中,并根据总数进行排序和着色,总数可以解释为因素的整体重要性。然而,我们将省略二阶指数,因为它们是二维的,类似于相关图:

sobol_df = pd.DataFrame(
    {
        'features':sobol_problem['names'],
        '1st':factor_fixing_sa['S1'],
        'Total':factor_fixing_sa['ST'],
        'Total Conf':factor_fixing_sa['ST_conf'],
        'Mean of Input':saltelli_sample.mean(axis=0)[:8]
    }
)
sobol_df.sort_values('Total', ascending=False).style
    .background_gradient(cmap='plasma', subset=['Total']) 

上一段代码输出了图 9.15中展示的 DataFrame。你可以看出tempis_holiday至少在问题定义中指定的边界内排在前面四位。另一个需要注意的事情是weather_Clear确实对其自身有更大的影响,但rain_1hcloud_coverage似乎对潜在成本没有影响,因为它们的总一阶指数为零:

时间线描述自动生成

图 9.15:八个因素的 Sobol 全局敏感性指数

关于一阶值的一些有趣之处在于它们有多低,这表明交互作用占模型输出方差的大部分。我们可以很容易地使用二阶索引来证实这一点。这些索引和一阶索引的组合加起来就是总数:

S2 = factor_fixing_sa['S2']
divnorm = TwoSlopeNorm(vmin=S2.min(), vcenter=0, vmax=S2.max())
sns.heatmap(S2, center=0.00, norm=divnorm, cmap='coolwarm_r',\
            annot=True, fmt ='.2f',\
            xticklabels=sobol_problem['names'],\
            yticklabels=sobol_problem['names']) 

上一段代码输出了图 9.16中的热图:

图表,瀑布图 描述自动生成

图 9.16:八个因素的 Sobol 二阶指数

在这里,您可以知道is_holidayweather_Clear是两个对输出方差贡献最大的因素,其绝对值最高为 0.26。dowhr与所有因素都有相当大的相互作用。

引入一个现实成本函数

现在,我们可以创建一个成本函数,它接受我们的输入(saltelli_sample)和输出(saltelli_preds),并计算双城将对建筑公司罚款多少,以及额外的交通可能产生的任何额外成本。

如果输入和输出都在同一个数组中,这样做会更好,因为我们需要从两者中获取详细信息来计算成本。我们可以使用hstack将样本及其对应的预测结果连接起来,生成一个包含八个列的数组(saltelli_sample_preds)。然后我们可以定义一个成本函数,它可以计算包含这些九个列的数组的成本(cost_fn):

#Join input and outputs into a sample+prediction array
saltelli_sample_preds = np.hstack((saltelli_sample, saltelli_preds)) 

我们知道,对于任何样本预测,半容量阈值都没有超过,所以我们甚至不需要在函数中包含每日罚款。除此之外,罚款是每辆超过每小时无施工阈值的车辆 15 美元。除了这些罚款之外,为了能够按时离开,建筑公司估计额外的成本:如果凌晨 4 点超过阈值,额外工资为 1,500 美元,周五额外 4,500 美元以加快设备移动速度,因为周末不能停在高速公路的路肩上。一旦我们有了成本函数,我们就可以遍历组合数组(saltelli_sample_preds),为每个样本计算成本。列表推导可以有效地完成这项工作:

#Define cost function
def cost_fn(x):
    cost = 0
    if x[8] > 1500:
        cost = (x[8] - 1500) * 15
    if round(x[1]) == 4:
        cost = cost + 1500
        if round(x[0]) == 4:
            cost = cost + 4500
    return cost
#Use list comprehension to compute costs for sample+prediction array
costs2 = np.array([cost_fn(xi) for xi in saltelli_sample_preds])
#Print total fines for entire sample predictions
print('Total Fines: $%s' % '{:,.2f}'.format(sum(costs2))) 

print语句应该输出一个介于 17 万美元和 20 万美元之间的成本。但不必担心!建筑队每年只计划在现场工作大约 195 天,每天 5 小时,总共 975 小时。然而,有 4,608 个样本,这意味着由于交通过多,几乎有 5 年的预测成本。无论如何,计算这些成本的目的在于了解它们与模型输入的关系。更多的样本年意味着更紧密的置信区间:

factor_fixing2_sa = sa.analyze(
    sobol_problem, costs2, calc_second_order=True,
    print_to_console=False
) 

现在,我们可以再次进行分析,但使用costs2,并将分析保存到factor_fixing2_sa字典中。最后,我们可以使用这个字典的值生成一个新的排序和彩色编码的 DataFrame,就像我们之前为图 9.15所做的那样,这将生成图 9.17中的输出。

如您从图 9.17中可以看出,一旦实际成本被考虑在内,dowhris_holiday成为更具风险的因素,而与图 9.15相比,snow_1htemp变得不那么相关:

时间线 描述自动生成

图 9.17:使用现实成本函数计算八个因素的 Sobol 全局敏感性指数

用表格难以欣赏的是敏感性指数的置信区间。为此,我们可以使用条形图,但首先,我们必须将整个字典转换成一个 DataFrame,以便SALib的绘图函数可以绘制它:

factor_fixing2_df = factor_fixing2_sa.to_df()
fig, (ax) = plt.subplots(1,1, figsize=(15, 7))
sp.plot(factor_fixing2_df[0], ax=ax) 

前面的代码生成了图 9.18中的条形图。dow的 95%置信区间比其他重要因素大得多,考虑到一周中各天之间的差异很大,这并不令人惊讶。另一个有趣的见解是weather_Clear具有负一阶效应,因此正的总阶指数完全归因于二阶指数,这扩大了置信区间:

图表,散点图  自动生成的描述

图 9.18:使用现实成本函数绘制的条形图,包含 Sobol 敏感性总阶指数及其置信区间

要了解如何,让我们再次绘制图 9.16所示的散点图,但这次使用factor_fixing2_sa而不是factor_fixing_sa图 9.19中的散点图应该描绘出模型中成本的现实反映:

图表,瀑布图  自动生成的描述

图 9.19:在考虑更现实的成本函数时,七个因素的 Sobol 二阶指数

前面的散点图显示了与图 9.16中相似的显著交互,但由于有更多的阴影,它们更加细腻。很明显,weather_Clearis_holiday结合时具有放大作用,而对dowhr则有调和作用。

任务完成

任务是训练一个交通预测模型,并了解哪些因素会创造不确定性,并可能增加建筑公司的成本。我们可以得出结论,潜在的 35,000 美元/年的罚款中有很大一部分可以归因于is_holiday因素。因此,建筑公司应该重新考虑工作假日。三月至十一月之间只有七个或八个假日,由于罚款,它们可能比在几个星期日工作成本更高。考虑到这个警告,任务已经成功,但仍有很多改进的空间。

当然,这些结论是针对LSTM_traffic_168_compact1模型——我们可以将其与其他模型进行比较。尝试将笔记本开头的model_name替换为LSTM_traffic_168_compact2,这是一个同样小巧但显著更稳健的模型,或者LSTM_traffic_168_optimal,这是一个更大但表现略好的模型,并重新运行笔记本。或者浏览名为Traffic_compact2Traffic_optimal的笔记本,这些笔记本已经使用相应的模型重新运行。你会发现,可以训练和选择能够更好地管理不确定输入的模型。话虽如此,改进并不总是通过简单地选择更好的模型就能实现。

例如,可以进一步深入探讨的是temprain_1hsnow_1h的真正影响。我们的预测近似方法排除了 Sobol 测试极端天气事件的影响。如果我们修改模型以在单个时间步长上训练聚合的天气特征,并内置一些安全措施,我们就可以使用 Sobol 模拟天气极端情况。而且,敏感性分析的“第三设置”,即因素映射,可以帮助精确指出某些因素值如何影响预测结果,从而进行更稳健的成本效益分析,但这一点我们不会在本章中涉及。

在本书的第二部分,我们探讨了多种解释方法的生态系统:全局和局部;针对特定模型和非特定模型;基于排列和基于敏感度的。对于任何机器学习用例,可供选择的方法并不缺乏。然而,必须强调的是,没有任何方法是完美的。尽管如此,它们可以相互补充,以更接近地理解您的机器学习解决方案及其旨在解决的问题。

本章关注预测中的确定性,旨在揭示机器学习社区中的一个特定问题:过度自信。在“可解释性的商业案例”部分的第一章,《解释、可解释性、可解释性;以及这一切为什么都重要?》,描述了充斥在人类决策中的许多偏见。这些偏见通常是由对领域知识或我们模型令人印象深刻的成果的过度自信所驱动的。而这些令人印象深刻的成果使我们无法理解我们模型的局限性,随着公众对 AI 的不信任增加,这一点变得更加明显。

正如我们在第一章中讨论的,解释、可解释性、可解释性;以及为什么这一切都很重要?,机器学习仅用于解决不完整问题。否则,我们不如使用在闭环系统中发现的确定性程序编程。解决不完整问题的最佳方法是一个不完整的解决方案,它应该被优化以尽可能多地解决它。无论是通过梯度下降、最小二乘估计还是分割和修剪决策树,机器学习不会产生一个完美泛化的模型。机器学习中的这种不完整性正是我们需要解释方法的原因。简而言之:模型从我们的数据中学习,我们可以从我们的模型中学到很多,但只有当我们解释它们时才能做到!

然而,可解释性并不止于此。模型解释可以驱动决策并帮助我们理解模型的优势和劣势。然而,数据或模型本身的问题有时会使它们变得难以解释。在本书的第三部分中,我们将学习如何通过降低复杂性、减轻偏差、设置护栏和增强可靠性来调整模型和训练数据以提高可解释性。

统计学家 George E.P. Box 曾著名地开玩笑说,“所有模型都是错误的,但有些是有用的。”也许它们并不总是错误的,但机器学习从业者需要谦逊地接受,即使是高性能模型也应受到审查,并且我们对它们的假设也应受到质疑。机器学习模型的不确定性是可以预期的,不应该是羞耻或尴尬的来源。这使我们得出本章的另一个结论:不确定性伴随着后果,无论是成本还是利润提升,我们可以通过敏感性分析来衡量这些后果。

摘要

阅读本章后,你应该了解如何评估时间序列模型的预测性能,知道如何使用集成梯度对他们进行局部解释,以及如何使用 SHAP 产生局部和全局归因。你还应该知道如何利用敏感性分析因子优先级和因子固定来优化任何模型。

在下一章中,我们将学习如何通过特征选择和工程来降低模型的复杂性,使其更具可解释性。

数据集和图像来源

进一步阅读

  • Wilson, D.R. 和 Martinez, T.,1997 年,改进的异构距离函数。J. Artif. Int. Res. 6-1. 第 1-34 页:arxiv.org/abs/cs/9701101

  • Morris, M., 1991, 《初步计算实验的因子抽样计划》. Quality Engineering, 37, 307-310: doi.org/10.2307%2F1269043

  • Saltelli, A., Tarantola, S., Campolongo, F., and Ratto, M., 2007, 《实践中的敏感性分析:评估科学模型指南》. Chichester: John Wiley & Sons.

  • Sobol, I.M., 2001, 《非线性数学模型的全球敏感性指数及其蒙特卡洛估计》. MATH COMPUT SIMULAT, 55(1–3), 271-280: doi.org/10.1016/S0378-4754(00)00270-6

  • Saltelli, A., P. Annoni, I. Azzini, F. Campolongo, M. Ratto, and S. Tarantola, 2010, 《模型输出的方差敏感性分析:总敏感性指数的设计和估计器》. Computer Physics Communications, 181(2):259-270: doi.org/10.1016/j.cpc.2009.09.018

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

第十章:可解释性特征选择与工程

在前三章中,我们讨论了复杂性如何阻碍机器学习ML)的可解释性。这里有一个权衡,因为你可能需要一些复杂性来最大化预测性能,但又不能达到无法依赖模型来满足可解释性原则:公平性、责任和透明度的程度。本章是四个专注于如何调整以实现可解释性的章节中的第一个。提高可解释性最简单的方法之一是通过特征选择。它有许多好处,例如加快训练速度并使模型更容易解释。但如果这两个原因不能说服你,也许另一个原因会。

一个常见的误解是,复杂的模型可以自行选择特征并仍然表现良好,那么为什么还要费心选择特征呢?是的,许多模型类别都有机制可以处理无用的特征,但它们并不完美。而且,随着每个剩余的机制的加入,过拟合的可能性也会增加。过拟合的模型是不可靠的,即使它们更准确。因此,虽然仍然强烈建议使用模型机制,如正则化,以避免过拟合,但特征选择仍然是有用的。

在本章中,我们将理解无关特征如何对模型的输出产生不利影响,从而了解特征选择对模型可解释性的重要性。然后,我们将回顾基于过滤器的特征选择方法,如斯皮尔曼相关系数,并了解嵌入式方法,如LASSO 和岭回归。然后,我们将发现包装方法,如顺序特征选择,以及混合方法,如递归特征消除RFE)。最后,尽管特征工程通常在选择之前进行,但在特征选择完成后,探索特征工程仍有其价值。

这些是我们将在本章中讨论的主要主题:

  • 理解无关特征的影响

  • 回顾基于过滤器的特征选择方法

  • 探索嵌入式特征选择方法

  • 发现包装、混合和高级特征选择方法

  • 考虑特征工程

让我们开始吧!

技术要求

本章的示例使用了mldatasetspandasnumpyscipymlxtendsklearn-genetic-optxgboostsklearnmatplotlibseaborn库。有关如何安装所有这些库的说明见前言

本章的 GitHub 代码位于此处:packt.link/1qP4P

任务

据估计,全球有超过 1000 万个非营利组织,尽管其中很大一部分有公共资金,但大多数组织主要依赖私人捐赠者,包括企业和个人,以继续运营。因此,筹款是至关重要的任务,并且全年都在进行。

年复一年,捐款收入有所增长,但非营利组织面临一些问题:捐赠者的兴趣在变化,因此一年受欢迎的慈善机构可能在下一年被遗忘;非营利组织之间的竞争激烈,人口结构也在变化。在美国,平均捐赠者每年只捐赠两次慈善礼物,且年龄超过 64 岁。识别潜在捐赠者具有挑战性,而且吸引他们的活动可能成本高昂。

一个国家级退伍军人组织非营利分支拥有大约 190,000 名往届捐赠者的庞大邮件列表,并希望发送一份特别邮件请求捐款。然而,即使有特殊的批量折扣率,每地址的成本也高达 0.68 美元。这总计超过 130,000 美元。他们的市场预算只有 35,000 美元。鉴于他们已将此事列为高优先级,他们愿意扩展预算,但前提是投资回报率ROI)足够高,以证明额外成本是合理的。

为了最大限度地减少使用他们有限的预算,他们希望尝试直接邮寄,目的是利用已知的信息来识别潜在捐赠者,例如过去的捐赠、地理位置和人口统计数据。他们将通过电子邮件联系其他捐赠者,这要便宜得多,整个列表的月成本不超过 1,000 美元。他们希望这种混合营销计划能产生更好的结果。他们还认识到,高价值捐赠者对个性化的纸质邮件响应更好,而较小的捐赠者无论如何对电子邮件的响应更好。

最多只有 6%的邮件列表捐赠者会对任何特定的活动进行捐赠。使用机器学习预测人类行为绝非易事,尤其是在数据类别不平衡的情况下。尽管如此,成功不是以最高的预测准确性来衡量的,而是以利润提升来衡量。换句话说,在测试数据集上评估的直接邮寄模型应该产生比如果他们向整个数据集进行群发邮件更多的利润。

他们寻求您的帮助,使用机器学习(ML)来生成一个模型,以识别最可能的捐赠者,但同时也保证一个高的 ROI。

您收到了非营利组织的数据集,该数据集大约平均分为训练数据和测试数据。如果您向测试数据集中的所有人发送邮件,您将获得 11,173 美元的利润,但如果您能够仅识别那些会捐赠的人,最大收益将达到 73,136 美元。您的目标是实现高利润提升和合理的 ROI。当活动进行时,它将识别整个邮件列表中最可能的捐赠者,非营利组织希望总支出不超过 35,000 美元。然而,数据集有 435 个列,一些简单的统计测试和建模练习表明,由于过度拟合,数据过于嘈杂,无法识别潜在捐赠者的可靠性。

方法

你决定首先使用所有特征拟合一个基础模型,并在不同的复杂度级别上评估它,以了解特征数量增加与预测模型过度拟合训练数据之间的关联。然后,你将采用一系列从简单的基于过滤的方法到最先进的方法的特征选择方法,以确定哪种方法实现了客户寻求的盈利性和可靠性目标。最后,一旦选定了最终特征列表,你就可以尝试特征工程。

由于问题的成本敏感性,阈值对于优化利润提升至关重要。我们将在稍后讨论阈值的作用,但一个显著的影响是,尽管这是一个分类问题,最好使用回归模型,然后使用预测来分类,这样只有一个阈值需要调整。也就是说,对于分类模型,你需要一个用于标签的阈值,比如那些捐赠超过 1 美元的,然后还需要另一个用于预测概率的阈值。另一方面,回归预测捐赠金额,阈值可以根据这个进行优化。

准备工作

此示例的代码可以在github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/blob/main/10/Mailer.ipynb找到。

加载库

要运行此示例,我们需要安装以下库:

  • 使用mldatasets加载数据集

  • 使用pandasnumpyscipy来操作它

  • 使用mlxtendsklearn-genetic-optxgboostsklearn(scikit-learn)来拟合模型

  • 使用matplotlibseaborn创建和可视化解释

要加载库,请使用以下代码块:

import math
import os
import mldatasets
import pandas as pd
import numpy as np
import timeit
from tqdm.notebook import tqdm
from sklearn.feature_selection import VarianceThreshold,\
                                    mutual_info_classif, SelectKBest
from sklearn.feature_selection import SelectFromModel
from sklearn.linear_model import LogisticRegression,\
                                    LassoCV, LassoLarsCV, LassoLarsIC
from mlxtend.feature_selection import SequentialFeatureSelector
from sklearn.feature_selection import RFECV
from sklearn.decomposition import PCA import shap
from sklearn-genetic-opt import GAFeatureSelectionCV
from scipy.stats import rankdata
from sklearn.discriminant_analysis import
LinearDiscriminantAnalysis
from sklearn.ensemble import RandomForestRegressor
import xgboost as xgb
import matplotlib.pyplot as plt
import seaborn as sns 

接下来,我们将加载并准备数据集。

理解和准备数据

我们将数据这样加载到两个 DataFrame(X_trainX_test)中,其中包含特征,以及两个相应的numpy数组标签(y_trainy_test)。请注意,这些 DataFrame 已经为我们预先准备,以删除稀疏或不必要的特征,处理缺失值,并对分类特征进行编码:

X_train, X_test, y_train, y_test = mldatasets.load(
    "nonprofit-mailer",
    prepare=True
)
y_train = y_train.squeeze()
y_test = y_test.squeeze() 

所有特征都是数值型,没有缺失值,并且分类特征已经为我们进行了一热编码。在训练和测试邮件列表之间,应有超过 191,500 条记录和 435 个特征。你可以这样检查:

print(X_train.shape)
print(y_train.shape)
print(X_test.shape)
print(y_test.shape) 

上一段代码应该输出以下内容:

(95485, 435)
(95485,)
(96017, 435)
(96017,) 

接下来,我们可以使用变量成本 0.68(var_cost)验证测试标签是否有正确的捐赠者数量(test_donors)、捐赠金额(test_donations)和假设的利润范围(test_min_profittest_max_profit)。我们可以打印这些信息,然后对训练数据集做同样的操作:

var_cost = 0.68
y_test_donors = y_test[y_test > 0]
test_donors = len(y_test_donors)
test_donations = sum(y_test_donors)
test_min_profit = test_donations - (len(y_test)*var_cost)
test_max_profit = test_donations - (test_donors*var_cost)
print(
    '%s test donors totaling $%.0f (min profit: $%.0f,\
    max profit: $%.0f)'
    %(test_donors, test_donations, test_min_profit,\
       test_max_profit))
y_train_donors = y_train[y_train > 0]
train_donors = len(y_train_donors)
train_donations = sum(y_train_donors)
train_min_profit = train_donations – (len(y_train)*var_cost)
train_max_profit = train_donations – (train_donors*var_cost)
print(
    '%s train donors totaling $%.0f (min profit: $%.0f,\
    max profit: $%.0f)'
    %(train_donors, train_donations, train_min_profit,\
    train_max_profit)) 

上一段代码应该输出以下内容:

4894 test donors totaling $76464 (min profit: $11173, max profit: $73136)
4812 train donors totaling $75113 (min profit: $10183, max profit: $71841) 

事实上,如果非营利组织向测试邮件列表上的每个人大量邮寄,他们可能会获得大约 11,000 美元的利润,但为了实现这一目标,他们必须严重超支。非营利组织认识到,通过仅识别和针对捐赠者来获得最大利润几乎是一项不可能完成的任务。因此,他们宁愿生产一个能够可靠地产生超过最低利润但成本更低的模型,最好是低于预算。

理解无关特征的影响

特征选择也称为变量属性选择。这是你可以自动或手动选择一组对构建机器学习模型有用的特定特征的方法。

并非更多的特征就一定能导致更好的模型。无关特征可能会影响学习过程,导致过拟合。因此,我们需要一些策略来移除可能对学习产生不利影响的任何特征。选择较小特征子集的一些优点包括以下内容:

  • 理解简单的模型更容易:例如,对于使用 15 个变量的模型,其特征重要性比使用 150 个变量的模型更容易理解。

  • 缩短训练时间:减少变量的数量可以降低计算成本,加快模型训练速度,而且最值得注意的是,简单的模型具有更快的推理时间。

  • 通过减少过拟合来提高泛化能力:有时,预测价值很小,许多变量只是噪声。然而,机器学习模型却会从这些噪声中学习,并在最小化泛化的同时触发对训练数据的过拟合。通过移除这些无关或噪声特征,我们可以显著提高机器学习模型的泛化能力。

  • 变量冗余:数据集中存在共线性特征是很常见的,这可能意味着某些特征是冗余的。在这些情况下,只要没有丢失显著信息,我们只需保留一个相关的特征,删除其他特征即可。

现在,我们将拟合一些模型来展示过多特征的影响。

创建基础模型

让我们为我们的邮件列表数据集创建一个基础模型,看看这将如何展开。但首先,让我们设置随机种子以确保可重复性:

rand = 9
os.environ['PYTHONHASHSEED']=str(rand)
np.random.seed(rand) 

在本章中,我们将使用 XGBoost 的随机森林RF)回归器(XGBRFRegressor)。它就像 scikit-learn 一样,但更快,因为它使用了目标函数的二阶近似。它还有更多选项,例如设置学习率和单调约束,这些在第十二章单调约束和模型调优以提高可解释性中进行了考察。我们以保守的初始max_depth4初始化XGBRFRegressor,并始终使用200估计量以确保一致性。然后,我们使用我们的训练数据对其进行拟合。我们将使用timeit来测量它需要多长时间,并将其保存在变量(baseline_time)中供以后参考:

stime = timeit.default_timer()
reg_mdl = xgb.XGBRFRegressor(max_depth=4, n_estimators=200, seed=rand)
fitted_mdl = reg_mdl.fit(X_train, y_train)
etime = timeit.default_timer()
baseline_time = etime-stime 

现在我们已经有一个基础模型了,让我们来评估它。

评估模型

接下来,让我们创建一个字典(reg_mdls)来存放我们将在本章中拟合的所有模型,以测试哪些特征子集会产生最好的模型。在这里,我们可以使用evaluate_reg_mdl来评估具有所有特征和max_depth值为4的随机森林模型(rf_4_all)。它将生成一个总结和一个带有回归线的散点图:

reg_mdls = {}
reg_mdls['rf_4_all'] = mldatasets.evaluate_reg_mdl(
    fitted_mdl,
    X_train,
    X_test,
    y_train,
    y_test,
    plot_regplot=True,
    ret_eval_dict=True
) 

之前的代码生成了图 10.1中显示的指标和图表:

图表描述自动生成

图 10.1:基础模型的预测性能

对于像图 10.1这样的图表,通常期望看到一条对角线,所以一眼看去就能判断出这个模型不具备预测性。此外,均方根误差(RMSEs)可能看起来并不糟糕,但在这种不平衡的问题背景下,它们却是令人沮丧的。考虑一下这个情况:只有 5%的人捐赠,而其中只有 20%的人捐赠额超过 20 美元,所以平均误差 4.3 美元至 4.6 美元是巨大的。

那么,这个模型有没有用呢?答案在于我们用它来分类所使用的阈值。让我们首先定义一个从$0.40 到$25 的阈值数组(threshs),我们首先以每 0.01 美元的间隔来设置这些阈值,直到达到$1,然后以每 0.1 美元的间隔设置,直到达到$3,之后以每 1 美元的间隔设置:

threshs = np.hstack(
    [
      np.linspace(0.40,1,61),
      np.linspace(1.1,3,20),
      np.linspace(4,25,22)
    ]
) 

mldatasets中有一个函数可以计算每个阈值下的利润(profits_by_thresh)。它只需要实际的(y_test)和预测标签,然后是阈值(threshs)、可变成本(var_costs)和所需的min_profit。只要利润高于min_profit,它就会生成一个包含每个阈值的收入、成本、利润和投资回报率的pandas DataFrame。记住,我们在本章开始时将这个最低值设置为$11,173,因为针对低于这个金额的捐赠者是没有意义的。在为测试和训练数据集生成这些利润 DataFrame 之后,我们可以将这些最大和最小金额放入模型的字典中,以供以后使用。然后,我们使用compare_df_plots来绘制每个阈值的测试和训练的成本、利润和投资回报率比率,只要它超过了利润最低值:

y_formatter = plt.FuncFormatter(
    lambda x, loc: "${:,}K".format(x/1000)
)
profits_test = mldatasets.profits_by_thresh(
    y_test,
    reg_mdls['rf_4_all']['preds_test'],
    threshs,
    var_costs=var_cost,
    min_profit=test_min_profit
)
profits_train = mldatasets.profits_by_thresh(
    y_train,
    reg_mdls['rf_4_all']['preds_train'],
    threshs,
    var_costs=var_cost,
    min_profit=train_min_profit
)
reg_mdls['rf_4_all']['max_profit_train'] =profits_train.profit.max()
reg_mdls['rf_4_all']['max_profit_test'] = profits_test.profit.max()
reg_mdls['rf_4_all']['max_roi'] = profits_test.roi.max()
reg_mdls['rf_4_all']['min_costs'] = profits_test.costs.min()
reg_mdls['rf_4_all']['profits_train'] = profits_train
reg_mdls['rf_4_all']['profits_test'] = profits_test
mldatasets.compare_df_plots(
    profits_test[['costs', 'profit', 'roi']],
    profits_train[['costs', 'profit', 'roi']],
    'Test',
    'Train',
    y_formatter=y_formatter,
    x_label='Threshold',\
    plot_args={'secondary_y':'roi'}
) 
Figure 10.2. You can tell that Test and Train are almost identical. Costs decrease steadily at a high rate and profit at a lower rate, while ROI increases steadily. However, some differences exist, such as ROI, which becomes a bit higher eventually, and although viable thresholds start at the same point, Train does end at a different threshold. It turns out the model can turn a profit, so despite the appearance of the plot in *Figure 10.1*, the model is far from useless:

图表,折线图描述自动生成

图 10.2:测试和训练数据集在阈值下基础模型的利润、成本和投资回报率比较

训练集和测试集的 RMSEs 差异是真实的。模型没有过拟合。主要原因是我们通过将max_depth值设置为4使用了相对较浅的树。我们可以通过计算有多少特征的feature_importances_值超过 0 来轻易地看到使用浅树的效果:

reg_mdls['rf_4_all']['total_feat'] =\
    reg_mdls['rf_4_all']['fitted'].feature_importances_.shape[0] reg_mdls['rf_4_all']['num_feat'] = sum(
    reg_mdls['rf_4_all']['fitted'].feature_importances_ > 0
)
print(reg_mdls['rf_4_all']['num_feat']) 

之前的代码输出160。换句话说,只有 160 个在 435 个中使用了——在这样的浅树中只能容纳这么多的特征!自然地,这会导致降低过度拟合,但与此同时,在具有杂质度量的特征与随机选择特征之间的选择并不一定是最佳选择。

在不同的最大深度下训练基础模型

那么,如果我们使树更深会发生什么?让我们重复之前为浅层模型所做的所有步骤,但这次的最大深度在 5 到 12 之间:

for depth in tqdm(range(5, 13)):
mdlname = 'rf_'+str(depth)+'_all'
stime = timeit.default_timer()
reg_mdl = xgb.XGBRFRegressor(
    max_depth=depth,
    n_estimators=200,
    seed=rand
)
fitted_mdl = reg_mdl.fit(X_train, y_train)
etime = timeit.default_timer()
reg_mdls[mdlname] = mldatasets.evaluate_reg_mdl(
    fitted_mdl,
    X_train,
    X_test,
    y_train,
    y_test,
    plot_regplot=False,
    show_summary=False,
    ret_eval_dict=True
)
reg_mdls[mdlname]['speed'] = (etime - stime)/baseline_time
reg_mdls[mdlname]['depth'] = depth
reg_mdls[mdlname]['fs'] = 'all'
profits_test = mldatasets.profits_by_thresh(
    y_test,
    reg_mdls[mdlname]['preds_test'],
    threshs,
    var_costs=var_cost,
    min_profit=test_min_profit
)
profits_train = mldatasets.profits_by_thresh(
    y_train,
    reg_mdls[mdlname]['preds_train'],
    threshs,
    var_costs=var_cost,
    min_profit=train_min_profit
)
reg_mdls[mdlname]['max_profit_train'] = profits_train.profit.max()
reg_mdls[mdlname]['max_profit_test'] = profits_test.profit.max()
reg_mdls[mdlname]['max_roi'] = profits_test.roi.max()
reg_mdls[mdlname]['min_costs'] = profits_test.costs.min()
reg_mdls[mdlname]['profits_train'] = profits_train
reg_mdls[mdlname]['profits_test'] = profits_test
reg_mdls[mdlname]['total_feat'] =\
reg_mdls[mdlname]['fitted'].feature_importances_.shape[0]
reg_mdls[mdlname]['num_feat'] = sum(
    reg_mdls[mdlname]['fitted'].feature_importances_ > 0) 

现在,让我们像之前使用 compare_df_plots 一样,绘制“最深”模型(最大深度为 12)的利润 DataFrame 的细节,生成图 10.3

图表,折线图描述自动生成

图 10.3:比较测试和训练数据集对于“深”基础模型在阈值下的利润、成本和 ROI

看看这次图 10.3中不同的测试训练测试达到约 15,000 的最大值,而训练超过 20,000。训练的成本大幅下降,使得投资回报率比测试高几个数量级。此外,阈值范围也大不相同。你可能会问,这为什么会成为问题?如果我们必须猜测使用什么阈值来选择在下一封邮件中要针对的目标,训练的最佳阈值高于测试——这意味着使用过度拟合的模型,我们可能会错过目标,并在未见过的数据上表现不佳。

接下来,让我们将我们的模型字典(reg_mdls)转换为 DataFrame,并从中提取一些细节。然后,我们可以按深度排序它,格式化它,用颜色编码它,并输出它:

def display_mdl_metrics(reg_mdls, sort_by='depth', max_depth=None):
    reg_metrics_df = pd.DataFrame.from_dict( reg_mdls, 'index')\
                        [['depth', 'fs', 'rmse_train', 'rmse_test',\
                          'max_profit_train',\
                          'max_profit_test', 'max_roi',\
                          'min_costs', 'speed', 'num_feat']]
    pd.set_option('precision', 2) 
    html = reg_metrics_df.sort_values(
        by=sort_by, ascending=False).style.\
        format({'max_profit_train':'${0:,.0f}',\
        'max_profit_test':'${0:,.0f}', 'min_costs':'${0:,.0f}'}).\
        background_gradient(cmap='plasma', low=0.3, high=1,
                            subset=['rmse_train', 'rmse_test']).\
        background_gradient(cmap='viridis', low=1, high=0.3,
                            subset=[
                                'max_profit_train', 'max_profit_test'
                                ]
                            )
    return html
display_mdl_metrics(reg_mdls) 
display_mdl_metrics function to output the DataFrame shown in *Figure 10.4*. Something that should be immediately visible is how RMSE train and RMSE test are inverses. One decreases dramatically, and another increases slightly as the depth increases. The same can be said for profit. ROI tends to increase with depth and training speed and the number of features used as well:

表格描述自动生成

图 10.4:比较所有基础 RF 模型在不同深度下的指标

我们可能会倾向于使用具有最高盈利能力的 rf_11_all,但使用它是有风险的!一个常见的误解是,黑盒模型可以有效地消除任何数量的无关特征。虽然它们通常能够找到有价值的东西并充分利用它,但过多的特征可能会因为过度拟合训练数据集中的噪声而降低它们的可靠性。幸运的是,存在一个甜蜜点,你可以以最小的过度拟合达到高盈利能力,但为了达到这一点,我们首先必须减少特征的数量!

检查基于过滤的特征选择方法

基于过滤的方法独立地从数据集中选择特征,而不使用任何机器学习。这些方法仅依赖于变量的特征,并且相对有效、计算成本低、执行速度快。因此,作为特征选择方法的低垂之果,它们通常是任何特征选择流程的第一步。

基于过滤的方法可以分为:

  • 单变量:它们独立于特征空间,一次评估和评级一个特征。单变量方法可能存在的问题是,由于它们没有考虑特征之间的关系,可能会过滤掉太多信息。

  • 多元性:这些方法考虑整个特征空间以及特征之间的相互作用。

总体而言,对于移除过时、冗余、常数、重复和不相关的特征,过滤方法非常有效。然而,由于它们没有考虑到只有机器学习模型才能发现的复杂、非线性、非单调的相关性和相互作用,当这些关系在数据中突出时,它们并不有效。

我们将回顾三种基于过滤的方法:

  • 基础

  • 相关性

  • 排序

我们将在各自的章节中进一步解释它们。

基础过滤方法

在数据准备阶段,我们采用基本过滤方法,特别是在任何建模之前的数据清洗阶段。这样做的原因是,做出可能对模型产生不利影响的特征选择决策的风险很低。这些方法涉及常识性操作,例如移除不携带信息或重复信息的特征。

带有方差阈值的常数特征

常数特征在训练数据集中不发生变化,因此不携带任何信息,模型无法从中学习。我们可以使用一个名为VarianceThreshold的单变量方法,它移除低方差特征。我们将使用零作为阈值,因为我们只想过滤掉具有零方差的特征——换句话说,就是常数特征。它仅适用于数值特征,因此我们必须首先确定哪些特征是数值的,哪些是分类的。一旦我们将方法拟合到数值列上,get_support()返回的不是常数特征的列表,我们可以使用集合代数来返回仅包含常数特征的集合(num_const_cols):

num_cols_l = X_train.select_dtypes([np.number]).columns
cat_cols_l = X_train.select_dtypes([np.bool, np.object]).columns
num_const = VarianceThreshold(threshold=0)
num_const.fit(X_train[num_cols_l])
num_const_cols = list(
    set(X_train[num_cols_l].columns) -
    set(num_cols_l[num_const.get_support()])
) 
nunique() function on categorical features. It will return a pandas series, and then a lambda function can filter out only those with one unique value. Then, .index.tolist() returns the name of the features as a list. Now, we just join both lists of constant features, and voilà! We have all constants (all_const_cols). We can print them; there should be three:
cat_const_cols = X_train[cat_cols_l].nunique()[lambda x:\
                                               x<2].index.tolist()
all_const_cols = num_const_cols + cat_const_cols
print(all_const_cols) 

在大多数情况下,仅移除常数特征是不够的。一个冗余特征可能几乎是常数或准常数

带有 value_counts 的准常数特征

准常数特征几乎都是相同的值。与常数过滤不同,使用方差阈值不会起作用,因为高方差和准常数性不是互斥的。相反,我们将迭代所有特征并获取 value_counts(),它返回每个值的行数。然后,将这些计数除以总行数以获得百分比,并按最高百分比排序。如果最高值高于预先设定的阈值(thresh),则将其追加到准常数列列表(quasi_const_cols)中。请注意,选择此阈值必须非常谨慎,并且需要对问题有深入的理解。例如,在这种情况下,我们知道这是不平衡的,因为只有 5% 的人捐赠,其中大多数人捐赠的金额很低,所以即使是特征的一小部分也可能产生影响,这就是为什么我们的阈值如此之高,达到 99.9%:

thresh = 0.999
quasi_const_cols = []
num_rows = X_train.shape[0]
for col in tqdm(X_train.columns):
    top_val = (
        X_train[col].value_counts() / num_rows
        ).sort_values(ascending=False).values[0]
    if top_val >= thresh:
        quasi_const_cols.append(col)
print(quasi_const_cols) 

前面的代码应该已经打印出五个特征,其中包括之前获得的三个。接下来,我们将处理另一种形式的不相关特征:重复项!

重复特征

通常,当我们讨论数据中的重复项时,我们首先想到的是重复的行,但重复的列也是问题所在。我们可以像查找重复行一样找到它们,使用 pandas duplicated() 函数,但首先需要将 DataFrame 转置,反转列和行:

X_train_transposed = X_train.T
dup_cols = X_train_transposed[
    X_train_transposed.duplicated()].index.tolist()
print(dup_cols) 

前面的代码片段输出了一个包含两个重复列的列表。

移除不必要的特征

与其他特征选择方法不同,您应该用模型测试这些方法,您可以直接通过移除您认为无用的特征来应用基于基本过滤的特征选择方法。但以防万一,制作原始数据的副本是一个好习惯。请注意,我们不将常数列(all_constant_cols)包括在我们打算删除的列(drop_cols)中,因为准常数列已经包含它们:

X_train_orig = X_train.copy()
X_test_orig = X_test.copy()
drop_cols = quasi_const_cols + dup_cols
X_train.drop(labels=drop_cols, axis=1, inplace=True)
X_test.drop(labels=drop_cols, axis=1, inplace=True) 

接下来,我们将探索剩余特征上的多变量过滤方法。

基于相关性的过滤方法

基于相关性的过滤方法量化两个特征之间关系的强度。这对于特征选择很有用,因为我们可能想要过滤掉高度相关的特征或那些与其他特征完全不相关的特征。无论如何,它是一种多变量特征选择方法——更确切地说,是双变量特征选择方法。

但首先,我们应该选择一个相关性方法:

  • 皮尔逊相关系数:衡量两个特征之间的线性相关性。它输出一个介于 -1(负相关)和 1(正相关)之间的系数,0 表示没有线性相关性。与线性回归类似,它假设线性、正态性和同方差性——也就是说,线性回归线周围的误差项在所有值中大小相似。

  • 斯皮尔曼秩相关系数:衡量两个特征单调性的强度,无论它们是否线性相关。单调性是指一个特征增加时,另一个特征持续增加或减少的程度。它在-1 和 1 之间衡量,0 表示没有单调相关性。它不做分布假设,可以与连续和离散特征一起使用。然而,它的弱点在于非单调关系。

  • 肯德尔 tau 相关系数:衡量特征之间的序数关联——也就是说,它计算有序数字列表之间的相似性。它也介于-1 和 1 之间,但分别代表低和高。对于离散特征来说,它很有用。

数据集是连续和离散的混合,我们不能对其做出任何线性假设,因此spearman是正确的选择。尽管如此,所有三个都可以与pandascorr函数一起使用:

corrs = X_train.corr(method='spearman')
print(corrs.shape) 

前面的代码应该输出相关矩阵的形状,即(428, 428)。这个维度是有意义的,因为还剩下 428 个特征,每个特征都与 428 个特征有关,包括它自己。

我们现在可以在相关矩阵(corrs)中寻找要删除的特征。请注意,为了做到这一点,我们必须建立阈值。例如,我们可以说一个高度相关的特征具有超过 0.99 的绝对值系数,而对于一个不相关的特征则小于 0.15。有了这些阈值,我们可以找到只与一个特征相关并且与多个特征高度相关的特征。为什么是一个特征?因为在相关矩阵的对角线总是 1,因为一个特征总是与自己完美相关。以下代码中的lambda函数确保我们考虑到这一点:

extcorr_cols = (abs(corrs) > 0.99).sum(axis=1)[lambda x: x>1]\
.index.tolist()
print(extcorr_cols)
uncorr_cols = (abs(corrs) > 0.15).sum(axis=1)[lambda x: x==1]\
.index.tolist()
print(uncorr_cols) 

前面的代码以如下方式输出两个列表:

['MAJOR', 'HHAGE1', 'HHAGE3', 'HHN3', 'HHP1', 'HV1', 'HV2', 'MDMAUD_R', 'MDMAUD_F', 'MDMAUD_A']
['TCODE', 'MAILCODE', 'NOEXCH', 'CHILD03', 'CHILD07', 'CHILD12', 'CHILD18', 'HC15', 'MAXADATE'] 

第一个列表包含与除自身以外的其他特征高度相关的特征。虽然了解这一点很有用,但你不应在没有理解它们与哪些特征以及如何相关,以及与目标相关的情况下从该列表中删除特征。然后,只有在发现冗余的情况下,确保只删除其中一个。第二个列表包含与除自身以外的任何其他特征都不相关的特征,鉴于特征的数量众多,这在当前情况下是可疑的。话虽如此,我们也应该逐个检查它们,特别是要衡量它们与目标的相关性,看看它们是否冗余。然而,我们将冒险排除不相关的特征,创建一个特征子集(corr_cols):

corr_cols = X_train.columns[
    ~X_train.columns.isin(uncorr_cols)
].tolist()
print(len(corr_cols)) 

前面的代码应该输出419。现在让我们只使用这些特征来拟合 RF 模型。鉴于仍有超过 400 个特征,我们将使用max_depth值为11。除了这一点和一个不同的模型名称(mdlname)之外,代码与之前相同:

mdlname = 'rf_11_f-corr'
stime = timeit.default_timer()
reg_mdl = xgb.XGBRFRegressor(
    max_depth=11,
    n_estimators=200,
    seed=rand
)
fitted_mdl = reg_mdl.fit(X_train[corr_cols], y_train)
reg_mdls[mdlname]['num_feat'] = sum(
    reg_mdls[mdlname]['fitted'].feature_importances_ > 0
) 

在比较前面模型的输出结果之前,让我们了解一下排名滤波方法。

基于排序过滤的方法

基于排序过滤的方法基于统计单变量排序测试,这些测试评估特征与目标之间的依赖强度。这些是一些最受欢迎的方法:

  • 方差分析 F 检验ANOVA)F 检验衡量特征与目标之间的线性依赖性。正如其名所示,它是通过分解方差来做到这一点的。它做出了与线性回归类似的假设,例如正态性、独立性和同方差性。在 scikit-learn 中,您可以使用 f_regressionf_classification 分别对回归和分类进行排序,以 F 检验产生的 F 分数来排序特征。

  • 卡方检验独立性:这个测试衡量非负分类变量与二元目标之间的关联性,因此它只适用于分类问题。在 scikit-learn 中,您可以使用 chi2

  • 互信息MI):与前面两种方法不同,这种方法是从信息理论而不是经典统计假设检验中推导出来的。虽然名称不同,但这个概念我们在本书中已经讨论过,称为库尔巴克-莱布勒KL散度,因为它是对特征 X 和目标 Y 的 KL。scikit-learn 中的 Python 实现使用了一个数值稳定的对称 KL 衍生品,称为Jensen-ShannonJS)散度,并利用 k-最近邻来计算距离。可以使用 mutual_info_regressionmutual_info_classif 分别对回归和分类进行特征排序。

在提到的三种选项中,最适合这个数据集的是 MI,因为我们不能假设特征之间存在线性关系,而且其中大部分也不是分类数据。我们可以尝试使用阈值为 $0.68 的分类,这至少可以覆盖发送邮件的成本。为此,我们必须首先使用该阈值创建一个二元分类目标(y_train_class):

y_train_class = np.where(y_train > 0.68, 1, 0) 

接下来,我们可以使用 SelectKBest 根据互信息分类(MIC)获取前 160 个特征。然后我们使用 get_support() 获取一个布尔向量(或掩码),它告诉我们哪些特征在前 160 个中,并使用这个掩码对特征列表进行子集化:

mic_selection = SelectKBest(
    mutual_info_classif, k=160).fit(X_train, y_train_class)
mic_cols = X_train.columns[mic_selection.get_support()].tolist()
print(len(mic_cols)) 

前面的代码应该确认 mic_cols 列表中确实有 160 个特征。顺便说一下,这是一个任意数字。理想情况下,我们可以测试分类目标的不同阈值和 MI 的 k 值,寻找在最小过拟合的同时实现最高利润提升的模型。接下来,我们将使用与之前相同的 MIC 特征拟合 RF 模型。这次,我们将使用最大深度为 5,因为特征数量显著减少:

mdlname = 'rf_5_f-mic'
stime = timeit.default_timer()
reg_mdl = xgb.XGBRFRegressor(max_depth=5, n_estimators=200, seed=rand)
fitted_mdl = reg_mdl.fit(X_train[mic_cols], y_train)
reg_mdls[mdlname]['num_feat'] = sum(
    reg_mdls[mdlname]['fitted'].feature_importances_ > 0
) 

现在,让我们像在 图 10.3 中所做的那样绘制 测试训练 的利润,但这次是针对 MIC 模型。它将产生 图 10.5 中所示的内容:

图表,折线图  自动生成的描述

图 10.5:具有 MIC 特征的模型在阈值之间的利润、成本和 ROI 测试和训练数据集的比较

图 10.5中,你可以看出测试训练之间存在相当大的差异,但相似之处表明过拟合最小。例如,训练的最高盈利性可以在 0.66 和 0.75 之间找到,而测试主要在 0.66 和 0.7 之间,之后逐渐下降。

尽管我们已经视觉检查了 MIC 模型,但查看原始指标也是一种令人放心的方式。接下来,我们将使用一致的指标比较我们迄今为止训练的所有模型。

比较基于滤波的方法

我们已经将指标保存到一个字典(reg_mdls)中,我们很容易将其转换为 DataFrame,并像之前那样输出,但这次我们按max_profit_test排序:

display_mdl_metrics(reg_mdls, 'max_profit_test') 
Figure 10.6. It is evident that the filter MIC model is the least overfitted of all. It ranked higher than more complex models with more features and took less time to train than any model. Its speed is an advantage for hyperparameter tuning. What if we wanted to find the best classification target thresholds or MIC *k*? We won’t do this now, but we would likely get a better model if we ran every combination, but it would take time to do and even more with more features:

应用,表格,自动生成中等置信度的描述

图 10.6:比较所有基础模型和基于滤波的特征选择模型的指标

图 10.6中,我们可以看出,与具有更多特征和相同max_depth量的模型(rf_11_all)相比,相关滤波模型(rf_11_f-corr)的表现更差,这表明我们可能移除了一个重要的特征。正如该部分所警告的,盲目设置阈值并移除其上所有内容的问题在于你可能会无意中移除有用的东西。并非所有高度相关和无关的特征都是无用的,因此需要进一步检查。接下来,我们将探索一些嵌入方法,当与交叉验证结合使用时,需要更少的监督。

探索嵌入特征选择方法

嵌入方法存在于模型本身中,通过训练过程中自然选择特征。你可以利用具有这些特性的任何模型的内在属性来捕获所选特征:

  • 基于树的模型:例如,我们多次使用以下代码来计算 RF 模型使用的特征数量,这是学习过程中自然发生特征选择的证据:

    sum(reg_mdls[mdlname]['fitted'].feature_importances_ > 0) 
    

    XGBoost 的 RF 默认使用增益,这是在所有使用该特征进行特征重要性计算的分割中平均错误减少。我们可以将阈值提高到 0 以上,根据它们的相对贡献选择更少的特征。然而,通过限制树的深度,我们迫使模型选择更少的特征。

  • 具有系数的正则化模型:我们将在第十二章单调约束和模型调优以提高可解释性中进一步研究这个问题,但许多模型类可以采用基于惩罚的正则化,如 L1、L2 和弹性网络。然而,并非所有这些模型都具有可以提取以确定哪些特征被惩罚的内在参数,如系数。

本节将仅涵盖正则化模型,因为我们已经使用了一个基于树的模型。最好利用不同的模型类别来获得对哪些特征最重要的不同视角。

我们在第三章解释挑战中介绍了一些这些模型,但这些都是一些结合基于惩罚的正则化和输出特征特定系数的模型类别:

  • 最小绝对收缩和选择算子LASSO):因为它在损失函数中使用 L1 惩罚,所以 LASSO 可以将系数设置为 0。

  • 最小角度回归LARS):类似于 LASSO,但基于向量,更适合高维数据。它也对等相关的特征更加公平。

  • 岭回归:在损失函数中使用 L2 惩罚,因此只能将不相关的系数缩小到接近 0,但不能缩小到 0。

  • 弹性网络回归:使用 L1 和 L2 范数的混合作为惩罚。

  • 逻辑回归:根据求解器,它可以处理 L1、L2 或弹性网络惩罚。

之前提到的模型也有一些变体,例如LASSO LARS,它使用 LARS 算法进行 LASSO 拟合,或者甚至是LASSO LARS IC,它与前者相同,但在模型部分使用 AIC 或 BIC 准则:

  • 赤池信息准则AIC):基于信息理论的一种相对拟合优度度量

  • 贝叶斯信息准则BIC):与 AIC 具有相似的公式,但具有不同的惩罚项

好的,现在让我们使用SelectFromModel从 LASSO 模型中提取顶级特征。我们将使用LassoCV,因为它可以自动进行交叉验证以找到最优的惩罚强度。一旦拟合,我们就可以使用get_support()获取特征掩码。然后我们可以打印特征数量和特征列表:

lasso_selection = SelectFromModel(
    LassoCV(n_jobs=-1, random_state=rand)
)
lasso_selection.fit(X_train, y_train)
lasso_cols = X_train.columns[lasso_selection.get_support()].tolist()
print(len(lasso_cols))
print(lasso_cols) 

上一段代码输出以下内容:

7
['ODATEDW', 'TCODE', 'POP901', 'POP902', 'HV2', 'RAMNTALL', 'MAXRDATE'] 

现在,让我们尝试使用LassoLarsCV进行相同的操作:

llars_selection = SelectFromModel(LassoLarsCV(n_jobs=-1))
llars_selection.fit(X_train, y_train)
llars_cols = X_train.columns[llars_selection.get_support()].tolist()
print(len(llars_cols))
print(llars_cols) 

上一段代码生成以下输出:

8
['RECPGVG', 'MDMAUD', 'HVP3', 'RAMNTALL', 'LASTGIFT', 'AVGGIFT', 'MDMAUD_A', 'DOMAIN_SOCIALCLS'] 

LASSO 将除七个特征外的所有系数缩小到 0,而 LASSO LARS 也将八个系数缩小到 0。然而,请注意这两个列表之间没有重叠!好的,那么让我们尝试将 AIC 模型选择与 LASSO LARS 结合使用LassoLarsIC

llarsic_selection = SelectFromModel(LassoLarsIC(criterion='aic'))
llarsic_selection.fit(X_train, y_train)
llarsic_cols = X_train.columns[
    llarsic_selection.get_support()
].tolist()
print(len(llarsic_cols))
print(llarsic_cols) 

上一段代码生成以下输出:

111
['TCODE', 'STATE', 'MAILCODE', 'RECINHSE', 'RECP3', 'RECPGVG', 'RECSWEEP',..., 'DOMAIN_URBANICITY', 'DOMAIN_SOCIALCLS', 'ZIP_LON'] 

这是一种相同的算法,但采用了不同的方法来选择正则化参数的值。注意这种不那么保守的方法将特征数量扩展到 111 个。到目前为止,我们使用的方法都具有 L1 范数。让我们尝试一个使用 L2 的——更具体地说,是 L2 惩罚逻辑回归。我们做的是之前所做的,但这次,我们使用二元分类目标(y_train_class)进行拟合:

log_selection = SelectFromModel(
    LogisticRegression(
        C=0.0001,
        solver='sag',
        penalty='l2',
        n_jobs=-1,
        random_state=rand
    )
)
log_selection.fit(X_train, y_train_class)
log_cols = X_train.columns[log_selection.get_support()].tolist()
print(len(log_cols))
print(log_cols) 

上一段代码生成以下输出:

87
['ODATEDW', 'TCODE', 'STATE', 'POP901', 'POP902', 'POP903', 'ETH1', 'ETH2', 'ETH5', 'CHIL1', 'HHN2',..., 'AMT_7', 'ZIP_LON'] 

现在我们有几个特征子集要测试,我们可以将它们的名称放入一个列表(fsnames)中,将特征子集列表放入另一个列表(fscols)中:

fsnames = ['e-lasso', 'e-llars', 'e-llarsic', 'e-logl2']
fscols = [lasso_cols, llars_cols, llarsic_cols, log_cols] 

然后,我们可以遍历所有列表名称,并在每次迭代中增加max_depth,就像我们之前做的那样来拟合和评估我们的XGBRFRegressor模型:

def train_mdls_with_fs(reg_mdls, fsnames, fscols, depths):
    for i, fsname in tqdm(enumerate(fsnames), total=len(fsnames)):
       depth = depths[i]
       cols = fscols[i]
       mdlname = 'rf_'+str(depth)+'_'+fsname
       stime = timeit.default_timer()
       reg_mdl = xgb.XGBRFRegressor(
           max_depth=depth, n_estimators=200, seed=rand
       )
       fitted_mdl = reg_mdl.fit(X_train[cols], y_train)
       reg_mdls[mdlname]['num_feat'] = sum(
           reg_mdls[mdlname]['fitted'].feature_importances_ > 0
       )
train_mdls_with_fs(reg_mdls, fsnames, fscols, [3, 4, 5, 6]) 

现在,让我们看看我们的嵌入式特征选择模型与过滤模型相比的表现如何。我们将重新运行之前运行的代码,输出图 10.6中显示的内容。这次,我们将得到图 10.7中显示的内容:

表描述自动生成,置信度中等

图 10.7:比较所有基础模型和基于过滤和嵌入式特征选择模型的指标

根据图 10.7,我们尝试的四种嵌入式方法中有三种产生了具有最低测试 RMSE(rf_5_e-llarsicrf_e-lassorf_4_e-llars)的模型。它们也都比其他模型训练得快得多,并且比任何同等复杂性的模型都更有利可图。其中之一(rf_5_e-llarsic)甚至非常有利可图。与具有相似测试盈利能力的rf_9_all进行比较,看看性能如何从训练数据中偏离。

发现包装、混合和高级特征选择方法

到目前为止研究的特征选择方法在计算上成本较低,因为它们不需要模型拟合或拟合更简单的白盒模型。在本节中,我们将了解其他更全面的方法,这些方法具有许多可能的调整选项。这里包括的方法类别如下:

  • 包装:通过使用测量指标改进的搜索策略来拟合机器学习模型,彻底搜索最佳特征子集。

  • 混合:一种结合嵌入式和过滤方法以及包装方法的方法。

  • 高级:一种不属于之前讨论的任何类别的的方法。例如包括降维、模型无关特征重要性和遗传算法GAs)。

现在,让我们开始包装方法吧!

包装方法

包装方法背后的概念相当简单:评估特征的不同子集在机器学习模型上的表现,并选择在预定的目标函数上实现最佳得分的那个。这里变化的是搜索策略:

  • 顺序正向选择SFS):这种方法开始时没有特征,然后每次添加一个。

  • 顺序正向浮点选择SFFS):与之前相同,除了每次添加一个特征时,它可以移除一个特征,只要目标函数增加。

  • 顺序向后选择SBS):这个过程从所有特征都存在开始,每次消除一个特征。

  • 顺序浮点向后选择SFBS):与之前相同,除了每次移除一个特征时,它还可以添加一个特征,只要目标函数增加。

  • 穷举特征选择EFS):这种方法寻求所有可能的特征组合。

  • 双向搜索BDS):这个方法同时允许向前和向后进行函数选择,以获得一个独特的解决方案。

这些方法是贪婪算法,因为它们逐个解决问题,根据它们的即时利益选择部分。尽管它们可能达到全局最大值,但它们采取的方法更适合寻找局部最大值。根据特征的数量,它们可能过于计算密集,以至于不实用,特别是 EFS,它呈指数增长。另一个重要的区别是,向前方法随着特征的添加而提高准确性,而向后方法则随着特征的移除而监控准确性下降。为了缩短搜索时间,我们将做两件事:

  1. 我们从其他方法共同选出的特征开始搜索,以拥有更小的特征空间进行选择。为此,我们将来自几种方法的特征列表合并成一个单一的top_cols列表:

    top_cols = list(set(mic_cols).union(set(llarsic_cols)\
    ).union(set(log_cols)))
    len(top_cols) 
    
  2. 样本我们的数据集,以便机器学习模型加速。我们可以使用np.random.choice进行随机选择行索引,而不进行替换:

    sample_size = 0.1
    sample_train_idx = np.random.choice(
        X_train.shape[0],
        math.ceil(X_train.shape[0]*sample_size),
        replace=False
    )
    sample_test_idx = np.random.choice(
        X_test.shape[0],
        math.ceil(X_test.shape[0]*sample_size),
        replace=False
    ) 
    

在所提出的包装方法中,我们只执行 SFS,因为它们非常耗时。然而,对于更小的数据集,你可以尝试其他选项,这些选项mlextend库也支持。

顺序前向选择(SFS)

包装方法的第一参数是一个未拟合的估计器(一个模型)。在SequentialFeatureSelector中,我们放置了一个LinearDiscriminantAnalysis模型。其他参数包括方向(forward=true),是否浮动(floating=False),这意味着它可能会撤销之前对特征的排除或包含,我们希望选择的特征数量(k_features=27),交叉验证的数量(cv=3),以及要使用的损失函数(scoring=f1)。一些推荐的可选参数包括详细程度(verbose=2)和并行运行的工作数量(n_jobs=-1)。由于它可能需要一段时间,我们肯定希望它输出一些内容,并尽可能多地使用处理器:

sfs_lda = SequentialFeatureSelector(
    LinearDiscriminantAnalysis(n_components=1),
    forward=True,
    floating=False,
    k_features=100,
    cv=3,
    scoring='f1',
    verbose=2,
    n_jobs=-1
)
sfs_lda = sfs_lda.fit(X_train.iloc[sample_train_idx][top_cols],\
                      y_train_class[sample_train_idx])
sfs_lda_cols = X_train.columns[list(sfs_lda.k_feature_idx_)].tolist() 

一旦我们拟合了 SFS,它将返回使用k_feature_idx_选定的特征的索引,我们可以使用这些索引来子集列并获取特征名称列表。

混合方法

从 435 个特征开始,仅 27 个特征子集的组合就有超过 10⁴²种!所以,你可以看到在如此大的特征空间中 EFS 是如何不切实际的。因此,除了在整个数据集上使用 EFS 之外,包装方法不可避免地会采取一些捷径来选择特征。无论你是向前、向后还是两者都进行,只要你不评估每个特征的组合,你就很容易错过最佳选择。

然而,我们可以利用包装方法的更严格、更全面的搜索方法,同时结合筛选和嵌入方法的效率。这种方法的结果是混合方法。例如,你可以使用筛选或嵌入方法仅提取前 10 个特征,并在这些特征上仅执行 EFS 或 SBS。

递归特征消除(RFE)

另一种更常见的方法是 SBS,但它不是仅基于改进一个指标来删除特征,而是使用模型的内在参数来对特征进行排序,并仅删除排名最低的特征。这种方法被称为递归特征消除RFE),它是嵌入和包装方法之间的混合。我们只能使用具有feature_importances_或系数(coef_)的模型,因为这是该方法知道要删除哪些特征的方式。具有这些属性的 scikit-learn 模型类别被归类为linear_modeltreeensemble。此外,XGBoost、LightGBM 和 CatBoost 的 scikit-learn 兼容版本也具有feature_importances_

我们将使用交叉验证版本的递归特征消除(RFE),因为它更可靠。RFECV首先采用估计器(LinearDiscriminantAnalysis)。然后我们可以定义step,它设置每次迭代应删除多少特征,交叉验证的次数(cv),以及用于评估的指标(scoring)。最后,建议设置详细程度(verbose=2)并尽可能利用更多处理器(n_jobs=-1)。为了加快速度,我们将再次使用样本进行训练,并从top_cols的 267 开始:

rfe_lda = RFECV(
    LinearDiscriminantAnalysis(n_components=1),
    step=2, cv=3, scoring='f1', verbose=2, n_jobs=-1
)
rfe_lda.fit(
    X_train.iloc[sample_train_idx][top_cols],
    y_train_class[sample_train_idx]
)
rfe_lda_cols = np.array(top_cols)[rfe_lda.support_].tolist() 

接下来,我们将尝试与主要三个特征选择类别(筛选、嵌入和包装)无关的不同方法。

高级方法

许多方法可以归类为高级特征选择方法,包括以下子类别:

  • 模型无关特征重要性:任何在第四章全局模型无关解释方法中提到的特征重要性方法都可以用于获取模型的特征选择中的顶级特征。

  • 遗传算法:这是一种包装方法,因为它“包装”了一个评估多个特征子集预测性能的模型。然而,与我们所检查的包装方法不同,它并不总是做出最局部最优的选择。它更适合与大型特征空间一起工作。它被称为遗传算法,因为它受到了生物学的启发——自然选择,特别是。

  • 降维:一些降维方法,如主成分分析PCA),可以在特征基础上返回解释方差。对于其他方法,如因子分析,它可以从其他输出中推导出来。解释方差可以用于对特征进行排序。

  • 自动编码器:我们不会深入探讨这一点,但深度学习可以利用自动编码器进行特征选择。这种方法在 Google Scholar 上有许多变体,但在工业界并不广泛采用。

在本节中,我们将简要介绍前两种方法,以便您了解它们如何实现。让我们直接进入正题!

模型无关特征重要性

在这本书的整个过程中,我们使用的一个流行的模型无关特征重要性方法是 SHAP,它有许多属性使其比其他方法更可靠。在下面的代码中,我们可以使用TreeExplainer提取我们最佳模型的shap_values

fitted_rf_mdl = reg_mdls['rf_11_all']['fitted']
shap_rf_explainer = shap.TreeExplainer(fitted_rf_mdl)
shap_rf_values = shap_rf_explainer.shap_values(
    X_test_orig.iloc[sample_test_idx]
)
shap_imps = pd.DataFrame(
    {'col':X_train_orig.columns, 'imp':np.abs(shap_rf_values).mean(0)}
).sort_values(by='imp',ascending=False)
shap_cols = shap_imps.head(120).col.tolist() 

然后,SHAP 值绝对值的平均值在第一维上为我们提供了每个特征的排名。我们将这个值放入一个 DataFrame 中,并按我们为 PCA 所做的方式对其进行排序。最后,也将前 120 个放入一个列表(shap_cols)。

遗传算法

算法遗传学(GAs)是一种受自然选择启发的随机全局优化技术,它像包装方法一样包装一个模型。然而,它们不是基于一步一步的序列。GAs 没有迭代,但有代,包括染色体的种群。每个染色体是特征空间的二进制表示,其中 1 表示选择一个特征,0 表示不选择。每一代都是通过以下操作产生的:

  • 选择:就像自然选择一样,这部分是随机的(探索)和部分是基于已经有效的东西(利用)。有效的是其适应性。适应性是通过一个“scorer”来评估的,就像包装方法一样。适应性差的染色体被移除,而好的染色体则通过“交叉”繁殖。

  • 交叉:随机地,一些好的位(或特征)从每个父代传递给子代。

  • 变异:即使染色体已经证明有效,给定一个低的突变率,它偶尔也会突变或翻转其位之一,换句话说,特征。

我们将要使用的 Python 实现有许多选项。在这里我们不会解释所有这些选项,但如果您感兴趣,它们在代码中都有很好的文档说明。第一个属性是估计器。我们还可以定义交叉验证迭代次数(cv=3)和scoring来决定染色体是否适合。有一些重要的概率属性,例如突变位(mutation_probability)的概率和位交换(crossover_probability)的概率。在每一代中,n_gen_no_change提供了一种在代数没有改进时提前停止的手段,默认的generations是 40,但我们将使用 5。我们可以像任何模型一样拟合GeneticSelectionCV。这可能需要一些时间,因此最好定义详细程度并允许它使用所有处理能力。一旦完成,我们可以使用布尔掩码(support_)来子集特征:

ga_rf = GAFeatureSelectionCV(
    RandomForestRegressor(random_state=rand, max_depth=3),
    cv=3,
    scoring='neg_root_mean_squared_error',
    crossover_probability=0.8,
    mutation_probability=0.1,
    generations=5, n_jobs=-1
)
ga_rf = ga_rf.fit(
    X_train.iloc[sample_train_idx][top_cols].values,
    y_train[sample_train_idx]
)
ga_rf_cols = np.array(top_cols)[ga_rf.best_features_].tolist() 

好的,现在我们已经在本节中介绍了各种包装、混合和高级特征选择方法,让我们一次性评估它们并比较结果。

评估所有特征选择模型

正如我们对待嵌入方法一样,我们可以将特征子集名称 (fsnames)、列表 (fscols) 和相应的 depths 放入列表中:

fsnames = ['w-sfs-lda', 'h-rfe-lda', 'a-shap', 'a-ga-rf']
fscols = [sfs_lda_cols, rfe_lda_cols, shap_cols, ga_rf_cols]
depths = [5, 6, 5, 6] 

然后,我们可以使用我们创建的两个函数,首先遍历所有特征子集,用它们训练和评估一个模型。然后第二个函数输出评估结果,以 DataFrame 的形式包含先前训练的模型:

train_mdls_with_fs(reg_mdls, fsnames, fscols, depths) 
display_mdl_metrics(reg_mdls, 'max_profit_test', max_depth=7) 
Figure 10.8:

图形用户界面,应用程序描述自动生成

图 10.8:比较所有特征选择模型的指标

图 10.8 展示了与包含所有特征相比,特征选择模型在相同深度下的盈利能力更强。此外,嵌入的 LASSO LARS 与 AIC (e-llarsic) 方法和 MIC (f-mic) 过滤方法在相同深度下优于所有包装、混合和高级方法。尽管如此,我们还是通过使用训练数据集的一个样本来阻碍了这些方法,这是加快过程所必需的。也许在其他情况下,它们会优于最顶尖的模型。然而,接下来的三种特征选择方法竞争力相当强:

  • 基于 LDA 的 RFE:混合方法 (h-rfe-lda)

  • 带有 L2 正则化的逻辑回归:嵌入方法 (e-logl2)

  • 基于 RF 的 GAs:高级方法 (a-ga-rf)

在这本书中回顾的方法有很多变体,花费很多天去运行这些变体是有意义的。例如,也许 RFE 与 L1 正则化的逻辑回归或 GA 与支持向量机以及额外的突变会产生最佳模型。有如此多的不同可能性!然而,如果你被迫仅基于 图 10.8 中的利润来做出推荐,那么 111 特征的 e-llarsic 是最佳选择,但它也有比任何顶级模型更高的最低成本和更低的最高回报率。这是一个权衡。尽管它的测试 RMSE 值最高,但 160 特征的模型 (f-mic) 在最大利润训练和测试之间的差异相似,并且在最大回报率和最低成本方面超过了它。因此,这两个选项是合理的。但在做出最终决定之前,必须将不同阈值下的盈利能力进行比较,以评估每个模型在什么成本和回报率下可以做出最可靠的预测。

考虑特征工程

假设非营利组织选择了使用具有 LASSO LARS 与 AIC (e-llarsic) 选择特征的模型,但想评估你是否可以进一步改进它。现在你已经移除了可能只略微提高预测性能但主要增加噪声的 300 多个特征,你剩下的是更相关的特征。然而,你也知道,e-llars 选出的 8 个特征产生了与 111 个特征相同的 RMSE。这意味着虽然那些额外特征中有些东西可以提高盈利能力,但它并没有提高 RMSE。

从特征选择的角度来看,可以采取许多方法来解决这个问题。例如,检查e-llarsice-llars之间特征的交集和差异,并在这些特征上严格进行特征选择,以查看 RMSE 是否在任何组合中下降,同时保持或提高当前的盈利能力。然而,还有一种可能性,那就是特征工程。在这个阶段进行特征工程有几个重要的原因:

  • 使模型解释更容易理解:例如,有时特征有一个不直观的尺度,或者尺度是直观的,但分布使得理解变得困难。只要对这些特征的转换不会降低模型性能,转换特征以更好地理解解释方法的输出是有价值的。随着你在更多工程化特征上训练模型,你会意识到什么有效以及为什么有效。这将帮助你理解模型,更重要的是,理解数据。

  • 对单个特征设置护栏:有时,特征分布不均匀,模型倾向于在特征直方图的稀疏区域或存在重要异常值的地方过拟合。

  • 清理反直觉的交互:一些模型发现的不合逻辑的交互,仅因为特征相关,但并非出于正确的原因而存在。它们可能是混淆变量,甚至可能是冗余的(例如我们在第四章全局模型无关解释方法中找到的)。你可以决定设计一个交互特征或删除一个冗余的特征。

关于最后两个原因,我们将在第十二章单调约束和模型调优以实现可解释性中更详细地研究特征工程策略。本节将重点介绍第一个原因,尤其是因为它是一个很好的起点,因为它将允许你更好地理解数据,直到你足够了解它,可以做出更转型的改变。

因此,我们剩下 111 个特征,但不知道它们如何与目标或彼此相关。我们首先应该做的是运行一个特征重要性方法。我们可以在e-llarsic模型上使用 SHAP 的TreeExplainerTreeExplainer的一个优点是它可以计算 SHAP 交互值,shap_interaction_values。与shap_values输出一个(N, 111)维度的数组不同,其中N是观察数量,它将输出(N, 111, 111)。我们可以用它生成一个summary_plot图,该图对单个特征和交互进行排名。交互值唯一的区别是您使用plot_type="compact_dot"

winning_mdl = 'rf_5_e-llarsic'
fitted_rf_mdl = reg_mdls[winning_mdl]['fitted']
shap_rf_explainer = shap.TreeExplainer(fitted_rf_mdl)
shap_rf_interact_values = \
    shap_rf_explainer.shap_interaction_values(
        X_test.iloc[sample_test_idx][llarsic_cols]
    )
shap.summary_plot(
    shap_rf_interact_values,
    X_test.iloc[sample_test_idx][llarsic_cols],
    plot_type="compact_dot",
    sort=True
) 
Figure 10.9:

图形用户界面,应用程序,表格  自动生成的描述

图 10.9:SHAP 交互总结图

我们可以像阅读任何总结图一样阅读图 10.9,除了它包含了两次双变量交互——首先是一个特征,然后是另一个。例如,MDMAUD_A* - CLUSTER是从MDMAUD_A的角度来看该交互的交互 SHAP 值,因此特征值对应于该特征本身,但 SHAP 值是针对交互的。我们在这里可以达成一致的是,考虑到重要性值的规模和比较无序的双变量交互的复杂性,这个图很难阅读。我们将在稍后解决这个问题。

在这本书中,带有表格数据的章节通常以数据字典开始。这个例外是因为一开始有 435 个特征。现在,至少了解哪些是顶级特征是有意义的。完整的数据字典可以在kdd.ics.uci.edu/databases/kddcup98/epsilon_mirror/cup98dic.txt找到,但由于分类编码,一些特征已经发生了变化,因此我们将在这里更详细地解释它们:

  • MAXRAMNT: 连续型,迄今为止最大赠礼的美元金额

  • HVP2: 离散型,捐赠者社区中价值>= $150,000 的房屋比例(值在 0 到 100 之间)

  • LASTGIFT: 连续型,最近一次赠礼的美元金额

  • RAMNTALL: 连续型,迄今为止终身赠礼的美元金额

  • AVGGIFT: 连续型,迄今为止赠礼的平均美元金额

  • MDMAUD_A: 序数型,对于在其捐赠历史中任何时间点都捐赠了$100+赠礼的捐赠者的捐赠金额代码(值在 0 到 3 之间,对于从未超过$100 的捐赠者为-1)。金额代码是RFA最近/频率/金额)主要客户矩阵代码的第三个字节,即捐赠的金额。类别如下:

0:少于$100(低金额)

1:$100 – 499(核心)

2: $500 – 999 (major)

3: $1,000 + (top)

  • NGIFTALL: 离散型,迄今为止终身赠礼的数量

  • AMT_14: 序数型,14 次之前推广的 RFA 捐赠金额代码,这对应于当时最后一次捐赠的金额:

0: $0.01 – 1.99

1: $2.00 – 2.99

2: $3.00 – 4.99

3: $5.00 – 9.99

4: $10.00 – 14.99

5: $15.00 – 24.99

6: $25.00 及以上

  • DOMAIN_SOCIALCLS: 名义型,社会经济地位SES)的社区,它与DOMAIN_URBANICITY(0:城市,1:城市,2:郊区,3:镇,4:农村)结合,意味着以下:

1: 最高社会经济地位

2: 平均社会经济地位,但城市社区的平均水平以上

3: 最低社会经济地位,但城市社区的平均水平以下

4: 仅城市社区最低社会经济地位

  • CLUSTER: 名义型,表示捐赠者所属的集群组的代码

  • MINRAMNT: 连续型,迄今为止最小赠礼的美元金额

  • LSC2: 离散型,捐赠者社区中西班牙语家庭的比例(值在 0 到 100 之间)

  • IC15:离散值,捐赠者所在地区家庭收入低于$15,000 的家庭百分比(值在 0 到 100 之间)

可以从前面的字典和图 10.9中提炼出以下见解:

  • 赠款金额优先:其中七个顶级功能与赠款金额相关,无论是总额、最小值、最大值、平均值还是最后值。如果你包括赠款总数(NGIFTALL),则有八个特征涉及捐赠历史,这完全合理。那么,这有什么相关性呢?因为这些特征很可能高度相关,理解它们可能是提高模型的关键。也许可以创建其他特征,更好地提炼这些关系。

  • 连续赠款金额特征的值较高具有高 SHAP 值:像这样绘制任何这些特征的箱线图,例如plt.boxplot(X_test.MAXRAMNT),你会看到这些特征是如何右偏斜的。也许通过将它们分成区间——称为“离散化”——或使用不同的尺度,如对数尺度(尝试plt.boxplot(np.log(X_test.MAXRAMNT))),可以帮助解释这些特征,同时也有助于找到捐赠可能性显著增加的区域。

  • 与第十四次促销的关系:他们在两年前进行的促销与数据集标签中标记的促销之间发生了什么?促销材料是否相似?是否每两年发生一次季节性因素?也许你可以设计一个特征来更好地识别这种现象。

  • 分类不一致DOMAIN_SOCIALCLS根据DOMAIN_URBANITY值的不同而具有不同的类别。我们可以通过使用量表中的所有五个类别(最高、高于平均水平、平均水平、低于平均水平、最低)来使这一分类一致,即使这意味着非城市捐赠者只会使用三个类别。这样做的好处是更容易解释,而且不太可能对模型的性能产生不利影响。

SHAP 交互摘要图可以用来识别特征和交互排名以及它们之间的某些共同点,但在这种情况下(见图 10.9),阅读起来很困难。但要深入挖掘交互,你首先需要量化它们的影响。为此,让我们创建一个热图,只包含按其平均绝对 SHAP 值(shap_rf_interact_avgs)测量的顶级交互。然后,我们应该将所有对角线值设置为 0(shap_rf_interact_avgs_nodiag),因为这些不是交互,而是特征 SHAP 值,没有它们更容易观察交互。我们可以将这个矩阵放入 DataFrame 中,但它是一个有 111 列和 111 行的 DataFrame,所以为了过滤出具有最多交互的特征,我们使用scipyrankdata对它们求和并排名。然后,我们使用排名来识别 12 个最具交互性的特征(most_interact_cols),并按这些特征子集 DataFrame。最后,我们将 DataFrame 绘制成热图:

shap_rf_interact_avgs = np.abs(shap_rf_interact_values).mean(0)
shap_rf_interact_avgs_nodiag = shap_rf_interact_avgs.copy()
np.fill_diagonal(shap_rf_interact_avgs_nodiag, 0)
shap_rf_interact_df = pd.DataFrame(shap_rf_interact_avgs_nodiag)
shap_rf_interact_df.columns = X_test[llarsic_cols].columns
shap_rf_interact_df.index = X_test[llarsic_cols].columns
shap_rf_interact_ranks = 112 -rankdata(np.sum(
     shap_rf_interact_avgs_nodiag, axis=0)
)
most_interact_cols = shap_rf_interact_df.columns[
    shap_rf_interact_ranks < 13
]
shap_rf_interact_df = shap_rf_interact_df.loc[
most_interact_cols,most_interact_cols
]
sns.heatmap(
    shap_rf_interact_df,
    cmap='Blues',
    annot=True,
    annot_kws={'size':10},
    fmt='.3f',
    linewidths=.5
) 
Figure 10.10. It depicts the most salient feature interactions according to SHAP interaction absolute mean values. Note that these are averages, so given how right-skewed most of these features are, it is likely much higher for many observations. However, it’s still a good indication of relative impact:

图表  描述自动生成

图 10.10:SHAP 交互热图

我们可以通过 SHAP 的dependence_plot逐个理解特征交互。例如,我们可以选择我们的顶级特征MAXRAMNT,并将其与RAMNTALLLSC4HVP2AVGGIFT等特征进行颜色编码的交互绘图。但首先,我们需要计算shap_values。然而,还有一些问题需要解决,我们之前已经提到了。这些问题与以下内容有关:

  • 异常值的普遍性:我们可以通过使用特征和 SHAP 值的百分位数来限制x轴和y轴,分别用plt.xlimplt.ylim来将这些异常值从图中剔除。这本质上是在 1st 和 99th 百分位数之间的案例上进行放大。

  • 金额特征的偏斜分布:在涉及金钱的任何特征中,它通常是右偏斜的。有许多方法可以简化它,例如使用百分位数对特征进行分箱,但一个快速的方法是使用对数刻度。在matplotlib中,您可以通过plt.xscale('log')来实现这一点,而无需转换特征。

以下代码考虑了两个问题。您可以尝试取消注释xlimylimxscale,以查看它们各自在理解dependence_plot时产生的巨大差异:

shap_rf_values = shap_rf_explainer.shap_values(
    X_test.iloc[sample_test_idx] [llarsic_cols]
)
maxramt_shap = shap_rf_values[:,llarsic_cols.index("MAXRAMNT")]
shap.dependence_plot(
    "MAXRAMNT",
    shap_rf_values,
    X_test.iloc[sample_test_idx][llarsic_cols],
    interaction_index="AVGGIFT",
    show=False, alpha=0.1
)
plt.xlim(xmin=np.percentile(X_test.MAXRAMNT, 1),\
         xmax=np.percentile(X_test.MAXRAMNT, 99))
plt.ylim(ymin=np.percentile(maxramt_shap, 1),\
         ymax=np.percentile(maxramt_shap, 99))
plt.xscale('log') 

上一段代码生成了图 10.11中所示的内容。它显示了MAXRAMNT在 10 到 100 之间有一个转折点,模型输出的平均影响开始逐渐增加,这些与更高的AVGGIFT值相关:

图表  描述自动生成,中等置信度

图 10.11:MAXRAMNT 和 AVGGIFT 之间的 SHAP 交互图

图 10.11中可以得到的教训是,这些特征的一定值以及可能的一些其他值可以增加捐赠的可能性,从而形成一个簇。从特征工程的角度来看,我们可以采用无监督方法,仅基于您已识别为相关的少数特征来创建特殊的簇特征。或者,我们可以采取更手动的方法,通过比较不同的图表来了解如何最好地识别簇。我们可以从这个过程中推导出二元特征,甚至可以推导出特征之间的比率,这些比率可以更清楚地描述交互或簇归属。

这里的想法不是试图重新发明轮子,去做模型已经做得很好的事情,而是首先追求一个更直观的模型解释。希望这甚至可以通过整理特征对预测性能产生积极影响,因为如果您更好地理解它们,也许模型也会!这就像平滑一个颗粒感强的图像;它可能会让您和模型都少一些困惑(有关更多信息,请参阅第十三章对抗鲁棒性)!但通过模型更好地理解数据还有其他积极的影响。

事实上,课程不仅仅是关于特征工程或建模,还可以直接应用于促销活动。如果能够识别出转折点,能否用来鼓励捐款呢?或许如果你捐款超过X美元,就可以获得一个免费的杯子?或者设置一个每月捐款X美元的定期捐款,并成为“银牌”赞助者的专属名单之一?

我们将以这个好奇的笔记结束这个话题,但希望这能激发你去欣赏我们如何将模型解释的教训应用到特征选择、工程以及更多方面。

任务完成

为了完成这个任务,你主要使用特征选择工具集来减少过拟合。非营利组织对大约 30%的利润提升感到满意,总成本为 35,601 美元,比向测试数据集中的每个人发送邮件的成本低 30,000 美元。然而,他们仍然希望确保他们可以安全地使用这个模型,而不用担心会亏损。

在本章中,我们探讨了过拟合如何导致盈利曲线不一致。不一致性是关键的,因为它可能意味着基于训练数据选择的阈值在样本外数据上不可靠。因此,你使用compare_df_plots来比较测试集和训练集之间的盈利,就像你之前做的那样,但这次是为了选定的模型(rf_5_e-llarsic):

profits_test = reg_mdls['rf_5_e-llarsic']['profits_test']
profits_train = reg_mdls['rf_5_e-llarsic']['profits_train']
mldatasets.compare_df_plots(
    profits_test[['costs', 'profit', 'roi']],
    profits_train[['costs', 'profit', 'roi']],
    'Test',
    'Train',
    x_label='Threshold',
    y_formatter=y_formatter,
    plot_args={'secondary_y':'roi'}
) 

上述代码生成了图 10.12中所示的内容。你可以向非营利组织展示,以证明在测试中,0.68 美元是一个甜点,是可获得的第二高利润。它也在他们的预算范围内,实现了 41%的投资回报率。更重要的是,这些数字与训练数据非常接近。另一个令人高兴的是,训练测试的利润曲线缓慢下降,而不是突然跌落悬崖。非营利组织可以确信,如果他们选择提高阈值,运营仍然会盈利。毕竟,他们希望针对整个邮件列表的捐赠者,为了使这从财务上可行,他们必须更加专属。比如说,他们在整个邮件列表上使用 0.77 美元的阈值,活动成本约为 46,000 美元,但利润超过 24,000 美元:

图表,折线图  自动生成的描述

图 10.12:通过 AIC 特征在不同阈值下,模型使用 LASSO LARS 的测试集和训练集的盈利、成本和投资回报率比较

恭喜!你已经完成了这个任务!

但有一个关键细节,如果我们不提出来,我们可能会疏忽。

尽管我们考虑到下一场活动来训练这个模型,但这个模型很可能会在未来直接营销活动中使用,而无需重新训练。这种模型的重用带来一个问题。有一个概念叫做数据漂移,也称为特征漂移,即随着时间的推移,模型关于目标变量特征的所学内容不再成立。另一个概念,概念漂移,是关于目标特征定义随时间变化的情况。例如,构成有利捐赠者的条件可能会改变。这两种漂移可能同时发生,并且涉及人类行为的问题,这是可以预料的。行为受到文化、习惯、态度、技术和时尚的影响,这些总是在不断发展。您可以警告非营利组织,您只能保证模型在下一场活动中是可靠的,但他们无法承担每次都雇佣您进行模型重新训练的费用!

您可以向客户提议创建一个脚本,直接监控他们的邮件列表数据库中的漂移情况。如果它检测到模型使用的特征有显著变化,它将向他们和您发出警报。在这种情况下,您可以触发模型的自动重新训练。然而,如果漂移是由于数据损坏造成的,您将没有机会解决这个问题。即使进行了自动重新训练,如果性能指标没有达到预定的标准,也无法部署。无论如何,您都应该密切关注预测性能,以确保可靠性。可靠性是模型可解释性的一个基本主题,因为它与问责制密切相关。本书不会涵盖漂移检测,但未来的章节将讨论数据增强(第十一章,偏差缓解和因果推断方法)和对抗鲁棒性(第十三章,对抗鲁棒性),这些都关乎可靠性。

摘要

在本章中,我们学习了无关特征如何影响模型结果,以及特征选择如何提供一套工具来解决此问题。然后,我们探讨了这套工具中的许多不同方法,从最基本过滤器方法到最先进的方法。最后,我们讨论了特征工程的可解释性问题。特征工程可以使模型更具可解释性,从而表现更好。我们将在第十二章,单调约束和模型调优以实现可解释性中更详细地介绍这个主题。

在下一章中,我们将讨论偏差缓解和因果推断的方法。

数据集来源

进一步阅读

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

二维码

第十一章:偏差缓解和因果推断方法

在第六章“锚点和反事实解释”中,我们探讨了公平及其与决策的关系,但仅限于事后模型解释方法。在第十章“用于可解释性的特征选择和工程”中,我们提出了成本敏感性的问题,这通常与平衡或公平相关。在本章中,我们将探讨平衡数据和调整模型以实现公平的方法。

使用信用卡违约数据集,我们将学习如何利用目标可视化工具,如类平衡,来检测不希望的偏差,然后通过预处理方法,如重新加权和不平等影响移除(用于处理过程中)和等概率(用于后处理)来减少它。从第六章的“锚点和反事实解释”和第十章的“用于可解释性的特征选择和工程”等主题扩展,我们还将研究政策决策可能产生意外、反直觉或有害的影响。在假设检验的背景下,一个决策被称为治疗。对于许多决策场景,估计其效果并确保这个估计是可靠的至关重要。

因此,我们将假设针对最脆弱的信用卡违约人群的治疗方法,并利用因果模型来确定其平均治疗效果ATE)和条件平均治疗效果CATE)。最后,我们将使用各种方法测试因果假设和估计的稳健性。

这些是我们将要涵盖的主要主题:

  • 检测偏差

  • 缓解偏差

  • 创建因果模型

  • 理解异质治疗效果

  • 测试估计的稳健性

技术要求

本章的示例使用了mldatasetspandasnumpysklearnlightgbmxgboostmatplotlibseabornxaiaif360econmldowhy库。有关如何安装所有这些库的说明见前言。

本章的代码位于此处:

packt.link/xe6ie

任务

全球流通的信用卡超过 28 亿张,我们每年在它们上的总消费超过 25 万亿美元(美元)(www.ft.com/content/ad826e32-2ee8-11e9-ba00-0251022932c8)。这无疑是一个天文数字,但衡量信用卡行业的规模,不应仅看消费额,而应看债务总额。银行等发卡机构主要通过收取利息来赚取大部分收入。因此,消费者(2022 年)欠下的超过 60 万亿美元的债务,其中信用卡债务占很大一部分,为贷方提供了稳定的利息收入。这可能有利于商业,但也带来了充足的风险,因为如果借款人在本金加运营成本偿还之前违约,贷方可能会亏损,尤其是当他们已经用尽法律途径追讨债务时。

当出现信用泡沫时,这个问题会加剧,因为不健康的债务水平可能会损害贷方的财务状况,并在泡沫破裂时将他们的股东拖下水。2008 年的住房泡沫,也称为次贷危机,就是这种情况。这些泡沫通常始于对增长的投机和对无资格需求的寻求,以推动这种增长。在次贷危机的情况下,银行向那些没有证明还款能力的个人提供了抵押贷款。遗憾的是,他们也针对了少数族裔,一旦泡沫破裂,他们的全部净资产就会被清零。金融危机、萧条以及介于两者之间的每一次灾难,往往以更高的比率影响最脆弱的人群。

信用卡也涉及到了灾难性的泡沫,特别是在 2003 年的韩国(www.bis.org/repofficepubl/arpresearch_fs_200806.10.pdf)和 2006 年的台湾。本章将考察 2005 年的数据,导致台湾信用卡危机。到 2006 年,逾期信用卡债务达到 2680 亿美元,由超过 70 万人欠下。超过 3%的台湾人口甚至无法支付信用卡的最低还款额,俗称为信用卡奴隶。随之而来的是重大的社会影响,如无家可归者数量的急剧增加、毒品走私/滥用,甚至自杀。在 1997 年亚洲金融危机之后,该地区的自杀率稳步上升。2005 年至 2006 年间的 23%的增幅将台湾的自杀率推到了世界第二高。

如果我们将危机追溯到其根本原因,那是因为新发卡银行已经耗尽了一个饱和的房地产市场,削减了获取信用卡的要求,而当时这些信用卡的监管由当局执行得并不好。

这对年轻人影响最大,因为他们通常收入较低,管理资金的经验也较少。2005 年,台湾金融监督管理委员会发布了新规定,提高了信用卡申请人的要求,防止出现新的信用卡奴隶。然而,还需要更多的政策来处理系统中已经存在的债务和债务人。

当局开始讨论创建资产管理公司AMCs)以从银行的资产负债表中剥离不良债务。他们还希望通过一项债务人还款规定,为谈判合理的还款计划提供一个框架。这两项政策直到 2006 年才被纳入法律。

假设一下,现在是 2005 年 8 月,你从未来带着新颖的机器学习和因果推理方法来到这里!一家台湾银行希望创建一个分类模型来预测将违约的客户。他们为你提供了一份包含 30,000 名信用卡客户的数据库。监管机构仍在起草法律,因此有机会提出既有利于银行又有利于债务人的政策。当法律通过后,他们可以使用分类模型预测哪些债务应该卖给资产管理公司(AMCs),并通过因果模型估计哪些政策将有利于其他客户和银行,但他们希望公平且稳健地完成这项任务——这是你的使命!

方法

银行已经强调,将公平性嵌入到你的方法中是多么重要,因为监管机构和公众普遍希望确保银行不会造成更多的伤害。他们的声誉也依赖于这一点,因为在最近几个月里,媒体一直在无情地指责他们进行不诚实和掠夺性贷款行为,导致消费者失去信任。因此,他们希望使用最先进的稳健性测试来证明规定的政策将减轻问题。你提出的方法包括以下要点:

  • 据报道,年轻放贷人更容易违约还款,因此你预计会发现年龄偏差,但你也会寻找其他受保护群体,如性别,的偏差。

  • 一旦检测到偏差,你可以使用AI Fairness 360AIF360)库中的预处理、处理和后处理算法来减轻偏差。在这个过程中,你将使用每个算法训练不同的模型,评估它们的公平性,并选择最公平的模型。

  • 为了能够理解政策的影响,银行对一小部分客户进行了一项实验。通过实验结果,你可以通过dowhy库拟合一个因果模型,这将识别出因果效应。这些效应进一步由因果模型分解,以揭示异质的治疗效应。

  • 然后,你可以评估异质处理效果来理解它们并决定哪种处理最有效。

  • 最后,为了确保你的结论是稳健的,你需要用几种方法来反驳结果,看看效果是否仍然存在。

让我们深入探讨!

准备工作

您可以在以下链接找到本例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python/blob/master/Chapter11/CreditCardDefaults.ipynb.

加载库

要运行此示例,您需要安装以下库:

  • mldatasets用于加载数据集

  • pandasnumpy用于操作数据

  • sklearn(scikit-learn)、xgboostaif360lightgbm用于分割数据和拟合模型

  • matplotlibseabornxai用于可视化解释

  • econmldowhy用于因果推断

您应该首先加载所有这些库,如下所示:

import math
import os
import mldatasets
import pandas as pd
import numpy as np
from tqdm.notebook import tqdm
from sklearn import model_selection, tree
import lightgbm as lgb
import xgboost as xgb
from aif360.datasets import BinaryLabelDataset
from aif360.metrics import BinaryLabelDatasetMetric,\
                           ClassificationMetric
from aif360.algorithms.preprocessing import Reweighing,\
                                           DisparateImpactRemover
from aif360.algorithms.inprocessing import ExponentiatedGradientReduction, GerryFairClassifier
from aif360.algorithms.postprocessing.\
                      calibrated_eq_odds_postprocessing \
                            import CalibratedEqOddsPostprocessing
from aif360.algorithms.postprocessing.eq_odds_postprocessing\
                            import EqOddsPostprocessing
from econml.dr import LinearDRLearner
import dowhy
from dowhy import CausalModel
import xai
from networkx.drawing.nx_pydot import to_pydot
from IPython.display import Image, display
import matplotlib.pyplot as plt
import seaborn as sns 

理解和准备数据

我们将数据如下加载到名为ccdefault_all_df的 DataFrame 中:

ccdefault_all_df = mldatasets.load("cc-default", prepare=True) 

应该有 30,000 条记录和 31 列。我们可以使用info()来验证这一点,如下所示:

ccdefault_all_df.info() 

上述代码输出以下内容:

Int64Index: 30000 entries, 1 to 30000
Data columns (total 31 columns):
#   Column            Non-Null Count  Dtype  
---  ------            --------------  -----  
0   CC_LIMIT_CAT      30000 non-null  int8   
1   EDUCATION         30000 non-null  int8   
2   MARITAL_STATUS    30000 non-null  int8   
3   GENDER            30000 non-null  int8   
4   AGE_GROUP         30000 non-null  int8   
5   pay_status_1      30000 non-null  int8   
6   pay_status_2      30000 non-null  int8   
7   pay_status_3      30000 non-null  int8   
8   pay_status_4      30000 non-null  int8   
9   pay_status_5      30000 non-null  int8   
10  pay_status_6      30000 non-null  int8   
11  paid_pct_1        30000 non-null  float64
12  paid_pct_2        30000 non-null  float64
13  paid_pct_3        30000 non-null  float64
14  paid_pct_4        30000 non-null  float64
15  paid_pct_5        30000 non-null  float64
16  paid_pct_6        30000 non-null  float64
17  bill1_over_limit  30000 non-null  float64
18  IS_DEFAULT        30000 non-null  int8   
19  _AGE              30000 non-null  int16  
20  _spend            30000 non-null  int32  
21  _tpm              30000 non-null  int16  
22  _ppm              30000 non-null  int16  
23  _RETAIL           30000 non-null  int8   
24  _URBAN            30000 non-null  int8   
25  _RURAL            30000 non-null  int8   
26  _PREMIUM          30000 non-null  int8   
27  _TREATMENT        30000 non-null  int8   
28  _LTV              30000 non-null  float64
29  _CC_LIMIT         30000 non-null  int32  
30  _risk_score       30000 non-null  float64 

输出检查无误。所有特征都是数值型,没有缺失值,因为我们使用了prepare=True,这确保了所有空值都被填补。分类特征都是int8,因为它们已经被编码。

数据字典

有 30 个特征,但我们不会一起使用它们,因为其中 18 个用于偏差缓解练习,剩下的 12 个以下划线(_)开头的用于因果推断练习。很快,我们将把数据分割成每个练习对应的相应数据集。重要的是要注意,小写特征与每个客户的交易历史有关,而客户账户或目标特征是大写的。

我们将在以下偏差缓解练习中使用以下特征:

  • CC_LIMIT_CAT: 序数;信用卡额度(_CC_LIMIT)分为八个大致均匀分布的四分位数

  • EDUCATION: 序数;客户的受教育程度(0: 其他,1: 高中,2: 本科,3: 研究生)

  • MARITAL_STATUS: 名义变量;客户的婚姻状况(0: 其他,1: 单身,2: 已婚)

  • GENDER: 名义变量;客户的性别(1: 男,2: 女)

  • AGE GROUP: 二元变量;表示客户是否属于特权年龄组(1: 特权组(26-47 岁),0: 非特权组(其他所有年龄))

  • pay_status_1 pay_status_6: 序数;从 2005 年 4 月的pay_status_6到 8 月的还款状态(-1: 按时还款,1: 延迟 1 个月还款,2: 延迟 2 个月还款,8: 延迟 8 个月,9: 延迟 9 个月及以上)

  • paid_pct_1 paid_pct_6: 连续型;从 4 月到 2005 年 8 月,每月应付款项的百分比,paid_pct_6为 8 月,paid_pct_1为 4 月。

  • bill1_over_limit: 连续型;2005 年 8 月最后一张账单与相应信用额度的比率

  • IS_DEFAULT: 二元型;目标变量;客户是否违约

这些是我们将在因果推断练习中使用的特征:

  • _AGE: 连续型;客户的年龄,单位为年。

  • _spend: 连续型;每位客户在新台币NT$)中的消费金额。

  • _tpm: 连续型;客户在前 6 个月内使用信用卡的月均交易量。

  • _ppm: 连续型;客户在前 6 个月内使用信用卡的月均购买量。

  • _RETAIL: 二元型;如果客户是零售客户,而不是通过雇主获得的客户。

  • _URBAN: 二元型;如果客户是城市客户。

  • _RURAL: 二元型;如果客户是农村客户。

  • _PREMIUM: 二元型;如果客户是“高级”客户。高级客户会获得现金返还和其他消费激励。

  • _TREATMENT: 名义型;针对每位客户指定的干预或政策(-1:非实验部分,0:对照组,1:降低信用额度,2:支付计划,3:支付计划和信用额度)。

  • _LTV: 连续型;干预的结果,即在过去的 6 个月信用支付行为的基础上,估计的新台币(NT$)终身价值。

  • _CC_LIMIT: 连续型;客户在治疗前的原始信用卡额度,单位为新台币(NT$)。银行家们预期治疗结果将受到这一特征的极大影响。

  • _risk_score: 连续型;银行在 6 个月前根据信用卡账单与信用额度比率计算出的每位客户的风险评分。它与bill1_over_limit类似,但它是对 6 个月支付历史记录的加权平均值,且在选择治疗措施前 5 个月产生。

我们将在接下来的几节中更详细地解释因果推断特征及其目的。同时,让我们通过value_counts()按其值分解_TREATMENT特征,以了解我们将如何分割这个数据集,如下所示:

ccdefault_all_df._TREATMENT.value_counts() 

上述代码输出了以下内容:

-1    28904
3      274
2      274
1      274
0      274 

大多数观测值是治疗-1,因此它们不是因果推断的一部分。其余部分在三种治疗(1-3)和对照组(0)之间平均分配。自然地,我们将使用这四个组进行因果推断练习。然而,由于对照组没有指定治疗措施,我们可以将其与-1治疗措施一起用于我们的偏差缓解练习。我们必须小心排除在偏差缓解练习中行为被操纵的客户。整个目的是在尝试减少偏差的同时,预测在“照常营业”的情况下,哪些客户最有可能违约。

数据准备

目前,我们的单一数据准备步骤是将数据集拆分,这可以通过使用_TREATMENT列对pandas DataFrame 进行子集化轻松完成。我们将为每个练习创建一个 DataFrame,使用这种子集化:偏差缓解(ccdefault_bias_df)和因果推断(ccdefault_causal_df)。这些可以在以下代码片段中看到:

ccdefault_bias_df = ccdefault_all_df[
    ccdefault_all_df._TREATMENT < 1
]
ccdefault_causal_df =ccdefault_all_df[
    ccdefault_all_df._TREATMENT >= 0
] 

我们将在深入部分进行一些其他数据准备步骤,但现在我们可以开始着手了!

检测偏差

机器学习中存在许多偏差来源。如第一章中所述,“解释、可解释性和可解释性;以及这一切为什么都重要?”,存在大量的偏差来源。那些根植于数据所代表真相的,如系统性和结构性偏差,导致数据中的偏见偏差。还有根植于数据的偏差,如样本、排除、关联和测量偏差。最后,还有我们从数据或模型中得出的见解中的偏差,我们必须小心处理,如保守主义偏差、显著性偏差和基本归因错误。

对于这个例子,为了正确地解开这么多偏差水平,我们应该将我们的数据与 2005 年台湾的普查数据和按人口统计划分的历史贷款数据联系起来。然后,使用这些外部数据集,控制信用卡合同条件,以及性别、收入和其他人口统计数据,以确定年轻人是否特别被针对,获得他们不应有资格的高利率信用卡。我们还需要追踪数据集到作者那里,并与他们以及领域专家协商,检查数据集中与偏差相关的数据质量问题。理想情况下,这些步骤对于验证假设是必要的,但这将是一个需要几章解释的巨大任务。

因此,本着简便的原则,我们直接接受本章的前提。也就是说,由于掠夺性贷款行为,某些年龄群体更容易受到信用卡违约的影响,这不是他们自己的任何过错。我们还将直接接受数据集的质量。有了这些保留,这意味着如果我们发现数据或由此数据派生的任何模型中年龄群体之间存在差异,这可以归因于掠夺性贷款行为。

这里还概述了两种公平性类型:

  • 程序公平性:这是关于公平或平等对待。在法律上很难定义这个术语,因为它在很大程度上取决于上下文。

  • 结果公平性:这完全是关于衡量公平的结果。

这两个概念并不是相互排斥的,因为程序可能是公平的,但结果可能不公平,反之亦然。在这个例子中,不公平的程序是向不合格的客户提供高利率信用卡。尽管如此,我们将在本章中关注结果公平性。

当我们讨论机器学习中的偏差时,它将影响受保护的群体,并且在这些群体中,将存在特权弱势群体。后者是一个受到偏差负面影响的群体。偏差的表现方式也有很多,以下是如何应对偏差的:

  • 代表性:可能存在代表性不足或弱势群体过度代表的情况。与其它群体相比,模型将学习关于这个群体的信息要么太少要么太多。

  • 分布:特征在群体之间的分布差异可能导致模型做出有偏的关联,这些关联可能直接或间接地影响模型的结果。

  • 概率:对于分类问题,如第六章中讨论的,群体之间的类别平衡差异可能导致模型学习到某个群体有更高的概率属于某一类或另一类。这些可以通过混淆矩阵或比较它们的分类指标(如假阳性或假阴性率)来轻松观察到。

  • 混合:上述任何表现形式的组合。

缓解偏差部分讨论了任何偏差表现策略,但我们在本章中讨论的偏差类型与我们的主要受保护属性(_AGE)的概率差异有关。我们将通过以下方式观察这一点:

  • 可视化数据集偏差:通过可视化观察受保护特征的差异。

  • 量化数据集偏差:使用公平性指标来衡量偏差。

  • 量化模型偏差:我们将训练一个分类模型并使用为模型设计的其他公平性指标。

模型偏差可以像我们在第六章锚点和反事实解释中已经做的那样可视化,或者像我们在第十二章单调约束和模型调优以提高可解释性中将要做的那样可视化。我们将在本章稍后的一个子节将所有内容结合起来中快速探索一些其他可视化。现在,让我们不耽搁,继续本节的实际部分。

可视化数据集偏差

数据本身讲述了某个群体属于正类与另一类相比的可能性有多大。如果是一个分类特征,可以通过将正类的value_counts()函数除以所有类别的总和来获得这些概率。例如,对于性别,我们可以这样做:

ccdefault_bias_df[
    ccdefault_bias_df.**IS_DEFAULT==1**
].**GENDER.value_counts()**/ccdefault_bias_df.**GENDER.value_counts()** 

前面的代码片段产生了以下输出,显示了男性平均有更高的概率违约他们的信用卡:

2    0.206529
1    0.241633 

对于连续特征的这种操作代码要复杂一些。建议您首先使用pandasqcut将特征划分为四分位数,然后使用与分类特征相同的方法。幸运的是,plot_prob_progression函数为您完成了这项工作,并绘制了每个四分位数的概率进展。第一个属性是pandas系列,一个包含受保护特征(_AGE)的数组或列表,第二个是相同的,但用于目标特征(IS_DEFAULT)。然后我们选择要设置为四分位数的区间数(x_intervals)(use_quantiles=True)。

其余的属性是美学上的,例如标签、标题和添加mean_line。代码可以在下面的片段中看到:

mldatasets.**plot_prob_progression(**
    ccdefault_bias_df.**_AGE**,
    ccdefault_bias_df.**IS_DEFAULT**,
    **x_intervals**=8,
    use_quantiles=True,
    xlabel='Age',
    **mean_line**=True,
    title='Probability of Default by Age'
) 

上述代码生成了以下输出,展示了最年轻(21-25)和最年长(47-79)的人群最有可能违约。其他所有群体仅代表超过一个标准差:

图表,折线图  自动生成的描述

图 11.1:按年龄划分的 CC 违约概率

我们可以将最年轻和最年长的四分位数称为弱势群体,而其他所有人称为特权群体。为了检测和减轻不公平性,最好将它们编码为二元特征——我们正是这样使用AGE_GROUP做到的。我们可以再次利用plot_prob_progression,但这次用AGE_GROUP代替AGE,并将数字替换为我们更容易理解的标签。代码可以在下面的片段中看到:

mldatasets.**plot_prob_progression(**
    ccdefault_bias_df.**AGE_GROUP**.**replace**({0:'21-25,48+',1:'26-47'}),
    ccdefault_bias_df.**IS_DEFAULT**,
    xlabel='Age Group',
    title='Probability of Default by Age Group',
    **mean_line**=True
) 

上述片段生成了以下输出,其中两组之间的差异相当明显:

图表,折线图  自动生成的描述

图 11.2:按年龄组划分的 CC 违约概率

接下来,让我们将GENDER重新引入画面。我们可以使用plot_prob_contour_map,它类似于plot_prob_progression,但在二维空间中,用颜色编码概率而不是绘制线条。因此,前两个属性是我们希望在x轴(GENDER)和y轴(AGE_GROUP)上的特征,第三个是目标(IS_DEFAULT)。由于我们的特征都是二元的,最好使用plot_type='grid'而不是contour。代码可以在下面的片段中看到:

mldatasets.plot_prob_contour_map(
    ccdefault_bias_df.**GENDER**.replace({1:'Male',2:'Female'}),
    ccdefault_bias_df.**AGE_GROUP**.replace({0:'21-25,48+',1:'26-47'}),
    ccdefault_bias_df.**IS_DEFAULT**,
    xlabel='Gender',
    ylabel='Age Group',
    annotate=True,
    plot_type='grid',
    title='Probability of Default by Gender/Age Group'
) 
group is 26- 47-year-old females, followed by their male counterparts at about 3-4% apart. The same happens with the underprivileged age group:

图表,树状图  自动生成的描述

图 11.3:按性别和年龄组划分的 CC 默认概率网格

性别差异是一个有趣的观察结果,我们可以提出许多假设来解释为什么女性违约较少。她们是否只是简单地更擅长管理债务?这是否与她们的婚姻状况或教育有关?我们不会深入探讨这些问题。鉴于我们只知道基于年龄的歧视,我们将在特权组中仅使用AGE_GROUP,但将GENDER保持为受保护属性,这将在我们监控的一些公平性指标中考虑。说到指标,我们将量化数据集偏差。

量化数据集偏差

公平性指标分为三类,如下概述:

  • 个体公平性:个体观察值在数据中与同龄人的接近程度。例如,欧几里得距离曼哈顿距离等距离度量可以用于此目的。

  • 组公平性:组与组之间标签或结果的平均距离。这可以在数据或模型中进行衡量。

  • 两者都:一些指标通过在组内和组间同时考虑不平等来衡量熵或方差,例如Theil 指数变异系数

在本章中,我们将专注于组公平性指标。

在我们计算公平性指标之前,有一些待处理的数据准备步骤。让我们确保我们将用于偏差缓解练习的数据集(ccdefault_bias_df)只包含相关的列,这些列不以下划线("_")开头。另一方面,因果推断练习将包括以下划线开头的列以及AGE_GROUPIS_DEFAULT。代码可以在下面的代码片段中看到:

cols_bias_l = ccdefault_all_df.columns[
    **~ccdefault_all_df.columns.str.startswith('_')**
].tolist()
cols_causal_l = [**'AGE_GROUP'**,**'IS_DEFAULT'**] +\
    ccdefault_all_df.columns[
        **ccdefault_all_df.columns.str.startswith('_')**
    ].tolist()
ccdefault_bias_df = ccdefault_bias_df[**cols_bias_l**]
ccdefault_causal_df = ccdefault_causal_df[**cols_causal_l**] 

此外,量化训练数据集中的数据集偏差更为重要,因为这是模型将从中学习的数据,所以让我们继续将数据分成训练集和测试集Xy对。我们在初始化随机种子以实现某些可重复性之后进行此操作。代码可以在下面的代码片段中看到:

rand = 9
os.environ['PYTHONHASHSEED']=str(rand)
np.random.seed(rand)
y = ccdefault_bias_df[**'IS_DEFAULT'**]
X = ccdefault_bias_df.drop([**'IS_DEFAULT'**], axis=1).copy()
X_train, X_test, y_train, y_test = model_selection.**train_test_split**(
    X, y, test_size=0.25, random_state=rand
) 

尽管我们将使用我们刚刚分割的pandas数据用于训练和性能评估,但我们将在这次练习中使用的库,称为 AIF360,将数据集抽象为基类。这些类包括转换为numpy数组的数据,并存储与公平性相关的属性。

对于回归,AIF360 有RegressionDataset,但对于这个二元分类示例,我们将使用BinaryLabelDataset。你可以使用包含特征和标签的pandas DataFrame 来初始化它(X_train.join(y_train))。然后,你指定标签的名称(label_names)和受保护的属性(protected_attribute_names),并且建议你为favorable_labelunfavorable_label输入一个值,这样 AIF360 就可以将其纳入评估公平性的考量。尽管这可能听起来很复杂,但在二元分类中,正数和负数仅与我们要预测的内容相关——正类——而不是它是否是一个有利的结果。代码可以在下面的片段中看到:

train_ds = **BinaryLabelDataset**(
    df=**X_train.join(y_train)**,
    label_names=['IS_DEFAULT'],
    protected_attribute_names=['AGE_GROUP', 'GENDER'],
    favorable_label=0,
    unfavorable_label=1
)
test_ds = **BinaryLabelDataset**(
    **df=X_test.join(y_test)**,
    label_names=['IS_DEFAULT'],
    protected_attribute_names=['AGE_GROUP', 'GENDER'],
    favorable_label=0, unfavorable_label=1
) 

接下来,我们为underprivileged groupsprivileged_groups创建数组。在AGE_GROUP=1中的成员有较低的违约概率,因此他们是特权组,反之亦然。然后,使用这些数组以及用于训练的抽象数据集(train_ds),我们可以通过BinaryLabelDatasetMetric初始化一个度量类。这个类有计算几个群体公平度度量的函数,仅凭数据本身进行判断。我们将输出其中的三个,并解释它们的含义。代码可以在下面的片段中看到:

underprivileged_groups=[**{'AGE_GROUP': 0}**]
privileged_groups=[**{'AGE_GROUP': 1}**]
metrics_train_ds = **BinaryLabelDatasetMetric**(
    train_ds,
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
print('Statistical Parity Difference (SPD): %.4f' %
      metrics_train_ds.**statistical_parity_difference**())
print('Disparate Impact (DI): %.4f' % 
      metrics_train_ds.**disparate_impact**())
print('Smoothed Empirical Differential Fairness (SEDF): %.4f' %\
      metrics_train_ds.**smoothed_empirical_differential_fairness**()) 

前面的代码片段生成了以下输出:

Statistical Parity Difference (SPD):               -0.0437
Disparate Impact (DI):                              0.9447
Smoothed Empirical Differential Fairness (SEDF):    0.3514 

现在,让我们解释每个度量分别代表什么,如下:

  • 统计差异差异SPD):也称为平均差异,这是弱势群体和特权群体之间有利结果平均概率的差异。负数表示对弱势群体的不公平,正数表示更好,但接近零的数字表示公平的结果,特权群体和弱势群体之间没有显著差异。它使用以下公式计算,其中f是有利类的值,D是客户组,Y是客户是否会违约:

  • 差异影响DI):DI 与 SPD 完全相同,只是它是比率而不是差异。在比率方面,越接近一,对弱势群体来说越好。换句话说,一代表群体之间公平的结果,没有差异,低于一表示与特权群体相比,弱势群体不利的结果,而高于一表示与特权群体相比,弱势群体有利的结果。公式如下:

  • 平滑经验差异公平性SEDF):这个公平性指标是从一篇名为“交叉性公平性的定义。”的论文中提出的许多新指标之一。与前面两个指标不同,它不仅限于预定的特权群体和弱势群体,而是扩展到包括受保护属性中的所有类别——在本例中是图 11.3中的四个。论文的作者们认为,当有受保护属性的交叉表时,公平性尤其棘手。这是因为辛普森悖论,即一个群体在总体上可能是有利的或是不利的,但在细分到交叉表时则不是。我们不会深入数学,但他们的方法在测量交叉性场景中的合理公平性水平时考虑到这种可能性。为了解释它,零代表绝对公平,越远离零,公平性越低。

接下来,我们将量化模型的群体公平性指标。

量化模型偏差

在我们计算指标之前,我们需要训练一个模型。为此,我们将使用最佳超参数(lgb_params)初始化一个 LightGBM 分类器(LGBMClassifier)。这些参数已经为我们进行了超参数调整(更多关于如何做这件事的细节在第十二章单调约束和模型调优以提高可解释性)。

请注意,这些参数包括scale_pos_weight,这是用于类别加权的。由于这是一个不平衡的分类任务,这是一个重要的参数,以便使分类器进行成本敏感训练,对一种误分类形式进行惩罚,而不是另一种。一旦分类器初始化,它将通过evaluate_class_mdl进行fit和评估,该函数返回一个包含预测性能指标的字典,我们可以将其存储在模型字典(cls_mdls)中。代码可以在下面的代码片段中看到:

cls_mdls = {}
lgb_params = {
    'learning_rate': 0.4,
    'reg_alpha': 21,
    'reg_lambda': 1,
    **'scale_pos_weight'**: 1.8
}
lgb_base_mdl = lgb.LGBMClassifier(
    random_seed=rand,
    max_depth=6,
    num_leaves=33,
    **lgb_params
)
lgb_base_mdl.fit(X_train, y_train)
**cls_mdls**['lgb_0_base'] = mldatasets.**evaluate_class_mdl**(
    lgb_base_mdl,
    X_train,
    X_test,
    y_train,
    y_test,
    plot_roc=False,
    plot_conf_matrix=True,
    show_summary=True,
    ret_eval_dict=True
) 
Figure 11.4. The scale_pos_weight parameter ensures a healthier balance between false positives in the top-right corner and false negatives at the bottom left. As a result, precision and recall aren’t too far off from each other. We favor high precision for a problem such as this one because we want to maximize true positives, but, not at the great expense of recall, so a balance between both is critical. While hyperparameter tuning, the F1 score, and the Matthews correlation coefficient (MCC) are useful metrics to use to this end. The evaluation of the LightGBM base model is shown here:

图 11.4:LightGBM 基础模型的评估

接下来,让我们计算模型的公平性指标。为此,我们需要对 AIF360 数据集进行“深度”复制(deepcopy=True),但我们将labelsscores更改为我们的模型预测的值。compute_aif_metrics函数使用 AIF360 的ClassificationMetric类为模型执行BinaryLabelDatasetMetric为数据集所执行的操作。然而,它并不直接与模型交互。它使用原始数据集(test_ds)和修改后的数据集(test_pred_ds)包含模型的预测来计算公平性。compute_aif_metrics函数创建一个包含几个预先计算的指标(metrics_test_dict)和指标类(metrics_test_cls)的字典,可以用来逐个获取指标。代码可以在下面的代码片段中看到:

test_pred_ds = test_ds.copy(deepcopy=True)
test_pred_ds.labels =\
    cls_mdls['lgb_0_base']['preds_test'].reshape(-1,1)
test_pred_ds.scores = \
    cls_mdls['lgb_0_base']['probs_test'].reshape(-1,1)
metrics_test_dict, metrics_test_cls = \
    mldatasets.**compute_aif_metrics**(
        test_ds,
        test_pred_ds,
        unprivileged_groups=underprivileged_groups,
        privileged_groups=privileged_groups
    )
cls_mdls['lgb_0_base'].**update**(metrics_test_dict)
print('Statistical Parity Difference (SPD): %.4f' %
      metrics_test_cls.**statistical_parity_difference**())
print('Disparate Impact (DI): %.4f' %
      metrics_test_cls.**disparate_impact**())
print('Average Odds Difference (AOD): %.4f' %
      metrics_test_cls.**average_odds_difference**())
print('Equal Opportunity Difference (EOD): %.4f' %
      metrics_test_cls.**equal_opportunity_difference**())
print('Differential Fairness Bias Amplification(DFBA): %.4f' % \
    metrics_test_cls.**differential_fairness_bias_amplification**()) 

前面的代码片段生成了以下输出:

Statistical Parity Difference (SPD):               -0.0679
Disparate Impact (DI):                                      0.9193
Average Odds Difference (AOD):                        -0.0550
Equal Opportunity Difference (EOD):                 -0.0265
Differential Fairness Bias Amplification (DFBA):    0.2328 

现在,把我们已经解释过的指标放在一边,让我们解释其他指标的含义,如下所示:

  • 平均机会差异AOD):这是对特权组和弱势群体假阳性率FPR)的平均值与假阴性率FNR)之间的差异。负值表示弱势群体存在不利,越接近零越好。公式如下:

图片

  • 平等机会差异EOD):它只是 AOD 的真正阳性率TPR)差异,因此它只用于测量 TPR 的机会。与 AOD 一样,负值确认了弱势群体存在不利,值越接近零意味着组间没有显著差异。公式如下:

图片

  • 差异公平性偏差放大DFBA):这个指标与 SEDF 在同一篇论文中定义,同样以零作为公平性的基准,并且也是交叉的。然而,它只测量了在称为偏差放大的现象中,模型和数据在不公平性比例上的差异。换句话说,这个值表示模型相对于原始数据增加了多少不公平性。

如果你将模型的SPDDI指标与数据相比,它们确实更差。这并不奇怪,因为这是预期的,因为模型学习到的表示往往会放大偏差。你可以用DFBA指标来证实这一点。至于AODEOD,它们通常与SPD指标在同一区域,但理想情况下,EOD指标应该比AOD指标更接近零,因为我们在这个例子中更关心 TPR。

接下来,我们将介绍减轻模型偏差的方法。

减轻偏差

我们可以通过在以下三个不同层面上操作的方法来在三个不同层面上减轻偏差:

  • 预处理:这些是在训练模型之前检测和消除训练数据中偏差的干预措施。利用预处理的方法的优点是它们在源头解决偏差。另一方面,任何未检测到的偏差仍可能被模型放大。

  • 内处理:这些方法在模型训练期间减轻偏差,因此高度依赖于模型,并且通常不像预处理和后处理方法那样不依赖于模型。它们还需要调整超参数来校准公平性指标。

  • 后处理:这些方法在模型推理期间缓解偏差。在第六章锚点和反事实解释中,我们提到了使用 What-If 工具来选择正确的阈值(参见该章节中的图 6.13),并且我们手动调整它们以达到与假阳性相同的效果。就像那时一样,后处理方法旨在直接在结果中检测和纠正公平性,但需要进行的调整将取决于哪些指标对你的问题最重要。后处理方法的优势在于它们可以解决结果不公平性,这在可以产生最大影响的地方,但由于它与模型开发的其余部分脱节,可能会扭曲事物。

请注意,偏差缓解方法可能会损害预测性能,因此通常存在权衡。可能会有相反的目标,尤其是在数据反映了有偏见的真相的情况下。我们可以选择追求更好的真相:一个正义的真相——我们想要的,而不是我们拥有的那个

本节将解释每个级别的几种方法,但只为每种方法实现和评估两个。此外,我们不会在本章中这样做,但你可以将不同类型的方法结合起来以最大化缓解——例如,你可以使用预处理方法来去偏数据,然后用它来训练模型,最后使用后处理方法来移除模型添加的偏差。

预处理偏差缓解方法

这些是一些最重要的预处理或数据特定偏差缓解方法:

  • 无意识:也称为压制。移除偏差最直接的方法是排除数据集中的有偏特征,但这是一种天真方法,因为你假设偏差严格包含在这些特征中。

  • 特征工程:有时,连续特征会捕捉到偏差,因为存在许多稀疏区域,模型可以用假设来填补空白或从异常值中学习。它也可以与交互做同样的事情。特征工程可以设置护栏。我们将在第十二章单调约束和模型调优以实现可解释性中讨论这个话题。

  • 平衡:也称为重采样。单独来看,通过平衡数据集可以相对容易地修复表示问题。XAI 库(github.com/EthicalML/xai)有一个balance函数,通过随机下采样和上采样组表示来实现这一点。下采样,或欠采样,就是我们通常所说的采样,即只取一定比例的观察结果,而上采样,或过采样,则是创建一定比例的随机重复。一些策略会合成上采样而不是重复,例如合成少数过采样技术SMOTE)。然而,我们必须警告,如果你有足够的数据,总是优先下采样而不是上采样。如果有其他可能的偏见问题,最好不要只使用平衡策略。

  • 重新标记:也称为调整,这是一种算法改变最可能存在偏见的观察结果的标签,通过排名来产生调整后的数据。通常,这使用朴素贝叶斯分类器执行,为了保持类别分布,它不仅提升了一些观察结果,还降低了一样多的数量。

  • 重新加权:这种方法与重新标记类似,但不是翻转它们的标签,而是为每个观察结果推导出一个权重,我们可以在学习过程中实现它。就像类别权重应用于每个类别一样,样本权重应用于每个观察结果或样本。许多回归器和分类器,包括LGBMClassifier,都支持样本权重。尽管技术上重新加权不接触数据和模型应用到的解决方案,但它是一种预处理方法,因为我们检测到数据中的偏见。

  • 差异影响消除器:这种方法的设计者非常小心,遵守法律对偏见的定义,并在不改变标签或受保护属性的情况下保持数据的完整性。它实施了一个修复过程,试图从剩余的特征中消除偏见。当我们怀疑偏见主要存在于那里时,这是一个非常好的过程——也就是说,特征与受保护属性高度相关,但它不解决其他地方的偏见。在任何情况下,它都是一个很好的基线,用于了解有多少偏见是非受保护特征。

  • 学习公平表示:这种方法利用了对抗性学习框架。有一个生成器(自动编码器)创建排除受保护属性的数据表示,还有一个评论家,其目标是使特权组和弱势群体中学习的表示尽可能接近。

  • 用于歧视预防的优化预处理:这种方法通过数学优化数据,以保持整体概率分布。同时,保护属性与目标之间的相关性被消除。这个过程的结果是数据略微扭曲,以消除偏差。

由于存在许多预处理方法,我们将在本章中仅使用其中两种。尽管如此,如果你对使用我们未涉及的方法感兴趣,它们在 AIF360 库中可用,你可以在其文档中了解它们(aif360.res.ibm.com/)。

重新加权方法

重新加权方法相对简单易行。你通过指定组来初始化它,然后像使用任何 scikit-learn 编码器或缩放器一样fittransform数据。对于那些不熟悉fit的人来说,算法学习如何转换提供的数据,而transform使用学到的知识来转换它。以下代码片段中可以看到代码:

reweighter= **Reweighing**(
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
reweighter.**fit**(train_ds)
train_rw_ds = reweighter.**transform**(train_ds) 

从这个过程得到的转换不会改变数据,但为每个观测值创建权重。AIF360 库能够将这些权重因素纳入公平性的计算中,因此我们可以使用之前使用的BinaryLabelDatasetMetric来计算不同的指标。以下代码片段中可以看到代码:

metrics_train_rw_ds = **BinaryLabelDatasetMetric**(
    train_rw_ds,
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
print('Statistical Parity Difference (SPD): %.4f' %
      metrics_train_rw_ds.**statistical_parity_difference**())
print('Disparate Impact (DI): %.4f' %
       metrics_train_rw_ds.**disparate_impact**())
print('Smoothed Empirical Differential Fairness(SEDF): %.4f'%
metrics_train_rw_ds.**smoothed_empirical_differential_fairness**()) 

上述代码输出以下内容:

Statistical Parity Difference (SPD):                    -0.0000
Disparate Impact (DI):                                   1.0000
Smoothed Empirical Differential Fairness (SEDF):    0.1942 

权重对 SPD 和 DI 有完美的影响,使它们在这些指标方面绝对公平。然而,请注意,SEDF 比以前更好,但不是零。这是因为特权群体和弱势群体仅与AGE_GROUP保护属性相关,但不与GENDER相关。SEDF 是交叉公平性的度量,重新加权没有涉及。

你可能会认为给观测值添加权重会对预测性能产生不利影响。然而,这种方法被设计用来保持平衡。在未加权的数据集中,所有观测值都有一个权重为 1,因此所有权重的平均值是 1。在重新加权时,会改变观测值的权重,但平均值仍然大约是 1。你可以通过比较原始数据集和重新加权的数据集中instance_weights的平均值的绝对差异来检查这一点。它应该是微不足道的。以下代码片段中可以看到代码:

np.**abs**(train_ds.**instance_weights.mean**() -\
       train_rw_ds.**instance_weights.mean**()) < 1e-6 

那么,你可能会问,如何应用instance_weights?许多模型类在fit方法中有一个不太为人所知的属性,称为sample_weight。你只需将其插入其中,在训练过程中,它将根据相应的权重从观测值中学习。以下代码片段展示了这种方法:

lgb_rw_mdl = lgb.LGBMClassifier(
    random_seed=rand,
    max_depth=6,
    num_leaves=33,
    **lgb_params
)
lgb_rw_mdl.fit(
    X_train,
    y_train,
    **sample_weight**=train_rw_ds.instance_weights
) 

我们可以使用与基础模型相同的方法评估此模型,使用evaluate_class_mdl。然而,当我们使用compute_aif_metrics计算公平性指标时,我们将它们保存在模型字典中。我们不会逐个查看每种方法的输出,而是在本节结束时进行比较。以下是一个代码片段:

cls_mdls['lgb_1_rw'] = mldatasets.**evaluate_class_mdl**(
    lgb_rw_mdl,
    train_rw_ds.features,
    X_test,
    train_rw_ds.labels,
    y_test,
    plot_roc=False,
    plot_conf_matrix=True,
    show_summary=True,
    ret_eval_dict=True
)
test_pred_rw_ds = test_ds.copy(deepcopy=True)
test_pred_rw_ds.labels = cls_mdls['lgb_1_rw']['preds_test'
    ].reshape(-1,1)
test_pred_rw_ds.scores = cls_mdls['lgb_1_rw']['probs_test'
    ].reshape(-1,1)
metrics_test_rw_dict, _ = mldatasets.**compute_aif_metrics**(
    test_ds,
    test_pred_rw_ds,
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
cls_mdls['lgb_1_rw'].update(metrics_test_rw_dict) 
Figure 11.5:

图表,瀑布图 描述自动生成

图 11.5:LightGBM 重新加权模型的评估

如果你将图 11.5图 11.4进行比较,你可以得出结论,重新加权模型和基础模型之间的预测性能没有太大差异。这个结果是可以预料的,但仍然值得验证。一些偏差缓解方法可能会对预测性能产生不利影响,但重新加权并没有。同样,DI 消除器(差异影响DI)也不应该如此,我们将在下一节中讨论!

差异影响消除方法

此方法专注于不在受保护属性(AGE_GROUP)中的偏差,因此我们将在过程中删除此特征。为此,我们需要它的索引——换句话说,它在列列表中的位置。我们可以将此位置(protected_index)保存为变量,如下所示:

protected_index = train_ds.feature_names.index('AGE_GROUP') 

DI 消除器是参数化的。它需要一个介于零和一之间的修复水平,因此我们需要找到最佳值。为此,我们可以遍历一个具有不同修复水平值的数组(levels),使用每个level初始化DisparateImpactRemover,并对数据进行fit_transform,这将消除数据中的偏差。然而,我们随后在不包含受保护属性的情况下训练模型,并使用BinaryLabelDatasetMetric评估disparate_impact。记住,DI 是一个比率,因此它是一个可以在超过和低于一之间的指标,最佳 DI 是最接近一的。因此,当我们遍历不同的修复水平时,我们将持续保存 DI 最接近一的模型。我们还将 DI 追加到数组中,以供以后使用。以下是一个代码片段:

di = np.array([])
train_dir_ds = None
test_dir_ds = None
lgb_dir_mdl = None
X_train_dir = None
X_test_dir = None
levels = np.hstack(
    [np.linspace(0., 0.1, 41), np.linspace(0.2, 1, 9)]
)
for level in tqdm(levels):
    di_remover = **DisparateImpactRemover**(repair_level=level)
    train_dir_ds_i = di_remover.**fit_transform**(train_ds)
    test_dir_ds_i = di_remover.**fit_transform**(test_ds)
    X_train_dir_i = np.**delete**(
        train_dir_ds_i.features,
        protected_index,
        axis=1
    )
    X_test_dir_i = np.**delete**(
        test_dir_ds_i.features,
        protected_index,
        axis=1
    )
    lgb_dir_mdl_i = lgb.**LGBMClassifier**(
        random_seed=rand,
        max_depth=5,
        num_leaves=33,
        **lgb_params
    )
    lgb_dir_mdl_i.**fit**(X_train_dir_i, train_dir_ds_i.labels)
    test_dir_ds_pred_i = test_dir_ds_i.copy()
    test_dir_ds_pred_i.labels = lgb_dir_mdl_i.predict(
        X_test_dir_i
    )
    metrics_test_dir_ds = **BinaryLabelDatasetMetric**(
        test_dir_ds_pred_i,
        unprivileged_groups=underprivileged_groups,
        privileged_groups=privileged_groups
    )
    di_i = metrics_test_dir_ds.disparate_impact()
    if (di.shape[0]==0) or (np.min(np.abs(di-1)) >= abs(di_i-1)):
        print(abs(di_i-1))
        train_dir_ds = train_dir_ds_i
        test_dir_ds = test_dir_ds_i
        X_train_dir = X_train_dir_i
        X_test_dir = X_test_dir_i
        lgb_dir_mdl = lgb_dir_mdl_i
    di = np.append(np.array(di), di_i) 

为了观察不同修复水平下的 DI,我们可以使用以下代码,如果你想要放大最佳 DI 所在区域,只需取消注释xlim行:

plt.plot(**levels**, **di**, marker='o') 

上述代码生成以下输出。正如你所看到的,最佳修复水平位于 0 和 0.1 之间,因为那里的值最接近 1:

图表,折线图 描述自动生成

图 11.6:不同 DI 消除修复水平下的 DI

现在,让我们使用evaluate_class_mdl评估最佳的 DI 修复模型,并计算公平性指标(compute_aif_metrics)。这次我们甚至不会绘制混淆矩阵,但我们会将所有结果保存到cls_mdls字典中,以供后续检查。代码如下所示:

cls_mdls['lgb_1_dir'] = mldatasets.**evaluate_class_mdl**(
    lgb_dir_mdl,
    X_train_dir,
    X_test_dir,
    train_dir_ds.labels,
    test_dir_ds.labels,
    plot_roc=False,
    plot_conf_matrix=False,
    show_summary=False,
    ret_eval_dict=True
)
test_pred_dir_ds = test_ds.copy(deepcopy=True)
test_pred_dir_ds.labels = cls_mdls['lgb_1_dir']['preds_test'
].reshape(-1,1)
metrics_test_dir_dict, _ = mldatasets.**compute_aif_metrics**(
    test_ds,
    test_pred_dir_ds,
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
cls_mdls['lgb_1_dir'].**update**(metrics_test_dir_dict) 

数据链中的下一个链接是模型,因此即使我们去除了数据中的偏差,模型本身也会引入偏差,因此训练能够处理这种偏差的模型是有意义的,这正是我们接下来将要学习如何做的!

处理中偏差减轻方法

这些是一些最重要的处理中或模型特定的偏差减轻方法:

  • 成本敏感训练:我们已经在本章训练的每个 LightGBM 模型中通过scale_pos_weight参数整合了这种方法。它通常用于不平衡分类问题,并被简单地视为提高少数类准确率的一种手段。然而,鉴于类别不平衡往往倾向于使某些群体优于其他群体,这种方法也可以用来减轻偏差,但并不能保证一定会这样做。它可以作为类权重或通过创建自定义损失函数来整合。实现方式将根据模型类别和与偏差相关的成本而有所不同。如果它们与误分类线性增长,则类权重就足够了,否则建议使用自定义损失函数。

  • 约束:许多模型类别支持单调性和交互约束,TensorFlow LatticeTFL)提供了更高级的自定义形状约束。这些确保了特征和目标之间的关系被限制在某种模式中,在模型级别上设置了护栏。你会有很多理由想要使用它们,但其中最重要的是减轻偏差。我们将在第十二章单调约束和模型调优以实现可解释性中讨论这个话题。

  • 偏见消除正则化器:这种方法将偏见定义为敏感变量和目标变量之间的统计依赖性。然而,这种方法的目标是最大限度地减少间接偏见,排除可以通过简单地删除敏感变量来避免的偏见。因此,该方法首先通过偏见指数PI)对其进行量化,这是目标和敏感变量之间的互信息。顺便提一下,我们在第十章可解释性特征选择和工程中介绍了互信息。然后,与 L2 一起,PI 被整合到一个自定义正则化项中。从理论上讲,任何模型分类器都可以使用基于 PI 的正则化器进行正则化,但到目前为止,唯一实现的例子是逻辑回归。

  • Gerry fair 分类器:这是受公平性划分概念的启发,它在某一群体中看似公平,但在细分到子群体时却缺乏公平性。该算法利用一种基于博弈论的虚构博弈方法,其中你有一个学习者和审计员之间的零和游戏。学习者最小化预测误差和基于公平性的总惩罚项。审计员通过基于在最不公平对待的子群体中观察到的最坏结果来进一步惩罚学习者。

游戏的目标是达到纳什均衡,这是在两个可能具有矛盾目标的非合作玩家达成部分满足双方的解决方案时实现的。在这种情况下,学习者获得最小的预测误差和总体不公平性,审计员获得最小的子群体不公平性。该方法的实现是模型无关的。

  • 对抗性去偏:与 gerry fair 分类器类似,对抗性去偏利用两个对立的演员,但这次是两个神经网络:预测器和对手。我们最大化预测器预测目标的能力,同时最小化对手预测受保护特征的能力,从而增加特权群体和弱势群体之间机会的平等性。

  • 指数梯度下降法:这种方法通过将其简化为一系列此类问题来自动化成本敏感优化,并使用关于受保护属性(如人口统计学平等或均衡机会)的公平性约束。它是模型无关的,但仅限于与 scikit-learn 兼容的二分类器。

由于存在如此多的预处理方法,我们将在本章中仅使用其中两种。尽管如此,如果你对我们将不涉及的方法感兴趣,它们可以在 AIF360 库和文档中找到。

指数梯度下降法

ExponentiatedGradientReduction方法是对具有约束的成本敏感训练的实现。我们用基估计器初始化它,指定要执行的迭代次数的最大值(max_iter),并指定要使用的差异constraints。然后,我们fit它。这种方法可以在下面的代码片段中看到:

lgb_egr_mdl = ExponentiatedGradientReduction(
    estimator=lgb_base_mdl,
    max_iter=50,
    constraints='DemographicParity'
)
lgb_egr_mdl.fit(train_ds) 

我们可以使用predict函数来获取训练和测试预测,然后使用evaluate_class_metrics_mdlcompute_aif_metrics分别获取预测性能和公平性指标。我们将它们都放入cls_mdls字典中,如下面的代码片段所示:

train_pred_egr_ds = lgb_egr_mdl.**predict**(train_ds)
test_pred_egr_ds = lgb_egr_mdl.**predict**(test_ds)
cls_mdls['lgb_2_egr'] = mldatasets.**evaluate_class_metrics_mdl**(
    lgb_egr_mdl,
    train_pred_egr_ds.labels,
    test_pred_egr_ds.scores,
    test_pred_egr_ds.labels,
    y_train,
    y_test
)
metrics_test_egr_dict, _ = mldatasets.**compute_aif_metrics**(
    test_ds,
    test_pred_egr_ds,
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
cls_mdls['lgb_2_egr'].**update**(metrics_test_egr_dict) 

接下来,我们将了解一种部分模型无关的预处理方法,它考虑了交叉性。

gerry fair 分类器方法

Gerry fair 分类器部分是模型无关的。它只支持线性模型、支持向量机SVMs)、核回归和决策树。我们通过定义正则化强度(C)、用于早期停止的公平近似(gamma)、是否详细输出(printflag)、最大迭代次数(max_iters)、模型(predictor)以及要采用的公平概念(fairness_def)来初始化GerryFairClassifier。我们将使用错误的负例("FN")的公平概念来计算公平违规的加权差异。一旦初始化完成,我们只需要调用fit方法并启用early_termination,如果它在五次迭代中没有改进,则停止。以下代码片段展示了代码:

dt_gf_mdl = **GerryFairClassifier**(
    C=100,
    gamma=.005,
    max_iters=50,
    **fairness_def**='FN',
    printflag=True,
    **predictor**=tree.DecisionTreeRegressor(max_depth=3)
)
dt_gf_mdl.**fit**(train_ds, early_termination=True) 

我们可以使用predict函数来获取训练和测试预测,然后使用evaluate_class_metrics_mdlcompute_aif_metrics来分别获得预测性能和公平性指标。我们将它们都放入cl_smdls字典中,如下面的代码片段所示:

train_pred_gf_ds = dt_gf_mdl.**predict**(train_ds, threshold=False)
test_pred_gf_ds = dt_gf_mdl.**predict**(test_ds, threshold=False)
cls_mdls['dt_2_gf'] = mldatasets.evaluate_class_metrics_mdl(
    dt_gf_mdl,
    train_pred_gf_ds.labels,
    None,
    test_pred_gf_ds.labels,
    y_train,
    y_test
)
metrics_test_gf_dict, _ = mldatasets.**compute_aif_metrics**(
    test_ds,
    test_pred_gf_ds,
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
cls_mdls['dt_2_gf'].**update**(metrics_test_gf_dict) 

在模型推理之后的链中的下一个和最后一个链接,因此即使你去除了数据和模型的偏差,也可能还剩下一些偏差,因此在这个阶段处理它也是有意义的,这正是我们接下来将要学习如何做的!

后处理偏差缓解方法

这些是一些最重要的后处理或推理特定偏差缓解方法:

  • 预测弃权:这有许多潜在的好处,如公平性、安全性和控制成本,但具体应用取决于你的问题。通常,模型会返回所有预测,即使是低置信度的预测——也就是说,接近分类阈值的预测,或者当模型返回的置信区间超出预定阈值时。当涉及公平性时,如果我们将在低置信度区域将预测改为我不知道IDK),那么在评估公平性指标时,仅针对所做的预测,模型可能会因为副作用而变得更加公平。还可能将预测弃权作为一个内部处理方法。一篇名为《负责任地预测:通过学习推迟来提高公平性》的论文讨论了两种方法,通过训练模型来回避(学习预测 IDK)或推迟(当正确率低于专家意见时预测 IDK)。另一篇名为《在二元分类中弃权的效用》的论文采用了一个名为Knows What It KnowsKWIK)的强化学习框架,它对自己的错误有自我意识,但允许弃权。

  • 均衡机会后处理:也称为不同对待,这确保了特权群体和弱势群体在错误分类方面得到平等对待,无论是假阳性还是假阴性。它找到最佳概率阈值,通过改变标签来平衡组之间的机会。

  • 校准的均等机会后处理:这种方法不是改变标签,而是修改概率估计,使它们平均相等。它称之为校准。然而,这个约束不能同时满足假阳性和假阴性,因此你被迫在两者之间做出选择。因此,在召回率远比精确度更重要或反之亦然的情况下,校准估计的概率是有利的。

  • 拒绝选项分类法:这种方法利用了直觉,即决策边界周围的预测往往是最不公平的。然后,它找到决策边界周围的一个最优带,在这个带中,翻转弱势和优势群体的标签可以产生最公平的结果。

在本章中,我们只会使用这两种后处理方法。拒绝选项分类法在 AIF360 库和文档中可用。

均等机会后处理方法

均等机会后处理方法(EqOddsPostprocessing)初始化时,需要指定我们想要均等机会的群体和随机种子。然后,我们fit它。请注意,拟合需要两个数据集:原始数据集(test_ds)以及为我们基础模型提供预测的数据集(test_pred_ds)。fit所做的就是计算最优概率阈值。然后,predict创建一个新的数据集,其中这些阈值已经改变了labels。代码可以在下面的片段中看到:

epp = **EqOddsPostprocessing**(
    privileged_groups=privileged_groups,
    unprivileged_groups=underprivileged_groups,
    seed=rand
)
epp = epp.**fit**(test_ds, test_pred_ds)
test_pred_epp_ds = epp.**predict**(test_pred_ds) 

我们可以使用evaluate_class_metrics_mdlcompute_aif_metrics来分别获得等比例概率EPP)的预测性能和公平性指标。我们将它们都放入cls_mdls字典中。代码可以在下面的片段中看到:

cls_mdls['lgb_3_epp'] = mldatasets.**evaluate_class_metrics_mdl**(
    lgb_base_mdl,
    cls_mdls['lgb_0_base']['preds_train'],
    test_pred_epp_ds.scores,
    test_pred_epp_ds.labels,
    y_train,
    y_test
)
metrics_test_epp_dict, _ = mldatasets.**compute_aif_metrics**(
    test_ds,
    test_pred_epp_ds,
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
cls_mdls['lgb_3_epp'].**update**(metrics_test_epp_dict) 

接下来,我们将了解另一种后处理方法。主要区别在于它校准概率分数,而不仅仅是改变预测标签。

校准的均等机会后处理方法

校准的均等机会(CalibratedEqOddsPostprocessing)的实现方式与均等机会完全相同,但它有一个更关键的属性(cost_constraint)。这个属性定义了要满足哪个约束,因为它不能同时使分数对 FPRs 和 FNRs 都是公平的。我们选择 FPR,然后fitpredictevaluate,就像我们对均等机会所做的那样。代码可以在下面的片段中看到:

cpp = **CalibratedEqOddsPostprocessing**(
    privileged_groups=privileged_groups,
    unprivileged_groups=underprivileged_groups,
    **cost_constraint**="fpr",
    seed=rand
)
cpp = cpp.**fit**(test_ds, test_pred_ds)
test_pred_cpp_ds = cpp.**predict**(test_pred_ds)
cls_mdls['lgb_3_cpp'] = mldatasets.**evaluate_class_metrics_mdl**(
    lgb_base_mdl,
    cls_mdls['lgb_0_base']['preds_train'],
    test_pred_cpp_ds.scores,
    test_pred_cpp_ds.labels,
    y_train,
    y_test
)
metrics_test_cpp_dict, _ = mldatasets.**compute_aif_metrics**(
    test_ds,
    test_pred_cpp_ds,
    unprivileged_groups=underprivileged_groups,
    privileged_groups=privileged_groups
)
cls_mdls['lgb_3_cpp'].**update**(metrics_test_cpp_dict) 

现在我们已经尝试了六种偏差缓解方法,每个级别两种,我们可以将它们相互比较,并与基础模型进行比较!

将所有这些结合起来!

为了比较所有方法的指标,我们可以将字典(cls_mdls)放入 DataFrame(cls_metrics_df)中。我们只对一些性能指标和记录的大多数公平性指标感兴趣。然后,我们输出按测试准确率排序的 DataFrame,并使用所有公平性指标进行颜色编码。代码可以在下面的片段中看到:

cls_metrics_df = pd.DataFrame.from_dict(cls_mdls, 'index')[
    [
        'accuracy_train',
        'accuracy_test',
        'f1_test',
        'mcc_test',
        'SPD',
        'DI',
        'AOD',
        'EOD',
        'DFBA'
    ]
]
metrics_fmt_dict = dict(
    zip(cls_metrics_df.columns,['{:.1%}']*3+ ['{:.3f}']*6)
)
cls_metrics_df.sort_values(
    by='accuracy_test',
    ascending=False
).style.format(metrics_fmt_dict) 

前面的代码片段输出了以下 DataFrame:

图 11.7:所有偏差缓解方法与不同公平性指标的对比

图 11.7 显示,大多数方法在 SPD、DI、AOD 和 EOD 方面产生的模型比基础模型更公平。校准等概率后处理 (lgb_3_cpp) 是一个例外,但它具有最佳的 DFBAs 之一,但由于校准的不平衡性质,它产生了次优的 DI。请注意,这种方法在校准分数时特别擅长实现 FPR 或 FNR 的平衡,但所有这些公平性指标对于捕捉这一点都没有用。相反,你可以创建一个指标,它是 FPRs 的比率,就像我们在 第六章锚点和反事实解释 中所做的那样。偶然的是,这将是一个完美的 校准等概率CPP) 的用例。

获得最佳 SPD、DI、AOD 和 DFBA 以及次优 EOD 的方法是等概率后处理 (lgb_3_epp),因此让我们使用 XAI 的图表来可视化其公平性。为此,我们首先创建一个包含测试示例的 DataFrame (test_df),然后使用 replaceAGE_GROUP 转换为分类变量,并获取分类列的列表 (cat_cols_l)。然后,我们可以使用真实标签 (y_test)、EPP 模型的预测概率分数、DataFrame (test_df)、受保护属性 (cross_cols) 和分类列来比较不同的指标 (metrics_plot)。我们也可以为 受试者工作特征ROC) 图表 (roc_plot) 和 精确率-召回率PR) 图表 (pr_plot) 做同样的事情。代码可以在下面的代码片段中看到:

test_df = ccdefault_bias_df.loc[**X_test.index**]
test_df['AGE_GROUP'] = test_df.AGE_GROUP.**replace**(
    {0:'underprivileged', 1:'privileged'}
)
cat_cols_l = ccdefault_bias_df.dtypes[**lambda x: x==np.int8**
                                      ].index.tolist()
_ = xai.**metrics_plot**(
    y_test,cls_mdls['lgb_3_epp']['probs_test'],
    df=test_df, cross_cols=['AGE_GROUP'],
    categorical_cols=cat_cols_l
)
_ = xai.**roc_plot**(
    y_test, cls_mdls['lgb_3_epp']['probs_test'],
    df=test_df, cross_cols=['AGE_GROUP'],
    categorical_cols=cat_cols_l
)
_ = xai.**pr_plot**(
    y_test,
    cls_mdls['lgb_3_epp']['probs_test'],
    df=test_df, cross_cols=['AGE_GROUP'],
    categorical_cols=cat_cols_l
) 
Figure 11.8. The first one shows that even the fairest model still has some disparities between both groups, especially between precision and recall and, by extension, F1 score, which is their average. However, the ROC curve shows how close both groups are from an FPR versus a TPR standpoint. The third plot is where the disparities in precision and recall become even more evident. This all demonstrates how hard it is to keep a fair balance on all fronts! Some methods are best for making one aspect perfect but nothing else, while others are pretty good on a handful of aspects but nothing else. Despite the shortcomings of the methods, most of them achieved a sizable improvement. Ultimately, choosing methods will depend on what you most care about, and combining them is also recommended for maximum effect! The output is shown here:

图 11.8:展示最公平模型的公平性图表

我们已经完成了偏差缓解练习,并将继续进行因果推断练习,我们将讨论如何确保公平和稳健的政策。

创建因果模型

决策通常需要理解因果关系。如果效果是可取的,你可以决定复制其原因,或者避免它。你可以故意改变某些东西来观察它如何改变结果,或者将意外效应追溯到其原因,或者模拟哪种改变会产生最大的积极影响。因果推断可以通过创建因果图和模型来帮助我们完成所有这些,这些图将所有变量联系起来并估计效应,以便做出更原则性的决策。然而,为了正确评估原因的影响,无论是设计还是意外,你需要将其效应与混杂变量分开。

因果推断与本章相关的原因是银行的决策具有显著影响持卡人生计的力量,鉴于自杀率的上升,甚至关系到生死。因此,有必要极其谨慎地评估政策决策。

台湾银行进行了一项为期 6 个月的贷款政策实验。银行看到了形势的严峻,知道那些最高风险的违约客户将 somehow 从资产负债表中注销,从而减轻了这些客户的财务义务。因此,实验的重点仅涉及银行认为可以挽救的部分,即低至中等风险的违约客户,现在实验已经结束,他们想了解以下政策如何影响了客户行为:

  • 降低信用额度:一些客户的信用额度降低了 25%。

  • 付款计划:他们被给予 6 个月的时间来偿还当前的信用卡债务。换句话说,债务被分成六部分,每个月他们必须偿还一部分。

  • 两项措施:降低信用额度和付款计划。

此外,2005 年台湾普遍的信用卡利率约为 16-20%,但银行得知这些利率将被台湾金融监督管理委员会限制在 4%。因此,他们确保所有参与实验的客户都能自动获得该水平的利率。一些银行高管认为这只会加剧债务负担,并在这个过程中创造更多的“信用卡奴隶”。这些担忧促使提出以较低的信用卡额度作为对策进行实验的建议。另一方面,制定付款计划是为了了解债务减免是否给了客户使用信用卡而不必担心的空间。

在业务方面,理由是必须鼓励健康水平的消费,因为随着利率的降低,大部分利润将来自支付处理、现金返还合作伙伴关系和其他与消费相关的来源,从而增加客户的使用寿命。这对客户也有好处,因为如果他们在作为消费者比作为债务人更有利可图,这意味着激励措施已经到位,以防止他们成为后者。所有这些都证明了使用估计的终身价值(_LTV)作为代理指标来衡量实验结果如何使银行和客户受益的合理性。多年来,银行一直在使用一种相当准确的计算方法来估计信用卡持卡人根据他们的消费和支付历史以及诸如额度、利率等参数将为银行提供多少价值。

在实验设计的术语中,选择的政策被称为治疗,除了三个接受治疗的组别外,还有一个未接受治疗的对照组——即政策没有任何变化,甚至没有降低利率。在我们继续前进之前,让我们首先初始化一个包含治疗名称的列表(treatment_names)和一个包含甚至对照组的列表(all_treatment_names),如下所示:

treatment_names = [
    'Lower Credit Limit',
    'Payment Plan',
    'Payment Plan &Credit Limit'
]
all_treatment_names = np.array(["None"] + treatment_names) 

现在,让我们检查实验的结果,以帮助我们设计一个最优的因果模型。

理解实验结果

评估治疗有效性的一个相当直观的方法是通过比较它们的成果。我们想知道以下两个简单问题的答案:

  • 相比对照组,治疗是否降低了违约率?

  • 支出行为是否有利于提高终身价值估计?

我们可以在一个图表中可视化这两个因素。为此,我们获得一个包含每个组违约百分比的pandas系列(pct_s),然后另一个包含每个组终身价值总和的系列(ltv_s),单位为千新台币(NTD)(K$)。我们将这两个系列放入pandas DataFrame 中,并绘制它,如下面的代码片段所示:

pct_s = ccdefault_causal_df[
    ccdefault_causal_df.IS_DEFAULT==1]
    .groupby(['_TREATMENT'])
    .size()
    /ccdefault_causal_df.groupby(['_TREATMENT']).size()
ltv_s = ccdefault_causal_df.groupby(
    ['_TREATMENT'])['_LTV'].sum()/1000
plot_df = pd.DataFrame(
    {'% Defaulted':pct_s,
     'Total LTV, K$':ltv_s}
)
plot_df.index = all_treatment_names
ax = plot_df.plot(secondary_y=['Total LTV, K$'], figsize=(8,5))
ax.get_legend().set_bbox_to_anchor((0.7, 0.99))
plt.grid(False) 
Figure 11.9. It can be inferred that all treatments fare better than the control group. The lowering of the credit limit on its own decreases the default rate by over 12% and more than doubles the estimated LTV, while the payment plan only decreases the defaults by 3% and increases the LTV by about 85%. However, both policies combined quadrupled the control group’s LTV and reduced the default rate by nearly 15%! The output can be seen here:

图表,折线图 描述自动生成

图 11.9:不同信用政策的治疗实验结果

在银行高管们为找到了获胜政策而欢欣鼓舞之前,我们必须检查他们是如何在实验中的信用卡持卡人之间分配它的。我们了解到,他们根据风险因素(由_risk_score变量衡量)选择治疗。然而,终身价值在很大程度上受到可用信用额(_CC_LIMIT)的影响,因此我们必须考虑这一点。理解分布的一种方法是通过将两个变量以散点图的形式相互对比,并按_TREATMENT进行颜色编码。以下代码片段展示了如何实现这一点:

sns.**scatterplot**(
    x=ccdefault_causal_df['_CC_LIMIT'].values,
    y=ccdefault_causal_df['_risk_score'].values,
    hue=all_treatment_names[ccdefault_causal_df['_TREATMENT'].values],
    hue_order = all_treatment_names
) 

上述代码生成了图 11.10中的图表。它显示三种治疗对应不同的风险水平,而对照组(None)在垂直方向上分布得更广。基于风险水平分配治疗的选择也意味着他们基于_CC_LIMIT不均匀地分配了治疗。我们应该问自己,这个实验的偏见条件是否使得结果解释甚至变得可行。请看以下输出:

图表,散点图 描述自动生成

图 11.10:风险因素与原始信用额的比较

图 11.10中的散点图展示了治疗在风险因素上的分层。然而,散点图在理解分布时可能具有挑战性。为此,最好使用核密度估计(KDE)图。因此,让我们看看_CC_LIMIT和终身价值(_LTV)在所有治疗中的分布情况,使用 Seaborn 的displot。请看以下代码片段:

sns.**displot**(
    ccdefault_causal_df,
    x="_CC_LIMIT",
    hue="_TREATMENT",
    kind="kde",
    fill=True
)
sns.**displot**(
    ccdefault_causal_df,
    x="_LTV",
    hue="_TREATMENT",
    kind="kde", fill=True
) 
Figure 11.11. We can easily tell how far apart all four distributions are for both plots, mostly regarding treatment #3 (Payment Plan & Lower Credit Limit), which tends to be centered significantly more to the right and has a longer and fatter right tail. You can view the output here:

图表 描述自动生成

图 11.11:根据 _TREATMENT 的 _CC_LIMIT 和 _LTV 的 KDE 分布

理想情况下,当你设计此类实验时,你应该根据可能改变结果的相关因素,在所有组别中追求平等分布。然而,这并不总是可行的,可能因为物流或战略限制。在这种情况下,结果(_LTV)根据客户信用卡额度(_CC_LIMIT)、异质性特征——换句话说,直接影响处理效果的变量,也称为异质性处理效应调节因子而变化。我们可以创建一个包含_TREATMENT特征和效应调节因子(_CC_LIMIT)的因果模型。

理解因果模型

我们将要构建的因果模型可以分为以下四个部分:

  • 结果 (Y): 因果模型的结果变量。

  • 处理 (T): 影响结果的处理变量。

  • 效应调节因子 (X): 影响效应异质性的变量,它位于处理和结果之间。

  • 控制变量 (W): 也称为共同原因混杂因素。它们是影响结果和处理的特征。

我们将首先将这些组件在数据中识别为单独的pandas数据框,如下所示:

**W** = ccdefault_causal_df[
    [
      '_spend','_tpm', '_ppm', '_RETAIL','_URBAN', '_RURAL',
      '_PREMIUM'
    ]
]
**X** = ccdefault_causal_df[['_CC_LIMIT']]
**T** = ccdefault_causal_df[['_TREATMENT']]
**Y** = ccdefault_causal_df[['_LTV']] 

我们将使用双重稳健学习DRL)方法来估计处理效应。它被称为“双重”,因为它利用了两个模型,如下所示:

  • 它使用回归模型预测结果,如图所示:

图片

  • 它使用倾向模型预测处理,如图所示:

图片

由于最终阶段结合了两种模型,同时保持了多个理想的统计特性,如置信区间和渐近正态性,因此它也是稳健的。更正式地说,估计利用了条件在处理t上的回归模型g和倾向模型p,如下所示:

图片

它还做了以下操作:

目标是推导出与每个处理t相关的异质效应X条件平均处理效应CATE),表示为。首先,DRL 方法通过应用逆倾向来去偏回归模型,如下所示:

如何精确地估计模型中的系数将取决于所采用的 DRL 变体。我们将使用线性变体(LinearDRLearner),以便它返回系数和截距,这些可以很容易地解释。它通过在处理组t和控制组x[t]上的结果差异中运行普通线性回归OLS)来推导。这种直观的做法是有意义的,因为处理的估计效应减去没有处理的估计效应(t = 0)是这种处理的效应。

现在,所有理论都已经讲完,让我们深入挖掘吧!

初始化线性双重稳健学习器

我们可以通过指定任何与 scikit-learn 兼容的回归器(model_regression)和分类器(model_propensity)来从econml库初始化一个LinearDRLearner,我们称之为drlearner。我们将使用 XGBoost 来处理这两个,但请注意,分类器有一个objective=multi:softmax属性。记住,我们有多个处理,所以这是一个多类分类问题。代码可以在下面的片段中看到:

drlearner = **LinearDRLearner**(
    model_regression=xgb.XGBRegressor(learning_rate=0.1),
    model_propensity=xgb.XGBClassifier(learning_rate=0.1,
    max_depth=2,
    objective="multi:softmax"),
    random_state=rand
) 

如果你想了解回归模型和倾向性模型都在做什么,你可以轻松地拟合xgb.XGBRegressor().fit(W.join(X),Y)xgb.XGBClassifier(objective="multi:softmax").fit(W.join(X), T)模型。我们现在不会这样做,但如果你好奇,你可以评估它们的性能,甚至运行特征重要性方法来了解它们各自预测的影响。因果模型将它们与 DRL 框架结合在一起,导致不同的结论。

拟合因果模型

我们可以使用drlearner中的fit来拟合因果模型,利用econmldowhy包装器。首先的属性是YTXY组件:pandas数据框。可选地,你可以为这些组件提供变量名称:每个pandas数据框的列名。最后,我们希望估计处理效应。可选地,我们可以提供用于此的效果修饰符(X),我们将使用其中的一半数据来这样做,如下面的代码片段所示:

causal_mdl = drlearner.dowhy.fit(
    Y,
    T,
    X=X,
    W=W,
    outcome_names=Y.columns.to_list(),
    treatment_names=T.columns.to_list(),
    feature_names=X.columns.to_list(),
    confounder_names=W.columns.to_list(),
    target_units=X.iloc[:550].values
) 

在因果模型初始化后,我们可以可视化它。pydot库与pygraphviz可以为我们完成这项工作。请注意,这个库在某些环境中配置困难,所以它可能无法加载并显示view_model的默认图形。如果发生这种情况,请不要担心。看看下面的代码片段:

try:
    display(**Image**(to_pydot(causal_mdl._graph._graph).create_png()))
except:
    causal_mdl.**view_model**() 

前一个代码片段中的代码输出了此处显示的模型图。有了它,你可以欣赏到所有变量是如何相互连接的:

图描述自动生成

图 11.12:因果模型图

因果模型已经拟合,那么让我们检查和解释结果,好吗?

理解异质处理效应

首先,需要注意的是,econmldowhy 包装器通过 dowhy.fit 方法简化了一些步骤。通常,当你直接使用 dowhy 构建 CausalModel(如本例所示)时,它有一个名为 identify_effect 的方法,该方法推导出要估计的效果的概率表达式(即 识别估计量)。在这种情况下,这被称为 平均处理效应ATE)。然后,另一个名为 estimate_effect 的方法接受这个表达式以及它应该与之关联的模型(回归和倾向)。有了它们,它为每个结果 i 和处理 t 计算 ATE,,和 CATE,。然而,由于我们使用了包装器来 fit 因果模型,它自动处理了识别和估计步骤。

您可以通过因果模型的 identified_estimand_ 属性访问识别的 ATE,并通过 estimate_ 属性访问估计结果。以下代码片段显示了代码:

identified_ate = causal_mdl.identified_estimand_
print(identified_ate)
drlearner_estimate = causal_mdl.estimate_
print(drlearner_estimate) 
 the estimand expression for identified_estimand_, which is a derivation of the expected value for , with some assumptions. Then, the causal-realized estimate_ returns the ATE for treatment #1, as illustrated in the following code snippet:
Estimand type: nonparametric-ate
### Estimand : 1
Estimand name: backdoor1 (Default)
Estimand expression:
      d 
─────────────(E[_LTV|_RETAIL,_URBAN,_PREMIUM,_RURAL,_CC_LIMIT,
d[_TREATMENT]
])
Estimand assumption 1, Unconfoundedness: If U→{_TREATMENT} and U→_LTV then \ P(_LTV|_TREATMENT,_RETAIL,_URBAN,_PREMIUM,_RURAL,_CC_LIMIT,_spend,_ppm,_tpm,U) = \ P(_LTV|_TREATMENT,_RETAIL,_URBAN,_PREMIUM,_RURAL,_CC_LIMIT,_spend,_ppm,_tpm)
*** Causal Estimate ***
## Identified estimand
Estimand type: nonparametric-ate
## Realized estimand
b:_LTV ~ _TREATMENT + _RETAIL + _URBAN + _PREMIUM + _RURAL + \ _CC_LIMIT + _spend + _ppm + _tpm | _CC_LIMIT
Target units:
## Estimate
Mean value: 7227.904763676559
Effect estimates: [6766.07978487 7337.39526574 7363.36013004
                   7224.20893104 7500.84310705 7221.40328496] 

接下来,我们可以遍历因果模型中的所有处理,并为每个处理返回一个总结,如下所示:

for i in range(causal_mdl._d_t[0]):
    print("Treatment: %s" % treatment_names[i])
     display(econml_mdl.**summary**(T=i+1)) 

前面的代码输出了三个线性回归总结。第一个看起来像这样:

图形用户界面,表格  自动生成的描述

图 11.13:某处理总结

为了更好地理解系数和截距,我们可以用它们各自的置信区间来绘制它们。为此,我们首先创建一个处理索引(idxs)。有三个处理,所以这是一个介于 0 和 2 之间的数字数组。然后,使用列表推导将所有系数(coef_)和截距(intercept_)放入一个数组中。然而,对于系数和截距的 90%置信区间来说,这要复杂一些,因为 coef__intervalintercept__interval 返回这些区间的下限和上限。我们需要两个方向的误差范围的长度,而不是界限。我们从这些界限中减去系数和截距,以获得它们各自的误差范围,如下面的代码片段所示:

idxs = np.arange(0, causal_mdl._d_t[0])
coefs = np.hstack([causal_mdl.**coef_**(T=i+1) for i in idxs])
intercepts = np.hstack(
    [causal_mdl.**intercept_**(T=i+1)for i in idxs]
)
coefs_err = np.hstack(
    [causal_mdl.**coef__interval**(T=i+1) for i in idxs]
)
coefs_err[0, :] = coefs - coefs_err[0, :]
coefs_err[1, :] = coefs_err[1, :] - coefs
intercepts_err = np.vstack(
    [causal_mdl.**intercept__interval**(T=i+1) for i in idxs]
).Tintercepts_err[0, :] = intercepts - intercepts_err[0, :]
intercepts_err[1, :] = intercepts_err[1, :] - intercepts 

接下来,我们使用 errorbar 函数绘制每个处理及其相应误差的系数。我们还可以将截距作为另一个子图进行相同的操作,如下面的代码片段所示:

ax1 = plt.subplot(2, 1, 1)
plt.errorbar(**idxs**, **coefs**, **coefs_err**, fmt="o")
plt.xticks(idxs, treatment_names)
plt.setp(ax1.get_xticklabels(), visible=False)
plt.title("Coefficients")
plt.subplot(2, 1, 2)
plt.errorbar(**idxs**, **intercepts**, **intercepts_err**, fmt="o")
plt.xticks(idxs, treatment_names)
plt.title("Intercepts") 

前面的代码片段输出以下内容:

包含图表的图片  自动生成的描述

图 11.14:所有处理的系数和截距

通过图 11.14,你可以欣赏到所有截距和系数的相对误差范围有多大。尽管如此,很明显,仅从系数来看,从左到右读取时,治疗的效果会逐渐变好。但在我们得出支付计划 & 降低信用额度是最佳政策的结论之前,我们必须考虑截距,这个截距对于这种治疗比第一个要低。本质上,这意味着具有最低信用卡额度的客户更有可能通过第一种政策提高终身价值,因为系数是乘以限制的,而截距是起点。鉴于没有一种最佳政策适用于所有客户,让我们来看看如何使用因果模型为每个客户选择政策。

选择政策

我们可以使用const_marginal_effect方法根据客户基础制定信用政策,该方法考虑了X效果修正器(_CC_LIMIT)并计算反事实 CATE,图片。换句话说,它返回了所有观察到的X中所有治疗的估计_LTV

然而,它们并不都花费相同。制定支付计划需要每份合同约NT\(*1,000 的行政和法律费用,根据银行的精算部门,降低信用额度 25 的机遇成本估计为每月平均支付*NT\)72(_ppm),在整个客户生命周期内。为了考虑这些成本,我们可以设置一个简单的lambda函数,该函数接受所有治疗的支付计划成本并将它们添加到变量信用额度成本中,这自然地乘以_ppm。给定一个长度为n的信用卡额度数组,成本函数返回一个(n, 3)维度的数组,其中包含每个治疗的成本。然后,我们获得反事实 CATE 并扣除成本(treatment_effect_minus_costs)。然后,我们将数组扩展以包括一列表示治疗的零,并使用argmax返回每个客户的推荐治疗索引(recommended_T),如下面的代码片段所示:

**cost_fn** = lambda X: np.repeat(
    np.array([[0, 1000, 1000]]),
    X.shape[0], axis=0) + (np.repeat(np.array([[72, 0, 72]]),
    X.shape[0], axis=0)
    *X._ppm.values.reshape(-1,1)
)
**treatment_effect_minus_costs** = causal_mdl.const_marginal_effect(
    X=X.values) - **cost_fn**(ccdefault_causal_df)
treatment_effect_minus_costs = np.hstack(
    [
        np.zeros(X.shape),
        **treatment_effect_minus_costs**
    ]
)
recommended_T = np.**argmax**(treatment_effect_minus_costs, axis=1) 

我们可以使用scatterplot _CC_LIMIT_ppm,按推荐治疗进行颜色编码,以观察客户的最佳信用政策,如下所示:

sns.scatterplot(
    x=ccdefault_causal_df['_CC_LIMIT'].values,
    y=ccdefault_causal_df['_ppm'].values,
    hue=all_treatment_names[recommended_T],
    hue_order=all_treatment_names
)
plt.title("Optimal Credit Policy by Customer")
plt.xlabel("Original Credit Limit")
plt.ylabel("Payments/month") 

前面的代码片段输出以下散点图:

图表,散点图  自动生成的描述图 11.15:根据原始信用额度和卡片使用情况,客户最优信用政策

图 11.15 中很明显,“None”(无治疗)永远不会被推荐给任何客户。即使不扣除成本,这一事实也成立——你可以从 treatment_effect_minus_costs 中移除 cost_fn 并重新运行输出图表的代码来验证,无论成本如何,治疗总是被推荐的。你可以推断出所有治疗对客户都有益,其中一些比其他更多。当然,根据客户的不同,一些治疗比其他治疗对银行更有利。这里有一条很细的界限。

最大的担忧之一是客户的公平性,特别是那些银行伤害最严重的客户:弱势年龄群体。仅仅因为一项政策对银行的成本比另一项更高,并不意味着应该排除访问其他政策的机会。评估这一点的一种方法可以使用所有推荐政策的百分比堆叠条形图。这样,我们可以观察推荐政策在特权群体和弱势群体之间的分配情况。看看下面的代码片段:

ccdefault_causal_df['recommended_T'] = recommended_T
plot_df = ccdefault_causal_df.groupby(
    ['recommended_T','AGE_GROUP']).size().reset_index()
plot_df['AGE_GROUP'] = plot_df.AGE_GROUP.**replace**(
    {0:'underprivileged', 1:'privileged'}
)
plot_df = plot_df.pivot(
    columns='AGE_GROUP',
    index='recommended_T',
    values=0
)
plot_df.index = treatment_names
plot_df = plot_df.apply(lambda r: **r/r.sum()*100**, axis=1)
plot_df.plot.bar(stacked=True, rot=0)
plt.xlabel('Optimal Policy')
plt.ylabel('%') 

前一个代码片段中的代码输出如下:

图 11.16:最优策略分布的公平性

图 11.16 展示了特权群体被分配到具有支付计划的政策的比例更高。这种差异主要是由于银行的成本是一个因素,所以如果银行能够承担一些这些成本,那么它可能会更加公平。但什么是公平的解决方案呢?选择信贷政策是程序公平性的一个例子,并且有许多可能的定义。平等对待是否字面意义上的平等对待或比例对待?它是否包括选择自由的概念?如果客户更喜欢一项政策而不是另一项,他们应该被允许切换吗?无论定义如何,都可以通过因果模型的帮助来解决。我们可以将相同的政策分配给所有客户,或者调整推荐政策的分布,使得比例相等,或者每个客户都可以在第一和第二最优政策之间进行选择。有如此多的方法可以这样做!

测试估计的鲁棒性

dowhy 库提供了四种方法来测试估计因果效应的鲁棒性,具体如下:

  • 随机共同原因:添加一个随机生成的混杂因素。如果估计是鲁棒的,ATE(平均处理效应)不应该变化太多。

  • 安慰剂治疗反驳者:用随机变量(安慰剂)替换治疗。如果估计是鲁棒的,ATE 应该接近零。

  • 数据子集反驳者:移除数据的一个随机子集。如果估计器泛化良好,ATE 不应该变化太多。

  • 添加未观察到的共同原因:添加一个与处理和结果都相关的未观察到的混杂因素。估计量假设存在一定程度的未混杂性,但添加更多应该会偏误估计。根据混杂因素效应的强度,它应该对 ATE 有相同的影响。

我们将用前两个来测试稳健性。

添加随机共同原因

此方法通过调用refute_estimate并指定method_name="random_common_cause"来实现,这是最简单的实现方式。这将返回一个可以打印的摘要。请看以下代码片段:

ref_random = causal_mdl.refute_estimate(
    method_name="random_common_cause"
)
print(ref_random) 

前述代码片段输出如下:

Refute: Add a Random Common Cause
Estimated effect:7227.904763676559
New effect:7241.433599647397 

前面的输出告诉我们,一个新的共同原因,或称 W 变量,对平均处理效应(ATE)没有显著影响。

用随机变量替换处理变量

使用此方法,我们将用噪声替换处理变量。如果处理与结果有稳健的相关性,这应该将平均效应降至零。为了实现它,我们同样调用refute_estimate函数,但使用placebo_treatment_refuter作为方法。我们还必须指定placebo_type和模拟次数(num_simulations)。我们将使用的安慰剂类型是permute,模拟次数越多越好,但这也会花费更长的时间。代码可以在以下片段中看到:

ref_placebo = causal_mdl.refute_estimate(
    method_name="placebo_treatment_refuter",
    placebo_type="permute", num_simulations=20
)
print(ref_placebo) 

前面的代码输出如下:

Refute: Use a Placebo Treatment
Estimated effect:7227.904763676559
New effect:381.05420029741083
p value:0.32491556283289624 

如前述输出所示,新的效应接近于零。然而,鉴于 p 值高于 0.05,我们不能拒绝 ATE 大于零的零假设。这告诉我们,估计的因果效应并不非常稳健。我们可能通过添加相关的混杂因素或使用不同的因果模型来改进它,但同样,实验设计存在我们无法修复的缺陷,例如银行根据风险因素偏袒地指定治疗方式。

任务完成

本章的任务有两个,如下所述:

  • 创建一个公平的预测模型来预测哪些客户最有可能违约。

  • 创建一个稳健的因果模型来估计哪些政策对客户和银行最有益。

关于第一个目标,我们已经根据四个公平性指标(SPD、DI、AOD、EOD)——在比较特权群体和弱势群体年龄组时——产生了四个具有偏差缓解方法的模型,这些模型在客观上比基础模型更公平。然而,根据 DFBA(参见图 11.7),只有其中两个模型在同时使用年龄组和性别时具有交叉公平性。通过结合方法,我们仍然可以显著提高公平性,但任何一种模型都能改进基础模型。

对于第二个目标,因果推断框架确定,所测试的任何政策对于双方来说都比没有政策要好。太好了!然而,它得出的估计并没有确立一个单一的获胜者。尽管如此,正如预期的那样,推荐的政策会根据客户的信用额度而变化——另一方面,如果我们旨在最大化银行利润,我们必须考虑信用卡的平均使用情况。盈利性的问题提出了我们必须协调的两个目标:制定对客户或银行最有利的推荐政策。

因此,如何程序上公平是一个复杂的问题,有许多可能的答案,任何解决方案都可能导致银行吸收与实施政策相关的部分成本。至于鲁棒性,尽管实验存在缺陷,但我们可以说我们的估计具有中等水平的鲁棒性,通过了一个鲁棒性测试但没有通过另一个。话虽如此,这完全取决于我们认为足够鲁棒以验证我们的发现。理想情况下,我们会要求银行开始一个新的无偏实验,但等待另外 6 个月可能不可行。

在数据科学中,我们经常发现自己在与有缺陷的实验和有偏差的数据打交道,并必须充分利用它们。因果推断通过分离原因和效果,包括估计及其相应的置信区间,提供了一种这样做的方法。然后我们可以提供带有所有免责声明的发现,以便决策者可以做出明智的决策。有偏差的决策会导致有偏差的结果,因此解决偏差的道德必要性可以从塑造决策开始。

摘要

在阅读本章之后,你应该了解如何通过视觉和指标在数据和模型中检测偏差,然后通过预处理、处理和后处理方法来减轻偏差。我们还通过估计异质处理效应、用它们做出公平的政策决策以及测试它们的鲁棒性来了解因果推断。在下一章中,我们也将讨论偏差,但学习如何调整模型以满足多个目标,包括公平性。

数据集来源

Yeh, I. C., & Lien, C. H. (2009). 比较数据挖掘技术在预测信用卡客户违约概率方面的准确性. 《专家系统与应用》,36(2),2473-2480: dl.acm.org/doi/abs/10.1016/j.eswa.2007.12.020

进一步阅读

  • Chang, C., Chang, H.H., and Tien, J., 2017, 关于信息不对称下金融监管机构应对策略的研究:台湾信用卡市场案例研究. 《通用管理杂志》,5,429-436: doi.org/10.13189/ujm.2017.050903

  • Foulds, J., and Pan, S., 2020, An Intersectional Definition of Fairness. 2020 IEEE 36th International Conference on Data Engineering (ICDE), 1918-1921: arxiv.org/abs/1807.08362

  • Kamiran, F., and Calders, T., 2011, Data preprocessing techniques for classification without discrimination. Knowledge and Information Systems, 33, 1-33: link.springer.com/article/10.1007/s10115-011-0463-8

  • Feldman, M., Friedler, S., Moeller, J., Scheidegger, C., and Venkatasubramanian, S., 2015, Certifying and Removing DI. Proceedings of the 21st ACM SIGKDD International Conference on Knowledge Discovery and Data Mining: arxiv.org/abs/1412.3756

  • Kamishima, T., Akaho, S., Asoh, H., and Sakuma, J., 2012, Fairness-Aware Classifier with Prejudice Remover Regularizer. ECML/PKDD: dl.acm.org/doi/10.5555/3120007.3120011

  • A. Agarwal, A. Beygelzimer, M. Dudik, J. Langford, and H. Wallach, A Reductions Approach to Fair Classification, International Conference on Machine Learning, 2018. arxiv.org/pdf/1803.02453.pdf

  • Kearns, M., Neel, S., Roth, A., and Wu, Z., 2018, Preventing Fairness Gerrymandering: Auditing and Learning for Subgroup Fairness. ICML: arxiv.org/pdf/1711.05144.pdf

  • Pleiss, G., Raghavan, M., Wu, F., Kleinberg, J., and Weinberger, K.Q., 2017, On Fairness and Calibration. NIPS: arxiv.org/abs/1709.02012

  • Foster, D. and Syrgkanis, V., 2019, Orthogonal Statistical Learning. ICML: arxiv.org/abs/1901.09036

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/inml

第十二章:单调约束和模型调优以提高解释性

大多数模型类都有超参数,可以通过调整来提高执行速度、增强预测性能和减少过拟合。减少过拟合的一种方法是在模型训练中引入正则化。在第三章解释性挑战中,我们将正则化称为一种补救的解释性属性,它通过惩罚或限制来降低复杂性,迫使模型学习输入的更稀疏表示。正则化模型具有更好的泛化能力,这就是为什么强烈建议使用正则化调整模型以避免对训练数据的过拟合。作为副作用,正则化模型通常具有更少的特征和交互,这使得模型更容易解释——更少的噪声意味着更清晰的信号

尽管有许多超参数,但我们只会关注那些通过控制过拟合来提高解释性的参数。在一定意义上,我们还将回顾通过前几章中探讨的类别不平衡相关超参数来减轻偏差。

第二章解释性的关键概念,解释了三个影响解释性的模型属性:非线性、交互性和非单调性。如果模型自行其是,它可能会学习到一些虚假的、反直觉的非线性和交互性。正如在第十章,为解释性进行特征选择和工程中讨论的那样,可以通过仔细的特征工程来设置限制以防止这种情况。然而,我们如何为单调性设置限制呢?在本章中,我们将学习如何使用单调约束来实现这一点。同样,单调约束可以是模型与特征工程的对应物,而正则化可以是我们在第十章中涵盖的特征选择方法的模型对应物!

本章我们将涵盖的主要主题包括:

  • 通过特征工程设置限制

  • 调整模型以提高解释性

  • 实现模型约束

技术要求

本章的示例使用了mldatasetspandasnumpysklearnxgboostlightgbmcatboosttensorflowbayes_opttensorflow_latticematplotlibseabornscipyxaishap库。如何安装这些库的说明在序言中。

本章的代码位于此处:packt.link/pKeAh

任务

算法公平性问题具有巨大的社会影响,从福利资源的分配到救命手术的优先级,再到求职申请的筛选。这些机器学习算法可以决定一个人的生计或生命,而且往往是边缘化和最脆弱的群体从这些算法中受到最恶劣的对待,因为这些算法持续传播从数据中学到的系统性偏见。因此,贫困家庭可能被错误地归类为虐待儿童;种族少数群体在医疗治疗中可能被优先级过低;而女性可能被排除在高薪技术工作之外。即使在涉及不那么直接和个性化的风险的情况下,如在线搜索、Twitter/X 机器人账户和社交媒体档案,社会偏见如精英主义、种族主义、性别歧视和年龄歧视也会得到加强。

本章将继续延续第六章的主题,即锚点和反事实解释。如果您不熟悉这些技术,请回过头去阅读第六章,以获得对问题的深入了解。第六章中的再犯案例是算法偏差的一个例子。开发COMPAS 算法(其中COMPAS代表矫正犯人管理配置文件替代制裁)的公司的联合创始人承认,在没有与种族相关的问题的情况下很难给出分数。这种相关性是分数对非裔美国人产生偏见的主要原因之一。另一个原因是训练数据中黑人被告可能被过度代表。我们无法确定这一点,因为我们没有原始的训练数据,但我们知道非白人少数族裔在服刑人员群体中被过度代表。我们还知道,由于与轻微毒品相关罪行相关的编码歧视和黑人社区的过度执法,黑人通常在逮捕中被过度代表。

那么,我们该如何解决这个问题呢?

第六章锚点和反事实解释中,我们通过一个代理模型成功地证明了 COMPAS 算法存在偏见。对于本章,让我们假设记者发表了你的发现,一个算法正义倡导团体阅读了文章并联系了你。制作犯罪评估工具的公司没有对偏见承担责任,声称他们的工具只是反映了现实。该倡导团体雇佣你来证明机器学习模型可以被训练得对黑人被告的偏见显著减少,同时确保该模型仅反映经过验证的刑事司法现实

这些被证实的现实包括随着年龄增长,再犯风险单调下降,以及与先前的强烈相关性,这种相关性随着年龄的增长而显著增强。学术文献支持的另一个事实是,女性在总体上显著不太可能再犯和犯罪。

在我们继续之前,我们必须认识到监督学习模型在从数据中捕获领域知识方面面临几个障碍。例如,考虑以下情况:

  • 样本、排除或偏见偏差:如果您的数据并不能真正代表模型意图推广的环境,会怎样?如果是这样,领域知识将与您在数据中观察到的结果不一致。如果产生数据的那个环境具有固有的系统性或制度性偏见,那么数据将反映这些偏见。

  • 类别不平衡:如第十一章“偏差缓解和因果推断方法”中所述,类别不平衡可能会使某些群体相对于其他群体更有利。在追求最高准确率的最有效途径中,模型将从这个不平衡中学习,这与领域知识相矛盾。

  • 非单调性:特征直方图中的稀疏区域或高杠杆异常值可能导致模型在领域知识要求单调性时学习到非单调性,任何之前提到的问题都可能促成这一点。

  • 无影响力的特征:一个未正则化的模型将默认尝试从所有特征中学习,只要它们携带一些信息,但这会阻碍从相关特征中学习或过度拟合训练数据中的噪声。一个更简约的模型更有可能支持由领域知识支持的特性。

  • 反直觉的交互作用:如第十章“用于可解释性的特征选择和工程”中提到的,模型可能会偏好与领域知识支持的交互作用相反的反直觉交互作用。作为一种副作用,这些交互作用可能会使一些与它们相关的群体受益。在第六章“锚点和反事实解释”中,我们通过理解双重标准证明了这一点。

  • 例外情况:我们的领域知识事实基于总体理解,但在寻找更细粒度的模式时,模型会发现例外,例如女性再犯风险高于男性的区域。已知现象可能不支持这些模型,但它们可能是有效的,因此我们必须小心不要在我们的调整努力中抹去它们。

该倡导组织已验证数据仅足以代表佛罗里达州的一个县,并且他们已经向您提供了一个平衡的数据集。第一个障碍很难确定和控制。第二个问题已经得到解决。现在,剩下的四个问题就交给你来处理了!

方法

您已经决定采取三步走的方法,如下所示:

  • 使用特征工程设置护栏:借鉴第六章“锚点和反事实解释”中学习到的经验,以及我们已有的关于先验和年龄的领域知识,我们将设计一些特征。

  • 调整模型以提高可解释性:一旦数据准备就绪,我们将使用不同的类别权重和过拟合预防技术调整许多模型。这些方法将确保模型不仅泛化能力更好,而且更容易解释。

  • 实施模型约束:最后但同样重要的是,我们将对最佳模型实施单调性和交互约束,以确保它们不会偏离可信和公平的交互。

在最后两个部分中,我们将确保模型准确且公平地执行。我们还将比较数据和模型之间的再犯风险分布,以确保它们一致。

准备工作

你可以在这里找到这个示例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/blob/main/12/Recidivism_part2.ipynb

加载库

要运行此示例,您需要安装以下库:

  • mldatasets 用于加载数据集

  • pandasnumpy 用于操作

  • sklearn(scikit-learn)、xgboostlightgbmcatboosttensorflowbayes_opttensorflow_lattice 用于分割数据和拟合模型

  • matplotlibseabornscipyxaishap 以可视化解释

您应该首先加载所有这些库,如下所示:

import math
import os
import copy
import mldatasets
import pandas as pd
import numpy as np
from sklearn import preprocessing, model_selection, metrics,\
    linear_model, svm, neural_network, ensemble
import xgboost as xgb
import lightgbm as lgb
import catboost as cb
import tensorflow as tf
from bayes_opt import BayesianOptimization
import tensorflow_lattice as tfl
from tensorflow.keras.wrappers.scikit_learn import\
                                                  KerasClassifier
import matplotlib.pyplot as plt
import seaborn as sns
import scipy
import xai
import shap 

让我们检查 tensorflow 是否加载了正确的版本,使用 print(tf.__version__)。这应该是 2.8 版本及以上。

理解和准备数据

我们将数据以这种方式加载到我们称为 recidivism_df 的 DataFrame 中:

recidivism_df = mldatasets.**load**("recidivism-risk-balanced") 

应该有超过 11,000 条记录和 11 个列。我们可以使用 info() 验证这一点,如下所示:

recidivism_df.info() 

上一段代码输出了以下内容:

RangeIndex: 11142 entries, 0 to 11141
Data columns (total 12 columns):
#   Column                   Non-Null Count  Dtype  
---  ------                   --------------  -----  
0   sex                      11142 non-null  object
1   age                      11142 non-null  int64
2   race                     11142 non-null  object
3   juv_fel_count            11142 non-null  int64
4   juv_misd_count           11142 non-null  int64
5   juv_other_count          11142 non-null  int64
6   priors_count             11142 non-null  int64
7   c_charge_degree          11142 non-null  object
8   days_b_screening_arrest  11142 non-null  float64
9   length_of_stay           11142 non-null  float64
10  compas_score             11142 non-null  int64
11  is_recid                 11142 non-null  int64
dtypes: float64(2), int64(7), object(3) 

输出检查无误。没有缺失值,除了三个特征(sexracecharge_degree)外,所有特征都是数值型的。这是我们用于 第六章锚点和反事实解释 的相同数据,因此数据字典完全相同。然而,数据集已经通过采样方法进行了平衡,这次它没有为我们准备,因此我们需要这样做,但在这样做之前,让我们了解平衡做了什么。

验证采样平衡

我们可以使用 XAI 的 imbalance_plot 检查 raceis_recid 的分布情况。换句话说,它将统计每个 race-is_recid 组合的记录数量。这个图将使我们能够观察每个 race 的被告中是否有再犯人数的不平衡。代码可以在以下片段中查看:

categorical_cols_l = [
    'sex', 'race', 'c_charge_degree', 'is_recid', 'compas_score'
]
xai.**imbalance_plot**(
    recidivism_df,
    'race',
    'is_recid',
    categorical_cols=categorical_cols_l
) 

前面的代码输出了图 12.1,它描述了所有种族的is_recid=0is_recid=1的数量相等。然而,其他种族在数量上与其他种族不相等。顺便提一下,这个数据集版本将所有其他种族都归入了其他类别,选择不upsample 其他downsample其他两个种族以实现总数相等,是因为它们在被告人口中代表性较低。这种平衡选择是在这种情况下可以做的许多选择之一。从人口统计学角度看,这完全取决于你的数据应该代表什么。被告?囚犯?普通民众中的平民?以及在哪一层面?县一级?州一级?国家一级?

输出结果如下:

图片

图 12.1:按种族分布的两年再犯率(is_recid)

接下来,让我们计算每个特征与目标变量单调相关性的程度。Spearman 等级相关系数在本章中将起到关键作用,因为它衡量了两个特征之间的单调性。毕竟,本章的一个技术主题是单调约束,主要任务是产生一个显著减少偏差的模型。

我们首先创建一个新的 DataFrame,其中不包含compas_scorerecidivism_corr_df)。使用这个 DataFrame,我们输出一个带有feature列的彩色 DataFrame,其中包含前 10 个特征的名称,以及另一个带有所有 10 个特征与第 11 个特征(目标变量)的 Spearman 相关系数(correlation_to_target)。代码如下所示:

recidivism_corr_df = recidivism_df.**drop**(
    ['compas_score'], axis=1
)
pd.**DataFrame**(
    {'feature': recidivism_corr_df.columns[:-1],
     'correlation_to_target':\
          scipy.stats.**spearmanr**(recidivism_corr_df).\
          correlation[10,:-1]
    }
).style.background_gradient(cmap='coolwarm') 

前面的代码输出了图 12.2所示的 DataFrame。最相关的特征是priors_count,其次是age、三个青少年计数和sexc_charge_degreedays_b_screening_arrestlength_of_stayrace的系数可以忽略不计。

输出结果如下:

表格描述自动生成

图 12.2:在特征工程之前,所有特征对目标变量的 Spearman 系数

接下来,我们将学习如何使用特征工程将一些领域知识“嵌入”到特征中。

使用特征工程设置护栏

第六章锚点和反事实解释中,我们了解到除了race之外,在我们解释中最突出的特征是agepriors_countc_charge_degree。幸运的是,数据现在已经平衡,因此这种不平衡导致的种族偏见现在已经消失。然而,通过锚点和反事实解释,我们发现了一些令人不安的不一致性。在agepriors_count的情况下,这些不一致性是由于这些特征的分布方式造成的。我们可以通过特征工程来纠正分布问题,从而确保模型不会从不均匀的分布中学习。在c_charge_degree的情况下,由于它是分类的,它缺乏可识别的顺序,这种缺乏顺序导致了不直观的解释。

在本节中,我们将研究序列化离散化交互项,这是通过特征工程设置护栏的三种方式。

序列化

c_charge_degree category:
recidivism_df.c_charge_degree.**value_counts()** 

前面的代码生成了以下输出:

(F3)     6555
(M1)     2632
(F2)      857
(M2)      768
(F1)      131
(F7)      104
(MO3)      76
(F5)        7
(F6)        5
(NI0)       4
(CO3)       2
(TCX)       1 

每个电荷度数对应电荷的重力。这些重力有一个顺序,使用分类特征时会丢失。我们可以通过用相应的顺序替换每个类别来轻松解决这个问题。

我们可以对此顺序进行很多思考。例如,我们可以查看判决法或指南——对于不同的程度,实施了最低或最高的监禁年数。我们还可以查看这些人的平均暴力统计数据,并将这些信息分配给电荷度数。每个此类决策都存在潜在的偏见,如果没有充分的证据支持它,最好使用整数序列。所以,我们现在要做的就是创建一个字典(charge_degree_code_rank),将度数映射到从低到高对应的重力等级的数字。然后,我们可以使用pandasreplace函数使用这个字典来进行替换。以下代码片段中可以看到代码:

charge_degree_code_rank = {
    '(F10)': 15, '(F9)':14, '(F8)':13,\
    '(F7)':12, '(TCX)':11, '(F6)':10, '(F5)':9,\
    '(F4)':8, '(F3)':7, '(F2)':6, '(F1)':5, '(M1)':4,\
    '(NI0)':4, '(M2)':3, '(CO3)':2, '(MO3)':1, '(X)':0
}
recidivism_df.c_charge_degree.**replace**(
    charge_degree_code_rank, inplace=True
) 

评估这种顺序如何对应再犯概率的一种方法是通过一条线图,显示随着电荷度数的增加,它如何变化。我们可以使用一个名为plot_prob_progression的函数来做这件事,它接受一个连续特征作为第一个参数(c_charge_degree),以衡量一个二元特征的概率(is_recid)。它可以按区间(x_intervals)分割连续特征,甚至可以使用分位数(use_quantiles)。最后,你可以定义轴标签和标题。以下代码片段中可以看到代码:

mldatasets.**plot_prob_progression**(
    recidivism_df.**c_charge_degree**,
    recidivism_df.**is_recid**, x_intervals=12,
    use_quantiles=False,
    xlabel='Relative Charge Degree',
    title='Probability of Recidivism by Relative Charge Degree'
) 

前面的代码生成了图 12.3 中的图表。随着现在排名的电荷度数的增加,趋势是 2 年再犯的概率降低,除了排名 1。在概率下方有柱状图显示了每个排名的观测值的分布。由于分布非常不均匀,你应该谨慎对待这种趋势。你会注意到一些排名,如 0、8 和 13-15,没有在图表中,因为电荷度数的类别存在于刑事司法系统中,但在数据中不存在。

输出结果如下:

图表,折线图  自动生成的描述

图 12.3:按电荷度数的概率进展图

在特征工程方面,我们无法做更多的事情来改进 c_charge_degree,因为它已经代表了现在带有顺序的离散类别。除非我们有证据表明否则,任何进一步的转换都可能导致信息的大量丢失。另一方面,连续特征本质上具有顺序;然而,由于它们携带的精度水平,可能会出现问题。因为小的差异可能没有意义,但数据可能告诉模型否则。不均匀的分布和反直觉的交互只会加剧这个问题。

离散化

为了理解如何最佳地离散化我们的年龄连续特征,让我们尝试两种不同的方法。我们可以使用等宽离散化,也称为固定宽度箱或区间,这意味着箱的大小由 决定,其中 N 是箱的数量。另一种方法是使用等频离散化,也称为分位数,这确保每个箱大约有相同数量的观测值。尽管如此,有时由于直方图的偏斜性质,可能无法以 N 种方式分割它们,因此你可能最终得到 N-1N-2 个分位数。

使用 plot_prob_progression 比较这两种方法很容易,但这次我们生成了两个图表,一个使用固定宽度箱(use_quantiles=False),另一个使用分位数(use_quantiles=True)。代码可以在下面的代码片段中看到:

mldatasets.plot_**prob_progression**(
    recidivism_df.**age**,
    recidivism_df.**is_recid**,
    x_intervals=7,
    use_quantiles=False,
    title='Probability of Recidivism by Age Discretized in Fix-Width \
    Bins',
    xlabel='Age'
)
mldatasets.**plot_prob_progression**(
    recidivism_df.**age**,
    recidivism_df.**is_recid**,
    x_intervals=7, use_quantiles=True,
    title='Probability of Recidivism by Age Discretized \
    in Quantiles',
    xlabel='Age'
) 
Figure 12.4. By looking at the Observations portion of the fixed-width bin plot, you can tell that the histogram for the age feature is right-skewed, which causes the probability to shoot up for the last bin. The reason for this is that some outliers exist in this bin. On the other hand, the fixed-frequency (quantile) plot histogram is more even, and probability consistently decreases. In other words, it’s monotonic—as it should be, according to our domain knowledge on the subject.

输出结果如下:

图 12.4:比较两种年龄离散化方法

很容易观察到为什么使用分位数对特征进行箱化是一个更好的方法。我们可以将 age 工程化为一个新的特征,称为 age_grouppandasqcut 函数可以执行基于分位数的离散化。代码可以在下面的代码片段中看到:

recidivism_df['age_group'] = pd.**qcut**(
    recidivism_df.**age**, 7, precision=0
).astype(str) 

因此,我们现在已经将age离散化为age_group。然而,必须注意的是,许多模型类会自动进行离散化,那么为什么还要这么做呢?因为这允许你控制其影响。否则,模型可能会选择不保证单调性的桶。例如,模型可能会在可能的情况下始终使用 10 个分位数。尽管如此,如果你尝试在age上使用这种粒度(x_intervals=10),你最终会在概率进展中遇到峰值。我们的目标是确保模型会学习到ageis_recid的发病率之间存在单调关系,如果我们允许模型选择可能或可能不达到相同目标的桶,我们就无法确定这一点。

我们将移除age,因为age_group包含了我们所需的所有信息。但是等等——你可能会问——移除这个变量会不会丢失一些重要信息?是的,但仅仅是因为它与priors_count的交互作用。所以,在我们丢弃任何特征之前,让我们检查这种关系,并意识到通过创建交互项,我们如何通过移除age来保留一些丢失的信息,同时保持交互。

交互项和非线性变换

我们从第六章锚点和反事实解释中已经知道,agepriors_count是最重要的预测因子之一,我们可以观察到它们如何一起影响再犯的发病率(is_recid),使用plot_prob_contour_map。这个函数产生带有彩色等高线区域的等高线,表示不同的幅度。它们在地理学中很有用,可以显示海拔高度。在机器学习中,它们可以显示一个二维平面,表示特征与度量之间的交互。在这种情况下,维度是agepriors_count,度量是再犯的发病率。这个函数接收到的参数与plot_prob_progression相同,只是它接受对应于x轴和y轴的两个特征。代码可以在下面的代码片段中看到:

mldatasets.plot_**prob_contour_map**(
    recidivism_df.**age**,
    recidivism_df.**priors_count**,
    recidivism_df.**is_recid**,
    use_quantiles=True,
    xlabel='Age',
    ylabel='Priors Count',
    title='Probability of Recidivism by Age/Priors Discretized in \
    Quantiles'
) 
Figure 12.5, which shows how, when discretized by quantiles, the probability of 2-year recidivism increases, the lower the age and the higher the priors_count. It also shows histograms for both features. priors_count is very right-skewed, so discretization is challenging, and the contour map does not offer a perfectly diagonal progression between the bottom right and top left. And if this plot looks familiar, it’s because it’s just like the partial dependence interaction plots we produced in *Chapter 4*, *Global Model-Agnostic Interpretation Methods*, except it’s not measured against the predictions of a model but the ground truth (is_recid). We must distinguish between what the data can tell us directly and what the model has learned from it.

输出结果如下:

图片

图 12.5:年龄和先前的计数再犯概率等高线图

我们现在可以构建一个包含两个特征的交互项。即使等高线图将特征离散化以观察更平滑的进展,我们也不需要将这种关系离散化。有意义的是将其作为每年priors_count的比率。但是从哪一年开始算起?当然是被告成年以来的年份。但是要获得这些年份,我们不能使用age - 18,因为这会导致除以零,所以我们将使用17代替。当然,有许多方法可以做到这一点。最好的方法是我们假设年龄有小数,通过减去 18,我们可以计算出非常精确的priors_per_year比率。然而,不幸的是,我们并没有这样的数据。你可以在下面的代码片段中看到代码:

recidivism_df['priors_per_year'] =\
            recidivism_df['priors_count']/(recidivism_df['age'] - 17) 

黑盒模型通常会自动找到交互项。例如,神经网络中的隐藏层具有所有一阶交互项,但由于非线性激活,它并不仅限于线性组合。然而,“手动”定义交互项甚至非线性转换,一旦模型拟合完成,我们可以更好地解释这些交互项。此外,我们还可以对它们使用单调约束,这正是我们稍后将在priors_per_year上所做的。现在,让我们检查其单调性是否通过plot_prob_progression保持。查看以下代码片段:

mldatasets.**plot_prob_progression**(
    recidivism_df.**priors_per_year**,
    recidivism_df.**is_recid**,
    x_intervals=8,
    xlabel='Priors Per Year',
    title='Probability of Recidivism by Priors per Year (\
    according to data)'
) 

前面的代码片段输出以下截图,显示了新特征的几乎单调进展:

图表,折线图  自动生成的描述

图 12.6:priors_per_year的先验概率进展

priors_per_year不是更单调的原因是 3.0 以上的priors_per_year区间非常稀疏。因此,对这些少数被告强制执行该特征的单调性将非常不公平,因为他们呈现了 75%的风险下降。解决这一问题的方法之一是将它们左移,将这些观察结果中的priors_per_year设置为-1,如下面的代码片段所示:

recidivism_df.loc[recidivism_df.priors_per_year > 3,\
                  'priors_per_year'] = -1 

当然,这种移动会略微改变特征的解释,考虑到-1的少数值实际上意味着超过3。现在,让我们生成另一个等高线图,但这次是在age_grouppriors_per_year之间。后者将按分位数(y_intervals=6, use_quantiles=True)进行离散化,以便更容易观察到再犯概率。以下代码片段显示了代码:

mldatasets.**plot_prob_contour_map**(
    recidivism_df.**age_group**,
    recidivism_df.**priors_per_year**,
    recidivism_df.**is_recid**,
    y_intervals=6,
    use_quantiles=True,
    xlabel='Age Group',
    title='Probability of Recidivism by Age/Priors per Year \
    Discretized in Quantiles', ylabel='Priors Per Year'
) 
 generates the contours in *Figure 12.7*. It shows that, for the most part, the plot moves in one direction. We were hoping to achieve this outcome because it allows us, through one interaction feature, to control the monotonicity of what used to involve two features.

输出结果如下:

![图片,B18406_12_07.png]

图 12.7:age_grouppriors_per_year的再犯概率等高线图

几乎一切准备就绪,但age_group仍然是分类的,所以我们必须将其编码成数值形式。

分类别编码

对于age_group的最佳分类编码方法是序数编码,也称为标签编码,因为它会保留其顺序。我们还应该对数据集中的其他两个分类特征进行编码,即sexrace。对于sex,序数编码将其转换为二进制形式——相当于虚拟编码。另一方面,race是一个更具挑战性的问题,因为它有三个类别,使用序数编码可能会导致偏差。然而,是否使用独热编码取决于你使用的模型类别。基于树的模型对序数特征没有偏差问题,但其他基于特征权重的模型,如神经网络和逻辑回归,可能会因为这种顺序而产生偏差。

考虑到数据集已经在种族上进行了平衡,因此这种情况发生的风险较低,我们稍后无论如何都会移除这个特征,所以我们将继续对其进行序数编码。

为了对三个特征进行序数编码,我们将使用 scikit-learn 的OrdinalEncoder。我们可以使用它的fit_transform函数一次性拟合和转换特征。然后,我们还可以趁机删除不必要的特征。请看下面的代码片段:

cat_feat_l = ['sex', 'race', 'age_group']
ordenc = preprocessing.**OrdinalEncoder**(dtype=np.int8)
recidivism_df[cat_feat_l] =\
                  ordenc.**fit_transform**(recidivism_df[cat_feat_l])
recidivism_df.drop(['age', 'priors_count', 'compas_score'],\
                    axis=1, inplace=True) 

现在,我们还没有完全完成。我们仍然需要初始化我们的随机种子并划分我们的数据为训练集和测试集。

其他准备工作

下一步的准备工作很简单。为了确保可重复性,让我们在需要的地方设置随机种子,然后将我们的y设置为is_recid,将X设置为其他所有特征。我们对这两个进行train_test_split。最后,我们使用X后跟y重建recidivism_df DataFrame。这样做只有一个原因,那就是is_recid是最后一列,这将有助于下一步。代码可以在这里看到:

rand = 9
os.environ['PYTHONHASHSEED'] = str(rand)
tf.random.set_seed(rand)
np.random.seed(rand)
y = recidivism_df['is_recid']
X = recidivism_df.drop(['is_recid'], axis=1).copy()
X_train, X_test, y_train, y_test = model_selection.**train_test_split**(
    X, y, test_size=0.2, random_state=rand
)
recidivism_df = X.join(y) 

现在,我们将验证 Spearman 的相关性是否在需要的地方有所提高,在其他地方保持不变。请看下面的代码片段:

pd.DataFrame(
    {
        'feature': X.columns,
        'correlation_to_target':scipy.stats.**spearmanr**(recidivism_df).\
        correlation[10,:-1]
    }
).style.background_gradient(cmap='coolwarm') 

前面的代码输出了图 12.8中所示的 DataFrame。请将其与图 12.2进行比较。请注意,在分位数离散化后,age与目标变量的单调相关性略有降低。一旦进行序数编码,c_charge_degree的相关性也大大提高,而priors_per_year相对于priors_count也有所改善。其他特征不应受到影响,包括那些系数最低的特征。

输出如下:

表格描述自动生成图 12.8:所有特征与目标变量的 Spearman 相关系数(特征工程后)

系数最低的特征在模型中可能也是不必要的,但我们将让模型通过正则化来决定它们是否有用。这就是我们接下来要做的。

调整模型以提高可解释性

传统上,正则化是通过在系数或权重上施加惩罚项(如L1L2弹性网络)来实现的,这会减少最不相关特征的影响。如第十章“可解释性特征选择和工程”部分的嵌入式方法中所示,这种正则化形式在特征选择的同时也减少了过拟合。这使我们来到了正则化的另一个更广泛的概念,它不需要惩罚项。通常,这相当于施加限制或停止标准,迫使模型限制其复杂性。

除了正则化,无论是其狭义(基于惩罚)还是广义(过拟合方法),还有其他方法可以调整模型以提高可解释性——也就是说,通过调整训练过程来提高模型的公平性、责任性和透明度。例如,我们在第十章特征选择和可解释性工程中讨论的类别不平衡超参数,以及第十一章偏差缓解和因果推断方法中的对抗性偏差,都有助于提高公平性。此外,我们将在本章进一步研究的约束条件对公平性、责任性和透明度也有潜在的好处。

有许多不同的调整可能性和模型类别。如本章开头所述,我们将关注与可解释性相关的选项,但也将模型类别限制在流行的深度学习库(Keras)、一些流行的树集成(XGBoost、随机森林等)、支持向量机SVMs)和逻辑回归。除了最后一个,这些都被认为是黑盒模型。

调整 Keras 神经网络

对于 Keras 模型,我们将通过超参数调整和分层 K 折交叉验证来选择最佳正则化参数。我们将按照以下步骤进行:

  1. 首先,我们需要定义模型和要调整的参数。

  2. 然后,我们进行调整。

  3. 接下来,我们检查其结果。

  4. 最后,我们提取最佳模型并评估其预测性能。

让我们详细看看这些步骤。

定义模型和要调整的参数

我们首先应该创建一个函数(build_nn_mdl)来构建和编译一个可正则化的 Keras 模型。该函数接受一些参数,以帮助调整模型。它接受一个包含隐藏层中神经元数量的元组(hidden_layer_sizes),以及应用于层核的 L1(l1_reg)和 L2(l1_reg)正则化值。最后,它还接受dropout参数,与 L1 和 L2 惩罚不同,它是一种随机正则化方法,因为它采用随机选择。请看以下代码片段:

def **build_nn_mdl**(hidden_layer_sizes, l1_reg=0, l2_reg=0, dropout=0):
    nn_model = tf.keras.Sequential([
        tf.keras.Input(shape=[len(X_train.keys())]),\
        tf.keras.layers.experimental.preprocessing.**Normalization**()
    ])
    reg_args = {}
    if (l1_reg > 0) or (l2_reg > 0):
        reg_args = {'kernel_regularizer':\
                    tf.keras.regularizers.**l1_l2**(l1=l1_reg, l2=l2_reg)}
    for hidden_layer_size in hidden_layer_sizes:
        nn_model.add(tf.keras.layers.**Dense**(hidden_layer_size,\
                        activation='relu', ****reg_args**))
    if dropout > 0:
        nn_model.add(tf.keras.layers.**Dropout**(dropout))
    nn_model.add(tf.keras.layers.**Dense**(1, activation='sigmoid'))
    nn_model.compile(
        loss='binary_crossentropy',
        optimizer=tf.keras.optimizers.Adam(lr=0.0004),
        metrics=['accuracy',tf.keras.metrics.AUC(name='auc')]
)
    return nn_model 

之前的功能将模型(nn_model)初始化为一个 Sequential 模型,其输入层与训练数据中的特征数量相对应,并添加一个 Normalization() 层来标准化输入。然后,如果任一惩罚项超过零,它将设置一个字典(reg_args),将 kernel_regularizer 分配给 tf.keras.regularizers.l1_l2 并用这些惩罚项初始化。一旦添加了相应的 hidden_layer_size 的隐藏(Dense)层,它将 reg_args 字典作为额外参数传递给每个层。在添加所有隐藏层之后,它可以选择添加 Dropout 层和具有 sigmoid 激活的最终 Dense 层。然后,模型使用 binary_crossentropy 和具有较慢学习率的 Adam 优化器编译,并设置为监控 accuracyauc 指标。

运行超参数调整

现在我们已经定义了模型和要调整的参数,我们初始化了 RepeatedStratifiedKFold 交叉验证器,它将训练数据分成五份,总共重复三次(n_repeats),每次重复使用不同的随机化。然后我们为网格搜索超参数调整创建一个网格(nn_grid)。它只测试三个参数(l1_regl2_regdropout)的两个可能选项,这将产生 种组合。我们将使用 scikit-learn 包装器(KerasClassifier)来使我们的模型与 scikit-learn 网格搜索兼容。说到这一点,我们接下来初始化 GridSearchCV,它使用 Keras 模型(estimator)执行交叉验证网格搜索(param_grid)。我们希望它根据精度(scoring)选择最佳参数,并且在过程中不抛出错误(error_score=0)。最后,我们像使用任何 Keras 模型一样拟合 GridSearchCV,传递 X_trainy_trainepochsbatch_size。代码可以在以下代码片段中看到:

cv = model_selection.**RepeatedStratifiedKFold**(
    n_splits=5,
    n_repeats=3,
    random_state=rand
)
nn_grid = {
    'hidden_layer_sizes':[(80,)],
    'l1_reg':[0,0.005],
    'l2_reg':[0,0.01],
    'dropout':[0,0.05]
}
nn_model = KerasClassifier(build_fn=build_nn_mdl)
nn_grid_search = model_selection.**GridSearchCV**(
    estimator=nn_model,
    cv=cv,
    n_jobs=-1,
    param_grid=nn_grid,
    scoring='precision',
    error_score=0
)
nn_grid_result = nn_grid_search.**fit**(
    X_train.astype(float),
    y_train.astype(float),
    epochs=400,batch_size=128
) 

接下来,我们可以检查网格搜索的结果。

检查结果

一旦完成网格搜索,你可以使用以下命令输出最佳参数:print(nn_grid_result.best_params_)。或者,你可以将所有结果放入一个 DataFrame 中,按最高精度(sort_values)排序,并按以下方式输出:

pd.**DataFrame**(nn_grid_result.**cv_results**_)[
    [
        'param_hidden_layer_sizes',
        'param_l1_reg',
        'param_l2_reg',
        'param_dropout',
        'mean_test_score',
        'std_test_score',
        'rank_test_score'
    ]
].**sort_values**(by='rank_test_score') 
Figure 12.9. The unregularized model is dead last, showing that all regularized model combinations performed better. One thing to note is that given the 1.5–2% standard deviations (std_test_score) and that the top performer is only 2.2% from the lowest performer, in this case, the benefits are marginal from a precision standpoint, but you should use a regularized model nonetheless because of other benefits.

输出如下所示:

表格描述自动生成图 12.9:神经网络模型交叉验证网格搜索的结果

评估最佳模型

网格搜索产生的另一个重要元素是表现最佳模型(nn_grid_result.best_estimator_)。我们可以创建一个字典来存储我们将在本章中拟合的所有模型(fitted_class_mdls),然后使用 evaluate_class_mdl 评估这个正则化的 Keras 模型,并将评估结果同时保存在字典中。请查看以下代码片段:

fitted_class_mdls = {}
fitted_class_mdls['keras_reg'] = mldatasets.**evaluate_class_mdl**(
    nn_grid_result.best_estimator_,
    X_train.astype(float),
    X_test.astype(float),
    y_train.astype(float),
    y_test.astype(float),
    plot_roc=False,
    plot_conf_matrix=True,
    **ret_eval_dict=****True**
) 
Figure 12.10. The accuracy is a little bit better than the original COMPAS model from *Chapter 6*, *Anchors and Counterfactual Explanations*, but the strategy to optimize for higher precision while regularizing yielded a model with nearly half as many false positives but 50% more false negatives.

输出如下所示:

图表,树状图  自动生成的描述图 12.10:正则化 Keras 模型的评估

通过使用自定义损失函数或类权重,可以进一步校准类平衡,正如我们稍后将要做的。接下来,我们将介绍如何调整其他模型类。

调整其他流行模型类

在本节中,我们将拟合许多不同的模型,包括未正则化和正则化的模型。为此,我们将从广泛的参数中选择,这些参数执行惩罚正则化,通过其他方式控制过拟合,并考虑类别不平衡。

相关模型参数的简要介绍

供您参考,有两个表格包含用于调整许多流行模型的参数。这些已经被分为两部分。Part A(图 12.11)包含五个具有惩罚正则化的 scikit-learn 模型。Part B(图 12.12)显示了所有树集成,包括 scikit-learn 的随机森林模型和来自最受欢迎的增强树库(XGBoost、LightGBM 和 CatBoost)的模型。

Part A 可以在这里查看:

表格,日历  自动生成的描述图 12.11:惩罚正则化 scikit-learn 模型的调整参数

在图 12.11 中,您可以在列中观察到模型,在行中观察到相应的参数名称及其默认值在右侧。在参数名称和默认值之间,有一个加号或减号,表示是否改变默认值的一个方向或另一个方向应该使模型更加保守。这些参数还按以下类别分组:

  • 算法:一些训练算法不太容易过拟合,但这通常取决于数据。

  • 正则化:仅在更严格的意义上。换句话说,控制基于惩罚的正则化的参数。

  • 迭代:这控制执行多少个训练轮次、迭代或 epoch。调整这个方向或另一个方向可能会影响过拟合。在基于树的模型中,估计器或树的数量是类似的。

  • 学习率:这控制学习发生的速度。它与迭代一起工作。学习率越低,需要的迭代次数越多以优化目标函数。

  • 提前停止:这些参数控制何时停止训练。这允许您防止您的模型对训练数据过拟合。

  • 类别不平衡:对于大多数模型,这在损失函数中惩罚了较小类别的误分类,对于基于树的模型,特别是这样,它被用来重新加权分割标准。无论如何,它只与分类器一起工作。

  • 样本权重:我们在第十一章“偏差缓解和因果推断方法”中利用了这一点,根据样本分配权重以减轻偏差。

标题中既有分类模型也有回归模型,并且它们共享相同的参数。请注意,scikit-learn 的LinearRegressionLogisticRegression下没有特色,因为它没有内置的正则化。无论如何,我们将在本节中仅使用分类模型。

B 部分可以在这里看到:

表格,日历  自动生成的描述

表格,日历  自动生成的描述

图 12.12:树集成模型的调整参数

图 12.12图 12.11非常相似,除了它有更多仅在树集成中可用的参数类别,如下所示:

  • 特征采样:这种方法通过在节点分裂、节点或树训练中考虑较少的特征来实现。因为它随机选择特征,所以它是一种随机正则化方法。

  • 树的大小:这通过最大深度、最大叶子数或其他限制其增长的参数来约束树,从而反过来抑制过拟合。

  • 分裂:任何控制树中节点如何分裂的参数都可以间接影响过拟合。

  • 袋装:也称为自助聚合,它首先通过自助采样开始,这涉及到从训练数据中随机抽取样本来拟合弱学习器。这种方法减少了方差,有助于减少过拟合,并且相应地,采样参数通常在超参数调整中很突出。

  • 约束:我们将在下一节中进一步详细解释这些内容,但这是如何将特征约束以减少或增加对输出的影响。它可以在数据非常稀疏的领域减少过拟合。然而,减少过拟合通常不是主要目标,而交互约束可以限制哪些特征可以交互。

请注意,图 12.12中带有星号(*)的参数表示在fit函数中设置的,而不是用模型初始化的。此外,除了 scikit-learn 的RandomForest模型外,所有其他参数通常有许多别名。对于这些,我们使用 scikit-learn 的包装函数,但所有参数也存在于原生版本中。我们不可能在这里解释每个模型参数,但建议您直接查阅文档以深入了解每个参数的作用。本节的目的在于作为指南或参考。

接下来,我们将采取与我们对 Keras 模型所做类似的步骤,但一次针对许多不同的模型,最后我们将评估最适合公平性的最佳模型。

批量超参数调整模型

好的——既然我们已经快速了解了我们可以拉动的哪些杠杆来调整模型,那么让我们定义一个包含所有模型的字典,就像我们在其他章节中所做的那样。这次,我们包括了一个用于网格搜索的参数值的grid。看看下面的代码片段:

class_mdls = {
    'logistic':{
        'model':linear_model.**LogisticRegression**(random_state=rand,\
                                                max_iter=1000),
        'grid':{
            'C':np.linspace(0.01, 0.49, 25),
            'class_weight':[{0:6,1:5}],
            'solver':['lbfgs', 'liblinear', 'newton-cg']
        }
     },
    'svc':{
        'model':svm.**SVC**(probability=True, random_state=rand),
        'grid':{'C':[15,25,40], 'class_weight':[{0:6,1:5}]}
    },
    'nu-svc':{
        'model':svm.**NuSVC**(
            probability=True,
            random_state=rand
        ),
        'grid':{
            'nu':[0.2,0.3], 'gamma':[0.6,0.7],\
            'class_weight':[{0:6,1:5}]}
        },
    'mlp':{
        'model':neural_network.**MLPClassifier**(
            random_state=rand,
            hidden_layer_sizes=(80,),
            early_stopping=True
        ),
        'grid':{
            'alpha':np.linspace(0.05, 0.15, 11),
            'activation':['relu','tanh','logistic']}
        },
        'rf':{
            'model':ensemble.**RandomForestClassifier**(
                random_state=rand, max_depth=7, oob_score=True, \
                bootstrap=True
             ),
            'grid':{
                'max_features':[6,7,8],
                'max_samples':[0.75,0.9,1],
                'class_weight':[{0:6,1:5}]}
            },
    'xgb-rf':{
        'model':xgb.**XGBRFClassifier**(
            seed=rand, eta=1, max_depth=7, n_estimators=200
        ),
        'grid':{
            'scale_pos_weight':[0.85],
            'reg_lambda':[1,1.5,2],
            'reg_alpha':[0,0.5,0.75,1]}
        },
    'xgb':{
        'model':xgb.**XGBClassifier**(
            seed=rand, eta=1, max_depth=7
        ),
        'grid':{
            'scale_pos_weight':[0.7],
            'reg_lambda':[1,1.5,2],
            'reg_alpha':[0.5,0.75,1]}
        },
    'lgbm':{
        'model':lgb.**LGBMClassifier**(
            random_seed=rand,
            learning_rate=0.7,
            max_depth=5
        ),
        'grid':{
            'lambda_l2':[0,0.5,1],
            'lambda_l1':[0,0.5,1],
            'scale_pos_weight':[0.8]}
        },
    'catboost':{
        'model':cb.**CatBoostClassifier**(
            random_seed=rand,
            depth=5,
            learning_rate=0.5,
            verbose=0
        ),
        'grid':{
            'l2_leaf_reg':[2,2.5,3],
            'scale_pos_weight':[0.65]}
        }
} 

下一步是为字典中的每个模型添加一个for循环,然后deepcopy它并使用fit来生成一个“基础”的非正则化模型。接下来,我们使用evaluate_class_mdl对其进行评估,并将其保存到我们之前为 Keras 模型创建的fitted_class_mdls字典中。现在,我们需要生成模型的正则化版本。因此,我们再次进行deepcopy,并遵循与 Keras 相同的步骤进行RepeatedStratifiedKFold交叉验证网格搜索,并且我们也以相同的方式进行评估,将结果保存到拟合模型字典中。代码如下所示:

for mdl_name in class_mdls:
    base_mdl = copy.deepcopy(class_mdls[mdl_name]['model'])
    base_mdl = base_mdl.**fit**(X_train, y_train)
    fitted_class_mdls[mdl_name+'_base'] = \
        mldatasets.**evaluate_class_mdl**(
            base_mdl, X_train, X_test,y_train, y_test,
            plot_roc=False, plot_conf_matrix=False,
            show_summary=False, ret_eval_dict=True
    )
    reg_mdl = copy.deepcopy(class_mdls[mdl_name]['model'])
    grid = class_mdls[mdl_name]['grid']
    cv = model_selection.**RepeatedStratifiedKFold**(
        n_splits=5, n_repeats=3, random_state=rand
    )
    grid_search = model_selection.**GridSearchCV**(
    estimator=reg_mdl, cv=cv, param_grid=grid,
    scoring='precision', n_jobs=-1, error_score=0, verbose=0
    )
    grid_result = grid_search.**fit**(X_train, y_train)
    fitted_class_mdls[mdl_name+'_reg'] =\
        mldatasets.**evaluate_class_mdl**(
            grid_result.**best_estimator**_, X_train, X_test, y_train,
            y_test, plot_roc=False,
            plot_conf_matrix=False, show_summary=False,
            ret_eval_dict=True
    )
    fitted_class_mdls[mdl_name+'_reg']['cv_best_params'] =\
        grid_result.**best_params**_ 

一旦代码执行完毕,我们可以根据精确度对模型进行排名。

根据精确度评估模型

我们可以提取拟合模型字典的指标,并将它们放入一个 DataFrame 中,使用from_dict。然后我们可以根据最高的测试精确度对模型进行排序,并为最重要的两个列着色编码,这两个列是precision_testrecall_test。代码可以在下面的代码片段中看到:

class_metrics = pd.DataFrame.from_dict(fitted_class_mdls, 'index')[
    [
        'accuracy_train',
        'accuracy_test',
        'precision_train',
        'precision_test',
        'recall_train',
        'recall_test',
        'roc-auc_test',
        'f1_test',
        'mcc_test'
    ]
]
with pd.option_context('display.precision', 3):
    html = class_metrics.sort_values(
        by='precision_test', ascending=False
    ).style.background_gradient(
        cmap='plasma',subset=['precision_test']
    ).background_gradient(
        cmap='viridis', subset=['recall_test'])
html 

前面的代码将输出图 12.13所示的 DataFrame。你可以看出,正则化树集成模型在排名中占据主导地位,其次是它们的非正则化版本。唯一的例外是正则化 Nu-SVC,它排名第一,而它的非正则化版本排名最后!

输出如下所示:

表格描述自动生成

图 12.13:根据交叉验证网格搜索的顶级模型

你会发现,Keras 正则化神经网络模型的精确度低于正则化逻辑回归,但召回率更高。确实,我们希望优化高精确度,因为它会影响假阳性,这是我们希望最小化的,但精确度可以达到 100%,而召回率可以是 0%,如果那样的话,你的模型就不好了。同时,还有公平性,这关乎于保持低假阳性率,并且在种族间均匀分布。因此,这是一个权衡的问题,追求一个指标并不能让我们达到目标。

评估最高性能模型的公平性

为了确定如何进行下一步,我们必须首先评估我们的最高性能模型在公平性方面的表现。我们可以使用compare_confusion_matrices来完成这项工作。正如你使用 scikit-learn 的confusion_matrix一样,第一个参数是真实值或目标值(通常称为y_true),第二个是模型的预测值(通常称为y_pred)。这里的区别是它需要两组y_truey_pred,一组对应于观察的一个部分,另一组对应于另一个部分。在这四个参数之后,你给每个部分起一个名字,所以这就是以下两个参数告诉你的内容。最后,compare_fpr=True确保它将比较两个混淆矩阵之间的假阳性率FPR)。看看下面的代码片段:

y_test_pred = fitted_class_mdls['catboost_reg']['preds_test']
_ = mldatasets.**compare_confusion_matrices**(
    y_test[X_test.race==1],
    y_test_pred[X_test.race==1],
    y_test[X_test.race==0],
    y_test_pred[X_test.race==0],
    'Caucasian',
    'African-American',
    **compare_fpr=****True**
)
y_test_pred =  fitted_class_mdls['catboost_base']['preds_test']
_ = mldatasets.**compare_confusion_matrices**(
    y_test[X_test.race==1],
    y_test_pred[X_test.race==1],
    y_test[X_test.race==0],
    y_test_pred[X_test.race==0],
    'Caucasian',
    'African-American',
    **compare_fpr=****True**
) 
Figure 12.14 and *Figure 12.15*, corresponding to the regularized and base models, respectively. You can see *Figure 12.14* here:

图表,树状图图表,描述自动生成

图 12.14:正则化 CatBoost 模型之间的混淆矩阵

图 12.15告诉我们,正则化模型的 FPR 显著低于基础模型。您可以看到输出如下:

图表,瀑布图,树状图图表,描述自动生成

图 12.15:基础 CatBoost 模型之间的混淆矩阵

然而,如图 12.15 所示的基础模型与正则化模型的 FPR 比率为 1.11,而正则化模型的 FPR 比率为 1.47,尽管整体指标相似,但差异显著。但在尝试同时实现几个目标时,很难评估和比较模型,这就是我们将在下一节中要做的。

使用贝叶斯超参数调整和自定义指标优化公平性

我们的使命是生产一个具有高精确度和良好召回率,同时在不同种族间保持公平性的模型。因此,实现这一使命将需要设计一个自定义指标。

设计一个自定义指标

我们可以使用 F1 分数,但它对精确度和召回率的处理是平等的,因此我们不得不创建一个加权指标。我们还可以考虑每个种族的精确度和召回率的分布情况。实现这一目标的一种方法是通过使用标准差,它量化了这种分布的变化。为此,我们将用精确度的一半作为组间标准差来惩罚精确度,我们可以称之为惩罚后的精确度。公式如下:

![图片,B18406_12_003.png]

我们可以对召回率做同样的处理,如图所示:

![图片,B18406_12_004.png]

然后,我们为惩罚后的精确度和召回率做一个加权平均值,其中精确度是召回率的两倍,如图所示:

![图片,B18406_12_005.png]

为了计算这个新指标,我们需要创建一个可以调用weighted_penalized_pr_average的函数。它接受y_truey_pred作为预测性能指标。然而,它还包括X_group,它是一个包含组值的pandas序列或数组,以及group_vals,它是一个列表,它将根据这些值对预测进行子集划分。在这种情况下,组是race,可以是 0 到 2 的值。该函数包括一个for循环,遍历这些可能的值,通过每个组对预测进行子集划分。这样,它可以计算每个组的精确度和召回率。之后,函数的其余部分只是简单地执行之前概述的三个数学运算。代码可以在以下片段中看到:

def **weighted_penalized_pr_average**(y_true, y_pred, X_group,\
                    group_vals, penalty_mult=0.5,\
                    precision_mult=2,\
                    recall_mult=1):
    precision_all = metrics.**precision_score**(
        y_true, y_pred, zero_division=0
    )
    recall_all = metrics.**recall_score**(
        y_true, y_pred, zero_division=0
    )
    p_by_group = []
    r_by_group = []
    for group_val in group_vals:
        in_group = X_group==group_val
        p_by_group.append(metrics.**precision_score**(
            y_true[in_group], y_pred[in_group], zero_division=0
            )
        )
        r_by_group.append(metrics.**recall_score**(
            y_true[in_group], y_pred[in_group], zero_division=0
            )
        )
    precision_all = precision_all - \
                   (np.array(p_by_group).std()*penalty_mult)
    recall_all = recall_all -\
                (np.array(r_by_group).std()*penalty_mult)
    return ((precision_all*precision_mult)+
            (recall_all*recall_mult))/\
            (precision_mult+recall_mult) 

现在,为了使这个函数发挥作用,我们需要运行调整。

运行贝叶斯超参数调整

贝叶斯优化是一种 全局优化方法,它使用黑盒目标函数的后验分布及其连续参数。换句话说,它根据过去的结果顺序搜索下一个要测试的最佳参数。与网格搜索不同,它不会在网格上尝试固定参数组合,而是利用它已经知道的信息并探索未知领域。

bayesian-optimization 库是模型无关的。它所需的所有东西是一个函数以及它们的界限参数。它将在这些界限内探索这些参数的值。该函数接受这些参数并返回一个数字。这个数字,或目标,是贝叶斯优化算法将最大化的。

以下代码是用于 objective 函数的,它使用四个分割和三个重复初始化一个 RepeatedStratifiedKFold 交叉验证。然后,它遍历分割并使用它们拟合 CatBoostClassifier。最后,它计算每个模型训练的 weighted_penalized_pr_average 自定义指标并将其追加到一个列表中。最后,该函数返回所有 12 个训练样本的自定义指标的中位数。代码在以下片段中显示:

def **hyp_catboost**(l2_leaf_reg, scale_pos_weight):
    cv = model_selection.**RepeatedStratifiedKFold**(
        n_splits=4,n_repeats=3, random_state=rand
    )
    metric_l = []
    for train_index, val_index in cv.split(X_train, y_train):
        X_train_cv, X_val_cv = X_train.iloc[train_index],\
                               X_train.iloc[val_index]
        y_train_cv, y_val_cv = y_train.iloc[train_index],
                               y_train.iloc[val_index]
        mdl = cb.**CatBoostClassifier**(
            random_seed=rand, learning_rate=0.5, verbose=0, depth=5,\
            l2_leaf_reg=l2_leaf_reg, scale_pos_weight=scale_pos_weight
        )
        mdl = mdl.**fit**(X_train_cv, y_train_cv)
        y_val_pred = mdl.**predict**(X_val_cv)
        metric = **weighted_penalized_pr_average**(
            y_val_cv,y_val_pred, X_val_cv['race'], range(3)
        )
        metric_l.**append**(metric)
    return np.**median**(np.array(metric_l)) 

现在函数已经定义,运行贝叶斯优化过程很简单。首先,设置参数界限字典(pbounds),使用 hyp_catboost 函数初始化 BayesianOptimization,然后使用 maximize 运行它。maximize 函数接受 init_points,它设置初始使用随机探索运行的迭代次数。然后,n_iter 是它应该执行的优化迭代次数以找到最大值。我们将 init_pointsn_iter 分别设置为 37,因为可能需要很长时间,但这些数字越大越好。代码可以在以下片段中看到:

pbounds = {
    'l2_leaf_reg': (2,4),
    'scale_pos_weight': (0.55,0.85)
    }
optimizer = **BayesianOptimization**(
    **hyp_catboost**,
    pbounds, 
    random_state=rand
)
optimizer.maximize(init_points=3, n_iter=7) 

一旦完成,你可以访问最佳参数,如下所示:

print(optimizer.max['params']) 

它将返回一个包含参数的字典,如下所示:

{'l2_leaf_reg': 2.0207483077713997, 'scale_pos_weight': 0.7005623776446217} 

现在,让我们使用这些参数拟合一个模型并评估它。

使用最佳参数拟合和评估模型

使用这些参数初始化 CatBoostClassifier 与将 best_params 字典作为参数传递一样简单。然后,你所需要做的就是 fit 模型并评估它(evaluate_class_mdl)。代码在以下片段中显示:

cb_opt = cb.**CatBoostClassifier**(
    random_seed=rand,
    depth=5,
    learning_rate=0.5,
    verbose=0,
    **optimizer.max['params']
)
cb_opt = cb_opt.**fit**(X_train, y_train)
fitted_class_mdls['catboost_opt'] = mldatasets.**evaluate_class_mdl**(
    cb_opt,
    X_train,
    X_test,
    y_train,
    y_test,
    plot_roc=False,
    plot_conf_matrix=True,
    **ret_eval_dict=****True**
) 

前面的代码片段输出了以下预测性能指标:

Accuracy_train:  0.9652		Accuracy_test:   0.8192
Precision_test:  0.8330		Recall_test:     0.8058
ROC-AUC_test:    0.8791		F1_test:         0.8192 

这些是我们迄今为止达到的最高 Accuracy_testPrecision_testRecall_test 指标。现在让我们看看模型使用 compare_confusion_matrices 进行公平性测试的表现。请看以下代码片段:

y_test_pred = fitted_class_mdls['catboost_opt']['preds_test']
_ = mldatasets.**compare_confusion_matrices**(
    y_test[X_test.race==1],
    y_test_pred[X_test.race==1],
    y_test[X_test.race==0],
    y_test_pred[X_test.race==0],
    'Caucasian',
    'African-American',
    **compare_fpr=****True**
) 

前面的代码输出了 图 12.16,它显示了迄今为止我们获得的一些最佳公平性指标,如你所见:

图表 描述自动生成

图 12.16:优化后的 CatBoost 模型不同种族之间的混淆矩阵比较

这些结果很好,但我们不能完全确信模型没有种族偏见,因为特征仍然存在。衡量其影响的一种方法是通过特征重要性方法。

通过特征重要性来检查种族偏见

尽管 CatBoost 在大多数指标上,包括准确率、精确率和 F1 分数,都是我们表现最好的模型,但我们正在使用 XGBoost 前进,因为 CatBoost 不支持交互约束,我们将在下一节中实现。但首先,我们将比较它们在发现哪些特征重要方面的差异。此外,SHapley Additive exPlanationsSHAP)值提供了一种稳健的方法来衡量和可视化特征重要性,因此让我们为我们的优化 CatBoost 和正则化 XGBoost 模型计算它们。为此,我们需要用每个模型初始化TreeExplainer,然后使用shap_values为每个模型生成值,如下面的代码片段所示:

fitted_cb_mdl = fitted_class_mdls['catboost_opt']['fitted']
shap_cb_explainer = shap.**TreeExplainer**(fitted_cb_mdl)
shap_cb_values = shap_cb_explainer.**shap_values**(X_test)
fitted_xgb_mdl = fitted_class_mdls['xgb_reg']['fitted']
shap_xgb_explainer = shap.**TreeExplainer**(fitted_xgb_mdl)
shap_xgb_values = shap_xgb_explainer.**shap_values**(X_test) 

接下来,我们可以使用 Matplotlib 的subplot功能并排生成两个summary_plot图,如下所示:

ax0 = plt.subplot(1, 2, 1)
shap.**summary_plot**(
    **shap_xgb_values**,
    X_test,
    plot_type="dot",
    plot_size=None,
    show=False
)
ax0.set_title("XGBoost SHAP Summary")
ax1 = plt.subplot(1, 2, 2)
shap.**summary_plot**(
    **shap_cb_values**,
    X_test,
    plot_type="dot",
    plot_size=None,
    show=False
)
ax1.set_title("Catboost SHAP Summary") 
Figure 12.17, which shows how similar CatBoost and XGBoost are. This similarity shouldn’t be surprising because, after all, they are both gradient-boosted decision trees. The bad news is that race is in the top four for both. However, the prevalence of the shade that corresponds to lower feature values on the right suggests that African American (race=0) negatively correlates with recidivism.

输出结果如下:

图 12.17:XGBoost 正则化和 CatBoost 优化模型的 SHAP 总结图

在任何情况下,从训练数据中移除race是有意义的,但我们必须首先确定模型为什么认为这是一个关键特征。请看以下代码片段:

shap_xgb_interact_values =\
                shap_xgb_explainer.shap_interaction_values(X_test) 

第四章全局模型无关解释方法中,我们讨论了评估交互效应。现在是时候回顾这个话题了,但这次,我们将提取 SHAP 的交互值(shap_interaction_values)而不是使用 SHAP 的依赖图。我们可以很容易地使用summary_plot图对 SHAP 交互进行排序。SHAP 总结图非常有信息量,但它并不像交互热图那样直观。为了生成带有标签的热图,我们必须将shap_xgb_interact_values的总和放在 DataFrame 的第一个轴上,然后使用特征的名称命名列和行(index)。其余的只是使用 Seaborn 的heatmap函数将 DataFrame 绘制为热图。代码可以在下面的代码片段中看到:

shap_xgb_interact_avgs = np.abs(
    **shap_xgb_interact_values**
).mean(0)
np.fill_diagonal(shap_xgb_interact_avgs, 0)
shap_xgb_interact_df = pd.**DataFrame**(shap_xgb_interact_avgs)
shap_xgb_interact_df.columns = X_test.columns
shap_xgb_interact_df.index = X_test.columns
sns.**heatmap**(shap_xgb_interact_df, cmap='Blues', annot=True,\
            annot_kws={'size':13}, fmt='.2f', linewidths=.5) 

上述代码生成了图 12.18所示的热图。它展示了racelength_of_stayage_grouppriors per year之间的相互作用最为强烈。当然,一旦我们移除race,这些相互作用就会消失。然而,鉴于这一发现,如果这些特征中内置了种族偏见,我们应该仔细考虑。研究支持了age_grouppriors_per_year的必要性,这使length_of_stay成为审查的候选者。我们不会在本章中这样做,但这确实值得思考:

图形用户界面,应用程序描述自动生成

图 12.18:正则化 XGBoost 模型的 SHAP 交互值热图

图 12.18中得到的另一个有趣的见解是特征如何被聚类。你可以在c_charge_degreepriors_per_year之间的右下象限画一个框,因为一旦我们移除race,大部分的交互都将位于这里。限制令人烦恼的交互有很多好处。例如,为什么所有青少年犯罪特征,如juv_fel_count,都应该与age_group交互?为什么sex应该与length_of_stay交互?接下来,我们将学习如何围绕右下象限设置一个围栏,通过交互约束限制这些特征之间的交互。我们还将确保priors_per_year单调约束

实现模型约束

我们将首先讨论如何使用 XGBoost 以及所有流行的树集成实现约束,因为它们的参数名称相同(见图 12.12)。然后,我们将使用 TensorFlow Lattice 进行操作。但在我们继续之前,让我们按照以下方式从数据中移除race

X_train_con = X_train.**drop**(['race'], axis=1).copy()
X_test_con = X_test.**drop**(['race'], axis=1).copy() 

现在,随着race的消失,模型可能仍然存在一些偏见。然而,我们进行的特征工程和将要施加的约束可以帮助模型与这些偏见对齐,考虑到我们在第六章中发现的锚点和反事实解释的双重标准。话虽如此,生成的模型可能在对测试数据的性能上会较差。这里有两大原因,如下所述:

  • 信息丢失:种族,尤其是与其他特征的交互,影响了结果,因此不幸地携带了一些信息。

  • 现实与政策驱动理想的错位:当实施这些约束的主要原因是确保模型不仅符合领域知识,而且符合理想,而这些理想可能不在数据中明显体现时,这种情况就会发生。我们必须记住,一整套制度化的种族主义可能已经玷污了真实情况。模型反映了数据,但数据反映了地面的现实,而现实本身是有偏见的。

考虑到这一点,让我们开始实施约束!

XGBoost 的约束

在本节中,我们将采取三个简单的步骤。首先,我们将定义我们的训练参数,然后训练和评估一个约束模型,最后检查约束的效果。

设置正则化和约束参数

我们使用 print(fitted_class_mdls['xgb_reg']['cv_best_params']) 来获取我们正则化 XGBoost 模型的最佳参数。它们位于 best_xgb_params 字典中,包括 etamax_depth。然后,为了对 priors_per_year 应用单调约束,我们首先需要知道其位置和单调相关性的方向。从 图 12.8 中,我们知道这两个问题的答案。它是最后一个特征,相关性是正的,所以 mono_con 元组应该有九个项目,最后一个是一个 1,其余的是 0s。至于交互约束,我们只允许最后五个特征相互交互,前四个也是如此。interact_con 元组是一个列表的列表,反映了这些约束。代码可以在下面的片段中看到:

**best_xgb_params** = {'eta': 0.3, 'max_depth': 28,\
                   'reg_alpha': 0.2071, 'reg_lambda': 0.6534,\
                   'scale_pos_weight': 0.9114}
**mono_con** = (0,0,0,0,0,0,0,0,1)
**interact_con** = [[4, 5, 6, 7, 8],[0, 1, 2, 3]] 

接下来,我们将使用这些约束条件训练和评估 XGBoost 模型。

训练和评估约束模型

现在,我们将使用这些约束条件训练和评估我们的约束模型。首先,我们使用我们的约束和正则化参数初始化 XGBClassifier 模型,然后使用缺少 race 特征的训练数据 (X_train_con) 来拟合它。然后,我们使用 evaluate_class_mdl 评估预测性能,并与 compare_confusion_matrices 比较公平性,就像我们之前所做的那样。代码可以在下面的片段中看到:

xgb_con = xgb.XGBClassifier(
    seed=rand,monotone_constraints=**mono_con**,\
    interaction_constraints=**interact_con**, ****best_xgb_params**
)
xgb_con = xgb_con.**fit**(X_train_con, y_train)
fitted_class_mdls['xgb_con'] = mldatasets.**evaluate_class_mdl**(
    xgb_con, X_train_con, X_test_con, y_train, y_test,\
    plot_roc=False, ret_eval_dict=True
)
y_test_pred = fitted_class_mdls['xgb_con']['preds_test']
_ = mldatasets.**compare_confusion_matrices**(
    y_test[X_test.race==1],
    y_test_pred[X_test.race==1],
    y_test[X_test.race==0],
    y_test_pred[X_test.race==0],
    'Caucasian',
    'African-American',
     **compare_fpr=****True**
) 
Figure 12.19 and some predictive performance metrics. If we compare the matrices to those in *Figure 12.16*, racial disparities, as measured by our FPR ratio, took a hit. Also, predictive performance is lower than the optimized CatBoost model across the board, by 2–4%. We could likely increase these metrics a bit by performing the same *Bayesian hyperparameter tuning* on this model.

可以在这里看到混淆矩阵的输出:

图表描述自动生成

图 12.19:约束 XGBoost 模型不同种族之间的混淆矩阵比较

有一个需要考虑的事情是,尽管种族不平等是本章的主要关注点,但我们还希望确保模型在其他方面也是最优的。正如之前所述,这是一个权衡。例如,被告的 priors_per_year 越多,风险越高,这是很自然的,我们通过单调约束确保了这一点。让我们验证这些结果!

检查约束

观察约束条件在作用中的简单方法是将 SHAP summary_plot 绘制出来,就像我们在 图 12.17 中所做的那样,但这次我们只绘制一个。请看下面的 ode 程序片段:

fitted_xgb_con_mdl = fitted_class_mdls['xgb_con']['fitted']
shap_xgb_con_explainer = shap.**TreeExplainer**(fitted_xgb_con_mdl)
shap_xgb_con_values = shap_xgb_con_explainer.**shap_values**(
    X_test_con
)
shap.**summary_plot**(
    shap_xgb_con_values, X_test_con, plot_type="dot"
) 

上述代码生成了 图 12.20。这展示了从左到右的 priors_per_year 是一个更干净的梯度,这意味着较低的值持续产生负面影响,而较高的值产生正面影响——正如它们应该的那样!

你可以在这里看到输出:

图表描述自动生成

图 12.20:约束 XGBoost 模型的 SHAP 概述图

接下来,让我们通过 图 12.7 中的数据视角检查我们看到的 age_grouppriors_per_year 的交互。我们也可以通过添加额外的参数来为模型使用 plot_prob_contour_map,如下所示:

  • 拟合的模型 (fitted_xgb_con_mdl)

  • 用于模型推理的 DataFrame (X_test_con)

  • 在每个轴上比较的 DataFrame 中两列的名称(x_coly_col

结果是一个交互部分依赖图,类似于第四章中展示的,全局模型无关解释方法,只不过它使用数据集(recidivism_df)为每个轴创建直方图。我们现在将创建两个这样的图进行比较——一个用于正则化的 XGBoost 模型,另一个用于约束模型。此代码的示例如下:

mldatasets.**plot_prob_contour_map**(
    recidivism_df.**age_group**, recidivism_df.**priors_per_year**,
    recidivism_df.**is_recid**, x_intervals=ordenc.categories_[2],
    y_intervals=6, use_quantiles=True, xlabel='Age Group',
    ylabel='Priors Per Year', model=**fitted_xgb_mdl**,
    X_df=**X_test**,x_col='age_group',y_col='priors_per_year',
    title='Probability of Recidivism by Age/Priors per Year \
          (according to XGBoost Regularized Model)'
)
mldatasets.**plot_prob_contour_map**(
    recidivism_df.**age_group**, recidivism_df.**priors_per_year**,
    recidivism_df.is_recid, x_intervals=ordenc.categories_[2],
    y_intervals=6, use_quantiles=True, xlabel='Age Group',
    ylabel='Priors Per Year', model=**fitted_xgb_con_mdl**,
    X_df=**X_test_con**,x_col='age_group',y_col='priors_per_year',
    title='(according to XGBoost Constrained Model)'
) 

上述代码生成了图 12.21中显示的图表。它表明正则化的 XGBoost 模型反映了数据(参见图 12.7)。另一方面,约束的 XGBoost 模型平滑并简化了等高线,如下所示:

图表,自动生成描述

图 12.21:根据 XGBoost 正则化和约束模型,针对 age_group 和 priors_per_year 的再犯概率等高线图

接下来,我们可以从图 12.18生成 SHAP 交互值热图,但针对的是约束模型。代码相同,但使用shap_xgb_con_explainer SHAP 解释器和X_test_con数据。代码的示例如下:

shap_xgb_interact_values =\
        shap_xgb_con_explainer.**shap_interaction_values**(X_test_con)
shap_xgb_interact_df =\
        pd.**DataFrame**(np.sum(**shap_xgb_interact_values**, axis=0))
shap_xgb_interact_df.columns = X_test_con.columns
shap_xgb_interact_df.index = X_test_con.columns
sns.**heatmap**(
    shap_xgb_interact_df, cmap='RdBu', annot=True,
    annot_kws={'size':13}, fmt='.0f', linewidths=.5
) 
Figure 12.22. It shows how the interaction constraints were effective because of zeros in the lower-left and lower-right quadrants, which correspond to interactions between the two groups of features we separated. If we compare with *Figure 12.18*, we can also tell how the constraints shifted the most salient interactions, making age_group and length_of_stay by far the most important ones.

输出结果如下:

包含应用的图片,自动生成描述

图 12.22:约束 XGBoost 模型的 SHAP 交互值热图

现在,让我们看看 TensorFlow 是如何通过 TensorFlow Lattice 实现单调性和其他“形状约束”的。

TensorFlow Lattice 的约束条件

神经网络在寻找loss函数的最优解方面可以非常高效。损失与我们要预测的后果相关联。在这种情况下,那将是 2 年的再犯率。在伦理学中,功利主义(或后果主义)的公平观只要模型的训练数据没有偏见,就没有问题。然而,义务论的观点是,伦理原则或政策驱动着伦理问题,并超越后果。受此启发,TensorFlow LatticeTFL)可以在模型中将伦理原则体现为模型形状约束。

晶格是一种插值查找表,它通过插值近似输入到输出的网格。在高维空间中,这些网格成为超立方体。每个输入到输出的映射通过校准层进行约束,并且支持许多类型的约束——不仅仅是单调性。图 12.23展示了这一点:

图表,自动生成描述

图 12.23:TensorFlow Lattice 支持的约束条件

图 12.23展示了几个形状约束。前三个应用于单个特征(x),约束了线,代表输出。最后两个应用于一对特征(x[1]和x[2]),约束了彩色等高线图()。以下是对每个约束的简要说明:

  • 单调性:这使得函数()相对于输入(x)总是增加(1)或减少(-1)。

  • 凸性:这迫使函数()相对于输入(x)是凸的(1)或凹的(-1)。凸性可以与单调性结合,产生图 12.23中的效果。

  • 单峰性:这类似于单调性,不同之处在于它向两个方向延伸,允许函数()有一个单一的谷底(1)或峰值(-1)。

  • 信任:这迫使一个单调特征(x[1])依赖于另一个特征(x[2])。图 12.23中的例子是爱德华兹信任,但也有一个具有不同形状约束的梯形信任变体。

  • 支配性:单调支配性约束一个单调特征(x[1])定义斜率或效果的方向,当与另一个特征(x[2])比较时。另一种选择,范围支配性,类似,但两个特征都是单调的。

神经网络特别容易过拟合,控制它的杠杆相对更难。例如,确切地说,隐藏节点、dropout、权重正则化和 epoch 的哪种组合会导致可接受的过拟合水平是难以确定的。另一方面,在基于树的模型中移动单个参数,即树深度,朝一个方向移动,可能会将过拟合降低到可接受的水平,尽管可能需要许多不同的参数才能使其达到最佳状态。

强制形状约束不仅增加了可解释性,还因为简化了函数而正则化了模型。TFL 还支持基于惩罚的正则化,针对每个特征或校准层的核,利用拉普拉斯海森扭转皱纹正则化器通过 L1 和 L2 惩罚。这些正则化器的作用是使函数更加平坦、线性或平滑。我们不会详细解释,但可以说,存在正则化来覆盖许多用例。

实现框架的方法也有几种——太多,这里无法一一详述!然而,重要的是指出,这个例子只是实现它的几种方法之一。TFL 内置了预定义的估计器,它们抽象了一些配置。您还可以使用 TFL 层创建一个自定义估计器。对于 Keras,您可以使用预制的模型,或者使用 TensorFlow Lattice 层构建一个 Keras 模型。接下来,我们将进行最后一项操作!

初始化模型和 Lattice 输入

现在我们将创建一系列输入层,每个输入层包含一个特征。这些层连接到校准层,使每个输入适合符合个体约束和正则化的分段线性PWL)函数,除了sex,它将使用分类校准。所有校准层都输入到一个多维晶格层,通过一个具有sigmoid激活的密集层产生输出。这个描述可能有点难以理解,所以您可以自由地跳到图 12.24以获得一些视觉辅助。

顺便说一下,有许多种类的层可供连接,以产生深度晶格网络DLN),包括以下内容:

  • 线性用于多个输入之间的线性函数,包括具有支配形状约束的函数。

  • 聚合用于对多个输入执行聚合函数。

  • 并行组合将多个校准层放置在单个函数中,使其与 Keras Sequential层兼容。

在这个例子中,我们不会使用这些层,但也许了解这些会激发您进一步探索 TensorFlow Lattice 库。无论如何,回到这个例子!

首先要定义的是lattice_sizes,它是一个元组,对应于每个维度的顶点数。在所选架构中,每个特征都有一个维度,因此我们需要选择九个大于或等于 2 的数字。对于分类特征的基数较小的特征或连续特征的拐点,需要较少的顶点。然而,我们也可能想通过故意选择更少的顶点来限制特征的表达能力。例如,juv_fel_count有 10 个唯一值,但我们将只给它分配两个顶点。lattice_sizes如下所示:

lattice_sizes = [2, 2, 2, 2, 4, 5, 7, 7, 7] 

接下来,我们初始化两个列表,一个用于放置所有输入层(model_inputs)和另一个用于校准层(lattice_inputs)。然后,对于每个特征,我们逐一定义一个输入层使用tf.keras.layers.Input和一个校准层使用分类校准(tfl.layers.CategoricalCalibration)或 PWL 校准(tfl.layers.PWLCalibration)。每个特征的所有输入和校准层都将分别添加到各自的列表中。校准层内部发生的事情取决于特征。所有 PWL 校准都使用input_keypoints,它询问 PWL 函数应该在何处分段。有时,使用固定宽度(np.linspace)回答这个问题是最好的,而有时使用固定频率(np.quantile)。分类校准则使用桶(num_buckets),它对应于类别的数量。所有校准器都有以下参数:

  • output_min:校准器的最小输出

  • output_max:校准器的最大输出——始终必须与输出最小值 + 晶格大小 - 1 相匹配

  • monotonicity:是否应该单调约束 PWL 函数,如果是,如何约束

  • kernel_regularizer:如何正则化函数

除了这些参数之外,convexityis_cyclic(对于单调单峰)可以修改约束形状。看看下面的代码片段:

model_inputs = []
lattice_inputs = []
sex_input = **tf.keras.layers.Input**(shape=[1], name='sex')
lattice_inputs.append(**tfl.layers.CategoricalCalibration**(
    name='sex_calib',
    num_buckets=2,
    output_min=0.0,
    output_max=lattice_sizes[0] - 1.0,
    kernel_regularizer=tf.keras.regularizers.l1_l2(l1=0.001),
    kernel_initializer='constant')(sex_input)
)
model_inputs.append(sex_input)
juvf_input = **tf.keras.layers.Input**(shape=[1],\
                                   name='juv_fel_count')
lattice_inputs.append(**tfl.layers.PWLCalibration**(
    name='juvf_calib',
    **monotonicity**='none',
    input_keypoints=np.linspace(0, 20, num=5, dtype=np.float32),
    output_min=0.0,
    output_max=lattice_sizes[1] - 1.0,\
    kernel_regularizer=tf.keras.regularizers.l1_l2(l1=0.001),
    kernel_initializer='equal_slopes')(juvf_input)
)
model_inputs.append(juvf_input)
age_input = **tf.keras.layers.Input**(shape=[1], name='age_group')
lattice_inputs.append(**tfl.layers.PWLCalibration**(
    name='age_calib',
    **monotonicity**='none',
    input_keypoints=np.linspace(0, 6, num=7, dtype=np.float32),
    output_min=0.0,
    output_max=lattice_sizes[7] - 1.0,
    kernel_regularizer=('hessian', 0.0, 1e-4))(age_input)
)
model_inputs.append(age_input)
priors_input = **tf.keras.layers.Input**(shape=[1],\
                                     name='priors_per_year')
lattice_inputs.append(**tfl.layers.PWLCalibration**(
    name='priors_calib',
    **monotonicity**='increasing',
    input_keypoints=np.quantile(X_train_con['priors_per_year'],
                                np.linspace(0, 1, num=7)),
    output_min=0.0,
    output_max=lattice_sizes[8]-1.0)(priors_input))
model_inputs.append(priors_input) 

因此,我们现在有一个包含 model_inputs 的列表和另一个包含校准层的列表,这些校准层将成为 lattice 的输入(lattice_inputs)。我们现在需要做的就是将这些连接到一个 lattice 上。

使用 TensorFlow Lattice 层构建 Keras 模型

我们已经将这个模型的前两个构建块连接起来。现在,让我们创建最后两个构建块,从 lattice (tfl.layers.Lattice) 开始。作为参数,它接受 lattice_sizes、输出最小值和最大值以及它应该执行的 monotonicities。注意,最后一个参数 priors_per_year 的单调性设置为 increasing。然后,lattice 层将输入到最终的部件,即具有 sigmoid 激活的 Dense 层。代码如下所示:

lattice = **tfl.layers.Lattice**(
    name='lattice',
    lattice_sizes=**lattice_sizes**,
    **monotonicities**=[
        'none', 'none', 'none', 'none', 'none',
        'none', 'none', 'none', **'increasing'**
    ],
    output_min=0.0, output_max=1.0)(**lattice_inputs**)
model_output = tf.keras.layers.**Dense**(1, name='output',
                                     activation='sigmoid')(lattice) 

前两个构建块作为 inputs 现在可以与最后两个作为 outputs 通过 tf.keras.models.Model 连接起来。哇!我们现在有一个完整的模型,代码如下所示:

tfl_mdl = **tf.keras.models.Model**(inputs=model_inputs,
                                outputs=model_output) 

你总是可以运行 tfl_mdl.summary() 来了解所有层是如何连接的,但使用 tf.keras.utils.plot_model 更直观,如下面的代码片段所示:

tf.keras.utils.plot_model(tfl_mdl, rankdir='LR') 

上述代码生成了 图 12.24 中显示的模型图:

图  描述自动生成

图 12.24:带有 TFL 层的 Keras 模型图

接下来,我们需要编译模型。我们将使用 binary_crossentropy 损失函数和 Adam 优化器,并使用准确率和 曲线下面积AUC)作为指标,如下面的代码片段所示:

tfl_mdl.**compile**(
    loss='binary_crossentropy',
    optimizer=tf.keras.optimizers.Adam(lr=0.001),
    metrics=['accuracy',tf.keras.metrics.AUC(name='auc')]
) 

我们现在几乎准备就绪了!接下来是最后一步。

训练和评估模型

如果你仔细观察 图 12.24,你会注意到模型没有一层输入,而是有九层,这意味着我们必须将我们的训练和测试数据分成九部分。我们可以使用 np.split 来做这件事,这将产生九个 NumPy 数组的列表。至于标签,TFL 不接受单维数组。使用 expand_dims,我们将它们的形状从 (N,) 转换为 (N,1),如下面的代码片段所示:

X_train_expand = np.**split**(
    X_train_con.values.astype(np.float32),
    indices_or_sections=9,
    axis=1
)
y_train_expand = np.**expand_dims**(
    y_train.values.astype(np.float32),
    axis=1
)
X_test_expand = np.**split**(
    X_test_con.values.astype(np.float32),
    indices_or_sections=9,
    axis=1)
y_test_expand = np.**expand_dims**(
    y_test.values.astype(np.float32),
    axis=1
) 

接下来是训练!为了防止过拟合,我们可以通过监控验证 AUC (val_auc) 来使用 EarlyStopping。为了解决类别不平衡问题,在 fit 函数中,我们使用 class_weight,如下面的代码片段所示:

es = tf.keras.callbacks.**EarlyStopping**(
    monitor='**val_auc**',
    mode='max',
    patience=40,
    restore_best_weights=True
)
tfl_history = tfl_mdl.**fit**(
    X_train_expand,
    y_train_expand,
    **class_weight**={0:18, 1:16},
    batch_size=128,
    epochs=300,
    validation_split=0.2,
    shuffle=True,
    callbacks=[**es**]
) 

一旦模型训练完成,我们可以使用 evaluate_class_mdl 来输出预测性能的快速摘要,就像我们之前做的那样,然后使用 compare_confusion_matrices 来检查公平性,就像我们之前所做的那样。代码如下所示:

fitted_class_mdls['tfl_con'] = mldatasets.**evaluate_class_mdl**(
    tfl_mdl,
    X_train_expand,
    X_test_expand,
    y_train.values.astype(np.float32),
    y_test.values.astype(np.float32),
    plot_roc=False,
    ret_eval_dict=True
)
y_test_pred = fitted_class_mdls['tfl_con']['preds_test']
_ = mldatasets.**compare_confusion_matrices**(
    y_test[X_test.race==1],
    y_test_pred[X_test.race==1],
    y_test[X_test.race==0],
    y_test_pred[X_test.race==0],
    'Caucasian',
    'African-American',
    compare_fpr=True
) 
Figure 12.25. The TensorFlow Lattice model performs much better than the regularized Keras model, yet the FPR ratio is better than the constrained XGBoost model. It must be noted that XGBoost’s parameters were previously tuned. With TensorFlow Lattice, a lot could be done to improve FPR, including using a custom loss function or better early-stopping metrics that somehow account for racial disparities.

输出如下所示:

图表,树状图  描述自动生成

图 12.25:约束 TensorFlow Lattice 模型在种族之间的混淆矩阵比较

接下来,我们将根据本章学到的内容得出一些结论,并确定我们是否完成了任务。

任务完成

通常,数据会因为表现不佳、不可解释或存在偏见而被责备,这可能是真的,但在准备和模型开发阶段可以采取许多不同的措施来改进它。为了提供一个类比,这就像烘焙蛋糕。你需要高质量的原料,是的。但似乎微小的原料准备和烘焙本身——如烘焙温度、使用的容器和时间——的差异可以产生巨大的影响。天哪!甚至是你无法控制的事情,如大气压力或湿度,也会影响烘焙!甚至在完成之后,你有多少种不同的方式可以评估蛋糕的质量?

本章讨论了这些许多细节,就像烘焙一样,它们既是精确科学的一部分,也是艺术形式的一部分。本章讨论的概念也具有深远的影响,特别是在如何优化没有单一目标且具有深远社会影响的问题方面。一种可能的方法是结合指标并考虑不平衡。为此,我们创建了一个指标:一个加权平均的精确率召回率,它惩罚种族不平等,并且我们可以为所有模型高效地计算它并将其放入模型字典(fitted_class_mdls)。然后,就像我们之前做的那样,我们将其放入 DataFrame 并输出,但这次是按照自定义指标(wppra_test)排序。代码可以在下面的代码片段中看到:

for mdl_name in fitted_class_mdls:
    fitted_class_mdls[mdl_name]['wppra_test'] =\
    **weighted_penalized_pr_average**(
        y_test,
        fitted_class_mdls[mdl_name]['preds_test'],
        X_test['race'],
        range(3)
    )
class_metrics = pd.**DataFrame.from_dict**(fitted_class_mdls, 'index')[
    ['precision_test', 'recall_test', 'wppra_test']
]
with pd.option_context('display.precision', 3):
    html = class_metrics.**sort_values**(
        by='**wppra_test**',
        ascending=False
        ).style.background_gradient(
           cmap='plasma',subset=['precision_test']
        ).background_gradient(
           cmap='viridis', subset=['recall_test'])
html 

上一段代码生成了图 12.26中显示的 DataFrame:

表格描述自动生成

图 12.26:按加权惩罚精确率-召回率平均值自定义指标排序的本章顶级模型

图 12.26中,很容易提出最上面的其中一个模型。然而,它们是用race作为特征进行训练的,并且没有考虑到证明的刑事司法现实。然而,性能最高的约束模型——XGBoost 模型(xgb_con)——没有使用race,确保了priors_per_year是单调的,并且不允许age_group与青少年犯罪特征相互作用,而且与原始模型相比,它在显著提高预测性能的同时做到了这一切。它也更公平,因为它将特权群体和弱势群体之间的 FPR 比率从 1.84x(第六章中的图 6.2)降低到 1.39x(图 12.19)。它并不完美,但这是一个巨大的改进!

任务是证明准确性和领域知识可以与公平性的进步共存,我们已经成功地完成了它。话虽如此,仍有改进的空间。因此,行动计划将不得不向您的客户展示受约束的 XGBoost 模型,并继续改进和构建更多受约束的模型。未受约束的模型应仅作为基准。

如果你将本章的方法与第十一章中学习的那些方法(偏差缓解和因果推断方法)相结合,你可以实现显著的公平性改进。我们没有将这些方法纳入本章,以专注于通常不被视为偏差缓解工具包一部分的模型(或内处理)方法,但它们在很大程度上可以协助达到这一目的,更不用说那些旨在使模型更可靠的模型调优方法了。

摘要

阅读本章后,你现在应该了解如何利用数据工程来增强可解释性,正则化来减少过拟合,以及约束来符合政策。主要的目标是设置护栏和遏制阻碍可解释性的复杂性。

在下一章中,我们将探讨通过对抗鲁棒性来增强模型可靠性的方法。

数据集来源

进一步阅读

  • Hastie, T. J., Tibshirani, R. J. 和 Friedman, J. H. (2001). 统计学习的要素. Springer-Verlag, 纽约,美国

  • Wang, S. & Gupta, M. (2020). 通过单调性形状约束的德性伦理. AISTATS. arxiv.org/abs/2001.11990

  • Cotter, A., Gupta, M., Jiang, H., Ilan, E. L., Muller, J., Narayan, T., Wang, S. 和 Zhu, T. (2019). 集合函数的形状约束. ICML. proceedings.mlr.press/v97/cotter19a.html

  • Gupta, M. R., Cotter A., Pfeifer, J., Voevodski, K., Canini, K., Mangylov, A., Moczydlowski, W. 和 van Esbroeck, A. (2016). 单调校准插值查找表. 机器学习研究杂志 17(109):1−47. arxiv.org/abs/1505.06378

  • Noble, S. (2018). 压迫算法:谷歌时代的数据歧视. NYU Press

在 Discord 上了解更多

要加入本书的 Discord 社区——在那里您可以分享反馈,向作者提问,并了解新发布的内容——请扫描下面的二维码:

packt.link/inml

第十三章:对抗性鲁棒性

机器学习解释有许多关注点,从知识发现到具有实际伦理影响的高风险问题,如上一两章中探讨的公平性问题。在本章中,我们将关注涉及可靠性、安全性和安全性的问题。

正如我们在第七章可视化卷积神经网络中使用的对比解释方法所意识到的那样,我们可以轻易地欺骗图像分类器做出令人尴尬的错误预测。这种能力可能具有严重的后果。例如,一个肇事者可以在让路标志上贴上一个黑色贴纸,尽管大多数司机仍然会将其识别为让路标志,但自动驾驶汽车可能就不再能识别它,从而导致撞车。银行劫匪可能穿着一种冷却服装来欺骗银行保险库的热成像系统,尽管任何人都可能注意到它,但成像系统却无法做到。

风险不仅限于复杂的图像分类器。其他模型也可能被欺骗!在第六章,锚点和反事实解释中产生的反事实示例,就像对抗性示例一样,但目的是欺骗。攻击者可以利用任何误分类示例,在决策边界上进行对抗性操作。例如,垃圾邮件发送者可能会意识到调整一些电子邮件属性可以增加绕过垃圾邮件过滤器的可能性。

复杂模型更容易受到对抗性攻击。那么我们为什么还要信任它们呢?!我们当然可以使它们更加可靠,这就是对抗性鲁棒性的含义。对手可以通过多种方式故意破坏模型,但我们将重点关注逃避攻击,并简要解释其他形式的攻击。然后我们将解释两种防御方法:空间平滑预处理和对抗性训练。最后,我们将展示一种鲁棒性评估方法。

这些是我们将要讨论的主要主题:

  • 了解逃避攻击

  • 使用预处理防御针对攻击

  • 通过对鲁棒分类器的对抗性训练来防御任何逃避攻击

技术要求

本章的示例使用了mldatasetsnumpysklearntensorflowkerasadversarial-robustness-toolboxmatplotlibseaborn库。如何安装所有这些库的说明在前言中。

本章的代码位于此处:packt.link/1MNrL

任务

全球私人签约保安服务行业市场规模超过 2500 亿美元,年增长率为约 5%。然而,它面临着许多挑战,例如在许多司法管辖区缺乏足够培训的保安和专业的安全专家,以及一系列意外的安全威胁。这些威胁包括广泛的协调一致的网络安全攻击、大规模暴乱、社会动荡,以及最后但同样重要的是,由大流行带来的健康风险。确实,2020 年通过勒索软件、虚假信息攻击、抗议活动和 COVID-19 等一系列事件考验了该行业。

在此之后,美国最大的医院网络之一要求他们的签约保安公司监控医院内访客和员工佩戴口罩的正确性。保安公司因为这项请求而感到困扰,因为它分散了保安人员应对其他威胁(如入侵者、斗殴患者和挑衅访客)的精力。该公司在每个走廊、手术室、候诊室和医院入口都有视频监控。每次都不可能监控到每个摄像头的画面,因此保安公司认为他们可以用深度学习模型来协助保安:

这些模型已经能够检测到异常活动,例如在走廊里奔跑和在物业任何地方挥舞武器。他们向医院网络提出建议,希望添加一个新模型来检测口罩的正确使用。在 COVID-19 之前,医院各区域已经实施了强制佩戴口罩的政策,而在 COVID-19 期间,则要求在所有地方佩戴口罩。医院管理员希望根据未来的大流行风险水平来开启和关闭这一监控功能。他们意识到,人员会感到疲惫并忘记戴上口罩,或者有时口罩会部分滑落。许多访客也对佩戴口罩持敌对态度,他们可能会在进入医院时戴上口罩,但如果没有保安在场,就会摘下。这并不总是故意的,因此他们不希望像对其他威胁一样,在每次警报时都派遣保安:

杆上的黄色标志  描述由低置信度自动生成

图 13.1:像这样的雷达速度标志有助于遏制超速

意识是雷达速度标志(见图 13.1)的一种非常有效的方法,它通过仅让驾驶员意识到他们开得太快,从而使道路更安全。同样,在繁忙走廊的尽头设置屏幕,显示最近错误或故意未遵守强制佩戴口罩规定的人的快照,可能会让违规者感到尴尬。系统将记录反复违规者,以便保安可以找到他们,要么让他们遵守规定,要么要求他们离开现场。

对于试图欺骗模型规避合规性的访客,存在一些担忧,因此安全公司雇佣你来确保模型在面对这种对抗性攻击时具有鲁棒性。安全官员在之前注意到一些低技术含量的诡计,例如人们在意识到摄像头正在监控他们时,会暂时用手或毛衣的一部分遮住他们的脸。在一个令人不安的事件中,访客降低了灯光,并在摄像头上喷了一些凝胶,在另一个事件中,有人涂鸦了他们的嘴巴。然而,人们对更高技术攻击的担忧,例如干扰摄像头的无线信号或直接向摄像头照射高功率激光。执行这些攻击的设备越来越容易获得,可能会对更大规模的监控功能,如防止盗窃,产生影响。安全公司希望这种鲁棒性练习能够为改善每个监控系统和模型的努力提供信息。

最终,安全公司希望使用他们监控的医院中的面部图像来生成自己的数据集。同时,从外部来源合成的面具面部图像是他们短期内将模型投入生产的最佳选择。为此,你被提供了一组合成的正确和错误面具面部图像及其未面具对应图像的大型数据集。这两个数据集被合并成一个,原始的 1,024 × 1,024 尺寸被减少到缩略图的 124 × 124 尺寸。此外,为了提高效率,从这些数据集中采样了大约 21,000 张图像。

方法

你已经决定采取四步方法:

  • 探索几种可能的规避攻击,以了解模型对这些攻击的脆弱性以及它们作为威胁的可靠性

  • 使用预处理方法来保护模型免受这些攻击

  • 利用对抗性再训练来生成一个本质上对许多此类攻击更不易受影响的鲁棒分类器

  • 使用最先进的方法评估鲁棒性,以确保医院管理员相信该模型具有对抗性鲁棒性

让我们开始吧!

准备工作

你可以在以下位置找到这个示例的代码:github.com/PacktPublishing/Interpretable-Machine-Learning-with-Python-2E/tree/main/13/Masks.ipynb

加载库

要运行此示例,你需要安装以下库:

  • mldatasets 用于加载数据集

  • numpysklearn (scikit-learn) 用于操作它

  • tensorflow 用于拟合模型

  • matplotlibseaborn 用于可视化解释

你应该首先加载所有这些:

import math
import os
import warnings
warnings.filterwarnings("ignore")
import mldatasets
import numpy as np
from sklearn import preprocessing
import tensorflow as tf
from tensorflow.keras.utils import get_file
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn import metrics
from art.estimators.classification import KerasClassifier
from art.attacks.evasion import FastGradientMethod,\
                      ProjectedGradientDescent, BasicIterativeMethod
from art.attacks.evasion import CarliniLInfMethod
from art.attacks.evasion import AdversarialPatchNumpy
from art.defences.preprocessor import SpatialSmoothing
from art.defences.trainer import AdversarialTrainer
from tqdm.notebook import tqdm 

让我们用 print(tf.__version__) 检查 TensorFlow 是否加载了正确的版本。版本应该是 2.0 及以上。

我们还应该禁用即时执行,并验证它是否已通过以下命令完成:

tf.compat.v1.disable_eager_execution()
print('Eager execution enabled:', tf.executing_eagerly()) 

输出应该显示为 False

在 TensorFlow 中,开启急切执行模式意味着它不需要计算图或会话。这是 TensorFlow 2.x 及以后版本的默认设置,但在之前的版本中不是,所以你需要禁用它以避免与为 TensorFlow 早期版本优化的代码不兼容。

理解和准备数据

我们将数据加载到四个 NumPy 数组中,对应于训练/测试数据集。在此过程中,我们将X面部图像除以 255,因为这样它们的值将在零和一之间,这对深度学习模型更好。我们称这种特征缩放。我们需要记录训练数据的min_max_,因为我们稍后会需要这些信息:

X_train, X_test, y_train, y_test = mldatasets.load(
    "maskedface-net_thumbs_sampled", prepare=True
)
X_train, X_test = X_train / 255.0, X_test / 255.0
min_ = X_train.min()
max_ = X_train.max() 

当我们加载数据时,始终验证数据非常重要,以确保数据没有被损坏:

print('X_train dim:\t%s' % (X_train.**shape**,))
print('X_test dim:\t%s' % (X_test.**shape**,))
print('y_train dim:\t%s' % (y_train.**shape**,))
print('y_test dim:\t%s' % (y_test.**shape**,))
print('X_train min:\t%s' % (**min_**))
print('X_train max:\t%s' % (**max_**))
print('y_train labels:\t%s' % (np.**unique**(y_train))) 
that they are not one-hot encoded. Indeed, by printing the unique values (np.unique(y_train)), we can tell that labels are represented as text: Correct for correctly masked, Incorrect for incorrectly masked, and None for no mask:
X_train dim:    (16800, 128, 128, 3)
X_test dim: (4200, 128, 128, 3)
y_train dim:    (16800, 1)
y_test dim: (4200, 1)
X_train min:    0.0
X_train max:    1.0
y_train labels: ['Correct' 'Incorrect' 'None'] 

因此,我们需要执行的一个预处理步骤是将y标签独热编码OHE),因为我们需要 OHE 形式来评估模型的预测性能。一旦我们初始化OneHotEncoder,我们就需要将其fit到训练数据(y_train)中。我们还可以将编码器中的类别提取到一个列表(labels_l)中,以验证它包含所有三个类别:

ohe = preprocessing.**OneHotEncoder**(sparse=False)
ohe.**fit**(y_train)
labels_l = ohe.**categories_**[0].tolist()
print(labels_l) 

为了确保可复现性,始终以这种方式初始化你的随机种子:

rand = 9
os.environ['PYTHONHASHSEED'] = str(rand)
tf.random.set_seed(rand)
np.random.seed(rand) 

使机器学习真正可复现意味着也要使其确定性,这意味着使用相同的数据进行训练将产生具有相同参数的模型。在深度学习中实现确定性非常困难,并且通常依赖于会话、平台和架构。如果你使用 NVIDIA GPU,你可以安装一个名为framework-reproducibility的库。

本章我们将要学习的许多对抗攻击、防御和评估方法都非常资源密集,所以如果我们用整个测试数据集来使用它们,它们可能需要数小时才能完成单个方法!为了提高效率,强烈建议使用测试数据集的样本。因此,我们将使用np.random.choice创建一个中等大小的 200 张图像样本(X_test_mdsample, y_test_mdsample)和一个小型 20 张图像样本(X_test_smsample, y_test_smsample):

sampl_md_idxs = np.random.choice(X_test.shape[0], 200, replace=False)
X_test_mdsample = X_test[sampl_md_idxs]
y_test_mdsample = y_test[sampl_md_idxs]
sampl_sm_idxs = np.random.choice(X_test.shape[0], 20, replace=False)
X_test_smsample = X_test[sampl_sm_idxs]
y_test_smsample = y_test[sampl_sm_idxs] 

我们有两个样本大小,因为某些方法在较大的样本大小下可能需要太长时间。现在,让我们看看我们的数据集中有哪些图像。在先前的代码中,我们已经从我们的测试数据集中取了一个中等和一个小样本。我们将使用以下代码将我们小样本中的每张图像放置在一个 4 × 5 的网格中,类别标签位于其上方:

plt.subplots(figsize=(15,12))
for s in range(20):
    plt.subplot(4, 5, s+1)
    plt.title(y_test_smsample[s][0], fontsize=12)
    plt.imshow(X_test_smsample[s], interpolation='spline16')
    plt.axis('off')
plt.show() 

上述代码在图 13.2中绘制了图像网格:

一个人物的拼贴画  描述由中等置信度自动生成

图 13.2:一个带有遮挡和未遮挡面部的小型测试数据集样本

图 13.2 展示了各种年龄、性别和种族的正面和反面、带口罩和不带口罩的面部图像。尽管种类繁多,但关于这个数据集的一个需要注意的事项是,它只展示了浅蓝色的外科口罩,且图像大多是正面角度。理想情况下,我们会生成一个包含所有颜色和类型口罩的更大数据集,并在训练前或训练期间对其进行随机旋转、剪切和亮度调整,以进一步增强模型的鲁棒性。这些增强将使模型更加鲁棒。尽管如此,我们必须区分这种一般类型的鲁棒性和对抗鲁棒性。

加载 CNN 基础模型

您不必训练 CNN 基础模型,但相关的代码已提供在 GitHub 仓库中。预训练模型也已存储在那里。我们可以快速加载模型并输出其摘要,如下所示:

model_path = **get_file**('CNN_Base_MaskedFace_Net.hdf5',\
    'https://github.com/PacktPublishing/Interpretable-Machine- \
    Learning-with-Python/blob/master/models/ \
    CNN_Base_MaskedFace_Net.hdf5?raw=true')
base_model = tf.keras.models.**load_model**(model_path)
base_model.**summary**() 

上述代码片段输出了以下摘要:

Model: "CNN_Base_MaskedFaceNet_Model"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_1 (Conv2D)            (None, 126, 126, 16)      448       _________________________________________________________________
maxpool2d_1 (MaxPooling2D)   (None, 63, 63, 16)        0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 61, 61, 32)        4640      
_________________________________________________________________
maxpool2d_2 (MaxPooling2D)   (None, 30, 30, 32)        0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 28, 28, 64)        18496     
_________________________________________________________________
maxpool2d_3 (MaxPooling2D)   (None, 14, 14, 64)        0         
_________________________________________________________________
conv2d_4 (Conv2D)            (None, 12, 12, 128)       73856     
_________________________________________________________________
maxpool2d_4 (MaxPooling2D)   (None, 6, 6, 128)         0         
_________________________________________________________________
flatten_6 (Flatten)          (None, 4608)              0         
_________________________________________________________________
dense_1 (Dense)              (None, 768)               3539712   
_________________________________________________________________
dropout_6 (Dropout)          (None, 768)               0         
_________________________________________________________________
dense_2 (Dense)              (None, 3)                 2307      
=================================================================
Total params: 3,639,459
Trainable params: 3,639,459
Non-trainable params: 0
_________________________________________________________________ 

摘要几乎包含了我们需要了解的所有关于模型的信息。它有四个卷积层 (Conv2D),每个卷积层后面都跟着一个最大池化层 (MaxPooling2D)。然后是一个 Flatten 层和一个全连接层 (Dense)。接着,在第二个 Dense 层之前还有更多的 Dropout。自然地,这个最终层有三个神经元,对应于每个类别。

评估 CNN 基础分类器

我们可以使用 evaluate_multiclass_mdl 函数和测试数据集来评估模型。参数包括模型 (base_model)、我们的测试数据 (X_test) 和相应的标签 (y_test),以及类名 (labels_l) 和编码器 (ohe)。最后,由于准确率很高,我们不需要绘制 ROC 曲线(plot_roc=False)。此函数返回预测标签和概率,我们可以将这些变量存储起来以供以后使用:

y_test_pred, y_test_prob = mldatasets.**evaluate_multiclass_mdl**(
    base_model,
    X_test,
    y_test,
    labels_l,
    ohe,
    plot_conf_matrix=True,
    predopts={"verbose":1}
) 

上述代码生成了 图 13.3,其中包含每个类别的混淆矩阵和性能指标:

图 13.3:在测试数据集上评估的基础分类器的混淆矩阵和预测性能指标

尽管图 13.3 中的混淆矩阵似乎表明分类完美,但请注意圈出的区域。一旦我们看到召回率(99.5%)的分解,我们就可以知道模型在错误地分类带口罩的面部图像时存在一些问题。

现在,我们可以开始攻击这个模型,以评估它的实际鲁棒性!

了解逃避攻击

有六种广泛的对抗攻击类别:

  • 规避攻击:设计一个输入,使其能够导致模型做出错误的预测,尤其是当它不会欺骗人类观察者时。它可以是定向的或非定向的,这取决于攻击者意图欺骗模型将特定类别(定向)或任何类别(非定向)误分类。攻击方法可以是白盒攻击,如果攻击者可以完全访问模型及其训练数据集,或者黑盒攻击,只有推理访问。灰盒攻击位于中间。黑盒攻击总是模型无关的,而白盒和灰盒方法可能不是。

  • 投毒攻击:将错误的训练数据或参数注入模型,其形式取决于攻击者的能力和访问权限。例如,对于用户生成数据的系统,攻击者可能能够添加错误的数据或标签。如果他们有更多的访问权限,也许他们可以修改大量数据。他们还可以调整学习算法、超参数或数据增强方案。像规避攻击一样,投毒攻击也可以是定向的或非定向的。

  • 推理攻击:通过模型推理提取训练数据集。推理攻击也以多种形式出现,可以通过成员推理进行间谍活动(隐私攻击),以确认一个示例(例如,一个特定的人)是否在训练数据集中。属性推理确定一个示例类别(例如,种族)是否在训练数据中表示。输入推理(也称为模型反演)有攻击方法可以从模型中提取训练数据集,而不是猜测和确认。这些具有广泛的隐私和监管影响,尤其是在医疗和法律应用中。

  • 特洛伊木马攻击:这会在推理期间通过触发器激活恶意功能,但需要重新训练模型。

  • 后门攻击:类似于特洛伊木马,但即使模型从头开始重新训练,后门仍然存在。

  • 重编程:在训练过程中通过悄悄引入专门设计以产生特定输出的示例来远程破坏模型。例如,如果你提供了足够多的标记为虎鲨的示例,其中四个小黑方块总是出现在相同的位置,模型就会学习到那是一个虎鲨,无论它是什么,从而故意迫使模型过度拟合。

前三种是最受研究的对抗攻击形式。一旦我们根据阶段和目标将它们分开,攻击可以进一步细分(见图 13.4)。阶段是指攻击实施时,因为它可以影响模型训练或其推理,而目标是攻击者希望从中获得什么。本章将仅处理规避破坏攻击,因为我们预计医院访客、患者和工作人员偶尔会破坏生产模型:

图片

图 13.4:按阶段和目标分类的对抗攻击方法表

尽管我们使用白盒方法来攻击、防御和评估模型的鲁棒性,但我们并不期望攻击者拥有这种级别的访问权限。我们只会使用白盒方法,因为我们完全访问了模型。在其他情况下,例如带有热成像系统和相应模型以检测犯罪者的银行监控系统,我们可能会预期专业攻击者使用黑盒方法来寻找漏洞!因此,作为该系统的防御者,我们明智的做法是尝试相同的攻击方法。

我们将用于对抗鲁棒性的库称为对抗鲁棒性工具箱ART),它由LF AI & 数据基金会支持——这些人还支持其他开源项目,如 AIX360 和 AIF360,这些项目在第十一章中进行了探讨,即偏差缓解和因果推断方法。ART 要求攻击模型被抽象为估计器或分类器,即使它是黑盒的。在本章的大部分内容中,我们将使用 KerasClassifier,但在最后一节中,我们将使用 TensorFlowV2Classifier。初始化 ART 分类器很简单。你必须指定 model,有时还有其他必需的属性。对于 KerasClassifier,所有剩余的属性都是可选的,但建议你使用 clip_values 来指定特征的取值范围。许多攻击是输入排列,因此了解允许或可行的输入值是什么至关重要:

base_classifier = **KerasClassifier**(
    model=base_model, clip_values=(min_, max_)
)
y_test_mdsample_prob = np.**max**(
    y_test_prob[sampl_md_idxs], axis=1
)
y_test_smsample_prob = np.**max**(
    y_test_prob[sampl_sm_idxs], axis=1
) 

在前面的代码中,我们还准备了两个数组,用于预测中等和较小样本的类别概率。这完全是可选的,但这些有助于在绘制一些示例时将预测概率放置在预测标签旁边。

快速梯度符号法攻击

最受欢迎的攻击方法之一是快速梯度符号法FSGMFGM)。正如其名所示,它利用深度学习模型的梯度来寻找对抗性示例。它对输入图像的像素进行小的扰动,无论是加法还是减法。使用哪种方法取决于梯度的符号,这表明根据像素的强度,哪个方向会增加或减少损失。

与所有 ART 攻击方法一样,你首先通过提供 ART 估计器或分类器来初始化它。FastGradientMethod 还需要一个攻击步长 eps,这将决定攻击强度。顺便提一下,eps 代表 epsilon (),它代表误差范围或无穷小近似误差。小的步长会导致像素强度变化不太明显,但它也会错误分类较少的示例。较大的步长会导致更多示例被错误分类,并且变化更明显:

attack_fgsm = **FastGradientMethod**(base_classifier, eps=0.1) 

初始化后,下一步是generate对抗示例。唯一必需的属性是原始示例(X_test_mdsample)。请注意,FSGM 可以是针对特定目标的,因此在初始化中有一个可选的targeted属性,但你还需要在生成时提供相应的标签。这种攻击是非针对特定目标的,因为攻击者的意图是破坏模型:

X_test_fgsm = attack_fgsm.**generate**(X_test_mdsample) 

与其他方法相比,使用 FSGM 生成对抗示例非常快,因此称之为“快速”!

现在,我们将一举两得。首先,使用evaluate_multiclass_mdl评估对抗示例(X_test_fgsm)对我们基础分类器模型(base_classifier.model)的模型。然后我们可以使用compare_image_predictions来绘制图像网格,对比随机选择的对抗示例(X_test_fgsm)与原始示例(X_test_mdsample)及其相应的预测标签(y_test_fgsm_predy_test_mdsample)和概率(y_test_fgsm_proby_test_mdsample_prob)。我们自定义标题并限制网格为四个示例(num_samples)。默认情况下,compare_image_predictions仅比较误分类,但可以通过将可选属性use_misclass设置为False来比较正确分类:

y_test_fgsm_pred, y_test_fgsm_prob =\
    mldatasets.**evaluate_multiclass_mdl**(\
        base_classifier.model, X_test_fgsm, y_test_mdsample,\
        labels_l, ohe, plot_conf_matrix=False, plot_roc=False
    )
y_test_fgsm_prob = np.**max**(y_test_fgsm_prob, axis=1)
mldatasets.**compare_image_predictions**(
    X_test_fgsm, X_test_mdsample, y_test_fgsm_pred,\
    y_test_mdsample.flatten(), y_test_fgsm_prob,\
    y_test_mdsample_prob, title_mod_prefix="Attacked:",\
    title_difference_prefix="FSGM Attack Average Perturbation:",\
    num_samples=4
) 

之前的代码首先输出一个表格,显示模型在 FSGM 攻击示例上的准确率仅为 44%!尽管这不是针对特定目标的攻击,但它对正确遮挡的面部效果最为显著。所以假设,如果肇事者能够造成这种程度的信号扭曲或干扰,他们将严重削弱公司监控口罩合规性的能力。

代码还输出了图 13.5,该图显示了由 FSGM 攻击引起的一些误分类。攻击在图像中几乎均匀地分布了噪声。它还显示图像仅通过均方误差 0.092 进行了修改,由于像素值介于 0 和 1 之间,这意味着 9.2%。如果你要校准攻击以使其更难检测但仍然具有影响力,你必须注意,eps为 0.1 会导致 9.2%的平均绝对扰动,这会将准确性降低到 44%:

一个人物的拼贴画  描述由低置信度自动生成

图 13.5:比较基础分类器 FSGM 攻击前后图像的图表

说到更难检测的攻击,我们现在将了解 Carlini 和 Wagner 攻击。

Carlini 和 Wagner 无穷范攻击

在 2017 年,Carlini 和 WagnerC&W)采用了三种基于范数的距离度量:img/B18406_13_002.pngimg/B18406_13_003.png,和img/B18406_13_004.png,测量原始样本与对抗样本之间的差异。在其他论文中,这些度量已经被讨论过,包括 FSGM。C&W 引入的创新是如何利用这些度量,使用基于梯度下降的优化算法来近似损失函数的最小值。具体来说,为了避免陷入局部最小值,他们在梯度下降中使用多个起始点。为了使过程“生成一个有效的图像”,它评估了三种方法来约束优化问题。在这种情况下,我们想要找到一个对抗样本,该样本与原始图像之间的距离是最小的,同时仍然保持现实性。

所有的三种 C&W 攻击(img/B18406_13_002.pngimg/B18406_13_003.png,和img/B18406_13_004.png)都使用 Adam 优化器快速收敛。它们的主要区别是距离度量,其中img/B18406_13_004.png可以说是最好的一个。它定义如下:

img/B18406_13_009.png

由于它是到任何坐标的最大距离,你确保对抗样本不仅在“平均”上最小化差异,而且在特征空间的任何地方都不会有太大差异。这就是使攻击更难以检测的原因!

使用 C&W 无穷范数攻击初始化和生成对抗样本与 FSGM 类似。要初始化CarliniLInfMethod,我们可以可选地定义一个batch_size(默认为128)。然后,为了generate一个非目标对抗攻击,与 FSGM 相同。在非目标攻击中只需要X,而在目标攻击中需要y

attack_cw = **CarliniLInfMethod**(
    base_classifier, batch_size=40
)
X_test_cw = attack_cw.**generate**(X_test_mdsample) 

我们现在将评估 C&W 对抗样本(X_test_cw),就像我们评估 FSGM 一样。代码完全相同,只是将fsgm替换为cw,并在compare_image_predictions中更改不同的标题。就像 FSGM 一样,以下代码将生成一个分类报告和图像网格(图 13.6):

y_test_cw_pred, y_test_cw_prob =\
    mldatasets.**evaluate_multiclass_mdl**(
        base_classifier.model, X_test_cw, y_test_mdsample, labels_l,\
        ohe, plot_conf_matrix=False, plot_roc=False
    )
y_test_cw_prob = np.**max**(y_test_cw_prob, axis=1)
mldatasets.**compare_image_predictions**(
    X_test_cw,\
    X_test_mdsample, y_test_cw_pred,\
    y_test_mdsample.flatten(), y_test_cw_prob,\
    y_test_mdsample_prob, title_mod_prefix="Attacked:",\
    title_difference_prefix="C&W Inf Attack Average Perturbation",\
    num_samples=4
) 

如前述代码输出,C&W 对抗样本在我们的基础模型中具有 92%的准确率。这种下降足以使模型对其预期用途变得无用。如果攻击者仅对摄像机的信号进行足够的干扰,他们就能达到相同的结果。而且,正如你从图 13.6中可以看出,与 FSGM 相比,0.3%的扰动非常小,但它足以将 8%的分类错误,包括网格中看起来明显的四个分类错误。

一个孩子的拼贴,描述由低置信度自动生成

图 13.6:比较基础分类器中 C&W 无穷范数攻击与原始图像的绘图

有时候,攻击是否被检测到并不重要。重点是做出声明,这正是对抗补丁所能做到的。

目标对抗补丁攻击

对抗性补丁APs)是一种鲁棒、通用且具有针对性的方法。你可以生成一个补丁,既可以叠加到图像上,也可以打印出来并物理放置在场景中以欺骗分类器忽略场景中的其他所有内容。它旨在在各种条件和变换下工作。与其他对抗性示例生成方法不同,没有意图隐藏攻击,因为本质上,你用补丁替换了场景中可检测的部分。该方法通过利用期望变换EOT)的变体来工作,该变体在图像的不同位置对给定补丁的变换上进行图像训练。它所学习的是在训练示例中欺骗分类器最多的补丁。

这种方法比 FSGM 和 C&W 需要更多的参数和步骤。首先,我们将使用AdversarialPatchNumpy,这是可以与任何神经网络图像或视频分类器一起工作的变体。还有一个适用于 TensorFlow v2 的版本,但我们的基础分类器是KerasClassifier。第一个参数是分类器(base_classifier),我们将定义的其他参数是可选的,但强烈推荐。缩放范围scale_minscale_max尤其重要,因为它们定义了补丁相对于图像的大小可以有多大——在这种情况下,我们想测试的最小值不小于 40%,最大值不大于 70%。除此之外,定义一个目标类别(target)也是有意义的。在这种情况下,我们希望补丁针对“正确”类别。对于learning_rate和最大迭代次数(max_iter),我们使用默认值,但请注意,这些可以调整以提高补丁对抗的有效性:

attack_ap = **AdversarialPatchNumpy**(
    base_classifier, scale_min=0.4, scale_max=0.7,\
    learning_rate=5., max_iter=500,\
    batch_size=40, target=0
) 

我们不希望补丁生成算法在图像的每个地方都浪费时间测试补丁,因此我们可以通过使用布尔掩码来指导这种努力。这个掩码告诉它可以在哪里定位补丁。为了制作这个掩码,我们首先创建一个 128 × 128 的零数组。然后我们在像素 80–93 和 45–84 之间的矩形区域内放置 1,这大致对应于覆盖大多数图像中嘴巴的中心区域。最后,我们扩展数组的维度,使其变为(1, W, H),并将其转换为布尔值。然后我们可以使用小尺寸测试数据集样本和掩码来继续generate补丁:

placement_mask = np.**zeros**((128,128))
placement_mask[**80****:****93****,****45****:****83**] = 1
placement_mask = np.**expand_dims**(placement_mask, axis=0).astype(bool)
patch, patch_mask = attack_ap.**generate**(
    x=X_test_smsample,
    y=ohe.transform(y_test_smsample),
    mask=placement_mask
) 

我们现在可以使用以下代码片段绘制补丁:

plt.**imshow**(patch * patch_mask) 

上述代码生成了图 13.7中的图像。正如预期的那样,它包含了掩码中发现的许多蓝色阴影。它还包含明亮的红色和黄色色调,这些色调在训练示例中大多缺失,这会混淆分类器:

图表描述自动生成,置信度低

图 13.7:AP 生成的图像,误分类为正确掩码

与其他方法不同,generate没有生成对抗样本,而是一个单独的补丁,这是一个我们可以放置在图像上以创建对抗样本的图像。这个任务是通过apply_patch完成的,它接受原始示例X_test_smsample和一个比例;我们将使用 55%。还建议使用一个mask,以确保补丁被应用到更有意义的地方——在这种情况下,是嘴巴周围的区域:

X_test_ap = attack_ap.**apply_patch**(
    X_test_smsample,
    scale=0.55,
    mask=placement_mask
) 

现在是时候评估我们的攻击并检查一些误分类了。我们将像以前一样做,并重用生成图 13.5图 13.7的代码,只是我们将变量替换为ap和相应的标题:

y_test_ap_pred, y_test_ap_prob =\
    mldatasets.evaluate_multiclass_mdl(
        base_classifier.model, X_test_ap, y_test_smsample,
        labels_l, ohe, plot_conf_matrix=False, plot_roc=False
    )
y_test_ap_prob = np.max(y_test_ap_prob, axis=1)
mldatasets.compare_image_predictions(
    X_test_ap, X_test_smsample, y_test_ap_pred,\
    y_test_smsample.flatten(), y_test_ap_prob,
    y_test_smsample_prob, title_mod_prefix="Attacked:",\
    title_difference_prefix="AP Attack Average Perturbation:", num_samples=4
) 

前面的代码给出了我们攻击的准确率结果为 65%,考虑到它训练的样本数量很少,这个结果相当不错。与其它方法相比,AP 需要更多的数据。一般来说,定向攻击需要更多的样本来理解如何最好地针对某一类。前面的代码还生成了图 13.8中的图像网格,展示了假设人们如果在前额前拿着一张硬纸板,他们可以轻易地欺骗模型:

包含人物、姿势、不同、相同 描述自动生成

图 13.8:比较 AP 攻击与原始图像的基础分类器的图表

到目前为止,我们已经研究了三种攻击方法,但还没有解决如何防御这些攻击的问题。接下来,我们将探讨一些解决方案。

使用预处理防御定向攻击

有五种广泛的对抗防御类别:

  • 预处理:改变模型的输入,使其更难以攻击。

  • 训练:训练一个新的健壮模型,该模型旨在克服攻击。

  • 检测:检测攻击。例如,你可以训练一个模型来检测对抗样本。

  • Transformer:修改模型架构和训练,使其更健壮——这可能包括蒸馏、输入过滤器、神经元剪枝和重新学习等技术。

  • 后处理:改变模型输出以克服生产推理或模型提取攻击。

只有前四种防御可以与规避攻击一起工作,在本章中,我们只涵盖前两种:预处理对抗训练。FGSM 和 C&W 可以用这两种方法中的任何一种来防御,但 AP 更难防御,可能需要更强的检测Transformer方法。

在我们进行防御之前,我们必须先发起有针对性的攻击。我们将使用投影梯度下降法PGD),这是一种非常强大的攻击方法,其输出与 FSGM 非常相似——也就是说,它会产生噪声图像。在这里我们不会详细解释 PGD,但重要的是要注意,就像 FSGM 一样,它被视为一阶对抗者,因为它利用了关于网络的一阶信息(由于梯度下降)。此外,实验证明,对 PGD 的鲁棒性确保了对任何一阶对抗者的鲁棒性。具体来说,PGD 是一种强大的攻击,因此它为结论性的基准提供了依据。

要对正确掩码的类别发起有针对性的攻击,最好只选择那些没有被正确掩码的示例(x_test_notmasked)、它们对应的标签(y_test_notmasked)和预测概率(y_test_notmasked_prob)。然后,我们想要创建一个包含我们想要生成对抗性示例的类别(Correct)的数组(y_test_masked):

**not_masked_idxs** = np.**where**(y_test_smsample != 'Correct')[0]
X_test_notmasked = X_test_smsample[**not_masked_idxs**]
y_test_notmasked = y_test_smsample[**not_masked_idxs**]
y_test_notmasked_prob = y_test_smsample_prob[**not_masked_idxs**]
y_test_masked = np.array(
    ['Correct'] * X_test_notmasked.shape[0]
).reshape(-1,1) 

我们将ProjectedGradientDescent初始化与 FSGM 相同,除了我们将设置最大扰动(eps)、攻击步长(eps_step)、最大迭代次数(max_iter)和targeted=True。正是因为它是针对性的,所以我们将同时设置Xy

attack_pgd = **ProjectedGradientDescent**(
    base_classifier, eps=0.3, eps_step=0.01,\
    max_iter=40, targeted=True
)
X_test_pgd = attack_pgd.**generate**(
    X_test_notmasked, y=ohe.transform(y_test_masked)
) 

现在,让我们像之前一样评估 PGD 攻击,但这次,让我们绘制混淆矩阵(plot_conf_matrix=True):

y_test_pgd_pred, y_test_pgd_prob =\
    mldatasets.**evaluate_multiclass_mdl**(
        base_classifier.model, X_test_pgd, y_test_notmasked,\
        labels_l, ohe, plot_conf_matrix=True, plot_roc=False
    )
y_test_pgd_prob = np.**max**(y_test_pgd_prob, axis=1) 
Figure 13.9. The PGD attack was so effective that it produced an accuracy of 0%, making all unmasked and incorrectly masked examples appear to be masked:

图表描述自动生成

图 13.9:针对基础分类器评估的 PGD 攻击示例的混淆矩阵

接下来,让我们运行compare_image_prediction来查看一些随机误分类:

mldatasets.**compare_image_predictions**(
    X_test_pgd, X_test_notmasked, y_test_pgd_pred,\
    y_test_notmasked.flatten(), y_test_pgd_prob,\
    y_test_smsample_prob, title_mod_prefix="Attacked:",\
    num_samples=4, title_difference_prefix="PGD Attack Average Perturbation:"
) 

上述代码在图 13.10中绘制了图像网格。平均绝对扰动是我们迄今为止看到的最高值,达到 14.7%,并且网格中所有未掩码的面部都被分类为正确掩码:

一个人拼贴,描述自动生成,中等置信度

图 13.10:比较基础分类器的 PGD 攻击图像与原始图像的绘图

准确率不能变得更差,图像的颗粒度已经无法修复。那么我们如何对抗噪声呢?如果你还记得,我们之前已经处理过这个问题了。在第七章可视化卷积神经网络中,SmoothGrad通过平均梯度改进了显著性图。这是一个不同的应用,但原理相同——就像人类一样,噪声显著性图比平滑的显著性图更难以解释,而颗粒图像比平滑图像对模型来说更难以解释。

空间平滑只是说模糊的一种花哨说法!然而,它作为对抗防御方法引入的新颖之处在于,所提出的实现(SpatialSmoothing)要求在滑动窗口中使用中位数而不是平均值。window_size是可配置的,建议在最有用的防御位置进行调整。一旦防御初始化,你就可以插入对抗示例(X_test_pgd)。它将输出空间平滑的对抗示例(X_test_pgd_ss):

defence_ss = **SpatialSmoothing**(window_size=11)
X_test_pgd_ss, _ = **defence_ss**(X_test_pgd) 

现在,我们可以将产生的模糊对抗示例评估如前所述——首先,使用evaluate_multiclass_mdl获取预测标签(y_test_pgd_ss_pred)和概率(y_test_pgd_ss_prob),以及一些预测性能指标输出。使用compare_image_predictions绘制图像网格,让我们使用use_misclass=False来正确分类图像——换句话说,就是成功防御的对抗示例:

y_test_pgd_ss_pred, y_test_pgd_ss_prob =\
    mldatasets.**evaluate_multiclass_mdl**(
        base_classifier.model, X_test_pgd_ss,\
        y_test_notmasked, labels_l, ohe,\
        plot_conf_matrix=False, plot_roc=False
)
y_test_pgd_ss_prob = np.**max**(y_test_pgd_ss_prob, axis=1)
mldatasets.**compare_image_predictions**(
    X_test_pgd_ss, X_test_notmasked, y_test_pgd_ss_pred,\
    y_test_notmasked.flatten(), y_test_pgd_ss_prob,\
    y_test_notmasked_prob, use_misclass=False,\
    title_mod_prefix="Attacked+Defended:", num_samples=4,\
    title_difference_prefix="PGD Attack & Defended Average:"
) 

上述代码得到 54%的准确率,这比空间平滑防御之前的 0%要好得多。它还生成了图 13.11,展示了模糊如何有效地阻止 PGD 攻击。它甚至将平均绝对扰动减半!

一群人的拼贴,描述自动生成,置信度低

图 13.11:比较空间平滑 PGD 攻击图像与基础分类器原始图像的图表

接下来,我们将在我们的工具箱中尝试另一种防御方法:对抗训练!

通过对鲁棒分类器进行对抗训练来抵御任何逃避攻击

第七章可视化卷积神经网络中,我们确定了一个垃圾图像分类器,它很可能在市政回收厂预期的环境中表现不佳。在样本外数据上的糟糕表现是由于分类器是在大量公开可用的图像上训练的,这些图像与预期的条件不匹配,或者与回收厂处理的材料的特征不匹配。章节的结论呼吁使用代表其预期环境的图像来训练网络,以创建一个更鲁棒的模型。

为了模型的鲁棒性,训练数据的多样性至关重要,但前提是它能够代表预期的环境。在统计学的术语中,这是一个关于使用样本进行训练的问题,这些样本能够准确描述总体,从而使模型学会正确地分类它们。对于对抗鲁棒性,同样的原则适用。如果你增强数据以包括可能的对抗攻击示例,模型将学会对它们进行分类。这就是对抗训练的本质。

对抗鲁棒性领域的机器学习研究人员建议这种防御形式对任何类型的规避攻击都非常有效,本质上可以保护它。但话虽如此,它并非坚不可摧。其有效性取决于在训练中使用正确类型的对抗样本、最优的超参数等等。研究人员概述了一些指导方针,例如增加隐藏层中的神经元数量,并使用 PGD 或 BIM 生成训练对抗样本。BIM代表基本迭代方法。它类似于 FSGM,但速度不快,因为它通过迭代来逼近原始图像在-邻域内的最佳对抗样本。eps属性限制了这一邻域。

训练一个鲁棒模型可能非常耗费资源。虽然我们也可以下载一个已经训练好的,但这很重要,要理解如何使用 ART 来完成这一过程。我们将解释这些步骤,以便有选择地使用 ART 完成模型训练。否则,只需跳过这些步骤并下载训练好的模型。robust_modelbase_model非常相似,除了我们在四个卷积(Conv2D)层中使用等大小的过滤器。我们这样做是为了降低复杂性,以抵消我们通过将第一隐藏(Dense)层中的神经元数量翻倍所增加的复杂性,正如机器学习研究人员所建议的:

robust_model = tf.keras.models.**Sequential**([
    tf.keras.layers.**InputLayer**(input_shape=X_train.shape[1:]),
    tf.keras.layers.**Conv2D**(32, kernel_size=(3, 3), activation='relu'),
    tf.keras.layers.**MaxPooling2D**(pool_size=(2, 2)),
    tf.keras.layers.**Conv2D**(32, kernel_size=(3, 3), activation='relu'),
    tf.keras.layers.**MaxPooling2D**(pool_size=(2, 2)),
    tf.keras.layers.**Conv2D**(32, kernel_size=(3, 3), activation='relu'),
    tf.keras.layers.**MaxPooling2D**(pool_size=(2, 2)),
    tf.keras.layers.**Conv2D**(32, kernel_size=(3, 3), activation='relu'),
    tf.keras.layers.**MaxPooling2D**(pool_size=(2, 2)),
    tf.keras.layers.**Flatten**(),
    tf.keras.layers.**Dense**(3072, activation='relu'),
    tf.keras.layers.**Dropout**(0.2),
    tf.keras.layers.**Dense**(3, activation='softmax')
], name='CNN_Robust_MaskedFaceNet_Model')
robust_model.**compile**(
    optimizer=tf.keras.optimizers.Adam(lr=0.001),
    loss='categorical_crossentropy',
    metrics=['accuracy'])
robust_model.**summary**() 

前面代码中的summary()输出了以下内容。你可以看到可训练参数总数约为 360 万,与基础模型相似:

Model: "CNN_Robust_MaskedFaceNet_Model"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_1 (Conv2D)            (None, 126, 126, 32)      896       
_________________________________________________________________
maxpool2d_1 (MaxPooling2D)   (None, 63, 63, 32)        0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 61, 61, 32)        9248      
_________________________________________________________________
maxpool2d_2 (MaxPooling2D)   (None, 30, 30, 32)        0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 28, 28, 32)        9248      
_________________________________________________________________
maxpool2d_3 (MaxPooling2D)   (None, 14, 14, 32)        0         
_________________________________________________________________
conv2d_4 (Conv2D)            (None, 12, 12, 32)        9248      
_________________________________________________________________
maxpool2d_4 (MaxPooling2D)   (None, 6, 6, 32)          0         
_________________________________________________________________
flatten (Flatten)            (None, 1152)              0         
_________________________________________________________________
dense_1 (Dense)              (None, 3072)              3542016   
_________________________________________________________________
dropout (Dropout)            (None, 3072)              0         
_________________________________________________________________
dense_2 (Dense)              (None, 3)                 9219      
=================================================================
Total params: 3,579,875
Trainable params: 3,579,875
Non-trainable params: 0
_________________________________________________________________ 

接下来,我们可以通过首先使用robust_model初始化一个新的KerasClassifier来对抗性地训练模型。然后,我们初始化这个分类器上的BasicIterativeMethod攻击。最后,我们使用robust_classifier和 BIM 攻击初始化AdversarialTrainer并对其进行fit。请注意,我们将 BIM 攻击保存到了一个名为attacks的变量中,因为这个变量可能是一系列 ART 攻击而不是单一的一个。另外,请注意AdversarialTrainer有一个名为ratio的属性。这个属性决定了训练样本中有多少是对抗样本。这个百分比会极大地影响对抗攻击的有效性。如果它太低,可能无法很好地处理对抗样本,如果太高,可能在与非对抗样本的交互中效果较差。如果我们运行trainer,它可能需要很多小时才能完成,所以请不要感到惊讶:

robust_classifier = **KerasClassifier**(
    model=robust_model, clip_values=(min_, max_)
)
attacks = **BasicIterativeMethod**(
    robust_classifier, eps=0.3, eps_step=0.01, max_iter=20
)
trainer = **AdversarialTrainer**(
    robust_classifier, attacks, ratio=0.5
)
trainer.fit(
    X_train, ohe.transform(y_train), nb_epochs=30, batch_size=128
) 

如果你没有训练robust_classifier,你可以下载一个预训练的robust_model,并像这样用它来初始化robust_classifier

model_path = **get_file**(
    'CNN_Robust_MaskedFace_Net.hdf5',
    'https://github.com/PacktPublishing/Interpretable-Machine- \
    Learning-with-Python/blob/master/models/ \
    CNN_Robust_MaskedFace_Net.hdf5?raw=true'
)
robust_model = tf.keras.models.**load_model**(model_path)
robust_classifier = **KerasClassifier**(
    model=robust_model, clip_values=(min_, max_)
) 

现在,让我们使用evaluate_multiclass_mdl来评估robust_classifier对原始测试数据集的性能。我们将plot_conf_matrix设置为True以查看混淆矩阵:

y_test_robust_pred, y_test_robust_prob =\
mldatasets.**evaluate_multiclass_mdl**(
    robust_classifier.model, X_test, y_test, labels_l, ohe,\
    plot_conf_matrix=True, predopts={"verbose":1}
) 

上述代码输出了图 13.12中的混淆矩阵和性能指标。它的准确率比基础分类器低 1.8%。大多数错误分类都是将正确遮挡的面部错误地分类为错误遮挡。在选择 50%的对抗示例比例时,肯定存在权衡,或者我们可以调整超参数或模型架构来改进这一点:

图表,树状图图表  自动生成的描述

图 13.12:鲁棒分类器的混淆度指标和性能指标

让我们看看鲁棒模型在对抗攻击中的表现。我们再次使用FastGradientMethod,但这次,将base_classifier替换为robust_classifier

attack_fgsm_robust = **FastGradientMethod**(
    robust_classifier, eps=0.1
)
X_test_fgsm_robust = attack_fgsm_robust.**generate**(X_test_mdsample) 

接下来,我们可以使用evaluate_multiclass_mdlcompare_image_predictions来衡量和观察我们攻击的有效性,但这次针对的是robust_classifier

y_test_fgsm_robust_pred, y_test_fgsm_robust_prob =\
    mldatasets.**evaluate_multiclass_mdl**(
        robust_classifier.model, X_test_fgsm_robust,\
        y_test_mdsample, labels_l, ohe,\
        plot_conf_matrix=False, plot_roc=False
    )
y_test_fgsm_robust_prob = np.**max**(
    y_test_fgsm_robust_prob, axis=1
)
mldatasets.**compare_image_predictions**(
    X_test_fgsm_robust, X_test_mdsample,
    y_test_fgsm_robust_pred, num_samples=4,\
    y_test_mdsample.flatten(), y_test_fgsm_robust_prob,\
    y_test_mdsample_prob, title_mod_prefix="Attacked:",\
    title_difference_prefix="FSGM Attack Average Perturbation:"
) 
base_classifier, it yielded a 44% accuracy. That was quite an improvement! The preceding code also produces the image grid in *Figure 13.13*. You can tell how the FSGM attack against the robust model makes less grainy and more patchy images. On average, they are less perturbed than they were against the base model because so few of them were successful, but those that were significantly degraded. It appears as if the FSGM reduced their color depth from millions of possible colors (24+ bits) to 256 (8-bit) or 16 (4-bit) colors. Of course, an evasion attack can’t actually do that, but what happened was that the FSGM algorithm converged at the same shades of blue, brown, red, and orange that could fool the classifier! Other shades remain unaltered: 

婴儿拼贴  使用低置信度自动生成的描述

图 13.13:比较鲁棒分类器被 FSGM 攻击与原始图像的图表

到目前为止,我们只评估了模型的鲁棒性,但只针对一种攻击强度,没有考虑可能的防御措施,因此评估了其鲁棒性。在下一节中,我们将研究一种实现这一目标的方法。

评估对抗鲁棒性

在任何工程实践中测试你的系统以了解它们对攻击或意外故障的脆弱性是必要的。然而,安全是一个你必须对你的解决方案进行压力测试的领域,以确定需要多少级别的攻击才能使你的系统崩溃超过可接受的阈值。此外,弄清楚需要多少级别的防御来遏制攻击也是非常有用的信息。

比较模型鲁棒性与攻击强度

现在我们有两个分类器可以与同等强度的攻击进行比较,我们尝试不同的攻击强度,看看它们在所有这些攻击中的表现如何。我们将使用 FSGM,因为它速度快,但你可以使用任何方法!

我们可以评估的第一个攻击强度是没有攻击强度。换句话说,没有攻击的情况下,对测试数据集的分类准确率是多少?我们已经有存储了基础模型(y_test_pred)和鲁棒模型(y_test_robust_pred)的预测标签,所以这可以通过 scikit-learn 的accuracy_score指标轻松获得:

accuracy_base_0 = metrics.accuracy_score(
    y_test, y_test_pred
)
accuracy_robust_0 = metrics.accuracy_score(
    y_test, y_test_robust_pred
) 

现在,我们可以在 0.01 和 0.9 之间迭代一系列攻击强度(eps_range)。使用linspace,我们可以生成 0.01 和 0.09 之间的 9 个值和 0.1 和 0.9 之间的 9 个值,并将它们concatenate成一个单一的数组。我们将通过for循环测试这 18 个eps值的所有攻击,攻击每个模型,并使用evaluate检索攻击后的准确度。相应的准确度被附加到两个列表(accuracy_baseaccuracy_robust)中。在for循环之后,我们将 0 添加到eps_range中,以考虑任何攻击之前的准确度:

eps_range = np.**concatenate**(
    (np.linspace(0.01, 0.09, 9), np.linspace(0.1, 0.9, 9)), axis=0
).tolist()
accuracy_base = [accuracy_base_0]
accuracy_robust = [accuracy_robust_0]
for **eps** in tqdm(eps_range, desc='EPS'):
    attack_fgsm.set_params(**{'eps': **eps**})
    X_test_fgsm_base_i =attack_fgsm.**generate**(X_test_mdsample)
    _, accuracy_base_i =\
    base_classifier.model.**evaluate**(
        X_test_fgsm_base_i, ohe.transform(y_test_mdsample)
    )
    attack_fgsm_robust.set_params(**{'eps': **eps**})
    X_test_fgsm_robust_i=attack_fgsm_robust.**generate**(
        X_test_mdsample
    )
    _, accuracy_robust_i =\
        robust_classifier.model.**evaluate**(
            X_test_fgsm_robust_i, ohe.transform(y_test_mdsample)
            )
    accuracy_base.append(accuracy_base_i)
    accuracy_robust.append(accuracy_robust_i) 
eps_range = [0] + eps_range 

现在,我们可以使用以下代码绘制两个分类器在所有攻击强度下的准确度图:

fig, ax = plt.subplots(figsize=(14,7))
ax.plot(
    np.array(eps_range), np.array(accuracy_base),\
    'b–', label='Base classifier'
)
ax.plot(
    np.array(eps_range), np.array(accuracy_robust),\
    'r–', label='Robust classifier'
)
legend = ax.legend(loc='upper center')
plt.xlabel('Attack strength (eps)')
plt.ylabel('Accuracy') 

之前的代码生成了图 13.14,该图展示了鲁棒模型在攻击强度为 0.02 和 0.3 之间表现更好,但之后则始终比基准模型差大约 10%:

图表,折线图  自动生成描述

图 13.14:在多种 FSGM 攻击强度下对鲁棒和基准分类器的准确度进行测量

图 13.14未能考虑的是防御措施。例如,如果医院摄像头持续受到干扰或篡改,安全公司不保护他们的模型将是失职的。对于这种攻击,最简单的方法是使用某种平滑技术。

对抗性训练也产生了一个经验上鲁棒的分类器,你不能保证它在某些预定义的情况下会工作,这就是为什么需要可验证的防御措施。

任务完成

任务是执行他们面部口罩模型的一些对抗性鲁棒性测试,以确定医院访客和员工是否可以规避强制佩戴口罩的规定。基准模型在许多规避攻击中表现非常糟糕,从最激进的到最微妙的。

你还研究了这些攻击的可能防御措施,例如空间平滑和对抗性重新训练。然后,你探索了评估你提出的防御措施鲁棒性的方法。你现在可以提供一个端到端框架来防御这种攻击。话虽如此,你所做的一切只是一个概念验证。

现在,你可以提出训练一个可验证的鲁棒模型来对抗医院预期遇到的最常见的攻击。但首先,你需要一个一般鲁棒模型的成分。为此,你需要使用原始数据集中的所有 210,000 张图片,对它们进行许多关于遮罩颜色和类型的变体,并使用合理的亮度、剪切和旋转变换进一步增强。最后,鲁棒模型需要用几种攻击进行训练,包括几种 AP 攻击。这些攻击很重要,因为它们模仿了最常见的合规规避行为,即用身体部位或衣物物品隐藏面部。

摘要

阅读本章后,您应该了解如何对机器学习模型进行攻击,特别是逃避攻击。您应该知道如何执行 FSGM、BIM、PGD、C&W 和 AP 攻击,以及如何通过空间平滑和对抗性训练来防御它们。最后但同样重要的是,您应该了解如何评估对抗性鲁棒性。

下一章是最后一章,它概述了关于机器学习解释未来发展的想法。

数据集来源

  • Adnane Cabani,Karim Hammoudi,Halim Benhabiles 和 Mahmoud Melkemi,2020,MaskedFace-Net - 在 COVID-19 背景下正确/错误佩戴口罩的人脸图像数据集,Smart Health,ISSN 2352–6483,Elsevier:doi.org/10.1016/j.smhl.2020.100144(由 NVIDIA 公司提供的 Creative Commons BY-NC-SA 4.0 许可证)

  • Karras, T.,Laine, S.,和 Aila, T.,2019,用于生成对抗网络的基于风格的生成器架构。2019 IEEE/CVF 计算机视觉与模式识别会议(CVPR),4396–4405:arxiv.org/abs/1812.04948(由 NVIDIA 公司提供的 Creative Commons BY-NC-SA 4.0 许可证)

进一步阅读

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

二维码

第十四章:机器学习可解释性的未来是什么?

在过去的十三章中,我们探讨了机器学习(ML)可解释性的领域。正如前言所述,这是一个广泛的研究领域,其中大部分甚至还没有离开实验室,尚未得到广泛应用,本书无意涵盖所有内容。相反,目标是深入介绍各种可解释性工具,以便作为初学者的起点,甚至补充更高级读者的知识。本章将总结我们在机器学习可解释性方法生态系统中的所学,并推测接下来会发生什么!

这些是我们将在本章中讨论的主要主题:

  • 理解机器学习可解释性的当前格局

  • 对机器学习可解释性未来的推测

理解机器学习可解释性的当前格局

首先,我们将提供一些关于本书如何与机器学习可解释性的主要目标相关联以及从业者如何开始应用这些方法来实现这些广泛目标的背景信息。然后,我们将讨论研究中的当前增长领域。

将一切联系在一起!

第一章中所述,解释、可解释性和可解释性;以及为什么这一切都很重要?,在讨论机器学习可解释性时,有三个主要主题:公平性、责任和透明度(FAT),每个主题都提出了一系列关注点(见图 14.1)。我想我们都可以同意,这些都是模型所期望的特性!确实,这些关注点都为人工智能(AI)系统的改进提供了机会。这些改进首先通过利用模型解释方法来评估模型、确认或反驳假设以及发现问题开始。

你的目标将取决于你在机器学习(ML)工作流程中的哪个阶段。如果模型已经投入生产,目标可能是用一系列指标来评估它,但如果模型仍处于早期开发阶段,目标可能是找到指标无法发现的更深层问题。也许你也在像我们在第四章中做的那样,仅仅使用黑盒模型进行知识发现——换句话说,利用模型从数据中学习,但没有计划将其投入生产。如果是这种情况,你可能会确认或反驳你对数据的假设,以及由此产生的模型:

图 14.1:机器学习解释方法

无论如何,这些目标都不是相互排斥的,你很可能始终在寻找问题并反驳假设,即使模型看起来表现良好!

并且无论目标是什么,主要关注点是什么,都建议您使用许多解释方法,这不仅是因为没有哪种技术是完美的,而且还因为所有问题和目标都是相互关联的。换句话说,没有一致性就没有正义,没有透明性就没有可靠性。

实际上,你可以从下到上阅读图 14.1,就像它是一座金字塔一样,因为透明性是基础,其次是第二层的问责制,最终,公平性是顶部的樱桃。

因此,即使目标是评估模型公平性,模型也应进行压力测试以确保其鲁棒性。应理解大多数相关特征重要性和交互作用。否则,如果预测不鲁棒且不透明,那就无关紧要了。

图 14.1中涵盖了多种解释方法,这些方法绝不是所有可用的解释方法。它们代表了背后有良好维护的开源库的最受欢迎的方法。在本书中,我们简要地提到了它们中的大多数,尽管其中一些只是简要提及。未讨论的用斜体表示,而讨论过的旁边提供了相关的章节编号。本书重点介绍了模型无关黑盒监督学习模型方法。然而,在这个领域之外,还有许多其他解释方法,例如强化学习、生成模型或仅用于线性回归的许多统计方法。即使在监督学习黑盒模型领域内,也有数百种针对特定应用的应用特定模型解释方法,这些应用范围从化学图 CNN 到客户流失分类器。

话虽如此,本书中讨论的许多方法可以定制用于各种应用。集成梯度可用于解释音频分类器和天气预报模型。敏感性分析可用于金融建模和传染病风险模型。因果推断方法可以用来改善用户体验和药物试验。

改进是这里的关键词,因为解释方法有另一面!

在本书中,这一面被称为为解释性调整,这意味着为 FAT 问题创造解决方案。这些解决方案可以在图 14.2中欣赏到:

图片

图 14.2:处理 FAT 问题的工具集

我观察到五种解释性解决方案的方法:

  • 缓解偏差:任何为考虑偏差而采取的纠正措施。请注意,这里的偏差指的是数据中的采样、排除、偏见和测量偏差,以及任何引入到机器学习工作流程中的其他偏差。

  • 设置护栏:任何确保模型受到约束,使其不与领域知识相矛盾且不自信地预测的解决方案。

  • 增强可靠性:任何增加预测信心和一致性的修复,不包括通过减少复杂性来实现的修复。

  • 减少复杂性:任何引入稀疏性的方法。作为一种副作用,这通常通过更好地泛化来增强可靠性。

  • 确保隐私:任何努力确保第三方无法获取私有数据和模型架构。我们在这本书中没有涵盖这种方法。

这些方法还可以应用于以下三个领域:

  • 数据(“预处理”):通过修改训练数据

  • 模型(“处理中”):通过修改模型、其参数或训练过程

  • 预测(“后处理”):通过干预模型的推理

有一个第四个领域可能会影响其他三个领域——即数据和算法治理。这包括规定某些方法或框架的法规和标准。这是一个缺失的列,因为很少有行业和司法管辖区有法律规定应该应用哪些方法和方法来遵守 FAT。例如,治理可以强制执行解释算法决策、数据来源或鲁棒性认证阈值的标准。我们将在下一节中进一步讨论这一点。

你可以从图 14.2中看出,许多方法在 FAT 上重复使用。特征选择和工程、单调约束正则化对三者都有益,但并不总是通过相同的方法来实现。数据增强也可以提高公平性和问责制的可靠性。与图 14.1一样,斜体中的内容在书中没有涉及,其中三个主题脱颖而出:不确定性估计对抗鲁棒性隐私保护是迷人的主题,值得有自己的一本书。

当前趋势

AI 采用的最重要的障碍之一是缺乏可解释性,这也是 50-90%的 AI 项目从未起飞的部分原因(关于这一点,请参阅进一步阅读部分的相关文章),另一个原因是由于不遵守 FAT 而产生的道德违规行为。在这方面,可解释机器学习iML)有力量引领整个 ML,因为它可以帮助实现这两个目标,如图 14.1 和图 14.2 中的相应方法。

幸运的是,我们正在见证对 iML 的兴趣和生产力的增加,这主要是在可解释人工智能XAI)的背景下——参见图 14.3。在科学界,iML 仍然是最受欢迎的术语,但在公共场合 XAI 占主导地位:

图形用户界面,应用程序描述自动生成

图 14.3:iML 和 XAI 的出版和搜索趋势

这意味着,正如机器学习开始标准化、监管、整合到众多其他学科一样,解释性也将很快获得一席之地。

机器学习(ML)正在取代所有行业的软件。随着越来越多的自动化,更多的模型被部署到云端,而人工智能物联网AIoT)的出现将使情况变得更糟。部署并不是传统上属于机器学习实践者的领域。这就是为什么机器学习越来越多地依赖于机器学习运维MLOps)。自动化速度的加快意味着需要更多的工具来构建、测试、部署和监控这些模型。同时,还需要对工具、方法和指标进行标准化。虽然这个过程缓慢但确实在发生。自 2017 年以来,我们已经有了开放神经网络交换ONNX),这是一个用于互操作性的开放标准。在撰写本文时,国际标准化组织ISO)正在编写超过二十项 AI 标准(其中一项已发布),其中一些涉及可解释性。自然地,由于机器学习模型类别、方法、库、服务提供商和实践的整合,一些事物将因常用而标准化。随着时间的推移,每个领域都将出现一或几个。最后,鉴于机器学习在算法决策中的重要作用,它被监管只是时间问题。只有一些金融市场监管交易算法,例如美国的证券交易委员会SEC)和英国的金融服务管理局FCA)。除此之外,只有数据隐私和来源法规得到广泛执行,例如美国的 HIPAA 和巴西的 LGPD。欧盟的通用数据保护条例GDPR)在算法决策的“解释权”方面更进一步,但预期的范围和方法仍然不明确。

可解释人工智能(XAI)与机器学习(IML)——哪一个该使用?

我的看法:尽管在行业中它们被视为同义词,且iML被视为更学术性的术语,但机器学习实践者,即使在工业界,也应该对使用iML这个术语持谨慎态度。词语可能具有过大的暗示力。可解释性意味着完全理解,但可解释性留有出错的空间,这在讨论模型时总是应该如此,尤其是对于极其复杂的黑盒模型。此外,人工智能被公众想象为万能的灵丹妙药,或者被诋毁为危险的。无论哪种情况,与iML这个术语一样,它都使得那些认为它是万能灵丹妙药的人更加自大,也许可以平息那些认为它是危险的人的担忧。XAI 这个术语可能作为一个营销术语在发挥作用。然而,对于构建模型的人来说,可解释性这个词语的暗示力可能会让我们对自己的解释过于自信。话虽如此,这仅仅是一个观点。

机器学习可解释性正在快速发展,但落后于机器学习。一些解释工具已经集成到云生态系统中,从 SageMaker 到 DataRobot。它们尚未完全自动化、标准化、整合和监管,但毫无疑问,这将会发生。

对机器学习可解释性的未来进行推测

我习惯了听到这个时期被比喻为“人工智能的蛮荒西部”,或者更糟糕的是,“人工智能淘金热”!这让人联想到一个未开发、未驯服的领土被急切地征服,或者更糟糕的是,被文明化。然而,在 19 世纪,美国的西部地区与其他地区的地球上的其他地区并没有太大的不同,并且已经被美洲原住民居住了数千年,所以这个比喻并不完全适用。用我们能够通过机器学习实现的准确性和信心来预测,会让我们的祖先感到惊恐,并且对我们人类来说,这并不是一个“自然”的位置。这更像是飞行而不是探索未知土地。

文章《迈向机器学习的喷气时代》(在本章末尾的“进一步阅读”部分链接)提出了一个更适合的比喻,即人工智能就像航空业的黎明。它是新的、令人兴奋的,人们仍然对我们从下面能做什么感到惊奇(见图 14.4)!

然而,航空业还有待发挥其潜力。在 barnstorming 时代几十年后,航空业成熟为安全、可靠和高效的商业航空喷气时代。在航空业的情况下,承诺是它可以在不到一天的时间内可靠地将货物和人员运送到地球另一半。在人工智能的情况下,承诺是它可以做出公平、负责任和透明的决策——也许不是针对任何决策,但至少是它被设计去做的决策,除非它是通用人工智能AGI)的例子:

包含文本、户外、旧式描述自动生成

图 14.4:20 世纪 20 年代的 barnstorming(美国国会图书馆的印刷与照片部)

那么,我们如何才能达到那里?以下是我预期在追求达到机器学习喷气时代的过程中可能会发生的一些想法。

机器学习的新愿景

由于我们打算在人工智能方面走得更远,比以往任何时候都要远,明天的机器学习从业者必须更加意识到天空的危险。在这里,我指的是预测和规范性分析的新前沿。风险众多,涉及各种偏见和假设,已知和潜在的数据问题,以及我们模型的数学属性和局限性。很容易被机器学习模型欺骗,认为它们是软件。然而,在这个类比中,软件在本质上是完全确定性的——它牢牢地扎根于地面,而不是在天空中悬浮!

为了使民用航空变得安全,需要一种新的思维方式——一种新的文化。二战时期的战斗机飞行员,尽管他们能力出众,但也必须重新训练以在民用航空中工作。这不是同一个任务,因为当你知道你正在携带乘客,并且风险很高时,一切都会改变。

伦理 AI,以及由此产生的 iML,最终需要这种认识,即模型直接或间接地携带乘客“在船上”,并且模型并不像看起来那样稳健。一个稳健的模型必须能够可靠地经受住几乎任何条件,一次又一次地,就像今天的飞机一样。为此,我们需要使用更多的工具,这些工具以解释方法的形式出现。

多学科方法

对于符合 FAT 原则的模型,需要与许多学科更紧密地集成。这意味着 AI 伦理学家、律师、社会学家、心理学家、以人为本的设计师以及无数其他职业的更大参与。他们将与 AI 技术专家和软件工程师一起,将最佳实践编码到标准和法规中。

足够的标准化

不仅需要新的标准来规范代码、指标和方法,还需要规范语言。数据背后的语言主要来自统计学、数学、计算机科学和计量经济学,这导致了很多混淆。

执行监管

很可能需要所有生产模型满足以下规范:

  • 能够通过认证证明其稳健和公平

  • 能够使用 TRACE 命令解释其预测背后的推理,在某些情况下,还必须与预测一起提供推理

  • 可以拒绝他们不确定的预测

  • 为所有预测提供置信水平(参见“进一步阅读”部分的一致性预测教程和书籍)

  • 拥有训练数据的元数据(即使匿名)和作者身份,以及必要时,符合监管要求的证书和与公共账本(可能是区块链)相关的元数据

  • 拥有类似于网站的证书,以确保一定程度的信任

  • 过期后停止工作,直到用新数据进行重新训练

  • 当它们在模型诊断失败时自动离线,并且只有通过诊断后才能再次上线

  • 拥有持续训练/持续集成CT/CI)管道,帮助定期重新训练模型并执行模型诊断,以避免任何模型停机时间

  • 当它们在灾难性失败并造成公共损害时,由认证的 AI 审计师进行诊断

新法规可能会催生新的职业,例如 AI 审计师和模型诊断工程师。但它们也将支持 MLOps 工程师和 ML 自动化工具。

具有内置解释的无缝机器学习自动化

在未来,我们不会编写 ML 管道;它将主要是一个拖放功能,带有仪表板提供各种指标。它将主要实现自动化。自动化不应令人惊讶,因为一些现有的库执行自动特征选择模型训练。一些增强可解释性的程序可能自动执行,但大多数程序将需要人工判断。然而,解释应该贯穿整个流程,就像大多数飞机主要由自己飞行,但飞机上仍然有仪器提醒飞行员问题一样;价值在于向机器学习实践者提供每一步的潜在问题和改进信息。它是否找到了推荐用于单调约束的特征?它是否发现了可能需要调整的一些不平衡?它是否发现了可能需要一些纠正的数据异常?向实践者展示他们需要看到的内容,以便做出明智的决定,并让他们做出决定。

与 MLOps 工程师更紧密的集成

通过一键操作训练、验证和部署的、可认证的稳健模型需要的不只是云基础设施,还需要工具、配置以及接受过 MLOps 培训的人员来监控它们并在定期间隔进行维护。

摘要

可解释机器学习是一个广泛的话题,本书只覆盖了其最重要领域的一些方面,在诊断和治疗两个层面上进行了探讨。实践者可以在机器学习管道的任何地方利用工具包提供的工具。然而,选择何时以及如何应用它们取决于实践者。

最重要的是要熟悉工具。不使用可解释的机器学习工具包就像驾驶一架几乎没有仪器或完全没有仪器的飞机。就像驾驶飞机在不同的天气条件下运行一样,机器学习模型在不同的数据条件下运行,要成为一名熟练的飞行员或机器学习工程师,我们不能过于自信,而应该用我们的仪器来验证或排除假设。就像航空业花了几十年才成为最安全的交通方式一样,人工智能也需要几十年才能成为最安全的决策方式。这将需要全球村子的共同努力,但这将是一次激动人心的旅程!记住,预测未来的最好方法就是创造它

进一步阅读

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/inml

posted @ 2025-09-03 10:21  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报