宋天龙(TonySong) Webtrekk中国区技术和咨询负责人(Webtrekk,德国最大的网站数据分析服务提供商), 数据常青藤和数据研究与商业应用博主,资深数据分析领域专家。
著有《Python数据分析与数据化运营》、《网站数据挖掘与分析:系统方法与商业实践》 、《企业大数据系统构建实战:技术、架构、实施与应用》
Python数据分析与数据化运营 已经正式上线,点击阅读全文可以购买!
1 案例背景
文本分类是对内容做类别划分的常用场景。本案例是从一堆新闻文件中通过对文本内容建立分类模型,通过增量学习的方式实现对未知数据的预测。
案例数据源文件都在可以从《Python数据分析与数据化运营》附件中,共有两部分,第一部分在 “附件-chapter8”中的news_data.tar.gz压缩包中,该压缩包包含10个文件,这些是用来做主题模型的训练集;另外一个文件在相同目录下,名为test_sets.txt,该文件是用来做每次增量学习后的测试和检验;最后一个是article.txt,用来做分类预测。案例的程序文件chapter8_code2.py也在数据源目录之下。该附件可以在可从http://www.dataivy.cn/book/python_book.zip或https://pan.baidu.com/s/1kUUBWNX下载。
2 案例主要应用技术
本案例用到的主要技术包括:
q 数据预处理:字符串全角转半角、XML文件内容解析、文本转稀疏矩阵
q 数据建模:基于增量学习的分类器
主要用到的库包括:re、tarfile、os、numpy、bs4、sklearn,其中sklearn是核心。
本案例的重点技术有3个:
q 使用bs4的BeautifulSoup做XML文件内容解析。
q 文本转稀疏矩阵,基于sklearn的HashingVectorizer库实现,而非之前介绍过的分词技术。
q 使用sklearn中的MultinomialNB(多项式朴素贝叶斯)做分类学习,并基于增量学习的策略实现文本分类训练和预测。
3 案例数据
本案例的数据与上个案例的数据格式相同,具体参照“8.7.3 案例数据”。
4 案例过程
步骤1 导入库
import re
import tarfile # tar压缩包出库
import os # 操作系统功能模块
import numpy as np
from bs4 import BeautifulSoup # 用于XML格式化处理
from sklearn.feature_extraction.text import HashingVectorizer # 文本转稀疏矩阵
from sklearn.naive_bayes import MultinomialNB # 贝叶斯分类器
from sklearn.metrics import accuracy_score # 分类评估指标
本案例主要用到了以下库:
re:正则表达式库,用来在文本解析时提取标签
tarfile:用来解析原始数据压缩文件
os:用来判断数据是否存在以及遍历目录文件
numpy:基础数据处理模块
bs4:这里用到了其中的BeautifulSoup做XML文件内容解析
HashingVectorizer:文本转稀疏矩阵
MultinomialNB:贝叶斯分类器
accuracy_score:分类评估指标
步骤2 定义功能函数,包括全角转半角、解析文件内容、交叉检验、word to vector、label to vecotr。
全角转半角
def str_convert(content):
'''
将内容中的全角字符,包含英文字母、数字键、符号等转换为半角字符
:param content: 要转换的字符串内容
:return: 转换后的半角字符串
'''
new_str = '' # 新字符串
for each_char incontent: # 循环读取每个字符
code_num =ord(each_char) # 读取字符的ASCII值或Unicode值
if code_num ==12288: # 全角空格直接转换
code_num = 32
elif (code_num >=65281 and code_num <= 65374): # 全角字符(除空格)根据关系转化
code_num -= 65248
new_str +=unichr(code_num)
return new_str
该函数用来将文本内容中的全角字符串转换为半角字符串,这样会避免在自然语言处理过程中由于字符不统一而导致的信息混乱问题。其输入参数如下:
q content:要转换的字符串内容
函数返回:转换后的半角字符串。
注意 由于该功能是针对每一个字符做识别转换的,因此其过程比较慢。
该函数的具体定义如下:
定义一个空字符串对象new_str,用于存储转换后的结果。
使用for循环读取每个字符,然后使用ord方法读取字符的ASCII值或Unicode值赋值给code_num,接下来根据值做判断:当code_num值等于12288时,这是一个全角空格,此时直接将其值设置为32来转换成半角空格;当code_num值在[65281,65374]时,将其直接减去65248来转换为半角字符值。
数值转换完成后,使用unichr方法将其转换为字符,并通过直接相加的方法重新组合字符串。
最后返回转换后的新字符串。
相关知识点:全角字符串转半角字符串
全角指的是一个字符占2个标准字符的位置(例如中国汉字),半角指的是占1个标准字符的位置(例如普通的字符a)。在文本相关的处理过程中,半角和全角字符通常是数据预处理的必要过程。全角字符包含两类字符:
一类是特殊字符:空格,它的全角值十进制整数为12288,表示为十六进制结果是0x3000;而其半角十进制整数数值为 32,十六进制结果是0x20。
一类是有规律的字符,这类字符的全角十进制整数的范围是[65281,65374],用十六进制表示的结果是[0xFF01,0xFF5E];其对应的半角十进制整数值为[33,126],用十六进制表示的结果是[0x21,0x7E]。
除了空格外,有规律的字符在半角和全角之间的差值是65248,因此我们可以直接在全角数值上减去65248即可得到半角数值。
例如,全角字符串“00527825CBD”转换为半角字符串的结果是“00527825CBD”
在转换过程中,我们用到了2个新的函数:ord和unichr,这两
个是配对使用的函数,前者用来从字符串读取对应的数值,后者用于将数值转换为unicode字符串。
注意 并不是所有的全角字符都能被转换为半角字符,例如汉字是全角字符,占2个字符的位置,但无法被转换;只有英文字母、数字键、符号键等才能可以做全角和半角之间的转换。
解析文件内容
def data_parse(data):
'''
从原始文件中解析出文本内容和标签数据
:param data: 包含代码的原始内容
:return: 以列表形式返回文本中的所有内容和对应标签
'''
raw_code =BeautifulSoup(data, "lxml") # 建立BeautifulSoup对象
doc_code =raw_code.find_all('doc') # 从包含文本的代码块中找到doc标签
content_list = [] # 建立空列表,用来存储每个content标签的内容
label_list = [] # 建立空列表,用来存储每个content对应的label的内容
for each_doc indoc_code: # 循环读出每个doc标签
if len(each_doc) >0: # 如果dco标签的内容不为空
content_code =each_doc.find('content') # 从包含文本的代码块中找到doc标签
raw_content = content_code.text # 获取原始内容字符串
convert_content =str_convert(raw_content) # 将全角转换为半角
content_list.append(convert_content) # 将content文本内容加入列表
label_code =each_doc.find('url') # 从包含文本的代码块中找到url标签
label_content =label_code.text # 获取url信息
label =re.split('[/|.]', label_content)[2] # 将URL做分割并提取子域名
label_list.append(label) # 将子域名加入列表
return content_list,label_list
该函数主要用来从原始文件中解析出文本内容和标签数据。函数输入参数:
data:包含代码的原始内容
函数返回:以列表形式返回文本中的所有内容和对应标签
该函数的具体定义如下:
使用BeautifulSoup库建立处理对象raw_code,这里指定的解析库是lxml。
对raw_code使用find_all方法查找所有的doc标签,将返回的列表结果赋值给doc_code。
分别新建空列表对象content_list和label_list,用来存储每个content标签的内容及其对应标签。
使用for循环读出每个doc标签对象each_doc,先判断其是否为空,如果不为空,则从each_doc中使用find方法查找content标签,然后使用text属性获得标签内的内容,再调用
str_convert函数将其中的全角转换为半角,最后将结果追加到列表content_list。使用相同的思路从each_doc中提取URL信息,然后使用re正则表达式库根据/和.做字符串分割,从得到的结果中提取第3个字符(子域名)作为标签加入到标签列表。
最后返回内容列表和标签列表
提示 由于在原始文本中,url的子域名代表该文本所属的主题分类,因此直接从URL中提取子域名可以做该内容的分类标签。
相关知识点:使用re.split对内容做分割操作
在Python中,对字符串做分割是常用操作。默认的字符串支持split方法做分割,并且可指定分割符号。例如通过如下操作可以将字符串s以逗号为分隔符分割为6个元素对象:
s = 'a,2,3,4,f,5'
print (s.split(','))
但是如果要分割的对象中包含多个分割字符规则,那么可以使用正则表达式库的split方法做分割。re.split的主要参数如下:
re.split(pattern, string, maxsplit=0, flags=0)
pattern:分割规则
string:要分割的字符串
pattern是正则表达式灵活处理字符的核心,在本案例中,我们使用了最简单的并列字符的方法来表示,基于/或.都分割。除此以外,正则表达式还支持多种规则模式。
首先是正则表达式对于不同对象的表示方法,例如数字、字母等。如表8-1.
表8-1常用正则表达式的对象表示方法
但是如果要分割的对象中包含多个分割字符规则,那么可以使用正则表达式库的split方法做分割。re.split的主要参数如下:
re.split(pattern, string, maxsplit=0, flags=0)
pattern:分割规则
string:要分割的字符串
pattern是正则表达式灵活处理字符的核心,在本案例中,我们使用了最简单的并列字符的方法来表示,基于/或.都分割。除此以外,正则表达式还支持多种规则模式。
首先是正则表达式对于不同对象的表示方法,例如数字、字母等。如表8-1.
表8-1常用正则表达式的对象表示方法
关于对象的位置
在对某个对象做匹配时,可能对象具有特定的位置属性,例如字符的开头、结尾等,此时可以利用其位置属性做规则匹配,如表8-2.
表8-2常用正则表达式的位置表示方法
关于对象的次数
在匹配过程中,可能我们希望对其出现的次数做限制,例如出现5个数字,3个字母等,此时我们需要次数控制规则,如表8-3.
表8-3常用正则表达式的此时表示方法
关于特殊匹配模式
很多时候,我们会有一些特殊的匹配模式,例如:
匹配多个并列条件,此时使用|来表示
匹配字符集,使用[]表示,[]中可以使用表8-1中的规则来定义字符对象
对字符集的总体取非操作,使用[^]
定义一个字符集区间,例如使用[1-5]定义一个区间
交叉检验
def cross_val(model_object, data, label):
'''
通过交叉检验计算每次增量学习后的模型得分
:param model_object: 每次增量学习后的模型对象
:param data: 训练数据集
:param label: 训练数据集对应的标签
:return: 交叉检验得分
'''
predict_label =model_object.predict(data) # 预测测试集标签
score_tmp =round(accuracy_score(label, predict_label), 4) # 计算预测准确率
return score_tmp
本函数通过交叉检验计算每次增量学习后的模型得分。输入参数:
model_object:每次增量学习后的模型对象
data:训练数据集
label:训练数据集对应的标签
函数返回:交叉检验得分
函数功能具体实现如下:
直接调用增量学习对象model_object的predict方法对测试数据集data做测试,并对照其真实label做数据检验,最后返回每次得分。其中:
使用指定的accuracy_score方法获得预测标签与真实标签的预测准确率
使用round()方法保留结果为4位小数
注意 这里没有使用普通的cross_val_score方法做多折交叉检验,原因是cross_val_score方法中默认使用函数对象的fit方法做训练,而不支持增量学习的方法,因此无法对增量学习的效果做验证。所以,这里通过“手动”的方法做结果测试。
word to vector
def word_to_vector(data):
'''
将训练集文本数据转换为稀疏矩阵
:param data: 输入的文本列表
:return: 稀疏矩阵
'''
model_vector =HashingVectorizer(non_negative=True) # 建立HashingVectorizer对象
vector_data =model_vector.fit_transform(data) # 将输入文本转化为稀疏矩阵
return vector_data
本函数用来将训练集文本数据转换为稀疏矩阵。在之前的章节中,我们介绍过使用结巴分词、sklearn中的TfidfVectorizer、gensim中的TfidfModel建立文本向量模型。这里我们介绍另外一种方法HashingVectorizer。
HashingVectorizer用来将文本文档集合转化为token发生次数的集合(这点跟上个案例中gensim的doc2bow思路很像),其结果是用token发生次数(或者二进制信息)的scipy.sparse稀疏矩阵来表示文本文档信息。这种方法的优势在于:
无需在内存中存储任何字典信息,因此非常适合大数据集的计算
没有任何对象的“状态”信息,因此可以很快的执行pickle或un-pickle操作
非常适合在增量学习或者管道方法中应用,因为其在fit期间没有任何状态变更
提示 如何理解HashingVectorizer的状态?在之前的章节中,我们要做预测应用时,一般都需要针对训练集和预测集分阶段执行,原因是很多对象都有固定的转换或计算模式,该模式在fit期间产生,在训练或预测时都是用相同的fit对象的计算模式。例如使用PCA做降维,先对其做fit操作形成降维对象,然后分别对训练集和预测集做降维。而HashingVectorizer则在fit方法下其对象不产生任何状态的变更,这点决定了我们不需将其对象存储下来,也不需要分阶段针对不同数据集做区分应用。
该函数的实现过程如下:
通过HashingVectorizer(non_negative=True)建立HashingVectorizer对象,指定结果非负。
model_vector.fit_transform(data)将输入文本转化为稀疏矩阵,并返回其结果
label to vecotr
def label_to_vector(label, unique_list):
'''
将文本标签转换为向量标签
:param label: 文本列表
:unique_list: 唯一值列表
:return: 向量标签列表
'''
for each_index, each_datain enumerate(label): # 循环读取每个标签的索引及对应值
label[each_index] =unique_list.index(each_data) # 将值替换为其索引
return label
本函数用来将文本标签转换为向量标签,例如将['sports', 'house', 'news']转换为[0,1,2]。输入参数:
label:原始文本标签列表
unique_list:标签唯一值列表
函数返回:向量标签列表
函数的实现过程如下:
先通过for循环结合enumerate()方法,从label中读取每个索引及其对应文本标签值
然后使用列表赋值方法,逐个将其文本值替换为索引值
最后返回新的向量标签列表
步骤3 解压缩文件,从该步骤开始进入到应用过程。解压缩文件步骤是将tar.gz中的压缩文件提取出来。
if not os.path.exists('./news_data'): # 如果不存在数据目录,则先解压数据文件
print ('extract data fromnews_data.tar.gz...')
tar =tarfile.open('news_data.tar.gz') # 打开tar.gz压缩包对象
names =tar.getnames() # 获得压缩包内的每个文件对象的名称
for name in names: # 循环读出每个文件
tar.extract(name,path='./') # 将文件解压到指定目录
tar.close() # 关闭压缩包对象
该过程主要定义如下:
if not os.path.exists('./news_data'):通过os.path.exists来判断指定目录是否存在,如果不存在则执行该之后的功能
提示 os.path.exists是对文件做操作的常用方法,基于该方法可以对文件做增、删、改等操作。而os.path则是更加实用的功能库,该库可以用来对文件或目录做查找、判断、合并、分裂等,通过更多的会配合目录或文件操作命令实现目录操作。
使用tarfile.open方法打开压缩包并建立操作对象tar
使用tar.getnames()获得压缩包内的每个文件对象的名称
通过for循环配合extract方法将每个文件提取到指定目录下面(path定义的目录)
关闭压缩包对象
步骤4定义对象,该步骤用来定义在接下来的训练过程中用到
的全局变量和常量。其中:
all_content = [] # 列表,用于存储所有训练集的文本内容
all_label = [] # 列表,用于存储所有训练集的标签
score_list = list() # 列表,用于存储每次交叉检验得分
pre_list = list() # 列表,用于存储每次增量计算后的预测标签
unique_list = ['sports', 'house', 'news'] # 标签唯一值列表
print ('unique label:', unique_list)
model_nb = MultinomialNB() # 建立MultinomialNB模型对象
由于上述定义非常简单,在注释中都有解释,在此不再赘述,仅说明几个可能有疑问的定义:
pre_list:由于每次增量学习时,都会调用增量学习后的对象做预测,因此这里可以分析每次预测与实际值是否相符。
unique_list:由于我们在做训练之前已经获知内容分类,因此这里直接定义;如果没有该先验经验信息,那么需要单独从列表中获取。需要注意的是,该列表的运算只能全局性运算一次,否则每个文件中由于训练集出现的顺序不同,可能导致不同文件下唯一值列表的顺序不同。例如文件1的唯一值列表是['sports','house', 'news'],而文件2的唯一值列表可能是['house', 'sports', 'news'],虽然对应的索引都是[01,2],但对应的类别已经不同。
上述过程返回唯一值列表信息如下:('unique label:', ['sports', 'house', 'news']),该列表将作为新数据集预测的索引结果参照。
步骤4交叉检验和预测数据集预处理,该步骤用来实现在增量学习过程中涉及到的交叉检验和预测数据集的预处理工作。由于在
训练过程中会不断调用该信息,因此这里统一处理之后再做后续调用,避免重复计算浪费时间和资源。
# 交叉检验集
with open('test_sets.txt') as f:
test_data = f.read()
test_content, test_label = data_parse(test_data) # 解析文本内容和标签
test_data_vector = word_to_vector(test_content) # 将文本内容向量化
test_label_vecotr = label_to_vector(test_label, unique_list) # 将标签内容向量化
# 预测集
with open('article.txt') as f:
new_data = f.read()
new_content, new_label = data_parse(new_data) # 解析文本内容和标签
new_data_vector = word_to_vector(new_content) # 将文本内容向量化
上述实现过程比较简单,基本思路是:
使用with open()方法打开文件并读取文件中的数据,形成原始数据对象,交叉检验和预测数据集都是独立于训练集的数据
调用data_parse()解析出文本内容和对应标签
调用word_to_vector()将文本内容向量化
调用label_to_vector()将标签内容向量化(仅针对交叉检验集)
步骤5 增量学习,该步骤是本节内容的主要环节。
print ('{:*^60}'.format('incremental learning...'))
for root, dirs, files in os.walk('./news_data'): # 分别读取遍历目录下的根目录、子目录和文件列表
for file in files: # 读取每个文件
file_name =os.path.join(root, file) # 将目录路径与文件名合并为带有完整路径的文件名
print ('training file:%s' % file)
# 增量训练
with open(file_name)as f: # 以只读方式打开文件
data =f.read() # 读取文件内容
content, label =data_parse(data) # 解析文本内容和标签
data_vector =word_to_vector(content) # 将文本内容向量化
label_vecotr =label_to_vector(label, unique_list) # 将标签内容向量化
model_nb.partial_fit(data_vector, label_vecotr, classes=np.array([0, 1,2])) # 增量学习
# 交叉检验
score_list.append(cross_val(model_nb,test_data_vector, test_label_vecotr)) # 将交叉检验结果存入列表
# 增量预测
predict_y =model_nb.predict(new_data_vector) # 预测内容标签
pre_list.append(predict_y.tolist())
print ('{:*^60}'.format('cross validation score:'))
print (score_list) # 打印输出每次交叉检验得分
print ('{:*^60}'.format('predicted labels:'))
print (pre_list) # 打印输出每次预测标签索引值
print ('{:*^60}'.format('true labels:'))
print (new_label) # 打印输出正确的标签值
先通过两层for循环获取目录news_data下的每个文件名。使用os.path.join将目录和文件名连接起来,方便后续按文件名读取内容。然后针对每个文件做3部分应用:增量训练、交叉检验、增量预测。
增量训练。使用with open()方法读取每个文件的数据,然后依次调用data_parse、word_to_vector、label_to_vector函数实现解析文本内容和标签并将内容和标签做向量化转换。使用贝叶斯分类器的partial_fit方法而非fit方法做增量训练。
注意 在第一次增量训练时,必须通过classes来指定分类的类别,后续过程的类别指定是可选的。
交叉检验。调用cross_val函数,并将在步骤4中处理好的数据以及增量学习的模型对象做检验检查,将结果追加到score_list列表中。
增量预测。调用贝叶斯增量学习对象的predict方法做预测,将预测结果转换为列表后追加到pre_list。
最后依次输出每次交叉检验得分、预测标签索引值、正确的标签值。
上述代码完成后返回如下信息:
5 案例数据结论
从cross validation score得到的结果看,随着每次数据量的增加,交叉检验的得分的趋势不断提高,这也证实了增量学习本身对于准确率的提升贡献,但在第8次训练时,总体得分从0.9147下降到0.9142,其中可能包含以下原因:
第8次的数据集本身是有误的(或者不准确的),导致检验结果下降。
之前的数据中可能存在有误信息,而第8次本身的信息是准确的,导致第8次的结果略有下降。
从10次的检验结果来看,整体趋势的增长是良好的。
对新数据集的预测时,无论哪个阶段都能准确的预测出其类别归属('sports'对应的索引值为0)。
6 案例应用和部署
在此类应用的部署中,有以下几点需要经过改造才能部署到应用环境:
类别标签的定义,应该从常量定义改为从固定标签获取。但考虑到增量学习时,一般情况下标签值都是固定的,即不能出现第2次训练时的预测标签类别是[1,2,3],到后期变成[2,3,4]。因此这种一次性操作只需在首次执行时设置即可。同样的,还有在第一次增量学习时,指定的classes值也需要做对应设置。
数据来源,需要改造为实际读取数据的环境。一般情况下,
这种增量学习适合实时计算需求比较高的场景,例如个性化推荐,因此数据来源一般会基于实时产生数据的系统或机制,例如网站分析系统等。
7 案例注意点
在针对文本做分类时,需要读者注意以下几个问题:
针对文本的全角和半角转换必不可少,实际上除此以外还包括大小写转换等操作,这些更多的用于英文处理。本案例中没有任何对于大小写转换的操作,原因是在HashingVectorizer中默认通过lowercase=True来实现该功能。
在针对每个文档(doc)做解析时,由于每个doc下面只含有一个url和content,因此使用的是find而不是find_all方法(当然find_all方法也能实现,但返回的是一个列表)。
不要直接使用不支持增量学习的交叉检验方法做模型做检验,否则将得到错误的得分信息。
增量学习的一般趋势是良好的,但如果遇到随着增量学习其准确率不断下降或不稳定的情况,需要检查检验方法是否准确或者分析是否存在数据集标签标定或解析错误的问题。
8 案例引申思考
关于增量学习的价值
增量学习的优点并不是通过算法或模型本身来提供较高的准确率,而是通过不断有新数据的加入来提高模型的准确率,因此在一定意义上,模型本身的选择以及调参等动作都变得“不那么重要”,因为只要数据足够大,即使再差的模型也会由于掌握了足够的多的数据规律而更加精准的预测新样本,这是增量学习的关键所在。
当然,增量学习还能实现在物理硬件限制(尤其是内存)及其
他软硬件不作任何优化的条件下,对于海量数据的训练的支持,是一种非常好的解决大数据量计算问题的有效方法。
关于本案例中涉及到的方法
训练集的文本跟预测集的文本不一致,会导致训练时的中间过程或分类模型无法适用于预测过程,这点在文本分类时非常常见。案例中使用的HashingVectorizer能将词语出现的频率映射到固定维度空间,即使出现新的词语也不会影响固定维度空间的模式,因此非常适合预测应用时新词较多的场景。
HashingVectorizer本身能提供压缩后的稀疏矩阵,其本身就能大量降低对于系统内存的占用,非常适合大数据集下的计算和处理。
贝叶斯分类器广泛应用于文本分类领域,其效果较好。除了本文提到的MultinomialNB外,还包括BernoulliNB和 GaussianNB两种方法,他们各自有其适用场景。
有关数据分析与挖掘的更多内容,请查看《Python数据分析与数据化运营》。有关这本书的写作感受、详细内容介绍、附件(含数据和代码源文件-源代码可更改数据源直接使用)下载、关键知识和方法以及完整书稿目录,请访问《Python数据分析与数据化运营》新书上线,要购买此书请直接点击图片或扫描二维码去京东购买。