从零开始的数据科学第二版-全-

从零开始的数据科学第二版(全)

原文:zh.annas-archive.org/md5/48ab308fc34189a6d7d26b91b72a6df9

译者:飞龙

协议:CC BY-NC-SA 4.0

第二版序言

我对《从零开始的数据科学》第一版感到异常自豪。它确实成为了我想要的那本书。但是,在数据科学的几年发展中,在 Python 生态系统的进步以及作为开发者和教育者的个人成长中,改变了我认为第一本数据科学入门书应该是什么样子的。

在生活中,没有重来的机会。但在写作中,有第二版。

因此,我重新编写了所有代码和示例,使用了 Python 3.6(以及其新引入的许多功能,如类型注解)。我强调编写清晰代码的理念贯穿整本书。我用“真实”数据集替换了第一版中的一些玩具示例。我添加了关于深度学习、统计学和自然语言处理等主题的新材料,这些都是今天的数据科学家可能正在处理的内容。(我还删除了一些似乎不太相关的内容。)我仔细检查了整本书,修复了错误,重写了不够清晰的解释,并更新了一些笑话。

第一版是一本很棒的书,而这一版更好。享受吧!

  • Joel Grus

  • 华盛顿州西雅图

  • 2019

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序清单,以及段落内指代程序元素(如变量或函数名、数据库、数据类型、环境变量、语句和关键字)。

等宽粗体

显示用户应该按照字面意思输入的命令或其他文本。

等宽斜体

显示应该用用户提供的值或上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/joelgrus/data-science-from-scratch下载。

本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在自己的程序和文档中使用它们。除非您要复制大量代码,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发包含 O'Reilly 书籍示例的 CD-ROM 需要许可。通过引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码纳入产品文档中需要许可。

我们感谢您的评价,但不要求。署名通常包括标题、作者、出版商和 ISBN。例如:“从零开始的数据科学,第二版,作者 Joel Grus(奥莱利)。版权所有 2019 年 Joel Grus,978-1-492-04113-9。”

如果您认为您使用的代码示例超出了合理使用范围或上述权限,请随时与我们联系:permissions@oreilly.com

奥莱利在线学习

注意

近 40 年来,奥莱利媒体一直为企业提供技术和商业培训、知识和见解,帮助它们取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议以及我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编程环境以及来自奥莱利和其他 200 多家出版商的大量文本和视频内容。欲了解更多信息,请访问 http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版社:

  • 奥莱利媒体公司

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为这本书建立了一个网页,列出勘误、示例和任何其他信息。您可以访问 http://bit.ly/data-science-from-scratch-2e

如有关于本书的评论或技术问题,请发送电子邮件至 bookquestions@oreilly.com

有关我们的图书、课程、会议和新闻的更多信息,请访问我们的网站:http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

观看我们的 YouTube 视频:http://www.youtube.com/oreillymedia

致谢

首先,我要感谢 Mike Loukides 接受了我关于这本书的提案(并坚持让我将其减少到一个合理的大小)。他完全可以说:“这个人一直给我发样章,我怎么让他走开?”我很感激他没有那样做。我还要感谢我的编辑 Michele Cronin 和 Marie Beaugureau,他们指导我完成出版过程,使这本书达到了一个比我一个人写得更好的状态。

如果不是因为 Dave Hsu、Igor Tatarinov、John Rauser 及 Farecast 团队的影响,我可能永远不会学习数据科学,也就无法写出这本书。(当时甚至还没有称之为数据科学!)Coursera 和 DataTau 的好人们也值得称赞。

我也非常感谢我的试读者和评论者。Jay Fundling 找出了大量错误,并指出了许多不清晰的解释,多亏他,这本书变得更好(也更正确)。Debashis Ghosh 对我所有的统计进行了合理性检查,他是个英雄。Andrew Musselman 建议我减少书中“喜欢 R 而不喜欢 Python 的人是道德败类”的内容,我觉得这个建议非常中肯。Trey Causey、Ryan Matthew Balfanz、Loris Mularoni、Núria Pujol、Rob Jefferson、Mary Pat Campbell、Zach Geary、Denise Mauldin、Jimmy O’Donnell 和 Wendy Grus 也提供了宝贵的反馈。感谢所有读过第一版并帮助改进这本书的人。当然,任何剩下的错误都是我个人的责任。

我非常感谢 Twitter 的 #datascience 社区,让我接触到大量新概念,认识了很多优秀的人,让我感觉自己像个低成就者,于是我写了一本书来弥补。特别感谢 Trey Causey(再次),他(无意间地)提醒我要包括一个线性代数章节,以及 Sean J. Taylor,他(无意间地)指出了“处理数据”章节中的一些重大空白。

最重要的是,我要无比感谢 Ganga 和 Madeline。写书比写书的人更难,没有他们的支持我无法完成这本书。

第一版序言

数据科学

据说数据科学家是“21 世纪最性感的工作”,这个说法可能是由一个从未访问过消防站的人发表的。尽管如此,数据科学是一个炙手可热且不断发展的领域,不需要花费太多精力就能发现分析人员在紧张地预测,在未来 10 年内,我们将需要比目前拥有的数据科学家多出数十亿。

但数据科学到底是什么?毕竟,如果我们不知道数据科学是什么,我们就不能培养出数据科学家。根据业界一张有点出名的维恩图,数据科学位于以下交叉点上:

  • 黑客技能

  • 数学和统计学知识

  • 实质性专业知识

尽管我最初打算写一本涵盖所有三个方面的书,但我很快意识到,对“实质性专业知识”的深入探讨将需要数万页的篇幅。在那时,我决定专注于前两者。我的目标是帮助你发展出入门数据科学所需的黑客技能。我的目标是帮助你熟悉数据科学核心的数学和统计学知识。

对于一本书来说,这是一个相当沉重的愿望。学习黑客技能的最佳方式是通过对事物进行黑客攻击。通过阅读本书,你将深入了解我对事物进行黑客攻击的方式,这未必是你对事物进行黑客攻击的最佳方式。你将深入了解我使用的一些工具,这未必是你使用的最佳工具。你将深入了解我解决数据问题的方式,这未必是你解决数据问题的最佳方式。我希望(并且希望)我的示例将激励你尝试自己的方式。本书中的所有代码和数据都可在GitHub上获取,以便让你开始。

同样,学习数学的最佳方式是通过做数学。这绝不是一本数学书,而且在大多数情况下,我们不会“进行数学”。然而,如果没有对概率、统计学和线性代数有一定了解,你实际上无法真正进行数据科学。这意味着,在适当的情况下,我们将深入研究数学方程式、数学直觉、数学公理和大型数学思想的卡通版本。我希望你不会害怕跟我一起深入探讨。

在这一切过程中,我也希望让你感觉到玩弄数据很有趣,因为嗯,玩弄数据确实很有趣!(特别是与某些其他选择相比,如纳税准备或煤矿工作。)

从零开始

有大量数据科学库、框架、模块和工具包,高效地实现了最常见(以及最不常见)的数据科学算法和技术。如果你成为一名数据科学家,你将对 NumPy、scikit-learn、pandas 以及其他大量库变得非常熟悉。它们非常适合进行数据科学。但它们也是一种在不真正理解数据科学的情况下开始进行数据科学的好方法。

在本书中,我们将从零开始学习数据科学。这意味着我们将手工构建工具并实施算法,以更好地理解它们。我在创建清晰、注释良好和可读的实现和示例时考虑了很多。在大多数情况下,我们构建的工具将具有启发性但不实用。它们在小型玩具数据集上表现良好,但在“Web 规模”数据集上则会失败。

在整本书中,我会指导你使用库来将这些技术应用到更大的数据集上。但我们这里不会使用它们。

有关最佳数据科学学习语言的讨论蓬勃发展。许多人认为是统计编程语言 R。(我们称这些人为错误的。)少数人建议 Java 或 Scala。然而,在我看来,Python 是显而易见的选择。

Python 有几个特性使其非常适合学习(和实施)数据科学:

  • 它是免费的。

  • 它相对简单编码(特别是理解起来)。

  • 它拥有许多有用的与数据科学相关的库。

我很犹豫是否称 Python 为我最喜欢的编程语言。有其他一些语言我觉得更令人愉悦,设计更好,或者更有趣来编码。然而,几乎每次我开始一个新的数据科学项目时,我最终都会使用 Python。每当我需要快速原型化一些只要能工作的东西时,我都会使用 Python。每当我想要以清晰、易于理解的方式演示数据科学概念时,我也会使用 Python。因此,本书选择了 Python 作为工具语言。

本书的目标不是教会你 Python。(尽管几乎可以肯定通过阅读本书你会学到一些 Python。)我将带你进行一章节的速成课程,重点介绍对我们目的最重要的特性,但如果你对 Python 编程一无所知(或对编程一无所知),那么你可能需要在本书之外补充一些“初学者 Python”教程。

我们数据科学介绍的其余部分将采用同样的方法——在看起来关键或有启发性的地方进行详细讨论,而在其他时候留下细节让你自己解决(或在维基百科上查找)。

多年来,我培训了许多数据科学家。虽然他们并非所有人都成为了改变世界的数据忍者摇滚巨星,但我确实让他们都成为了比我发现时更优秀的数据科学家。我渐渐相信,任何具备一定数学才能和一定编程技能的人都具备做数据科学所需的原始材料。她只需要一颗好奇的心,愿意努力工作,以及这本书。因此是这本书。

第一章:介绍

“数据!数据!数据!”他不耐烦地喊道。“没有粘土我无法制造砖块。”

亚瑟·柯南·道尔

数据的崛起

我们生活在一个数据泛滥的世界。网站跟踪每个用户的每一次点击。你的智能手机每天每秒都在记录你的位置和速度。“量化自我”的人们穿着像计步器一样的设备,始终记录着他们的心率、运动习惯、饮食和睡眠模式。智能汽车收集驾驶习惯,智能家居收集生活习惯,智能营销人员收集购买习惯。互联网本身就是一个包含巨大知识图谱的网络,其中包括(但不限于)一个庞大的交叉参考百科全书;关于电影、音乐、体育结果、弹珠机、网络文化和鸡尾酒的专业数据库;以及来自太多政府的太多(有些几乎是真实的!)统计数据,以至于你无法完全理解。

在这些数据中埋藏着无数问题的答案,这些问题甚至没有人曾想过去问。在本书中,我们将学习如何找到它们。

什么是数据科学?

有一个笑话说,数据科学家是那些比计算机科学家懂更多统计学、比统计学家懂更多计算机科学的人。(我并不是说这是个好笑话。)事实上,一些数据科学家在实际上更像是统计学家,而另一些则几乎无法与软件工程师区分开来。有些是机器学习专家,而另一些甚至无法从幼儿园的机器学习出来。有些是拥有令人印象深刻出版记录的博士,而另一些从未读过学术论文(虽然他们真是太可耻了)。简而言之,几乎无论你如何定义数据科学,你都会找到那些对于定义完全错误的从业者。

尽管如此,我们不会因此而放弃尝试。我们会说,数据科学家是那些从杂乱数据中提取见解的人。今天的世界充满了试图将数据转化为见解的人们。

例如,约会网站 OkCupid 要求其会员回答成千上万个问题,以便找到最合适的匹配对象。但它也分析这些结果,以找出你可以问某人的听起来无伤大雅的问题,来了解在第一次约会时某人愿意与你发生关系的可能性有多大

Facebook 要求你列出你的家乡和当前位置,表面上是为了让你的朋友更容易找到并联系你。但它也分析这些位置,以识别全球迁移模式不同足球队球迷居住地的分布

作为一家大型零售商,Target 跟踪你的在线和门店购买及互动行为。它使用数据来预测模型,以更好地向客户市场化婴儿相关购买。

在 2012 年,奥巴马竞选团队雇用了数十名数据科学家,通过数据挖掘和实验,找到需要额外关注的选民,选择最佳的特定捐款呼吁和方案,并将选民动员工作集中在最有可能有用的地方。而在 2016 年,特朗普竞选团队测试了多种在线广告,并分析数据找出有效和无效的广告。

现在,在你开始感到太厌倦之前:一些数据科学家偶尔也会运用他们的技能来做些善事——使用数据使政府更有效帮助无家可归者,以及改善公共健康。但如果你喜欢研究如何最好地让人们点击广告,那对你的职业生涯肯定也是有好处的。

激励假设:DataSciencester

祝贺!你刚刚被聘为 DataSciencester 的数据科学主管,数据科学家的社交网络。

注意

当我写这本书的第一版时,我认为“为数据科学家建立社交网络”是一个有趣、愚蠢的假设。自那时以来,人们实际上创建了为数据科学家建立的社交网络,并从风险投资家那里筹集到比我从我的书中赚到的钱多得多的资金。很可能这里有一个关于愚蠢的数据科学假设和/或图书出版的宝贵教训。

尽管数据科学家为核心,DataSciencester 实际上从未投资于建立自己的数据科学实践。(公平地说,DataSciencester 从未真正投资于建立自己的产品。)这将是你的工作!在本书中,我们将通过解决你在工作中遇到的问题来学习数据科学概念。有时我们会查看用户明确提供的数据,有时我们会查看通过他们与网站的互动生成的数据,有时甚至会查看我们设计的实验数据。

而且因为 DataSciencester 有着强烈的“非自主创新”精神,我们将从头开始建立自己的工具。最后,你将对数据科学的基础有相当扎实的理解。你将准备好在一个基础更稳固的公司应用你的技能,或者解决任何其他你感兴趣的问题。

欢迎加入,并祝你好运!(星期五可以穿牛仔裤,洗手间在右边的走廊尽头。)

寻找关键联络人

你在 DataSciencester 的第一天上班,网络副总裁对你的用户充满了疑问。直到现在,他没有人可以问,所以他对你的加入非常兴奋。

他特别想让你识别出数据科学家中的“关键连接者”。为此,他给了你整个 DataSciencester 网络的数据转储。(在现实生活中,人们通常不会把你需要的数据直接交给你。第九章专门讨论获取数据的问题。)

这个数据转储看起来是什么样子?它包含了一个用户列表,每个用户由一个dict表示,其中包含该用户的id(一个数字)和name(在一个伟大的宇宙巧合中,与用户的id押韵):

users = [
    { "id": 0, "name": "Hero" },
    { "id": 1, "name": "Dunn" },
    { "id": 2, "name": "Sue" },
    { "id": 3, "name": "Chi" },
    { "id": 4, "name": "Thor" },
    { "id": 5, "name": "Clive" },
    { "id": 6, "name": "Hicks" },
    { "id": 7, "name": "Devin" },
    { "id": 8, "name": "Kate" },
    { "id": 9, "name": "Klein" }
]

他还给了你“友谊”数据,表示为一组 ID 对的列表:

friendship_pairs = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4),
                    (4, 5), (5, 6), (5, 7), (6, 8), (7, 8), (8, 9)]

例如,元组(0, 1)表示id为 0(Hero)的数据科学家和id为 1(Dunn)的数据科学家是朋友。网络在图 1-1 中有所展示。

DataSciencester 网络。

图 1-1. 数据科学家网络

将友谊表示为一组对并不是最容易处理它们的方式。要找出用户 1 的所有友谊,你必须遍历每一对,查找包含 1 的对。如果有很多对,这将需要很长时间。

相反,让我们创建一个dict,其中键是用户的id,值是朋友的id列表。(在dict中查找东西非常快。)

注意

现在先别太过于纠结代码的细节。在第二章,我将带你快速入门 Python。现在只需试着把握我们正在做的大致意思。

我们仍然必须查看每一对来创建dict,但我们只需这样做一次,之后查找将会很快:

# Initialize the dict with an empty list for each user id:
friendships = {user["id"]: [] for user in users}

# And loop over the friendship pairs to populate it:
for i, j in friendship_pairs:
    friendships[i].append(j)  # Add j as a friend of user i
    friendships[j].append(i)  # Add i as a friend of user j

现在我们已经将友谊关系存入dict中,我们可以轻松地询问我们的图形问题,比如“平均连接数是多少?”

首先,我们通过总结所有friends列表的长度来找到连接数:

def number_of_friends(user):
    """How many friends does _user_ have?"""
    user_id = user["id"]
    friend_ids = friendships[user_id]
    return len(friend_ids)

total_connections = sum(number_of_friends(user)
                        for user in users)        # 24

然后我们只需通过用户数量来除以:

num_users = len(users)                            # length of the users list
avg_connections = total_connections / num_users   # 24 / 10 == 2.4

要找到最连接的人也很容易 —— 他们是朋友最多的人。

由于用户数量不多,我们可以简单地按“最多朋友”到“最少朋友”的顺序排序:

# Create a list (user_id, number_of_friends).
num_friends_by_id = [(user["id"], number_of_friends(user))
                     for user in users]

num_friends_by_id.sort(                                # Sort the list
       key=lambda id_and_friends: id_and_friends[1],   # by num_friends
       reverse=True)                                   # largest to smallest

# Each pair is (user_id, num_friends):
# [(1, 3), (2, 3), (3, 3), (5, 3), (8, 3),
#  (0, 2), (4, 2), (6, 2), (7, 2), (9, 1)]

另一种理解我们所做的是作为识别网络中某些关键人物的一种方式。事实上,我们刚刚计算的是网络度量指标度中心性(图 1-2)。

DataSciencester 网络按度数排列大小。

图 1-2. 数据科学家网络按度数排列大小

这个方法非常容易计算,但不总是给出你希望或预期的结果。例如,在 DataSciencester 网络中,Thor(id 4)只有两个连接,而 Dunn(id 1)有三个。然而,当我们查看网络时,直觉上 Thor 应该更为核心。在第二十二章中,我们将更详细地研究网络,并且会探讨可能与我们直觉更符合的更复杂的中心性概念。

可能认识的数据科学家

当你还在填写新员工文件时,友谊副总裁来到你的办公桌前。她希望在你的成员之间建立更多连接,她要求你设计一个“数据科学家你可能认识”的建议者。

你的第一反应是建议用户可能认识他们朋友的朋友。因此,你编写了一些代码来迭代他们的朋友并收集朋友的朋友:

def foaf_ids_bad(user):
    """foaf is short for "friend of a friend" """
    return [foaf_id
            for friend_id in friendships[user["id"]]
            for foaf_id in friendships[friend_id]]

当我们在users[0](Hero)上调用它时,它产生:

[0, 2, 3, 0, 1, 3]

它包括用户 0 两次,因为 Hero 确实与他的两个朋友都是朋友。它包括用户 1 和 2,尽管他们已经是 Hero 的朋友。它包括用户 3 两次,因为通过两个不同的朋友可以到达 Chi:

print(friendships[0])  # [1, 2]
print(friendships[1])  # [0, 2, 3]
print(friendships[2])  # [0, 1, 3]

知道人们通过多种方式是朋友的信息似乎是有趣的信息,因此也许我们应该产生共同朋友的计数。而且我们可能应该排除用户已知的人:

from collections import Counter                   # not loaded by default

def friends_of_friends(user):
    user_id = user["id"]
    return Counter(
        foaf_id
        for friend_id in friendships[user_id]     # For each of my friends,
        for foaf_id in friendships[friend_id]     # find their friends
        if foaf_id != user_id                     # who aren't me
        and foaf_id not in friendships[user_id]   # and aren't my friends.
    )

print(friends_of_friends(users[3]))               # Counter({0: 2, 5: 1})

这正确地告诉了 Chi(id 3),她与 Hero(id 0)有两个共同的朋友,但只有一个与 Clive(id 5)有共同朋友。

作为一名数据科学家,您知道您也可能喜欢与兴趣相似的用户会面。(这是数据科学“实质性专业知识”方面的一个很好的例子。)在询问之后,您成功获取了这些数据,作为一对(用户 ID,兴趣)的列表:

interests = [
    (0, "Hadoop"), (0, "Big Data"), (0, "HBase"), (0, "Java"),
    (0, "Spark"), (0, "Storm"), (0, "Cassandra"),
    (1, "NoSQL"), (1, "MongoDB"), (1, "Cassandra"), (1, "HBase"),
    (1, "Postgres"), (2, "Python"), (2, "scikit-learn"), (2, "scipy"),
    (2, "numpy"), (2, "statsmodels"), (2, "pandas"), (3, "R"), (3, "Python"),
    (3, "statistics"), (3, "regression"), (3, "probability"),
    (4, "machine learning"), (4, "regression"), (4, "decision trees"),
    (4, "libsvm"), (5, "Python"), (5, "R"), (5, "Java"), (5, "C++"),
    (5, "Haskell"), (5, "programming languages"), (6, "statistics"),
    (6, "probability"), (6, "mathematics"), (6, "theory"),
    (7, "machine learning"), (7, "scikit-learn"), (7, "Mahout"),
    (7, "neural networks"), (8, "neural networks"), (8, "deep learning"),
    (8, "Big Data"), (8, "artificial intelligence"), (9, "Hadoop"),
    (9, "Java"), (9, "MapReduce"), (9, "Big Data")
]

例如,Hero(id 0)与 Klein(id 9)没有共同朋友,但他们分享 Java 和大数据的兴趣。

建立一个找出具有特定兴趣的用户的函数很容易:

def data_scientists_who_like(target_interest):
    """Find the ids of all users who like the target interest."""
    return [user_id
            for user_id, user_interest in interests
            if user_interest == target_interest]

这样做虽然有效,但每次搜索都需要检查整个兴趣列表。如果我们有很多用户和兴趣(或者我们想进行大量搜索),我们可能最好建立一个从兴趣到用户的索引:

from collections import defaultdict

# Keys are interests, values are lists of user_ids with that interest
user_ids_by_interest = defaultdict(list)

for user_id, interest in interests:
    user_ids_by_interest[interest].append(user_id)

另一个从用户到兴趣的:

# Keys are user_ids, values are lists of interests for that user_id.
interests_by_user_id = defaultdict(list)

for user_id, interest in interests:
    interests_by_user_id[user_id].append(interest)

现在很容易找出与给定用户共同兴趣最多的人:

  • 迭代用户的兴趣。

  • 对于每个兴趣,迭代具有该兴趣的其他用户。

  • 记录我们看到每个其他用户的次数。

在代码中:

def most_common_interests_with(user):
    return Counter(
        interested_user_id
        for interest in interests_by_user_id[user["id"]]
        for interested_user_id in user_ids_by_interest[interest]
        if interested_user_id != user["id"]
    )

我们可以利用这一点来构建一个更丰富的“数据科学家你可能认识”的功能,基于共同朋友和共同兴趣的组合。我们将在第二十三章中探讨这些应用的类型。

薪水和经验

正在准备去午餐时,公共关系副总裁问您是否可以提供一些关于数据科学家赚多少钱的有趣事实。薪资数据当然是敏感的,但他设法为您提供了一个匿名数据集,其中包含每个用户的薪资(以美元计)和作为数据科学家的任期(以年计):

salaries_and_tenures = [(83000, 8.7), (88000, 8.1),
                        (48000, 0.7), (76000, 6),
                        (69000, 6.5), (76000, 7.5),
                        (60000, 2.5), (83000, 10),
                        (48000, 1.9), (63000, 4.2)]

自然的第一步是绘制数据(我们将在第三章中看到如何做到这一点)。您可以在图 1-3 中看到结果。

按年经验计算的工资。

图 1-3. 按经验年限计算的工资

看起来明显,有更多经验的人往往赚更多。您如何将其转化为有趣的事实?您的第一个想法是查看每个任期的平均工资:

# Keys are years, values are lists of the salaries for each tenure.
salary_by_tenure = defaultdict(list)

for salary, tenure in salaries_and_tenures:
    salary_by_tenure[tenure].append(salary)

# Keys are years, each value is average salary for that tenure.
average_salary_by_tenure = {
    tenure: sum(salaries) / len(salaries)
    for tenure, salaries in salary_by_tenure.items()
}

结果证明这并不特别有用,因为没有一个用户拥有相同的任期,这意味着我们只是报告个别用户的薪水:

{0.7: 48000.0,
 1.9: 48000.0,
 2.5: 60000.0,
 4.2: 63000.0,
 6: 76000.0,
 6.5: 69000.0,
 7.5: 76000.0,
 8.1: 88000.0,
 8.7: 83000.0,
 10: 83000.0}

对职位进行分桶可能更有帮助:

def tenure_bucket(tenure):
    if tenure < 2:
        return "less than two"
    elif tenure < 5:
        return "between two and five"
    else:
        return "more than five"

然后我们可以将对应于每个桶的工资分组在一起:

# Keys are tenure buckets, values are lists of salaries for that bucket.
salary_by_tenure_bucket = defaultdict(list)

for salary, tenure in salaries_and_tenures:
    bucket = tenure_bucket(tenure)
    salary_by_tenure_bucket[bucket].append(salary)

最后为每个组计算平均工资:

# Keys are tenure buckets, values are average salary for that bucket.
average_salary_by_bucket = {
  tenure_bucket: sum(salaries) / len(salaries)
  for tenure_bucket, salaries in salary_by_tenure_bucket.items()
}

哪个更有趣:

{'between two and five': 61500.0,
 'less than two': 48000.0,
 'more than five': 79166.66666666667}

而你的声音片段是:“有五年以上经验的数据科学家比没有经验或经验较少的数据科学家赚 65%的工资!”

但我们选择桶的方式相当随意。我们真正想要的是对增加一年经验的平均薪水效应做一些说明。除了制作更生动的趣味事实外,这还允许我们对我们不知道的工资做出预测。我们将在第十四章中探讨这个想法。

付费帐户

当您回到桌前时,收入副总裁正在等待您。她想更好地了解哪些用户为帐户付费,哪些不付费。(她知道他们的名字,但那不是特别可行的信息。)

您注意到经验年限与付费帐户之间似乎存在对应关系:

0.7  paid
1.9  unpaid
2.5  paid
4.2  unpaid
6.0  unpaid
6.5  unpaid
7.5  unpaid
8.1  unpaid
8.7  paid
10.0 paid

经验非常少或非常多的用户往往会支付;经验平均的用户则不会。因此,如果你想创建一个模型——尽管这绝对不是足够的数据来建立模型——你可以尝试预测经验非常少或非常多的用户的“有偿”情况,以及经验适中的用户的“无偿”情况:

def predict_paid_or_unpaid(years_experience):
  if years_experience < 3.0:
    return "paid"
  elif years_experience < 8.5:
    return "unpaid"
  else:
    return "paid"

当然,我们完全是靠眼睛估计的分界线。

有了更多数据(和更多数学),我们可以构建一个模型,根据用户的经验年限来预测他是否会付费。我们将在第十六章中研究这类问题。

兴趣主题

当您完成第一天工作时,内容战略副总裁要求您提供关于用户最感兴趣的主题的数据,以便她能够相应地规划博客日历。你已经有了来自朋友推荐项目的原始数据:

interests = [
    (0, "Hadoop"), (0, "Big Data"), (0, "HBase"), (0, "Java"),
    (0, "Spark"), (0, "Storm"), (0, "Cassandra"),
    (1, "NoSQL"), (1, "MongoDB"), (1, "Cassandra"), (1, "HBase"),
    (1, "Postgres"), (2, "Python"), (2, "scikit-learn"), (2, "scipy"),
    (2, "numpy"), (2, "statsmodels"), (2, "pandas"), (3, "R"), (3, "Python"),
    (3, "statistics"), (3, "regression"), (3, "probability"),
    (4, "machine learning"), (4, "regression"), (4, "decision trees"),
    (4, "libsvm"), (5, "Python"), (5, "R"), (5, "Java"), (5, "C++"),
    (5, "Haskell"), (5, "programming languages"), (6, "statistics"),
    (6, "probability"), (6, "mathematics"), (6, "theory"),
    (7, "machine learning"), (7, "scikit-learn"), (7, "Mahout"),
    (7, "neural networks"), (8, "neural networks"), (8, "deep learning"),
    (8, "Big Data"), (8, "artificial intelligence"), (9, "Hadoop"),
    (9, "Java"), (9, "MapReduce"), (9, "Big Data")
]

找到最受欢迎的兴趣的一种简单(如果不是特别令人兴奋)的方法是计算单词数:

  1. 将每个兴趣都转换为小写(因为不同用户可能会或不会将其兴趣大写)。

  2. 将其分割成单词。

  3. 计算结果。

在代码中:

words_and_counts = Counter(word
                           for user, interest in interests
                           for word in interest.lower().split())

这使得可以轻松列出出现超过一次的单词:

for word, count in words_and_counts.most_common():
    if count > 1:
        print(word, count)

这会得到您预期的结果(除非您期望“scikit-learn”被分成两个单词,在这种情况下它不会得到您期望的结果):

learning 3
java 3
python 3
big 3
data 3
hbase 2
regression 2
cassandra 2
statistics 2
probability 2
hadoop 2
networks 2
machine 2
neural 2
scikit-learn 2
r 2

我们将在第二十一章中探讨从数据中提取主题的更复杂方法。

继续

这是一个成功的第一天!精疲力尽地,你溜出大楼,趁没人找你要事情之前。好好休息,因为明天是新员工入职培训。(是的,你在新员工入职培训之前已经度过了一整天的工作。这个问题可以找 HR 解决。)

第二章:Python 速成课程

二十五年过去了,人们仍然对 Python 着迷,这让我难以置信。

迈克尔·帕林

在 DataSciencester 的所有新员工都必须通过新员工入职培训,其中最有趣的部分是 Python 的速成课程。

这不是一篇全面的 Python 教程,而是旨在突出我们最关心的语言部分的部分(其中一些通常不是 Python 教程的重点)。如果你以前从未使用过 Python,你可能想要补充一些初学者教程。

Python 之禅

Python 有一种有点禅意的设计原则描述,你也可以在 Python 解释器内部通过输入“import this”来找到它。

其中最受讨论的之一是:

应该有一种——最好只有一种——明显的方法来做到这一点。

根据这种“显而易见”的方式编写的代码(这对于新手来说可能根本不明显)通常被描述为“Pythonic”。尽管这不是一本关于 Python 的书,我们偶尔会对比 Pythonic 和非 Pythonic 的解决方案,并且我们通常会倾向于使用 Pythonic 的解决方案来解决问题。

还有几个触及美学的:

美丽比丑陋好。显式优于隐式。简单优于复杂。

并代表我们在代码中努力追求的理想。

获取 Python

由于安装说明可能会更改,而印刷书籍不能,因此关于如何安装 Python 的最新说明可以在该书的 GitHub 仓库中找到。

如果这里打印的方法对你不起作用,请检查那些方法。

你可以从Python.org下载 Python。但如果你还没有 Python,我建议你安装Anaconda发行版,它已经包含了你做数据科学所需的大多数库。

当我写《从零开始的数据科学》的第一版时,Python 2.7 仍然是大多数数据科学家首选的版本。因此,该书的第一版是基于 Python 2.7 的。

然而,在过去的几年里,几乎所有有份量的人都已经迁移到 Python 3。Python 的最新版本具有许多功能,使得编写清晰的代码更容易,并且我们将充分利用仅在 Python 3.6 或更高版本中可用的功能。这意味着你应该获得 Python 3.6 或更高版本。(此外,许多有用的库正在终止对 Python 2.7 的支持,这是切换的另一个原因。)

虚拟环境

从下一章开始,我们将使用 matplotlib 库生成图表和图形。这个库不是 Python 的核心部分;你必须自己安装它。每个数据科学项目都需要某些外部库的组合,有时候具体版本可能与你用于其他项目的版本不同。如果你只有一个 Python 安装,这些库可能会冲突并引起各种问题。

标准解决方案是使用虚拟环境,它们是沙箱化的 Python 环境,维护其自己版本的 Python 库(根据环境设置,还可能包括 Python 本身的版本)。

我建议你安装 Anaconda Python 发行版,因此在本节中我将解释 Anaconda 环境的工作原理。如果你不使用 Anaconda,可以使用内置的venv模块或安装virtualenv。在这种情况下,应遵循它们的说明。

要创建(Anaconda)虚拟环境,只需执行以下操作:

# create a Python 3.6 environment named "dsfs"
conda create -n dsfs python=3.6

按照提示操作,你将拥有一个名为“dsfs”的虚拟环境,带有以下指令:

#
# To activate this environment, use:
# > source activate dsfs
#
# To deactivate an active environment, use:
# > source deactivate
#

如指示的那样,你可以使用以下命令激活环境:

source activate dsfs

此时,你的命令提示符应该更改以指示活动环境。在我的 MacBook 上,提示现在看起来像:

(dsfs) ip-10-0-0-198:~ joelg$

只要此环境处于活动状态,你安装的任何库都将仅安装在 dsfs 环境中。完成本书后,继续进行自己的项目时,应为它们创建自己的环境。

现在你已经有了自己的环境,值得安装IPython,这是一个功能齐全的 Python shell:

python -m pip install ipython
注意

Anaconda 带有自己的包管理器conda,但你也可以使用标准的 Python 包管理器pip,这是我们将要做的事情。

本书的其余部分假设你已经创建并激活了这样一个 Python 3.6 的虚拟环境(尽管你可以将其命名为任何你想要的名称),后续章节可能会依赖于我在早期章节中要求你安装的库。

作为良好纪律的一部分,你应该始终在虚拟环境中工作,而不是使用“基础”Python 安装。

空白格式化

许多语言使用大括号来界定代码块。Python 使用缩进:

# The pound sign marks the start of a comment. Python itself
# ignores the comments, but they're helpful for anyone reading the code.
for i in [1, 2, 3, 4, 5]:
    print(i)                    # first line in "for i" block
    for j in [1, 2, 3, 4, 5]:
        print(j)                # first line in "for j" block
        print(i + j)            # last line in "for j" block
    print(i)                    # last line in "for i" block
print("done looping")

这使得 Python 代码非常易读,但这也意味着你必须非常注意你的格式。

警告

程序员经常争论是否应该使用制表符(tabs)还是空格(spaces)进行缩进。对于许多语言来说,这并不是太重要;然而,Python 认为制表符和空格是不同的缩进方式,如果混合使用两者,你的代码将无法正常运行。在编写 Python 代码时,应始终使用空格,而不是制表符。(如果你在编辑器中编写代码,可以配置 Tab 键插入空格。)

圆括号和方括号内的空白会被忽略,这对于冗长的计算很有帮助:

long_winded_computation = (1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 +
                           13 + 14 + 15 + 16 + 17 + 18 + 19 + 20)

以及为了使代码更易于阅读:

list_of_lists = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

easier_to_read_list_of_lists = [[1, 2, 3],
                                [4, 5, 6],
                                [7, 8, 9]]

你也可以使用反斜杠来指示语句在下一行继续,尽管我们很少这样做:

two_plus_three = 2 + \
                 3

空白格式化的一个后果是,很难将代码复制粘贴到 Python shell 中。例如,如果你试图粘贴以下代码:

for i in [1, 2, 3, 4, 5]:

    # notice the blank line
    print(i)

到普通的 Python shell 中,你会得到以下投诉:

IndentationError: expected an indented block

因为解释器认为空白行表示for循环块的结束。

IPython 有一个名为 %paste 的魔法函数,它可以正确地粘贴你剪贴板上的任何内容,包括空白等。这已经是使用 IPython 的一个很好的理由。

模块

Python 的某些特性不会默认加载。这些特性既包括作为语言的一部分的功能,也包括你自己下载的第三方功能。为了使用这些特性,你需要import包含它们的模块。

一个方法是简单地import模块本身:

import re
my_regex = re.compile("[0-9]+", re.I)

在这里,re 是包含用于处理正则表达式的函数和常量的模块。在这种类型的import之后,你必须使用re.前缀来访问这些函数。

如果你的代码中已经有了不同的re,你可以使用别名:

import re as regex
my_regex = regex.compile("[0-9]+", regex.I)

如果你的模块名称过长或者你需要频繁输入它,你也可以这样做。例如,在使用 matplotlib 可视化数据时的标准约定是:

import matplotlib.pyplot as plt

plt.plot(...)

如果你需要从一个模块中获取几个特定的值,你可以显式导入它们并在不需要限定的情况下使用它们:

from collections import defaultdict, Counter
lookup = defaultdict(int)
my_counter = Counter()

如果你是个坏人,你可以将模块的整个内容导入到你的命名空间中,这可能会无意中覆盖你已定义的变量:

match = 10
from re import *    # uh oh, re has a match function
print(match)        # "<function match at 0x10281e6a8>"

然而,由于你不是个坏人,你永远不会这样做。

函数

函数是一个规则,用于接收零个或多个输入,并返回相应的输出。在 Python 中,我们通常使用 def 来定义函数:

def double(x):
    """
 This is where you put an optional docstring that explains what the
 function does. For example, this function multiplies its input by 2.
 """
    return x * 2

Python 函数是一等公民,这意味着我们可以将它们赋值给变量,并像任何其他参数一样传递给函数:

def apply_to_one(f):
    """Calls the function f with 1 as its argument"""
    return f(1)

my_double = double             # refers to the previously defined function
x = apply_to_one(my_double)    # equals 2

创建短匿名函数或lambda也很容易:

y = apply_to_one(lambda x: x + 4)      # equals 5

你可以将 lambda 表达式赋值给变量,尽管大多数人会告诉你应该使用def代替:

another_double = lambda x: 2 * x       # don't do this

def another_double(x):
    """Do this instead"""
    return 2 * x

函数参数也可以给定默认参数,只有在需要其他值时才需要指定:

def my_print(message = "my default message"):
    print(message)

my_print("hello")   # prints 'hello'
my_print()          # prints 'my default message'

有时候通过名称指定参数也是很有用的:

def full_name(first = "What's-his-name", last = "Something"):
    return first + " " + last

full_name("Joel", "Grus")     # "Joel Grus"
full_name("Joel")             # "Joel Something"
full_name(last="Grus")        # "What's-his-name Grus"

我们将创建很多很多函数。

字符串

字符串可以用单引号或双引号括起来(但引号必须匹配):

single_quoted_string = 'data science'
double_quoted_string = "data science"

Python 使用反斜杠来编码特殊字符。例如:

tab_string = "\t"       # represents the tab character
len(tab_string)         # is 1

如果你需要保留反斜杠作为反斜杠(例如在 Windows 目录名或正则表达式中),可以使用 r"" 创建原始字符串:

not_tab_string = r"\t"  # represents the characters '\' and 't'
len(not_tab_string)     # is 2

你可以使用三个双引号创建多行字符串:

multi_line_string = """This is the first line.
and this is the second line
and this is the third line"""

Python 3.6 中的一个新特性是f-string,它提供了一种简单的方法来将值替换到字符串中。例如,如果我们有单独给出的名字和姓氏:

first_name = "Joel"
last_name = "Grus"

我们可能希望将它们组合成一个完整的名字。有多种方法可以构建这样一个full_name字符串:

full_name1 = first_name + " " + last_name             # string addition
full_name2 = "{0} {1}".format(first_name, last_name)  # string.format

但是f-string的方式要简单得多:

full_name3 = f"{first_name} {last_name}"

并且我们将在整本书中更喜欢它。

异常

当出现问题时,Python 会引发一个异常。如果不加处理,异常会导致程序崩溃。你可以使用tryexcept来处理它们:

try:
    print(0 / 0)
except ZeroDivisionError:
    print("cannot divide by zero")

尽管在许多语言中异常被认为是不好的,但在 Python 中使用它们来使代码更清晰是无可厚非的,有时我们会这样做。

列表

可能是 Python 中最基本的数据结构是列表,它只是一个有序集合(它类似于其他语言中可能被称为数组,但具有一些附加功能):

integer_list = [1, 2, 3]
heterogeneous_list = ["string", 0.1, True]
list_of_lists = [integer_list, heterogeneous_list, []]

list_length = len(integer_list)     # equals 3
list_sum    = sum(integer_list)     # equals 6

你可以使用方括号获取或设置列表的第n个元素:

x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

zero = x[0]          # equals 0, lists are 0-indexed
one = x[1]           # equals 1
nine = x[-1]         # equals 9, 'Pythonic' for last element
eight = x[-2]        # equals 8, 'Pythonic' for next-to-last element
x[0] = -1            # now x is [-1, 1, 2, 3, ..., 9]

你还可以使用方括号切片列表。切片i:j表示从i(包含)到j(不包含)的所有元素。如果省略切片的开始,你将从列表的开头切片,如果省略切片的结尾,你将切片直到列表的末尾:

first_three = x[:3]                 # [-1, 1, 2]
three_to_end = x[3:]                # [3, 4, ..., 9]
one_to_four = x[1:5]                # [1, 2, 3, 4]
last_three = x[-3:]                 # [7, 8, 9]
without_first_and_last = x[1:-1]    # [1, 2, ..., 8]
copy_of_x = x[:]                    # [-1, 1, 2, ..., 9]

你可以类似地切片字符串和其他“顺序”类型。

切片可以使用第三个参数来指示其步长,步长可以是负数:

every_third = x[::3]                 # [-1, 3, 6, 9]
five_to_three = x[5:2:-1]            # [5, 4, 3]

Python 有一个in运算符来检查列表成员资格:

1 in [1, 2, 3]    # True
0 in [1, 2, 3]    # False

这个检查涉及逐个检查列表的元素,这意味着除非你知道你的列表相当小(或者你不关心检查要花多长时间),否则你可能不应该使用它。

将列表连接在一起很容易。如果你想原地修改一个列表,你可以使用extend从另一个集合中添加项目:

x = [1, 2, 3]
x.extend([4, 5, 6])     # x is now [1, 2, 3, 4, 5, 6]

如果你不想修改x,你可以使用列表加法:

x = [1, 2, 3]
y = x + [4, 5, 6]       # y is [1, 2, 3, 4, 5, 6]; x is unchanged

更频繁地,我们将逐个项目附加到列表中:

x = [1, 2, 3]
x.append(0)      # x is now [1, 2, 3, 0]
y = x[-1]        # equals 0
z = len(x)       # equals 4

当你知道列表包含多少元素时,解包列表通常很方便:

x, y = [1, 2]    # now x is 1, y is 2

虽然如果两边的元素数量不相同,你将会得到一个ValueError

一个常见的习惯用法是使用下划线表示你要丢弃的值:

_, y = [1, 2]    # now y == 2, didn't care about the first element

元组

元组是列表的不可变表亲。几乎你可以对列表做的任何事情,只要不涉及修改它,你都可以对元组做。你可以使用圆括号(或什么都不使用)而不是方括号来指定一个元组:

my_list = [1, 2]
my_tuple = (1, 2)
other_tuple = 3, 4
my_list[1] = 3      # my_list is now [1, 3]

try:
    my_tuple[1] = 3
except TypeError:
    print("cannot modify a tuple")

元组是从函数中返回多个值的一种便捷方式:

def sum_and_product(x, y):
    return (x + y), (x * y)

sp = sum_and_product(2, 3)     # sp is (5, 6)
s, p = sum_and_product(5, 10)  # s is 15, p is 50

元组(和列表)也可以用于多重赋值

x, y = 1, 2     # now x is 1, y is 2
x, y = y, x     # Pythonic way to swap variables; now x is 2, y is 1

字典

另一个基本的数据结构是字典,它将关联起来,并允许您快速检索与给定键对应的值:

empty_dict = {}                     # Pythonic
empty_dict2 = dict()                # less Pythonic
grades = {"Joel": 80, "Tim": 95}    # dictionary literal

你可以使用方括号查找键的值:

joels_grade = grades["Joel"]        # equals 80

但是如果你要求一个字典中不存在的键,你将得到一个KeyError

try:
    kates_grade = grades["Kate"]
except KeyError:
    print("no grade for Kate!")

你可以使用in来检查键的存在:

joel_has_grade = "Joel" in grades     # True
kate_has_grade = "Kate" in grades     # False

即使对于大字典来说,这种成员检查也很快。

字典有一个 get 方法,在查找不在字典中的键时返回默认值(而不是引发异常):

joels_grade = grades.get("Joel", 0)   # equals 80
kates_grade = grades.get("Kate", 0)   # equals 0
no_ones_grade = grades.get("No One")  # default is None

你可以使用相同的方括号分配键/值对:

grades["Tim"] = 99                    # replaces the old value
grades["Kate"] = 100                  # adds a third entry
num_students = len(grades)            # equals 3

正如你在第一章看到的,你可以使用字典来表示结构化数据:

tweet = {
    "user" : "joelgrus",
    "text" : "Data Science is Awesome",
    "retweet_count" : 100,
    "hashtags" : ["#data", "#science", "#datascience", "#awesome", "#yolo"]
}

虽然我们很快会看到一种更好的方法。

除了查找特定键外,我们还可以查看所有键:

tweet_keys   = tweet.keys()     # iterable for the keys
tweet_values = tweet.values()   # iterable for the values
tweet_items  = tweet.items()    # iterable for the (key, value) tuples

"user" in tweet_keys            # True, but not Pythonic
"user" in tweet                 # Pythonic way of checking for keys
"joelgrus" in tweet_values      # True (slow but the only way to check)

字典的键必须是“可哈希的”;特别是,你不能使用列表作为键。如果你需要一个多部分键,你可能应该使用元组或想出一种方法将键转换为字符串。

defaultdict

想象一下,你正在尝试计算文档中的单词数。一个明显的方法是创建一个字典,其中键是单词,值是计数。当你检查每个单词时,如果它已经在字典中,你可以增加它的计数,如果它不在字典中,你可以将其添加到字典中:

word_counts = {}
for word in document:
    if word in word_counts:
        word_counts[word] += 1
    else:
        word_counts[word] = 1

你也可以采用“宁可原谅,也不要求许可”的方法,只需处理尝试查找缺失键时引发的异常:

word_counts = {}
for word in document:
    try:
        word_counts[word] += 1
    except KeyError:
        word_counts[word] = 1

第三种方法是使用 get,它对于缺失的键行为优雅:

word_counts = {}
for word in document:
    previous_count = word_counts.get(word, 0)
    word_counts[word] = previous_count + 1

每一个这些都稍微笨拙,这就是为什么 defaultdict 是有用的。defaultdict 类似于普通字典,但是当你尝试查找它不包含的键时,它会首先使用你创建时提供的零参数函数为其添加一个值。为了使用 defaultdict,你必须从 collections 导入它们:

from collections import defaultdict

word_counts = defaultdict(int)          # int() produces 0
for word in document:
    word_counts[word] += 1

它们在处理 listdict 甚至是你自己的函数时也很有用:

dd_list = defaultdict(list)             # list() produces an empty list
dd_list[2].append(1)                    # now dd_list contains {2: [1]}

dd_dict = defaultdict(dict)             # dict() produces an empty dict
dd_dict["Joel"]["City"] = "Seattle"     # {"Joel" : {"City": Seattle"}}

dd_pair = defaultdict(lambda: [0, 0])
dd_pair[2][1] = 1                       # now dd_pair contains {2: [0, 1]}

当我们使用字典以某个键“收集”结果时,并且不想每次都检查键是否已存在时,这些方法将非常有用。

计数器

一个 Counter 将一系列值转换为类似于 defaultdict(int) 的对象,将键映射到计数:

from collections import Counter
c = Counter([0, 1, 2, 0])          # c is (basically) {0: 2, 1: 1, 2: 1}

这为我们提供了一个解决 word_counts 问题的非常简单的方法:

# recall, document is a list of words
word_counts = Counter(document)

Counter 实例有一个经常有用的 most_common 方法:

# print the 10 most common words and their counts
for word, count in word_counts.most_common(10):
    print(word, count)

集合(sets)

另一个有用的数据结构是集合,它表示一组 不同 的元素。你可以通过在大括号之间列出其元素来定义一个集合:

primes_below_10 = {2, 3, 5, 7}

然而,对于空的 set,这并不适用,因为 {} 已经表示“空 dict”。在这种情况下,你需要使用 set() 本身:

s = set()
s.add(1)       # s is now {1}
s.add(2)       # s is now {1, 2}
s.add(2)       # s is still {1, 2}
x = len(s)     # equals 2
y = 2 in s     # equals True
z = 3 in s     # equals False

我们会出于两个主要原因使用集合。首先,in 在集合上是一个非常快的操作。如果我们有一个大量项目的集合,我们想用于成员测试,那么集合比列表更合适:

stopwords_list = ["a", "an", "at"] + hundreds_of_other_words + ["yet", "you"]

"zip" in stopwords_list     # False, but have to check every element

stopwords_set = set(stopwords_list)
"zip" in stopwords_set      # very fast to check

第二个原因是在集合中找到 不同 的项:

item_list = [1, 2, 3, 1, 2, 3]
num_items = len(item_list)                # 6
item_set = set(item_list)                 # {1, 2, 3}
num_distinct_items = len(item_set)        # 3
distinct_item_list = list(item_set)       # [1, 2, 3]

我们会比较少使用集合(sets),相对于字典和列表来说。

控制流程

就像大多数编程语言一样,你可以使用 if 有条件地执行一个操作:

if 1 > 2:
    message = "if only 1 were greater than two..."
elif 1 > 3:
    message = "elif stands for 'else if'"
else:
    message = "when all else fails use else (if you want to)"

你也可以在一行上写一个 三元 if-then-else,我们偶尔会这样做:

parity = "even" if x % 2 == 0 else "odd"

Python 有一个 while 循环:

x = 0
while x < 10:
    print(f"{x} is less than 10")
    x += 1

虽然更多时我们会使用 forin

# range(10) is the numbers 0, 1, ..., 9
for x in range(10):
    print(f"{x} is less than 10")

如果你需要更复杂的逻辑,可以使用 continuebreak

for x in range(10):
    if x == 3:
        continue  # go immediately to the next iteration
    if x == 5:
        break     # quit the loop entirely
    print(x)

这将打印0124

真值

Python 中的布尔值与大多数其他语言的工作方式相同,只是它们大写了起来。

one_is_less_than_two = 1 < 2          # equals True
true_equals_false = True == False     # equals False

Python 使用值None来表示不存在的值。它类似于其他语言的null

x = None
assert x == None, "this is the not the Pythonic way to check for None"
assert x is None, "this is the Pythonic way to check for None"

Python 允许你在期望布尔值的地方使用任何值。以下值都是“假”的:

  • False

  • None

  • [](一个空的list

  • {}(一个空的dict

  • ""

  • set()

  • 0

  • 0.0

几乎任何其他东西都会被视为“真”。这使你可以轻松地使用if语句来测试空列表、空字符串、空字典等。但如果你没有预料到这种行为,有时会导致棘手的错误:

s = some_function_that_returns_a_string()
if s:
    first_char = s[0]
else:
    first_char = ""

这样做的一种更简短(但可能更令人困惑)的方法是:

first_char = s and s[0]

因为and在第一个值为“真”时返回第二个值,在第一个值不为“真”时返回第一个值。类似地,如果x是一个数字或可能是None

safe_x = x or 0

显然是一个数字,尽管:

safe_x = x if x is not None else 0

可能更易读。

Python 有一个all函数,它接受一个可迭代对象,在每个元素都为真时返回True,还有一个any函数,当至少一个元素为真时返回True

all([True, 1, {3}])   # True, all are truthy
all([True, 1, {}])    # False, {} is falsy
any([True, 1, {}])    # True, True is truthy
all([])               # True, no falsy elements in the list
any([])               # False, no truthy elements in the list

排序

每个 Python 列表都有一个sort方法,它会原地对列表进行排序。如果你不想改变你的列表,可以使用sorted函数,它会返回一个新的列表:

x = [4, 1, 2, 3]
y = sorted(x)     # y is [1, 2, 3, 4], x is unchanged
x.sort()          # now x is [1, 2, 3, 4]

默认情况下,sort(和sorted)根据简单地比较元素之间的结果将列表从最小到最大排序。

如果你想要将元素按从大到小的顺序排序,可以指定一个reverse=True参数。而且你可以使用key指定的函数的结果来比较元素本身而不是比较元素本身:

# sort the list by absolute value from largest to smallest
x = sorted([-4, 1, -2, 3], key=abs, reverse=True)  # is [-4, 3, -2, 1]

# sort the words and counts from highest count to lowest
wc = sorted(word_counts.items(),
            key=lambda word_and_count: word_and_count[1],
            reverse=True)

列表推导

经常情况下,你可能希望通过选择特定的元素、转换元素或两者兼而有之来将一个列表转换为另一个列表。在 Python 中,实现这一点的方式是使用列表推导

even_numbers = [x for x in range(5) if x % 2 == 0]  # [0, 2, 4]
squares      = [x * x for x in range(5)]            # [0, 1, 4, 9, 16]
even_squares = [x * x for x in even_numbers]        # [0, 4, 16]

你可以类似地将列表转换为字典或集合:

square_dict = {x: x * x for x in range(5)}  # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
square_set  = {x * x for x in [1, -1]}      # {1}

如果你不需要列表中的值,使用下划线作为变量名是很常见的:

zeros = [0 for _ in even_numbers]      # has the same length as even_numbers

列表推导可以包含多个for

pairs = [(x, y)
         for x in range(10)
         for y in range(10)]   # 100 pairs (0,0) (0,1) ... (9,8), (9,9)

以后的for循环可以使用先前的结果:

increasing_pairs = [(x, y)                       # only pairs with x < y,
                    for x in range(10)           # range(lo, hi) equals
                    for y in range(x + 1, 10)]   # [lo, lo + 1, ..., hi - 1]

我们会经常使用列表推导。

自动化测试和断言

作为数据科学家,我们会写很多代码。我们如何确保我们的代码是正确的?一种方法是使用类型(稍后讨论),另一种方法是使用自动化测试

有复杂的框架用于编写和运行测试,但在本书中,我们将限制使用assert语句,如果指定的条件不为真,将导致你的代码引发AssertionError

assert 1 + 1 == 2
assert 1 + 1 == 2, "1 + 1 should equal 2 but didn't"

正如你在第二种情况中看到的那样,你可以选择添加一条消息,如果断言失败,该消息将被打印。

断言 1 + 1 = 2 并不特别有趣。更有趣的是断言你编写的函数是否符合你的预期:

def smallest_item(xs):
    return min(xs)

assert smallest_item([10, 20, 5, 40]) == 5
assert smallest_item([1, 0, -1, 2]) == -1

在整本书中,我们会这样使用assert。这是一个良好的实践,我强烈鼓励你在自己的代码中大量使用它。(如果你在 GitHub 上查看书中的代码,你会发现它包含比书中打印出来的更多assert语句。这有助于确信我为你编写的代码是正确的。)

另一个不太常见的用法是对函数的输入进行断言:

def smallest_item(xs):
    assert xs, "empty list has no smallest item"
    return min(xs)

我们偶尔会这样做,但更常见的是我们会使用assert来检查我们的代码是否正确。

面向对象编程

像许多语言一样,Python 允许您定义,封装数据和操作数据的函数。我们有时会使用它们来使我们的代码更清晰简单。最简单的方法可能是通过构造一个带有详细注释的示例来解释它们。

在这里,我们将构造一个代表“计数点击器”的类,这种点击器用于跟踪参加“数据科学高级主题”聚会的人数。

它维护一个count,可以通过click增加计数,允许您read_count,并可以通过reset重置为零。(在现实生活中,其中一个从 9999 滚动到 0000,但我们不会费心去处理。)

要定义一个类,您使用class关键字和 PascalCase 名称:

class CountingClicker:
    """A class can/should have a docstring, just like a function"""

类包含零个或多个成员函数。按照惯例,每个函数都有一个名为self的第一个参数,它引用特定的类实例。

通常,类具有一个名为__init__的构造函数。它接受构造类实例所需的任何参数,并执行你需要的任何设置:

    def __init__(self, count = 0):
        self.count = count

尽管构造函数有一个有趣的名字,我们只使用类名来构造点击器的实例:

clicker1 = CountingClicker()           # initialized to 0
clicker2 = CountingClicker(100)        # starts with count=100
clicker3 = CountingClicker(count=100)  # more explicit way of doing the same

注意,__init__方法名称以双下划线开头和结尾。这些“魔术”方法有时被称为“dunder”方法(double-UNDERscore,明白了吗?)并代表“特殊”行为。

注意

方法名以下划线开头的类方法,按照惯例被认为是“私有”的,类的用户不应直接调用它们。然而,Python 不会阻止用户调用它们。

另一个这样的方法是__repr__,它生成类实例的字符串表示:

    def __repr__(self):
        return f"CountingClicker(count={self.count})"

最后,我们需要实现类的公共 API

    def click(self, num_times = 1):
        """Click the clicker some number of times."""
        self.count += num_times

    def read(self):
        return self.count

    def reset(self):
        self.count = 0

定义好后,让我们使用assert为我们的点击器编写一些测试案例:

clicker = CountingClicker()
assert clicker.read() == 0, "clicker should start with count 0"
clicker.click()
clicker.click()
assert clicker.read() == 2, "after two clicks, clicker should have count 2"
clicker.reset()
assert clicker.read() == 0, "after reset, clicker should be back to 0"

像这样编写测试有助于我们确信我们的代码按设计方式运行,并且在我们对其进行更改时仍然保持如此。

我们偶尔会创建子类,从父类继承一些功能。例如,我们可以通过使用CountingClicker作为基类,并重写reset方法什么也不做,来创建一个不可重置的点击器:

# A subclass inherits all the behavior of its parent class.
class NoResetClicker(CountingClicker):
    # This class has all the same methods as CountingClicker

    # Except that it has a reset method that does nothing.
    def reset(self):
        pass

clicker2 = NoResetClicker()
assert clicker2.read() == 0
clicker2.click()
assert clicker2.read() == 1
clicker2.reset()
assert clicker2.read() == 1, "reset shouldn't do anything"

可迭代对象和生成器

列表的一个好处是你可以通过它们的索引检索特定元素。但你并不总是需要这样做!一个包含十亿个数字的列表会占用大量内存。如果你只想逐个获取元素,那么没有必要将它们全部保存下来。如果你最终只需要前几个元素,生成整个十亿个元素是极其浪费的。

通常我们只需要使用 forin 迭代集合。在这种情况下,我们可以创建生成器,它们可以像列表一样被迭代,但是在需要时会惰性地生成它们的值。

创建生成器的一种方式是使用函数和 yield 运算符:

def generate_range(n):
    i = 0
    while i < n:
        yield i   # every call to yield produces a value of the generator
        i += 1

以下循环将逐个消耗 yield 出的值,直到没有剩余值为止:

for i in generate_range(10):
    print(f"i: {i}")

(事实上,range 本身也是惰性的,所以这样做没有意义。)

使用生成器,甚至可以创建一个无限序列:

def natural_numbers():
    """returns 1, 2, 3, ..."""
    n = 1
    while True:
        yield n
        n += 1

尽管你可能不应该在没有使用某种 break 逻辑的情况下迭代它。

提示

惰性的另一面是,你只能对生成器进行一次迭代。如果你需要多次迭代某个东西,你需要每次重新创建生成器,或者使用一个列表。如果生成值很昂贵,那么使用列表可能是个好理由。

创建生成器的第二种方式是使用 for 推导式,用括号括起来:

evens_below_20 = (i for i in generate_range(20) if i % 2 == 0)

这样的“生成器推导式”在你迭代它(使用fornext)之前不会执行任何操作。我们可以利用这一点构建复杂的数据处理流水线:

# None of these computations *does* anything until we iterate
data = natural_numbers()
evens = (x for x in data if x % 2 == 0)
even_squares = (x ** 2 for x in evens)
even_squares_ending_in_six = (x for x in even_squares if x % 10 == 6)
# and so on

在我们迭代列表或生成器时,经常会需要不仅值而且它们的索引。为了这种常见情况,Python 提供了一个 enumerate 函数,它将值转换为 (index, value) 对:

names = ["Alice", "Bob", "Charlie", "Debbie"]

# not Pythonic
for i in range(len(names)):
    print(f"name {i} is {names[i]}")

# also not Pythonic
i = 0
for name in names:
    print(f"name {i} is {names[i]}")
    i += 1

# Pythonic
for i, name in enumerate(names):
    print(f"name {i} is {name}")

我们会经常使用到这个。

随机性

当我们学习数据科学时,我们经常需要生成随机数,这可以通过 random 模块来实现:

import random
random.seed(10)  # this ensures we get the same results every time

four_uniform_randoms = [random.random() for _ in range(4)]

# [0.5714025946899135,       # random.random() produces numbers
#  0.4288890546751146,       # uniformly between 0 and 1.
#  0.5780913011344704,       # It's the random function we'll use
#  0.20609823213950174]      # most often.

random 模块实际上产生伪随机(即确定性)数,其基于一个你可以用 random.seed 设置的内部状态,如果你想要可重复的结果:

random.seed(10)         # set the seed to 10
print(random.random())  # 0.57140259469
random.seed(10)         # reset the seed to 10
print(random.random())  # 0.57140259469 again

我们有时会使用 random.randrange,它接受一个或两个参数,并从相应的range中随机选择一个元素:

random.randrange(10)    # choose randomly from range(10) = [0, 1, ..., 9]
random.randrange(3, 6)  # choose randomly from range(3, 6) = [3, 4, 5]

还有一些方法,我们有时会发现很方便。例如,random.shuffle 随机重新排列列表的元素:

up_to_ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
random.shuffle(up_to_ten)
print(up_to_ten)
# [7, 2, 6, 8, 9, 4, 10, 1, 3, 5]   (your results will probably be different)

如果你需要从列表中随机选择一个元素,你可以使用 random.choice

my_best_friend = random.choice(["Alice", "Bob", "Charlie"])     # "Bob" for me

如果你需要无重复地随机选择一组元素(即,没有重复),你可以使用 random.sample

lottery_numbers = range(60)
winning_numbers = random.sample(lottery_numbers, 6)  # [16, 36, 10, 6, 25, 9]

要选择一个带有替换(即允许重复)的元素样本,你可以多次调用 random.choice

four_with_replacement = [random.choice(range(10)) for _ in range(4)]
print(four_with_replacement)  # [9, 4, 4, 2]

正则表达式

正则表达式提供了一种搜索文本的方式。它们非常有用,但也相当复杂——以至于整本书都可以写关于它们的详细内容。我们会在遇到它们的几次机会中深入了解它们的细节;以下是如何在 Python 中使用它们的几个示例:

import re

re_examples = [                        # All of these are True, because
    not re.match("a", "cat"),              #  'cat' doesn't start with 'a'
    re.search("a", "cat"),                 #  'cat' has an 'a' in it
    not re.search("c", "dog"),             #  'dog' doesn't have a 'c' in it.
    3 == len(re.split("[ab]", "carbs")),   #  Split on a or b to ['c','r','s'].
    "R-D-" == re.sub("[0-9]", "-", "R2D2") #  Replace digits with dashes.
    ]

assert all(re_examples), "all the regex examples should be True"

重要的一点是,re.match 检查字符串的 开头 是否与正则表达式匹配,而 re.search 则检查字符串的 任何部分 是否与正则表达式匹配。你迟早会搞混它们,并因此而苦恼。

官方文档提供了更详细的信息,可以参考 official documentation

函数式编程

注意

本书的第一版在此处介绍了 Python 函数 partialmapreducefilter。在我追求真理的过程中,我意识到最好避免使用这些函数,并且书中它们的用法已被列表推导式、for 循环和其他更具 Python 风格的结构所取代。

zip 和 参数解包

我们经常需要将两个或更多的可迭代对象 zip 在一起。zip 函数将多个可迭代对象转换为一个由相应函数的元组组成的单一可迭代对象:

list1 = ['a', 'b', 'c']
list2 = [1, 2, 3]

# zip is lazy, so you have to do something like the following
[pair for pair in zip(list1, list2)]    # is [('a', 1), ('b', 2), ('c', 3)]

如果列表长度不同,zip 会在第一个列表结束时停止。

你也可以使用一个奇怪的技巧来“解压缩”列表:

pairs = [('a', 1), ('b', 2), ('c', 3)]
letters, numbers = zip(*pairs)

星号 (*) 执行 参数解包,它使用 pairs 的元素作为 zip 的单独参数。最终效果与你直接调用时一样:

letters, numbers = zip(('a', 1), ('b', 2), ('c', 3))

您可以将参数解包与任何函数一起使用:

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

add(1, 2)      # returns 3
try:
    add([1, 2])
except TypeError:
    print("add expects two inputs")
add(*[1, 2])   # returns 3

很少会发现这很有用,但当我们需要时,这是一个很好的技巧。

args 和 kwargs

假设我们想创建一个接受某些函数 f 作为输入并返回一个新函数的高阶函数,对于任何输入,它返回 f 的值的两倍:

def doubler(f):
    # Here we define a new function that keeps a reference to f
    def g(x):
        return 2 * f(x)

    # And return that new function
    return g

这在某些情况下有效:

def f1(x):
    return x + 1

g = doubler(f1)
assert g(3) == 8,  "(3 + 1) * 2 should equal 8"
assert g(-1) == 0, "(-1 + 1) * 2 should equal 0"

但是,它不能与接受多个参数的函数一起工作:

def f2(x, y):
    return x + y

g = doubler(f2)
try:
    g(1, 2)
except TypeError:
    print("as defined, g only takes one argument")

我们需要一种方法来指定一个接受任意参数的函数。我们可以通过参数解包和一点点魔法来实现这一点:

def magic(*args, **kwargs):
    print("unnamed args:", args)
    print("keyword args:", kwargs)

magic(1, 2, key="word", key2="word2")

# prints
#  unnamed args: (1, 2)
#  keyword args: {'key': 'word', 'key2': 'word2'}

换句话说,当我们像这样定义一个函数时,args 是它的未命名参数的元组,而 kwargs 是它的命名参数的字典。它也可以反过来使用,如果您想使用一个列表(或元组)和字典来 提供 函数的参数:

def other_way_magic(x, y, z):
    return x + y + z

x_y_list = [1, 2]
z_dict = {"z": 3}
assert other_way_magic(*x_y_list, **z_dict) == 6, "1 + 2 + 3 should be 6"

使用这个,您可以做各种奇怪的技巧;我们只会用它来生成接受任意参数的高阶函数:

def doubler_correct(f):
    """works no matter what kind of inputs f expects"""
    def g(*args, **kwargs):
        """whatever arguments g is supplied, pass them through to f"""
        return 2 * f(*args, **kwargs)
    return g

g = doubler_correct(f2)
assert g(1, 2) == 6, "doubler should work now"

作为一般规则,如果你明确说明函数需要什么样的参数,你的代码将更加正确和可读;因此,只有在没有其他选择时我们才会使用 argskwargs

类型注解

Python 是一种 动态类型 语言。这意味着它一般不关心我们使用的对象的类型,只要我们以有效的方式使用它们即可:

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

assert add(10, 5) == 15,                  "+ is valid for numbers"
assert add([1, 2], [3]) == [1, 2, 3],     "+ is valid for lists"
assert add("hi ", "there") == "hi there", "+ is valid for strings"

try:
    add(10, "five")
except TypeError:
    print("cannot add an int to a string")

而在 静态类型 语言中,我们的函数和对象会有特定的类型:

def add(a: int, b: int) -> int:
    return a + b

add(10, 5)           # you'd like this to be OK
add("hi ", "there")  # you'd like this to be not OK

实际上,最近的 Python 版本确实(某种程度上)具有这种功能。带有 int 类型注释的前一版本在 Python 3.6 中是有效的!

然而,这些类型注释实际上并不会执行任何操作。您仍然可以使用带有注释的 add 函数来添加字符串,调用 add(10, "five") 仍然会引发完全相同的 TypeError

尽管如此,在您的 Python 代码中仍然有(至少)四个好理由使用类型注释:

  • 类型是重要的文档形式。在这本书中,代码用于教授您理论和数学概念,这一点尤为重要。比较以下两个函数桩代码:

    def dot_product(x, y): ...
    
    # we have not yet defined Vector, but imagine we had
    def dot_product(x: Vector, y: Vector) -> float: ...
    

    我发现第二种方法更加信息丰富;希望您也这样认为。(到此为止,我已经习惯了类型提示,现在发现未经类型标注的 Python 代码难以阅读。)

  • 有一些外部工具(最流行的是 mypy)可以读取您的代码,检查类型注释,并在您运行代码之前通知您有关类型错误。例如,如果您运行 mypy 并包含 add("hi ", "there") 的文件,它会警告您:

    error: Argument 1 to "add" has incompatible type "str"; expected "int"
    

    类似于 assert 测试,这是发现代码错误的好方法,而不需要运行它。本书的叙述不涉及这样的类型检查器;然而,在幕后,我将运行一个,这将有助于确保书本身是正确的。

  • 不得不考虑代码中的类型强制您设计更清晰的函数和接口:

    from typing import Union
    
    def secretly_ugly_function(value, operation): ...
    
    def ugly_function(value: int,
                      operation: Union[str, int, float, bool]) -> int:
        ...
    

    这里我们有一个函数,其 operation 参数可以是 stringintfloatbool。很可能这个函数很脆弱且难以使用,但是当类型明确时,它变得更加清晰。这样做将迫使我们以更少的笨拙方式进行设计,用户会因此受益。

  • 使用类型允许您的编辑器帮助您完成诸如自动完成(图 2-1)之类的事情,并且可以在类型错误时发出警告。

VSCode, but likely your editor does the same.

图 2-1. VSCode,但可能您的编辑器也是如此。

有时候人们坚持认为类型提示可能对大型项目有价值,但对于小型项目来说不值得花费时间。然而,由于类型提示几乎不需要额外的输入时间,并且允许您的编辑器节省时间,因此我认为它们实际上可以让您更快地编写代码,即使是对于小型项目也是如此。

出于所有这些原因,本书其余部分的所有代码都将使用类型注释。我预计一些读者可能对使用类型注释感到不满意;然而,我认为到本书结束时,他们的想法会改变。

如何编写类型注释

正如我们所见,对于像 intboolfloat 这样的内置类型,您只需使用类型本身作为注释。如果您有(比如)一个 list 呢?

def total(xs: list) -> float:
    return sum(total)

这不是错误,但类型不够具体。显然我们真正想要的是 xsfloatslist,而不是(比如)字符串的 list

typing 模块提供了许多参数化类型,我们可以使用它们来做这件事:

from typing import List  # note capital L

def total(xs: List[float]) -> float:
    return sum(total)

到目前为止,我们只为函数参数和返回类型指定了注解。对于变量本身来说,通常很明显它们的类型是什么:

# This is how to type-annotate variables when you define them.
# But this is unnecessary; it's "obvious" x is an int.
x: int = 5

但有时候并不明显:

values = []         # what's my type?
best_so_far = None  # what's my type?

在这种情况下,我们将提供内联类型提示:

from typing import Optional

values: List[int] = []
best_so_far: Optional[float] = None  # allowed to be either a float or None

typing 模块包含许多其他类型,我们可能只会用到其中的几种:

# the type annotations in this snippet are all unnecessary
from typing import Dict, Iterable, Tuple

# keys are strings, values are ints
counts: Dict[str, int] = {'data': 1, 'science': 2}

# lists and generators are both iterable
if lazy:
    evens: Iterable[int] = (x for x in range(10) if x % 2 == 0)
else:
    evens = [0, 2, 4, 6, 8]

# tuples specify a type for each element
triple: Tuple[int, float, int] = (10, 2.3, 5)

最后,由于 Python 拥有头等函数,我们需要一个类型来表示这些。这里有一个相当牵强的例子:

from typing import Callable

# The type hint says that repeater is a function that takes
# two arguments, a string and an int, and returns a string.
def twice(repeater: Callable[[str, int], str], s: str) -> str:
    return repeater(s, 2)

def comma_repeater(s: str, n: int) -> str:
    n_copies = [s for _ in range(n)]
    return ', '.join(n_copies)

assert twice(comma_repeater, "type hints") == "type hints, type hints"

由于类型注解只是 Python 对象,我们可以将它们分配给变量,以便更容易引用它们:

Number = int
Numbers = List[Number]

def total(xs: Numbers) -> Number:
    return sum(xs)

当你读完本书时,你将非常熟悉读写类型注解,希望你会在自己的代码中使用它们。

欢迎来到 DataSciencester!

这就结束了新员工的入职培训。哦,还有:尽量不要贪污任何东西。

进一步探索

  • 世界上没有缺少 Python 教程。官方教程 不是一个坏的起点。

  • 官方 IPython 教程 将帮助您开始使用 IPython,如果您决定使用它。请使用它。

  • mypy 文档 将告诉你关于 Python 类型注解和类型检查的一切,甚至超出了你想知道的范围。

第三章:数据可视化

我相信可视化是实现个人目标最强大的手段之一。

Harvey Mackay

数据科学家工具包的基本组成部分是数据可视化。虽然创建可视化非常容易,但要生成优质的可视化要困难得多。

数据可视化有两个主要用途:

  • 探索数据

  • 传达数据

在本章中,我们将集中于构建您开始探索自己数据所需的技能,并生成我们在本书其余部分中将使用的可视化内容。与大多数章节主题一样,数据可视化是一个值得拥有自己书籍的丰富领域。尽管如此,我将尝试给您一些关于好的可视化和不好的可视化的认识。

matplotlib

存在各种工具用于数据可视化。我们将使用广泛使用的matplotlib 库,尽管它有些显得老旧,但仍被广泛使用。如果您有兴趣生成精致的互动可视化内容,那么它可能不是正确的选择,但对于简单的柱状图、折线图和散点图,它运行得相当不错。

正如前面提到的,matplotlib 不是 Python 核心库的一部分。在激活虚拟环境后(要设置一个,请返回到“虚拟环境”并按照说明操作),使用以下命令安装它:

python -m pip install matplotlib

我们将使用matplotlib.pyplot模块。在最简单的用法中,pyplot保持一个内部状态,您可以逐步构建可视化内容。完成后,您可以使用savefig保存它,或者使用show显示它。

例如,制作简单的图表(如图 3-1)非常简单:

from matplotlib import pyplot as plt

years = [1950, 1960, 1970, 1980, 1990, 2000, 2010]
gdp = [300.2, 543.3, 1075.9, 2862.5, 5979.6, 10289.7, 14958.3]

# create a line chart, years on x-axis, gdp on y-axis
plt.plot(years, gdp, color='green', marker='o', linestyle='solid')

# add a title
plt.title("Nominal GDP")

# add a label to the y-axis
plt.ylabel("Billions of $")
plt.show()

一个简单的折线图。

图 3-1. 一个简单的折线图

制作看起来像出版物质量的图表更为复杂,超出了本章的范围。您可以通过诸如轴标签、线条样式和点标记等方式自定义图表的许多方面。与其尝试全面处理这些选项,我们将在示例中简单使用(并强调)其中一些。

尽管我们不会使用这种功能的大部分,但 matplotlib 能够在图形中生成复杂的细节图、高级格式化和交互式可视化内容。如果您希望比我们在本书中更深入地了解,请查阅其文档

柱状图

当您想展示某些数量在一些离散项目中如何变化时,柱状图是一个不错的选择。例如,图 3-2 显示了各种电影赢得的学院奖数量:

movies = ["Annie Hall", "Ben-Hur", "Casablanca", "Gandhi", "West Side Story"]
num_oscars = [5, 11, 3, 8, 10]

# plot bars with left x-coordinates [0, 1, 2, 3, 4], heights [num_oscars]
plt.bar(range(len(movies)), num_oscars)

plt.title("My Favorite Movies")     # add a title
plt.ylabel("# of Academy Awards")   # label the y-axis

# label x-axis with movie names at bar centers
plt.xticks(range(len(movies)), movies)

plt.show()

一个简单的柱状图。

图 3-2. 一个简单的柱状图

柱状图也可以是绘制分桶数值直方图的好选择,如图 3-3,以便直观地探索数值的分布

from collections import Counter
grades = [83, 95, 91, 87, 70, 0, 85, 82, 100, 67, 73, 77, 0]

# Bucket grades by decile, but put 100 in with the 90s
histogram = Counter(min(grade // 10 * 10, 90) for grade in grades)

plt.bar([x + 5 for x in histogram.keys()],  # Shift bars right by 5
        histogram.values(),                 # Give each bar its correct height
        10,                                 # Give each bar a width of 10
        edgecolor=(0, 0, 0))                # Black edges for each bar

plt.axis([-5, 105, 0, 5])                  # x-axis from -5 to 105,
                                           # y-axis from 0 to 5

plt.xticks([10 * i for i in range(11)])    # x-axis labels at 0, 10, ..., 100
plt.xlabel("Decile")
plt.ylabel("# of Students")
plt.title("Distribution of Exam 1 Grades")
plt.show()

条形图直方图。

图 3-3. 使用条形图制作直方图

plt.bar 的第三个参数指定了条形宽度。在这里,我们选择宽度为 10,以填满整个十分位。我们还将条形向右移动了 5,因此,例如,“10” 条形(对应于十分位 10-20)其中心在 15 处,因此占据了正确的范围。我们还为每个条形添加了黑色边缘,使它们在视觉上更加突出。

plt.axis 的调用指示我们希望 x 轴范围从 -5 到 105(仅留出一点左右的空间),y 轴应该从 0 到 5。对 plt.xticks 的调用在 0、10、20、…、100 处放置 x 轴标签。

使用 plt.axis 时要慎重。在创建条形图时,如果你的 y 轴不从 0 开始,这被认为是一种特别糟糕的做法,因为这很容易误导人(图 3-4):

mentions = [500, 505]
years = [2017, 2018]

plt.bar(years, mentions, 0.8)
plt.xticks(years)
plt.ylabel("# of times I heard someone say 'data science'")

# if you don't do this, matplotlib will label the x-axis 0, 1
# and then add a +2.013e3 off in the corner (bad matplotlib!)
plt.ticklabel_format(useOffset=False)

# misleading y-axis only shows the part above 500
plt.axis([2016.5, 2018.5, 499, 506])
plt.title("Look at the 'Huge' Increase!")
plt.show()

误导性的 y 轴。

图 3-4. 具有误导性 y 轴的图表

在 图 3-5 中,我们使用更合理的轴,效果看起来不那么令人印象深刻:

plt.axis([2016.5, 2018.5, 0, 550])
plt.title("Not So Huge Anymore")
plt.show()

非误导性的 y 轴。

图 3-5. 具有非误导性 y 轴的相同图表

折线图

正如我们已经看到的,我们可以使用 plt.plot 制作折线图。这些图表适合展示趋势,如 图 3-6 所示:

variance     = [1, 2, 4, 8, 16, 32, 64, 128, 256]
bias_squared = [256, 128, 64, 32, 16, 8, 4, 2, 1]
total_error  = [x + y for x, y in zip(variance, bias_squared)]
xs = [i for i, _ in enumerate(variance)]

# We can make multiple calls to plt.plot
# to show multiple series on the same chart
plt.plot(xs, variance,     'g-',  label='variance')    # green solid line
plt.plot(xs, bias_squared, 'r-.', label='bias²')      # red dot-dashed line
plt.plot(xs, total_error,  'b:',  label='total error') # blue dotted line

# Because we've assigned labels to each series,
# we can get a legend for free (loc=9 means "top center")
plt.legend(loc=9)
plt.xlabel("model complexity")
plt.xticks([])
plt.title("The Bias-Variance Tradeoff")
plt.show()

带有图例的多条线图。

图 3-6. 带有图例的多条线图

散点图

散点图是可视化两组配对数据之间关系的正确选择。例如,图 3-7 描述了用户拥有的朋友数量与他们每天在网站上花费的时间之间的关系:

friends = [ 70,  65,  72,  63,  71,  64,  60,  64,  67]
minutes = [175, 170, 205, 120, 220, 130, 105, 145, 190]
labels =  ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']

plt.scatter(friends, minutes)

# label each point
for label, friend_count, minute_count in zip(labels, friends, minutes):
    plt.annotate(label,
        xy=(friend_count, minute_count), # Put the label with its point
        xytext=(5, -5),                  # but slightly offset
        textcoords='offset points')

plt.title("Daily Minutes vs. Number of Friends")
plt.xlabel("# of friends")
plt.ylabel("daily minutes spent on the site")
plt.show()

朋友和网站上时间的散点图。

图 3-7. 朋友和网站上时间的散点图

如果你散布可比较的变量,如果让 matplotlib 自行选择比例,可能会得到一个误导性的图像,如 图 3-8 所示。

具有不可比较轴的散点图。

图 3-8. 具有不可比较轴的散点图
test_1_grades = [ 99, 90, 85, 97, 80]
test_2_grades = [100, 85, 60, 90, 70]

plt.scatter(test_1_grades, test_2_grades)
plt.title("Axes Aren't Comparable")
plt.xlabel("test 1 grade")
plt.ylabel("test 2 grade")
plt.show()

如果我们包含对 plt.axis("equal") 的调用,则绘图(图 3-9)更准确地显示了大部分变化发生在测试 2 上。

这已足够让你开始进行可视化了。我们会在本书中更多地学习可视化。

具有相等轴的散点图。

图 3-9. 具有相等轴的相同散点图

进一步探索

  • matplotlib Gallery 将为你展示使用 matplotlib 可以做的各种事情(以及如何做)。

  • seaborn 建立在 matplotlib 之上,使你能够轻松生成更漂亮(和更复杂)的可视化。

  • Altair 是一个较新的 Python 库,用于创建声明式可视化。

  • D3.js 是用于在 Web 上生成复杂交互式可视化的 JavaScript 库。虽然它不是 Python 的库,但它被广泛使用,你值得熟悉它。

  • Bokeh 是一个将 D3 风格的可视化引入 Python 的库。

第四章:线性代数

还有比代数更无用或更少有用的东西吗?

比利·康诺利

线性代数是处理向量空间的数学分支。虽然我不能指望在简短的章节中教给您线性代数,但它是许多数据科学概念和技术的基础,这意味着我至少应该尝试一下。我们在本章学到的内容将在本书的其余部分中广泛使用。

向量

抽象地说,向量是可以相加以形成新向量的对象,并且可以乘以标量(即数字),也可以形成新向量。

具体来说(对于我们而言),向量是某个有限维空间中的点。尽管您可能不认为自己的数据是向量,但它们通常是表示数值数据的有用方式。

例如,如果您有许多人的身高、体重和年龄数据,您可以将您的数据视为三维向量[height, weight, age]。如果您正在教授一个有四次考试的班级,您可以将学生的成绩视为四维向量[exam1, exam2, exam3, exam4]

从头开始的最简单方法是将向量表示为数字列表。三个数字的列表对应于三维空间中的一个向量,反之亦然。

我们将通过一个类型别名来实现,即 Vector 只是 floatlist

from typing import List

Vector = List[float]

height_weight_age = [70,  # inches,
                     170, # pounds,
                     40 ] # years

grades = [95,   # exam1
          80,   # exam2
          75,   # exam3
          62 ]  # exam4

我们还希望在向量上执行算术运算。因为 Python 的 list 不是向量(因此不提供向量算术的工具),我们需要自己构建这些算术工具。所以让我们从这里开始。

首先,我们经常需要添加两个向量。向量是分量相加的。这意味着如果两个向量vw长度相同,则它们的和就是一个向量,其第一个元素是v[0] + w[0],第二个元素是v[1] + w[1],依此类推。(如果它们长度不同,则不允许将它们相加。)

例如,将向量 [1, 2][2, 1] 相加的结果是 [1 + 2, 2 + 1][3, 3],如图 4-1 所示。

添加两个向量。

图 4-1. 添加两个向量

我们可以通过使用 zip 将向量一起并使用列表推导来添加相应元素来轻松实现这一点:

def add(v: Vector, w: Vector) -> Vector:
    """Adds corresponding elements"""
    assert len(v) == len(w), "vectors must be the same length"

    return [v_i + w_i for v_i, w_i in zip(v, w)]

assert add([1, 2, 3], [4, 5, 6]) == [5, 7, 9]

类似地,要减去两个向量,我们只需减去相应的元素:

def subtract(v: Vector, w: Vector) -> Vector:
    """Subtracts corresponding elements"""
    assert len(v) == len(w), "vectors must be the same length"

    return [v_i - w_i for v_i, w_i in zip(v, w)]

assert subtract([5, 7, 9], [4, 5, 6]) == [1, 2, 3]

我们有时还希望对向量列表进行分量求和,即创建一个新向量,其第一个元素是所有第一个元素的和,第二个元素是所有第二个元素的和,依此类推:

def vector_sum(vectors: List[Vector]) -> Vector:
    """Sums all corresponding elements"""
    # Check that vectors is not empty
    assert vectors, "no vectors provided!"

    # Check the vectors are all the same size
    num_elements = len(vectors[0])
    assert all(len(v) == num_elements for v in vectors), "different sizes!"

    # the i-th element of the result is the sum of every vector[i]
    return [sum(vector[i] for vector in vectors)
            for i in range(num_elements)]

assert vector_sum([[1, 2], [3, 4], [5, 6], [7, 8]]) == [16, 20]

我们还需要能够将向量乘以标量,这可以通过将向量的每个元素乘以该数来简单实现:

def scalar_multiply(c: float, v: Vector) -> Vector:
    """Multiplies every element by c"""
    return [c * v_i for v_i in v]

assert scalar_multiply(2, [1, 2, 3]) == [2, 4, 6]

这使我们能够计算(相同大小的)向量列表的分量均值:

def vector_mean(vectors: List[Vector]) -> Vector:
    """Computes the element-wise average"""
    n = len(vectors)
    return scalar_multiply(1/n, vector_sum(vectors))

assert vector_mean([[1, 2], [3, 4], [5, 6]]) == [3, 4]

一个不那么明显的工具是点积。两个向量的点积是它们各分量的乘积之和:

def dot(v: Vector, w: Vector) -> float:
    """Computes v_1 * w_1 + ... + v_n * w_n"""
    assert len(v) == len(w), "vectors must be same length"

    return sum(v_i * w_i for v_i, w_i in zip(v, w))

assert dot([1, 2, 3], [4, 5, 6]) == 32  # 1 * 4 + 2 * 5 + 3 * 6

如果w的大小为 1,则点积测量向量vw方向延伸的距离。例如,如果w = [1, 0],那么dot(v, w)就是v的第一个分量。另一种说法是,这是您在将 v 投影到 w上时获得的向量的长度(图 4-2)。

作为向量投影的点积。

图 4-2。向量投影的点积

使用这个方法,计算一个向量的平方和很容易:

def sum_of_squares(v: Vector) -> float:
    """Returns v_1 * v_1 + ... + v_n * v_n"""
    return dot(v, v)

assert sum_of_squares([1, 2, 3]) == 14  # 1 * 1 + 2 * 2 + 3 * 3

我们可以用它来计算其大小(或长度):

import math

def magnitude(v: Vector) -> float:
    """Returns the magnitude (or length) of v"""
    return math.sqrt(sum_of_squares(v))   # math.sqrt is square root function

assert magnitude([3, 4]) == 5

我们现在已经有了计算两个向量之间距离的所有要素,定义为:

(v 1 -w 1 ) 2 + ... + (v n -w n ) 2

在代码中:

def squared_distance(v: Vector, w: Vector) -> float:
    """Computes (v_1 - w_1) ** 2 + ... + (v_n - w_n) ** 2"""
    return sum_of_squares(subtract(v, w))

def distance(v: Vector, w: Vector) -> float:
    """Computes the distance between v and w"""
    return math.sqrt(squared_distance(v, w))

如果我们将其写为(等价的)形式,则可能更清楚:

def distance(v: Vector, w: Vector) -> float:
    return magnitude(subtract(v, w))

这应该足以让我们开始了。我们将在整本书中大量使用这些函数。

注意

将列表用作向量在阐述上很好,但在性能上很差。

在生产代码中,您会希望使用 NumPy 库,该库包括一个高性能的数组类,其中包括各种算术运算。

矩阵

矩阵是一种二维数字集合。我们将矩阵表示为列表的列表,每个内部列表具有相同的大小,并表示矩阵的一行。如果A是一个矩阵,那么A[i][j]是矩阵的第i行和第j列的元素。按照数学约定,我们经常使用大写字母表示矩阵。例如:

# Another type alias
Matrix = List[List[float]]

A = [[1, 2, 3],  # A has 2 rows and 3 columns
     [4, 5, 6]]

B = [[1, 2],     # B has 3 rows and 2 columns
     [3, 4],
     [5, 6]]
注意

在数学上,您通常会将矩阵的第一行命名为“第 1 行”,第一列命名为“第 1 列”。因为我们用 Python list表示矩阵,而 Python 中的列表是从零开始索引的,所以我们将矩阵的第一行称为“第 0 行”,第一列称为“第 0 列”。

给定这个列表的列表表示,矩阵Alen(A)行和len(A[0])列,我们将其称为其形状

from typing import Tuple

def shape(A: Matrix) -> Tuple[int, int]:
    """Returns (# of rows of A, # of columns of A)"""
    num_rows = len(A)
    num_cols = len(A[0]) if A else 0   # number of elements in first row
    return num_rows, num_cols

assert shape([[1, 2, 3], [4, 5, 6]]) == (2, 3)  # 2 rows, 3 columns

如果一个矩阵有n行和k列,我们将其称为n × k 矩阵。我们可以(有时会)将n × k矩阵的每一行视为长度为k的向量,将每一列视为长度为n的向量:

def get_row(A: Matrix, i: int) -> Vector:
    """Returns the i-th row of A (as a Vector)"""
    return A[i]             # A[i] is already the ith row

def get_column(A: Matrix, j: int) -> Vector:
    """Returns the j-th column of A (as a Vector)"""
    return [A_i[j]          # jth element of row A_i
            for A_i in A]   # for each row A_i

我们还希望能够根据其形状和生成其元素的函数创建一个矩阵。我们可以使用嵌套列表推导来实现这一点:

from typing import Callable

def make_matrix(num_rows: int,
                num_cols: int,
                entry_fn: Callable[[int, int], float]) -> Matrix:
    """
 Returns a num_rows x num_cols matrix
 whose (i,j)-th entry is entry_fn(i, j)
 """
    return [[entry_fn(i, j)             # given i, create a list
             for j in range(num_cols)]  #   [entry_fn(i, 0), ... ]
            for i in range(num_rows)]   # create one list for each i

给定此函数,您可以制作一个 5 × 5 单位矩阵(对角线上为 1,其他位置为 0),如下所示:

def identity_matrix(n: int) -> Matrix:
    """Returns the n x n identity matrix"""
    return make_matrix(n, n, lambda i, j: 1 if i == j else 0)

assert identity_matrix(5) == [[1, 0, 0, 0, 0],
                              [0, 1, 0, 0, 0],
                              [0, 0, 1, 0, 0],
                              [0, 0, 0, 1, 0],
                              [0, 0, 0, 0, 1]]

矩阵对我们来说将是重要的,有几个原因。

首先,我们可以使用矩阵来表示由多个向量组成的数据集,简单地将每个向量视为矩阵的一行。例如,如果您有 1,000 人的身高、体重和年龄,您可以将它们放在一个 1,000 × 3 矩阵中:

data = [[70, 170, 40],
        [65, 120, 26],
        [77, 250, 19],
        # ....
       ]

其次,正如我们后面将看到的,我们可以使用n × k矩阵来表示将k维向量映射到n维向量的线性函数。我们的许多技术和概念都涉及这样的函数。

第三,矩阵可用于表示二元关系。在 第一章 中,我们将网络的边表示为一组对 (i, j)。另一种表示方法是创建一个矩阵 A,使得如果节点 ij 连接,则 A[i][j] 为 1,否则为 0。

请记住,在此之前我们有:

friendships = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4),
               (4, 5), (5, 6), (5, 7), (6, 8), (7, 8), (8, 9)]

我们也可以将这表示为:

#            user 0  1  2  3  4  5  6  7  8  9
#
friend_matrix = [[0, 1, 1, 0, 0, 0, 0, 0, 0, 0],  # user 0
                 [1, 0, 1, 1, 0, 0, 0, 0, 0, 0],  # user 1
                 [1, 1, 0, 1, 0, 0, 0, 0, 0, 0],  # user 2
                 [0, 1, 1, 0, 1, 0, 0, 0, 0, 0],  # user 3
                 [0, 0, 0, 1, 0, 1, 0, 0, 0, 0],  # user 4
                 [0, 0, 0, 0, 1, 0, 1, 1, 0, 0],  # user 5
                 [0, 0, 0, 0, 0, 1, 0, 0, 1, 0],  # user 6
                 [0, 0, 0, 0, 0, 1, 0, 0, 1, 0],  # user 7
                 [0, 0, 0, 0, 0, 0, 1, 1, 0, 1],  # user 8
                 [0, 0, 0, 0, 0, 0, 0, 0, 1, 0]]  # user 9

如果连接很少,这种表示方法会更加低效,因为你最终需要存储很多个零。然而,使用矩阵表示法可以更快地检查两个节点是否连接——你只需要进行矩阵查找,而不是(可能)检查每条边:

assert friend_matrix[0][2] == 1, "0 and 2 are friends"
assert friend_matrix[0][8] == 0, "0 and 8 are not friends"

同样,要找到一个节点的连接,你只需要检查对应节点的列(或行):

# only need to look at one row
friends_of_five = [i
                   for i, is_friend in enumerate(friend_matrix[5])
                   if is_friend]

对于一个小图,你可以简单地为每个节点对象添加一个连接列表以加速这个过程;但对于一个大型、不断演化的图,这可能会太昂贵和难以维护。

我们将在本书中多次涉及矩阵。

进一步探索

  • 线性代数被数据科学家广泛使用(通常是隐式地使用,并且经常被不理解它的人使用)。阅读一本教材并不是一个坏主意。你可以在网上找到几本免费的教材:

    • 线性代数,作者 Jim Hefferon(圣迈克尔学院)

    • 线性代数,作者 David Cherney、Tom Denton、Rohit Thomas 和 Andrew Waldron(加州大学戴维斯分校)

    • 如果你感觉有冒险精神,线性代数错误方法,作者 Sergei Treil(布朗大学),是一个更高级的入门教程。

  • 如果你使用 NumPy,那么我们在本章中构建的所有机制都是免费的。(你还会得到更多,包括更好的性能。)

第五章:统计学

事实是倔强的,但统计数据更加灵活。

马克·吐温

统计学是指我们理解数据的数学和技术。这是一个丰富而庞大的领域,更适合放在图书馆的书架(或房间)上,而不是书中的一章,因此我们的讨论必然不会很深入。相反,我会尽量教你足够的知识,让你有点危险,并激起你的兴趣,让你去学更多的东西。

描述单一数据集

通过口口相传和一点运气,DataSciencester 已经发展成了数十个成员,筹款副总裁要求你提供一些关于你的成员拥有多少朋友的描述,以便他可以在提出自己的想法时加入其中。

使用第一章中的技术,你很容易就能产生这些数据。但现在你面临着如何描述它的问题。

任何数据集的一个明显描述就是数据本身:

num_friends = [100, 49, 41, 40, 25,
               # ... and lots more
              ]

对于足够小的数据集,这甚至可能是最好的描述。但对于较大的数据集来说,这是笨重且可能晦涩的。(想象一下盯着一百万个数字的列表。)因此,我们使用统计数据来提炼和传达我们数据的相关特征。

作为第一步,你可以使用 Counterplt.bar 将朋友数量制作成直方图(参见图 5-1):

from collections import Counter
import matplotlib.pyplot as plt

friend_counts = Counter(num_friends)
xs = range(101)                         # largest value is 100
ys = [friend_counts[x] for x in xs]     # height is just # of friends
plt.bar(xs, ys)
plt.axis([0, 101, 0, 25])
plt.title("Histogram of Friend Counts")
plt.xlabel("# of friends")
plt.ylabel("# of people")
plt.show()

朋友数量。

图 5-1 朋友数量直方图

不幸的是,这张图表仍然太难以插入对话中。所以你开始生成一些统计数据。可能最简单的统计数据是数据点的数量:

num_points = len(num_friends)               # 204

你可能也对最大值和最小值感兴趣:

largest_value = max(num_friends)            # 100
smallest_value = min(num_friends)           # 1

这些只是想要知道特定位置的值的特例:

sorted_values = sorted(num_friends)
smallest_value = sorted_values[0]           # 1
second_smallest_value = sorted_values[1]    # 1
second_largest_value = sorted_values[-2]    # 49

但我们只是刚刚开始。

中心趋势

通常,我们会想知道我们的数据位于何处。最常见的是我们会使用均值(或平均值),它只是数据之和除以其计数:

def mean(xs: List[float]) -> float:
    return sum(xs) / len(xs)

mean(num_friends)   # 7.333333

如果你有两个数据点,平均值就是它们之间的中点。随着你增加更多的点,平均值会移动,但它总是依赖于每个点的值。例如,如果你有 10 个数据点,而你增加其中任何一个的值 1,你就会增加平均值 0.1。

我们有时也会对中位数感兴趣,它是中间最值(如果数据点数量为奇数)或两个中间最值的平均值(如果数据点数量为偶数)。

例如,如果我们在一个排序好的向量 x 中有五个数据点,中位数是 x[5 // 2]x[2]。如果我们有六个数据点,我们想要 x[2](第三个点)和 x[3](第四个点)的平均值。

注意——与平均值不同——中位数并不完全依赖于你的数据中的每个值。例如,如果你使最大值变大(或最小值变小),中间值保持不变,这意味着中位数也保持不变。

我们将为偶数和奇数情况编写不同的函数并将它们结合起来:

# The underscores indicate that these are "private" functions, as they're
# intended to be called by our median function but not by other people
# using our statistics library.
def _median_odd(xs: List[float]) -> float:
    """If len(xs) is odd, the median is the middle element"""
    return sorted(xs)[len(xs) // 2]

def _median_even(xs: List[float]) -> float:
    """If len(xs) is even, it's the average of the middle two elements"""
    sorted_xs = sorted(xs)
    hi_midpoint = len(xs) // 2  # e.g. length 4 => hi_midpoint 2
    return (sorted_xs[hi_midpoint - 1] + sorted_xs[hi_midpoint]) / 2

def median(v: List[float]) -> float:
    """Finds the 'middle-most' value of v"""
    return _median_even(v) if len(v) % 2 == 0 else _median_odd(v)

assert median([1, 10, 2, 9, 5]) == 5
assert median([1, 9, 2, 10]) == (2 + 9) / 2

现在我们可以计算朋友的中位数了:

print(median(num_friends))  # 6

显然,计算均值更简单,并且随着数据的变化而平稳变化。如果我们有n个数据点,其中一个增加了一小部分e,那么均值将增加e / n。(这使得均值适合各种微积分技巧。)然而,为了找到中位数,我们必须对数据进行排序。并且改变数据点中的一个小量e可能会使中位数增加e,少于e的某个数字,或者根本不增加(这取决于其他数据)。

注意

实际上,有一些不明显的技巧可以高效地计算中位数,而无需对数据进行排序。然而,这超出了本书的范围,所以我们必须对数据进行排序。

同时,均值对我们数据中的异常值非常敏感。如果我们最友好的用户有 200 个朋友(而不是 100 个),那么均值将上升到 7.82,而中位数将保持不变。如果异常值可能是不良数据(或者在其他方面不代表我们试图理解的现象),那么均值有时可能会给我们提供错误的图片。例如,通常会讲述这样一个故事:上世纪 80 年代中期,北卡罗来纳大学的起薪最高的专业是地理学,主要是因为 NBA 球星(及异常值)迈克尔·乔丹。

中位数的一种推广是分位数,它表示数据中位于某个百分位以下的值(中位数表示 50%的数据位于其以下):

def quantile(xs: List[float], p: float) -> float:
    """Returns the pth-percentile value in x"""
    p_index = int(p * len(xs))
    return sorted(xs)[p_index]

assert quantile(num_friends, 0.10) == 1
assert quantile(num_friends, 0.25) == 3
assert quantile(num_friends, 0.75) == 9
assert quantile(num_friends, 0.90) == 13

更少见的是,您可能希望查看模式,或者最常见的值:

def mode(x: List[float]) -> List[float]:
    """Returns a list, since there might be more than one mode"""
    counts = Counter(x)
    max_count = max(counts.values())
    return [x_i for x_i, count in counts.items()
            if count == max_count]

assert set(mode(num_friends)) == {1, 6}

但是大多数情况下,我们会使用平均值。

离散度

分散是指我们的数据分散程度的度量。通常,这些统计量的值接近零表示完全不分散,而大值(无论其含义如何)则表示非常分散。例如,一个非常简单的度量是范围,它只是最大和最小元素之间的差异:

# "range" already means something in Python, so we'll use a different name
def data_range(xs: List[float]) -> float:
    return max(xs) - min(xs)

assert data_range(num_friends) == 99

maxmin相等时,范围恰好为零,这只可能发生在x的元素全部相同的情况下,这意味着数据尽可能不分散。相反,如果范围很大,则max远远大于min,数据更加分散。

像中位数一样,范围实际上并不依赖于整个数据集。一个数据集,其数据点全部为 0 或 100,其范围与一个数据集相同,其中数值为 0、100 和大量 50。但是第一个数据集看起来“应该”更加分散。

分散度的更复杂的测量是方差,其计算如下:

from scratch.linear_algebra import sum_of_squares

def de_mean(xs: List[float]) -> List[float]:
    """Translate xs by subtracting its mean (so the result has mean 0)"""
    x_bar = mean(xs)
    return [x - x_bar for x in xs]

def variance(xs: List[float]) -> float:
    """Almost the average squared deviation from the mean"""
    assert len(xs) >= 2, "variance requires at least two elements"

    n = len(xs)
    deviations = de_mean(xs)
    return sum_of_squares(deviations) / (n - 1)

assert 81.54 < variance(num_friends) < 81.55
注意

这看起来几乎是平均平方偏差,不过我们除以的是n - 1而不是n。事实上,当我们处理来自更大总体的样本时,x_bar只是实际均值的估计值,这意味着平均而言(x_i - x_bar) ** 2x_i偏离均值的平方偏差的一个低估值,这就是为什么我们除以n - 1而不是n。参见Wikipedia

现在,无论我们的数据以什么单位表示(例如,“朋友”),我们所有的集中趋势度量都是以相同的单位表示的。范围也将以相同的单位表示。另一方面,方差的单位是原始单位的平方(例如,“朋友的平方”)。由于这些单位可能难以理解,我们通常转而查看标准差

import math

def standard_deviation(xs: List[float]) -> float:
    """The standard deviation is the square root of the variance"""
    return math.sqrt(variance(xs))

assert 9.02 < standard_deviation(num_friends) < 9.04

范围和标准差都有与我们之前对均值所看到的相同的异常值问题。以相同的例子,如果我们最友好的用户反而有 200 个朋友,标准差将为 14.89——高出 60%以上!

一种更加稳健的替代方法是计算第 75 百分位数值和第 25 百分位数值之间的差异:

def interquartile_range(xs: List[float]) -> float:
    """Returns the difference between the 75%-ile and the 25%-ile"""
    return quantile(xs, 0.75) - quantile(xs, 0.25)

assert interquartile_range(num_friends) == 6

这是一个显然不受少数异常值影响的数字。

相关性

DataSciencester 的增长副总裁有一个理论,即人们在网站上花费的时间与他们在网站上拥有的朋友数量有关(她不是无缘无故的副总裁),她请求您验证这一理论。

在分析了流量日志后,您得到了一个名为daily_minutes的列表,显示每个用户每天在 DataSciencester 上花费的分钟数,并且您已经按照前面的num_friends列表的顺序对其进行了排序。我们想要调查这两个指标之间的关系。

我们首先来看看协方差,它是方差的配对模拟。而方差衡量的是单个变量偏离其均值的程度,协方差衡量的是两个变量如何联动地偏离各自的均值:

from scratch.linear_algebra import dot

def covariance(xs: List[float], ys: List[float]) -> float:
    assert len(xs) == len(ys), "xs and ys must have same number of elements"

    return dot(de_mean(xs), de_mean(ys)) / (len(xs) - 1)

assert 22.42 < covariance(num_friends, daily_minutes) < 22.43
assert 22.42 / 60 < covariance(num_friends, daily_hours) < 22.43 / 60

请记住,dot求和了对应元素对的乘积。当xy的对应元素都高于它们的均值或者都低于它们的均值时,一个正数进入总和。当一个高于其均值而另一个低于其均值时,一个负数进入总和。因此,“大”正协方差意味着xy大时也大,在y小时也小。 “大”负协方差意味着相反的情况,即xy大时较小,在y小时较大。接近零的协方差意味着没有这种关系存在。

尽管如此,由于一些原因,这个数字可能难以解释:

  • 其单位是输入单位的乘积(例如,每天的朋友分钟),这可能难以理解。(“每天的朋友分钟”是什么意思?)

  • 如果每个用户的朋友数量增加一倍(但花费的时间相同),协方差将增加一倍。但在某种意义上,变量之间的关联性将是一样的。换句话说,很难说什么才算是“大”协方差。

因此,更常见的是看相关性,它将两个变量的标准差除出:

def correlation(xs: List[float], ys: List[float]) -> float:
    """Measures how much xs and ys vary in tandem about their means"""
    stdev_x = standard_deviation(xs)
    stdev_y = standard_deviation(ys)
    if stdev_x > 0 and stdev_y > 0:
        return covariance(xs, ys) / stdev_x / stdev_y
    else:
        return 0    # if no variation, correlation is zero

assert 0.24 < correlation(num_friends, daily_minutes) < 0.25
assert 0.24 < correlation(num_friends, daily_hours) < 0.25

相关性是无单位的,并且总是介于–1(完全反相关)和 1(完全相关)之间。像 0.25 这样的数字代表相对较弱的正相关。

然而,我们忽略的一件事是检查我们的数据。查看图 5-2。

相关性离群值。

图 5-2. 含有离群值的相关性

拥有 100 个朋友(每天只在网站上花 1 分钟)的人是一个巨大的离群值,而相关性对离群值非常敏感。如果我们忽略他会发生什么?

outlier = num_friends.index(100)    # index of outlier

num_friends_good = [x
                    for i, x in enumerate(num_friends)
                    if i != outlier]

daily_minutes_good = [x
                      for i, x in enumerate(daily_minutes)
                      if i != outlier]

daily_hours_good = [dm / 60 for dm in daily_minutes_good]

assert 0.57 < correlation(num_friends_good, daily_minutes_good) < 0.58
assert 0.57 < correlation(num_friends_good, daily_hours_good) < 0.58

如果没有这个离群值,相关性会更加显著(图 5-3)。

相关性无离群值。

图 5-3. 移除离群值后的相关性

你进一步调查并发现,这个离群值实际上是一个从未被删除的内部测试账户。因此,你认为排除它是合理的。

Simpson 悖论

在分析数据时,一个不常见的惊喜是Simpson 悖论,其中当忽略混淆变量时,相关性可能会误导。

例如,假设你可以将所有成员标识为东海岸数据科学家或西海岸数据科学家。你决定检查哪个海岸的数据科学家更友好:

海岸 成员数 平均朋友数
西海岸 101 8.2
东海岸 103 6.5

确实看起来西海岸的数据科学家比东海岸的数据科学家更友好。你的同事们对此提出了各种理论:也许是阳光、咖啡、有机农产品或者太平洋的悠闲氛围?

但在处理数据时,你发现了一些非常奇怪的事情。如果你只看有博士学位的人,东海岸的数据科学家平均有更多的朋友。如果你只看没有博士学位的人,东海岸的数据科学家平均也有更多的朋友!

海岸 学位 成员数 平均朋友数
西海岸 博士学位 35 3.1
东海岸 博士学位 70 3.2
西海岸 无博士学位 66 10.9
东海岸 无博士学位 33 13.4

一旦考虑到用户的学位,相关性的方向就会相反!将数据分桶为东海岸/西海岸掩盖了东海岸数据科学家更倾向于博士类型的事实。

这种现象在现实世界中经常发生。关键问题在于,相关性是在“其他一切相等”的情况下衡量你两个变量之间的关系。如果你的数据分类是随机分配的,如在一个设计良好的实验中可能发生的那样,“其他一切相等”可能不是一个糟糕的假设。但是,当类别分配有更深层次的模式时,“其他一切相等”可能是一个糟糕的假设。

避免这种情况的唯一真正方法是了解你的数据,并尽力确保你已经检查了可能的混淆因素。显然,这并不总是可能的。如果你没有这 200 名数据科学家的教育背景数据,你可能会简单地得出结论,西海岸有一些与社交性相关的因素。

其他一些相关性警告

相关系数为零表明两个变量之间没有线性关系。然而,可能存在其他类型的关系。例如,如果:

x = [-2, -1, 0, 1, 2]
y = [ 2,  1, 0, 1, 2]

那么xy的相关性为零。但它们肯定有一种关系——y的每个元素等于x对应元素的绝对值。它们没有的是一种关系,即知道x_imean(x)的比较信息无法提供有关y_imean(y)的信息。这是相关性寻找的关系类型。

此外,相关性并不能告诉你关系的大小。以下变量:

x = [-2, -1, 0, 1, 2]
y = [99.98, 99.99, 100, 100.01, 100.02]

完全相关,但(根据你测量的内容)很可能这种关系并不那么有趣。

相关性与因果关系

你可能曾经听说过“相关不等于因果”,很可能是从某人那里听来的,他看到的数据挑战了他不愿质疑的世界观的某些部分。尽管如此,这是一个重要的观点——如果xy强相关,这可能意味着x导致yy导致x,彼此相互导致,第三个因素导致两者,或者根本不导致。

考虑num_friendsdaily_minutes之间的关系。可能在这个网站上拥有更多的朋友导致DataSciencester 用户花更多时间在网站上。如果每个朋友每天发布一定数量的内容,这可能是情况,这意味着你拥有的朋友越多,就需要花费更多时间来了解他们的更新。

但是,在 DataSciencester 论坛上花费更多时间可能会导致用户遇到和结识志同道合的人。也就是说,花费更多时间在这个网站上导致用户拥有更多的朋友。

第三种可能性是,对数据科学最热衷的用户在网站上花费更多时间(因为他们觉得更有趣),并且更积极地收集数据科学朋友(因为他们不想与其他人交往)。

一种增强因果关系信心的方法是进行随机试验。如果你能随机将用户分为两组,这两组具有相似的人口统计学特征,并给其中一组提供稍有不同的体验,那么你通常可以相当确信不同的体验导致了不同的结果。

例如,如果你不介意被指责https://www.nytimes.com/2014/06/30/technology/facebook-tinkers-with-users-emotions-in-news-feed-experiment-stirring-outcry.html?r=0[对用户进行实验],你可以随机选择你的一部分用户,并仅向他们展示部分朋友的内容。如果这部分用户随后在网站上花费的时间较少,那么这将使你相信拥有更多朋友 _ 会导致更多的时间花费在该网站上。

进一步探索

  • SciPypandas,和StatsModels都提供各种各样的统计函数。

  • 统计学是重要的。(或许统计重要的?)如果你想成为更好的数据科学家,阅读统计学教材是个好主意。许多在线免费教材可供选择,包括:

第六章:概率

概率定律,在一般情况下正确,在特定情况下谬误。

爱德华·吉本

没有对概率及其数学的某种理解,数据科学是很难做的。就像我们在第五章中处理统计学一样,我们将在很多地方简化和省略技术细节。

对于我们的目的,你应该把概率看作是对从某个事件宇宙中选择的事件的不确定性进行量化的一种方式。与其深究这些术语的技术含义,不如想象掷骰子。事件宇宙包含所有可能的结果。而这些结果的任何子集就是一个事件;例如,“骰子掷出 1”或“骰子掷出偶数”。

在符号上,我们写 P(E) 来表示“事件 E 发生的概率”。

我们将使用概率理论来建立模型。我们将使用概率理论来评估模型。我们将在很多地方使用概率理论。

如果愿意,可以深入探讨概率论的哲学含义。(最好在喝啤酒时进行。)我们不会做这件事。

依赖性与独立性

简而言之,如果知道 E 发生与否能够提供 F 发生与否的信息(反之亦然),我们称事件 EF依赖的。否则,它们是独立的。

例如,如果我们抛一枚公平的硬币两次,知道第一次抛硬币是正面并不能提供关于第二次抛硬币是否正面的信息。这两个事件是独立的。另一方面,知道第一次抛硬币是正面肯定会影响到第二次抛硬币是否都是反面。(如果第一次抛硬币是正面,那么肯定不会是两次抛硬币都是反面。)这两个事件是依赖的。

数学上,我们说事件 EF 是独立的,如果它们同时发生的概率等于它们各自发生的概率的乘积:

P ( E , F ) = P ( E ) P ( F )

在例子中,“第一次抛硬币为正面”的概率是 1/2,“两次抛硬币都为反面”的概率是 1/4,但“第一次抛硬币为正面两次抛硬币都为反面”的概率是 0。

条件概率

当两个事件 EF 是独立的时候,根据定义我们有:

P ( E , F ) = P ( E ) P ( F )

如果它们不一定是独立的(如果 F 的概率不为零),那么我们定义 E 在给定 F 的条件下的概率为:

P ( E | F ) = P ( E , F ) / P ( F )

你应该把这看作是在我们知道 F 发生的情况下,E 发生的概率。

我们经常将其重写为:

P ( E , F ) = P ( E | F ) P ( F )

EF 是独立的时候,你可以检查这是否成立:

P ( E | F ) = P ( E )

这是数学上表达,即知道 F 发生了并不能给我们关于 E 是否发生额外的信息。

一个常见的棘手例子涉及一对(未知的)孩子的家庭。如果我们假设:

  • 每个孩子都同等可能是男孩或女孩。

  • 第二个孩子的性别与第一个孩子的性别是独立的。

那么事件“没有女孩”的概率为 1/4,事件“一个女孩一个男孩”的概率为 1/2,事件“两个女孩”的概率为 1/4。

现在我们可以问事件“两个孩子都是女孩”(B)在事件“老大是女孩”(G)条件下的概率是多少?使用条件概率的定义:

P ( B | G ) = P ( B , G ) / P ( G ) = P ( B ) / P ( G ) = 1 / 2

因为事件BG(“两个孩子都是女孩老大是女孩”)就是事件B。(一旦知道两个孩子都是女孩,老大是女孩就是必然的。)

大多数情况下,这个结果符合您的直觉。

我们还可以询问事件“两个孩子都是女孩”在事件“至少一个孩子是女孩”(L)条件下的概率。令人惊讶的是,答案与之前不同!

与之前一样,事件BL(“两个孩子都是女孩至少一个孩子是女孩”)就是事件B。这意味着我们有:

P ( B | L ) = P ( B , L ) / P ( L ) = P ( B ) / P ( L ) = 1 / 3

这是怎么回事?嗯,如果您所知道的是至少有一个孩子是女孩,那么这个家庭有一个男孩和一个女孩的可能性是有两倍多于两个女孩的可能性的。

我们可以通过“生成”许多家庭来验证这一点:

import enum, random

# An Enum is a typed set of enumerated values. We can use them
# to make our code more descriptive and readable.
class Kid(enum.Enum):
    BOY = 0
    GIRL = 1

def random_kid() -> Kid:
    return random.choice([Kid.BOY, Kid.GIRL])

both_girls = 0
older_girl = 0
either_girl = 0

random.seed(0)

for _ in range(10000):
    younger = random_kid()
    older = random_kid()
    if older == Kid.GIRL:
        older_girl += 1
    if older == Kid.GIRL and younger == Kid.GIRL:
        both_girls += 1
    if older == Kid.GIRL or younger == Kid.GIRL:
        either_girl += 1

print("P(both | older):", both_girls / older_girl)     # 0.514 ~ 1/2
print("P(both | either): ", both_girls / either_girl)  # 0.342 ~ 1/3

贝叶斯定理

数据科学家的最佳朋友之一是贝叶斯定理,这是一种“反转”条件概率的方法。假设我们需要知道某事件E在另一事件F发生条件下的概率。但我们只有关于E发生条件下F的概率信息。使用条件概率的定义两次告诉我们:

P ( E | F ) = P ( E , F ) / P ( F ) = P ( F | E ) P ( E ) / P ( F )

事件F可以分为两个互斥事件:“FE”以及“F和非E”。如果我们用¬ E表示“非E”(即“E不发生”),那么:

P ( F ) = P ( F , E ) + P ( F , ¬ E )

所以:

P ( E | F ) = P ( F | E ) P ( E ) / [ P ( F | E ) P ( E ) + P ( F | ¬ E ) P ( ¬ E ) ]

这通常是贝叶斯定理的陈述方式。

这个定理经常被用来展示为什么数据科学家比医生更聪明。想象一种影响每 10,000 人中 1 人的特定疾病。再想象一种测试这种疾病的方法,它在 99%的情况下给出正确结果(如果您有疾病则为“患病”,如果您没有则为“未患病”)。

正测试意味着什么?让我们用T表示“您的测试呈阳性”事件,D表示“您患有疾病”事件。那么贝叶斯定理表明,在测试呈阳性的条件下,您患有疾病的概率是:

P ( D | T ) = P ( T | D ) P ( D ) / [ P ( T | D ) P ( D ) + P ( T | ¬ D ) P ( ¬ D ) ]

在这里我们知道,P ( T | D ),即染病者测试阳性的概率,为 0.99。P(D),即任何给定人患病的概率,为 1/10,000 = 0.0001。 P ( T | ¬ D ),即没有患病者测试阳性的概率,为 0.01。而 P ( ¬ D ),即任何给定人没有患病的概率,为 0.9999。如果你把这些数字代入贝叶斯定理,你会发现:

P ( D | T ) = 0 . 98 %

也就是说,只有不到 1%的阳性测试者实际上患有这种疾病。

这假设人们更多或更少是随机参加测试的。如果只有具有某些症状的人参加测试,我们将需要在“阳性测试 症状”事件上进行条件判断,而阳性测试的人数可能会高得多。

更直观地看待这个问题的方法是想象一种 100 万人口的人群。你会预期其中有 100 人患有这种疾病,其中有 99 人测试结果呈阳性。另一方面,你会预期这中间有 999,900 人没有患有这种疾病,其中有 9,999 人测试结果呈阳性。这意味着你只会预期(99 + 9999)个阳性测试者中有 99 人实际上患有这种疾病。

随机变量

随机变量是具有相关概率分布的可能值的变量。一个非常简单的随机变量,如果抛硬币正面朝上则值为 1,如果反面朝上则值为 0。一个更复杂的随机变量可能会测量你在抛硬币 10 次时观察到的头像数量,或者从range(10)中选取的值,其中每个数字都是同样可能的。

相关的分布给出了变量实现其可能值的概率。抛硬币变量等于 0 的概率为 0.5,等于 1 的概率为 0.5。range(10)变量具有分布,将 0 到 9 中的每个数字分配概率 0.1。

有时我们会谈论一个随机变量的期望值,这是其值按其概率加权的平均值。抛硬币变量的期望值为 1/2(= 0 * 1/2 + 1 * 1/2),而range(10)变量的期望值为 4.5。

随机变量可以像其他事件一样被条件化。回到“条件概率”中的两个孩子示例,如果X是表示女孩数量的随机变量,那么X等于 0 的概率为 1/4,等于 1 的概率为 1/2,等于 2 的概率为 1/4。

我们可以定义一个新的随机变量Y,条件是至少有一个孩子是女孩。然后Y以 2/3 的概率等于 1,以 1/3 的概率等于 2。还有一个变量Z,条件是较大的孩子是女孩,则以 1/2 的概率等于 1,以 1/2 的概率等于 2。

大多数情况下,在我们进行的操作中,我们将隐含地使用随机变量,而不特别关注它们。但是如果你深入研究,你会发现它们。

连续分布

抛硬币对应于离散分布——它将离散结果与正概率关联起来。通常我们希望模拟跨越连续结果的分布。(对我们而言,这些结果将始终是实数,尽管在现实生活中并非总是如此。)例如,均匀分布将在 0 到 1 之间所有数字上赋予相等的权重

因为在 0 和 1 之间有无穷多个数,这意味着它分配给单个点的权重必然为零。因此,我们用概率密度函数(PDF)表示连续分布,使得在某个区间内看到一个值的概率等于该区间上密度函数的积分。

注意

如果你的积分微积分有点生疏,一个更简单的理解方式是,如果一个分布有密度函数f,那么在xx + h之间看到一个值的概率大约是h * f(x),如果h很小的话。

均匀分布的密度函数就是:

def uniform_pdf(x: float) -> float:
    return 1 if 0 <= x < 1 else 0

按照该分布,随机变量落在 0.2 到 0.3 之间的概率是 1/10,正如你所期望的那样。Python 的random.random是一个均匀密度的(伪)随机变量。

我们通常更感兴趣的是累积分布函数(CDF),它给出随机变量小于或等于某个值的概率。对于均匀分布,创建 CDF 并不困难(见图 6-1):

def uniform_cdf(x: float) -> float:
    """Returns the probability that a uniform random variable is <= x"""
    if x < 0:   return 0    # uniform random is never less than 0
    elif x < 1: return x    # e.g. P(X <= 0.4) = 0.4
    else:       return 1    # uniform random is always less than 1

均匀分布的 CDF。

图 6-1. 均匀分布的累积分布函数

正态分布

正态分布是经典的钟形曲线分布,完全由两个参数确定:其均值μ(mu)和标准差σ(sigma)。均值表示钟形曲线的中心位置,标准差表示其“宽度”。

它的 PDF 是:

f ( x | μ , σ ) = 1 2πσ exp ( - (x-μ) 2 2σ 2 )

我们可以这样实现它:

import math
SQRT_TWO_PI = math.sqrt(2 * math.pi)

def normal_pdf(x: float, mu: float = 0, sigma: float = 1) -> float:
    return (math.exp(-(x-mu) ** 2 / 2 / sigma ** 2) / (SQRT_TWO_PI * sigma))

在图 6-2 中,我们绘制了一些这些 PDF,看看它们的样子:

import matplotlib.pyplot as plt
xs = [x / 10.0 for x in range(-50, 50)]
plt.plot(xs,[normal_pdf(x,sigma=1) for x in xs],'-',label='mu=0,sigma=1')
plt.plot(xs,[normal_pdf(x,sigma=2) for x in xs],'--',label='mu=0,sigma=2')
plt.plot(xs,[normal_pdf(x,sigma=0.5) for x in xs],':',label='mu=0,sigma=0.5')
plt.plot(xs,[normal_pdf(x,mu=-1)   for x in xs],'-.',label='mu=-1,sigma=1')
plt.legend()
plt.title("Various Normal pdfs")
plt.show()

各种正态分布的 PDF。

图 6-2. 各种正态分布的 PDF

μ = 0 且σ = 1 时,称为标准正态分布。如果Z是一个标准正态随机变量,则有:

X = σ Z + μ

也服从正态分布,但其均值为μ,标准差为σ 。反之,如果X是均值为μ,标准差为σ的正态随机变量,

Z = ( X - μ ) / σ

是一个标准正态变量。

正态分布的累积分布函数不能用“基本”的方式写出,但我们可以使用 Python 的math.erf 误差函数来表示它:

def normal_cdf(x: float, mu: float = 0, sigma: float = 1) -> float:
    return (1 + math.erf((x - mu) / math.sqrt(2) / sigma)) / 2

再次,在图 6-3 中,我们绘制了几个累积分布函数(CDF):

xs = [x / 10.0 for x in range(-50, 50)]
plt.plot(xs,[normal_cdf(x,sigma=1) for x in xs],'-',label='mu=0,sigma=1')
plt.plot(xs,[normal_cdf(x,sigma=2) for x in xs],'--',label='mu=0,sigma=2')
plt.plot(xs,[normal_cdf(x,sigma=0.5) for x in xs],':',label='mu=0,sigma=0.5')
plt.plot(xs,[normal_cdf(x,mu=-1) for x in xs],'-.',label='mu=-1,sigma=1')
plt.legend(loc=4) # bottom right
plt.title("Various Normal cdfs")
plt.show()

各种正态分布的 CDF。

图 6-3. 各种正态分布的累积分布函数

有时我们需要求逆normal_cdf以找到对应于指定概率的值。虽然没有简单的方法来计算其逆,但normal_cdf是连续且严格递增的,因此我们可以使用二分查找

def inverse_normal_cdf(p: float,
                       mu: float = 0,
                       sigma: float = 1,
                       tolerance: float = 0.00001) -> float:
    """Find approximate inverse using binary search"""

    # if not standard, compute standard and rescale
    if mu != 0 or sigma != 1:
        return mu + sigma * inverse_normal_cdf(p, tolerance=tolerance)

    low_z = -10.0                      # normal_cdf(-10) is (very close to) 0
    hi_z  =  10.0                      # normal_cdf(10)  is (very close to) 1
    while hi_z - low_z > tolerance:
        mid_z = (low_z + hi_z) / 2     # Consider the midpoint
        mid_p = normal_cdf(mid_z)      # and the CDF's value there
        if mid_p < p:
            low_z = mid_z              # Midpoint too low, search above it
        else:
            hi_z = mid_z               # Midpoint too high, search below it

    return mid_z

该函数重复地将区间二分,直到找到接近所需概率的Z

中心极限定理

正态分布如此有用的一个原因是中心极限定理,它(本质上)表明大量独立同分布的随机变量的平均值本身近似服从正态分布。

特别是,如果x 1 , ... , x n是均值为μ,标准差为σ的随机变量,并且n很大,则

1 n ( x 1 + ... + x n )

大致上符合正态分布,均值为μ,标准差为σ / n 。同样(但通常更有用),

(x 1 +...+x n )-μn σn

大致上符合均值为 0,标准差为 1 的正态分布。

一个易于说明这一点的方法是看二项式随机变量,它具有两个参数np。一个二项式(n,p)随机变量简单地是n个独立的伯努利(p)随机变量的和,其中每个随机变量以概率p等于 1,以概率 1 - p等于 0:

def bernoulli_trial(p: float) -> int:
    """Returns 1 with probability p and 0 with probability 1-p"""
    return 1 if random.random() < p else 0

def binomial(n: int, p: float) -> int:
    """Returns the sum of n bernoulli(p) trials"""
    return sum(bernoulli_trial(p) for _ in range(n))

伯努利变量(Bernoulli(p))的均值是 p,标准差是 p ( 1 - p ) 。 中心极限定理表明,当 n 变大时,二项分布(Binomial(n,p))变量近似于均值 μ = n p 和标准差 σ = n p ( 1 - p ) 的正态随机变量。 如果我们同时绘制它们,你可以很容易地看到它们的相似之处:

from collections import Counter

def binomial_histogram(p: float, n: int, num_points: int) -> None:
    """Picks points from a Binomial(n, p) and plots their histogram"""
    data = [binomial(n, p) for _ in range(num_points)]

    # use a bar chart to show the actual binomial samples
    histogram = Counter(data)
    plt.bar([x - 0.4 for x in histogram.keys()],
            [v / num_points for v in histogram.values()],
            0.8,
            color='0.75')

    mu = p * n
    sigma = math.sqrt(n * p * (1 - p))

    # use a line chart to show the normal approximation
    xs = range(min(data), max(data) + 1)
    ys = [normal_cdf(i + 0.5, mu, sigma) - normal_cdf(i - 0.5, mu, sigma)
          for i in xs]
    plt.plot(xs,ys)
    plt.title("Binomial Distribution vs. Normal Approximation")
    plt.show()

例如,当你调用 make_hist(0.75, 100, 10000) 时,你会得到图 6-4 中的图表。

调用 binomial_histogram 的结果。

图 6-4. 调用 binomial_histogram 的输出

这个近似的道理是,如果你想知道(比如说)在 100 次投掷中,一个公平硬币出现超过 60 次正面的概率,你可以估计为一个正态分布(Normal(50,5))大于 60 的概率,这比计算二项分布(Binomial(100,0.5))的累积分布函数要简单。 (尽管在大多数应用中,你可能会使用愿意计算任何你想要的概率的统计软件。)

进一步探索

  • scipy.stats 包含大多数流行概率分布的概率密度函数(PDF)和累积分布函数(CDF)。

  • 还记得在第 5 章的结尾处我说过,学习一本统计学教材是个好主意吗?学习一本概率论教材也是个好主意。我知道的最好的在线教材是 Introduction to Probability,由 Charles M. Grinstead 和 J. Laurie Snell(美国数学学会)编写。

第七章:假设与推断

被统计数据感动是真正聪明的人的标志。

乔治·伯纳德·肖

我们会用所有这些统计数据和概率理论做什么呢?数据科学的科学部分经常涉及形成和测试关于数据及其生成过程的假设

统计假设检验

作为数据科学家,我们经常想测试某个假设是否可能成立。对于我们来说,假设是一些断言,比如“这枚硬币是公平的”或“数据科学家更喜欢 Python 而不是 R”或“人们更有可能在看不见关闭按钮的烦人插页广告出现后,不阅读内容直接离开页面”。这些可以转化为关于数据的统计信息。在各种假设下,这些统计信息可以被视为来自已知分布的随机变量的观察结果,这使我们能够对这些假设的可能性进行陈述。

在经典设置中,我们有一个零假设H 0 ,代表某些默认位置,以及我们想要与之比较的一些备选假设,H 1 。我们使用统计数据来决定我们是否可以拒绝H 0 作为虚假的。这可能通过一个例子更容易理解。

示例:抛硬币

想象我们有一枚硬币,我们想测试它是否公平。我们假设硬币有一定概率 p 出现正面,因此我们的零假设是硬币是公平的——也就是说,p = 0.5。我们将这个假设与备选假设 p ≠ 0.5 进行测试。

特别是,我们的测试将涉及抛硬币 n 次,并计算正面出现的次数 X。每次抛硬币都是伯努利试验,这意味着 X 是一个二项分布变量,我们可以用正态分布(如我们在 第六章 中看到的)来近似它:

from typing import Tuple
import math

def normal_approximation_to_binomial(n: int, p: float) -> Tuple[float, float]:
    """Returns mu and sigma corresponding to a Binomial(n, p)"""
    mu = p * n
    sigma = math.sqrt(p * (1 - p) * n)
    return mu, sigma

每当一个随机变量遊服从正态分布时,我们可以使用 normal_cdf 来确定其实现值在特定区间内或外的概率:

from scratch.probability import normal_cdf

# The normal cdf _is_ the probability the variable is below a threshold
normal_probability_below = normal_cdf

# It's above the threshold if it's not below the threshold
def normal_probability_above(lo: float,
                             mu: float = 0,
                             sigma: float = 1) -> float:
    """The probability that an N(mu, sigma) is greater than lo."""
    return 1 - normal_cdf(lo, mu, sigma)

# It's between if it's less than hi, but not less than lo
def normal_probability_between(lo: float,
                               hi: float,
                               mu: float = 0,
                               sigma: float = 1) -> float:
    """The probability that an N(mu, sigma) is between lo and hi."""
    return normal_cdf(hi, mu, sigma) - normal_cdf(lo, mu, sigma)

# It's outside if it's not between
def normal_probability_outside(lo: float,
                               hi: float,
                               mu: float = 0,
                               sigma: float = 1) -> float:
    """The probability that an N(mu, sigma) is not between lo and hi."""
    return 1 - normal_probability_between(lo, hi, mu, sigma)

我们也可以反过来——找到非尾部区域或(对称的)包含一定概率水平的平均值的区间。例如,如果我们想找到一个以均值为中心且包含 60% 概率的区间,那么我们找到上下尾部各包含 20% 概率的截止点(留下 60%):

from scratch.probability import inverse_normal_cdf

def normal_upper_bound(probability: float,
                       mu: float = 0,
                       sigma: float = 1) -> float:
    """Returns the z for which P(Z <= z) = probability"""
    return inverse_normal_cdf(probability, mu, sigma)

def normal_lower_bound(probability: float,
                       mu: float = 0,
                       sigma: float = 1) -> float:
    """Returns the z for which P(Z >= z) = probability"""
    return inverse_normal_cdf(1 - probability, mu, sigma)

def normal_two_sided_bounds(probability: float,
                            mu: float = 0,
                            sigma: float = 1) -> Tuple[float, float]:
    """
 Returns the symmetric (about the mean) bounds
 that contain the specified probability
 """
    tail_probability = (1 - probability) / 2

    # upper bound should have tail_probability above it
    upper_bound = normal_lower_bound(tail_probability, mu, sigma)

    # lower bound should have tail_probability below it
    lower_bound = normal_upper_bound(tail_probability, mu, sigma)

    return lower_bound, upper_bound

特别是,假设我们选择抛硬币 n = 1,000 次。如果我们的公平假设成立,X 应该近似正态分布,均值为 500,标准差为 15.8:

mu_0, sigma_0 = normal_approximation_to_binomial(1000, 0.5)

我们需要就显著性做出决策——我们愿意做第一类错误的程度,即拒绝H 0即使它是真的。由于历史记载已经遗失,这种愿意通常设定为 5%或 1%。让我们选择 5%。

考虑到如果X落在以下界限之外则拒绝H 0的测试:

# (469, 531)
lower_bound, upper_bound = normal_two_sided_bounds(0.95, mu_0, sigma_0)

假设p真的等于 0.5(即H 0为真),我们只有 5%的机会观察到一个落在这个区间之外的X,这正是我们想要的显著性水平。换句话说,如果H 0为真,那么大约有 20 次中有 19 次这个测试会给出正确的结果。

我们还经常关注测试的功效,即不犯第二类错误(“假阴性”)的概率,即我们未能拒绝H 0,尽管它是错误的。为了衡量这一点,我们必须明确H 0假设为假的含义。 (仅知道p不等于 0.5 并不能给我们关于X分布的大量信息。)特别是,让我们检查p真的是 0.55 的情况,这样硬币稍微偏向正面。

在这种情况下,我们可以计算测试的功效:

# 95% bounds based on assumption p is 0.5
lo, hi = normal_two_sided_bounds(0.95, mu_0, sigma_0)

# actual mu and sigma based on p = 0.55
mu_1, sigma_1 = normal_approximation_to_binomial(1000, 0.55)

# a type 2 error means we fail to reject the null hypothesis,
# which will happen when X is still in our original interval
type_2_probability = normal_probability_between(lo, hi, mu_1, sigma_1)
power = 1 - type_2_probability      # 0.887

假设相反,我们的零假设是硬币不偏向正面,或者p 0 . 5。在这种情况下,我们需要一个单边测试,当X远大于 500 时拒绝零假设,但当X小于 500 时不拒绝。因此,一个 5%显著性测试涉及使用normal_probability_below找到概率分布中 95%以下的截止值:

hi = normal_upper_bound(0.95, mu_0, sigma_0)
# is 526 (< 531, since we need more probability in the upper tail)

type_2_probability = normal_probability_below(hi, mu_1, sigma_1)
power = 1 - type_2_probability      # 0.936

这是一个更强大的测试,因为当X低于 469(如果H 1为真的话,这几乎不太可能发生)时,它不再拒绝H 0,而是在X在 526 到 531 之间时拒绝H 0(如果H 1为真的话,这有一定可能发生)。

p-值

对前述测试的另一种思考方式涉及p 值。我们不再基于某个概率截断选择边界,而是计算概率——假设H 0为真——我们会看到一个至少与我们实际观察到的值一样极端的值的概率。

对于我们的双边测试,检验硬币是否公平,我们计算:

def two_sided_p_value(x: float, mu: float = 0, sigma: float = 1) -> float:
    """
 How likely are we to see a value at least as extreme as x (in either
 direction) if our values are from an N(mu, sigma)?
 """
    if x >= mu:
        # x is greater than the mean, so the tail is everything greater than x
        return 2 * normal_probability_above(x, mu, sigma)
    else:
        # x is less than the mean, so the tail is everything less than x
        return 2 * normal_probability_below(x, mu, sigma)

如果我们看到 530 次正面朝上,我们会计算:

two_sided_p_value(529.5, mu_0, sigma_0)   # 0.062
注意

为什么我们使用529.5而不是530?这就是所谓的连续性校正。这反映了normal_probability_between(529.5, 530.5, mu_0, sigma_0)normal_probability_between(530, 531, mu_0, sigma_0)更好地估计看到 530 枚硬币正面的概率。

相应地,normal_probability_above(529.5, mu_0, sigma_0)更好地估计了至少看到 530 枚硬币正面的概率。你可能已经注意到,我们在生成图 6-4 的代码中也使用了这个。

说服自己这是一个明智的估计的一种方法是通过模拟:

import random

extreme_value_count = 0
for _ in range(1000):
    num_heads = sum(1 if random.random() < 0.5 else 0    # Count # of heads
                    for _ in range(1000))                # in 1000 flips,
    if num_heads >= 530 or num_heads <= 470:             # and count how often
        extreme_value_count += 1                         # the # is 'extreme'

# p-value was 0.062 => ~62 extreme values out of 1000
assert 59 < extreme_value_count < 65, f"{extreme_value_count}"

由于p-值大于我们的 5%显著性水平,我们不拒绝零假设。如果我们看到 532 枚硬币正面,p-值将是:

two_sided_p_value(531.5, mu_0, sigma_0)   # 0.0463

这比 5%的显著性水平还要小,这意味着我们将拒绝零假设。这和之前完全一样的检验,只是统计学上的不同方法而已。

同样,我们会得到:

upper_p_value = normal_probability_above
lower_p_value = normal_probability_below

对于我们的单边检验,如果我们看到 525 枚硬币正面,我们会计算:

upper_p_value(524.5, mu_0, sigma_0) # 0.061

这意味着我们不会拒绝零假设。如果我们看到 527 枚硬币正面,计算将是:

upper_p_value(526.5, mu_0, sigma_0) # 0.047

我们将拒绝零假设。

警告

在使用normal_probability_above计算p-值之前,请确保你的数据大致服从正态分布。糟糕的数据科学充满了人们声称某些观察到的事件在随机发生的机会是百万分之一的例子,当他们真正的意思是“机会,假设数据是正态分布的”,如果数据不是的话,这是相当毫无意义的。

有各种检验正态性的统计方法,但即使是绘制数据也是一个很好的开始。

置信区间

我们一直在测试关于硬币正面概率p值的假设,这是未知“正面”分布的一个参数。在这种情况下,第三种方法是围绕参数的观察值构建一个置信区间

例如,我们可以通过观察与每次翻转相对应的伯努利变量的平均值来估计不公平硬币的概率—如果是正面则为 1,如果是反面则为 0。如果我们在 1,000 次翻转中观察到 525 枚硬币正面,则我们估计p等于 0.525。

我们对这个估计有多么有信心?嗯,如果我们知道p的确切值,那么中心极限定理(回顾“中心极限定理”)告诉我们,这些伯努利变量的平均值应该近似正态分布,均值为p,标准差为:

math.sqrt(p * (1 - p) / 1000)

在这里我们不知道p的值,所以我们使用我们的估计值:

p_hat = 525 / 1000
mu = p_hat
sigma = math.sqrt(p_hat * (1 - p_hat) / 1000)   # 0.0158

这并不完全合理,但人们似乎仍然这样做。使用正态近似,我们得出“95%的置信度”下以下区间包含真实参数p的结论:

normal_two_sided_bounds(0.95, mu, sigma)        # [0.4940, 0.5560]
注意

这是关于区间而不是关于p的说法。你应该理解它是这样的断言:如果你重复进行实验很多次,那么 95%的时间,“真实”的参数(每次都相同)会落在观察到的置信区间内(每次可能不同)。

特别是,我们不会得出硬币不公平的结论,因为 0.5 位于我们的置信区间内。

如果我们看到了 540 次正面,那么我们将会:

p_hat = 540 / 1000
mu = p_hat
sigma = math.sqrt(p_hat * (1 - p_hat) / 1000) # 0.0158
normal_two_sided_bounds(0.95, mu, sigma) # [0.5091, 0.5709]

在这里,“公平硬币”的置信区间中不存在。(如果真的是公平硬币假设,它不会通过一个测试。)

p-操纵

一个过程,只有 5%的时间错误地拒绝原假设,按定义来说:

from typing import List

def run_experiment() -> List[bool]:
    """Flips a fair coin 1000 times, True = heads, False = tails"""
    return [random.random() < 0.5 for _ in range(1000)]

def reject_fairness(experiment: List[bool]) -> bool:
    """Using the 5% significance levels"""
    num_heads = len([flip for flip in experiment if flip])
    return num_heads < 469 or num_heads > 531

random.seed(0)
experiments = [run_experiment() for _ in range(1000)]
num_rejections = len([experiment
                      for experiment in experiments
                      if reject_fairness(experiment)])

assert num_rejections == 46

这意味着,如果你试图找到“显著”结果,通常你可以找到。对数据集测试足够多的假设,几乎肯定会出现一个显著结果。移除正确的异常值,你可能会将p-值降低到 0.05 以下。(我们在“相关性”中做了类似的事情;你注意到了吗?)

这有时被称为p-操纵,在某种程度上是“从p-值框架推断”的结果。一篇批评这种方法的好文章是“地球是圆的”,作者是雅各布·科恩。

如果你想进行良好的科学研究,你应该在查看数据之前确定你的假设,清理数据时不要考虑假设,并且要记住p-值并不能替代常识。(另一种方法在“贝叶斯推断”中讨论。)

例如:运行 A/B 测试

你在 DataSciencester 的主要职责之一是体验优化,这是一个试图让人们点击广告的委婉说法。你的一个广告客户开发了一种新的面向数据科学家的能量饮料,广告部副总裁希望你帮助选择广告 A(“味道棒!”)和广告 B(“更少偏见!”)之间的区别。

作为科学家,你决定通过随机向站点访问者展示两个广告,并跟踪点击每个广告的人数来运行实验

如果在 1,000 个 A 观众中有 990 个点击他们的广告,而在 1,000 个 B 观众中只有 10 个点击他们的广告,你可以相当有信心认为 A 是更好的广告。但如果差异不那么明显呢?这就是你会使用统计推断的地方。

假设N A人看到广告 A,并且其中n A人点击了它。我们可以将每次广告浏览视为伯努利试验,其中p A是某人点击广告 A 的概率。那么(如果N A很大,这里是这样),我们知道n A / N A大致上是一个均值为p A,标准差为σ A = p A ( 1 - p A ) / N A的正态随机变量。

同样,n B / N B大致上是一个均值为p B,标准差为σ B = p B ( 1 - p B ) / N B。我们可以用代码表示这个过程:

def estimated_parameters(N: int, n: int) -> Tuple[float, float]:
    p = n / N
    sigma = math.sqrt(p * (1 - p) / N)
    return p, sigma

如果我们假设这两个正态分布是独立的(这似乎是合理的,因为单个伯努利试验应该是独立的),那么它们的差异也应该是均值为p B - p A,标准差为σ A 2 + σ B 2

注意

这有点作弊。只有当您知道标准偏差时,数学才能完全工作。在这里,我们是从数据中估计它们,这意味着我们确实应该使用t分布。但是对于足够大的数据集,它接近标准正态分布,所以差别不大。

这意味着我们可以测试零假设,即p Ap B相同(即p A - p B为 0),使用的统计量是:

def a_b_test_statistic(N_A: int, n_A: int, N_B: int, n_B: int) -> float:
    p_A, sigma_A = estimated_parameters(N_A, n_A)
    p_B, sigma_B = estimated_parameters(N_B, n_B)
    return (p_B - p_A) / math.sqrt(sigma_A ** 2 + sigma_B ** 2)

应该大约是一个标准正态分布。

例如,如果“味道好”的广告在 1,000 次浏览中获得了 200 次点击,“更少偏见”的广告在 1,000 次浏览中获得了 180 次点击,则统计量等于:

z = a_b_test_statistic(1000, 200, 1000, 180)    # -1.14

如果实际上平均值相等,观察到这么大差异的概率将是:

two_sided_p_value(z)                            # 0.254

这个概率足够大,我们无法得出有太大差异的结论。另一方面,如果“更少偏见”的点击仅为 150 次,则有:

z = a_b_test_statistic(1000, 200, 1000, 150)    # -2.94
two_sided_p_value(z)                            # 0.003

这意味着如果广告效果相同,我们看到这么大的差异的概率只有 0.003。

贝叶斯推断

我们所看到的程序涉及对我们的测试做出概率陈述:例如,“如果我们的零假设成立,你观察到如此极端的统计量的概率只有 3%”。

推理的另一种方法涉及将未知参数本身视为随机变量。分析员(也就是你)从参数的先验分布开始,然后使用观察到的数据和贝叶斯定理来获得参数的更新后验分布。与对测试进行概率判断不同,你对参数进行概率判断。

例如,当未知参数是概率时(如我们抛硬币的示例),我们通常使用Beta 分布中的先验,该分布将其所有概率都放在 0 和 1 之间:

def B(alpha: float, beta: float) -> float:
    """A normalizing constant so that the total probability is 1"""
    return math.gamma(alpha) * math.gamma(beta) / math.gamma(alpha + beta)

def beta_pdf(x: float, alpha: float, beta: float) -> float:
    if x <= 0 or x >= 1:          # no weight outside of [0, 1]
        return 0
    return x ** (alpha - 1) * (1 - x) ** (beta - 1) / B(alpha, beta)

一般来说,这个分布将其权重集中在:

alpha / (alpha + beta)

而且alphabeta越大,分布就越“紧密”。

例如,如果alphabeta都为 1,那就是均匀分布(以 0.5 为中心,非常分散)。如果alpha远大于beta,大部分权重都集中在 1 附近。如果alpha远小于beta,大部分权重都集中在 0 附近。图 7-1 展示了几种不同的 Beta 分布。

示例 Beta 分布。

图 7-1。示例 Beta 分布

假设我们对p有一个先验分布。也许我们不想对硬币是否公平发表立场,我们选择alphabeta都等于 1。或者我们非常相信硬币 55%的时间会正面朝上,我们选择alpha等于 55,beta等于 45。

然后我们多次抛硬币,看到h次正面和t次反面。贝叶斯定理(以及一些在这里过于繁琐的数学)告诉我们p的后验分布再次是 Beta 分布,但参数为alpha + hbeta + t

注意

后验分布再次是 Beta 分布并非巧合。头数由二项式分布给出,而 Beta 是与二项式分布共轭先验。这意味着每当您使用相应的二项式观察更新 Beta 先验时,您将得到一个 Beta 后验。

假设你抛了 10 次硬币,只看到 3 次正面。如果你从均匀先验开始(在某种意义上拒绝对硬币的公平性发表立场),你的后验分布将是一个 Beta(4, 8),中心在 0.33 左右。由于你认为所有概率都是同等可能的,你的最佳猜测接近观察到的概率。

如果你最初使用一个 Beta(20, 20)(表达了一种认为硬币大致公平的信念),你的后验分布将是一个 Beta(23, 27),中心在 0.46 左右,表明修正后的信念可能硬币稍微偏向反面。

如果你最初假设一个 Beta(30, 10)(表达了一种认为硬币有 75%概率翻转为正面的信念),你的后验分布将是一个 Beta(33, 17),中心在 0.66 左右。在这种情况下,你仍然相信硬币有正面的倾向,但不像最初那样强烈。这三种不同的后验分布在图 7-2 中绘制出来。

来自不同先验的后验分布。

图 7-2。来自不同先验的后验分布

如果你不断地翻转硬币,先验的影响将越来越小,最终你将拥有(几乎)相同的后验分布,无论你最初选择了哪个先验。

例如,无论你最初认为硬币有多么偏向正面,看到 2000 次翻转中有 1000 次正面后,很难维持那种信念(除非你选择像 Beta(1000000,1)这样的先验,这是疯子的行为)。

有趣的是,这使我们能够对假设进行概率性陈述:“基于先验和观察数据,硬币的正面概率在 49%到 51%之间的可能性仅为 5%。”这在哲学上与“如果硬币是公平的,我们预期观察到极端数据的概率也仅为 5%”这样的陈述有很大的不同。

使用贝叶斯推断来测试假设在某种程度上被认为是有争议的——部分原因是因为数学可以变得相当复杂,部分原因是因为选择先验的主观性质。我们在本书中不会进一步使用它,但了解这一点是很好的。

进一步探索

  • 我们只是浅尝辄止地探讨了统计推断中你应该了解的内容。第五章末推荐的书籍详细讨论了这些内容。

  • Coursera 提供了一门数据分析与统计推断课程,涵盖了许多这些主题。

第八章:梯度下降

那些夸口自己的衰退的人,吹嘘他们欠别人的东西。

塞内加

在做数据科学时,我们经常会试图找到某种情况下的最佳模型。通常,“最佳”意味着“最小化预测误差”或“最大化数据的可能性”。换句话说,它将代表某种优化问题的解决方案。

这意味着我们需要解决许多优化问题。特别是,我们需要从头开始解决它们。我们的方法将是一种称为梯度下降的技术,它非常适合从头开始的处理。你可能不会觉得它本身特别令人兴奋,但它将使我们能够在整本书中做一些令人兴奋的事情,所以请忍耐一下。

梯度下降背后的思想

假设我们有一个函数f,它以一个实数向量作为输入并输出一个实数。一个简单的这样的函数是:

from scratch.linear_algebra import Vector, dot

def sum_of_squares(v: Vector) -> float:
    """Computes the sum of squared elements in v"""
    return dot(v, v)

我们经常需要最大化或最小化这样的函数。也就是说,我们需要找到产生最大(或最小)可能值的输入v

对于像我们这样的函数,梯度(如果你记得你的微积分,这是偏导数的向量)给出了函数增加最快的输入方向。(如果你不记得你的微积分,相信我或者去互联网上查一查。)

相应地,最大化一个函数的一种方法是选择一个随机起点,计算梯度,沿着梯度的方向迈出一小步(即导致函数增加最多的方向),然后用新的起点重复这个过程。类似地,你可以尝试通过向相反方向迈小步来最小化一个函数,如图 8-1 所示。

使用梯度下降找到最小值。

图 8-1. 使用梯度下降找到最小值
注意

如果一个函数有一个唯一的全局最小值,这个过程很可能会找到它。如果一个函数有多个(局部)最小值,这个过程可能会“找到”它们中的错误之一,这种情况下你可能需要从不同的起点重新运行该过程。如果一个函数没有最小值,那么可能这个过程会永远进行下去。

估计梯度

如果f是一个关于一个变量的函数,在点x处的导数测量了当我们对x做一个非常小的变化时f(x)如何改变。导数被定义为差商的极限:

from typing import Callable

def difference_quotient(f: Callable[[float], float],
                        x: float,
                        h: float) -> float:
    return (f(x + h) - f(x)) / h

h趋近于零时。

(许多想要学习微积分的人都被极限的数学定义所困扰,这是美丽的但可能看起来有些令人生畏的。在这里,我们将作弊并简单地说,“极限”意味着你认为的意思。)

导数是切线在 ( x , f ( x ) ) 处的斜率,而差商是穿过 ( x + h , f ( x + h ) ) 的不完全切线的斜率。随着 h 变得越来越小,这个不完全切线越来越接近切线 (图 8-2)。

差商作为导数的近似。

图 8-2. 使用差商近似导数

对于许多函数来说,精确计算导数是很容易的。例如,square 函数:

def square(x: float) -> float:
    return x * x

具有导数:

def derivative(x: float) -> float:
    return 2 * x

对于我们来说很容易检查的是,通过明确计算差商并取极限。(这只需要高中代数就可以做到。)

如果你不能(或不想)找到梯度怎么办?尽管我们不能在 Python 中取极限,但我们可以通过评估一个非常小的 e 的差商来估计导数。图 8-3 显示了这样一个估计的结果:

xs = range(-10, 11)
actuals = [derivative(x) for x in xs]
estimates = [difference_quotient(square, x, h=0.001) for x in xs]

# plot to show they're basically the same
import matplotlib.pyplot as plt
plt.title("Actual Derivatives vs. Estimates")
plt.plot(xs, actuals, 'rx', label='Actual')       # red  x
plt.plot(xs, estimates, 'b+', label='Estimate')   # blue +
plt.legend(loc=9)
plt.show()

差商是一个良好的近似。

图 8-3. 差商近似的好处

f 是多变量函数时,它具有多个 偏导数,每个偏导数指示当我们只对其中一个输入变量进行微小变化时 f 如何变化。

我们通过将其视为仅其第 i 个变量的函数,并固定其他变量来计算其 ith 偏导数:

def partial_difference_quotient(f: Callable[[Vector], float],
                                v: Vector,
                                i: int,
                                h: float) -> float:
    """Returns the i-th partial difference quotient of f at v"""
    w = [v_j + (h if j == i else 0)    # add h to just the ith element of v
         for j, v_j in enumerate(v)]

    return (f(w) - f(v)) / h

然后我们可以以同样的方式估计梯度:

def estimate_gradient(f: Callable[[Vector], float],
                      v: Vector,
                      h: float = 0.0001):
    return [partial_difference_quotient(f, v, i, h)
            for i in range(len(v))]
注意

这种“使用差商估计”的方法的一个主要缺点是计算成本高昂。如果 v 的长度为 nestimate_gradient 必须在 2n 个不同的输入上评估 f。如果你反复估计梯度,那么你需要做大量额外的工作。在我们所做的一切中,我们将使用数学来显式计算我们的梯度函数。

使用梯度

很容易看出,当其输入 v 是一个零向量时,sum_of_squares 函数最小。但想象一下,如果我们不知道这一点。让我们使用梯度来找到所有三维向量中的最小值。我们只需选择一个随机起点,然后沿着梯度的相反方向迈出微小的步骤,直到我们到达梯度非常小的点:

import random
from scratch.linear_algebra import distance, add, scalar_multiply

def gradient_step(v: Vector, gradient: Vector, step_size: float) -> Vector:
    """Moves `step_size` in the `gradient` direction from `v`"""
    assert len(v) == len(gradient)
    step = scalar_multiply(step_size, gradient)
    return add(v, step)

def sum_of_squares_gradient(v: Vector) -> Vector:
    return [2 * v_i for v_i in v]
# pick a random starting point
v = [random.uniform(-10, 10) for i in range(3)]

for epoch in range(1000):
    grad = sum_of_squares_gradient(v)    # compute the gradient at v
    v = gradient_step(v, grad, -0.01)    # take a negative gradient step
    print(epoch, v)

assert distance(v, [0, 0, 0]) < 0.001    # v should be close to 0

如果你运行这个程序,你会发现它总是最终得到一个非常接近 [0,0,0]v。你运行的周期越多,它就会越接近。

选择合适的步长

尽管反对梯度的理由很明确,但移动多远并不清楚。事实上,选择正确的步长更多地是一门艺术而不是一门科学。流行的选择包括:

  • 使用固定步长

  • 随着时间逐渐缩小步长

  • 在每一步中,选择使目标函数值最小化的步长。

最后一种方法听起来很好,但在实践中是一种昂贵的计算。为了保持简单,我们将主要使用固定步长。“有效”的步长取决于问题——太小,你的梯度下降将永远不会结束;太大,你将采取巨大的步骤,可能会使你关心的函数变得更大甚至未定义。因此,我们需要进行实验。

使用梯度下降拟合模型

在这本书中,我们将使用梯度下降来拟合数据的参数化模型。通常情况下,我们会有一些数据集和一些(假设的)依赖于一个或多个参数的数据模型(以可微分的方式)。我们还会有一个损失函数,用于衡量模型拟合数据的好坏程度(越小越好)。

如果我们将数据视为固定的,那么我们的损失函数告诉我们任何特定模型参数的好坏程度。这意味着我们可以使用梯度下降找到使损失尽可能小的模型参数。让我们看一个简单的例子:

# x ranges from -50 to 49, y is always 20 * x + 5
inputs = [(x, 20 * x + 5) for x in range(-50, 50)]

在这种情况下,我们知道线性关系的参数xy,但想象一下我们希望从数据中学习这些参数。我们将使用梯度下降来找到最小化平均平方误差的斜率和截距。

我们将以一个基于单个数据点误差的梯度确定梯度的函数开始:

def linear_gradient(x: float, y: float, theta: Vector) -> Vector:
    slope, intercept = theta
    predicted = slope * x + intercept    # The prediction of the model.
    error = (predicted - y)              # error is (predicted - actual).
    squared_error = error ** 2           # We'll minimize squared error
    grad = [2 * error * x, 2 * error]    # using its gradient.
    return grad

让我们考虑一下梯度的含义。假设对于某个x,我们的预测值过大。在这种情况下,error是正的。第二个梯度项,2 * error,是正的,这反映了截距的小增加会使(已经过大的)预测值变得更大,进而导致该x的平方误差变得更大。

第一个梯度项2 * error * xx的符号相同。确实,如果x为正,那么斜率的小增加会再次使预测(因此误差)变大。但是如果x为负,斜率的小增加会使预测(因此误差)变小。

现在,这个计算是针对单个数据点的。对于整个数据集,我们将看均方误差。均方误差的梯度就是单个梯度的均值。

所以,我们要做的是:

  1. 从一个随机值theta开始。

  2. 计算梯度的均值。

  3. 在那个方向上调整theta

  4. 重复。

经过许多epochs(我们称每次通过数据集的迭代),我们应该学到一些正确的参数:

from scratch.linear_algebra import vector_mean

# Start with random values for slope and intercept
theta = [random.uniform(-1, 1), random.uniform(-1, 1)]

learning_rate = 0.001

for epoch in range(5000):
    # Compute the mean of the gradients
    grad = vector_mean([linear_gradient(x, y, theta) for x, y in inputs])
    # Take a step in that direction
    theta = gradient_step(theta, grad, -learning_rate)
    print(epoch, theta)

slope, intercept = theta
assert 19.9 < slope < 20.1,   "slope should be about 20"
assert 4.9 < intercept < 5.1, "intercept should be about 5"

小批量和随机梯度下降

前述方法的一个缺点是,我们必须在可以采取梯度步骤并更新参数之前对整个数据集进行梯度评估。在这种情况下,这是可以接受的,因为我们的数据集只有 100 对,梯度计算是廉价的。

然而,您的模型通常会有大型数据集和昂贵的梯度计算。在这种情况下,您会希望更频繁地进行梯度步骤。

我们可以使用一种称为小批量梯度下降的技术来实现这一点,通过从更大的数据集中抽取“小批量”来计算梯度(并执行梯度步骤):

from typing import TypeVar, List, Iterator

T = TypeVar('T')  # this allows us to type "generic" functions

def minibatches(dataset: List[T],
                batch_size: int,
                shuffle: bool = True) -> Iterator[List[T]]:
    """Generates `batch_size`-sized minibatches from the dataset"""
    # start indexes 0, batch_size, 2 * batch_size, ...
    batch_starts = [start for start in range(0, len(dataset), batch_size)]

    if shuffle: random.shuffle(batch_starts)  # shuffle the batches

    for start in batch_starts:
        end = start + batch_size
        yield dataset[start:end]
注意

TypeVar(T)允许我们创建一个“泛型”函数。它表示我们的dataset可以是任何一种类型的列表——strintlist等等——但无论是哪种类型,输出都将是它们的批次。

现在我们可以再次使用小批量来解决我们的问题:

theta = [random.uniform(-1, 1), random.uniform(-1, 1)]

for epoch in range(1000):
    for batch in minibatches(inputs, batch_size=20):
        grad = vector_mean([linear_gradient(x, y, theta) for x, y in batch])
        theta = gradient_step(theta, grad, -learning_rate)
    print(epoch, theta)

slope, intercept = theta
assert 19.9 < slope < 20.1,   "slope should be about 20"
assert 4.9 < intercept < 5.1, "intercept should be about 5"

另一种变体是随机梯度下降,在这种方法中,您基于一个训练样本一次进行梯度步骤:

theta = [random.uniform(-1, 1), random.uniform(-1, 1)]

for epoch in range(100):
    for x, y in inputs:
        grad = linear_gradient(x, y, theta)
        theta = gradient_step(theta, grad, -learning_rate)
    print(epoch, theta)

slope, intercept = theta
assert 19.9 < slope < 20.1,   "slope should be about 20"
assert 4.9 < intercept < 5.1, "intercept should be about 5"

在这个问题上,随机梯度下降在更少的迭代次数内找到了最优参数。但总是存在权衡。基于小型小批量(或单个数据点)进行梯度步骤可以让您执行更多次梯度步骤,但单个点的梯度可能与整个数据集的梯度方向差异很大。

此外,如果我们不是从头开始进行线性代数运算,通过“向量化”我们在批次中的计算而不是逐点计算梯度,会有性能提升。

在整本书中,我们将尝试找到最佳的批量大小和步长。

注意

关于各种梯度下降方法的术语并不统一。通常称为批量梯度下降的方法是“对整个数据集计算梯度”,有些人在提到小批量版本时称为随机梯度下降(其中逐点版本是一种特例)。

进一步探索

  • 继续阅读!我们将在本书的其余部分使用梯度下降来解决问题。

  • 此时,您无疑对我建议您阅读教科书感到厌倦。如果能稍微安慰一下的话,Active Calculus 1.0,由 Matthew Boelkins、David Austin 和 Steven Schlicker(Grand Valley State University Libraries)编写的书,似乎比我学习的微积分教科书更好。

  • Sebastian Ruder 在他的 epic 博客文章 中比较了梯度下降及其许多变体。

第九章:获取数据

要写它,用了三个月;要构思它,用了三分钟;要收集其中的数据,用了一生。

F. 斯科特·菲茨杰拉德

要成为一名数据科学家,你需要数据。事实上,作为数据科学家,你将花费大量时间来获取、清理和转换数据。如果必要,你可以自己键入数据(或者如果有下属,让他们来做),但通常这不是你时间的好用法。在本章中,我们将探讨将数据引入 Python 及其转换为正确格式的不同方法。

stdin 和 stdout

如果在命令行中运行 Python 脚本,你可以使用sys.stdinsys.stdout将数据管道通过它们。例如,这是一个读取文本行并返回匹配正则表达式的脚本:

# egrep.py
import sys, re

# sys.argv is the list of command-line arguments
# sys.argv[0] is the name of the program itself
# sys.argv[1] will be the regex specified at the command line
regex = sys.argv[1]

# for every line passed into the script
for line in sys.stdin:
    # if it matches the regex, write it to stdout
    if re.search(regex, line):
        sys.stdout.write(line)

这里有一个示例,它会计算接收到的行数并将其写出:

# line_count.py
import sys

count = 0
for line in sys.stdin:
    count += 1

# print goes to sys.stdout
print(count)

然后你可以使用它们来计算文件中包含数字的行数。在 Windows 中,你会使用:

type SomeFile.txt | python egrep.py "[0-9]" | python line_count.py

在 Unix 系统中,你会使用:

cat SomeFile.txt | python egrep.py "[0-9]" | python line_count.py

管道符号|表示管道字符,意味着“使用左侧命令的输出作为右侧命令的输入”。你可以通过这种方式构建非常复杂的数据处理管道。

注意

如果你使用 Windows,你可能可以在该命令中省略python部分:

type SomeFile.txt | egrep.py "[0-9]" | line_count.py

如果你在 Unix 系统上,这样做需要几个额外步骤。首先在你的脚本的第一行添加一个“shebang” #!/usr/bin/env python。然后,在命令行中使用chmod x egrep.py++将文件设为可执行。

同样地,这是一个计算其输入中单词数量并写出最常见单词的脚本:

# most_common_words.py
import sys
from collections import Counter

# pass in number of words as first argument
try:
    num_words = int(sys.argv[1])
except:
    print("usage: most_common_words.py num_words")
    sys.exit(1)   # nonzero exit code indicates error

counter = Counter(word.lower()                      # lowercase words
                  for line in sys.stdin
                  for word in line.strip().split()  # split on spaces
                  if word)                          # skip empty 'words'

for word, count in counter.most_common(num_words):
    sys.stdout.write(str(count))
    sys.stdout.write("\t")
    sys.stdout.write(word)
    sys.stdout.write("\n")

然后你可以像这样做一些事情:

$ cat the_bible.txt | python most_common_words.py 10
36397	the
30031	and
20163	of
7154	to
6484	in
5856	that
5421	he
5226	his
5060	unto
4297	shall

(如果你使用 Windows,则使用type而不是cat。)

注意

如果你是一名经验丰富的 Unix 程序员,可能已经熟悉各种命令行工具(例如,egrep),这些工具已经内建到你的操作系统中,比从头开始构建更可取。不过,了解自己可以这样做也是很好的。

读取文件

你也可以在代码中直接显式地读取和写入文件。Python 使得处理文件变得非常简单。

文本文件的基础知识

处理文本文件的第一步是使用open获取一个文件对象

# 'r' means read-only, it's assumed if you leave it out
file_for_reading = open('reading_file.txt', 'r')
file_for_reading2 = open('reading_file.txt')

# 'w' is write -- will destroy the file if it already exists!
file_for_writing = open('writing_file.txt', 'w')

# 'a' is append -- for adding to the end of the file
file_for_appending = open('appending_file.txt', 'a')

# don't forget to close your files when you're done
file_for_writing.close()

因为很容易忘记关闭文件,所以你应该总是在with块中使用它们,在块结束时它们将自动关闭:

with open(filename) as f:
    data = function_that_gets_data_from(f)

# at this point f has already been closed, so don't try to use it
process(data)

如果你需要读取整个文本文件,可以使用for循环迭代文件的每一行:

starts_with_hash = 0

with open('input.txt') as f:
    for line in f:                  # look at each line in the file
        if re.match("^#",line):     # use a regex to see if it starts with '#'
            starts_with_hash += 1   # if it does, add 1 to the count

通过这种方式获取的每一行都以换行符结尾,所以在处理之前通常会将其strip掉。

例如,假设你有一个文件,其中包含一个邮箱地址一行,你需要生成一个域名的直方图。正确提取域名的规则有些微妙,可以参考公共后缀列表,但一个很好的初步方法是仅仅取邮箱地址中“@”后面的部分(对于像joel@mail.datasciencester.com这样的邮箱地址,这个方法会给出错误的答案,但在这个例子中我们可以接受这种方法):

def get_domain(email_address: str) -> str:
    """Split on '@' and return the last piece"""
    return email_address.lower().split("@")[-1]

# a couple of tests
assert get_domain('joelgrus@gmail.com') == 'gmail.com'
assert get_domain('joel@m.datasciencester.com') == 'm.datasciencester.com'

from collections import Counter

with open('email_addresses.txt', 'r') as f:
    domain_counts = Counter(get_domain(line.strip())
                            for line in f
                            if "@" in line)

分隔文件

我们刚刚处理的假设的邮箱地址文件每行一个地址。更频繁地,你将使用每行有大量数据的文件。这些文件往往是逗号分隔或制表符分隔的:每行有多个字段,逗号或制表符表示一个字段的结束和下一个字段的开始。

当你的字段中有逗号、制表符和换行符时(这是不可避免的)。因此,你不应该尝试自己解析它们。相反,你应该使用 Python 的csv模块(或 pandas 库,或设计用于读取逗号分隔或制表符分隔文件的其他库)。

警告

永远不要自己解析逗号分隔的文件。你会搞砸一些边缘情况!

如果你的文件没有表头(这意味着你可能希望每行作为一个list,并且需要你知道每一列中包含什么),你可以使用csv.reader来迭代行,每行都会是一个适当拆分的列表。

例如,如果我们有一个制表符分隔的股票价格文件:

6/20/2014   AAPL    90.91
6/20/2014   MSFT    41.68
6/20/2014   FB  64.5
6/19/2014   AAPL    91.86
6/19/2014   MSFT    41.51
6/19/2014   FB  64.34

我们可以用以下方式处理它们:

import csv

with open('tab_delimited_stock_prices.txt') as f:
    tab_reader = csv.reader(f, delimiter='\t')
    for row in tab_reader:
        date = row[0]
        symbol = row[1]
        closing_price = float(row[2])
        process(date, symbol, closing_price)

如果你的文件有表头:

date:symbol:closing_price
6/20/2014:AAPL:90.91
6/20/2014:MSFT:41.68
6/20/2014:FB:64.5

你可以通过初始调用reader.next跳过表头行,或者通过使用csv.DictReader将每一行作为dict(表头作为键)来获取:

with open('colon_delimited_stock_prices.txt') as f:
    colon_reader = csv.DictReader(f, delimiter=':')
    for dict_row in colon_reader:
        date = dict_row["date"]
        symbol = dict_row["symbol"]
        closing_price = float(dict_row["closing_price"])
        process(date, symbol, closing_price)

即使你的文件没有表头,你仍然可以通过将键作为fieldnames参数传递给DictReader来使用它。

你也可以使用csv.writer类似地写出分隔数据:

todays_prices = {'AAPL': 90.91, 'MSFT': 41.68, 'FB': 64.5 }

with open('comma_delimited_stock_prices.txt', 'w') as f:
    csv_writer = csv.writer(f, delimiter=',')
    for stock, price in todays_prices.items():
        csv_writer.writerow([stock, price])

如果你的字段本身包含逗号,csv.writer会处理得很好。但是,如果你自己手动编写的写入器可能不会。例如,如果你尝试:

results = [["test1", "success", "Monday"],
           ["test2", "success, kind of", "Tuesday"],
           ["test3", "failure, kind of", "Wednesday"],
           ["test4", "failure, utter", "Thursday"]]

# don't do this!
with open('bad_csv.txt', 'w') as f:
    for row in results:
        f.write(",".join(map(str, row))) # might have too many commas in it!
        f.write("\n")                    # row might have newlines as well!

你将会得到一个如下的.csv文件:

test1,success,Monday
test2,success, kind of,Tuesday
test3,failure, kind of,Wednesday
test4,failure, utter,Thursday

而且没有人能够理解。

网页抓取

另一种获取数据的方式是从网页中抓取数据。事实证明,获取网页很容易;但从中获取有意义的结构化信息却不那么容易。

HTML 及其解析

网页是用 HTML 编写的,文本(理想情况下)被标记为元素及其属性:

<html>
  <head>
    <title>A web page</title>
  </head>
  <body>
    <p id="author">Joel Grus</p>
    <p id="subject">Data Science</p>
  </body>
</html>

在一个完美的世界中,所有网页都会被语义化地标记,为了我们的利益。我们将能够使用诸如“查找idsubject<p>元素并返回其包含的文本”之类的规则来提取数据。但实际上,HTML 通常并不规范,更不用说注释了。这意味着我们需要帮助来理解它。

要从 HTML 中获取数据,我们将使用Beautiful Soup 库,它会构建一个网页上各种元素的树,并提供一个简单的接口来访问它们。在我写这篇文章时,最新版本是 Beautiful Soup 4.6.0,这也是我们将使用的版本。我们还将使用Requests 库,这是一种比 Python 内置的任何东西都更好的方式来进行 HTTP 请求。

Python 内置的 HTML 解析器并不那么宽容,这意味着它不能很好地处理不完全形式的 HTML。因此,我们还将安装html5lib解析器。

确保您处于正确的虚拟环境中,安装库:

python -m pip install beautifulsoup4 requests html5lib

要使用 Beautiful Soup,我们将一个包含 HTML 的字符串传递给BeautifulSoup函数。在我们的示例中,这将是对requests.get调用的结果:

from bs4 import BeautifulSoup
import requests

# I put the relevant HTML file on GitHub. In order to fit
# the URL in the book I had to split it across two lines.
# Recall that whitespace-separated strings get concatenated.
url = ("https://raw.githubusercontent.com/"
       "joelgrus/data/master/getting-data.html")
html = requests.get(url).text
soup = BeautifulSoup(html, 'html5lib')

然后我们可以使用几种简单的方法走得相当远。

我们通常会使用Tag对象,它对应于表示 HTML 页面结构的标签。

例如,要找到第一个<p>标签(及其内容),您可以使用:

first_paragraph = soup.find('p')        # or just soup.p

您可以使用其text属性获取Tag的文本内容:

first_paragraph_text = soup.p.text
first_paragraph_words = soup.p.text.split()

您可以通过将其视为dict来提取标签的属性:

first_paragraph_id = soup.p['id']       # raises KeyError if no 'id'
first_paragraph_id2 = soup.p.get('id')  # returns None if no 'id'

您可以按以下方式一次获取多个标签:

all_paragraphs = soup.find_all('p')  # or just soup('p')
paragraphs_with_ids = [p for p in soup('p') if p.get('id')]

经常,您会想要找到具有特定class的标签:

important_paragraphs = soup('p', {'class' : 'important'})
important_paragraphs2 = soup('p', 'important')
important_paragraphs3 = [p for p in soup('p')
                         if 'important' in p.get('class', [])]

你可以结合这些方法来实现更复杂的逻辑。例如,如果你想找到每个包含在<div>元素内的<span>元素,你可以这样做:

# Warning: will return the same <span> multiple times
# if it sits inside multiple <div>s.
# Be more clever if that's the case.
spans_inside_divs = [span
                     for div in soup('div')     # for each <div> on the page
                     for span in div('span')]   # find each <span> inside it

这些功能的几个特点就足以让我们做很多事情。如果你最终需要做更复杂的事情(或者你只是好奇),请查阅文档

当然,重要的数据通常不会标记为class="important"。您需要仔细检查源 HTML,通过选择逻辑推理,并担心边缘情况,以确保数据正确。让我们看一个例子。

例如:监控国会

DataSciencester 的政策副总裁担心数据科学行业可能会受到监管,并要求您量化国会在该主题上的言论。特别是,他希望您找出所有发表关于“数据”内容的代表。

在发布时,有一个页面链接到所有代表的网站,网址为https://www.house.gov/representatives

如果“查看源代码”,所有指向网站的链接看起来像:

<td>
  <a href="https://jayapal.house.gov">Jayapal, Pramila</a>
</td>

让我们开始收集从该页面链接到的所有 URL:

from bs4 import BeautifulSoup
import requests

url = "https://www.house.gov/representatives"
text = requests.get(url).text
soup = BeautifulSoup(text, "html5lib")

all_urls = [a['href']
            for a in soup('a')
            if a.has_attr('href')]

print(len(all_urls))  # 965 for me, way too many

这返回了太多的 URL。如果你查看它们,我们想要的 URL 以http://https://开头,有一些名称,并且以.house.gov.house.gov/结尾。

这是使用正则表达式的好地方:

import re

# Must start with http:// or https://
# Must end with .house.gov or .house.gov/
regex = r"^https?://.*\.house\.gov/?$"

# Let's write some tests!
assert re.match(regex, "http://joel.house.gov")
assert re.match(regex, "https://joel.house.gov")
assert re.match(regex, "http://joel.house.gov/")
assert re.match(regex, "https://joel.house.gov/")
assert not re.match(regex, "joel.house.gov")
assert not re.match(regex, "http://joel.house.com")
assert not re.match(regex, "https://joel.house.gov/biography")

# And now apply
good_urls = [url for url in all_urls if re.match(regex, url)]

print(len(good_urls))  # still 862 for me

这仍然太多了,因为只有 435 位代表。如果你看一下列表,会发现很多重复。让我们使用set来去重:

good_urls = list(set(good_urls))

print(len(good_urls))  # only 431 for me

总会有几个众议院席位是空缺的,或者可能有一个没有网站的代表。无论如何,这已经足够了。当我们查看这些站点时,大多数都有一个指向新闻稿的链接。例如:

html = requests.get('https://jayapal.house.gov').text
soup = BeautifulSoup(html, 'html5lib')

# Use a set because the links might appear multiple times.
links = {a['href'] for a in soup('a') if 'press releases' in a.text.lower()}

print(links) # {'/media/press-releases'}

注意这是一个相对链接,这意味着我们需要记住原始站点。让我们来做一些抓取:

from typing import Dict, Set

press_releases: Dict[str, Set[str]] = {}

for house_url in good_urls:
    html = requests.get(house_url).text
    soup = BeautifulSoup(html, 'html5lib')
    pr_links = {a['href'] for a in soup('a') if 'press releases'
                                             in a.text.lower()}
    print(f"{house_url}: {pr_links}")
    press_releases[house_url] = pr_links
注意

通常情况下,自由地抓取一个网站是不礼貌的。大多数网站会有一个robots.txt文件,指示您可以多频繁地抓取该站点(以及您不应该抓取的路径),但由于涉及到国会,我们不需要特别礼貌。

如果你看这些内容滚动显示,你会看到很多/media/press-releasesmedia-center/press-releases,以及各种其他地址。其中一个 URL 是https://jayapal.house.gov/media/press-releases

请记住,我们的目标是找出哪些国会议员在其新闻稿中提到了“数据”。我们将编写一个稍微更通用的函数,检查新闻稿页面是否提到了任何给定的术语。

如果你访问该网站并查看源代码,似乎每篇新闻稿都有一个在<p>标签中的片段,所以我们将用它作为我们的第一个尝试:

def paragraph_mentions(text: str, keyword: str) -> bool:
    """
 Returns True if a <p> inside the text mentions {keyword}
 """
    soup = BeautifulSoup(text, 'html5lib')
    paragraphs = [p.get_text() for p in soup('p')]

    return any(keyword.lower() in paragraph.lower()
               for paragraph in paragraphs)

让我们为此写一个快速的测试:

text = """<body><h1>Facebook</h1><p>Twitter</p>"""
assert paragraph_mentions(text, "twitter")       # is inside a <p>
assert not paragraph_mentions(text, "facebook")  # not inside a <p>

最后,我们准备好找到相关的国会议员,并把他们的名字交给副总裁:

for house_url, pr_links in press_releases.items():
    for pr_link in pr_links:
        url = f"{house_url}/{pr_link}"
        text = requests.get(url).text

        if paragraph_mentions(text, 'data'):
            print(f"{house_url}")
            break  # done with this house_url

当我运行这个时,我得到了大约 20 位代表的列表。你的结果可能会有所不同。

注意

如果你看各种“新闻稿”页面,大多数页面都是分页的,每页只有 5 或 10 篇新闻稿。这意味着我们只检索了每位国会议员最近的几篇新闻稿。更彻底的解决方案将迭代每一页,并检索每篇新闻稿的全文。

使用 API

许多网站和 Web 服务提供应用程序编程接口(API),允许您以结构化格式显式请求数据。这样可以避免您必须进行抓取的麻烦!

JSON 和 XML

因为 HTTP 是一个用于传输文本的协议,通过 Web API 请求的数据需要被序列化为字符串格式。通常这种序列化使用JavaScript 对象表示法(JSON)。JavaScript 对象看起来非常类似于 Python 的dict,这使得它们的字符串表示易于解释:

{ "title" : "Data Science Book",
  "author" : "Joel Grus",
  "publicationYear" : 2019,
  "topics" : [ "data", "science", "data science"] }

我们可以使用 Python 的json模块解析 JSON。特别地,我们将使用它的loads函数,将表示 JSON 对象的字符串反序列化为 Python 对象:

import json
serialized = """{ "title" : "Data Science Book",
 "author" : "Joel Grus",
 "publicationYear" : 2019,
 "topics" : [ "data", "science", "data science"] }"""

# parse the JSON to create a Python dict
deserialized = json.loads(serialized)
assert deserialized["publicationYear"] == 2019
assert "data science" in deserialized["topics"]

有时 API 提供者会讨厌你,并且只提供 XML 格式的响应:

<Book>
  <Title>Data Science Book</Title>
  <Author>Joel Grus</Author>
  <PublicationYear>2014</PublicationYear>
  <Topics>
    <Topic>data</Topic>
    <Topic>science</Topic>
    <Topic>data science</Topic>
  </Topics>
</Book>

你可以像从 HTML 中获取数据那样,使用 Beautiful Soup 从 XML 中获取数据;请查看其文档以获取详细信息。

使用未经身份验证的 API

大多数 API 现在要求你先进行身份验证,然后才能使用它们。虽然我们不反对这种策略,但这会产生很多额外的样板代码,使我们的解释变得混乱。因此,我们将首先看一下GitHub 的 API,它可以让你无需身份验证就能进行一些简单的操作:

import requests, json

github_user = "joelgrus"
endpoint = f"https://api.github.com/users/{github_user}/repos"

repos = json.loads(requests.get(endpoint).text)

此时repos是我 GitHub 账户中的公共仓库的 Python dict列表。(随意替换你的用户名并获取你的 GitHub 仓库数据。你有 GitHub 账户,对吧?)

我们可以用这个来找出我最有可能创建仓库的月份和星期几。唯一的问题是响应中的日期是字符串:

"created_at": "2013-07-05T02:02:28Z"

Python 自带的日期解析器不是很好用,所以我们需要安装一个:

python -m pip install python-dateutil

其中你可能只会需要dateutil.parser.parse函数:

from collections import Counter
from dateutil.parser import parse

dates = [parse(repo["created_at"]) for repo in repos]
month_counts = Counter(date.month for date in dates)
weekday_counts = Counter(date.weekday() for date in dates)

同样地,你可以获取我最近五个仓库的语言:

last_5_repositories = sorted(repos,
                             key=lambda r: r["pushed_at"],
                             reverse=True)[:5]

last_5_languages = [repo["language"]
                    for repo in last_5_repositories]

通常情况下,我们不会在低层次(“自己发起请求并解析响应”)处理 API。使用 Python 的好处之一是,几乎任何你有兴趣访问的 API,都已经有人建立了一个库。如果做得好,这些库可以节省你很多访问 API 的复杂细节的麻烦。(如果做得不好,或者当它们基于已失效的 API 版本时,可能会带来巨大的麻烦。)

尽管如此,偶尔你会需要自己编写 API 访问库(或者更有可能,调试为什么别人的库不起作用),因此了解一些细节是很有用的。

寻找 API

如果你需要从特定网站获取数据,请查找该网站的“开发者”或“API”部分以获取详细信息,并尝试在网上搜索“python api”来找到相应的库。

有关 Yelp API、Instagram API、Spotify API 等等,都有相应的库。

如果你在寻找 Python 封装的 API 列表,Real Python 在 GitHub 上有一个很好的列表。

如果找不到你需要的内容,总有一种方法,那就是网页抓取,数据科学家的最后避风港。

示例:使用 Twitter 的 API

Twitter 是一个非常好的数据来源。你可以用它来获取实时新闻,也可以用它来衡量对当前事件的反应。你还可以用它来查找与特定主题相关的链接。你可以用它来做几乎任何你能想到的事情,只要你能访问到它的数据。通过它的 API,你可以获取到它的数据。

要与 Twitter 的 API 交互,我们将使用Twython 库python -m pip install twython)。目前有许多 Python Twitter 库,但这是我使用最成功的一个。当然,也鼓励你探索其他库!

获取凭证

为了使用 Twitter 的 API,你需要获取一些凭据(你需要一个 Twitter 帐户,这样你就可以成为活跃且友好的 Twitter #datascience 社区的一部分)。

警告

像所有与我无法控制的网站相关的说明一样,这些说明可能在某个时候过时,但希望能够一段时间内工作。(尽管自我最初开始写这本书以来,它们已经多次发生变化,所以祝你好运!)

以下是步骤:

  1. 前往 https://developer.twitter.com/

  2. 如果你没有登录,点击“登录”并输入你的 Twitter 用户名和密码。

  3. 点击申请以申请开发者帐户。

  4. 为你自己的个人使用请求访问。

  5. 填写申请。 它需要 300 字(真的)解释为什么你需要访问,所以为了超过限制,你可以告诉他们关于这本书以及你有多么喜欢它。

  6. 等待一段不确定的时间。

  7. 如果你认识在 Twitter 工作的人,请给他们发电子邮件,询问他们是否可以加快你的申请。 否则,继续等待。

  8. 一旦你获得批准,返回到 developer.twitter.com,找到“Apps”部分,然后点击“创建应用程序”。

  9. 填写所有必填字段(同样,如果你需要描述的额外字符,你可以谈论这本书以及你发现它多么有启发性)。

  10. 点击创建。

现在你的应用程序应该有一个“Keys and tokens”选项卡,其中包含一个“Consumer API keys”部分,列出了一个“API key”和一个“API secret key”。 记下这些密钥; 你会需要它们。(另外,保持它们保密! 它们就像密码。)

小心

不要分享密钥,不要在书中发布它们,也不要将它们检入你的公共 GitHub 存储库。 一个简单的解决方案是将它们存储在一个不会被检入的 credentials.json 文件中,并让你的代码使用 json.loads 来检索它们。 另一个解决方案是将它们存储在环境变量中,并使用 os.environ 来检索它们。

使用 Twython

使用 Twitter API 的最棘手的部分是验证身份。(事实上,这是使用许多 API 中最棘手的部分之一。) API 提供商希望确保你被授权访问他们的数据,并且你不会超出他们的使用限制。 他们还想知道谁在访问他们的数据。

认证有点痛苦。 有一种简单的方法,OAuth 2,在你只想做简单搜索时足够使用。 还有一种复杂的方法,OAuth 1,在你想执行操作(例如,发推文)或(特别是对我们来说)连接到 Twitter 流时需要使用。

所以我们被迫使用更复杂的方式,我们会尽可能自动化它。

首先,你需要你的 API 密钥和 API 密钥(有时也称为消费者密钥和消费者密钥)。 我将从环境变量中获取我的,但请随意以任何你希望的方式替换你的:

import os

# Feel free to plug your key and secret in directly
CONSUMER_KEY = os.environ.get("TWITTER_CONSUMER_KEY")
CONSUMER_SECRET = os.environ.get("TWITTER_CONSUMER_SECRET")

现在我们可以实例化客户端:

import webbrowser
from twython import Twython

# Get a temporary client to retrieve an authentication URL
temp_client = Twython(CONSUMER_KEY, CONSUMER_SECRET)
temp_creds = temp_client.get_authentication_tokens()
url = temp_creds['auth_url']

# Now visit that URL to authorize the application and get a PIN
print(f"go visit {url} and get the PIN code and paste it below")
webbrowser.open(url)
PIN_CODE = input("please enter the PIN code: ")

# Now we use that PIN_CODE to get the actual tokens
auth_client = Twython(CONSUMER_KEY,
                      CONSUMER_SECRET,
                      temp_creds['oauth_token'],
                      temp_creds['oauth_token_secret'])
final_step = auth_client.get_authorized_tokens(PIN_CODE)
ACCESS_TOKEN = final_step['oauth_token']
ACCESS_TOKEN_SECRET = final_step['oauth_token_secret']

# And get a new Twython instance using them.
twitter = Twython(CONSUMER_KEY,
                  CONSUMER_SECRET,
                  ACCESS_TOKEN,
                  ACCESS_TOKEN_SECRET)
提示

此时,你可能希望考虑将ACCESS_TOKENACCESS_TOKEN_SECRET保存在安全的地方,这样下次你就不必再经历这个烦琐的过程了。

一旦我们有了一个经过身份验证的Twython实例,我们就可以开始执行搜索:

# Search for tweets containing the phrase "data science"
for status in twitter.search(q='"data science"')["statuses"]:
    user = status["user"]["screen_name"]
    text = status["text"]
    print(f"{user}: {text}\n")

如果你运行这个程序,你应该会得到一些推文,比如:

haithemnyc: Data scientists with the technical savvy &amp; analytical chops to
derive meaning from big data are in demand. http://t.co/HsF9Q0dShP

RPubsRecent: Data Science http://t.co/6hcHUz2PHM

spleonard1: Using #dplyr in #R to work through a procrastinated assignment for
@rdpeng in @coursera data science specialization. So easy and Awesome.

这并不那么有趣,主要是因为 Twitter 搜索 API 只会显示出它觉得最近的结果。在进行数据科学时,更多时候你会想要大量的推文。这就是Streaming API有用的地方。它允许你连接到(部分)巨大的 Twitter firehose。要使用它,你需要使用你的访问令牌进行身份验证。

为了使用 Twython 访问 Streaming API,我们需要定义一个类,该类继承自TwythonStreamer并重写其on_success方法,可能还有其on_error方法:

from twython import TwythonStreamer

# Appending data to a global variable is pretty poor form
# but it makes the example much simpler
tweets = []

class MyStreamer(TwythonStreamer):
    def on_success(self, data):
        """
 What do we do when Twitter sends us data?
 Here data will be a Python dict representing a tweet.
 """
        # We only want to collect English-language tweets
        if data.get('lang') == 'en':
            tweets.append(data)
            print(f"received tweet #{len(tweets)}")

        # Stop when we've collected enough
        if len(tweets) >= 100:
            self.disconnect()

    def on_error(self, status_code, data):
        print(status_code, data)
        self.disconnect()

MyStreamer将连接到 Twitter 流并等待 Twitter 提供数据。每次接收到一些数据(在这里是表示为 Python 对象的推文)时,它都会将其传递给on_success方法,如果推文的语言是英语,则将其追加到我们的tweets列表中,然后在收集到 1,000 条推文后断开流。

唯一剩下的就是初始化它并开始运行:

stream = MyStreamer(CONSUMER_KEY, CONSUMER_SECRET,
                    ACCESS_TOKEN, ACCESS_TOKEN_SECRET)

# starts consuming public statuses that contain the keyword 'data'
stream.statuses.filter(track='data')

# if instead we wanted to start consuming a sample of *all* public statuses
# stream.statuses.sample()

这将持续运行,直到收集到 100 条推文(或遇到错误为止),然后停止,此时你可以开始分析这些推文。例如,你可以找出最常见的标签:

top_hashtags = Counter(hashtag['text'].lower()
                       for tweet in tweets
                       for hashtag in tweet["entities"]["hashtags"])

print(top_hashtags.most_common(5))

每条推文都包含大量的数据。你可以自己探索,或者查看Twitter API 文档

注意

在一个非玩具项目中,你可能不想依赖于内存中的list来存储推文。相反,你可能想把它们保存到文件或数据库中,这样你就能永久地拥有它们。

进一步探索

  • pandas是数据科学家们用来处理数据,特别是导入数据的主要库。

  • Scrapy是一个用于构建复杂网络爬虫的全功能库,可以执行诸如跟踪未知链接等操作。

  • Kaggle拥有大量的数据集。

第十章:处理数据

专家往往拥有比判断更多的数据。

科林·鲍威尔

处理数据既是一门艺术,也是一门科学。我们主要讨论科学部分,但在本章中我们将探讨一些艺术方面。

探索您的数据

在确定您要回答的问题并获取到数据之后,您可能会有冲动立即开始构建模型和获取答案。但您应该抑制这种冲动。您的第一步应该是 探索 您的数据。

探索一维数据

最简单的情况是您有一个一维数据集,它只是一组数字。例如,这些可能是每个用户每天在您的网站上花费的平均分钟数,一系列数据科学教程视频的观看次数,或者您的数据科学图书馆中每本数据科学书籍的页数。

显而易见的第一步是计算一些摘要统计信息。您想知道有多少数据点,最小值,最大值,均值和标准差。

但即使这些也不一定能给您带来很好的理解。一个很好的下一步是创建直方图,将数据分组为离散的 并计算落入每个桶中的点数:

from typing import List, Dict
from collections import Counter
import math

import matplotlib.pyplot as plt

def bucketize(point: float, bucket_size: float) -> float:
    """Floor the point to the next lower multiple of bucket_size"""
    return bucket_size * math.floor(point / bucket_size)

def make_histogram(points: List[float], bucket_size: float) -> Dict[float, int]:
    """Buckets the points and counts how many in each bucket"""
    return Counter(bucketize(point, bucket_size) for point in points)

def plot_histogram(points: List[float], bucket_size: float, title: str = ""):
    histogram = make_histogram(points, bucket_size)
    plt.bar(histogram.keys(), histogram.values(), width=bucket_size)
    plt.title(title)

例如,考虑以下两组数据:

import random
from scratch.probability import inverse_normal_cdf

random.seed(0)

# uniform between -100 and 100
uniform = [200 * random.random() - 100 for _ in range(10000)]

# normal distribution with mean 0, standard deviation 57
normal = [57 * inverse_normal_cdf(random.random())
          for _ in range(10000)]

两者的均值接近 0,标准差接近 58。然而,它们的分布却非常不同。 第 10-1 图 显示了 uniform 的分布:

plot_histogram(uniform, 10, "Uniform Histogram")

而 第 10-2 图 显示了 normal 的分布:

plot_histogram(normal, 10, "Normal Histogram")

均匀分布直方图

第 10-1 图。均匀分布直方图

在这种情况下,两个分布的 maxmin 相差很大,但即使知道这一点也不足以理解它们的 差异

两个维度

现在想象一下,您有一个具有两个维度的数据集。除了每天的分钟数,您可能还有数据科学经验的年数。当然,您希望单独了解每个维度。但您可能还想散点数据。

例如,考虑另一个虚构数据集:

def random_normal() -> float:
    """Returns a random draw from a standard normal distribution"""
    return inverse_normal_cdf(random.random())

xs = [random_normal() for _ in range(1000)]
ys1 = [ x + random_normal() / 2 for x in xs]
ys2 = [-x + random_normal() / 2 for x in xs]

如果你对 ys1ys2 运行 plot_histogram,你会得到类似的图表(实际上,两者均为具有相同均值和标准差的正态分布)。

正态分布直方图

第 10-2 图。正态分布直方图

但每个维度与 xs 的联合分布非常不同,如 第 10-3 图 所示:

plt.scatter(xs, ys1, marker='.', color='black', label='ys1')
plt.scatter(xs, ys2, marker='.', color='gray',  label='ys2')
plt.xlabel('xs')
plt.ylabel('ys')
plt.legend(loc=9)
plt.title("Very Different Joint Distributions")
plt.show()

散射两个不同 ys 的图

第 10-3 图。散射两个不同的 ys

如果你查看相关性,这种差异也会显现:

from scratch.statistics import correlation

print(correlation(xs, ys1))      # about 0.9
print(correlation(xs, ys2))      # about -0.9

多维度

对于许多维度,您可能想了解所有维度之间的关系。一个简单的方法是查看 相关矩阵,其中第 i 行和第 j 列的条目是数据的第 i 维和第 j 维之间的相关性:

from scratch.linear_algebra import Matrix, Vector, make_matrix

def correlation_matrix(data: List[Vector]) -> Matrix:
    """
 Returns the len(data) x len(data) matrix whose (i, j)-th entry
 is the correlation between data[i] and data[j]
 """
    def correlation_ij(i: int, j: int) -> float:
        return correlation(data[i], data[j])

    return make_matrix(len(data), len(data), correlation_ij)

一种更直观的方法(如果维度不多)是制作一个散点图矩阵(参见图 10-4),显示所有成对的散点图。为此,我们将使用plt.subplots,它允许我们创建图表的子图。我们给它行数和列数,它返回一个figure对象(我们将不使用它)和一个二维数组的axes对象(我们将每个都绘制):

# corr_data is a list of four 100-d vectors
num_vectors = len(corr_data)
fig, ax = plt.subplots(num_vectors, num_vectors)

for i in range(num_vectors):
    for j in range(num_vectors):

        # Scatter column_j on the x-axis vs. column_i on the y-axis
        if i != j: ax[i][j].scatter(corr_data[j], corr_data[i])

        # unless i == j, in which case show the series name
        else: ax[i][j].annotate("series " + str(i), (0.5, 0.5),
                                xycoords='axes fraction',
                                ha="center", va="center")

        # Then hide axis labels except left and bottom charts
        if i < num_vectors - 1: ax[i][j].xaxis.set_visible(False)
        if j > 0: ax[i][j].yaxis.set_visible(False)

# Fix the bottom-right and top-left axis labels, which are wrong because
# their charts only have text in them
ax[-1][-1].set_xlim(ax[0][-1].get_xlim())
ax[0][0].set_ylim(ax[0][1].get_ylim())

plt.show()

散点图矩阵

图 10-4. 散点图矩阵

从散点图中可以看出,系列 1 与系列 0 之间存在非常负相关的关系,系列 2 与系列 1 之间存在正相关的关系,而系列 3 只取值 0 和 6,其中 0 对应于系列 2 的小值,6 对应于系列 2 的大值。

这是快速了解哪些变量相关的一种粗略方法(除非你花费数小时调整 matplotlib 以完全按照你想要的方式显示,否则这不是一个快速方法)。

使用 NamedTuples

表示数据的一种常见方式是使用dicts:

import datetime

stock_price = {'closing_price': 102.06,
               'date': datetime.date(2014, 8, 29),
               'symbol': 'AAPL'}

然而,这不太理想的几个原因。这是一种略微低效的表示形式(一个dict涉及一些开销),因此如果你有很多股价,它们将占用比它们应该占用的更多内存。在大多数情况下,这只是一个小考虑。

一个更大的问题是通过dict键访问事物容易出错。以下代码将不会报错,但会执行错误的操作:

# oops, typo
stock_price['cosing_price'] = 103.06

最后,虽然我们可以为统一的字典进行类型注释:

prices: Dict[datetime.date, float] = {}

没有一种有用的方法来注释具有许多不同值类型的字典数据。因此,我们也失去了类型提示的力量。

作为一种替代方案,Python 包含一个namedtuple类,它类似于一个tuple,但具有命名的槽位:

from collections import namedtuple

StockPrice = namedtuple('StockPrice', ['symbol', 'date', 'closing_price'])
price = StockPrice('MSFT', datetime.date(2018, 12, 14), 106.03)

assert price.symbol == 'MSFT'
assert price.closing_price == 106.03

像常规的tuples一样,namedtuples 是不可变的,这意味着一旦创建就无法修改它们的值。偶尔这会成为我们的障碍,但大多数情况下这是件好事。

你会注意到我们还没有解决类型注解的问题。我们可以通过使用类型化的变体NamedTuple来解决:

from typing import NamedTuple

class StockPrice(NamedTuple):
    symbol: str
    date: datetime.date
    closing_price: float

    def is_high_tech(self) -> bool:
        """It's a class, so we can add methods too"""
        return self.symbol in ['MSFT', 'GOOG', 'FB', 'AMZN', 'AAPL']

price = StockPrice('MSFT', datetime.date(2018, 12, 14), 106.03)

assert price.symbol == 'MSFT'
assert price.closing_price == 106.03
assert price.is_high_tech()

现在你的编辑器可以帮助你,就像在图 10-5 中显示的那样。

有用的编辑器

图 10-5. 有用的编辑器
注意

很少有人以这种方式使用NamedTuple。但他们应该!

Dataclasses

Dataclasses 是NamedTuple的一种(某种程度上)可变版本。(我说“某种程度上”,因为NamedTuples 将它们的数据紧凑地表示为元组,而 dataclasses 是常规的 Python 类,只是为您自动生成了一些方法。)

注意

Dataclasses 在 Python 3.7 中是新功能。如果你使用的是旧版本,则本节对你无效。

语法与NamedTuple非常相似。但是,我们使用装饰器而不是从基类继承:

from dataclasses import dataclass

@dataclass
class StockPrice2:
    symbol: str
    date: datetime.date
    closing_price: float

    def is_high_tech(self) -> bool:
        """It's a class, so we can add methods too"""
        return self.symbol in ['MSFT', 'GOOG', 'FB', 'AMZN', 'AAPL']

price2 = StockPrice2('MSFT', datetime.date(2018, 12, 14), 106.03)

assert price2.symbol == 'MSFT'
assert price2.closing_price == 106.03
assert price2.is_high_tech()

正如前面提到的,最大的区别在于我们可以修改 dataclass 实例的值:

# stock split
price2.closing_price /= 2
assert price2.closing_price == 51.03

如果我们尝试修改NamedTuple版本的字段,我们会得到一个AttributeError

这也使我们容易受到我们希望通过不使用dict来避免的错误的影响:

# It's a regular class, so add new fields however you like!
price2.cosing_price = 75  # oops

我们不会使用 dataclasses,但你可能会在野外遇到它们。

清洁和操纵

现实世界的数据。通常你需要在使用之前对其进行一些处理。我们在第九章中看到了这方面的例子。在使用之前,我们必须将字符串转换为floatint。我们必须检查缺失值、异常值和错误数据。

以前,我们在使用数据之前就这样做了:

closing_price = float(row[2])

但是在一个我们可以测试的函数中进行解析可能更少出错:

from dateutil.parser import parse

def parse_row(row: List[str]) -> StockPrice:
    symbol, date, closing_price = row
    return StockPrice(symbol=symbol,
                      date=parse(date).date(),
                      closing_price=float(closing_price))

# Now test our function
stock = parse_row(["MSFT", "2018-12-14", "106.03"])

assert stock.symbol == "MSFT"
assert stock.date == datetime.date(2018, 12, 14)
assert stock.closing_price == 106.03

如果有错误数据怎么办?一个不实际代表数字的“浮点”值?也许你宁愿得到一个None而不是使程序崩溃?

from typing import Optional
import re

def try_parse_row(row: List[str]) -> Optional[StockPrice]:
    symbol, date_, closing_price_ = row

    # Stock symbol should be all capital letters
    if not re.match(r"^[A-Z]+$", symbol):
        return None

    try:
        date = parse(date_).date()
    except ValueError:
        return None

    try:
        closing_price = float(closing_price_)
    except ValueError:
        return None

    return StockPrice(symbol, date, closing_price)

# Should return None for errors
assert try_parse_row(["MSFT0", "2018-12-14", "106.03"]) is None
assert try_parse_row(["MSFT", "2018-12--14", "106.03"]) is None
assert try_parse_row(["MSFT", "2018-12-14", "x"]) is None

# But should return same as before if data is good
assert try_parse_row(["MSFT", "2018-12-14", "106.03"]) == stock

举个例子,如果我们有用逗号分隔的股票价格数据有错误:

AAPL,6/20/2014,90.91
MSFT,6/20/2014,41.68
FB,6/20/3014,64.5
AAPL,6/19/2014,91.86
MSFT,6/19/2014,n/a
FB,6/19/2014,64.34

现在我们可以只读取并返回有效的行了:

import csv

data: List[StockPrice] = []

with open("comma_delimited_stock_prices.csv") as f:
    reader = csv.reader(f)
    for row in reader:
        maybe_stock = try_parse_row(row)
        if maybe_stock is None:
            print(f"skipping invalid row: {row}")
        else:
            data.append(maybe_stock)

并决定我们想要如何处理无效数据。一般来说,三个选择是摆脱它们,返回到源头并尝试修复错误/丢失的数据,或者什么也不做,只是交叉手指。如果数百万行中有一行错误的数据,那么忽略它可能没问题。但是如果一半的行都有错误数据,那就是你需要解决的问题。

下一个好的步骤是检查异常值,使用“探索您的数据”中的技术或通过临时调查来进行。例如,你是否注意到股票文件中的一个日期的年份是 3014 年?这不会(必然)给你一个错误,但很明显是错误的,如果你不注意到它,你会得到混乱的结果。现实世界的数据集有缺失的小数点、额外的零、排版错误以及无数其他问题,你需要解决。(也许官方上不是你的工作,但还有谁会做呢?)

数据操作

数据科学家最重要的技能之一是数据操作。这更像是一种通用方法而不是特定的技术,所以我们只需通过几个示例来让你了解一下。

想象我们有一堆股票价格数据,看起来像这样:

data = [
    StockPrice(symbol='MSFT',
               date=datetime.date(2018, 12, 24),
               closing_price=106.03),
    # ...
]

让我们开始对这些数据提出问题。在此过程中,我们将尝试注意到我们正在做的事情,并抽象出一些工具,使操纵更容易。

例如,假设我们想知道 AAPL 的最高收盘价。让我们将这个问题分解成具体的步骤:

  1. 限制自己只看 AAPL 的行。

  2. 从每行中获取closing_price

  3. 获取那些价格的最大值。

我们可以一次完成所有三个任务使用推导:

max_aapl_price = max(stock_price.closing_price
                     for stock_price in data
                     if stock_price.symbol == "AAPL")

更一般地,我们可能想知道数据集中每支股票的最高收盘价。做到这一点的一种方法是:

  1. 创建一个dict来跟踪最高价格(我们将使用一个defaultdict,对于缺失值返回负无穷大,因为任何价格都将大于它)。

  2. 迭代我们的数据,更新它。

这是代码:

from collections import defaultdict

max_prices: Dict[str, float] = defaultdict(lambda: float('-inf'))

for sp in data:
    symbol, closing_price = sp.symbol, sp.closing_price
    if closing_price > max_prices[symbol]:
        max_prices[symbol] = closing_price

现在我们可以开始询问更复杂的问题,比如数据集中最大和最小的单日百分比变化是多少。百分比变化是price_today / price_yesterday - 1,这意味着我们需要一种将今天价格和昨天价格关联起来的方法。一种方法是按符号分组价格,然后在每个组内:

  1. 按日期排序价格。

  2. 使用zip获取(前一个,当前)对。

  3. 将这些对转换为新的“百分比变化”行。

让我们从按符号分组的价格开始:

from typing import List
from collections import defaultdict

# Collect the prices by symbol
prices: Dict[str, List[StockPrice]] = defaultdict(list)

for sp in data:
    prices[sp.symbol].append(sp)

由于价格是元组,它们将按字段顺序排序:首先按符号,然后按日期,最后按价格。这意味着如果我们有一些价格具有相同的符号,sort将按日期排序(然后按价格排序,但由于每个日期只有一个价格,所以这没有什么效果),这正是我们想要的。

# Order the prices by date
prices = {symbol: sorted(symbol_prices)
          for symbol, symbol_prices in prices.items()}

我们可以用它来计算一系列日对日的变化:

def pct_change(yesterday: StockPrice, today: StockPrice) -> float:
    return today.closing_price / yesterday.closing_price - 1

class DailyChange(NamedTuple):
    symbol: str
    date: datetime.date
    pct_change: float

def day_over_day_changes(prices: List[StockPrice]) -> List[DailyChange]:
    """
 Assumes prices are for one stock and are in order
 """
    return [DailyChange(symbol=today.symbol,
                        date=today.date,
                        pct_change=pct_change(yesterday, today))
            for yesterday, today in zip(prices, prices[1:])]

然后收集它们全部:

all_changes = [change
               for symbol_prices in prices.values()
               for change in day_over_day_changes(symbol_prices)]

在这一点上,找到最大值和最小值很容易:

max_change = max(all_changes, key=lambda change: change.pct_change)
# see e.g. http://news.cnet.com/2100-1001-202143.html
assert max_change.symbol == 'AAPL'
assert max_change.date == datetime.date(1997, 8, 6)
assert 0.33 < max_change.pct_change < 0.34

min_change = min(all_changes, key=lambda change: change.pct_change)
# see e.g. http://money.cnn.com/2000/09/29/markets/techwrap/
assert min_change.symbol == 'AAPL'
assert min_change.date == datetime.date(2000, 9, 29)
assert -0.52 < min_change.pct_change < -0.51

现在我们可以使用这个新的all_changes数据集来找出哪个月份最适合投资科技股。我们只需查看每月的平均每日变化:

changes_by_month: List[DailyChange] = {month: [] for month in range(1, 13)}

for change in all_changes:
    changes_by_month[change.date.month].append(change)

avg_daily_change = {
    month: sum(change.pct_change for change in changes) / len(changes)
    for month, changes in changes_by_month.items()
}

# October is the best month
assert avg_daily_change[10] == max(avg_daily_change.values())

在整本书中,我们将会进行这些操作,通常不会过多显式地提及它们。

重新缩放

许多技术对您数据的尺度很敏感。例如,想象一下,您有一个由数百名数据科学家的身高和体重组成的数据集,您试图识别体型的聚类

直觉上,我们希望聚类表示彼此附近的点,这意味着我们需要某种点之间距离的概念。我们已经有了欧氏distance函数,因此一个自然的方法可能是将(身高,体重)对视为二维空间中的点。考虑表 10-1 中列出的人员。

表 10-1. 身高和体重

人员 身高(英寸) 身高(厘米) 体重(磅)
A 63 160 150
B 67 170.2 160
C 70 177.8 171

如果我们用英寸测量身高,那么 B 的最近邻是 A:

from scratch.linear_algebra import distance

a_to_b = distance([63, 150], [67, 160])        # 10.77
a_to_c = distance([63, 150], [70, 171])        # 22.14
b_to_c = distance([67, 160], [70, 171])        # 11.40

然而,如果我们用厘米测量身高,那么 B 的最近邻将变为 C:

a_to_b = distance([160, 150], [170.2, 160])    # 14.28
a_to_c = distance([160, 150], [177.8, 171])    # 27.53
b_to_c = distance([170.2, 160], [177.8, 171])  # 13.37

显然,如果改变单位会导致结果发生变化,这是一个问题。因此,当维度不可比较时,我们有时会重新缩放我们的数据,使得每个维度的均值为 0,标准差为 1。这实际上消除了单位,将每个维度转换为“均值的标准偏差数”。

首先,我们需要计算每个位置的meanstandard_deviation

from typing import Tuple

from scratch.linear_algebra import vector_mean
from scratch.statistics import standard_deviation

def scale(data: List[Vector]) -> Tuple[Vector, Vector]:
    """returns the mean and standard deviation for each position"""
    dim = len(data[0])

    means = vector_mean(data)
    stdevs = [standard_deviation([vector[i] for vector in data])
              for i in range(dim)]

    return means, stdevs

vectors = [[-3, -1, 1], [-1, 0, 1], [1, 1, 1]]
means, stdevs = scale(vectors)
assert means == [-1, 0, 1]
assert stdevs == [2, 1, 0]

然后我们可以用它们创建一个新的数据集:

def rescale(data: List[Vector]) -> List[Vector]:
    """
 Rescales the input data so that each position has
 mean 0 and standard deviation 1\. (Leaves a position
 as is if its standard deviation is 0.)
 """
    dim = len(data[0])
    means, stdevs = scale(data)

    # Make a copy of each vector
    rescaled = [v[:] for v in data]

    for v in rescaled:
        for i in range(dim):
            if stdevs[i] > 0:
                v[i] = (v[i] - means[i]) / stdevs[i]

    return rescaled

当然,让我们写一个测试来确认rescale是否按我们想的那样工作:

means, stdevs = scale(rescale(vectors))
assert means == [0, 0, 1]
assert stdevs == [1, 1, 0]

如常,您需要根据自己的判断。例如,如果您将一个大量的身高和体重数据集筛选为只有身高在 69.5 英寸和 70.5 英寸之间的人,剩下的变化很可能(取决于您试图回答的问题)只是噪声,您可能不希望将其标准差与其他维度的偏差平等看待。

旁注:tqdm

经常我们会进行需要很长时间的计算。当您进行这样的工作时,您希望知道自己在取得进展并且预计需要等待多长时间。

一种方法是使用 tqdm 库,它生成自定义进度条。我们将在本书的其他部分中多次使用它,所以现在让我们学习一下它是如何工作的。

要开始使用,您需要安装它:

python -m pip install tqdm

你只需要知道几个特性。首先是,在 tqdm.tqdm 中包装的可迭代对象会生成一个进度条:

import tqdm

for i in tqdm.tqdm(range(100)):
    # do something slow
    _ = [random.random() for _ in range(1000000)]

这会生成一个类似于以下输出的结果:

 56%|████████████████████              | 56/100 [00:08<00:06,  6.49it/s]

特别地,它会显示循环的完成部分百分比(尽管如果您使用生成器,它无法这样做),已运行时间以及预计的剩余运行时间。

在这种情况下(我们只是包装了对 range 的调用),您可以直接使用 tqdm.trange

在其运行时,您还可以设置进度条的描述。要做到这一点,您需要在 with 语句中捕获 tqdm 迭代器:

from typing import List

def primes_up_to(n: int) -> List[int]:
    primes = [2]

    with tqdm.trange(3, n) as t:
        for i in t:
            # i is prime if no smaller prime divides it
            i_is_prime = not any(i % p == 0 for p in primes)
            if i_is_prime:
                primes.append(i)

            t.set_description(f"{len(primes)} primes")

    return primes

my_primes = primes_up_to(100_000)

这会添加一个如下描述,其中计数器会随着新的质数被发现而更新:

5116 primes:  50%|████████        | 49529/99997 [00:03<00:03, 15905.90it/s]

使用 tqdm 有时会使您的代码变得不稳定——有时屏幕重绘不良,有时循环会简单地挂起。如果您意外地将 tqdm 循环包装在另一个 tqdm 循环中,可能会发生奇怪的事情。尽管如此,通常它的好处超过这些缺点,因此在我们有运行缓慢的计算时,我们将尝试使用它。

降维

有时数据的“实际”(或有用)维度可能与我们拥有的维度不对应。例如,请考虑图示的数据集 Figure 10-6。

带有“错误”轴的数据

图 10-6. 带有“错误”轴的数据

数据中的大部分变化似乎沿着一个不对应于 x 轴或 y 轴的单一维度发生。

当情况如此时,我们可以使用一种称为主成分分析(PCA)的技术来提取尽可能多地捕获数据变化的一个或多个维度。

注意

在实践中,您不会在这样低维度的数据集上使用此技术。当您的数据集具有大量维度并且您希望找到捕获大部分变化的小子集时,降维大多数时候非常有用。不幸的是,在二维书籍格式中很难说明这种情况。

作为第一步,我们需要转换数据,使得每个维度的均值为 0:

from scratch.linear_algebra import subtract

def de_mean(data: List[Vector]) -> List[Vector]:
    """Recenters the data to have mean 0 in every dimension"""
    mean = vector_mean(data)
    return [subtract(vector, mean) for vector in data]

(如果我们不这样做,我们的技术可能会识别出均值本身,而不是数据中的变化。)

图 10-7 显示了去均值后的示例数据。

去均值后的 PCA 数据。

图 10-7. 去均值后的数据

现在,给定一个去均值的矩阵 X,我们可以问哪个方向捕捉了数据中的最大方差。

具体来说,给定一个方向 d(一个大小为 1 的向量),矩阵中的每一行 xd 方向上延伸 dot(x, d)。并且每个非零向量 w 确定一个方向,如果我们重新缩放它使其大小为 1:

from scratch.linear_algebra import magnitude

def direction(w: Vector) -> Vector:
    mag = magnitude(w)
    return [w_i / mag for w_i in w]

因此,给定一个非零向量 w,我们可以计算由 w 确定的数据集在方向上的方差:

from scratch.linear_algebra import dot

def directional_variance(data: List[Vector], w: Vector) -> float:
    """
 Returns the variance of x in the direction of w
 """
    w_dir = direction(w)
    return sum(dot(v, w_dir) ** 2 for v in data)

我们希望找到最大化这种方差的方向。我们可以使用梯度下降来实现这一点,只要我们有梯度函数:

def directional_variance_gradient(data: List[Vector], w: Vector) -> Vector:
    """
 The gradient of directional variance with respect to w
 """
    w_dir = direction(w)
    return [sum(2 * dot(v, w_dir) * v[i] for v in data)
            for i in range(len(w))]

现在,我们拥有的第一个主成分就是最大化directional_variance函数的方向:

from scratch.gradient_descent import gradient_step

def first_principal_component(data: List[Vector],
                              n: int = 100,
                              step_size: float = 0.1) -> Vector:
    # Start with a random guess
    guess = [1.0 for _ in data[0]]

    with tqdm.trange(n) as t:
        for _ in t:
            dv = directional_variance(data, guess)
            gradient = directional_variance_gradient(data, guess)
            guess = gradient_step(guess, gradient, step_size)
            t.set_description(f"dv: {dv:.3f}")

    return direction(guess)

在去均值的数据集上,这将返回方向 [0.924, 0.383],看起来捕捉了数据变化的主轴(图 10-8)。

带有第一个成分的 PCA 数据。

图 10-8. 第一个主成分

一旦找到了第一个主成分的方向,我们可以将数据投影到这个方向上,以找到该成分的值:

from scratch.linear_algebra import scalar_multiply

def project(v: Vector, w: Vector) -> Vector:
    """return the projection of v onto the direction w"""
    projection_length = dot(v, w)
    return scalar_multiply(projection_length, w)

如果我们想找到更多的成分,我们首先要从数据中移除投影:

from scratch.linear_algebra import subtract

def remove_projection_from_vector(v: Vector, w: Vector) -> Vector:
    """projects v onto w and subtracts the result from v"""
    return subtract(v, project(v, w))

def remove_projection(data: List[Vector], w: Vector) -> List[Vector]:
    return [remove_projection_from_vector(v, w) for v in data]

因为这个示例数据集仅有二维,在移除第一个成分后,剩下的有效是一维的(图 10-9)。

移除第一个主成分后的数据

图 10-9. 移除第一个主成分后的数据

在那一点上,通过在 remove_projection 的结果上重复这个过程,我们可以找到下一个主成分(图 10-10)。

在一个高维数据集上,我们可以迭代地找到我们想要的许多成分:

def pca(data: List[Vector], num_components: int) -> List[Vector]:
    components: List[Vector] = []
    for _ in range(num_components):
        component = first_principal_component(data)
        components.append(component)
        data = remove_projection(data, component)

    return components

然后我们可以将我们的数据转换到由这些成分张成的低维空间中:

def transform_vector(v: Vector, components: List[Vector]) -> Vector:
    return [dot(v, w) for w in components]

def transform(data: List[Vector], components: List[Vector]) -> List[Vector]:
    return [transform_vector(v, components) for v in data]

这种技术有几个原因很有价值。首先,它可以通过消除噪声维度和整合高度相关的维度来帮助我们清理数据。

前两个主成分。

图 10-10. 前两个主成分

第二,当我们提取出数据的低维表示后,我们可以使用多种在高维数据上效果不佳的技术。本书中将展示此类技术的示例。

同时,尽管这种技术可以帮助你建立更好的模型,但也可能使这些模型更难以解释。理解“每增加一年经验,平均增加 1 万美元的薪水”这样的结论很容易。但“第三主成分每增加 0.1,平均薪水增加 1 万美元”则更难理解。

进一步探索

  • 正如在 第九章 结尾提到的,pandas 可能是清洗、处理和操作数据的主要 Python 工具。本章我们手动完成的所有示例,使用 pandas 都可以更简单地实现。《Python 数据分析》(O’Reilly) 由 Wes McKinney 编写,可能是学习 pandas 最好的方式。

  • scikit-learn 提供了各种矩阵分解函数,包括 PCA。

第十一章:机器学习

我总是乐意学习,尽管我并不总是喜欢被教导。

温斯顿·丘吉尔

许多人想象数据科学主要是机器学习,认为数据科学家整天都在构建、训练和调整机器学习模型。(不过,很多这样想的人其实并不知道机器学习是什么。)事实上,数据科学主要是将业务问题转化为数据问题,收集数据、理解数据、清理数据和格式化数据,而机器学习几乎成了事后的事情。尽管如此,它是一个有趣且必不可少的事后步骤,你基本上必须了解它才能从事数据科学工作。

建模

在我们讨论机器学习之前,我们需要谈谈模型

什么是模型?简单来说,它是描述不同变量之间数学(或概率)关系的规范。

例如,如果你正在为你的社交网络站点筹集资金,你可能会建立一个商业模型(通常在电子表格中),该模型接受“用户数量”、“每用户广告收入”和“员工数量”等输入,并输出未来几年的年度利润。烹饪食谱涉及一个模型,将“用餐者数量”和“饥饿程度”等输入与所需的食材量联系起来。如果你曾经在电视上观看扑克比赛,你会知道每位玩家的“获胜概率”是根据模型实时估算的,该模型考虑了到目前为止已经公开的牌和牌堆中牌的分布。

商业模型可能基于简单的数学关系:利润等于收入减去支出,收入等于销售单位数乘以平均价格,等等。食谱模型可能基于试错法——有人在厨房尝试不同的配料组合,直到找到自己喜欢的那一种。而扑克模型则基于概率论、扑克规则以及关于发牌随机过程的一些合理假设。

什么是机器学习?

每个人都有自己的确切定义,但我们将使用机器学习来指代从数据中创建和使用模型的过程。在其他情境下,这可能被称为预测建模数据挖掘,但我们将坚持使用机器学习。通常,我们的目标是利用现有数据开发模型,用于预测新数据的各种结果,比如:

  • 是否是垃圾邮件

  • 信用卡交易是否属于欺诈

  • 哪个广告最有可能被购物者点击

  • 哪支橄榄球队会赢得超级碗

我们将讨论监督模型(其中有一组带有正确答案标签的数据可供学习)和无监督模型(其中没有这些标签)两种模型。还有其他各种类型,比如半监督(其中只有部分数据被标记)、在线(模型需要持续调整以适应新到达的数据)和强化(在做出一系列预测后,模型会收到一个指示其表现如何的信号),这些我们在本书中不会涉及。

现在,即使在最简单的情况下,也有可能有整个宇宙的模型可以描述我们感兴趣的关系。在大多数情况下,我们会自己选择一个参数化的模型家族,然后使用数据来学习某种方式上最优的参数。

例如,我们可能假设一个人的身高(大致上)是他的体重的线性函数,然后使用数据来学习这个线性函数是什么。或者我们可能认为决策树是诊断我们的患者患有哪些疾病的好方法,然后使用数据来学习“最优”这样的树。在本书的其余部分,我们将研究我们可以学习的不同模型家族。

但在此之前,我们需要更好地理解机器学习的基本原理。在本章的其余部分,我们将讨论一些基本概念,然后再讨论模型本身。

过拟合和欠拟合

在机器学习中一个常见的危险是过拟合——生成一个在您训练它的数据上表现良好但泛化性能差的模型。这可能涉及学习数据中的噪音。或者可能涉及学习识别特定输入,而不是实际上对所需输出有预测能力的因素。

这种情况的另一面是拟合不足——产生一个即使在训练数据上表现也不好的模型,尽管通常在这种情况下,您会认为您的模型还不够好,继续寻找更好的模型。

在图 11-1 中,我拟合了三个多项式到一组数据样本中。(不用担心具体方法;我们会在后面的章节中介绍。)

过拟合和欠拟合。

图 11-1. 过拟合和欠拟合

水平线显示了最佳拟合度为 0(即常数)的多项式。它严重拟合不足训练数据。最佳拟合度为 9(即 10 参数)的多项式恰好通过每个训练数据点,但它非常严重过拟合;如果我们再选几个数据点,它很可能会严重偏离。而一次拟合度的线条达到了很好的平衡;它非常接近每个点,如果这些数据是代表性的,那么这条线也很可能接近新数据点。

显然,过于复杂的模型会导致过拟合,并且在训练数据之外不能很好地泛化。那么,我们如何确保我们的模型不会太复杂呢?最基本的方法涉及使用不同的数据来训练模型和测试模型。

这样做的最简单方法是将数据集分割,例如,将其的三分之二用于训练模型,之后我们可以在剩余的三分之一上测量模型的性能:

import random
from typing import TypeVar, List, Tuple
X = TypeVar('X')  # generic type to represent a data point

def split_data(data: List[X], prob: float) -> Tuple[List[X], List[X]]:
    """Split data into fractions [prob, 1 - prob]"""
    data = data[:]                    # Make a shallow copy
    random.shuffle(data)              # because shuffle modifies the list.
    cut = int(len(data) * prob)       # Use prob to find a cutoff
    return data[:cut], data[cut:]     # and split the shuffled list there.

data = [n for n in range(1000)]
train, test = split_data(data, 0.75)

# The proportions should be correct
assert len(train) == 750
assert len(test) == 250

# And the original data should be preserved (in some order)
assert sorted(train + test) == data

通常情况下,我们会有成对的输入变量和输出变量。在这种情况下,我们需要确保将对应的值放在训练数据或测试数据中:

Y = TypeVar('Y')  # generic type to represent output variables

def train_test_split(xs: List[X],
                     ys: List[Y],
                     test_pct: float) -> Tuple[List[X], List[X], List[Y],
                                                                 List[Y]]:
    # Generate the indices and split them
    idxs = [i for i in range(len(xs))]
    train_idxs, test_idxs = split_data(idxs, 1 - test_pct)

    return ([xs[i] for i in train_idxs],  # x_train
            [xs[i] for i in test_idxs],   # x_test
            [ys[i] for i in train_idxs],  # y_train
            [ys[i] for i in test_idxs])   # y_test

如常,我们要确保我们的代码能够正常工作:

xs = [x for x in range(1000)]  # xs are 1 ... 1000
ys = [2 * x for x in xs]       # each y_i is twice x_i
x_train, x_test, y_train, y_test = train_test_split(xs, ys, 0.25)

# Check that the proportions are correct
assert len(x_train) == len(y_train) == 750
assert len(x_test) == len(y_test) == 250

# Check that the corresponding data points are paired correctly
assert all(y == 2 * x for x, y in zip(x_train, y_train))
assert all(y == 2 * x for x, y in zip(x_test, y_test))

之后,您可以做一些像这样的事情:

model = SomeKindOfModel()
x_train, x_test, y_train, y_test = train_test_split(xs, ys, 0.33)
model.train(x_train, y_train)
performance = model.test(x_test, y_test)

如果模型对训练数据过拟合,那么它在(完全分开的)测试数据上的表现希望会非常差。换句话说,如果它在测试数据上表现良好,那么您可以更有信心它是在适应而不是过拟合

然而,有几种情况可能会出错。

第一种情况是测试数据和训练数据中存在的常见模式不会推广到更大的数据集中。

例如,想象一下,您的数据集包含用户活动,每个用户每周一行。在这种情况下,大多数用户会出现在训练数据和测试数据中,并且某些模型可能会学习识别用户而不是发现涉及属性的关系。这并不是一个很大的担忧,尽管我曾经遇到过一次。

更大的问题是,如果您不仅用于评估模型而且用于选择多个模型。在这种情况下,尽管每个单独的模型可能不会过拟合,“选择在测试集上表现最佳的模型”是一个元训练,使得测试集充当第二个训练集。(当然,在测试集上表现最佳的模型在测试集上表现良好。)

在这种情况下,您应该将数据分为三部分:用于构建模型的训练集,用于在训练后的模型中进行选择的验证集,以及用于评估最终模型的测试集。

正确性

当我不从事数据科学时,我涉足医学。在业余时间里,我想出了一种廉价的、无创的测试方法,可以给新生儿做,预测——准确率超过 98%——新生儿是否会患白血病。我的律师说服我这个测试方法无法申请专利,所以我会在这里和大家分享详细信息:只有当宝宝被命名为卢克(听起来有点像“白血病”)时,预测白血病。

如我们所见,这个测试确实有超过 98%的准确率。然而,这是一个非常愚蠢的测试,很好地说明了为什么我们通常不使用“准确性”来衡量(二元分类)模型的好坏。

想象构建一个用于进行二进制判断的模型。这封邮件是垃圾邮件吗?我们应该雇佣这位候选人吗?这位空中旅客是不是秘密的恐怖分子?

针对一组标记数据和这样一个预测模型,每个数据点都属于四个类别之一:

真阳性

“此消息是垃圾邮件,我们正确预测了垃圾邮件。”

假阳性(第一类错误)

“此消息不是垃圾邮件,但我们预测了垃圾邮件。”

假阴性(第二类错误)

“此消息是垃圾邮件,但我们预测了非垃圾邮件。”

真阴性

“此消息不是垃圾邮件,我们正确预测了非垃圾邮件。”

我们通常将这些表示为混淆矩阵中的计数:

垃圾邮件 非垃圾邮件
预测“垃圾邮件” 真阳性 假阳性
预测“非垃圾邮件” 假阴性 真阴性

让我们看看我的白血病测试如何符合这个框架。 近年来,大约每 1,000 名婴儿中有 5 名被命名为卢克。 白血病的终身患病率约为 1.4%,或每 1,000 人中有 14 人

如果我们相信这两个因素是独立的,并将我的“卢克是用于白血病检测”的测试应用于 1 百万人,我们预计会看到一个混淆矩阵,如下所示:

白血病 无白血病 总计
“卢克” 70 4,930 5,000
非“卢克” 13,930 981,070 995,000
总计 14,000 986,000 1,000,000

我们可以使用这些来计算有关模型性能的各种统计信息。 例如,准确度 定义为正确预测的分数的比例:

def accuracy(tp: int, fp: int, fn: int, tn: int) -> float:
    correct = tp + tn
    total = tp + fp + fn + tn
    return correct / total

assert accuracy(70, 4930, 13930, 981070) == 0.98114

这似乎是一个相当令人印象深刻的数字。 但显然这不是一个好的测试,这意味着我们可能不应该对原始准确性赋予很高的信任。

通常会查看精确度召回率的组合。 精确度衡量我们的阳性预测的准确性:

def precision(tp: int, fp: int, fn: int, tn: int) -> float:
    return tp / (tp + fp)

assert precision(70, 4930, 13930, 981070) == 0.014

召回率衡量了我们的模型识别出的阳性的分数:

def recall(tp: int, fp: int, fn: int, tn: int) -> float:
    return tp / (tp + fn)

assert recall(70, 4930, 13930, 981070) == 0.005

这两个数字都很糟糕,反映出这是一个糟糕的模型。

有时精确度和召回率会结合成F1 分数,其定义为:

def f1_score(tp: int, fp: int, fn: int, tn: int) -> float:
    p = precision(tp, fp, fn, tn)
    r = recall(tp, fp, fn, tn)

    return 2 * p * r / (p + r)

这是调和平均 精度和召回率,必然位于它们之间。

通常,模型的选择涉及精确度和召回率之间的权衡。 当模型在稍微有信心时预测“是”可能会具有很高的召回率但较低的精确度; 仅当模型极度自信时才预测“是”可能会具有较低的召回率和较高的精确度。

或者,您可以将其视为假阳性和假阴性之间的权衡。 说“是”的次数太多会产生大量的假阳性; 说“不”太多会产生大量的假阴性。

想象一下,白血病有 10 个风险因素,而且你拥有的风险因素越多,患白血病的可能性就越大。在这种情况下,你可以想象一系列测试:“如果至少有一个风险因素则预测患白血病”,“如果至少有两个风险因素则预测患白血病”,依此类推。随着阈值的提高,测试的准确性增加(因为拥有更多风险因素的人更有可能患病),而召回率降低(因为越来越少最终患病者将满足阈值)。在这种情况下,选择正确的阈值是找到正确权衡的问题。

偏差-方差权衡

另一种思考过拟合问题的方式是将其视为偏差和方差之间的权衡。

这两者都是在假设你会在来自同一较大总体的不同训练数据集上多次重新训练模型时会发生的情况的度量。

例如,“过拟合和欠拟合”中的零阶模型在几乎任何训练集上都会犯很多错误(从同一总体中抽取),这意味着它有很高的偏差。然而,任意选择的两个训练集应该产生相似的模型(因为任意选择的两个训练集应该具有相似的平均值)。所以我们说它的方差很低。高偏差和低方差通常对应欠拟合。

另一方面,九阶模型完美地适应了训练集。它的偏差非常低,但方差非常高(因为任意两个训练集可能会产生非常不同的模型)。这对应于过拟合。

以这种方式思考模型问题可以帮助你弄清楚当你的模型效果不佳时该怎么做。

如果你的模型存在高偏差(即使在训练数据上表现也很差),可以尝试的一种方法是添加更多特征。从“过拟合和欠拟合”中的零阶模型转换为一阶模型是一个很大的改进。

如果你的模型方差很高,你可以类似地删除特征。但另一个解决方案是获取更多数据(如果可能的话)。

在图 11-2 中,我们将一个九阶多项式拟合到不同大小的样本上。基于 10 个数据点进行的模型拟合到处都是,正如我们之前看到的。如果我们改为在 100 个数据点上训练,过拟合就会减少很多。而在 1,000 个数据点上训练的模型看起来与一阶模型非常相似。保持模型复杂性恒定,拥有的数据越多,过拟合就越困难。另一方面,更多的数据对偏差没有帮助。如果你的模型没有使用足够的特征来捕获数据的规律,那么扔更多数据进去是没有帮助的。

通过增加数据减少方差。

图 11-2. 通过增加数据减少方差

特征提取和选择

正如前面提到的,当你的数据没有足够的特征时,你的模型很可能会欠拟合。而当你的数据有太多特征时,很容易过拟合。但特征是什么,它们从哪里来呢?

特征就是我们向模型提供的任何输入。

在最简单的情况下,特征只是简单地给出。如果你想根据某人的工作经验预测她的薪水,那么工作经验就是你唯一拥有的特征。(尽管正如我们在“过拟合和欠拟合”中看到的那样,如果这有助于构建更好的模型,你可能还会考虑添加工作经验的平方、立方等。)

随着数据变得更加复杂,事情变得更加有趣。想象一下试图构建一个垃圾邮件过滤器来预测邮件是否是垃圾的情况。大多数模型不知道如何处理原始邮件,因为它只是一堆文本。你需要提取特征。例如:

  • 邮件中是否包含Viagra一词?

  • 字母d出现了多少次?

  • 发件人的域名是什么?

对于像这里第一个问题的答案,答案很简单,是一个是或否的问题,我们通常将其编码为 1 或 0。第二个问题是一个数字。第三个问题是从一组离散选项中选择的一个选项。

几乎总是,我们会从数据中提取属于这三类之一的特征。此外,我们拥有的特征类型限制了我们可以使用的模型类型。

  • 我们将在第十三章中构建的朴素贝叶斯分类器适用于像前面列表中的第一个这样的是或否特征。

  • 我们将在第 14 和第十六章中学习的回归模型需要数值特征(可能包括虚拟变量,即 0 和 1)。

  • 我们将在第十七章中探讨的决策树可以处理数值或分类数据。

虽然在垃圾邮件过滤器示例中我们寻找创建特征的方法,但有时我们会寻找删除特征的方法。

例如,你的输入可能是几百个数字的向量。根据情况,将这些特征简化为几个重要的维度可能是合适的(正如在“降维”中所示),然后仅使用这少量的特征。或者可能适合使用一种技术(如我们将在“正则化”中看到的那样),该技术惩罚使用更多特征的模型。

我们如何选择特征?这就是经验和领域专业知识结合起来发挥作用的地方。如果你收到了大量的邮件,那么你可能会意识到某些词语的出现可能是垃圾邮件的良好指标。而你可能还会觉得字母d的数量可能不是衡量邮件是否是垃圾的好指标。但总的来说,你必须尝试不同的方法,这也是乐趣的一部分。

进一步探索

  • 继续阅读!接下来的几章讲述不同类型的机器学习模型。

  • Coursera 的机器学习课程是最早的大规模在线开放课程(MOOC),是深入了解机器学习基础知识的好地方。

  • 统计学习的要素,作者是 Jerome H. Friedman、Robert Tibshirani 和 Trevor Hastie(Springer),是一本可以免费在线下载的经典教材。但请注意:它非常数学化。

第十二章:k-最近邻

如果你想要惹恼你的邻居,就告诉他们关于他们的真相。

皮耶特罗·阿雷蒂诺

想象一下,你试图预测我在下次总统选举中的投票方式。如果你对我一无所知(并且如果你有数据的话),一个明智的方法是看看我的邻居打算如何投票。像我一样住在西雅图,我的邻居们无一例外地计划投票给民主党候选人,这表明“民主党候选人”对我来说也是一个不错的猜测。

现在想象一下,你不仅了解我的地理位置,还知道我的年龄、收入、有几个孩子等等。在我行为受这些因素影响(或者说特征化)的程度上,只看那些在所有这些维度中与我接近的邻居,似乎比看所有邻居更有可能是一个更好的预测器。这就是最近邻分类背后的思想。

模型

最近邻是最简单的预测模型之一。它不做任何数学假设,也不需要任何重型设备。它唯一需要的是:

  • 某种距离的概念

  • 一个假设是相互接近的点是相似的。

在本书中,我们将看到的大多数技术都是整体看待数据集以便学习数据中的模式。而最近邻则故意忽略了很多信息,因为对于每个新点的预测仅依赖于最接近它的少数几个点。

此外,最近邻可能不会帮助你理解你正在研究的现象的驱动因素。根据我的邻居的投票预测我的投票并不能告诉你关于我为什么投票的原因,而一些基于(比如)我的收入和婚姻状况预测我的投票的替代模型可能会很好地做到这一点。

在一般情况下,我们有一些数据点和相应的标签集合。标签可以是TrueFalse,表示每个输入是否满足某些条件,比如“是垃圾邮件?”或“有毒?”或“看起来有趣吗?”或者它们可以是类别,比如电影评级(G,PG,PG-13,R,NC-17)。或者它们可以是总统候选人的名字。或者它们可以是喜欢的编程语言。

在我们的情况下,数据点将是向量,这意味着我们可以使用第四章中的distance函数。

假设我们选择了一个像 3 或 5 这样的数字k。那么,当我们想要对一些新的数据点进行分类时,我们找到k个最近的带标签点,并让它们对新的输出进行投票。

为此,我们需要一个计票的函数。一种可能性是:

from typing import List
from collections import Counter

def raw_majority_vote(labels: List[str]) -> str:
    votes = Counter(labels)
    winner, _ = votes.most_common(1)[0]
    return winner

assert raw_majority_vote(['a', 'b', 'c', 'b']) == 'b'

但这并不会处理带有智能的平局情况。例如,想象我们正在评分电影,而最近的五部电影分别被评为 G、G、PG、PG 和 R。那么 G 有两票,PG 也有两票。在这种情况下,我们有几个选项:

  • 随机挑选一个赢家。

  • 通过距离加权投票并选择加权赢家。

  • 减少k直到找到唯一的赢家。

我们将实现第三种方法:

def majority_vote(labels: List[str]) -> str:
    """Assumes that labels are ordered from nearest to farthest."""
    vote_counts = Counter(labels)
    winner, winner_count = vote_counts.most_common(1)[0]
    num_winners = len([count
                       for count in vote_counts.values()
                       if count == winner_count])

    if num_winners == 1:
        return winner                     # unique winner, so return it
    else:
        return majority_vote(labels[:-1]) # try again without the farthest

# Tie, so look at first 4, then 'b'
assert majority_vote(['a', 'b', 'c', 'b', 'a']) == 'b'

这种方法肯定最终会奏效,因为在最坏的情况下,我们最终只需一个标签,此时那个标签会获胜。

使用这个函数很容易创建一个分类器:

from typing import NamedTuple
from scratch.linear_algebra import Vector, distance

class LabeledPoint(NamedTuple):
    point: Vector
    label: str

def knn_classify(k: int,
                 labeled_points: List[LabeledPoint],
                 new_point: Vector) -> str:

    # Order the labeled points from nearest to farthest.
    by_distance = sorted(labeled_points,
                         key=lambda lp: distance(lp.point, new_point))

    # Find the labels for the k closest
    k_nearest_labels = [lp.label for lp in by_distance[:k]]

    # and let them vote.
    return majority_vote(k_nearest_labels)

让我们看看这是如何工作的。

示例:鸢尾花数据集

Iris 数据集是机器学习的重要数据集。它包含了 150 朵花的测量数据,代表三种鸢尾花物种。对于每朵花,我们有它的花瓣长度、花瓣宽度、萼片长度和萼片宽度,以及它的物种。你可以从https://archive.ics.uci.edu/ml/datasets/iris下载:

import requests

data = requests.get(
  "https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data"
)

with open('iris.dat', 'w') as f:
    f.write(data.text)

数据是逗号分隔的,包含字段:

sepal_length, sepal_width, petal_length, petal_width, class

例如,第一行看起来像:

5.1,3.5,1.4,0.2,Iris-setosa

在这一节中,我们将尝试构建一个模型,可以从前四个测量值预测类别(即物种)。

首先,让我们加载并探索数据。我们的最近邻函数期望一个LabeledPoint,所以让我们用这种方式表示我们的数据:

from typing import Dict
import csv
from collections import defaultdict

def parse_iris_row(row: List[str]) -> LabeledPoint:
    """
 sepal_length, sepal_width, petal_length, petal_width, class
 """
    measurements = [float(value) for value in row[:-1]]
    # class is e.g. "Iris-virginica"; we just want "virginica"
    label = row[-1].split("-")[-1]

    return LabeledPoint(measurements, label)

with open('iris.data') as f:
    reader = csv.reader(f)
    iris_data = [parse_iris_row(row) for row in reader]

# We'll also group just the points by species/label so we can plot them
points_by_species: Dict[str, List[Vector]] = defaultdict(list)
for iris in iris_data:
    points_by_species[iris.label].append(iris.point)

我们希望绘制测量结果,以便查看它们按物种的变化。不幸的是,它们是四维的,这使得绘图变得棘手。我们可以做的一件事是查看每一对测量的散点图(图 12-1)。我不会解释所有的细节,但这是对 matplotlib 更复杂用法的很好示例,所以值得学习:

from matplotlib import pyplot as plt
metrics = ['sepal length', 'sepal width', 'petal length', 'petal width']
pairs = [(i, j) for i in range(4) for j in range(4) if i < j]
marks = ['+', '.', 'x']  # we have 3 classes, so 3 markers

fig, ax = plt.subplots(2, 3)

for row in range(2):
    for col in range(3):
        i, j = pairs[3 * row + col]
        ax[row][col].set_title(f"{metrics[i]} vs {metrics[j]}", fontsize=8)
        ax[row][col].set_xticks([])
        ax[row][col].set_yticks([])

        for mark, (species, points) in zip(marks, points_by_species.items()):
            xs = [point[i] for point in points]
            ys = [point[j] for point in points]
            ax[row][col].scatter(xs, ys, marker=mark, label=species)

ax[-1][-1].legend(loc='lower right', prop={'size': 6})
plt.show()

鸢尾花散点图

图 12-1. 鸢尾花散点图

如果你看这些图,看起来测量结果确实按物种聚类。例如,仅看萼片长度和萼片宽度,你可能无法区分鸢尾花维吉尼亚。但一旦加入花瓣长度和宽度,似乎你应该能够根据最近邻来预测物种。

首先,让我们将数据分成测试集和训练集:

import random
from scratch.machine_learning import split_data

random.seed(12)
iris_train, iris_test = split_data(iris_data, 0.70)
assert len(iris_train) == 0.7 * 150
assert len(iris_test) == 0.3 * 150

训练集将是我们用来分类测试集中点的“邻居”。我们只需选择一个k值,即获得投票权的邻居数。如果太小(考虑k = 1),我们让离群值影响过大;如果太大(考虑k = 105),我们只是预测数据集中最常见的类别。

在真实的应用中(和更多数据),我们可能会创建一个单独的验证集,并用它来选择k。在这里我们只使用k = 5:

from typing import Tuple

# track how many times we see (predicted, actual)
confusion_matrix: Dict[Tuple[str, str], int] = defaultdict(int)
num_correct = 0

for iris in iris_test:
    predicted = knn_classify(5, iris_train, iris.point)
    actual = iris.label

    if predicted == actual:
        num_correct += 1

    confusion_matrix[(predicted, actual)] += 1

pct_correct = num_correct / len(iris_test)
print(pct_correct, confusion_matrix)

在这个简单的数据集上,模型几乎完美地预测了。有一个鸢尾花,它预测为维吉尼亚,但除此之外其他都是完全正确的。

维度灾难

在高维空间中,“k”最近邻算法在处理高维数据时遇到麻烦,这要归因于“维度的诅咒”,其核心问题在于高维空间是广阔的。高维空间中的点往往彼此之间并不接近。通过在各种维度中随机生成“d”维“单位立方体”中的点对,并计算它们之间的距离,可以看出这一点。

生成随机点现在应该是驾轻就熟了:

def random_point(dim: int) -> Vector:
    return [random.random() for _ in range(dim)]

编写一个生成距离的函数也是一样的:

def random_distances(dim: int, num_pairs: int) -> List[float]:
    return [distance(random_point(dim), random_point(dim))
            for _ in range(num_pairs)]

对于从 1 到 100 的每个维度,我们将计算 10,000 个距离,并使用这些距离计算点之间的平均距离以及每个维度中点之间的最小距离(参见图 12-2):

import tqdm
dimensions = range(1, 101)

avg_distances = []
min_distances = []

random.seed(0)
for dim in tqdm.tqdm(dimensions, desc="Curse of Dimensionality"):
    distances = random_distances(dim, 10000)      # 10,000 random pairs
    avg_distances.append(sum(distances) / 10000)  # track the average
    min_distances.append(min(distances))          # track the minimum

维度的诅咒。

图 12-2。维度的诅咒

随着维度的增加,点之间的平均距离也增加。但更为问题的是最近距离与平均距离之间的比率(参见图 12-3):

min_avg_ratio = [min_dist / avg_dist
                 for min_dist, avg_dist in zip(min_distances, avg_distances)]

再谈维度的诅咒。

图 12-3。再谈维度的诅咒

在低维数据集中,最近的点往往比平均距离要接近得多。但是只有当两个点在每个维度上都接近时,这两个点才是接近的,而每增加一个维度——即使只是噪音——都是使每个点与其他每个点的距离更远的机会。当你有很多维度时,最接近的点可能并不比平均距离要接近,所以两个点接近并不意味着太多(除非你的数据具有使其表现得像低维度的大量结构)。

对问题的另一种思考方式涉及到更高维度空间的稀疏性。

如果你在 0 和 1 之间随机选择 50 个数字,你可能会得到单位区间的一个相当好的样本(参见图 12-4)。

一维空间中的 50 个随机点。

图 12-4。一维空间中的 50 个随机点

如果你在单位正方形中随机选择 50 个点,你将得到更少的覆盖(参见图 12-5)。

二维空间中的 50 个随机点。

图 12-5。二维空间中的 50 个随机点

在三维空间中,覆盖更少(参见图 12-6)。

matplotlib 对于四维图表的呈现并不好,所以这是我们所能达到的最远的地方,但你已经可以看到开始出现大量空白区域,没有点靠近它们。在更多维度中——除非你得到指数级更多的数据——这些大量空白区域代表了远离所有你想要用于预测的点的区域。

因此,如果你尝试在更高维度中使用最近邻方法,最好先进行某种降维处理。

三维空间中的 50 个随机点。

图 12-6。三维空间中的 50 个随机点

进一步探索

scikit-learn 有许多最近邻模型。

第十三章:贝叶斯朴素分类

为心灵保持天真,为思想保持成熟。

阿纳托尔·法朗士

如果人们不能进行社交,那么社交网络就没什么用了。因此,DataSciencester 拥有一个受欢迎的功能,允许会员发送消息给其他会员。虽然大多数会员是负责任的公民,只发送受欢迎的“最近好吗?”消息,但一些不法分子坚持不懈地向其他成员发送关于致富计划、无需处方的药物和盈利数据科学证书项目的垃圾邮件。您的用户已经开始抱怨,因此消息副总裁要求您使用数据科学找出一种过滤这些垃圾邮件的方法。

一个非常愚蠢的垃圾邮件过滤器

假设一个“宇宙”,由从所有可能消息中随机选择的消息组成。设S为事件“消息是垃圾邮件”,B为事件“消息包含词bitcoin”。贝叶斯定理告诉我们,包含词bitcoin的消息是垃圾邮件的条件概率是:

P ( S | B ) = [ P ( B | S ) P ( S ) ] / [ P ( B | S ) P ( S ) + P ( B | ¬ S ) P ( ¬ S ) ]

分子是消息是垃圾邮件且包含bitcoin的概率,而分母只是消息包含bitcoin的概率。因此,您可以将这个计算视为简单地表示为垃圾邮件的bitcoin消息的比例。

如果我们有大量的已知是垃圾邮件的消息和大量的已知不是垃圾邮件的消息,那么我们可以很容易地估计P(B|S)和P(B|¬S)。如果我们进一步假设任何消息都有相等的可能性是垃圾邮件或不是垃圾邮件(所以P(S) = P(¬S) = 0.5),那么:

P ( S | B ) = P ( B | S ) / [ P ( B | S ) + P ( B | ¬ S ) ]

例如,如果垃圾邮件中有 50%的消息包含bitcoin这个词,而非垃圾邮件中只有 1%的消息包含,那么包含bitcoin的任意邮件是垃圾邮件的概率是:

0 . 5 / ( 0 . 5 + 0 . 01 ) = 98 %

一个更复杂的垃圾邮件过滤器

现在想象我们有一个包含许多词w[1] ... w[n]的词汇表。为了将其转化为概率理论的领域,我们将X[i]写成事件“消息包含词w[i]”。还想象一下(通过某种未指定的过程),我们已经得出了一个估计值P(X[i]|S),表示垃圾邮件消息包含第i个词的概率,以及类似的估计P(X[i]|¬S),表示非垃圾邮件消息包含第i个词的概率。

贝叶斯方法的关键在于做出(大胆的)假设,即每个单词的存在(或不存在)在消息是垃圾邮件与否的条件下是独立的。直观地说,这个假设意味着知道某个垃圾邮件消息是否包含词bitcoin并不会告诉你同一消息是否包含词rolex。用数学术语来说,这意味着:

P ( X 1 = x 1 , . . . , X n = x n | S ) = P ( X 1 = x 1 | S ) × × P ( X n = x n | S )

这是一个极端的假设。(这个技术名称中带有天真也是有原因的。)假设我们的词汇表仅仅包括比特币劳力士这两个词,而一半的垃圾邮件是关于“赚取比特币”,另一半是关于“正品劳力士”。在这种情况下,朴素贝叶斯估计一封垃圾邮件包含比特币劳力士的概率是:

P ( X 1 = 1 , X 2 = 1 | S ) = P ( X 1 = 1 | S ) P ( X 2 = 1 | S ) = . 5 × . 5 = . 25

因为我们假设了比特币劳力士实际上从不会同时出现的知识。尽管这种假设的不现实性,这种模型通常表现良好,并且在实际的垃圾邮件过滤器中历史悠久地使用。

我们用于“仅比特币”垃圾邮件过滤器的相同贝叶斯定理推理告诉我们可以使用以下方程来计算一封邮件是垃圾邮件的概率:

P ( S | X = x ) = P ( X = x | S ) / [ P ( X = x | S ) + P ( X = x | ¬ S ) ]

朴素贝叶斯假设允许我们通过简单地将每个词汇单词的概率估计相乘来计算右侧的每个概率。

实际操作中,通常要避免将大量概率相乘,以防止下溢问题,即计算机无法处理太接近 0 的浮点数。从代数中记得log ( a b ) = log a + log bexp( log x ) = x ,我们通常将p 1 p n 计算为等效的(但更友好于浮点数的)形式:

exp( log ( p 1 ) + + log ( p n ) )

唯一剩下的挑战是估计P ( Xi | S )P ( Xi | ¬ S ) ,即垃圾邮件(或非垃圾邮件)包含单词w i 的概率。如果我们有大量标记为垃圾邮件和非垃圾邮件的“训练”邮件,一个明显的第一次尝试是简单地估计P ( X i | S ) 为仅仅是包含单词w i 的垃圾邮件的比例。

不过,这会造成一个很大的问题。想象一下,在我们的训练集中,词汇表中的单词data只出现在非垃圾邮件中。然后我们会估计P ( data | S ) = 0。结果是,我们的朴素贝叶斯分类器将始终为包含单词data任何消息分配垃圾邮件概率 0,即使是像“免费比特币和正品劳力士手表数据”这样的消息也是如此。为了避免这个问题,我们通常使用某种平滑技术。

特别是,我们将选择一个伪计数——k——并估计在垃圾邮件中看到第i个单词的概率为:

P ( X i | S ) = ( k + number of spams containing w i ) / ( 2 k + number of spams )

我们对P ( X i | ¬ S )也是类似的。也就是说,当计算第i个单词的垃圾邮件概率时,我们假设我们还看到了包含该单词的k个额外的非垃圾邮件和k个额外的不包含该单词的非垃圾邮件。

例如,如果data出现在 0/98 封垃圾邮件中,如果k为 1,则我们将P(data|S)估计为 1/100 = 0.01,这使得我们的分类器仍然可以为包含单词data的消息分配一些非零的垃圾邮件概率。

实施

现在我们有了构建分类器所需的所有组件。首先,让我们创建一个简单的函数,将消息标记为不同的单词。我们首先将每条消息转换为小写,然后使用re.findall提取由字母、数字和撇号组成的“单词”。最后,我们将使用set获取不同的单词:

from typing import Set
import re

def tokenize(text: str) -> Set[str]:
    text = text.lower()                         # Convert to lowercase,
    all_words = re.findall("[a-z0-9']+", text)  # extract the words, and
    return set(all_words)                       # remove duplicates.

assert tokenize("Data Science is science") == {"data", "science", "is"}

我们还将为我们的训练数据定义一个类型:

from typing import NamedTuple

class Message(NamedTuple):
    text: str
    is_spam: bool

由于我们的分类器需要跟踪来自训练数据的标记、计数和标签,我们将其构建为一个类。按照惯例,我们将非垃圾邮件称为ham邮件。

构造函数只需要一个参数,即计算概率时使用的伪计数。它还初始化一个空的标记集合、计数器来跟踪在垃圾邮件和非垃圾邮件中看到每个标记的频率,以及它被训练的垃圾邮件和非垃圾邮件的数量:

from typing import List, Tuple, Dict, Iterable
import math
from collections import defaultdict

class NaiveBayesClassifier:
    def __init__(self, k: float = 0.5) -> None:
        self.k = k  # smoothing factor

        self.tokens: Set[str] = set()
        self.token_spam_counts: Dict[str, int] = defaultdict(int)
        self.token_ham_counts: Dict[str, int] = defaultdict(int)
        self.spam_messages = self.ham_messages = 0

接下来,我们将为其提供一个方法,让它训练一堆消息。首先,我们增加spam_messagesham_messages计数。然后我们对每个消息文本进行标记化,对于每个标记,根据消息类型递增token_spam_countstoken_ham_counts

    def train(self, messages: Iterable[Message]) -> None:
        for message in messages:
            # Increment message counts
            if message.is_spam:
                self.spam_messages += 1
            else:
                self.ham_messages += 1

            # Increment word counts
            for token in tokenize(message.text):
                self.tokens.add(token)
                if message.is_spam:
                    self.token_spam_counts[token] += 1
                else:
                    self.token_ham_counts[token] += 1

最终,我们将想要预测P(垃圾邮件 | 标记)。正如我们之前看到的,要应用贝叶斯定理,我们需要知道每个词汇表中的标记对于垃圾邮件非垃圾邮件P(标记 | 垃圾邮件)和P(标记 | 非垃圾邮件)。因此,我们将创建一个“私有”辅助函数来计算这些:

    def _probabilities(self, token: str) -> Tuple[float, float]:
        """returns P(token | spam) and P(token | ham)"""
        spam = self.token_spam_counts[token]
        ham = self.token_ham_counts[token]

        p_token_spam = (spam + self.k) / (self.spam_messages + 2 * self.k)
        p_token_ham = (ham + self.k) / (self.ham_messages + 2 * self.k)

        return p_token_spam, p_token_ham

最后,我们准备编写我们的predict方法。如前所述,我们不会将许多小概率相乘,而是将对数概率相加:

    def predict(self, text: str) -> float:
        text_tokens = tokenize(text)
        log_prob_if_spam = log_prob_if_ham = 0.0

        # Iterate through each word in our vocabulary
        for token in self.tokens:
            prob_if_spam, prob_if_ham = self._probabilities(token)

            # If *token* appears in the message,
            # add the log probability of seeing it
            if token in text_tokens:
                log_prob_if_spam += math.log(prob_if_spam)
                log_prob_if_ham += math.log(prob_if_ham)

            # Otherwise add the log probability of _not_ seeing it,
            # which is log(1 - probability of seeing it)
            else:
                log_prob_if_spam += math.log(1.0 - prob_if_spam)
                log_prob_if_ham += math.log(1.0 - prob_if_ham)

        prob_if_spam = math.exp(log_prob_if_spam)
        prob_if_ham = math.exp(log_prob_if_ham)
        return prob_if_spam / (prob_if_spam + prob_if_ham)

现在我们有了一个分类器。

测试我们的模型

让我们通过为其编写一些单元测试来确保我们的模型有效。

messages = [Message("spam rules", is_spam=True),
            Message("ham rules", is_spam=False),
            Message("hello ham", is_spam=False)]

model = NaiveBayesClassifier(k=0.5)
model.train(messages)

首先,让我们检查它是否正确计数:

assert model.tokens == {"spam", "ham", "rules", "hello"}
assert model.spam_messages == 1
assert model.ham_messages == 2
assert model.token_spam_counts == {"spam": 1, "rules": 1}
assert model.token_ham_counts == {"ham": 2, "rules": 1, "hello": 1}

现在让我们做出预测。我们还将(费力地)手动执行我们的朴素贝叶斯逻辑,并确保获得相同的结果:

text = "hello spam"

probs_if_spam = [
    (1 + 0.5) / (1 + 2 * 0.5),      # "spam"  (present)
    1 - (0 + 0.5) / (1 + 2 * 0.5),  # "ham"   (not present)
    1 - (1 + 0.5) / (1 + 2 * 0.5),  # "rules" (not present)
    (0 + 0.5) / (1 + 2 * 0.5)       # "hello" (present)
]

probs_if_ham = [
    (0 + 0.5) / (2 + 2 * 0.5),      # "spam"  (present)
    1 - (2 + 0.5) / (2 + 2 * 0.5),  # "ham"   (not present)
    1 - (1 + 0.5) / (2 + 2 * 0.5),  # "rules" (not present)
    (1 + 0.5) / (2 + 2 * 0.5),      # "hello" (present)
]

p_if_spam = math.exp(sum(math.log(p) for p in probs_if_spam))
p_if_ham = math.exp(sum(math.log(p) for p in probs_if_ham))

# Should be about 0.83
assert model.predict(text) == p_if_spam / (p_if_spam + p_if_ham)

此测试通过,因此似乎我们的模型正在做我们认为的事情。如果您查看实际的概率,两个主要驱动因素是我们的消息包含spam(我们唯一的训练垃圾邮件消息包含)和它不包含ham(我们的两个训练非垃圾邮件消息均不包含)。

现在让我们尝试在一些真实数据上运行它。

使用我们的模型

一个流行的(尽管有些陈旧的)数据集是SpamAssassin 公共语料库。我们将查看以20021010为前缀的文件。

这里是一个脚本,将下载并解压它们到您选择的目录(或者您可以手动执行):

from io import BytesIO  # So we can treat bytes as a file.
import requests         # To download the files, which
import tarfile          # are in .tar.bz format.

BASE_URL = "https://spamassassin.apache.org/old/publiccorpus"
FILES = ["20021010_easy_ham.tar.bz2",
         "20021010_hard_ham.tar.bz2",
         "20021010_spam.tar.bz2"]

# This is where the data will end up,
# in /spam, /easy_ham, and /hard_ham subdirectories.
# Change this to where you want the data.
OUTPUT_DIR = 'spam_data'

for filename in FILES:
    # Use requests to get the file contents at each URL.
    content = requests.get(f"{BASE_URL}/{filename}").content

    # Wrap the in-memory bytes so we can use them as a "file."
    fin = BytesIO(content)

    # And extract all the files to the specified output dir.
    with tarfile.open(fileobj=fin, mode='r:bz2') as tf:
        tf.extractall(OUTPUT_DIR)

文件的位置可能会更改(这在本书的第一版和第二版之间发生过),如果是这样,请相应调整脚本。

下载数据后,您应该有三个文件夹:spameasy_hamhard_ham。每个文件夹包含许多电子邮件,每封邮件都包含在单个文件中。为了保持非常简单,我们将仅查看每封电子邮件的主题行。

如何识别主题行?当我们浏览文件时,它们似乎都以“主题:”开头。因此,我们将寻找这个:

import glob, re

# modify the path to wherever you've put the files
path = 'spam_data/*/*'

data: List[Message] = []

# glob.glob returns every filename that matches the wildcarded path
for filename in glob.glob(path):
    is_spam = "ham" not in filename

    # There are some garbage characters in the emails; the errors='ignore'
    # skips them instead of raising an exception.
    with open(filename, errors='ignore') as email_file:
        for line in email_file:
            if line.startswith("Subject:"):
                subject = line.lstrip("Subject: ")
                data.append(Message(subject, is_spam))
                break  # done with this file

现在我们可以将数据分为训练数据和测试数据,然后我们就可以构建分类器了:

import random
from scratch.machine_learning import split_data

random.seed(0)      # just so you get the same answers as me
train_messages, test_messages = split_data(data, 0.75)

model = NaiveBayesClassifier()
model.train(train_messages)

让我们生成一些预测并检查我们的模型的表现:

from collections import Counter

predictions = [(message, model.predict(message.text))
               for message in test_messages]

# Assume that spam_probability > 0.5 corresponds to spam prediction
# and count the combinations of (actual is_spam, predicted is_spam)
confusion_matrix = Counter((message.is_spam, spam_probability > 0.5)
                           for message, spam_probability in predictions)

print(confusion_matrix)

这给出了 84 个真正的阳性(被分类为“垃圾邮件”的垃圾邮件),25 个假阳性(被分类为“垃圾邮件”的非垃圾邮件),703 个真负(被分类为“非垃圾邮件”的非垃圾邮件)和 44 个假阴性(被分类为“非垃圾邮件”的垃圾邮件)。这意味着我们的精度是 84 /(84 + 25)= 77%,我们的召回率是 84 /(84 + 44)= 65%,对于如此简单的模型来说,这些数字并不差。(假设如果我们查看的不仅仅是主题行,我们可能会做得更好。)

我们还可以检查模型的内部,看看哪些单词最少和最具有指示性的垃圾邮件。

def p_spam_given_token(token: str, model: NaiveBayesClassifier) -> float:
    # We probably shouldn't call private methods, but it's for a good cause.
    prob_if_spam, prob_if_ham = model._probabilities(token)

    return prob_if_spam / (prob_if_spam + prob_if_ham)

words = sorted(model.tokens, key=lambda t: p_spam_given_token(t, model))

print("spammiest_words", words[-10:])
print("hammiest_words", words[:10])

最垃圾的词包括像salemortgagemoneyrates这样的词,而最不垃圾的词包括像spambayesusersaptperl这样的词。因此,这也让我们对我们的模型基本做出正确的判断有了一些直观的信心。

我们如何才能获得更好的性能?一个明显的方法是获得更多的训练数据。还有很多改进模型的方法。以下是您可以尝试的一些可能性:

  • 查看消息内容,而不仅仅是主题行。您将需要小心处理消息头。

  • 我们的分类器考虑了训练集中出现的每个单词,甚至是仅出现一次的单词。修改分类器以接受可选的min_count阈值,并忽略不至少出现这么多次的标记。

  • 分词器没有类似词汇的概念(例如 cheapcheapest)。修改分类器以接受可选的 stemmer 函数,将单词转换为 等价类 的单词。例如,一个非常简单的词干提取函数可以是:

    def drop_final_s(word):
        return re.sub("s$", "", word)
    

    创建一个良好的词干提取函数很难。人们经常使用 Porter stemmer

  • 尽管我们的特征都是形如“消息包含词 w i”,但这并非必须如此。在我们的实现中,我们可以通过创建虚假标记,如 contains:number,并在适当时修改 tokenizer 以发出它们来添加额外的特征,比如“消息包含数字”。

进一步探索

第十四章:简单线性回归

艺术,如道德,就在于在某处划出界限。

G. K. Chesterton

在第五章中,我们使用correlation函数来衡量两个变量之间线性关系的强度。对于大多数应用程序,知道存在这样一个线性关系是不够的。我们需要理解关系的本质。这就是我们将使用简单线性回归的地方。

模型

回想一下,我们正在研究 DataSciencester 用户的朋友数量和每天在网站上花费的时间之间的关系。假设您已经确信,拥有更多朋友导致人们在网站上花费更多时间,而不是我们讨论过的其他解释之一。

参与用户参与部长要求您建立描述这种关系的模型。由于您找到了一个相当强的线性关系,线性模型是一个自然的起点。

特别是,您假设存在常数α(alpha)和β(beta),使得:

y i = β x i + α + ε i

其中y i是用户i每天在网站上花费的分钟数,x i是用户i的朋友数,ε是一个(希望很小的)误差项,表示这个简单模型未考虑到的其他因素。

假设我们已经确定了这样的alphabeta,那么我们可以简单地进行预测:

def predict(alpha: float, beta: float, x_i: float) -> float:
    return beta * x_i + alpha

如何选择alphabeta?嗯,任何alphabeta的选择都会给我们每个输入x_i预测输出。由于我们知道实际输出y_i,我们可以计算每对的误差:

def error(alpha: float, beta: float, x_i: float, y_i: float) -> float:
    """
 The error from predicting beta * x_i + alpha
 when the actual value is y_i
 """
    return predict(alpha, beta, x_i) - y_i

我们真正想知道的是整个数据集的总误差。但我们不只是想把误差加起来——如果x_1的预测值过高,而x_2的预测值过低,误差可能会抵消掉。

因此,我们将平方误差加起来:

from scratch.linear_algebra import Vector

def sum_of_sqerrors(alpha: float, beta: float, x: Vector, y: Vector) -> float:
    return sum(error(alpha, beta, x_i, y_i) ** 2
               for x_i, y_i in zip(x, y))

最小二乘解是选择使sum_of_sqerrors尽可能小的alphabeta

使用微积分(或繁琐的代数),最小化误差的alphabeta由以下公式给出:

from typing import Tuple
from scratch.linear_algebra import Vector
from scratch.statistics import correlation, standard_deviation, mean

def least_squares_fit(x: Vector, y: Vector) -> Tuple[float, float]:
    """
 Given two vectors x and y,
 find the least-squares values of alpha and beta
 """
    beta = correlation(x, y) * standard_deviation(y) / standard_deviation(x)
    alpha = mean(y) - beta * mean(x)
    return alpha, beta

不需要详细进行数学推导,让我们思考为什么这可能是一个合理的解决方案。选择alpha简单地表示,当我们看到自变量x的平均值时,我们预测因变量y的平均值。

选择beta的意义在于,当输入值增加了standard_deviation(x)时,预测值就会增加correlation(x, y) * standard_deviation(y)。如果xy完全正相关,x增加一个标准差会导致预测值增加一个y的标准差。当它们完全负相关时,x的增加会导致预测值的减少。当相关性为 0 时,beta为 0,这意味着x的变化对预测没有任何影响。

通常情况下,我们来快速测试一下:

x = [i for i in range(-100, 110, 10)]
y = [3 * i - 5 for i in x]

# Should find that y = 3x - 5
assert least_squares_fit(x, y) == (-5, 3)

现在很容易将其应用于第五章中去除异常值的数据:

from scratch.statistics import num_friends_good, daily_minutes_good

alpha, beta = least_squares_fit(num_friends_good, daily_minutes_good)
assert 22.9 < alpha < 23.0
assert 0.9 < beta < 0.905

这给出了alpha = 22.95 和beta = 0.903 的值。因此,我们的模型表明,我们预计一个没有朋友的用户每天在 DataSciencester 上花费大约 23 分钟。对于每个额外的朋友,我们预计用户每天在网站上多花大约一分钟。

在图 14-1 中,我们绘制预测线,以了解模型拟合观察数据的程度。

简单线性回归。

图 14-1. 我们的简单线性模型

当然,我们需要一种比盯着图表更好的方法来确定我们对数据的拟合程度。一个常见的度量是决定系数(或R 平方),它衡量因变量的总变异中模型所捕获的比例:

from scratch.statistics import de_mean

def total_sum_of_squares(y: Vector) -> float:
    """the total squared variation of y_i's from their mean"""
    return sum(v ** 2 for v in de_mean(y))

def r_squared(alpha: float, beta: float, x: Vector, y: Vector) -> float:
    """
 the fraction of variation in y captured by the model, which equals
 1 - the fraction of variation in y not captured by the model
 """
    return 1.0 - (sum_of_sqerrors(alpha, beta, x, y) /
                  total_sum_of_squares(y))

rsq = r_squared(alpha, beta, num_friends_good, daily_minutes_good)
assert 0.328 < rsq < 0.330

记住,我们选择了使预测误差平方和最小化的alphabeta。我们可以选择一个线性模型“始终预测mean(y)”(对应于alpha = mean(y)和beta = 0),其预测误差平方和恰好等于总平方和。这意味着 R 平方为 0,表明该模型(在这种情况下显然)的表现不比简单预测均值好。

显然,最小二乘模型至少要与此一样好,这意味着预测误差平方和最多等于总平方和,这意味着 R 平方至少为 0。而预测误差平方和至少为 0,这意味着 R 平方最多为 1。

数字越高,我们的模型拟合数据越好。这里我们计算了一个 R 平方为 0.329,表明我们的模型在拟合数据方面只算是可以接受,显然还有其他因素在起作用。

使用梯度下降法

如果我们写theta = [alpha, beta],我们也可以用梯度下降法解决这个问题:

import random
import tqdm
from scratch.gradient_descent import gradient_step

num_epochs = 10000
random.seed(0)

guess = [random.random(), random.random()]  # choose random value to start

learning_rate = 0.00001

with tqdm.trange(num_epochs) as t:
    for _ in t:
        alpha, beta = guess

        # Partial derivative of loss with respect to alpha
        grad_a = sum(2 * error(alpha, beta, x_i, y_i)
                     for x_i, y_i in zip(num_friends_good,
                                         daily_minutes_good))

        # Partial derivative of loss with respect to beta
        grad_b = sum(2 * error(alpha, beta, x_i, y_i) * x_i
                     for x_i, y_i in zip(num_friends_good,
                                         daily_minutes_good))

        # Compute loss to stick in the tqdm description
        loss = sum_of_sqerrors(alpha, beta,
                               num_friends_good, daily_minutes_good)
        t.set_description(f"loss: {loss:.3f}")

        # Finally, update the guess
        guess = gradient_step(guess, [grad_a, grad_b], -learning_rate)

# We should get pretty much the same results:
alpha, beta = guess
assert 22.9 < alpha < 23.0
assert 0.9 < beta < 0.905

如果您运行此操作,您将得到与我们使用精确公式相同的alphabeta值。

最大似然估计

为什么选择最小二乘法?其中一个理由涉及最大似然估计。想象一下,我们有来自依赖于某些未知参数θ(theta)的分布的数据样本v 1 , ... , v n

p ( v 1 , ... , v n | θ )

如果我们不知道θ,我们可以反过来将这个数量视为给定样本的θ似然

L ( θ | v 1 , ... , v n )

在这种方法下,最可能的θ是能够最大化这个似然函数的值——即使得观察数据最有可能出现的值。对于连续分布的情况,我们有一个概率分布函数而不是概率质量函数,我们也可以做同样的事情。

回到回归。关于简单回归模型经常做的一个假设是回归误差服从均值为 0、某个(已知)标准差σ的正态分布。如果是这种情况,那么基于观察到一对(x_i, y_i)的似然是:

L ( α , β | x i , y i , σ ) = 1 2πσ exp ( - (y i -α-βx i ) 2 / 2 σ 2 )

基于整个数据集的似然是每个个体似然的乘积,在alphabeta被选择以最小化平方误差时最大。也就是说,在这种情况下(在这些假设下),最小化平方误差等价于最大化观察数据的似然。

进一步探索

继续阅读关于多元回归的内容在第十五章!

第十五章:多元回归

我不会看着问题并在里面加入不影响它的变量。

比尔·帕塞尔

虽然副总统对你的预测模型印象深刻,但她认为你可以做得更好。因此,你收集了额外的数据:你知道每个用户每天工作的小时数,以及他们是否拥有博士学位。你希望利用这些额外数据来改进你的模型。

因此,你假设一个包含更多独立变量的线性模型:

minutes = α + β 1 friends + β 2 work hours + β 3 phd + ε

显然,用户是否拥有博士学位不是一个数字——但是,正如我们在第十一章中提到的,我们可以引入一个虚拟变量,对于拥有博士学位的用户设为 1,没有的设为 0,之后它与其他变量一样是数值化的。

模型

回想一下,在第十四章中,我们拟合了一个形式为:

y i = α + β x i + ε i

现在想象每个输入x i不是单个数字,而是一个包含k个数字的向量,x i1 , ... , x ik 。多元回归模型假设:

y i = α + β 1 x i1 + . . . + β k x ik + ε i

在多元回归中,参数向量通常称为β。我们希望这个向量包括常数项,可以通过在数据中添加一列 1 来实现:

beta = [alpha, beta_1, ..., beta_k]

以及:

x_i = [1, x_i1, ..., x_ik]

那么我们的模型就是:

from scratch.linear_algebra import dot, Vector

def predict(x: Vector, beta: Vector) -> float:
    """assumes that the first element of x is 1"""
    return dot(x, beta)

在这种特殊情况下,我们的自变量x将是一个向量列表,每个向量如下所示:

[1,    # constant term
 49,   # number of friends
 4,    # work hours per day
 0]    # doesn't have PhD

最小二乘模型的进一步假设

为了使这个模型(以及我们的解决方案)有意义,还需要一些进一步的假设。

第一个假设是x的列是线性独立的——没有办法将任何一个写成其他一些的加权和。如果这个假设失败,估计beta是不可能的。在一个极端情况下,想象我们在数据中有一个额外的字段num_acquaintances,对于每个用户都恰好等于num_friends

然后,从任意beta开始,如果我们将num_friends系数增加任意量,并将相同量从num_acquaintances系数减去,模型的预测将保持不变。这意味着没有办法找到num_friends系数。(通常这种假设的违反不会那么明显。)

第二个重要假设是x的列与误差ε不相关。如果这一点不成立,我们对beta的估计将会系统错误。

例如,在第十四章中,我们建立了一个模型,预测每增加一个朋友与额外 0.90 分钟的网站使用时间相关。

想象也是这种情况:

  • 工作时间更长的人在网站上花费的时间较少。

  • 拥有更多朋友的人 tend to work more hours.

换句话说,假设“实际”模型如下:

minutes = α + β 1 friends + β 2 work hours + ε

其中β 2是负数,而且工作时间和朋友数量是正相关的。在这种情况下,当我们最小化单变量模型的误差时:

minutes = α + β 1 friends + ε

我们会低估β 1

想象一下,如果我们使用单变量模型并使用“实际”值β 1 进行预测会发生什么。(也就是说,这个值是通过最小化我们称之为“实际”模型的误差得到的。)预测值会倾向于对工作时间较长的用户过大,并且对工作时间较少的用户也稍微偏大,因为β 2 < 0 而我们“忘记”将其包含在内。由于工作时间与朋友数量呈正相关,这意味着对于朋友较多的用户,预测值往往过大,而对于朋友较少的用户,则稍微过大。

这样做的结果是,我们可以通过减少对β 1的估计来减少(单变量模型中的)误差,这意味着误差最小化的β 1小于“实际”值。也就是说,在这种情况下,单变量最小二乘解法会倾向于低估β 1。而且,通常情况下,每当自变量与这些误差相关联时,我们的最小二乘解法都会给我们一个偏倚的β 1估计。

拟合模型

就像我们在简单线性模型中所做的那样,我们会选择beta来最小化平方误差的和。手动找到一个确切的解决方案并不容易,这意味着我们需要使用梯度下降法。同样,我们希望最小化平方误差的和。误差函数与我们在第十四章中使用的几乎完全相同,只是不再期望参数[alpha, beta],而是会接受任意长度的向量:

from typing import List

def error(x: Vector, y: float, beta: Vector) -> float:
    return predict(x, beta) - y

def squared_error(x: Vector, y: float, beta: Vector) -> float:
    return error(x, y, beta) ** 2

x = [1, 2, 3]
y = 30
beta = [4, 4, 4]  # so prediction = 4 + 8 + 12 = 24

assert error(x, y, beta) == -6
assert squared_error(x, y, beta) == 36

如果你懂得微积分,计算梯度就很容易:

def sqerror_gradient(x: Vector, y: float, beta: Vector) -> Vector:
    err = error(x, y, beta)
    return [2 * err * x_i for x_i in x]

assert sqerror_gradient(x, y, beta) == [-12, -24, -36]

否则,你需要相信我的话。

此时,我们准备使用梯度下降法找到最优的beta。让我们首先编写一个least_squares_fit函数,可以处理任何数据集:

import random
import tqdm
from scratch.linear_algebra import vector_mean
from scratch.gradient_descent import gradient_step

def least_squares_fit(xs: List[Vector],
                      ys: List[float],
                      learning_rate: float = 0.001,
                      num_steps: int = 1000,
                      batch_size: int = 1) -> Vector:
    """
 Find the beta that minimizes the sum of squared errors
 assuming the model y = dot(x, beta).
 """
    # Start with a random guess
    guess = [random.random() for _ in xs[0]]

    for _ in tqdm.trange(num_steps, desc="least squares fit"):
        for start in range(0, len(xs), batch_size):
            batch_xs = xs[start:start+batch_size]
            batch_ys = ys[start:start+batch_size]

            gradient = vector_mean([sqerror_gradient(x, y, guess)
                                    for x, y in zip(batch_xs, batch_ys)])
            guess = gradient_step(guess, gradient, -learning_rate)

    return guess

然后我们可以将其应用到我们的数据中:

from scratch.statistics import daily_minutes_good
from scratch.gradient_descent import gradient_step

random.seed(0)
# I used trial and error to choose num_iters and step_size.
# This will run for a while.
learning_rate = 0.001

beta = least_squares_fit(inputs, daily_minutes_good, learning_rate, 5000, 25)
assert 30.50 < beta[0] < 30.70  # constant
assert  0.96 < beta[1] <  1.00  # num friends
assert -1.89 < beta[2] < -1.85  # work hours per day
assert  0.91 < beta[3] <  0.94  # has PhD

在实践中,你不会使用梯度下降法来估计线性回归;你会使用超出本书范围的线性代数技术来得到精确的系数。如果你这样做,你会得到如下方程:

minutes = 30 . 58 + 0 . 972 friends - 1 . 87 work hours + 0 . 923 phd

这与我们找到的结果非常接近。

解释模型

模型中的系数代表每个因素的其他条件相等估计影响的总和。其他条件相等时,每增加一个朋友,每天会多花一分钟在网站上。其他条件相等时,用户工作日每增加一个小时,每天会少花约两分钟在网站上。其他条件相等时,拥有博士学位与每天在网站上多花一分钟相关联。

这并没有(直接)告诉我们关于变量之间互动的任何信息。有可能工作时间对于朋友多的人和朋友少的人有不同的影响。这个模型没有捕捉到这一点。处理这种情况的一种方法是引入一个新变量,即“朋友”和“工作时间”的乘积。这实际上允许“工作时间”系数随着朋友数量的增加而增加(或减少)。

或者可能是,你有更多的朋友,你在网站上花的时间就越多直到一个点,之后进一步的朋友导致你在网站上花费的时间减少。(也许有太多的朋友经验就太压倒性了?)我们可以尝试通过添加另一个变量,即朋友数量的平方,来捕捉这一点在我们的模型中。

一旦我们开始添加变量,就需要担心它们的系数是否“重要”。我们可以无限制地添加乘积、对数、平方和更高次方。

拟合优度

再次我们可以看一下 R 平方:

from scratch.simple_linear_regression import total_sum_of_squares

def multiple_r_squared(xs: List[Vector], ys: Vector, beta: Vector) -> float:
    sum_of_squared_errors = sum(error(x, y, beta) ** 2
                                for x, y in zip(xs, ys))
    return 1.0 - sum_of_squared_errors / total_sum_of_squares(ys)

现在已经增加到 0.68:

assert 0.67 < multiple_r_squared(inputs, daily_minutes_good, beta) < 0.68

请记住,然而,向回归中添加新变量必然会增加 R 平方。毕竟,简单回归模型只是多重回归模型的特殊情况,其中“工作时间”和“博士学位”的系数都等于 0。最佳的多重回归模型将至少有一个与该模型一样小的误差。

因此,在多重回归中,我们还需要看看系数的标准误差,这些标准误差衡量我们对每个β i的估计有多确定。整体回归可能非常适合我们的数据,但如果一些自变量相关(或不相关),它们的系数可能意义不大。

测量这些误差的典型方法始于另一个假设——误差ε i是独立的正态随机变量,均值为 0,有一些共享的(未知)标准偏差σ。在这种情况下,我们(或者更可能是我们的统计软件)可以使用一些线性代数来找出每个系数的标准误差。它越大,我们对该系数的模型越不确定。不幸的是,我们没有设置好可以从头开始执行这种线性代数的工具。

偏离:自助法

想象我们有一个由某个(对我们来说未知的)分布生成的包含n个数据点的样本:

data = get_sample(num_points=n)

在第五章中,我们编写了一个可以计算样本中位数的函数,我们可以将其用作对分布本身中位数的估计。

但我们对我们的估计有多自信呢?如果样本中所有数据点都非常接近 100,那么实际中位数似乎也接近 100。如果样本中大约一半的数据点接近 0,而另一半接近 200,则我们对中位数的估计就不太确定。

如果我们能够重复获得新样本,我们可以计算许多样本的中位数,并查看这些中位数的分布。通常我们做不到这一点。在这种情况下,我们可以通过从我们的数据中有放回地选择n个数据点来bootstrap新数据集。然后我们可以计算这些合成数据集的中位数:

from typing import TypeVar, Callable

X = TypeVar('X')        # Generic type for data
Stat = TypeVar('Stat')  # Generic type for "statistic"

def bootstrap_sample(data: List[X]) -> List[X]:
    """randomly samples len(data) elements with replacement"""
    return [random.choice(data) for _ in data]

def bootstrap_statistic(data: List[X],
                        stats_fn: Callable[[List[X]], Stat],
                        num_samples: int) -> List[Stat]:
    """evaluates stats_fn on num_samples bootstrap samples from data"""
    return [stats_fn(bootstrap_sample(data)) for _ in range(num_samples)]

例如,考虑以下两个数据集:

# 101 points all very close to 100
close_to_100 = [99.5 + random.random() for _ in range(101)]

# 101 points, 50 of them near 0, 50 of them near 200
far_from_100 = ([99.5 + random.random()] +
                [random.random() for _ in range(50)] +
                [200 + random.random() for _ in range(50)])

如果计算这两个数据集的median,两者都将非常接近 100。然而,如果你看一下:

from scratch.statistics import median, standard_deviation

medians_close = bootstrap_statistic(close_to_100, median, 100)

你将主要看到数字非常接近 100。但如果你看一下:

medians_far = bootstrap_statistic(far_from_100, median, 100)

你会看到很多接近 0 和很多接近 200 的数字。

第一组中位数的标准偏差接近 0,而第二组中位数的则接近 100:

assert standard_deviation(medians_close) < 1
assert standard_deviation(medians_far) > 90

(这种极端情况下,通过手动检查数据很容易找到答案,但通常情况下这是不成立的。)

回归系数的标准误差

我们可以采取同样的方法来估计回归系数的标准误差。我们重复从我们的数据中取出一个bootstrap_sample,并基于该样本估计beta。如果与一个独立变量(比如num_friends)对应的系数在样本中变化不大,那么我们可以相信我们的估计相对较为精确。如果系数在样本中变化很大,那么我们就不能对我们的估计感到有信心。

唯一的微妙之处在于,在抽样之前,我们需要zip我们的x数据和y数据,以确保独立变量和因变量的相应值一起被抽样。这意味着bootstrap_sample将返回一个成对的列表(x_i, y_i),我们需要重新组装成一个x_sample和一个y_sample

from typing import Tuple

import datetime

def estimate_sample_beta(pairs: List[Tuple[Vector, float]]):
    x_sample = [x for x, _ in pairs]
    y_sample = [y for _, y in pairs]
    beta = least_squares_fit(x_sample, y_sample, learning_rate, 5000, 25)
    print("bootstrap sample", beta)
    return beta

random.seed(0) # so that you get the same results as me

# This will take a couple of minutes!
bootstrap_betas = bootstrap_statistic(list(zip(inputs, daily_minutes_good)),
                                      estimate_sample_beta,
                                      100)

在此之后,我们可以估计每个系数的标准偏差。

bootstrap_standard_errors = [
    standard_deviation([beta[i] for beta in bootstrap_betas])
    for i in range(4)]

print(bootstrap_standard_errors)

# [1.272,    # constant term, actual error = 1.19
#  0.103,    # num_friends,   actual error = 0.080
#  0.155,    # work_hours,    actual error = 0.127
#  1.249]    # phd,           actual error = 0.998

(如果我们收集了超过 100 个样本并使用了超过 5,000 次迭代来估计每个beta,我们可能会得到更好的估计,但我们没有那么多时间。)

我们可以使用这些来测试假设,比如“β i是否等于 0?”在零假设β i = 0(以及我们对ε i分布的其他假设)下,统计量:

t j = β j ^ / σ j ^

其中,我们对β j的估计除以其标准误差的估计值,遵循“ n - k自由度”的学生 t 分布

如果我们有一个students_t_cdf函数,我们可以为每个最小二乘系数计算p-值,以指示如果实际系数为 0,则观察到这样的值的可能性有多大。不幸的是,我们没有这样的函数。(尽管如果我们不是从头开始工作,我们会有这样的函数。)

然而,随着自由度的增大,t-分布越来越接近于标准正态分布。在像这样的情况下,其中n远大于k,我们可以使用normal_cdf而仍然感觉良好:

from scratch.probability import normal_cdf

def p_value(beta_hat_j: float, sigma_hat_j: float) -> float:
    if beta_hat_j > 0:
        # if the coefficient is positive, we need to compute twice the
        # probability of seeing an even *larger* value
        return 2 * (1 - normal_cdf(beta_hat_j / sigma_hat_j))
    else:
        # otherwise twice the probability of seeing a *smaller* value
        return 2 * normal_cdf(beta_hat_j / sigma_hat_j)

assert p_value(30.58, 1.27)   < 0.001  # constant term
assert p_value(0.972, 0.103)  < 0.001  # num_friends
assert p_value(-1.865, 0.155) < 0.001  # work_hours
assert p_value(0.923, 1.249)  > 0.4    # phd

(在不像这样的情况下,我们可能会使用知道如何计算t-分布以及如何计算确切标准误差的统计软件。)

虽然大多数系数的p-值非常小(表明它们确实是非零的),但“PhD”的系数与 0 的差异不“显著”,这使得“PhD”的系数很可能是随机的,而不是有意义的。

在更复杂的回归场景中,有时您可能希望对数据进行更复杂的假设检验,例如“至少一个β j非零”或“β 1等于β 2 β 3等于β 4。” 您可以使用F-检验来执行此操作,但遗憾的是,这超出了本书的范围。

正则化

在实践中,您经常希望将线性回归应用于具有大量变量的数据集。这会产生一些额外的复杂性。首先,您使用的变量越多,就越有可能将模型过度拟合到训练集。其次,非零系数越多,就越难以理解它们。如果目标是解释某种现象,那么具有三个因素的稀疏模型可能比稍好的具有数百个因素的模型更有用。

正则化是一种方法,其中我们将惩罚项添加到误差项中,随着beta的增大而增加。然后,我们最小化组合误差和惩罚。我们越重视惩罚项,就越能够阻止大的系数。

例如,在岭回归中,我们添加的惩罚与beta_i的平方和成比例(通常不惩罚beta_0,即常数项):

# alpha is a *hyperparameter* controlling how harsh the penalty is.
# Sometimes it's called "lambda" but that already means something in Python.
def ridge_penalty(beta: Vector, alpha: float) -> float:
    return alpha * dot(beta[1:], beta[1:])

def squared_error_ridge(x: Vector,
                        y: float,
                        beta: Vector,
                        alpha: float) -> float:
    """estimate error plus ridge penalty on beta"""
    return error(x, y, beta) ** 2 + ridge_penalty(beta, alpha)

然后我们可以按照通常的方式将其插入梯度下降:

from scratch.linear_algebra import add

def ridge_penalty_gradient(beta: Vector, alpha: float) -> Vector:
    """gradient of just the ridge penalty"""
    return [0.] + [2 * alpha * beta_j for beta_j in beta[1:]]

def sqerror_ridge_gradient(x: Vector,
                           y: float,
                           beta: Vector,
                           alpha: float) -> Vector:
    """
 the gradient corresponding to the ith squared error term
 including the ridge penalty
 """
    return add(sqerror_gradient(x, y, beta),
               ridge_penalty_gradient(beta, alpha))

然后我们只需修改least_squares_fit函数,以使用sqerror_ridge_gradient而不是sqerror_gradient。(我不会在这里重复代码。)

alpha设置为 0 后,就没有惩罚了,我们获得了与以前相同的结果:

random.seed(0)
beta_0 = least_squares_fit_ridge(inputs, daily_minutes_good, 0.0,  # alpha
                                 learning_rate, 5000, 25)
# [30.51, 0.97, -1.85, 0.91]
assert 5 < dot(beta_0[1:], beta_0[1:]) < 6
assert 0.67 < multiple_r_squared(inputs, daily_minutes_good, beta_0) < 0.69

随着alpha的增加,拟合的好坏变得更差,但beta的大小变小:

beta_0_1 = least_squares_fit_ridge(inputs, daily_minutes_good, 0.1,  # alpha
                                   learning_rate, 5000, 25)
# [30.8, 0.95, -1.83, 0.54]
assert 4 < dot(beta_0_1[1:], beta_0_1[1:]) < 5
assert 0.67 < multiple_r_squared(inputs, daily_minutes_good, beta_0_1) < 0.69

beta_1 = least_squares_fit_ridge(inputs, daily_minutes_good, 1,  # alpha
                                 learning_rate, 5000, 25)
# [30.6, 0.90, -1.68, 0.10]
assert 3 < dot(beta_1[1:], beta_1[1:]) < 4
assert 0.67 < multiple_r_squared(inputs, daily_minutes_good, beta_1) < 0.69

beta_10 = least_squares_fit_ridge(inputs, daily_minutes_good,10,  # alpha
                                  learning_rate, 5000, 25)
# [28.3, 0.67, -0.90, -0.01]
assert 1 < dot(beta_10[1:], beta_10[1:]) < 2
assert 0.5 < multiple_r_squared(inputs, daily_minutes_good, beta_10) < 0.6

特别是,“PhD”的系数在增加惩罚时消失,这与我们先前的结果相符,即其与 0 没有显著不同。

注意

通常在使用这种方法之前,你应该重新缩放你的数据。毕竟,如果你将工作经验从年转换为世纪,其最小二乘系数将增加 100 倍,并且突然受到更严重的惩罚,尽管模型是相同的。

另一种方法是套索回归,它使用惩罚项:

def lasso_penalty(beta, alpha):
    return alpha * sum(abs(beta_i) for beta_i in beta[1:])

虽然岭回归的惩罚在整体上缩小了系数,但套索惩罚倾向于强制系数为 0,这使其非常适合学习稀疏模型。不幸的是,它不适合梯度下降,这意味着我们无法从头开始解决它。

进一步探索

  • 回归背后有丰富而广泛的理论支持。这是另一个你应该考虑阅读教科书或至少大量维基百科文章的地方。

  • scikit-learn 有一个linear_model模块,提供类似于我们的LinearRegression模型,以及岭回归、套索回归和其他类型的正则化。

  • Statsmodels是另一个 Python 模块,其中包含(除其他内容外)线性回归模型。

第十六章:logistic 回归

很多人说天才和疯狂之间只有一条细微的界限。我认为这不是细微的界限,实际上是一个巨大的鸿沟。

Bill Bailey

在第一章中,我们简要讨论了试图预测哪些 DataSciencester 用户购买高级帐户的问题。在这里,我们将重新讨论这个问题。

问题

我们有一个约 200 个用户的匿名数据集,包含每个用户的工资、作为数据科学家的经验年数,以及她是否为高级帐户支付费用(图 16-1)。像典型的分类变量一样,我们将依赖变量表示为 0(没有高级帐户)或 1(高级帐户)。

像往常一样,我们的数据是一列行 [experience, salary, paid_account]。让我们把它转换成我们需要的格式:

xs = [[1.0] + row[:2] for row in data]  # [1, experience, salary]
ys = [row[2] for row in data]           # paid_account

显而易见的第一次尝试是使用线性回归找到最佳模型:

paid account = β 0 + β 1 experience + β 2 salary + ε付费和非付费用户。

图 16-1 付费和非付费用户

当然,我们完全可以用这种方式对问题建模。结果显示在图 16-2 中:

from matplotlib import pyplot as plt
from scratch.working_with_data import rescale
from scratch.multiple_regression import least_squares_fit, predict
from scratch.gradient_descent import gradient_step

learning_rate = 0.001
rescaled_xs = rescale(xs)
beta = least_squares_fit(rescaled_xs, ys, learning_rate, 1000, 1)
# [0.26, 0.43, -0.43]
predictions = [predict(x_i, beta) for x_i in rescaled_xs]

plt.scatter(predictions, ys)
plt.xlabel("predicted")
plt.ylabel("actual")
plt.show()

使用线性回归预测高级账户。

图 16-2 使用线性回归预测高级账户

但是这种方法会导致一些即时问题:

  • 我们希望我们预测的输出是 0 或 1,以表示类别成员资格。如果它们在 0 和 1 之间,我们可以将其解释为概率,例如 0.25 的输出可能表示成为付费会员的 25%的机会。但是线性模型的输出可以是非常大的正数甚至是负数,这样不清楚如何解释。实际上,这里很多我们的预测是负数。

  • 线性回归模型假设误差与x的列无关。但是在这里,experience的回归系数为 0.43,表明经验越多,付费会员的可能性越大。这意味着我们的模型对于经验丰富的人输出非常大的值。但是我们知道实际值最多只能是 1,这意味着非常大的输出(因此非常大的experience值)对应于误差项非常大的负值。由于这种情况,我们对beta的估计是有偏的。

我们希望dot(x_i, beta)的大正值对应接近 1 的概率,大负值对应接近 0 的概率。我们可以通过对结果应用另一个函数来实现这一点。

逻辑函数

在逻辑回归中,我们使用逻辑函数,如图 16-3 所示:

def logistic(x: float) -> float:
    return 1.0 / (1 + math.exp(-x))

逻辑函数。

图 16-3. 逻辑函数

当其输入变得很大且为正时,它逐渐接近 1。当其输入变得很大且为负时,它逐渐接近 0。此外,它具有一个方便的性质,即其导数为:

def logistic_prime(x: float) -> float:
    y = logistic(x)
    return y * (1 - y)

稍后我们将利用这一点。我们将使用这个来拟合一个模型:

y i = f ( x i β ) + ε i

其中flogistic函数。

回想一下,对于线性回归,我们通过最小化平方误差来拟合模型,这最终选择了最大化数据的似然的β

在这里两者并不等价,因此我们将使用梯度下降直接最大化似然。这意味着我们需要计算似然函数及其梯度。

给定一些β,我们的模型表明每个y i应该等于 1 的概率为f ( x i β ),等于 0 的概率为1 - f ( x i β )

特别地,y i的概率密度函数可以写成:

p ( y i | x i , β ) = f (x i β) y i (1-f(x i β)) 1-y i

因为如果y i为 0,则此等于:

1 - f ( x i β )

如果y i为 1,则它等于:

f ( x i β )

结果表明,最大化对数似然实际上更简单:

log L ( β | x i , y i ) = y i log f ( x i β ) + ( 1 - y i ) log ( 1 - f ( x i β ) )

因为对数是一个严格递增的函数,任何使对数似然最大化的beta也将最大化似然,反之亦然。因为梯度下降是最小化的,所以我们实际上会处理对数似然,因为最大化似然等同于最小化其负值:

import math
from scratch.linear_algebra import Vector, dot

def _negative_log_likelihood(x: Vector, y: float, beta: Vector) -> float:
    """The negative log likelihood for one data point"""
    if y == 1:
        return -math.log(logistic(dot(x, beta)))
    else:
        return -math.log(1 - logistic(dot(x, beta)))

如果我们假设不同数据点彼此独立,则总体似然仅为各个似然的乘积。这意味着总体对数似然是各个对数似然的和:

from typing import List

def negative_log_likelihood(xs: List[Vector],
                            ys: List[float],
                            beta: Vector) -> float:
    return sum(_negative_log_likelihood(x, y, beta)
               for x, y in zip(xs, ys))

一点微积分给了我们梯度:

from scratch.linear_algebra import vector_sum

def _negative_log_partial_j(x: Vector, y: float, beta: Vector, j: int) -> float:
    """
 The jth partial derivative for one data point.
 Here i is the index of the data point.
 """
    return -(y - logistic(dot(x, beta))) * x[j]

def _negative_log_gradient(x: Vector, y: float, beta: Vector) -> Vector:
    """
 The gradient for one data point.
 """
    return [_negative_log_partial_j(x, y, beta, j)
            for j in range(len(beta))]

def negative_log_gradient(xs: List[Vector],
                          ys: List[float],
                          beta: Vector) -> Vector:
    return vector_sum([_negative_log_gradient(x, y, beta)
                       for x, y in zip(xs, ys)])

到这一点我们已经拥有所有需要的部分。

应用模型

我们将要将数据分成训练集和测试集:

from scratch.machine_learning import train_test_split
import random
import tqdm

random.seed(0)
x_train, x_test, y_train, y_test = train_test_split(rescaled_xs, ys, 0.33)

learning_rate = 0.01

# pick a random starting point
beta = [random.random() for _ in range(3)]

with tqdm.trange(5000) as t:
    for epoch in t:
        gradient = negative_log_gradient(x_train, y_train, beta)
        beta = gradient_step(beta, gradient, -learning_rate)
        loss = negative_log_likelihood(x_train, y_train, beta)
        t.set_description(f"loss: {loss:.3f} beta: {beta}")

之后我们发现beta大约是:

[-2.0, 4.7, -4.5]

这些是rescaled 数据的系数,但我们也可以将它们转换回原始数据:

from scratch.working_with_data import scale

means, stdevs = scale(xs)
beta_unscaled = [(beta[0]
                  - beta[1] * means[1] / stdevs[1]
                  - beta[2] * means[2] / stdevs[2]),
                 beta[1] / stdevs[1],
                 beta[2] / stdevs[2]]
# [8.9, 1.6, -0.000288]

不幸的是,这些并不像线性回归系数那样容易解释。其他条件相同,一年额外的经验将在logistic的输入上增加 1.6。其他条件相同,额外的 10,000 美元薪水将在logistic的输入上减少 2.88。

然而,输出的影响也取决于其他输入。如果dot(beta, x_i)已经很大(对应概率接近 1),即使大幅增加它也不能太大影响概率。如果接近 0,稍微增加可能会大幅增加概率。

我们可以说的是,其他条件相同的情况下,经验丰富的人更有可能支付账户。而其他条件相同的情况下,收入较高的人支付账户的可能性较低。(这在我们绘制数据时也有些明显。)

拟合度

我们还没有使用我们留出的测试数据。让我们看看如果我们在概率超过 0.5 时预测付费账户会发生什么:

true_positives = false_positives = true_negatives = false_negatives = 0

for x_i, y_i in zip(x_test, y_test):
    prediction = logistic(dot(beta, x_i))

    if y_i == 1 and prediction >= 0.5:  # TP: paid and we predict paid
        true_positives += 1
    elif y_i == 1:                      # FN: paid and we predict unpaid
        false_negatives += 1
    elif prediction >= 0.5:             # FP: unpaid and we predict paid
        false_positives += 1
    else:                               # TN: unpaid and we predict unpaid
        true_negatives += 1

precision = true_positives / (true_positives + false_positives)
recall = true_positives / (true_positives + false_negatives)

这给出了 75%的精度(“当我们预测付费账户时,我们有 75%的准确率”)和 80%的召回率(“当用户有付费账户时,我们 80%的时间预测为付费账户”),考虑到我们拥有的数据很少,这并不算糟糕。

我们还可以绘制预测与实际情况对比图(见图 16-4,#logistic_prediction_vs_actual),这也显示出模型表现良好:

predictions = [logistic(dot(beta, x_i)) for x_i in x_test]
plt.scatter(predictions, y_test, marker='+')
plt.xlabel("predicted probability")
plt.ylabel("actual outcome")
plt.title("Logistic Regression Predicted vs. Actual")
plt.show()

逻辑回归预测 vs 实际。

图 16-4。逻辑回归预测与实际

支持向量机

dot(beta, x_i)等于 0 的点集是我们类之间的边界。我们可以绘制这个图来精确了解我们的模型在做什么(见图 16-5,#logit_image_part_two)。

付费和未付费用户与决策边界。

图 16-5。付费和未付费用户与决策边界

这个边界是一个超平面,将参数空间分成两个半空间,对应预测付费预测未付费。我们发现它是在找到最可能的逻辑模型的副作用中发现的。

分类的另一种方法是只需寻找在训练数据中“最佳”分离类别的超平面。这是支持向量机背后的思想,它找到最大化每个类别最近点到超平面距离的超平面(见图 16-6,#separating_hyperplane)。

一个分离超平面。

图 16-6。一个分离超平面

寻找这样的超平面是一个涉及我们过于高级的技术的优化问题。另一个问题是,一个分离超平面可能根本不存在。在我们的“谁付费?”数据集中,简单地没有一条线完全分离付费用户和未付费用户。

我们有时可以通过将数据转换为更高维空间来绕过这个问题。例如,考虑到简单的一维数据集,如图 16-7 所示。

一个不可分离的一维数据集

图 16-7。一个不可分离的一维数据集

从明显的来看,没有一个超平面可以将正例和负例分开。然而,当我们通过将点x映射到(x, x**2)的方式将这个数据集映射到二维空间时,看看会发生什么。突然间,可以找到一个可以分割数据的超平面(见图 16-8)。

在更高维度中变得可分离

图 16-8. 数据集在更高维度中变得可分离

这通常被称为核技巧,因为我们不是实际将点映射到更高维度的空间(如果有很多点并且映射很复杂的话,这可能会很昂贵),而是可以使用一个“核”函数来计算在更高维度空间中的点积,并使用这些来找到超平面。

使用支持向量机而不依赖于由具有适当专业知识的人编写的专业优化软件是困难的(也可能不是一个好主意),因此我们将不得不在这里结束我们的讨论。

进一步调查

  • scikit-learn 既有逻辑回归模块,也有支持向量机模块。

  • LIBSVM是 scikit-learn 背后使用的支持向量机实现。其网站上有大量关于支持向量机的有用文档。

第十七章:决策树

树是一个难以理解的神秘。

Jim Woodring

DataSciencester 的人才副总裁已经面试了一些来自该网站的求职者,结果各不相同。他收集了一个数据集,其中包含每个候选人的几个(定性)属性,以及该候选人是否面试表现良好或不佳。他问道,你能否利用这些数据建立一个模型,识别哪些候选人会面试表现良好,这样他就不必浪费时间进行面试了?

这似乎非常适合决策树,这是数据科学家工具包中的另一种预测建模工具。

什么是决策树?

决策树使用树结构来表示多条可能的决策路径和每条路径的结果。

如果你玩过Twenty Questions游戏,那么你就熟悉决策树了。例如:

  • “我在想一个动物。”

  • “它有五条腿以上吗?”

  • “不。”

  • “它好吃吗?”

  • “不。”

  • “它出现在澳大利亚五分硬币的背面吗?”

  • “是的。”

  • “它是针鼹吗?”

  • “是的,就是它!”

这对应于路径:

“不超过 5 条腿” → “不好吃” → “在 5 分硬币上” → “针鼹!”

在一个古怪(并不是很全面的)“猜动物”决策树中(Figure 17-1)。

猜动物。

图 17-1. “猜动物”决策树

决策树有很多优点。它们非常容易理解和解释,它们达到预测的过程完全透明。与我们迄今所看到的其他模型不同,决策树可以轻松处理数值型(例如,腿的数量)和分类型(例如,好吃/不好吃)属性的混合数据,甚至可以对缺少属性的数据进行分类。

与此同时,为一组训练数据找到一个“最优”决策树在计算上是一个非常困难的问题。(我们将通过尝试构建一个足够好的树来避开这个问题,尽管对于大型数据集来说,这仍然可能是一项艰巨的工作。)更重要的是,很容易(也很糟糕)构建过度拟合训练数据的决策树,这些树在未见数据上的泛化能力很差。我们将探讨解决这个问题的方法。

大多数人将决策树分为分类树(生成分类输出)和回归树(生成数值输出)。在本章中,我们将专注于分类树,并通过 ID3 算法从一组标记数据中学习决策树,这将帮助我们理解决策树的实际工作原理。为了简化问题,我们将局限于具有二元输出的问题,例如“我应该雇佣这位候选人吗?”或“我应该向这位网站访客展示广告 A 还是广告 B?”或“我在办公室冰箱里找到的这种食物会让我生病吗?”

要构建决策树,我们需要决定提出什么问题以及顺序。在树的每个阶段,我们消除了一些可能性,还有一些没有。学到动物的腿不超过五条后,我们排除了它是蚱蜢的可能性。我们还没排除它是一只鸭子的可能性。每个可能的问题根据其答案将剩余的可能性进行分区。

理想情况下,我们希望选择的问题答案能够提供关于我们的树应该预测什么的大量信息。如果有一个单一的是/否问题,“是”答案总是对应于 True 输出,而“否”答案对应于 False 输出(反之亦然),那这将是一个很棒的问题选择。相反,如果一个是/否问题的任何答案都不能给出关于预测应该是什么的新信息,那可能不是一个好选择。

我们用来捕捉这种“信息量”概念。你可能听说过这个术语用来表示无序。我们用它来表示与数据相关的不确定性。

想象我们有一个数据集 S,其中每个成员都被标记为属于有限数量的类别 C 1 , ... , C n 中的一种。如果所有数据点属于同一类,则没有真正的不确定性,这意味着我们希望熵很低。如果数据点均匀分布在各个类别中,就会有很多不确定性,我们希望熵很高。

从数学角度来看,如果 p i 是标记为类别 c i 的数据的比例,我们定义熵如下:

H ( S ) = - p 1 log 2 p 1 - ... - p n log 2 p n

按照(标准)惯例,0 log 0 = 0

不必过分担心可怕的细节,每个术语 - p i log 2 p i 都是非负的,当 p i 接近于 0 或接近于 1 时,它接近于 0(图 17-2)。

–p log p 的图示。

图 17-2. -p log p 的图示

这意味着当每个 p i 接近于 0 或 1 时(即大多数数据属于单一类别时),熵将很小,当许多 p i 不接近于 0 时(即数据分布在多个类别中时),熵将较大。这正是我们期望的行为。

将所有这些内容整合到一个函数中是相当简单的:

from typing import List
import math

def entropy(class_probabilities: List[float]) -> float:
    """Given a list of class probabilities, compute the entropy"""
    return sum(-p * math.log(p, 2)
               for p in class_probabilities
               if p > 0)                     # ignore zero probabilities

assert entropy([1.0]) == 0
assert entropy([0.5, 0.5]) == 1
assert 0.81 < entropy([0.25, 0.75]) < 0.82

我们的数据将由成对的(输入,标签)组成,这意味着我们需要自己计算类别概率。注意,我们实际上并不关心每个概率与哪个标签相关联,只关心这些概率是多少:

from typing import Any
from collections import Counter

def class_probabilities(labels: List[Any]) -> List[float]:
    total_count = len(labels)
    return [count / total_count
            for count in Counter(labels).values()]

def data_entropy(labels: List[Any]) -> float:
    return entropy(class_probabilities(labels))

assert data_entropy(['a']) == 0
assert data_entropy([True, False]) == 1
assert data_entropy([3, 4, 4, 4]) == entropy([0.25, 0.75])

分区的熵

到目前为止,我们所做的是计算单一标记数据集的熵(想想“不确定性”)。现在,决策树的每个阶段都涉及提出一个问题,其答案将数据分成一个或多个子集。例如,我们的“它有超过五条腿吗?”问题将动物分成有超过五条腿的动物(例如,蜘蛛)和没有超过五条腿的动物(例如,针鼹)。

相应地,我们希望从某种程度上了解通过某种方式对数据集进行分区所产生的熵。如果分区将数据分成具有低熵(即高度确定)的子集,我们希望分区具有低熵;如果包含具有高熵(即高度不确定)的子集(即大且)分区具有高熵。

例如,我的“澳大利亚五分硬币”问题非常愚蠢(尽管非常幸运!),因为它将那时剩下的动物分成S 1 = {针鼹}和S 2 = {其他所有动物},其中S 2既大又高熵。 (S 1没有熵,但它表示剩余“类别”的小部分。)

从数学上讲,如果我们将我们的数据S分成包含数据比例的子集S 1 , ... , S m,那么我们将分区的熵计算为加权和:

H = q 1 H ( S 1 ) + ... + q m H ( S m )

我们可以实现为:

def partition_entropy(subsets: List[List[Any]]) -> float:
    """Returns the entropy from this partition of data into subsets"""
    total_count = sum(len(subset) for subset in subsets)

    return sum(data_entropy(subset) * len(subset) / total_count
               for subset in subsets)
注意

这种方法的一个问题是,使用具有许多不同值的属性进行分区会由于过拟合而导致熵非常低。例如,假设你在银行工作,试图建立一个决策树来预测哪些客户可能会违约他们的抵押贷款,使用一些历史数据作为你的训练集。进一步假设数据集包含每个客户的社会安全号码。在社会安全号码上进行分区将产生单个人的子集,每个子集的熵必然为零。但是依赖社会安全号码的模型肯定无法超出训练集的范围。因此,在创建决策树时,你应该尽量避免(或适当地分桶)具有大量可能值的属性。

创建决策树

副总裁为您提供了面试者数据,根据您的规定,每位候选人的相关属性是一个NamedTuple——她的级别、她偏爱的语言、她是否在 Twitter 上活跃、她是否有博士学位以及她是否面试表现良好:

from typing import NamedTuple, Optional

class Candidate(NamedTuple):
    level: str
    lang: str
    tweets: bool
    phd: bool
    did_well: Optional[bool] = None  # allow unlabeled data

                  #  level     lang     tweets  phd  did_well
inputs = [Candidate('Senior', 'Java',   False, False, False),
          Candidate('Senior', 'Java',   False, True,  False),
          Candidate('Mid',    'Python', False, False, True),
          Candidate('Junior', 'Python', False, False, True),
          Candidate('Junior', 'R',      True,  False, True),
          Candidate('Junior', 'R',      True,  True,  False),
          Candidate('Mid',    'R',      True,  True,  True),
          Candidate('Senior', 'Python', False, False, False),
          Candidate('Senior', 'R',      True,  False, True),
          Candidate('Junior', 'Python', True,  False, True),
          Candidate('Senior', 'Python', True,  True,  True),
          Candidate('Mid',    'Python', False, True,  True),
          Candidate('Mid',    'Java',   True,  False, True),
          Candidate('Junior', 'Python', False, True,  False)
         ]

我们的树将包含决策节点(提出问题并根据答案引导我们不同路径)和叶节点(给出预测)。我们将使用相对简单的ID3算法构建它,该算法操作如下。假设我们有一些带标签的数据,并且有一个要考虑分支的属性列表:

  • 如果所有数据都具有相同的标签,请创建一个预测该标签的叶节点,然后停止。

  • 如果属性列表为空(即没有更多可能的问题可问),创建一个预测最常见标签的叶节点,然后停止。

  • 否则,尝试按照每个属性对数据进行分割。

  • 选择具有最低分区熵的分区。

  • 根据选择的属性添加一个决策节点。

  • 对剩余属性使用递归对每个分区的子集进行分割。

这被称为“贪婪”算法,因为在每一步中,它选择最即时的最佳选项。给定一个数据集,可能有一个看起来更差的第一步,但却会有一个更好的树。如果确实存在这样的情况,此算法将无法找到它。尽管如此,它相对容易理解和实现,这使得它成为探索决策树的一个很好的起点。

让我们手动按照面试者数据集中的这些步骤进行。数据集具有TrueFalse标签,并且我们有四个可以分割的属性。因此,我们的第一步将是找到熵最小的分区。我们将首先编写一个执行分割的函数:

from typing import Dict, TypeVar
from collections import defaultdict

T = TypeVar('T')  # generic type for inputs

def partition_by(inputs: List[T], attribute: str) -> Dict[Any, List[T]]:
    """Partition the inputs into lists based on the specified attribute."""
    partitions: Dict[Any, List[T]] = defaultdict(list)
    for input in inputs:
        key = getattr(input, attribute)  # value of the specified attribute
        partitions[key].append(input)    # add input to the correct partition
    return partitions

还有一个使用它计算熵的函数:

def partition_entropy_by(inputs: List[Any],
                         attribute: str,
                         label_attribute: str) -> float:
    """Compute the entropy corresponding to the given partition"""
    # partitions consist of our inputs
    partitions = partition_by(inputs, attribute)

    # but partition_entropy needs just the class labels
    labels = [[getattr(input, label_attribute) for input in partition]
              for partition in partitions.values()]

    return partition_entropy(labels)

然后我们只需为整个数据集找到最小熵分区:

for key in ['level','lang','tweets','phd']:
    print(key, partition_entropy_by(inputs, key, 'did_well'))

assert 0.69 < partition_entropy_by(inputs, 'level', 'did_well')  < 0.70
assert 0.86 < partition_entropy_by(inputs, 'lang', 'did_well')   < 0.87
assert 0.78 < partition_entropy_by(inputs, 'tweets', 'did_well') < 0.79
assert 0.89 < partition_entropy_by(inputs, 'phd', 'did_well')    < 0.90

最低熵来自于按level分割,因此我们需要为每个可能的level值创建一个子树。每个Mid候选人都标记为True,这意味着Mid子树只是一个预测True的叶节点。对于Senior候选人,我们有TrueFalse的混合,因此我们需要再次分割:

senior_inputs = [input for input in inputs if input.level == 'Senior']

assert 0.4 == partition_entropy_by(senior_inputs, 'lang', 'did_well')
assert 0.0 == partition_entropy_by(senior_inputs, 'tweets', 'did_well')
assert 0.95 < partition_entropy_by(senior_inputs, 'phd', 'did_well') < 0.96

这告诉我们我们接下来应该在tweets上进行分割,这会导致零熵分区。对于这些Senior级别的候选人,“是”推文总是导致True,而“否”推文总是导致False

最后,如果我们对Junior候选人执行相同的操作,我们最终会在phd上进行分割,之后发现没有博士学位总是导致True,而有博士学位总是导致False

图 17-3 显示了完整的决策树。

招聘决策树。

图 17-3. 招聘的决策树

把所有这些整合起来

现在我们已经了解了算法的工作原理,我们希望更普遍地实现它。这意味着我们需要决定如何表示树。我们将使用可能最轻量级的表示。我们将一个定义为以下内容之一:

  • 一个Leaf(预测单个值),或者

  • 一个Split(包含要拆分的属性、特定属性值的子树,以及在遇到未知值时可能使用的默认值)。

    from typing import NamedTuple, Union, Any
    
    class Leaf(NamedTuple):
        value: Any
    
    class Split(NamedTuple):
        attribute: str
        subtrees: dict
        default_value: Any = None
    
    DecisionTree = Union[Leaf, Split]
    

有了这个表示,我们的招聘树将如下所示:

hiring_tree = Split('level', {   # first, consider "level"
    'Junior': Split('phd', {     # if level is "Junior", next look at "phd"
        False: Leaf(True),       #   if "phd" is False, predict True
        True: Leaf(False)        #   if "phd" is True, predict False
    }),
    'Mid': Leaf(True),           # if level is "Mid", just predict True
    'Senior': Split('tweets', {  # if level is "Senior", look at "tweets"
        False: Leaf(False),      #   if "tweets" is False, predict False
        True: Leaf(True)         #   if "tweets" is True, predict True
    })
})

还有一个问题,即如果我们遇到意外的(或缺失的)属性值该怎么办。如果我们的招聘树遇到levelIntern的候选人会怎么样?我们将通过用最常见的标签填充default_value属性来处理这种情况。

给定这样的表示,我们可以对输入进行分类:

def classify(tree: DecisionTree, input: Any) -> Any:
    """classify the input using the given decision tree"""

    # If this is a leaf node, return its value
    if isinstance(tree, Leaf):
        return tree.value

    # Otherwise this tree consists of an attribute to split on
    # and a dictionary whose keys are values of that attribute
    # and whose values are subtrees to consider next
    subtree_key = getattr(input, tree.attribute)

    if subtree_key not in tree.subtrees:   # If no subtree for key,
        return tree.default_value          # return the default value.

    subtree = tree.subtrees[subtree_key]   # Choose the appropriate subtree
    return classify(subtree, input)        # and use it to classify the input.

剩下的就是从我们的训练数据中构建树表示:

def build_tree_id3(inputs: List[Any],
                   split_attributes: List[str],
                   target_attribute: str) -> DecisionTree:
    # Count target labels
    label_counts = Counter(getattr(input, target_attribute)
                           for input in inputs)
    most_common_label = label_counts.most_common(1)[0][0]

    # If there's a unique label, predict it
    if len(label_counts) == 1:
        return Leaf(most_common_label)

    # If no split attributes left, return the majority label
    if not split_attributes:
        return Leaf(most_common_label)

    # Otherwise split by the best attribute

    def split_entropy(attribute: str) -> float:
        """Helper function for finding the best attribute"""
        return partition_entropy_by(inputs, attribute, target_attribute)

    best_attribute = min(split_attributes, key=split_entropy)

    partitions = partition_by(inputs, best_attribute)
    new_attributes = [a for a in split_attributes if a != best_attribute]

    # Recursively build the subtrees
    subtrees = {attribute_value : build_tree_id3(subset,
                                                 new_attributes,
                                                 target_attribute)
                for attribute_value, subset in partitions.items()}

    return Split(best_attribute, subtrees, default_value=most_common_label)

在我们构建的树中,每个叶子节点完全由True输入或完全由False输入组成。这意味着该树在训练数据集上的预测完全正确。但我们也可以将其应用于训练集中不存在的新数据:

tree = build_tree_id3(inputs,
                      ['level', 'lang', 'tweets', 'phd'],
                      'did_well')

# Should predict True
assert classify(tree, Candidate("Junior", "Java", True, False))

# Should predict False
assert not classify(tree, Candidate("Junior", "Java", True, True))

也适用于具有意外值的数据:

# Should predict True
assert classify(tree, Candidate("Intern", "Java", True, True))
注意

由于我们的目标主要是演示如何构建树,所以我们使用整个数据集构建了树。一如既往,如果我们真的试图为某事创建一个良好的模型,我们会收集更多数据并将其分割成训练/验证/测试子集。

随机森林

鉴于决策树可以如此紧密地适应其训练数据,他们很容易出现过拟合的倾向并不奇怪。避免这种情况的一种方法是一种称为随机森林的技术,其中我们构建多个决策树并组合它们的输出。如果它们是分类树,我们可以让它们投票;如果它们是回归树,我们可以平均它们的预测。

我们的树构建过程是确定性的,那么我们如何获得随机树呢?

其中一部分涉及引导数据(参见“插曲:自助法”)。我们不是在整个训练集上训练每棵树,而是在bootstrap_sample(inputs)的结果上训练每棵树。由于每棵树都是使用不同的数据构建的,因此每棵树与其他每棵树都不同。(一个副作用是,使用未抽样数据来测试每棵树是完全公平的,这意味着如果在衡量性能时聪明地使用所有数据作为训练集,你就可以获得成功。)这种技术被称为自助聚合装袋

第二个随机性源涉及更改选择要拆分的best_attribute的方法。我们不是查看所有剩余属性,而是首先选择它们的随机子集,然后在其中最好的属性上进行拆分:

    # if there are already few enough split candidates, look at all of them
    if len(split_candidates) <= self.num_split_candidates:
        sampled_split_candidates = split_candidates
    # otherwise pick a random sample
    else:
        sampled_split_candidates = random.sample(split_candidates,
                                                 self.num_split_candidates)

    # now choose the best attribute only from those candidates
    best_attribute = min(sampled_split_candidates, key=split_entropy)

    partitions = partition_by(inputs, best_attribute)

这是集成学习的一个例子,其中我们结合了多个弱学习器(通常是高偏差、低方差模型),以生成一个总体上强大的模型。

进一步探索

  • scikit-learn 包含许多决策树模型。它还有一个ensemble模块,其中包括RandomForestClassifier以及其他集成方法。

  • XGBoost 是一个用于训练梯度提升决策树的库,经常在许多 Kaggle 风格的机器学习竞赛中获胜。

  • 我们只是浅尝了解决策树及其算法的表面。维基百科是一个更广泛探索的良好起点。

第十八章:神经网络

我喜欢胡说八道;它唤醒了大脑细胞。

博士苏斯

人工神经网络(或简称神经网络)是一种受大脑运作方式启发的预测模型。将大脑视为一组互相连接的神经元。每个神经元查看输入到它的其他神经元的输出,进行计算,然后根据计算结果是否超过某个阈值来“激活”或不激活。

因此,人工神经网络由人工神经元组成,它们对其输入执行类似的计算。神经网络可以解决各种问题,如手写识别和面部检测,在深度学习中广泛使用,这是数据科学中最流行的子领域之一。然而,大多数神经网络是“黑箱”—检查它们的细节并不能让你理解它们如何解决问题。而且,大型神经网络可能难以训练。对于大多数初涉数据科学的问题,它们可能不是正确的选择。当你试图构建一个引发“奇点”的人工智能时,它们可能非常合适。

感知器

最简单的神经网络就是感知器,它模拟了一个具有n个二进制输入的单个神经元。它计算其输入的加权和,如果该加权和大于等于 0,则“激活”:

from scratch.linear_algebra import Vector, dot

def step_function(x: float) -> float:
    return 1.0 if x >= 0 else 0.0

def perceptron_output(weights: Vector, bias: float, x: Vector) -> float:
    """Returns 1 if the perceptron 'fires', 0 if not"""
    calculation = dot(weights, x) + bias
    return step_function(calculation)

感知器只是区分了由点x组成的超平面分隔的半空间:

dot(weights, x) + bias == 0

通过适当选择的权重,感知器可以解决许多简单的问题(图 18-1)。例如,我们可以创建一个AND 门(如果其输入都是 1 则返回 1,但如果其中一个输入是 0 则返回 0):

and_weights = [2., 2]
and_bias = -3.

assert perceptron_output(and_weights, and_bias, [1, 1]) == 1
assert perceptron_output(and_weights, and_bias, [0, 1]) == 0
assert perceptron_output(and_weights, and_bias, [1, 0]) == 0
assert perceptron_output(and_weights, and_bias, [0, 0]) == 0

如果两个输入都是 1,计算等于 2 + 2 – 3 = 1,并且输出为 1。如果只有一个输入是 1,计算等于 2 + 0 – 3 = –1,并且输出为 0。如果两个输入都是 0,计算等于–3,并且输出为 0。

使用类似的推理,我们可以用以下方式构建一个OR 门

or_weights = [2., 2]
or_bias = -1.

assert perceptron_output(or_weights, or_bias, [1, 1]) == 1
assert perceptron_output(or_weights, or_bias, [0, 1]) == 1
assert perceptron_output(or_weights, or_bias, [1, 0]) == 1
assert perceptron_output(or_weights, or_bias, [0, 0]) == 0

感知器。

图 18-1. 二输入感知器的决策空间

我们也可以用以下方式构建一个NOT 门(其只有一个输入,并将 1 转换为 0,将 0 转换为 1):

not_weights = [-2.]
not_bias = 1.

assert perceptron_output(not_weights, not_bias, [0]) == 1
assert perceptron_output(not_weights, not_bias, [1]) == 0

然而,有一些问题简单地无法通过单个感知器解决。例如,无论你多么努力,你都不能使用一个感知器来构建一个异或门,即当其输入中只有一个是 1 时输出为 1,否则为 0。这时我们需要更复杂的神经网络。

当然,你不需要模拟神经元来构建逻辑门:

and_gate = min
or_gate = max
xor_gate = lambda x, y: 0 if x == y else 1

像真实的神经元一样,人工神经元开始在连接起来时变得更加有趣。

前馈神经网络

大脑的拓扑结构极其复杂,因此常常用由离散的组成的理想化前馈神经网络来近似它,每个层都与下一层相连接。通常包括一个输入层(接收并不改变输入并向前传递),一个或多个“隐藏层”(每个都由神经元组成,接收前一层的输出,执行某些计算,并将结果传递到下一层),和一个输出层(生成最终的输出)。

就像在感知机中一样,每个(非输入)神经元都有与其每个输入对应的权重和偏置。为了简化我们的表示,我们将偏置添加到权重向量的末尾,并给每个神经元一个偏置输入,其值始终为 1。

与感知机类似,对于每个神经元,我们将计算其输入和权重的乘积之和。但在这里,我们不会输出应用于该乘积的step_function,而是输出其平滑的近似。这里我们将使用sigmoid函数(图 18-2):

import math

def sigmoid(t: float) -> float:
    return 1 / (1 + math.exp(-t))

Sigmoid.

图 18-2. sigmoid 函数

为什么使用sigmoid而不是更简单的step_function?为了训练神经网络,我们需要使用微积分,而要使用微积分,我们需要平滑的函数。step_function甚至不连续,而sigmoid是它的一个很好的平滑近似。

注意

你可能还记得sigmoid函数,它在第十六章中称为logistic。技术上,sigmoid指的是函数的形状,logistic指的是特定的函数,尽管人们经常将这些术语互换使用。

然后我们计算输出为:

def neuron_output(weights: Vector, inputs: Vector) -> float:
    # weights includes the bias term, inputs includes a 1
    return sigmoid(dot(weights, inputs))

有了这个函数,我们可以简单地将神经元表示为一个权重向量,其长度比该神经元的输入数量多一个(因为有偏置权重)。然后我们可以将神经网络表示为(非输入)的列表,其中每一层只是该层中神经元的列表。

那就是,我们将神经网络表示为向量的列表(层),其中每个向量(神经元)是向量(权重)的列表。

给定这样的表示法,使用神经网络非常简单:

from typing import List

def feed_forward(neural_network: List[List[Vector]],
                 input_vector: Vector) -> List[Vector]:
    """
 Feeds the input vector through the neural network.
 Returns the outputs of all layers (not just the last one).
 """
    outputs: List[Vector] = []

    for layer in neural_network:
        input_with_bias = input_vector + [1]              # Add a constant.
        output = [neuron_output(neuron, input_with_bias)  # Compute the output
                  for neuron in layer]                    # for each neuron.
        outputs.append(output)                            # Add to results.

        # Then the input to the next layer is the output of this one
        input_vector = output

    return outputs

现在很容易构建我们无法用单个感知机构建的 XOR 门。我们只需扩展权重,使得neuron_output要么非常接近 0,要么非常接近 1:

xor_network = [# hidden layer
               [[20., 20, -30],      # 'and' neuron
                [20., 20, -10]],     # 'or'  neuron
               # output layer
               [[-60., 60, -30]]]    # '2nd input but not 1st input' neuron

# feed_forward returns the outputs of all layers, so the [-1] gets the
# final output, and the [0] gets the value out of the resulting vector
assert 0.000 < feed_forward(xor_network, [0, 0])[-1][0] < 0.001
assert 0.999 < feed_forward(xor_network, [1, 0])[-1][0] < 1.000
assert 0.999 < feed_forward(xor_network, [0, 1])[-1][0] < 1.000
assert 0.000 < feed_forward(xor_network, [1, 1])[-1][0] < 0.001

对于给定的输入(一个二维向量),隐藏层生成一个二维向量,包含两个输入值的“与”和两个输入值的“或”。

输出层接收一个二维向量,并计算“第二个元素但不是第一个元素”。结果是一个执行“或但不是与”的网络,这正是 XOR(图 18-3)。

神经网络.

图 18-3. 一个用于 XOR 的神经网络

一个有启发性的思考方式是,隐藏层正在计算输入数据的特征(在本例中是“and”和“or”),输出层将这些特征组合起来以生成所需的输出。

反向传播

通常我们不会手工构建神经网络。部分原因是因为我们使用它们来解决更大的问题——例如图像识别问题可能涉及数百或数千个神经元。另一部分原因是通常我们无法“推理出”神经元应该是什么。

相反(通常情况下),我们使用数据来训练神经网络。典型的方法是一种称为反向传播的算法,它使用梯度下降或其变种之一。

假设我们有一个训练集,由输入向量和相应的目标输出向量组成。例如,在我们之前的xor_network示例中,输入向量[1, 0]对应于目标输出[1]。假设我们的网络有一些权重。然后,我们使用以下算法调整权重:

  1. 对输入向量运行feed_forward以产生网络中所有神经元的输出。

  2. 我们知道目标输出,所以我们可以计算一个损失,即平方误差的总和。

  3. 计算这种损失作为输出神经元权重的函数的梯度。

  4. “传播”梯度和误差向后计算与隐藏神经元权重相关的梯度。

  5. 进行梯度下降步骤。

通常我们会对整个训练集运行这个算法多次,直到网络收敛。

首先,让我们编写计算梯度的函数:

def sqerror_gradients(network: List[List[Vector]],
                      input_vector: Vector,
                      target_vector: Vector) -> List[List[Vector]]:
    """
 Given a neural network, an input vector, and a target vector,
 make a prediction and compute the gradient of the squared error
 loss with respect to the neuron weights.
 """
    # forward pass
    hidden_outputs, outputs = feed_forward(network, input_vector)

    # gradients with respect to output neuron pre-activation outputs
    output_deltas = [output * (1 - output) * (output - target)
                     for output, target in zip(outputs, target_vector)]

    # gradients with respect to output neuron weights
    output_grads = [[output_deltas[i] * hidden_output
                     for hidden_output in hidden_outputs + [1]]
                    for i, output_neuron in enumerate(network[-1])]

    # gradients with respect to hidden neuron pre-activation outputs
    hidden_deltas = [hidden_output * (1 - hidden_output) *
                         dot(output_deltas, [n[i] for n in network[-1]])
                     for i, hidden_output in enumerate(hidden_outputs)]

    # gradients with respect to hidden neuron weights
    hidden_grads = [[hidden_deltas[i] * input for input in input_vector + [1]]
                    for i, hidden_neuron in enumerate(network[0])]

    return [hidden_grads, output_grads]

前述计算背后的数学并不是非常困难,但涉及一些繁琐的微积分和仔细的注意细节,所以我会留给你作为练习。

有了计算梯度的能力,我们现在可以训练神经网络。让我们试着通过手工设计的 XOR 网络来学习它。

我们将从生成训练数据开始,并使用随机权重初始化我们的神经网络:

import random
random.seed(0)

# training data
xs = [[0., 0], [0., 1], [1., 0], [1., 1]]
ys = [[0.], [1.], [1.], [0.]]

# start with random weights
network = [ # hidden layer: 2 inputs -> 2 outputs
            [[random.random() for _ in range(2 + 1)],   # 1st hidden neuron
             [random.random() for _ in range(2 + 1)]],  # 2nd hidden neuron
            # output layer: 2 inputs -> 1 output
            [[random.random() for _ in range(2 + 1)]]   # 1st output neuron
          ]

通常情况下,我们可以使用梯度下降来训练它。与我们之前的例子不同之处在于,这里我们有几个参数向量,每个向量都有自己的梯度,这意味着我们需要为每个向量调用gradient_step

from scratch.gradient_descent import gradient_step
import tqdm

learning_rate = 1.0

for epoch in tqdm.trange(20000, desc="neural net for xor"):
    for x, y in zip(xs, ys):
        gradients = sqerror_gradients(network, x, y)

        # Take a gradient step for each neuron in each layer
        network = [[gradient_step(neuron, grad, -learning_rate)
                    for neuron, grad in zip(layer, layer_grad)]
                   for layer, layer_grad in zip(network, gradients)]

# check that it learned XOR
assert feed_forward(network, [0, 0])[-1][0] < 0.01
assert feed_forward(network, [0, 1])[-1][0] > 0.99
assert feed_forward(network, [1, 0])[-1][0] > 0.99
assert feed_forward(network, [1, 1])[-1][0] < 0.01

对我来说,得到的网络具有如下权重:

[   # hidden layer
    [[7, 7, -3],     # computes OR
     [5, 5, -8]],    # computes AND
    # output layer
    [[11, -12, -5]]  # computes "first but not second"
]

从概念上讲,这与我们之前的定制网络非常相似。

示例:Fizz Buzz

工程副总裁希望通过让技术候选人解决“Fizz Buzz”,来面试技术候选人,这是一个广为人知的编程挑战:

Print the numbers 1 to 100, except that if the number is divisible
by 3, print "fizz"; if the number is divisible by 5, print "buzz";
and if the number is divisible by 15, print "fizzbuzz".

他认为解决这个问题表明了极限编程技能。你认为这个问题如此简单,以至于一个神经网络可以解决它。

神经网络将向量作为输入,并产生向量作为输出。正如所述,编程问题是将整数转换为字符串。因此,第一个挑战是想出一种将其重新定义为向量问题的方法。

对于输出来说并不困难:基本上有四类输出,所以我们可以将输出编码为一个包含四个 0 和 1 的向量:

def fizz_buzz_encode(x: int) -> Vector:
    if x % 15 == 0:
        return [0, 0, 0, 1]
    elif x % 5 == 0:
        return [0, 0, 1, 0]
    elif x % 3 == 0:
        return [0, 1, 0, 0]
    else:
        return [1, 0, 0, 0]

assert fizz_buzz_encode(2) == [1, 0, 0, 0]
assert fizz_buzz_encode(6) == [0, 1, 0, 0]
assert fizz_buzz_encode(10) == [0, 0, 1, 0]
assert fizz_buzz_encode(30) == [0, 0, 0, 1]

我们将用这个来生成我们的目标向量。输入向量则不那么明显。你不应该只使用一个包含输入数字的一维向量,因为有几个原因。一个单一的输入捕捉到了“强度”,但是 2 是 1 的两倍,4 又是两倍,对这个问题并不感兴趣。此外,只有一个输入,隐藏层将无法计算非常有趣的特征,这意味着它可能无法解决问题。

原来,一个相对有效的方法是将每个数字转换为其二进制表示,由 1 和 0 组成。(别担心,这不明显——至少对我来说不是。)

def binary_encode(x: int) -> Vector:
    binary: List[float] = []

    for i in range(10):
        binary.append(x % 2)
        x = x // 2

    return binary

#                             1  2  4  8 16 32 64 128 256 512
assert binary_encode(0)   == [0, 0, 0, 0, 0, 0, 0, 0,  0,  0]
assert binary_encode(1)   == [1, 0, 0, 0, 0, 0, 0, 0,  0,  0]
assert binary_encode(10)  == [0, 1, 0, 1, 0, 0, 0, 0,  0,  0]
assert binary_encode(101) == [1, 0, 1, 0, 0, 1, 1, 0,  0,  0]
assert binary_encode(999) == [1, 1, 1, 0, 0, 1, 1, 1,  1,  1]

因为目标是构建 1 到 100 的输出,所以在这些数字上进行训练是作弊的。因此,我们将在数字 101 到 1,023 上进行训练(这是我们可以用 10 位二进制表示的最大数字):

xs = [binary_encode(n) for n in range(101, 1024)]
ys = [fizz_buzz_encode(n) for n in range(101, 1024)]

接下来,让我们创建一个具有随机初始权重的神经网络。它将有 10 个输入神经元(因为我们将我们的输入表示为 10 维向量)和 4 个输出神经元(因为我们将我们的目标表示为 4 维向量)。我们将给它 25 个隐藏单元,但我们将使用一个变量,所以这样很容易改变:

NUM_HIDDEN = 25

network = [
    # hidden layer: 10 inputs -> NUM_HIDDEN outputs
    [[random.random() for _ in range(10 + 1)] for _ in range(NUM_HIDDEN)],

    # output_layer: NUM_HIDDEN inputs -> 4 outputs
    [[random.random() for _ in range(NUM_HIDDEN + 1)] for _ in range(4)]
]

那就是了。现在我们准备好训练了。因为这是一个更复杂的问题(而且有很多事情可能会出错),我们希望密切监控训练过程。特别是,对于每个时期,我们将跟踪平方误差的总和并将其打印出来。我们希望确保它们会减少:

from scratch.linear_algebra import squared_distance

learning_rate = 1.0

with tqdm.trange(500) as t:
    for epoch in t:
        epoch_loss = 0.0

        for x, y in zip(xs, ys):
            predicted = feed_forward(network, x)[-1]
            epoch_loss += squared_distance(predicted, y)
            gradients = sqerror_gradients(network, x, y)

            # Take a gradient step for each neuron in each layer
            network = [[gradient_step(neuron, grad, -learning_rate)
                        for neuron, grad in zip(layer, layer_grad)]
                    for layer, layer_grad in zip(network, gradients)]

        t.set_description(f"fizz buzz (loss: {epoch_loss:.2f})")

这将需要一段时间来训练,但最终损失应该开始稳定下来。

最后,我们准备解决我们最初的问题。我们还有一个问题。我们的网络将生成一个四维向量的数字,但我们想要一个单一的预测。我们将通过取argmax来做到这一点,这是最大值的索引:

def argmax(xs: list) -> int:
    """Returns the index of the largest value"""
    return max(range(len(xs)), key=lambda i: xs[i])

assert argmax([0, -1]) == 0               # items[0] is largest
assert argmax([-1, 0]) == 1               # items[1] is largest
assert argmax([-1, 10, 5, 20, -3]) == 3   # items[3] is largest

现在我们终于可以解决“FizzBuzz”了:

num_correct = 0

for n in range(1, 101):
    x = binary_encode(n)
    predicted = argmax(feed_forward(network, x)[-1])
    actual = argmax(fizz_buzz_encode(n))
    labels = [str(n), "fizz", "buzz", "fizzbuzz"]
    print(n, labels[predicted], labels[actual])

    if predicted == actual:
        num_correct += 1

print(num_correct, "/", 100)

对我来说,训练后的网络获得了 96/100 的正确率,远高于工程副总裁的招聘门槛。面对这些证据,他屈服了并将面试挑战改为“反转二叉树”。

进一步探索

第十九章:深度学习

略知一二是危险的;要么深入探索,要么不要触碰那泊里亚之泉。

亚历山大·蒲柏

深度学习最初是指“深度”神经网络的应用(即具有多个隐藏层的网络),尽管实际上这个术语现在包含了各种各样的神经网络架构(包括我们在第十八章中开发的“简单”神经网络)。

在本章中,我们将在之前的工作基础上继续,并查看更广泛的神经网络。为此,我们将介绍一些允许我们以更一般方式思考神经网络的抽象概念。

张量

之前,我们区分了向量(一维数组)和矩阵(二维数组)。当我们开始处理更复杂的神经网络时,我们还需要使用更高维度的数组。

在许多神经网络库中,n维数组被称为张量,这也是我们将它们称为的方式。(有一些学术严谨的数学原因不将n维数组称为张量;如果你是这样的学究,我们注意到你的反对。)

如果我要写一本关于深度学习的整本书,我会实现一个功能齐全的Tensor类,重载 Python 的算术运算符,并能处理各种其他操作。这样的实现将需要一个完整的章节。在这里,我们将简单处理,并说一个Tensor只是一个list。在某种意义上是正确的——我们所有的向量、矩阵和更高维度的模拟 是列表。但在另一方面则不正确——大多数 Python 的list不是我们所说的n维数组。

注意

理想情况下,你想做这样的事情:

# A Tensor is either a float, or a List of Tensors
Tensor = Union[float, List[Tensor]]

然而,Python 不允许你定义这样的递归类型。即使它允许,那个定义仍然是错误的,因为它允许像这样的坏“张量”:

[[1.0, 2.0],
 [3.0]]

其行具有不同的大小,这使得它不是一个n维数组。

所以,正如我所说的,我们将简单地作弊:

Tensor = list

而且,我们将编写一个辅助函数来找到张量的形状

from typing import List

def shape(tensor: Tensor) -> List[int]:
    sizes: List[int] = []
    while isinstance(tensor, list):
        sizes.append(len(tensor))
        tensor = tensor[0]
    return sizes

assert shape([1, 2, 3]) == [3]
assert shape([[1, 2], [3, 4], [5, 6]]) == [3, 2]

因为张量可以具有任意数量的维度,我们通常需要递归地处理它们。在一维情况下我们会做一件事,在更高维情况下我们会递归处理:

def is_1d(tensor: Tensor) -> bool:
    """
 If tensor[0] is a list, it's a higher-order tensor.
 Otherwise, tensor is 1-dimensional (that is, a vector).
 """
    return not isinstance(tensor[0], list)

assert is_1d([1, 2, 3])
assert not is_1d([[1, 2], [3, 4]])

我们可以利用这一点编写一个递归的tensor_sum函数:

def tensor_sum(tensor: Tensor) -> float:
    """Sums up all the values in the tensor"""
    if is_1d(tensor):
        return sum(tensor)  # just a list of floats, use Python sum
    else:
        return sum(tensor_sum(tensor_i)      # Call tensor_sum on each row
                   for tensor_i in tensor)   # and sum up those results.

assert tensor_sum([1, 2, 3]) == 6
assert tensor_sum([[1, 2], [3, 4]]) == 10

如果你不习惯递归思维,你应该思考直到理解为止,因为我们将在本章中始终使用相同的逻辑。但是,我们将创建几个辅助函数,这样我们就不必在每个地方重写这个逻辑。首先,将一个函数逐元素地应用于单个张量:

from typing import Callable

def tensor_apply(f: Callable[[float], float], tensor: Tensor) -> Tensor:
    """Applies f elementwise"""
    if is_1d(tensor):
        return [f(x) for x in tensor]
    else:
        return [tensor_apply(f, tensor_i) for tensor_i in tensor]

assert tensor_apply(lambda x: x + 1, [1, 2, 3]) == [2, 3, 4]
assert tensor_apply(lambda x: 2 * x, [[1, 2], [3, 4]]) == [[2, 4], [6, 8]]

我们可以用它来编写一个函数,创建一个与给定张量形状相同的零张量:

def zeros_like(tensor: Tensor) -> Tensor:
    return tensor_apply(lambda _: 0.0, tensor)

assert zeros_like([1, 2, 3]) == [0, 0, 0]
assert zeros_like([[1, 2], [3, 4]]) == [[0, 0], [0, 0]]

我们还需要将函数应用于两个张量对应的元素(它们最好具有完全相同的形状,尽管我们不会检查这一点):

def tensor_combine(f: Callable[[float, float], float],
                   t1: Tensor,
                   t2: Tensor) -> Tensor:
    """Applies f to corresponding elements of t1 and t2"""
    if is_1d(t1):
        return [f(x, y) for x, y in zip(t1, t2)]
    else:
        return [tensor_combine(f, t1_i, t2_i)
                for t1_i, t2_i in zip(t1, t2)]

import operator
assert tensor_combine(operator.add, [1, 2, 3], [4, 5, 6]) == [5, 7, 9]
assert tensor_combine(operator.mul, [1, 2, 3], [4, 5, 6]) == [4, 10, 18]

层的抽象

在上一章中,我们构建了一个简单的神经网络,允许我们堆叠两层神经元,每层计算 sigmoid(dot(weights, inputs))

虽然这可能是对实际神经元功能的理想化表示,但实际上,我们希望允许更多种类的操作。也许我们希望神经元记住它们以前的输入。也许我们想使用不同的激活函数而不是 sigmoid。而且通常情况下,我们希望使用超过两层的网络。(我们的 feed_forward 函数实际上可以处理任意数量的层,但我们的梯度计算不能。)

在本章中,我们将构建用于实现各种神经网络的机制。我们的基本抽象将是 Layer,它知道如何将某个函数应用到其输入上,并知道如何反向传播梯度。

我们在第十八章构建的神经网络可以理解为一个“线性”层,后跟一个“sigmoid”层,然后是另一个线性层和另一个 sigmoid 层。在这些术语中没有区分它们,但这样做将允许我们尝试更通用的结构:

from typing import Iterable, Tuple

class Layer:
    """
 Our neural networks will be composed of Layers, each of which
 knows how to do some computation on its inputs in the "forward"
 direction and propagate gradients in the "backward" direction.
 """
    def forward(self, input):
        """
 Note the lack of types. We're not going to be prescriptive
 about what kinds of inputs layers can take and what kinds
 of outputs they can return.
 """
        raise NotImplementedError

    def backward(self, gradient):
        """
 Similarly, we're not going to be prescriptive about what the
 gradient looks like. It's up to you the user to make sure
 that you're doing things sensibly.
 """
        raise NotImplementedError

    def params(self) -> Iterable[Tensor]:
        """
 Returns the parameters of this layer. The default implementation
 returns nothing, so that if you have a layer with no parameters
 you don't have to implement this.
 """
        return ()

    def grads(self) -> Iterable[Tensor]:
        """
 Returns the gradients, in the same order as params().
 """
        return ()

forwardbackward 方法需要在我们的具体子类中实现。一旦我们建立了神经网络,我们将希望使用梯度下降来训练它,这意味着我们希望使用其梯度更新网络中的每个参数。因此,我们要求每一层能够告诉我们它的参数和梯度。

一些层(例如,将 sigmoid 应用于每个输入的层)没有需要更新的参数,因此我们提供了一个处理这种情况的默认实现。

让我们看看那层:

from scratch.neural_networks import sigmoid

class Sigmoid(Layer):
    def forward(self, input: Tensor) -> Tensor:
        """
 Apply sigmoid to each element of the input tensor,
 and save the results to use in backpropagation.
 """
        self.sigmoids = tensor_apply(sigmoid, input)
        return self.sigmoids

    def backward(self, gradient: Tensor) -> Tensor:
        return tensor_combine(lambda sig, grad: sig * (1 - sig) * grad,
                              self.sigmoids,
                              gradient)

这里有几件事情需要注意。一是在前向传播期间,我们保存了计算出的 sigmoid 值,以便稍后在反向传播中使用。我们的层通常需要执行这种操作。

其次,您可能想知道 sig * (1 - sig) * grad 是从哪里来的。这只是微积分中的链式法则,对应于我们先前神经网络中的 output * (1 - output) * (output - target) 项。

最后,您可以看到我们如何使用 tensor_applytensor_combine 函数。我们的大多数层将类似地使用这些函数。

线性层

我们还需要从第十八章复制神经网络所需的“线性”层,该层代表神经元中的 dot(weights, inputs) 部分。

这个层将有参数,我们希望用随机值初始化它们。

结果表明,初始参数值可能极大地影响网络的训练速度(有时甚至影响网络是否能训练)。如果权重过大,它们可能会在激活函数梯度接近零的范围内产生大的输出。具有零梯度部分的网络必然无法通过梯度下降学习任何内容。

因此,我们将实现三种不同的方案来随机生成我们的权重张量。第一种是从[0, 1]上的随机均匀分布中选择每个值,即使用random.random()。第二种(默认)是从标准正态分布中随机选择每个值。第三种是使用Xavier 初始化,其中每个权重都从均值为 0、方差为 2 / (num_inputs + num_outputs)的正态分布中随机抽取。事实证明,这通常对神经网络权重效果很好。我们将使用random_uniform函数和random_normal函数来实现这些:

import random

from scratch.probability import inverse_normal_cdf

def random_uniform(*dims: int) -> Tensor:
    if len(dims) == 1:
        return [random.random() for _ in range(dims[0])]
    else:
        return [random_uniform(*dims[1:]) for _ in range(dims[0])]

def random_normal(*dims: int,
                  mean: float = 0.0,
                  variance: float = 1.0) -> Tensor:
    if len(dims) == 1:
        return [mean + variance * inverse_normal_cdf(random.random())
                for _ in range(dims[0])]
    else:
        return [random_normal(*dims[1:], mean=mean, variance=variance)
                for _ in range(dims[0])]

assert shape(random_uniform(2, 3, 4)) == [2, 3, 4]
assert shape(random_normal(5, 6, mean=10)) == [5, 6]

然后将它们全部包装在一个random_tensor函数中:

def random_tensor(*dims: int, init: str = 'normal') -> Tensor:
    if init == 'normal':
        return random_normal(*dims)
    elif init == 'uniform':
        return random_uniform(*dims)
    elif init == 'xavier':
        variance = len(dims) / sum(dims)
        return random_normal(*dims, variance=variance)
    else:
        raise ValueError(f"unknown init: {init}")

现在我们可以定义我们的线性层了。我们需要用输入的维度来初始化它(这告诉我们每个神经元需要多少个权重),输出的维度(这告诉我们应该有多少个神经元),以及我们想要的初始化方案:

from scratch.linear_algebra import dot

class Linear(Layer):
    def __init__(self,
                 input_dim: int,
                 output_dim: int,
                 init: str = 'xavier') -> None:
        """
 A layer of output_dim neurons, each with input_dim weights
 (and a bias).
 """
        self.input_dim = input_dim
        self.output_dim = output_dim

        # self.w[o] is the weights for the oth neuron
        self.w = random_tensor(output_dim, input_dim, init=init)

        # self.b[o] is the bias term for the oth neuron
        self.b = random_tensor(output_dim, init=init)
注意

如果你想知道初始化方案有多重要,这一章中一些网络如果使用与我使用的不同初始化方法,我根本无法训练它们。

forward方法很容易实现。我们将得到每个神经元的一个输出,将其放入一个向量中。每个神经元的输出只是其权重与输入的dot积,加上偏置:

    def forward(self, input: Tensor) -> Tensor:
        # Save the input to use in the backward pass.
        self.input = input

        # Return the vector of neuron outputs.
        return [dot(input, self.w[o]) + self.b[o]
                for o in range(self.output_dim)]

backward方法更复杂一些,但如果你懂微积分,它并不难:

    def backward(self, gradient: Tensor) -> Tensor:
        # Each b[o] gets added to output[o], which means
        # the gradient of b is the same as the output gradient.
        self.b_grad = gradient

        # Each w[o][i] multiplies input[i] and gets added to output[o].
        # So its gradient is input[i] * gradient[o].
        self.w_grad = [[self.input[i] * gradient[o]
                        for i in range(self.input_dim)]
                       for o in range(self.output_dim)]

        # Each input[i] multiplies every w[o][i] and gets added to every
        # output[o]. So its gradient is the sum of w[o][i] * gradient[o]
        # across all the outputs.
        return [sum(self.w[o][i] * gradient[o] for o in range(self.output_dim))
                for i in range(self.input_dim)]
注意

在一个“真正”的张量库中,这些(以及许多其他)操作将被表示为矩阵或张量乘法,这些库被设计成能够非常快速地执行。我们的库非常慢。

最后,我们确实需要实现paramsgrads。我们有两个参数和两个相应的梯度:

    def params(self) -> Iterable[Tensor]:
        return [self.w, self.b]

    def grads(self) -> Iterable[Tensor]:
        return [self.w_grad, self.b_grad]

神经网络作为层序列

我们希望将神经网络视为层序列,因此让我们想出一种将多个层组合成一个的方法。得到的神经网络本身就是一个层,它以明显的方式实现了Layer方法:

from typing import List

class Sequential(Layer):
    """
 A layer consisting of a sequence of other layers.
 It's up to you to make sure that the output of each layer
 makes sense as the input to the next layer.
 """
    def __init__(self, layers: List[Layer]) -> None:
        self.layers = layers

    def forward(self, input):
        """Just forward the input through the layers in order."""
        for layer in self.layers:
            input = layer.forward(input)
        return input

    def backward(self, gradient):
        """Just backpropagate the gradient through the layers in reverse."""
        for layer in reversed(self.layers):
            gradient = layer.backward(gradient)
        return gradient

    def params(self) -> Iterable[Tensor]:
        """Just return the params from each layer."""
        return (param for layer in self.layers for param in layer.params())

    def grads(self) -> Iterable[Tensor]:
        """Just return the grads from each layer."""
        return (grad for layer in self.layers for grad in layer.grads())

因此,我们可以将用于 XOR 的神经网络表示为:

xor_net = Sequential([
    Linear(input_dim=2, output_dim=2),
    Sigmoid(),
    Linear(input_dim=2, output_dim=1),
    Sigmoid()
])

但我们仍然需要一些更多的机制来训练它。

损失和优化

之前,我们为我们的模型编写了单独的损失函数和梯度函数。在这里,我们将想要尝试不同的损失函数,所以(像往常一样)我们将引入一个新的Loss抽象,它封装了损失计算和梯度计算:

class Loss:
    def loss(self, predicted: Tensor, actual: Tensor) -> float:
        """How good are our predictions? (Larger numbers are worse.)"""
        raise NotImplementedError

    def gradient(self, predicted: Tensor, actual: Tensor) -> Tensor:
        """How does the loss change as the predictions change?"""
        raise NotImplementedError

我们已经多次使用过损失函数,即平方误差的和,所以实现它应该很容易。唯一的技巧是我们需要使用tensor_combine

class SSE(Loss):
    """Loss function that computes the sum of the squared errors."""
    def loss(self, predicted: Tensor, actual: Tensor) -> float:
        # Compute the tensor of squared differences
        squared_errors = tensor_combine(
            lambda predicted, actual: (predicted - actual) ** 2,
            predicted,
            actual)

        # And just add them up
        return tensor_sum(squared_errors)

    def gradient(self, predicted: Tensor, actual: Tensor) -> Tensor:
        return tensor_combine(
            lambda predicted, actual: 2 * (predicted - actual),
            predicted,
            actual)

(稍后我们将看一个不同的损失函数。)

最后需要弄清楚的是梯度下降。在整本书中,我们通过一个训练循环手动进行所有的梯度下降,类似于以下操作:

theta = gradient_step(theta, grad, -learning_rate)

这对我们来说不太适用,原因有几个。首先是我们的神经网络将有很多参数,我们需要更新所有这些参数。其次是我们希望能够使用更聪明的梯度下降的变体,而不想每次都重新编写它们。

因此,我们将引入一个(你猜对了)Optimizer 抽象,梯度下降将是一个具体的实例:

class Optimizer:
    """
 An optimizer updates the weights of a layer (in place) using information
 known by either the layer or the optimizer (or by both).
 """
    def step(self, layer: Layer) -> None:
        raise NotImplementedError

之后,使用 tensor_combine 再次实现梯度下降就很容易了:

class GradientDescent(Optimizer):
    def __init__(self, learning_rate: float = 0.1) -> None:
        self.lr = learning_rate

    def step(self, layer: Layer) -> None:
        for param, grad in zip(layer.params(), layer.grads()):
            # Update param using a gradient step
            param[:] = tensor_combine(
                lambda param, grad: param - grad * self.lr,
                param,
                grad)

可能令人惊讶的唯一一件事是“切片赋值”,这反映了重新分配列表不会改变其原始值的事实。也就是说,如果你只是做了 param = tensor_combine(. . .),你会重新定义局部变量 param,但你不会影响存储在层中的原始参数张量。然而,如果你分配给切片 [:],它实际上会改变列表内的值。

这里有一个简单的示例来演示:

tensor = [[1, 2], [3, 4]]

for row in tensor:
    row = [0, 0]
assert tensor == [[1, 2], [3, 4]], "assignment doesn't update a list"

for row in tensor:
    row[:] = [0, 0]
assert tensor == [[0, 0], [0, 0]], "but slice assignment does"

如果你在 Python 方面有些经验不足,这种行为可能会让你感到惊讶,所以要沉思一下,并尝试自己的例子,直到它变得清晰为止。

为了展示这种抽象的价值,让我们实现另一个使用 动量 的优化器。其思想是我们不希望对每一个新的梯度过于反应,因此我们保持已看到的梯度的运行平均,每次新的梯度更新它,并朝平均梯度的方向迈出一步:

class Momentum(Optimizer):
    def __init__(self,
                 learning_rate: float,
                 momentum: float = 0.9) -> None:
        self.lr = learning_rate
        self.mo = momentum
        self.updates: List[Tensor] = []  # running average

    def step(self, layer: Layer) -> None:
        # If we have no previous updates, start with all zeros
        if not self.updates:
            self.updates = [zeros_like(grad) for grad in layer.grads()]

        for update, param, grad in zip(self.updates,
                                       layer.params(),
                                       layer.grads()):
            # Apply momentum
            update[:] = tensor_combine(
                lambda u, g: self.mo * u + (1 - self.mo) * g,
                update,
                grad)

            # Then take a gradient step
            param[:] = tensor_combine(
                lambda p, u: p - self.lr * u,
                param,
                update)

因为我们使用了 Optimizer 抽象,我们可以轻松地在不同的优化器之间切换。

示例:重新思考 XOR

让我们看看使用我们的新框架来训练一个能计算 XOR 的网络有多容易。我们首先重新创建训练数据:

# training data
xs = [[0., 0], [0., 1], [1., 0], [1., 1]]
ys = [[0.], [1.], [1.], [0.]]

然后我们定义网络,尽管现在我们可以省略最后的 sigmoid 层:

random.seed(0)

net = Sequential([
    Linear(input_dim=2, output_dim=2),
    Sigmoid(),
    Linear(input_dim=2, output_dim=1)
])

现在我们可以编写一个简单的训练循环,除了现在我们可以使用 OptimizerLoss 的抽象。这使得我们可以轻松尝试不同的优化方法:

import tqdm

optimizer = GradientDescent(learning_rate=0.1)
loss = SSE()

with tqdm.trange(3000) as t:
    for epoch in t:
        epoch_loss = 0.0

        for x, y in zip(xs, ys):
            predicted = net.forward(x)
            epoch_loss += loss.loss(predicted, y)
            gradient = loss.gradient(predicted, y)
            net.backward(gradient)

            optimizer.step(net)

        t.set_description(f"xor loss {epoch_loss:.3f}")

这应该会快速训练,并且你应该会看到损失下降。现在我们可以检查权重了:

for param in net.params():
    print(param)

对于我的网络,我发现大致上:

hidden1 = -2.6 * x1 + -2.7 * x2 + 0.2  # NOR
hidden2 =  2.1 * x1 +  2.1 * x2 - 3.4  # AND
output =  -3.1 * h1 + -2.6 * h2 + 1.8  # NOR

因此,如果 hidden1 激活,那么没有一个输入是 1。如果 hidden2 激活,那么两个输入都是 1。如果 output 激活,那么既不是 hidden 输出是 1,也不是两个输入都是 1。确实,这正是 XOR 的逻辑。

注意,这个网络学习了不同于我们在 第十八章 中训练的网络的特征,但它仍然能够执行相同的操作。

其他激活函数

sigmoid 函数因为几个原因已经不再流行。其中一个原因是 sigmoid(0) 等于 1/2,这意味着输入总和为 0 的神经元具有正的输出。另一个原因是对于非常大和非常小的输入,它的梯度非常接近 0,这意味着它的梯度可能会“饱和”,它的权重可能会被困住。

一个流行的替代方案是tanh(“双曲正切”),这是一个不同的 S 型函数,范围从–1 到 1,并且如果其输入为 0,则输出为 0。tanh(x)的导数就是1 - tanh(x) ** 2,这样写起来很容易:

import math

def tanh(x: float) -> float:
    # If x is very large or very small, tanh is (essentially) 1 or -1.
    # We check for this because, e.g., math.exp(1000) raises an error.
    if x < -100:  return -1
    elif x > 100: return 1

    em2x = math.exp(-2 * x)
    return (1 - em2x) / (1 + em2x)

class Tanh(Layer):
    def forward(self, input: Tensor) -> Tensor:
        # Save tanh output to use in backward pass.
        self.tanh = tensor_apply(tanh, input)
        return self.tanh

    def backward(self, gradient: Tensor) -> Tensor:
        return tensor_combine(
            lambda tanh, grad: (1 - tanh ** 2) * grad,
            self.tanh,
            gradient)

在更大的网络中,另一个流行的替代方案是Relu,对于负输入为 0,对于正输入为恒等:

class Relu(Layer):
    def forward(self, input: Tensor) -> Tensor:
        self.input = input
        return tensor_apply(lambda x: max(x, 0), input)

    def backward(self, gradient: Tensor) -> Tensor:
        return tensor_combine(lambda x, grad: grad if x > 0 else 0,
                              self.input,
                              gradient)

还有许多其他的函数。我鼓励你在你的网络中尝试它们。

示例:FizzBuzz 再探讨

现在,我们可以使用我们的“深度学习”框架来重新生成我们在“示例:Fizz Buzz”中的解决方案。让我们设置数据:

from scratch.neural_networks import binary_encode, fizz_buzz_encode, argmax

xs = [binary_encode(n) for n in range(101, 1024)]
ys = [fizz_buzz_encode(n) for n in range(101, 1024)]

并创建网络:

NUM_HIDDEN = 25

random.seed(0)

net = Sequential([
    Linear(input_dim=10, output_dim=NUM_HIDDEN, init='uniform'),
    Tanh(),
    Linear(input_dim=NUM_HIDDEN, output_dim=4, init='uniform'),
    Sigmoid()
])

当我们训练时,让我们也跟踪一下训练集上的准确率:

def fizzbuzz_accuracy(low: int, hi: int, net: Layer) -> float:
    num_correct = 0
    for n in range(low, hi):
        x = binary_encode(n)
        predicted = argmax(net.forward(x))
        actual = argmax(fizz_buzz_encode(n))
        if predicted == actual:
            num_correct += 1

    return num_correct / (hi - low)
optimizer = Momentum(learning_rate=0.1, momentum=0.9)
loss = SSE()

with tqdm.trange(1000) as t:
    for epoch in t:
        epoch_loss = 0.0

        for x, y in zip(xs, ys):
            predicted = net.forward(x)
            epoch_loss += loss.loss(predicted, y)
            gradient = loss.gradient(predicted, y)
            net.backward(gradient)

            optimizer.step(net)

        accuracy = fizzbuzz_accuracy(101, 1024, net)
        t.set_description(f"fb loss: {epoch_loss:.2f} acc: {accuracy:.2f}")

# Now check results on the test set
print("test results", fizzbuzz_accuracy(1, 101, net))

经过 1000 次训练迭代后,模型在测试集上达到了 90%的准确率;如果你继续训练,它应该能表现得更好。(我认为只用 25 个隐藏单元不可能训练到 100%的准确率,但如果增加到 50 个隐藏单元,这是可能的。)

Softmax 函数和交叉熵

我们在前面章节使用的神经网络以Sigmoid层结束,这意味着其输出是介于 0 和 1 之间的向量。特别是,它可以输出一个完全是 0 的向量,或者一个完全是 1 的向量。然而,在分类问题中,我们希望输出一个 1 代表正确类别,0 代表所有不正确的类别。通常我们的预测不会那么完美,但我们至少希望预测一个实际的类别概率分布。

例如,如果我们有两个类别,而我们的模型输出[0, 0],很难理解这意味着什么。它不认为输出属于任何类别?

但是如果我们的模型输出[0.4, 0.6],我们可以将其解释为预测我们的输入属于第一类的概率为 0.4,属于第二类的概率为 0.6。

为了实现这一点,我们通常放弃最后的Sigmoid层,而是使用softmax函数,将实数向量转换为概率向量。我们对向量中的每个数计算exp(x),得到一组正数。然后,我们将这些正数除以它们的和,得到一组加起来为 1 的正数——即概率向量。

如果我们试图计算,比如exp(1000),我们会得到一个 Python 错误,所以在计算exp之前,我们要减去最大值。这样做结果是相同的概率;在 Python 中这样计算更安全:

def softmax(tensor: Tensor) -> Tensor:
    """Softmax along the last dimension"""
    if is_1d(tensor):
        # Subtract largest value for numerical stability.
        largest = max(tensor)
        exps = [math.exp(x - largest) for x in tensor]

        sum_of_exps = sum(exps)                 # This is the total "weight."
        return [exp_i / sum_of_exps             # Probability is the fraction
                for exp_i in exps]              # of the total weight.
    else:
        return [softmax(tensor_i) for tensor_i in tensor]

一旦我们的网络产生了概率,我们通常使用另一种称为交叉熵(有时称为“负对数似然”)的损失函数。

你可能记得,在“最大似然估计”中,我们通过引用最小二乘在线性回归中的使用来证明(在某些假设下)最小二乘系数最大化了观察数据的似然。

在这里我们可以做类似的事情:如果我们的网络输出是概率,交叉熵损失表示观察数据的负对数似然,这意味着最小化该损失等同于最大化(因而最大化)训练数据的似然。

通常情况下,我们不会将softmax函数作为神经网络本身的一部分。这是因为事实证明,如果softmax是你的损失函数的一部分,但不是网络本身的一部分,那么损失关于网络输出的梯度计算非常容易。

class SoftmaxCrossEntropy(Loss):
    """
 This is the negative-log-likelihood of the observed values, given the
 neural net model. So if we choose weights to minimize it, our model will
 be maximizing the likelihood of the observed data.
 """
    def loss(self, predicted: Tensor, actual: Tensor) -> float:
        # Apply softmax to get probabilities
        probabilities = softmax(predicted)

        # This will be log p_i for the actual class i and 0 for the other
        # classes. We add a tiny amount to p to avoid taking log(0).
        likelihoods = tensor_combine(lambda p, act: math.log(p + 1e-30) * act,
                                     probabilities,
                                     actual)

        # And then we just sum up the negatives.
        return -tensor_sum(likelihoods)

    def gradient(self, predicted: Tensor, actual: Tensor) -> Tensor:
        probabilities = softmax(predicted)

        # Isn't this a pleasant equation?
        return tensor_combine(lambda p, actual: p - actual,
                              probabilities,
                              actual)

如果现在我使用SoftmaxCrossEntropy损失训练相同的 Fizz Buzz 网络,我发现它通常训练速度快得多(即在更少的周期内)。这可能是因为找到使softmax到给定分布的权重比找到使sigmoid到给定分布的权重更容易。

换句话说,如果我需要预测类别 0(一个向量,第一个位置为 1,其余位置为 0),在linear + sigmoid的情况下,我需要第一个输出为一个较大的正数,其余的输出为较大的负数。然而,在softmax的情况下,我只需要第一个输出比其余的输出。显然,第二种情况发生的方式更多,这表明找到使其成为可能的权重应该更容易:

random.seed(0)

net = Sequential([
    Linear(input_dim=10, output_dim=NUM_HIDDEN, init='uniform'),
    Tanh(),
    Linear(input_dim=NUM_HIDDEN, output_dim=4, init='uniform')
    # No final sigmoid layer now
])

optimizer = Momentum(learning_rate=0.1, momentum=0.9)
loss = SoftmaxCrossEntropy()

with tqdm.trange(100) as t:
    for epoch in t:
        epoch_loss = 0.0

        for x, y in zip(xs, ys):
            predicted = net.forward(x)
            epoch_loss += loss.loss(predicted, y)
            gradient = loss.gradient(predicted, y)
            net.backward(gradient)

            optimizer.step(net)

        accuracy = fizzbuzz_accuracy(101, 1024, net)
        t.set_description(f"fb loss: {epoch_loss:.3f} acc: {accuracy:.2f}")

# Again check results on the test set
print("test results", fizzbuzz_accuracy(1, 101, net))

Dropout

像大多数机器学习模型一样,神经网络容易过拟合其训练数据。我们之前已经看到了一些缓解这种情况的方法;例如,在“正则化”中,我们对大的权重进行了惩罚,这有助于防止过拟合。

常见的神经网络正则化方法之一是使用dropout。在训练时,我们随机关闭每个神经元(即将其输出替换为 0),关闭的概率固定。这意味着网络不能学会依赖任何单个神经元,这似乎有助于防止过拟合。

在评估时,我们不希望关闭任何神经元,因此Dropout层需要知道它是在训练还是不训练。此外,在训练时,Dropout层仅传递其输入的一些随机部分。为了在评估期间使其输出可比较,我们将使用相同的比例缩减输出(均匀地):

class Dropout(Layer):
    def __init__(self, p: float) -> None:
        self.p = p
        self.train = True

    def forward(self, input: Tensor) -> Tensor:
        if self.train:
            # Create a mask of 0s and 1s shaped like the input
            # using the specified probability.
            self.mask = tensor_apply(
                lambda _: 0 if random.random() < self.p else 1,
                input)
            # Multiply by the mask to dropout inputs.
            return tensor_combine(operator.mul, input, self.mask)
        else:
            # During evaluation just scale down the outputs uniformly.
            return tensor_apply(lambda x: x * (1 - self.p), input)

    def backward(self, gradient: Tensor) -> Tensor:
        if self.train:
            # Only propagate the gradients where mask == 1.
            return tensor_combine(operator.mul, gradient, self.mask)
        else:
            raise RuntimeError("don't call backward when not in train mode")

我们将使用这个来帮助防止我们的深度学习模型过拟合。

示例:MNIST

MNIST 是一个手写数字数据集,每个人都用它来学习深度学习。

这种数据以一种有些棘手的二进制格式提供,因此我们将安装mnist库来处理它。(是的,这部分从技术上讲并非“从头开始”。)

python -m pip install mnist

然后我们可以加载数据:

import mnist

# This will download the data; change this to where you want it.
# (Yes, it's a 0-argument function, that's what the library expects.)
# (Yes, I'm assigning a lambda to a variable, like I said never to do.)
mnist.temporary_dir = lambda: '/tmp'

# Each of these functions first downloads the data and returns a numpy array.
# We call .tolist() because our "tensors" are just lists.
train_images = mnist.train_images().tolist()
train_labels = mnist.train_labels().tolist()

assert shape(train_images) == [60000, 28, 28]
assert shape(train_labels) == [60000]

让我们绘制前 100 张训练图像,看看它们的样子(图 19-1):

import matplotlib.pyplot as plt

fig, ax = plt.subplots(10, 10)

for i in range(10):
    for j in range(10):
        # Plot each image in black and white and hide the axes.
        ax[i][j].imshow(train_images[10 * i + j], cmap='Greys')
        ax[i][j].xaxis.set_visible(False)
        ax[i][j].yaxis.set_visible(False)

plt.show()

MNIST 图像

图 19-1. MNIST 图像

您可以看到它们确实看起来像手写数字。

注意

我第一次尝试显示图像时,结果是黄色数字在黑色背景上。我既不聪明也不够细心,不知道我需要添加 cmap=*Greys* 才能获得黑白图像;我在 Stack Overflow 上搜索找到了解决方案。作为一名数据科学家,你将变得非常擅长这种工作流程。

我们还需要加载测试图像:

test_images = mnist.test_images().tolist()
test_labels = mnist.test_labels().tolist()

assert shape(test_images) == [10000, 28, 28]
assert shape(test_labels) == [10000]

每个图像是 28 × 28 像素,但我们的线性层只能处理一维输入,因此我们将它们展平(同时除以 256 以使它们在 0 到 1 之间)。此外,如果我们的输入平均值为 0,我们的神经网络将更好地训练,因此我们将减去平均值:

# Compute the average pixel value
avg = tensor_sum(train_images) / 60000 / 28 / 28

# Recenter, rescale, and flatten
train_images = [[(pixel - avg) / 256 for row in image for pixel in row]
                for image in train_images]
test_images = [[(pixel - avg) / 256 for row in image for pixel in row]
               for image in test_images]

assert shape(train_images) == [60000, 784], "images should be flattened"
assert shape(test_images) == [10000, 784], "images should be flattened"

# After centering, average pixel should be very close to 0
assert -0.0001 < tensor_sum(train_images) < 0.0001

我们还希望对目标进行独热编码,因为我们有 10 个输出。首先让我们编写一个 one_hot_encode 函数:

def one_hot_encode(i: int, num_labels: int = 10) -> List[float]:
    return [1.0 if j == i else 0.0 for j in range(num_labels)]

assert one_hot_encode(3) == [0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
assert one_hot_encode(2, num_labels=5) == [0, 0, 1, 0, 0]

然后将其应用到我们的数据中:

train_labels = [one_hot_encode(label) for label in train_labels]
test_labels = [one_hot_encode(label) for label in test_labels]

assert shape(train_labels) == [60000, 10]
assert shape(test_labels) == [10000, 10]

我们抽象化的一个优势是,我们可以用多种模型来使用相同的训练/评估循环。因此,让我们首先编写它。我们会传入我们的模型、数据、损失函数和(如果我们在训练)一个优化器。

它将遍历我们的数据,跟踪性能,并且(如果我们传入了优化器)更新我们的参数:

import tqdm

def loop(model: Layer,
         images: List[Tensor],
         labels: List[Tensor],
         loss: Loss,
         optimizer: Optimizer = None) -> None:
    correct = 0         # Track number of correct predictions.
    total_loss = 0.0    # Track total loss.

    with tqdm.trange(len(images)) as t:
        for i in t:
            predicted = model.forward(images[i])             # Predict.
            if argmax(predicted) == argmax(labels[i]):       # Check for
                correct += 1                                 # correctness.
            total_loss += loss.loss(predicted, labels[i])    # Compute loss.

            # If we're training, backpropagate gradient and update weights.
            if optimizer is not None:
                gradient = loss.gradient(predicted, labels[i])
                model.backward(gradient)
                optimizer.step(model)

            # And update our metrics in the progress bar.
            avg_loss = total_loss / (i + 1)
            acc = correct / (i + 1)
            t.set_description(f"mnist loss: {avg_loss:.3f} acc: {acc:.3f}")

作为基准,我们可以使用我们的深度学习库训练(多类别)逻辑回归模型,这只是一个单一线性层,后跟一个 softmax。该模型(实质上)只是寻找 10 个线性函数,如果输入代表,比如说,一个 5,那么第 5 个线性函数将产生最大的输出。

通过我们的 60,000 个训练样本的一次遍历应该足以学习模型:

random.seed(0)

# Logistic regression is just a linear layer followed by softmax
model = Linear(784, 10)
loss = SoftmaxCrossEntropy()

# This optimizer seems to work
optimizer = Momentum(learning_rate=0.01, momentum=0.99)

# Train on the training data
loop(model, train_images, train_labels, loss, optimizer)

# Test on the test data (no optimizer means just evaluate)
loop(model, test_images, test_labels, loss)

这个准确率约为 89%。让我们看看是否可以通过深度神经网络做得更好。我们将使用两个隐藏层,第一个有 30 个神经元,第二个有 10 个神经元。我们将使用我们的 Tanh 激活函数:

random.seed(0)

# Name them so we can turn train on and off
dropout1 = Dropout(0.1)
dropout2 = Dropout(0.1)

model = Sequential([
    Linear(784, 30),  # Hidden layer 1: size 30
    dropout1,
    Tanh(),
    Linear(30, 10),   # Hidden layer 2: size 10
    dropout2,
    Tanh(),
    Linear(10, 10)    # Output layer: size 10
])

我们可以只使用相同的训练循环!

optimizer = Momentum(learning_rate=0.01, momentum=0.99)
loss = SoftmaxCrossEntropy()

# Enable dropout and train (takes > 20 minutes on my laptop!)
dropout1.train = dropout2.train = True
loop(model, train_images, train_labels, loss, optimizer)

# Disable dropout and evaluate
dropout1.train = dropout2.train = False
loop(model, test_images, test_labels, loss)

我们的深度模型在测试集上的准确率超过了 92%,这比简单的逻辑回归模型有了显著提升。

MNIST 网站 描述了多种优于这些模型的模型。其中许多模型可以使用我们迄今为止开发的机制实现,但在我们的列表作为张量的框架中训练时间将非常长。一些最佳模型涉及到 卷积 层,这很重要,但不幸的是对于数据科学入门书籍来说有些超出范围。

保存和加载模型

这些模型需要很长时间来训练,因此如果我们能保存它们以免每次都重新训练就太好了。幸运的是,我们可以使用 json 模块轻松地将模型权重序列化到文件中。

对于保存,我们可以使用 Layer.params 收集权重,将它们放入列表中,并使用 json.dump 将该列表保存到文件中:

import json

def save_weights(model: Layer, filename: str) -> None:
    weights = list(model.params())
    with open(filename, 'w') as f:
        json.dump(weights, f)

将权重加载回来只需要多做一点工作。我们只需使用json.load从文件中获取权重列表,然后使用切片赋值将权重设置到我们的模型中。

(具体来说,这意味着我们必须自己实例化模型,然后加载权重。另一种方法是保存模型架构的某种表示,并使用它来实例化模型。这并不是一个糟糕的想法,但这将需要大量的代码和对所有我们的Layer进行更改,所以我们将坚持使用更简单的方法。)

在加载权重之前,我们希望检查它们与我们加载到其中的模型参数具有相同的形状。(这是为了防止例如将保存的深度网络的权重加载到浅网络中,或类似的问题。)

def load_weights(model: Layer, filename: str) -> None:
    with open(filename) as f:
        weights = json.load(f)

    # Check for consistency
    assert all(shape(param) == shape(weight)
               for param, weight in zip(model.params(), weights))

    # Then load using slice assignment
    for param, weight in zip(model.params(), weights):
        param[:] = weight
注意

JSON 将数据存储为文本,这使其成为极其低效的表示形式。在实际应用中,您可能会使用pickle序列化库,它将事物序列化为更有效的二进制格式。在这里,我决定保持简单和人类可读性。

您可以从该书的 GitHub 存储库下载我们训练的各种网络的权重。

深入探索

现在深度学习非常火热,在本章中我们只是简单介绍了一下。关于几乎任何您想了解的深度学习方面,都有许多好书和博客文章(以及很多很多糟糕的博客文章)。

  • 深度学习 这本经典教材,作者是 Ian Goodfellow、Yoshua Bengio 和 Aaron Courville(MIT Press),可以在线免费获取。这本书非常好,但涉及到相当多的数学。

  • Francois Chollet 的Python 深度学习(Manning)是了解 Keras 库的绝佳入门书籍,我们的深度学习库就是基于这种模式设计的。

  • 我自己大多使用PyTorch进行深度学习。它的网站有大量的文档和教程。

第二十章:聚类

当我们有这样的聚类时

使我们变得高尚而不是疯狂

罗伯特·赫里克

本书中的大多数算法都属于监督学习算法,即它们从一组带标签的数据开始,并将其用作对新的未标记数据进行预测的基础。然而,聚类是无监督学习的一个例子,我们在其中使用完全未标记的数据(或者我们的数据具有标签但我们忽略它们)。

思路

每当您查看某些数据源时,很可能数据会以某种方式形成聚类。显示百万富翁住在哪里的数据集可能在比佛利山庄和曼哈顿等地形成聚类。显示人们每周工作多少小时的数据集可能在周围形成一个大约为 40 的聚类(如果它来自于一项法律规定每周至少工作 20 小时的州,那么它可能还有另一个大约在 19 左右的聚类)。登记选民的人口统计数据集可能形成各种各样的聚类(例如,“足球妈妈”,“无聊的退休者”,“失业的千禧一代”),这些聚类被民意调查员和政治顾问认为是相关的。

与我们已经研究过的一些问题不同,通常没有“正确”的聚类。另一种聚类方案可能会将一些“失业的千禧一代”与“研究生”分组,而将其他一些与“父母的地下室居民”分组。这两种方案都不一定更正确——而是每一种都可能更优,关于自身的“聚类有多好?”度量标准而言。

此外,这些簇不会自行标记。您需要通过查看每个簇下面的数据来完成。

模型

对我们来说,每个input将是d维空间中的一个向量,通常我们将其表示为数字列表。我们的目标将是识别相似输入的簇,并(有时)为每个簇找到一个代表值。

例如,每个输入可以是表示博客文章标题的数值向量,此时目标可能是找到相似帖子的簇,也许是为了理解我们的用户正在博客的内容。或者想象一下,我们有一张包含数千个(红色,绿色,蓝色)颜色的图片,并且我们需要丝网印刷它的 10 种颜色版本。聚类可以帮助我们选择最小化总“颜色误差”的 10 种颜色。

最简单的聚类方法之一是k-means,在其中聚类数k是预先选择的,然后目标是以最小化每个点到其分配的簇的平均值的距离的总平方和的方式将输入分区到集合S 1 , ... , S k

有很多方法可以将n个点分配到k个簇中,这意味着找到最佳聚类是一个非常困难的问题。我们将采用一个通常能找到良好聚类的迭代算法:

  1. 以一组k-means 开始,这些点位于d维空间中。

  2. 将每个点分配给最接近它的均值。

  3. 如果没有点的分配发生变化,请停止并保留这些簇。

  4. 如果某些点的分配发生了变化,请重新计算均值并返回到步骤 2。

使用来自第四章的vector_mean函数,创建一个执行此操作的类非常简单。

首先,我们将创建一个辅助函数,用于衡量两个向量在多少坐标上不同。我们将使用这个函数来跟踪我们的训练进度:

from scratch.linear_algebra import Vector

def num_differences(v1: Vector, v2: Vector) -> int:
    assert len(v1) == len(v2)
    return len([x1 for x1, x2 in zip(v1, v2) if x1 != x2])

assert num_differences([1, 2, 3], [2, 1, 3]) == 2
assert num_differences([1, 2], [1, 2]) == 0

我们还需要一个函数,它根据一些向量及其对簇的分配计算簇的均值。有可能某个簇没有分配到任何点。我们不能对空集合取平均值,因此在这种情况下,我们将随机选择一个点作为该簇的“均值”:

from typing import List
from scratch.linear_algebra import vector_mean

def cluster_means(k: int,
                  inputs: List[Vector],
                  assignments: List[int]) -> List[Vector]:
    # clusters[i] contains the inputs whose assignment is i
    clusters = [[] for i in range(k)]
    for input, assignment in zip(inputs, assignments):
        clusters[assignment].append(input)

    # if a cluster is empty, just use a random point
    return [vector_mean(cluster) if cluster else random.choice(inputs)
            for cluster in clusters]

现在我们可以编写我们的聚类器代码了。像往常一样,我们将使用tqdm来跟踪我们的进度,但在这里,我们不知道需要多少次迭代,因此我们使用itertools.count,它创建一个无限迭代器,当完成时我们会从中return出来:

import itertools
import random
import tqdm
from scratch.linear_algebra import squared_distance

class KMeans:
    def __init__(self, k: int) -> None:
        self.k = k                      # number of clusters
        self.means = None

    def classify(self, input: Vector) -> int:
        """return the index of the cluster closest to the input"""
        return min(range(self.k),
                   key=lambda i: squared_distance(input, self.means[i]))

    def train(self, inputs: List[Vector]) -> None:
        # Start with random assignments
        assignments = [random.randrange(self.k) for _ in inputs]

        with tqdm.tqdm(itertools.count()) as t:
            for _ in t:
                # Compute means and find new assignments
                self.means = cluster_means(self.k, inputs, assignments)
                new_assignments = [self.classify(input) for input in inputs]

                # Check how many assignments changed and if we're done
                num_changed = num_differences(assignments, new_assignments)
                if num_changed == 0:
                    return

                # Otherwise keep the new assignments, and compute new means
                assignments = new_assignments
                self.means = cluster_means(self.k, inputs, assignments)
                t.set_description(f"changed: {num_changed} / {len(inputs)}")

让我们看看这是如何工作的。

例子:见面会

为了庆祝 DataSciencester 的增长,您的用户奖励副总裁希望为您的本地用户组织几次面对面见面会,提供啤酒、披萨和 DataSciencester T 恤。您知道所有本地用户的位置(见图 20-1),她希望您选择方便每个人参加的见面地点。

用户位置。

图 20-1. 您的本地用户位置

根据你的视角不同,你可能看到两个或三个簇。(从视觉上很容易,因为数据只有二维。如果是更多维度,只凭眼睛判断会更难。)

想象一下,她预算足够举办三次见面会。你去电脑上尝试这个:

random.seed(12)                   # so you get the same results as me
clusterer = KMeans(k=3)
clusterer.train(inputs)
means = sorted(clusterer.means)   # sort for the unit test

assert len(means) == 3

# Check that the means are close to what we expect
assert squared_distance(means[0], [-44, 5]) < 1
assert squared_distance(means[1], [-16, -10]) < 1
assert squared_distance(means[2], [18, 20]) < 1

你会找到三个以[-44, 5],[-16, -10]和[18, 20]为中心的簇,并且寻找靠近这些位置的见面场所(见图 20-2)。

带有 3 个均值的用户位置。

图 20-2. 用户位置分为三个簇

您将结果展示给副总裁,她通知您现在只有预算足够举办两次见面会。

“没问题”,你说:

random.seed(0)
clusterer = KMeans(k=2)
clusterer.train(inputs)
means = sorted(clusterer.means)

assert len(means) == 2
assert squared_distance(means[0], [-26, -5]) < 1
assert squared_distance(means[1], [18, 20]) < 1

如图 20-3 所示,一个见面会仍应接近[18, 20],但另一个现在应该接近[-26, -5]。

带有 2 个均值的用户位置。

图 20-3. 用户位置分为两个簇

选择k

在上一个示例中,k的选择受到我们控制之外的因素的驱动。一般情况下,这不会发生。有各种选择k的方法。其中一个相对容易理解的方法涉及绘制作为k的平方误差和(每个点与其簇的平均值之间的平方误差)的函数,并查看图表“弯曲”的位置:

from matplotlib import pyplot as plt

def squared_clustering_errors(inputs: List[Vector], k: int) -> float:
    """finds the total squared error from k-means clustering the inputs"""
    clusterer = KMeans(k)
    clusterer.train(inputs)
    means = clusterer.means
    assignments = [clusterer.classify(input) for input in inputs]

    return sum(squared_distance(input, means[cluster])
               for input, cluster in zip(inputs, assignments))

这可以应用到我们之前的例子中:

# now plot from 1 up to len(inputs) clusters

ks = range(1, len(inputs) + 1)
errors = [squared_clustering_errors(inputs, k) for k in ks]

plt.plot(ks, errors)
plt.xticks(ks)
plt.xlabel("k")
plt.ylabel("total squared error")
plt.title("Total Error vs. # of Clusters")
plt.show()

查看图 20-4,这种方法与我们最初的直觉观察相符,认为三是“正确”的聚类数目。

选择一个*k*。

图 20-4. 选择一个k

示例:聚类颜色

Swag 的副总裁设计了引人注目的 DataSciencester 贴纸,他希望你在聚会上分发。不幸的是,你的贴纸打印机每张最多只能打印五种颜色。由于艺术副总裁正在休假,Swag 的副总裁问你是否有办法修改他的设计,使其只包含五种颜色。

计算机图像可以表示为像素的二维数组,其中每个像素本身是一个三维向量(red, green, blue),表示其颜色。

创建图片的五种颜色版本,因此,涉及:

  1. 选择五种颜色。

  2. 为每个像素分配其中的一种颜色。

原来这是k-means 聚类的一个很好的任务,它可以将像素在红-绿-蓝色彩空间中分成五个聚类。然后,如果我们将每个聚类中的像素重新着色为平均颜色,我们就完成了。

首先,我们需要一种方法将图像加载到 Python 中。我们可以通过 matplotlib 来实现这一点,前提是我们首先安装 pillow 库:

python -m pip install pillow

然后我们只需使用matplotlib.image.imread

image_path = r"girl_with_book.jpg"    # wherever your image is
import matplotlib.image as mpimg
img = mpimg.imread(image_path) / 256  # rescale to between 0 and 1

在幕后,img是一个 NumPy 数组,但是对于我们的目的,我们可以将其视为列表的列表的列表。

img[i][j]是第i行第j列的像素,每个像素是一个列表[red, green, blue],数字介于 0 和 1 之间,表示该像素的颜色

top_row = img[0]
top_left_pixel = top_row[0]
red, green, blue = top_left_pixel

特别是,我们可以获得所有像素的扁平化列表,如下所示:

# .tolist() converts a NumPy array to a Python list
pixels = [pixel.tolist() for row in img for pixel in row]

然后将它们提供给我们的聚类器:

clusterer = KMeans(5)
clusterer.train(pixels)   # this might take a while

完成后,我们只需构造一个新的具有相同格式的图像:

def recolor(pixel: Vector) -> Vector:
    cluster = clusterer.classify(pixel)        # index of the closest cluster
    return clusterer.means[cluster]            # mean of the closest cluster

new_img = [[recolor(pixel) for pixel in row]   # recolor this row of pixels
           for row in img]                     # for each row in the image

并显示它,使用plt.imshow

plt.imshow(new_img)
plt.axis('off')
plt.show()

在黑白书籍中展示彩色结果很困难,但图 20-5 显示了将全彩色图片转换为灰度版本以及使用此过程减少至五种颜色的输出。

原始图片及其 5-means 去色化结果。

图 20-5. 原始图片及其 5-means 去色化结果

自底向上的分层聚类

聚类的另一种方法是从底部向上“增长”聚类。我们可以这样做:

  1. 使每个输入成为自己的一个簇。

  2. 只要还有多个剩余的聚类,就找到最接近的两个聚类并将它们合并。

最后,我们将拥有一个包含所有输入的巨大聚类。如果我们跟踪合并顺序,我们可以通过取消合并来重新创建任意数量的聚类。例如,如果我们想要三个聚类,我们可以撤销最后两个合并。

我们将使用聚类的一个非常简单的表示。我们的值将存储在leaf簇中,并将其表示为NamedTuple

from typing import NamedTuple, Union

class Leaf(NamedTuple):
    value: Vector

leaf1 = Leaf([10,  20])
leaf2 = Leaf([30, -15])

我们将使用这些来增长merged聚类,我们也将其表示为NamedTuple

class Merged(NamedTuple):
    children: tuple
    order: int

merged = Merged((leaf1, leaf2), order=1)

Cluster = Union[Leaf, Merged]
注意

这是另一种情况,Python 的类型注解让我们感到失望。你想用Tuple[Cluster, Cluster]作为Merged.children的类型提示,但mypy不允许这样的递归类型。

我们稍后会讨论合并顺序,但首先让我们创建一个递归返回所有值的帮助函数,这些值包含在(可能已合并的)簇中:

def get_values(cluster: Cluster) -> List[Vector]:
    if isinstance(cluster, Leaf):
        return [cluster.value]
    else:
        return [value
                for child in cluster.children
                for value in get_values(child)]

assert get_values(merged) == [[10, 20], [30, -15]]

为了合并最接近的簇,我们需要一些关于簇之间距离的概念。我们将使用两个簇中元素之间的最小距离,这将合并最接近接触的两个簇(但有时会产生不太紧密的链式簇)。如果我们想要紧凑的球状簇,我们可能会改用最大距离,因为它会合并适合最小球中的两个簇。这两种选择都很常见,同样常见的是平均距离:

from typing import Callable
from scratch.linear_algebra import distance

def cluster_distance(cluster1: Cluster,
                     cluster2: Cluster,
                     distance_agg: Callable = min) -> float:
    """
 compute all the pairwise distances between cluster1 and cluster2
 and apply the aggregation function _distance_agg_ to the resulting list
 """
    return distance_agg([distance(v1, v2)
                         for v1 in get_values(cluster1)
                         for v2 in get_values(cluster2)])

我们将使用合并顺序插槽来跟踪我们执行合并的顺序。较小的数字将表示较晚的合并。这意味着当我们想要拆分簇时,我们会从最低的合并顺序到最高的顺序进行。由于Leaf簇从未合并过,我们将给它们分配无穷大,即最大可能的值。由于它们没有.order属性,因此我们将创建一个辅助函数:

def get_merge_order(cluster: Cluster) -> float:
    if isinstance(cluster, Leaf):
        return float('inf')  # was never merged
    else:
        return cluster.order

类似地,由于Leaf簇没有子节点,因此我们将为此创建并添加一个辅助函数:

from typing import Tuple

def get_children(cluster: Cluster):
    if isinstance(cluster, Leaf):
        raise TypeError("Leaf has no children")
    else:
        return cluster.children

现在我们准备创建聚类算法:

def bottom_up_cluster(inputs: List[Vector],
                      distance_agg: Callable = min) -> Cluster:
    # Start with all leaves
    clusters: List[Cluster] = [Leaf(input) for input in inputs]

    def pair_distance(pair: Tuple[Cluster, Cluster]) -> float:
        return cluster_distance(pair[0], pair[1], distance_agg)

    # as long as we have more than one cluster left...
    while len(clusters) > 1:
        # find the two closest clusters
        c1, c2 = min(((cluster1, cluster2)
                      for i, cluster1 in enumerate(clusters)
                      for cluster2 in clusters[:i]),
                      key=pair_distance)

        # remove them from the list of clusters
        clusters = [c for c in clusters if c != c1 and c != c2]

        # merge them, using merge_order = # of clusters left
        merged_cluster = Merged((c1, c2), order=len(clusters))

        # and add their merge
        clusters.append(merged_cluster)

    # when there's only one cluster left, return it
    return clusters[0]

它的使用非常简单:

base_cluster = bottom_up_cluster(inputs)

这将生成一个如下所示的聚类:

  0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15 16 17 18
──┬──┬─────┬────────────────────────────────┬───────────┬─ [19, 28]
  │  │     │                                │           └─ [21, 27]
  │  │     │                                └─ [20, 23]
  │  │     └─ [26, 13]
  │  └────────────────────────────────────────────┬─ [11, 15]
  │                                               └─ [13, 13]
  └─────┬─────┬──┬───────────┬─────┬─ [-49, 0]
        │     │  │           │     └─ [-46, 5]
        │     │  │           └─ [-41, 8]
        │     │  └─ [-49, 15]
        │     └─ [-34, 1]
        └───────────┬──┬──┬─────┬─ [-22, -16]
                    │  │  │     └─ [-19, -11]
                    │  │  └─ [-25, -9]
                    │  └─────────────────┬─────┬─────┬─ [-11, -6]
                    │                    │     │     └─ [-12, -8]
                    │                    │     └─ [-14, 5]
                    │                    └─ [-18, -3]
                    └─────────────────┬─ [-13, -19]
                                      └─ [-9, -16]

顶部的数字表示“合并顺序”。因为我们有 20 个输入,所以需要 19 次合并才能得到这一个簇。第一次合并通过组合叶子[19, 28]和[21, 27]创建了簇 18。最后一次合并创建了簇 0。

如果你只想要两个簇,你可以在第一个分叉点“0”处分割,创建一个包含六个点的簇和另一个包含剩余点的簇。对于三个簇,你将继续到第二个分叉点“1”,它指示要将第一个簇拆分为包含([19, 28], [21, 27], [20, 23], [26, 13])的簇和包含([11, 15], [13, 13])的簇。依此类推。

尽管如此,我们通常不想看到像这样的难看文本表示。相反,让我们编写一个函数,通过执行适当数量的拆分来生成任意数量的簇:

def generate_clusters(base_cluster: Cluster,
                      num_clusters: int) -> List[Cluster]:
    # start with a list with just the base cluster
    clusters = [base_cluster]

    # as long as we don't have enough clusters yet...
    while len(clusters) < num_clusters:
        # choose the last-merged of our clusters
        next_cluster = min(clusters, key=get_merge_order)
        # remove it from the list
        clusters = [c for c in clusters if c != next_cluster]

        # and add its children to the list (i.e., unmerge it)
        clusters.extend(get_children(next_cluster))

    # once we have enough clusters...
    return clusters

例如,如果我们想生成三个簇,我们只需执行以下操作:

three_clusters = [get_values(cluster)
                  for cluster in generate_clusters(base_cluster, 3)]

可以轻松绘制的部分:

for i, cluster, marker, color in zip([1, 2, 3],
                                     three_clusters,
                                     ['D','o','*'],
                                     ['r','g','b']):
    xs, ys = zip(*cluster)  # magic unzipping trick
    plt.scatter(xs, ys, color=color, marker=marker)

    # put a number at the mean of the cluster
    x, y = vector_mean(cluster)
    plt.plot(x, y, marker='$' + str(i) + '$', color='black')

plt.title("User Locations -- 3 Bottom-Up Clusters, Min")
plt.xlabel("blocks east of city center")
plt.ylabel("blocks north of city center")
plt.show()

这与k-means 产生了非常不同的结果,如图 20-6 所示。

使用最小距离生成的三个自下而上的簇。

图 20-6. 使用最小距离生成的三个自下而上的簇

如前所述,这是因为在cluster_distance中使用min倾向于生成链式簇。如果我们改用max(生成紧密簇),它将与 3-means 结果相同(见图 20-7)。

注意

先前的bottom_up_clustering实现相对简单,但效率惊人地低下。特别是,它在每一步重新计算每对输入之间的距离。更高效的实现可能会预先计算每对输入之间的距离,然后在cluster_distance内执行查找。真正高效的实现可能还会记住前一步骤的cluster_distance

使用最大距离的三个自底向上聚类。

图 20-7. 使用最大距离的三个自底向上聚类

进一步探索

  • scikit-learn 有一个完整的模块,sklearn.cluster,其中包含几种聚类算法,包括KMeansWard层次聚类算法(其合并集群的标准与我们的不同)。

  • SciPy 提供了两种聚类模型:scipy.cluster.vq,实现k-均值;以及scipy.cluster.hierarchy,提供多种层次聚类算法。

第二十一章:自然语言处理

他们已经在语言的盛宴中大快朵颐,窃取了残羹剩饭。

威廉·莎士比亚

自然语言处理(NLP)指的是涉及语言的计算技术。这是一个广泛的领域,但我们将看几种简单和复杂的技术。

词云

在 第 1 章,我们计算了用户兴趣的单词计数。一个可视化单词和计数的方法是 词云,它以比例大小艺术化地呈现单词。

通常,数据科学家们对词云并不看重,主要是因为单词的排列除了“这里是我能放置一个词的空间”之外没有其他意义。

如果你不得不创建一个词云,考虑一下是否可以让坐标轴传达某种信息。例如,想象一下,对于某些数据科学相关的流行术语,你有两个在 0 到 100 之间的数字——第一个表示它在职位发布中出现的频率,第二个表示它在简历中出现的频率:

data = [ ("big data", 100, 15), ("Hadoop", 95, 25), ("Python", 75, 50),
         ("R", 50, 40), ("machine learning", 80, 20), ("statistics", 20, 60),
         ("data science", 60, 70), ("analytics", 90, 3),
         ("team player", 85, 85), ("dynamic", 2, 90), ("synergies", 70, 0),
         ("actionable insights", 40, 30), ("think out of the box", 45, 10),
         ("self-starter", 30, 50), ("customer focus", 65, 15),
         ("thought leadership", 35, 35)]

词云的方法就是在页面上以酷炫的字体排列这些词(图 21-1)。

术语词云。

图 21-1 术语词云

这看起来很整齐,但实际上并没有告诉我们什么。一个更有趣的方法可能是将它们散布开来,使得水平位置表示发布的流行度,垂直位置表示简历的流行度,这将产生一个传达几个见解的可视化效果(图 21-2):

from matplotlib import pyplot as plt

def text_size(total: int) -> float:
    """equals 8 if total is 0, 28 if total is 200"""
    return 8 + total / 200 * 20

for word, job_popularity, resume_popularity in data:
    plt.text(job_popularity, resume_popularity, word,
             ha='center', va='center',
             size=text_size(job_popularity + resume_popularity))
plt.xlabel("Popularity on Job Postings")
plt.ylabel("Popularity on Resumes")
plt.axis([0, 100, 0, 100])
plt.xticks([])
plt.yticks([])
plt.show()

更有意义(虽然不够吸引人)的词云。

图 21-2 更有意义(虽然不够吸引人)的词云

n-Gram 语言模型

DataSciencester 搜索引擎市场副总裁希望创建成千上万个关于数据科学的网页,以便您的网站在与数据科学相关的搜索结果中排名更高。(你试图向她解释搜索引擎算法已经足够聪明,这实际上不会起作用,但她拒绝听取。)

当然,她不想写成千上万个网页,也不想支付一大群“内容战略师”来完成。相反,她问你是否可以以某种程序化的方式生成这些网页。为此,我们需要某种语言建模的方法。

一种方法是从一组文档语料库开始,并学习语言的统计模型。在我们的案例中,我们将从迈克·劳凯德斯的文章《什么是数据科学?》开始。

如同 第 9 章,我们将使用 Requests 和 Beautiful Soup 库来获取数据。这里有几个值得注意的问题。

首先,文本中的撇号实际上是 Unicode 字符 u"\u2019"。我们将创建一个辅助函数来将其替换为正常的撇号:

def fix_unicode(text: str) -> str:
    return text.replace(u"\u2019", "'")

第二个问题是,一旦我们获取了网页的文本,我们将希望将其拆分为一系列的单词和句号(以便我们可以知道句子的结束位置)。我们可以使用re.findall来实现这一点:

import re
from bs4 import BeautifulSoup
import requests

url = "https://www.oreilly.com/ideas/what-is-data-science"
html = requests.get(url).text
soup = BeautifulSoup(html, 'html5lib')

content = soup.find("div", "article-body")   # find article-body div
regex = r"[\w']+|[\.]"                       # matches a word or a period

document = []

for paragraph in content("p"):
    words = re.findall(regex, fix_unicode(paragraph.text))
    document.extend(words)

我们当然可以(而且很可能应该)进一步清理这些数据。文档中仍然有一些多余的文本(例如,第一个单词是Section),我们已经在中间句点上分割了(例如,在Web 2.0中),还有一些标题和列表散布在其中。话虽如此,我们将按照文档的样子进行处理。

现在我们将文本作为一系列单词,我们可以按以下方式对语言进行建模:给定一些起始词(比如book),我们查看源文档中跟随它的所有单词。我们随机选择其中一个作为下一个单词,并重复这个过程,直到我们遇到一个句号,这表示句子的结束。我们称之为bigram 模型,因为它完全由原始数据中 bigram(单词对)的频率决定。

起始词呢?我们可以随机从跟在句号后面的单词中选择一个。首先,让我们预先计算可能的单词转换。记住zip在其输入的任何一个完成时停止,因此zip(document, document[1:])给出了document中连续元素的精确配对:

from collections import defaultdict

transitions = defaultdict(list)
for prev, current in zip(document, document[1:]):
    transitions[prev].append(current)

现在我们准备好生成句子了:

def generate_using_bigrams() -> str:
    current = "."   # this means the next word will start a sentence
    result = []
    while True:
        next_word_candidates = transitions[current]    # bigrams (current, _)
        current = random.choice(next_word_candidates)  # choose one at random
        result.append(current)                         # append it to results
        if current == ".": return " ".join(result)     # if "." we're done

它生成的句子是胡言乱语,但如果你试图听起来像数据科学,它们就是你可能会放在你的网站上的那种胡言乱语。例如:

如果你知道你想要对数据进行排序的数据源网页友好的人在热门话题上作为 Hadoop 中的数据,那么数据科学需要一本书来演示为什么可视化是数据的可视化是但我们在 Python 语言中使用许多商业磁盘驱动器上的大量相关性,并创建更可管理的形式进行连接,然后使用它来解决数据的问题。

Bigram 模型

通过查看三元组,连续三个单词的三元组,我们可以使句子不那么胡言乱语。 (更一般地说,你可以查看由n个连续单词组成的n-gram,但对于我们来说,三个就足够了。)现在的转换将取决于前两个单词:

trigram_transitions = defaultdict(list)
starts = []

for prev, current, next in zip(document, document[1:], document[2:]):

    if prev == ".":              # if the previous "word" was a period
        starts.append(current)   # then this is a start word

    trigram_transitions[(prev, current)].append(next)

现在注意,我们现在必须单独跟踪起始词。我们几乎可以以相同的方式生成句子:

def generate_using_trigrams() -> str:
    current = random.choice(starts)   # choose a random starting word
    prev = "."                        # and precede it with a '.'
    result = [current]
    while True:
        next_word_candidates = trigram_transitions[(prev, current)]
        next_word = random.choice(next_word_candidates)

        prev, current = current, next_word
        result.append(current)

        if current == ".":
            return " ".join(result)

这样产生的句子更好,比如:

事后看来 MapReduce 看起来像是一场流行病,如果是这样,那么这是否给我们提供了新的见解,即经济如何运作这不是一个我们几年前甚至可以问的问题已经被工具化。

三元模型

当然,它们听起来更好,因为在每一步生成过程中的选择更少,而在许多步骤中只有一个选择。这意味着我们经常生成句子(或至少是长短语),这些句子在原始数据中原封不动地出现过。拥有更多的数据会有所帮助;如果我们收集了关于数据科学的多篇文章中的n-gram,它也会更有效。

语法

一种不同的语言建模方法是使用语法,即生成可接受句子的规则。在小学时,你可能学过词性及其如何组合。例如,如果你有一个非常糟糕的英语老师,你可能会说一个句子必然由名词后跟一个动词。如果你有名词和动词的列表,你可以根据这个规则生成句子。

我们将定义一个稍微复杂的语法:

from typing import List, Dict

# Type alias to refer to grammars later
Grammar = Dict[str, List[str]]

grammar = {
    "_S"  : ["_NP _VP"],
    "_NP" : ["_N",
             "_A _NP _P _A _N"],
    "_VP" : ["_V",
             "_V _NP"],
    "_N"  : ["data science", "Python", "regression"],
    "_A"  : ["big", "linear", "logistic"],
    "_P"  : ["about", "near"],
    "_V"  : ["learns", "trains", "tests", "is"]
}

我编制了以下约定:以下划线开头的名称指的是需要进一步扩展的规则,而其他名称是不需要进一步处理的终端

因此,例如,"_S"是“句子”规则,它生成一个"_NP"(“名词短语”)规则后跟一个"_VP"(“动词短语”)规则。

动词短语规则可以生成"_V"(“动词”)规则,或者动词规则后跟名词短语规则。

注意"_NP"规则包含在其一个产生中。语法可以是递归的,这使得像这样的有限语法可以生成无限多个不同的句子。

我们如何从这个语法生成句子?我们将从包含句子规则["_S"]的列表开始。然后,我们将通过用其产生之一随机替换每个规则来重复扩展每个规则。当我们有一个完全由终端组成的列表时,我们停止。

例如,这样的进展可能看起来像:

['_S']
['_NP','_VP']
['_N','_VP']
['Python','_VP']
['Python','_V','_NP']
['Python','trains','_NP']
['Python','trains','_A','_NP','_P','_A','_N']
['Python','trains','logistic','_NP','_P','_A','_N']
['Python','trains','logistic','_N','_P','_A','_N']
['Python','trains','logistic','data science','_P','_A','_N']
['Python','trains','logistic','data science','about','_A', '_N']
['Python','trains','logistic','data science','about','logistic','_N']
['Python','trains','logistic','data science','about','logistic','Python']

我们如何实现这一点?嗯,首先,我们将创建一个简单的辅助函数来识别终端:

def is_terminal(token: str) -> bool:
    return token[0] != "_"

接下来,我们需要编写一个函数,将标记列表转换为句子。我们将寻找第一个非终结符标记。如果找不到一个,那意味着我们有一个完成的句子,我们就完成了。

如果我们找到一个非终结符,然后我们随机选择它的一个产生式。如果该产生式是一个终端(即一个词),我们只需用它替换标记。否则,它是一系列以空格分隔的非终结符标记,我们需要split然后在当前标记中插入。无论哪种方式,我们都会在新的标记集上重复这个过程。

将所有这些放在一起,我们得到:

def expand(grammar: Grammar, tokens: List[str]) -> List[str]:
    for i, token in enumerate(tokens):
        # If this is a terminal token, skip it.
        if is_terminal(token): continue

        # Otherwise, it's a nonterminal token,
        # so we need to choose a replacement at random.
        replacement = random.choice(grammar[token])

        if is_terminal(replacement):
            tokens[i] = replacement
        else:
            # Replacement could be, e.g., "_NP _VP", so we need to
            # split it on spaces and splice it in.
            tokens = tokens[:i] + replacement.split() + tokens[(i+1):]

        # Now call expand on the new list of tokens.
        return expand(grammar, tokens)

    # If we get here, we had all terminals and are done.
    return tokens

现在我们可以开始生成句子了:

def generate_sentence(grammar: Grammar) -> List[str]:
    return expand(grammar, ["_S"])

尝试改变语法——添加更多单词,添加更多规则,添加你自己的词性——直到你准备生成公司所需的多个网页为止。

当语法反向使用时,语法实际上更有趣。给定一个句子,我们可以使用语法解析句子。这然后允许我们识别主语和动词,并帮助我们理解句子的意义。

使用数据科学生成文本是一个很棒的技巧;使用它来理解文本更加神奇。(参见“进一步探索”可以用于此目的的库。)

一个旁注:吉布斯采样

从一些分布生成样本很容易。我们可以得到均匀随机变量:

random.random()

和正常的随机变量一样:

inverse_normal_cdf(random.random())

但是一些分布很难进行抽样。吉布斯抽样 是一种从多维分布生成样本的技术,当我们只知道一些条件分布时使用。

例如,想象掷两个骰子。让 x 是第一个骰子的值,y 是两个骰子的和,想象你想生成大量 (x, y) 对。在这种情况下,直接生成样本是很容易的:

from typing import Tuple
import random

def roll_a_die() -> int:
    return random.choice([1, 2, 3, 4, 5, 6])

def direct_sample() -> Tuple[int, int]:
    d1 = roll_a_die()
    d2 = roll_a_die()
    return d1, d1 + d2

但是假设你只知道条件分布。知道 x 的值时,y 的分布很简单——如果你知道 x 的值,y 同样可能是 x + 1、x + 2、x + 3、x + 4、x + 5 或 x + 6:

def random_y_given_x(x: int) -> int:
    """equally likely to be x + 1, x + 2, ... , x + 6"""
    return x + roll_a_die()

另一个方向更为复杂。例如,如果你知道 y 是 2,则必然 x 是 1(因为使两个骰子的和为 2 的唯一方法是它们都是 1)。如果你知道 y 是 3,则 x 等可能是 1 或 2。同样,如果 y 是 11,则 x 必须是 5 或 6:

def random_x_given_y(y: int) -> int:
    if y <= 7:
        # if the total is 7 or less, the first die is equally likely to be
        # 1, 2, ..., (total - 1)
        return random.randrange(1, y)
    else:
        # if the total is 7 or more, the first die is equally likely to be
        # (total - 6), (total - 5), ..., 6
        return random.randrange(y - 6, 7)

吉布斯抽样的工作原理是我们从任意(有效的)xy 的值开始,然后反复替换,用在y 条件下随机选择的值替换 x,并在 x 条件下随机选择的值替换 y。经过若干次迭代,xy 的结果值将代表无条件联合分布的一个样本:

def gibbs_sample(num_iters: int = 100) -> Tuple[int, int]:
    x, y = 1, 2 # doesn't really matter
    for _ in range(num_iters):
        x = random_x_given_y(y)
        y = random_y_given_x(x)
    return x, y

你可以检查这是否给出与直接样本相似的结果:

def compare_distributions(num_samples: int = 1000) -> Dict[int, List[int]]:
    counts = defaultdict(lambda: [0, 0])
    for _ in range(num_samples):
        counts[gibbs_sample()][0] += 1
        counts[direct_sample()][1] += 1
    return counts

我们将在下一节中使用这种技术。

主题建模

当我们在第一章中构建“您可能认识的数据科学家”推荐系统时,我们简单地查找人们声明的兴趣的完全匹配。

更复杂的方法是尝试理解用户兴趣的主题。一种称为潜在狄利克雷分配(LDA)的技术通常用于识别一组文档中的常见主题。我们将其应用于由每个用户兴趣组成的文档。

LDA 与我们在第十三章中构建的朴素贝叶斯分类器有一些相似之处,因为它假设文档的概率模型。对于我们的目的,该模型假设以下内容,我们将略过更复杂的数学细节:

  • 有一些固定数量 K 的主题。

  • 有一个随机变量为每个主题分配与之相关联的单词概率分布。你应该将这个分布看作是给定主题 k 下看到单词 w 的概率。

  • 还有一个随机变量为每个文档分配一个主题的概率分布。你应该将这个分布看作是文档 d 中主题的混合。

  • 文档中的每个单词是通过首先随机选择一个主题(从文档的主题分布中)然后随机选择一个单词(从主题的单词分布中)生成的。

特别是,我们有一个documents的集合,每个文档都是一个单词的list。并且我们有一个相应的document_topics集合,它为每个文档中的每个单词分配一个主题(这里是 0 到K-1 之间的数字)。

因此,第四个文档中的第五个单词是:

documents[3][4]

选择该单词的主题是:

document_topics[3][4]

这非常明确地定义了每个文档在主题上的分布,并且隐含地定义了每个主题在单词上的分布。

通过比较主题 1 生成该单词的次数与主题 1 生成任何单词的次数,我们可以估计主题 1 生成某个单词的可能性。(类似地,在第十三章中建立垃圾邮件过滤器时,我们比较了每个单词在垃圾邮件中出现的次数与垃圾邮件中出现的总字数。)

虽然这些主题只是数字,但我们可以通过查看它们赋予最高权重的单词来为它们命名描述性名称。我们只需以某种方式生成document_topics。这就是吉布斯抽样发挥作用的地方。

我们首先随机地为每个文档中的每个单词分配一个主题。现在我们逐个单词地遍历每个文档。对于该单词和文档,我们为每个主题构造依赖于该文档中主题的(当前)分布和该主题中单词的(当前)分布的权重。然后我们使用这些权重来对该单词抽样一个新的主题。如果我们多次迭代这个过程,我们最终会得到从主题-单词分布和文档-主题分布的联合样本。

起步,我们需要一个函数根据任意一组权重随机选择一个索引:

def sample_from(weights: List[float]) -> int:
    """returns i with probability weights[i] / sum(weights)"""
    total = sum(weights)
    rnd = total * random.random()      # uniform between 0 and total
    for i, w in enumerate(weights):
        rnd -= w                       # return the smallest i such that
        if rnd <= 0: return i          # weights[0] + ... + weights[i] >= rnd

例如,如果给定权重 [1, 1, 3],那么它将返回 0 的概率为五分之一,返回 1 的概率为五分之一,返回 2 的概率为三分之五。让我们编写一个测试:

from collections import Counter

# Draw 1000 times and count
draws = Counter(sample_from([0.1, 0.1, 0.8]) for _ in range(1000))
assert 10 < draws[0] < 190   # should be ~10%, this is a really loose test
assert 10 < draws[1] < 190   # should be ~10%, this is a really loose test
assert 650 < draws[2] < 950  # should be ~80%, this is a really loose test
assert draws[0] + draws[1] + draws[2] == 1000

我们的文档是我们用户的兴趣,看起来像:

documents = [
    ["Hadoop", "Big Data", "HBase", "Java", "Spark", "Storm", "Cassandra"],
    ["NoSQL", "MongoDB", "Cassandra", "HBase", "Postgres"],
    ["Python", "scikit-learn", "scipy", "numpy", "statsmodels", "pandas"],
    ["R", "Python", "statistics", "regression", "probability"],
    ["machine learning", "regression", "decision trees", "libsvm"],
    ["Python", "R", "Java", "C++", "Haskell", "programming languages"],
    ["statistics", "probability", "mathematics", "theory"],
    ["machine learning", "scikit-learn", "Mahout", "neural networks"],
    ["neural networks", "deep learning", "Big Data", "artificial intelligence"],
    ["Hadoop", "Java", "MapReduce", "Big Data"],
    ["statistics", "R", "statsmodels"],
    ["C++", "deep learning", "artificial intelligence", "probability"],
    ["pandas", "R", "Python"],
    ["databases", "HBase", "Postgres", "MySQL", "MongoDB"],
    ["libsvm", "regression", "support vector machines"]
]

我们将尝试找到:

K = 4

主题。为了计算抽样权重,我们需要跟踪几个计数。让我们首先为它们创建数据结构。

  • 每个主题分配给每个文档的次数是:

    # a list of Counters, one for each document
    document_topic_counts = [Counter() for _ in documents]
    
  • 每个单词分配到每个主题的次数是:

    # a list of Counters, one for each topic
    topic_word_counts = [Counter() for _ in range(K)]
    
  • 分配给每个主题的总字数是:

    # a list of numbers, one for each topic
    topic_counts = [0 for _ in range(K)]
    
  • 每个文档包含的总字数是:

    # a list of numbers, one for each document
    document_lengths = [len(document) for document in documents]
    
  • 不同单词的数量是:

    distinct_words = set(word for document in documents for word in document)
    W = len(distinct_words)
    
  • 以及文档的数量:

    D = len(documents)
    

一旦我们填充了这些数据,我们可以如下找到例如与主题 1 相关联的documents[3]中的单词数:

document_topic_counts[3][1]

并且我们可以找到nlp与主题 2 相关联的次数如下:

topic_word_counts[2]["nlp"]

现在我们准备定义我们的条件概率函数。就像第十三章中那样,每个函数都有一个平滑项,确保每个主题在任何文档中被选择的概率都不为零,并且每个单词在任何主题中被选择的概率也不为零:

def p_topic_given_document(topic: int, d: int, alpha: float = 0.1) -> float:
    """
 The fraction of words in document 'd'
 that are assigned to 'topic' (plus some smoothing)
 """
    return ((document_topic_counts[d][topic] + alpha) /
            (document_lengths[d] + K * alpha))

def p_word_given_topic(word: str, topic: int, beta: float = 0.1) -> float:
    """
 The fraction of words assigned to 'topic'
 that equal 'word' (plus some smoothing)
 """
    return ((topic_word_counts[topic][word] + beta) /
            (topic_counts[topic] + W * beta))

我们将使用这些函数来创建更新主题的权重:

def topic_weight(d: int, word: str, k: int) -> float:
    """
 Given a document and a word in that document,
 return the weight for the kth topic
 """
    return p_word_given_topic(word, k) * p_topic_given_document(k, d)

def choose_new_topic(d: int, word: str) -> int:
    return sample_from([topic_weight(d, word, k)
                        for k in range(K)])

有坚实的数学原因解释为什么 topic_weight 被定义为它的方式,但是它们的细节会让我们走得太远。希望至少直观地理解,鉴于一个词和它的文档,选择任何主题的可能性取决于该主题对文档的可能性以及该词对该主题的可能性。

这就是我们需要的所有机制。我们从将每个单词分配给一个随机主题开始,并相应地填充我们的计数器:

random.seed(0)
document_topics = [[random.randrange(K) for word in document]
                   for document in documents]

for d in range(D):
    for word, topic in zip(documents[d], document_topics[d]):
        document_topic_counts[d][topic] += 1
        topic_word_counts[topic][word] += 1
        topic_counts[topic] += 1

我们的目标是获取主题-词分布和文档-主题分布的联合样本。我们使用一种基于吉布斯抽样的形式来完成这一过程,该过程使用之前定义的条件概率:

import tqdm

for iter in tqdm.trange(1000):
    for d in range(D):
        for i, (word, topic) in enumerate(zip(documents[d],
                                              document_topics[d])):

            # remove this word / topic from the counts
            # so that it doesn't influence the weights
            document_topic_counts[d][topic] -= 1
            topic_word_counts[topic][word] -= 1
            topic_counts[topic] -= 1
            document_lengths[d] -= 1

            # choose a new topic based on the weights
            new_topic = choose_new_topic(d, word)
            document_topics[d][i] = new_topic

            # and now add it back to the counts
            document_topic_counts[d][new_topic] += 1
            topic_word_counts[new_topic][word] += 1
            topic_counts[new_topic] += 1
            document_lengths[d] += 1

主题是什么?它们只是数字 0、1、2 和 3。如果我们想要它们的名称,我们必须自己添加。让我们看看每个主题的五个权重最高的词汇(表 21-1):

for k, word_counts in enumerate(topic_word_counts):
    for word, count in word_counts.most_common():
        if count > 0:
            print(k, word, count)

表 21-1. 每个主题的最常见词汇

主题 0 主题 1 主题 2 主题 3
Java R HBase 回归分析
大数据 统计 Postgres libsvm
Hadoop Python MongoDB scikit-learn
深度学习 概率 卡桑德拉 机器学习
人工智能 熊猫 NoSQL 神经网络

基于这些,我可能会分配主题名称:

topic_names = ["Big Data and programming languages",
               "Python and statistics",
               "databases",
               "machine learning"]

在这一点上,我们可以看到模型如何将主题分配给每个用户的兴趣:

for document, topic_counts in zip(documents, document_topic_counts):
    print(document)
    for topic, count in topic_counts.most_common():
        if count > 0:
            print(topic_names[topic], count)
    print()

这给出了:

['Hadoop', 'Big Data', 'HBase', 'Java', 'Spark', 'Storm', 'Cassandra']
Big Data and programming languages 4 databases 3
['NoSQL', 'MongoDB', 'Cassandra', 'HBase', 'Postgres']
databases 5
['Python', 'scikit-learn', 'scipy', 'numpy', 'statsmodels', 'pandas']
Python and statistics 5 machine learning 1

等等。考虑到我们在一些主题名称中需要使用的“和”,可能我们应该使用更多的主题,尽管最可能我们没有足够的数据成功学习它们。

单词向量

最近自然语言处理的许多进展涉及深度学习。在本章的其余部分中,我们将使用我们在第十九章中开发的机制来看一些这样的进展。

一个重要的创新涉及将单词表示为低维向量。这些向量可以进行比较、相加、输入到机器学习模型中,或者任何你想做的事情。它们通常具有良好的特性;例如,相似的单词倾向于有相似的向量。也就是说,通常单词 big 的向量与单词 large 的向量非常接近,因此一个操作单词向量的模型可以(在某种程度上)免费处理类似的词语。

经常向量也会展示出令人愉悦的算术特性。例如,在某些模型中,如果你取 king 的向量,减去 man 的向量,再加上 woman 的向量,你将得到一个非常接近 queen 向量的向量。思考这对于单词向量实际上“学到”了什么,可能会很有趣,尽管我们在这里不会花时间讨论这一点。

对于一个庞大的词汇表来说,设计这样的向量是一项困难的任务,所以通常我们会从文本语料库中 学习 它们。有几种不同的方案,但在高层次上,任务通常看起来像这样:

  1. 获取一堆文本。

  2. 创建一个数据集,目标是预测给定附近单词的单词(或者,预测给定单词的附近单词)。

  3. 训练一个神经网络在这个任务上表现良好。

  4. 将训练好的神经网络的内部状态作为单词向量。

特别是,由于任务是根据附近的单词预测单词,出现在类似上下文中的单词(因此具有类似的附近单词)应该具有类似的内部状态,因此也应该具有相似的单词向量。

我们将使用 余弦相似度(cosine similarity)来衡量“相似性”,它是一个介于-1 和 1 之间的数值,用于衡量两个向量指向相同方向的程度:

from scratch.linear_algebra import dot, Vector
import math

def cosine_similarity(v1: Vector, v2: Vector) -> float:
    return dot(v1, v2) / math.sqrt(dot(v1, v1) * dot(v2, v2))

assert cosine_similarity([1., 1, 1], [2., 2, 2]) == 1, "same direction"
assert cosine_similarity([-1., -1], [2., 2]) == -1,    "opposite direction"
assert cosine_similarity([1., 0], [0., 1]) == 0,       "orthogonal"

让我们学习一些词向量,看看它是如何工作的。

首先,我们需要一个玩具数据集。通常使用的单词向量通常是通过在数百万甚至数十亿个单词上训练而来的。由于我们的玩具库无法处理那么多数据,我们将创建一个具有某些结构的人工数据集:

colors = ["red", "green", "blue", "yellow", "black", ""]
nouns = ["bed", "car", "boat", "cat"]
verbs = ["is", "was", "seems"]
adverbs = ["very", "quite", "extremely", ""]
adjectives = ["slow", "fast", "soft", "hard"]

def make_sentence() -> str:
    return " ".join([
        "The",
        random.choice(colors),
        random.choice(nouns),
        random.choice(verbs),
        random.choice(adverbs),
        random.choice(adjectives),
        "."
    ])

NUM_SENTENCES = 50

random.seed(0)
sentences = [make_sentence() for _ in range(NUM_SENTENCES)]

这将生成许多具有类似结构但不同单词的句子;例如,“绿色的船似乎相当慢。” 在这种设置下,颜色将主要出现在“相似”的上下文中,名词也是如此,依此类推。因此,如果我们成功地分配了单词向量,颜色应该会得到相似的向量,依此类推。

注意

在实际使用中,您可能会有数百万个句子的语料库,在这种情况下,您将从句子中获得“足够的”上下文。在这里,我们只有 50 个句子,我们必须使它们有些人为的。

如前所述,我们将需要对我们的单词进行一位有效编码,这意味着我们需要将它们转换为 ID。我们将引入一个Vocabulary类来跟踪这个映射:

from scratch.deep_learning import Tensor

class Vocabulary:
    def __init__(self, words: List[str] = None) -> None:
        self.w2i: Dict[str, int] = {}  # mapping word -> word_id
        self.i2w: Dict[int, str] = {}  # mapping word_id -> word

        for word in (words or []):     # If words were provided,
            self.add(word)             # add them.

    @property
    def size(self) -> int:
        """how many words are in the vocabulary"""
        return len(self.w2i)

    def add(self, word: str) -> None:
        if word not in self.w2i:        # If the word is new to us:
            word_id = len(self.w2i)     # Find the next id.
            self.w2i[word] = word_id    # Add to the word -> word_id map.
            self.i2w[word_id] = word    # Add to the word_id -> word map.

    def get_id(self, word: str) -> int:
        """return the id of the word (or None)"""
        return self.w2i.get(word)

    def get_word(self, word_id: int) -> str:
        """return the word with the given id (or None)"""
        return self.i2w.get(word_id)

    def one_hot_encode(self, word: str) -> Tensor:
        word_id = self.get_id(word)
        assert word_id is not None, f"unknown word {word}"

        return [1.0 if i == word_id else 0.0 for i in range(self.size)]

这些都是我们可以手动完成的事情,但将它们放在一个类中很方便。我们可能应该测试它:

vocab = Vocabulary(["a", "b", "c"])
assert vocab.size == 3,              "there are 3 words in the vocab"
assert vocab.get_id("b") == 1,       "b should have word_id 1"
assert vocab.one_hot_encode("b") == [0, 1, 0]
assert vocab.get_id("z") is None,    "z is not in the vocab"
assert vocab.get_word(2) == "c",     "word_id 2 should be c"
vocab.add("z")
assert vocab.size == 4,              "now there are 4 words in the vocab"
assert vocab.get_id("z") == 3,       "now z should have id 3"
assert vocab.one_hot_encode("z") == [0, 0, 0, 1]

我们还应该编写简单的辅助函数来保存和加载词汇表,就像我们为深度学习模型所做的那样:

import json

def save_vocab(vocab: Vocabulary, filename: str) -> None:
    with open(filename, 'w') as f:
        json.dump(vocab.w2i, f)       # Only need to save w2i

def load_vocab(filename: str) -> Vocabulary:
    vocab = Vocabulary()
    with open(filename) as f:
        # Load w2i and generate i2w from it
        vocab.w2i = json.load(f)
        vocab.i2w = {id: word for word, id in vocab.w2i.items()}
    return vocab

我们将使用一个称为 skip-gram 的词向量模型,它以单词作为输入,并生成可能性,表明哪些单词可能会在附近出现。我们将向其提供训练对 (单词, 附近单词) 并尝试最小化 SoftmaxCrossEntropy 损失。

注意

另一个常见的模型,连续词袋(continuous bag-of-words,CBOW),将附近的单词作为输入,并尝试预测原始单词。

让我们设计我们的神经网络。在其核心将是一个 嵌入(embedding)层,它以单词 ID 作为输入并返回一个单词向量。在内部,我们可以简单地使用查找表来实现这一点。

然后,我们将单词向量传递给一个具有与我们词汇表中单词数量相同的输出的 Linear 层。与以前一样,我们将使用 softmax 将这些输出转换为附近单词的概率。当我们使用梯度下降训练模型时,我们将更新查找表中的向量。训练完成后,该查找表为我们提供了单词向量。

让我们创建那个嵌入层。实际上,我们可能希望嵌入除单词之外的其他内容,因此我们将构建一个更通用的Embedding层。(稍后我们将编写一个TextEmbedding子类,专门用于词向量。)

在其构造函数中,我们将提供我们嵌入向量的数量和维度,因此它可以创建嵌入(最初将是标准的随机正态分布):

from typing import Iterable
from scratch.deep_learning import Layer, Tensor, random_tensor, zeros_like

class Embedding(Layer):
    def __init__(self, num_embeddings: int, embedding_dim: int) -> None:
        self.num_embeddings = num_embeddings
        self.embedding_dim = embedding_dim

        # One vector of size embedding_dim for each desired embedding
        self.embeddings = random_tensor(num_embeddings, embedding_dim)
        self.grad = zeros_like(self.embeddings)

        # Save last input id
        self.last_input_id = None

在我们的情况下,我们一次只嵌入一个词。然而,在其他模型中,我们可能希望嵌入一个词序列并返回一个词向量序列。(例如,如果我们想要训练前面描述的 CBOW 模型。)因此,另一种设计将采用单词 ID 序列。我们将坚持一次只处理一个,以简化事务。

    def forward(self, input_id: int) -> Tensor:
        """Just select the embedding vector corresponding to the input id"""
        self.input_id = input_id    # remember for use in backpropagation

        return self.embeddings[input_id]

对于反向传播,我们将获得对应于所选嵌入向量的梯度,并且我们需要为self.embeddings构建相应的梯度,对于除所选之外的每个嵌入,其梯度都为零:

    def backward(self, gradient: Tensor) -> None:
        # Zero out the gradient corresponding to the last input.
        # This is way cheaper than creating a new all-zero tensor each time.
        if self.last_input_id is not None:
            zero_row = [0 for _ in range(self.embedding_dim)]
            self.grad[self.last_input_id] = zero_row

        self.last_input_id = self.input_id
        self.grad[self.input_id] = gradient

因为我们有参数和梯度,我们需要重写那些方法:

    def params(self) -> Iterable[Tensor]:
        return [self.embeddings]

    def grads(self) -> Iterable[Tensor]:
        return [self.grad]

如前所述,我们需要一个专门用于词向量的子类。在这种情况下,我们的嵌入数量由我们的词汇决定,所以让我们直接传入它:

class TextEmbedding(Embedding):
    def __init__(self, vocab: Vocabulary, embedding_dim: int) -> None:
        # Call the superclass constructor
        super().__init__(vocab.size, embedding_dim)

        # And hang onto the vocab
        self.vocab = vocab

所有其他内置方法都可以原样工作,但我们将添加一些特定于文本处理的方法。例如,我们希望能够检索给定单词的向量。(这不是Layer接口的一部分,但我们始终可以根据需要向特定层添加额外的方法。)

    def __getitem__(self, word: str) -> Tensor:
        word_id = self.vocab.get_id(word)
        if word_id is not None:
            return self.embeddings[word_id]
        else:
            return None

这个 dunder 方法将允许我们使用索引检索单词向量:

word_vector = embedding["black"]

我们还希望嵌入层告诉我们给定单词的最接近的单词:

    def closest(self, word: str, n: int = 5) -> List[Tuple[float, str]]:
        """Returns the n closest words based on cosine similarity"""
        vector = self[word]

        # Compute pairs (similarity, other_word), and sort most similar first
        scores = [(cosine_similarity(vector, self.embeddings[i]), other_word)
                  for other_word, i in self.vocab.w2i.items()]
        scores.sort(reverse=True)

        return scores[:n]

我们的嵌入层只输出向量,我们可以将其馈送到Linear层中。

现在我们准备好组装我们的训练数据。对于每个输入单词,我们将选择其左边的两个单词和右边的两个单词作为目标单词。

让我们从将句子转换为小写并拆分为单词开始:

import re

# This is not a great regex, but it works on our data.
tokenized_sentences = [re.findall("[a-z]+|[.]", sentence.lower())
                       for sentence in sentences]

在此之后,我们可以构建一个词汇表:

# Create a vocabulary (that is, a mapping word -> word_id) based on our text.
vocab = Vocabulary(word
                   for sentence_words in tokenized_sentences
                   for word in sentence_words)

现在我们可以创建训练数据:

from scratch.deep_learning import Tensor, one_hot_encode

inputs: List[int] = []
targets: List[Tensor] = []

for sentence in tokenized_sentences:
    for i, word in enumerate(sentence):          # For each word
        for j in [i - 2, i - 1, i + 1, i + 2]:   # take the nearby locations
            if 0 <= j < len(sentence):           # that aren't out of bounds
                nearby_word = sentence[j]        # and get those words.

                # Add an input that's the original word_id
                inputs.append(vocab.get_id(word))

                # Add a target that's the one-hot-encoded nearby word
                targets.append(vocab.one_hot_encode(nearby_word))

利用我们建立的机制,现在很容易创建我们的模型:

from scratch.deep_learning import Sequential, Linear

random.seed(0)
EMBEDDING_DIM = 5  # seems like a good size

# Define the embedding layer separately, so we can reference it.
embedding = TextEmbedding(vocab=vocab, embedding_dim=EMBEDDING_DIM)

model = Sequential([
    # Given a word (as a vector of word_ids), look up its embedding.
    embedding,
    # And use a linear layer to compute scores for "nearby words."
    Linear(input_dim=EMBEDDING_DIM, output_dim=vocab.size)
])

使用来自第十九章的工具,训练我们的模型非常容易:

from scratch.deep_learning import SoftmaxCrossEntropy, Momentum, GradientDescent

loss = SoftmaxCrossEntropy()
optimizer = GradientDescent(learning_rate=0.01)

for epoch in range(100):
    epoch_loss = 0.0
    for input, target in zip(inputs, targets):
        predicted = model.forward(input)
        epoch_loss += loss.loss(predicted, target)
        gradient = loss.gradient(predicted, target)
        model.backward(gradient)
        optimizer.step(model)
    print(epoch, epoch_loss)            # Print the loss
    print(embedding.closest("black"))   # and also a few nearest words
    print(embedding.closest("slow"))    # so we can see what's being
    print(embedding.closest("car"))     # learned.

当你看着这个训练过程时,你会看到颜色变得越来越接近,形容词变得越来越接近,名词也变得越来越接近。

模型训练完成后,探索最相似的单词是件有趣的事情:

pairs = [(cosine_similarity(embedding[w1], embedding[w2]), w1, w2)
         for w1 in vocab.w2i
         for w2 in vocab.w2i
         if w1 < w2]
pairs.sort(reverse=True)
print(pairs[:5])

这对我来说结果如下:

[(0.9980283554864815, 'boat', 'car'),
 (0.9975147744587706, 'bed', 'cat'),
 (0.9953153441218054, 'seems', 'was'),
 (0.9927107440377975, 'extremely', 'quite'),
 (0.9836183658415987, 'bed', 'car')]

(显然bedcat并不真正相似,但在我们的训练句子中它们似乎相似,并且模型捕捉到了这一点。)

我们还可以提取前两个主成分并将它们绘制出来:

from scratch.working_with_data import pca, transform
import matplotlib.pyplot as plt

# Extract the first two principal components and transform the word vectors
components = pca(embedding.embeddings, 2)
transformed = transform(embedding.embeddings, components)

# Scatter the points (and make them white so they're "invisible")
fig, ax = plt.subplots()
ax.scatter(*zip(*transformed), marker='.', color='w')

# Add annotations for each word at its transformed location
for word, idx in vocab.w2i.items():
    ax.annotate(word, transformed[idx])

# And hide the axes
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)

plt.show()

这表明相似的单词确实聚集在一起(参见图 21-3):

单词向量

图 21-3. 单词向量

如果你感兴趣,训练 CBOW 词向量并不难。你需要做一些工作。首先,你需要修改Embedding层,使其接受一个ID 列表作为输入,并输出一个嵌入向量列表。然后你需要创建一个新的层(Sum?),它接受一个向量列表并返回它们的总和。

每个单词表示一个训练示例,其中输入是周围单词的单词 ID,目标是单词本身的独热编码。

修改后的Embedding层将周围的单词转换为向量列表,新的Sum层将向量列表合并为单个向量,然后Linear层可以生成分数,这些分数可以经过softmax处理,得到表示“在这个上下文中最可能的单词”的分布。

我发现 CBOW 模型比跳字模型更难训练,但我鼓励你去尝试一下。

循环神经网络

我们在前一节中开发的单词向量通常用作神经网络的输入。做这件事的一个挑战是句子的长度不同:你可以将一个三个单词的句子想象为一个[3, embedding_dim]张量,而一个十个单词的句子想象为一个[10, embedding_dim]张量。为了,比如,将它们传递给Linear层,我们首先需要处理第一个可变长度维度。

一个选择是使用Sum层(或者一个接受平均值的变体);然而,句子中单词的顺序通常对其含义很重要。以一个常见的例子来说,“狗咬人”和“人咬狗”是两个非常不同的故事!

处理这个问题的另一种方法是使用循环神经网络(RNNs),它们具有它们在输入之间保持的隐藏状态。在最简单的情况下,每个输入与当前隐藏状态结合以产生输出,然后将其用作新的隐藏状态。这允许这些网络在某种意义上“记住”它们看到的输入,并建立到依赖于所有输入及其顺序的最终输出。

我们将创建一个非常简单的 RNN 层,它将接受单个输入(例如句子中的一个单词或一个单词中的一个字符),并在调用之间保持其隐藏状态。

回想一下,我们的Linear层有一些权重,w,和一个偏置,b。它接受一个向量input并使用逻辑生成不同的向量作为output

output[o] = dot(w[o], input) + b[o]

这里我们将要整合我们的隐藏状态,因此我们将有两组权重——一组用于应用于input,另一组用于前一个hidden状态:

output[o] = dot(w[o], input) + dot(u[o], hidden) + b[o]

接下来,我们将使用output向量作为新的hidden值。这并不是一个巨大的改变,但它将使我们的网络能够做出奇妙的事情。

from scratch.deep_learning import tensor_apply, tanh

class SimpleRnn(Layer):
    """Just about the simplest possible recurrent layer."""
    def __init__(self, input_dim: int, hidden_dim: int) -> None:
        self.input_dim = input_dim
        self.hidden_dim = hidden_dim

        self.w = random_tensor(hidden_dim, input_dim, init='xavier')
        self.u = random_tensor(hidden_dim, hidden_dim, init='xavier')
        self.b = random_tensor(hidden_dim)

        self.reset_hidden_state()

    def reset_hidden_state(self) -> None:
        self.hidden = [0 for _ in range(self.hidden_dim)]

你可以看到,我们将隐藏状态初始为一个 0 向量,并提供一个函数,供使用网络的人调用以重置隐藏状态。

在这种设置下,forward函数相对直接(至少,如果你记得并理解我们的Linear层是如何工作的话):

    def forward(self, input: Tensor) -> Tensor:
        self.input = input              # Save both input and previous
        self.prev_hidden = self.hidden  # hidden state to use in backprop.

        a = [(dot(self.w[h], input) +           # weights @ input
              dot(self.u[h], self.hidden) +     # weights @ hidden
              self.b[h])                        # bias
             for h in range(self.hidden_dim)]

        self.hidden = tensor_apply(tanh, a)  # Apply tanh activation
        return self.hidden                   # and return the result.

backward传递类似于我们Linear层中的传递,只是需要计算额外的u权重的梯度:

    def backward(self, gradient: Tensor):
        # Backpropagate through the tanh
        a_grad = [gradient[h] * (1 - self.hidden[h] ** 2)
                  for h in range(self.hidden_dim)]

        # b has the same gradient as a
        self.b_grad = a_grad

        # Each w[h][i] is multiplied by input[i] and added to a[h],
        # so each w_grad[h][i] = a_grad[h] * input[i]
        self.w_grad = [[a_grad[h] * self.input[i]
                        for i in range(self.input_dim)]
                       for h in range(self.hidden_dim)]

        # Each u[h][h2] is multiplied by hidden[h2] and added to a[h],
        # so each u_grad[h][h2] = a_grad[h] * prev_hidden[h2]
        self.u_grad = [[a_grad[h] * self.prev_hidden[h2]
                        for h2 in range(self.hidden_dim)]
                       for h in range(self.hidden_dim)]

        # Each input[i] is multiplied by every w[h][i] and added to a[h],
        # so each input_grad[i] = sum(a_grad[h] * w[h][i] for h in ...)
        return [sum(a_grad[h] * self.w[h][i] for h in range(self.hidden_dim))
                for i in range(self.input_dim)]

最后,我们需要重写paramsgrads方法:

    def params(self) -> Iterable[Tensor]:
        return [self.w, self.u, self.b]

    def grads(self) -> Iterable[Tensor]:
        return [self.w_grad, self.u_grad, self.b_grad]
警告

这个“简单”的 RNN 实在太简单了,你可能不应该在实践中使用它。

我们的SimpleRnn有几个不理想的特性。其中一个是每次调用它时,它的整个隐藏状态都用来更新输入。另一个是每次调用它时,整个隐藏状态都会被覆盖。这两点使得训练变得困难;特别是,它使模型难以学习长期依赖性。

因此,几乎没有人使用这种简单的 RNN。相反,他们使用更复杂的变体,如 LSTM(“长短期记忆”)或 GRU(“门控循环单元”),这些变体有更多的参数,并使用参数化的“门”来允许每个时间步只更新一部分状态(并且只使用一部分状态)。

这些变体并没有什么特别的困难;然而,它们涉及更多的代码,我认为阅读起来并不会相应更具有教育性。本章的代码可以在GitHub找到,其中包括了 LSTM 的实现。我鼓励你去看看,但这有点乏味,所以我们在这里不再详细讨论。

我们实现的另一个怪癖是,它每次只处理一个“步骤”,并且需要我们手动重置隐藏状态。一个更实用的 RNN 实现可以接受输入序列,将其隐藏状态在每个序列开始时设为 0,并生成输出序列。我们的实现肯定可以修改成这种方式;同样地,这将需要更多的代码和复杂性,而对理解的帮助不大。

示例:使用字符级别的循环神经网络

新任品牌副总裁并不是亲自想出DataSciencester这个名字的,因此他怀疑,换一个更好的名字可能会更有利于公司的成功。他请你使用数据科学来提出替换的候选名。

RNN 的一个“可爱”的应用包括使用字符(而不是单词)作为它们的输入,训练它们学习某个数据集中微妙的语言模式,然后使用它们生成该数据集的虚构实例。

例如,你可以训练一个 RNN 来学习另类乐队的名称,使用训练好的模型来生成新的假另类乐队的名称,然后手动选择最有趣的名称并分享在 Twitter 上。太有趣了!

见过这个技巧很多次后,你不再认为它很聪明,但你决定试试看。

经过一番调查,你发现创业加速器 Y Combinator 发布了其最成功的 100(实际上是 101)家初创企业的列表,这看起来是一个很好的起点。检查页面后,你发现公司名称都位于<b class="h4">标签内,这意味着你可以轻松使用你的网络爬虫技能来获取它们:

from bs4 import BeautifulSoup
import requests

url = "https://www.ycombinator.com/topcompanies/"
soup = BeautifulSoup(requests.get(url).text, 'html5lib')

# We get the companies twice, so use a set comprehension to deduplicate.
companies = list({b.text
                  for b in soup("b")
                  if "h4" in b.get("class", ())})
assert len(companies) == 101

和往常一样,页面可能会发生变化(或消失),这种情况下这段代码就不起作用了。如果是这样,你可以使用你新学到的数据科学技能来修复它,或者直接从书的 GitHub 站点获取列表。

那么我们的计划是什么呢?我们将训练一个模型来预测名称的下一个字符,给定当前字符和表示到目前为止所有字符的隐藏状态。

和往常一样,我们将预测字符的概率分布,并训练我们的模型以最小化SoftmaxCrossEntropy损失。

一旦我们的模型训练好了,我们可以使用它生成一些概率,根据这些概率随机抽取一个字符,然后将该字符作为下一个输入。这将允许我们使用学到的权重生成公司名称。

要开始,我们应该从名称中构建一个Vocabulary

vocab = Vocabulary([c for company in companies for c in company])

此外,我们将使用特殊的标记来表示公司名称的开始和结束。这允许模型学习哪些字符应该开始一个公司名称,以及何时一个公司名称结束

我们将只使用正则表达式字符来表示开始和结束,这些字符(幸运的是)不会出现在我们的公司名称列表中:

START = "^"
STOP = "$"

# We need to add them to the vocabulary too.
vocab.add(START)
vocab.add(STOP)

对于我们的模型,我们将对每个字符进行独热编码,通过两个SimpleRnn传递它们,然后使用Linear层生成每个可能的下一个字符的分数:

HIDDEN_DIM = 32  # You should experiment with different sizes!

rnn1 =  SimpleRnn(input_dim=vocab.size, hidden_dim=HIDDEN_DIM)
rnn2 =  SimpleRnn(input_dim=HIDDEN_DIM, hidden_dim=HIDDEN_DIM)
linear = Linear(input_dim=HIDDEN_DIM, output_dim=vocab.size)

model = Sequential([
    rnn1,
    rnn2,
    linear
])

假设我们已经训练好了这个模型。让我们编写一个函数,使用来自“主题建模”的sample_from函数来生成新的公司名称:

from scratch.deep_learning import softmax

def generate(seed: str = START, max_len: int = 50) -> str:
    rnn1.reset_hidden_state()  # Reset both hidden states
    rnn2.reset_hidden_state()
    output = [seed]            # Start the output with the specified seed

    # Keep going until we produce the STOP character or reach the max length
    while output[-1] != STOP and len(output) < max_len:
        # Use the last character as the input
        input = vocab.one_hot_encode(output[-1])

        # Generate scores using the model
        predicted = model.forward(input)

        # Convert them to probabilities and draw a random char_id
        probabilities = softmax(predicted)
        next_char_id = sample_from(probabilities)

        # Add the corresponding char to our output
        output.append(vocab.get_word(next_char_id))

    # Get rid of START and END characters and return the word
    return ''.join(output[1:-1])

终于,我们准备好训练我们的字符级 RNN。这会花费一些时间!

loss = SoftmaxCrossEntropy()
optimizer = Momentum(learning_rate=0.01, momentum=0.9)

for epoch in range(300):
    random.shuffle(companies)  # Train in a different order each epoch.
    epoch_loss = 0             # Track the loss.
    for company in tqdm.tqdm(companies):
        rnn1.reset_hidden_state()  # Reset both hidden states.
        rnn2.reset_hidden_state()
        company = START + company + STOP   # Add START and STOP characters.

        # The rest is just our usual training loop, except that the inputs
        # and target are the one-hot-encoded previous and next characters.
        for prev, next in zip(company, company[1:]):
            input = vocab.one_hot_encode(prev)
            target = vocab.one_hot_encode(next)
            predicted = model.forward(input)
            epoch_loss += loss.loss(predicted, target)
            gradient = loss.gradient(predicted, target)
            model.backward(gradient)
            optimizer.step(model)

    # Each epoch, print the loss and also generate a name.
    print(epoch, epoch_loss, generate())

    # Turn down the learning rate for the last 100 epochs.
    # There's no principled reason for this, but it seems to work.
    if epoch == 200:
        optimizer.lr *= 0.1

训练后,模型生成了一些实际的名称(这并不奇怪,因为模型具有相当的容量,但训练数据并不多),以及与训练名称略有不同的名称(Scripe, Loinbare, Pozium),看起来确实创意十足的名称(Benuus, Cletpo, Equite, Vivest),以及类似单词但是有点垃圾的名称(SFitreasy, Sint ocanelp, GliyOx, Doorboronelhav)。

不幸的是,像大多数字符级 RNN 输出一样,这些名称只是略有创意,品牌副总裁最终无法使用它们。

如果我将隐藏维度提升到 64,我将从列表中获得更多名称的原样;如果我将其降至 8,我将得到大多数垃圾名称。所有这些模型尺寸的词汇表和最终权重都可以在书的 GitHub 站点上找到,并且你可以使用load_weightsload_vocab来自己使用它们。

正如前面提到的,本章的 GitHub 代码还包含了 LSTM 的实现,你可以自由地将其替换为我们公司名称模型中的 SimpleRnn

进一步探索

  • NLTK 是一个流行的 Python 自然语言处理工具库。它有自己的整本 书籍,可以在线阅读。

  • gensim 是一个用于主题建模的 Python 库,比我们从头开始的模型更可靠。

  • spaCy 是一个用于“Python 中的工业级自然语言处理”库,也非常受欢迎。

  • Andrej Karpathy 有一篇著名的博文,“递归神经网络的非理性有效性”,非常值得一读。

  • 我的日常工作涉及构建 AllenNLP,一个用于进行自然语言处理研究的 Python 库。(至少在本书付印时是这样。)该库超出了本书的范围,但你可能会觉得它很有趣,而且还有一个很酷的交互式演示展示了许多最先进的 NLP 模型。

第二十二章:网络分析

你与你周围所有事物的连接实际上定义了你是谁。

Aaron O’Connell

许多有趣的数据问题可以通过网络的方式进行有益的思考,网络由某种类型的节点和连接它们的组成。

例如,你的 Facebook 朋友形成一个网络的节点,其边是友谊关系。一个不那么明显的例子是互联网本身,其中每个网页是一个节点,每个从一个页面到另一个页面的超链接是一条边。

Facebook 友谊是相互的——如果我在 Facebook 上是你的朋友,那么必然你也是我的朋友。在这种情况下,我们称这些边是无向的。而超链接则不是——我的网站链接到whitehouse.gov,但(出于我无法理解的原因)whitehouse.gov拒绝链接到我的网站。我们称这些类型的边为有向的。我们将研究这两种类型的网络。

中介中心性

在第一章中,我们通过计算每个用户拥有的朋友数量来计算 DataSciencester 网络中的关键连接者。现在我们有足够的机制来看看其他方法。我们将使用相同的网络,但现在我们将使用NamedTuple来处理数据。

回想一下,网络(图 22-1)包括用户:

from typing import NamedTuple

class User(NamedTuple):
    id: int
    name: str

users = [User(0, "Hero"), User(1, "Dunn"), User(2, "Sue"), User(3, "Chi"),
         User(4, "Thor"), User(5, "Clive"), User(6, "Hicks"),
         User(7, "Devin"), User(8, "Kate"), User(9, "Klein")]

以及友谊:

friend_pairs = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4),
                (4, 5), (5, 6), (5, 7), (6, 8), (7, 8), (8, 9)]

DataSciencester 网络。

图 22-1. DataSciencester 网络

友谊将更容易作为一个dict来处理:

from typing import Dict, List

# type alias for keeping track of Friendships
Friendships = Dict[int, List[int]]

friendships: Friendships = {user.id: [] for user in users}

for i, j in friend_pairs:
    friendships[i].append(j)
    friendships[j].append(i)

assert friendships[4] == [3, 5]
assert friendships[8] == [6, 7, 9]

当我们离开时,我们对我们关于度中心性的概念并不满意,这与我们对网络中关键连接者的直觉并不完全一致。

另一种度量标准是中介中心性,它识别频繁出现在其他人对之间最短路径上的人。特别地,节点i的中介中心性通过为每对节点jk添加上通过i的最短路径的比例来计算。

也就是说,要弄清楚 Thor 的中介中心性,我们需要计算所有不是 Thor 的人之间所有最短路径。然后我们需要计算有多少条这些最短路径通过 Thor。例如,Chi(id 3)和 Clive(id 5)之间唯一的最短路径通过 Thor,而 Hero(id 0)和 Chi(id 3)之间的两条最短路径都不通过 Thor。

因此,作为第一步,我们需要找出所有人之间的最短路径。有一些非常复杂的算法可以高效地完成这个任务,但是(几乎总是如此),我们将使用一种效率较低但更易于理解的算法。

这个算法(广度优先搜索的实现)是本书中比较复杂的算法之一,所以让我们仔细讨论一下它:

  1. 我们的目标是一个函数,它接受一个from_user,并找到到每个其他用户的所有最短路径。

  2. 我们将路径表示为用户 ID 的list。因为每条路径都从from_user开始,我们不会在列表中包含她的 ID。这意味着表示路径的列表长度将是路径本身的长度。

  3. 我们将维护一个名为shortest_paths_to的字典,其中键是用户 ID,值是以指定 ID 结尾的路径列表。如果有唯一的最短路径,列表将只包含该路径。如果有多条最短路径,则列表将包含所有这些路径。

  4. 我们还会维护一个称为frontier的队列,按照我们希望探索它们的顺序包含我们想要探索的用户。我们将它们存储为对(prev_user, user),这样我们就知道如何到达每一个用户。我们将队列初始化为from_user的所有邻居。(我们还没有讨论过队列,它们是优化了“添加到末尾”和“从前面删除”的数据结构。在 Python 中,它们被实现为collections.deque,实际上是一个双端队列。)

  5. 在探索图形时,每当我们发现新的邻居,而我们还不知道到达它们的最短路径时,我们将它们添加到队列的末尾以供稍后探索,当前用户为prev_user

  6. 当我们从队列中取出一个用户,并且我们以前从未遇到过该用户时,我们肯定找到了一条或多条最短路径——每条最短路径到prev_user再添加一步。

  7. 当我们从队列中取出一个用户,并且我们之前遇到过该用户时,那么要么我们找到了另一条最短路径(在这种情况下,我们应该添加它),要么我们找到了一条更长的路径(在这种情况下,我们不应该添加)。

  8. 当队列中没有更多的用户时,我们已经探索了整个图形(或者至少是从起始用户可达的部分),我们完成了。

我们可以把所有这些组合成一个(很大的)函数:

from collections import deque

Path = List[int]

def shortest_paths_from(from_user_id: int,
                        friendships: Friendships) -> Dict[int, List[Path]]:
    # A dictionary from user_id to *all* shortest paths to that user.
    shortest_paths_to: Dict[int, List[Path]] = {from_user_id: [[]]}

    # A queue of (previous user, next user) that we need to check.
    # Starts out with all pairs (from_user, friend_of_from_user).
    frontier = deque((from_user_id, friend_id)
                     for friend_id in friendships[from_user_id])

    # Keep going until we empty the queue.
    while frontier:
        # Remove the pair that's next in the queue.
        prev_user_id, user_id = frontier.popleft()

        # Because of the way we're adding to the queue,
        # necessarily we already know some shortest paths to prev_user.
        paths_to_prev_user = shortest_paths_to[prev_user_id]
        new_paths_to_user = [path + [user_id] for path in paths_to_prev_user]

        # It's possible we already know a shortest path to user_id.
        old_paths_to_user = shortest_paths_to.get(user_id, [])

        # What's the shortest path to here that we've seen so far?
        if old_paths_to_user:
            min_path_length = len(old_paths_to_user[0])
        else:
            min_path_length = float('inf')

        # Only keep paths that aren't too long and are actually new.
        new_paths_to_user = [path
                             for path in new_paths_to_user
                             if len(path) <= min_path_length
                             and path not in old_paths_to_user]

        shortest_paths_to[user_id] = old_paths_to_user + new_paths_to_user

        # Add never-seen neighbors to the frontier.
        frontier.extend((user_id, friend_id)
                        for friend_id in friendships[user_id]
                        if friend_id not in shortest_paths_to)

    return shortest_paths_to

现在让我们计算所有的最短路径:

# For each from_user, for each to_user, a list of shortest paths.
shortest_paths = {user.id: shortest_paths_from(user.id, friendships)
                  for user in users}

现在我们终于可以计算介数中心性了。对于每对节点ij,我们知道从ijn条最短路径。然后,对于每条路径,我们只需将 1/n 添加到该路径上每个节点的中心性:

betweenness_centrality = {user.id: 0.0 for user in users}

for source in users:
    for target_id, paths in shortest_paths[source.id].items():
        if source.id < target_id:      # don't double count
            num_paths = len(paths)     # how many shortest paths?
            contrib = 1 / num_paths    # contribution to centrality
            for path in paths:
                for between_id in path:
                    if between_id not in [source.id, target_id]:
                        betweenness_centrality[between_id] += contrib

如图 22-2 所示,用户 0 和 9 的中心性为 0(因为它们都不在任何其他用户之间的最短路径上),而 3、4 和 5 都具有很高的中心性(因为它们都位于许多最短路径上)。

DataSciencester 网络按介数中心性大小排序。

图 22-2. DataSciencester 网络按介数中心性大小排序
注意

通常中心性数值本身并不那么有意义。我们关心的是每个节点的数值与其他节点的数值相比如何。

另一个我们可以看的度量标准是closeness centrality。首先,对于每个用户,我们计算她的farness,即她到每个其他用户的最短路径长度之和。由于我们已经计算了每对节点之间的最短路径,所以将它们的长度相加很容易。(如果有多条最短路径,它们的长度都相同,所以我们只需看第一条。)

def farness(user_id: int) -> float:
    """the sum of the lengths of the shortest paths to each other user"""
    return sum(len(paths[0])
               for paths in shortest_paths[user_id].values())

之后计算接近中心度(图 22-3)的工作量就很小:

closeness_centrality = {user.id: 1 / farness(user.id) for user in users}

根据接近中心度调整大小的 DataSciencester 网络。

图 22-3. 根据接近中心度调整大小的 DataSciencester 网络

这里变化很少——即使非常中心的节点距离外围节点也相当远。

正如我们所见,计算最短路径有点麻烦。因此,介数和接近中心度在大型网络上并不经常使用。不太直观(但通常更容易计算)的eigenvector centrality更常用。

特征向量中心度

要谈论特征向量中心度,我们必须谈论特征向量,而要谈论特征向量,我们必须谈论矩阵乘法。

矩阵乘法

如果A是一个n × m矩阵,B是一个m × k矩阵(注意A的第二维度与B的第一维度相同),它们的乘积AB是一个n × k矩阵,其(i,j)项为:

A i1 B 1j + A i2 B 2j + + A im B mj

这只是A的第i行(看作向量)与B的第j列(也看作向量)的点积。

我们可以使用第四章中的make_matrix函数来实现这一点:

from scratch.linear_algebra import Matrix, make_matrix, shape

def matrix_times_matrix(m1: Matrix, m2: Matrix) -> Matrix:
    nr1, nc1 = shape(m1)
    nr2, nc2 = shape(m2)
    assert nc1 == nr2, "must have (# of columns in m1) == (# of rows in m2)"

    def entry_fn(i: int, j: int) -> float:
        """dot product of i-th row of m1 with j-th column of m2"""
        return sum(m1[i][k] * m2[k][j] for k in range(nc1))

    return make_matrix(nr1, nc2, entry_fn)

如果我们将一个m维向量视为(m, 1)矩阵,我们可以将其乘以一个(n, m)矩阵得到一个(n, 1)矩阵,然后我们可以将其视为一个n维向量。

这意味着另一种思考一个(n, m)矩阵的方式是将其视为将m维向量转换为n维向量的线性映射:

from scratch.linear_algebra import Vector, dot

def matrix_times_vector(m: Matrix, v: Vector) -> Vector:
    nr, nc = shape(m)
    n = len(v)
    assert nc == n, "must have (# of cols in m) == (# of elements in v)"

    return [dot(row, v) for row in m]  # output has length nr

A是一个方阵时,这个操作将n维向量映射到其他n维向量。对于某些矩阵A和向量v,当A作用于v时,可能得到v的一个标量倍数—也就是说,结果是一个指向v相同方向的向量。当这种情况发生时(并且此外v不是全零向量),我们称vA的一个特征向量。我们称这个乘数为特征值

找到A的一个特征向量的一个可能方法是选择一个起始向量v,应用matrix_times_vector,重新缩放结果使其大小为 1,并重复,直到过程收敛:

from typing import Tuple
import random
from scratch.linear_algebra import magnitude, distance

def find_eigenvector(m: Matrix,
                     tolerance: float = 0.00001) -> Tuple[Vector, float]:
    guess = [random.random() for _ in m]

    while True:
        result = matrix_times_vector(m, guess)    # transform guess
        norm = magnitude(result)                  # compute norm
        next_guess = [x / norm for x in result]   # rescale

        if distance(guess, next_guess) < tolerance:
            # convergence so return (eigenvector, eigenvalue)
            return next_guess, norm

        guess = next_guess

通过构造,返回的 guess 是一个向量,使得当你将 matrix_times_vector 应用于它并将其重新缩放为长度为 1 时,你会得到一个非常接近其自身的向量——这意味着它是一个特征向量。

并非所有的实数矩阵都有特征向量和特征值。例如,矩阵:

rotate = [[ 0, 1],
          [-1, 0]]

将向量顺时针旋转 90 度,这意味着它唯一将其映射到自身的标量倍数的向量是一个零向量。如果你尝试 find_eigenvector(rotate),它会无限运行。甚至具有特征向量的矩阵有时也会陷入循环中。考虑以下矩阵:

flip = [[0, 1],
        [1, 0]]

此矩阵将任何向量 [x, y] 映射到 [y, x]。这意味着,例如,[1, 1] 是一个特征向量,其特征值为 1。然而,如果你从具有不同坐标的随机向量开始,find_eigenvector 将永远只是无限交换坐标。(像 NumPy 这样的非从头开始的库使用不同的方法,可以在这种情况下工作。)尽管如此,当 find_eigenvector 确实返回一个结果时,那个结果确实是一个特征向量。

中心性

这如何帮助我们理解 DataSciencester 网络?首先,我们需要将网络中的连接表示为一个 adjacency_matrix,其(i,j)th 元素为 1(如果用户 i 和用户 j 是朋友)或 0(如果不是):

def entry_fn(i: int, j: int):
    return 1 if (i, j) in friend_pairs or (j, i) in friend_pairs else 0

n = len(users)
adjacency_matrix = make_matrix(n, n, entry_fn)

然后,每个用户的特征向量中心性就是在 find_eigenvector 返回的特征向量中对应于该用户的条目 (图 22-4)。

DataSciencester 网络的特征向量中心性大小。

图 22-4. DataSciencester 网络的特征向量中心性大小
注意

基于远远超出本书范围的技术原因,任何非零的邻接矩阵必然具有一个特征向量,其所有值都是非负的。幸运的是,对于这个 adjacency_matrix,我们的 find_eigenvector 函数找到了它。

eigenvector_centralities, _ = find_eigenvector(adjacency_matrix)

具有高特征向量中心性的用户应该是那些拥有许多连接并且连接到自身中心性高的人的用户。

在这里,用户 1 和 2 是最中心的,因为他们都有三个连接到自身中心性高的人。随着我们远离他们,人们的中心性逐渐下降。

在这样一个小网络上,特征向量中心性表现得有些不稳定。如果你尝试添加或删除链接,你会发现网络中的微小变化可能会极大地改变中心性数值。在一个规模大得多的网络中,情况可能不会特别如此。

我们仍然没有解释为什么特征向量可能导致一个合理的中心性概念。成为特征向量意味着如果你计算:

matrix_times_vector(adjacency_matrix, eigenvector_centralities)

结果是 eigenvector_centralities 的标量倍数。

如果你看矩阵乘法的工作方式,matrix_times_vector 生成一个向量,其 ith 元素为:

dot(adjacency_matrix[i], eigenvector_centralities)

这正是连接到用户 i 的用户的特征向量中心性的总和。

换句话说,特征向量中心度是一个数字,每个用户一个,其值是他的邻居值的常数倍。在这种情况下,中心度意味着与自身中心度很高的人连接。你直接连接的中心度越高,你自己就越中心。这当然是一个循环定义——特征向量是打破循环性的方法。

另一种理解方式是通过思考find_eigenvector在这里的作用来理解这个问题。它首先为每个节点分配一个随机的中心度,然后重复以下两个步骤,直到过程收敛:

  1. 给每个节点一个新的中心度分数,该分数等于其邻居(旧的)中心度分数的总和。

  2. 重新调整中心度向量,使其大小为 1。

尽管其背后的数学可能一开始看起来有些难懂,但计算本身相对简单(不像介数中心度那样),甚至可以在非常大的图上执行。 (至少,如果你使用真正的线性代数库,那么在大型图上执行起来是很容易的。如果你使用我们的矩阵作为列表的实现,你会有些困难。)

有向图和 PageRank

DataSciencester 并没有得到很多关注,因此收入副总裁考虑从友谊模式转向背书模式。结果表明,没有人特别在意哪些数据科学家彼此是朋友,但技术招聘人员非常在意其他数据科学家受到其他数据科学家的尊重

在这个新模型中,我们将跟踪不再代表互惠关系的背书(source, target),而是source背书target作为一个优秀的数据科学家(参见图 22-5)。

数据科学家背书网络。

图 22-5. DataSciencester 的背书网络

我们需要考虑这种不对称性:

endorsements = [(0, 1), (1, 0), (0, 2), (2, 0), (1, 2),
                (2, 1), (1, 3), (2, 3), (3, 4), (5, 4),
                (5, 6), (7, 5), (6, 8), (8, 7), (8, 9)]

之后,我们可以轻松地找到most_endorsed数据科学家,并将这些信息卖给招聘人员:

from collections import Counter

endorsement_counts = Counter(target for source, target in endorsements)

然而,“背书数量”是一个容易操控的度量标准。你所需要做的就是创建假账户,并让它们为你背书。或者与你的朋友安排互相为对方背书。(就像用户 0、1 和 2 似乎已经做过的那样。)

更好的度量标准应考虑为你背书。来自背书颇多的人的背书应该比来自背书较少的人的背书更有价值。这就是 PageRank 算法的本质,它被谷歌用来根据其他网站链接到它们的网站来排名网站,以及那些链接到这些网站的网站,依此类推。

(如果这让你想起特征向量中心度的想法,那是正常的。)

一个简化版本看起来像这样:

  1. 网络中总共有 1.0(或 100%)的 PageRank。

  2. 最初,这个 PageRank 在节点之间均匀分布。

  3. 在每一步中,每个节点的大部分 PageRank 均匀分布在其出链之间。

  4. 在每个步骤中,每个节点的 PageRank 剩余部分均匀分布在所有节点之间。

import tqdm

def page_rank(users: List[User],
              endorsements: List[Tuple[int, int]],
              damping: float = 0.85,
              num_iters: int = 100) -> Dict[int, float]:
    # Compute how many people each person endorses
    outgoing_counts = Counter(target for source, target in endorsements)

    # Initially distribute PageRank evenly
    num_users = len(users)
    pr = {user.id : 1 / num_users for user in users}

    # Small fraction of PageRank that each node gets each iteration
    base_pr = (1 - damping) / num_users

    for iter in tqdm.trange(num_iters):
        next_pr = {user.id : base_pr for user in users}  # start with base_pr

        for source, target in endorsements:
            # Add damped fraction of source pr to target
            next_pr[target] += damping * pr[source] / outgoing_counts[source]

        pr = next_pr

    return pr

如果我们计算页面排名:

pr = page_rank(users, endorsements)

# Thor (user_id 4) has higher page rank than anyone else
assert pr[4] > max(page_rank
                   for user_id, page_rank in pr.items()
                   if user_id != 4)

PageRank(图 22-6)将用户 4(Thor)标识为排名最高的数据科学家。

按 PageRank 排序的数据科学家网络。

图 22-6. 数据科学家网络按 PageRank 排序的大小

尽管 Thor 获得的认可少于用户 0、1 和 2(各有两个),但他的认可带来的排名来自他们的认可。此外,他的两个认可者仅认可了他一个人,这意味着他不必与任何其他人分享他们的排名。

进一步探索

  • 除了我们使用的这些之外,还有许多其他的中心度概念(尽管我们使用的这些基本上是最受欢迎的)。

  • NetworkX 是用于网络分析的 Python 库。它有计算中心度和可视化图形的函数。

  • Gephi 是一款爱它或者恨它的基于 GUI 的网络可视化工具。

第二十五章:Chapter 23. 推荐系统

O nature, nature, why art thou so dishonest, as ever to send men with these false recommendations into the world!

Henry Fielding

Another common data problem is producing recommendations of some sort. Netflix recommends movies you might want to watch. Amazon recommends products you might want to buy. Twitter recommends users you might want to follow. In this chapter, we’ll look at several ways to use data to make recommendations.

In particular, we’ll look at the dataset of users_interests that we’ve used before:

users_interests = [
    ["Hadoop", "Big Data", "HBase", "Java", "Spark", "Storm", "Cassandra"],
    ["NoSQL", "MongoDB", "Cassandra", "HBase", "Postgres"],
    ["Python", "scikit-learn", "scipy", "numpy", "statsmodels", "pandas"],
    ["R", "Python", "statistics", "regression", "probability"],
    ["machine learning", "regression", "decision trees", "libsvm"],
    ["Python", "R", "Java", "C++", "Haskell", "programming languages"],
    ["statistics", "probability", "mathematics", "theory"],
    ["machine learning", "scikit-learn", "Mahout", "neural networks"],
    ["neural networks", "deep learning", "Big Data", "artificial intelligence"],
    ["Hadoop", "Java", "MapReduce", "Big Data"],
    ["statistics", "R", "statsmodels"],
    ["C++", "deep learning", "artificial intelligence", "probability"],
    ["pandas", "R", "Python"],
    ["databases", "HBase", "Postgres", "MySQL", "MongoDB"],
    ["libsvm", "regression", "support vector machines"]
]

And we’ll think about the problem of recommending new interests to a user based on her currently specified interests.

Manual Curation

Before the internet, when you needed book recommendations you would go to the library, where a librarian was available to suggest books that were relevant to your interests or similar to books you liked.

Given DataSciencester’s limited number of users and interests, it would be easy for you to spend an afternoon manually recommending interests for each user. But this method doesn’t scale particularly well, and it’s limited by your personal knowledge and imagination. (Not that I’m suggesting that your personal knowledge and imagination are limited.) So let’s think about what we can do with data.

Recommending What’s Popular

One easy approach is to simply recommend what’s popular:

from collections import Counter

popular_interests = Counter(interest
                            for user_interests in users_interests
                            for interest in user_interests)

which looks like:

[('Python', 4),
 ('R', 4),
 ('Java', 3),
 ('regression', 3),
 ('statistics', 3),
 ('probability', 3),
 # ...
]

Having computed this, we can just suggest to a user the most popular interests that he’s not already interested in:

from typing import List, Tuple

def most_popular_new_interests(
        user_interests: List[str],
        max_results: int = 5) -> List[Tuple[str, int]]:
    suggestions = [(interest, frequency)
                   for interest, frequency in popular_interests.most_common()
                   if interest not in user_interests]
    return suggestions[:max_results]

So, if you are user 1, with interests:

["NoSQL", "MongoDB", "Cassandra", "HBase", "Postgres"]

then we’d recommend you:

[('Python', 4), ('R', 4), ('Java', 3), ('regression', 3), ('statistics', 3)]

If you are user 3, who’s already interested in many of those things, you’d instead get:

[('Java', 3), ('HBase', 3), ('Big Data', 3),
 ('neural networks', 2), ('Hadoop', 2)]

Of course, “lots of people are interested in Python, so maybe you should be too” is not the most compelling sales pitch. If someone is brand new to our site and we don’t know anything about them, that’s possibly the best we can do. Let’s see how we can do better by basing each user’s recommendations on her existing interests.

基于用户的协同过滤

One way of taking a user’s interests into account is to look for users who are somehow similar to her, and then suggest the things that those users are interested in.

In order to do that, we’ll need a way to measure how similar two users are. Here we’ll use cosine similarity, which we used in 第二十一章 to measure how similar two word vectors were.

We’ll apply this to vectors of 0s and 1s, each vector v representing one user’s interests. v[i] will be 1 if the user specified the ith interest, and 0 otherwise. Accordingly, “similar users” will mean “users whose interest vectors most nearly point in the same direction.” Users with identical interests will have similarity 1. Users with no identical interests will have similarity 0. Otherwise, the similarity will fall in between, with numbers closer to 1 indicating “very similar” and numbers closer to 0 indicating “not very similar.”

一个很好的开始是收集已知的兴趣,并(隐式地)为它们分配索引。我们可以通过使用集合推导来找到唯一的兴趣,并将它们排序成一个列表。结果列表中的第一个兴趣将是兴趣 0,依此类推:

unique_interests = sorted({interest
                           for user_interests in users_interests
                           for interest in user_interests})

这给我们一个以这样开始的列表:

assert unique_interests[:6] == [
    'Big Data',
    'C++',
    'Cassandra',
    'HBase',
    'Hadoop',
    'Haskell',
    # ...
]

接下来,我们想为每个用户生成一个“兴趣”向量,其中包含 0 和 1。我们只需遍历unique_interests列表,如果用户具有每个兴趣,则替换为 1,否则为 0:

def make_user_interest_vector(user_interests: List[str]) -> List[int]:
    """
 Given a list of interests, produce a vector whose ith element is 1
 if unique_interests[i] is in the list, 0 otherwise
 """
    return [1 if interest in user_interests else 0
            for interest in unique_interests]

现在我们可以制作一个用户兴趣向量的列表:

user_interest_vectors = [make_user_interest_vector(user_interests)
                         for user_interests in users_interests]

现在,如果用户i指定了兴趣j,那么user_interest_vectors[i][j]等于 1,否则为 0。

因为我们有一个小数据集,计算所有用户之间的成对相似性是没有问题的:

from scratch.nlp import cosine_similarity

user_similarities = [[cosine_similarity(interest_vector_i, interest_vector_j)
                      for interest_vector_j in user_interest_vectors]
                     for interest_vector_i in user_interest_vectors]

之后,user_similarities[i][j]给出了用户ij之间的相似性:

# Users 0 and 9 share interests in Hadoop, Java, and Big Data
assert 0.56 < user_similarities[0][9] < 0.58, "several shared interests"

# Users 0 and 8 share only one interest: Big Data
assert 0.18 < user_similarities[0][8] < 0.20, "only one shared interest"

特别地,user_similarities[i]是用户i与每个其他用户的相似性向量。我们可以使用这个来编写一个函数,找出与给定用户最相似的用户。我们会确保不包括用户本身,也不包括任何相似性为零的用户。并且我们会按照相似性从高到低对结果进行排序:

def most_similar_users_to(user_id: int) -> List[Tuple[int, float]]:
    pairs = [(other_user_id, similarity)                      # Find other
             for other_user_id, similarity in                 # users with
                enumerate(user_similarities[user_id])         # nonzero
             if user_id != other_user_id and similarity > 0]  # similarity.

    return sorted(pairs,                                      # Sort them
                  key=lambda pair: pair[-1],                  # most similar
                  reverse=True)                               # first.

例如,如果我们调用most_similar_users_to(0),我们会得到:

[(9, 0.5669467095138409),
 (1, 0.3380617018914066),
 (8, 0.1889822365046136),
 (13, 0.1690308509457033),
 (5, 0.1543033499620919)]

我们如何利用这个来向用户建议新的兴趣?对于每个兴趣,我们可以简单地加上对它感兴趣的其他用户的用户相似性:

from collections import defaultdict

def user_based_suggestions(user_id: int,
                           include_current_interests: bool = False):
    # Sum up the similarities
    suggestions: Dict[str, float] = defaultdict(float)
    for other_user_id, similarity in most_similar_users_to(user_id):
        for interest in users_interests[other_user_id]:
            suggestions[interest] += similarity

    # Convert them to a sorted list
    suggestions = sorted(suggestions.items(),
                         key=lambda pair: pair[-1],  # weight
                         reverse=True)

    # And (maybe) exclude already interests
    if include_current_interests:
        return suggestions
    else:
        return [(suggestion, weight)
                for suggestion, weight in suggestions
                if suggestion not in users_interests[user_id]]

如果我们调用user_based_suggestions(0),那么前几个建议的兴趣是:

[('MapReduce', 0.5669467095138409),
 ('MongoDB', 0.50709255283711),
 ('Postgres', 0.50709255283711),
 ('NoSQL', 0.3380617018914066),
 ('neural networks', 0.1889822365046136),
 ('deep learning', 0.1889822365046136),
 ('artificial intelligence', 0.1889822365046136),
 #...
]

对于那些声称兴趣是“大数据”和数据库相关的人来说,这些看起来是相当不错的建议。(权重本质上没有意义;我们只是用它们来排序。)

当项目数量变得非常大时,这种方法效果不佳。回想一下第十二章中的维度诅咒 —— 在高维向量空间中,大多数向量相距甚远(并且指向非常不同的方向)。也就是说,当兴趣的数量很多时,对于给定用户,“最相似的用户”可能完全不相似。

想象一个像 Amazon.com 这样的网站,我在过去几十年里购买了成千上万件物品。你可以基于购买模式尝试识别与我类似的用户,但在全世界范围内,几乎没有人的购买历史看起来像我的。无论我的“最相似”的购物者是谁,他可能与我完全不相似,他的购买几乎肯定不会提供好的推荐。

基于物品的协同过滤

另一种方法是直接计算兴趣之间的相似性。然后我们可以通过聚合与她当前兴趣相似的兴趣来为每个用户生成建议。

要开始,我们将希望转置我们的用户-兴趣矩阵,以便行对应于兴趣,列对应于用户:

interest_user_matrix = [[user_interest_vector[j]
                         for user_interest_vector in user_interest_vectors]
                        for j, _ in enumerate(unique_interests)]

这是什么样子?interest_user_matrix 的第 j 行就是 user_interest_matrix 的第 j 列。也就是说,对于每个具有该兴趣的用户,它的值为 1,对于每个没有该兴趣的用户,它的值为 0。

例如,unique_interests[0] 是大数据,所以 interest_user_matrix[0] 是:

[1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0]

因为用户 0、8 和 9 表示对大数据感兴趣。

现在我们可以再次使用余弦相似度。如果完全相同的用户对两个主题感兴趣,它们的相似度将为 1。如果没有两个用户对两个主题感兴趣,它们的相似度将为 0:

interest_similarities = [[cosine_similarity(user_vector_i, user_vector_j)
                          for user_vector_j in interest_user_matrix]
                         for user_vector_i in interest_user_matrix]

例如,我们可以使用以下方法找到与大数据(兴趣 0)最相似的兴趣:

def most_similar_interests_to(interest_id: int):
    similarities = interest_similarities[interest_id]
    pairs = [(unique_interests[other_interest_id], similarity)
             for other_interest_id, similarity in enumerate(similarities)
             if interest_id != other_interest_id and similarity > 0]
    return sorted(pairs,
                  key=lambda pair: pair[-1],
                  reverse=True)

这表明以下类似的兴趣:

[('Hadoop', 0.8164965809277261),
 ('Java', 0.6666666666666666),
 ('MapReduce', 0.5773502691896258),
 ('Spark', 0.5773502691896258),
 ('Storm', 0.5773502691896258),
 ('Cassandra', 0.4082482904638631),
 ('artificial intelligence', 0.4082482904638631),
 ('deep learning', 0.4082482904638631),
 ('neural networks', 0.4082482904638631),
 ('HBase', 0.3333333333333333)]

现在我们可以通过累加与其相似的兴趣的相似性来为用户创建推荐:

def item_based_suggestions(user_id: int,
                           include_current_interests: bool = False):
    # Add up the similar interests
    suggestions = defaultdict(float)
    user_interest_vector = user_interest_vectors[user_id]
    for interest_id, is_interested in enumerate(user_interest_vector):
        if is_interested == 1:
            similar_interests = most_similar_interests_to(interest_id)
            for interest, similarity in similar_interests:
                suggestions[interest] += similarity

    # Sort them by weight
    suggestions = sorted(suggestions.items(),
                         key=lambda pair: pair[-1],
                         reverse=True)

    if include_current_interests:
        return suggestions
    else:
        return [(suggestion, weight)
                for suggestion, weight in suggestions
                if suggestion not in users_interests[user_id]]

对于用户 0,这将生成以下(看起来合理的)推荐:

[('MapReduce', 1.861807319565799),
 ('Postgres', 1.3164965809277263),
 ('MongoDB', 1.3164965809277263),
 ('NoSQL', 1.2844570503761732),
 ('programming languages', 0.5773502691896258),
 ('MySQL', 0.5773502691896258),
 ('Haskell', 0.5773502691896258),
 ('databases', 0.5773502691896258),
 ('neural networks', 0.4082482904638631),
 ('deep learning', 0.4082482904638631),
 ('C++', 0.4082482904638631),
 ('artificial intelligence', 0.4082482904638631),
 ('Python', 0.2886751345948129),
 ('R', 0.2886751345948129)]

矩阵分解

正如我们所看到的,我们可以将用户的偏好表示为一个 [num_users, num_items] 的矩阵,其中 1 表示喜欢的项目,0 表示不喜欢的项目。

有时您实际上可能有数值型的 评分;例如,当您写亚马逊评论时,您为物品分配了从 1 到 5 星的评分。您仍然可以通过数字在一个 [num_users, num_items] 的矩阵中表示这些(暂时忽略未评分项目的问题)。

在本节中,我们假设已经有了这样的评分数据,并尝试学习一个能够预测给定用户和项目评分的模型。

解决这个问题的一种方法是假设每个用户都有一些潜在的“类型”,可以表示为一组数字向量,而每个项目同样也有一些潜在的“类型”。

如果将用户类型表示为 [num_users, dim] 矩阵,将项目类型的转置表示为 [dim, num_items] 矩阵,则它们的乘积是一个 [num_users, num_items] 矩阵。因此,构建这样一个模型的一种方式是将偏好矩阵“因子化”为用户矩阵和项目矩阵的乘积。

(也许这种潜在类型的想法会让你想起我们在 第二十一章 中开发的词嵌入。记住这个想法。)

而不是使用我们虚构的 10 用户数据集,我们将使用 MovieLens 100k 数据集,其中包含许多用户对许多电影的评分,评分从 0 到 5 不等。每个用户只对少数电影进行了评分。我们将尝试构建一个系统,可以预测任意给定的(用户,电影)对的评分。我们将训练它以在每个用户评分的电影上表现良好;希望它能推广到用户未评分的电影。

首先,让我们获取数据集。您可以从 http://files.grouplens.org/datasets/movielens/ml-100k.zip 下载它。

解压并提取文件;我们仅使用其中两个:

# This points to the current directory, modify if your files are elsewhere.
MOVIES = "u.item"   # pipe-delimited: movie_id|title|...
RATINGS = "u.data"  # tab-delimited: user_id, movie_id, rating, timestamp

常见情况下,我们会引入 NamedTuple 来使工作更加简便:

from typing import NamedTuple

class Rating(NamedTuple):
    user_id: str
    movie_id: str
    rating: float
注意

电影 ID 和用户 ID 实际上是整数,但它们不是连续的,这意味着如果我们将它们作为整数处理,将会有很多浪费的维度(除非我们重新编号所有内容)。因此,为了简化起见,我们将它们视为字符串处理。

现在让我们读取数据并探索它。电影文件是管道分隔的,并且有许多列。我们只关心前两列,即 ID 和标题:

import csv
# We specify this encoding to avoid a UnicodeDecodeError.
# See: https://stackoverflow.com/a/53136168/1076346.
with open(MOVIES, encoding="iso-8859-1") as f:
    reader = csv.reader(f, delimiter="|")
    movies = {movie_id: title for movie_id, title, *_ in reader}

评分文件是制表符分隔的,包含四列:user_idmovie_id、评分(1 到 5),以及timestamp。我们将忽略时间戳,因为我们不需要它:

# Create a list of [Rating]
with open(RATINGS, encoding="iso-8859-1") as f:
    reader = csv.reader(f, delimiter="\t")
    ratings = [Rating(user_id, movie_id, float(rating))
               for user_id, movie_id, rating, _ in reader]

# 1682 movies rated by 943 users
assert len(movies) == 1682
assert len(list({rating.user_id for rating in ratings})) == 943

有很多有趣的探索性分析可以在这些数据上进行;例如,您可能对星球大战电影的平均评分感兴趣(该数据集来自 1998 年,比星球大战:幽灵的威胁晚一年):

import re

# Data structure for accumulating ratings by movie_id
star_wars_ratings = {movie_id: []
                     for movie_id, title in movies.items()
                     if re.search("Star Wars|Empire Strikes|Jedi", title)}

# Iterate over ratings, accumulating the Star Wars ones
for rating in ratings:
    if rating.movie_id in star_wars_ratings:
        star_wars_ratings[rating.movie_id].append(rating.rating)

# Compute the average rating for each movie
avg_ratings = [(sum(title_ratings) / len(title_ratings), movie_id)
               for movie_id, title_ratings in star_wars_ratings.items()]

# And then print them in order
for avg_rating, movie_id in sorted(avg_ratings, reverse=True):
    print(f"{avg_rating:.2f} {movies[movie_id]}")

它们都评分很高:

4.36 Star Wars (1977)
4.20 Empire Strikes Back, The (1980)
4.01 Return of the Jedi (1983)

所以让我们尝试设计一个模型来预测这些评分。作为第一步,让我们将评分数据分成训练集、验证集和测试集:

import random
random.seed(0)
random.shuffle(ratings)

split1 = int(len(ratings) * 0.7)
split2 = int(len(ratings) * 0.85)

train = ratings[:split1]              # 70% of the data
validation = ratings[split1:split2]   # 15% of the data
test = ratings[split2:]               # 15% of the data

拥有一个简单的基线模型总是好的,并确保我们的模型比它表现更好。这里一个简单的基线模型可能是“预测平均评分”。我们将使用均方误差作为我们的指标,所以让我们看看基线在我们的测试集上表现如何:

avg_rating = sum(rating.rating for rating in train) / len(train)
baseline_error = sum((rating.rating - avg_rating) ** 2
                     for rating in test) / len(test)

# This is what we hope to do better than
assert 1.26 < baseline_error < 1.27

给定我们的嵌入,预测的评分由用户嵌入和电影嵌入的矩阵乘积给出。对于给定的用户和电影,该值只是对应嵌入的点积。

所以让我们从创建嵌入开始。我们将它们表示为dict,其中键是 ID,值是向量,这样可以轻松地检索给定 ID 的嵌入:

from scratch.deep_learning import random_tensor

EMBEDDING_DIM = 2

# Find unique ids
user_ids = {rating.user_id for rating in ratings}
movie_ids = {rating.movie_id for rating in ratings}

# Then create a random vector per id
user_vectors = {user_id: random_tensor(EMBEDDING_DIM)
                for user_id in user_ids}
movie_vectors = {movie_id: random_tensor(EMBEDDING_DIM)
                 for movie_id in movie_ids}

到目前为止,我们应该相当擅长编写训练循环:

from typing import List
import tqdm
from scratch.linear_algebra import dot

def loop(dataset: List[Rating],
         learning_rate: float = None) -> None:
    with tqdm.tqdm(dataset) as t:
        loss = 0.0
        for i, rating in enumerate(t):
            movie_vector = movie_vectors[rating.movie_id]
            user_vector = user_vectors[rating.user_id]
            predicted = dot(user_vector, movie_vector)
            error = predicted - rating.rating
            loss += error ** 2

            if learning_rate is not None:
                #     predicted = m_0 * u_0 + ... + m_k * u_k
                # So each u_j enters output with coefficent m_j
                # and each m_j enters output with coefficient u_j
                user_gradient = [error * m_j for m_j in movie_vector]
                movie_gradient = [error * u_j for u_j in user_vector]

                # Take gradient steps
                for j in range(EMBEDDING_DIM):
                    user_vector[j] -= learning_rate * user_gradient[j]
                    movie_vector[j] -= learning_rate * movie_gradient[j]

            t.set_description(f"avg loss: {loss / (i + 1)}")

现在我们可以训练我们的模型(即找到最优的嵌入)。对我来说,如果我每个时期都稍微降低学习率,效果最好:

learning_rate = 0.05
for epoch in range(20):
    learning_rate *= 0.9
    print(epoch, learning_rate)
    loop(train, learning_rate=learning_rate)
    loop(validation)
loop(test)

这个模型很容易过拟合训练集。我在测试集上的平均损失是大约 0.89,这时EMBEDDING_DIM=2的情况下取得最佳结果。

注意

如果您想要更高维度的嵌入,您可以尝试像我们在“正则化”中使用的正则化。特别是,在每次梯度更新时,您可以将权重收缩至 0 附近。但我没能通过这种方式获得更好的结果。

现在,检查学习到的向量。没有理由期望这两个组件特别有意义,因此我们将使用主成分分析:

from scratch.working_with_data import pca, transform

original_vectors = [vector for vector in movie_vectors.values()]
components = pca(original_vectors, 2)

让我们将我们的向量转换为表示主成分,并加入电影 ID 和平均评分:

ratings_by_movie = defaultdict(list)
for rating in ratings:
    ratings_by_movie[rating.movie_id].append(rating.rating)

vectors = [
    (movie_id,
     sum(ratings_by_movie[movie_id]) / len(ratings_by_movie[movie_id]),
     movies[movie_id],
     vector)
    for movie_id, vector in zip(movie_vectors.keys(),
                                transform(original_vectors, components))
]

# Print top 25 and bottom 25 by first principal component
print(sorted(vectors, key=lambda v: v[-1][0])[:25])
print(sorted(vectors, key=lambda v: v[-1][0])[-25:])

前 25 个电影评分都很高,而后 25 个大部分是低评分的(或在训练数据中未评级),这表明第一个主成分主要捕捉了“这部电影有多好?”

对于我来说,很难理解第二个组件的意义;而且二维嵌入的表现只比一维嵌入略好,这表明第二个组件捕捉到的可能是非常微妙的内容。(可以推测,在较大的 MovieLens 数据集中可能有更有趣的事情发生。)

进一步探索

  • 惊喜是一个用于“构建和分析推荐系统”的 Python 库,似乎相当受欢迎且更新及时。

  • Netflix Prize 是一个相当有名的比赛,旨在构建更好的系统,向 Netflix 用户推荐电影。

第二十四章:数据库和 SQL

记忆是人类最好的朋友,也是最坏的敌人。

Gilbert Parker

您需要的数据通常存储在数据库中,这些系统专门设计用于高效存储和查询数据。这些大部分是关系型数据库,如 PostgreSQL、MySQL 和 SQL Server,它们将数据存储在中,并通常使用结构化查询语言(SQL)进行查询,这是一种用于操作数据的声明性语言。

SQL 是数据科学家工具包中非常重要的一部分。在本章中,我们将创建 NotQuiteABase,这是 Python 实现的一个几乎不是数据库的东西。我们还将介绍 SQL 的基础知识,并展示它们在我们的几乎不是数据库中的工作方式,这是我能想到的最“从头开始”的方式,帮助您理解它们在做什么。我希望在 NotQuiteABase 中解决问题将使您对如何使用 SQL 解决相同问题有一个良好的感觉。

创建表和插入

关系数据库是表的集合,以及它们之间的关系。表只是行的集合,与我们一直在处理的一些矩阵类似。然而,表还有一个固定的模式,包括列名和列类型。

例如,想象一个包含每个用户的user_idnamenum_friendsusers数据集:

users = [[0, "Hero", 0],
         [1, "Dunn", 2],
         [2, "Sue", 3],
         [3, "Chi", 3]]

在 SQL 中,我们可以这样创建这个表:

CREATE TABLE users (
    user_id INT NOT NULL,
    name VARCHAR(200),
    num_friends INT);

注意我们指定了user_idnum_friends必须是整数(并且user_id不允许为NULL,表示缺少值,类似于我们的None),而name应该是长度不超过 200 的字符串。我们将类似地使用 Python 类型。

注意

SQL 几乎完全不区分大小写和缩进。这里的大写和缩进风格是我喜欢的风格。如果您开始学习 SQL,您肯定会遇到其他样式不同的例子。

您可以使用INSERT语句插入行:

INSERT INTO users (user_id, name, num_friends) VALUES (0, 'Hero', 0);

还要注意 SQL 语句需要以分号结尾,并且 SQL 中字符串需要用单引号括起来。

在 NotQuiteABase 中,您将通过指定类似的模式来创建一个Table。然后,要插入一行,您将使用表的insert方法,该方法接受一个与表列名顺序相同的list行值。

在幕后,我们将每一行都存储为一个从列名到值的dict。一个真正的数据库永远不会使用这样浪费空间的表示方法,但这样做将使得 NotQuiteABase 更容易处理。

我们将 NotQuiteABase Table实现为一个巨大的类,我们将一次实现一个方法。让我们先把导入和类型别名处理掉:

from typing import Tuple, Sequence, List, Any, Callable, Dict, Iterator
from collections import defaultdict

# A few type aliases we'll use later
Row = Dict[str, Any]                        # A database row
WhereClause = Callable[[Row], bool]         # Predicate for a single row
HavingClause = Callable[[List[Row]], bool]  # Predicate over multiple rows

让我们从构造函数开始。要创建一个 NotQuiteABase 表,我们需要传入列名列表和列类型列表,就像您在创建 SQL 数据库中的表时所做的一样:

class Table:
    def __init__(self, columns: List[str], types: List[type]) -> None:
        assert len(columns) == len(types), "# of columns must == # of types"

        self.columns = columns         # Names of columns
        self.types = types             # Data types of columns
        self.rows: List[Row] = []      # (no data yet)

我们将添加一个帮助方法来获取列的类型:

    def col2type(self, col: str) -> type:
        idx = self.columns.index(col)      # Find the index of the column,
        return self.types[idx]             # and return its type.

我们将添加一个 insert 方法来检查您要插入的值是否有效。特别是,您必须提供正确数量的值,并且每个值必须是正确的类型(或 None):

    def insert(self, values: list) -> None:
        # Check for right # of values
        if len(values) != len(self.types):
            raise ValueError(f"You need to provide {len(self.types)} values")

        # Check for right types of values
        for value, typ3 in zip(values, self.types):
            if not isinstance(value, typ3) and value is not None:
                raise TypeError(f"Expected type {typ3} but got {value}")

        # Add the corresponding dict as a "row"
        self.rows.append(dict(zip(self.columns, values)))

在实际的 SQL 数据库中,你需要明确指定任何给定列是否允许包含空值 (None);为了简化我们的生活,我们只会说任何列都可以。

我们还将引入一些 dunder 方法,允许我们将表视为一个 List[Row],我们主要用于测试我们的代码:

    def __getitem__(self, idx: int) -> Row:
        return self.rows[idx]

    def __iter__(self) -> Iterator[Row]:
        return iter(self.rows)

    def __len__(self) -> int:
        return len(self.rows)

我们将添加一个方法来漂亮地打印我们的表:

    def __repr__(self):
        """Pretty representation of the table: columns then rows"""
        rows = "\n".join(str(row) for row in self.rows)

        return f"{self.columns}\n{rows}"

现在我们可以创建我们的 Users 表:

# Constructor requires column names and types
users = Table(['user_id', 'name', 'num_friends'], [int, str, int])
users.insert([0, "Hero", 0])
users.insert([1, "Dunn", 2])
users.insert([2, "Sue", 3])
users.insert([3, "Chi", 3])
users.insert([4, "Thor", 3])
users.insert([5, "Clive", 2])
users.insert([6, "Hicks", 3])
users.insert([7, "Devin", 2])
users.insert([8, "Kate", 2])
users.insert([9, "Klein", 3])
users.insert([10, "Jen", 1])

如果您现在 print(users),您将看到:

['user_id', 'name', 'num_friends']
{'user_id': 0, 'name': 'Hero', 'num_friends': 0}
{'user_id': 1, 'name': 'Dunn', 'num_friends': 2}
{'user_id': 2, 'name': 'Sue', 'num_friends': 3}
...

列表样的 API 使得编写测试变得容易:

assert len(users) == 11
assert users[1]['name'] == 'Dunn'

我们还有更多功能要添加。

更新

有时您需要更新已经在数据库中的数据。例如,如果 Dunn 又交了一个朋友,您可能需要这样做:

UPDATE users
SET num_friends = 3
WHERE user_id = 1;

关键特性包括:

  • 要更新哪个表

  • 要更新哪些行

  • 要更新哪些字段

  • 它们的新值应该是什么

我们将在 NotQuiteABase 中添加一个类似的 update 方法。它的第一个参数将是一个 dict,其键是要更新的列,其值是这些字段的新值。其第二个(可选)参数应该是一个 predicate,对于应该更新的行返回 True,否则返回 False

    def update(self,
               updates: Dict[str, Any],
               predicate: WhereClause = lambda row: True):
        # First make sure the updates have valid names and types
        for column, new_value in updates.items():
            if column not in self.columns:
                raise ValueError(f"invalid column: {column}")

            typ3 = self.col2type(column)
            if not isinstance(new_value, typ3) and new_value is not None:
                raise TypeError(f"expected type {typ3}, but got {new_value}")

        # Now update
        for row in self.rows:
            if predicate(row):
                for column, new_value in updates.items():
                    row[column] = new_value

之后我们可以简单地这样做:

assert users[1]['num_friends'] == 2             # Original value

users.update({'num_friends' : 3},               # Set num_friends = 3
             lambda row: row['user_id'] == 1)   # in rows where user_id == 1

assert users[1]['num_friends'] == 3             # Updated value

删除

在 SQL 中从表中删除行有两种方法。危险的方式会删除表中的每一行:

DELETE FROM users;

较不危险的方式添加了一个 WHERE 子句,并且仅删除满足特定条件的行:

DELETE FROM users WHERE user_id = 1;

将此功能添加到我们的 Table 中很容易:

    def delete(self, predicate: WhereClause = lambda row: True) -> None:
        """Delete all rows matching predicate"""
        self.rows = [row for row in self.rows if not predicate(row)]

如果您提供一个 predicate 函数(即 WHERE 子句),这将仅删除满足它的行。如果您不提供一个,那么默认的 predicate 总是返回 True,并且您将删除每一行。

例如:

# We're not actually going to run these
users.delete(lambda row: row["user_id"] == 1)  # Deletes rows with user_id == 1
users.delete()                                 # Deletes every row

选择

通常你不直接检查 SQL 表。相反,您使用 SELECT 语句查询它们:

SELECT * FROM users;                            -- get the entire contents
SELECT * FROM users LIMIT 2;                    -- get the first two rows
SELECT user_id FROM users;                      -- only get specific columns
SELECT user_id FROM users WHERE name = 'Dunn';  -- only get specific rows

您还可以使用 SELECT 语句计算字段:

SELECT LENGTH(name) AS name_length FROM users;

我们将给我们的 Table 类添加一个 select 方法,该方法返回一个新的 Table。该方法接受两个可选参数:

  • keep_columns 指定结果中要保留的列名。如果您没有提供它,结果将包含所有列。

  • additional_columns 是一个字典,其键是新列名,值是指定如何计算新列值的函数。我们将查看这些函数的类型注解来确定新列的类型,因此这些函数需要有注解的返回类型。

如果你没有提供它们中的任何一个,你将简单地得到表的一个副本:

    def select(self,
               keep_columns: List[str] = None,
               additional_columns: Dict[str, Callable] = None) -> 'Table':

        if keep_columns is None:         # If no columns specified,
            keep_columns = self.columns  # return all columns

        if additional_columns is None:
            additional_columns = {}

        # New column names and types
        new_columns = keep_columns + list(additional_columns.keys())
        keep_types = [self.col2type(col) for col in keep_columns]

        # This is how to get the return type from a type annotation.
        # It will crash if `calculation` doesn't have a return type.
        add_types = [calculation.__annotations__['return']
                     for calculation in additional_columns.values()]

        # Create a new table for results
        new_table = Table(new_columns, keep_types + add_types)

        for row in self.rows:
            new_row = [row[column] for column in keep_columns]
            for column_name, calculation in additional_columns.items():
                new_row.append(calculation(row))
            new_table.insert(new_row)

        return new_table
注意

还记得在第二章中我们说过类型注解实际上什么也不做吗?好吧,这里是反例。但是看看我们必须经历多么复杂的过程才能得到它们。

我们的select返回一个新的Table,而典型的 SQL SELECT仅产生某种临时结果集(除非您将结果明确插入到表中)。

我们还需要wherelimit方法。这两者都很简单:

    def where(self, predicate: WhereClause = lambda row: True) -> 'Table':
        """Return only the rows that satisfy the supplied predicate"""
        where_table = Table(self.columns, self.types)
        for row in self.rows:
            if predicate(row):
                values = [row[column] for column in self.columns]
                where_table.insert(values)
        return where_table

    def limit(self, num_rows: int) -> 'Table':
        """Return only the first `num_rows` rows"""
        limit_table = Table(self.columns, self.types)
        for i, row in enumerate(self.rows):
            if i >= num_rows:
                break
            values = [row[column] for column in self.columns]
            limit_table.insert(values)
        return limit_table

然后我们可以轻松地构造与前面的 SQL 语句相等的 NotQuiteABase 等效语句:

# SELECT * FROM users;
all_users = users.select()
assert len(all_users) == 11

# SELECT * FROM users LIMIT 2;
two_users = users.limit(2)
assert len(two_users) == 2

# SELECT user_id FROM users;
just_ids = users.select(keep_columns=["user_id"])
assert just_ids.columns == ['user_id']

# SELECT user_id FROM users WHERE name = 'Dunn';
dunn_ids = (
    users
    .where(lambda row: row["name"] == "Dunn")
    .select(keep_columns=["user_id"])
)
assert len(dunn_ids) == 1
assert dunn_ids[0] == {"user_id": 1}

# SELECT LENGTH(name) AS name_length FROM users;
def name_length(row) -> int: return len(row["name"])

name_lengths = users.select(keep_columns=[],
                            additional_columns = {"name_length": name_length})
assert name_lengths[0]['name_length'] == len("Hero")

注意,对于多行“流畅”查询,我们必须将整个查询包装在括号中。

GROUP BY

另一个常见的 SQL 操作是GROUP BY,它将具有指定列中相同值的行分组在一起,并生成诸如MINMAXCOUNTSUM之类的聚合值。

例如,您可能希望找到每个可能的名称长度的用户数和最小的user_id

SELECT LENGTH(name) as name_length,
 MIN(user_id) AS min_user_id,
 COUNT(*) AS num_users
FROM users
GROUP BY LENGTH(name);

我们选择的每个字段都需要在GROUP BY子句(其中name_length是)或聚合计算(min_user_idnum_users是)中。

SQL 还支持一个HAVING子句,其行为类似于WHERE子句,只是其过滤器应用于聚合(而WHERE将在聚合之前过滤行)。

您可能想知道以特定字母开头的用户名的平均朋友数量,但仅查看其对应平均值大于 1 的字母的结果。(是的,这些示例中有些是人为构造的。)

SELECT SUBSTR(name, 1, 1) AS first_letter,
 AVG(num_friends) AS avg_num_friends
FROM users
GROUP BY SUBSTR(name, 1, 1)
HAVING AVG(num_friends) > 1;
注意

不同数据库中用于处理字符串的函数各不相同;一些数据库可能会使用SUBSTRING或其他东西。

您还可以计算整体聚合值。在这种情况下,您可以省略GROUP BY

SELECT SUM(user_id) as user_id_sum
FROM users
WHERE user_id > 1;

要将此功能添加到 NotQuiteABase 的Table中,我们将添加一个group_by方法。它接受您要按组分组的列的名称,您要在每个组上运行的聚合函数的字典,以及一个可选的名为having的谓词,该谓词对多行进行操作。

然后执行以下步骤:

  1. 创建一个defaultdict来将tuple(按分组值)映射到行(包含分组值的行)。请记住,您不能使用列表作为dict的键;您必须使用元组。

  2. 遍历表的行,填充defaultdict

  3. 创建一个具有正确输出列的新表。

  4. 遍历defaultdict并填充输出表,应用having过滤器(如果有)。

    def group_by(self,
                 group_by_columns: List[str],
                 aggregates: Dict[str, Callable],
                 having: HavingClause = lambda group: True) -> 'Table':

        grouped_rows = defaultdict(list)

        # Populate groups
        for row in self.rows:
            key = tuple(row[column] for column in group_by_columns)
            grouped_rows[key].append(row)

        # Result table consists of group_by columns and aggregates
        new_columns = group_by_columns + list(aggregates.keys())
        group_by_types = [self.col2type(col) for col in group_by_columns]
        aggregate_types = [agg.__annotations__['return']
                           for agg in aggregates.values()]
        result_table = Table(new_columns, group_by_types + aggregate_types)

        for key, rows in grouped_rows.items():
            if having(rows):
                new_row = list(key)
                for aggregate_name, aggregate_fn in aggregates.items():
                    new_row.append(aggregate_fn(rows))
                result_table.insert(new_row)

        return result_table
注意

实际的数据库几乎肯定会以更有效的方式执行此操作。

同样,让我们看看如何执行与前面的 SQL 语句等效的操作。name_length指标是:

def min_user_id(rows) -> int:
    return min(row["user_id"] for row in rows)

def length(rows) -> int:
    return len(rows)

stats_by_length = (
    users
    .select(additional_columns={"name_length" : name_length})
    .group_by(group_by_columns=["name_length"],
              aggregates={"min_user_id" : min_user_id,
                          "num_users" : length})
)

first_letter指标是:

def first_letter_of_name(row: Row) -> str:
    return row["name"][0] if row["name"] else ""

def average_num_friends(rows: List[Row]) -> float:
    return sum(row["num_friends"] for row in rows) / len(rows)

def enough_friends(rows: List[Row]) -> bool:
    return average_num_friends(rows) > 1

avg_friends_by_letter = (
    users
    .select(additional_columns={'first_letter' : first_letter_of_name})
    .group_by(group_by_columns=['first_letter'],
              aggregates={"avg_num_friends" : average_num_friends},
              having=enough_friends)
)

user_id_sum是:

def sum_user_ids(rows: List[Row]) -> int:
    return sum(row["user_id"] for row in rows)

user_id_sum = (
    users
    .where(lambda row: row["user_id"] > 1)
    .group_by(group_by_columns=[],
              aggregates={ "user_id_sum" : sum_user_ids })
)

ORDER BY

经常,您可能希望对结果进行排序。例如,您可能希望知道用户的(按字母顺序)前两个名称:

SELECT * FROM users
ORDER BY name
LIMIT 2;

这很容易通过给我们的Table添加一个order_by方法来实现,该方法接受一个order函数来实现:

    def order_by(self, order: Callable[[Row], Any]) -> 'Table':
        new_table = self.select()       # make a copy
        new_table.rows.sort(key=order)
        return new_table

然后我们可以像这样使用它们:

friendliest_letters = (
    avg_friends_by_letter
    .order_by(lambda row: -row["avg_num_friends"])
    .limit(4)
)

SQL 的ORDER BY允许您为每个排序字段指定ASC(升序)或DESC(降序);在这里,我们必须将其嵌入到我们的order函数中。

JOIN

关系型数据库表通常是规范化的,这意味着它们被组织成最小化冗余。例如,当我们在 Python 中处理用户的兴趣时,我们可以为每个用户分配一个包含其兴趣的list

SQL 表通常不能包含列表,所以典型的解决方案是创建第二个表,称为user_interests,包含user_idinterest之间的一对多关系。在 SQL 中,你可以这样做:

CREATE TABLE user_interests (
    user_id INT NOT NULL,
    interest VARCHAR(100) NOT NULL
);

而在 NotQuiteABase 中,你需要创建这样一个表:

user_interests = Table(['user_id', 'interest'], [int, str])
user_interests.insert([0, "SQL"])
user_interests.insert([0, "NoSQL"])
user_interests.insert([2, "SQL"])
user_interests.insert([2, "MySQL"])
注意

仍然存在大量冗余 —— 兴趣“SQL”存储在两个不同的地方。在实际数据库中,您可能会将user_idinterest_id存储在user_interests表中,然后创建第三个表interests,将interest_id映射到interest,这样您只需存储兴趣名称一次。但这会使我们的示例变得比必要的复杂。

当我们的数据分布在不同的表中时,我们如何分析它?通过将表进行JOINJOIN将左表中的行与右表中相应的行组合在一起,其中“相应”的含义基于我们如何指定连接的方式。

例如,要查找对 SQL 感兴趣的用户,你会这样查询:

SELECT users.name
FROM users
JOIN user_interests
ON users.user_id = user_interests.user_id
WHERE user_interests.interest = 'SQL'

JOIN指示,对于users中的每一行,我们应该查看user_id并将该行与包含相同user_iduser_interests中的每一行关联起来。

注意,我们必须指定要JOIN的表和要ON连接的列。这是一个INNER JOIN,它根据指定的连接条件返回匹配的行组合(仅限匹配的行组合)。

还有一种LEFT JOIN,除了匹配行的组合外,还返回每个左表行的未匹配行(在这种情况下,右表应该出现的字段都是NULL)。

使用LEFT JOIN,很容易统计每个用户的兴趣数量:

SELECT users.id, COUNT(user_interests.interest) AS num_interests
FROM users
LEFT JOIN user_interests
ON users.user_id = user_interests.user_id

LEFT JOIN确保没有兴趣的用户仍然在连接数据集中具有行(user_interests字段的值为NULL),而COUNT仅计算非NULL值。

NotQuiteABase 的join实现将更为严格 —— 它仅仅在两个表中存在共同列时进行连接。即便如此,编写起来也不是件简单的事情:

    def join(self, other_table: 'Table', left_join: bool = False) -> 'Table':

        join_on_columns = [c for c in self.columns           # columns in
                           if c in other_table.columns]      # both tables

        additional_columns = [c for c in other_table.columns # columns only
                              if c not in join_on_columns]   # in right table

        # all columns from left table + additional_columns from right table
        new_columns = self.columns + additional_columns
        new_types = self.types + [other_table.col2type(col)
                                  for col in additional_columns]

        join_table = Table(new_columns, new_types)

        for row in self.rows:
            def is_join(other_row):
                return all(other_row[c] == row[c] for c in join_on_columns)

            other_rows = other_table.where(is_join).rows

            # Each other row that matches this one produces a result row.
            for other_row in other_rows:
                join_table.insert([row[c] for c in self.columns] +
                                  [other_row[c] for c in additional_columns])

            # If no rows match and it's a left join, output with Nones.
            if left_join and not other_rows:
                join_table.insert([row[c] for c in self.columns] +
                                  [None for c in additional_columns])

        return join_table

因此,我们可以找到对 SQL 感兴趣的用户:

sql_users = (
    users
    .join(user_interests)
    .where(lambda row: row["interest"] == "SQL")
    .select(keep_columns=["name"])
)

我们可以通过以下方式获得兴趣计数:

def count_interests(rows: List[Row]) -> int:
    """counts how many rows have non-None interests"""
    return len([row for row in rows if row["interest"] is not None])

user_interest_counts = (
    users
    .join(user_interests, left_join=True)
    .group_by(group_by_columns=["user_id"],
              aggregates={"num_interests" : count_interests })
)

在 SQL 中,还有一种RIGHT JOIN,它保留来自右表且没有匹配的行,还有一种FULL OUTER JOIN,它保留来自两个表且没有匹配的行。我们不会实现其中任何一种。

子查询

在 SQL 中,您可以从(和JOIN)查询的结果中SELECT,就像它们是表一样。因此,如果您想找到任何对 SQL 感兴趣的人中最小的user_id,您可以使用子查询。(当然,您也可以使用JOIN执行相同的计算,但这不会说明子查询。)

SELECT MIN(user_id) AS min_user_id FROM
(SELECT user_id FROM user_interests WHERE interest = 'SQL') sql_interests;

鉴于我们设计的 NotQuiteABase 的方式,我们可以免费获得这些功能。(我们的查询结果是实际的表。)

likes_sql_user_ids = (
    user_interests
    .where(lambda row: row["interest"] == "SQL")
    .select(keep_columns=['user_id'])
)

likes_sql_user_ids.group_by(group_by_columns=[],
                            aggregates={ "min_user_id" : min_user_id })

索引

要查找包含特定值(比如name为“Hero”的行),NotQuiteABase 必须检查表中的每一行。如果表中有很多行,这可能需要很长时间。

类似地,我们的join算法非常低效。对于左表中的每一行,它都要检查右表中的每一行是否匹配。对于两个大表来说,这可能永远都需要很长时间。

此外,您经常希望对某些列应用约束。例如,在您的users表中,您可能不希望允许两个不同的用户具有相同的user_id

索引解决了所有这些问题。如果user_interests表上有一个关于user_id的索引,智能的join算法可以直接找到匹配项,而不必扫描整个表。如果users表上有一个关于user_id的“唯一”索引,如果尝试插入重复项,则会收到错误提示。

数据库中的每个表可以有一个或多个索引,这些索引允许您通过关键列快速查找行,在表之间有效地进行连接,并在列或列组合上强制唯一约束。

良好设计和使用索引有点像黑魔法(这取决于具体的数据库有所不同),但是如果您经常进行数据库工作,学习这些知识是值得的。

查询优化

回顾查询以查找所有对 SQL 感兴趣的用户:

SELECT users.name
FROM users
JOIN user_interests
ON users.user_id = user_interests.user_id
WHERE user_interests.interest = 'SQL'

在 NotQuiteABase 中有(至少)两种不同的方法来编写此查询。您可以在执行连接之前过滤user_interests表:

(
    user_interests
    .where(lambda row: row["interest"] == "SQL")
    .join(users)
    .select(["name"])
)

或者您可以过滤连接的结果:

(
    user_interests
    .join(users)
    .where(lambda row: row["interest"] == "SQL")
    .select(["name"])
)

无论哪种方式,最终的结果都是相同的,但是在连接之前过滤几乎肯定更有效,因为在这种情况下,join操作的行数要少得多。

在 SQL 中,您通常不必担心这个问题。您可以“声明”您想要的结果,然后由查询引擎来执行它们(并有效地使用索引)。

NoSQL

数据库的一个最新趋势是向非关系型的“NoSQL”数据库发展,它们不以表格形式表示数据。例如,MongoDB 是一种流行的无模式数据库,其元素是任意复杂的 JSON 文档,而不是行。

有列数据库,它们将数据存储在列中而不是行中(当数据具有许多列但查询只需少数列时很好),键/值存储优化了通过键检索单个(复杂)值的数据库,用于存储和遍历图形的数据库,优化用于跨多个数据中心运行的数据库,专为内存运行而设计的数据库,用于存储时间序列数据的数据库等等。

明天的热门可能甚至现在都不存在,所以我不能做更多的事情,只能告诉您 NoSQL 是一种事物。所以现在您知道了。它是一种事物。

进一步探索

  • 如果你想要下载一个关系型数据库来玩玩,SQLite 快速且小巧,而 MySQLPostgreSQL 则更大且功能丰富。所有这些都是免费的,并且有大量文档支持。

  • 如果你想探索 NoSQL,MongoDB 非常简单入门,这既是一种福音也有点儿“诅咒”。它的文档也相当不错。

  • NoSQL 的维基百科文章几乎可以肯定地包含了在这本书写作时甚至都不存在的数据库链接。

第二十五章:MapReduce

未来已经到来,只是尚未均匀分布。

威廉·吉布森

MapReduce 是一种在大型数据集上执行并行处理的编程模型。虽然它是一种强大的技术,但其基础相对简单。

想象我们有一系列希望以某种方式处理的项目。例如,这些项目可能是网站日志、各种书籍的文本、图像文件或其他任何内容。MapReduce 算法的基本版本包括以下步骤:

  1. 使用mapper函数将每个项转换为零个或多个键/值对。(通常称为map函数,但 Python 已经有一个名为map的函数,我们不需要混淆这两者。)

  2. 收集所有具有相同键的对。

  3. 对每个分组值集合使用reducer函数,以生成相应键的输出值。

注意

MapReduce 已经有些过时了,以至于我考虑从第二版中删除这一章节。但我决定它仍然是一个有趣的主题,所以最终我还是留了下来(显然)。

这些都有点抽象,让我们看一个具体的例子。数据科学中几乎没有绝对规则,但其中一个规则是,您的第一个 MapReduce 示例必须涉及单词计数。

示例:单词计数

DataSciencester 已经发展到数百万用户!这对于您的工作安全来说是好事,但也使得例行分析略微更加困难。

例如,您的内容副总裁想知道人们在其状态更新中谈论的内容。作为第一次尝试,您决定计算出现的单词,以便可以准备一份最频繁出现单词的报告。

当您只有几百个用户时,这样做很简单:

from typing import List
from collections import Counter

def tokenize(document: str) -> List[str]:
    """Just split on whitespace"""
    return document.split()

def word_count_old(documents: List[str]):
    """Word count not using MapReduce"""
    return Counter(word
        for document in documents
        for word in tokenize(document))

当您有数百万用户时,documents(状态更新)的集合突然变得太大,无法放入您的计算机中。如果您能将其适应 MapReduce 模型,您可以使用工程师们实施的一些“大数据”基础设施。

首先,我们需要一个将文档转换为键/值对序列的函数。我们希望输出按单词分组,这意味着键应该是单词。对于每个单词,我们只需发出值1来表示该对应单词的出现次数为一次:

from typing import Iterator, Tuple

def wc_mapper(document: str) -> Iterator[Tuple[str, int]]:
    """For each word in the document, emit (word, 1)"""
    for word in tokenize(document):
        yield (word, 1)

暂时跳过“管道”步骤 2,想象一下对于某个单词,我们已经收集到了我们发出的相应计数列表。为了生成该单词的总计数,我们只需:

from typing import Iterable

def wc_reducer(word: str,
               counts: Iterable[int]) -> Iterator[Tuple[str, int]]:
    """Sum up the counts for a word"""
    yield (word, sum(counts))

回到步骤 2,现在我们需要收集wc_mapper的结果并将其提供给wc_reducer。让我们考虑如何在一台计算机上完成这项工作:

from collections import defaultdict

def word_count(documents: List[str]) -> List[Tuple[str, int]]:
    """Count the words in the input documents using MapReduce"""

    collector = defaultdict(list)  # To store grouped values

    for document in documents:
        for word, count in wc_mapper(document):
            collector[word].append(count)

    return [output
            for word, counts in collector.items()
            for output in wc_reducer(word, counts)]

假设我们有三个文档["data science", "big data", "science fiction"]

然后,将wc_mapper应用于第一个文档,产生两对("data", 1)("science", 1)。在我们处理完所有三个文档之后,collector包含:

{"data" : [1, 1],
 "science" : [1, 1],
 "big" : [1],
 "fiction" : [1]}

然后,wc_reducer生成每个单词的计数:

[("data", 2), ("science", 2), ("big", 1), ("fiction", 1)]

为什么要使用 MapReduce?

正如前面提到的,MapReduce 的主要优势在于它允许我们通过将处理移到数据上来分布计算。假设我们想要跨数十亿文档进行单词计数。

我们最初的(非 MapReduce)方法要求处理文档的机器能够访问每个文档。这意味着所有文档都需要在该机器上存储,或者在处理过程中将其传输到该机器上。更重要的是,这意味着机器一次只能处理一个文档。

注意

如果具有多个核心并且代码已重写以利用它们,可能可以同时处理几个。但即便如此,所有文档仍然必须到达该机器。

现在假设我们的数十亿文档分散在 100 台机器上。有了正确的基础设施(并且忽略某些细节),我们可以执行以下操作:

  • 让每台机器在其文档上运行映射器,生成大量的键/值对。

  • 将这些键/值对分发到多个“减少”机器,确保与任何给定键对应的所有对最终都在同一台机器上。

  • 让每个减少机器按键分组然后对每组值运行减少器。

  • 返回每个(键,输出)对。

这其中令人惊奇的是它的水平扩展能力。如果我们将机器数量翻倍,那么(忽略运行 MapReduce 系统的某些固定成本),我们的计算速度应该大约加快一倍。每台映射器机器只需完成一半的工作量,并且(假设有足够多的不同键来进一步分发减少器的工作)减少器机器也是如此。

更普遍的 MapReduce

如果你仔细想一想,你会发现前面示例中所有与计数特定单词有关的代码都包含在wc_mapperwc_reducer函数中。这意味着只需做出一些更改,我们就可以得到一个更通用的框架(仍然在单台机器上运行)。

我们可以使用通用类型完全类型注释我们的map_reduce函数,但这在教学上可能会有些混乱,因此在本章中,我们对类型注释要更加随意:

from typing import Callable, Iterable, Any, Tuple

# A key/value pair is just a 2-tuple
KV = Tuple[Any, Any]

# A Mapper is a function that returns an Iterable of key/value pairs
Mapper = Callable[..., Iterable[KV]]

# A Reducer is a function that takes a key and an iterable of values
# and returns a key/value pair
Reducer = Callable[[Any, Iterable], KV]

现在我们可以编写一个通用的map_reduce函数:

def map_reduce(inputs: Iterable,
               mapper: Mapper,
               reducer: Reducer) -> List[KV]:
    """Run MapReduce on the inputs using mapper and reducer"""
    collector = defaultdict(list)

    for input in inputs:
        for key, value in mapper(input):
            collector[key].append(value)

    return [output
            for key, values in collector.items()
            for output in reducer(key, values)]

然后,我们可以简单地通过以下方式计算单词数:

word_counts = map_reduce(documents, wc_mapper, wc_reducer)

这使我们能够灵活地解决各种问题。

在继续之前,请注意wc_reducer仅仅是对每个键对应的值求和。这种聚合是如此普遍,以至于值得将其抽象出来:

def values_reducer(values_fn: Callable) -> Reducer:
    """Return a reducer that just applies values_fn to its values"""
    def reduce(key, values: Iterable) -> KV:
        return (key, values_fn(values))

    return reduce

然后我们可以轻松创建:

sum_reducer = values_reducer(sum)
max_reducer = values_reducer(max)
min_reducer = values_reducer(min)
count_distinct_reducer = values_reducer(lambda values: len(set(values)))

assert sum_reducer("key", [1, 2, 3, 3]) == ("key", 9)
assert min_reducer("key", [1, 2, 3, 3]) == ("key", 1)
assert max_reducer("key", [1, 2, 3, 3]) == ("key", 3)
assert count_distinct_reducer("key", [1, 2, 3, 3]) == ("key", 3)

等等。

示例:分析状态更新

内容 VP 对单词计数印象深刻,并询问您可以从人们的状态更新中学到什么其他内容。您设法提取出一个看起来像这样的状态更新数据集:

status_updates = [
    {"id": 2,
     "username" : "joelgrus",
     "text" : "Should I write a second edition of my data science book?",
     "created_at" : datetime.datetime(2018, 2, 21, 11, 47, 0),
     "liked_by" : ["data_guy", "data_gal", "mike"] },
     # ...
]

假设我们需要弄清楚人们在一周中哪一天最常谈论数据科学。为了找到这一点,我们只需计算每周的数据科学更新次数。这意味着我们需要按周几分组,这就是我们的关键。如果我们对每个包含“数据科学”的更新发出值 1,我们可以简单地通过 sum 得到总数:

def data_science_day_mapper(status_update: dict) -> Iterable:
    """Yields (day_of_week, 1) if status_update contains "data science" """
    if "data science" in status_update["text"].lower():
        day_of_week = status_update["created_at"].weekday()
        yield (day_of_week, 1)

data_science_days = map_reduce(status_updates,
                               data_science_day_mapper,
                               sum_reducer)

作为一个稍微复杂的例子,想象一下我们需要找出每个用户在其状态更新中最常用的单词。对于 mapper,有三种可能的方法会脱颖而出:

  • 将用户名放入键中;将单词和计数放入值中。

  • 将单词放入键中;将用户名和计数放入值中。

  • 将用户名和单词放入键中;将计数放入值中。

如果你再仔细考虑一下,我们肯定想要按 username 进行分组,因为我们希望单独考虑每个人的话语。而且我们不想按 word 进行分组,因为我们的减少器需要查看每个人的所有单词以找出哪个最受欢迎。这意味着第一个选项是正确的选择:

def words_per_user_mapper(status_update: dict):
    user = status_update["username"]
    for word in tokenize(status_update["text"]):
        yield (user, (word, 1))

def most_popular_word_reducer(user: str,
                              words_and_counts: Iterable[KV]):
    """
 Given a sequence of (word, count) pairs,
 return the word with the highest total count
 """
    word_counts = Counter()
    for word, count in words_and_counts:
        word_counts[word] += count

    word, count = word_counts.most_common(1)[0]

    yield (user, (word, count))

user_words = map_reduce(status_updates,
                        words_per_user_mapper,
                        most_popular_word_reducer)

或者我们可以找出每个用户的不同状态点赞者数量:

def liker_mapper(status_update: dict):
    user = status_update["username"]
    for liker in status_update["liked_by"]:
        yield (user, liker)

distinct_likers_per_user = map_reduce(status_updates,
                                      liker_mapper,
                                      count_distinct_reducer)

示例:矩阵乘法

从 “矩阵乘法” 回想一下,给定一个 [n, m] 矩阵 A 和一个 [m, k] 矩阵 B,我们可以将它们相乘得到一个 [n, k] 矩阵 C,其中 C 中第 i 行第 j 列的元素由以下给出:

C[i][j] = sum(A[i][x] * B[x][j] for x in range(m))

只要我们像我们一直在做的那样,将我们的矩阵表示为列表的列表,这就起作用了。

但是大矩阵有时是 sparse 的,这意味着它们的大多数元素等于 0。对于大稀疏矩阵,列表列表可能是非常浪费的表示方式。更紧凑的表示法仅存储具有非零值的位置:

from typing import NamedTuple

class Entry(NamedTuple):
    name: str
    i: int
    j: int
    value: float

例如,一个 10 亿 × 10 亿的矩阵有 1 quintillion 个条目,这在计算机上存储起来并不容易。但如果每行只有几个非零条目,这种替代表示法则小得多。

在这种表示法给定的情况下,我们发现可以使用 MapReduce 以分布方式执行矩阵乘法。

为了激励我们的算法,请注意每个元素 A[i][j] 仅用于计算 C 的第 i 行的元素,每个元素 B[i][j] 仅用于计算 C 的第 j 列的元素。我们的目标是使我们的 reducer 的每个输出成为 C 的一个单一条目,这意味着我们的 mapper 需要发出标识 C 的单个条目的键。这建议以下操作:

def matrix_multiply_mapper(num_rows_a: int, num_cols_b: int) -> Mapper:
    # C[x][y] = A[x][0] * B[0][y] + ... + A[x][m] * B[m][y]
    #
    # so an element A[i][j] goes into every C[i][y] with coef B[j][y]
    # and an element B[i][j] goes into every C[x][j] with coef A[x][i]
    def mapper(entry: Entry):
        if entry.name == "A":
            for y in range(num_cols_b):
                key = (entry.i, y)              # which element of C
                value = (entry.j, entry.value)  # which entry in the sum
                yield (key, value)
        else:
            for x in range(num_rows_a):
                key = (x, entry.j)              # which element of C
                value = (entry.i, entry.value)  # which entry in the sum
                yield (key, value)

    return mapper

然后:

def matrix_multiply_reducer(key: Tuple[int, int],
                            indexed_values: Iterable[Tuple[int, int]]):
    results_by_index = defaultdict(list)

    for index, value in indexed_values:
        results_by_index[index].append(value)

    # Multiply the values for positions with two values
    # (one from A, and one from B) and sum them up.
    sumproduct = sum(values[0] * values[1]
                     for values in results_by_index.values()
                     if len(values) == 2)

    if sumproduct != 0.0:
        yield (key, sumproduct)

例如,如果你有这两个矩阵:

A = [[3, 2, 0],
     [0, 0, 0]]

B = [[4, -1, 0],
     [10, 0, 0],
     [0, 0, 0]]

你可以将它们重写为元组:

entries = [Entry("A", 0, 0, 3), Entry("A", 0, 1,  2), Entry("B", 0, 0, 4),
           Entry("B", 0, 1, -1), Entry("B", 1, 0, 10)]

mapper = matrix_multiply_mapper(num_rows_a=2, num_cols_b=3)
reducer = matrix_multiply_reducer

# Product should be [[32, -3, 0], [0, 0, 0]].
# So it should have two entries.
assert (set(map_reduce(entries, mapper, reducer)) ==
        {((0, 1), -3), ((0, 0), 32)})

在如此小的矩阵上这并不是非常有趣,但是如果你有数百万行和数百万列,它可能会帮助你很多。

一则:组合器

你可能已经注意到,我们的许多 mapper 看起来包含了大量额外的信息。例如,在计算单词数量时,我们可以发射 (word, None) 并且只计算长度,而不是发射 (word, 1) 并对值求和。

我们没有这样做的一个原因是,在分布式设置中,有时我们想要使用组合器来减少必须从一台机器传输到另一台机器的数据量。如果我们的某个 mapper 机器看到单词 data 出现了 500 次,我们可以告诉它将 ("data", 1) 的 500 个实例合并成一个 ("data", 500),然后再交给 reducer 处理。这样可以减少传输的数据量,从而使我们的算法速度显著提高。

由于我们写的 reducer 的方式,它将正确处理这些合并的数据。(如果我们使用 len 写的话,就不会。)

深入探讨

  • 正如我所说的,与我写第一版时相比,MapReduce 现在似乎不那么流行了。也许不值得投入大量时间。

  • 尽管如此,最广泛使用的 MapReduce 系统是Hadoop。有各种商业和非商业的发行版,以及一个庞大的与 Hadoop 相关的工具生态系统。

  • Amazon.com 提供了一个Elastic MapReduce服务,可能比自己搭建集群更容易。

  • Hadoop 作业通常具有较高的延迟,这使得它们在“实时”分析方面表现不佳。这些工作负载的流行选择是Spark,它可以像 MapReduce 一样运行。

第二十六章:数据伦理

先吃饭,然后考虑伦理。

贝托尔特·布莱希特

什么是数据伦理?

随着数据的使用,数据的滥用也随之而来。这几乎一直如此,但最近这个想法已经被具体化为“数据伦理”,并在新闻中占据了一定的位置。

例如,在 2016 年的选举中,一家名为剑桥分析公司的公司不当获取了 Facebook 数据,并将其用于政治广告定向投放。

在 2018 年,由 Uber 测试的自动驾驶汽车撞死了一名行人(汽车上有一名“安全驾驶员”,但显然她当时没有注意)。

算法被用来预测罪犯再犯的风险并据此判刑。这比允许法官做出相同判断更公平吗?

一些航空公司给家庭分配单独的座位,强迫他们额外付费才能坐在一起。一个数据科学家应该介入阻止这种情况吗?(链接线程中的许多数据科学家似乎认为应该如此。)

“数据伦理”自称提供了对这些问题的答案,或者至少提供了一个处理这些问题的框架。我并不傲慢到告诉你如何考虑这些事情(而且“这些事情”正在迅速变化),所以在本章中,我们将快速浏览一些最相关的问题,并(希望)激发你进一步思考这些问题。 (遗憾的是,我不是一个足够好的哲学家,无法从零开始进行伦理思考。)

不,真的,什么是数据伦理?

好吧,让我们从“什么是伦理学?”开始。如果你总结每一个你能找到的定义的平均值,你最终会得到类似于伦理学是一个思考“正确”和“错误”行为的框架。数据伦理,因此,是一个思考涉及数据的正确和错误行为的框架。

有些人谈论“数据伦理”似乎是(也许是隐含地)关于你可以做什么和不可以做什么的一套戒律。有些人正在努力创建宣言,其他人正在制定希望你发誓遵守的强制性承诺。还有一些人正在努力让数据伦理成为数据科学课程的强制组成部分——因此本章,作为一种在他们成功的情况下敲定我的赌注的方式。

令人好奇的是,没有太多数据表明伦理课程会导致道德行为,在这种情况下,也许这场运动本身就是数据不道德的表现!

其他人(例如,诚挚地)认为,合理的人经常会在对错的微妙问题上意见分歧,并且数据伦理的重要部分是承诺考虑你的行为的伦理后果。这需要理解许多“数据伦理”倡导者不赞同的事情,但不一定需要同意他们的反对意见。

我应该关心数据伦理吗?

无论你的工作是什么,你都应该关注伦理问题。如果你的工作涉及数据,你可以自由地将你的关心称为“数据伦理”,但你也应该同样关心工作中与数据无关的伦理问题。

或许技术工作不同之处在于技术扩展,个人在解决技术问题时(无论是与数据相关还是其他)做出的决策可能具有潜在的广泛影响。

改动一点点新闻发现算法可能会导致成百上千的人阅读一篇文章,或者没有人阅读它。

一个单一有缺陷的假释算法在全国范围内使用,系统性地影响数百万人,而一个自身存在缺陷的假释委员会只影响到前来面见它的人。

因此,是的,总体而言,你应该关心你的工作对世界的影响。而你的工作影响越广泛,你就越需要担心这些事情。

不幸的是,围绕数据伦理的一些讨论涉及到人们试图把他们的伦理结论强加给你。你是否应该关心他们关心的事情,这确实取决于你自己。

构建糟糕的数据产品

一些“数据伦理”问题源于构建糟糕的产品

例如,微软发布了一个名为 Tay 的聊天机器人,它会复述对它发推特的内容,互联网很快发现这使得他们能让 Tay 发表各种冒犯性的言论。看起来微软没有讨论发布这个“种族主义”机器人的伦理性;很可能他们只是简单地制作了一个机器人,但未能深思其可能被滥用的后果。这可能是一个低门槛,但让我们一致认为你应该考虑你所构建的东西可能如何被滥用。

另一个例子是,Google Photos 曾经使用一个图像识别算法,有时会将黑人的照片分类为“大猩猩”。同样,几乎没有人认为谷歌有明确决定发布这一功能(更不用说在“伦理”方面苦苦挣扎了)。在这里,问题很可能是训练数据的问题,模型的不准确性,以及这个错误的极其冒犯性(如果模型偶尔将邮箱分类为消防车,可能没有人会在意)。

在这种情况下,解决方案不太明显:你如何确保你训练的模型不会做出在某种程度上冒犯性的预测?当然,你应该在各种输入上训练(和测试)你的模型,但你能确保你的模型永远不会出现某种让你感到尴尬的输入吗?这是一个难题。(谷歌似乎通过简单地拒绝预测“大猩猩”来“解决”了这个问题。)

平衡准确性与公平性

想象一下,你正在建立一个模型,预测人们采取某些行动的可能性。你做得相当不错(表格 26-1)。

表格 26-1. 做得相当不错

预测 人们 行动 %
不可能 125 25 20%
可能 125 75 60%

你预测的人中,有 20%的人不太可能采取行动。而你预测的人中,有 60%的人采取了行动。看起来不太糟糕。

现在想象一下,人们可以分为两组:A 和 B。你的一些同事担心你的模型对其中一组是不公平的。虽然模型不考虑组别成员资格,但它确实考虑了与组别成员资格相关的各种以复杂方式相关的其他因素。

实际上,当你按组别分解预测时,你会发现一些令人惊讶的统计数据(表格 26-2)。

表格 26-2. 令人惊讶的统计数据

组别 预测 人们 行动 %
A 不可能 100 20 20%
A 可能 25 15 60%
B 不可能 25 5 20%
B 可能 100 60 60%

你的模型不公平吗?你团队的数据科学家提出了各种论点:

Argument 1

你的模型将 80%的 A 组分类为“不可能”,但将 80%的 B 组分类为“可能”。这位数据科学家抱怨说,模型在某种程度上不公平地对待了两组,因为它在两组之间生成了截然不同的预测。

Argument 2

无论组别成员资格如何,如果我们预测“不可能”,你有 20%的行动机会,如果我们预测“可能”,你有 60%的行动机会。这位数据科学家坚持认为,模型在某种意义上是“准确”的,因为它的预测似乎无论你属于哪个组,都意味着相同的事情。

Argument 3

B 组的 40/125 = 32%被错误标记为“可能”,而 A 组的 10/125 = 8%被错误标记为“可能”。这位数据科学家(认为“可能”预测是一件坏事)坚持认为模型不公平地污名化了 B 组。

Argument 4

20/125 = 16%的 A 组被错误标记为“不可能”,而只有 5/125 = 4%的 B 组被错误标记为“不可能”。这位数据科学家(认为“不可能”预测是一件坏事)坚持认为模型不公平地污名化了 A 组。

这些数据科学家中哪些是正确的?有没有正确的?也许这取决于情境。

可能当两组是“男性”和“女性”时,您的感觉会有所不同;当两组是“R 用户”和“Python 用户”时,您的感觉又会有所不同。或者,如果 Python 用户偏向男性而 R 用户偏向女性,可能也不会有不同的感觉?

如果模型用于预测 DataSciencester 用户是否将通过 DataSciencester 求职板申请工作,您可能会有一种感觉;如果模型用于预测用户是否将通过这样的面试,您可能会有另一种感觉。

可能您的意见取决于模型本身,它考虑了哪些特征以及它训练的数据。

无论如何,我的观点是要向您强调“准确性”和“公平性”之间可能存在权衡(当然,这取决于您如何定义它们),而这些权衡并不总是有明显的“正确”解决方案。

合作

一个压制(按您的标准)国家的政府官员最终决定允许公民加入 DataSciencester。然而,他们坚持要求来自他们国家的用户不得讨论深度学习。此外,他们希望您向他们报告任何试图寻找深度学习信息的用户的姓名,即使他们只是尝试寻找。

这个国家的数据科学家是否更适合访问您将被允许提供的主题限制(并受到监视的)DataSciencester?还是建议的限制如此可怕,以至于他们干脆不访问?

可解释性

DataSciencester HR 部门要求您开发一个模型,预测哪些员工最有可能离开公司,以便他们可以进行干预并试图让他们更快乐。 (离职率是您的 CEO 渴望出现在“10 个最幸福工作场所”杂志特写中的重要组成部分。)

您收集了一系列历史数据,正在考虑三种模型:

  • 决策树

  • 一个神经网络

  • 一个高价“留存专家”

您的一个数据科学家坚持认为您应该使用表现最好的模型。

第二个坚持您不要使用神经网络模型,因为只有其他两个模型能解释它们的预测,而只有预测的解释才能帮助 HR 实施广泛的变革(而不是一次性的干预)。

第三个说,虽然这位“专家”可以对她的预测提供一个解释,但没有理由相信她的解释描述了她预测的真正原因。

和我们的其他例子一样,在这里没有绝对的最佳选择。在某些情况下(可能是出于法律原因或者如果您的预测对生活有重大影响),您可能更喜欢一个性能较差但可以解释其预测的模型。在其他情况下,您可能只想要预测最好的模型。在另一些情况下,也许没有一个可解释的模型表现良好。

推荐

正如我们在第二十三章中讨论的那样,一个常见的数据科学应用涉及向人们推荐事物。当有人观看 YouTube 视频时,YouTube 会推荐他们接下来应该观看的视频。

YouTube 通过广告赚钱,(据推测)希望推荐您更有可能观看的视频,以便它们可以向您展示更多广告。然而,事实证明,人们喜欢观看关于阴谋论的视频,这些视频往往出现在推荐中。

在我写这一章时,如果您在 YouTube 上搜索“saturn”,第三个结果是“Something Is Happening On Saturn… Are THEY Hiding It?”这也许可以让您感受到我所说的那些视频的类型。

YouTube 是否有义务不推荐阴谋视频?即使这是许多人似乎想观看的内容?

另一个例子是,如果您转到 google.com(或 bing.com)并开始输入搜索内容,搜索引擎会提供自动完成您搜索的建议。这些建议基于其他人的搜索(至少部分地);特别是,如果其他人正在搜索不良内容,这可能会反映在您的建议中。

搜索引擎是否应该积极过滤掉它不喜欢的建议?谷歌(出于某种原因)似乎坚决不提供与人们宗教有关的建议。例如,如果在 Bing 中键入“mitt romney m”,第一个建议是“mitt romney mormon”(这是我预料中的),而谷歌拒绝提供这样的建议。

实际上,谷歌明确过滤掉它认为是“冒犯性或贬低性”的自动建议。(它如何决定什么是冒犯性或贬低性是模糊的。)但有时候真相就是冒犯的。保护人们免受这些建议的影响是道德行为吗?还是不道德的行为?或者根本不是道德问题?

偏见数据

在“词向量”中,我们使用了一组文档语料库来学习单词的向量嵌入。这些向量被设计为展示分布式相似性。也就是说,出现在相似上下文中的词语应该具有相似的向量。特别是,训练数据中存在的任何偏见都将反映在词向量本身中。

例如,如果我们的文档都是关于 R 用户是道德败类,Python 用户是美德典范,那么模型很可能会学习到“Python”和“R”的这种关联。

更常见的情况是,词向量基于一些组合:Google 新闻文章、维基百科、书籍和爬取的网页。这意味着它们将学习到这些来源中存在的任何分布模式。

例如,如果关于软件工程师的大多数新闻文章都是关于男性软件工程师,那么“软件”的学习向量可能更接近于其他“男性”词语的向量,而不是“女性”词语的向量。

在那一点上,您使用这些向量构建的任何下游应用程序可能也会表现出这种紧密性。根据应用程序的不同,这可能是个问题,也可能不是。在这种情况下,您可以尝试各种技术来“消除”特定的偏见,尽管您可能永远无法消除所有偏见。但这是您应该注意的问题。

同样,就像在“构建糟糕的数据产品”中的“照片”示例一样,如果您在非代表性数据上训练模型,那么它很可能会在真实世界中表现不佳,可能会以冒犯或令人尴尬的方式表现出来。

另一方面,您的算法可能也会使实际世界中存在的实际偏见被编码。例如,您的假释模型可能完美地预测哪些释放的罪犯会再次被逮捕,但如果这些再次逮捕本身是有偏见的现实世界过程的结果,那么您的模型可能会延续这种偏见。

数据保护

您了解 DataSciencester 用户的很多信息。您知道他们喜欢什么技术,他们的数据科学家朋友是谁,他们在哪工作,他们赚多少钱,他们在网站上花费多少时间,他们点击哪些职位发布等等。

赚钱副总裁想将这些数据卖给广告商,他们渴望向您的用户营销各种“大数据”解决方案。首席科学家想将这些数据与学术研究人员分享,他们热衷于发表关于如何成为数据科学家的论文。竞选副总裁计划将这些数据提供给政治竞选活动,他们中的大多数人渴望招募自己的数据科学组织。政府事务副总裁希望使用这些数据来回答执法部门的问题。

由于一位有远见的合同副总裁,您的用户同意了服务条款,几乎允许您对他们的数据做任何想做的事情。

然而(正如您现在预料到的),您团队中的各个数据科学家对这些各种用途提出了各种异议。有人认为将数据交给广告商是错误的;另一些人担心学术界不能信任地负责保护数据。第三个人认为公司应该远离政治,而最后一个人坚持认为警方不可信任,与执法部门合作将伤害无辜人群。

这些数据科学家中有人有道理吗?

总结一下

这些都是很多需要担心的事情!而且我们还没有提到的无数其他问题,还会有更多未来会出现但今天我们无法想象的问题。

进一步探索

  • 在谈论数据伦理的重要思想的人并不少。在 Twitter(或者你最喜欢的新闻网站)上搜索可能是了解当前最新数据伦理争议的最佳方式。

  • 如果你想要更实际一点的东西,Mike Loukides、Hilary Mason 和 DJ Patil 编写了一本短篇电子书,数据科学与伦理,讲述了如何将数据伦理付诸实践,因为 Mike 在 2014 年同意出版《从零开始的数据科学》,我觉得有义务推荐这本书。(练习:这样做对吗?)

第二十七章:继续并进行数据科学

现在,再次,我命令我的可怕后代前往繁荣昌盛。

玛丽·雪莱

从这里开始,你要去哪里?假设我没有吓跑你对数据科学的兴趣,有几件事你应该学习。

IPython

我在书中早些时候提到了IPython。它提供了比标准 Python shell 功能更强大的 shell,并且增加了“魔术函数”,允许你(除了其他功能)轻松复制粘贴代码(这通常会受到空白行和空格格式的组合的复杂性的限制)并且从 shell 内运行脚本。

掌握 IPython 会让你的生活更轻松。(即使学习一点点 IPython 也会让你的生活变得更加轻松。)

注意

在第一版中,我还建议你了解 IPython(现在是 Jupyter)Notebook,这是一个计算环境,允许你结合文本、实时 Python 代码和可视化。

我自从成为笔记本怀疑论者以后,发现它们让初学者困惑并且鼓励了不良的编程实践。(我还有很多其他原因。)你肯定会从其他人那里得到使用它们的很多鼓励,但请记住,我是不同意见的声音。

数学

在本书中,我们涉足了线性代数(第四章)、统计学(第五章)、概率(第六章)和机器学习的各个方面。

要成为一名优秀的数据科学家,你应该更深入地了解这些主题,我鼓励你使用章节末推荐的教科书,你自己喜欢的教科书,在线课程,甚至是现实中的课程来进行更深入的学习。

非从头开始

从头实现事物对于理解它们如何工作是很棒的。但通常不适合性能(除非你专门以性能为目标实现它们)、易用性、快速原型设计或错误处理。

在实践中,你会想要使用设计良好、扎实实现基础功能的库。我的原始书稿提到了书的第二部分“现在让我们学习这些库”,但幸运的是,O'Reilly 否决了这一提议。自第一版发布以来,Jake VanderPlas 写了Python 数据科学手册(O'Reilly),它是相关库的良好入门读物,也是你接下来可以阅读的好书。

NumPy

NumPy(“Numeric Python”)提供了进行“真实”科学计算所需的设施。它提供比我们的list向量更优越的数组,比我们的list-of-list矩阵性能更好的矩阵,并且提供了大量用于处理它们的数值函数。

NumPy 是许多其他库的基础模块,这使得它特别有价值。

pandas

pandas为在 Python 中处理数据集提供了额外的数据结构。它的主要抽象是DataFrame,在概念上类似于我们在第二十四章中构建的 NotQuiteABase Table类,但功能更强大,性能更好。

如果你要使用 Python 来处理、切片、分组和操作数据集,pandas 是一个非常宝贵的工具。

scikit-learn

scikit-learn可能是 Python 中最流行的机器学习库。它包含了我们已经实现的所有模型,以及许多我们尚未涉及的模型。在真实问题中,你不会从头开始构建一个决策树;你会让 scikit-learn 来处理这些重活。在真实问题中,你不会手动编写一个优化算法;你会依赖于 scikit-learn 已经使用的优秀算法。

其文档包含许多例子,展示了它的功能(更广义地说,展示了机器学习的能力)。

可视化

我们创建的 matplotlib 图表干净实用,但不特别时尚(而且根本不具有交互性)。如果你想深入数据可视化,你有几个选择。

第一步是进一步探索 matplotlib,我们实际上仅涵盖了其中少数功能。它的网站包含许多例子展示其功能以及一些更有趣的画廊。如果你想创建静态可视化(比如说,为了在书中打印),这可能是你的最佳下一步。

你还应该查看seaborn,这是一个使 matplotlib 更具吸引力的库(除其他外)。

如果你想创建交互式可视化,并可以在 Web 上分享,显然选择D3.js是一个明智的选择,这是一个用于创建“数据驱动文档”的 JavaScript 库(这就是三个 D)。即使你对 JavaScript 不甚了解,通常也可以从D3 画廊中借鉴例子,并调整其适应你的数据。(优秀的数据科学家从 D3 画廊中借鉴;伟大的数据科学家从 D3 画廊中窃取。)

即使你对 D3 没有兴趣,浏览其画廊本身也是数据可视化教育的一个相当令人难以置信的途径。

Bokeh是一个将 D3 风格功能引入 Python 的项目。

R

虽然你完全可以不学习R,但许多数据科学家和数据科学项目都在使用它,所以值得至少熟悉一下。

部分原因在于你可以理解人们基于 R 的博客文章、示例和代码;部分原因在于帮助你更好地欣赏(相对)干净优雅的 Python;还有部分原因在于帮助你成为永无止境的“R vs Python”战争中更有见地的参与者。

深度学习

你可以成为一名数据科学家而不进行深度学习,但如果你想成为时髦的数据科学家,则必须进行深度学习。

Python 中最流行的两个深度学习框架是 TensorFlow(由谷歌创建)和 PyTorch(由 Facebook 创建)。网络上有很多关于它们的教程,从优秀到糟糕不等。

TensorFlow 是更老、更广泛使用的,但 PyTorch(依我看来)更易于使用,特别是对初学者更友好。我更喜欢(并推荐)PyTorch,但正如人们所说,选择 TensorFlow 也不会有人因此而被解雇。

寻找数据

如果你的工作涉及数据科学,那么很可能会作为工作的一部分获取数据(尽管不一定)。如果你只是出于兴趣从事数据科学呢?数据无处不在,但以下是一些起点:

  • Data.gov 是政府的开放数据门户。如果你想获取与政府有关的任何数据(这似乎是当今大多数事情的情况),这是一个很好的起点。

  • Reddit 有几个论坛,r/datasetsr/data,这些地方可以提问和发现数据。

  • Amazon.com 维护了一个 公共数据集 集合,他们希望你使用他们的产品分析(但你可以用任何你想用的产品进行分析)。

  • Robb Seaton 在他的博客上有一个精选的数据集列表 链接

  • Kaggle 是一个举办数据科学竞赛的网站。我从未真正参与其中(在涉及数据科学时我没有太多竞争性的天性),但你可能会有兴趣。它托管了许多数据集。

  • 谷歌推出了一个新的 Dataset Search,让你可以(你猜对了)搜索数据集。

做数据科学

浏览数据目录是可以的,但最好的项目(和产品)是那些解决了某些问题的项目。这里有几个我做过的项目。

Hacker News

Hacker News 是一个技术相关新闻的新闻聚合和讨论网站。它收集了大量文章,其中许多对我来说并不有趣。

因此,几年前,我着手建立了一个 Hacker News 故事分类器,以预测我是否对任何给定的故事感兴趣。这在 Hacker News 的用户中并不受欢迎,他们反感这种可能会对网站上的每个故事都不感兴趣的想法。

这涉及手动标记很多故事(为了有一个训练集),选择故事特征(例如标题中的单词和链接的域),并训练一个朴素贝叶斯分类器,类似于我们的垃圾邮件过滤器。

出于当时已经不记得的原因,我用 Ruby 构建了它。从我的错误中吸取教训。

消防车

多年来,我住在西雅图市中心的一条主要街道上,距离一个消防站和大部分城市的火灾都有一段距离(或者看起来是这样)。因此,我对西雅图消防局产生了一种娱乐兴趣。

幸运的是(从数据的角度来看),他们维护一个实时 911 网站,列出了每次火警以及涉及的消防车辆。

为了追求我的兴趣,我爬取了多年的火警数据,并对消防车进行了社交网络分析。其中,我需要发明一个专门针对消防车的中心性概念,我称之为 TruckRank。

T 恤衫

我有一个年幼的女儿,她整个童年期间令我极度沮丧的是,大多数“女孩衬衫”都相当无聊,而许多“男孩衬衫”则非常有趣。

特别是,我觉得在市场上针对幼儿男孩和女孩的衬衫之间有明显的区别。因此,我自问是否能训练一个模型来识别这些差异。

剧透:我可以

这涉及下载数百件衬衫的图片,将它们全部缩小到相同的尺寸,将它们转换为像素颜色向量,并使用逻辑回归构建分类器。

一种方法简单地查看每件衬衫中存在的颜色;第二种方法找到了衬衫图像向量的前 10 个主成分,并使用它们在“特征衬衫”所张成的 10 维空间中的投影对每件衬衫进行分类(图 27-1)。

第一个主成分对应的特征衬衫。

图 27-1. 第一个主成分对应的特征衬衫

地球仪上的推文

多年来,我一直想建立一个“旋转地球仪”可视化。在 2016 年选举期间,我制作了一个小型网络应用,监听匹配某些搜索条件(当时我使用了“Trump”,因为那时候有很多相关的推文),并显示这些推文,随着它们出现在地球仪上的位置而旋转。

这完全是一个 JavaScript 数据项目,所以也许要学一些 JavaScript。

你呢?

你感兴趣的是什么?什么问题让你彻夜难眠?寻找一个数据集(或者爬取一些网站)并进行一些数据科学分析。

告诉我你发现了什么!通过电子邮件联系我 joelgrus@gmail.com 或在 Twitter 上找到我 @joelgrus

posted @ 2024-06-17 17:11  绝不原创的飞龙  阅读(14)  评论(0编辑  收藏  举报