Python-真实世界的数据科学-八-

48 阅读1小时+

Python 真实世界的数据科学(八)

原文:Python: Real-World Data Science

协议:CC BY-NC-SA 4.0

二十七、将朴素贝叶斯用于社交媒体洞察

基于文本的数据集包含许多信息,无论它们是书籍,历史文档,社交媒体,电子邮件还是我们通过书面形式进行交流的任何其他方式。 从基于文本的数据集中提取特征并将其用于分类是一个难题。 但是,有一些常见的文本挖掘模式。

我们使用朴素贝叶斯算法(Naive Bayes algorithm)来查看社交媒体中的歧义术语,该算法是一种功能强大且令人惊讶的简单算法。 朴素贝叶斯采取了一些捷径来正确计算分类的概率,因此名称中的术语朴素。 它也可以很容易地扩展到其他类型的数据集,而不依赖于数字特征。 本章中的模型是文本挖掘研究的基线,因为该过程对于各种数据集都可以很好地运行。

我们将在本章介绍以下主题:

  • 从社交网络 API 下载数据
  • 文字变形金刚
  • 朴素贝叶斯分类器
  • 使用 JSON 保存和加载数据集
  • NLTK 库,用于从文本中提取特征
  • 评估的 F 量度

消除歧义

文本通常为,称为非结构化格式。 那里有很多信息,但那里只是; 没有标题,没有要求的格式,宽松的语法以及等其他问题,使得无法轻松地从文本中提取信息。 数据之间也是高度关联的,有很多提及和交叉引用-只是格式不便于我们提取!

我们可以将书籍中存储的信息与大型数据库中存储的信息进行比较,以了解两者之间的区别。 书中有人物,主题,地点和许多信息。 但是,需要阅读本书,更重要的是,要对其进行解释才能获得此信息。 数据库位于您的服务器上,具有列名和数据类型。 所有信息都在那里,并且所需的解释水平很低。 有关数据的信息(例如其类型或含义)称为元数据,而文本则缺少该信息。 一本书还包含一些表格形式的内容和索引元数据,但程度明显低于数据库。

问题之一是术语消除歧义。 当一个人使用银行这个词时,这是金融消息还是环境消息(例如河岸)? 在许多情况下,这种类型的歧义消除对于人类来说是很容易的(尽管仍然存在麻烦),但是对于计算机而言,消除歧义却要困难得多。

在本章中,我们将消除在 Twitter 流中使用 Python 一词的歧义。 Twitter 上的消息称为推文,并且限制为 140 个字符。 这意味着没有上下文的空间。 尽管通常使用井号来表示推文的主题,但可用的元数据很少。

当人们谈论 Python 时,他们可能在谈论以下事情:

  • 编程语言 Python
  • 经典喜剧团 Monty Python
  • 蛇蟒
  • 一双叫做 Python 的鞋子

可能还有许多其他东西叫做 Python。 我们实验的目的是仅通过一条推文的内容来提及 Python,并确定它是否在谈论编程语言。

从社交网络下载数据

我们将要从 Twitter 下载数据集,并使用它从有用的内容中清除垃圾邮件。 Twitter 提供了一个健壮的 API,用于从其服务器收集信息,该 API 对于小规模使用是免费的。 但是,如果您开始在商业环境中使用 Twitter 的数据,则需要注意一些条件。

首先,您需要注册一个 Twitter 帐户(免费)。 如果您还没有帐户,请访问这个页面并注册一个帐户。

接下来,您需要确保每分钟仅发出一定数量的请求。 目前,该限制为每小时 180 个请求。 确保不违反此限制可能很棘手,因此强烈建议您使用库与 Twitter 的 API 进行通信。

您将需要一个密钥来访问 Twitter 的数据。 转到这个页面并登录到您的帐户。

当您登录后,转到这个页面并单击创建新应用

为您的应用创建的名称和说明以及网站地址。 如果您没有要使用的网站,请插入一个占位符。 将此应用的回调 URL 字段保留为空白-我们将不需要它。 同意使用条款(如果需要),然后单击创建您的 Twitter 应用

保持结果网站保持打开状态–您需要此页面上的访问密钥。 接下来,我们需要一个图书馆与 Twitter 对话。 有很多选择。 我喜欢的一个简称为twitter,是官方 Twitter Python 库。

注意

如果要使用pip至安装软件包,则可以使用pip3 install twitter安装twitter。 如果您使用的是其他系统,请查看这个页面上的文档。

创建一个新的 IPython Notebook 以下载数据。 我们将在本章中出于不同的目的创建多个笔记本,因此最好创建一个文件夹来跟踪它们。 第一个笔记本ch6_get_twitter专用于下载新的 Twitter 数据。

首先,我们导入twitter库并设置我们的授权令牌。 消费者密钥消费者秘密将在 Twitter 应用页面的密钥和访问令牌选项卡上可用。 要获取访问令牌,您需要单击同一页面上的创建我的访问令牌按钮。 在以下代码中将密钥输入适当的位置:

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret, consumer_key, consumer_secret)

我们将从 Twitter 的search函数获得推文。 我们将创建一个使用我们的授权连接到twitter的阅读器,然后使用该阅读器执行搜索。 在笔记本中,我们设置将存储推文的文件名:

import os
output_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")

我们还需要json库来保存我们的推文:

import json

接下来,创建一个可以从 Twitter 读取的对象。 我们使用之前设置的授权对象创建该对象:

t = twitter.Twitter(auth=authorization)

然后,我们打开输出文件进行写入。 我们打开它进行追加,这使我们可以重新运行脚本以获得更多推文。 然后,我们使用 Twitter 连接对 Python 进行搜索。 我们只想要为数据集返回的状态。 该代码使用了 tweet,使用json库通过dumps函数创建字符串表示形式,然后将其写入文件中。 然后,它在 tweet 下创建一个空白行,以便我们可以轻松地区分一条 tweet 在文件中的开始和结束位置:

with open(output_filename, 'a') as output_file:
    search_results = t.search.tweets(q="python", count=100)['statuses']
    for tweet in search_results:
        if 'text' in tweet:
            output_file.write(json.dumps(tweet))
            output_file.write("\n\n")

在前面的循环中,我们还执行检查以查看推文中是否有文本。 并非 twitter 返回的所有对象都是实际的 tweet(有些是删除 tweet 的动作,另一些是删除 tweet 的动作)。 关键的区别在于将文本作为密钥,我们对其进行了测试。

运行此命令几分钟将导致 100 条推文添加到输出文件中。

注意

您可以继续运行此脚本以向数据集中添加更多推文,请记住,如果重新运行速度太快(即在 Twitter 获得新的推文返回之前),您可能会在输出文件中获得一些重复项。

加载和分类数据集

在收集了一组推文(我们的数据集)之后,需要标签进行分类。 我们将通过在 IPython Notebook 中设置表单来标记数据集,以允许我们输入标签。

我们以 JSON 格式存储的数据集接近。 JSON 是一种数据格式,它不要求太多结构,并且可以在 JavaScript 中直接读取(因此,其名称为 JavaScript Object Notation)。 JSON 定义了基本对象,例如数字,字符串,列表和字典,如果它们包含非数字数据,则使其成为存储数据集的一种很好的格式。 如果数据集是完全数值的,则可以使用基于矩阵的格式(如 NumPy)来节省空间和时间。

我们的数据集与实际 JSON 之间的关键区别在于,我们在 tweet 之间添加了新行。 这样做的原因是允许我们轻松添加新的 tweet(实际的 JSON 格式不允许这样做)。 我们的格式是 tweet 的 JSON 表示形式,其后是换行符,然后是下一个 tweet,依此类推。

为了解析它,我们可以使用json库,但是我们必须首先用换行符分割文件,以获取实际的 tweet 对象本身。

设置一个新的 IPython Notebook(我叫我的ch6_label_twitter)并输入数据集的文件名。 这与上一部分中保存数据的文件名相同。 我们还定义了用于保存标签的文件名。 代码如下:

import os
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")

如前所述,我们将使用json库,因此也要导入该库:

import json

我们创建一个列表,该列表将存储从文件中收到的推文:

tweets = []

然后,我们遍历文件中的每一行。 我们对没有信息的行不感兴趣(它们为我们分开了 tweet),因此请检查行的长度(减去任何空白字符)是否为零。 如果是,请忽略它并移至下一行。 否则,请使用json.loads(从字符串中加载 JSON 对象)加载推文,并将其添加到我们的推文列表中。 代码如下:

with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0:
            continue
        tweets.append(json.loads(line))

现在,我们对分类某项是否与我们相关感兴趣(在这种情况下,相关意味着指的是编程语言 Python )。 我们将使用 IPython Notebook 的功能来嵌入 HTML,并在 JavaScript 和 Python 之间进行对话,以创建推文查看器,从而使我们能够轻松,快速地将推文归为垃圾邮件。

该代码将向用户(您)显示一条新推文,并要求提供标签:是否相关? 然后它将存储输入并显示下一个要标记的推文。

首先,我们创建一个存储标签的列表。 无论给定的推文是否引用编程语言 Python,这些标签都将被存储,这将使我们的分类器能够学习如何区分含义。

我们还将检查是否已经有任何标签并加载它们。 如果您需要在贴标签的中途关闭笔记本计算机,这将很有帮助。 此代码将从停止的地方加载标签。 通常,最好考虑如何在中点保存此类任务。 没有什么比损失一个小时的工作更令人痛心的了,因为在保存标签之前计算机崩溃了! 代码如下:

labels = []
if os.path.exists(labels_filename):
    with open(labels_filename) as inf:
        labels = json.load(inf)

接下来,我们创建一个简单的函数,该函数将返回下一个需要标记的推文。 我们可以通过找到尚未标记的第一条推文来算出下一条推文。 代码如下:

def get_next_tweet():
    return tweet_sample[len(labels)]['text']

注意

我们实验的下一步是从用户(您!)收集信息,其中哪些推文是指 Python(编程语言),哪些不是。 到目前为止,在 IPython Notebook 中还没有一种简单明了的纯 Python 交互式反馈方式。 因此,我们将使用一些 JavaScript 和 HTML 从用户那里获得输入。

接下来,我们在 IPython Notebook 中创建一些 JavaScript 以运行我们的输入。 笔记本允许我们使用魔术功能将 HTML 和 JavaScript(以及其他功能)直接嵌入到笔记本本身中。 从顶部的以下行开始新的单元格:

%%javascript

此处的代码将使用 JavaScript,因此出现了花括号。 不用担心,我们将很快返回 Python。 请记住,以下代码必须与%%javascript魔术函数位于同一单元格中。

我们将在 JavaScript 中定义的第一个函数显示了从 IPython Notebooks 中的 JavaScript 与您的 Python 代码进行对话有多么容易。 如果调用此函数,它将在labels数组中添加标签(在python代码中)。 为此,我们将 IPython 内核加载为 JavaScript 对象,并为其提供 Python 命令来执行。 代码如下:

function set_label(label){
    var kernel = IPython.notebook.kernel;
    kernel.execute("labels.append(" + label + ")");
    load_next_tweet();
}

在该函数的结尾,我们调用load_next_tweet函数。 此功能加载要标记的下一条推文。 它以相同的原理运行; 我们加载 IPython 内核并为其执行命令(调用我们先前定义的get_next_tweet函数)。

但是,在中,我们想要得到结果。 这有点困难。 我们需要定义一个callback,这是一个在返回数据时调用的函数。 定义callback的格式超出了该模块的范围。 如果您对更高级的 JavaScript / Python 集成感兴趣,请查阅 IPython 文档。

代码如下:

function load_next_tweet(){
   var code_input = "get_next_tweet()";
   var kernel = IPython.notebook.kernel;
   var callbacks = { 'iopub' : {'output' : handle_output}};
   kernel.execute(code_input, callbacks, {silent:false});
}

回调函数称为handle_output,我们现在将对其进行定义。 当kernel.execute调用的 Python 函数返回值时,将调用此函数。 和以前一样,其完整格式不在本模块的范围之内。 但是,出于我们的目的,结果将作为 text / plain 类型的数据返回,我们将其提取并显示在要在下一个单元格中创建的表单的#tweet_text div中。 代码如下:

function handle_output(out){
   var res = out.content.data["text/plain"];
   $("div#tweet_text").html(res);
}

我们的表单将带有一个div,显示要标记的下一条推文,我们将为其提供ID #tweet_text。 我们还创建了一个文本框,使我们能够捕获按键(否则,Notebook 将捕获按键,而 JavaScript 不会执行任何操作)。 这使我们能够使用键盘设置10的标签,这比使用鼠标单击按钮要快-因为我们至少需要标记 100 条推文。

运行上一个单元格,将一些 JavaScript 嵌入到页面中,尽管结果部分中不会显示任何内容。

现在,我们将使用另一个魔术函数%%html。 毫不奇怪,此魔术功能使我们可以将 HTML 直接嵌入到 Notebook 中。 在新单元格中,从以下这一行开始:

%%html

对于此单元格,我们将使用 HTML 和一些 JavaScript 进行编码。 首先,定义div元素以存储要标记的当前 tweet。 我还添加了一些使用此表单的说明。 然后,创建#tweet_text div,该 div 将存储要标记的下一条推文的文本。 如前所述,我们需要创建一个文本框以捕获按键。 代码如下:

<div name="tweetbox">
    Instructions: Click in textbox. Enter a 1 if the tweet is relevant, enter 0 otherwise.<br>
Tweet: <div id="tweet_text" value="text"></div><br>
<input type=text id="capture"></input><br>
</div>

暂时不要运行单元!

我们创建用于捕获按键的 JavaScript。 在创建表单后,需要定义,因为在上述代码运行之前#tweet_text div 不存在。 我们使用 JQuery 库(IPython 已经在使用,因此我们不需要包含 JavaScript 文件)来添加一个在#capture上按下键时调用的函数。 我们定义的文本框。 但是,请记住,这是%%html单元而不是 JavaScript 单元,因此我们需要将此 JavaScript 封装在[H​​TG3]标签中。

仅当用户按下01时,我们才对按键感兴趣,在这种情况下,将添加相关标签。 我们可以确定存储在e.which中的 ASCII 值按下了哪个键。 如果用户按下 0 或 1,我们将附加标签并清除文本框。 代码如下:

<script>
$("input#capture").keypress(function(e) {
if(e.which == 48) {
    set_label(0);
    $("input#capture").val("");
}else if (e.which == 49){
    set_label(1);
    $("input#capture").val("");
  }
});

所有其他按键均被忽略。

作为本章最后的 JavaScript(我保证),我们称为load_next_tweet()函数。 这将设置要标记的第一个 tweet,然后关闭 JavaScript。 代码如下:

load_next_tweet();
</script>

运行此单元格后,您将获得一个 HTML 文本框以及第一个 tweet 的文本。 单击文本框,如果与我们的目标相关,则输入1(在这种情况下,这意味着是与编程语言 Python 相关的推文),如果与我们的目标无关,则输入 0。 完成此操作后,将加载下一条推文。 输入标签,下一个将加载。 这一直持续到推文用完为止。

完成所有这些操作后,只需将标签保存到我们先前为类值定义的输出文件名即可:

with open(labels_filename, 'w') as outf:
    json.dump(labels, outf)

即使您尚未完成操作,也可以调用前面的代码。 到此为止,您所做的所有标签都将被保存。 再次运行此笔记本将在您离开的地方继续,您可以继续为自己的推文贴上标签。

为此可能需要一段时间! 如果数据集中有很多推文,则需要对所有这些推文进行分类。 如果时间有限,则可以下载我使用的相同数据集,其中包含分类。

从 Twitter 创建可复制的数据集

在数据挖掘中,有很多变量。 这些不仅仅出现在数据挖掘算法中,它们还出现在数据收集,环境和许多其他因素中。 能够复制结果非常重要,因为它使您可以验证或改进结果。

注意

使用算法X在一个数据集上获得 80%的准确性,使用算法Y在另一数据集上获得 90%的准确性并不意味着 Y 会更好。 我们需要能够在相同条件下对相同数据集进行测试,以进行正确比较。

在运行上述代码时,您将获得与我创建和使用的数据集不同的数据集。 主要原因是,Twitter 将根据您执行搜索的时间为您返回与我不同的搜索结果。 即使在那之后,您的推文标签可能与我的标签有所不同。 尽管有一些明显的示例,其中给定的推文与 python 编程语言相关,但总会有灰色区域的标签不明显。 我遇到的一个困难的灰色区域是我看不懂的非英语推文。 在这种特定情况下,Twitter 的 API 中提供了用于设置语言的选项,但是即使这些选项也不是完美的。

由于这些因素,很难在从社交媒体提取的数据库上复制实验,Twitter 也不例外。 Twitter 明确禁止直接共享数据集。

一种解决方案是仅共享鸣叫 ID,您可以自由共享它们。 在本节中,我们将首先创建一个我们可以自由共享的 tweet ID 数据集。 然后,我们将看到如何从该文件下载原始推文以重新创建原始数据集。

首先,我们保存 tweet ID 的可复制数据集。 创建另一个新的 IPython Notebook,首先设置文件名。 这样做的方式与标记相同,但是有一个新文件名可以存储可复制的数据集。 代码如下:

import os
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")
replicable_dataset = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_dataset.json")

我们像上一个笔记本一样加载推文和标签:

import json
tweets = []
with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0:
            continue
        tweets.append(json.loads(line))
if os.path.exists(labels_filename):
    with open(classes_filename) as inf:
        labels = json.load(inf)

现在,我们通过同时遍历推文和标签并将它们保存在列表中来创建数据集:

dataset = [(tweet['id'], label) for tweet, label in zip(tweets, labels)]

最后,我们将结果保存在文件中:

with open(replicable_dataset, 'w') as outf:
    json.dump(dataset, outf)

现在我们已经保存了推特 ID 和标签,我们可以重新创建原始数据集。 如果要重新创建本章使用的数据集,可以在本课程随附的代码包中找到它。

加载之前的数据集并不困难,但可能需要一些时间。 启动一个新的 IPython Notebook,并像以前一样设置数据集,标签和 tweet ID 文件名。 我在这里调整了文件名,以确保您不会覆盖以前收集的数据集,但可以根据需要随时更改它们。 代码如下:

import os
tweet_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_python_classes.json")
replicable_dataset = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_dataset.json")

然后使用 JSON 从文件中加载 tweet ID:

import json
with open(replicable_dataset) as inf:
    tweet_ids = json.load(inf)

保存标签非常容易。 我们只是遍历此数据集并提取 ID。 我们只需两行代码(打开文件并保存推文)就可以轻松完成此操作。 但是,我们不能保证会得到所有的 tweet(例如,自收集数据集以来,某些 tweet 可能已更改为 private),因此标签将不正确地针对数据建立索引。

