您现在的位置是:首页 >技术交流 >自实现朴素贝叶斯分类器with案例:基于SMS Spam Collection数据集的广告邮件分类网站首页技术交流

自实现朴素贝叶斯分类器with案例:基于SMS Spam Collection数据集的广告邮件分类

敲键盘的老乡 2023-06-26 20:00:07
简介自实现朴素贝叶斯分类器with案例:基于SMS Spam Collection数据集的广告邮件分类


贝叶斯分类器

首先要理解贝叶斯决策的理论依据,引用西瓜书上的原话:对于分类任务,在所有相关概率都已知的理想情形下,贝叶斯决策论考虑如何基于这些概率误判损失来选择最优的类别标记。

然后引入我们很熟悉的贝叶斯公式:
P ( c ∣ x ) = P ( c ) P ( x ∣ c ) P ( x ) P(cmid oldsymbol{x}) = frac{P(c)P(oldsymbol{x} mid c)}{P(oldsymbol{x})} P(cx)=P(x)P(c)P(xc)
其中 c c c 是类别标记, x x x 是样本点(一个包含n维属性的向量)。 P ( c ) P(c) P(c)就是所谓的“先验”概率,这个概率是可以通过数据集统计直接得到的,那么 P ( c ∣ x ) P(c mid oldsymbol{x}) P(cx)就是所谓的“后验“概率,即我们要在已有数据的信息背景下推断得到的。


与其它机器学习的算法不同,贝叶斯分类算法似乎看不出一个明显的待训练参数,但观察公式也能明白,我们要求出的 P ( c ∣ x ) P(c mid oldsymbol{x}) P(cx)是由 P ( c ) 、 P ( x ∣ c ) P(c)、P(oldsymbol{x} mid c) P(c)P(xc)以及 P ( x ) P(oldsymbol{x}) P(x)三者变量所共同决定的,而这三者的现实意义其实就是给定的信息背景(数据集)——多数情况下,我们在不同的信息背景下总能得到不同的 P ( c ∣ x ) 、 P ( c ) 、 P ( x ∣ c ) P(c mid oldsymbol{x})、P(c)、P(oldsymbol{x} mid c) P(cx)P(c)P(xc),进而推出不同的 P ( c ∣ x ) P(c mid oldsymbol{x}) P(cx)

有些信息背景对于作出决策的贡献是“好的”,这时 P ( c ∣ x ) P(c mid oldsymbol{x}) P(cx)体现出来的意义能很真实地反映出作出某项决策的正确性,而在有些信息背景(比如样本过于稀疏)下得出的结果就并不能很好地反映待检测样本所属的真实类别,进而造成误分类。

于是Bayes分类器的训练意义在于寻求“好的”数据集,使得后验概率值能较好地反映出决策的真实性。


何为朴素

从概率学原理来讲,类条件概率 P ( x ∣ c ) P(oldsymbol{x} mid c) P(xc),是所有属性上的联合概率,很难从有限的训练样本直接估计而得。那么为避开这个障碍,朴素贝叶斯分类器采用了“属性条件独立性假设”:对已知类别假设所有属性之间相互独立

此时类条件概率满足:
P ( x ∣ c ) = ∏ i = 1 d P ( x i ∣ c ) P(oldsymbol{x} mid c)=prod_{i=1}^{d}P(x_i mid c) P(xc)=i=1dP(xic)
其中 d d d 代表样本点的属性个数, x i x_i xi 代表 x oldsymbol{x} x的各个属性。

于是开头的贝叶斯公式进一步推:
P ( c ∣ x ) = P ( c ) P ( x ∣ c ) P ( x ) = P ( c ) ∏ i = 1 d P ( x i ∣ c ) P ( x ) P(cmid oldsymbol{x}) = frac{P(c)P(oldsymbol{x} mid c)}{P(oldsymbol{x})}=frac{P(c)prod_{i=1}^{d}P(x_i mid c)}{P(oldsymbol{x})} P(cx)=P(x)P(c)P(xc)=P(x)P(c)i=1dP(xic)
于是在此假设前提下,进而大大地简化了计算,这也正是“朴素(Naive)”一词修饰的由来。

而且在一般分类任务下,是不会计算 P ( x ) P(oldsymbol x) P(x)的,而是只计算分子便进行比较。


案例:基于SMS Spam Collection数据集的广告邮件分类

SMS数据集

SMS Spam Collection是用于广告短信识别的经典数据集,完全来自真实短信内容,包括4831条正常短信和747条广告短信。

