基于C#的机器学习--垃圾邮件过滤
在这一章,我们将建立一个垃圾邮件过滤分类模型。我们将使用一个包含垃圾邮件和非垃圾邮件的原始电子邮件数据集,并使用它来训练我们的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非垃圾邮件与相应项出现和另一个电子邮件在垃圾邮件单词列表和相应的项出现。在下面的模型构建部分中,当我们为垃圾邮件过滤构建分类模型时,我们将使用这个术语频率输出来进行特征选择过程。
逻辑回归与朴素贝叶斯的Email垃圾邮件过滤
我们已经走了很长的路,最终在c#中构建了我们的第一个ML模型。在本节中,我们将训练逻辑回归和朴素贝叶斯分类器来将电子邮件分为垃圾邮件和非垃圾邮件。我们将使用这两种学习算法来进行交叉验证,以更好地了解我们的分类模型在实践中的表现。如前一章所简要讨论的,在k-fold交叉验证中,训练集被划分为k个大小相等的子集,其中一个子集作为验证集,其余的k-1子集用于训练模型。然后重复这个过程k次,在每次迭代中使用不同的子集或折叠作为测试的验证集,然后对相应的k验证结果求平均值以报告单个估计。
让我们首先看看如何使用Accord在c#中用逻辑回归来实例化交叉验证算法。代码如下:
1 var cvLogisticRegressionClassifier = CrossValidation.Create<LogisticRegression, 2 IterativeReweightedLeastSquares<LogisticRegression>, double[], int>( 3 // 折叠数量 4 k: numFolds, 5 // 学习算法 6 learner: (p) => new IterativeReweightedLeastSquares<LogisticRegression>() 7 { 8 MaxIterations = 100, 9 Regularization = 1e-6 10 }, 11 // 使用0 - 1损失函数作为成本函数 12 loss: (actual, expected, p) => new ZeroOneLoss(expected).Loss(actual), 13 // 合适的分类器 14 fit: (teacher, x, y, w) => teacher.Learn(x, y, w), 15 // 输入 16 x: input, 17 // 输出 18 y: output 19 ); 20 // 运行交叉验证 21 var result = cvLogisticRegressionClassifier.Learn(input, output);
让我们更深入地看看这段代码。通过提供要训练的模型类型、适合模型的学习算法类型、输入数据类型和输出数据类型,我们可以使用静态create函数创建一个新的交叉验证算法。对于这个例子,我们创建了一个新的交叉验证算法,以逻辑回归为模型,以IterativeReweightedLeastSquares作为学习算法,以双数组作为输入类型,以整数作为输出类型(每个标签)。您可以尝试使用不同的学习算法来训练逻辑回归模型。在协议。您可以选择使用随机梯度下降算法(LogisticGradientDescent)作为适合逻辑回归模型的学习算法。
对于参数,我们可以为k-fold交叉验证(k)、带有自定义参数的学习方法(learner)、选择的损失/成本函数(loss)和一个知道如何使用学习算法(fit)来拟合模型的函数(x)、输入(x)和输出(y)指定折叠数。为了在本节中进行说明,我们为k-fold交叉验证设置了一个相对较小的数字3。此外,对于最大的迭代,我们选择了一个相对较小的数字,100,而对于迭代加权最小二乘学习算法的正则化,我们选择了一个相对较大的数字,le-6或1/1,000,000。对于损耗函数,我们使用一个简单的0 - 1损耗函数,它为正确的预测分配0,为错误的预测分配1。这就是我们的学习算法试图最小化的代价函数。所有这些参数都可以进行不同的调优。我们可以选择一个不同的损耗/成本函数,k折叠交叉验证中使用的折叠数,以及学习算法的最大迭代次数和正则化次数。我们甚至可以使用不同的学习算法来适应逻辑回归模型,比如LogisticGradientDescent,它将迭代地尝试找到损失函数的局部最小值。
我们可以用同样的方法训练朴素贝叶斯分类器,用k次交叉验证。使用朴素贝叶斯学习算法进行k-fold交叉验证的代码如下:
1 var cvNaiveBayesClassifier = CrossValidation.Create<NaiveBayes<BernoulliDistribution>, 2 NaiveBayesLearning<BernoulliDistribution>, double[], int>( 3 // 折叠的数量 4 k: numFolds, 5 // 二项分布的朴素贝叶斯分类器 6 learner: (p) => new NaiveBayesLearning<BernoulliDistribution>(), 7 // 使用0 - 1损失函数作为成本函数 8 loss: (actual, expected, p) => new ZeroOneLoss(expected).Loss(actual), 9 // 合适的分类器 10 fit: (teacher, x, y, w) => teacher.Learn(x, y, w), 11 // 输入 12 x: input, 13 // 输出 14 y: output 15 ); 16 // 运行交叉验证 17 var result = cvNaiveBayesClassifier.Learn(input, output);
之前的逻辑回归模型代码与这段代码的唯一区别是我们选择的模型和学习算法。我们使用NaiveBayes作为模型,NaiveBayesLearning作为学习算法来训练我们的NaiveBayes分类器,而不是使用LogisticRegression和IterativeReweightedLeastSquares。由于所有的输入值都是二进制的(0或1),所以我们使用BernoulliDistribution作为我们的朴素Byes分类器模型。
当你运行这段代码,你应该看到一个输出如下:
在下面讨论模型验证方法的小节中,我们将进一步研究这些数字所代表的内容。为了尝试不同的ML模型。可以使用我们前面讨论过的逻辑回归模型代码来替换它们,或者也可以尝试选择不同的学习算法使用。
验证分类模型
我们使用Accord.Net Framework在c#中建立了第一个ML模型。然而,我们还没有完全完成。如果我们更仔细地查看以前的控制台输出,就会发现有一件事非常令人担忧的情形。训练误差约为0.03,而验证误差约为0.26。这意味着我们的分类模型在训练集中正确预测了100次中的87次,而在验证或测试集中正确预测了100次中的74次。这是一个典型的过度拟合的例子,其中模型与训练集非常接近,以至于它对未预见数据集的预测是不可靠和不可预测的。如果我们将这个模型应用到垃圾邮件过滤系统中,那么实际用于过滤垃圾邮件的模型性能将是不可靠的,并且会与我们在训练集中看到的有所不同。
过度拟合通常是因为模型对于给定的数据集来说太复杂,或者使用了太多的参数来拟合模型。我们在上一节中建立的朴素贝叶斯分类器模型的过拟合问题很可能是由于我们用来训练模型的复杂性和特征的数量。
如果再次查看上一节末尾的控制台输出,我们可以看到用于训练朴素贝叶斯模型的特性的数量是2,212。这太多了,考虑到我们只有约4200封电子邮件记录,在我们的样本集只有三分之二(或大约3000条记录)被用来训练我们的模型(这是因为我们使用三倍交叉验证,只有两三个折叠用作训练集在每个迭代)。为了解决这个过拟合问题,我们必须减少用于训练模型的特性的数量。为了做到这一点,我们可以过滤掉那些不经常出现的项。完成此任务的代码,如下所示:
1 // 改变特征的数量以减少过度拟合 2 int minNumOccurences = 1; 3 string[] wordFeatures = indexedSpamTermFrequencyDF.Where( 4 x => x.Value.GetAs<int>("num_occurences") >= minNumOccurences 5 ).RowKeys.ToArray(); 6 Console.WriteLine("Num特征选择: {0}", wordFeatures.Count());
从这段代码可以看出,我们在前一节中构建的Naive Bayes分类器模型至少使用了垃圾邮件中出现的所有单词。
如果我们查看垃圾邮件中的单词频率,大约有1400个单词只出现一次(查看在数据分析步骤中创建的spam-frequencies.csv文件)。直观地说,那些出现次数少的单词只会产生噪音,对我们的模型来说没有多少信息可以学习。这告诉我们,当我们在前一节中最初构建分类模型时,我们的模型将暴露在多少噪声中。
现在我们知道了这个过度拟合问题的原因,让我们来修复它。让我们用不同的阈值来选择特征。我们已经尝试了5、10、15、20和25,以使垃圾邮件中出现的次数最少(也就是说,我们将minNumOccurrences设置为5、10、15等等),并使用这些阈值训练Naive Bayes分类器。
首先,朴素贝叶斯分类器的结果至少出现5次,如下图所示:
首先,朴素贝叶斯分类器的结果至少出现10次,如下图所示:
首先,朴素贝叶斯分类器的结果至少出现15次,如下图所示:
首先,朴素贝叶斯分类器的结果至少出现20次,如下图所示:
从这些实验结果可以看到,当我们增加了最小数量的单词出现次数和减少相应的特性数量用来训练模型, 训练误差与验证误差之间的差距减小,训练误差开始与验证误差近似。当我们解决过拟合问题时,我们可以更加确信模型将如何处理未预见的数据和生产系统。
现在我们已经介绍了如何处理过拟合问题,我们希望看看更多的模型性能度量工具:
Confusion matrix(混淆矩阵): 混淆矩阵是一个表,它告诉我们预测模型的整体性能。每一列表示每个实际类,每一行表示每个预测类。对于二元分类问题,混淆矩阵是一个2×2的矩阵,其中第一行表示消极预测,第二行表示积极预测。第一列表示实际的否定,第二列表示实际的肯定。下表说明了一个二元分类问题的混淆矩阵中的每个单元格代表什么。
True Negative (TN) :
TP、True Positive 真阳性:预测为正,实际也为正
FP、False Positive 假阳性:预测为正,实际为负
FN、False Negative 假阴性:预测与负、实际为正
TN、True Negative 真阴性:预测为负、实际也为负。
从表中可以看出,混淆矩阵描述了整个模型的性能。在我们的例子中,如果我们看最后一个控制台输出在前面的屏幕截图,显示了控制台输出的逻辑回归分类模型中,我们可以看到,TNs的数量是2847,fn的数量是606,FPs的数量是102,和76 tps的数量是772。根据这些信息,我们可以进一步计算真实阳性率(TPR)、真实负性率(TNR)、假阳性率(FPR)和假阴性率(FNR),如下:
使用前面的例子,我们例子中的真实阳性率是0.56,TNR是0.97,FPR是0.03,FNR是0.44
Accuracy(准确性):准确性是正确预测的比例。使用与前面示例混淆矩阵相同的表示法,计算精度如下:
准确性是一个经常使用的模型性能指标,但有时它并不能很好地代表整个模型的性能。例如,如果样本集很大程度上是不平衡的,并且,假设在我们的样本集中有5封垃圾邮件和95条火腿,那么一个简单的分类器将每封邮件都归类为火腿,那么它必须有95%的准确率。然而,它永远不会捕捉垃圾邮件。这就是为什么我们需要查看混乱矩阵和其他性能指标,如精度和正确率
Precision rate(精度):精度是正确的正面预测数量占全部正面预测数量的比例。使用与之前相同的符号,我们可以计算出精度率如下:
如果看看过去的控制台输出之前的截图的逻辑回归分类模型结果,精确率计算的数量除以TPs混淆矩阵,772年,由TPs的总和,FPs, 102年,772年从混淆矩阵,结果是0.88。
Recall rate(召回率):正确率是正确正面预测的数量占实际阳性总数的比例。这是告诉我们有多少实际的积极案例是由这个模型检索到的一种方式。使用与前面相同的符号,我们可以计算召回率,如下所示:
如果看看过去的控制台输出在前面的截图为我们的逻辑回归分类模式的结果,正确率计算的数量除以TPs混淆矩阵,通过TPs的总和,772年,772年和fn, 606年,混淆矩阵,其结果是0.56。
有了这些性能指标,我们就可以选择最佳模型。在精度和正确率之间总是存在权衡。与其他模型相比,具有较高准确率的模型召回率较低。对于我们的垃圾邮件过滤问题,如果认为正确地过滤垃圾邮件更重要,并且可以牺牲一些通过用户收件箱的垃圾邮件,那么我们可以优化精度。另一方面,如果认为过滤掉尽可能多的垃圾邮件更重要,即使我们可能会过滤掉一些非垃圾邮件,那么可以优化正确率。选择正确的模型不是一个简单的决定,仔细考虑需求和成功标准是做出正确选择的关键。
总之,下面是我们可以用来从交叉验证结果和混淆矩阵中计算性能指标的代码:
1 // 运行交叉验证 2 var result = cvNaiveBayesClassifier.Learn(input, output); 3 // 训练错误 4 double trainingError = result.Training.Mean; 5 //验证错误 6 double validationError = result.Validation.Mean; 7 混淆矩阵:真阳性与假阳性和真阴性与假阴性: 8 // 混淆矩阵 9 GeneralConfusionMatrix gcm = result.ToConfusionMatrix(input, output); 10 float truePositive = (float)gcm.Matrix[1, 1]; 11 float trueNegative = (float)gcm.Matrix[0, 0]; 12 float falsePositive = (float)gcm.Matrix[1, 0]; 13 float falseNegative = (float)gcm.Matrix[0, 1];
训练与验证(测试)错误:用于识别过拟合问题:
1 // 计算的准确率, 精度, 召回 2 float accuracy = (truePositive + trueNegative) / numberOfSamples; 3 float precision = truePositive / (truePositive + falsePositive); 4 float recall = truePositive / (truePositive + falseNegative);
总结
在本章中,我们用c#构建了第一个可以用于垃圾邮件过滤的ML模型。我们首先定义并清楚地说明我们要解决的问题以及成功的标准。然后,我们从原始邮件数据中提取相关信息,并将其转换为一种格式,用于数据分析、特征工程和ML模型构建步骤。
在数据分析步骤中,我们学习了如何应用单一热编码并构建主题行中使用的单词的矩阵表示。
我们还从数据分析过程中发现了一个数据问题,并了解了如何在数据准备和分析步骤之间来回迭代。
然后,我们进一步改进了我们的特性集,过滤掉停止单词,并使用正则表达式将非字母数字或非字母单词分隔开。
有了这个特征集,我们使用逻辑回归和朴素贝叶斯分类器算法建立了第一个分类模型,简要介绍了过度拟合的危险,并学习了如何通过观察准确性、精度和召回率来评估和比较模型性能。
最后,我们还学习了精度和召回之间的权衡,以及如何根据这些度量和业务需求选择模型。