第十课.简单文本分类

文本处理

通常来说,在使用一个算法进行文本分类之前,需要做一些文本获取、文本处理和特征提取的工作。其中,文本获取的方式有第三方提供的语料库、通过爬虫技术获取等;文本处理主要是分词、去停用词、标准化等,特征提取则是将文本表示成特征向量的形式

基本处理方式

字符串的连接

>>> s1 = 'abc'
>>> s2 = 'def'
# 通过加号连接字符串
>>> s1 + s2
'abcdef'

>>> s1 = 'abc'
>>> s2 = 'def'
# 通过jion()方法连接字符串
>>> ''.join([s1,s2])
'abcdef'

>>> s1 = 'abc'
>>> s2 = 'def'
# 设置连接字符为 -
>>> '-'.join([s1,s2])
'abc-def'

字符串的翻转

>>> s = 'abcdef'
# 字符串翻转
>>> s[::-1]
'fedcba'

字符串的大小写转换

>>> s = 'abcdef'
# 转换为大写字母
>>> s.upper()
'ABCDEF'

>>> s = 'abcDEF'
# 转换为小写字母
>>> s.lower()
'abcdef'

字符串的替换与删除

>>> notice = '今天凌晨4点到洛杉矶!'
# 字符串替换
>>> notice.replace('凌晨4点','凌晨4点24分')
'今天凌晨4点24分到洛杉矶!'

>>> notice = '今天凌晨4点到洛杉矶!'
# 将‘凌晨4点’替换为空,相当于删除
>>> notice.replace('凌晨4点','')
'今天到洛杉矶!'

>>> emotion = '凯文杜兰特回归雷霆!   '
# 删除字符串两侧的空白字符
>>> emotion.strip()
'凯文杜兰特回归雷霆!'

# \n是换行符,属于空白字符
>>> emotion = '凯文杜兰特回归雷霆!\n'
>>> emotion.strip()
'凯文杜兰特回归雷霆!'

字符串的查找与分割

>>> s = 'abcdef'
# 查找字符 c 在字符序列 s 中的索引
>>> s.find('c')
2

>>> s = 'userid,itemid,categoryid'
# 使用逗号分割字符串
>>> s.split(',')
['userid', 'itemid', 'categoryid']

>>> s = '2021-01-30'
# 使用连字符分割字符串
>>> s.split('-')
['2021', '01', '30']

>>> s = 'James Harden joins the Nets'
# 默认使用空白符分割字符串
>>> s.split()
['James', 'Harden', 'joins', 'the', 'Nets']

上面的实例是通过分隔符将文本分成一个个单词,简称分词。英文文本由于是使用空格划分每个有意义的单词,所以直接使用split方法即可完成分词;而中文文本没有特定的分割符,只能依靠语境将句子划分一个个独立的单词,常用的中文分词工具是jieba,使用方法如下:

>>> import jieba
>>> s = '詹姆斯哈登加盟篮网'
>>> list(jieba.cut(s))
['詹姆斯', '哈登', '加盟', '篮网']

正则表达式

一般的字符串查找替换可以使用字符串的内置函数实现,如上面提到的find方法、replace方法,但是有些复杂形式的字符串处理,就需要用到正则表达式;
正则表达式(regular expression)是一个特殊的字符序列,通常用原始字符串来表示。所谓的原始字符串是指在字符串前面加上一个字符r
下面介绍常见的四种语法:

  • (1)[...] 表示一个字符集合;

  • (2)[^...] 表示不在 [...] 中的字符;

  • (3)\d 表示数字 [0-9]

  • (4)+ 表示匹配前一个字符一次至多次

实例如下:

r'[abc]' 匹配a,b,c中的任意一个字符
r'[a-z]' 匹配任意小写字母
r'\d' 相当于 r'[0-9]',匹配任意一个数字
r'[^abc]' 匹配除了a,b,c之外的字符
r'[\u4e00-\u9fa5]' 匹配任意一个中文字符

上面的\u4e00\u9fa5是Unicode编码,Unicode编码是为了把世界上的文字都映射到一套字符空间,详细内容回顾C++学习记录第五课

