Python-数据挖掘学习指南-三-

104 阅读1小时+

Python 数据挖掘学习指南(三)

原文:annas-archive.org/md5/403522ad77dfa36ee05e0fc0022b1b5e

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:聚类新闻文章

在大多数早期章节中,我们进行数据挖掘时已知我们正在寻找什么。我们使用目标类别使我们能够在训练阶段了解我们的特征如何模拟那些目标,这使得算法能够设置内部参数以最大化其学习。这种有目标进行训练的学习类型被称为监督学习。在本章中,我们将考虑在没有那些目标的情况下我们做什么。这是无监督学习,它更多的是一种探索性任务。在无监督学习中,我们的目标不是用我们的模型进行分类,而是探索数据以发现洞察。

在本章中,我们将探讨如何通过链接聚合网站提取数据,以展示各种新闻故事,从而对新闻文章进行聚类以发现数据中的趋势和模式。

本章涵盖的关键概念包括:

  • 使用 reddit API 收集有趣的新闻故事

  • 从任意网站获取文本

  • 无监督数据挖掘的聚类分析

  • 从文档中提取主题

  • 在线学习以更新模型而无需重新训练

  • 聚类集成以结合不同的模型

趋势主题发现

在本章中,我们将构建一个系统,该系统可以接收新闻文章的实时流并将其分组,使得组内的文章具有相似的主题。你可以运行这个系统几周(或更长的时间)多次,以查看趋势是如何随时间变化的。

我们的系统将从流行的链接聚合网站(www.reddit.com)开始,该网站存储指向其他网站的链接列表,以及一个用于讨论的评论部分。reddit 上的链接被分为几个链接类别,称为subreddits。有专门针对特定电视节目、搞笑图片和其他许多事物的 subreddits。我们感兴趣的是新闻的 subreddits。在本章中,我们将使用*/r/worldnews* subreddits,但代码应该适用于任何其他基于文本的 subreddits。

在本章中,我们的目标是下载流行的故事,然后对它们进行聚类,以查看任何主要主题或概念的出现。这将使我们能够了解流行的焦点,而无需手动分析数百个单独的故事。一般过程如下:

  1. 从 reddit 收集最近流行的新闻故事的链接。

  2. 从这些链接下载网页。

  3. 仅从下载的网站中提取新闻故事。

  4. 执行聚类分析以找到故事集群。

  5. 分析那些集群以发现趋势。

使用 Web API 获取数据

我们在之前的几章中已经使用基于 Web 的 API 提取数据。例如,在第七章使用图挖掘遵循推荐中,我们使用了 Twitter 的 API 来提取数据。收集数据是数据挖掘流程中的关键部分,基于 Web 的 API 是收集各种主题数据的一种极好的方式。

当使用基于 Web 的 API 收集数据时,您需要考虑三个因素:授权方法、速率限制和 API 端点。

授权方法允许数据提供者知道谁在收集数据,以确保他们被适当限制速率,并且数据访问可以被追踪。对于大多数网站,一个个人账户通常足以开始收集数据,但某些网站会要求您创建一个正式的开发者账户以获取这种访问权限。

速率限制应用于数据收集,尤其是免费服务。在使用 API 时了解规则非常重要,因为它们可以从网站到网站而变化。Twitter 的 API 限制为每 15 分钟 180 次请求(取决于特定的 API 调用)。Reddit,如我们稍后将要看到的,允许每分钟 30 次请求。其他网站可能实施每日限制,而有些网站则按每秒限制。即使在网站内部,不同的 API 调用之间也可能存在巨大的差异。例如,Google Maps 有更小的限制和不同的 API 限制,每个资源都有不同的每小时请求次数限制。

如果您发现您正在创建一个需要更多请求和更快响应的应用或运行实验,大多数 API 提供商都有商业计划,允许更多的调用。请联系提供商获取更多详情。

API 端点是您用来提取信息的实际 URL。这些 URL 因网站而异。通常,基于 Web 的 API 将遵循 RESTful 接口(即表示状态传输)。RESTful 接口通常使用 HTTP 相同的操作:GETPOSTDELETE是最常见的。例如,为了检索资源的信息,我们可能会使用以下(仅作示例)API 端点:

www.dataprovider.com/api/resourc…

为了获取信息,我们只需向该 URL 发送一个 HTTP GET请求。这将返回有关给定类型和 ID 的资源的信息。大多数 API 遵循这种结构,尽管在实现上存在一些差异。大多数具有 API 的网站都会对其进行适当的文档说明,为您提供可以检索的所有 API 的详细信息。

首先,我们设置连接到服务的参数。为此,您需要 Reddit 的开发者密钥。为了获取这个密钥,请登录到 www.reddit.com/login 网站,然后转到 www.reddit.com/prefs/apps。从这里,点击“您是开发者吗?创建一个应用...”,填写表格,将类型设置为脚本。您将获得客户端 ID 和一个密钥,您可以将它们添加到一个新的 Jupyter Notebook 中:

CLIENT_ID = "<Enter your Client ID here>" 
CLIENT_SECRET = "<Enter your Client Secret here>"

Reddit 还要求您(当您使用他们的 API 时)设置一个包含您用户名的唯一字符串作为用户代理。创建一个唯一标识您的应用程序的用户代理字符串。我使用了书名、第十章,以及版本号 0.1 来创建我的用户代理,但可以是任何您喜欢的字符串。请注意,如果不这样做,可能会导致您的连接被大量限制:

USER_AGENT = "python:<your unique user agent> (by /u/<your reddit username>)"

此外,您还需要使用您的用户名和密码登录 Reddit。如果您还没有,可以免费注册一个新的账户(您不需要用个人信息进行验证)。

您将需要密码来完成下一步,所以在将代码分享给他人之前,请小心移除它。如果您不输入密码,将其设置为 none,您将被提示输入它。

现在让我们创建用户名和密码:

from getpass import getpass
USERNAME = "<your reddit username>" 
PASSWORD = getpass("Enter your reddit password:")

接下来,我们将创建一个函数来记录这些信息。Reddit 登录 API 将返回一个令牌,您可以使用它进行进一步的连接,这将是这个函数的结果。以下代码获取登录 Reddit 所需的必要信息,设置用户代理,然后获取我们可以用于未来请求的访问令牌:

import requests
def login(username, password):
    if password is None:
        password = getpass.getpass("Enter reddit password for user {}: ".format(username))    
    headers = {"User-Agent": USER_AGENT}
    # Setup an auth object with our credentials
    client_auth = requests.auth.HTTPBasicAuth(CLIENT_ID, CLIENT_SECRET)
    # Make a post request to the access_token endpoint
    post_data = {"grant_type": "password", "username": username, "password": password}
    response = requests.post("https://www.reddit.com/api/v1/access_token", auth=client_auth,     
                             data=post_data, headers=headers) 
    return response.json()

我们现在可以调用我们的函数来获取访问令牌:

token = login(USERNAME, PASSWORD)

这个令牌对象只是一个字典,但它包含我们将与未来请求一起传递的 access_token 字符串。它还包含其他信息,例如令牌的作用域(这将是一切)和它过期的时间,例如:

{'access_token': '<semi-random string>', 'expires_in': 3600, 'scope': '*', 'token_type': 'bearer'}

如果您正在创建一个生产级别的应用程序,请确保检查令牌的有效期,并在其过期时刷新它。您也会在尝试进行 API 调用时,如果访问令牌停止工作,知道这种情况已经发生。

Reddit 作为数据源

Reddit 是一个全球数百万用户使用的链接聚合网站,尽管其英文版本以美国为中心。任何用户都可以提交他们发现的有趣网站的链接,并为该链接添加标题。其他用户可以对其进行点赞,表示喜欢该链接,或者点踩,表示不喜欢该链接。得票最高的链接会被移至页面顶部,而得票较低的链接则不会显示。随着时间的推移,根据得票数,较旧的链接会被从首页移除。那些获得点赞的用户会获得称为 karma 的积分,这为提交优质故事提供了激励。

Reddit 还允许非链接内容,称为 self-posts。这些包含提交者输入的标题和一些文本。这些用于提问和开始讨论。在本章中,我们将只考虑基于链接的帖子,而不是基于评论的帖子。

帖子被分为网站的不同部分,称为 subreddit。subreddit 是一系列相关的帖子。当用户向 reddit 提交链接时,他们会选择它进入哪个 subreddit。subreddit 有自己的管理员,并有自己的关于该 subreddit 有效内容的规则。

默认情况下,帖子按热门排序,这是一个帖子年龄、点赞数、踩数以及内容自由度的函数。还有,它只提供最近发布的帖子(因此包含大量垃圾邮件和差评帖子),以及Top,它是在给定时间段内获得最高票数的帖子。在本章中,我们将使用热门,这将给我们带来最近、质量较高的故事(在新中确实有很多低质量的链接)。

使用我们之前创建的令牌,我们现在可以从一个 subreddit 中获取链接集合。为此,我们将使用/r/ API 端点,默认情况下返回热门故事。我们将使用/r/worldnews subreddit:

subreddit = "worldnews"

上一个端点的 URL 让我们可以创建完整的 URL,我们可以通过字符串格式化来设置它:

url = "https://oauth.reddit.com/r/{}".format(subreddit)

接下来,我们需要设置头部信息。这是出于两个原因:允许我们使用我们之前收到的授权令牌,并将用户代理设置为防止我们的请求受到严格限制。代码如下:

headers = {"Authorization": "bearer {}".format(token['access_token']), 
"User-Agent": USER_AGENT}

然后,像之前一样,我们使用 requests 库来发起调用,确保我们设置了头部信息:

response = requests.get(url, headers=headers)

在这个端点上调用json()将返回一个包含 reddit 返回信息的 Python 字典。它将包含给定 subreddit 的前 25 个结果。我们可以通过遍历此响应中的故事来获取标题。故事本身存储在字典的 data 键下。代码如下:

result = response.json()
for story in result['data']['children']: 
    print(story['data']['title'])

获取数据

我们的数据集将包括来自/r/worldnews subreddit 热门列表的帖子。我们在前面的部分中看到了如何连接到 reddit 以及如何下载链接。为了将这些内容整合在一起,我们将创建一个函数,该函数将提取给定 subreddit 中每个项目的标题、链接和分数。

我们将遍历 subreddit,每次获取最多 100 个故事。我们还可以进行分页以获取更多结果。在 reddit 阻止我们之前,我们可以读取大量页面,但我们将将其限制为 5 页。

由于我们的代码将反复调用 API,因此记住对我们的调用进行速率限制非常重要。为此,我们需要 sleep 函数:

from time import sleep

我们的功能将接受一个 subreddit 名称和一个授权令牌。我们还将接受要读取的页数,尽管我们将默认设置为 5:

def get_links(subreddit, token, n_pages=5):
    stories = []
    after = None
    for page_number in range(n_pages):
        # Sleep before making calls to avoid going over the API limit
        sleep(2)
        # Setup headers and make call, just like in the login function
        headers = {"Authorization": "bearer {}".format(token['access_token']), "User-Agent": USER_AGENT} 
        url = "https://oauth.reddit.com/r/{}?limit=100". format(subreddit)
        if after:
            # Append cursor for next page, if we have one
            url += "&after={}".format(after)
        response = requests.get(url, headers=headers)
        result = response.json()
        # Get the new cursor for the next loop
        after = result['data']['after']
        # Add all of the news items to our stories list
        for story in result['data']['children']:
            stories.append((story['data']['title'], story['data']['url'], story['data']['score']))
    return stories

我们在第七章*,使用图挖掘遵循建议*中看到,分页是如何在 Twitter API 中工作的。我们通过返回的结果获得一个游标,我们将它与我们的请求一起发送。Twitter 将使用这个游标来获取下一页的结果。Reddit API 几乎做了完全相同的事情,只是它调用参数在后面。我们不需要它用于第一页,所以我们最初将其设置为 None。在我们的第一页结果之后,我们将将其设置为有意义的值。

调用故事功能是一个简单的案例,只需传递授权令牌和 subreddit 名称:

stories = get_links("worldnews", token)

返回的结果应包含标题、URL 和 500 个故事,我们将使用这些结果提取实际网站上的文本。以下是我运行脚本时收到的标题样本:

俄罗斯考虑禁止向 2015 年后出生的人出售香烟

瑞士穆斯林女孩必须与男孩一起游泳

报告:俄罗斯在瑞典散布虚假新闻和误导信息 - 根据瑞典国际事务研究所研究人员的一份报告,俄罗斯在过去两年中通过散布虚假信息、宣传和伪造文件协调了一场影响瑞典决策的运动

荷兰所有火车现在都使用风能。荷兰提前一年实现了可再生能源目标

对英国全面监控法律的合法挑战迅速获得众筹

一块约等于特拉华州大小的、厚达 1000 英尺的冰块正在从南极洲断裂

根据对美国在全球范围内打击的分析,2016 年美国平均每天投下 72 枚炸弹——相当于每小时三枚——2016 年美国轰炸了伊拉克、叙利亚、巴基斯坦、阿富汗、利比亚、也门、索马里

德国政府正在调查最近虚假新闻激增的情况,据称俄罗斯试图干预该国今年晚些时候的议会选举

巴西乡村几天内农药杀死超过 1000 万只蜜蜂

欧洲伊斯兰国恐怖袭击的美国受害者家属起诉 Twitter,指控这家社交媒体巨头允许恐怖组织在网上传播

尽管气候变化,全球汽油税下降;石油和天然气行业获得 5000 亿美元补贴;美国上一次新的汽油税是在 1993 年

在“超级大屠杀”的情况下,捷克政府告诉公民武装自己并射击穆斯林恐怖分子

如果美国大使馆迁至耶路撒冷,巴勒斯坦解放组织威胁撤销对以色列的承认

欧洲所有新发现的艾滋病病例中有三分之二仅记录在一个国家——俄罗斯:现在有超过一百万俄罗斯人感染了病毒,预计在下一个十年内这个数字将几乎翻倍

捷克政府告诉其公民如何对抗恐怖分子:自己开枪射击 | 内政部正在推动一项宪法变革,这将允许公民使用枪支对抗恐怖分子

摩洛哥禁止出售布卡

大规模杀手布雷维克在权利上诉案中行纳粹礼

索罗斯集团在特朗普获胜后面临清洗风险,匈牙利变得更加大胆

尼日利亚在反腐行动中清除 5 万名“幽灵员工”

研究发现,酒精广告具有侵略性,与青少年饮酒有关 | 社会

英国政府在法律挑战者背后悄然发起“对自由的攻击”,同时分散人们的注意力 - 上一年年底,《调查权力法案》成为法律,赋予间谍阅读每个人整个互联网历史的能力

俄罗斯储备基金在 2016 年下跌 70%

在雅典发现一名俄罗斯外交官死亡

在喀布尔阿富汗议会的附近发生双爆炸,造成至少 21 人死亡(其中大多数是平民)和 45 人受伤

英镑的下跌加深,货币重新获得可疑的荣誉

世界新闻通常不是最乐观的地方,但它确实能让我们了解世界上正在发生的事情,这个 subreddit 的趋势通常可以表明全球的趋势。

从任意网站提取文本

我们从 Reddit 获得的链接指向由许多不同组织运行的任意网站。为了使其更难,这些页面被设计成由人类阅读,而不是由计算机程序阅读。当试图获取这些结果的实际内容/故事时,这可能会引起问题,因为现代网站在后台有很多活动。JavaScript 库被调用,样式表被应用,广告通过 AJAX 加载,侧边栏中添加了额外的内容,以及进行各种其他操作,使现代网页成为一个复杂的文档。这些功能使现代网络成为它现在这样,但使自动从其中获取良好信息变得困难!

在任意网站上寻找故事

首先,我们将从每个链接下载完整的网页,并将它们存储在我们的数据文件夹中,在 raw 子文件夹下。我们将在稍后处理这些内容以提取有用的信息。这种结果的缓存确保我们在工作时不需要持续下载网站。首先,我们设置数据文件夹路径:

import os 
data_folder = os.path.join(os.path.expanduser("~"), "Data", "websites", "raw")

我们将使用 MD5 散列来为我们的文章创建唯一的文件名,通过散列 URL,我们将导入hashlib来完成这个任务。散列函数是一个将某些输入(在我们的情况下是一个包含标题的字符串)转换为看似随机的字符串的函数。相同的输入将始终返回相同的输出,但略微不同的输入将返回截然不同的输出。从散列值到原始值也是不可能的,这使得它成为一个单向函数。

import hashlib

对于本章的实验,我们将简单地跳过任何失败的网站下载。为了确保我们不会因为这样做而丢失太多信息,我们维护一个简单的错误计数器来记录发生的错误数量。我们将抑制任何可能引起系统问题、阻止下载的错误。如果错误计数器过高,我们可以查看这些错误并尝试修复它们。例如,如果电脑没有互联网访问,所有 500 次下载都会失败,你应该在继续之前修复这个问题!

number_errors = 0

接下来,我们遍历我们的每个故事,下载网站,并将结果保存到文件中:

for title, url, score in stories:
    output_filename = hashlib.md5(url.encode()).hexdigest() 
    fullpath = os.path.join(data_folder, output_filename + ".txt")
    try: 
        response = requests.get(url) 
        data = response.text 
        with open(fullpath, 'w') as outf: 
            outf.write(data)
        print("Successfully completed {}".format(title))
    except Exception as e:
        number_errors += 1
        # You can use this to view the errors, if you are getting too many:
        # raise

如果在获取网站时出现错误,我们简单地跳过这个网站并继续。这段代码可以在大量网站上工作,这对我们的应用来说已经足够好了,因为我们寻找的是一般趋势而不是精确度。

注意,有时你确实关心获取 100%的响应,你应该调整你的代码以适应更多的错误。但是要警告,要创建在互联网数据上可靠工作的代码需要显著增加工作量。获取那些最终 5%到 10%的网站的代码将显著更复杂。

在前面的代码中,我们简单地捕获发生的任何错误,记录错误并继续。

如果你发现错误太多,将 print(e)行改为仅输入 raise。这将导致异常被调用,允许你调试问题。

