MIT-6-0001-Python-计算和编程入门第三版-一-
MIT 6.0001:Python 计算和编程入门第三版(一)
原文:
zh.z-lib.gs/md5/b81f9400901fb07c6e4e456605c4cd1f
译者:飞龙
前言
本书基于自 2006 年起在 MIT 开设的课程,并自 2012 年起通过 edX 和 MITx 作为“大规模在线开放课程”(MOOCs)提供。第一版基于一个学期的课程。然而,随着时间的推移,我无法抗拒添加超出一个学期内容的材料。目前版本适合两学期或三学季的计算机科学导论课程。
本书面向 1) 希望理解计算方法解决问题的初学者,几乎没有或没有编程经验,2) 想学习如何利用计算来建模或探索数据的更有经验的程序员。
我们强调广度而非深度。目标是为读者提供对多个主题的简要介绍,以便他们在思考如何利用计算来实现目标时,能了解可能性。也就是说,这不是一本“计算欣赏”书籍。它具有挑战性和严谨性。希望真正学习这些材料的读者需要花费大量时间和精力,让计算机顺应他们的意志。
本书的主要目标是帮助读者熟练地有效使用计算技术。他们应该学会利用计算思维来框定问题,建立计算模型,并指导从数据中提取信息的过程。他们从本书中获取的主要知识是计算问题解决的艺术。
我们选择不在章节末尾附上问题,而是在章节中适时插入“手指练习”。有些相当简短,旨在让读者确认他们理解了刚读过的材料。有些则更具挑战性,适合考试问题。而其他一些则足够有挑战性,适合作为家庭作业。
第 1-13 章包含通常在计算机科学导论课程中包含的材料,但呈现方式并不传统。我们将四个领域的材料交织在一起:
编程基础,
Python 3 编程语言,
计算问题解决技术,以及
计算复杂性。
我们涵盖了 Python 的大多数特性,但重点在于人们可以用编程语言做什么,而不是语言本身。例如,到第三章结束时,书中只涵盖了 Python 的一小部分,但已经介绍了穷举枚举、猜测与检查算法、二分查找和高效*似算法的概念。我们在整本书中引入了 Python 的特性。同样,我们在整本书中引入了编程方法的各个方面。我们的想法是帮助读者在使用计算解决有趣问题的过程中学习 Python 并成为优秀的程序员。这些章节已被修订,以更温和的方式进行,并比本书第二版的相应章节包含更多的练习。
第十三章介绍了在 Python 中绘图。这一主题在入门课程中通常不涉及,但我们认为,学习如何制作信息可视化是一个重要技能,应纳入入门计算机科学课程。这一章包括第二版未涵盖的内容。
第 14-26 章讨论了如何使用计算帮助理解现实世界。它们涵盖了我们认为应成为计算机科学课程的常规第二门课程的材料。这些章节不假设有超出高中代数的数学知识,但假设读者对严谨的思维感到舒适,并且不被数学概念所吓倒。
本书的这一部分专门讨论在大多数入门教材中找不到的主题:数据可视化与分析、随机程序、仿真模型、概率与统计思维,以及机器学习。我们认为,这些内容对于大多数学生来说,远比通常在第二门计算机科学课程中涵盖的材料更为相关。除了第二十三章,本部分的内容侧重于概念问题,而非编程。第二十三章是对 Pandas 的介绍,这是早期版本未涉及的主题。
本书有三个贯穿的主题:系统问题解决、抽象的力量以及计算作为思考世界的一种方式。完成本书后,你应该能够:
学习了一种语言,Python,用于表达计算,
学会了系统化的方法来组织、编写和调试中等规模的程序,
对计算复杂性有了非正式的理解,
对于如何从模糊的问题陈述转变为计算方法的形成,获得了一些见解,
学到一套有用的算法和问题简化技巧,
学会了如何使用随机性和模拟来阐明那些不易用封闭形式解决的问题,和
学会使用计算工具(包括简单的统计、可视化和机器学习工具)来建模和理解数据。
编程是一项本质上困难的活动。正如“没有通往几何的捷径”, ¹ 编程也没有捷径。如果你真的想学习这些材料,仅仅阅读书籍是不够的。至少你应该完成一些涉及编码的指法练习。如果你愿意尝试更有挑战性的任务,可以尝试一些可通过
`[`ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-0001-introduction-to-computer-science-and-programming-in-python-fall-2016/`](https://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-0001-introduction-to-computer-science-and-programming-in-python-fall-2016/)`
和
`[`ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-0002-introduction-to-computational-thinking-and-data-science-fall-2016/`](https://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-0002-introduction-to-computational-thinking-and-data-science-fall-2016/).`
致谢
本书的第一版源于我在麻省理工学院教授本科课程时准备的一套讲义。这个课程,因此本书,受益于来自教职员工(特别是Ana Bell、Eric Grimson、Srinivas Devadas、Fredo Durand、Ron Rivest和Chris Terman)的建议、助教和选修该课程的学生们的反馈。David Guttag克服了他对计算机科学的厌恶,并校对了第一版的多个章节。
像所有成功的教授一样,我非常感谢我的研究生们。除了进行出色的研究(并让我为此获得一些功劳)外,Guha Balakrishnan、Davis Blalock、Joel Brooks、Ganeshapillai Gartheeban、Jen Gong、Katie Lewis、Yun Liu、Jose Javier Gonzalez Ortiz、Anima Singh、Divya Shanmugam、Jenna Wiens和Amy Zhao都对本手稿的不同版本提供了有用的意见。
我特别感谢Julie Sussman(P.P.A.),她编辑了本书的前两版,以及Lisa Ruffolo,她编辑了本版。Julie和Lisa都是以学生的视角阅读本书的合作者,告诉我需要做什么、应该做什么以及如果我有时间和精力可以做什么。他们给我提供了太多不容忽视的“建议”。
最后,感谢我的妻子奥尔加,感谢她鼓励我完成这本书,并免除我承担各种家务,以便我能够专心写作。
第一章:开始
计算机只做两件事,而且仅仅这两件事:执行计算并记住计算结果。但它在这两方面做得极其出色。典型的桌面或背包里的计算机每秒可以执行大约 1000 亿次计算。很难想象这是多么快。想象一下把一个球放在离地面一米高的地方,然后放手。在它落地的那一刻,你的计算机可能已经执行了超过十亿条指令。至于内存,小型计算机可能有数百 GB 的存储。那有多大?如果一个字节(表示一个字符所需的位数,通常是八个)重一克(实际上并不是),100GB 将重达 10 万吨。相比之下,这大约是 16000 头非洲大象的总重量。²
在人类历史的大部分时间里,计算受到人脑计算速度和人手记录计算结果的能力的限制。这意味着只有最小的问题可以通过计算方法解决。即使在现代计算机的速度下,一些问题仍然超出了现代计算模型(例如,全面理解气候变化),但越来越多的问题证明可以通过计算解决。我们希望,当你完成本书时,能够自如地将计算思维应用于解决你在学习、工作乃至日常生活中遇到的许多问题。
我们所说的计算思维是什么?
所有知识都可以被视为陈述性知识或命令性知识。陈述性知识由事实陈述组成。例如,“x
的*方根是一个数字y
,使得y*y = x
,”以及“可以通过火车从巴黎到罗马旅行。”这些都是事实陈述。不幸的是,它们并没有告诉我们如何找到*方根或如何从巴黎乘火车到罗马。
命令性知识是“如何”知识,或推导信息的食谱。亚历山大的希罗尼斯是第一个³记录计算*方根的方法的人。他找到*方根的步骤,可以总结为:
1. 从一个猜测
g
开始。2. 如果
g*g
足够接*x
,则停止并说g
是答案。3. 否则,通过*均
g
和x/g
来创建一个新的猜测,即(g + x/g)/2
。4. 使用这个新的猜测,我们再次称之为 g,重复这个过程,直到
g*g
足够接*x
。
考虑找到25
的*方根。
1. 将
g
设为某个任意值,例如3
。2. 我们决定
3*3 = 9
不够接*25
。3. 将
g
设为(3 + 25/3)/2 = 5.67
。⁴4. 我们决定
5.67*5.67 = 32.15
仍然没有足够接*25
。5. 将
g
设为(5.67 + 25/5.67)/2 = 5.04
6. 我们决定
5.04*5.04 = 25.4
足够接*,所以我们停止并宣布5.04
是25
的*方根的足够*似值。
注意,该方法的描述是一系列简单步骤,以及指定何时执行每个步骤的控制流。这种描述称为算法。⁵ 我们用来*似*方根的算法是猜测和检查算法的一个例子。
更正式地说,算法是一系列有限的指令,描述了一组计算,当应用于一组输入时,将按照一系列明确定义的状态序列进行,并最终产生一个输出。
算法就像食谱书中的配方:
1. 将奶油混合物加热。
2. 搅拌。
3. 将勺子浸入奶油中。
4. 取出勺子并在勺背上划过手指。
5. 如果留下清晰的路径,将奶油从热源上取下并让其冷却。
6. 否则重复。
配方包括一些测试,用于确定过程何时完成,以及关于执行顺序的指示,有时基于测试跳转到特定指令。
那么如何将食谱的概念转化为机械过程?一种方法是设计一台专门用于计算*方根的机器。尽管听起来很奇怪,但最早的计算机确实是固定程序计算机,意味着它们设计用来解决特定的数学问题,比如计算炮弹的轨迹。首台计算机之一(由阿塔纳索夫和贝里于 1941 年建造)解决了线性方程组,但其他问题则无法处理。艾伦·图灵在二战期间开发的波美机被设计用来破解德国恩尼格玛密码。一些简单的计算机仍然沿用这种方法。例如,四则运算计算器⁶就是一种固定程序计算机。它可以进行基本算术运算,但不能用作文字处理器或运行视频游戏。要更改这类机器的程序,必须更换电路。
第一台真正现代化的计算机是曼彻斯特 Mark 1.⁷ 与其前身的显著区别在于它是存储程序计算机。这种计算机存储(和操作)一系列指令,并具有能够执行该序列中任何指令的组件。这种计算机的核心是一个解释器,可以执行任何合法的指令集,因此可用于计算任何可以用这些指令描述的东西。计算的结果甚至可以是一系列新的指令,随后可以由生成它们的计算机执行。换句话说,计算机可以对自己进行编程。⁸
程序及其操作的数据都驻留在内存中。通常,一个程序计数器指向内存中的特定位置,计算从执行该点的指令开始。大多数情况下,解释器会简单地转向序列中的下一条指令,但并不总是如此。在某些情况下,它会执行一个测试,并根据该测试,执行可能跳转到指令序列中的另一个点。这被称为控制流,对于让我们编写执行复杂任务的程序至关重要。
人们有时使用流程图来描述控制流。根据惯例,我们用矩形框表示处理步骤,用菱形表示测试,用箭头指示执行的顺序。图 1-1 包含了一个展示如何准备晚餐的流程图。
图 1-1 获取晚餐的流程图
回到食谱的比喻,给定一组固定的原料,一个好的厨师可以通过不同的组合制作出无数美味的菜肴。同样,给定一小组固定的基本特征,一个优秀的程序员可以生成无数有用的程序。这就是编程如此惊人之处。
要创建食谱或指令序列,我们需要一种编程语言来描述它们,给计算机下达指令的方式。
在 1936 年,英国数学家阿兰·图灵描述了一种假设的计算设备,后来被称为通用图灵机。该机器具有无限的内存,表现为可以写入零和一的“磁带”,以及一小部分简单的原始指令用于移动、读取和写入磁带。教会-图灵理论指出,如果一个函数是可计算的,那么可以编写一个图灵机来计算它。
教会-图灵理论中的“如果”是重要的。并非所有问题都有计算解决方案。例如,图灵证明了,写一个程序,使其能够接受任意程序作为输入,并且仅当输入程序会永远运行时输出true
,这是不可能的。这被称为停机问题。
教会-图灵理论直接引出了图灵完备性的概念。如果一种编程语言可以用于模拟通用图灵机,则称其为图灵完备。所有现代编程语言都是图灵完备的。因此,任何可以用一种编程语言(例如 Python)编程的东西,都可以用任何其他编程语言(例如 Java)编程。当然,在特定语言中,有些东西可能更容易编程,但所有语言在计算能力上基本上是*等的。
幸运的是,没有程序员需要使用图灵的原始指令来构建程序。相反,现代编程语言提供了更大、更方便的原语集。然而,编程作为组装一系列操作的过程这一基本思想仍然是核心。
不论你拥有哪一组原语,以及你有何种组装它们的方法,编程的最好和最糟的地方是一样的:计算机将完全按照你所指示的方式执行——没有更多,也没有更少。这是好事,因为这意味着你可以让计算机做各种有趣和有用的事情。这也是坏事,因为当它没有按照你的意图运行时,你通常只能责怪自己。
世界上有数百种编程语言。没有最好的语言。不同的语言适用于不同类型的应用。MATLAB 例如,是一种适合操作向量和矩阵的良好语言。C 是一种适合编写控制数据网络程序的良好语言。PHP 是一种适合构建网站的良好语言。而 Python 是一种出色的通用语言。
每种编程语言都有一组原始构造、语法、静态语义和语义。类比于自然语言,例如英语,原始构造是单词,语法描述哪些单词序列构成良好格式的句子,静态语义定义哪些句子有意义,而语义定义这些句子的意义。Python 中的原始构造包括文字(例如数字3.2
和字符串‘abc’)以及**中缀运算符**(例如
+和
/`)。
一种语言的语法定义了哪些字符和符号的字符串是良好格式的。例如,在英语中,字符串“Cat dog boy.”不是语法上有效的句子,因为英语语法不接受<名词> <名词> <名词>形式的句子。在 Python 中,原语序列3.2 + 3.2
是语法上良好格式的,但序列3.2 3.2
则不是。
静态语义定义了哪些语法上有效的字符串具有意义。例如,考虑字符串“He run quickly”和“I runs quickly。”每个字符串的形式为
一种语言的语义将含义与每个没有静态语义错误的语法正确的符号串关联。在自然语言中,一个句子的语义可能是模糊的。例如,“我无法过于赞扬这个学生”这句话可以是恭维或谴责。编程语言的设计是确保每个合法程序只有一个确切的含义。
虽然语法错误是最常见的错误类型(尤其是对于学习新编程语言的人来说),但它们是最不危险的错误。每种严肃的编程语言都会检测所有语法错误,并不允许用户执行即使只有一个语法错误的程序。此外,在大多数情况下,语言系统会清楚地指示错误的位置,使程序员能够在不费太多脑筋的情况下修复它。
识别和解决静态语义错误更为复杂。一些编程语言,例如 Java,在允许程序执行之前会进行大量的静态语义检查。其他语言,例如 C 和 Python(可悲的是),在执行程序之前相对较少进行静态语义检查。Python 在运行程序时确实会进行相当多的语义检查。
如果程序没有语法错误和静态语义错误,它就有意义,即具有语义。当然,它可能没有其创建者所意图的语义。当一个程序意味着与其创建者所认为的不同的东西时,坏事可能会发生。
如果程序有错误,并且以意想不到的方式运行,会发生什么?
它可能会崩溃,即停止运行并明显指示其已崩溃。在一个设计良好的计算系统中,当程序崩溃时,它不会损坏整体系统。可悲的是,一些非常流行的计算机系统没有这种良好特性。几乎每个使用个人计算机的人都运行过需要重新启动整个系统的程序。
它可能会不断运行,运行,再运行,永远不会停止。如果你对程序完成工作的时间没有大致的概念,这种情况可能很难被识别。
它可能会运行到完成并产生一个可能正确或可能不正确的答案。
每一个结果都不好,但最后一个无疑是最糟糕的。当一个程序看似在做正确的事情但实际上并非如此时,坏事可能随之而来:财富可能会丧失,患者可能会收到致命剂量的放射治疗,飞机可能会坠毁。
在可能的情况下,程序应编写得如此,当它们运行不正常时,显而易见。我们将在本书中讨论如何做到这一点。
指法练习:计算机往往会字面理解你的指令。如果你没有准确告诉它们你想要做什么,它们可能会做错事情。试着为从一个目的地开车到另一个目的地写一个算法。按照你给人的方式来写,然后想象如果那个人像计算机一样愚蠢,并严格执行算法会发生什么。(想看有趣的例子,请查看视频 www.youtube.com/watch?v=FN2RM-CHkuI&t=24s
。)
1.1 章节中引入的术语
声明式知识
命令式知识
算法
计算
固定程序计算机
存储程序计算机
解释器
程序计数器
控制流
流程图
编程语言
通用图灵机
丘奇-图灵论题
停止问题
图灵完备性
字面量
中缀运算符
语法
静态语义
语义
第二章:Python 简介
尽管每种编程语言都是不同的(虽然没有设计者们所认为的那样不同),但它们可以在某些维度上关联。
低级与高级指的是我们是否使用机器级别的指令和数据对象进行编程(例如,将 64 位数据从这个位置移动到那个位置),或者我们是否使用语言设计者提供的更抽象的操作(例如,在屏幕上弹出菜单)进行编程。
通用与特定于应用领域指的是编程语言的原始操作是广泛适用还是针对某一领域进行细化。例如,SQL 旨在从关系数据库中提取信息,但你不希望用它来构建操作系统。
解释执行与编译执行指的是程序员编写的指令序列(称为源代码)是否被直接执行(通过解释器),或者是否首先被转换(通过编译器)为一系列机器级原始操作。(在计算机的早期,人们必须用接*机器代码的语言编写源代码,以便计算机硬件可以直接解释。)这两种方法各有优劣。通常,调试那些设计为解释执行的语言编写的程序更容易,因为解释器能够产生易于与源代码关联的错误信息。编译语言通常生成运行更快且占用更少空间的程序。
在本书中,我们使用Python。然而,这本书并不是关于 Python 的。它当然会帮助你学习 Python,这很好。然而,更重要的是,你将学习如何编写解决问题的程序。你可以将这种技能转移到任何编程语言上。
Python 是一种通用编程语言,你可以有效地使用它构建几乎任何不需要直接访问计算机硬件的程序。对于具有高可靠性约束的程序(由于其弱静态语义检查)或由多人或在长时间内构建和维护的程序(同样因为弱静态语义检查),Python 并不是最佳选择。
Python 确实比许多其他语言具有多个优势。它是一种相对简单、易于学习的语言。由于 Python 被设计为解释执行,它能提供对初学者程序员特别有帮助的运行时反馈。大量不断增长的可自由获取的库与 Python 接口,提供了有用的扩展功能。本书中使用了其中的多个库。
我们准备介绍一些 Python 的基本元素。这些在概念上几乎所有编程语言都是共同的,尽管在细节上有所不同。
本书不仅仅是 Python 的介绍。它使用 Python 作为工具来呈现与计算问题解决和思维相关的概念。语言的呈现是按需逐步进行的。我们不需要的 Python 特性则完全不予展示。我们对没有涵盖每个细节感到放心,因为优秀的在线资源描述了语言的每一个方面。我们建议根据需要使用这些免费的在线资源。
Python 是一种活语言。自 1990 年由 Guido van Rossum 引入以来,它经历了许多变化。在其生命的头十年,Python 是一种鲜为人知且使用不多的语言。2000 年 Python 2.0 的到来改变了这一现状。除了对语言本身的重要改进外,它还标志着语言演变路径的转变。许多团体开始开发与 Python 无缝接口的库,Python 生态系统的持续支持和开发成为了一项社区活动。
Python 3.0 于 2008 年底发布。这个版本的 Python 清理了 Python 2 设计中的许多不一致性。然而,Python 3 不向后兼容。这意味着为早期版本的 Python 编写的大多数程序和库无法使用 Python 3 的实现运行。
到现在,所有重要的公共领域 Python 库都已移植到 Python 3。今天,没有理由使用 Python 2。
2.1 安装 Python 和 Python IDE
曾几何时,程序员使用通用文本编辑器输入他们的程序。如今,大多数程序员更喜欢使用集成开发环境(IDE)中的文本编辑器。
第一个 Python IDE,IDLE,作为标准 Python 安装包的一部分而出现。随着 Python 的普及,其他 IDE 应运而生。这些较新的 IDE 通常集成了一些更流行的 Python 库,并提供 IDLE 未提供的功能。Anaconda和 Canopy 是这些 IDE 中更受欢迎的选择。本书中的代码是在 Anaconda 中创建和测试的。
IDE 是应用程序,就像计算机上的任何其他应用程序一样。启动一个 IDE 的方式与启动其他应用程序相同,例如,通过双击图标。
提供具有语法高亮、自动补全和智能缩进的文本编辑器,
具有语法高亮的命令行,
一个集成调试器,目前可以安全地忽略它。
现在是安装 Anaconda(或其他 IDE)的好时机,以便你可以运行书中的示例,更重要的是,尝试编程练习。要安装 Anaconda,请访问
[`www.anaconda.com/distribution/`](https://www.anaconda.com/distribution/)
并遵循说明。
安装完成后,启动应用程序Anaconda-Navigator
。将出现一个包含 Python 工具集合的窗口。该窗口看起来类似于图 2-1。目前,我们唯一要使用的工具是**Spyder**
。当你启动 Spyder(通过点击它的Launch
按钮)时,将打开一个类似于图 2-2 的窗口。
图 2-1 Anaconda 启动窗口
图 2-2 Spyder 窗口
图 2-2 右下角的窗格是一个运行交互式 Python shell 的 IPython 控制台。你可以在这个窗口中输入并执行 Python 命令。右上角的窗格是一个帮助窗口。通常,关闭该窗口(通过点击 x
)会更方便,这样可以为 IPython 控制台提供更多空间。左侧的窗格是一个编辑窗口,你可以在其中输入可以保存和运行的程序。窗口顶部的工具栏使打开文件和打印程序等各种任务变得容易。¹¹ Spyder
的文档可以在 www.spyder-ide.org/
找到。
2.2 Python 的基本元素
Python 程序有时称为 脚本,是一系列定义和命令的序列。Shell 中的 Python 解释器评估定义并执行命令。
我们建议你现在启动一个 Python shell(例如,通过启动Spyder
),并使用它尝试本章其余部分中的示例。事实上,在本书的其余部分中也可以这样做。
命令,通常称为 语句,指示解释器执行某项操作。例如,语句 print('Yankees rule!')
指示解释器调用函数¹² print
,该函数将字符串 Yankees rule!
输出到与 shell 关联的窗口。
命令的序列
print('Yankees rule!')
print('But not in Boston!')
print('Yankees rule,', 'but not in Boston!')
导致解释器产生输出
Yankees rule!
But not in Boston!
Yankees rule, but not in Boston!
请注意,在第三条语句中传递了两个值给 print
。print
函数接受由逗号分隔的可变数量的参数,并按出现顺序以空格字符分隔输出。
2.2.1 对象、表达式和数值类型
对象是 Python 程序操作的核心事物。每个对象都有一个 类型,定义了程序可以对该对象执行的操作。
类型分为标量和非标量。标量对象是不可分割的。可以将它们视为语言的原子。¹³ 非标量对象,例如字符串,具有内部结构。
许多类型的对象可以通过程序文本中的 字面量 来表示。例如,文本 2
是表示数字的字面量,而文本 'abc'
是表示字符串的字面量。
Python 有四种类型的标量对象:
**int**
用于表示整数。类型为int
的字面量是以我们通常表示整数的方式书写的(例如,-3
或5
或10002
)。**float**
用于表示实数。类型为float
的字面量总是包含小数点(例如,3.0
或3.17
或-28.72
)。 (也可以使用科学记数法来书写类型为float
的字面量。例如,字面量1.6E3
代表 1.6*10³,即它与 1600.0 相同。)你可能会想知道为什么这个类型不叫real
。在计算机内部,类型为float
的值以 浮点数 的形式存储。这种表示法被所有现代编程语言采用,具有许多优点。然而,在某些情况下,它会导致浮点运算的行为与实数运算略有不同。我们将在第 3.3 节中讨论这个问题。**bool**
用于表示布尔值True
和False
。**None**
是一种只有一个值的类型。我们将在第四部分中详细讨论None
。
对象和 运算符 可以组合形成 表达式,每个表达式的值为某种类型的对象。这称为表达式的 值。例如,表达式 3 + 2
表示类型为 int
的对象 5
,而表达式 3.0 + 2.0
表示类型为 float
的对象 5.0
。
==
运算符用于测试两个表达式是否评估为相同的值,而 !=
运算符用于测试两个表达式是否评估为不同的值。单个 =
意味着完全不同的东西,正如我们将在第 2.2.2 节中看到的。请提前警告——你会犯下将“=
”输入成“==
”的错误。注意这个错误。
在 Spyder 控制台中,类似于 In [1]:
的内容是 shell 提示符,表明解释器正在等待用户输入一些 Python 代码。提示符下方的行是在解释器评估用户输入的 Python 代码时产生的,下面是与解释器的一个交互示例:
3
Out[1]: 3
3+2
Out[2]: 5
3.0+2.0
Out[3]: 5.0
3!=2
Out[4]: True
内置的 Python 函数 type
可用于查找对象的类型:
type(3)
Out[5]: int
type(3.0)
Out[6]: float
类型为 int
和 float
的对象的运算符列在 图 2-3 中。算术运算符具有通常的优先级。例如,*
的优先级高于 +
,因此表达式 x+y*2
会先计算 y
乘以 2
,然后再将结果加到 x
上。可以通过使用括号来改变评估顺序,例如 (x+y)*2
会先将 x
和 y
相加,然后再将结果乘以 2
。
图 2-3 类型 int
和 float
的运算符
类型 bool
的基本运算符是 and
、or
和 not
:
**a 和 b**
为True
当且仅当a
和b
都为True
,否则为False
。**a 或 b**
为True
当至少一个a
或b
为True
,否则为False
。**not a**
在a
为False
时为True
,而在a
为True
时为False
。
2.2.2 变量与赋值
变量提供了一种将名称与对象关联的方法。考虑以下代码
pi = 3
radius = 11
area = pi * (radius**2)
radius = 14
代码首先绑定了名称pi
和radius
到不同的int
类型对象上。¹⁴ 然后将名称area
绑定到第三个int
类型的对象。这在图 2-4 的左侧进行了描述。
图 2-4 变量与对象的绑定
如果程序接着执行radius = 14
,那么名称radius
将重新绑定到不同的int
类型对象上,如图 2-4 右侧所示。请注意,这个赋值对area
绑定的值没有影响。它仍然绑定于表达式3*(11**2)
所表示的对象。
在 Python 中,变量仅仅是一个名称,没有更多的意义。记住这一点——这很重要。一个赋值语句将=
符号左侧的名称与=
符号右侧表达式所表示的对象关联起来。也请记住:一个对象可以有一个、多个或没有与之关联的名称。
也许我们不应该说,“变量仅仅是一个名称。”尽管朱丽叶这么说过,¹⁵ 名称是重要的。编程语言让我们描述计算,以便计算机可以执行它们。这并不意味着只有计算机在阅读程序。
正如你很快会发现的,编写正确工作的程序并不总是容易。经验丰富的程序员会确认,他们花费了大量时间阅读程序,以尝试理解它们为何以这样的方式运行。因此,编写易于阅读的程序是至关重要的。恰当选择变量名称在增强可读性方面起着重要作用。
考虑这两个代码片段
a = 3.14159 pi = 3.14159
b = 11.2 diameter = 11.2
c = a*(b**2) area = pi*(diameter**2)
就 Python 而言,这些代码片段没有区别。当执行时,它们将执行相同的操作。然而,对于人类读者来说,它们却截然不同。当我们阅读左侧的片段时,没有先验的理由怀疑有什么问题。然而,快速看一下右侧的代码应该会让我们怀疑某些地方不对。要么变量应该命名为radius
而不是diameter
,要么在计算面积时应该将diameter
除以2.0
。
在 Python 中,变量名可以包含大写和小写字母、数字(虽然不能以数字开头)以及特殊字符_
(下划线)。Python 的变量名区分大小写,例如,Romeo
和romeo
是不同的名称。最后,Python 中有一些保留字(有时称为关键字),这些字有内置的意义,不能用作变量名。不同版本的 Python 有略微不同的保留字列表。Python 3.8 中的保留字包括:
and |
break |
elif |
for |
in |
not |
True |
---|---|---|---|---|---|---|
as |
class |
else |
from |
is |
or |
try |
assert |
continue |
except |
global |
lambda |
pass |
while |
async |
def |
False |
if |
nonlocal |
raise |
with |
await |
del |
finally |
import |
None |
return |
yield |
提高代码可读性的另一个好方法是添加 注释。符号 #
后面的文本不会被 Python 解释。例如,我们可能会写
side = 1 #length of sides of a unit square
radius = 1 #radius of a unit circle
#subtract area of unit circle from area of unit square
area_circle = pi*radius**2
area_square = side*side
difference = area_square – area_circle
Python 允许多重赋值。语句
`x, y = 2, 3`
将 x
绑定到 2
,将 y
绑定到 3
。赋值右侧的所有表达式在任何绑定改变之前都会被评估。这很方便,因为它允许你使用多重赋值来交换两个变量的绑定。
例如,代码
x, y = 2, 3
x, y = y, x
print('x =', x)
print('y =', y)
将打印
x = 3
y = 2
2.3 分支程序
到目前为止,我们正在查看的计算类型称为 直线程序。它们按照出现的顺序一个接一个地执行语句,并在用尽语句时停止。我们可以用直线程序描述的计算类型并不是很有趣。事实上,它们实在是太无聊了。
分支程序更有趣。最简单的分支语句是 条件。如图 2-5 所示,条件语句有三个部分:
一个测试,即评估为
True
或False
的表达式如果测试评估为
True
,则执行的代码块如果测试评估为
False
,则执行的可选代码块
在条件语句执行后,执行将继续在语句后的代码处。
图 2-5 条件语句的流程图
在 Python 中,条件语句的形式是
`if` *Boolean expression*`: if` *Boolean expression*`:`
*block of code * or * block of code*
`else:`
*block of code*
在描述 Python 语句的形式时,我们使用斜体来标识在程序的某一点可能出现的代码类型。例如,布尔表达式表示任何评估为 True
或 False
的表达式可以跟在保留字 if
后,而 代码块 表示任何 Python 语句序列可以跟在 else:
后。
考虑以下程序,如果变量 x
的值为偶数,则打印“Even”,否则打印“Odd”:
if x%2 == 0:
print('Even')
else:
print('Odd')
print('Done with conditional')
当 x
除以 2
的余数为 0
时,表达式 x%2 == 0
评估为 True
,否则为 False
。请记住,==
用于比较,而 =
是用于赋值的。
缩进 在 Python 中具有语义意义。例如,如果上述代码中的最后一条语句被缩进,它将属于与 else
相关的代码块,而不是条件语句后的代码块。
Python 在使用缩进方面是非常特殊的。大多数其他编程语言使用括号符号来划分代码块,例如,C 语言使用花括号{
}
来封闭代码块。Python 方法的一个优点是,它确保程序的视觉结构准确地代表其语义结构。由于缩进在语义上很重要,行的概念也很重要。过长的代码行可以通过在屏幕上每一行的末尾(除了最后一行)加上反斜杠(\)来拆分为多行。比如,
x = 1111111111111111111111111111111 + 222222222222333222222222 +\
3333333333333333333333333333333
py`Long lines can also be wrapped using Python's implied line continuation. This is done with bracketing, i.e., parentheses, square brackets, and braces. For example,
x = 1111111111111111111111111111111 + 222222222222333222222222 + 3333333333333333333333333333333 py is interpreted as two lines (and therefore produces an “unexpected indent” syntax error whereas
x = (1111111111111111111111111111111 + 222222222222333222222222 + 3333333333333333333333333333333) py is interpreted as a single line because of the parentheses. Many Python programmers prefer using implied line continuations to using a backslash. Most commonly, programmers break long lines at commas or operators. Returning to conditionals, when either the true block or the false block of a conditional contains another conditional, the conditional statements are said to be **nested**. The following code contains nested conditionals in both branches of the top-level `if` statement.
如果 x%2 == 0: 如果 x%3 == 0: print('能被 2 和 3 整除') 否则: print('能被 2 整除但不能被 3 整除') 否则如果 x%3 == 0: print('能被 3 整除但不能被 2 整除') py The `elif` in the above code stands for “else if.” It is often convenient to use a **compound Boolean expression** in the test of a conditional, for example,
如果 x < y 且 x < z: print('x 是最小的') 否则如果 y < z: print('y 是最小的') 否则: print('z 是最小的') py **Finger exercise:** Write a program that examines three variables—`x`, `y`, and `z`—and prints the largest odd number among them. If none of them are odd, it should print the smallest value of the three. You can attack this exercise in a number of ways. There are eight separate cases to consider: they are all odd (one case), exactly two of them are odd (three cases), exactly one of them is odd (three cases), or none of them is odd (one case). So, a simple solution would involve a sequence of eight `if` statements, each with a single `print` statement:
如果 x%2 != 0 且 y%2 != 0 且 z%2 != 0: print(max(x, y, z)) 如果 x%2 != 0 且 y%2 != 0 且 z%2 == 0: print(max(x, y)) 如果 x%2 != 0 且 y%2 == 0 且 z%2 != 0: print(max(x, z)) 如果 x%2 == 0 且 y%2 != 0 且 z%2 != 0: print(max(y, z)) 如果 x%2 != 0 且 y%2 == 0 且 z%2 == 0: print(x) 如果 x%2 == 0 且 y%2 != 0 且 z%2 == 0: print(y) 如果 x%2 == 0 且 y%2 == 0 且 z%2 != 0: print(z) 如果 x%2 == 0 且 y%2 == 0 且 z%2 == 0: print(min(x, y, z)) py That gets the job done, but is rather cumbersome. Not only is it 16 lines of code, but the variables are repeatedly tested for oddness. The following code is both more elegant and more efficient:
answer = min(x, y, z) 如果 x%2 != 0: answer = x 如果 y%2 != 0 且 y > answer: answer = y 如果 z%2 != 0 且 z > answer: answer = z print(answer) py The code is based on a common programming paradigm. It starts by assigning a provisional value to a variable (`answer`), updating it when appropriate, and then printing the final value of the variable. Notice that it tests whether each variable is odd exactly once, and contains only a single print statement. This code is pretty much as well as we can do, since any correct program must check each variable for oddness and compare the values of the odd variables to find the largest of them. Python supports **conditional expressions** as well as conditional statements. Conditional expressions are of the form
expr1 如果 condition 否则 expr2 py If the condition evaluates to `True`, the value of the entire expression is `*expr1*`; otherwise it is `*expr2*`. For example, the statement
x = y 如果 y > z 否则 z py sets `x` to the maximum of `y` and `z`. A conditional expression can appear any place an ordinary expression can appear, including within conditional expressions. So, for example,
print((x 如果 x > z 否则 z) 如果 x > y 否则 (y 如果 y > z 否则 z)) py prints the maximum of `x`, `y`, and `z`. Conditionals allow us to write programs that are more interesting than straight-line programs, but the class of branching programs is still quite limited. One way to think about the power of a class of programs is in terms of how long they can take to run. Assume that each line of code takes one unit of time to execute. If a straight-line program has `n` lines of code, it will take `n` units of time to run. What about a branching program with `n` lines of code? It might take less than `n` units of time to run, but it cannot take more, since each line of code is executed at most once. A program for which the maximum running time is bounded by the length of the program is said to run in **constant time**. This does not mean that each time the program is run it executes the same number of steps. It means that there exists a constant, `k`, such that the program is guaranteed to take no more than `k` steps to run. This implies that the running time does not grow with the size of the input to the program. Constant-time programs are limited in what they can do. Consider writing a program to tally the votes in an election. It would be truly surprising if one could write a program that could do this in a time that was independent of the number of votes cast. In fact, it is impossible to do so. The study of the intrinsic difficulty of problems is the topic of **computational complexity**. We will return to this topic several times in this book. Fortunately, we need only one more programming language construct, iteration, to allow us write programs of arbitrary complexity. We get to that in Section 2.5.````
py## 2.4 Strings and Input Objects of type
strare used to represent characters.¹⁶ Literals of type
strcan be written using either single or double quotes, e.g.,
'abc'or
"abc". The literal
'123'denotes a string of three characters, not the number 123. Try typing the following expressions in to the Python interpreter. ```
'a'3*4
3*'a'3+4
'a'+'a'```py The operator
+is said to be **overloaded** because it has different meanings depending upon the types of the objects to which it is applied. For example, the operator
+means addition when applied to two numbers and concatenation when applied to two strings. The operator
is also overloaded. It means what you expect it to mean when its operands are both numbers. When applied to an
intand a
str, it is a **repetition operator**—the expression
ns, where
nis an
intand
sis a
str, evaluates to a
strwith
nrepeats of
s. For example, the expression
2*'John'has the value
'JohnJohn'. There is a logic to this. Just as the mathematical expression
32is equivalent to
2+2+2, the expression
3'a'is equivalent to
'a'+'a'+'a'. Now try typing ``` new_id
'a''a'```py Each of these lines generates an error message. The first line produces the message ``` NameError: name 'new_id' is not defined ```py Because
new_idis not a literal of any type, the interpreter treats it as a name. However, since that name is not bound to any object, attempting to use it causes a runtime error. The code
'a''a'produces the error message ``` TypeError: can't multiply sequence by non-int of type ‘str' ```py That **type checking** exists is a good thing. It turns careless (and sometimes subtle) mistakes into errors that stop execution, rather than errors that lead programs to behave in mysterious ways. The type checking in Python is not as strong as in some other programming languages (e.g., Java), but it is better in Python 3 than in Python 2\. For example, it is clear what
<should mean when it is used to compare two strings or two numbers. But what should the value of
'4' < 3 be
?Rather arbitrarily, the designers of Python 2 decided that it should be
False, because all numeric values should be less than all values of type
str. The designers of Python 3, and most other modern languages, decided that since such expressions don't have an obvious meaning, they should generate an error message. Strings are one of several sequence types in Python. They share the following operations with all sequence types. * The **length** of a string can be found using the
lenfunction. For example, the value of
len('abc')is
3. * **Indexing** can be used to extract individual characters from a string. In Python, all indexing is zero-based. For example, typing
'abc'[0]into the interpreter will cause it to display the string
'a'. Typing
'abc'[3]will produce the error message
IndexError: string index out of range. Since Python uses
0to indicate the first element of a string, the last element of a string of length 3 is accessed using the index
2. Negative numbers are used to index from the end of a string. For example, the value of
'abc'[‑1]is
'c'. * **Slicing** is used to extract substrings of arbitrary length. If
sis a string, the expression
s[start:end]denotes the substring of
sthat starts at index
startand ends at index
end-1. For example,
'abc'[1:3]evaluates to
'bc'. Why does it end at index
end-1rather than
end? So that expressions such as
'abc'[0:len('abc')]have the value you might expect. If the value before the colon is omitted, it defaults to
0. If the value after the colon is omitted, it defaults to the length of the string. Consequently, the expression
'abc'[:]is semantically equivalent to the more verbose
'abc'[0:len('abc')]. It is also possible to supply a third argument to select a non-contiguous slice of a string. For example, the value of the expression
'
123456789'[0:8:2] is the string
'1357'. It is often convenient to convert objects of other types to strings using the
strfunction. Consider, for example, the code ``` num = 30000000 fraction = 1/2 print(num*fraction, '是', fraction*100, '%', '的', num) print(num*fraction, '是', str(fraction*100) + '%', '的', num) ```py which prints ``` 15000000.0 是 50.0 % 的 30000000 15000000.0 是 50.0% 的 30000000 ```py The first print statement inserts a space between
50and
%because Python automatically inserts a space between the arguments to
print. The second print statement produces a more appropriate output by combining the
50and the
%into a single argument of type
str. **Type conversions** (also called **type casts**) are used often in Python code. We use the name of a type to convert values to that type. So, for example, the value of
int('3')4is
12. When a
floatis converted to an
int, the number is truncated (not rounded), e.g., the value of
int(3.9)is the
int
3. Returning to the output of our print statements, you might be wondering about that
.0at the end of the first number printed. That appears because
1/2is a floating-point number, and the product of an
intand a
floatis a
float. It can be avoided by converting
numfractionto an
int. The code ``` print(int(num*fraction), '是', str(fraction*100) + '%', '的', num) ```py prints
15000000 is 50.0% of 30000000. Python 3.6 introduced an alternative, more compact, way to build string expressions. An **f-string** consists of the character
f(or
F) following by a special kind of string literal called a **formatted string literal**. Formatted string literals contain both sequences of characters (like other string literals) and expressions bracketed by curly braces. These expressions are evaluated at runtime and automatically converted to strings. The code ``` print(f'{int(num*fraction)} 是 {fraction*100}% 的 {num}') ```py produces the same output as the previous, more verbose, print statement. If you want to include a curly brace in the string denoted by an f-string, use two braces. E.g.,
print(f'{{{35}}}')prints
{15}. The expression inside an f-string can contain modifiers that control the appearance of the output string.¹⁷ These modifiers are separated from the expression denoting the value to be modified by a colon. For example, the f-string
f'{3.14159:.2f}'evaluates to the string
'3.14'because the modifier
.2f instructs Python to truncate the string representation of a floating-point number to two digits after the decimal point. And the statement ``` print(f'{num*fraction:,.0f} 是 {fraction*100}% 的 {num:,}') ```py prints
15,000,000 is 50.0% of 30,000,000because the
,modifier instructs Python to use commas as thousands separators. We will introduce other modifiers as convenient later in the book. ### 2.4.1 Input Python 3 has a function,
input, that can be used to get input directly from a user. The
inputfunction takes a string as an argument and displays it as a prompt in the shell. The function then waits for the user to type something and press the
enterkey. The line typed by the user is treated as a string and becomes the value returned by the function. Executing the code
name = input('Enter your name: ')will display the line ```
请输入你的名字:```py in the console window. If you then type
George Washingtonand press
enter, the string
'George Washington'will be assigned to the variable
name. If you then execute
print('Are you really', name, '?'), the line ``` 你真的是乔治·华盛顿吗? ```py would be displayed. Notice that the
print statement introduces a space before the “
?”. It does this because when
printis given multiple arguments, it places a space between the values associated with the arguments. The space could be avoided by executing
print('Are you really ' + name + '?') or
print(f'Are you really ?'), each of which produces a single string and passes that string as the only argument to
print. Now consider the code ``` n = input('请输入一个整数: ') print(type(n)) ```py This code will always print ``` <class ‘str'> ```py because
inputalways returns an object of type
str, even if the user has entered something that looks like an integer. For example, if the user had entered
3,
nwould be bound to the str
'3'not the int
3. So, the value of the expression
n4would be
'3333'rather than
12. The good news is that whenever a string is a valid literal of some type, a type conversion can be applied to it. **Finger exercise:** Write code that asks the user to enter their birthday in the form mm/dd/yyyy, and then prints a string of the form
‘You were born in the year yyyy.’ ### 2.4.2 A Digression about Character Encoding For many years most programming languages used a standard called ASCII for the internal representation of characters. This standard included
128characters, plenty for representing the usual set of characters appearing in English-language text—but not enough to cover the characters and accents appearing in all the world's languages. The **Unicode** standard is a character coding system designed to support the digital processing and display of the written texts of all languages. The standard contains more than
120,000characters—covering
129modern and historic scripts and multiple symbol sets. The Unicode standard can be implemented using different internal character encodings. You can tell Python which encoding to use by inserting a comment of the form ```
# -- coding: encoding name -- ```py as the first or second line of your program. For example, ``` # -*- coding: utf-8 -*- ```py instructs Python to use UTF-8, the most frequently used character encoding for webpages.¹⁸ If you don't have such a comment in your program, most Python implementations will default to UTF-8. When using UTF-8, you can, text editor permitting, directly enter code like ``` print('你会说英语吗?') print(
'क्या आप अंग्रेज़ी बोलते हैं?
') ```py which will print ``` 你会说英语吗? क्या आप अंग्रेज़ी बोलते हैं? ```py You might be wondering how I managed to type the string
'क्या आप अंग्रेज़ी बोलते हैं?
'. I didn't. Because most of the web uses UTF-8, I was able to cut the string from a webpage and paste it directly into my program. There are ways to directly enter Unicode characters from a keyboard, but unless you have a special keyboard, they are all rather cumbersome. ## 2.5 While Loops Near the end of Section 2.3, we mentioned that most computational tasks cannot be accomplished using branching programs. Consider, for example, writing a program that asks for the number of X's. You might think about writing something like ``` num_x = int(input('我应该打印字母 X 几次? ')) to_print = '' 如果 num_x == 1: to_print = 'X' elif num_x == 2: to_print = 'XX' elif num_x == 3: to_print = 'XXX' #… print(to_print) ```py But it would quickly become apparent that you would need as many conditionals as there are positive integers—and there are an infinite number of those. What you want to write is a program that looks like (the following is **pseudocode**, not Python) ``` num_x = int(input('我应该打印字母 X 几次? ')) to_print = '' # 将 X 连接到 to_print num_x 次 print(to_print) ```py When we want a program to do the same thing many times, we can use **iteration**. A generic iteration (also called **looping**) mechanism is shown in the box in Figure 2-6. Like a conditional statement, it begins with a test. If the test evaluates to
True, the program executes the **loop** body once, and then goes back to reevaluate the test. This process is repeated until the test evaluates to
False, after which control passes to the code following the iteration statement. ![c2-fig-0006.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c2-fig-0006.jpg) Figure 2-6 Flowchart for iteration We can write the kind of loop depicted in Figure 2-6 using a **while** statement. Consider the code in Figure 2-7. ![c2-fig-0007.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c2-fig-0007.jpg) Figure 2-7 Squaring an integer, the hard way The code starts by binding the variable
xto the integer
3. It then proceeds to square
xby using repetitive addition. The table in Figure 2-8 shows the value associated with each variable each time the test at the start of the loop is reached. We constructed the table by **hand-simulating** the code, i.e., we pretended to be a Python interpreter and executed the program using pencil and paper. Using pencil and paper might seem quaint, but it is an excellent way to understand how a program behaves.¹⁹ ![c2-fig-0008.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c2-fig-0008.jpg) Figure 2-8 Hand simulation of a small program The fourth time the test is reached, it evaluates to
Falseand flow of control proceeds to the
printstatement following the loop. For what values of
xwill this program terminate? There are three cases to consider:
x == 0,
x > 0, and
x < 0. Suppose
x == 0. The initial value of num_
iterationswill also be
0, and the loop body will never be executed. Suppose
x > 0. The initial value of
num_iterationswill be less than
x, and the loop body will be executed at least once. Each time the loop body is executed, the value of
num_iterationsis increased by exactly
1. This means that since
num_iterationsstarted out less than
x, after some finite number of iterations of the loop, num_
iterationswill equal
x. At this point the loop test evaluates to
False, and control proceeds to the code following the
whilestatement. Suppose
x < 0. Something very bad happens. Control will enter the loop, and each iteration will move
num_iterationsfarther from
xrather than closer to it. The program will therefore continue executing the loop forever (or until something else bad, e.g., an overflow error, occurs). How might we remove this flaw in the program? Changing the test to
num_iterations < abs(x)almost works. The loop terminates, but it prints a negative value. If the assignment statement inside the loop is also changed, to
ans = ans + abs(x), the code works properly. **Finger exercise:** Replace the comment in the following code with a
whileloop. ``` num_x = int(input('我应该打印字母 X 几次? ')) to_print = '' #将 X 连接到 to_print num_x 次 print(to_print) ```py It is sometimes convenient to exit a loop without testing the loop condition. Executing a **break** statement terminates the loop in which it is contained and transfers control to the code immediately following the loop. For example, the code ``` # 找到一个同时能被 11 和 12 整除的正整数 x = 1 while True: 如果 x%11 == 0 且 x%12 == 0: break x = x + 1 print(x, '能被 11 和 12 整除') ```py prints ``` 132 能被 11 和 12 整除 ```py If a
breakstatement is executed inside a nested loop (a loop inside another loop), the break will terminate the inner loop. **Finger exercise:** Write a program that asks the user to input 10 integers, and then prints the largest odd number that was entered. If no odd number was entered, it should print a message to that effect. ## 2.6 For Loops and Range The
whileloops we have used so far are highly stylized, often iterating over a sequence of integers. Python provides a language mechanism, the
for**loop**, that can be used to simplify programs containing this kind of iteration. The general form of a
forstatement is (recall that the words in italics are descriptions of what can appear, not actual code): ```
for*variable*
in *sequence*
: *code block* ```py The variable following
foris bound to the first value in the sequence, and the code block is executed. The variable is then assigned the second value in the sequence, and the code block is executed again. The process continues until the sequence is exhausted or a
breakstatement is executed within the code block. For example, the code ``` total = 0 for num in (77, 11, 3): total = total + num print(total) ```py will print
91. The expression (77, 11, 3) is a **tuple**. We discuss tuples in detail in Section 5\. For now, just think of a tuple as a sequence of values. The sequence of values bound to *variable* is most commonly generated using the built-in function
range, which returns a series of integers. The
rangefunction takes three integer arguments:
start,
stop, and
step. It produces the progression
start, start + step, start + 2step,etc. If
step is positive, the last element is the largest integer such that (
start + istep)is strictly less than
stop. If
step is negative, the last element is the smallest integer such that (
start + i*step)is greater than
stop. For example, the expression
range(5, 40, 10)yields the sequence
5, 15, 25, 35, and the expression
range(40, 5, -10)yields the sequence
40, 30, 20, 10. If the first argument to
rangeis omitted, it defaults to
0, and if the last argument (the step size) is omitted, it defaults to
1. For example,
range(0, 3)and
range(3)both produce the sequence
0, 1, 2. The numbers in the progression are generated on an “as needed” basis, so even expressions such as
range(1000000)consume little memory. We will discuss
rangein more depth in Section 5.2. Consider the code ``` x = 4 for i in range(x): print(i) ```py It prints ``` 0 1 2 3 ```py The code in Figure 2-9 reimplements the algorithm in Figure 2-7 for squaring an integer (corrected so that it works for negative numbers). Notice that unlike the
whileloop implementation, the number of iterations is not controlled by an explicit test, and the index variable
num_iterationsis not explicitly incremented. ![c2-fig-0009.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c2-fig-0009.jpg) Figure 2-9 Using a
forstatement Notice that the code in Figure 2-9 does not change the value of
num_iterationswithin the body of the
forloop. This is typical, but not necessary, which raises the question of what happens if the index variable is modified within the
forloop. Consider ``` for i in range(2): print(i) i = 0 print(i) ```py Do you think that it will print
0, 0, 1, 0, and then halt? Or do you think it will print
0over and over again? The answer is
0, 0, 1, 0. Before the first iteration of the
forloop, the
rangefunction is evaluated and the first value in the sequence it yields is assigned to the index variable,
i. At the start of each subsequent iteration of the loop,
iis assigned the next value in the sequence. When the sequence is exhausted, the loop terminates. The above
forloop is equivalent to the code ``` index = 0 last_index = 1 while index <= last_index: i = index print(i) i = 0 print(i) index = index + 1 ```py Notice, by the way, that code with the
whileloop is considerably more cumbersome than the
forloop. The
forloop is a convenient linguistic mechanism. Now, what do you think ``` x = 1 for i in range(x): print(i) x = 4 ```py prints? Just
0, because the arguments to the
rangefunction in the line with
forare evaluated just before the first iteration of the loop, and not reevaluated for subsequent iterations. Now, let's see how often things are evaluated when we nest loops. Consider ``` x = 4 for j in range(x): for i in range(x): x = 2 ```py How many times is each of the two loops executed? We already saw that the
range(x)controlling the outer loop is evaluated the first time it is reached and not reevaluated for each iteration, so there are four iterations of the outer loop. That implies that the inner
for loop is reached four times. The first time it is reached, the variable x = 4, so there will be four iterations. However, the next three times it is reached, x = 2, so there will be two iterations each time. Consequently, if you run ```
x = 3 for j in range(x): print('外循环的迭代') for i in range(x): print(' 内循环的迭代') x = 2```py it prints ``` 外循环的迭代 内循环的迭代 内循环的迭代 内循环的迭代 外循环的迭代 内循环的迭代 内循环的迭代 外循环的迭代 内循环的迭代 内循环的迭代 ```py The
forstatement can be used in conjunction with the
in**operator** to conveniently iterate over characters of a string. For example, ``` total = 0 for c in '12345678': total = total + int(c) print(total) ```py sums the digits in the string denoted by the literal
'12345678'and prints the total. **Finger exercise:** Write a program that prints the sum of the prime numbers greater than 2 and less than 1000\. Hint: you probably want to use a
forloop that is a primality test nested inside a
forloop that iterates over the odd integers between 3 and 999\. ## 2.7 Style Matters Much of this book is devoted to helping you learn a programming language. But knowing a language and knowing how to use a language well are two different things. Consider the following two sentences: *“Everybody knows that if a man is* *unmarried and has a lot of money, he* *needs to get married.”* *“It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.”*²⁰ Each is a proper English sentence, and each means approximately the same thing. But they are not equally compelling, and perhaps not equally easy to understand. Just as style matters when writing in English, style matters when writing in Python. However, while having a distinctive voice might be an asset for a novelist, it is not an asset for a programmer. The less time readers of a program have to spend thinking about things irrelevant to the meaning of the code, the better. That's why good programmers follow coding conventions designed to make programs easy to understand, rather than fun to read. Most Python programmers follow the conventions laid out in the **PEP 8** **style guide**.²¹ Like all sets of conventions, some of its prescriptions are arbitrary. For example, it prescribes using four spaces for indents. Why four spaces and not three or five? No particularly good reason. But if everybody uses the same number of spaces, it is easier to read (and perhaps combine) code written by different people. More generally, if everyone uses the same set of conventions when writing Python, readers can concentrate on understanding the semantics of the code rather than wasting mental cycles assimilating stylistic decisions. The most important conventions have to do with naming. We have already discussed the importance of using variable names that convey the meaning of the variable. Noun phrases work well for this. For example, we used the name
num_iterations for a variable denoting the number of iterations. When a name includes multiple words, the convention in Python is to use an underscore (
_)to separate the words. Again, this convention is arbitrary. Some programmers prefer to use what is often called camelCase, e.g.,
numIterations`—arguing that it is faster to type and uses less space. There are also some conventions for single-character variable names. The most important is to avoid using a lowercase L or uppercase I (which are easily confused with the number one) or an uppercase O (which is easily confused with the number zero). That's enough about conventions for now. We will return to the topic as we introduce various aspects of Python. We have now covered pretty much everything about Python that you need to know to start writing interesting programs that deal with numbers and strings. In the next chapter, we take a short break from learning Python, and use what you have already learned to solve some simple problems. ## 2.8 Terms Introduced in Chapter * low-level language * high-level language * interpreted language * compiled language * source code * machine code * Python * integrated development environment (IDE) * Anaconda * Spyder * IPython console * shell * program (script) * command (statement) * object * type * scalar object * non-scalar object * literal * floating point * bool * None * operator * expression * value * shell prompt * variable * binding * assignment * reserved word * comment (in code) * straight-line program * branching program * conditional * indentation (in Python) * nested statement * compound expression * constant time * computational complexity * conditional expression * strings * overloaded operator * repetition operator * type checking * indexing * slicing * type conversion (casting) * formatted string expression * input * Unicode * iteration (looping) * pseudocode * while loop * hand simulation * break * for loop * tuple * range * in operator * PEP 8 style guide````
第三章:一些简单的数值程序
既然我们已经涵盖了一些基本的 Python 结构,现在是时候开始思考如何将这些结构结合起来编写简单的程序。在这个过程中,我们会悄悄引入更多的语言结构和一些算法技术。
3.1 穷举枚举
图 3-1 中的代码会打印出一个整数的立方根(如果存在的话)。如果输入不是一个完美的立方,则会打印出相应的消息。操作符 !=
意思是“不等于”。
图 3-1 使用穷举枚举来寻找立方根
代码首先尝试将变量 ans
设置为 x
的绝对值的立方根。如果成功了,接着如果 x
为负,则将 ans
设置为 -ans
。在这段代码中,重任(虽说也不算重)是在 while
循环中完成的。每当程序包含一个循环时,理解是什么导致程序最终退出这个循环是很重要的。对于什么值的 x
,这个 while
循环会终止?答案是“所有整数”。这一点可以很简单地说明。
表达式
ans**3
的值从0
开始,每次循环时增大。当其值达到或超过
abs(x)
时,循环终止。由于
abs(x)
始终是正数,因此在循环必须终止之前,只有有限的迭代次数。
这个论点基于递减函数的概念。这个函数具有以下属性:
它将一组程序变量映射到一个整数。
当循环被进入时,它的值是非负的。
当其值 ≤ 0 时,循环终止。
它的值在每次循环时减少。
在 图 3-1 中,while
循环的递减函数是什么?它是 abs(x) ‑ ans**3
。
现在,让我们插入一些错误,看看会发生什么。首先,尝试注释掉语句 ans = 0
。Python 解释器将不断打印错误信息。
`NameError: name 'ans' is not defined`
因为解释器尝试在 ans
被绑定到任何值之前找到它的绑定值。现在,恢复 ans
的初始化,将语句 ans = ans + 1
替换为 ans
=
ans
,并尝试寻找 8 的立方根。当你等得不耐烦时,输入“control c”(同时按住 Ctrl
键和 c
键)。这将把你带回到 shell 的用户提示符。
现在,添加语句
print('Value of the decrementing function abs(x) - ans**3 is',
abs(x) - ans**3)
在循环开始时,再次尝试运行。这次它将打印
Value of the decrementing function abs(x) - ans**3 is 8
一遍又一遍地。
程序会永远运行下去,因为循环体不再缩小 ans**3
和 abs(x)
之间的距离。当遇到一个似乎没有终止的程序时,经验丰富的程序员常常会插入打印语句,比如这里的语句,以测试递减函数是否确实在递减。
本程序使用的算法技术是猜测与检查的一种变体,称为耗尽枚举。我们枚举所有可能性,直到找到正确答案或穷尽所有可能性。乍一看,这似乎是解决问题的一个极其愚蠢的方法。然而,令人惊讶的是,耗尽枚举算法往往是解决问题的最实际方法。它们通常易于实现和理解,并且在许多情况下,它们的运行速度足够快以满足所有实际需求。去掉或注释掉你为调试插入的打印语句,并重新插入语句ans = ans + 1
。现在尝试寻找1957816251
的立方根。程序几乎会瞬间完成。现在试试7406961012236344616
。
正如你所看到的,即使需要数百万次猜测,运行时间通常也不是问题。现代计算机速度惊人。执行一条指令所需的时间不到一纳秒——十亿分之一秒。很难想象这有多快。为了更好地理解,光在一英尺(0.3 米)中传播的时间稍微超过一纳秒。另一种理解方式是,在你声音传播 100 英尺的时间内,现代计算机可以执行数百万条指令。
仅为好玩,尝试执行该代码。
max_val = int(input('Enter a postive integer: '))
i = 0
while i < max_val:
i = i + 1
print(i)
看看你需要输入多大的整数,才能在结果打印之前感知到延迟。
让我们看一个耗尽枚举的另一个例子:测试一个整数是否为质数,并在不是时返回最小的因子。质数是大于 1 的整数,仅能被自身和 1 整除。例如,2、3、5 和 111,119 是质数,而 4、6、8 和 62,710,561 不是质数。
确定一个大于 3 的整数x
是否为质数的最简单方法是将x
除以 2 到x-1
之间的每个整数。如果其中任何一个除法的余数为 0,则x
不是质数,否则x
是质数。图 3-2 中的代码实现了这种方法。它首先要求用户输入一个整数,将返回的字符串转换为int
,并将该整数赋值给变量x
。接着,它通过将guess
初始化为2
和将变量smallest_divisor
初始化为None
来设置耗尽枚举的初始条件——这表明在未证明的情况下,代码假定x
为质数。
耗尽枚举是在for
循环中完成的。当所有可能的x
的整数因子都已尝试过,或者发现一个x
的因子时,循环终止。
退出循环后,代码检查smallest_divisor
的值并打印相应的文本。在进入循环之前初始化变量,然后检查该值在退出时是否已更改是一种常见的技巧。
图 3-2 使用耗尽枚举测试质性。
练习: 修改图 3-2 中的代码,使其返回最大而不是最小的除数。提示:如果y*z = x
且y
是x
的最小除数,z
就是x
的最大除数。
图 3-2 中的代码能够运行,但效率不必要地低。例如,检查大于 2 的偶数是没有必要的,因为如果一个整数能被任何偶数整除,那么它必定能被 2 整除。图 3-3 中的代码利用了这一点,先测试x
是否是偶数。如果不是,它会使用循环测试x
是否能被任何奇数整除。
尽管图 3-3 中的代码比图 3-2 中的代码稍复杂,但它明显更快,因为在循环中检查的数字减少了一半。将代码复杂性与运行效率进行权衡是一个常见现象。但更快并不总意味着更好。简单的代码明显正确,并且仍然足够快以便有用,值得赞美。
图 3-3 更高效的质数测试
练习: 编写一个程序,要求用户输入一个整数,并打印两个整数root
和pwr
,使得1 < pwr < 6
并且root**pwr
等于用户输入的整数。如果不存在这样的整数对,应该打印相应的消息。
练习: 编写一个程序,打印大于 2 且小于 1000 的所有质数的和。提示:你可能想要一个循环,其中包含一个嵌套在循环内的质数测试,迭代 3 到 999 之间的奇数。
3.2 *似解和二分查找
想象一下,有人要求你编写一个程序,打印任何非负数的*方根。你应该怎么做?
你可能应该先说你需要一个更好的问题陈述。例如,如果被要求找到2
的*方根,程序应该怎么做?2
的*方根不是一个有理数。这意味着没有办法精确表示它的值为有限的数字字符串(或作为float
),所以最初陈述的问题无法解决。
程序可以做的事情是找到*方根的*似值——即,接*实际*方根的答案,以便有用。我们稍后将在书中详细讨论这个问题。但现在,让我们把“足够接*”理解为在实际答案的某个常量范围内,称之为epsilon
。
图 3-4 中的代码实现了一个打印x
的*方根*似值的算法。
图 3-4 使用穷举枚举来*似*方根
我们再次使用穷举枚举。请注意,这种寻找*方根的方法与您在中学时学到的用铅笔找*方根的方法没有任何共同之处。计算机解决问题的最佳方法通常与手工解决问题的方法大相径庭。
如果x
是 25,代码将打印
number of guesses = 49990
4.999000000001688 is close to square root of 25
我们应该失望于程序没有识别出25
是一个完全*方并打印5
吗?不。程序做了它应该做的事。虽然打印5
也可以,但这样做与打印任何接*5
的值并没有什么不同。
如果我们设定x = 0.25
,你认为会发生什么?它会找到接*0.5
的根吗?不。可惜,它会报告
number of guesses = 2501
Failed on square root of 0.25
穷举枚举是一种搜索技术,只有在被搜索的值集合中包含答案时才有效。在这种情况下,我们在枚举0
到x
的值。当x
在0
和1
之间时,x
的*方根不在这个区间内。解决这个问题的一种方法是改变while
循环第一行中and
的第二个操作数,以得到
`while abs(ans**2 - x) >= epsilon and ans*ans <= x:`
当我们在这个变化后运行代码时,它报告
0.48989999999996237 is close to square root of 0.25
现在,让我们想想程序运行需要多长时间。迭代次数取决于答案与我们起始点 0 的接*程度,以及步长的大小。粗略来说,程序最多会执行while
循环x/step
次。
让我们尝试在更大的数上运行代码,例如x = 123456
。它将运行一段时间,然后打印
number of guesses = 3513631
Failed on square root of 123456
你认为发生了什么?肯定有一个浮点数可以将123456
的*方根*似到0.01
之内。为什么我们的程序没有找到它?问题在于我们的步长太大,程序跳过了所有合适的答案。我们又一次在一个不包含解的空间中进行穷举搜索。试着将step
设为epsilon**3
并运行程序。它最终会找到一个合适的答案,但你可能没有耐心等待它。
大概需要多少次猜测?步长将是0.000001
,123456
的*方根大约是351.36
。这意味着程序必须进行大约351,000,000
次猜测才能找到满意的答案。我们可以通过更接*答案的起始点来加速,但这假设我们知道答案的邻域。
现在是寻找不同方法解决问题的时候了。我们需要选择一个更好的算法,而不是微调当前的算法。但在这样做之前,让我们看一个乍一看与根寻找完全不同的问题。
考虑发现一个以给定字母序列开头的单词是否出现在英语的纸质字典²²中的问题。理论上,穷举枚举是可行的。你可以从第一个单词开始,检查每个单词,直到找到一个以该字母序列开头的单词,或者检查完所有单词。如果字典包含 n
个单词,*均需要 n/2
次查询才能找到该单词。如果该单词不在字典中,则需要 n
次查询。当然,那些曾经在纸质(而非在线)字典中查找单词的人永远不会以这种方式进行。
幸运的是,出版纸质字典的人会费心将单词按字典顺序排列。这使我们能够打开书本到我们认为该单词可能存在的一页(例如,对于以字母 m 开头的单词,通常是在中间附*)。如果字母序列在页面上第一个单词之前,我们就知道要向后查找。如果字母序列在页面上最后一个单词之后,我们就知道要向前查找。否则,我们检查字母序列是否与页面上的单词匹配。
现在我们来把同样的思路应用于寻找 x 的*方根的问题。假设我们知道 x
的*方根的一个好*似值位于 0
和 max
之间。我们可以利用数字是完全有序的这一事实。也就是说,对于任何一对不同的数字,n1
和 n2
,要么 n1 < n2
,要么 n1 > n2
。因此,我们可以认为 x 的*方根位于以下线段上
0
_________________________________________________________max
然后开始搜索该区间。由于我们不一定知道从哪里开始搜索,让我们从中间开始。
0
guessmax
如果这不是正确的答案(大多数时候都不是),请询问它是太大还是太小。如果太大,我们知道答案必须在左侧。如果太小,我们知道答案必须在右侧。然后我们在更小的区间上重复这个过程。图 3-5 包含了该算法的实现和测试。
图 3-5 使用二分查找来*似*方根
当运行 x
= 25 时,它打印
low = 0.0 high = 25 ans = 12.5
low = 0.0 high = 12.5 ans = 6.25
low = 0.0 high = 6.25 ans = 3.125
low = 3.125 high = 6.25 ans = 4.6875
low = 4.6875 high = 6.25 ans = 5.46875
low = 4.6875 high = 5.46875 ans = 5.078125
low = 4.6875 high = 5.078125 ans = 4.8828125
low = 4.8828125 high = 5.078125 ans = 4.98046875
low = 4.98046875 high = 5.078125 ans = 5.029296875
low = 4.98046875 high = 5.029296875 ans = 5.0048828125
low = 4.98046875 high = 5.0048828125 ans = 4.99267578125
low = 4.99267578125 high = 5.0048828125 ans = 4.998779296875
low = 4.998779296875 high = 5.0048828125 ans = 5.0018310546875
numGuesses = 13
5.00030517578125 is close to square root of 25
请注意,它找到的答案与我们早期的算法不同。这完全没问题,因为它仍然符合问题的规范。
更重要的是,请注意在循环的每次迭代中,要搜索的空间大小减半。因此,该算法被称为二分查找。二分查找相比于我们早期的算法有了巨大的改进,后者在每次迭代中仅减少了少量搜索空间。
让我们再次尝试x = 123456
。这次程序仅需 30 次猜测就能找到一个可接受的答案。x = 123456789
呢?仅需 45 次猜测。
使用该算法来寻找*方根并没有什么特别之处。例如,通过将几个2
改为3
,我们可以用它来*似一个非负数的立方根。在第四章中,我们将介绍一种语言机制,使我们能够将这段代码推广到寻找任何根。
二分搜索是一种广泛适用的技术,除了寻找根以外还可以用于许多其他事情。例如,图 3-6 中的代码使用二分搜索来寻找x
的以 2 为底的对数的*似值(即,一个数字ans
,使得2**ans
接*于x
)。它的结构与用于寻找*方根*似值的代码完全相同。它首先找到一个包含合适答案的区间,然后使用二分搜索高效地探索该区间。
图 3-6 使用二分搜索来估计以 2 为底的对数
二分搜索是逐次逼*方法的一个示例。这种方法通过一系列猜测来工作,每个猜测都比前一个猜测更接*正确答案。我们将在本章稍后讨论一个重要的逐次逼*算法,牛顿法。
动手练习: 如果x = -25
,那么图 3-5 中的代码会做什么?
动手练习: 为了让图 3-5 中的代码能够找到负数和正数的立方根*似值,需要做出什么更改?提示:考虑改变low
以确保答案位于被搜索的区域内。
动手练习: 帝国大厦有 102 层。一名男子想知道他可以从哪个最高楼层扔鸡蛋而不会破。于是他提出从顶楼扔一个鸡蛋。如果鸡蛋破了,他就下降一层,再试一次。他会一直这样做,直到鸡蛋不破。最坏情况下,这种方法需要 102 个鸡蛋。实现一种方法,最多只使用七个鸡蛋。
3.3 关于使用浮点数的几点说明
大多数情况下,float
类型的数字能够 reasonably 地*似真实数字。但“在大多数情况下”并不意味着在所有情况下,当它们不准确时,可能会导致意想不到的后果。例如,尝试运行以下代码:
x = 0.0
for i in range(10):
x = x + 0.1
if x == 1.0:
print(x, '= 1.0')
else:
print(x, 'is not 1.0')
或许你和大多数人一样,发现它打印的结果令人惊讶。
0.9999999999999999 is not 1.0
为什么它首先会进入else
语句呢?
要理解为什么会发生这种情况,我们需要了解在计算过程中浮点数是如何在计算机中表示的。要理解这一点,我们需要了解二进制数。
当你第一次学习十进制数字——即以 10
为底的数字——时,你了解到任何十进制数字可以通过数字 0123456789
的序列表示。最右边的数字是 10
⁰ 位,向左下一个数字是 10
¹ 位,等等。例如,十进制数字序列 302
表示 3
* 100
+ 0
* 10 + 2
* 1
。一个长度为 n
的序列可以表示多少不同的数字?长度为 1
的序列可以表示 10 个数字(0-9
);长度为 2 的序列可以表示 100 个不同的数字(0-99
)。更一般地,长度为 n 的序列可以表示 10
^n 个不同的数字。
二进制数字——以 2
为底的数字——工作原理类似。一个二进制数字由一系列数字表示,每个数字要么是 0
要么是 1
。这些数字通常称为位。最右边的数字是 2
⁰ 位,向左下一个数字是 2
¹ 位,等等。例如,二进制数字序列 101
表示 1
* 4 + 0
* 2 + 1
* 1 = 5
。一个长度为 n 的序列可以表示多少不同的数字? 2
^n。
手指练习: 二进制数 10011
的十进制等值是多少?
也许因为大多数人有十根手指,我们喜欢用十进制表示数字。另一方面,所有现代计算机系统都是用二进制表示数字。这并不是因为计算机天生只有两根手指,而是因为构建只能处于开或关两种状态的硬件开关很简单。计算机使用二进制表示法而人类使用十进制表示法可能会导致偶尔的认知失调。
在现代编程语言中,非整数数字使用一种称为浮点的表示法实现。暂时假设内部表示是十进制。我们可以将数字表示为一对整数——数字的有效数字和一个指数。例如,数字 1.949
可以表示为一对 (1949, -3)
,这表示 1949
* 10
^(-3) 的乘积。
有效数字的数量决定了数字的精度。例如,如果只有两个有效数字,数字 1.949
将无法精确表示。它必须被转换为某种*似值,在这种情况下是 1.9
。这种*似值称为舍入值。
现代计算机使用二进制而不是十进制表示法。它们以二进制而不是十进制表示有效数字和指数,并将 2
而不是 10
提升到指数。例如,十进制数字 0.625
(5/8
)表示为一对 (101, -11)
;因为 101
是数字 5 的二进制表示,-11
是 -3 的二进制表示,所以一对 (101, -11)
表示 5
* 2
^(-3) = 5/8 = 0.625。
那么十进制分数1/10
,我们在 Python 中写作0.1
,最佳的四个有效二进制数字是(0011, -101)
。这相当于3/32
,即0.09375
。如果我们有五个有效二进制数字,我们将0.1
表示为(11001, -1000)
,这相当于25/256
,即0.09765625
。我们需要多少个有效数字才能准确表示0.1
的浮点数?无限多个数字!不存在整数sig
和exp
使得sig
* 2^(-exp)
等于0.1
。因此,无论 Python(或任何其他语言)使用多少位来表示浮点数,它只能表示对0.1
的*似。在大多数 Python 实现中,浮点数可用53
位精度,因此存储的十进制数0.1
的有效数字将是。
11001100110011001100110011001100110011001100110011001
这相当于十进制数。
0.1000000000000000055511151231257827021181583404541015625
非常接*1/10
,但并不完全是1/10
。
回到最初的谜题,为什么。
`x = 0.0 for i in range(10): x = x + 0.1 if x == 1.0: print(x, '= 1.0') else: print(x, 'is not 1.0')`
打印。
0.9999999999999999 is not 1.0
我们现在看到测试`x == 1.0`的结果为`False`,因为`x`绑定的值并不完全等于`1.0`。这解释了为什么执行了`else`分支。但为什么在`0.1`的浮点表示稍大于`0.1`时,系统却认为`x`小于`1.0`?因为在循环的某次迭代中,Python 的有效数字用完了,进行了向下的四舍五入。如果我们在`else`分支末尾添加代码`print x == 10.0*0.1`,会打印出`False`。这与我们小学老师教的不同,但十次加`0.1`的结果并不等于将`0.1`乘以`10`。顺便说一下,如果你想明确地对浮点数进行四舍五入,使用`round`函数。表达式`round(x, num_digits)`返回将`x`的值四舍五入到小数点后`num_digits`位的浮点数。例如,打印`round(2**0.5, 3)`会打印`1.414`,作为`2`的*方根的*似值。 实际数与浮点数之间的差异真的重要吗?大多数情况下,谢天谢地,它并不重要。`0.9999999999999999`、`1.0`和`1.00000000000000001`之间的差异几乎没有影响。然而,有一件事几乎总是值得担心,那就是等式测试。正如我们所见,使用`==`比较两个浮点值可能会产生令人惊讶的结果。询问两个浮点值是否足够接*,几乎总是比询问它们是否相同更合适。因此,例如,写`abs(x‑y) < 0.0001`要比`x == y`更好。 另一个需要担心的问题是舍入误差的积累。大多数时候事情运转良好,因为有时存储在计算机中的数字比预期稍大,有时则稍小。然而,在某些程序中,误差会朝同一方向累积。
``## 3.4 牛顿–拉弗森方法²³ 最常用的*似算法通常归因于艾萨克·牛顿。通常称为牛顿法,有时也称为牛顿–拉弗森方法。²⁴ 它可以用于找到许多函数的实根,但我们只在寻找单变量多项式的实根的背景下讨论它。推广到多个变量的多项式在数学和算法上都是直接的。 一个多项式(按照约定,我们将变量写为*x*
)要么是 0,要么是有限个非零项的和,例如3*x*² + 2*x* + 3
。每一项,例如3*x*²
,由一个常数(这一项的系数,在本例中为3
)乘以变量(在本例中为*x*
),后者的指数为非负整数(在本例中为2
)。项的指数称为该项的次数。多项式的次数是任何单一项的最大次数。一些例子是3
(次数为0
)、2.5*x* + 12
(次数为1
)和3*x*²
(次数为2
)。 如果*p*
是一个多项式,*r*
是一个实数,我们将写*p*(*r*)
表示多项式在*x* = *r*
时的值。多项式*p*
的一个根是方程*p* = 0
的解,即使得*p*(*r*) = 0
的*r*
。例如,寻找24
的*方根的*似值的问题可以表述为寻找一个*x*
,使得*x*² − 24
接*于0
。 牛顿证明了一个定理,意味着如果一个值,称为*guess*
,是多项式的根的*似值,那么*guess* − 𝑝(*guess*)/𝑝’(*guess*)
(其中*p*’
是*p*
的一阶导数)是比*guess*
更好的*似值。 一个函数*f*(*x*)
的一阶导数可以理解为表达*f*(*x*)
的值相对于*x*
的变化。例如,一个常数的一阶导数是0
,因为常数的值不变。对于任何项*c***x^p*
,该项的一阶导数是*c***p***x^p*^(−1)
。因此,形式为 的多项式的一阶导数是 为了找到一个数字的*方根,例如k,我们需要找到一个值x,使得*x*² − *k* = 0
。这个多项式的一阶导数就是2*x*
。因此,我们知道可以通过选择下一个猜测为*guess* − (*guess*² − 𝑘)/2 * *guess*
来改进当前猜测。图 3-7 包含代码,展示如何使用该方法快速找到*方根的*似值。 图 3-7 牛顿–拉弗森方法的实现 **指尖练习:**在牛顿–拉弗森的实现中添加一些代码,以跟踪找到根所用的迭代次数。将该代码作为一个程序的一部分,比较牛顿–拉弗森法与二分搜索的效率。(你会发现牛顿–拉弗森法更高效。) ## 3.5 本章引入的术语 * 递减函数 * 猜测与检查 * 穷举枚举 * *似 * 全序 * 二分搜索 * 逐次*似 * 二进制数 * 位 * 开关 * 浮点数 * 有效数字 * 指数 * 精度 * 舍入 * 牛顿–拉弗森 * 多项式 * 系数 * 次数 * 根```
第四章:函数、作用域和抽象
到目前为止,我们已经介绍了数字、赋值、输入/输出、比较和循环结构。这个 Python 子集有多强大?从理论上讲,它强大到你所需的一切,即它是图灵完备的。这意味着,如果一个问题可以通过计算解决,那么它可以仅仅使用你已经看到的语言机制来解决。
但仅仅因为某件事可以做,并不意味着应该去做!虽然原则上任何计算都可以仅使用这些机制来实现,但这样做非常不实际。在上一章中,我们查看了一个寻找正数*方根*似值的算法,见图 4-1。
图 4-1 使用二分查找法*似计算 x 的*方根
这是一个合理的代码片段,但缺乏通用性。它仅适用于分配给变量x
和epsilon
的值。这意味着如果我们想重用它,就需要复制代码,可能还要编辑变量名,然后粘贴到我们想要的位置。我们不能轻松地在其他更复杂的计算中使用这个计算。此外,如果我们想计算立方根而不是*方根,我们就得编辑代码。如果我们想要一个能够计算*方根和立方根的程序(或者说在两个不同地方计算*方根),程序中将包含多块几乎相同的代码。
图 4-2 将图 4-1 中的代码改编为打印x1
的*方根和x2
的立方根的和。代码可以运行,但看起来不太美观。
图 4-2 *方根和立方根的和
程序包含的代码越多,出错的机会就越大,代码的维护也越困难。例如,想象一下,如果二分查找法的初始实现存在错误,并且在测试程序时发现了这个错误,那么很容易在一个地方修复实现,而忽视了其他需要修复的类似代码。
幸运的是,Python 提供了几种语言特性,使得泛化和重用代码相对容易。最重要的是函数。
4.1 函数与作用域
我们已经使用了多个内置函数,例如在图 4-1 中使用的max
和abs
。程序员能够定义并像内置函数一样使用自己的函数,这在便利性上是一个质的飞跃。
4.1.1 函数定义
在 Python 中,每个函数定义的形式是²⁵
def *name of function* (*list of formal parameters*):
*body of function*
例如,我们可以通过代码定义函数max_val
²⁶
def max_val(x, y):
if x > y:
return x
else:
return y
def
是一个保留字,告诉 Python 即将定义一个函数。函数名(在此示例中为max_val
)只是一个用于引用函数的名称。PEP 8 规范要求函数名应全小写,单词用下划线分隔,以提高可读性。
函数名后括号中的名称序列(在此示例中为x
,y
)是函数的形式参数。当使用函数时,形式参数被绑定(如同赋值语句)到实际参数(通常称为实参)的函数调用(也称为函数调用)。例如,该调用
`max_val(3, 4)`
将x
绑定到3
,y
绑定到4
。
函数体是任何 Python 代码的片段。²⁷ 然而,有一个特殊语句**return**
,只能在函数体内使用。
函数调用是一个表达式,像所有表达式一样,它有一个值。该值由被调用的函数返回。例如,表达式max_val(3,4)*max_val(3,2)
的值是12
,因为第一次调用max_val
返回int
4
,第二次返回int
3
。请注意,执行return
语句会终止函数的调用。
概括地说,当调用函数时
1. 组成实际参数的表达式被求值,函数的形式参数被绑定到结果值。例如,调用
max_val(3+4, z)
将把形式参数x
绑定到7
,将形式参数y
绑定到调用执行时变量z
的值。2. 执行点(下一条要执行的指令)从调用点移动到函数体中的第一条语句。
3. 函数体中的代码会执行,直到遇到
return
语句,在这种情况下,return
后面表达式的值成为函数调用的值;如果没有更多语句可执行,函数将返回值None
。(如果return
后没有表达式,调用的值为None
。)²⁸4. 调用的值是返回的值。
5. 执行点被转移回紧接在调用后面的代码。
参数使程序员能够编写访问特定对象的代码,而是访问调用函数时选择用作实际参数的对象。这被称为lambda 抽象。²⁹
图 4-3 包含一个有三个形式参数并返回一个值的函数,称其为result
,使得abs(result**power – x) >= epsilon
。
图 4-3 一个寻找根的函数
图 4-4 包含可用于测试 find_root
是否按预期工作的代码。测试函数 test_find_root
的长度与 find_root
本身大致相同。对于缺乏经验的程序员来说,编写 测试函数 通常看起来是一种浪费精力。然而,经验丰富的程序员知道,编写测试代码的投资往往会带来丰厚的回报。这绝对胜过在键盘前反复输入测试用例到 shell 中进行 调试(找出程序为何不工作并修复的过程)。请注意,由于我们使用三个长度为三的元组调用 test_find_root
,一次调用检查 27 种参数组合。最后,因为 test_find_root
检查 find_root
是否返回合适的答案并报告结果,它使程序员免于逐个检查每个输出并验证其正确性的乏味且容易出错的任务。我们将在第八章回到测试的主题。
手指练习: 使用 图 4-3 中的 find_root
函数打印 25 的*方根、-8 的立方根和 16 的四次根的*似值之和。使用 0.001 作为 epsilon。
手指练习: 编写一个函数 is_in
,接受两个字符串作为参数,如果其中一个字符串在另一个字符串中出现,则返回 True,否则返回 False。提示:你可能想使用内置的 str
操作符 in
。
手指练习: 编写一个函数来测试 is_in
。
图 4-4 测试 find_root
的代码
4.1.2 关键字参数和默认值
在 Python 中,正式参数绑定到实际参数有两种方式。最常见的方法是 位置参数——第一个正式参数绑定到第一个实际参数,第二个正式参数绑定到第二个实际参数,依此类推。Python 还支持 关键字参数,其中正式参数通过其名称绑定到实际参数。考虑以下函数定义
def print_name(first_name, last_name, reverse):
if reverse:
print(last_name + ', ' + first_name)
else:
print(first_name, last_name)
函数 print_name
假设 first_name
和 last_name
是字符串,reverse
是布尔值。如果 reverse == True
,它打印 last_name,
first_name
;否则,它打印 first_name last_name
。
以下每个都是 print_name
的等效调用:
print_name('Olga', 'Puchmajerova', False)
print_name('Olga', 'Puchmajerova', reverse = False)
print_name('Olga', last_name = 'Puchmajerova', reverse = False)
print_name(last_name = 'Puchmajerova', first_name = 'Olga',
reverse = False)
尽管关键字参数可以在实际参数列表中以任何顺序出现,但在关键字参数后跟非关键字参数是非法的。因此,会产生错误消息
print_name('Olga', last_name = 'Puchmajerova', False)
关键字参数通常与 默认参数值 一起使用。例如,我们可以写
def print_name(first_name, last_name, reverse = False):
if reverse:
print(last_name + ', ' + first_name)
else:
print(first_name, last_name)
默认值允许程序员用少于指定数量的参数调用函数。例如,
print_name('Olga', 'Puchmajerova')
print_name('Olga', 'Puchmajerova', True)
print_name('Olga', 'Puchmajerova', reverse = True)
将打印
Olga Puchmajerova
Puchmajerova, Olga
Puchmajerova, Olga
最后两次调用 print_name
在语义上是等价的。最后一次调用的优势在于为可能神秘的参数 True
提供了一些文档。更一般地说,使用关键字参数降低了将实际参数错误绑定到形式参数的风险。这行代码
print_name(last_name = 'Puchmajerova', first_name = 'Olga')
不会对编写它的程序员的意图产生歧义。这是有用的,因为以错误的顺序调用函数并传递正确的参数是一种常见错误。
默认参数关联的值是在函数定义时计算的。这可能导致程序行为出人意料,正如我们在第 5.3 节中讨论的那样。
手指练习: 编写一个函数 mult
,接受一个或两个整数作为参数。如果调用时传入两个参数,函数将打印两个参数的乘积;如果调用时传入一个参数,则打印该参数。
4.1.3 可变数量的参数
Python 有许多内置函数可以处理可变数量的参数。例如,
min(6,4)
min(3,4,1,6)
两者都是合法的(并且评估的结果符合你的预期)。Python 使程序员可以轻松定义接受可变数量参数的函数。拆包运算符 *
允许函数接受可变数量的位置参数。例如,
def mean(*args):
# Assumes at least one argument and all arguments are numbers
# Returns the mean of the arguments
tot = 0
for a in args:
tot += a
return tot/len(args)
打印 1.5 -1.0
。请注意,参数列表中跟在 *
后面的名称不必是 args
,可以是任何名称。对于 mean
,更具描述性的写法可能是 def mean(*numbers)
。
4.1.4 作用域
我们来看另一个小例子:
def f(x): #name x used as formal parameter
y = 1
x = x + y
print('x =', x)
return x
x = 3
y = 2
z = f(x) #value of x used as actual parameter
print('z =', z)
print('x =', x)
print('y =', y)
运行时,这段代码打印
x = 4
z = 4
x = 3
y = 2
这里发生了什么?在调用 f
时,形式参数 x
在 f
的函数体上下文中被局部绑定到实际参数 x
的值。尽管实际参数和形式参数同名,但它们不是同一个变量。每个函数定义了一个新的名称空间,也称为作用域。在 f
中使用的形式参数 x
和局部变量 y
仅在 f
的定义范围内存在。函数体内的赋值语句 x = x + y
将局部名称 x
绑定到对象 4
。在 f
中的赋值对 f
作用域外存在的名称 x
和 y
的绑定没有影响。
这里有一种思考方式:
1. 在顶层,即 shell 的级别,符号表 跟踪在该级别定义的所有名称及其当前绑定。
2. 当函数被调用时,会创建一个新的符号表(通常称为栈帧)。这个表跟踪在函数内定义的所有名称(包括形式参数)及其当前绑定。如果在函数体内再次调用该函数,则会创建另一个栈帧。
3. 当函数完成时,其栈帧会消失。
在 Python 中,你可以通过查看程序文本来确定名称的作用域。这被称为静态或词法作用域。图 4-5 包含一个示例,说明了 Python 的作用域规则。与代码相关的栈帧历史在图 4-6 中描绘。
图 4-5 嵌套作用域
图 4-6 中的第一列包含函数f
体外已知的名称集合,即变量x
和z
以及函数名称f
。第一个赋值语句将x
绑定到3
。
图 4-6 栈帧
赋值语句z = f(x)
首先通过调用函数f
并传入x
绑定的值来计算表达式f(x)
。当进入f
时,创建了一个栈帧,如第 2 列所示。栈帧中的名称有x
(形式参数x
,而不是调用上下文中的x
)、g
和h
。变量g
和h
绑定到类型为function
的对象。这些函数的属性由f
内部的函数定义给出。
当从f
内部调用h
时,又创建了一个栈帧,如第 3 列所示。此帧仅包含局部变量z
。为什么它不包含x
呢?只有当名称是函数的形式参数或绑定到函数体内对象的变量时,才会将名称添加到与函数关联的作用域中。在h
的主体内,x
仅出现在赋值语句的右侧。一个名称(在这种情况下是x
)出现在函数体(在这种情况下是h
)中但未绑定到任何对象,导致解释器搜索与函数定义相关的作用域的栈帧(与f
相关的栈帧)。如果找到了名称(在这种情况下找到了),则使用其绑定的值(4
)。如果没有找到,则会产生错误信息。
当h
返回时,与h
调用相关的栈帧消失(它从栈顶弹出),如第 4 列所示。请注意,我们从不移除栈中间的帧,而只移除最新添加的帧。由于这种“后进先出”(LIFO)的行为,我们称其为栈。(想象一下煎一叠煎饼。当第一个煎饼从*底锅上取下时,厨师将其放在一个餐盘上。随着每个后续煎饼从*底锅上取下,它被叠放在已经在餐盘上的煎饼上。当要吃煎饼时,首先上桌的煎饼是叠在最上面的,即最后一个放入的煎饼——这使得倒数第二个放入的煎饼成为新的顶部煎饼,接下来要上桌的煎饼。)
回到我们的 Python 示例,g
现在被调用,并添加一个包含g
的局部变量x
的栈帧(列 5)。当g
返回时,该帧被弹出(列 6)。当f
返回时,包含与f
相关名称的栈帧被弹出,使我们回到原始栈帧(列7
)。
请注意,当f
返回时,尽管变量g
不再存在,曾经与该名称绑定的function
类型的对象仍然存在。这是因为函数是对象,可以像其他任何类型的对象一样被返回。因此,z
可以绑定到f
返回的值,函数调用z()
可以用来调用在f
中与名称g
绑定的函数——即使名称g
在f
的外部上下文中是未知的。
那么,图 4-5 中的代码打印了什么?它打印了
x = 4
z = 4
x = abc
x = 4
x = 3
z = <function f.<locals>.g at 0x1092a7510>
x = abc
对名称的引用顺序并不重要。如果在函数体内的任何地方将一个对象绑定到一个名称(即使它出现在赋值的左侧之前的表达式中),它被视为该函数的局部。³⁰ 请考虑以下代码
def f():
print(x)
def g():
print(x)
x = 1
x = 3
f()
x = 3
g()
当调用f
时,它打印3
,但错误信息
UnboundLocalError: local variable 'x' referenced before assignment
当遇到g
中的打印语句时会打印出来。这是因为print
语句后面的赋值语句使得x
变成了g
的局部变量。而且因为x
是g
的局部变量,所以在执行print
语句时没有值。
还感到困惑吗?大多数人需要一点时间来理解作用域规则。不要让这困扰你。现在,继续前进,开始使用函数。大多数时候,你只想使用局部于函数的变量,而作用域的细微差别将无关紧要。事实上,如果你的程序依赖于一些微妙的作用域规则,你可能会考虑重写以避免这样做。
4.2 规范
一个规范定义了函数的实现者与将编写使用该函数的程序的人之间的契约。我们将函数的用户称为其客户。这个契约可以被视为包含两个部分:
假设:这些描述了函数的客户必须满足的条件。通常,它们描述了对实际参数的约束。几乎总是,它们指定了每个参数的可接受类型集,并且不时对一个或多个参数的值施加一些约束。例如,
find_root
的规范可能要求power
为正整数。保证:这些描述了在满足假设的情况下,函数必须满足的条件。例如,
find_root
的规范可能保证,如果被要求找到不存在的根(例如,负数的*方根),它将返回None
。
函数是一种创建计算元素的方法,我们可以将其视为原始元素。它们提供了分解和抽象。
分解创造结构。它允许我们将程序分解为合理自-contained 的部分,并可以在不同环境中重复使用。
抽象隐藏了细节。它允许我们像使用一个黑箱一样使用一段代码——即,我们看不到、也不需要看、甚至不应该想要看其内部细节。³¹ 抽象的本质是在特定上下文中保留相关信息,并遗忘该上下文中不相关的信息。在编程中有效使用抽象的关键是找到一个适合抽象构建者和潜在抽象客户端的相关性概念。这就是编程的真正艺术。
抽象就是关于遗忘的。有很多方法可以对其建模,例如,大多数青少年的听觉系统。
青少年说:我今晚可以借车吗?
父母说:是的,但要在午夜之前回来,并确保油箱是满的。
青少年听到:是的。
青少年忽略了所有他或她认为无关紧要的琐碎细节。抽象是一个多对一的过程。如果父母说“是的,但要在凌晨 2 点之前回来,并确保车子干净”,这也会被抽象为“是的”。
通过类比,想象一下你被要求制作一个包含 25 节课的计算机科学入门课程。一种方法是招募 25 位教授,让每位教授准备一个小时的讲座,讲述他们最喜欢的话题。尽管你可能会得到 25 个精彩的小时,但整个课程可能会让人感觉像是皮兰德罗的寻找作者的六个角色(或你参加的那门有 15 位客座讲师的政治科学课程)。如果每位教授独立工作,他们就无法将自己讲座中的材料与其他讲座中的材料联系起来。
不知怎么的,你需要让每个人知道其他人正在做什么,而不产生太多的工作让没人愿意参与。这就是抽象的作用。你可以写 25 个规格,每个规格说明学生在每节课上应该学习什么材料,但不提供关于如何教授这些材料的任何细节。你得到的可能在教育上并不完美,但至少可能是有意义的。
这是组织使用程序员团队完成任务的方式。给定一个模块的规格,程序员可以在实现该模块时无需担心团队中其他程序员在做什么。此外,其他程序员可以利用该规格开始编写使用该模块的代码,而不必担心模块将如何实现。
图 4-7 为 图 4-3 中的 find_root
实现添加了一个规范。
图 4-7 带有规范的函数定义。
三重引号之间的文本在 Python 中称为 docstring。按照惯例,Python 程序员使用 docstring 提供函数的规范。这些 docstring 可以使用内置函数 **help**
进行访问。
Python IDEs 的一个优点是它们提供了一个交互式工具,用于查询内置对象。如果你想知道某个特定函数的功能,只需在控制台窗口中输入 help(*object*)
。例如,输入 help(abs)
会产生以下文本。
Help on built-in function abs in module builtins:
abs(x, /)
Return the absolute value of the argument.
这告诉我们 abs
是一个将单个参数映射到其绝对值的函数。(参数列表中的 /
意味着该参数必须是位置参数。)如果你输入 help()
,会启动一个交互式帮助会话,解释器会在控制台窗口中显示提示符 help>
。交互模式的一个优点是,你可以获得有关不是对象的 Python 构造的帮助。例如,
help> if
The "if" statement
******************
The "if" statement is used for conditional execution:
if_stmt ::= "if" expression ":" suite
("elif" expression ":" suite)*
["else" ":" suite]
It selects exactly one of the suites by evaluating the expressions one
by one until one is found to be true (see section Boolean operations
for the definition of true and false); then that suite is executed
(and no other part of the "if" statement is executed or evaluated).
If all expressions are false, the suite of the "else" clause, if
present, is executed.
Related help topics: TRUTHVALUE
可以通过输入 quit
退出交互帮助。
如果 图 4-4 中的代码已加载到 IDE 中,在 shell 中输入 help(find_root)
将显示
find_root(x, power, epsilon)
Assumes x and epsilon int or float, power an int,
epsilon > 0 & power >= 1
Returns float y such that y**power is within epsilon of x.
If such a float does not exist, it returns None
find_root
的规范是所有可能满足该规范的实现的抽象。find_root
的客户端可以假设实现符合规范,但不应假设其他内容。例如,客户端可以假设调用 find_root(4, 2, 0.01)
返回的某个值的*方在 3.99
和 4.01
之间。返回的值可以是正数或负数,即使 4
是一个完全*方,返回的值也可能不是 2
或 -2
。关键是,如果未满足规范的假设,则无法对调用函数的效果做出任何假设。例如,调用 find_root(8, 3, 0)
可能返回 2
,但也可能崩溃、无限运行,或者返回与 8 的立方根相去甚远的某个数字。
指尖练习: 使用图 3-6 的算法,编写一个满足规范的函数。
def log(x, base, epsilon):
"""Assumes x and epsilon int or float, base an int,
x > 1, epsilon > 0 & power >= 1
Returns float y such that base**y is within epsilon of x."""
4.3 使用函数模块化代码。
到目前为止,我们实现的所有函数都很小,适合单页展示。随着我们实现更复杂的功能,将函数拆分为多个执行单一简单任务的函数是很方便的。为了说明这个想法,我们稍微多余地将 find_root
拆分为三个独立的函数,如 图 4-8 所示。每个函数都有自己的规范,并且每个函数作为独立实体是有意义的。函数 find_root_bounds
查找根必须位于的区间,bisection_solve
使用二分搜索在此区间中寻找根的*似值,而 find_root
则简单地调用其他两个函数并返回根。
这个版本的 find_root
比原始单体实现更容易理解吗?可能不是。一个好的经验法则是,如果一个函数可以舒适地放在单页上,它可能不需要被细分以便于理解。
图 4-8 将 find_root 拆分为多个函数
4.4 函数作为对象
在 Python 中,函数是 一等对象。这意味着它们可以像其他类型的对象一样被处理,例如 int
或 list
。它们有类型,例如,表达式 type(abs)
的值为 <type 'built-in_function_or_method'>
;它们可以出现在表达式中,例如,作为赋值语句的右侧或作为函数的参数;它们可以由函数返回;等等。
使用函数作为参数允许一种称为 高阶编程 的编码风格。它使我们能够编写更具通用性的函数。例如,图 4-8 中的 bisection_solve
函数可以重写,以便应用于根以外的任务,如 图 4-9 所示。
图 4-9 概括 bisection_solve
我们开始将整数参数 power
替换为一个函数 eval_ans
,该函数将浮点数映射到浮点数。然后,我们将表达式 ans**power
的每个实例替换为函数调用 eval_ans(ans)
。
如果我们想使用新的 bisection_solve
打印出 99 的*方根*似值,我们可以运行代码
def square(ans):
return ans**2
low, high = find_root_bounds(99, 2)
print(bisection_solve(99, square, 0.01, low, high))
为了简单地定义一个函数来计算*方,似乎有些多余。幸运的是,Python 支持使用保留字 lambda
创建匿名函数(即不绑定名称的函数)。lambda 表达式的一般形式是
lambda *sequence of variable names* : *expression*
例如,lambda 表达式 lambda x, y: x*y
返回一个返回其两个参数乘积的函数。lambda 表达式常作为高阶函数的参数使用。比如,我们可以用
print(bisection_solve(99, lambda ans: ans**2, 0.01, low, high))
练习: 编写一个具有两个数值参数的 lambda 表达式。如果第二个参数为零,它应该返回 None
。否则,它应该返回第一个参数除以第二个参数的值。提示:使用条件表达式。
由于函数是一等对象,它们可以在函数内创建和返回。例如,给定函数定义
def create_eval_ans():
power = input('Enter a positive integer: ')
return lambda ans: ans**int(power)
代码
eval_ans = create_eval_ans()
print(bisection_solve(99, eval_ans, 0.01, low, high))
将打印 99 的 n^(th) 根的*似值,其中 n 是用户输入的数字。
我们对bisection_solve
的概括意味着它现在不仅可以用来搜索根的逼*值,还可以搜索任何将浮点数映射到浮点数的单调³²函数的逼*值。例如,图 4-10 中的代码使用bisection_solve
找到对数的逼*值。
图 4-10 使用bisection_solve
来逼*对数
请注意,log
的实现包含了一个局部函数find_log_bounds
的定义。这个函数本可以在log
之外定义,但由于我们不期望在其他上下文中使用它,因此不这样做似乎更好。
4.5 方法,简单化
方法是类似函数的对象。它们可以带参数调用,可以返回值,并且可以具有副作用。它们在一些重要方面与函数有所不同,我们将在第十章中讨论。
目前,可以将方法视为为函数调用提供一种特殊语法。我们使用点表示法将第一个参数放在函数名称之前,而不是将其放在括号内。我们在这里引入方法,因为许多内置类型上的有用操作都是方法,因此通过点表示法调用。例如,如果s
是一个字符串,可以使用find
方法找到子字符串在s
中第一次出现的索引。因此,如果s
是'abcbc'
,调用s.find('bc')
将返回1
。尝试将find
视为函数,例如调用find(s,'bc')
,会产生错误信息NameError: name 'find' is not defined
。
指尖练习: 如果sub
在s
中不存在,s.find(sub)
会返回什么?
指尖练习: 使用find
实现一个满足规范的函数
def find_last(s, sub):
"""s and sub are non-empty strings
Returns the index of the last occurrence of sub in s.
Returns None if sub does not occur in s"""
4.6 在章节中引入的术语
函数定义
形式参数
实际参数
参数
函数调用
返回语句
执行点
lambda 抽象
测试函数
调试
位置参数
关键字参数
默认参数值
解包运算符 (*)
名称空间
范围
局部变量
符号表
栈帧
静态(词法)作用域
栈(后进先出)
规范
客户端
假设
保证
分解
抽象
文档字符串
帮助函数
一等对象
高阶编程
lambda 表达式
方法
点表示法
第五章:结构化类型与可变性
到目前为止,我们所查看的程序处理了三种类型的对象:int
、float
和str
。数值类型int
和float
是标量类型。也就是说,这些类型的对象没有可访问的内部结构。相比之下,str
可以被认为是结构化或非标量类型。我们可以使用索引提取字符串中的单个字符,并使用切片提取子字符串。
在本章中,我们介绍四种额外的结构化类型。其中,tuple
是对str
的简单概括。其他三种——list
、range
和dict
——则更有趣。我们还将回到高阶编程的话题,通过一些示例说明能够以与其他类型的对象相同的方式对待函数的实用性。
5.1 元组
像字符串一样,元组是不可变的有序元素序列。不同之处在于,元组的元素不一定是字符。单个元素可以是任何类型,且不必彼此具有相同类型。
类型为tuple
的字面量是通过将用逗号分隔的元素列表括在圆括号中来书写的。例如,我们可以写
t1 = ()
t2 = (1, ‘two', 3)
print(t1)
print(t2)
不出所料,print
语句产生的输出是
()
(1, ‘two', 3)
看这个例子,你可能会认为包含单一值1
的元组应该写作(1)
。但是,引用 H.R. Haldeman 引用理查德·尼克松的话,“这样做是错误的。” ³³ 由于圆括号用于分组表达式,(1)
仅仅是写整数1
的一种冗长方式。为了表示包含此值的单例元组,我们写作(1,)
。几乎所有使用 Python 的人都有过一次或多次不小心省略那个恼人的逗号的经历。
可以对元组使用重复操作。例如,表达式3*('a', 2)
的值为('a', 2, 'a', 2, 'a', 2)
。
像字符串一样,元组可以连接、索引和切片。考虑一下
t1 = (1, ‘two', 3)
t2 = (t1, 3.25)
print(t2)
print((t1 + t2))
print((t1 + t2)[3])
print((t1 + t2)[2:5])
第二个赋值语句将名称t2
绑定到一个包含t1
所绑定的元组和浮点数3.25
的元组。这是可能的,因为元组和 Python 中的其他一切一样,都是一个对象,所以元组可以包含元组。因此,第一个print
语句产生的输出是,
((1, ‘two', 3), 3.25)
第二个print
语句打印由绑定到t1
和t2
的值连接生成的值,这是一个包含五个元素的元组。它产生的输出是
(1, ‘two', 3, (1, ‘two', 3), 3.25)
下一个语句选择并打印连接后的元组的第四个元素(与往常一样,在 Python 中,索引从0
开始),紧接着的语句创建并打印该元组的切片,产生的输出是
(1, ‘two', 3)
(3, (1, ‘two', 3), 3.25)
可以使用for
语句遍历元组的元素。并且可以使用in
运算符测试一个元组是否包含特定值。例如,以下代码
def intersect(t1, t2):
"""Assumes t1 and t2 are tuples
Returns a tuple containing elements that are in
both t1 and t2"""
result = ()
for e in t1:
if e in t2:
result += (e,)
return result
print(intersect((1, 'a', 2), ('b', 2, 'a')))
打印('a', 2)
。
5.1.1 多重赋值
如果你知道序列的长度(例如,元组或字符串),使用 Python 的多重赋值语句提取单独元素会很方便。例如,语句x, y = (3, 4)
会将x
绑定为3
,将y
绑定为4
。类似地,语句a, b, c = 'xyz'
会将a
绑定为'x'
,b
绑定为'y'
,c
绑定为'z'
。
当与返回多个值的函数一起使用时,这种机制特别方便。考虑函数定义
def find_extreme_divisors(n1, n2):
"""Assumes that n1 and n2 are positive ints
Returns a tuple containing the smallest common divisor > 1 and
the largest common divisor of n1 & n2\. If no common divisor,
other than 1, returns (None, None)"""
min_val, max_val = None, None
for i in range(2, min(n1, n2) + 1):
if n1%i == 0 and n2%i == 0:
if min_val == None:
min_val = i
max_val = i
return min_val, max_val
多重赋值语句
min_divisor, max_divisor = find_extreme_divisors(100, 200)
将min_divisor
绑定为2
,将max_divisor
绑定为200
。
5.2 范围与可迭代对象
如第 2.6 节所述,函数range
生成range
类型的对象。与字符串和元组一样,range
类型的对象是不可变的。所有元组上的操作也适用于范围,除了连接和重复。例如,range(10)[2:6][2]
的结果是4
。当使用==
运算符比较range
类型的对象时,如果两个范围表示相同的整数序列,则返回True
。例如,range(0, 7, 2) == range(0, 8, 2)
的结果为True
。然而,range(0, 7, 2) == range(6, -1, -2)
的结果为False
,因为虽然两个范围包含相同的整数,但它们的顺序不同。
与tuple
类型的对象不同,range
类型的对象所占用的空间与其长度无关。因为一个范围完全由其起始、结束和步长值定义,所以它可以存储在较小的空间中。
range
最常见的用法是在for
循环中,但range
类型的对象可以在任何可以使用整数序列的地方使用。
在 Python 3 中,range
是可迭代 对象的特例。所有可迭代类型都有一个方法,³⁴__iter__
,返回类型迭代器的对象。然后,可以在for
循环中使用该迭代器逐个返回对象序列。例如,元组是可迭代的,for
语句
for elem in (1, 'a', 2, (3, 4)):
创建一个迭代器,逐个返回元组的元素。Python 有许多内置的可迭代类型,包括字符串、列表和字典。
许多有用的内置函数可以作用于可迭代对象。其中一些更有用的包括sum
、min
和max
。函数sum
可以应用于数字的可迭代对象,返回元素的总和。函数max
和min
可以应用于有明确定义的元素顺序的可迭代对象。
指尖练习: 写一个表达式,计算一个数字元组的均值。使用函数sum
。
5.3 列表与可变性
像元组一样,列表是值的有序序列,每个值由索引标识。list
类型的文字表达语法类似于元组;不同之处在于我们使用方括号而不是圆括号。空列表写作[]
,单例列表则写作不带(很容易忘记的)逗号的形式。
由于列表是可迭代的,我们可以使用for
语句来迭代列表中的元素。因此,例如,代码
L = ['I did it all', 4, 'love']
for e in L:
print(e)
产生的输出,
I did it all
4
Love
我们也可以像对元组一样对列表进行索引和切片。例如,代码
`L1 = [1, 2, 3] L2 = L1[-1::-1] for i in range(len(L1)): print(L1[i]*L2[i])`
打印
3
4
3
用方括号执行三种不同的用途(list
类型的文字、对可迭代对象的索引和对可迭代对象的切片)可能导致视觉上的混淆。例如,表达式[1,2,3,4][1:3][1]
,它的值为3
,在三种方式中使用了方括号。这在实践中很少成为问题,因为大多数时候,列表是逐步构建的,而不是作为文字书写的。
列表与元组在一个重要方面不同:列表是可变的。相反,元组和字符串是不可变的。许多运算符可用于创建不可变类型的对象,并且变量可以绑定到这些类型的对象。但不可变类型的对象在创建后无法被修改。另一方面,可变类型的对象在创建后可以被修改。
修改对象和将对象赋值给变量之间的区别在开始时可能看起来微妙。然而,如果你不断重复这个咒语:“在 Python 中,变量仅仅是一个名称,即可以附加到对象上的标签,”这会带来清晰的理解。或许下面的一组示例也会有所帮助。
当这些语句
Techs = ['MIT', 'Caltech']
Ivys = ['Harvard', 'Yale', 'Brown']
当这些语句执行时,解释器创建两个新列表并将适当的变量绑定到它们,如图 5-1 所示。
图 5-1 两个列表
赋值语句
Univs = [Techs, Ivys]
Univs1 = [['MIT', 'Caltech'], ['Harvard', 'Yale', 'Brown']]
还可以创建新列表并将变量绑定到它们。这些列表的元素本身也是列表。三个打印语句
print('Univs =', Univs)
print('Univs1 =', Univs1)
print(Univs == Univs1)
产生的输出
Univs = [['MIT', 'Caltech'], ['Harvard', 'Yale', 'Brown']]
Univs1 = [['MIT', 'Caltech'], ['Harvard', 'Yale', 'Brown']]
True
看起来好像Univs
和Univs1
绑定到相同的值。但表象可能会欺骗。如图 5-2 所示,Univs
和Univs1
绑定到完全不同的值。
图 5-2 两个看似具有相同值但实际上不相同的列表
Univs
和Univs1
绑定到不同对象可以通过内置 Python 函数**id**
验证,该函数返回对象的唯一整数标识符。这个函数使我们能够通过比较它们的 id 来测试对象相等性。测试对象相等性的更简单方法是使用**is**
运算符。当我们运行代码时
print(Univs == Univs1) #test value equality
print(id(Univs) == id(Univs1)) #test object equality
print(Univs is Univs1) #test object equality
print('Id of Univs =', id(Univs))
print('Id of Univs1 =', id(Univs1))
打印
True
False
False
Id of Univs = 4946827936
Id of Univs1 = 4946612464
(如果你运行这段代码,不要指望看到相同的唯一标识符。Python 的语义并未说明每个对象关联的标识符是什么;它仅要求没有两个对象具有相同的标识符。)
请注意,在图 5-2 中,Univs
的元素不是与Techs
和Ivys
绑定的列表的副本,而是这些列表本身。Univs1
的元素是包含与Univs
中列表相同元素的列表,但它们不是相同的列表。我们可以通过运行代码来看到这一点
print('Ids of Univs[0] and Univs[1]', id(Univs[0]), id(Univs[1]))
print('Ids of Univs1[0] and Univs1[1]', id(Univs1[0]), id(Univs1[1]))
其打印结果为
Ids of Univs[0] and Univs[1] 4447807688 4456134664
Ids of Univs1[0] and Univs1[1] 4447805768 4447806728
为什么值与对象相等之间的差异会引起如此大的关注?这很重要,因为列表是可变的。考虑以下代码
Techs.append('RPI')
列表的append
方法有一个副作用。它不是创建一个新列表,而是通过在现有列表Techs
的末尾添加一个新元素(本例中是字符串'RPI'
)来修改现有列表。图 5-3 描绘了执行append
后计算的状态。
图 5-3 可变性的演示
与Univs
绑定的对象仍然包含相同的两个列表,但其中一个列表的内容已被更改。因此,print
语句
print('Univs =', Univs)
print('Univs1 =', Univs1)
现在生成的输出是
Univs = [['MIT', 'Caltech', 'RPI'], ['Harvard', 'Yale', 'Brown']]
Univs1 = [['MIT', 'Caltech'], ['Harvard', 'Yale', 'Brown']]
我们在这里看到的叫做别名。有两条不同的路径指向同一个列表对象。一条路径通过变量Techs
,另一条通过与Univs
绑定的list
对象的第一个元素。我们可以通过任一条路径修改对象,并且修改的效果将通过两条路径可见。这可能很方便,但也可能是危险的。无意中的别名会导致编程错误,这些错误往往非常难以追踪。例如,你认为以下代码打印什么
L1 = [[]]*2
L2 = [[], []]
for i in range(len(L1)):
L1[i].append(i)
L2[i].append(i)
print('L1 =', L1, 'but', 'L2 =', L2)
它打印出L1 = [[0, 1], [0, 1]]
但L2 = [[0], [1]]
。为什么?因为第一个赋值语句创建了一个包含两个元素的列表,而这两个元素是同一个对象,而第二个赋值语句创建了一个包含两个不同对象的列表,这两个对象最初都是空列表。
手指练习: 以下代码打印什么?
`L = [1, 2, 3] L.append(L) print(L is L[-1])`
别名和可变性与默认参数值的相互作用是需要注意的事项。考虑以下代码
def append_val(val, list_1 = []):
List_1.append(val)
print(list_1)
append_val(3)
append_val(4)
你可能认为第二次调用append_val
会打印出列表[4]
,因为它会将4
添加到空列表中。实际上,它将打印[3, 4]
。这是因为在函数定义时,创建了一个新类型为list
的对象,初始值为空列表。每次调用append_val
而不提供正式参数list_1
的值时,函数定义时创建的对象会绑定到list_1
,被修改,然后被打印。因此,第二次调用append_val
会修改并打印已经被第一次调用该函数修改过的列表。
当我们将一个列表附加到另一个列表时,例如Techs.append(Ivys)
,原始结构保持不变。结果是一个包含列表的列表。假设我们不想保持这种结构,而是想将一个列表的元素添加到另一个列表中。我们可以使用列表连接(使用+
运算符)或extend
方法来做到这一点,例如,
L1 = [1,2,3]
L2 = [4,5,6]
L3 = L1 + L2
print('L3 =', L3)
L1.extend(L2)
print('L1 =', L1)
L1.append(L2)
print('L1 =', L1)
将打印
L3 = [1, 2, 3, 4, 5, 6]
L1 = [1, 2, 3, 4, 5, 6]
L1 = [1, 2, 3, 4, 5, 6, [4, 5, 6]]
请注意,运算符+
没有副作用。它创建一个新列表并返回它。相反,extend
和append
都会改变L1
。
图 5-4 简要描述了一些与列表相关的方法。请注意,除了count
和index
,所有这些方法都会改变列表。
图 5-4 与列表相关的常见方法
5.3.1 克隆
通常,避免在迭代时改变一个列表是明智的。考虑以下代码
def remove_dups(L1, L2):
"""Assumes that L1 and L2 are lists.
Removes any element from L1 that also occurs in L2"""
for e1 in L1:
if e1 in L2:
L1.remove(e1)
L1 = [1,2,3,4]
L2 = [1,2,5,6]
Remove_dups(L1, L2)
print('L1 =', L1)
你可能会惊讶地发现这会打印
L1 = [2, 3, 4]
在for
循环期间,Python 使用一个内部计数器跟踪当前在列表中的位置,该计数器在每次迭代结束时递增。当计数器的值达到列表的当前长度时,循环终止。如果在循环中列表没有被改变,这样的行为是可以预期的,但如果列表被改变,可能会有意想不到的后果。在这种情况下,隐藏的计数器初始值为0
,发现L1[0]
在L2
中,并将其移除——将L1
的长度减少到3
。计数器接着递增到1
,代码继续检查L1[1]
的值是否在L2
中。请注意,这不是L1[1]
的原始值(即2
),而是L1[1]
的当前值(即3
)。正如你所看到的,弄清楚在循环中修改列表时发生了什么是可能的,但并不容易。而且,发生的事情可能是无意的,就像这个例子一样。
避免这种问题的一种方法是使用切片来克隆³⁵(即,创建一个副本)列表,并写作for e1 in L1[:]
。请注意,写作
new_L1 = L1
for e1 in new_L1:
并不会解决问题。它不会创建L1
的副本,而仅仅为现有列表引入一个新名称。
切片并不是在 Python 中克隆列表的唯一方法。表达式L.copy()
与L[:]
的值相同。切片和copy
都执行所谓的浅拷贝。浅拷贝创建一个新列表,然后将要复制列表的对象(而不是对象的副本)插入到新列表中。代码
L = [2]
L1 = [L]
L2 = L1[:]
L2 = copy.deepcopy(L1)
L.append(3)
print(f'L1 = {L1}, L2 = {L2}')
打印L1 = [[2, 3]] L2 = [[2, 3]]
,因为L1
和L2
都包含在第一条赋值语句中绑定到L
的对象。
如果要复制的列表包含可变对象且你也希望复制它们,请导入标准库模块copy
并使用copy.deepcopy
函数进行深复制。方法deepcopy
创建一个新列表,然后将待复制列表中的对象复制到新列表中。如果我们将上述代码的第三行替换为L2 = copy.deepcopy(L1)
,它将打印L1 = [[2, 3]], L2 = [[2]]
,因为L1
不会包含L
所绑定的对象。
如果列表的元素是包含列表(或任何可变类型)的列表,理解copy.deepcopy
会比较棘手。考虑
L1 = [2]
L2 = [[L1]]
L3 = copy.deepcopy(L2)
L1.append(3)
L3
的值将为[[[2]]]
,因为copy.deepcopy
不仅为列表[L1]
创建新对象,也为列表L1
创建新对象。也就是说,它会复制到最底层——大多数情况下。为什么是“大多数情况下”?代码
L1 = [2]
L1.append(L1)
创建一个包含自身的列表。尝试复制到最底层将永远不会终止。为了解决这个问题,copy.deepcopy
对每个对象仅制作一次副本,然后在每个对象实例中使用该副本。即使在列表不包含自身的情况下,这也很重要。例如,
L1 = [2]
L2 = [L1, L1]
L3 = copy.deepcopy(L2)
L3[0].append(3)
print(L3)
打印[[2, 3], [2, 3]]
,因为copy.deepcopy
对L1
进行了一次复制,并在L2
中两次使用。
5.3.2 列表推导式
列表推导式提供了一种简洁的方式,通过迭代可迭代对象的值来应用操作。它创建一个新列表,其中每个元素是对可迭代对象中的某个值(例如,另一个列表的元素)应用给定操作的结果。其表达形式为
[*expr* for *elem* in *iterable* if *test*]
评估该表达式等同于调用函数
def f(expr, old_list, test = lambda x: True):
new_list = []
for e in iterable:
if test(e):
new_list.append(expr(e))
return new_list
例如,[e**2 for e in range(6)]
评估为[0, 1, 4, 9, 16, 25]
,[e**2 for e in range(8) if e%2 == 0]
评估为[0, 4, 16, 36]
,[x**2 for x in [2, 'a', 3, 4.0] if type(x) == int]
评估为[4, 9]
。
列表推导式提供了一种方便的方式来初始化列表。例如,[[] for _ in range(10)]
生成一个包含 10 个不同(即非别名)空列表的列表。变量名_
表示该变量的值未在生成列表的元素中使用,即它仅是一个占位符。这种约定在 Python 程序中很常见。
Python 允许在列表推导式中使用多个for
语句。考虑以下代码
L = [(x, y)
for x in range(6) if x%2 == 0
for y in range(6) if y%3 == 0]
Python 解释器首先评估第一个for
,将值序列0,2,4
赋给x
。对于这三个x
的值,它会评估第二个for
(每次生成值序列0,3
)。然后它将元组(x, y)
添加到正在生成的列表中,从而生成列表
[(0, 0), (0, 3), (2, 0), (2, 3), (4, 0), (4, 3)]
当然,我们可以不使用列表推导式生成相同的列表,但代码会显得相对不够简洁:
L = []
for x in range(6):
if x%2 == 0:
for y in range(6):
if y%3 == 0:
L.append((x, y))
以下代码是将列表推导式嵌套在另一个列表推导式中的示例。
print([[(x,y) for x in range(6) if x%2 == 0]
for y in range(6) if y%3 == 0])
它打印[[ (0, 0), (2, 0), (4, 0)], [(0, 3), (2, 3), (4, 3)]]
。
熟悉嵌套列表推导需要实践,但它们可以非常有用。让我们使用嵌套列表推导生成一个小于 100 的所有质数的列表。基本思想是使用一个推导生成所有候选数字的列表(即 2 到 99),第二个推导生成将候选质数除以每个潜在除数的余数的列表,以及使用内置函数all
来测试这些余数中是否有任何为 0。
[x for x in range(2, 100) if all(x % y != 0 for y in range(3, x))]
评估表达式等同于调用该函数
def gen_primes():
primes = []
for x in range(2, 100):
is_prime = True
for y in range(3, x):
if x%y == 0:
is_prime = False
if is_prime:
primes.append(x)
return primes
手指练习:编写一个列表推导,生成 2 到 100 之间的所有非质数。
一些 Python 程序员以奇妙而微妙的方式使用列表推导。这并不总是一个好主意。请记住,其他人可能需要阅读你的代码,而“微妙”通常不是程序的理想属性。
5.4 列表的高阶操作
在第 4.4 节中,我们介绍了高阶编程的概念。对于列表来说,它特别方便,如图 5-5 所示。
图 5-5 对列表元素应用函数
函数apply_to_each
被称为高阶,因为它的参数本身就是一个函数。第一次调用时,它通过对每个元素应用一元内置函数abs
来修改L
。第二次调用时,它对每个元素进行类型转换。第三次调用时,它通过应用使用lambda
定义的函数来替换每个元素。它打印
L = [1, -2, 3.33]
Apply abs to each element of L.
L = [1, 2, 3.33]
Apply int to each element of [1, 2, 3.33].
L = [1, 2, 3]
Apply squaring to each element of [1, 2, 3].
L = [1, 4, 9]
Python 有一个内置的高阶函数map
,它类似于但比图 5-5 中定义的apply_to_each
函数更通用。在它最简单的形式中,map
的第一个参数是一个一元函数(即只有一个参数的函数),第二个参数是适合作为第一个参数的值的任何有序集合。它通常用作列表推导的替代。例如,list(map(str, range(10)))
等价于[str(e) for e in range(10)]
。
map
函数通常与for
循环一起使用。在for
循环中使用时,map
的行为类似于range
函数,它为循环的每次迭代返回一个值。这些值是通过将第一个参数应用于第二个参数的每个元素生成的。例如,代码
`for i in map(lambda x: x**2, [2, 6, 4]): print(i)`
打印
4
36
16
更一般地说,map
的第一个参数可以是一个有n
个参数的函数,此时它必须后跟n
个后续有序集合(每个集合长度相同)。例如,代码
L1 = [1, 28, 36]
L2 = [2, 57, 9]
for i in map(min, L1, L2):
print(i)
打印
1
28
9
手指练习:实现一个满足以下规范的函数。提示:在实现的主体中使用lambda
会很方便。
def f(L1, L2):
"""L1, L2 lists of same length of numbers
returns the sum of raising each element in L1
to the power of the element at the same index in L2
For example, f([1,2], [2,3]) returns 9"""
5.5 字符串、元组、范围和列表
我们已经看过四种可迭代的序列类型:str
、tuple
、range
和 list
。它们相似之处在于这些类型的对象可以按照图 5-6 中描述的方式进行操作。它们的其他一些相似性和差异在图 5-7 中总结。
图 5-6 序列类型的常见操作
图 5-7 序列类型的比较
Python 程序员往往比使用元组更频繁地使用列表。由于列表是可变的,因此可以在计算过程中逐步构建。例如,以下代码逐步构建一个包含另一个列表中所有偶数的列表。
even_elems = []
for e in L:
if e%2 == 0:
even_elems.append(e)
由于字符串只能包含字符,因此它们的通用性远不如元组或列表。另一方面,当你处理字符字符串时,有许多有用的内置方法。图 5-8 包含了其中一些方法的简短描述。请记住,由于字符串是不可变的,这些方法都返回值,并且没有副作用。
图 5-8 字符串上的一些方法
一个更有用的内置方法是 split
,它接受两个字符串作为参数。第二个参数指定一个分隔符,用于将第一个参数拆分为一系列子字符串。例如,
print('My favorite professor–John G.–rocks'.split(' '))
print('My favorite professor–John G.–rocks'.split('-'))
print('My favorite professor–John G.–rocks'.split('–'))
打印
['My', 'favorite', 'professor–John', 'G.–rocks']
['My favorite professor', '', 'John G.', '', 'rocks']
['My favorite professor', 'John G.', 'rocks']
第二个参数是可选的。如果省略该参数,第一个字符串将使用任意的空白字符(空格、制表符、换行符、回车和换页)进行分割。³⁶
5.6 集合
集合是另一种集合类型。它们类似于数学中的集合概念,因为它们是无序的独特元素集合。它们用程序员称之为大括号、数学家称之为集合括号的符号表示,例如,
`baseball_teams = {'Dodgers', 'Giants', 'Padres', 'Rockies'} football_teams = {'Giants', 'Eagles', 'Cardinals', 'Cowboys'}`
由于集合的元素是无序的,尝试索引集合,例如,评估 baseball_teams[0]
会产生运行时错误。我们可以使用 for
语句来迭代集合的元素,但与我们看到的其他集合类型不同,元素产生的顺序是未定义的。
像列表一样,集合是可变的。我们使用 add
方法向集合中添加单个元素。通过将元素集合(例如列表)传递给 update
方法来向集合中添加多个元素。例如,代码
baseball_teams.add('Yankees')
football_teams.update(['Patriots', 'Jets'])
print(baseball_teams)
print(football_teams)
打印
{'Dodgers', 'Yankees', 'Padres', 'Rockies', 'Giants'}
{'Jets', 'Eagles', 'Patriots', 'Cowboys', 'Cardinals', 'Giants'}
(元素出现的顺序并不由语言定义,因此如果运行此示例,可能会得到不同的输出。)
可以使用 remove
方法从集合中移除元素,如果元素不在集合中则会引发错误,或者使用 discard
方法,如果元素不在集合中则不会引发错误。
可以使用 in
运算符测试一个对象是否属于集合。例如,'Rockies' in baseball_teams
返回 True
。二元方法 union
、intersection
、difference
和 issubset
具有通常的数学含义。例如,
`print(baseball_teams.union({1, 2})) print(baseball_teams.intersection(football_teams)) print(baseball_teams.difference(football_teams)) print({'Padres', 'Yankees'}.issubset(baseball_teams))`
打印
{'Padres', 'Rockies', 1, 2, 'Giants', ‘Dodgers', 'Yankees'}
{'Giants'}
{'Padres', 'Rockies', ‘Dodgers', 'Yankees'}
True
集合的一个好处是许多方法都有方便的中缀运算符,包括 |
表示 union
,&
表示 intersect
,-
表示 difference
,<=
表示 subset
,以及 >=
表示 superset
。使用这些运算符可以使代码更易读。例如比较,
print(baseball_teams | {1, 2})
print(baseball_teams & football_teams)
print(baseball_teams - football_teams)
print({'Padres', 'Yankees'} <= baseball_teams)
与之前呈现的代码相同,它使用点表示法打印相同的值。
并非所有类型的对象都可以成为集合的元素。集合中的所有对象必须是可哈希的。一个对象是可哈希的,如果它有
一个将对象映射到
int
的__hash__
方法,并且__hash__
返回的值在对象生命周期内不会改变,并且一个用于比较它与其他对象相等的
__eq__
方法。
Python 的标量不可变类型的所有对象都是可哈希的,而 Python 内置的可变类型的对象则不可哈希。一个非标量不可变类型的对象(例如,元组)是可哈希的,当且仅当它的所有元素都是可哈希的。
5.7 字典
**dict**
类型的对象(即字典的缩写)与列表类似,只不过我们使用键而不是整数来索引它们。任何可哈希对象都可以用作键。可以将字典视为键/值对的集合。类型 dict
的字面量用大括号括起来,每个元素写为键后跟冒号再跟值。例如,代码
month_numbers = {'Jan':1, 'Feb':2, 'Mar':3, 'Apr':4, 'May':5,
1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May'}
print(month_numbers)
print('The third month is ' + month_numbers[3])
dist = month_numbers['Apr'] - month_numbers['Jan']
print('Apr and Jan are', dist, 'months apart')
将打印
{'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 1: 'Jan', 2: 'Feb', 3: 'Mar', 4: 'Apr', 5: 'May'}
The third month is Mar
Apr and Jan are 3 months apart
dict
中的条目不能使用索引访问。这就是为什么 month_numbers[1]
明确指向键为 1
的条目,而不是第二个条目。可以使用 in
运算符测试一个键是否在字典中定义。
像列表一样,字典是可变的。我们可以通过编写,例如,month_numbers['June'] = 6
来添加条目,或通过编写,例如,month_numbers['May'] = 'V'.
来更改条目。
字典是 Python 的一大亮点。它们大大减少了编写各种程序的难度。例如,在 图 5-9 中,我们使用字典编写了一个(相当糟糕的)程序来进行语言之间的翻译。
图中的代码打印
Je bois "good" rouge vin, et mange pain.
I drink of wine red.
请记住,字典是可变的。因此,要注意副作用。例如,
FtoE['bois'] = 'wood'
print(translate('Je bois du vin rouge.', dicts, 'French to English'))
将打印
I wood of wine red.
图 5-9 翻译文本(糟糕地)
许多编程语言没有内置类型来提供从键到值的映射。相反,程序员使用其他类型来提供类似的功能。例如,使用列表实现字典相对简单,其中每个元素是表示键/值对的元组。然后我们可以编写一个简单的函数来进行关联检索,例如,
def key_search(L, k):
for elem in L:
if elem[0] == k:
return elem[1]
return None
这种实现的问题在于其计算效率低下。在最坏的情况下,程序可能必须检查列表中的每个元素才能执行一次检索。相比之下,内置实现速度很快。它使用了一种称为哈希的技术,如第十二章所述,能够在几乎不依赖字典大小的时间内进行查找。
有多种方法可以使用for
语句遍历字典中的条目。如果d
是一个字典,形式为for k in d
的循环会遍历d
的键。选择键的顺序是根据它们在字典中插入的顺序。³⁷ 例如,
capitals = {'France': 'Paris', 'Italy': 'Rome', 'Japan': 'Kyoto'}
for key in capitals:
print('The capital of', key, 'is', capitals[key])
打印
The capital of France is Paris
The capital of Italy is Rome
The capital of Japan is Kyoto
要遍历字典中的值,我们可以使用values
方法。例如,
cities = []
for val in capitals.values():
cities.append(val)
print(cities, 'is a list of capital cities')
打印['巴黎', '罗马', '京都'] 是一个首都城市的列表
。
values
方法返回类型为dict_values
的对象。这是一个视图对象的示例。视图对象是动态的,如果与之关联的对象发生更改,变化将通过视图对象可见。例如,代码
cap_vals = capitals.values()
print(cap_vals)
capitals['Japan'] = ‘Tokyo'
print(cap_vals)
打印
dict_values(['Paris', 'Rome', 'Kyoto'])
dict_values(['Paris', 'Rome', ‘Toyko'])
同样,keys
方法返回类型为dict_keys
的视图对象。视图对象可以转换为列表,例如,list(capitals.values())
返回一个包含首都值的列表。
要遍历键/值对,我们使用items
方法。此方法返回类型为dict_items
的视图对象。类型为dict_items
的对象的每个元素都是一个包含键及其关联值的tuple
。例如,代码
for key, val in capitals.items():
print(val, 'is the capital of', key)
打印
Paris is the capital of France
Rome is the capital of Italy
Tokyo is the capital of Japan
指尖练习: 实现一个满足规范的函数
def get_min(d):
"""d a dict mapping letters to ints
returns the value in d with the key that occurs first in the
alphabet. E.g., if d = {x = 11, b = 12}, get_min returns 12."""
使用元组作为键通常很方便。想象一下,使用形式为(flight_number, day)
的元组来表示航空公司航班。然后很容易将这些元组作为键用于实现航班与到达时间映射的字典。列表不能用作键,因为列表类型的对象是不可哈希的。
正如我们所看到的,字典有许多有用的方法,包括一些用于删除元素的方法。我们不会在这里列举所有这些方法,但在书中的示例中会方便地使用它们。图 5-10 包含一些更有用的字典操作。
图 5-10 字典上的一些常见操作
5.8 字典推导式
字典推导式类似于列表推导式。一般形式是
{*key*: *value* for *id1*, *id2* in *iterable* if *test*}
关键区别(除了使用大括号而不是方括号)是它使用两个值来创建字典的每个元素,并允许(但不要求)可迭代对象同时返回两个值。考虑一个将某些十进制数字映射到英语单词的字典:
number_to_word = {1: 'one', 2: ‘two', 3: ‘three', 4: 'four', 10: ‘ten'}
我们可以轻松地使用字典推导式来生成一个将单词映射到数字的字典。
word_to_number = {w: d for d, w in number_to_word.items()}
如果我们决定只想在word_to_number
中使用单个数字,我们可以使用推导式。
word_to_number = {w: d for d, w in number_to_word.items() if d < 10}
现在,让我们尝试一些更有挑战性的内容。密码是一种算法,它将明文(人类可以轻松阅读的文本)映射到密文。最简单的密码是替换密码,它用一个独特的字符串替换明文中的每个字符。从原始字符到替换它们的字符串的映射称为密钥(与打开锁所用的钥匙类似,而不是用于 Python 字典的那种钥匙)。在 Python 中,字典提供了一种方便的方式来实现可以用于编码和解码文本的映射。
书本密码是一种密钥来源于书籍的密码。例如,它可能将明文中的每个字符映射到该字符在书中首次出现的数字索引(或书页上的索引)。假设发送者和接收者之前已达成一致使用的书,但拦截编码信息的对手并不知道使用了哪本书。
以下函数定义使用字典推导创建一个字典,该字典可用于使用书本密码编码明文。
gen_code_keys = (lambda book, plain_text:(
{c: str(book.find(c)) for c in plain_text}))
如果 plain_text
是 “no is no” 并且 book
以 “从前,在一个遥远的地方有一所房子” 开头,则调用 gen_code_keys(book, plain_text)
会返回。
{'n': '1', 'o': '7', ' ': '4', 'i': '13', ‘s': '26'}
顺便提一下,o 映射为七而不是零,因为 o 和 O 是不同的字符。如果 book
是 堂吉诃德 的文本,³⁸ 调用 gen_code_keys(book, plain_text)
将返回。
{'n': '1', 'o': '13', ' ': '2', 'i': '6', ‘s': '57'}
现在我们有了编码字典,可以使用列表推导定义一个函数,利用它来加密明文。
encoder = (lambda code_keys, plain_text:
''.join(['*' + code_keys[c] for c in plain_text])[1:])
由于明文中的字符可能被多个字符替换为密文中的字符,我们使用 *
来分隔密文中的字符。.join
操作符用于将字符串列表转换为单个字符串。
函数 encrypt
使用 gen_code_keys
和 encoder
来加密明文。
encrypt = (lambda book, plain_text:
encoder(gen_code_keys(book, plain_text), plain_text))
调用 encrypt(Don_Quixote, 'no is no')
返回。
1*13*2*6*57*2*1*13
在我们能够解码密文之前,需要构建一个解码字典。简单的做法是反转编码字典,但那样会不诚实。书本密码的核心在于发送者发送加密信息,但没有关于密钥的任何信息。接收者解码信息所需的唯一条件是获得编码者使用的书。以下函数定义使用字典推导从书和编码信息构建解码密钥。
gen_decode_keys = (lambda book, cipher_text:
{s: book[int(s)] for s in cipher_text.split('*')})
调用 gen_decode_keys(Don_Quixote, '1*13*2*6*57*2*1*13')
会产生解密密钥。
{'1': 'n', '13': 'o', '2': ' ', '6': 'i', '57': ‘s'}
如果明文中出现的字符在书中不存在,就会发生一些不好的事情。code_keys
字典会将每个这样的字符映射到 -1
,而 decode_keys
会将 -1
映射到书中最后一个字符。
指尖练习:解决上一段描述的问题。提示:简单的方法是通过向原始书籍附加内容来创建一本新书。
指尖练习:以encoder
和encrypt
为模型,实现decoder
和decrypt
函数。使用它们解密消息
22*13*33*137*59*11*23*11*1*57*6*13*1*2*6*57*2*6*1*22*13*33*137*59*11*23*11*1*57*6*173*7*11
使用唐吉诃德的开头进行加密。
5.9 章节引入的术语
元组
多重赋值
可迭代对象
类型迭代器
列表
可变类型
不可变类型
id 函数
对象相等
副作用
别名
克隆
浅拷贝
深拷贝
列表推导式
高阶函数
空白字符
集合
可哈希类型
字典
键
值
视图对象
字典推导式
书本密码
第六章:递归与全局变量
你可能听说过递归,而且很可能把它视为一种相当微妙的编程技巧。这是计算机科学家传播的一个迷人的都市传说,让人们觉得我们比实际上聪明。递归是一个重要的概念,但并不是那么微妙,它不仅仅是一种编程技巧。
递归作为一种描述性方法被广泛使用,甚至连那些从未想过写程序的人也会使用它。考虑美国法律中对“出生公民权”概念的部分定义。大致来说,定义如下
在美国境内出生的任何孩子或
在美国境外婚生的任何孩子,其中一位父母是美国公民。
第一个部分很简单;如果你在美国出生,你就是出生公民(例如巴拉克·奥巴马)。如果你不是在美国出生,则取决于你出生时你的父母是否是美国公民。而你的父母是否是美国公民可能还取决于他们的父母是否是美国公民,依此类推。
一般来说,递归定义由两部分组成。至少有一个基例,直接指定特殊情况下的结果(上面示例中的情况 1),还有至少一个递归(归纳)情况(上面示例中的情况 2),根据某个其他输入的答案来定义答案,通常是同一问题的简化版本。基例的存在使得递归定义不成为循环定义。³⁹
世界上最简单的递归定义可能是阶乘函数(通常在数学中用!表示)针对自然数的定义。⁴⁰经典的归纳定义是
第一个方程定义了基例。第二个方程在前一个数字的阶乘的基础上,定义了所有自然数的阶乘,除了基例。
图 6-1 包含了阶乘的迭代实现(fact_iter
)和递归实现(fact_rec
)。
图 6-1 阶乘的迭代和递归实现
图 6-1 阶乘的迭代和递归实现
这个函数足够简单,两个实现都不难理解。然而,第二个实现是对原始递归定义的更直接翻译。
实现 fact_rec
通过在 fact_rec
的主体中调用 fact_rec
似乎有些作弊。这是因为与迭代实现的工作原理相同。我们知道 fact_iter
中的迭代将终止,因为 n
一开始为正数,并且在每次循环中减少 1。这意味着它不可能永远大于 1
。类似地,如果 fact_rec
被以 1 调用,它会返回一个值而不进行递归调用。当它进行递归调用时,它总是以一个比被调用时小 1 的值进行。最终,递归以调用 fact_rec(1)
终止。
手指练习: 整数的谐波和,n > 0,可以使用公式计算 。编写一个递归函数来计算这个值。
6.1 斐波那契数
斐波那契数列是另一种常见的数学函数,通常以递归方式定义。“它们繁殖得像兔子一样快,”通常用来描述说话者认为增长过快的人口。在 1202 年,意大利数学家比萨的莱昂纳多,即斐波那契,提出了一个公式来量化这一概念,尽管有一些并不太现实的假设。⁴¹
假设一对新生的兔子,一只雄性和一只雌性,被放在一个围栏里(或者更糟的是,被释放到野外)。进一步假设这些兔子在一个月大时就能交配(令人惊讶的是,某些品种可以)并且有一个月的怀孕期(令人惊讶的是,某些品种确实如此)。最后,假设这些神话中的兔子永远不会死(这不是任何已知兔子品种的特性),而且雌性兔子从第二个月开始每个月都会产下一对新兔子(一个雄性,一个雌性)。六个月结束时会有多少只雌性兔子?
在第一个月的最后一天(称之为月份 0
),将有一只雌性兔子(准备在下一个月的第一天交配)。在第二个月的最后一天,仍然只有一只雌性兔子(因为她直到下一个月的第一天才会产仔)。在下一个月的最后一天,将会有两只雌性兔子(一只怀孕的和一只不怀孕的)。在下一个月的最后一天,将会有三只雌性兔子(两只怀孕的和一只不怀孕的)。依此类推。让我们以表格形式查看这一进程,图 6-2。
图 6-2 雌性兔子数量的增长
注意到对于月份 n > 1
,females(n) = females(n‑1) + females(n-2)
。这并不是偶然。每只在月份 n-1
仍然存活的雌性将在月份 n
依然存活。此外,每只在月份 n‑2
存活的雌性将在月份 n
产生一只新的雌性。新的雌性可以加到月份 n-1
的雌性数量中,以获得月份 n
的雌性数量。
图 6-2 雌性兔子数量的增长
人口增长自然地通过递推描述⁴²
females(0) = 1
females(1) = 1
females(n + 2) = females(n+1) + females(n)
这个定义与阶乘的递归定义不同:
它有两个基本情况,而不仅仅是一个。一般来说,我们可以有任意多的基本情况。
在递归情况下,有两个递归调用,而不仅仅是一个。同样,我们可以有任意多的调用。
图 6-3 包含了斐波那契递推的直接实现,⁴³以及一个可以用来测试它的函数。
图 6-3 递归实现斐波那契数列
编写代码是解决这个问题的简单部分。一旦我们从一个模糊的关于兔子的问题陈述转变为一组递归方程,代码几乎是自然而然地生成的。找到一种抽象的方法来表达当前问题的解决方案通常是构建一个有用程序中最困难的步骤。我们将在本书后面详细讨论这个问题。
正如你可能猜到的,这并不是野生兔子种群增长的完美模型。在 1859 年,澳大利亚农民托马斯·奥斯丁从英格兰进口了 24 只兔子作为猎物。一些兔子逃脱。十年后,澳大利亚每年大约有两百万只兔子被射杀或捕获,对种群没有明显影响。这是很多兔子,但与120
^(th)斐波那契数r
相差甚远。⁴⁴
虽然斐波那契数列并没有提供兔子种群增长的完美模型,但它确实具有许多有趣的数学性质。斐波那契数在自然界中也很常见。例如,大多数花朵的花瓣数是斐波那契数。
手指练习: 当图 6-3 中的fib
实现被用来计算fib(5)
时,它在计算fib(5)
的过程中计算了多少次fib(2)
的值?
6.2 回文
递归对许多不涉及数字的问题也很有用。图 6-4 包含一个函数is_palindrome
,它检查字符串是否正反读相同。
图 6-4 回文测试
函数is_palindrome
包含两个内部辅助函数。这对函数的客户端没有太大兴趣,客户端只需关心is_palindrome
的实现是否符合其规范。但你应该关心,因为通过检查实现可以学到很多东西。
辅助函数to_chars
将所有字母转换为小写并移除所有非字母。它首先使用字符串上的内置方法生成一个与s
相同的字符串,只是所有大写字母都被转换为小写。
辅助函数is_pal
使用递归来完成实际工作。两个基本情况是长度为零或一的字符串。这意味着递归实现部分只会在长度为二或更多的字符串上被触及。在else
子句中的连接⁴⁵是从左到右进行评估的。代码首先检查第一个和最后一个字符是否相同,如果相同,则继续检查去掉这两个字符的字符串是否为回文。在这个例子中,只有当第一个连接评估为True
时,第二个连接才会被评估,这在语义上并不相关。然而,在书的后面部分,我们将看到一些例子,其中这种短路评估布尔表达式在语义上是相关的。
这个is_palindrome
的实现是一个重要问题解决原则的例子,称为分而治之。(这个原则与分而治之算法相关,但略有不同,后者将在第十二章讨论。)这个问题解决原则是通过将一个难题拆分为一组子问题来征服一个困难的问题,具备以下属性。
子问题比原始问题更容易解决。
子问题的解决方案可以结合起来解决原始问题。
分而治之是一个古老的思想。尤利乌斯·凯撒践行了罗马人所称的divide et impera(分而治之)。英国人巧妙地运用这一方法来控制印度次大陆。本杰明·富兰克林非常清楚英国在运用这一技术方面的专业知识,因此在美国独立宣言签署时他说:“我们必须团结一致,否则我们必将各自面对困境。”
在这种情况下,我们通过将原始问题拆分为一个更简单的同类问题(检查一个较短的字符串是否为回文)和一个我们知道如何处理的简单问题(比较单个字符)来解决问题,然后使用逻辑运算符and
结合解决方案。图 6-5 包含一些可视化这一过程的代码。
图 6-5 可视化回文测试的代码
执行代码
print('Try dogGod')
print(is_palindrome('dogGod'))
print('Try doGood')
print(is_palindrome('doGood'))
打印
Try dogGod
is_pal called with doggod
is_pal called with oggo
is_pal called with gg
is_pal called with
About to return True from base case
About to return True for gg
About to return True for oggo
About to return True for doggod
True
Try doGood
is_pal called with dogood
is_pal called with ogoo
is_pal called with go
About to return False for go
About to return False for ogoo
About to return False for dogood
False
6.3 全局变量
如果你尝试用一个大数字调用fib
,你可能会注意到运行时间非常长。假设我们想知道进行了多少次递归调用。我们可以仔细分析代码来找出答案,在第十一章中我们将讨论如何做到这一点。另一种方法是添加一些代码来计算调用次数。一种方法是使用全局变量。
到目前为止,我们所编写的所有函数仅通过其参数和返回值与环境进行通信。在大多数情况下,这正是应该的。这通常导致程序相对容易阅读、测试和调试。然而,偶尔情况下,全局变量也会派上用场。考虑图 6-6 中的代码。
图 6-6 使用全局变量
在每个函数中,代码行global
num_fib_calls
告诉 Python,名称num_fib_calls
应该在其出现的函数外部定义。如果我们没有包含代码global num_fib_calls
,那么名称num_fib_calls
将在函数fib
和test_fib
中是局部的,因为num_fib_calls
在fib
和test_fib
的赋值语句左侧。函数fib
和test_fib
都可以不受限制地访问变量num_fib_calls
所引用的对象。函数test_fib
每次调用fib
时都会将num_fib_calls
绑定为0
,而fib
每次进入时都会递增num_fib_calls
的值。
调用test_fib(6)
会产生输出
fib of 0 = 1
fib called 1 times.
fib of 1 = 1
fib called 1 times.
fib of 2 = 2
fib called 3 times.
fib of 3 = 3
fib called 5 times.
fib of 4 = 5
fib called 9 times.
fib of 5 = 8
fib called 15 times.
fib of 6 = 13
fib called 25 times.
我们对全局变量这一主题的引入怀有些许忧虑。自 1970 年代以来,持卡计算机科学家们对它们表示反对,这一点是有充分理由的。全局变量的滥用可能导致许多问题。使程序可读的关键在于局部性。人们是逐段阅读程序的,每一段所需的上下文越少,理解就越好。由于全局变量可以在多种地方被修改或读取,它们的不当使用可能破坏局部性。然而,在某些情况下,全局变量确实是所需的。全局变量最常见的用法可能是定义一个将在多个地方使用的全局常量。例如,某个编写与物理相关的程序的人可能希望一次性定义光速 C,然后在多个函数中使用它。
6.4 本章介绍的术语
递归
基本情况
递归(归纳)情况
归纳定义
递归关系
辅助函数
短路求值
分治法
全局变量
全局常量
第七章:模块和文件
到目前为止,我们假设 1)我们的整个程序存储在一个文件中,2)我们的程序不依赖于之前编写的代码(除了实现 Python 的代码),以及 3)我们的程序不访问之前收集的数据,也不以允许在程序运行结束后访问的方式存储结果。
第一个假设在程序较小时是完全合理的。然而,随着程序的增大,将不同部分存储在不同文件中通常更方便。比如说,想象一下多个开发者在同一个程序上工作。如果他们都试图更新同一个文件,那将是一场噩梦。在第 7.1 节中,我们讨论了一种机制,即 Python 模块,它允许我们轻松地从多个文件中的代码构建程序。
第二和第三个假设对于旨在帮助人们学习编程的练习是合理的,但在编写旨在完成某些有用功能的程序时,这种假设很少是合理的。在第 7.2 节中,我们展示了如何利用标准 Python 发行版中的库模块。在本章中,我们使用了其中几个模块,而在书的后面部分还会使用许多其他模块。第 7.3 节简要介绍了如何从文件中读取和写入数据。
7.1 模块
模块是一个包含 Python 定义和语句的.py
文件。例如,我们可以创建一个包含图 7-1 中的代码的文件circle.py
。
图 7-1 与圆和球体相关的一些代码
程序通过导入语句访问模块。例如,代码
import circle
pi = 3
print(pi)
print(circle.pi)
print(circle.area(3))
print(circle.circumference(3))
print(circle.sphere_surface(3))
将打印
3
3.14159
28.27431
18.849539999999998
113.09724
模块通常存储在单独的文件中。每个模块都有自己的私有符号表。因此,在circle.py
中,我们以通常的方式访问对象(例如,pi
和area
)。执行import M
将在import
出现的作用域中为模块M
创建一个绑定。因此,在导入上下文中,我们使用点表示法来表明我们引用的是在导入模块中定义的名称。⁴⁶ 例如,在circle.py
之外,引用pi
和circle.pi
可以(并且在这种情况下确实)指代不同的对象。
初看,使用点表示法似乎有些繁琐。另一方面,当导入一个模块时,通常不知道该模块的实现中可能使用了哪些局部名称。使用点表示法完全 限定 名称避免了因意外名称冲突而导致的错误。例如,在circle
模块之外执行赋值pi = 3
不会改变在circle
模块中使用的pi
的值。
正如我们所见,一个模块可以包含可执行语句以及函数定义。通常,这些语句用于初始化模块。因此,模块中的语句仅在模块首次被导入到程序时执行。此外,每个解释器会话中,一个模块只会被导入一次。如果你启动一个控制台,导入一个模块,然后更改该模块的内容,解释器仍将使用原始版本的模块。这在调试时可能导致令人困惑的行为。当有疑问时,请启动一个新的 shell。
一种允许导入程序在访问导入模块内定义的名称时省略模块名称的import
语句变体。执行语句from M import *
会在当前作用域中创建对M
中定义的所有对象的绑定,但不包括M
本身。例如,代码
from circle import *
print(pi)
print(circle.pi)
首先会打印3.14159
,然后生成错误信息
`NameError: name 'circle' is not defined`
许多 Python 程序员对使用这种“通配符”import
表示不满。他们认为这会使代码更难以阅读,因为不再明显一个名称(例如上述代码中的pi
)是在哪里定义的。
一种常用的导入语句变体是
import *module_name* as *new_name*
这指示解释器导入名为module_name的模块,但将其重命名为new_name。如果module_name在导入程序中已被用于其他用途,这将非常有用。程序员使用这种形式的最常见原因是为长名称提供一个缩写。
7.2 使用预定义包
许多有用的模块包作为标准 Python 库的一部分而提供;我们将在本书后面使用其中的一些。此外,大多数 Python 发行版还附带标准库以外的包。Python 3.8 的 Anaconda 发行版包含超过 600 个包!我们将在本书后面使用其中的一些。
在这一节中,我们介绍两个标准包,math
和calendar
,并给出一些简单的使用示例。顺便提一下,这些包与所有标准模块一样,使用我们尚未涉及的 Python 机制(例如,异常,在第九章中讨论)。
在之前的章节中,我们介绍了*似对数的各种方法。但我们没有告诉你最简单的方法。最简单的方法是简单地导入模块math
。例如,要打印以 2 为底的 x 的对数,你只需写
import math
print(math.log(x, 2))
除了包含大约 50 个有用的数学函数外,math
模块还包含几个有用的浮点常量,例如math.pi
和math.inf
(正无穷大)。
设计用于支持数学编程的标准库模块仅占标准库模块的一小部分。
想象一下,比如你想打印 1949 年 3 月星期几的文本表示,就像右侧的图片。你可以在线查找那个月和那年的日历。然后,凭借足够的耐心和多次尝试,你可能会写出一个可以完成这项工作的打印语句。或者,你也可以简单地写
import calendar as cal
cal_english = cal.TextCalendar()
print(cal_english.formatmonth(1949, 3))
或者,如果你更喜欢用法语、波兰语和丹麦语查看日历,你可以写
print(cal.LocaleTextCalendar(locale='fr_FR').formatmonth(2049, 3))
print(cal.LocaleTextCalendar(locale='pl_PL').formatmonth(2049, 3))
print(cal.LocaleTextCalendar(locale='da_dk').formatmonth(2049, 3))
将产生
假设你想知道 2033 年圣诞节是星期几。该行
print(cal.day_name[cal.weekday(2033, 12, 25)])
将回答这个问题。调用cal.weekday
将返回一个表示星期几的整数,⁴⁷,然后用它来索引cal.day_name
——一个包含英语星期几的列表。
现在,假设你想知道 2011 年美国感恩节是星期几。星期几很简单,因为美国感恩节总是在 11 月的第四个星期四。⁴⁸ 找到实际日期稍微复杂一些。首先,我们使用cal.monthcalendar
获取一个表示月份周数的列表。列表的每个元素包含七个整数,表示月份的日期。如果该日期在该月不存在,则该周的列表第一个元素将为0
。例如,如果一个有 31 天的月份从星期二开始,则列表的第一个元素将是[0, 1, 2, 3, 4, 5, 6]
,而列表的最后一个元素将是[30, 31, 0, 0, 0, 0, 0]
。
我们使用calendar.monthcalendar
返回的列表来检查第一周是否有一个星期四。如果有,第四个星期四就在这个月的第四周(即索引 3);否则,它在第五周。
def find_thanksgiving(year):
month = cal.monthcalendar(year, 11)
if month[0][cal.THURSDAY] != 0:
thanksgiving = month[3][cal.THURSDAY]
else:
thanksgiving = month[4][cal.THURSDAY]
return thanksgiving
print('In 2011', 'U.S. Thanksgiving was on November',
find_thanksgiving(2011))
指尖练习: 编写一个满足规范的函数。
def shopping_days(year):
"""year a number >= 1941
returns the number of days between U.S. Thanksgiving and
Christmas in year"""
指尖练习: 自 1958 年以来,加拿大感恩节在十月的第二个星期一举行。编写一个接受年份(>1957)作为参数的函数,返回加拿大感恩节与圣诞节之间的天数。
按惯例,Python 程序员通常
1. 每行导入一个模块。
2. 将所有导入放在程序的开头。
3. 首先导入标准模块,其次是第三方模块(例如,通过 Anaconda 提供的模块),最后是特定应用程序的模块。
有时将所有导入放在程序的开头会导致问题。导入语句是可执行的代码行,Python 解释器在遇到时会执行它。一些模块包含在导入模块时会执行的代码。通常,这些代码初始化模块所使用的一些对象。由于这些代码可能会访问共享资源(例如计算机上的文件系统),因此导入在程序中的执行位置可能很重要。好消息是,这不太可能成为你可能使用的模块的问题。
7.3 文件
每个计算机系统使用 文件 来保存从一次计算到下一次计算的内容。Python 提供了许多创建和访问文件的功能。这里我们展示一些基本的功能。
每个操作系统(例如 Windows 和 macOS)都有自己用于创建和访问文件的文件系统。Python 通过一种称为 文件句柄 的方式实现操作系统的独立性。代码
name_handle = open('kids', 'w')
指示操作系统创建一个名为 kids
的文件,并返回该文件的文件句柄。open
的参数 'w'
表示文件将以 写入 模式打开。以下代码 打开 一个文件,使用 **write**
方法写入两行。(在 Python 字符串中,转义字符“\
”用于表示下一个字符应以特殊方式处理。在这个例子中,字符串 '\n'
表示 换行符。)最后,代码 关闭 该文件。记得在程序使用完文件后关闭它。否则,有可能某些或所有写入的数据不会被保存。
name_handle = open('kids', 'w')
for i in range(2):
name = input('Enter name: ')
name_handle.write(name + '\n')
name_handle.close()
你可以通过使用 **with**
语句来确保不会忘记关闭文件。此形式的代码
with open(*file_name*) as *name_handle*:
*code_block*
打开一个文件,将一个本地名称绑定到它,可以在 code_block 中使用,并在 code_block 退出时关闭该文件。
以下代码以 读取 模式打开一个文件(使用参数 'r'
),并打印其内容。由于 Python 将文件视为一系列行,我们可以使用 for
语句遍历文件的内容。
with open('kids', 'r') as name_handle:
for line in name_handle:
print(line)
如果我们输入大卫和安德烈亚的名字,这将打印。
David
Andrea
大卫和安德烈亚之间的额外行是因为打印在文件中每行末尾遇到 '\n'
时会开始新的一行。我们可以通过写 print(line[:-1])
来避免打印额外的行。
代码
name_handle = open('kids', 'w')
name_handle.write('Michael')
name_handle.write('Mark')
name_handle.close()
name_handle = open('kids', 'r')
for line in name_handle:
print(line)
将打印单行 MichaelMark
。
注意,我们已经覆盖了文件 kids
的先前内容。如果我们不想这样做,可以使用参数 'a'
打开文件以 追加 模式(而不是写入模式)。例如,如果我们现在运行代码
name_handle = open('kids', 'a')
name_handle = open('kids', 'a')
name_handle.write('David')
name_handle.write('Andrea')
name_handle.close()
name_handle = open('kids', 'r')
for line in name_handle:
print(line)
它将打印行 MichaelMarkDavidAndrea
。
指尖练习:编写一个程序,首先将斐波那契数列的前十个数字存储到名为fib_file
的文件中。每个数字应单独占一行。程序然后从文件中读取这些数字并打印出来。
对文件的一些常见操作总结在图 7-2 中。
图 7-2 访问文件的常见函数
7.4 本章介绍的术语
模块
导入语句
完全限定名称
标准 Python 库
文件
文件句柄
写入和读取
从文件中
换行符
打开和关闭文件
with 语句
追加到文件
第八章:测试和调试
我们不想提起这一点,但庞格洛斯博士⁴⁹错了。我们并不生活在“所有可能世界中最好的世界”里。有些地方降雨太少,有些地方降雨太多。有些地方太冷,有些地方太热,有些地方夏天太热而冬天太冷。有时股市大幅下跌。有时作弊者会赢(参见休斯顿太空人队)。而且令人恼火的是,我们的程序并不总是在第一次运行时就正常工作。
关于如何处理最后一个问题已经有书籍问世,从这些书中有很多可以学习的东西。然而,为了给你提供一些可能帮助你按时完成下一个问题集的提示,本章提供了这一主题的高度浓缩讨论。虽然所有的编程示例都是用 Python 写的,但一般原则适用于让任何复杂系统正常工作。
测试是运行程序以试图确定其是否按预期工作的过程。调试是尝试修复一个你已经知道不按预期工作的程序的过程。
测试和调试并不是你在程序构建后才应该考虑的过程。优秀的程序员以更容易进行测试和调试的方式设计他们的程序。做到这一点的关键是将程序拆分成可以独立实现、测试和调试的组件。在本书的这一点上,我们只讨论了一种模块化程序的机制,即函数。因此,目前为止,我们的所有示例将围绕函数进行。当我们涉及其他机制,特别是类时,我们会回到本章讨论的一些主题。
使程序正常工作的第一步是让语言系统同意运行它——也就是说,消除那些在不运行程序的情况下就能检测到的语法错误和静态语义错误。如果你在编程中还没有过这一关,那你还不准备好进入本章。多花点时间做一些小程序,然后再回来。
8.1 测试
测试的目的是显示存在错误,而不是证明程序没有错误。引用艾兹格·迪杰斯特拉的话:“程序测试可以用来显示错误的存在,但永远不能证明它们的缺失!” ⁵⁰ 或者,正如阿尔伯特·爱因斯坦所说:“任何实验都无法证明我正确;一个实验可以证明我错误。”
为什么会这样?即使是最简单的程序也有数十亿种可能的输入。比如,考虑一个声称满足规格的程序。
def is_smaller(x, y):
"""Assumes x and y are ints
Returns True if x is less than y and False otherwise."""
在所有整数对上运行它,至少可以说是乏味的。我们能做的最好是对那些在程序中存在错误的情况下,有合理概率产生错误答案的整数对进行测试。
测试的关键在于找到一个输入集合,称为测试套件(test suite),它有较高的可能性揭示缺陷,但运行时间不应过长。做到这一点的关键是将所有可能输入的空间划分为提供关于程序正确性的等效信息的子集,然后构建一个包含每个划分至少一个输入的测试套件。(通常,构建这样的测试套件实际上是不可能的。可以将其视为一个不可实现的理想。)
一个集合的划分将该集合分为一组子集,使得原集合的每个元素正好属于一个子集。例如,考虑is_smaller(x, y)
。可能输入的集合是所有整数的成对组合。划分这个集合的一种方式是将其分为九个子集:
`x positive, y positive, x < y x positive, y positive, y < x x negative, y negative, x < y x negative, y negative, y < x x negative, y positive x positive, y negative x = 0, y = 0 x = 0, y` `≠ 0 x ≠ 0, y = 0`
如果我们在这些子集的每个值上测试实现,如果存在缺陷,我们有很好的机会(但没有保证)暴露出该缺陷。
对于大多数程序,找到良好的输入划分远比说起来容易。通常,人们依赖于基于探索代码和规范某种组合的不同路径的启发式方法。基于通过代码探索路径的启发式方法属于透明盒(glass-box)(或白盒(white-box))测试类。基于通过规范探索路径的启发式方法属于**黑盒测试(black-box testing)**类。
8.1.1 黑盒测试
原则上,黑盒测试是在不查看要测试代码的情况下构建的。黑盒测试允许测试人员和实现人员来自不同的群体。当我们这些教授编程课程的人为分配给学生的问题集生成测试用例时,我们正在开发黑盒测试套件。商业软件的开发者通常拥有与开发组基本独立的质量保证组。他们也会开发黑盒测试套件。
这种独立性降低了生成测试套件时错误与代码中错误相关联的可能性。例如,假设程序的作者做出了隐含但无效的假设,即一个函数永远不会被负数调用。如果同一个人构建了程序的测试套件,他很可能会重复这个错误,而不测试负参数的函数。
黑盒测试的另一个积极特征是它对实现变化的鲁棒性。由于测试数据是在不知实现的情况下生成的,因此实现变更时测试无需更改。
正如我们之前所说,生成黑盒测试数据的一个好方法是通过规范探索路径。考虑这个规范。
def sqrt(x, epsilon):
"""Assumes x, epsilon floats
x >= 0
epsilon > 0
Returns result such that
x-epsilon <= result*result <= x+epsilon"""
在这个规范中似乎只有两条不同的路径:一条对应于x = 0
,另一条对应于x > 0
。然而,常识告诉我们,虽然必须测试这两种情况,但这远远不够。
边界条件也应进行测试。查看类型为列表的参数通常意味着查看空列表、只有一个元素的列表、不可变元素的列表、可变元素的列表,以及包含列表的列表。处理数字时,通常意味着查看非常小和非常大的值以及“典型”值。例如,对于sqrt
,尝试值x
和epsilon
与图 8-1 中的值相似可能是有意义的。
图 8-1 测试边界条件
前四行旨在表示典型案例。注意,x
的值包括一个完全*方数、一个小于一的数和一个具有无理*方根的数。如果这些测试中的任何一个失败,则程序中存在需要修复的错误。
剩余的行测试x
和epsilon
的极大和极小值。如果这些测试中的任何一个失败,就需要修复某些内容。可能代码中存在一个需要修复的错误,或者可能需要更改规范,以便更容易满足。例如,期望在epsilon
极小的情况下找到*方根的*似值可能是不合理的。
另一个重要的边界条件是别名。考虑代码
def copy(L1, L2):
"""Assumes L1, L2 are lists
Mutates L2 to be a copy of L1"""
while len(L2) > 0: #remove all elements from L2
L2.pop() #remove last element of L2
for e in L1: #append L1's elements to initially empty L2
L2.append(e)
这通常可以正常工作,但在L1
和L2
指向同一列表时则不然。任何未包含形式为copy(L, L)
的调用的测试套件,都无法揭示这个错误。
8.1.2 玻璃盒测试
黑箱测试绝不能被跳过,但它通常是不够的。没有查看代码的内部结构,无法知道哪些测试用例可能提供新信息。考虑这个简单的例子:
def is_prime(x):
"""Assumes x is a nonnegative int
Returns True if x is prime; False otherwise"""
if x <= 2:
return False
for i in range(2, x):
if x%i == 0:
return False
return True
查看代码后,我们可以看到由于测试if x <= 2
,值0
、1
和2
被视为特例,因此需要进行测试。如果不查看代码,可能不会测试is_prime(2)
,因此不会发现函数调用is_prime(2)
返回False
,错误地表示2
不是素数。
玻璃盒测试套件通常比黑盒测试套件更容易构建。规范(包括本书中的许多规范)通常是不完整的,且相当粗糙,这使得估计黑盒测试套件对有趣输入空间的探索程度变得具有挑战性。相比之下,代码路径的概念是明确的,评估探索的彻底程度相对容易。实际上,有商业工具可用于客观地测量玻璃盒测试的完整性。
一个玻璃盒测试套件是路径完整的,如果它测试程序中的每一个潜在路径。这通常是无法实现的,因为它取决于每个循环执行的次数和每次递归的深度。例如,递归实现的阶乘对每个可能的输入遵循不同的路径(因为递归的层数不同)。
此外,即使是路径完整的测试套件也不能保证所有的 bug 都会被暴露。考虑:
def abs(x):
"""Assumes x is an int
Returns x if x>=0 and –x otherwise"""
if x < -1:
return -x
else:
return x
规范建议有两种可能情况:x
要么为负,要么不是。这表明输入集合{2, -2}
足以探索规范中的所有路径。这个测试套件还有一个额外的优点,就是强迫程序遍历所有路径,因此它看起来像是一个完整的玻璃盒测试套件。唯一的问题是,这个测试套件不会暴露abs(-1)
会返回-1
的事实。
尽管玻璃盒测试存在局限性,但通常值得遵循一些经验法则:
针对所有
if
语句的两个分支都要进行测试。确保每个
except
子句(见第九章)都被执行。对于每个
for
循环,设置测试用例,其中○ 循环未被进入(例如,如果循环是迭代列表中的元素,请确保在空列表上进行测试)。
○ 循环体被执行恰好一次。
○ 循环体被执行多于一次。
对于每个
while
循环○ 查看与处理
for
循环时相同类型的情况。○ 包含对应于所有可能退出循环的测试用例。例如,对于一个以
while len(L) > 0 and not L[i] == e
找到由于
len(L)
大于零而退出循环的情况,以及由于L[i] == e
而退出循环的情况。
对于递归函数,包含导致函数在没有递归调用、恰好一次递归调用和多于一次递归调用时返回的测试用例。
8.1.3 进行测试
测试通常被认为分为两个阶段。应始终从单元测试开始。在这个阶段,测试人员构建并运行旨在确定单个代码单元(例如函数)是否正常工作的测试。接下来是集成测试,旨在确定单元组合在一起时是否正常工作。最后,功能测试用于检查程序整体是否按预期行为。实际上,测试人员在这些阶段之间循环,因为集成或功能测试中的失败会导致对单个单元进行更改。
功能测试几乎总是最具挑战性的阶段。整个程序的预期行为比每个部分的预期行为更难以表征。例如,表征文字处理器的预期行为要比表征计算文档中字符数的子系统的行为困难得多。规模问题也可能使功能测试变得困难。功能测试耗时数小时甚至数天并不罕见。
许多工业软件开发组织设有独立于实施软件的团队的**软件质量保证(SQA)**小组。SQA 小组的使命是确保软件在发布之前适合其预期目的。在某些组织中,开发小组负责单元测试,而质量保证小组负责集成和功能测试。
在工业界,测试过程通常高度自动化。测试人员⁵¹不会坐在终端上输入数据和检查输出。相反,他们使用测试驱动程序,这些驱动程序能够自主执行。
设置需要调用程序(或单元)进行测试的环境。
使用预定义或自动生成的输入序列调用程序(或单元)进行测试。
保存这些调用的结果。
检查测试结果的可接受性。
准备一份适当的报告。
在单元测试过程中,我们通常需要构建桩和驱动程序。驱动程序模拟使用被测单元的程序部分,而桩则模拟被测单元使用的程序部分。桩很有用,因为它们允许人们测试依赖于尚未存在的软件或有时甚至是硬件的单元。这使得程序员团队能够同时开发和测试系统的多个部分。
理想情况下,桩应该
检查调用者提供的环境和参数的合理性(用不当参数调用函数是一种常见错误)。
以与规范一致的方式修改参数和全局变量。
返回与规范一致的值。
构建足够的桩常常是一项挑战。如果桩所替代的单元旨在执行某些复杂任务,构建一个与规范一致的桩可能相当于编写桩所设计替代的程序。克服这个问题的一种方法是限制桩接受的参数集,并创建一个包含每种参数组合的返回值的表。
自动化测试过程的一个吸引力是它促进了回归测试。当程序员试图调试一个程序时,安装一个“修复”通常会破坏曾经正常工作的一些或多个功能。无论做出多小的改变,都应检查程序是否仍通过所有之前通过的测试。
8.2 调试
关于修复软件缺陷的过程为何被称为调试,有一个迷人的都市传说。图 8-2 中的照片是一份来自 1947 年 9 月的实验室记录,记录了哈佛大学 Mark II 艾肯继电器计算机组的工作。注意页面上贴着的蛾子和下面的短语“第一次发现的 bug”。
图 8-2 不是第一个 bug
有人声称,困在 Mark II 中的那只不幸的蛾子的发现导致了“调试”这一短语的使用。然而,措辞“第一次发现的真实案例”暗示这个短语的更不字面化的解释已经相当普遍。Mark II 项目的领导者格雷斯·穆雷·霍普明确表示,“bug”一词在二战期间已经被广泛用来描述电子系统的问题。而在此之前,霍金斯电力新教义这本 1896 年的电力手册中已包含条目:“‘bug’一词在有限的范围内用来指代电器连接或工作中的任何故障或问题。”在英语中,“bugbear”一词意为“导致似乎无谓或过度恐惧或焦虑的任何事物”。莎士比亚似乎把这个缩短为“bug”,当他让哈姆雷特抱怨“我生活中的 bug 和小鬼”时。
“bug”一词的使用有时会让人忽视一个基本事实:如果你编写了一个程序并且它有一个“bug”,那么是你犯了错。错误不会自然而然地出现在完美的程序中。如果你的程序有 bug,那是因为你把它放进去了。错误不会在程序中繁殖。如果你的程序有多个 bug,那是因为你犯了多个错误。
运行时错误可以沿两个维度进行分类:
明显→隐蔽:一个明显的 bug有明显的表现,例如程序崩溃或运行时间远长于应有的时间(可能永远)。一个隐蔽的 bug没有明显的表现。程序可能顺利运行到结束——只是提供了错误的答案。许多 bug 介于这两种极端之间,bug 是否明显可能取决于你多仔细地检查程序的行为。
持久性 → 间歇性:持久性错误是在相同输入下,每次运行程序时都会发生的错误。间歇性错误则只在某些情况下发生,即使程序在相同输入和看似相同条件下运行。当我们进入第十六章时,将探讨在随机性起作用的情况下建模的程序。这类程序中,间歇性错误是很常见的。
最好的错误是明显且持久的。开发人员不应抱有部署该程序的幻想。如果其他人傻到尝试使用它,他们会迅速意识到自己的愚蠢。也许程序会在崩溃之前做一些可怕的事情,例如删除文件,但至少用户会有理由感到担忧(甚至恐慌)。优秀的程序员会尽量以一种方式编写程序,使编程错误导致的缺陷既明显又持久。这通常被称为防御性编程。
进入不理想状态的下一步是明显但间歇性的错误。一个几乎总能计算出飞机正确位置的空中交通管制系统,远比一个总是犯明显错误的系统危险。人们可能会在虚幻的乐园中生活一段时间,甚至将有缺陷的程序部署,但迟早这个错误会显现出来。如果促使错误显现的条件容易重现,通常相对容易追踪和修复问题。如果导致错误的条件不明确,生活就会变得困难得多。
以隐蔽方式失败的程序往往非常危险。由于它们表面上并没有明显问题,人们使用并信任它们能正确执行任务。社会日益依赖软件进行超出人类能力范围的关键计算,甚至无法验证其正确性。因此,一个程序可能会在长时间内提供未被发现的错误答案。这类程序造成的损害是巨大的。⁵⁴ 评估抵押债券投资组合风险的程序若输出错误答案,可能会让银行(甚至整个社会)陷入麻烦。飞行管理计算机中的软件可能决定飞机是否能继续飞行。⁵⁵ 对于癌症患者来说,放射治疗机器若提供多或少的辐射,都可能是生与死的区别。偶尔发生隐蔽错误的程序,可能不会比总是出错的程序造成更少的破坏。隐蔽且间歇性出现的错误几乎总是最难发现和修复的。
8.2.1 学习调试
调试是一项学习技能。没有人能够凭直觉做到这一点。好消息是,它并不难学,而且是一项可转移的技能。用于调试软件的相同技能可以用于发现其他复杂系统中的问题,例如实验室实验或生病的人。
在过去的四十多年里,人们一直在构建称为调试器的工具,调试工具已经内置于所有流行的 Python IDE 中。(如果你还没试过,建议尝试 Spyder 中的调试工具。)这些工具可以提供帮助。但更重要的是你如何看待这个问题。许多经验丰富的程序员甚至不使用调试工具,而是依赖于print
语句。
当测试表明程序表现出不理想的行为时,调试便开始了。调试是寻找该行为解释的过程。持续优秀调试的关键是在进行搜索时保持系统性。
首先,研究可用的数据。这包括测试结果和程序文本。研究所有测试结果。不仅要检查揭示问题存在的测试,还要检查那些似乎完美工作的测试。试图理解为什么一个测试有效而另一个无效常常会很有启发性。在查看程序文本时,要记住你并不完全理解它。如果你理解了,可能就不会有错误。
接下来,形成一个你认为与所有数据一致的假设。这个假设可以像“如果我将第 403 行从x < y
改为x <= y
,问题就会消失”那样狭窄,也可以像“我的程序不工作是因为我忘记了多个地方可能存在别名问题”那样广泛。
接下来,设计并运行一个可重复的实验,以可能驳斥这个假设。例如,你可以在每个循环前后放置打印语句。如果这些打印语句总是成对出现,那么循环导致非终止的假设就被驳斥了。在运行实验之前决定如何解释各种可能的结果。所有人都受到心理学家称之为确认偏误的影响——我们以一种强化我们想要相信的方式来解释信息。如果你在运行实验后才考虑结果应该是什么,你更可能落入一厢情愿的思维陷阱。
最后,记录你尝试过的实验。当你花了很多小时更改代码以试图追踪一个难以捉摸的错误时,很容易忘记你已经尝试过什么。如果你不小心,你可能会浪费太多时间反复尝试相同的实验(或者更可能是看似不同但会给你相同信息的实验)。记住,正如许多人所说,“疯狂就是不断重复相同的事情,却期待不同的结果。” ⁵⁶
8.2.2 设计实验
将调试视为一个搜索过程,每个实验都是试图缩小搜索空间的尝试。缩小搜索空间的一种方法是设计一个实验,用于判断代码的特定区域是否对在测试中发现的问题负责。另一种缩小搜索空间的方法是减少需要引发 bug 表现的测试数据量。
让我们看一个虚构的例子,看看你可能如何进行调试。想象一下,你在图 8-3 中编写了回文检查代码。
图 8-3 有 bug 的程序
现在,想象一下你对自己的编程技能充满信心,以至于将这段代码上传到网络上——没有经过测试。进一步假设你收到了一个电子邮件,上面写着:“我通过输入圣经中的 3,116,480 个字母测试了你的!!**!程序,你的程序打印了是
。然而任何傻瓜都能看出圣经不是回文。修复它!(是你的程序,不是圣经。)”
你可以尝试在圣经上测试它。但开始时尝试在更小的东西上可能更明智。实际上,测试一个最小的非回文字符串是有意义的,例如,
>>> silly(2)
Enter element: a
Enter element: b
好消息是,它甚至未能通过这个简单测试,因此你不必输入数百万个字符。坏消息是,你不知道为什么它失败了。
在这种情况下,代码足够小,你可能可以盯着它找到 bug(或多个 bug)。不过,让我们假装它太大而无法做到这一点,开始系统地缩小搜索空间。
最好的方法通常是进行二分搜索。找到代码中大约一半的某个点,并设计一个实验,以便在该点之前判断是否存在可能与症状相关的问题。(当然,该点之后可能也有问题,但通常最好一次解决一个问题。)在选择这样一个点时,寻找一些容易检查的中间值,这些值能提供有用的信息。如果某个中间值不是你所期望的,那么很可能在代码的那个点之前就发生了问题。如果所有中间值看起来都正常,bug 可能出现在代码的后面。这个过程可以重复进行,直到你将问题所在的区域缩小到几行代码,或者如果你在测试一个大型系统,则缩小到几个单元。
查看 silly
,中途点大约在 if is_pal(result)
行。显而易见的检查是 result
是否具有预期值 ['a', 'b']
。我们通过在 silly
的 if
语句之前插入 print(result)
语句来检查这一点。当实验运行时,程序打印 ['b']
,这表明事情已经出错。下一步是在循环中大约中途打印值 result
。这很快揭示 result
从未超过一个元素,表明 result
的初始化需要移到 for
循环之外。
“修正后的” silly
代码是
def silly(n):
"""Assumes n is an int > 0
Gets n inputs from user
Prints 'Yes' if the sequence of inputs forms a palindrome;
'No' otherwise"""
result = []
for i in range(n):
elem = input('Enter element: ')
result.append(elem)
print(result)
if is_pal(result):
print('Yes')
else:
print('No')
让我们试试这个,看看 for
循环后 result
是否有正确的值。确实如此,但不幸的是程序仍然打印 Yes
。现在,我们有理由相信第二个错误出现在 print
语句下方。因此,让我们查看 is_pal
。插入这一行
`print(temp, x)`
return
语句之前。当我们运行代码时,我们发现 temp
的值是预期的,但 x
不是。向上移动代码,我们在代码行 temp = x
后插入了一个 print
语句,发现 temp
和 x
的值都是 ['a', 'b']
。快速检查代码后发现,在 is_pal
中我们写成了 temp.reverse
而不是 temp.reverse()
——temp.reverse
的评估返回了列表的内置 reverse
方法,但并没有调用它。
我们再次运行测试,现在似乎 temp
和 x
的值都是 ['b', 'a']
。我们已经将错误缩小到一行。看起来 temp.reverse()
意外地改变了 x
的值。一个别名错误出现了:temp
和 x
是同一个列表的名称,在列表被反转之前和之后都是如此。修复这个错误的一种方法是将 is_pal
中的第一个赋值语句替换为 temp = x[:]
,这会创建 x
的一个副本。
修正后的 is_pal
版本是
def is_pal(x):
"""Assumes x is a list
Returns True if the list is a palindrome; False otherwise"""
temp = x[:]
temp.reverse()
return temp == x
8.2.3 当事情变得棘手时
约瑟夫·P·肯尼迪,美国总统约翰·F·肯尼迪的父亲,曾声称告诫他的孩子们:“当事情变得棘手时,强者开始行动。” ⁵⁷但他从未调试过一段软件。这个小节包含一些关于调试变得棘手时该怎么办的务实提示。
寻找常见问题。你是否在
○ 以错误的顺序向函数传递参数?
○ 拼写错误的名字,例如,在应该大写字母时打成小写字母?
○ 未能重新初始化变量?
○ 测试两个浮点值是否相等 (
==
),而不是*似相等(记住,浮点运算与你在学校学的算术是不同的)?○ 在需要测试对象相等性时(例如,使用表达式
L1 == L2
比较两个列表)测试值相等性(例如,id(L1) == id(L2)
)?○ 忘记某个内置函数有副作用?
○ 忘记了将一个对
function
类型对象的引用转换为函数调用的()
?○ 创建了一个无意的别名?
○ 是否犯了你典型的其他错误?
停止问自己为什么程序没有按照你的想法运行。相反,问问自己为什么它会这样运行。 这个问题应该更容易回答,也可能是找出如何修复程序的第一步。
记住,错误可能不在你认为的地方。 如果在,你早就找到了。决定查看哪里的一个实用方法是问问错误不可能出现在哪里。正如福尔摩斯所说:“排除所有其他因素,剩下的那个必须是真相。” ⁵⁸
尝试向其他人解释这个问题。 我们都会产生盲点。仅仅尝试向某人解释问题,通常会让你看到自己遗漏的地方。你还可以尝试解释为什么这个错误不可能出现在某些地方。
*不要相信你看到的一切。*⁵⁹ 特别是,不要相信文档。代码可能并没有按照注释所建议的那样运行。
停止调试,开始编写文档。 这将帮助你从不同的角度看待问题。
走开,明天再试。 这可能意味着修复这个错误的时间比坚持下去要晚,但你可能会花更少的时间去寻找它。也就是说,可以用延迟来换取效率。(学生们,这也是你们早点开始编程问题集工作的一个绝佳理由!)
8.2.4 当你找到“那个”错误时
当你认为在代码中发现了一个错误时,开始编码和测试修复的诱惑几乎不可抵挡。然而,通常更好的是暂停。记住,目标不是修复一个错误,而是快速有效地朝着无错误的程序迈进。
问问自己这个错误是否解释了所有观察到的症状,还是仅仅冰山一角。如果是后者,可能最好与其他更改一起处理这个错误。假设,例如,你发现这个错误是因为意外改变了一个列表。你可以局部规避这个问题,可能通过复制列表来实现。或者,你可以考虑用元组代替列表(因为元组是不可变的),也许可以消除代码中其他类似的错误。
在做任何更改之前,尝试理解提议的“修复”的影响。它会破坏其他内容吗?是否引入了过度复杂性?是否提供了整理代码其他部分的机会?
始终确保你可以回到当前位置。没有什么比意识到一系列更改让你离目标更远却没有办法回到起点更让人沮丧。磁盘空间通常是充足的。利用它来存储程序的旧版本。
最后,如果有许多未解释的错误,你可能需要考虑一下,逐个查找和修复错误是否是正确的方法。也许你更应该考虑更好的程序组织方式,或者可能是一个更简单的算法,这样更容易正确实现。
8.3 本章介绍的术语
测试
调试
测试套件
输入分区
玻璃盒测试
黑盒测试
路径完全测试
单元测试
集成测试
功能测试
软件质量保证(SQA)
测试驱动程序
测试存根
回归测试
错误
显性错误
隐蔽错误
持久性错误
间歇性错误
防御性编程
调试工具
确认偏误
二分搜索
第九章:异常和断言
“异常”通常定义为“不符合规范的事物”,因此有些稀有。在 Python 中,异常并不稀有。它们无处不在。标准 Python 库中的几乎每个模块都在使用它们,而 Python 本身在许多情况下也会引发它们。你已经见过一些异常。
打开一个 Python 终端并输入
test = [1,2,3]
test[3]
解释器会响应类似于以下内容的内容。
IndexError: list index out of range
IndexError
是 Python 在程序尝试访问超出可索引类型范围的元素时引发的异常类型。紧随IndexError
的字符串提供了有关导致异常发生的额外信息。
Python 的大多数内置异常处理那些程序试图执行没有适当语义的语句的情况。(我们将在本章后面处理那些不涉及错误的特殊异常。)
那些尝试编写和运行 Python 程序的读者(我们希望你们都是)已经遇到过许多这样的异常。最常见的异常类型包括TypeError
、IndexError
、NameError
和ValueError
。
9.1 处理异常
到目前为止,我们将异常视为终止事件。当抛出异常时,程序终止(在这种情况下,崩溃可能是更合适的词),然后我们返回代码,试图找出出错的原因。当抛出导致程序终止的异常时,我们说发生了未处理的异常。
异常不一定会导致程序终止。引发的异常可以并且应该由程序处理。有时异常是因为程序中存在错误(例如访问不存在的变量),但许多情况下,异常是程序员可以并且应该预见的。程序可能尝试打开一个不存在的文件。如果一个交互式程序要求用户输入,用户可能会输入不适当的内容。
Python 提供了一个方便的机制,try-except,用于捕获和处理异常。一般形式是
try
*code block*
except (*list of exception names*):
*code block*
else:
*code block*
如果你知道某行代码在执行时可能会引发异常,你应该处理该异常。在一段写得好的程序中,未处理的异常应该是个例外。
考虑一下这段代码。
success_failure_ratio = num_successes/num_failures
print('The success/failure ratio is', success_failure_ratio)
大多数情况下,这段代码将正常工作,但如果num_failures
恰好为零,则会失败。尝试除以零将导致 Python 运行时系统引发ZeroDivisionError
异常,而print
语句将永远无法被执行。
最好写成如下内容
try:
success_failure_ratio = num_successes/num_failures
print('The success/failure ratio is', success_failure_ratio)
except ZeroDivisionError:
print('No failures, so the success/failure ratio is undefined.')
进入try
块后,解释器尝试评估表达式num_successes/num_failures
。如果表达式评估成功,程序将表达式的值赋给变量success_failure_ratio
,执行try
块末尾的print
语句,然后继续执行try-except
块后面的代码。然而,如果在表达式评估过程中抛出ZeroDivisionError
异常,控制将立即跳转到except
块(跳过try
块中的赋值和print
语句),执行except
块中的print
语句,然后继续执行try-except
块之后的代码。
手指练习: 实现一个满足以下规范的函数。使用try-except
块。提示:在开始编码之前,你可能想在 shell 中输入类似1 + 'a'
的内容,以查看抛出什么类型的异常。
def sum_digits(s):
"""Assumes s is a string
Returns the sum of the decimal digits in s
For example, if s is 'a2b3c' it returns 5"""
如果程序代码块可能引发多种异常,保留字except
后面可以跟一个异常元组,例如,
except (ValueError, TypeError):
在这种情况下,如果在try
块内抛出列出的任何异常,将进入except
块。
另外,我们可以为每种异常编写一个单独的except
块,这样程序可以根据抛出的异常选择相应的操作。如果程序员编写
except:
如果在try
块内抛出任何类型的异常,将会进入except
块。请参阅图 9-1 中的函数定义。
图 9-1 使用异常进行控制流
与try
块相关联的有两个except
块。如果在try
块中抛出异常,Python 首先检查它是否为ZeroDivisionError
。如果是,它将类型为float
的特殊值nan
附加到ratios
中。(值nan
表示“不是一个数字”。它没有字面意义,但可以通过将字符串'nan'
或字符串'NaN'
转换为类型float
来表示。当nan
作为类型为float
的表达式的操作数时,该表达式的值也是nan
。)如果异常是其他类型而不是ZeroDivisionError
,则代码执行第二个except
块,抛出带有关联字符串的ValueError
异常。
原则上,第二个except
块不应该被进入,因为调用get_ratios
的代码应该遵循get_ratios
规范中的假设。然而,由于检查这些假设所带来的计算负担微乎其微,因此进行防御性编程并检查它们可能是值得的。
以下代码演示了程序如何使用get_ratios
。在行except ValueError as msg:
中,msg
绑定到与抛出的ValueError
相关联的参数(在这种情况下是一个字符串)。当代码
try:
print(get_ratios([1, 2, 7, 6], [1, 2, 0, 3]))
print(get_ratios([], []))
print(get_ratios([1, 2], [3]))
except ValueError as msg:
print(msg)
执行后打印
[1.0, 1.0, nan, 2.0]
[]
get_ratios called with bad arguments
为了比较,图 9-2 包含了相同规范的实现,但没有使用try-except
。 图 9-2 中的代码比图 9-1 中的代码更长且更难阅读,效率也更低。(图 9-2 中的代码可以通过消除局部变量vect1_elem
和vect2_elem
来缩短,但这样做将通过重复索引列表而引入更多的低效。)
图 9-2 没有 try-except 的控制流
让我们看另一个例子。考虑以下代码:
val = int(input('Enter an integer: '))
print('The square of the number you entered is', val**2)
如果用户乐意输入一个可以转换为整数的字符串,一切都会很好。但假设用户输入abc
呢?执行这行代码将导致 Python 运行时系统抛出ValueError
异常,而print
语句将永远不会被执行。
程序员应该写的代码大致如下:
while True:
val = input('Enter an integer: ')
try:
val = int(val)
print('The square of the number you entered is', val**2)
break #to exit the while loop
except ValueError:
print(val, 'is not an integer')
进入循环后,程序会要求用户输入一个整数。一旦用户输入了某个值,程序将执行try—except
块。如果try
块中的前两个语句都没有引发ValueError
异常,将执行break
语句并退出while
循环。然而,如果执行try
块中的代码引发了ValueError
异常,控制权将立即转移到except
块中的代码。因此,如果用户输入了一个不表示整数的字符串,程序将要求用户重试。无论用户输入什么文本,都不会导致未处理的异常。
这种改变的缺点是程序文本从两行增加到了八行。如果有很多地方要求用户输入整数,这可能会成为一个问题。当然,这个问题可以通过引入一个函数来解决:
def read_int():
while True:
val = input('Enter an integer: ')
try:
return(int(val)) #convert str to int before returning
except ValueError:
print(val, 'is not an integer')
更好的是,这个函数可以推广到请求任何类型的输入:
def read_val(val_type, request_msg, error_msg):
while True:
val = input(request_msg + ' ')
try:
return(val_type(val)) #convert str to val_type
except ValueError:
print(val, error_msg)
函数read_val
是多态的,即它适用于多种不同类型的参数。这类函数在 Python 中很容易编写,因为类型是一等对象。我们现在可以使用以下代码请求一个整数:
`val = read_val(int, 'Enter an integer:', 'is not an integer')`
异常可能看起来不友好(毕竟,如果不处理,异常会导致程序崩溃),但考虑一下替代方案。当要求将字符串'abc'
转换为int
类型的对象时,类型转换int
应该怎么做?它可以返回与编码字符串所用位对应的整数,但这与程序员的意图不太可能相关。或者,它可以返回特殊值None
。如果这样做,程序员就需要插入代码来检查类型转换是否返回了None
。如果程序员忘记了这个检查,程序执行时就有可能出现一些奇怪的错误。
使用异常时,程序员仍需包含处理异常的代码。然而,如果程序员忘记包含这样的代码且异常被引发,程序将立即停止。这是件好事。它提醒程序用户发生了一些麻烦的事情。(正如我们在第八章讨论的,显性错误远比隐性错误好。)此外,它为调试程序的人提供了明确的指示,说明哪里出了问题。
9.2 异常作为控制流机制
不要认为异常仅仅是错误的表现。它们是一个便捷的控制流机制,可以用来简化程序。
在许多编程语言中,处理错误的标准方法是让函数返回一个值(通常类似于 Python 的None
),以指示出现了问题。每次函数调用都必须检查是否返回了该值。在 Python 中,通常在函数无法生成与其规格一致的结果时引发异常。
Python 中的**raise**
语句强制引发指定的异常。raise 语句的形式是:
`raise` *exceptionName*`(`*arguments*`)`
exceptionName 通常是内置异常之一,例如ValueError
。但是,程序员可以通过创建内置类Exception
的子类(见第十章)来定义新的异常。不同类型的异常可以有不同类型的参数,但大多数情况下,参数是一个字符串,用于描述引发异常的原因。
手指练习: 实现一个满足规格的函数。
def find_an_even(L):
"""Assumes L is a list of integers
Returns the first even number in L
Raises ValueError if L does not contain an even number"""
让我们看一个例子,图 9-3。函数get_grades
要么返回一个值,要么引发一个与之关联的值的异常。如果调用open
引发IOError
,它将引发ValueError
异常。它本可以忽略IOError
,让调用get_grades
的程序部分处理,但那会给调用代码提供更少的信息,关于出错的原因。调用get_grades
的代码要么使用返回的值计算另一个值,要么处理异常并打印有用的错误信息。
图 9-3 获取成绩
9.3 断言
Python 的assert
语句为程序员提供了一种简单的方法,以确认计算状态是否如预期。assert 语句可以有两种形式:
`assert` *Boolean expression*
py`or
assert
布尔表达式,
参数 ```py When an assert
statement is encountered, the Boolean expression is evaluated. If it evaluates to True
, execution proceeds on its merry way. If it evaluates to False
, an AssertionError
exception is raised. Assertions are a useful defensive programming tool. They can be used to confirm that the arguments to a function are of appropriate types. They are also a useful debugging tool. They can be used, for example, to confirm that intermediate values have the expected values or that a function returns an acceptable value.```` ## 9.4 在第章中引入的术语 * 异常 * 引发异常 * 未处理异常 * 已处理异常 * try-except 构造 * 捕获(异常) * 多态函数 * 一流对象 * raise 语句 * 断言
第十章:类与面向对象编程
现在我们将注意力转向与在 Python 中编程相关的最后一个主要主题:使用类来围绕数据抽象组织程序。
类可以以多种不同的方式使用。在本书中,我们强调在面向对象编程的背景下使用它们。面向对象编程的关键在于将对象视为数据和操作这些数据的方法的集合。
面向对象编程的理念已有大约 50 年的历史,在过去的 30 年左右得到了广泛接受和实践。在 1970 年代中期,人们开始撰写文章解释这种编程方法的好处。大约在同一时间,编程语言 SmallTalk(在 Xerox PARC)和 CLU(在 MIT)为这些理念提供了语言支持。但直到 C++ 和 Java 的出现,面向对象编程才真正开始在实践中蓬勃发展。
在本书的大部分内容中,我们一直隐含地依赖于面向对象编程。在第 2.2.1 节中,我们说过:“对象是 Python 程序操作的核心。每个对象都有一个类型,定义了程序可以对该对象做的事情。”自第二章以来,我们依赖于内置类型,如 float
和 str
及其相关的方法。但正如编程语言的设计者只能内置一小部分有用的函数,他们也只能内置一小部分有用的类型。我们已经看过一种允许程序员定义新函数的机制;现在我们将看一种允许程序员定义新类型的机制。
10.1 抽象数据类型与类
抽象数据类型的概念相当简单。抽象数据类型是一组对象及其上的操作。这些被绑定在一起,以便程序员可以将一个对象从程序的一个部分传递到另一个部分,并在此过程中提供对对象的数据属性的访问,以及方便操作这些数据的操作。
这些操作的规范定义了抽象数据类型与程序其他部分之间的接口。接口定义了操作的行为——它们做什么,但不说明它们如何做到这一点。接口因此提供了一个抽象屏障,将程序的其余部分与提供类型抽象实现所涉及的数据结构、算法和代码隔离开来。
编程是以一种促进变化的方式来管理复杂性。有两种强大的机制可以实现这一点:分解和抽象。分解在程序中创建结构,而抽象则抑制细节。关键在于抑制适当的细节。这就是数据抽象能够发挥作用的地方。我们可以创建提供便利抽象的特定领域类型。理想情况下,这些类型捕捉在程序生命周期内相关的概念。如果我们在编程过程中设计出数月甚至数十年后仍然相关的类型,那么在维护该软件方面我们就有了很大的优势。
在本书中,我们一直在使用抽象数据类型(尽管没有这样称呼它们)。我们已经编写了使用整数、列表、浮点数、字符串和字典的程序,而没有考虑这些类型可能是如何实现的。用莫里哀的*《布尔乔亚绅士》的话来说,“我发誓,有超过一百页我们使用了 ADT,而我们并不知道。”*⁶⁰
在 Python 中,我们使用类实现数据抽象。每个类定义以保留字class
开头,后面跟着类名和关于它如何与其他类相关的信息。
考虑以下微小(完全无用的)类定义。
class Toy(object):
def __init__(self):
self._elems = []
def add(self, new_elems):
"""new_elems is a list"""
self._elems += new_elems
def size(self):
return len(self._elems)
第一行表示Toy
是object
的子类。目前,忽略作为子类意味着什么。我们很快就会涉及到这一点。
类定义创建一个type
类型的对象,并将一组称为属性的对象与该类对象关联。在这个例子中,与类相关的三个属性是__init__
、add
和size
。每个属性都是function
类型。因此,代码
print(type(Toy))
print(type(Toy.__init__), type(Toy.add), type(Toy.size))
打印
<class ‘type'>
<class 'function'> <class 'function'> <class 'function'>
正如我们将看到的,Python 有许多特殊的函数名称以两个下划线开始和结束。这些通常被称为魔法方法。⁶¹我们将要看的第一个是__init__
。每当类被实例化时,都会调用在该类中定义的__init__
函数。当执行以下代码行时
`s = Toy()`
被执行时,解释器将创建一个新的Toy
类型的实例,然后调用Toy.__init__
,将新创建的对象作为实际参数绑定到形式参数self
。当调用Toy.__init__
时,会创建列表对象_elems
,它成为新创建的Toy
类型实例的一部分。(该列表使用现在已熟悉的[]
符号创建,实际上是list()
的缩写。)列表_elems
被称为Toy
实例的数据属性。代码
`t1 = Toy() print(type(t1)) print(type(t1.add)) t2 = Toy() print(t1 is t2) #test for object identity`
打印
<class '__main__.Toy'>
<class 'method'>
False
请注意,t1.add
是method
类型,而Toy.add
是function
类型。由于t1.add
是一个方法,我们可以使用点表示法调用它(和t1.size
)。
类不应与该类的实例混淆,就像list
类型的对象不应与list
类型混淆一样。属性可以与类本身或类的实例关联:
类属性是在类定义中定义的;例如,
Toy.size
是类Toy
的一个属性。当类被实例化时,比如通过语句t = Toy()
,实例属性,比如t.size
,会被创建。虽然
t.size
最初绑定到类Toy
中定义的size
函数,但在计算过程中,这种绑定是可以改变的。例如,你可以(但绝对不应该!)通过执行t.size = 3
来改变绑定。当数据属性与类关联时,我们称它们为类变量。当它们与实例关联时,我们称它们为实例变量。例如,
_elems
是一个实例变量,因为对于每个Toy
类的实例,_elems
绑定到一个不同的列表。到目前为止,我们还没有看到类变量。我们将在图 10-4 中使用一个。
现在,考虑一下这段代码。
t1 = Toy()
t2 = Toy()
t1.add([3, 4])
t2.add([4])
print(t1.size() + t2.size())
因为每个Toy
实例都是不同的对象,所以每个Toy
类型的实例都会有不同的_elems
属性。因此,代码输出3.
。
初看起来,这段代码似乎存在不一致的地方。看起来每个方法调用时参数少了一。比如,add
有两个正式参数,但我们似乎只用一个实际参数在调用它。这是使用点表示法调用与类实例相关联的方法的结果。与点前的表达式相关联的对象会隐式地作为第一个参数传递给方法。在本书中,我们遵循使用self
作为这个实际参数绑定的正式参数名称的惯例。Python 程序员几乎普遍遵循这一惯例,我们强烈建议你也这样做。
另一个常见的惯例是以一个下划线开始数据属性的名称。正如我们在 10.3 节中详细讨论的那样,我们使用前导的_
来表示该属性是类的私有属性,即不应在类外部直接访问。
现在,让我们来看一个更有趣的例子。图 10-1 包含一个类定义,它提供了一个名为Int_set
的整数集合抽象的简单实现。(考虑到 Python 有内置的set
类型,这个实现既不必要又不必要地复杂。不过,它在教学上是有用的。)
图 10-1 类Int_set
请注意,类定义顶部的文档字符串(用"""
括起来的注释)描述的是类提供的抽象,而不是类的实现信息。相比之下,文档字符串下方的注释包含实现信息。这些信息面向可能想修改实现或构建该类子类的程序员,而不是希望使用该抽象的程序员。
如我们所见,与类实例相关的方法可以使用点符号调用。例如,代码,
s = Int_set()
s.insert(3)
print(s.member(3))
创建一个新的Int_set
实例,将整数 3 插入该Int_set
,然后打印True
。
数据抽象实现了表示独立性。将抽象类型的实现视为具有多个组件:
该类型方法的实现
一起编码该类型值的数据结构
关于方法实现如何使用数据结构的约定;一个关键约定由表示不变性捕捉
表示不变性定义了数据属性的哪些值对应于类实例的有效表示。Int_set
的表示不变性是vals
不包含重复值。__init__
的实现负责建立该不变性(空列表时成立),其他方法负责维护该不变性。这就是为什么insert
仅在self.vals
中不存在e
时才会添加它。
remove
的实现利用了在进入remove
时满足表示不变性的假设。它仅调用一次list.remove
,因为表示不变性保证self.vals
中最多只有一个e
的出现。
类中定义的最后一个方法__str__
是另一种特殊的__
方法。当程序通过调用str
将该类的实例转换为字符串时,将调用类的__str__
方法。因此,当使用print
命令时,打印对象的__str__
函数将被调用。例如,代码
s = Int_set()
s.insert(3)
s.insert(4)
print(str(s))
print('The value of s is', s)
将打印
{3,4}
The value of s is {3,4}
(如果没有定义__str__
方法,执行print(s)
将打印类似于<__main__.Int_set object at 0x1663510>
的内容。)
手指练习: 向Int_set
类添加一个满足以下规范的方法。
def union(self, other):
"""other is an Int_set
mutates self so that it contains exactly the elemnts in self
plus the elements in other."""
10.1.1 魔法方法与可哈希类型
Python 设计目标之一是允许程序员使用类定义新的类型,使其使用与 Python 内置类型一样简单。使用魔法方法为内置函数如str
和len
提供类特定的定义在实现这一目标中发挥了重要作用。
魔法方法还可以用于为中缀运算符如==和+提供类特定的定义。可用于中缀运算符的方法名称是
+: __add__ |
*: __mul__ |
/: __truediv__ |
---|---|---|
-: __sub__ |
//: __floordiv__ |
%: __mod__ |
**: __pow__ |
|: __or__ |
<: __lt__ |
<<: __lshift__ |
∧: __xor__ |
>: __gt__ |
>>: __rshsift__ |
==: __eq__ |
<=: __le__ |
&: __and__ |
!=: __ne__ |
>=: __ge__ |
你可以将任何实现与这些操作符关联。如果你愿意,可以将+
实现为减法,将<
实现为指数运算,等等。然而,我们建议你抵制这种想象力的机会,保持与这些操作符的传统含义一致的实现。
回到我们的玩具示例,考虑图 10-2 中的代码。
图 10-2 使用魔法方法
当运行图 10-2 中的代码时,它会打印
The value of t3 is [1, 2, 3, 4]
The length of t3 is 4
The value A is associated with the key t1 in d.
我们可以将Toy
的实例用作字典的键,因为我们为该类定义了__hash__
函数。如果我们定义了__eq__
函数但没有定义__hash__
函数,当我们尝试使用t1
和t2
作为键创建字典时,代码将生成错误消息unhashable type: ‘Toy'
。提供用户定义的__hash__
时,应该确保对象的哈希值在该对象的生命周期内保持不变。
所有未显式定义__eq__
的用户定义类的实例在==
中使用对象标识,并且是可哈希的。如果没有提供__hash__
方法,则对象的哈希值来自对象的标识(见第 5.3 节)。
手指练习:用一种允许Int_set
的客户端使用+
操作符表示集合并集的方法替换你添加到Int_set
中的union
方法。
10.1.2 使用抽象数据类型设计程序
抽象数据类型非常重要。它们导致了对组织大型程序的新思维方式。当我们思考世界时,我们依赖于抽象。在金融界,人们谈论股票和债券。在生物界,人们谈论蛋白质和残基。当试图理解这些概念时,我们会在脑海中将相关数据和特征汇集成一个知识包。例如,我们认为债券具有利率、到期日和价格等数据属性。我们还认为债券具有“设定价格”和“计算到期收益率”等操作。抽象数据类型使我们能够将这种组织方式融入程序设计中。
数据抽象鼓励程序设计者关注数据对象的核心,而不是函数。将程序更多地视为类型的集合而不是函数的集合,会导致根本不同的组织原则。除此之外,它鼓励我们将编程视为组合相对较大块的过程,因为数据抽象通常涵盖比单个函数更多的功能。这反过来使我们认为编程的本质是一个不是写个别代码行,而是组合抽象的过程。
可重用抽象的可用性不仅减少了开发时间,而且通常导致更可靠的程序,因为成熟的软件通常比新软件更可靠。多年来,常用的程序库只有统计或科学库。然而,今天可用的程序库范围广泛(尤其是针对 Python),通常基于丰富的数据抽象集,正如我们在本书后面将看到的那样。
10.1.3 使用类跟踪学生和教职工
作为类的示例使用,想象你正在设计一个程序,以帮助跟踪大学的所有学生和教职工。确实可以在不使用数据抽象的情况下编写这样的程序。每个学生将有一个姓氏、名字、家庭地址、年级、一些成绩等。这些数据可以通过列表和字典的组合来存储。跟踪教职工需要一些类似的数据结构和一些不同的数据结构,例如,用于跟踪薪资历史的数据结构。
在急于设计一堆数据结构之前,让我们考虑一些可能有用的抽象。是否存在一个覆盖学生、教授和工作人员共同属性的抽象?有人会争辩说,他们都是人。图 10-3 包含一个类,它结合了人类的两个共同属性(姓名和生日)。它使用了标准的 Python 库模块datetime
,该模块提供了许多方便的方法来创建和处理日期。
图 10-3 类Person
以下代码使用了Person
和datetime
。
me = Person('Michael Guttag')
him = Person('Barack Hussein Obama')
her = Person('Madonna')
print(him.get_last_name())
him.set_birthday(datetime.date(1961, 8, 4))
her.set_birthday(datetime.date(1958, 8, 16))
print(him.get_name(), 'is', him.get_age(), ‘days old')
请注意,每当实例化Person
时,都需要向__init__
函数提供一个参数。一般来说,实例化一个类时,我们需要查看该类的__init__
函数的规范,以了解需要提供哪些参数以及这些参数应该具备什么属性。
执行上述代码会创建三个类Person
的实例。我们可以使用与这些实例相关联的方法访问有关它们的信息。例如,him.get_last_name()
返回'Obama'
。表达式him._last_name
也会返回'Obama'
;然而,由于本章后面讨论的原因,直接访问实例变量的表达式被认为是不良的写法,应当避免。同样,尽管实现中包含一个具有该值的属性,但对于Person
抽象的用户来说,没有合适的方法提取一个人的生日。(当然,可以很容易地为该类添加一个get_birthday
方法。)不过,有一种方法可以提取依赖于个人生日的信息,如上述代码中的最后一个print
语句所示。
类Person
为另一个特殊命名方法__lt__
提供了特定于Person
的定义。该方法重载了<
运算符。每当<
运算符的第一个参数为Person
类型时,方法Person__lt__
会被调用。类Person
中的__lt__
方法是使用类型str
的二元<
运算符实现的。表达式self._name < other._name
是self._name.__lt__(other._name)
的简写。由于self._name
是str
类型,因此这个__lt__
方法与类型str
相关联。
除了提供使用<
的中缀表达式书写的语法便利外,这种重载还自动访问任何使用__lt__
定义的多态方法。内置方法sort
就是这样一个方法。因此,例如,如果p_list
是由Person
类型元素组成的列表,则调用p_list.sort()
将使用类Person
中定义的__lt__
方法对该列表进行排序。因此,代码
pList = [me, him, her]
for p in pList:
print(p)
pList.sort()
for p in pList:
print(p)
将打印
Michael Guttag
Barack Hussein Obama
Madonna
Michael Guttag
Madonna
Barack Hussein Obama
10.2 继承
许多类型与其他类型有共同的属性。例如,类型list
和str
各自都有len
函数,其意义相同。继承提供了一种方便的机制,用于构建相关抽象的分组。它允许程序员创建一个类型层次结构,在这个结构中,每个类型从其上层类型继承属性。
类object
位于层次结构的顶部。这是合理的,因为在 Python 中,运行时存在的所有内容都是对象。由于Person
继承了对象的所有属性,程序可以将变量绑定到Person
,将Person
添加到列表等。
图 10-4 中的类MIT_person
继承自其父类Person
的属性,包括Person
从其父类object
继承的所有属性。在面向对象编程的术语中,MIT_person
是Person
的子类,因此继承了其超类的属性。除了继承的属性外,子类还可以:
添加新属性。例如,子类
MIT_person
添加了类变量 _next_id_num
、实例变量 _id_num
以及方法get_id_num
。覆盖,即替换超类的属性。例如,
MIT_person
覆盖了__init__
和__lt__
。当一个方法被覆盖时,执行的方法版本取决于用于调用该方法的对象。如果对象的类型是子类,则使用在子类中定义的版本。如果对象的类型是超类,则使用超类中的版本。
方法MIT_person.__init__
首先使用super().__init__(name)
调用其超类(Person
)的__init__
函数。这初始化了继承的实例变量self._name
。然后它初始化self._id_num
,这是MIT_person
的实例拥有但Person
的实例没有的实例变量。
实例变量self._id_num
使用一个属于类MIT_person
的类 变量_next_id_num
进行初始化,而不是类的实例。当创建MIT_person
的实例时,并不会创建一个新的next_id_num
实例。这允许__init__
确保每个MIT_person
的实例都有一个唯一的 _id_num
。
图 10-4 类MIT_person
考虑这段代码
p1 = MIT_person('Barbara Beaver')
print(str(p1) + '\'s id number is ' + str(p1.get_id_num()))
第一行创建了一个新的MIT_person
。第二行则更复杂。当它尝试评估表达式str(p1)
时,运行时系统首先检查类MIT_person
是否有与之关联的__str__
方法。由于没有,它接着检查MIT_person
的直接超类Person
是否有__str__
方法。确实存在,因此使用该方法。当运行时系统尝试评估表达式p1.get_id_num()
时,它首先检查类MIT_person
是否有与之关联的get_id_num
方法。确实存在,因此它调用该方法并打印
Barbara Beaver's id number is 0
(回想一下,在字符串中,字符“\
”是一个转义字符,用于指示下一个字符应以特殊方式处理。在字符串中
`'\'s id number is '`
“\
”表示撇号是字符串的一部分,而不是终止字符串的分隔符。)
现在考虑这段代码
p1 = MIT_person('Mark Guttag')
p2 = MIT_person('Billy Bob Beaver')
p3 = MIT_person('Billy Bob Beaver')
p4 = Person('Billy Bob Beaver')
我们创建了四个虚拟人物,其中三个名为比利·鲍勃·河狸。两个比利·鲍勃是类型为MIT_person
,而一个仅是Person
。如果我们执行以下代码行
print('p1 < p2 =', p1 < p2)
print('p3 < p2 =', p3 < p2)
print('p4 < p1 =', p4 < p1)
解释器将打印
p1 < p2 = True
p3 < p2 = False
p4 < p1 = True
由于p1
、p2
和p3
都是MIT_person
类型,解释器在评估前两个比较时将使用在MIT_person
类中定义的__lt__
方法,因此排序将基于识别号。在第三个比较中,<
运算符应用于不同类型的操作数。由于表达式的第一个参数用于确定调用哪个__lt__
方法,因此p4 < p1
是p4.__lt__(p1)
的简写。因此,解释器使用与p4
类型相关的__lt__
方法Person
,并按名称对“人”进行排序。
如果我们尝试
print('p1 < p4 =', p1 < p4)
运行时系统将调用与p1
类型相关联的__lt__
运算符,即在MIT_person
类中定义的那个。这将导致异常。
AttributeError: 'Person' object has no attribute '_id_num'
因为p4
绑定的对象没有属性 _id_num
。
练习:实现一个符合规范的Person
子类。
class Politician(Person):
""" A politician is a person who can belong to a political party"""
def __init__(self, name, party = None):
"""name and party are strings"""
def get_party(self):
"""returns the party to which self belongs"""
def might_agree(self, other):
"""returns True if self and other belong to the same part
or at least one of then does not belong to a party"""
10.2.1 多层继承
图 10-5 为类层次结构增加了另几层继承。
图 10-5 两种类型的学生
添加UG
似乎是合乎逻辑的,因为我们希望将每位本科生与一个毕业年份(或预期毕业年份)关联起来。但Student
和Grad
类有什么情况呢?通过使用 Python 保留字**pass**
作为主体,我们表明该类除了从其超类继承的属性外没有其他属性。为什么会有人想创建一个没有新属性的类呢?
通过引入类Grad
,我们获得了创建两种类型学生的能力,并使用它们的类型来区分一种对象与另一种对象。例如,代码
p5 = Grad('Buzz Aldrin')
p6 = UG('Billy Beaver', 1984)
print(p5, 'is a graduate student is', type(p5) == Grad)
print(p5, 'is an undergraduate student is', type(p5) == UG)
将打印
Buzz Aldrin is a graduate student is True
Buzz Aldrin is an undergraduate student is False
中间类型Student
的实用性更为微妙。考虑回到class
MIT_person
并添加该方法。
def is_student(self):
return isinstance(self, Student)
函数isinstance
是 Python 内置的。isinstance
的第一个参数可以是任何对象,但第二个参数必须是type
类型的对象或一个type
类型对象的元组。只有当第一个参数是第二个参数的实例时(或者,如果第二个参数是元组,则是元组中某种类型的实例),函数才会返回True
。例如,isinstance([1,2], list)
的值为True
。
回到我们的示例,代码
print(p5, 'is a student is', p5.is_student())
print(p6, 'is a student is', p6.is_student())
print(p3, 'is a student is', p3.is_student())
打印
Buzz Aldrin is a student is True
Billy Beaver is a student is True
Billy Bob Beaver is a student is False
请注意,isinstance(p6, Student)
的含义与type(p6) == Student
的含义截然不同。p6
绑定的对象的类型是UG
,而不是Student
,但由于UG
是Student
的子类,p6
绑定的对象是Student
类的一个实例(同时也是MIT_person
和Person
的实例)。
由于只有两种类型的学生,我们可以将is_student
实现为,
def is_student(self):
return type(self) == Grad or type(self) == UG
然而,如果后续添加了一种新类型的学生,就有必要回过头来编辑实现is_student
的代码。通过引入中间类Student
并使用isinstance
,我们避免了这个问题。例如,如果我们添加了
class Transfer_student(Student):
def __init__(self, name, from_school):
MIT_person.__init__(self, name)
self._from_school = from_school
def get_old_school(self):
return self._from_school
不需要对is_student
进行更改。
在程序的创建和后期维护过程中,回过头来添加新类或旧类的新属性并不少见。好的程序员设计他们的程序,以尽量减少在进行此操作时可能需要更改的代码量。
**手指练习:**以下表达式的值是多少?
isinstance('ab', str) == isinstance(str, str)
10.2.2 替换原则
当使用子类化来定义类型层次结构时,子类应该被视为扩展其超类的行为。我们通过添加新属性或重写从超类继承的属性来实现。例如,TransferStudent
通过引入前学校来扩展Student
。
有时,子类会重写超类的方法,但这必须谨慎进行。特别是,超类的重要行为必须被每个子类支持。如果客户端代码在使用超类实例时正常工作,那么在替换为子类实例时也应正常工作(因此称为替换原则)。例如,应该能够编写使用Student
规范的客户端代码,并使其在TransferStudent
上正常工作。⁶²
反之,没有理由期望为TransferStudent
编写的代码能够适用于任意类型的Student
。
10.3 封装与信息隐藏
只要我们在处理学生,没必要让他们经历上课和获得成绩的痛苦那就太可惜了。
图 10-6 包含一个可以用来跟踪一组学生成绩的类。类Grades
的实例是使用列表和字典实现的。列表跟踪班级中的学生,而字典将学生的身份证明号码映射到成绩列表。
图 10-6 类Grades
请注意,get_grades
返回与学生关联的成绩列表的副本,而get_students
返回学生列表的副本。通过简单返回实例变量本身,可以避免复制列表的计算成本。然而,这样做可能会导致问题。考虑代码
course = Grades()
course.add_student(Grad('Bernie'))
all_students = course.get_students()
all_students.append(Grad('Liz'))
如果get_students
返回self._students
,那么代码的最后一行将会有(可能是意外的)副作用,改变course
中的学生集合。
实例变量_is_sorted
用于跟踪自上次添加学生以来学生列表是否已排序。这使得get_students
的实现可以避免对已排序列表进行排序。
图 10-7 包含一个使用类Grades
为一些修读课程six_hundred
的学生生成成绩报告的函数。
图 10-7 生成成绩报告。
运行时,图中的代码打印。
Jane Doe's mean grade is 75.0
Pierce Addison's mean grade is 75.0
David Henry has no grades
Billy Buckner's mean grade is 50.0
Bucky F. Dent's mean grade is 87.5
面向对象编程的核心有两个重要概念。第一个是封装的概念。我们指的是将数据属性和操作这些属性的方法捆绑在一起。例如,如果我们写。
Rafael = MIT_person('Rafael Reif')
我们可以使用点符号访问诸如 Rafael 的名字和身份证号等属性。
第二个重要概念是信息隐藏。这是模块化的关键之一。如果使用类的程序部分(即类的客户端)仅依赖于类中方法的规范,那么实现该类的程序员就可以自由地更改类的实现(例如,提高效率),而不必担心该更改会破坏使用该类的代码。
一些编程语言(例如 Java 和 C++)提供强制信息隐藏的机制。程序员可以将类的属性设为私有,以便类的客户端只能通过对象的方法访问数据。Python 3 使用命名约定使得属性在类外不可见。当属性的名称以__
(双下划线)开头但不以__
结尾时,该属性在类外不可见。请参考图 10-8 中的类。
图 10-8 类中的信息隐藏。
当我们运行代码时。
test = info_hiding()
print(test.visible)
print(test.__also_visible__)
print(test.__invisible)
它打印。
Look at me
Look at me too
然后引发异常。
AttributeError: 'info_hiding' object has no attribute '__invisible'
代码。
test = info_hiding()
test.print_invisible()
test.__print_invisible__()
test.__print_invisible()
打印。
Don't look at me directly
Don't look at me directly
然后引发异常。
AttributeError: 'info_hiding' object has no attribute '__print_invisible'
以及代码。
class Sub_class(info_hiding):
def new_print_invisible(self):
print(self.__invisible)
test_sub = Sub_class()
test_sub.new_print_invisible()
打印。
AttributeError: ‘Sub_class' object has no attribute '_Sub_class__invisible'
请注意,当子类尝试使用其超类的隐藏属性时,会发生AttributeError
。这使得使用__
进行信息隐藏有些繁琐。
由于这可能很繁琐,许多 Python 程序员并不利用__
机制来隐藏属性——我们在本书中也是如此。因此,例如,Person
的客户端可以写表达式Rafael._last_name
而不是Rafael.get_last_name()
。我们通过在属性前放置单个_
来劝阻这种不良行为,以表明我们希望客户端不要直接访问它。
我们避免直接访问数据属性,因为依赖于不属于规范的一部分的内容对客户端代码是危险的,因此可能会发生变化。例如,如果Person
的实现被更改为在请求时提取姓氏,而不是将其存储在实例变量中,那么直接访问_last_name
的客户端代码将不再有效。
Python 不仅允许程序从类定义之外读取实例和类变量,还允许程序写入这些变量。因此,例如,代码Rafael._birthday = '8/21/50'
是完全合法的。如果稍后在计算中调用Rafael.get_age
,将会导致运行时类型错误。甚至可以在类定义之外创建实例变量。例如,如果赋值语句
`me.age = Rafael.get_id_num()`
在类定义之外发生。
尽管这种相对弱的静态语义检查是 Python 的一项缺陷,但这并不是致命的缺陷。一个有纪律的程序员可以简单地遵循一个合理的规则,即不直接从定义它们的类之外访问数据属性,就像我们在本书中所做的那样。
10.3.1 生成器
信息隐藏的一个被感知的风险是,阻止客户端程序直接访问关键数据结构会导致不可接受的效率损失。在数据抽象的早期,许多人担心引入多余的函数或方法调用的成本。现代编译技术使这个担忧变得无关紧要。一个更严重的问题是,客户端程序将被迫使用低效的算法。
考虑在图 10-7 中实现的grade_report
。调用course.get_students
会创建并返回一个大小为n
的列表,其中n
是学生的数量。这对单个班级的成绩册来说可能不是问题,但想象一下,要跟踪 170 万名参加 SAT 考试的高中生的成绩。当列表已经存在时,创建这样大小的新列表是一种显著的低效。一种解决方案是放弃抽象,允许grade_report
直接访问实例变量course.students
,但这将违反信息隐藏。幸运的是,还有更好的解决方案。
图 10-9 中的代码用一种我们尚未见过的语句替换了Grades
类中的get_students
函数:yield
语句。
任何包含yield
语句的函数定义都会以特殊的方式处理。yield
的存在告诉 Python 系统该函数是一个生成器。生成器通常与for
语句一起使用,如
`for s in course.get_students():`
在图 10-7 中。
图 10-9 get_students
的新版本
在使用生成器的for
循环的第一次迭代开始时,生成器被调用并运行,直到第一次执行yield
语句,此时返回yield
语句中表达式的值。在下一次迭代中,生成器立即在yield
后恢复执行,所有局部变量绑定到yield
语句执行时绑定的对象,再次运行直到执行yield
语句。它会继续这样做,直到没有代码可执行或执行return
语句,此时循环退出。⁶³
图 10-9 中的get_students
版本允许程序员使用for
循环以与内置类型如list
相同的方式迭代Grades
类型对象中的学生。例如,代码
book = Grades()
book.add_student(Grad('Julie'))
book.add_student(Grad('Lisa'))
for s in book.get_students():
print(s)
打印
Julie
Lisa
因此,图 10-7 中的循环以
`for s in course.get_students():`
不需要修改以利用包含新实现的get_students
的Grades
类版本。(当然,依赖于get_students
返回列表的大多数代码将不再有效。)相同的for
循环可以迭代get_students
提供的值,无论get_students
是返回一个值的列表还是一次生成一个值。一次生成一个值将更有效,因为不会创建包含学生的新列表。
指尖练习: 向Grades
添加一个满足规范的生成器
def get_students_above(self, grade):
"""Return the students a mean grade > g one at a time"""
10.4 扩展示例
2008 年秋季,美国房地产价格崩溃帮助引发了一场国际经济危机。其中一个原因是,太多房主承担了最终带来意想不到后果的抵押贷款。⁶⁴
一开始,抵押贷款相对简单。买家从银行借款,并在抵押贷款的整个生命周期内每月支付固定金额,通常为 15 到 30 年。在这段时间结束时,银行收回了初始贷款(本金)加上利息,房主就“完全拥有”了房子。
到二十世纪末,抵押贷款变得更加复杂。人们可以通过在接受抵押贷款时向贷款人支付“点数”来获得更低的利率。一个点是贷款价值的1%
的现金支付。人们可以选择在一段时间内仅支付“利息”的抵押贷款。也就是说,在贷款开始的几个月内,借款人仅支付累计的利息,而不支付本金。其他贷款涉及多种利率。通常,最初的利率(称为“诱饵利率”)较低,随后随着时间的推移而上升。这些贷款通常是浮动利率的——在初始期限后要支付的利率将根据某种旨在反映贷款人在批发信贷市场借款成本的指数而变化。
原则上,为消费者提供多种选择是一件好事。然而,不负责任的贷款提供者并不总是小心地充分解释各种选择的潜在长期影响,某些借款人做出了证明后果严重的选择。
让我们构建一个程序,检查三种抵押贷款的成本:
一种没有点数的固定利率抵押贷款
固定利率抵押贷款和点数
一种初始诱饵利率,随后在持续期间为更高利率的抵押贷款
本次练习的目的是提供一些有关一组相关类增量开发的经验,而不是让你成为抵押贷款专家。
我们将结构化我们的代码,以包含一个Mortgage
类及其对应于上述三种抵押贷款类型的子类。图 10-10 包含抽象类Mortgage
。该类包含每个子类共享的方法,但不打算直接实例化。也就是说,不会创建类型为Mortgage
的对象。
图形顶部的find_payment
函数计算需要在贷款到期时偿还贷款所需的固定月供,包括利息。它使用一个众所周知的封闭形式表达式来实现这一点。这个表达式并不难推导,但查找它要容易得多,也更可能是正确的,而不是现场推导出来的。
然而,请记住,并非你在网络上(甚至教科书中)发现的所有信息都是正确的。当你的代码包含你查找的公式时,请确保:
你已从可信来源获取公式。我们查看了多个可信来源,它们都包含等效的公式。
你完全理解公式中所有变量的含义。
你将你的实现与来自可信来源的示例进行测试。在实现此功能后,我们通过将我们的结果与网络上可用计算器提供的结果进行比较来进行测试。
图 10-10 Mortgage
基类
观察 __init__
,我们看到所有 Mortgage
实例将具有与初始贷款金额、月利率、贷款期限(以月为单位)、每月初已支付的付款列表(该列表以 0
开头,因为在第一个月初尚未支付任何款项)、每月初未偿贷款余额的列表、每月需支付的金额(使用函数 find_payment
返回的值初始化)以及抵押贷款描述(初始值为 None
)相对应的实例变量。每个 Mortgage
子类的 __init__
操作预计会首先调用 Mortgage.__init__
,然后将 self._legend
初始化为该子类的适当描述。
方法 make_payment
用于记录抵押贷款支付。每次支付的一部分覆盖了未偿贷款余额的利息,剩余部分用于减少贷款余额。这就是 make_payment
更新 self.paid
和 self.outstanding
的原因。
方法 get_total_paid
使用内置的 Python 函数 sum
,该函数返回一系列数字的总和。如果序列中包含非数字,则会引发异常。
图 10-11 包含实现三种类型抵押贷款的类。类 Fixed
和 Fixed_with_pts
重写 __init__
并从 Mortgage
继承其他三种方法。类 Two_rate
将抵押贷款视为两个不同利率贷款的串联。(由于 self.paid
初始化为包含一个元素的列表,因此它包含的元素比已支付的款项多一个。这就是方法 make_payment
将 len(self.paid)
与 self.teaser_months + 1
进行比较的原因。)
图 10-11 还包含一个计算并打印每种抵押贷款总成本的函数,基于一组示例参数。它首先创建每种类型的一份抵押贷款。然后,对每一份抵押贷款在给定的年份内进行每月支付。最后,打印每笔贷款的支付总额。
我们现在终于准备好比较不同的抵押贷款了:
compare_mortgages(amt=200000, years=30, fixed_rate=0.035,
pts = 2, pts_rate=0.03, var_rate1=0.03,
var_rate2=0.05, var_months=60)
注意,在调用 compare_mortgages
时,我们使用了关键字参数而非位置参数。这样做是因为 compare_mortgages
有大量相同类型的形式参数,使用关键字参数可以更容易地确保我们为每个形式参数提供了预期的实际值。
当代码运行时,它会打印
Fixed, 3.5%
Total payments = $323,312
Fixed, 3.0%, 2 points
Total payments = $307,555
3.0% for 60 months, then 5.0%
Total payments = $362,435
图 10-11 抵押贷款子类
初看结果似乎相当明确。可变利率贷款对借款人(而非贷款人)来说是个坏主意,而带点的固定利率贷款则是成本最低的。然而,需要注意的是,总成本并不是评估抵押贷款的唯一标准。例如,预计未来收入会更高的借款人可能愿意在后期支付更多,以减轻初期还款的负担。
这表明,与其看一个单一的数字,不如观察随时间变化的付款情况。这反过来又表明,我们的程序应该生成旨在展示抵押贷款随时间变化的图表。我们将在第 13.2 节中进行讨论。
10.5 章节中引入的术语
面向对象编程
抽象数据类型
接口
抽象屏障
分解
抽象
类
类定义
方法属性
类属性
类实例
属性引用
__ 方法
魔法(双下划线)方法
数据属性
类变量
实例变量
类定义
表示不变式
继承
子类
超类
方法重写
isinstance
替代原则
封装
信息隐藏
私有
生成器
抽象类
第十一章:对算法复杂性的简单介绍
设计和实现程序时最重要的是,它应该产生可依赖的结果。我们希望我们的银行余额能够正确计算。我们希望汽车中的燃油喷射器能够注入适量的燃料。我们更希望飞机和操作系统都不会崩溃。
有时性能是正确性的一个重要方面。这对于需要实时运行的程序最为明显。一个警告飞机潜在障碍物的程序需要在遇到障碍物之前发出警告。性能也会影响许多非实时程序的实用性。在评估数据库系统的实用性时,每分钟完成的事务数量是一个重要指标。用户关心在手机上启动应用程序所需的时间。生物学家关心它们的系统发育推断计算需要多长时间。
编写高效程序并不容易。最简单的解决方案往往不是最有效的。计算上高效的算法通常采用微妙的技巧,这可能使它们难以理解。因此,程序员往往增加程序的概念复杂性以降低其计算复杂性。为了以合理的方式做到这一点,我们需要了解如何估算程序的计算复杂性。这是本章的主题。
11.1 思考计算复杂性
如何回答“以下函数运行需要多长时间?”这个问题?
def f(i):
"""Assumes i is an int and i >= 0"""
answer = 1
while i >= 1:
answer *= i
i -= 1
return answer
我们可以在某些输入上运行程序并计时。但这并不会提供特别有用的信息,因为结果会依赖于
运行程序的计算机的速度
在那台机器上 Python 实现的效率
输入的值
我们通过使用更抽象的时间度量来解决前两个问题。我们不是以微秒来衡量时间,而是通过程序执行的基本步骤数量来衡量时间。
为了简单起见,我们将使用随机访问机器作为我们的计算模型。在随机访问机器中,步骤是依次执行的,一次一个。⁶⁶ 一个步骤是一个固定时间内执行的操作,比如将变量绑定到对象、进行比较、执行算术操作或访问内存中的对象。
现在我们有了一种更抽象的方式来思考时间的意义,我们转向输入值的依赖性问题。我们通过不再将时间复杂性表达为单一数字,而是与输入的大小相关联来解决这个问题。这使我们能够通过讨论每个算法的运行时间如何随输入大小的变化而变化,从而比较两种算法的效率。
当然,算法的实际运行时间不仅取决于输入的大小,还取决于它们的值。考虑线性搜索算法的实现。
def linear_search(L, x):
for e in L:
if e == x:
return True
return False
假设L
是一个包含一百万个元素的列表,考虑调用linear_search(L, 3)
。如果L
中的第一个元素是3
,linear_search
几乎会立即返回True
。另一方面,如果3
不在L
中,linear_search
必须检查所有一百万个元素才能返回False
。
一般来说,有三种广泛的情况需要考虑:
最佳情况运行时间是算法在输入尽可能有利时的运行时间。也就是说,最佳情况运行时间是给定大小的所有可能输入中的最小运行时间。对于
linear_search
,最佳情况运行时间与L
的大小无关。同样,最坏情况运行时间是给定大小的所有可能输入中的最大运行时间。对于
linear_search
,最坏情况运行时间与L
的大小成线性关系。与最佳情况和最坏情况运行时间的定义类比,*均情况(也称为期望情况)运行时间是给定大小的所有可能输入的*均运行时间。或者,如果人们对输入值的分布有一些先验信息(例如,
90%
的时间x
在L
中),可以考虑这些信息。
人们通常关注最坏情况。所有工程师都有一个共同信条,墨菲定律:如果某件事可能出错,它就一定会出错。最坏情况提供了运行时间的上限。在对计算所需时间有限制时,这一点至关重要。仅仅知道“在大多数情况下”航空交通控制系统会在碰撞发生前发出警告是不够的。
让我们看看阶乘函数的迭代实现的最坏情况运行时间:
def fact(n):
"""Assumes n is a positive int
Returns n!"""
answer = 1
while n > 1:
answer *= n
n -= 1
return answer
运行此程序所需的步骤数大约是2
(1
步用于初始赋值语句,1
步用于return)
+ 5n
(计算while
中的测试需要1
步,在while
循环中的第一个赋值语句需要2
步,循环中的第二个赋值语句需要2
步)。所以,例如,如果n
是1000
,函数大约会执行5002
步。
很明显,随着n
增大,担心5n
与5n+2
之间的差异有些无谓。因此,当我们推理运行时间时,通常会忽略加性常数。乘法常数则更为复杂。我们是否需要关心计算需要1000
步还是5000
步?乘法因素可能很重要。搜索引擎响应查询所需的时间是0.5
秒还是2.5
秒,可能会影响用户是使用该搜索引擎还是转向竞争对手。
另一方面,在比较两个不同算法时,即使乘法常数也往往是无关紧要的。回想一下,在第三章中,我们查看了两种算法:穷举枚举和二分搜索,用于寻找浮点数*方根的*似值。基于这些算法的函数在图 11-1 和图 11-2 中显示。
图 11-1 使用穷举枚举来*似*方根
图 11-2 使用二分搜索来*似*方根
我们看到,穷举枚举在处理许多x
和epsilon
值组合时是如此缓慢,以至于不切实际。例如,评估square_root_exhaustive(100, 0.0001)
大约需要十亿次while
循环迭代。相比之下,评估square_root_bi(100, 0.0001)
大约只需 20 次稍微复杂的while
循环迭代。当迭代次数差异如此之大时,循环中的指令数量其实并不重要。也就是说,乘法常数是无关紧要的。
11.2 渐进符号
我们使用一种称为渐进符号的东西,提供了一种正式的方式来讨论算法运行时间与其输入大小之间的关系。其根本动机是,几乎任何算法在小输入上运行时都是足够高效的。我们通常需要担心的是算法在非常大输入上运行时的效率。作为“非常大”的代理,渐进符号描述了当输入大小趋向于无穷大时算法的复杂度。
例如,考虑图 11-3 中的代码。
图 11-3 渐进复杂度
如果我们假设每行代码执行一次需要一个单位的时间,则该函数的运行时间可以描述为1000 + x + 2x
²。常数1000
对应于第一个循环执行的次数。项x
对应于第二个循环执行的次数。最后,项2x
²对应于在嵌套for
循环中执行两个语句所花费的时间。因此,调用f(10)
将打印
Number of additions so far 1000
Number of additions so far 1010
Number of additions so far 1210
而调用f(1000)
将打印
Number of additions so far 1000
Number of additions so far 2000
Number of additions so far 2002000
对于小的x
值,常数项占主导地位。如果x
是10
,则超过80%
的步骤由第一个循环完成。另一方面,如果x
是1000
,前两个循环仅占约0.05%
的步骤。当x
为1,000,000
时,第一个循环大约占总时间的0.00000005%
,第二个循环约占0.00005%
。总共2,000,000,000,000
的2,000,001,001,000
步骤在内部for
循环的主体中。
显然,通过只考虑内层循环,即二次项,我们可以获得对这段代码在非常大输入下运行时间的有意义的概念。我们是否在乎这个循环需要2x²
步而不是x²
步?如果你的计算机每秒执行大约 1 亿步,评估f
将需要大约5.5
小时。如果我们能将复杂度降低到x²
步,将只需大约2.25
小时。在这两种情况下,结论是一样的:我们可能应该寻找更高效的算法。
这种分析引导我们在描述算法的渐*复杂度时使用以下经验法则:
如果运行时间是多个项的总和,保留增长率最大的项,去掉其他项。
如果剩余项是一个乘积,则去掉任何常数。
最常用的渐*符号被称为“大 O”符号。大 O 符号用于给出函数的渐*增长的上界(通常称为增长阶)。例如,公式f(x) ∈ O(x²)
意味着函数f
的增长速度不快于二次多项式x²
,从渐*意义上看。
许多计算机科学家会滥用大 O 符号,像这样表述:“f(x)
的复杂度是 O(x²)
。”他们的意思是在最坏情况下,f
的运行步骤不超过O(x²)
。一个函数“在O(x²)
”与“是O(x²)
”之间的区别是微妙但重要的。说f(x) ∈ O(x²)
并不排除f
的最坏情况下运行时间明显小于O(x²)
。为避免这种混淆,我们在描述某个上界和下界的渐*最坏情况下运行时间时,会使用大Θ(θ)。这称为紧界。
指尖练习:以下每个函数的渐*复杂度是什么?
def g(L, e):
"""L a list of ints, e is an int"""
for i in range(100):
for e1 in L:
if e1 == e:
return True
return False
def h(L, e):
"""L a list of ints, e is an int"""
for i in range(e):
for e1 in L:
if e1 == e:
return True
return False
11.3 一些重要的复杂度类
大 O(和θ)的最常见实例如下所示。在每种情况下,n 是输入大小的度量。
O(1)
表示常数运行时间。O(log n)
表示对数运行时间。O(n)
表示线性运行时间。O(n log n)
表示对数线性运行时间。O(n^k)
表示多项式运行时间。注意 k 是一个常数。O(c^n)
表示指数运行时间。这里常数是基于输入大小的幂。
11.3.1 常数复杂度
这表明渐进复杂度与输入的大小无关。这个类别中有很少有趣的程序,但所有程序都有一些片段(例如,计算 Python 列表的长度或乘两个浮点数),适合这个类别。常数运行时间并不意味着代码中没有循环或递归调用,但它确实意味着迭代或递归调用的次数与输入的大小无关。
11.3.2 对数复杂度
这样的函数具有复杂度,随着至少一个输入的对数而增长。二分查找,例如,在被搜索列表的长度上是对数级的。(我们将在第十二章中讨论二分查找并分析其复杂度。)顺便提一下,我们不关心对数的底数,因为使用一个底数和另一个底数之间的差异仅仅是一个常数乘法因子。例如,O(log
[2](x)) = O(log
[2](10)
*log
[10](x))
。有许多有趣的函数具有对数复杂度。考虑一下
def int_to_str(i):
"""Assumes i is a nonnegative int
Returns a decimal string representation of i"""
digits = '0123456789'
if i == 0:
return '0'
result = ''
while i > 0:
result = digits[i%10] + result
i = i//10
return result
由于这段代码中没有函数或方法调用,我们知道我们只需查看循环以确定复杂度类别。只有一个循环,因此我们需要做的就是描述迭代次数。这归结为在得到0
的结果之前,我们可以使用//
(向下取整除法)将i
除以10
的次数。因此,int_to_str
的复杂度是O(log(i))
。更确切地说,它是 θ(log(i)),因为 log(i) 是一个紧密的界限。
那么复杂度如何呢
def add_digits(n):
"""Assumes n is a nonnegative int
Returns the sum of the digits in n"""
string_rep = int_to_str(n)
val = 0
for c in string_rep:
val += int(c)
return val
使用int_to_str
将n
转换为字符串的复杂度是 θ(log(n))
,并且int_to_str
返回长度为log(n)
的字符串。for
循环将执行 θ(len(string_rep))
次,即 θ(log(n))
次。综合来看,假设表示数字的字符可以在常数时间内转换为整数,程序的运行时间将与 θ(log(n)) +
θ(log(n))
成正比,这使得它的复杂度是 θ(log(n))
。
11.3.3 线性复杂度
许多处理列表或其他类型序列的算法是线性的,因为它们以一个常数(大于0
)的次数访问序列的每个元素。
举个例子,
def add_digits(s):
"""Assumes s is a string of digits
Returns the sum of the digits in s"""
val = 0
for c in string_rep:
val += int(c)
return val
这个函数在s
的长度上是线性的,即 θ(len(s))
。
当然,一个程序不需要有循环也可以具有线性复杂度。考虑一下
def factorial(x):
"""Assumes that x is a positive int
Returns x!"""
if x == 1:
return 1
else:
return x*factorial(x-1)
这段代码中没有循环,因此为了分析复杂度,我们需要弄清楚进行多少次递归调用。调用序列就是
`factorial(x)`, `factorial(x-1)`, `factorial(x-2), … , factorial(1)`
这个序列的长度,因此函数的复杂度,是 θ(x)
*。
到目前为止,我们只关注了代码的时间复杂度。这对于使用恒定空间的算法是可以的,但这个阶乘实现并不具备这一特性。如我们在第四章讨论的,每次递归调用factorial
都会分配一个新的堆栈帧,并且该帧在调用返回之前会占用内存。在递归的最大深度,代码将分配x
个堆栈帧,因此空间复杂度为O(x)
。
空间复杂度的影响比时间复杂度更难以察觉。程序完成所需的一分钟或两分钟对用户来说非常明显,但它使用一兆字节还是两兆字节的内存则大多对用户是不可见的。这就是为什么人们通常更关注时间复杂度而不是空间复杂度的原因。例外情况发生在程序需要的空间超过运行它的机器的快速内存时。
11.3.4 对数线性复杂度
这比我们迄今为止看到的复杂度类稍微复杂一些。它涉及两个项的乘积,每个项都依赖于输入的大小。这是一个重要的类别,因为许多实际算法是对数线性的。最常用的对数线性算法可能是归并排序,其复杂度为θ(n log(n))
,其中n
是待排序列表的长度。我们将在第十二章中查看该算法并分析其复杂度。
11.3.5 多项式复杂度
最常用的多项式算法是二次算法,即它们的复杂度随着输入大小的*方而增长。例如,考虑在图 11-4 中实现的子集测试函数。
图 11-4 子集测试的实现
每次到达内层循环时,它将执行θ(len(L2))
次。函数is_subset
将执行外层循环θ(len(L1))
次,因此内层循环将达到θ(len(L1)
*len(L2))
。
现在考虑图 11-5 中的函数intersect
。构建可能包含重复项的列表的代码部分的运行时间显然是θ(len(L1)
*len(L2)
)。乍一看,构建无重复列表的代码部分似乎是线性的,但实际上并非如此。
图 11-5 列表交集的实现
评估表达式e not in result
可能涉及查看result
中的每个元素,因此是θ(len(result))
;因此,实现的第二部分的复杂度为θ(len(tmp)
len(result))
。然而,由于result
和tmp
的长度受限于L1
和L2
中较小者的长度,并且我们忽略了加法项,intersect
的复杂度为θ*(len(L1)
*len(L2))
。
11.3.6 指数复杂性
正如我们将在本书后面看到的,许多重要问题本质上是指数级的,即完全解决它们可能需要与输入大小成指数关系的时间。这是令人遗憾的,因为编写一个在合理概率上需要指数时间运行的程序往往得不偿失。考虑一下图 11-6 中的代码。
图 11-6 生成幂集
函数gen_powerset(L)
返回一个列表的列表,包含L
的所有可能组合。例如,如果L
是['x', 'y']
,那么L
的幂集将是一个包含列表[]
、['x']
、['y']
和['x', 'y']
的列表。
这个算法有些微妙。考虑一个包含n
个元素的列表。我们可以用一个包含n
个0
和1
的字符串来表示元素的任何组合,其中1
表示元素的存在,0
表示其不存在。包含没有项目的组合用全是0
的字符串表示,包含所有项目的组合用全是1
的字符串表示,仅包含第一个和最后一个元素的组合用100…001
表示,等等。
生成长度为n
的列表L
的所有子列表可以如下进行:
生成所有
n
位的二进制数。这些数字从0
到2
^n - 1。对于每个
2
^n 的二进制数b
,通过选择L
中索引与b
中的1
对应的元素来生成一个列表。例如,如果L
是['x', 'y', 'z']
,而b
是101
,则生成列表['x', 'z']
。
尝试在包含字母表前 10 个字母的列表上运行gen_powerset
。它会很快完成,并生成一个包含 1024 个元素的列表。接下来,尝试在前 20 个字母的列表上运行gen_powerset
。这将需要一些时间,并返回一个大约有一百万个元素的列表。如果你在所有 26 个字母的列表上运行gen_powerset
,你可能会厌倦等待它完成,除非你的计算机因尝试构建一个包含数千万个元素的列表而耗尽内存。甚至不要考虑在包含所有大写和小写字母的列表上运行gen_powerset
。算法的第 1 步生成的二进制数是θ(2
^(len(L)))
,因此算法在 len(L)
中是指数级的。
这是否意味着我们不能使用计算来解决指数级困难的问题?绝对不是。这意味着我们必须找到提供这些问题的*似解或在某些实例上找到精确解的算法。但这是后面章节的主题。
11.3.7 复杂性类的比较
本节中的图表旨在传达算法处于这些复杂性类之一或另一类的含义。
图 11-7 左侧的图表比较了常数时间算法与对数算法的增长。注意,输入的大小必须达到约 30,000,两者才会相交,即使常数非常小(15)。当输入大小为 100,000 时,对数算法所需的时间仍然相当小。其道理是,对数算法几乎与常数时间算法一样优秀。
图 11-7 右侧的图表展示了对数算法与线性算法之间的显著差异。虽然我们需要观察较大输入才能理解常数时间算法与对数时间算法之间的差异,但对数时间算法与线性时间算法之间的差异即使在小输入上也很明显。对数算法和线性算法相对性能的显著差异并不意味着线性算法不好。实际上,线性算法在很多情况下效率足够可接受。
图 11-7 常数、对数和线性增长
图 11-8 左侧的图表显示 O(n)
和 O(n log(n))
之间存在显著差异。考虑到 log(n)
的增长非常缓慢,这可能看起来令人惊讶,但请记住,这是一个乘法因子。还要记住,在许多实际情况中,O(n log(n))
是足够快速且有用的。另一方面,正如图 11-8 右侧的图所示,存在许多情况下,二次增长的速度是不可接受的。
图 11-8 线性、对数线性和二次增长
图 11-9 中的图表讨论了指数复杂度。在图 11-9 左侧的图中,y 轴左侧的数字从 0
到 6
。然而,左上角的标记 1e29
意味着 y 轴上的每个刻度都应乘以 10
²⁹。因此,绘制的 y 值范围从 0
到大约 6.6*10
²⁹。在图 11-9 左侧的图中,二次增长的曲线几乎不可见。这是因为指数函数增长得如此迅速,以至于与最高点的 y 值(决定 y 轴的刻度)相比,指数曲线上早期点的 y 值(以及二次曲线上所有点)几乎与 0
无法区分。
图 11-9 右侧的图表通过在 y 轴上使用对数尺度解决了这个问题。人们很容易看到,指数算法对于除了最小输入之外的所有情况都是不切实际的。
注意,在对数尺度上绘制时,指数曲线呈现为直线。我们将在后面的章节中对此进行更深入的探讨。
图 11-9 二次和指数增长
11.4 本章引入的术语
概念复杂度
计算复杂度
随机访问机
计算步骤
最佳情况复杂度
最坏情况复杂度
*均情况复杂度
期望情况复杂度
上界
渐进记号
大 O 记号
增长阶
大θ记号
下界
紧界
常数时间
对数时间
线性时间
对数线性时间
多项式时间
二次时间
指数时间
第十二章:一些简单的算法和数据结构
尽管我们在本书中花了相当多的页面讨论效率,但目标并不是让你成为设计高效程序的专家。还有许多专门讨论这一主题的长书(甚至一些不错的长书)。在第十一章中,我们介绍了一些复杂性分析的基本概念。在这一章中,我们利用这些概念来考察几个经典算法的复杂性。本章的目标是帮助你培养一些关于如何处理效率问题的一般直觉。当你完成本章时,你应该理解为什么有些程序瞬间完成,为什么有些需要整夜运行,以及为什么有些在你的一生中都无法完成。
本书中我们首次探讨的算法是基于穷举法的。我们认为现代计算机如此快速,以至于采用巧妙的算法往往是浪费时间。编写简单且显然正确的代码通常是正确的做法。
我们接着研究了一些问题(例如,寻找多项式根的*似值),在这些问题中,搜索空间太大,无法通过穷举法进行实际处理。这使我们考虑了更高效的算法,如二分法和牛顿-拉夫森法。关键在于,效率的关键是一个好的算法,而不是巧妙的编码技巧。
在科学(物理、生物和社会科学)中,程序员通常首先快速编码一个简单的算法,以测试关于数据集的假设的合理性,然后在少量数据上运行。如果这产生了令人鼓舞的结果,那么就开始了在大数据集上运行(或许是反复运行)可实施方案的艰苦工作。这种实施需要基于高效的算法。
高效的算法很难发明。成功的职业计算机科学家可能在整个职业生涯中只发明一个算法——如果他们幸运的话。我们大多数人从未发明过新算法。相反,我们学习将面临的复杂问题简化为之前解决过的问题。
更具体地说,我们
理解问题的内在复杂性。
考虑如何将这个问题分解为子问题。
将这些子问题与其他已经存在高效算法的问题关联起来。
本章包含一些示例,旨在让你对算法设计有一些直观的理解。书中还有许多其他算法。
请记住,最有效的算法并不总是首选算法。以最有效的方式完成所有事情的程序往往会让人难以理解。通常,从最直接的方式解决手头的问题是一个好策略,记录以找到任何计算瓶颈,然后寻找改善程序中导致瓶颈的部分的计算复杂性的方法。
12.1 搜索算法
搜索算法是一种在项目集合中查找具有特定属性的单个项目或一组项目的方法。我们将项目集合称为搜索空间。搜索空间可以是一些具体的东西,如一组电子病历,也可以是一些抽象的东西,如所有整数的集合。许多实际发生的问题都可以表述为搜索问题。
本书中早期提出的许多算法可以视为搜索算法。在第三章中,我们将寻找多项式根的*似值表述为一个搜索问题,并考察了三种算法——穷举枚举、二分搜索和牛顿-拉夫森法——用于搜索可能答案的空间。
在本节中,我们将研究两种搜索列表的算法。每种算法都符合规范。
def search(L, e):
"""Assumes L is a list.
Returns True if e is in L and False otherwise"""
机智的读者可能会想,这是否与 Python 表达式e in L
在语义上等价。答案是肯定的,是的。如果你不关心发现e
是否在L
中的效率,你应该简单地写出该表达式。
12.1.1 线性搜索和使用间接访问元素
Python 使用以下算法来确定元素是否在列表中:
for i in range(len(L)):
if L[i] == e:
return True
return False
如果元素e
不在列表中,算法将执行θ(len(L))
次测试,即复杂度在L
的长度上至多为线性。为什么说“至多”线性?只有在循环内部的每个操作都可以在常数时间内完成时,它才是线性的。这引出了一个问题:Python 是否能在常数时间内检索列表的第i
(th)个元素。由于我们的计算模型假设获取地址内容是常数时间操作,问题就变成了我们是否能在常数时间内计算列表第i
(th)个元素的地址。
让我们首先考虑每个列表元素都是整数的简单情况。这意味着列表中的每个元素大小相同,例如,占用四个内存单元(四个 8 位字节⁶⁹)。假设列表元素连续存储,列表第i
(th)个元素在内存中的地址为start + 4
*i
,其中start
是列表起始地址。因此,我们可以假设 Python 能够在常数时间内计算出整数列表第i
(th)个元素的地址。
当然,我们知道 Python 列表可以包含除int
之外的其他类型的对象,而且同一个列表可以包含多种类型和大小的对象。你可能会认为这会带来问题,但事实并非如此。
在 Python 中,列表由一个长度(列表中对象的数量)和一系列固定大小的指针⁷⁰表示对象。图 12-1 说明了这些指针的使用。
图 12-1 实现列表
被阴影区域表示的列表包含四个元素。最左边的阴影框包含一个指向整数的指针,指示列表的长度。其他阴影框中的每一个都包含一个指向列表中对象的指针。
如果长度字段占用四个内存单元,而每个指针(地址)占用四个内存单元,则列表的i
(th)元素的地址存储在地址start + 4 + 4
*i
中。同样,这个地址可以在常数时间内找到,然后可以使用该地址存储的值来访问i
(th)元素。这个访问也是一个常数时间的操作。
这个例子说明了计算中使用的最重要的实现技术之一:间接寻址。⁷¹一般来说,间接寻址涉及先访问包含所寻物体引用的其他内容,然后再访问所需物体。每次我们使用变量引用与该变量绑定的对象时,都会发生这种情况。当我们使用变量访问列表,然后通过列表中存储的引用访问另一个对象时,我们通过两个级别的间接寻址。⁷²
12.1.2 二分搜索和利用假设
回到实现search(L, e)
的问题,θ(len(L))
是我们能做的最好的吗?是的,如果我们对列表中元素的值和它们的存储顺序没有任何了解。在最坏的情况下,我们必须查看L
中的每个元素,以确定L
是否包含e
。
但假设我们对元素存储的顺序有所了解,例如,假设我们知道我们有一个按升序存储的整数列表。我们可以改变实现,使得搜索在遇到一个大于要搜索的数字时停止,如图 12-2 所示。
图 12-2 有序列表的线性搜索
这将改善*均运行时间。然而,这不会改变算法的最坏情况复杂度,因为在最坏情况下,每个L
中的元素都要被检查。
然而,我们可以通过使用一种类似于第三章中用于找到浮点数*方根*似值的二分搜索算法的二分查找算法,显著改善最坏情况下的复杂性。在那里,我们依赖于浮点数之间固有的全序关系。在这里,我们依赖于列表已排序的假设。
这个想法很简单:
1. 选择一个索引
i
,大致将列表L
分为两半。2. 询问
L[i] == e
。3. 如果没有,询问
L[i]
是否大于或小于e
。4. 根据答案,搜索
L
的左半部分或右半部分以查找e
。
鉴于该算法的结构,二分查找最直接的实现使用递归,这并不令人惊讶,如图 12-3 所示。
图 12-3 中的外部函数search(L, e)
具有与图 12-2 中定义的函数相同的参数和规范。规范表明实现可以假定L
是按升序排序的。确保这一假设成立的责任在于search
的调用者。如果假设不成立,实现没有义务表现良好。它可能会工作,但也可能崩溃或返回错误的答案。是否应该修改search
以检查假设是否成立?这可能消除错误来源,但会违背使用二分查找的目的,因为检查假设本身将耗时O(len(L))
。
图 12-3 递归二分查找
像search
这样的函数通常被称为包装函数。这个函数为客户端代码提供了一个良好的接口,但本质上是一个不进行复杂计算的通过函数。相反,它使用适当的参数调用辅助函数bSearch
。这引出了一个问题:为什么不消除search
,让客户端直接调用bin_search
呢?原因是参数low
和high
与搜索列表中元素的抽象无关。它们是实现细节,应当对调用search
的程序员隐藏。
现在让我们分析bin_search
的复杂性。我们在上一节中展示了列表访问是常量时间。因此,可以看出,除了递归调用之外,bSearch
的每个实例都是θ(1)
。因此,bin_search
的复杂性仅依赖于递归调用的次数。
如果这是一本关于算法的书,我们将深入分析一个称为递归关系的内容。但因为这不是,我们将采取一个不那么正式的方法,从“我们如何知道程序终止?”这个问题开始。回想一下在第三章中,我们对while
循环问了同样的问题。我们通过提供循环的递减函数来回答了这个问题。这里我们也做同样的事情。在这个上下文中,递减函数具有以下性质:
它将正式参数绑定的值映射到一个非负整数。
当其值为
0
时,递归终止。对于每个递归调用,递减函数的值小于调用该函数实例时的递减函数的值。
bin_search
的递减函数是high
–low
。search
中的if
语句确保第一次调用bSearch
时,该递减函数的值至少为0
(递减函数性质 1)。
当进入bin_search
时,如果high
–low
恰好为0
,则该函数不进行递归调用—直接返回值L[low] == e
(满足递减函数性质 2)。
bin_search
函数包含两个递归调用。一个调用使用的参数覆盖mid
左侧的所有元素,另一个调用使用的参数覆盖mid
右侧的所有元素。在这两种情况下,high
–low
的值都减半(满足递减函数性质 3)。
我们现在明白了为什么递归会终止。下一个问题是,在high–low == 0
之前,high–low
的值可以被减半多少次?回想一下,log
[y](x)
是将y
自身相乘多少次才能达到x
。相反,如果将x
除以y log
[y](x)
次,结果是1
。这意味着high–low
最多可以通过向下取整的除法被减半log
[2](``high–low``)
次,直到达到0
。
最后,我们可以回答这个问题,二分查找的算法复杂度是什么?由于当search
调用bSearch
时,high
–low
的值等于len(L)-1
,因此search
的复杂度是θ(log(len(``L``)))
。⁷³
练习题: 为什么代码在第二个递归调用中使用mid+1
而不是mid
?
12.2 排序算法
我们刚刚看到,如果我们知道一个列表是排序好的,我们可以利用这一信息大大减少搜索该列表所需的时间。这是否意味着当被要求搜索一个列表时,我们应该先对其进行排序,然后再进行搜索?
让θ(sortComplexity(L))
成为对排序列表复杂度的紧密界限。因为我们知道可以在θ(len(L))
时间内搜索一个无序列表,因此,是否应该先排序再搜索的问题归结为,sortComplexity(L) + log(len(L))
是否小于len(L)
?可悲的是,答案是否定的。要排序一个列表,至少必须查看列表中的每个元素一次,因此不可能在次线性时间内对列表进行排序。
这是否意味着二分搜索是一个没有实际意义的智力好奇?幸运的是,答案是否定的。假设我们期望多次搜索同一列表。一次性对列表进行排序的开销可能是值得的,然后在多次搜索中摊销排序的成本。如果我们期望搜索列表k
次,相关的问题变为,sortComplexity(L) + k
log(len(L))
是否小于k
len(L)
?
随着k
变得较大,排序列表所需的时间变得越来越不相关。k
需要多大取决于排序列表所需的时间。例如,如果排序在列表大小上是指数级的,k
必须相当大。
幸运的是,排序可以相当高效地完成。例如,大多数 Python 实现中标准的排序实现大约在O(n
*log(n))
时间内运行,其中n
是列表的长度。实际上,您很少需要实现自己的排序函数。在大多数情况下,正确的做法是使用 Python 的内置sort
方法(L.sort()
对列表L
进行排序)或其内置函数sorted
(sorted(L)
返回一个与L
具有相同元素的列表,但不会修改L
)。我们在此呈现排序算法,主要是为了提供一些思考算法设计和复杂性分析的练习。
我们从一个简单但低效的算法开始,即选择排序。选择排序,图 12-4,通过保持循环不变量来工作,给定将列表划分为前缀(L[0:i]
)和后缀(L[i+1:len(L)]
),前缀是排序的,并且前缀中的没有任何元素大于后缀中的最小元素。
我们使用归纳法推理循环不变量。
基础情况:在第一次迭代开始时,前缀为空,即后缀是整个列表。因此,不变量(显然)成立。
归纳步骤:在算法的每一步中,我们将一个元素从后缀移动到前缀。我们通过将后缀中的最小元素附加到前缀的末尾来实现这一点。由于在移动元素之前不变量成立,我们知道在附加元素后,前缀仍然是排序的。我们还知道,由于我们移除了后缀中的最小元素,前缀中的没有任何元素大于后缀中的最小元素。
终止:当退出循环时,前缀包括整个列表,而后缀为空。因此,整个列表现在按升序排序。
图 12-4 选择排序
很难想象有比这更简单或更明显正确的排序算法。不幸的是,它的效率相当低下。⁷⁴ 内部循环的复杂度是θ(len(L))
。外部循环的复杂度也是θ(len(L))
。因此,整个函数的复杂度是θ(len(L)
²)
。即,它在L
的长度上是二次的。⁷⁵
12.2.1 归并排序
幸运的是,我们可以使用分治 算法来比二次时间表现得更好。基本思想是组合原问题的简单实例的解决方案。一般来说,分治算法的特点是
一个阈值输入大小,低于此大小的问题不进行细分。
将一个实例分割成的子实例的大小和数量。
用于组合子解决方案的算法。
阈值有时称为递归基。对于项目 2,通常考虑初始问题大小与子实例大小的比率。在我们到目前为止看到的大多数例子中,比率为2
。
归并排序是一个典型的分治算法。它于 1945 年由约翰·冯·诺依曼发明,至今仍广泛使用。像许多分治算法一样,它最容易以递归方式描述:
1. 如果列表长度为
0
或1
,则已排序。2. 如果列表有多个元素,则将列表拆分为两个列表,并使用归并排序对每个列表进行排序。
3. 合并结果。
冯·诺依曼的关键观察是,可以高效地将两个已排序的列表合并为一个已排序的列表。这个想法是查看每个列表的第一个元素,并将较小的一个移动到结果列表的末尾。当其中一个列表为空时,只需将另一个列表中剩余的项目复制过来。例如,考虑合并两个列表L_1 =
[1,5,12,18,19,20]
和L_2 =
[2,3,4,17]
:
L_1 中剩余 | L_2 中剩余 | 结果 |
---|---|---|
[1,5,12,18,19,20] |
[2,3,4,17] |
[] |
[5,12,18,19,20] |
[2,3,4,17] |
[1] |
[5,12,18,19,20] |
[3,4,17] |
[1,2] |
[5,12,18,19,20] |
[4,17] |
[1,2,3] |
[5,12,18,19,20] |
[17] |
[1,2,3,4] |
[12,18,19,20] |
[17] |
[1,2,3,4,5] |
[18,19,20] |
[17] |
[1,2,3,4,5,12] |
[18,19,20] |
[] |
[1,2,3,4,5,12,17] |
[] |
[] |
[1,2,3,4,5,12,17,18,19,20] |
合并过程的复杂度是什么?它涉及两个常数时间操作,比较元素的值和从一个列表复制元素到另一个列表。比较的数量是θ(len(L))
,其中L
是两个列表中较长的一个。复制操作的数量是θ(len(L1) + len(L2))
,因为每个元素正好复制一次。(复制一个元素的时间取决于元素的大小。然而,这不会影响排序的增长顺序,作为列表中元素数量的函数。)因此,合并两个已排序的列表在列表长度上是线性的。
图 12-5 包含了合并排序算法的实现。
图 12-5 合并排序
注意我们将比较操作符作为merge_sort
函数的参数,并编写了一个 lambda 表达式来提供默认值。因此,例如,代码
L = [2,1,4,5,3]
print(merge_sort(L), merge_sort(L, lambda x, y: x > y))
打印
[1, 2, 3, 4, 5] [5, 4, 3, 2, 1]
让我们分析一下merge_sort
的复杂度。我们已经知道merge
的时间复杂度是θ(len(L))
。在每一层递归中,要合并的元素总数是len(L)
。因此,merge_sort
的时间复杂度是θ(len(``L``))
乘以递归层数。由于merge_sort
每次将列表分成两半,我们知道递归层数是θ(log(len(``L``)))
。因此,merge_sort
的时间复杂度是θ(n
*log(n))
,其中n
是len(L)
。⁷⁶
这比选择排序的θ(len(``L``)
²)好得多。例如,如果
L有
10,000个元素,
len(L
)²是
100百万,但
len(L
)*
log[2]
(len(L
))大约是
130,000`。
这种时间复杂度的改进是有代价的。选择排序是就地排序算法的一个例子。因为它通过交换列表中的元素位置来工作,所以只使用了常量数量的额外存储(在我们的实现中是一个元素)。相比之下,合并排序算法涉及对列表的复制。这意味着它的空间复杂度是θ(len(``L``))
。这对于大型列表来说可能是一个问题。⁷⁷
假设我们想对以名字和姓氏书写的名字列表进行排序,例如,列表['Chris Terman', ‘Tom Brady', 'Eric Grimson', 'Gisele Bundchen']
。图 12-6 定义了两个排序函数,然后用这两个函数以两种不同方式对列表进行排序。每个函数使用了str
类型的split
方法。
图 12-6 对名字列表进行排序
当运行图 12-6 中的代码时,它打印
Sorted by last name = ['Tom Brady', 'Gisele Bundchen', 'Eric Grimson']
Sorted by first name = ['Eric Grimson', 'Gisele Bundchen', ‘Tom Brady']
手指练习:使用merge_sort
对整数元组列表进行排序。排序顺序应由元组中整数的总和决定。例如,(5, 2)
应在(1, 8)
之前,并在(1, 2, 3)
之后。
12.2.2 Python 中的排序
大多数 Python 实现中使用的排序算法称为timsort。⁷⁸ 关键思想是利用许多数据集中数据已经部分排序的事实。Timsort 的最坏情况性能与归并排序相同,但*均性能明显更好。
如前所述,Python 方法list.sort
将列表作为第一个参数并修改该列表。相比之下,Python 函数sorted
将可迭代对象(例如列表或视图)作为第一个参数并返回一个新的排序列表。例如,代码
L = [3,5,2]
D = {'a':12, 'c':5, 'b':'dog'}
print(sorted(L))
print(L)
L.sort()
print(L)
print(sorted(D))
D.sort()
将打印
[2, 3, 5]
[3, 5, 2]
[2, 3, 5]
['a', 'b', 'c']
AttributeError: ‘dict' object has no attribute ‘sort'
注意,当sorted
函数应用于字典时,它返回字典键的排序列表。相比之下,当对字典应用sort
方法时,会引发异常,因为没有dict.sort
方法。
list.sort
方法和sorted
函数可以有两个额外参数。key
参数在我们实现的归并排序中与compare
的作用相同:它提供要使用的比较函数。reverse
参数指定列表是相对于比较函数按升序还是降序排序。例如,代码
L = [[1,2,3], (3,2,1,0), 'abc']
print(sorted(L, key = len, reverse = True))
按长度反向顺序对L
的元素进行排序并打印
`[(3, 2, 1, 0), [1, 2, 3], 'abc']`
list.sort
方法和sorted
函数都提供稳定排序。这意味着如果两个元素在排序中根据比较(在这个例子中是len
)是相等的,它们在原始列表(或其他可迭代对象)中的相对顺序会在最终列表中保留。(由于在dict
中没有键可以出现多次,应用于dict
时sorted
是否稳定的问题无关紧要。)
指尖练习:merge_sort
是稳定排序吗?
12.3 哈希表
如果我们将归并排序与二分搜索结合起来,就有了一种很好的方式来搜索列表。我们使用归并排序在θ(n
log(n))
时间内预处理列表,然后用二分搜索测试元素是否在列表中,这样的时间复杂度是θ*log(n))
。如果我们搜索列表k
次,总体时间复杂度是θ(n
log(n) + k
log(n))
。
这很好,但我们仍然可以问,当我们愿意进行一些预处理时,是否对搜索来说对数是我们能做到的最佳?
当我们在第五章介绍类型dict
时,我们提到字典使用一种称为哈希的技术,以几乎与字典大小无关的时间进行查找。哈希表的基本思想很简单。我们将键转换为整数,然后使用该整数在列表中进行索引,这可以在常数时间内完成。原则上,任何类型的值都可以轻松转换为整数。毕竟,我们知道每个对象的内部表示是一系列位,任何位序列都可以视为表示一个整数。例如,字符串'abc'
的内部表示是位序列011000010110001001100011
,可以视为十进制整数6,382,179
的表示。当然,如果我们想将字符串的内部表示用作列表的索引,列表将不得不相当长。
那么,当键已经是整数时呢?暂时想象一下,我们正在实现一个字典,所有键都是美国社会安全号码,九位整数。如果我们用包含10
⁹个元素的列表来表示字典,并使用社会安全号码作为索引,我们可以以常数时间进行查找。当然,如果字典只包含一万(10
⁴)人的条目,这将浪费很多空间。
这使我们进入哈希函数的话题。哈希函数将大量输入空间(例如,所有自然数)映射到较小的输出空间(例如,介于0
和5000
之间的自然数)。哈希函数可以用来将大量键转换为较小的整数索引空间。
由于可能输出的空间小于可能输入的空间,哈希函数是一种多对一映射,即多个不同的输入可能映射到相同的输出。当两个输入映射到相同的输出时,这被称为碰撞——我们将很快回到这个话题。一个好的哈希函数产生均匀分布;即范围内的每个输出都是同样可能的,从而最小化碰撞的概率。
图 12-7 使用了一个简单的哈希函数(回忆一下i%j
返回整数i
除以整数j
的余数)来实现一个以整数为键的字典。
基本思想是通过一组哈希****桶表示Int_dict
类的实例,其中每个桶是实现为元组的键/值对的列表。通过将每个桶作为列表,我们通过将所有哈希到同一桶的值存储在列表中来处理碰撞。
哈希表的工作原理如下:实例变量buckets
被初始化为一个包含num_buckets
个空列表的列表。要存储或查找具有键key
的条目,我们使用哈希函数%
将key
转换为整数,并使用该整数索引到buckets
中以找到与key
关联的哈希桶。然后我们在线性搜索该桶(记住,它是一个列表),以查看是否有与键key
的条目。如果我们正在查找并且有该键的条目,我们就简单地返回存储的值。如果没有该键的条目,我们返回None
。如果要存储一个值,我们首先检查哈希桶中是否已经存在该键的条目。如果是,我们用一个新的元组替换该条目;否则我们将新的条目追加到桶中。
处理碰撞还有许多其他方法,有些比使用列表高效得多。但这可能是最简单的机制,如果哈希表相对于存储的元素数量足够大,并且哈希函数提供了足够好的均匀分布*似,这种方法效果很好。
图 12-7 使用哈希实现字典
请注意,__str__
方法产生的字典表示与添加元素的顺序无关,而是按键的哈希值的顺序排列。
以下代码首先构造一个包含 17 个桶和 20 个条目的Int_dict
。条目的值是整数0
到19
。键是随机选择的,使用random.choice
从范围0
到10
⁵ - 1
的整数中选择。(我们在第十六章和第十七章讨论random
模块。)然后代码使用类中定义的__str__
方法打印Int_dict
。
import random
D = Int_dict(17)
for i in range(20):
#choose a random int in the range 0 to 10**5 - 1
key = random.choice(range(10**5))
D.add_entry(key, i)
print('The value of the Int_dict is:')
print(D)
当我们运行这段代码时,它打印了⁷⁹
The value of the Int_dict is:
{99740:6,61898:8,15455:4,99913:18,276:19,63944:13,79618:17,51093:15,8271:2,3715:14,74606:1,33432:3,58915:7,12302:12,56723:16,27519:11,64937:5,85405:9,49756:10,17611:0}
以下代码通过迭代D.buckets
打印各个哈希桶。(这严重违反了信息隐藏,但在教学上是有用的。)
print('The buckets are:')
for hash_bucket in D.buckets: #violates abstraction barrier
print(' ', hash_bucket)
当我们运行这段代码时,它打印了
The buckets are:
[]
[(99740, 6), (61898, 8)]
[(15455, 4)]
[]
[(99913, 18), (276, 19)]
[]
[]
[(63944, 13), (79618, 17)]
[(51093, 15)]
[(8271, 2), (3715, 14)]
[(74606, 1), (33432, 3), (58915, 7)]
[(12302, 12), (56723, 16)]
[]
[(27519, 11)]
[(64937, 5), (85405, 9), (49756, 10)]
[]
[(17611, 0)]
当我们违反抽象边界并查看Int_dict
的表示时,我们看到一些哈希桶是空的。其他桶则包含一个或多个条目——这取决于发生的碰撞数量。
get_value
的复杂度是什么?如果没有碰撞,它将是常数时间
,因为每个哈希桶的长度将是 0 或 1。但当然,可能会发生碰撞。如果所有内容都哈希到同一个桶,它将在字典中的条目数量上是线性的
,因为代码将在该哈希桶上执行线性搜索。通过使哈希表足够大,我们可以减少碰撞的数量,从而将复杂度视为常数时间
。也就是说,我们可以用空间换取时间。但是,这种权衡是什么?要回答这个问题,我们需要使用一点概率,所以我们将答案推迟到第十七章。
12.4 在章节中介绍的术语
搜索算法
搜索空间
指针
间接寻址
二分查找
包装函数
*摊复杂度
选择排序
循环不变式
分治算法
递归基
归并排序
原地排序
快速排序
TimSort
稳定排序
哈希表
哈希函数
多对一映射
冲突
均匀分布
哈希桶
第十三章:绘图及类的更多信息
通常,文本是传达信息的最佳方式,但有时有一句中国谚语非常真实:“图片的意义可以表达*万字”。然而,大多数程序依赖文本输出与用户交流。为什么?因为在许多编程语言中,呈现视觉数据太难。幸运的是,在 Python 中这很简单。
13.1 使用 Matplotlib 绘图
Matplotlib是一个 Python 库模块,提供类似于 MATLAB 的绘图功能,“MATLAB 是一个用于算法开发、数据可视化、数据分析和数值计算的高级技术计算语言和交互式环境。” ⁸⁰ 在本书后面我们将看看其他提供 MATLAB 类似功能的 Python 库。在这一章中,我们专注于一些简单的数据绘图方式。关于 Matplotlib 绘图功能的完整用户指南可以在网站上找到。
`[plt.sourceforge.net/users/index.html](http://plt.sourceforge.net/users/index.html)`
我们不会在这里尝试提供用户指南或完整的教程。相反,在这一章中,我们仅提供几个示例图并解释生成这些图的代码。我们将在后面的章节中介绍许多其他绘图功能。
让我们从一个简单的例子开始,使用plt.plot
生成一个单一的图表。执行
import matplotlib.pyplot as plt
plt.plot([1,2,3,4], [1,7,3,5]) #draw on current figure
将产生一个类似但不完全相同于图 13-1 的图表。你的图表可能会有一条彩色线。⁸¹ 此外,如果你使用大多数 Matplotlib 安装的默认参数设置运行此代码,线条的粗细可能不会像图 13-1 中的线条那么粗。我们使用了非标准的默认值来设置线宽和字体大小,以便图形在黑白打印时效果更佳。我们将在本节后面讨论如何做到这一点。
图表在你的显示器上出现的位置取决于你使用的 Python 环境。在用于生成本书本版的 Spyder 版本中,默认情况下,它出现在被称为“图表窗格”的地方。
图 13-1 一个简单的图表
生成多个图形并将其写入文件是可能的。这些文件可以有你喜欢的任何名称。默认情况下,它们都将具有.png
的文件扩展名,但你可以使用关键字参数format
将其更改为其他格式(例如,.jpg
)。
代码
`plt.figure(1) #create figure 1 plt.plot([1,2,3,4], [1,2,3,4]) #draw on figure 1 plt.figure(2) #create figure 2 plt.plot([1,4,2,3], [5,6,7,8]) #draw on figure 2 plt.savefig('Figure-Addie') #save figure 2 plt.figure(1) #go back to working on figure 1 plt.plot([5,6,10,3]) #draw again on figure 1 plt.savefig('Figure-Jane') #save figure 1`
生成并保存到名为Figure-Jane.png
和Figure-Addie.png
的文件中的两个图表,见图 13-2。
图 13-2 Figure-Jane.png(左)和 Figure-Addie.png(右)的内容
请注意,最后一次调用plt.plot
仅传递了一个参数。该参数提供了y
值。对应的x
值默认为range(len([5, 6, 10, 3]))
所生成的序列,这就是它们在此情况下从0
到3
的原因。
Matplotlib 有一个 当前图形 的概念。执行 plt.figure(x)
将当前图形设置为编号为 x
的图形。后续执行的绘图函数调用隐式引用该图形,直到再次调用 plt.figure
。这解释了为什么写入文件 Figure-Addie.png
的图形是第二个创建的图形。
让我们看另一个例子。 图 13-3 左侧的代码生成了 图 13-4 左侧的图。
图 13-3 生成复合增长图
图 13-4 复合增长图
如果我们查看代码,可以推断出这是一个展示初始投资 $10,000
在每年复合利率 5%
下增长的图。然而,仅仅通过查看图形本身无法轻易推断出来。这是一个不好的地方。所有图形都应该有信息丰富的标题,所有轴都应该标记。如果我们在代码末尾添加 图 13-3 右侧的行,我们就能得到 图 13-4 右侧的图形。
对于每个绘制的曲线,有一个可选参数是格式字符串,指示图形的颜色和线型。格式字符串的字母和符号源于 MATLAB,并由一个颜色指示符后跟一个可选的线型指示符组成。默认格式字符串是 'b-'
,表示生成一条实心蓝线。要用黑色圆圈绘制本金增长,将调用 plt.plot(values)
替换为 plt.plot(values, 'ko')
,这会生成 图 13-5 中的图形。有关颜色和线型指示符的完整列表,请参见
[`plt.org/api/pyplot_api.html#plt.pyplot.plot`](http://plt.org/api/pyplot_api.html#plt.pyplot.plot)
图 13-5 另一幅复合增长图
也可以更改绘图中使用的字体大小和线宽。这可以通过在单个函数调用中使用关键字参数来完成。例如,代码
principal = 10000 #initial investment
interestRate = 0.05
years = 20
values = []
for i in range(years + 1):
values.append(principal)
principal += principal*interestRate
plt.plot(values, '-k', linewidth = 30)
plt.title('5% Growth, Compounded Annually',
fontsize = 'xx-large')
plt.xlabel('Years of Compounding', fontsize = 'x-small')
plt.ylabel('Value of Principal ($)')
生成的图形在 图 13-6 中看起来故意奇怪。
图 13-6 奇怪的图形
也可以更改默认值,这些值被称为“rc 设置”。(名称“rc”源于 Unix 中用于运行时配置文件的 .rc
文件扩展名。)这些值存储在一个字典样式的变量中,可以通过名称 plt.rcParams
访问。因此,例如,你可以通过执行代码将默认线宽设置为 6 个点⁸²。
`plt.rcParams['lines.linewidth'] = 6`.
rcParams
设置的数量非常庞大。完整列表可以在这里找到
[`plt.org/users/customizing.html`](http://plt.org/users/customizing.html)
如果你不想担心自定义单个参数,可以使用预定义的样式表。相关描述可以在这里找到
`[`plt.org/users/style_sheets.html#style-sheets`](http://plt.org/users/style_sheets.html#style-sheets)`
本书中大多数剩余示例使用的值是通过以下代码设置的
#set line width
plt.rcParams['lines.linewidth'] = 4
#set font size for titles
plt.rcParams['axes.titlesize'] = 20
#set font size for labels on axes
plt.rcParams['axes.labelsize'] = 20
#set size of numbers on x-axis
plt.rcParams['xtick.labelsize'] = 16
#set size of numbers on y-axis
plt.rcParams['ytick.labelsize'] = 16
#set size of ticks on x-axis
plt.rcParams['xtick.major.size'] = 7
#set size of ticks on y-axis
plt.rcParams['ytick.major.size'] = 7
#set size of markers, e.g., circles representing points
plt.rcParams['lines.markersize'] = 10
#set number of times marker is shown when displaying legend
plt.rcParams['legend.numpoints'] = 1
#Set size of type in legend
plt.rcParams['legend.fontsize'] = 14
如果你在彩色显示器上查看图表,就很少有理由更改默认设置。我们定制了设置,以便在将图表缩小以适应页面并转换为灰度时,更容易阅读图表。
13.2 绘制抵押贷款,扩展示例
在第十章中,我们通过一个抵押贷款的层次结构来说明子类化的使用。我们通过观察“我们的程序应该生成旨在显示抵押贷款随时间变化的图表”来结束这一章。图 13-7 通过添加方便生成这些图表的方法来增强类 Mortgage
。(图 10-10 中的函数 find_payment
在第 10.4 节中讨论。)
图 13-7 类 Mortgage
及其绘图方法
类 Mortgage
中的非*凡方法是 plot_tot_paid
和 plot_net
。方法 plot_tot_paid
绘制已支付款项的累计总额。方法 plot_net
绘制通过减去偿还部分贷款所获得的权益而得到的抵押贷款总成本的*似值。⁸³
在函数 plot_net
中,表达式 np.array(self.outstanding)
执行类型转换。到目前为止,我们一直使用 list
类型的参数调用 Matplotlib 的绘图函数。在后台,Matplotlib 已将这些列表转换为另一种类型 **array**
,这是 numpy
模块的一部分。导入 import numpy as np
和调用 np.array
使这一点明确。
**Numpy**
是一个提供科学计算工具的 Python 模块。除了提供多维数组外,它还提供各种数学功能。本书后面会有更多关于 numpy
的内容。
有许多方便的方式来操作数组,这些方法在列表中并不容易实现。特别是,可以使用数组和算术运算符形成表达式。在 numpy
中创建数组有多种方式,但最常见的方法是先创建一个列表,然后转换它。考虑以下代码
`import numpy as np`
`a1 = np.array([1, 2, 4]) print('a1 =', a1) a2 = a1*2 print('a2 =', a2) print('a1 + 3 =', a1 + 3) print('3 - a1 =', 3 - a1) print('a1 - a2 =', a1 - a2) print('a1*a2 =', a1*a2)`
表达式 a1*2
将 a1
的每个元素乘以常数 2
。表达式 a1 + 3
将整数 3
加到 a1
的每个元素上。表达式 a1 ‑ a2
从 a1
的相应元素中减去 a2
的每个元素(如果数组长度不同,会发生错误)。表达式 a1*a2
将 a1
的每个元素与 a2
的相应元素相乘。当上述代码运行时,它打印
a1 = [1 2 4]
a2 = [2 4 8]
a1 + 3 = [4 5 7]
3 - a1 = [ 2 1 -1]
a1 - a2 = [-1 -2 -4]
a1*a2 = [ 2 8 32]
图 13-8 重复了图 10-11 中 Mortgage
的三个子类。每个子类都有一个独特的 __init__
方法,覆盖了 Mortgage
中的 __init__
方法。子类 Two_rate
也覆盖了 Mortgage
的 make_payment
方法。
图 13-8 Mortgage
的子类
图 13-9 和图 13-10 包含可以用于生成旨在提供有关不同类型抵押贷款洞见的图表的函数。
函数compare_mortgages
,图 13-9,创建不同类型抵押贷款的列表,并模拟对每种贷款进行一系列付款。然后调用plot_mortgages
,图 13-10,以生成图表。
图 13-9 比较抵押贷款
图 13-10 生成抵押贷款图表
图 13-10 中的函数plot_mortgages
使用Mortgage
中的绘图方法生成包含三种不同类型抵押贷款信息的图表。plot_mortgages
中的循环使用索引i
从列表morts
和styles
中选择元素,以确保在图形中以一致的方式表示不同类型的抵押贷款。例如,由于morts
中的第三个元素是可变利率抵押贷款,styles
中的第三个元素是'k:'
,因此可变利率抵押贷款总是使用黑色虚线绘制。局部函数label_plot
用于为每个图表生成适当的标题和轴标签。plt.figure
的调用确保标题和标签与相应的图表相关联。
调用
`compare_mortgages(amt=200000, years=30, fixed_rate=0.07, pts = 3.25, pts_rate=0.05, var_rate1=0.045, var_rate2=0.095, var_months=48)`
生成的图表(图 13-11 到 13-13)比较三种类型的抵押贷款。
图 13-11 中显示的图表是通过调用plot_payments
生成的,它简单地将每种抵押贷款的每期付款与时间进行绘制。包含图例的框出现的位置是由于在调用plt.legend
时提供给关键字参数loc
的值。当loc
绑定到'best'
时,位置会自动选择。该图表清楚地显示了每月付款如何随时间变化(或不变化),但对每种抵押贷款的相对成本没有太多启示。
图 13-11 不同类型抵押贷款的每月付款
图 13-12 中的图表是通过调用plot_tot_pd
生成的。它通过绘制每月开始时已产生的累计成本来比较每种抵押贷款的成本。
图 13-12 不同类型抵押贷款的时间成本
图 13-13 中的图表显示了剩余债务(左侧)和持有抵押贷款的总净成本(右侧)。
图 13-13 不同类型抵押贷款的剩余余额和净成本
13.3 传染病的交互式图表
当我对这本书进行最后润色时,我正在家中遵循与限制 Covid-19 疾病传播相关的“社交距离”限制。像许多呼吸道病毒一样,SARS-CoV-2 病毒主要通过人与人之间的接触传播。社交距离旨在减少人类之间的接触,从而限制由病毒引起的疾病传播。
图 13-14 包含对传染病发生率随时间变化的简单模拟。参数fixed
是一个字典,定义了与感染传播相关的关键变量的初始值。参数variable
是一个字典,定义了与社交距离相关的变量。稍后我们将展示如何在交互式图中改变variable
的值。
图 13-14 传染病传播的模拟
在书的后面部分,我们详细讨论了模拟模型。然而,在这里,我们关注的是交互式绘图,模拟的目的是为我们提供一些有趣的绘图内容。如果你不理解模拟的细节,那也没关系。
图 13-15 包含一个生成静态图的函数,该图显示了每一天的感染人数。它还包含一个文本框,显示感染总人数。以txt_box = plt.text
开头的语句指示 Python 在由plt.text
的前两个参数指定的位置开始绘制由第三个参数指定的文本。表达式plt.xlim()[1]/2
将文本的左边缘放置在 x 轴左端(该图的 0)和 x 轴右端之间的中间位置。表达式plt.ylim()[1]/1.25
将文本放置在 y 轴底部(该图的 0)到 y 轴顶部之间的 80%处。
图 13-15 绘制感染历史的函数
图 13-16 使用图 13-14 和图 13-15 中的函数生成一个图图 13-17,显示感染人数——假设没有社交距离。fixed
中的值并不基于特定的疾病。然而,假设一个人*均每天与 50 人“接触”可能会让人感到惊讶。不过,请记住,这个数字包括间接接触,例如,与感染者乘坐同一辆公交车或接触可能被感染者留下病原体的物体。
图 13-16 用一组参数生成图
图 13-17 感染人数的静态图
图表显示当前感染人数的快速上升,随后迅速下降至零当前感染的稳定状态。这种快速增长发生是因为每个感染者会感染多个其他人,因此能够传播感染的人数呈指数增长。没有新感染的稳定状态是因为人口已经达到了群体免疫。当一个足够大比例的人口对一种疾病免疫时(假设从这种疾病中恢复的人不会再感染),就会有长时间没有人感染该疾病,这最终导致没有人再能传播它。⁸⁴ 如果我们想探索不同参数设置的影响,可以在fixed
中改变一些变量的值,并生成另一个图表。然而,这是一种相当繁琐的方式来探索“如果……会怎样”的情境。相反,让我们生成一个包含滑块⁸⁵的图形,可以用来动态改变与社交距离相关的关键参数:reduced_contacts_per_day
、red_start
和red_end
。
图形将有四个独立的组件:主图和每个字典variable
元素的一个滑块。我们首先通过指定图形的整体尺寸(宽 12 英寸,高 8.5 英寸)、位置(以与文本框相同的方式指定)和每个组件的尺寸(相对于整个图形的大小)来描述图形的布局。我们还为每个组件绑定一个名称,以便后续引用。
fig = plt.figure(figsize=(12, 8.5))
infections_ax = plt.axes([0.12, 0.2, 0.8, 0.65])
contacts_ax = plt.axes([0.25, 0.09, 0.65, 0.03])
start_ax = plt.axes([0.25, 0.06, 0.65, 0.03])
end_ax = plt.axes([0.25, 0.03, 0.65, 0.03]
接下来的代码行定义了三个滑块,每个滑块对应我们想要变化的一个值。首先,我们导入一个包含Slider
类的模块。
from Matplotlib.widgets import Slider
接下来,我们创建三个滑块,将每个滑块绑定到一个变量上。
contacts_slider = Slider(
contacts_ax, # axes object containing the slider
‘reduced\ncontacts/day', # name of slider
0, # minimal value of the parameter
50, # maximal value of the parameter
50) # initial value of the parameter)
contacts_slider.label.set_fontsize(12)
start_day_slider = Slider(start_ax, ‘start reduction', 1, 30, 20)
start_day_slider.label.set_fontsize(12)
end_day_slider = Slider(end_ax, 'end reduction', 30, 400, 200)
end_day_slider.label.set_fontsize(12)
接下来,我们提供一个函数update
,该函数根据滑块的当前值更新图表。
def update(fixed, infection_plot, txt_box,
contacts_slider, start_day_slider, end_day_slider):
variable = {'red_daily_contacts': contacts_slider.val,
‘red_start': start_day_slider.val,
‘red_end': end_day_slider.val}
I, total_infections = simulation(fixed, variable)
infection_plot.set_ydata(I) # new y-coordinates for plot
txt_box.set_text(f'Total Infections = {total_infections:,.0f}')
接下来,我们需要指示 Python 在滑块的值发生变化时调用update
。这有点棘手。Slider
类包含一个方法on_changed
,它接受一个类型为function
的参数,该参数在滑块变化时被调用。这个函数始终只接受一个参数,即表示滑块当前值的数字。然而,在我们的情况下,每次滑块改变时,我们希望使用所有三个滑块的值和字典fixed
中的值运行模拟。
我们通过引入一个新的函数来解决这个问题,该函数是on_changed
的合适参数。函数slider_update
接受所需的数字参数,但并不使用它。相反,定义slider_update
的 lambda 表达式捕获了与fixed
、infection_plot
、txt_box
和三个滑块绑定的对象。然后,它使用这些参数调用update
。
slider_update = lambda _: update(fixed, infection_plot, txt_box,
contacts_slider, start_day_slider,
end_day_slider)
contacts_slider.on_changed(slider_update)
start_day_slider.on_changed(slider_update)
end_day_slider.on_changed(slider_update)
最后,我们绘制感染曲线,并在与infections_ax
绑定的图形部分更新文本框。
infections, total_infections = simulation(fixed, variable)
plt.axes(infections_ax)
infection_plot, txt_box = plot_infections(infections,
total_infections, fixed)
当运行此代码时,它会生成图 13-18 中的图形。⁸⁶
图 13-18 初始滑块值的交互式图
现在,我们可以轻松实验许多滑块值的组合,其中一个组合如图 13-19 所示。
图 13-19 滑块值改变后的交互式图
图 13-19 显示,如果在 20 天后将接触次数减少到*均每天 25 次,并保持这个水* 40 周,则总感染人数会减少。更重要的是,感染的峰值数量(因此对医疗系统的最大负担)会显著降低。这通常被称为扁*化曲线。
13.4 章节中引入的术语
图形
Matplotlib
图
当前图形
rcParams
数组
numpy
交互式图
文本框
群体免疫
滑块
扁*化曲线
第十四章:背包和图优化问题
优化问题的概念提供了一种结构化的方式来思考解决许多计算问题。每当你开始解决一个涉及寻找最大、最小、最多、最少、最快、最便宜等的问题时,很可能可以将该问题映射到一个经典的优化问题上,而这个问题有已知的计算解决方案。
一般来说,优化问题有两个部分:
一个需要最大化或最小化的目标函数。例如,波士顿和伊斯坦布尔之间的机票费用。
一组必须遵循的约束条件(可能为空)。例如,旅行时间的上限。
在本章中,我们引入了优化问题的概念并给出了一些例子。我们还提供了一些解决这些问题的简单算法。在第十五章中,我们讨论了一种有效解决重要类别优化问题的方法。
本章要点包括:
许多重要的问题可以用一种简单的方式表述,自然引导到计算解决方案。
将一个看似新颖的问题简化为一个已知问题的实例,可以让你利用现有的解决方案。
背包问题和图问题是可以将其他问题简化为的类别。
穷举枚举算法提供了一种简单但通常在计算上不可行的方式来寻找最佳解决方案。
贪婪算法通常是寻找一个相当不错但不总是最佳的优化问题解决方案的实用方法。
和往常一样,我们将用一些 Python 代码和编程技巧补充计算思维的材料。
14.1 背包问题
作为一个小偷并不容易。除了显而易见的问题(确保家中空无一人、撬锁、规避警报、处理伦理困境等),小偷还必须决定偷什么。问题在于,大多数家庭的有价值物品超出一般小偷能携带的数量。一个可怜的小偷该怎么办?他(或她)需要找到提供最大价值的物品集合,同时不超过他的携带能力。
假设,例如,一个小偷有一个最多能装20
磅战利品的背包⁸⁷,他闯入一所房子,发现图 14-1 中的物品。显然,他无法将所有物品放入背包,因此他需要决定带走什么,留下什么。
图 14-1 物品表
14.1.1 贪婪算法
找到这个问题的*似解的最简单方法是使用贪心算法。小偷会先选择最好的物品,然后是下一个最好的,持续进行直到他达到极限。当然,在这样做之前,小偷必须决定什么是“最好”。最好的物品是最有价值的,最轻的,还是可能是价值与重量比最高的物品?如果他选择最高价值,他将只拿走电脑,可以以$200
变卖。如果他选择最低重量,他将依次拿走书、花瓶、收音机和画作,总价值为$170
。最后,如果他决定最好的意味着价值与重量比最高,他将首先拿走花瓶和时钟。这将留下三个价值与重量比为10
的物品,但其中只有书能放进背包。拿走书后,他将拿走仍能放进去的剩余物品,即收音机。他的战利品总价值将为$255
。
尽管按密度贪心(价值与重量比)恰好为这个数据集提供了最佳结果,但没有保证贪心按密度算法总能找到比按重量或价值贪心算法更好的解决方案。更一般来说,任何通过贪心算法找到的这类背包问题的解决方案都不一定是最优的。⁸⁸我们稍后会更详细地讨论这个问题。
接下来三幅图中的代码实现了这三种贪心算法。在图 14-2 中,我们定义了类Item
。每个Item
都有一个name
、value
和weight
属性。我们还定义了三个可以绑定到我们实现的greedy
的参数key_function
的函数;见图 14-3。
图 14-2 类Item
图 14-3 贪心算法的实现
通过引入参数key_function
,我们使得greedy
不再依赖于考虑列表元素的顺序。所需的只是key_function
定义了items
中元素的排序。然后,我们使用这个排序生成一个包含与items
相同元素的排序列表。我们使用内置的 Python 函数sorted
来实现这一点。(我们使用sorted
而不是sort
,因为我们希望生成一个新列表,而不是改变传入函数的列表。)我们使用reverse
参数来表示我们希望列表从最大(根据key_function
)排序到最小。
greedy
的算法效率如何?需要考虑两件事:内置函数sorted
的时间复杂度,以及在greedy
主体中for
循环的执行次数。循环的迭代次数受限于items
中的元素数量,即为θ(n)
,其中n
是items
的长度。然而,Python 内置排序函数的最坏情况时间大致为θ(n log n)
,其中n
是待排序列表的长度。⁸⁹因此,greedy 的运行时间为θ(n log n)
。
图 14-4 中的代码构建了一项列表,然后使用不同的排序方式测试函数greedy
。
图 14-4 使用贪心算法选择项目
当执行test_greedys()
时,它会打印
Use greedy by value to fill knapsack of size 20
Total value of items taken is 200.0
<computer, 200, 20>
Use greedy by weight to fill knapsack of size 20
Total value of items taken is 170.0
<book, 10, 1>
<vase, 50, 2>
<radio, 20, 4>
<painting, 90, 9>
Use greedy by density to fill knapsack of size 20
Total value of items taken is 255.0
<vase, 50, 2>
<clock, 175, 10>
<book, 10, 1>
<radio, 20, 4>
14.1.2 0/1 背包问题的最优解决方案
假设我们决定*似解不够好,即我们希望这个问题的最佳可能解决方案。这样的解决方案称为最优,这并不奇怪,因为我们正在解决一个优化问题。恰好,我们的盗贼面临的问题是经典优化问题的一个实例,称为0/1 背包问题。0/1 背包问题可以形式化如下:
每个项目由一对表示,<
*value, weight*
>。背包可以容纳总重量不超过
w
的项目。长度为 n 的向量 I 表示可用项目的集合。向量的每个元素都是一个项目。
长度为 n 的向量 V 用于指示每个项目是否被盗贼拿走。如果 V[i] = 1,则项目 I[i]被拿走。如果 V[i] = 0,则项目 I[i]未被拿走。
找到一个最大化的 V
受限于以下约束条件
让我们看看如果尝试以直接方式实现此问题的公式会发生什么:
1. 列举所有可能的项目组合。也就是说,生成项目集的所有子集。⁹⁰这称为幂集,已在第十一章讨论过。
2. 移除所有重量超过允许重量的组合。
3. 从剩余组合中选择一个价值最大的组合。
这种方法肯定能找到一个最佳答案。然而,如果原始项目集很大,运行将需要很长时间,因为正如我们在第 11.3.6 节看到的,子集的数量随着项目数量的增加而迅速增长。
图 14-5 包含了这种暴力解决 0/1 背包问题的直接实现。它使用了在图 14-2、图 14-3、图 14-4 中定义的类和函数,以及在图 11-6 中定义的函数gen_powerset
。
图 14-5 暴力求解 0/1 背包问题的最优解
这个实现的复杂度是θ(n
2
^n)
,其中n
是items
的长度。函数gen_powerset
返回一个包含Item
的列表的列表。这个列表的长度为2
^n,其中最长的列表的长度为n
。因此,choose_best
中的外部循环将执行θ*(2
^n)
次,内部循环的执行次数由n
限制。
许多小的优化可以应用于加速这个程序。例如,gen_powerset
可以有如下头部
def `gen_powerset`(`item`s, con`str`aint, get_val, get_weight)
并且只返回那些符合重量约束的组合。或者,choose_best
可以在超过重量约束时立即退出内部循环。虽然这些类型的优化通常是值得做的,但它们并没有解决根本问题。choose_best
的复杂度仍然是θ(n
*2
^n)
,因此当items
很大时,choose_best
仍然会运行很长时间。
从理论上讲,这个问题是绝望的。0/1 背包问题在物品数量上本质上是指数级的。然而,从实践的角度来看,这个问题远非绝望,正如我们将在 15.2 节中讨论的那样。
当运行test_best
时,它会打印
Total value of items taken is 275.0
<clock, 175, 10>
<painting, 90, 9>
<book, 10, 1>
注意,这个解决方案找到了总价值高于贪心算法找到的任何解决方案的物品组合。贪心算法的本质是在每一步做出最佳(由某种标准定义的)局部选择。它做出了一个局部最优的选择。然而,正如这个例子所示,一系列局部最优的决策并不总是会导致一个全局最优的解决方案。
尽管贪心算法并不总是找到最佳解决方案,但它们在实践中经常被使用。通常情况下,它们比保证找到最优解的算法更容易实现且更高效。正如伊万·博斯基曾经说过的:“我认为贪婪是健康的。你可以贪婪,但仍然对自己感觉良好。” ⁹¹
对于一个称为分数(或连续)背包问题的背包问题变体,贪心算法可以保证找到一个最优解。由于这个变体中的物品可以无限分割,因此总是选择尽可能多的具有最高剩余价值与重量比的物品是有意义的。假设,例如,我们的窃贼在房子里只发现了三样有价值的东西:一袋金粉、一袋银粉和一袋葡萄干。在这种情况下,贪婪密度算法将始终找到最优解。
14.2 图优化问题
让我们考虑另一种优化问题。假设你有一份美国各城市之间所有航空航班价格的清单。假设对于所有城市 A, B,
和 C
,从 A
经 B
飞往 C
的费用是从 A
飞往 B
的费用加上从 B
飞往 C
的费用。你可能会想问的几个问题是:
两个城市之间的最少停留次数是多少?
两个城市之间的最低机票价格是多少?
在不超过两次停留的情况下,两个城市之间的最低机票价格是多少?
访问某些城市集合的最低费用是什么?
所有这些问题(以及其他许多问题)都可以很容易地形式化为图问题。
图⁹²是由称为 节点(或 顶点)的对象构成的一组对象,这些对象通过一组 边(或 弧)连接。如果边是单向的,则该图称为 有向图 或 有向图。在有向图中,如果存在一条从 n1 到 n2 的边,我们称 n1 为 源节点 或 父节点,而 n2 为 目标节点 或 子节点。
如果存在一系列边 < e[0], … , e[n] >,使得 e[0] 的起点是 n1,e[n] 的终点是 n2,并且在序列中所有边 e[1] 到 e[n] 的起点是 e[i]* 的终点 e[i][−1],那么称图中包含 路径 从 n1 到 n2。一个从节点到自身的路径称为 循环。包含循环的图称为 有向图,而不包含循环的图称为 无向图。
图通常用于表示各部分之间存在有趣关系的情况。图在数学中首次被记录的使用是在 1735 年,当时瑞士数学家莱昂哈德·欧拉使用了后来被称为 图论 的方法来制定和解决 柯尼斯堡桥问题。
柯尼斯堡,当时是东普鲁士的首都,建于两条河流的交汇处,河流中有多个岛屿。岛屿通过七座桥相互连接,并与大陆相连,如 图 14-6 左侧的地图所示。出于某种原因,市民们对是否能进行一次恰好经过每座桥一次的散步这个问题产生了浓厚的兴趣。
图 14-6 左侧是柯尼斯堡的桥,右侧是欧拉简化后的地图
欧拉的伟大见解在于,通过将每个独立的陆地块视为一个点(想想“节点”),每座桥视为连接这两个点的线(想想“边”),可以大大简化问题。镇的地图可以用右侧的无向图表示,如图 14-6 所示。欧拉推理,如果一次走遍每条边,走的过程中每个节点(即在行走过程中被进入和退出的任何岛屿)必须由偶数条边连接。由于这个图中的节点没有偶数条边,欧拉得出结论,不可能每座桥恰好走一次。
比起柯尼斯堡桥问题,甚至比欧拉定理(它将欧拉对柯尼斯堡桥问题的解决方案进行了推广)更有趣的是,使用图论来帮助理解问题的整个想法。
例如,仅需要对欧拉使用的那种图形进行一个小的扩展,就可以建模一个国家的高速公路系统。如果图(或有向图)中的每条边都有一个权重,则称之为加权图。使用加权图,高速公路系统可以表示为一个图,其中城市由节点表示,连接它们的高速公路作为边,每条边标记为两个节点之间的距离。更一般来说,我们可以通过加权有向图表示任何道路地图(包括单行街道的地图)。
同样,万维网的结构可以表示为一个有向图,其中节点是网页,只有在页面A上有指向页面B的链接时,节点A到节点B之间才会有一条边。流量模式可以通过为每条边添加一个权重来建模,以指示使用频率。
图形还有许多不太明显的用途。生物学家使用图来建模从蛋白质相互作用到基因表达网络的各种现象。物理学家使用图来描述相变。流行病学家使用图来建模疾病轨迹,等等。
图 14-7 包含实现与节点、加权边和边对应的抽象类型的类。
图 14-7 节点和边
拥有一个节点类可能看起来有些多余。毕竟,Node
类中的任何方法都没有执行任何有趣的计算。我们引入这个类只是为了给我们灵活性,以便在某些时刻决定引入一个具有附加属性的Node
子类。
图 14-8 包含Digraph
和Graph
类的实现。
图 14-8 类Graph
和Digraph
一个重要的决定是用于表示Digraph
的数据结构选择。一个常见的表示方式是n × n
邻接矩阵,其中n
是图中节点的数量。矩阵的每个单元格包含关于连接节点对<*i*, *j*>
的边的信息(例如,权重)。如果边是无权的,则只有在存在从i到j的边时,每个条目为True
。
另一种常见的表示方式是邻接表,我们在这里使用。类Digraph
有两个实例变量。变量nodes
是一个包含Digraph
中节点名称的 Python 列表。节点的连通性使用实现为字典的邻接表表示。变量edges
是一个字典,将Digraph
中的每个Node
映射到该Node
的子节点列表。
类Graph
是Digraph
的子类。它继承了Digraph
的所有方法,除了重写的add_edge
方法。(这并不是实现Graph
的最节省空间的方式,因为它每条边存储了两次,一次用于Digraph
的每个方向。但它的优点在于简单。)
你可能想停下来思考一下,为什么Graph
是Digraph
的子类,而不是反过来。在我们观察的许多子类化示例中,子类为超类添加了属性。例如,类Weighted_edge
向类Edge
添加了一个weight
属性。
在这里,Digraph
和Graph
具有相同的属性。唯一的区别是add_edge
方法的实现。任一类都可以通过继承另一类的方法轻松实现,但选择哪个作为超类并不是任意的。在第十章中,我们强调了遵循替代原则的重要性:如果客户端代码使用超类的实例正常工作,那么当用子类的实例替代超类的实例时,它也应正常工作。
确实,如果客户端代码使用Digraph
的实例工作正常,那么如果用Graph
的实例替代Digraph
的实例,它也会正常工作。反之则不然。有许多算法适用于图(通过利用边的对称性),而这些算法在有向图上并不适用。
14.2.1 一些经典的图论问题
使用图论来制定问题的一个好处是,有众所周知的算法可以解决许多图上的优化问题。一些最著名的图优化问题包括:
最短路径。对于某一对节点n1 和n2,找到边的最短序列
<*s[n], d[n]*>
(源节点和目标节点),使得○ 第一条边中的源节点是n1。
○ 最后一条边的目标节点是n2。
○ 对于序列中的所有边e1 和e2,如果e2 在序列中跟随e1,则e2 的源节点是e1 的目标节点。
最短加权路径。这类似于最短路径,但我们不是选择连接两个节点的最短边序列,而是定义某个函数来计算序列中边的权重(例如,它们的总和),并最小化该值。这就是谷歌和苹果地图在计算两个点之间的驾驶路线时所解决的那种问题。
最小割。给定图中两个节点集,割是一组边,去除这些边会消除从一个集合中的每个节点到另一个集合中每个节点的所有路径。
最大团。团是一组节点,集合中的每对节点之间都有一条边。⁹³ 最大团是图中最大规模的团。最小割是去除这些边后能实现这一点的最小边集。
14.2.2 最短路径:深度优先搜索和广度优先搜索
社交网络由个体和个体之间的关系组成。这些通常被建模为图,其中个体为节点,边为关系。如果关系是对称的,边是无向的;如果关系是非对称的,边是有向的。一些社交网络建模多种类型的关系,在这种情况下,边上的标签指示关系的类型。
在 1990 年,剧作家约翰·古埃尔写了《六度分隔》。该剧的可疑前提是“这个星球上的每个人仅被其他六个人隔开。”他的意思是,如果我们利用“认识”的关系建立一个包含地球上每个人的社交网络,那么任何两个个体之间的最短路径至多会经过六个其他节点。
一个较少假设性的问题是,使用 Facebook 上“朋友”关系的两个人之间的距离。例如,你可能想知道你是否有一个朋友,他有一个朋友,而那个朋友是嘎嘎女士的朋友。让我们考虑设计一个程序来回答这样的问题。
朋友关系(至少在 Facebook 上)是对称的,例如,如果萨姆是安德烈亚的朋友,安德烈亚也是萨姆的朋友。因此,我们将使用类型为 Graph
的社交网络来实现。然后我们可以将寻找你与嘎嘎女士之间最短连接的问题定义为:
让
*G*
表示朋友关系的图。对于
*G*
,寻找最短节点序列,[你,…,嘎嘎女士],使得如果 n[i] 和 n[i][+1] 是序列中的连续节点,则在
G
中有一条边连接 n[i] 和 n[i][+1]。
图 14-9 包含一个递归函数,该函数找到 Digraph
中两个节点 start
和 end
之间的最短路径。由于 Graph
是 Digraph
的子类,因此它适用于我们的 Facebook 问题。
图 14-9 深度优先搜索最短路径算法
DFS
实现的算法是递归深度优先搜索(DFS)算法的一个示例。一般来说,深度优先搜索算法通过选择起始节点的一个子节点开始。然后它选择该节点的一个子节点,依此类推,越来越深入,直到它达到目标节点或一个没有子节点的节点。然后搜索回溯,返回到最*的尚未访问的有子节点的节点。当所有路径都被探索完毕时,它选择从起点到目标的最短路径(假设存在这样一条路径)。
该代码比我们刚刚描述的算法更复杂,因为它必须处理图中包含循环的可能性。它还避免探索比已找到的最短路径更长的路径。
函数
shortest_path
以path == []
(表示当前探索的路径为空)和shortest == None
(表示尚未找到从start
到end
的路径)调用DFS
。DFS
开始时选择start
的一个子节点。然后它选择该节点的一个子节点,依此类推,直到到达节点end
或一个没有未访问子节点的节点。○ 检查
if node not in path
防止程序陷入循环。○ 检查
if shortest == None or len(path) < len(shortest)
用于决定继续搜索该路径是否可能产生比当前找到的最佳路径更短的路径。○ 如果是这样,
DFS
被递归调用。如果它找到一条到end
的路径,其长度不超过目前为止找到的最佳路径,则更新shortest
。○ 当
path
中的最后一个节点没有子节点可供访问时,程序回溯到之前访问过的节点,并访问该节点的下一个子节点。
当从
start
到end
的所有可能的最短路径都被探索后,函数返回。
图 14-10 包含一些代码,该代码运行图 14-9 中的代码。图 14-10 中的函数test_SP
首先构建一个有向图,如图中所示,然后在节点 0 和节点 5 之间搜索最短路径。
图 14-10 测试深度优先搜索代码
当执行时,test_SP
生成的输出是
Current DFS path: 0
Current DFS path: 0->1
Current DFS path: 0->1->2
Current DFS path: 0->1->2->3
Current DFS path: 0->1->2->3->4
Current DFS path: 0->1->2->3->5
Current DFS path: 0->1->2->4
Current DFS path: 0->2
Current DFS path: 0->2->3
Current DFS path: 0->2->3->4
Current DFS path: 0->2->3->5
Current DFS path: 0->2->3->1
Current DFS path: 0->2->4
Shortest path found by DFS: 0->2->3->5
请注意,在探索路径0->1->2->3->4
后,它回退到节点3
并探索路径0->1->2->3->5
。在将其保存为迄今为止的最短成功路径后,它回退到节点2
并探索路径0->1->2->4
。当到达该路径的尽头(节点4
)时,它回退到节点0
并调查从0
到2
的边开始的路径。依此类推。
在图 14-9 中实现的 DFS 算法找到边数最少的路径。如果边具有权重,它不一定找到最小化边权重和的路径。不过,它可以很容易地修改以实现此目的。
手指练习: 修改深度优先搜索算法以找到使权重总和最小的路径。假设所有权重都是正整数。
当然,除了深度优先搜索之外,还有其他遍历图的方法。另一种常见的方法是广度优先搜索(BFS)。广度优先遍历首先访问起始节点的所有子节点。如果其中没有一个是结束节点,则访问每个子节点的所有子节点,依此类推。与通常递归实现的深度优先搜索不同,广度优先搜索通常是迭代实现的。BFS 同时探索多条路径,每次迭代为每条路径添加一个节点。由于它以长度递增的顺序生成路径,第一次找到以目标节点为最后节点的路径保证边数最少。
图 14-11 包含使用广度优先搜索在有向图中找到最短路径的代码。变量path_queue
用于存储当前正在探索的所有路径。每次迭代开始时,从path_queue
中移除一条路径并将其分配给tmp_path
。如果tmp_path
的最后一个节点是end
,则tmp_path
是最短路径并被返回。否则,创建一组新路径,每条路径通过添加其子节点扩展tmp_path
。这些新路径随后被添加到path_queue
中。
图 14-11 广度优先搜索最短路径算法
当这些线
sp = BFS(g, nodes[0], nodes[5])
print('Shortest path found by BFS:', print_path(sp))
添加到test_SP
末尾并执行函数时,它会打印附加行
Current BFS path: 0
Current BFS path: 0->1
Current BFS path: 0->2
Current BFS path: 0->1->2
Current BFS path: 0->2->3
Current BFS path: 0->2->4
Current BFS path: 0->1->2->3
Current BFS path: 0->1->2->4
Current BFS path: 0->2->3->4
Current BFS path: 0->2->3->5
Shortest path found by BFS: 0->2->3->5
令人安心的是,每个算法找到的路径长度相同。在这种情况下,它们找到的是相同的路径。然而,如果图中存在多条最短路径,DFS 和 BFS 不一定找到相同的最短路径。
如上所述,广度优先搜索是一种方便的搜索路径的方式,因为第一次找到的路径保证是最少边数的路径。
手指练习: 考虑一个带权边的有向图。BFS 找到的第一条路径是否保证能够最小化边的权重总和?
14.3 本章引入的术语
优化问题
目标函数
约束集
背包问题
贪心算法
最优解
0/1 背包问题
局部最优
全局最优
连续背包问题
图
节点(顶点)
边(弧)
有向图(digraph)
源(父)节点
目标(子)节点
路径
循环
有环图
无环图
图论
带权图
邻接矩阵
邻接表
最短路径
最短带权路径
最小割
最大团
深度优先搜索(DFS)
回溯法
广度优先搜索(BFS)
第十五章:动态规划
动态规划是理查德·贝尔曼在 1950 年代发明的。不要试图从其名称中推测任何关于该技术的内容。正如贝尔曼所描述的,名称“动态规划”被选择是为了掩盖政府赞助者“我实际上是在做数学… [动态规划这个短语]是连国会议员都无法反对的东西。” ⁹⁴
动态规划是一种高效解决具有重叠子问题和最优子结构特征的问题的方法。幸运的是,许多优化问题具有这些特征。
如果一个全局最优解可以通过结合局部子问题的最优解来找到,则该问题具有最优子结构。我们已经查看了许多此类问题。例如,归并排序利用了列表可以通过先排序子列表然后合并解决方案来进行排序这一事实。
如果一个问题的最优解涉及多次解决同一问题,则该问题具有重叠子问题。归并排序并不具备这个特性。尽管我们多次进行归并,但每次归并的列表都是不同的。
这并不是显而易见的,但 0/1 背包问题同时具备这两个特性。首先,我们稍作偏离,来看一个最优子结构和重叠子问题更加明显的问题。
15.1 重新审视斐波那契数列
在第四章中,我们查看了斐波那契函数的一个简单递归实现:
def fib(n):
"""Assumes n is an int >= 0
Returns Fibonacci of n"""
if n == 0 or n == 1:
return 1
else:
return fib(n-1) + fib(n-2)
尽管这个递归实现显然是正确的,但它非常低效。例如,尝试运行fib(120)
,但不要等它完成。该实现的复杂度有点难以推导,但大致为O(fib(n))
。也就是说,它的增长与结果值的增长成比例,而斐波那契序列的增长率是相当大的。例如,fib(120)
是8,670,007,398,507,948,658,051,921
。如果每个递归调用花费一纳秒,fib(120)
将需要大约250,000
年才能完成。
让我们试着找出为什么这个实现耗时如此之长。考虑到fib
主体中的代码量很小,很明显问题在于fib
调用自身的次数。例如,查看与调用fib(6)
相关的调用树。
图 15-1 递归斐波那契的调用树
请注意,我们在反复计算相同的值。例如,fib
被调用了三次,且每次调用都会引发四次额外的fib
调用。想出一个好主意,即记录第一次调用返回的值,然后查找而不是每次都计算,并不需要天才。这是动态规划背后的关键思想。
动态规划有两种方法
记忆化从自顶向下解决原始问题。它从原始问题开始,将其分解为子问题,再将子问题分解为子问题,依此类推。每次解决一个子问题时,它都会将答案存储在表中。每次需要解决一个子问题时,它会首先尝试在表中查找答案。
表格法是一种自底向上的方法。它从最小的问题开始,并将这些问题的答案存储在表中。然后,它将这些问题的解决方案结合起来,以解决下一个最小的问题,并将这些答案存储在表中。
图 15-2 包含使用每种动态规划方法实现的斐波那契数列。函数 fib_memo
有一个参数 memo
,用于跟踪它已经评估的数字。该参数有一个默认值,即空字典,因此 fib_memo
的客户不必担心提供 memo
的初始值。当 fib_memo
被调用时,若 n > 1
,它会尝试在 memo
中查找 n
。如果不存在(因为这是第一次以该值调用 fib_memo
),则会引发异常。当这种情况发生时,fib_memo
使用正常的斐波那契递归,然后将结果存储在 memo
中。
函数 fib_tab
非常简单。它利用了所有斐波那契的子问题都是提前已知且容易以有用的顺序列举的事实。
图 15-2 使用备忘录实现斐波那契数列
如果你尝试运行 fib_memo
和 fib_tab
,你会看到它们确实非常快:fib(120)
几乎瞬间返回。这个函数的复杂度是什么?fib_memo
针对从 0 到 n
的每个值调用 fib
恰好一次。因此,在假设字典查找可以在常数时间内完成的前提下,fib_memo(n)
的时间复杂度为 O(n)
。而 fib_tab
的复杂度更明显也是 O(n)
。
如果解决原始问题需要解决所有子问题,通常使用表格方法更好。它更简单,速度更快,因为它没有递归调用的开销,并且可以预先分配适当大小的表,而不是动态增长备忘录。如果仅需要解决某些子问题(这通常是情况),记忆化通常更高效。
手指练习:使用表格方法实现一个符合规范的动态规划解决方案
def make_change(coin_vals, change):
"""coin_vals is a list of positive ints and coin_vals[0] = 1
change is a positive int,
return the minimum number of coins needed to have a set of
coins the values of which sum to change. Coins may be used
more than once. For example, make_change([1, 5, 8], 11)
should return 3."""
15.2 动态规划与 0/1 背包问题
我们在第十四章中研究的优化问题之一是 0/1 背包问题。回想一下,我们研究了一种在 n log n
时间内运行的贪心算法,但不能保证找到最优解。我们还研究了一种保证找到最优解的暴力算法,但其运行时间为指数级。最后,我们讨论了这个问题在输入规模上本质上是指数级的。在最坏的情况下,如果不查看所有可能的答案,就无法找到最优解。
幸运的是,情况并不像看起来那么糟。动态规划提供了一种在合理时间内解决大多数 0/1 背包问题的实用方法。作为推导此类解决方案的第一步,我们从基于穷举枚举的指数解决方案开始。关键思想是通过构建一个根节点二叉树来探索满足重量约束的所有可能解的空间。
根节点二叉树是一个无环有向图,其中。
恰好有一个没有父节点的节点。这被称为根。
每个非根节点恰好有一个父节点。
每个节点最多有两个子节点。没有子节点的节点称为叶子。
0/1 背包问题的搜索树中的每个节点都带有一个四元组,表示背包问题的部分解。四元组的元素包括:
一组要取的物品。
尚未做出决策的物品列表。
要取的物品集合的总价值(这仅仅是一个优化,因为价值可以从集合中计算得出)。
背包中剩余的空间。(同样,这是一个优化,因为它仅仅是允许的重量与迄今为止所有物品重量的差值。)
树是自上而下构建的,从根节点开始。⁹⁵ 从待考虑的物品中选择一个元素。如果该物品可以放入背包中,就构建一个节点,反映选择这个物品的后果。根据约定,我们将该节点绘制为左子节点。右子节点显示选择不带这个物品的后果。然后递归应用此过程,直到背包满或没有更多物品可考虑。因为每条边代表一个决策(选择带入或不带入一个物品),这样的树被称为决策树。⁹⁶
图 15-3 是描述一组物品的表。
图 15-3 物品的价值和重量表。
图 15-4 是一个决策树,用于决定在假设背包最大重量为5
的情况下应选择哪些物品。树的根节点(节点 0)标记为<{}, [a,b,c,d], 0, 5>
,表示没有物品被选中,所有物品仍需考虑,已选物品的价值为0
,并且仍有5
的重量可用。节点1
表示物品a
已被选中,剩余需考虑的物品为[b,c,d]
,已选物品的价值为6
,而背包还可以容纳2
磅。节点1
没有左子节点,因为重量为3
磅的物品b
无法放入背包。
在图 15-4 中,每个节点前的冒号之前的数字表示节点生成的一种顺序。这种特定的顺序称为左优先深度优先。在每个节点,我们尝试生成左节点。如果不可能,则尝试生成右节点。如果也不可能,则向上回退一个节点(到父节点)并重复该过程。最终,我们生成了根节点的所有子孙,过程停止。当过程结束时,所有可以放入背包的物品组合都已生成,任何具有最大价值的叶节点都代表了一个最佳解。注意,对于每个叶节点,第二个元素要么是空列表(表示没有更多物品需要考虑),要么第四个元素为 0(表示背包没有剩余空间)。
图 15-4 背包问题的决策树
毫不奇怪(特别是如果你阅读了第十四章),深度优先树搜索的自然实现是递归的。图 15-5 包含这样的实现。它使用Item
类及在图 14-2 中定义的函数。
函数max_val
返回两个值,所选物品的集合及这些物品的总价值。它被调用时有两个参数,分别对应树中节点标签的第二个和第四个元素:
to_consider
。那些在树中较高节点(对应于递归调用栈中的早期调用)尚未考虑的物品。avail
。仍然可用的空间量。
注意max_val
的实现并不是构建决策树再寻找最佳节点。相反,它使用局部变量result
记录到目前为止找到的最佳解。可以使用图 15-6 中的代码来测试max_val
。
当运行small_test
(使用图 15-3 中的值)时,它打印出一个结果,表明图 15-4 中的节点8
是一个最佳解:
<c, 8, 2>
<b, 7, 3>
Total value of items taken = 15
图 15-5 使用决策树解决背包问题
函数build_many_items
和big_test
可用于在随机生成的项目集上测试max_val
。尝试big_test(10, 40)
。这并没有花费太长时间。现在尝试big_test(40, 100)
。当你等得不耐烦时,停止计算,问问自己发生了什么。
让我们思考一下我们正在探索的树的大小。由于在树的每一层我们都在决定保留或不保留一个项目,因此树的最大深度为len(items)
。在层级0
时,我们只有一个节点;在层级1
时最多有两个节点;在层级2
时最多有四个节点;在层级3
时最多有八个节点。在层级39
时,最多有 2³⁹个节点。难怪运行起来需要很长时间!
让我们看看动态规划是否能提供帮助。
在图 15-4 和图 15-5 中都可以看到最优子结构。每个父节点结合其子节点达到的解决方案,以推导出该父节点根树的最优解决方案。这在图 15-5 中通过注释#Choose better branch
后的代码得以体现。
图 15-6 测试基于决策树的实现
是否也存在重叠子问题?乍一看,答案似乎是“没有”。在树的每一层,我们都有不同的可用项目集可供考虑。这意味着,如果确实存在公共子问题,它们必须在树的同一层级。实际上,在树的每一层中,每个节点都有相同的项目集可供考虑。然而,通过查看图 15-4 中的标签,我们可以看到,每个层级的节点表示关于更高层树中所考虑项目的不同选择集。
思考每个节点正在解决的问题:在给定剩余可用重量的情况下,从剩下的可考虑项目中找到最佳项目。可用重量依赖于到目前为止所取项目的总重量,但与所取项目或所取项目的总价值无关。因此,例如,在图 15-4 中,节点 2 和 7 实际上在解决同一个问题:在可用重量为2
的情况下,决定应该取[c,d]中的哪些元素。
图 15-7 中的代码利用了最优子结构和重叠子问题,提供了一种基于记忆化的动态规划解决方案,来解决 0/1 背包问题。
图 15-7 背包问题的动态规划解决方案
添加了一个额外的参数memo
来跟踪已经解决的子问题的解。它是使用一个字典实现的,键由to_consider
的长度和可用重量构成。表达式len(to_consider)
是一种简洁的方式来表示仍需考虑的物品。这个方法有效,因为物品总是从列表to_consider
的同一端(前端)移除。
现在,将对max_val
的调用替换为对fast_max_val
的调用,并尝试运行big_test(40, 100)
。它几乎瞬间返回问题的最优解。
图 15-8 显示了当我们在具有不同物品数量和最大重量为 100 的情况下运行代码时的调用次数。增长量很难量化,但显然远低于指数级。⁹⁷但这怎么可能呢?因为我们知道 0/1 背包问题在物品数量上本质上是指数级的?我们是否找到了一种推翻宇宙基本法则的方法?不,但我们发现计算复杂性可能是一个微妙的概念。⁹⁸
图 15-8 动态编程解决方案的性能
fast_max_val
的运行时间由生成的不同对<to_consider, avail>
的数量决定。这是因为关于下一步该做什么的决定仅依赖于仍然可用的物品和已选物品的总重量。
to_consider
的可能值数量受len(items)
的限制。avail
的可能值更难以界定。它的上限是背包可以容纳的项目重量总和的最大不同值。如果背包最多可以容纳n
个物品(根据背包的容量和可用物品的重量),则avail
最多可以取2
^n 个不同值。从原则上讲,这可能是一个相当大的数字。然而,实际上通常并不是如此。即使背包的容量很大,如果物品的重量是从合理小的重量集合中选择的,许多物品的组合将具有相同的总重量,从而大大减少运行时间。
这个算法属于一个被称为伪多项式的复杂度类。对此概念的仔细解释超出了本书的范围。粗略来说,fast_max_val
在表示avail
可能值所需的位数上是指数级的。
要查看当avail
的值从一个相当大空间中选择时会发生什么,请在图 15-6 中的big_test
函数里将对max_val
的调用更改为
val, taken = fast_max_val(items, 1000)
现在,当物品数量为1024
时,找到解决方案需要1,028,403
次对fast_max_val
的调用。
为了观察在选取来自一个巨大空间的权重时会发生什么,我们可以从正实数中选择可能的权重,而不是从正整数中选择。为此,替换该行,
items.append(Item(str(i),
random.randint(1, max_val),
random.randint(1, max_weight)))
在build_many_items
中,由于这一行
items.append(Item(str(i),
random.randint(1, max_val),
random.randint(1, max_weight)*random.random()))
每次调用时,random.random()
返回一个介于0.0
和1.0
之间的随机浮点数,因此,在所有实用意义上,可能的权重数量是无限的。不要指望等待这个最后的测试完成。动态规划在一般意义上可能是一种奇迹技术,⁹⁹,但在礼仪意义上并不能创造奇迹。
15.3 动态规划与分治法
与分治算法类似,动态规划基于解决独立的子问题,然后将这些解决方案组合起来。然而,仍然存在一些重要的区别。
分治算法基于寻找比原始问题小得多的子问题。例如,归并排序通过在每一步将问题规模减半来工作。相对而言,动态规划涉及解决仅比原始问题稍小的问题。例如,计算第十九个斐波那契数并不是一个比计算第二十个斐波那契数小得多的问题。
另一个重要的区别是,分治算法的效率并不依赖于将算法结构化以便重复解决相同的问题。相比之下,动态规划仅在不同子问题的数量显著少于总子问题数量时才高效。
15.4 章节中介绍的术语
动态规划
最优子结构
重叠子问题
备忘录法
表格法
有根二叉树
根
叶子
决策树
伪多项式复杂度
第十六章:随机游走与数据可视化
本书讲述的是如何使用计算来解决问题。到目前为止,我们关注的问题是可以通过确定性程序解决的问题。程序是确定性的,如果它在相同输入下运行时,产生相同输出。这种计算非常有用,但显然不足以应对某些类型的问题。我们生活的世界的许多方面只能准确地建模为随机过程。¹⁰⁰ 如果一个过程的下一个状态可能依赖于某个随机因素,那么它就是随机的。随机过程的结果通常是不确定的。因此,我们很少能对随机过程会做什么作出明确的陈述。相反,我们会对它们可能做什么作出概率性陈述。本书的其余部分主要讨论构建帮助理解不确定情况的程序。这些程序中的许多将是模拟模型。
模拟模仿真实系统的活动。例如,图 10-11 中的代码模拟了一个人进行一系列按揭付款。可以将这段代码视为一种实验设备,称为模拟 模型,它提供有关所建模系统可能行为的有用信息。除此之外,模拟通常用于预测物理系统的未来状态(例如,50
年后地球的温度),并代替那些进行成本过高、耗时或危险的真实世界实验(例如,税法变化的影响)。
重要的是要始终记住,模拟模型和所有模型一样,仅仅是对现实的*似。我们永远无法确定实际系统是否会按照模型预测的方式表现。事实上,我们通常相当自信地认为实际系统不会完全按模型预测的方式表现。例如,并不是每个借款人都会按时支付所有的按揭款。常言道:“所有模型都是错误的,但有些是有用的。” ¹⁰¹
16.1 随机游走
1827 年,苏格兰植物学家罗伯特·布朗观察到悬浮在水中的花粉颗粒似乎在随机漂浮。他对后来的布朗运动没有合理的解释,也没有试图从数学上进行建模。¹⁰² 1900 年,路易斯·巴歇利耶在其博士论文投机理论中首次提出了这一现象的清晰数学模型。然而,由于该论文涉及当时不光彩的金融市场理解问题,因此被主流学术界大多忽视。五年后,年轻的阿尔伯特·爱因斯坦将这种随机思维带入物理学界,提出了几乎与巴歇利耶的模型相同的数学模型,并描述了如何利用该模型确认原子的存在。¹⁰³ 不知为何,人们似乎认为理解物理学比赚钱更重要,因此世界开始关注这方面。那时的时代确实不同。
布朗运动是随机漫步的一个例子。随机漫步广泛用于模拟物理过程(例如扩散)、生物过程(例如 DNA 对异源双链 RNA 位移的动力学)和社会过程(例如股市的运动)。
在本章中,我们出于三个原因研究随机漫步:
随机漫步本身具有内在趣味性,且应用广泛。
它们为我们提供了一个良好的示例,说明如何使用抽象数据类型和继承来构建一般程序,尤其是模拟模型。
这些提供了引入 Python 更多特性和展示一些额外绘图技巧的机会。
16.2 醉汉的漫步
让我们来看一个实际上涉及走动的随机漫步。一个醉汉农民站在田地中央,每秒钟随机朝一个方向迈出一步。她(或他)在1000
秒后与原点的预期距离是多少?如果她走了很多步,是否更可能离原点越来越远,还是更可能一次又一次地徘徊回到原点,最终不远离起点?让我们写一个模拟来找出答案。
在开始设计程序之前,尝试对程序所要模拟的情况发展一些直觉总是个好主意。让我们先使用笛卡尔坐标系勾勒出一个简单的模型。假设农民正站在一片田地中,草神秘地被修剪成类似于方格纸的形状。进一步假设,农民每走一步的长度为一,并且与 x 轴或 y 轴*行。
图 16-1 左侧的图片描绘了一个站在田野中间的农夫¹⁰⁴。微笑脸表示农夫在一步之后可能到达的所有地方。请注意,在一步之后,她始终离起始点恰好一单位。假设她在第一步时朝东从她的初始位置游荡。她在第二步之后可能离她的初始位置有多远?
图 16-1 一个不寻常的农夫
看着右侧图片中的微笑脸,我们看到她以0.25
的概率会离原点0
单位,以0.25
的概率会离原点2
单位,以0.5
的概率会离原点单位。¹⁰⁵因此,*均而言,经过两步后她会比经过一步后离原点更远。第三步呢?如果第二步是到顶部或底部的微笑脸,第三步将使农夫在一半的情况下更靠*原点,而在另一半的情况下更远离。如果第二步是到左侧的微笑脸(原点),第三步将远离原点。如果第二步是到右侧的微笑脸,第三步在四分之一的时间内将更靠*原点,而在四分之三的时间内则会更远离原点。
看起来醉汉走的步数越多,预期离原点的距离就越大。我们可以继续这种可能性的详尽枚举,也许可以很好地直观理解这种距离是如何随着步数的增加而增长的。然而,这变得有些繁琐,因此写一个程序来为我们完成这项工作似乎是个更好的主意。
让我们通过思考一些可能在构建此模拟以及其他类型随机行走的模拟中有用的数据抽象来开始设计过程。像往常一样,我们应该尝试发明与我们试图建模的情况中出现的事物类型相对应的类型。三个显而易见的类型是Location
、Field
和Drunk
。在查看提供这些类型的类时,考虑它们可能对我们能够构建的模拟模型暗示的内容是很有价值的。
让我们从Location
开始,图 16-2。这是一个简单的类,但它体现了两个重要的决策。它告诉我们模拟将最多涉及两个维度。这与上面的图片是一致的。此外,由于提供给delta_x
和delta_y
的值可以是浮点数而不是整数,因此这个类没有关于醉汉可能移动的方向集合的内置假设。这是对非正式模型的一个概括,其中每一步的长度为一,并且与 x 轴或 y 轴*行。
类Field
,图 16-2,也相当简单,但它同样体现了一些显著的决策。它只是维护了醉汉与位置的映射。它对位置没有任何限制,因此可以推测Field
是无限大小的。它允许多个醉汉随机添加到Field
中的任意位置。它没有对醉汉的移动模式做出任何说明,也不禁止多个醉汉占据同一位置或穿过其他醉汉占据的空间。
图 16-2 Location
和Field
类
图 16-3 中的Drunk
和Usual_drunk
类定义了醉汉在场地上漫游的方式。特别是,Usual_drunk
中step_choices
的值引入了每一步的长度为一且*行于 x 轴或 y 轴的限制。由于函数random.choice
返回传入序列中随机选择的成员,因此每种步伐的可能性是相等的,并且不受之前步伐的影响。稍后我们将查看具有不同行为的Drunk
子类。
图 16-3 定义醉汉的类
下一步是使用这些类构建一个模拟,以回答最初的问题。图 16-4 包含用于此模拟的三个函数。
图 16-4 醉汉的行走(带有错误)
函数walk
模拟一次num_steps
步的行走。函数sim_walks
调用walk
模拟num_trials
次每次num_steps
步的行走。函数drunk_test
调用sim_walks
以模拟不同长度的行走。
sim_walks
的参数d_class
是class
类型,并在代码的第一行用于创建适当子类的Drunk
。稍后,当drunk.take_step
从Field.move_drunk
调用时,将自动选择适当子类的方法。
函数drunk_test
还有一个参数d_class
,类型为class
。它被使用了两次,一次在调用sim_walks
时,另一次在第一个print
语句中。在print
语句中,内置的class
属性__name__
用于获取类名的字符串。
当我们执行drunk_test((10, 100, 1000, 10000), 100, Usual_drunk)
时,它打印了
Usual_drunk walk of 10 steps: Mean = 8.634, Max = 21.6, Min = 1.4
Usual_drunk walk of 100 steps: Mean = 8.57, Max = 22.0, Min = 0.0
Usual_drunk walk of 1000 steps: Mean = 9.206, Max = 21.6, Min = 1.4
Usual_drunk walk of 10000 steps: Mean = 8.727, Max = 23.5, Min = 1.4
这令人惊讶,因为我们之前形成的直觉是,*均距离应该随着步数的增加而增加。这可能意味着我们的直觉是错误的,或者可能意味着我们的模拟有问题,或者两者都有。
此时的第一步是对我们已经认为知道答案的值运行模拟,确保模拟产生的结果与预期结果匹配。我们来试试零步数的步行(对于它,离原点的*均、最小和最大距离都应该是0
)和一步的步行(对于它,离原点的*均、最小和最大距离都应该是1
)。
当我们运行drunk_test((0,1), 100, Usual_drunk)
时,得到了高度可疑的结果
Usual_drunk walk of 0 steps: Mean = 8.634, Max = 21.6, Min = 1.4
Usual_drunk walk of 1 steps: Mean = 8.57, Max = 22.0, Min = 0.0
零步数的步行的*均距离怎么可能超过8
?我们的模拟中肯定有至少一个错误。经过一些调查,问题变得清楚。在sim_walks
中,函数调用walk(f, Homer, num_trials)
应该是walk(f, Homer, num_steps)
。
这里的道德非常重要:在查看模拟结果时,始终保持一些怀疑态度。首先要问结果是否合理(即,看起来可信)。并始终对你对结果有强烈直觉的参数进行烟雾测试¹⁰⁶。
当纠正后的模拟在我们的两个简单案例中运行时,得到了完全预期的结果:
Usual_drunk walk of 0 steps: Mean = 0.0, Max = 0.0, Min = 0.0
Usual_drunk walk of 1 steps: Mean = 1.0, Max = 1.0, Min = 1.0
当在更长的步行中运行时,它打印出
Usual_drunk walk of 10 steps: Mean = 2.863, Max = 7.2, Min = 0.0
Usual_drunk walk of 100 steps: Mean = 8.296, Max = 21.6, Min = 1.4
Usual_drunk walk of 1000 steps: Mean = 27.297, Max = 66.3, Min = 4.2
Usual_drunk walk of 10000 steps: Mean = 89.241, Max = 226.5, Min = 10.0
正如预期的那样,离原点的*均距离随着步数的增加而增长。
现在让我们看看从原点的*均距离的图表,图 16-5。为了展示距离增长的速度,我们在图上绘制了一条显示步数*方根的线(并将步数增加到100,000
)。
图 16-5 从起点到已走步数的距离
指尖练习: 编写代码生成图 16-5 中的图表。
这个图表是否提供了关于醉汉最终可能位置的任何信息?它确实告诉我们,*均而言,醉汉会在一个以原点为中心,半径等于预期距离的圆上。然而,它对于我们在任何特定步行结束时可能找到醉汉的具体位置几乎没有提供信息。我们将在下一节回到这个话题。
16.3 偏置随机步行
现在我们有了一个有效的模拟,可以开始修改它来研究其他类型的随机步行。例如,假设我们想考虑一个厌恶寒冷的北半球醉汉农夫的行为,即使在醉酒状态下,他在朝南方向移动时速度也会快一倍。或者可能是一个光向性醉汉,总是朝着太阳移动(早上向东,下午向西)。这些都是偏置随机步行的例子。步行仍然是随机的,但结果中存在偏差。
图 16-6 定义了两个额外的Drunk
子类。在每种情况下,专业化涉及为step_choices
选择合适的值。函数sim_all
迭代一系列Drunk
子类,以生成有关每种类型行为的信息。
图 16-6 Drunk
基类的子类
当我们运行时
`sim_all((Usual_drunk, Cold_drunk, EW_drunk), (100, 1000), 10)`
它打印了
Usual_drunk walk of 100 steps: Mean = 9.64, Max = 17.2, Min = 4.2
Usual_drunk walk of 1000 steps: Mean = 22.37, Max = 45.5, Min = 4.5
Cold_drunk walk of 100 steps: Mean = 27.96, Max = 51.2, Min = 4.1
Cold_drunk walk of 1000 steps: Mean = 259.49, Max = 320.7, Min = 215.1
EW_drunk walk of 100 steps: Mean = 7.8, Max = 16.0, Min = 0.0
EW_drunk walk of 1000 steps: Mean = 20.2, Max = 48.0, Min = 4.0
看起来我们的热寻求醉汉比其他两种醉汉更快地远离原点。然而,消化这段输出中的所有信息并不容易。是时候远离文本输出,开始使用图表了。
由于我们在同一个图表上展示不同类型的醉汉,我们将为每种醉汉关联一种独特的样式,以便于区分。样式将具有三个方面:
线条和标记的颜色
标记的形状
线条的类型,例如,实线或虚线。
类style_iterator
,图 16-7 通过传递给style_iterator.__init__
的参数旋转一系列样式。
图 16-7 迭代样式
图 16-8 中的代码在结构上与图 16-4 中的代码相似。
图 16-8 绘制不同醉汉的步态
sim_drunk
和sim_all_plot
中的print
语句对仿真的结果没有贡献。它们的存在是因为这个仿真可能需要较长时间完成,偶尔打印一条指示进展的消息可以让用户感到安心,避免他们怀疑程序是否真的在运行。
图 16-9 中的图是通过执行生成的。
sim_all_plot((Usual_drunk, Cold_drunk, EW_drunk),
(10, 100, 1000, 10000, 100000), 100)
图 16-9 不同类型醉汉的*均距离
普通醉汉和光向性醉汉(EW_drunk
)似乎以大约相同的速度远离原点,但热寻求醉汉(Cold_drunk
)似乎远离的速度要快几个数量级。这很有趣,因为*均而言,他只比其他人快25%
(他每四步*均走五步)。
让我们构建一个不同的图表,以帮助我们更深入地了解这三个类的行为。代码在图 16-10 中绘制了单步数的最终位置分布,而不是随着步数增加绘制距离随时间的变化。
图 16-10 绘制最终位置
plot_locs
的第一个操作是创建一个style_iterator
实例,该实例有三种样式的标记。然后它使用plt.plot
在每次试验结束时对应的位置放置标记。对plt.plot
的调用设置了标记的颜色和形状,使用了style_iterator
返回的值。
调用plot_locs((Usual_drunk, Cold_drunk, EW_drunk), 100, 200)
生成了图 16-11 中的图。
图 16-11 醉汉停下的地方
首先要说的是,我们的醉汉似乎表现如预期。EW_drunk
最终位于 x 轴上,Cold_drunk
似乎向南推进,而Usual_drunk
则显得漫无目的。
但为什么圆形标记似乎比三角形或+标记少得多呢?因为许多EW_drunk
的行走最终到达了同一个地方。这并不令人惊讶,考虑到EW_drunk
可能的终点数量只有200
。而且,圆形标记在 x 轴上似乎分布相对均匀。
至少对我们来说,Cold_drunk
为什么在*均情况下能比其他类型的醉汉走得更远,这一点仍然不是显而易见的。也许是时候关注一条单一行走的路径,而不是许多行走的终点。 图 16-12 中的代码生成了图 16-13 中的图。
图 16-12 行走轨迹追踪
图 16-13 行走轨迹
由于行走长度为200
步,而EW_drunk
的行走访问了不到30
个不同的位置,因此显然他花费了很多时间在回溯自己的步骤上。对于Usual_drunk
也是如此。相比之下,虽然Cold_drunk
并不是直奔佛罗里达,但他相对花费更少的时间去访问他已经去过的地方。
这些模拟本身并没有什么有趣之处。(在第十八章中,我们将关注更内在有趣的模拟。)但有几点值得注意:
起初,我们将模拟代码分为四个独立的部分。其中三个是类(
Location
、Field
和Drunk
),对应于问题非正式描述中出现的抽象数据类型。第四部分是一组函数,这些函数使用这些类执行简单的模拟。我们随后将
Drunk
详细划分为一个类的层次结构,以便观察不同类型的偏向随机行走。Location
和Field
的代码保持不变,但模拟代码被更改为迭代Drunk
的不同子类。在此过程中,我们利用了类本身是一个对象,因此可以作为参数传递的事实。最后,我们对模拟进行了一系列渐进的更改,而没有涉及表示抽象类型的类的任何变化。这些更改主要涉及引入旨在提供对不同游走的洞察的图表。这是模拟开发的典型方式。首先让基本模拟工作,然后再开始添加功能。
16.4 危险场
你是否玩过在美国被称为滑梯与梯子、在英国被称为蛇与梯子的桌面游戏?这个儿童游戏起源于印度(可能是在公元前二世纪),当时称为Moksha-patamu。落在代表美德(例如,慷慨)的方块上,会让玩家爬上梯子,进入更高的生活层次。落在代表邪恶(例如,欲望)的方块上,则会将玩家送回更低的生活层次。
我们可以通过创建一个带有虫洞的Field
轻松地为我们的随机游走添加这种特性,¹⁰⁷,如图 16-14 所示,并将函数trace_walk
中的第二行代码替换为
`f = Odd_field(1000, 100, 200)`
在Odd_field
中,走入虫洞位置的醉汉会被传送到虫洞另一端的位置。
图 16-14 具有奇怪属性的场
当我们运行trace_walk((Usual_drunk, Cold_drunk, EW_drunk), 500)
时,得到了图 16-15 中相当奇怪的图。
图 16-15 奇怪的游走
显然,更改场的属性产生了显著效果。然而,这并不是本示例的重点。主要观点是:
由于我们代码的结构方式,适应被建模情况的重大变化变得容易。正如我们可以添加不同类型的醉汉而不触动
Field
一样,我们也可以添加一种新的Field
而不触动Drunk
或其任何子类。(如果我们足够有远见,将场作为trace_walk
的参数,我们就不必更改trace_walk
了。)尽管可以从分析上推导出简单随机游走甚至有偏随机游走的预期行为的不同信息,但一旦引入虫洞,这就变得很具挑战性。然而,改变模拟以适应新情况却极为简单。模拟模型通常相对于分析模型享有这一优势。
16.5 在章节中引入的术语确定性程序
随机过程
模拟模型
随机游走
烟雾测试
有偏随机游走
对数尺度
第十七章:随机程序、概率和分布
纽顿力学给人一种安慰。你在杠杆的一端施加压力,另一端就会上升。你把球抛向空中;它沿着抛物线轨迹移动,最终落下。简而言之,一切都是有原因的。物理世界是一个完全可预测的地方——所有物理系统的未来状态都可以从对其当前状态的知识中推导出来。
几个世纪以来,这一直是主流科学智慧;然后量子力学和哥本哈根公理出现了。以波尔和海森堡为首的公理支持者们争辩说,从最根本的层面来看,物理世界的行为是无法预测的。可以做出“x 很可能发生”的概率性陈述,但不能做出“x 必然发生”的陈述。其他著名物理学家,尤其是爱因斯坦和薛定谔, vehemently disagreed.
这场辩论搅动了物理学、哲学甚至宗教的世界。辩论的核心是因果非确定性的有效性,即并非每个事件都是由之前的事件引起的信念。爱因斯坦和薛定谔发现这种观点在哲学上不可接受,正如爱因斯坦经常重复的那句话,“上帝不会掷骰子。”他们所能接受的是预测非确定性,即我们的测量能力有限使得无法对未来状态做出准确预测。这一区别被爱因斯坦很好地总结,他说:“当代理论的本质统计特征完全归因于这一理论以不完整的物理系统描述为基础。”
因果非确定性的问题仍然没有解决。然而,我们无法预测事件的原因是因为它们真正不可预测,还是因为我们仅仅没有足够的信息去预测它们,这在实践中并无重要意义。
当波尔/爱因斯坦的辩论涉及如何理解物理世界的最低层次时,相同的问题也出现在宏观层面。或许马赛的结果、轮盘赌的旋转以及股票市场的投资是因果确定的。然而,有充足的证据表明,将它们视为可预测的确定性是危险的。
17.1 随机程序
如果一个程序是确定性的,那么每当它在相同输入上运行时,它产生相同的输出。注意,这与说输出完全由问题的规格定义并不相同。考虑,例如,square_root
的规格:
`def square_root(x, epsilon): """Assumes x and epsilon are of type float; x >= 0 and epsilon > 0 Returns float y such that x-epsilon <= y*y <= x+epsilon"""`
这个规格为函数调用square_root(2, 0.001)
允许了许多可能的返回值。然而,我们在第三章看到的连续逼*算法将始终返回相同的值。该规格并不要求实现是确定性的,但它确实允许确定性实现。
不是所有有趣的规格都可以通过确定性实现来满足。例如,考虑实现一个骰子游戏的程序,比如西洋双陆棋或掷骰子。程序中会有一个函数来模拟一个公*的六面骰子的掷骰子。¹⁰⁹假设它的规格是这样的。
`def roll_die(): """Returns an int between 1 and 6"""`
这将是个问题,因为它允许实现每次调用时返回相同的数字,这会使游戏变得相当无聊。更好的方法是指定roll_die
“返回一个在 1 和 6 之间随机选择的整数
”,这样就要求实现是随机的。
大多数编程语言,包括 Python,都提供了简单的方法来编写随机程序,即利用随机性的程序。图 17-1 中的小程序是一个模拟模型。我们不是让某个人多次掷骰子,而是写了一个程序来模拟这个活动。代码使用了从导入的 Python 标准库模块random
中找到的几个有用函数之一。
import random
import numpy as np
import matplotlib.pyplot as plt
import scipy.integrate
所有这些都已执行。
函数random.choice
以一个非空序列作为参数,并返回该序列中随机选择的成员。几乎所有random
中的函数都是使用函数random.random
构建的,该函数生成一个在0.0
和1.0
之间的随机浮点数。¹¹⁰
图 17-1 掷骰子
现在,想象一下运行roll_n(10)
。你会对打印出1111111111
还是5442462412
感到更惊讶?换句话说,这两个序列哪个更随机?这是一个技巧性问题。这两个序列发生的可能性是一样的,因为每次掷骰子的值与之前的掷骰子值是独立的。在随机过程中的两个事件是独立的,如果一个事件的结果对另一个事件的结果没有影响。
如果我们通过考虑一个两面的骰子(也称为硬币),其值为 0 和 1 来简化情况,这样更容易理解。这样我们可以将roll_n
的输出视为一个二进制数字。当我们使用二进制骰子时,roll_n
可能返回的序列有2
^n 种可能性。这些序列的可能性是一样的,因此每个序列发生的概率为(1/2)
^n。
让我们回到六面骰子。长度为10
的不同序列有多少种?6
¹⁰。所以,连续掷到 10 个1
的概率是1/6
¹⁰。少于六千万分之一。相当低,但不低于任何其他序列的概率,例如5442462412
。
17.2 计算简单概率
一般来说,当我们谈论某个结果具有某种属性的概率(例如,都是1
)时,我们是在询问所有可能结果中有多少比例具有该属性。这就是为什么概率的范围是从 0 到 1。假设我们想知道掷骰子时得到的序列不是全部1
的概率。它只是1 – (1/6
¹⁰)
,因为某件事发生的概率和同一事件不发生的概率之和必须为1
。
假设我们想知道在 10 次掷骰子中没有掷到一个1
的概率。回答这个问题的一种方法是将其转化为6¹⁰
个可能序列中有多少个不包含1
。这可以通过以下方式计算:
1. 在任何一次掷骰子中,不掷到
1
的概率是5/6
。2. 在第一次或第二次掷骰子时,不掷到
1
的概率是(5/6)
*(5/6)
,或(5/6)
²。3. 所以,不连续掷到
1
10 次的概率是(5/6)
¹⁰,略高于0.16
。
第 2 步是对独立概率的乘法法则的应用。例如,考虑两个独立事件A
和B
。如果A
发生的概率为1/3
,而B
发生的概率为1/4
,那么A
和B
同时发生的概率为1/4 of 1/3
,即(1/3)/4
或(1/3)*(1/4)
。
至于掷到至少一个1
的概率呢?它就是 1 减去不掷到至少一个1
的概率,即1
- (5/6)
¹⁰。注意,不能简单地说任何一次掷骰子的1
的概率是1/6
,因此至少掷到一个1
的概率是10*(1/6)
,即10/6
。这显然是不正确的,因为概率不能大于1
。
那么在 10 次掷骰子中掷到恰好两个1
的概率是多少呢?这相当于问在6¹⁰
个整数中,有多少个整数在其基数6
表示中恰好有两个1
。我们可以轻松编写程序生成所有这些序列,并计算包含恰好一个1
的数量。用解析方法推导这个概率有点棘手,我们将其留到第 17.4.4 节讨论。
17.3 推论统计
正如你刚才看到的,我们可以使用系统化的过程基于已知的一个或多个简单事件的概率,推导出一些复杂事件的精确概率。例如,我们可以轻松计算投掷硬币得到 10 次连续正面的概率,前提是我们假设每次投掷是独立的,并且知道每次投掷出现正面的概率。然而,假设我们实际上并不知道相关的简单事件的概率。举例来说,假设我们不知道这枚硬币是否公*(即,正反面出现的概率相等)。
一切尚未失去。如果我们对硬币的行为有一些数据,我们可以将这些数据与我们的概率知识结合,以推导出真实概率的估计。我们可以使用推断统计来估计一次投掷出现正面的概率,然后用传统概率来计算这种行为的硬币连续出现 10 次正面的概率。
简而言之(因为这不是一本关于统计的书),推断统计的指导原则是,随机样本往往表现出与其来源总体相同的特性。
假设哈维·登特(也称为“双面人”)投掷了一枚硬币,结果是正面。你不会由此推断下一次投掷也会是正面。假设他投掷了两次,结果都是正面。你可能会推测,对于一枚公*的硬币,这种情况发生的概率是0.25
,因此仍然没有理由假设下一次投掷会是正面。然而,假设100
次投掷中都有100
次是正面。事件的概率(1/2)¹⁰⁰
(假设硬币是公*的)非常小,因此你可能会觉得推断这枚硬币的两面都是正面是安全的。
你对硬币是否公*的信念基于这样的直觉:单次100
次投掷的行为与所有100
次投掷样本的总体行为是相似的。当所有100
次投掷都是正面时,这种信念似乎是合适的。假设有52
次投掷是正面,48
次是反面。你是否会对预测下一次100
次投掷的正反面比例保持信心?更进一步,你会对预测下一次100
次投掷中正面会多于反面感到有把握吗?花几分钟思考一下这个问题,然后尝试进行实验。或者,如果你没有现成的硬币,可以使用图 17-2 中的代码来模拟投掷。
图 17-2 中的函数flip
模拟了一枚公*硬币投掷num_flips
次,并返回其中出现正面的比例。对于每次投掷,调用random.choice(('H', ‘T'))
随机返回'H'
或'T'
。
图 17-2 投掷硬币
尝试执行函数 flip_sim(10, 1)
几次。我们在前两次尝试 print('Mean =', flip_sim(10, 1))
时看到的结果如下:
Mean = 0.2
Mean = 0.6
从单次 10
次抛掷的试验中假设太多(除了硬币有正反两面)似乎不合适。这就是为什么我们通常将模拟结构化为包含多个试验并比较结果。让我们尝试 flip_sim(10, 100)
几次:
Mean = 0.5029999999999999
Mean = 0.496
你对这些结果感觉更好了吗?当我们尝试 flip_sim(100, 100000)
时,我们得到了
Mean = 0.5005000000000038
Mean = 0.5003139999999954
这看起来真的很好(尤其是我们知道答案应该是 0.5
——但这算作弊)。现在似乎我们可以安全地得出关于下一次抛掷的结论,即正反面大致同样可能。但我们为什么认为可以得出这样的结论呢?
我们所依赖的是 大数法则(也称为 伯努利定理¹¹¹)。该法则声明,在相同实际概率 p
的重复独立测试中(在这种情况下为抛掷),该结果的出现频率与 p
的差异概率随着试验次数趋向于无穷大而收敛为零。
值得注意的是,大数法则并不意味着,正如太多人所想,若出现与预期行为的偏差,这些偏差可能会被未来的相反偏差“抵消”。这种对大数法则的误用被称为 赌徒谬误。¹¹²
人们常常将赌徒谬误与回归均值混淆。回归均值¹¹³表示,在经历极端随机事件后,下一个随机事件可能会更不极端。如果你抛一枚公正的硬币六次并得到六个正面,回归均值暗示下一个六次抛掷的结果更接*三个正面的期望值。它并不意味着,正如赌徒谬误所暗示的,下一次抛掷的正面数量可能少于反面数量。
大多数事务的成功需要技能与运气的结合。技能成分决定了均值,而运气成分则解释了变异性。运气的随机性导致回归均值。
图 17-3 中的代码生成了一个图,图 17-4,说明了回归均值。函数 regress_to_mean
首先生成 num_trials
次试验,每次 num_flips
次抛硬币。然后,它识别出所有结果中正面比例低于 1/3
或高于 2/3
的试验,并将这些极端值绘制为圆圈。接着,对于每个这些点,它在与圆圈相同的列中绘制后续试验的值作为三角形。
0.5
处的水*线,即预期均值,是使用axhline
函数创建的。plt.xlim
函数控制 x 轴的范围。函数调用plt.xlim(xmin, xmax)
设置当前图形 x 轴的最小值和最大值。函数调用plt.xlim()
返回一个元组,包含当前图形 x 轴的最小值和最大值。plt.ylim
函数的工作方式相同。
图 17-3 回归到均值
注意到,在极端结果后,通常会跟随一个更接*均值的试验,但这并不总是发生——如框中的一对所示。手指练习: 安德烈在打高尔夫时每洞*均5
杆。一天,她完成前九洞用了40
杆。她的搭档推测她可能会回归均值,接下来用50
杆完成后九洞。你同意她的搭档吗? 图 17-4 均值回归的插图 图 17-5 包含一个函数flip_plot
,生成两个图, 图 17-6,旨在展示大数法则的应用。第一个图显示头和尾的数量差的绝对值如何随翻转次数变化。第二个图比较头与尾的比率和翻转次数。由于 x 轴上的数字较大,我们使用plt.xticks(rotation = ‘vertical')
来旋转它们。 图 17-5 掷硬币结果的绘图 random.seed(0)
在图底部确保random.random
使用的伪随机数生成器每次执行时生成相同的伪随机数序列。这在调试时很方便。函数random.seed
可以用任何数字调用。如果没有参数调用,则随机选择种子。 图 17-6 大数法则的应用 左边的图似乎表明,头和尾的绝对差在开始时波动,随后迅速下跌,然后迅速上升。然而,我们需要记住,x = 300,000
右侧只有两个数据点。plt.plot
用线连接这些点可能会误导我们看到趋势,而实际上只有孤立点。这不是不常见的现象,所以在对图的含义做出任何结论之前,总是要询问图实际包含多少个点。 在右侧的图中,很难看出任何内容,主要是*坦的线。这也是误导。尽管有 16 个数据点,但大多数都挤在图左侧的小区域中,因此细节难以看清。这是因为绘制的点的 x 值是2
⁴, 2
⁵, 2
⁶, …, 2
²⁰,因此 x 轴的值范围从
16到超过一百万,除非另行指示,
plot会根据这些点与原点的相对距离来放置它们。这被称为**线性缩放**。由于大多数点的 x 值相对于
2²⁰较小,它们看起来相对接*原点。 幸运的是,这些可视化问题在 Python 中很容易解决。正如我们在第十三章和本章早些时候看到的,我们可以轻松指示程序绘制不连接的点,例如,通过编写
plt.plot(xAxis, diffs, 'ko')。 图 17-7 中的两个图都在 x 轴上使用对数尺度。由于
flip_plot生成的 x 值为
2^(minExp),
2^(minExp+1), …,
2^(maxExp),使用对数 x 轴使得点沿 x 轴均匀分布——提供点之间的最大分离。 图 17-7 左侧的图在 y 轴上也使用对数尺度。该图的 y 值范围从接*
0到约
550。如果 y 轴是线性缩放,则很难看到图左端 y 值的相对小差异。另一方面,右侧的图 y 值相对较紧凑,因此我们使用线性 y 轴。![c17-fig-0007.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c17-fig-0007.jpg) 图 17-7 大数法则的应用 **手指练习:** 修改图 17-5 中的代码,使其生成如图 17-7 所示的图。 图 17-7 中的图比早期图更容易解释。右侧的图强烈暗示,随着翻转次数增大,头与尾的比率收敛于
1.0。左侧图的意义不太清晰。似乎绝对差随着翻转次数增长,但这并不完全令人信服。 通过抽样实现完美的准确性永远是不可能的,除非抽样整个种群。无论我们检查多少样本,只有在检查种群的每个元素后,才能确定样本集是否典型(而且由于我们通常处理的是无限种群,例如所有可能的掷硬币序列,这往往是不可能的)。当然,这并不是说估计不能是精确正确的。我们可能掷硬币两次,得到一头一尾,得出每个结果的真实概率是
0.5。我们会得出正确的结论,但我们的推理是错误的。 我们需要查看多少个样本,才能对我们计算的答案有合理的信心?这取决于**方差**在潜在分布中的情况。大致而言,方差是可能不同结果的扩散程度的度量。更正式地,值集合*X*的方差定义为:![c17-fig-5002.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c17-fig-5002.jpg) 其中|*X*|是集合的大小,*μ*(mu)是其均值。非正式地,方差描述有多少比例的值接*均值。如果许多值相对接*均值,方差相对较小。如果许多值相对远离均值,方差相对较大。如果所有值都相同,方差为零。 一组值的**标准差**是方差的*方根。虽然它包含与方差完全相同的信息,但标准差更易于解释,因为它与原始数据的单位相同。例如,“一个种群的*均身高是
70英寸,标准差是
4英寸”的说法比“一个种群的*均身高是
70英寸,方差是
16*方英寸”的说法更易于理解。图 17-8 包含方差和标准差的实现。¹¹⁵![c17-fig-0008.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c17-fig-0008.jpg) 图 17-8 方差和标准差 我们可以利用标准差的概念来思考我们查看的样本数量与我们在计算的答案中应有的信心程度之间的关系。图 17-10 包含一个修改版的
flip_plot。它使用辅助函数
run_trial运行每个掷硬币次数的多个试验,然后绘制
头/尾比率的均值和标准差。辅助函数
make_plot,图 17-9,包含生成图的代码。![c17-fig-0009.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c17-fig-0009.jpg) 图 17-9 掷硬币模拟的辅助函数 ![c17-fig-0010.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c17-fig-0010.jpg) 图 17-10 掷硬币模拟 让我们尝试
flip_plot1(4, 20, 20)。它生成图 17-11 中的图。![c17-fig-0011.jpg](https://github.com/OpenDocCN/geekdoc-ds-zh/raw/master/intr-comp-prog-py/img/c17-fig-0011.jpg) 图 17-11 头尾比率的收敛 这令人鼓舞。*均头/尾比率正在收敛于
1,标准差的对数与每次试验的翻转次数的对数线性下降。当我们进行大约
10⁶次掷硬币时,标准差(约
10^(-3))大约比均值(约
1)小三个数量级,因此我们可以相当有信心预期头/尾比率非常接*
1.0`。随着我们掷更多硬币,我们不仅得到更精确的答案,更重要的是,我们还有更多理由相信它
第十八章:蒙特卡罗模拟
在第十六章和第十七章中,我们探讨了在计算中使用随机性的不同方式。我们呈现的许多示例属于被称为蒙特卡罗模拟的计算类别。蒙特卡罗模拟是一种通过多次运行相同模拟并*均结果来*似事件概率的技术。
斯坦尼斯瓦夫·乌拉姆和尼古拉斯·梅特罗波利斯于 1949 年创造了“蒙特卡罗模拟”这一术语,以向摩纳哥公国赌场中的机会游戏致敬。乌拉姆以与爱德华·泰勒共同设计氢弹而闻名,他这样描述该方法的发明:
*我对[蒙特卡罗方法]的首次思考和尝试,是在 1946 年我在康复期间玩接龙时想到的一个问题。这个问题是,一个用 52 张牌摆成的坎菲尔德接龙成功的机会是多少?在花了大量时间试图通过纯组合计算来估算它们之后,我想知道是否有比“抽象思维”更实际的方法,比如说摆一百次并简单观察和计算成功的次数。随着新一代快速计算机的出现,这已经是可以想象的了,*¹²⁴ *我立即想到了中子扩散问题和其他数学物理问题,以及更一般地如何将由某些微分方程描述的过程转变为可解释为随机操作序列的等效形式。后来…… [在 1946 年,我] 向约翰·冯·诺依曼描述了这个想法,我们开始规划实际计算。*¹²⁵
该技术在曼哈顿计划期间被用来预测核裂变反应会发生什么,但直到 1950 年代计算机变得更加普及和强大时,它才真正起飞。
乌拉姆并不是第一个想到使用概率工具来理解机会游戏的数学家。概率的历史与赌丨博的历史密切相关。正是这种不确定性使得赌丨博成为可能。而赌丨博的存在促使了许多必要的数学的发展,以便更好地推理不确定性。卡尔达诺、帕斯卡尔、费马、伯努利、德摩根和拉普拉斯对概率理论基础的贡献,都是出于希望更好地理解(并可能从中获利)机会游戏的愿望。
18.1 帕斯卡尔问题
早期概率理论的大部分研究围绕着使用骰子的游戏进行。传闻,帕斯卡对后来被称为概率理论的领域的兴趣开始于一个朋友问他,在 24 次掷骰中掷出双6
是否会有利可图。这在十七世纪中期被视为一个棘手的问题。帕斯卡和费尔马这两位聪明人就如何解决这个问题交换了多封信件,但现在看来这是一个容易回答的问题:
在第一次掷骰中,每个骰子掷出
6
的概率为1/6
,因此两个骰子同时掷出6
的概率为1/36
。因此,第一次掷骰不掷出双
6
的概率为1 ‑ 1/36 =
35/36
。因此,连续 24 次掷骰不掷出
6
的概率为(35/36)
²⁴,约为0.51
,因此掷出双6
的概率为1 - (35/36)
²⁴,大约为0.49
。从长远来看,押注在 24 次掷骰中掷出双6
并不划算。
为了安全起见,我们写一个小程序,图 18-1,来模拟帕斯卡朋友的游戏,并确认我们得到的答案与帕斯卡相同。(本章中的所有代码都假设
import random
import numpy as np
在代码出现的文件开始时出现。当第一次运行时,调用check_pascal(1000000)
打印了结果。
`Probability of winning = 0.490761`
这确实非常接*1 - (35/36)
²⁴;在 Python shell 中输入1-(35.0/36.0)**24
产生0.49140387613090342
。
图 18-1 检查帕斯卡的分析
18.2 过线或不过线?
关于机会游戏的问题并非都那么容易回答。在掷骰子游戏中,掷骰者(投掷骰子的人)在“过线”或“不过线”投注之间选择。
过线:如果第一次掷骰结果是“自然数”(
7
或11
),则掷骰者获胜;如果结果是“掷骰”(2
、3
或12
),则掷骰者失败。如果掷出其他数字,该数字成为“点数”,掷骰者继续掷骰。如果掷骰者在掷出7
之前掷出点数,掷骰者获胜;否则掷骰者失败。不过线:如果第一次掷骰结果是
7
或11
,则掷骰者失败;如果结果是2
或3
,则掷骰者获胜;如果结果是12
,则*局(在赌丨博术语中称为“*推”)。如果掷出其他数字,该数字成为点数,掷骰者继续掷骰。如果掷骰者在掷出点数之前掷出7
,掷骰者获胜;否则掷骰者失败。
这两者中哪个更划算?哪一个算是一个好赌注吗?可以通过分析得出这些问题的答案,但对我们来说,编写一个模拟掷骰游戏的程序并看看发生了什么似乎更简单。图 18-2 包含了这种模拟的核心。
图 18-2 Craps_game
类
Craps_game
类的实例变量记录自游戏开始以来通过线和不通过线的表现。观察方法pass_results
和dp_results
返回这些值。方法play_hand
模拟一局游戏。一局的开始是当投掷者“出场”,这是在确定点数之前的投掷术语。一局在投掷者赢得或失去初始赌注时结束。play_hand
中的大部分代码仅是上述规则的算法描述。请注意,else
子句中有一个循环,对应于确定点数后发生的情况。当掷出七点或点数时,通过break
语句退出循环。
图 18-3 包含一个使用Craps_game
类模拟一系列掷骰子游戏的函数。
图 18-3 模拟掷骰子游戏
craps_sim
的结构是许多模拟程序的典型:
1. 它运行多个游戏(可以把每个游戏看作是我们之前模拟中的一次试验)并累积结果。每个游戏包括多局,因此有一个嵌套循环。
2. 然后它生成并存储每个游戏的统计数据。
3. 最后,它生成并输出汇总统计数据。在这种情况下,它打印每种投注线的预计投资回报率(ROI)和该 ROI 的标准差。
投资回报率由以下方程定义¹²⁷
由于通过线和不通过线支付的是等额奖金(如果你投注$1
并赢得,你获得$1
),因此投资回报率为
例如,如果你进行了100
次通过线的投注并赢得了一半,那么你的 ROI 将是
如果你在不通过线上投注 100 次,并赢得 25 次和 5 次*局,那么 ROI 将是
让我们运行掷骰子游戏模拟,看看尝试craps_sim(20, 10)
时会发生什么:¹²⁸
Pass: Mean ROI = -7.0% Std. Dev. = 23.6854%
Don't pass: Mean ROI = 4.0% Std Dev = 23.5372%
看起来避免通过线是个好主意——预计投资回报为7%
的损失。但不通过线似乎是一个不错的赌注。或者说并非如此?
从标准差来看,不通过线似乎并不是如此安全的赌注。回想一下,在假设分布是正态的情况下,95%
的置信区间由均值两侧的1.96
个标准差包围。对于不通过线,95%
的置信区间是[4.0–1.96
23.5372, 4.0+1.96
23.5372]
——大约为[-43%, +51%]
。这当然并不表明投注不通过线是万无一失的。
是时候将大数法则付诸实践;craps_sim(1000000, 10)
输出
Pass: Mean ROI = -1.4204% Std. Dev. = 0.0614%
Don't pass: Mean ROI = -1.3571% Std Dev = 0.0593%
我们现在可以相对安全地假设这两个选项都不是好的投注。¹²⁹ 看起来不通过线可能稍微好一些,但我们可能不应该依赖于此。如果通过线和不通过线的95%
置信区间没有重叠,假设两个均值之间的差异是统计显著的将是安全的。¹³⁰ 然而,它们确实重叠,所以不能安全得出结论。
假设我们不是增加每局的手数,而是增加游戏的局数,例如,通过调用craps_sim(20, 1000000)
:
Pass: Mean ROI = -1.4133% Std. Dev. = 22.3571%
Don't pass: Mean ROI = -1.3649% Std Dev = 22.0446%
标准差很高——这表明一局20
手的结果高度不确定。
模拟的一个好处是它们使得进行“如果”实验变得简单。例如,如果一名玩家能够悄悄地引入一对有利于5
而非2
的作弊骰子(5
和2
在骰子的对面)呢?要测试这个,只需将roll_die
的实现替换为类似的内容。
def roll_die():
return random.choice([1,1,2,3,3,4,4,5,5,5,6,6])
骰子上的这个相对较小的变化在赔率上产生了显著差异。运行craps_sim(1000000, 10)
得出的结果是
Pass: Mean ROI = 6.6867% Std. Dev. = 0.0993%
Don't pass: Mean ROI = -9.469% Std Dev = 0.1024%
难怪赌场费尽心思确保玩家不在游戏中引入自己的骰子!
手指练习:如果在掷出 6 之前掷出了 7,"大 6"的投注支付是*赔。假设每小时有 30 个$5 的投注,编写一个蒙特卡罗模拟来估算玩“大 6”投注的每小时成本和该成本的标准差。
18.3 使用表查找来提高性能
你可能不想在家尝试运行craps_sim(100000000, 10)
。在大多数计算机上完成这个任务需要很长时间。这引出了一个问题:是否有简单的方法来加快模拟速度。
函数craps_sim
的实现复杂度大约是θ(``play_hand``)
hands_per_game
num_games
。play_hand
的运行时间取决于循环执行的次数。从理论上讲,循环可以被执行无限次,因为掷出7
或点数所需的时间没有上限。但实际上,我们有充分理由相信它总会终止。
不过请注意,play_hand
的调用结果并不依赖于循环执行的次数,而只取决于达到的退出条件。对于每个可能的点,我们可以轻松计算在掷出7
之前掷出该点的概率。例如,使用一对骰子我们可以用三种方式掷出4
:<1, 3>, <3, 1>,
和 <2, 2>
。我们可以用六种方式掷出7
:<1, 6>, <6, 1>, <2, 5>, <5, 2>, <3, 4>
和 <4, 3>
。因此,通过掷出7
退出循环的可能性是通过掷出4
的两倍。
图 18-4 包含了一个利用这种思维的play_hand
实现。我们首先计算在掷出7
之前,针对每个可能的点值形成该点的概率,并将这些值存储在字典中。例如,假设点值为8
。投掷者会继续掷骰,直到掷出该点或掷出“掷坏”。掷出8
有五种方式(<6,2>, <2,6>, <5,3>, <3,5>, <4,4>)
,而掷出7
有六种方式。因此,字典键8
的值为表达式5/11
的值。拥有这个表允许我们将包含不受限制掷骰次数的内部循环替换为对一次random.random
调用的测试。这个版本的play_hand
的渐进复杂度为O(1)
。
用查表替代计算的思想具有广泛的适用性,通常在速度成为问题时使用。查表是以时间换空间这一一般思想的一个例子。正如我们在第十五章中看到的,这也是动态规划背后的关键思想。在我们对哈希分析中看到过这一技术的另一个例子:表越大,碰撞越少,*均查找速度越快。在这种情况下,表很小,所以空间成本微不足道。
图 18-4 使用查表来提高性能
18.4 寻找π
很容易看出,蒙特卡罗模拟在解决非确定性问题时是有用的。有趣的是,蒙特卡罗模拟(以及随机算法一般)也可以用来解决那些本质上并不是随机的问题,即没有结果不确定性的问题。
考虑π。数千年来,人们一直知道有一个常数(自 18 世纪以来称为π),使得圆的周长等于π
* 直径
,而圆的面积等于π * 半径
²。他们不知道的是这个常数的值。
最早的估算之一,4
(8/9)
² = 3.16
,可以在公元前 1650 年的埃及林德纸草书中找到。千年之后,旧约(列王纪上 7.23)在给出所罗门王某个建筑项目的规格时暗示了一个不同的π*值,
他造了一个铸造的海,从一边到另一边十肘:它周围是圆的,高五肘:周围有三十肘的线环绕着它。
解决π,10π = 30
,因此π = 3
。也许圣经只是错了,或者铸造的海并不完美圆形,或者周长是从墙外测量的而直径是从内部测量的,或者这只是诗意的许可。我们留给读者自己决定。
阿基米德(公元前 287-212 年)通过使用高阶多边形来*似圆形,从而得出了π的上下界。使用 96 边的多边形,他得出结论223/71 <
π < 22/7
。给出上下界在当时是一种相当复杂的方法。如果将他的两个界的*均值作为最佳估计,我们得到了3.1418
,误差约为0.0002
。不错!但大约 700 年后,中国数学家祖冲之使用一个有 24,576 个边的多边形得出结论3.1415962 <
π < 3.1415927\
。大约 800 年后,荷兰制图师阿德里安·安东尼兹(1527-1607)估算为355/113
,大约为 3.1415929203539825。这一估算对大多数实际用途来说已经足够好,但并未阻止数学家继续研究这个问题。
在计算机发明之前,法国数学家布丰(1707-1788)和拉普拉斯(1749-1827)提出使用随机模拟来估算π的值。¹³¹ 想象一下在一条边长为2
的正方形内画一个圆,使得圆的半径r
为1
。
图 18-5 单位圆被画在正方形内
根据π的定义,面积 = πr²。由于r
为1
,π = 面积
。但圆的面积是多少呢?布丰建议通过在正方形附*抛投大量针来估算圆的面积(他认为针在下落时会随机移动)。落在正方形内的针尖数量与落在圆内的针尖数量的比率可以用来估算圆的面积。
如果针的位置是完全随机的,我们知道
并求解圆的面积,
记住,2
乘以2
的正方形面积是4
,所以,
一般来说,要估算某个区域R
的面积:
1. 选择一个封闭区域
E
,使得E
的面积容易计算,且R
完全位于E
内。2. 选择一组随机点,这些点位于
E
内。3. 设
F
为落在R
内的点的比例。4. 将区域
E
的面积乘以F
。
如果你尝试布丰的实验,你会很快意识到针落下的位置并不是完全随机的。此外,即使你能随机投放它们,也需要大量的针才能得到与Bible相当的π
*似值。幸运的是,计算机可以以惊人的速度随机投放模拟针。¹³²
图 18-6 包含一个使用布丰-拉普拉斯方法估算π
的程序。为了简化,程序仅考虑落在正方形右上象限的针。
图 18-6 估计 π
函数 throw_needles
通过首先使用 random.random
获取一对正的笛卡尔坐标(x
和 y
值),模拟扔掉一根针,这代表了针相对于正方形中心的位置。然后,它使用毕达哥拉斯定理计算以 x
为底、y
为高的直角三角形的斜边。这是针尖离原点(正方形的中心)的距离。由于圆的半径为 1
,我们知道,只有当从原点的距离不大于 1
时,针才会落在圆内。我们利用这一事实来计算落在圆内的针的数量。
函数 get_est
使用 throw_needles
通过首先扔掉 num_needles
根针,然后对 num_trials
次试验的结果取*均,来找出 π 的估计值。它返回试验的均值和标准差。
函数 est_pi
以不断增加的针的数量调用 get_est
,直到 get_est
返回的标准差不大于 precision/1.96
。在假设误差服从正态分布的前提下,这意味着 95%
的值位于均值的 precision
范围内。
当我们运行 est_pi(0.01, 100)
时,它打印了
Est. = 3.14844, Std. dev. = 0.04789, Needles = 1000
Est. = 3.13918, Std. dev. = 0.0355, Needles = 2000
Est. = 3.14108, Std. dev. = 0.02713, Needles = 4000
Est. = 3.14143, Std. dev. = 0.0168, Needles = 8000
Est. = 3.14135, Std. dev. = 0.0137, Needles = 16000
Est. = 3.14131, Std. dev. = 0.00848, Needles = 32000
Est. = 3.14117, Std. dev. = 0.00703, Needles = 64000
Est. = 3.14159, Std. dev. = 0.00403, Needles = 128000
正如我们所预期的,随着样本数量的增加,标准差单调减少。最开始时,π
的估计值也稳步提高。有些高于真实值,有些低于真实值,但每次增加 num_needles
都会导致估计值的改善。在每次试验中使用 1000
个样本时,模拟的估计值已经优于圣经和林德纸草书。
有趣的是,当针的数量从 8,000
增加到 16,000
时,估计值反而变差,因为 3.14135
离真实的 π 值比 3.14143
更远。然而,如果我们查看每个均值周围一个标准差所定义的范围,这两个范围都包含真实的 π 值,且与较大样本量相关的范围更小。尽管使用 16,000
个样本生成的估计恰好离真实的 π 值更远,但我们应该对其准确性更有信心。这是一个极其重要的概念。仅仅给出一个好答案是不够的。我们必须有合理的理由相信它实际上是一个好答案。当我们扔掉足够多的针时,小标准差给我们提供了信心,表明我们得到了正确的答案,对吧?
不完全是。拥有小的标准差是对结果有效性有信心的必要条件,但不是充分条件。统计有效结论的概念永远不应与正确结论的概念混淆。
每个统计分析都始于一组假设。这里的关键假设是我们的模拟是现实的准确模型。回想一下,我们的布丰-拉普拉斯模拟的设计是从一些代数开始的,这些代数展示了我们如何利用两个区域的比率来找到π的值。然后我们将这个想法转化为依赖于一些几何知识和random.random
随机性的代码。
让我们看看如果我们在这些方面出错会发生什么。例如,假设我们将函数throw_needles
最后一行中的4
替换为2
,然后再次运行est_pi(0.01, 100)
。这次它打印
Est. = 1.57422, Std. dev. = 0.02394, Needles = 1000
Est. = 1.56959, Std. dev. = 0.01775, Needles = 2000
Est. = 1.57054, Std. dev. = 0.01356, Needles = 4000
Est. = 1.57072, Std. dev. = 0.0084, Needles = 8000
Est. = 1.57068, Std. dev. = 0.00685, Needles = 16000
Est. = 1.57066, Std. dev. = 0.00424, Needles = 32000
仅仅32,000
根针的标准差表明我们对这个估计应该有相当的信心。但这到底意味着什么呢?这意味着我们可以合理地相信,如果我们从同一分布中抽取更多样本,我们会得到一个类似的值。它并未说明这个值是否接*实际的π值。如果你要记住关于统计学的一件事,请记住这一点:统计上有效的结论不应与正确的结论混淆!
在相信模拟结果之前,我们需要对我们的概念模型的正确性以及我们是否正确实现了该模型有信心。尽可能地,你应该尝试将结果与现实进行验证。在这种情况下,你可以使用其他方法计算圆的面积的*似值(例如,物理测量),并检查计算得到的π值至少是否在正确的范围内。
18.5 关于模拟模型的一些结束语
在科学历史的大部分时间里,理论家们使用数学技术构建纯粹的解析模型,这些模型可以根据一组参数和初始条件预测系统的行为。这导致了重要数学工具的发展,从微积分到概率论。这些工具帮助科学家们对宏观物理世界形成了相对准确的理解。
随着二十世纪的进展,这种方法的局限性变得越来越明显。这些原因包括:
对社会科学(例如经济学)的兴趣增加,促使人们希望构建那些在数学上无法处理的系统的良好模型。
随着被建模的系统变得越来越复杂,似乎逐步完善一系列模拟模型比构建准确的解析模型要容易得多。
从模拟中提取有用的中间结果往往比从解析模型中提取更容易,例如进行“如果……会怎样”的游戏。
计算机的可用性使得运行大规模模拟成为可能。在二十世纪中叶现代计算机出现之前,模拟的实用性受限于手动计算所需的时间。
仿真模型是描述性的,而不是处方性的。它们描述系统在给定条件下如何工作,而不是如何安排条件以使系统表现最佳。仿真并不优化,它只是描述。这并不是说仿真不能作为优化过程的一部分。例如,仿真通常作为寻找最佳参数设置的一部分搜索过程。
仿真模型可以沿三个维度分类:
确定性与随机性
静态与动态
离散与连续
确定性仿真的行为完全由模型定义。重新运行仿真不会改变结果。确定性仿真通常用于被建模系统本身也是确定性的情况,但分析过于复杂,例如,处理器芯片的性能。随机仿真在模型中引入了随机性。对同一模型的多次运行可能会生成不同的值。这种随机因素迫使我们生成多个结果,以查看可能性的范围。生成10
、1000
或100,000
个结果的问题是一个统计问题,如前所述。
在静态模型中,时间没有本质作用。本章中用于估计π
的针落仿真就是一个静态仿真的例子。在动态模型中,时间或某种类似物起着重要作用。在第十六章中模拟的一系列随机行走中,所采取的步数被用作时间的替代。
在离散模型中,相关变量的值是可枚举的,例如,它们是整数。在连续模型中,相关变量的值范围在不可枚举的集合上,例如,实数。想象分析高速公路上的交通流。我们可能选择对每辆汽车进行建模,在这种情况下,我们有一个离散模型。或者,我们可能选择将交通视为一种流,其中流的变化可以用微分方程描述。这就导致了一个连续模型。在这个例子中,离散模型更接*物理情况(没有人开半辆车,尽管有些车的尺寸是其他车的一半),但计算复杂性大于连续模型。在实践中,模型往往同时具有离散和连续组件。例如,我们可能选择使用离散模型对血液流动进行建模(即,对单个血球建模),并使用连续模型对血压进行建模。
18.6 本章引入的术语
蒙特卡洛仿真
投资回报率(ROI)
表查找
时间/空间权衡
描述模型
处方模型
确定性仿真
随机仿真
静态模型
动态模型
离散模型
连续模型
第十九章:抽样与置信度
回想一下,推断统计涉及通过分析随机选择的样本来对总体进行推断。这个样本被称为样本。
抽样很重要,因为通常无法观察到整个感兴趣的总体。医生无法计算患者血液中某种细菌的数量,但可以测量患者血液的小样本中的细菌数量,从而推断整个总体的特征。如果你想知道十八岁美国人的*均体重,可以尝试把他们全部召集起来,放在一个非常大的秤上,然后除以人数。或者,你可以随机召集 50 个十八岁的人,计算他们的*均体重,并假设这个*均体重是整个十八岁人群*均体重的合理估计。
样本与感兴趣总体之间的对应关系至关重要。如果样本不能代表总体,再复杂的数学也无法得出有效的推断。50 个女性或 50 个亚裔美国人或 50 个足球运动员的样本不能用于对美国所有十八岁人群的*均体重进行有效推断。
在本章中,我们关注概率抽样。使用概率抽样,感兴趣总体的每个成员都有非零的被纳入样本的概率。在简单随机样本中,总体的每个成员被选中的机会是均等的。在分层抽样中,总体首先被划分为子群体,然后从每个子群体随机抽样以构建样本。分层抽样可以提高样本代表整个总体的概率。例如,确保样本中男性和女性的比例与总体中的比例相符,会增加样本均值(样本均值)是整个总体均值(总体均值)的良好估计的概率。
本章中的代码假设以下导入语句
import random
import numpy as np
import matplotlib.pyplot as plt
import scipy
19.1 抽样波士顿马拉松
自 1897 年以来,每年都有运动员(主要是跑步者,但自 1975 年以来有残疾人组别)聚集在马萨诸塞州参加波士顿马拉松。¹³³*年来,每年约有 20,000 名勇敢的人成功挑战42.195
公里(26
英里,385
码)的赛道。
包含 2012 年比赛数据的文件可在与本书相关的网站上获得。文件bm_results2012.csv
为逗号分隔格式,包含每位参与者的姓名、性别、¹³⁴年龄、组别、国家和时间。图 19-1 包含该文件内容的前几行。
图 19-1 bm_results2012.csv
中的前几行
由于每场比赛的完整结果数据很容易获得,因此没有实际需要使用抽样来推导比赛的统计数据。然而,从教育角度来看,将从样本中得出的统计估计与实际估计值进行比较是有益的。
图 19-2 中的代码生成了图 19-3 中显示的图表。函数get_BM_data
从包含比赛中每位竞争者信息的文件中读取数据。它返回一个包含六个元素的字典。每个键描述与该键关联的列表中元素的数据类型(例如,'name'
或'gender'
)。例如,data['time']
是一个浮点数列表,包含每个竞争者的完成时间,data['name'][i]
是第i
位竞争者的名字,data['time'][i]
是第i
位竞争者的完成时间。函数make_hist
生成完成时间的可视化表示。(在第二十三章中,我们将研究一个可以简化本章中很多代码的 Python 模块 Pandas,包括get_BM_data
和make_hist
。)
图 19-2 读取数据并生成波士顿马拉松的图表
代码
times = get_BM_data('bm_results2012.csv')['time']
make_hist(times, 20, '2012 Boston Marathon',
'Minutes to Complete Race', 'Number of Runners')
在图 19-3 中生成图表。
图 19-3 波士顿马拉松完成时间
完成时间的分布类似于正态分布,但由于右侧的粗尾明显不正常。
现在,让我们假装没有关于所有竞争者的数据,而是想通过抽样一小部分随机选择的竞争者来估计整个参赛者完成时间的一些统计数据。
图 19-4 中的代码创建了times
元素的简单随机样本,然后使用该样本来估计times
的均值和标准差。函数sample_times
使用random.sample(times, num_examples)
来提取样本。调用random.sample
返回一个大小为num_examples
的列表,包含从列表times
中随机选择的不同元素。在提取样本后,sample_times
生成一个直方图,显示样本中值的分布。
图 19-4 抽样完成时间
正如图 19-5 所示,样本的分布与其抽取的分布相比,远离正态分布。这并不令人惊讶,因为样本大小较小。更令人惊讶的是,尽管样本大小为(40
,约为21,000
)小,但估计的均值与总体均值相差约3%
。我们是运气好,还是有理由期待均值的估计会相当准确?换句话说,我们能否以量化的方式表达我们对估计的信心有多大?
图 19-5 分析小样本
正如我们在第十七章和第十八章讨论的那样,提供置信区间和置信水*以指示估计的可靠性通常是有用的。给定一个从更大总体中抽取的单个样本(任意大小),总体均值的最佳估计是样本的均值。估计所需达到期望置信水*的置信区间宽度则更复杂。这在一定程度上取决于样本的大小。
很容易理解样本大小为什么重要。大数法则告诉我们,随着样本大小的增加,样本值的分布更可能类似于其抽取的总体的分布。因此,随着样本大小的增加,样本均值和样本标准差更可能接*总体均值和总体标准差。
所以,越大越好,但多大才算足够?这取决于总体的方差。方差越高,需要的样本越多。考虑两个正态分布,一个均值为0
,标准差为1
,另一个均值为0
,标准差为100
。如果我们从这些分布中随机选择一个元素并用它来估计该分布的均值,那么该估计在任何期望精度∈内的真实均值(0
)的概率,将等于在概率密度函数下方,范围在−∈和∈之间的面积(见第 17.4.1 节)。图 19-6 中的代码计算并打印了对于∈ = 3 分钟的这些概率。
图 19-6 方差对均值估计的影响
当运行图 19-6 中的代码时,它会打印出
Probability of being within 3 of true mean of tight dist. = 0.9973
Probability of being within 3 of true mean of wide dist. = 0.0239
图 19-7 中的代码绘制了来自两个正态分布的1000
个样本,每个样本大小为40
的均值。再次强调,每个分布的均值为0
,但一个的标准差为1
,另一个的标准差为100
。
图 19-7 计算并绘制样本均值
图 19-8 的左侧显示了每个样本的均值。正如预期的那样,当总体标准差为 1 时,样本均值都接*总体均值0
,这就是为什么看不到明显的圆圈——它们密集到合并成看似一条柱形的状态。相反,当总体标准差为100
时,样本均值则以难以辨认的模式分散。
图 19-8 样本均值
然而,当我们查看标准差为100
时的均值直方图时,在图 19-8 的右侧,出现了一个重要的现象:均值形成了一个类似于以0
为中心的正态分布。右侧的图 19-8 看起来如此并非偶然。这是中心极限定理的结果,这是所有概率和统计中最著名的定理。
19.2 中心极限定理
中心极限定理解释了为什么可以使用从一个总体中抽取的单一样本来估计从同一总体中抽取的一组假设样本均值的变异性。
中心极限定理(简称CLT)的一个版本最早由拉普拉斯于 1810 年发表,并在 1820 年代由泊松进一步完善。但我们今天所知的 CLT 是 20 世纪上半叶一系列杰出数学家工作的成果。
尽管(或许正因为)有许多杰出的数学家参与其中,CLT 实际上相当简单。它的意思是
给定从同一总体中抽取的足够大的样本集,样本的均值(样本均值)将*似呈正态分布。
这种正态分布的均值将接*总体的均值。
样本均值的方差(使用
numpy.var
计算)将接*于总体方差除以样本大小。
让我们来看一个中心极限定理(CLT)实际应用的例子。想象一下,你有一个骰子,每次掷出的结果会产生一个在 0 到 5 之间的随机实数。图 19-9 中的代码模拟了多次掷骰子的过程,打印均值和方差(variance
函数在图 17-8 中定义),并绘制了显示各个数字范围出现概率的直方图。它还模拟了多次掷100
个骰子,并在同一图中绘制这些100
个骰子的均值的直方图。hatch
关键字参数用于在视觉上区分两个直方图。
图 19-9 估计连续骰子的均值
weights
关键字绑定到与hist
的第一个参数相同长度的数组,用于给第一个参数中的每个元素分配权重。在生成的直方图中,箱子中的每个值都贡献其相关的权重到箱子计数中(而不是通常的1
)。在这个例子中,我们使用weights
来缩放 y 值,以反映每个箱子的相对(而非绝对)大小。因此,对于每个箱子,y 轴上的值是均值落在该箱子内的概率。
运行代码后,生成了图 19-10 中的绘图,并打印了,
图 19-10 中心极限定理的示意图
Mean of rolling 1 die = 2.5003 Variance = 2.0814
Mean of rolling 100 dice = 2.4999 Variance = 0.0211
在每种情况下,均值都非常接*预期的均值2.5
。由于我们的骰子是公*的,一个骰子的概率分布几乎是完全均匀的,¹³⁵即远非正态。然而,当我们查看100
个骰子的*均值时,分布几乎是完全正态的,峰值包括预期的均值。此外,100
次掷骰子的均值方差接*单次掷骰子的值除以100
的方差。所有结果都如中心极限定理所预测。
中心极限定理似乎有效,但它有什么用呢?或许它可以帮助那些在特别书呆子的酒吧喝酒的人赢得酒吧赌注。然而,中心极限定理的主要价值在于它允许我们计算置信水*和区间,即使基础的总体分布不是正态分布。当我们在第 17.4.2 节中讨论置信区间时,指出经验法则是基于对所采样空间性质的假设。我们假设
均值估计误差为 0。
估计值的误差分布是正态的。
当这些假设成立时,针对正态分布的经验法则提供了一种便捷的方法,根据均值和标准差估计置信区间和水*。
让我们回到波士顿马拉松的例子。代码在图 19-11 中生成的绘图在图 19-12 中显示,针对各种样本大小绘制了 200 个简单随机样本。对于每个样本大小,它计算了这 200 个样本的均值;然后计算这些均值的均值和标准差。由于中心极限定理告诉我们样本均值将服从正态分布,我们可以使用标准差和经验法则为每个样本大小计算95%
置信区间。
图 19-11 带误差条的绘图
图 19-12 带误差条的完成时间估计
如图 19-12 所示,所有估计值都与实际总体均值相当接*。然而,请注意,估计均值的误差并不是随着样本大小单调减少——使用700
个例子的估计恰好比使用50
个例子的估计更差。随着样本大小的增加,我们对均值估计的信心是单调增加的。当样本大小从100
增加到1500
时,置信区间从大约±15
减少到大约±2.5
。这非常重要。仅仅运气好并得到一个好的估计是不够的。我们需要知道对我们的估计要有多少信心。
19.3 均值的标准误差
我们刚刚看到,如果选择 200 个随机样本,每个样本1,500
名竞争者,我们可以以95%
的置信度,在大约五分钟的范围内估计均值完成时间。我们使用了样本均值的标准差。不幸的是,由于这涉及到使用比竞争者更多的总例子(200
*1500 = 300,000
),这似乎并不是一个有用的结果。我们直接使用整个总体计算实际均值会更好。我们需要一种方法,通过单个例子估计置信区间。引入均值的标准误差(SE或SEM)的概念。
样本大小为n
的 SEM 是从同一总体中抽取的无穷多个样本均值的标准差,样本大小为n
。不出所料,它依赖于n
和σ,总体的标准差:
图 19-13 将图 19-12 中使用的样本大小的 SEM 与我们为每个样本大小生成的 200 个样本的均值标准差进行了比较。
图 19-13 标准误差
我们的 200 个样本的均值的实际标准差与标准误差(SE)紧密相关。注意,SEM 和 SD 在开始时迅速下降,然后随着样本大小的增大而减缓。这是因为该值依赖于样本大小的*方根。换句话说,要将标准差减半,我们需要将样本大小增加四倍。
可惜的是,如果我们只有一个样本,就不知道总体的标准差。通常,我们假设样本的标准差是总体标准差的合理替代。这在总体分布不严重偏斜时是成立的。
图 19-14 中的代码创建了来自波士顿马拉松数据的100
个不同大小的样本,并将每个大小样本的均值标准差与总体的标准差进行了比较。它生成了图 19-15 中的图。
图 19-14 样本标准偏差与总体标准偏差
图 19-15 样本标准偏差
当样本大小达到100
时,样本标准偏差与总体标准偏差之间的差异相对较小(约为实际*均完成时间的 1.2%)。
实际上,人们通常使用样本标准偏差来代替(通常未知的)总体标准偏差以估计标准误差。如果样本大小足够大,¹³⁶并且总体分布与正态分布相差不大,那么使用这个估计值来计算基于经验法则的置信区间是安全的。
这意味着什么?如果我们抽取一个包含 200 名跑步者的单一样本,我们可以
计算该样本的均值和标准偏差。
使用该样本的标准偏差来估计标准误差(SE)。
使用估计的标准误差(SE)来生成围绕样本均值的置信区间。
图 19-16 中的代码执行此操作10,000
次,然后打印样本均值与总体均值之间超过1.96
个估计标准误差的次数比例。(请记住,对于正态分布,95%
的数据落在均值的1.96
个标准偏差范围内。)
图 19-16 估计总体均值 10,000 次
当代码运行时,它会打印,
Fraction outside 95% confidence interval = 0.0533
这基本上是理论的预测。中心极限定理胜出一分!
19.4 本章引入的术语
总体
样本
样本大小
概率抽样
简单随机样本
分层抽样
样本均值
总体均值
中心极限定理
标准误差(SE,SEM)
第二十章:理解实验数据
本章是关于理解实验数据的。我们将广泛使用绘图来可视化数据,并展示如何使用线性回归构建实验数据模型。我们还将讨论物理实验和计算实验之间的相互作用。我们将讨论如何得出有效的统计结论的内容推迟到第二十一章。
20.1 弹簧的行为
弹簧是非常奇妙的东西。当它们被某种力压缩或拉伸时,会储存能量。当这个力不再施加时,它们释放储存的能量。这一特性使它们能够在汽车中*滑行驶,帮助床垫适应我们的身体,收回安全带,发射弹丸。
1676 年,英国物理学家罗伯特·胡克制定了弹性胡克定律:Ut tensio, sic vis,用英语表达为F = -kx
。换句话说,存储在弹簧中的力F
与弹簧被压缩(或拉伸)的距离呈线性关系。(负号表示弹簧施加的力与位移方向相反。)胡克定律适用于各种材料和系统,包括许多生物系统。当然,它不适用于任意大的力。所有弹簧都有一个弹性极限,超过这个极限,定律就失效了。那些拉伸过度的 Slinky 玩具的人对此了解得太清楚了。
比例常数k
称为弹簧常数。如果弹簧很硬(比如汽车悬挂中的弹簧或弓箭手的弓臂),k
就大。如果弹簧很弱,比如圆珠笔中的弹簧,k
就小。
知道特定弹簧的弹簧常数可能非常重要。简单秤和原子力显微镜的标定都依赖于知道组件的弹簧常数。DNA 链的机械行为与压缩它所需的力相关。弓发射箭矢的力与其弓臂的弹簧常数相关,等等。
代代物理学生通过使用类似于图 20-1 所示的实验装置来估计弹簧常数。
图 20-1 经典实验
我们从一个没有附加重量的弹簧开始,测量弹簧底部到支架顶部的距离。然后我们在弹簧上挂上已知质量的物体,并等待它停止移动。在此时,存储在弹簧中的力就是悬挂物体施加在弹簧上的力。这就是胡克定律中的F
值。我们再次测量弹簧底部到支架顶部的距离。这个距离与挂上重量之前的距离之间的差值就是胡克定律中的x
值。
我们知道施加在弹簧上的力F
等于质量m
乘以重力加速度g
(9.81 m/s
²是这个星球表面g
的一个相当好的*似值),因此我们将m
g
代入F
。通过简单的代数,我们知道k = -(m
g)/x.
假设,例如,m = 1kg
和x = 0.1m
,那么
根据这个计算,拉伸弹簧一米需要*98.1*
牛顿¹³⁷的力。
如果一切都很好
我们完全相信我们能够完美地进行这个实验。在这种情况下,我们可以进行一次测量,执行计算,并知道我们找到了
k
。不幸的是,实验科学几乎从不这样运作。我们可以确保我们在弹簧的弹性极限以下进行操作。
一个更稳健的实验是将一系列越来越重的重物悬挂在弹簧上,每次测量弹簧的伸长并绘制结果。我们进行了这样的实验,并将结果输入到一个名为springData.csv
的文件中:
Distance (m), Mass (kg)
0.0865,0.1
0.1015,0.15
…
0.4416,0.9
0.4304,0.95
0.437,1.0
图 20-2 中的函数从一个文件读取数据,例如我们保存的文件,并返回包含距离和质量的列表。
图 20-2 从文件中提取数据
图 20-3 中的函数使用get_data
从文件中提取实验数据,然后生成图 20-4 中的图表。
图 20-3 绘制数据
图 20-4 弹簧的位移
这不是胡克定律所预测的。胡克定律告诉我们,距离应与质量线性增加,即点应位于一条直线上,其斜率由弹簧常数决定。当然,我们知道当我们进行真实测量时,实验数据很少与理论完全吻合。测量误差是可以预期的,因此我们应当期望点位于一条线附*,而不是在线上。
仍然,看到一条代表我们最佳猜测的线会很好,假如没有测量误差,点将会在何处。通常的做法是对数据进行线性拟合。
20.1.1 使用线性回归找到拟合
每当我们将任何曲线(包括直线)拟合到数据时,我们需要某种方法来判断哪条曲线是数据的最佳拟合。这意味着我们需要定义一个客观函数,以定量评估曲线与数据的拟合程度。一旦我们有了这样的函数,找到最佳拟合可以被表述为寻找一个最小化(或最大化)该函数值的曲线,即作为一个优化问题(见第 14 和 15 章)。
最常用的目标函数称为 最小二乘法。令 *observed*
和 *predicted*
为相同长度的向量,其中 *observed*
包含测量点,predicted 包含建议拟合的相应数据点。
然后定义目标函数为:
对观察值和预测值之间的差异进行*方处理,使观察值和预测值之间的大差异相对比小差异更为重要。*方差异还丢失了关于差异是正还是负的信息。
我们如何找到最佳的最小二乘拟合?一种方法是使用类似于第三章中牛顿–拉夫森算法的逐次逼*算法。或者,通常适用解析解。但我们不必实现牛顿–拉夫森或解析解,因为 numpy
提供了一个内置函数 polyfit
,该函数可以找到最佳最小二乘拟合的*似值。该调用
`np.polyfit(observed_x_vals, observed_y_vals, n)`
查找提供最佳最小二乘拟合的多项式的系数,次数为 n
,该多项式由两个数组 observed_x_vals
和 observed_y_vals
定义的点集给出。例如,该调用
`np.polyfit(observed_x_vals, observed_y_vals, 1)`
将找到由多项式 y = ax + b
描述的直线,其中 a
是直线的斜率,b
是 y 轴截距。在这种情况下,调用返回一个包含两个浮点值的数组。类似地,抛物线由二次方程 y = ax
² + bx + c
描述。因此,该调用
`np.polyfit(observed_x_vals, observed_y_vals, 2)`
返回一个包含三个浮点值的数组。
polyfit
使用的算法称为 线性回归。这可能有些令人困惑,因为我们可以用它来拟合除直线以外的曲线。一些作者确实区分线性回归(当模型是直线时)和 多项式回归(当模型是次数大于 1
的多项式时),但大多数作者没有。¹³⁸
图 20-5 中的函数 fit_data
通过添加一条表示数据最佳拟合的直线来扩展 图 20-3 中的 plot_data
函数。它使用 polyfit
找到系数 a
和 b
,然后利用这些系数生成每个力的预测弹簧位移。注意,forces
和 distance
的处理方式存在不对称性。forces
中的值(来自悬挂在弹簧上的质量)被视为独立变量,用于生成因悬挂该质量而产生的因变量 predicted_distances
的值。
图 20-5 拟合数据曲线
该函数还计算弹簧常数 k
。直线的斜率 a
是 Δdistance/Δforce
。另一方面,弹簧常数 k
是 Δforce/Δdistance
。因此,k
是 a
的倒数。
调用fit_data('springData.csv')
生成图 20-6 中的图表。
图 20-6 测量点和线性模型
有趣的是,实际上很少有点落在最小二乘拟合上。这是合理的,因为我们试图最小化*方误差的总和,而不是最大化落在直线上的点的数量。不过,这看起来似乎并不是一个很好的拟合。让我们通过向fit_data
添加代码来尝试三次拟合
#find cubic fit
fit = np.polyfit(forces, distances, 3)
predicted_distances = np.polyval(fit, forces)
plt.plot(forces, predicted_distances, 'k:', label = 'cubic fit')
在这段代码中,我们使用了函数polyval
来生成与三次拟合相关的点。这个函数接受两个参数:一组多项式系数和一组用于计算多项式值的自变量。代码片段
fit = np.polyfit(forces, distances, 3)
predicted_distances = np.polyval(fit, forces)
和
a,b,c,d = np.polyfit(forces, distances, 3)
predicted_distances = a*(forces**3) + b*forces**2 + c*forces + d
是等价的。
这生成了图 20-7 中的图表。三次拟合似乎比线性拟合更好地描述了数据,但真的如此吗?可能不是。
图 20-7 线性与三次拟合
技术性和大众化文章经常包含这样的图表,展示原始数据和与数据拟合的曲线。然而,作者常常假设拟合曲线就是对实际情况的描述,而原始数据仅仅是实验误差的指示。这是很危险的。
请记住,我们开始时的理论是x
和y
值之间应该存在线性关系,而不是三次关系。让我们看看如果使用我们的线性和三次拟合来预测对应于悬挂1.5kg
重量的点会落在哪里,图 20-8。
图 20-8 使用模型进行预测
现在三次拟合看起来不太好。特别是,通过在弹簧上挂一个大重量,弹簧会升高到(y 值为负)其悬挂的杆上方,这似乎极不可能。我们遇到的是过拟合的例子。过拟合通常发生在模型过于复杂时,例如,相对于数据量,它有太多参数。当这种情况发生时,拟合可以捕捉到数据中的噪声,而不是有意义的关系。过拟合的模型通常具有较差的预测能力,正如这个例子所示。
手指练习: 修改图 20-5 中的代码,以便生成图 20-8 中的图表。
让我们回到线性拟合。此刻,忘掉直线,研究原始数据。它有什么奇怪的地方吗?如果我们对最右侧的六个点拟合一条直线,它将几乎与 x 轴*行。这似乎与胡克定律相矛盾——直到我们记起胡克定律仅在某个弹性极限内有效。也许这个极限在7N
(大约0.7kg
)附*。
让我们看看如果通过替换fit_data
的第二和第三行来消除最后六个点会发生什么。
`distances = np.array(distances[:-6]) masses = np.array(masses[:-6])`
正如图 20-9 所示,去掉那些点确实会产生影响:k
值显著下降,线性和立方拟合几乎无法区分。但我们怎么知道哪条线性拟合更好地代表了我们的弹簧在其弹性极限内的表现呢?我们可以使用某种统计检验来确定哪条线更适合数据,但那并不是重点。这个问题无法通过统计来回答。毕竟,我们可以抛弃所有数据,只保留任意两个点,并知道polyfit
会找到一条完全适合这两个点的线。单纯为了获得更好的拟合而抛弃实验结果是完全不合适的。¹³⁹ 在这里,我们通过引用胡克定律的理论,即弹簧具有弹性极限,来合理化抛弃最右侧的点。这个理由不应该被用于其他地方的数据点。
图 20-9 弹性极限的模型
20.2 发射体的行为
对于仅仅拉伸弹簧感到厌倦,我们决定用我们的一个弹簧制作一个能够发射发射体的装置。¹⁴⁰ 我们使用该装置四次,将发射体发射到距离发射点30
码(1080
英寸)的目标上。每次,我们都测量发射体在距离发射点不同距离时的高度。发射点和目标在同一高度,我们在测量中将其视为0.0
。
数据存储在一个文件中,部分数据如图 20-10 所示。第一列包含发射体距离目标的距离。其他列包含在该距离下四次实验中发射体的高度。所有测量均以英寸为单位。
图 20-10 来自发射体实验的数据
图 20-11 中的代码用于绘制四次实验中发射体的*均高度与发射点之间的距离关系。它还绘制了这些点的最佳线性和二次拟合。(如果你忘记了将列表与整数相乘的含义,表达式[0]*len(distances)
将生成一个包含len(distances)
个0
的列表。)
图 20-11 绘制发射体的轨迹
通过快速查看图 20-12 中的图表,很明显,二次拟合远优于线性拟合。¹⁴¹ 但从绝对意义上讲,这条线的拟合有多糟糕,二次拟合又有多好呢?
图 20-12 轨迹图
20.2.1 决定系数
当我们为一组数据拟合曲线时,我们是在寻找一个将自变量(本例中从发射点水*距离的英寸数)与因变量的预测值(本例中发射点以上的英寸数)关联的函数。询问拟合优度相当于询问这些预测的准确性。请记住,拟合是通过最小化均方误差来找到的。这表明我们可以通过查看均方误差来评估拟合优度。该方法的问题在于,虽然均方误差有下界(0),但没有上界。这意味着尽管均方误差对于比较同一数据的两个拟合的相对优度是有用的,但它对于获取拟合的绝对优度的感觉并不特别有用。
我们可以使用决定系数计算拟合的绝对优度,通常写作R
²。¹⁴² 设*y[i]为第i^(th)*个观察值,*p[i]*为模型预测的对应值,μ为观察值的均值。
通过将估计误差(分子)与原始值的变异性(分母)进行比较,R
²旨在捕捉统计模型所解释的数据集中相对于均值的变异比例。当评估的模型由线性回归产生时,R
²的值始终介于0
和1
之间。如果R
²= 1
,则模型与数据完全拟合。如果R
²= 0
,则模型预测的值与数据围绕均值的分布之间没有关系。
图 20-13 中的代码提供了这个统计度量的直接实现。它的紧凑性源于对 numpy 数组操作的表达能力。表达式(predicted - measured)**2
从一个数组的元素中减去另一个数组的元素,然后对结果中的每个元素进行*方。表达式(measured - mean_of_measured)**2
从数组measured
的每个元素中减去标量值mean_of_measured
,然后对结果中的每个元素进行*方。
图 20-13 计算 R²
当代码行
print('r**2 of linear fit =', r_squared(mean_heights, altitudes))
和
print('r**2 of quadratic fit =', r_squared(mean_heights, altitudes))
在process_trajectories
中适当调用plt.plot
之后插入时,它们会打印
r**2 of linear fit = 0.0177433205440769
r**2 of quadratic fit = 0.9857653692869693
粗略地说,这告诉我们,线性模型只能解释测量数据中不到2%
的变异,但二次模型可以解释超过98%
的变异。
20.2.2 使用计算模型
现在我们有了一个似乎是我们数据的良好模型,我们可以利用这个模型帮助回答关于我们原始数据的问题。一个有趣的问题是弹道在击中目标时的水*速度。我们可以使用以下思路来设计一个计算,以回答这个问题:
1. 我们知道,弹道的轨迹由形式为
y = ax
²+ bx + c
的公式给出,即它是一个抛物线。由于每个抛物线在其顶点处是对称的,我们知道其最高点发生在发射点与目标之间的中间位置;将此距离称为xMid
。因此,峰值高度 yPeak 由 yPeak = axMid² + bxMid + c 给出。2. 如果我们忽略空气阻力(记住没有模型是完美的),我们可以计算弹道从
yPeak
降到目标高度所需的时间,因为这纯粹是重力的函数。它由方程给出。¹⁴³这也是弹道从xMid
移动到目标所需的时间,因为一旦它到达目标就停止移动。3. 给定从
xMid
到目标的时间,我们可以轻松计算该时间段内弹道的*均水*速度。如果我们假设弹道在该时间段内在水*方向上既没有加速也没有减速,我们可以将*均水*速度作为弹道击中目标时水*速度的估计。
图 20-14 实现了这种估算弹道水*速度的技术。¹⁴⁴
图 20-14 计算弹道的水*速度
当在process_trajectories (
图 20-11)
末尾插入行get_horizontal_speed(fit, distances[-1], distances[0])
时,它会打印。
Horizontal speed = 136 feet/sec
我们刚刚经历的步骤序列遵循一个常见模式。
1. 我们首先进行了一项实验,以获取有关物理系统行为的数据。
2. 然后我们使用计算来寻找并评估系统行为模型的质量。
3. 最后,我们使用一些理论和分析设计了一个简单的计算,以推导出模型的一个有趣结果。
手指练习: 在真空中,物体下落的速度由方程v = v0 + gt
定义,其中v0
是物体的初始速度,t
是物体下落的秒数,g是重力常数,地球表面约为9.8 m/sec
²,火星上为3.711 m/sec
²。一位科学家在一个未知星球上测量下落物体的速度。她通过在不同时间点测量物体的下落速度来实现。在时间0
时,物体的速度为未知的v0
。实现一个函数,将模型拟合到时间和速度数据上,并估计该星球的g
和实验的v0
。它应该返回g
和v0
的估计值,以及模型的 r *方值。
20.3 拟合指数分布数据
Polyfit
使用线性回归来找到某个给定次数的多项式,该多项式是某些数据的最佳最小二乘拟合。如果数据可以直接用多项式*似,它工作得很好。但这并不总是可能。例如,考虑简单的指数增长函数y = 3
^x。图 20-15 中的代码拟合了一个五次多项式到前十个点,并按图 20-16 中的结果绘制。它使用函数调用np.arange(10)
,返回一个包含整数0-9
的array
。参数设置markeredgewidth = 2
设置了标记中使用的线条宽度。
图 20-15 拟合指数分布的多项式曲线
图 20-16 拟合指数分布
拟合对于这些数据点显然是好的。然而,让我们看看模型对3
²⁰的预测。当我们添加代码时
print('Model predicts that 3**20 is roughly',
np.polyval(fit, [3**20])[0])
print('Actual value of 3**20 is', 3**20)
到图 20-15 的末尾,它打印出,
Model predicts that 3**20 is roughly 2.4547827637212492e+48
Actual value of 3**20 is 3486784401
哎呀!尽管拟合了数据,但polyfit
产生的模型显然不好。这是因为5
不是正确的次数吗?不。这是因为没有多项式能很好地拟合指数分布。这是否意味着我们无法使用polyfit
来建立指数分布的模型?幸运的是,不是这样,因为我们可以使用polyfit
找到一个适合原始独立值和依赖值对数的曲线。
考虑指数序列[1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
。如果我们对每个值取以2
为底的对数,我们得到序列[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
,即线性增长的序列。实际上,如果一个函数y = f(x)
表现出指数增长,则f(x)
的对数(以任何底数)都是线性增长的。这可以通过绘制具有对数 y 轴的指数函数来可视化。代码
x_vals, y_vals = [], []
for i in range(10):
x_vals.append(i)
y_vals.append(3**i)
plt.plot(x_vals, y_vals, 'k')
plt.semilogy()
产生的图在图 20-17 中。
图 20-17 半对数图上的指数
对指数函数取对数会产生线性函数这一事实可以用来构建指数分布的数据点模型,如图 20-18 中的代码所示。我们使用 polyfit
找到适合 x
值和 y
值对数的曲线。请注意,我们还使用了另一个 Python 标准库模块 math
,它提供了一个 log
函数。(我们本可以使用 np.log2
,但想指出 math
有一个更通用的对数函数。)
图 20-18 使用 polyfit
拟合指数函数
运行时,代码
x_vals = range(10)
f = lambda x: 3**x
y_vals = create_data(f, x_vals)
plt.plot(x_vals, y_vals, 'ko', label = 'Actual values')
fit, base = fit_exp_data(x_vals, y_vals)
predictedy_vals = []
for x in x_vals:
predictedy_vals.append(base**np.polyval(fit, x))
plt.plot(x_vals, predictedy_vals, label = 'Predicted values')
plt.title('Fitting an Exponential Function')
plt.legend(loc = 'upper left')
#Look at a value for x not in original data
print('f(20) =', f(20))
print('Predicted value =', int(base**(np.polyval(fit, [20]))))
生成的图在图 20-19 中,实际值与预测值重合。此外,当模型在一个未用于生成拟合值的值(20
)上进行测试时,它输出为
f(20) = 3486784401
Predicted value = 3486784401
图 20-19 指数函数的拟合
使用 polyfit
找到数据模型的方法适用于关系可以用形式为 y = base
^(ax+b) 的方程描述的情况。如果用于不适合此模型的数据,将会产生糟糕的结果。
为了看到这一点,我们使用
f = lambda x: 3**x + x
该模型现在的预测效果很差,输出为
f(20) = 3486784421
Predicted value = 2734037145
20.4 当理论缺失时
在本章中,我们强调了理论、实验和计算科学之间的相互作用。然而,有时我们发现自己拥有大量有趣的数据,却几乎没有理论。在这种情况下,我们通常 resort to 使用计算技术通过构建一个似乎符合数据的模型来发展理论。
在理想情况下,我们会进行一次受控实验(例如,从弹簧上挂重物),研究结果,然后回顾性地制定与结果一致的模型。接着我们会进行新的实验(例如,从同一个弹簧上挂不同的重物),并将实验结果与模型预测进行比较。
不幸的是,在许多情况下,甚至无法进行一次受控实验。举个例子,假设建立一个模型来揭示利率如何影响股价。我们中的很少有人有能力设定利率并观察结果。另一方面,相关的历史数据却是源源不断的。
在这种情况下,我们可以通过将现有数据分为训练集和保留集来模拟一组实验,用作测试集。在不查看保留集的情况下,我们建立一个似乎能解释训练集的模型。例如,我们找到一个对训练集具有合理 R
² 的曲线。然后我们在保留集上测试该模型。大多数情况下,该模型会比保留集拟合训练集得更好。但是如果模型是好的,它应该能合理地拟合保留集。如果没有,那么该模型可能应该被舍弃。
我们如何选择训练集?我们希望它能代表整个数据集。一种方法是随机选择训练集的样本。如果数据集足够大,这通常效果不错。
检查模型的另一种相关但稍微不同的方法是对原始数据的多个随机选择子集进行训练,并查看模型之间的相似程度。如果它们非常相似,那么我们可以比较有信心。这种方法称为交叉验证。
交叉验证在第二十一章和第二十四章中有更详细的讨论。
20.5 在章节中引入的术语
胡克定律
弹性极限
弹簧常数
曲线拟合
最小二乘法
线性回归
多项式回归
过拟合
拟合优度
决定系数 (R²)
训练集
测试集
保留集
交叉验证
第二十一章:随机试验与假设检验
X 博士发明了一种药物 PED-X,旨在帮助职业自行车手骑得更快。当他试图推销时,自行车手们坚持要求 X 博士证明他的药物优于 PED-Y,这种他们使用多年的禁药。X 博士从一些投资者那里筹集资金并启动了随机试验。
他说服200
名职业自行车手参与试验。然后他将他们随机分为两个组:治疗组和对照组。治疗组的每位成员都接受了 PED-X 的剂量。对照组的成员被告知他们接受了 PED-X 的剂量,但实际上他们接受的是 PED-Y 的剂量。
每位自行车手被要求尽可能快地骑行50
英里。每组的完成时间呈正态分布。治疗组的*均完成时间为118.61
分钟,而对照组为120.62
分钟。图 21-1 显示了每位自行车手的时间。
图 21-1 自行车手的完成时间
X 博士非常兴奋,直到遇到一位统计学家,她指出两个组中几乎总会有一个组的均值低于另一个组,或许均值之间的差异仅仅是随机现象。当她看到科学家失落的表情时,统计学家提出要教他如何检查研究的统计显著性。
21.1 检查显著性
在任何涉及从人群中随机抽样的实验中,观察到的效应总有可能纯粹是偶然的。图 21-2 可视化了 2020 年 1 月的温度与 1981 年至 2010 年 1 月的*均温度的变化。现在,想象你通过选择地球上的 20 个随机地点来构建一个样本,然后发现样本的*均温度变化为+1
摄氏度。观察到的*均温度变化是因为你恰好抽取的地点的伪影,而不是表明整个星球正在变暖的概率是多少?回答这种问题就是统计显著性的核心所在。
图 21-2 2020 年 1 月与 1981-2010 年*均气温差异¹⁴⁵
在 20 世纪初,罗纳德·费舍尔开发了一种统计假设检验的方法,已成为评估观察到的效果纯粹是偶然发生的概率的最常见方法。费舍尔说他发明了这种方法,是回应布里斯托-罗奇博士的一个声明,她声称当她喝加奶茶时,可以判断是茶先倒入茶杯还是牛奶。费舍尔向她挑战进行“茶测试”,她被提供了八杯茶(每种加茶和牛奶的顺序各四杯),要求识别那些茶先倒入的杯子。她完美地完成了。费舍尔随后计算了她纯粹偶然做到这一点的概率。正如我们在第 17.4.4 节中看到的,,即有70
种方法从8
杯中选择4
杯。由于只有这70
种组合中有一种包含所有“茶先倒入”的4
杯,费舍尔计算出布里斯托-罗奇博士纯靠运气选择正确的概率是。由此,他得出结论,她的成功极不可能归因于运气。
费舍尔的显著性检验方法可以总结为
1. 陈述原假设和备择假设。原假设是“处理”没有有趣的效果。对于“茶测试”,原假设是布里斯托-罗奇博士无法品尝出差异。备择假设只有在原假设为假时才能成立,例如,布里斯托-罗奇博士能够品尝出差异。¹⁴⁶
2. 理解被评估样本的统计假设。对于“茶测试”,费舍尔假设布里斯托-罗奇博士为每杯茶做出了独立的决定。
3. 计算相关的检验统计量。在这种情况下,检验统计量是布里斯托-罗奇博士给出的正确答案的比例。
4. 在原假设下推导该检验统计量的概率。在这种情况下,这是偶然得到所有杯子的概率,即
0.014
。5. 决定该概率是否足够小,以至于你愿意假设原假设为假,即拒绝原假设。拒绝水*的常见值,应该提前选择,是
0.05
和0.01
。
回到我们的骑行者,假设治疗组和对照组的时间是从 PED-X 用户和 PED-Y 用户的无限完成时间总体中抽取的样本。这个实验的原假设是这两个更大总体的均值相同,即治疗组的总体均值与对照组的总体均值之间的差为 0。备择假设是它们不相同,即均值之差不等于 0。
接下来,我们开始尝试拒绝原假设。我们为统计显著性选择一个阈值α,并尝试证明数据来自与原假设一致的分布的概率小于α。然后我们说可以以置信度α拒绝原假设,并以概率1 –
α接受原假设的否定。
α的选择影响我们犯错的类型。α越大,我们越可能拒绝实际上真实的原假设。这被称为第一类错误。当α较小的时候,我们更可能接受实际上是错误的原假设。这被称为第二类错误。
通常,人们选择α = 0.05
。然而,根据错误的后果,选择一个较小或较大的α可能更为合适。例如,假设原假设是,服用 PED-X 和服用 PED-Y 之间的早逝率没有差异。我们可能希望选择一个较小的α,比如0.001
,作为拒绝该假设的基础,然后再决定哪种药物更安全。另一方面,如果原假设是 PED-X 和 PED-Y 在增强表现的效果上没有差异,我们可能会舒适地选择一个相对较大的α。¹⁴⁷
下一步是计算检验统计量。最常见的检验统计量是t 统计量。t 统计量告诉我们,从数据中得出的估计值与原假设之间的差异有多大,以标准误的单位来衡量。t 统计量越大,越有可能拒绝原假设。在我们的例子中,t 统计量告诉我们两个均值的差异(118.44 – 119.82 = -1.38
)距离0
有多少个标准误。我们的 PED-X 示例的 t 统计量是-2.11
(稍后你会看到如何计算这个值)。这意味着什么?我们如何使用它?
我们使用 t 统计量的方式与使用均值的标准差数量来计算置信区间的方法非常相似(参见第 17.4.2 节)。请记住,对于所有正态分布,样本落在均值的固定标准差数量内的概率是固定的。在这里,我们做了一些稍微复杂的事情,考虑了用于计算标准误的样本数量。我们假设的是t 分布,而不是正态分布。
t 分布最早由威廉·戈塞特(William Gosset)于 1908 年描述,他是一位为亚瑟·吉尼斯与儿子酿酒厂工作的统计学家。¹⁴⁸ t 分布实际上是一系列分布,因为分布的形状取决于样本的自由度。
自由度描述了用于推导 t 统计量的独立信息量。一般来说,我们可以将自由度视为样本中可用于估计某个关于样本所抽取的总体的统计量的独立观察数量。
t 分布类似于正态分布,自由度越大,越接*正态分布。对于小自由度,t 分布的尾部比正态分布明显更胖。对于自由度在 30 或以上的情况,t 分布非常接*正态分布。
现在,让我们用样本方差来估计总体方差。考虑一个包含三个例子 100、200 和 300 的样本。回想一下
所以我们的样本方差是
看起来我们似乎在使用三个独立的信息片段,但实际上并不是。分子中的三个项并不是彼此独立的,因为这三个观察值都用于计算200
名骑行者样本的均值。自由度为2
,因为如果我们知道均值和三个观察值中的任何两个,则第三个观察值的值是固定的。
自由度越大,样本统计量代表总体的概率越高。从单个样本计算的 t 统计量的自由度比样本大小少一,因为在计算 t 统计量时使用了样本的均值。如果使用两个样本,则自由度比样本大小之和少二,因为在计算 t 统计量时使用了每个样本的均值。例如,对于 PED-X/PED-Y 实验,自由度为198
。
在给定自由度的情况下,我们可以绘制一个显示适当 t 分布的图,并查看我们为 PED-X 示例计算的 t 统计量在分布中的位置。图 21-3 中的代码执行了这一点,并生成了图 21-4 中的图。该代码首先使用函数scipy.random.standard_t
生成许多从自由度为198
的 t 分布中抽取的样本。然后,它在 PED-X 样本的 t 统计量及其负值处绘制白线。
图 21-3 绘制 t 分布
图 21-4 可视化 t 统计量
白线左右的直方图区域的分数之和等于在原假设为真时,观察值至少极端的概率。
样本代表总体,并且
原假设为真。
我们需要查看两个尾部,因为我们的原假设是总体均值相等。因此,如果治疗组的均值在统计上显著大于或小于对照组的均值,检验应该失败。
在原假设成立的假设下,得到至少与观察值一样极端的值的概率被称为p 值。对于我们的 PED-X 示例,p 值是指在假设治疗组和对照组的实际总体均值相同的情况下,观察到的均值差异至少与实际差异一样大的概率。
如果原假设成立,p 值似乎告诉我们事件发生的概率,这看起来有些奇怪,因为我们通常希望原假设不成立。然而,这与经典的科学方法并没有太大区别,科学方法是基于设计能够反驳假设的实验。图 21-5 中的代码计算并打印我们的两个样本的 t 统计量和 p 值,一个样本包含对照组的时间,另一个样本包含治疗组的时间。库函数 scipy.stats.ttest_ind
执行双尾两样本t 检验并返回 t 统计量和 p 值。将参数 equal_var
设置为 False
表示我们不知道这两个总体是否具有相同的方差。
图 21-5 计算并打印 t 统计量和 p 值
当我们运行代码时,它报告
Treatment mean - control mean = -1.38 minutes
The t-statistic from two-sample test is -2.11
The p-value from two-sample test is 0.04
“是的,”X 博士兴奋地说,“看来 PED-X 不比 PED-Y 更好的概率只有 4%
,因此 PED-X 有效的概率是 96%
。让现金注册机开始响起来。”可惜,他的兴奋仅持续到他阅读本章的下一部分。
21.2 警惕 P 值
过于简单地将某种含义读入 p 值是非常容易的。将 p 值视为原假设为真的概率是很有诱惑力的,但这并不是它的实际含义。
零假设类似于英美刑事司法系统中的被告。该系统基于一种称为“无罪推定”的原则,即在未被证明有罪之前被认为是无罪。类似地,我们假设零假设是真实的,除非我们看到足够的证据反对它。在审判中,陪审团可以裁定被告是“有罪”或“无罪”。“无罪”裁决意味着证据不足以使陪审团相信被告“超出合理怀疑”是有罪的。¹⁴⁹可以将其视为“没有证明有罪”。“无罪”裁决并不意味着证据足以使陪审团相信被告是无罪的。它也没有说明如果陪审团看到不同的证据会得出什么结论。可以将 p 值视为陪审团裁决,其中标准“超出合理怀疑”由 α 定义,而证据则是构建 t 统计量的数据。
小 p 值表明,如果零假设为真,则特定样本不太可能出现。它类似于陪审团得出结论,认为如果被告是无罪的,就不太可能会呈现出这一组证据,因此做出了有罪的裁决。当然,这并不意味着被告实际上有罪。也许陪审团看到了误导性的证据。类似地,低 p 值可能是因为零假设实际上是错误的,或者只是因为样本并不能代表其来源的人群,即证据是误导性的。
正如你所预料的,Dr. X 坚决声称他的实验表明零假设可能是错误的。Dr. Y 坚持认为低 p 值可能是由于不具代表性的样本,并资助了与 Dr. X 的实验规模相同的另一项实验。当用她的实验样本计算统计数据时,代码打印了
Treatment mean - control mean = 0.18 minutes
The t-statistic from two-sample test is -0.27
The p-value from two-sample test is 0.78
这个 p 值比 Dr. X 的实验得到的值大了 17 倍以上,确实没有理由怀疑零假设。混乱盛行。但我们可以澄清它!
你可能不会惊讶地发现这并不是一个真实的故事——毕竟,骑自行车的人服用增强表现的药物的想法确实令人怀疑。实际上,实验的样本是通过图 21-6 中的代码生成的。
图 21-6 生成竞赛示例的代码
由于实验纯粹是计算性的,我们可以多次运行它以获得许多不同的样本。当我们生成了 10,000
对样本(每个分布各一个)并绘制 p 值的概率时,得到了图 21-7。
图 21-7 p 值的概率
由于约10%
的 p 值低于0.04
,因此一个实验恰好在4%
水*上显示显著性并不令人感到惊讶。另一方面,第二个实验得出完全不同的结果也并不令人意外。看起来令人惊讶的是,考虑到我们知道两个分布的均值实际上是不同的,我们得到的结果在5%
水*上只有约12%
的时间是显著的。大约88%
的时间,我们未能在5%
水*上拒绝错误的原假设。
p 值可能是不可靠的指标,这也是许多出现在科学文献中的结果无法被其他科学家重复的原因之一。一个问题是,研究能力(样本的大小)与统计发现的可信度之间存在强关系。¹⁵⁰ 如果我们将样本大小增加到3000
,我们仅约1%
的时间未能拒绝错误的原假设。
为什么这么多研究的统计能力不足?如果我们真的在进行一项对人进行实验(而不是模拟),那么提取大小为2000
的样本将比提取大小为100
的样本贵 20 倍。
样本大小的问题是所谓频率主义统计方法的一个内在特征。在 21.7 节中,我们讨论一种试图缓解这一问题的替代方法。
21.3 单尾和单样本检验
到目前为止,我们在本章中只讨论了双尾双样本检验。有时,使用单尾和/或单样本t 检验更为合适。
首先,让我们考虑一个单尾双样本检验。在我们对 PED-X 和 PED-Y 相对有效性的双尾检验中,我们考虑了三种情况:1)它们同样有效,2)PED-X 比 PED-Y 更有效,以及 3)PED-Y 比 PED-X 更有效。目标是通过论证如果原假设(情况 1)为真,那么看到 PED-X 和 PED-Y 样本均值之间如此大的差异是不太可能的,从而拒绝原假设。
然而,假设 PED-X 的成本显著低于 PED-Y。为了为他的化合物找到市场,X 博士只需证明 PED-X 至少与 PED-Y 一样有效。可以这样理解,我们希望拒绝均值相等或 PED-X 均值更大的假设。请注意,这比均值相等的假设要严格弱。(假设A
严格弱于假设B
,如果B
为真时A
也为真,但反之不然。)
为此,我们从一个双样本检验开始,原始原假设由图 21-5 中的代码计算得出。它打印了
Treatment mean - control mean = -1.38 minutes
The t-statistic from two-sample test is -2.11
The p-value from two-sample test is 0.04
允许我们在约4%
的水*上拒绝原假设。
我们的较弱假设怎么样?回想一下图 21-4。我们观察到在零假设成立的假设下,白线左侧和右侧直方图区域的分数之和等于获得至少与观察值一样极端的值的概率。然而,为了拒绝我们的较弱假设,我们不需要考虑左尾下的区域,因为这对应于 PED-X 比 PED-Y 更有效(一个负时间差),而我们只对拒绝 PED-X 不如 PED-Y 的假设感兴趣。也就是说,我们可以进行单尾检验。
由于 t 分布是对称的,因此为了获得单尾检验的值,我们将双尾检验的 p 值减半。因此,单尾检验的 p 值为0.02
。这使我们能够在2%
的水*上拒绝我们较弱的假设,而这在使用双尾检验时无法做到。
因为单尾检验提供了更强的效能来检测效应,所以每当有关于效应方向的假设时,使用单尾检验是很诱人的。但这通常不是一个好主意。仅当未检验方向上漏掉效应的后果微不足道时,单尾检验才合适。
现在让我们看看单样本检验。假设经过多年使用 PED-Y 的经验,赛车手在 PED-Y 下完成 50 英里赛道的*均时间是120
分钟。为了发现 PED-X 是否与 PED-Y 有不同的效应,我们将检验零假设,即单个 PED-X 样本的*均时间等于120
。我们可以使用函数scipy.stats.ttest_1samp
来实现,该函数接受一个样本和与之比较的总体均值作为参数。它返回一个包含 t 统计量和 p 值的元组。例如,如果我们在图 21-5 的代码末尾附加代码。
one_sample_test = scipy.stats.ttest_1samp(treatment_times, 120)
print('The t-statistic from one-sample test is', one_sample_test[0])
print('The p-value from one-sample test is', one_sample_test[1])
它打印。
The t-statistic from one-sample test is -2.9646117910591645
The p-value from one-sample test is 0.0037972083811954023
p 值小于我们使用双样本双尾检验得到的值并不令人惊讶。通过假设我们知道两个均值之一,我们消除了一个不确定性来源。
那么,经过这一切,我们从 PED-X 和 PED-Y 的统计分析中学到了什么?尽管 PED-X 和 PED-Y 用户的预期表现存在差异,但没有任何有限的 PED-X 和 PED-Y 用户样本能保证揭示这一差异。此外,由于预期均值的差异很小(不到千分之五),因此不太可能像 Dr. X 进行的实验(每组100
名骑手)会产生足够的证据,使我们能在95%
的置信水*下得出均值存在差异的结论。我们可以通过使用单尾检验来提高在95%
水*上获得统计显著结果的可能性,但那将是误导性的,因为我们没有理由假设 PED-X 的效果不低于 PED-Y。
21.4 显著性与否?
林德赛和约翰在过去几年中浪费了大量时间玩一个叫做“与朋友单词”的游戏。他们之间共进行了 1273 场比赛,林德赛赢了 666 场,因此她得意洋洋地说:“我在这个游戏中比你强多了。”约翰坚称林德赛的说法毫无意义,并认为胜利的差异完全应该归因于运气。
最*读过一本关于统计学的书的约翰,提出了一种方法来判断是否合理将林德赛的相对成功归因于技能:
将每场
1,273
场比赛视为一次实验,如果林德赛获胜则返回1
,否则返回0
。选择零假设,即这些实验的*均值为
0.5
。对该零假设执行双尾单样本检验。
当他运行代码时
num_games = 1273
lyndsay_wins = 666
outcomes = [1.0]*lyndsay_wins + [0.0]*(num_games - lyndsay_wins)
print('The p-value from a one-sample test is',
scipy.stats.ttest_1samp(outcomes, 0.5)[1])
它打印了
The p-value from a one-sample test is 0.0982205871243577
促使约翰声称差异甚至没有接*5%
水*的显著性。
林德赛没有学习过统计学,但读过本书第十八章,对此并不满意。“让我们进行一次蒙特卡罗模拟,”她建议,并提供了图 21-8 中的代码。
图 21-8 林德赛的游戏模拟
当林德赛的代码运行时,它打印了,
Probability of result at least this extreme by accident = 0.0491
促使她声称约翰的统计检验完全无效,并且胜利的差异在5%
水*上是统计显著的。
“不,”约翰耐心解释,“是你的模拟有问题。它假设你是更好的玩家,并进行了相当于单尾检验的操作。你模拟的内部循环是错误的。你应该进行相当于双尾检验,测试在模拟中是否有任一玩家赢得了你在实际比赛中赢得的666
场比赛。”约翰随后运行了图 21-9 中的模拟。
图 21-9 游戏的正确模拟
约翰的模拟打印了
Probability of result at least this extreme by accident = 0.0986
“这与我的双尾检验预测的非常接*,”约翰得意地说。林德赛不雅的反应不适合出现在家庭导向的书中。
手指练习:一位调查记者发现,林德赛不仅使用了可疑的统计方法,还将其应用于她仅仅虚构的数据上。¹⁵¹ 实际上,约翰击败了林德赛 479 次,输掉了 443 次。这个差异在统计上有多显著?
21.5 哪个 N?
一位教授想知道上课是否与他所在系的成绩相关。他招募了40
名新生,并给他们所有人都佩戴了脚踝手环,以便追踪他们的去向。一半的学生不被允许参加他们所有课程的任何讲座,¹⁵²而另一半则被要求参加所有讲座。¹⁵³ 在接下来的四年中,每位学生参加了40
门不同的课程,为每组学生提供了800
个成绩。
当教授对这两个样本(每个样本大小为800
)的均值进行双尾 t 检验时,p 值约为0.01
。这让教授感到失望,他原本希望没有统计显著性效应——这样他就能少一些因取消讲座而感到的愧疚,可以去海滩。出于绝望,他查看了两组的*均 GPA,发现差异非常小。他想知道,均值如此微小的差异为何会在那个水*上显著?
当样本大小足够大时,即使是微小的效应也可能具有高度统计显著性。N
很重要,影响很大。 图 21-10 绘制了1000
次试验的*均 p 值与试验中使用的样本大小的关系。对于每个样本大小和每次试验,我们生成了两个样本。每个样本均来自标准差为5
的高斯分布。其中一个样本的均值为100
,另一个的均值为100.5
。*均 p 值随着样本大小线性下降。当样本大小达到约2000
时,均值之间0.5%
的差异在5%
水*上变得显著,而当样本大小接*3000
时,在1%
水*上显著。
图 21-10 样本大小对 p 值的影响
回到我们的例子,教授在他的研究中使用N
为800
的每个组别是否合理?换句话说,是否真的有800
个独立的样本对应每组20
名学生?可能不是。每个样本有800
个成绩,但只有20
名学生,而与每名学生相关的40
个成绩可能不应视为独立样本。毕竟,有些学生始终能获得好成绩,而有些学生的成绩则令人失望。
教授决定以不同的方式查看数据。他计算了每位学生的 GPA。当他对这两个样本(每个样本大小为20
)进行双尾 t 检验时,p 值约为0.3
。于是他去了海滩。
21.6 多重假设
在第十九章中,我们使用波士顿马拉松的数据进行了抽样。 图 21-11 中的代码读取了 2012 年比赛的数据,并查找来自少数国家的女性完赛时间的统计显著性差异。它使用了图 19-2 中定义的get_BM_data
函数。
图 21-11 比较所选国家的*均完赛时间
当代码运行时,它打印
ITA and JPN have significantly different means, p-value = 0.025
看起来意大利或日本都可以声称其女性跑者比对方更快。¹⁵⁴然而,这样的结论将是相当脆弱的。虽然一组跑者的*均时间确实比另一组快,但样本大小(20
和32
)较小,可能并不能代表各国女性马拉松选手的能力。
更重要的是,我们的实验构建方式存在缺陷。我们检查了10
个零假设(每对国家各一个),并发现其中一个在5%
水*上可以被拒绝。可以认为我们实际上在检查零假设:“所有国家对之间的女性马拉松选手的*均完赛时间相同。”拒绝这个零假设可能没问题,但这并不等同于拒绝意大利和日本的女性马拉松选手速度相同的零假设。
这一点在图 21-12 的例子中表现得非常明显。在那个例子中,我们从同一总体中抽取 50 对样本,每个样本大小为200
,并测试每对样本的均值是否存在统计差异。
图 21-12 检查多个假设
由于所有样本都来自同一总体,我们知道零假设是正确的。然而,当我们运行代码时,它打印
# of statistically significantly different (p < 0.05) pairs = 2
表示可以拒绝两个配对的零假设。
这并不特别令人惊讶。回想一下,p 值为0.05
表示如果零假设成立,观察到两个样本之间的均值差异至少与此差异一样大的概率为0.05
。因此,如果我们检查 50 对样本,其中两对样本的均值在统计上显著不同,这并不奇怪。运行大量相关实验,然后挑选你喜欢的结果,可以被温和地描述为马虎。不友好的人可能会称之为其他东西。
回到我们的波士顿马拉松实验,我们检查是否可以拒绝零假设(均值无差异)对于10
对样本。当进行涉及多个假设的实验时,最简单且最保守的方法是使用邦费罗尼校正。其直觉很简单:在检查m
个假设的家庭时,维持适当的家庭错误率的一种方法是以的水*测试每个单独的假设。使用邦费罗尼校正检查意大利和日本之间的差异在α = 0.05
水*上是否显著,我们应该检查 p 值是否小于0.05/10
,即0.005
——而它并不是。
如果有许多测试或者测试统计量之间正相关,博恩费罗尼校正是保守的(即,它比必要时更频繁地未能拒绝原假设)。另一个问题是缺乏一个普遍接受的“假设家族”的定义。显然,图 21-12 中代码生成的假设是相关的,因此需要进行校正。但情况并不总是如此明确。
21.7 条件概率与贝叶斯统计
到目前为止,我们采取了所谓的频率主义统计方法。我们完全基于数据的频率或比例从样本中得出结论。这是最常用的推理框架,并导致本书前面讨论的统计假设检验和置信区间等成熟方法。从原则上讲,它的优点在于无偏。结论完全基于观察到的数据。
然而在某些情况下,另一种统计方法贝叶斯统计更为合适。考虑图 21-13 中的漫画。¹⁵⁵
图 21-13 太阳爆炸了吗?
这里发生了什么?频率主义者知道只有两种可能性:机器掷出一对六且在说谎,或者没有掷出一对六且在讲真话。由于不掷出一对六的概率为35/36
(97.22%
),频率主义者得出结论,机器可能在讲真话,因此太阳可能爆炸了。¹⁵⁶
贝叶斯使用额外信息来建立她的概率模型。她同意机器不太可能掷出一对六;然而,她认为这种情况发生的概率需要与太阳未爆炸的先验概率进行比较。她得出结论,太阳未爆炸的可能性甚至高于97.22%
,并决定押注“太阳明天会出来”。
21.7.1 条件概率
贝叶斯推理的关键思想是条件概率。
在我们之前讨论概率时,依赖于事件独立的假设。例如,我们假设抛硬币结果为正面或反面与之前的结果无关。这在数学上是方便的,但生活并不总是这样。在许多实际情况下,独立性是一个糟糕的假设。
考虑随机选择的美国成年人是男性且体重超过197
磅的概率。男性的概率约为0.5
,而体重超过197
磅(美国的*均体重¹⁵⁷)的概率也约为0.5
。¹⁵⁸ 如果这些事件是独立的,则选定的人同时是男性且体重超过197
磅的概率将是0.25
。然而,这些事件并不是独立的,因为*均美国男性的体重比*均女性重约30
磅。因此,更好的问题是 1)选定的人是男性的概率是多少,以及 2)在选定的人是男性的情况下,该人体重超过197
磅的概率是多少?条件概率的符号使得这一点变得容易表达。
符号P(A|B)
表示在假设B
为真的情况下A
为真的概率。通常读作“在B
的条件下A
的概率”。因此,公式
正好表达了我们要寻找的概率。如果P(A)
和P(B)
是独立的,则P(A|B) = P(A)
。在上述例子中,B
是男性,而*A*
是体重> 197
。
一般而言,如果P(B) ≠ 0,
像常规概率一样,条件概率总是在0
和1
之间。此外,如果Ā表示不 A,则P(A|B) + P(Ā|B) = 1。人们常常错误地假设P(A|B)等于P(B|A)。没有理由期望这种情况成立。例如,P(Male|Maltese)的值大约为0.5
,但P(Maltese|Male)约为0.000064
。¹⁵⁹
指尖练习: 估计随机选择的美国人同时是男性且体重超过197
磅的概率。假设50%
的人口是男性,男性的体重呈正态分布,*均为210
磅,标准差为30
磅。(提示:考虑使用经验法则。)
公式P(A|B, C)表示在B和C均为真的条件下* A为真的概率。假设B和C*相互独立,条件概率的定义和独立概率的乘法法则表明
公式P(A, B, C)代表* A*、B和C都为真的概率。
同样,P(A, B|C)表示在C条件下* A 和 B的概率。假设 A和B*相互独立
21.7.2 贝叶斯定理
假设一位四十多岁的无症状女性去做乳腺 X 光检查,并收到了坏消息:乳腺 X 光检查结果“阳性”。¹⁶⁰
患有乳腺癌的女性在乳腺 X 光检查中得到真实阳性结果的概率是0.9
。没有乳腺癌的女性在乳腺 X 光检查中得到误报的概率是0.07
。
我们可以使用条件概率来表达这些事实。设
canc = has breast cancer
TP = true positive
FP = false positive
使用这些变量,我们写出条件概率
P(TP | canc) = 0.9
P(FP | not Canc) = 0.07
考虑到这些条件概率,四十多岁女性在阳性乳腺 X 光检查后该多担心?她实际患乳腺癌的概率是多少?是0.93
吗,因为误报率是7%
?更多?更少?
这是一个技巧性问题:我们没有提供足够的信息让你以合理的方式回答问题。要做到这一点,你需要知道四十多岁女性患乳腺癌的先验概率。四十多岁女性患乳腺癌的比例是0.008 (1000 中有 8)
。因此,她们没有乳腺癌的比例为1 – 0.008 = 0.992
:
P(canc | woman in her 40s) = 0.008
P(not canc | woman in her 40s) = 0.992
现在我们拥有了解决四十多岁女性该多担心的所有信息。要计算她患乳腺癌的概率,我们使用称为贝叶斯定理的东西¹⁶¹(通常称为贝叶斯法则或贝叶斯规则):
在贝叶斯世界中,概率测量的是信念的程度。贝叶斯定理将考虑证据前后的信念程度联系起来。等号左侧的公式P(A|B)
是后验概率,即在考虑B
后对A
的信念程度。后验概率以先验P(A)
和证据B
对A
提供的支持来定义。支持是B
在A
成立时的概率与B
独立于A
的概率之比,即。
如果我们使用贝叶斯定理来估计这位女性实际患乳腺癌的概率,我们会得到(其中canc
在我们的贝叶斯定理中扮演A
的角色,pos
则扮演B
的角色)
阳性检测的概率是
所以
也就是说,约90%
的阳性乳腺 X 光检查结果是误报。贝叶斯定理在这里帮助了我们,因为我们准确估计了四十多岁女性患乳腺癌的先验概率。
请记住,如果我们开始时使用了错误的先验,将该先验纳入我们的概率估计中,会使估计结果更糟而非更好。例如,如果我们开始时的先验是
P(canc | women in her 40's) = 0.6
我们会得出误报率大约为5%
,即,四十多岁女性在阳性乳腺 X 光检查中患乳腺癌的概率大约为0.95
。
指尖练习: 你正在森林中漫步,看到一片看起来美味的蘑菇田。你用篮子装满了它们,准备回家烹饪并给丈夫端上。不过,在你烹饪之前,他要求你查阅一本关于当地蘑菇种类的书,检查它们是否有毒。书中说当地森林中 80%的蘑菇是有毒的。然而,你将你的蘑菇与书中的图片进行比较,决定你有 95%的把握认为这些蘑菇是安全的。你在给丈夫端上这些蘑菇时应该有多安心(假设你宁愿不成为寡妇)?
21.8 章节中引入的术语
随机试验
治疗组
对照组
统计显著性
假设检验
零假设
备择假设
检验统计量
假设拒绝
第一类错误
第二类错误
t 统计量
t 分布
自由度
p 值
科学方法
t 检验
研究的效能
双尾 p 检验
单尾 p 检验
研究的组别
樱桃采摘
邦费罗尼校正
家族型错误率
频率统计
贝叶斯统计
条件概率
真阳性
假阳性
先验概率
贝叶斯定理
信度
后验概率
先验
支持
第二十二章:谎言、可怕的谎言和统计数据
*“如果你无法证明你想证明的东西,就展示其他东西并假装它们是同一回事。在统计与人类思维碰撞后的迷茫中,几乎没有人会注意到差异。”*¹⁶³
任何人都可以通过简单地编造虚假的统计数据来撒谎。用准确的统计数据讲谎话更具挑战性,但仍然不难。
统计思维是相对较新的发明。在大多数记录历史中,事物是以定性而非定量的方式评估的。人们必然对一些统计事实有直观的认识(例如,女性通常比男性矮),但他们没有数学工具可以从轶事证据得出统计结论。这种情况在十七世纪中叶开始改变,尤其是约翰·格朗特的*《自然与政治观察——关于死亡率的记录》*的出版。这部开创性的著作利用统计分析从死亡记录中估算伦敦人口,并试图提供一个可用于预测瘟疫传播的模型。
可惜,自那时以来,人们在使用统计数据时,既用于误导也用于告知。有些人故意使用统计数据进行误导;其他人则仅仅是无能。在本章中,我们讨论了一些人可能被引导到从统计数据中得出不恰当推论的方式。我们相信你会仅将这些信息用于善良的目的——成为更好的消费者和更诚实的统计信息传播者。
22.1 垃圾进垃圾出(GIGO)
“我曾两次被[国会议员]问到,‘请问,巴贝奇先生,如果你把错误的数据输入机器,能否得到正确的答案?’我无法正确理解引发这样问题的思维混乱。”——查尔斯·巴贝奇**¹⁶⁴
这里传达的信息很简单。如果输入数据严重缺陷,任何数量的统计调整都无法产生有意义的结果。
1840 年的美国人口普查显示,自由黑人和混血者的精神病发生率大约是被奴役的黑人和混血者的十倍。结论显而易见。正如美国参议员(前副总统和未来国务卿)约翰·C·卡尔霍恩所说:“这个普查所揭示的精神病数据是无可争议的。我们的国家必须得出结论,废除奴隶制对非洲人而言将是一个诅咒。”更别提很快就清楚普查数据充满了错误。正如卡尔霍恩据说向约翰·昆西·亚当斯解释的那样:“错误太多,以至于互相抵消,导致的结论就像所有数据都正确一样。”
卡尔霍恩对亚当斯的(或许是故意的)错误反应基于一个经典错误,即独立性假设。如果他的数学素养更高,他可能会说:“我相信测量误差是无偏的,且相互独立,因此在均值两侧均匀分布。”事实上,后来的分析显示,误差严重偏向,以至于无法得出统计有效的结论。¹⁶⁵
垃圾进垃圾出(GIGO)在科学文献中是一个特别有害的问题——因为它难以被检测到。2020 年 5 月,世界上最负盛名的医学期刊之一(柳叶刀)发表了一篇关于当时肆虐的 Covid-19 大流行的论文。该论文依赖于来自六大洲* 700 家医院的 96,000 名患者的数据。在审核过程中,审稿人检查了论文中报告的分析的合理性,但没有检查分析所依据的数据的合理性。发表不到一个月后,因发现数据存在缺陷,该论文被撤回。
22.2 测试是不完美的
每个实验都应被视为一个潜在存在缺陷的测试。我们可以对化学物质、现象、疾病等进行测试。然而,我们测试的事件不一定与测试结果相同。教授们设计考试的目的是为了了解学生对某些学科内容的掌握情况,但考试结果不应与学生实际理解的程度混淆。每个测试都有固有的错误率。想象一下,一个学习第二语言的学生被要求学习100
个单词的含义,但他只学习了 80 个单词的含义。他的理解率是80%
,但他在一个包含20
个单词的测试中得分 80%的概率当然不是1
。
测试可能同时存在假阴性和假阳性。如我们在第 21.7 节中看到的,阴性的乳腺 X 光检查并不能保证没有乳腺癌,而阳性的乳腺 X 光检查也不能保证其存在。此外,测试概率和事件概率并不是同一回事。当测试稀有事件(例如,稀有疾病的存在)时,这一点尤其相关。如果假阴性的代价很高(例如,错过了一个严重但可治愈的疾病),则测试应设计得具有高敏感性,即使因此导致许多假阳性。
22.3 图片可能具有误导性
毫无疑问,图形在快速传达信息方面是非常有用的。然而,当使用不当(或恶意使用)时,图表可能会产生极具误导性的效果。例如,考虑图 22-1 中描绘的美国中西部州的房价图表。
图 22-1 美国中西部的房价
从图 22-1 左侧的图表来看,似乎在 2006-2009 年间房价相对稳定。但等一下!在 2008 年底,美国住宅房地产崩溃并引发全球金融危机不是吗?确实如此,正如右侧图表所示。
这两个图表展示了完全相同的数据,但传达了截然不同的印象。左侧的图表旨在给人房价稳定的印象。在 y 轴上,设计者使用了一个范围极低的房屋*均价格为$1,000
到一个极高的*均价格$500,000
。这最小化了房价变动区域所占空间,给人变化相对较小的印象。右侧的图表则旨在展示房价的剧烈波动,随后崩溃。设计者使用了一个狭窄的价格范围,从而夸大了变化的幅度。
图 22-2 中的代码生成了我们之前查看的两个图表,以及一个旨在准确反映房价变动的图表。它使用了我们尚未见过的两种绘图工具。
图 22-2 绘制房价
调用plt.bar(quarters, prices, width)
会生成一个条形图,条形宽度为给定值。条形的左边缘对应列表quarters
的元素值,而条形的高度对应列表prices
的相应元素值。函数调用plt.xticks(quarters+width/2, labels)
描述了与条形相关的标签。第一个参数指定每个标签的放置位置,第二个参数则是标签的文本。函数yticks
的行为类似。调用plot_housing('fair')
会生成图 22-3 中的图表。
图 22-3 房价的不同视角
手指练习:相对基线绘制图形有时会启发思考,如图 22-4 所示。修改plot_housing
以生成这样的图表。基线以下的条形应为红色。提示:使用plt.bar
的bottom
关键字参数。
图 22-4 相对于$200,000 的房价
对数 y 轴为制作具有误导性的图表提供了一个绝佳的工具。考虑图 22-5 中的条形图。左侧的图表更准确地展示了跟随 khemric 和 katyperry 的人数差异。右侧图表中跟随人数稀少的 jguttag 使 y 轴不得不将更大比例的长度分配给较小的值,从而留下更少的距离来区分 khemric 和 katyperry 的粉丝数量。¹⁶⁶
图 22-5 比较 Instagram 粉丝数量
22.4 因果关系与相关关系¹⁶⁷
研究表明,定期上课的大学生*均成绩高于偶尔上课的学生。我们这些教授这些课程的人希望相信,这是因为学生从我们教授的课程中学到了东西。当然,学生们能取得更好成绩也可能同样因为那些更有可能上课的学生也更有可能努力学习。
相关性是衡量两个变量朝同一方向移动程度的指标。如果x
与y
朝同一方向移动,变量是正相关的;如果朝相反方向移动,则是负相关的。如果没有关系,则相关性为0
。人们的身高与父母的身高正相关。吸烟与寿命之间的相关性是负的。
当两件事相关时,人们容易假设其中一件事导致了另一件事。考虑北美的流感发生率。病例数呈可预测的模式上下波动。夏季几乎没有病例;病例数在初秋开始上升,接着在夏季来临时开始下降。现在考虑上学的儿童人数。夏季上学的儿童非常少;入学人数在初秋开始上升,然后在夏季来临时下降。
学校开学与流感发生率之间的关联是无可争辩的。这使得一些人得出结论,上学是流感传播的重要因果因素。这可能是对的,但我们不能仅基于相关性得出这一结论。相关性并不意味着因果关系!毕竟,相关性同样可以用来证明流感爆发导致学校开学的信念。或者,也许在任何一个方向上都没有因果关系,而是我们尚未考虑的某个潜在变量导致了两者的发生。实际上,流感病毒在凉爽干燥的空气中存活的时间明显长于温暖潮湿的空气,在北美,流感季节与学校开学时间都与较凉爽和干燥的天气相关。
只要有足够的回溯数据,总是可以找到两个相关的变量,正如图 22-6 中的图表所示。¹⁶⁸
图 22-6 墨西哥柠檬能救命吗?
当发现这样的相关性时,首先要问的是是否有一个合理的理论来解释这种相关性。
陷入cum hoc ergo propter hoc谬论可能是相当危险的。2002 年初,大约有六百万美国女性在被开处方激素替代疗法(HRT),她们相信这将大大降低她们的心血管疾病风险。这一信念得到了几项高度可信的已发表研究的支持,这些研究表明,使用 HRT 的女性心血管死亡的发生率降低。
当《美国医学协会杂志》发表一篇文章声称激素替代疗法(HRT)实际上增加了心血管疾病的风险时,许多女性及其医生感到十分惊讶。¹⁶⁹ 这怎么可能发生?
对一些早期研究的重新分析表明,进行 HRT 的女性往往来自饮食和锻炼习惯优于*均水*的群体。或许进行 HRT 的女性在健康意识上普遍高于研究中的其他女性,因此 HRT 和心脏健康改善是共同原因的巧合效应。
指尖练习:在过去的 100 年里,加拿大每年的死亡人数与每年肉类消费量呈正相关。有什么潜在变量可以解释这一点?
22.5 统计指标并不能全面反映事实
从数据集中可以提取出大量不同的统计数据。通过仔细选择这些数据,可以传达关于同一数据的不同印象。一个好的解毒剂是查看数据集本身。
1973 年,统计学家 F.J.安斯科姆发表了一篇论文,表格见图 22-7,通常称为安斯科姆四重奏。它包含了来自四个数据集的点的<x, y>
坐标。这四个数据集的x
(9.0
)和y
(7.5
)的均值相同,x
(10.0
)和y
(3.75
)的方差相同,以及x
与y
之间的相关性(0.816
)相同。如果我们使用线性回归为每个数据集拟合一条线,结果都是y = 0.5x + 3
。
图 22-7 安斯科姆四重奏的统计数据
这是否意味着没有明显的方法可以区分这些数据集?不。我们只需绘制数据,就会发现这些数据集并不相同(图 22-8)。
图 22-8 安斯科姆四重奏的数据
道理很简单:如果可能的话,始终查看一些原始数据的代表。
22.6 抽样偏差
在第二次世界大战期间,每当一架盟军飞机从欧洲的任务中返回时,都会检查飞机,看高射炮弹是在哪些地方击中的。根据这些数据,机械师会加强那些看起来最有可能被击中的飞机部位。
这有什么问题呢?他们没有检查那些未能从任务中返回的飞机,因为它们是被高射炮击落的。也许这些未检查的飞机正是因为在高射炮造成最大伤害的地方受到了攻击而未能返回。这种特定的错误被称为非响应偏差。在调查中相当常见。例如,在许多大学,学生会在学期末的一次讲座中被要求填写一份表格,评价教授讲座的质量。尽管这些调查的结果常常不尽如人意,但情况可能更糟。那些认为讲座糟糕到不值得参加的学生并没有被纳入调查中。¹⁷⁰
如第十九章所讨论,所有统计技术都是基于这样一个假设:通过抽样某一人群的子集,我们可以推断出整个群体的情况。如果使用随机抽样,我们可以对样本与整个群体之间的期望关系做出精确的数学陈述。不幸的是,许多研究,特别是在社会科学领域,是基于所谓的便利(或偶然)抽样。这涉及到根据样本获取的便利性选择样本。为什么那么多心理学研究使用本科生作为研究对象?因为他们在大学校园中容易找到。便利样本可能是具有代表性的,但我们无法确定它实际上是否具有代表性。
手指练习:一种疾病的感染致死率是感染该疾病的人数与因该疾病死亡的人数之比。疾病的病例致死率是被诊断为该疾病的人数与因该疾病死亡的人数之比。哪一种更容易准确估计,为什么?
22.7 上下文很重要
在阅读数据时,尤其是将数据放在上下文之外时,很容易对数据的含义解读得过于深刻。2009 年 4 月 29 日,CNN 报道说:“墨西哥卫生官员怀疑,猪流感疫情已导致超过159
人死亡和大约2,500
人感染。”听起来相当可怕——直到我们将其与每年在美国季节性流感中约36,000
的死亡人数进行比较。
一个常被引用且准确的统计数据是大多数汽车事故发生在离家10
英里以内。那么这又意味着什么呢?大多数驾驶都是在离家10
英里以内完成的!此外,在这个上下文中,“家”意味着什么?该统计数据是以注册汽车的地址作为“家”来计算的。你是否可以通过将汽车注册在某个遥远的地方来降低发生事故的概率?
反对美国政府减少枪支普及率的倡议者喜欢引用这样的统计数据:大约99.8%
的美国枪支在任何给定年份都不会用于暴力犯罪。但没有一些背景,很难知道这意味着什么。这是否意味着美国的枪支暴力并不严重?全国步枪协会报告称,美国大约有3
亿支私人拥有的枪支——0.2%
的3
亿是600,000
!
22.8 比较苹果与橙子
快速查看图 22-9 中的图像。
图 22-9 福利与全职工作
这给你留下了什么印象?领取福利的美国人是否比工作的人要多得多?
左边的柱子比右边的柱子高约 500%。然而,柱子上的数字告诉我们 y 轴已经被截断。如果没有被截断,左边的柱子只会高出 6.8%。不过,想到领取福利的人比工作的人多 6.8%还是让人震惊的,震惊且误导。
“领取福利的人数”来源于美国人口普查局对参与条件性援助项目人数的统计。这项统计包括任何居住在至少一人领取福利的家庭中。例如,考虑一个由两个父母和三个孩子组成的家庭,其中一个父母有全职工作,另一个有兼职工作。如果该家庭领取了食品券,则该家庭将为“领取福利的人数”统计增加五人,为全职工作统计增加一人。
这两个数字都是“正确”的,但它们不可比较。这就像是得出结论,奥尔加比马克更会种田,因为她每英亩种植 20 吨土豆,而马克每英亩只种 3 吨蓝莓。
22.9 摘樱桃
既然我们谈到了水果,摘樱桃和比较苹果与橙子一样糟糕。选择性取材涉及选择特定的数据,而忽略其他数据,以支持某种立场。
请考虑图 22-10 中的图表。趋势很明显,但如果我们希望用这些数据来争辩地球没有变暖,我们可以引用 2013 年 4 月冰量比 1988 年 4 月更多这一事实,而忽略其他数据。
图 22-10 北极海冰
22.10 小心推断
从数据中推断是非常容易的。我们在第 20.1.1 节中就这样做了,当时我们将从线性回归中得出的拟合延伸到了回归中使用的数据之外。只有在有合理的理论依据时,才应进行外推。尤其要警惕直线外推。
考虑一下图 22-11 左侧的图表。它显示了 1994 年至 2000 年间美国互联网使用的增长。正如你所看到的,直线提供了相当不错的拟合。
图 22-11 美国互联网使用的增长。
图 22-11 右侧的图表利用这一拟合预测了未来几年使用互联网的美国人口百分比。这个预测让人难以相信。似乎到 2009 年,美国的每个人都在使用互联网是非常不太可能的,而到 2015 年,美国使用互联网的人口超过140%
更是不可能。
22.11 德克萨斯神枪手谬论。
想象一下,你在德克萨斯的乡村公路上行驶。你看到一座有六个靶子画在上面的谷仓,每个靶子的正中心都有一个子弹孔。“是的,先生,”谷仓的主人说,“我从不失手。” “没错,”他的配偶说,“没有一个德克萨斯州的人比他用刷子更准确。”明白了吗?他开了六枪,然后在他们周围画了靶子。
图 22-12 教授困惑于学生们扔粉笔的准确性。
这一类型的经典案例出现在 2001 年。¹⁷¹它报告了一项研究团队在阿伯丁的皇家康希尔医院发现,“厌食症女性最有可能是在春季或初夏出生……在三月到六月之间,出生的厌食症患者比*均多了13%
,而六月份出生的则多了30%
。”
让我们看看那些在六月份出生的女性的令人担忧的统计数据。该团队研究了446
名被诊断为厌食症的女性,因此每月的*均出生人数略多于37
。这表明六月出生的人数为48 (37
*1.3)
。让我们写一个短程序(图 22-13)来估计这纯属偶然发生的概率。
图 22-13 在六月出生 48 名厌食症患者的概率。
当我们运行june_prob(10000)
时,它打印了。
`Probability of at least 48 births in June = 0.0427`
看起来在六月份出生至少有48
个婴儿的概率大约是4.25%
。所以也许阿伯丁的那些研究者发现了一些东西。嗯,如果他们从假设更多将成为厌食症患者的婴儿在六月份出生开始,然后进行一项旨在检验该假设的研究,那他们可能确实发现了一些东西。
但他们并没有这样做。相反,他们查看了数据,然后模仿德克萨斯神枪手,在六月周围画了一个圈。正确的统计问题是,在12
个月中,至少有48
名婴儿出生的概率是什么。图 22-14 中的程序回答了这个问题。
图 22-14 48 名厌食症患者出生于某个月的概率
调用any_prob(10000)
打印
`Probability of at least 48 births in some month = 0.4357`
看起来,研究中报告的结果并非如此不太可能,这些结果反映了一个偶然事件,而不是出生月份与厌食症之间的真实关联。人们不必来自德克萨斯州就能成为德克萨斯神枪手谬论的受害者。
结果的统计显著性取决于实验的进行方式。如果阿伯丁团队最初假设在六月出生的厌食症患者更多,那么他们的结果是值得考虑的。但如果他们假设存在一个异常比例的厌食症患者出生的月份,那么他们的结果就不是很有说服力。实际上,他们是在测试多个假设并选择性地挑选结果。他们可能应该应用 Bonferroni 校正(见第 21.6 节)。
阿伯丁团队可能采取了什么后续步骤来验证他们新发现的假设?一种可能性是进行前瞻性研究。在前瞻性研究中,研究者从一组假设开始,在研究对象发展出感兴趣的结果(在本例中为厌食症)之前招募对象,然后在一段时间内跟踪这些对象。如果该小组进行了具有特定假设的前瞻性研究,并得到了类似的结果,我们可能会信服。
前瞻性研究可能既昂贵又耗时。在回顾性研究中,必须以减少获取误导性结果的方式分析现有数据。一种常见的技术,如第 20.4 节所述,是将数据分成训练集和保留测试集。例如,他们可以随机选择446/2
名女性作为训练集,并统计每个月的出生人数。然后,他们可以将这与剩余女性(保留集)每个月的出生人数进行比较。
22.12 百分比可能会引起混淆
一位投资顾问打电话给客户,报告他的股票投资组合在上个月上涨了16%
。顾问承认,过去一年中有一些起伏,但很高兴地报告*均每月变动为+0.5%
。想象一下,当客户收到一年的报表并注意到他的投资组合价值在一年内下降时的惊讶。
他打电话给他的顾问,并指责他是个骗子。“在我看来,”他说,“我的投资组合下降了大约8%
,而你告诉我它上涨了0.5%
每月。” “我没有,”财务顾问回答。“我告诉你*均每月变化是+0.5%
。”当他检查每月的账单时,投资者意识到他并没有被欺骗,只是被误导了。他的投资组合在上半年每个月下降了15%
,然后在下半年每个月上涨了16%
。
在考虑百分比时,我们始终需要关注计算百分比的基数。在这种情况下,15%
的下降是基于一个比16%
的上升更高的*均基础。
当应用于小基数时,百分比可能特别具有误导性。你可能会看到关于某种药物的报道,称其副作用使某种疾病的发生率增加了200%
。但如果该疾病的基线发生率非常低,比如1,000,000
中有一个,你可能会认为服用该药物的风险被其积极效果所抵消。
手指练习:2020 年 5 月 19 日,纽约时报报道称美国航空旅行在一个月内增加了 123%(从 95,161 名乘客增至 212,508 名乘客)。它还报道了这一增长是在最*一次航空旅行下降 96%之后发生的。那么总的净百分比变化是多少?
22.13 回归谬误
回归谬误发生在当人们未能考虑事件的自然波动时。
所有运动员都有好日子和坏日子。当他们有好日子时,他们会尽量不做任何改变。然而,当他们经历一系列异常糟糕的日子时,他们通常会尝试进行改变。即使这些改变实际上并没有帮助,均值回归(第 17.3 节)使得在接下来的几天里,运动员的表现可能会比之前异常糟糕的表现更好。这可能会误导运动员,以为有治疗效果,即将改善的表现归因于他或她所做的改变。
诺贝尔奖得主心理学家丹尼尔·卡尼曼讲述了一个故事,关于一位以色列空军飞行教官拒绝了卡尼曼的主张,即“对改善表现的奖励比对错误的惩罚更有效。”教官的论点是:“我曾多次表扬飞行学员清晰执行某些特技动作。下次他们尝试相同动作时,通常会表现得更糟。另一方面,我常常对学员的错误执行大声训斥,通常他下次会表现得更好。” ¹⁷² 人类自然会想象治疗效果,因为我们喜欢因果推理。但有时这仅仅是运气问题。
想象一个不存在的治疗效果可能是危险的。这可能导致人们相信疫苗有害,蛇油能治愈所有疼痛,或仅投资于“去年超过市场”的基金是一个好策略。
22.14 统计显著差异可能是微不足道的
在毛伊岛科技学院(MIT),一位招生官希望向世界证明 MIT 的招生过程是“性别盲”的,宣称:“在 MIT,男性和女性的*均成绩没有显著差异。”同一天,一位热情的女性沙文主义者宣称“在 MIT,女性的*均成绩显著高于男性。”一位困惑的学生报社记者决定调查数据以揭露这个说谎者。但当她最终设法从大学获得数据时,她得出结论:两者都在说真话。
句子“在麻省理工学院,女性的*均成绩显著高于男性”实际上意味着什么?没有学习过统计学的人(大多数人群)可能会得出女性和男性的 GPA 之间存在“有意义”的差异。相比之下,最*学习过统计的人可能只会得出 1)女性的*均 GPA 高于男性的*均 GPA,2)可以在5%
水*上拒绝因随机性导致的 GPA 差异的零假设。
假设,例如,有2,500
名女性和2,500
名男性在麻省理工学院学习。进一步假设男性的*均 GPA 为3.5
,女性的*均 GPA 为3.51
,男性和女性的 GPA 标准差均为0.25
。大多数理智的人会认为 GPA 的差异“微不足道”。然而,从统计学的角度看,这一差异在接*2%
水*上是“显著的”。这种奇怪二分法的根源是什么?正如我们在第 21.5 节中所示,当研究具有足够的效能——即足够的样本时,即使微不足道的差异也可以统计显著。
当研究规模非常小的时候,相关问题就会出现。假设你投了两次硬币,结果都是正面。现在,让我们使用在第 21.3 节中看到的双尾单样本 t 检验来检验硬币是公正的零假设。如果我们假设正面的值为1
,反面的值为0
,我们可以使用代码得到 p 值。
`scipy.stats.ttest_1samp([1, 1], 0.5)[1]`
它返回一个 p 值为0
,这表明如果硬币是公正的,那么得到两个连续正面的概率为零。如果我们采用贝叶斯方法,从硬币是公正的先验出发,我们会得到不同的答案。
22.15 只需小心
填充几百页统计滥用的历史会很简单,也很有趣。但现在你可能已经明白:用数字撒谎和用文字撒谎一样简单。在你得出结论之前,确保你了解实际测量的内容以及那些“统计显著”结果是如何计算的。正如诺贝尔经济学奖获得者罗纳德·科斯所说:“如果你足够折磨数据,它会承认任何事情。”
22.16 本章引入的术语
垃圾进垃圾出(GIGO)
独立性假设
条形图
相关性
因果关系
潜在变量
非应答偏差
便利性(偶然)抽样
感染致死率
病例致死率
精挑细选
前瞻性研究
回顾性研究
回归谬误
治疗效果
第二十三章:使用 PANDAS 探索数据
本书后半部分的大部分内容都集中在构建各种计算模型上,这些模型可以用来从数据中提取有用的信息。在接下来的章节中,我们将快速了解使用机器学习从数据构建模型的简单方法。
然而,在这样做之前,我们将查看一个流行的库,它可以快速帮助我们熟悉数据集,然后再深入到更详细的分析中。Pandas¹⁷³ 是建立在 numpy 之上的。Pandas 提供了促进
组织数据
计算数据的简单统计
以便于未来分析的格式存储数据
23.1 DataFrames 和 CSV 文件
Pandas 中的一切都是围绕 DataFrame 类型构建的。DataFrame 是一个可变的二维表格数据结构,具有标记的轴(行和列)。可以将其视为一种增强版的电子表格。
虽然可以使用 Python 代码从头构建 DataFrame,但创建 DataFrame 更常见的方法是通过读取 CSV 文件。正如我们在第十九章中看到的,CSV 文件的每一行由一个或多个值组成,这些值由逗号分隔。¹⁷⁴ CSV 文件通常用于以纯文本格式存储表格数据。在这种情况下,行通常具有相同数量的字段。由于它们是纯文本,因此经常用于将数据从一个应用程序移动到另一个应用程序。例如,大多数电子表格程序允许用户将电子表格的内容写入 CSV 文件。
图 23-1 显示了一个 DataFrame,其中包含关于 2019 年 FIFA 女子世界杯后期轮次的信息。每一列代表一个称为 series 的东西。每一行都有一个 index 相关联。默认情况下,索引是连续的数字,但它们并不一定需要如此。每一列都有一个 name 相关联。正如我们将看到的,这些名称的作用类似于字典中的键。
图 23-1 一个绑定到变量 wwc 的示例 Pandas DataFrame
在 图 23-1 中显示的 DataFrame 是使用下面的代码和 图 23-2 中所示的 CSV 文件生成的。
import pandas as pd
`wwc = pd.read_csv('wwc2019_q-f.csv') print(wwc)`
图 23-2 一个示例 CSV 文件
在导入 Pandas 后,代码使用 Pandas 的函数 read_csv
来读取 CSV 文件,然后以 图 23-1 中所示的表格形式打印出来。如果 DataFrame 具有大量的行或列,print
将在 DataFrame 的中心用省略号替换列和/或行。这可以通过首先使用 DataFrame 方法 to_string
将 DataFrame 转换为字符串来避免。
一行索引和一个列标签一起指示一个数据单元(如在电子表格中)。我们将在第 23.3 节讨论如何访问单个单元和单元组。通常,但并不总是,列中的单元格都是同一种类型。在 图 23-1 的 DataFrame 中,Round
、Winner
和 Loser
列中的每个单元格都是 str
类型。W Goals
和 L Goals
列中的单元格是 numpy.int64
类型。如果你将它们视为 Python 的整型,你就不会有问题。
我们可以直接使用属性 index
、columns
和 values
访问 DataFrame 的三个组成部分。
index
属性的类型为 RangeIndex
。例如,wwc.index
的值为 RangeIndex(start=0, stop=8, step=1)
。因此,代码
for i in wwc.index:
print(i)
将按升序打印整数 0-7。
columns
属性的类型为 Index
。例如,值 wwc.columns
是 Index(['Round', 'Winner', 'W Goals', 'Loser', 'L Goals'], dtype='object')
,而代码
for c in wwc.columns:
print(c)
打印
Round
Winner
W Goals
Loser
L Goals
values
属性的类型为 numpy.ndarray
。在第十三章中,我们介绍了类型 numpy.array
。事实证明,array
是 ndarray
的特殊情况。虽然数组是一维的(像其他序列类型一样),但 ndarrays 可以是多维的。ndarray 的维数和项目数称为其 shape,并用一个表示每个维度大小的非负整数元组表示。值 wwc.values
是二维 ndarrays
[['Quarters' 'England' 3 'Norway' 0]
['Quarters' 'USA' 2 'France' 1]
['Quarters' 'Netherlands' 2 'Italy' 0]
['Quarters' ‘Sweden' 2 'Germany' 1]
['Semis' 'USA' 2 'England' 1]
['Semis' 'Netherlands' 1 ‘Sweden' 0]
['3rd Place' ‘Sweden' 2 'England' 1]
['Championship' 'USA' 2 'Netherlands' 0]]
由于它有八行五列,因此它的形状为 (8, 5)
。
23.2 创建系列和 DataFrame
实际上,Pandas 的 DataFrame 通常是通过加载存储为 SQL 数据库、CSV 文件或与电子表格应用程序相关的格式的数据集来创建的。然而,有时使用 Python 代码构建系列和 DataFrame 是有用的。
表达式 pd.DataFrame()
生成一个空的 DataFrame,而语句 print(pd.DataFrame())
产生的输出
Empty DataFrame
Columns: []
Index: []
创建一个非空 DataFrame 的简单方法是传入一个列表。例如,代码
rounds = ['Semis', ‘Semis', '3rd Place', 'Championship']
print(pd.DataFrame(rounds))
打印
0
0 Semis
1 Semis
2 3rd Place
3 Championship
请注意,Pandas 为 DataFrame 的唯一一列自动生成了一个标签,尽管这个标签并不是特别描述性。为了获得更具描述性的标签,我们可以传入一个字典而不是一个列表。例如,代码 print(pd.DataFrame({'Round': rounds}))
会打印
Round
0 Semis
1 Semis
2 3rd Place
3 Championship
要直接创建一个具有多列的 DataFrame,我们只需传入一个包含多个条目的字典,每个条目由一个列标签作为键和一个与每个键相关联的列表作为值。这些列表必须具有相同的长度。例如,代码
rounds = ['Semis', ‘Semis', '3rd Place', 'Championship']
teams = ['USA', 'Netherlands', ‘Sweden', 'USA']
df = pd.DataFrame({'Round': rounds, 'Winner': teams})
print(df)
打印
Round Winner
0 Semis USA
1 Semis Netherlands
2 3rd Place Sweden
3 Championship USA
一旦创建了 DataFrame,就很容易添加列。例如,语句 df['W Goals'] = [2, 1, 0, 0]
会修改 df
,使其值变为
Round Winner W Goals
0 Semis USA 2
1 Semis Netherlands 1
2 3rd Place Sweden 0
3 Championship USA 0
就像字典中与键关联的值可以被替换一样,与列关联的值也可以被替换。例如,在执行语句df['W Goals'] = [2, 1, 2, 2]
后,df
的值变为
Round Winner W Goals
0 Semis USA 2
1 Semis Netherlands 1
2 3rd Place Sweden 2
3 Championship USA 2
从 DataFrame 中删除列也是很简单的。函数调用print(df.drop('Winner', axis = 'columns'))
会打印出
Round W Goals
0 Semis 2
1 Semis 1
2 3rd Place 2
3 Championship 2
并且df
保持不变。如果在调用drop
时没有包含axis = 'columns'
(或等效地axis = 1
),则轴将默认为'rows'
(等效于axis = 0
),这将导致生成异常KeyError: "['Winner'] not found in axis."
如果一个 DataFrame 很大,以这种方式使用drop
是低效的,因为这需要复制 DataFrame。通过将drop
的inplace
关键字参数设置为True
,可以避免复制。调用df.drop('Winner', axis = 'columns', inplace = True)
会修改df
并返回None
。
可以使用DataFrame
构造函数将行添加到 DataFrame 的开头或末尾,然后使用concat
函数将新 DataFrame 与现有 DataFrame 组合。例如,代码
quarters_dict = {'Round': ['Quarters']*4,
'Winner': ['England', 'USA', 'Netherlands', ‘Sweden'],
'W Goals': [3, 2, 2, 2]}
df = pd.concat([pd.DataFrame(quarters_dict), df], sort = False)
将df
设置为
Round Winner W Goals
0 Quarters England 3
1 Quarters USA 2
2 Quarters Netherlands 2
3 Quarters Sweden 2
0 Semis USA 2
1 Semis Netherlands 1
2 3rd Place Sweden 2
3 Championship USA 2
如果将关键字参数sort
设置为True
,concat
也会根据列标签的字典序改变列的顺序。也就是说,
pd.concat([pd.DataFrame(quarters_dict), df], sort = True)
交换最后两列的位置并返回 DataFrame。
Round W Goals Winner
0 Quarters 3 England
1 Quarters 2 USA
2 Quarters 2 Netherlands
3 Quarters 2 Sweden
0 Semis 2 USA
1 Semis 1 Netherlands
2 3rd Place 2 Sweden
3 Championship 2 USA
如果没有提供sort
的值,则默认为False
。
注意每个连接的 DataFrame 的索引保持不变。因此,会有多个具有相同索引的行。可以使用reset_index
方法重置索引。例如,表达式df.reset_index(drop = True)
的值为
Round Winner W Goals
0 Quarters England 3
1 Quarters USA 2
2 Quarters Netherlands 2
3 Quarters Sweden 2
4 Semis USA 2
5 Semis Netherlands 1
6 3rd Place Sweden 2
7 Championship USA 2
如果reset_index
被调用时drop = False
,则会向 DataFrame 添加一个包含旧索引的新列。该列被标记为index
。
你可能会想知道为什么 Pandas 甚至允许重复索引。原因是使用语义上有意义的索引来标记行通常是有帮助的。例如,df.set_index('Round')
的值为
Winner W Goals
Round
Quarters England 3
Quarters USA 2
Quarters Netherlands 2
Quarters Sweden 2
Semis USA 2
Semis Netherlands 1
3rd Place Sweden 2
Championship USA 2
23.3 选择列和行
对于 Python 中的其他复合类型,方括号是选择 DataFrame 部分的主要机制。要选择 DataFrame 的单列,只需将列的标签放在方括号之间。例如,wwc['Winner']
的值为
0 England
1 USA
2 Netherlands
3 Sweden
4 USA
5 Netherlands
6 Sweden
7 USA
该对象的类型是**Series**
,即它不是 DataFrame。Series 是一维值序列,每个值都有一个索引标签。要从 Series 中选择单个项目,我们在系列后面的方括号中放入一个索引。因此,wwc['Winner'][3]
的值为字符串Sweden
。
我们可以使用for
循环遍历一个序列。例如,
winners = ''
for w in wwc['Winner']:
winners += w + ','
print(winners[:-1])
打印出England,USA,Netherlands,Sweden,USA,Netherlands,Sweden,USA
。
指尖练习: 编写一个函数,返回获胜者所进球的总和。
方括号也可以用来从 DataFrame 中选择多个列。这是通过在方括号内放置列标签的列表来完成的。这将产生一个 DataFrame 而不是 Series。例如,wwc[['Winner', 'Loser']]
产生的 DataFrame 为:
Winner Loser
0 England Norway
1 USA France
2 Netherlands Italy
3 Sweden Germany
4 USA England
5 Netherlands Sweden
6 Sweden England
7 USA Netherlands
选择方括号中的标签列表的顺序不必与原始 DataFrame 中的标签顺序相同。这使得通过选择来重新组织 DataFrame 变得方便。例如,wwc[['Round','Winner','Loser','W Goals','L Goals']]
返回的 DataFrame 为:
Round Winner Loser W Goals L Goals
0 Quarters England Norway 3 0
1 Quarters USA France 2 1
2 Quarters Netherlands Italy 2 0
3 Quarters Sweden Germany 2 1
4 Semis USA England 2 1
5 Semis Netherlands Sweden 1 0
6 3rd Place Sweden England 2 1
7 Championship USA Netherlands 2 0
请注意,尝试通过将行的索引放入方括号中来选择一行将不起作用。这将产生一个KeyError
异常。然而,奇怪的是,我们可以通过切片选择行。因此,虽然wwc[1]
会导致异常,wwc[1:2]
会产生一个包含单行的 DataFrame。
Round Winner W Goals Loser L Goals
1 Quarters USA 2 France 1
我们将在下一小节讨论其他选择行的方法。
23.3.1 使用 loc 和 iloc 进行选择
**loc**
方法可以用于从 DataFrame 中选择行、列或行列的组合。重要的是,所有选择都是通过标签进行的。这一点值得强调,因为某些标签(例如索引)看起来可能像数字。
如果df
是一个 DataFrame,则表达式df.loc[label]
返回与df
中label
关联的行对应的 Series。例如,wwc.loc[3]
返回的 Series 为:
Round Quarters
Winner Sweden
W Goals 2
Loser Germany
L Goals 1
请注意,wwc
的列标签是 Series 的索引标签,与这些标签相关联的值是wwc
中标签为3
的行对应列的值。
要选择多行,我们只需在.loc
后面的方括号内放置标签列表(而不是单个标签)。这样做时,表达式的值是一个 DataFrame 而不是一个 Series。例如,表达式wwc.loc[[1,3,5]]
产生:
Round Winner W Goals Loser L Goals
1 Quarters USA 2 France 1
3 Quarters Sweden 2 Germany 1
5 Semis Netherlands 1 Sweden 0
请注意,新 DataFrame 中每一行的索引是旧 DataFrame 中该行的索引。
切片提供了另一种选择多行的方法。一般形式为df.loc[first:last:step]
。如果未提供first
,则默认为 DataFrame 中的第一个索引。如果未提供last
,则默认为 DataFrame 中的最后一个索引。如果未提供step
,则默认为1
。表达式wwc.loc[3:7:2]
产生的 DataFrame 为:
Round Winner W Goals Loser L Goals
3 Quarters Sweden 2 Germany 1
5 Semis Netherlands 1 Sweden 0
7 Championship USA 2 Netherlands 0
作为一名 Python 程序员,你可能会惊讶于标签为7
的行被包含在内。对于其他 Python 数据容器(如列表),切片时最后一个值会被排除,但对于 DataFrame 则不是这样。¹⁷⁵ 表达式wwc.loc[6:]
产生的 DataFrame 为:
Round Winner W Goals Loser L Goals
6 3rd Place Sweden 2 England 1
7 Championship USA 2 Netherlands 0
表达式wwc.loc[:2]
产生:
Round Winner W Goals Loser L Goals
0 Quarters England 3 Norway 0
1 Quarters USA 2 France 1
2 Quarters Netherlands 2 Italy 0
指尖练习: 写一个表达式,选择wwc
中所有偶数编号的行。
如前所述,loc
可以用来同时选择行和列的组合。这是通过类似以下形式的表达式完成的:
df.loc[*row_selector*, *column_selector*]
行和列选择器可以使用之前讨论过的任何机制编写,即单个标签、标签列表或切片表达式。例如,wwc.loc[0:2, 'Round':'L Goals':2]
生成
Round W Goals L Goals
0 Quarters 3 0
1 Quarters 2 1
2 Quarters 2 0
手指练习: 写一个生成数据框的表达式
Round Winner W Goals Loser L Goals
1 Quarters USA 2 France 1
2 Quarters Netherlands 2 Italy 0
到目前为止,如果你把索引标签视为整数,你是不会错的。让我们看看当 1)标签不是数字型,2)有多行具有相同标签时,选择是如何工作的。让wwc_by_round
成为数据框
Winner W Goals Loser L Goals
Round
Quarters England 3 Norway 0
Quarters USA 2 France 1
Quarters Netherlands 2 Italy 0
Quarters Sweden 2 Germany 1
Semis USA 2 England 1
Semis Netherlands 1 Sweden 0
3rd Place Sweden 2 England 1
Championship USA 2 Netherlands 0
你认为表达式wwc_by_round.loc['Semis']
的结果是什么?它选择所有标签为Semis
的行,以返回
Winner W Goals Loser L Goals
Round
Semis USA 2 England 1
Semis Netherlands 1 Sweden 0
同样,wwc_by_round.loc[['Semis', 'Championship']]
选择所有标签为Semis
或Championship
的行:
Winner W Goals Loser L Goals
Round
Semis USA 2 England 1
Semis Netherlands 1 Sweden 0
Championship USA 2 Netherlands 0
切片也适用于非数字索引。表达式
`wwc_by_round.loc['Quarters':'Semis':2]`
通过选择标记为Quarters
的第一行,然后选择每隔一行,直到经过标记为Semis
的行,生成
Winner W Goals Loser L Goals
Round
Quarters England 3 Norway 0
Quarters Netherlands 2 Italy 0
Semis USA 2 England 1
现在,假设我们想选择标记为Quarters
的第二行和第三行。我们不能简单地写wwc_by_round.loc['Quarters']
,因为那样会选择所有四行标记为Quarters
。使用iloc
方法。
**iloc**
方法类似于loc
,但它是基于整数而不是标签(因此有i
在iloc
中)。数据框的第一行是iloc 0
,第二行为iloc 1
,依此类推。因此,要选择标记为Quarters
的第二行和第三行,我们写wwc_by_round.iloc[[1,2]]
。
23.3.2 按组选择
将数据框分成子集,并对每个子集分别应用一些聚合或转换,通常很方便。groupby
方法使得这种操作变得简单。
假设,例如,我们想知道每轮中获胜和失利球队总进球数。代码
grouped_by_round = wwc.groupby('Round')
将group_by_round
绑定到类型为DataFrameGroupBy
的对象。我们可以对该对象应用聚合器sum
以生成一个数据框。代码
grouped_by_round = wwc.groupby('Round')
print(grouped_by_round.sum())
打印
W Goals L Goals
Round
3rd Place 2 1
Championship 2 0
Quarters 9 2
Semis 3 1
代码print(wwc.groupby('Winner').mean())
打印出
W Goals L Goals
Winner
England 3.0 0.000000
Netherlands 1.5 0.000000
Sweden 2.0 1.000000
USA 2.0 0.666667
从中我们可以很容易地看出,英格兰在赢得的比赛中*均进了三球,同时零封了对手。
代码print(wwc.groupby(['Loser', 'Round']).mean())
打印出
` W Goals L Goals Loser Round England 3rd Place 2 1 Semis 2 1 France Quarters 2 1 Germany Quarters 2 1 Italy Quarters 2 0 Netherlands Championship 2 0 Norway Quarters 3 0 Sweden Semis 1 0`
从中我们可以很容易地看出,英格兰在输掉的比赛中*均进了一球,而丢了两个。
23.3.3 按内容选择
假设我们想从数据框中选择瑞典赢得的所有比赛的行,如图 23-1。由于这个数据框比较小,我们可以查看每一行并找到对应比赛的行索引。当然,这种方法不适用于大型数据框。幸运的是,使用称为布尔索引的东西可以轻松根据内容选择行。
基本思想是编写一个逻辑表达式,引用 DataFrame 中的值。该表达式随后在 DataFrame 的每一行上进行评估,评估结果为 True
的行将被选中。表达式 wwc.loc[wwc['Winner'] == 'Sweden']
评估为 DataFrame
Round Winner W Goals Loser L Goals
3 Quarters Sweden 2 Germany 1
6 3rd Place Sweden 2 England 1
提取所有涉及瑞典的比赛稍微复杂一些。逻辑运算符 **&**
(对应于与),**|**
(对应于或)和 **–**
(对应于非)可用于形成表达式。表达式 wwc.loc[(wwc['Winner'] == 'Sweden') | (wwc['Loser'] == 'Sweden')]
返回
Round Winner W Goals Loser L Goals
3 Quarters Sweden 2 Germany 1
5 Semis Netherlands 1 Sweden 0
6 3rd Place Sweden 2 England 1
请注意,逻辑表达式两个子项周围的括号是必要的,因为在 Pandas 中,|
的优先级高于 ==
。
练习: 编写一个表达式,返回一个包含美国参赛但法国没有参赛的比赛的 DataFrame。
如果我们预计会进行许多查询以选择某个国家参与的比赛,定义一个函数可能会很方便。
def get_country(df, country):
"""df a DataFrame with series labeled Winner and Loser
country a str
returns a DataFrame with all rows in which country appears
in either the Winner or Loser column"""
return df.loc[(df['Winner'] == country) | (df['Loser'] == country)]
由于 get_country
返回一个 DataFrame,因此通过组合两次调用 get_country
很容易提取成对球队之间的比赛。例如,评估 get_country(get_country(wwc, 'Sweden'),'Germany')
提取了这两支球队之间的一场比赛(球队在淘汰赛阶段最多相互对阵一次)。
假设我们想要对 get_country
进行概括,使其接受国家列表作为参数并返回列表中任何国家参加的所有比赛。我们可以使用 isin
方法来实现:
def get_games(df, countries):
return df[(df['Winner'].isin(countries)) |
(df['Loser'].isin(countries))]
isin
方法通过在指定列中选择仅包含指定值(或指定值集合中的元素)的行来过滤 DataFrame。在 get_games
的实现中,表达式 df['Winner'].isin(countries)
选择了 df
中 Winner
列包含 countries
列表中的元素的行。
练习: 打印一个只包含瑞典与德国或荷兰比赛的 DataFrame。
23.4 在 DataFrame 中操作数据
我们现在已经查看了一些创建和选择 DataFrame 部分的简单方法。创建 DataFrame 的一个原因是能够轻松提取聚合信息。让我们首先看看如何从 DataFrame wwc
中提取聚合信息,如 图 23-1 所示。
DataFrame 的列可以以类似于我们对 numpy 数组操作的方式进行操作。例如,类似于表达式 2*np.array([1,2,3])
评估为数组 [2 4 6]
,表达式 2*wwc['W Goals']
评估为系列
0 6
1 4
2 4
3 4
4 4
5 2
6 4
7 4
表达式 wwc['W Goals'].sum()
对 W Goals
列中的值进行求和,得到值 16
。类似地,表达式
(wwc[wwc['Winner'] == ‘Sweden']['W Goals'].sum() +
wwc[wwc['Winner'] == ‘Sweden']['L Goals'].sum())
计算瑞典队进球总数为 6
,并且表达式
`(wwc['W Goals'].sum() - wwc['L Goals'].sum())/len(wwc['W Goals'])`
计算 DataFrame 中比赛的*均进球差为 1.5
。
手指练习: 写一个表达式,计算所有轮次中进球的总数。
手指练习: 写一个表达式,计算四分之一决赛中输球队进球的总数。
假设我们想添加一列,包含所有比赛的进球差异,并添加一行,总结所有包含数字的列的总计。添加列很简单。我们只需执行wwc['G Diff'] = wwc['W Goals'] - wwc['L Goals']
。添加行则更复杂。我们首先创建一个包含所需行内容的字典,然后使用该字典创建一个只包含新行的新数据框。接着,我们使用concat
函数将wwc
和新数据框连接起来。
#Add new column to wwc
wwc['G Diff'] = wwc['W Goals'] - wwc['L Goals']
#create a dict with values for new row
new_row_dict = {'Round': ['Total'],
'W Goals': [wwc['W Goals'].sum()],
'L Goals': [wwc['L Goals'].sum()],
'G Diff': [wwc['G Diff'].sum()]}
#Create DataFrame from dict, then pass it to concat
new_row = pd.DataFrame(new_row_dict)
wwc = pd.concat([wwc, new_row], sort = False).reset_index(drop = True)
这段代码生成了数据框。
Round Winner W Goals Loser L Goals G Diff
0 Quarters England 3 Norway 0 3
1 Quarters USA 2 France 1 1
2 Quarters Netherlands 2 Italy 0 2
3 Quarters Sweden 2 Germany 1 1
4 Semis USA 2 England 1 1
5 Semis Netherlands 1 Sweden 0 1
6 3rd Place Sweden 2 England 1 1
7 Championship USA 2 Netherlands 0 2
8 Total NaN 16 NaN 4 12
注意,当我们尝试对不包含数字的列中的值求和时,Pandas 没有产生异常。相反,它提供了特殊值NaN
(非数字)。
除了提供简单的算术操作,如求和和均值,Pandas 还提供计算各种有用统计函数的方法。其中最有用的是corr
,用于计算两个系列之间的相关性。
相关性是一个介于-1 和 1 之间的数字,提供有关两个数值之间关系的信息。正相关表明一个变量的值增加时,另一个变量的值也增加;负相关表明一个变量的值增加时,另一个变量的值减少。相关性为零表示变量之间没有关系。
最常用的相关性度量是皮尔逊相关性。皮尔逊相关性衡量两个变量之间线性关系的强度和方向。除了皮尔逊相关性外,Pandas 还支持其他两种相关性度量,斯皮尔曼和肯德尔。这三种度量之间存在重要差异(例如,斯皮尔曼对异常值的敏感性低于皮尔逊,但仅对单调关系有用),但讨论何时使用哪一种超出了本书的范围。
要打印W Goals
、L Goals
和G Diff
的皮尔逊成对相关性(并排除包含总计的行),我们只需执行。
print(wwc.loc[wwc['Round'] != ‘Total'].corr(method = 'pearson'))
这产生了。
W Goals L Goals G Diff
W Goals 1.000000 0.000000 0.707107
L Goals 0.000000 1.000000 -0.707107
G Diff 0.707107 -0.707107 1.000000
对角线上的值都是 1,因为每个系列与自身完全正相关。不出所料,进球差异与获胜队的进球数强正相关,而与输球队的进球数强负相关。获胜者和输者进球之间较弱的负相关在职业足球中也有其道理。¹⁷⁶
23.5 扩展示例
在本节中,我们将查看两个数据集,一个包含 21 个美国城市的历史温度数据,另一个包含全球化石燃料使用的历史数据。
23.5.1 温度数据
代码
pd.set_option('display.max_rows', 6)
pd.set_option('display.max_columns', 5)
temperatures = pd.read_csv('US_temperatures.csv')
print(temperatures)
打印
Date Albuquerque ... St Louis Tampa
0 19610101 -0.55 ... -0.55 15.00
1 19610102 -2.50 ... -0.55 13.60
2 19610103 -2.50 ... 0.30 11.95
... ... ... ... ...
20085 20151229 -2.15 ... 1.40 26.10
20086 20151230 -2.75 ... 0.60 25.55
20087 20151231 -0.75 ... -0.25 25.55
[20088 rows x 22 columns]
前两行代码设置了默认选项,以限制打印 DataFrame 时显示的行数和列数。这些选项的作用类似于我们用于设置各种绘图默认值的rcParams
。函数reset_option
可以用来将选项恢复为系统默认值。
这个 DataFrame 以一种便于查看特定日期不同城市天气的方式组织。例如,查询
temperatures.loc[temperatures['Date']==19790812][['New York','Tampa']]
告诉我们在 1979 年 8 月 12 日,纽约的温度为 15°C,坦帕为 25.55°C。
手指练习: 写一个表达式,如果 2000 年 10 月 31 日凤凰城比坦帕温暖则评估为True
,否则评估为False
。
手指练习: 编写代码提取凤凰城温度为 41.4°C 的日期。¹⁷⁷
不幸的是,查看 21 个城市在 20,088 个日期的数据并不能直接洞察与温度趋势相关的大问题。让我们开始添加提供每日温度汇总信息的列。代码
temperatures['Max T'] = temperatures.max(axis = 'columns')
temperatures['Min T'] = temperatures.min(axis = 'columns')
temperatures['Mean T'] = round(temperatures.mean(axis = 'columns'), 2)
print(temperatures.loc[20000704:20000704])
打印
Date Albuquerque ... Min T Mean T
14429 20000704 26.65 ... 15.25 1666747.37
2000 年 7 月 4 日,那 21 个城市的*均温度真的比太阳表面的温度高很多吗?可能不是。更可能的是我们的代码存在 bug。问题在于我们的 DataFrame 将日期编码为数字,而这些数字用于计算每行的*均值。从概念上讲,考虑日期作为温度系列的索引可能更有意义。因此,让我们将 DataFrame 中的日期改为索引。代码
temperatures.set_index('Date', drop = True, inplace = True)
temperatures['Max'] = temperatures.max(axis = 'columns')
temperatures['Min'] = temperatures.min(axis = 'columns')
temperatures['Mean T'] = round(temperatures.mean(axis = 'columns'), 2)
print(temperatures.loc[20000704:20000704])
打印更可信的
Albuquerque Baltimore ... Min T Mean T
Date ...
20000704 26.65 25.55 ... 15.25 24.42
顺便提一下,由于Date
不再是列标签,我们不得不使用不同的打印语句。我们为什么要用切片选择单行?因为我们想创建一个 DataFrame 而不是一个系列。
我们现在可以开始绘制一些显示各种趋势的图表。例如,
plt.figure(figsize = (14, 3)) #set aspect ratio for figure
plt.plot(list(temperatures['Mean T']))
plt.title('Mean Temp Across 21 US Cities')
plt.xlabel('Days Since 1/1/1961')
plt.ylabel('Degrees C')
生成的图表显示了美国温度的季节性。请注意,在绘制*均温度之前,我们将系列转换为列表。如果直接绘制系列,它将使用系列的索引(代表日期的整数)作为 x 轴。这将产生一种相当奇怪的图,因为 x 轴上的点会奇怪地间隔。例如,1961 年 12 月 30 日和 1961 年 12 月 31 日之间的距离为 1,但 1961 年 12 月 31 日和 1962 年 1 月 1 日之间的距离为 8870(19620,101 – 19611231)。
通过放大几年的数据并使用调用plt.plot(list(temperatures['Mean T'])[0:3*365])
生成图表,我们可以更清楚地看到季节模式。
在过去的几十年里,关于地球变暖的共识已经形成。让我们看看这些数据是否与这一共识一致。由于我们正在研究一个关于长期趋势的假设,可能不应关注每日或季节性的温度变化。相反,让我们看一下年度数据。
作为第一步,让我们使用temperatures
中的数据构建一个新的数据框,其中行代表年份而不是天。执行此操作的代码包含在图 23-3 和图 23-4 中。大部分工作在函数get_dict
中完成, 图 23-3 返回一个字典,将年份映射到一个字典,该字典给出与不同标签相关的该年份的值。get_dict
的实现使用iterrows
遍历temperatures
中的行。该方法返回一个迭代器,每一行返回一个包含索引标签和该行内容的系列对。可以使用列标签选择生成系列的元素。¹⁷⁸
图 23-3 构建一个将年份映射到温度数据的字典
如果test
是数据框。
Max T Min T Mean T
Date
19611230 24.70 -13.35 3.35
19611231 24.75 -10.25 5.10
19620101 25.55 -10.00 5.70
19620102 25.85 -4.45 6.05
调用get_dict(test, ['Max', 'Min'])
将返回字典。
{'1961': {'Max T': [24.7, 24.75], 'Min T': [-13.35, -10.25], 'Mean T': [3.35, 5.1]}, '1962': {'Max T': [25.55, 25.85], 'Min T': [-10.0, -4.45], 'Mean T': [5.7, 6.05]}}
图 23-4 围绕年份构建数据框
在图 23-4 中调用get_dict
后的代码构建了一个包含出现在temperatures
中的每一年的列表,以及包含这些年的最低、最高和*均温度的附加列表。最后,它使用这些列表构建数据框yearly_temps
:
Year Min T Max T Mean T
0 1961 -17.25 38.05 15.64
1 1962 -21.65 36.95 15.39
2 1963 -24.70 36.10 15.50
.. ... ... ... ...
52 2013 -15.00 40.55 16.66
53 2014 -22.70 40.30 16.85
54 2015 -18.80 40.55 17.54
现在我们已经以便于处理的格式获得了数据,让我们生成一些图表来可视化温度随时间的变化。 图 23-5 中的代码生成了图 23-6 中的图表。
图 23-5 生成与年份相关的温度测量图
图 23-6 年度*均和最低温度
图 23-6 左侧的图表显示了一个不可否认的趋势;¹⁷⁹这 21 个城市的*均温度随着时间的推移而上升。右侧的图表则不那么清晰。极端的年度波动使得很难看出趋势。通过绘制温度的移动*均,可以生成更具启示性的图表。
Pandas 方法rolling
用于对系列的多个连续值执行操作。评估表达式yearly_temps['Min T'].rolling(7).mean()
会生成一个系列,其中前 6 个值为NaN
,对于每个大于 6 的 i,系列中的第 i 个值为yearly_temps['Min'][i-6:i+1]
的*均值。将该系列与年份绘制在一起会生成图 图 23-7,这确实暗示了一个趋势。
图 23-7 滚动*均最低温度
虽然可视化两个系列之间的关系可以提供信息,但通常更有用的是以更定量的方式观察这些关系。让我们先看一下年份与七年滚动*均最低、最高和*均温度之间的相关性。在计算相关性之前,我们首先更新yearly_temps
中的系列以包含滚动*均值,然后将年份值从字符串转换为整数。代码
num_years = 7
for label in ['Min T', 'Max T', 'Mean T']:
yearly_temps[label] = yearly_temps[label].rolling(num_years).mean()
yearly_temps['Year'] = yearly_temps['Year'].apply(int)
print(yearly_temps.corr())
打印
Year Min T Max T Mean T
Year 1.000000 0.713382 0.918975 0.969475
Min T 0.713382 1.000000 0.629268 0.680766
Max T 0.918975 0.629268 1.000000 0.942378
Mean T 0.969475 0.680766 0.942378 1.000000
所有汇总的温度值与年份呈正相关,其中*均温度的相关性最强。这引发了一个问题:年份解释了*均温度滚动*均值方差的多少。以下代码打印决定系数(第 20.2.1 节)。
indices = np.isfinite(yearly_temps['Mean T'])
model = np.polyfit(list(yearly_temps['Year'][indices]),
list(yearly_temps['Mean T'][indices]), 1)
print(r_squared(yearly_temps['Mean T'][indices],
np.polyval(model, yearly_temps['Year'][indices])))
由于Mean
系列中的一些值为NaN
,我们首先使用函数np.isfinite
获取yearly_temps['Mean']
中非NaN
值的索引。然后我们构建一个线性模型,最后使用r_squared
函数(见图 20-13)将模型预测的结果与实际温度进行比较。与年份相关的七年滚动*均温度几乎解释了 94%的方差。
指尖练习: 找到*均年温度而非滚动*均和十年滚动*均的决定系数(r²)。
如果你恰好住在美国或计划前往美国,你可能更有兴趣按城市而不是按年份查看数据。让我们首先生成一个新的 DataFrame,以提供每个城市的汇总数据。为了考虑到我们的美国读者,我们通过对city_temps
中的所有值应用转换函数,将所有温度转换为华氏度。倒数第二行添加了一列,显示温度变化的极端程度。执行此代码会生成 图 23-8 中的 DataFrame。¹⁸⁰
temperatures = pd.read_csv('US_temperatures.csv')
temperatures.drop('Date', axis = 'columns', inplace = True)
means = round(temperatures.mean(), 2)
maxes = temperatures.max()
mins = temperatures.min()
city_temps = pd.DataFrame({'Min T':mins, 'Max T':maxes,
'Mean T':means})
city_temps = city_temps.apply(lambda x: 1.8*x + 32)
city_temps['Max-Min'] = city_temps['Max T'] - city_temps['Min T']
print(city_temps.sort_values('Mean T', ascending = False).to_string())
图 23-8 部分城市的*均温度
为了可视化城市之间的差异,我们使用代码生成了图 图 23-9
plt.plot(city_temps.sort_values('Max-Min', ascending=False)
['Max-Min'], 'o')
plt.figure()
plt.plot(city_temps.sort_values('Max-Min', ascending=False)['Min T'],
'b∧', label = 'Min T')
plt.plot(city_temps.sort_values('Max-Min', ascending=False)['Max T'],
'kx', label = 'Max T')
plt.plot(city_temps.sort_values('Max-Min', ascending=False)['Mean T'],
'ro', label = 'Mean T')
plt.xticks(rotation = ‘vertical')
plt.legend()
plt.title('Variation in Extremal Daily\nTemperature 1961-2015')
plt.ylabel('Degrees F')
请注意,我们对所有三个系列使用了排序顺序Max - Min
。使用ascending = False
会逆转默认的排序顺序。
图 23-9 温度极端变化
看这个图我们可以看到,除了其他内容之外,
在城市之间,最低温度的差异远大于最高温度。因此,最大值减去最小值(排序顺序)与最低温度之间存在强正相关。
旧金山或西雅图从未变得非常炎热。
圣胡安的温度几乎保持不变。
芝加哥的温度并不接*恒定。这个风城的温度既会变得相当炎热,也会变得令人畏惧的寒冷。
凤凰城和拉斯维加斯都变得异常炎热。
旧金山和阿尔伯克基的*均气温大致相同,但最低和最高温度差异显著。
23.5.2 化石燃料消费
文件 global-fossil-fuel-consumption.csv 包含 1965 年至 2015 年地球上化石燃料年消费的数据。代码
emissions = pd.read_csv('global-fossil-fuel-consumption.csv')
print(emissions)
打印
Year Coal Crude Oil Natural Gas
0 1965 16151.96017 18054.69004 6306.370076
1 1966 16332.01679 19442.23715 6871.686791
2 1967 16071.18119 20830.13575 7377.525476
.. ... ... ... ...
50 2015 43786.84580 52053.27008 34741.883490
51 2016 43101.23216 53001.86598 35741.829870
52 2017 43397.13549 53752.27638 36703.965870
现在,让我们将显示每种燃料消费的列替换为两列,一列显示三者的总和,另一列显示五年滚动*均的总和。
emissions['Fuels'] = emissions.sum(axis = 'columns')
emissions.drop(['Coal', 'Crude Oil', 'Natural Gas'], axis = 'columns',
inplace = True)
num_years = 5
emissions['Roll F'] =\
emissions['Fuels'].rolling(num_years).mean()
emissions = emissions.round()
我们可以使用
plt.plot(emissions['Year'], emissions['Fuels'],
label = 'Consumption')
plt.plot(emissions['Year'], emissions['Roll F'],
label = str(num_years) + ' Year Rolling Ave.')
plt.legend()
plt.title('Consumption of Fossil Fuels')
plt.xlabel('Year')
plt.ylabel('Consumption')
要获取图 23-10 中的情节。
图 23-10 全球化石燃料消费
尽管消费量在少数小幅下滑(例如,2008 年金融危机期间),但上升趋势显而易见。
科学界已达成共识,燃料消费的上升与地球*均温度的上升之间存在关联。让我们看看它与我们在 23.5.1 节中查看的 21 个美国城市的温度之间的关系。
请记住,yearly_temps
被绑定到数据框中。
Year Min T Max T Mean T
0 1961 -17.25 38.05 15.64
1 1962 -21.65 36.95 15.39
2 1963 -24.70 36.10 15.50
.. ... ... ... ...
52 2013 -15.00 40.55 16.66
53 2014 -22.70 40.30 16.85
54 2015 -18.80 40.55 17.54
如果有一种简单的方法来组合yearly_temps
和emissions
,那该多好啊?Pandas 的merge
函数正是如此。代码
yearly_temps['Year'] = yearly_temps['Year'].astype(int)
merged_df = pd.merge(yearly_temps, emissions,
left_on = 'Year', right_on = 'Year')
print(merged_df)
打印数据框
Year Min T ... Fuels Roll F
0 1965 -21.7 ... 42478.0 NaN
1 1966 -25.0 ... 44612.0 NaN
2 1967 -17.8 ... 46246.0 NaN
.. ... ... ... ... ...
48 2013 -15.0 ... 131379.0 126466.0
49 2014 -22.7 ... 132028.0 129072.0
50 2015 -18.8 ... 132597.0 130662.0
数据框包含出现在yearly_temps
和emissions
中的列的并集,但仅包括来自yearly_temps
和emissions
中具有相同Year
值的行构建的行。
现在我们在同一个数据框中拥有排放和温度信息,轻松查看它们之间的相关性。代码print(merged_df.corr().round(2).to_string())
打印
Year Min T Max T Mean T Fuels Roll F
Year 1.00 0.37 0.72 0.85 0.99 0.98
Min T 0.37 1.00 0.22 0.49 0.37 0.33
Max T 0.72 0.22 1.00 0.70 0.75 0.66
Mean T 0.85 0.49 0.70 1.00 0.85 0.81
Fuels 0.99 0.37 0.75 0.85 1.00 1.00
Roll F 0.98 0.33 0.66 0.81 1.00 1.00
我们看到,前几年的全球燃料消费确实与这些美国城市的*均温度和最高温度高度相关。这是否意味着增加的燃料消费导致温度上升?并不是。请注意,两者都与年份高度相关。也许某个潜在变量也与年份相关并且是因果因素。从统计学的角度来看,我们可以说,数据并不反驳广泛接受的科学假设,即化石燃料的增加使用产生的温室气体导致温度上升。
这就是我们对 Pandas 的简要介绍。我们只是在它所提供的内容上轻轻触及了一下。我们将在书中后续部分使用它,并介绍更多功能。如果你想了解更多,有许多在线资源以及一些优秀的廉价书籍。网站 [
www.dataschool.io/best-python-pandas-resources/](https://www.dataschool.io/best-python-pandas-resources/)
列出了其中的一些。
23.6 在章节中引入的术语
数据框
行
序列
索引
名称
CSV 文件
ndarray 的形状
布尔索引
序列的相关性
移动(滚动)*均
第二十四章:机器学习速览
世界上的数字数据量以难以人类理解的速度增长。自 1980 年代以来,世界的数据存储能力每三年大约翻一番。在你阅读本章所需的时间里,世界上的数据存储将增加约10
¹⁸位。这么大的数字不容易理解。可以这样想:10
¹⁸个加元便士的表面积大约是地球的两倍。
当然,更多的数据并不总是意味着更有用的信息。进化是一个缓慢的过程,人类心智同化数据的能力并不会每三年翻倍。世界正在使用的一种方法,以期从“大数据”中提取更多有用信息,就是统计机器学习。
机器学习很难定义。从某种意义上说,每个有用的程序都会学习一些东西。例如,牛顿法的实现学习一个多项式的根。美国电气工程师和计算机科学家阿瑟·萨缪尔提出的最早定义之一是,它是一个“使计算机能够在没有明确编程的情况下学习的研究领域”。
人类通过两种方式学习——记忆和概括。我们利用记忆来积累个别事实。例如,在英国,小学生可能会学习一份英王列表。人类使用概括从旧事实推导出新事实。例如,政治学学生可能会观察大量政治家的行为,并从这些观察中推导出所有政治家在竞选时都撒谎。
当计算机科学家谈论机器学习时,他们最常指的是编写程序的学科,这些程序自动学习从数据中的隐含模式中得出有用的推论。例如,线性回归(见第二十章)学习一条曲线,该曲线是一组示例的模型。然后可以使用该模型对以前未见过的示例进行预测。基本范式是
1. 观察一组示例,通常称为训练数据,它表示有关某些统计现象的不完整信息。
2. 使用推理技术创建一个可能生成所观察示例的过程模型。
3. 使用该模型对以前未见过的示例进行预测。
假设,例如,你获得了图 24-1 中的两个名称集合和图 24-2 中的特征向量。
图 24-1 两个名称集合
图 24-2 将特征向量与每个名称关联
向量的每个元素对应于某个方面(即特征)。基于对这些历史人物的有限信息,你可能推断出将标签A
或标签B
分配给每个示例的过程是为了将高个子总统与矮个子总统区分开来。
机器学习有许多方法,但所有方法都试图学习一个提供示例的概括模型。所有方法都有三个组成部分:
模型的一个表示
用于评估模型优劣的目标函数
一种优化方法,用于学习最小化或最大化目标函数值的模型
广义来说,机器学习算法可以分为有监督和无监督两种。
在有监督学习中,我们从一组特征向量/值对开始。目标是从这些对中推导出一个规则,以预测与先前未见的特征向量相关联的值。回归模型将一个实数与每个特征向量关联。分类模型将有限数量的标签中的一个与每个特征向量关联。¹⁸²
在第二十章中,我们看了一种回归模型,即线性回归。每个特征向量是一个 x 坐标,与之相关的值是对应的 y 坐标。通过特征向量/值对的集合,我们学习了一个模型,可以用来预测与任何 x 坐标相关联的 y 坐标。
现在,让我们来看一个简单的分类模型。给定我们在图 24-1 中标记的总统集合A
和B
以及图 24-2 中的特征向量,我们可以生成图 24-3 中的特征向量/标签对。
图 24-3 总统的特征向量/标签对
从这些标记的示例中,学习算法可能推断出所有高个子总统应该标记为A
,所有矮个子总统标记为B
。当被要求为
[American, President, 189 cm.]¹⁸³
它会使用它所学到的规则来选择标签A
。
有监督机器学习广泛应用于诸如检测信用卡欺诈使用和向人们推荐电影等任务。
在无监督学习中,我们获得一组特征向量,但没有标签。无监督学习的目标是揭示特征向量集合中的潜在结构。例如,给定总统特征向量的集合,无监督学习算法可能会将总统分为高个子和矮个子,或者可能分为美国人和法国人。无监督机器学习的方法可以分为聚类方法或学习潜变量模型的方法。
潜变量是一个其值不是直接观察到的变量,但可以从观察到的变量值中推断出来。例如,大学的招生官员试图根据一组可观察的值(如中学成绩和标准化测试成绩)推断申请者成为成功学生的概率(即潜变量)。有许多丰富的方法用于学习潜变量模型,但我们在本书中不讨论这些。
聚类将一组实例划分为多个组(称为簇),使得同一组中的实例彼此之间更相似,而与其他组中的实例则相对不相似。例如,遗传学家使用聚类来寻找相关基因的群体。许多流行的聚类方法出人意料地简单。
我们在第二十五章介绍了一种广泛使用的聚类算法,并在第二十六章讨论了几种监督学习的方法。在本章的其余部分,我们讨论了构建特征向量的过程以及计算两个特征向量之间相似性的不同方法。
24.1 特征向量
信噪比(SNR)的概念在许多工程和科学领域中使用。确切的定义在不同应用中有所不同,但基本思想很简单。将其视为有用输入与无关输入的比率。在餐厅里,信号可能是你约会对象的声音,而噪声则是其他 diners 的声音。如果我们试图预测哪些学生在编程课程中表现良好,之前的编程经验和数学能力将是信号的一部分,而发色则仅仅是噪声。将信号与噪声分离并不总是容易。当做得不够好时,噪声可能会干扰,掩盖信号中的真相。
特征工程的目的是将可用数据中对信号有贡献的特征与仅仅是噪声的特征分开。如果未能做好这项工作,可能导致模型表现不佳。当数据的维度(即不同特征的数量)相对于样本数量较大时,风险特别高。
成功的特征工程能够减少可用信息的庞大数量,使其成为有助于推广的有用信息。例如,想象一下,你的目标是学习一个模型,以预测一个人是否可能会发生心脏病发作。一些特征,比如他们的年龄,可能非常相关。其他特征,比如他们是否是左撇子,可能不太相关。
特征选择技术可以自动识别在给定特征集合中哪些特征最有可能是有帮助的。例如,在监督学习的背景下,我们可以选择与样本标签最强相关的特征。¹⁸⁵然而,如果没有相关特征,这些特征选择技术帮助不大。假设我们心脏病发作示例的原始特征集合包括身高和体重。尽管身高和体重都不是心脏病发作的强预测因素,但身体质量指数(BMI)可能是。虽然 BMI 可以通过身高和体重计算得出,但其关系(体重以千克为单位除以身高以米为单位的*方)过于复杂,无法通过典型的机器学习技术自动发现。成功的机器学习通常涉及领域专家设计特征。
在无监督学习中,问题甚至更困难。通常,我们根据对哪些特征可能与我们希望发现的结构相关的直觉来选择特征。然而,依赖于对特征潜在相关性的直觉是有问题的。你对某人的牙齿历史是否是未来心脏病发作的有用预测因素的直觉有多好?
考虑图 24-4,其中包含特征向量的表格和每个向量相关的标签(爬行动物或非爬行动物)。
图 24-4 各种动物的名称、特征和标签
仅仅通过有关眼镜蛇的信息(即,仅表格的第一行),一个监督机器学习算法(或人类)几乎只能记住眼镜蛇是爬行动物这一事实。现在,让我们添加有关响尾蛇的信息。我们可以开始进行概括,并可能推断出一个规则:如果动物下蛋、有鳞、是毒性、是冷血动物,并且没有腿,则它是爬行动物。
现在,假设我们被要求决定一条巨蟒是否是爬行动物。我们可能会回答“不是”,因为巨蟒既不是毒性动物,也不是下蛋的。然而,这将是错误的答案。当然,试图从两个示例中进行概括而导致错误也并不令人惊讶。一旦我们将巨蟒纳入我们的训练数据,我们可能会形成新的规则:如果动物有鳞、是冷血动物,并且没有腿,则它是爬行动物。在这样做的过程中,我们将特征下蛋
和毒性
视为与分类问题无关而丢弃。
如果我们用新规则来分类鳄鱼,我们错误地得出结论,认为因为它有腿,所以不是爬行动物。一旦我们将鳄鱼纳入训练数据,我们就重新制定规则,允许爬行动物没有腿或有四条腿。当我们观察飞镖蛙时,我们正确得出结论,它不是爬行动物,因为它不是冷血的。然而,当我们用目前的规则来分类鲑鱼时,我们错误地得出结论,认为鲑鱼是爬行动物。我们可以为我们的规则增加更多复杂性,以将鲑鱼与鳄鱼区分开来,但这是徒劳的。没有办法修改我们的规则,使其能够正确分类鲑鱼和蟒蛇,因为这两种物种的特征向量是相同的。
这种问题在机器学习中比比皆是。特征向量中包含足够信息以完美分类的情况非常罕见。在这种情况下,问题在于我们没有足够的特征。
如果我们考虑到爬行动物的卵有羊膜,我们可以制定出一个将爬行动物与鱼类分开的规则。不幸的是,在大多数机器学习的实际应用中,构造允许完美区分的特征向量是不可能的。
这是否意味着我们应该放弃,因为所有可用的特征只是噪声?不。在这种情况下,特征鳞片
和冷血
是成为爬行动物的必要条件,但不是充分条件。如果动物有鳞片且是冷血的,就不可能产生假阴性,即,任何被分类为非爬行动物的动物确实不会是爬行动物。然而,这条规则会产生一些假阳性,即,一些被分类为爬行动物的动物实际上并不是爬行动物。
24.2 距离度量
在图 24-4 中,我们使用四个二元特征和一个整数特征描述了动物。假设我们想用这些特征来评估两种动物的相似性,例如,询问响尾蛇与蚺蛇或飞镖蛙更相似。
进行这种比较的第一步是将每种动物的特征转换为数字序列。如果我们说True = 1
和False = 0
,我们会得到以下特征向量:
Rattlesnake: [1,1,1,1,0]
Boa constrictor: [0,1,0,1,0]
Dart frog: [1,0,1,0,4]
比较数字向量相似性的方法有很多。比较相同长度向量时最常用的度量基于闵可夫斯基距离:
其中len是向量的长度。
参数p
,至少为 1,定义了在遍历向量V和W之间的距离时可以遵循的路径类型。这在向量长度为二时可以很容易地可视化,因此可以使用笛卡尔坐标表示。请考虑图 24-5 中的图像。
图 24-5 可视化距离度量
左下角的圆形更接*十字还是更接*星形?这要看情况。如果我们可以直线行走,十字更*。毕达哥拉斯定理告诉我们,十字距离圆形8
单位的*方根,大约是2.8
单位,而星形距离圆形3
单位。这些距离被称为欧几里得距离,对应于使用闵可夫斯基距离时p = 2
。但想象一下,图中的线条对应于街道,我们必须沿街道从一个地方到另一个地方。星形仍然距离圆形3
单位,但十字现在距离4
单位。这些距离被称为曼哈顿 距离,¹⁹⁰,它们对应于使用闵可夫斯基距离时p = 1
。图 24-6 包含一个实现闵可夫斯基距离的函数。
图 24-6 闵可夫斯基距离
图 24-7 包含类Animal
。它将两只动物之间的距离定义为与动物相关的特征向量之间的欧几里得距离。
图 24-7 类Animal
图 24-8 包含一个比较动物列表的函数,并生成一张显示成对距离的表格。代码使用了我们之前未使用的 Matplotlib 绘图工具:table
。
table
函数生成一个(惊喜!)看起来像表格的图。关键字参数rowLabels
和colLabels
用于提供行和列的标签(在本例中是动物的名称)。关键字参数cellText
用于提供表格单元格中出现的值。在该示例中,cellText
绑定到table_vals
,这是一个字符串列表的列表。table_vals
中的每个元素都是表格一行中单元格值的列表。关键字参数cellLoc
用于指定文本在每个单元格中的位置,关键字参数loc
用于指定表格本身在图中的位置。示例中使用的最后一个关键字参数是colWidths
。它绑定到一个浮点数列表,给出表格中每一列的宽度(以英寸为单位)。代码table.scale(1, 2.5)
指示 Matplotlib 保持单元格的水*宽度不变,但将单元格的高度增加2.5
倍(使表格看起来更美观)。
图 24-8 建立动物对之间的距离表
如果我们运行代码
rattlesnake = Animal('rattlesnake', [1,1,1,1,0])
boa = Animal('boa', [0,1,0,1,0])
dart_frog = Animal('dart frog', [1,0,1,0,4])
animals = [rattlesnake, boa, dart_frog]
compare_animals(animals, 3)
它生成了图 24-9 中的表格。
正如你可能预期的,响尾蛇与蟒蛇之间的距离小于任何一条蛇与飞蛙之间的距离。顺便提一下,飞蛙离响尾蛇稍微*一点,而不是蟒蛇。
图 24-9 三种动物之间的距离
现在,让我们在上述代码的最后一行之前插入以下行
alligator = Animal('alligator', [1,1,0,1,4])
animals.append(alligator)
它生成了图 24-10 中的表格。
图 24-10 四种动物之间的距离
也许你会惊讶于鳄鱼与飞蛙之间的距离明显小于与响尾蛇或蟒蛇之间的距离。花点时间思考一下为什么。
鳄鱼的特征向量与响尾蛇的特征向量在两个地方不同:是否有毒和腿的数量。鳄鱼的特征向量与飞蛙的特征向量在三个地方不同:是否有毒、是否有鳞片,以及是否是冷血动物。然而,根据我们的欧几里得距离度量,鳄鱼与飞蛙更相似,而非响尾蛇。这是怎么回事?
问题的根源在于不同特征具有不同的值范围。除了一个特征,其他特征的范围均在0
和1
之间,但腿的数量则在0
到4
之间。这意味着当我们计算欧几里得距离时,腿的数量权重不成比例。让我们看看如果将特征变为二元特征,若动物无腿则值为0
,否则为1
会发生什么。
图 24-11 使用不同特征表示的距离
这看起来更合理了。
当然,仅使用二元特征并不总是方便。在第 25.4 节中,我们将介绍一种更通用的方法来处理特征之间的规模差异。
24.3 本章介绍的术语
统计机器学习
泛化
训练数据
特征向量
监督学习
回归模型
分类模型
标签
无监督学习
潜在变量
聚类
信噪比(SNR)
特征工程
数据的维度
特征选择
闵可夫斯基距离
三角不等式
欧几里得距离
曼哈顿距离
第二十五章:聚类
无监督学习涉及在无标签数据中寻找隐藏结构。最常用的无监督机器学习技术是聚类。
聚类可以定义为将对象组织成某种方式相似的组的过程。一个关键问题是定义“相似”的含义。考虑图 25-1 中的图示,显示了 13 个人的身高、体重和衬衫颜色。
图 25-1 身高、体重和衬衫颜色
如果我们按身高对人进行聚类,会出现两个明显的簇——由虚线水*线划分。如果我们按体重对人进行聚类,会出现两个不同的明显簇——由实线垂直线划分。如果我们根据他们的衬衫进行聚类,还有第三种聚类——由倾斜的虚线划分。顺便提一下,最后这种划分不是线性的,因为我们无法用一条直线根据衬衫颜色将人们分开。
聚类是一个优化问题。目标是找到一组簇,以优化目标函数,同时遵循一组约束条件。给定一个可以用来决定两个示例之间接*程度的距离度量,我们需要定义一个目标函数,以最小化簇内示例之间的不相似度。
我们称之为变异性(在文献中通常称为惯性)的一个度量,表示单个簇内的示例c之间的差异性是
其中mean(c)是簇内所有示例特征向量的均值。一个向量集的均值是逐组件计算的。对应元素相加,然后结果除以向量的数量。如果v1
和v2
是数字的arrays
,表达式(v1+v2)/2
的值是它们的欧几里得均值。
我们所称的变异性类似于第十七章中提出的方差的概念。不同之处在于,变异性没有通过簇的大小进行归一化,因此根据该度量,具有更多点的簇看起来可能不那么凝聚。如果我们想比较两个不同大小簇的凝聚性,就需要将每个簇的变异性除以簇的大小。
单个簇c内的变异性的定义可以扩展为一组簇C的不相似度度量:
请注意,由于我们不将变异性除以簇的大小,因此一个大的不凝聚簇会比一个小的不凝聚簇更大地增加*dissimilarity(C)*的值。这是有意设计的。
那么,优化问题是否是找到一组簇 C,使得 dissimilarity(C) 被最小化?不完全是。通过将每个示例放在其自己的簇中,可以很容易地将其最小化。我们需要添加一些约束。例如,我们可以对簇之间的最小距离施加约束,或者要求最大簇数为某个常数 k。
一般来说,解决这个优化问题在大多数有趣的问题上都是计算上不可行的。因此,人们依赖于提供*似解的贪心算法。在第 25.2 节中,我们介绍一种这样的算法,即 k 均值聚类。但首先我们将介绍一些实现该算法(以及其他聚类算法)时有用的抽象。
25.1 类 Cluster
类 Example
(图 25-2) 将用于构建要聚类的样本。与每个示例相关联的是一个名称、一个特征向量和一个可选标签。distance
方法返回两个示例之间的欧几里得距离。
图 25-2 类 Example
类 Cluster
(图 25-3) 稍微复杂一些。一个簇是一组示例。Cluster
中两个有趣的方法是 compute_centroid
和 variability
。将簇的 质心 视为其质心。方法 compute_centroid
返回一个示例,其特征向量等于簇中示例特征向量的欧几里得均值。方法 variability
提供了簇的连贯性度量。
图 25-3 类 Cluster
动手练习:一个簇的质心是否总是该簇中的一个示例?
25.2 K 均值聚类
K 均值聚类可能是最广泛使用的聚类方法。¹⁹¹ 它的目标是将一组示例划分为 k
个簇,使得
每个示例位于其质心最*的簇中。
簇集的相异性被最小化。
不幸的是,在大数据集上找到该问题的最优解在计算上是不可行的。幸运的是,有一种高效的贪心算法¹⁹² 可用于找到有用的*似解。它由伪代码描述。
randomly choose k examples as initial centroids of clusters
while true:
1\. Create k clusters by assigning each example to closest centroid
2\. Compute k new centroids by averaging the examples in each cluster
3\. If none of the centroids differ from the previous iteration:
return the current set of clusters
步骤 1 的复杂度为 θ(k*n*d)
,其中 k
是聚类的数量,n
是样本的数量,d
是计算一对样本之间距离所需的时间。步骤 2 的复杂度为 θ(n)
,步骤 3 的复杂度为 θ(k)
。因此,单次迭代的复杂度为 θ(k*n*d)
。如果使用闵可夫斯基距离比较样本,d
与特征向量的长度呈线性关系。¹⁹³ 当然,整个算法的复杂度取决于迭代的次数。这一点不容易表征,但可以说通常是较小的。
图 25-4 包含描述 k-means 的伪代码的 Python 翻译。唯一的特殊之处在于,如果任何迭代创建了一个没有成员的聚类,则会抛出异常。生成空聚类是很少见的。它不会出现在第一次迭代中,但可能在后续迭代中出现。这通常是由于选择的 k
过大或初始质心选择不幸。将空聚类视为错误是 MATLAB 使用的选项之一。另一个选项是创建一个只包含一个点的新聚类——该点是其他聚类中距离质心最远的点。我们选择将其视为错误,以简化实现。
k-means 算法的一个问题是返回的值依赖于初始随机选择的质心。如果选择了一组特别不幸的初始质心,算法可能会陷入一个远离全局最优解的局部最优解。在实践中,通常通过多次运行 k-means 来解决这个问题,初始质心随机选择。然后,我们选择具有最小聚类相异度的解决方案。
图 25-5 包含一个函数 try_k_means
,它多次调用 k_means
(
图 25-4)
并选择相异度最低的结果。如果试验失败,因为 k_means
生成了一个空聚类并因此抛出了异常,try_k_means
仅仅是重新尝试——假设最终 k_means
会选择一个成功收敛的初始质心集合。
图 25-4 K-means 聚类
图 25-5 寻找最佳的 k-means 聚类
25.3 一个人为的例子
图 25-6 包含生成、绘制和聚类来自两个分布的样本的代码。
函数 gen_distributions
生成一个包含 n
个样本的列表,这些样本具有二维特征向量。这些特征向量元素的值来自正态分布。
函数plot_samples
绘制一组示例的特征向量。它使用plt.annotate
在绘图中的点旁边放置文本。第一个参数是文本,第二个参数是与文本相关联的点,第三个参数是文本相对于与其相关联的点的位置。
函数contrived_test
使用gen_distributions
创建两个包含 10 个示例的分布(每个分布具有相同的标准差但不同的均值),使用plot_samples
绘制示例,然后使用try_k_means
对其进行聚类。
图 25-6 k 均值的测试
调用contrived_test(1, 2, True)
生成了图 25-7 中的绘图,并打印了图 25-8 中的线条。注意,初始(随机选择的)质心导致了高度偏斜的聚类,其中一个聚类包含了除一个点以外的所有点。然而,到第四次迭代时,质心移动到使得两个分布的点合理分开为两个聚类的位置。唯一的“错误”发生在A0
和A8
上。
图 25-7 来自两个分布的示例
图 25-8 调用contrived_test(1, 2, True)
打印的线条
当我们尝试50
次实验而不是1
次时,通过调用contrived_test(50, 2, False)
,它打印了
Final result
Cluster with centroid [2.74674403 4.97411447] contains:
A1, A2, A3, A4, A5, A6, A7, A8, A9
Cluster with centroid [6.0698851 6.20948902] contains:
A0, B0, B1, B2, B3, B4, B5, B6, B7, B8, B9
A0
仍然与B
混在一起,但A8
则没有。如果我们尝试1000
次实验,结果也一样。这可能会让你感到惊讶,因为图 25-7 显示,如果A0
和B0
被选为初始质心(这在1000
次实验中可能发生),第一次迭代将产生完美分离A
和B
的聚类。然而,在第二次迭代中将计算新的质心,A0
将被分配到与B
的一个聚类中。这不好吗?请记住,聚类是一种无监督学习形式,它在未标记数据中寻找结构。将A0
与B
分组并不不合理。
使用 k 均值聚类的一个关键问题是选择k
。图 25-9 中的函数contrived_test_2
生成、绘制并聚类来自三个重叠高斯分布的点。我们将使用它来查看在不同k
值下对该数据的聚类结果。数据点在图 25-10 中显示。
图 25-9 从三个分布生成点
图 25-10 来自三个重叠高斯的点
调用contrived_test2(40, 2)
打印
Final result has dissimilarity 90.128
Cluster with centroid [5.5884966 4.43260236] contains:
A0, A3, A5, B0, B1, B2, B3, B4, B5, B6, B7
Cluster with centroid [2.80949911 7.11735738] contains:
A1, A2, A4, A6, A7, C0, C1, C2, C3, C4, C5, C6, C7
调用contrived_test2(40, 3)
打印
Final result has dissimilarity 42.757
Cluster with centroid [7.66239972 3.55222681] contains:
B0, B1, B3, B6
Cluster with centroid [3.56907939 4.95707576] contains:
A0, A1, A2, A3, A4, A5, A7, B2, B4, B5, B7
Cluster with centroid [3.12083099 8.06083681] contains:
A6, C0, C1, C2, C3, C4, C5, C6, C7
调用contrived_test2(40, 6)
打印
Final result has dissimilarity 11.441
Cluster with centroid [2.10900238 4.99452866] contains:
A1, A2, A4, A7
Cluster with centroid [4.92742554 5.60609442] contains:
B2, B4, B5, B7
Cluster with centroid [2.80974427 9.60386549] contains:
C0, C6, C7
Cluster with centroid [3.27637435 7.28932247] contains:
A6, C1, C2, C3, C4, C5
Cluster with centroid [3.70472053 4.04178035] contains:
A0, A3, A5
Cluster with centroid [7.66239972 3.55222681] contains:
B0, B1, B3, B6
最后的聚类是最紧密的拟合,即聚类的不相似度最低(11.441
)。这是否意味着这是“最佳”聚类?不一定。回想一下我们在 20.1.1 节中观察到的线性回归,通过增加多项式的次数,我们得到了一个更复杂的模型,从而更紧密地拟合了数据。我们还观察到,当我们增加多项式的次数时,我们有可能找到一个预测值较差的模型——因为它过拟合了数据。
选择合适的k
值与为线性回归选择合适的多项式次数完全类似。通过增加k
,我们可以减少不相似度,但有过拟合的风险。(当k
等于待聚类样本数量时,不相似度为 0!)如果我们知道待聚类样本的生成方式,例如从m
个分布中选择,我们可以利用这些信息来选择k
。在缺乏此类信息的情况下,有多种启发式方法可以选择k
。深入讨论这些超出了本书的范围。
25.4 一个不那么复杂的例子
不同物种的哺乳动物有不同的饮食习惯。一些物种(例如,大象和海狸)只吃植物,其他物种(例如,狮子和老虎)只吃肉,还有一些物种(例如,猪和人类)则吃任何能放进嘴里的东西。素食物种称为草食动物,肉食动物称为肉食动物,而那些既吃植物又吃动物的物种称为杂食动物。
在千百年的演化过程中(或者,如果你愿意,可以认为是某种神秘的过程),物种的牙齿被赋予了适合其偏好食物的形态。¹⁹⁴ 这引出了一个问题,即基于哺乳动物的牙齿结构进行聚类是否会产生与其饮食相关的聚类。
图 25-11 显示了一个文件的内容,列出了某些哺乳动物的物种、其牙齿公式(前8
个数字)以及其*均成年体重(磅)。¹⁹⁵ 文件顶部的注释描述了与每种哺乳动物相关的项目,例如,名称后第一个项目是顶端门牙的数量。
图 25-11 哺乳动物的牙齿结构在dentalFormulas.csv
中
图 25-12 包含三个函数。read_mammal_data
函数首先读取一个 CSV 文件,格式如图 25-11 所示,以创建一个数据框。关键字参数comment
用于指示read_csv
忽略以#
开头的行。如果参数scale_method
不等于None
,则使用scale_method
缩放数据框中的每一列。最后,它创建并返回一个将物种名称映射到特征向量的字典。build_mammal_examples
函数使用read_mammal_data
返回的字典生成并返回一组示例。test_teeth
函数生成并打印聚类。
图 25-12 读取和处理 CSV 文件
调用test_teeth('dentalFormulas.csv', 3, 40)
打印
Bear, Cow, Deer, Elk, Fur seal, Grey seal, Lion, Sea lion
Badger, Cougar, Dog, Fox, Guinea pig, Human, Jaguar, Kangaroo, Mink, Mole, Mouse, Pig, Porcupine, Rabbit, Raccoon, Rat, Red bat, Skunk, Squirrel, Wolf, Woodchuck
Moose
粗略检查表明,我们的聚类完全被动物的体重主导。问题在于,体重的范围远大于其他任何特征的范围。因此,当计算样本之间的欧几里得距离时,唯一真正重要的特征是体重。
我们在第 24.2 节遇到过类似的问题,当时发现动物之间的距离主要由腿的数量主导。我们通过将腿的数量转换为二元特征(有腿或无腿)来解决了那个问题。那对于该数据集是可行的,因为所有动物的腿数恰好是零或四条。然而,在这里,毫无疑问地将体重转换为单一二元特征而不损失大量信息是不现实的。
这是一个常见的问题,通常通过对特征进行缩放来解决,使每个特征的均值为0
,标准差为1
,¹⁹⁶,就像在图 25-13 中z_scale
函数所做的那样。很容易看出,语句result = result - mean
确保返回数组的均值总是接*0
。¹⁹⁷ 标准差总是为1
并不明显,可以通过一系列繁琐的代数变换证明,但我们不想让你感到乏味。这种缩放通常被称为z-缩放,因为标准正态分布有时被称为 Z 分布。
另一种常见的缩放方法是将最小特征值映射到0
,将最大特征值映射到1
,并在其间使用线性缩放,就像在图 25-13 中linear_scale
函数所做的那样。这通常被称为最小-最大缩放。
图 25-13 缩放属性
调用test_teeth('dentalFormulas.csv', 3, 40, z_scale)
打印
Badger, Bear, Cougar, Dog, Fox, Fur seal, Grey seal, Human, Jaguar, Lion, Mink, Mole, Pig, Raccoon, Red bat, Sea lion, Skunk, Wolf
Guinea pig, Kangaroo, Mouse, Porcupine, Rabbit, Rat, Squirrel, Woodchuck
Cow, Deer, Elk, Moose
这种聚类如何与这些哺乳动物相关的特征并不明显,但至少它并不是仅仅通过体重对哺乳动物进行分组。
回想一下,我们在本节开始时假设哺乳动物的牙齿特征与其饮食之间存在关系。 图 25-14 包含了一个 CSV 文件diet.csv
的摘录,关联了哺乳动物及其饮食偏好。
图 25-14 CSV 文件的开始,用于按饮食分类哺乳动物
我们可以使用diet.csv
中的信息来看我们生成的聚类与饮食之间的关系。 图 25-15 中的代码正是这样做的。
图 25-15 将聚类与标签相关联
当运行test_teeth_diet('dentalFormulas.csv', ‘diet.csv', 3, 40, z_scale)
时,它打印了
Badger, Bear, Cougar, Dog, Fox, Fur seal, Grey seal, Human, Jaguar, Lion, Mink, Mole, Pig, Raccoon, Red bat, Sea lion, Skunk, Wolf
0 herbivores, 13 carnivores, 5 omnivores
Guinea pig, Kangaroo, Mouse, Porcupine, Rabbit, Rat, Squirrel, Woodchuck
3 herbivores, 0 carnivores, 5 omnivores
Cow, Deer, Elk, Moose
4 herbivores, 0 carnivores, 0 omnivores
使用 z 缩放的聚类(线性缩放产生相同的聚类)并没有完美地根据动物的饮食习惯进行划分,但它与它们的饮食确实相关。它很好地将食肉动物与草食动物分开,但杂食动物出现的地方没有明显的模式。这表明,除了牙齿和体重外,可能还需要其他特征来区分杂食动物与草食动物和食肉动物。
25.5 本章引入的术语
欧几里得均值
不相似性
重心
k 均值聚类
标准正态分布
z 缩放
线性缩放
最小-最大缩放
线性插值
第二十六章:分类方法
监督机器学习最常见的应用是构建分类模型。分类模型或分类器用于将示例标记为属于有限类别集中的一个。例如,判断一封电子邮件是否为垃圾邮件就是一个分类问题。在文献中,这些类别通常称为类(因此得名分类)。我们也可以将一个示例描述为属于某个类或具有标签。
在单类学习中,训练集仅包含来自一个类的示例。目标是学习一个模型,预测示例是否属于该类。当很难找到不在该类之外的训练示例时,单类学习非常有用。单类学习常用于构建异常检测器,例如,检测计算机网络上以前未见过的攻击类型。
在二分类学习(通常称为二元分类)中,训练集包含来自正负两个类的示例,目标是找到一个边界将这两个类分开。多类学习涉及找到将多个类别彼此分开的边界。
在本章中,我们研究两种广泛使用的监督学习方法来解决分类问题:k-*邻和回归。在此之前,我们先讨论如何评估这些方法产生的分类器的问题。
本章中的代码假设了导入语句
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import random
import sklearn.linear_model as sklm
import sklearn.metrics as skm
26.1 评估分类器
阅读过第二十章的朋友可能会记得,该章的一部分讨论了选择线性回归的次数的问题,1) 提供对可用数据的合理拟合,2) 有合理的机会对尚未见过的数据做出良好的预测。使用监督机器学习训练分类器时同样会出现这些问题。
我们首先将数据分为两个集合,一个训练集和一个测试集。训练集用于学习模型,测试集用于评估该模型。当我们训练分类器时,我们试图最小化训练误差,即在训练集中对示例分类时的错误,前提是满足某些约束。这些约束旨在提高模型在尚未见过的数据上表现良好的概率。让我们用图示来看一下。
图 26-1 左侧的图表显示了 60 位(模拟)美国公民的投票模式。x 轴表示选民家距离马萨诸塞州波士顿的距离,y 轴表示选民的年龄。星形标记表示通常投票给民主党的选民,三角形标记表示通常投票给共和党的选民。右侧的图表在图 26-1 中展示了包含随机选择的 30 位选民的训练集。实线和虚线表示两个群体之间的两个可能边界。基于实线的模型中,线下的点被分类为民主党选民;基于虚线的模型中,线左侧的点被分类为民主党选民。
图 26-1 选民偏好的图
这两个边界都不能完美分隔训练数据。两个模型的训练错误显示在图 26-2 中的混淆矩阵里。每个矩阵的左上角显示被分类为民主党的例子的数量,这些例子实际上也是民主党,即真正的正例。左下角显示被分类为民主党的例子的数量,但这些例子实际上是共和党,即假阳性。右侧列显示了顶部的假阴性数量和底部的真正负例数量。
图 26-2 混淆矩阵
每个分类器在训练数据上的准确性可以计算为
在这种情况下,每个分类器的准确率为0.7
。哪个更好地拟合了训练数据?这取决于我们是否更关注将共和党选民错误分类为民主党选民,或反之亦然。
如果我们愿意画出更复杂的边界,就可以得到一个更准确分类训练数据的分类器。例如,图 26-3 中所示的分类器在训练数据上的准确率约为 0.83,如图的左侧图所示。然而,正如我们在第二十章的线性回归讨论中看到的,模型越复杂,就越有可能出现对训练数据的过拟合。图 26-3 右侧的图展示了如果将复杂模型应用于保留集,会发生什么——准确率下降至0.6
。
图 26-3 更复杂的模型
当两个类别的大小大致相等时,准确度是一种合理的评估分类器的方法。但在类别严重不*衡的情况下,准确度是一种糟糕的评估方式。想象一下,你被指派评估一个分类器,该分类器预测某种潜在致命疾病,该疾病在大约 0.1%
的待测人群中出现。准确度并不是一个特别有用的统计数据,因为仅仅通过宣称所有患者无病,就可以达到 99.9%
的准确率。对那些负责支付治疗费用的人来说,该分类器可能看起来很好(没人会接受治疗!),但对于那些担心自己可能患有该疾病的人来说,这个分类器可能看起来就没那么好了。
幸运的是,有关分类器的统计数据能够在类别不*衡时提供洞见:
灵敏度(在某些领域称为 召回率)是真阳性率,即正确识别为阳性的比例。特异性是真阴性率,即正确识别为阴性的比例。正预测值是被分类为阳性的示例真正为阳性的概率。负预测值是被分类为阴性的示例真正为阴性的概率。
这些统计测量的实现以及一个使用它们生成一些统计数据的函数在 图 26-4 中。我们将在本章稍后使用这些函数。
图 26-4 评估分类器的函数
26.2 预测跑者的性别
在本书早些时候,我们使用波士顿马拉松的数据来说明许多统计概念。现在我们将使用相同的数据来说明各种分类方法的应用。任务是根据跑者的年龄和完成时间预测其性别。
函数 build_marathon_examples
在 图 26-6 中从如 图 26-5 所示格式的 CSV 文件中读取数据,然后构建一组示例。每个示例是类 Runner
的一个实例。每个跑者都有一个标签(性别)和一个特征向量(年龄和完成时间)。在 Runner
中唯一有趣的方法是 feature_dist
。它返回两个跑者特征向量之间的欧几里得距离。
图 26-5 bm_results2012.csv
的前几行
下一步是将示例分成训练集和保留的测试集。像往常一样,我们使用80%
的数据进行训练,使用剩下的20%
进行测试。这是通过图 26-6 底部的函数divide_80_20
完成的。请注意,我们随机选择训练数据。简单地选择数据的前80%
会减少代码量,但这样做有可能无法代表整个集合。例如,如果文件按完成时间排序,我们将得到一个偏向于更优秀跑者的训练集。
我们现在准备看看使用训练集构建一个可以预测跑者性别的分类器的不同方法。检查发现,训练集中58%
的跑者是男性。因此,如果我们总是猜测男性,我们应该期待58%
的准确率。在查看更复杂分类算法的性能时,请牢记这一基线。
图 26-6 构建示例并将数据分为训练集和测试集
26.3 K-最*邻
K-最*邻(KNN)可能是所有分类算法中最简单的一种。“学习”模型只是训练示例本身。新的示例会根据它们与训练数据中示例的相似度被分配标签。
想象一下,你和朋友在公园散步,发现了一只鸟。你认为它是一只黄喉啄木鸟,但你的朋友则很确定它是一只金绿色啄木鸟。你急忙回家,翻找出你的鸟类书籍(或者,如果你在35 岁
以下,去你喜欢的搜索引擎),开始查看标记好的鸟类图片。把这些标记好的图片看作训练集。没有一张图片完全匹配你看到的那只鸟,因此你选择了看起来最像你看到的鸟的五张(五个“最*邻”)。其中大多数是黄喉啄木鸟的照片——你宣布胜利。
KNN(和其他)分类器的一个弱点是,当训练数据中的示例分布与测试数据中的分布不同时,它们往往会给出较差的结果。如果书中鸟类照片的频率与您所在社区中该物种的频率相同,KNN 可能会表现良好。然而,假设尽管这种物种在你所在的社区同样常见,但你的书中有 30 张黄喉啄木鸟的照片,仅有一张金绿色啄木鸟的照片。如果使用简单的多数投票来确定分类,即使这些照片看起来与您看到的鸟不太相似,黄喉啄木鸟仍然会被选中。通过使用更复杂的投票方案,可以部分缓解这个问题,在这种方案中,k 个最*邻会根据它们与待分类示例的相似度加权。
图 26-7 中的函数实现了一个 k 最*邻分类器,根据跑步者的年龄和完成时间来预测跑步者的性别。该实现是暴力破解。函数 find_k_nearest
在 example_set
的示例数量上是线性的,因为它计算了 example
和 example_set
中每个元素之间的特征距离。函数 k_nearest_classify
使用简单的多数投票机制进行分类。k_nearest_classify
的复杂度为 O(len(training)*len(test_set))
,因为它总共调用 find_k_nearest
函数 len(test_set)
次。
图 26-7 查找 k 最*邻
当代码
examples = build_marathon_examples('bm_results2012.csv')
training, test_set = divide_80_20(examples)
true_pos, false_pos, true_neg, false_neg =\
k_nearest_classify(training, test_set, 'M', 9)
get_stats(true_pos, false_pos, true_neg, false_neg)
运行后,它打印了
Accuracy = 0.65
Sensitivity = 0.715
Specificity = 0.563
Pos. Pred. Val. = 0.684
我们是否应该感到高兴,因为在给定年龄和完成时间的情况下,我们可以以 65%
的准确率预测性别?评估分类器的一种方法是将其与一个甚至不考虑年龄和完成时间的分类器进行比较。图 26-8 中的分类器首先使用 training
中的示例来估计在 test_set
中随机选择的示例属于 label
类的概率。利用这个先验概率,它然后随机分配一个标签给 test_set
中的每个示例。
图 26-8 基于普遍性的分类器
当我们在同样的波士顿马拉松数据上测试 prevalence_classify
时,它打印了
Accuracy = 0.514
Sensitivity = 0.593
Specificity = 0.41
Pos. Pred. Val. = 0.57
表明我们在考虑年龄和完成时间时获得了相当大的优势。
这种优势是有代价的。如果你运行 图 26-7 中的代码,你会注意到它需要相当长的时间才能完成。训练示例有 17,233
个,测试示例有 4,308
个,因此计算了* 75
百万的距离。这引发了一个问题:我们是否真的需要使用所有的训练示例。让我们看看如果我们简单地将训练数据按 10
的比例 下采样 会发生什么。
如果我们运行
reduced_training = random.sample(training, len(training)//10)
true_pos, false_pos, true_neg, false_neg =\
k_nearest_classify(reduced_training, test_set, 'M', 9)
get_stats(true_pos, false_pos, true_neg, false_neg)
它完成所需时间的一半,分类性能几乎没有变化:
Accuracy = 0.638
Sensitivity = 0.667
Specificity = 0.599
Pos. Pred. Val. = 0.687
在实际操作中,当人们将 KNN 应用于大型数据集时,他们常常会对训练数据进行下采样。一个更常见的替代方法是使用某种快速*似 KNN 算法。
在上述实验中,我们将 k
设置为 9
。我们并不是因为科学上的角色(我们太阳系中的行星数量),¹⁹⁸它的宗教意义(印度女神杜尔迦的形式数量),或它的社会学重要性(棒球阵容中的击球手数量)而选择这个数字。相反,我们通过使用 图 26-9 中的代码从训练数据中学习了 k
,以搜索一个合适的 k
。
外层循环测试一系列 k
的值。我们只测试奇数值,以确保在 k_nearest_classify
中投票时,总会有一个性别占多数。
内部循环使用n 折交叉验证测试每个 k 值。在循环的每次num_folds
迭代中,原始训练集被分成新的训练集/测试集对。然后,我们计算使用 k *邻和新训练集对新测试集分类的准确度。当我们退出内部循环时,计算num_folds
折的*均准确度。
当我们运行代码时,生成了图 26-10 中的图。正如我们所见,17
是导致 5 折交叉验证中最佳准确度的k
值。当然,没有保证某个大于21
的值会更好。然而,一旦k
达到9
,准确度就在合理的狭窄范围内波动,因此我们选择使用9
。
图 26-9 寻找合适的 k 值
图 26-10 选择 k 值
26.4 基于回归的分类器
在第二十章中,我们使用线性回归构建了数据模型。我们可以在这里尝试同样的做法,使用训练数据为男性和女性分别构建模型。图 25-11 中的图是通过图 26-12 中的代码生成的。
图 26-11 男性和女性的线性回归模型
图 26-12 生成并绘制线性回归模型
看一眼图 26-11 就足以看到,线性回归模型仅解释了数据中很小一部分的方差。¹⁹⁹ 尽管如此,可以利用这些模型构建分类器。每个模型试图捕捉年龄与完成时间之间的关系。这种关系对男性和女性是不同的,我们可以利用这一点来构建分类器。给定一个示例,我们会问年龄与完成时间之间的关系更接*男性跑者的模型(实线)还是女性跑者的模型(虚线)所预测的关系。这个想法在图 26-13 中得到了实现。
当代码运行时,它打印
Accuracy = 0.614
Sensitivity = 0.684
Specificity = 0.523
Pos. Pred. Val. = 0.654
结果比随机更好,但不如 KNN。
图 26-13 使用线性回归构建分类器
你可能会想知道为什么我们采用这种间接的方法来使用线性回归,而不是明确使用年龄和时间的某种函数作为因变量,使用实际数字(比如0
代表女性,1
代表男性)作为自变量。
我们可以轻松地使用polyfit
构建这样的模型,将年龄和时间的函数映射到一个实数。然而,预测某个跑步者处于男性和女性之间的中间位置意味着什么呢?比赛中有双性人吗?也许我们可以将 y 轴解释为一个跑步者是男性的概率。其实并不是。甚至没有保证将polyval
应用于模型会返回一个介于0
和1
之间的值。
幸运的是,有一种回归形式,逻辑回归,²⁰⁰专门设计用于预测事件的概率。Python 库sklearn
²⁰¹提供了良好的逻辑回归实现,以及与机器学习相关的许多其他有用函数和类。
模块sklearn.linear_model
包含类LogisticRegression
。该类的__init__
方法有大量参数,用于控制解决回归方程所用的优化算法等。它们都有默认值,在大多数情况下,使用这些默认值是可以的。
LogisticRegression
类的核心方法是fit
。该方法接受两个相同长度的序列(元组、列表或数组)作为参数。第一个是特征向量的序列,第二个是相应标签的序列。在文献中,这些标签通常称为结果。
fit
方法返回一个类型为LogisticRegression
的对象,该对象已为特征向量中的每个特征学习了系数。这些系数通常称为特征权重,捕捉了特征与结果之间的关系。正特征权重表明特征与结果之间存在正相关,而负特征权重表明负相关。权重的绝对值与相关性的强度有关。²⁰² 这些权重的值可以通过LogisticRegression
的coef_
属性访问。由于可以在多个结果(在包的文档中称为类)上训练LogisticRegression
对象,因此coef_
的值是一个序列,其中每个元素包含与单个结果相关的权重序列。例如,表达式model.coef_[1][0]
表示第二个结果的第一个特征的系数值。
一旦学习了系数,就可以使用LogisticRegression
类的predict_proba
方法来预测与特征向量相关的结果。predict_proba
方法接受一个参数(除了self
),即特征向量的序列。它返回一个数组,其中每个特征向量对应一个数组。返回数组中的每个元素包含对应特征向量的预测。预测为数组的原因是它包含构建model
时所用每个标签的概率。
图 26-14 中的代码简单地展示了这一切是如何运作的。它首先创建了一个包含100,000
个示例的列表,每个示例都有一个长度为3
的特征向量,并标记为'A'
、'B'
、'C'
或‘D'
。每个示例的前两个特征值来自标准差为0.5
的高斯分布,但均值根据标签不同而变化。第三个特征的值是随机选择的,因此不应在预测标签时有用。在创建示例后,代码生成一个逻辑回归模型,打印特征权重,最后打印与四个示例相关的概率。
图 26-14 使用sklearn
进行多类逻辑回归
当我们运行图 26-14 中的代码时,它打印了
model.classes_ = ['A' 'B' 'C' ‘D']
For label A feature weights = [-4.7229 -4.3618 0.0595]
For label B feature weights = [-3.3346 4.7875 0.0149]
For label C feature weights = [ 3.7026 -4.4966 -0.0176]
For label D feature weights = [ 4.3548 4.0709 -0.0568]
[0, 0] probs = [9.998e-01 0.000e+00 2.000e-04 0.000e+00]
[0, 2] probs = [2.60e-03 9.97e-01 0.00e+00 4.00e-04]
[2, 0] probs = [3.000e-04 0.000e+00 9.996e-01 2.000e-04]
[2, 2] probs = [0.000e+00 5.000e-04 2.000e-04 9.992e-01]
首先让我们看看特征权重。第一行告诉我们前两个特征的权重大致相同,并且与示例标签为'A'
的概率呈负相关。²⁰³也就是说,前两个特征值越大,示例为'A'
的可能性就越小。我们预计在预测标签时价值较小的第三个特征,其相对于其他两个值的值较小,表明它相对不重要。第二行告诉我们,示例标签为'B'
的概率与第一个特征的值呈负相关,而与第二个特征呈正相关。同样,第三个特征的值相对较小。第三行和第四行是前两行的镜像。
现在,让我们来看与四个示例相关的概率。概率的顺序对应于属性model.classes_
中结果的顺序。正如你所希望的,当我们预测与特征向量[0, 0]
相关的标签时,'A'
的概率非常高,而'D'
的概率非常低。类似地,[2, 2]
对'D'
的概率非常高,而对'A'
的概率非常低。与中间两个示例相关的概率也符合预期。
图 26-15 中的示例与图 26-14 中的示例相似,只是我们只创建了两个类'A'
和'D'
的示例,并且不包括不相关的第三个特征。
图 26-15 二分类逻辑回归示例
当我们运行图 26-15 中的代码时,它打印了
model.coef = [[6.7081 6.5737]]
[0, 0] probs = [1\. 0.]
[0, 2] probs = [0.5354 0.4646]
[2, 0] probs = [0.4683 0.5317]
[2, 2] probs = [0\. 1.]
注意,coef_
中只有一组权重。当使用fit
为二元分类器生成模型时,它只为一个标签生成权重。这是足够的,因为一旦proba
计算出某个示例属于任一类的概率,就可以确定它属于另一类的概率——因为这两者的概率之和必须为1
。coef_
中的权重对应于哪一个标签?由于权重是正的,它们必须对应于'D'
,因为我们知道特征向量中的值越大,示例越可能属于'D'
类。传统上,二元分类使用标签0
和1
,分类器使用1
的权重。在这种情况下,coef_
包含与最大标签相关联的权重,正如str
类型的>
运算符所定义的。
让我们回到波士顿马拉松的例子。图 26-16 中的代码使用LogisticRegression
类为我们的波士顿马拉松数据构建和测试模型。函数apply_model
接受四个参数:
model
:一个LogisticRegression
类型的对象,它已经构建了一个拟合模型。test_set
:一系列示例。这些示例具有与构建model
拟合模型所使用的特征和标签相同的类型。label
:正类的标签。apply_model
返回的混淆矩阵信息是相对于这个标签的。prob
:用于决定在test_set
中将哪个标签分配给示例的概率阈值。默认值为0.5
。由于它不是常量,apply_model
可以用来研究假阳性和假阴性之间的权衡。
apply_model
的实现首先使用列表推导(第 5.3.2 节)构建一个列表,其元素是test_set
中示例的特征向量。然后它调用model.predict_proba
获取与每个特征向量预测相对应的对的数组。最后,它将预测与与该特征向量相关的示例的标签进行比较,并跟踪并返回真正例、假正例、真负例和假负例的数量。
当我们运行代码时,它打印出:
Feature weights for label M: age = 0.055, time = -0.011
Accuracy = 0.636
Sensitivity = 0.831
Specificity = 0.377
Pos. Pred. Val. = 0.638
让我们将这些结果与我们使用 KNN 时获得的结果进行比较:
Accuracy = 0.65
Sensitivity = 0.715
Specificity = 0.563
Pos. Pred. Val. = 0.684
准确率和正预测值相似,但逻辑回归具有更高的敏感性和更低的特异性。这使得这两种方法难以比较。我们可以通过调整apply_model
使用的概率阈值,使其具有与 KNN 大致相同的敏感性,从而解决这个问题。我们可以通过迭代prob
的值,直到获得接* KNN 的敏感性的概率。
如果我们用prob = 0.578
来调用apply_model
而不是0.5
,我们会得到以下结果。
Accuracy = 0.659
Sensitivity = 0.715
Specificity = 0.586
Pos. Pred. Val. = 0.695
换句话说,这些模型的性能相似。
图 26-16 使用逻辑回归预测性别
由于探索改变逻辑回归模型的决策阈值的影响可能会很复杂,人们常常使用称为接收器操作特征曲线,²⁰⁴或ROC 曲线,来可视化敏感性和特异性之间的权衡。该曲线绘制了多个决策阈值下的真实正例率(敏感性)与假正例率(1
– 特异性)的关系。
ROC 曲线通常通过计算曲线下的面积(AUROC,常缩写为AUC)来彼此比较。该面积等于模型将随机选择的正例分配更高的正概率的概率,相对于随机选择的负例。这被称为模型的区分能力。请记住,区分能力并不反映概率的准确性,通常称为校准。例如,我们可以将所有估计的概率除以2
,而不会改变区分能力,但肯定会改变估计的准确性。
图 26-17 中的代码将逻辑回归分类器的 ROC 曲线绘制为实线,图 26-18。虚线是随机分类器的 ROC——一个随机选择标签的分类器。我们本可以先插值(因为我们只有离散的点)然后积分 ROC 曲线来计算 AUROC,但我们懒惰地直接调用了函数sklearn.metrics.auc
。
图 26-17 构建 ROC 曲线并找到 AUROC
图 26-18 ROC 曲线和 AUROC
指尖练习:编写代码以绘制 ROC 曲线并计算在 200 名随机选择的竞争者上测试时所构建模型的 AUROC。使用该代码调查训练样本数量对 AUROC 的影响(尝试从10
变化到1010
,增量为50
)。
26.5 生存于泰坦尼克号
在 1912 年 4 月 15 日的早晨,RMS 泰坦尼克号撞上冰山并在北大西洋沉没。大约有1,300
名乘客在船上,832
人在这场灾难中遇难。许多因素导致了这场灾难,包括导航错误、救生艇不足以及附*船只反应缓慢。个别乘客的生存与否有随机因素,但远非完全随机。有一个有趣的问题是,是否可以仅通过船上乘客名单的信息建立一个合理的生存预测模型。
在本节中,我们从一个包含1046
名乘客信息的 CSV 文件构建分类模型。²⁰⁵ 文件的每一行包含关于单个乘客的信息:舱位等级(1 等、2 等或 3 等)、年龄、性别、乘客是否在灾难中幸存以及乘客的姓名。CSV 文件的前几行是
Class,Age,Gender,Survived,Last Name,Other Names
1,29.0,F,1,Allen, Miss. Elisabeth Walton
1,0.92,M,1,Allison, Master. Hudson Trevor
1,2.0,F,0,Allison, Miss. Helen Loraine
在构建模型之前,快速查看一下数据,可能是个好主意。这样做通常能提供有关各种特征在模型中可能发挥的作用的有用见解。执行代码
manifest = pd.read_csv('TitanicPassengers.csv')
print(manifest.corr().round(2))
生成相关性表
Class Age Survived
Class 1.00 -0.41 -0.32
Age -0.41 1.00 -0.06
Survived -0.32 -0.06 1.00
为什么Gender
没有出现在这个表中?因为它在 CSV 文件中没有编码为数字。我们来处理一下,看看相关性是什么样的。
manifest['Gender'] = (manifest['Gender'].
apply(lambda g: 1 if g == 'M' else 0))
print(manifest.corr().round(2))
生成
Class Age Gender Survived
Class 1.00 -0.41 0.14 -0.32
Age -0.41 1.00 0.06 -0.06
Gender 0.14 0.06 1.00 -0.54
Survived -0.32 -0.06 -0.54 1.00
class
和Gender
与Survived
之间的负相关性表明,确实有可能利用清单中的信息建立预测模型。(因为我们将男性编码为 1,女性编码为 0,Survived
和Gender
的负相关性告诉我们,女性比男性更可能生存。同样,Class
的负相关性表明,头等舱的乘客更安全。)
现在,让我们使用逻辑回归构建一个模型。我们选择使用逻辑回归是因为
这是最常用的分类方法。
通过检查逻辑回归生成的权重,我们可以获得一些关于为什么某些乘客比其他乘客更可能生存的见解。
图 26-19 定义了Passenger
类。该代码中唯一感兴趣的地方是舱位等级的编码。虽然 CSV 文件将舱位等级编码为整数,但它实际上是类别的简写。舱位等级不像数字那样运作,例如,一个头等舱加一个二等舱并不等于一个三等舱。我们使用三个二进制特征(每种可能的舱位等级一个)对舱位等级进行编码。对于每位乘客,这三个变量中的一个被设置为1
,其他两个被设置为0
。
这是机器学习中经常出现的问题的一个例子。类别特征(有时称为名义特征)是描述许多事物的自然方式,例如,跑步者的国家。用整数替换这些特征是很简单的,例如,我们可以根据国家的 ISO 3166-1 数字代码来选择表示,例如,巴西为 076,英国为 826,委内瑞拉为 862。这样做的问题在于,回归会将这些视为数值变量,从而对国家施加无意义的排序,导致委内瑞拉距离英国比距离巴西更*。
通过将分类变量转换为二元变量可以避免这个问题,就像我们处理舱位类时所做的那样。这样做的一个潜在问题是,它可能导致非常长且稀疏的特征向量。例如,如果一家医院配发 2000
种不同的药物,我们将把一个分类变量转换为 2000
个二元变量,每种药物一个。
图 26-20 包含使用 Pandas 从文件中读取数据并根据 泰坦尼克号 数据构建示例集的代码。
现在我们有了数据,可以使用构建波士顿马拉松数据模型时使用的相同代码构建逻辑回归模型。然而,由于数据集样本相对较少,我们需要关注使用之前采用的评估方法。完全有可能得到一个不具代表性的 80-20
数据划分,然后生成误导性结果。
为了降低风险,我们创建了许多 80-20
划分(每个划分使用在 图 26-6 中定义的 divide_80_20
函数创建),为每个划分构建和评估一个分类器,然后报告均值和 95%
置信区间,使用 图 26-21 和 图 26-22 中的代码。
图 26-19 类 Passenger
图 26-20 读取 泰坦尼克号 数据并构建示例列表²⁰⁷
图 26-21 泰坦尼克号 生存测试模型
图 26-22 打印有关分类器的统计信息
调用 test_models(build_Titanic_examples(), 100, True, False)
打印了
Averages for 100 trials
Mean accuracy = 0.783, 95% conf. int. = 0.736 to 0.83
Mean sensitivity = 0.702, 95% conf. int. = 0.603 to 0.801
Mean specificity = 0.783, 95% conf. int. = 0.736 to 0.83
Mean pos. pred. val. = 0.702, 95% conf. int. = 0.603 to 0.801
Mean AUROC = 0.839, 95% conf. int. = 0.789 to 0.889
看起来这小组特征足以很好地预测生存情况。为了了解原因,让我们看看各种特征的权重。我们可以通过调用 test_models(build_Titanic_examples(), 100, False, True)
来做到这一点,它打印了
Averages for 100 trials
Mean weight 1st Class = 1.145, 95% conf. int. = 1.02 to 1.27
Mean weight 2nd Class = -0.083, 95% conf. int. = -0.185 to 0.019
Mean weight 3rd Class = -1.062, 95% conf. int. = -1.179 to -0.945
Mean weight age = -0.034, 95% conf. int. = -0.04 to -0.028
Mean weight male = -2.404, 95% conf. int. = -2.542 to -2.266
当谈到船难生存时,似乎拥有财富是有用的(泰坦尼克号的一等舱舱位在今天的美国美元中相当于超过$70,000),年轻和女性也是优势。
26.6 总结
在最后三章中,我们几乎只是触及了机器学习的表面。
这同样适用于本书第二部分中介绍的许多其他主题。我试图让你感受到利用计算更好理解世界所涉及的思维方式——希望你能找到独立研究该主题的方法。
26.7 章节中引入的术语
分类模型
类别
标签
单类学习
两类学习
二元分类
多类学习
测试集
训练误差
混淆矩阵
准确率
类别不*衡
敏感性(召回率)
特异性(精准度)
正预测值(PPV)
k 最*邻(KNN)
下采样
阴性预测值
n 倍交叉验证
逻辑回归
结果
特征权重
ROC 曲线
AUROC
模型区分度
校准
类别特征
第二十七章:PYTHON 3.8 快速参考
对数值类型的常见操作
**i+j**
是 i
和 j
的和。
**i–j**
是 i
减去 j
。
**i*j**
是 i
和 j
的乘积。
**i//j**
是向下取整除法。
**i/j**
是浮点除法。
**i%j**
是整型 i
除以整型 j
的余数。
**i**j**
是 i
的 j
次幂。
**x += y**
等同于 x = x + y
。***=**
和 **-=**
也以相同方式工作。
比较运算符有 ==
(等于)、 !=
(不等于)、 >
(大于)、 >=
(至少)、 <
(小于)和 <=
(最多)。
布尔运算符
**x == y**
如果 x
和 y
相等,则返回 True
。
**x != y**
如果 x
和 y
不相等,则返回 True
。
**<, >, <=, >=**
具有其通常的含义。
**a and b**
如果 a
和 b
都为 True
,则为 True
,否则为 False
。
**a or b**
如果 a
或 b
至少有一个为 True
,则为 True
,否则为 False
。
**not a**
如果 a
为 False
,则为 True
;如果 a
为 True
,则为 False
。
对序列类型的常见操作
**seq[i]**
返回序列中的第 i
个元素。
**len(seq)**
返回序列的长度。
**seq1 + seq2**
连接两个序列。(范围不适用。)
**n*seq**
返回一个重复 seq
n
次的序列。(范围不适用。)
**seq[start:end]**
返回一个新的序列,它是 seq
的切片。
**e in seq**
测试 e
是否包含在序列中。
**e not in seq**
测试 e
是否不包含在序列中。
**for e in seq**
遍历序列中的元素。
常见字符串方法
**s.count(s1)**
计算字符串 s1
在 s
中出现的次数。
**s.find(s1)**
返回子字符串 s1
在 s
中第一次出现的索引;如果 s1
不在 s
中,则返回 -1
。
**s.rfind(s1)**
与 find
相同,但从 s
的末尾开始。
**s.index(s1)**
与 find
相同,但如果 s1
不在 s
中,则引发异常。
**s.rindex(s1)**
与 index
相同,但从 s
的末尾开始。
**s.lower()**
将所有大写字母转换为小写。
**s.replace(old, new)**
将字符串 old
的所有出现替换为字符串 new
。
**s.rstrip()**
移除末尾的空白字符。
**s.split(d)**
使用 d
作为分隔符分割 s
。返回 s
的子字符串列表。
常见列表方法
**L.append(e)**
将对象 e
添加到列表 L
的末尾。
**L.count(e)**
返回元素 e
在列表 L
中出现的次数。
**L.insert(i, e)**
在列表 L
的索引 i
处插入对象 e
。
**L.extend(L1)**
将列表 L1
中的项追加到列表 L
的末尾。
**L.remove(e)**
从列表 L
中删除 e
的第一次出现。
**L.index(e)**
返回 e
在列表 L
中第一次出现的索引。如果 e
不在 L
中,则引发 ValueError
。
**L.pop(i)**
移除并返回索引 i
处的项;i
默认为 -1
。如果 L
为空,则引发 IndexError
。
**L.sort()**
具有对 L
中元素进行排序的副作用。
**L.reverse()**
具有反转 L
中元素顺序的副作用。
**L.copy()**
返回 L
的浅拷贝。
**L.deepcopy()**
返回 L
的深拷贝。
字典的常见操作
**len(d)**
返回 d
中项目的数量。
**d.keys()**
返回 d
中键的视图。
**d.values()**
返回 d
中值的视图。
**d.items()**
返回 d
中的 (键, 值) 对的视图。
**k in d**
如果键 k
在 d
中,则返回 True
。
**d[k]**
返回 d
中键为 k
的项目。如果 k
不在 d
中,则引发 KeyError
。
**d.get(k, v)**
如果 k
在 d
中,则返回 d[k]
,否则返回 v
。
**d[k] = v**
将值 v
关联到键 k
。如果 k
已经关联了一个值,则该值会被替换。
**del d[k]**
从 d
中删除键为 k
的元素。如果 k
不在 d
中,则引发 KeyError
。
**for k in d**
遍历 d
中的键。
常见的输入/输出机制
**input(msg)**
打印 msg
,然后返回输入的值作为字符串。
**print(s1, …, sn)**
打印字符串 s1, …, sn
,并用空格分隔。
**open('file_name', 'w')**
创建一个用于写入的文件。
**open('file_name', 'r')**
打开现有文件以进行读取。
**open('file_name', 'a')**
打开现有文件以进行追加。
**file_handle.read()**
返回包含文件内容的字符串。
**file_handle.readline()**
返回文件中的下一行。
**file_handle.readlines()**
返回包含文件行的列表。
**file_handle.write(s)**
将字符串 s
写入文件末尾。
**file_handle.writelines(L)**
将 L
的每个元素写入文件作为单独的行。
**file_handle.close()**
关闭文件。