本章内容:
- 分析语义(意义)以创建主题向量
- 使用主题向量之间的语义相似度进行语义搜索
- 针对大规模语料库的可扩展语义分析和语义搜索
- 将语义成分(主题)作为特征应用于你的 NLP 流水线
- 导航高维向量空间
在前几章中,你学到了许多自然语言处理的技巧,但这可能是你第一次能够做一些“小魔法”。这是我们第一次讨论机器能够理解词语的意义。
你在第三章学到的术语频率–逆文档频率(TF–IDF)向量帮助你估算了词语在一段文本中的重要性。你使用 TF–IDF 向量和矩阵来告诉你每个词在整个文档集合中文本的整体意义中的重要性。这些 TF–IDF “重要性”分数不仅适用于单词,也适用于短语序列(n-grams)。如果你知道确切的词或 n-grams,TF–IDF 对文本搜索非常有效,但它们也有一定的局限性。通常,你需要一种不仅仅统计词频,而是还能考虑它们意义的表示方式。
研究人员已经发现了几种方法,通过词语之间的共现来表示词语的意义。在本章中,你将学习其中一些方法,如潜在语义分析(LSA)和潜在狄利克雷分配(LDA)。这些方法创建语义或主题向量来表示词语和文档。你将使用来自 TF–IDF 向量或前一章中学习的袋模型(BOW)向量的加权频率分数。这些分数及其之间的相关性将帮助你计算组成主题向量维度的“主题”分数。
主题向量将帮助你做很多有趣的事情。它们使得基于文档意义的搜索成为可能——这就是语义搜索。大多数情况下,语义搜索比关键词搜索返回的结果要好得多。有时候,语义搜索甚至能返回完全符合用户搜索需求的文档,即使用户无法想到正确的查询词。
语义向量还可以用来识别最佳表示陈述、文档或语料库(文档集合)主题(话题)的单词和 n-grams。有了这些单词及其相对重要性的向量,你就能为某个文档提供最有意义的单词——一组总结其意义的关键词。最后,你将能够比较任意两个陈述或文档,评估它们在意义上的“接近”程度。
提示 术语“主题”、“语义”和“意义”具有相似的含义,通常在谈论 NLP 时可以互换使用。在本章中,你将学习如何构建一个能够自动识别这种同义词关系的 NLP 流水线。你的流水线甚至可能能够发现短语“figure it out”和单词“compute”在意义上的相似性。机器只能计算意义,而不能“弄明白”它。
你很快会看到,构成主题向量维度的词汇的线性组合是意义的强大表示。
4.1 从词频到主题得分
你已经学会了如何计算词语的频率,并为词语在 TF–IDF 向量或矩阵中的重要性打分。但这还不够。让我们看看这可能带来的问题,以及如何表示文本的意义,而不仅仅是单个词的频率。
4.1.1 TF–IDF 向量和词形还原的局限性
TF–IDF 向量根据词语在文档中的准确拼写来计算词频。因此,表达相同意义的文本如果拼写不同或使用不同的词,TF–IDF 向量的表示就会完全不同。这会破坏依赖于词语计数的搜索引擎和文档相似性比较。
在第二章中,你通过标准化词尾处理了最后几个字符不同的单词,将它们归为一个单一的词项。你使用了词干提取和词形还原等标准化方法,创建了拼写相似、通常也有相似意义的小词集。你将这些词集标注为词干或词形,然后处理这些新词项,而不是原始的单词。
这种词形还原的方法将拼写相似的词汇聚在一起进行分析,但不一定能将意义相似的单词聚在一起。而且它肯定不能将大多数同义词配对起来(比如,beautiful 和 pretty 在拼写上并无相似之处,对吧?)。同义词通常在词尾之外有更多差异,甚至更糟的是,词形还原和词干提取有时会错误地将反义词(如 useful 和 useless)归为一类。
最终的结果是,两段讲述相同内容但使用不同词语的文本,在你进行词形还原后的 TF–IDF 向量空间模型中不会接近。而有时,即便是两个接近的词形还原后的 TF–IDF 向量,它们的意义也完全不相同。即便是先进的 TF–IDF 相似度评分方法,如 Okapi BM25 或余弦相似度,也无法正确连接这些同义词或区分这些反义词。拼写不同的同义词生成的 TF–IDF 向量在向量空间中根本不接近。
例如,本章讨论的 NLPIA(你正在阅读的章节)可能与关于潜在语义索引的大学教材中的相似意义段落并不接近。但这正是本章的主题,只不过我们在这里使用了现代和口语化的术语。而教授和研究人员在教科书和讲座中使用的语言更加一致、严谨。更何况,教授们使用的术语在过去十年中可能已经随着快速发展的科技进步而演变。例如,潜在语义索引(latent semantic indexing)这一术语曾比现在研究者使用的潜在语义分析(latent semantic analysis)更为流行。
所以,具有相似意义的不同词语给 TF–IDF 带来了问题,但外表相似但意义截然不同的词语同样如此。即使是英语教授写的正式英文文本,也无法避免大多数英语单词具有多重含义的事实,这是任何新学习者,包括机器学习者,都必须面对的挑战。这种多义词的概念被称为多义性。
以下是多义性如何影响词语或陈述语义的几种方式:
- 同音异义词——拼写和发音相同但意义不同的词(例如,(a) 乐队在演奏老 Beatles 歌曲。(b) 她的发夹非常漂亮。)
- 同形异音词——拼写相同但发音和意义不同的词(例如,(a) 我反对这个决定。(b) 我不认识这个物体。)
- 语义悬挂——在同一句话中同时使用一个词的两个含义(例如,Pickwick 先生戴着他的帽子并告辞。)
你可以看到,所有这些现象都会降低 TF–IDF 的性能,使得那些具有相似词但意义不同的句子的 TF–IDF 向量比实际应有的更为相似。为了解决这些挑战,我们需要一个更强大的工具。
4.1.2 主题向量
当你对 TF–IDF 向量进行加减等数学运算时,这些加和差仅仅告诉你你合并或差分的文档中的词语使用频率。这些数学运算并不能告诉你这些词语背后的“意义”。你可以通过将 TF–IDF 矩阵与自身相乘来计算词对词的 TF–IDF 向量(词共现或相关性向量)。但使用这些稀疏的高维向量进行向量推理并不好。当你加减这些向量时,它们并没有很好地代表某个已有的概念、词汇或主题。
因此,你需要一种方法,从词语统计数据中提取一些额外的、深层的意义。你需要更好地估算文档中词语的意义,并了解这些词汇组合在特定文档中的含义。你希望用一个像 TF–IDF 向量那样的向量来表示这些意义,但它应该更紧凑、更有意义。
本质上,在创建这些新向量时,你将定义一个新的空间。当你使用 TF–IDF 或 BOW 向量表示单词和文档时,你是在一个由文档中出现的单词或词项定义的空间中操作。每个词项都有一个维度——这就是为什么你很容易达到几千个维度。而且每个词项与其他所有词项都是正交的——当你将表示一个词的向量与表示另一个词的向量相乘时,结果总是零,即使这些词是同义词。
主题建模的过程就是找到一个维度更少的空间,使得语义上接近的词汇对齐到相似的维度。我们将这些维度称为主题,而这个新空间中的向量则是主题向量。你可以拥有任意多个主题。
你可以像处理任何其他向量一样,加减你计算得到的主题向量。只不过这次,向量的和与差比起 TF–IDF 向量来说更有意义。主题向量之间的距离或相似度对于查找关于相似主题的文档或进行语义搜索非常有用(我们将在本章末进一步讨论语义搜索)。
当你将向量转化为新空间时,每个文档都会有一个文档-主题向量。你的词汇表中的每个词也会有一个词-主题向量,因此你可以通过将所有词-主题向量相加,轻松地为任何新文档计算主题向量。
创建词汇和句子语义(意义)的数字表示可能是一个棘手的问题。对于英语等“模糊”语言尤为如此,因为英语有多种方言,并且同一个词的解释可能有很多不同的版本。
考虑到这些挑战,你能想象如何将一个具有百万维度(词项)的 TF–IDF 向量压缩为一个具有 10 或 100 个维度(主题)的向量吗?这就像确定正确的主色调,以便重新粉刷你公寓中的墙面,掩盖那些钉孔。
你需要找出那些“属于”同一主题的词汇维度,将它们的 TF–IDF 值加起来,创建一个新数值来表示文档中该主题的出现频率。你甚至可以为这些词的重要性加权,决定每个词对“混合”的贡献程度。对于那些减少文本属于该主题可能性的词语,你可以给予负权重。
4.1.3 思维实验
让我们通过一个思维实验来探讨这个问题。假设你有一个特定文档的 TF–IDF 向量,现在你想将其转换为一个主题向量。你可以考虑每个词语在主题中的贡献程度。
假设你正在处理一些关于纽约市中央公园宠物的句子。我们创建三个主题:一个关于宠物,一个关于动物,另一个关于城市;我们将它们分别称为宠物性(petness)、动物性(animalness)和城市性(cityness)。例如,你的宠物性主题将显著给 cat 和 dog 打分,但可能忽略像 NYC 和 apple 这样的词。如果你“训练”你的主题模型,就像我们所做的那样,而不是依赖计算机,而仅凭常识,你可能会得出以下的权重。
示例:为主题分配权重
>>> import numpy as np
>>> topic = {}
>>> tfidf = dict(list(zip('cat dog apple lion NYC love'.split(),
... np.random.rand(6)))) #1
>>> topic['petness'] = (.3 * tfidf['cat'] +\
... .3 * tfidf['dog'] +\
... 0 * tfidf['apple'] +\
... 0 * tfidf['lion'] -\
... .2 * tfidf['NYC'] +\
... .2 * tfidf['love']) #2
>>> topic['animalness'] = (.1 * tfidf['cat'] +\
... .1 * tfidf['dog'] -\
... .1 * tfidf['apple'] +\
... .5 * tfidf['lion'] +\
... .1 * tfidf['NYC'] -\
... .1 * tfidf['love'])
>>> topic['cityness'] = ( 0 * tfidf['cat'] -\
... .1 * tfidf['dog'] +\
... .2 * tfidf['apple'] -\
... .1 * tfidf['lion'] +\
... .5 * tfidf['NYC'] +\
... .1 * tfidf['love'])
- 这段代码中的 TF–IDF 向量仅作为一个随机示例,假设它是通过一个包含这些词汇的文档以某种随机比例计算出来的。
- 手工设置的权重(.3, .3, 0, 0, -0.2, 0.2)与虚拟的 TF–IDF 值相乘,以创建你假设的随机文档的主题向量。你将计算实际的主题向量。
在这个思维实验中,我们通过词频相加来确定每个主题的指标。我们根据词汇与主题的相关性加权词频(TF–IDF 值)。这些权重对某些词汇来说可能是负值,表示它们在某种意义上与该主题相反。
这不是一个实际的算法或实现,只是一个思维实验。你只是想弄清楚如何教机器像你一样思考。你随意地选择将词汇和文档分解为三个主题(petness、animalness 和 cityness)。并且你的词汇表很有限——它只包含六个词。
下一步是思考一个人类如何在数学上决定哪些主题与哪些词语相关,以及这些联系的权重应该是多少。一旦你决定了要建模的三个主题,你就需要决定如何为这些主题加权每个词。你将词语按比例混合,以创建你的主题“调色板”。主题建模转换(调色板混合配方)是一个 3 × 6 的比例(权重)矩阵,将三个主题与六个词项连接起来。你将该矩阵与一个虚拟的 6 × 1 的 TF–IDF 向量相乘,从而获得该文档的 3 × 1 主题向量。
你做出了判断,认为 cat 和 dog 对宠物性主题的贡献应该是相似的(权重为 .3)。因此,你的 TF–IDF 到主题转换矩阵的左上角的两个值都是 .3。你能想象如何使用软件计算这些比例吗?记住,你有一堆计算机可以读取的文档,可以对其进行分词和计算词项频率。你有 TF–IDF 向量,可以处理任意多的文档。继续思考你如何使用这些词频来计算一个词的主题权重。
你认为 NYC 对宠物性主题的权重应该为负值。某种意义上,城市名(以及一般的专有名词)、缩写和首字母缩写与关于宠物的词语共享的意义很少。想想“共享意义”对于词语来说意味着什么——在 TF–IDF 矩阵中,是否有表示词语共享意义的东西?
注意到 apple 在城市主题向量中的小值了吗?这可能是因为你手动操作——我们人类知道 NYC 和 Big Apple 通常是同义词。希望我们的语义分析算法能够基于这两个词出现在同一文档中的频率计算出它们之间的同义性。
当你阅读列表 4.1 中的加权和时,尝试猜测我们是如何为这三个主题和六个词确定权重的。你可能头脑中的语料库和我们使用的语料库不同,所以你可能对这些词的适当权重有不同的看法。你会如何改变它们?你可以使用什么作为这些比例(权重)的客观衡量标准?我们将在下一节回答这个问题。
提示:我们选择了带符号的加权(允许正负权重),以便为词语产生主题向量。这使得你可以使用负权重来表示与某个主题“相反”的词。因为这是手工操作,我们选择使用容易计算的 L1 范数来对你的主题向量进行标准化(意味着向量各维度的绝对值之和等于 1)。然而,真正的 LSA 在本章稍后会使用更有用的 L2 范数进行标准化。我们将在本章后面介绍不同的范数和距离。
当你阅读这些向量时,你可能已经意识到,词语与主题之间的关系是可以“翻转”的。通过转置 3 × 6 的矩阵,你可以为词汇表中的每个词计算主题权重。这些权重的向量将成为你六个词的词向量:
>>> word_vector = {}
>>> word_vector['cat'] = .3 * topic['petness'] + \
... .1 * topic['animalness'] + \
... 0 * topic['cityness']
>>> word_vector['dog'] = .3 * topic['petness'] \
... -.1 * topic['animalness'] + \
... .1 * topic['cityness']
>>> word_vector['apple']= 0 * topic['petness'] \
... -.1 * topic['animalness'] + \
... .2 * topic['cityness']
>>> word_vector['lion'] = 0 * topic['petness'] + \
... .5 * topic['animalness'] \
... -.1 * topic['cityness']
>>> word_vector['NYC'] = -.2 * topic['petness'] + \
... .1 * topic['animalness'] + \
... .5 * topic['cityness']
>>> word_vector['love'] = .2 * topic['petness'] \
... -.1 * topic['animalness'] + \
... .1 * topic['cityness']
图 4.1 显示了六个词汇-主题向量,每个词的向量表示了这些词的 3D 向量表示。
之前,每个主题的向量,通过每个词的权重,给出了6维向量,表示了你三个主题中词语的线性组合。现在,你已经手工创建了一种通过主题表示文档的方法。如果你只计算这些六个词的出现频率,并将其乘以你的权重,你就可以得到任何文档的3维主题向量。使用3维向量可以使数据可视化变得简单;你可以绘制这些向量并以图形的形式分享你对语料库或特定文档的洞察。此外,3维向量(或任何低维向量空间)非常适合机器学习分类问题,因为算法可以通过一个平面(或超平面)切割向量空间,将空间分成不同的类别。
尽管你的语料库中的文档可能使用了更多的词汇,但这个特定的主题向量模型只会受到这六个词的影响。你可以将这种方法扩展到任意多的词汇,只要你有耐心(或有算法)。只要你的模型只需要根据三个不同的维度或主题来区分文档,你的词汇表可以根据需要任意扩展。在这个思维实验中,你将六个维度(TF–IDF归一化频率)压缩成了三个维度(主题)。
这种主观且劳动密集型的语义分析方法依赖于人类直觉和常识,将文档分解为主题。常识很难被编码到算法中。显然,这不适合用于机器学习管道,而且它对更多的主题和词汇扩展的效果也不好。那么,让我们用一个不依赖常识来为我们选择主题权重的算法,自动化这个手动过程。
这些加权和实际上就是点积,三个点积(加权和)实际上就是矩阵乘法,或者说是内积。你将一个3 × n 的权重矩阵与一个TF–IDF向量相乘(文档中每个词的一个值),其中n是你词汇表中的词项数量。这个乘法的输出就是该文档的一个新的3 × 1 主题向量。你所做的就是将一个向量从一个向量空间(TF–IDFs)“转换”到另一个低维向量空间(主题向量)。你的算法应该创建一个n个词项和m个主题的矩阵,可以将其与文档中词频的向量相乘,以得到该文档的新主题向量。
现在,假设你有一组关于不同主题的文档。如果你将这些文档的表示转换到主题空间,你将更容易找到与你的搜索查询匹配的文档。你可以通过高“城市性”文档寻找城市规划信息,通过高“宠物性”文档寻找宠物护理建议。
但是,主题建模甚至能帮助你回答一个更基本的问题:在你面前的文档语料库中,你看到了哪些趋势和主题?例如,如果你有一组开放式调查回复或在线产品评论,主题建模可以帮助你识别出受访者最常提到的问题。通过将评论中的词汇与主题(如昂贵、价格、成本和味道、风味、好、奇怪)连接起来,你可以找到专注于特定特征的评论。
4.1.4 主题评分算法
你仍然需要一种算法方法来确定这些主题向量,或者从你已经拥有的向量(例如TF–IDF或BOW向量)中推导出来。机器难道不能判断哪些词属于同一组,或者它们的含义是什么吗?20世纪的英国语言学家J. R. Firth研究了你如何估计一个词或语素的含义。1957年,他给出了一个计算单词主题的线索:
“你应该通过一个词所处的环境来理解它。”
—J. R. Firth
那么,你如何判断一个词的“环境”呢?最直接的方法是统计词语在同一文档中的共现频率,而你在第3章中的BOW和TF–IDF向量正好提供了所需的条件。这种“统计共现”方法促使了多种算法的出现,用于创建表示文档或句子中词语使用统计的向量。
在接下来的部分,你将看到两种用于创建主题向量的算法。第一个是潜在语义分析(LSA),它应用于你的TF–IDF矩阵,将词语聚集成主题。它也适用于BOW向量,但使用TF–IDF向量通常会得到稍好的结果。LSA优化这些主题,以保持主题维度的多样性;当你使用这些新的主题代替原始的词汇时,你仍然能捕捉到文档的许多含义(语义)。为了捕捉文档的意义,你的模型所需要的主题数远低于你TF–IDF向量词汇表中的词汇数,因此LSA常被称为一种降维技术。LSA减少了捕捉文档含义所需的维度数。
我们将讨论的另一个算法是潜在Dirichlet分配(LDA),通常缩写为LDiA。由于我们在本书中将LDA用作线性判别分析的缩写,因此我们将潜在Dirichlet分配缩写为LDiA。
LDiA将LSA的数学方法应用于不同的方向。它使用非线性统计算法将词语聚类在一起。因此,它的训练时间通常比像LSA这样的线性方法长得多。通常,这使得LDiA在许多现实应用中不太实用,且它应该很少作为你首先尝试的方法。然而,它所创建的主题的统计数据有时更能贴近人类对词语和主题的直觉,因此LDiA的主题通常更容易向你的上司解释。LDiA对于一些单文档问题(如文档摘要)也更有用。
对于大多数分类或回归问题,由于LDiA的可扩展性和可解释性,通常还是使用LSA更为合适。基于这个原因,你将从LSA开始,并探索它背后的SVD线性代数。
4.2 挑战:检测有害言论
为了展示主题建模的强大功能,你将解决一个实际问题:识别维基百科评论中的有害言论。 有害言论是指旨在让受众感到不安、羞辱或恐惧的消息。威胁、侮辱、脏话和仇恨言论都是有害言论的表现形式。这种有害言论在参考类网站(如维基百科)上尤其危险,因为编辑者之间的对话中的有害言论可能会蔓延到他们的页面内容中。此外,维基百科在很大程度上影响了整个互联网的基调,因为它被所有主要的搜索引擎信任,能够回答大量问题,包括那些敏感和有争议的话题。
如果社交媒体平台的管理员和版主无法迅速识别并阻止有害内容的传播,这些平台很容易陷入有害言论的恶性循环。当社交媒体平台上的社区版主使用LSA时,他们可以减少工作负担,减少脆弱用户暴露于有害内容的时间。与语言模型和其他深度学习NLP工具不同,LSA在计算效率上足够高,能够在大规模的社交媒体平台上运行。在本章中,你将使用维基百科讨论评论的数据集,将其自动分类为两类:有害和无害。首先,使用以下代码加载有害评论数据集。
Listing 4.2 有害评论数据集
>>> import pandas as pd
>>> pd.options.display.width = 120 #1
>>> DATA_DIR = ('https://gitlab.com/tangibleai/nlpia/-/raw/master/'
... 'src/nlpia/data')
>>> url= DATA_DIR + '/toxic_comment_small.csv'
>>>
>>> comments = pd.read_csv(url)
>>> index = ['comment{}{}'.format(i, '!'*j) for (i,j) in
... zip(range(len(comments)), comments.toxic)
... ] #2
>>> comments = pd.DataFrame(
... comments.values, columns=comments.columns, index=index)
>>> mask = comments.toxic.astype(bool).values
>>> comments['toxic'] = comments.toxic.astype(int)
>>> len(comments)
5000
>>> comments.toxic.sum()
650
>>> comments.head(6)
text toxic
comment0 you have yet to identify where my edits violat... 0
comment1 "\n as i have already said,wp:rfc or wp:ani. (... 0
comment2 your vote on wikiquote simple english when it ... 0
comment3 your stalking of my edits i've opened a thread... 0
comment4! straight from the smear site itself. the perso... 1
comment5 no, i can't see it either - and i've gone back... 0
#1 To display more of the comment text within a pandas DataFrame printout
#2 To help you recognize toxic comments, you can append an exclamation point to their label.
你有5,000条评论,其中650条被标记为有害评论(分类标签为“toxic”)。在深入探讨所有复杂的降维方法之前,我们先尝试用你已经熟悉的向量表示方法——TF–IDF来解决分类问题。但是,你会选择什么模型来分类这些评论呢?为了决定,让我们先看一下以下列出的TF–IDF向量。
Listing 4.3 创建有害评论数据集的TF–IDF向量
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> import spacy
>>> nlp = spacy.load("en_core_web_sm")
>>>
>>> def spacy_tokenize(sentence):
... return [token.text for token in nlp(sentence.lower())]
>>>
>>> tfidf_model = TfidfVectorizer(tokenizer=spacy_tokenize)
>>> tfidf_docs = tfidf_model.fit_transform(\
... raw_documents=comments.text).toarray()
>>> tfidf_docs.shape
(5000, 19169)
使用spaCy的分词器,你的词汇表中有19,169个单词。你有大约4倍的单词数,而有害评论的单词数几乎是其4倍。由此可见,你的模型对能够表明评论是否有害的词汇了解有限。
你已经在本书中遇到过至少一个分类器:在第2章中提到的朴素贝叶斯。通常,朴素贝叶斯分类器在词汇量远大于标记样本数的情况下效果不佳。因此,这次我们需要一些不同的方法。
4.2.1 线性判别分析分类器
在本章中,我们将介绍基于LDA(线性判别分析)的分类器。LDA是你可以找到的最快且最直接的模型之一,它比一些更复杂的算法需要的样本更少。
LDA的输入将是带标签的数据,因此我们需要既有表示消息的向量,也有它们的类别。在本例中,我们有两个类别:有害评论和无害评论。LDA算法使用的一些数学超出了本书的范围,但在两个类别的情况下,其实现相当直观。
本质上,当LDA算法面对一个二分类问题时,它会做以下几件事:
- 它在你的向量空间中找到一条线或轴,使得如果你将所有的向量(数据点)投影到这条轴上,两个类别将尽可能地分开。
- 它将所有向量投影到那条线。
- 它根据两个类别之间的分界点预测每个向量属于某个类别的概率。
令人惊讶的是,在大多数情况下,最大化类别分离的那条线与连接两个类别代表的簇的两个质心的线非常接近。
让我们手动执行这个LDA的近似方法,看看它在我们的数据集上表现如何:
>>> mask = comments.toxic.astype(bool).values #1
>>> toxic_centroid = tfidf_docs[mask].mean(axis=0) #2
>>> nontoxic_centroid = tfidf_docs[~mask].mean(axis=0) #3
>>> centroid_axis = toxic_centroid - nontoxic_centroid
>>> toxicity_score = tfidf_docs.dot(centroid_axis) #4
>>> toxicity_score.round(3)
array([-0.008, -0.022, -0.014, ..., -0.025, -0.001, -0.022])
#1 你可以使用这个mask来选择numpy数组或pandas DataFrame中的有害评论行。
#2 因为你的TF–IDF向量是行向量,你需要确保numpy计算每一列(或维度)的均值,使用axis=0。
#3 你可以使用波浪符号(~)反转mask来选择所有无害消息。现在,你可以将一个质心从另一个质心中减去,得到它们之间的线,并计算每个向量的毒性。
#4 toxicity_score是评论向量沿从无害质心到有害质心的线的影像(投影)。
某个评论的毒性评分是该评论向量沿从无害到有害评论的线的影像(投影)的长度。你通过计算这些投影,就像计算余弦距离一样。它是评论向量与指向从无害评论到有害评论的向量的点积。你通过将每个TF–IDF向量投影到两个质心之间的线,使用点积来计算毒性评分。而且你通过在一次向量化的numpy操作中使用.dot()方法一次性完成了5,000次点积计算。与Python的for循环相比,这可以加速100倍。
接下来,我们只需一步就能完成分类。你需要将评分转换为实际的类别预测。理想情况下,你希望评分的范围在0和1之间,就像概率一样。得到标准化的评分后,你可以根据一个截止点来推断分类——在这里,我们使用简单的0.5。你可以使用sklearn中的MinMaxScaler来执行标准化。
Listing 4.4 基于毒性评分分类评论
>>> from sklearn.preprocessing import MinMaxScaler
>>> comments['manual_score'] = MinMaxScaler().fit_transform(\
... toxicity_score.reshape(-1,1))
>>> comments['manual_predict'] = (comments.manual_score > .5).astype(int)
>>> comments['toxic manual_predict manual_score'.split()].round(2).head(6)
toxic manual_predict manual_score
comment0 0 0 0.41
comment1 0 0 0.27
comment2 0 0 0.35
comment3 0 0 0.47
comment4! 1 0 0.48
comment5 0 0 0.31
看起来不错。前六条评论几乎都被正确分类了。让我们看看它在训练集的其余部分表现如何:
>>> (1 - (comments.toxic - comments.manual_predict).abs().sum()
... / len(comments))
0.895...
不错!这个简单的“近似”版本的LDA将89.5%的消息正确分类。那么“完整的”LDA会怎样呢?你将使用scikit-learn(sklearn)实现的LDA。
Listing 4.5 LDA在训练集上的表现
>>> from sklearn.discriminant_analysis import \
... LinearDiscriminantAnalysis
>>> lda_tfidf = LinearDiscriminantAnalysis(n_components=1)
>>> lda_tfidf = lda_tfidf.fit(tfidf_docs, comments['toxic'])
>>> comments['tfidf_predict'] = lda_tfidf.predict(tfidf_docs)
>>> float(lda_tfidf.score(tfidf_docs, comments['toxic']))
0.999...
这次,我们得到了99.9%的准确率!几乎是完美的准确率。这是不是意味着你不需要使用更复杂的主题建模算法,如LDA或深度学习了?
这是个陷阱问题。你可能已经发现了这个问题的根源。这个接近完美的99.9%结果的原因是我们没有分离出测试集。这个A+成绩是在分类器已经“见过”的“问题”上得出的。这就像你在学校考试时得到了一些和你前一天学习的完全相同的题目。所以,这个模型在现实世界中可能无法很好地工作,尤其是在面对网络喷子和垃圾邮件时。
Tip 注意你用来训练和做预测的类方法。sklearn中的每个模型都有这些相同的方法:fit()和predict()。所有分类器模型甚至都会有一个predict_proba()方法,它会给你所有类别的概率分数。这使得你在尝试找到最适合解决机器学习问题的模型时,可以更容易地替换不同的模型算法。这样,你就可以将脑力集中在NLP工程师的创造性工作上,即调整模型的超参数,使其能够在现实世界中有效工作。
让我们看看我们的分类器在更现实的情况下如何表现,使用一个叫做混淆矩阵的工具。混淆矩阵将告诉你模型犯错的次数。错误分为两种:假阳性和假阴性。测试集中被标记为有害的错误称为假阴性,因为它们被错误地标记为负(无害),但应该标记为正(有害)。相反,在测试集中对无害标签的错误称为假阳性,因为它们应该标记为负(无害),但被错误地标记为有害。以下是如何使用sklearn函数实现:
>>> from sklearn.metrics import confusion_matrix
>>> confusion_matrix(y_test, lda.predict(X_test))
array([[1261, 913],
[ 201, 125]], dtype=int64)
嗯,事情看起来不太清楚。幸运的是,sklearn考虑到了你可能需要以更直观的方式展示混淆矩阵,并且提供了一个专门的函数。让我们试试看:
>>> import matplotlib.pyplot as plt
>>> from sklearn.metrics import ConfusionMatrixDisplay
>>> ConfusionMatrixDisplay.from_estimator(lda, X_test, y_test, cmap="Greys",
... display_labels=['non-toxic', 'toxic'], colorbar=False)
>>> plt.show()
你可以在图4.2中看到结果的matplotlib图,显示了两个标签(有害和无害)的错误预测和正确预测次数。查看这个图,看看你能否发现模型表现的不足之处。
首先,在测试集中,实际有326条评论是有害的,而模型仅正确识别了其中的125条——这相当于38.3%。这个衡量模型识别我们关心的类别实例数量的指标被称为召回率(recall)或敏感度。另一方面,在模型标记为有害的1,038条评论中,只有125条是真正的有害评论。因此,正标签只有12%的情况是正确的。这个指标被称为精确度(precision)。
你已经可以看到,精确度和召回率比模型准确率提供了更多的信息。例如,假设你没有使用机器学习模型,而是决定使用一个确定性规则,只将所有评论标记为无害。在我们的数据集中,大约13%的评论实际上是有害的,那么这个模型的准确率将为0.87——比你训练的上一个LDA模型要好得多!然而,它的召回率将是0;在我们的任务中,这并没有帮助,因为我们需要识别有害消息。
你可能还会意识到,这两个指标之间存在权衡。如果你使用另一个确定性规则,将所有评论标记为有害,那么召回率将是完美的,因为你会正确分类所有有害评论。然而,精确度将会降低,因为大多数被标记为有害的评论实际上是完全无害的。
根据你的使用场景,你可能决定优先考虑精确度或召回率。想象你正在为一个系统构建算法,该系统会检测给定评论的毒性,并在评论太有害时提醒社区管理员,甚至阻止用户发布该评论。低精确度意味着你的管理员会收到过多的提醒,从而增加工作人员的工作量。如果你选择自动隐藏有害评论,那么你的低精度、过度提醒的算法肯定会惹恼用户,导致他们离开或想出创意策略来绕过你的算法。另一方面,低召回率意味着真正的有害评论可能会出现在平台上。但在许多情况下,你希望精确度和召回率都能达到合理的水平。
在这种情况下,你可能会使用F1分数——精确度和召回率的调和平均数。更高的精确度和更高的召回率都会导致更高的F1分数,这使得用一个指标来基准测试你的模型变得更加容易。
你可以在附录D中进一步了解如何分析分类器的性能。现在,我们先记录下这个模型的F1分数,再继续往下进行。
4.2.2 超越线性
LDA 在许多情况下表现良好;然而,它仍然有一些假设条件,当这些假设未得到满足时,分类器的表现可能会不尽如人意。例如,LDA 假设所有类别的特征协方差矩阵是相同的。这是一个相当强的假设!因此,LDA 只能学习类别之间的线性边界。
如果你需要放宽这一假设,可以使用 LDA 的一种更一般的形式,称为二次判别分析(QDA)。QDA 允许不同类别具有不同的协方差矩阵,并且会分别估计每个类别的协方差矩阵。这就是为什么它能够学习二次的或曲线的边界。这样,它变得更加灵活,在某些情况下,能够表现得更好。
4.3 降维
在我们深入探讨 LSA 之前,先花点时间理解它在概念上是如何处理数据的。LSA 进行主题建模的方法背后的思想是降维。正如其名称所示,降维是一个过程,我们通过该过程找到数据的低维表示,同时尽可能保留尽量多的信息。
让我们分析一下这个定义并理解它的含义。为了给你一个直观的理解,让我们暂时离开自然语言处理,转而使用更直观的例子。首先,什么是数据的低维表示呢?想象一下将一个三维物体(比如你的沙发)在二维空间中表示。例如,如果你在一个黑暗的房间里把光线照在沙发后面,它在墙上的影子就是它的二维表示。
为什么我们需要这种表示呢?原因可能有很多。也许我们没有能力像原样那样存储或传输完整的数据,或者我们想要可视化数据以便更好地理解它。当我们讨论 LDA 时,你已经看到了可视化和聚类数据点的强大功能。但我们的头脑实际上无法处理超过两到三维的数据——而当我们处理现实世界中的数据,特别是自然语言数据时,我们的数据集可能包含成百上千个维度。降维工具,如主成分分析(PCA),在我们希望简化并可视化数据集时非常有用。
另一个重要的原因是我们在第三章简要提到的“维度灾难”。稀疏的多维数据更难处理,而且在其上训练的分类器更容易发生过拟合。数据科学家常用的一个经验法则是,每个维度至少需要五条记录。我们已经看到,即使对于小型文本数据集,TF–IDF 矩阵也会迅速推高到 1 万或 2 万维。对于许多其他类型的数据来说,这一点也是成立的。
通过沙发影子的例子,你可以看到,我们可以为同一数据集构建无限多种低维表示。这些表示有些比其他的更好,但在这种情况下,“更好”意味着什么呢?当我们讨论可视化数据时,你可以直观地理解,能够帮助我们识别物体的表示比无法识别物体的表示更好。例如,假设我们有一个从真实物体的三维扫描得到的点云,并将其投影到二维平面上。你可以在图 4.3 中看到结果。你能从这个表示中猜出三维物体是什么吗?
为了延续我们关于影子的类比,想象一下中午的太阳高悬在一群人头顶。每个人的影子都会是一个圆形的斑块。我们能否通过这些斑块来分辨谁高谁矮,或者哪个人有长发?可能不能。
现在,你明白了,良好的降维不仅是将数据压缩到更少的维度,而是能够在新的表示中区分不同的对象和数据点,并且你也知道,数据的所有特征或维度并不是在这一区分过程中同等重要的。因此,有一些特征你可以轻松丢弃,而不会失去太多信息。但对于一些特征,丢弃它们会显著损害你理解数据的能力。而且,因为你正在处理线性代数问题,你不仅可以选择排除或包含某个维度,还可以将多个维度合并成一个更小的维度集合,这样就能以更简洁的方式表示你的数据。接下来,让我们看看如何做到这一点。
4.3.1 主成分分析(PCA)登场
你现在知道了,为了在更少的维度中找到数据的表示,你需要找到一个维度组合,这些维度可以保持你区分数据点的能力。这将使你能够,例如,将数据分成有意义的聚类。继续使用影子的例子,一个好的影子表示方式允许你看到影子中头部和腿部的位置。它通过保留这些物体之间的高度差异来做到这一点,而不是像中午的太阳影子那样将它们“压缩”到一个点。另一方面,我们身体的厚度从上到下大致均匀,因此当你看到“平面”影子表示时,丢弃这个维度并不会失去像丢弃身高那样多的信息。在数学中,这个差异通过方差来表示。考虑到这一点,你会发现具有较大方差的特征——即与均值的偏离更大、更频繁——对于区分数据点更有帮助。
但你不仅可以单独考虑每个特征。另一个重要的因素是特征之间的相互关系。在这里,视觉类比可能会开始失效,因为我们操作的三个维度是正交的,因此完全无关。但让我们回想一下你在前一部分看到的主题向量:动物性、宠物性和城市性。如果你检查这三个特征中的每一对,你会发现一些特征之间的关系比其他特征更紧密。大多数带有宠物性特征的词语也同时具备动物性特征。特征对之间的这种关系被称为协方差。它与相关性密切相关,相关性是通过每个特征在对中的方差来规范化的协方差。特征之间的协方差越大,它们的关系就越紧密;因此,两个特征之间有更多的冗余,因为你可以通过其中一个特征推断出另一个特征。这也意味着你可以找到一个维度,保留这两个维度中的大部分方差。
总结一下,要在不失去信息的情况下减少描述数据的维度,你需要找到一个表示方式,使得每个新轴的方差最大化,同时减少维度之间的依赖,并去除那些具有高协方差的维度。这正是PCA(主成分分析)所做的,它通过找到一组使方差最大化的维度来实现。 这些维度是正交归一化的(就像物理世界中的x、y和z轴),称为主成分——这也是该方法名称的由来。PCA还允许你看到每个维度所负责的方差量,这样你就可以选择保留数据集“本质”的主成分的最优数量。然后,它将你的数据投影到一个新的坐标系中。
在我们深入了解PCA如何执行这一操作之前,让我们先看看这个过程的实际效果。在接下来的代码示例中,你将使用scikit-learn的PCA方法,处理图4.3中看到的相同3D点云,并找到一个二维表示,以最大化该点云的方差。
示例 4.7 PCA魔法
>>> import pandas as pd
>>> from sklearn.decomposition import PCA
>>> import seaborn
>>> from matplotlib import pyplot as plt
>>> DATA_DIR = ('https://gitlab.com/tangibleai/nlpia/'
... '-/raw/master/src/nlpia/data')
>>> df = pd.read_csv(DATA_DIR + '/pointcloud.csv.gz', index_col=0)
>>> pca = PCA(n_components=2) #1
>>> df2d = pd.DataFrame(pca.fit_transform(df), columns=list('xy'))
>>> df2d.plot(kind='scatter', x='x', y='y')
>>> plt.show()
#1 将3D点云的维度降低到2D,以便能够在平面上展示
当你将3D点(向量)的维度降到2D时,就像拍摄这组3D点云的照片。结果可能看起来像图4.4右侧或左侧的图片,但它永远不会向新的角度倾斜或旋转。x轴(轴0)将始终与点云中点的最长轴对齐,即点分布最广的地方。这是因为PCA始终找到能够最大化方差的维度,并按方差递减的顺序排列它们。方差最大的一维将成为第一个轴(x轴)。经过PCA变换后,方差第二大的维度将成为第二维(y轴)。然而,这些轴的极性(符号)是任意的。优化过程可以自由地镜像(翻转)向量(点)在x轴、y轴或两者周围。
现在我们已经看到了PCA的实际应用,接下来让我们看看它是如何找到那些主成分的,这些主成分使得我们能够在不失去太多信息的情况下,以更少的维度来处理数据。
4.3.2 奇异值分解(SVD)
PCA的核心是一个叫做奇异值分解(SVD)的数学过程。SVD是一种将任何矩阵分解为三个因子的算法,这三个矩阵可以相乘以重建原始矩阵。这就像是为一个大整数找到恰好三个整数因子一样。但你的因子不是标量整数,而是具有特殊性质的二维实矩阵。
假设我们有一个由m个n维点组成的数据集,表示为矩阵W。在其完整版本中,W的SVD将会是这样的数学表示(假设m > n):
矩阵U、S和V具有特殊的性质。矩阵U和V是正交的,这意味着如果你将它们与它们的转置矩阵相乘,你将得到单位矩阵。而S是对角矩阵,这意味着它只有对角线上的元素是非零的。
请注意这个公式中的等号。这意味着如果你将U、S和V相乘,你将得到完全相同的W,即原始的TF–IDF向量矩阵,每一行对应一个文档。你可以看到,这些矩阵的最小维度仍然是n,而你希望减少维度的数量。这就是为什么在本章中,你将使用SVD的简化版或截断版。也就是说,你只会关注你感兴趣的前p个维度。
此时,你可能会问:“等等,难道我不能做完整的SVD,然后选择具有最多方差的维度并保留这些吗?”你完全正确——你确实可以——这就是SVD算法的核心!然而,使用截断SVD还有其他好处。特别是,有几种算法可以高效地计算大矩阵的截断SVD分解,特别是当矩阵是稀疏矩阵时。稀疏矩阵是指大部分单元格中的值相同(通常是零或NaN)的矩阵。NLP中的BOW和TF–IDF矩阵几乎总是稀疏的,因为大多数文档并不包含词汇表中的很多词汇。
截断SVD的数学形式如下:
在这个公式中,m和n分别是原始矩阵的行数和列数,而p是你希望保留的维度数量。例如,在马的例子中,如果我们想将马的点云显示在二维空间中,p将等于2。对于p值为2的情况,矩阵V将包含二维行向量,每个向量对应一个文档的m维TF–IDF向量,这些向量将在二维散点图中进行绘制。
Scikit-learn的TruncatedSVD.fit()方法将近似地求解公式4.2,用于获取V中二维向量,当p值为2时。使用SVD进行LSA时,p代表你希望从文档中计算的主题数量。当然,p需要小于m和n。你不能创建比词汇表中的单词数量n还多的主题维度,也不能创建比语料库中的文档数量m还多的主题。
请注意公式中的“近似等于”符号——因为我们正在丢失维度,所以我们不能指望通过乘以因子得到完全相同的矩阵!总是会有一些信息损失。然而,我们得到的是一种用比原始表示更少的维度来表示数据的新方式。对于我们的马点云,我们现在能够传达它的“马性”本质,而不需要打印庞大的三维图。而当PCA在实际中使用时,它可以将数百维或数千维的数据简化为更短的向量,这些向量更容易进行分析、聚类和可视化。
那么,矩阵U、S和V有何用处呢?目前,我们将简要介绍它们的作用。在下一章,我们将深入探讨这些矩阵在LSA中的应用。
让我们从VT开始——或者更确切地说,从它的转置版本V开始。V矩阵的列有时被称为主方向,有时被称为主成分。由于本章使用的scikit-learn库采用了后者的约定,我们也将坚持使用它。
你可以将V看作一个“变换工具”,用于将数据从“旧”空间(它在矩阵W中的表示)映射到新的、低维的空间。假设我们向我们的3D马点云中添加了一些新点,现在想了解这些新点在我们的二维表示中会在哪里,而不需要为所有点重新计算变换。为了将每个新点q映射到二维图上的位置,你所需要做的就是将它乘以V:
那么,U · S的含义是什么呢?通过一些代数技巧,你可以发现它实际上是你的数据映射到新空间的结果!基本上,它是你的数据点在新的、更低维度表示中的形式。
4.4 潜在语义分析(LSA)
最后,我们可以停止“玩马”并回到话题建模!让我们看看你关于降维、PCA和SVD的所有知识,如何在我们讨论从文本数据中发现话题和概念时变得更加有意义。
首先,让我们从数据集本身开始。你将使用在4.1节中用于LDA分类器的相同评论语料库,并使用TF–IDF将其转换为一个矩阵。你可能还记得,结果称为术语-文档矩阵。这个名字很有用,因为它让你对矩阵的行和列包含的内容有了直观的理解:行包含术语——你的词汇表中的词——而列包含文档。
让我们重新运行列表4.1和4.2,重新获取我们的TF–IDF矩阵。在深入讨论LSA之前,我们检查了矩阵的形状:
>>> tfidf_docs.shape
(5000, 19169)
我们在这里得到了什么?这是一个19,169维的数据集,其空间由语料库词汇表中的术语定义。处理这个空间中每个向量的单一表示非常麻烦,因为每个向量中有将近20,000个数字——比消息本身还多!而且,很难判断这些消息或其中的句子在概念上是否相似——例如,“leave this page”和“go away”这类表达,尽管它们的意思非常接近,但会有非常低的相似度评分。因此,以这种方式表示的数据在进行文档聚类和分类时变得更加困难。
还要注意的是,在你的5,000条消息中,只有650条(13%)被标记为有毒消息。所以你有一个不平衡的训练集,大约有8:1的无毒评论与有毒评论的比例(包括人身攻击、猥亵、种族歧视等)。而且你有一个庞大的词汇表——你的词汇标记数(25,172)大于你所拥有的4,837条消息(样本)数。所以你有比评论更多的唯一词汇(或词汇表),当你与有毒消息的数量进行比较时,这一差距会更大。这是过拟合的一个典型案例。仅有词汇表中的一些独特单词会被标记为数据集中的“有毒”词汇。
过拟合意味着你将“只依赖”词汇表中的几个单词。因此,你的有毒内容过滤器将依赖于这些有毒单词出现在其过滤的有毒消息中。这就留下了被恶意用户利用的空间,他们只需使用这些有毒单词的同义词就能绕过你的过滤器。如果你的词汇表不包括这些新的同义词,那么你的过滤器将错误地将这些巧妙构造的评论归类为无毒。
而这种过拟合问题在NLP中是固有的。很难找到一个标注好的自然语言数据集,涵盖所有可能的变体,它们可能共享一个标签。我们找不到一个理想的评论集,其中包含人们表示有毒和无毒内容的所有不同方式,只有少数几家公司有资源创建这样的数据集。所以我们其余的人需要对抗过拟合。你必须使用能在少量示例上很好地泛化的算法。
对抗过拟合的主要方法是将数据映射到一个新的、低维的空间。这个新空间的定义是词的加权组合,或者是语料库中以各种方式讨论的话题。使用话题而不是特定术语频率来表示消息,将使你的NLP管道更加通用,并使你的垃圾邮件过滤器能够处理更广泛的消息。这正是LSA的作用——它使用我们在上一节中发现的SVD方法,找到新的话题维度,并最大化方差。
这些新话题不一定与我们人类认为的话题(如宠物或历史)相关。机器并不理解词的组合含义,只知道它们是一起出现的。当它看到像“dog”、“cat”和“love”这样的词一起出现时,它会将它们放在一个话题中,但并不知道这个话题可能是关于宠物的。它可能会在同一个话题中包含“domesticated”和“feral”这样的词——它们意味着完全相反的事情。如果这些词在相同的文档中经常一起出现,LSA会为它们的同一话题赋予较高的分数。我们人类需要查看每个话题中权重较高的词,并为它们命名。
不过,你不需要为话题命名就可以使用它们。就像你在前几章中没有分析所有数千维的词干BOW向量或TF–IDF向量一样,你不必知道所有话题的含义。你仍然可以像操作TF–IDF向量那样,使用这些新的话题向量做向量运算。你可以对它们进行加法和减法,并基于话题表示而非术语频率表示来估计文档之间的相似性。而且这些相似性估计会更准确,因为你的新表示实际上考虑了令牌的含义及其与其他令牌的共现。
4.4.1 深入语义分析
但够了,别再讨论LSA了——让我们开始编程吧!这一次,我们将使用另一个scikit-learn工具,名为TruncatedSVD,它执行我们在上一章中讨论的截断SVD方法。我们本可以使用你在上一节看到的PCA模型,但我们选择这种更直接的方法,因为它能帮助我们更好地理解底层的运作过程。此外,TruncatedSVD是专为处理稀疏矩阵设计的,所以它在大多数TF–IDF和BOW矩阵上表现更好。
我们将从将维度从9,232减少到16开始。稍后我们将解释为什么选择这个数字。
示例 4.8 使用TruncatedSVD进行LSA
>>> from sklearn.decomposition import TruncatedSVD
>>>
>>> svd = TruncatedSVD(n_components=16, n_iter=100) #1
>>> columns = ['topic{}'.format(i) for i in range(svd.n_components)]
>>> svd_topic_vectors = svd.fit_transform(tfidf_docs) #2
>>> svd_topic_vectors = pd.DataFrame(svd_topic_vectors, columns=columns,\
... index=index)
>>> svd_topic_vectors.round(3).head(6)
topic0 topic1 topic2 ... topic13 topic14 topic15
comment0 0.121 -0.055 0.036 ... -0.038 0.089 0.011
comment1 0.215 0.141 -0.006 ... 0.079 -0.016 -0.070
comment2 0.342 -0.200 0.044 ... -0.138 0.023 0.069
comment3 0.130 -0.074 0.034 ... -0.060 0.014 0.073
comment4! 0.166 -0.081 0.040 ... -0.008 0.063 -0.020
comment5 0.256 -0.122 -0.055 ... 0.093 -0.083 -0.074
#1 TruncatedSVD算法是随机的,所以我们将对数据进行100次迭代以平衡这一点。
#2 fit_transform方法一步到位地将TF–IDF向量分解并转换为话题向量。
你刚刚使用fit_transform方法得到的是文档的向量在新表示中的形式。它们不再用19,169个频率计数来表示,而是只用了16个维度。这个矩阵也叫做文档-话题矩阵。通过查看列,你可以看到每个话题在每条评论中有多大程度的“表达”。
注意:我们使用的方法与我们描述的矩阵分解过程有什么关系吗?你可能已经意识到,fit_transform方法返回的正是U · S——你的TF–IDF向量被投影到新空间。而你的V矩阵保存在TruncatedSVD对象的components_变量中。
如果你想探索话题,你可以通过检查每个话题中的每个词或词组的权重来发现它们包含了哪些词。
首先,让我们将词汇分配到你转换后的所有维度中。你需要按照正确的顺序获取它们,因为TfidfVectorizer将词汇表存储为一个字典,它将每个术语映射到一个索引编号(列号):
>>> list(tfidf_model.vocabulary_.items())[:5] #1
[('you', 18890), ('have', 8093), ('yet', 18868), ('to', 17083), ('identify', 8721)]
>>> column_nums, terms = zip(*sorted(zip(tfidf_model.vocabulary_.values(),
... tfidf_model.vocabulary_.keys()))) #2
>>> terms[:5]
('\n', '\n ', '\n \n', '\n \n ', '\n ')
#1 将词汇表转化为可迭代对象,使用items()方法列出前五项
#2 按照术语计数排序词汇表;这个zip(*sorted(zip()))模式在你想按非最左边的元素排序时很有用,然后排序完成后再重新组合。
现在,你可以创建一个包含权重的漂亮的pandas DataFrame,并正确标记所有列和行。但看起来我们的前几个术语只是不同的换行符组合——这没什么用处!
数据集的提供者应该做得更好,清理工作做得更仔细。让我们用pandas的DataFrame.sample()方法来看一些随机词汇:
>>> topic_term_matrix = pd.DataFrame(
... svd.components_, columns=terms,
... index=['topic{}'.format(i) for i in range(16)])
>>> pd.options.display.max_columns = 8
>>> topic_term_matrix.sample(5, axis='columns',
... random_state=271828).head(4) #1
...
littered unblock.(t•c orchestra flanking civilised
topic0 0.000268 0.000143 0.000630 0.000061 0.000119
topic1 0.000297 -0.000211 -0.000830 -0.000088 -0.000168
topic2 -0.000367 0.000157 -0.001457 -0.000150 -0.000133
topic3 0.000147 -0.000458 0.000804 0.000127 0.000181
#1 使用相同的random_state参数以获取相同的输出
这些词看起来都不太具有毒性。让我们看看一些我们直观上预期出现在有毒评论中的词,并看看它们在不同话题中的权重:
>>> pd.options.display.max_columns = 8
>>> toxic_terms = topic_term_matrix[
... 'pathetic crazy stupid idiot lazy hate die kill'.split()
... ].round(3) * 100 #1
...
>>> toxic_terms
pathetic crazy stupid idiot lazy hate die kill
topic0 0.3 0.1 0.7 0.6 0.1 0.4 0.2 0.2
topic1 -0.2 0.0 -0.1 -0.3 -0.1 -0.4 -0.1 0.1
topic2 0.7 0.1 1.1 1.7 -0.0 0.9 0.6 0.8
topic3 -0.3 -0.0 -0.0 0.0 0.1 -0.0 0.0 0.2
topic4 0.7 0.2 1.2 1.4 0.3 1.7 0.6 0.0
topic5 -0.4 -0.1 -0.3 -1.3 -0.1 0.5 -0.2 -0.2
topic6 0.0 0.1 0.8 1.7 -0.1 0.2 0.8 -0.1
...
>>> toxic_terms.T.sum()
topic0 2.4
topic1 -1.2
topic2 5.0
topic3 -0.2
topic4 5.9
topic5 -1.8
topic6 3.4
topic7 -0.7
topic8 1.0
topic9 -0.1
topic10 -6.6
...
#1 乘以100使权重更容易读取并相互比较。
话题2和话题4似乎更可能包含有毒情绪,而话题10看起来像是一个“反有毒”话题。因此,和有毒性相关的词语在一些话题中会有正向影响,而在另一些话题中则可能有负向影响。没有单一的明显有毒话题编号。
转换过程只是将你传递给它的任何内容与V矩阵相乘,V矩阵保存在components_中。你可以查看TruncatedSVD的代码,亲自验证这一点!
4.4.2 截断SVD还是PCA?
此时,你可能会问:“为什么我们在马的例子中使用了scikit-learn的PCA类,而在评论数据集的话题分析中使用了TruncatedSVD?难道我们没有说过PCA是基于SVD算法的吗?”你是对的——如果你查看scikit-learn中PCA和TruncatedSVD的实现,你会发现它们的大部分代码是相似的。它们使用相同的SVD分解算法来处理矩阵。然而,有几个区别使得每个模型在某些使用场景下可能更为合适。
最大的区别是TruncatedSVD在分解之前不对矩阵进行中心化,而PCA会进行中心化。这意味着你可以在执行TruncatedSVD之前通过从矩阵中减去按列计算的均值来对数据进行中心化,方法如下:
>>> tfidf_docs = tfidf_docs - tfidf_docs.mean()
对于这两种方法,你将得到相同的结果。你可以自己尝试,将TruncatedSVD应用于中心化后的数据,并与PCA的结果进行比较,看看会有什么不同!
数据中心化对PCA的一些属性很重要,正如你可能记得的,它在NLP之外有许多应用。然而,对于大多数稀疏的TF–IDF矩阵,中心化并不总是有意义的。在大多数情况下,中心化会使稀疏矩阵变得密集,从而导致模型运行更慢,并且占用更多内存。PCA通常用于处理密集矩阵,并且可以为小型矩阵计算精确的完整矩阵SVD。相对而言,TruncatedSVD已经假定输入矩阵是稀疏的,并使用更快的近似、随机化方法。因此,它比PCA在处理你的TF–IDF数据时效率更高。
4.4.3 LSA在毒性检测中的表现如何?
你已经花了足够的时间分析话题——现在让我们看看使用较低维度表示的评论模型表现如何!你将使用我们在列表4.3中运行的相同代码,但这次应用于新的16维向量。此次分类将会更快:
>>> X_train_16d, X_test_16d, y_train_16d, y_test_16d = train_test_split(
... svd_topic_vectors, comments.toxic.values, test_size=0.5,
... random_state=271828)
>>> lda_lsa = LinearDiscriminantAnalysis(n_components=1)
>>> lda_lsa = lda_lsa.fit(X_train_16d, y_train_16d)
>>> round(float(lda_lsa.score(X_train_16d, y_train_16d)), 3)
0.881
>>> round(float(lda_lsa.score(X_test_16d, y_test_16d)), 3)
0.88
哇,差别真大!训练集的分类准确率从使用TF–IDF向量时的99.9%下降到88.1%,但测试集的准确率竟然提高了33%!这是一次相当大的进步。
让我们检查F1分数:
>>> from sklearn.metrics import f1_score
>>> f1_score(y_test_16d, lda_lsa.predict(X_test_16d).round(3))
0.342
相比于使用TF–IDF向量进行分类,你几乎将F1分数提高了一倍!不错吧。
除非你有完美的记忆,否则到现在你一定对来回翻阅之前模型的表现感到相当烦躁。而在实际的NLP工作中,你可能会尝试比我们玩具示例中更多的模型。这就是数据科学家们记录他们模型参数和性能的原因,他们通常会用超参数表来记录。
让我们自己做一个。首先,回忆我们在运行LDA分类器对TF–IDF向量进行分类时获得的表现,并将其保存到表中:
>>> hparam_table = pd.DataFrame()
>>> tfidf_performance = {'classifier': 'LDA',
... 'features': 'TF–IDF (spacy tokenizer)',
... 'train_accuracy': 0.99 ,
... 'test_accuracy': 0.554,
... 'test_precision': 0.383 ,
... 'test_recall': 0.12,
... 'test_f1': 0.183}
>>> hparam_table = hparam_table.append(
... tfidf_performance, ignore_index=True) #1
#1 使用ignore_index参数将字典形式的记录添加到pandas DataFrame
实际上,因为你将提取这些分数来比较几个模型,所以创建一个执行以下操作的函数可能更有意义。
示例 4.9 创建超参数表记录的函数
from sklearn.metrics import precision_score, recall_score
>>> def hparam_rec(model, X_train, y_train, X_test, y_test,
... model_name, features):
... return {
... 'classifier': model_name,
... 'features': features,
... 'train_accuracy': float(model.score(X_train, y_train)),
... 'test_accuracy': float(model.score(X_test, y_test)),
... 'test_precision':
... precision_score(y_test, model.predict(X_test)),
... 'test_recall':
... recall_score(y_test, model.predict(X_test)),
... 'test_f1': f1_score(y_test, model.predict(X_test))
... }
>>> lsa_performance = hparam_rec(lda_lsa, X_train_16d, y_train_16d,
... X_test_16d, y_test_16d, 'LDA', 'LSA (16 components)'))
>>> hparam_table = hparam_table.append(lsa_performance, ignore_index=True)
>>> hparam_table.T #1
0 1
classifier LDA LDA
features TF–IDF (spacy tokenizer) LSA (16d)
train_accuracy 0.99 0.8808
test_accuracy 0.554 0.88
test_precision 0.383 0.6
test_recall 0.12 0.239264
test_f1 0.183 0.342105
#1 转置表格以便打印
你甚至可以进一步封装大部分分析到一个漂亮的函数中,这样你就不必再进行复制粘贴:
>>> def evaluate_model(X,y, classifier, classifier_name, features):
... X_train, X_test, y_train, y_test = train_test_split(
... X, y, test_size=0.5, random_state=271828)
... classifier = classifier.fit(X_train, y_train)
... return hparam_rec(classifier, X_train, y_train, X_test, y_test,
... classifier_name, features)
4.4.4 其他降维方法
SVD无疑是最流行的降维方法,因此在考虑话题建模时,LSA是你的首选。然而,还有一些其他降维技术也可以用来达到相同的目标。并非所有方法都用于NLP,但了解它们也是很有益的。我们在这里提到两种方法:随机投影和非负矩阵分解(NMF)。
随机投影是一种将高维数据投影到低维空间的方法,从而保持数据点之间的距离。它的随机性使得它更易于在并行机器上运行。它还允许算法使用更少的内存,因为它不需要像PCA那样将所有数据同时加载到内存中。由于其计算复杂度较低,随机投影有时可以用于维度非常高的数据集,尤其是在分解速度是一个重要因素时。
NMF是另一种矩阵分解方法,类似于SVD,但它假设数据点和分量都是非负的。它通常用于图像处理和计算机视觉,但在NLP和话题建模中偶尔也会派上用场。在大多数情况下,你最好坚持使用LSA,因为它在底层使用了经过验证的SVD算法。
4.5 潜在狄利克雷分配(LDA)
在本章的大部分时间里,你学习了潜在语义分析(LSA)以及如何使用scikit-learn将词汇和短语的潜在含义表示为向量。LSA应该是大多数话题建模、语义搜索或基于内容的推荐引擎的首选方法。它的数学原理简单高效,并且它产生的线性变换可以应用于新的自然语言数据批次,而无需训练并且准确率损失较小。在这里,你将学习一个更复杂的算法——潜在狄利克雷分配(LDA)。在某些情况下,LDA会为你提供稍微更好的结果。
LDA做了很多与LSA相似的事情(在底层也使用SVD),但与LSA不同,LDA假设词频遵循狄利克雷分布。与LSA的线性数学方法相比,它在将词分配到话题上的统计方式更加精确。
LDA通过一种类似于你在本章早些时候思维实验中所做的方式创建一个语义向量空间模型(像你的话题向量)。在你的思维实验中,你根据词语在同一文档中同时出现的频率,手动将词语分配到话题中。然后,通过每个话题在该文档中的分布和共现,便能确定该文档的主题混合。这使得LDA的话题模型更容易理解,因为分配给话题的词和分配给文档的话题通常比LSA更有意义。
LDA假设每个文档是你在开始训练LDA模型时选择的某些话题的混合(线性组合)。LDA还假设每个话题可以通过词的分布(词频)来表示。每个话题在文档中的概率或权重,以及一个词被分配到某个话题的概率,都假设从一个狄利克雷概率分布开始(如果你记得统计学中的先验分布)。这也是算法得名的由来。
4.5.1 LDA的思路
LDA的思路最初是为生物学应用而开发的,后来才进入NLP领域。斯坦福的研究人员通过将我们的思维实验倒过来,提出了这个想法。他们想象了一台只能掷骰子(生成随机数)的机器,能够生成你想要分析的语料库中的文档。由于你只处理词袋,他们去除了那些将词按顺序组合成有意义的部分,即写成一个真实文档的部分。他们只是建模了每个文档的词袋中会包含哪些词的统计信息。
他们想象了一台机器,它在生成特定文档的词汇混合时,只需要做出两个选择。文档生成器将随机选择这些词,基于某种概率分布来做出选择,就像选择骰子的面数和掷骰子组合来生成一个《龙与地下城》角色卡一样。你的文档“角色卡”只需要两次掷骰,但骰子很大,并且有多个,它们有复杂的规则来组合以产生你想要的不同值的概率。你希望为词的数量和话题的数量设置特定的概率分布,这样它们就能匹配人类分析的实际文档中的这些值的分布,包括话题和词。
这两次掷骰表示如下:
- 生成文档的词数(泊松分布)
- 为文档混合的主题数(狄利克雷分布)
当机器得到这两个数字后,难题才开始:为文档选择词语。想象中的词袋生成机器将遍历这些话题,并根据每个话题的相关性随机选择词,直到它达到文档应该包含的词数。确定这些词与话题的概率——即每个话题的词的适用性——是难点。但一旦确定了这些,机器就会从词汇-话题矩阵中查找每个话题的词的概率。如果你不记得这个矩阵长什么样,可以回顾一下本章早些时候的简单示例。
这台机器只需要一个泊松分布参数(在第一步中的骰子掷出的数字),它告诉机器文档的平均长度应该是多少,还需要几个额外的参数来定义设定话题数的狄利克雷分布。然后,你的文档生成算法需要一个词汇-话题矩阵,包含所有机器喜欢使用的词汇和话题。它还需要一个它喜欢“讨论”的话题混合。
让我们将文档生成(写作)问题反转回你原来的问题——从现有文档中估计话题和词。你需要测量或计算关于词和话题的这些参数以完成前两步。然后,你需要从一组文档中计算出词汇-话题矩阵。这就是LDA所做的工作。
Blei和Ng意识到,他们可以通过分析语料库中文档的统计信息来确定步骤1和步骤2的参数。例如,对于步骤1,他们可以计算语料库中文档的词汇袋中词语的平均数量(或n-grams),就像这样:
>>> total_corpus_len = 0
>>> for document_text in comments.text:
... total_corpus_len += len(spacy_tokenize(document_text))
>>> mean_document_len = total_corpus_len / len(comments.text)
>>> round(mean_document_len, 2)
54.21
或者,使用sum函数,像这样:
>>> sum([len(spacy_tokenize(t)) for t in comments.text]
... ) * 1. / len(comments.text)
54.206
请记住,你应该直接从词袋中计算这个统计数据。你需要确保在计算唯一术语之前,已经应用了任何停用词过滤或其他标准化处理。这样,你的计数就包括了词袋向量词汇中的所有词汇(你正在计算的所有n-grams),但只包括那些词袋中使用的词(例如,不包括停用词)。LDA算法依赖于词袋向量空间模型,而不像LSA那样使用TF–IDF矩阵作为输入。
LDA模型的第二个参数——话题数——稍微复杂一些。一个特定文档集中的话题数不能直接测量,直到你将词分配到这些话题中。就像k-means、k-最近邻和其他聚类算法一样,你必须提前告诉它k值。你可以猜测话题数(类似于k-means中的k,聚类的数量),然后检查这个猜测是否适合你的文档集。一旦你告诉LDA你要查找的话题数,它将找到每个话题中应该包含的词汇组合,以优化其目标函数。
你可以通过调整超参数(k,主题的数量)来优化它,直到它适合你的应用。如果你能衡量LDiA语言模型在表示文档意义方面的质量,你可以自动化这个优化。你可以用来进行优化的一个“成本函数”是LDiA模型在某些分类或回归问题中的表现好坏,比如情感分析、文档关键词标记或主题分析。你只需要一些标注的文档来测试你的主题模型或分类器。
4.5.2 LDiA评论主题模型
LDiA生成的主题通常更容易理解和“解释”。这是因为经常一起出现的单词会被分配到相同的主题,而人类通常也会期望如此。LSA试图保持最初分开的事物继续分开,而LDiA则试图将最初靠近的事物保持在一起。
这听起来好像是相同的,但其实不是。数学优化的是不同的目标。你的优化器有不同的目标函数,因此会达到不同的目标。为了在低维空间中保持相似的高维向量紧密相连,LDiA必须以非线性方式扭曲和变形空间(以及向量)。在做3D可视化并对结果向量进行2D投影之前,这很难直观理解。
让我们看看它如何在我们的有毒评论数据集上工作。首先,计算TF-IDF向量,然后为每个评论(文档)计算一些主题向量。我们假设仍然使用16个主题(组件)来分类评论的毒性。保持主题数量(维度)较低有助于减少过拟合。
LDiA使用原始的BOW计数向量,而不是归一化的TF-IDF向量。你已经在第3章中做过这个过程,示例如下。
示例4.10 计算评论数据集的BOW向量
>>> from sklearn.feature_extraction.text import CountVectorizer
>>> counter = CountVectorizer(tokenizer=spacy_tokenize)
>>> bow_docs = pd.DataFrame(
... counter.fit_transform(
... raw_documents=comments.text).toarray(), index=index)
>>> column_nums, terms = zip(*sorted(zip(counter.vocabulary_.values(),
... counter.vocabulary_.keys())))
>>> bow_docs.columns = terms
我们来检查一下第一个评论(标记为comment0)的计数是否合理:
>>> comments.loc['comment0'].text
'you have yet to identify where my edits violated policy. 4 july 2005 02:58 (utc)'
>>> bow_docs.loc['comment0'][bow_docs.loc['comment0'] > 0].head()
1
( 1
) 1
. 1
02:58 1
Name: comment0, dtype: int64
我们将以与LSA在TF-IDF矩阵中应用的方式,将LDiA应用于计数向量矩阵:
>>> from sklearn.decomposition import LatentDirichletAllocation as LDiA
>>> ldia = LDiA(n_components=16, learning_method='batch')
>>> ldia = ldia.fit(bow_docs) #1
>>> ldia.components_.shape
(16, 19169)
#1 LDiA比PCA或SVD需要更长的时间,尤其是在主题数量较多且语料库中的单词数量较大时。
你的模型将你的19,169个单词(术语)分配给了16个主题(组件)。让我们看看前几个单词是如何分配的。请记住,你的计数和主题可能会与我们的不同。LDiA是一个随机算法,它依赖于随机数生成器做出关于将单词分配到主题的统计决策。因此,每次运行sklearn.LatentDirichletAllocation(或任何LDiA算法)时,除非你将随机种子设置为固定值,否则你会得到不同的结果:
>>> pd.set_option('display.width', 75)
>>> term_topic_matrix = pd.DataFrame(ldia.components_, index=terms,\
... columns=columns) #1
>>> term_topic_matrix.round(2).head(3)
topic0 topic1 ... topic14 topic15
a 21.853 0.063 ... 0.063 922.515
aaaaaaaaaahhhhhhhhhhhhhh 0.063 0.063 ... 0.063 0.063
aalst 0.063 0.063 ... 0.063 0.063
aap 0.063 0.063 ... 2.062 0.062
#1 这与我们为LSA主题模型构建的矩阵是一样的,只是转置了!
看起来LDiA主题向量中的值比LSA主题向量的值分布更广泛——有许多接近零的值,但也有一些非常大的值。让我们用我们在进行LSA主题建模时使用的相同方法来处理。我们可以查看典型的有毒词汇,看看它们在每个主题中的突出程度。
示例4.11 有毒词在不同LDiA主题中的普遍性
>>> toxic_terms = term_topic_matrix.loc['pathetic crazy stupid lazy idiot hate die kill'.split()].round(2)
>>> toxic_terms
topic0 topic1 topic2 ... topic13 topic14 topic15
pathetic 1.06 0.06 32.35 ... 0.06 0.06 9.47
crazy 0.06 0.06 3.82 ... 1.17 0.06 0.06
stupid 0.98 0.06 4.58 ... 8.29 0.06 35.80
lazy 0.06 0.06 1.34 ... 0.06 0.06 3.97
idiot 0.06 0.06 6.31 ... 0.06 1.11 9.91
hate 0.06 0.06 0.06 ... 0.06 480.06 0.06
die 0.06 0.06 26.17 ... 0.06 0.06 0.06
kill 0.06 4.06 0.06 ... 0.06 0.06 0.06
这与LSA对我们有毒词汇的表示看起来非常不同!看起来某些词在某些主题中可能有较高的主题-词权重,而在其他主题中则没有。topic0和topic1似乎对有毒词汇“漠不关心”,而topic2和topic15对至少四到五个有毒词汇有相当大的主题-词权重。而topic14对“hate”这个词的权重非常高!
让我们看看在这个主题中得分较高的其他词汇。正如你之前看到的,由于我们没有对数据集进行任何预处理,许多词汇并不特别有趣。我们来关注那些是单词且长度超过三个字母的词——这样可以去除很多停用词:
>>> non_trivial_terms = [term for term in term_topic_matrix.index
if term.isalpha() and len(term) > 3]
>>> term_topic_matrix.topic14.loc[non_trivial_terms].sort_values(ascending=False)[:10]
hate 480.062500
killed 14.032799
explosion 7.062500
witch 7.033359
june 6.676174
wicked 5.062500
dead 3.920518
years 3.596520
wake 3.062500
arrived 3.062500
看起来这个主题中的许多词汇之间存在语义关系。像“killed”和“hate”或“wicked”和“witch”这样的词汇似乎都属于有毒领域。你可以看到,即使是快速浏览,这些词汇分配到主题中的方式也可以被理性解释或推理。
在拟合分类器之前,你需要为所有文档(评论)计算这些LDiA主题向量。让我们看看它们与LSA为这些相同文档生成的主题向量有何不同:
>>> ldia16_topic_vectors = ldia.transform(bow_docs)
>>> ldia16_topic_vectors = pd.DataFrame(ldia16_topic_vectors,\
... index=index, columns=columns)
>>> ldia16_topic_vectors.round(2).head()
topic0 topic1 topic2 ... topic13 topic14 topic15
comment0 0.0 0.0 0.00 ... 0.00 0.0 0.0
comment1 0.0 0.0 0.28 ... 0.00 0.0 0.0
comment2 0.0 0.0 0.00 ... 0.00 0.0 0.0
comment3 0.0 0.0 0.00 ... 0.95 0.0 0.0
comment4! 0.0 0.0 0.07 ... 0.00 0.0 0.0
你可以看到,这些主题的分配更加清晰分开。许多主题在分配给消息时的值为零。这是LDiA主题在基于NLP管道结果做出业务决策时,更容易向同事解释的原因之一。
所以,LDiA主题对人类来说效果很好,但对机器来说呢?你的LDA分类器在这些主题上的表现如何呢?
4.5.3 使用LDiA检测毒性
让我们看看这些LDiA主题在预测有用信息(如评论毒性)方面有多好。你将使用LDiA主题向量再次训练一个LDA模型(就像你之前使用TF-IDF向量和LSA主题向量时做的那样)。由于你在示例4.5中定义了一个方便的函数,你只需要几行代码来评估你的模型:
python
复制
>>> model_ldia16 = LinearDiscriminantAnalysis()
>>> ldia16_performance = evaluate_model(ldia16_topic_vectors,
... comments.toxic, model_ldia16, 'LDA', 'LDIA (16 components)')
>>> hparam_table = hparam_table.append(ldia16_performance,
... ignore_index=True)
>>> hparam_table.T
0 1 2
classifier LDA LDA LDA
features TF–IDF (spacy tokenizer) LSA (16d) LDIA (16d)
train_accuracy 0.99 0.8808 0.8688
test_accuracy 0.554 0.88 0.8616
test_precision 0.383 0.6 0.388889
test_recall 0.12 0.239264 0.107362
test_f1 0.183 0.342105 0.168269
看起来,16主题LDiA向量的分类性能比原始的TF-IDF向量差,后者没有进行主题建模。这是否意味着LDiA在这个案例中没有用呢?让我们不要太快放弃它,试试增加主题数量。
4.5.4 更公平的比较:32个LDiA主题
让我们再试一次,增加维度和主题。也许LDiA没有像LSA那样高效,因此需要更多的主题来分配单词。我们尝试32个主题(组件):
>>> ldia32 = LDiA(n_components=32, learning_method='batch')
>>> ldia32 = ldia32.fit(bow_docs)
>>> ldia32_topic_vectors = ldia32.transform(bow_docs)
>>> model_ldia32 = LinearDiscriminantAnalysis()
>>> ldia32_performance = evaluate_model(ldia32_topic_vectors,
... comments.toxic, model_ldia32, 'LDA', 'LDIA (32d)')
>>> hparam_table = hparam_table.append(ldia32_performance,
... ignore_index=True)
>>> hparam_table.T
0 1 2 3
classifier LDA LDA LDA LDA
features TF–IDF (spacy tokenizer) LSA (16d) LDIA (16d) LDIA (32d)
train_accuracy 0.99 0.8808 0.8688 0.8776
test_accuracy 0.554 0.88 0.8616 0.8796
test_precision 0.383 0.6 0.388889 0.619048
test_recall 0.12 0.239264 0.107362 0.199387
test_f1 0.183 0.342105 0.168269 0.301624
太好了!增加LDiA的维度几乎使模型的精度和召回率翻了一番,F1分数看起来好多了。更多的主题使得LDiA能更精确地划分主题,至少对于这个数据集,产生了更好地线性分离的主题。但这些向量表示的性能仍然不如LSA。LSA能够更高效地将评论主题向量分散开,从而在用超平面分割类时留下更宽的间隔。
你可以自由地浏览scikit-learn和gensim中可用的Dirichlet分配模型的源代码。它们有类似于LSA的API(sklearn.TruncatedSVD和gensim.LsiModel)。当我们在后续章节讨论总结时,我们会给你展示一个应用示例。找到可解释的主题,像那些用于总结的主题,就是LDiA擅长的,而且它在创建有用的线性分类主题方面也不差。
快速查找Python源代码
你之前看到了如何通过文档页面浏览sklearn的源代码,但实际上从Python控制台有一个更直接的方法。你可以通过任何Python模块上的
__file__属性来找到源代码路径,例如sklearn.__file__,并且在ipython(Jupyter控制台)中,你可以使用??查看任何函数、类或对象的源代码,例如LDA??:>>> import sklearn >>> sklearn.__file__ '/Users/hobs/anaconda3/envs/conda_env_nlpia/lib/python3.6/site-packages/sklearn/__init__.py' >>> from sklearn.discriminant_analysis\ ... import LinearDiscriminantAnalysis as LDA >>> LDA?? Init signature: LDA(solver='svd', shrinkage=None, priors=None, n_components =None, store_covariance=False, tol=0.0001) Source: class LinearDiscriminantAnalysis(BaseEstimator, LinearClassifierMixin, TransformerMixin): """Linear Discriminant Analysis A classifier with a linear decision boundary, generated by fitting class conditional densities to the data and using Bayes' rule. The model fits a Gaussian density to each class, assuming that all classes share the same covariance matrix.""" ...
这对于函数和类不起作用,特别是当它们是扩展时,其源代码被隐藏在已编译的C++模块中。
4.6 距离与相似度
我们需要重新审视我们在第二章和第三章中讨论的相似度得分,确保你的新主题向量空间能够与之兼容。记住,你可以使用相似度得分(和距离)来衡量两个文档之间的相似度或距离,基于你用来表示它们的向量的相似度(或距离)。
例如,你可以使用相似度得分(和距离)来查看你的LSA主题模型与第三章中的高维TF-IDF模型的契合度。你将看到,在消除许多高维BOW中包含的信息后,你的模型在保留这些距离方面表现如何。你可以检查主题向量之间的距离,并确定这是否是一个良好的表示,能够反映文档主题内容之间的距离。你希望检查的是,具有相似含义的文档是否在你的新主题向量空间中接近。
LSA保留了大距离,但并不总是保留较小的距离(即文档之间关系的精细“结构”)。基础的SVD算法专注于最大化新主题向量空间中所有文档之间的方差。
特征向量(如词向量、主题向量、文档上下文向量等)之间的距离驱动了NLP管道或任何机器学习管道的性能。那么,在高维空间中衡量距离有什么选择呢?你应该为特定的NLP问题选择哪种方法?你可能熟悉一些来自几何课或线性代数的常见示例,但许多其他方法可能对你来说是新的:
- 欧几里得/笛卡尔距离或均方根误差(RMSE)—2范数或L2
- 平方欧几里得距离;平方和距离(SSD)—L2范数的平方
- 余弦或角度距离或投影距离—标准化点积
- 闵可夫斯基距离—p范数或Lp
- 分数距离,分数范数—p范数或Lp,0 < p < 1
- 曼哈顿距离,城市街区距离,出租车距离;绝对距离和(SAD)—1范数或L1
- 杰卡德距离;逆集合相似度
- 马哈拉诺比斯距离
- 莱文斯坦距离或编辑距离
计算距离的多种方式证明了它的重要性。除了scikit-learn中的成对距离实现外,许多其他方法也在数学专业领域中使用,例如拓扑学、统计学和工程学。作为参考,所有可以在sklearn.metrics模块中计算的距离方法如下所示。
示例4.12 在sklearn中可用的成对距离
'cityblock', 'cosine', 'euclidean', 'l1', 'l2', 'manhattan', 'braycurtis',
'canberra', 'chebyshev', 'correlation', 'dice', 'hamming', 'jaccard',
'kulsinski', 'mahalanobis', 'matching', 'minkowski', 'rogerstanimoto',
'russellrao', 'seuclidean', 'sokalmichener', 'sokalsneath', 'sqeuclidean',
'yule'
距离度量通常是从相似度度量(得分)计算得来的,反之亦然,因此距离与相似度得分成反比。相似度得分的设计范围通常是0到1之间。典型的转换公式如下:
>>> similarity = 1. / (1. + distance)
>>> distance = (1. / similarity) - 1.
但是对于范围在0到1之间的距离和相似度得分(如概率),更常用的公式是:
>>> similarity = 1. - distance
>>> distance = 1. - similarity
而余弦距离有自己的约定来定义它所使用的值的范围。两个向量之间的角度距离通常作为两个向量之间最大可能角度分离的分数来计算,这个最大角度是180度或π弧度。因此,余弦相似度和距离是彼此的倒数:
>>> import math
>>> angular_distance = math.acos(cosine_similarity) / math.pi
>>> distance = 1. / similarity - 1.
>>> similarity = 1. - distance
为什么我们花这么多时间讨论距离?在本书的最后一部分,我们将讨论语义搜索。语义搜索的理念是找到与你的搜索查询具有最高语义相似度的文档——或最小的语义距离。在我们的语义搜索应用中,我们将使用余弦相似度,但正如你在本节中看到的那样,衡量文档相似度的方法有很多种。
4.7 通过反馈引导
之前所有的语义分析方法都没有考虑文档之间相似性的信息。我们创建了适用于通用规则集的最佳主题。我们对这些模型进行的无监督学习(用于特征(主题)提取)没有包含关于主题向量应该如何彼此接近的数据。我们没有允许任何关于主题向量最终位置或它们之间关系的“反馈”。
引导式或学习的距离度量是降维和特征提取的最新进展。通过调整聚类和嵌入算法报告的距离得分,你可以“引导”你的向量,使它们最小化某些成本函数。这样,你可以强制你的向量关注你感兴趣的信息内容的某些方面。
在前面关于LSA的部分,你忽略了所有关于文档的元信息。例如,对于评论,你忽略了消息的发送者。这是一个很好的主题相似性指示,可以用来指导你的主题向量转换(LSA)。
在一个招聘机构中,我们尝试通过计算每个文档的主题向量之间的余弦距离,将简历与职位描述进行匹配。这个方法效果还行,但我们很快发现,当我们根据候选人和负责帮助他们找工作的客户经理的反馈来引导主题向量时,结果要好得多。对于好的配对,主题向量被引导得比其他所有配对更靠近。
一种做法是计算两个质心之间的平均差异(就像你在LDA中做的那样),并将这种“偏差”的一部分加到所有简历或职位描述的向量中。这样做应该消除简历和职位描述之间的平均主题向量差异。像午餐时的“啤酒桶”这样的主题可能出现在职位描述中,但从未出现在简历中。类似地,像“水下雕塑”这样的奇怪爱好可能出现在一些简历中,但从未出现在职位描述中。引导你的主题向量可以帮助你将它们集中在你想要建模的主题上。
4.8 主题向量的力量
通过主题向量,你可以做很多事情,比如比较单词、文档、陈述和语料库的含义。你可以找到相似文档和陈述的聚类。你不再仅仅基于单词使用来比较文档之间的距离,也不再局限于完全基于单词选择或词汇的关键词搜索和相关性排名。现在,你可以找到与你的查询相关的文档,而不仅仅是与单词统计匹配良好的文档。
这就是语义搜索,不能与语义网混淆。语义搜索是强大的搜索引擎在你查询中没有很多单词时,仍能给你提供准确匹配文档的方式。这些高级搜索引擎使用文本的语义表示来区分《奶酪商店》中的Python包和佛罗里达宠物店水族馆中的蛇,同时仍然能够识别它与Ruby宝石的相似性。
语义搜索为你提供了一种寻找和生成有意义文本的工具,但我们的头脑不擅长处理高维对象、向量、超平面、超球体和超立方体。作为开发者和机器学习工程师,我们的直觉在三维以上会出现问题。例如,要在2D向量上进行查询,比如在Google地图上的纬度/经度位置,你可以快速找到附近的咖啡店,而无需做太多的搜索。你可以通过扫描(用眼睛或代码)靠近你的位置,然后向外螺旋搜索。或者,你可以用代码创建越来越大的边界框,检查每个框内的经纬度范围——这只是进行比较操作,应该能找到所有附近的东西。然而,使用超平面和超立方体作为搜索边界来划分高维向量空间(超空间)是不现实的,在许多情况下是不可能的。
正如Geoffrey Hinton所说:“为了处理14维空间中的超平面,想象一个3D空间,然后大声对自己说14。”如果你在年轻时读过Abbott的《Flatland》,你可能能比这种手势做得更好一些。你甚至可以尝试把头从你三维世界的窗户探出一点,瞥见外面的三维世界。就像在《Flatland》中一样,你在这一章中使用了许多2D可视化,帮助你探索在超空间中单词投射到你三维世界中的阴影。在深入探讨语义搜索的下一部分之前,你可能想回顾一下图4.1中的三维主题向量,并尝试想象,如果你再增加一个主题,创建一个四维的语言含义世界,那些向量会是什么样子。如果你的大脑没有感到疼痛,那说明你没有认真思考四维主题向量。如果你在锻炼大脑时感到燃烧,记住,你正在试图理解的复杂性爆炸比从二维到三维的复杂性增长还要大,并且是从一维数字世界到二维三角形、正方形和圆形的复杂性增长的指数级增长。在思考四维向量时,你开始接触到“维度的诅咒”。
4.8.1 语义搜索
当你根据文档中包含的单词或部分单词搜索文档时,这被称为全文搜索。这是搜索引擎的工作原理。它们将文档拆分成块(通常是单词),这些块可以使用倒排索引进行索引,就像你在教科书的后面找到的索引一样。处理拼写错误和打字错误需要很多书面工作和猜测,但它的确非常有效。
语义搜索是考虑到查询中单词的含义和你正在搜索的文档的含义的全文搜索。在本章中,你学习了两种方法——LSA和LDiA——来计算能够捕捉单词和文档语义(含义)的主题向量,这些向量具有恒定的长度,无论你的数据集的大小和内容如何。潜在语义分析最初被称为潜在语义索引的原因之一,是因为它承诺通过数值索引(如BOW和TF-IDF表)来支持语义搜索。随着越来越多更好的文本表示方法的出现(我们将在后续章节中讨论),语义搜索变得越来越相关。随着生成模型的爆炸性发展和检索增强生成(RAG)技术的普及,许多公司提供了存储和检索表示文本的向量的方式。Knowt是一个开源项目,创建了一个私人语义搜索向量数据库和RAG虚拟助手,用于私人自然语言文档,如你的日记或医疗记录。
但不同于BOW和TF-IDF表,语义向量表不能轻易地使用传统的倒排索引技术进行索引。倒排索引适用于离散向量或二进制向量,例如二进制或整数词文档向量表,因为索引只需要为每个非零离散维度维护一个条目。该维度的值要么存在,要么在引用的向量或文档中不存在。由于TF-IDF向量是稀疏的,大多数值为零,因此大多数文档的大多数维度在索引中不需要条目。
LSA和LDiA生成的主题向量是高维的、连续的并且是密集的(零值稀少)。语义分析算法并未为可扩展搜索生成高效的索引。事实上,在前一节中讨论的维度诅咒使得精确索引变得不可能。潜在语义索引中的索引部分曾是一种希望,而不是现实,因此这一术语实际上是一个误称。也许,这就是为什么LSA成为了描述生成主题向量的语义分析算法的更常用方式。
应对高维向量挑战的一种解决方案是使用局部敏感哈希(LSH)对其进行索引。LSH就像是一个邮政编码,它指定了超空间的一个区域,以便以后可以轻松找到,就像普通哈希一样,它是离散的,只依赖于向量中的值。但是,一旦超过大约12维,这种方法就不再完美。在图4.5中,每一行代表一个主题向量的大小(维度),从2维开始,一直到16维,就像你之前用于短信垃圾邮件问题的向量。
表格显示了如果你使用局部敏感哈希(LSH)来索引大量语义向量时,搜索结果的质量。当你的向量维度超过16时,你将很难返回任何有效的搜索结果。
如果没有索引,如何在100维向量上进行语义搜索呢?你现在已经知道如何使用LSA将查询字符串转换为主题向量,并且知道如何使用余弦相似度得分(标量积、内积或点积)来比较两个向量的相似性,找到最接近的匹配项。为了找到精确的语义匹配,你需要找到所有与特定查询(搜索)主题向量最接近的文档主题向量。(在专业术语中,这叫做穷举搜索。)但如果你有n个文档,你就必须与查询主题向量做n次比较。这就是大量的点积操作。
你可以在numpy中使用矩阵乘法来向量化这个操作,但这并不会减少操作的数量——它只是让操作变得更快,速度是原来的100倍。从根本上说,精确的语义搜索仍然需要为每个查询进行O(N)次乘法和加法运算,因此它的扩展性仅与语料库的大小成线性关系。对于一个大型语料库,比如Google搜索或甚至维基百科的语义搜索,这种方法是行不通的。
关键是要满足于“足够好”,而不是为了高维向量的完美索引或LSH算法而苦苦追求。现在已有几种开源实现的高效且准确的近似最近邻算法,这些算法使用LSH来高效实现语义搜索。我们将在第10章中进一步讨论它们。从技术上讲,这些索引或哈希解决方案不能保证你会找到所有最好的语义搜索匹配项,但如果你愿意牺牲一些精确度,它们几乎可以和常规的TF-IDF向量或BOW向量的倒排索引一样快地给你提供一个很好的接近匹配项的列表。
4.9 为你的机器人装备语义搜索
让我们利用你在主题建模中学到的新知识,来改进你在上一章开始构建的机器人。我们将专注于相同的任务:问答。
我们的代码实际上会和你在第三章的代码非常相似。我们仍然会使用向量表示法来找到数据集中最相似的问题,但这次,我们的表示将更接近于表示这些问题的含义。
首先,让我们像上一章一样加载问题和答案数据:
>>> REPO_URL = 'https://gitlab.com/tangibleai/community/qary-cli/-/raw/main'
>>> FAQ_DIR = 'src/qary/data/faq'
>>> FAQ_FILENAME = 'short-faqs.csv'
>>> DS_FAQ_URL = '/'.join([REPO_URL, FAQ_DIR, FAQ_FILENAME])
>>> df = pd.read_csv(DS_FAQ_URL)
下一步是将问题和查询都表示为向量。这个步骤我们需要加几行代码,让我们的表示更具语义性。由于我们的问答数据集较小,我们不需要应用LSH或其他任何索引算法;我们只需要逐一检查我们的所有问题,并选择最匹配的。
示例4.13 为使用语义相似度的机器人创建答案函数
>>> vectorizer = TfidfVectorizer()
>>> vectorizer.fit(df['question'])
>>> tfidfvectors = vectorizer.transform(df['question'])
>>> svd = TruncatedSVD(n_components=16, n_iterations=100)
>>> tfidfvectors_16d = svd.fit_transform(tfidfvectors)
>>>
>>> def bot_reply(question):
... question_tfidf = vectorizer.transform([question]).todense()
... question_16d = svd.transformnp.asarray(question_tfidf)
... idx = question_16d.dot(tfidfvectors_16d.T).argmax()
... print(
... f"Your question:\n {question}\n\n"
... f"Most similar FAQ question:\n {df['question'][idx]}\n\n"
... f"Answer to that FAQ question:\n {df['answer'][idx]}\n\n"
... )
让我们做一个简单的检查,确保我们的模型仍然能回答一些简单的问题:
>>> bot_reply("What's overfitting a model?")
Your question:
What's overfitting a model?
Most similar FAQ question:
What is overfitting?
Answer to that FAQ question:
When your test set accuracy is significantly lower than your training
↪ set accuracy.
现在,让我们给模型一个更难的挑战——就像之前模型没能很好处理的问题那样。它能做得更好吗?
>>> bot_reply("How do I decrease overfitting for Logistic Regression?")
Your question:
How do I decrease overfitting for Logistic Regression?
Most similar FAQ question:
How to reduce overfitting and improve test set accuracy for a
↪ LogisticRegression model?
Answer to that FAQ question:
Decrease the C value, this increases the regularization strength.
哇!看起来你新版本的机器人能够“意识到” decrease 和 reduce 有相似的含义。不仅如此,它还能够“理解” Logistic Regression 和 LogisticRegression 非常接近——这样的简单步骤几乎是你使用TF-IDF模型时无法做到的。
看起来你正在逐步接近构建一个真正强大的问答系统。在下一章中,你将看到如何通过比主题建模更好的方法来提升你的系统!
4.10 自我测试
你会使用哪些预处理技术来准备文本,以便更高效地进行LDiA主题建模?LSA呢? 你能想到一个TF-IDF比LSA表现更好的数据集或问题吗?相反的情况呢? 我们提到了将停用词过滤作为LDiA的预处理步骤。这种过滤何时会有益? 语义搜索的主要挑战是,密集的LSA主题向量无法进行倒排索引。你能解释为什么吗?
总结
-
你可以通过分析数据集中术语的共现来推导出单词和文档的含义。
-
SVD可用于语义分析,将TF-IDF和BOW向量分解并转换为主题向量。
-
超参数表可以用来比较不同管道和模型的性能。
-
当你需要进行可解释的主题分析时,使用LDiA。
-
无论你如何创建主题向量,它们都可以用于语义搜索,根据文档的含义来查找相关文档。