完成此操作后,我们将在raw子文件夹中有一堆网站。在查看这些页面(在文本编辑器中打开创建的文件)后,你可以看到内容在那里,但还有 HTML、JavaScript、CSS 代码以及其他内容。因为我们只对故事本身感兴趣,所以我们现在需要一种方法从这些不同的网站上提取这些信息。

提取内容

在我们获取原始数据后,我们需要在每个中找到故事。为此有几种复杂的算法,以及一些简单的算法。在这里我们将坚持使用简单的方法,考虑到通常情况下,简单的算法就足够了。这是数据挖掘的一部分——知道何时使用简单的算法来完成工作,以及何时使用更复杂的算法来获得额外的性能。

首先,我们获取raw子文件夹中每个文件名的列表:

filenames = [os.path.join(data_folder, filename) for filename in os.listdir(data_folder)]

接下来,我们创建一个用于存放提取的纯文本版本的输出文件夹:

text_output_folder = os.path.join(os.path.expanduser("~"), "Data", "websites", "textonly")

接下来,我们编写将提取文件文本的代码。我们将使用 lxml 库来解析 HTML 文件,因为它有一个处理一些格式不良表达式的良好 HTML 解析器。代码如下:

from lxml import etree

实际提取文本的代码基于三个步骤:

  1. 我们遍历 HTML 文件中的每个节点并提取其中的文本。

  2. 我们跳过任何 JavaScript、样式或注释的节点,因为这些不太可能包含对我们感兴趣的信息。

  3. 我们确保内容至少有 100 个字符。这是一个很好的基线,但可以通过更精确的结果来改进。

正如我们之前所说的,我们对脚本、样式或注释不感兴趣。因此,我们创建了一个列表来忽略这些类型的节点。任何类型在此列表中的节点都不会被视为包含故事。代码如下:

skip_node_types = ["script", "head", "style", etree.Comment]

我们现在将创建一个函数,将 HTML 文件解析为 lxml etree,然后我们将创建另一个函数来解析这个树,寻找文本。这个第一个函数相当直接;只需打开文件并使用 lxml 库的 HTML 文件解析函数创建一个树。代码如下:

parser = etree.HTMLParser()

def get_text_from_file(filename):
    with open(filename) as inf:
        html_tree = etree.parse(inf, parser) 
    return get_text_from_node(html_tree.getroot())

在该函数的最后一条语句中,我们调用getroot()函数来获取树的根节点,而不是完整的etree。这允许我们编写接受任何节点的文本提取函数,因此可以编写递归函数。

此函数将在任何子节点上调用自身以提取文本,然后返回子节点文本的连接。

如果传递给此函数的节点没有子节点,我们只需从它返回文本。如果没有文本,我们只返回一个空字符串。请注意,我们在这里也检查了我们的第三个条件——文本至少 100 个字符长。

检查文本至少 100 个字符长的代码如下:

def get_text_from_node(node):
    if len(node) == 0: 
        # No children, just return text from this item
        if node.text: 
            return node.text 
        else:
            return ""
    else:
        # This node has children, return the text from it:
        results = (get_text_from_node(child)
                   for child in node
                   if child.tag not in skip_node_types)
    result = str.join("n", (r for r in results if len(r) > 1))
    if len(result) >= 100:
        return result
    else:
        return ""

在这一点上,我们知道该节点有子节点,因此我们将递归地对每个子节点调用此函数,然后在它们返回时将结果连接起来。

返回行内的最终条件阻止了空白行的返回(例如,当一个节点没有子节点且没有文本时)。我们还使用了一个生成器,这使得代码更高效,因为它只在需要时获取文本数据,即最终的返回语句,而不是创建多个子列表。

现在,我们可以通过遍历所有原始 HTML 页面来运行此代码,对每个页面调用文本提取函数,并将结果保存到文本仅子文件夹中:

for filename in os.listdir(data_folder):
    text = get_text_from_file(os.path.join(data_folder, filename)) 
    with open(os.path.join(text_output_folder, filename), 'w') as outf: 
        outf.write(text)

您可以通过打开文本仅子文件夹中的每个文件并检查其内容来手动评估结果。如果您发现结果中有太多非故事内容,尝试增加最小 100 字符的限制。如果您仍然无法获得良好的结果,或者需要为您的应用程序获得更好的结果,请尝试在附录 A,下一步中列出的方法。

新闻文章分组

本章的目的是通过聚类或分组新闻文章来发现新闻文章的趋势。为此,我们将使用 k-means 算法,这是一种经典的机器学习算法,最初于 1957 年开发。

聚类是一种无监督学习技术,我们经常使用聚类算法来探索数据。我们的数据集包含大约 500 个故事,逐一检查这些故事会相当困难。使用聚类使我们能够将相似的故事分组在一起,我们可以独立地探索每个簇的主题。

当我们没有明确的数据目标类别时,我们会使用聚类技术。从这个意义上说,聚类算法在他们的学习中几乎没有方向。它们根据某些函数学习,而不考虑数据的潜在含义。

因此,选择好的特征至关重要。在监督学习中,如果你选择了不好的特征,学习算法可以选择不使用这些特征。例如,支持向量机会对在分类中无用的特征赋予很小的权重。然而,在聚类中,所有特征都会用于最终结果——即使这些特征没有为我们提供我们想要的答案。

在对现实世界数据进行聚类分析时,了解在您的场景中哪些类型的特征将起作用总是一个好主意。在本章中,我们将使用词袋模型。我们正在寻找基于主题的组,因此我们将使用基于主题的特征来建模文档。我们知道这些特征有效,因为其他人已经在我们的问题的监督版本中进行了工作。相比之下,如果我们进行基于作者身份的聚类,我们会使用诸如第九章*,作者身份归因*实验中找到的特征。

k-means 算法

k-means 聚类算法通过迭代过程找到最能代表数据的质心。算法从一个预定义的质心集合开始,这些质心通常是来自训练数据的数据点。k-means 中的k是要寻找的质心数量以及算法将找到多少个簇。例如,将 k 设置为 3 将在数据集中找到三个簇。

k-means 有两个阶段:分配更新。它们如下所述:

  • 分配步骤中,我们为数据集中的每个样本设置一个标签,将其与最近的质心关联。对于距离质心 1 最近的样本,我们分配标签 1。对于距离质心 2 最近的样本,我们分配标签 2,依此类推,为每个 k 个质心分配标签。这些标签形成了簇,因此我们说每个带有标签 1 的数据点都在簇 1 中(目前是这样,因为随着算法的运行,分配可能会改变)。

  • 更新步骤中,我们针对每个簇计算其质心,即该簇中所有样本的平均值。

算法在分配步骤和更新步骤之间迭代;每次更新步骤发生时,每个质心都会移动一小段距离。这导致分配略有变化,导致在下一个迭代中质心也会略有移动。这会一直重复,直到达到某个停止标准。

常常在迭代一定次数后停止,或者当质心的总移动量非常低时停止。在某些情况下,算法也可以完成,这意味着簇是稳定的——分配不会改变,质心也不会改变。

在下面的图中,对随机创建但包含三个数据集簇的数据集执行了 k-means 算法。星星代表质心的起始位置,这些位置是通过从数据集中随机选择一个样本来随机选择的。经过 k-means 算法的 5 次迭代,质心移动到由三角形表示的位置。

图片

K-means 算法因其数学特性和历史意义而令人着迷。这是一个(大致上)只有一个参数的算法,非常有效且经常使用,即使在其发现 50 多年后也是如此。

scikit-learn 中有一个 k-means 算法,我们是从 scikit-learn 的cluster模块导入的:

from sklearn.cluster import KMeans

我们还导入了CountVectorizer类的近亲TfidfVectorizer。这个向量器根据每个词的出现次数进行加权,具体取决于它在多少个文档中出现,使用以下公式:tf / log(df),其中 tf 是一个词的频率(它在当前文档中出现的次数)和 df 是一个词的文档频率(在我们的语料库中它出现的文档数)。在许多文档中出现的词被赋予较低的权重(通过除以它出现的文档数的对数)。对于许多文本挖掘应用,使用这种类型的加权方案可以非常可靠地提高性能。代码如下:

from sklearn.feature_extraction.text import TfidfVectorizer

然后,我们为我们的分析设置管道。这有两个步骤。第一步是应用我们的向量器,第二步是应用我们的 k-means 算法。代码如下:

from sklearn.pipeline import Pipeline
n_clusters = 10 
pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
                                     ('clusterer', KMeans(n_clusters=n_clusters)) ])

max_df参数设置为低值 0.4,这意味着忽略任何在超过 40%的文档中出现的单词。这个参数对于移除那些本身对主题意义贡献很小的功能词来说是无价的。

移除在超过 40%的文档中出现的任何单词将移除功能词,这使得这种类型的预处理对于我们在第九章*,作者归属*中看到的工作来说非常无用。

documents = [open(os.path.join(text_output_folder, filename)).read()
             for filename in os.listdir(text_output_folder)]

然后,我们拟合并预测这个管道。到目前为止,这本书中我们已经多次遵循这个过程进行分类任务,但这里有一个区别——我们不将数据集的目标类别提供给拟合函数。这就是使其成为无监督学习任务的原因!代码如下:

pipeline.fit(documents)
labels = pipeline.predict(documents)

标签变量现在包含每个样本的聚类编号。具有相同标签的样本被认为属于同一个聚类。需要注意的是,聚类标签本身没有意义:聚类 1 和 2 与聚类 1 和 3 的相似度没有区别。

我们可以使用Counter类查看每个聚类中放置了多少个样本:

from collections import Counter
c = Counter(labels) 
for cluster_number in range(n_clusters): 
    print("Cluster {} contains {} samples".format(cluster_number, c[cluster_number]))

Cluster 0 contains 1 samples
Cluster 1 contains 2 samples
Cluster 2 contains 439 samples
Cluster 3 contains 1 samples
Cluster 4 contains 2 samples
Cluster 5 contains 3 samples
Cluster 6 contains 27 samples
Cluster 7 contains 2 samples
Cluster 8 contains 12 samples
Cluster 9 contains 1 samples

许多结果(记住你的数据集将与我的大不相同)包括一个包含大多数实例的大聚类,几个中等大小的聚类,以及一些只有一个或两个实例的聚类。这种不平衡在许多聚类应用中相当正常。

评估结果

聚类主要是一种探索性分析,因此很难有效地评估聚类算法的结果。一种直接的方法是根据算法试图学习的标准来评估算法。

如果你有一个测试集,你可以用它来评估聚类效果。更多详情,请访问nlp.standford.edu/IR-book/html/htmledition/evaluation-of-clustering-1.html

在 k-means 算法的情况下,它开发质心时使用的标准是使每个样本到其最近质心的距离最小化。这被称为算法的惯性,可以从任何已经调用 fit 的 KMeans 实例中检索到:

pipeline.named_steps['clusterer'].inertia_

在我的数据集上的结果是 343.94。不幸的是,这个值本身相当没有意义,但我们可以用它来确定我们应该使用多少个聚类。在先前的例子中,我们将n_clusters设置为 10,但这真的是最佳值吗?下面的代码运行 k-means 算法 10 次,每次使用从 2 到 20 的每个n_clusters值,这需要一些时间来完成大量运行。

对于每次运行,它记录结果的惯性。

你可能会注意到以下代码我们没有使用 Pipeline;相反,我们将步骤拆分出来。我们只为每个n_clusters值从我们的文本文档中创建一次 X 矩阵,以(显著地)提高代码的速度。

inertia_scores = [] 
n_cluster_values = list(range(2, 20)) 
for n_clusters in n_cluster_values: 
    cur_inertia_scores = [] 
    X = TfidfVectorizer(max_df=0.4).fit_transform(documents) 
 for i in range(10): 
        km = KMeans(n_clusters=n_clusters).fit(X) 
        cur_inertia_scores.append(km.inertia_) 
    inertia_scores.append(cur_inertia_scores)

inertia_scores变量现在包含从 2 到 20 的每个n_clusters值的惯性分数列表。我们可以绘制这些值,以了解这个值如何与n_clusters相互作用:

%matplotlib inline
from matplotlib import pyplot as plt
inertia_means = np.mean(inertia_scores, axis=1)
inertia_stderr = np.std(inertia_scores, axis=1)
fig = plt.figure(figsize=(40,20))
plt.errorbar(n_cluster_values, inertia_means, inertia_stderr, color='green')
plt.show()

图片

总体来说,随着聚类数量的增加,惯性值应该随着改进的减少而降低,这一点我们可以从这些结果中大致看出。从 6 到 7 之间的值增加仅由于选择质心的随机性,这直接影响了最终结果的好坏。尽管如此,对于我的数据来说(你的结果可能会有所不同),大约 6 个聚类是惯性发生重大改进的最后一次。

在这一点之后,对惯性只有轻微的改进,尽管很难具体说明这种模糊的标准。寻找这种类型的模式被称为肘部规则,因为我们正在寻找图表中的肘部弯曲。一些数据集有更明显的肘部,但这个特征并不保证出现(有些图表可能是平滑的!)

基于这种分析,我们将 n_clusters 设置为 6,然后重新运行算法:

n_clusters = 6 
pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
                     ('clusterer', KMeans(n_clusters=n_clusters)) ])
pipeline.fit(documents) 
labels = pipeline.predict(documents)

从簇中提取主题信息

现在,我们将注意力转向簇,试图发现每个簇中的主题。

我们首先从特征提取步骤中提取术语列表:

terms = pipeline.named_steps['feature_extraction'].get_feature_names()

我们还设置了一个计数器来统计每个类的大小:

c = Counter(labels)

遍历每个簇,我们像以前一样打印簇的大小。

在评估结果时,重要的是要记住簇的大小——一些簇可能只有一个样本,因此不能表明一般趋势。

接下来(仍然在循环中),我们遍历这个簇最重要的术语。为此,我们从质心中取出五个最大的值,这些值是通过找到质心中具有最高值的特征得到的。

for cluster_number in range(n_clusters): 
    print("Cluster {} contains {} samples".format(cluster_number, c[cluster_number]))
    print(" Most important terms")
    centroid = pipeline.named_steps['clusterer'].cluster_centers_[cluster_number]
    most_important = centroid.argsort()
    for i in range(5):
        term_index = most_important[-(i+1)]
        print(" {0}) {1} (score: {2:.4f})".format(i+1, terms[term_index], centroid[term_index]))

结果可以非常指示当前趋势。在我的结果(2017 年 1 月获得)中,簇对应于健康问题、中东紧张局势、韩国紧张局势和俄罗斯事务。这些是当时新闻中频繁出现的主要话题——尽管这些年来几乎没有变化!

你可能会注意到一些没有太多价值的词出现在最上面,例如 你, 她和mr.* 这些功能词对于作者分析来说非常好——正如我们在第九章中看到的,作者归属分析,但它们通常并不适合主题分析。将功能词列表传递到我们上面管道中的 stop_words 参数将会忽略这些词。以下是构建此类管道的更新代码:

function_words = [... list from Chapter 9 ...]

pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4, stop_words=function_words)),
                     ('clusterer', KMeans(n_clusters=n_clusters)) ])

将聚类算法作为转换器使用

作为旁注,关于 k-means 算法(以及任何聚类算法)的一个有趣性质是,你可以用它来进行特征降维。有许多方法可以减少特征数量(或创建新特征以在数据集上嵌入),例如 主成分分析潜在语义索引和许多其他方法。这些算法的一个问题是它们通常需要大量的计算能力。

在前面的例子中,术语列表中有超过 14,000 个条目——这是一个相当大的数据集。我们的 k-means 算法将其转换成仅仅六个簇。然后我们可以通过取每个质心的距离作为特征来创建一个具有更低特征数量的数据集。

要做到这一点,我们在一个 KMeans 实例上调用 transform 函数。我们的管道适合这个目的,因为它在最后有一个 k-means 实例:

X = pipeline.transform(documents)

这是在管道的最后一步调用转换方法,这是一个 k-means 的实例。这导致一个具有六个特征和样本数量与文档长度相同的矩阵。

然后,你可以对结果进行自己的二级聚类,或者如果你有目标值,可以使用它进行分类。这个工作流程的一个可能方法是使用监督数据执行一些特征选择,使用聚类将特征数量减少到更易于管理的数量,然后在一个分类算法(如 SVMs)中使用这些结果。

聚类集成

在第三章*,使用决策树预测体育胜者*中,我们研究了使用随机森林算法的分类集成,它是由许多低质量的基于树的分类器组成的集成。集成也可以使用聚类算法来完成。这样做的一个关键原因是平滑算法多次运行的结果。正如我们之前看到的,k-means 运行的结果因初始质心的选择而异。通过多次运行算法并合并结果可以减少这种变化。

集成也减少了选择参数对最终结果的影响。大多数聚类算法对算法选择的参数值非常敏感。选择略微不同的参数会导致不同的聚类。

证据累积

作为基本集成,我们首先多次聚类数据并记录每次运行的标签。然后我们在一个新的矩阵中记录每一对样本被聚类的次数。这就是证据累积聚类EAC)算法的本质。

EAC 有两个主要步骤。

  1. 第一步是多次使用低级聚类算法(如 k-means)对数据进行聚类,并记录样本在每个迭代中位于同一聚类的频率。这被存储在一个共关联矩阵中。

  2. 第二步是对结果共关联矩阵进行聚类分析,这使用另一种称为层次聚类的聚类算法来完成。它具有一个有趣的基于图论的性质,因为它在数学上等同于找到一个将所有节点连接起来的树并移除弱连接。

我们可以通过遍历每个标签并记录两个样本具有相同标签的位置,从标签数组创建一个共关联矩阵。我们使用 SciPy 的csr_matrix,这是一种稀疏矩阵:

from scipy.sparse import csr_matrix

我们的功能定义接受一组标签,然后记录每个匹配的行和列。我们在一个列表中完成这些操作。稀疏矩阵通常只是记录非零值位置的列表集合,csr_matrix就是这种类型稀疏矩阵的一个例子。对于具有相同标签的每一对样本,我们在列表中记录这两个样本的位置:

import numpy as np
def create_coassociation_matrix(labels):
    rows = [] 
    cols = []
    unique_labels = set(labels) 
    for label in unique_labels:
        indices = np.where(labels == label)[0]
        for index1 in indices:
            for index2 in indices:
                rows.append(index1)
                cols.append(index2)
    data = np.ones((len(rows),)) 
    return csr_matrix((data, (rows, cols)), dtype='float')

要从标签中获得共关联矩阵,我们只需调用此函数:

C = create_coassociation_matrix(labels)

从这里,我们可以将这些矩阵的多个实例相加。这允许我们将 k-means 多次运行的结果结合起来。打印出C(只需在 Jupyter Notebook 的新单元格中输入 C 并运行)将告诉你有多少单元格中有非零值。在我的情况下,大约一半的单元格中有值,因为我的聚类结果有一个大簇(簇越均匀,非零值的数量越低)。

下一步涉及共关联矩阵的层次聚类。我们将通过在这个矩阵上找到最小生成树并移除权重低于给定阈值的边来完成此操作。

在图论中,生成树是连接图中所有节点的边的集合。最小生成树(MST)只是具有最低总权重的生成树。在我们的应用中,图中的节点是我们数据集中的样本,边权重是这两个样本被聚在一起的次数——即我们的共关联矩阵中的值。

在以下图中,展示了六个节点的图上的 MST。在 MST 中,图上的节点可以多次连接,只要所有节点都连接在一起即可。

图片

为了计算 MST,我们使用 SciPy 的minimum_spanning_tree函数,该函数位于 sparse 包中:

from scipy.sparse.csgraph import minimum_spanning_tree

mst函数可以直接在由我们的共关联函数返回的稀疏矩阵上调用:

mst = minimum_spanning_tree(C)

然而,在我们的共关联矩阵 C 中,较高的值表示经常被聚在一起的样本——这是一个相似度值。相比之下,minimum_spanning_tree将输入视为距离,较高的分数会受到惩罚。因此,我们在共关联矩阵的取反上计算最小生成树:

mst = minimum_spanning_tree(-C)

前一个函数的结果是一个与共关联矩阵大小相同的矩阵(行数和列数与数据集中的样本数量相同),只保留了 MST 中的边,其他所有边都被移除。

然后,我们移除任何权重低于预定义阈值的节点。为此,我们遍历 MST 矩阵中的边,移除任何小于特定值的边。我们无法仅通过在共关联矩阵中迭代一次来测试这一点(值将是 1 或 0,因此没有太多可操作的空间)。因此,我们首先创建额外的标签,创建共关联矩阵,然后将两个矩阵相加。代码如下:

pipeline.fit(documents) 
labels2 = pipeline.predict(documents) 
C2 = create_coassociation_matrix(labels2) 
C_sum = (C + C2) / 2

然后,我们计算 MST 并移除在这两个标签中都没有出现的任何边:

mst = minimum_spanning_tree(-C_sum) 
mst.data[mst.data > -1] = 0

我们想要截断的阈值是任何不在两个聚类中的边——即值为 1 的边。然而,由于我们取反了共关联矩阵,我们也必须取反阈值值。

最后,我们找到所有的连通分量,这仅仅是一种找到在移除低权重边后仍然通过边连接的所有样本的方法。第一个返回值是连通分量的数量(即簇的数量),第二个是每个样本的标签。代码如下:

from scipy.sparse.csgraph import connected_components 
number_of_clusters, labels = connected_components(mst)

在我的数据集中,我获得了八个簇,簇与之前的大致相同。考虑到我们只使用了两次 k-means 迭代,这几乎不足为奇;使用更多的 k-means 迭代(如我们在下一节中做的那样)将导致更大的方差。

工作原理

在 k-means 算法中,每个特征都是无差别地使用的。本质上,所有特征都被假定为处于相同的尺度。我们在第二章使用 scikit-learn 估计器的分类中看到了不缩放特征的问题。结果是 k-means 正在寻找圆形簇,如图所示:

图片

k-means 也可以发现椭圆形簇。分离通常并不那么平滑,但可以通过特征缩放来简化。以下是一个这种形状簇的例子:

图片

如前一个屏幕截图所示,并非所有簇都具有这种形状。蓝色簇是圆形的,是 k-means 非常擅长识别的类型。红色簇是椭圆形。k-means 算法可以通过一些特征缩放识别这种形状的簇。

下面第三个簇甚至不是凸形的——它是一个 k-means 难以发现的奇形怪状,但至少在大多数观察图片的人类看来,它仍然被认为是

图片

聚类分析是一个困难的任务,其中大部分困难仅仅在于试图定义问题。许多人直观地理解它的含义,但试图用精确的术语定义它(对于机器学习来说是必要的)是非常困难的。甚至人们经常对这个词有不同的看法!

EAC 算法通过将特征重新映射到新的空间中工作,本质上是将 k-means 算法的每次运行转化为一个使用与上一节中用于特征减少的 k-means 相同原理的转换器。然而,在这种情况下,我们只使用实际的标签,而不是到每个质心的距离。这是记录在共关联矩阵中的数据。

结果是,EAC 现在只关心事物之间的接近程度,而不是它们在原始特征空间中的位置。仍然存在未缩放特征的问题。特征缩放很重要,无论如何都应该进行(我们在本章中使用了 tf**-**idf 进行缩放,这导致特征值具有相同的尺度)。

我们在第九章作者归属分析中看到了类似类型的转换,通过在 SVM 中使用核。这些转换非常强大,应该记住用于复杂的数据集。然而,将数据重新映射到新特征空间上的算法不需要太复杂,正如你将在 EAC 算法中看到的那样。

实现

将所有这些放在一起,我们现在可以创建一个符合 scikit-learn 接口的聚类算法,该算法执行 EAC 中的所有步骤。首先,我们使用 scikit-learn 的ClusterMixin创建类的基本结构。

我们的参数包括在第一步中执行 k-means 聚类的数量(用于创建共关联矩阵)、截断的阈值以及每个 k-means 聚类中要找到的聚类数量。我们设置了一个 n_clusters 的范围,以便在我们的 k-means 迭代中获得大量的方差。一般来说,在集成术语中,方差是一件好事;没有它,解决方案可能不会比单个聚类更好(尽管如此,高方差并不是集成将更好的指标)。

我将首先展示完整的类,然后概述每个函数:

from sklearn.base import BaseEstimator, ClusterMixin
class EAC(BaseEstimator, ClusterMixin):
    def __init__(self, n_clusterings=10, cut_threshold=0.5, n_clusters_range=(3, 10)): 
        self.n_clusterings = n_clusterings
        self.cut_threshold = cut_threshold
        self.n_clusters_range = n_clusters_range

    def fit(self, X, y=None):
        C = sum((create_coassociation_matrix(self._single_clustering(X))
                 for i in range(self.n_clusterings)))
        mst = minimum_spanning_tree(-C)
        mst.data[mst.data > -self.cut_threshold] = 0
        mst.eliminate_zeros()
        self.n_components, self.labels_ = connected_components(mst)
        return self

    def _single_clustering(self, X):
        n_clusters = np.random.randint(*self.n_clusters_range)
        km = KMeans(n_clusters=n_clusters)
        return km.fit_predict(X)

    def fit_predict(self, X):
        self.fit(X)
        return self.labels_

fit函数的目标是执行 k-means 聚类多次,合并共关联矩阵,然后通过找到 MST 来分割它,就像我们在之前的 EAC 示例中看到的那样。然后我们使用 k-means 执行我们的低级聚类,并将每个迭代的共关联矩阵结果相加。我们这样做是为了节省内存,只在需要时创建共关联矩阵。在这个生成器的每个迭代中,我们使用我们的数据集创建一个新的单次 k-means 运行,然后为其创建共关联矩阵。我们使用sum将这些值相加。

与之前一样,我们创建最小生成树(MST),移除任何小于给定阈值的边(如前所述,正确地取反值),并找到连通分量。与 scikit-learn 中的任何 fit 函数一样,我们需要返回 self,以便类在管道中有效地工作。

_single_clustering函数被设计用来在我们的数据上执行一次 k-means 迭代,然后返回预测的标签。为此,我们使用 NumPy 的randint函数和我们的n_clusters_range参数随机选择要找到的聚类数量,该参数设置了可能值的范围。然后我们使用 k-means 聚类和预测数据集。这里的返回值将是来自 k-means 的标签。

最后,fit_predict函数简单地调用 fit,然后返回文档的标签。

现在,我们可以通过设置一个与之前相同的管道并使用 EAC(而不是之前作为管道的最终阶段的 KMeans 实例)来运行此代码。代码如下:

pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
                     ('clusterer', EAC()) ])

在线学习

在某些情况下,在我们开始学习之前,我们没有所有需要的训练数据。有时,我们正在等待新数据的到来,也许我们拥有的数据太大而无法放入内存,或者在我们做出预测之后收到了额外的数据。在这种情况下,在线学习是随着时间训练模型的一个选项。

在线学习是新数据到达时对模型的增量更新。支持在线学习的算法可以一次训练一个或几个样本,并在新样本到达时进行更新。相比之下,不支持在线的算法需要一次性访问所有数据。标准的 k-means 算法就是这样,我们在这本书中看到的大多数算法也是如此。

算法的在线版本有一种方法,只需少量样本就可以部分更新其模型。神经网络是算法在线工作的一种标准示例。当神经网络接收到一个新的样本时,网络中的权重会根据学习率进行更新,这个学习率通常是一个非常小的值,如 0.01。这意味着任何单个实例只会对模型产生小的(但希望是改进的)变化。

神经网络也可以批量模式进行训练,其中一次给出一个样本组,并在一个步骤中完成训练。算法在批量模式下通常更快,但使用更多的内存。

在这个思路下,我们可以在单个或小批量样本之后稍微更新 k-means 的中心点。为此,我们在 k-means 算法的更新步骤中对中心点的移动应用一个学习率。假设样本是从总体中随机选择的,中心点应该倾向于移动到它们在标准、离线和 k-means 算法中的位置。

在线学习与基于流的 leaning 相关;然而,有一些重要的区别。在线学习能够在模型中使用过旧样本之后回顾它们,而基于流的机器学习算法通常只进行一次遍历——也就是说,只有一个机会查看每个样本。

实现

scikit-learn 包包含MiniBatchKMeans算法,它允许在线学习。这个类实现了一个 partial_fit 函数,它接受一组样本并更新模型。相比之下,调用 fit()将删除任何以前的训练,并且仅在新的数据上重新拟合模型。

MiniBatchKMeans 遵循 scikit-learn 中其他算法相同的聚类格式,因此创建和使用它与其他算法非常相似。

该算法通过计算它所看到的所有点的流平均来工作。为此,我们只需要跟踪两个值,即所有已看到点的当前总和和已看到点的数量。然后我们可以使用这些信息,结合一组新的点,在更新步骤中计算新的平均值。

因此,我们可以通过使用TfIDFVectorizer从我们的数据集中提取特征来创建矩阵 X,然后从该矩阵中采样以增量更新我们的模型。代码如下:

vec = TfidfVectorizer(max_df=0.4) 
X = vec.fit_transform(documents)

我们首先导入 MiniBatchKMeans 并创建其实例:

from sklearn.cluster import MiniBatchKMeans 
mbkm = MiniBatchKMeans(random_state=14, n_clusters=3)

接下来,我们将从我们的 X 矩阵中随机采样以模拟来自外部源的数据。每次我们获取一些数据时,我们都会更新模型:

batch_size = 10 
for iteration in range(int(X.shape[0] / batch_size)): 
    start = batch_size * iteration 
    end = batch_size * (iteration + 1) 
    mbkm.partial_fit(X[start:end])

然后,我们可以通过要求实例进行预测来获取原始数据集的标签:

labels = mbkm.predict(X)

然而,在这个阶段,我们无法在流水线中这样做,因为TfidfVectorizer不是一个在线算法。为了克服这个问题,我们使用HashingVectorizerHashingVectorizer类是巧妙地使用哈希算法来极大地减少计算词袋模型所需的内存。我们不是记录特征名称,如文档中找到的单词,而是只记录这些名称的哈希值。这使得我们甚至在查看数据集之前就能知道我们的特征,因为它是所有可能的哈希值的集合。这是一个非常大的数字,通常为 2 的 18 次方。使用稀疏矩阵,我们可以非常容易地存储和计算甚至这样大小的矩阵,因为矩阵中的大部分值将是 0。

目前,Pipeline类不允许在在线学习中使用。不同应用中存在一些细微差别,意味着没有一种显而易见的通用方法可以实施。相反,我们可以创建自己的Pipeline子类,这样我们就可以用它来进行在线学习。我们首先从Pipeline派生我们的类,因为我们只需要实现一个函数:

class PartialFitPipeline(Pipeline):
    def partial_fit(self, X, y=None):
        Xt = X
        for name, transform in self.steps[:-1]:
            Xt = transform.transform(Xt)
        return self.steps[-1][1].partial_fit(Xt, y=y)

我们需要实现的唯一函数是partial_fit函数,它首先执行所有转换步骤,然后在最终步骤(应该是分类器或聚类算法)上调用部分拟合。所有其他函数与正常Pipeline类中的函数相同,因此我们通过类继承来引用它们。

现在,我们可以创建一个流水线来在我们的在线学习中使用MiniBatchKMeansHashingVectorizer。除了使用我们新的PartialFitPipelineHashingVectorizer类之外,这个过程与本章其余部分使用的过程相同,只是我们一次只对少量文档进行拟合。代码如下:

from sklearn.feature_extraction.text import HashingVectorizer

pipeline = PartialFitPipeline([('feature_extraction', HashingVectorizer()),
                               ('clusterer', MiniBatchKMeans(random_state=14, n_clusters=3)) ])
batch_size = 10 
for iteration in range(int(len(documents) / batch_size)): 
    start = batch_size * iteration end = batch_size * (iteration + 1)
    pipeline.partial_fit(documents[start:end]) 
labels = pipeline.predict(documents)

这种方法有一些缺点。首先,我们无法轻易找出对每个簇最重要的单词。我们可以通过拟合另一个CountVectorizer并取每个单词的哈希值来解决这个问题。然后我们通过哈希值而不是单词来查找值。这有点繁琐,并且抵消了使用 HashingVectorizer 带来的内存节省。此外,我们无法使用之前使用的max_df参数,因为它需要我们知道特征的含义并随时间计数。

在进行在线训练时,我们也不能使用 tf-idf 权重。虽然可以近似这种方法并应用这种权重,但这种方法仍然很繁琐。HashingVectorizer仍然是一个非常有用的算法,并且是哈希算法的绝佳应用。

摘要

在本章中,我们探讨了聚类,这是一种无监督学习方法。我们使用无监督学习来探索数据,而不是用于分类和预测目的。在本实验中,我们没有为在 Reddit 上找到的新闻条目设置主题,因此无法进行分类。我们使用了 k-means 聚类来将这些新闻故事分组,以找到数据中的共同主题和趋势。

在从 Reddit 获取数据时,我们必须从任意网站提取数据。这是通过寻找大文本段来完成的,而不是使用完整的机器学习方法。对于这项任务,有一些有趣的机器学习方法可能可以改进这些结果。在这本书的附录中,我为每一章列出了超越章节范围并改进结果的方法。这包括对其他信息来源和每一章中方法的更复杂应用的参考。

我们还研究了简单的集成算法 EAC。集成通常是一种处理结果方差的好方法,尤其是当你不知道如何选择好的参数时(这在聚类中总是很困难)。

最后,我们介绍了在线学习。这是通向更大学习练习的门户,包括大数据,这将在本书的最后两章中讨论。这些最后的实验相当大,需要管理数据以及从数据中学习模型。

作为本章工作的扩展,尝试实现 EAC 作为在线学习算法。这不是一个简单任务,需要考虑当算法更新时应该发生什么。另一个扩展是收集更多来自更多数据源(如其他 subreddits 或直接从新闻网站或博客)的数据,并寻找一般趋势。

在下一章中,我们将从无监督学习转向分类。我们将探讨深度学习,这是一种基于复杂神经网络的分类方法。

第十一章:使用深度神经网络在图像中进行对象检测

我们在第八章*,使用神经网络战胜 CAPTCHA中使用了基本的神经网络。神经网络的研究正在创造许多领域中最先进和最精确的分类算法。本章介绍的概念与第八章,使用神经网络战胜 CAPTCHA中介绍的概念之间的区别在于复杂性*。在本章中,我们将探讨深度神经网络,那些具有许多隐藏层的网络,以及用于处理特定类型信息(如图像)的更复杂的层类型。

这些进步是在计算能力提高的基础上实现的,使我们能够训练更大、更复杂的网络。然而,这些进步远不止于简单地投入更多的计算能力。新的算法和层类型极大地提高了性能,而不仅仅是计算能力。代价是这些新的分类器需要比其他数据挖掘分类器更多的数据来学习。

在本章中,我们将探讨确定图像中代表的是什么对象。像素值将被用作输入,然后神经网络将自动找到有用的像素组合来形成高级特征。这些特征将被用于实际的分类。

总体而言,在本章中,我们将探讨以下内容:

  • 在图像中分类对象

  • 不同的深度神经网络

  • 使用 TensorFlow 和 Keras 库构建和训练神经网络

  • 使用 GPU 提高算法的速度

  • 使用基于云的服务为数据挖掘提供额外的计算能力

图像中的对象分类

计算机视觉正成为未来技术的重要组成部分。例如,我们将在不久的将来能够使用自动驾驶汽车 - 汽车制造商计划在 2017 年发布自动驾驶车型,并且已经部分实现自动驾驶。为了实现这一点,汽车的电脑需要能够看到周围的环境;识别障碍物、其他交通和天气状况;然后利用这些信息规划安全的行程。

虽然我们可以轻松地检测是否有障碍物,例如使用雷达,但了解那个物体是什么也同样重要。如果它是路上的动物,我们可以停车让它离开;如果它是一座建筑,这种策略效果不会很好!

用例

