机器学习算法(二)
原文:
annas-archive.org/md5/790dc401711df8b2093de3b73e595cb9译者:飞龙
第八章:决策树和集成学习
在本章中,我们将讨论二进制决策树和集成方法。尽管它们可能不是最常见的分类方法,但它们提供了良好的简单性,并且可以在许多不需要高复杂性的任务中应用。当需要展示决策过程的工作原理时,它们也非常有用,因为它们基于一种可以在演示中轻松展示并逐步描述的结构。
集成方法是复杂算法的有力替代品,因为它们试图利用多数投票的统计概念。可以训练许多弱学习器来捕捉不同的元素并做出自己的预测,这些预测不是全局最优的,但使用足够数量的元素,从统计上讲,大多数预测将是正确的。特别是,我们将讨论决策树的随机森林和一些提升方法,这些方法稍微不同的算法可以通过关注误分类样本或通过持续最小化目标损失函数来优化学习过程。
二进制决策树
二进制决策树是一种基于顺序决策过程的结构。从根节点开始,评估一个特征并选择两个分支中的一个。这个过程会重复进行,直到达到一个最终的叶子节点,它通常代表我们寻找的分类目标。与其他算法相比,决策树在动态上似乎更简单;然而,如果数据集在保持内部平衡的同时可以分割,整个过程在预测上既直观又相对快速。此外,决策树可以有效地处理未归一化的数据集,因为它们的内部结构不受每个特征所取值的影响。在下图中,有未归一化的二维数据集的图和用逻辑回归和决策树获得的交叉验证分数:
决策树始终达到接近 1.0 的分数,而逻辑回归的平均分数略大于 0.6。然而,如果没有适当的限制,决策树可能会潜在地生长到每个节点中只有一个样本(或非常少的样本)。这种情况会导致模型过拟合,并且树无法正确泛化。使用一致的测试集或交叉验证可以帮助避免这个问题;然而,在关于 scikit-learn 实现的部分,我们将讨论如何限制树的增长。
二进制决策
让我们考虑一个输入数据集 X:
每个向量由 m 个特征组成,因此每个特征都可以作为基于(特征,阈值)元组的节点的好候选:
根据特征和阈值,树的结构将发生变化。直观上,我们应该选择最能分离我们的数据的特征,换句话说,一个完美的分离特征将只存在于一个节点,接下来的两个分支将不再基于它。在现实问题中,这往往是不可行的,因此需要找到最小化后续决策步骤数量的特征。
例如,让我们考虑一个学生群体,其中所有男生都有深色头发,所有女生都有金色头发,而这两个子集都有不同大小的样本。如果我们的任务是确定班级的组成,我们可以从以下细分开始:
然而,包含深色?的块将包含男性和女性(这是我们想要分类的目标)。这个概念用术语纯净度(或者更常见的是其对立概念,杂质)来表示。一个理想的场景是基于杂质为零的节点,这样后续的所有决策都只基于剩余的特征。在我们的例子中,我们可以简单地从颜色块开始:
根据颜色特征,现在得到的两个集合是纯净的,这足以满足我们的任务。如果我们需要更详细的细节,例如发长,必须添加其他节点;它们的杂质不会为零,因为我们知道,例如,既有长发男生也有长发女生。
更正式地说,假设我们定义选择元组如下:
这里,第一个元素是我们想要在某个节点上分割数据集所使用的特征的索引(它只会在开始时是整个数据集;每一步之后,样本数都会减少),而第二个是确定左右分支的阈值。最佳阈值的选择是一个基本元素,因为它决定了树的结构,因此也决定了其性能。目标是减少分割中剩余的杂质,以便在样本数据和分类结果之间有非常短的决策路径。
我们还可以通过考虑两个分支来定义总杂质度量:
在这里,D 是所选节点上的整个数据集,D[left] 和 D[right] 是通过应用选择元组得到的结果子集,而 I 是杂质度量。
杂质度量
要定义最常用的杂质度量,我们需要考虑目标类别的总数:
在某个节点 j,我们可以定义概率 p(i|j),其中 i 是与每个类别关联的索引 [1, n]。换句话说,根据频率主义方法,这个值是属于类别 i 的样本数与属于所选节点的总样本数之间的比率。
Gini 不纯度指数
Gini 不纯度指数定义为:
在这里,总和总是扩展到所有类别。这是一个非常常见的度量,并且被 scikit-learn 用作默认值。给定一个样本,Gini 不纯度衡量的是如果使用分支的概率分布随机选择标签时发生错误分类的概率。当节点中所有样本都被分类到单个类别时,该指标达到最小值(0.0)。
交叉熵不纯度指数
交叉熵度量定义为:
这个度量基于信息理论,并且仅在分割中存在属于单个类别的样本时假设为空值,而在类别之间有均匀分布时达到最大值(这是决策树中最坏的情况之一,因为它意味着还有许多决策步骤直到最终分类)。这个指标与 Gini 不纯度非常相似,尽管更正式地说,交叉熵允许你选择最小化关于分类不确定性的分割,而 Gini 不纯度最小化错误分类的概率。
在第二章《机器学习中的重要元素》中,我们定义了互信息的概念 I(X; Y) = H(X) - H(X|Y),作为两个变量共享的信息量,从而减少了由 Y 的知识提供的关于 X 的不确定性。我们可以使用这个来定义分割提供的信息增益:
当生长树时,我们首先选择提供最高信息增益的分割,并继续进行,直到满足以下条件之一:
-
所有节点都是纯净的
-
信息增益为零
-
已达到最大深度
错误分类不纯度指数
错误分类不纯度是最简单的指标,定义为:
在质量性能方面,这个指标并不是最佳选择,因为它对不同的概率分布(这可以很容易地驱动选择使用 Gini 或交叉熵指标进行细分)并不特别敏感。
特征重要性
当使用多维数据集生长决策树时,评估每个特征在预测输出值中的重要性可能很有用。在第三章《特征选择和特征工程》中,我们讨论了一些通过仅选择最显著的特征来降低数据集维度的方法。决策树提供了一种基于每个特征确定的不纯度减少的不同方法。特别是,考虑一个特征 x[i],其重要性可以确定如下:
求和扩展到所有使用x[i]的节点,而N[k]是达到节点k的样本数量。因此,重要性是所有仅考虑使用特征分割的节点的杂质减少的加权总和。如果采用 Gini 杂质指数,这个度量也称为Gini 重要性。
使用 scikit-learn 进行决策树分类
scikit-learn 包含DecisionTreeClassifier类,它可以训练具有 Gini 和交叉熵杂质度量的二叉决策树。在我们的例子中,让我们考虑一个具有三个特征和三个类别的数据集:
from sklearn.datasets import make_classification
>>> nb_samples = 500
>>> X, Y = make_classification(n_samples=nb_samples, n_features=3, n_informative=3, n_redundant=0, n_classes=3, n_clusters_per_class=1)
让我们先考虑一个默认 Gini 杂质度量的分类:
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score
>>> dt = DecisionTreeClassifier()
>>> print(cross_val_score(dt, X, Y, scoring='accuracy', cv=10).mean())
0.970
一个非常有趣的功能是能够将树以Graphviz格式导出,并将其转换为 PDF。
Graphviz 是一个免费工具,可以从www.graphviz.org下载。
要导出训练好的树,必须使用内置函数export_graphviz():
from sklearn.tree import export_graphviz
>>> dt.fit(X, Y)
>>> with open('dt.dot', 'w') as df:
df = export_graphviz(dt, out_file=df,
feature_names=['A','B','C'],
class_names=['C1', 'C2', 'C3'])
在这种情况下,我们使用了A、B和C作为特征名称,C1、C2和C3作为类别名称。一旦文件创建完成,可以使用命令行工具将其转换为 PDF:
>>> <Graphviz Home>bindot -Tpdf dt.dot -o dt.pdf
我们的示例图相当大,所以在下图中您只能看到分支的一部分:
如您所见,有两种类型的节点:
-
非终端,它包含分割元组(作为特征 <= 阈值)和正杂质度量
-
终端,其中杂质度量值为空且存在一个最终的目标类别
在这两种情况下,您都可以始终检查样本数量。这种类型的图在理解需要多少决策步骤非常有用。不幸的是,即使过程相当简单,数据集的结构可能导致非常复杂的树,而其他方法可以立即找到最合适的类别。当然,不是所有特征的重要性都相同。如果我们考虑树的根和第一个节点,我们会发现能够分离大量样本的特征;因此,它们的重要性必须高于所有终端节点的重要性,在终端节点中剩余的样本数量最少。在 scikit-learn 中,在训练模型后可以评估每个特征的 Gini 重要性:
>>> dt.feature_importances_
array([ 0.12066952, 0.12532507, 0.0577379 , 0.14402762, 0.14382398,
0.12418921, 0.14638565, 0.13784106])
>>> np.argsort(dt.feature_importances_)
array([2, 0, 5, 1, 7, 4, 3, 6], dtype=int64)
下图显示了重要性的绘图:
最重要的特征是 6、3、4 和 7,而例如特征 2 将非常少的样本分开,可以认为对于分类任务来说是非信息的。
在效率方面,也可以使用max_depth参数对树进行剪枝;然而,理解哪个值是最好的并不总是那么简单(网格搜索可以帮助完成这项任务)。另一方面,决定在每个分割点考虑的最大特征数更容易。可以使用max_features参数来完成这个目的:
-
如果是一个数字,该值将在每个分割时直接考虑
-
如果是
'auto'或'sqrt',将采用特征数量的平方根 -
如果是
'log2',将使用以 2 为底的对数 -
如果是
'None',将使用所有特征(这是默认值)
通常,当总特征数量不是太高时,默认值是最好的选择,尽管当太多特征可能相互干扰时,引入小的压缩(通过 sqrt 或 log2)是有用的。另一个有助于控制性能和效率的参数是 min_samples_split,它指定了考虑分割的最小样本数。以下是一些示例:
>>> cross_val_score(DecisionTreeClassifier(), X, Y, scoring='accuracy', cv=10).mean()
0.77308070807080698
>>> cross_val_score(DecisionTreeClassifier(max_features='auto'), X, Y, scoring='accuracy', cv=10).mean()
0.76410071007100711
>>> cross_val_score(DecisionTreeClassifier(min_samples_split=100), X, Y, scoring='accuracy', cv=10).mean()
0.72999969996999692
如前所述,找到最佳参数通常是一项困难的任务,而执行它的最佳方式是在包括所有可能影响准确性的值的同时进行网格搜索。
在前一个集合上使用逻辑回归(仅用于比较),我们得到:
from sklearn.linear_model import LogisticRegression
>>> lr = LogisticRegression()
>>> cross_val_score(lr, X, Y, scoring='accuracy', cv=10).mean()
0.9053368347338937
因此,正如预期的那样,得分更高。然而,原始数据集相当简单,基于每个类别只有一个簇的概念。这允许更简单、更精确的线性分离。如果我们考虑一个具有更多变量和更复杂结构(线性分类器难以捕捉)的略微不同的场景,我们可以比较线性回归和决策树的 ROC 曲线:
>>> nb_samples = 1000
>>> X, Y = make_classification(n_samples=nb_samples, n_features=8, n_informative=6, n_redundant=2, n_classes=2, n_clusters_per_class=4)
结果的 ROC 曲线显示在下图中:
在 MNIST 数字数据集上使用最常见的参数进行网格搜索,我们可以得到:
from sklearn.model_selection import GridSearchCV
param_grid = [
{
'criterion': ['gini', 'entropy'],
'max_features': ['auto', 'log2', None],
'min_samples_split': [ 2, 10, 25, 100, 200 ],
'max_depth': [5, 10, 15, None]
}
]
>>> gs = GridSearchCV(estimator=DecisionTreeClassifier(), param_grid=param_grid,
scoring='accuracy', cv=10, n_jobs=multiprocessing.cpu_count())
>>> gs.fit(digits.data, digits.target)
GridSearchCV(cv=10, error_score='raise',
estimator=DecisionTreeClassifier(class_weight=None, criterion='gini', max_depth=None,
max_features=None, max_leaf_nodes=None,
min_impurity_split=1e-07, min_samples_leaf=1,
min_samples_split=2, min_weight_fraction_leaf=0.0,
presort=False, random_state=None, splitter='best'),
fit_params={}, iid=True, n_jobs=8,
param_grid=[{'max_features': ['auto', 'log2', None], 'min_samples_split': [2, 10, 25, 100, 200], 'criterion': ['gini', 'entropy'], 'max_depth': [5, 10, 15, None]}],
pre_dispatch='2*n_jobs', refit=True, return_train_score=True,
scoring='accuracy', verbose=0)
>>> gs.best_estimator_
DecisionTreeClassifier(class_weight=None, criterion='entropy', max_depth=None,
max_features=None, max_leaf_nodes=None,
min_impurity_split=1e-07, min_samples_leaf=1,
min_samples_split=2, min_weight_fraction_leaf=0.0,
presort=False, random_state=None, splitter='best')
>>> gs.best_score_
0.8380634390651085
在这种情况下,影响准确率最大的因素是考虑分割的最小样本数。考虑到这个数据集的结构和需要有许多分支来捕捉甚至微小的变化,这是合理的。
集成学习
到目前为止,我们已经在单个实例上训练模型,通过迭代算法来最小化目标损失函数。这种方法基于所谓的强学习器,或通过寻找最佳可能解决方案来优化解决特定问题的方法。另一种方法是基于一组弱学习器,这些学习器可以并行或顺序(对参数进行轻微修改)训练,并基于多数投票或结果平均作为集成使用。这些方法可以分为两大类:
-
Bagged(或 Bootstrap)树:在这种情况下,集成是完整构建的。训练过程基于随机选择的分割,预测基于多数投票。随机森林是 Bagged 树集成的一个例子。
-
Boosted 树:集成是按顺序构建的,专注于先前被错误分类的样本。Boosted 树的例子包括 AdaBoost 和梯度提升树。
随机森林
随机森林是一组基于随机样本构建的决策树,其分割节点的策略不同:在这种模型中,不是寻找最佳选择,而是使用随机特征子集(对于每棵树),试图找到最佳的数据分割阈值。因此,将训练出许多以较弱方式训练的树,并且每棵树都会产生不同的预测。
解释这些结果有两种方式;更常见的方法是基于多数投票(得票最多的类别将被认为是正确的)。然而,scikit-learn 实现了一个基于平均结果的算法,这产生了非常准确的预测。即使它们在理论上不同,训练好的随机森林的概率平均也不可能与多数预测相差很大(否则,应该有不同的稳定点);因此,这两种方法通常会导致可比的结果。
例如,让我们考虑由不同数量的树组成的随机森林 MNIST 数据集:
from sklearn.ensemble import RandomForestClassifier
>>> nb_classifications = 100
>>> accuracy = []
>>> for i in range(1, nb_classifications):
a = cross_val_score(RandomForestClassifier(n_estimators=i), digits.data, digits.target, scoring='accuracy', cv=10).mean()
rf_accuracy.append(a)
下图显示了生成的图表:
如预期,当树的数量低于最小阈值时,准确性较低;然而,当树的数量少于 10 棵时,它开始迅速增加。在 20 到 30 棵树之间可以获得最佳结果(95%),这比单棵决策树要高。当树的数量较少时,模型的方差非常高,平均过程会产生许多错误的结果;然而,增加树的数量可以减少方差,并使模型收敛到一个非常稳定的解。scikit-learn 还提供了一个方差,它增强了选择最佳阈值时的随机性。使用ExtraTreesClassifier类,可以实现一个随机计算阈值并选择最佳值的模型。正如官方文档中讨论的那样,这使我们能够进一步减少方差:
from sklearn.ensemble import ExtraTreesClassifier
>>> nb_classifications = 100
>>> for i in range(1, nb_classifications):
a = cross_val_score(ExtraTreesClassifier(n_estimators=i), digits.data, digits.target, scoring='accuracy', cv=10).mean()
et_accuracy.append(a)
在准确性方面,具有相同树数量的结果略好,如下图所示:
随机森林中的特征重要性
我们之前介绍的特征重要性概念也可以应用于随机森林,通过计算森林中所有树的平均值:
我们可以很容易地使用包含 50 个特征和 20 个非信息元素的虚拟数据集来测试重要性评估:
>>> nb_samples = 1000
>>> X, Y = make_classification(n_samples=nb_samples, n_features=50, n_informative=30, n_redundant=20, n_classes=2, n_clusters_per_class=5)
下图展示了由 20 棵树组成的随机森林计算出的前 50 个特征的重要性:
如预期的那样,有几个非常重要的特征,一个中等重要性的特征块,以及一个包含对预测影响相当低的特征的尾部。这种类型的图表在分析阶段也很有用,可以帮助更好地理解决策过程是如何构建的。对于多维数据集,理解每个因素的影响相当困难,有时许多重要的商业决策在没有完全意识到它们潜在影响的情况下就被做出了。使用决策树或随机森林,可以评估所有特征的“真实”重要性,并排除所有低于固定阈值的元素。这样,复杂的决策过程就可以简化,同时部分去噪。
AdaBoost
另一种技术被称为AdaBoost(即自适应提升),其工作方式与许多其他分类器略有不同。其背后的基本结构可以是决策树,但用于训练的数据集会持续适应,迫使模型专注于那些被错误分类的样本。此外,分类器是按顺序添加的,因此新的一个通过提高那些它不如预期准确的地方的性能来增强前一个。
在每次迭代中,都会对每个样本应用一个权重因子,以增加错误预测样本的重要性并降低其他样本的重要性。换句话说,模型会反复增强,从一个非常弱的学习者开始,直到达到最大的n_estimators数量。在这种情况下,预测总是通过多数投票获得。
在 scikit-learn 实现中,还有一个名为learning_rate的参数,它衡量每个分类器的影响。默认值是 1.0,因此所有估计器都被认为是同等重要的。然而,正如我们从 MNIST 数据集中看到的那样,降低这个值是有用的,这样每个贡献都会减弱:
from sklearn.ensemble import AdaBoostClassifier
>>> accuracy = []
>>> nb_classifications = 100
>>> for i in range(1, nb_classifications):
a = cross_val_score(AdaBoostClassifier(n_estimators=i, learning_rate=0.1), digits.data, digits.target, scoring='accuracy', cv=10).mean()
>>> ab_accuracy.append(a)
结果显示在下图中:
准确率不如前面的例子高;然而,可以看到当提升添加大约 20-30 棵树时,它达到了一个稳定值。对learning_rate进行网格搜索可以让你找到最佳值;然而,在这种情况下,顺序方法并不理想。一个经典的随机森林,从第一次迭代开始就使用固定数量的树,表现更好。这很可能是由于 AdaBoost 采用的策略;在这个集合中,增加正确分类样本的权重并降低错误分类的强度可能会在损失函数中产生振荡,最终结果不是最优的最小点。用 Iris 数据集(结构上要简单得多)重复实验可以得到更好的结果:
from sklearn.datasets import load_iris
>>> iris = load_iris()
>>> ada = AdaBoostClassifier(n_estimators=100, learning_rate=1.0)
>>> cross_val_score(ada, iris.data, iris.target, scoring='accuracy', cv=10).mean()
0.94666666666666666
在这种情况下,学习率为 1.0 是最好的选择,很容易理解提升过程可以在几次迭代后停止。在下图中,你可以看到显示此数据集准确率的图表:
经过大约 10 次迭代后,准确率变得稳定(残差振荡可以被忽略),达到与这个数据集兼容的值。使用 AdaBoost 的优势在于资源利用;它不与一组完全配置好的分类器和整个样本集一起工作。因此,在大型数据集上训练时,它可以帮助节省时间。
梯度树提升
梯度树提升是一种技术,允许你逐步构建一个树集成,目标是最小化目标损失函数。集成的一般输出可以表示为:
这里,fi是一个表示弱学习者的函数。该算法基于在每个步骤添加一个新的决策树的概念,以使用最速下降法(参见en.wikipedia.org/wiki/Method_of_steepest_descent,获取更多信息)来最小化全局损失函数:
在引入梯度后,前面的表达式变为:
scikit-learn 实现了GradientBoostingClassifier类,支持两种分类损失函数:
-
二项式/多项式负对数似然(这是默认选择)
-
指数(例如 AdaBoost)
让我们使用一个由 500 个样本组成、具有四个特征(三个信息性和一个冗余)和三个类别的更复杂的虚拟数据集来评估此方法的准确率:
from sklearn.datasets import make_classification
>>> nb_samples = 500
>>> X, Y = make_classification(n_samples=nb_samples, n_features=4, n_informative=3, n_redundant=1, n_classes=3)
现在,我们可以收集一定范围内(1, 50)的多个估计器的交叉验证平均准确率。损失函数是默认的(多项式负对数似然):
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import cross_val_score
>>> a = []
>>> max_estimators = 50
>>> for i in range(1, max_estimators):
>>> score = cross_val_score(GradientBoostingClassifier(n_estimators=i, learning_rate=10.0/float(i)), X, Y, cv=10, scoring='accuracy').mean()
>>> a.append(score)
在增加估计器数量(参数n_estimators)时,重要的是要降低学习率(参数learning_rate)。最佳值难以预测;因此,进行网格搜索通常很有用。在我们的例子中,我一开始设置了非常高的学习率(5.0),当估计器数量达到 100 时,收敛到 0.05。这并不是一个完美的选择(在大多数实际情况下都是不可接受的!),这样做只是为了展示不同的准确率性能。结果如下所示:
如我们所见,最佳估计器数量约为 50,学习率为 0.1。读者可以尝试不同的组合,并比较此算法与其他集成方法的性能。
投票分类器
类VotingClassifier提供了一个非常有趣的集成解决方案,它不是一个实际的分类器,而是一组不同分类器的包装,这些分类器是并行训练和评估的。预测的最终决策是根据两种不同的策略通过多数投票来确定的:
- 硬投票:在这种情况下,获得最多投票的类别,即Nc,将被选择:
- 软投票:在这种情况下,每个预测类(对于所有分类器)的概率向量被相加并平均。获胜的类别是对应最高值的类别:
让我们考虑一个虚拟数据集,并使用硬投票策略计算准确率:
from sklearn.datasets import make_classification
>>> nb_samples = 500
>>> X, Y = make_classification(n_samples=nb_samples, n_features=2, n_redundant=0, n_classes=2)
对于我们的示例,我们将考虑三个分类器:逻辑回归、决策树(默认使用 Gini 不纯度),以及一个 SVM(使用多项式核,并将probability=True设置为生成概率向量)。这个选择仅出于教学目的,可能不是最佳选择。在创建集成时,考虑每个涉及分类器的不同特征并避免“重复”算法(例如,逻辑回归和线性 SVM 或感知器可能会产生非常相似的性能)是有用的。在许多情况下,将非线性分类器与随机森林或 AdaBoost 分类器混合可能很有用。读者可以用其他组合重复此实验,比较每个单一估计器的性能和投票分类器的准确率:
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import VotingClassifier
>>> lr = LogisticRegression()
>>> svc = SVC(kernel='poly', probability=True)
>>> dt = DecisionTreeClassifier()
>>> classifiers = [('lr', lr),
('dt', dt),
('svc', svc)]
>>> vc = VotingClassifier(estimators=classifiers, voting='hard')
计算交叉验证准确率,我们得到:
from sklearn.model_selection import cross_val_score
>>> a = []
>>> a.append(cross_val_score(lr, X, Y, scoring='accuracy', cv=10).mean())
>>> a.append(cross_val_score(dt, X, Y, scoring='accuracy', cv=10).mean())
>>> a.append(cross_val_score(svc, X, Y, scoring='accuracy', cv=10).mean())
>>> a.append(cross_val_score(vc, X, Y, scoring='accuracy', cv=10).mean())
>>> print(np.array(a))
[ 0.90182873 0.84990876 0.87386955 0.89982873]
每个单一分类器和集成方法的准确率在以下图中展示:
如预期,集成方法利用了不同的算法,其性能优于任何单一算法。现在我们可以用软投票重复实验,考虑到也可以通过参数weights引入权重向量,以给予每个分类器更多或更少的重视:
例如,考虑前面的图,我们可以决定给予逻辑回归更多的重视,而给予决策树和 SVM 较少的重视:
>>> weights = [1.5, 0.5, 0.75]
>>> vc = VotingClassifier(estimators=classifiers, weights=weights, voting='soft')
重复相同的计算用于交叉验证准确率,我们得到:
>>> print(np.array(a))
[ 0.90182873 0.85386795 0.87386955 0.89578952]
结果图如下所示:
权重分配不仅限于软策略。它也可以应用于硬投票,但在此情况下,它将用于过滤(减少或增加)实际发生次数的数量。
在这里,Nc是每个目标类别的投票数,每个投票数都乘以相应的分类器权重因子。
当单一策略无法达到所需的准确度阈值时,投票分类器可以是一个不错的选择;在利用不同的方法的同时,仅使用一小组强大(但有时有限)的学习器就可以捕捉到许多微观趋势。
参考文献
Louppe G.,Wehenkel L.,Sutera A.,和 Geurts P.,在随机树森林中理解变量重要性,NIPS 进程 2013。
摘要
在本章中,我们介绍了决策树作为一种特定的分类器。其概念背后的基本思想是,通过使用分裂节点,决策过程可以变成一个顺序过程,其中根据样本,选择一个分支,直到我们达到最终的叶子节点。为了构建这样的树,引入了不纯度的概念;从完整的数据集开始,我们的目标是找到一个分割点,创建两个具有最小特征数且在过程结束时应与单个目标类别相关联的独立集合。树的复杂性取决于内在的不纯度——换句话说,当总是容易确定一个最佳分离集合的特征时,深度会较低。然而,在许多情况下,这几乎是不可行的,因此生成的树需要许多中间节点来减少不纯度,直到达到最终的叶子节点。
我们还讨论了一些集成学习方法:随机森林、AdaBoost、梯度树提升和投票分类器。它们都基于训练多个弱学习器并使用多数投票或平均来评估其预测的想法。然而,虽然随机森林创建了一组部分随机训练的决策树,AdaBoost 和梯度提升树则采用逐步添加新模型的技术,并专注于那些先前被错误分类的样本,或者专注于最小化特定的损失函数。相反,投票分类器允许混合不同的分类器,在预测期间采用多数投票来决定哪个类别必须被视为获胜者。
在下一章中,我们将介绍第一种无监督学习方法,k-means,这是最广泛使用的聚类算法之一。我们将集中讨论其优势和劣势,并探索 scikit-learn 提供的某些替代方案。
第九章:聚类基础
在本章中,我们将介绍聚类的基概念和 k-means 的结构,这是一个相当常见的算法,可以有效地解决许多问题。然而,它的假设非常强烈,特别是关于簇的凸性,这可能导致其在应用中存在一些局限性。我们将讨论其数学基础及其优化方法。此外,我们将分析两种在 k-means 无法对数据集进行聚类时可以采用的替代方案。这些替代方案是 DBSCAN(通过考虑样本密度的差异来工作)和基于点之间亲和力的非常强大的方法——谱聚类。
聚类基础
让我们考虑一个点集数据集:
我们假设可以找到一个标准(不是唯一的)以便每个样本都能与一个特定的组相关联:
传统上,每个组被称为簇,寻找函数 G 的过程称为聚类。目前,我们没有对簇施加任何限制;然而,由于我们的方法是未监督的,应该有一个相似性标准来连接某些元素并分离其他元素。不同的聚类算法基于不同的策略来解决这个问题,并可能产生非常不同的结果。在下图中,有一个基于四组二维样本的聚类示例;将一个点分配给簇的决定仅取决于其特征,有时还取决于一组其他点的位置(邻域):
在这本书中,我们将讨论硬聚类技术,其中每个元素必须属于单个簇。另一种方法称为软聚类(或模糊聚类),它基于一个成员分数,该分数定义了元素与每个簇“兼容”的程度。通用的聚类函数变为:
向量 m[i] 代表 x[i] 的相对成员资格,通常将其归一化为概率分布。
K-means
k-means 算法基于(强烈的)初始条件,通过分配 k 个初始质心或均值来决定簇的数量:
然后计算每个样本与每个质心之间的距离,并将样本分配到距离最小的簇。这种方法通常被称为最小化簇的惯性,其定义如下:
该过程是迭代的——一旦所有样本都已被处理,就会计算一个新的质心集 K^((1))(现在考虑属于聚类的实际元素),并且重新计算所有距离。算法在达到所需的容差时停止,换句话说,当质心变得稳定,因此惯性最小化时停止。
当然,这种方法对初始条件非常敏感,已经研究了某些方法来提高收敛速度。其中之一被称为k-means++(Karteeka Pavan K.,Allam Appa Rao,Dattatreya Rao A. V.,和 Sridhar G.R.,《K-Means 类型算法的鲁棒种子选择算法》,国际计算机科学和信息技术杂志 3,第 5 期,2011 年 10 月 30 日),该方法选择初始质心,使其在统计上接近最终质心。数学解释相当困难;然而,这种方法是 scikit-learn 的默认选择,并且通常对于任何可以用此算法解决的聚类问题来说都是最佳选择。
让我们考虑一个简单的示例,使用一个虚拟数据集:
from sklearn.datasets import make_blobs
nb_samples = 1000
X, _ = make_blobs(n_samples=nb_samples, n_features=2, centers=3, cluster_std=1.5)
我们期望有三个具有二维特征的聚类,由于每个团块的方差,它们之间存在部分重叠。在我们的例子中,我们不会使用Y变量(它包含预期的聚类),因为我们只想生成一组局部一致的点来尝试我们的算法。
结果图示如下所示:
在这种情况下,问题非常简单,所以我们期望 k-means 在X的[-5, 0]区间内以最小误差将三个组分开。保持默认值,我们得到:
from sklearn.cluster import KMeans
>>> km = KMeans(n_clusters=3)
>>> km.fit(X)
KMeans(algorithm='auto', copy_x=True, init='k-means++', max_iter=300,
n_clusters=3, n_init=10, n_jobs=1, precompute_distances='auto',
random_state=None, tol=0.0001, verbose=0)
>>> print(km.cluster_centers_)
[[ 1.39014517, 1.38533993]
[ 9.78473454, 6.1946332 ]
[-5.47807472, 3.73913652]]
使用三种不同的标记重新绘制数据,可以验证 k-means 如何成功地将数据分离:
在这种情况下,分离非常容易,因为 k-means 基于欧几里得距离,它是径向的,因此预期聚类将是凸集。当这种情况不发生时,无法使用此算法解决问题。大多数时候,即使凸性没有得到完全保证,k-means 也能产生良好的结果,但有一些情况下预期的聚类是不可能的,让 k-means 找到质心可能会导致完全错误的结果。
让我们考虑同心圆的情况。scikit-learn 提供了一个内置函数来生成这样的数据集:
from sklearn.datasets import make_circles
>>> nb_samples = 1000
>>> X, Y = make_circles(n_samples=nb_samples, noise=0.05)
该数据集的图示如下所示:
我们希望有一个内部聚类(对应于用三角形标记表示的样本)和一个外部聚类(用点表示)。然而,这样的集合不是凸集,k-means 无法正确地将它们分离(均值应该是相同的!)。实际上,假设我们尝试将算法应用于两个聚类:
>>> km = KMeans(n_clusters=2)
>>> km.fit(X)
KMeans(algorithm='auto', copy_x=True, init='k-means++', max_iter=300,
n_clusters=2, n_init=10, n_jobs=1, precompute_distances='auto',
random_state=None, tol=0.0001, verbose=0)
我们得到以下图所示的分离:
如预期,k-means 收敛到两个半圆中间的两个质心,并且得到的聚类结果与我们预期的完全不同。此外,如果必须根据与公共中心的距离来考虑样本的不同,这个结果将导致完全错误的预测。显然,必须采用另一种方法。
寻找最佳聚类数量
k-means 最常见的一个缺点与选择最佳聚类数量有关。过小的值将确定包含异质元素的大分组,而较大的值可能导致难以识别聚类之间差异的场景。因此,我们将讨论一些可以用来确定适当分割数量和评估相应性能的方法。
优化惯性
第一种方法基于这样的假设:适当的聚类数量必须产生小的惯性。然而,当聚类数量等于样本数量时,这个值达到最小(0.0);因此,我们不能寻找最小值,而是寻找一个在惯性和聚类数量之间权衡的值。
假设我们有一个包含 1,000 个元素的数据库。我们可以计算并收集不同数量聚类下的惯性(scikit-learn 将这些值存储在实例变量inertia_中):
>>> nb_clusters = [2, 3, 5, 6, 7, 8, 9, 10]
>>> inertias = []
>>> for n in nb_clusters:
>>> km = KMeans(n_clusters=n)
>>> km.fit(X)
>>> inertias.append(km.inertia_)
绘制值,我们得到以下图所示的结果:
如您所见,2 和 3 之间有一个戏剧性的减少,然后斜率开始变平。我们希望找到一个值,如果减少,会导致惯性大幅增加,如果增加,会产生非常小的惯性减少。因此,一个好的选择可能是 4 或 5,而更大的值可能会产生不希望的聚类内分割(直到极端情况,每个点成为一个单独的聚类)。这种方法非常简单,可以用作确定潜在范围的第一个方法。接下来的策略更复杂,可以用来找到最终的聚类数量。
形状系数
形状系数基于“最大内部凝聚力和最大聚类分离”的原则。换句话说,我们希望找到产生数据集细分,形成彼此分离的密集块的数量。这样,每个聚类将包含非常相似的元素,并且选择属于不同聚类的两个元素,它们的距离应该大于最大聚类内距离。
在定义距离度量(欧几里得通常是不错的选择)之后,我们可以计算每个元素的聚类内平均距离:
我们还可以定义平均最近簇距离(这对应于最低的簇间距离):
元素*x[i]*的轮廓得分定义为:
这个值介于-1 和 1 之间,其解释如下:
-
一个接近 1 的值是好的(1 是最佳条件),因为这表示a(x[i]) << b(x[i])。
-
接近 0 的值表示簇内和簇间测量的差异几乎为零,因此存在簇重叠。
-
接近-1 的值表示样本被分配到了错误的聚类,因为a(x[i]) >> b(x[i])。
scikit-learn 允许计算平均轮廓得分,以便对不同数量的聚类有一个立即的概览:
from sklearn.metrics import silhouette_score
>>> nb_clusters = [2, 3, 5, 6, 7, 8, 9, 10]
>>> avg_silhouettes = []
>>> for n in nb_clusters:
>>> km = KMeans(n_clusters=n)
>>> Y = km.fit_predict(X)
>>> avg_silhouettes.append(silhouette_score(X, Y))
对应的图表如下所示:
最佳值是 3(非常接近 1.0),然而,考虑到前面的方法,4 个聚类提供了更小的惯性,同时轮廓得分也合理。因此,选择 4 而不是 3 可能是一个更好的选择。然而,3 和 4 之间的决定并不立即,应该通过考虑数据集的性质来评估。轮廓得分表明存在 3 个密集的聚簇,但惯性图表明其中至少有一个可以可能分成两个簇。为了更好地理解聚类是如何工作的,还可以绘制轮廓图,显示所有簇中每个样本的排序得分。在以下代码片段中,我们为 2、3、4 和 8 个簇创建图表:
from sklearn.metrics import silhouette_samples
>>> fig, ax = subplots(2, 2, figsize=(15, 10))
>>> nb_clusters = [2, 3, 4, 8]
>>> mapping = [(0, 0), (0, 1), (1, 0), (1, 1)]
>>> for i, n in enumerate(nb_clusters):
>>> km = KMeans(n_clusters=n)
>>> Y = km.fit_predict(X)
>>> silhouette_values = silhouette_samples(X, Y)
>>> ax[mapping[i]].set_xticks([-0.15, 0.0, 0.25, 0.5, 0.75, 1.0])
>>> ax[mapping[i]].set_yticks([])
>>> ax[mapping[i]].set_title('%d clusters' % n)
>>> ax[mapping[i]].set_xlim([-0.15, 1])
>>> ax[mapping[i]].grid()
>>> y_lower = 20
>>> for t in range(n):
>>> ct_values = silhouette_values[Y == t]
>>> ct_values.sort()
>>> y_upper = y_lower + ct_values.shape[0]
>>> color = cm.Accent(float(t) / n)
>>> ax[mapping[i]].fill_betweenx(np.arange(y_lower, y_upper), 0,
>>> ct_values, facecolor=color, edgecolor=color)
>>> y_lower = y_upper + 20
每个样本的轮廓系数是通过函数 silhouette_values(这些值始终介于-1 和 1 之间)计算的。在这种情况下,我们将图表限制在-0.15 和 1 之间,因为没有更小的值。然而,在限制之前检查整个范围是很重要的。
结果图表如下所示:
每个轮廓的宽度与属于特定聚类的样本数量成正比,其形状由每个样本的得分决定。理想的图表应包含均匀且长的轮廓,没有峰值(它们必须类似于梯形而不是三角形),因为我们期望同一聚类中的样本得分方差非常低。对于两个聚类,形状是可以接受的,但一个聚类的平均得分为 0.5,而另一个的值大于 0.75;因此,第一个聚类的内部一致性较低。在对应于 8 个聚类的图表中,展示了完全不同的情况。所有轮廓都是三角形的,其最大得分略大于 0.5。这意味着所有聚类在内部是一致的,但分离度不可接受。对于三个聚类,图表几乎是完美的,除了第二个轮廓的宽度。如果没有其他指标,我们可以考虑这个数字是最好的选择(也由平均得分证实),但聚类的数量越多,惯性越低。对于四个聚类,图表略差,有两个轮廓的最大得分约为 0.5。这意味着两个聚类完美一致且分离,而剩下的两个则相对一致,但它们可能没有很好地分离。目前,我们应在 3 和 4 之间做出选择。接下来,我们将介绍其他方法,以消除所有疑虑。
卡尔金斯-哈拉巴斯指数
另一种基于密集和分离良好聚类概念的方法是卡尔金斯-哈拉巴斯指数。要构建它,我们首先需要定义簇间分散度。如果我们有 k 个聚类及其相对质心和全局质心,簇间分散度(BCD)定义为:
在上述表达式中,n[k] 是属于聚类 k 的元素数量,mu(公式中的希腊字母)是全局质心,而 mu[i] 是聚类 i 的质心。簇内分散度(WCD)定义为:
卡尔金斯-哈拉巴斯指数定义为 BCD(k) 和 WCD(k) 之间的比率:
我们在寻找低簇内分散度(密集的聚团)和高簇间分散度(分离良好的聚团),需要找到最大化此指数的聚类数量。我们可以以类似于我们之前为轮廓得分所做的方式获得一个图表:
from sklearn.metrics import calinski_harabaz_score
>>> nb_clusters = [2, 3, 5, 6, 7, 8, 9, 10]
>>> ch_scores = []
>>> km = KMeans(n_clusters=n)
>>> Y = km.fit_predict(X)
>>> for n in nb_clusters:
>>> km = KMeans(n_clusters=n)
>>> Y = km.fit_predict(X)
>>> ch_scores.append(calinski_harabaz_score(X, Y))
结果图表如下所示:
如预期的那样,最高值(5,500)是在三个聚类时获得的,而四个聚类得到的值略低于 5,000。仅考虑这种方法,没有疑问,最佳选择是 3,即使 4 也是一个合理的值。让我们考虑最后一种方法,它评估整体稳定性。
聚类不稳定性
另一种方法基于在 Von Luxburg U. 的文章《Cluster stability: an overview》中定义的簇不稳定性概念,arXiv 1007:1075v1,2010 年 7 月 7 日。直观地说,我们可以认为,如果一个聚类方法在扰动相同数据集的版本中产生非常相似的结果,那么这个聚类方法是稳定的。更正式地说,如果我们有一个数据集 X,我们可以定义一组 m 扰动(或噪声)版本:
考虑两个具有相同簇数(k)的聚类之间的距离度量 d(C(X[1]), C(X[2])),不稳定性定义为噪声版本聚类对之间的平均距离:
对于我们的目的,我们需要找到使 I(C) 最小化的 k 值(因此最大化稳定性)。首先,我们需要生成一些数据集的噪声版本。假设 X 包含 1,000 个二维样本,标准差为 10.0。我们可以通过添加一个均匀随机值(范围在 [-2.0, 2.0] 内)以 0.25 的概率扰动 X:
>>> nb_noisy_datasets = 4
>>> X_noise = []
>>> for _ in range(nb_noisy_datasets):
>>> Xn = np.ndarray(shape=(1000, 2))
>>> for i, x in enumerate(X):
>>> if np.random.uniform(0, 1) < 0.25:
>>> Xn[i] = X[i] + np.random.uniform(-2.0, 2.0)
>>> else:
>>> Xn[i] = X[i]
>>> X_noise.append(Xn)
在这里,我们假设有四个扰动版本。作为一个度量标准,我们采用汉明距离,该距离与不同意的输出元素数量成比例(如果归一化)。在这个阶段,我们可以计算不同簇数量下的不稳定性:
from sklearn.metrics.pairwise import pairwise_distances
>>> instabilities = []
>>> for n in nb_clusters:
>>> Yn = []
>>>
>>> for Xn in X_noise:
>>> km = KMeans(n_clusters=n)
>>> Yn.append(km.fit_predict(Xn))
>>> distances = []
>>> for i in range(len(Yn)-1):
>>> for j in range(i, len(Yn)):
>>> d = pairwise_distances(Yn[i].reshape(-1, 1), Yn[j].reshape(-1, -1), 'hamming')
>>> distances.append(d[0, 0])
>>> instability = (2.0 * np.sum(distances)) / float(nb_noisy_datasets ** 2)
>>> instabilities.append(instability)
由于距离是对称的,我们只计算矩阵的上三角部分。结果如下所示:
排除具有 2 个簇的配置,其中惯性非常高,我们有 3 个簇的最小值,这个值已经被前三种方法所确认。因此,我们最终可以决定将 n_clusters 设置为 3,排除 4 个或更多簇的选项。这种方法非常强大,但重要的是要用合理的噪声数据集数量来评估稳定性,注意不要过度改变原始几何形状。一个好的选择是使用高斯噪声,方差设置为数据集方差的分数(例如 1/10)。其他方法在 Von Luxburg U. 的文章《Cluster stability: an overview》中有所介绍,arXiv 1007:1075v1,2010 年 7 月 7 日。
即使我们已经用 k-means 展示了这些方法,它们也可以应用于任何聚类算法来评估性能并比较它们。
DBSCAN
DBSCAN 或 基于密度的空间聚类应用噪声 是一种强大的算法,可以轻松解决 k-means 无法解决的非凸问题。其思想很简单:簇是一个高密度区域(对其形状没有限制),周围被低密度区域包围。这个陈述通常是正确的,并且不需要对预期簇的数量进行初始声明。该过程从分析一个小区域(形式上,一个由最小数量的其他样本包围的点)开始。如果密度足够,它被认为是簇的一部分。此时,考虑邻居。如果它们也有高密度,它们将与第一个区域合并;否则,它们将确定拓扑分离。当扫描完所有区域后,簇也已经确定,因为它们是被空空间包围的岛屿。
scikit-learn 允许我们通过两个参数来控制此过程:
-
eps: 负责定义两个邻居之间的最大距离。值越高,聚合的点越多,而值越小,创建的簇越多。 -
min_samples: 这决定了定义一个区域(也称为核心点)所需的周围点的数量。
让我们尝试一个非常困难的聚类问题,称为半月形。可以使用内置函数创建数据集:
from sklearn.datasets import make_moons
>>> nb_samples = 1000
>>> X, Y = make_moons(n_samples=nb_samples, noise=0.05)
数据集的图示如下所示:
为了理解,k-means 将通过寻找最优凸性来进行聚类,结果如下所示:
当然,这种分离是不可接受的,而且没有方法可以提高准确性。让我们尝试使用 DBSCAN(将 eps 设置为 0.1,min_samples 的默认值为 5):
from sklearn.cluster import DBSCAN
>>> dbs = DBSCAN(eps=0.1)
>>> Y = dbs.fit_predict(X)
与其他实现方式不同,DBSCAN 在训练过程中预测标签,因此我们已经有了一个包含每个样本分配的簇的数组 Y。在下图中,有两种不同的标记表示:
如您所见,准确度非常高,只有三个孤立点被错误分类(在这种情况下,我们知道它们的类别,因此我们可以使用这个术语,即使它是一个聚类过程)。然而,通过执行网格搜索,很容易找到优化聚类过程的最佳值。调整这些参数非常重要,以避免两个常见问题:少数大簇和许多小簇。这个问题可以通过以下方法轻松避免。
谱聚类
谱聚类是一种基于对称亲和矩阵的更复杂的方法:
在这里,每个元素*a[ij]*代表两个样本之间的亲和度度量。最常用的度量(也由 scikit-learn 支持)是径向基函数和最近邻。然而,如果核产生的度量具有距离的特征(非负、对称和递增),则可以使用任何核。
计算拉普拉斯矩阵并应用标准聚类算法到特征向量的子集(这个元素严格与每个单独的策略相关)。
scikit-learn 实现了 Shi-Malik 算法(Shi J., Malik J., Normalized Cuts and Image Segmentation, IEEE Transactions on Pattern Analysis and Machine Intelligence, Vol. 22, 08/2000),也称为 normalized-cuts,该算法将样本划分为两个集合(G[1]和G[2],这些集合形式上是图,其中每个点是一个顶点,边由归一化拉普拉斯矩阵导出),使得属于簇内点的权重远高于属于分割的权重。完整的数学解释超出了本书的范围;然而,在Von Luxburg U., A Tutorial on Spectral Clustering, 2007中,你可以阅读关于许多替代谱方法的完整解释。
让我们考虑之前的半月形示例。在这种情况下,亲和度(就像 DBSCAN 一样)应该基于最近邻函数;然而,比较不同的核很有用。在第一个实验中,我们使用具有不同gamma参数值的 RBF 核:
from sklearn.cluster import SpectralClustering
>>> Yss = []
>>> gammas = np.linspace(0, 12, 4)
>>> for gamma in gammas:
sc = SpectralClustering(n_clusters=2, affinity='rbf', gamma=gamma)
Yss.append(sc.fit_predict(X))
在这个算法中,我们需要指定我们想要多少个簇,因此我们将值设置为 2。结果图如下所示:
如您所见,当缩放因子 gamma 增加时,分离变得更加准确;然而,考虑到数据集,在任何搜索中都不需要使用最近邻核。
>>> sc = SpectralClustering(n_clusters=2, affinity='nearest_neighbors')
>>> Ys = sc.fit_predict(X)
结果图如下所示:
对于许多基于核的方法,谱聚类需要先前的分析来检测哪个核可以提供亲和度矩阵的最佳值。scikit-learn 也允许我们为那些难以使用标准核解决的问题定义自定义核。
基于真实情况的评估方法
在本节中,我们介绍了一些需要了解真实情况的评估方法。由于聚类通常作为无监督方法应用,因此这种条件并不总是容易获得;然而,在某些情况下,训练集已经被手动(或自动)标记,在预测新样本的簇之前评估模型是有用的。
同质性
对于一个聚类算法(给定真实情况)的一个重要要求是,每个簇应只包含属于单个类别的样本。在第二章《机器学习中的重要元素》中,我们定义了熵 H(X) 和条件熵 H(X|Y) 的概念,这些概念衡量了在知道 Y 的情况下 X 的不确定性。因此,如果类集表示为 C,聚类集表示为 K,则 H(C|K) 是在聚类数据集后确定正确类别的不确定性的度量。为了得到同质性分数,有必要考虑类集的初始熵 H(C) 来归一化这个值:
在 scikit-learn 中,有一个内置函数 homogeneity_score() 可以用来计算这个值。对于这个和接下来的几个例子,我们假设我们有一个标记的数据集 X(带有真实标签 Y):
from sklearn.metrics import homogeneity_score
>>> km = KMeans(n_clusters=4)
>>> Yp = km.fit_predict(X)
>>> print(homogeneity_score(Y, Yp))
0.806560739827
0.8 的值意味着大约有 20%的残余不确定性,因为一个或多个簇包含一些属于次要类别的点。与其他在上一节中展示的方法一样,可以使用同质性分数来确定最佳簇数量。
完整性
另一个互补的要求是,属于一个类别的每个样本都被分配到同一个簇中。这个度量可以通过条件熵 H(K|C) 来确定,这是在知道类别的情况下确定正确簇的不确定性。像同质性分数一样,我们需要使用熵 H(K) 来归一化这个值:
我们可以使用函数 completeness_score()(在相同的数据集上)来计算这个分数:
from sklearn.metrics import completeness_score
>>> km = KMeans(n_clusters=4)
>>> Yp = km.fit_predict(X)
>>> print(completeness_score(Y, Yp))
0.807166746307
此外,在这种情况下,这个值相当高,这意味着大多数属于一个类别的样本已经被分配到同一个簇中。这个值可以通过不同的簇数量或改变算法来提高。
调整后的 rand 指数
调整后的 rand 指数衡量原始类划分(Y)和聚类之间的相似性。考虑到与前面评分中采用相同的符号,我们可以定义:
-
a:属于类集 C 和聚类集 K 中相同划分的元素对的数量
-
b:属于类集 C 和聚类集 K 中不同划分的元素对的数量
如果数据集中的样本总数为 n,则 rand 指数定义为:
校正后的随机性版本是调整后的 rand 指数,其定义如下:
我们可以使用函数 adjusted_rand_score() 来计算调整后的 rand 分数:
from sklearn.metrics import adjusted_rand_score
>>> km = KMeans(n_clusters=4)
>>> Yp = km.fit_predict(X)
>>> print(adjusted_rand_score(Y, Yp))
0.831103137285
由于调整后的兰德指数介于-1.0 和 1.0 之间,负值表示不良情况(分配高度不相关),0.83 的分数意味着聚类与真实情况非常相似。此外,在这种情况下,可以通过尝试不同的簇数量或聚类策略来优化这个值。
参考文献
-
Karteeka Pavan K., Allam Appa Rao, Dattatreya Rao A. V. 和 Sridhar G.R.,针对 k-means 类型算法的鲁棒种子选择算法,《International Journal of Computer Science and Information Technology》第 3 卷第 5 期(2011 年 10 月 30 日)
-
Shi J., Malik J., 归一化切割与图像分割,《IEEE Transactions on Pattern Analysis and Machine Intelligence》,第 22 卷(2000 年 8 月)
-
Von Luxburg U.,谱聚类教程,2007
-
Von Luxburg U.,簇稳定性:概述,arXiv 1007:1075v1,2010 年 7 月 7 日
摘要
在本章中,我们介绍了基于定义(随机或根据某些标准)k 个质心代表簇并优化它们的位置,使得每个簇中每个点到质心的平方距离之和最小的 k-means 算法。由于距离是一个径向函数,k-means 假设簇是凸形的,不能解决形状有深凹处的(如半月形问题)问题。
为了解决这类情况,我们提出了两种替代方案。第一个被称为 DBSCAN,它是一个简单的算法,分析被其他样本包围的点与边界样本之间的差异。这样,它可以很容易地确定高密度区域(成为簇)以及它们之间的低密度空间。对于簇的形状或数量没有假设,因此需要调整其他参数,以便生成正确的簇数量。
谱聚类是一类基于样本之间亲和度度量的算法。它们在由亲和度矩阵的拉普拉斯算子生成的子空间上使用经典方法(如 k-means)。这样,就可以利用许多核函数的力量来确定点之间的亲和度,而简单的距离无法正确分类。这种聚类对于图像分割特别有效,但也可以在其他方法无法正确分离数据集时成为一个好的选择。
在下一章中,我们将讨论另一种称为层次聚类的另一种方法。它允许我们通过分割和合并簇直到达到最终配置来分割数据。
第十章:层次聚类
在本章中,我们将讨论一种称为层次聚类的特定聚类技术。这种方法不是与整个数据集中的关系一起工作,而是从一个包含所有元素的单个实体(分裂)或 N 个分离元素(聚合)开始,然后根据某些特定的标准分裂或合并簇,我们将分析和比较这些标准。
层次化策略
层次聚类基于寻找部分簇的层次结构的一般概念,这些簇是通过自下而上或自上而下的方法构建的。更正式地说,它们被称为:
-
聚合聚类:过程从底部开始(每个初始簇由一个元素组成)并通过合并簇进行,直到达到停止标准。一般来说,目标在过程结束时具有足够小的簇数量。
-
分裂聚类:在这种情况下,初始状态是一个包含所有样本的单簇,过程通过分裂中间簇直到所有元素分离。在这个点上,过程继续使用基于元素之间差异的聚合标准。一个著名的(超出了本书范围)方法称为DIANA,由 Kaufman L.,Roussew P.J.,在数据中寻找群体:聚类分析导论,Wiley 描述。
scikit-learn 仅实现聚合聚类。然而,这并不是一个真正的限制,因为分裂聚类的复杂度更高,而聚合聚类的性能与分裂方法达到的性能相当。
聚合聚类
让我们考虑以下数据集:
我们定义亲和力,这是一个具有相同维度 m 的两个参数的度量函数。最常见的度量(也由 scikit-learn 支持)是:
- 欧几里得或 L2:
- 曼哈顿(也称为城市街区)或 L1:
- 余弦距离:
欧几里得距离通常是好的选择,但有时拥有一个与欧几里得距离差异逐渐增大的度量是有用的。曼哈顿度量具有这种特性;为了展示这一点,在下面的图中有一个表示属于直线 y = x 的点从原点到距离的图:
余弦距离,相反,在我们需要两个向量之间角度成比例的距离时很有用。如果方向相同,距离为零,而当角度等于 180°(意味着相反方向)时,距离最大。这种距离可以在聚类必须不考虑每个点的L2范数时使用。例如,一个数据集可能包含具有不同尺度的二维点,我们需要将它们分组到对应于圆形扇区的聚类中。或者,我们可能对它们根据四个象限的位置感兴趣,因为我们已经为每个点分配了特定的含义(对点与原点之间的距离不变)。
一旦选择了度量(让我们简单地称之为d(x,y)),下一步是定义一个策略(称为连接)来聚合不同的聚类。有许多可能的方法,但 scikit-learn 支持三种最常见的方法:
- 完全连接:对于每一对聚类,算法计算并合并它们,以最小化聚类之间的最大距离(换句话说,最远元素的距离):
- 平均连接:它与完全连接类似,但在这个情况下,算法使用聚类对之间的平均距离:
- Ward 的连接:在这个方法中,考虑所有聚类,算法计算聚类内的平方距离之和,并合并它们以最小化它。从统计学的角度来看,聚合过程导致每个结果聚类的方差减少。该度量是:
- Ward 的连接只支持欧几里得距离。
树状图
为了更好地理解聚合过程,引入一种称为树状图的图形方法很有用,它以静态方式显示聚合是如何进行的,从底部(所有样本都分离)到顶部(连接完全)。不幸的是,scikit-learn 不支持它们。然而,SciPy(它是其强制性要求)提供了一些有用的内置函数。
让我们从创建一个虚拟数据集开始:
from sklearn.datasets import make_blobs
>>> nb_samples = 25
>>> X, Y = make_blobs(n_samples=nb_samples, n_features=2, centers=3, cluster_std=1.5)
为了避免结果图过于复杂,样本数量已经保持得很低。在以下图中,有数据集的表示:
现在我们可以计算树状图。第一步是计算距离矩阵:
from scipy.spatial.distance import pdist
>>> Xdist = pdist(X, metric='euclidean')
我们选择了一个欧几里得度量,这在当前情况下是最合适的。此时,必须决定我们想要哪种连接。让我们选择 Ward;然而,所有已知的方法都是支持的:
from scipy.cluster.hierarchy import linkage
>>> Xl = linkage(Xdist, method='ward')
现在,我们可以创建并可视化树状图:
from scipy.cluster.hierarchy import dendrogram
>>> Xd = dendrogram(Xl)
结果图示如下截图:
在x轴上,有样本(按顺序编号),而y轴表示距离。每个弧连接两个由算法合并的簇。例如,23 和 24 是合并在一起的单个元素。然后元素 13 被聚合到结果簇中,这个过程继续进行。
如您所见,如果我们决定在距离 10 处切割图,我们将得到两个独立的簇:第一个簇从 15 到 24,另一个簇从 0 到 20。查看之前的数据集图,所有Y < 10 的点都被认为是第一个簇的一部分,而其他点属于第二个簇。如果我们增加距离,链接变得非常激进(特别是在这个只有少数样本的例子中),并且当值大于 27 时,只生成一个簇(即使内部方差相当高!)。
scikit-learn 中的层次聚类
让我们考虑一个具有 8 个中心的更复杂的虚拟数据集:
>>> nb_samples = 3000
>>> X, _ = make_blobs(n_samples=nb_samples, n_features=2, centers=8, cluster_std=2.0)
下图显示了图形表示:
我们现在可以使用不同的链接方法(始终保持欧几里得距离)进行层次聚类,并比较结果。让我们从完全链接开始(AgglomerativeClustering使用fit_predict()方法来训练模型并转换原始数据集):
from sklearn.cluster import AgglomerativeClustering
>>> ac = AgglomerativeClustering(n_clusters=8, linkage='complete')
>>> Y = ac.fit_predict(X)
下图显示了结果的图示(使用不同的标记和颜色):
这种方法的结果完全糟糕。这种方法惩罚了组间方差并合并簇,这在大多数情况下应该是不同的。在之前的图中,中间的三个簇相当模糊,考虑到由点表示的簇的方差,错误放置的概率非常高。现在让我们考虑平均链接:
>>> ac = AgglomerativeClustering(n_clusters=8, linkage='average')
>>> Y = ac.fit_predict(X)
结果显示在下述截图:
在这种情况下,簇的定义更加清晰,尽管其中一些簇可能变得非常小。尝试其他度量标准(特别是L1)并比较结果也可能很有用。最后一种方法,通常是最佳方法(它是默认方法),是 Ward 的链接方法,只能与欧几里得度量一起使用(也是默认的):
>>> ac = AgglomerativeClustering(n_clusters=8)
>>> Y = ac.fit_predict(X)
下图显示了生成的结果图:
在这种情况下,无法修改度量标准,因此,正如官方 scikit-learn 文档中建议的那样,一个有效的替代方案可能是平均链接,它可以与任何亲和力一起使用:
连通性约束
scikit-learn 还允许指定连接矩阵,该矩阵在寻找要合并的聚类时可以用作约束。通过这种方式,彼此距离较远的聚类(在连接矩阵中不相邻)将被跳过。创建此类矩阵的一个非常常见的方法是使用基于样本邻居数量的 k 近邻图函数(作为kneighbors_graph()实现),该函数根据特定的度量来确定样本的邻居数量。在以下示例中,我们考虑了一个圆形虚拟数据集(常在官方文档中使用):
from sklearn.datasets import make_circles
>>> nb_samples = 3000
>>> X, _ = make_circles(n_samples=nb_samples, noise=0.05)
下图显示了图形表示:
我们从基于平均连接的未结构化聚合聚类开始,并设定了 20 个聚类:
>>> ac = AgglomerativeClustering(n_clusters=20, linkage='average')
>>> ac.fit(X)
在这种情况下,我们使用了fit()方法,因为AgglomerativeClustering类在训练后通过实例变量labels_公开标签(聚类编号),当聚类数量非常高时,使用此变量更方便。以下图显示了结果的图形表示:
现在我们可以尝试为k设定不同的约束值:
from sklearn.neighbors import kneighbors_graph
>>> acc = []
>>> k = [50, 100, 200, 500]
>>> for i in range(4):
>>> kng = kneighbors_graph(X, k[i])
>>> ac1 = AgglomerativeClustering(n_clusters=20, connectivity=kng, linkage='average')
>>> ac1.fit(X)
>>> acc.append(ac1)
以下截图显示了生成的图表:
如您所见,施加约束(在这种情况下,基于 k 近邻)可以控制聚合如何创建新的聚类,并且可以成为调整模型或避免在原始空间中距离较大的元素(这在聚类图像时特别有用)的有力工具。
参考文献
Kaufman L.,Roussew P.J.,在数据中寻找群组:聚类分析导论,Wiley
摘要
在本章中,我们介绍了层次聚类,重点关注聚合版本,这是 scikit-learn 唯一支持的版本。我们讨论了哲学,这与许多其他方法采用的哲学相当不同。在聚合聚类中,过程从将每个样本视为单个聚类开始,并通过合并块直到达到所需的聚类数量。为了执行此任务,需要两个元素:一个度量函数(也称为亲和力)和一个连接标准。前者用于确定元素之间的距离,而后者是一个目标函数,用于确定哪些聚类必须合并。
我们还展示了如何使用 SciPy 通过树状图可视化这个过程。当需要保持对整个过程和最终聚类数量的完全控制,且初始时聚类数量未知(决定在哪里截断图更容易)时,这项技术非常有用。我们展示了如何使用 scikit-learn 执行基于不同指标和连接方式的层次聚类,并在本章末尾,我们还介绍了在需要强制过程避免合并距离过远的聚类时有用的连通性约束。
在下一章中,我们将介绍推荐系统,这些系统被许多不同的系统日常使用,以根据用户与其他用户及其偏好的相似性自动向用户推荐项目。
第十一章:推荐系统简介
想象一个拥有数千篇文章的在线商店。如果你不是注册用户,你可能会看到一些突出显示的主页,但如果你已经购买了一些商品,网站显示你可能购买的产品,而不是随机选择的产品,这将是很有趣的。这就是推荐系统的目的,在本章中,我们将讨论创建此类系统最常见的技术。
基本概念包括用户、项目和评分(或关于产品的隐式反馈,例如购买的事实)。每个模型都必须与已知数据(如在监督场景中)一起工作,以便能够建议最合适的项目或预测尚未评估的所有项目的评分。
我们将讨论两种不同的策略:
-
基于用户或内容
-
协同过滤
第一种方法基于我们对用户或产品的信息,其目标是将新用户与现有的一组同龄人关联起来,以建议其他成员正面评价的所有项目,或者根据其特征对产品进行聚类,并提出与考虑的项目相似的项目子集。第二种方法稍微复杂一些,使用显式评分,其目的是预测每个项目和每个用户的此值。尽管协同过滤需要更多的计算能力,但如今,廉价资源的广泛可用性允许使用此算法处理数百万用户和产品,以提供最准确的实时推荐。该模型还可以每天重新训练或更新。
天真的基于用户的系统
在这个第一个场景中,我们假设我们有一组由特征向量表示的用户:
典型的特征包括年龄、性别、兴趣等。所有这些都必须使用前几章中讨论的技术之一进行编码(例如,它们可以被二值化)。此外,我们有一组项目:
假设也存在一个关系,将每个用户与一组项目(已购买或正面评价)相关联,对于这些项目已执行了明确的行为或反馈:
在基于用户的系统中,用户会定期进行聚类(通常使用k 最近邻方法),因此,考虑一个通用的用户 u(也是新的),我们可以立即确定包含所有与我们样本相似(因此是邻居)的用户球体:
在这一点上,我们可以使用之前介绍的关系创建建议项目的集合:
换句话说,这个集合包含所有被邻居积极评价或购买的唯一产品。我之所以使用“天真”这个词,是因为我们将要在专门讨论协同过滤的章节中讨论一个类似的替代方案。
基于用户的系统实现与 scikit-learn
对于我们的目的,我们需要创建一个用户和产品的虚拟数据集:
import numpy as np
>>> nb_users = 1000
>>> users = np.zeros(shape=(nb_users, 4))
>>> for i in range(nb_users):
>>> users[i, 0] = np.random.randint(0, 4)
>>> users[i, 1] = np.random.randint(0, 2)
>>> users[i, 2] = np.random.randint(0, 5)
>>> users[i, 3] = np.random.randint(0, 5)
我们假设我们有 1,000 个用户,他们有四个特征,这些特征由介于 0 和 4 或 5 之间的整数表示。它们的具体含义无关紧要;它们的作用是表征用户并允许对集合进行聚类。
对于产品,我们还需要创建关联:
>>> nb_product = 20
>>> user_products = np.random.randint(0, nb_product, size=(nb_users, 5))
我们假设我们有 20 个不同的项目(从 1 到 20;0 表示用户没有购买任何东西)和一个关联矩阵,其中每个用户都与 0 到 5(最大值)之间的产品数量相关联。例如:
在这一点上,我们需要使用 scikit-learn 提供的NearestNeighbors实现来对用户进行聚类:
from sklearn.neighbors import NearestNeighbors
>>> nn = NearestNeighbors(n_neighbors=20, radius=2.0)
>>> nn.fit(users)
NearestNeighbors(algorithm='auto', leaf_size=30, metric='minkowski',
metric_params=None, n_jobs=1, n_neighbors=20, p=2, radius=2.0)
我们选择了 20 个邻居和等于 2 的欧几里得半径。当我们要查询模型以了解包含在以样本为中心且具有固定半径的球体内的项目时,我们会使用这个参数。在我们的案例中,我们将查询模型以获取一个测试用户的全部邻居:
>>> test_user = np.array([2, 0, 3, 2])
>>> d, neighbors = nn.kneighbors(test_user.reshape(1, -1))
>>> print(neighbors)
array([[933, 67, 901, 208, 23, 720, 121, 156, 167, 60, 337, 549, 93,
563, 326, 944, 163, 436, 174, 22]], dtype=int64)
现在我们需要使用关联矩阵来构建推荐列表:
>>> suggested_products = []
>>> for n in neighbors:
>>> for products in user_products[n]:
>>> for product in products:
>>> if product != 0 and product not in suggested_products:
>>> suggested_products.append(product)
>>> print(suggested_products)
[14, 5, 13, 4, 8, 9, 16, 18, 10, 7, 1, 19, 12, 11, 6, 17, 15, 3, 2]
对于每个邻居,我们检索他/她购买的产品并执行并集操作,避免包含值为零的项目(表示没有产品)和重复元素。结果是(未排序)建议列表,对于许多不同的系统,几乎可以实时获得。在某些情况下,当用户或项目数量太多时,可以限制列表为固定数量的元素并减少邻居的数量。这种方法也是天真的,因为它没有考虑用户之间的实际距离(或相似性)来权衡建议。可以考虑将距离作为权重因子,但采用提供更稳健解决方案的协同过滤方法更简单。
基于内容的系统
这可能是最简单的方法,它仅基于产品,将其建模为特征向量:
就像用户一样,特征也可以是分类的(实际上,对于产品来说更容易),例如,书籍或电影的类型,并且它们可以在编码后与数值(如价格、长度、正面评价数量等)一起使用。
然后采用聚类策略,尽管最常用的是k 最近邻,因为它允许控制每个邻域的大小,从而确定给定一个样本产品,其质量和建议的数量。
使用 scikit-learn,首先我们创建一个虚拟产品数据集:
>>> nb_items = 1000
>>> items = np.zeros(shape=(nb_items, 4))
>>> for i in range(nb_items):
>>> items[i, 0] = np.random.randint(0, 100)
>>> items[i, 1] = np.random.randint(0, 100)
>>> items[i, 2] = np.random.randint(0, 100)
>>> items[i, 3] = np.random.randint(0, 100)
在这种情况下,我们有 1000 个样本,四个整数特征介于 0 和 100 之间。然后我们继续,就像上一个例子一样,将它们进行聚类:
>>> nn = NearestNeighbors(n_neighbors=10, radius=5.0)
>>> nn.fit(items)
在这一点上,我们可以使用方法 radius_neighbors() 来查询我们的模型,这允许我们仅将研究限制在有限的子集。默认半径(通过参数 radius 设置)为 5.0,但我们可以动态地更改它:
>>> test_product = np.array([15, 60, 28, 73])
>>> d, suggestions = nn.radius_neighbors(test_product.reshape(1, -1), radius=20)
>>> print(suggestions)
[array([657, 784, 839, 342, 446, 196], dtype=int64)]
>>> d, suggestions = nn.radius_neighbors(test_product.reshape(1, -1), radius=30)
>>> print(suggestions)
[ array([844, 340, 657, 943, 461, 799, 715, 863, 979, 784, 54, 148, 806,
465, 585, 710, 839, 695, 342, 881, 864, 446, 196, 73, 663, 580, 216], dtype=int64)]
当然,当尝试这些示例时,建议的数量可能不同,因为我们正在使用随机数据集,所以我建议尝试不同的半径值(特别是当使用不同的度量标准时)。
当使用 k-最近邻 进行聚类时,考虑用于确定样本之间距离的度量标准非常重要。scikit-learn 的默认值是 Minkowski 距离,它是欧几里得和曼哈顿距离的推广,定义为:
参数 p 控制距离的类型,默认值为 2,因此得到的度量是经典的欧几里得距离。SciPy(在 scipy.spatial.distance 包中)提供了其他距离,例如 汉明 和 杰卡德 距离。前者定义为两个向量之间的不一致比例(如果它们是二进制的,则这是不同位的标准化数量)。例如:
from scipy.spatial.distance import hamming
>>> a = np.array([0, 1, 0, 0, 1, 0, 1, 1, 0, 0])
>>> b = np.array([1, 1, 0, 0, 0, 1, 1, 1, 1, 0])
>>> d = hamming(a, b)
>>> print(d)
0.40000000000000002
这意味着有 40% 的不一致比例,或者考虑到两个向量都是二进制的,有 4 个不同的位(在 10 位中)。这个度量在需要强调特定特征的呈现/缺失时可能很有用。
杰卡德距离定义为:
测量两个不同集合(A 和 B)的项之间的差异特别有用。如果我们的特征向量是二进制的,则可以使用布尔逻辑立即应用此距离。使用之前的测试值,我们得到:
from scipy.spatial.distance import jaccard
>>> d = jaccard(a, b)
>>> print(d)
0.5714285714285714
这个度量在 0(相等向量)和 1(完全不同)之间有界。
至于汉明距离,当需要比较由二进制状态(如存在/不存在、是/否等)组成的项时,它非常有用。如果您想为 k-最近邻 采用不同的度量标准,可以直接使用 metric 参数指定它:
>>> nn = NearestNeighbors(n_neighbors=10, radius=5.0, metric='hamming')
>>> nn.fit(items) >>> nn = NearestNeighbors(n_neighbors=10, radius=5.0, metric='jaccard')
>>> nn.fit(items)
无模型(或基于记忆)的协同过滤
与基于用户的方法一样,让我们考虑有两个元素集合:用户和物品。然而,在这种情况下,我们不假设它们有显式特征。相反,我们试图根据每个用户(行)对每个物品(列)的偏好来建模用户-物品矩阵。例如:
在这种情况下,评分介于 1 到 5 之间(0 表示没有评分),我们的目标是根据用户的评分向量(实际上,这是一种基于特定类型特征的内部表示)对用户进行聚类。这允许在没有任何关于用户的明确信息的情况下产生推荐。然而,它有一个缺点,称为冷启动,这意味着当一个新用户没有评分时,无法找到正确的邻域,因为他/她可能属于几乎任何聚类。
一旦完成聚类,很容易检查哪些产品(尚未评分)对特定用户有更高的评分,因此更有可能被购买。可以像之前那样在 scikit-learn 中实现解决方案,但我想要介绍一个名为Crab的小型框架(见本节末尾的框),它简化了这一过程。
为了构建模型,我们首先需要将用户-物品矩阵定义为 Python 字典,其结构如下:
{ user_1: { item1: rating, item2: rating, ... }, ..., user_n: ... }
用户内部字典中的缺失值表示没有评分。在我们的例子中,我们考虑了 5 个用户和 5 个物品:
from scikits.crab.models import MatrixPreferenceDataModel
>>> user_item_matrix = {
1: {1: 2, 2: 5, 3: 3},
2: {1: 5, 4: 2},
3: {2: 3, 4: 5, 3: 2},
4: {3: 5, 5: 1},
5: {1: 3, 2: 3, 4: 1, 5: 3}
}
>>> model = MatrixPreferenceDataModel(user_item_matrix)
一旦定义了用户-物品矩阵,我们需要选择一个度量标准以及因此一个距离函数 d(u[i], u[j]) 来构建相似度矩阵:
使用 Crab,我们以以下方式(使用欧几里得距离)进行此操作:
from scikits.crab.similarities import UserSimilarity
from scikits.crab.metrics import euclidean_distances
>>> similarity_matrix = UserSimilarity(model, euclidean_distances)
有许多度量标准,如皮尔逊或贾卡德,所以我建议访问网站(muricoca.github.io/crab)以获取更多信息。在此阶段,可以构建基于 k 最近邻聚类方法的推荐系统并对其进行测试:
from scikits.crab.recommenders.knn import UserBasedRecommender
>>> recommender = UserBasedRecommender(model, similarity_matrix, with_preference=True)
>>> print(recommender.recommend(2))
[(2, 3.6180339887498949), (5, 3.0), (3, 2.5527864045000417)]
因此,推荐系统为用户 2 建议以下预测评分:
-
物品 2:3.6(可以四舍五入到 4.0)
-
物品 5:3
-
物品 3:2.5(可以四舍五入到 3.0)
在运行代码时,可能会看到一些警告(Crab 仍在开发中);然而,它们并不影响功能。如果您想避免它们,可以使用catch_warnings()上下文管理器:
import warnings
>>> with warnings.catch_warnings():
>>> warnings.simplefilter("ignore")
>>> print(recommender.recommend(2))
可以建议所有物品,或者限制列表只包含高评分(例如,避免物品 3)。这种方法与基于用户的模型相当相似。然而,它更快(非常大的矩阵可以并行处理)并且它不关心可能导致误导结果的具体细节。只有评分被视为定义用户的有用特征。像基于模型的协同过滤一样,冷启动问题可以通过两种方式解决:
-
要求用户对一些物品进行评分(这种做法通常被采用,因为它很容易展示一些电影/书籍封面,让用户选择他们喜欢和不喜欢的内容)。
-
通过随机分配一些平均评分将用户放置在平均邻域中。在这种方法中,可以立即开始使用推荐系统。然而,在开始时必须接受一定程度的错误,并在产生真实评分时纠正虚拟评分。
Crab 是一个用于构建协同过滤系统的开源框架。它仍在开发中,因此尚未实现所有可能的功能。然而,它非常易于使用,对于许多任务来说非常强大。带有安装说明和文档的主页是:muricoca.github.io/crab/index.html。Crab 依赖于 scikits.learn,它仍然与 Python 3 有一些问题。因此,我建议在这个例子中使用 Python 2.7。可以使用 pip 安装这两个包:pip install -U scikits.learn 和 pip install -U crab。
基于模型的协同过滤
这是目前最先进的方法之一,是前一个章节中已看到内容的扩展。起点始终是基于评分的用户-项目矩阵:
然而,在这种情况下,我们假设用户和项目都存在潜在因素。换句话说,我们定义一个通用的用户为:
一个通用的项目定义为:
我们不知道每个向量分量的值(因此它们被称为潜在值),但我们假设通过以下方式获得排名:
因此,我们可以这样说,排名是从一个包含 k 个潜在变量的潜在空间中获得的,其中 k 是我们希望在模型中考虑的潜在变量的数量。一般来说,有规则可以确定 k 的正确值,因此最佳方法是检查不同的值,并使用已知评分的子集测试模型。然而,仍然有一个大问题需要解决:找到潜在变量。有几种策略,但在讨论它们之前,了解我们问题的维度很重要。如果我们有 1000 个用户和 500 个产品,M 有 500,000 个元素。如果我们决定排名等于 10,这意味着我们需要找到 5000000 个变量,这些变量受已知评分的限制。正如你可以想象的那样,这个问题很容易变得无法用标准方法解决,并且必须采用并行解决方案。
单值分解策略
第一种方法是基于用户-项目矩阵的奇异值分解(SVD)。这种技术允许通过低秩分解来转换矩阵,也可以像 Sarwar B.,Karypis G.,Konstan J.,Riedl J.,Incremental Singular Value Decomposition Algorithms for Highly Scalable Recommender Systems,2002 年描述的那样以增量方式使用。特别是,如果用户-项目矩阵有m行和n列:
我们假设我们拥有实矩阵(在我们的情况下通常是真的),但一般来说,它们是复数。U和V是正交的,而 sigma 是对角的。U的列包含左奇异向量,转置V的行包含右奇异向量,而对角矩阵 Sigma 包含奇异值。选择k个潜在因子意味着取前k个奇异值,因此,相应的k个左和右奇异向量:
这种技术具有最小化M和M[k]之间 Frobenius 范数差异的优点,对于任何k的值,因此,它是逼近完整分解的最佳选择。在进入预测阶段之前,让我们使用 SciPy 创建一个示例。首先要做的是创建一个虚拟的用户-项目矩阵:
>>> M = np.random.randint(0, 6, size=(20, 10))
>>> print(M)
array([[0, 4, 5, 0, 1, 4, 3, 3, 1, 3],
[1, 4, 2, 5, 3, 3, 3, 4, 3, 1],
[1, 1, 2, 2, 1, 5, 1, 4, 2, 5],
[0, 4, 1, 2, 2, 5, 1, 1, 5, 5],
[2, 5, 3, 1, 1, 2, 2, 4, 1, 1],
[1, 4, 3, 3, 0, 0, 2, 3, 3, 5],
[3, 5, 2, 1, 5, 3, 4, 1, 0, 2],
[5, 2, 2, 0, 1, 0, 4, 4, 1, 0],
[0, 2, 4, 1, 3, 1, 3, 0, 5, 4],
[2, 5, 1, 5, 3, 0, 1, 4, 5, 2],
[1, 0, 0, 5, 1, 3, 2, 0, 3, 5],
[5, 3, 1, 5, 0, 0, 4, 2, 2, 2],
[5, 3, 2, 4, 2, 0, 4, 4, 0, 3],
[3, 2, 5, 1, 1, 2, 1, 1, 3, 0],
[1, 5, 5, 2, 5, 2, 4, 5, 1, 4],
[4, 0, 2, 2, 1, 0, 4, 4, 3, 3],
[4, 2, 2, 3, 3, 4, 5, 3, 5, 1],
[5, 0, 5, 3, 0, 0, 3, 5, 2, 2],
[1, 3, 2, 2, 3, 0, 5, 4, 1, 0],
[1, 3, 1, 4, 1, 5, 4, 4, 2, 1]])
我们假设有 20 个用户和 10 个产品。评分介于 1 到 5 之间,0 表示没有评分。现在我们可以分解M:
from scipy.linalg import svd
import numpy as np
>>> U, s, V = svd(M, full_matrices=True)
>>> S = np.diag(s)
>>> print(U.shape)
(20L, 20L)
>>> print(S.shape)
(10L, 10L)
>>> print(V.shape)
(10L, 10L)
现在让我们只考虑前八个奇异值,这将使用户和项目都有八个潜在因子:
>>> Uk = U[:, 0:8]
>>> Sk = S[0:8, 0:8]
>>> Vk = V[0:8, :]
请记住,在 SciPy 的 SVD 实现中,V已经转置。根据 Sarwar B.,Karypis G.,Konstan J.,Riedl J.,Incremental Singular Value Decomposition Algorithms for Highly Scalable Recommender Systems,2002 年的描述,我们可以很容易地通过考虑客户和产品之间的余弦相似度(与点积成正比)来进行预测。两个潜在因子矩阵是:
为了考虑到精度的损失,考虑每个用户的平均评分(这对应于用户-项目矩阵的行平均值)也是很有用的,这样用户i和项目j的结果评分预测就变为:
这里SU和SI分别是用户和产品向量。继续我们的例子,让我们确定用户 5 和项目 2 的评分预测:
>>> Su = Uk.dot(np.sqrt(Sk).T)
>>> Si = np.sqrt(Sk).dot(Vk).T
>>> Er = np.mean(M, axis=1)
>>> r5_2 = Er[5] + Su[5].dot(Si[2])
>>> print(r5_2)
2.38848720112
此方法具有中等复杂度。特别是,SVD 是O(m³),当添加新用户或项目时,必须采用增量策略(如 Sarwar B.、Karypis G.、Konstan J.、Riedl J.在 2002 年发表的高度可扩展推荐系统增量奇异值分解算法中所述);然而,当元素数量不是太多时,它可能非常有效。在所有其他情况下,可以采用下一个策略(与并行架构一起)。
交替最小二乘策略
通过定义以下损失函数,可以轻松地将寻找潜在因子的难题表达为一个最小二乘优化问题:
L 仅限于已知样本(用户、项目)。第二个项作为一个正则化因子,整个问题可以很容易地通过任何优化方法解决。然而,还有一个额外的问题:我们有两组不同的变量需要确定(用户和项目因子)。我们可以通过一种称为交替最小二乘的方法来解决此问题,该方法由 Koren Y.、Bell R.、Volinsky C.在 2009 年 8 月的 IEEE 计算机杂志上发表的推荐系统矩阵分解技术中描述。该算法非常容易描述,可以总结为两个主要的迭代步骤:
-
*p[i]*是固定的,*q[j]*是优化的
-
q[j]是固定的,*p[i]*是优化的
当达到预定义的精度时,算法停止。它可以很容易地通过并行策略实现,以便在短时间内处理大量矩阵。此外,考虑到虚拟集群的成本,还可以定期重新训练模型,以立即(在可接受的延迟内)包含新产品和用户。
使用 Apache Spark MLlib 的交替最小二乘
Apache Spark 超出了本书的范围,因此如果您想了解更多关于这个强大框架的信息,我建议您阅读在线文档或许多可用的书籍。在 Pentreath N.的Spark 机器学习(Packt)中,有一个关于库 MLlib 和如何实现本书中讨论的大多数算法的有趣介绍。
Spark 是一个并行计算引擎,现在是 Hadoop 项目的一部分(即使它不使用其代码),可以在本地模式或非常大的集群(具有数千个节点)上运行,以使用大量数据执行复杂任务。它主要基于 Scala,尽管有 Java、Python 和 R 的接口。在这个例子中,我们将使用 PySpark,这是运行 Spark 的 Python 代码的内置 shell。
在本地模式下启动 PySpark 后,我们得到一个标准的 Python 提示符,我们可以开始工作,就像在任何其他标准 Python 环境中一样:
# Linux
>>> ./pyspark
# Mac OS X
>>> pyspark
# Windows
>>> pyspark
Python 2.7.12 |Anaconda 4.0.0 (64-bit)| (default, Jun 29 2016, 11:07:13) [MSC v.1500 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
Anaconda is brought to you by Continuum Analytics.
Please check out: http://continuum.io/thanks and https://anaconda.org
Using Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevewl(newLevel).
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/__ / .__/\_,_/_/ /_/\_\ version 2.0.2
/_/
Using Python version 2.7.12 (default, Jun 29 2016 11:07:13)
SparkSession available as 'spark'.
>>>
Spark MLlib 通过一个非常简单的机制实现了 ALS 算法。Rating类是元组(user, product, rating)的包装器,因此我们可以轻松地定义一个虚拟数据集(这只能被视为一个示例,因为它非常有限):
from pyspark.mllib.recommendation import Rating
import numpy as np
>>> nb_users = 200
>>> nb_products = 100
>>> ratings = []
>>> for _ in range(10):
>>> for i in range(nb_users):
>>> rating = Rating(user=i,
>>> product=np.random.randint(1, nb_products),
>>> rating=np.random.randint(0, 5))
>>> ratings.append(rating)
>>> ratings = sc.parallelize(ratings)
我们假设有 200 个用户和 100 个产品,并且通过迭代 10 次主循环,为随机产品分配评分来填充评分列表。我们没有控制重复或其他不常见的情况。最后的命令sc.parallelize()是一种请求 Spark 将我们的列表转换为称为弹性分布式数据集(RDD)的结构的方法,它将被用于剩余的操作。这些结构的大小实际上没有限制,因为它们分布在不同的执行器上(如果是在集群模式下),并且可以像处理千字节数据集一样处理 PB 级的数据集。
在这个阶段,我们可以训练一个ALS模型(形式上是MatrixFactorizationModel),并使用它来进行一些预测:
from pyspark.mllib.recommendation import ALS
>>> model = ALS.train(ratings, rank=5, iterations=10)
我们想要 5 个潜在因素和 10 次优化迭代。正如之前讨论的那样,确定每个模型的正确秩并不容易,因此,在训练阶段之后,应该始终有一个使用已知数据的验证阶段。均方误差是一个很好的指标,可以用来了解模型的工作情况。我们可以使用相同的训练数据集来完成这项工作。首先要做的是移除评分(因为我们只需要由用户和产品组成的元组):
>>> test = ratings.map(lambda rating: (rating.user, rating.product))
如果您不熟悉 MapReduce 范式,您只需要知道map()会对所有元素应用相同的函数(在这种情况下,是一个 lambda 函数)。现在我们可以大量预测评分:
>>> predictions = model.predictAll(test)
然而,为了计算误差,我们还需要添加用户和产品,以便可以进行比较:
>>> full_predictions = predictions.map(lambda pred: ((pred.user, pred.product), pred.rating))
结果是一系列具有结构((user, item), rating)的行,就像一个标准的字典条目(key, value)。这很有用,因为使用 Spark,我们可以通过它们的键来连接两个 RDD。我们也对原始数据集做了同样的事情,然后通过连接训练值和预测值来继续操作:
>>> split_ratings = ratings.map(lambda rating: ((rating.user, rating.product), rating.rating))
>>> joined_predictions = split_ratings.join(full_predictions)
现在对于每个键 (user, product),我们有两个值:目标和预测。因此,我们可以计算均方误差:
>>> mse = joined_predictions.map(lambda x: (x[1][0] - x[1][1]) ** 2).mean()
第一个map操作将每一行转换为目标和预测之间的平方差,而mean()函数计算平均值。在这个时候,让我们检查我们的误差并生成一个预测:
>>> print('MSE: %.3f' % mse)
MSE: 0.580
>>> prediction = model.predict(10, 20)
>>> print('Prediction: %3.f' % prediction)
Prediction: 2.810
因此,我们的误差相当低,但可以通过改变秩或迭代次数来提高。用户 10 对产品 20 的评分预测约为 2.8(可以四舍五入到 3)。如果您运行代码,这些值可能会有所不同,因为我们正在使用随机的用户-项目矩阵。此外,如果您不想使用 shell 直接运行代码,您需要在文件开头显式声明一个SparkContext:
from pyspark import SparkContext, SparkConf
>>> conf = SparkConf().setAppName('ALS').setMaster('local[*]')
>>> sc = SparkContext(conf=conf)
我们通过SparkConf类创建了一个配置,并指定了应用程序名称和主节点(在本地模式下,使用所有可用核心)。这足以运行我们的代码。然而,如果您需要更多信息,请访问章节末尾信息框中提到的页面。要运行应用程序(自 Spark 2.0 起),您必须执行以下命令:
# Linux, Mac OSx
./spark-submit als_spark.py
# Windows
spark-submit als_spark.py
当使用spark-submit运行脚本时,您将看到数百行日志,这些日志会通知您正在执行的所有操作。其中,在计算结束时,您还会看到打印函数消息(stdout)。
当然,这只是一个 Spark ALS 的介绍,但我希望它有助于理解这个过程有多简单,同时如何有效地解决维度限制问题。
如果您不知道如何设置环境和启动 PySpark,我建议阅读在线快速入门指南(spark.apache.org/docs/2.1.0/quick-start.html),即使您不了解所有细节和配置参数,它也可能很有用。
参考文献
-
Sarwar B.,Karypis G.,Konstan J.,Riedl J.,高度可扩展推荐系统的增量奇异值分解算法,2002
-
Koren Y.,Bell R.,Volinsky C.,推荐系统的矩阵分解技术,IEEE 计算机杂志,2009 年 8 月
-
Pentreath N.,使用 Spark 进行机器学习,Packt
摘要
在本章中,我们讨论了构建推荐系统的主要技术。在基于用户的场景中,我们假设我们拥有足够关于用户的信息来对他们进行聚类,并且我们隐含地假设相似的用户会喜欢相同的产品。这样,就可以立即确定每个新用户所在的邻域,并建议其同龄人给予正面评价的产品。以类似的方式,基于内容的场景是基于根据产品的独特特征对产品进行聚类。在这种情况下,假设较弱,因为更有可能的是,购买过某个商品或给予正面评价的用户会对类似产品做同样的事情。
然后我们介绍了协同过滤,这是一种基于显式评分的技术,用于预测所有用户和产品的所有缺失值。在基于记忆的变体中,我们不训练模型,而是直接与用户-产品矩阵一起工作,寻找测试用户的 k 个最近邻,并通过平均计算排名。这种方法与基于用户的场景非常相似,并且具有相同的局限性;特别是,管理大型矩阵非常困难。另一方面,基于模型的方法更复杂,但在训练模型后,它可以实时预测评分。此外,还有像 Spark 这样的并行框架,可以使用廉价的集群服务器处理大量数据。
在下一章中,我们将介绍一些自然语言处理技术,这些技术在自动分类文本或与机器翻译系统协同工作时非常重要。
第十二章:自然语言处理简介
自然语言处理是一组机器学习技术,允许处理文本文档,考虑其内部结构和单词的分布。在本章中,我们将讨论收集文本、将它们拆分为原子并转换为数值向量的所有常用方法。特别是,我们将比较不同的方法来分词文档(分离每个单词)、过滤它们、应用特殊转换以避免屈折或动词变位形式,并最终构建一个共同词汇。使用词汇,将能够应用不同的向量化方法来构建特征向量,这些向量可以轻松用于分类或聚类目的。为了展示如何实现整个管道,在本章末尾,我们将设置一个简单的新闻行分类器。
NLTK 和内置语料库
自然语言工具包(NLTK)是一个非常强大的 Python 框架,实现了大多数 NLP 算法,并将与 scikit-learn 一起在本章中使用。此外,NLTK 提供了一些内置的语料库,可用于测试算法。在开始使用 NLTK 之前,通常需要使用特定的图形界面下载所有附加元素(语料库、词典等)。这可以通过以下方式完成:
import nltk
>>> nltk.download()
此命令将启动用户界面,如图所示:
可以选择每个单独的特征或下载所有元素(如果您有足够的空闲空间,我建议选择此选项)以立即利用所有 NLTK 功能。
NLTK 可以使用 pip(pip install -U nltk)或通过www.nltk.org上可用的二进制分发安装。在同一网站上,有完整的文档,对于深入了解每个主题非常有用。
语料库示例
格鲁吉亚项目的一个子集被提供,并且可以通过这种方式免费访问:
from nltk.corpus import gutenberg
>>> print(gutenberg.fileids())
[u'austen-emma.txt', u'austen-persuasion.txt', u'austen-sense.txt', u'bible-kjv.txt', u'blake-poems.txt', u'bryant-stories.txt', u'burgess-busterbrown.txt', u'carroll-alice.txt', u'chesterton-ball.txt', u'chesterton-brown.txt', u'chesterton-thursday.txt', u'edgeworth-parents.txt', u'melville-moby_dick.txt', u'milton-paradise.txt', u'shakespeare-caesar.txt', u'shakespeare-hamlet.txt', u'shakespeare-macbeth.txt', u'whitman-leaves.txt']
单个文档可以以原始版本访问或拆分为句子或单词:
>>> print(gutenberg.raw('milton-paradise.txt'))
[Paradise Lost by John Milton 1667]
Book I
Of Man's first disobedience, and the fruit
Of that forbidden tree whose mortal taste
Brought death into the World, and all our woe,
With loss of Eden, till one greater Man
Restore us, and regain the blissful seat,
Sing, Heavenly Muse, that, on the secret top...
>>> print(gutenberg.sents('milton-paradise.txt')[0:2])
[[u'[', u'Paradise', u'Lost', u'by', u'John', u'Milton', u'1667', u']'], [u'Book', u'I']]
>>> print(gutenberg.words('milton-paradise.txt')[0:20])
[u'[', u'Paradise', u'Lost', u'by', u'John', u'Milton', u'1667', u']', u'Book', u'I', u'Of', u'Man', u"'", u's', u'first', u'disobedience', u',', u'and', u'the', u'fruit']
正如我们将要讨论的,在许多情况下,拥有原始文本以便使用自定义策略将其拆分为单词是有用的。在许多其他情况下,直接访问句子允许使用原始的结构性细分。其他语料库包括网络文本、路透社新闻行、布朗语料库以及许多更多。例如,布朗语料库是一个按体裁划分的著名文档集合:
from nltk.corpus import brown
>>> print(brown.categories())
[u'adventure', u'belles_lettres', u'editorial', u'fiction', u'government', u'hobbies', u'humor', u'learned', u'lore', u'mystery', u'news', u'religion', u'reviews', u'romance', u'science_fiction']
>>> print(brown.sents(categories='editorial')[0:100])
[[u'Assembly', u'session', u'brought', u'much', u'good'], [u'The', u'General', u'Assembly', u',', u'which', u'adjourns', u'today', u',', u'has', u'performed', u'in', u'an', u'atmosphere', u'of', u'crisis', u'and', u'struggle', u'from', u'the', u'day', u'it', u'convened', u'.'], ...]
关于语料库的更多信息可以在www.nltk.org/book/ch02.html找到。
词袋策略
在 NLP 中,一个非常常见的管道可以细分为以下步骤:
-
将文档收集到语料库中。
-
分词、去除停用词(冠词、介词等)和词干提取(还原到词根形式)。
-
构建共同词汇。
-
向量化文档。
-
对文档进行分类或聚类。
该管道被称为词袋模型,将在本章中讨论。一个基本假设是句子中每个单词的顺序并不重要。实际上,当我们定义特征向量时,我们将要看到,所采取的措施总是与频率相关,因此它们对所有元素的局部位置不敏感。从某些观点来看,这是一个限制,因为在自然语言中,句子的内部顺序对于保留意义是必要的;然而,有许多模型可以在不涉及局部排序的复杂性的情况下有效地处理文本。当绝对有必要考虑小序列时,将通过采用标记组(称为 n-gram)来实现,但在向量化步骤中将它们视为单个原子元素。
在以下图例中,有一个该过程的示意图(不包括第五步)对于一个示例文档(句子):
执行每个步骤有许多不同的方法,其中一些是上下文特定的。然而,目标始终相同:通过移除过于频繁或来自同一词根(如动词)的术语来最大化文档的信息量并减少常用词汇表的大小。实际上,文档的信息含量是由在语料库中频率有限的特定术语(或术语组)的存在决定的。在前面图例中显示的例子中,狐狸和狗是重要术语,而the则无用(通常称为停用词)。此外,跳跃可以转换为标准形式跳,当以不同形式出现时(如跳跃或跳过),它表达了一个特定的动作。最后一步是将其转换为数值向量,因为我们的算法处理的是数字,因此限制向量的长度对于提高学习速度和内存消耗非常重要。在接下来的章节中,我们将详细讨论每个步骤,并在最后构建一个用于新闻分类的示例分类器。
标记化
处理文本或语料库的第一步是将它们拆分成原子(句子、单词或单词的一部分),通常定义为标记。这个过程相当简单;然而,针对特定问题可能会有不同的策略。
句子标记化
在许多情况下,将大文本拆分成句子是有用的,这些句子通常由句号或其他等效标记分隔。由于每种语言都有自己的正字法规则,NLTK 提供了一个名为sent_tokenize()的方法,它接受一种语言(默认为英语)并根据特定规则拆分文本。在以下示例中,我们展示了该函数在不同语言中的使用:
from nltk.tokenize import sent_tokenize
>>> generic_text = 'Lorem ipsum dolor sit amet, amet minim temporibus in sit. Vel ne impedit consequat intellegebat.'
>>> print(sent_tokenize(generic_text))
['Lorem ipsum dolor sit amet, amet minim temporibus in sit.',
'Vel ne impedit consequat intellegebat.']
>>> english_text = 'Where is the closest train station? I need to reach London'
>>> print(sent_tokenize(english_text, language='english'))
['Where is the closest train station?', 'I need to reach London']
>>> spanish_text = u'¿Dónde está la estación más cercana? Inmediatamente me tengo que ir a Barcelona.'
>>> for sentence in sent_tokenize(spanish_text, language='spanish'):
>>> print(sentence)
¿Dónde está la estación más cercana?
Inmediatamente me tengo que ir a Barcelona.
单词标记化
将句子拆分成单词的最简单方法是类TreebankWordTokenizer提供的,然而,它也有一些局限性:
from nltk.tokenize import TreebankWordTokenizer
>>> simple_text = 'This is a simple text.'
>>> tbwt = TreebankWordTokenizer()
>>> print(tbwt.tokenize(simple_text))
['This', 'is', 'a', 'simple', 'text', '.']
>>> complex_text = 'This isn\'t a simple text'
>>> print(tbwt.tokenize(complex_text))
['This', 'is', "n't", 'a', 'simple', 'text']
如您所见,在第一种情况下,句子已经被正确地拆分成单词,同时保持了标点符号的分离(这不是一个真正的问题,因为可以在第二步中将其删除)。然而,在复杂示例中,缩写词isn't被拆分为is和n't。不幸的是,没有进一步的加工步骤,将带有缩写词的标记转换为正常形式(如not)并不那么容易,因此,必须采用另一种策略。通过类RegexpTokenizer提供了一种灵活的方式来根据正则表达式拆分单词,这是解决单独标点问题的一个好方法:
from nltk.tokenize import RegexpTokenizer
>>> complex_text = 'This isn\'t a simple text.'
>>> ret = RegexpTokenizer('[a-zA-Z0-9\'\.]+')
>>> print(ret.tokenize(complex_text))
['This', "isn't", 'a', 'simple', 'text.']
大多数常见问题都可以很容易地使用这个类来解决,所以我建议你学习如何编写可以匹配特定模式的简单正则表达式。例如,我们可以从句子中移除所有数字、逗号和其他标点符号:
>>> complex_text = 'This isn\'t a simple text. Count 1, 2, 3 and then go!'
>>> ret = RegexpTokenizer('[a-zA-Z\']+')
>>> print(ret.tokenize(complex_text))
['This', "isn't", 'a', 'simple', 'text', 'Count', 'and', 'the', 'go']
即使 NLTK 提供了其他类,它们也总是可以通过自定义的RegexpTokenizer来实现,这个类足够强大,可以解决几乎每一个特定问题;因此,我更喜欢不深入这个讨论。
停用词移除
停用词是正常言语的一部分(如冠词、连词等),但它们的出现频率非常高,并且不提供任何有用的语义信息。因此,过滤句子和语料库时移除它们是一个好的做法。NLTK 提供了最常见语言的停用词列表,并且其使用是直接的:
from nltk.corpus import stopwords
>>> sw = set(stopwords.words('english'))
下面的代码片段显示了英语停用词的一个子集:
>>> print(sw)
{u'a',
u'about',
u'above',
u'after',
u'again',
u'against',
u'ain',
u'all',
u'am',
u'an',
u'and',
u'any',
u'are',
u'aren',
u'as',
u'at',
u'be', ...
要过滤一个句子,可以采用一种功能性的方法:
>>> complex_text = 'This isn\'t a simple text. Count 1, 2, 3 and then go!'
>>> ret = RegexpTokenizer('[a-zA-Z\']+')
>>> tokens = ret.tokenize(complex_text)
>>> clean_tokens = [t for t in tokens if t not in sw]
>>> print(clean_tokens)
['This', "isn't", 'simple', 'text', 'Count', 'go']
语言检测
停用词,像其他重要特征一样,与特定语言密切相关,因此在进行任何其他步骤之前,通常有必要检测语言。由langdetect库提供的一个简单、免费且可靠的解决方案,该库已从谷歌的语言检测系统移植过来。其使用是直接的:
from langdetect import detect
>>> print(detect('This is English'))
en
>>> print(detect('Dies ist Deutsch'))
de
该函数返回 ISO 639-1 代码(en.wikipedia.org/wiki/List_of_ISO_639-1_codes),这些代码可以用作字典中的键来获取完整的语言名称。当文本更复杂时,检测可能更困难,了解是否存在任何歧义是有用的。可以通过detect_langs()方法获取预期语言的概率:
from langdetect import detect_langs
>>> print(detect_langs('I really love you mon doux amour!'))
[fr:0.714281321163, en:0.285716747181]
可以使用 pip 安装 langdetect(pip install --upgrade langdetect)。更多信息可在pypi.python.org/pypi/langdetect找到。
词干提取
词干提取是一个将特定单词(如动词或复数形式)转换为它们的根形式的过程,以便在不增加唯一标记数量的情况下保留语义。例如,如果我们考虑三个表达式“我跑”、“他跑”和“跑步”,它们可以被简化为一个有用的(尽管语法上不正确)形式:“我跑”、“他跑”、“跑”。这样,我们就有一个定义相同概念(“跑”)的单个标记,在聚类或分类目的上,可以无任何精度损失地使用。NLTK 提供了许多词干提取器的实现。最常见(且灵活)的是基于多语言算法的SnowballStemmer:
from nltk.stem.snowball import SnowballStemmer
>>> ess = SnowballStemmer('english', ignore_stopwords=True)
>>> print(ess.stem('flies'))
fli
>>> fss = SnowballStemmer('french', ignore_stopwords=True)
>>> print(fss.stem('courais'))
cour
ignore_stopwords参数通知词干提取器不要处理停用词。其他实现包括PorterStemmer和LancasterStemmer。结果通常相同,但在某些情况下,词干提取器可以实施更选择性的规则。例如:
from nltk.stem.snowball import PorterStemmer
from nltk.stem.lancaster import LancasterStemmer
>>> print(ess.stem('teeth'))
teeth
>>> ps = PorterStemmer()
>>> print(ps.stem('teeth'))
teeth
>>> ls = LancasterStemmer()
>>> print(ls.stem('teeth'))
tee
如您所见,Snowball 和 Porter 算法保持单词不变,而 Lancaster 算法提取一个根(这在语义上是无意义的)。另一方面,后者算法实现了许多特定的英语规则,这实际上可以减少唯一标记的数量:
>>> print(ps.stem('teen'))
teen
>>> print(ps.stem('teenager'))
teenag
>>> print(ls.stem('teen'))
teen
>>> print(ls.stem('teenager'))
teen
不幸的是,Porter 和 Lancaster 词干提取器在 NLTK 中仅适用于英语;因此,默认选择通常是 Snowball,它在许多语言中都可用,并且可以与适当的停用词集一起使用。
向量化
这是词袋模型管道的最后一步,它是将文本标记转换为数值向量的必要步骤。最常见的技术是基于计数或频率计算,它们都在 scikit-learn 中可用,以稀疏矩阵表示(考虑到许多标记只出现几次而向量必须有相同的长度,这是一个可以节省大量空间的选择)。
计数向量化
该算法非常简单,它基于考虑一个标记在文档中出现的次数来表示一个标记。当然,整个语料库必须被处理,以确定有多少唯一的标记及其频率。让我们看看CountVectorizer类在简单语料库上的一个例子:
from sklearn.feature_extraction.text import CountVectorizer
>>> corpus = [
'This is a simple test corpus',
'A corpus is a set of text documents',
'We want to analyze the corpus and the documents',
'Documents can be automatically tokenized'
]
>>> cv = CountVectorizer()
>>> vectorized_corpus = cv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[0 0 0 0 0 1 0 1 0 0 1 1 0 0 1 0 0 0 0]
[0 0 0 0 0 1 1 1 1 1 0 0 1 0 0 0 0 0 0]
[1 1 0 0 0 1 1 0 0 0 0 0 0 2 0 1 0 1 1]
[0 0 1 1 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0]]
如您所见,每个文档都已转换为固定长度的向量,其中 0 表示相应的标记不存在,而正数表示出现的次数。如果我们需要排除所有文档频率低于预定义值的标记,我们可以通过参数min_df(默认值为 1)来设置它。有时避免非常常见的术语可能是有用的;然而,下一个策略将以更可靠和完整的方式解决这个问题。
词汇表可以通过实例变量vocabulary_访问:
>>> print(cv.vocabulary_)
{u'and': 1, u'be': 3, u'we': 18, u'set': 9, u'simple': 10, u'text': 12, u'is': 7, u'tokenized': 16, u'want': 17, u'the': 13, u'documents': 6, u'this': 14, u'of': 8, u'to': 15, u'can': 4, u'test': 11, u'corpus': 5, u'analyze': 0, u'automatically': 2}
给定一个通用向量,可以通过逆变换检索相应的标记列表:
>>> vector = [0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1]
>>> print(cv.inverse_transform(vector))
[array([u'corpus', u'is', u'simple', u'test', u'this', u'want', u'we'],
dtype='<U13')]
这两种方法都可以使用外部分词器(通过参数 tokenizer),可以通过前几节中讨论的技术进行自定义:
>>> ret = RegexpTokenizer('[a-zA-Z0-9\']+')
>>> sw = set(stopwords.words('english'))
>>> ess = SnowballStemmer('english', ignore_stopwords=True)
>>> def tokenizer(sentence):
>>> tokens = ret.tokenize(sentence)
>>> return [ess.stem(t) for t in tokens if t not in sw]
>>> cv = CountVectorizer(tokenizer=tokenizer)
>>> vectorized_corpus = cv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[0 0 1 0 0 1 1 0 0 0]
[0 0 1 1 1 0 0 1 0 0]
[1 0 1 1 0 0 0 0 0 1]
[0 1 0 1 0 0 0 0 1 0]]
使用我们的分词器(使用停用词和词干提取),词汇表更短,向量也更短。
N-grams
到目前为止,我们只考虑了单个标记(也称为单语素),但在许多情况下,考虑单词的短序列(双词组或三词组)作为我们的分类器的原子是有用的,就像所有其他标记一样。例如,如果我们正在分析某些文本的情感,考虑双词组如 pretty good、very bad 等可能是个好主意。从语义角度来看,实际上,考虑不仅仅是副词,而是整个复合形式很重要。我们可以向向量器告知我们想要考虑的 n-grams 范围。例如,如果我们需要单语素和双语素,我们可以使用以下代码片段:
>>> cv = CountVectorizer(tokenizer=tokenizer, ngram_range=(1, 2))
>>> vectorized_corpus = cv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[0 0 0 0 0 1 0 1 0 0 1 1 0 0 1 0 0 0 0]
[0 0 0 0 0 1 1 1 1 1 0 0 1 0 0 0 0 0 0]
[1 1 0 0 0 1 1 0 0 0 0 0 0 2 0 1 0 1 1]
[0 0 1 1 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0]]
>>> print(cv.vocabulary_)
{u'and': 1, u'be': 3, u'we': 18, u'set': 9, u'simple': 10, u'text': 12, u'is': 7, u'tokenized': 16, u'want': 17, u'the': 13, u'documents': 6, u'this': 14, u'of': 8, u'to': 15, u'can': 4, u'test': 11, u'corpus': 5, u'analyze': 0, u'automatically': 2}
如您所见,词汇表现在包含了双词组,并且向量包括了它们的相对频率。
Tf-idf 向量化
计数向量化的最常见限制是,算法在考虑每个标记的频率时没有考虑整个语料库。向量化的目标通常是准备数据供分类器使用;因此,避免非常常见的特征是必要的,因为当全局出现次数增加时,它们的信息量会减少。例如,在一个关于体育的语料库中,单词 match 可能会在大量文档中出现;因此,它几乎作为一个分类特征是无用的。为了解决这个问题,我们需要不同的方法。如果我们有一个包含 n 个文档的语料库 C,我们定义词频,即一个标记在文档中出现的次数,如下所示:
我们定义逆文档频率,如下所示:
换句话说,idf(t,C) 衡量每个单个术语提供的信息量。实际上,如果 count(D,t) = n,这意味着一个标记总是存在,且 idf(t, C) 接近 0,反之亦然。分母中的术语 1 是一个校正因子,它避免了当 (D,t) = n 时的 null idf。因此,我们不仅考虑术语频率,还通过定义一个新的度量来权衡每个标记:
scikit-learn 提供了 TfIdfVectorizer 类,我们可以将其应用于前一段中使用的相同玩具语料库:
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> tfidfv = TfidfVectorizer()
>>> vectorized_corpus = tfidfv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[ 0\. 0\. 0\. 0\. 0\. 0.31799276
0\. 0.39278432 0\. 0\. 0.49819711 0.49819711
0\. 0\. 0.49819711 0\. 0\. 0\. 0\. ]
[ 0\. 0\. 0\. 0\. 0\. 0.30304005
0.30304005 0.37431475 0.4747708 0.4747708 0\. 0.
0.4747708 0\. 0\. 0\. 0\. 0\. 0\. ]
[ 0.31919701 0.31919701 0\. 0\. 0\. 0.20373932
0.20373932 0\. 0\. 0\. 0\. 0\. 0.
0.63839402 0\. 0.31919701 0\. 0.31919701 0.31919701]
[ 0\. 0\. 0.47633035 0.47633035 0.47633035 0.
0.30403549 0\. 0\. 0\. 0\. 0\. 0.
0\. 0\. 0\. 0.47633035 0\. 0\. ]]
现在我们检查词汇表,以便与简单的计数向量化进行比较:
>>> print(tfidfv.vocabulary_)
{u'and': 1, u'be': 3, u'we': 18, u'set': 9, u'simple': 10, u'text': 12, u'is': 7, u'tokenized': 16, u'want': 17, u'the': 13, u'documents': 6, u'this': 14, u'of': 8, u'to': 15, u'can': 4, u'test': 11, u'corpus': 5, u'analyze': 0, u'automatically': 2}
术语documents在两个向量器中都是第六个特征,并且出现在最后三个文档中。正如你所看到的,它的权重大约是 0.3,而术语the只在第三个文档中出现了两次,其权重大约是 0.64。一般规则是:如果一个术语代表一个文档,那么它的权重会接近 1.0,而如果在一个样本文档中找到它不能轻易确定其类别,那么它的权重会降低。
在这个情况下,也可以使用外部标记化器并指定所需的 n-gram 范围。此外,还可以通过参数norm对向量进行归一化,并决定是否将 1 添加到 idf 分母中(通过参数smooth_idf)。还可以使用参数min_df和max_df定义接受的文档频率范围,以便排除出现次数低于或高于最小/最大阈值的标记。它们接受整数(出现次数)或范围在[0.0, 1.0]内的浮点数(文档比例)。在下一个示例中,我们将使用这些参数中的一些:
>>> tfidfv = TfidfVectorizer(tokenizer=tokenizer, ngram_range=(1, 2), norm='l2')
>>> vectorized_corpus = tfidfv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[ 0\. 0\. 0\. 0\. 0.30403549 0\. 0.
0\. 0\. 0\. 0\. 0.47633035 0.47633035
0.47633035 0.47633035 0\. 0\. 0\. 0\. 0\. ]
[ 0\. 0\. 0\. 0\. 0.2646963 0.
0.4146979 0.2646963 0\. 0.4146979 0.4146979 0\. 0.
0\. 0\. 0.4146979 0.4146979 0\. 0\. 0\. ]
[ 0.4146979 0.4146979 0\. 0\. 0.2646963 0.4146979
0\. 0.2646963 0\. 0\. 0\. 0\. 0.
0\. 0\. 0\. 0\. 0\. 0.4146979
0.4146979 ]
[ 0\. 0\. 0.47633035 0.47633035 0\. 0\. 0.
0.30403549 0.47633035 0\. 0\. 0\. 0\. 0.
0\. 0\. 0\. 0.47633035 0\. 0\. ]]
>>> print(tfidfv.vocabulary_)
{u'analyz corpus': 1, u'set': 9, u'simpl test': 12, u'want analyz': 19, u'automat': 2, u'want': 18, u'test corpus': 14, u'set text': 10, u'corpus set': 6, u'automat token': 3, u'corpus document': 5, u'text document': 16, u'token': 17, u'document automat': 8, u'text': 15, u'test': 13, u'corpus': 4, u'document': 7, u'simpl': 11, u'analyz': 0}
特别是,如果向量必须作为分类器的输入,那么归一化向量总是一个好的选择,正如我们将在下一章中看到的。
基于路透社语料库的文本分类器示例
我们将基于 NLTK 路透社语料库构建一个文本分类器示例。这个分类器由成千上万的新闻行组成,分为 90 个类别:
from nltk.corpus import reuters
>>> print(reuters.categories())
[u'acq', u'alum', u'barley', u'bop', u'carcass', u'castor-oil', u'cocoa', u'coconut', u'coconut-oil', u'coffee', u'copper', u'copra-cake', u'corn', u'cotton', u'cotton-oil', u'cpi', u'cpu', u'crude', u'dfl', u'dlr', u'dmk', u'earn', u'fuel', u'gas', u'gnp', u'gold', u'grain', u'groundnut', u'groundnut-oil', u'heat', u'hog', u'housing', u'income', u'instal-debt', u'interest', u'ipi', u'iron-steel', u'jet', u'jobs', u'l-cattle', u'lead', u'lei', u'lin-oil', u'livestock', u'lumber', u'meal-feed', u'money-fx', u'money-supply', u'naphtha', u'nat-gas', u'nickel', u'nkr', u'nzdlr', u'oat', u'oilseed', u'orange', u'palladium', u'palm-oil', u'palmkernel', u'pet-chem', u'platinum', u'potato', u'propane', u'rand', u'rape-oil', u'rapeseed', u'reserves', u'retail', u'rice', u'rubber', u'rye', u'ship', u'silver', u'sorghum', u'soy-meal', u'soy-oil', u'soybean', u'strategic-metal', u'sugar', u'sun-meal', u'sun-oil', u'sunseed', u'tea', u'tin', u'trade', u'veg-oil', u'wheat', u'wpi', u'yen', u'zinc']
为了简化过程,我们将只选取两个具有相似文档数量的类别:
import numpy as np
>>> Xr = np.array(reuters.sents(categories=['rubber']))
>>> Xc = np.array(reuters.sents(categories=['cotton']))
>>> Xw = np.concatenate((Xr, Xc))
由于每个文档已经被分割成标记,并且我们想要应用我们的自定义标记化器(带有停用词去除和词干提取),我们需要重建完整的句子:
>>> X = []
>>> for document in Xw:
>>> X.append(' '.join(document).strip().lower())
现在我们需要准备标签向量,将rubber分配为 0,将cotton分配为 1:
>>> Yr = np.zeros(shape=Xr.shape)
>>> Yc = np.ones(shape=Xc.shape)
>>> Y = np.concatenate((Yr, Yc))
在这一点上,我们可以对语料库进行向量化:
>>> tfidfv = TfidfVectorizer(tokenizer=tokenizer, ngram_range=(1, 2), norm='l2')
>>> Xv = tfidfv.fit_transform(X)
现在数据集已经准备好了,我们可以通过将其分为训练集和测试集来继续,并最终训练我们的分类器。我决定采用随机森林,因为它特别适合这类任务,但读者可以尝试不同的分类器并比较结果:
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
>>> X_train, X_test, Y_train, Y_test = train_test_split(Xv, Y, test_size=0.25)
>>> rf = RandomForestClassifier(n_estimators=25)
>>> rf.fit(X_train, Y_train)
>>> score = rf.score(X_test, Y_test)
>>> print('Score: %.3f' % score)
Score: 0.874
得分大约是 88%,这是一个相当好的结果,但让我们尝试用一条假新闻进行预测:
>>> test_newsline = ['Trading tobacco is reducing the amount of requests for cotton and this has a negative impact on our economy']
>>> yvt = tfidfv.transform(test_newsline)
>>> category = rf.predict(yvt)
>>> print('Predicted category: %d' % int(category[0]))
Predicted category: 1
分类结果正确;然而,通过采用我们将在下一章中讨论的一些技术,在更复杂的现实生活问题中也可以获得更好的性能。
参考文献
-
Perkins J.,《Python 3 文本处理与 NLTK 3 烹饪书》,Packt。
-
Hardeniya N.,《NLTK 基础》,Packt
-
Bonaccorso G.,《BBC 新闻分类算法比较》,
github.com/giuseppebonaccorso/bbc_news_classification_comparison。
摘要
在本章中,我们讨论了所有基本的自然语言处理技术,从语料库的定义开始,直到最终将其转换为特征向量。我们分析了不同的分词方法,以解决将文档分割成单词的特定问题或情况。然后我们介绍了一些过滤技术,这些技术是必要的,以去除所有无用的元素(也称为停用词)并将屈折形式转换为标准标记。
这些步骤对于通过去除常用术语来增加信息内容非常重要。当文档被成功清理后,可以使用简单的方法如计数向量器实现的方法,或者更复杂的方法,如考虑术语全局分布的方法,例如 tf-idf。后者是为了补充词干处理阶段的工作;实际上,它的目的是定义向量,其中每个分量在信息量高时接近 1,反之亦然。通常,一个在许多文档中都存在的单词不是一个好的分类器标记;因此,如果在前面的步骤中没有被去除,tf-idf 将自动降低其权重。在本章结束时,我们构建了一个简单的文本分类器,该分类器实现了整个词袋模型管道,并使用随机森林对新闻行进行分类。
在下一章中,我们将通过简要讨论高级技术,如主题建模、潜在语义分析和情感分析,来完成这个介绍。
第十三章:自然语言处理中的主题建模和情感分析
在本章中,我们将介绍一些常见的主题建模方法,并讨论一些应用。主题建模是自然语言处理的一个重要部分,其目的是从文档语料库中提取语义信息。我们将讨论潜在语义分析,这是最著名的方法之一;它基于已经讨论过的基于模型的推荐系统的相同哲学。我们还将讨论其概率变体 PLSA,它旨在构建一个没有先验分布假设的潜在因子概率模型。另一方面,潜在狄利克雷分配是一种类似的方法,它假设潜在变量具有先验狄利克雷分布。在最后一节中,我们将通过基于 Twitter 数据集的具体示例来讨论情感分析。
主题建模
自然语言处理中主题建模的主要目标是分析语料库,以识别文档之间的共同主题。在这种情况下,即使我们谈论语义,这个概念也有一个特定的含义,它是由一个非常重要的假设驱动的。一个主题来源于同一文档中特定术语的使用,并且通过多个不同文档中第一个条件成立来得到证实。
换句话说,我们不考虑面向人类的语义,而是一种与有意义的文档一起工作的统计模型(这保证了术语的使用旨在表达特定的概念,因此,它们背后有人的语义目的)。因此,我们所有方法的起点都是一个发生矩阵,通常定义为文档-词矩阵(我们已经在第十二章,《自然语言处理导论》中讨论了计数向量化 tf-idf):
在许多论文中,这个矩阵是转置的(它是一个词-文档矩阵);然而,scikit-learn 生成文档-词矩阵,为了避免混淆,我们将考虑这种结构。
潜在语义分析
潜在语义分析背后的思想是将*M[dw]*分解,以提取一组潜在变量(这意味着我们可以假设它们的存在,但它们不能直接观察到)。正如在第十一章,《推荐系统导论》中讨论的那样,一个非常常见的分解方法是 SVD:
然而,我们并不对完全分解感兴趣;我们只对由前k个奇异值定义的子空间感兴趣:
这个近似在考虑 Frobenius 范数的情况下享有最佳声誉,因此它保证了非常高的精度。当将其应用于文档-词矩阵时,我们得到以下分解:
或者,以更紧凑的方式:
这里,第一个矩阵定义了文档和 k 个潜在变量之间的关系,第二个则定义了 k 个潜在变量和单词之间的关系。考虑到原始矩阵的结构以及本章开头所解释的内容,我们可以将潜在变量视为主题,它们定义了一个子空间,文档被投影到这个子空间中。现在,一个通用的文档可以这样定义:
此外,每个主题都成为单词的线性组合。由于许多单词的权重接近于零,我们可以决定只取前 r 个单词来定义一个主题;因此,我们得到:
在这里,每个 h[ji] 都是在对 M[twk] 的列进行排序后得到的。为了更好地理解这个过程,让我们基于布朗语料库的一个子集(来自 news 类别的 500 篇文档)展示一个完整的示例:
from nltk.corpus import brown
>>> sentences = brown.sents(categories=['news'])[0:500]
>>> corpus = []
>>> for s in sentences:
>>> corpus.append(' '.join(s))
在定义语料库之后,我们需要使用 tf-idf 方法进行分词和向量化:
from sklearn.feature_extraction.text import TfidfVectorizer
>>> vectorizer = TfidfVectorizer(strip_accents='unicode', stop_words='english', norm='l2', sublinear_tf=True)
>>> Xc = vectorizer.fit_transform(corpus).todense()
现在可以对 Xc 矩阵应用 SVD(记住在 SciPy 中,V 矩阵已经转置):
from scipy.linalg import svd
>>> U, s, V = svd(Xc, full_matrices=False)
由于语料库不是非常小,设置参数 full_matrices=False 以节省计算时间是很有用的。我们假设有两个主题,因此我们可以提取我们的子矩阵:
import numpy as np
>>> rank = 2
>>> Uk = U[:, 0:rank]
>>> sk = np.diag(s)[0:rank, 0:rank]
>>> Vk = V[0:rank, :]
如果我们想要分析每个主题的前 10 个单词,我们需要考虑以下几点:
因此,我们可以通过使用向量器提供的 get_feature_names() 方法对矩阵进行排序后,获得每个主题的最显著单词:
>>> Mtwks = np.argsort(Vk, axis=1)[::-1]
>>> for t in range(rank):
>>> print('\nTopic ' + str(t))
>>> for i in range(10):
>>> print(vectorizer.get_feature_names()[Mtwks[t, i]])
Topic 0
said
mr
city
hawksley
president
year
time
council
election
federal
Topic 1
plainfield
wasn
copy
released
absence
africa
clash
exacerbated
facing
difficulties
在这种情况下,我们只考虑矩阵 Vk 中的非负值;然而,由于主题是单词的混合,负成分也应该被考虑。在这种情况下,我们需要对 Vk 的绝对值进行排序:
>>> Mtwks = np.argsort(np.abs(Vk), axis=1)[::-1]
如果我们想要分析一个文档在这个子空间中的表示,我们必须使用:
例如,让我们考虑我们语料库的第一个文档:
>>> print(corpus[0])
The Fulton County Grand Jury said Friday an investigation of Atlanta's recent primary election produced `` no evidence '' that any irregularities took place .
>>> Mdtk = Uk.dot(sk)
>>> print('d0 = %.2f*t1 + %.2f*t2' % (Mdtk[0][0], Mdtk[0][1]))
d0 = 0.15*t1 + -0.12*t2
由于我们正在处理一个二维空间,绘制每个文档对应的所有点是有趣的:
在前面的图中,我们可以看到许多文档是相关的,有一个小的异常值组。这可能是由于我们选择两个主题的限制性。如果我们使用布朗语料库的两个类别(news 和 fiction)重复相同的实验,我们会观察到不同的行为:
sentences = brown.sents(categories=['news', 'fiction'])
corpus = []
for s in sentences:
corpus.append(' '.join(s))
我不再重复剩余的计算,因为它们是相似的。(唯一的区别是,我们的语料库现在相当大,这导致计算时间更长。因此,我们将讨论一个替代方案,它要快得多。)绘制文档对应的点,我们现在得到:
现在更容易区分两组,它们几乎是正交的(这意味着许多文档只属于一个类别)。我建议使用不同的语料库和秩重复这个实验。不幸的是,不可能绘制超过三个维度,但总是可以使用仅数值计算来检查子空间是否正确描述了潜在的语义。
如预期的那样,当发生矩阵很大时,标准的 SciPy SVD 实现可能会非常慢;然而,scikit-learn 提供了一个截断 SVD 实现,TruncatedSVD,它仅与子空间一起工作。结果是速度更快(它还可以直接管理稀疏矩阵)。让我们使用这个类重复之前的实验(使用完整的语料库):
from sklearn.decomposition import TruncatedSVD
>>> tsvd = TruncatedSVD(n_components=rank)
>>> Xt = tsvd.fit_transform(Xc)
通过n_components参数,可以设置所需的秩,丢弃矩阵的其余部分。在拟合模型后,我们可以直接将文档-主题矩阵*M[dtk]作为fit_transform()方法的输出获得,而主题-单词矩阵M[twk]*可以通过实例变量components_访问:
>>> Mtws = np.argsort(tsvd.components_, axis=1)[::-1]
>>> for t in range(rank):
>>> print('\nTopic ' + str(t))
>>> for i in range(10):
>>> print(vectorizer.get_feature_names()[Mwts[t, i]])
Topic 0
said
rector
hans
aloud
liston
nonsense
leave
whiskey
chicken
fat
Topic 1
bong
varnessa
schoolboy
kaboom
keeeerist
aggravated
jealous
hides
mayonnaise
fowl
读者可以验证这个过程可以有多快;因此,我建议只有在需要访问完整矩阵时才使用标准的 SVD 实现。不幸的是,正如文档中所述,这种方法对算法和随机状态非常敏感。它还受到称为符号不确定性的现象的影响,这意味着如果使用不同的随机种子,所有组件的符号都可能改变。我建议你声明:
import numpy as np
np.random.seed(1234)
在每个文件的开始处使用固定的种子(即使是 Jupyter 笔记本)以确保可以重复计算并始终获得相同的结果。
此外,我建议使用非负矩阵分解重复这个实验,如第三章所述,特征选择与特征工程。
概率潜在语义分析
之前的模型是基于确定性方法,但也可以在由文档和单词确定的范围内定义一个概率模型。在这种情况下,我们不对 Apriori 概率做出任何假设(这将在下一个方法中完成),我们将确定最大化我们模型对数似然参数的参数。特别是,考虑以下图中所示的板符号(如果你想了解更多关于这种技术的信息,请阅读en.wikipedia.org/wiki/Plate_notation):
我们假设我们有一个包含 m 个文档的语料库,并且每个文档由 n 个单词组成(这两个元素都是观察到的,因此用灰色圆圈表示);然而,我们还假设存在一组有限的 k 个共同潜在因子(主题),它们将文档与一组单词联系起来(由于它们没有被观察到,圆圈是白色的)。正如已经写过的,我们无法直接观察到它们,但我们允许假设它们的存在。
找到具有特定单词的文档的联合概率是:
因此,在引入潜在因子后,找到特定文档中单词的条件概率可以写成:
初始联合概率 P(d, w) 也可以用潜在因子表示:
这包括先验概率 P(t)。由于我们不想处理它,因此使用表达式 P(w|d) 更为可取。为了确定两个条件概率分布,一个常见的方法是 期望最大化(EM)策略。完整的描述可以在 Hofmann T.的《基于概率潜在语义分析的无监督学习》,机器学习 42,177-196,2001,Kluwer 学术出版社中找到。*在此上下文中,我们只展示最终结果,而不提供任何证明。
对数似然可以写成:
变成:
M[dw] 是一个出现矩阵(通常通过计数向量器获得),而 Mdw 是单词 w 在文档 d 中的频率。为了简化,我们将通过排除第一个项(它不依赖于 t[k])来近似它:
此外,引入条件概率 P(t|d,w) 也是有用的,它是给定文档和单词的主题概率。EM 算法在后验概率 P(t|d,w) 下最大化期望的完整对数似然:
算法的 E 阶段可以表示为:
必须扩展到所有主题、单词和文档,并且必须按主题求和进行归一化,以确保始终具有一致的概率。
M 阶段分为两个计算:
在这个情况下,计算必须扩展到所有主题、单词和文档。但在第一种情况下,我们是按文档求和并按单词和文档进行归一化,而在第二种情况下,我们是按单词求和并按文档长度进行归一化。
算法必须迭代,直到对数似然停止增加其幅度。不幸的是,scikit-learn 没有提供 PLSA 实现(也许是因为下一个策略 LDA 被认为更强大和高效),因此我们需要从头编写一些代码。让我们首先定义布朗语料库的一个小子集,从editorial类别中取 10 个句子,从fiction类别中取 10 个:
>>> sentences_1 = brown.sents(categories=['editorial'])[0:10]
>>> sentences_2 = brown.sents(categories=['fiction'])[0:10]
>>> corpus = []
>>> for s in sentences_1 + sentences_2:
>>> corpus.append(' '.join(s))
现在我们可以使用CountVectorizer类进行向量化:
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
>>> cv = CountVectorizer(strip_accents='unicode', stop_words='english')
>>> Xc = np.array(cv.fit_transform(corpus).todense())
在这一点上,我们可以定义排名(为了简单起见,我们选择 2),两个稍后将要使用的常数,以及用于存储概率 P(t|d),P(w|t) 和 P(t|d,w) 的矩阵:
>>> rank = 2
>>> alpha_1 = 1000.0
>>> alpha_2 = 10.0
>>> Ptd = np.random.uniform(0.0, 1.0, size=(len(corpus), rank))
>>> Pwt = np.random.uniform(0.0, 1.0, size=(rank, len(cv.vocabulary_)))
>>> Ptdw = np.zeros(shape=(len(cv.vocabulary_), len(corpus), rank))
>>> for d in range(len(corpus)):
>>> nf = np.sum(Ptd[d, :])
>>> for t in range(rank):
>>> Ptd[d, t] /= nf
>>> for t in range(rank):
>>> nf = np.sum(Pwt[t, :])
>>> for w in range(len(cv.vocabulary_)):
>>> Pwt[t, w] /= nf
两个矩阵 P(t|d),P(w|t) 必须归一化,以便与算法保持一致;另一个初始化为零。现在我们可以定义对数似然函数:
>>> def log_likelihood():
>>> value = 0.0
>>>
>>> for d in range(len(corpus)):
>>> for w in range(len(cv.vocabulary_)):
>>> real_topic_value = 0.0
>>>
>>> for t in range(rank):
>>> real_topic_value += Ptd[d, t] * Pwt[t, w]
>>>
>>> if real_topic_value > 0.0:
>>> value += Xc[d, w] * np.log(real_topic_value)
>>>
>>> return value
最后,期望最大化函数:
>>> def expectation():
>>> global Ptd, Pwt, Ptdw
>>>
>>> for d in range(len(corpus)):
>>> for w in range(len(cv.vocabulary_)):
>>> nf = 0.0
>>>
>>> for t in range(rank):
>>> Ptdw[w, d, t] = Ptd[d, t] * Pwt[t, w]
>>> nf += Ptdw[w, d, t]
>>>
>>> Ptdw[w, d, :] = (Ptdw[w, d, :] / nf) if nf != 0.0 else 0.0
在前面的函数中,当归一化因子为 0 时,每个主题的概率 P(t|w, d) 被设置为 0.0:
>>> def maximization():
>>> global Ptd, Pwt, Ptdw
>>>
>>> for t in range(rank):
>>> nf = 0.0
>>>
>>> for d in range(len(corpus)):
>>> ps = 0.0
>>>
>>> for w in range(len(cv.vocabulary_)):
>>> ps += Xc[d, w] * Ptdw[w, d, t]
>>>
>>> Pwt[t, w] = ps
>>> nf += Pwt[t, w]
>>>
>>> Pwt[:, w] /= nf if nf != 0.0 else alpha_1
>>>
>>> for d in range(len(corpus)):
>>> for t in range(rank):
>>> ps = 0.0
>>> nf = 0.0
>>>
>>> for w in range(len(cv.vocabulary_)):
>>> ps += Xc[d, w] * Ptdw[w, d, t]
>>> nf += Xc[d, w]
>>>
>>> Ptd[d, t] = ps / (nf if nf != 0.0 else alpha_2)
当归一化因子变为 0 时,常数 alpha_1 和 alpha_2 被使用。在这种情况下,分配一个小的概率值可能是有用的;因此,我们为这些常数除以分子。我建议尝试不同的值,以便调整算法以适应不同的任务。
在这一点上,我们可以尝试我们的算法,限制迭代次数:
>>> print('Initial Log-Likelihood: %f' % log_likelihood())
>>> for i in range(50):
>>> expectation()
>>> maximization()
>>> print('Step %d - Log-Likelihood: %f' % (i, log_likelihood()))
Initial Log-Likelihood: -1242.878549
Step 0 - Log-Likelihood: -1240.160748
Step 1 - Log-Likelihood: -1237.584194
Step 2 - Log-Likelihood: -1236.009227
Step 3 - Log-Likelihood: -1234.993974
Step 4 - Log-Likelihood: -1234.318545
Step 5 - Log-Likelihood: -1233.864516
Step 6 - Log-Likelihood: -1233.559474
Step 7 - Log-Likelihood: -1233.355097
Step 8 - Log-Likelihood: -1233.218306
Step 9 - Log-Likelihood: -1233.126583
Step 10 - Log-Likelihood: -1233.064804
Step 11 - Log-Likelihood: -1233.022915
Step 12 - Log-Likelihood: -1232.994274
Step 13 - Log-Likelihood: -1232.974501
Step 14 - Log-Likelihood: -1232.960704
Step 15 - Log-Likelihood: -1232.950965
...
在第 30 步之后,可以验证收敛性。在这个时候,我们可以检查每个主题按主题权重降序排列的 P(w|t) 条件分布的前五项单词:
>>> Pwts = np.argsort(Pwt, axis=1)[::-1]
>>> for t in range(rank):
>>> print('\nTopic ' + str(t))
>>> for i in range(5):
>>> print(cv.get_feature_names()[Pwts[t, i]])
Topic 0
years
questions
south
reform
social
Topic 1
convened
maintenance
penal
year
legislators
潜在狄利克雷分配
在先前的方法中,我们没有对主题先验分布做出任何假设,这可能导致限制,因为算法没有受到任何现实世界直觉的驱动。相反,LDA 基于这样的观点:一个主题由一组重要的单词组成,通常一个文档不会涵盖许多主题。因此,主要假设是先验主题分布是对称的狄利克雷分布。概率密度函数定义为:
如果浓度参数 alpha 小于 1.0,分布将是稀疏的,正如所期望的那样。这允许我们模拟主题-文档和主题-单词分布,这些分布将始终集中在少数几个值上。这样我们就可以避免以下情况:
-
分配给文档的主题混合可能会变得平坦(许多主题具有相似权重)
-
考虑到单词集合,一个主题的结构可能会变得类似于背景(实际上,只有有限数量的单词必须是重要的;否则语义边界会变得模糊)。
使用板状符号,我们可以表示文档、主题和单词之间的关系,如下面的图所示:
在前面的图中,alpha 是主题-文档分布的 Dirichlet 参数,而 gamma 在主题-单词分布中具有相同的作用。Theta 相反,是特定文档的主题分布,而 beta 是特定单词的主题分布。
如果我们有一个包含 m 个文档和 n 个单词(每个文档有 n[i] 个单词)的语料库,并且我们假设有 k 个不同的主题,生成算法可以用以下步骤描述:
- 对于每个文档,从主题-文档分布中抽取一个样本(一个主题混合):
- 对于每个主题,从主题-单词分布中抽取一个样本:
必须估计两个参数。在此阶段,考虑到发生矩阵 M[dw] 和表示 m-th 文档中 n-th 单词所分配的主题的符号 z[mn],我们可以遍历文档(索引 d)和单词(索引 w):
- 根据文档 d 和单词 w 选择一个主题:
- 根据以下标准选择一个单词:
在这两种情况下,一个分类分布是一个单次多项式分布。参数估计的完整描述相当复杂,超出了本书的范围;然而,主要问题是找到潜在变量的分布:
读者可以在 Blei D.,Ng A.,Jordan M.,潜在狄利克雷分配,《机器学习研究杂志》,3,(2003) 993-1022 中找到更多信息。然而,LDA 和 PLSA 之间一个非常重要的区别是 LDA 的生成能力,它允许处理未见过的文档。实际上,PLSA 训练过程只为语料库找到最优参数 p(t|d),而 LDA 采用随机变量。可以通过定义 theta(一个主题混合)的概率为与一组主题和一组单词的联合,并给定模型参数来理解这个概念:
如前所述的论文所示,给定模型参数的文档(一组单词)的条件概率可以通过积分获得:
这个表达式显示了 PLSA 和 LDA 之间的区别。一旦学习到 p(t|d),PLSA 就无法泛化,而 LDA 通过从随机变量中采样,总能找到一个适合未见文档的合适主题混合。
scikit-learn 通过 LatentDirichletAllocation 类提供了一个完整的 LDA 实现。我们将使用从布朗语料库的子集构建的更大的数据集(4,000 个文档)来使用它:
>>> sentences_1 = brown.sents(categories=['reviews'])[0:1000]
>>> sentences_2 = brown.sents(categories=['government'])[0:1000]
>>> sentences_3 = brown.sents(categories=['fiction'])[0:1000]
>>> sentences_4 = brown.sents(categories=['news'])[0:1000]
>>> corpus = []
>>> for s in sentences_1 + sentences_2 + sentences_3 + sentences_4:
>>> corpus.append(' '.join(s))
现在,我们可以通过假设我们有八个主要主题来向量化、定义和训练我们的 LDA 模型:
from sklearn.decomposition import LatentDirichletAllocation
>>> cv = CountVectorizer(strip_accents='unicode', stop_words='english', analyzer='word', token_pattern='[a-z]+')
>>> Xc = cv.fit_transform(corpus)
>>> lda = LatentDirichletAllocation(n_topics=8, learning_method='online', max_iter=25)
>>> Xl = lda.fit_transform(Xc)
在CountVectorizer中,我们通过参数token_pattern添加了一个正则表达式来过滤标记。这很有用,因为我们没有使用完整的分词器,在语料库中也有许多我们想要过滤掉的数字。LatentDirichletAllocation类允许我们指定学习方法(通过learning_method),可以是批处理或在线。我们选择了在线,因为它更快;然而,两种方法都采用变分贝叶斯来学习参数。前者采用整个数据集,而后者使用小批量。在线选项将在 0.20 版本中删除;因此,现在使用它时,您可能会看到弃用警告。theta 和 beta Dirichlet 参数可以通过doc_topic_prior(theta)和topic_word_prior(beta)来指定。默认值(我们也是如此)是1.0 / n_topics。保持这两个值都很小,特别是小于 1.0,以鼓励稀疏性。最大迭代次数(max_iter)和其他学习相关参数可以通过阅读内置文档或访问scikit-learn.org/stable/modules/generated/sklearn.decomposition.LatentDirichletAllocation.html来应用。
现在,我们可以通过提取每个主题的前五个关键词来测试我们的模型。就像TruncatedSVD一样,主题-词分布结果存储在实例变量components_中:
>>> Mwts_lda = np.argsort(lda.components_, axis=1)[::-1]
>>> for t in range(8):
>>> print('\nTopic ' + str(t))
>>> for i in range(5):
>>> print(cv.get_feature_names()[Mwts_lda[t, i]])
Topic 0
code
cadenza
unlocks
ophthalmic
quo
Topic 1
countless
harnick
leni
addle
chivalry
Topic 2
evasive
errant
tum
rum
orations
Topic 3
grigory
tum
absurdity
tarantara
suitably
Topic 4
seventeenth
conant
chivalrous
janitsch
knight
Topic 5
hypocrites
errantry
adventures
knight
errant
Topic 6
counter
rogues
tum
lassus
wars
Topic 7
pitch
cards
cynicism
silences
shrewd
存在一些重复,这可能是由于某些主题的组成,读者可以尝试不同的先验参数来观察变化。可以进行实验来检查模型是否工作正确。
让我们考虑两份文档:
>>> print(corpus[0])
It is not news that Nathan Milstein is a wizard of the violin .
>>> print(corpus[2500])
The children had nowhere to go and no place to play , not even sidewalks .
它们相当不同,它们的主题分布也是如此:
>>> print(Xl[0])
[ 0.85412134 0.02083335 0.02083335 0.02083335 0.02083335 0.02083677
0.02087515 0.02083335]
>>> print(Xl[2500])
[ 0.22499749 0.02500001 0.22500135 0.02500221 0.025 0.02500219
0.02500001 0.42499674]
对于第一份文档,我们有一个主导主题(0.85t[0]),而对于第二份文档,有一个混合(0.22t[0] + 0.22t[2 ]+ 0.42t[7])。现在让我们考虑这两份文档的拼接:
>>> test_doc = corpus[0] + ' ' + corpus[2500]
>>> y_test = lda.transform(cv.transform([test_doc]))
>>> print(y_test)
[[ 0.61242771 0.01250001 0.11251451 0.0125011 0.01250001 0.01250278
0.01251778 0.21253611]]
在生成的文档中,正如预期的那样,混合比例已经发生了变化:0.61t[0] + 0.11t[2] + 0.21t[7]。换句话说,算法通过削弱主题 2 和主题 7,引入了之前占主导地位的主题 5(现在变得更加强大)。这是合理的,因为第一份文档的长度小于第二份,因此主题 5 不能完全抵消其他主题。
情感分析
自然语言处理(NLP)最广泛的应用之一是对短文本(推文、帖子、评论、评论等)的情感分析。从市场营销的角度来看,理解这些信息片段所表达的情感语义非常重要。正如你可以理解的,当评论精确且只包含一组正面/负面词汇时,这项任务可以非常简单,但当同一句子中有可能相互冲突的不同命题时,它就变得更加复杂。例如,我喜欢那家酒店。那是一次美妙的经历显然是一个积极的评论,而这家酒店不错,然而,餐厅很糟糕,即使服务员很友好,我不得不与前台服务员争论再要一个枕头。在这种情况下,情况更加难以管理,因为既有积极的也有消极的元素,导致一个中性的评论。因此,许多应用不是基于二元决策,而是承认中间级别(至少一个来表达中立)。
这种类型的问题通常是监督性的(正如我们即将要做的那样),但也有更便宜且更复杂的解决方案。评估情感的最简单方法就是寻找特定的关键词。这种基于字典的方法速度快,并且与一个好的词干提取器结合使用,可以立即标记出正面和负面的文档。然而,它不考虑术语之间的关系,也不能学习如何权衡不同的组成部分。例如,美好的一天,糟糕的心情将导致中性(+1,-1),而使用监督方法,模型可以学习到mood非常重要,并且糟糕的心情通常会导致负面情感。其他方法(更为复杂)基于主题建模(你现在可以理解如何应用 LSA 或 LDA 来确定基于积极或消极的潜在主题);然而,它们需要进一步步骤来使用主题-词和主题-文档分布。在现实语义中,这可能很有帮助,例如,一个积极的形容词通常与其他类似成分(如动词)一起使用。比如说,这家酒店很棒,我肯定会再回来。在这种情况下(如果样本数量足够大),一个主题可以从诸如lovely或amazing等词语的组合中产生,以及(积极的)动词,如returning或coming back。
另一种方法是考虑正负文档的主题分布,并在主题子空间中使用监督方法。其他方法包括深度学习技术(如 Word2Vec 或 Doc2Vec),其基于生成一个向量空间,其中相似的词彼此靠近,以便容易管理同义词。例如,如果训练集包含句子Lovely hotel,但它不包含Wonderful hotel,Word2Vec 模型可以从其他示例中学习到lovely和wonderful非常接近;因此,新的文档Wonderful hotel可以立即使用第一评论提供的信息进行分类。关于这项技术的介绍和一些技术论文可以在code.google.com/archive/p/word2vec/找到。
现在让我们考虑我们的例子,它基于Twitter Sentiment Analysis Training Corpus数据集的一个子集。为了加快过程,我们将实验限制在 100,000 条推文。下载文件后(见本段末尾的框),需要解析它(使用 UTF-8 编码):
>>> dataset = 'dataset.csv'
>>> corpus = []
>>> labels = []
>>> with open(dataset, 'r', encoding='utf-8') as df:
>>> for i, line in enumerate(df):
>>> if i == 0:
>>> continue
>>>
>>> parts = line.strip().split(',')
>>> labels.append(float(parts[1].strip()))
>>> corpus.append(parts[3].strip())
dataset变量必须包含 CSV 文件的完整路径。此过程读取所有行,跳过第一行(它是标题),并将每条推文作为新的列表条目存储在corpus变量中,相应的情感(二进制,0 或 1)存储在labels变量中。在这个阶段,我们像往常一样进行,标记化、向量化,并准备训练集和测试集:
from nltk.tokenize import RegexpTokenizer
from nltk.corpus import stopwords
from nltk.stem.lancaster import LancasterStemmer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.model_selection import train_test_split
>>> rt = RegexpTokenizer('[a-zA-Z0-9\.]+')
>>> ls = LancasterStemmer()
>>> sw = set(stopwords.words('english'))
>>> def tokenizer(sentence):
>>> tokens = rt.tokenize(sentence)
>>> return [ls.stem(t.lower()) for t in tokens if t not in sw]
>>> tfv = TfidfVectorizer(tokenizer=tokenizer, sublinear_tf=True, ngram_range=(1, 2), norm='l2')
>>> X = tfv.fit_transform(corpus[0:100000])
>>> Y = np.array(labels[0:100000])
>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.1)
我们选择在RegexpTokenizer实例中包括点和字母数字,因为它们对于表达特定情绪很有用。此外,n-gram 的范围已被设置为(1,2),因此我们包括了二元组(读者也可以尝试三元组)。在这个阶段,我们可以训练一个随机森林:
from sklearn.ensemble import RandomForestClassifier
import multiprocessing
>>> rf = RandomForestClassifier(n_estimators=20, n_jobs=multiprocessing.cpu_count())
>>> rf.fit(X_train, Y_train)
现在我们可以生成一些指标来评估模型:
from sklearn.metrics import precision_score, recall_score
>>> print('Precision: %.3f' % precision_score(Y_test, rf.predict(X_test)))
Precision: 0.720
>>> print('Recall: %.3f' % recall_score(Y_test, rf.predict(X_test)))
Recall: 0.784
性能并不出色(使用 Word2Vec 可以实现更好的准确率);然而,对于许多任务来说是可以接受的。特别是,78%的召回率意味着错误负例的数量大约是 20%,当使用情感分析进行自动处理任务时可能很有用(在许多情况下,自动发布负面评论的风险阈值相当低,因此必须采用更好的解决方案)。性能也可以通过相应的 ROC 曲线来确认:
示例中使用的Twitter Sentiment Analysis Training Corpus数据集(CSV 文件格式)可以从thinknook.com/wp-content/uploads/2012/09/Sentiment-Analysis-Dataset.zip下载。考虑到数据量,训练过程可能非常长(甚至在较慢的机器上可能需要数小时)。
VADER 情感分析与 NLTK
对于英语语言,NLTK 提供了一个已经训练好的模型,称为 VADER(Valence Aware Dictionary and sEntiment Reasoner),它以略不同的方式工作,并采用规则引擎与词典一起推断文本的情感强度。更多信息和细节可以在 Hutto C.J.,Gilbert E.,VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text,AAAI,2014* 中找到。
NLTK 版本使用 SentimentIntensityAnalyzer 类,可以直接使用,以获得由四个组成部分组成的极性情感度量:
-
正面因素
-
负面因素
-
中性因素
-
复合因素
前三项无需解释,而最后一个是特定度量(一个归一化的总体得分),其计算方式如下:
在这里,Sentiment(w[i]) 是单词 w[i] 的情感得分,alpha 是一个归一化系数,它应该近似最大预期值(NLTK 中默认设置为 15)。这个类的使用非常直接,以下代码片段可以证实:
from nltk.sentiment.vader import SentimentIntensityAnalyzer
>>> text = 'This is a very interesting and quite powerful sentiment analyzer'
>>> vader = SentimentIntensityAnalyzer()
>>> print(vader.polarity_scores(text))
{'neg': 0.0, 'neu': 0.535, 'pos': 0.465, 'compound': 0.7258}
NLTK Vader 实现使用 Twython 库的一些功能。尽管这不是必需的,但为了避免警告,可以使用 pip 安装它(pip install twython)。
参考文献
-
Hofmann T.,Unsupervised Learning by Probabilistic Latent Semantic Analysis,Machine Learning 42,177-196,2001,Kluwer Academic Publishers。
-
Blei D.,Ng A.,Jordan M.,Latent Dirichlet Allocation, Journal of Machine Learning Research,3,(2003) 993-1022。
-
Hutto C.J.,Gilbert E.,VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text,AAAI,2014。
摘要
在本章中,我们介绍了主题建模。我们讨论了基于截断 SVD 的潜在语义分析、概率潜在语义分析(旨在构建一个不假设潜在因素先验概率的模型)以及潜在狄利克雷分配,后者优于前一种方法,并基于潜在因素具有稀疏先验狄利克雷分布的假设。这意味着一个文档通常只覆盖有限数量的主题,而一个主题只由少数几个重要单词来表征。
在最后一节中,我们讨论了文档的情感分析,其目的是确定一段文本是否表达了一种积极的或消极的感觉。为了展示一个可行的解决方案,我们基于一个 NLP 管道和一个随机森林构建了一个分类器,其平均性能可用于许多现实生活中的情况。
在下一章中,我们将简要介绍深度学习以及 TensorFlow 框架。由于这个主题本身就需要一本专门的书籍,我们的目标是定义一些主要概念,并通过一些实际例子进行说明。如果读者想要了解更多信息,本章末尾将提供一个完整的参考文献列表。