磐创AI分享
作者 | Wei-Ting Yap
编译 | VK
来源 | Towards Data Science
自然语言处理是机器学习的一个领域,涉及到对人类语言的理解。与数字数据不同,NLP主要处理文本。探索和预处理文本数据需要不同的技术和库,本教程将演示基础知识。
然而,预处理不是一个算法过程。在数据科学任务中,数据的上下文通常决定了数据的哪些方面是有价值的,哪些方面是不相关的或不可靠的。在本教程中,我们将探讨tweets上下文中的文本预处理,或者更广泛地说,社交媒体。
我们的数据集来自Kaggle(https://www.kaggle.com/c/nlp-getting-started),Kaggle提供了一个合理大小的数据集(训练集中大约7500条推文)供练习。挑战在于根据tweet的文本、关键字和位置,将其归类为是否真的是灾难。
本教程的代码可以在本笔记本和代码仓库中找到:https://github.com/weiting109/disaster-tweets-classifier/blob/main/nb.ipynb
在开始之前,请从Kaggle下载nlp-getting-started
数据。在我的项目目录中,我把train.csv
, test.csv
, 和sample_submission.csv
放在数据子目录下。
让我们从导入典型和有用的数据科学库开始,并创建一个`train.csv. 我不会深入研究非NLP特定的库的细节。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
data = pd.read_csv('data/train.csv', index_col='id')
data.head()
keyword location text target
id
1 NaN NaN Our Deeds are the Reason of this #earthquake M... 1
4 NaN NaN Forest fire near La Ronge Sask. Canada 1
5 NaN NaN All residents asked to 'shelter in place' are ... 1
6 NaN NaN 13,000 people receive #wildfires evacuation or... 1
7 NaN NaN Just got sent this photo from Ruby #Alaska as ...
我们的数据包括4列,关键字,位置,文本和目标。引用Kaggle的数据描述:
为了确保数据集中的行数和列数的完整性,以及对训练集的泛化性做出判断,让我们了解一下训练数据的大小。
data.shape
(7613, 4)
仔细检查,我们发现有52行重复(不同的id,但是关键字、位置、文本和目标相同。
np.sum(data.duplicated()) # 52份相同的
所以让我们删除重复的行。索引保持不变。删除重复行之后,我们只剩下7561条tweet(完整性检查,如前所述),这是本教程中可使用的数量。
然而,对于NLP来说,7561个数据点仍然相对较少,特别是如果我们使用深度学习模型的话。考虑到每天可能有将近一百万条推文,我怀疑一个仅训练了7561个数据点的模型是否足够普遍。
# 我们把相同的丢弃
df = data
df = data.drop_duplicates()
df.shape
我们的输出:
(7561, 4)
除了训练规模外,训练集中的类别(target)的平衡也很重要。所有目标值为0的训练集将使模型将每条tweet分类为与灾难无关。反之亦然。理想情况下,训练集中的所有类数量都应该平衡。
我们可以使用panda的dataframe value_counts方法来计算每个类的行数。4322条不是关于灾难的tweet(target=0)和3239条关于灾难的tweet(target=1),类别为4:3。这不是完美的,但也不是灾难性的不平衡。
# 目标1指灾难微博,0不是灾难微博
df['target'].value_counts()
我们的输出:
0 4322
1 3239
Name: target, dtype: int64
让我们来看看数据的完整性。我们可以使用panda的dataframe isna
方法返回的序列求和,以计算每个列的na数。
# 检查数据的完整性
print(f"{np.sum(df['keyword'].isna())} rows have no keywords")
print(f"{np.sum(df['location'].isna())} rows have no location")
print(f"{np.sum(df['text'].isna())} rows have no text")
print(f"{np.sum(df['text'].isna())} rows have no target")
我们的输出:
61 rows have no keywords
2533 rows have no location
0 rows have no text
0 rows have no target
理想情况下,我们将通过分析字长、句子长度、词频等来进一步描述和探索数据。虽然这超出了本教程的范围,但是你可以在这里了解更多:https://neptune.ai/blog/exploratory-data-analysis-natural-language-processing-tools
现在我们已经研究了数据,让我们预处理tweets并以模型可以接受的形式表示它们。
文本最常见的数字表示是词袋表示法。
词袋是一种用数字表示文本数据的方法。文本数据本质上被分割成单词(或者更准确地说,标识),这是特征。每个文本数据中每个词的频率都是相应的特征值。例如,我们可以将“I love cake very much”表示为:
{
'I':1,
'love':1,
'cake':1,
'very':2,
'much':1
}
标识化将文本数据分解为标识(token)。最简单的(也是最常见的)也就是单词,它完全符合我们的词袋表示。但是,这些标识还可以包括标点符号、停用词和其他自定义标识。我们将在下一节课中结合tweets和挑战来考虑这些问题。
词根还原是指将词缀(前缀或后缀)截断,使其近似于词根形式。这通常是通过查找字典来判断是否是前缀和后缀来完成的,这使得它的计算速度很快。
然而,这是一个性能权衡。在英语中,一些词缀会完全改变词义,从而产生准确的特征表示。
词干分析的另一种方法是词形还原。这是通过查找字典来完成的,因此会导致计算开销更大。然而,性能通常更好,因为词形一般是真实单词,而词根不是。
鉴于我们的数据集相对较小,我们将使用词形还原。
从tweets到他们的词袋表示就不那么简单了。关于:
在决定如何处理这些元素时,我们必须考虑数据的上下文,并将其与挑战相协调。
在互联网行话中,大小写不同可以传达不同的情感(例如,danger vs DANGER!)。通过将所有标识改为大写或小写,我们可能会丢失有助于分类的数据。
但是,由于我们有一个小的数据集(7500条tweets),以上类型的数据可能会很少,所以我们全部小写化。
毫无疑问,tweet将包含标点符号,这些标点符号也可以传达不同的情感或情绪。考虑一下,在互联网术语中,以下两者之间的区别:
我们将把标点符号视为各自的标识,特殊情况下,“…”是“.”与“.”分开的标识。这样我们就不会丢失数据,我们可以在调整超参数时忽略它们(甚至调整要忽略的标点)。
停用词本质上是非常常见的词,它们对文本的意义没有什么重要的贡献。这些词包括冠词(the, a, that)和其他常用的词(what, how, many)。
在NLP处理中,停用词标识通常被忽略。然而,与其从一开始就忽略停用词,不如在调整超参数时忽略它们(甚至调整要忽略的停用词),这样就不会丢失数据。
tweet中的数字可以传达文字对象的数量,但也可以传达某种事物的规模(如里氏7.9级地震)或年份(如2005年卡特里娜飓风)。
在后两种情况下,这些数字信息可能很有价值,这取决于我们以后选择的NLP级别(单词级别与短语级别或句子级别),或者我们是否希望过滤有关历史灾难与当前灾难的tweet。
因此,我们将保留数字作为标识,在调整超参数时可以选择忽略它们(甚至只计算年份)。
在Twitter上,提及允许用户通过tweet互相称呼。虽然个人账户之间的提及可能不那么重要,但提及各种机构的账号却是十分重要(考虑一下@policeauthorities,gurn shooting down brick lane)
让我们将提及的内容和他们的用户名一起标识化,同时计算被提及的次数。
Twitter上的标签允许用户发现与特定主题或主题相关的内容。当谈到自然灾害时,像*#prayforCountryX和#RIPxyzShootings*这样的标签可以将关于灾难的tweet与日常的tweets区分开来。
因此,让我们用标签的内容来标识标签,但也要计算标签的数量。
灾难推特可以包括新闻文章、救灾工作或图片的网址。然而,日常微博也是如此。由于我们不确定灾难性tweet是否更有可能具有URL或某种类型的URL,所以让我们将URL作为标识,并将URL的数量作为一个特征。
这个数据集以tweets的短网址为特色(http://t.co),但更多当前的tweet数据可以包括域,然后可以提取这些域(我想红十字会的域将与灾难tweets高度相关)。对于更复杂的算法,还可以考虑访问缩短的URL和抓取web页面元素。
spaCy是一个用于自然语言处理的开源python库。它与其他python机器学习库(scikitlearn、TensorFlow、PyTorch)等集成良好,并使用面向对象的方法来保持其接口的可读性和易用性。
值得注意的是,它的模型返回文档类型数据,它由带有各种有用注释(例如,其词形,是否为停用词)的标识组成,作为属性。
让我们导入spaCy,下载American English的模型,并加载相关的spaCy模型。
# 下载美国英语spaCy库
!python3 -m spacy download en_core_web_sm
import spacy
import en_core_web_sm
nlp = en_core_web_sm.load()
在定制spaCy之前,我们可以看看spaCy是如何用默认规则标识tweet的。我创建了一个tweet,包括一个数字、一个缩写、一个标签、一个提及和一个链接。
如下所示,spaCy已经分解了,并给出了相关的词形。它还根据默认规则将数字、提及和url识别为它们自己的标识。这就给我们留下了hashtags,它们被分成一个“#”标点和hashtag内容,而不是作为一个完整的标识。
# 让我们看看spaCy对数字、缩写、hashtags、@提及和url做了什么
s = "2020 can't get any worse #ihate2020 @bestfriend <https://t.co>"
doc = nlp(s)
# 让我们看看词形与是否它为停用词
print(f"Token\\t\\tLemma\\t\\tStopword")
print("="*40)
for token in doc:
print(f"{token}\\t\\t{token.lemma_}\\t\\t{token.is_stop}"
打印:
Token Lemma Stopword
========================================
2020 2020 False
ca can True
n't not True
get get True
any any True
worse bad False
# # False
ihate2020 ihate2020 False
@bestfriend @bestfriend False
<https://t.co> <https://t.co> False
我们可以修改spaCy的模型,将hashtags识别为整个标识。
可以修改spaCy的标识器(如果需要,也可以构建自定义标识器!)通过重新定义其默认规则。spaCy的标识器按以下顺序排列规则的优先级:标识匹配模式、前缀、后缀、中缀、URL、特殊情况(请参阅spaCy的标识器是如何工作的):https://spacy.io/usage/linguistic-features#how-tokenizer-works
在我们的例子中,我们将通过添加“#\\w+
”来修改标识器的模式匹配regex模式(在这里阅读有关regex的更多信息:一个用Python编写的regex的简单介绍:https://towardsdatascience.com/a-simple-intro-to-regex-with-python-14d23a34d170)
# 我们还希望保留#hashtags作为标识,因此我们将修改spaCy模型的tokenŠmatch
import re
# 检索匹配regex模式的默认标识
re_token_match = spacy.tokenizer._get_regex_pattern(nlp.Defaults.token_match)
# 添加标签模式
re_token_match = f"({re_token_match}|#\\w+)"
nlp.tokenizer.token_match = re.compile(re_token_match).match
# 现在让我们再试一次
s = "2020 can't get any worse #ihate2020 @bestfriend <https://t.co>"
doc = nlp(s)
# 让我们看看词形与是否它为停用词
print(f"Token\\t\\tLemma\\t\\tStopword")
print("="*40)
for token in doc:
print(f"{token}\\t\\t{token.lemma_}\\t\\t{token.is_stop}")
我们的代码打印:
Token Lemma Stopword
========================================
2020 2020 False
ca can True
n't not True
get get True
any any True
worse bad False
#ihate2020 #ihate2020 False
@bestfriend @bestfriend False
<https://t.co> <https://t.co> False
然后我们可以继续创建一个预处理算法,并将其放入一个函数中,这样就可以在训练集中的每个tweet上调用它。在以下预处理函数中,每条tweet:
features
集联合# 为每个tweet创建预处理函数
def preprocess(s, nlp, features):
"""
给定参数s, spaCy模型nlp, 和特征集
预处理s并返回更新的特征和词袋
- 小写
- 创建具有spaCy的文档
- 词形与特征集的结合
- 为tweet构建一个词袋
"""
# 小写
s = s.lower()
# 创建具有spaCy的文档
doc = nlp(s)
lemmas = []
for token in doc:
lemmas.append(token.lemma_)
# 词形与特征集的结合
features |= set(lemmas)
# 为tweet构建一个词袋
freq = {'#':0,'@':0,'URL':0}
for word in lemmas:
freq[str(word)] = 0
for token in doc:
if '#' in str(token): freq['#'] += 1 # 对哈希标识计数
if '@' in str(token): freq['@'] += 1 # 对被提及的次数计数
if 'http://' in str(token): freq['URL'] += 1 # 对URL计数
freq[str(token.lemma_)] += 1
return features, freq
我们将创建一个消除重复数据的副本,这样任何预处理更改都不会影响训练数据的原始状态。然后,我们将初始化一个python集合特征,它将包含每个tweet的所有特征。除了通过标识化每个tweet遇到的所有词形之外,特征还包括hashtags数量(#)、提及次数(@)和URL数量(URL)。
preprocess_df = df # 备份
features = set({'#','@','URL'}) # 使用feature包含所看到的所有单词(词形)
使用我们的预处理函数,我们将对每条tweet进行预处理,每次都用新的词形。对于每个tweet,tweet的词袋表示被附加到bow_array。
# bow_array[i]是tweet id(i+1)的表示
bow_array = []
for i in range(len(preprocess_df)):
features, freq = preprocess(preprocess_df.iloc[i]['text'],nlp,features)
bow_array.append(freq)
len(bow_array) # 7561
通过在features中收集到的所有tweet中遇到的所有词形,我们可以创建一个数据帧bow来表示所有tweet的特征。
# 为每条tweet创建词袋表示的数据帧
bow = pd.DataFrame('0', columns=features,index=range(len(preprocess_df)))
bow['id']=preprocess_df.index
bow.set_index('id',drop=True,inplace=True)
现在,让我们用每条tweet的特征值更新我们的数据帧。
# 用tweet id的词袋频率更新bow[i](i+1)
for i in range(len(preprocess_df)):
freq = bow_array[i]
for f in freq:
bow.loc[i+1,f]=freq[f]
我们使用pandas Dataframe的join方法。保存preprocessed .csv文件,以便于下一步操作!
# 将词袋表示加入到训练数据帧中
# 对于不是词形标识的特征,请在“keyword”、“location”、“text”和“target”后附加“data后缀”
preprocess_df = preprocess_df.join(bow,lsuffix='_data')
# 为合作者保存词袋表示
preprocess_df.to_csv("data/train_preprocessed.csv",index=True,index_label='id')
既然我们已经预先处理了我们的数据,在我们开始使用它来训练我们选择的模型之前,还有最后一步。我们把它分成训练集和验证集,根据类的分布进行分层。我们使用sklearn.model_selection:
from sklearn.model_selection import train_test_split
# stratify=y创建一个平衡的验证集
y = preprocess_df['target_data']
df_train, df_val = train_test_split(preprocess_df, test_size=0.10, random_state=101, stratify=y)
# 保存csv文件
df_train.to_csv("data/train_preprocessed_split.csv",index=True)
df_val.to_csv("data/val_preprocessed_split.csv",index=True)
print(df_train.shape, df_val.shape)
(6851, 21330) (762, 21330)
为了确定,我们可以检查一下余额。
# 检查余额
print(f"""
Ratio of target=1 to target=0 tweets in:\\n
Original data set = {np.sum(preprocess_df['target_data']==1)/np.sum(preprocess_df['target_data']==0)},
\\n
Training data set = {np.sum(df_train['target_data']==1)/np.sum(df_train['target_data']==0)},
\\n
Validation data set = {np.sum(df_val['target_data']==1)/np.sum(df_val['target_data']==0)}""")
打印:
Ratio of target=1 to target=0 tweets in:
Original data set = 0.7533394748963611,
Training data set = 0.7535193242897363,
Validation data set = 0.7517241379310344
如果你看过其他NLP预处理教程,你会发现它们的许多步骤都作为考虑因素包括在内,但在这里没有实现。其中包括删除标点、数字和停用词。但是,我们的训练数据集很小,因此,我们没有在预处理阶段消除这些数据,而是将它们作为调整模型超参数的可能方法。
通过本教程,我们已经将tweet预处理成词袋表示。但是,你可以选择使用TFIDF进一步研究。
在本教程中,我们忽略了位置和关键字,只关注tweets。你可以考虑根据相似性来编码位置,考虑同一个地方的不同拼写(例如USA vs U.S.),以及缺失的值。还可以将关键字的权重加重,并查看这对模型的性能有何影响。
最后,URL中可能有我们遗漏的有价值的信息。鉴于它们是缩写形式,我们无法单独从文本数据中提取域名或页面内容。你可以考虑建立一个算法来访问站点,提取域名,以及在页面上爬取相关元素(例如页面标题)。
现在我们已经探索并预处理了数据集,现在是时候在它们上尝试机器学习模型了!此类分类问题的可能模型包括logistic回归、神经网络和支持向量机。
[1] Kaggle, Disaster tweets classification challenge on Kaggle (2020), Kaggle
[2] D. Becker and M. Leonard, Intro to NLP (n.d.), Natural Language Processing Course on Kaggle
[3] D. Becker and M. Leonard, Text Classification with SpaCy (n.d.), Natural Language Processing Course on Kaggle
[4] Yse, D. L. Your Guide to Natural Language Processing (2019), Towards Data Science
[5] Explosion AI, spaCy’s 101: Everything you need to know (n.d.), spaCy
✄------------------------------------------------