Python 的re模块提供了正则表达式的功能和处理函数,使用正则表达式查找和替换特定字符串的方法如下:

>>> import re

>>> s = '凯文杜兰特在2016年7月离开雷霆让人叹息'
# 找出s中的所有数字串
>>> re.findall(r'\d+',s)
['2016', '7']

>>> s = '希望Kevin Durant在篮网Nets可以获得荣誉'
# 将s中的小写英文字母替换为空串
# 将某个字符串替换为空串,效果上等同于删除该字符串
>>> re.sub(r'[a-z]','',s)
'希望K D在篮网N可以获得荣誉'

>>> s = '希望Kevin Durant在篮网Nets可以获得荣誉'
# 将s中的中文字符替换为空串
>>> re.sub(r'[\u4e00-\u9fa5]','',s)
'Kevin DurantNets'

>>> s = '希望Kevin Durant在篮网Nets可以获得荣誉'
# 将s中的非中文字符替换为空串
>>> re.sub(r'[^\u4e00-\u9fa5]','',s)
'希望在篮网可以获得荣誉'

去除停用词

停用词是人为生成的,生成后的停用词会形成一个停用词表(文件)。针对具体的自然语言处理任务,若某些词的加入对于任务实现的价值不大,就需要从语料库中过滤掉,即去除停用词。下面将标点符号作为停用词,示例如何从文本中去除停用词:

import jieba

# 句子
sentence = 'Durant and Irving come to the Nets together, hoping they can win.'
# 将句子进行分词
sentence = list(jieba.cut(sentence))
print(sentence)
# ['Durant', ' ', 'and', ' ', 'Irving', ' ', 'come', ' ', 'to', ' ', 'the', ' ', 'Nets', ' ', 'together', ',', ' ', 'hoping', ' ', 'they', ' ', 'can', ' ', 'win', '.']
# 自定义几个停用词(空白符,标点符号)
stop_words = [' ',',','.']
# 过滤停用词
sentence = [s for s in sentence if s not in stop_words]
# 展示结果
print(sentence)
# ['Durant', 'and', 'Irving', 'come', 'to', 'the', 'Nets', 'together', 'hoping', 'they', 'can', 'win']

上面是先对一个句子进行分词,然后去除列表中的停用词;接下来还以这个句子为例,先去除文本中的标点符号,然后对句子进行分词,对比效果:

import string

# 句子
sentence = 'Durant and Irving come to the Nets together, hoping they can win.'
# 将标点符号作为停用词
stop_words = string.punctuation
# 过滤掉句子中的停用词
sentence = ''.join(s for s in sentence if s not in stop_words)
# 使用空白符对句子分词
sentence=sentence.split()
print(sentence)
# ['Durant', 'and', 'Irving', 'come', 'to', 'the', 'Nets', 'together', 'hoping', 'they', 'can', 'win']

文本表示

单词表示

常见的单词向量化表示有两种,一种是离散值向量表示,另一种是连续值向量表示;

  • 离散值向量:
    如图所示,假设词库里只有 4 个单词:医生、老师、学生、警察,将其依次编码为 0、1、2、3,则每个单词都可以用一个 4 维的 OneHot 向量来表示;假如语料库中共有100万个单词,则每个单词就要用100万维的 OneHot 向量来表示,这样做的缺点是维数过高,且由于不同词向量的内积为零,无法衡量不同单词之间的语义相关性:
    fig1

  • 连续值向量
    为了解决 OneHot 词向量的上述缺点,可以通过词嵌入矩阵将高维稀疏的向量表示映射为低维稠密的向量表示;图中左侧为高维稀疏的词向量,右侧为低维稠密的词向量,中间为词嵌入矩阵。词嵌入矩阵一般是通过Word2Vec、GloVe这类词嵌入算法在大规模文本语料库上训练得到的;通过词嵌入矩阵映射得到的词向量既具备低维特性,又可以保证词向量之间的上下文相关性:
    fig2

词袋模型