举例来说,我试图在收集数据后的一天之内重新创建数据集,并且已经丢失了两条推文(它们可能被用户删除或设为私有)。 因此,仅打印出所需标签很重要。 为此,我们首先创建一个空的actual labels列表来存储实际从twitter恢复的推文的标签,然后创建将推文 ID 映射到标签的字典。

代码如下:

actual_labels = []
label_mapping = dict(tweet_ids)

接下来,我们将创建一个twitter服务器来收集所有这些推文。 这将花费更长的时间。 导入我们之前使用的twitter库,创建一个授权令牌,并使用该令牌创建twitter对象:

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret, consumer_key, consumer_secret)
t = twitter.Twitter(auth=authorization)

通过使用以下命令将 ID 提取到列表中来迭代每个 Twitter ID:

all_ids = [tweet_id for tweet_id, label in tweet_ids]

然后,我们打开输出文件以保存推文:

with open(tweets_filename, 'a') as output_file:

Twitter API 允许我们一次获得 100 条推文。 因此,我们遍历每 100 条 tweet:

    for start_index in range(0, len(tweet_ids), 100):

要按 ID 进行搜索,我们首先创建一个将所有 ID(在此批次中)连接在一起的字符串:

        id_string = ",".join(str(i) for i in all_ids[start_index:start_index+100])

接下来,我们执行状态/查找 API 调用,该调用由 Twitter 定义。 我们将 ID 列表(我们将其转换为字符串)传递到 API 调用中,以便将这些 tweet 返回给我们:

        search_results = t.statuses.lookup(_id=id_string)

然后,对于搜索结果中的每个推文,我们将其保存到文件中的方式与最初收集数据集时的方式相同:

        for tweet in search_results:
            if 'text' in tweet:
                output_file.write(json.dumps(tweet))
                output_file.write("\n\n")

作为此处的最后一步(仍在前面的if块下),我们想存储此推文的标签。 我们可以使用之前创建的label_mapping字典来执行此操作,查找推特 ID。 代码如下:

                actual_labels.append(label_mapping[tweet['id']])

运行上一个单元格,代码将为您收集所有推文。 如果您创建了一个非常大的数据集,则可能需要一段时间-Twitter 会进行限速请求。 作为此处的最后一步,请将actual_labels保存到我们的classes文件中:

with open(labels_filename, 'w') as outf:
    json.dump(actual_labels, outf)

Creating a replicable dataset from Twitter

文字转换器

现在我们有了数据集,我们将如何对其进行数据挖掘?

基于文本的数据集包括书籍,论文,网站,手稿,编程代码和其他形式的书面表达。 到目前为止,我们已经看到的所有算法都处理数字或分类特征,那么如何将文本转换为该算法可以处理的格式?

有个可以进行的测量。 例如,平均单词和平均句子长度用于预测文档的可读性。 但是,我们现在将研究许多特征类型,例如单词出现。

口碑

最简单但高效的模型中的一种是简单地计算数据集中的每个单词。 我们创建一个矩阵,其中每一行代表数据集中的一个文档,每一列代表一个单词。 单元格的值是该单词在文档中的出现频率。

这是指环王J.R.R.的摘录 托尔金

|   | *天上的精灵王三环,**七个在石头大厅里的矮人,**九个人命中注定要死,为黑暗之王登上黑暗宝座的人在暗影存在的魔多国。**一个环来统治所有人,一个环来找到他们,**一枚戒指将它们全部带走,在黑暗中将它们绑起来。*在暗影存在的魔多国。 |   | |   | - J.R.R. 托尔金在《指环王》中的题词 |

出现在单词中,而出现在中,显示为显示为显示为 ]分别出现四次。 单词出现了 3 次,的单词也出现了 3 次。

我们可以由此创建一个数据集,选择一个单词子集并计算频率:

| **字** | 这 | 一 | 戒指 | 到 | | **频率** | 9 | 4 | 3 | 4 |

我们可以使用counter类对给定的字符串进行简单计数。 在计算单词时,通常将所有字母都转换为小写,这在创建字符串时会执行。 代码如下:

s = """Three Rings for the Elven-kings under the sky,
Seven for the Dwarf-lords in halls of stone,
Nine for Mortal Men, doomed to die,
One for the Dark Lord on his dark throne
In the Land of Mordor where the Shadows lie.
One Ring to rule them all, One Ring to find them,
One Ring to bring them all and in the darkness bind them.
In the Land of Mordor where the Shadows lie. """.lower()
words = s.split()
from collections import Counter
c = Counter(words)

打印c.most_common(5)给出了最频繁出现的前五个单词的列表。 领带处理不佳,仅给出了五个,并且大量单词都并列第五。

词袋模型有三种主要类型。 首先是使用原始频率,如前面的示例所示。 当文档的大小从较少的单词变为很多单词时,这确实有一个缺点,因为的整体值将有很大的不同。 第二种模型是使用归一化频率,其中每个文档的总和等于 1。这是更好的解决方案,因为文档的长度无关紧要。 第三种是简单地使用二进制功能-如果单词完全出现,则值为 1;否则,则返回 0。 我们将在本章中使用二进制表示。

另一种执行标准化的流行方法(可能更流行)称为词频-反向文档频率tf-idf。 在这种加权方案中,术语计数首先被归一化为频率,然后除以它出现在语料库中的文档数。 我们将在第 10 章,“聚类新闻文章”中使用 tf-idf。

有许多库可用于在 Python 中处理文本数据。 我们将使用一个主要的工具,称为自然语言工具包NLTK)。 scikit-learn库还具有执行类似操作的 CountVectorizer类,建议您看一下(我们将在第 9 章,作者归因中使用它)。 但是,NLTK 版本具有更多用于单词标记化的选项。 如果您使用 python 进行自然语言处理,则 NLTK 是一个很好的库。

N-grams

从单个单词袋特征开始的步骤是 n-gram 的特征。 n-gram 是n个连续标记的子序列。 在这种情况下,单词 n-gram 是连续出现的n个单词的集合。

他们的计数方法相同,n 元语法词构成,并放入中。 此数据集中的单元格值是特定 n-gram 在给定文档中出现的频率。

注意

n的值是一个参数。 对于英语,将其设置为 2 到 5 之间是一个好的开始,尽管某些应用要求使用更高的值。

例如,对于 n = 3,我们提取以下引号中的前几个 n-gram:

始终朝着生活的光明面看。

第一个 n-gram(大小为 3)是始终在上看,第二个是上看,第三个是在明亮的上。 如您所见,n 元语法重叠并覆盖了三个单词。

单词 n-gram 优于使用单个单词。 这个简单的概念通过考虑词的本地环境为词的使用引入了一些上下文,而无需花大量的时间来理解该语言。 使用 n-gram 的一个缺点是矩阵变得更加稀疏-不太可能出现单词 n-gram 两次(特别是在推文和其他简短文档中!)。

专门用于社交媒体和其他短文档的单词 n-gram 不太可能出现在太多不同的推文中,除非它是转推。 但是,在较大的文档中,单词 n-gram 对于许多应用都非常有效。

文本文档的另一种形式的 n-gram 是字符 n-gram。 而不是使用单词集,我们只使用字符集(尽管字符 n-gram 对于它们的计算方式有很多选择!)。 这种类型的数据集可以选择拼写错误的单词,并提供其他好处。 我们将在本章中测试字符 n-gram,然后在第 9 章和“作者身份”中再次看到它们。

其他功能

还有其他可以提取的功能。 这些包括句法功能,例如句子中特定单词的使用。 词性标签在需要理解文本含义的数据挖掘应用中也很流行。 此类功能类型将不在本模块中介绍。 如果您有兴趣了解更多信息,建议使用 Python 3 文本处理和 NLTK 3 食谱Jacob PerkinsPackt Publishing

朴素贝叶斯

朴素贝叶斯是概率模型,毫无疑问地基于朴素的贝叶斯统计解释。 尽管幼稚的方面,该方法在大量上下文中仍然表现良好。 它可以用于对许多不同的特征类型和格式进行分类,但是在本章中我们将重点介绍一种:词袋模型中的二进制特征。

贝叶斯定理

对于我们大多数来说,当我们学习统计学时,我们是从一种常识性方法开始的。 在这种方法中,我们假设数据来自某个分布,并且我们旨在确定该分布的参数。 但是,这些参数(可能不正确)被假定为固定的。 我们使用模型来描述数据,甚至进行测试以确保数据适合我们的模型。

贝叶斯统计代替了人们(非统计学家)实际推理的模型。 我们有一些数据,并使用这些数据来更新模型,以了解发生某件事的可能性。 在贝叶斯统计中,我们使用数据来描述模型,而不是使用模型并通过数据进行确认(按照常客方法)。

贝叶斯定理计算 P(A | B)的值,也就是说,知道B发生了,A的概率是多少。 在大多数情况下,B是观察到的事件,例如,昨天下雨的,A是今天下雨的预报。 对于数据挖掘,B通常是,我们观察到此样本A是*,它属于此类*。 下一节将介绍如何使用贝叶斯定理进行数据挖掘。

贝叶斯定理的方程给出如下:

Bayes' theorem

例如,我们要确定包含药物一词的电子邮件是垃圾邮件的可能性(因为我们认为这样的推文可能是制药垃圾邮件)。

在此上下文中,是该推文是垃圾邮件的概率。 我们可以通过计算数据集中的垃圾邮件百分比来直接从训练数据集中计算 P(A),称为先验信念。 如果我们的数据集中每 100 封电子邮件包含 30 条垃圾邮件,则 *P(A)*为 30/1000.3

在此上下文中,B是*,此推文包含单词“ drugs”。 同样,我们可以通过计算包含药物一词的数据集中的推文百分比来计算 P(B)。 如果我们的训练数据集中每 100 封电子邮件中有 10 封邮件包含药物*字词,则 *P(B)*为 10/100 或 0.1。 请注意,计算此值时,我们不在乎电子邮件是否为垃圾邮件。

P(B|A)是电子邮件中包含毒品单词的可能性,如果它是垃圾邮件。 从我们的训练数据集进行计算也很容易。 我们仔细查看垃圾邮件的训练集,并计算出包含毒品一词的邮件所占的百分比。 在我们的 30 封垃圾邮件中,如果 6 封包含药物一词,则 *P(B | A)*计算为 6/30 或 0.2。

从这里开始,我们使用贝叶斯定理来计算 P(A | B),这是包含药物的推文成为垃圾邮件的可能性。 使用前面的公式,我们看到结果是 0.6。 这表明,如果电子邮件中包含毒品一词,则有 60%的可能性是垃圾邮件。

注意前面示例的经验性质-我们直接从训练数据集中使用证据,而不是从某些先入为主的分布中使用证据。 相反,对此的常识性看法将依靠我们在推文中创建单词概率的分布来计算相似的方程式。

朴素贝叶斯算法

回顾的贝叶斯定理方程,我们可以用它来计算给定样本属于给定类别的概率。 这允许将该方程用作分类算法。

在数据集中以C为给定类,以D为样本,我们创建贝叶斯定理和随后的朴素贝叶斯所需的元素。 朴素贝叶斯是一种分类算法,利用贝叶斯定理来计算新数据样本属于特定类别的概率。

*P(C)*是类别的概率,它是从训练数据集本身计算出来的(就像我们对垃圾邮件示例所做的那样)。 我们只需计算训练数据集中属于给定类别的样本的百分比即可。

*P(D)*是给定数据样本的概率。 由于样本是不同功能之间的复杂交互,因此可能很难计算出来,但是幸运的是,它在所有类中都是一个常数。 因此,我们根本不需要计算它。 稍后我们将看到如何解决此问题。

P(D | C)是数据点属于类别的概率。 由于功能不同,这也可能难以计算。 但是,这是我们介绍朴素贝叶斯算法的朴素部分的地方。 我们天真地假设每个功能都是彼此独立的。 而不是计算 *P(D | C)*的全部概率,我们计算每个特征 D1,D2,D3 等的概率。 然后,我们将它们相乘:

P(D|C) = P(D1|C) x P(D2|C).... x P(Dn|C)

这些值中的每一个都相对易于使用二进制功能进行计算。 我们只计算样本数据集中相等时间的百分比。

相反,如果要执行本部分的非朴素贝叶斯版本,则需要为每个类计算不同特征之间的相关性。 这种计算充其量是不可行的,如果没有大量的数据或适当的语言分析模型,几乎是不可能的。

从开始,该算法非常简单。 我们为每个可能的类别计算 P(C | D),而忽略了 *P(D)*项。 然后,我们选择概率最高的类别。 由于 *P(D)*术语在每个类别中都是一致的,因此忽略它不会对最终预测产生影响。

工作原理

作为的示例,假设我们从数据集中的样本中获得以下(二进制)特征值:[0, 0, 0, 1]

我们的训练数据集包含两个类别,其中 75%的样本属于0类别,而 25%的样本属于1类别。 每个类的特征值的可能性如下:

对于课程 0:[0.3, 0.4, 0.4, 0.7]

对于类别 1:[0.7, 0.3, 0.4, 0.9]

这些值应解释为:对于特征 1 ,对于类别 0,在 30%的情况下为 1。

现在我们可以计算出该样本属于0类的概率。 P(C = 0)= 0.75,这是类别为0的概率。

朴素贝叶斯算法不需要 P(D)。 让我们看一下计算:

P(D|C=0) = P(D1|C=0) x P(D2|C=0) x P(D3|C=0) x P(D4|C=0)
= 0.3 x 0.6 x 0.6 x 0.7
= 0.0756

注意

第二个和第三个值为 0.6,因为样本中该特征的值为0。 列出的概率是每个功能的1值。 因此,0的概率是其倒数:P(0)= 1-P(1)

现在,我们可以计算出属于此类的数据点的概率。 需要注意的重要一点是,我们尚未计算出 P(D),因此这不是真实的概率。 但是,将相同的值与类别 1 的概率进行比较就足够了。让我们看一下计算:

P(C=0|D) = P(C=0) P(D|C=0)
= 0.75 * 0.0756
= 0.0567

现在,我们为类 1 计算相同的值:

P(C=1) = 0.25

天真贝叶斯不需要P(D)。 让我们看一下计算:

P(D|C=1) = P(D1|C=1) x P(D2|C=1) x P(D3|C=1) x P(D4|C=1)
= 0.7 x 0.7 x 0.6 x 0.9
= 0.2646
P(C=1|D) = P(C=1)P(D|C=1)
= 0.25 * 0.2646
= 0.06615

注意

通常,*P(C = 0 | D)+ P(C = 1 | D)*应该等于 1。毕竟,这是仅有的两个可能的选择! 但是,由于我们没有在此处的方程式中包含 *P(D)*的计算,因此概率不是 1。

数据点应分类为属于类别1。 无论如何,您可能已经在通过方程式进行猜测了; 但是,您可能对最终决定如此之近感到有些惊讶。 毕竟,对于类别1,计算 *P(D | C)*的概率要高得多。 这是因为我们引入了一个先验的信念,即大多数样本通常都属于0类。

如果类的大小相等,则得出的概率将有很大不同。 通过将 *P(C = 0)*和 *P(C = 1)*都更改为 0.5,以实现相同的班级大小,然后再次计算结果,自己尝试一下。

二十八、使用图挖掘发现要关注的帐户

许多事物可以表示为图形。 在大数据,在线社交网络和物联网的今天,尤其如此。 尤其是,在线社交网络是一项大生意,Facebook 等网站拥有超过 5 亿活跃用户(其中​​50%每天登录)。 这些网站通常通过有针对性的广告获利。 但是,要使用户与网站互动,他们通常需要关注有趣的人或页面。

在本章中,我们将研究相似性的概念以及如何基于相似性创建图。 我们还将看到如何使用连接的组件将此图分成有意义的子图。 这个简单的算法引入了聚类分析的概念-根据相似度将数据集分为子集。 我们将在第 10 章,“聚类新闻文章”中更深入地研究聚类分析。

本章涵盖的主题包括:

  • 从社交网络创建图
  • 加载和保存内置分类器
  • NetworkX 软件包
  • 将图转换为矩阵
  • 距离和相似度
  • 基于评分功能优化参数
  • 损失函数和计分函数

加载数据集

在本章的中,我们的任务是基于共享连接向在线社交网络上的用户推荐。 我们的逻辑是,如果两个用户有相同的朋友,则他们非常相似,值得彼此推荐

我们将使用上一章介绍的 API 从 Twitter 创建一个小的社交图。 我们正在寻找的数据是对类似主题感兴趣的用户的子集(再次是 Python 编程语言)以及所有朋友的列表(他们关注的人)。 使用此数据,我们将基于检查两个用户共有多少个朋友,他们的相似程度如何。

注意

除 Twitter 外,还有许多其他在线社交网络。 我们选择 Twitter 进行此实验的原因是,他们的 API 使得获取此类信息非常容易。 该信息也可以从其他站点获得,例如 Facebook,LinkedIn 和 Instagram。 但是,获取此信息更加困难。

要开始收集数据,请像上一章一样,设置一个新的 IPython Notebook 和一个twitter连接的实例。 您可以重用上一章中的应用信息,也可以创建一个新的信息:

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret, consumer_key, consumer_secret)
t = twitter.Twitter(auth=authorization, retry=True)

另外,创建输出文件名:

import os
data_folder = os.path.join(os.path.expanduser("~"), "Data", "twitter")
output_filename = os.path.join(data_folder, "python_tweets.json")

我们还需要json库来保存数据:

import json

接下来,我们将需要一个用户列表。 和上一章一样,我们将搜索鸣叫,并寻找那些提及单词 python 的人。 首先,创建两个列表,用于存储推文的文本和相应的用户。 稍后我们将需要用户 ID,因此我们现在创建一个字典映射。 代码如下:

original_users = []
tweets = []
user_ids = {}

我们将像上一章一样搜索 python 一词,并遍历搜索结果:

search_results = t.search.tweets(q="python", count=100)['statuses']
for tweet in search_results:

我们只对推文感兴趣,对 Twitter 可以传递的其他消息不感兴趣。 因此,我们检查结果中是否有文字:

    if 'text' in tweet:

如果是这样,我们将记录用户的屏幕名称,鸣叫文本以及屏幕名称到用户 ID 的映射。 代码如下:

        original_users.append(tweet['user']['screen_name'])
        user_ids[tweet['user']['screen_name']] = tweet['user']['id']
        tweets.append(tweet['text'])

运行此代码将获得约 100 条推文,在某些情况下可能会少一些。 但是,并非所有这些都与编程语言有关。