计算机视觉在许多场景中被使用。以下是一些它们的应用非常重要的例子。

  • 在线地图网站,如谷歌地图,出于多种原因使用计算机视觉。其中一个原因是自动模糊他们发现的任何面孔,以给作为其街景功能一部分被拍摄的人提供一些隐私。

  • 面部检测也被广泛应用于许多行业。现代相机自动检测面部,作为提高拍摄照片质量的一种手段(用户最常希望聚焦于可见的面部)。面部检测还可以用于识别。例如,Facebook 自动识别照片中的人,使得轻松标记朋友变得容易。

  • 正如我们之前所述,自动驾驶汽车高度依赖于计算机视觉来识别它们的路径并避开障碍物。计算机视觉是正在解决的关键问题之一,这不仅是在自动驾驶汽车的研究中,不仅是为了消费使用,还包括采矿和其他行业。

  • 其他行业也在使用计算机视觉,包括仓库自动检查货物缺陷。

  • 太空产业也在使用计算机视觉,帮助自动化数据的收集。这对于有效使用航天器至关重要,因为从地球向火星上的漫游车发送信号可能需要很长时间,而且在某些时候(例如,当两颗行星不面对彼此时)是不可能的。随着我们开始更频繁地处理基于太空的车辆,并且从更远的距离处理,提高这些航天器的自主性是绝对必要的,而计算机视觉是这一过程中的关键部分。以下图片展示了 NASA 设计和使用的火星漫游车;它在识别一个陌生、不适宜居住的星球周围环境时,显著地使用了计算机视觉。

图片

应用场景

在本章中,我们将构建一个系统,它将接受图像作为输入,并预测图像中的物体是什么。我们将扮演汽车视觉系统的角色,观察道路上的任何障碍物。图像的形式如下:

图片

这个数据集来自一个流行的数据集,称为 CIFAR-10。它包含 60,000 张宽度为 32 像素、高度为 32 像素的图像,每个像素都有一个红绿蓝(RGB)值。数据集已经被分为训练集和测试集,尽管我们将在完成训练后才会使用测试数据集。

CIFAR-10 数据集可在www.cs.toronto.edu/~kriz/cifar.html下载。

下载 Python 版本,它已经被转换成 NumPy 数组。

打开一个新的 Jupyter Notebook,我们可以看到数据的样子。首先,我们设置数据文件名。我们最初只关注第一批数据,并在最后扩大到整个数据集的大小;

import os
data_folder = os.path.join(os.path.expanduser("~"), "Data", "cifar-10-batches-py") 
batch1_filename = os.path.join(data_folder, "data_batch_1")

接下来,我们创建一个函数来读取存储在批次中的数据。这些批次是使用 pickle 保存的,pickle 是一个用于保存对象的 Python 库。通常,我们只需在文件上调用pickle.load(file)来获取对象。然而,这个数据存在一个小问题:它是在 Python 2 中保存的,但我们需要在 Python 3 中打开它。为了解决这个问题,我们将编码设置为latin(即使我们是以字节模式打开的):

import pickle
# Bugfix thanks to: http://stackoverflow.com/questions/11305790/pickle-incompatability-of-numpy-arrays-between-python-2-and-3 
def unpickle(filename): 
    with open(filename, 'rb') as fo: 
        return pickle.load(fo, encoding='latin1')

使用这个函数,我们现在可以加载批数据集:

batch1 = unpickle(batch1_filename)

这个批次是一个字典,包含实际数据(NumPy 数组)、相应的标签和文件名,以及一个说明它是哪个批次的注释(例如,这是 5 个训练批次中的第 1 个)。

我们可以通过使用批次的索引来提取图片:

image_index = 100 
image = batch1['data'][image_index]

图片数组是一个包含 3072 个条目的 NumPy 数组,范围从 0 到 255。每个值是图像中特定位置的红色、绿色或蓝色强度。

这些图片的格式与 matplotlib 通常使用的格式不同(用于显示图片),因此为了显示图片,我们首先需要重塑数组并旋转矩阵。这对训练我们的神经网络来说并不重要(我们将以适合数据的方式定义我们的网络),但我们确实需要将其转换为 matplotlib 的原因:

image = image.reshape((32,32, 3), order='F') 
import numpy as np 
image = np.rot90(image, -1)

现在我们可以使用 matplotlib 显示图片:

%matplotlib inline

from matplotlib import pyplot as plt 
plt.imshow(image)

结果图像,一艘船,被显示出来:

图片

这张图片的分辨率相当低——它只有 32 像素宽和 32 像素高。尽管如此,大多数人看到这张图片时都会看到一个船。我们能否让计算机做到同样的事情?

你可以更改图片索引来显示不同的图片,从而了解数据集的特性。

本章我们的项目目标是构建一个分类系统,它可以接受这样的图片并预测其中的物体是什么。但在我们这样做之前,我们将绕道学习我们将要使用的分类器:深度神经网络

深度神经网络

我们在第八章*,使用神经网络战胜 CAPTCHA中使用的神经网络具有一些出色的理论*特性。例如,学习任何映射只需要一个隐藏层(尽管中间层的大小可能需要非常大)。由于这种理论上的完美,神经网络在 20 世纪 70 年代和 80 年代是一个非常活跃的研究领域。然而,一些问题导致它们不再受欢迎,尤其是与其他分类算法(如支持向量机)相比。以下是一些主要问题:

  • 主要问题之一是运行许多神经网络所需的计算能力超过了其他算法,也超过了许多人能够访问的能力。

  • 另一个问题是在训练网络。虽然反向传播算法已经为人所知有一段时间了,但它在大网络中存在问题,需要大量的训练才能使权重稳定。

这些问题在最近得到了解决,导致神经网络再次受到欢迎。计算能力现在比 30 年前更容易获得,算法训练的进步意味着我们现在可以轻松地使用这种能力。

直觉

区分深度神经网络和我们在第八章*,使用神经网络击败 CAPTCHA*中看到的基本神经网络的方面是尺寸。

当神经网络有两个或更多隐藏层时,它被认为是深度神经网络。在实践中,深度神经网络通常更大,不仅每层的节点数量更多,而且层数也更多。虽然 2005 年中期的某些研究关注了非常大的层数,但更智能的算法正在减少实际所需的层数。

尺寸是一个区分因素,但新的层类型和神经网络结构正帮助创建特定领域的深度神经网络。我们已经看到了由密集层组成的正向神经网络。这意味着我们有一系列按顺序排列的层,其中每一层的每个神经元都与另一层的每个神经元相连。其他类型包括:

  • 卷积神经网络CNN)用于图像分析。在这种情况下,图像的一个小段被作为一个单独的输入,然后这个输入被传递到池化层以组合这些输出。这有助于处理图像的旋转和平移等问题。我们将在这章中使用这些网络。

  • 循环神经网络RNN)用于文本和时间序列分析。在这种情况下,神经网络的前一个状态被记住并用于改变当前输出。想想句子中的前一个词如何修改短语中当前词的输出:美国。最受欢迎的类型之一是 LSTM 循环网络,代表长短期记忆

  • 自编码器,它学习从输入通过一个隐藏层(通常节点较少)回到输入的映射。这找到了输入数据的压缩,并且这个层可以在其他神经网络中重用,从而减少所需的标记训练数据量。

神经网络有更多更多类型。对深度神经网络的应用和理论研究每个月都在发现越来越多的神经网络形式。有些是为通用学习设计的,有些是为特定任务设计的。此外,还有多种方法可以组合层、调整参数以及改变学习策略。例如,dropout 层在训练过程中随机将一些权重减少到零,迫使神经网络的所有部分学习良好的权重。

尽管有所有这些差异,神经网络通常被设计为接受非常基本的特征作为输入——在计算机视觉的情况下,是简单的像素值。随着这些数据在网络上结合并传递,这些基本特征组合成更复杂的特征。有时,这些特征对人类来说意义不大,但它们代表了计算机寻找以进行分类的样本的方面。

深度神经网络的实现

由于这些深度神经网络的大小,实现它们可能相当具有挑战性。一个糟糕的实现将比一个好的实现运行时间更长,并且可能由于内存使用而根本无法运行。

一个神经网络的基本实现可能从创建一个节点类并将这些节点集合到一个层类开始。然后,每个节点通过一个Edge类的实例连接到下一层的节点。这种基于类的实现对于展示网络如何操作是好的,但对于更大的网络来说效率太低。神经网络有太多的动态部分,这种策略效率不高。

相反,大多数神经网络操作都可以表示为矩阵上的数学表达式。一个网络层与下一个网络层之间连接的权重可以表示为一个值矩阵,其中行代表第一层的节点,列代表第二层的节点(有时也使用这个矩阵的转置)。这个值是层与层之间边的权重。一个网络可以定义为这些权重矩阵的集合。除了节点外,我们还在每一层添加一个偏差项,这基本上是一个始终开启并连接到下一层每个神经元的节点。

这种洞察力使我们能够使用矩阵运算来构建、训练和使用神经网络,而不是创建基于类的实现。这些数学运算非常出色,因为已经编写了许多高度优化的代码库,我们可以使用它们以尽可能高效的方式执行这些计算。

我们在第八章*,使用神经网络战胜 CAPTCHA*中使用的 scikit-learn 实现确实包含了一些构建神经网络的特性,但缺乏该领域的一些最新进展。然而,对于更大和更定制的网络,我们需要一个能给我们更多权力的库。我们将使用Keras库来创建我们的深度神经网络。

在本章中,我们将首先使用 Keras 实现一个基本的神经网络,然后(几乎)复制第八章*,使用神经网络战胜 CAPTCHA*中的实验,在预测图像中的哪个字母。最后,我们将使用一个更复杂的卷积神经网络在 CIFAR 数据集上执行图像分类,这还将包括在 GPU 上而不是 CPU 上运行以提高性能。

Keras 是用于实现深度神经网络的图计算库的高级接口。图计算库概述了一系列操作,然后稍后计算这些值。这些非常适合矩阵操作,因为它们可以用来表示数据流,将这些数据流分配到多个系统,并执行其他优化。Keras 可以在底层使用两种图计算库中的任意一种。第一种称为Theano,它稍微老一些,但拥有强大的支持(并在本书的第一版中使用过),第二种是 Google 最近发布的TensorFlow,它是许多深度学习背后的库。最终,您可以在本章中使用这两个库中的任意一个。

TensorFlow 简介

TensorFlow 是由 Google 工程师设计的图计算库,并且开始为 Google 在深度学习人工智能方面的许多最新进展提供动力。

图计算库有两个步骤。它们如下列出:

  1. 定义一系列操作序列(或更复杂的图),这些操作接受输入数据,对其进行操作,并将其转换为输出。

  2. 使用步骤 1 中获得的图和给定的输入进行计算。

许多程序员在日常工作中不使用这种类型的编程,但他们中的大多数人与一个相关的系统交互。关系数据库,特别是基于 SQL 的数据库,使用一个类似的概念,称为声明性范式。虽然程序员可能在数据库上定义一个带有WHERE子句的SELECT查询,但数据库会解释它并根据多个因素创建一个优化的查询,例如WHERE子句是否应用于主键,数据存储的格式,以及其他因素。程序员定义他们想要的,系统确定如何实现。

您可以使用 Anaconda 安装 TensorFlow:conda install tensorflow

对于更多选项,Google 有一个详细的安装页面www.tensorflow.org/get_started/os_setup

使用 TensorFlow,我们可以定义许多在标量、数组、矩阵以及其他数学表达式上工作的函数类型。例如,我们可以创建一个计算给定二次方程值的图:

import tensorflow as tf

# Define the parameters of the equation as constant values
a = tf.constant(5.0)
b = tf.constant(4.5)
c = tf.constant(3.0)

# Define the variable x, which lets its value be changed
x = tf.Variable(0., name='x')  # Default of 0.0

# Define the output y, which is an operation on a, b, c and x
y = (a * x ** 2) + (b * x) + c

这个y对象是一个张量对象。它还没有值,因为这还没有被计算。我们所做的只是创建了一个声明如下图的图:

当我们计算 y 时,首先取 x 的平方值并乘以 a,然后加上 b 倍的 x,最后再加上 c 得到结果。

图本身可以通过 TensorFlow 查看。以下是一些在 Jupyter Notebook 中可视化此图的代码,由 StackOverflow 用户 Yaroslav Bulatov 提供(参见此答案:stackoverflow.com/a/38192374/307363):

from IPython.display import clear_output, Image, display, HTML

def strip_consts(graph_def, max_const_size=32):
    """Strip large constant values from graph_def."""
    strip_def = tf.GraphDef()
    for n0 in graph_def.node:
        n = strip_def.node.add() 
        n.MergeFrom(n0)
        if n.op == 'Const':
            tensor = n.attr['value'].tensor
            size = len(tensor.tensor_content)
            if size > max_const_size:
                tensor.tensor_content = "<stripped %d bytes>"%size
    return strip_def

def show_graph(graph_def, max_const_size=32):
    """Visualize TensorFlow graph."""
    if hasattr(graph_def, 'as_graph_def'):
        graph_def = graph_def.as_graph_def()
    strip_def = strip_consts(graph_def, max_const_size=max_const_size)
    code = """
        <script>
          function load() {{
            document.getElementById("{id}").pbtxt = {data};
          }}
        </script>
        <link rel="import" href="https://tensorboard.appspot.com/tf-graph-basic.build.html" onload=load()>
        <div style="height:600px">
          <tf-graph-basic id="{id}"></tf-graph-basic>
        </div>
    """.format(data=repr(str(strip_def)), id='graph'+str(np.random.rand()))

    iframe = """
        <iframe seamless style="width:1200px;height:620px;border:0" srcdoc="{}"></iframe>
    """.format(code.replace('"', '&quot;'))
    display(HTML(iframe))

然后,我们可以在新的单元格中使用此代码进行实际的可视化:

show_graph(tf.get_default_graph().as_graph_def())

结果显示了这些操作如何在有向图中链接。可视化平台称为 TensorBoard,它是 TensorFlow 的一部分:

当我们想要计算 y 的值时,我们需要通过图中的其他节点传递 x 的值,这些节点在上图中被称为 OpNodes,简称 操作节点

到目前为止,我们已经定义了图本身。下一步是计算值。我们可以用多种方式来做这件事,特别是考虑到 x 是一个变量。要使用当前 x 的值来计算 y,我们创建一个 TensorFlow Session 对象,然后让它运行 y:

model = tf.global_variables_initializer()
with tf.Session() as session:
    session.run(model)
    result = session.run(y)
print(result)

第一行初始化变量。TensorFlow 允许你指定操作范围和命名空间。在此阶段,我们只是使用全局命名空间,这个函数是一个方便的快捷方式来正确初始化这个范围,这可以被视为 TensorFlow 编译图所需的步骤。

第二步创建一个新的会话,该会话将运行模型本身。tf.global_variables_initializer() 的结果本身是图上的一个操作,必须执行才能发生。下一行实际上运行变量 y,它计算计算 y 值所需的必要 OpNodes。在我们的例子中,那就是所有节点,但可能更大的图可能不需要计算所有节点 - TensorFlow 将只做足够的工作来得到答案,而不会更多。

如果你收到一个错误,说 global_variables_initializer 未定义,请将其替换为 initialize_all_variables - 接口最近已更改。

打印结果给出了我们的值为 3。

我们还可以执行其他操作,例如更改 x 的值。例如,我们可以创建一个赋值操作,将新值赋给现有的变量。在这个例子中,我们将 x 的值改为 10,然后计算 y,结果为 548。

model = tf.global_variables_initializer()
with tf.Session() as session:
    session.run(model)
    session.run(x.assign(10))
    result = session.run(y)
print(result)

虽然这个简单的例子可能看起来并不比我们用 Python 能做的更强大,但 TensorFlow(和 Theano)提供了大量的分布式选项,用于在多台计算机上计算更大的网络,并为此进行了优化。这两个库还包含额外的工具,用于保存和加载网络,包括值,这使得我们可以保存在这些库中创建的模型。

使用 Keras

TensorFlow 不是一个直接构建神经网络的库。以类似的方式,NumPy 不是一个执行数据挖掘的库;它只是做繁重的工作,通常用于其他库。TensorFlow 包含一个内置库,称为 TensorFlow Learn,用于构建网络和执行数据挖掘。其他库,如 Keras,也是出于这个目的而构建的,并在后端使用 TensorFlow。

Keras 实现了许多现代类型的神经网络层及其构建块。在本章中,我们将使用卷积层,这些层旨在模仿人类视觉的工作方式。它们使用连接的神经元的小集合,只分析输入值的一部分——在这种情况下,是图像。这使得网络能够处理标准的改变,例如处理图像的平移。在基于视觉的实验中,卷积层处理的一个改变示例是图像的平移。

相比之下,传统的神经网络通常连接非常紧密——一个层的所有神经元都连接到下一层的所有神经元。这被称为密集层。

Keras 中神经网络的标准模型是Sequential模型,它通过传递一个层列表来创建。输入(X_train)被提供给第一层,其输出被提供给下一层,依此类推,在一个标准的正向传播配置中。

在 Keras 中构建神经网络比仅使用 TensorFlow 构建要容易得多。除非你对神经网络结构进行高度定制的修改,我强烈建议使用 Keras。

为了展示使用 Keras 进行神经网络的基本方法,我们将实现一个基本网络,基于我们在第一章*,数据挖掘入门*中看到的 Iris 数据集。Iris 数据集非常适合测试新的算法,即使是复杂的算法,如深度神经网络。

首先,打开一个新的 Jupyter Notebook。我们将在本章的后面回到包含 CIFAR 数据的 Notebook。

接下来,我们加载数据集:

import numpy as np
from sklearn.datasets import load_iris 
iris = load_iris() 
X = iris.data.astype(np.float32) 
y_true = iris.target.astype(np.int32)

当处理像 TensorFlow 这样的库时,最好对数据类型非常明确。虽然 Python 乐于隐式地将一种数值数据类型转换为另一种,但像 TensorFlow 这样的库是围绕底层代码(在这种情况下,是 C++)的包装器。这些库并不能总是转换数值数据类型。

