09 March 2018

概念

贝叶斯定理是概率论中的一个定理,其数学公式如下:

其中 P(Y|X) 是在X发生的情况下,Y发生的概率,也叫条件概率。

贝叶斯公式的推导过程如下:

其中 P(Y, X) 和 P(X, Y) 都表示事件 X 和事件 Y 同时发生的概率(即事件 X,Y 的联合概率)。

如果集合 Y 由 N 种元素构成($Y_1$ 到 $Y_n$),且集合 X 由所有 Y 的逆映射构成则:

所以贝叶斯的另一种表达方式(即全概率公式)是:

朴素贝叶斯是在贝叶斯定理基础之上,假设特征条件之间是相互独立的。

建模

我们知道了贝叶斯公式,该如何利用贝叶斯来识别垃圾邮件呢?根据 Paul Graham 的文章 我们知道利用邮件的单词作为集合 X 就可以利用贝叶斯公式推断出结果 Y 是否属于垃圾邮件。

我们知道邮件分类的结果就两种:垃圾邮件(Spam)和正常邮件(Ham)。所以在开始之前先定义以下几个符号:

  • $P(Y=S)$ : 垃圾邮件的概率
  • $P(Y=H)$ : 正常邮件的概率
  • $P(W_i|Y=S)$ : 是垃圾邮件时,出现单词 $W_i$ 的概率
  • $P(W_i|Y=H)$ : 是正常邮件时,出现单词 $W_i$ 的概率
  • $P(Y=S | W_i)$ : 出现单词 $W_i$ 时,是垃圾邮件的概率

我们收到一份邮件之后,可以识别出邮件中包含的所有单词,所以需要计算的是 P(Y=S|W) 的概率(即每个单词出现时是垃圾邮件的概率)。根据贝叶斯全概率公式可以得出:

上述公式中需要计算垃圾邮件、正常邮件的概率以及相应条件下出现各个单词的概率,这些都可以从训练数据中获取。

比如收到的 2500 封邮件中有 779 封是垃圾邮件,则 P(Y=S) = 0.3116, P(Y=H) = 0.6884

又比如,收到的正常邮件和垃圾邮件中分别包含 sex 的单词的概率分别为 0.00050.05 则对应可以求出:

现在我们可以使用以下步骤使用训练集的数据求出所有单词对应垃圾邮件的概率:

  1. 提供两组识别好的邮件数据(正常邮件和垃圾邮件)
  2. 计算每个单词在正常邮件和垃圾邮件中的出现频率
  3. 分别计算出垃圾邮件的封数以及单词总数、正常邮件的封数以及单词总数
  4. 根据上述公式算出所有 P(Y=S|W)

训练和测试数据来自 kaggle 垃圾邮件分类任务,其中训练数据包含 1721 封正常邮件和 779 封垃圾邮件,测试数据同时包含 1827 封正常邮件和垃圾邮件。

下面的代码是用来读取邮件文件数据并计数生成 pandas 的 DataFrame,训练集邮件点击这里下载,其中 TR 目录是训练邮件,TT 目录是测试邮件。主要实现功能如下:

  1. 遍历所有训练集的邮件
  2. 对邮件内容进行分词
  3. 根据邮件标签统计单词出现次数、总单词数以及邮件封数
  4. 把上述信息转换成数据帧和Hash表返回
%matplotlib notebook
from sklearn.datasets import load_iris
from sklearn.naive_bayes import GaussianNB
from bs4 import BeautifulSoup

import re
import os, sys, stat
import email
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

def read_file(filename):
    '''
    读出邮件内容,需要使用 email 解析
    :param filename: 邮件文件路径
    :return: 返回邮件主题和邮件内容字符串, 可能带 html 格式
    '''
    with open(filename, encoding='latin-1') as fp:
        msg = email.message_from_file(fp)
        payload = msg.get_payload()
        if type(payload) == type(list()):
            payload = payload[0]
        if type(payload) != type(''):
            payload = str(payload)
            
        sub = msg.get('subject')
        sub = str(sub)
        return sub + payload

def clean_html(raw_html):
    '''
    清除邮件内容中的 html 标签
    :param raw_html: 带 html 标签的文本内容
    :return: 不带 html 标签的文本内容
    '''
    return raw_html # BeautifulSoup(raw_html, 'html.parser').text

