现如今,大多数的开发人员都听说过机器学习,但是当他们试图寻找捷径来学习这些技术时,却有很多人都对机器学习中的一些抽象概念望而却步,诸如回归、无监督学习、概率密度函数和其他许多定义。如果诉诸于书本,代表著作有《An Introduction to Statistical Learning with Applications in R》与《Machine Learning for Hackers》,其中的实例是用 R 语言实现的。
然而,R 实际上跟 Java、C#、Scala 等用于开发日常应用的编程语言不同。这也是本文采用 Smile 来介绍机器学习的原因,Smile 是关于机器学习的一个库,它可以在 Java 与 Scala 中使用,而这两种语言对大多数开发者来说至少是不陌生的。
第一部分“机器学习的整体框架”包含了学习下文应用实例要用到的所有重要概念。“应用实例”这一部分参考了《Machine Learning for Hackers》这本书的例子。另外,《Machine Learning in Action》这本书将用于验证。
第二部分“应用实例”包含了各种机器学习 (ML) 应用实例,以 Smile 作为 ML 库。
注意在本文中,“新”定义会添加超链接,以备读者了解该主题的更多相关内容,但是完成这些实例,并不需要阅读全部的内容。
最后,我想感谢以下这些人:
也许你曾听说过机器学习这个概念。然而,如果要你向另一个人解释什么是机器学习,你要怎么做呢?在继续阅读之前,请先考虑一下这个问题。
机器学习有很多不同的定义方式,其中一些更加精确,然而,在这些定义中也有许多不一致之处。有些定义认为机器学习就是根据历史数据建立一个静态的模型,然后可以用于预测未来的数据。另一些则认为随着数据的增加,它是一个随时间不断变化的动态模型。
我是比较支持动态说的,但是由于某些限制,我们的实例只用来阐释静态的模型方法。不过,在动态机器学习这一节中,我们也对动态原则怎样运作做了解释。
接下来的这一部分给出了机器学习中常用的定义和概念。我们建议读者在进入应用实例的学习之前先通读这部分。
一个特征就是用来训练模型的一种性质。例如,基于文字“买”和“钱”出现的频率可以把邮件分类为垃圾邮件和正常邮件。这些用来分类的单词就是特征,如果把它们与其他单词组合在一起,那它们就是特征的一部分。如果你想用机器学习来预测一个人是否是你的朋友,那么“共同的朋友”可以作为一个特征。注意,在这个领域中,特征有时也指属性。
一提到机器学习,模型是经常会碰到的一个术语。模型就是一种机器学习方法的结果以及该方法采用的算法。这个模型可以在监督学习中用来做预测,或者在无监督学习中用来检索聚类。在这个领域中,在线训练和离线训练这两个术语也有很大机会见到。在线训练指的是往一个已经存在的模型中添加训练数据,而离线训练指的是从头开始建立一个新模型。由于性能原因,在线训练方法是最可取的。然而,对某些算法来说,也有例外。
在机器学习领域中,有两种前沿的的学习方式,也就是监督学习和无监督学习,简要地介绍一下还是很有必要的,因为在机器学习的应用中,选择合适的机器学习方法和算法是一个重要而有时又有点乏味的过程。
在监督学习中,你可以明确地定义要使用的特征,以及你预期的输出结果。例如,通过身高和体重预测性别,这是一个分类的问题。此外,你还可以通过回归分析预测绝对值。用同样的数据做回归分析的一个例子是通过性别和体重预测一个人的身高。某些监督算法只能用来解决分类问题和回归分析中的一种,例如 K-NN。不过也有一些算法如 Support Vector Machines 在两种情况下都适用。
在监督学习的范围内,分类问题是相对简单的。考虑一组标签以及一些已经打上正确标签的数据,我们想要做的就是为新数据预测标签。然而,在把数据考虑为分类问题之前,你应该分析一下数据的特点。如果数据的结构明显可以让你轻松地画出一条回归线,那么应用回归算法反而会更好。如果数据无法拟合出一条回归线,或者当算法的性能不理想时,那么分类就是一个很好的选择。
分类问题的一个例子是,根据邮件的内容把邮件分为正常或垃圾邮件。考虑一个训练组,其中的邮件被标为正常或垃圾,可以应用一个分类算法来训练模型。这个模型就可以用来预测未来的邮件是正常或是垃圾。分类算法的一个典型的例子是 K-NN 算法,分类问题的常用实例是将邮件分为垃圾邮件或正常邮件,这也是本文当中使用到的例子之一。
回归要比分类要强大很多。这是因为,回归分析预测的是实际值而不是标签。一个简单的例子可以说明这一点。考虑一个表格,其中包含体重、身高和性别等数据,当给定一个体重和身高数据时,你可以应用 K-NN 算法来预测某人的性别。对这个同样的数据集使用回归分析,如果是给定性别以及其他各个缺失的参数,反过来,你可以预测某人的体重或身高。
能力越大,责任就越大,所以在用回归分析建立模型时必须格外小心。常见的陷阱是过拟合、欠拟合以及对模型如何控制外推法与内插法欠缺考虑。
相比监督学习,无监督学习不需要你事先确切地了解输出结果。应用无监督学习的中心思想是发掘出一个数据集内在的结构。PCA 就是一个例子,它通过组合特征从而减小特征数。 组合过程基于这些特征之间可能隐含的关联。另一个无监督学习的例子是 K-均值聚类。K-均值聚类就是要找出一个数据集中的分组,之后这些分组可以用于其他目的,例如用于监督学习中。
主成分分析是统计学中的一种技术,它用于将一组相关列转化为较小的一组无关列,以化简一个问题的特征数。这组较小的列就叫做主成分。这种技术主要用于探索性数据分析中,因为它揭示了数据中的内部结构,这样的结构无法直观地看出来。
PCA 一个最大弱点就是数据中的异常值。这些异常值严重地影响了结果,所以,事先观察数据,排除较大的异常值能够极大地提高这种方法的性能。
为了清楚 PCA 到底是做什么用的,我们将一组二维点数据的图与同样数据经 PCA 处理后的图作了对比。
原始数据表示在左图中,其中每个颜色表示不同的类别。显然,这些数据可以从二维约化到一维之后,仍然能够恰当的分类。这就是 PCA 被提出的缘故了。根据每个数据点的原始维度,通过 PCA 可以计算出一个新的值来。
右图是对这些数据点应用了 PCA 后的结果。注意,这些数据有一个y值,但这纯粹是为了能够将数作图展示出来。所有这些数据点的Y值都是0,因为 PCA 算法只返回 X 值。同时注意,右图数据点中的 X 值并不对应左图中各点的X值,这表明 PCA 并不仅仅是“丢掉”一个维度。
在这部分中我们会介绍一些用于模型验证的技术方法,以及一些与机器学习验证方法相关的专业术语。
交叉验证法是机器学习领域中最常用的验证方法之一。它的基本思想是,将原始数据分为训练集和验证集,先用训练集对模型进行训练,然后再用模型来预测验证集的数据。将预测值与实际值进行对比,以此来评价模型的性能和训练数据的质量。
这种交叉验证法最重要的环节是分割数据。应用这种方法时,应该始终使用整个数据集。换言之,你不可以随机选取 X 个数据点作为训练集然后随机选取X个数据点作为验证集,这样的话,在两个数据集中可能就有些点是重复的,而另一些点又没有被利用到。
在2-折交叉验证中,每一“折”(所以要执行两次)都要将数据分割为验证集和训练集,用训练集训练模型,再用验证集做验证。这样做就可以在验证模型时计算两次误差。这些误差值不应该相差太大。万一差太大,那么不是你的数据有问题,就是你选来建立模型的特征有问题。无论是哪种情况,你都应该再看看数据,找出具体的问题,因为以数据为基础来训练一个模型有可能因为错误数据而出现模型过度拟合的情况。
正则化的基本思想是,通过简化一个模型而防止它过度拟合。假设你的数据满足一个三次多项式函数,但是数据中有噪声,这会使模型函数的次数加高一级。于是,尽管模型刚开始好像不错,但碰到新的数据就表现不理想了。正则化通过一个特定的 λ 值来简化模型,有利于防止这种情况的发生。然而,要找到一个合适的 λ 值却不易,因为你不知道模型什么时候才会过度拟合。这也是交叉验证法经常被用来寻找适合模型的最佳 λ 值的原因。
在计算机科学中,我们用精确率这个术语来描述相关的选中条目的数量。因此,当你计算一个文档搜索算法的精确率时,那个算法的精确率就定义为在设定的结果中有多少个文档是确实相关的。
这个值由下式算出:
掌握这个内容可能会有点难,所以我举个例子:
假设有一个完备的文档集 {aa,ab,bc,bd,ee},我们要查找名字带有 a 的文档。如果算法返回的文档集是 {aa,ab},那么直觉告诉我们精确率是100%。我们可以代入公式验证一下:
事实上就是100%。如果我们再查找一次,除了{aa,ab}这个结果,我们还得到{bc,de}这个结果,精确率就会受到影响如下:
这里,结果中包含了相关的结果,也包含了两个不相关的结果,导致精确率降低了。然而,如果给这个例子计算召回率,那它将是100%,这就是精确率与召回率之间的不同之处。
召回率是指,给定查找条件和一个数据集,算法检索到的相关条目的数量。因此,给定一组文档以及能够返回一个文档子集的查找条件,召回率就表示相关的文档中有多少被实际返回。召回率由下式计算:
我们举个例子看看如何应用该公式:
假设有一个完备的文档集 { aa, ab, bc, bd, ee },我们要查找名字带有a的文档。如果算法返回{aa,ab},那么召回率显然就是100%。我们可以代入公式验证一下:
事实上就是100%。下面我们看看如果算法只返回部分相关结果会怎么样:
这里,结果只包含一半的相关结果,导致召回率降低了。然而,如果计算这种情况下的精确率,结果是会100%,因为所有返回结果都是相关的。
给定一个数据点,一个分类器的先验值代表了这个数据点属于该分类器的可能性大小。在实践中,这意味着当你在一个数据点处得到一个预测值时,先验值就表示模型对那个数据点的分类的确信度有多高。
均方根误差(RMSE 或 RMSD,D 代表 deviation,即偏差)是指对实际值与预测值之差先平方,再求均值,然后开方。我举个例子来解释一下好帮助理解。假设我们有以下数据:
模型的平方差的均值是 4.33333,该值的平方根是 2.081666. 因此,该模型的预测值的平均误差为 2.08。RMSE 值越低,模型的预测效果越好。这就是为什么在选择特征时,人们会分别计算包含和不包含某个特征的 RMSE 值,以判断那个特征是如何影响模型的性能的。通过这些信息,人们就可以确定,和模型的效率提升比起来,由于该特征值增加的额外的计算时间是否值得。
此外,因为 RMSE 值是一个绝对值,所以它可以归一化以进行模型之间的比较。这就是归一化均方根误差 (NRMSE)。然而,要计算这个值,首先要知道系统所包含的最小值与最大值。假设我们有一个最小值为 5 度、最大值为 25 度的温度范围,那么可用下式来计算 NRMSE 值:
若代入实际值,可得到如下结果:
那这个 10.4 表示什么呢?这是模型对数据点进行预测的平均误差百分数。
最后,我们可以利用 RMSE 值来计算拟合度 (R Squared)。拟合度反映的是,与各个值的平均值作比较(不考虑模型的情况),模型的预测效果有多好。我们首先要计算出平均法的 RMSE 值。对上文的表格最后一列值的取平均,结果是 4.22222,其平方根是 2.054805。首先注意到这个值比模型的值要小。这不是个好现象,因为这表明模型的预测效果比单单取平均值要差。然而,我们主要是演示怎么计算拟合度,所以我们继续计算过程。
现在,模型与平均法的 RSME 值都求出来了,接着用下式计算拟合度:
代入实际值得到以下结果:
那么,-1.307229 代表什么呢?它就是表示模型每次对一个值的预测效果比平均法差约 1.31%。换言之,在这个具体情况中,用平均法来做预测比用模型的效果要好。
这部分要介绍的是在应用机器学习技术的过程中经常会碰到的问题,主要内容是向读者解析这些陷阱以帮助读者避开它们。
对数据进行拟合时,数据本身可能会包含噪声(例如有测量误差)。如果你精确地把每一个数据点都拟合进一个函数中,那你会把噪声也耦合到模型中去。这虽然能使模型在预测测试数据时表现良好,但在预测新数据时会相对较差。
把数据点和拟合函数画在图表中,下列左图反映过度拟合的情况,右图表示一条穿过数据点的回归曲线,它对数据点能够适当拟合。
应用回归分析时容易产生过度拟合,也很容易出现在朴素贝叶斯分类算法中。在回归分析中,产生过度拟合的途径有舍入操作、测量不良和噪声数据。在朴素贝叶斯分类算法中,选定的特征可能导致过度拟合。例如,对垃圾和正常邮件的分类问题保留所有停用词。
通过运用验证技术、观察数据的统计特征以及检测和剔除异常值,可以检测出过度拟合。
当你对数据进行建模时,把很多统计数据都遗漏掉了,这叫做欠拟合。有很多原因可以导致欠拟合,例如,对数据应用不合适的回归类型。如果数据中包含了非线性结构,而你却运用线性回归,这就产生了一个欠拟合模型。下列左图代表了一条欠拟合的回归线,右图右图表示一条合适的回归线。
为了防止欠拟合,你可以画出数据点从而了解数据的内在结构,以及应用验证技术,例如交叉验证。
维度灾难
对于已知的数据量存在一个最大的特征数(维数),当实际用于建立机器模型的特征数超过这个最大值时,就产生维度灾难问题。矩阵秩亏就是这样的一种问题。普通最小二乘 (OLS) 算法通过解一个线性系统来建立模型。然而,如果矩阵的列数多于行数,那这个系统不可能有唯一解。最好的解决办法是获取更多数据点或者减小特征数。
如果读者想了解更多关于维度灾难的内容,可以参考一个关于这方面的研究。在这个研究中,研究人员 Haifeng Li,Keshu Zhang 和 Tao Jiang 发展一种用少量数据点改善肿瘤分类的算法。他们还将其与支持向量机和随机森林算法做了比较。
在几乎所有你能找到的机器学习文献中,静态模型就是首先通过建立、验证流程,然后作预测或建议用途。然而在实践中,仅仅这样做还不足以应用好机器学习。所以,我们在这部分中将要介绍怎么样把一个静态模型改造成一个动态模型。因为(最佳的)实现是依赖于你所使用的算法的,所以我们将只作概念介绍而不给出实例了。鉴于文本解释不够清晰,我们首先用一个图表展示整个体系,然后用这个图表介绍机器学习以及如何做成一个动态系统。
机器学习的基本流程如下:
1、搜集数据
2、把数据分割为测试集和训练集
3、训练一个模型(应用某种机器学习算法)
4、验证模型,验证方法需要使用模型和测试数据
5、基于模型作出预测。
在该领域的实际应用中,以上的流程是不完整的,有些步骤并未包含进去。在我看来,这些步骤对于一个智能学习系统来说至关重要。
所谓的动态机器学习,其基本思路如下:模型作出预测后,将预测信息连同用户反馈一起返回给系统,以改善数据集和模型。那么,这些用户反馈是怎么获得的呢?我们以为 Facebook 的朋友推荐为例。用户面临两种选择:“添加朋友”或“移除”。基于用户的决定,对于那个预测你就得到了用户的直接反馈。
因此,假设你获得了这些用户反馈,那么你可以对模型应用机器学习来学习这些用户反馈。听起来可能有点奇怪,我们会更详细地解释这一过程。然而在那之前,我们要做一个免责声明:我们关于脸书朋友推荐系统的解释是一个100%的假说,并且绝对没有经过脸书本身的证实。就我们所知,他们的系统对外是不公开的。
假设该系统基于以下特征进行预测:
1、共同朋友的数量
2、相同的户籍
3、相同的年龄
然后你可以为脸书上的每一个人计算出一个先验值,这个先验值描述了他/她是你的朋友的概率有多大。假设你把一段时间内所有的预测信息都存储下来,就可以用机器学习分析这些数据来改善你的系统。更详细地说,假设大多数的“移除好友”推荐在特征 2 上具有较高评级,但在特征 1 上评级相对较低,那么我们可以给预测系统加入权重系数,让特征 1 比特征 2 更重要。这样就可以为我们改善推荐系统。
此外,数据集随时间而增大,所以我们要不断更新模型,加入新数据,使预测更准确。不过,在这个过程中,数据的量级及其突变率起着决定性作用。
在这部分中,我们结合实际环境介绍了一些机器学习算法。这些实例主要为了方便读者入门之用,因此我们不对其内在的算法作深入讲解。讨论的重点完全集中在这些算法的功能方面、如何验证算法实现以及让读者了解常见的陷阱。
我们讨论了如下例子:
基于下载/上传速度的互联网服务提供商标记法 (K-NN)
正常/垃圾邮件分类(朴素贝叶斯法)
基于内容的邮件排序(推荐系统)
基于身高预测体重(线性回归:普通最小二乘法)
尝试预测最畅销书排行(文本回归)
应用无监督学习合并特征(主成分分析)
应用支持向量机(支持向量机)
我们在这些实例中都使用了 Smile 机器学习库,包括 smile-core 和 smile-plot 这两个库。这些库在 Maven, Gradle, Ivy, SBT 和 Leiningen 等工具上都是可用的。如何把这些库添加到这些工具中去,对于 core 库,参考这里,对于 plot 库,参考这里。
所以,在开始做这些实例之前,我假定你在自己最喜欢的 IDE 上建立了一个新项目,并把 smile-core 库和 smile-plot 库添加到了你的项目中。其他需要用到的库以及如何获取实例的数据会在每个例子中分别予以说明。
这一部分的主要目标是运用 K 最近邻算法,根据下载/上传速度对将互联网服务供应商 (ISP) 分为 Alpha 类(由 0 代表)或 Beta 类(由 1 代表)。K-NN 算法的思路如下:给定一组已经分好类的点,那么,对新点的分类可以通过判别它的 K 个最近邻点的类别(K 是一个正整数)。K 个最近邻点可以通过计算新点与其周围点之间的欧氏距离来查找。找出这些邻近点,你就得到了最具代表性的类别,并将新点分到这一类别中。
做这一案例需要先下载示例数据。此外,还要把代码段中的路径改为你存储示例数据的地方。
首先要加载 CSV 数据文件。这没什么难的,所以我直接给代码,不做进一步解释:
object KNNExample { def main(args: Array[String]): Unit = { val basePath = "/.../KNN_Example_1.csv" val testData = getDataFromCSV(new File(basePath)) } def getDataFromCSV(file: File): (Array[Array[Double]], Array[Int]) = { val source = scala.io.Source.fromFile(file) val data = source .getLines() .drop(1) .map(x => getDataFromString(x)) .toArray source.close() val dataPoints = data.map(x => x._1) val classifierArray = data.map(x => x._2) return (dataPoints, classifierArray) } def getDataFromString(dataString: String): (Array[Double], Int) = { //Split the comma separated value string into an array of strings //把用逗号分隔的数值字符串分解为一个字符串数组 val dataArray: Array[String] = dataString.split(',') //Extract the values from the strings //从字符串中抽取数值 val xCoordinate: Double = dataArray(0).toDouble val yCoordinate: Double = dataArray(1).toDouble val classifier: Int = dataArray(2).toInt //And return the result in a format that can later //easily be used to feed to Smile //并以一定格式返回结果,使得该结果之后容易输入到Smile中处理 return (Array(xCoordinate, yCoordinate), classifier) } }
你首先可能会奇怪为什么数据要使用这种格式。数据点与它们的标记值之间的间隔是为了更容易地分割测试数据和训练数据,并且在执行 K-NN 算法以及给数据绘图时,API 需要这种数据格式。其次,把数据点存储为一个数组 (Array[Array[Double]]),能够支持 2 维以上的数据点。
给出这些数据之后,接下来要做的就是将数据可视化。Smile 为这一目的提供了一个很好的绘图库。不过,要使用这一功能,应该把代码转换到 Swing 中去。此外还要把数据导入到绘图库中以得到带有实际绘图结果的 JPane 面板。代码转换之后如下:
object KNNExample extends SimpleSwingApplication { def top = new MainFrame { title = "KNN Example" val basePath = "/.../KNN_Example_1.csv" val testData = getDataFromCSV(new File(basePath)) val plot = ScatterPlot.plot(testData._1, testData._2, '@', Array(Color.red, Color.blue) ) peer.setContentPane(plot) size = new Dimension(400, 400) } ...
将数据绘成图是为了检验 K-NN 在这种具体情况中是否为合适的机器学习算法。绘图结果如下:
在这个图中可以看到,蓝点和红点在区域 3<x<5 和 5<y<7.5 是混合的。既然两组点混合在一起,那 K-NN 算法就是个不错的选择,若是拟合一条决策边界则会在混合区域造成很多误分。
考虑到 K-NN 算法是这个问题的一个不错的选择,我们可以继续机器学习的实践。GUI 在这里实际上没有用武之地,我们摒弃不用。回顾机器学习的全局体系这部分,其中提到机器学习的两个关键部分:预测和验证。首先我们进行验证,不作任何验证就把模型拿来使用并不是一个好主意。这里验证模型的主要原因是为了防止过拟合。不过在做验证之前,我们要选择一个合适的 K 值。
这个方法的缺点就是不存在寻找最佳 K 值的黄金法则。然而,可以通过观察数据来找出一个合理的 K 值,使大多数数据点可以被正确分类。此外,K 值的选取要小心,以防止算法引起的不可判定性。例如,假设 K=2,并且问题包含两种标签,那么,当有一个点落在两种标签之间时,算法会选择哪一种标签呢?有一条经验法则是这样的:K应该是特征数(维数)的平方根。那在我们的例子中就会有K=1,但这真不是一个好主意,因为它会在决策边界导致更高的误分率。考虑到我们有两种标签,让 K=2 会导致错误,因此,目前来说,选择 K=3 是比较合适。
在这个例子中,我们做 2 折交叉验证。一般来讲,2 折交叉验证是一种相当弱的模型验证方法,因为它将数据集分割为两半并且只验证两次,仍有可能产生过拟合,不过由于这里的数据集只包含 100 个点,10 折验证(一个较强的版本)发挥不了作用,因为这样的话,将只有 10 个点用于测试,会导致误差率倾斜。
def main(args: Array[String]): Unit = { val basePath = "/.../KNN_Example_1.csv" val testData = getDataFromCSV(new File(basePath)) //Define the amount of rounds, in our case 2 and //initialise the cross validation //在这第二种情况中我们设置了交叉验证的次数并初始化交叉验证 val cv = new CrossValidation(testData._2.length, validationRounds) val testDataWithIndices = (testData ._1 .zipWithIndex, testData ._2 .zipWithIndex) val trainingDPSets = cv.train .map(indexList => indexList .map(index => testDataWithIndices ._1.collectFirst { case (dp, `index`) => dp}.get)) val trainingClassifierSets = cv.train .map(indexList => indexList .map(index => testDataWithIndices ._2.collectFirst { case (dp, `index`) => dp}.get)) val testingDPSets = cv.test .map(indexList => indexList .map(index => testDataWithIndices ._1.collectFirst { case (dp, `index`) => dp}.get)) val testingClassifierSets = cv.test .map(indexList => indexList .map(index => testDataWithIndices ._2.collectFirst { case (dp, `index`) => dp}.get)) val validationRoundRecords = trainingDPSets .zipWithIndex.map(x => ( x._1, trainingClassifierSets(x._2), testingDPSets(x._2), testingClassifierSets(x._2) ) ) validationRoundRecords .foreach { record => val knn = KNN.learn(record._1, record._2, 3) //And for each test data point make a prediction with the model //对每个测试数据点,用模型做一次预测 val predictions = record ._3 .map(x => knn.predict(x)) .zipWithIndex //Finally evaluate the predictions as correct or incorrect //and count the amount of wrongly classified data points. //最后检验预测结果正确与否,并记下被错误分类的数据点的个数 val error = predictions .map(x => if (x._1 != record._4(x._2)) 1 else 0) .sum println("False prediction rate: " + error / predictions.length * 100 + "%") } }
如果你多次执行上面这段代码,你可能发现错误预测率会有一些波动。这是由于用来做训练和测试的随机样本。如果不幸地取到不好的随机样本,误差率会比较高,若取到好的随机样本,则误差率会极低。
不幸的是,关于如何为模型选取最好的随机样本来训练,我没有这样的黄金法则。也许有人会说,产生最小误差率的模型总是最好的。不过,再回顾一下过拟合这个概念,选用这样的特殊模型也可能会对新数据束手无策。这就是为什么获得一个足够大且具有代表性的数据集对一个成功的机器学习应用来说非常关键。然而,当遇到这种问题时,你可以用新的数据和已知的正确分类不断更新模型。
我们概括一下目前为止的进展状况。首先小心地选取训练和测试数据;下一步,建立几个模型并验证,选出给出最好结果的模型。接下来我们到达最后一步,就是用这个模型做预测:
val knn = KNN.learn(record._1, record._2, 3) val unknownDataPoint = Array(5.3, 4.3) val result = knn.predict(unknownDatapoint) if (result == 0) { println("Internet Service Provider Alpha") } else if (result == 1) { println("Internet Service Provider Beta") } else { println("Unexpected prediction") }
执行这段代码之后,未标记点 (5.3, 4.3) 就被标记为 ISP Alpha。这个点是最容易分类的点之一,它明显落在数据图的 Alpha 区域中。如何做预测已经显而易见,我不再列举其他点,你可以随意尝试其它不同点,看看预测结果。
在这个实例中,基于邮件内容,我们将用朴素贝叶斯算法把邮件分为正常邮件与垃圾邮件。朴素贝叶斯算法是计算一个目标在每个可能类别中的几率,然后返回具有最高几率的那个类别。要计算这种几率,算法会使用特征。该算法被称为朴素贝叶斯是由于它不考虑特征之间的任何相关性。换言之,每个特征都一样重要。我举个例子进一步解释:
假设你正在征颜色、直径和形状这几个特征将水果和蔬菜进行分类,现在有以下类别:苹果、番茄和蔓越莓。
假设你现在要把一个具有如下特征值的目标分类:(红色、4cm、圆形)。对我们来说,它显然是一个番茄,因为对比苹果它比较小,对比蔓越莓它太大了。然而,朴素贝叶斯算法会独立地评估每个特征,它的分类过程如下:
苹果 66.6% 可能性(基于颜色和形状)
番茄 100.0% 可能性(基于颜色、形状和大小)
蔓越莓 66.6% 可能性(基于颜色和形状)
因此,尽管实际上很明显它不可能是蔓越莓或苹果,朴素贝叶斯仍会给出 66.6% 的机会是这两种情况之一。所以即使它正确地把目标分类为番茄,在边界情况(目标大小刚好超出训练集的范围)下,它也可能给出糟糕的结果。不过,在邮件分类中,朴素贝叶斯算法表现还是不错的,这是由于邮件的好坏无法仅仅通过一个特征(单词)来分类。
你现在应该大概了解朴素贝叶斯算法了,我们可以继续做之前的实例了。在这个例子中,我们使用 Scala 语言,利用 Smile 库中的朴素贝叶斯实现,将邮件按内容分为垃圾邮件和正常邮件。
在开始之前还需要你从 SpamAssasins 公共文库上下载这个例子的数据。你所要用到的数据在 easy_ham 和 spam 文件里,但其余的文件在你需要做更多实验时也会用到。把这些文件解压之后,修改代码段里的文件路径以适应文件夹的位置。此外,在做筛选时,你还需要用到停用词文件。
对于每一个机器学习实现,第一步是要加载训练数据。不过在这个例子中,我们需要进一步深入机器学习。在 K-NN 实例中,我们用下载速度和上传速度作为特征。我们并不指明它们是特征,因为它们就是唯一可用的属性。对邮件分类这个例子来说,拿什么作为特征并非毫无意义。要分类出垃圾或正常邮件。你可以使用的特征有发送人、主题、邮件内容,甚至发送时间。
在这个例子中,我们选用邮件内容作为特征,也就是,我们要在训练集中,从邮件正文中选出特征(此例中是单词)。为了做到这一点,我们需要建立一个词汇文档矩阵 (TDM)。
我们从编写一个加载案例数据的函数开始。这个函数就是 getMessage 方法,在给定一个文件作为参数后,它从一个邮件中获取过滤文本。
def getMessage(file : File) : String = { //Note that the encoding of the example files is latin1, // thus this should be passed to the fromFile method. //注意案例文件采用latin1编码,所以应该把它们传递给fromFile方法 val source = scala.io.Source.fromFile(file)("latin1") val lines = source.getLines mkString "n" source.close() //Find the first line break in the email, //as this indicates the message body //在邮件中找出第一个换行符,因为这暗示信息的主体 val firstLineBreak = lines.indexOf("nn") //Return the message body filtered by only text from a-z and to lower case //返回过滤后的信息主体,即只包含a-z并且为小写的文本。 return lines .substring(firstLineBreak) .replace("n"," ") .replaceAll("[^a-zA-Z ]","") .toLowerCase() }
到此,在我们提供的案例数据文件夹中,我们需要一个方法来获取所有邮件的文件名。
def getFilesFromDir(path: String):List[File] = { val d = new File(path) if (d.exists && d.isDirectory) { //Remove the mac os basic storage file, //and alternatively for unix systems "cmds" //移除mac os的基本存储文件或者unix系统的“cmd”文件 d .listFiles .filter(x => x .isFile && !x .toString .contains(".DS_Store") && !x .toString .contains("cmds")) .toList } else { List[File]() } }
然后,我们要定义一组路径,它们可以方便我们从案例数据中加载不同的数据集。与此同时,我们也直接定义一组大小为 500 的样本,这是垃圾邮件训练集的总数。为了让训练集在两种分类上保持平衡,我们把正常邮件的样本总数也定为 500。
def main(args: Array[String]): Unit = { val basePath = "/Users/../Downloads/data" val spamPath = basePath + "/spam" val spam2Path = basePath + "/spam_2" val easyHamPath = basePath + "/easy_ham" val easyHam2Path = basePath + "/easy_ham_2" val amountOfSamplesPerSet = 500 val amountOfFeaturesToTake = 100 //First get a subset of the filenames for the spam // sample set (500 is the complete set in this case) //首先取出一个包含文件名的子集作为垃圾邮件样本集(在这个案例中,全集是500个文件名) val listOfSpamFiles = getFilesFromDir(spamPath) .take(amountOfSamplesPerSet) //Then get the messages that are contained in these files //然后取得包含在这些文件中的信息 val spamMails = listOfSpamFiles.map(x => (x, getMessage(x))) //Get a subset of the filenames from the ham sample set //取出一个文件名子集作为正常邮件样本集 // (note that in this case it is not necessary to randomly // sample as the emails are already randomly ordered) //(注意在本案例中没有必要随机取样,因为这些邮件已经是随机排序) val listOfHamFiles = getFilesFromDir(easyHamPath) .take(amountOfSamplesPerSet) //Get the messages that are contained in the ham files //取得包含在正常邮件中的信息 val hamMails = listOfHamFiles .map{x => (x,getMessage(x)) } }
既然我们已经获取了正常邮件和垃圾邮件的训练数据,就可以开始建立两个 TDM 了。不过在给出实现这个过程的代码之前,我们首先简短解释下这么做的原因。TDM 包含了所有出现在训练集正文中的单词,以及词频。然而,词频可能不是最好的量度方法(比如,一封含有 1000000 个“cake”的邮件就能把整个表搞砸),因此我们也会计算出现率,也就是,包含那个特定词汇的文档数量。现在我们开始生成两个 TDM。
val spamTDM = spamMails .flatMap(email => email ._2.split(" ") .filter(word => word.nonEmpty) .map(word => (email._1.getName,word))) .groupBy(x => x._2) .map(x => (x._1, x._2.groupBy(x => x._1))) .map(x => (x._1, x._2.map( y => (y._1, y._2.length)))) .toList //Sort the words by occurrence rate descending //以出现率降序排列这些单词 //(amount of times the word occurs among all documents) //(该单词在所有文档中出现的总次数) val sortedSpamTDM = spamTDM .sortBy(x => - (x._2.size.toDouble / spamMails.length)) val hamTDM = hamMails .flatMap(email => email ._2.split(" ") .filter(word => word.nonEmpty) .map(word => (email._1.getName,word))) .groupBy(x => x._2) .map(x => (x._1, x._2.groupBy(x => x._1))) .map(x => (x._1, x._2.map( y => (y._1, y._2.length)))) .toList //Sort the words by occurrence rate descending //以出现率降序排列这些单词 //(amount of times the word occurs among all documents) //(该单词在所有文档中出现的总次数) val sortedHamTDM = hamTDM .sortBy(x => - (x._2.size.toDouble / spamMails.length))
给定了那些表格,为了更深入了解它们,我用 wordcloud 将它们生成图片。这些图片中反映了频率最高的 50 个单词 (top 50)的情况,我们观察一下。注意红色单词来自垃圾邮件,绿色单词来自正常邮件。此外,单词的大小代表出现率。因此,单词越大,至少出现一次该单词的文档越多。
你可以看到,停用词大多出现在前面。这些停用词是噪声,在特征选择过程中我们要尽可能避开它们。所以在选出特征之前,我们要从表格中剔除这些词。案例数据集已经包含了一列停用词,我们首先编写代码来获取这些词。
def getStopWords() : List[String] = { val source = scala.io.Source .fromFile(new File("/Users/.../.../Example Data/stopwords.txt"))("latin1") val lines = source.mkString.split("n") source.close() return lines.toList }
现在我们可以扩展前文中的 TDM 生成代码,剔除停用词:
val stopWords = getStopWords val spamTDM = spamMails .flatMap(email => email ._2.split(" ") .filter(word => word.nonEmpty && !stopWords.contains(word)) .map(word => (email._1.getName,word))) .groupBy(x => x._2) .map(x => (x._1, x._2.groupBy(x => x._1))) .map(x => (x._1, x._2.map( y => (y._1, y._2.length)))) .toList val hamTDM = hamMails .flatMap(email => email ._2.split(" ") .filter(word => word.nonEmpty && !stopWords.contains(word)) .map(word => (email._1.getName,word))) .groupBy(x => x._2) .map(x => (x._1, x._2.groupBy(x => x._1))) .map(x => (x._1, x._2.map( y => (y._1, y._2.length)))) .toList
如果马上观察垃圾邮件和正常邮件的 top 50 单词,就可以看到大多数停用词已经消失了。我们可以再作调整,但现在就用这个结果吧。
在了解了什么是“垃圾单词”和“正常单词”之后,我们就可以决定建立一个特征集了,稍后我们会把它用在朴素贝叶斯算法中以创建一个分类器。注意:包含更多的特征总是更好的,然而,若把所有单词都作为特征,则可能出现性能问题。这就是为什么在机器学习领域,很多开发者倾向于弃用没有明显影响的特征,纯粹就是性能方面的原因。另外,机器学习过程可以通过在完整的 Hadoop 集群上运行来完成,但是阐明这方面的内容就超出了本文的范围。
现在我们要选出出现率(而不是频率)最高的 100 个“垃圾单词”和 100 个“正常单词”,并把它们组合成一个单词集,它将输入到贝叶斯算法。最后,我们要转换这些训练数据以适应贝叶斯算法的输入格式。注意最终的特征集大小为 200(其中,#公共单词×2)。请随意用更大或更小的特征数做实验。
//Add the code for getting the TDM data and combining it into a feature bag. //添加生成TDM数据并将其合成一个特征包的代码 val hamFeatures = hamTDM .records .take(amountOfFeaturesToTake) .map(x => x.term) val spamFeatures = spamTDM .records .take(amountOfFeaturesToTake) .map(x => x.term) //Now we have a set of ham and spam features, //现在我们有了一套正常邮件和垃圾邮件特征, // we group them and then remove the intersecting features, as these are noise. //我们将它们组合在一起并将公共特征去除,因为它们是噪声 var data = (hamFeatures ++ spamFeatures).toSet hamFeatures .intersect(spamFeatures) .foreach(x => data = (data - x)) //Initialise a bag of words that takes the top x features //from both spam and ham and combines them //初始化一个单词包,从垃圾特征集和正常特征集中取出最前的x个特征,将它们合并 var bag = new Bag[String] (data.toArray) //Initialise the classifier array with first a set of 0(spam) //and then a set of 1(ham) values that represent the emails //将该分类器数组初始化,首先用一组数值0代替垃圾邮件,然后用一组数值1代替正常邮件 var classifiers = Array.fill[Int](amountOfSamplesPerSet)(0) ++ Array.fill[Int](amountOfSamplesPerSet)(1) //Get the trainingData in the right format for the spam mails //取得正确格式的垃圾邮件训练数据 var spamData = spamMails .map(x => bag.feature(x._2.split(" "))) .toArray //Get the trainingData in the right format for the ham mails //取得正确格式的正常邮件训练数据 var hamData = hamMails .map(x => bag.feature(x._2.split(" "))) .toArray //Combine the training data from both categories //将两种训练数据合并 var trainingData = spamData ++ hamData
给定了这个特征包以及一个训练数据集,我们就可以开始训练算法。为此,我们有几个模型可以采用:General 模型、Multinomial 模型和Bernoulli 模型。General 模型需要一个定义好的分布,而这个分布我们事先并不知道,因此这个模型并不是个好的选择。Multinomial 和Bernoulli 两个模型之间的区别就是它们对单词出现率的处理方式不同。Bernoulli模型仅仅是验证一个特征是否存在(二元值 1 或 0),因此它忽略了出现率这个统计值。反之,Multinomial 模型结合了出现率(由数值表示)。所以,与 Multinomial 模型比较,Bernoulli 模型在长文档中的表现较差。既然我们要对邮件排序,并且也要使用到出现率,因此我们主要讨论 Multinomial 模型,但尽管试试 Bernoulli 模型。
//Create the bayes model as a multinomial with 2 classification // groups and the amount of features passed in the constructor. //建立一个多项式形式的贝叶斯模型,将类别数2以及特征总数传递给该构建函数 var bayes = new NaiveBayes(NaiveBayes.Model.MULTINOMIAL, 2, data.size) //Now train the bayes instance with the training data, // which is represented in a specific format due to the //bag.feature method, and the known classifiers. //现在可以用训练数据和已知的分类器对贝叶斯模型进行训练, //训练数据已经用bag.feature方法表示为特定的格式 bayes.learn(trainingData, classifiers)
现在我们有了一个训练好的模型,可以再次进行验证环节。不过呢,在案例数据中,我们已经将简单的和复杂的垃圾邮件(正常邮件)分开来,因此我们就不使用交叉验证了,而是用这些测试集来验证模型。以垃圾邮件分类作为开始,为此,我们使用 spam2 文件夹中的 1397 封垃圾邮件。
val listOfSpam2Files = getFilesFromDir(spam2Path) val spam2Mails = listOfSpam2Files .map{x => (x,getMessage(x)) } val spam2FeatureVectors = spam2Mails .map(x => bag.feature(x._2.split(" "))) val spam2ClassificationResults = spam2FeatureVectors .map(x => bayes.predict(x)) //Correct classifications are those who resulted in a spam classification (0) //正确的分类是那些分出垃圾邮件(0)的结果 val correctClassifications = spam2ClassificationResults .count( x=> x == 0) println ( correctClassifications + " of " + listOfSpam2Files.length + "were correctly classified" ) println (( (correctClassifications.toDouble / listOfSpam2Files.length) * 100) + "% was correctly classified" ) //In case the algorithm could not decide which category the email //belongs to, it gives a -1 (unknown) rather than a 0 (spam) or 1 (ham) //如果算法无法确定一封邮件属于哪一类, //它会给出-1(未知的)的结果而不是0(垃圾邮件)或1(正常邮件) val unknownClassifications = spam2ClassificationResults .count( x=> x == -1) println( unknownClassifications + " of " + listOfSpam2Files.length + "were unknowingly classified" ) println( ( (unknownClassifications.toDouble / listOfSpam2Files.length) * 100) + % was unknowingly classified" )
如果以不同的特征数多次运行这段代码,就可以得到下列结果:
注意,被标记为垃圾的邮件数量正是由模型所正确分类的。有趣的是,在只有 50 个特征的情况,这个算法分类垃圾邮件表现的最好。不过,考虑到在这50个最高频特征词中仍然有停用词,这个结果就不难解释了。若观察被分类为垃圾邮件的数目随特征数增加的变化(从 100 开始),可以看到,特征数越多,结果越大。注意还有一组被分为未知邮件。这些邮件在“正常”和“垃圾”两个类别中的先验值是相等的。这种情况也适用于那些其中未包含正常或垃圾邮件特征词的邮件,因为这样的话,算法会认为它有 50% 是正常邮件, 50% 是垃圾邮件。
现在我们对正常邮件进行相同的分类过程。通过将变量 listOfSpam2Files 的路径改为 easyHam2Path,并重新运行该代码,我们可以得到以下结果:
注意现在被正确分类的是那些被标记为“正常”的邮件。从这里可以看到,事实上当只用50个特征时,被正确分类为正常邮件的数目明显低于使用100个特征时的情况。你应该注意到这点并且对所有类别验证你的模型,就如在这个例子中,用垃圾邮件和正常邮件的测试数据对模型都做了验证。
概括一下这个实例,我们演示了如何应用朴素贝叶斯算法来分类正常或垃圾邮件,并得到如下的结果:高达 87.26% 的垃圾邮件识别率和 97.79% 的正常邮件识别率。这表明朴素贝叶斯算法在识别正常或垃圾邮件时表现得确实相当好。
朴素贝叶斯算法实例到这里就结束了。如果你还想多研究一下这个算法和垃圾邮件分类,文库里还有一组“困难级别的”正常邮件,你可以通过调整特征数、剔除更多停用词来尝试正确分类。
这个实例完全是关于建立你自己的推荐系统的。我们将基于如下特征对邮件进行排序:“发送人”、“主题”、“主题中的公共词汇”和“邮件正文中的公共词汇”。稍后我们会对实例中的这些特征一一做解释。注意在设计你自己的推荐系统时,你要自己定义这些特征,而这正是最困难的环节之一。想出合适的特征来非常重要,而且就算最终选好了特征,已有的数据往往可能无法直接利用。
这个实例旨在教你如何选取特征以及解决在这个过程中使用你自己的数据时会遇到的问题。
我们将会使用邮件数据的一个子集,这些邮件数据已在实例“垃圾/正常邮件分类”中使用过了。这个子集可以从这里下载。此外,你还需要停用词文件。注意这些数据是一组接收到的邮件,因此我们还缺少一半数据,也就是这个邮箱发送出去的邮件。然而,就算没有这些信息,我们还是可以做一些相当漂亮的排序工作,待会儿就知道了。
在我们开始建立排序系统之前,我们首先需要从邮件数据集中抽取出尽可能多的数据来。因为这些数据本身有点冗长,我们给出处理这个过程的代码。行内注释对代码的作用进行了解释。注意,该程序一开始就是一个在 GUI 中的 swing 应用。之所以这样做,是因为稍后我们要将数据绘成图以洞悉其隐含的模式。同时注意,我们直接将数据分割为测试数据和训练数据,为我们之后验证模型做准备。
import java.awt.{Rectangle} import java.io.File import java.text.SimpleDateFormat import java.util.Date import smile.plot.BarPlot import scala.swing.{MainFrame, SimpleSwingApplication} import scala.util.Try object RecommendationSystem extends SimpleSwingApplication { case class EmailData(emailDate : Date, sender : String, subject : String, body : String) def top = new MainFrame { title = "Recommendation System Example" val basePath = "/Users/../data" val easyHamPath = basePath + "/easy_ham" val mails = getFilesFromDir(easyHamPath).map(x => getFullEmail(x)) val timeSortedMails = mails .map (x => EmailData ( getDateFromEmail(x), getSenderFromEmail(x), getSubjectFromEmail(x), getMessageBodyFromEmail(x) ) ) .sortBy(x => x.emailDate) val (trainingData, testingData) = timeSortedMails .splitAt(timeSortedMails.length / 2) } def getFilesFromDir(path: String): List[File] = { val d = new File(path) if (d.exists && d.isDirectory) { //Remove the mac os basic storage file, //and alternatively for unix systems "cmds" //移除mac os的基本存储文件或者unix系统的“cmds”文件 d.listFiles.filter(x => x.isFile && !x.toString.contains(".DS_Store") && !x.toString.contains("cmds")).toList } else { List[File]() } } def getFullEmail(file: File): String = { //Note that the encoding of the example files is latin1, //thus this should be passed to the from file method. //注意案例文件采用latin1编码,因此应该把它们传递给fronFile方法 val source = scala.io.Source.fromFile(file)("latin1") val fullEmail = source.getLines mkString "n" source.close() fullEmail } def getSubjectFromEmail(email: String): String = { //Find the index of the end of the subject line //找出主题行的结束标志 val subjectIndex = email.indexOf("Subject:") val endOfSubjectIndex = email .substring(subjectIndex) .indexOf('n') + subjectIndex //Extract the subject: start of subject + 7 // (length of Subject:) until the end of the line. //抽取主题:主题开端+7(主题的长度),直到该行结束 val subject = email .substring(subjectIndex + 8, endOfSubjectIndex) .trim .toLowerCase //Additionally, we check whether the email was a response and //remove the 're: ' tag, to make grouping on topic easier: //此外,我们检查邮件是否是一封回复并删除“re:”标签,使对话题的分组更容易 subject.replace("re: ", "") } def getMessageBodyFromEmail(email: String): String = { val firstLineBreak = email.indexOf("nn") //Return the message body filtered by only text //from a-z and to lower case //返回过滤后的信息主体,即只包含a-z小写形式的文本 email.substring(firstLineBreak) .replace("n", " ") .replaceAll("[^a-zA-Z ]", "") .toLowerCase } def getSenderFromEmail(email: String): String = { //Find the index of the From: line //找出带有“From:”标志的行 val fromLineIndex = email .indexOf("From:") val endOfLine = email .substring(fromLineIndex) .indexOf('n') + fromLineIndex //Search for the <> tags in this line, as if they are there, // the email address is contained inside these tags //在该行中搜索“<>”标签,如果标签存在,则邮件地址包含在其中 val mailAddressStartIndex = email .substring(fromLineIndex, endOfLine) .indexOf('<') + fromLineIndex + 1 val mailAddressEndIndex = email .substring(fromLineIndex, endOfLine) .indexOf('>') + fromLineIndex if (mailAddressStartIndex > mailAddressEndIndex) { //The email address was not embedded in <> tags, // extract the substring without extra spacing and to lower case //邮件地址不是括在<>标签内,抽取子字符串并去除空格,转为小写形式 var emailString = email .substring(fromLineIndex + 5, endOfLine) .trim .toLowerCase //Remove a possible name embedded in () at the end of the line, //for example in test@test.com (tester) the name would be removed here //删除行末可能包含在()内的名字,例如,在test@test.com(tester) 中,名字会被删除掉 val additionalNameStartIndex = emailString.indexOf('(') if (additionalNameStartIndex == -1) { emailString .toLowerCase } else { emailString .substring(0, additionalNameStartIndex) .trim .toLowerCase } } else { //Extract the email address from the tags. //抽取标签中的邮件地址 //If these <> tags are there, there is no () with a name in // the From: string in our data //我们的数据中,如果“From:”字符串存在标签<>,则不会有带()的名字出现 email .substring(mailAddressStartIndex, mailAddressEndIndex) .trim .toLowerCase } } def getDateFromEmail(email: String): Date = { //Find the index of the Date: line in the complete email //在整封邮件中找出日期行的标志 val dateLineIndex = email .indexOf("Date:") val endOfDateLine = email .substring(dateLineIndex) .indexOf('n') + dateLineIndex //All possible date patterns in the emails. //邮件中所有可能的日期格式 val datePatterns = Array( "EEE MMM dd HH:mm:ss yyyy", "EEE, dd MMM yyyy HH:mm", "dd MMM yyyy HH:mm:ss", "EEE MMM dd yyyy HH:mm") datePatterns.foreach { x => //Try to directly return a date from the formatting. //尝试用一种日期格式直接返回一个日期 //when it fails on a pattern it continues with the next one // until one works //对于一种格式,当返回错误时,它接着尝试下一种格式直到成功。 Try(return new SimpleDateFormat(x) .parse(email .substring(dateLineIndex + 5, endOfDateLine) .trim.substring(0, x.length))) } //Finally, if all failed return null //最后,如果都失败了则返回null //(this will not happen with our example data but without //this return the code will not compile) //(对于我们的案例数据,这不会发生,但是没有这一返回,代码就不会编译) null } }
这一数据预处理过程非常普遍,并且,一旦你的数据格式不标准,如带有邮件的日期和发送人,那这个过程将会令人非常纠结。不过,给出了这段代码之后,我们的实例数据现在就有了以下这些可用的属性:完整邮件、接收日期、发送人、主题和正文。有了这些,我们得以确定推荐系统实际要用到的特征。
我们建议选取的第一个特征来源于邮件的发送人。那些你经常收到 Ta 邮件的人应该排在那些你很少收到 Ta 邮件的人的前面。这是个很有力的假设,但是你应该会本能地认同我们没有考虑垃圾邮件这件事。我们来看看发送人在整个邮件集上的分布。
//Add to the top body: //添加到主体的顶部 //First we group the emails by Sender, then we extract only the sender address //and amount of emails, and finally we sort them on amounts ascending //首先我们按发送人给邮件分组,然后只抽取发送人的地址以及邮件数, //最后按邮件数给这些地址按升序排序 val mailsGroupedBySender = trainingData .groupBy(x => x.sender) .map(x => (x._1, x._2.length)) .toArray .sortBy(x => x._2) //In order to plot the data we split the values from the addresses as //this is how the plotting library accepts the data. //为了将数据绘图,我们将数值与这些地址分离开,因为绘图库只接收这样的数据 val senderDescriptions = mailsGroupedBySender .map(x => x._1) val senderValues = mailsGroupedBySender .map(x => x._2.toDouble) val barPlot = BarPlot.plot("", senderValues, senderDescriptions) //Rotate the email addresses by -80 degrees such that we can read them //将邮件地址旋转80度以方便阅读 barPlot.getAxis(0).setRotation(-1.3962634) barPlot.setAxisLabel(0, "") barPlot.setAxisLabel(1, "Amount of emails received ") peer.setContentPane(barPlot) bounds = new Rectangle(800, 600)
这里可以看到,给你发送邮件最多的人发了45封,其后是37封,然后就迅速下降了。这些异常值的存在,会导致直接使用这些数据时,推荐系统仅将邮件发送最多的 1 到 2 位发送人列为重要级别,而剩下的则不考虑。为了防止出现这个问题,我们将通过 log1p 函数对数据进行重缩放。 log1p 函数是将数据加 1,然后再对其取对数 log。将数据加 1 的操作是考虑到发送人只发送一封邮件的情况。在对数据进行这样一个取对数操作之后,其图像是这样的。
//Code changes: //代码更新 val mailsGroupedBySender = trainingData .groupBy(x => x.sender) .map(x => (x._1, Math.log1p(x._2.length))) .toArray .sortBy(x => x._2) barPlot.setAxisLabel(1, "Amount of emails received on log Scale ")
事实上,这些数据仍是相同的,只不过用了不同的比例来展示。注意数据的数值范围在 0.69 与 3.83 之间。这个范围小了很多,异常值也不会太偏离其他数据。在机器学习领域,这种数据操作技巧是很常用的。找到合适的缩放比例需要某种洞察力。所以,在做重缩放时,应用 Smile 的绘图库画出多幅不同缩放比例的图,会给工作带来很大的帮助。
下一个我们要分析的特征是主题出现的频率和时段。如果一个主题经常出现,那它有可能更重要。此外,我们还考虑了一个邮件会话的持续时间。因此,一个主题的频率可以用这个主题下邮件会话的持续时间来标准化。这样,高度活跃的邮件会话会被排在最前面,再次强调,这也是我们的一个假设。
我们来看看这些主题和它们的出现次数:
//Add to 'def top' //添加到‘def top’ val mailsGroupedByThread = trainingData .groupBy(x => x.subject) //Create a list of tuples with (subject, list of emails) //创建一列元组(主题,邮件列表) val threadBarPlotData = mailsGroupedByThread .map(x => (x._1, x._2.length)) .toArray .sortBy(x => x._2) val threadDescriptions = threadBarPlotData .map(x => x._1) val threadValues = threadBarPlotData .map(x => x._2.toDouble) //Code changes in 'def top' //改变的‘def top’代码 val barPlot = BarPlot.plot(threadValues, threadDescriptions) barPlot.setAxisLabel(1, "Amount of emails per subject")
可以看出这与发送人的情况有类似的分布,因此我们再一次应用 log1p 函数。
//Code change: //代码更新 val threadBarPlotData = mailsGroupedByThread .map(x => (x._1, Math.log1p(x._2.length))) .toArray .sortBy(x => x._2)
现在,每个主题的邮件数的取值范围变为 0.69 到 3.41,对推荐系统来说,这比 1 到 29 的范围要好。不过,我们还没有把时间段考虑进去,因此我们回到标准频率上来,着手对数据作变换。为此,我们首先要获取每个邮件会话中,第一个邮件到最后一个邮件的时间间隔:
//Create a list of tuples with (subject, list of emails, //time difference between first and last email) //创建一列元组(主题,邮件列表,首末两封邮件的时间间隔) val mailGroupsWithMinMaxDates = mailsGroupedByThread .map(x => (x._1, x._2, (x._2 .maxBy(x => x.emailDate) .emailDate.getTime - x._2 .minBy(x => x.emailDate) .emailDate.getTime ) / 1000 ) ) //turn into a list of tuples with (topic, list of emails, // time difference, and weight) filtered that only threads occur //转换为一列过滤后的元组(话题,邮件列表,时间间隔,权重),其中只出现邮件对话 val threadGroupedWithWeights = mailGroupsWithMinMaxDates .filter(x => x._3 != 0) .map(x => (x._1, x._2, x._3, 10 + Math.log10(x._2.length.toDouble / x._3))) .toArray .sortBy(x => x._4) val threadGroupValues = threadGroupedWithWeights .map(x => x._4) val threadGroupDescriptions = threadGroupedWithWeights .map(x => x._1) //Change the bar plot code to plot this data: //改变条形图代码以将这些数据绘图 val barPlot = BarPlot.plot(threadGroupValues, threadGroupDescriptions) barPlot.setAxisLabel(1, "Weighted amount of emails per subject")
注意代码中我们是如何确定时间差的,并将其除以 1000。这是为了把时间单位从毫秒化成秒。此外,我们用一个主题的频率除以时间差来计算权重。因为该值很小,我们对其取 log10 函数以使它放大一些。但这样做会把数值变负数,因此,我们将每个数值都加上 10 使其为正数。这个加权过程的结果如下:
我们想要的数值大概落在 4.4 到 8.6 的范围,这表明异常值对特征的影响已经很小。此外,我们观察权重最高的 10 个主题和最低的 10 个主题,看看到底发生了什么。
权重最高的 10 个主题
权重最低的 10 个主题
可以看到,权重最高的是那些短时间内就收到回复的邮件,而权重最低的则是那些回复等待时间很长的邮件。这样的话,即使是那些频率很低的主题也可以根据其往来邮件之间时间间隔短而被排在重要的位置。因此,我们可以得到两个特征:来自发送人的邮件数量 mailsGroupedBySender 和属于一个已知邮件会话的邮件的权重 threadGroupedWithWeights。
如前所述,我们的排序系统是要基于尽可能多的特征的,我们继续找下一个特征。这个特征是以我们刚刚计算出的权重值为基础的。我们的想法是,邮箱会收到带有不同主题的新邮件。不过,这些邮件的主题有可能含有类似于之前收到的重要邮件主题的关键词。因此,在一个邮件会话(一个主题下的多封往来邮件)开始之前,我们就能够将邮件排序。为此,我们把关键词的权重指定为含有这些关键词的主题的权重。如果多个主题都含有这些关键词,我们就选择权重最高的一个排在第一位。
这个特征有个问题,那就是停用词。还好,目前我们有一个停用词文件可以让我们剔除(大部分)英语停用词。然而,当你在设计自己的系统时,还应该考虑到可能会有多种语言出现,这时你就要剔除所有这些语言的停用词了。此外,在不同的语言中,某些单词会有多种不同的意思,因此,在这种情况下剔除停用词就要小心了。就现在来说,我们继续剔除英语停用词。这个特征的代码如下:
def getStopWords: List[String] = { val source = scala.io.Source .fromFile(new File("/Users/../stopwords.txt"))("latin1") val lines = source.mkString.split("n") source.close() lines.toList } //Add to top: //添加到顶部 val stopWords = getStopWords val threadTermWeights = threadGroupedWithWeights .toArray .sortBy(x => x._4) .flatMap(x => x._1 .replaceAll("[^a-zA-Z ]", "") .toLowerCase.split(" ") .filter(_.nonEmpty) .map(y => (y,x._4))) val filteredThreadTermWeights = threadTermWeights .groupBy(x => x._1) .map(x => (x._1, x._2.maxBy(y => y._2)._2)) .toArray.sortBy(x => x._1) .filter(x => !stopWords.contains(x._1))
给定这段代码,我们就得到了一张列表 filteredThreadTermWeights,该列表基于已有邮件会话中的权重列出了一些关键词。这些权重可以用来计算新邮件的主题的权重,即使这封邮件不是对已有会话的回复。
对于第四个特征,我们打算合并考虑在所有邮件中出现频率很高的词汇的权重。为此,我们要创建一个 TDM,不过这一次和前一个例子中的有点不同,这次,我们只将所有文档中的词汇频率取对数。此外,我们还要对出现率取 log10 函数。这么做可以缩小词汇频率的比例,防止结果被可能的异常值影响。
val tdm = trainingData .flatMap(x => x.body.split(" ")) .filter(x => x.nonEmpty && !stopWords.contains(x)) .groupBy(x => x) .map(x => (x._1, Math.log10(x._2.length + 1))) .filter(x => x._2 != 0)
这个 TDM 列表可以帮助我们根据历史数据计算新邮件正文的权重,这个权重很重要。
准备了这 4 个特征之后,我们就可以对训练数据做实际的排序计算。为此,我们要计算出每封邮件的 senderWeight (代表发送人的权重)、termWeight (代表主题词汇的权重)、threadGroupWeight (代表邮件会话的权重)以及 commonTermsWeight (代表邮件正文的权重),并将它们相乘以得到最后的排序。由于我们是做乘法而不是加法,我们需要小心那些小于1的数值。例如,有人发送了一封邮件,那么其 sengerWeight 就是 0.69,若将其与那些还未发送过任何邮件的人作比较就会不公平,因为对方的 senderWeight 值是 1。因此,对于那些数值可能低于 1 的各个特征,我们取函数 Math.max(value,1)。我们来看看代码:
val trainingRanks = trainingData.map(mail => { //Determine the weight of the sender, if it is lower than 1, pick 1 instead //确定发送人的权重,如果小于1,则设为1 //This is done to prevent the feature from having a negative impact //这是为了防止特征出现负面影响 val senderWeight = mailsGroupedBySender .collectFirst { case (mail.sender, x) => Math.max(x,1)} .getOrElse(1.0) //Determine the weight of the subject //确定主题的权重 val termsInSubject = mail.subject .replaceAll("[^a-zA-Z ]", "") .toLowerCase.split(" ") .filter(x => x.nonEmpty && !stopWords.contains(x) ) val termWeight = if (termsInSubject.size > 0) Math.max(termsInSubject .map(x => { tdm.collectFirst { case (y, z) if y == x => z} .getOrElse(1.0) }) .sum / termsInSubject.length,1) else 1.0 //Determine if the email is from a thread, //and if it is the weight from this thread: //判断邮件是否来自一个会话,如果是,其在该会话的权重: val threadGroupWeight: Double = threadGroupedWithWeights .collectFirst { case (mail.subject, _, _, weight) => weight} .getOrElse(1.0) //Determine the commonly used terms in the email and the weight belonging to it: //确定邮件中的常用词汇及其权重: val termsInMailBody = mail.body .replaceAll("[^a-zA-Z ]", "") .toLowerCase.split(" ") .filter(x => x.nonEmpty && !stopWords.contains(x) ) val commonTermsWeight = if (termsInMailBody.size > 0) Math.max(termsInMailBody .map(x => { tdm.collectFirst { case (y, z) if y == x => z} .getOrElse(1.0) }) .sum / termsInMailBody.length,1) else 1.0 val rank = termWeight * threadGroupWeight * commonTermsWeight * senderWeight (mail, rank) }) val sortedTrainingRanks = trainingRanks .sortBy(x => x._2) val median = sortedTrainingRanks(sortedTrainingRanks.length / 2)._2 val mean = sortedTrainingRanks .map(x => x._2).sum / sortedTrainingRanks.length
我们计算了训练集中所有邮件的排序,还将它们做了排序并取中位数和平均值。我们取中位数和平均值是为了确定一个决策边界,通过该边界来评估一封邮件是优先级还是非优先级。在实践中,这个办法通常并不管用。实际上最好的办法是让用户标记一组邮件作为优先级,与另一组邮件作为非优先级。然后就可以用这些邮件的排序来计算出决策边界,此外还可以确认排序系统的特征是否选得正确。如果最终用户标记为非优先级的邮件评级比标记为优先级的邮件还高,那你可能要重新评估你所选取的特征。
我们之所以提出这个决策边界,而不是仅仅对用户邮件进行排序,是考虑了时间这个因素。如果你纯粹根据排序来整理邮件,那结果将会令人讨厌,因为人们通常喜欢根据时间来整理邮件。然而,假设我们要把这一排序系统融合到一个邮件客户端中,有了这个决策边界之后,我们就可以标记优先级邮件,然后把它们单独显示在一个列表中。
我们来看看在训练集中,有多少封邮件被标记为优先级。为此,我们首先需要添加下列代码:
val testingRanks = trainingData.map(mail => { //mail contains (full content, date, sender, subject, body) //包含(全部内容、日期、发送人、主题、正文)的邮件 //Determine the weight of the sender //确定发送人的权重 val senderWeight = mailsGroupedBySender .collectFirst { case (mail.sender, x) => Math.max(x,1)} .getOrElse(1.0) //Determine the weight of the subject //确定主题的权重 val termsInSubject = mail.subject .replaceAll("[^a-zA-Z ]", "") .toLowerCase.split(" ") .filter(x => x.nonEmpty && !stopWords.contains(x) ) val termWeight = if (termsInSubject.size > 0) Math.max(termsInSubject .map(x => { tdm.collectFirst { case (y, z) if y == x => z} .getOrElse(1.0) }) .sum / termsInSubject.length,1) else 1.0 //Determine if the email is from a thread, //and if it is the weight from this thread: //判断一封邮件是否来自一个会话,如果是,其在该会话的权重: val threadGroupWeight: Double = threadGroupedWithWeights .collectFirst { case (mail.subject, _, _, weight) => weight} .getOrElse(1.0) //Determine the commonly used terms in the email and the weight belonging to it: //确定邮件中的常用词汇及其权重: val termsInMailBody = mail.body .replaceAll("[^a-zA-Z ]", "") .toLowerCase.split(" ") .filter(x => x.nonEmpty && !stopWords.contains(x) ) val commonTermsWeight = if (termsInMailBody.size > 0) Math.max(termsInMailBody .map(x => { tdm.collectFirst { case (y, z) if y == x => z} .getOrElse(1.0) }) .sum / termsInMailBody.length,1) else 1.0 val rank = termWeight * threadGroupWeight * commonTermsWeight * senderWeight (mail, rank) }) val priorityEmails = testingRanks .filter(x => x._2 >= mean) println(priorityEmails.length + " ranked as priority")
在实际执行了这个测试代码以后,你将看到这个测试集被标记为优先级的邮件数量实际是 563,占到测试集邮件数的 45%。这是一个相当大的值,所以我们可以用决策边界进行调节。不过,我们仅以此作为说明的目的,这个值并不应该拿去代表实际情况,所以我们就不再去纠结那个百分比。反之,我们来看看优先级邮件中评级最高的10封。
注意我已经将电子邮件地址的部分内容删除了,以防止垃圾邮件程序抓取这些地址。从下表中可以看到,这10封优先级最高的邮件中,大部分邮件都来自不同的会话,这些会话的活动性很高。以评级最高的那封邮件为例,这封邮件是一封9分钟前的邮件的回复。这就表明了这个邮件会话的重要性。
此外,我们还看到 tim.One… 在这个表中出现很多次。这反映出他的所有邮件都很重要,或者,他发送了这么多邮件以致排序系统自动将其评为优先。作为这个实例的最后一步,我们对这一点再做些讨论:
val timsEmails = testingRanks .filter(x => x._1.sender == "tim.one@...") .sortBy(x => -x._2) timsEmails .foreach(x => println( "| " + x._1.emailDate + " | " + x._1.subject + " | " + df.format(x._2) + " |") )
运行这一段代码后,一个包含 45 封邮件的列表就会被打印出来,评级最低的 10 封邮件如下:
我们知道决策边界就是平均值,这里是 25.06,那么可以看出,Tim 只有一封邮件没有被标记为优先级。这表明,一方面,我们的决策边界太低了,而另一方面,Tim也许真的发送了很多重要邮件,否则很多邮件的评级就会低于决策边界。不幸的是,我们无法为你提供确切的答案,因为我们不是这些测试数据的原主人。
当你手中的数据并非一手数据时,验证一个像这样的排序系统是相当困难的。验证并改善这个系统最常用的方式是将它开放给客户使用,让他们标记、纠正错误。而这些纠错就可以用来改善系统。
总的来说,我们介绍了如何从含有异常值的原始数据中获取特征,以及如何把这些特征耦合到最终的排序值中。此外,我们还尝试对这些特征进行验证,但由于缺乏对数据集的确切把握,我们无法得出明确的结论。不过,如果你想使用自己的直接数据进行同样的过程,那么这个实例就可以帮助你建立自己的排序系统。
在这部分中,我们将介绍普通最小二乘法,它是一种线性回归方法。因为这种方法十分强大,所以开始实例之前,有必要先了解回归与常见的陷阱。我们将在这部分介绍一部分这些问题,其他的相关问题我们已在欠拟合与过拟合这两小节中介绍过了。
线性回归的基本思想是用一条“最优的”回归线来拟合数据点。注意这只对线性数据并且无过大异常值的情况才适用。如果你的数据不满足这种条件,那你可以尝试对数据进行操作,例如将数据取平方或取对数,直到满足适用条件。
与往常一样,在一项工程开始时,首先要导入一个数据集。为此,我们提供了如下 csv 文件以及读取文件用的代码:
def getDataFromCSV(file: File): (Array[Array[Double]], Array[Double]) = { val source = scala.io.Source.fromFile(file) val data = source.getLines().drop(1).map(x => getDataFromString(x)).toArray source.close() var inputData = data.map(x => x._1) var resultData = data.map(x => x._2) return (inputData,resultData) } def getDataFromString(dataString: String): (Array[Double], Double) = { //Split the comma separated value string into an array of strings //把用逗号分隔的数值字符串分解为一个字符串数组 val dataArray: Array[String] = dataString.split(',') var person = 1.0 if (dataArray(0) == ""Male"") { person = 0.0 } //Extract the values from the strings //从字符串中抽取数值 //Since the data is in US metrics //inch and pounds we will recalculate this to cm and kilo's //因数据是采用美制单位英寸和磅,我们要将它们转换为厘米和千克 val data : Array[Double] = Array(person,dataArray(1).toDouble * 2.54) val weight: Double = dataArray(2).toDouble * 0.45359237 //And return the result in a format that can later easily be used to feed to Smile //并以一定格式返回结果,使得该结果之后容易输入到Smile中处理 return (data, weight) }
注意,该数据读取器将数值从英制单位转换为公制单位。这对 OLS 的应用没有什么大影响,不过我们还是采用更为常用的公制单位。
这样操作之后我们得到一个数组 Array[Array[Double]],该数组包含了数据点和 Array[Double] 值,该值代表男性或女性。这种格式既有利于将数据绘图,也有利于将数据导入机器学习算法中。
我们首先看看数据是什么样的。为此,用下列代码将数据绘成图。
object LinearRegressionExample extends SimpleSwingApplication { def top = new MainFrame { title = "Linear Regression Example" val basePath = "/Users/.../OLS_Regression_Example_3.csv" val testData = getDataFromCSV(new File(basePath)) val plotData = (testData._1 zip testData._2).map(x => Array(x._1(1) ,x._2)) val maleFemaleLabels = testData._1.map( x=> x(0).toInt) val plot = ScatterPlot.plot( plotData, maleFemaleLabels, '@', Array(Color.blue, Color.green) ) plot.setTitle("Weight and heights for male and females") plot.setAxisLabel(0,"Heights") plot.setAxisLabel(1,"Weights") peer.setContentPane(plot) size = new Dimension(400, 400) }
如果你执行上面这段代码,就会弹出一个窗口显示以下右边那幅图像。注意当代码运行时,你可以滚动鼠标来放大和缩小图像。
在这幅图像中,绿色代表女性,蓝色代表男性,可以看到,男女的身高和体重有很大部分是重叠的。因此,如果我们忽略男女性别,数据看上去依旧是呈线性的(如左图所示)。然而,若不考虑男女性别差异,模型就不够精确。
在本例中,找出这种区别(将数据依性别分组)是小事一桩,然而,你可能会碰到一些其中的数据区分不那么明显的数据集。意识到这种可能性对数据分组是有帮助的,从而有助于改善机器学习应用程序的性能。
既然我们已经考察过数据,也知道我们确实可以建立一条回归线来拟合数据,现在就该训练模型了。Smile 库提供了普通最小二乘算法,我们可以用如下代码轻松调用:
val olsModel = new OLS(testData._1,testData._2)
有了这个 OLS 模型,我们现在可以根据某人的身高和性别预测其体重了:
println("Prediction for Male of 1.7M: " +olsModel.predict(Array(0.0,170.0))) println("Prediction for Female of 1.7M:" + olsModel.predict(Array(1.0,170.0))) println("Model Error:" + olsModel.error())
结果如下:
Prediction for Male of 1.7M: 79.14538559840447 Prediction for Female of 1.7M:70.35580395758966 Model Error:4.5423150758157185
回顾前文的分类算法,它有一个能够反映模型性能的先验值。回归分析是一种更强大的统计方法,它可以给出一个实际误差。这个值反映了偏离拟合回归线的平均程度,因此可以说,在这个模型中,一个身高1.70米的男性的预测体重是 79.15kg ± 4.54kg,4.54 为误差值。注意,如果不考虑数据的男女差异,这一误差会增加到 5.5428。换言之,考虑了数据的男女差异后,模型在预测时,精确度提高了 ±1kg
最后一点,Smile 库也提供了一些关于模型的统计信息。R平方值是模型的均方根误差(RMSE)与平均函数的 RMSE 之比。这个值介于 0 与 1 之间。假如你的模型能够准确的预测每一个数据点,R平方值就是 1,如果模型的预测效果比平均函数差,则该值为 0。在机器学习领域中,通常将该值乘以 100,代表模型的精确度。它是一个归一化值,所以可以用来比较不同模型的性能。
本部分总结了线性回归分析的过程,如果你还想了解如何将回归分析应用于非线性数据,请随时学习下一个实例“应用文本回归尝试畅销书排行预测”。
在实例“根据身高预测体重”中,我们介绍了线性回归的概念。然而,有时候需要将回归分析应用到像文本这类的非数字数据中去。
在本例中,我们将通过尝试预测最畅销的 100 本 O’Reilly 公司出版的图书,说明如何应用文本回归。此外,我们还介绍在本例的特殊情况下应用文本回归无法解决问题。原因仅仅是这些数据中不含有可以被我们的测试数据利用的信号。即使如此,本例也并非一无是处,因为在实践中,数据可能会含有实际信号,该信号可以被这里要介绍的文本回归检测到。
本例使用到的数文件可以在这里下载。除了 Smile 库,本例也会使用 Scala-csv 库,因为 csv 中包含带逗号的字符串。我们从获取需要的数据开始:
object TextRegression { def main(args: Array[String]): Unit = { //Get the example data //获取案例数据 val basePath = "/users/.../TextRegression_Example_4.csv" val testData = getDataFromCSV(new File(basePath)) } def getDataFromCSV(file: File) : List[(String,Int,String)]= { val reader = CSVReader.open(file) val data = reader.all() val documents = data.drop(1).map(x => (x(1),x(3)toInt,x(4))) return documents } }
现在我们得到了 O’Reilly 出版社最畅销100部图书的书名、排序和详细说明。然而,当涉及某种回归分析时,我们需要数字数据。这就是问什么我们要建立一个文档词汇矩阵 (DTM)。注意这个 DTM 与我们在垃圾邮件分类实例中建立的词汇文档矩阵 (TDM) 是类似的。区别在于,DTM 存储的是文档记录,包含文档中的词汇,相反,TDM 存储的是词汇记录,包含这些词汇所在的一系列文档。
我们自己用如下代码生成 DTM:
import java.io.File import scala.collection.mutable class DTM { var records: List[DTMRecord] = List[DTMRecord]() var wordList: List[String] = List[String]() def addDocumentToRecords(documentName: String, rank: Int, documentContent: String) = { //Find a record for the document //找出一条文档记录 val record = records.find(x => x.document == documentName) if (record.nonEmpty) { throw new Exception("Document already exists in the records") } var wordRecords = mutable.HashMap[String, Int]() val individualWords = documentContent.toLowerCase.split(" ") individualWords.foreach { x => val wordRecord = wordRecords.find(y => y._1 == x) if (wordRecord.nonEmpty) { wordRecords += x -> (wordRecord.get._2 + 1) } else { wordRecords += x -> 1 wordList = x :: wordList } } records = new DTMRecord(documentName, rank, wordRecords) :: records } def getStopWords(): List[String] = { val source = scala.io.Source.fromFile(new File("/Users/.../stopwords.txt"))("latin1") val lines = source.mkString.split("n") source.close() return lines.toList } def getNumericRepresentationForRecords(): (Array[Array[Double]], Array[Double]) = { //First filter out all stop words: //首先过滤出所有停用词 val StopWords = getStopWords() wordList = wordList.filter(x => !StopWords.contains(x)) var dtmNumeric = Array[Array[Double]]() var ranks = Array[Double]() records.foreach { x => //Add the rank to the array of ranks //将评级添加到排序数组中 ranks = ranks :+ x.rank.toDouble //And create an array representing all words and their occurrences //for this document: //为该文档创建一个数组,表示所有单词及其出现率 var dtmNumericRecord: Array[Double] = Array() wordList.foreach { y => val termRecord = x.occurrences.find(z => z._1 == y) if (termRecord.nonEmpty) { dtmNumericRecord = dtmNumericRecord :+ termRecord.get._2.toDouble } else { dtmNumericRecord = dtmNumericRecord :+ 0.0 } } dtmNumeric = dtmNumeric :+ dtmNumericRecord } return (dtmNumeric, ranks) } } class DTMRecord(val document : String, val rank : Int, var occurrences : mutable.HashMap[String,Int] )
观察这段代码,注意到这里面有一个方法 def getNumericRepresentationForRecords(): (Array[Array[Double]], Array[Double])。这一方法返回一个元组,该元组以一个矩阵作为第一个参数,该矩阵中每一行代表一个文档,每一列代表来自 DTM 文档的完备词汇集中的词汇。注意第一个列表中的浮点数表示词汇出现的次数。
第二个参数是一个数组,包含第一个列表中所有记录的排序值。
现在我们可以按如下方式扩展主程序,这样就可以得到所有文档的数值表示:
val documentTermMatrix = new DTM() testData.foreach(x => documentTermMatrix.addDocumentToRecords(x._1,x._2,x._3))
有了这个从文本到数值的转换,现在我们可以利用回归分析工具箱了。我们在“基于身高预测体重”的实例中应用了普通最小二乘法 (OLS),不过这次我们要应用“最小绝对收缩与选择算子”(Lasso) 回归。这是因为我们可以给这种回归方法提供某个 λ 值,它代表一个惩罚值。该惩罚值可以帮助 LASSO 算法选择相关的特征(单词)而丢弃其他一些特征(单词)。
LASSO 执行的这一特征选择功能非常有用,因为在本例中,文档说明包含了大量的单词。LASSO 会设法找出那些单词的一个合适的子集作为特征,而要是应用 OLS,则所有单词都会被使用,那么运行时间将会变得极其漫长。此外,OLS 算法实现会检测非满秩。这是维数灾难的一种情形。
无论如何,我们需要找出一个最佳的 λ 值,因此,我们应该用交叉验证法尝试几个 λ 值,操作过程如下:
for (i <- 0 until cv.k) { //Split off the training datapoints and classifiers from the dataset //从数据集中将用于训练的数据点与分类器分离出来 val dpForTraining = numericDTM ._1 .zipWithIndex .filter(x => cv .test(i) .toList .contains(x._2) ) .map(y => y._1) val classifiersForTraining = numericDTM ._2 .zipWithIndex .filter(x => cv .test(i) .toList .contains(x._2) ) .map(y => y._1) //And the corresponding subset of data points and their classifiers for testing //以及对应的用于测试的数据点子集及其分类器 val dpForTesting = numericDTM ._1 .zipWithIndex .filter(x => !cv .test(i) .contains(x._2) ) .map(y => y._1) val classifiersForTesting = numericDTM ._2 .zipWithIndex .filter(x => !cv .test(i) .contains(x._2) ) .map(y => y._1) //These are the lambda values we will verify against //这些是我们将要验证的λ值 val lambdas: Array[Double] = Array(0.1, 0.25, 0.5, 1.0, 2.0, 5.0) lambdas.foreach { x => //Define a new model based on the training data and one of the lambda's //定义一个基于训练数据和其中一个λ值的新模型 val model = new LASSO(dpForTraining, classifiersForTraining, x) //Compute the RMSE for this model with this lambda //计算该模型的RMSE值 val results = dpForTesting.map(y => model.predict(y)) zip classifiersForTesting val RMSE = Math .sqrt(results .map(x => Math.pow(x._1 - x._2, 2)).sum / results.length ) println("Lambda: " + x + " RMSE: " + RMSE) } }
多次运行这段代码会给出一个在 36 和 51 之间变化的 RMSE 值。这表示我们排序的预测值会偏离至少 36 位。鉴于我们要尝试预测最高的 100 位,结果表明这个模型的效果非常差。在本例中,λ 值变化对模型的影响并不明显。然而,在实践中应用这种算法时,要小心地选取 λ 值: λ 值选得越大,算法选取的特征数就越少。 所以,交叉验证法对分析不同 λ 值对算法的影响很重要。
引述 John Tukey 的一句话来总结这个实例:
“数据中未必隐含答案。某些数据和对答案的迫切渴求的结合,无法保证人们从一堆给定数据中提取出一个合理的答案。”
主成分分析 (PCA) 的基本思路是减少一个问题的维数。这是一个很好的方法,它可以避免维灾难,也可以帮助合并数据,避开无关数据的干扰,使其中的趋势更明显。
在本例中,我们打算应用 PCA 把 2002-2012 年这段时间内 24 只股票的股价合并为一只股票的股价。这个随时间变化的值就代表一个基于这 24 只股票数据的股票市场指数。把这24种股票价格合并为一种,明显地减少了处理过程中的数据量,并减少了数据维数,对于之后应用其他机器学习算法作预测,如回归分析来说,有很大的好处。为了看出特征数从 24 减少为 1 之后的效果,我们会将结果与同一时期的道琼斯指数 (DJI) 作比较。
随着工程的开始,下一步要做的是加载数据。为此,我们提供了两个文件:Data file 1 和 Data file 2.
object PCA extends SimpleSwingApplication{ def top = new MainFrame { title = "PCA Example" //Get the example data //获取案例数据 val basePath = "/users/.../Example Data/" val exampleDataPath = basePath + "PCA_Example_1.csv" val trainData = getStockDataFromCSV(new File(exampleDataPath)) } def getStockDataFromCSV(file: File): (Array[Date],Array[Array[Double]]) = { val source = scala.io.Source.fromFile(file) //Get all the records (minus the header) //获取所有记录(减去标头) val data = source .getLines() .drop(1) .map(x => getStockDataFromString(x)) .toArray source.close() //group all records by date, and sort the groups on date ascending //按日期将所有记录分组,并按日期将组升序排列 val groupedByDate = data.groupBy(x => x._1).toArray.sortBy(x => x._1) //extract the values from the 3-tuple and turn them into // an array of tuples: Array[(Date, Array[Double)] //抽取这些3元组的值并将它们转换为一个元组数组:Array[(Date,Array[Double])] val dateArrayTuples = groupedByDate .map(x => (x._1, x ._2 .sortBy(x => x._2) .map(y => y._3) ) ) //turn the tuples into two separate arrays for easier use later on //将这些元组分隔为两个数组以方便之后使用 val dateArray = dateArrayTuples.map(x => x._1).toArray val doubleArray = dateArrayTuples.map(x => x._2).toArray (dateArray,doubleArray) } def getStockDataFromString(dataString: String): (Date,String,Double) = { //Split the comma separated value string into an array of strings //把用逗号分隔的数值字符串分解为一个字符串数组 val dataArray: Array[String] = dataString.split(',') val format = new SimpleDateFormat("yyyy-MM-dd") //Extract the values from the strings //从字符串中抽取数值 val date = format.parse(dataArray(0)) val stock: String = dataArray(1) val close: Double = dataArray(2).toDouble //And return the result in a format that can later //easily be used to feed to Smile //并以一定格式返回结果,使得该结果之后容易输入到Smile中处理 (date,stock,close) } }
有了训练数据,并且我们已经知道要将24个特征合并为一个单独的特征,现在我们可以进行主成分分析,并按如下方式为数据点检索数据。
//Add to `def top` //添加到‘def top’中 val pca = new PCA(trainData._2) pca.setProjection(1) val points = pca.project(trainData._2) val plotData = points .zipWithIndex .map(x => Array(x._2.toDouble, -x._1(0) )) val canvas: PlotCanvas = LinePlot.plot("Merged Features Index", plotData, Line.Style.DASH, Color.RED); peer.setContentPane(canvas) size = new Dimension(400, 400)
这段代码不仅执行了 PCA,还将结果绘成图像,y 轴表示特征值,x 轴表示每日。
为了能看出 PCA 合并的效果,我们现在通过如下方式调整代码将道琼斯指数加入到图像中:
首先把下列代码添加到 def top 方法中:
//Verification against DJI //用道琼斯指数验证 val verificationDataPath = basePath + "PCA_Example_2.csv" val verificationData = getDJIFromFile(new File(verificationDataPath)) val DJIIndex = getDJIFromFile(new File(verificationDataPath)) canvas.line("Dow Jones Index", DJIIndex._2, Line.Style.DOT_DASH, Color.BLUE)
然后我们需要引入下列两个方法:
def getDJIRecordFromString(dataString: String): (Date,Double) = { //Split the comma separated value string into an array of strings //把用逗号分隔的数值字符串分解为一个字符串数组 val dataArray: Array[String] = dataString.split(',') val format = new SimpleDateFormat("yyyy-MM-dd") //Extract the values from the strings //从字符串中抽取数值 val date = format.parse(dataArray(0)) val close: Double = dataArray(4).toDouble //And return the result in a format that can later //easily be used to feed to Smile //并以一定格式返回结果,使得该结果之后容易输入到Smile中处理 (date,close) } def getDJIFromFile(file: File): (Array[Date],Array[Double]) = { val source = scala.io.Source.fromFile(file) //Get all the records (minus the header) //获取所有记录(减去标头) val data = source .getLines() .drop(1) .map(x => getDJIRecordFromString(x)).toArray source.close() //turn the tuples into two separate arrays for easier use later on //将这些元组分隔为两个数组以方便之后使用 val sortedData = data.sortBy(x => x._1) val dates = sortedData.map(x => x._1) val doubles = sortedData.map(x => x._2 ) (dates, doubles) }
这段代码加载了 DJI 数据,并把它绘成图线添加到我们自己的股票指数图中。然而,当我们执行这段代码时,效果图有点无用。
如你所见,DJI 的取值范围与我们的计算特征的取值范围偏离很远。因此,现在我们要将数据标准化。办法就是根据数据的取值范围将数据进行缩放,这样,两个数据集就会落在同样的比例中。
用下列代码替换 getDJIFromFile 方法:
def getDJIFromFile(file: File): (Array[Date],Array[Double]) = { val source = scala.io.Source.fromFile(file) //Get all the records (minus the header) //获取所有记录(减去标头) val data = source .getLines() .drop(1) .map(x => getDJIRecordFromString(x)) .toArray source.close() //turn the tuples into two separate arrays for easier use later on //将这些元组分隔为两个数组以方便之后使用 val sortedData = data.sortBy(x => x._1) val dates = sortedData.map(x => x._1) val maxDouble = sortedData.maxBy(x => x._2)._2 val minDouble = sortedData.minBy(x => x._2)._2 val rangeValue = maxDouble - minDouble val doubles = sortedData.map(x => x._2 / rangeValue ) (dates, doubles) }
用下列代码替换 def top 方法中 plotData 的定义:
val maxDataValue = points.maxBy(x => x(0)) val minDataValue = points.minBy(x => x(0)) val rangeValue = maxDataValue(0) - minDataValue(0) val plotData = points .zipWithIndex .map(x => Array(x._2.toDouble, -x._1(0) / rangeValue))
现在我们看到,虽然 DJI 的取值范围落在 0.8 与 1.8 之间,而我们的新特征的取值范围落在 -0.5 与 0.5 之间,但两条曲线的趋势符合得很好。学完这个实例,加上段落中对 PCA 的说明,现在你应该学会了 PCA 并能把它应用到你自己的数据中。
在我们实际开始应用支持向量机 (SVM) 之前,我会稍微介绍一下 SVM。基本的 SVM 是一个二元分类器,它通过挑选出一个代表数据点之间最大距离的超平面,将数据集分为两部分。一个 SVM 就带有一个所谓的“校正率”值。如果不存在理想分割,则该校正率提供了一个误差范围,允许人们在该范围内找出一个仍尽可能合理分割的超平面。因此,即使仍存在一些令人不快的点,在校正率规定的误差范围内,超平面也是合适的。这意味着,我们无法为每种情形提出一个“标准的”校正率。不过,如果数据中没有重叠部分,则较低的校正率要优于较高的校正率。
我刚刚说明了作为一个二元分类器的基本 SVM,但是这些原理也适用于具有更多类别的情形。然而,现在我们要继续完成具有 2 种类别的实例,因为仅说明这种情况已经足够了。
在本例中,我们将完成几个小案例,其中,支持向量机 (SVM) 的表现都胜过其他分离算法如 KNN。这种方法与前几例中的不同,但它能帮你更容易学会怎么使用以及何时使用 SVM。
对于每个小案例,我们会提供代码、图像、不同参数时的 SVM 运行测试以及对测试结果的分析。这应该使你对输入 SVM 算法的参数有所了解。
在第一个小案例中,我们将应用高斯核函数,不过在 Smile 库中还有其他核函数。其他核函数可以在这里找到。紧接着高斯核函数,我们将讲述多项式核函数,因为这个核函数与前者有很大的不同。
我们会在每个小案例中用到下列的基本代码,其中只有构造函数 filePaths 和 svm 随每个小案例而改变。
object SupportVectorMachine extends SimpleSwingApplication { def top = new MainFrame { title = "SVM Examples" //File path (this changes per example) //文件路径(随案例而改变) val trainingPath = "/users/.../Example Data/SVM_Example_1.csv" val testingPath = "/users/.../Example Data/SVM_Example_1.csv" //Loading of the test data and plot generation stays the same //加载测试数据,绘图生成代码保持相同 val trainingData = getDataFromCSV(new File(path)) val testingData = getDataFromCSV(new File(path)) val plot = ScatterPlot.plot( trainingData._1, trainingData._2, '@', Array(Color.blue, Color.green) ) peer.setContentPane(plot) //Here we do our SVM fine tuning with possibly different kernels //此处,我们用可能的不同核函数对SVM进行微调 val svm = new SVM[Array[Double]](new GaussianKernel(0.01), 1.0,2) svm.learn(trainingData._1, trainingData._2) svm.finish() //Calculate how well the SVM predicts on the training set //计算SVM对测试集的预测效果 val predictions = testingData ._1 .map(x => svm.predict(x)) .zip(testingData._2) val falsePredictions = predictions .map(x => if (x._1 == x._2) 0 else 1 ) println(falsePredictions.sum.toDouble / predictions.length * 100 + " % false predicted") size = new Dimension(400, 400) } def getDataFromCSV(file: File): (Array[Array[Double]], Array[Int]) = { val source = scala.io.Source.fromFile(file) val data = source .getLines() .drop(1) .map(x => getDataFromString(x)) .toArray source.close() val dataPoints = data.map(x => x._1) val classifierArray = data.map(x => x._2) return (dataPoints, classifierArray) } def getDataFromString(dataString: String): (Array[Double], Int) = { //Split the comma separated value string into an array of strings //把用逗号分隔的数值字符串分解为一个字符串数组 val dataArray: Array[String] = dataString.split(',') //Extract the values from the strings //从字符串中抽取数值 val coordinates = Array( dataArray(0).toDouble, dataArray(1).toDouble) val classifier: Int = dataArray(2).toInt //And return the result in a format that can later //easily be used to feed to Smile //并以一定格式返回结果,使得该结果之后容易输入到Smile中处理 return (coordinates, classifier) }
在本案例中,我们介绍了最常用的 SVM 核函数,即高斯核函数。我们的想法是帮助读者寻找该核函数的最佳输入参数。本例中用到的数据可以在这里下载。
从该图中可以清楚看出,线性回归线在这里起不了作用。我们要使用一个 SVM 来作预测。在给出的第一段代码中,高斯核函数的 sigma 值为 0.01,边距惩罚系数为 1.0,类别总数为 2,并将其传递给了 SVM。那么,这些都代表什么意思呢?
我们从高斯核函数说起。这个核函数反映了 SVM 如何计算系统中成对数据的相似度。对于高斯核函数,用到了欧氏距离中的方差。我们特意挑选高斯核函数的原因是,数据中并不含有明显的结构如线性函数、多项式函数或者双曲线函数。相反地,数据聚集成了3组。
我们传递到高斯核中构造函数的参数是 sigma。这个 sigma 值反映了核函数的平滑程度。我们会演示改变这一取值如何影响预测效果。我们将边距惩罚系数取 1。这一参数定义了系统中向量的边距,因此,这一值越小,约束向量就越多。我们会执行一组运行测试,通过结果向读者说明这个参数在实践中的作用。注意其中 s: 代表 sigma,c: 代表校正惩罚系数。百分数表示预测效果的误差率, 它只不过是训练之后,对相同数据集的错误预测的百分数。
不幸的是,并不存在为每个数据集寻找正确 sigma 的黄金法则。不过,可能最好的方法就是计算数据的 sigma 值,即 √(variance),然后在这个值附近取值看看哪一个 sigma 值效果最好。因为本例数据的方差在 0.2 与 0.5 之间,我们把这区间作为中心并在中心的两边都选取一些值,以比较我们的案例中使用高斯核的 SVM 的表现。
看看表格中的结果和错误预测的百分比,它表明产生最佳效果的参数组合是一个非常低的 sigma (0.001) 和一个 1.0 及以上的校正率。不过,如果把这个模型应用到实际中的新数据上,可能会产生过拟合。因此,在用模型本身的训练数据测试模型时,你应该保持谨慎。一个更好的方法是使用交叉验证,或用新数据验证。
高斯核并不总是最佳选择,尽管在应用 SVM 时,它是最常用的核函数。因此,在本例中,我们将演示一个多项式核函数胜过高斯核函数的案例。注意,虽然本案例中的示例数据是构建好的,但在本领域内相似的数据(带有一点噪声)是可以找到的。本案例中的训练数据可以在这里下载,测试数据在这里下载。
对于本例数据,我们用一个三次多项式创建了两个类别,并生成了一个测试数据文件和一个训练数据文件。训练数据包含x轴上的前500个点,而测试数据则包含x轴上500到1000这些点。为了分析多项式核函数的工作原理,我们将数据汇成图。左图是训练数据的,右图是测试数据的。
考虑到本实例开头给出的基本代码,我们作如下的替换:
val trainingPath = "/users/.../Example Data/SVM_Example_2.csv" val testingPath = "/users/.../Example Data/SVM_Example_2_Test_data.csv"
然后,如果我们使用高斯核并且运行代码,就可以得到如下结果:
可以看到,即使是最佳情况,仍然有 27.4% 的测试数据被错误分类。这很有趣,因为当我们观察图像时,可以看到两个类别之间有一个很明显的区分。我们可以对 sigma 和校正率进行微调,但是当预测点很远时(例如 x 是 100000),sigma 和校正率就会一直太高而使模型表现不佳(时间方面与预测效果方面)。
因此,我们将高斯核替换为多项式核,代码如下:
val svm = new SVM[Array[Double]](new PolynomialKernel(2), 1.0,2)
注意我们给多项式核的构造函数传递 2 的方式。这个 2 代表它要拟合的函数的次数。如果我们不单考虑次数为 2 的情况,我们还考虑次数为2、3、4、5的情况,并且让校正率再一次在 0.001 到 100 之间变化,则得到如下结果:
从中我们可以看到,次数为 3 和 5 的情况得到了100%的准确率,这两种情况中测试数据与训练数据之间没有一个点是重叠的。与高斯核的最佳情况 27.4% 的错误率相比,这种表现令人惊喜。确实要注意本例这些数据是构建好的,因此没有什么噪声数据。所以才能出现所有的“校正率”都为 0% 错误率。如果添加了噪声,则需要对校正率进行微调。
以上就是对支持向量机这一部分的总结。
在了解了机器学习的整体思想之后,你应该可以辨别出哪些情况分别属于分类问题、回归问题或是维数约化问题。此外,你应该理解机器学习的基本概念,什么是模型,并且知道机器学习中的一些常见陷阱。
在学完本文中的实例之后,你应该学会应用 K-NN、朴素贝叶斯算法以及线性回归分析了。此外,你也能够应用文本回归、使用 PCA 合并特征以及应用支持向量机。还有非常重要的一点,就是能够建立你自己的推荐系统。