其内容如下,每一个line都表示一段邮件,"ham"和"spam"分别表示邮件的类别“正常邮件”和“广告邮件”,然后以' '为间隔右边的长字符串为邮件内容。

在这里插入图片描述
这里选取了数据集中的3308条记录。


词向量表示

  • 每个词语的出现各看成一个事件,先分别计算单个词语的事件概率进行训练,然后将一个完整的邮件看成这些事件的交事件。

给定一系列邮件的文本,将每个邮件的关键词提取出来,我们认为长度>2的单词才为关键词,然后将这些关键词转换为小写,其组成的列表作为一个数据样本,所以加载数据集如下

# 创建数据集,加载数据
adClass = 1  # 广告,垃圾标识

def textParse(bigString):
    '''接收一个长字符串解析成字符串列表'''
    #将特殊符号作为划分标志进行字符串切分,即非字母,非数字
    listOfTokens = re.split(r'W+', bigString)
    #除单字母  其它单词全变成小写
    return [tok.lower() for tok in listOfTokens if len(tok) > 2]

def loadDataSet():
    '''加载数据集合及其对应的分类'''
    classVec = []    #0-1列表 第i个元素标识了wordList第i行类别
    wordList = []    #提取出的词矩阵,每一行是对应一个邮件的单词列表
    
    smss = open("./SMSSpamCollection.txt", 'r', encoding = 'utf-8')
    data = csv.reader(smss, delimiter = '	')
    for line in data:      #line:左边一个"ham" or "spam",右边一个大字符串
        if line[0] == "ham":
            classVec.append(0)
        else:
            classVec.append(1)
        wordList.append(textParse(line[1]))

    return wordList, classVec

打印数据样本,可以看到邮件文本以及它的关键词列表:

在这里插入图片描述


然后,将这些词全放在一起,构成一个“语料库”。

def doc2VecList(docList):		#docList是一个二维矩阵,每行表示一个邮件的关键词组成的列表
    """数据进行并集操作,最后返回一个词不重复的并集"""
    a = list(reduce(lambda x, y:set(x) | set(y), docList))
    return a

这么做的意义在于我们要改变这个数据样本的表示方式(否则不利于概率计算),在这里就是用词向量的表示方法:

对于一个数据样本,将其视作一个长度为 n = ∣ 语 料 库 中 词 的 个 数 ∣ n=left | 语料库中词的个数 ight | n= 的01向量,如果样本某个词在语料库中出现了,那就在这个词的对应位置记1,否则记0.

于是该词向量就有了n个属性,每个属性取值∈{0,1}。

def words2Vec(vecList, inputWords):     #vecList:语料库,inputWords:输入的词组
    '''把单词转化为词向量'''
    dimensions = len(vecList)
    resultVec = [0] * dimensions
    for i in range(dimensions):
        if vecList[i] in inputWords:
            resultVec[i] += 1
    #转化为一维数组
    return array(resultVec)

Laplacian平滑

接下来就是计算

  1. P ( c ) P(c) P(c)
  2. P ( x ∣ c ) = ∏ i = 1 d P ( x i ∣ c ) P(oldsymbol{x} mid c)=prod_{i=1}^{d}P(x_i mid c) P(xc)=i=1dP(xic)

但这里尤其需要注意的是待分类样本词向量中可能存在“词没有记录在语料库”中的情况,也即它属于任何类别的概率值为0,显然会导致 ∏ i = 1 d P ( x i ∣ c ) prod_{i=1}^{d}P(x_i mid c) i=1dP(xic)直接变成0不合理,因此进行**+1的Laplacian平滑处理**:
P ^ ( c ) = ∣ D c ∣ + 1 ∣ D ∣ + N hat{P}{(c)} = frac{|D_c| + 1} {|D|+N} P^(c)=D+NDc+1

P ^ ( x i ∣ c ) = ∣ D c , x i ∣ + 1 ∣ D c ∣ + N i hat{P}({x_imid c}) = frac{|D_{c,x_i}| + 1} {|D_c|+N_i} P^(xic)=Dc+NiDc,xi+1

  • 也就是令没出现过的词“所属类别0和类别1的次数均+1”,“类别0的样本数和类别1的样本数均+2(类别数)”。

在代码中体现为:

'''Laplacian +1平滑'''
# 全部都初始化为1(全1数组), 防止出现概率为0的情况
p0Num = ones(numWords)
p1Num = ones(numWords)
# 相应的单词初始化为2
p0Words = 2.0
p1Words = 2.0

