使用 C# 进行 Naive Bayes 分类

Naive Bayes 分类的工作原理

图 1 为例,程序目标是预测职业为教育,习惯用右手且身高较高(不低于 71.0 英寸)的人员的性别(男性或女性)。 为此,我们可以计算具有这一指定信息的人员是男性的概率,以及具有这一指定信息的人员是女性的概率,然后依据其中较大的概率来预测性别。 若用符号表示,我们希望得出 P(male | X)(含义是“给定自变量值 X 的条件下是男性的概率”)和 P(female | X),其中 X 是 (education, right, tall)。 Naive Bayes 中“naive”一词指所有 X 属性都假定为数学上的自变量,这可以极大地简化分类。 您可以找到许多在线参考资料,其中介绍了 Naive Bayes 分类背后很有趣的数学计算,但结果都是相对简单的。 符号表示如下:


          P(male | X) =
  [ P(education | male) * P(right | male) * P(tall | male) * P(male) ] /
    [ PP(male | X) + PP(female | X) ]
        

可以看到,该等式是一个分数。 分子有时不严格地称为部分概率,是四个项的乘积。 本文中,我用非标准符号 PP 表示部分概率项。 分母是两项之和,其中一项是分子。 首先要计算 P(education | male),即在某人是男性的前提下其职业为教育的概率。 此概率最终可通过以下公式估计得出,即将职业为教育且性别为男性的训练用例的计数除以性别为男性(任意职业)的用例数:


          P(education | male ) = count(education & male) / count(male) = 2/24 = 0.0833
        

同理可得:


          P(right | male) = count(right & male) / count(male) = 17/24 = 0.7083
P(tall | male) = count(tall & male) / count(male) = 4/24 = 0.1667
        

其次要计算 P(male)。 在 Naive Bayes 术语中,它称为先验概率。 对于计算先验概率的最佳方法,存在着一些争议。 一方面,我们可以假定没有任何理由认为存在的男性比存在的女性多,因此为 P(male) 赋值 0.5。 另一方面,我们可以利用训练数据有 24 位男性和 16 位女性这一事实,估计出 P(male) 概率为 24/40 = 0.6000。 我更喜欢后一种方法,即使用训练数据估计先验概率。

现在,如果选择前面的 P(male | X) 等式,会发现该等式中包含 PP(female | X)。 下面的总和 PP(male | X) + PP(female | X) 有时称为证据。 PP(female | X) 各部分计算如下:


          P(education | female) = count(education & female) / count(female) = 4/16 = 0.2500
P(right | female) = count(right & female) / count(female) = 14/16 = 0.8750
P(tall | female) = count(tall & female) / count(female) = 2/16 = 0.1250
P(female) = 16/40 = 0.4000
        

因此,P(male | X) 的部分概率分子为:


          PP(male | X) = 0.0833 * 0.7083 * 0.1667 * 0.6000 = 0.005903
        

同理可得,在 X = (education, right, tall) 的前提下女性的部分概率为:


          PP(female | X) = 0.2500 * 0.8750 * 0.1250 * 0.4000 = 0.010938
        

最后,得出男性和女性的总体概率分别为:


          P(male | X) = 0.005903 / (0.005903 + 0.010938) = 0.3505
P(female | X) = 0.010938 / (0.005903 + 0.010938) = 0.6495
        

这些总体概率有时称为后验概率。 由于 P(female | X) 大于 P(male | X),因此系统判定未知人员的性别为女性。 但是,请等一等。 这两个概率 0.3505 和 0.6495 与图 1 所示的两个概率 0.3855 和 0.6145 虽然很接近,却截然不同。 产生这一差异的原因在于,演示程序使用了一项对基本 Naive Bayes 的重大可选修改,这项修改称为 Laplacian 平滑处理。

Laplacian 平滑处理

参考图 1 可以发现,人员职业为建筑且性别为女性的训练用例计数为 0。 在演示中,X 值为 (education, right, tall),其中并不包含建筑。 但假设 X 为 (construction, right, tall), 那么,在计算 PP(female | X) 的过程中,必须计算 P(construction | female) = count(construction & female) / count(female),而该概率为 0,这又会使整个部分概率为零。 简而言之,当结点计数为 0 时很糟糕。 避免此情况的最常用方法就是给所有结点计数加 1。 此方法看似粗暴,实际却有可靠的数学基础。 该方法称为加一平滑处理,是 Laplacian 平滑处理的一种具体形式。

