一. 生成式(generative)学习算法

如果算法直接学习\(p(y|x)\),或者尝试学习从输入空间\(X\)到类别\(\{0,1\}\)的映射关系的算法,称为判别式(discriminative)学习算法;比线性回归(lineaar regression)的模型:

\[f(y|x;\theta) = h_{\theta}(x) + \theta_{0} + \theta_{1}x_1 + \theta_{2}x_2 + ... + \theta_{n}x_n \]

再比如逻辑回归(logistic regression):

\[f(y|x;\theta) = h_{\theta}(x) = g(\theta^{T}x) = \frac{1}{1+e^{-\theta^{T}x}} \]

这里的\(g\)是sigmoid函数

而另外一种算法是建立\(p(x|y)\)(和\(p(y)\))的模型,这类算法称为生成式(generative)学习算法,我们今天要讨论的朴素贝叶斯算法即是其中一个。对\(p(y)\)(先验 prior)和\(p(x|y)\),使用贝叶斯规则来推导给定\(x\)\(y\)的后验(posterior)分布:

\[p(y|x) = \frac{p(x|y)p(y)}{p(x)} \]

其中分母可由全概率公式计算:

\[p(x) = p(x|y=1)p(y=1) + p(x|y=0)p(y=0) \]

实际上,我们在计算\(p(y|x)\)做预测时,可以不用计算分母\(p(x)\),因为:

\[arg \ \max \limits_{y} \ p(y|x) = arg \ \max \limits_{y} \ \frac{p(x|y)p(y)}{p(x)} = arg \ \max \limits_{y} \ p(x|y)p(y) \]

二. 朴素贝叶斯(Naive Bayes)

2.1 朴素贝叶斯算法

假设我们想利用机器学习来构建一个言论过滤器,希望对网站的留言评论自动区分是侮辱性言论,还是非侮辱性言论。现在已知一个训练数据集(一些已经标记为侮辱或者非侮辱的言论集合)如下:

"""
函数说明:创建实验样本

Parameters:
    无
Returns:
    postingList - 实验样本切分的词条
    classVec - 类别标签向量
"""
def loadDataSet():
    postingList = [['my', 'dog', 'has', 'flea', 'problems', 'help', 'please'],          # 切分的词条
                   ['maybe', 'not', 'take', 'him', 'to', 'dog', 'park', 'stupid'],
                   ['my', 'dalmation', 'is', 'so', 'cute', 'I', 'love', 'him'],
                   ['stop', 'posting', 'stupid', 'worthless', 'garbage'],
                   ['mr', 'licks', 'ate', 'my', 'steak', 'how', 'to', 'stop', 'him'],
                   ['quit', 'buying', 'worthless', 'dog', 'food', 'stupid']]
    classVec = [0, 1, 0, 1, 0, 1]           # 对应postingList中每个样本的类别标签向量,1代表侮辱性言论, 0代表非侮辱性言论
    return postingList, classVec

\(y\) 表示该言论是否是侮辱性言论:

\[y =\begin{cases}1 & \mbox{侮辱性言论} \\0 & \mbox{非侮辱性言论}\end{cases} \]

接下来,我们将样本数据,即postingList,转化成特征向量,用 \(x\) 表示,其中包含单词特征:

\[x_j =\begin{cases}1 & \mbox{第 $j$ 个单词出现} \\0 & \mbox{未出现}\end{cases} \]

编码到向量中的单词的集合称为词汇表,例如“ my”,“stupid”等等,向量\(x\)的长度等于词汇表的长度。

注意,这里的词汇表不是将英语词典中单词全部列出来,通常是将训练数据集中的所有单词,即使仅出现过一次,放到词汇表中。这样做可以减少模型的词汇数量,从而减少计算量,节省空间;还有个好处是能够将英语词典中不会出现的词,但会出现在留言评论中的词,比如“\(2233\)”放到词汇表中;同时还需要剔除一些高频词,比如 “\(the\)” “\(of\)” “\(and\)”,这些词在很多的文本中都会出现,对区分是否是侮辱性言论没有任何帮助。

特征向量\(x\)与词汇表如下图所示,言论中包含“\(a\)”,“buy”,不包含“aardvard”,“aardwolf”,“zygmurgy”:

创建词汇表代码如下:

"""
函数说明:将切分的实验样本词条整理成不重复的词条列表,也就是词汇表

Parameters:
    dataSet - 样本数据集 postingList
Returns:
    vocabSet - 返回不重复的词条列表,也就是词汇表
"""
def createVocabList(dataSet):
    vocabSet = set([])
    for document in dataSet:
        vocabSet = vocabSet | set(document)
    return list(vocabSet)

