机器学习 – 朴素贝叶斯

简介

朴素贝叶斯是一种基于概率进行分类的算法,跟之前的逻辑回归有些相似,两者都使用了概率和最大似然的思想。但与逻辑回归不同的是,朴素贝叶斯通过先验概率和似然概率计算样本在每个分类下的概率,并将其归为概率值最大的那个分类。朴素贝叶斯适用于文本分类、垃圾邮件处理等NLP下的多分类问题。

核心思想

每个样本的分类由组成这个样本的特征所决定,所以,如果能求出每个特征在每个分类下的概率,就能通过最大似然法求出这个样本在每个分类下的概率,概率值最大的那个分类即是样本的分类。用贝叶斯公式表示,即:

\(
\begin{equation*}
p(y_i|x) = \frac{p(y_i)*p(x|y_i)}{p(x)}
\end{equation*}
\)


其中:
\(\qquad p(y_i|x)\)表示样本 \(x\) 是类别 \(y_i\) 的概率;
\(\qquad y_i \mbox{ 表示某一种类别, } y_i \in \{y|y_1, y_2, …, y_n\}\),所以 \(p(y_i)\) 即先验概率;
\(\qquad p(x|y_i)\) 是似然概率,需要先统计样本特征 \(x_i\)\(y_i\) 类别下的分布,再计算得出;
\(\qquad p(x)\) 是样本 \(x\) 出现的概率,由于当前样本是已知的,所以可以将 \(p(x)\) 视作常量,在比较概率大小时可忽略。
所以只要求出先验概率和似然概率,即可预测出样本 \(x\) 的分类。

一般步骤

1.数据预处理

需要注意的问题有:
(1)如遇中文单词,需要将其编码成数字或字符,以方便统计;
(2)将整个数据集分为训练集和测试集,便于评估模型。通常使用留出法进行分割。

2.数据统计

统计样本的分类情况以及样本特征在每个分类下的分布情况。
(1)统计样本的分类情况:即每个分类在整体中的占比个数;
(2)样本特征在每个分类下的分布情况:对于不同的数据集,有着不同的定义方式,例如在文本分类中,可以将单词是否在文章中出现过作为统计标准(不考虑重复出现的情况),也可以将单词在文章中出现的次数作为统计标准。前者统计的粒度相对粗一些,后者的粒度相对细一些。

3.训练模型

根据统计数据,计算先验概率和似然概率,作为训练的模型。
(1)先验概率:可通过计算每个分类在整体的占比比例得出;
(2)似然概率:根据特征在每个分类下的分布情况,可计算出特征在每个分类下的概率。仍以文本分类为例,单词出现过的文章篇数/当前分类下文章总篇数(单词出现在该分类下的总次数/当前分类下的单词总数)即每个特征在当前分类下的概率,再使用最大似然法,则可得到每个样本在当前分类下的概率。
(3)对缺失特征的考虑。训练集的大小有限,不可能包含所有特征,如果测试集或后来需要预测的数据包含模型中不存在的特征时,需要给这些不存在的特征一个较小的概率值。

4.模型评估

使用测试集预测分类,并与实际分类相比较,得出模型准确率。
在用测试集预测分类的过程中,需要计算似然概率,即所有单词的概率相乘。但是考虑到一篇文章的单词量很大,所有概率相乘的话会影响似然概率值的精度,不利于后续的比较,所以统一对似然概率取对数,转换成概率对数的累加:

\(
\begin{equation*}
p(x|y_i) = \log{\prod \limits_{i=1}^{m}{p(x_i|y_i)}} = \sum_{i=1}^{m}\log{p(x_i|y_i)}
\end{equation*}
\)


下面就以一个文章分类的例子对朴素贝叶斯分类进行运用。

文本分类实例

现在有一个包含了上千篇文章的article数据集(提取码:ivo8),已知文章共分为商业、汽车和体育三大类,试通过朴素贝叶斯建立预测模型。

1.数据预处理

首先,我们先来对数据有个初步的了解。打开文件目录,发现共有3000多篇文章,文章的分类已经包含在文件名中了:
数据标签
随便打开一篇文章,发现内容是中文,意味着需要对其进行编码,方便后续的统计。不过文章是已经分过词的:
文章内容
所以我们在这一阶段需要做的工作有:
(1)提取文章对应的标签;
(2)对中文单词进行编码;
(3)把所有文章分成训练集和测试集两类,并输出到相应目录