利用 Laplacian 平滑处理,如果如前所述 X = (education, right, tall),则 P(male | X) 和 P(female | X) 计算如下:


          P(education | male ) =
count(education & male) + 1 / count(male) + 3 = 3/27 = 0.1111
P(right | male) =
count(right & male) + 1 / count(male) + 3 = 18/27 = 0.6667
P(tall | male) =
count(tall & male) + 1 / count(male) + 3 = 5/27 = 0.1852
P(male) = 24/40 = 0.6000
P(education | female) =
count(education & female) + 1 / count(female) + 3 = 5/19 = 0.2632
P(right | female) =
count(right & female) + 1 / count(female) + 3 = 15/19 = 0.7895
P(tall | female) =
count(tall & female) + 1 / count(female) + 3 = 3/19 = 0.1579
P(female) = 16/40 = 0.4000
        

部分概率为:


          PP(male | X) = 0.1111 * 0.6667 * 0.1852 * 0.6000 = 0.008230
PP(female | X) = 0.2632 * 0.7895 * 0.1579 * 0.4000 = 0.013121
        

因此,两个最终概率为:


          P(male | X) = 0.008230 / (0.008230 + 0.013121) = 0.3855
P(female | X) = 0.013121 / (0.008230 + 0.013121) = 0.6145
        

这些便是图 1 所示屏幕快照中的值。 可以看到,虽然每个结点计数加了 1,但给分母 count(male) 和 count(female) 加了 3。 在某种程度上来说,3 是任意值,因为 Laplacian 平滑处理并不指定要使用的任何具体值。 在本例中,它是 X 属性 (occupation, dominance, height) 的数目。 在 Laplacian 平滑处理中,这是加到部分概率分母的最常见的值,但您也可以试用其他值。 在 Naive Bayes 的数学文字中,要加到分母的值通常被赋予符号 k。 您还会看到,在 Naive Bayes Laplacian 平滑处理中,通常不会修改先验概率 P(male) 和 P(female)。

程序的整体结构

图 1 所示运行中的演示程序是单个 C# 控制台应用程序。 Main 方法如图 2 所示(其中删除了一些 WriteLine 语句)。

图 2 Naive Bayes 程序结构

  1.           using System;
  2. namespace NaiveBayes
  3. {
  4.   class Program
  5.   {
  6.     static Random ran = new Random(25); // Arbitrary
  7.     static void Main(string[] args)
  8.     {
  9.       try
  10.       {
  11.         string[] attributes = new string[] { "occupation""dominance",
  12.           "height""sex"};
  13.         string[][] attributeValues = new string[attributes.Length][];
  14.         attributeValues[0] = new string[] { "administrative",
  15.           "construction""education""technology" };
  16.         attributeValues[1] = new string[] { "left""right" };
  17.         attributeValues[2] = new string[] { "short""medium""tall" };
  18.         attributeValues[3] = new string[] { "male""female" };
  19.         double[][] numericAttributeBorders = new double[1][];
  20.         numericAttributeBorders[0] = new double[] { 64.071.0 };
  21.         string[] data = MakeData(40);
  22.         for (int i = 0; i < 4; ++i)
  23.           Console.WriteLine(data[i]);
  24.         string[] binnedData = BinData(data, attributeValues,
  25.           numericAttributeBorders);
  26.         for (int i = 0; i < 4; ++i)
  27.           Console.WriteLine(binnedData[i]);
  28.         int[][][] jointCounts = MakeJointCounts(binnedData, attributes,
  29.           attributeValues);
  30.         int[] dependentCounts = MakeDependentCounts(jointCounts, 2);
  31.         Console.WriteLine("Total male = " + dependentCounts[0]);
  32.         Console.WriteLine("Total female = " + dependentCounts[1]);
  33.         ShowJointCounts(jointCounts, attributeValues);
  34.         string occupation = "education";
  35.         string dominance = "right";
  36.         string height = "tall";
  37.         bool withLaplacian = true;
  38.         Console.WriteLine(" occupation = " + occupation);
  39.         Console.WriteLine(" dominance = " + dominance);
  40.         Console.WriteLine(" height = " + height);
  41.         int c = Classify(occupation, dominance, height, jointCounts,
  42.           dependentCounts, withLaplacian, 3);
  43.         if (c == 0)
  44.           Console.WriteLine("\nData case is most likely male");
  45.         else if (c == 1)
  46.           Console.WriteLine("\nData case is most likely female");
  47.         Console.WriteLine("\nEnd demo\n");
  48.       }
  49.       catch (Exception ex)
  50.       {
  51.         Console.WriteLine(ex.Message);
  52.       }
  53.     } // End Main
  54.     // Methods to create data
  55.     // Method to bin data
  56.     // Method to compute joint counts
  57.     // Helper method to compute partial probabilities
  58.     // Method to classify a data case
  59.   } // End class Program
  60. }
  61.         