上面介绍了单词的两种向量表示,但文本分类常见的输入形式是句子或者段落篇章,如何将这类文本进行向量化?一个最简单的想法是将文本中每个单词对应的向量进行相加或者求平均。

和这种想法比较近似的是词袋模型:首先对语料库中的文本进行分词、去停用词、单词去重,得到词汇表,然后对于给定的句子,统计词汇表中每个单词在这个句子中是否出现 / 出现的次数 / 出现的频率,进而得到大小为词表长度的句子向量,下面是通过词袋模型进行文本向量化表示的实现:

from collections import Counter

# 语料库(两句话),注意逗号和句号后有空格
corpus = 'In the finals, Leonard defeated the Golden State Warriors in the Raptors. The situation in the NBA has changed.'
# 去除标点符号(停用词),并根据空白符进行分词
corpus = ''.join(c for c in corpus if c not in [',','.']).split()
print(corpus)
# ['In', 'the', 'finals', 'Leonard', 'defeated', 'the', 'Golden', 'State', 'Warriors', 'in', 'the', 'Raptors', 'The', 'situation', 'in', 'the', 'NBA', 'has', 'changed']
# 得到词汇表集合(词袋)
word_set = set(corpus)
print(word_set)
# {'defeated', 'in', 'Warriors', 'NBA', 'Leonard', 'changed', 'finals', 'Raptors', 'In', 'situation', 'Golden', 'has', 'the', 'State', 'The'}

# 输入一个句子
sentence_input = 'Leonard changed the NBA.'
# 计算词袋中每个单词的特征值,初始化特征值为 0
words_input = ''.join(s for s in sentence_input if s not in [',','.']).split()
print(words_input)
# ['Leonard', 'changed', 'the', 'NBA']

# 计算词袋中每个单词的特征值,初始化特征值为 0
word_dict = {w:0 for w in word_set}
# 遍历输入句子中的每个单词和它出现的次数
for w,count in Counter(words_input).items():
    # 如果当前单词是词袋中的词汇,则更新对应的特征值
    if w in word_set:
        # 方式一:统计词袋中的每个单词是否出现在输入句子中
        # word_dict[w] = 1
        # 方式二:统计词袋中每个单词在输入句子中出现的次数
        word_dict[w] = count
        # 方式三:统计词袋中每个单词在输入句子中出现的频率
        # word_dict[w] = count /len(words_input)

# 获取句子向量每个维度的特征名
sentence_feature_name = word_dict.keys()
print(sentence_feature_name)
# dict_keys(['defeated', 'in', 'Warriors', 'NBA', 'Leonard', 'changed', 'finals', 'Raptors', 'In', 'situation', 'Golden', 'has', 'the', 'State', 'The'])

# 获取句子向量
sentence_vector = word_dict.values()
print(sentence_vector)
# dict_values([0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0])

当词袋包含的单词数量很多时,得到的句子向量维度也会很高,所以去除停用词的一个目的是为了降低句子向量的维度,从而减小文本分类模型的复杂性。

以下是 sklearn 中实现的上述文本向量化方式:

from sklearn.feature_extraction.text import CountVectorizer

# 语料库(两句话),句子中逗号与句号后的空格可以存在也可以不存在
sentence1 = 'In the finals, Leonard defeated the Golden State Warriors in the Raptors.'
sentence2 = 'The situation in the NBA has changed.'
corpus = [sentence1,sentence2]

# 实例化对象
vec = CountVectorizer()
# 将语料库的两句话转换为文本向量,元素取值为词袋中的单词在句子中出现的次数
print(vec.fit_transform(corpus).toarray())
# array([[0 1 1 1 0 2 1 0 1 0 1 3 1],
#        [1 0 0 0 1 1 0 1 0 1 0 2 0]], dtype=int64)

# vec的词汇表 vocab {word:count}
print(vec.vocabulary_)
# {'in': 5, 'the': 11, 'finals': 2, 'leonard': 6, 'defeated': 1, 'golden': 3, 'state': 10, 'warriors': 12, 'raptors': 8, 'situation': 9, 'nba': 7, 'has': 4, 'changed': 0}

