Classifying email as spam or ham (NaiveBayes)
在这个例子中,我们将使用朴素贝叶斯算法来根据电子邮件的内容将电子邮件分类为ham(好的电子邮件)或spam邮件(坏的电子邮件)。朴素贝叶斯算法计算每个可能类的对象的概率,然后返回具有最高概率的类。对于这种概率计算,此算法使用特征。它被称为朴素贝叶斯的原因是因为它不包含特征之间的任何相关性。换句话说,每个特征计数相同。 我将用一个例子解释一点:
假设你是基于颜色、直径和形状这些特征来分类水果和蔬菜的,你有以下类:苹果,番茄和蔓越莓。
假设接下来你想要使用以下特征值来分类对象:(红色,4厘米,圆形)。显然这是一个番茄,因为它比苹果小,比蔓越莓大。 但是,由于朴素贝叶斯算法单独评估每个要素,因此将其分类如下:
• 苹果有66.6%的可能性(基于颜色和形状)
• 番茄有100.0%可能性 (基于颜色,形状和大小)
• 蔓越莓有66.6% 可能性(基于颜色和形状)
因此,即使它似乎真的很明显,它不可能是蔓越莓或苹果,朴素贝叶斯仍然给它一个66.6%的可能性。 因此,即使它正确地分类出了番茄,它可能在边界条件下给出差的结果,当大小刚好在训练集的范围之外时。 然而,对于垃圾邮件分类,朴素贝叶斯效果很好,因为垃圾邮件或ham不能纯粹基于一个特征(单词)进行分类。
正如您现在应该知道朴素贝叶斯算法是如何工作的,我们可以继续使用实际的例子。对于这个例子,我们将使用Scala下Smile中的Naive Bayes实现根据内容将电子邮件分类为垃圾邮件或ham。
在我们可以开始之前,您应该从SpamAssasins公共语料库下载这个例子的数据。这个例子您需要的数据是easy_ham和垃圾邮件文件,但其余的也可以使用,如果你想尝试更多的情况的化。您应该解压缩这些文件,并调整代码片段中的文件路径以匹配文件夹的位置。 此外,您将需要停用词文件用于过滤主题。
与每个机器学习实现一样,第一步是加载训练数据。然而在这个例子中,我们直接进入机器学习中。在KNN的例子中,我们有下载和上传速度作为功能。 我们没有将它们称为特征,因为它们是唯一可用的属性。对于垃圾邮件分类,它不是特定的东西作为特征。你可以使用发件人、主题、消息内容,甚至发送时间作为用于分类为垃圾邮件或ham的特征。
在这个例子中,我们将使用电子邮件的内容作为特征。这意味着我们将从训练集中的电子邮件的主体中选择特征(在这种情况下的词)。 为了能够做到这一点,我们需要建立一个术语文档矩阵(TDM)。
我们将开始编写用于加载示例数据的函数。这通过getMessage方法来完成,该方法从给定File作为参数的电子邮件中获取过滤的主体。
代码语言:javascript复制def getMessage(file : File) : String =
{
//Note that theencoding of the example files is latin1,
// thus thisshould be passed to the fromFile method.
val source =scala.io.Source.fromFile(file)("latin1")
val lines =source.getLines mkString "n"
source.close()
//Find thefirst line break in the email,
//as thisindicates the message body
valfirstLineBreak = lines.indexOf("nn")
//Return themessage body filtered by only text from a-z and to lower case
return lines
.substring(firstLineBreak)
.replace("n"," ")
.replaceAll("[^a-zA-Z ]","")
.toLowerCase()
}
现在我们需要一个方法从我们为您提供的样本数据的文件夹结构来获取电子邮件的所有文件名。
代码语言:javascript复制def getFilesFromDir(path: String):List[File] = {
val d = newFile(path)
if (d.exists&& d.isDirectory) {
//Remove themac os basic storage file,
//andalternatively for unix systems "cmds"
d .listFiles
.filter(x=> x .isFile &&
!x .toString
.contains(".DS_Store") &&
!x .toString
.contains("cmds"))
.toList
}
else {
List[File]()
}
}
最后,让我们定义一组路径,使它更容易从样本数据种加载不同的数据集。与此同时,我们还直接定义样本大小为500,因为这是针对垃圾邮件集的完整的电子邮件训练数量。我们采用相同数量的ham电子邮件,因为训练集应该对这两个分类组进行平衡。
代码语言:javascript复制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"
valeasyHam2Path = basePath "/easy_ham_2"
valamountOfSamplesPerSet = 500
valamountOfFeaturesToTake = 100
//First get asubset of the filenames for the spam
// sample set(500 is the complete set in this case)
vallistOfSpamFiles = getFilesFromDir(spamPath)
.take(amountOfSamplesPerSet)
//Then get themessages that are contained in these files
val spamMails =listOfSpamFiles.map(x => (x, getMessage(x)))
//Get a subsetof the filenames from the ham sample set
// (note thatin this case it is not necessary to randomly
// sample asthe emails are already randomly ordered)
vallistOfHamFiles = getFilesFromDir(easyHamPath)
.take(amountOfSamplesPerSet)
//Get themessages that are contained in the ham files
valhamMails = listOfHamFiles
.map{x=> (x,getMessage(x)) }
}
现在我们已经拥有了ham和垃圾邮件的训练数据,我们可以开始构建2个TDM。 但在我们向你展示代码之前,让我们先简单解释为什么我们实际上需要这个。TDM将包含包含在训练集的主体中的所有字,包括频率大小。然而,由于频率可能不是最好的措施(因为1个电子邮件包含1.000.000倍的单词'cake'从而会弄乱整个表),我们还将计算发生率。我们的意思是,包含该特定术语的文档数量。 所以让我们开始生成两个TDM。
代码语言:javascript复制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 thewords by occurrence rate descending
//(amount oftimes 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 thewords by occurrence rate descending
//(amount oftimes the word occurs among all documents)
valsortedHamTDM = hamTDM
.sortBy(x => - (x._2.size.toDouble / spamMails.length))
对于这个表,我使用wordcloud生成图像以获得更多了解。 让我们来看看这些图像中表示的每个表的前50个字。请注意,红色字来自垃圾邮件表,绿色字来自ham表。 另外,单词的大小表示发生率。 因此,单词越大,该单词至少包含一次的文档越多。
正如你所看到的,前面的大多数是停止词。这些停止词是噪声,我们应该尽可能地在我们的特征选择中去除掉。 因此,我们应该在选择特征之前从表中删除这些。 我们在示例数据集中包含了停止词列表。 让我们首先定义代码来获取这些停止词。
代码语言:javascript复制def getStopWords() : List[String] =
{
val source =scala.io.Source
.fromFile(new File("/Users/.../.../ExampleData/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
现在我们在看最前面的对于垃圾/ham邮件的50个词,此时大多数停止词消失了
通过对“垃圾”单词和典型的“ham-words”的了解,我们决定构建一个特征集,然后我们可以在朴素贝叶斯算法中使用它来创建分类器。 注意:包含更多特征总是更好,但是将所有字作为特征时,性能可能会成为一个问题。这就是为什么在实践中,开发人员倾向于删除没有显着影响的特征,这纯粹是出于性能原因。 或者机器学习的完成是运行在完整的Hadoop集群上的,但解释这个将超出本博客的范围。
现在,我们将基于出现次数(这并不是频率)选择前100个垃圾词,并对ham-words执行相同操作,并将其组合成1组词,这些词可以反馈回贝叶斯算法。最后,我们还将训练数据转换为适应贝叶斯算法的输入。注意,最终特征集因此是200-(#intersecting words * 2)。
代码语言:javascript复制//Add the code for getting the TDM data and combining itinto a feature bag.
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 intersectingfeatures, 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
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 theemails
var classifiers = Array.fill[Int](amountOfSamplesPerSet)(0)
Array.fill[Int](amountOfSamplesPerSet)(1)
//Get the trainingData in the right format for the spammails
var spamData = spamMails
.map(x =>bag.feature(x._2.split(" ")))
.toArray
//Get the trainingData in the right format for the hammails
var hamData = hamMails
.map(x =>bag.feature(x._2.split(" ")))
.toArray
//Combine the training data from both categories
var trainingData = spamData hamData
给定这个特征集,和一组训练数据,我们可以开始训练算法。 为此,我们可以选择几个不同的模型:一般的,多项式的和伯努利的。 一般模型需要定义一个分布,这个分布我们事先不知道,所以这不是一个好的选择。多项式和伯努利之间的差异是它们处理单词出现的方式。 伯努利模型仅验证特征是否存在(双值1或0),从而省略出现的统计数据,其中多项式模型包含事件(occurrences)(由值表示)。与多项式模型相比,这会导致伯努利模型对较长文档的执行错误。因此我们将评级电子邮件,并且我们想使用事件,我们专注于多项式,但也可以自由地尝试伯努利模型。
代码语言:javascript复制//Create the bayes model as a multinomial with 2classification
// groups and the amount of features passed in theconstructor.
var bayes = newNaiveBayes(NaiveBayes.Model.MULTINOMIAL, 2, data.size)
//Now train thebayes instance with the training data,
// which isrepresented in a specific format due to the
//bag.featuremethod, and the known classifiers.
bayes.learn(trainingData, classifiers)
现在我们已经训练了模型,因此可以再次做一些验证。 然而,在示例数据中,我们已经做出了简单和困难的ham以及垃圾邮件之间的分离,因此我们不会应用交叉验证,而是使用这些测试集验证模型。我们将开始验证垃圾邮件分类。 为此,我们使用spam97文件夹中的1397封垃圾邮件。
代码语言:javascript复制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 aspam classification (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 categorythe email
//belongs to, it gives a -1 (unknown) rather than a 0(spam) or 1 (ham)
val unknownClassifications = spam2ClassificationResults
.count( x=>x == -1)
println( unknownClassifications
"of "
listOfSpam2Files.length
"were unknowingly classified"
)
println( ( (unknownClassifications.toDouble /
listOfSpam2Files.length) * 100)
%was unknowingly classified"
)
运行此程序后的显示如下:
amountOfFeaturesToTake | Spam (Correct) | Unknown | Ham |
---|---|---|---|
50 | 1281 (91.70%) | 16 (1.15%) | 100 (7.15%) |
100 | 1189 (85.11%) | 18 (1.29%) | 190 (13.6%) |
200 | 1197 (85.68%) | 16 (1.15%) | 184 (13.17%) |
400 | 1219 (87.26%) | 13 (0.93%) | 165 (11.81%) |
请注意,归类为垃圾邮件的电子邮件数量是按模型正确分类的数量。 有趣的是,该算法最适合分类垃圾邮件只有50个特征。然而,前50个分类术语中仍然有停止词的查全率可以解释这个结果。 如果您注意到此值随着特征数量而增加(从100开始)而变化的方式,您可以看到,随着更多特征,总体结果增加。 请注意,这里有一组未知的电子邮件。 对于这些电子邮件,两个类的先验值是相等的。注意,如果在电子邮件中没有用于ham或垃圾邮件的特征词,也是如此,因为算法随后将其分类为50%ham和50%垃圾邮件。
我们现在将对ham电子邮件执行相同的分类过程。 这是通过更改从listOfSpam2Files到easyHam2Path的变量路径并重新运行代码来完成的。 这给了我们以下结果:
amountOfFeaturesToTake | Spam | Unknown | Ham (Correct) |
---|---|---|---|
50 | 120 (8.57%) | 28 ( 2.0%) | 1252 (89.43%) |
100 | 44 (3.14%) | 11 (0.79%) | 1345 (96.07%) |
200 | 36 (2.57%) | 7 (0.5%) | 1357 (96.93%) |
400 | 24 (1.71%) | 7 (0.5%) | 1369 (97.79%) |
请注意,现在正确分类的电子邮件是那些被归类为ham的。这里我们看到,当你只使用50个特征时,正确分类的ham数量与使用100个特征时的正确分类相比明显更低。你应该知道这一点,并始终验证所有类的模型,所以在这种情况下,应同时处理垃圾邮件和ham测试数据。
回顾这个例子,我们已经讨论了如何使用Naive Bayes将电子邮件分类为ham或垃圾邮件,并且结果为垃圾邮件的正确分类为87.26%,ham的正确分类结果为97.79%。 这表明,朴素贝叶斯确实表现得很好,在对将电子邮件分类为ham或垃圾邮件这个操作中。