程序首先设置硬编码的 X 属性 occupation、dominance 和 height 以及因变量属性 sex。 在某些情况下,您可能更愿意通过扫描现有数据源确定这些属性,尤其在数据源为包含标题的数据文件或是包含列名的 SQL 表时。 演示程序还指定九个分类 X 属性值: occupation 的 (administrative, construction, education, technology);dominance 的 (left, right) 和 height 的 (short, medium, tall)。 在本例中,sex 有两个因变量属性值: (male, female)。 同样,您也可以通过扫描数据以编程方式确定属性值。

演示程序通过设置硬编码的边界值 64.0 和 71.0 将 height 数值装箱,从而将小于等于 64.0 的 height 值分类为 short;将介于 64.0 与 71.0 之间的 height 值分类为 medium;将大于等于 71.0 的 height 值分类为 tall。 将 Naive Bayes 数值数据装箱时,边界值的数目比类别数少一个。 在本例中,确定 64.0 和 71.0 的方式如下:先扫描训练数据找出最小和最大 height 值(57.0 和 78.0),计算这两者之差 21.0,然后通过将该差值除以 height 类别数目 3 计算出间隔大小(即 7.0)。 多数情况下,您都会以编程而不是手动方式确定数值 X 属性的边界值。

演示程序通过调用 Helper 方法 MakeData 生成有些随机的训练数据。 MakeData 再调用 Helper 方法 MakeSex、MakeOccupation、MakeDominance 和 MakeHeight。 例如,这些 Helper 方法会生成数据,使男性职业更可能为建筑和技术,男性习惯用手更可能为右手,而男性身高更可能介于 66.0 和 72.0 英寸之间。

Main 中调用的主要方法及其用途如下:BinData 用于分类身高数据;MakeJointCounts 用于扫描装箱数据并计算结点计数;MakeDependentCounts 用于计算男性和女性的总人数;Classify 使用结点计数和因变量计数执行 Naive Bayes 分类。

数据装箱

方法 BinData 如图 3 所示。 该方法接受一个由逗号分隔字符串组成的数组,其中每个字符串类似于“education,left,67.5,male”。许多情况下,您将从每行均为字符串的文本文件读取训练数据。 该方法使用 String.Split 将每个字符串解析为标记。 Token[2] 表示 height。 它通过 double.Parse 方法从字符串转换为双精度类型。 height 数值与边界值进行比较,直到找到 height 的间隔,然后确定字符串形式的相应 height 类别。 然后,使用旧标记、逗号分隔符和新计算出的 height 类别字符串将所得的字符串连在一起。

图 3 用于对身高进行分类的方法 BinData

  1.           static string[] BinData(string[] data, string[][] attributeValues,
  2.   double[][] numericAttributeBorders)
  3. {
  4.   string[] result = new string[data.Length];
  5.   string[] tokens;
  6.   double heightAsDouble;
  7.   string heightAsBinnedString;
  8.   for (int i = 0; i < data.Length; ++i)
  9.   {
  10.     tokens = data[i].Split(',');
  11.     heightAsDouble = double.Parse(tokens[2]);
  12.     if (heightAsDouble <= numericAttributeBorders[0][0]) // Short
  13.       heightAsBinnedString = attributeValues[2][0];
  14.     else if (heightAsDouble >= numericAttributeBorders[0][1]) // Tall
  15.       heightAsBinnedString = attributeValues[2][2];
  16.     else
  17.       heightAsBinnedString = attributeValues[2][1]; // Medium
  18.     string s = tokens[0] + "," + tokens[1] + "," + heightAsBinnedString +
  19.       "," + tokens[3];
  20.     result[i] = s;
  21.   }
  22.   return result;
  23. }
  24.         

将数值数据装箱并非执行 Naive Bayes 分类的硬性要求。 Naive Bayes 可直接处理数值数据,但这些方法超出了本文的讨论范围。 数据装箱的优点是十分简单,而且无需对数据的数学分布(如高斯或泊松分布)明确作出任何具体假定。 不过,数据装箱实际上会丢失信息,而且需要确定并指定将数据划分为多少个类别。

确定结点计数

