常见陷阱
这部分要介绍的是在应用机器学习技术的过程中经常会碰到的问题,主要内容是向读者解析这些陷阱以帮助读者避开它们。
过度拟合
对数据进行拟合时,数据本身可能会包含噪声(例如有测量误差)。如果你精确地把每一个数据点都拟合进一个函数中,那你会把噪声也耦合到模型中去。这虽然能使模型在预测测试数据时表现良好,但在预测新数据时会相对较差。
把数据点和拟合函数画在图表中,下列左图反映过度拟合的情况,右图表示一条穿过数据点的回归曲线,它对数据点能够适当拟合。
应用回归分析时容易产生过度拟合,也很容易出现在朴素贝叶斯分类算法中。在回归分析中,产生过度拟合的途径有舍入操作、测量不良和噪声数据。在朴素贝叶斯分类算法中,选定的特征可能导致过度拟合。例如,对垃圾和正常邮件的分类问题保留所有停用词。
通过运用验证技术、观察数据的统计特征以及检测和剔除异常值,可以检测出过度拟合。
欠拟合
当你对数据进行建模时,把很多统计数据都遗漏掉了,这叫做欠拟合。有很多原因可以导致欠拟合,例如,对数据应用不合适的回归类型。如果数据中包含了非线性结构,而你却运用线性回归,这就产生了一个欠拟合模型。下列左图代表了一条欠拟合的回归线,右图右图表示一条合适的回归线。
为了防止欠拟合,你可以画出数据点从而了解数据的内在结构,以及应用验证技术,例如交叉验证。
维度灾难
对于已知的数据量存在一个最大的特征数(维数),当实际用于建立机器模型的特征数超过这个最大值时,就产生维度灾难问题。矩阵秩亏就是这样的一种问题。普通最小二乘 (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-NN 算法、Smile 库、Scala 语言)
这一部分的主要目标是运用 K 最近邻算法,根据下载/上传速度对将互联网服务供应商 (ISP) 分为 Alpha 类(由 0 代表)或 Beta 类(由 1 代表)。K-NN 算法的思路如下:给定一组已经分好类的点,那么,对新点的分类可以通过判别它的 K 个最近邻点的类别(K 是一个正整数)。K 个最近邻点可以通过计算新点与其周围点之间的欧氏距离来查找。找出这些邻近点,你就得到了最具代表性的类别,并将新点分到这一类别中。
做这一案例需要先下载示例数据。此外,还要把代码段中的路径改为你存储示例数据的地方。
首先要加载 CSV 数据文件。这没什么难的,所以我直接给代码,不做进一步解释:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
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 面板。代码转换之后如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
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 个点用于测试,会导致误差率倾斜。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
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
+
"%"
)
}
}
|
如果你多次执行上面这段代码,你可能发现错误预测率会有一些波动。这是由于用来做训练和测试的随机样本。如果不幸地取到不好的随机样本,误差率会比较高,若取到好的随机样本,则误差率会极低。
不幸的是,关于如何为模型选取最好的随机样本来训练,我没有这样的黄金法则。也许有人会说,产生最小误差率的模型总是最好的。不过,再回顾一下过拟合这个概念,选用这样的特殊模型也可能会对新数据束手无策。这就是为什么获得一个足够大且具有代表性的数据集对一个成功的机器学习应用来说非常关键。然而,当遇到这种问题时,你可以用新的数据和已知的正确分类不断更新模型。
我们概括一下目前为止的进展状况。首先小心地选取训练和测试数据;下一步,建立几个模型并验证,选出给出最好结果的模型。接下来我们到达最后一步,就是用这个模型做预测:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
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 方法,在给定一个文件作为参数后,它从一个邮件中获取过滤文本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
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
(
)
}
|
到此,在我们提供的案例数据文件夹中,我们需要一个方法来获取所有邮件的文件名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
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。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
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。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
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)的情况,我们观察一下。注意红色单词来自垃圾邮件,绿色单词来自正常邮件。此外,单词的大小代表出现率。因此,单词越大,至少出现一次该单词的文档越多。
你可以看到,停用词大多出现在前面。这些停用词是噪声,在特征选择过程中我们要尽可能避开它们。所以在选出特征之前,我们要从表格中剔除这些词。案例数据集已经包含了一列停用词,我们首先编写代码来获取这些词。
1
2
3
4
5
6
7
8
|
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 生成代码,剔除停用词:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
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)。请随意用更大或更小的特征数做实验。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
//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 模型。
1
2
3
4
5
6
7
8
9
10
11
|
//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 封垃圾邮件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
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% 的正常邮件识别率。这表明朴素贝叶斯算法在识别正常或垃圾邮件时表现得确实相当好。
朴素贝叶斯算法实例到这里就结束了。如果你还想多研究一下这个算法和垃圾邮件分类,文库里还有一组“困难级别的”正常邮件,你可以通过调整特征数、剔除更多停用词来尝试正确分类。