将样本数据转换成特征向量\(x\)代码如下:

"""
函数说明:根据vocabList词汇表,将inputSet向量化,向量的每个元素为1或0

Parameters:
    vocabList - 词汇表
    inputSet - 某条言论文档
Returns:
    returnVec - 言论文档向量
"""
def setOfWords2Vec(vocabList, inputSet):
    returnVec = [0] * len(vocabList)            # 创建一个其中所含元素都为0的向量
    for word in inputSet:                       # 遍历每个词条
        if word in vocabList:                   # 如果词条存在于词汇表中,则置1
            returnVec[vocabList.index(word)] = 1
        else:
            print("the word: %s is not in my vocabulary !" % word)
    return returnVec

现在我们来构建\(p(x|y)\)。上面代码示例中词汇表中单词较少,但如果词汇表中包含50000个单词,那么\(x \in \{0, 1\rbrace^{50000}\)\(x\)是50000维0和1组成的向量),如果我们直接用多项式来构造\(x\)\(2^{50000}\)个可能的结果,那么多项式的参数向量有\(2^{50000} - 1\)维,显然参数太多了。

因此算法做了一个强假设,即假设给定\(y\)的情况下\(x_j\)条件独立,这个假设就称为朴素贝叶斯假设,算法称为朴素贝叶斯分类。例如,如果\(y=1\)表示侮辱性言论,“\(stupid\)”是第\(2087\)个单词,“$worthless $”是第\(39831\)个单词,那么我们假设如果已知\(y=1\),那么\(x_{2087}\)的值(“\(stupid\)”是否出现在言论中) 对\(x_{39831}\)的值(“$worthless \(”是否出现在言论中)没有任何影响。正式一点来描述,上述可以写成\)p(x_{2087}|y) = p(x_{2087}|y,x_{39831})\(。需要注意的是,这里并不是说\)x_{2087}\(和\)x_{39831}\(相互独立,相互独立表示为\)p(x_{2087}) = p(x_{2087}|x_{39831})\(,而是仅假设\)x_{2087}\(和\)x_{39831}\(在给定\)y$的情况下条件独立。根据上述假设,有如下等式成立:

\[\begin{eqnarray} p(x_1,...,x_{50000}|y) \\ &=& p(x_1|y)p(x_2|y,x_1)p(x_3|y,x_1,x_2)...p(x_{50000}|y,x1,...,x_{49999}) \\ &=& p(x_1|y)p(x_2|y)p(x_3|y)...p(x_{50000}|y) \\ &=& \prod_{j=1}^{n} p(x_j|y) \end{eqnarray}\]

第一个等式是概率的基本性质,第二个等式用了朴素贝叶斯假设。

我们的模型中的参数为\(\phi_{j|y=1} = p(x_j = 1|y = 1)\)\(\phi_{j|y=0} = p(x_j = 1|y = 0)\)\(\phi_{y} = p(y = 1)\),其中\(j\)为词汇表中第\(j\)个单词。给定一个训练数据集$\lbrace (x^{(i)}, y^{(i)}); i=1,...,m\rbrace $,我们可以写出似然函数:

\[L(\phi_y, \phi_{j|y=0}, \phi_{j|y=1}) = \prod_{i=1}^{m} p(x^{ ( i )}, y^{ ( i )}) \]

最大化关于参数\(\phi_y\), \(\phi_{j|y=0}\), \(\phi_{j|y=1}\)的上述似然函数,得到第\(j\)个单词相关参数的极大似然估计:

\[\phi_{j|y=1} = \frac{\sum_{i=1}^{m} 1 \{ x_j^{(i)} = 1 \land y^{(i)} = 1 \rbrace }{\sum_{i=1}^{m} 1 \{ y^{(i)} = 1 \rbrace } \]

$\phi_{j|y=1} \(即是对\)p(x_j|y=1)$的估计;

\[\phi_{j|y=0} = \frac{\sum_{i=1}^{m} 1 \{ x_j^{(i)} = 1 \land y^{(i)} = 0 \rbrace }{\sum_{i=1}^{m} 1 \{ y^{(i)} = 0 \rbrace } \]

$\phi_{j|y=0} \(即是对\)p(x_j|y=0)$的估计;

\[\phi_{y=1} = \frac{\sum_{i=1}^{m} 1 \{ y^{(i)} = 1 \rbrace }{m} \]

\(\phi_{y=1}\)即是对\(p(y=1)\)的估计;同理

\[\phi_{y=0} = \frac{\sum_{i=1}^{m} 1 \{ y^{(i)} = 0 \rbrace }{m} \]

\(\phi_{y=0}\)即是对\(p(y=0)\)的估计;参数表达式中的 $ \land \(表示“与”。上面的参数不难理解,\)\phi_{j|y=1}\(就是侮辱性言论(\)y=1\()中单词\)j$出现的百分比。训练阶段代码如下:

"""
函数说明:朴素贝叶斯分类器训练函数

Parameters:
    trainMatrix - 训练文档矩阵,即setOfWords2Vec返回的returnVec构成的矩阵
    trainCategory - 训练类别标签向量,即loadDataSet返回的classVec
Returns:
    p0Vect - 非侮辱类的条件概率数组
    p1Vect - 侮辱类的条件概率数组
    pAbusive - 文档属于侮辱类的概率
"""
def trainNB0(trainMatrix, trainCategory):
    numTrainDocs = len(trainMatrix)         #计算训练的文档数目
    numWords = len(trainMatrix[0])          #计算每篇文档的词条数
    pAbusive = sum(trainCategory)/float(numTrainDocs)  #文档属于侮辱类的概率
    p0Num = np.zeros(numWords)              #创建numpy.zeros数组,词条出现数初始化为0
    p1Num = np.zeros(numWords)
    p0Denom = 0.0                           #分母初始化为0
    p1Denom = 0.0
    for i in range(numTrainDocs):
        if trainCategory[i] == 1:           #向量相加,统计属于侮辱类的条件概率所需的数据,即P(w0|1),P(w1|1),P(w2|1)···
            p1Num += trainMatrix[i]
            p1Denom += sum(trainMatrix[i])
        else:                               #向量相加,统计属于非侮辱类的条件概率所需的数据,即P(w0|0),P(w1|0),P(w2|0)···
            p0Num += trainMatrix[i]
            p0Denom += sum(trainMatrix[i])

    p1Vect = p1Num/p1Denom
    p0Vect = p0Num/p0Denom
    return p0Vect, p1Vect, pAbusive         #返回属于侮辱类的条件概率数组,属于非侮辱类的条件概率数组,文档属于侮辱类的概率

已知参数估计之后,对一个特征向量\(x\)的新样本计算是侮辱性言论的概率如下:

\[\begin{eqnarray} p(y=1|x) &=& \frac{p(x|y)p(y=1)} {p(x)} \\ &=& \frac{(\prod_{j=1}^{n} p(x_j | y=1))p(y=1)} {(\prod_{j=1}^{n} p(x_j | y= 1))p(y=1) + (\prod_{j=1}^{n} p(x_j | y= 0))p(y=0)} \end{eqnarray} \]

同样,计算非侮辱性言论的概率如下:

\[\begin{eqnarray} p(y=0|x) &=& \frac{p(x|y)p(y=0)} {p(x)} \\ &=& \frac{(\prod_{j=1}^{n} p(x_j | y=0))p(y=0)} {(\prod_{j=1}^{n} p(x_j | y= 1))p(y=1) + (\prod_{j=1}^{n} p(x_j | y= 0))p(y=0)} \end{eqnarray}\]

如果计算得出$p(y=1|x) > p(y=0|x) $,则认为言论是侮辱性言论,反之是非侮辱性言论,预测代码如下:

"""
函数说明:朴素贝叶斯分类器分类函数

Parameters:
    vec2Classify - 待分类的词条数组
    p0Vec - 侮辱类的条件概率数组
    p1Vec -非侮辱类的条件概率数组
    pClass1 - 文档属于侮辱类的概率
Returns:
    0 - 属于非侮辱类
    1 - 属于侮辱类
"""
def classifyNB0(vec2Classify, p0Vec, p1Vec, pClass1):
    p1 = reduce(lambda x,y : x * y, vec2Classify * p1Vec) * pClass1             # 对应元素相乘
    p0 = reduce(lambda x,y : x * y, vec2Classify * p0Vec) * (1 - pClass1)
    print('p0:', p0)
    print('p1', p1)
    if p1 > p0:
        return p1
    else:
        return p0

2.2 下溢出与拉普拉斯平滑

2.1节中我们使用朴素贝叶斯算法构造了言论分类器,下面我们对算法进行测试:

"""
函数说明:测试朴素贝叶斯分类器

Parameters:
    无
Returns:
    无
"""
def testingNB0():
    listOPosts, classVec = loadDataSet()  # 创建实验样本

    myVocabList = createVocabList(listOPosts)  # 创建词汇表

    # 打印中间结果
    print('myVocabList:\n', myVocabList)

    trainMat = []
    for postinDoc in listOPosts:
        trainMat.append(setOfWords2Vec(myVocabList, postinDoc))  # 将实验样本向量化
    p0V, p1V, pAb = trainNB0(np.array(trainMat), np.array(classVec))  # 训练朴素贝叶斯分类器

    # 打印中间结果
    print('p0V:\n', p0V)
    print('p1V:\n', p1V)
    print('classVec:\n', classVec)
    print('pAb:\n', pAb)


    testEntry = ['love', 'my', 'dalmation']  # 测试样本1
    thisDoc = np.array(setOfWords2Vec(myVocabList, testEntry))  # 测试样本向量化
    if classifyNB0(thisDoc, p0V, p1V, pAb):
        print(testEntry, '属于侮辱类')  # 执行分类并打印分类结果
    else:
        print(testEntry, '属于非侮辱类')  # 执行分类并打印分类结果

    testEntry = ['stupid', 'garbage']  # 测试样本2
    thisDoc = np.array(setOfWords2Vec(myVocabList, testEntry))  # 测试样本向量化
    if classifyNB0(thisDoc, p0V, p1V, pAb):
        print(testEntry, '属于侮辱类')  # 执行分类并打印分类结果
    else:
        print(testEntry, '属于非侮辱类')  # 执行分类并打印分类结果

if __name__ == "__main__":
    testingNB0()

运行结果截图如下:

小伙伴们,发现问题了吗?显然这个结果是不对的。算法存在两个问题:

  • 某些单词0概率,导致整体乘积为0,例如“\(stupid\)”;
  • 下溢出(underflow):

对于第一个问题,我们通过打印中间结果可以看出:

又或者对一个训练数据集中未出现的词,概率也是0,显然,这样是不合理的,因为训练集有限,不能因为训练集中没有出现,就认为这个词永远不会出现。记得吴恩达老师在机器学习课上讲了个段子,斯坦福大学的校篮球队接连输了5场比赛,问下一场比赛赢的概率🤭?连输5场,下一场肯定输吗?合理的预测是有赢的概率,只是比较小,设置赢的概率为1/7吧🤭。这种做法就叫做拉普拉斯平滑(Laplace Smoothing)。具体做法是修改极大似然估计的参数:

\[\phi_{j|y=1} = \frac{\sum_{i=1}^{m} 1 \{ x_j^{(i)} = 1 \land y^{(i)} = 1 \rbrace + 1}{\sum_{i=1}^{m} 1 \{ y^{(i)} = 1 \rbrace + k} \]

$\phi_{j|y=1} \(即是对\)p(x_j|y=1)$的估计;

\[\phi_{j|y=0} = \frac{\sum_{i=1}^{m} 1 \{ x_j^{(i)} = 1 \land y^{(i)} = 0 \rbrace + 1 }{\sum_{i=1}^{m} 1 \{ y^{(i)} = 0 \rbrace + k} \]

$\phi_{j|y=0} \(即是对\)p(x_j|y=0)$的估计。

其中\(k\)代表\(y\)的可能取值数量,我们的例子中\(y\)的取值只有\(0\)\(1\)两种,因此\(k=2\)
那么为什么分子加\(1\),分母加\(k\)呢?因为要保证\(\phi_j\)相加仍然是\(1\)。下面我们检验一下,假设随机变量\(z\)的取值有\(k\)

\[\phi_{j} = \frac{\sum_{i=1}^{m} 1 \{z_i = j\rbrace}{m} \]

其中\(\sum_{j=0}^{k} \phi_j = 1\),则应用拉普拉斯平滑之后

\[\phi_{j}^{'} = \frac{\sum_{i=1}^{m} 1 \{z_i = j\rbrace + 1}{m+ k} \]

不难推出\(\sum_{j=0}^{k} \phi_{j}^{'} = 1\)

除此之外,另外一个遇到的问题就是下溢出,这是由于太多很小的数相乘造成的。学过数学的人都知道,两个小数相乘,越乘越小,这样就造成了下溢出。在程序中,在相应小数位置进行四舍五入,计算结果可能就变成0了。为了解决这个问题,对乘积结果取自然对数。通过求对数可以避免下溢出或者浮点数舍入导致的错误。同时,采用自然对数进行处理不会有任何损失。下图给出函数f(x)和ln(f(x))的曲线:

检查这两条曲线,就会发现它们在相同区域内同时增加或者减少,并且在相同点上取到极值。它们的取值虽然不同,但不影响最终结果。因此我们可以对上篇文章的trainNB0(trainMatrix, trainCategory)函数进行更改,修改如下:

"""
函数说明:朴素贝叶斯分类器训练函数

Parameters:
    trainMatrix - 训练文档矩阵,即setOfWords2Vec返回的returnVec构成的矩阵
    trainCategory - 训练类别标签向量,即loadDataSet返回的classVec
Returns:
    p0Vect - 非侮辱类的条件概率数组
    p1Vect - 侮辱类的条件概率数组
    pAbusive - 文档属于侮辱类的概率
"""
def trainNB(trainMatrix, trainCategory):
    numTrainDocs = len(trainMatrix)         # 计算训练的文档数目
    numWords = len(trainMatrix[0])          # 计算每篇文档的词条数
    pAbusive = sum(trainCategory) / float(numTrainDocs)  # 文档属于侮辱类的概率
    p0Num = np.ones(numWords)
    p1Num = np.ones(numWords)               # 创建numpy.ones数组,词条出现数初始化为1,拉普拉斯平滑
    p0Denom = 2.0
    p1Denom = 2.0                           # 分母初始化为2,拉普拉斯平滑
    for i in range(numTrainDocs):
        if trainCategory[i] == 1:           # 向量相加,统计属于侮辱类的条件概率所需的数据,,即P(w0|1),P(w1|1),P(w2|1)···
            p1Num += trainMatrix[i]
            p1Denom += sum(trainMatrix[i])
        else:                               # 向量相加,统计属于非侮辱类的条件概率所需的数据,即P(w0|0),P(w1|0),P(w2|0)···
            p0Num += trainMatrix[i]
            p0Denom += sum(trainMatrix[i])
    p1Vect = np.log(p1Num / p1Denom)        # 取对数,防止下溢出
    p0Vect = np.log(p0Num / p0Denom)
    return p0Vect, p1Vect, pAbusive         # 返回属于侮辱类的条件概率数组,属于非侮辱类的条件概率数组,文档属于侮辱类的概率

"""
函数说明:朴素贝叶斯分类器分类函数

Parameters:
    vec2Classify - 待分类的词条数组
    p0Vec - 非侮辱类的条件概率数组
    p1Vec -侮辱类的条件概率数组
    pClass1 - 文档属于侮辱类的概率
Returns:
    0 - 属于非侮辱类
    1 - 属于侮辱类
"""
def classifyNB(vec2Classify, p0Vec, p1Vec, pClass1):
    p1 = sum(vec2Classify * p1Vec) + np.log(pClass1)  # 对应元素相乘。logA * B = logA + logB,所以这里加上log(pClass1)
    p0 = sum(vec2Classify * p0Vec) + np.log(1.0 - pClass1)
    if p1 > p0:
        return 1
    else:
        return 0

"""
函数说明:测试朴素贝叶斯分类器

Parameters:
    无
Returns:
    无
"""
def testingNB():
    listOPosts, classVec = loadDataSet()  # 创建实验样本

    myVocabList = createVocabList(listOPosts)  # 创建词汇表

    # 打印中间结果
    print('myVocabList:\n', myVocabList)

    trainMat = []
    for postinDoc in listOPosts:
        trainMat.append(setOfWords2Vec(myVocabList, postinDoc))  # 将实验样本向量化
    p0V, p1V, pAb = trainNB(np.array(trainMat), np.array(classVec))  # 训练朴素贝叶斯分类器

    # 打印中间结果
    print('p0V:\n', p0V)
    print('p1V:\n', p1V)
    print('classVec:\n', classVec)
    print('pAb:\n', pAb)


    testEntry = ['love', 'my', 'dalmation']  # 测试样本1
    thisDoc = np.array(setOfWords2Vec(myVocabList, testEntry))  # 测试样本向量化
    if classifyNB(thisDoc, p0V, p1V, pAb):
        print(testEntry, '属于侮辱类')  # 执行分类并打印分类结果
    else:
        print(testEntry, '属于非侮辱类')  # 执行分类并打印分类结果

    testEntry = ['stupid', 'garbage']  # 测试样本2
    thisDoc = np.array(setOfWords2Vec(myVocabList, testEntry))  # 测试样本向量化
    if classifyNB(thisDoc, p0V, p1V, pAb):
        print(testEntry, '属于侮辱类')  # 执行分类并打印分类结果
    else:
        print(testEntry, '属于非侮辱类')  # 执行分类并打印分类结果

if __name__ == "__main__":
    testingNB()

运行结果如下:

Reference

  1. Naive_Bayes_classifier wiki
  2. Gaussian Discriminant Analysis an example of Generative Learning Algorithms
  3. cs229 lecture notes Part IV Generative Learning algorithms
  4. Machine Learning in Action
  5. [Jack Cui 机器学习实战教程(四):朴素贝叶斯基础篇之言论过滤器](