# 获取vocab的特征word
print(vec.get_feature_names())
# ['changed', 'defeated', 'finals', 'golden', 'has', 'in', 'leonard', 'nba', 'raptors', 'situation', 'state', 'the', 'warriors']

# 实例化对象,参数vocabulary限制了词汇表
new_vec = CountVectorizer(vocabulary=vec.vocabulary_)
# 将新输入的一句话转换为文本向量
print(new_vec.fit_transform(['New star Leonard changed the NBA.']).toarray())
# array([[1 0 0 0 0 0 1 1 0 0 0 1 0]], dtype=int64)

TF-IDF
对于词袋中单词特征值的计算,词频计算包含的信息最多,而且词频在一定程度上反映了这个单词在句子中的重要性。但是输入句子中每个单词的重要性不仅与其在该句子中的词频(term frequency,简称TF)相关,还与它在整个语料库中的稀缺性相关。

使用逆文档频率(Inverse document frequency,简称 IDF)来衡量这种稀缺性的大小:对于词袋中的每个单词,计算 log[(文档总数)/(出现该单词的文档数)]。这里的文档相当与上面代码中的句子,即:IDF=log[(句子总数)/(出现该单词的句子数)],出现单词的句子数越少,IDF越大,说明该单词越稀缺。使用TF*IDF作为词袋模型的单词特征值,相应的文本向量化示例如下:

import numpy as np
from collections import Counter

# 语料库(两句话)
sentence1 = 'In the finals, Leonard defeated the Golden State Warriors in the Raptors.'
sentence2 = 'The situation in the NBA has changed.'
corpus = [sentence1, sentence2]

# 词汇表集合(词袋)
word_set = set()
# 遍历语料库中的每句话
for sentence in corpus:
    # 获取词表:去除标点符号(停用词),并根据空白符进行分词
    word_list = ''.join(s for s in sentence if s not in [',', '.']).split()
    # 将当前句子的词表放入词袋
    word_set.update(word_list)

print(word_set)
# {'In', 'Raptors', 'Golden', 'situation', 'Leonard', 'changed', 'finals', 'defeated', 'State', 'Warriors', 'The', 'NBA', 'the', 'in', 'has'}

# 输入一个句子
sentence_input = 'New star Leonard changed the NBA.'
# 对该句子去停用词并分词
words_input = ''.join(s for s in sentence_input if s not in [',', '.']).split()
# 计算词袋中每个单词的特征值,初始化特征值为 0
word_dict = {w: 0 for w in word_set}
# 遍历输入句子中的每个单词和它出现的次数
for w, count in Counter(words_input).items():
    # 如果当前单词是词袋中的词汇,则更新对应的特征值
    if w in word_set:
        # 统计词袋中每个单词在输入句子中出现的频率(TF值)
        word_dict[w] = count / len(words_input)

        # 当前单词出现的句子数
        sentence_num = 0
        for sentence in corpus:
            # 获取词表:去除标点符号(停用词),并根据空白符进行分词
            word_list = ''.join(s for s in sentence if s not in [',', '.']).split()
            if w in word_list:
                sentence_num += 1
        # 将 TF*IDF 作为当前单词的特征值
        word_dict[w] *= np.log(len(corpus) / sentence_num)

# 获取句子向量
sentence_vector = word_dict.values()
print(sentence_vector)
# dict_values([0, 0, 0, 0, 0.11552453009332421, 0.11552453009332421, 0, 0, 0, 0, 0, 0.11552453009332421, 0.0, 0, 0])

# 获取句子向量每个维度的特征名
sentence_feature_name = word_dict.keys()
print(sentence_feature_name)
# dict_keys(['In', 'Raptors', 'Golden', 'situation', 'Leonard', 'changed', 'finals', 'defeated', 'State', 'Warriors', 'The', 'NBA', 'the', 'in', 'has'])

同样,使用单词的TF*IDF值作为特征值进行文本向量化在 sklearn 中也有相应的实现:

from sklearn.feature_extraction.text import TfidfVectorizer