我们当前的输出是一个单维度的分类值数组(0、1 或 2,取决于类别)。神经网络可以以这种格式输出数据,但常规做法是神经网络有n个输出,其中n是类别的数量。因此,我们使用 one-hot 编码将我们的分类 y 转换为 one-hot 编码的y_onehot

from sklearn.preprocessing import OneHotEncoder

y_onehot = OneHotEncoder().fit_transform(y_true.reshape(-1, 1))
y_onehot = y_onehot.astype(np.int64).todense()

然后,我们将数据集分为训练集和测试集:

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y_onehot, random_state=14)

接下来,我们通过创建不同的层来构建我们的网络。我们的数据集包含四个输入变量和三个输出类别。这给出了第一层和最后一层的大小,但不是中间层的大小。对这个数字进行尝试将给出不同的结果,尝试不同的值以查看会发生什么是有价值的。我们将从一个具有以下维度的小型网络开始:

input_layer_size, hidden_layer_size, output_layer_size = 4, 6, 3

接下来,我们创建我们的隐藏层和输出层(输入层是隐式的)。在这个例子中,我们将使用 Dense 层:

from keras.layers import Dense
hidden_layer = Dense(output_dim=hidden_layer_size, input_dim=input_layer_size, activation='relu')
output_layer = Dense(output_layer_size, activation='sigmoid')

我鼓励你尝试调整激活值,看看它如何影响结果。这里的值如果你对问题没有更多信息的话是很好的默认值。也就是说,对于隐藏层使用relu,对于输出层使用sigmoid

然后,我们将层组合成一个 Sequential 模型:

from keras.models import Sequential
model = Sequential(layers=[hidden_layer, output_layer])

从这里开始的一个必要步骤是编译网络,这会创建图。在编译步骤中,我们得到了有关网络如何训练和评估的信息。这里的值定义了神经网络试图训练以减少什么,在下面的例子中,它是输出神经元和它们的期望值之间的均方误差。优化器的选择在很大程度上影响了它执行此操作的效率,通常需要在速度和内存使用之间进行权衡。

model.compile(loss='mean_squared_error',
              optimizer='adam',
              metrics=['accuracy'])

我们随后使用fit函数来训练我们的模型。Keras 模型从fit()函数返回一个历史对象,这使我们能够以细粒度级别查看数据。

history = model.fit(X_train, y_train)

你将得到相当多的输出。神经网络将训练 10 个 epoch,这些是训练周期,包括取训练数据,通过神经网络运行它,更新权重并评估结果。如果你调查历史对象(尝试print(history.history)),你将看到在每个 epoch 之后损失函数的分数(越低越好)。还包括准确度,越高越好。你可能还会注意到它并没有真正改善多少。

我们可以使用matplotlib绘制历史对象:

import seaborn as sns
from matplotlib import pyplot as plt

plt.plot(history.epoch, history.history['loss'])
plt.xlabel("Epoch")
plt.ylabel("Loss")

图片

当训练损失在下降时,下降得并不快。这是神经网络的一个问题——它们训练得很慢。默认情况下,fit 函数只会执行 10 个 epoch,这对于几乎所有应用来说都远远不够。为了看到这一点,使用神经网络预测测试集并运行分类报告:

from sklearn.metrics import classification_report
y_pred = model.predict_classes(X_test)
print(classification_report(y_true=y_test.argmax(axis=1), y_pred=y_pred))

结果相当糟糕,整体 f1 分数为 0.07,分类器仅对所有实例预测类别 2。起初,可能会觉得神经网络并不那么出色,但让我们看看当我们训练 1000 个 epoch 时会发生什么:

history = model.fit(X_train, y_train, nb_epoch=1000, verbose=False)

再次可视化每个 epoch 的损失,当运行像神经网络这样的迭代算法时,这是一个非常有用的可视化,使用上述代码显示了一个非常不同的故事:

图片

最后,我们再次执行分类报告以查看结果:

y_pred = model.predict_classes(X_test)
print(classification_report(y_true=y_test.argmax(axis=1), y_pred=y_pred))

完美。

卷积神经网络

要开始使用 Keras 进行图像分析,我们将重新实现第八章*,使用神经网络击败 CAPTCHAs中使用的示例,以预测图像中代表的是哪个字母。我们将重新创建第八章,使用神经网络击败 CAPTCHAs中使用的密集神经网络。首先,我们需要再次在我们的笔记本中输入我们的数据集构建代码。关于此代码的描述,请参阅第八章,使用神经网络击败 CAPTCHAs*(请记住更新 Coval 字体的文件位置):

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

def create_captcha(text, shear=0, size=(100, 30), scale=1):
    im = Image.new("L", size, "black")
    draw = ImageDraw.Draw(im)
    font = ImageFont.truetype(r"bretan/Coval-Black.otf", 22) 
    draw.text((0, 0), text, fill=1, font=font)
    image = np.array(im)
    affine_tf = tf.AffineTransform(shear=shear)
    image = tf.warp(image, affine_tf)
    image = image / image.max()
    shape = image.shape
    # Apply scale
    shapex, shapey = (shape[0] * scale, shape[1] * scale)
    image = tf.resize(image, (shapex, shapey))
    return image

from skimage.measure import label, regionprops
from skimage.filters import threshold_otsu
from skimage.morphology import closing, square

def segment_image(image):
    # label will find subimages of connected non-black pixels
    labeled_image = label(image>0.2, connectivity=1, background=0)
    subimages = []
    # regionprops splits up the subimages
    for region in regionprops(labeled_image):
        # Extract the subimage
        start_x, start_y, end_x, end_y = region.bbox
        subimages.append(image[start_x:end_x,start_y:end_y])
    if len(subimages) == 0:
        # No subimages found, so return the entire image
        return [image,]
    return subimages

from sklearn.utils import check_random_state
random_state = check_random_state(14) 
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
assert len(letters) == 26
shear_values = np.arange(0, 0.8, 0.05)
scale_values = np.arange(0.9, 1.1, 0.1)

def generate_sample(random_state=None): 
    random_state = check_random_state(random_state) 
    letter = random_state.choice(letters) 
    shear = random_state.choice(shear_values)
    scale = random_state.choice(scale_values)
    return create_captcha(letter, shear=shear, size=(30, 30), scale=scale), letters.index(letter)

dataset, targets = zip(*(generate_sample(random_state) for i in range(1000)))
dataset = np.array([tf.resize(segment_image(sample)[0], (20, 20)) for sample in dataset])
dataset = np.array(dataset, dtype='float') 
targets = np.array(targets)

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

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

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

在重新运行所有这些代码之后,你将得到一个类似于第八章*,使用神经网络击败 CAPTCHAs*实验的数据集。接下来,我们不再使用 scikit-learn 来构建我们的神经网络,而是将使用 Keras。

首先,我们创建了两个密集层,并将它们组合在一个顺序模型中。我选择在隐藏层中放置 100 个神经元。

from keras.layers import Dense
from keras.models import Sequential
hidden_layer = Dense(100, input_dim=X_train.shape[1])
output_layer = Dense(y_train.shape[1])
# Create the model
model = Sequential(layers=[hidden_layer, output_layer])
model.compile(loss='mean_squared_error', optimizer='adam', metrics=['accuracy'])

然后,我们拟合模型。和之前一样,你将希望有相当多的周期数。我再次使用了 1000,如果你想得到更好的结果,你可以增加这个数字。

model.fit(X_train, y_train, nb_epoch=1000, verbose=False)
y_pred = model.predict(X_test)

你还可以收集结果的历史对象,就像我们在 Iris 示例中所做的那样,以进一步调查训练情况。

from sklearn.metrics import classification_report
print(classification_report(y_pred=y_pred.argmax(axis=1),
y_true=y_test.argmax(axis=1)))

再次,完美。

至少,在我的机器上是这样的,但你的结果可能会有所不同。

GPU 优化

神经网络可以变得相当大。这对内存使用有一些影响;然而,像稀疏矩阵这样的高效结构意味着我们通常不会遇到在内存中拟合神经网络的问题。

当神经网络变得很大时,主要问题在于它们计算所需的时间非常长。此外,一些数据集和神经网络可能需要运行许多个训练周期才能得到对数据集的良好拟合。

我们在本章中将要训练的神经网络在我的性能相当强大的计算机上每个周期需要超过 8 分钟,我们预计要运行数十个,甚至数百个周期。一些更大的网络可能需要数小时来训练单个周期。为了获得最佳性能,你可能需要考虑数千个训练周期。

神经网络规模的增长会导致训练时间变长。

一个积极因素是,神经网络在其核心中充满了浮点运算。此外,还有大量可以并行执行的操作,因为神经网络训练主要由矩阵运算组成。这些因素意味着在 GPU 上进行计算是一个吸引人的选项,可以加快这一训练过程。

何时使用 GPU 进行计算

GPU 最初是为了渲染显示图形而设计的。这些图形使用矩阵和那些矩阵上的数学方程来表示,然后被转换成我们在屏幕上看到的像素。这个过程涉及到大量的并行计算。虽然现代 CPU 可能有多个核心(你的电脑可能有 2 个、4 个,甚至 16 个或更多!),但 GPU 有数千个专为图形设计的小核心。

CPU 更适合顺序任务,因为核心通常单独运行得更快,像访问计算机内存这样的任务也更有效率。实际上,让 CPU 做重活更容易。几乎每个机器学习库默认都使用 CPU,在使用 GPU 进行计算之前,你需要做额外的工作。好处可能非常显著。

因此,GPU 更适合那些有很多小操作可以在同一时间进行的任务。许多机器学习任务都是这样的,通过使用 GPU 可以带来效率的提升。

让你的代码在 GPU 上运行可能是一个令人沮丧的经历。这很大程度上取决于你有什么类型的 GPU,它的配置如何,你的操作系统,以及你是否准备对你的电脑做一些低级更改。

幸运的是,Keras 会自动使用 GPU 进行操作,如果操作适合,并且可以找到 GPU(如果你使用 TensorFlow 作为后端)。然而,你仍然需要设置你的电脑,以便 Keras 和 TensorFlow 可以找到 GPU。

有三条主要途径可以选择:

  • 第一条途径是查看你的电脑,搜索你的 GPU 和操作系统的工具和驱动程序,探索许多教程中的几个,找到一个适合你情况的教程。这能否成功取决于你的系统是什么样的。尽管如此,这个场景比几年前容易得多,因为现在有更好的工具和驱动程序可以执行 GPU 启用计算。

  • 第二条途径是选择一个系统,找到设置它的良好文档,然后购买一个与之匹配的系统。这样做效果会更好,但可能相当昂贵——在大多数现代计算机中,GPU 是成本最高的部件之一。如果你想要从系统中获得出色的性能,你需要一个非常好的 GPU,这可能会非常昂贵。如果你是一家企业(或者有更多的资金可以花费),你可以购买专门用于深度学习的高端 GPU,并与供应商直接交谈以确保你获得正确的硬件。

  • 第三条途径是使用已经配置好用于此目的的虚拟机。例如,Altoros Systems 已经创建了一个在亚马逊云服务上运行的系统。运行这个系统需要花费一定的费用,但价格远低于新电脑的价格。根据您的位置、您获得的精确系统和您使用的频率,您可能每小时只需花费不到 1 美元,通常还要低得多。如果您在亚马逊云服务中使用 spot 实例,您只需每小时几分钱就可以运行它们(尽管,您需要单独开发可以在 spot 实例上运行的代码)。

如果您无法承担虚拟机的运行成本,我建议您考虑第一条途径,即使用您当前的系统。您也可能能够从经常更新电脑的家庭成员或朋友那里购买到一台不错的二手 GPU(游戏玩家朋友在这方面很棒!)。

在 GPU 上运行我们的代码

我们在本章中将选择第三条途径,基于 Altoros Systems 的基础系统创建一个虚拟机。这个虚拟机将在亚马逊的 EC2 服务上运行。还有许多其他 Web 服务可以使用,每种服务的流程都会略有不同。在本节中,我将概述亚马逊的流程。

如果您想使用自己的计算机并且已经配置好以运行 GPU 启用计算,请自由跳过本节。

您可以了解更多关于如何设置的信息,请参阅aws.amazon.com/marketplace/pp/B01H1VWUOY?qid=1485755720051&sr=0-1&ref_=srh_res_product_title

  1. 首先,请访问 AWS 控制台:console.aws.amazon.com/console/home?region=us-east-1

  2. 使用您的亚马逊账户登录。如果您没有账户,系统将提示您创建一个,您需要创建一个账户才能继续。

  3. 接下来,请访问以下 EC2 服务控制台:console.aws.amazon.com/ec2/v2/home?region=us-east-1.

  4. 点击“启动实例”,并在右上角的下拉菜单中选择 N. California 作为您的位置。

  5. 点击“社区 AMI”,搜索由 Altoros Systems 创建的带有 TensorFlow(GPU)的 Ubuntu x64 AMI。然后,点击“选择”。在下一屏幕上,选择 g2.2xlarge 作为机器类型,然后点击“审查和启动”。在下一屏幕上,点击“启动”。

  6. 在此阶段,您将开始收费,所以请记住,当您完成使用机器时请关闭它们。您可以去 EC2 服务,选择机器,然后停止它。对于未运行的机器,您将不会产生费用。

  7. 您将收到一些有关如何连接到您实例的信息。如果您之前没有使用过 AWS,您可能需要创建一个新的密钥对以安全地连接到您的实例。在这种情况下,给您的密钥对起一个名字,下载 pem 文件,并将其存储在安全的地方——如果丢失,您将无法再次连接到您的实例!

  8. 点击“连接”以获取有关如何使用 pem 文件连接到您的实例的信息。最可能的情况是您将使用以下命令使用 ssh:

ssh -i <certificante_name>.pem ubuntu@<server_ip_address>

设置环境

接下来,我们需要将我们的代码放到机器上。有许多方法可以将此文件放到您的计算机上,但其中一种最简单的方法就是直接复制粘贴内容。

首先,打开我们之前使用的 Jupyter Notebook(在您的计算机上,而不是在亚马逊虚拟机上)。在笔记本本身有一个菜单。点击文件,然后选择下载为。选择 Python 并将其保存到您的计算机。此过程将 Jupyter Notebook 中的代码下载为可以在命令行中运行的 Python 脚本。

打开此文件(在某些系统上,您可能需要右键单击并使用文本编辑器打开)。选择所有内容并将它们复制到您的剪贴板。

在亚马逊虚拟机上,移动到主目录并使用新文件名打开 nano:

$ cd~/

$ nano chapter11script.py

nano 程序将打开,这是一个命令行文本编辑器。

打开此程序,将剪贴板的内容粘贴到该文件中。在某些系统上,您可能需要使用 ssh 程序的文件选项,而不是按 Ctrl+V 粘贴。

在 nano 中,按 Ctrl+O 保存文件到磁盘,然后按 Ctrl+X 退出程序。

您还需要字体文件。最简单的方法是从原始位置重新下载它。为此,请输入以下内容:

$ wget http://openfontlibrary.org/assets/downloads/bretan/680bc56bbeeca95353ede363a3744fdf/bretan.zip

$ sudo apt-get install unzip

$ unzip -p bretan.zip

在虚拟机中,您可以使用以下命令运行程序:

$ python chapter11script.py

程序将像在 Jupyter Notebook 中一样运行,并将结果打印到命令行。

结果应该与之前相同,但实际训练和测试神经网络的速度将快得多。请注意,在其他程序方面,它不会快那么多——我们没有编写 CAPTCHA 数据集创建以使用 GPU,因此我们不会在那里获得加速。

您可能希望关闭亚马逊虚拟机以节省一些费用;我们将在本章末尾使用它来运行我们的主要实验,但首先将在您的计算机上开发代码。

应用

现在,回到您的计算机上,打开我们本章创建的第一个 Jupyter Notebook——我们加载 CIFAR 数据集的那个。在这个主要实验中,我们将使用 CIFAR 数据集,创建一个深度卷积神经网络,然后在我们的基于 GPU 的虚拟机上运行它。

获取数据

首先,我们将我们的 CIFAR 图像和它们一起创建一个数据集。与之前不同,我们将保留像素结构——即在行和列中。首先,将所有批次加载到一个列表中:

import os
import numpy as np 

data_folder = os.path.join(os.path.expanduser("~"), "Data", "cifar-10-batches-py")

batches = [] 
for i in range(1, 6):
    batch_filename = os.path.join(data_folder, "data_batch_{}".format(i))
    batches.append(unpickle(batch_filename)) 
    break

最后的行,即 break,是为了测试代码——这将大大减少训练示例的数量,让你可以快速看到代码是否工作。我会在你测试代码工作后提示你删除这一行。

接下来,通过将这些批次堆叠在一起来创建一个数据集。我们使用 NumPy 的 vstack,这可以想象为向数组的末尾添加行:

X = np.vstack([batch['data'] for batch in batches])

然后,我们将数据集归一化到 0 到 1 的范围,并强制类型为 32 位浮点数(这是 GPU 启用虚拟机可以运行的唯一数据类型):

X = np.array(X) / X.max() 
X = X.astype(np.float32)

然后,我们对类别做同样的处理,除了我们执行一个 hstack,这类似于向数组的末尾添加列。然后我们可以使用 OneHotEncoder 将其转换为 one-hot 数组。这里我将展示一个使用 Keras 中提供的实用函数的替代方法,但结果是一样的:

from keras.utils import np_utils
y = np.hstack(batch['labels'] for batch in batches).flatten()
nb_classes = len(np.unique(y))
y = np_utils.to_categorical(y, nb_classes)

接下来,我们将数据集分为训练集和测试集:

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

接下来,我们将数组重塑以保留原始数据结构。原始数据是 32x32 像素的图像,每个像素有 3 个值(红色、绿色和蓝色值)。虽然标准的正向传播神经网络只接受单个输入数据数组(参见 CAPTCHA 示例),但卷积神经网络是为图像设计的,并接受三维图像数据(2-D 图像,以及包含颜色深度的另一个维度)。

