Python 文本分析蓝图(三)
原文:
zh.annas-archive.org/md5/c63f0fe6d74b904d41494495addce0ab译者:飞龙
第六章:文本分类算法
互联网通常被称为巨大的促成者:它通过在线工具和平台帮助我们在日常生活中取得很多成就。另一方面,它也可能是信息超载和无休止搜索的来源。无论是与同事、客户、合作伙伴还是供应商进行沟通,电子邮件和其他消息工具都是我们日常工作生活中固有的一部分。品牌通过社交媒体平台如 Facebook 和 Twitter 与客户互动,并获取产品的宝贵反馈。软件开发者和产品经理使用类似Trello的工单应用程序来跟踪开发任务,而开源社区则使用GitHub的问题跟踪和Bugzilla来追踪需要修复的软件缺陷或需要添加的新功能。
虽然这些工具对完成工作很有用,但它们也可能变得无法控制,并迅速成为信息泛滥的源头。许多电子邮件包含推广内容、垃圾邮件和营销通讯,通常会分散注意力。同样,软件开发者很容易被大量的错误报告和功能请求淹没,这些会降低他们的生产力。为了充分利用这些工具,我们必须采用分类、过滤和优先处理更重要信息与不太相关信息的技术。文本分类是其中一种技术,可以帮助我们实现这一目标。
最常见的例子是由电子邮件提供商提供的垃圾邮件检测。在这种文本分类应用中,每封收件箱中的电子邮件都会被分析,以确定其是否包含有意义和有用的内容,或者是否是无用的无关信息。这样一来,邮件应用程序就可以仅展示相关和重要的电子邮件,过滤掉不太有用的信息泛滥。另一个应用是分类进入的客户服务请求或软件错误报告。如果我们能够对它们进行分类并分配给正确的人员或部门,那么它们将会更快地得到解决。文本分类有多种应用场景,在本章中,我们将开发一个可以跨多个应用场景应用的蓝图。
您将学到什么以及我们将构建什么
在本章中,我们将使用监督学习技术构建文本分类的蓝图。我们将使用包含某个软件应用程序错误报告的数据集,并使用这个蓝图来预测这些错误的优先级及特定模块。学习完本章后,您将了解如何应用监督学习技术,将数据分为训练和测试部分,使用准确度指标验证模型性能,并应用交叉验证技术。您还将了解二元分类和多类分类等不同类型的文本分类。
引入 Java 开发工具 Bug 数据集
软件技术产品通常很复杂,并且由几个互动组件组成。例如,假设您是一个开发 Android 应用程序播放播客的团队的一部分。除了播放器本身外,还可以有诸如库管理器、搜索和发现等单独的组件。如果用户报告无法播放任何播客,则需要意识到这是一个需要立即解决的关键 bug。另一位用户可能会报告他们喜欢的播客未显示的问题。这可能不那么关键,但重要的是确定这是否需要由库管理团队处理,或者实际上是搜索和发现团队的问题。为了确保快速响应时间,准确分类问题并将其分配给正确的团队至关重要。Bug 是任何软件产品不可避免的一部分,但快速响应将确保客户满意并继续使用您的产品。
在本章中,我们将使用蓝图对 Java 开发工具(JDT)开源项目 中开发期间提出的 bug 和问题进行分类。JDT 项目是 Eclipse 基金会的一部分,该基金会开发 Eclipse 集成开发环境(IDE)。JDT 提供了开发人员使用 Eclipse IDE 编写 Java 代码所需的所有功能。JDT 用户使用 Bugzilla 工具报告 bug 和跟踪问题,Bugzilla 是一款流行的开源 bug 跟踪软件,也被 Firefox 和 Eclipse 平台等其他开源项目使用。包含所有这些项目的 bug 的数据集可以在 GitHub 上找到,我们将使用 JDT 项目的 bug 数据集。
以下部分加载了一个包含 JDT bug 数据集的 CSV 文件。该数据集包含 45,296 个 bug 和每个 bug 的一些可用特征。我们列出了报告的所有特征的列表,并更详细地查看了其中一些,以了解 bug 报告的具体内容:
df = pd.read_csv('eclipse_jdt.csv')
print (df.columns)
df[['Issue_id','Priority','Component','Title','Description']].sample(2)
输出:
Index(['Issue_id', 'Priority', 'Component', 'Duplicated_issue', 'Title',
'Description', 'Status', 'Resolution', 'Version', 'Created_time',
'Resolved_time'],
dtype='object')
| 问题编号 | 优先级 | 组件 | 标题 | 描述 | |
|---|---|---|---|---|---|
| 38438 | 239715 | P3 | UI | TestCaseElement 的属性测试器不存在 | I20080613-2000; ; 不确定这是否属于 JDT/Debug 还是 Platform/Debug。; ; 我今天在我的错误日志中看到了这个错误消息多次,但我还不确定如何重现它。; ; -- 错误详细信息... |
| 44129 | 395007 | P3 | UI | [package explorer] Java 包文件夹上不可用的刷新操作 | M3.; ; 对于普通源文件夹,F5(刷新)作为上下文菜单项可用,但对于 e4 Java 包资源管理器中的 Java 包文件夹则不可用。; ; 请恢复 3.x 的功能。 |
根据前表显示的细节,我们可以看到每个 bug 报告包含以下重要特征:
问题编号
用于跟踪 bug 的问题的主键。
优先级
这个从 P1(最关键)到 P5(最不关键)变化,并定义了 bug 的严重程度(一个分类字段)。
组件
这指的是项目中特定的架构部分,bug 出现的地方。这可以是 UI、APT 等(一个分类字段)。
标题
这是用户输入的简短摘要,简要描述 bug(一个全文字段)。
描述
这是对产生 bug 的软件行为及其对使用影响的更详细描述(一个全文字段)。
在创建 bug 报告时,用户遵循 JDT Bugzilla 网站上提到的指南。这些指南描述了用户在提出 bug 时需要提供的信息,以便开发人员能够快速解决问题。该网站还包括帮助用户确定给特定 bug 分配优先级的指南。我们的蓝图将使用这些 bug 报告开发一个监督学习算法,该算法可用于自动为未来提出的任何 bug 分配优先级。
在前一节中,我们对数据集和每个 bug 报告的各种特征有了高层次的理解。现在让我们更详细地探索单个 bug 报告。我们随机抽样一个单个 bug(您可以选择不同的 random_state 值以查看不同的 bug),并转置结果,以便更详细地显示结果。如果不进行转置,描述特性将以截断的方式显示,而现在我们可以看到所有内容:
df.sample(1).T
Out:
| 11811 | |
|---|---|
| Issue_id | 33113 |
| Priority | P3 |
| Component | 调试 |
| Title | 评估 URLClassLoader 中的 for 循环挂起问题 |
| Description | 调试 HelloWorld 程序中断到断点。在 DisplayView 中;突出显示并;显示以下代码片段:;; for (int i = 0; i < 10; i++) {; System.out.println(i);; };; 而不仅仅报告没有明确的返回值;调试器在; URLClassLoader;显然尝试加载 int 类。您需要多次单击“继续”按钮,直到评估完成。DebugView 不显示暂停的原因(线程仅标记为“评估”)。如果关闭“挂起未捕获异常”偏好设置,此行为将不会发生。 |
| Status | 验证通过 |
| Resolution | 已修复 |
| Version | 2.1 |
| Created_time | 2003-02-25 15:40:00 -0500 |
| Resolved_time | 2003-03-05 17:11:17 -0500 |
从上表中我们可以看到,这个错误是在调试组件中引发的,程序在评估for循环时会崩溃。我们还可以看到,用户给了一个中等优先级(P3),这个错误在一周内被修复了。我们可以看到,这个错误的报告者遵循了指南并提供了大量信息,这也帮助软件开发人员理解和识别问题并提供修复。大多数软件用户知道,他们提供的信息越多,开发人员理解问题并提供修复就越容易。因此,我们可以假设大多数错误报告包含足够的信息,以便我们创建一个监督学习模型。
输出图描述了不同优先级的错误报告分布情况。我们可以看到大多数错误被分配了 P3 级别。尽管这可能是因为 Bugzilla 将 P3 作为默认选项,但更可能的是这反映了用户在选择其错误报告的优先级时的自然倾向。他们认为该错误不具有高优先级(P1),同时又不希望他们的错误报告完全不被考虑,因此选择了 P5。这在许多现实现象中都有所体现,并通常称为正态分布,其中大多数观测值位于中心或平均值处,而末端的观测值较少。这也可以被视为钟形曲线的可视化。
df['Priority'].value_counts().sort_index().plot(kind='bar')
Out:
优先级为 P3 与其他优先级之间的巨大差异是构建监督学习模型的问题,并被称为类别不平衡。因为类别 P3 的观察数量比其他类别(P1、P2、P4 和 P5)大一个数量级,文本分类算法对 P3 错误的信息要比其他优先级(P1、P2、P4 和 P5)多得多:我们将看到优先级特征的类别不平衡如何影响我们的解决方案,并试图在蓝图中稍后克服这一问题。这与人类学习某些东西相似。如果你见过更多的某种结果的例子,你会更多“预测”相同的结果。
在下面的片段中,我们可以看到针对 JDT 的每个组件报告了多少个错误。UI 和核心组件比文档或 APT 组件报告的错误要多得多。这是预期的,因为软件系统的某些组件比其他组件更大更重要。例如,文档组件包括软件的文档部分,被软件开发人员用来理解功能,但可能不是一个工作组件。另一方面,核心组件是 JDT 的一个重要功能组件,因此分配给它的错误要多得多:
df['Component'].value_counts()
Out:
UI 17479
Core 13669
Debug 7542
Text 5901
APT 406
Doc 299
Name: Component, dtype: int64
蓝图:构建文本分类系统
我们将逐步构建文本分类系统,并将所有这些步骤结合起来,提供一个统一的蓝图。这种文本分类系统属于更广泛的监督学习模型类别。监督学习是指一类机器学习算法,它使用标记的数据点作为训练数据,来学习独立变量和目标变量之间的关系。学习这种关系的过程也称为训练机器学习模型。如果目标变量是连续的数值变量,如距离、销售单位或交易金额,我们会训练一个回归模型。然而,在我们的情况下,目标变量(优先级)是一个类别变量,我们将选择一个分类方法来训练监督学习模型。该模型将使用标题或描述等独立变量来预测错误的优先级或组件。监督机器学习方法旨在学习从输入到输出变量的映射函数,数学上定义如下:
y = f ( X )
在上述方程中,y 是输出或目标变量,f 是映射函数,X 是输入变量或一组变量。
由于我们使用包含标记目标变量的数据,这被称为监督学习。图 6-1 说明了监督学习模型的工作流程。工作流程分为两个阶段:训练阶段和预测阶段。训练阶段从包含训练观测(如错误报告)和相关标签(我们想要预测的优先级或软件组件)的训练数据开始。虽然许多训练观测的特征可以直接使用,但仅此可能不足以学习映射函数,我们希望增加领域知识以帮助模型更好地理解关系。例如,我们可以添加一个显示错误报告何时报告的特征,因为如果错误在周初报告,则很可能更快修复。这一步骤称为特征工程,其结果是每个文档的一组特征向量。监督学习模型的训练步骤接受特征向量及其相关标签作为输入,并试图学习映射函数。在训练步骤结束时,我们得到了映射函数,也称为训练模型,可以用来生成预测。
在预测阶段,模型接收到一个新的输入观察值(例如一个错误报告),并且像在训练阶段应用的方式一样转换文档以生成特征向量。新的特征向量被馈送到训练好的模型中以生成预测结果(例如一个错误的优先级)。通过这种方式,我们实现了一种预测标签的自动化方式。
图 6-1. 用于分类的监督学习算法工作流程。
文本分类是一个监督学习算法的示例,其中我们使用文本数据和文本向量化等自然语言处理技术来为给定的文档分配一个分类目标变量。分类算法可以归为以下几类:
二元分类
实际上,这是多类分类的特殊情况,其中一个观察值可以有两个值中的任何一个(二元)。例如,给定的电子邮件可以标记为垃圾邮件或非垃圾邮件。但是每个观察值只会有一个标签。
多类分类
在这种类型的分类算法中,每个观察值与一个标签相关联。例如,错误报告可以从优先级的五个类别 P1、P2、P3、P4 或 P5 中选择一个单一值。类似地,当尝试识别错误报告所在的软件组件时,每个错误可以属于六个类别之一(UI、核心、调试、文本、APT 或 Doc)。
多标签分类
在这种类型的分类算法中,每个观察值可以分配给多个标签。例如,一篇单一的新闻文章可以被标记为多个标签,如安全性、技术和区块链。可以使用多个二元分类模型来生成最终结果,但我们不会在我们的蓝图中涵盖此部分。
第一步:数据准备
在继续构建文本分类模型之前,我们必须执行一些必要的预处理步骤来清洁数据,并以适合机器学习算法应用的方式格式化数据。由于我们的目标是根据标题和描述来识别错误报告的优先级,我们只选择与文本分类模型相关的列。我们还使用 dropna 函数删除任何包含空值的行。最后,我们组合标题和描述列以创建单个文本值,并且应用 第四章 中的文本清洁蓝图来删除特殊字符。在删除特殊字符后,我们过滤掉那些文本字段少于 50 个字符的观察值。这些错误报告填写不正确,并且包含的问题描述很少,对于训练模型没有帮助:
df = df[['Title','Description','Priority']]
df = df.dropna()
df['text'] = df['Title'] + ' ' + df['Description']
df = df.drop(columns=['Title','Description'])
df.columns
输出:
Index(['Priority', 'text'], dtype='object')
然后:
df['text'] = df['text'].apply(clean)
df = df[df['text'].str.len() > 50]
df.sample(2)
输出:
| 优先级 | 文本 | |
|---|---|---|
| 28311 | P3 | 需要在生成文件时重新运行 APT 反依赖项 如果生成的文件满足另一个文件中的缺失类型,我们应在该文件上重新运行 APT,以修复新类型。当前的 Java 编译执行了正确的操作,但 APT 没有。需要跟踪具有缺失类型的文件,并在回合结束时重新编译生成新类型的文件。为了良好的性能,需要跟踪名称,并仅编译那些生成的缺失类型。 |
| 25026 | P2 | 外部化字符串向导:可用性改进 M6 测试通过 由于大多数 Java 开发者不会面对 Eclipse 模式,我会将复选框移动到 Accessor 类的区域下方。此外,如果工作空间中不存在 org.eclipse.osgi.util.NLS,向导不应提供此选项。这将避免普通 Java 开发者面对此选项。 |
我们可以从前面两个缺陷报告的文本特征总结中看到,我们的清理步骤已删除了许多特殊字符;我们仍然保留了形成描述的代码结构和语句的大部分。这是模型可以用来理解缺陷的有用信息,也会影响其是否属于更高优先级的因素。
第二步:训练-测试分离
在训练监督学习模型的过程中,我们试图学习一个最接近真实世界行为的函数。我们利用训练数据中的信息来学习这个函数。随后,评估我们学到的函数与真实世界行为的接近程度至关重要,因此我们将整个数据集划分为训练集和测试集来实现这一目标。我们通常使用百分比划分数据,其中较大份额分配给训练集。例如,如果数据集有 100 个观测值,并且按 80-20 的比例进行训练-测试分离,则训练集将包含 80 个观测值,测试集将包含 20 个观测值。模型现在在训练集上进行训练,仅使用这 80 个观测值来学习函数。我们将使用这 20 个观测值的测试集来评估学习到的函数。如图 6-2 所示,这一过程进行了说明。
在训练阶段:
y train = F ( X train )
在评估过程中:
y prediction = F ( X test )
图 6-2. 以 80-20 比例划分的训练-测试集。
模型仅看到训练集中的 80 个观测数据,并且学到的函数现在应用于完全独立和未见过的测试集上以生成预测。我们知道测试集中目标变量的真实值,并将这些与预测进行比较,以真实地评估学到的函数的表现以及它与真实世界行为的接近程度:
a c c u r a c y = e r r o r _ m e t r i c ( y prediction , y true )
在测试分割上评估学习到的模型提供了文本分类模型错误的无偏估计,因为测试分割中的观察结果是从训练观察结果中随机抽样的,不是学习过程的一部分。测试分割将在模型评估过程中使用,并且有几种可以用来衡量此错误的度量标准,这将在“第 4 步:模型评估”中讨论。
我们使用sklearn.model_selection.train_test_split函数来实现训练-测试分割,并将test_size参数设为 0.2(表示我们的数据的 20%作为测试分割)。此外,我们还必须指定我们的自变量和目标变量,该方法会返回一个包含四个元素的列表;前两个元素是自变量拆分为训练和测试分割,后两个元素是目标变量拆分。该函数的一个重要参数是random_state。这个数字影响着如何对行进行抽样,因此哪一组观察结果进入训练分割,哪一组观察结果进入测试分割。如果提供不同的数字,80-20 分割将保持不变,但不同的观察结果将进入训练和测试分割。重要的是要记住,要复制相同的结果,你必须选择相同的random_state值。例如,如果你想要检查在添加新的自变量后模型的变化情况,你必须能够比较添加新变量前后的准确度。因此,你必须使用相同的random_state,以便确定是否发生了变化。要注意的最后一个参数是stratify,它确保目标变量的分布在训练和测试分割中保持不变。如果这个分布没有保持不变,那么训练分割中某个类别的观察结果可能会有更多,这不符合训练数据中的分布,导致模型学习一个不现实的函数:
X_train, X_test, Y_train, Y_test = train_test_split(df['text'],
df['Priority'],
test_size=0.2,
random_state=42,
stratify=df['Priority'])
print('Size of Training Data ', X_train.shape[0])
print('Size of Test Data ', X_test.shape[0])
输出:
Size of Training Data 36024
Size of Test Data 9006
第三步:训练机器学习模型
创建文本分类蓝图的下一步是使用适当的算法训练监督式机器学习模型。当处理文本分类时,SVM 是一种常用的算法之一,我们将首先介绍该方法,然后说明为什么它非常适合我们的任务。
考虑一个在 X-Y 平面上的点集,每个点属于两个类别中的一个:十字或圆圈,如图 6-3 所示。支持向量机通过选择一条清晰地分隔这两个类别的直线来工作。当然,可能存在几条这样的直线(用虚线选项表示),算法选择能在最靠近的十字和圆圈点之间提供最大分离的直线。这些最靠近的十字和圆圈点称为支持向量。在示例中,我们能够识别出一个能够清晰分隔十字和圆圈点的超平面,但实际情况中可能难以实现这一点。例如,可能有几个圆圈点位于极左侧,这时生成超平面就不可能了。算法通过允许一定灵活性的容差参数tol来处理这种情况,并在决定超平面时接受误分类点的错误。
图 6-3. 简单二维分类示例中的超平面和支持向量。
在继续运行支持向量机模型之前,我们必须将文本数据准备成算法可以使用的合适格式。这意味着我们必须找到一种方法将文本数据表示为数值格式。最简单的方法是计算每个词在一个缺陷报告中出现的次数,并将所有词的计数组合起来,为每个观察结果创建一个数值表示。这种技术的缺点是常见的单词将有很大的值,并可能被误认为是重要特征,这种情况并非真实。因此,我们采用首选选项,即使用词频逆文档频率(TF-IDF)向量化来表示文本,详细解释请参见第五章。
tfidf = TfidfVectorizer(min_df = 10, ngram_range=(1,2), stop_words="english")
X_train_tf = tfidf.fit_transform(X_train)
在前一步执行的 TF-IDF 向量化生成了一个稀疏矩阵。当处理文本数据时,SVM 算法更为适用,因为它更适合处理稀疏数据,相比其他算法如随机森林。它们还更适合处理纯数值型输入特征(就像我们的情况),而其他算法则能够处理数值和分类输入特征的混合。对于我们的文本分类模型,我们将使用由 scikit-learn 库提供的sklearn.svm.LinearSVC模块。实际上,SVM 可以使用不同的核函数进行初始化,线性核函数在处理文本数据时推荐使用,因为可以考虑到大量线性可分的特征。它也更快速适应,因为需要优化的参数更少。scikit-learn 包提供了线性 SVM 的不同实现,如果你有兴趣,可以通过阅读“SVC Versus LinearSVC Versus SGDClassifier”来了解它们之间的区别。
在以下代码中,我们使用特定的random_state初始化模型,并指定了容差值为 0.00001。这些参数是针对我们使用的模型类型具体指定的,我们将在本章后面展示如何为这些参数值找到最优值。现在,我们从指定一些默认值开始,然后调用fit方法,确保使用我们在前一步创建的向量化独立变量:
model1 = LinearSVC(random_state=0, tol=1e-5)
model1.fit(X_train_tf, Y_train)
Out:
LinearSVC(C=1.0, class_weight=None, dual=True, fit_intercept=True,
intercept_scaling=1, loss='squared_hinge', max_iter=1000,
multi_class='ovr', penalty='l2', random_state=0, tol=1e-05,
verbose=0)
在执行上述代码后,我们使用训练数据拟合了一个模型,结果显示了生成的模型的各种参数。由于我们只指定了random_state和容差,大多数参数都是默认值。
第四步:模型评估
现在我们有一个可以用来预测测试集中所有观测目标变量的模型。对于这些观测,我们也知道真实的目标变量,因此我们可以计算我们模型的表现。有许多可以用来量化我们模型准确性的指标,在本节中我们将介绍其中三个。
验证我们的文本分类模型最简单的方法是通过准确率:模型正确预测数量与观测总数的比率。数学上可以表示如下:
A c c u r a c y = Numberofcorrectpredictions Totalnumberofpredictionsmade
为了衡量模型的准确性,我们使用训练好的模型生成预测并与真实值进行比较。为了生成预测,我们必须对独立变量的测试集应用相同的向量化,然后调用训练模型的预测方法。一旦我们有了预测结果,我们可以使用下面展示的accuracy_score方法来自动生成这个度量,通过比较测试集的真实值和模型预测值来完成:
X_test_tf = tfidf.transform(X_test)
Y_pred = model1.predict(X_test_tf)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred))
Out:
Accuracy Score - 0.8748612036420165
如您所见,我们取得了 87.5%的高准确率,表明我们有一个能够准确预测缺陷优先级的好模型。请注意,如果您使用不同的random_state初始化模型,则可能得到不同但相似的分数。始终比较训练模型与基线方法(可能基于简单的经验法则或业务知识)的表现是个好主意。我们可以使用sklearn.svm.DummyClassifier模块,它提供诸如most_frequent的简单策略,基线模型始终预测出现频率最高的类别,或者stratified,它生成符合训练数据分布的预测:
clf = DummyClassifier(strategy='most_frequent')
clf.fit(X_train, Y_train)
Y_pred_baseline = clf.predict(X_test)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred_baseline))
输出:
Accuracy Score - 0.8769709082833667
我们可以清楚地看到,我们训练的模型并未增加任何价值,因为其表现与始终选择 P3 类别的基线相当。我们还需深入挖掘模型在不同优先级上的表现如何。它在预测 P1 或 P5 优先级方面表现更好吗?为了分析这一点,我们可以使用另一个评估工具,称为混淆矩阵。混淆矩阵是一个网格,比较了所有分类观察的预测值与实际值。混淆矩阵最常见的表示是针对只有两个标签的二元分类问题。
我们可以通过将一个类别视为 P3,将另一个类别视为所有其余类别,来修改我们的多类别分类问题以适应这种表示。让我们看看图 6-4,这是一个仅预测特定缺陷是否具有优先级 P3 的混淆矩阵的示例表示。
图 6-4. 优先级 P3 和非 P3 的混淆矩阵。
行代表预测结果,列代表实际值。矩阵中的每个单元格都是落入该格的观察计数:
真阳性
预测为正且确实为正的观察计数。
真阴性
预测为负且确实为负的观察计数。
假阳性
预测为正但实际为负的观察计数。
假阴性
预测为负但实际为正的观察计数。
基于此列表,我们可以使用以下方程自动推导出准确度度量:
A c c u r a c y = (TruePositive+TrueNegative) (TruePositive+TrueNegative+FalsePositive+FalseNegative)
这不过是所有预测正确与总预测数的比率而已。
精确率和召回率
使用混淆矩阵的真正价值在于精确率和召回率等其他度量,这些度量能够更深入地了解模型在不同类别下的表现。
让我们考虑正(P3)类,并考虑精确率:
P r e c i s i o n = TruePositive (TruePositive+FalsePositive)
此指标告诉我们预测的正例中实际上是正例的比例,或者说我们的模型在预测正类时的准确性。如果我们希望对我们的正面预测有把握,那么这是一个必须最大化的指标。例如,如果我们将电子邮件分类为垃圾邮件(正类),那么我们必须在这方面做到准确;否则,一封好的电子邮件可能会意外地发送到垃圾邮件文件夹。
源自混淆矩阵的另一个衡量指标是召回率:
R e c a l l = TruePositive (TruePositive+FalseNegative)
此指标告诉我们实际正值中被我们的模型识别的比例。高召回率意味着我们的模型能够捕捉现实中大多数的正类分类。这在成本未识别正例很高的情况下尤为重要,例如,如果一个患者患有癌症但我们的模型未能识别出来。
从前面的讨论中,我们可以得出结论,无论模型的应用是什么,精确度和召回率都是重要的指标。F1 分数是一个创建这两个度量的调和平均值的指标,也可以用作评估模型整体准确性的代理:
F 1 S c o r e = 2(PrecisionRecall) (Precision+Recall)
现在我们已经对混淆矩阵有了理解,让我们回到我们的蓝图,并添加评估训练模型的混淆矩阵的步骤。请注意,早期的表示被简化为二元分类,而我们的模型实际上是一个多类分类问题,因此混淆矩阵会相应地改变。例如,我们模型的混淆矩阵可以通过函数confusion_matrix生成,如下所示:
Y_pred = model1.predict(X_test_tf)
confusion_matrix(Y_test, Y_pred)
输出:
array([[ 17, 6, 195, 5, 0],
[ 7, 14, 579, 7, 0],
[ 21, 43, 7821, 13, 0],
[ 0, 7, 194, 27, 0],
[ 0, 0, 50, 0, 0]])
这也可以通过使用plot_confusion_matrix函数以热图形式进行可视化,如下所示:
plot_confusion_matrix(model1,X_test_tf,
Y_test, values_format='d',
cmap=plt.cm.Blues)
plt.show()
我们可以使用与前述相同的方法为每个类别定义精确度和召回率,但现在还将包括被错误分类到其他类别的观察计数。
例如,类别 P3 的精度可以计算为正确预测的 P3 值(7,821)与所有预测的 P3 值(195 + 579 + 7,821 + 194 + 50)的比率,结果如下:
精确度(P3) = 7,821 / 8,839 = 0.88
类似地,P3 的召回率可以计算为正确预测的 P3 值与所有实际 P3 值(21 + 43 + 7,821 + 13 + 0)的比率,结果如下:
召回率(P2) = 7,821 / 7,898 = 0.99
直接确定这些度量的更简单方法是使用 scikit-learn 的classification_report函数,它可以自动计算这些值:
print(classification_report(Y_test, Y_pred))
输出:
precision recall f1-score support
P1 0.38 0.08 0.13 223
P2 0.20 0.02 0.04 607
P3 0.88 0.99 0.93 7898
P4 0.52 0.12 0.19 228
P5 0.00 0.00 0.00 50
accuracy 0.87 9006
macro avg 0.40 0.24 0.26 9006
weighted avg 0.81 0.87 0.83 9006
根据我们的计算和之前的分类报告,一个问题变得明显:尽管 P3 类别的召回率和精确度值相当高,但其他类别的这些值很低,甚至在某些情况下为 0(P5)。模型的整体准确率为 88%,但如果我们硬编码我们的预测始终为 P3,这也将在 88%的时间内是正确的。这清楚地表明我们的模型并未学习到太多显著的信息,而只是预测了多数类别。这凸显了在模型评估期间,我们必须分析几个指标,而不能仅依赖准确率。
类别不平衡
模型表现如此的原因是由于我们之前观察到的优先级类别中的类别不平衡。尽管 P3 优先级有接近 36,000 个错误,但其他优先级类别的错误数量只有大约 4,000 个,其他情况更少。这意味着当我们训练我们的模型时,它只能学习 P3 类别的特征。
有几种技术可以用来解决类别不平衡的问题。它们属于上采样和下采样技术的两类。上采样技术是指用于人工增加少数类观测数量(例如我们例子中的非 P3 类别)的方法。这些技术可以从简单地添加多个副本到使用 SMOTE 等方法生成新观测数据。^(1) 下采样技术是指用于减少多数类观测数量(例如我们例子中的 P3 类别)的方法。我们将选择随机下采样 P3 类别,使其观测数量与其他类别相似:
# Filter bug reports with priority P3 and sample 4000 rows from it
df_sampleP3 = df[df['Priority'] == 'P3'].sample(n=4000)
# Create a separate DataFrame containing all other bug reports
df_sampleRest = df[df['Priority'] != 'P3']
# Concatenate the two DataFrame to create the new balanced bug reports dataset
df_balanced = pd.concat([df_sampleRest, df_sampleP3])
# Check the status of the class imbalance
df_balanced['Priority'].value_counts()
Out:
P3 4000
P2 3036
P4 1138
P1 1117
P5 252
Name: Priority, dtype: int64
请注意,在执行下采样时,我们正在丢失信息,这通常不是一个好主意。但是,每当遇到类别不平衡的问题时,这会阻止我们的模型学习正确的信息。我们尝试通过使用上采样和下采样技术来克服这一问题,但这将始终涉及到数据质量的妥协。虽然我们选择了一种简单的方法,请查看下面的侧边栏,了解处理这种情况的各种方法。
文本分类最终蓝图
现在,我们将结合到目前为止列出的所有步骤,创建我们的文本分类蓝图:
# Loading the balanced DataFrame
df = df_balanced[['text', 'Priority']]
df = df.dropna()
# Step 1 - Data Preparation
df['text'] = df['text'].apply(clean)
# Step 2 - Train-Test Split
X_train, X_test, Y_train, Y_test = train_test_split(df['text'],
df['Priority'],
test_size=0.2,
random_state=42,
stratify=df['Priority'])
print('Size of Training Data ', X_train.shape[0])
print('Size of Test Data ', X_test.shape[0])
# Step 3 - Training the Machine Learning model
tfidf = TfidfVectorizer(min_df=10, ngram_range=(1, 2), stop_words="english")
X_train_tf = tfidf.fit_transform(X_train)
model1 = LinearSVC(random_state=0, tol=1e-5)
model1.fit(X_train_tf, Y_train)
# Step 4 - Model Evaluation
X_test_tf = tfidf.transform(X_test)
Y_pred = model1.predict(X_test_tf)
print('Accuracy Score - ', accuracy_score(Y_test, Y_pred))
print(classification_report(Y_test, Y_pred))
Out:
Size of Training Data 7634
Size of Test Data 1909
Accuracy Score - 0.4903090623363017
precision recall f1-score support
P1 0.45 0.29 0.35 224
P2 0.42 0.47 0.44 607
P3 0.56 0.65 0.61 800
P4 0.39 0.29 0.33 228
P5 0.00 0.00 0.00 50
accuracy 0.49 1909
macro avg 0.37 0.34 0.35 1909
weighted avg 0.47 0.49 0.48 1909
根据结果,我们可以看到我们的准确率现在达到了 49%,这不太好。进一步分析,我们可以看到对于 P1 和 P2 优先级,精确度和召回率值已经提高,这表明我们能够更好地预测具有这些优先级的错误。然而,显然对于 P5 优先级的错误,这个模型并没有提供任何信息。我们看到这个模型比使用分层策略的简单基线模型表现更好,如下所示。尽管早期的模型具有更高的准确性,但实际上并不是一个好模型,因为它是无效的。这个模型也不好,但至少呈现了一个真实的画面,并告诉我们我们不能用它来生成预测:
clf = DummyClassifier(strategy='stratified')
clf.fit(X_train, Y_train)
Y_pred_baseline = clf.predict(X_test)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred_baseline))
输出:
Accuracy Score - 0.30434782608695654
下面是一些我们模型对这些优先级的预测准确的示例:
# Create a DataFrame combining the Title and Description,
# Actual and Predicted values that we can explore
frame = { 'text': X_test, 'actual': Y_test, 'predicted': Y_pred }
result = pd.DataFrame(frame)
result[((result['actual'] == 'P1') | (result['actual'] == 'P2')) &
(result['actual'] == result['predicted'])].sample(2)
输出:
| 文本 | 实际 | 预测 | |
|---|---|---|---|
| 64 | Java 启动器:如果只有一个元素,不要提示要启动的元素。我想通过选择它并单击调试工具项来调试一个 CU。我被提示选择一个启动器,然后我还必须在第二页上选择唯一可用的类。第二步是不必要的。第一页上的下一步按钮应该被禁用。注意:DW,第一次在工作空间中启动某个东西时,你必须经历这种痛苦...这是由于调试器对不同语言是可插拔的。在这种情况下,启动器选择是通用调试支持,选择要启动的类是特定于 Java 调试支持。为了促进懒加载插件并避免启动器对可启动目标进行详尽搜索,启动器选择页面不会轮询可插拔的启动页面,以查看是否可以使用当前选择完成。一旦你为项目选择了默认启动器,启动器选择页面就不会再打扰你。移至非活动状态以供 6 月后考虑 | ||
| 5298 | 快速步进 toString 当您快速步进并选择一个对象显示详细信息时,我们会在日志中得到异常。这是因为 toString 尝试在步骤进行时进行评估。我们必须允许在评估过程中进行步进,所以这是一个棘手的时间问题。 </log-entr | P1 | P1 |
以下是模型预测不准确的情况:
result[((result['actual'] == 'P1') | (result['actual'] == 'P2')) &
(result['actual'] != result['predicted'])].sample(2)
输出:
| 文本 | 实际 | 预测 | |
|---|---|---|---|
| 4707 | Javadoc 向导:默认包存在问题 20020328 1. 空项目。在默认包中创建 A.java 2. 启动导出向导选择默认包按下完成按钮 3. 创建失败 javadoc:包 A 的源文件不存在 为包 A 加载源文件... 1 个错误 不知道这是否是一般的 javadoc 问题 | P1 | P2 |
| 16976 | 断点条件编译器不应关心非 NLS 字符串 我有一个项目,在这个项目中,我设置了编译器选项,将非外部化字符串的使用设置为警告。当我想在包含字符串对象.equals 的断点条件上设置条件时,由于编译错误,我总是在这一点上中断……然后我不得不这样写我的条件:boolean cond = object.equals // return cond 以避免这个问题。调试器是否可以使用特定的编译器,它将忽略当前项目/工作区的编译器选项,而仅使用默认的选项呢? | P2 | P3 |
我们的模型不准确,从观察预测结果来看,不清楚描述和优先级之间是否存在关系。为了提高模型的准确性,我们必须执行额外的数据清理步骤,如词形还原,去除噪声标记,修改min_df和max_df,包括三元组等。我们建议您修改“大数据集上的特征提取”中提供的当前clean函数,并检查其性能。另一种选择是确定所选模型的正确超参数,在下一节中,我们将介绍交叉验证和网格搜索技术,这些技术可以帮助我们更好地理解模型性能,并得出优化的模型。
Blueprint: 使用交叉验证估算实际准确度指标
在训练模型之前,我们创建了一个训练-测试分离,以便能够准确评估我们的模型。根据测试分离,我们得到了 48.7%的准确度。然而,我们希望提高这个准确度。我们可以使用的一些技术包括添加额外的特征,如三元组,添加额外的文本清理步骤,选择不同的模型参数,然后在测试分离上检查性能。我们的结果始终基于一个我们使用训练-测试分离创建的单个留出数据集。如果我们返回并更改random_state或shuffle我们的数据,那么我们可能会得到一个不同的测试分离,对于相同的模型可能会有不同的准确度。因此,我们严重依赖于给定的测试分离来确定我们模型的准确度。
交叉验证 是一种技术,允许我们在数据的不同分割上进行训练和验证,以便最终训练的模型在欠拟合和过拟合之间取得适当的平衡。欠拟合是指我们训练的模型未能很好地学习底层关系,并对每个观察结果进行类似的预测,这些预测与真实值相去甚远。这是因为所选模型复杂性不足以建模现象(错误的模型选择)或者学习关系的观察样本不足。过拟合是指选择的模型非常复杂,在训练过程中很好地拟合了底层模式,但在测试数据上产生显著偏差。这表明训练的模型在未见数据上泛化能力不强。通过使用交叉验证技术,我们可以通过在数据的多个分割上进行训练和测试,意识到这些缺点,并得出模型更真实的性能。
有许多交叉验证的变体,其中最广泛使用的是 K 折交叉验证。图 6-5 展示了一种 K 折策略,我们首先将整个训练数据集分成 K 份。在每次迭代中,模型在不同的 K-1 折数据集上进行训练,并在保留的第 K 折上进行验证。整体性能被视为所有保留的 K 折上性能的平均值。通过这种方式,我们不仅仅基于一个测试分割来评估模型的准确性,而是基于多个这样的分割,同样我们也在多个训练数据的分割上进行模型训练。这使我们可以利用所有观察样本来训练我们的模型,因为我们不需要单独的保留测试分割。
图 6-5. 一种 K 折交叉验证策略,每次训练模型时选择不同的留出集(阴影部分)。其余集合形成训练数据的一部分。
要执行交叉验证,我们将使用 scikit-learn 中的cross_val_score方法。它的参数包括需要拟合的模型、训练数据集以及我们想要使用的折数。在这种情况下,我们使用五折交叉验证策略,根据训练观测数和计算基础设施的可用性,这可以在 5 到 10 之间变化。该方法返回每次交叉验证迭代的验证分数,并且我们可以计算所有验证折叠中得到的平均值。从结果中,我们可以看到验证分数从 36%变化到 47%不等。这表明我们之前在测试数据集上报告的模型准确率是乐观的,并且是特定的训练测试分割方式的产物。从交叉验证中得到的更实际的准确率平均分为 44%。执行此练习以理解任何模型的真实潜力非常重要。我们再次执行向量化步骤,因为我们将使用整个数据集,而不仅仅是训练分割:
# Vectorization
tfidf = TfidfVectorizer(min_df = 10, ngram_range=(1,2), stop_words="english")
df_tf = tfidf.fit_transform(df['text']).toarray()
# Cross Validation with 5 folds
scores = cross_val_score(estimator=model1,
X=df_tf,
y=df['Priority'],
cv=5)
print ("Validation scores from each iteration of the cross validation ", scores)
print ("Mean value across of validation scores ", scores.mean())
print ("Standard deviation of validation scores ", scores.std())
输出:
Validation scores from each iteration of the cross validation
[0.47773704 0.47302252 0.45468832 0.44054479 0.3677318 ]
Mean value across of validation scores 0.44274489261393396
Standard deviation of validation scores 0.03978852971586144
注意
使用交叉验证技术允许我们使用所有观测数据,而不需要创建单独的保留测试分割。这为模型提供了更多的学习数据。
蓝图:使用网格搜索执行超参数调优
网格搜索是一种通过评估不同作为模型参数的参数来提高模型准确性的有用技术。它通过尝试不同的超参数组合来最大化给定指标(例如准确率)来实现这一目标。例如,如果我们使用sklearn.svm.SVC模型,它有一个名为kernel的参数,可以采用几个值:linear、rbf(径向基函数)、poly(多项式)等等。此外,通过设置预处理流水线,我们还可以测试不同的ngram_range值用于 TF-IDF 向量化。当我们进行网格搜索时,我们提供要评估的参数值集,并结合交叉验证方法来训练模型,从而确定最大化模型准确性的超参数集。这种技术的最大缺点是它对 CPU 和时间要求高;换句话说,我们需要测试许多可能的超参数组合,以确定表现最佳的数值集。
为了测试我们模型的超参数的正确选择,我们首先创建了一个training_pipeline,在其中定义我们想要运行的步骤。在这种情况下,我们指定了 TF-IDF 向量化和 LinearSVC 模型训练。然后,我们定义了一组参数,我们希望使用变量grid_param进行测试。由于参数值特定于管道中的某个步骤,因此在指定grid_param时,我们使用步骤的名称作为前缀。例如,min_df是向量化步骤使用的参数,因此称为tfidf__min_df。最后,我们使用GridSearchCV方法,该方法提供了测试整个管道的多个版本以及不同超参数集合的功能,并生成交叉验证分数,从中选择性能最佳的版本:
training_pipeline = Pipeline(
steps=[('tfidf', TfidfVectorizer(stop_words="english")),
('model', LinearSVC(random_state=42, tol=1e-5))])
grid_param = [{
'tfidf__min_df': [5, 10],
'tfidf__ngram_range': [(1, 3), (1, 6)],
'model__penalty': ['l2'],
'model__loss': ['hinge'],
'model__max_iter': [10000]
}, {
'tfidf__min_df': [5, 10],
'tfidf__ngram_range': [(1, 3), (1, 6)],
'model__C': [1, 10],
'model__tol': [1e-2, 1e-3]
}]
gridSearchProcessor = GridSearchCV(estimator=training_pipeline,
param_grid=grid_param,
cv=5)
gridSearchProcessor.fit(df['text'], df['Priority'])
best_params = gridSearchProcessor.best_params_
print("Best alpha parameter identified by grid search ", best_params)
best_result = gridSearchProcessor.best_score_
print("Best result identified by grid search ", best_result)
Out:
Best alpha parameter identified by grid search {'model__loss': 'hinge',
'model__max_iter': 10000, 'model__penalty': 'l2', 'tfidf__min_df': 10,
'tfidf__ngram_range': (1, 6)}
Best result identified by grid search 0.46390780513357777
我们评估了两个min_df和ngram_range的值,并使用两组不同的模型参数。在第一组中,我们尝试了 l2 model_penalty和 hinge model_loss,最多进行了 1,000 次迭代。在第二组中,我们尝试改变正则化参数C和模型的tolerance值。虽然我们之前看到了最佳模型的参数,但我们也可以检查生成的所有其他模型的性能,以了解参数值之间的相互作用。您可以查看前五个模型及其参数值如下:
gridsearch_results = pd.DataFrame(gridSearchProcessor.cv_results_)
gridsearch_results[['rank_test_score', 'mean_test_score',
'params']].sort_values(by=['rank_test_score'])[:5]
| rank_test_score | mean_test_score | params | |
|---|---|---|---|
| 3 | 1 | 0.46 | {'model__loss’: ‘hinge', ‘model__max_iter’: 10000, ‘model__penalty’: ‘l2', ‘tfidf__min_df’: 10, ‘tfidf__ngram_range’: (1, 6)} |
| 2 | 2 | 0.46 | {'model__loss’: ‘hinge', ‘model__max_iter’: 10000, ‘model__penalty’: ‘l2', ‘tfidf__min_df’: 10, ‘tfidf__ngram_range’: (1, 3)} |
| 0 | 3 | 0.46 | {'model__loss’: ‘hinge', ‘model__max_iter’: 10000, ‘model__penalty’: ‘l2', ‘tfidf__min_df’: 5, ‘tfidf__ngram_range’: (1, 3)} |
| 1 | 4 | 0.46 | {'model__loss’: ‘hinge', ‘model__max_iter’: 10000, ‘model__penalty’: ‘l2', ‘tfidf__min_df’: 5, ‘tfidf__ngram_range’: (1, 6)} |
| 5 | 5 | 0.45 | {'model__C’: 1, ‘model__tol’: 0.01, ‘tfidf__min_df’: 5, ‘tfidf__ngram_range’: (1, 6)} |
蓝图总结与结论
让我们通过将其应用于不同的分类任务来总结文本分类的蓝图步骤。如果您还记得,我们在本章开头提到,为了能够快速修复错误,我们必须确定错误的优先级,并将其分配给正确的团队。可以通过识别错误属于软件的哪个部分来自动执行分配。我们已经看到,错误报告有一个名为Component的功能,其中的值包括Core、UI和Doc。这有助于将错误分配给正确的团队或个人,从而加快解决速度。这项任务类似于确定错误优先级,并将帮助我们理解蓝图如何应用于任何其他应用程序。
我们用以下更改更新蓝图:
-
附加步骤,包括网格搜索以确定最佳超参数,并限制测试的选项数量以增加运行时
-
使用
sklearn.svm.SVC函数的额外选项来比较性能并尝试非线性核函数
# Flag that determines the choice of SVC and LinearSVC
runSVC = True
# Loading the DataFrame
df = pd.read_csv('eclipse_jdt.csv')
df = df[['Title', 'Description', 'Component']]
df = df.dropna()
df['text'] = df['Title'] + df['Description']
df = df.drop(columns=['Title', 'Description'])
# Step 1 - Data Preparation
df['text'] = df['text'].apply(clean)
df = df[df['text'].str.len() > 50]
if (runSVC):
# Sample the data when running SVC to ensure reasonable run-times
df = df.groupby('Component', as_index=False).apply(pd.DataFrame.sample,
random_state=21,
frac=.2)
# Step 2 - Train-Test Split
X_train, X_test, Y_train, Y_test = train_test_split(df['text'],
df['Component'],
test_size=0.2,
random_state=42,
stratify=df['Component'])
print('Size of Training Data ', X_train.shape[0])
print('Size of Test Data ', X_test.shape[0])
# Step 3 - Training the Machine Learning model
tfidf = TfidfVectorizer(stop_words="english")
if (runSVC):
model = SVC(random_state=42, probability=True)
grid_param = [{
'tfidf__min_df': [5, 10],
'tfidf__ngram_range': [(1, 3), (1, 6)],
'model__C': [1, 100],
'model__kernel': ['linear']
}]
else:
model = LinearSVC(random_state=42, tol=1e-5)
grid_param = {
'tfidf__min_df': [5, 10],
'tfidf__ngram_range': [(1, 3), (1, 6)],
'model__C': [1, 100],
'model__loss': ['hinge']
}
training_pipeline = Pipeline(
steps=[('tfidf', TfidfVectorizer(stop_words="english")), ('model', model)])
gridSearchProcessor = GridSearchCV(estimator=training_pipeline,
param_grid=grid_param,
cv=5)
gridSearchProcessor.fit(X_train, Y_train)
best_params = gridSearchProcessor.best_params_
print("Best alpha parameter identified by grid search ", best_params)
best_result = gridSearchProcessor.best_score_
print("Best result identified by grid search ", best_result)
best_model = gridSearchProcessor.best_estimator_
# Step 4 - Model Evaluation
Y_pred = best_model.predict(X_test)
print('Accuracy Score - ', accuracy_score(Y_test, Y_pred))
print(classification_report(Y_test, Y_pred))
输出:
Size of Training Data 7204
Size of Test Data 1801
Best alpha parameter identified by grid search {'model__C': 1,
'model__kernel': 'linear', 'tfidf__min_df': 5, 'tfidf__ngram_range': (1, 6)}
Best result identified by grid search 0.739867279666898
Accuracy Score - 0.7368128817323709
precision recall f1-score support
APT 1.00 0.25 0.40 16
Core 0.74 0.77 0.75 544
Debug 0.89 0.77 0.82 300
Doc 0.50 0.17 0.25 12
Text 0.61 0.45 0.52 235
UI 0.71 0.81 0.76 694
accuracy 0.74 1801
macro avg 0.74 0.54 0.58 1801
weighted avg 0.74 0.74 0.73 1801
基于准确性和分类报告,我们实现了 73%的准确性,我们可以得出结论,该模型能够更准确地预测软件组件所指的 Bug,而不是优先级。虽然部分改进归功于网格搜索和交叉验证的额外步骤,但大部分只是因为模型能够识别描述与其所指的组件之间的关系。组件功能并没有显示出我们之前注意到的同等级别的类不平衡问题。但是,即使在组件内部,我们也可以看到对 Doc 软件组件的差结果,该组件的观察数量较其他组件少。另外,与基线相比,我们可以看到这个模型在性能上有所提高。我们可以尝试平衡我们的数据,或者我们可以做出一个明智的商业决策,即模型更重要的是预测那些具有较多 Bug 的软件组件:
clf = DummyClassifier(strategy='most_frequent')
clf.fit(X_train, Y_train)
Y_pred_baseline = clf.predict(X_test)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred_baseline))
输出:
Accuracy Score - 0.38534147695724597
让我们也尝试了解这个模型如何进行预测,看看它在哪些方面表现良好,在哪些方面失败。我们首先将采样两个预测准确的观察结果:
# Create a DataFrame combining the Title and Description,
# Actual and Predicted values that we can explore
frame = { 'text': X_test, 'actual': Y_test, 'predicted': Y_pred }
result = pd.DataFrame(frame)
result[result['actual'] == result['predicted']].sample(2)
输出:
| 文本 | 实际 | 预测 | |
|---|---|---|---|
| 28225 | 移动静态初始化程序缺乏原子撤销。当移动方法时,可以使用单个撤销命令原子地撤销移动。但是当移动静态初始化程序时,只能在源文件和目标文件中发出 Undo 命令来撤销。 | UI | UI |
| 30592 | 断点命中时,调试视图窃取焦点 M5 - I20060217-1115 当您调试具有断点的程序时,当调试器命中断点时,按下 Ctrl+Sht+B 不会删除断点,即使该行看起来已经获得焦点。要实际删除断点,必须在编辑器中单击正确的行并重新按下键 | Debug | Debug |
我们可以看到,当将错误分类为 Debug 组件时,描述中使用了诸如debugger和breakpoint之类的术语,而当将错误分类为 UI 时,我们看到了Undo和movement的指示。这似乎表明训练好的模型能够学习描述中的单词与相应软件组件之间的关联。让我们也看看一些预测错误的观察结果:
result[result['actual'] != result['predicted']].sample(2)
输出:
| 文本 | 实际 | 预测 | |
|---|---|---|---|
| 16138 | @see 标签上的行包装创建了一个新的警告,即无效的参数声明。在启用了 javadoc 检查的 eclipse 3.0M5 中,行包装将导致警告 Javadoc: Invalid parameters declaration 这将导致警告: /** * @see com.xyz.util.monitoring.MonitoringObserver#monitorSetValue / 这样不会: /* * @see com.xyz.util.monitoring.MonitoringObserver#monitorSetValue * | 文本 | 核心 |
| 32903 | 创建字符串数组后,eclipse 无法识别对象的方法。在任何程序中键入这些行。 String abc = new String {a b c} System。在 System 后。eclipse 将不会列出所有可用的方法 | 核心 | UI |
在这里,要识别不正确分类的原因更加困难,但如果我们想要提高模型的准确性,我们必须进一步分析。在建立模型之后,我们必须调查我们的预测并理解为什么模型做出这些预测。有几种技术可以用来解释模型的预测,这将在第七章中更详细地讨论。
结语
本章中,我们提出了在构建监督文本分类模型的不同步骤中执行的蓝图。它始于数据准备步骤,包括如有必要的类平衡。然后我们展示了创建训练和测试分割的步骤,包括使用交叉验证作为到达模型准确度的首选技术。然后我们介绍了网格搜索作为验证不同超参数设置以找到最优组合的技术之一。监督学习是一个广泛的领域,有多种应用,如贷款违约预测、广告点击预测等。这个蓝图提供了一个端到端的技术,用于构建监督学习模型,并且也可以扩展到文本分类以外的问题。
进一步阅读
-
James Bergstra 和 Yoshua Bengio 的文章“Random Search for Hyper-Parameter Optimization.” 2012 年. http://www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf.
-
R. Berwick 的文章“An Idiot’s guide to Support Vector Machines.” http://web.mit.edu/6.034/wwwbob/svm-notes-long-08.pdf.
-
Ron Kohavi 的文章“A Study of CrossValidation and Bootstrap for Accuracy Estimation and Model Selection.” http://ai.stanford.edu/~ronnyk/accEst.pdf.
-
Sebastian Raschka 的文章“Model Evaluation, Model Selection, and Algorithm Selection in Machine Learning.” 2018 年. https://arxiv.org/pdf/1811.12808.pdf.
^(1) Nitesh Chawla 等人的文章“Synthetic Minority Over-Sampling Technique.” 人工智能研究杂志 16 (2002 年 6 月). https://arxiv.org/pdf/1106.1813.pdf.
第七章:如何解释文本分类器
在前几章中,我们已经学习了许多关于针对非结构化文本数据的高级分析方法。从统计学开始,使用自然语言处理,我们从文本中找到了有趣的见解。
使用监督方法进行分类,我们通过训练算法将文本文档分配到已知类别。虽然我们已经检查了分类过程的质量,但我们忽略了一个重要的方面:我们不知道模型为什么决定将一个类别分配给一个文本。
如果类别是正确的,这可能听起来不重要。然而,在日常生活中,您经常必须解释您自己的决定,并使它们对他人透明。对于机器学习算法也是如此。
在现实项目中,您很可能经常听到“为什么算法分配了这个类别/情绪?”的问题。甚至在此之前,了解算法是如何学习的将帮助您通过使用不同的算法、添加特征、更改权重等来改进分类。与结构化数据相比,对于文本来说,这个问题更为重要,因为人类可以解释文本本身。此外,文本有许多人为因素,比如电子邮件中的签名,最好避免这些因素,并确保它们不是分类中的主要特征。
除了技术视角之外,还有一些法律方面需要注意。您可能需要证明您的算法没有偏见或不歧视。欧盟的 GDPR 甚至要求对公共网站上做出决策(比如只允许某种支付方式)的算法进行证明。
最后但同样重要的是,信任需要信息。如果您尽可能地公开您的结果,您将大大增加某人对您的方法的信心和信任。
你将学到什么,我们将构建什么
在本章中,我们将介绍几种解释监督机器学习模型结果的方法。在可能的情况下,我们将建立在先前章节中的分类示例之上。
我们将从重新审视第六章中的错误报告的分类开始。一些报告被正确分类,一些没有。我们将退后一步,分析分类是否总是二进制决策。对于某些模型来说,它不是,我们将计算错误报告属于某个类别的概率,并与正确值(所谓的地 实)进行核对。
在接下来的部分中,我们将分析哪些特征决定了模型的决策。我们可以使用支持向量机来计算这一点。我们将尝试解释结果,并看看我们是否可以利用这些知识来改进方法。
之后,我们将采取更一般的方法,并介绍本地可解释的模型无关解释(LIME)。LIME(几乎)不依赖于特定的机器学习模型,可以解释许多算法的结果。
近年来人们在研究可解释人工智能方面投入了大量工作,并提出了一种更复杂的模型称为Anchor,我们将在本章的最后部分介绍它。
在学习了本章之后,您将了解到解释监督学习模型结果的不同方法。您将能够将这些方法应用于您自己的项目,并决定哪种方法最适合您的特定需求。您将能够解释结果并创建直观的可视化,以便非专家也能轻松理解。
蓝图:使用预测概率确定分类置信度
您可能还记得第六章中的例子,我们尝试根据其组件对缺陷报告进行分类。现在我们将使用在该章节中找到的最佳参数来训练支持向量机。其余的符号表示保持不变:
svc = SVC(kernel="linear", C=1, probability=True, random_state=42)
svc.fit(X_train_tf, Y_train)
如果您还记得分类报告,我们的平均精确度和召回率为 75%,因此分类效果相当不错。但也有一些情况下预测与实际值不同。现在我们将更详细地查看这些预测结果,以了解是否有可以用来区分“好”和“坏”预测的模式,而不查看实际结果,因为在真实的分类场景中这些将是未知的。
为此,我们将使用支持向量机模型的predict_proba函数,该函数告诉我们 SVM 的内部情况,即它对各个类别计算的概率(显然,预测本身的概率最高)。^(1) 作为参数,它期望一个由文档向量组成的矩阵。结果是不同类别的概率。作为第一步,我们将从预测结果构建一个DataFrame:
X_test_tf = tfidf.transform(X_test)
Y_pred = svc.predict(X_test_tf)
result = pd.DataFrame({ 'text': X_test.values, 'actual': Y_test.values,
'predicted': Y_pred })
让我们尝试使用测试数据集的一个文档,并假设我们想优化我们的分类,主要关注预测错误的情况:
result[result["actual"] != result["predicted"]].head()
Out:
| 文本 | 实际 | 预测 | |
|---|---|---|---|
| 2 | 在执行 JDT/UI 时 Delta 处理器中的 NPE... | Core | UI |
| 15 | 在编辑器中插入文本块时排版不佳... | UI | 文本 |
| 16 | 在调试相同对象时的差异... | Debug | Core |
| 20 | 模板中对类成员使用 Foreach 不起作用... | Core | UI |
| 21 | 交换比较运算符的左右操作数... | UI | Core |
文档 21 看起来是一个很好的候选。预测的类“Core”是错误的,但“left”和“right”听起来也像是 UI(这将是正确的)。让我们深入研究一下:
text = result.iloc[21]["text"]
print(text)
Out:
exchange left and right operands for comparison operators changes semantics
Fix for Bug 149803 was not good.; ; The right fix should do the following;
if --> if --> if ; if ; if
这看起来是更详细分析的一个不错的候选项,因为它包含了既可能是核心也可能是 UI 的词汇。也许如果我们查看概率,我们就能更详细地理解这一点。计算这个是相当容易的:
svc.predict_proba(X_test_tf[21])
输出:
array([[0.002669, 0.46736578, 0.07725225, 0.00319434, 0.06874877,
0.38076986]])
记住类别的顺序是 APT、核心、调试、文档、文本和 UI,该算法对核心的确信度比对 UI 高一些,而 UI 则是其次选择。
这种情况总是这样吗?我们将尝试找出答案,并计算测试数据集中所有文档的决策概率,并将其添加到一个 DataFrame 中:
class_names = ["APT", "Core", "Debug", "Doc", "Text", "UI"]
prob = svc.predict_proba(X_test_tf)
# new dataframe for explainable results
er = result.copy().reset_index()
for c in enumerate(class_names):
er[c] = prob[:, i]
让我们看看数据帧的一些样本,并找出是否在算法相当确信其决策(即,所选类别的概率远高于其他类别)的情况下预测更准确:
er[["actual", "predicted"] + class_names].sample(5, random_state=99)
输出:
| 实际 | 预测 | APT | 核心 | 调试 | 文档 | 文本 | UI | |
|---|---|---|---|---|---|---|---|---|
| 266 | UI | UI | 0.000598 | 0.000929 | 0.000476 | 0.001377 | 0.224473 | 0.772148 |
| 835 | 文本 | 文本 | 0.002083 | 0.032109 | 0.001481 | 0.002085 | 0.696666 | 0.265577 |
| 998 | 文本 | 文本 | 0.000356 | 0.026525 | 0.003425 | 0.000673 | 0.942136 | 0.026884 |
| 754 | 核心 | 文本 | 0.003862 | 0.334308 | 0.011312 | 0.015478 | 0.492112 | 0.142927 |
| 686 | UI | UI | 0.019319 | 0.099088 | 0.143744 | 0.082969 | 0.053174 | 0.601705 |
查看表格,只有一个错误的预测(754)。在这种情况下,算法相当“不确定”,并且以低于 50% 的概率决定了类别。我们能找到这种情况的模式吗?
让我们尝试构建两个 DataFrame,一个包含正确的预测,另一个包含错误的预测。然后,我们将分析最高概率的分布,看看是否能找到任何差异:
er['max_probability'] = er[class_names].max(axis=1)
correct = (er[er['actual'] == er['predicted']])
wrong = (er[er['actual'] != er['predicted']])
我们现在将其绘制成直方图:
correct["max_probability"].plot.hist(title="Correct")
wrong["max_probability"].plot.hist(title="Wrong")
输出:
我们可以看到,在正确预测的情况下,模型经常以高概率决定,而当决策错误时,概率明显较低。正如我们稍后将看到的那样,错误类别中的高概率小峰值是由于短文本或缺失单词造成的。
最后,我们将看看是否可以改进结果,如果我们只考虑已经做出概率超过 80% 决策的情况:
high = er[er["max_probability"] > 0.8]
print(classification_report(high["actual"], high["predicted"]))
输出:
precision recall f1-score support
APT 0.90 0.75 0.82 12
Core 0.94 0.89 0.92 264
Debug 0.94 0.99 0.96 202
Doc 1.00 0.67 0.80 3
Text 0.78 0.75 0.77 72
UI 0.90 0.92 0.91 342
accuracy 0.91 895
macro avg 0.91 0.83 0.86 895
weighted avg 0.91 0.91 0.91 895
将其与原始结果进行比较,如下所示:
print(classification_report(er["actual"], er["predicted"]))
输出:
precision recall f1-score support
APT 0.90 0.56 0.69 16
Core 0.76 0.77 0.76 546
Debug 0.90 0.78 0.84 302
Doc 1.00 0.25 0.40 12
Text 0.64 0.51 0.57 236
UI 0.72 0.82 0.77 699
accuracy 0.75 1811
macro avg 0.82 0.62 0.67 1811
weighted avg 0.75 0.75 0.75 1811
我们可以看到,在预测核心、调试、文本和用户界面组件的精度方面,我们有了显著的改进,同时也提高了召回率。这很棒,因为支持向量机(SVM)的解释使我们进入了一个数据子集,分类器在这里工作得更好。然而,在样本较少的组件(Apt、Doc)中,实际上只改善了召回率。看来这些类别中的样本太少了,算法基于文本的信息也太少,难以作出决策。在 Doc 的情况下,我们刚刚移除了大部分属于此类的文档,从而提高了召回率。
然而,改进是有代价的。我们排除了超过 900 份文件,大约是数据集的一半。因此,总体而言,我们在较小的数据集中实际上找到了更少的文档!在某些项目中,让模型仅在“确定”情况下做决策,并且丢弃模棱两可的情况(或手动分类),可能是有用的。这通常取决于业务需求。
在这一部分中,我们发现了预测概率与结果质量之间的相关性。但我们尚未理解模型如何预测(即使用哪些单词)。我们将在下一节中进行分析。
蓝图:测量预测模型的特征重要性
在本节中,我们希望找出哪些特征对模型找到正确类别是相关的。幸运的是,我们的 SVM 类可以告诉我们必要的参数(称为系数):
svc.coef_
输出:
<15x6403 sparse matrix of type '<class 'numpy.float64'>'
with 64451 stored elements in Compressed Sparse Row format>
6403 是词汇表的大小(检查len(tfidf.get_feature_names()),但是 15 是从哪里来的呢?这有点复杂。从技术上讲,系数组织成一个矩阵,每个类与其他类以一对一的方式竞争。由于我们有六个类别,并且类别不必与自身竞争,因此有 15 个组合(组合数 6 选 2)。这 15 个系数如表 7-1 所述组织。
表 7-1. 多类支持向量分类器的系数布局
| APT | 核心 | 调试 | Doc | 文本 | 用户界面 | |
|---|---|---|---|---|---|---|
| APT | 0 | 1 | 2 | 3 | 4 | |
| 核心 | 5 | 6 | 7 | 8 | ||
| 调试 | 9 | 10 | 11 | |||
| Doc | 12 | 13 | ||||
| 文本 | 14 | |||||
| 用户界面 |
系数结构取决于机器学习模型
如果您使用其他分类器,则系数可能具有完全不同的组织结构。即使对于 SVM,使用 SGDClassifier 创建的非线性模型也每类只有一个系数集。当我们讨论 ELI5 时,我们将看到一些示例。
应首先阅读行,因此如果我们想要了解模型如何区分 APT 和核心组件,我们应该查看系数的索引 0。然而,我们更感兴趣的是核心和 UI 的差异,因此我们取索引 8. 在第一步中,我们按照它们的值对系数进行排序,并保留词汇位置的索引:
# coef_[8] yields a matrix, A[0] converts to array and takes first row
coef = svc.coef_[8].A[0]
vocabulary_positions = coef.argsort()
vocabulary = tfidf.get_feature_names()
接下来,我们现在获取顶部的正负贡献:
top_words = 10
top_positive_coef = vocabulary_positions[-top_words:].tolist()
top_negative_coef = vocabulary_positions[:top_words].tolist()
然后,我们将这些聚合到一个 DataFrame 中,以便更容易地显示结果:
core_ui = pd.DataFrame([[vocabulary[c],
coef[c]] for c in top_positive_coef + top_negative_coef],
columns=["feature", "coefficient"]).sort_values("coefficient")
我们希望可视化系数的贡献,以便易于理解。正值偏好核心组件,负值偏好 UI,如图 7-1 所示。为此,我们使用以下方法:
core_ui.set_index("feature").plot.barh()
这些结果非常容易解释。SVM 模型很好地学习到,compiler 和 ast 这些词语是特定于核心组件的,而 wizard、ui 和 dialog 则用于识别 UI 组件中的错误。似乎在 UI 中更倾向于快速修复,这强调了核心的长期稳定性。
我们刚刚找到了整个 SVM 模型选择核心和 UI 之间的重要特征。但这并不表明哪些特征对于识别可以归类为核心的 bug 很重要。如果我们想要获取这些核心组件的特征,并考虑到先前的矩阵,我们需要索引 5、6、7 和 8. 采用这种策略,我们忽略了 APT 和核心之间的差异。要考虑到这一点,我们需要减去索引 0:
c = svc.coef_
coef = (c[5] + c[6] + c[7] + c[8] - c[0]).A[0]
vocabulary_positions = coef.argsort()
图 7-1. UI 的词语贡献(负)和核心的贡献(正)。
其余代码几乎与之前的代码相同。我们现在将图表扩展到 20 个词语(图 7-2):
top_words = 20
top_positive_coef = vocabulary_positions[-top_words:].tolist()
top_negative_coef = vocabulary_positions[:top_words].tolist()
core = pd.DataFrame([[vocabulary[c], coef[c]]
for c in top_positive_coef + top_negative_coef],
columns=["feature", "coefficient"]).\
sort_values("coefficient")
core.set_index("feature").plot.barh(figsize=(6, 10),
color=[['red']*top_words + ['green']*top_words])
在图表中,您可以看到模型用于识别核心组件的许多词语,以及主要用于识别其他组件的词语。
您可以使用本蓝图中描述的方法,使 SVM 模型的结果透明和可解释。在许多项目中,这已被证明非常有价值,因为它消除了机器学习的“魔力”和主观性。
这方法效果相当好,但我们还不知道模型对某些词语的变化有多敏感。这是一个更复杂的问题,我们将在下一节中尝试回答。
图 7-2. 偏好或反对核心组件的系数。
蓝图:使用 LIME 解释分类结果
LIME 是 “局部可解释模型无关解释” 的首字母缩写,是一个用于可解释机器学习的流行框架。它是在 华盛顿大学 构想的,并且在 GitHub 上 公开可用。
让我们看看 LIME 的定义特征。它通过单独查看每个预测局部地工作。通过修改输入向量以找到预测敏感的局部组件来实现这一点。
可解释性需要计算时间
运行解释器代码可能需要相当长的时间。我们尝试通过调整示例的方式,让您在普通计算机上等待时间不超过 10 分钟。但是,通过增加样本大小,这可能很容易需要几个小时。
从向量周围的行为来看,它将得出哪些组件更重要或不重要的结论。LIME 将可视化贡献,并解释算法对个别文档的决策机制。
LIME 不依赖于特定的机器学习模型,可以应用于多种问题。并非所有模型都符合条件;模型需要预测类别的概率。并非所有支持向量机模型都能做到这一点。此外,在像文本分析中常见的高维特征空间中使用复杂模型进行预测时间较长并不是很实际。由于 LIME 试图局部修改特征向量,因此需要执行大量预测,在这种情况下需要很长时间才能完成。
最后,LIME 将根据每个样本生成模型解释,并帮助您理解模型。您可以用它来改进模型,也可以用来解释分类的工作原理。虽然模型仍然是黑箱,但您将获得一些可能发生在箱子里的知识。
让我们回到前一节的分类问题,并尝试为几个样本找到 LIME 解释。由于 LIME 需要文本作为输入和分类概率作为输出,我们将向量化器和分类器安排在管道中:
from sklearn.pipeline import make_pipeline
pipeline = make_pipeline(tfidf, best_model)
如果我们给它一些文本,流水线应该能够进行预测,就像这里做的那样:
pipeline.predict_proba(["compiler not working"])
输出:
array([[0.00240522, 0.95605684, 0.00440957, 0.00100242, 0.00971824,
0.02640771]])
分类器建议将此置于类别 2 中的概率非常高,即核心。因此,我们的流水线正按照我们希望的方式运行:我们可以将文本文档作为参数传递给它,并返回文档属于每个类别的概率。现在是打开 LIME 的时候了,首先导入该包(您可能需要使用pip或conda先安装该包)。之后,我们将创建一个解释器,这是 LIME 的核心元素之一,负责解释单个预测:
from lime.lime_text import LimeTextExplainer
explainer = LimeTextExplainer(class_names=class_names)
我们检查DataFrame中错误预测的类别如下:
er[er["predicted"] != er["actual"]].head(5)
输出:
| 索引 | 文本 | 实际 | 预测 | APT | 核心 | 调试 | 文档 | 文本 | UI | |
|---|---|---|---|---|---|---|---|---|---|---|
| 2 | 2 | Delta 处理器中的 NPE 执行 JDT/UI ... | 核心 | UI | 0.003357 | 0.309548 | 0.046491 | 0.002031 | 0.012309 | 0.626265 |
| 15 | 15 | 在编辑器中插入文本块严重对齐不良... | UI | 文本 | 0.001576 | 0.063076 | 0.034610 | 0.003907 | 0.614473 | 0.282356 |
| 16 | 16 | 调试相同对象时的差异 W... | 调试 | 核心 | 0.002677 | 0.430862 | 0.313465 | 0.004193 | 0.055838 | 0.192965 |
| 20 | 20 | 模板的 foreach 对类成员不起作用... | 核心 | UI | 0.000880 | 0.044018 | 0.001019 | 0.000783 | 0.130766 | 0.822535 |
| 21 | 21 | 交换比较中左右操作数... | UI | 核心 | 0.002669 | 0.467366 | 0.077252 | 0.003194 | 0.068749 | 0.380770 |
看看相应记录(我们的情况下是第 21 行):
id = 21
print('Document id: %d' % id)
print('Predicted class =', er.iloc[id]["predicted"])
print('True class: %s' % er.iloc[id]["actual"])
Out:
Document id: 21
Predicted class = Core
True class: UI
现在是 LIME 向我们解释的时候了!
exp = explainer.explain_instance(result.iloc[id]["text"],
pipeline.predict_proba, num_features=10, labels=[1, 5])
print('Explanation for class %s' % class_names[1])
print('\n'.join(map(str, exp.as_list(label=1))))
print()
print('Explanation for class %s' % class_names[5])
print('\n'.join(map(str, exp.as_list(label=5))))
Out:
Explanation for class Core
('fix', -0.14306948642919184)
('Bug', 0.14077384623641856)
('following', 0.11150012169630388)
('comparison', 0.10122423126000728)
('Fix', -0.0884162779420967)
('right', 0.08315255286108318)
('semantics', 0.08143857054730141)
('changes', -0.079427782008582)
('left', 0.03188240169394561)
('good', -0.0027133756042246504)
Explanation for class UI
('fix', 0.15069083664026453)
('Bug', -0.14853911521141774)
('right', 0.11283930406785869)
('comparison', -0.10654654371478504)
('left', -0.10391669738035045)
('following', -0.1003931859632352)
('semantics', -0.056644426928774076)
('Fix', 0.05365037666619837)
('changes', 0.040806391076561165)
('good', 0.0401761761717476)
LIME 展示了哪些词语它认为对某个类别有利(正面)或不利(负面)。这与我们在 SVM 示例中实现的情况非常相似。更好的是,现在它独立于模型本身;它只需要支持predict_proba(这也适用于随机森林等)。
使用 LIME,您可以将分析扩展到更多类别,并创建它们特定词语的图形表示:
exp = explainer.explain_instance(result.iloc[id]["text"],
pipeline.predict_proba, num_features=6, top_labels=3)
exp.show_in_notebook(text=False)
Out:
这看起来很直观,更适合解释甚至包含在演示中。我们可以清楚地看到fix 和 right 对于分配 UI 类别至关重要,同时反对核心。然而,Bug 表示核心,正如comparison 和 semantics 一样。不幸的是,这不是人类接受作为分类规则的样子;它们似乎过于具体,没有抽象化。换句话说,我们的模型看起来过拟合。
改进模型
有了这些知识和熟悉票务的专家的经验,您可以改进模型。例如,我们可以询问Bug 是否真的特定于核心,或者我们最好将其作为停用词。把所有内容转换为小写可能也会证明有用。
LIME 甚至可以帮助您找到有助于全面解释模型性能的代表性样本。这个功能被称为子模块挑选,工作原理如下:
from lime import submodular_pick
import numpy as np
np.random.seed(42)
lsm = submodular_pick.SubmodularPick(explainer, er["text"].values,
pipeline.predict_proba,
sample_size=100,
num_features=20,
num_exps_desired=5)
个别“挑选”可以像之前笔记本中显示的那样进行可视化,并且现在更加完整,带有高亮显示。我们在这里只展示了第一个挑选:
lsm.explanations[0].show_in_notebook()
Out:
在以下情况下,我们可以解释结果,但它似乎并没有学习抽象化,这又是过拟合的迹象。
LIME 软件模块适用于 scikit-learn 中的线性支持向量机,但不适用于具有更复杂内核的支持向量机。图形化展示很好,但不直接适合演示。因此,我们将看看 ELI5,这是一种替代实现,试图克服这些问题。
蓝图:使用 ELI5 解释分类结果
ELI5(“Explain it to me like I’m 5”)是另一个流行的机器学习解释软件库,也使用 LIME 算法。由于它可用于非线性 SVM,并且具有不同的 API,我们将简要介绍它,并展示如何在我们的案例中使用它。
ELI5 需要一个使用libsvm训练过的模型,而我们之前的 SVC 模型不幸不是这样的。幸运的是,训练 SVM 非常快速,因此我们可以用相同的数据创建一个新的分类器,但使用基于libsvm的模型,并检查其性能。你可能还记得第六章中的分类报告,它提供了模型质量的良好总结:
from sklearn.linear_model import SGDClassifier
svm = SGDClassifier(loss='hinge', max_iter=1000, tol=1e-3, random_state=42)
svm.fit(X_train_tf, Y_train)
Y_pred_svm = svm.predict(X_test_tf)
print(classification_report(Y_test, Y_pred_svm))
Out:
precision recall f1-score support
APT 0.89 0.50 0.64 16
Core 0.77 0.78 0.77 546
Debug 0.85 0.84 0.85 302
Doc 0.75 0.25 0.38 12
Text 0.62 0.59 0.60 236
UI 0.76 0.79 0.78 699
accuracy 0.76 1811
macro avg 0.77 0.62 0.67 1811
weighted avg 0.76 0.76 0.76 1811
看看最后一行,这大致与我们使用 SVC 取得的效果一样好。因此,解释它是有意义的!使用 ELI5,找到这个模型的解释是很容易的:
import eli5
eli5.show_weights(svm, top=10, vec=tfidf, target_names=class_names)
正面特征(即词汇)显示为绿色。更浓烈的绿色意味着该词对应类别的贡献更大。红色则完全相反:出现在红色中的词汇会“排斥”类别(例如,第二行下部的“refactoring”强烈排斥Core类)。<BIAS>则是一个特例,包含所谓的截距,即模型的系统性失败。
如您所见,我们现在为各个类别获得了权重。这是由于非线性 SVM 模型在多类场景下与 SVC 不同的工作方式。每个类别都“打分”,没有竞争。乍一看,这些词看起来非常合理。
ELI5 还可以解释单个观察结果:
eli5.show_prediction(svm, X_test.iloc[21], vec=tfidf, target_names=class_names)
这是一个很好的可视化工具,用于理解哪些词汇对算法决定类别具有贡献。与原始的 LIME 软件包相比,使用 ELI5 需要的代码要少得多,你可以将 ELI5 用于非线性 SVM 模型。根据你的分类器和使用情况,你可能会选择 LIME 或 ELI5。由于使用了相同的方法,结果应该是可比较的(如果不是相同的)。
工作正在进行中
ELI5 仍在积极开发中,您可能会在新版本的 scikit-learn 中遇到困难。在本章中,我们使用了 ELI5 版本 0.10.1。
ELI5 是一个易于使用的软件库,用于理解和可视化分类器的决策逻辑,但它也受到底层 LIME 算法的缺点的影响,例如只能通过示例来解释的可解释性。为了使黑盒分类更透明,获得模型使用的“规则”将是有见地的。这是华盛顿大学团队创建后续项目 Anchor 的动机。
蓝图:使用 Anchor 解释分类结果
类似于 LIME,Anchor与任何黑盒模型都兼容。作为解释工具,它创建了规则,即所谓的锚点,用于解释模型的行为。阅读这些规则,你不仅能够解释模型的预测,还能以与模型学习相同的方式进行预测。
相较于 LIME,Anchor 在通过规则更好地解释模型方面具有显著优势。然而,软件本身还是比较新的,仍在不断完善中。并非所有示例对我们都适用,因此我们选择了一些有助于解释分类模型的方法。
使用带有屏蔽词的分布
Anchor 有多种使用方式。我们从所谓的未知分布开始。Anchor 将通过用词汇unknown替换预测中被认为不重要的现有标记,解释模型的决策方式。
再次,我们将使用 ID 为 21 的文档。在这种情况下,分类器需要在两个概率大致相同的类别之间进行选择,这对研究是一个有趣的示例。
为了在文本中创建(语义)差异,Anchor 使用 spaCy 的词向量,并需要包含这些向量的 spaCy 模型,例如en_core_web_lg。
因此,作为先决条件,您应该安装anchor-exp和spacy(使用conda或pip),并加载以下模型:
python -m spacy download en_core_web_lg
在第一步中,我们可以实例化我们的解释器。解释器具有一些概率元素,因此最好同时重新启动随机数生成器:
np.random.seed(42)
explainer_unk = anchor_text.AnchorText(nlp, class_names, \
use_unk_distribution=True)
让我们检查预测结果及其替代方案,并将其与真实情况进行比较。predicted_class_ids包含预测类的索引,按概率降序排列,因此元素 0 是预测值,元素 1 是其最接近的竞争者:
text = er.iloc[21]["text"]
actual = er.iloc[21]["actual"]
# we want the class with the highest probability and must invert the order
predicted_class_ids = np.argsort(pipeline.predict_proba([text])[0])[::-1]
pred = explainer_unk.class_names[predicted_class_ids[0]]
alternative = explainer_unk.class_names[predicted_class_ids[1]]
print(f'predicted {pred}, alternative {alternative}, actual {actual}')
Out:
predicted Core, alternative UI, actual UI
在下一步中,我们将让算法找出预测的规则。参数与之前的 LIME 相同:
exp_unk = explainer_unk.explain_instance(text, pipeline.predict, threshold=0.95)
计算时间取决于 CPU 的速度,可能需要高达 60 分钟。
现在一切都包含在解释器中,因此我们可以查询解释器,了解模型的内部工作情况:
print(f'Rule: {" AND ".join(exp_unk.names())}')
print(f'Precision: {exp_unk.precision()}')
Out:
Rule: following AND comparison AND Bug AND semantics AND for
Precision: 0.9865771812080537
因此,规则告诉我们,单词following和comparison与Bug和semantic的组合会导致“Core”预测,精度超过 98%,但不幸的是这是错误的。现在,我们还可以找到模型将其分类为 Core 的典型示例:
print(f'Made-up examples where anchor rule matches and model predicts {pred}\n')
print('\n'.join([x[0] for x in exp_unk.examples(only_same_prediction=True)]))
下面显示的 UNK 标记代表“未知”,意味着对应位置的词汇不重要:
Made-up examples where anchor rule matches and model predicts Core
UNK left UNK UNK UNK UNK comparison operators UNK semantics Fix for Bug UNK UNK
exchange left UNK UNK operands UNK comparison operators changes semantics Fix fo
exchange UNK and UNK operands UNK comparison UNK UNK semantics UNK for Bug UNK U
exchange UNK and right UNK for comparison UNK UNK semantics UNK for Bug 149803 U
UNK left UNK UNK operands UNK comparison UNK changes semantics UNK for Bug 14980
exchange left UNK right UNK UNK comparison UNK changes semantics Fix for Bug UNK
UNK UNK and right operands for comparison operators UNK semantics Fix for Bug 14
UNK left and right operands UNK comparison operators changes semantics UNK for B
exchange left UNK UNK operands UNK comparison operators UNK semantics UNK for Bu
UNK UNK UNK UNK operands for comparison operators changes semantics Fix for Bug
我们还可以要求提供符合规则但模型预测错误类的示例:
print(f'Made-up examples where anchor rule matches and model predicts \
{alternative}\n')
print('\n'.join([x[0] for x in exp_unk.examples(partial_index=0, \
only_different_prediction=True)]))
Out:
Made-up examples where anchor rule matches and model predicts UI
exchange left and right UNK for UNK UNK UNK UNK Fix for UNK 149803 was not UNK .
exchange left UNK UNK UNK for UNK UNK UNK semantics Fix for Bug 149803 UNK not U
exchange left UNK UNK operands for comparison operators UNK UNK Fix UNK Bug 1498
exchange left UNK right operands UNK comparison UNK UNK UNK Fix for UNK UNK UNK
exchange left and right operands UNK UNK operators UNK UNK Fix UNK UNK UNK UNK U
UNK UNK and UNK UNK UNK comparison UNK UNK UNK Fix for UNK UNK was not good UNK
exchange left and UNK UNK UNK UNK operators UNK UNK Fix UNK Bug 149803 was not U
exchange left and right UNK UNK UNK operators UNK UNK UNK for Bug 149803 UNK UNK
exchange left UNK right UNK for UNK operators changes UNK Fix UNK UNK UNK was no
UNK left UNK UNK operands UNK UNK operators changes UNK UNK for UNK 149803 was n
老实说,这对模型来说并不是一个好结果。我们本来期望模型学习的底层规则会对不同组件特定的单词比较敏感。然而,并没有明显的理由可以解释为什么following和Bug会特定于核心。这些都是一些通用词汇,不太具有任何类别的特征。
UNK 令牌有点误导。即使它们在这个样本中并不重要,它们可能被其他真实的单词替换,这些单词会影响算法的决策。Anchor 也可以帮助我们说明这一点。
使用真实词语进行工作
通过在解释器的原始构造函数中替换use_unk_distribution=False,我们可以告诉 Anchor 使用真实词语(类似于使用 spaCy 的词向量替换)并观察模型的行为:
np.random.seed(42)
explainer_no_unk = anchor_text.AnchorText(nlp, class_names,
use_unk_distribution=False, use_bert=False)
exp_no_unk = explainer_no_unk.explain_instance(text, pipeline.predict,
threshold=0.95)
print(f'Rule: {" AND ".join(exp_no_unk.names())}')
print(f'Precision: {exp_no_unk.precision()}')
输出:
Rule: following AND Bug AND comparison AND semantics AND left AND right
Precision: 0.9601990049751243
这些规则与之前未知的分布有些不同。似乎有些单词变得更加特定于核心,如left和right,而其他词语如for则消失了。
让我们还让 Anchor 生成一些替代文本,这些文本也会(错误地)被分类为核心,因为前面的规则也适用:
Examples where anchor applies and model predicts Core:
exchange left and right suffixes for comparison operators affects semantics NEED
exchange left and right operands for comparison operators depends semantics UPDA
exchange left and right operands for comparison operators indicates semantics so
exchange left and right operands for comparison operators changes semantics Firm
exchange left and right operands into comparison dispatchers changes semantics F
exchange left and right operands with comparison operators changes semantics Fix
exchange left and right operands beyond comparison operators changes semantics M
exchange left and right operands though comparison representatives changes seman
exchange left and right operands before comparison operators depends semantics M
exchange left and right operands as comparison operators changes semantics THING
一些单词已经改变,并且并没有影响分类结果。在某些情况下,只是介词,通常情况下这不会影响结果。然而,operators也可以被dispatchers替换而不会影响结果。Anchor 向您展示它对这些修改是稳定的。
将先前的结果与模型正确预测“UI”的结果进行比较。同样,这种差异影响单词如changes、metaphors等,这些单词显然比前一个例子中的较小修改更具意义,但很难想象你作为一个人类会将这些词解释为不同类别的信号:
Examples where anchor applies and model predicts UI:
exchange left and good operands for comparison operators changes metaphors Fix i
exchange landed and right operands for comparison supervisors changes derivation
exchange left and happy operands for correlation operators changes equivalences
exchange left and right operands for scenario operators changes paradigms Fix be
exchange left and right operands for trade customers occurs semantics Fix as BoT
exchange did and right operands than consumer operators changes analogies Instal
exchange left and few operands for reason operators depends semantics Fix for Bu
exchange left and right operands for percentage operators changes semantics MESS
exchange left and right pathnames after comparison operators depends fallacies F
exchange left and right operands of selection operators changes descriptors Fix
Anchor 还有一种直观的方式在笔记本中显示结果,重要的单词会被突出显示,同时还包括它计算出的规则:^(2)
exp_unk.show_in_notebook()
输出:
由于你很可能也熟悉软件开发,单靠规则很难确定正确的类别。换句话说,这意味着当模型使用语料库训练时,模型似乎是相当脆弱的。只有那些具有大量背景知识的项目贡献者才能可能真正确定“正确”的类别(我们稍后将在第十一章回顾)。因此,发现分类器有效并不一定意味着它真正学习的方式对我们是透明的。
总结本节,Anchor 很有趣。Anchor 的作者选择版本号 0.0.1 并非偶然;该程序仍处于起步阶段。在我们的实验中,我们看到了一些小问题,要使其在生产环境中运行,还需要改进许多事情。但从概念上来说,它已经非常令人信服,可以用于解释单个预测并使模型透明化。特别是计算出的规则几乎是独一无二的,任何其他解决方案都无法创建。
结语
使用本章介绍的技术将有助于使您的模型预测更加透明。
从技术角度来看,这种透明性可以极大地帮助您在选择竞争模型或改进特征模型时提供支持。本章介绍的技术能够深入了解模型的“内部运作”,有助于检测和改进不可信的模型。
从商业角度来看,可解释性对项目来说是一个很好的销售主张。如果不只是追求黑盒模型,而是使模型透明化,那么在谈论模型并展示它们时会更容易。最近的文章在福布斯和VentureBeat上已经专注于这一有趣的发展。当您想要构建可信的机器学习解决方案时,“信任”模型将变得越来越重要。
可解释人工智能是一个年轻的领域。我们可以预期未来会看到巨大的进步,更好的算法和改进的工具。
大多数情况下,机器学习方法都很好地作为黑盒模型运行。只要结果一致,我们就不需要为模型辩护,这样也挺好。但如果其中任何一个受到质疑(这种情况变得越来越普遍),那么可解释人工智能的时代就已经到来了。
^(1) 从图形上来看,您可以将这些概率视为样本到由 SVM 定义的超平面的距离。
^(2) 我们在让此工作起来时遇到了一些困难,因为它只适用于数值类别。我们计划提交一些拉取请求,以使上游也适用于文本类别。
第八章:无监督方法:主题建模和聚类
当处理大量文档时,您想要在不阅读所有文档的情况下首先问的问题之一是“它们在谈论什么?”您对文档的主题感兴趣,即文档中经常一起使用的(理想情况下是语义的)单词。
主题建模试图通过使用统计技术从文档语料库中找出主题来解决这个挑战。根据您的向量化(见第五章),您可能会发现不同类型的主题。主题由特征(单词、n-gram 等)的概率分布组成。
主题通常彼此重叠;它们并不明确分开。文档也是如此:不可能将文档唯一地分配给一个主题;文档始终包含不同主题的混合体。主题建模的目的不是将主题分配给任意文档,而是找出语料库的全局结构。
通常,一组文档具有由类别、关键词等确定的显式结构。如果我们想要查看语料库的有机构成,那么主题建模将对揭示潜在结构有很大帮助。
主题建模已经被人们熟知很长一段时间,并在过去的 15 年中获得了巨大的流行,这主要归因于 LDA(一种用于发现主题的随机方法)的发明。LDA 灵活多变,允许进行许多修改。然而,它并不是主题建模的唯一方法(尽管通过文献,您可能会认为它是唯一的,因为很多文献都倾向于 LDA)。概念上更简单的方法包括非负矩阵分解、奇异值分解(有时称为 LSI)等。
您将学到什么以及我们将构建什么
在这一章中,我们将深入研究各种主题建模方法,试图找到这些方法之间的差异和相似之处,并在同一个用例上运行它们。根据您的需求,尝试单一方法可能是个不错的主意,但比较几种方法的结果也是一个好选择。
在学习了本章后,您将了解到不同的主题建模方法及其特定的优缺点。您将了解到主题建模不仅可以用于发现主题,还可以用于快速创建文档语料库的摘要。您将学会选择正确的实体粒度来计算主题模型的重要性。您已经通过许多参数实验找到了最佳的主题模型。您可以通过数量方法和数据来评判生成的主题模型的质量。
我们的数据集:联合国大会辩论
我们的用例是语义分析联合国大会辩论语料库。您可能从早期关于文本统计的章节了解过这个数据集。
这一次,我们更感兴趣的是演讲的含义和语义内容,以及我们如何将它们按主题排列。我们想知道演讲者在谈论什么,并回答这样的问题:文档语料库中是否有结构?有哪些主题?哪一个最突出?这种情况随时间而变化吗?
检查语料库的统计数据
在开始主题建模之前,检查底层文本语料库的统计数据总是一个好主意。根据此分析的结果,您通常会选择分析不同的实体,例如文档、部分文本或段落。
我们对作者和其他信息不是很感兴趣,因此只需处理提供的一个 CSV 文件即可:
import pandas as pd
debates = pd.read_csv("un-general-debates.csv")
debates.info()
输出:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 7507 entries, 0 to 7506
Data columns (total 4 columns):
session 7507 non-null int64
year 7507 non-null int64
country 7507 non-null object
text 7507 non-null object
dtypes: int64(2), object(2)
memory usage: 234.7+ KB
结果看起来不错。文本列中没有空值;我们可能稍后会使用年份和国家,它们也只有非空值。
演讲非常长,涵盖了许多主题,因为每个国家每年只能发表一次演讲。演讲的不同部分几乎总是由段落分隔。不幸的是,数据集存在一些格式问题。比较两篇选定演讲的文本:
print(repr(df.iloc[2666]["text"][0:200]))
print(repr(df.iloc[4729]["text"][0:200]))
输出:
'\ufeffIt is indeed a pleasure for me and the members of my delegation to
extend to Ambassador Garba our sincere congratulations on his election to the
presidency of the forty-fourth session of the General '
'\ufeffOn behalf of the State of Kuwait, it\ngives me pleasure to congratulate
Mr. Han Seung-soo,\nand his friendly country, the Republic of Korea, on
his\nelection as President of the fifty-sixth session of t'
正如您所见,在一些演讲中,换行符用于分隔段落。在其他演讲的转录中,换行符用于分隔行。因此,为了恢复段落,我们不能只是在换行符处拆分。事实证明,在行尾出现的句号、感叹号或问号处拆分效果也很好。我们忽略停止后的空格:
import re
df["paragraphs"] = df["text"].map(lambda text: re.split('[.?!]\s*\n', text))
df["number_of_paragraphs"] = df["paragraphs"].map(len)
根据 第二章 中的分析,我们已经知道每年的演讲数量变化不大。段落数量也是这样吗?
%matplotlib inline
debates.groupby('year').agg({'number_of_paragraphs': 'mean'}).plot.bar()
输出:
段落平均数量随时间显著减少。我们本应该预期,随着每年演讲者人数的增加和演讲总时间的限制。
除此之外,统计分析显示数据集没有系统性问题。语料库仍然很新;任何一年都没有缺失数据。我们现在可以安全地开始揭示潜在结构并检测主题。
准备工作
主题建模是一种机器学习方法,需要矢量化数据。所有主题建模方法都从文档-术语矩阵开始。回顾这个矩阵的含义(它在 第四章 中介绍过),其元素是对应文档(行)中单词(列)的词频(或经常作为 TF-IDF 权重进行缩放)。该矩阵是稀疏的,因为大多数文档只包含词汇的一小部分。
让我们计算演讲和演讲段落的 TF-IDF 矩阵。首先,我们需要从 scikit-learn 中导入必要的包。我们从一个简单的方法开始,使用标准的 spaCy 停用词:
from sklearn.feature_extraction.text import TfidfVectorizer
from spacy.lang.en.stop_words import STOP_WORDS as stopwords
计算演讲的文档-术语矩阵很容易;我们还包括二元组:
tfidf_text = TfidfVectorizer(stop_words=stopwords, min_df=5, max_df=0.7)
vectors_text = tfidf_text.fit_transform(debates['text'])
vectors_text.shape
输出:
(7507, 24611)
对于段落来说,稍微复杂一些,因为我们首先必须展平列表。在同一步骤中,我们省略空段落:
# flatten the paragraphs keeping the years
paragraph_df = pd.DataFrame([{ "text": paragraph, "year": year }
for paragraphs, year in \
zip(df["paragraphs"], df["year"])
for paragraph in paragraphs if paragraph])
tfidf_para_vectorizer = TfidfVectorizer(stop_words=stopwords, min_df=5,
max_df=0.7)
tfidf_para_vectors = tfidf_para_vectorizer.fit_transform(paragraph_df["text"])
tfidf_para_vectors.shape
输出:
(282210, 25165)
当然,段落矩阵的行数要多得多。列数(单词数)也不同,因为 min_df 和 max_df 在选择特征时有影响,文档的数量也已经改变。
非负矩阵分解(NMF)
在文档语料库中找到潜在结构的概念上最简单的方法是对文档-术语矩阵进行因子分解。幸运的是,文档-术语矩阵只有正值元素;因此,我们可以使用线性代数中允许我们表示矩阵为两个其他非负矩阵的乘积的方法。按照惯例,原始矩阵称为 V,而因子是 W 和 H:
V ≈ W · H
或者我们可以以图形方式表示它(可视化进行矩阵乘法所需的维度),如图 8-1 所示。
根据维度的不同,可以执行精确的因子分解。但由于这样做计算成本更高,近似因子分解已经足够。
图 8-1. 概要的非负矩阵分解;原始矩阵 V 被分解为 W 和 H。
在文本分析的背景下,W 和 H 都有一个解释。矩阵 W 的行数与文档-术语矩阵相同,因此将文档映射到主题(文档-主题矩阵)。H 的列数与特征数相同,因此显示了主题由特征构成的方式(主题-特征矩阵)。主题的数量(W 的列数和 H 的行数)可以任意选择。这个数字越小,因子分解的精确度就越低。
蓝图:使用 NMF 创建文档的主题模型
在 scikit-learn 中为演讲执行此分解真的很容易。由于(几乎)所有主题模型都需要主题数量作为参数,我们任意选择了 10 个主题(后来证明这是一个很好的选择):
from sklearn.decomposition import NMF
nmf_text_model = NMF(n_components=10, random_state=42)
W_text_matrix = nmf_text_model.fit_transform(tfidf_text_vectors)
H_text_matrix = nmf_text_model.components_
与 TfidfVectorizer 类似,NMF 也有一个 fit_transform 方法,返回其中一个正因子矩阵。可以通过 NMF 类的 components_ 成员变量访问另一个因子。
主题是单词分布。我们现在将分析这个分布,看看我们是否可以找到主题的解释。看看图 8-1,我们需要考虑 H 矩阵,并找到每行(主题)中最大值的索引,然后将其用作词汇表中的查找索引。因为这对所有主题模型都有帮助,我们定义一个输出摘要的函数:
def display_topics(model, features, no_top_words=5):
for topic, word_vector in enumerate(model.components_):
total = word_vector.sum()
largest = word_vector.argsort()[::-1] # invert sort order
print("\nTopic %02d" % topic)
for i in range(0, no_top_words):
print(" %s (%2.2f)" % (features[largest[i]],
word_vector[largest[i]]*100.0/total))
调用此函数,我们可以得到 NMF 在演讲中检测到的主题的良好总结(数字是单词对各自主题的百分比贡献):
display_topics(nmf_text_model, tfidf_text_vectorizer.get_feature_names())
输出:
| 主题 00 co (0.79)
操作 (0.65)
裁军 (0.36)
核 (0.34)
关系 (0.25) | 主题 01 恐怖主义 (0.38)
挑战 (0.32)
可持续 (0.30)
千年 (0.29)
改革 (0.28) | 主题 02 非洲 (1.15)
非洲 (0.82)
南 (0.63)
纳米比亚 (0.36)
代表团 (0.30) | 主题 03 阿拉伯 (1.02)
以色列 (0.89)
巴勒斯坦的 (0.60)
黎巴嫩 (0.54)
以色列的 (0.54) | 主题 04 美国的 (0.33)
美国 (0.31)
拉丁 (0.31)
巴拿马 (0.21)
玻利维亚 (0.21) |
| 主题 05 太平洋 (1.55)
岛屿 (1.23)
索罗门 (0.86)
岛屿 (0.82)
斐济 (0.71) | 主题 06 苏联 (0.81)
共和国 (0.78)
核 (0.68)
越南 (0.64)
社会主义 (0.63) | 主题 07 几内亚 (4.26)
赤道 (1.75)
比绍 (1.53)
巴布亚 (1.47)
共和国 (0.57) | 主题 08 欧洲 (0.61)
欧洲 (0.44)
合作 (0.39)
波斯尼亚 (0.34)
赫尔采哥维纳 (0.30) | 主题 09 加勒比 (0.98)
小 (0.66)
巴哈马 (0.63)
圣 (0.63)
巴巴多斯 (0.61) |
主题 00 和 主题 01 看起来非常有前景,因为人们正在讨论核裁军和恐怖主义。这些确实是联合国大会辩论中的真实主题。
然而,后续主题或多或少集中在世界不同地区。这是因为演讲者主要提到自己的国家和邻国。这在主题 03 中特别明显,反映了中东的冲突。
查看单词在主题中的贡献百分比也很有趣。由于单词数量众多,单个贡献相当小,除了主题 07 中的几内亚。正如我们后面将看到的,单词在主题内的百分比是主题模型质量的一个很好的指标。如果主题内的百分比迅速下降,则表明该主题定义良好,而缓慢下降的单词概率表明主题不太明显。直觉上找出主题分离得有多好要困难得多;我们稍后将进行审视。
发现“大”主题有多大将会很有趣,即每个主题主要可以分配给多少篇文档。可以通过文档-主题矩阵计算,并对所有文档中的各个主题贡献求和来计算这一点。将它们与总和归一化,并乘以 100 给出一个百分比值:
W_text_matrix.sum(axis=0)/W_text_matrix.sum()*100.0
输出:
array([11.13926287, 17.07197914, 13.64509781, 10.18184685, 11.43081404,
5.94072639, 7.89602474, 4.17282682, 11.83871081, 6.68271054])
我们可以清楚地看到,有较小和较大的主题,但基本上没有离群值。具有均匀分布是质量指标。例如,如果你的主题模型中有一两个大主题与其他所有主题相比,你可能需要调整主题数量。
在接下来的部分,我们将使用演讲段落作为主题建模的实体,并尝试找出是否改进了主题。
蓝图:使用 NMF 创建段落的主题模型
在联合国的一般辩论中,以及许多其他文本中,不同的主题通常会混合,这使得主题建模算法难以找到个别演讲的共同主题。特别是在较长的文本中,文档往往涵盖多个而不仅仅是一个主题。我们如何处理这种情况?一种想法是在文档中找到更具主题一致性的较小实体。
在我们的语料库中,段落是演讲的自然分割,我们可以假设演讲者在一个段落内试图坚持一个主题。在许多文档中,段落是一个很好的候选对象(如果可以识别),我们已经准备好了相应的 TF-IDF 向量。让我们尝试计算它们的主题模型:
nmf_para_model = NMF(n_components=10, random_state=42)
W_para_matrix = nmf_para_model.fit_transform(tfidf_para_vectors)
H_para_matrix = nmf_para_model.components_
我们之前开发的display_topics函数可以用来找到主题的内容:
display_topics(nmf_para_model, tfidf_para_vectorizer.get_feature_names())
Out:
| 主题 00 国家 (5.63)
united (5.52)
组织 (1.27)
州 (1.03)
宪章 (0.93) | 主题 01 总的 (2.87)
会议 (2.83)
大会 (2.81)
先生 (1.98)
主席 (1.81) | 主题 02 国家 (4.44)
发展 (2.49)
经济 (1.49)
发展 (1.35)
贸易 (0.92) | 主题 03 人民 (1.36)
和平 (1.34)
东 (1.28)
中 (1.17)
巴勒斯坦 (1.14) | 主题 04 核 (4.93)
武器 (3.27)
裁军 (2.01)
条约 (1.70)
扩散 (1.46) |
| 主题 05 权利 (6.49)
人类 (6.18)
尊重 (1.15)
基础 (0.86)
全球 (0.82) | 主题 06 非洲 (3.83)
南 (3.32)
非洲 (1.70)
纳米比亚 (1.38)
种族隔离 (1.19) | 主题 07 安全 (6.13)
理事会 (5.88)
永久 (1.50)
改革 (1.48)
和平 (1.30) | 主题 08 国际 (2.05)
世界 (1.50)
共同体 (0.92)
新 (0.77)
和平 (0.67) | 主题 09 发展 (4.47)
可持续 (1.18)
经济 (1.07)
社会 (1.00)
目标 (0.93) |
与以前用于演讲主题建模的结果相比,我们几乎失去了所有国家或地区,除了南非和中东地区。这些都是由于引发了世界其他地区兴趣的地区冲突。段落中的主题如“人权”,“国际关系”,“发展中国家”,“核武器”,“安理会”,“世界和平”和“可持续发展”(最后一个可能只是最近才出现)与演讲的主题相比显得更加合理。观察单词的百分比值,我们可以看到它们下降得更快,主题更加显著。
潜在语义分析/索引
另一种执行主题建模的算法是基于所谓的奇异值分解(SVD),这是线性代数中的另一种方法。
从图形上看,我们可以将奇异值分解(SVD)视为以一种方式重新排列文档和单词,以揭示文档-词矩阵中的块结构。在topicmodels.info有一个这个过程的良好可视化。图 8-2 显示了文档-词矩阵的开始和最终的块对角形式。
利用主轴定理,正交 n × n 矩阵有一个特征值分解。不幸的是,我们没有正交的方形文档-词矩阵(除了少数情况)。因此,我们需要一种称为奇异值分解的泛化。在其最一般的形式中,该定理表明任何 m × n 矩阵**V **都可以分解如下:
V = U · Σ · V *
图 8-2. 使用 SVD 进行主题建模的可视化。
U 是一个单位 m × m 矩阵,V* 是一个 n × n 矩阵,Σ 是一个 m × n 对角矩阵,其中包含奇异值。对于这个方程,有确切的解,但是由于它们需要大量的时间和计算工作来找到,所以我们正在寻找可以快速找到的近似解。这个近似方法仅考虑最大的奇异值。这导致Σ成为一个 t × t 矩阵;相应地,U有 m × t 和 V* 有 t × n 的维度。从图形上看,这类似于非负矩阵分解,如图 8-3 所示。
图 8-3. 示意奇异值分解。
奇异值是Σ的对角元素。文档-主题关系包含在U中,而词-主题映射由V**表示。注意,U的元素和V**的元素都不能保证是正的。贡献的相对大小仍然是可解释的,但概率解释不再有效。
蓝图:使用 SVD 为段落创建主题模型
在 scikit-learn 中,SVD 的接口与 NMF 的接口相同。这次我们直接从段落开始:
from sklearn.decomposition import TruncatedSVD
svd_para_model = TruncatedSVD(n_components = 10, random_state=42)
W_svd_para_matrix = svd_para_model.fit_transform(tfidf_para_vectors)
H_svd_para_matrix = svd_para_model.components_
我们之前定义的用于评估主题模型的函数也可以使用:
display_topics(svd_para_model, tfidf_para_vectorizer.get_feature_names())
输出:
| 主题 00 国家(0.67)
联合(0.65)
国际(0.58)
和平(0.46)
世界(0.46) | 主题 01 一般(14.04)
装配(13.09)
会话(12.94)
先生(10.02)
总统(8.59) | 主题 02 国家(19.15)
发展(14.61)
经济(13.91)
发展中(13.00)
会议(10.29) | 主题 03 国家(4.41)
联合(4.06)
发展(0.95)
组织(0.84)
宪章(0.80) | 主题 04 核(21.13)
武器(14.01)
裁军(9.02)
条约(7.23)
扩散(6.31) |
| 主题 05 权利(29.50)
人类(28.81)
核(9.20)
武器(6.42)
尊重(4.98) | 主题 06 非洲(8.73)
南方(8.24)
联合(3.91)
非洲(3.71)
国家(3.41) | 主题 07 理事会(14.96)
安全(13.38)
非洲(8.50)
南方(6.11)
非洲(3.94) | 主题 08 世界(48.49)
国际(41.03)
和平(32.98)
社区(23.27)
非洲(22.00) | 主题 09 发展(63.98)
可持续(20.78)
和平(20.74)
目标(15.92)
非洲(15.61) |
大多数生成的主题与非负矩阵分解的主题非常相似。然而,中东冲突这一主题这次没有单独出现。由于主题-词映射也可能具有负值,因此归一化从主题到主题有所不同。只有构成主题的单词的相对大小才是相关的。
不用担心负百分比。这是因为 SVD 不保证 W 中的值为正,因此个别单词的贡献可能为负。这意味着出现在文档中的单词“排斥”相应的主题。
如果我们想确定主题的大小,现在就要查看分解的奇异值:
svd_para.singular_values_
Out:
array([68.21400653, 39.20120165, 36.36831431, 33.44682727, 31.76183677,
30.59557993, 29.14061799, 27.40264054, 26.85684195, 25.90408013])
主题的大小与 NMF 方法的段落相当相符。
NMF 和 SVF 都使用了文档-词矩阵(应用了 TF-IDF 转换)作为主题分解的基础。此外,U矩阵的维度与W的维度相同;V和H也是如此。因此,这两种方法产生类似且可比较的结果并不奇怪。由于这些方法计算速度很快,因此我们建议在实际项目中首先使用线性代数方法。
现在我们将摆脱这些基于线性代数的方法,专注于概率主题模型,在过去 20 年中已经变得极为流行。
潜在狄利克雷分配
LDA 可以说是当今使用最广泛的主题建模方法。它在过去 15 年间变得流行,并且可以灵活地适应不同的使用场景。
它是如何工作的?
LDA 将每个文档视为包含不同主题。换句话说,每个文档是不同主题的混合。同样,主题是从词中混合而来。为了保持每个文档中主题数量的少而且只包含一些重要词语,LDA 最初使用狄利克雷分布,即所谓的狄利克雷先验。这一分布用于为文档分配主题和为主题找到单词。狄利克雷分布确保文档只有少量主题,并且主题主要由少量单词定义。假设 LDA 生成了像之前那样的主题分布,一个主题可能由诸如核、条约和裁军等词汇构成,而另一个主题则由可持续、发展等词汇组成。
在初始分配之后,生成过程开始。它使用主题和单词的狄利克雷分布,并尝试用随机抽样重新创建原始文档中的单词。这个过程必须多次迭代,因此计算量很大。^(2) 另一方面,结果可以用来为任何确定的主题生成文档。
蓝图:使用 LDA 为段落创建主题模型
Scikit-learn 隐藏了所有这些差异,并使用与其他主题建模方法相同的 API:
from sklearn.feature_extraction.text import CountVectorizer
count_para_vectorizer = CountVectorizer(stop_words=stopwords, min_df=5,
max_df=0.7)
count_para_vectors = count_para_vectorizer.fit_transform(paragraph_df["text"])
from sklearn.decomposition import LatentDirichletAllocation
lda_para_model = LatentDirichletAllocation(n_components = 10, random_state=42)
W_lda_para_matrix = lda_para_model.fit_transform(count_para_vectors)
H_lda_para_matrix = lda_para_model.components_
等待时间
由于概率抽样的原因,该过程比 NMF 和 SVD 需要更长时间。期望至少分钟,甚至小时的运行时间。
我们的效用函数可以再次用于可视化段落语料库的潜在主题:
display_topics(lda_para_model, tfidf_para.get_feature_names())
Out:
| 主题 00 非洲(2.38)
人们(1.86)
南方(1.57)
纳米比亚(0.88)
政权(0.75)| 主题 01 共和国(1.52)
政府(1.39)
联合(1.21)
和平(1.16)
人民(1.02)| 主题 02 普通(4.22)
大会(3.63)
会议(3.38)
总统(2.33)
先生(2.32)| 主题 03 人类(3.62)
权利(3.48)
国际(1.83)
法律(1.01)
恐怖主义(0.99)| 主题 04 世界(2.22)
人们(1.14)
国家(0.94)
年(0.88)
今天(0.66)|
| 主题 05 和平(1.76)
安全(1.63)
东方(1.34)
中间(1.34)
以色列(1.24)| 主题 06 国家(3.19)
发展(2.70)
经济(2.22)
发展(1.61)
国际(1.45)| 主题 07 核(3.14)
武器(2.32)
裁军(1.82)
国家(1.47)
军备(1.46)| 主题 08 国家(5.50)
联合(5.11)
国际(1.46)
安全(1.45)
组织(1.44)| 主题 09 国际(1.96)
世界(1.91)
和平(1.60)
经济(1.00)
关系(0.99)|
有趣的是观察到,与前述的线性代数方法相比,LDA 生成了完全不同的主题结构。人们是三个完全不同主题中最突出的词。在主题 04 中,南非与以色列和巴勒斯坦有关联,而在主题 00 中,塞浦路斯、阿富汗和伊拉克有关联。这不容易解释。这也反映在主题的逐渐减少的单词权重中。
其他主题更容易理解,比如气候变化、核武器、选举、发展中国家和组织问题。
在这个例子中,LDA 的结果并不比 NMF 或 SVD 好多少。然而,由于抽样过程,LDA 并不仅限于样本主题仅仅由单词组成。还有几种变体,比如作者-主题模型,也可以抽样分类特征。此外,由于在 LDA 领域有很多研究,其他想法也经常被发表,这些想法大大超出了文本分析的焦点(例如,见 Minghui Qiu 等人的 “不仅仅是我们说了什么,而是我们如何说它们:基于 LDA 的行为-主题模型” 或 Rahji Abdurehman 的 “关键词辅助 LDA:探索监督主题建模的新方法”)。
蓝图:可视化 LDA 结果
由于 LDA 非常流行,Python 中有一个很好的包来可视化 LDA 结果,称为 pyLDAvis。^(3) 幸运的是,它可以直接使用 sciki-learn 的结果进行可视化。
注意,这需要一些时间:
import pyLDAvis.sklearn
lda_display = pyLDAvis.sklearn.prepare(lda_para_model, count_para_vectors,
count_para_vectorizer, sort_topics=False)
pyLDAvis.display(lda_display)
Out:
可视化中提供了大量信息。让我们从“气泡”话题开始,并点击它。现在看一下红色条,它们象征着当前选定话题中的单词分布。由于条的长度没有迅速减少,说明话题 2 并不十分显著。这与我们在 “Blueprint: Creating a Topic Model for Paragraphs with LDA” 表格中看到的效果相同(看看话题 1,在那里我们使用了数组索引,而 pyLDAvis 从 1 开始枚举话题)。
为了可视化结果,话题从原始维度(单词数)通过主成分分析(PCA)映射到二维空间,这是一种标准的降维方法。这导致了一个点;圆圈被添加以查看话题的相对大小。可以通过在准备阶段传递 mds="tsne" 参数来使用 T-SNE 替代 PCA。这改变了话题之间的距离映射,并显示了较少重叠的话题气泡。然而,这只是在可视化时将许多单词维度投影到仅两个维度的一个副作用。因此,查看话题的单词分布并不完全依赖于低维度的可视化是一个好主意。
看到话题 4、6 和 10(“国际”)之间的强重叠是很有趣的,而话题 3(“大会”)似乎远离其他所有话题。通过悬停在其他话题气泡上或点击它们,您可以查看右侧的单词分布。尽管不是所有话题都完全分离,但有些话题(如话题 1 和话题 7)远离其他话题。尝试悬停在它们上面,您会发现它们的单词内容也不同。对于这样的话题,提取最具代表性的文档并将它们用作监督学习的训练集可能是有用的。
pyLDAvis 是一个很好的工具,适合在演示文稿中使用截图。尽管看起来探索性十足,但真正的探索在于修改算法的特征和超参数。
使用 pyLDAvis 能让我们很好地了解话题是如何相互排列的,以及哪些单词是重要的。然而,如果我们需要更质量的话题理解,可以使用额外的可视化工具。
Blueprint: 使用词云来显示和比较话题模型
到目前为止,我们已经使用列表显示了话题模型。这样,我们可以很好地识别不同话题的显著程度。然而,在许多情况下,话题模型用于给出关于语料库有效性和更好可视化的第一印象。正如我们在 第一章 中看到的,词云是展示这一点的定性和直观工具。
我们可以直接使用词云来展示我们的主题模型。代码可以很容易地从之前定义的display_topics函数中推导出来:
import matplotlib.pyplot as plt
from wordcloud import WordCloud
def wordcloud_topics(model, features, no_top_words=40):
for topic, words in enumerate(model.components_):
size = {}
largest = words.argsort()[::-1] # invert sort order
for i in range(0, no_top_words):
size[features[largest[i]]] = abs(words[largest[i]])
wc = WordCloud(background_color="white", max_words=100,
width=960, height=540)
wc.generate_from_frequencies(size)
plt.figure(figsize=(12,12))
plt.imshow(wc, interpolation='bilinear')
plt.axis("off")
# if you don't want to save the topic model, comment the next line
plt.savefig(f'topic{topic}.png')
通过使用此代码,我们可以定性地比较 NMF 模型(图 8-4)的结果与 LDA 模型(图 8-5)。较大的单词在各自的主题中更为重要。如果许多单词的大小大致相同,则该主题没有明显表现:
wordcloud_topics(nmf_para_model, tfidf_para_vectorizer.get_feature_names())
wordcloud_topics(lda_para_model, count_para_vectorizer.get_feature_names())
使用单独的缩放制作词云
词云中的字体大小在每个主题内部使用缩放,因此在绘制任何最终结论之前,验证实际数字非常重要。
现在的展示更加引人入胜。很容易在两种方法之间匹配主题,比如 0-NMF 与 8-LDA。对于大多数主题来说,这是显而易见的,但也存在差异。1-LDA(“人民共和国”)在 NMF 中没有相对应项,而 9-NMF(“可持续发展”)在 LDA 中找不到。
由于我们找到了主题的良好定性可视化,我们现在对主题分布随时间的变化感兴趣。
图 8-4。展示 NMF 主题模型的词云。
图 8-5。展示 LDA 主题模型的词云。
蓝图:计算文档主题分布和时间演变
正如您在本章开头的分析中所看到的,演讲的元数据随时间变化。这引发了一个有趣的问题,即主题的分布随时间如何变化。结果表明,这很容易计算并且具有洞察力。
像 scikit-learn 的向量化器一样,主题模型也有一个transform方法,用于计算现有文档的主题分布,保持已拟合的主题模型不变。让我们首先使用这个方法将 1990 年之前和之后的演讲分开。为此,我们为 1990 年之前和之后的文档创建 NumPy 数组:
import numpy as np
before_1990 = np.array(paragraph_df["year"] < 1990)
after_1990 = ~ before_1990
然后,我们可以计算相应的W矩阵:
W_para_matrix_early = nmf_para_model.transform(tfidf_para_vectors[before_1990])
W_para_matrix_late = nmf_para_model.transform(tfidf_para_vectors[after_1990])
print(W_para_matrix_early.sum(axis=0)/W_para_matrix_early.sum()*100.0)
print(W_para_matrix_late.sum(axis=0)/W_para_matrix_late.sum()*100.0)
Out:
['9.34', '10.43', '12.18', '12.18', '7.82', '6.05', '12.10', '5.85', '17.36',
'6.69']
['7.48', '8.34', '9.75', '9.75', '6.26', '4.84', '9.68', '4.68', '13.90',
'5.36']
结果非常有趣,某些百分比发生了显著变化;特别是后期年份中倒数第二个主题的大小要小得多。现在,我们将尝试更深入地研究主题及其随时间的变化。
让我们尝试计算各个年份的分布,看看是否能找到可视化方法来揭示可能的模式:
year_data = []
years = np.unique(paragraph_years)
for year in tqdm(years):
W_year = nmf_para_model.transform(tfidf_para_vectors[paragraph_years \
== year])
year_data.append([year] + list(W_year.sum(axis=0)/W_year.sum()*100.0))
为了使图表更直观,我们首先创建一个包含两个最重要单词的主题列表:
topic_names = []
voc = tfidf_para_vectorizer.get_feature_names()
for topic in nmf_para_model.components_:
important = topic.argsort()
top_word = voc[important[-1]] + " " + voc[important[-2]]
topic_names.append("Topic " + top_word)
然后,我们将结果与以前的主题作为列名合并到一个DataFrame中,这样我们可以轻松地进行可视化,如下所示:
df_year = pd.DataFrame(year_data,
columns=["year"] + topic_names).set_index("year")
df_year.plot.area()
Out:
在生成的图表中,您可以看到主题分布随着年份的变化而变化。我们可以看到,“可持续发展”主题在持续增加,而“南非”在种族隔离制度结束后失去了流行度。
相比于展示单个(猜测的)单词的时间发展,主题似乎更自然,因为它们源于文本语料库本身。请注意,此图表是通过一种纯无监督的方法生成的,因此其中没有偏见。一切都已经在辩论数据中;我们只是揭示了它。
到目前为止,我们在主题建模中仅使用了 scikit-learn。在 Python 生态系统中,有一个专门用于主题模型的库称为 Gensim,我们现在将对其进行调查。
使用 Gensim 进行主题建模
除了 scikit-learn,Gensim 是另一个在 Python 中执行主题建模的流行工具。与 scikit-learn 相比,它提供了更多用于计算主题模型的算法,并且还可以给出关于模型质量的估计。
蓝图:为 Gensim 准备数据
在我们开始计算 Gensim 模型之前,我们必须准备数据。不幸的是,API 和术语与 scikit-learn 不同。在第一步中,我们必须准备词汇表。Gensim 没有集成的分词器,而是期望每篇文档语料库的每一行已经被分词了:
# create tokenized documents
gensim_paragraphs = [[w for w in re.findall(r'\b\w\w+\b' , paragraph.lower())
if w not in stopwords]
for paragraph in paragraph_df["text"]]
分词后,我们可以用这些分词后的文档初始化 Gensim 字典。将字典视为从单词到列的映射(就像我们在 第二章 中使用的特征):
from gensim.corpora import Dictionary
dict_gensim_para = Dictionary(gensim_paragraphs)
与 scikit-learn 的 TfidfVectorizer 类似,我们可以通过过滤出现频率不够高或者太高的单词来减少词汇量。为了保持低维度,我们选择单词至少出现在五篇文档中,但不能超过文档的 70%。正如我们在 第二章 中看到的,这些参数可以进行优化,并需要一些实验。
在 Gensim 中,这通过参数 no_below 和 no_above 过滤器实现(在 scikit-learn 中,类似的是 min_df 和 max_df):
dict_gensim_para.filter_extremes(no_below=5, no_above=0.7)
读取了字典后,我们现在可以使用 Gensim 计算词袋矩阵(在 Gensim 中称为 语料库,但我们将坚持我们当前的术语):
bow_gensim_para = [dict_gensim_para.doc2bow(paragraph) \
for paragraph in gensim_paragraphs]
最后,我们可以执行 TF-IDF 转换。第一行适配词袋模型,而第二行转换权重:
from gensim.models import TfidfModel
tfidf_gensim_para = TfidfModel(bow_gensim_para)
vectors_gensim_para = tfidf_gensim_para[bow_gensim_para]
vectors_gensim_para 矩阵是我们将在 Gensim 中进行所有即将进行的主题建模任务的矩阵。
蓝图:使用 Gensim 进行非负矩阵分解
让我们首先检查 NMF 的结果,看看我们是否可以复现 scikit-learn 的结果:
from gensim.models.nmf import Nmf
nmf_gensim_para = Nmf(vectors_gensim_para, num_topics=10,
id2word=dict_gensim_para, kappa=0.1, eval_every=5)
评估可能需要一些时间。虽然 Gensim 提供了一个 show_topics 方法来直接显示主题,但我们有一个不同的实现,使其看起来像 scikit-learn 的结果,这样更容易进行比较:
display_topics_gensim(nmf_gensim_para)
输出:
| 主题 00 国家 (0.03)
联合 (0.02)
人类 (0.02)
权利 (0.02)
角色 (0.01) | 主题 01 非洲 (0.02)
南部 (0.02)
人们 (0.02)
政府 (0.01)
共和国 (0.01) | 主题 02 经济 (0.01)
发展 (0.01)
国家 (0.01)
社会 (0.01)
国际(0.01)| 主题 03 国家(0.02)
发展中(0.02)
资源(0.01)
海(0.01)
发达(0.01)| 主题 04 以色列(0.02)
阿拉伯(0.02)
巴勒斯坦(0.02)
理事会(0.01)
安全(0.01)|
| 主题 05 组织(0.02)
宪章(0.02)
原则(0.02)
成员(0.01)
尊重(0.01)| 主题 06 问题(0.01)
解决方案(0.01)
东部(0.01)
情况(0.01)
问题(0.01)| 主题 07 核(0.02)
公司(0.02)
操作(0.02)
裁军(0.02)
武器(0.02)| 主题 08 会议(0.02)
将军(0.02)
大会(0.02)
先生(0.02)
总统(0.02)| 主题 09 世界(0.02)
和平(0.02)
人民(0.02)
安全(0.01)
国家(0.01)|
NMF 也是一种统计方法,因此结果不应与我们用 scikit-learn 计算的结果完全相同,但它们非常相似。Gensim 有用于计算主题模型的一致性评分的代码,作为质量指标。让我们试试这个:
from gensim.models.coherencemodel import CoherenceModel
nmf_gensim_para_coherence = CoherenceModel(model=nmf_gensim_para,
texts=gensim_paragraphs,
dictionary=dict_gensim_para,
coherence='c_v')
nmf_gensim_para_coherence_score = nmf_gensim_para_coherence.get_coherence()
print(nmf_gensim_para_coherence_score)
Out:
0.6500661701098243
分数随主题数量变化。如果想找到最佳主题数,常见的方法是运行多个不同值的 NMF,计算一致性评分,然后选择最大化评分的主题数。
让我们尝试用 LDA 做同样的事情并比较质量指标。
蓝图:使用 Gensim 的 LDA
使用 Gensim 运行 LDA 与准备好的数据一样简单如使用 NMF。 LdaModel 类有许多用于调整模型的参数;我们在这里使用推荐的数值:
from gensim.models import LdaModel
lda_gensim_para = LdaModel(corpus=bow_gensim_para, id2word=dict_gensim_para,
chunksize=2000, alpha='auto', eta='auto', iterations=400, num_topics=10,
passes=20, eval_every=None, random_state=42)
我们对主题的词分布很感兴趣:
display_topics_gensim(lda_gensim_para)
Out:
| 主题 00 气候(0.12)
公约(0.03)
太平洋(0.02)
环境(0.02)
海(0.02)| 主题 01 国家(0.05)
人民(0.05)
政府(0.03)
国家(0.02)
支持(0.02)| 主题 02 国家(0.10)
联合(0.10)
人类(0.04)
安全(0.03)
权利(0.03)| 主题 03 国际(0.03)
社区(0.01)
努力(0.01)
新(0.01)
全球(0.01)| 主题 04 非洲(0.06)
非洲人(0.06)
大陆(0.02)
恐怖主义者(0.02)
罪行(0.02)|
| 主题 05 世界(0.05)
年份(0.02)
今天(0.02)
和平(0.01)
时间(0.01)| 主题 06 和平(0.03)
冲突(0.02)
区域(0.02)
人民(0.02)
国家(0.02)| 主题 07 南(0.10)
苏丹(0.05)
中国(0.04)
亚洲(0.04)
索马里(0.04)| 主题 08 将军(0.10)
大会(0.09)
会议(0.05)
总统(0.04)
秘书(0.04)| 主题 09 发展(0.07)
国家(0.05)
经济(0.03)
可持续(0.02)
2015 年(0.02)|
主题的解释并不像 NMF 生成的解释那样容易。如前所示检查一致性评分,我们发现较低的评分为 0.45270703180962374。Gensim 还允许我们计算 LDA 模型的困惑度评分。困惑度衡量概率模型预测样本的能力。当我们执行 lda_gensim_para.log_perplexity(vectors_gensim_para) 时,我们得到一个困惑度评分为 -9.70558947109483。
蓝图:计算一致性评分
Gensim 还可以计算主题一致性。方法本身是一个包含分割、概率估计、确认度量计算和聚合的四阶段过程。幸运的是,Gensim 有一个CoherenceModel类,封装了所有这些单一任务,我们可以直接使用它:
from gensim.models.coherencemodel import CoherenceModel
lda_gensim_para_coherence = CoherenceModel(model=lda_gensim_para,
texts=gensim_paragraphs, dictionary=dict_gensim_para, coherence='c_v')
lda_gensim_para_coherence_score = lda_gensim_para_coherence.get_coherence()
print(lda_gensim_para_coherence_score)
Out:
0.5444930496493174
用nmf替换lda,我们可以为我们的 NMF 模型计算相同的得分:
nmf_gensim_para_coherence = CoherenceModel(model=nmf_gensim_para,
texts=gensim_paragraphs, dictionary=dict_gensim_para, coherence='c_v')
nmf_gensim_para_coherence_score = nmf_gensim_para_coherence.get_coherence()
print(nmf_gensim_para_coherence_score)
Out:
0.6505110480127619
分数要高得多,这意味着与 LDA 相比,NMF 模型更接近真实主题。
计算 LDA 模型各个主题的一致性得分更加简单,因为它直接由 LDA 模型支持。让我们首先看一下平均值:
top_topics = lda_gensim_para.top_topics(vectors_gensim_para, topn=5)
avg_topic_coherence = sum([t[1] for t in top_topics]) / len(top_topics)
print('Average topic coherence: %.4f.' % avg_topic_coherence)
Out:
Average topic coherence: -2.4709.
我们还对各个主题的一致性得分感兴趣,这些得分包含在top_topics中。但是,输出内容太冗长(检查一下!),因此我们试图通过仅将一致性得分与主题中最重要的单词一起打印来压缩它:
[(t[1], " ".join([w[1] for w in t[0]])) for t in top_topics]
Out:
[(-1.5361194241843663, 'general assembly session president secretary'),
(-1.7014902754187737, 'nations united human security rights'),
(-1.8485895463251694, 'country people government national support'),
(-1.9729985026779555, 'peace conflict region people state'),
(-1.9743434414778658, 'world years today peace time'),
(-2.0202823396586433, 'international community efforts new global'),
(-2.7269347656599225, 'development countries economic sustainable 2015'),
(-2.9089975883502706, 'climate convention pacific environmental sea'),
(-3.8680684770508753, 'africa african continent terrorist crimes'),
(-4.1515707817343195, 'south sudan china asia somalia')]
使用 Gensim 可以轻松计算主题模型的一致性得分。绝对值很难解释,但是通过变化方法(NMF 与 LDA)或主题数可以让您了解您希望在主题模型中前进的方向。一致性得分和一致性模型是 Gensim 的一大优势,因为它们(尚)未包含在 scikit-learn 中。
由于很难估计“正确”的主题数量,我们现在看一种创建层次模型的方法,不需要固定的主题数作为参数。
蓝图:找到最佳主题数
在前面的章节中,我们始终使用了 10 个主题。到目前为止,我们还没有将此主题模型的质量与具有较少或更多主题数的不同模型进行比较。我们希望找到一种结构化的方式来找到最佳主题数量,而无需深入解释每个主题模型。
原来有一种方法可以实现这一点。主题模型的“质量”可以通过先前引入的一致性得分来衡量。为了找到最佳一致性得分,我们现在将为不同数量的主题使用 LDA 模型来计算它。我们将尝试找到最高得分,这应该给我们提供最佳的主题数量:
from gensim.models.ldamulticore import LdaMulticore
lda_para_model_n = []
for n in tqdm(range(5, 21)):
lda_model = LdaMulticore(corpus=bow_gensim_para, id2word=dict_gensim_para,
chunksize=2000, eta='auto', iterations=400,
num_topics=n, passes=20, eval_every=None,
random_state=42)
lda_coherence = CoherenceModel(model=lda_model, texts=gensim_paragraphs,
dictionary=dict_gensim_para, coherence='c_v')
lda_para_model_n.append((n, lda_model, lda_coherence.get_coherence()))
一致性计算需要时间
计算 LDA 模型(及其一致性)在计算上是昂贵的,因此在现实生活中,最好优化算法,仅计算少量模型和困惑度。有时,如果只计算少量主题的一致性得分,这可能是有意义的。
现在我们可以选择哪个主题数产生良好的一致性得分。注意,通常随着主题数量的增加,得分会增加。选择太多的主题会使解释变得困难:
pd.DataFrame(lda_para_model_n, columns=["n", "model", \
"coherence"]).set_index("n")[["coherence"]].plot(figsize=(16,9))
Out:
总体而言,图表随主题数量增加而增长,这几乎总是情况。但是,我们可以看到在 13 和 17 个主题时出现了“峰值”,因此这些数字看起来是不错的选择。我们将为 17 个主题的结果进行可视化:
display_topics_gensim(lda_para_model_n[12][1])
Out:
| 主题 00 和平 (0.02)
国际 (0.02)
合作 (0.01)
国家 (0.01)
地区 (0.01) | 主题 01 将军 (0.05)
装配 (0.04)
会议 (0.02)
总统 (0.03)
先生 (0.03) | 主题 02 联合 (0.04)
国家 (0.04)
国家 (0.03)
欧洲 (0.02)
联盟 (0.02) | 主题 03 国家 (0.07)
联合 (0.07)
安全 (0.03)
理事会 (0.02)
国际 (0.02) | 主题 04 发展 (0.03)
将军 (0.02)
会议 (0.02)
装配 (0.02)
可持续 (0.01) | 主题 05 国际 (0.03)
恐怖主义 (0.03)
国家 (0.01)
伊拉克 (0.01)
行为 (0.01) |
| 主题 06 和平 (0.03)
东部 (0.02)
中东 (0.02)
以色列 (0.02)
解决方案 (0.01) | 主题 07 非洲 (0.08)
南方 (0.05)
非洲 (0.05)
纳米比亚 (0.02)
共和国 (0.01) | 主题 08 国家 (0.04)
小 (0.04)
岛屿 (0.03)
海洋 (0.02)
太平洋 (0.02) | 主题 09 世界 (0.03)
国际 (0.02)
问题 (0.01)
战争 (0.01)
和平 (0.01) | 主题 10 人类 (0.07)
权利 (0.06)
法律 (0.02)
尊重 (0.02)
国际 (0.01) | 主题 11 气候 (0.03)
变革 (0.03)
全球 (0.02)
环境 (0.01)
能源 (0.01) |
| 主题 12 世界 (0.03)
人们 (0.02)
未来 (0.01)
年度 (0.01)
今天 (0.01) | 主题 13 人民 (0.03)
独立 (0.02)
人民 (0.02)
斗争 (0.01)
国家 (0.01) | 主题 14 人民 (0.02)
国家 (0.02)
政府 (0.02)
人道主义 (0.01)
难民 (0.01) | 主题 15 国家 (0.05)
发展 (0.03)
经济 (0.03)
发展中 (0.02)
贸易 (0.01) | 主题 16 核 (0.06)
武器 (0.04)
裁军 (0.03)
武器 (0.03)
条约 (0.02) |
大多数主题都很容易解释,但有些主题很难(如 0、3、8),因为它们包含许多单词,大小相近,但不完全相同。17 个主题的主题模型是否更容易解释?实际上并非如此。连贯性得分更高,但这并不一定意味着更明显的解释。换句话说,如果主题数量过多,仅依赖连贯性得分可能是危险的。尽管理论上,较高的连贯性应有助于更好的可解释性,但通常存在权衡,选择较少的主题可以使生活更轻松。回顾连贯性图表,10 似乎是一个不错的选择,因为它是连贯性得分的局部最大值。
由于明显很难找到“正确”的主题数量,我们现在将看看一种创建层次模型并且不需要固定主题数量作为参数的方法。
蓝图:使用 Gensim 创建层次狄利克雷过程
退一步,回想一下在 “Blueprint: Using LDA with Gensim” 中关于主题的可视化。主题的大小差异很大,有些主题有较大的重叠。如果结果能先给我们更广泛的主题,然后在其下方列出一些子主题,那将是非常好的。这正是层次狄利克雷过程(HDP)的确切想法。层次主题模型应该先给我们几个广泛的主题,这些主题有良好的分离性,然后通过添加更多词汇和更详细的主题定义来进一步详细说明。
HDP 目前仍然比较新,尚未进行广泛的分析。Gensim 在研究中也经常被使用,并且已经集成了 HDP 的实验性实现。由于我们可以直接使用已有的向量化,尝试起来并不复杂。请注意,我们再次使用词袋向量化,因为狄利克雷过程本身可以正确处理频繁出现的词:
from gensim.models import HdpModel
hdp_gensim_para = HdpModel(corpus=bow_gensim_para, id2word=dict_gensim_para)
HDP 能够估计主题的数量,并能展示其识别出的所有内容:
hdp_gensim_para.print_topics(num_words=10)
输出:
结果有时很难理解。可以先执行一个只包含少数主题的“粗略”主题建模。如果发现某个主题确实很大或者怀疑可能有子主题,可以创建原始语料库的子集,其中仅包含那些与该主题具有显著混合的文档。这需要一些手动交互,但通常比仅使用 HDP 得到更好的结果。在这个开发阶段,我们不建议仅使用 HDP。
主题模型专注于揭示大量文档语料库的主题结构。由于所有文档被建模为不同主题的混合物,它们不适合于将文档分配到确切的一个主题中。这可以通过聚类来实现。
Blueprint: 使用聚类揭示文本数据的结构
除了主题建模,还有许多其他无监督方法。并非所有方法都适用于文本数据,但许多聚类算法可以使用。与主题建模相比,对我们来说重要的是每个文档(或段落)都被分配到一个簇中。
对于单一类型的文本,聚类效果良好
在我们的情况下,合理假设每个文档属于一个簇,因为一个段落中可能包含的不同内容并不多。对于更大的文本片段,我们更倾向于使用主题建模来考虑可能的混合情况。
大多数聚类方法需要簇的数量作为参数,虽然有少数方法(如均值漂移)可以猜测正确的簇数量。后者大多数不适用于稀疏数据,因此不适合文本分析。在我们的情况下,我们决定使用 k-means 聚类,但 birch 或谱聚类应该以类似的方式工作。有几种解释说明了 k-means 算法的工作原理。^(4)
聚类比主题建模慢得多
对于大多数算法,聚类需要相当长的时间,甚至比 LDA 还要长。因此,在执行下一个代码片段中的聚类时,请做好大约等待一小时的准备。
scikit-learn 的聚类 API 与我们在主题模型中看到的类似:
from sklearn.cluster import KMeans
k_means_text = KMeans(n_clusters=10, random_state=42)
k_means_text.fit(tfidf_para_vectors)
KMeans(n_clusters=10, random_state=42)
但是现在要找出有多少段落属于哪个聚类变得更容易了。所有必要的东西都在 k_means_para 对象的 labels_ 字段中。对于每个文档,它包含了聚类算法分配的标签:
np.unique(k_means_para.labels_, return_counts=True)
输出:
(array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=int32),
array([133370, 41705, 12396, 9142, 12674, 21080, 19727, 10563,
10437, 11116]))
在许多情况下,你可能已经发现了一些概念上的问题。如果数据太异构,大多数聚类往往很小(包含相对较小的词汇),并伴随着一个吸收所有剩余的大聚类。幸运的是(由于段落很短),这在这里并不是问题;聚类 0 比其他聚类要大得多,但它并不是数量级。让我们用 y 轴显示聚类的大小来可视化分布(参见 图 8-6):
sizes = []
for i in range(10):
sizes.append({"cluster": i, "size": np.sum(k_means_para.labels_==i)})
pd.DataFrame(sizes).set_index("cluster").plot.bar(figsize=(16,9))
可视化聚类的工作方式与主题模型类似。但是,我们必须手动计算各个特征的贡献。为此,我们将集群中所有文档的 TF-IDF 向量相加,并仅保留最大值。
图 8-6. 聚类大小的可视化。
这些是它们相应单词的权重。实际上,这与前面的代码唯一的区别就是:
def wordcloud_clusters(model, vectors, features, no_top_words=40):
for cluster in np.unique(model.labels_):
size = {}
words = vectors[model.labels_ == cluster].sum(axis=0).A[0]
largest = words.argsort()[::-1] # invert sort order
for i in range(0, no_top_words):
size[features[largest[i]]] = abs(words[largest[i]])
wc = WordCloud(background_color="white", max_words=100,
width=960, height=540)
wc.generate_from_frequencies(size)
plt.figure(figsize=(12,12))
plt.imshow(wc, interpolation='bilinear')
plt.axis("off")
# if you don't want to save the topic model, comment the next line
plt.savefig(f'cluster{cluster}.png')
wordcloud_clusters(k_means_para, tfidf_para_vectors,
tfidf_para_vectorizer.get_feature_names())
输出:
正如你所看到的,结果与各种主题建模方法(幸运地)并没有太大不同;你可能会认出核武器、南非、大会等主题。然而,请注意,聚类更加明显。换句话说,它们有更具体的单词。不幸的是,这并不适用于最大的聚类 1,它没有明确的方向,但有许多具有相似较小尺寸的单词。这是与主题建模相比聚类算法的典型现象。
聚类计算可能需要相当长的时间,尤其是与 NMF 主题模型相比。积极的一面是,我们现在可以自由选择某个聚类中的文档(与主题模型相反,这是明确定义的)并执行其他更复杂的操作,如层次聚类等。
聚类的质量可以通过使用一致性或 Calinski-Harabasz 分数来计算。这些指标并不针对稀疏数据进行优化,计算时间较长,因此我们在这里跳过它们。
进一步的想法
在本章中,我们展示了执行主题建模的不同方法。但是,我们只是触及了可能性的表面:
-
可以在向量化过程中添加 n-gram。在 scikit-learn 中,通过使用
ngram_range参数可以轻松实现这一点。Gensim 有一个特殊的Phrases类。由于 n-gram 具有更高的 TF-IDF 权重,它们可以在话题的特征中起到重要作用,并添加大量的上下文信息。 -
由于我们已经使用多年来依赖时间相关的话题模型,您也可以使用国家或大洲,并找出其大使在演讲中最相关的话题。
-
使用整个演讲而不是段落来计算 LDA 话题模型的一致性分数,并进行比较。
总结和建议
在日常工作中,无监督方法(如话题建模或聚类)通常被用作了解未知文本语料库内容的首选方法。进一步检查是否选择了正确的特征或是否仍可优化,这也是非常有用的。
计算话题时,最重要的决定之一是你将用来计算话题的实体。正如我们蓝图示例所示,文件并不总是最佳选择,特别是当它们非常长,并且由算法确定的子实体组成时。
找到正确的话题数量始终是一个挑战。通常,这必须通过计算质量指标来迭代解决。一个经常使用的更为实用的方法是尝试合理数量的话题,并找出结果是否可解释。
使用(大量)更多的话题(如几百个),话题模型经常被用作文本文档的降维技术。通过生成的向量化,可以在潜在空间中计算相似度分数,并且通常与 TF-IDF 空间中的朴素距离相比,产生更好的结果。
结论
话题模型是一种强大的技术,并且计算成本不高。因此,它们可以广泛用于文本分析。使用它们的首要原因是揭示文档语料库的潜在结构。
话题模型对于获取大型未知文本的总结和结构的概念也是有用的。因此,它们通常在分析的开始阶段被常规使用。
由于存在大量不同的算法和实现方法,因此尝试不同的方法并查看哪种方法在给定的文本语料库中产生最佳结果是有意义的。基于线性代数的方法速度很快,并且通过计算相应的质量指标,可以进行分析。
在执行主题建模之前以不同方式聚合数据可以导致有趣的变化。正如我们在联合国大会辩论数据集中看到的那样,段落更适合,因为发言者一个接一个地讨论了一个话题。如果您有来自许多作者的语料库,将每位作者的所有文本串联起来将为您提供不同类型作者的人物模型。
^(1) Blei, David M., et al. “潜在狄利克雷分配。” 机器学习研究杂志 3 (4–5): 993–1022. doi:10.1162/jmlr.2003.3.4-5.993.
^(2) 要了解更详细的描述,请参阅Wikipedia 页面。
^(3) pyLDAvis 必须单独安装,使用**pip install pyldavis或conda install pyldavis**。
^(4) 参见,例如,安德烈·A·沙巴林的k-means 聚类页面或纳夫塔利·哈里斯的“可视化 K-Means 聚类”。