Naive Bayes 分类的关键在于计算结点计数。 在演示示例中,共有九个自变量 X 属性值 (administrative, construction, … tall) 和两个因变量属性值 (male, female),因此总共必须计算并存储 9 * 2 = 18 个结点计数。 我的首选方法是将结点计数存储在一个三维数组 int[][][] jointCounts 中。 第一个索引表示自变量 X 属性;第二个索引表示自变量 X 属性值;第三个索引表示因变量属性值。 例如,jointCounts[0][3][1] 表示属性 0 (occupation)、属性值 3 (technology) 和 sex 1 (female),换句话说,jointCounts[0][3][1] 中的值是职业为技术且性别为女性的训练用例的计数。 方法 MakeJointCounts 如图 4 所示。

图 4 方法 MakeJointCounts

  1.           static int[][][] MakeJointCounts(string[] binnedData, string[] attributes,
  2.   string[][] attributeValues)
  3. {
  4.   int[][][] jointCounts = new int[attributes.Length - 1][][]; // -1 (no sex)
  5.   jointCounts[0] = new int[4][]; // 4 occupations
  6.   jointCounts[1] = new int[2][]; // 2 dominances
  7.   jointCounts[2] = new int[3][]; // 3 heights
  8.   jointCounts[0][0] = new int[2]; // 2 sexes for administrative
  9.   jointCounts[0][1] = new int[2]; // construction
  10.   jointCounts[0][2] = new int[2]; // education
  11.   jointCounts[0][3] = new int[2]; // technology
  12.   jointCounts[1][0] = new int[2]; // left
  13.   jointCounts[1][1] = new int[2]; // right
  14.   jointCounts[2][0] = new int[2]; // short
  15.   jointCounts[2][1] = new int[2]; // medium
  16.   jointCounts[2][2] = new int[2]; // tall
  17.   for (int i = 0; i < binnedData.Length; ++i)
  18.   {
  19.     string[] tokens = binnedData[i].Split(',');
  20.     int occupationIndex = AttributeValueToIndex(0, tokens[0]);
  21.     int dominanceIndex = AttributeValueToIndex(1, tokens[1]);
  22.     int heightIndex = AttributeValueToIndex(2, tokens[2]);
  23.     int sexIndex = AttributeValueToIndex(3, tokens[3]);
  24.     ++jointCounts[0][occupationIndex][sexIndex];
  25.     ++jointCounts[1][dominanceIndex][sexIndex];
  26.     ++jointCounts[2][heightIndex][sexIndex];
  27.   }
  28.   return jointCounts;
  29. }
  30.         

该实现包含许多硬编码值,这样更容易理解。 例如,下面三条语句可换为一个 for 循环,该循环通过在数组 attributeValues 中使用 Length 属性来分配空间:

  1.           jointCounts[0] = new int[4][]; // 4 occupations
  2. jointCounts[1] = new int[2][]; // 2 dominances
  3. jointCounts[2] = new int[3][]; // 3 heights
  4.         

Helper 函数 AttributeValueToIndex 接受属性索引和属性值字符串并返回相应的索引。 例如,AttributeValueToIndex(2, “medium”) 返回 height 属性中“medium”的索引,也就是 1。

演示程序使用方法 MakeDependentCounts 确定男性和女性数据用例的数目。 有多种方法可以做到这一点。 参考图 1 可以发现,有一种方法是添加三个属性中任何一个的结点计数。 例如,男性人数是 count(administrative & male)、count(construction & male)、count(education & male) 和 count(technology & male) 的总和:

  1.           static int[] MakeDependentCounts(int[][][] jointCounts,
  2.   int numDependents)
  3. {
  4.   int[] result = new int[numDependents];
  5.   for (int k = 0; k < numDependents; ++k) 
  6.   // Male then female
  7.     for (int j = 0; j < jointCounts[0].Length; ++j)
  8.     // Scanning attribute 0
  9.       result[k] += jointCounts[0][j][k];
  10.   return result;
  11. }
  12.         

对数据用例进行分类

方法 Classify 如图 5 所示,该方法很短,因为它依赖于 Helper 方法。

图 5 方法 Classify

  1.           static int Classify(string occupation, string dominance, string height,
  2.   int[][][] jointCounts, int[] dependentCounts, bool withSmoothing,
  3.   int xClasses)
  4. {
  5.   double partProbMale = PartialProbability("male", occupation, dominance,
  6.     height, jointCounts, dependentCounts, withSmoothing, xClasses);
  7.   double partProbFemale = PartialProbability("female", occupation, dominance,
  8.     height, jointCounts, dependentCounts, withSmoothing, xClasses);
  9.   double evidence = partProbMale + partProbFemale;
  10.   double probMale = partProbMale / evidence;
  11.   double probFemale = partProbFemale / evidence;
  12.   if (probMale > probFemale) return 0;
  13.   else return 1;
  14. }
  15.         