X_train = X_train.reshape(-1, 3, 32, 32)
X_test = X_test.reshape(-1, 3, 32, 32)
n_samples, d, h, w = X_train.shape  # Obtain dataset dimensions
# Convert to floats and ensure data is normalised.
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')

现在,我们有一个熟悉的训练和测试数据集,以及每个目标类。我们现在可以构建分类器。

创建神经网络

现在,我们将构建卷积神经网络。我已经进行了一些调整,发现了一个效果很好的布局,但你可以自由地尝试更多层(或更少层)、不同类型和不同大小的层。较小的网络训练更快,但较大的网络可以实现更好的结果。

首先,我们创建神经网络中的层:

from keras.layers import Dense, Flatten, Convolution2D, MaxPooling2D
conv1 = Convolution2D(32, 3, 3, input_shape=(d, h, w), activation='relu')
pool1 = MaxPooling2D()
conv2 = Convolution2D(64, 2, 2, activation='relu')
pool2 = MaxPooling2D()
conv3 = Convolution2D(128, 2, 2, activation='relu')
pool3 = MaxPooling2D()
flatten = Flatten()
hidden4 = Dense(500, activation='relu')
hidden5 = Dense(500, activation='relu')
output = Dense(nb_classes, activation='softmax')
layers = [conv1, pool1,
          conv2, pool2,
          conv3, pool3,
          flatten, hidden4, hidden5,
          output]

我们使用密集层作为最后三层,按照正常的正向传播神经网络,但在那之前,我们使用结合了池化层的卷积层。我们有三组这样的层。

对于每一对 Convolution2D 和 MaxPooling2D 层,发生以下情况:

  1. Convolution2D 网络从输入数据中获取补丁。这些通过一个过滤器传递,这是一个类似于支持向量机使用的核操作符的矩阵变换。过滤器是一个较小的矩阵,大小为 k 乘以 n(在上面的 Convolution2D 初始化器中指定为 3x3),它应用于图像中找到的每个 k 乘以 n 模式。结果是卷积特征。

  2. MaxPooling2D 层从 Convolution2D 层获取结果,并为每个卷积特征找到最大值。

虽然这确实丢弃了很多信息,但这实际上有助于图像检测。如果一个图像中的对象只是向右偏移了几像素,标准的神经网络会认为它是一个全新的图像。相比之下,卷积层会找到它,并报告几乎相同的输出(当然,这取决于广泛的其它因素)。

经过这些层对后,进入网络密集部分的特性是元特性,它们代表了图像的抽象概念,而不是具体特性。通常这些特性是可以可视化的,例如像一条略微向上的线这样的特性。

接下来,我们将这些层组合起来构建我们的神经网络并对其进行训练。这次训练将比之前的训练花费更长的时间。我建议从 10 个 epochs 开始,确保代码能够完全运行,然后再用 100 个 epochs 重新运行。此外,一旦你确认代码可以运行并且得到了预测结果,返回并移除我们在创建数据集时放入的break行(它在批次循环中)。这将允许代码在所有样本上训练,而不仅仅是第一个批次。

model = Sequential(layers=layers)
model.compile(loss='mean_squared_error', optimizer='adam', metrics=['accuracy'])
import tensorflow as tf
history = model.fit(X_train, y_train, nb_epoch=25, verbose=True,
validation_data=(X_test, y_test),batch_size=1000))

最后,我们可以用网络进行预测并评估。

y_pred = model.predict(X_test)
from sklearn.metrics import classification_report
print(classification_report(y_pred=y_pred.argmax(axis=1),
 y_true=y_test.argmax(axis=1)))

运行 100 个 epochs 后,在这个案例中它仍然不是完美的,但仍然是一个非常好的结果。如果你有时间(比如一整夜),尝试运行 1000 个 epochs 的代码。准确率有所提高,但投入的时间回报却在减少。一个(不是那么)好的经验法则是,要减半错误,你需要将训练时间加倍。

将所有这些组合起来

现在我们已经让网络代码运行正常,我们可以在远程机器上用我们的训练数据集来训练它。如果你使用本地机器运行神经网络,你可以跳过这一部分。

我们需要将脚本上传到我们的虚拟机。和之前一样,点击文件|另存为,Python,并将脚本保存在你的电脑上的某个位置。启动并连接到虚拟机,然后像之前一样上传脚本(我给我的脚本命名为chapter11cifar.py——如果你命名不同,只需更新以下代码)。

接下来,我们需要将数据集放在虚拟机上。最简单的方法是进入虚拟机并输入以下命令:

$ wget http://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz

这将下载数据集。一旦下载完成,你可以通过首先创建该文件夹然后在该文件夹中解压缩数据来将数据提取到数据文件夹中:

$ mkdir Data

$ tar -zxf cifar-10-python.tar.gz -C Data

最后,我们可以用以下方式运行我们的示例:

$ python3 chapter11cifar.py

你首先会注意到速度上的显著提升。在我的家用电脑上,每个 epoch 需要超过 100 秒来运行。在启用 GPU 的虚拟机上,每个 epoch 只需 16 秒!如果我们尝试在我的电脑上运行 100 个 epochs,将近需要三个小时,而在虚拟机上只需 26 分钟。

这种显著的速度提升使得尝试不同的模型变得更快。通常在尝试机器学习算法时,单个算法的计算复杂度并不太重要。一个算法可能只需要几秒钟、几分钟或几小时来运行。如果你只运行一个模型,这个训练时间不太可能很重要——特别是预测,因为大多数机器学习算法的预测都非常快,这也是机器学习模型主要被使用的地方。

然而,当你有很多参数要运行时,你突然需要训练成千上万个具有略微不同参数的模型——突然,这些速度提升变得非常重要。

经过 100 个训练轮次,总共花费了 26 分钟,你将得到最终结果的打印输出:

0.8497

还不错!我们可以增加训练的轮数来进一步提高这个结果,或者我们可以尝试改变参数;也许,更多的隐藏节点、更多的卷积层,或者一个额外的密集层。Keras 还有其他类型的层可以尝试;尽管通常来说,卷积层更适合视觉。

摘要

在本章中,我们探讨了使用深度神经网络,特别是卷积网络,来进行计算机视觉。我们通过 Keras 包来实现,它使用 Tensorflow 或 Theano 作为其计算后端。使用 Keras 的辅助函数构建网络相对简单。

卷积网络是为计算机视觉设计的,所以结果相当准确并不令人惊讶。最终结果显示,计算机视觉确实是使用今天算法和计算能力的一个有效应用。

我们还使用了一个启用 GPU 的虚拟机来极大地加快了过程,我的机器速度提高了近 10 倍。如果你需要额外的计算能力来运行这些算法,云服务提供商的虚拟机可以是一个有效的方法(通常每小时不到一美元)——只是记得用完之后关掉它们!

为了扩展本章的工作,尝试调整网络结构,以进一步提高我们在这里获得的准确性。另一种提高准确性的方法是创建更多数据,无论是通过拍摄自己的照片(较慢)还是通过修改现有的照片(更快)。为了进行修改,你可以翻转图像上下颠倒、旋转、剪切等等。Keras 有一个做这个的函数,非常实用。请参阅keras.io/preprocessing/image/的文档。

值得调查的另一个领域是神经网络结构的变体,包括更多节点、更少节点、更多层等等。还可以尝试不同的激活类型、不同的层类型和不同的组合。

本章的重点是讨论一个非常复杂的算法。卷积网络训练时间较长,并且需要训练许多参数。最终,与数据集的大小相比,数据量较小;尽管这是一个大型数据集,但我们甚至无需使用稀疏矩阵就能将其全部加载到内存中。在下一章中,我们将转向一个更加简单的算法,但数据集规模大得多,无法全部装入内存。这是大数据的基础,也是支撑数据挖掘在许多大型行业(如采矿和社交网络)中应用的基础。

第十二章:与大数据一起工作

数据量正在以指数级增长。今天的系统正在生成和记录有关客户行为、分布式系统、网络分析、传感器以及许多其他来源的信息。虽然当前移动数据的大趋势正在推动当前的增长,但下一个大趋势——物联网(IoT)——将进一步增加增长速度。

这对数据挖掘意味着一种新的思维方式。需要高运行时间的复杂算法需要改进或丢弃,而可以处理更多样本的简单算法越来越受欢迎。例如,虽然支持向量机是优秀的分类器,但某些变体在处理非常大的数据集时难以使用。相比之下,像逻辑回归这样的简单算法在这些情况下更容易管理。

这种复杂性与分布问题只是深度神经网络(DNNs)变得如此受欢迎的原因之一。你可以使用 DNNs 创建非常复杂的模型,但也可以非常容易地将训练这些模型的负载分布到多台计算机上。

在本章中,我们将探讨以下内容:

  • 大数据挑战与应用

  • MapReduce 范式

  • Hadoop MapReduce

  • mrjob,一个在亚马逊 AWS 基础设施上运行 MapReduce 程序的 Python 库

大数据

大数据有什么不同之处?大多数大数据倡导者谈论大数据的四个 V:

  • Volume:我们生成和存储的数据量正在以越来越快的速度增长,对未来的一般预测只表明将进一步增加。今天的多吉字节大小的硬盘将在几年内变成艾字节大小的硬盘,网络吞吐量也将增加。信号与噪声的比例可能非常困难,重要数据可能丢失在非重要数据的大山中。

  • Velocity:虽然与数据量相关,但数据速度也在增加。现代汽车有数百个传感器将数据流式传输到它们的计算机中,而这些传感器提供的信息需要以亚秒级进行分析,以便操作汽车。这不仅仅是找到数据量中的答案;这些答案通常需要迅速得出。在某些情况下,我们甚至没有足够的磁盘空间来存储数据,这意味着我们还需要决定保留哪些数据以供后续分析。

  • Variety:具有明确定义列的优质数据集只是我们今天拥有的数据集的一小部分。考虑一下社交媒体帖子,它可能包含文本、照片、用户提及、点赞、评论、视频、地理信息和其他字段。简单地忽略不适合你模型的数据部分会导致信息丢失,但整合这些信息本身可能非常困难。

  • 真实性:随着数据量的增加,很难确定数据是否被正确收集——是否过时、嘈杂、包含异常值——或者总的来说是否有用。当人类无法可靠地验证数据时,能够信任数据是很困难的。外部数据集越来越多地被合并到内部数据集中,这也引发了更多与数据真实性相关的问题。

这四个主要“V”(其他人还提出了额外的“V”)概述了大数据与仅仅“大量数据”的不同之处。在这些规模下,处理数据的工程问题通常更加困难——更不用说分析问题了。虽然有很多卖假药的人夸大特定产品分析大数据的能力,但很难否认工程挑战和大数据分析潜力。

我们在书中迄今为止使用的算法是将数据集加载到内存中,然后在该内存版本上工作。这在计算速度方面带来了很大的好处(因为使用计算机内存比使用硬盘驱动器快),因为对内存中的数据进行计算比在用它之前加载样本要快得多。此外,内存数据允许我们多次迭代数据,从而提高我们的机器学习模型。

在大数据中,我们不能将数据加载到内存中。在许多方面,这可以作为一个很好的定义,来判断一个问题是否是大数据问题——如果数据可以适合你电脑的内存,那么你就不在处理大数据问题。

当查看你创建的数据时,例如你公司内部应用程序的日志数据,你可能想简单地将其全部放入一个文件中,不进行结构化,以后再使用大数据概念来分析它。最好不要这样做;相反,你应该为你的数据集使用结构化格式。原因是我们刚才概述的四个“V”实际上是需要解决以执行数据分析的问题,而不是需要努力实现的目标!

大数据的应用

大数据在公共和私营部门有许多用例。

人们使用基于大数据的系统最常见的体验是在互联网搜索中,例如 Google。为了运行这些系统,需要在不到一秒钟的时间内对数十亿个网站进行搜索。进行基于文本的基本搜索不足以处理这样的问题。简单地存储所有这些网站的文本就是一个大问题。为了处理查询,需要创建和实施专门针对此应用的新数据结构和数据挖掘方法。

大数据也被用于许多其他科学实验,例如大型强子对撞机,其中一部分在下面的图片中展示。它跨越 27 公里,包含 1.5 亿个传感器,每秒监测数亿次粒子碰撞。这个实验的数据量巨大,每天产生 25 拍字节的数据,经过过滤过程(如果没有过滤,每年将有 1.5 亿拍字节的数据)。对如此大量数据的分析导致了关于我们宇宙的惊人见解,但这已经是一个重大的工程和数据分析挑战。

图片

政府越来越多地使用大数据来追踪人口、企业和与其国家相关的其他方面。追踪数百万人和数十亿次的互动(如商业交易或医疗支出)导致许多政府机构需要大数据分析。

交通管理是全球许多政府关注的重点,他们通过数百万个传感器追踪交通情况,以确定哪些道路最拥堵,并预测新道路对交通水平的影响。这些管理系统将在不久的将来与自动驾驶汽车的数据相连,从而获得更多关于实时交通状况的数据。利用这些数据的城市会发现,他们的交通流动更加顺畅。

大型零售组织正在利用大数据来改善客户体验和降低成本。这包括预测客户需求,以便拥有正确的库存水平,向客户推荐他们可能喜欢购买的产品,并跟踪交易以寻找趋势、模式和潜在的欺诈行为。能够自动创建出色预测的公司可以在较低的成本下实现更高的销售额。

其他大型企业也在利用大数据来自动化其业务的一些方面并改善其产品。这包括利用分析来预测其行业未来的趋势和跟踪外部竞争对手。大型企业还使用分析来管理自己的员工——追踪员工以寻找他们可能离职的迹象,以便在他们这样做之前进行干预。

信息安全领域也在利用大数据来寻找大型网络中的恶意软件感染,通过监控网络流量来实现。这可能包括寻找异常流量模式、恶意软件传播的证据和其他异常情况。高级持续性威胁(APTs)也是一个问题,其中一名有动机的攻击者将他们的代码隐藏在大型网络中,以在长时间内窃取信息或造成损害。寻找 APTs 通常需要法医检查许多计算机,这是一个人类难以有效完成的任务。分析有助于自动化和分析这些法医图像以找到感染。

大数据正在越来越多地应用于各个领域和应用程序,这一趋势很可能会持续下去。

MapReduce

在大数据上进行数据挖掘和一般计算有许多概念。其中最受欢迎的是 MapReduce 模型,它可以用于对任意大型数据集进行一般计算。

MapReduce 起源于谷歌,它是在考虑分布式计算的情况下开发的。它还引入了容错性和可扩展性的改进。MapReduce 的原始研究于 2004 年发表,从那时起,已有成千上万个项目、实现和应用使用了它。

虽然这个概念与许多先前的概念相似,但 MapReduce 已经成为大数据分析的一个基本工具。

MapReduce 作业有两个主要阶段。

  1. 第一步是 Map,通过它我们取一个函数和一个项目列表,并将该函数应用于每个项目。换句话说,我们将每个项目作为函数的输入,并存储该函数调用的结果:

  1. 第二步是 Reduce,在这一步中,我们使用一个函数将 Map 步骤的结果组合起来。对于统计,这可以简单地将所有数字相加。在这个场景中,reduce 函数是一个加法函数,它会将前一个总和与新的结果相加:

在这两个步骤之后,我们将已经转换了我们的数据,并将其减少到最终结果。

MapReduce 作业可以有多个迭代,其中一些只是 Map 作业,一些只是 Reduce 作业,还有一些迭代既有 Map 步骤又有 Reduce 步骤。现在让我们看看一些更具体的例子,首先使用内置的 Python 函数,然后使用特定的 MapReduce 工具。

MapReduce 背后的直觉

MapReduce 有两个主要步骤:Map步骤和Reduce步骤。这些步骤建立在将函数映射到列表和减少结果的函数式编程概念之上。为了解释这个概念,我们将开发代码,它将遍历一个列表的列表,并产生这些列表中所有数字的总和。

在 MapReduce 范式中,还有shufflecombine步骤,我们将在后面看到。

首先,Map 步骤取一个函数并将其应用于列表中的每个元素。返回的结果是一个大小相同的列表,其中包含对每个元素应用函数的结果。

要打开一个新的 Jupyter Notebook,首先创建一个包含数字的列表的列表:

a = [[1,2,1], [3,2], [4,9,1,0,2]]

接下来,我们可以使用求和函数进行map操作。这一步将求和函数应用于a中的每个元素:

sums = map(sum, a)

虽然sums是一个生成器(实际值只有在请求时才会计算),但前面的步骤大约等于以下代码:

sums = []
for sublist in a:
    results = sum(sublist)
    sums.append(results)

reduce步骤稍微复杂一些。它涉及到将一个函数应用于返回结果的每个元素,以及某个起始值。我们从初始值开始,然后应用给定的函数到初始值和第一个值。然后我们将给定的函数应用于结果和下一个值,依此类推

我们首先创建一个函数,该函数接受两个数字并将它们相加。

def add(a, b): 
    return a + b

然后,我们执行 reduce 操作。reduce的签名是:reduce(function, sequence, initial),其中函数在每一步应用于序列。在第一步中,初始值用作第一个值而不是列表的第一个元素:

from functools import reduce 
print(reduce(add, sums, 0))

结果,25,是求和列表中每个值的总和,因此也是原始数组中每个元素的总和。

上述代码类似于以下代码:

initial = 0 
current_result = initial 
for element in sums: 
    current_result = add(current_result, element)

在这个简单的例子中,如果我们的代码不使用 MapReduce 范式,那么代码将会大大简化,但真正的收益来自于计算的分布。例如,如果我们有一百万个子列表,并且每个子列表包含一百万个元素,我们可以在多台计算机上分布这个计算。

为了做到这一点,我们通过分割数据来分配map步骤。对于我们列表中的每个元素,我们将其以及我们函数的描述发送到一台计算机。然后,这台计算机将结果返回到我们的主计算机(即主节点)。

主节点然后将结果发送到一台计算机进行reduce步骤。在我们的例子中,一百万个子列表,我们会将一百万个任务发送到不同的计算机(同一台计算机在完成我们的第一个任务后可能被重复使用)。返回的结果将只是一个包含一百万个数字的单列表,然后我们计算这些数字的总和。

