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

52 阅读44分钟

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

原文: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 类型注解和类型检查的一切,甚至超出了你想知道的范围。