方法 Classify 接受以下参数:jointCounts 和 dependentCounts 数组;一个布尔型字段,用于指示是否使用 Laplacian 平滑处理;参数 xClasses,在本示例中为 3,因为有三个自变量 (occupation, dominance, height)。 此参数也可从 jointCounts 参数推断得到。

方法 Classify 返回 int 值,用于表示预测因变量的索引。 实际上,您可能想要返回每个因变量的概率的数组。 请注意,分类基于 probMale 和 probFemale,它们均是通过部分概率与证据值相除得出的。 您可能希望直接忽略证据项,只比较部分概率值本身。

方法 Classify 返回具有最大概率的因变量的索引。 替代方法是提供一个阈值。 例如,假设 probMale 为 0.5001,probFemale 为 0.4999。 您可能认为这两个值过于接近,因而返回一个表示“未定”的分类值。

方法 PartialProbability 代 Classify 执行了大部分操作,如图 6 所示。

图 6 方法 PartialProbability

  1.           static double PartialProbability(string sex, string occupation, string dominance,
  2.   string height, int[][][] jointCounts, int[] dependentCounts,
  3.   bool withSmoothing, int xClasses)
  4. {
  5.   int sexIndex = AttributeValueToIndex(3, sex);
  6.   int occupationIndex = AttributeValueToIndex(0, occupation);
  7.   int dominanceIndex = AttributeValueToIndex(1, dominance);
  8.   int heightIndex = AttributeValueToIndex(2, height);
  9.   int totalMale = dependentCounts[0];
  10.   int totalFemale = dependentCounts[1];
  11.   int totalCases = totalMale + totalFemale;
  12.   int totalToUse = 0;
  13.   if (sex == "male") totalToUse = totalMale;
  14.   else if (sex == "female") totalToUse = totalFemale;
  15.   double p0 = (totalToUse * 1.0) / (totalCases); // Prob male or female
  16.   double p1 = 0.0;
  17.   double p2 = 0.0;
  18.   double p3 = 0.0;
  19.   if (withSmoothing == false)
  20.   {
  21.     p1 = (jointCounts[0][occupationIndex][sexIndex] * 1.0) / totalToUse
  22.     p2 = (jointCounts[1][dominanceIndex][sexIndex] * 1.0) / totalToUse;  
  23.     p3 = (jointCounts[2][heightIndex][sexIndex] * 1.0) / totalToUse;     
  24.   }
  25.   else if (withSmoothing == true)
  26.   {
  27.     p1 = (jointCounts[0][occupationIndex][sexIndex] + 1) /
  28.      ((totalToUse + xClasses) * 1.0); 
  29.     p2 = (jointCounts[1][dominanceIndex][sexIndex] + 1) /
  30.      ((totalToUse + xClasses) * 1.0 ;
  31.     p3 = (jointCounts[2][heightIndex][sexIndex] + 1) /
  32.      ((totalToUse + xClasses) * 1.0);
  33.   }
  34.   //return p0 * p1 * p2 * p3; // Risky if any very small values
  35.   return Math.Exp(Math.Log(p0) + Math.Log(p1) + Math.Log(p2) + Math.Log(p3));
  36. }
  37.         

为清楚起见,方法 PartialProbability 几乎全部采用了硬编码。 例如,有四个概率组分 p0、p1、p2 和 p3。 您可以使用概率数组使 PartialProbability 更通用,该数组大小由 jointCounts 数组确定。

请注意,该方法并不是返回四个概率组分的乘积,而是返回每个组分的对数和的对等指数。 使用对数概率是计算机学习算法中的一种标准方法,可用于避免非常小的实数数值可能出现的数值错误。

总结

本文展示的示例应该能为您向 .NET 应用程序添加 Naive Bayes 分类功能打下良好的基础。 Naive Bayes 分类是一种相对粗糙的方法,但相较神经网络分类、逻辑回归分类和支持向量机分类等比较精深的其他方法,确有多方面优势。 Naive Bayes 十分简单,相对易于实现,并且能够适应超大型数据集。 此外,Naive Bayes 还可轻松应对多项分类问题,即包含三个或更多因变量的问题。

源码下载

posted on 2013-02-28 16:38  。!  阅读(488)  评论(0编辑  收藏  举报