从零开始的数据科学第二版-全-
从零开始的数据科学第二版(全)
原文:
zh.annas-archive.org/md5/48ab308fc34189a6d7d26b91b72a6df9
译者:飞龙
第二版序言
我对《从零开始的数据科学》第一版感到异常自豪。它确实成为了我想要的那本书。但是,在数据科学的几年发展中,在 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 中有所展示。
图 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)。
图 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")
]
找到最受欢迎的兴趣的一种简单(如果不是特别令人兴奋)的方法是计算单词数:
-
将每个兴趣都转换为小写(因为不同用户可能会或不会将其兴趣大写)。
-
将其分割成单词。
-
计算结果。
在代码中:
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 会引发一个异常。如果不加处理,异常会导致程序崩溃。你可以使用try
和except
来处理它们:
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
它们在处理 list
或 dict
甚至是你自己的函数时也很有用:
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
虽然更多时我们会使用 for
和 in
:
# range(10) is the numbers 0, 1, ..., 9
for x in range(10):
print(f"{x} is less than 10")
如果你需要更复杂的逻辑,可以使用 continue
和 break
:
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)
这将打印0
、1
、2
和4
。
真值
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"
可迭代对象和生成器
列表的一个好处是你可以通过它们的索引检索特定元素。但你并不总是需要这样做!一个包含十亿个数字的列表会占用大量内存。如果你只想逐个获取元素,那么没有必要将它们全部保存下来。如果你最终只需要前几个元素,生成整个十亿个元素是极其浪费的。
通常我们只需要使用 for
和 in
迭代集合。在这种情况下,我们可以创建生成器,它们可以像列表一样被迭代,但是在需要时会惰性地生成它们的值。
创建生成器的一种方式是使用函数和 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)
这样的“生成器推导式”在你迭代它(使用for
或next
)之前不会执行任何操作。我们可以利用这一点构建复杂的数据处理流水线:
# 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 函数 partial
、map
、reduce
和 filter
。在我追求真理的过程中,我意识到最好避免使用这些函数,并且书中它们的用法已被列表推导式、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"
作为一般规则,如果你明确说明函数需要什么样的参数,你的代码将更加正确和可读;因此,只有在没有其他选择时我们才会使用 args
和 kwargs
。
类型注解
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
参数可以是string
、int
、float
或bool
。很可能这个函数很脆弱且难以使用,但是当类型明确时,它变得更加清晰。这样做将迫使我们以更少的笨拙方式进行设计,用户会因此受益。 -
使用类型允许您的编辑器帮助您完成诸如自动完成(图 2-1)之类的事情,并且可以在类型错误时发出警告。
图 2-1. VSCode,但可能您的编辑器也是如此。
有时候人们坚持认为类型提示可能对大型项目有价值,但对于小型项目来说不值得花费时间。然而,由于类型提示几乎不需要额外的输入时间,并且允许您的编辑器节省时间,因此我认为它们实际上可以让您更快地编写代码,即使是对于小型项目也是如此。
出于所有这些原因,本书其余部分的所有代码都将使用类型注释。我预计一些读者可能对使用类型注释感到不满意;然而,我认为到本书结束时,他们的想法会改变。
如何编写类型注释
正如我们所见,对于像 int
、bool
和 float
这样的内置类型,您只需使用类型本身作为注释。如果您有(比如)一个 list
呢?
def total(xs: list) -> float:
return sum(total)
这不是错误,但类型不够具体。显然我们真正想要的是 xs
是 floats
的 list
,而不是(比如)字符串的 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()
图 3-4. 具有误导性 y 轴的图表
在 图 3-5 中,我们使用更合理的轴,效果看起来不那么令人印象深刻:
plt.axis([2016.5, 2018.5, 0, 550])
plt.title("Not So Huge Anymore")
plt.show()
图 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
只是 float
的 list
:
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
不是向量(因此不提供向量算术的工具),我们需要自己构建这些算术工具。所以让我们从这里开始。
首先,我们经常需要添加两个向量。向量是分量相加的。这意味着如果两个向量v
和w
长度相同,则它们的和就是一个向量,其第一个元素是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,则点积测量向量v
在w
方向延伸的距离。例如,如果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
我们现在已经有了计算两个向量之间距离的所有要素,定义为:
在代码中:
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 列”。
给定这个列表的列表表示,矩阵A
有len(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
,使得如果节点 i
和 j
连接,则 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]
对于一个小图,你可以简单地为每个节点对象添加一个连接列表以加速这个过程;但对于一个大型、不断演化的图,这可能会太昂贵和难以维护。
我们将在本书中多次涉及矩阵。
进一步探索
-
线性代数被数据科学家广泛使用(通常是隐式地使用,并且经常被不理解它的人使用)。阅读一本教材并不是一个坏主意。你可以在网上找到几本免费的教材:
-
如果你使用 NumPy,那么我们在本章中构建的所有机制都是免费的。(你还会得到更多,包括更好的性能。)
第五章:统计学
事实是倔强的,但统计数据更加灵活。
马克·吐温
统计学是指我们理解数据的数学和技术。这是一个丰富而庞大的领域,更适合放在图书馆的书架(或房间)上,而不是书中的一章,因此我们的讨论必然不会很深入。相反,我会尽量教你足够的知识,让你有点危险,并激起你的兴趣,让你去学更多的东西。
描述单一数据集
通过口口相传和一点运气,DataSciencester 已经发展成了数十个成员,筹款副总裁要求你提供一些关于你的成员拥有多少朋友的描述,以便他可以在提出自己的想法时加入其中。
使用第一章中的技术,你很容易就能产生这些数据。但现在你面临着如何描述它的问题。
任何数据集的一个明显描述就是数据本身:
num_friends = [100, 49, 41, 40, 25,
# ... and lots more
]
对于足够小的数据集,这甚至可能是最好的描述。但对于较大的数据集来说,这是笨重且可能晦涩的。(想象一下盯着一百万个数字的列表。)因此,我们使用统计数据来提炼和传达我们数据的相关特征。
作为第一步,你可以使用 Counter
和 plt.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
当max
和min
相等时,范围恰好为零,这只可能发生在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) ** 2
是x_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
求和了对应元素对的乘积。当x
和y
的对应元素都高于它们的均值或者都低于它们的均值时,一个正数进入总和。当一个高于其均值而另一个低于其均值时,一个负数进入总和。因此,“大”正协方差意味着x
在y
大时也大,在y
小时也小。 “大”负协方差意味着相反的情况,即x
在y
大时较小,在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]
那么x
和y
的相关性为零。但它们肯定有一种关系——y
的每个元素等于x
对应元素的绝对值。它们没有的是一种关系,即知道x_i
与mean(x)
的比较信息无法提供有关y_i
与mean(y)
的信息。这是相关性寻找的关系类型。
此外,相关性并不能告诉你关系的大小。以下变量:
x = [-2, -1, 0, 1, 2]
y = [99.98, 99.99, 100, 100.01, 100.02]
完全相关,但(根据你测量的内容)很可能这种关系并不那么有趣。
相关性与因果关系
你可能曾经听说过“相关不等于因果”,很可能是从某人那里听来的,他看到的数据挑战了他不愿质疑的世界观的某些部分。尽管如此,这是一个重要的观点——如果x
和y
强相关,这可能意味着x
导致y
,y
导致x
,彼此相互导致,第三个因素导致两者,或者根本不导致。
考虑num_friends
和daily_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[对用户进行实验],你可以随机选择你的一部分用户,并仅向他们展示部分朋友的内容。如果这部分用户随后在网站上花费的时间较少,那么这将使你相信拥有更多朋友 _ 会导致更多的时间花费在该网站上。
进一步探索
-
SciPy,pandas,和StatsModels都提供各种各样的统计函数。
-
统计学是重要的。(或许统计是重要的?)如果你想成为更好的数据科学家,阅读统计学教材是个好主意。许多在线免费教材可供选择,包括:
-
Introductory Statistics,由道格拉斯·莎弗和张志义(Saylor Foundation)编写。
-
OnlineStatBook,由大卫·莱恩(莱斯大学)编写。
-
Introductory Statistics,由 OpenStax(OpenStax College)编写。
-
第六章:概率
概率定律,在一般情况下正确,在特定情况下谬误。
爱德华·吉本
没有对概率及其数学的某种理解,数据科学是很难做的。就像我们在第五章中处理统计学一样,我们将在很多地方简化和省略技术细节。
对于我们的目的,你应该把概率看作是对从某个事件宇宙中选择的事件的不确定性进行量化的一种方式。与其深究这些术语的技术含义,不如想象掷骰子。事件宇宙包含所有可能的结果。而这些结果的任何子集就是一个事件;例如,“骰子掷出 1”或“骰子掷出偶数”。
在符号上,我们写 P(E) 来表示“事件 E 发生的概率”。
我们将使用概率理论来建立模型。我们将使用概率理论来评估模型。我们将在很多地方使用概率理论。
如果愿意,可以深入探讨概率论的哲学含义。(最好在喝啤酒时进行。)我们不会做这件事。
依赖性与独立性
简而言之,如果知道 E 发生与否能够提供 F 发生与否的信息(反之亦然),我们称事件 E 和 F 是依赖的。否则,它们是独立的。
例如,如果我们抛一枚公平的硬币两次,知道第一次抛硬币是正面并不能提供关于第二次抛硬币是否正面的信息。这两个事件是独立的。另一方面,知道第一次抛硬币是正面肯定会影响到第二次抛硬币是否都是反面。(如果第一次抛硬币是正面,那么肯定不会是两次抛硬币都是反面。)这两个事件是依赖的。
数学上,我们说事件 E 和 F 是独立的,如果它们同时发生的概率等于它们各自发生的概率的乘积:
在例子中,“第一次抛硬币为正面”的概率是 1/2,“两次抛硬币都为反面”的概率是 1/4,但“第一次抛硬币为正面且两次抛硬币都为反面”的概率是 0。
条件概率
当两个事件 E 和 F 是独立的时候,根据定义我们有:
如果它们不一定是独立的(如果 F 的概率不为零),那么我们定义 E 在给定 F 的条件下的概率为:
你应该把这看作是在我们知道 F 发生的情况下,E 发生的概率。
我们经常将其重写为:
当 E 和 F 是独立的时候,你可以检查这是否成立:
这是数学上表达,即知道 F 发生了并不能给我们关于 E 是否发生额外的信息。
一个常见的棘手例子涉及一对(未知的)孩子的家庭。如果我们假设:
-
每个孩子都同等可能是男孩或女孩。
-
第二个孩子的性别与第一个孩子的性别是独立的。
那么事件“没有女孩”的概率为 1/4,事件“一个女孩一个男孩”的概率为 1/2,事件“两个女孩”的概率为 1/4。
现在我们可以问事件“两个孩子都是女孩”(B)在事件“老大是女孩”(G)条件下的概率是多少?使用条件概率的定义:
因为事件B和G(“两个孩子都是女孩且老大是女孩”)就是事件B。(一旦知道两个孩子都是女孩,老大是女孩就是必然的。)
大多数情况下,这个结果符合您的直觉。
我们还可以询问事件“两个孩子都是女孩”在事件“至少一个孩子是女孩”(L)条件下的概率。令人惊讶的是,答案与之前不同!
与之前一样,事件B和L(“两个孩子都是女孩且至少一个孩子是女孩”)就是事件B。这意味着我们有:
这是怎么回事?嗯,如果您所知道的是至少有一个孩子是女孩,那么这个家庭有一个男孩和一个女孩的可能性是有两倍多于两个女孩的可能性的。
我们可以通过“生成”许多家庭来验证这一点:
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的概率信息。使用条件概率的定义两次告诉我们:
事件F可以分为两个互斥事件:“F和E”以及“F和非E”。如果我们用表示“非E”(即“E不发生”),那么:
所以:
这通常是贝叶斯定理的陈述方式。
这个定理经常被用来展示为什么数据科学家比医生更聪明。想象一种影响每 10,000 人中 1 人的特定疾病。再想象一种测试这种疾病的方法,它在 99%的情况下给出正确结果(如果您有疾病则为“患病”,如果您没有则为“未患病”)。
正测试意味着什么?让我们用T表示“您的测试呈阳性”事件,D表示“您患有疾病”事件。那么贝叶斯定理表明,在测试呈阳性的条件下,您患有疾病的概率是:
在这里我们知道,,即染病者测试阳性的概率,为 0.99。P(D),即任何给定人患病的概率,为 1/10,000 = 0.0001。 ,即没有患病者测试阳性的概率,为 0.01。而 ,即任何给定人没有患病的概率,为 0.9999。如果你把这些数字代入贝叶斯定理,你会发现:
也就是说,只有不到 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,那么在x和x + 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
图 6-1. 均匀分布的累积分布函数
正态分布
正态分布是经典的钟形曲线分布,完全由两个参数确定:其均值μ(mu)和标准差σ(sigma)。均值表示钟形曲线的中心位置,标准差表示其“宽度”。
它的 PDF 是:
我们可以这样实现它:
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()
图 6-2. 各种正态分布的 PDF
当μ = 0 且σ = 1 时,称为标准正态分布。如果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()
图 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。
中心极限定理
正态分布如此有用的一个原因是中心极限定理,它(本质上)表明大量独立同分布的随机变量的平均值本身近似服从正态分布。
特别是,如果是均值为μ,标准差为σ的随机变量,并且n很大,则
大致上符合正态分布,均值为μ,标准差为 。同样(但通常更有用),
大致上符合均值为 0,标准差为 1 的正态分布。
一个易于说明这一点的方法是看二项式随机变量,它具有两个参数n和p。一个二项式(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,标准差是 。 中心极限定理表明,当 n 变大时,二项分布(Binomial(n,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 中的图表。
图 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”或“人们更有可能在看不见关闭按钮的烦人插页广告出现后,不阅读内容直接离开页面”。这些可以转化为关于数据的统计信息。在各种假设下,这些统计信息可以被视为来自已知分布的随机变量的观察结果,这使我们能够对这些假设的可能性进行陈述。
在经典设置中,我们有一个零假设, ,代表某些默认位置,以及我们想要与之比较的一些备选假设, 。我们使用统计数据来决定我们是否可以拒绝 作为虚假的。这可能通过一个例子更容易理解。
示例:抛硬币
想象我们有一枚硬币,我们想测试它是否公平。我们假设硬币有一定概率 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)
我们需要就显著性做出决策——我们愿意做第一类错误的程度,即拒绝即使它是真的。由于历史记载已经遗失,这种愿意通常设定为 5%或 1%。让我们选择 5%。
考虑到如果X落在以下界限之外则拒绝的测试:
# (469, 531)
lower_bound, upper_bound = normal_two_sided_bounds(0.95, mu_0, sigma_0)
假设p真的等于 0.5(即为真),我们只有 5%的机会观察到一个落在这个区间之外的X,这正是我们想要的显著性水平。换句话说,如果为真,那么大约有 20 次中有 19 次这个测试会给出正确的结果。
我们还经常关注测试的功效,即不犯第二类错误(“假阴性”)的概率,即我们未能拒绝,尽管它是错误的。为了衡量这一点,我们必须明确假设为假的含义。 (仅知道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
假设相反,我们的零假设是硬币不偏向正面,或者。在这种情况下,我们需要一个单边测试,当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(如果为真的话,这几乎不太可能发生)时,它不再拒绝,而是在X在 526 到 531 之间时拒绝(如果为真的话,这有一定可能发生)。
p-值
对前述测试的另一种思考方式涉及p 值。我们不再基于某个概率截断选择边界,而是计算概率——假设为真——我们会看到一个至少与我们实际观察到的值一样极端的值的概率。
对于我们的双边测试,检验硬币是否公平,我们计算:
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 是更好的广告。但如果差异不那么明显呢?这就是你会使用统计推断的地方。
假设人看到广告 A,并且其中人点击了它。我们可以将每次广告浏览视为伯努利试验,其中是某人点击广告 A 的概率。那么(如果很大,这里是这样),我们知道大致上是一个均值为,标准差为的正态随机变量。
同样,大致上是一个均值为,标准差为。我们可以用代码表示这个过程:
def estimated_parameters(N: int, n: int) -> Tuple[float, float]:
p = n / N
sigma = math.sqrt(p * (1 - p) / N)
return p, sigma
如果我们假设这两个正态分布是独立的(这似乎是合理的,因为单个伯努利试验应该是独立的),那么它们的差异也应该是均值为,标准差为。
注意
这有点作弊。只有当您知道标准偏差时,数学才能完全工作。在这里,我们是从数据中估计它们,这意味着我们确实应该使用t分布。但是对于足够大的数据集,它接近标准正态分布,所以差别不大。
这意味着我们可以测试零假设,即和相同(即为 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)
而且alpha
和beta
越大,分布就越“紧密”。
例如,如果alpha
和beta
都为 1,那就是均匀分布(以 0.5 为中心,非常分散)。如果alpha
远大于beta
,大部分权重都集中在 1 附近。如果alpha
远小于beta
,大部分权重都集中在 0 附近。图 7-1 展示了几种不同的 Beta 分布。
图 7-1。示例 Beta 分布
假设我们对p有一个先验分布。也许我们不想对硬币是否公平发表立场,我们选择alpha
和beta
都等于 1。或者我们非常相信硬币 55%的时间会正面朝上,我们选择alpha
等于 55,beta
等于 45。
然后我们多次抛硬币,看到h次正面和t次反面。贝叶斯定理(以及一些在这里过于繁琐的数学)告诉我们p的后验分布再次是 Beta 分布,但参数为alpha + h
和beta + 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
趋近于零时。
(许多想要学习微积分的人都被极限的数学定义所困扰,这是美丽的但可能看起来有些令人生畏的。在这里,我们将作弊并简单地说,“极限”意味着你认为的意思。)
导数是切线在 处的斜率,而差商是穿过 的不完全切线的斜率。随着 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
的长度为 n,estimate_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)]
在这种情况下,我们知道线性关系的参数x
和y
,但想象一下我们希望从数据中学习这些参数。我们将使用梯度下降来找到最小化平均平方误差的斜率和截距。
我们将以一个基于单个数据点误差的梯度确定梯度的函数开始:
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 * x
与x
的符号相同。确实,如果x
为正,那么斜率的小增加会再次使预测(因此误差)变大。但是如果x
为负,斜率的小增加会使预测(因此误差)变小。
现在,这个计算是针对单个数据点的。对于整个数据集,我们将看均方误差。均方误差的梯度就是单个梯度的均值。
所以,我们要做的是:
-
从一个随机值
theta
开始。 -
计算梯度的均值。
-
在那个方向上调整
theta
。 -
重复。
经过许多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
可以是任何一种类型的列表——str
、int
、list
等等——但无论是哪种类型,输出都将是它们的批次。
现在我们可以再次使用小批量来解决我们的问题:
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.stdin
和sys.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>
在一个完美的世界中,所有网页都会被语义化地标记,为了我们的利益。我们将能够使用诸如“查找id
为subject
的<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-releases
和media-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
有关 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 社区的一部分)。
警告
像所有与我无法控制的网站相关的说明一样,这些说明可能在某个时候过时,但希望能够一段时间内工作。(尽管自我最初开始写这本书以来,它们已经多次发生变化,所以祝你好运!)
以下是步骤:
-
如果你没有登录,点击“登录”并输入你的 Twitter 用户名和密码。
-
点击申请以申请开发者帐户。
-
为你自己的个人使用请求访问。
-
填写申请。 它需要 300 字(真的)解释为什么你需要访问,所以为了超过限制,你可以告诉他们关于这本书以及你有多么喜欢它。
-
等待一段不确定的时间。
-
如果你认识在 Twitter 工作的人,请给他们发电子邮件,询问他们是否可以加快你的申请。 否则,继续等待。
-
一旦你获得批准,返回到 developer.twitter.com,找到“Apps”部分,然后点击“创建应用程序”。
-
填写所有必填字段(同样,如果你需要描述的额外字符,你可以谈论这本书以及你发现它多么有启发性)。
-
点击创建。
现在你的应用程序应该有一个“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_TOKEN
和ACCESS_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 & 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
来存储推文。相反,你可能想把它们保存到文件或数据库中,这样你就能永久地拥有它们。
进一步探索
第十章:处理数据
专家往往拥有比判断更多的数据。
科林·鲍威尔
处理数据既是一门艺术,也是一门科学。我们主要讨论科学部分,但在本章中我们将探讨一些艺术方面。
探索您的数据
在确定您要回答的问题并获取到数据之后,您可能会有冲动立即开始构建模型和获取答案。但您应该抑制这种冲动。您的第一步应该是 探索 您的数据。
探索一维数据
最简单的情况是您有一个一维数据集,它只是一组数字。例如,这些可能是每个用户每天在您的网站上花费的平均分钟数,一系列数据科学教程视频的观看次数,或者您的数据科学图书馆中每本数据科学书籍的页数。
显而易见的第一步是计算一些摘要统计信息。您想知道有多少数据点,最小值,最大值,均值和标准差。
但即使这些也不一定能给您带来很好的理解。一个很好的下一步是创建直方图,将数据分组为离散的 桶 并计算落入每个桶中的点数:
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 图。均匀分布直方图
在这种情况下,两个分布的 max
和 min
相差很大,但即使知道这一点也不足以理解它们的 差异。
两个维度
现在想象一下,您有一个具有两个维度的数据集。除了每天的分钟数,您可能还有数据科学经验的年数。当然,您希望单独了解每个维度。但您可能还想散点数据。
例如,考虑另一个虚构数据集:
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]
如果你对 ys1
和 ys2
运行 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()
第 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
表示数据的一种常见方式是使用dict
s:
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
一样,namedtuple
s 是不可变的,这意味着一旦创建就无法修改它们的值。偶尔这会成为我们的障碍,但大多数情况下这是件好事。
你会注意到我们还没有解决类型注解的问题。我们可以通过使用类型化的变体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
的一种(某种程度上)可变版本。(我说“某种程度上”,因为NamedTuple
s 将它们的数据紧凑地表示为元组,而 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,但你可能会在野外遇到它们。
清洁和操纵
现实世界的数据脏。通常你需要在使用之前对其进行一些处理。我们在第九章中看到了这方面的例子。在使用之前,我们必须将字符串转换为float
或int
。我们必须检查缺失值、异常值和错误数据。
以前,我们在使用数据之前就这样做了:
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 的最高收盘价。让我们将这个问题分解成具体的步骤:
-
限制自己只看 AAPL 的行。
-
从每行中获取
closing_price
。 -
获取那些价格的最大值。
我们可以一次完成所有三个任务使用推导:
max_aapl_price = max(stock_price.closing_price
for stock_price in data
if stock_price.symbol == "AAPL")
更一般地,我们可能想知道数据集中每支股票的最高收盘价。做到这一点的一种方法是:
-
创建一个
dict
来跟踪最高价格(我们将使用一个defaultdict
,对于缺失值返回负无穷大,因为任何价格都将大于它)。 -
迭代我们的数据,更新它。
这是代码:
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
,这意味着我们需要一种将今天价格和昨天价格关联起来的方法。一种方法是按符号分组价格,然后在每个组内:
-
按日期排序价格。
-
使用
zip
获取(前一个,当前)对。 -
将这些对转换为新的“百分比变化”行。
让我们从按符号分组的价格开始:
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。这实际上消除了单位,将每个维度转换为“均值的标准偏差数”。
首先,我们需要计算每个位置的mean
和standard_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 显示了去均值后的示例数据。
图 10-7. 去均值后的数据
现在,给定一个去均值的矩阵 X,我们可以问哪个方向捕捉了数据中的最大方差。
具体来说,给定一个方向 d
(一个大小为 1 的向量),矩阵中的每一行 x
在 d
方向上延伸 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)。
图 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-最近邻
如果你想要惹恼你的邻居,就告诉他们关于他们的真相。
皮耶特罗·阿雷蒂诺
想象一下,你试图预测我在下次总统选举中的投票方式。如果你对我一无所知(并且如果你有数据的话),一个明智的方法是看看我的邻居打算如何投票。像我一样住在西雅图,我的邻居们无一例外地计划投票给民主党候选人,这表明“民主党候选人”对我来说也是一个不错的猜测。
现在想象一下,你不仅了解我的地理位置,还知道我的年龄、收入、有几个孩子等等。在我行为受这些因素影响(或者说特征化)的程度上,只看那些在所有这些维度中与我接近的邻居,似乎比看所有邻居更有可能是一个更好的预测器。这就是最近邻分类背后的思想。
模型
最近邻是最简单的预测模型之一。它不做任何数学假设,也不需要任何重型设备。它唯一需要的是:
-
某种距离的概念
-
一个假设是相互接近的点是相似的。
在本书中,我们将看到的大多数技术都是整体看待数据集以便学习数据中的模式。而最近邻则故意忽略了很多信息,因为对于每个新点的预测仅依赖于最接近它的少数几个点。
此外,最近邻可能不会帮助你理解你正在研究的现象的驱动因素。根据我的邻居的投票预测我的投票并不能告诉你关于我为什么投票的原因,而一些基于(比如)我的收入和婚姻状况预测我的投票的替代模型可能会很好地做到这一点。
在一般情况下,我们有一些数据点和相应的标签集合。标签可以是True
和False
,表示每个输入是否满足某些条件,比如“是垃圾邮件?”或“有毒?”或“看起来有趣吗?”或者它们可以是类别,比如电影评级(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)。
图 12-4。一维空间中的 50 个随机点
如果你在单位正方形中随机选择 50 个点,你将得到更少的覆盖(参见图 12-5)。
图 12-5。二维空间中的 50 个随机点
在三维空间中,覆盖更少(参见图 12-6)。
matplotlib 对于四维图表的呈现并不好,所以这是我们所能达到的最远的地方,但你已经可以看到开始出现大量空白区域,没有点靠近它们。在更多维度中——除非你得到指数级更多的数据——这些大量空白区域代表了远离所有你想要用于预测的点的区域。
因此,如果你尝试在更高维度中使用最近邻方法,最好先进行某种降维处理。
图 12-6。三维空间中的 50 个随机点
进一步探索
scikit-learn 有许多最近邻模型。
第十三章:贝叶斯朴素分类
为心灵保持天真,为思想保持成熟。
阿纳托尔·法朗士
如果人们不能进行社交,那么社交网络就没什么用了。因此,DataSciencester 拥有一个受欢迎的功能,允许会员发送消息给其他会员。虽然大多数会员是负责任的公民,只发送受欢迎的“最近好吗?”消息,但一些不法分子坚持不懈地向其他成员发送关于致富计划、无需处方的药物和盈利数据科学证书项目的垃圾邮件。您的用户已经开始抱怨,因此消息副总裁要求您使用数据科学找出一种过滤这些垃圾邮件的方法。
一个非常愚蠢的垃圾邮件过滤器
假设一个“宇宙”,由从所有可能消息中随机选择的消息组成。设S为事件“消息是垃圾邮件”,B为事件“消息包含词bitcoin”。贝叶斯定理告诉我们,包含词bitcoin的消息是垃圾邮件的条件概率是:
分子是消息是垃圾邮件且包含bitcoin的概率,而分母只是消息包含bitcoin的概率。因此,您可以将这个计算视为简单地表示为垃圾邮件的bitcoin消息的比例。
如果我们有大量的已知是垃圾邮件的消息和大量的已知不是垃圾邮件的消息,那么我们可以很容易地估计P(B|S)和P(B|¬S)。如果我们进一步假设任何消息都有相等的可能性是垃圾邮件或不是垃圾邮件(所以P(S) = P(¬S) = 0.5),那么:
例如,如果垃圾邮件中有 50%的消息包含bitcoin这个词,而非垃圾邮件中只有 1%的消息包含,那么包含bitcoin的任意邮件是垃圾邮件的概率是:
一个更复杂的垃圾邮件过滤器
现在想象我们有一个包含许多词w[1] ... w[n]的词汇表。为了将其转化为概率理论的领域,我们将X[i]写成事件“消息包含词w[i]”。还想象一下(通过某种未指定的过程),我们已经得出了一个估计值P(X[i]|S),表示垃圾邮件消息包含第i个词的概率,以及类似的估计P(X[i]|¬S),表示非垃圾邮件消息包含第i个词的概率。
贝叶斯方法的关键在于做出(大胆的)假设,即每个单词的存在(或不存在)在消息是垃圾邮件与否的条件下是独立的。直观地说,这个假设意味着知道某个垃圾邮件消息是否包含词bitcoin并不会告诉你同一消息是否包含词rolex。用数学术语来说,这意味着:
这是一个极端的假设。(这个技术名称中带有天真也是有原因的。)假设我们的词汇表仅仅包括比特币和劳力士这两个词,而一半的垃圾邮件是关于“赚取比特币”,另一半是关于“正品劳力士”。在这种情况下,朴素贝叶斯估计一封垃圾邮件包含比特币和劳力士的概率是:
因为我们假设了比特币和劳力士实际上从不会同时出现的知识。尽管这种假设的不现实性,这种模型通常表现良好,并且在实际的垃圾邮件过滤器中历史悠久地使用。
我们用于“仅比特币”垃圾邮件过滤器的相同贝叶斯定理推理告诉我们可以使用以下方程来计算一封邮件是垃圾邮件的概率:
朴素贝叶斯假设允许我们通过简单地将每个词汇单词的概率估计相乘来计算右侧的每个概率。
实际操作中,通常要避免将大量概率相乘,以防止下溢问题,即计算机无法处理太接近 0 的浮点数。从代数中记得 和 ,我们通常将 计算为等效的(但更友好于浮点数的)形式:
唯一剩下的挑战是估计 和 ,即垃圾邮件(或非垃圾邮件)包含单词 的概率。如果我们有大量标记为垃圾邮件和非垃圾邮件的“训练”邮件,一个明显的第一次尝试是简单地估计 为仅仅是包含单词 的垃圾邮件的比例。
不过,这会造成一个很大的问题。想象一下,在我们的训练集中,词汇表中的单词data只出现在非垃圾邮件中。然后我们会估计。结果是,我们的朴素贝叶斯分类器将始终为包含单词data的任何消息分配垃圾邮件概率 0,即使是像“免费比特币和正品劳力士手表数据”这样的消息也是如此。为了避免这个问题,我们通常使用某种平滑技术。
特别是,我们将选择一个伪计数——k——并估计在垃圾邮件中看到第i个单词的概率为:
我们对也是类似的。也就是说,当计算第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_messages
和ham_messages
计数。然后我们对每个消息文本进行标记化,对于每个标记,根据消息类型递增token_spam_counts
或token_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)
文件的位置可能会更改(这在本书的第一版和第二版之间发生过),如果是这样,请相应调整脚本。
下载数据后,您应该有三个文件夹:spam,easy_ham和hard_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])
最垃圾的词包括像sale,mortgage,money和rates这样的词,而最不垃圾的词包括像spambayes,users,apt和perl这样的词。因此,这也让我们对我们的模型基本做出正确的判断有了一些直观的信心。
我们如何才能获得更好的性能?一个明显的方法是获得更多的训练数据。还有很多改进模型的方法。以下是您可以尝试的一些可能性:
-
查看消息内容,而不仅仅是主题行。您将需要小心处理消息头。
-
我们的分类器考虑了训练集中出现的每个单词,甚至是仅出现一次的单词。修改分类器以接受可选的
min_count
阈值,并忽略不至少出现这么多次的标记。 -
分词器没有类似词汇的概念(例如 cheap 和 cheapest)。修改分类器以接受可选的
stemmer
函数,将单词转换为 等价类 的单词。例如,一个非常简单的词干提取函数可以是:def drop_final_s(word): return re.sub("s$", "", word)
创建一个良好的词干提取函数很难。人们经常使用 Porter stemmer。
-
尽管我们的特征都是形如“消息包含词 ”,但这并非必须如此。在我们的实现中,我们可以通过创建虚假标记,如 contains:number,并在适当时修改
tokenizer
以发出它们来添加额外的特征,比如“消息包含数字”。
进一步探索
-
保罗·格雷厄姆的文章 “一个垃圾邮件过滤计划” 和 “更好的贝叶斯过滤” 非常有趣,并深入探讨了构建垃圾邮件过滤器背后的思想。
-
scikit-learn 包含一个
BernoulliNB
模型,实现了我们这里实现的相同朴素贝叶斯算法,以及模型的其他变体。
第十四章:简单线性回归
艺术,如道德,就在于在某处划出界限。
G. K. Chesterton
在第五章中,我们使用correlation
函数来衡量两个变量之间线性关系的强度。对于大多数应用程序,知道存在这样一个线性关系是不够的。我们需要理解关系的本质。这就是我们将使用简单线性回归的地方。
模型
回想一下,我们正在研究 DataSciencester 用户的朋友数量和每天在网站上花费的时间之间的关系。假设您已经确信,拥有更多朋友导致人们在网站上花费更多时间,而不是我们讨论过的其他解释之一。
参与用户参与部长要求您建立描述这种关系的模型。由于您找到了一个相当强的线性关系,线性模型是一个自然的起点。
特别是,您假设存在常数α(alpha)和β(beta),使得:
其中是用户i每天在网站上花费的分钟数,是用户i的朋友数,ε是一个(希望很小的)误差项,表示这个简单模型未考虑到的其他因素。
假设我们已经确定了这样的alpha
和beta
,那么我们可以简单地进行预测:
def predict(alpha: float, beta: float, x_i: float) -> float:
return beta * x_i + alpha
如何选择alpha
和beta
?嗯,任何alpha
和beta
的选择都会给我们每个输入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
尽可能小的alpha
和beta
。
使用微积分(或繁琐的代数),最小化误差的alpha
和beta
由以下公式给出:
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)
。如果x
和y
完全正相关,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
记住,我们选择了使预测误差平方和最小化的alpha
和beta
。我们可以选择一个线性模型“始终预测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
如果您运行此操作,您将得到与我们使用精确公式相同的alpha
和beta
值。
最大似然估计
为什么选择最小二乘法?其中一个理由涉及最大似然估计。想象一下,我们有来自依赖于某些未知参数θ(theta)的分布的数据样本:
如果我们不知道θ,我们可以反过来将这个数量视为给定样本的θ的似然:
在这种方法下,最可能的θ是能够最大化这个似然函数的值——即使得观察数据最有可能出现的值。对于连续分布的情况,我们有一个概率分布函数而不是概率质量函数,我们也可以做同样的事情。
回到回归。关于简单回归模型经常做的一个假设是回归误差服从均值为 0、某个(已知)标准差σ的正态分布。如果是这种情况,那么基于观察到一对(x_i, y_i)
的似然是:
基于整个数据集的似然是每个个体似然的乘积,在alpha
和beta
被选择以最小化平方误差时最大。也就是说,在这种情况下(在这些假设下),最小化平方误差等价于最大化观察数据的似然。
进一步探索
继续阅读关于多元回归的内容在第十五章!
第十五章:多元回归
我不会看着问题并在里面加入不影响它的变量。
比尔·帕塞尔
虽然副总统对你的预测模型印象深刻,但她认为你可以做得更好。因此,你收集了额外的数据:你知道每个用户每天工作的小时数,以及他们是否拥有博士学位。你希望利用这些额外数据来改进你的模型。
因此,你假设一个包含更多独立变量的线性模型:
显然,用户是否拥有博士学位不是一个数字——但是,正如我们在第十一章中提到的,我们可以引入一个虚拟变量,对于拥有博士学位的用户设为 1,没有的设为 0,之后它与其他变量一样是数值化的。
模型
回想一下,在第十四章中,我们拟合了一个形式为:
现在想象每个输入不是单个数字,而是一个包含k个数字的向量, 。多元回归模型假设:
在多元回归中,参数向量通常称为β。我们希望这个向量包括常数项,可以通过在数据中添加一列 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.
换句话说,假设“实际”模型如下:
其中是负数,而且工作时间和朋友数量是正相关的。在这种情况下,当我们最小化单变量模型的误差时:
我们会低估 。
想象一下,如果我们使用单变量模型并使用“实际”值 进行预测会发生什么。(也就是说,这个值是通过最小化我们称之为“实际”模型的误差得到的。)预测值会倾向于对工作时间较长的用户过大,并且对工作时间较少的用户也稍微偏大,因为 而我们“忘记”将其包含在内。由于工作时间与朋友数量呈正相关,这意味着对于朋友较多的用户,预测值往往过大,而对于朋友较少的用户,则稍微过大。
这样做的结果是,我们可以通过减少对的估计来减少(单变量模型中的)误差,这意味着误差最小化的小于“实际”值。也就是说,在这种情况下,单变量最小二乘解法会倾向于低估。而且,通常情况下,每当自变量与这些误差相关联时,我们的最小二乘解法都会给我们一个偏倚的估计。
拟合模型
就像我们在简单线性模型中所做的那样,我们会选择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
在实践中,你不会使用梯度下降法来估计线性回归;你会使用超出本书范围的线性代数技术来得到精确的系数。如果你这样做,你会得到如下方程:
这与我们找到的结果非常接近。
解释模型
模型中的系数代表每个因素的其他条件相等估计影响的总和。其他条件相等时,每增加一个朋友,每天会多花一分钟在网站上。其他条件相等时,用户工作日每增加一个小时,每天会少花约两分钟在网站上。其他条件相等时,拥有博士学位与每天在网站上多花一分钟相关联。
这并没有(直接)告诉我们关于变量之间互动的任何信息。有可能工作时间对于朋友多的人和朋友少的人有不同的影响。这个模型没有捕捉到这一点。处理这种情况的一种方法是引入一个新变量,即“朋友”和“工作时间”的乘积。这实际上允许“工作时间”系数随着朋友数量的增加而增加(或减少)。
或者可能是,你有更多的朋友,你在网站上花的时间就越多直到一个点,之后进一步的朋友导致你在网站上花费的时间减少。(也许有太多的朋友经验就太压倒性了?)我们可以尝试通过添加另一个变量,即朋友数量的平方,来捕捉这一点在我们的模型中。
一旦我们开始添加变量,就需要担心它们的系数是否“重要”。我们可以无限制地添加乘积、对数、平方和更高次方。
拟合优度
再次我们可以看一下 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。最佳的多重回归模型将至少有一个与该模型一样小的误差。
因此,在多重回归中,我们还需要看看系数的标准误差,这些标准误差衡量我们对每个的估计有多确定。整体回归可能非常适合我们的数据,但如果一些自变量相关(或不相关),它们的系数可能意义不大。
测量这些误差的典型方法始于另一个假设——误差是独立的正态随机变量,均值为 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
,我们可能会得到更好的估计,但我们没有那么多时间。)
我们可以使用这些来测试假设,比如“是否等于 0?”在零假设(以及我们对分布的其他假设)下,统计量:
其中,我们对的估计除以其标准误差的估计值,遵循“ 自由度”的学生 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”的系数很可能是随机的,而不是有意义的。
在更复杂的回归场景中,有时您可能希望对数据进行更复杂的假设检验,例如“至少一个非零”或“等于 和 等于。” 您可以使用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
显而易见的第一次尝试是使用线性回归找到最佳模型:
图 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)
稍后我们将利用这一点。我们将使用这个来拟合一个模型:
其中f是logistic
函数。
回想一下,对于线性回归,我们通过最小化平方误差来拟合模型,这最终选择了最大化数据的似然的β。
在这里两者并不等价,因此我们将使用梯度下降直接最大化似然。这意味着我们需要计算似然函数及其梯度。
给定一些β,我们的模型表明每个应该等于 1 的概率为,等于 0 的概率为。
特别地,的概率密度函数可以写成:
因为如果为 0,则此等于:
如果为 1,则它等于:
结果表明,最大化对数似然实际上更简单:
因为对数是一个严格递增的函数,任何使对数似然最大化的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]
这些是rescale
d 数据的系数,但我们也可以将它们转换回原始数据:
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()
图 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. 数据集在更高维度中变得可分离
这通常被称为核技巧,因为我们不是实际将点映射到更高维度的空间(如果有很多点并且映射很复杂的话,这可能会很昂贵),而是可以使用一个“核”函数来计算在更高维度空间中的点积,并使用这些来找到超平面。
使用支持向量机而不依赖于由具有适当专业知识的人编写的专业优化软件是困难的(也可能不是一个好主意),因此我们将不得不在这里结束我们的讨论。
进一步调查
第十七章:决策树
树是一个难以理解的神秘。
Jim Woodring
DataSciencester 的人才副总裁已经面试了一些来自该网站的求职者,结果各不相同。他收集了一个数据集,其中包含每个候选人的几个(定性)属性,以及该候选人是否面试表现良好或不佳。他问道,你能否利用这些数据建立一个模型,识别哪些候选人会面试表现良好,这样他就不必浪费时间进行面试了?
这似乎非常适合决策树,这是数据科学家工具包中的另一种预测建模工具。
什么是决策树?
决策树使用树结构来表示多条可能的决策路径和每条路径的结果。
如果你玩过Twenty Questions游戏,那么你就熟悉决策树了。例如:
-
“我在想一个动物。”
-
“它有五条腿以上吗?”
-
“不。”
-
“它好吃吗?”
-
“不。”
-
“它出现在澳大利亚五分硬币的背面吗?”
-
“是的。”
-
“它是针鼹吗?”
-
“是的,就是它!”
这对应于路径:
“不超过 5 条腿” → “不好吃” → “在 5 分硬币上” → “针鼹!”
在一个古怪(并不是很全面的)“猜动物”决策树中(Figure 17-1)。
图 17-1. “猜动物”决策树
决策树有很多优点。它们非常容易理解和解释,它们达到预测的过程完全透明。与我们迄今所看到的其他模型不同,决策树可以轻松处理数值型(例如,腿的数量)和分类型(例如,好吃/不好吃)属性的混合数据,甚至可以对缺少属性的数据进行分类。
与此同时,为一组训练数据找到一个“最优”决策树在计算上是一个非常困难的问题。(我们将通过尝试构建一个足够好的树来避开这个问题,尽管对于大型数据集来说,这仍然可能是一项艰巨的工作。)更重要的是,很容易(也很糟糕)构建过度拟合训练数据的决策树,这些树在未见数据上的泛化能力很差。我们将探讨解决这个问题的方法。
大多数人将决策树分为分类树(生成分类输出)和回归树(生成数值输出)。在本章中,我们将专注于分类树,并通过 ID3 算法从一组标记数据中学习决策树,这将帮助我们理解决策树的实际工作原理。为了简化问题,我们将局限于具有二元输出的问题,例如“我应该雇佣这位候选人吗?”或“我应该向这位网站访客展示广告 A 还是广告 B?”或“我在办公室冰箱里找到的这种食物会让我生病吗?”
熵
要构建决策树,我们需要决定提出什么问题以及顺序。在树的每个阶段,我们消除了一些可能性,还有一些没有。学到动物的腿不超过五条后,我们排除了它是蚱蜢的可能性。我们还没排除它是一只鸭子的可能性。每个可能的问题根据其答案将剩余的可能性进行分区。
理想情况下,我们希望选择的问题答案能够提供关于我们的树应该预测什么的大量信息。如果有一个单一的是/否问题,“是”答案总是对应于 True
输出,而“否”答案对应于 False
输出(反之亦然),那这将是一个很棒的问题选择。相反,如果一个是/否问题的任何答案都不能给出关于预测应该是什么的新信息,那可能不是一个好选择。
我们用熵来捕捉这种“信息量”概念。你可能听说过这个术语用来表示无序。我们用它来表示与数据相关的不确定性。
想象我们有一个数据集 S,其中每个成员都被标记为属于有限数量的类别 中的一种。如果所有数据点属于同一类,则没有真正的不确定性,这意味着我们希望熵很低。如果数据点均匀分布在各个类别中,就会有很多不确定性,我们希望熵很高。
从数学角度来看,如果 是标记为类别 的数据的比例,我们定义熵如下:
按照(标准)惯例, 。
不必过分担心可怕的细节,每个术语 都是非负的,当 接近于 0 或接近于 1 时,它接近于 0(图 17-2)。
图 17-2. -p log p 的图示
这意味着当每个
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· .NET Core 托管堆内存泄露/CPU异常的常见思路
· PostgreSQL 和 SQL Server 在统计信息维护中的关键差异
· C++代码改造为UTF-8编码问题的总结
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库