def label_from_file(filename):
    '''
    从文件名中读取需要,如 'TRAIN_1234.eml' 的文件序号为 1234
    :param filename: 文件名
    :return: 文件序号
    '''
    for s in re.findall(r'\d+', filename):
        return int(s)
    raise ValueError('filename error : ' + filename)

def calc_tf_idf(tf, idf, text, ignore=3):
    '''
    计算一份邮件内容的词频和逆文档频率(仅计数)
    :param tf: 词频计数
    :param idf: 逆文档频率计数
    :return: 文档的单词数
    '''
    words = re.findall('\w+', text)
    count = 0
    word_set = set()
    for word in words:
        # 过滤无效的单词
        if len(word) < ignore or len(word) > 20:
            continue
        word = word.lower()
        
        # 统计逆文档频率, 一篇文章只加一次
        if not (word in word_set):
            idf[word] = idf.get(word, 0) + 1
            word_set.add(word)
            
        # 统计词频
        tf[word] = tf.get(word, 0) + 1
        
        # 计算一篇文档的单词总数
        count = count + 1
    
    return count

def get_label(labels, index):
    '''
    获取邮件的标签
    :param labels: 全部标签数据(Id 和 Prediction 两列)
    :return: 1 表示正常邮件,0 表示垃圾邮件
    '''
    return labels.Prediction[labels.Id == index].iloc[0]

def train_data():
    '''
    读取训练目录下到所有邮件和邮件分类标签
    :return: 所有词频和逆文档频率和邮件数量信息
    '''
    pathname = 'emails/TR'
    labels = pd.read_csv('emails/spam-mail.tr.label')
    
    ham_tf = dict()
    spam_tf = dict()
    word_idf = dict()
    ham_word_count = 0
    spam_word_count = 0
    file_count = 0
    spam_file_count = 0
    ham_file_count = 0
    
    # 遍历所有邮件文件
    for file in os.listdir(pathname):
        fpath = os.path.join(pathname, file)
        info = os.stat(fpath)
        if stat.S_ISREG(info.st_mode) and file.endswith('.eml'):
            '''
            1. 从邮件文件出读出所有文本
            2. 根据邮件标签,分别计算垃圾邮件的词频和逆文档频率
            '''
            text = clean_html(read_file(fpath))
            index = label_from_file(file)
            file_count = file_count + 1
            if get_label(labels, index) == 1:
                ham_file_count = ham_file_count + 1
                ham_word_count = ham_word_count + calc_tf_idf(ham_tf, word_idf, text)
            else:
                spam_file_count = spam_file_count + 1
                spam_word_count = spam_word_count + calc_tf_idf(spam_tf, word_idf, text)

    info = {}
    info['ham_word_count'] = ham_word_count
    info['spam_word_count'] = spam_word_count
    info['file_count'] = file_count
    info['ham_file_count'] = ham_file_count
    info['spam_file_count'] = spam_file_count
    print('train email info : ', info)

    word_df = pd.DataFrame([ham_tf, spam_tf, word_idf]).T
    word_df.columns = ['ham_tf', 'spam_tf', 'word_idf']
    return (word_df, info)

'''
读取所有训练集中的邮件,范围正常邮件和垃圾邮件对应每个词出现的次数以及训练集邮件的计数信息
'''
email_df, email_info = train_data()

train email info :  {'ham_word_count': 448008, 'spam_word_count': 349625, 'file_count': 2500, 'ham_file_count': 1721, 'spam_file_count': 779}

上面的代码把训练集中的正常邮件和垃圾邮件的信息都统计出来了。其中 email_df 是一个二维数据,每行代表一个词,spam_tf 这列代表单词在垃圾邮件出现的次数,ham_tf 这列代表单词在正常邮件出现的次数。 下面要根据公式

计算出每个单词出现时是垃圾邮件的概率,并选择概率大于 90% 的词作为识别关键词。

# 拷贝数据,可重复运行这段代码
word_df = email_df.copy()
word_df.fillna(1, inplace=True)    

# P(Y=S) : 垃圾邮件的概率
p_y_s = email_info['spam_file_count'] /  email_info['file_count']

# P(Y=H) : 正常邮件的概率
p_y_h = 1 - p_y_s

# P(W|Y=H) : 正常邮件时,出现单词 W 的概率
word_df['ham_tf'] = word_df['ham_tf'] / email_info['ham_word_count']