结果是,没有任何计算机需要存储超过一百万个数字,尽管我们的原始数据中包含了一万亿个数字。

单词计数示例

任何实际的 MapReduce 实现都比仅仅使用mapreduce步骤要复杂一些。这两个步骤都是通过键来调用的,这允许数据的分离和值的跟踪。

map 函数接受一个键值对,并返回一个键值对列表。输入和输出的键不一定相互关联。

例如,对于一个执行单词计数的 MapReduce 程序,输入键可能是一个样本文档的 ID 值,而输出键将是给定的单词。输入值将是文档的文本,输出值将是每个单词的频率。我们拆分文档以获取单词,然后产生每个单词、计数对。在这里,单词是键,计数在 MapReduce 术语中是值:

from collections import defaultdict
def map_word_count(document_id, document):
    counts = defaultdict(int) 
    for word in document.split(): 
        counts[word] += 1
    for word in counts: 
        yield (word, counts[word])

如果你有一个非常大的数据集?你可以在遇到新单词时直接执行yield (word, 1),然后在洗牌步骤中合并它们,而不是在 map 步骤中进行计数。你放置的位置取决于你的数据集大小、每份文档的大小、网络容量以及一系列其他因素。大数据是一个巨大的工程问题,为了从系统中获得最佳性能,你需要模拟数据在整个算法中的流动方式。

以单词作为关键字,我们就可以执行一个洗牌步骤,该步骤将每个键的所有值分组:

def shuffle_words(results):
    records = defaultdict(list)
    for results in results_generators: 
        for word, count in results: 
            records[word].append(count)
    for word in records: 
        yield (word, records[word])

最后一步是减少步骤,它接受一个键值对(在这个例子中,值始终是一个列表)并生成一个键值对作为结果。在我们的例子中,键是单词,输入列表是在洗牌步骤中产生的计数列表,输出值是计数的总和:

def reduce_counts(word, list_of_counts): 
    return (word, sum(list_of_counts))

要看到这个动作的实际效果,我们可以使用 scikit-learn 提供的 20 个新闻组数据集。这个数据集不是大数据,但我们可以在这里看到概念的实际应用:

from sklearn.datasets import fetch_20newsgroups 
dataset = fetch_20newsgroups(subset='train') 
documents = dataset.data

然后我们应用我们的映射步骤。我们在这里使用 enumerate 来自动为我们生成文档 ID。虽然它们在这个应用中并不重要,但这些键在其他应用中很重要:

map_results = map(map_word_count, enumerate(documents))

这里实际的结果只是一个生成器;没有产生实际的计数。尽管如此,它是一个发出(单词,计数)对的生成器。

接下来,我们执行洗牌步骤来对这些单词计数进行排序:

shuffle_results = shuffle_words(map_results)

从本质上讲,这是一个 MapReduce 作业;然而,它只在一个线程上运行,这意味着我们没有从 MapReduce 数据格式中获得任何好处。在下一节中,我们将开始使用 Hadoop,一个开源的 MapReduce 提供者,以开始获得这种类型范式的好处。

Hadoop MapReduce

Hadoop 是 Apache 提供的一系列开源工具,包括 MapReduce 的一个实现。在许多情况下,它是许多人所使用的默认实现。该项目由 Apache 集团管理(他们负责同名的著名网络服务器)。

Hadoop 生态系统相当复杂,包含大量工具。我们将使用的主要组件是 Hadoop MapReduce。Hadoop 中包含的其他用于处理大数据的工具如下:

  • Hadoop 分布式文件系统(HDFS):这是一个可以在多台计算机上存储文件的文件系统,旨在在提供高带宽的同时,对硬件故障具有鲁棒性。

  • YARN:这是一种用于调度应用程序和管理计算机集群的方法。

  • Pig:这是一种用于 MapReduce 的高级编程语言。Hadoop MapReduce 是用 Java 实现的,而 Pig 位于 Java 实现之上,允许你用其他语言编写程序——包括 Python。

  • Hive:这是用于管理数据仓库和执行查询的。

  • HBase:这是 Google 的 BigTable 的一个实现,一个分布式数据库。

这些工具都解决了在大数据实验中出现的问题,包括数据分析。

还有基于非 Hadoop 的 MapReduce 实现,以及其他具有类似目标的项目。此外,许多云服务提供商都有基于 MapReduce 的系统。

应用 MapReduce

在这个应用程序中,我们将研究根据作者使用不同词汇来预测作者的性别。我们将使用朴素贝叶斯方法进行此操作,并在 MapReduce 中进行训练。最终的模型不需要 MapReduce,尽管我们可以使用 Map 步骤来这样做——也就是说,在列表中的每个文档上运行预测模型。这是 MapReduce 中数据挖掘的常见 Map 操作,而 reduce 步骤只是组织预测列表,以便可以追溯到原始文档。

我们将使用亚马逊的基础设施来运行我们的应用程序,这样我们可以利用他们的计算资源。

获取数据

我们将要使用的数据是一组标记了年龄、性别、行业(即工作)以及有趣的是,星座的博客。这些数据是在 2004 年 8 月从blogger.com收集的,包含超过 600,000 篇帖子,超过 1.4 亿个单词。每篇博客可能是由一个人写的,尽管我们投入了一些工作来验证这一点(尽管,我们永远不能完全确定)。帖子还与发布日期相匹配,这使得这是一个非常丰富的数据集。

要获取数据,请访问u.cs.biu.ac.il/~koppel/BlogCorpus.htm并点击下载语料库。从那里,将文件解压缩到您的计算机上的一个目录中。

数据集以单个博客对应一个文件的方式组织,文件名表示类别。例如,以下是一个文件名:

1005545.male.25.Engineering.Sagittarius.xml

文件名由点分隔,字段如下:

  • 博主 ID:这是一个简单的 ID 值,用于组织身份。

  • 性别:这是男性或女性,所有博客都被标识为这两种选项之一(此数据集中不包含其他选项)。

  • 年龄:给出了确切的年龄,但故意存在一些间隔。存在的年龄范围在(包含)13-17 岁、23-27 岁和 33-48 岁之间。存在间隔的原因是为了允许将博客分成有间隔的年龄范围,因为将 18 岁年轻人的写作与 19 岁年轻人的写作区分开来相当困难,而且年龄本身可能已经有些过时,可能需要更新到 19 岁。

  • 行业:包括科学、工程、艺术和房地产在内的 40 个不同行业中。还包括 indUnk,表示未知行业。

  • 星座:这是 12 个占星术星座之一。

所有值都是自行报告的,这意味着可能会有错误或不一致,但假设它们大多是可靠的——人们有选择不设置值以保护他们隐私的方式。

单个文件采用伪 XML 格式,包含一个<Blog>标签,然后是一系列<post>标签。每个<post>标签之前都有一个<date>标签。虽然我们可以将其解析为 XML,但由于文件并非完全符合良好格式的 XML,存在一些错误(主要是编码问题),因此按行逐行解析要简单得多。为了读取文件中的帖子,我们可以使用循环遍历行。

我们设置一个测试文件名,以便我们可以看到这个动作:

import os 
filename = os.path.join(os.path.expanduser("~"), "Data", "blogs", "1005545.male.25.Engineering.Sagittarius.xml")

首先,我们创建一个列表,以便我们可以存储每篇帖子:

all_posts = []

然后,我们打开文件进行读取:

with open(filename) as inf:
    post_start = False
    post = []
    for line in inf: 
        line = line.strip()
        if line == "<post>":
            # Found a new post
            post_start = True 
        elif line == "</post>":
            # End of the current post, append to our list of posts and start a new one
            post_start = False
            all_posts.append("n".join(post))
            post = []
        elif post_start:
            # In a current post, add the line to the text of the post
            post.append(line)

如果我们不在当前帖子中,我们只需忽略该行。

然后,我们可以获取每篇帖子的文本:

print(all_posts[0])

我们还可以找出这位作者创建了多少篇帖子:

print(len(all_posts))

朴素贝叶斯预测

现在,我们将使用 mrjob 实现朴素贝叶斯算法,使其能够处理我们的数据集。技术上,我们的版本将是大多数朴素贝叶斯实现的一个简化版本,没有许多您预期的功能,如平滑小值。

mrjob 包

mrjob包允许我们创建可以在亚马逊基础设施上轻松计算的 MapReduce 作业。虽然 mrjob 听起来像是儿童书籍《先生们》系列的一个勤奋的补充,但它代表的是Map Reduce Job

您可以使用以下命令安装 mrjob:pip install ``mrjob

我不得不单独使用conda install -c conda-forge filechunkio安装 filechunkio 包,但这将取决于您的系统设置。还有其他 Anaconda 通道可以安装 mrjob,您可以使用以下命令检查它们:

anaconda search -t conda mrjob

事实上,mrjob 提供了大多数 MapReduce 作业所需的标准功能。它最令人惊叹的功能是您可以编写相同的代码,在本地机器上测试(无需像 Hadoop 这样的重基础设施),然后推送到亚马逊的 EMR 服务或另一个 Hadoop 服务器。

这使得测试代码变得容易得多,尽管它不能神奇地将大问题变小——任何本地测试都使用数据集的子集,而不是整个大数据集。相反,mrjob 为您提供了一个框架,您可以使用小问题进行测试,并更有信心解决方案可以扩展到更大的问题,分布在不同的系统上。

提取博客帖子

我们首先将创建一个 MapReduce 程序,该程序将从每个博客文件中提取每篇帖子,并将它们作为单独的条目存储。由于我们对帖子的作者性别感兴趣,我们还将提取该信息并将其与帖子一起存储。

我们不能在 Jupyter Notebook 中做这件事,所以相反,打开一个 Python IDE 进行开发。如果您没有 Python IDE,可以使用文本编辑器。我推荐 PyCharm,尽管它有一个较大的学习曲线,而且可能对于本章的代码来说有点重。

至少,我推荐使用具有语法高亮和基本变量名补全的 IDE(最后一个有助于轻松找到代码中的错误)。

如果您仍然找不到喜欢的 IDE,您可以在 IPython 笔记本中编写代码,然后点击文件|下载为|Python。将此文件保存到目录中,然后按照我们在第十一章中概述的方式运行它,使用深度学习对图像中的对象进行分类

要做到这一点,我们需要osre库,因为我们将会获取环境变量,我们还将使用正则表达式进行单词分隔:

import os 
import re

然后我们导入 MRJob 类,我们将从我们的 MapReduce 作业中继承它:

from mrjob.job import MRJob

然后,我们创建一个新的类,该类继承自 MRJob。我们将使用与之前类似的循环来从文件中提取博客文章。我们将定义的映射函数将针对每一行工作,这意味着我们必须在映射函数外部跟踪不同的帖子。因此,我们将post_startpost作为类变量,而不是函数内部的变量。然后我们定义我们的映射函数——这个函数从文件中读取一行作为输入,并产生博客文章。这些行保证是从同一个作业文件中按顺序排列的。这允许我们使用上面的类变量来记录当前帖子数据:

class ExtractPosts(MRJob):
    post_start = False 
    post = []

    def mapper(self, key, line):
        filename = os.environ["map_input_file"]
        # split the filename to get the gender (which is the second token)
        gender = filename.split(".")[1]
        line = line.strip()
        if line == "<post>":
            self.post_start = True
        elif line == "</post>":
            self.post_start = False
            yield gender, repr("n".join(self.post))
            self.post = []
        elif self.post_start:
            self.post.append(line)

与我们之前将帖子存储在列表中的做法不同,我们现在产生它们。这允许 mrjob 跟踪输出。我们产生性别和帖子,这样我们就可以记录每个记录匹配的性别。这个函数的其余部分与上面的循环定义方式相同。

最后,在函数和类外部,我们将脚本设置为在从命令行调用时运行此 MapReduce 作业:

if __name__ == '__main__': 
 ExtractPosts.run()

现在,我们可以使用以下 shell 命令运行此 MapReduce 作业。

$ python extract_posts.py <your_data_folder>/blogs/51* 
 --output-dir=<your_data_folder>/blogposts --no-output

提醒一下,您不需要在上面的行中输入$ - 这只是表示这是一个从命令行运行的命令,而不是在 Jupyter 笔记本中。

第一个参数,<your_data_folder>/blogs/51*(只需记住将<your_data_folder>更改为您数据文件夹的完整路径),获取数据样本(所有以 51 开头的文件,这只有 11 个文档)。然后我们将输出目录设置为一个新的文件夹,我们将它放在数据文件夹中,并指定不要输出流数据。如果没有最后一个选项,当运行时,输出数据会显示在命令行上——这对我们来说并不很有帮助,并且会大大减慢计算机的速度。

运行脚本,并且相当快地,每篇博客文章都会被提取并存储在我们的输出文件夹中。这个脚本只在本地计算机上的单个线程上运行,所以我们根本得不到加速,但我们知道代码是运行的。

现在,我们可以在输出文件夹中查看结果。创建了一堆文件,每个文件都包含每篇博客文章,每篇文章之前都标有博客作者的性别。

训练朴素贝叶斯

既然我们已经提取了博客文章,我们就可以在它们上面训练我们的朴素贝叶斯模型。直觉是,我们记录一个词被特定性别写下的概率,并将这些值记录在我们的模型中。为了分类一个新的样本,我们将乘以概率并找到最可能的性别。

这段代码的目的是输出一个文件,列出语料库中的每个单词,以及该单词在每个性别写作中的频率。输出文件看起来可能像这样:

"'ailleurs" {"female": 0.003205128205128205}
"'air" {"female": 0.003205128205128205}
"'an" {"male": 0.0030581039755351682, "female": 0.004273504273504274}
"'angoisse" {"female": 0.003205128205128205}
"'apprendra" {"male": 0.0013047113868622459, "female": 0.0014172668603481887}
"'attendent" {"female": 0.00641025641025641}
"'autistic" {"male": 0.002150537634408602}
"'auto" {"female": 0.003205128205128205}
"'avais" {"female": 0.00641025641025641}
"'avait" {"female": 0.004273504273504274}
"'behind" {"male": 0.0024390243902439024} 
"'bout" {"female": 0.002034152292059272}

第一个值是单词,第二个是一个将性别映射到该性别写作中该单词频率的字典。

在你的 Python IDE 或文本编辑器中打开一个新的文件。我们还需要osre库,以及来自mrjobNumPyMRJob。我们还需要itemgetter,因为我们将会对一个字典进行排序:

import os 
import re 
import numpy as np 
from mrjob.job import MRJob 
from operator import itemgetter

我们还需要MRStep,它概述了 MapReduce 作业中的一个步骤。我们之前的作业只有一个步骤,它被定义为映射函数然后是减少函数。这个作业将会有多个步骤,我们将进行映射、减少,然后再进行映射和减少。直觉与我们在早期章节中使用的管道相同,其中一步的输出是下一步的输入:

from mrjob.step import MRStep

我们然后创建我们的单词搜索正则表达式并编译它,这样我们就可以找到单词边界。这种正则表达式比我们在一些早期章节中使用的简单分割更强大,但如果你在寻找一个更准确的单词分割器,我建议使用 NLTK 或 Spacey,就像我们在第六章,使用朴素贝叶斯进行社交媒体洞察中做的那样:

word_search_re = re.compile(r"[w']+") 

我们为我们的训练定义了一个新的类。我首先会提供一个完整的代码块,然后我们将回到每个部分来回顾它所做的工作:

class NaiveBayesTrainer(MRJob):

    def steps(self):
    return [
            MRStep(mapper=self.extract_words_mapping,
                   reducer=self.reducer_count_words),
            MRStep(reducer=self.compare_words_reducer),
    ]

    def extract_words_mapping(self, key, value):
        tokens = value.split()
        gender = eval(tokens[0])
        blog_post = eval(" ".join(tokens[1:]))
        all_words = word_search_re.findall(blog_post)
        all_words = [word.lower() for word in all_words]
        for word in all_words:
            # Occurence probability
            yield (gender, word), 1\. / len(all_words)

    def reducer_count_words(self, key, counts):
        s = sum(counts)
        gender, word = key #.split(":")
        yield word, (gender, s)

    def compare_words_reducer(self, word, values):
        per_gender = {}
        for value in values:
            gender, s = value
            per_gender[gender] = s
            yield word, per_gender

    def ratio_mapper(self, word, value):
        counts = dict(value)
        sum_of_counts = float(np.mean(counts.values()))
        maximum_score = max(counts.items(), key=itemgetter(1))
        current_ratio = maximum_score[1] / sum_of_counts
        yield None, (word, sum_of_counts, value)

    def sorter_reducer(self, key, values):
        ranked_list = sorted(values, key=itemgetter(1), reverse=True)
        n_printed = 0
        for word, sum_of_counts, scores in ranked_list:
            if n_printed < 20:
                print((n_printed + 1), word, scores)
            n_printed += 1
        yield word, dict(scores)

让我们一步一步地看看这个代码的各个部分:

class NaiveBayesTrainer(MRJob):

我们定义了我们的 MapReduce 作业的步骤。有两个步骤:

第一步将提取单词出现概率。第二步将比较两个性别,并将每个性别的概率输出到我们的输出文件。在每个 MRStep 中,我们定义映射器和减少器函数,这些函数是这个朴素贝叶斯训练器类中的类函数(我们将在下面编写这些函数):

    def steps(self):
        return [
            MRStep(mapper=self.extract_words_mapping,
                   reducer=self.reducer_count_words),
            MRStep(reducer=self.compare_words_reducer),
        ]

第一个函数是第一步的映射器函数。这个函数的目标是取每一篇博客文章,获取该文章中的所有单词,并记录出现次数。我们想要单词的频率,所以我们将返回1 / len(all_words),这样我们就可以在之后对频率值求和。这里的计算并不完全正确——我们需要对文档数量进行归一化。然而,在这个数据集中,类的大小是相同的,所以我们方便地忽略这一点,对最终版本的影响很小。

我们还输出了文章作者的性别,因为我们稍后会需要它:

    def extract_words_mapping(self, key, value):
        tokens = value.split()
        gender = eval(tokens[0])
        blog_post = eval(" ".join(tokens[1:]))
        all_words = word_search_re.findall(blog_post)
        all_words = [word.lower() for word in all_words]
        for word in all_words:
            # Occurence probability
            yield (gender, word), 1\. / len(all_words)

