import os import requests import gzip import shutil # Retrieve the data if not os.path.exists(os.path.join('data','Video_Games_5.json.gz')): ❶ url = ➥ "http:/ /deepyeti.ucsd.edu/jianmo/amazon/categoryFilesSmall/Video_Games_ ➥ 5.json.gz" # Get the file from web r = requests.get(url) if not os.path.exists('data'): os.mkdir('data') # Write to a file with open(os.path.join('data','Video_Games_5.json.gz'), 'wb') as f: f.write(r.content) else: ❷ print("The tar file already exists.") if not os.path.exists(os.path.join('data', 'Video_Games_5.json')): ❸ with gzip.open(os.path.join('data','Video_Games_5.json.gz'), 'rb') as f_in: with open(os.path.join('data','Video_Games_5.json'), 'wb') as f_out: shutil.copyfileobj(f_in, f_out) else: print("The extracted data already exists")
❶ 如果 gzip 文件尚未下载,请下载并保存到磁盘上。
❷ 如果 gzip 文件位于本地磁盘中,则无需下载。
❸ 如果 gzip 文件存在但尚未解压,请解压它。
这段代码将数据下载到本地文件夹(如果尚未存在)并提取内容。它将包含一个包含数据的 JSON 文件。 JSON 是一种用于表示数据的格式,主要用于在 Web 请求中传输数据。它允许我们将数据定义为键值对。如果你查看 JSON 文件,你会看到每行有一条记录,每条记录都是一组键值对,键是列名,值是该记录的该列的值。你可以从数据中提取出几条记录:
{"overall": 5.0, "verified": true, "reviewTime": "10 17, 2015", ➥ "reviewerID": "xxx", "asin": "0700026657", "reviewerName": "xxx", ➥ "reviewText": "This game is a bit hard to get the hang of, but when you ➥ do it's great.", "summary": "but when you do it's great.", ➥ "unixReviewTime": 1445040000} {"overall": 4.0, "verified": false, "reviewTime": "07 27, 2015", ➥ "reviewerID": "xxx", "asin": "0700026657", "reviewerName": "xxx", ➥ "reviewText": "I played it a while but it was alright. The steam was a ➥ bit of trouble. The more they move ... looking forward to anno 2205 I ➥ really want to play my way to the moon.", "summary": "But in spite of ➥ that it was fun, I liked it", "unixReviewTime": 1437955200} {"overall": 3.0, "verified": true, "reviewTime": "02 23, 2015", ➥ "reviewerID": "xxx", "asin": "0700026657", "reviewerName": "xxx", ➥ "reviewText": "ok game.", "summary": "Three Stars", "unixReviewTime": ➥ 1424649600}
接下来,我们将进一步探索我们拥有的数据:
import pandas as pd # Read the JSON file review_df = pd.read_json( os.path.join('data', 'Video_Games_5.json'), lines=True, orient='records' ) # Select on the columns we're interested in review_df = review_df[["overall", "verified", "reviewTime", "reviewText"]] review_df.head()
数据是以 JSON 格式呈现的。pandas 提供了一个 pd.read_json()函数来轻松读取 JSON 数据。在读取 JSON 数据时,你必须确保正确设置 orient 参数。这是因为 orient 参数使 pandas 能够理解 JSON 数据的结构。与具有更一致结构的 CSV 文件相比,JSON 数据是非结构化的。设置 orient='records’将使 pandas 能够正确读取以这种方式结构化的数据(每行一个记录)到一个 pandas DataFrame 中。运行上述代码片段将产生表 9.1 中所示的输出。
表 9.1 Amazon 评论数据集的示例数据
整体 | 验证 | 评论时间 | 评论文本 | |
0 | 5 | True | 10 17, 2015 | 这个游戏有点难以掌握,但… |
1 | 4 | False | 07 27, 2015 | 我玩了一会儿,还行。… |
2 | 3 | True | 02 23, 2015 | 好吧,游戏还可以。 |
3 | 2 | True | 02 20, 2015 | 觉得这个游戏有点太复杂,不是我想要的… |
4 | 5 | True | 12 25, 2014 | 好游戏,我喜欢它,从那时起就一直在玩… |
我们现在将删除评论文本列中的任何空值或 null 值记录:
review_df = review_df[~review_df["reviewText"].isna()] review_df = review_df[review_df["reviewText"].str.strip().str.len()>0]
正如你可能已经注意到的,有一列显示评论是否来自验证购买者。为了保护我们数据的完整性,让我们只考虑来自验证购买者的评论。但在此之前,我们必须确保在过滤未经验证的评论后有足够的数据。为此,让我们看看不同值(即 True 和 False)的验证列有多少记录。为此,我们将使用 pandas 的内置 value_counts()函数,如下所示:
review_df["verified"].value_counts()
这将会返回
True 332504 False 164915 Name: verified, dtype: int64
这是个好消息。看起来我们从验证购买者那里得到的数据比未验证用户的数据要多。让我们创建一个名为 verified_df 的新 DataFrame,其中只包含验证过的评论:
verified_df = review_df.loc[review_df["verified"], :]
接下来,我们将评估整体列中每个不同评分的评论数量:
verified_df["overall"].value_counts()
这将会给出
5 222335 4 54878 3 27973 1 15200 2 12118 Name: overall, dtype: int64
这是一个有趣的发现。通常,我们希望对每种不同的评分有相等数量的数据。但这在现实世界中从来都不是这样的。例如,在这里,我们有比 4 星评价多四倍的 5 星评价。这被称为类别不平衡。现实世界的数据通常是嘈杂的、不平衡的和肮脏的。当我们进一步研究数据时,我们将看到这些特征。在开发我们的模型时,我们将回到数据中类别不平衡的问题。
Sentiment analysis 被设计为一个分类问题。给定评论(例如,作为单词序列),模型预测一组离散类别中的一个类别。我们将专注于两个类别:积极或消极。我们将假设 5 或 4 星表示积极情感,而 3、2 或 1 星表示消极情感。精明的问题形式化,比如减少类别数量,可以使分类任务变得更容易。为此,我们可以使用方便的内置 pandas 函数 map()。map() 接受一个字典,其中键表示当前值,值表示当前值需要映射到的值:
verified_df["label"]=verified_df["overall"].map({5:1, 4:1, 3:0, 2:0, 1:0})
现在让我们在转换后检查每个类别的实例数量
verified_df["label"].value_counts()
将返回
1 277213 0 55291 Name: label, dtype: int64
积极样本约占 83%,消极样本约占 17%。这在样本数量上存在明显的不一致。我们简单的数据探索的最后一步是确保数据没有顺序。为了对数据进行洗牌,我们将使用 pandas 的 sample() 函数。sample() 技术上是用于从大数据集中抽样一小部分数据的。但通过设置 frac=1.0 和固定的随机种子,我们可以以随机的方式获取全部数据集的洗牌:
verified_df = verified_df.sample(frac=1.0, random_state=random_seed)
最后,我们将输入和标签分别分开成两个变量,因为这将使下一步的处理更容易:
inputs, labels = verified_df["reviewText"], verified_df["label"]
接下来,我们将专注于一项关键任务,这将最终改善进入模型的数据的质量:清理和预处理文本。在这里,我们将专注于执行以下子任务。您将在接下来的讨论中了解每个子任务的更多细节:
- 将单词的大小写转换为小写。
- 处理单词的缩写形式(例如,“aren’t”、“you’ll”等)。
- 将文本标记为单词(称为 分词)。
- 移除不相关的文本,如数字、标点和停用词。停用词是文本语料库中频繁出现但对于大多数自然语言处理任务来说其存在的价值不足以证明的单词(例如,“and”、“the”、“am”、“are”、“it”、“he”、“she”等)。
- Lemmatize words. 词形还原是将单词转换为其基本形式的过程(例如,将复数名词转换为单数名词,将过去式动词转换为现在式动词)。
要执行大多数这些任务,我们将依赖于一个著名且广为人知的用于文本处理的 Python 库,称为 NLTK(自然语言工具包)。如果您已设置开发环境,应该已安装 NLTK 库。但我们的工作还没有完成。为了执行一些子任务,我们需要下载 NLTK 提供的几个外部资源:
- averaged_perceptron_tagger—用于识别词性
- wordnet 和 omw-1.4-_ 将被用于词形还原(即,将单词转换为其基本形式)
- stopwords—提供各种语言的停用词列表
- punkt—用于将文本标记为较小的组件(例如,单词、句子等)
首先让我们这样做:
import nltk nltk.download('averaged_perceptron_tagger', download_dir='nltk') nltk.download('wordnet', download_dir='nltk') nltk.download('omw-1.4', download_dir='nltk') nltk.download('stopwords', download_dir='nltk') nltk.download('punkt', download_dir='nltk') nltk.data.path.append(os.path.abspath('nltk'))
现在我们可以继续我们的项目了。为了理解这里列出的各种预处理步骤,我们将放大一个单独的评价,它只是一个 Python 字符串(即字符序列)。让我们将这个单独的评价称为 doc。
首先,我们可以通过在字符串上调用 lower()函数将字符串转换为小写。lower()是 Python 中的一个内置函数,可用于将给定字符串中的字符转换为小写字符:
doc = doc.lower()
接下来,如果存在"n’t",我们将将其扩展为"not":
import re doc = re.sub(pattern=r"\w+n\'t ", repl="not ", string=doc)
为了实现这一点,我们将使用正则表达式。正则表达式使我们能够匹配任意模式并以各种方式操纵它们。Python 有一个用于处理正则表达式的内置库,称为 re。在这里,re.sub()将用作 repl 参数(即“not ”)中的字符串替换符合某个模式的单词(即任何字母字符序列后跟“n’t;例如,“don’t”,“can’t”),并将它们替换为字符串 doc 中的字符串。例如,“won’t”将被替换为“not”。我们不关心前缀“will”,因为在稍后我们将执行的停用词移除过程中它将被移除。如果你感兴趣,可以在www.rexegg.com/regex-quickstart.xhtml
了解更多关于正则表达式语法的信息。
我们将删除如’ll、're、'd 和’ve 等缩写形式。你可能会注意到,这将导致不太完整的词组,比如“wo”(即“won’t”变成“wo”+“not”);但我们可以放心地忽略它们。请注意,我们对“not”的缩写形式处理与其他缩写形式有所不同。这是因为与其他缩写形式不同,如果存在,“not”可以对评价实际传达的意义产生重要影响。我们稍后将再次讨论这个问题:
doc = re.sub(pattern=r"(?:\'ll |\'re |\'d |\'ve )", repl=" ", string=doc)
在这里,为了替换’ll、're、'd 和’ve 的缩写形式,我们再次使用正则表达式。在这里,r"(?:'ll|'re|'d|'ve)"是 Python 中的一个正则表达式,它主要识别出 doc 中的任何出现的’ll/'re/'d/'ve。然后我们将使用 re.sub()函数如前所述删除 doc 中的任何数字:
doc = re.sub(pattern=r"/d+", repl="", string=doc)
接下来的步骤中,我们将删除停用词和任何标点符号。如前所述,停用词是出现在文本中但对文本的含义几乎没有贡献的词。换句话说,即使文本中没有停用词,你仍然能够推断出所说内容的意义。NLTK 库提供了一个停用词列表,因此我们不必自己编写停用词:
from nltk.corpus import stopwords from nltk import word_tokenize import string EN_STOPWORDS = set(stopwords.words('english')) - {'not', 'no'} (doc) if w not in EN_STOPWORDS and w not in string.punctuation]
要访问停用词,您只需调用 nltk.corpus 中的 stopwords 并调用 stopwords.words(‘english’)。这将返回一个列表。如果您查看停用词列表中的单词,您会发现几乎所有常见单词(例如,“I”,“you”,“a”,“the”,“am”,“are”等),这些单词在阅读文本时都会遇到。但正如我们之前强调的,词“not”是一个特殊的词,特别是在情感分析的背景下。诸如“no”和“not”之类的单词的存在可以完全改变我们情况下文本的含义。
还要注意一下函数 word_tokenize()的使用。这是一种特殊的处理步骤,称为标记化。在这里,将字符串传递给 word_tokenize()会返回一个列表,其中每个单词都是一个元素。对于像英语这样的语言,单词是由空格字符或句号分隔的,单词标记化可能看起来非常微不足道。但是在其他语言(例如,日语)中,标记之间的分隔并不明显,这可能是一个复杂的任务。
不要让停用词愚弄你!
如果您查看大多数停用词列表,您会发现单词“no”和“not”被视为停用词,因为它们是在文本语料库中常见的单词。但是,对于我们的情感分析任务,这些单词在改变评价的含义(以及可能的标签)方面起着重要作用。评价“这是一个很棒的视频游戏”的含义与“这不是一个很棒的视频游戏”或“这个游戏不好”相反。因此,我们特别从停用词列表中删除单词“no”和“not”。
接下来,我们有另一种称为词形还原的处理方法。词形还原将给定的单词截断/变形为基本形式,例如将复数名词转换为单数名词或将过去时动词转换为现在时,等等。这可以通过 NLTK 包中附带的一个词形还原器对象轻松完成:
lemmatizer = WordNetLemmatizer()
在这里,我们正在下载 WordNetLemmatizer。WordNetLemmatizer 是建立在著名的 WordNet 数据库上的词形还原器。如果您还没有听说过 WordNet,那么它是一个著名的词汇数据库(以网络/图形的形式),您可以将其用于信息检索、机器翻译、文本摘要等任务。WordNet 有多种大小和口味(例如,多语言 WordNet,非英语 WordNet 等)。您可以在线探索 WordNet 并浏览数据库wordnet.princeton.edu/
。
pos_tags = nltk.pos_tag(tokens) clean_text = [ lemmatizer.lemmatize(w, pos=p[0].lower()) \ if p[0]=='N' or p[0]=='V' else w \ for (w, p) in pos_tags ]
通过调用 lemmatizer.lemmatize()函数,您可以将任何给定的单词转换为其基本形式(如果尚未处于基本形式)。但是在调用函数时,您需要传递一个重要的参数,称为 pos。pos 是该单词的词性标签(part-of-speech tag)的缩写。词性标注是一项特殊的自然语言处理任务,任务是从给定的一组离散的词性标签中将给定的单词分类到一个词性标签中。以下是一些词性标签的示例:
- DT—限定词(例如,a,the)
- JJ—形容词(例如,beautiful,delicious)
- NN—名词,单数或质量(例如,person,dog)
- NNS — 名词,复数(例如,people,dogs)
- NNP — 专有名词,单数(例如,我,他,她)
- NNPS — 专有名词,复数(例如,we,they)
- VB — 动词,基本形式(例如,go,eat,walk)
- VBD — 动词,过去时(例如,went,ate,walked)
- VBG — 动词,动名词或现在分词(例如,going,eating,walking)
- VBN — 动词,过去分词(例如,gone,eaten,walked)
- VBP — 动词,第三人称单数现在时
- VBZ — 动词,第三人称单数现在时
您可以在 mng.bz/mO1W
找到完整的词性标签列表。值得注意的是标签是如何组织的。您可以看到,如果只考虑标签的前两个字符,您会得到一个更广泛的类别集(例如,NN,VB),其中所有名词都将被分类为 NN,动词将被分类为 VB,以此类推。我们将利用这一特性来简化我们的生活。
回到我们的代码:让我们看看我们如何使用词性标签来将词形还原。在将词形还原为词时,您必须传递该词的词性标签。这一点很重要,因为不同类型的词的词形还原逻辑是不同的。我们首先会得到一个列表,其中包含 tokens(由分词过程返回)中的单词的(,)元素。然后,我们遍历 pos_tags 列表并调用 lemmatizer.lemmatize() 函数,传入单词和词性标签。我们只会将动词和名词进行词形还原,以节省计算时间。
关于 WordNet 的更多信息
WordNet 是一个以互联网络形式存在的词汇数据库(有时称为词汇本体论)。这些连接基于两个词的相似程度。例如,单词“car”和“automobile”的距离较近,而“dog”和“volcano”则相距甚远。
WordNet 中的单词被分组为同义词集(简称为 synsets)。一个同义词集包含共享共同含义的单词(例如,dog,cat,hamster)。每个单词可以属于一个或多个同义词集。每个同义词集都有词条,这些词条是该同义词集中的单词。
向上一层,词集之间存在关系。有四种不同的关系:
- 超类词 — 超类词集是比给定词集更一般的词集。例如,“动物”是“宠物”的超类词集。
- 下义词 — 下义词集是比给定词集更具体的词集。例如,“car”是“vehicle”词集的下义词集。
- 部分词 — 部分词集是给定词集的一部分(是-部分-关系)。例如,“engine”是“car”词集的部分词集。
- 成员词 — 成员词集是给定词集所构成的词集(是-构成-关系)。例如,“leaf”是“plant”词集的成员词集。
由于这种互联词集的组织,使用 WordNet 还可以测量两个词之间的距离。相似的词将具有较小的距离,而不同的词将具有较大的距离。
您可以通过从 nltk.corpus 导入 WordNet 来在 NLTK 中尝试这些想法。有关更多信息,请参阅 www.nltk.org/howto/wordnet.xhtml
。
这结束了我们正在合并的一系列步骤,以构建我们文本的预处理工作流程。我们将这些步骤封装在一个名为 clean_ text() 的函数中,如下所示。
列表 9.2 数据集中评论的预处理逻辑
def clean_text(doc): """ A function that cleans a given document (i.e. a text string)""" doc = doc.lower() ❶ doc = doc.replace("n\'t ", ' not ') ❷ doc = re.sub(r"(?:\'ll |\'re |\'d |\'ve )", " ", doc) ❸ doc = re.sub(r"/d+","", doc) ❹ tokens = [ w for w in word_tokenize(doc) if w not in EN_STOPWORDS and w not in ➥ string.punctuation ] ❺ pos_tags = nltk.pos_tag(tokens) ❻ clean_text = [ lemmatizer.lemmatize(w, pos=p[0].lower()) \ ❼ if p[0]=='N' or p[0]=='V' else w \ ❼ for (w, p) in pos_tags ❼ ] return clean_text
❶ 转换为小写。
❷ 将缩写形式 n’t 扩展为“not”。
❸ 删除缩写形式,如’ll,’re,’d,’ve,因为它们对此任务没有太多价值。
❹ 删除数字。
❺ 将文本分解为标记(或单词);在此过程中,忽略结果中的停用词。
❻ 获取字符串中标记的词性标签。
❼ 对于每个标记,获取其词性标签;如果是名词(N)或动词(V),则进行词形还原,否则保留原始形式。
你可以通过在示例文本上调用函数来检查函数中完成的处理。
sample_doc = 'She sells seashells by the seashore.' print("Before clean: {}".format(sample_doc)) print("After clean: {}".format(clean_text(sample_doc)))
返回
Before clean: She sells seashells by the seashore. After clean: [“sell”, “seashell”, “seashore”]
我们将利用此函数以及 panda 的 apply 函数,在我们的数据 DataFrame 中的每一行文本上应用此处理管道:
inputs = inputs.apply(lambda x: clean_text(x))
你可能想离开电脑一会儿去喝咖啡或看看朋友。运行这个一行代码可能需要接近一个小时。最终结果看起来像表 9.2。
表 9.2 原始文本与预处理文本
原始文本 | 清理文本(标记化) |
在 Wii 和 GameCube 上完美运行。与兼容性或内存丢失无关的问题。 | [‘work’, ‘perfectly’, ‘wii’, ‘gamecube’, ‘no’, ‘issue’, ‘compatibility’, ‘loss’, ‘memory’] |
喜欢这款游戏,而且其他附带的收藏品做得很好。面具很大,几乎适合我的脸,所以令人印象深刻。 | [‘loved’, ‘game’, ‘collectible’, ‘come’, ‘well’, ‘make’, ‘mask’, ‘big’, ‘almost’, ‘fit’, ‘face’, ‘impressive’] |
这是一个可以的游戏。说实话,我对这类游戏很差劲,对我来说这很困难!我总是死去,这让我沮丧。也许如果我更有技巧,我会更喜欢这款游戏! | [“'s”, ‘okay’, ‘game’, ‘honest’, ‘bad’, ‘type’, ‘game’, ‘–’, “'s”, ‘difficult’, ‘always’, ‘die’, ‘depresses’, ‘maybe’, ‘skill’, ‘would’, ‘enjoy’, ‘game’] |
产品描述很好。 | [‘excellent’, ‘product’, ‘describe’] |
细节水平很高;你可以感受到这款游戏对汽车的热爱。 | [‘level’, ‘detail’, ‘great’, ‘feel’, ‘love’, ‘car’, ‘game’] |
我不能玩这个游戏。 | [‘not’, ‘play’, ‘game’] |
最后,为了避免因运行次数过多而过度依赖咖啡或打扰朋友,我们将数据保存到磁盘上:
inputs.to_pickle(os.path.join('data','sentiment_inputs.pkl')) labels.to_pickle(os.path.join('data','sentiment_labels.pkl'))
现在,我们将定义一个数据管道,将数据转换为模型理解的格式,并用于训练和评估模型。
练习 1
给定字符串 s,“i-purchased-this-game-for-99-i-want-a-refund”,你想用空格替换短横线“-”,然后仅对文本中的动词进行词形还原。你会如何做?
准备模型的文本
你有一个干净的数据集,其中的文本已经剥离了任何不必要或不合理的语言复杂性,以解决我们正在解决的问题。此外,二元标签是根据每条评论给出的星级生成的。在继续进行模型训练和评估之前,我们必须对数据集进行进一步处理。具体来说,我们将创建三个数据子集——训练、验证和测试——用于训练和评估模型。接下来,我们将查看数据集的两个重要特征:词汇量和示例中序列长度(即单词数量)的分布。最后,你将把单词转换为数字(或数字 ID),因为机器学习模型不理解字符串而理解数字。
在本节中,我们将进一步准备数据以供模型使用。现在,我们有一个非常好的处理步骤布局,可以从杂乱、不一致的评论转变为保留评论语义的简单、一致的文本字符串。但是,我们还没有解决问题的关键!也就是说,机器学习模型理解的是数值数据,而不是文本数据。如果你直接呈现字符串“not a great game”,对模型来说没有任何意义。我们必须进一步完善我们的数据,以便最终得到一系列数字,而不是单词序列。在准备好数据供模型使用的过程中,我们将执行以下子任务:
- 在预处理后检查词汇表/单词频率的大小。这将稍后用作模型的超参数。
- 检查序列长度的摘要统计信息(均值、中位数和标准偏差)。这将稍后用作模型的超参数。
- 创建一个字典,将每个唯一的单词映射到一个唯一的 ID(我们将其称为分词器)。
分割训练/验证和测试数据
警告!在执行这些任务时,你可能会无意中在我们的模型中创建渗漏数据泄露。我们必须确保我们只使用训练数据集来执行这些任务,并将验证和测试数据保持分开。因此,我们的第一个目标应该是分离训练/验证/测试数据。
潜在的自然语言处理数据泄漏
你可能会想:“太好了!我只需加载处理过的文本语料库并在上面执行分析或任务。”不要那么快!这是错误的做法。在执行任何特定于数据的处理/分析之前,如计算词汇量或开发分词器,你必须将数据分割成训练/验证和测试集,然后在训练数据上执行此处理/分析。
验证数据的目的是作为选择超参数的指南,并确定何时停止训练。测试数据集是你的基准,用于评估模型在实际世界中的表现。考虑到验证/测试数据的用途性质,它们不应该成为你分析的一部分,而只应该用于评估性能。在你的分析中使用验证/测试数据来开发模型会给你一个不公平的优势,并导致所谓的数据泄漏。数据泄漏是指直接或间接提供访问你在模型上评估的示例。如果验证/测试数据在我们进行任何分析时被使用,我们在评估阶段之前提供了对这些数据集的访问。带有数据泄漏的模型可能导致在实际世界中性能不佳。
我们知道我们有一个不均衡的数据集。尽管有一个不均衡的数据集,我们必须确保我们的模型能够很好地识别出积极和消极的评论。这意味着我们将要评估的数据集需要是平衡的。为了实现这一点,我们将做以下操作:
- 创建平衡的(即正负样本数量相等的)验证集和测试集
- 将剩余的数据点分配给训练集
图 9.1 描述了这个过程。
图 9.1 分割训练/验证/测试数据的过程
现在我们将看看如何在 Python 中实现这一点。首先,我们分别识别对应于正标签和负标签的索引:
neg_indices = pd.Series(labels.loc[(labels==0)].index) pos_indices = pd.Series(labels.loc[(labels==1)].index)
分层抽样:不均衡数据集的替代方法
你对验证和测试集的设计将决定你如何定义性能指标来评估训练模型。如果你创建了同样平衡的验证/测试集,那么你可以安全地使用准确率作为评估训练模型的指标。这就是我们将要做的:创建平衡的验证/测试数据集,然后使用准确率作为评估模型的指标。但你可能并不总是那么幸运。有时候可能会出现少数类别非常恐怖,你无法承担创建平衡数据集的情况。
在这种情况下,你可以使用分层抽样。分层抽样创建单独的数据集,大致保持完整数据集中原始类别比例。在这种情况下,你必须谨慎选择你的度量标准,因为标准准确率不能再被信任。例如,如果你关心以高准确率识别正样本而牺牲一些误报率,那么你应该使用召回率(或 F1 分数,对召回率给予更高的权重)作为性能指标。
接下来,我们将定义我们的验证/测试集的大小作为 train_fraction 的函数(一个用户定义的参数,确定留给训练集的数据量)。我们将使用 train_fraction 的默认值 0.8:
n_valid = int( min([len(neg_indices), len(pos_indices)]) * ((1-train_fraction)/2.0) )
它可能看起来像是一个复杂的计算,但事实上,它是一个简单的计算。我们将使用有效分数作为留给训练数据的数据分数的一半(另一半用于测试集)。最后,为了将分数值转换为实际样本数,我们将分数乘以正样本和负样本计数中较小的那个。通过这种方式,我们确保少数类在数据拆分过程中保持为焦点。我们保持验证集和测试集相等。所以
n_test = n_valid
接下来,我们为每种标签类型(正和负)定义三组索引(用于训练/验证/测试数据集)。我们将创建一个漏斗过程来将数据点分配到不同的数据集中。首先,我们执行以下操作:
- 从负索引(neg_ test_indices)中随机抽样 n_test 个索引。
- 然后从剩余的索引中随机抽样 n_valid 个索引(neg_ valid_inds)。
- 剩余的索引被保留为训练实例(neg_train_inds)。
然后,对正索引重复相同的过程,以创建用于训练/验证/测试数据集的三个索引集:
neg_test_inds = neg_indices.sample(n=n_test, random_state=random_seed) neg_valid_inds = neg_indices.loc[ ~neg_indices.isin(neg_test_inds) ].sample(n=n_test, random_state=random_seed) neg_train_inds = neg_indices.loc[ ~neg_indices.isin(neg_test_inds.tolist()+neg_valid_inds.tolist()) ] pos_test_inds = pos_indices.sample(n=n_test, random_state=random_seed ) pos_valid_inds = pos_indices.loc[ ~pos_indices.isin(pos_test_inds) ].sample(n=n_test, random_state=random_seed) pos_train_inds = pos_indices.loc[ ~pos_indices.isin(pos_test_inds.tolist()+pos_valid_inds.tolist()) ]
使用负索引和正索引来切片输入和标签,现在是时候创建实际的数据集了:
tr_x = inputs.loc[ neg_train_inds.tolist() + pos_train_inds.tolist() ].sample(frac=1.0, random_state=random_seed) tr_y = labels.loc[ neg_train_inds.tolist() + pos_train_inds.tolist() ].sample(frac=1.0, random_state=random_seed) v_x = inputs.loc[ neg_valid_inds.tolist() + pos_valid_inds.tolist() ].sample(frac=1.0, random_state=random_seed) v_y = labels.loc[ neg_valid_inds.tolist() + pos_valid_inds.tolist() ].sample(frac=1.0, random_state=random_seed) ts_x = inputs.loc[ neg_test_inds.tolist() + pos_test_inds.tolist() ].sample(frac=1.0, random_state=random_seed) ts_y = labels.loc[ neg_test_inds.tolist() + pos_test_inds.tolist() ].sample(frac=1.0, random_state=random_seed)
在这里,(tr_x,tr_y),(v_x,v_y)和(ts_x,ts_y)分别代表训练,验证和测试数据集。在这里,以 _x 结尾的数据集来自输入,以 _y 结尾的数据集来自标签。最后,我们可以将我们讨论的逻辑包装在一个单独的函数中,如下面的清单所示。
清单 9.3 拆分训练/验证/测试数据集
def train_valid_test_split(inputs, labels, train_fraction=0.8): """ Splits a given dataset into three sets; training, validation and test """ neg_indices = pd.Series(labels.loc[(labels==0)].index) ❶ pos_indices = pd.Series(labels.loc[(labels==1)].index) ❶ n_valid = int(min([len(neg_indices), len(pos_indices)]) * ((1-train_fraction)/2.0)) ❷ n_test = n_valid ❷ neg_test_inds = neg_indices.sample(n=n_test, random_state=random_seed) ❸ neg_valid_inds = neg_indices.loc[~neg_indices.isin( neg_test_inds)].sample(n=n_test, random_state=random_seed) ❹ neg_train_inds = neg_indices.loc[~neg_indices.isin( neg_test_inds.tolist()+neg_valid_inds.tolist())] ❺ pos_test_inds = pos_indices.sample(n=n_test) ❻ pos_valid_inds = pos_indices.loc[ ~pos_indices.isin(pos_test_inds)].sample(n=n_test) ❻ pos_train_inds = pos_indices.loc[ ~pos_indices.isin(pos_test_inds.tolist()+pos_valid_inds.tolist()) ❻ ] tr_x = inputs.loc[neg_train_inds.tolist() + ➥ pos_train_inds.tolist()].sample(frac=1.0, random_state=random_seed) ❼ tr_y = labels.loc[neg_train_inds.tolist() + ➥ pos_train_inds.tolist()].sample(frac=1.0, random_state=random_seed) ❼ v_x = inputs.loc[neg_valid_inds.tolist() + ➥ pos_valid_inds.tolist()].sample(frac=1.0, random_state=random_seed) ❼ v_y = labels.loc[neg_valid_inds.tolist() + ➥ pos_valid_inds.tolist()].sample(frac=1.0, random_state=random_seed) ❼ ts_x = inputs.loc[neg_test_inds.tolist() + ➥ pos_test_inds.tolist()].sample(frac=1.0, random_state=random_seed) ❼ ts_y = labels.loc[neg_test_inds.tolist() + ➥ pos_test_inds.tolist()].sample(frac=1.0, random_state=random_seed) ❼ print('Training data: {}'.format(len(tr_x))) print('Validation data: {}'.format(len(v_x))) print('Test data: {}'.format(len(ts_x))) return (tr_x, tr_y), (v_x, v_y), (ts_x, ts_y)
❶ 将负数据点和正数据点的索引分开。
❷ 计算有效和测试数据集的大小(针对少数类)。
❸ 获取进入测试集的少数类索引。
❹ 获取进入验证集的少数类索引。
❺ 少数类中其余的索引属于训练集。
❻ 计算用于测试/验证/训练集的多数类索引
❼ 使用创建的索引获取训练/验证/测试数据集。
然后只需调用函数来生成训练/验证/测试数据:
(tr_x, tr_y), (v_x, v_y), (ts_x, ts_y) = train_valid_test_split(data, labels)
接下来,我们将进一步检查语料库,以探索与我们在训练集中拥有的评论相关的词汇量和序列长度。稍后这些将作为模型的超参数。
9.2.2 分析词汇
词汇量是模型的重要超参数。因此,我们必须找到最佳的词汇量,以便能够捕获足够的信息以准确解决任务。为此,我们首先会创建一个长列表,其中每个元素都是一个单词:
data_list = [w for doc in tr_x for w in doc]
此行遍历 tr_x 中的每个文档,然后遍历该文档中的每个单词(w),并创建一个展平的序列,其中包含所有文档中存在的单词。由于我们有一个 Python 列表,其中每个元素都是一个单词,我们可以利用 Python 的内置 Counter 对象来获取一个字典,其中每个单词都映射到一个键,该值表示该单词在语料库中的频率。请注意,我们仅在此分析中使用训练数据集,以避免数据泄漏:
from collections import Counter cnt = Counter(data_list)
有了我们的单词频率字典,让我们来看看我们语料库中一些最常见的单词:
freq_df = pd.Series( list(cnt.values()), index=list(cnt.keys()) ).sort_values(ascending=False) print(freq_df.head(n=10))
这将返回以下结果,您可以看到出现在文本中的最常见的单词。从结果来看,这是合理的。毫不奇怪,像 “game”、“like” 和 “play” 这样的词在频率上优先于其他单词:
game 407818 not 248244 play 128235 's 127844 get 108819 like 100279 great 97041 one 89948 good 77212 time 63450 dtype: int64
更进一步,让我们对文本语料库进行摘要统计。通过这样做,我们可以看到单词的平均频率、标准偏差、最小值、最大值等:
print(freq_df.describe())
这将提供有关单词频率的一些重要基本统计信息。例如,从中我们可以说单词的平均频率是约为 ~76,标准偏差为 ~1754:
count 133714.000000 mean 75.768207 std 1754.508881 min 1.000000 25% 1.000000 50% 1.000000 75% 4.000000 max 408819.000000 dtype: float64
然后,我们将创建一个名为 n_vocab 的变量,该变量将保存在语料库中至少出现 25 次的单词的词汇量的大小。您应该得到接近 11,800 的 n_vocab 值:
n_vocab = (freq_df >= 25).sum()
9.2.3 分析序列长度
请记住,tr_x 是一个 pandas Series 对象,其中每一行都包含一条评论,每个评论都是一个单词列表。当数据处于这种格式时,我们可以使用 pd.Series.str.len() 函数来获取每行的长度(或每条评论中的单词数):
seq_length_ser = tr_x.str.len()
在计算基本统计量时,我们将采取一些不同的方法。我们的目标是找到三个序列长度的区间,以便将它们分为短、中、长序列。我们将在定义 TensorFlow 数据流水线时使用这些桶边界。为此,我们将首先确定截断点(或分位数),以去除数据的前 10% 和后 10%。这是因为顶部和底部切片都充满了异常值,正如你所知,它们会使诸如均值之类的统计量产生偏差。在 pandas 中,您可以使用 quantile() 函数获取分位数,其中您传递一个分数值来指示您感兴趣的分位数:
p_10 = seq_length_ser.quantile(0.1) p_90 = seq_length_ser.quantile(0.9)
然后,您只需在这些分位数之间过滤数据。接下来,我们使用 describe 函数,其中包含 33% 分位数和 66% 分位数,因为我们希望将其分为三个不同的类别:
seq_length_ser[(seq_length_ser >= p_10) & (seq_length_ser < p_90)].describe(percentiles=[0.33, 0.66])
如果运行此代码,您将得到以下输出:
count 278675.000000 mean 15.422596 std 16.258732 min 1.000000 33% 5.000000 50% 10.000000 66% 16.000000 max 74.000000 Name: reviewText, dtype: float64
根据结果,我们将使用 5 和 15 作为我们的桶边界。换句话说,评论按照以下逻辑进行分类:
- 长度在 [0, 5) 的评论为短评论。
- 长度在 [5, 15) 的评论为中等评论。
- 长度在 [15, inf) 的评论为长评论。
最后两个小节总结了我们分析以找到词汇表大小和序列长度的过程。这里呈现的输出提供了所有信息,以便以有原则的态度选择我们的超参数。
9.2.4 使用 Keras 将文本转换为单词,然后转换为数字
我们有一段干净的、经过处理的文本语料库,以及我们稍后将使用的词汇表大小和序列长度参数。我们的下一个任务是将文本转换为数字。将文本转换为数字有两个标准步骤:
- 将文本拆分为标记(例如,字符/单词/句子)。
- 创建一个将每个唯一标记映射到唯一 ID 的字典。
例如,如果您有以下句子
the cat sat on the mat
我们将首先将其标记化为单词,得到
[the, cat, sat, on, the, mat]
并且有字典
{the: 1, cat: 2, sat: 3, on: 4, mat: 5}
然后,您可以创建以下序列来表示原始文本:
[1,2,3,4,1,5]
Keras Tokenizer 对象支持这种功能。它接受一段文本语料库,使用一些用户定义的参数进行标记化,自动构建词典,并将其保存为状态。这样,您可以使用 Tokenizer 将任意文本转换为数字,次数不限。让我们看看如何使用 Keras Tokenizer 完成这个过程:
from tensorflow.keras.preprocessing.text import Tokenizer tokenizer = Tokenizer( num_words=n_vocab, oov_token='unk', lower=False, filters='!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n', split=' ', char_level=False )
您可以看到 Tokenizer 传递了几个参数。让我们稍微详细地看看这些参数:
- num_words——这定义了词汇表大小,以限制字典的大小。如果 num_words 设置为 1,000,则会考虑语料库中最常见的 1,000 个单词,并为它们分配唯一的 ID。
- oov_token——这个参数处理落在定义的词汇表大小之外的词。出现在语料库中但未包含在最常见的 num_words 个词中的单词将被替换为此标记。
- lower——这确定是否对文本进行大小写转换。由于我们已经做过了,我们将其设置为 False。
- filter——这定义了在标记化文本之前要删除的任何字符。
- split——这是用于标记化文本的分隔符字符。我们希望单词是标记,因此我们将使用空格,因为单词通常用空格分隔。
- char_level——这指示是否执行字符级标记化(即,每个字符都是一个标记)。
在我们继续之前,让我们回顾一下我们的数据在当前状态下是什么样子。请记住,我们有
- 清理后的数据
- 预处理数据
- 将每个评论拆分为单独的单词
在这个过程结束时,我们的数据如下所示。首先,我们有输入,它是一个 pd.Series 对象,包含一系列干净的单词列表。文本前面的数字是该记录在 pd.Series 对象中的索引:
122143 [work, perfectly, wii, gamecube, issue, compat... 444818 [loved, game, collectible, come, well, make, m... 79331 ['s, okay, game, honest, bad, type, game, --, ... 97250 [excellent, product, describe] 324411 [level, detail, great, feel, love, car, game] ... 34481 [not, actually, believe, write, review, produc... 258474 [good, game, us, like, movie, franchise, hard,... 466203 [fun, first, person, shooter, nice, combinatio... 414288 [love, amiibo, classic, color] 162670 [fan, halo, series, start, enjoy, game, overal... Name: reviewText, dtype: object
接下来,我们有标签,其中每个标签都是一个二进制标签,用于指示评论是正面评论还是负面评论:
122143 1 444818 1 79331 0 97250 1 324411 1 ... 34481 1 258474 1 466203 1 414288 1 162670 0 Name: label, dtype: int64
从某种意义上说,标记文本的第一步已经完成了。如果已经完成了这一步,那么 Keras Tokenizer 足够智能,会跳过这一步。要构建 Tokenizer 的字典,可以调用 tf.keras.preprocessing.text.Tokenizer.fit_on_texts() 函数,如下所示:
tokenizer.fit_on_texts(tr_x.tolist())
fit_on_texts() 函数接受一个字符串列表,其中每个字符串是你正在处理的单个实体(例如,一个句子、一个评论、一个段落等),或者是一个标记列表的列表,其中标记可以是一个单词、一个字符,甚至是一个句子。当你将 Tokenizer 拟合到某些文本时,你可以检查一些内部状态变量。你可以使用以下方式检查单词到 ID 的映射:
tokenizer.word_index[“game”]
这将返回
2
你还可以使用以下方式检查 ID 到单词的映射(即将单词映射到 ID 的反向操作):
tokenizer.index_word[4]
这将返回
“play”
要将文本语料库转换为索引序列,你可以使用 texts_to_sequences() 函数。它接受一个标记列表的列表,并返回一个 ID 列表的列表:
tr_x = tokenizer.texts_to_sequences(tr_x.tolist()) v_x = tokenizer.texts_to_sequences(v_x.tolist()) ts_x = tokenizer.texts_to_sequences(ts_x.tolist())
TensorFlow 实战(四)(2)https://developer.aliyun.com/article/1522805