使用现有模型进行分类

正如我们在上一章中所了解的那样,并非所有提及单词 python 的推文都将与编程语言相关。 为此,我们将使用上一章中使用的分类器来基于编程语言获取推文。 我们的分类器并不完美,但是与仅进行搜索相比,它会带来更好的专业化。

在这种情况下,我们只对发推特有关 Python(一种编程语言)的用户感兴趣。 我们将使用上一章中的分类器来确定哪些推文与编程语言相关。 从那里,我们将只选择那些在推特上发布有关编程语言的用户。

为此,我们首先需要保存模型。 打开我们在上一章中创建的 IPython Notebook,我们在其中构建了分类器。 如果您关闭了它,那么 IPython Notebook 将不会记住您所做的事情,因此您将需要再次运行这些单元。 为此,请在笔记本电脑的单元格菜单上单击,然后选择全部运行

计算完所有单元格后,选择最终的空白单元格。 如果您的笔记本电脑末尾没有空白单元格,请选择最后一个单元格,选择插入菜单,然后选择下面插入单元格。

我们将使用joblib库保存并加载模型。

注意

scikit-learn软件包中包含joblib

首先,导入库并为我们的模型创建输出文件名(确保目录存在,否则将不会创建目录)。 我已经将该模型存储在 Models 目录中,但是您可以选择将它们存储在其他位置。 代码如下:

from sklearn.externals import joblib
output_filename = os.path.join(os.path.expanduser("~"), "Models", "twitter", "python_context.pkl")

接下来,我们使用joblib中的dump函数,其功能类似于json库中的函数。 我们传递模型本身(如果忘记了,则简称为model),并传递输出文件名:

joblib.dump(model, output_filename)

运行此代码会将模型保存到给定的文件名。 接下来,返回到在上一小节中创建的新 IPython Notebook 并加载此模型。

您将需要通过复制以下代码在此笔记本中再次设置模型的文件名:

model_filename = os.path.join(os.path.expanduser("~"), "Models", "twitter", "python_context.pkl")

确保文件名是保存模型之前使用的文件名。 接下来,我们需要重新创建我们的NLTKBOW类,因为它是一个定制类,不能由joblib直接加载。 在后面的章节中,我们将解决此问题的一些更好方法。 现在,只需从上一章的代码中复制整个NLTKBOW类,包括其依赖项即可:

from sklearn.base import TransformerMixin
from nltk import word_tokenize

class NLTKBOW(TransformerMixin):
    def fit(self, X, y=None):
        return self

    def transform(self, X):
        return [{word: True for word in word_tokenize(document)}
                 for document in X]

现在加载模型只需要调用joblibload函数:

from sklearn.externals import joblib
context_classifier = joblib.load(model_filename)

我们的context_classifier的工作原理与我们在第 6 章,“使用朴素贝叶斯”的社交媒体见解中看到的笔记本的model对象相同,它是Pipeline的一个实例, 具有与之前相同的三个步骤(NLTKBOWDictVectorizerBernoulliNB分类器)。

在此模型上调用predict函数可以预测我们的推文是否与编程语言相关。 代码如下:

y_pred = context_classifier.predict(tweets)

如果(预测为)与编程语言相关的ith推文,则y_pred中的ith项将为 1,否则它将为 0。从这里,我们可以仅获得与之相关的推文。 他们的相关用户:

relevant_tweets = [tweets[i] for i in range(len(tweets)) if y_pred[i] == 1]
relevant_users = [original_users[i] for i in range(len(tweets)) if y_pred[i] == 1]

使用我的数据,这涉及到 46 个相关用户。 比以前的 100 条推文/用户略低,但现在我们有了构建社交网络的基础。

从 Twitter 获取关注者信息

接下来,我们需要获得的个朋友每个用户。 朋友是用户关注的人。 用于此的 API 称为friends/ids,它有优点也有缺点。 好消息是,它在一个 API 调用中最多返回 5,000 个朋友 ID。 坏消息是,您每 15 分钟只能拨打 15 个电话,这意味着每位用户至少要花 1 分钟才能吸引所有关注者-如果他们的朋友数超过 5,000,则关注者会更多(发生的次数比您想象的要多) 。

但是,代码相对容易。 我们将其打包为一个函数,因为在接下来的两节中将使用此代码。 首先,我们将创建带有我们的 Twitter 连接和用户 ID 的功能签名。 该函数将返回该用户的所有关注者,因此我们还将创建一个列表来存储这些关注者。我们还将需要时间模块,因此也需要导入它。 我们将首先介绍函数的组成,但是接下来,我将为您完整介绍整个函数。 代码如下:

import time
def get_friends(t, user_id):
    friends = []

尽管这可能令人惊讶,但许多 Twitter 用户拥有超过 5,000 个朋友。 因此,我们将需要使用 Twitter 的分页。 Twitter 通过使用游标管理多个页面数据。 当要求 Twitter 提供信息时,它会提供该信息以及光标,这是 Twitter 用户跟踪您的请求的整数。 如果没有更多信息,则此光标为 0;否则为 0。 否则,您可以使用提供的光标来获取下一页结果。 首先,将光标设置为-1,指示结果的开始:

    cursor = -1

接下来,我们继续循环,而此游标不等于 0(因为当它是游标时,就没有更多数据可收集了)。 然后,我们请求用户的关注者并将其添加到我们的列表中。 我们在try块中执行此操作,因为我们可以处理可能发生的错误。 追随者的 ID 存储在results词典的ids键中。 获取该信息后,我们更新游标。 它将在循环的下一次迭代中使用。 最后,我们检查是否有超过 10,000 个朋友。 如果是这样,我们就跳出了循环。 代码如下:

    while cursor != 0:
        try:
            results = t.friends.ids(user_id= user_id, cursor=cursor, count=5000)
            friends.extend([friend for friend in results['ids']])
            cursor = results['next_cursor']
            if len(friends) >= 10000:
                break

注意

值得在此处插入警告。 我们正在处理来自 Internet 的数据,这意味着奇怪的事情可能而且确实会定期发生。 我在开发此代码时遇到的一个问题是,一些用户有很多很多朋友。 为解决此问题,我们将在此处放置一个故障保护,如果我们达到 10,000 个以上的用户,则退出。 如果要收集完整的数据集,则可以删除这些行,但是请注意,它可能会长时间卡在特定用户上。

现在,我们处理可能发生的错误。 如果我们不小心达到了 API 限制,则会发生最有可能发生的错误(虽然我们有sleep来阻止它,但是,如果在sleep完成之前停止并运行代码,则可能会发生)。 在这种情况下,results is None和我们的代码将失败,并带有TypeError。 在这种情况下,我们等待 5 分钟,然后重试,希望我们到达下一个 15 分钟的窗口。 此时可能会出现另一种TypeError。 如果其中之一确实存在,我们将提出并需要单独处理。 代码如下:

        except TypeError as e:
            if results is None:
                print("You probably reached your API limit, waiting for 5 minutes")
                sys.stdout.flush()
                time.sleep(5*60) # 5 minute wait
            else:
                raise e

可能发生的第二个错误发生在 Twitter 的末端,例如要求一个不存在的用户或其他一些基于数据的错误。 在这种情况下,请勿再尝试使用该用户,而只返回我们确实获得的所有关注者(在这种情况下,该关注者可能为 0)。 代码如下:

        except twitter.TwitterHTTPError as e:
            break

现在,我们将处理我们的 API 限制。 Twitter 仅让我们每 15 分钟询问 15 次关注者信息,因此我们将等待 1 分钟后再继续。 我们在finally块中执行此操作,以便即使发生错误也可以发生:

        finally:
            time.sleep(60)

通过返回我们收集的朋友来完成我们的功能:

    return friends

完整功能如下:

import time
def get_friends(t, user_id):
    friends = []
    cursor = -1
    while cursor != 0:
        try:
            results = t.friends.ids(user_id= user_id, cursor=cursor, count=5000)
            friends.extend([friend for friend in results['ids']])
            cursor = results['next_cursor']
            if len(friends) >= 10000:
                break
        except TypeError as e:
            if results is None:
                print("You probably reached your API limit, waiting for 5 minutes")
                sys.stdout.flush()
                time.sleep(5*60) # 5 minute wait
            else:
                raise e
        except twitter.TwitterHTTPError as e:
                break
               finally:
                   time.sleep(60)
    return friends

建立网络

现在我们将建立我们的网络。 从我们的原始用户开始,我们将获得他们的每个朋友并将其存储在字典中(从user_id字典中获得用户的 ID 后):

friends = {}
for screen_name in relevant_users:
    user_id = user_ids[screen_name]
    friends[user_id] = get_friends(t, user_id)

接下来,我们将删除没有任何朋友的所有用户。 对于这些用户,我们真的不能以这种方式提出建议。 相反,我们可能必须查看他们的内容或关注他们的人。 但是,我们将其排除在本章的范围之外,因此我们仅删除这些用户。 代码如下:

friends = {user_id:friends[user_id] for user_id in friends
             if len(friends[user_id]) > 0}

现在,我们有 30 至 50 个用户,具体取决于您的初始搜索结果。 现在,我们将其数量增加到 150 个。以下代码将花费很长时间才能运行-鉴于 API 的限制,我们每分钟只能为用户获得一次好友。 简单的数学将告诉我们,150 个用户将花费 150 分钟,即 2.5 个小时。 考虑到我们将花费时间来获取这些数据,确保我们仅获得个良好的用户是值得的。

但是,什么才是好的用户呢? 鉴于我们将寻求基于共享连接的建议,我们将基于共享连接搜索用户。 我们将从与我们现有用户之间有更好联系的那些用户开始,吸引现有用户的朋友。 为此,我们将统计用户在friends列表之一中的所有访问次数。 在考虑采样策略时,值得考虑应用的目标。 为此,获得大量相似用户可以使建议更加定期地适用。

为此,我们只需遍历我们拥有的所有friends列表,然后在每次出现朋友时计数。

from collections import defaultdict
def count_friends(friends):
    friend_count = defaultdict(int)
    for friend_list in friends.values():
        for friend in friend_list:
            friend_count[friend] += 1
    return friend_count

计算我们当前的个朋友计数,然后我们可以从样本中获得最多个联系的人(即,现有列表中的大多数朋友)。 代码如下:

friend_count
reverse=True) = count_friends(friends)
from operator import itemgetter
best_friends = sorted(friend_count.items(), key=itemgetter(1),

从这里开始,我们建立了一个循环,一直持续到拥有 150 个用户的朋友为止。 然后,我们遍历所有最好的朋友(这按照将他们作为朋友的人数的顺序进行),直到找到一个我们尚未认识其朋友的用户。 然后,我们得到该用户的朋友并更新friends计数。 最后,我们算出谁是我们名单中尚未联系最多的用户:

while len(friends) < 150:
    for user_id, count in best_friends:
        if user_id not in friends:
            break       
        friends[user_id] = get_friends(t, user_id)
    for friend in friends[user_id]:
        friend_count[friend] += 1
    best_friends = sorted(friend_count.items(), 
      key=itemgetter(1), reverse=True)

然后,这些代码将循环播放并继续,直到达到 150 个用户为止。

注意

您可能希望将这些值设置得较低,例如 40 个或 50 个用户(甚至只是暂时跳过此代码位)。 然后,完成本章的代码并了解结果的工作方式。 之后,将该循环中的用户数重置为 150,使代码运行几个小时,然后返回并重新运行以后的代码。

考虑到收集数据可能要花费 2 个小时以上的时间,因此保存它是个好主意,以防万一我们必须关闭计算机。 使用json库,我们可以轻松地将好友字典保存到文件中:

import json
friends_filename = os.path.join(data_folder, "python_friends.json")
with open(friends_filename, 'w') as outf:
    json.dump(friends, outf)

如果需要加载文件,请使用json.load函数:

with open(friends_filename) as inf:
    friends = json.load(inf)

创建图表

现在,我们有一个用户及其朋友的列表,其中许多用户是从其他用户的朋友那里获取的。 这为我们提供了一个图表,其中一些用户是其他用户的朋友(尽管不一定相反)。

图是一组节点和边。 节点通常是对象,在这种情况下,它们是我们的用户。 该初始图中的边缘指示用户 A 是用户 B 的朋友。 我们将其称为有向图,因为节点的顺序很重要。 仅仅因为用户 A 是用户 B 的朋友,并不意味着用户 B 是用户 A 的朋友。我们可以使用 NetworkX 包来可视化该图。

注意

再次单击,即可使用pip安装 NetworkX:pip3 install networkx

首先,我们使用 NetworkX 创建一个有向图。 按照惯例,在导入 NetworkX 时,我们使用缩写nx(尽管这不是必需的)。 代码如下:

import networkx as nx
G = nx.DiGraph()

我们只会可视化关键用户,而不是所有朋友(因为有成千上万的用户,很难想象)。 我们得到了主要用户,然后将它们作为节点添加到我们的图形中。 代码如下:

main_users = friends.keys()
G.add_nodes_from(main_users)

接下来,我们设置边缘。 如果第二个用户是第一个用户的朋友,则创建从一个用户到另一个用户的边缘。 为此,我们遍历所有朋友:

for user_id in friends:
    for friend in friends[user_id]:

我们确保朋友是我们的主要用户之一(因为我们目前对其他用户不感兴趣),如果有,请添加优势。 代码如下:

        if friend in main_users:
           G.add_edge(user_id, friend)

现在,我们可以使用 NetworkX 的draw函数(使用matplotlib)来可视化网络。 为了在笔记本中获取图像,我们使用matplotlib上的inline函数,然后调用draw函数。 代码如下:

%matplotlib inline
nx.draw(G)

结果有点难以理解。 他们显示有些节点的连接很少,但许多节点的连接却很多:

Creating a graph

通过使用pyplot处理图形的创建,我们可以使图稍微大一些。 为此,我们导入pyplot,创建一个大图形,然后调用 NetworkX 的draw函数(NetworkX 使用pyplot绘制其图形):

from matplotlib import pyplot as plt
plt.figure(3,figsize=(20,20))
nx.draw(G, alpha=0.1, edge_color='b')

结果对于此处的页面来说太大了,但是通过放大图形,现在可以看到图形的外观轮廓。 在我的图表中,有一个主要的用户组,它们彼此之间高度连接,而大多数其他用户根本没有多少连接。 在这里,我仅放大了网络的中心,并在前面的代码中将边缘颜色设置为蓝色,且alpha较低。

如您所见,它在中心位置连接非常好!

Creating a graph

实际上是我们选择新用户的方法的一个属性-我们选择那些在图中已经很好地联系在一起的用户,因此他们很可能会使这个组更大。 对于社交网络,通常,用户具有的连接数遵循幂定律。 一小部分用户拥有许多连接,而其他用户只有少数。 通常将图形的形状描述为具有长尾巴。 我们的数据集不遵循这种模式,因为我们是通过结交已有用户的朋友来收集数据的。

创建相似图

通过共享的朋友推荐本章中的任务。 如前所述,我们的逻辑是,如果两个用户有相同的朋友,则他们是非常相似的。 我们可以在此基础上向另一个推荐一个用户。

因此,我们将采用我们现有的图(具有与友谊有关的边缘)并创建一个新图。 节点仍然是用户,但是边缘将成为加权边缘。 加权边仅是具有weight属性的边。 逻辑是,较高的权重表示两个节点之间的相似性高于较低的权重。 这是取决于上下文的。 如果权重代表距离,则权重越低表示相似度越高。

对于我们的应用,权重将是通过该边缘连接的两个用户的相似性(基于他们共享的朋友数)。 此图还具有不定向的特性。 这是由于我们进行了相似度计算,其中用户 A 与用户 B 的相似度与用户 B 与用户 A 的相似度相同。

这样有很多方法可以计算两个列表之间的相似度。 例如,我们可以计算两者共有的朋友数。 但是,对于有更多朋友的人来说,此指标总是更高。 取而代之的是,我们可以将归一化,以除以两者拥有的不同好友总数。 这称为 Jaccard 相似度

Jaccard 相似度始终在 0 和 1 之间,表示两者的百分比重叠。 正如我们在第 2 章,“使用 scikit-learn 估计器进行分类”中一样,规范化是数据挖掘练习的重要组成部分,通常是一件好事(除非您有特定的原因而不是 到)。

为了计算此 Jaccard 相似度,我们将两组跟随者的交集除以两者的并集。 这些是set操作,我们有lists,因此我们需要首先将friends列表转换为集合。 代码如下:

friends = {user: set(friends[user]) for user in friends}

然后,我们创建一个函数来计算两组friends列表的相似度。 代码如下:

def compute_similarity(friends1, friends2):
return len(friends1 & friends2) / len(friends1 | friends2)

从这里,我们可以创建用户之间相似度的加权图。 在本章的其余部分中,我们将大量使用它,因此我们将创建一个函数来执行此操作。 让我们看一下 threshold 参数:

def create_graph(followers, threshold=0):
    G = nx.Graph()

我们遍历所有用户组合,而忽略了将用户与其自身进行比较的实例:

    for user1 in friends.keys():
        for user2 in friends.keys():
            if user1 == user2:
                continue

我们计算两个用户之间边缘的权重:

            weight = compute_similarity(friends[user1], friends[user2])

接下来,我们仅在超过特定阈值时添加边缘。 这样可以阻止我们添加不需要的边缘,例如权重为 0 的边缘。默认情况下,我们的阈值为 0,因此现在将包括所有边缘。 但是,我们将在本章后面使用此参数。 代码如下:

            if weight >= threshold:

如果权重高于阈值,则将两个用户添加到图中(如果两个用户已经在图中,则不会将它们添加为重复项):

                G.add_node(user1)
                G.add_node(user2)

然后,我们在它们之间添加边,将权重设置为计算出的相似度:

                G.add_edge(user1, user2, weight=weight)

循环完成后,我们将获得一个完整的图形,并从函数中将其返回:

    return G

现在,我们可以通过调用此函数来创建图形。 我们从没有阈值开始,这意味着将创建所有链接。 代码如下:

G = create_graph(friends)

结果是一个非常紧密相连的图-所有节点都具有边,尽管其中许多结点的权重为 0。通过绘制线宽相对于边权重的图,我们将看到边的权重 -粗线表示较高的权重。

由于节点数,将数字增大以更清楚地了解连接是有意义的:

plt.figure(figsize=(10,10))

我们将使用权重绘制边缘,因此我们需要先绘制节点。 NetworkX 根据某些条件使用布局来确定将节点和边放置在何​​处。 网络可视化是一个非常困难的问题,尤其是随着节点数量的增长。 存在多种用于可视化网络的技术,但是它们的工作程度在很大程度上取决于您的数据集,个人喜好和可视化的目标。 我发现spring_layout效果很好,但是其他选项,例如circular_layout(如果没有其他效果,这是一个很好的默认设置),random_layoutshell_layoutspectral_layout

注意

请访问这个页面了解有关 NetworkX 中布局的更多详细信息。 尽管draw_graphviz选项增加了一些复杂性,但其效果很好,值得进行更好的可视化研究。 在实际使用中非常值得考虑。

让我们使用spring_layout进行可视化:

pos = nx.spring_layout(G)

使用我们的pos布局,然后可以定位节点:

nx.draw_networkx_nodes(G, pos)

接下来,我们绘制边缘。 为了获得权重,我们遍历图形的边缘(以特定顺序)并收集权重:

edgewidth = [ d['weight'] for (u,v,d) in G.edges(data=True)]

然后,我们绘制边缘:

nx.draw_networkx_edges(G, pos, width=edgewidth)

结果将取决于您的数据,但通常会显示一个图形,其中有大量节点牢固连接,而有几个节点与网络其余部分的连接不良。

Creating a similarity graph

与上一张图相比,此图的不同之处在于,边基于我们的相似性度量标准而不是根据一个人是否是另一个的朋友来确定节点之间的相似性(尽管两者之间存在相似性! )。 现在,我们可以开始从该图中提取信息以提出建议。

Creating a similarity graph

应用

现在,我们将创建一个仅包含一条 tweet 的管道,并仅基于该 tweet 的内容确定其是否相关。

为了执行单词提取,我们将使用 NLTK,这是​​一个包含大量用于对自然语言进行分析的工具的库。 我们还将在以后的章节中使用 NLTK。

注意

要在计算机上获取 NLTK,请使用pip安装软件包:pip3 install nltk

如果不起作用,请参见这个页面上的 NLTK 安装说明。

我们将创建一个管道以提取单词特征并使用朴素贝叶斯对推文进行分类。 我们的管道具有以下步骤:

  1. 使用 NLTK 的word_tokenize函数将原始文本文档转换为计数字典。
  2. 使用scikit-learn中的DictVectorizer转换器将这些词典转换为向量矩阵。 这对于使 Naive Bayes 分类器能够读取第一步中提取的特征值是必需的。
  3. 如前几章所述,训练朴素贝叶斯分类器。
  4. 我们将需要创建另一个名为ch6_classify_twitter的笔记本(本章的最后一个!)来执行分类。

Application

提取字数

我们将使用 NLTK 提取我们的字数统计。 我们仍然想在管道中使用它,但是 NLTK 不符合我们的转换器接口。 因此,我们将需要创建一个基本的转换器来执行此操作,以获得fittransform方法,从而使我们能够在管道中使用它。

首先,将设置为transformer类。 我们不需要在该类中放入任何内容,因为此转换器仅提取文档中的单词。 因此,我们的 fit 是一个空函数,除了它返回self(对于提升器对象而言是必需的)。

我们的转换要复杂一些。 我们想从每个文档中提取每个单词并记录True(如果已发现)。 我们仅在此处使用二进制功能-如果在文档中使用True,否则使用False。 如果我们想使用频率,就可以像过去几章一样设置计数字典。

让我们看一下代码:

from sklearn.base import TransformerMixin
class NLTKBOW(TransformerMixin):
    def fit(self, X, y=None):
        return self
    def transform(self, X):
        return [{word: True for word in word_tokenize(document)}
                 for document in X]

结果是字典列表,其中第一个字典是第一条推文中的单词列表,依此类推。 每个词典都有一个单词作为关键字,值true表示已发现此单词。 词典中未包含的任何单词都将被视为未出现在推文中。 明确指出单词的出现是False也可以,但是会占用不必要的空间来存储。

将字典转换为矩阵

此步骤将根据上一步构建的字典转换为可与分类器一起使用的矩阵。 通过DictVectorizer提升器,此步骤变得非常简单。

DictVectorizer类仅获取字典列表并将其转换为矩阵。 此矩阵中的特征是每个词典中的键,并且值对应于每个样本中这些特征的出现。 字典很容易用代码创建,但是许多数据算法实现更喜欢矩阵。 这使DictVectorizer成为非常有用的类。

在我们的数据集中,每个字典都将单词作为关键字,并且仅在单词实际出现在推文中时才会出现。 因此,如果单词出现在推文中,我们的矩阵会将每个单词作为特征,并在单元格中使用True的值。

要使用DictVectorizer,只需使用以下命令将其导入:

from sklearn.feature_extraction import DictVectorizer

训练朴素贝叶斯分类器

最后,我们需要设置一个分类器,本章将使用朴素贝叶斯。 由于我们的数据集仅包含二进制特征,因此我们使用专为二进制特征设计的BernoulliNB分类器。 作为分类器,它非常易于使用。 与DictVectorizer一样,我们只需将其导入并将其添加到管道中即可:

from sklearn.naive_bayes import BernoulliNB

全部放在一起

现在到了将所有这些片段放在一起的时刻。 在我们的 IPython Notebook 中,像以前一样设置文件名并加载数据集和类。 设置推文本身(而不是 ID!)和我们分配给它们的标签的文件名。 代码如下:

import os
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")

自己加载推文。 我们只对 tweet 的内容感兴趣,因此我们提取text值并仅存储该值。 代码如下:

tweets = []
with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0:
            continue
        tweets.append(json.loads(line)['text'])

加载每个推文的标签:

with open(classes_filename) as inf:
    labels = json.load(inf)

现在,创建一个管道,将以前的组件放在一起。 我们的管道包括三个部分:

  • 我们创建的NLTKBOW提升器
  • DictVectorizer提升器
  • BernoulliNB分类器

代码如下:

from sklearn.pipeline import Pipeline
pipeline = Pipeline([('bag-of-words', NLTKBOW()),
                     ('vectorizer', DictVectorizer()),
                     ('naive-bayes', BernoulliNB())
                     ])

我们现在可以几乎运行我们的管道,就像我们之前做过很多次一样,我们将使用cross_val_score来完成。 在之前,我们将引入比以前使用的精度指标更好的评估指标。 正如我们将看到的,当每个类别中的样本数量不同时,对数据集使用准确性是不够的。

使用 F1 评分进行评估

当选择评估指标时,考虑该评估指标无用的情况总是很重要的。 在许多情况下,准确性是一个很好的评估指标,因为它易于理解且易于计算。 但是,它很容易伪造。 换句话说,在许多情况下,由于实用性差,您可以创建具有高精度的算法。

虽然我们的推文数据集(通常,您的结果可能有所不同)包含大约 50%的与编程相关的信息和 50%的非编程相关信息,但许多数据集并不像平衡那样。

作为示例,电子邮件垃圾邮件筛选器可能希望看到超过 80%的传入电子邮件都是垃圾邮件。 仅将所有内容标记为垃圾邮件的垃圾邮件过滤器完全没有用; 但是,它将获得 80%的精度!

为了解决这个问题,我们可以使用其他评估指标。 最常用的一种称为 f1 分数(也称为 f 分数,f 度量或该术语的许多其他变体之一)。

f1 分数是基于每类定义的,基于两个概念:精度调用精度是预测为属于特定类别的实际上来自该类别的所有样本的百分比。 召回是数据集中属于某个类别并实际标记为属于该类别的样本的百分比。

在我们的应用中,我们可以计算两个类的值(相关和不相关)。 但是,我们对垃圾邮件确实很感兴趣。 因此,我们的精度计算成为一个问题:在所有被预测为相关的推文中,实际相关的百分比是多少? 同样,召回也成为一个问题:在数据集中所有相关推文中,有多少被预测为相关?

在中计算精度和查全率后,f1 分数是精度和查全率的谐波均值:

Evaluation using the F1-score

要在scikit-learn方法中使用 f1-score,只需将评分参数设置为 f1。 默认情况下,此将返回带有标签 1 的类的 f1 分数。在我们的数据集上运行代码,我们只需使用以下代码行:

scores = cross_val_score(pipeline, tweets, labels, scoring='f1')

然后,我们打印出平均分数:

import numpy as np
print("Score: {:.3f}".format(np.mean(scores)))

结果为 0.798,这意味着我们可以准确地确定使用 Python 的推文是否有 80%的时间与编程语言相关。 这使用的数据集中只有 200 条推文。 返回并收集更多数据,您会发现结果有所增加!

注意

通常,更多数据意味着更好的准确性,但不能保证!

从模型中获得有用的功能

您可能会问的问题是*,用于确定推文是否相关的最佳功能是什么?* 根据朴素贝叶斯的说法,我们可以从朴素贝叶斯模型中提取这些信息,并找出哪些功能最适合个人。

首先,我们适合一个新模型。 尽管cross_val_score为我们提供了经过交叉验证的测试数据的不同折分,但它并不容易为我们提供经过训练的模型。 为此,我们只需将推文与流水线配合,以创建新模型。 代码如下:

model = pipeline.fit(tweets, labels)

注意

请注意,我们此处并没有真正评估模型,因此我们不需要在训练/测试拆分方面格外小心。 但是,在将这些功能付诸实践之前,应该对单独的测试拆分进行评估。 为了清楚起见,我们在这里跳过该内容。

管道使您可以通过named_steps属性和步骤名称访问各个步骤(在创建管道对象本身时,我们自己定义了这些名称)。 例如,我们可以获得朴素贝叶斯模型:

nb = model.named_steps['naive-bayes']

从这个模型中,我们可以提取每个单词的概率。 这些作为对数概率存储,即 log(P(A | f)),其中f是给定特征。

之所以将它们存储为对数概率,是因为实际值非常低。 例如,第一个值是-3.486,它与低于 0.03%的概率相关。 对数概率用于涉及这样的小概率的计算中,因为它们会阻止下溢错误,在这种情况下,很小的值会四舍五入为零。 假定所有概率都相乘,那么单个值 0 将导致整个答案始终为 0! 无论如何,值之间的关系仍然相同。 值越高,该功能越有用。

通过对对数概率数组进行排序,我们可以获得最有用的功能。 我们想要降序,因此我们只需要先取反值即可。 代码如下:

top_features = np.argsort(-feature_probabilities[1])[:50]

前面的代码只会给我们提供索引,而不是实际的特征值。 这不是很有用,因此我们将要素的索引映射到实际值。 关键是管道的DictVectorizer步骤,它为我们创建了矩阵。 幸运的是,它还记录了映射,使我们能够查找与不同列相关的要素名称。 我们可以从管道的那一部分中提取特征:

dv = model.named_steps['vectorizer']

在这里,我们可以通过在DictVectorizerfeature_names_属性中查找顶级功能部件的名称来打印它们。 在新单元格中输入以下行,然后运行以打印出一些主要功能:

for i, feature_index in enumerate(top_features):
    print(i, dv.feature_names_[feature_index], np.exp(feature_probabilities[1][feature_index]))

前几个功能包括:http#@。 根据我们收集的数据,这很可能是噪音(尽管在编程之外,冒号的使用并不常见)。 收集更多数据对于解决这些问题至关重要。 通过列表,我们可以看到许多更明显的编程功能:

7 for 0.188679245283
11 with 0.141509433962
28 installing 0.0660377358491
29 Top 0.0660377358491
34 Developer 0.0566037735849
35 library 0.0566037735849
36 ] 0.0566037735849
37 [ 0.0566037735849
41 version 0.0471698113208
43 error 0.0471698113208

还有在工作环境中也提到 Python,因此可能是指编程语言(尽管自由蛇处理程序也可能使用类似的术语,但它们在 Twitter 上并不常见):

22 jobs 0.0660377358491
30 looking 0.0566037735849
31 Job 0.0566037735849
34 Developer 0.0566037735849
38 Freelancer 0.0471698113208
40 projects 0.0471698113208
47 We're 0.0471698113208

最后一个通常采用以下格式:我们正在寻找该职位的候选人

浏览这些功能会给我们带来很多好处。 我们可以训练人们认识这些推文,寻找共同点(可以深入了解某个主题),甚至摆脱毫无意义的功能。 例如,单词 RT 在此列表中显得很高。 但是,这是推特上常见的 Twitter 短语(即在其他人的推文上转发)。 专家可以决定从列表中删除该单词,从而使分类器因数据集较小而较不容易受到噪声的影响。

Getting useful features from models

Getting useful features from models

Getting useful features from models

Getting useful features from models

查找子图

从我们的相似度函数中,我们可以简单地为每个用户对结果进行排名,并以最相似的用户作为推荐来返回-就像我们对产品推荐一样。 相反,我们可能想要查找彼此相似的用户集群。 我们可以建议这些用户成立一个小组,针对该细分受众群创建广告,甚至只是使用这些集群自己进行推荐。

查找相似用户的这些集群是一项称为集群分析的任务。 这是一项艰巨的任务,具有分类任务通常没有的复杂性。 例如,评估分类结果相对容易-我们将结果与基本事实进行比较(来自我们的训练集),然后查看正确的百分比。 但是,对于聚类分析,通常没有基本事实。 基于一些先入为主的概念,我们通常会评估集群是否有意义,这取决于集群的外观。 聚类分析的另一个复杂之处在于,无法针对预期的学习结果训练模型-它必须基于聚类的数学模型使用一些近似值,而不是用户希望从分析中获得的结果。

连接的组件

最简单的聚类方法之一是在图中找到连接的组件。 连接的组件是图中通过边连接的一组节点。 并非所有节点都需要彼此连接才能成为连接的组件。 但是,要使两个节点位于同一连接的组件中,就需要一种从该连接的组件中的一个节点移动到另一个节点的方法。

注意

连接的组件在计算时不考虑边缘权重; 他们只检查边缘的存在。 因此,后面的代码将删除重量较轻的任何边缘。

NetworkX 具有用于计算连接的组件的功能,可以在图形上调用它。 首先,我们使用create_graph函数创建一个新图形,但是这次我们将的阈值设置为 0.1,以仅获得权重至少为 0.1 的那些边。

G = create_graph(friends, 0.1)

然后,我们使用 NetworkX 在图中找到连接的组件:

sub_graphs = nx.connected_component_subgraphs(G)

为了使了解图的大小,我们可以遍历各组并打印出一些基本信息:

for i, sub_graph in enumerate(sub_graphs):
    n_nodes = len(sub_graph.nodes())
    print("Subgraph {0} has {1} nodes".format(i, n_nodes))

结果将告诉您每个连接的组件有多大。 我的结果有一个大子图,其中包含 62 个用户,而很多小子中,则有 12 个或更少的用户。

我们可以更改阈值以更改连接的组件。 这是因为较高的阈值具有较少的连接节点的边缘,因此将具有较小的连接组件,并且连接的组件更多。 我们可以通过以更高的阈值运行前面的代码来看到这一点:

G = create_graph(friends, 0.25)
sub_graphs = nx.connected_component_subgraphs(G)
for i, sub_graph in enumerate(sub_graphs):
    n_nodes = len(sub_graph.nodes())
    print("Subgraph {0} has {1} nodes".format(i, n_nodes))

前面的代码为我们提供了更小的节点和更多的节点。 我最大的群集至少分为三个部分,并且这些群集中的任何一个都没有超过 10 个用户。 下图显示了一个示例集群,并且还显示了该集群内的连接。 请注意,由于它是一个连接的组件,因此从该组件中的节点到图形中的其他节点之间没有任何边(至少将阈值设置为 0.25):

Connected components

我们也可以用绘制整个集合的图形,以不同的颜色显示每个连接的组件。 由于这些连接的组件未相互连接,因此将它们绘制在单个图形上实际上没有任何意义。 这是因为节点和组件的位置是任意的,并且可能使可视化混乱。 相反,我们可以将它们分别绘制在单独的子图上。

在新单元格中,获取连接的组件以及连接的组件的数量:

sub_graphs = nx.connected_component_subgraphs(G)
n_subgraphs = nx.number_connected_components(G)

注意

sub_graphs是一个生成器,而不是已连接组件的列表。 因此,请使用nx.number_connected_components找出有多少个已连接的组件。 不要使用len,因为 NetworkX 存储此信息的方式不起作用。 这就是为什么我们需要在此处重新计算连接的组件的原因。

创建一个新的图形,并为提供足够的空间来显示我们所有连接的组件。 因此,我们允许图形随着连接的组件数的增加而增大:

fig = plt.figure(figsize=(20, (n_subgraphs * 3)))

接下来,遍历每个连接的组件并为每个组件添加一个子图。 add_subplot的参数是我们感兴趣的子图的行数,列数和子图的索引。我的可视化使用三列,但是您可以尝试其他值而不是三列(记住要更改 两个值):

for i, sub_graph in enumerate(sub_graphs):
    ax = fig.add_subplot(int(n_subgraphs / 3), 3, i)

默认情况下,pyplot显示带有轴标签的图,在这种情况下是没有意义的。 因此,我们关闭标签:

    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)

然后,我们绘制节点和边(使用ax参数绘制到正确的子图)。 为此,我们还需要首先设置一个布局:

    pos = nx.spring_layout(G)
    nx.draw_networkx_nodes(G, pos, sub_graph.nodes(), ax=ax, node_size=500)
    nx.draw_networkx_edges(G, pos, sub_graph.edges(), ax=ax)

结果直观地显示了每个连接的组件,使我们对每个组件中的节点数量以及它们之间的连接方式有了一定的了解。

Connected components

优化标准

我们用于查找这些连接组件的算法依赖于阈值参数,该阈值参数指示是否将边缘添加到图形中。 反过来,这直接决定了我们发现多少个相连的组件以及它们的大小。 从这里开始,我们可能想要确定使用最佳阈值的一些概念。 这是一个非常主观的问题,没有明确的答案。 这是任何集群分析任务的主要问题。

但是,我们可以确定我们认为好的解决方案应该是什么样的,并根据该想法定义一个指标。 通常,我们通常需要以下解决方案:

  • 同一簇(连接的组件)中的样本彼此之间高度相似 ** 不同簇中的样本彼此之间高度不同*

*Silhouette Coefficient是量化这些点的度量。 给定一个样本,我们将轮廓系数定义如下:

Optimizing criteria

其中a集群内距离或到样本集群中与其他样本的平均距离,b是集群间距离 或下一个簇中与其他样本的平均距离。

为了计算总体轮廓系数,我们取每个样本的轮廓平均值。 提供接近最大 1 的轮廓系数的聚类,其聚类具有彼此相似的样本,并且这些聚类非常分散。 接近 0 的值表示聚类全部重叠,并且聚类之间几乎没有区别。 接近最小值-1 的值表示样本可能位于错误的群集中,也就是说,在其他群集中效果最好。