训练过程

:这里的训练过程跟测试集的内容无关系,而是一种预存储*。

首先,根据训练集计算从 i ∈ iin i[1~ n ] n] n]所有 P ( x i ∣ 0 ) P(x_i mid 0) P(xi0) P ( x i ∣ 1 ) P(x_i mid 1) P(xi1),在这里 x i x_i xi指代的语料库中第 i i i个词,然后存到两个数组里——p0Vecp1Vec,在这里有个小技巧就是在存储时对每个P值都取了一个log运算,这样可以扩大数值域,方便后面的计算。

# 统计每个分类的词的总数
    for i in range(numTrainClass):
        if trainClass[i] == 1:
            # 数组在对应的位置上相加
            p1Num += trainMatrix[i]
            p1Words += sum(trainMatrix[i])
        else:
            p0Num += trainMatrix[i]
            p0Words += sum(trainMatrix[i])

    # 计算每种类型里面, 每个单词出现的概率
    # 在计算过程中,由于概率的值较小,于是取对数扩大数值域
    p0Vec = log(p0Num / p0Words)	#P(所有xi|0)
    p1Vec = log(p1Num / p1Words)	#P(所有xi|1)
    # 计算在类别中1出现的概率,0出现的概率可通过1-p得到
    pClass1 = sum(trainClass) / float(numTrainClass)	#P(c=1)

分类过程

对于测试集的词向量testVec存在如下关系:
∏ i = 1 d P ( x i ∣ 1 或 0 ) = ∏ i ∈ { i ∣ t e s t V e c [ i ] = 1 } P ( x i ∣ 1 或 0 ) prod_{i=1}^{d}P(x_i mid 1或0)=prod_{iin{imid testVec[i] = 1}}P(x_i mid 1或0) i=1dP(xi10)=i{itestVec[i]=1}P(xi10)
也就是说,只需要testVec * p0VectestVec * p1Vec 便可分别得到在testVec第i个位置上为1 P ( x i ∣ 0 ) P(x_i mid 0) P(xi0) P ( x i ∣ 1 ) P(x_i mid 1) P(xi1)了!

所以只要计算 P ( c ) ∏ i ∈ { i ∣ t e s t V e c [ i ] = 1 } P ( x i ∣ 1 或 0 ) P(c)prod_{iin{imid testVec[i] = 1}}P(x_i mid 1或0) P(c)i{itestVec[i]=1}P(xi10)便得到两个后验概率,同样根据对数特性 l n [ P ( c ) × P ( X 1 ∣ c ) × P ( X 2 ∣ c ) × . . . × P ( X n ∣ c ) ] = l n P ( c ) + l n P ( X 1 ∣ c ) + . . . + l n P ( X n ∣ c ) ln[{ P(c)×P(X1|c)×P(X2|c)×...×P(Xn|c)}] = lnP(c) + lnP(X1|c) + ... + lnP(Xn|c) ln[P(c)×P(X1c)×P(X2c)×...×P(Xnc)]=lnP(c)+lnP(X1c)+...+lnP(Xnc),将乘法改成加法:

def classifyNB(testVec, p0Vec, p1Vec, pClass1):
    """分类, 返回分类结果 0 or 1"""
    # 因为概率的值太小了,所以乘法改加法,可以简化计算且不失精度
    p1 = sum(testVec * p1Vec) + log(pClass1)
    p0 = sum(testVec * p0Vec) + log(1 - pClass1)
    if p0 > p1:
        return 0
    return 1

完整代码

#导入所需库文件
from numpy import *
from functools import reduce
import re
import csv

def textParse(bigString):
    '''接收一个长字符串解析成字符串列表'''
    #将特殊符号作为划分标志进行字符串切分,即非字母,非数字
    listOfTokens = re.split(r'W+', bigString)
    #除单字母  其它单词全变成小写
    return [tok.lower() for tok in listOfTokens if len(tok) > 2]

# 创建数据集,加载数据
adClass = 1  # 广告,垃圾标识

def loadDataSet():
    '''加载数据集合及其对应的分类'''
    classVec = []  # 0-1列表 第i个元素标识了wordList第i行类别
    wordList = []  # 提取出的词矩阵,每一行是对应一个邮件的单词列表

    smss = open("./SMSSpamCollection.txt", 'r', encoding='utf-8')
    data = csv.reader(smss, delimiter='	')
    for line in data:  # line:左边一个"ham" or "spam",右边一个大字符串
        if line[0] == "ham":
            classVec.append(0)
        else:
            classVec.append(1)
        wordList.append(textParse(line[1]))

    return wordList, classVec