# P(W|Y=S) : 垃圾邮件时,出现单词 W 的概率
word_df['spam_tf'] = word_df['spam_tf'] / email_info['spam_word_count']

# 根据公式计算 P(Y=S|W)
word_df['spam_sp'] = (word_df['spam_tf'] * p_y_s) / (word_df['ham_tf'] * p_y_h + word_df['spam_tf'] * p_y_s)

# 根据公式计算 P(Y=H|W)
# word_df['spam_hp'] = (word_df['ham_tf'] * p_y_h) / (word_df['ham_tf'] * p_y_h + word_df['spam_tf'] * p_y_s)

# 选择 P(Y=S|W) >= 0.9 的单词作为识别关键词,节省计算
word_df = word_df.loc[(word_df['spam_sp'] >= 0.9)]

# 从大到小排序
word_df = word_df.sort_values(by=['spam_sp'], ascending=[False])

print(word_df)
                 ham_tf   spam_tf  word_idf   spam_sp
hibody         0.000002  0.001176     214.0  0.995823
111n           0.000002  0.001090       8.0  0.995495
0px            0.000004  0.001779     204.0  0.994487
111r           0.000002  0.000887       7.0  0.994469
11px           0.000002  0.000775     107.0  0.993678
1px            0.000002  0.000664      62.0  0.992623
111l           0.000002  0.000521       7.0  0.990616
97n            0.000002  0.000443       7.0  0.988999
xmlns          0.000002  0.000440     150.0  0.988929
xhtml1         0.000004  0.000818     148.0  0.988087
0in            0.000002  0.000398       5.0  0.987748
ptsize         0.000002  0.000386      10.0  0.987390
0pt            0.000002  0.000383      12.0  0.987297
islands        0.000002  0.000366      17.0  0.986710
115            0.000018  0.002883      26.0  0.986501
97t            0.000002  0.000360       7.0  0.986501
enenkio        0.000002  0.000355       1.0  0.986287
20px           0.000002  0.000349      53.0  0.986065
97nd           0.000002  0.000343       7.0  0.985836
2px            0.000002  0.000340      38.0  0.985719
padding        0.000018  0.002640     144.0  0.985277
mso            0.000002  0.000326       6.0  0.985102
111bject       0.000002  0.000320       7.0  0.984840
normal__char   0.000002  0.000297       1.0  0.983693
97l            0.000002  0.000295       7.0  0.983537
15px           0.000002  0.000289      40.0  0.983216
700            0.000002  0.000277      44.0  0.982536
5px            0.000007  0.000767      93.0  0.981066
doctype        0.000007  0.000732     259.0  0.980196
13px           0.000002  0.000243      43.0  0.980120
...                 ...       ...       ...       ...
3b6797         0.000002  0.000046       1.0  0.902726
medievalmedia  0.000002  0.000046       2.0  0.902726
skonibut       0.000002  0.000046       1.0  0.902726
mortgages101   0.000002  0.000046       2.0  0.902726
23ffffff       0.000002  0.000046       3.0  0.902726
225            0.000002  0.000046       7.0  0.902726
sorowxf        0.000002  0.000046       1.0  0.902726
equivalence    0.000002  0.000046       2.0  0.902726
97cteri        0.000002  0.000046       7.0  0.902726
medicine       0.000002  0.000046      15.0  0.902726
eid            0.000002  0.000046       5.0  0.902726
97lled         0.000002  0.000046       7.0  0.902726
187            0.000002  0.000046       5.0  0.902726
tabdesmond61a  0.000002  0.000046       2.0  0.902726
97me           0.000002  0.000046       7.0  0.902726
dd3960         0.000002  0.000046       2.0  0.902726
abolished      0.000002  0.000046      10.0  0.902726
ght            0.000002  0.000046      10.0  0.902726
97u            0.000002  0.000046       7.0  0.902726
taongi         0.000002  0.000046       1.0  0.902726
97ttered       0.000002  0.000046       7.0  0.902726
000066         0.000002  0.000046       5.0  0.902726
118ed          0.000002  0.000046       7.0  0.902726
118ely         0.000002  0.000046       7.0  0.902726
118i           0.000002  0.000046       7.0  0.902726
97ngle         0.000002  0.000046       7.0  0.902726
118iewing      0.000002  0.000046       7.0  0.902726
97ncy          0.000002  0.000046       7.0  0.902726
heat           0.000013  0.000272      23.0  0.901803
farmer         0.000007  0.000134      13.0  0.900862