使用此度量,我们希望找到一种解决方案(即阈值),该解决方案通过更改阈值参数来最大化轮廓系数。 为此,我们创建一个将阈值作为参数并计算轮廓系数的函数。

然后,将其传递到 SciPy 的optimize模块中,该模块包含minimize函数,该函数用于通过更改参数之一来查找函数的最小值。 尽管我们对最大化轮廓系数感兴趣,但是 SciPy 没有最大化功能。 取而代之的是,我们最小化 Silhouette 的逆(基本上是同一件事)。

scikit-learn 库具有用于计算轮廓系数sklearn.metrics.silhouette_score的功能; 但是,它不能解决 SciPy minimize函数所需的函数格式。 最小化函数要求变量参数为第一个(在我们的示例中为阈值),而任何参数都在其后。 在我们的例子中,我们需要将friends字典作为参数传递,以计算图形。 代码如下:

def compute_silhouette(threshold, friends):

然后,我们使用 threshold 参数创建图,并检查它是否至少包含一些节点:

    G = create_graph(friends, threshold=threshold)
    if len(G.nodes()) < 2:

除非至少有两个节点(以便完全计算距离),否则不定义轮廓系数。 在这种情况下,我们将问题范围定义为无效。 有几种方法可以解决此问题,但最简单的方法是返回非常差的分数。 在我们的示例中,轮廓系数可以采用的最小值是-1,我们将返回-99 表示无效问题。 任何有效的解决方案都将获得更高的分数。 代码如下:

        return -99 

然后,我们提取连接的组件:

    sub_graphs = nx.connected_component_subgraphs(G)

仅当我们具有至少两个连接的组件(以计算群集间距离)并且这些连接的组件中的至少一个具有两个成员(以计算群集内距离)时,才定义 Silhouette。 我们对这些情况进行测试,如果不合适,则返回无效的问题分数。 代码如下:

    if not (2 <= nx.number_connected_components() < len(G.nodes()) - 1):
        return -99

接下来,我们需要获取指示每个样本放置在哪个连接组件上的标签。我们在所有连接的组件上进行迭代,并在字典中注明哪个用户属于哪个连接的组件。 代码如下:

label_dict = {}
for i, sub_graph in enumerate(sub_graphs):
    for node in sub_graph.nodes():
        label_dict[node] = i

然后,我们遍历图中的节点以按顺序获取每个节点的标签。 我们需要执行此两步过程,因为节点在图中没有明确排序,但是只要不对图进行任何更改,它们就可以维持其顺序。 这意味着在更改图表之前,我们可以在图表上调用.nodes()以获得相同的顺序。 代码如下:

labels = np.array([label_dict[node] for node in G.nodes()])

接下来,轮廓系数函数采用距离矩阵,而不是。 解决这个问题是另外两个步骤。 首先,NetworkX 提供了一个方便的函数to_scipy_sparse_matrix,该函数以可以使用的矩阵格式返回图形:

X = nx.to_scipy_sparse_matrix(G).todense()

在撰写本文时,scikit-learn 中的 Silhouette Coefficient 实现不支持稀疏矩阵。 因此,我们需要调用todense()函数。 通常,这是个坏主意-通常使用稀疏矩阵,因为数据通常不应采用密集格式。 在这种情况下,会很好,因为我们的数据集相对较小; 但是,不要对较大的数据集尝试这样做。

注意

为了评估稀疏数据集,我建议您研究 V 度量或调整后的相互信息。 这些都是在 scikit-learn 中实现的,但是它们具有非常不同的参数来执行评估。

但是,这些值基于我们的权重,这是相似的,而不是距离。 对于距离,值越高表示差异越大。 我们可以从最大可能值中减去该值,从而将相似度转换为距离,这对于我们的权重为 1:

    X = 1 - X

现在我们有了距离矩阵和标签,因此我们有了计算轮廓系数所需的所有信息。 我们将度量传递为precomputed; 否则,矩阵 X 将被视为特征矩阵,而不是距离矩阵(默认情况下,在 scikit-learn 中几乎所有地方都使用特征矩阵)。 代码如下:

    return silhouette_score(X, labels, metric='precomputed')

注意

我们在这里发生两种形式的反转。 首先是采用相似度的逆函数来计算距离函数。 这是必需的,因为轮廓系数仅接受距离。 第二个是轮廓系数得分的倒数,因此我们可以使用 SciPy 的optimize模块最小化。

但是,我们有一个小问题。 此函数返回轮廓系数,该系数是一个得分,其中较高的值被认为更好。 Scipy 的optimize模块仅定义了minimize函数,该函数在分数较低的loss函数上起作用。 我们可以通过反转值来解决此问题,该值采用score函数并返回loss函数。

def inverted_silhouette(threshold, friends):
    return -compute_silhouette(threshold, friends)

该功能从原始功能创建了一个新功能。 调用新函数时,所有相同的参数和关键字都传递到原始函数上,并返回返回值,但在返回之前取反该返回值。

现在我们可以进行实际的优化了。 我们在定义的反向compute_silhouette函数上调用minimize函数:

result = minimize(inverted_silhouette, 0.1, args=(friends,))

参数如下:

  • invert(compute_silhouette):这是我们要最小化的功能(记住,我们将其反转以使其变为损失功能)
  • 0.1:这是在某个阈值处的初始猜测,该阈值将使功能最小化
  • options={'maxiter':10}:这表明仅要执行 10 次迭代(增加迭代次数可能会得到更好的结果,但运行时间会更长)
  • method='nelder-mead':用于选择 Nelder-Mead 优化路由(SciPy 支持很多不同的选项)
  • args=(friends,):这会将friends词典传递给正在最小化的函数

注意

此功能将需要一段时间才能运行。 我们的图形创建功能不是那么快,计算轮廓系数的功能也不是那么快。 减小maxiter值将导致执行的迭代次数减少,但是我们冒着找到次优解决方案的风险。

运行此函数,我得到的阈值为 0.135,该阈值返回 10 个组件。 最小化函数返回的分数为-0.192。 但是,我们必须记住,我们否定了这个价值。 这意味着我们的分数实际上是 0.192。 值是正数,表示群集比没有群集的分离程度更高(这是一件好事)。 我们可以运行其他模型,并检查其结果是否更好,这意味着群集可以更好地分离。

我们可以使用结果来推荐用户-如果一个用户在连接的组件中,那么我们可以推荐该组件中的其他用户。 在此建议之前,我们使用了“杰卡德相似性”来找到用户之间的良好连接,使用了连接的组件将它们分成多个集群,并使用了优化技术来找到此设置中的最佳模型。

但是,可能根本没有连接大量用户,因此我们将使用其他算法为他们找到集群。

Optimizing criteria

Optimizing criteria

Optimizing criteria

Optimizing criteria*

二十九、使用神经网络击败验证码

长期以来,解释图像中包含的信息一直是数据挖掘中的难题,但这是一个真正开始得到解决的问题。 最新的研究正在提供算法,以检测和理解图像,直到主要供应商在现实世界中使用自动化商业监视系统。 这些系统能够理解和识别录像中的物体和人物。

从图像中提取信息是困难的。 图像中有许多原始数据,而对图像进行编码的标准方法(像素)本身并不能提供足够的信息。 图像(尤其是照片)可能会模糊,太靠近目标,太暗,太亮,缩放,裁剪,偏斜或其他各种问题,这些问题会给试图提取有用信息的计算机系统造成破坏。

在本章中,我们着眼于使用神经网络预测每个字母从图像中提取文本。 我们正在尝试解决的问题是自动理解 CAPTCHA 消息。 根据首字母缩写词:CAPTCHA 是旨在使人类易于解决而计算机难以解决的图像:全自动公共图灵测试,用于区分计算机和人类。 许多网站使用它们进行注册和评论系统,以阻止自动化程序用假帐户和垃圾评论充斥其网站。

本章涵盖的主题包括:

  • 神经网络
  • 创建我们自己的验证码和字母数据集
  • scikit-image 库,用于处理图像数据
  • 神经网络的 PyBrain 库
  • 从图像中提取基本特征
  • 使用神经网络进行大规模分类任务
  • 使用后处理提高性能

人工神经网络

神经网络是一类算法,最初是根据人脑的工作方式设计的。 但是,现代技术通常基于数学而非生物学见解。 神经网络是连接在一起的神经元的集合。 每个神经元都是其输入的简单函数,它生成一个输出:

Artificial neural networks