我们在上一段代码中使用了eval来简化从文件中解析博客文章的过程,为了这个示例。这并不推荐。相反,使用 JSON 这样的格式来正确存储和解析文件中的数据。一个可以访问数据集的恶意用户可以将代码插入这些标记中,并让这些代码在您的服务器上运行。

在第一步的 reducer 中,我们为每个性别和单词对的总频率求和。我们还更改了键,使其成为单词,而不是组合,这样当我们使用最终训练好的模型进行搜索时,我们可以按单词搜索(尽管,我们仍然需要输出性别以供以后使用);

    def reducer_count_words(self, key, counts):
        s = sum(counts)
        gender, word = key #.split(":")
        yield word, (gender, s)

最后一步不需要 mapper 函数,这就是为什么我们没有添加一个。数据将直接作为一种类型的身份 mapper 通过。然而,reducer 将合并给定单词下的每个性别的频率,然后输出单词和频率字典。

这为我们朴素贝叶斯实现提供了所需的信息:

    def compare_words_reducer(self, word, values):
        per_gender = {}
        for value in values:
            gender, s = value
            per_gender[gender] = s
            yield word, per_gender

最后,我们将代码设置为在文件作为脚本运行时运行此模型。我们需要将此代码添加到文件中:

if __name__ == '__main__': 
 NaiveBayesTrainer.run()

然后,我们可以运行这个脚本。这个脚本的输入是上一个后提取脚本(如果您愿意,实际上可以将它们作为同一个 MapReduce 作业中的不同步骤)的输出;

$ python nb_train.py <your_data_folder>/blogposts/ 
 --output-dir=<your_data_folder>/models/ --no-output

输出目录是一个文件夹,将存储包含 MapReduce 作业输出的文件,这将是我们运行朴素贝叶斯分类器所需的概率。

将所有内容整合在一起

我们现在可以使用这些概率实际运行朴素贝叶斯分类器。我们将使用 Jupyter Notebook 来完成这项工作,尽管这个处理本身可以被转移到 mrjob 包中,以进行大规模处理。

首先,查看在上一个 MapReduce 作业中指定的models文件夹。如果输出文件多于一个,我们可以通过将它们附加在一起来合并文件,使用models目录内的命令行功能:

cat * > model.txt

如果这样做,您需要更新以下代码,将model.txt作为模型文件名。

回到我们的 Notebook,我们首先导入一些标准导入,这些导入对于我们的脚本来说是必需的:

import os 
import re
import numpy as np 
from collections import defaultdict 
from operator import itemgetter

我们再次重新定义我们的单词搜索正则表达式——如果您在实际应用中这样做,我建议集中化功能。对于训练和测试来说,单词的提取方式必须相同:

word_search_re = re.compile(r"[w']+")

接下来,我们创建一个函数,从给定的文件名中加载我们的模型。模型参数将采用字典的字典形式,其中第一个键是一个单词,内部字典将每个性别映射到一个概率。我们使用defaultdicts,如果值不存在,则返回零;

def load_model(model_filename):
    model = defaultdict(lambda: defaultdict(float))
    with open(model_filename) as inf: 
        for line in inf:
            word, values = line.split(maxsplit=1) 
            word = eval(word) 
            values = eval(values)
            model[word] = values
    return model

这行被分成两个部分,由空格分隔。第一部分是单词本身,第二部分是概率的字典。对于每个部分,我们运行eval以获取实际值,该值在之前的代码中使用repr存储。

接下来,我们加载我们的实际模型。你可能需要更改模型文件名——它将在最后一个 MapReduce 作业的输出目录中;

model_filename = os.path.join(os.path.expanduser("~"), "models", "part-00000") 
model = load_model(model_filename)

例如,我们可以看到在男性与女性之间,单词 i(在 MapReduce 作业中所有单词都转换为小写)的使用差异:

model["i"]["male"], model["i"]["female"]

接下来,我们创建一个函数,该函数可以使用这个模型进行预测。在这个例子中,我们不会使用 scikit-learn 接口,而是创建一个函数。我们的函数接受模型和文档作为参数,并返回最可能的性别:

def nb_predict(model, document):
    probabilities = defaultdict(lambda : 1)
    words = word_search_re.findall(document)
    for word in set(words): 
        probabilities["male"] += np.log(model[word].get("male", 1e-15)) 
        probabilities["female"] += np.log(model[word].get("female", 1e-15))
        most_likely_genders = sorted(probabilities.items(), key=itemgetter(1), reverse=True) 
    return most_likely_genders[0][0]

重要的是要注意,我们使用了np.log来计算概率。朴素贝叶斯模型中的概率通常非常小。对于许多统计值来说,乘以小值是必要的,这可能导致下溢错误,即计算机的精度不够好,整个值变成 0。在这种情况下,这会导致两种性别的似然值都为零,从而导致预测错误。

为了解决这个问题,我们使用对数概率。对于两个值 a 和 b,log(a× b) 等于 log(a) + log(b)。小概率的对数是一个负值,但相对较大。例如,log(0.00001)大约是-11.5。这意味着,而不是乘以实际概率并冒着下溢错误的风险,我们可以相加对数概率,并以相同的方式比较值(数值仍然表示更高的可能性)。

如果你想要从对数概率中获取概率,确保通过使用 e 的幂来撤销对数操作。要将-11.5 转换成概率,取 e^(-11.5),这等于 0.00001(大约)。

使用对数概率的一个问题是它们不能很好地处理零值(尽管,乘以零概率也不能)。这是因为对数(0)是未定义的。在一些朴素贝叶斯实现中,为了解决这个问题,会将所有计数加 1,但还有其他方法可以解决这个问题。这是对数值进行简单平滑的一种形式。在我们的代码中,如果对于给定的性别没有看到这个单词,我们只返回一个非常小的值。

在所有计数上加一是平滑的一种形式。另一种选择是初始化到一个非常小的值,例如 10^(-16)——只要它不是正好是 0!

回到我们的预测函数,我们可以通过复制数据集中的一个帖子来测试这个函数:

new_post = """ Every day should be a half day. Took the afternoon off to hit the dentist, and while I was out I managed to get my oil changed, too. Remember that business with my car dealership this winter? Well, consider this the epilogue. The friendly fellas at the Valvoline Instant Oil Change on Snelling were nice enough to notice that my dipstick was broken, and the metal piece was too far down in its little dipstick tube to pull out. Looks like I'm going to need a magnet. Damn you, Kline Nissan, daaaaaaammmnnn yooouuuu.... Today I let my boss know that I've submitted my Corps application. The news has been greeted by everyone in the company with a level of enthusiasm that really floors me. The back deck has finally been cleared off by the construction company working on the place. This company, for anyone who's interested, consists mainly of one guy who spends his days cursing at his crew of Spanish-speaking laborers. Construction of my deck began around the time Nixon was getting out of office.
"""

然后,我们使用以下代码进行预测:

nb_predict(model, new_post)

结果预测,男性,对于这个例子是正确的。当然,我们永远不会在单个样本上测试一个模型。我们使用了以 51 开头的文件来训练这个模型。样本并不多,所以我们不能期望很高的准确度。

我们应该做的第一件事是在更多样本上训练。我们将测试以 6 或 7 开头的任何文件,并在其余文件上训练。

在命令行和你的数据文件夹(cd <your_data_folder>),其中存在博客文件夹,将博客文件夹复制到一个新文件夹中。

为我们的训练集创建一个文件夹:

mkdir blogs_train

将以 6 或 7 开头的任何文件移动到测试集,从训练集中:

cp blogs/4* blogs_train/ 
cp blogs/8* blogs_train/

然后,为我们的测试集创建一个文件夹:

mkdir blogs_test

将以 6 或 7 开头的任何文件移动到测试集,从训练集中:

cp blogs/6* blogs_test/ 
cp blogs/7* blogs_test/

我们将在训练集中的所有文件上重新运行博客提取。然而,这是一个更适合云基础设施而不是我们系统的大计算量。因此,我们现在将解析工作迁移到亚马逊的基础设施。

在命令行上运行以下命令,就像之前一样。唯一的区别是我们将在不同的输入文件文件夹上训练。在运行以下代码之前,请删除博客文章和模型文件夹中的所有文件:

$ python extract_posts.py ~/Data/blogs_train --output-dir=/home/bob/Data/blogposts_train --no-output

接下来是训练我们的朴素贝叶斯模型。这里的代码运行时间会相当长。可能需要很多很多小时。除非你有一个非常强大的系统,否则你可能想跳过本地运行这一步!如果你想跳过,请转到下一节。

$ python nb_train.py ~/Data/blogposts_train/ --output-dir=/home/bob/models/ --no-output

我们将在测试集中的任何博客文件上进行测试。为了获取这些文件,我们需要提取它们。我们将使用extract_posts.py MapReduce 作业,但将文件存储在单独的文件夹中:

python extract_posts.py ~/Data/blogs_test --output-dir=/home/bob/Data/blogposts_test --no-output

在 Jupyter Notebook 中,我们列出所有输出的测试文件:

testing_folder = os.path.join(os.path.expanduser("~"), "Data", "blogposts_testing") 
testing_filenames = [] 
for filename in os.listdir(testing_folder): 
    testing_filenames.append(os.path.join(testing_folder, filename))

对于这些文件中的每一个,我们提取性别和文档,然后调用预测函数。我们这样做是因为有很多文档,我们不希望使用太多内存。生成器产生实际的性别和预测的性别:

def nb_predict_many(model, input_filename): 
    with open(input_filename) as inf: # remove leading and trailing whitespace 
    for line in inf: 
        tokens = line.split() 
        actual_gender = eval(tokens[0]) 
        blog_post = eval(" ".join(tokens[1:])) 
        yield actual_gender, nb_predict(model, blog_post)

然后,我们在整个数据集上记录预测和实际的性别。这里的预测要么是男性,要么是女性。为了使用 scikit-learn 中的f1_score函数,我们需要将这些转换为 1 和 0。为了做到这一点,我们记录性别为男性时为 0,性别为女性时为 1。为此,我们使用布尔测试,查看性别是否为女性。然后,我们使用 NumPy 将这些布尔值转换为int

y_true = []
y_pred = [] 
for actual_gender, predicted_gender in nb_predict_many(model, testing_filenames[0]):                    
    y_true.append(actual_gender == "female")   
    y_pred.append(predicted_gender == "female") 
    y_true = np.array(y_true, dtype='int') 
    y_pred = np.array(y_pred, dtype='int')

现在,我们使用 scikit-learn 中的 F1 分数来测试这个结果的质量:

from sklearn.metrics import f1_score 
print("f1={:.4f}".format(f1_score(y_true, y_pred, pos_label=None)))

0.78 的结果相当合理。我们可能可以通过使用更多数据来提高这个结果,但为了做到这一点,我们需要迁移到一个更强大的基础设施,它可以处理这些数据。

在亚马逊的 EMR 基础设施上训练

我们将使用亚马逊的弹性映射减少EMR)基础设施来运行我们的解析和模型构建作业。

为了做到这一点,我们首先需要在亚马逊的存储云中创建一个存储桶。为此,通过访问console.aws.amazon.com/s3在您的网络浏览器中打开亚马逊 S3 控制台,并点击创建存储桶。记住存储桶的名称,因为我们稍后会用到它。

右键单击新存储桶并选择属性。然后,更改权限,授予所有人完全访问权限。这通常不是一种好的安全实践,我建议您在完成本章后更改访问权限。您可以使用亚马逊服务的高级权限来授予您的脚本访问权限,并防止第三方查看您的数据。

左键单击存储桶以打开它,然后单击创建文件夹。将文件夹命名为 blogs_train。我们将把我们的训练数据上传到这个文件夹,以便在云上进行处理。

在您的计算机上,我们将使用亚马逊的 AWS CLI,这是亚马逊云上处理的一个命令行界面。

要安装它,请使用以下命令:

sudo pip install awscli

按照以下说明设置此程序的凭证:docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-set-up.html

我们现在想要将我们的数据上传到我们的新存储桶。首先,我们需要创建我们的数据集,即所有不以 6 或 7 开头的博客。虽然还有更优雅的方式来执行这个复制操作,但没有一个是足够跨平台的,值得推荐。相反,只需简单地复制所有文件,然后从训练数据集中删除以 6 或 7 开头的文件:

cp -R ~/Data/blogs ~/Data/blogs_train_large 
rm ~/Data/blogs_train_large/blogs/6* 
rm ~/Data/blogs_train_large/blogs/7*

接下来,将数据上传到您的亚马逊 S3 存储桶。请注意,这将花费一些时间,并且会使用相当多的上传数据(数百万字节)。对于那些互联网连接较慢的用户,在更快连接的地方进行此操作可能值得;

aws s3 cp ~/Data/blogs_train_large/ s3://ch12/blogs_train_large --recursive --exclude "*" 
--include "*.xml"

我们将使用 mrjob 连接到亚马逊的 EMR(弹性映射减少),它为我们处理整个流程;它只需要我们的凭证来完成这项任务。按照pythonhosted.org/mrjob/guides/emr-quickstart.html中的说明,使用您的亚马逊凭证设置 mrjob。

完成此操作后,我们仅对 mrjob 运行进行轻微的修改,以便在亚马逊 EMR 上运行。我们只需告诉 mrjob 使用-r 开关来使用 emr,然后设置我们的 s3 容器作为输入和输出目录。尽管这将运行在亚马逊的基础设施上,但它仍然需要相当长的时间来运行,因为 mrjob 的默认设置使用的是单个低功耗计算机。

$ python extract_posts.py -r emr s3://ch12gender/blogs_train_large/ 
--output-dir=s3://ch12/blogposts_train/ --no-output 
$ python nb_train.py -r emr s3://ch12/blogposts_train/ --output-dir=s3://ch12/model/ --o-output

您将因使用 S3 和 EMR 而付费。这只会花费几美元,但如果您打算继续运行作业或在更大的数据集上执行其他作业,请记住这一点。我运行了大量的作业,总共花费了大约 20 美元。仅运行这些作业应该不到 4 美元。然而,您可以通过访问console.aws.amazon.com/billing/home来检查您的余额并设置价格警报;

blogposts_train 和 model 文件夹的存在并不是必需的——它们将由 EMR 创建。实际上,如果它们存在,你会得到一个错误。如果你要重新运行,只需将这些文件夹的名称更改为新的名称,但请记住将两个命令都改为相同的名称(即第一个命令的输出目录是第二个命令的输入目录)。

如果你感到不耐烦,你可以在一段时间后停止第一个任务,只需使用到目前为止收集到的训练数据。我建议至少让任务运行 15 分钟,可能至少一个小时。但是,你不能停止第二个任务并得到好的结果;第二个任务可能需要比第一个任务长两到三倍的时间。

如果你有能力购买更先进的硬件,mrjob 支持在 Amazon 的基础设施上创建集群,并且也支持使用更强大的计算硬件。你可以在命令行中指定类型和数量来在机器集群上运行一个任务。例如,要使用 16 台 c1.medium 计算机提取文本,请运行以下命令:

$ python extract_posts.py -r emr s3://chapter12/blogs_train_large/blogs/ --output-dir=s3://chapter12/blogposts_train/ --no-output  --instance-type c1.medium --num-core-instances 16

此外,你可以单独创建集群并将任务重新附加到这些集群上。有关此过程的更多信息,请参阅 mrjob 的文档pythonhosted.org/mrjob/guides/emr-advanced.html。请注意,更高级的选项是 mrjob 的高级功能和 Amazon 的 AWS 基础设施的高级功能之间的交互,这意味着你需要研究这两种技术以获得强大的处理能力。请注意,如果你运行更多更强大的硬件实例,你将相应地支付更多费用。

现在,你可以回到 s3 控制台并从你的存储桶中下载输出模型。将其保存在本地后,我们可以回到我们的 Jupyter Notebook 并使用新的模型。我们在此重新输入代码——只有差异被突出显示,只是为了更新到我们的新模型:

ws_model_filename = os.path.join(os.path.expanduser("~"), "models", "aws_model")
aws_model = load_model(aws_model_filename) 
y_true = [] 
y_pred = [] 
for actual_gender, predicted_gender in nb_predict_many(aws_model, testing_filenames[0]):
    y_true.append(actual_gender == "female") 
    y_pred.append(predicted_gender == "female") 
y_true = np.array(y_true, dtype='int') 
y_pred = np.array(y_pred, dtype='int') 
print("f1={:.4f}".format(f1_score(y_true, y_pred, pos_label=None)))

结果更好,达到 0.81。

如果一切按计划进行,你可能想从 Amazon S3 中删除该存储桶——你将为此付费。

摘要

在本章中,我们讨论了在大数据上运行任务。根据大多数标准,我们的数据集实际上相当小——只有几百兆字节。许多工业数据集要大得多,因此需要额外的处理能力来执行计算。此外,我们使用的算法可以根据不同的任务进行优化,以进一步提高可扩展性。

我们的方法从博客文章中提取单词频率,以预测文档作者的性别。我们使用基于 mrjob 的 MapReduce 项目提取博客和单词频率。有了这些提取的数据,我们就可以执行类似朴素贝叶斯(Naive Bayes)的计算来预测新文档的性别。

我们只是触及了 MapReduce 所能做到的一小部分,而且在这个应用中甚至没有充分利用它的全部潜力。为了进一步吸取教训,将预测函数转换为 MapReduce 作业。也就是说,你在 MapReduce 上训练模型以获得一个模型,然后使用 MapReduce 运行模型以获取预测列表。通过在 MapReduce 中执行评估来扩展这一过程,最终结果简单地以 F1 分数的形式返回!

我们可以使用 mrjob 库在本地进行测试,然后自动设置并使用亚马逊的 EMR 云基础设施。您可以使用其他云基础设施,甚至自定义构建的亚马逊 EMR 集群来运行这些 MapReduce 作业,但需要做一些额外的调整才能使它们运行。