本章的重点是使用python进行自然语言处理(NLP)。
我会结合具体案例——使用机器学习算法对电子邮件进行分类,看看是不是垃圾邮件。因此这些习题涉及到supervised learning过程。在数据集里面,每个电子邮件的标签都已经给定,我们希望使用这个数据集训练模型,以便能够将代码逻辑嵌入到应用程序里。
先看一眼数据
作为数据分析师,工作的第一步就是看数据,以便对数据有初步了解。
首先我们看到csv文件中有两列,分别是label标签,和文本text。由此可知这是个典型的有监督机器学习问题。
然后看仔细,在文件中两列是由波浪线分隔开的,因此在导入数据的时候要注意设置这个参数。
以下是本章的大纲,我会对解决问题的思路和常见问题以及某些函数的用法进行阐述:
Outline
- 使用pandas读入数据并进行初步的data understanding
- 进行data preprocessing,如果发现重复的文本内容,则进行去重
- 在原有两列的基础上新增一个特征列,用于表达每个电子邮件文本的字数,并进行可视化以加深对数据集的理解,这里可以用直方图,分别用不同 的颜色表示正常邮件(ham)和垃圾邮件(spam),通过两种邮件的频数分布,我们大致可以得到一个基于字数的分类标准
- 对电子邮件的文本text进行处理,以便能更好地适应后面用到的模型。包括:去掉停止词stopwords,去掉特殊符号,文本统一改成小写,以及把所有单词转化成其词根的形式
- 把垃圾邮件和正常邮件中,各自最频繁出现的10个单词进行可视化,可以使用条形图或词云
- 对单词的频率进行分析,创建一个tfidf矩阵,并把标签中的“spam”和“ham”编码,用数字形式表示标签的内容
- 训练模型,用于训练的数据占原始数据的7成,并保证每次执行代码,输出的结果相同。我在这里选择逻辑回归
LogisticRegression
和Multinomial Naive Bayes
导入需要的库
# basics
import random
import string
import pandas as pd
# visualization
import matplotlib.pyplot as plt
import seaborn as sns
import plotly_express as px
# Natural Language Processing
from nltk.corpus import stopwords
import nltk
# nltk.download('stopwords')
# nltk.download('wordnet')
# nltk.download('punkt')
from wordcloud import WordCloud
# Machine Learning
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import MultinomialNB
from sklearn.metrics import accuracy_score,confusion_matrix,precision_score, f1_score, recall_score
*注意:我在导入nltk之后,注释掉了三行download,这三行指令只需要执行一次就好,用于下载stopwords,wordnet和punkt三个文件。网上很多教程教我们自己手动下载,然后配置环境变量,这样太复杂了,我们只需要在import nltk后面跟上这三句指令就好
1. 读入数据和Data Understanding
data = pd.read_csv("spam.csv", sep='~')
*这里只需要注意波浪线,要使用sep参数
然后输出前几行,看看读入数据的效果怎样:
看上表,数据集是由两列组成,一列是标签,另一列是文本。我们把ham标签看作是我们需要的正常邮件,而spam标签表示垃圾邮件
还可以使用data frame 的shape属性和info函数:
*注意:在describe函数的输出中,有每列的总行数count和独一无二的行数unique,这二者的差异给我们提供了信息。此外,top表示列中出现次数最多的行
要想获取垃圾邮件和正常邮件的数量,也可以用value_counts()
函数
接下来用上一步的输出做一个饼图,看看数据集里垃圾邮件和正常邮件的比重分别是多少,这样更直观:
fig = plt.figure(figsize=(8, 5))
plt.pie(data['label'].value_counts(), labels = ['ham', 'spam ']) # 查pie函数的用法
plt.show()
pie() 方法语法格式如下:
matplotlib.pyplot.pie(x, explode=None, labels=None, colors=None, autopct=None, pctdistance=0.6, shadow=False, labeldistance=1.1, startangle=0, radius=1, counterclock=True, wedgeprops=None, textprops=None, center=0, 0, frame=False, rotatelabels=False, *, normalize=None, data=None)[source]
参数说明:
x:浮点型数组,表示每个扇形的面积。
explode:数组,表示各个扇形之间的间隔,默认值为0。
labels:列表,各个扇形的标签,默认值为 None。
colors:数组,表示各个扇形的颜色,默认值为 None。
autopct:设置饼图内各个扇形百分比显示格式,%d%% 整数百分比,%0.1f 一位小数, %0.1f%% 一位小数百分比, %0.2f%% 两位小数百分比。
labeldistance:标签标记的绘制位置,相对于半径的比例,默认值为 1.1,如 <1则绘制在饼图内侧。
pctdistance::类似于 labeldistance,指定 autopct 的位置刻度,默认值为 0.6。
shadow::布尔值 True 或 False,设置饼图的阴影,默认为 False,不设置阴影。
radius::设置饼图的半径,默认为 1。
startangle::起始绘制饼图的角度,默认为从 x 轴正方向逆时针画起,如设定 =90 则从 y 轴正方向画起。
counterclock:布尔值,设置指针方向,默认为 True,即逆时针,False 为顺时针。
wedgeprops :字典类型,默认值 None。参数字典传递给 wedge 对象用来画一个饼图。例如:wedgeprops={'linewidth':5} 设置 wedge 线宽为5。
textprops :字典类型,默认值为:None。传递给 text 对象的字典参数,用于设置标签(labels)和比例文字的格式。
center :浮点类型的列表,默认值:(0,0)。用于设置图标中心位置。
frame :布尔类型,默认值:False。如果是 True,绘制带有表的轴框架。
rotatelabels :布尔类型,默认为 False。如果为 True,旋转每个 label 到指定的角度。
2. 检查重复数据,并删除数据集里面的重复数据
*注意:我们想做的是对邮件进行分类,因此重复的数据对我们来说没用。我们只关心某个邮件是否是垃圾邮件
# 这里的思路就是看看删除重复项之前和之后,重复项的数量的变化
print(data.duplicated().sum())
print(data.duplicated())
data = data.drop_duplicates(keep='first')
print(data.duplicated().sum())
df.dublicated()函数会检查调用该函数的dataFrame中是否包含重复的行,如果有重复,则在该位置返回True
*注意:在执行完data.drop_duplicates(keep='first')之后,数据集就不在包含重复的行了,并且只要发现有重复,则保留第一个
3. 创建一个新的特征feature,用来表示文本的长度(df中增加一列)
data['len'] = data['text'].apply(len)
print(data.head())
现在我想看看data中的文本长度的分布情况,因此进行以下操作,发现最长的邮件居然有910个单词,我想读一下这么长的邮件
有以下三种输出方式,第三种最好:
print(data[data.len == 910]) # print all the entry
print(data[data.len == 910]['text'])
print(data[data.len == 910]['text'].iloc[0])
再来输出第一个长度是155的文本:
然后是所有长度为155 的文本:
继续进行data understanding。写一个函数,按照以下格式进行输出:标签:文本
def printtxts(df):
txts = df['text']
lab = df['label']
for ind in range(len(txts)):
print(f'{lab.iloc[ind]}: {txts.iloc[ind]}', end='\n\n')
# 输出数据集里面,文本长度等于155 的标签与文本信息
printtxts(data[data.len == 155][: 10])
# 不难发现,这里出现了很多垃圾邮件标签
较长的文本中,属于垃圾邮件的比较多。那么试试稍短一些的文本,
printtxts(data[data['len'] == 25][: 10])
# 在较短的邮件集合中,不难发现几乎没有垃圾邮件。这对数据分析工作来说是个很重要的趋势
这10个数据都不是垃圾邮件,说明短一些的邮件是正常邮件的几率比较大。
做个直方图,更直观:
px.histogram(data, 'len', nbins= 182, color='label')
# 看下图,大多ham邮件都较短,而spam垃圾邮件则普遍较长
现在尝试在仅仅考虑文本长度的情况下,创建一个分类器:长的邮件是垃圾,短的不是.这可能是一个很好的分类标准:
print(data[(data.len >= 90) & (data.len <= 170)]['label'].value_counts()["spam"])
print(data.label.value_counts()['spam'])
前面说的长度是字符串的长度,即一个字符串中有多少个字符(空格和标点也算),现在再来增加一个新的属性,words,表示一个邮件中包含多少个单词。要实现这一步,需要用到nltk.word_tokenize()函数。:
# split strings into section is tokenization
data['words'] = data['text'].apply(lambda x: len(nltk.word_tokenize(x))) # to produce a list of words
round(data['words'].describe(), 2)
# 注意这里的最小值min,最短的邮件只有一个单词
现在再来看一下我们的数据data:
按照单词数的频数分布,制作直方图。因为在语义学中,意思的最小单位是词汇。因此单词数量在识别垃圾邮件方面是个更好用的指标。不难看出,这个直方图的分布情况和基于字母数量的直方图类似。
px.histogram(data, "words", nbins=44, color="label")
数据预处理data preprocessing
- 文本处理的一个重要的库:string
当我们对字符串进行操作时,如果感觉代码很复杂,可以借助string库,里面有很多实用的属性。
>>> import string
>>> dir(string)
['Formatter', 'Template', '_ChainMap', '_TemplateMetaclass', '__all__', '__built
ins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__packag
e__', '__spec__', '_re', '_string', 'ascii_letters', 'ascii_lowercase', 'ascii_u
ppercase', 'capwords', 'digits', 'hexdigits', 'octdigits', 'printable', 'punctua
tion', 'whitespace']
>>> string.ascii_lowercase #所有的小写字母
'abcdefghijklmnopqrstuvwxyz'
>>> string.ascii_uppercase #所有的大写字母
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
>>> string.hexdigits #所有的十六进制字符
'0123456789abcdefABCDEF'
>>> string.whitespace #所有的空白字符
' \t\n\r\x0b\x0c'
>>> string.punctuation #所有的标点字符
'!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
-
当我们统计一个字符串中所有的单词出现的次数时,由于句子中存在标点符号,直接对字符串进行分割(split)会把单词和标点分割到一起,例如:
-
如果指定标点符号进行切割,在句子很长或者有多个标点的情况下会非常复杂。解决方案如下:
先把句子中的标点符号统一替换为空格,然后用split分割即可。替换操作可以用list comprehension来完成,判断标点符号则需要用到string.punctuation。
去掉标点之后可以用split进行字符串分割。这里就不作 演示了。后面会讲一种更好的方法:tokenization
在对data进行数据预处理之前,我先来处理一个字符串,以便于理解数据预处理的流程。
# 4. 在充分了解数据集之后,我们需要做一些数据预处理data preprocessing ---NLP
# 首先用一个长字符串实验一下
ex = 'I am a student of Business Informatics at the TU Dresden!!:) I would like to learn more about NLP...'
punct = list(string.punctuation) # 第一步:移除ex中的特殊符号。需要掌握punctuation属性和list comprehension
print(punct)
# a) 移除特殊符号
nop = [lett for lett in ex if lett not in punct]
clean = ''.join(nop) # join 的返回值是什么
print(clean) # 特殊符号已经被移除了
print(type(clean))
# 现在开始处理data,
# b) 移除stopwords, 因为stopwords并不会增加任何信息. NLTK包中已经给出了stopwords,可以直接拿来用
print(random.sample(stopwords.words('english'), 10))
print(random.sample(stopwords.words('german'), 10))
clean = [word for word in str(clean).split(" ") if not word in stopwords.words('english')]
print(clean)
# 上方单元格已经移除对于句子含义没用用处的停止词,现在进行第二步:
# c) 统一大小写
clean = [word.lower() for word in clean]
print(clean)
# d) 把字符串中的词汇转换成原型(包括但不限于,名词的单复数形式,动词的不同时态,形容词比较级等)。这样做的目的是为了让程序更好地理解句子的意思
ps = nltk.stem.PorterStemmer()
lemma = nltk.wordnet.WordNetLemmatizer()
print(ps.stem('walking'))
# import nltk
# nltk.download('omw-1.4')
print(lemma.lemmatize('walking', nltk.corpus.wordnet.VERB))
# 这里是对现在分词的lemmatization
# 如果使用过去式,则会有不同的结果。在实际使用中,需要结合实际需求
print(ps.stem('went'))
print(lemma.lemmatize('went', nltk.corpus.wordnet.VERB))
# start lemmatize of ex sentence
print('stemming')
print([ps.stem(word) for word in clean])
print('lemmatize')
print([lemma.lemmatize(word, nltk.corpus.wordnet.VERB) for word in clean]) # using dictionary words
# 现在开始对我们的数据集也进行以上四个操作,提示:可以将四种操作按照顺序封装在一个函数中,然后把这个自定义的函数apply到数据集
def preproc(message):
nostop = " ".join([word for word in message.split(' ') if word not in stopwords.words('english')])
nopunct = ''.join([lett for lett in nostop if lett not in list(string.punctuation)])
nocaps = [word.lower() for word in nopunct.split(' ')]
norm = [ps.stem(word) for word in nocaps]
return ' '.join(norm)
data['prc_text'] = data['text'].apply(preproc)
print(data.head())
# 5. 数据可视化,尝试输出垃圾邮件和正常邮件中最经常出现的单词
ham_text = " ".join(data[data.label == 'ham']['prc_text'])
spam_text = " ".join(data[data['label'] == 'spam']['prc_text'])
# 计算单词的频率
ham_freq = nltk.FreqDist(nltk.word_tokenize(ham_text))
spam_freq = nltk.FreqDist(nltk.word_tokenize(spam_text))
# 把上方的两个频率进行可视化
spam_freq_df = pd.DataFrame.from_dict(dict(spam_freq), 'index', columns=['count']).reset_index()
sns.barplot(data = spam_freq_df.sort_values('count', ascending=False)[: 15], x = 'index', y = 'count')
plt.show()
ham_freq_df = pd.DataFrame.from_dict(dict(ham_freq), 'index', columns=['count']).reset_index()
sns.barplot(data = ham_freq_df.sort_values('count', ascending=False)[: 15], x = 'index', y = 'count')
plt.show()
# 現在嘗試使用词云
wc = WordCloud(width = 500, height = 500, min_font_size = 10, background_color = 'white')
spam_wc = wc.generate(data[data['label'] == 'spam']['prc_text'].str.cat(sep = " "))
plt.figure(figsize = (20, 8))
plt.imshow(spam_wc)
plt.show()
wc = WordCloud(width = 500, height = 500, min_font_size = 10, background_color = 'white')
ham_wc = wc.generate(data[data['label'] == 'ham']['prc_text'].str.cat(sep = " "))
plt.figure(figsize = (20, 8))
plt.imshow(ham_wc)
plt.show()
# 6. tfidf
data.head()
# 构建一个tfidf矩阵,即二维数组,
tfidf = TfidfVectorizer(max_features = 3000)
x = tfidf.fit_transform(data['prc_text']).toarray()
print(x)
print(data.label)
encoder = LabelEncoder() # 把原本的文本标签spam和ham转换成数字0和1
data['label'] = encoder.fit_transform(data['label'])
print(data.label)
y = data['label'].values
print(y)
print(len(y))
# feature matrix with 3000 features, and 5169 rows, these rows are our text messages left
pd.DataFrame(x)
x.shape
y.shape # label matrix
# 7. split our dataset into train and test data set
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.3, random_state = 1703)
# 原始数据集的3成作为test集合,余下的都是用于训练的数据。为了保证每次训练的模型都一样,需要设置random_state参数
# 使用logistic regression和naive bayes模型进行分类(预测)
lr = LogisticRegression(solver='liblinear', penalty='l1')
mnb = MultinomialNB() # multi nomial naives bayes: feature matrix represents the frequencies
# train our model with train set and do some predictions
# firstly, using lr
lr.fit(x_train, y_train)
y_pred_lr = lr.predict(x_test)
test_acc_lr = accuracy_score(y_test, y_pred_lr)
precision_lr = precision_score(y_test, y_pred_lr)
f1_lr = f1_score(y_test, y_pred_lr)
recall_lr = recall_score(y_test, y_pred_lr)
print(f'Test ACC LR: {round(test_acc_lr, 3)}')
print(f'Precision LR: {round(precision_lr, 3)}')
print(f'F1_Score LR: {round(f1_lr, 3)}')
print(f'Recall LR: {round(recall_lr, 3)}')
cf_matrix = confusion_matrix(y_test, y_pred_lr)
ax = sns.heatmap(cf_matrix, annot=True, cmap='Blues', fmt='.5g')
ax.set_title('LogReg Confusion Matrix\n')
ax.set_xlabel('\nPredicted values')
ax.set_ylabel('Actual values')
ax.xaxis.set_ticklabels(['False', 'True'])
ax.yaxis.set_ticklabels(['False', 'True'])
plt.show()
# secondly, using mnb
mnb.fit(x_train, y_train)
y_pred_mnb = mnb.predict(x_test)
test_acc_mnb = accuracy_score(y_test, y_pred_mnb)
precision_mnb = precision_score(y_test, y_pred_mnb)
f1_mnb = f1_score(y_test, y_pred_mnb)
recall_mnb = recall_score(y_test, y_pred_mnb)
print(f'Test ACC MNB: {round(test_acc_mnb, 3)}')
print(f'Precision MNB: {round(precision_mnb, 3)}')
print(f'F1_Score MNB: {round(f1_mnb, 3)}')
print(f'Recall MNB: {round(recall_mnb, 3)}')
cf_matrix = confusion_matrix(y_test, y_pred_mnb)
ax = sns.heatmap(cf_matrix, annot=True, cmap='Blues', fmt='.5g')
ax.set_title('LogReg Confusion Matrix\n')
ax.set_xlabel('\nPredicted values')
ax.set_ylabel('Actual values')
ax.xaxis.set_ticklabels(['False', 'True'])
ax.yaxis.set_ticklabels(['False', 'True'])