def doc2VecList(docList):
    """函数说明:数据进行并集操作,最后返回一个词不重复的并集"""
    #reduce(function, iterable[, initializer]): 从左至右积累地应用到 iterable 的条目,以便将该可迭代对象缩减为单一的值
    a = list(reduce(lambda x, y:set(x) | set(y), docList))
    return a  #['','',...,'']

def words2Vec(vecList, inputWords):     #所有词,输入的词组
    '''把单词转化为词向量'''
    dimensions = len(vecList)
    resultVec = [0] * dimensions
    for i in range(dimensions):
        if vecList[i] in inputWords:
            resultVec[i] += 1
    #转化为一维数组
    return array(resultVec)


def trainNB(trainMatrix, trainClass):
    """函数说明:计算,生成每个词对于类别上的概率"""
    # 类别行数
    numTrainClass = len(trainClass)
    # 列数
    numWords = len(trainMatrix[0])

    # 全部都初始化为1(全1数组), 防止出现概率为0的情况出现
    p0Num = ones(numWords)
    p1Num = ones(numWords)

    # 相应的单词初始化为2
    p0Words = 2.0
    p1Words = 2.0

    # 统计每个分类的词的总数
    for i in range(numTrainClass):
        if trainClass[i] == 1:
            # 数组在对应的位置上相加
            p1Num += trainMatrix[i]
            p1Words += sum(trainMatrix[i])
        else:
            p0Num += trainMatrix[i]
            p0Words += sum(trainMatrix[i])

    # 计算每种类型里面, 每个单词出现的概率
    # 在计算过程中,由于概率的值较小,所以我们就取对数进行比较,其中ln可替换为log的任意对数底
    p0Vec = log(p0Num / p0Words)
    p1Vec = log(p1Num / p1Words)

    # 计算在类别中1出现的概率,0出现的概率可通过1-p得到
    pClass1 = sum(trainClass) / float(numTrainClass)
    return p0Vec, p1Vec, pClass1

def classifyNB(testVec, p0Vec, p1Vec, pClass1):
    """分类, 返回分类结果 0 or 1"""
    # 因为概率的值太小了,所以乘法改加法
    # 根据对数特性ln{ P(c)×P(X1|c)×P(X2|c)×...×P(Xn|c) } = lnP(c) + lnP(X1|c) + ... + lnP(Xn|c)
    # 可以简化计算且不失精度
    '''test * pVec已经在trainNB中取过对数了直接相加'''
    p1 = sum(testVec * p1Vec) + log(pClass1)
    p0 = sum(testVec * p0Vec) + log(1 - pClass1)
    if p0 > p1:
        return 0
    return 1

def printClass(words, testClass):
    if testClass == adClass:
        print(words, '推测为:广告邮件')
    else:
        print(words, '推测为:正常邮件')


def tNB():
    # 加载训练数据集
    docList, classVec = loadDataSet()  # 单词矩阵、 01向量

    # 生成包含所有单词的list
    allWordsVec = doc2VecList(docList)

    # 构建词向量矩阵
    '''lambda中的x对应docList的每一行词组'''
    trainMat = list(map(lambda x: words2Vec(allWordsVec, x), docList))  # 和docList对应的行(词)向量组

    # 训练计算每个词在分类上的概率
    # 其中p0V:每个单词在“非”分类出现的概率, p1V:每个单词在“是”分类出现的概率  pClass1:类别中是1的概率
    p0V, p1V, pClass1 = trainNB(trainMat, classVec)

    # 测试数据集
    text1 = "As a valued cutomer, I am pleased to advise you that following recent review of your Mob No"
    testwords1 = textParse(text1)
    # 转换成单词向量,32个单词构成的数组,如果此单词在数组中,数组的项值置1
    testVec1 = words2Vec(allWordsVec, testwords1)
    # 通过将单词向量testVec代入,根据贝叶斯公式,比较各个类别的后验概率,判断当前数据的分类情况
    testClass1 = classifyNB(testVec1, p0V, p1V, pClass1)
    # 打印出测试结果
    printClass(testwords1, testClass1)

    text2 = "Please don't text me anymore. I have nothing else to say"
    testwords2 = textParse(text2)
    testVec2 = words2Vec(allWordsVec, testwords2)
    testClass2 = classifyNB(testVec2, p0V, p1V, pClass1)
    printClass(testwords2, testClass2)

if __name__ == '__main__':
    tNB()
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。