构建 Python 机器学习系统(二)
原文:Building Machine Learning Systems with Python
协议:CC BY-NC-SA 4.0
五、降维
垃圾输入,垃圾输出——在本书中,我们将在将机器学习方法应用于数据时看到这种模式。回顾过去,我们可以看到,最有趣的机器学习挑战总是涉及某种特征工程,我们试图利用对问题的洞察来精心设计模型有望获得的附加特征。
在这一章中,我们将朝着与降维相反的方向前进,去掉不相关或冗余的特征。删除特征起初可能看起来是反直觉的,因为更多的信息似乎总是比更少的信息更好。同样,即使我们的数据集中有冗余特征,学习算法难道不能快速计算出来并将其权重设置为0
?事实上,有充分的理由尽可能缩小尺寸:
- 多余的特征会激怒或误导学习者。并非所有机器学习方法都是如此(例如,支持向量机喜欢高维空间)。然而,大多数模型感觉更安全,尺寸更小
- 反对高维特征空间的另一个观点是,更多的特征意味着更多的参数需要调整,并且过拟合的风险更高
- 我们为解决任务而检索的数据可能只是人为的高维,而真实的维度可能很小
- 更少的维度等于更快的训练等于更多的参数变化,在相同的时间范围内尝试等于更好的最终结果
- 缩小尺寸更有利于可视化。如果我们想要可视化数据,我们将被限制在二维或三维
在这一章中,我们将向您展示如何清除数据中的垃圾,同时保留其中真正有价值的部分。
绘制我们的路线图
降维可以大致分为特征选择和特征投影方法。到目前为止,当我们发明、分析、然后可能丢弃一些特征时,我们已经在几乎每一章中采用了某种特征选择。在本章中,我们将介绍一些使用统计方法的方法,即相关性和互信息,以便能够在广阔的特征空间中做到这一点。特征投影试图将原始特征空间转换为低维特征空间。当我们无法使用选择方法去除特征,但是我们仍然有太多的特征需要学习时,这尤其有用。我们将使用主成分分析 ( 主成分分析)、线性判别分析 ( LDA )和多维标度 ( MDS )来演示这一点。
选择功能
如果我们想对我们的机器学习算法好一点,我们会为它提供彼此不依赖的特性,但这些特性高度依赖于要预测的值。这意味着每个特征都添加了显著的信息。删除任何功能都会导致性能下降。
如果我们只有少数特征,我们可以绘制一个散点图矩阵(每个特征对组合一个散点图)。然后就可以很容易地发现特征之间的关系。对于每一个表现出明显依赖性的特征对,我们会考虑是否应该删除其中一个,或者更好地从两个特征中设计一个更新、更干净的特征。
然而,大多数时候,我们有很多功能可以选择。试想一下分类任务,我们有一袋单词来对答案的质量进行分类,这将需要 1,000 x 1,000 的散点图(使用 1000 个单词的词汇)。在这种情况下,我们需要一种更自动化的方法来检测重叠的特征并解决它们。我们将在下面的小节中介绍两种通用的方法。
使用过滤器检测冗余特征
过滤器试图独立于任何后来使用的机器学习方法来清理特征空间。他们依靠统计方法来找出哪些特征是多余的或不相关的。在冗余特征的情况下,过滤器只为每个冗余特征组保留一个。不相关的功能将被简单地删除。一般来说,过滤器的工作方式如下图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/bcdde029-394c-44ff-b9fa-29bca7bd6a44.png
首先,我们使用仅考虑训练数据的统计信息过滤掉冗余的特征。然后,我们检查剩余的特征在分类标签时是否有用。
相互关系
利用相关性,我们可以很容易地看到特征对之间的线性关系。在下图中,我们可以看到不同程度的相关性,以及绘制为虚线的潜在线性相关性(拟合的一维多项式)。借助于scipy.stat
的pearsonr()
函数,使用共同的皮尔逊相关系数(皮尔逊https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/f1b306e5-6641-4fa5-b3fe-5142c380aef0.png算各个图顶部的相关系数 Cor (X 1 ,X 2 ) 。
给定两个大小相等的数据序列,它返回相关系数值和 p 值的元组。 p 值描述了数据序列由不相关系统生成的可能性。换句话说, p 值越高,我们越不应该相信相关系数:
>>> from scipy.stats import pearsonr>>> pearsonr([1,2,3], [1,2,3.1])(0.99962228516121843, 0.017498096813278487)>>> pearsonr([1,2,3], [1,20,6])(0.25383654128340477, 0.83661493668227427)
在第一种情况下,我们有一个明确的迹象表明,这两个系列是相关的。在第二种情况下,我们仍然有一个明显非零的https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/f1b306e5-6641-4fa5-b3fe-5142c380aef0.png
但是p-值0.84
告诉我们相关系数不显著,不要太在意。在下图中具有高相关系数的前三种情况下,我们可能会想抛出XT5】1 或 X 2 ,因为它们似乎传达了相似的信息,如果不是相同的话:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/7dcab7b1-8535-4c1e-b6f3-cfbf84d20ed5.png
然而,在最后一种情况下,我们应该保留这两个特性。在我们的应用中,这个决定当然是由这个 p 值驱动的。
虽然它在前面的例子中表现得很好,但现实很少是好的。基于相关性的特征选择的一个很大的缺点是它只检测线性关系(可以用直线建模的关系)。如果我们在非线性数据上使用相关性,我们可以看到这个问题。在下面的例子中,我们有一个二次关系:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/641896fc-ea8a-4060-ac11-6ec2940e997f.png
虽然人眼立即看到除了右下方的图之外的所有图中 X 1 和 X 2 之间的关系,但是相关系数没有。很明显,相关性对于检测线性关系是有用的,但是对于其他任何事情都是失败的。有时,应用简单的变换来获得线性关系确实很有帮助。例如,在前面的图中,如果我们画出 X 2 而不是 X 1 的平方,我们就会得到一个很高的相关系数。然而,正常数据很少提供这种机会。
幸运的是,对于非线性关系,相互信息有所帮助。
交互信息
在考虑特征选择时,我们不应该像上一节(线性关系)那样关注关系的类型。相反,考虑到
我们已经有了另一个功能,我们应该从一个功能提供多少信息来考虑。
为了理解这一点,让我们假设我们想要使用来自house_size
、number_of_levels
和avg_rent_price
特征集的特征来训练输出房屋是否有电梯的分类器。在这个例子中,我们可以直观地看到,知道了house_size
,我们就不需要再知道number_of_levels
了,因为它不知何故包含了多余的信息。有了avg_rent_price
,就不一样了,因为我们不能简单地从房子的大小或层数来推断出租空间的价值。因此,明智的做法是,除了租赁空间的平均价格之外,只保留其中一个。
互信息通过计算两个特征共有多少信息来形式化上述推理。然而,与相关性不同,它不依赖于数据的顺序,而是依赖于分布。为了理解它是如何工作的,我们必须深入研究信息熵。
让我们假设我们有一个公平的硬币。在我们翻转它之前,我们将对它是正面还是反面有最大的不确定性,因为每个结果都有 50%的相同概率。这种不确定性可以通过克劳德·香农的信息熵来衡量:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/d6910354-67de-4412-96b7-44f07ee32fd5.png
在我们的公平硬币案例中,我们有两种情况:让 X 0 为正面的情况,让 X 1 为带有https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/33860cce-62da-4b7b-9e53-fcc57393225e.png的情况。
因此,它的结论如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/389ddd49-f65e-4ab5-ac44-d8c5cb8d79ec.png
For convenience, we can also use scipy.stats.entropy([0.5, 0.5], base=2)
. We set the base parameter to 2
to get the same result as earlier. Otherwise, the function will use the natural logarithm via np.log()
. In general, the base does not matter (as long as you use it consistently).
现在,想象一下,我们事先知道硬币实际上并不公平,在翻转后,头部有 60%的机会出现:
H(X) = -0.6 。日志2T4【0.6】-0.4。log 2 (0.4)=0.97
我们可以看到这种情况不太不确定。不确定性会随着我们远离*0.5*
而降低,达到 0 的极值,无论是 0 百分比还是 100 百分比的头部出现概率,如下图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/93091112-2e11-4331-9906-16b5a127a936.png
我们现在将修改熵 H(X) ,将它应用于两个特征,而不是一个特征,这样当我们了解 Y 时,它可以测量从 X 中消除了多少不确定性。然后,我们可以了解一个特性如何减少另一个特性的不确定性。
例如,没有任何关于天气的进一步信息,我们完全不确定外面是否在下雨。如果我们现在知道外面的草是湿的,那么不确定性就降低了(我们仍然需要检查洒水器是否已经打开)。
更正式地说,相互信息的定义如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/3c80703b-da55-4744-98d9-03c19f1d7ced.png
这看起来有点吓人,但实际上只不过是总和和乘积。例如,p()
的计算可以通过对特征值进行宁滨运算,然后计算每个箱中值的分数来完成。在下面的图中,我们将箱的数量设置为十。
为了将互信息限制在[0,1]
的区间内,我们必须将其除以它们相加的个体熵,这给了我们以下归一化互信息:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/219f9497-f058-4f30-a8b7-daf44ea0a459.png
代码如下:
def normalized_mutual_info(x, y, bins=10): counts_xy, bins_x, bins_y = np.histogram2d(x, y, bins=(bins, bins)) counts_x, bins = np.histogram(x, bins=bins) counts_y, bins = np.histogram(y, bins=bins) counts_xy += 1 # add-one smoothing as we have counts_x += 1 # seen in the previous chapters counts_y += 1 P_xy = counts_xy / np.sum(counts_xy) P_x = counts_x / np.sum(counts_x) P_y = counts_y / np.sum(counts_y) I_xy = np.sum(P_xy * np.log2(P_xy / (P_x.reshape(-1, 1) * P_y))) return I_xy / (entropy(counts_x) + entropy(counts_y))
相互信息的好处是,与相关性不同,它不仅仅关注线性关系,正如我们在下图中看到的:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/1a844100-f4cb-4a89-b6a1-99a93e4fc6d2.png
正如我们所看到的,相互信息能够表明线性关系的强度。下图显示它也适用于平方关系:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/1b605a34-9e96-482b-a492-593708497121.png
因此,我们需要做的是计算所有特征对的归一化互信息。对于每一对过高的值(我们必须确定这意味着什么),我们将丢弃其中一个。在回归的情况下,我们可以删除与期望结果值相互信息太少的特征。
这可能适用于少量的功能。然而,在某些时候,这个过程可能非常昂贵,因为计算量随着特征数量的增加而呈二次增长。
过滤器的另一个巨大缺点是,它们会丢弃在孤立情况下似乎没有用的功能。更多的时候,有一些特征看起来完全独立于目标变量,但是当它们结合在一起时,就会摇摆不定。为了保存这些,我们需要包装纸。
向模型询问使用包装器的特性
虽然过滤器可以极大地帮助摆脱无用的功能,但它们只能到此为止。在所有的过滤之后,可能仍然有一些特征在它们之间是独立的,并且显示出与结果变量的某种程度的依赖,但是从模型的角度来看,这些特征是完全无用的。试想以下描述XOR
功能的数据。就个体而言,无论是 A 还是 B 都不会表现出任何依赖 Y 的迹象,而合在一起,它们显然会表现出:
| A | B | Y |
| 0
| 0
| 0
|
| 0
| 1
| 1
|
| 1
| 0
| 1
|
| 1
| 1
| 0
|
那么,为什么不要求模型本身对个体特征进行投票呢?这就是 scikit 包装器所做的,正如我们在下面的流程图中看到的:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/f2de0fe0-2854-454f-8e29-495ccd691240.png
在这里,我们将特征重要性的计算推到了模型训练过程中。不幸的是(但可以理解),特征重要性不是以二进制来确定的,而是以排名值来确定的,所以我们仍然需要指定在哪里进行切割,我们愿意接受特征的哪一部分,以及我们想要放弃哪一部分。
回到 scikit-learn,我们在sklearn.feature_selection
包中找到了各种优秀的包装类。这个领域真正的主力是RFE
,代表递归特征消除。它需要一个估计器和所需数量的特征作为参数,然后用各种特征集训练估计器,只要它找到了足够小的特征子集。RFE
实例本身假装是一个估计量,从而实际上包装了所提供的估计量。
在下面的例子中,我们将使用数据集方便的make_classification()
函数创建一个 100 个样本的人工分类问题。它让我们指定创建 10 个特征,其中只有三个是真正有价值的,以解决分类问题:
>>> from sklearn.feature_selection import RFE>>> from sklearn.linear_model import LogisticRegression>>> from sklearn.datasets import make_classification>>> X,y = make_classification(n_samples=100, n_features=10, n_informative=3, random_state=0)>>> clf = LogisticRegression()>>> selector = RFE(clf, n_features_to_select=3)>>> selector = selector.fit(X, y)>>> print(selector.support_)[False False True False False False True True False False]>>> print(selector.ranking_)[5 4 1 2 6 7 1 1 8 3]
当然,现实场景中的问题是,我们如何知道n_features_to_select
的正确值?事实是,我们不能。然而,大多数时候,我们可以使用数据样本,并使用不同的设置来玩它,以快速获得对正确棒球场的感觉。
好的一点是,我们不用那么精确地使用包装器。让我们尝试一下n_features_to_select
的不同值,看看support_
和ranking_
是如何变化的:
| **n_features_**``**to_select**
| **support_**
| **ranking_**
|
| one | [False False False False False False False True False False]
| [ 7 6 3 4 8 9 2 1 10 5]
|
| Two | [False False False False False False True True False False]
| [6 5 2 3 7 8 1 1 9 4]
|
| three | [False False True False False False True True False False]
| [5 4 1 2 6 7 1 1 8 3]
|
| four | [False False True True False False True True False False]
| [4 3 1 1 5 6 1 1 7 2]
|
| five | [False False True True False False True True False True]
| [3 2 1 1 4 5 1 1 6 1]
|
| six | [False True True True False False True True False True]
| [2 1 1 1 3 4 1 1 5 1]
|
| seven | [ True True True True False False True True False True]
| [1 1 1 1 2 3 1 1 4 1]
|
| eight | [ True True True True True False True True False True]
| [1 1 1 1 1 2 1 1 3 1]
|
| nine | [ True True True True True True True True False True]
| [1 1 1 1 1 1 1 1 2 1]
|
| Ten | [ True True True True True True True True True True]
| [1 1 1 1 1 1 1 1 1 1]
|
我们可以看到结果非常稳定。在请求较小特征集时使用的特征在允许更多特征进入时继续被选择。最后,当我们走错路时,我们依靠我们的火车/测试设备分裂来警告我们。
其他特征选择方法
阅读机器学习文献时,你会发现其他几种特征选择方法。有些甚至看起来不像是特征选择方法,因为它们嵌入在学习过程中(不要与前面提到的包装器混淆)。例如,决策树的核心深处有一个特征选择机制。其他学习方法采用某种惩罚模型复杂性的正则化,因此将学习过程推向仍然简单的良好表现的模型。他们通过将影响较小的特征的重要性降低到零,然后放弃它们(L1 正则化)来做到这一点。
通常,机器学习方法的力量在很大程度上要归功于它们植入的特征选择方法。
特征投影
在某个时候,在我们删除了多余的特征并丢弃了不相关的特征之后,我们仍然会经常发现我们有太多的特征。无论我们使用什么样的学习方法,它们都表现不佳,并且,考虑到巨大的特征空间,我们理解它们实际上不能做得更好。我们必须摆脱特征,即使常识告诉我们它们是有价值的。另一种需要降低特征维数的情况是,当我们想要可视化数据时,特征选择没有太大帮助。然后,我们需要在最后最多有三个维度来提供任何有意义的图形。
输入要素投影方法。他们重组特征空间,使其更容易被模型访问,或者简单地将维度减少到两到三个,这样我们就可以直观地显示依赖关系。
同样,我们可以区分特征投影方法是线性的还是非线性的。此外,正如之前在选择特征部分所看到的,我们将为每种类型提供一种方法(主成分分析作为多维标度的线性和非线性版本)。尽管它们广为人知并被广泛使用,但它们只是可用的更有趣和更强大的特征投影方法中的一部分。
主成分分析
主成分分析 ( PCA )如果想减少特征数量,又不知道用哪种特征投影方法,往往是首先要尝试的。主成分分析是有限的,因为它是一种线性方法,但很有可能它已经走得足够远,让你的模型学得足够好。再加上它提供的强大的数学属性,它找到变换后的特征空间的速度,以及它后来能够在原始特征和变换后的特征之间进行变换的速度,我们几乎可以保证它也将成为您经常使用的机器学习工具之一。
总而言之,给定原始特征空间,PCA 在具有以下属性的低维空间中找到其自身的线性投影:
- 保守方差被最大化
- 最终的重建误差(当试图从变换后的特征返回到原始特征时)被最小化
由于主成分分析只是对输入数据进行转换,因此它可以应用于分类和回归问题。在本节中,我们将使用分类任务来讨论该方法。
绘制主成分分析
主成分分析涉及很多线性代数,我们不想深入讨论。然而,基本算法的过程可以很容易地描述如下:
- 通过减去平均值使数据居中
- 计算协方差矩阵
- 计算协方差矩阵的特征向量
如果我们从 N 个特征开始,那么算法将返回一个具有 N 个维度的变换特征空间(到目前为止我们什么都没有得到)。然而,这个算法的好处是,特征值表明有多少方差是由相应的特征向量描述的。
让我们假设我们从 N = 1000 个特征开始,并且我们知道我们的模型在多于 20 个特征的情况下不能很好地工作。然后,我们简单地选择具有最高特征值的 20 特征向量。
应用主成分分析
让我们考虑下面的人工数据集,它在下面的左图中可视化:
>>> x1 = np.arange(0, 10, .2)>>> x2 = x1+np.random.normal(loc=0, scale=1, size=len(x1))>>> X = np.c_[(x1, x2)]>>> good = (x1>5) | (x2>5) # some arbitrary classes>>> bad = ~good
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/36f20fc1-8db9-4c99-a0b6-f41ca8006ef7.png
Scikit-learn 在其decomposition
包中提供了PCA
类。在这个例子中,我们可以清楚地看到,一个维度应该足以描述数据。我们可以使用n_components
参数来指定:
>>> from sklearn import linear_model, decomposition, datasets>>> pca = decomposition.PCA(n_components=1)
同样,在这里,我们可以使用pca
(或其fit_transform()
组合)的fit()
和transform()
方法来分析数据,并将其投影到变换后的特征空间中:
>>> Xtrans = pca.fit_transform(X)
正如我们所指定的,Xtrans
只包含一个维度。您可以在前面的右手图中看到结果。在这种情况下,结果甚至是线性可分的。我们甚至不需要复杂的分类器来区分这两个类。
为了理解重建误差,我们可以看看我们在转换中保留的数据的方差:
>>> print(pca.explained_variance_ratio_)>>> [ 0.96393127]
这意味着,从两个维度到一个维度后,我们仍然剩下 96%的方差。
当然,事情并不总是这么简单。通常情况下,我们不知道预先建议多少维度。在这种情况下,我们在初始化PCA
时不指定n_components
参数,让它计算完整的变换。拟合数据后,explained_variance_ratio_
包含一组递减顺序的比值:第一个值是描述最高方差方向的基向量的比值,第二个值是第二个最高方差方向的比值,依此类推。在绘制完这个数组后,我们很快就能感觉到我们需要多少个组件:图表肘部之前的组件数量通常是一个很好的猜测。
Plots displaying the explained variance over the number of components are called scree plots. A nice example of combining a scree plot with a grid search to find the best setting for the classification problem can be found at http://scikit-learn.org/stable/auto_examples/plot_digits_pipe.html.
主成分分析的局限性和线性判别分析的帮助
作为一种线性方法,当我们面对具有非线性关系的数据时,主成分分析当然有其局限性。我们在这里不做详细介绍,但是可以说 PCA 有一些扩展,例如内核 PCA,引入了非线性变换,这样我们仍然可以使用 PCA 方法。
主成分分析的另一个有趣的缺点是当它被应用于特殊的分类问题时。如果我们把 good = (x1 > 5) | (x2 > 5) 换成 good = x1 > x2 来模拟这样的特殊情况,就可以很快看出问题,如下图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/7c831c37-e74f-4eb4-a6ae-8dc5959e4caf.png
这里,类不是按照方差最高的轴分布,而是按照方差第二高的轴分布。显然,主成分分析完全失败了。由于我们没有向 PCA 提供任何关于类别标签的提示,所以它不能做得更好。
线性判别分析 ( LDA )来到这里救援。这是一种试图最大化属于不同类的点的距离,同时最小化同一类的点的距离的方法。我们不会给出关于底层理论如何工作的更多细节,只是简单介绍一下如何使用它,如下面的代码所示:
>>> from sklearn import lda>>> lda_inst = lda.LDA(n_components=1)>>> Xtrans = lda_inst.fit_transform(X, good)
仅此而已。请注意,与前面的主成分分析示例相反,我们为fit_transform()
方法提供了类标签。因此,主成分分析是一种无监督的特征投影方法,而线性判别分析是一种有监督的方法。结果如预期:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/4a1fde7b-9170-4b42-984e-1fbe9a446871.png
那么,为什么要考虑 PCA 呢?为什么不干脆用 LDA?没那么简单。随着类别数量的增加和每个类别样本的减少,LDA 看起来不再那么好了。此外,主成分分析似乎不像线性判别分析那样对不同的训练集敏感。所以,当我们不得不建议使用哪种方法时,我们只能说这取决于情况。
多维标度
主成分分析试图对保留的方差进行优化,而多维缩放 ( MDS )则试图在减少维度时尽可能保留相对距离。当我们拥有高维数据集并希望获得视觉印象时,这非常有用。
MDS 不在乎数据点本身;相反,它对数据点对之间的差异感兴趣,并将其解释为距离。它获取维度 k 的所有 N 数据点,并使用距离函数 d 0 计算距离矩阵,该距离函数测量原始特征空间中的(大部分时间,欧几里德)距离:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/258fbf41-dcf2-4ac7-814a-925bc175a6a7.png
现在,MDS 试图将各个数据点定位在较低的维度上,这样那里的新距离就尽可能地类似于原始空间中的距离。由于 MDS 经常被用于可视化,低维度的选择在大多数情况下是两个或三个。
让我们来看看下面由五维空间中的三个数据点组成的简单数据。其中两个数据点在附近,一个非常明显,我们希望在三维和二维中看到这一点:
>>> X = np.c_[np.ones(5), 2 * np.ones(5), 10 * np.ones(5)].T>>> print(X)[[ 1\\. 1\\. 1\\. 1\\. 1.] [ 2\\. 2\\. 2\\. 2\\. 2.] [ 10\\. 10\\. 10\\. 10\\. 10.]]
使用 scikit-learn 的manifold
包中的MDS
类,我们首先指定要将X
转换为三维欧氏空间:
>>> from sklearn import manifold>>> mds = manifold.MDS(n_components=3)>>> Xtrans = mds.fit_transform(X)
为了在两个维度上可视化它,我们需要相应地设置n_components
。
结果可以在下面两张图表中看到。三角形和圆形靠得很近,而星星离得很远:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/de5b450b-5e9d-45cb-9532-d91507c3ab82.png
让我们来看看稍微复杂一点的 Iris 数据集。我们后面会用它来对比 LDA 和 PCA。Iris 数据集包含每朵花的四个属性。使用前面的代码,我们将它投影到三维空间,同时尽可能保持单个花之间的相对距离。在前面的例子中,我们没有指定任何度量,所以MDS
将默认为欧几里德。这意味着根据四种属性不同的花在 MDS 尺度的三维空间中也应该很远,相似的花现在应该几乎在一起,如下图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/82f3d4ca-6ed1-4ea4-a6d2-7f8273af0e47.png
相反,用主成分分析将维度减少到三维和二维,我们看到属于同一类别的花的预期更大的传播:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/3b72d5a7-664d-437b-82ec-4469b25eb607.png
当然,使用 MDS 需要了解单个特征的单位;也许我们使用的特征无法用欧几里得度量来比较。例如,一个分类变量,即使被编码为整数(0 =圆,
1 =星,2 =三角形,等等),也不能用欧几里德度量来比较(一个圆是否更接近于星而不是三角形?).
然而,一旦我们意识到这个问题,MDS 是一个有用的工具,它揭示了我们的数据中的相似性,否则在原始特征空间中很难看到这些相似性。
深入观察 MDS,我们意识到它不是一个单一的算法,而是一系列不同的算法,我们只使用了其中的一个。PCA 也是如此。另外,如果你意识到 PCA 和 MDS 都不能解决你的问题,只要看看 scikit-learn 工具包中的许多其他学习和嵌入算法就可以了。
然而,在你被许多不同的算法淹没之前,最好从最简单的算法开始,看看你能走多远。然后,采取下一个更复杂的,并继续从那里。
用于降维的自动编码器或神经网络
十几年前,神经网络降维的主要工具是科霍宁地图,或自组织地图 ( SOM )。它们是神经网络,可以将数据映射到离散的嵌入 1D 的空间中。从那时起,有了更快的计算机,现在有可能使用深度学习来创建嵌入式空间。
诀窍是有一个比输入层节点少的中间层和一个必须再现输入层的输出层。这个中间层上的数据会给出嵌入空间中的坐标。
如果我们使用没有特定激活函数的规则密集层,我们得到从输入到嵌入层到输出层的线性函数。不止一层到嵌入层不会改变训练的结果,因此,我们得到线性嵌入,例如 PCA(没有在嵌入层中具有正交基的约束)。
给稠密层增加一个非线性激活函数将使得能够在数据中找到流形,而不仅仅是超平面。与 Isomap 等工具试图匹配数据之间的距离(这是 MDS 的变体,试图匹配近似测地线距离而不是欧几里德距离)或拉普拉斯特征映射(试图匹配数据之间的相似性)相反,自动编码器不知道我们试图保留什么——它们只会试图再现我们在输入端提供的任何东西。
Neural networks can extract features from data, as we will see in the TensorFlow chapter, but we will keep things simple here by using a dataset that is features-only.
我们将考虑的数据集是瑞士卷。它是流形中使用的最著名的数据集之一,因为它是一个非线性数据集,人眼很容易理解,但这种包装足以使算法很难正确描述它:
import numpy as npmax = 4def generate_swissroll(n): \"\"\" Generates data for the swissroll Returns the parameter space, the swissroll \"\"\" orig = np.random.random((2, n)) * max return (orig.T, np.array((orig[1] * np.cos(orig[1]), orig[1] * np.sin(orig[1]), orig[0])).T)def color_from_parameters(params): \"\"\" Defines a color scheme for the swissroll \"\"\" return np.array((params[:,0], params[:,1], max - params[:,1])).T / max
从这些函数中,我们可以生成新数据以及一个颜色代码,该代码允许我们检查嵌入的数据是否与我们使用的原始参数匹配,如下图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/989a3d52-fe9a-48f0-a84d-49d8016b0002.png
现在是时候考虑我们将使用的架构了。我们将从输入层开始,输入层将使用两个层在网络内部馈送数据,这两个层将完成将输入数据展开到具有两个层的嵌入层的繁重工作。为了重建瑞士卷,我们将在三单元输出层结束之前使用另一个密集层。为了创建非线性,每个层(输入除外)将使用leaky_relu
激活。安排如下图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/4f7f42cc-281e-4f34-a464-509071e8cd9d.png
让我们创建脚手架:
import tensorflow as tfdef tf_create_variables(): swissroll_tf = tf.placeholder(tf.float32, (None, 3), name=\"swissroll\") return swissroll_tfdef tf_create_dense_layer(x, size): return tf.layers.dense(x, size, activation=tf.nn.leaky_relu, kernel_initializer=tf.contrib.layers.xavier_initializer())
这一次,自动编码器将被封装在一个类中。构造器将创建变量,train
方法将运行优化,并创建一些显示图像。
当我们构建层时,我们保存嵌入层变量,因为这个变量是我们想要用来获取嵌入空间中新样本的参数的变量:
class Autoencoder(object): def __init__(self, swissroll, swissroll_test, nb_intermediate, learning_rate): self.swissroll = swissroll self.swissroll_test = swissroll_test self.swissroll_tf = tf_create_variables() intermediate_input = tf_create_dense_layer(self.swissroll_tf, nb_intermediate) intermediate_input = tf_create_dense_layer(intermediate_input, nb_intermediate) self.encoded = tf_create_dense_layer(intermediate_input, 2) intermediate_output = tf_create_dense_layer(self.encoded, nb_intermediate) self.output = tf_create_dense_layer(intermediate_output, 3) self.meansq = tf.reduce_mean(tf.squared_difference( self.output, self.swissroll_tf)) self.train_step = tf.train .GradientDescentOptimizer(learning_rate) .minimize(self.meansq) def train(self, display, n_epochs, batch_size, **kwargs): n = len(self.swissroll) with tf.Session() as sess: sess.run(tf.global_variables_initializer()) for i in range(n_epochs): permut = np.random.permutation(n) for j in range(0, n, batch_size): samples = permut[j:j+batch_size] batch = self.swissroll[samples] sess.run(self.train_step, feed_dict={self.swissroll_tf: batch}) if i % step == step - 1: print(\"Epoch :%i\\n Loss %f\" %\\ (i, sess.run(self.meansq, feed_dict={self.swissroll_tf: self.swissroll}))) error = sess.run(self.meansq, feed_dict={self.swissroll_tf: self.swissroll}) error_test = sess.run(self.meansq, feed_dict={self.swissroll_tf: self.swissroll_test}) if display: pred = sess.run(self.encoded, feed_dict={self.swissroll_tf : self.swissroll}) pred = np.asarray(pred) recons = sess.run(self.output, feed_dict={self.swissroll_tf : self.swissroll}) recons = np.asarray(recons) recons_test = sess.run(self.output, feed_dict={self.swissroll_tf : self.swissroll_test}) recons_test = np.asarray(recons_test) print(\"Embedded manifold\") plot_2d(pred, colors) save_png(\"swissroll_embedded\") plt.show() print(\"Reconstructed manifold\") plot_3d(recons, colors) save_png(\"swissroll_reconstructed\") plt.show() print(\"Reconstructed test manifold\") plot_3d(recons_test, kwargs[\'colors_test\']) save_png(\"swissroll_test\") plt.show() return error, error_test
我们可以运行这个自动编码器,并检查它是否也适用于新数据:
n = 5000n_epochs = 2000batch_size = 100nb_intermediate = 20learning_rate = 0.05step = 100params, swissroll = generate_swissroll(n)params_test, swissroll_test = generate_swissroll(n)colors = color_from_parameters(params)colors_test = color_from_parameters(params_test)model = Autoencoder(swissroll, swissroll_test, nb_intermediate, learning_rate)error, error_test = model.train(True, n_epochs, batch_size, colors=colors, test=swissroll_test, colors_test = colors_test)…Epoch :1599 Loss 0.001498Epoch :1699 Loss 0.001008Epoch :1799 Loss 0.000870Epoch :1899 Loss 0.000952Epoch :1999 Loss 0.000830
训练数据的嵌入空间很好,并且尊重我们用来生成swissroll
的配色方案。我们可以在下图中看到它的表示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/789a7e18-f1f7-4903-a814-8474245c0058.png
这里有趣的一点是,参数空间并不直接链接到我们用来创建数据的参数。幅度不同,每次新跑都会得到一个新的嵌入空间。我们可以在均方成本函数中添加正则化,就像我们在回归一章中所做的那样。
关键一点是检查输出数据是否与输入数据匹配。我们看到损失很低。测试数据还显示重建误差较低,但目视检查有时是一件好事。下图显示了图形表示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/ffc7b154-3100-423c-b5e4-fd086d4a123e.png
我们可以看到,与最初的瑞士卷相比,有一些颠簸和不连续。在重建过程中添加第二层将有助于减少这种情况;我们在这里这样做不是为了表明我们不必为自动编码器使用对称神经网络。
摘要
在本章中,您了解到,有时,您可以使用特征选择方法来删除完整的特征。我们还看到,在某些情况下,这是不够的,我们必须采用特征投影方法来揭示我们数据中的真实和低维结构,希望模型使用起来更容易。
当然,我们只是触及了大量可用降维方法的表面。尽管如此,我们希望我们能让你对这整个领域感兴趣,因为还有很多其他方法等着你去发现。归根结底,特征选择和投影是一门艺术,就像选择正确的学习方法或训练模型一样。
在第 6 章、聚类–寻找相关帖子中,我们将介绍聚类,这是一种无监督的学习技术。我们将使用它来查找给定文本的类似新闻帖子。
六、聚类——查找相关帖子
直到现在,我们一直认为训练是学习一个把一些数据映射到一些标签的函数。对于本章中的任务,我们可能没有可以用来学习分类模型的标签。例如,这可能是因为它们太贵而无法收集。试想一下,如果获得数百万个标签的唯一方法是让人类手动注释这些标签,成本会有多大。在那种情况下我们能做什么?
我们在数据本身中找到一些模式。这就是我们在这一章要做的,我们再次考虑问答网站的挑战。当用户浏览我们的网站时,也许是因为他们在搜索特定的信息,搜索引擎很可能会给他们一个特定的答案。如果给出的答案不是他们想要的,网站应该给出(至少)相关的答案,这样他们就可以很快看到还有哪些其他答案,并希望留在我们的网站上。
天真的方法是简单地获取帖子,计算它与所有其他帖子的相似度,并将最相似的帖子作为链接显示在页面上。这将很快变得非常昂贵。相反,我们需要一种快速找到所有相关帖子的方法。
在本章中,我们将通过对从文本中提取的特征进行聚类来实现这个目标。聚类是一种排列项目的方法,使得相似的项目在一个聚类中,而不相似的项目在不同的聚类中。我们必须首先解决的棘手问题是如何将文本转化为我们可以用来计算相似度的东西。有了这样的相似性度量,我们将继续研究如何利用它来快速获得包含相似帖子的集群。一旦到了那里,我们只需要检查那些也属于那个集群的文档。为了实现这一点,我们将向您介绍奇妙的scikit
库,它附带了各种机器学习方法,我们也将在后面的章节中使用。
衡量职位的相关性
从机器学习的角度来看,原始文本毫无用处。如果我们设法把它转换成有意义的数字,我们就可以把它输入到我们的机器学习算法中,比如聚类。对于更普通的文本操作也是如此,比如相似性度量。
怎么不做呢
一种文本相似性度量是莱文斯坦距离,也称为编辑距离。假设我们有两个词,machine 和 mchiene。它们之间的相似性可以表示为将一个单词变成另一个单词所需的最小编辑集。在这种情况下,编辑距离将是两个,因为我们必须在m
之后添加一个a
,并删除第一个e
。然而,这种算法相当昂贵,因为它受到第一个单词的长度乘以第二个单词的长度的限制。
看看我们的帖子,我们可以通过将整个单词视为字符并在单词级别上执行编辑距离计算来作弊。假设我们有两个帖子叫做,如何格式化我的硬盘,以及硬盘格式化问题(为了简单起见,让我们假设这个帖子只包含标题)。我们将需要五个编辑距离,因为删除,如何,到,格式,我的,然后在最后添加格式和问题。因此,人们可以将两个帖子之间的差异表示为必须添加或删除的单词数量,以便一个文本变形为另一个文本。虽然我们可以加快整个方法的速度,但是时间复杂度保持不变。
但即使它足够快,还有另一个问题。在之前的帖子中,word format 占了两个编辑距离,因为先删除它,再添加它。因此,我们的距离似乎不够稳固,不足以考虑单词重排。
怎么做
比编辑距离更稳健的是所谓的包词法。它忽略了单词的顺序,简单地使用单词计数作为它们的基础。对于帖子中的每个单词,它的出现都被计算并记录在一个向量中。不出意外,这一步也叫矢量化。向量通常很大,因为它包含的元素和整个数据集中出现的单词一样多。前面提到的两个示例帖子将具有以下字数:
| 字 | 1 号岗位发生的事件 | 岗位 2 发生情况 |
| 唱片 | one | one |
| 格式 | one | one |
| 怎么 | one | Zero |
| 困难的 | one | one |
| 我的 | one | Zero |
| 问题 | Zero | one |
| 到 | one | Zero |
第 2 篇文章中出现的列和第 1 篇文章中出现的列现在可以被视为向量。我们可以简单地计算所有帖子的向量之间的欧几里得距离,并取最近的一个(太慢了,正如我们之前发现的)。因此,我们可以在后面的聚类步骤中使用它们作为特征向量,具体过程如下:
- 从每个帖子中提取显著特征,并将其存储为每个帖子的向量
- 对向量进行聚类
- 确定有问题的帖子的群
- 从这个集群中,获取一些与所讨论的帖子具有不同相似性的帖子。这将增加多样性
但是在我们到达那里之前还有一些工作要做。在我们做这项工作之前,我们需要一些数据。
预处理–相似性以相似数量的常用词来衡量
正如我们之前看到的,单词包方法既快速又健壮。然而,这并非没有挑战。让我们直接进入它们。
将原始文本转换为单词包
我们不需要编写自定义代码来计算单词,并将这些计数表示为向量。Scikit 的CountVectorizer
方法,工作效率高,而且界面非常方便:
>>> from sklearn.feature_extraction.text import CountVectorizer>>> vectorizer = CountVectorizer(min_df=1)
min_df
参数决定CountVectorizer
如何处理很少的单词(最小文档频率)。如果设置为整数,将删除较少文档中出现的所有单词。如果它是一个分数,所有出现在小于整个数据集的分数的单词都将被删除。max_df
参数的工作方式类似。如果我们打印实例,我们可以看到 scikit 提供的其他参数及其默认值:
>>> print(vectorizer) CountVectorizer(analyzer=\'word\', binary=False, decode_error=\'strict\', dtype=<class \'numpy.int64\'>, encoding=\'utf-8\', input=\'content\', lowercase=True, max_df=1.0, max_features=None, min_df=1, ngram_range=(1, 1), preprocessor=None, stop_words=None, strip_accents=None, token_pattern=\'(?u)\\b\\w\\w+\\b\', tokenizer=None, vocabulary=None)
我们可以看到,不出所料,计数是在单词层面(analyzer=word
)完成的,单词是由正则表达式模式token_pattern
决定的。例如,它将交叉验证分为交叉验证和验证。这个过程也称为标记化。
现在让我们忽略其他参数,考虑下面两个示例主题行:
>>> content = [\"How to format my hard disk\", \" Hard disk format problems \"]
我们现在可以将这个主题行列表放入我们的矢量器的fit_transform()
功能中,该功能完成所有困难的矢量化工作:
>>> X = vectorizer.fit_transform(content)>>> vectorizer.get_feature_names()[\'disk\', \'format\', \'hard\', \'how\', \'my\', \'problems\', \'to\']
矢量器检测到七个单词,我们可以分别获取它们的计数:
>>> print(X.toarray().transpose())[[1 1] [1 1] [1 1] [1 0] [1 0] [0 1] [1 0]]
这意味着第一句包含除了问题之外的所有单词,而第二句包含除了 how、my 和 to 之外的所有单词。事实上,这些列与我们在上表中看到的相同。从X
中,我们可以提取一个特征向量,我们将使用它来比较两个文档。
我们将首先从一个天真的方法开始,指出一些我们必须考虑的预处理特性。让我们选择一个随机的帖子,然后为它创建计数向量。然后,我们将它的距离与所有计数向量进行比较,并获取最小的一个。
数词
让我们来玩玩具数据集,由以下帖子组成:
| 发布文件名 | 发文内容 |
| 01.txt
| 这是一个关于机器学习的玩具帖子。实际上,它包含的有趣的东西并不多 |
| 02.txt
| 成像数据库会变得很庞大 |
| 03.txt
| 大多数成像数据库会永久保存图像 |
| 04.txt
| 成像数据库存储图像 |
| 05.txt
| 成像数据库存储图像 |
在这个帖子数据集中,我们希望为短帖子成像数据库找到最相似的帖子。
假设帖子位于\"data/toy\"
目录下(请查看 Jupyter 笔记本),我们可以用它来喂养CountVectorizer
:
>>> from pathlib import Path # for easy path management >>> TOY_DIR = Path(\'data/toy\') >>> posts = [] >>> for fn in TOY_DIR.iterdir(): ... with open(fn, \'r\') as f: ... posts.append(f.read()) ... >>> from sklearn.feature_extraction.text import CountVectorizer >>> vectorizer = CountVectorizer(min_df=1)
我们必须通知矢量器完整的数据集,以便它提前知道哪些单词是预期的:
>>> X_train = vectorizer.fit_transform(posts)>>> num_samples, num_features = X_train.shape>>> print(\"#samples: %d, #features: %d\" % ... (num_samples, num_features))#samples: 5, #features: 25
不出所料,我们有五个帖子,总共有 25 个不同的单词。将计算以下已标记化的单词:
>>> print(vectorizer.get_feature_names())[\'about\', \'actually\', \'capabilities\', \'contains\', \'data\', \'databases\', \'images\', \'imaging\', \'interesting\', \'is\', \'it\', \'learning\', \'machine\', \'most\', \'much\', \'not\', \'permanently\', \'post\', \'provide\', \'save\', \'storage\', \'store\', \'stuff\', \'this\', \'toy\']
现在我们可以向量化我们的新帖子:
>>> new_post = \"imaging databases\">>> new_post_vec = vectorizer.transform([new_post])
注意transform
方法返回的计数向量是稀疏的,这是合适的格式,因为数据本身也是稀疏的。也就是说,每个向量不会为每个单词存储一个计数值,因为大多数计数值都为零(帖子不包含该单词)。相反,它使用了内存效率更高的实现方式coo_matrix
(用于坐标)。例如,我们的新帖子实际上只包含两个元素:
>>> print(new_post_vec)(0, 7) 1(0, 5) 1
通过其toarray()
成员,我们可以再次完全访问ndarray
:
>>> print(new_post_vec.toarray())[[0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]]
如果我们想用它作为相似度计算的向量,我们需要使用完整的数组。对于相似性度量(幼稚的),我们计算新帖子和所有旧帖子的计数向量之间的欧几里德距离:
import scipydef dist_raw(v1, v2): delta = v1-v2 return scipy.linalg.norm(delta.toarray())
norm()
函数计算欧几里德范数(最短距离)。这只是一个显而易见的第一选择,还有很多更有趣的方法来计算距离。只需看看 Python 论文源代码中两个列表或集合之间的论文距离系数,其中莫里斯·凌很好地呈现了 35 个不同的列表或集合。
有了dist_raw
,我们只需要遍历所有帖子,记住最近的一个即可。由于我们将在整本书中使用它,让我们定义一个便利函数,该函数以矢量化的形式获取当前数据集和新帖子以及距离函数,并打印出距离函数工作情况的分析:
def best_post(X, new_vec, dist_func): best_doc = None best_dist = float(\'inf\') # infinite value as a starting point best_i = None for i, post in enumerate(posts): if post == new_post: continue post_vec = X.getrow(i) d = dist_func(post_vec, new_vec) print(\"=== Post %i with dist=%.2f:n \'%s\'\" % (i, d, post)) if d < best_dist: best_dist = d best_i = i print(\"n==> Best post is %i with dist=%.2f\" % (best_i, best_dist))
当我们执行为best_post(X_train, new_post_vec, dist_raw)
时,我们可以在输出中看到这些帖子以及它们各自到新帖子的距离:
=== Post 0 with dist=4.00: \'This is a toy post about machine learning. Actually, it contains not much interesting stuff.\' === Post 1 with dist=1.73: \'Imaging databases provide storage capabilities.\' === Post 2 with dist=2.00: \'Most imaging databases save images permanently.\' === Post 3 with dist=1.41: \'Imaging databases store data.\' === Post 4 with dist=5.10: \'Imaging databases store data. Imaging databases store data. Imaging databases store data.\' ==> Best post is 3 with dist=1.41
恭喜,我们有了第一个相似性度量。Post 0
和我们的新帖子最不一样。可以理解的是,它与新帖子没有一个共同的词。我们也可以理解Post 1
和新帖很像,但不是赢家,因为它比新帖没有包含的Post 3
多了一个字。
然而看着Post 3
和Post 4
,画面就没那么清晰了。Post 4
同Post 3
重复三次。所以,它也应该和Post 3
一样类似于新的岗位。
打印相应的特征向量解释了为什么:
>>> print(X_train.getrow(3).toarray())[[0 0 0 0 1 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0]]>>> print(X_train.getrow(4).toarray())[[0 0 0 0 3 3 0 3 0 0 0 0 0 0 0 0 0 0 0 0 0 3 0 0 0]]
显然,仅使用原始单词的计数是不够的。我们必须对它们进行归一化,以得到单位长度的向量。
归一化字数向量
我们将不得不扩展dist_raw
来计算矢量距离,而不是在原始矢量上,而是在归一化矢量上:
def dist_norm(v1, v2): v1_normalized = v1 / scipy.linalg.norm(v1.toarray()) v2_normalized = v2 / scipy.linalg.norm(v2.toarray()) delta = v1_normalized - v2_normalized return scipy.linalg.norm(delta.toarray())
当使用best_post(X_train, new_post_vec, dist_norm)
执行时,这导致以下相似性测量:
=== Post 0 with dist=1.41: \'This is a toy post about machine learning. Actually, it contains not much interesting stuff.\' === Post 1 with dist=0.86: \'Imaging databases provide storage capabilities.\' === Post 2 with dist=0.92: \'Most imaging databases save images permanently. \' === Post 3 with dist=0.77: \'Imaging databases store data.\' === Post 4 with dist=0.77: \'Imaging databases store data. Imaging databases store data. Imaging databases store data.\' ==> Best post is 3 with dist=0.77
现在看起来好多了。岗位 3 和岗位 4 被计算为同等相似。人们可能会争论这么多重复是否会让读者高兴,但就计算帖子中的字数而言,这似乎是正确的。
删除不太重要的单词
我们再来看看《邮报 2》。在新帖子里没有的词中,我们有最多的,保存,图像,和永久的。他们对这个职位的整体重要性大不相同。像大多数这样的词经常出现在各种不同的上下文中,被称为停止词。它们携带的信息不多,因此不应该像图像等词汇那样被高度重视,因为这些词汇在不同的语境中并不经常出现。最好的选择是删除所有频繁出现的单词,因为它们无法帮助我们区分不同的文本。这些词被称为停止词。
由于这是文本处理中常见的步骤,因此CountVectorizer
中有一个简单的参数来实现:
>>> vect_engl = CountVectorizer(min_df=1, stop_words=\'english\')
如果你清楚地知道你想删除什么类型的停止词,你也可以传递一个列表。将stop_words
设置为english
将使用一组 318 个英语停止词。要找出哪些,可以使用get_stop_words()
:
>>> sorted(vect_engl.get_stop_words())[0:20][\'a\', \'about\', \'above\', \'across\', \'after\', \'afterwards\', \'again\', \'against\', \'all\', \'almost\', \'alone\', \'along\', \'already\', \'also\', \'although\', \'always\', \'am\', \'among\', \'amongst\', \'amoungst\']
新单词列表轻了七个单词:
>>> X_train_engl = vect_engl.fit_transform(posts)>>> num_samples_engl, num_features_engl = X_train_engl.shape>>> print(vect_engl.get_feature_names()) [\'actually\', \'capabilities\', \'contains\', \'data\', \'databases\', \'images\', \'imaging\', \'interesting\', \'learning\', \'machine\', \'permanently\', \'post\', \'provide\', \'save\', \'storage\', \'store\', \'stuff\', \'toy\']
在丢弃停止词之后,我们得出以下相似性度量:
>>> best_post(X_train_engl, new_post_vec_engl, dist_norm) === Post 0 with dist=1.41: \'This is a toy post about machine learning. Actually, it contains not much interesting stuff.\' === Post 1 with dist=0.86: \'Imaging databases provide storage capabilities.\' === Post 2 with dist=0.86: \'Most imaging databases save images permanently.\' === Post 3 with dist=0.77: \'Imaging databases store data.\' === Post 4 with dist=0.77: \'Imaging databases store data. Imaging databases store data. Imaging databases store data.\' ==> Best post is 3 with dist=0.77
Post 2
现在和Post 1
不相上下。但是,由于我们的岗位很短,仅用于演示目的,总体上没有太大变化。当我们查看真实世界的数据时,这将变得至关重要。
堵塞物
还有一件事没说。我们把不同变体中相似的词算作不同的词。例如,Post 2 包含成像和图像。把它们算在一起是有意义的。毕竟,他们指的是同一个概念。
我们需要一个将单词简化为特定词干的函数。默认情况下,Scikit 不包含词干分析器。有了自然语言工具包 ( NLTK ,我们可以下载一个免费的软件工具包,它提供了一个词干分析器,我们可以很容易地插入CountVectorizer
。
安装和使用 NLTK
NLTK 是一个简单的pip install nltk
之外。
要检查安装是否成功,请打开 Python 解释器并键入:
>>> import nltk
You will find a very nice tutorial on NLTK in the book *Python 3 Text Processing with NLTK 3 Cookbook *by Jacob Perkins, published by Packt Publishing.
To play around a little bit with a stemmer, you can visit the web page http://text-processing.com/demo/stem/.
NLTK 自带不同的词干。这是必要的,因为每种语言都有一套不同的词干规则。对于英语,我们可以取SnowballStemmer
:
>>> import nltk.stem>>> s = nltk.stem.SnowballStemmer(\'english\')>>> s.stem(\"graphics\")\'graphic\'>>> s.stem(\"imaging\")\'imag\'>>> s.stem(\"image\")\'imag\'>>> s.stem(\"imagination\")\'imagin\'>>> s.stem(\"imagine\")\'imagin\'
The stemming does not necessarily have to result in valid English words.
它也适用于动词:
>>> s.stem(\"buys\")\'buy\'>>> s.stem(\"buying\")\'buy\'
这意味着它大部分时间都有效:
>>> s.stem(\"bought\")\'bought\'
用 NLTK 的词干分析器扩展矢量器
在我们把帖子输入CountVectorizer
之前,我们需要阻止它们。该类提供了几个钩子,通过这些钩子我们可以自定义舞台的预处理和标记化。预处理器和标记器可以在构造函数中设置为参数。我们不想将词干分析器放入其中的任何一个,因为我们将不得不自己进行标记化和规范化。相反,我们覆盖build_analyzer
方法:
import nltk.stemenglish_stemmer = nltk.stem.SnowballStemmer(\'english\')class StemmedCountVectorizer(CountVectorizer): def build_analyzer(self): analyzer = super(StemmedCountVectorizer, self).build_analyzer() return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc))vect_engl_stem = StemmedCountVectorizer(min_df=1, stop_words=\'english\')
这将为每个帖子执行以下过程:
- 将预处理步骤中的原始帖子小写(在父类中完成)。
- 在标记化步骤中提取所有单个单词(在父类中完成)。
- 将每个单词转换成词干版本(在我们的
build_analyzer
中完成)。
因此,我们现在少了一个功能,因为图像和成像合二为一:
[\'actual\', \'capabl\', \'contain\', \'data\', \'databas\', \'imag\', \'interest\', \'learn\', \'machin\', \'perman\', \'post\', \'provid\', \'save\', \'storag\', \'store\', \'stuff\', \'toy\']
在我们的帖子上运行我们的新词干向量器,我们看到折叠的图像和图片揭示了实际上,Post 2
是与我们的新帖子最相似的帖子,因为它包含了两次概念图片:
=== Post 0 with dist=1.41: \'This is a toy post about machine learning. Actually, it contains not much interesting stuff.\' === Post 1 with dist=0.86: \'Imaging databases provide storage capabilities.\' === Post 2 with dist=0.63: \'Most imaging databases save images permanently.\' === Post 3 with dist=0.77: \'Imaging databases store data.\' === Post 4 with dist=0.77: \'Imaging databases store data. Imaging databases store data. Imaging databases store data.\' ==> Best post is 2 with dist=0.63
停止使用类固醇
既然我们已经有了一个合理的方法来从一篇嘈杂的文本帖子中提取一个紧凑的向量,让我们后退一步,思考一下特征值实际上意味着什么。
特征值只是计算文章中出现的术语。我们默默地假设一个术语的较高值也意味着该术语对给定的职位更重要。但是,举例来说,主题这个词呢,它自然出现在每一篇文章中(主题:…)?好的,我们也可以通过max_df
参数告诉CountVectorizer
将其删除。例如,我们可以将其设置为0.9
,这样所有帖子中超过 90%的单词都将被忽略。但是出现在 89%的帖子中的单词呢?我们愿意把max_df
设多低?问题是,无论我们如何设定,总会有这样一个问题:有些术语比其他术语更具歧视性。
这只能通过计算每个帖子的词条频率来解决,此外,还要对许多帖子中出现的词条频率进行折扣。换句话说,如果某个术语经常出现在某个特定的岗位上,而很少出现在其他地方,那么我们就希望该术语在某个给定的值中具有较高的值。
这正是术语频率-逆文档频率 ( TF-IDF )的作用。TF 代表计算部分,而 IDF 在折扣中考虑了因素。一个天真的实现看起来像这样:
def tfidf(term, doc, corpus): tf = doc.count(term) / len(doc) idf = np.log(float(len(corpus)) / (len([d for d in corpus if term in d]))) tf_idf = tf * idf print(\"term=\'%s\' doc=%-17s tf=%.2f idf=%.2f tf*idf=%.2f\"% (term, doc, tf, idf, tf_idf)) return tf_idf
您可以看到,我们不仅对术语进行了简单的计数,还按照文档长度对计数进行了标准化。这样,较长的文档不会比较短的文档有不公平的优势。当然,为了快速计算,我们会将 IDF 计算移出函数,因为它对所有文档都是相同的值。
对于以下文档,D
,由三个已经标记化的文档组成,我们可以看到这些术语是如何被区别对待的,尽管每个文档出现的频率都相同:
>>> a, abb, abc = [\"a\"], [\"a\", \"b\", \"b\"], [\"a\", \"b\", \"c\"]>>> D = [a, abb, abc]>>> print(\"=> tfidf=%.2f\" % tfidf(\"a\", a, D))term=\'a\' doc=[\'a\'] tf=1.00 idf=0.00=> tfidf=0.00>>> print(\"=> tfidf=%.2f\" % tfidf(\"a\", abb, D))term=\'a\' doc=[\'a\', \'b\', \'b\'] tf=0.33 idf=0.00=> tfidf=0.00>>> print(\"=> tfidf=%.2f\" % tfidf(\"a\", abc, D))term=\'a\' doc=[\'a\', \'b\', \'c\'] tf=0.33 idf=0.00=> tfidf=0.00>>> print(\"=> tfidf=%.2f\" % tfidf(\"b\", abb, D))term=\'b\' doc=[\'a\', \'b\', \'b\'] tf=0.67 idf=0.41=> tfidf=0.27>>> print(\"=> tfidf=%.2f\" % tfidf(\"b\", abc, D))term=\'b\' doc=[\'a\', \'b\', \'c\'] tf=0.33 idf=0.41=> tfidf=0.14>>> print(\"=> tfidf=%.2f\" % tfidf(\"c\", abc, D))term=\'c\' doc=[\'a\', \'b\', \'c\'] tf=0.33 idf=1.10=> tfidf=0.37
我们看到a
对于任何文件都没有意义,因为它无处不在。b
这个词对于文件abb
比abc
更重要,因为它在那里出现了两次。
实际上,要处理的角落案例比前面的例子要多。多亏了 scikit,我们不必去想它们,因为它们已经很好地封装在TfidfVectorizer
中了,而TfidfVectorizer
继承自CountVectorizer
。我们不想错过我们的词干:
from sklearn.feature_extraction.text import TfidfVectorizer class StemmedTfidfVectorizer(TfidfVectorizer): def build_analyzer(self): analyzer = super(TfidfVectorizer, self).build_analyzer() return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc)) vect_tfidf = StemmedTfidfVectorizer(stop_words=\'english\')
生成的文档向量将不再包含计数。相反,它们将包含每个术语的单个 TF-IDF 值。
我们的成就和目标
我们当前的文本预处理阶段包括以下步骤:
- 首先,标记文本
- 接下来是扔掉那些经常出现的对检测相关帖子没有任何帮助的词
- 扔掉那些很少出现的单词,这样它们就很少有机会出现在未来的帖子中
- 数着剩下的单词
- 最后,从计数中计算 TF-IDF 值,考虑整个文本语料库
我们可以再次祝贺自己。通过这个过程,我们能够将一堆有噪声的文本转换成特征值的简洁表示。
但是,尽管单词包方法及其扩展简单而强大,但它也有一些缺点,我们应该意识到:
- 不覆盖字关系:用前面提到的矢量化方法,文字、车撞墙、墙撞车,都会有相同的特征向量
- 它没有正确捕捉否定:比如文本,我会吃冰淇淋,我不会吃冰淇淋,通过它们的特征向量看起来非常相似,虽然它们包含完全相反的意思。然而,这个问题可以很容易地通过不仅计算单个单词(也称为 unigrams),而且考虑二元模型(单词对)或三元模型(连续三个单词)来缓解
- 对于拼错的单词它完全失败了:虽然对我们来说很明显,数据库和 databas 传达了相同的意思,但是我们的方法会将它们视为完全不同的单词
为了简洁起见,让我们继续使用当前的方法,我们现在可以使用它来高效地构建集群。
使聚集
最后,我们有了我们的向量,我们相信它在足够的程度上捕捉了帖子。毫不奇怪,有许多方法可以将它们组合在一起。对聚类算法进行分类的一种方法是区分平面聚类和层次聚类。
平面聚类将帖子分成一组聚类,而不将聚类相互关联。目标只是想出一个分区,使得一个集群中的所有帖子彼此最相似,而与所有其他集群中的帖子不相似。许多平面聚类算法要求预先指定聚类的数量。
在分层聚类中,不必指定聚类的数量。相反,层次聚类创建了聚类的层次结构。当相似的帖子被分组到一个集群中时,相似的集群再次被分组到一个超级集群中。例如,在凝聚聚类方法中,这是递归进行的,直到只剩下一个包含所有内容的聚类。在这个层次结构中,人们可以在事后选择期望的集群数量。然而,这是以较低的效率为代价的。
Scikit 在sklearn.cluster
包中提供了广泛的聚类方法。您可以在http://scikit-learn.org/stable/modules/clustering.html快速了解它们各自的优缺点。
在接下来的部分中,我们将使用平面聚类方法 K-means。
k 均值
K-means 是应用最广泛的平面聚类算法。在用期望数量的簇num_clusters
初始化它之后,它保持所谓的簇形心的数量。最初,它会选择任何num_clusters
帖子,并将质心设置为它们的特征向量。然后它将遍历所有其他帖子,并为它们分配最近的质心作为它们当前的簇。接下来,它会将每个质心移动到该特定类的所有向量的中间。这当然会改变集群分配。一些哨所现在更靠近另一个集群。因此,它将更新那些已更改帖子的分配。只要质心有相当大的移动,就可以做到这一点。在一些迭代之后,移动将下降到阈值以下,我们认为聚类是收敛的。
让我们用一个只包含两个单词的帖子的玩具例子来演示一下。下图中的每个点代表一个文档:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/30867097-5619-4797-a7c0-f17454e00521.png
在运行一次 K-means 迭代后,即以任意两个向量为起点,将标签分配给其余的向量,并将聚类中心更新为该聚类中所有点的中心点,我们得到以下聚类:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/720e05c5-a82b-49b9-a5de-c9a0302a17f4.png
由于群集中心已移动,我们必须重新分配群集标签并重新计算群集中心。迭代 2 之后,我们得到以下聚类:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/aeff4f40-e8de-47f1-9043-16057028eb97.png
箭头显示了集群中心的移动。经过十次迭代。如下例截图所示,集群中心不再明显移动(scikit 的容差阈值默认为 0.0001):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/743aa9d2-3ec6-46f9-9c9a-085e8a19e7df.png
聚类稳定后,我们只需要记下聚类中心和它们的聚类号。对于每个新的文档,我们必须向量化,并与所有集群中心进行比较。与我们的新帖子向量距离最小的聚类中心属于我们将分配给新帖子的聚类。
获取测试数据来评估我们的想法
为了测试聚类,让我们远离玩具文本示例,找到一个类似于我们未来期望的数据的数据集,这样我们就可以测试我们的方法。出于我们的目的,我们需要关于已经分组在一起的技术主题的文档,以便我们可以在稍后将其应用于我们希望收到的帖子时检查我们的算法是否如预期那样工作。
机器学习中的一个标准数据集是20newsgroup
数据集,它包含来自 20 个不同新闻组的 18,826 篇文章。这些小组的话题中有技术性的,如comp.sys.mac.hardware
或sci.crypt
,也有更多与政治和宗教相关的,如talk.politics.guns
或soc.religion.christian
。我们将仅限于技术组。如果我们假设每个新闻组是一个集群,我们可以很好地测试我们寻找相关帖子的方法是否有效。
The dataset can be downloaded from http://people.csail.mit.edu/jrennie/20Newsgroups.
为方便起见,sklearn.datasets
模块还包含fetch_20newsgroups
功能,自动下载后台数据:
>>> import sklearn.datasets>>> all_data = sklearn.datasets.fetch_20newsgroups(subset=\'all\')>>> print(len(all_data.filenames))18846>>> print(all_data.target_names)[\'alt.atheism\', \'comp.graphics\', \'comp.os.ms-windows.misc\', \'comp.sys.ibm.pc.hardware\', \'comp.sys.mac.hardware\', \'comp.windows.x\', \'misc.forsale\', \'rec.autos\', \'rec.motorcycles\', \'rec.sport.baseball\', \'rec.sport.hockey\', \'sci.crypt\', \'sci.electronics\', \'sci.med\', \'sci.space\', \'soc.religion.christian\', \'talk.politics.guns\', \'talk.politics.mideast\', \'talk.politics.misc\', \'talk.religion.misc\']
我们可以在训练集和测试集之间进行选择:
>>> train_data = sklearn.datasets.fetch_20newsgroups(subset=\'train\')>>> print(len(train_data.filenames))11314>>> test_data = sklearn.datasets.fetch_20newsgroups(subset=\'test\')>>> print(len(test_data.filenames))7532
为了简单起见,我们将仅限于一些新闻组,这样整个实验周期就更短了。我们可以通过categories
参数实现这一点:
>>> groups = [\'comp.graphics\', \'comp.os.ms-windows.misc\', \'comp.sys.ibm.pc.hardware\', \'comp.sys.mac.hardware\', \'comp.windows.x\', \'sci.space\']>>> train_data = sklearn.datasets.fetch_20newsgroups(subset=\'train\', categories=groups)>>> print(len(train_data.filenames))3529>>> test_data = sklearn.datasets.fetch_20newsgroups(subset=\'test\', categories=groups)>>> print(len(test_data.filenames))2349
将帖子分组
我们已经注意到一件事——真实数据是有噪音的。新闻组数据集也不例外。它甚至包含将导致UnicodeDecodeError
的无效字符。
我们必须告诉矢量器忽略它们:
>>> vectorizer = StemmedTfidfVectorizer(min_df=10, max_df=0.5,... stop_words=\'english\', decode_error=\'ignore\')>>> vectorized = vectorizer.fit_transform(train_data.data)>>> num_samples, num_features = vectorized.shape>>> print(\"#samples: %d, #features: %d\" % (num_samples, num_features)) #samples: 3529, #features: 4712
我们现在有了一个3529
帖子池,并为每个帖子提取了一个4712
维度的特征向量。这就是 K-means 作为输入的内容。本章我们将把集群大小固定在50
上,希望你有足够的好奇心尝试不同的值作为练习:
>>> num_clusters = 50>>> from sklearn.cluster import KMeans>>> km = KMeans(n_clusters=num_clusters, n_init=1, verbose=1, random_state=3)>>> km.fit(vectorized)
就这样。我们提供了一个随机状态,这样你就可以得到相同的结果。在现实应用程序中,您不会这样做。拟合后,我们可以得到km
成员的聚类信息。对于每个已经拟合的矢量化帖子,在km.labels_
中有一个对应的整数标签:
>>> print(\"km.labels_=%s\" % km.labels_)km.labels_=[48 23 31 ..., 6 2 22]>>> print(\"km.labels_.shape=%s\" % km.labels_.shape)km.labels_.shape=3529
可以通过km.cluster_centers_
访问集群中心。
在下一节中,我们将看到如何使用km.predict
将集群分配给新到达的帖子。
解决我们最初的挑战
现在,我们将把所有的东西放在一起,并为我们分配给new_post
变量的以下新帖子演示我们的系统:
new_post = \'\'\'Disk drive problems. Hi, I have a problem with my hard disk.After 1 year it is working only sporadically now.I tried to format it, but now it doesn\'t boot any more.Any ideas? Thanks. \'\'\'
正如你之前所学,在你预测
它的标签之前,你首先必须向量化这篇文章:
>>> new_post_vec = vectorizer.transform([new_post])>>> new_post_label = km.predict(new_post_vec)[0]
现在我们已经有了聚类,我们不需要将new_post_vec
与所有的后向量进行比较。相反,我们只能关注同一组的帖子。让我们从原始数据集中获取它们的索引:
>>> similar_indices = (km.labels_ == new_post_label).nonzero()[0]
括号中的比较产生一个布尔数组,nonzero
将该数组转换为包含True
元素索引的较小数组。
使用similar_indices
,我们只需要建立一个帖子列表以及它们的相似度分数:
>>> similar = []>>> for i in similar_indices:... dist = scipy.linalg.norm((new_post_vec - vectorized[i]).toarray())... similar.append((dist, train_data.data[i]))>>> similar = sorted(similar)>>> print(\"Count similar: %i\" % len(similar))Count similar: 56
我们在帖子的群中找到了56
个帖子。为了让用户快速了解什么样的类似帖子可用,我们现在可以展示最相似的帖子(show_at_1
)和两个不太相似但仍然相关的帖子,它们都来自同一个集群:
>>> show_at_1 = similar[0]>>> show_at_2 = similar[len(similar) // 10]>>> show_at_3 = similar[len(similar) // 2]
下表显示了帖子及其相似度值:
| 位置 | 相似度 | 节选自帖子 |
| one | One point zero three eight | 集成开发环境控制器的启动问题嗨,我有一个多输入/输出卡(集成开发环境控制器+串行/并行接口)和两个软驱(5 1/4,3 1/2)以及一个连接到它的量子驱动器 80AT。我可以格式化硬盘,但无法从它启动。我可以从驱动器 A 引导:(哪个磁盘驱动器不重要)但是如果我从驱动器 A 中取出磁盘并按下重置开关,驱动器 A:的 LED 会继续发光,硬盘根本无法访问。我猜这一定是多 I/O 卡或软盘驱动器设置的问题(跳线配置?)有人知道这可能是什么原因吗。[…] |
| Two | One point one five | 从 B 驱动器启动我有一个 5 1/4 英寸的驱动器作为驱动器 a。如何让系统从我的 3 1/2 英寸驱动器 B 启动?(最理想的情况是,计算机能够从 A 或 B 引导,检查它们以获得可引导磁盘。但是,如果我必须切换电缆,只需切换驱动器,这样它就无法引导 5 个 1/4 英寸的磁盘,这没关系。另外,boot_b 对我来说也没用。[…][…] |
| three | One point two eight | IBM PS/1 vs TEAC FD 你好,我已经尝试了我们的国家新闻集团,但没有成功。我试图用一个普通的 TEAC 驱动器替换一个朋友 PS/1-PC 中的原始 IBM 软盘。我已经确定了针脚 3 (5V)和 6 (12V)上的电源,短路了针脚 6 (5.25 英寸/3.5 英寸开关),并在针脚 8、26、28、30 和 34 上插入了上拉电阻(2K2)。电脑不会抱怨 FD 不见了,但是 FD 的灯一直亮着。驱动器旋转正常。当我插入磁盘,但无法访问它时。TEAC 在普通电脑上运行良好。有没有我漏掉的点?[…][…] |
有趣的是,帖子反映了相似性度量分数。第一篇文章包含了我们新文章中所有的关键词。第二个也是围绕着引导问题,但是是关于软盘而不是硬盘。最后,第三个问题既不是关于硬盘,也不是关于引导问题。尽管如此,所有的帖子,我们会说,属于同一个领域的新帖子。
再看看噪音
我们不应该期望完美的聚类,因为来自同一个新闻组(例如,comp.graphics
)的帖子也被聚类在一起。一个例子会给我们一个快速印象的噪音,我们不得不期待。为了简单起见,我们将关注其中一个较短的帖子:
>>> post_group = zip(train_data.data, train_data.target)>>> all = [(len(post[0]), post[0], train_data.target_names[post[1]]) for post in post_group]>>> graphics = sorted([post for post in all if post[2]==\'comp.graphics\'])>>> print(graphics[5])(245, \'From: SITUNAYA@IBM3090.BHAM.AC.UKnSubject: test....(sorry)nOrganization: The University of Birmingham, United KingdomnLines: 1nNNTP-Posting-Host: ibm3090.bham.ac.uk<...snip...>\', \'comp.graphics\')
对于这个帖子,没有真正的迹象表明它属于comp.graphics
,只考虑预处理步骤后剩下的措辞:
>>> noise_post = graphics[5][1]>>> analyzer = vectorizer.build_analyzer()>>> print(list(analyzer(noise_post)))[\'situnaya\', \'ibm3090\', \'bham\', \'ac\', \'uk\', \'subject\', \'test\', \'sorri\', \'organ\', \'univers\', \'birmingham\', \'unit\', \'kingdom\', \'line\', \'nntp\', \'post\', \'host\', \'ibm3090\', \'bham\', \'ac\', \'uk\']
我们在应用标记化、降级和停止单词删除后收到了这些单词。如果我们也减去那些稍后将通过min_df
和max_df
过滤掉的单词,这将在fit_transform
稍后进行,情况会变得更糟:
>>> useful = set(analyzer(noise_post)).intersection (vectorizer.get_feature_names())>>> print(sorted(useful))[\'ac\', \'birmingham\', \'host\', \'kingdom\', \'nntp\', \'sorri\', \'test\', \'uk\', \'unit\', \'univers\']
从 IDF 分数中我们可以看出,大多数单词也经常出现在其他帖子中。请记住,TF-IDF 越高,特定职位的术语就越具有歧视性。由于 IDF 在这里是一个乘法因子,它的低值表明它通常没有很大的价值:
>>> for term in sorted(useful):... print(\'IDF(%-10s) = %.2f\' % (term, ... vectorizer._tfidf.idf_[vectorizer.vocabulary_[term]]))IDF(ac ) = 3.51IDF(birmingham) = 6.77IDF(host ) = 1.74IDF(kingdom ) = 6.68IDF(nntp ) = 1.77IDF(sorri ) = 4.14IDF(test ) = 3.83IDF(uk ) = 3.70IDF(unit ) = 4.42IDF(univers ) = 1.91
因此,具有最高辨别能力的术语birmingham
和kingdom
显然与计算机图形无关,IDF 分数较低的术语也是如此。可以理解的是,来自不同新闻组的帖子将聚集在一起。
然而,对于我们的目标来说,这没什么大不了的,因为我们只对减少我们必须与新帖子进行比较的帖子数量感兴趣。毕竟,我们的训练数据来自的特定新闻组并没有什么特别的兴趣。
调整参数
其他参数呢?例如,我们可以调整集群的数量,或者使用矢量器的max_features
参数(你应该试试!).此外,我们可以使用不同的集群中心初始化。然后还有比 K-means 本身更令人兴奋的替代品。例如,有一些聚类方法允许您使用不同的相似性度量,如余弦相似性、皮尔逊相似性或雅克卡相似性。一个让你兴奋的地方。
但是在你去那里之前,你必须更好地定义你真正的意思。Scikit 有一个完整的包,专门用于这个定义。这个包叫做sklearn.metrics
,它还包含了一系列不同的度量来衡量聚类质量。也许这应该是现在要做的第一件事——直接进入度量包的来源。
摘要
这是一个艰难的旅程——我们讨论了聚类前的预处理,以及一个可以将有噪声的文本转换成有意义的简洁矢量表示的解决方案,我们可以对其进行聚类。如果我们看看我们必须做些什么才能最终实现集群化,这是整个任务的一半以上。但是在路上,我们学到了很多关于文本处理的知识,以及简单的计数如何让你在嘈杂的真实数据中走得更远。
不过,由于 scikit 及其强大的包装,这一旅程变得更加顺畅。还有更多要探索的。在这一章中,我们只是触及了它能力的表面。在第七章、推荐中,我们会构建一个推荐系统,我们会看到它更多的力量。
七、推荐
推荐已经成为在线服务和商业的主要内容之一。这种类型的自动化系统可以为每个用户提供个性化的建议列表(无论是要购买的产品列表、要使用的功能还是新的连接)。在本章中,我们将看到自动推荐生成系统的基本工作方式。基于消费者输入生成推荐的领域通常被称为协作过滤,因为用户通过系统协作来为彼此找到最佳项目。
在本章的第一部分,我们将看到如何利用消费者过去的产品评级来预测新的评级。我们从一些有用的想法开始,然后把它们结合起来。当组合它们时,我们使用回归来学习组合它们的最佳方式。这也将允许我们探索机器学习中的一个通用概念:集成学习。
在本章的第二部分,我们将看看一个不同的学习方法建议:篮子分析。与我们有数字评级的情况不同,在购物篮分析设置中,我们所拥有的只是关于购物篮的信息,也就是说,哪些物品是一起购买的。目标是了解推荐。你可能已经看过类似这样的推荐,网购买 X 的人也买了 Y、。我们将开发自己的类似功能。总之,本章将涵盖以下内容:
- 通过预测产品评级建立推荐系统的不同方法。
- 堆叠作为一种组合多种预测的方式。这是一种结合机器学习方法的通用技术。
- 购物篮分析和关联规则挖掘,仅根据一起消费的项目来构建预测。
评级预测和建议
如果你在过去 10 年中使用过任何在线购物系统,你可能已经看到过推荐。有些像亚马逊,买了 X 的客户也买了 Y ,功能。这些将在篮子分析部分讨论。其他推荐是基于预测产品的评级,例如电影。
基于过去产品评级的学习推荐问题因网飞奖而出名,这是网飞发起的一项耗资百万美元的机器学习公开挑战。网飞是一家电影流媒体公司。这项服务的一个显著特点是,它让用户可以选择对他们看过的电影进行评分。然后,网飞利用这些评分向其客户推荐其他电影。在这个机器学习问题中,你不仅有关于用户看了哪些电影的信息,还有关于用户如何评价它们的信息。
2006 年,网飞在其数据库中提供了大量电影的客户评级,以应对公开挑战。目标是改进他们内部的评级预测算法。谁能赢 10%或更多,谁就能赢得 100 万美元。2009 年,一个名为 BellKor’s 务实混乱的国际团队能够打破这一纪录并获奖。他们这样做的 20 分钟前,另一个团队,合奏团,也通过了 10%的分数——一个持续了几年的比赛令人兴奋的照片结束。
Machine learning in the real world:
Much has been written about the Netflix Prize, and you may learn a lot by reading up on it. The techniques that won were a mixture of advanced machine learning and a lot of work put into preprocessing the data. For example, some users like to rate everything very highly, while others are always more negative; if you do not account for this in preprocessing, your model will suffer. Other normalizations were also necessary for a good result, bearing in mind factors such as the film’s age and how many ratings it received. Good algorithms are a good thing, but you always need to get your hands dirty and tune your methods to the properties of the data you have in front of you. Preprocessing and normalizing the data is often the most time-consuming part of the machine-learning process. However, this is also the place where one can have the biggest impact on the final performance of the system.
关于网飞奖,首先要注意的是它有多难。粗略地说,网飞使用的内部系统比完全没有推荐要好 10%(也就是说,给每部电影分配所有用户的平均值)。目标是在此基础上再提高 10%。总的来说,获胜的系统只比没有个性化好 20%。然而,实现这一目标花费了大量的时间和精力,尽管 20%似乎不多,但结果是一个在实践中有用的系统。
不幸的是,由于法律原因,该数据集不再可用。虽然数据集是匿名的,但有人担心可能会发现谁是客户,并泄露电影租赁的私人细节。然而,我们可以使用具有相似特征的学术数据集。这些数据来自明尼苏达大学的研究实验室 GroupLens。
如何解决网飞式的收视率预测问题?我们将研究两种不同的方法:邻域方法和回归方法。我们还将看到如何结合这些方法来获得一个单一的预测。
分为培训和测试
在高层次上,将数据集拆分为训练和测试数据,以便获得系统性能的原则性估计,其执行方式与我们在前面章节中看到的方式相同:我们获取一定比例的数据点(我们将使用 10%),并将其保留用于测试;其余的将用于训练。
但是,因为在这种情况下数据的结构不同,所以代码也不同。在我们探索的一些模型中,当我们传输数据时,留出 10%的用户是行不通的。
第一步是从磁盘加载数据,我们使用以下函数:
def load(): import numpy as np from scipy import sparse data = np.loadtxt(\'data/ml-100k/u.data\') ij = data[:, :2] ij = 1 # original data is in 1-based system values = data[:, 2] reviews = sparse.csc_matrix((values, ij.T)).astype(float) return reviews.toarray()
请注意,此矩阵中的零条目表示缺少评级:
reviews = load() U,M = np.where(reviews)
我们现在使用标准random
模块选择要测试的指数:
import random test_idxs = np.array(random.sample(range(len(U)), len(U)//10))
现在我们构建train
矩阵,类似于reviews
,但是测试条目设置为零:
train = reviews.copy() train[U[test_idxs], M[test_idxs]] = 0
最后,test
矩阵只包含测试值:
test = np.zeros_like(reviews) test[U[test_idxs], M[test_idxs]] = reviews[U[test_idxs], M[test_idxs]]
从现在开始,我们将继续获取训练数据,并尝试预测数据集中所有缺失的条目。也就是说,我们将编写代码,为每个用户-电影对分配一个推荐。
标准化训练数据
正如我们所看到的,最好对数据进行规范化,以消除明显的电影或用户特定的效果。我们将只使用我们之前使用的一种非常简单的标准化类型:转换为z-分数。
不幸的是,我们不能简单地使用 scikit-learn 的规范化对象,因为我们必须处理数据中缺失的值(也就是说,不是所有的电影都由所有用户评分)。因此,我们希望通过实际存在的值的平均值和标准偏差进行标准化。
我们将编写自己的类来忽略丢失的值。这个类将遵循 scikit-learn 预处理 API。我们甚至可以从 scikit-learn 的TransformerMixin
类中派生出一个fit_transform
方法:
from sklearn.base import TransformerMixin class NormalizePositive(TransformerMixin):
我们要选择正常化的轴。默认情况下,我们沿着第一个轴进行标准化,但有时沿着第二个轴进行标准化会很有用。这遵循了许多其他 NumPy 相关函数的约定:
def __init__(self, axis=0): self.axis = axis
最重要的方法是fit
法。在我们的实现中,我们计算不为零的值的平均值和标准偏差。请记住,零表示缺少值:
def fit(self, features, y=None):
如果轴是1
,我们对转置数组的操作如下:
if self.axis == 1: features = features.T # count features that are greater than zero in axis 0: binary = (features > 0) count0 = binary.sum(axis=0) # to avoid division by zero, set zero counts to one: count0[count0 == 0] = 1\\. # computing the mean is easy: self.mean = features.sum(axis=0)/count0 # only consider differences where binary is True: diff = (features - self.mean) * binary diff **= 2 # regularize the estimate of std by adding 0.1 self.std = np.sqrt(0.1 + diff.sum(axis=0)/count0) return self
我们将0.1
加到标准差的直接估计中,以避免在只有少数样本时低估标准差的值,所有样本可能完全相同。使用的确切值对最终结果并不重要,但我们需要避免被零除。
transform
方法需要注意维护二元结构,如下所示:
def transform(self, features): if self.axis == 1: features = features.T binary = (features > 0) features = features - self.mean features /= self.std features *= binary if self.axis == 1: features = features.T return features
请注意,当轴为1
时,我们如何处理输入矩阵的转换,然后将其转换回来,以便返回值与输入具有相同的形状。inverse_transform
方法执行逆运算进行变换,如下代码所示:
def inverse_transform(self, features, copy=True): if copy: features = features.copy() if self.axis == 1: features = features.T features *= self.std features += self.mean if self.axis == 1: features = features.T return features
最后,我们添加fit_transform
方法,顾名思义,它结合了fit
和transform
操作:
def fit_transform(self, features): return self.fit(features).transform(features)
我们定义的方法(fit
、transform
、transform_inverse
和fit_transform
)与sklearn.preprocessing
模块中定义的对象相同。在接下来的部分中,我们将首先归一化输入,生成归一化预测,最后应用逆变换来获得最终预测。
推荐的邻域方法
邻域概念可以通过两种方式实现:用户邻域或电影邻域。用户社区基于一个非常简单的概念:了解用户对电影的评价,找到与他们最相似的用户,并查看他们的评价。我们暂时只考虑用户邻居。在本节的最后,我们将讨论如何修改代码来计算电影邻居。
我们现在将探索的一个有趣的技术是只看每个用户给哪些电影评分,甚至不看给了什么评分。即使有一个二元矩阵,当用户评价一部电影时,我们有一个等于 1 的条目,当他们不评价时,我们有一个等于 0 的条目,我们也可以做出有用的预测。事后看来,这是完全有道理的——我们不会完全随机地选择要看的电影,而是选择那些我们已经期望喜欢的电影。我们也不会随机选择给哪些电影评分,但可能只会给那些我们感觉最强烈的电影评分(当然,也有例外,但平均来说,这可能是真的)。
我们可以将矩阵的值可视化为图像,其中每个评级都被描绘为一个小方块。黑色代表没有评级,灰色级别代表评级值。
可视化数据的代码非常简单(您可以对其进行调整,以显示比本书中更大的矩阵部分),如以下代码所示:
from matplotlib import pyplot as plt # Build an instance of the object we defined previously norm = NormalizePositive(axis=1) binary = (train > 0) train = norm.fit_transform(train) # plot just 200x200 area for space reasons fix, ax = plt.subplots() ax.imshow(binary[:200, :200], interpolation=\'nearest\')
下面的截图是这段代码的输出:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/80eef917-e562-4a4b-9286-8ee99c788c1e.png
我们可以看到矩阵是稀疏的——大多数正方形是黑色的。我们还可以看到,一些用户对电影的评分比其他人高得多,一些电影的评分比其他电影高得多。
我们现在要用这个二元矩阵来预测电影的收视率。一般算法的计算(伪代码)如下:
- 对于每个用户,根据接近程度对其他用户进行排名。对于这一步,我们将使用二进制矩阵,并使用相关性作为接近度的度量(将二进制矩阵解释为 0 和 1 允许我们执行这一计算)。
- 当我们需要估计一个用户(电影对)的评分时,我们会查看所有给该电影评分的用户,并将他们分成两组:最相似的一半和最不相似的一半。然后,我们使用最相似的一半的平均值作为预测。
我们可以使用scipy.spatial.distance.pdist
函数来获取所有用户之间的距离,作为一个矩阵。该函数返回相关距离,该距离通过反转相关值来转换相关值,因此较大的数字意味着它们不太相似。数学上,相关距离为 1-r ,其中 r 为相关值。代码如下:
from scipy.spatial import distance # compute all pair-wise distances: dists = distance.pdist(binary, \'correlation\') # Convert to square form, so that dists[i,j] # is distance between binary[i] and binary[j]: dists = distance.squareform(dists)
我们可以用这个矩阵选择每个用户最近的neighbors
。这些是最像它的用户。我们使用以下代码选择这些neighbors
:
neighbors = dists.argsort(axis=1)
现在,我们迭代所有用户来估计所有输入的预测:
# We are going to fill this matrix with results filled = train.copy() for u in range(filled.shape[0]): # n_u is neighbors of user n_u = neighbors[u, 1:] # t_u is training data for m in range(filled.shape[1]): # get relevant reviews in order! revs = train[n_u, m] # Only use valid entries: revs = revs[binary[n_u, m]] if len(revs): # n is the number of reviews for this movie n = len(revs) # consider half of the reviews plus one n //= 2 n += 1 revs = revs[:n] filled[u,m] = np.mean(revs)
前面片段中棘手的部分是通过正确的值来索引,以选择给电影评分的邻居。然后,我们选择最接近用户的那一半(在rev[:n]
线)并对其进行平均。因为有些电影评论很多,有些评论很少,所以很难找到所有案例的单一用户数量。选择一半的可用数据是比设置固定值更通用的方法。
为了获得最终结果,我们需要对预测进行如下反规格化:
predicted = norm.inverse_transform(filled)
我们可以使用我们在讨论回归时了解到的相同指标(第 2 章、用真实世界的例子进行分类)。回想一下 r 评分范围从0
(预测不比基线好)到1
(预测完美)。为了方便起见,我们经常用百分比来表示(从0
到100
):
from sklearn import metrics r2 = metrics.r2_score(test[test > 0], predicted[test > 0]) print(\'R2 score (binary neighbors): {:.1%}\'.format(r2)) R2 score (binary neighbors): 29.5%
前面的代码为用户neighbors
计算结果。也就是说,当试图对用户-电影对进行预测时,它会查看对同一部电影进行评分的相似用户,并对他们进行平均。我们可以用同样的代码通过变换输入矩阵来计算电影neighbors
。也就是说,现在我们将寻找由同一用户评分的类似电影,并对它们的评分进行平均。
在在线代码库中,推荐代码被包装在一个名为predict_positive_nn
的函数中,所以我们可以用转置矩阵来调用它,最后转置结果:
predicted = predict_positive_nn(train.T).T r2 = metrics.r2_score(test[test > 0], predicted[test > 0]) print(\'R2 score (binary movie neighbors): {:.1%}\'.format(r2)) R2 score (binary movie neighbors): 29.8%
我们可以看到结果并没有那么不同。
建议的回归方法
邻域的一种替代方法是将推荐表述为回归问题,并应用我们在第 6 章、聚类-查找相关帖子中学习的方法。
我们首先考虑为什么这个问题不适合分类公式。我们当然可以尝试学习一个五级模型,对每个可能的电影等级使用一个等级。然而,这种方法有两个问题:
- 不同的可能错误完全不同。例如,把一部 5 星电影误认为 4 星电影并不像把一部 5 星电影误认为 1 星电影那样严重
- 中间值有意义。即使我们的输入只是整数值,说预测是 4.3 也是完全有意义的。我们可以看到,这是一个不同于 3.5 的预测,即使它们都四舍五入到 4
这两个因素加在一起意味着分类并不适合这个问题。回归框架更适合。
对于一个基本的方法,我们再次有两个选择:我们可以构建特定于电影或特定于用户的模型。在我们的案例中,我们将首先构建用户特定的模型。这意味着,对于每个用户,我们将用户评价的电影作为我们的目标变量。输入是其他用户的评分。我们假设这将为与我们的用户相似的用户提供高价值(或者为喜欢我们的用户不喜欢的相同电影的用户提供负价值)。
设置train
和test
矩阵的方法与之前相同(包括运行标准化步骤)。因此,我们直接跳到学习步骤:
- 首先,我们实例化一个
regression
对象如下(回想一下,在第 2 章、用真实世界的例子分类中,我们已经得出结论,具有自动参数搜索的弹性网是一个很好的通用回归方法):
reg = ElasticNetCV(alphas=[0.0125, 0.025, 0.05, .125, .25, .5, 1., 2., 4.])
- 然后,我们构建一个数据矩阵,其中包含每个用户-电影对的评分。我们将其初始化为训练数据的副本:
filled = train.copy()
- 现在,我们对所有用户进行迭代,每次只根据用户给我们的数据学习一个回归模型:
for u in range(train.shape[0]): curtrain = np.delete(train, u, axis=0) # binary records whether this rating is present bu = binary[u] # fit the current user based on everybody else reg.fit(curtrain[:,bu].T, train[u, bu]) # Fill in all the missing ratings filled[u, ~bu] = reg.predict(curtrain[:,~bu].T)
- 评估方法可以像以前一样进行:
predicted = norm.inverse_transform(filled) r2 = metrics.r2_score(test[test > 0], predicted[test > 0]) print(\'R2 score (user regression): {:.1%}\'.format(r2)) R2 score (user regression): 32.3%
如前所述,我们可以通过使用转置矩阵来修改这段代码以执行电影回归(有关这方面的示例,请参见伴随代码库)。
结合多种方法
我们现在将上述方法结合成一个单一的预测。从直觉上看,这似乎是一个好主意,但我们如何在实践中做到这一点?也许首先想到的是我们可以对预测进行平均。这可能会给出不错的结果,但没有理由认为所有估计的预测都应该被同等对待。可能是其中一个比其他的好。
我们可以尝试加权平均,将每个预测乘以给定的权重,然后将所有预测相加。但是,我们如何找到最佳重量呢?我们当然是从数据中学习的!
Ensemble learning:
We are using a general technique in machine learning that is not just applicable in regression: ensemble learning. We learn an ensemble (that is, a set) of predictors. Then, we combine them to obtain a single output. What is interesting is that we can see each prediction as being a new feature, and we are now just combining features based on training data, which is what we have been doing all along. Note that we are doing this for regression here, but the same reasoning is applicable to classification: you learn several classifiers, then a master classifier, which takes the output of all of them and gives a final prediction. Different forms of ensemble learning differ in how you combine the base predictors.
为了结合这些方法,我们将使用一种称为堆叠学习的技术。想法是你学习一组预测器,然后你使用这些预测器的输出作为另一个预测器的特征。您甚至可以有几个层,其中每个层通过使用前一层的输出作为其预测的特征来学习。请看下图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/ce509d13-0882-4d0f-850b-c6a731f9cfce.jpg
为了适合这个组合模型,我们将训练数据分成两部分。或者,我们可以使用交叉验证(最初的堆叠学习模型是这样工作的)。然而,在这种情况下,我们有足够的数据,通过留出一些来获得良好的估计。
就像我们在拟合超参数时一样,我们需要两层训练/测试分割:第一层,更高级别的分割,然后在训练分割内部,第二层分割可以适合堆叠的学习者。这类似于我们在使用内部交叉验证循环查找超参数值时如何使用多级交叉验证:
train,test = get_train_test(random_state=12) # Now split the training again into two subgroups tr_train,tr_test = load_ml100k.get_train_test(train) tr_predicted0 = predict_positive_nn(tr_train) tr_predicted1 = predict_positive_nn(tr_train.T).T tr_predicted2 = predict_regression(tr_train) tr_predicted3 = predict_regression(tr_train.T).T # Now assemble these predictions into a single array: stack_tr = np.array([ tr_predicted0[tr_test > 0], tr_predicted1[tr_test > 0], tr_predicted2[tr_test > 0], tr_predicted3[tr_test > 0], ]).T # Fit a simple linear regression lr = linear_model.LinearRegression() lr.fit(stack_tr, tr_test[tr_test > 0])
现在,我们将整个过程应用于测试分割并评估:
stack_te = np.array([ tr_predicted0.ravel(), tr_predicted1.ravel(), tr_predicted2.ravel(), tr_predicted3.ravel(), ]).T predicted = lr.predict(stack_te).reshape(train.shape)
评价和以前一样:
r2 = metrics.r2_score(test[test > 0], predicted[test > 0]) print(\'R2 stacked: {:.2%}\'.format(r2)) R2 stacked: 33.15%
堆叠学习的结果比任何单一的方法都要好。很典型的情况是,组合方法是获得少量性能提升的简单方法,但结果并不惊天动地。
通过灵活地组合多种方法,我们可以简单地尝试任何我们想要的想法,方法是将它添加到学习者的组合中,并让系统将其折叠到预测中。例如,我们可以替换最近邻码中的邻域准则。
然而,我们必须小心不要过度填充数据集。事实上,如果我们随机尝试太多的东西,其中一些会在特定的数据集上运行良好,但不会一概而论。即使我们在分割数据,我们也没有严格地交叉验证我们的设计决策。为了有一个好的估计,如果数据丰富,你应该保留一部分数据不动,直到你有一个最终的模型即将投入生产。然后,在这些数据上测试你的模型将会给你一个不偏不倚的预测,告诉你它在现实世界中会有多好的表现。
Of course, collaborative filtering also works with neural networks, but don’t forget to keep validation data available for the testing—or, more precisely, validating—your ensemble model.
篮子分析
到目前为止,当你对用户喜欢一个产品的程度进行数字评分时,我们所研究的方法效果很好。这种类型的信息并不总是可用的,因为它需要消费者的主动行为。
篮子分析是学习推荐的一种替代模式。在这种模式下,我们的数据只包含一起购买的物品;它不包含任何关于单个项目是否被欣赏的信息。即使用户有时会购买他们后悔的商品,平均来说,知道他们的购买会给你足够的信息来建立好的推荐。获得这些数据往往比评级数据更容易,因为许多用户不会提供评级,而购物篮数据是作为购物的副作用产生的。下面的截图向您展示了亚马逊网站上托尔斯泰经典著作《战争与和平》的网页片段,展示了使用这些结果的常见方式:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/50f80b10-5115-445d-b331-2174b8b88273.png
当然,这种学习模式不仅适用于购物篮。它适用于任何一种情况,在这种情况下,您将一组对象放在一起,需要推荐另一组对象。例如,向用户推荐额外的收件人写电子邮件是由 Gmail 完成的,并且可以使用类似的技术来实现(我们不知道 Gmail 内部使用哪些方法;也许他们结合了多种技术,就像我们之前做的那样)。或者,我们可以使用这些方法开发一个应用程序,根据您的浏览历史推荐要访问的网页。即使我们正在处理采购,将客户的所有采购组合到一个篮子中也是有意义的,无论这些项目是一起购买的还是在单独的交易中购买的。这取决于业务环境,但请记住,这些技术是灵活的,在许多情况下都是有用的。
Beer and diapers:
One of the stories that is often mentioned in the context of basket analysis is the diapers and beer story. When supermarkets first started to look at their data, they found that diapers were often bought together with beer. Supposedly, it was the father who would go out to the supermarket to buy diapers and would then pick up some beer as well. There has been much discussion of whether this is true or just an urban myth. In this case, it seems that it is true. In the early 1990s, Osco Drug discovered that, in the early evening, beer and diapers were bought together, and it did surprise the managers who had, until then, never considered these two products to be similar. What is not true is that this led the store to move the beer display closer to the diaper section. Also, we have no idea whether it was really true that fathers were buying beer and diapers together more than mothers (or grandparents).
获得有用的预测
不仅仅是顾客买了 X 也买了 Y ,尽管这是很多在线零售商的推荐用语(见前面给出的 Amazon.com 截图);一个真正的系统不能这样工作。为什么不呢?因为这样的系统会被频繁购买的商品所迷惑,并且会简单地推荐没有任何个性化的流行商品。
例如,在一家超市,许多顾客每次购物都会买面包,或者几乎每次都买(为了论证,我们假设 50%的访问以购买面包结束)。所以,如果你专注于任何特定的物品,比如洗碗机肥皂,看看经常用洗碗机肥皂买的东西,你可能会发现面包经常用洗碗机肥皂买。事实上,只是偶然的机会,假设 50%的情况下,当有人购买洗碗机肥皂时,他们会购买面包。然而,面包经常和其他东西一起买,只是因为人们经常买面包。
我们真正要找的是购买了 X 的客户比没有购买 X 的普通客户更有可能购买 Y 。如果你买洗碗机肥皂,你可能会买面包,但不会超过基线。同样,一家书店,不管你已经买了哪些书,只要简单地推荐畅销书,就不能很好地个性化推荐。
分析超市购物筐
举个例子,我们来看看比利时一家超市的匿名交易构成的dataset
。这个dataset
是由哈塞尔特大学的汤姆·布里斯提供的。出于隐私考虑,数据已被匿名化,因此我们对每个产品只有一个数字,因此每个篮子都由一组数字组成。数据文件可从几个在线来源获得(包括本书的配套网站)。
我们首先加载数据集并查看一些统计数据(这总是一个好主意):
from collections import defaultdict from itertools import chain # File is downloaded as a compressed file import gzip # file format is a line per transaction # of the form \'12 34 342 5...\' dataset = [[int(tok) for tok in line.strip().split()] for line in gzip.open(\'retail.dat.gz\')] # It is more convenient to work with sets dataset = [set(d) for d in dataset] # count how often each product was purchased: counts = defaultdict(int) for elem in chain(*dataset): counts[elem] += 1
我们可以看到下表中总结的结果计数:
| 购买次数 | 产品数量 |
| 就一次 | Two thousand two hundred and twenty-four |
| 2 或 3 | Two thousand four hundred and thirty-eight |
| 4 至 7 岁 | Two thousand five hundred and eight |
| 8 至 15 岁 | Two thousand two hundred and fifty-one |
| 16 岁至 31 岁 | Two thousand one hundred and eighty-two |
| 32 至 63 岁 | One thousand nine hundred and forty |
| 64 至 127 | One thousand five hundred and twenty-three |
| 128 至 511 | One thousand two hundred and twenty-five |
| 512 或更多 | One hundred and seventy-nine |
有很多产品只被买过几次。例如,33%的产品被购买四次或更少。然而,这仅占购买量的 1%。这种很多产品只被少量购买的现象,有时被贴上长尾的标签,随着互联网使得小众商品的库存和销售变得更加便宜,这种现象才变得更加突出。为了能够为这些产品提供建议,我们需要更多的数据。
有一些篮子分析算法的开源实现,但是没有一个能很好地与 scikit-learn 或我们一直在使用的任何其他包集成。因此,我们将自己实现一个经典算法。这个算法叫做 Apriori 算法,有点老(由 Rakesh Agrawal 和 Ramakrishnan Srikant 在 1994 年发表),但它仍然有效(算法当然永远不会停止工作;他们只是被更好的想法所取代)。
Apriori 算法获取一组集合(即您的购物篮),并将非常频繁的集合作为子集返回(即一起成为许多购物篮一部分的项目)。
该算法使用自下而上的方法工作:从最小的候选(由单个元素组成的候选)开始,逐步积累,一次添加一个元素。该算法取一组篮子和应该考虑的最小输入(一个我们称之为minsupport
的参数)。第一步是考虑所有的篮子,只有一个元件,支撑最小。然后,以各种可能的方式将它们结合起来,构建二元篮子。这些被过滤,以便只保留那些支持最少的。然后,考虑所有可能的三元篮,保留那些具有最小支撑的,以此类推。Apriori 的诀窍是,当构建一个更大的篮子时,它只需要考虑那些由更小的集合构建的篮子。
下图给出了算法的示意图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/8ead282c-8762-47e7-b060-a23e1995176e.jpg
我们现在用代码实现这个算法。我们需要定义我们寻求的最低支持:
minsupport = 100
支持是一组产品一起购买的次数。
Apriori 的目标是找到高支持度的项目集。从逻辑上讲,任何具有超过最小支持的项目集只能由本身至少具有最小支持的项目组成:
valid = set(k for k,v in counts.items() if (v >= minsupport))
我们最初的itemsets
是单线态(具有单一元素的集合)。特别是,所有至少得到最低限度支持的单身者都很频繁itemsets
:
itemsets = [frozenset([v]) for v in valid]
我们需要使用以下代码设置一个索引来加快计算速度:
baskets = defaultdict(set) for i, ds in enumerate(dataset): for ell in ds: baskets[ell].add(i)
也就是说,baskets[i]
包含数据集中出现i
的所有元素的索引。现在,我们的循环如下:
itemsets = [frozenset([v]) for v in valid] freqsets = [] for i in range(16): print(i) nextsets = [] tested = set() for it in itemsets: for v in valid: if v not in it: # Create a new candidate set by adding v to it c = (it | frozenset([v])) # check if we have tested it already if c in tested: continue tested.add(c) candidates = set() for elem in c: candidates.update(baskets[elem]) support_c = sum(1 for d in candidates \\ if dataset[d].issuperset(c)) if support_c > minsupport: nextsets.append(c) freqsets.extend(nextsets) itemsets = nextsets if not len(itemsets): break print(\"Finished!\") Finished!
Apriori 算法返回频繁的itemsets
,即出现在某个阈值以上的篮子(由代码中的minsupport
变量给出)。
关联规则挖掘
频繁项集本身并不是很有用。下一步是建立关联规则。因为这个最终目标,篮网分析的整个领域有时被称为关联规则挖掘。
关联规则是一种类型的陈述,如果 X ,那么Y—例如,如果客户购买了战争与和平,那么他们将购买安娜·卡列尼娜。注意规则不是确定性的(并不是所有买 X 的客户都会买 Y ,但总是拼出来就比较麻烦了:如果一个客户买了 X ,他们比基线更有可能买Y;因此,我们说如果 X ,那么 Y ,但我们指的是概率意义上的。
有趣的是,前因和结论都可能包含多个对象:购买了 X 、 Y 、 Z 的客户也购买了 A 、 B 、 C 。多个前因可能会让你做出比单个项目更具体的预测。
只要尝试所有可能的 X 暗示 Y 的组合,你就可以从一个频繁的集合中得到一个规则。很容易生成许多这样的规则。然而,你只想要有价值的规则。因此,我们需要衡量一个规则的价值。一种常用的测量方法叫做升力。升力是通过应用规则获得的概率和基线之间的比率,如下式所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/5ffacd01-8ec7-4b39-a98c-6abaeba21696.png
在上式中, P(Y) 是包含 Y 的所有交易的分数,而 P(Y|X) 是包含 Y 的交易的分数,因为它们还包含 X 。使用电梯有助于避免推荐畅销书的问题;对于一本畅销书来说, P(Y) 和 P(Y|X) 都会很大。因此,电梯将接近 1,该规则将被视为无关紧要。实际上,我们希望升力值至少为 10,甚至可能为 100。
参考以下代码:
minlift = 5.0 nr_transactions = float(len(dataset)) for itemset in freqsets: for item in itemset: consequent = frozenset([item]) antecedent = itemset-consequent base = 0.0 # acount: antecedent count acount = 0.0 # ccount : consequent count ccount = 0.0 for d in dataset: if item in d: base += 1 if d.issuperset(itemset): ccount += 1 if d.issuperset(antecedent): acount += 1 base /= nr_transactions p_y_given_x = ccount/acount lift = p_y_given_x / base if lift > minlift: print(\'Rule {0} -> {1} has lift {2}\' .format(antecedent, consequent,lift))
下表显示了一些结果。计数是指仅包含结果(即购买该产品的基本价格)的交易数量、前因中的所有项目以及前因和后果中的所有项目:
| 先行 | 结果 | 后续计数 | 先行计数 | 先行和后续计数 | 抬起 |
| 1378, 1379, 1380
| One thousand two hundred and sixty-nine | 279 人(0.3%) | Eighty | Fifty-seven | Two hundred and twenty-five |
| 48, 41, 976
| One hundred and seventeen | 1026 人(1.1%) | One hundred and twenty-two | Fifty-one | Thirty-five |
| 48, 41, 1,6011
| Sixteen thousand and ten | 1316 人(1.5%) | One hundred and sixty-five | One hundred and fifty-nine | Sixty-four |
例如,我们可以看到,在 80 笔交易中,1378、1379 和 1380 是一起购买的。其中 57 也包括 1269,所以估计的条件概率是 57/80 ≈ 71 百分比。相比之下,所有交易中只有 0.3%包含 1269,这让我们提升了 255。
为了能够做出相对可靠的推断,需要在这些计数中有相当数量的事务,这就是为什么我们必须首先选择频繁项集。如果我们从一个不经常出现的项目集中生成规则,那么数量将非常少;因此,相对值将毫无意义(或者受到非常大的误差线的影响)。
请注意,从这个数据集中发现了更多的关联规则:该算法发现了 1030 个规则(要求支持至少 80 个篮子,最小提升量为 5)。与现在的网络相比,这仍然是一个很小的数据集。对于包含数百万个事务的数据集,您可以预期生成数千个规则,甚至数百万个。
然而,对于每个客户或产品,在任何给定的时间,只有几个规则是相关的。所以每个客户只收到少量的推荐。
更高级的篮子分析
现在有其他算法比 Apriori 运行得更快。我们之前看到的代码很简单,对我们来说已经足够好了,因为我们只有大约 100,000 个事务。如果我们有几百万,也许值得使用更快的算法。不过,请注意,学习关联规则通常可以在离线状态下完成,在离线状态下,效率并不是很重要。
您还可以使用一些方法来处理时间信息,从而产生考虑到您购买顺序的规则。举个例子,有人为一个大型聚会购买用品,可能会回来拿垃圾袋。第一次去的时候提议用垃圾袋可能是有道理的。然而,向每个购买垃圾袋的人提议派对用品是没有意义的。
摘要
在本章中,我们从使用回归进行评级预测开始。我们看到了两种不同的方法,然后通过学习一组权重将它们组合成一个预测。这种集成学习技术——特别是堆叠学习——是一种可以在许多情况下使用的通用技术,而不仅仅是用于回归。它允许你组合不同的想法,即使它们的内部机制完全不同——你可以组合它们的最终输出。
在这一章的后半部分,我们转换了话题,看了另一种产生推荐的模式:购物篮分析,或关联规则挖掘。在这种模式下,我们试图发现购买 X 的客户可能对 Y 感兴趣的形式的(概率)关联规则。这利用了仅从销售中生成的数据,而不需要用户对项目进行数字评分。这在 scikit-learn 中暂时没有,所以我们编写了自己的代码。
如果你正在使用关联规则挖掘,那么你需要注意不要简单地向每个用户推荐畅销书(否则,个性化的意义何在?).为了做到这一点,我们学习了测量规则相对于基线的值,使用了一种称为规则提升的度量。
在第八章、人工神经网络和深度学习中,我们将最终用 TensorFlow 深入学习。我们将学习它的应用编程接口,然后继续学习卷积网络(以及它们如何彻底改变图像处理),然后学习递归网络。
八、人工神经网络与深度学习
神经网络正在引领当前的机器学习趋势。无论是 Tensorflow、Keras、CNTK、PyTorch、Caffee 还是其他任何包,它们目前都取得了其他算法很少取得的成果,尤其是在图像处理等地方。随着快速计算机和大数据的出现,20 世纪 70 年代设计的神经网络算法现在可以使用了。即使在十年前,最大的问题是你需要大量的训练数据,而这些数据是不可用的,同时,即使你有足够的数据,训练模型所需的时间也太多了。这个问题现在差不多解决了。
这些年的主要改进是神经网络架构。用于更新神经网络的反向传播算法与以前大致相同,但是结构已经有了许多改进,例如卷积层而不是密集层,或者,长短期记忆 ( LSTM )用于规则递归层。
下面是我们将遵循的计划:首先深入了解 TensorFlow 及其 API,然后我们将它应用于卷积神经网络进行图像处理,最后我们将处理递归神经网络(特别是被称为 LSTM 的味道)进行图像处理和文本处理。
谈论机器学习速度主要是关于神经网络速度。为什么呢?因为神经网络基本上是矩阵乘法和并行数学函数——图形处理器非常擅长的模块。
使用张量流
我们已经看到了一些使用 TensorFlow 的例子,现在是时候更多地了解它是如何工作的了。
首先,这个名字来源于这样一个事实,即张量流使用张量(二维以上的矩阵)进行所有计算。所有函数都作用于这些对象,返回张量或行为类似张量的运算,并为所有对象定义新的名称。名字的第二部分来自张量之间数据流动的基础图。
神经网络的灵感来自于大脑的工作方式,但它并不像神经网络使用的模型那样工作。是的,每个神经元都与许多其他神经元相连,但输出不是输入乘以转换矩阵加上激活函数内部反馈的偏置的乘积。此外,神经网络具有层次(深度学习指的是具有多个所谓的隐藏层的神经网络,意味着既没有输入也没有输出),其体系结构具有严格的进展。大脑在各处都有联系并持续进化,而神经网络对于给定的输入和给定的时刻总是有稳定的输出(直到我们得到一个新的节拍,正如我们将在循环网络中看到的)。
现在让我们深入了解一下 TensorFlow 图形应用编程接口。
TensorFlow API
最好的开始方式是看一下编程环境:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/1c39946b-8c9b-46e2-918d-aa03592dc147.png
我们显然对 Python 堆栈感兴趣,我们将主要关注层和度量。数据集很有趣,但其中许多来自外部贡献,有些是针对移除的。scikit learn API 被认为是更面向未来的,所以我们就不看了。
估计器是更高层次的 API,但是它们没有 scikit 学习到的那样发达。当我们开发新的网络时,能够调试它们并检查它们肠道内的信息在中间的应用编程接口中比在顶部的应用编程接口中更容易,尽管所有张量都有名字的事实使得在估计器应用编程接口之外获得这些信息成为可能。
很多在线教程仍然直接使用较低的 API,我们在我们的回归示例中通过直接调用tf.matmult
来使用它。我们认为使用中级或高级应用编程接口比其他应用编程接口更好,即使它们有时看起来更灵活,更接近您认为的需求。
图形
正如我们从张量流的定义中看到的,图是张量流的核心。默认图包含对象(占位符、变量或常量)之间的结构以及这些对象的类型(变量例如是可训练变量,所有可训练变量都可以通过调用tf.trainable_variables()
来检索)。
可以通过使用with
构造来更改默认图形:
g = tf.Graph()with g.as_default(): c = tf.constant(\"Node in g\")
因此,每次我们调用 TensorFlow 函数时,我们都会向默认图中添加节点(无论我们是否在一个块中)。一张图表本身没有任何作用。当我们创建新图层、使用度量或创建占位符时,实际上不会执行任何操作。我们唯一要做的就是在图上添加节点。
一旦我们有了一个有趣的图,我们需要在所谓的会话中执行。这是 TensorFlow 实际执行代码的唯一地方,也是可以从图中检索值的唯一地方。
Just as for the graph, variables, placeholders, and so on will be put on the best possible device. This device will be the CPU for all platforms, at the time of writing, that’s Linux for HIP-capable AMD GPUs or nVidia GPU for Linux and Windows.
可以使用以下命令将它们固定在特定设备上:
with tf.device(\"/device:CPU:0\"):
图形的不同部分使用相同的名称有时很有趣。为了实现这一点,我们可以使用name_scope
为名称加上路径前缀。当然,它们可以递归使用:
var = tf.constant([0, 1, 2, 3])with tf.name_scope(\"section\"): mean = tf.reduce_mean(var)
会议
是时候多学一点关于会话的知识了。正如我们前面看到的,TensorFlow 只执行会话中的操作。
会话最简单的用法如下:
with tf.Session() as sess: sess.run(tf.global_variables_initializer()) sess.run([mean], feed_dict={})
这将通过在会话中调用变量的初始值来初始化变量。在神经网络中,我们不能让所有变量都以零值开始,尤其是不同层中的权重(有些,如偏差,可以初始化为 0)。初始值设定项是最重要的,要么直接为显式变量设置,要么在调用中级函数时是隐式的(参见 TensorFlow 中可用的不同初始值设定项,并在我们所有的示例中使用它们)。
TensorFlow 还使用一种外部方式来获取报告,称为摘要。它们生活在tf.summary
模块中,可以跟踪张量,并对它们进行预处理:
tf.summary.histogram(var)tf.summary.scalar(\'mean\', tf.reduce_mean(var))
然后,所有这些摘要报告都可以在会话运行期间写入和检索,并由一个特殊对象保存:
merged = tf.summary.merge_all()writer = tf.summary.FileWriter(path/to/log-directory)with tf.Session() as sess: summary, _ = sess.run([merged, train_step], feed_dict={}) writer.add_summary(summary, i)
Tensorboard is a tool provided with TensorFlow that allows us to display these summaries. It can be launched with tensorboard --logdir=path/to/log-directory
.
如果我们不想直接使用会话,可以使用估计器类。
从一个估计量,我们可以调用它的方法训练,它把一个数据生成器和我们想要运行的步骤数作为一个参数。例如,这可能是:
def input_fn(): features = {\'SepalLength\': np.array([6.4, 5.0]), \'SepalWidth\': np.array([2.8, 2.3]), \'PetalLength\': np.array([5.6, 3.3]), \'PetalWidth\': np.array([2.2, 1.0])} labels = np.array([2, 1]) return features, labelsestimator.train(input_fn=input_fn , steps=STEPS)
同样,我们可以使用 test 从模型中获得结果:
estimator.test(input_fn=input_train)
如果你想使用比张量流已经提供的简单估算器更多的东西,我们建议遵循张量流网站上的教程:https://www.tensorflow.org/get_started/custom_estimators。
有用的操作
在所有之前的张量流模型中,我们遇到了在张量流中创建层的函数。有几层或多或少是不可避免的。
第一个是tf.dense
,将所有输入连接到一个新的图层。我们在自动编码器示例中看到了它们,它们将张量(变量,占位符…)然后units
输出单位的数量。默认情况下,它也有偏差,这意味着图层计算inputs * weights + bias
。
我们后面会看到的另一个重要的层是conv2d
。它计算图像上的卷积,这一次需要filters
来指示输出图层中的节点数量。这就是卷积神经网络的定义。以下是卷积的常用公式:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/95e9c4ce-6d7c-4edf-ac10-6a97de1754dc.png
The standard name for the tensor of coefficients in the convolution is called a kernel.
让我们看看其他几层:
dropout
会在训练阶段随机放一些权重到零。这在复杂的深度学习网络中非常重要,以防止它过度拟合。我们稍后还会看到。max_pooling2d
是卷积层非常重要的补充。它选择二维形状上输入的最大值。还有一个一维版本,在密集层之后工作。
所有层都有一个activation
参数。这种激活将线性运算转化为非线性运算。让我们看看tf.nn
模块中最有用的:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/1994a9d7-f50b-4e8c-aafa-71ffb351e873.png
正如我们之前看到的,scikit learn 提供了许多计算精度、曲线等的指标。TensorFlow 在tf.metrics
模块中提供了类似的操作。
保存和恢复神经网络
有两种方法可以存储训练好的神经网络以备将来使用,然后恢复它。我们将在卷积神经网络的例子中看到它们实现了这一点。
第一个住在tf.train
。它是用以下语句创建的:
saver = tf.train.Saver(max_to_keep=10)
然后每个训练步骤都可以保存为:
saver.save(sess, \'./classifier\', global_step=step)
这里保存了完整的图形,但可能只保存了其中的一部分。我们在这里保存所有内容,并且只保留最后 10 次保存,并且我们用我们所在的步骤来后缀保存的名称。
假设我们用saver.save(sess, \'./classifier-final\')
保存了最后的训练步骤。我们知道,我们首先必须恢复图形状态:
new_saver = tf.train.import_meta_graph(\"classifier-final.meta\")
这没有恢复变量状态,为此我们不得不调用:
new_saver.restore(sess, tf.train.latest_checkpoint(\'./\'))
Be aware that only the graph is restored. If you have Python variables pointing to nodes in this graph, you need to restore them before you can use them. This is true for placeholders and operations.
我们还必须恢复一些变量:
graph = tf.get_default_graph()training_tf = graph.get_tensor_by_name(\'is_training:0\')
这也是对所有张量(占位符、运算等)使用专有名称的一个很好的理由,因为我们需要在恢复图形时使用它们的名称来再次获得对它们的引用。
另一种机制建立在这种机制的基础上,功能更强大,但我们将展示模仿简单机制的基本用法。我们首先创建通常被称为builder
的东西:
builder = tf.saved_model.builder.SavedModelBuilder(export_dir)
The export_dir
folder is created by the builder here. If it already exists, you have to remove it before creating a new saved model.
现在训练结束后,我们可以称之为拯救网络的状态:
builder.add_meta_graph_and_variables(sess, [tf.saved_model.tag_constants.TRAINING])
显然,我们可以在这个对象中保存多个网络,具有更多的属性,但是,在我们的例子中,我们只需要调用一个函数来恢复状态:
tf.saved_model.loader.load(sess, [tf.saved_model.tag_constants.TRAINING], export_dir)
训练神经网络
我们没怎么讨论过训练神经网络。基本上所有的优化都是梯度下降,问题是我们要用什么步长,要不要考虑之前的梯度?
当计算一个梯度时,还有一个问题,我们是只对一个新样本这样做,还是同时对多个样本(批次)这样做。基本上,我们几乎从不一次只输入一个样本(随着批次大小的变化,所有占位符都有一个设置为None
的第一维,表示它将是动态的)。
这也要求创建一个特殊的层batch_normalization
,它可以缩放梯度(向上或向下,这样层可以以有意义的方式更新,因此批量大小在这里很重要),并且在一些网络架构中,它将是强制性的。这一层还有两个学习参数,即均值和标准差。如果它们不重要,可以实现一个更简单的批处理标准化层,并将在第 12 章、计算机视觉中的示例中使用。
我们之前用的优化器是GradientDescentOptimizer
。这是一个简单的梯度下降与固定的步骤。这是非常脆弱的,因为步长严重依赖于数据集和我们使用的模型。
另一个非常重要的是AdamOptimizer
。它是目前最有效的优化器之一,因为它基于前一个缩放新的梯度(试图模仿成本函数减少的牛顿方法的 hessian 缩放)。
另一个值得一提的是RMSPropOptimizer
。这里,额外的技巧是动量。动量表示新渐变在新渐变之上使用了先前渐变的一部分。
The size of the gradient step, or learning rate, is crucial. The selection of an adequate for it often requires some know-how. The rate must be small enough so that the optimizations makes the network better, but big enough to have efficient first iterations. The improvement of the training is supposed to be fast for the first iterations and then improve slowly (it is globally fitting an often requires some know-how. The rate must be small enough so that the optimizations makes the network better, but big enough to have efficient first iterations. The improvement of the training is supposed to be fast for the first iterations and then improve slowly (it is globally fitting an e-t curve).
To avoid over-generalization, it is sometimes advised to stop optimization early (called early stopping), when the improvements are slow. In this context, using collaborative filtering can also achieve better results.
Additional information can be found at http://ruder.io/optimizing-gradient-descent/.
卷积神经网络
不到十年前,神经网络在图像处理方面还不是最好的。除了数据和中央处理器的能力,原因是研究人员使用了密集的层。当堆叠几个层和连接几千个像素到一千个隐藏单元的密集层时,我们最终得到了一个非凸成本函数来优化,该函数有数百万个参数。
因此,维度的诅咒是一个非常大的问题,即使是最大的数据库也可能不够。但是让我们回到引言。机器学习不仅仅是训练一个模型,它也是关于特征处理的。在图像处理中,人们使用许多不同的工具来从图像中提取特征,但是所有这些预处理工作流的一个常见工具是过滤。
现在,让我们回到神经网络。如果我们能把这些过滤器植入神经网络呢?那么问题就是要知道哪些过滤器是最好的。这就是卷积网络的用武之地:卷积层创建特征,然后密集层完成它们的工作(分类、回归等等)。
不像密集层那样有数百万个系数,我们创建一个输出像素的图像,每个像素有固定数量的单位。然后,这些单元中的每一个都有固定数量的权重,并且它们对于输出图像中的所有像素都是相同的。当我们在输出图像中从一个像素移动到另一个像素时,我们也在输入图像中以相同的方式移动连接(可能是一个步幅):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/b87628c7-140d-4fe7-8545-8872e57d5a71.png
所以一个 conv2d 图层的权重有一个维度[kernel_size_1, kernel_size_2, filters]
维度的核,如果考虑权重的数量,这个核是非常小的!这远远少于一千个数字,而不是超过一百万个。这是可以训练的,甚至可以通过查看这些权重来查看哪些特征与我们提出的问题相关。我们应该能够看到像梯度滤波器、索贝尔滤波器或者看曲线的滤波器这样简单的东西。
既然我们有了所有这些零件,我们就可以把它们放在一起了。我们将再次尝试使用手数字数据集,并将这些图像分为 10 类(每个数字一类)。我们还将保存训练好的模型,并用前面看到的两种方法进行恢复。
让我们从一些简单的导入和一些超参数开始:
import tensorflow as tfimport numpy as npfrom sklearn.model_selection import train_test_splitn_epochs = 10learning_rate = 0.0002batch_size = 128export_dir = \"data/classifier-mnist\"image_shape = [28,28,1]step = 1000dim_W3 = 1024dim_W2 = 128dim_W1 = 64dropout_rate = 0.1
我们将训练神经网络 10 个时期(因此我们将通过完整的训练数据集10
次),我们将使用0.0002
的学习率,128
的批次大小(因此我们将一次用128
图像训练模型),然后我们将使用64
卷积滤波器,然后是128
滤波器,最后是最后一层中的1024
节点,在最后 10 个节点之前,这将给出我们的分类结果。最后,1,024 节点层还将有一个速率为0.1
的丢弃部分,这意味着在训练期间,我们将始终在该层任意将 102 节点输出设置为 0:
from sklearn.datasets import fetch_mldatamnist = fetch_mldata(\'MNIST original\')mnist.data.shape = (-1, 28, 28)mnist.data = mnist.data.astype(np.float32).reshape( [-1, 28, 28, 1]) / 255.mnist.num_examples = len(mnist.data)mnist.labels = mnist.target.astype(np.int64)X_train, X_test, y_train, y_test = train_test_split(mnist.data, mnist.labels, test_size=(1\\. / 7.))
我们现在获得数据,重塑它,改变它的类型,并保留 60,000 张图像用于培训,10,000 张图像用于测试。标签将为int64
,因为这是我们将用于自定义检查功能的标签。我们不需要在一个热数组中转换标签,因为张量流已经有了一个处理这个问题的函数。无需添加超出要求的处理!
Why a four-dimension matrix? The first dimension, -1
, is our batch size, and it will be dynamic. The second two dimensions are for the width and height of our image. The final one is the number of input channels, here just 1
.
让我们创建我们的卷积神经网络类:
class CNN(): def __init__( self, image_shape=(28,28,1) dim_W3=1024, dim_W2=128, dim_W1=64, classes=10 ): self.image_shape = image_shape self.dim_W3 = dim_W3 self.dim_W2 = dim_W2 self.dim_W1 = dim_W1 self.classes = classes
我们为我们的CNN
创建一个类,并在本地保存一些我们之前设置的参数:
def create_conv2d(self, input, filters, kernel_size, name): layer = tf.layers.conv2d( inputs=input, filters=filters, kernel_size=kernel_size, activation=tf.nn.leaky_relu, name=\"Conv2d_\" + name, padding=\"same\") return layer
这种方法使用我们之前看到的参数,以及filters
和kernel_size
来创建卷积层。我们将输出激活设置为一个泄漏的relu
,因为它为这些情况给出了很好的结果。
The padding
parameter can be same
or precise
. The second option relates to the convolution equation. When we don’t want to have partial convolutions (on the edges of the image), this is the option we want to use.
def create_maxpool(self, input, name): layer = tf.layers.max_pooling2d( inputs=input, pool_size=[2,2], strides=2, name=\"MaxPool_\" + name) return layer
最大池层也非常简单。我们希望在 2x2 像素范围内获得最大值,输出大小将是原始图像在所有方向上除以2
(因此步幅等于2
):
def create_dropout(self, input, name, is_training): layer = tf.layers.dropout( inputs=input, rate=dropout_rate, name=\"DropOut_\" + name, training=is_training) return layer
我们在这个例子中介绍的脱落层有一个额外的参数,一个名为is_training
的占位符。当我们测试数据时(或当我们在训练后使用模型时),停用该层将非常重要:
def create_dense(self, input, units, name, is_training): layer = tf.layers.dense( inputs=input, units=units, name=\"Dense\" + name, ) layer = tf.layers.batch_normalization( inputs=layer, momentum=0, epsilon=1e-8, training=is_training, name=\"BatchNorm_\" + name, ) layer = tf.nn.leaky_relu(layer, name=\"LRELU_\" + name) return layer
我们的致密层比普通层更复杂。我们在激活之前添加了一个batch_normalization
步骤,它将根据批次大小缩放我们的梯度。那里还有一个使用动量的选项,使得优化类似于RMSProp
:
def discriminate(self, image, training): h1 = self.create_conv2d(image, self.dim_W3, 5, \"Layer1”) h1 = self.create_maxpool(h1, \"Layer1\") h2 = self.create_conv2d(h1, self.dim_W2, 5, \"Layer2\") h2 = self.create_maxpool(h2, \"Layer2\") h2 = tf.reshape(h2, (-1, self.dim_W2 * 7 * 7)) h3 = self.create_dense(h2, self.dim_W1, \"Layer3\", train-ing) h3 = self.create_dropout(h3, \"Layer3\", training) h4 = self.create_dense(h3, self.classes, \"Layer4”, train-ing) return h4
现在我们已经有了网络的所有独立模块,我们可以将它们放在一起。所以会是:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/b72ea0dc-22eb-48a8-ab02-991d28cb54e5.png
让我们开始创建我们的模型:
def build_model(self): image = tf.placeholder(tf.float32, [None]+self.image_shape, name=\"image\") Y = tf.placeholder(tf.int64, [None], name=\"label\") training = tf.placeholder(tf.bool, name=\"is_training\") probabilities = self.discriminate(image, training) cost = tf.reduce_mean( tf.nn.sparse_softmax_cross_entropy_with_logits(labels=Y, logits=probabilities)) accuracy = tf.reduce_mean( tf.cast(tf.equal(tf.argmax(probabilities, axis=1), Y), tf.float32), name=\" accuracy\") return image, Y, cost, accuracy, probabilities, training
为输入图像添加一个占位符,为标签和训练添加另一个占位符,我们现在使用sparse_softmax_cross_entropy_with_logits
成本函数,它将单值labels
数组和名为logits
的张量(密集层的输出)作为参数。当我们一次只有一个活动标签时,这个功能非常好(例如,它非常适合分类,但不适用于图像注释)。
现在是时候使用这个新类了:
cnn_model = CNN( image_shape=image_shape, dim_W1=dim_W1, dim_W2=dim_W2, dim_W3=dim_W3, )image_tf, Y_tf, cost_tf, accuracy_tf, output_tf, training_tf = cnn_model.build_model()train_step = tf.train.AdamOptimizer(learning_rate, beta1=0.5).minimize(cost_tf)saver = tf.train.Saver(max_to_keep=10)builder = tf.saved_model.builder.SavedModelBuilder(export_dir)
我们用它来实例化我们的优化器(这里是Adam
),并借此机会构建我们的模型序列化器:
accuracy_vec = []with tf.Session() as sess: sess.run(tf.global_variables_initializer()) for epoch in range(n_epochs): permut = np.random.permutation(len(X_train)) print(\"epoch: %i\" % epoch) for j in range(0, len(X_train), batch_size): if j % step == 0: print(\" batch: %i\" % j) batch = permut[j:j+batch_size] Xs = X_train[batch] Ys = y_train[batch] sess.run(train_step, feed_dict={ training_tf: True, Y_tf: Ys, image_tf: Xs }) if j % step == 0: temp_cost, temp_prec = sess.run([cost_tf, accura-cy_tf], feed_dict={ training_tf: False, Y_tf: Ys, image_tf: Xs }) print(\" cost: %f\\n prec: %f\" % (temp_cost, temp_prec)) saver.save(sess, \'./classifier\', global_step=epoch) saver.save(sess, \'./classifier-final\') builder.add_meta_graph_and_variables(sess, [tf.saved_model.tag_constants.TRAINING])builder.save()Epoch #-1 train accuracy = 0.068963 test accuracy = 0.071796Result for the 10 first training images: [0 8 9 9 7 6 3 5 1 3]Reference for the 10 first training images: [9 8 4 4 9 8 1 8 2 5]epoch: 0 batch: 0 cost: 1.319493 prec: 0.687500 batch: 16000 cost: 0.452003 prec: 1.000000 batch: 32000 cost: 0.383446 prec: 1.000000 batch: 48000 cost: 0.392471 prec: 0.992188Epoch #0 train accuracy = 0.991166 test accuracy = 0.986650#...Epoch #9 train accuracy = 0.999833 test accuracy = 0.991693Result for the 10 first training images: [9 8 4 4 9 3 1 8 2 5]Reference for the 10 first training images: [9 8 4 4 9 8 1 8 2 5]
这是我们在前面的例子中遵循的通常模式;我们刚刚添加了中间层的保存程序。请注意,构建器要求在会话结束后进行最后一次save()
调用。
不经过任何训练,算法的准确率在 1/10 左右,这是随机网络会达到的效果。经过 10 个时代,我们的训练和测试精度接近 1。让我们看看训练和测试错误是如何随之演变的:
from matplotlib import pyplot as pltaccuracy = np.array(accuracy_vec)plt.semilogy(1 - accuracy[:,0], \'k-\', label=\"train\")plt.semilogy(1 - accuracy[:,1], \'r-\', label=\"test\")plt.title(\'Classification error per Epoch\')plt.xlabel(\'Epoch\')plt.ylabel(\'Classification error\')plt.legend()plt.show()
参考下图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/2a171300-20ad-49b4-b785-9a8363783271.png
显然,更多的训练时期会降低训练误差,但在少数时期后,测试误差(泛化能力)不会进化。这意味着没有必要在这上面花更多的时间。但也许改变一些参数会有所帮助?还是不同的激活功能?
当我们保存训练好的网络时,我们可以用两种方法恢复它:
tf.reset_default_graph()new_saver = tf.train.import_meta_graph(\"classifier-final.meta\") with tf.Session() as sess: new_saver.restore(sess, tf.train.latest_checkpoint(\'./\')) graph = tf.get_default_graph() training_tf = graph.get_tensor_by_name(\'is_training:0\') Y_tf = graph.get_tensor_by_name(\'label:0\') image_tf = graph.get_tensor_by_name(\'image:0\') accuracy_tf = graph.get_tensor_by_name(\'accuracy:0\') output_tf = graph.get_tensor_by_name(\'LeakyRELU_Layer4/Maximum:0\') show_train(sess, 0) # Function defined in the support notebookINFO:tensorflow:Restoring parameters from ./classifier-finalEpoch #0 train accuracy = 0.999833 test accuracy = 0.991693Result for the 10 first training images: [9 8 4 4 9 3 1 8 2 5]Reference for the 10 first training images: [9 8 4 4 9 8 1 8 2 5]
而第二种方法:
tf.reset_default_graph()with tf.Session() as sess: tf.saved_model.loader.load(sess, [tf.saved_model.tag_constants.TRAINING], export_dir) graph = tf.get_default_graph() training_tf = graph.get_tensor_by_name(\'is_training:0\') Y_tf = graph.get_tensor_by_name(\'label:0\') image_tf = graph.get_tensor_by_name(\'image:0\') accuracy_tf = graph.get_tensor_by_name(\'accuracy:0\') output_tf = graph.get_tensor_by_name(\'LeakyRELU_Layer4/Maximum:0\') show_train(sess, 0)INFO:tensorflow:Restoring parameters from b\'data/classifier-mnist/variables/variables\' Epoch #0 train accuracy = 0.999833 test accuracy = 0.991693 Result for the 10 first training images: [9 8 4 4 9 3 1 8 2 5] Reference for the 10 first training images: [9 8 4 4 9 8 1 8 2 5]
它们都返回了与我们在训练后得到的相同的训练和测试错误,所以我们可以重用它来进行额外的分类。
现在是时候解决另一种类型的网络,递归神经网络。
递归神经网络
我们之前看到的所有网络都有一层向另一层提供数据,并且没有环路。循环网络自身循环,因此发生的情况是输出的新值也依赖于节点过去的内部状态及其输入。这可以总结为下图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/52c6d9a9-4cb4-43bc-acb0-c89d79f6d4ab.png
理论上,这些网络是可以训练的,但这是一项艰巨的任务,尤其是在文本预测中,当一个新单词可能依赖于早已消失的其他单词时(想想天空中的云,其中预测的单词 sky 依赖于过去三个单词的云)。
More information on this problem can be found by looking up “vanishing gradient in recurrent neural networks” on your favorite search engine.
因此,开发了没有这些问题的其他体系结构。最主要的一个叫 LSTM。这个自相矛盾的名字反映了它的工作原理。首先,它有两种内部状态,如下图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-ml-sys-py/img/e4f70939-2e80-45c1-a0b0-a6e8da164a74.png
Image adapted from: http://colah.github.io/posts/2015-08-Understanding-LSTMs/
内部状态是我们设置的输入和内部状态的非线性的混合。这方面有一些进展,但对于我们这里的应用来说已经足够好了。
如果我们把它和金融中使用的隐马尔可夫模型(AR(n)或更复杂的滤波器)进行比较,这个模型是非线性的。就像卷积层一样,LSTM 层将从输入信号中提取特征,然后密集层将做出最终决定(在我们的示例中是分类)。
LSTM 预测文本
我们对 LSTM 的第一个测试将是文本预测。我们的网络会学习一个短语中的下一个单词,就像我们在学校背一首诗一样。在这里,我们只是把它训练在一首小诗上,但是如果这样的网络是训练在一个作家的全文上,有更多的容量(所以有更多的层次,可能更大的层次),它可以学习他们的风格,像作家一样写作。
让我们存储我们的寓言:
text=\"\"\"A slave named Androcles once escaped from his master and fled to the forest. As he was wandering about there he came upon a Lion lying down moaning and groaning. At first he turned to flee, but finding that the Lion did not pursue him, he turned back and went up to him.As he came near, the Lion put out his paw, which was all swollen and bleeding, and Androcles found that a huge thorn had got into it, and was causing all the pain. He pulled out the thorn and bound up the paw of the Lion, who was soon able to rise and lick the hand of Androcles like a dog. Then the Lion took Androcles to his cave, and every day used to bring him meat from which to live.But shortly afterwards both Androcles and the Lion were captured, and the slave was sentenced to be thrown to the Lion, after the latter had been kept without food for several days. The Emperor and all his Court came to see the spectacle, and Androcles was led out into the middle of the arena. Soon the Lion was let loose from his den, and rushed bounding and roaring towards his victim.But as soon as he came near to Androcles he recognised his friend, and fawned upon him, and licked his hands like a friendly dog. The Emperor, surprised at this, summoned Androcles to him, who told him the whole story. Whereupon the slave was pardoned and freed, and the Lion let loose to his native forest.\"\"\"
我们可以去掉标点符号并标记它:
training_data = text.lower().replace(\",\", \"\").replace(\".\", \"\").split()
我们现在可以通过索引单词,然后创建单词和整数之间的映射,将标记(或单词)转换为整数,反之亦然(也称为单词包)。我们还可以通过将表示文本的令牌数组转换为整数数组来获得一些时间,整数是映射到单词的索引:
def build_dataset(words): count = list(set(words)) dictionary = dict() for word, _ in count: dictionary[word] = len(dictionary) reverse_dictionary = dict(zip(dictionary.values(), diction-ary.keys())) return dictionary, reverse_dictionarydictionary, reverse_dictionary = build_dataset(training_data)training_data_args = [dictionary[word] for word in training_data]
我们的主层RNN
不是 TensorFlow 的一部分,而是contrib
包的一部分。创建它需要多行,但它是不言自明的。我们最终得到了一个密集的层,其输出节点与令牌一样多:
import tensorflow as tftf.reset_default_graph()from tensorflow.contrib import rnndef RNN(x): # Generate a n_input-element sequence of inputs # (eg. [had] [a] [general] -> [20] [6] [33]) x = tf.split(x,n_input,1) # 1-layer LSTM with n_hidden units. rnn_cell = rnn.BasicLSTMCell(n_hidden) # generate prediction outputs, states = rnn.static_rnn(rnn_cell, x, dtype=tf.float32) # there are n_input outputs but we only want the last output return tf.layers.dense(inputs = outputs[-1], units = vocab_size)x = tf.placeholder(tf.float32, [None, n_input])y = tf.placeholder(tf.int64, [None])
让我们添加我们的hyper
参数:
import randomimport numpy as npvocab_size = len(dictionary)# Parameterslearning_rate = 0.001training_iters = 50000display_step = 1000# number of inputs (past words that we use)n_input = 3# number of units in the RNN celln_hidden = 512
我们准备好创建网络和我们的优化cost
功能:
pred = RNN(x)cost = tf.reduce_mean( tf.nn.sparse_softmax_cross_entropy_with_logits(logits=pred, labels=y))optimizer = tf.train.RMSPropOptimizer( learning_rate=learning_rate).minimize(cost)correct_pred = tf.equal(tf.argmax(pred,1), y)accuracy = tf.reduce_mean(tf.cast(correct_pred, tf.float32))
我们开始训练吧:
with tf.Session() as session: session.run(tf.global_variables_initializer()) step = 0 offset = random.randint(0,n_input+1) end_offset = n_input + 1 acc_total = 0 loss_total = 0 while step < training_iters: # Batch with just one sample. Add some randomness on se-lection process. if offset > (len(training_data)-end_offset): offset = random.randint(0, n_input+1) symbols_in_keys = [ [training_data_args[i]] for i in range(offset, offset+n_input) ] symbols_in_keys = np.reshape(np.array(symbols_in_keys), [1, n_input]) symbols_out_onehot = [training_data_args[offset+n_input]] _, acc, loss, onehot_pred = session.run( [optimizer, accu-racy, cost, pred], feed_dict={x: sym-bols_in_keys, y: symbols_out_onehot}) loss_total += loss acc_total += acc if (step+1) % display_step == 0: print((\"Iter= %i , Average Loss= %.6f,\" + \" Average Accuracy= %.2f%%\") % (step+1, loss_total/display_step, 100*acc_total/display_step)) acc_total = 0 loss_total = 0 symbols_in = [training_data[i] for i in range(offset, offset + n_input)] symbols_out = training_data[offset + n_input] symbols_out_pred = reverse_dictionary[ np.argmax(onehot_pred, axis=1)[0]] print(\"%s - [%s] vs [%s]\" % (symbols_in, symbols_out, symbols_out_pred)) step += 1 offset += (n_input+1)Iter= 1000 , Average Loss= 4.034577, Average Accuracy= 11.50%[\'shortly\', \'afterwards\', \'both\'] - [androcles] vs [to]Iter= 2000 , Average Loss= 3.143990, Average Accuracy= 21.10%[\'he\', \'came\', \'upon\'] - [a] vs [he]Iter= 3000 , Average Loss= 2.145266, Average Accuracy= 44.10%[\'and\', \'the\', \'slave\'] - [was] vs [was]…Iter= 48000 , Average Loss= 0.442764, Average Accuracy= 87.90%[\'causing\', \'all\', \'the\'] - [pain] vs [pain]Iter= 49000 , Average Loss= 0.507615, Average Accuracy= 85.20%[\'recognised\', \'his\', \'friend\'] - [and] vs [and]Iter= 50000 , Average Loss= 0.427877, Average Accuracy= 87.10%[\'of\', \'androcles\', \'like\'] - [a] vs [a]
准确性远非很好,但对于一个小的试验来说,它已经相当有趣了。通过使用pred
功能,我们可以要求网络基于前面会话中的三个输入单词生成一个新单词:
symbols_in_keys = [ [‘causing’], [‘all’], [‘the’]]symbols_in_keys = np.reshape(np.array(symbols_in_keys), [1, n_input])onehot_pred = session.run(pred, feed_dict={x: sym-bols_in_keys})print(“Estimate is: %s” % reverse_dictionary[np.argmax(onehot_pred, axis=1)[0]])Estimate is: pain
一个有趣的问题是,如果有更多的训练时间会发生什么?如果我们将中间层改为使用多个 LSTM 层,会发生什么?
递归神经网络不仅仅用于文本或金融。它们也可以用于图像识别。
图像处理 LSTM
假设我们想要执行手写识别。不时地,我们会得到一个新的数据列。是信的结尾吗?如果有,是哪一个?是一句话的结尾吗?是标点吗?所有这些问题都可以用一个循环网络来回答。
对于我们的测试示例,我们将返回到我们的 10 位数数据集,并使用 LSTMs 而不是卷积层。
我们使用类似的超参数:
import tensorflow as tffrom tensorflow.contrib import rnn# rows of 28 pixelsn_input=28# unrolled through 28 time steps (our images are (28,28))time_steps=28# hidden LSTM unitsnum_units=128# learning rate for adamlearning_rate=0.001n_classes=10batch_size=128n_epochs = 10step = 100
设置训练和测试数据几乎类似于我们的 CNN 示例,除了我们重塑图像的方式:
import osimport numpy as npfrom sklearn.datasets import fetch_mldatafrom sklearn.model_selection import train_test_splitmnist = fetch_mldata(\'MNIST original\')mnist.data = mnist.data.astype(np.float32).reshape( [-1, time_steps, n_input]) / 255.mnist.num_examples = len(mnist.data)mnist.labels = mnist.target.astype(np.int8)X_train, X_test, y_train, y_test = train_test_split( mnist.data, mnist.labels, test_size=(1\\. / 7.))
让我们快速建立我们的网络及其支架:
x = tf.placeholder(tf.float32, [None,time_steps, n_input])y = tf.placeholder(tf.int64, [None])# processing the input tensor from [batch_size, n_steps,n_input]# to \"time_steps\" number of [batch_size, n_input] tensorsinput = tf.unstack(x, time_steps,1)lstm_layer = rnn.BasicLSTMCell(num_units, forget_bias=True)outputs, _ = rnn.static_rnn(lstm_layer, input,dtype=tf.float32)prediction = tf.layers.dense(inputs=outputs[-1], units = n_classes)loss = tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits( logits=prediction, labels=y))opt = tf.train.AdamOptimizer(learning_rate=learning_rate).minimize(loss)correct_prediction = tf.equal(tf.argmax(prediction,1), y)accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))
我们现在准备训练:
with tf.Session() as sess: sess.run(tf.global_variables_initializer()) for epoch in range(n_epochs): permut = np.random.permutation(len(X_train)) print(\"epoch: %i\" % epoch) for j in range(0, len(X_train), batch_size): if j % step == 0: print(\" batch: %i\" % j) batch = permut[j:j+batch_size] Xs = X_train[batch] Ys = y_train[batch] sess.run(opt, feed_dict={x: Xs, y: Ys}) if j % step == 0: acc=sess.run(accuracy,feed_dict={x:Xs,y:Ys}) los=sess.run(loss,feed_dict={x:Xs,y:Ys}) print(\" accuracy %f\" % acc) print(\" loss %f\" % los) print(\"\")epoch: 0 batch: 0 accuracy 0.195312 loss 2.275624 batch: 3200 accuracy 0.484375 loss 1.514501… batch: 54400 accuracy 0.992188 loss 0.022468 batch: 57600 accuracy 1.000000 loss 0.007411
我们在这里也获得了相当高的准确度,但是我们将让读者检查测试样本的准确度。
摘要
使用张量流总是遵循类似的模式。设置您的输入、图表和您想要优化的功能。训练您的模型,保存它,并在您的应用程序中重用它。因为它有很多功能,所以很少有 TensorFlow 做不到的事情。在未来的章节中,我们还将探索其他类型的网络。
在书中的这一点上,我们已经看到了机器学习的主要模式:分类。在接下来的两章中,我们将研究用于两种特定数据的技术:音乐和图像。我们的第一个目标是建立一个音乐类型分类器。