# 语料库(两句话)
sentence1 = 'In the finals, Leonard defeated the Golden State Warriors in the Raptors.'
sentence2 = 'The situation in the NBA has changed.'
corpus = [sentence1, sentence2]

# 实例化对象
vec = TfidfVectorizer()
# 将语料库的两句话转换为文本向量(numpy数组,保留2位小数)
print(vec.fit_transform(corpus).toarray().round(2))
# array([[0.   0.27 0.27 0.27 0.   0.39 0.27 0.   0.27 0.   0.27 0.58 0.27],
#        [0.39 0.   0.   0.   0.39 0.28 0.   0.39 0.   0.39 0.   0.56 0.  ]])

# 获取特征名
print(vec.get_feature_names())
# ['changed', 'defeated', 'finals', 'golden', 'has', 'in', 'leonard', 'nba', 'raptors', 'situation', 'state', 'the', 'warriors']

# 实例化对象
new_vec = TfidfVectorizer(vocabulary=vec.vocabulary_)
# 将新输入的一句话转换为文本向量
print(new_vec.fit_transform(['New star Leonard changed the NBA.']).toarray())
# array([[0.5 0.  0.  0.  0.  0.  0.5 0.5 0.  0.  0.  0.5 0. ]])

实验:基于朴素贝叶斯的垃圾邮件过滤

实验将通过朴素贝叶斯模型分类邮件数据,判断邮件属于垃圾邮件还是正常邮件,邮件数据保存在个人资源,名称为:基于朴素贝叶斯的垃圾邮件过滤data.rar;邮件数据包括训练集train(正常邮件normal,垃圾邮件spam),测试集test,中文停用词文件cn_stopwords.txt

首先获取邮件列表:

import os

# 训练数据中的垃圾邮件目录
train_spam_dir = './data/train/spam'
# 训练数据中的垃圾邮件列表
train_spam_list = os.listdir(train_spam_dir)

# 训练数据中的正常邮件目录
train_normal_dir = './data/train/normal'
# 训练数据中的正常邮件列表
train_normal_list = os.listdir(train_normal_dir)

# 测试数据中的邮件目录
test_dir = './data/test'
# 测试数据中的邮件列表
test_list = os.listdir(test_dir)

定义函数get_word_set获取邮件词集:

import re
import jieba

def get_word_set(email_path):
    # 该邮件的单词列表
    word_list = []
    # 中文停用词表的文件路径
    stop_word_path = './data/cn_stopwords.txt'
    # 将中文停用词表文件读取为Python列表
    with open(stop_word_path,mode='r',encoding='utf-8') as f:
        stop_word_list=[line.strip() for line in f.readlines()]
    
    # 遍历邮件文本的每一行
    with open(email_path,mode='r',encoding='gbk') as email_f:
        for line in email_f.readlines():
            # 使用正则表达式将 line 中的非中文字符替换为空串
            s=re.sub(r'[^\u4e00-\u9fa5]','',line)
            # 将每行文本分词后的列表添加到结果列表
            word_list += list(jieba.cut(s))
    
    # 过滤停用词,并去重    
    return set([w for w in word_list if w not in stop_word_list])

email_path="./data/train/normal/201"
word_set=get_word_set(email_path)
print(word_set)

"""
{'总编', '负责', '学历', '招聘', '管理者', '年限', '活动', '一名', '外', '了解', '操作', '语言表达', '要求', '具有', '联盟', '沟通', '注明', '规划', '基本', '应聘者', '较强', '优先', '待遇', '简历', '编辑', '应聘', '邮件', '栏目', '肯干', '组织', '良好', '能力', '推荐', '限', '帮忙', '一年', '踏实', '知识', '德胜门', '人员', '男女', '软件', '配合', '职位', '大专', '项目', '文字', '更新', '经验', '请', '收集整理', '功底', '地点', '内容', '工作', '一定', '进行', '发送', '北京市', '中', '网站', '网络', '项目管理', '熟悉', '欢迎'}
"""

定义函数get_word_frequency计算邮件词频,作为条件概率:

  • 垃圾邮件中 发票 的词频定义为:

n u m s p a m b i l l n u m s p a m \frac{numspam_{bill}}{numspam} numspamnumspambill

  • 正常邮件中 希望 的词频定义为:

n u m n o r m a l h o p e n u m n o r m a l \frac{numnormal_{hope}}{numnormal} numnormalnumnormalhope

其中, n u m s p a m b i l l numspam_{bill} numspambill为出现"发票"的垃圾邮件数, n u m s p a m numspam numspam为垃圾邮件总数; n u m n o r m a l h o p e numnormal_{hope} numnormalhope为出现"希望"的正常邮件数, n u m n o r m a l numnormal numnormal为正常邮件总数;

from collections import Counter

def get_word_frequency(email_dir,email_list):
    # 该类邮件的单词列表
    word_list = []
    # 遍历所有邮件
    for email in email_list:
        # 邮件路径
        email_path = os.path.join(email_dir,email)
        # 将每封邮件的词集转化为列表后进行合并
        word_list += list(get_word_set(email_path))
    
    # 统计 word_list 中每个单词出现的邮件数
    # 由于每个email已经将word转为集合,所以直接统计counter
    word_dict = Counter(word_list)
    
    # 返回每个单词在该类邮件中出现的频率
    return  {w:(count/len(email_list)) for w,count in word_dict.items()}

计算先验概率:

# 训练集中垃圾邮件的数量
spam_num = len(train_spam_list)
# 训练集中正常邮件的数量
normal_num = len(train_normal_list)
# 垃圾邮件在训练集中数量占比作为垃圾邮件的先验概率估计值
p_spam = spam_num/(spam_num+normal_num)
# 反之为正常邮件的先验概率估计值
p_normal = 1-p_spam
# 输出先验概率分布
p_spam,p_normal

计算条件概率:

# 获取垃圾邮件的词频作为条件概率估计值
spam_word_probability = get_word_frequency(train_spam_dir,train_spam_list)

# 获取正常邮件的词频作为条件概率估计值
normal_word_probability = get_word_frequency(train_normal_dir,train_normal_list)

垃圾邮件过滤:

# 测试集邮件预测结果
test_pred = dict()

# 遍历测试邮件列表
for email in test_list:
    # 测试邮件路径
    email_path = os.path.join(test_dir,email)
    # 测试邮件词集
    test_word = get_word_set(email_path) 
    # 用于朴素贝叶斯分类的单词需要满足:1.存在于测试邮件词集 2. 在两类邮件中有相应的词频
    test_word = test_word&spam_word_probability.keys()&normal_word_probability.keys()
    # 初始化联合概率为先验概率:P(X,Y=spam) = P(Y=spam)
    p_words_spam = p_spam
    # 初始化联合概率为先验概率:P(X,Y=normal) = P(Y=normal)
    p_words_normal = p_normal
    # 遍历邮件分类词集
    for w in test_word:
        # 更新联合概率: P(X,Y=spam) *= P(X_i|Y=spam),i=[1-n]
        p_words_spam *=  spam_word_probability[w]
        # 更新联合概率: P(X,Y=normal) *= P(X_i|Y=normal),i=[1-n]
        p_words_normal *=  normal_word_probability[w]
    # 计算后验概率
    p_spam_words = p_words_spam/(p_words_normal+p_words_spam)
    # 大于阈值判定为垃圾邮件,否则为正常邮件
    if p_spam_words > 0.5: 
        test_pred[email] = 1
    else:
        test_pred[email] = 0

print(test_pred)
"""
{'1': 0,
...
'7998': 1,
'7999': 1}
"""

准确率的评估:

def get_accuracy(test_pred):
    
    # 预测正确的样本个数
    correct_count = 0
    # 遍历预测结果
    for email,pred in test_pred.items():
        # 邮件名<1000的为正常邮件;邮件名>1000的为垃圾邮件
        if (int(email)<1000 and pred==0) or (int(email)>1000 and pred==1): 
            correct_count += 1
    # 返回预测准确率
    return correct_count/len(test_pred) 

get_accuracy(test_pred)
# 0.9821428571428571
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值