在这一章,我们将建立一个垃圾邮件过滤分类模型。我们将使用一个包含垃圾邮件和非垃圾邮件的原始电子邮件数据集,并使用它来训练我们的ML模型。我们将开始遵循上一章讨论的开发ML模型的步骤。这将帮助我们理解工作流程。
在本章中,我们将讨论以下主题:
l 定义问题
l 准备数据
l 数据分析
l 构建数据的特征
l 逻辑回归与朴素贝叶斯的Email垃圾邮件过滤
l 验证分类模型
定义问题
让我们从定义本章要解决的问题开始。我们可能已经对垃圾邮件很熟悉了;垃圾邮件过滤是众电子邮件服务的基本功能。垃圾邮件对用户来说可能是恼人的,但它们除此之外,也会带来更多的问题和风险。例如,可以设计垃圾邮件来获取信用卡号或银行帐户信息,这些信息可用于信用卡欺诈或洗钱。垃圾邮件也可以用来获取个人数据,然后可以用于身份盗窃和各种其他犯罪。垃圾邮件过滤技术是电子邮件服务避免用户遭受此类犯罪的重要一步。然而,有正确的垃圾邮件过滤解决方案是困难的。我们想过滤掉可疑的邮件,但同时,我们又不想过滤太多,以至于非垃圾邮件进入垃圾邮件文件夹,永远不会被用户看到。为了解决这个问题,我们将让我们的ML模型从原始电子邮件数据集中学习,并使用主题行将可疑电子邮件归类为垃圾邮件。我们将着眼于两个性能指标来衡量我们的成功:准确度和召回率。我们将在以下几节中详细讨论这些指标。
总结我们的问题定义:
n 需要解决的问题时什么?我们需要一个垃圾邮件过滤解决方案,以防止我们的用户成为欺诈活动的受害者,同时改善用户体验。
n 为什么这是个问题?在过滤可疑邮件和不过滤太多邮件之间取得适当的平衡是很困难的,这样垃圾邮件仍然会进入收件箱。我们将依靠ML模型来学习如何对这些可疑邮件进行统计分类。
n 解决这个问题的方法有哪些?我们将建立一个分类模型,根据邮件的主题行,标记潜在的垃圾邮件。我们将使用准确性和召回率来平衡被过滤的邮件数量。
n 成功的标准是什么?我们想要高回复率(实际垃圾邮件检索的百分比占垃圾邮件的总数),而不牺牲太多的精确率(正确分类的垃圾邮件的百分比中预测为垃圾邮件)。
准备数据
现在,我们已经清楚地描述和定义了将要用ML解决的问题,接下来我们需要准备数据。通常,我们需要在数据准备步骤之前采取额外的步骤来收集我们需要的数据,但是现在,我们将使用一个预先编译并标记为公共可用的数据集。在本章中,我们将使用CSDMC2010垃圾数据集来训练和测试我们的模型。我们将看到一个名为SPAMTrain.label的文本文件。SPAMTrain.label文件对训练文件夹中的每封邮件都进行了编码,0代表垃圾邮件,1代表非垃圾邮件。我们将使用此文本文件和训练文件夹中的电子邮件数据来构建垃圾邮件分类模型。
我们现在拥有的是一个原始数据集,其中包含许多EML文件,其中包含关于单个电子邮件的信息,以及一个包含标记信息的文本文件。为了使这个原始数据集可用来构建垃圾邮件分类模型,我们需要做以下工作:
- 从EML文件中提取主题行:为将来的任务准备数据的第一步是从各个EML文件中提取主题和正文。我们将使用一个名为EAGetMail的包来加载和提取EML文件中的信息。使用EAGetMail包,我们可以轻松地从EML文件中加载和提取主题和正文内容。一旦从电子邮件中提取了主题和正文,就需要将每行数据作为一行附加到Deedle数据框架中。
- 将提取的数据与标签结合起来:在从各个EML文件中提取主题和正文内容之后,我们还需要做一件事。我们需要将经过编码的标签(垃圾邮件为0,而非垃圾邮件为1)映射到我们在前一步中创建的数据帧的每一行。如果我们打开垃圾邮件。标签文件与任何文本编辑器,您可以看到编码的标签在第一列和相应的电子邮件文件名在第二列,由一个空格分隔。使用Deedle frame的ReadCsv函数,我们可以通过指定一个空格作为分隔符来轻松地将这个标签数据加载到数据框架中。一旦我们将这个标记的数据加载到一个数据框架中,我们就可以简单地将这个数据框架的第一列添加到前面步骤中使用Deedle框架的AddColumn函数创建的其他数据框架中。
- 将合并后的数据导出为CSV文件:现在我们已经有了一个包含电子邮件和标签数据的数据框架,现在可以将该数据框架导出为CSV文件,以供将来使用。使用Deedle frame的SaveCsv函数,您可以轻松地将数据帧保存为CSV文件。
这个准备数据步骤的代码如下:
1 using Deedle; 2 using EAGetMail; 3 using System; 4 using System.IO; 5 using System.Linq; 6 7 namespace 准备数据 8 { 9 internal class Program 10 { 11 private static void Main(string[] args) 12 { 13 // 获取所有原始的电子邮件格式的文件 14 // TODO: 更改指向数据目录的路径 15 string rawDataDirPath = @"D:\工作\代码库\AI\垃圾邮件过滤\raw-data"; 16 string[] emailFiles = Directory.GetFiles(rawDataDirPath, "*.eml"); 17 18 // 从电子邮件文件中解析出主题和正文 19 var emailDF = ParseEmails(emailFiles); 20 // 获取每个电子邮件的标签(spam vs. ham) 21 var labelDF = Frame.ReadCsv(rawDataDirPath + "\\SPAMTrain.label", hasHeaders: false, separators: " ", schema: "int,string"); 22 // 将这些标签添加到电子邮件数据框架中 23 emailDF.AddColumn("is_ham", labelDF.GetColumnAt<String>(0)); 24 // 将解析后的电子邮件和标签保存为CSV文件 25 emailDF.SaveCsv("transformed.csv"); 26 27 Console.WriteLine("准备数据步骤完成!"); 28 Console.ReadKey(); 29 } 30 31 private static Frame<int, string> ParseEmails(string[] files) 32 { 33 // 我们将解析每个电子邮件的主题和正文,并将每个记录存储到键值对中 34 var rows = files.AsEnumerable().Select((x, i) => 35 { 36 // 将每个电子邮件文件加载到邮件对象中 37 Mail email = new Mail("TryIt"); 38 email.Load(x, false); 39 40 // 提取主题和正文 41 string EATrialVersionRemark = "(Trial Version)"; // EAGetMail在试用版本中附加主题“(试用版本)” 42 string emailSubject = email.Subject.EndsWith(EATrialVersionRemark) ? 43 email.Subject.Substring(0, email.Subject.Length - EATrialVersionRemark.Length) : email.Subject; 44 string textBody = email.TextBody; 45 46 // 使用电子邮件id (emailNum)、主题和正文创建键-值对 47 return new { emailNum = i, subject = emailSubject, body = textBody }; 48 }); 49 50 // 根据上面创建的行创建一个数据帧 51 return Frame.FromRecords(rows); 52 } 53 } 54 }
运行这段代码后,程序将会创建一个名为transformed.csv的文件,它将包含四列(emailNum、subject、body和is_ham)。我们将使用此输出数据作为后面步骤的输入,以构建垃圾邮件过滤项目的ML模型。但是,我们也可以尝试使用Deedle框架和EAGetMail包,以不同的方式调整和准备这些数据。我在这里提供的代码是准备这些原始电子邮件数据以供将来使用的一种方法,以及我们可以从原始电子邮件数据中提取的一些信息。使用EAGetMail包,我们也可以提取其他特征,比如发件人的电子邮件地址和电子邮件中的附件,这些额外的特征可能有助于改进垃圾邮件分类模型。
数据分析
在准备数据步骤中,我们将原始数据集转换为更具可读性和可用性的数据集。我们现在有一个文件可以查看,以找出哪些邮件是垃圾邮件,哪些不是。此外,我们可以很容易地找到垃圾邮件和非垃圾邮件的主题行。有了这些转换后的数据,让我们开始看看数据实际上是什么样子的,看看我们能否在数据中找到任何模式或问题。
因为我们正在处理文本数据,所以我们首先要看的是垃圾邮件和非垃圾邮件的单词分布有什么不同。为此,我们需要将上一步的数据输出转换为单词出现次数的矩阵表示。让我们以数据中的前三个主题行为例,一步步地完成这一工作。我们的前三个主题如下:
如果我们转换这些数据,使每一列对应于每一个主题行中的每个单词,并将每个单元格的值编码为1,如果给定的主题行有单词,则编码为0,如果没有,则生成的矩阵如下所示:
这种特定的编码方式称为one-hot编码,我们只关心特定的单词是否出现在主题行中,而不关心每个单词在主题行中实际出现的次数。在前面的例子中,我们还去掉了所有的标点符号,比如冒号、问号和感叹号。要以编程方式做到这一点,我们可以使用regex将每个主题行拆分为只包含字母-数字字符的单词,然后用one-hot编码构建一个数据框架。完成这个编码步骤的代码如下:
1 private static Frame<int, string> CreateWordVec(Series<int, string> rows) 2 { 3 var wordsByRows = rows.GetAllValues().Select((x, i) => 4 { 5 var sb = new SeriesBuilder<string, int>(); 6 7 ISet<string> words = new HashSet<string>( 8 Regex.Matches( 9 // 只字母字符 10 x.Value, "[a-zA-Z]+('(s|d|t|ve|m))?" 11 ).Cast<Match>().Select( 12 // 然后,将每个单词转换为小写字母 13 y => y.Value.ToLower() 14 ).ToArray() 15 ); 16 17 // 对每行出现的单词进行1的编码 18 foreach (string w in words) 19 { 20 sb.Add(w, 1); 21 } 22 23 return KeyValue.Create(i, sb.Series); 24 }); 25 26 // 从我们刚刚创建的行创建一个数据框架 并将缺失的值编码为0 27 var wordVecDF = Frame.FromRows(wordsByRows).FillMissing(0); 28 29 return wordVecDF; 30 }
有了这种one-hot编码矩阵表示的单词,使我们的数据分析过程变的更容易。例如,如果我们想查看垃圾邮件中出现频率最高的10个单词,我们可以简单地对垃圾邮件的一个one-hot编码单词矩阵的每一列的值进行求和,然后取求和值最高的10个单词。这正是我们在以下代码中所做的:
1 var hamTermFrequencies = subjectWordVecDF.Where( 2 x => x.Value.GetAs<int>("is_ham") == 1 3 ).Sum().Sort().Reversed.Where(x => x.Key != "is_ham"); 4 5 var spamTermFrequencies = subjectWordVecDF.Where( 6 x => x.Value.GetAs<int>("is_ham") == 0 7 ).Sum().Sort().Reversed; 8 9 // 查看排名前十的垃圾邮件和非垃圾邮件 10 var topN = 10; 11 12 var hamTermProportions = hamTermFrequencies / hamEmailCount; 13 var topHamTerms = hamTermProportions.Keys.Take(topN); 14 var topHamTermsProportions = hamTermProportions.Values.Take(topN); 15 16 System.IO.File.WriteAllLines( 17 dataDirPath + "\\ham-frequencies.csv", 18 hamTermFrequencies.Keys.Zip( 19 hamTermFrequencies.Values, (a, b) => string.Format("{0},{1}", a, b) 20 ) 21 ); 22 23 var spamTermProportions = spamTermFrequencies / spamEmailCount; 24 var topSpamTerms = spamTermProportions.Keys.Take(topN); 25 var topSpamTermsProportions = spamTermProportions.Values.Take(topN); 26 27 System.IO.File.WriteAllLines( 28 dataDirPath + "\\spam-frequencies.csv", 29 spamTermFrequencies.Keys.Zip( 30 spamTermFrequencies.Values, (a, b) => string.Format("{0},{1}", a, b) 31 ) 32 );
从这段代码可以看出,我们使用Deedle的数据框架的求和方法来对每一列中的值求和,并按相反的顺序排序。我们对垃圾邮件这样做一次,对非垃圾邮件这样做一次。然后,我们使用Take方法获得垃圾邮件和非垃圾邮件中出现频率最高的十个单词。当问运行这段代码时,它将生成两个CSV文件:ham-frequency-cies.csv和spam-frequency-cies.csv。这两个文件包含关于垃圾邮件和非垃圾邮件中出现的单词数量的信息,我们将在稍后的构造数据特征和模型构建步骤中使用这些信息。
现在让我们将一些数据可视化,以便进一步分析。首先,看一下数据集中ham电子邮件中出现频率最高的10个术语:
从这个柱状图中可以看出,数据集中的非垃圾邮件比垃圾邮件要多,就像在现实世界中一样。我们的收件箱里收到的非垃圾邮件比垃圾邮件要多。
我们使用以下代码来生成这个柱状图,以可视化数据集中的ham和spam电子邮件的分布:
1 var barChart = DataBarBox.Show( 2 new string[] { "Ham", "Spam" }, 3 new double[] { 4 hamEmailCount, 5 spamEmailCount 6 } 7 ); 8 barChart.SetTitle("Ham vs. Spam in Sample Set");
使用Accord.Net中的DataBarBox类。我们可以很容易地在柱状图中可视化数据。现在让我们来看看在ham和spam邮件中出现频率最高的十个词。可以使用下面的代码来为ham和spam邮件中排名前十的术语生成柱状图:
1 var hamBarChart = DataBarBox.Show( 2 topHamTerms.ToArray(), 3 new double[][] { 4 topHamTermsProportions.ToArray(), 5 spamTermProportions.GetItems(topHamTerms).Values.ToArray() 6 } 7 ); 8 hamBarChart.SetTitle("Top 10 Terms in Ham Emails (blue: HAM, red: SPAM)"); 9 System.Threading.Thread.Sleep(3000); 10 hamBarChart.Invoke( 11 new Action(() => 12 { 13 hamBarChart.Size = new System.Drawing.Size(5000, 1500); 14 }) 15 ); 16 17 var spamBarChart = DataBarBox.Show( 18 topSpamTerms.ToArray(), 19 new double[][] { 20 hamTermProportions.GetItems(topSpamTerms).Values.ToArray(), 21 topSpamTermsProportions.ToArray() 22 } 23 ); 24 spamBarChart.SetTitle("Top 10 Terms in Spam Emails (blue: HAM, red: SPAM)");
类似地,我们使用DataBarBox类来显示条形图。当运行这段代码时,我们将看到下面的图,其中显示了在ham电子邮件中出现频率最高的10个术语:
spam邮件中最常出现的十大术语的柱状图如下:
正如所料,垃圾邮件中的单词分布与非垃圾邮件有很大的不同。例如,如果你看一下上上边的图表,spam和hibody这两个词在垃圾邮件中出现的频率很高,但在非垃圾邮件中出现的频率不高。然而,有些事情并没有多大意义。如果你仔细观察,你会发现所有的垃圾邮件和非垃圾邮件都有trial和version这两个单词,是不太可能的。如果你在文本编辑器中打开一些原始的EML文件,你会很容易发现并不是所有的电子邮件的标题行都包含这两个词。
那么,到底发生了什么?我们的数据是否被之前的数据准备或数据分析步骤污染了?
进一步的研究表明,我们使用的其中一个软件包导致了这个问题。我们用来加载和提取电子邮件内容的EAGetMail包在使用其试用版本时,会自动将(Trial Version)附加到主题行末尾。现在我们知道了这个数据问题的根本原因,我们需要回去修复它。一种解决方案是返回到数据准备步骤,用以下代码更新ParseEmails函数,它只是从主题行删除附加的(Trial Version)标志:
1 private static Frame<int, string> ParseEmails(string[] files) 2 { 3 // 我们将解析每个电子邮件的主题和正文,并将每个记录存储到键值对中 4 var rows = files.AsEnumerable().Select((x, i) => 5 { 6 // 将每个电子邮件文件加载到邮件对象中 7 Mail email = new Mail("TryIt"); 8 email.Load(x, false); 9 10 // 提取主题和正文 11 string EATrialVersionRemark = "(Trial Version)"; // EAGetMail在试用版本中附加主题“(试用版本)” 12 string emailSubject = email.Subject.EndsWith(EATrialVersionRemark) ? 13 email.Subject.Substring(0, email.Subject.Length - EATrialVersionRemark.Length) : email.Subject; 14 string textBody = email.TextBody; 15 16 // 使用电子邮件id (emailNum)、主题和正文创建键-值对 17 return new { emailNum = i, subject = emailSubject, body = textBody }; 18 }); 19 20 // 根据上面创建的行创建一个数据帧 21 return Frame.FromRecords(rows); 22 }
在更新了这段代码并再次运行之前的数据准备和分析代码之后,word分布的柱状图就更有意义了。
下面的条形图显示了修复和删除(Trial Version)标记后,ham邮件中出现频率最高的10个术语:
下面的条形图显示了修复和删除(Trial Version)标志后spam邮件中出现频率最高的10个术语
这是一个很好的例子,说明了在构建ML模型时数据分析步骤的重要性。在数据准备和数据分析步骤之间进行迭代是非常常见的,因为我们通常会在分析步骤中发现数据的问题,通常我们可以通过更新数据准备步骤中使用的一些代码来提高数据质量。现在,我们已经有了主题行中使用的单词的矩阵表示形式的清晰数据,是时候开始研究我们将用于构建ML模型的实际特性了。
构建数据的特征
在前面的步骤中,我们简要地查看了垃圾邮件和非垃圾邮件的单词分类,我们注意到了一些事情。首先,大量的最频繁出现的单词是经常使用的单词,没有什么意义。例如,像to、the、For和a这样的单词是常用的单词,而我们的ML算法不会从这些单词中学到什么。这些类型的单词被称为停止单词,它们经常被忽略或从功能集中删除。我们将使用NLTK的停止单词列表从功能集中过滤出常用的单词。
过滤这些停止字的一种方法是如下代码所示:
1 //读停词表 2 ISet<string> stopWords = new HashSet<string>(File.ReadLines(<path-to-your-stopwords.txt>); 3 //从词频序列中过滤出停止词 4 var spamTermFrequenciesAfterStopWords = spamTermFrequencies.Where( 5 x => !stopWords.Contains(x.Key) 6 );
经过滤后,非垃圾邮件常出现的十大新词语如下:
过滤掉停止词后,垃圾邮件最常出现的十大词语如下:
从这些柱状图中可以看出,过滤掉特性集中的停止词,使得更有意义的词出现在频繁出现的单词列表的顶部。然而,我们还注意到一件事。数字似乎是最常出现的单词之一。例如,数字3和2进入了非垃圾邮件中出现频率最高的10个单词。数字80和70进入了垃圾邮件中出现频率最高的10个单词。然而,很难确定这些数字是否有助于训练ML模型将电子邮件归类为垃圾邮件或垃圾邮件。
有多种方法可以从特性集中过滤掉这些数字,但是我们将只在这里展示一种方法。我们更新了上一步中使用的正则表达式,以匹配只包含字母字符而不包含字母数字字符的单词。下面的代码展示了我们如何更新CreateWordVec函数来过滤掉特性集中的数字。
1 private static Frame<int, string> CreateWordVec(Series<int, string> rows) 2 { 3 var wordsByRows = rows.GetAllValues() 4 .Select((x, i) => 5 { 6 var sb = new SeriesBuilder<string, int>(); 7 ISet<string> words = new HashSet<string>( 8 //仅字母字符 9 Regex.Matches(x.Value, "[a-zA-Z]+('(s|d|t|ve|m))?") 10 .Cast<Match>() 11 //然后,将每个单词转换为小写字母 12 .Select(y => y.Value.ToLower()) 13 .ToArray() 14 ); 15 //对每行出现的单词进行1的编码 16 foreach (string w in words) 17 { 18 sb.Add(w, 1); 19 } 20 return KeyValue.Create(i, sb.Series); 21 }); 22 //从我们刚刚创建的行中创建一个数据帧,并用0对缺失的值进行编码 23 var wordVecDF = Frame.FromRows(wordsByRows).FillMissing(0); 24 return wordVecDF; 25 }
一旦我们从功能集过滤掉这些数字,非垃圾邮件的单词分布如下:
而垃圾邮件的单词分布,在过滤掉来自功能集的数字后,看起来像这样:
可以从这些柱状图中看到,我们有更多的有意义的词在顶部的名单上,这似乎和之前有一个很大的区别,在垃圾邮件和非垃圾邮件的单词分布。那些经常出现在垃圾邮件中的单词在非垃圾邮件中似乎并不多见,反之亦然。
一旦您运行这段代码时,它将生成柱状图显示垃圾邮件单词分布和非垃圾邮件和两个单词列表的CSV files-one非垃圾邮件与相应项出现和另一个电子邮件在垃圾邮件单词列表和相应的项出现。在下面的模型构建部分中,当我们为垃圾邮件过滤构建分类模型时,我们将使用这个术语频率输出来进行特征选择过程。