[530 rows x 4 columns]

到这里为止,我们已经利用贝叶斯算出了每个单词对应垃圾邮件的概率。我们知道,邮件是由很多个词构成的,一个单词还不足以判断是否为垃圾邮件。所以下面还需要用到朴素贝叶斯方法(即假设每个词是相互独立的)。假设一封邮件有 n 个单词,则这封邮件是垃圾邮件的概率为:

假设每个词是独立概率分布,则:

同理可得:

到这里,我们已经把整个邮件所有单词对应垃圾邮件的联合概率转换成单词的条件概率乘积了。由于邮件中的单词数比较多,我们只挑选比较可能是垃圾邮件的词来计算,以提高运算效率。下面我们用代码读出测试邮件并计算每封邮件是垃圾邮件的概率。


def is_spam_email(filename, word_df, info, ignore=3):
    text = clean_html(read_file(filename))
    words = re.findall('[A-Za-z]+', text)
    word_set = set()
    p_s_w = info['spam_file_count'] /  info['file_count']
    p_h_w = 1 - p_s_w
    
    for word in words:
        # 过滤无效的单词
        if len(word) < ignore or len(word) > 20:
            continue
            
        word = word.lower()

        # 属于垃圾邮件关键词 且 未参与计算过, 分子分母都乘以1000,防止小数点过小导致计算结果为0
        if (word in word_df.index) and not (word in word_set):
            word_set.add(word)
            p_s_w = 1000 * p_s_w * (word_df.loc[word].spam_tf)
            p_h_w = 1000 * p_h_w * (word_df.loc[word].ham_tf)
            

    # 没有垃圾邮件关键词则认为是正常邮件
    if len(word_set) == 0:
        return (False, 0)
    
    # print('file %s p_s_w : %f, p_h_w %f, word count %d' % (filename, p_s_w, p_h_w, len(word_set)))
    result = p_s_w / (p_s_w + p_h_w)
    if result > 0.9:
        return (True, result)
    return (False, result)

def test_data():
    pathname = 'emails/TT'
    spam_count = 0
    ham_count = 0
    Id = []
    Prediction = []
    
    # 遍历所有邮件文件
    for file in os.listdir(pathname):
        fpath = os.path.join(pathname, file)
        info = os.stat(fpath)
        if stat.S_ISREG(info.st_mode) and file.endswith('.eml'):
            spam, p = is_spam_email(fpath, word_df, email_info)
            value = 0 if spam else 1
            index = label_from_file(fpath)
            Id.append(index)
            Prediction.append(value)
            if spam:
                spam_count = spam_count + 1
                # print('email %s is %s and p %f' % (fpath, value, p))
            else:
                ham_count = ham_count + 1
    
    print('emal count ham %d spam %d' % (ham_count, spam_count))
    return (Id, Prediction)

# 执行测试
# is_spam_email('emails/TT/TEST_117.eml', word_df, email_info)
# is_spam_email('emails/TT/TEST_998.eml', word_df, email_info)
# is_spam_email('emails/TT/TEST_1302.eml', word_df, email_info)
Id, Prediction = test_data()
emal count ham 1178 spam 649
'''
根据返回的 Id 和 预测结果,生成 DataFrame 并排序后写到 csv 文件中
生的 csv 文件结果可以直接提交到 kaggle
'''
test_df = pd.DataFrame(data=np.array([Id, Prediction])).T
test_df.columns = ['Id', 'Prediction']
test_df = test_df.sort_values(by=['Id'], ascending=[True])
test_df.to_csv('emails/submission.csv', sep=',', index=False, encoding='utf-8')

上述代码根据朴素贝叶斯算法测试出 1827 封邮件中有 649 封是垃圾邮件并且把测试结果写到 submission.csv 中。这里我们已经完成了贝叶斯算法识别垃圾邮件的 Python 代码编码,上面的代码可以直接在 jupyter notebook 中打开运行,方便大家参考。

后记:上述基础的朴素贝叶斯算法生成的测试结果在 kaggle 得分是 0.83470

参考

  1. 贝叶斯推断及其互联网应用(二):过滤垃圾邮件
  2. 机器学习算法系列(10):朴素贝叶斯
  3. TF-IDF与余弦相似性的应用(一):自动提取关键词
  4. 数学之美番外篇:平凡而又神奇的贝叶斯方法