定义神经元处理的函数可以是任何标准函数,例如输入的线性组合,称为激活函数。 为了使常用的学习算法正常工作,我们需要激活函数是可导出的且平滑的。 常用的激活函数是逻辑函数,由以下等式定义(k通常简单为 1,x是神经元的输入,L [ 通常为 1,即该函数的最大值):

Artificial neural networks

该图的值从-6 到+6,如下所示:

Artificial neural networks

红线表示x为零时的值为 0.5。

每个单独的神经元接收其输入,然后根据这些值计算输出。 神经网络只是将这些神经元连接在一起的网络,它们对于数据挖掘应用可能非常强大。 这些神经元的组合,它们如何组合在一起以及如何组合以学习模型是机器学习中最强大的概念之一。

神经网络介绍

对于数据挖掘应用,神经元的排列通常位于层中。 第一层是输入层,它从数据集中获取输入。 计算每个神经元的输出,然后将其传递到下一层中的神经元。 这称为前馈 神经网络。 在本章中,我们将这些简称为神经网络。 还有其他类型的神经网络可用于不同的应用。 我们将在第 11 章,“使用深度学习”对图像中的对象进行分类中看到另一种类型的网络。

一层的输出变为下一层的输入,一直持续到到达最后一层:输出层为止。 这些输出将神经网络的预测表示为分类。 输入层和输出层之间的任何神经元层都被称为隐藏层,因为他们学习了人类无法直观理解的数据表示。 大多数神经网络至少具有三层,尽管大多数现代应用使用的网络要多得多。

An introduction to neural networks

通常,我们考虑完全连接的层。 一层中每个神经元的输出将到达下一层中的所有神经元。 虽然我们确实定义了一个完全连接的网络,但在训练过程中许多权重将被设置为零,从而有效地删除了这些链接。 完全连接的神经网络也比其他连接模式更简单,更有效地编程。

由于神经元的功能通常是逻辑功能,并且神经元完全连接到下一层,因此用于构建和训练神经网络的参数必须是其他因素。 神经网络的第一个因素是在构建阶段:神经网络的大小。 这包括神经网络有多少层以及每个隐藏层中有多少神经元(输入和输出层的大小通常由数据集决定)。

在训练阶段确定神经网络的第二个参数:神经元之间连接的权重。 当一个神经元连接到另一个神经元时,该连接具有关联的权重,该权重乘以信号(第一个神经元的输出)。 如果连接的权重为 0.8,则神经元被激活,并输出值 1,下一个神经元的输入结果为 0.8。 如果第一个神经元未激活且值为 0,则保持为 0。

适当大小的网络和训练有素的权重的结合确定了进行分类时神经网络的准确性。 “适当地”一词也不一定意味着更大,因为太大的神经网络需要花费很长时间进行训练,并且更容易过拟合训练数据。

注意

通常从一开始就随机设置权重,然后在训练阶段进行权重更新。

现在,我们有了一个分类器,该分类器具有要设置的初始参数(网络的大小)和要从数据集中训练的参数。 然后,分类器可用于基于输入来预测数据样本的目标,这与我们在前几章中使用的分类算法非常相似。 但是首先,我们需要一个数据集进行训练和测试。

创建数据集

在本章中,我们将扮演坏蛋的角色。 我们要创建一个可以击败验证码的程序,从而使我们的垃圾评论程序能够在某人的网站上做广告。 应当指出,我们的验证码将比今天在网络上使用的验证码要容易一些,并且垃圾邮件并不是一件好事。

我们的验证码将仅是四个字母的单个英文单词,如下图所示:

Creating the dataset

我们的目标将是创建一个程序,可以从这样的图像中恢复单词。 为此,我们将使用四个步骤:

  1. 将图像分成单个字母。
  2. 对每个单独的字母进行分类。
  3. 重新组合字母以形成单词。
  4. 使用字典对单词进行排名,以尝试纠正错误。

我们的验证码消除算法将做出以下假设。 首先,该单词将是一个完整且有效的四字符英文单词(实际上,我们使用相同的词典来创建和删除验证码)。 其次,单词将仅包含大写字母。 不会使用符号,数字或空格。 我们将使问题更加棘手:我们将执行剪切转换为文本,以及不同的剪切速率。

绘制基本的验证码

接下来,我们开发用于创建验证码的功能。 我们的目标是绘制带有单词的图像以及剪切变换。 我们将使用PIL库绘制我们的验证码,并使用scikit-image库执行剪切变换。 scikit-image库可以读取PIL可以导出到的 NumPy 数组格式的图像,从而允许我们使用这两个库。

注意

PILscikit-image均可通过pip安装:

pip install PIL
pip install scikit-image

首先,我们导入必要的库和模块。 我们导入 NumPy 和Image绘图函数,如下所示:

import numpy as np
from PIL import Image, ImageDraw, ImageFont
from skimage import transform as tf

然后,我们创建我们的基本函数以生成验证码。 此函数需要一个单词和一个剪切值(通常在 0 到 0.5 之间)以 NumPy 数组格式返回图像。 我们允许用户设置结果图像的大小,因为我们还将将此功能用于单字母训练样本。 代码如下:

def create_captcha(text, shear=0, size=(100, 24)):

我们使用 L 作为格式创建一个新图像,即仅表示黑白像素,然后创建ImageDraw类的实例。 这使我们可以使用PIL绘制此图像。 代码如下:

    im = Image.new("L", size, "black")
    draw = ImageDraw.Draw(im)

接下来,我们设置将使用的验证码的字体。 您将需要一个字体文件,并且以下代码(Coval.otf)中的文件名应指向该文件(我只是将文件放置在Notebook's目录中。

    font = ImageFont.truetype(r"Coval.otf", 22)
    draw.text((2, 2), text, fill=1, font=font)

注意

您可以从这个页面的开放字体库中获得我使用的 Coval 字体。

我们将 PIL 图像转换为 NumPy 数组,这使我们可以使用scikit-image对它进行剪切。 scikit-image库在其大部分计算中倾向于使用 NumPy 数组。 代码如下:

    image = np.array(im)

然后,我们应用剪切变换并返回图像:

    affine_tf = tf.AffineTransform(shear=shear)
    image = tf.warp(image, affine_tf)
    return image / image.max()

在最后一行,我们通过除以最大值进行归一化,确保特征值在 0 到 1 的范围内。此归一化可以发生在数据预处理阶段,分类阶段或其他地方。

从这里,我们现在可以很容易地生成图像并使用pyplot来显示它们。 首先,我们将在线显示用于matplotlib图并导入pyplot。 代码如下:

%matplotlib inline 
from matplotlib import pyplot as plt

然后,我们创建我们的第一个 CAPTCHA 并显示它:

image = create_captcha("GENE", shear=0.5)
plt.imshow(image, cmap='Greys')

结果是本节开头显示的图像:我们的 CAPTCHA。

将图像分割成单个字母

我们的验证码是单词。 与其构建可以识别成千上万个可能单词的分类器,不如将问题分解为一个较小的问题:预测字母。

击败这些验证码的算法的下一步涉及对单词进行分段以发现其中的每个字母。 为此,我们将创建一个函数,以查找图像上黑色像素的连续部分并将其提取为子图像。 这些是(或至少应该是)我们的来信。

首先,我们导入labelregionprops函数,我们将在此函数中使用它们:

from skimage.measure import label, regionprops

我们的函数将拍摄一张图像,并返回一个子图像列表,其中每个子图像都是图像中原始单词的字母:

def segment_image(image):

我们需要做的第一件事是检测每个字母在哪里。 为此,我们将使用scikit-image中的标签功能,该功能可找到具有相同值的相连像素集。 这类似于我们在第 7 章,“使用图形挖掘”中发现要遵循的帐户中的连接组件发现。

label函数拍摄图像并返回与原始形状相同的数组。 但是,每个连接区域在阵列中具有不同的编号,不在连接区域中的像素的值为 0。代码如下:

    labeled_image = label(image > 0)

我们将提取每个子图像并将其放入列表中:

    subimages = []

scikit-image库还包含用于提取有关以下区域的信息的功能:regionprops。 我们可以遍历这些区域并分别处理每个区域:

    for region in regionprops(labeled_image): 

从这里,我们可以查询region对象以获取有关当前区域的信息。 对于我们的算法,我们需要获取当前区域的开始和结束坐标:

        start_x, start_y, end_x, end_y = region.bbox

然后,我们可以通过使用子图像的开始位置和结束位置对图像进行索引(请记住将其表示为简单的 NumPy 数组,以便我们可以轻松对其进行索引)来提取子图像,并将所选子图像添加到 我们的清单。 代码如下:

        subimages.append(image[start_x:end_x,start_y:end_y])

最后(在循环外部),我们返回发现的子图像,每个子图像(希望是)都包含图像中带有单个字母的部分。 但是,如果未找到任何子图像,则仅将原始图像作为唯一的子图像返回。 代码如下:

    if len(subimages) == 0:
        return [image,]
    return subimages

然后,我们可以使用以下函数从示例验证码中获取子图像:

subimages = segment_image(image)

我们还可以查看以下每个子图像:

f, axes = plt.subplots(1, len(subimages), figsize=(10, 3))
for i in range(len(subimages)):
    axes[i].imshow(subimages[i], cmap="gray")

结果将如下所示:

Splitting the image into individual letters

正如您所看到的,我们的图像分割做得很合理,但是结果仍然很混乱,显示了一些先前的字母。

创建训练数据集

使用此功能,我们现在可以创建字母的数据集,每个字母具有不同的剪切值。 由此,我们将训练一个神经网络来识别图像中的每个字母。

首先,我们设置随机状态和一个数组,其中包含我们将从中随机选择的字母和剪切值的选项。 这里没有什么奇怪的,但是如果您以前没有使用过 NumPy 的arange函数,则它类似于 Python 的range函数-区别在于该函数可与 NumPy 数组一起使用并使步骤成为浮点数。 代码如下:

from sklearn.utils import check_random_state
random_state = check_random_state(14)
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
shear_values = np.arange(0, 0.5, 0.05)

然后,我们创建一个函数(用于在训练数据集中生成单个样本),该函数从可用选项中随机选择一个字母和一个剪切值。 代码如下:

def generate_sample(random_state=None):
    random_state = check_random_state(random_state)
    letter = random_state.choice(letters)
    shear = random_state.choice(shear_values)

然后,我们返回字母的图像以及代表图像中字母的目标值。 我们的班级对于 A 来说是 0,对于 B 来说是 1,对于 C 来说是 2,依此类推。 代码如下:

    return create_captcha(letter, shear=shear, size=(20, 20)), letters.index(letter)

在功能块之外,我们现在可以调用此代码以生成新的示例,然后使用pyplot进行显示:

image, target = generate_sample(random_state)
plt.imshow(image, cmap="Greys")
print("The target for this image is: {0}".format(target))

现在,我们可以通过调用数千次来生成所有数据集。 然后,我们将数据放入 NumPy 数组中,因为它们比列表更易于使用。 代码如下:

dataset, targets = zip(*(generate_sample(random_state) for i in range(3000)))
dataset = np.array(dataset, dtype='float')
targets =  np.array(targets)

我们的目标是 0 到 26 之间的整数值,每个值代表一个字母。 神经网络通常不支持单个神经元的多个值,而是更喜欢具有多个输出,每个输出的值为 0 或 1。因此,我们对目标执行一次热编码,从而为我们提供了一个目标数组,每个样本具有 26 个输出 ,如果该字母很可能使用接近 1 的值,否则使用接近 0 的值。 代码如下:

from sklearn.preprocessing import OneHotEncoder
onehot = OneHotEncoder()
y = onehot.fit_transform(targets.reshape(targets.shape[0],1))

我们将要使用的库不支持稀疏数组,因此我们需要将稀疏矩阵转换为密集的 NumPy 数组。 代码如下:

y = y.todense()

根据我们的方法调整训练数据集

我们的训练数据集与我们的最终方法有很大不同。 我们的数据集是精心创建的单个字母,适合 20 像素乘 20 像素的图像。 该方法包括从单词中提取字母,这可能会挤压它们,将其移离中心或产生其他问题。

理想情况下,您训练分类器所使用的数据应模拟将要使用的环境。在实践中,我们做出了让步,但目的是尽可能地减少差异。

对于此实验,理想情况下,我们将从实际的验证码中提取字母并将其标记。 为了稍微加快该过程,我们将在训练数据集上运行分段功能,然后返回这些字母。

我们将需要scikit-image中的resize函数,因为我们的子图像并不总是 20 像素乘 20 像素。 代码如下:

from skimage.transform import resize

从这里开始,我们可以对每个样本运行segment_image函数,然后将它们的大小调整为 20 x 20 像素。 代码如下:

dataset = np.array([resize(segment_image(sample)[0], (20, 20)) for sample in dataset])

最后,我们将创建我们的数据集。 此dataset数组是三维的,因为它是二维图像的数组。 我们的分类器将需要一个二维数组,因此我们只需将最后两个维度展平:

X = dataset.reshape((dataset.shape[0], dataset.shape[1] * dataset.shape[2]))

最后,使用 scikit-learn 的train_test_split函数,我们创建了一组训练数据和一个测试数据。 代码如下:

from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = \
    train_test_split(X, y, train_size=0.9)

Adjusting our training dataset to our methodology

训练和分类

现在,我们将构建一个神经网络,该神经网络将图像作为输入并尝试预测图像中的哪个(单个)字母。

我们将使用我们之前创建的单个字母的训练集。 数据集本身非常简单。 我们有一个 20 x 20 像素的图像,每个像素 1(黑色)或 0(白色)。 这些代表我们将用作神经网络输入的 400 个功能。 输出将是 0 到 1 之间的 26 个值,其中较高的值表示关联字母(第一个神经元是 A,第二个神经元是 B,依此类推)是输入图像表示的字母的可能性更高。

我们将对神经网络使用 PyBrain 库。

注意

与到目前为止我们所看到的所有库一样,可以从pippip install pybrain安装 PyBrain。

PyBrain库使用其自己的数据集格式,但幸运的是,使用此格式创建训练和测试数据集并不难。 代码如下:

from pybrain.datasets import SupervisedDataSet

首先,我们在训练数据集上迭代,并将每个作为样本添加到新的SupervisedDataSet实例中。 代码如下:

training = SupervisedDataSet(X.shape[1], y.shape[1])
for i in range(X_train.shape[0]):
    training.addSample(X_train[i], y_train[i])

然后,我们遍历测试数据集,并将每个样本作为样本添加到新的SupervisedDataSet实例中进行测试。 代码如下:

testing = SupervisedDataSet(X.shape[1], y.shape[1])
for i in range(X_test.shape[0]):
    testing.addSample(X_test[i], y_test[i])

现在我们可以建立一个神经网络。 我们将创建一个基本的三层网络,该网络由输入层,输出层和它们之间的单个隐藏层组成。 输入层和输出层中神经元的数量是固定的。 数据集中的 400 个特征指示第一层需要 400 个神经元,而 26 个可能的目标指示我们需要 26 个输出神经元。

确定隐藏层中神经元的数量可能非常困难。 太多的结果会导致网络稀疏,这意味着很难训练足够的神经元来正确表示数据。 这通常会导致训练数据过拟合。 如果神经元中尝试进行太多分类的结果太少而又没有正确训练,则数据不足会成为问题。 我发现创建漏斗形状(中间层介于输入的大小和输出的大小之间)是一个很好的起点。 在本章中,我们将在隐藏层中使用 100 个神经元,但是使用此值可能会产生更好的结果。

我们导入buildNetwork函数,并告诉它根据我们的必要尺寸来构建网络。 第一个值X.shape[1]是输入层中神经元的数量,并设置为要素数量(这是X中的列数)。 第二个特征是我们确定的隐藏层中 100 个神经元的值。 第三个值是输出数量,它基于目标阵列y的形状。 最后,我们将网络设置为对每一层(输出层除外)使用偏向神经元,即有效地始终激活(但仍具有受过训练的权重的连接)的神经元。 代码如下:

from pybrain.tools.shortcuts import buildNetwork
net = buildNetwork(X.shape[1], 100, y.shape[1], bias=True)

现在,从,我们可以训练网络并为权重确定好的值。 但是我们如何训练神经网络呢?

反向传播

反向传播(反向传播)算法是一种将错误归因于错误预测的每个神经元的方法。 从输出层开始,我们计算哪些神经元的预测不正确,并以少量调整这些神经元的权重以尝试修正错误的预测。

这些神经元犯了错误,是因为神经元向它们提供了输入,但更具体地说,是由于神经元与其输入之间的连接权重。 然后,我们通过少量更改它们来更改这些权重。 变化量基于两个方面:神经元单个权重的误差函数的偏导数和学习率,这是算法的参数(通常设置为非常低的值)。 我们计算函数误差的梯度,将其乘以学习率,然后从权重中减去该梯度。 在下面的示例中显示。 取决于误差,该梯度将为正或负,并且减去权重将始终尝试将权重校正为正确的预测。 不过,在某些情况下,校正会朝着称为局部最优值的方向发展,该最优值优于类似的权重,但不是最佳的权重集。

此过程从输出层开始,然后返回每一层,直到我们到达输入层。 此时,所有连接上的权重已更新。

PyBrain 包含 backprop 算法的实现,该算法通过trainer类在神经网络上调用。 代码如下:

from pybrain.supervised.trainers import BackpropTrainer
trainer = BackpropTrainer(net, training, learningrate=0.01, weightdecay=0.01)

使用训练数据集反复运行反向传播算法,每次调整权重时都会进行一次。 当错误减少非常少时,我们可以停止运行 backprop,这表明该算法不能进一步改善错误,并且不值得继续进行训练。 在理论中,我们将运行算法,直到误差完全不改变为止。 这称为收敛,但是实际上这需要很长时间才能获得很少的收益。

或者,更简单地说,我们可以将算法运行固定的次数,称为时期。 纪元数越多,算法将花费的时间越长,结果越好(每个纪元的改进程度都在下降)。 我们将为此代码训练 20 个纪元,但尝试使用更大的值将提高性能(如果只是略微提高)。 代码如下:

trainer.trainEpochs(epochs=20)

在运行了之前的代码(可能需要花费几分钟的时间,具体取决于硬件)之后,我们可以对测试数据集中的样本进行预测。 PyBrain 包含一个为此功能,并在trainer实例上调用它:

predictions = trainer.testOnClassData(dataset=testing)

根据这些预测,我们可以使用scikit-learn计算 F1 分数:

from sklearn.metrics import f1_score 
print("F-score: {0:.2f}".format(f1_score(predictions,
                                         y_test.argmax(axis=1) )))

此处的分数是 0.97,对于这样一个相对简单的模型,这是一个不错的结果。 回想一下,我们的功能仅是简单的像素值; 神经网络找出了如何使用它们。

既然我们有了一个字母预测准确度很高的分类器,就可以开始为我们的验证码组合单词。

预测字

我们希望从这些段中的每个段中预测每个字母,并将这些预测放在一起以形成来自给定验证码的预测单词。

我们的函数将接受 CAPTCHA 和经过训练的神经网络,并将返回预测的单词:

def predict_captcha(captcha_image, neural_network):

我们首先使用先前创建的segment_image函数提取子图像:

    subimages = segment_image(captcha_image)

我们将从每个字母中建立我们的单词。 子图像是根据其位置排序的,因此通常这将以正确的顺序放置字母:

    predicted_word = ""

接下来,我们遍历子图像:

    for subimage in subimages:

每个子图像不太可能恰好是 20 像素乘 20 像素,因此我们将需要调整其大小,以便为神经网络拥有正确的尺寸。

        subimage = resize(subimage, (20, 20))

我们将通过将子图像数据发送到输入层来激活神经网​​络。 这会通过我们的神经网络传播并返回给定的输出。 所有这些都发生在我们之前对神经网络的测试中,但是我们不必显式调用它。 代码如下:

        outputs = net.activate(subimage.flatten())

神经网络的输出是 26 个数字,每个数字都与给定索引处的字母为预测字母的可能性有关。 为了获得实际的预测,我们获取这些输出的最大值的索引,并从之前的字母列表中查找实际字母。 例如,如果第五个输出的值最高,则预测字母将为E。 代码如下:

        prediction = np.argmax(outputs)

然后,我们将预测字母附加到要构建的预测单词上:

        predicted_word += letters[prediction]

循环完成后,我们遍历了每个字母并形成了我们预测的单词:

    return predicted_word

现在,我们可以使用以下代码对一个单词进行测试。 尝试使用不同的单词,看看会遇到什么样的错误,但是请记住,我们的神经网络只知道大写字母。

word = "GENE"
captcha = create_captcha(word, shear=0.2)
print(predict_captcha(captcha, net))

我们可以将其编码为一个函数,从而使我们可以更轻松地执行预测。 我们还利用了这样的假设,即单词只有四个字符,使预测更容易。 尝试不使用prediction = prediction[:4]行的情况,然后查看您得到的错误类型。 代码如下:

def test_prediction(word, net, shear=0.2):
    captcha = create_captcha(word, shear=shear)
    prediction = predict_captcha(captcha, net)
    prediction = prediction[:4]
    return word == prediction, word, prediction

返回的结果指定预测是否正确,原始单词和预测单词。

该代码可以正确预测单词 GENE,但会出错。 它有多精确? 为了进行测试,我们将创建一个数据集,其中包含来自 NLTK 的一串四个四个字母的英语单词。 代码如下:

from nltk.corpus import words

这里的words实例实际上是一个语料库对象,因此我们需要对其调用words()以从该语料库中提取单个单词。 我们还过滤以从此列表中仅获取四个字母的单词。 代码如下:

valid_words = [word.upper() for word in words.words() if len(word) == 4]

然后,我们可以遍历所有单词,只需简单地计算正确和不正确的预测就可以看到有多少正​​确的单词:

num_correct = 0
num_incorrect = 0
for word in valid_words:
    correct, word, prediction = test_prediction(word, net,
                                                shear=0.2)
if correct:
        num_correct += 1
    else:
        num_incorrect += 1
print("Number correct is {0}".format(num_correct))
print("Number incorrect is {0}".format(num_incorrect))

我们得到的结果是 2,832 正确和 2,681 不正确,准​​确度超过 51%。 从我们最初的每个字母 97%的准确性来看,这是一个很大的下降。 发生了什么?

影响的第一个因素是我们的准确性。 在所有其他条件相同的情况下,如果我们有四个字母,并且每个字母的准确性为 97%,那么我们可以期望大约 88%的成功率(在所有其他条件相同的情况下)连续四个字母(0.88≈0.974)。 单个字母的预测中的单个错误会导致预测错误的单词。

第二个影响是剪切值。 我们的数据集在 0 到 0.5 的剪切值之间随机选择。 先前的测试使用的剪切力为 0.2。 值为 0 时,我的准确度为 75%; 对于 0.5 的剪切,结果差得多,为 2.5%。 剪切力越高,性能越低。

接下来的影响是我们的字母是为数据集随机选择的。 实际上,这根本不是真的。 字母(例如 E)比其他字母(例如 Q)出现的频率要高得多。合理地普遍出现但经常被误认的字母也会导致这种错误。

我们可以使用混淆矩阵(二维数组)来确定哪些字母经常被误认为彼此。 它的行和列分别代表一个单独的类。

每个单元代表样品实际来自一类(由行表示)并被预测为处于第二类(由列表示)的次数。 例如,如果单元格(4,2)的值为 6,则意味着在六种情况下,带有字母 D 的样本被预测为字母 B。

from sklearn.metrics import confusion_matrix
cm = confusion_matrix(np.argmax(y_test, axis=1), predictions)

理想情况下,混淆矩阵应仅沿对角线具有值。 单元格(i, i)具有值,但其他单元格的值为零。 这表明预测的类别与实际的类别完全相同。 不在对角线上的值表示分类中的错误。

我们还可以使用pyplot对此进行绘制,以图形方式显示哪些字母相互混淆。 代码如下:

plt.figure(figsize=(10, 10))
plt.imshow(cm)

我们设置轴和刻度线以轻松引用每个索引对应的字母:

tick_marks = np.arange(len(letters))
plt.xticks(tick_marks, letters)
plt.yticks(tick_marks, letters)
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()

下图显示结果。 可以很清楚地看出,错误的主要根源是几乎每次都将 U 误认为 H!

Predicting words

在列表中 17%的单词中显示字母 U。 对于出现 U 的每个单词,我们可以预期这是错误的。 实际上,U 的出现频率比 H(大约占单词的 11%)多,这表明我们可以通过将任何 H 预测更改为 U 来廉价(尽管可能不是很健壮)地提高准确性。

在的下一部分中,我们将做一些更聪明的操作,并实际使用词典搜索相似的单词。

使用字典提高准确性

除了而不只是返回给定的预测,我们可以检查单词是否确实存在于我们的字典中。 如果是的话,那就是我们的预测。 如果字典中没有它,我们可以尝试找到一个与它相似的词,然后进行预测。 请注意,此策略基于我们的假设,即所有 CAPTCHA 单词都是有效的英语单词,因此该策略不适用于随机字符序列。 这就是某些验证码不使用单词的原因之一。

这里有一个问题-我们如何确定最接近的词? 有很多方法可以做到这一点。 例如,我们可以比较单词的长度。 具有相似长度的两个单词可以被认为更相似。 但是,如果单词在相同位置具有相同字母,我们通常认为它们是相似的。 这是编辑距离的来源。

单词排名机制

Levenshtein 编辑距离是比较两个短字符串的相似度的常用方法。 它不是可扩展性很高的,因此它不常用于很长的字符串。 编辑距离计算从一个单词到到另一个单词所需的步数。 这些步骤可以是以下三个操作之一:

  1. 在单词的任意位置插入一个新字母。
  2. 删除单词中的任何字母。
  3. 用一封信代替另一封信。

将第一个单词转换为第二个单词所需的最少操作数作为距离给出。 较高的值表示单词不太相似。

该距离在 NLTK 中以nltk.metrics.edit_distance的形式提供。 我们可以使用两个字符串来调用它,并返回编辑距离:

from nltk.metrics import edit_distance
steps = edit_distance("STEP", "STOP")
print("The number of steps needed is: {0}".format(steps))

当与不同的单词一起使用时,编辑距离非常接近许多人在直觉上会感觉到的相似单词。 编辑距离非常适合测试拼写错误,听写错误和名称匹配(您可以在其中轻松混合 Marc 和 Mark 拼写)。

但是,它不是很好。 我们并不真正期望字母会四处移动,只是将单个字母进行比较是错误的。 因此,我们将创建一个不同的距离度量标准,该距离度量标准只是在相同位置上不正确的字母数。 代码如下:

def compute_distance(prediction, word):
    return len(prediction) - sum(prediction[i] == word[i] for i in range(len(prediction)))

我们从预测字的长度(为四)中减去该值,以使其成为距离度量,其中较低的值表示这些字之间的相似度更高。

全部放在一起

现在,我们可以使用与以前相似的代码来测试改进的预测功能。 首先,我们定义一个预测,该预测也使用我们的有效单词列表:

from operator import itemgetter
def improved_prediction(word, net, dictionary, shear=0.2):
    captcha = create_captcha(word, shear=shear)
    prediction = predict_captcha(captcha, net)
    prediction = prediction[:4]

至此,代码与以前一样。 我们进行预测并将其限制为前四个字符。 但是,我们现在检查单词是否在字典中。 如果是,我们将其作为我们的预测。 如果不是,我们找到下一个最接近的词。 代码如下:

    if prediction not in dictionary:

我们计算字典中预测单词与其他单词之间的距离,并按距离排序(最低的优先)。 代码如下:

        distances = sorted([(word, compute_distance(prediction, word))
                            for word in dictionary], key=itemgetter(1))

然后,我们得到匹配度最高的单词(即距离最小的单词)并预测该单词:

        best_word = distances[0]
        prediction = best_word[0]

然后,我们像以前一样返回正确性,单词和预测:

    return word == prediction, word, prediction

以下代码突出显示了我们测试代码中的更改:

num_correct = 0
num_incorrect = 0
for word in valid_words:
    correct, word, prediction = improved_prediction(word, net, valid_words, shear=0.2)
    if correct:
        num_correct += 1
    else:
        num_incorrect += 1
print("Number correct is {0}".format(num_correct))
print("Number incorrect is {0}".format(num_incorrect))

前面的代码需要一段时间才能运行(计算所有距离都需要一些时间),但最终结果是正确的 3,037 个样本和错误的 2,476 个样本。 这是 55%的准确度,可提高 4 个百分点。 该改进之所以如此之低,是因为多个单词都具有相同的相似性,并且算法是在这组最相似的单词之间随机选择最佳。 例如,列表中的第一个单词 AANI(我刚刚选择了列表中的第一个单词,这是埃及神话中的狗头猿),具有 44 个候选单词,它们与该单词的距离相同。 从列表中选择正确单词的机会只有 1/44。

如果我们作弊并将预测视为正确,如果实际单词是最佳候选者中的任何一个,则我们会将 78%的预测评为正确(要查看此代码,请查看捆绑包中的代码)。

为了进一步改善结果,我们可以研究距离度量,也许使用来自混淆矩阵的信息来查找常见混淆字母或对此进行一些其他改进。 这种迭代式改进是许多数据挖掘方法的一个功能,它模仿了科学方法-有一个主意,对其进行测试,分析结果,然后使用该主意来改进下一个主意。

Putting it all together

Putting it all together

Putting it all together

Putting it all together

三十、作者归属

作者身份分析主要是一项文本挖掘任务,旨在仅基于作者的内容来确定有关作者的某些方面。 这可能包括年龄,性别或背景等特征。 在特定的作者归属任务中,我们旨在确定一组特定文档中谁是谁撰写的。 这是分类任务的典型案例。 在许多方面,作者权限分析任务是使用标准数据挖掘方法执行的,例如交叉折叠验证,特征提取和分类算法。

在本章中,我们将使用作者归属问题来拼凑我们在前几章中开发的数据挖掘方法的各个部分。 我们确定问题并讨论问题的背景和知识。 这使我们可以选择要提取的功能,从而为实现建立一个管道。 我们将测试两种不同类型的功能:功能词和字符 n-gram。 最后,我们将对结果进行深入分析。 我们将使用书籍数据集,然后使用非常混乱的真实电子邮件语料库。

我们将在本章中介绍的主题如下:

  • 功能工程以及功能根据应用的不同
  • 带着特定目标重新审视词袋模型
  • 特征类型和字符 n-gram 模型
  • 支持向量机
  • 清理凌乱的数据集以进行数据挖掘

将文件归给作者

作者身份分析在笔法中具有背景,是对作者写作风格的研究。 这个概念的基础是每个人学习语言的方式略有不同,并且衡量人们写作中的细微差别将使我们能够仅使用他们的写作内容来区分他们。

该问题历来是使用手动分析和统计数据执行的,这很好地表明了可以通过数据挖掘将其自动化。 现代作者分析研究几乎完全基于数据挖掘,尽管仍然有相当多的工作需要使用语言样式进行更多手动驱动的分析。

作者分析有个子问题,主要子问题如下:

  • 作者身份分析:这根据撰写内容确定作者的年龄,性别或其他特征。 例如,我们可以通过寻找说英语的特定方式来检测该人说英语的母语。
  • 作者身份验证:这检查该文档的作者是否也写了另一文档。 这个问题就是您在法律法庭上通常会想到的。 例如,将分析犯罪嫌疑人的写作风格(从内容上看),看其是否与赎金记录相符。
  • 作者身份聚类:这是作者身份验证的扩展,在中,我们使用聚类分析将来自大集合的文档分组为聚类,每个聚类由同一位作者编写。

但是,作者身份分析研究的最常见形式是作者身份归属,这是一种分类任务,我们试图预测一组作者中的哪位撰写了给定文档。

应用和用例

作者分析有许多用例。 的许多使用案例都涉及诸如验证作者身份,证明共享作者身份/出处或将社交媒体资料与实际用户链接等问题。

从历史的角度来看,我们可以使用作者身份分析来验证某些文档是否确实由其假定的作者撰写。 有争议的作者主张包括莎士比亚的一些戏剧,美国建国时期的联邦主义者论文以及其他历史著作。

单独的作者研究不能证明作者,但是可以提供支持或反对给定理论的证据。 例如,在测试给定十四行诗是否确实源于莎士比亚之前,我们可以分析莎士比亚的戏剧以确定他的写作风格。

一个更现代的用例是关联社交网络帐户的用例。 例如,恶意的在线用户可能在多个在线社交网络上设置帐户。 能够链接它们允许当局跟踪给定帐户的用户-例如,如果它正在骚扰其他在线用户。

过去使用的另一个示例是成为法庭上提供专家证词以确定给定人员是否撰写文档的支柱。 例如,犯罪嫌疑人可能被指控撰写骚扰他人的电子邮件。 作者分析的使用可以确定该人是否确实确实在写文件。 另一种基于法院的用途是解决版权被盗的主张。 例如,两名作者可能声称写过一本书,而作者身份分析可以提供证据证明可能的作者。

作者分析并不是万无一失的。 最近的一项研究发现,仅要求未受过训练的人隐藏其写作风格,就很难将文档赋予作者。 这项研究还研究了一种框架练习,要求人们以另一种人的风格写作。 事实证明,这种对他人的取证非常可靠,伪造的文件通常归因于被陷害的人。

尽管存在这些问题,作者身份分析仍在越来越多的领域中被证明是有用的,并且是一个有趣的数据挖掘问题,需要研究。

署名作者

作者身份归因是一项分类任务,通过该任务,我们具有一组候选作者,每个作者的一组文档(训练集)和一组未知作者的文档(测试集) 。 如果作者身份不明的文档肯定是属于其中一个候选者,我们将其称为封闭问题

Attributing authorship

如果我们不能确定,则将其称为未解决的问题。 但是,这种区别不仅仅针对作者归属-在实际训练中可能没有实际班级的任何数据挖掘应用都被认为是一个开放性问题,任务是查找候选作者或不选择任何作者。

Attributing authorship

在作者身份归属中,我们通常对任务有两个限制。 首先,我们仅使用文档中的内容信息,而不使用有关书写时间,交付时间,笔迹样式等的元数据。 有多种方法可以将这些不同类型的信息中的模型进行组合,但这通常不被认为是作者身份归属,它更是一种数据融合应用。

第二个限制是我们不关注文档主题。 相反,我们寻找更显着的功能,例如单词使用,标点符号和其他基于文本的功能。 这里的理由是,一个人可以写许多不同的主题,因此担心他们的写作主题不会模拟他们的实际作者风格。 查看主题词也可能导致训练数据过拟合-我们的模型可能会训练来自同一作者的文档,也可能针对同一主题。 例如,如果您要通过查看此模块来建模我的作者风格,则您可能会得出结论,数据挖掘表示我的风格,而实际上我也在其他主题上写作。

从这里开始,用于执行作者身份归因的管道看起来很像我们在第 6 章,“使用朴素贝叶斯”的社交媒体洞察中开发的管道。 首先,我们从文本中提取特征。 然后,我们对这些功能执行一些功能选择。 最后,我们训练分类算法以适合模型,然后将其用于预测文档的类(在这种情况下为作者)。

我们将在本章中介绍一些差异,主要与所使用的功能有关。 但是首先,我们将定义问题的范围。

获取数据

我们将在本章中使用的数据是 Gutenberg 项目在这个页面上的一组书籍,该书籍是公共领域文献作品的存储库。 我用于这些实验的书籍来自不同的作者:

  • 摊位 Tarkington(22 个标题)
  • 查尔斯·狄更斯(44 题)
  • 伊迪丝·内斯比特(10 个标题)
  • 亚瑟·柯南·道尔(51 题)
  • 马克·吐温(29 个标题)
  • 理查德·弗朗西斯·伯顿爵士(11 个冠军)
  • 埃米尔·加博里奥(10 标题)

总体而言,有 7 位作者撰写的 177 篇文档,为您提供了大量可参考的文字。 代码包中提供了标题的完整列表,以及下载链接和自动获取它们的脚本。

要下载这些书,我们使用请求库将文件下载到我们的数据目录中。 首先,设置数据目录并确保以下代码链接到该目录:

import os
import sys
data_folder = os.path.join(os.path.expanduser("~"), "Data", "books")

接下来,从代码包中运行脚本以从 Gutenberg 项目下载每本书。 这会将它们放置在此数据文件夹的相应子文件夹中。

要运行该脚本,请从代码包的Chapter 9文件夹中下载getdata.py脚本。 将其保存到notebooks文件夹,然后在新单元格中输入以下内容:

!load getdata.py

然后,从 IPython Notebook 的中,按 Shift + 输入以运行单元格。 这会将脚本加载到单元格中。 然后再次单击代码,然后按 Shift + 输入以运行脚本本身。 这将花费一些时间,但是它将打印一条消息以通知您它已完成。

在查看了这些文件之后,您会发现其中的许多文件都是非常混乱的-至少从数据分析的角度来看。 文件开始处有一个大型项目 Gutenberg 免责声明。 在进行分析之前,需要将其删除。

我们可以更改磁盘上的单个文件以删除这些内容。 但是,如果我们丢失数据怎么办? 我们将丢失所做的更改,并可能无法复制研究。 因此,我们将在加载文件时执行预处理-这使我们可以确保结果是可复制的(只要数据源保持不变)。 代码如下:

def clean_book(document):

我们首先将文档分成几行,因为我们可以通过开始和结束行来标识免责声明的开始和结束:

    lines = document.split("\n")

我们将遍历每一行。 我们寻找指示书的开始的线和指示书的结束的线。 然后,我们将介于两者之间的文本作为本书本身。 代码如下:

    start = 0
    end = len(lines)
    for i in range(len(lines)):
        line = lines[i]
        if line.startswith("*** START OF THIS PROJECT GUTENBERG"):
            start = i + 1
        elif line.startswith("*** END OF THIS PROJECT GUTENBERG"):
            end = i - 1

最后,我们将这些行与换行符连接在一起,以重新创建不带免责声明的书:

    return "\n".join(lines[start:end])

现在,从这里,我们可以创建一个函数,该函数加载所有书籍,执行预处理,并将其与每个作者的班级号一起返回。 代码如下:

import numpy as np

默认情况下,我们的函数签名使用包含每个包含实际书籍的子文件夹的父文件夹。 代码如下:

def load_books_data(folder=data_folder):

我们创建用于存储文档本身和作者类的列表:

    documents = []
    authors = []

然后,我们直接在父级中创建每个子文件夹的列表,因为脚本为每个作者创建了一个子文件夹。 代码如下:

    subfolders = [subfolder for subfolder in os.listdir(folder)
                  if os.path.isdir(os.path.join(folder, subfolder))]

接下来,我们遍历这些子文件夹,并使用enumerate为每个子文件夹分配一个数字:

    for author_number, subfolder in enumerate(subfolders):

然后,我们创建完整的子文件夹路径,并在该子文件夹中查找所有文档:

        full_subfolder_path = os.path.join(folder, subfolder)
        for document_name in os.listdir(full_subfolder_path):

对于每个文件,我们都将其打开,阅读其中的内容,对其进行预处理并将其附加到我们的文档列表中。 代码如下:

            with open(os.path.join(full_subfolder_path, document_name)) as inf:
                documents.append(clean_book(inf.read()))

我们还将分配给该作者的编号附加到我们的作者列表中,这将构成我们的课程:

                authors.append(author_number)

然后,我们返回文档和类(稍后将其转换为每个索引的 NumPy 数组):

    return documents, np.array(authors, dtype='int')

现在,我们可以使用以下函数调用获取我们的文档和类:

documents, classes = load_books_data(data_folder)

注意

该数据集很容易装入内存,因此我们可以一次加载所有文本。 如果整个数据集都不适合,则更好的解决方案是一次(或分批)从每个文档中提取特征,并将结果值保存到文件或内存矩阵中。

功能词

最早的一种功能,对于作者权分析仍然非常有效,一种功能是在词袋模型中使用功能词。 功能词是本身没有什么意义的词,但是创建(英语)句子是必需的。 例如,单词*,(其中*)实际上是仅由它们在句子中的作用而不是其含义定义的词。 将此内容与诸如 Tiger 之类的内容词进行对比,该词具有明确的含义,当在句子中使用时会调用大型猫的图像。

并非总是清楚地阐明功能词。 一个好的经验法则是选择使用频率最高的单词(在所有可能的文档中,而不仅仅是同一作者的文档)。 通常,单词使用得越频繁,对作者身份分析的效果就越好。 相反,单词使用的频率越少,基于内容的文本挖掘就越有用,例如在下一章中,我们将讨论不同文档的主题。

Function words

功能词的使用较少地由文档的内容来定义,而更多地由作者的决定来定义。 这使它们成为区分不同用户之间的作者特征的理想人选。 例如,虽然许多美国人特别关注和在句子中所用的之间的区别,但是来自其他国家(如澳大利亚)的人们对此不太关注。 这意味着一些澳大利亚人将倾向于只使用一个单词或另一个单词,而另一些澳大利亚人可能会使用,而更多。 这种差异加上成千上万的其他细微差异构成了作者身份的模型。**

计数功能字

我们可以使用第 6 章,“使用朴素贝叶斯”的社交媒体洞察力来使用CountVectorizer类对功能词进行计数。 此类可以通过词汇表传递,这是它将要查找的一组单词。 如果未传递词汇表(在Chapter 6的代码中我们未传递词汇表),则它将从数据集中学习该词汇表。 所有单词都在训练文档集中(取决于课程的其他参数)。

首先,我们建立功能词的词汇表,它只是包含每个功能词的列表。 确切地说,哪些词是功能词,哪些不该争论。 从公开发表的研究中,我发现这个列表相当不错:

function_words = ["a", "able", "aboard", "about", "above", "absent",
"according" , "accordingly", "across", "after", "against",
"ahead", "albeit", "all", "along", "alongside", "although",
"am", "amid", "amidst", "among", "amongst", "amount", "an",
"and", "another", "anti", "any", "anybody", "anyone",
"anything", "are", "around", "as", "aside", "astraddle",
"astride", "at", "away", "bar", "barring", "be", "because",
"been", "before", "behind", "being", "below", "beneath",
"beside", "besides", "better", "between", "beyond", "bit",
"both", "but", "by", "can", "certain", "circa", "close",
"concerning", "consequently", "considering", "could",
"couple", "dare", "deal", "despite", "down", "due", "during",
"each", "eight", "eighth", "either", "enough", "every",
"everybody", "everyone", "everything", "except", "excepting",
"excluding", "failing", "few", "fewer", "fifth", "first",
"five", "following", "for", "four", "fourth", "from", "front",
"given", "good", "great", "had", "half", "have", "he",
"heaps", "hence", "her", "hers", "herself", "him", "himself",
"his", "however", "i", "if", "in", "including", "inside",
"instead", "into", "is", "it", "its", "itself", "keeping",
"lack", "less", "like", "little", "loads", "lots", "majority",
"many", "masses", "may", "me", "might", "mine", "minority",
"minus", "more", "most", "much", "must", "my", "myself",
"near", "need", "neither", "nevertheless", "next", "nine",
"ninth", "no", "nobody", "none", "nor", "nothing",
"notwithstanding", "number", "numbers", "of", "off", "on",
"once", "one", "onto", "opposite", "or", "other", "ought",
"our", "ours", "ourselves", "out", "outside", "over", "part",
"past", "pending", "per", "pertaining", "place", "plenty",
"plethora", "plus", "quantities", "quantity", "quarter",
"regarding", "remainder", "respecting", "rest", "round",
"save", "saving", "second", "seven", "seventh", "several",
"shall", "she", "should", "similar", "since", "six", "sixth",
"so", "some", "somebody", "someone", "something", "spite",
"such", "ten", "tenth", "than", "thanks", "that", "the",
"their", "theirs", "them", "themselves", "then", "thence",
"therefore", "these", "they", "third", "this", "those",
"though", "three", "through", "throughout", "thru", "thus",
"till", "time", "to", "tons", "top", "toward", "towards",
"two", "under", "underneath", "unless", "unlike", "until",
"unto", "up", "upon", "us", "used", "various", "versus",
"via", "view", "wanting", "was", "we", "were", "what",
"whatever", "when", "whenever", "where", "whereas",
"wherever", "whether", "which", "whichever", "while",
"whilst", "who", "whoever", "whole", "whom", "whomever",
"whose", "will", "with", "within", "without", "would", "yet",
"you", "your", "yours", "yourself", "yourselves"]

现在,我们可以设置一个提取器来获取这些功能字的计数。 稍后我们将使用管道进行调整:

from sklearn.feature_extraction.text import CountVectorizer
extractor = CountVectorizer(vocabulary=function_words)

用功能词分类

接下来,我们导入我们的类。 这里唯一的新事物是支持向量机,我们将在下一节中介绍(目前,仅将其视为标准分类算法)。 我们导入 SVC 类,用于分类的 SVM 以及我们之前看到的其他标准工作流程工具:

from sklearn.svm import SVC
from sklearn.cross_validation import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn import grid_search

支持向量机采用许多参数。 就像我说的,在下一节中详细介绍之前,我们将在这里盲目使用。 然后,我们使用字典来设置要搜索的参数。 对于kernel参数,我们将尝试linearrbf。 对于C,我们将尝试使用值 1 和 10(这些参数的说明将在下一部分中介绍)。 然后,我们创建一个网格搜索以搜索以下参数以获得最佳选择:

parameters = {'kernel':('linear', 'rbf'), 'C':[1, 10]}
svr = SVC()
grid = grid_search.GridSearchCV(svr, parameters)

注意

高斯核(例如rbf)仅适用于大小合理的数据集,例如要素数量少于 10,000 个时。

接下来,我们建立一个管道,该管道使用CountVectorizer(仅使用功能词)以及使用 SVM 的网格搜索进行特征提取步骤。 代码如下:

pipeline1 = Pipeline([('feature_extraction', extractor),
                     ('clf', grid)
                     ])

接下来,我们应用cross_val_score来获得该管道的交叉验证分数。 结果为 0.811,这意味着大约有 80%的预测正确。 对于 7 位作者来说,这是一个很好的结果!

支持向量机

支持向量机SVM)是基于简单直观的思想的分类算法。 它仅在两个类之间执行分类(尽管我们可以将其扩展到更多类)。 假设我们的两个类可以用一条线分开,使得该线之上的任何点都属于一个类,而该线之下的任何点都属于另一类。 支持向量机找到这条线并将其用于预测,与线性回归的工作方式大致相同。 但是,SVM 找到用于分离数据集的最佳

在下图中,我们用三行分隔数据集:蓝色,黑色和绿色。 您会说哪个是最好的选择?

Support vector machines

凭直觉,人通常会选择蓝线作为最佳选项,因为这会最大程度地分离数据。 也就是说,它与每个类别中任何点的最大距离。

找到这条线是一个优化问题,其基础是找到边缘之间的最大距离的线。

注意

这些方程式的推导超出了本模块的范围,但我建议感兴趣的读者详细阅读这个页面的推导。 另外,您可以访问这个页面

使用 SVM 分类

在训练模型后,我们有一条最大边距的线。 然后,新样本的分类只是问一个问题:是落在该线之上还是之下? 如果它落在该线的上方,则被预测为一类。 如果在该线以下,则将其预测为另一类。

对于多个类,我们创建了多个 SVM-每个二进制分类器。 然后,我们使用多种策略中的任何一种将它们连接起来。 一种基本策略是为每个类别创建一个对所有分类器,我们在其中使用两个类别(给定的类别和所有其他样本)进行训练。 我们为每个类别执行此操作,并在一个新样本上运行每个分类器,并从每个类别中选择最佳匹配项。 在大多数 SVM 实现中,都会自动执行此过程。

我们在之前的代码中看到了两个参数:Ckernel。 我们将在下一节介绍kernel参数,但是C参数是安装 SVM 的重要参数。 C参数涉及分类器在过度拟合的风险下应旨在正确预测所有训练样本的程度。 选择较高的C值将找到一条边距较小的分隔线,目的是对所有训练样本进行正确分类。 选择较低的C值将导致分离线具有较大的余量-即使这意味着某些训练样本未正确分类。 在这种情况下,较低的C值表示过拟合的机会较小,存在选择通常较差的分离线的风险。

SVM(以其基本形式)的局限性在于它们仅分离线性可分离的数据。 如果没有数据该怎么办? 对于这个问题,我们使用内核。

内核

当数据无法线性分离时,诀窍是将其嵌入到高维空间中。 这意味着要花很多时间在细节上,这是要添加伪特征,直到数据可线性分离为止(如果添加了足够多的正确种类的特征,则总是会发生这种情况)。

诀窍在于,当找到最佳数据线以分离数据集时,我们通常会计算样本的内部生成量。 给定使用点积的功能,我们可以有效地制造新功能,而不必实际定义这些新功能。 这很方便,因为我们仍然不知道这些功能将是什么。 现在,我们将kernel定义为一个函数,该函数本身就是数据集中两个样本的函数的点积,而不是基于样本(及其组成特征)本身。

现在我们可以计算出点积是什么(或近似值),然后使用它。

有个常用内核。 linear内核是最简单的内核,它只是两个样本特征向量,权重特征和偏差值的点积。 还有一个多项式内核,它将点积提高到给定的度数(例如 2)。 其他函数包括高斯函数(rbf)和 Sigmoidal 函数。 在我们先前的代码示例中,我们在linear内核和rbf内核之间进行了测试。

所有这些推导的最终结果是,这些内核有效地定义了两个样本之间的距离,该距离用于支持向量机中新样本的分类。 从理论上讲,可以使用任何距离,尽管它可能不具有能够轻松优化 SVM 训练的相同特征。

在 scikit-learn 的 SVM 的实现中,我们可以定义kernel参数来更改在计算中使用哪个内核函数,就像我们在前面的代码示例中看到的那样。

Kernels

字符 n-gram

我们看到了功能词如何用作预测文档作者的特征。 另一种特征类型是字符 n-gram。 n-gram 是n对象的序列,其中n是一个值(对于文本,通常在 2 到 6 之间)。 单词 n-gram 已用于许多研究中,通常与文档的主题有关。 但是,字符 n-gram 已被证明具有高质量的作者身份。

通过将文档表示为字符序列,可以在文本文档中找到字符 n-gram。 然后从该序列中提取这些 n-gram,并训练模型。 有很多不同的模型,但是一个标准模型与我们之前使用的词袋模型非常相似。

对于训练语料库中的每个不同的 n-gram,我们为其创建一个特征。 n-gram 的示例是<e t>,它是字母e,空格,然后是字母t(尖括号用于表示开始和结束 而不是其中的一部分)。 然后,我们使用训练文档中每个 n-gram 的频率训练模型,并使用创建的特征矩阵训练分类器。

注意

字符 n 元语法有多种定义方式。 例如,某些应用仅选择单词内字符,而忽略空格和标点符号。 有些人使用此信息(例如本章中的实现)。

关于字符 n-gram 为何起作用的一个普遍理论是,人们通常会写出他们可以轻松说出的单词,而字符 n-gram(至少当 n 在 2 到 6 之间时)是音素的一个很好的近似值。 说的话。 从这个意义上讲,使用字符 n-gram 可以近似单词的声音,也可以近似您的写作风格。 这是创建新功能时的常见模式。 首先,我们有一个理论来研究哪些概念会影响最终结果(作者风格),然后创建特征来近似或度量这些概念。

字符 n 元语法矩阵的主要特征是它稀疏并且稀疏度随 n 值较高而迅速增加。 对于n-值为 2,大约 75%的特征矩阵为零。 对于n值为 5,超过 93%的是零。 但是,这通常比同类型的单词 n-gram 矩阵稀疏,并且使用基于单词的分类的分类器不会引起很多问题。

提取字符 n-gram

将使用我们的CountVectorizer类提取字符 n-gram。 为此,我们设置analyzer参数并为n指定一个值以提取 n-gram。

scikit-learn 中的实现使用 n-gram 范围,使您可以同时提取多个大小的 n-gram。 在本实验中,我们不会研究不同的n-值,因此我们将这些值设置为相同。 要提取大小为 3 的 n-gram,您需要指定(3,3)作为 n-gram 范围的值。

我们可以重用先前代码中的网格搜索。 我们需要做的就是在新管道中指定新功能提取器:

pipeline = Pipeline([('feature_extraction', CountVectorizer(analyzer='char', ngram_range=(3, 3))),
                     ('classifier', grid)
                     ])
scores = cross_val_score(pipeline, documents, classes, scoring='f1')
print("Score: {:.3f}".format(np.mean(scores)))

注意

功能词和字符 n 元语法词之间存在很多隐式重叠,因为功能词中的字符序列更可能出现。 但是,实际特征非常不同,字符 n-gram 捕获标点符号,而功能词则没有。 例如,一个字符 n-gram 在句子的末尾包括句号,而基于功能词的方法将仅使用前一个词本身。

Extracting character n-grams

使用 Enron 数据集

在 1990 年代后期,安然(HTG0)是世界上最大的能源公司之一,报告收入超过 1000 亿美元。 它拥有 20,000 多名员工,并且-到 2000 年-似乎没有迹象表明有什么不对劲。

在 2001 年,发生了安然丑闻,发现该安然正在采取系统的,欺诈性的会计惯例。 这种欺诈行为是故意的,遍及整个公司,涉及金额可观。 在公开发现此消息后,其股价从 2000 年的 90 多美元跌至 2001 年的 1 美元以下。安然不久便申请了破产,一团糟,最终需要 5 年以上的时间才能解决。

作为对安然公司的调查的一部分,美国联邦能源管理委员会公开发布了 60 万封电子邮件。 从那时起,此数据集已用于从社交网络分析到欺诈分析的所有内容。 它也是进行作者身份分析的绝佳数据集,因为我们能够从单个用户的已发送文件夹中提取电子邮件。 这使我们可以创建一个比许多以前的数据集大得多的数据集。

访问 Enron 数据集

可在这个页面上获取完整的 Enron 电子邮件的集。

注意

完整的数据集为 423 MB,压缩格式为gzip。 如果您没有基于 Linux 的计算机来解压缩(解压缩)该文件,请获取其他程序,例如如 7-zip

下载完整的语料库并将其解压缩到您的数据文件夹中。 默认情况下,它将解压缩到名为enron_mail_20110402的文件夹中。

在寻找作者身份信息时,我们只希望可以将电子邮件归因于特定作者。 因此,我们将查看每个用户的已发送文件夹,即他们已发送的电子邮件。

在笔记本中,设置 Enron 数据集的数据文件夹:

enron_data_folder = os.path.join(os.path.expanduser("~"), "Data", "enron_mail_20110402", "maildir")

创建数据集加载器

现在,我们可以创建一个函数,该函数将随机选择几个作者,并在其发送的文件夹中返回每个电子邮件。 具体来说,我们正在寻找有效载荷,即内容而不是电子邮件本身。 为此,我们将需要一个电子邮件解析器。 代码如下:

from email.parser import Parser
p = Parser()

稍后我们将使用它从数据文件夹中的电子邮件文件中提取有效负载。

我们将随机选择作者,因此我们将使用随机状态,该状态允许我们在需要时复制结果:

from sklearn.utils import check_random_state

通过我们的数据加载功能,我们将有很多选择。 这些大多数确保我们的数据集相对平衡。 有些作者的发送邮件中将有数千封电子邮件,而其他作者则只有几十封。 我们将搜索限制为仅使用min_docs_author接收至少 10 封电子邮件的作者,并使用max_docs_author参数从每位作者接收最多 100 封电子邮件。 我们还指定了我们希望获得多少作者-默认情况下,使用num_authors参数为 10 位作者。 代码如下:

def get_enron_corpus(num_authors=10, data_folder=data_folder,
                     min_docs_author=10, max_docs_author=100,
                     random_state=None):
    random_state = check_random_state(random_state)

接下来,我们列出 data 文件夹中的所有文件夹,它们是 Enron 员工的单独电子邮件地址。 当我们随机地对它们进行洗牌时,我们可以在每次运行代码时选择一个新的集合。 请记住,设置随机状态将使我们能够复制以下结果:

    email_addresses = sorted(os.listdir(data_folder))
    random_state.shuffle(email_addresses)

注意

我们对电子邮件地址进行排序,只是将它们乱码,似乎有些奇怪。 os.listdir函数并不总是返回相同的结果,因此我们首先对其进行排序以获得一定的稳定性。 然后,我们使用随机状态进行混洗,这意味着我们的混洗可以根据需要重现过去的结果。

然后,我们设置了我们的文档和类列表。 我们还创建了一个author_num,它将告诉我们每个新作者要使用哪个类。 我们不会使用我们之前使用的enumerate技巧,因为我们可能不会选择某些作者。 例如,如果作者没有发送 10 封电子邮件,我们将不使用它。 代码如下:

    documents = []
    classes = []
    author_num = 0

我们还将记录使用的作者以及分配给他们的班级编号。 这不是用于数据挖掘,而是将在可视化中使用,以便我们可以更轻松地识别作者。 该词典将简单地将电子邮件用户名映射到类值。 代码如下:

    authors = {}

接下来,我们遍历每个电子邮件地址,并查找名称中带有“已发送”的所有子文件夹,以指示已发送的邮箱。 代码如下:

    for user in email_addresses:
      users_email_folder = os.path.join(data_folder, user)
      mail_folders = [os.path.join(users_email_folder, subfolder) for subfolder in os.listdir(users_email_folder)
                        if "sent" in subfolder]

然后,我们获取此文件夹中的每封电子邮件。 我把这个调用放在了 try-except 块中,因为有些作者的发送邮件中有子目录。 我们可以使用一些更详细的代码来获取所有这些电子邮件,但是现在我们将继续并忽略这些用户。 代码如下:

        try:
          authored_emails = [open(os.path.join(mail_folder, email_filename), encoding='cp1252').read()
          for mail_folder in mail_folders
          for email_filename in os.listdir(mail_folder)]
        except IsADirectoryError:
            continue

接下来,我们检查至少有 10 封电子邮件(或设置为min_docs_author的任何内容):

        if len(authored_emails) < min_docs_author:
            continue

作为的下一步,如果我们收到来自该作者的过多电子邮件,则仅接收前 100 条电子邮件(来自max_docs_author):

        if len(authored_emails) > max_docs_author:
            authored_emails = authored_emails[:max_docs_author]

接下来,我们解析电子邮件以提取内容。 我们对标题不感兴趣-作者对此处的内容几乎没有控制权,因此它不能为作者身份分析提供良好的数据。 然后,我们将这些电子邮件有效负载添加到我们的数据集中:

        contents = [p.parsestr(email)._payload for email in authored_emails]
        documents.extend(contents)

然后,对于添加到数据集中的每封电子邮件,我们都为该作者添加一个类值:

        classes.extend([author_num] * len(authored_emails))

然后,我们记录用于该作者的类编号,然后按*,然后按*递增:

        authors[user] = author_num
        author_num += 1

然后,我们检查我们是否有足够的作者,如果有,我们跳出循环返回数据集。 代码如下:

        if author_num >= num_authors or author_num >= len(email_addresses):
            break

然后,我们返回数据集的文档和类,以及我们的作者映射。 代码如下:

    return documents, np.array(classes), authors

在此函数之外,我们现在可以通过执行以下函数调用来获取数据集。 我们将在这里使用 14 的随机状态(与本模块一样),但是您可以尝试其他值或将其设置为 none,以在每次调用函数时获得随机设置:

documents, classes, authors = get_enron_corpus(data_folder=enron_data_folder, random_state=14)

如果您看一下数据集,我们还需要进行进一步的预处理。 我们的电子邮件非常混乱,但是(从数据分析的角度来看)最糟糕的一点是,这些电子邮件包含其他作者的来信,形式为。 以以下电子邮件为例:documents[100]

我对时机感到失望,但我理解。 谢谢。 标记

-----原始消息-----

来自:马克·格林伯格

发送:2001 年 9 月 28 日,星期五,下午 4:19

致:海迪克(Mark E.)

主题:网站

标记-

仅供参考-我在下面附上了该网站拟议的新外观的屏幕截图。 我们需要进行一些调整,但是我认为这比我们现在的外观干净得多。

本文档包含另一封作为回复的电子邮件,作为一种常见的电子邮件格式,附加在底部。 电子邮件的第一部分来自 Mark Haedicke,而第二部分是 Mark Greenberg 写给 Mark Haedicke 的先前电子邮件。 只有前面的文本(-----原始消息的第一个实例-----)可以归因于作者,这是我们真正担心的唯一一环。

通常,提取此信息并不容易。 电子邮件是一种众所周知的不好使用的格式。 不同的电子邮件客户端添加其自己的标头,以不同的方式定义答复,并根据需要进行操作。 电子邮件在当前环境中可以正常工作真是令人惊讶。

我们可以寻找一些常用的模式。 quotequail程序包将查找这些,并且可以找到电子邮件的新部分,丢弃回复和其他信息。

提示

您可以使用 pip pip3 install quotequail安装quotequail

我们将编写一个简单的函数来包装quotequail功能,使我们能够轻松地在所有文档上调用它。 首先,我们导入quotequail并设置函数定义:

import quotequail
def remove_replies(email_contents):

接下来,我们使用quotequail展开电子邮件,这将返回包含电子邮件不同部分的字典。 代码如下:

    r = quotequail.unwrap(email_contents)

在某些情况下,r可以为无。 如果无法解析电子邮件,则会发生这种情况。 在这种情况下,我们只返回完整的电子邮件内容。 在处理实际数据集时,通常需要这种凌乱的解决方案。 代码如下:

    if r is None:
        return email_contents

我们感兴趣的电子邮件的实际部分称为text_top(由quotequail表示)。 如果存在,我们将其作为电子邮件中有趣的部分返回。 代码如下:

    if 'text_top' in r:
        return r['text_top']

如果不存在,则quotequail找不到。 可能在电子邮件中找到其他文本。 如果存在,我们仅返回该文本。 代码如下:

    elif 'text' in r:
        return r['text']

最后,如果无法获得结果,我们只返回电子邮件内容,希望它们为我们的数据分析提供一些好处:

    return email_contents

现在,我们可以通过在每个文档上运行此功能来预处理所有文档:

documents = [remove_replies(document) for document in documents]

我们之前的电子邮件示例现在已经得到了很大的澄清,并且仅包含 Mark Greenberg 编写的电子邮件:

我对时机感到失望,但我理解。 谢谢。 标记

全部放在一起

我们可以使用先前实验的中现有的参数空间和分类器-我们要做的就是将其重新调整为新数据。 默认情况下,对 scikit-learn 的训练是从头开始的,随后对fit()的调用将丢弃任何先前的信息。

注意

有一种称为在线学习的算法,可以用新样本更新训练,而不必每次都重新开始训练。 我们将在本模块的稍后部分看到在线学习的实际应用,包括下一章,第 10 章,“聚类新闻文章”。

和以前一样,我们可以使用cross_val_score计算分数并打印结果。 代码如下:

scores = cross_val_score(pipeline, documents, classes, scoring='f1')
print("Score: {:.3f}".format(np.mean(scores)))

结果是 0.523,对于这样一个混乱的数据集,这是一个合理的结果。 添加更多数据(例如增加数据集加载中的max_docs_author)可以改善这些结果。

评估

通常,永远不要将评估基于单个数字。 在 f 得分的情况下,它通常比技巧更强健,尽管它们没有用,但仍能给出良好的分数。 一个例子就是准确性。 正如我们在上一章中所述,垃圾邮件分类器可以将所有内容预测为垃圾邮件,并且可以达到 80%以上的准确性,尽管该解决方案根本没有用。 因此,通常值得对结果进行更深入的研究。

首先,我们将研究混淆矩阵,就像在第 8 章,“使用神经网络击败 CAPTCHA”一样。 在我们这样做之前,我们需要预测一个测试集。 先前的代码使用cross_val_score,实际上并没有给我们提供可以使用的经过训练的模型。 因此,我们将需要改装一个。 为此,我们需要训练和测试子集:

from sklearn.cross_validation import train_test_split
training_documents, testing_documents, y_train, y_test = train_test_split(documents, classes, random_state=14)

接下来,我们将管道调整到训练文档中,并为测试集创建预测:

pipeline.fit(training_documents, y_train)
y_pred = pipeline.predict(testing_documents)

此时,您可能想知道参数的最佳组合实际上是什么。 我们可以很容易地从网格搜索对象中提取它(这是管道的classifier步骤):

print(pipeline.named_steps['classifier'].best_params_)

结果为您提供了分类器的所有参数。 但是,大多数参数是我们没有碰过的默认值。 我们搜索的是Ckernel,它们分别设置为1linear

现在我们可以创建一个混淆矩阵:

from sklearn.metrics import confusion_matrix
cm = confusion_matrix(y_pred, y_test)
cm = cm / cm.astype(np.float).sum(axis=1)

接下来,我们得到我们的作者,以便我们可以正确地标记轴。 为此,我们使用 Enron 数据集加载的authors字典。 代码如下:

sorted_authors = sorted(authors.keys(), key=lambda x:authors[x])

最后,我们使用matplotlib显示混淆矩阵。 与上一章相比,唯一的变化如下。 只需用本章实验中的作者替换字母标签:

%matplotlib inline
from matplotlib import pyplot as plt
plt.figure(figsize=(10,10))
plt.imshow(cm, cmap='Blues')
tick_marks = np.arange(len(sorted_authors))
plt.xticks(tick_marks, sorted_authors)
plt.yticks(tick_marks, sorted_authors)
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()

结果如下图所示:

Evaluation

我们可以看到,在大多数情况下,作者的预测是正确的-存在一条清晰的对角线,其值很高。 但是,错误的来源很多(较暗的值更大):例如,通常预测来自用户baughman-d的电子邮件来自reitmeyer-j

Evaluation

Evaluation

Evaluation