class NB:
    def __init__(self):
        self.article_path = 'articles'  # 存放3000+文章的目录
        self.dataset_path = 'dataset'  # 生成数据集的目录
        self.train_set = 'train.txt'  # 训练集文件名称
        self.test_set = 'test.txt'  # 测试集文件名称
        self.train_threshold = 0.8  # 训练集划分比例

    def prepare_data(self):
        if not os.path.exists(self.article_path):
            print('文件目录不存在,请重新指定!')
            return
        # 对中文单词重新编码,然后区分训练集和测试集,输出成文件
        file_list = os.listdir(self.article_path)
        # 如果目录不存在,直接创建
        if not os.path.exists(self.dataset_path):
            os.system('mkdir ' + self.dataset_path)
        train_f = open(os.path.join(self.dataset_path, self.train_set), 'w')
        test_f = open(os.path.join(self.dataset_path, self.test_set), 'w')
        # 对中文进行编码
        trans_dict = {}
        code = 0
        class_id = -1
        if file_list:
            for file_name in file_list:
                # 获取文章分类
                if 'business' in file_name:
                    class_id = 0
                elif 'auto' in file_name:
                    class_id = 1
                elif 'sports' in file_name:
                    class_id = 2
                # 每篇文章占一行,第一位存放文章分类
                output_str = str(class_id) + ' '
                file = os.path.join(self.article_path, file_name)
                with open(file, 'r', encoding='utf-8') as f:
                    lines = f.readlines()
                for line in lines:
                    for word in line.strip().split():
                        # 按照单词出现顺序进行编码
                        if word not in trans_dict:
                            trans_dict[word] = code
                            code += 1
                        output_str += str(trans_dict[word]) + ' '
                # 使用留出法区分训练集和测试集
                num = random.random()
                if num < self.train_threshold:
                    output_f = train_f
                else:
                    output_f = test_f
                # 输出编码后的文章
                output_f.write(output_str + '\n')
        train_f.close()
        test_f.close()

经过了第一步的处理,我们得到了经过编码的训练集和测试集。

2.数据统计

有了训练集,我们就可以统计每个类别的数量以及单词在每个分类中出现的次数。这里我们使用比较细的粒度进行统计,因为文章数量比较多,重复的词肯定有不少,重复出现的次数在一定程度上也影响着样本所属的类别。

class NB:
    def __init__(self):
        ... ...

    def prepare_data(self):
        ... ...

    def stat_data(self):
        # 统计每个分类出现的次数
        class_dict = {}
        # 统计每个单词在每个分类下的出现次数
        class_word_dict = {}
        with open(os.path.join(self.dataset_path, self.train_set), 'r') as f:
            lines = f.readlines()
        for line in lines:
            word_list = line.strip().split()
            # 样本分类
            class_id = word_list[0]
            # 处理数据结构
            if class_id not in class_dict:
                class_dict[class_id] = 0
                class_word_dict[class_id] = {}
            # 统计分类个数
            class_dict[class_id] += 1
            # 统计每个单词在每个分类下的出现次数
            for word in word_list[1:]:
                if word not in class_word_dict[class_id]:
                    class_word_dict[class_id][word] = 1
                else:
                    class_word_dict[class_id][word] += 1
        # 返回统计结果
        return class_dict, class_word_dict

3.训练模型

有了统计结果,我们就可以计算先验概率以及单词在每个类别中出现的概率,并输出模型:

class NB:
    def __init__(self):
        ... ...
        self.model_path = 'model'  # 生成预测模型文件的存放目录
        self.miss_word_prob = 0.1  # 缺失单词的默认概率

    def prepare_data(self):
        ... ...

    def stat_data(self):
        ... ...

    def train_model(self):
        class_dict, class_word_dict = self.stat_data()
        # 先验概率p(y)
        class_prob = {}
        # 单词在每个分类中出现的概率
        class_word_prob = {}
        # 默认概率,用于单词不在模型中的情况
        class_default_prob = {}
        for class_id in class_dict:
            # 当前分类下所有文章个数
            class_sum = sum(class_dict.values())
            class_prob[class_id] = class_dict[class_id] / class_sum
        for class_id in class_word_dict:
            # 当前分类下所有单词出现次数的总和
            class_word_sum = sum(class_word_dict[class_id].values())
            if class_id not in class_word_prob:
                class_word_prob[class_id] = {}
            for word in class_word_dict[class_id]:
                # 每个单词在当前分类下出现的概率
                class_word_prob[class_id][word] = class_word_dict[class_id][word] / class_word_sum
            # 计算单词缺失的情况下的概率
            class_default_prob[class_id] = self.miss_word_prob / (1.0 + class_word_sum)
        # 输出model文件至目录
        if not os.path.exists(self.model_path):
            os.system('mkdir ' + self.model_path)
        self.json_file_dump(class_prob, self.model_path, 'class_prob.txt')
        self.json_file_dump(class_word_prob, self.model_path, 'class_word_prob.txt')
        self.json_file_dump(class_default_prob, self.model_path, 'class_default_prob.txt')

    @staticmethod
    def json_file_dump(dump_file, dump_file_path, dump_filename):
        # 导出模型到目录
        dump_file_f = open(os.path.join(dump_file_path, dump_filename), 'w')
        json.dump(dump_file, dump_file_f)
        dump_file_f.close()

这里我们补充了两个初始化参数,self.model_path和self.miss_word_prob,分别用来存放模型文件以及计算缺失单词的概率,我们用self.miss_word_prob再除以每个分类的单词总数,当做每个分类下缺失单词的出现概率。

4.模型评估

最后我们通过测试集,对模型的预测性能进行评估。

class NB:
    def __init__(self):
        ... ...

    def prepare_data(self):
        ... ...

    def stat_data(self):
        ... ...

    def train_model(self):
        ... ...

    @staticmethod
    def json_file_load(file_path, filename):
        # 加载模型文件
        file_f = open(os.path.join(file_path, filename), 'r')
        file_dict = json.load(file_f)
        file_f.close()
        return file_dict

    def predict(self):
        # 存储真实值和预测值
        real_list = []
        pre_list = []
        # 读取模型文件
        class_prob = self.json_file_load(self.model_path, 'class_prob.txt')
        class_word_prob = self.json_file_load(self.model_path, 'class_word_prob.txt')
        class_default_prob = self.json_file_load(self.model_path, 'class_default_prob.txt')
        # 使用测试集计算p(y|x)预测class_id
        # 朴素贝叶斯公式:
        # p(y|x) = p(x|y)*p(y) / p(x)
        with open(os.path.join(self.dataset_path, self.test_set), 'r') as f:
            lines = f.readlines()
        for line in lines:
            # p(y|x)
            article_class_prob = {}
            word_list = line.strip().split()
            # 真实分类
            class_id = word_list[0]
            real_list.append(class_id)
            # 先验概率p(y)取对数
            for class_id in class_prob:
                article_class_prob[class_id] = math.log(class_prob[class_id])
            # 似然概率p(x|y)
            for word in word_list[1:]:
                for class_id in class_word_prob:
                    if word not in class_word_prob[class_id]:
                        article_class_prob[class_id] += math.log(class_default_prob[class_id])
                    else:
                        article_class_prob[class_id] += math.log(class_word_prob[class_id][word])
            # 取概率最大的为预测值
            max_prob = max(article_class_prob.values())
            for class_id in article_class_prob:
                if article_class_prob[class_id] == max_prob:
                    pre_list.append(class_id)
        # 计算准确率
        accurate = 0
        for i in range(len(real_list)):
            if real_list[i] == pre_list[i]:
                accurate += 1
        accurate_rate = round(accurate / len(real_list) * 100, 2)
        return '模型预测准确率为:' + str(accurate_rate) + '%'

最后,跑一次程序,可以得到以下结果:

# 初始化
nb = NB()
# 数据预处理
nb.prepare_data()
# 训练
nb.train_model()
# 预测
accurate_rate = nb.predict()
print(accurate_rate)


# 输出结果
模型预测准确率为:97.08%

总结

朴素贝叶斯算法由于简单、易于理解,经常用于文本分类等场景中。但由于它是基于“样本特征之间是相互独立的”这一假设,所以不适合一些基于上下文进行判断的任务。
该算法不像之前的线性回归和k-means聚类那样,可以通过画图对数据集和分类结果有比较直观的认识,但是只要理解了算法中最大似然的思想,以及利用贝叶斯公式计算似然概率的方法,就已经可以说是掌握了。