Python-统计和微积分研讨会(一)
Python 统计和微积分研讨会(一)
原文:
zh.annas-archive.org/md5/6cbaed7d834977b8ea96cc7aa6d8a083
译者:飞龙
前言
关于本书
你是否想要开始开发人工智能应用程序?您是否需要对关键数学概念进行复习?充满有趣的实践练习,《Python 统计学和微积分工作坊》将向您展示如何在 Python 环境中应用您对高级数学的理解。
本书首先概述了在使用 Python 进行统计时将使用的库。随着学习的深入,您将使用 Python 编程语言执行各种数学任务,例如使用 Python 解决代数函数,从基本函数开始,然后进行变换和解方程。本书的后几章将涵盖统计学和微积分概念,以及如何使用它们来解决问题并获得有用的见解。最后,您将学习重点是数值方法的微分方程,并了解直接计算函数值的算法。
通过本书,您将学会如何将基本统计学和微积分概念应用于开发解决业务挑战的强大 Python 应用程序。
受众
如果您是一名希望开发解决具有挑战性的业务问题的智能解决方案的 Python 程序员,那么本书适合您。为了更好地理解本书中解释的概念,您必须对高级数学概念有透彻的理解,例如马尔可夫链、欧拉公式和龙格-库塔方法,因为本书只解释了这些技术和概念如何在 Python 中实现。
关于章节
第一章《Python 基础》介绍了 Python 语言。您将学习如何使用 Python 最基本的数据结构和控制流程,以及掌握针对编程特定任务的最佳实践,如调试、测试和版本控制。
第二章《Python 统计学的主要工具》介绍了 Python 中科学计算和可视化的生态系统。这些讨论将围绕着促进这些任务的特定 Python 库展开,如 NumPy、pandas 和 Matplotlib。动手练习将帮助您练习它们的使用。
第三章《Python 统计工具箱》描述了统计分析的理论基础。您将了解统计学领域的基本组成部分,即各种类型的统计和统计变量。本章还包括对各种不同 Python 库和工具的简要概述,这些库和工具可以帮助简化专门任务,如 SymPy、PyMC3 和 Bokeh。
第四章《Python 函数和代数》讨论了数学函数和代数方程的理论基础。这些讨论还伴随着交互式练习,展示了 Python 中相应的工具,可以简化和/或自动化各种过程,如绘制函数图形和解方程组。
第五章《Python 更多数学知识》教授您序列、级数、三角学和复数的基础知识。虽然这些可能是具有挑战性的理论主题,但我们将从不同的实际角度考虑它们,特别是通过实际应用,如财务分析和 401(k)/退休计算。
第六章《Python 矩阵和马尔可夫链》介绍了矩阵和马尔可夫链的概念。这些是数学对象,在人工智能和机器学习等一些最流行的数学应用中常用。本章配有动手实践活动,开发一个单词预测器。
第七章《Python 基础统计学》标志着本书重点讨论统计和统计分析的部分的开始。本章介绍了探索性数据分析的过程,以及一般使用简单的统计技术来解释数据集。
第八章《Python 基础概率概念及其应用》深入探讨了复杂的统计概念,如随机性,随机变量以及使用模拟作为分析随机性的技术。本章将帮助您更加熟练地处理涉及随机性的统计问题。
第九章《Python 中级统计学》总结了统计学的主题,重点介绍了该领域中最重要的理论,如大数定律和中心极限定理,以及常用的技术,包括置信区间,假设检验和线性回归。通过本章获得的知识,您将能够使用 Python 解决许多现实生活中的统计问题。
第十章《Python 基础微积分》开始讨论微积分的主题,包括更多涉及的概念,如函数的斜率,曲线下的面积,优化和旋转体。这些通常被认为是数学中复杂的问题,但本书通过 Python 以直观和实用的方式解释这些概念。
第十一章《Python 更多微积分》涉及微积分中更复杂的主题,包括弧长和表面积的计算,偏导数和级数展开。再次,我们将看到 Python 在帮助我们处理这些高级主题方面的强大力量,这些通常对许多学生来说可能非常具有挑战性。
第十二章《Python 中级微积分》总结了本书中最有趣的微积分主题,如微分方程,欧拉方法和 Runge-Kutta 方法。这些方法提供了解微分方程的算法方法,特别适用于 Python 作为计算工具。
约定
文本中的代码单词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄显示如下:
“为此,我们可以使用with
关键字和open()
函数与文本文件交互。”
代码块设置如下:
if x % 6 == 0:
print('x is divisible by 6')
在某些情况下,一行代码紧接着它的输出。这些情况如下所示:
>>> find_sum([1, 2, 3])
6
在此示例中,执行的代码是以>>>
开头的行,输出是第二行(6
)。
在其他情况下,输出与代码块分开显示,以便阅读。
屏幕上显示的单词,例如菜单或对话框中的单词,也会出现在文本中,如:“当您单击获取图像
按钮时,图像将显示作者的名称。”
新术语和重要单词显示如下:“将返回的列表以相同的逗号分隔值(CSV)格式写入同一输入文件的新行中”。
代码演示
跨多行的代码使用反斜杠(\
)进行分割。当代码执行时,Python 将忽略反斜杠,并将下一行的代码视为当前行的直接延续。
例如:
history = model.fit(X, y, epochs=100, batch_size=5, verbose=1, \
validation_split=0.2, shuffle=False)
代码中添加了注释以帮助解释特定的逻辑部分。单行注释使用#
符号表示,如下所示:
# Print the sizes of the dataset
print("Number of Examples in the Dataset = ", X.shape[0])
print("Number of Features for each example = ", X.shape[1])
多行注释使用三重引号括起来,如下所示:
"""
Define a seed for the random number generator to ensure the
result will be reproducible
"""
seed = 1
np.random.seed(seed)
random.set_seed(seed)
设置您的环境
在我们详细探讨本书之前,我们需要设置特定的软件和工具。在接下来的部分中,我们将看到如何做到这一点。
软件要求
您还需要预先安装以下软件:
-
操作系统:Windows 7 SP1 64 位,Windows 8.1 64 位或 Windows 10 64 位,macOS 或 Linux
-
浏览器:最新版本的 Google Chrome,Firefox 或 Microsoft Edge
-
Python 3.7
-
Jupyter 笔记本
安装和设置
在开始本书之前,您需要安装 Python(3.7 或更高版本)和 Jupyter,这是我们将在整个章节中使用的主要工具。
安装 Python
安装 Python 的最佳方法是通过环境管理器 Anaconda,可以从docs.anaconda.com/anaconda/install/
下载。一旦成功安装了 Anaconda,您可以按照docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html
上的说明创建一个虚拟环境,其中可以运行 Python。
与其他安装 Python 的方法不同,Anaconda 提供了一个易于导航的界面,当安装 Python 及其库时,它还负责大部分低级过程。
按照上述说明,您可以使用命令conda create -n workshop python=3.7
创建一个名为workshop
的新环境。要激活新环境,请运行conda activate workshop
。在接下来的步骤中,您需要每次需要测试代码时激活此环境。
在本研讨会中,每次使用尚未安装的新库时,可以使用pip install [library_name]
或conda install [library_name]
命令来安装该库。
Jupyter 项目
Jupyter 项目是开源的免费软件,它使您能够从特殊的笔记本中以交互方式运行用 Python 和其他一些语言编写的代码,类似于浏览器界面。它诞生于 2014 年的IPython项目,自那时起就成为整个数据科学工作人员的默认选择。
要在workshop
环境中安装 Jupyter Notebook,只需运行conda install -c conda-forge notebook
。有关 Jupyter 安装的更多信息,请访问:jupyter.org/install
。
在jupyterlab.readthedocs.io/en/stable/getting_started/starting.html
上,您将找到所有关于如何启动 Jupyter Notebook 服务器的详细信息。在本书中,我们使用经典的笔记本界面。
通常,我们从 Anaconda Prompt 使用jupyter notebook
命令启动笔记本。
从您选择下载代码文件的目录开始笔记本,参见安装代码包部分。
例如,如果您已将文件安装在 macOS 目录/Users/YourUserName/Documents/
The-Statistics-and-Calculus-with-Python-Workshop
中,那么在 CLI 中,您可以输入cd /Users/YourUserName/Documents/The-Statistics-and-Calculus-with-Python-Workshop
并运行jupyter notebook
命令。Jupyter 服务器将启动,您将看到 Jupyter 浏览器控制台:
图 0.1:Jupyter 浏览器控制台
一旦您运行了 Jupyter 服务器,点击New
,选择Python 3
。一个新的浏览器标签页将打开一个新的空白笔记本。重命名 Jupyter 文件:
图 0.2:Jupyter 服务器界面
Jupyter 笔记本的主要构建模块是单元格。有两种类型的单元格:In
(输入的缩写)和Out
(输出的缩写)。您可以在In
单元格中编写代码、普通文本和 Markdown,按Shift + Enter(或Shift + Return),那个特定In
单元格中编写的代码将被执行。结果将显示在Out
单元格中,然后您将进入一个新的In
单元格,准备好下一个代码块。一旦您习惯了这个界面,您将慢慢发现它提供的强大和灵活性。
当您开始一个新的单元格时,默认情况下假定您将在其中编写代码。但是,如果您想要编写文本,那么您必须更改类型。您可以使用以下键序列来执行此操作:Esc | M | Enter。这将将所选单元格转换为Markdown(M)单元格类型:
图 0.3:Jupyter Notebook
当您完成编写一些文本时,请使用Shift + Enter执行它。与代码单元格不同,编译后的 Markdown 的结果将显示在与In
单元格相同的位置。
要获取 Jupyter 中所有方便的快捷键的备忘单,请访问packt.live/33sJuB6
。通过这个基本介绍,我们准备开始一段激动人心和启发人心的旅程。
安装库
pip
已经预装在 Anaconda 中。一旦在您的计算机上安装了 Anaconda,所有所需的库都可以使用pip
安装,例如pip install numpy
。或者,您可以使用pip install –r requirements.txt
安装所有所需的库。您可以在packt.live/3gv0zhb
找到requirements.txt
文件。
练习和活动将在 Jupyter 笔记本中执行。Jupyter 是一个 Python 库,可以像其他 Python 库一样安装-即使用pip install jupyter
,但幸运的是,它已经预装在 Anaconda 中。要打开笔记本,只需在终端或命令提示符中运行jupyter notebook
命令。
访问代码文件
您可以在packt.live/3kcWZe6
找到本书的完整代码文件。您还可以通过使用packt.live/2PpqDOX
上的交互式实验室环境直接在 Web 浏览器中运行许多活动和练习。
我们已经尝试支持所有活动和练习的交互式版本,但我们也建议在不支持此功能的情况下进行本地安装。
如果您在安装过程中遇到任何问题或有任何问题,请发送电子邮件至workshops@packt.com
与我们联系。
第一章:Python 基础
概述
本章回顾了将在未来讨论中使用的基本 Python 数据结构和工具。这些概念将使我们能够刷新我们对 Python 最基本和重要特性的记忆,同时为以后章节中的高级主题做好准备。
通过本章结束时,您将能够使用控制流方法设计 Python 程序并初始化常见的 Python 数据结构,以及操纵它们的内容。您将巩固对 Python 算法设计中函数和递归的理解。您还将能够为 Python 程序进行调试、测试和版本控制。最后,在本章末尾的活动中,您将创建一个数独求解器。
介绍
Python 近年来在数学领域的受欢迎程度和使用率前所未有。然而,在深入讨论数学的高级主题之前,我们需要巩固对语言基础知识的理解。
本章将对 Python 的一般概念进行复习;所涵盖的主题将使您在本书的后续讨论中处于最佳位置。具体来说,我们将复习一般编程中的基本概念,如条件和循环,以及 Python 特定的数据结构,如列表和字典。我们还将讨论函数和算法设计过程,这是包括与数学相关的程序在内的任何中型或大型 Python 项目中的重要部分。所有这些都将通过实践练习和活动来完成。
通过本章结束时,您将能够在本书后续章节中处理更复杂、更有趣的问题。
控制流方法
控制流是一个通用术语,表示可以重定向程序执行的任何编程语法。一般来说,控制流方法是使程序在执行和计算时具有动态性的原因:根据程序的当前状态或输入,程序的执行和输出将动态改变。
if 语句
任何编程语言中最常见的控制流形式是条件语句,或者if
语句。if
语句用于检查程序当前状态的特定条件,并根据结果(条件是真还是假)执行不同的指令集。
在 Python 中,if
语句的语法如下:
if [condition to check]:
[instruction set to execute if condition is true]
鉴于 Python 的可读性,你可能已经猜到条件语句的工作原理:当给定程序的执行达到条件语句并检查if
语句中的条件时,如果条件为真,则将执行缩进的指令集在if
语句内部;否则,程序将简单地跳过这些指令并继续执行。
在if
语句内部,我们可以检查复合条件,这是多个单独条件的组合。例如,使用and
关键字,当满足其两个条件时,将执行以下if
块:
if [condition 1] and [condition 2]:
[instruction set]
与此相反,我们可以在复合条件中使用or
关键字,如果关键字左侧或右侧的条件为真,则显示正(真)。还可以使用多个and
/or
关键字扩展复合条件,以实现嵌套在多个级别上的条件语句。
当条件不满足时,我们可能希望程序执行不同的一组指令。为了实现这种逻辑,我们可以使用elif
和else
语句,这些语句应该紧随在if
语句之后。如果if
语句中的条件不满足,我们的程序将继续并评估elif
语句中的后续条件;如果没有一个条件被满足,else
块中的任何代码都将被执行。Python 中的if...elif...else
块的形式如下:
if [condition 1]:
[instruction set 1]
elif [condition 2]:
[instruction set 2]
...
elif [condition n]:
[instruction set n]
else:
[instruction set n + 1]
当程序需要检查一组可能性时,这种控制流方法非常有价值。根据给定时刻的真实可能性,程序应该执行相应的指令。
练习 1.01:条件除法
在数学中,对变量及其内容的分析是非常常见的,其中最常见的分析之一是整数的可整除性。在这个练习中,我们将使用if
语句来考虑给定数字是否可以被 5、6 或 7 整除。
为了实现这一点,请按照以下步骤进行:
- 创建一个新的 Jupyter 笔记本,并声明一个名为
x
的变量,其值为任何整数,如下面的代码所示:
x = 130
- 在声明之后,编写一个
if
语句,检查x
是否可以被 5 整除。相应的代码块应该打印出一个指示条件是否满足的语句:
if x % 5 == 0:
print('x is divisible by 5')
在这里,%
是 Python 中的取模运算符;var % n
表达式返回当我们用数字n
除以变量var
时的余数。
- 在同一个代码单元格中,编写两个
elif
语句,分别检查x
是否可以被 6 和 7 整除。适当的print
语句应该放在相应条件下面:
elif x % 6 == 0:
print('x is divisible by 6')
elif x % 7 == 0:
print('x is divisible by 7')
- 编写最终的
else
语句,以打印一条消息,说明x
既不能被 5 整除,也不能被 6 或 7 整除(在同一个代码单元格中):
else:
print('x is not divisible by 5, 6, or 7')
- 每次为
x
分配一个不同的值来测试我们的条件逻辑。以下输出是x
被赋值为104832
的一个示例:
x is divisible by 6
- 现在,我们不想打印关于
x
的可整除性的消息,而是想将该消息写入文本文件。具体来说,我们想创建一个名为output.txt
的文件,其中包含我们先前打印出的相同消息。
为了做到这一点,我们可以使用with
关键字和open()
函数与文本文件进行交互。请注意,open()
函数接受两个参数:要写入的文件名,在我们的例子中是output.txt
,以及w
(表示写入),指定我们想要写入文件,而不是从文件中读取内容:
if x % 5 == 0:
with open('output.txt', 'w') as f:
f.write('x is divisible by 5')
elif x % 6 == 0:
with open('output.txt', 'w') as f:
f.write('x is divisible by 6')
elif x % 7 == 0:
with open('output.txt', 'w') as f:
f.write('x is divisible by 7')
else:
with open('output.txt', 'w') as f:
f.write('x is not divisible by 5, 6, or 7')
- 检查输出文本文件中的消息是否正确。如果
x
变量仍然保持值104832
,则您的文本文件应该包含以下内容:
x is divisible by 6
在这个练习中,我们应用了条件语句来编写一个程序,使用%
运算符来确定给定数字是否可以被 6、3 和 2 整除。我们还学习了如何在 Python 中向文本文件写入内容。在下一节中,我们将开始讨论 Python 中的循环。
注意
elif
块中的代码行按顺序执行,并在任何一个条件为真时中断序列。这意味着当 x 被赋值为 30 时,一旦满足x%5==0
,就不会检查x%6==0
。
要访问此特定部分的源代码,请参阅packt.live/3dNflxO.
您也可以在packt.live/2AsqO8w
上在线运行此示例。
循环
另一个广泛使用的控制流方法是使用循环。这些用于在指定范围内重复执行相同的指令,或者在满足条件时重复执行相同的指令。Python 中有两种类型的循环:while
循环和for
循环。让我们详细了解每种循环。
while 循环
while
循环,就像if
语句一样,检查指定的条件,以确定给定程序的执行是否应该继续循环。例如,考虑以下代码:
>>> x = 0
>>> while x < 3:
... print(x)
... x += 1
0
1
2
在前面的代码中,x
被初始化为值0
后,使用while
循环来连续打印变量的值,并在每次迭代中递增相同的变量。可以想象,当这个程序执行时,将打印出0
、1
和2
,当x
达到3
时,while
循环中指定的条件不再满足,因此循环结束。
请注意,x += 1
命令对应于x = x + 1
,它在循环的每次迭代中增加x
的值。如果我们删除这个命令,那么我们将得到一个无限循环,每次打印0
。
for 循环
另一方面,for
循环通常用于迭代特定序列的值。使用 Python 中的range
函数,以下代码产生了与我们之前相同的输出:
>>> for x in range(3):
... print(x)
0
1
2
in
关键字是 Python 中任何for
循环的关键:当使用它时,其前面的变量将被分配在我们想要顺序循环的迭代器中的值。在前面的例子中,x
变量被分配了range(3)
迭代器中的值,依次是0
、1
和2
,在for
循环的每次迭代中。
在 Python 的for
循环中,除了range()
之外,还可以使用其他类型的迭代器。以下表格简要总结了一些最常见的用于for
循环的迭代器。如果您对此表中包含的数据结构不熟悉,不要担心;我们将在本章后面介绍这些概念:
图 1.1:数据集及其示例列表
还可以在彼此内部嵌套多个循环。当给定程序的执行位于循环内部时,我们可以使用break
关键字退出当前循环并继续执行。
练习 1.02:猜数字游戏
在这个练习中,我们将把我们对循环的知识付诸实践,并编写一个简单的猜数字游戏。程序开始时随机选择一个介于 0 和 100 之间的目标整数。然后,程序将接受用户输入作为猜测这个数字的猜测。作为回应,程序将打印出一条消息,如果猜测大于实际目标,则打印Lower
,如果相反,则打印Higher
。当用户猜对时,程序应该终止。
执行以下步骤完成这个练习:
- 在新的 Jupyter 笔记本的第一个单元格中,导入 Python 中的
random
模块,并使用其randint
函数生成随机数:
import random
true_value = random.randint(0, 100)
每次调用randint()
函数时,它都会生成两个传递给它的数字之间的随机整数;在我们的情况下,将生成介于 0 和 100 之间的整数。
虽然它们在本练习的其余部分中并不需要,但如果您对随机模块提供的其他功能感兴趣,可以查看其官方文档docs.python.org/3/library/random.html
。
注意
程序的其余部分也应该放在当前代码单元格中。
- 使用 Python 中的
input()
函数接受用户的输入,并将返回的值赋给一个变量(在以下代码中为guess
)。这个值将被解释为用户对目标的猜测:
guess = input('Enter your guess: ')
- 使用
int()
函数将用户输入转换为整数,并将其与真实目标进行比较。针对比较的所有可能情况打印出适当的消息:
guess = int(guess)
if guess == true_value:
print('Congratulations! You guessed correctly.')
elif guess > true_value:
print('Lower.') # user guessed too high
else:
print('Higher.') # user guessed too low
注意
下面代码片段中的#
符号表示代码注释。注释被添加到代码中,以帮助解释特定的逻辑部分。
- 使用我们当前的代码,如果
int()
函数的输入无法转换为整数(例如,输入为字符串字符),它将抛出错误并使整个程序崩溃。因此,我们需要在try...except
块中实现我们的代码,以处理用户输入非数字值的情况:
try:
if guess == true_value:
print('Congratulations! You guessed correctly.')
elif guess > true_value:
print('Lower.') # user guessed too high
else:
print('Higher.') # user guessed too low
# when the input is invalid
except ValueError:
print('Please enter a valid number.')
- 目前,用户只能在程序终止之前猜一次。为了实现允许用户重复猜测直到找到目标的功能,我们将迄今为止开发的逻辑包装在一个
while
循环中,只有当用户猜对时(通过适当放置while True
循环和break
关键字来实现)才会中断。
完整的程序应该类似于以下代码:
import random
true_value = random.randint(0, 100)
while True:
guess = input('Enter your guess: ')
try:
guess = int(guess)
if guess == true_value:
print('Congratulations! You guessed correctly.')
break
elif guess > true_value:
print('Lower.') # user guessed too high
else:
print('Higher.') # user guessed too low
# when the input is invalid
except ValueError:
print('Please enter a valid number.')
- 尝试通过执行代码单元格重新运行程序,并尝试不同的输入选项,以确保程序可以很好地处理其指令,并处理无效输入的情况。例如,当目标数字被随机选择为 13 时,程序可能产生的输出如下:
Enter your guess: 50
Lower.
Enter your guess: 25
Lower.
Enter your guess: 13
Congratulations! You guessed correctly.
在这个练习中,我们已经练习了在猜数字游戏中使用while
循环,以巩固我们对编程中循环使用的理解。此外,您已经了解了在 Python 中读取用户输入和random
模块的方法。
注意
要访问此特定部分的源代码,请参阅packt.live/2BYK6CR.
您也可以在packt.live/2CVFbTu
上在线运行此示例。
接下来,我们将开始考虑常见的 Python 数据结构。
数据结构
数据结构是代表您可能想要在程序中创建、存储和操作的不同形式的信息的变量类型。与控制流方法一起,数据结构是任何编程语言的另一个基本构建块。在本节中,我们将介绍 Python 中一些最常见的数据结构,从字符串开始。
字符串
字符串是字符序列,通常用于表示文本信息(例如,消息)。Python 字符串由单引号或双引号中的任何给定文本数据表示。例如,在以下代码片段中,a
和b
变量保存相同的信息:
a = 'Hello, world!'
b = "Hello, world!"
由于在 Python 中字符串大致被视为序列,因此可以将常见的与序列相关的操作应用于此数据结构。特别是,我们可以将两个或多个字符串连接在一起以创建一个长字符串,我们可以使用for
循环遍历字符串,并且可以使用索引和切片访问单个字符和子字符串。这些操作的效果在以下代码中进行了演示:
>>> a = 'Hello, '
>>> b = 'world!'
>>> print(a + b)
Hello, world!
>>> for char in a:
... print(char)
H
e
l
l
o
,
# a blank character printed here, the last character in string a
>>> print(a[2])
l
>>> print(a[1: 4])
ell
在 Python 3.6 中添加的最重要的功能之一是 f-strings,这是 Python 中格式化字符串的语法。由于我们使用的是 Python 3.7,因此可以使用此功能。字符串格式化用于在我们想要将给定变量的值插入预定义字符串时使用。在 f-strings 之前,还有两种其他格式化选项,您可能熟悉:%-格式化和str.format()
。不详细介绍这两种方法,这两种方法都有一些不良特性,因此开发了 f-strings 来解决这些问题。
f-strings 的语法是用大括号{
和}
定义的。例如,我们可以使用 f-string 将变量的打印值组合如下:
>>> a = 42
>>> print(f'The value of a is {a}.')
The value of a is 42.
当将变量放入 f-string 大括号中时,它的__str__()
表示将在最终的打印输出中使用。这意味着在使用 Python 对象时,您可以通过覆盖和自定义 dunder 方法__str__()
来获得 f-strings 的更多灵活性。
在 f-strings 中,可以使用冒号来指定字符串的常见数字格式选项,例如指定小数点后的位数或日期时间格式,如下所示:
>>> from math import pi
>>> print(f'Pi, rounded to three decimal places, is {pi:.3f}.')
Pi, rounded to three decimal places, is 3.142.
>>> from datetime import datetime
>>> print(f'Current time is {datetime.now():%H:%M}.')
Current time is 21:39.
f-strings 的另一个好处是它们比其他两种字符串格式化方法更快渲染和处理。接下来,让我们讨论 Python 列表。
列表
列表可以说是 Python 中最常用的数据结构。它是 Python 版本的 Java 或 C/C++中的数组。列表是可以按顺序访问或迭代的元素序列。与 Java 数组不同,Python 列表中的元素不必是相同的数据结构,如下所示:
>>> a = [1, 'a', (2, 3)] # a list containing a number, a string, and a tuple
注意
我们将在下一节更多地讨论元组。
正如我们之前讨论过的,列表中的元素可以在for
循环中以与字符串中字符类似的方式进行迭代。列表也可以像字符串一样进行索引和切片:
>>> a = [1, 'a', (2, 3), 2]
>>> a[2]
(2, 3)
>>> a[1: 3]
['a', (2, 3)]
有两种方法可以向 Python 列表添加新元素:append()
将一个新的单个元素插入到列表的末尾,而列表连接简单地将两个或多个字符串连接在一起,如下所示:
>>> a = [1, 'a', (2, 3)]
>>> a.append(3)
>>> a
[1, 'a', (2, 3), 3]
>>> b = [2, 5, 'b']
>>> a + b
[1, 'a', (2, 3), 3, 2, 5, 'b']
要从列表中删除一个元素,可以使用pop()
方法,该方法接受要删除的元素的索引。
使 Python 列表独特的操作之一是列表推导:一种 Python 语法,可以使用放置在方括号内的for
循环来高效地初始化列表。列表推导通常用于当我们想要对现有列表应用操作以创建新列表时。例如,假设我们有一个包含一些整数的列表变量a
:
>>> a = [1, 4, 2, 9, 10, 3]
现在,我们想要创建一个新的列表b
,其元素是a
中元素的两倍,按顺序。我们可以潜在地将b
初始化为空列表,并迭代地遍历a
并将适当的值附加到b
。然而,使用列表推导,我们可以用更优雅的语法实现相同的结果:
>>> b = [2 * element for element in a]
>>> b
[2, 8, 4, 18, 20, 6]
此外,我们甚至可以在列表推导中结合条件语句来实现在创建 Python 列表的过程中的复杂逻辑。例如,要创建一个包含a
中奇数元素两倍的列表,我们可以这样做:
>>> c = [2 * element for element in a if element % 2 == 1]
>>> c
[2, 18, 6]
另一个经常与列表进行对比的 Python 数据结构是元组,我们将在下一节中讨论。然而,在继续之前,让我们通过一个新概念的练习来了解多维列表/数组。
多维数组,也称为表或矩阵(有时称为张量),是数学和机器学习领域中常见的对象。考虑到 Python 列表中的元素可以是任何 Python 对象,我们可以使用列表中的列表来模拟跨越多个维度的数组。具体来说,想象一下,在一个总体的 Python 列表中,我们有三个子列表,每个子列表中有三个元素。这个对象可以被看作是一个 2D 的 3 x 3 表。一般来说,我们可以使用嵌套在其他列表中的 Python 列表来模拟n维数组。
练习 1.03:多维列表
在这个练习中,我们将熟悉多维列表的概念以及通过它们进行迭代的过程。我们的目标是编写逻辑命令,动态显示 2D 列表的内容。
执行以下步骤完成此练习:
- 创建一个新的 Jupyter 笔记本,并在一个代码单元格中声明一个名为
a
的变量,如下所示:
a = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
这个变量表示一个 3 x 3 的 2D 表,列表中的各个子列表表示行。
- 在一个新的代码单元格中,通过循环遍历列表
a
中的元素来迭代行(暂时不要运行单元格):
for row in a:
- 在这个
for
循环的每次迭代中,a
中的一个子列表被分配给一个名为row
的变量。然后,我们可以通过索引访问 2D 表中的单个单元格。以下for
循环将打印出每个子列表中的第一个元素,或者换句话说,表中每行的第一个单元格中的数字(1
、4
和7
):
for row in a:
print(row[0])
- 在一个新的代码单元格中,通过嵌套的
for
循环打印出表a
中所有单元格的值,内部循环将遍历a
中的子列表:
for row in a:
for element in row:
print(element)
这应该打印出从 1 到 9 的数字,每个数字在单独的行中。
- 最后,在一个新的单元格中,我们需要以格式良好的消息打印出这个表的对角线元素。为此,我们可以使用一个索引变量——在我们的例子中是
i
——从0
循环到2
来访问表的对角线元素:
for i in range(3):
print(a[i][i])
您的输出应该是 1、5 和 9,每个在单独的行中。
注意
这是因为表/矩阵中对角线元素的行索引和列索引相等。
- 在一个新的单元格中,使用 f-strings 更改前面的
print
语句以格式化我们的打印输出:
for i in range(3):
print(f'The {i + 1}-th diagonal element is: {a[i][i]}')
这应该产生以下输出:
The 1-th diagonal element is: 1
The 2-th diagonal element is: 5
The 3-th diagonal element is: 9
在这个练习中,我们结合了关于循环、索引和 f-string 格式化的知识,创建了一个动态迭代 2D 列表的程序。
注意
要访问此特定部分的源代码,请参阅packt.live/3dRP8OA.
您也可以在packt.live/3gpg4al
上线上运行此示例。
接下来,我们将继续讨论其他 Python 数据结构。
元组
用括号而不是方括号声明的 Python 元组仍然是不同元素的序列,类似于列表(尽管在赋值语句中可以省略括号)。这两种数据结构之间的主要区别在于元组是 Python 中的不可变对象——这意味着它们在初始化后无法以任何方式进行变异或更改,如下所示:
>>> a = (1, 2)
>>> a[0] = 3 # trying to change the first element
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> a.append(2) # trying to add another element
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'tuple' object has no attribute 'append'
鉴于元组和列表之间的这一关键差异,我们可以相应地利用这些数据结构:当我们希望一个元素序列由于任何原因(例如,确保逻辑完整性函数)是不可变的时,可以使用元组;如果允许序列在初始化后进行更改,可以将其声明为列表。
接下来,我们将讨论数学计算中常见的数据结构:集合。
集合
如果您已经熟悉数学概念,Python 集合的定义本质上是相同的:Python 集合是无序元素的集合。可以使用大括号初始化集合,并可以使用add()
方法向集合添加新元素,如下所示:
>>> a = {1, 2, 3}
>>> a.add(4)
>>> a
{1, 2, 3, 4}
由于集合是 Python 元素的集合,或者换句话说,是一个迭代器,因此其元素仍然可以使用for
循环进行迭代。但是,鉴于其定义,不能保证这些元素将以与它们初始化或添加到集合中相同的顺序进行迭代。
此外,当将已存在于集合中的元素添加到该集合时,该语句将不起作用:
>>> a
{1, 2, 3, 4}
>>> a.add(3)
>>> a
{1, 2, 3, 4}
对两个给定集合进行并集或交集操作是最常见的集合操作,并可以分别通过 Python 中的union()
和intersection()
方法来实现:
>>> a = {1, 2, 3, 4}
>>> b = {2, 5, 6}
>>> a.union(b)
{1, 2, 3, 4, 5, 6}
>>> a.intersection(b)
{2}
最后,要从集合中删除给定的元素,我们可以使用discard()
方法或remove()
方法。两者都会从集合中删除传递给它们的项目。但是,如果项目不存在于集合中,前者将不会改变集合,而后者将引发错误。与元组和列表一样,您可以选择在程序中使用这两种方法之一来实现特定逻辑,具体取决于您的目标。
接下来,我们将讨论本节中要讨论的最后一个 Python 数据结构,即字典。
字典
Python 字典相当于 Java 中的哈希映射,我们可以指定键值对关系,并对键进行查找以获得其对应的值。我们可以通过在花括号内用逗号分隔的形式列出键值对来声明 Python 字典。
例如,一个包含学生姓名映射到他们在课堂上的最终成绩的样本字典可能如下所示:
>>> score_dict = {'Alice': 90, 'Bob': 85, 'Carol': 86}
>>> score_dict
{'Alice': 90, 'Bob': 85, 'Carol': 86}
在这种情况下,学生的姓名('Alice','Bob'和'Carol')是字典的键,而他们的成绩是键映射到的值。一个键不能用来映射到多个不同的值。可以通过将键传递给方括号内的字典来访问给定键的值:
>>> score_dict['Alice']
90
>>> score_dict['Carol']
86
>>> score_dict['Chris']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'Chris'
请注意,在前面片段的最后一个语句中,'Chris'
不是字典中的键,因此当我们尝试访问它的值时,Python 解释器会返回KeyError
。
可以使用相同的语法更改现有键的值或向现有字典添加新的键值对:
>>> score_dict['Alice'] = 89
>>> score_dict
{'Alice': 89, 'Bob': 85, 'Carol': 86}
>>> score_dict['Chris'] = 85
>>> score_dict
{'Alice': 89, 'Bob': 85, 'Carol': 86, 'Chris': 85}
类似于列表推导,可以使用字典推导来声明 Python 字典。例如,以下语句初始化了一个将整数从-1
到1
(包括边界)映射到它们的平方的字典:
>>> square_dict = {i: i ** 2 for i in range(-1, 2)}
>>> square_dict
{-1: 1, 0: 0, 1: 1}
正如我们所看到的,这个字典包含了每个x
在-1
和1
之间的x
- x ** 2
键值对,这是通过在字典初始化中放置for
循环来完成的。
要从字典中删除键值对,我们需要使用del
关键字。假设我们想删除'Alice'
键及其对应的值。我们可以这样做:
>>> del score_dict['Alice']
尝试访问已删除的键将导致 Python 解释器引发错误:
>>> score_dict['Alice']
KeyError: 'Alice'
要牢记的 Python 字典最重要的一点是,只有不可变对象可以作为字典键。到目前为止,我们已经看到字符串和数字作为字典键。列表是可变的,初始化后可以改变,不能用作字典键;而元组可以。
练习 1.04:购物车计算
在这个练习中,我们将使用字典数据结构构建一个购物应用程序的骨架版本。这将使我们能够复习和进一步了解数据结构以及可以应用于它的操作。
执行以下步骤完成此练习:
- 在第一个代码单元中创建一个新的 Jupyter 笔记本,并声明一个字典,表示可以购买的任何商品及其相应的价格。在这里,我们将添加三种不同类型的笔记本电脑及其美元价格:
prices = {'MacBook 13': 1300, 'MacBook 15': 2100, \
'ASUS ROG': 1600}
注意
这里显示的代码片段使用反斜杠(\
)将逻辑分割成多行。当代码执行时,Python 将忽略反斜杠,并将下一行的代码视为当前行的直接延续。
- 在下一个单元格中,初始化一个表示我们购物车的字典。字典在开始时应该是空的,但它应该将购物车中的商品映射到要购买的副本数量:
cart = {}
- 在一个新的单元格中,编写一个
while True
循环,表示购物过程的每个步骤,并询问用户是否想继续购物。使用条件语句来处理输入的不同情况(您可以留下用户想要继续购物直到下一步的情况):
while True:
_continue = input('Would you like to continue '\
'shopping? [y/n]: ')
if _continue == 'y':
...
elif _continue == 'n':
break
else:
print('Please only enter "y" or "n".')
- 在第一个条件情况下,接受另一个用户输入,询问应该将哪个商品添加到购物车。使用条件语句来增加
cart
字典中商品的数量或处理无效情况:
if _continue == 'y':
print(f'Available products and prices: {prices}')
new_item = input('Which product would you like to '\
'add to your cart? ')
if new_item in prices:
if new_item in cart:
cart[new_item] += 1
else:
cart[new_item] = 1
else:
print('Please only choose from the available products.')
- 在下一个单元格中,循环遍历
cart
字典,并计算用户需要支付的总金额(通过查找购物车中每件商品的数量和价格):
# Calculation of total bill.
running_sum = 0
for item in cart:
running_sum += cart[item] * prices[item] # quantity times price
- 最后,在一个新的单元格中,通过
for
循环打印出购物车中的商品及其各自的数量,并在最后打印出总账单。使用 f-string 格式化打印输出:
print(f'Your final cart is:')
for item in cart:
print(f'- {cart[item]} {item}(s)')
print(f'Your final bill is: {running_sum}')
- 运行程序并尝试使用不同的购物车来确保我们的程序是正确的。例如,如果您要将两台 MacBook 13 和一台华硕 ROG 添加到我的购物车中并停止,相应的输出将如下所示:
图 1.2:购物车应用程序的输出
这就结束了我们的购物车练习,通过这个练习,我们熟悉了使用字典查找信息。我们还回顾了使用条件和循环来实现控制流方法在 Python 程序中的使用。
注意
要访问本节的源代码,请参阅packt.live/2C1Ra1C
您也可以在packt.live/31F7QXg
上线运行此示例。
在下一节中,我们将讨论任何复杂程序的两个重要组成部分:函数和算法。
函数和算法
虽然函数在 Python 编程中表示特定的对象,我们可以用它来对程序进行排序和分解,但术语算法通常指的是一系列逻辑的一般组织,用于处理给定的输入数据。在数据科学和科学计算中,算法是无处不在的,通常以处理数据并可能进行预测的机器学习模型的形式出现。
在本节中,我们将讨论 Python 函数的概念和语法,然后解决一些示例算法设计问题。
函数
在其最抽象的定义中,函数只是一个可以接受输入并根据给定的一组指令产生输出的对象。Python 函数的形式如下:
def func_name(param1, param2, ...):
[…]
return […]
def
关键字表示 Python 函数的开始。函数的名称可以是任何东西,尽管规则是避免名称开头的特殊字符,并使用蛇形命名法。括号内是函数接受的参数,它们用逗号分隔,并可以在函数的缩进代码中使用。
例如,以下函数接受一个字符串(尽管这个要求未指定),并打印出问候消息:
>>> def greet(name):
... print(f'Hello, {name}!')
然后,我们可以在任何想要的字符串上调用这个函数,并实现函数内部指令所期望的效果。如果我们以某种方式错误地指定了函数所需的参数(例如,以下代码片段中的最后一条语句),解释器将返回一个错误:
>>> greet('Quan')
Hello, Quan!
>>> greet('Alice')
Hello, Alice!
>>> greet()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: greet() missing 1 required positional argument: 'name'
重要的是要注意,任何局部变量(在函数内部声明的变量)都不能在函数范围之外使用。换句话说,一旦函数完成执行,它的变量将无法被其他代码访问。
大多数情况下,我们希望我们的函数在结束时返回某种值,这是由return
关键字实现的。一旦执行return
语句,程序的执行将退出给定的函数,并返回调用函数的父级范围。这使我们能够设计许多动态逻辑序列。
例如,想象一个函数,它接受一个 Python 整数列表,并返回第一个可以被 2 整除的元素(如果列表中没有偶数元素,则返回False
):
def get_first_even(my_list):
[...]
return # should be the first even element
现在,编写这个函数的自然方式是循环遍历列表中的元素,并检查它们是否可以被2
整除:
def get_first_even(my_list):
for item in my_list:
if item % 2 == 0:
[...]
return # should be the first even element
然而,如果条件满足(即我们正在迭代的当前元素可以被2
整除),那么该元素应该是函数的返回值,因为它是列表中第一个可以被2
整除的元素。这意味着我们实际上可以在if
块内返回它(最后在函数末尾返回False
):
def get_first_even(my_list):
for item in my_list:
if item % 2 == 0:
return item
return False
这种方法与另一种版本形成对比,另一种版本只在循环结束时返回满足我们条件的元素,这将更耗时(执行方面),并需要额外的检查,以确定输入列表中是否有偶数元素。我们将在下一个练习中深入研究这种逻辑的变体。
练习 1.05:查找最大值
在任何入门编程课程中,查找数组或列表的最大/最小值是一个常见的练习。在这个练习中,我们将考虑这个问题的一个提升版本,即我们需要编写一个函数,返回列表中最大元素的索引和实际值(如果需要进行平局处理,我们返回最后一个最大元素)。
执行以下步骤完成这个练习:
- 创建一个新的 Jupyter 笔记本,并在一个代码单元格中声明我们目标函数的一般结构:
def get_max(my_list):
...
return ...
- 创建一个变量来跟踪当前最大元素的索引,称为
running_max_index
,应初始化为0
:
def get_max(my_list):
running_max_index = 0
...
return ...
- 使用
for
循环和enumerate
操作循环遍历参数列表中的值及其对应的索引:
def get_max(my_list):
running_max_index = 0
# Iterate over index-value pairs.
for index, item in enumerate(my_list):
[...]
return ...
- 在每一步迭代中,检查当前元素是否大于或等于与运行索引变量对应的元素。如果是这样,将当前元素的索引分配给运行的最大索引:
def get_max(my_list):
running_max_index = 0
# Iterate over index-value pairs.
for index, item in enumerate(my_list):
if item >= my_list[running_max_index]:
running_max_index = index
return [...]
- 最后,将运行的最大索引及其对应的值作为一个元组返回:
def get_max(my_list):
running_max_index = 0
# Iterate over index-value pairs.
for index, item in enumerate(my_list):
if item >= my_list[running_max_index]:
running_max_index = index
return running_max_index, my_list[running_max_index]
- 在一个新的单元格中,调用这个函数来测试不同情况下的各种列表。一个例子如下:
>>> get_max([1, 3, 2])
(1, 3)
>>> get_max([1, 3, 56, 29, 100, 99, 3, 100, 10, 23])
(7, 100)
这个练习帮助我们复习了 Python 函数的一般语法,也提供了一个循环的复习。此外,我们考虑的逻辑变体通常在科学计算项目中找到(例如,在迭代器中找到最小值或满足某些给定条件的元素)。
注意
要访问本节的源代码,请参阅packt.live/2Zu6KuH.
您也可以在packt.live/2BUNjDk.
上线运行此示例
接下来,让我们讨论一种非常特定的函数设计风格,称为递归。
递归
在编程中,术语递归表示使用函数解决问题的风格,通过使函数递归调用自身。其思想是每次调用函数时,其逻辑将向问题的解决方案迈出一小步,通过这样做多次,最终解决原始问题。如果我们以某种方式有办法将我们的问题转化为一个可以以相同方式解决的小问题,我们可以重复分解问题以达到基本情况,并确保解决原始的更大问题。
考虑计算n个整数的总和的问题。如果我们已经有了前n-1个整数的总和,那么我们只需将最后一个数字加到这个总和中,就可以计算出n个数字的总和。但是如何计算前n-1个数字的总和呢?通过递归,我们再次假设如果我们有前n-2个数字的总和,那么我们将最后一个数字加进去。这个过程重复进行,直到我们达到列表中的第一个数字,整个过程完成。
让我们在以下示例中考虑这个函数:
>>> def find_sum(my_list):
... if len(my_list) == 1:
... return my_list[0]
... return find_sum(my_list[: -1]) + my_list[-1]
我们可以看到,在一般情况下,该函数计算并返回了将输入列表的最后一个元素my_list[-1]
与不包括这个最后一个元素的子列表my_list[: -1]
的总和的结果,而这又是由find_sum()
函数本身计算的。同样,我们可以理解,如果find_sum()
函数可以以某种方式解决在较小情况下对列表求和的问题,我们可以将结果推广到任何给定的非空列表。
处理基本情况因此是任何递归算法的一个组成部分。在这里,我们的基本情况是当输入列表是一个单值列表(通过我们的if
语句检查),在这种情况下,我们应该简单地返回列表中的那个元素。
我们可以看到这个函数正确地计算了任何非空整数列表的总和,如下所示:
>>> find_sum([1, 2, 3])
6
>>> find_sum([1])
1
这是一个相当基本的例子,因为可以通过保持运行总和并使用for
循环来迭代输入列表中的所有元素来轻松地找到列表的总和。实际上,大多数情况下,递归不如迭代高效,因为在程序中重复调用函数会产生重大开销。
然而,正如我们将在接下来的练习中看到的那样,通过将我们对问题的方法抽象为递归算法,我们可以显著简化问题的解决方法。
练习 1.06:汉诺塔
汉诺塔是一个众所周知的数学问题,也是递归的一个经典应用。问题陈述如下。
有三个盘堆,可以在其中放置盘子,有n个盘子,所有盘子都是不同大小的。一开始,盘子按升序堆叠(最大的在底部)在一个单独的堆栈中。在游戏的每一步中,我们可以取一个堆栈的顶部盘子,并将其放在另一个堆栈的顶部(可以是一个空堆栈),条件是不能将盘子放在比它更小的盘子的顶部。
我们被要求计算将整个n个盘子从一个堆栈移动到另一个堆栈所需的最小移动次数。如果我们以线性方式思考这个问题,它可能会变得非常复杂,但是当我们使用递归算法时,它变得更简单。
具体来说,为了移动n个盘子,我们需要将顶部的n - 1个盘子移动到另一个堆栈,将底部最大的盘子移动到最后一个堆栈,最后将另一个堆栈中的n - 1个盘子移动到与最大盘子相同的堆栈中。现在,想象我们可以计算移动(n - 1)个盘子所需的最小步骤,表示为S(n - 1),然后移动n个盘子,我们需要2 S(n - 1) + 1步。
这就是问题的递归分析解决方案。现在,让我们编写一个函数来实际计算任何给定n的数量。
执行以下步骤以完成此练习:
- 在一个新的 Jupyter 笔记本中,定义一个函数,该函数接受一个名为
n
的整数,并返回我们之前得到的数量:
def solve(n):
return 2 * solve(n - 1) + 1
- 在函数中创建一个条件来处理基本情况,即
n = 1
(注意,只需一步即可移动单个盘子):
def solve(n):
if n == 1:
return 1
return 2 * solve(n - 1) + 1
- 在另一个单元格中,调用该函数以验证函数返回问题的正确分析解决方案,即2n - 1:
>>> print(solve(3) == 2 ** 3 - 1)
True
>>> print(solve(6) == 2 ** 6 - 1)
True
在这里,我们使用==
运算符来比较两个值:从我们的solve()
函数返回的值和解决方案的分析表达式。如果它们相等,我们应该看到布尔值True
被打印出来,这是我们这里的两个比较的情况。
在这个练习中的代码虽然很短,但它已经说明了递归可以提供优雅的解决方案来解决许多问题,并且希望巩固了我们对递归算法程序的理解(包括一般步骤和基本案例)。
注意
要访问此特定部分的源代码,请参考packt.live/2NMrGrk.
您也可以在packt.live/2AnAP6R
上在线运行此示例。
有了这个,我们将继续讨论算法设计的一般过程。
算法设计
设计算法实际上是我们一直在做的事情,特别是在本节中,这一节主要讨论函数和算法:讨论一个函数对象应该接受什么,它应该如何处理输入,以及在执行结束时应该返回什么输出。在本节中,我们将简要讨论一般算法设计过程中的一些实践,然后考虑一个稍微复杂的问题,称为N-Queens 问题作为练习。
在编写 Python 函数时,一些程序员可能选择实现子函数(在其他函数中的函数)。遵循软件开发中的封装思想,当子函数只被另一个函数内的指令调用时,应该实现子函数。如果是这种情况,第一个函数可以被视为第二个函数的辅助函数,因此应该放在第二个函数内。这种封装形式使我们能够更有条理地组织我们的程序/代码,并确保如果一段代码不需要使用给定函数内的逻辑,则不应该访问它。
下一个讨论点涉及递归搜索算法,我们将在下一个练习中进行讨论。具体来说,当算法递归地尝试找到给定问题的有效解决方案时,它可能会达到一个没有有效解决方案的状态(例如,当我们试图在仅包含奇数的列表中找到一个偶数元素时)。这导致需要一种方式来指示我们已经达到了一个无效状态。
在我们找到第一个偶数的例子中,我们选择返回False
来指示一个无效状态,即我们的输入列表只包含奇数。返回False
或0
这样的标志实际上是一个常见的做法,我们在本章的后续示例中也会遵循这种做法。
考虑到这一点,让我们开始本节的练习。
练习 1.07:N-Queens 问题
数学和计算机科学中的另一个经典算法设计问题是 N 皇后问题,它要求我们在* n * x * n 棋盘上放置 n *个皇后棋子,以便没有皇后棋子可以攻击另一个。如果两个皇后棋子共享相同的行、列或对角线,那么一个皇后可以攻击另一个棋子,因此问题实质上是找到皇后棋子的位置组合,使得任意两个皇后在不同的行、列和对角线上。
对于这个练习,我们将设计一个回溯算法,为任何正整数n搜索这个问题的有效解决方案。算法如下:
-
考虑到问题的要求,我们认为为了放置n个棋子,棋盘的每一行都需要包含恰好一个棋子。
-
对于每一行,我们迭代地遍历该行的所有单元格,并检查是否可以在给定单元格中放置一个新的皇后棋子:
a. 如果存在这样的单元格,我们在该单元格中放置一个棋子,然后转到下一行。
b. 如果新的皇后棋子无法放置在当前行的任何单元格中,我们知道已经达到了一个无效状态,因此返回False
。
- 我们重复这个过程,直到找到一个有效的解决方案。
以下图描述了这个算法在n=4时的工作方式:
图 1.3:N-Queens 问题的递归
现在,让我们实际实现算法:
- 创建一个新的 Jupyter 笔记本。在第一个单元格中,声明一个名为
N
的变量,表示棋盘的大小,以及我们需要在棋盘上放置的皇后数量:
N = 8
- 国际象棋棋盘将被表示为一个 2D 的n x n列表,其中 0 表示一个空单元格,1 表示一个带有皇后棋子的单元格。现在,在一个新的代码单元中,实现一个函数,该函数接受这种形式的列表并以良好的格式打印出来:
# Print out the board in a nice format.
def display_solution(board):
for i in range(N):
for j in range(N):
print(board[i][j], end=' ')
print()
请注意,我们print
语句中的end=' '
参数指定,不是用换行符结束打印输出,而是用空格字符。这样我们就可以使用不同的print
语句打印出同一行中的单元格。
- 在下一个单元格中,编写一个函数,该函数接受一个棋盘、一个行号和一个列号。该函数应该检查是否可以在给定的行和列号位置的棋盘上放置一个新的皇后棋子。
请注意,由于我们正在逐行放置棋子,每次检查新棋子是否可以放在给定位置时,我们只需要检查位置上方的行:
# Check if a queen can be placed in the position.
def check_next(board, row, col):
# Check the current column.
for i in range(row):
if board[i][col] == 1:
return False
# Check the upper-left diagonal.
for i, j in zip(range(row, -1, -1), \
range(col, -1, -1)):
if board[i][j] == 1:
return False
# Check the upper-right diagonal.
for i, j in zip(range(row, -1, -1), \
range(col, N)):
if board[i][j] == 1:
return False
return True
- 在同一个代码单元中,实现一个函数,该函数接受一个棋盘和一个行号。该函数应该遍历给定行中的所有单元格,并检查是否可以在特定单元格放置一个新的皇后棋子(使用前面步骤中编写的
check_next()
函数)。
对于这样的单元格,在该单元格中放置一个皇后(将单元格值更改为1
),并递归调用函数本身以获取下一个行号。如果最终解决方案有效,则返回True
;否则,从单元格中移除皇后棋子(将其更改回0
)。
如果在考虑了给定行的所有单元格后没有找到有效解决方案,则返回False
表示无效
状态。函数还应该在开始时有一个条件检查,检查行号是否大于棋盘大小N
,在这种情况下,我们只需返回True
表示已经找到有效的最终解决方案:
def recur_generate_solution(board, row_id):
# Return if we have reached the last row.
if row_id >= N:
return True
# Iteratively try out cells in the current row.
for i in range(N):
if check_next(board, row_id, i):
board[row_id][i] = 1
# Return if a valid solution is found.
final_board = recur_generate_solution(\
board, row_id + 1)
if final_board:
return True
board[row_id][i] = 0
# When the current board has no valid solutions.
return False
- 在同一个代码单元中,编写一个最终求解器函数,该函数包装了两个函数
check_next()
和recur_generate_solution()
(换句话说,这两个函数应该是我们正在编写的函数的子函数)。该函数应该初始化一个空的 2D n x n列表(表示国际象棋棋盘),并调用recur_generate_solution()
函数,行号为 0。
函数还应该在最后打印出解决方案:
# Generate a valid solution.
def generate_solution():
# Check if a queen can be placed in the position.
def check_next(board, row, col):
[...]
# Recursively generate a solution.
def recur_generate_solution(board, row_id):
[...]
# Start out with en empty board.
my_board = [[0 for _ in range(N)] for __ in range(N)]
final_solution = recur_generate_solution(my_board, 0)
# Display the final solution.
if final_solution is False:
print('A solution cannot be found.')
else:
print('A solution was found.')
display_solution(my_board)
- 在另一个代码单元中,运行前面步骤中的总体函数以生成并打印出解决方案:
>>> generate_solution()
A solution was found.
1 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 1
0 0 0 0 0 1 0 0
0 0 1 0 0 0 0 0
0 0 0 0 0 0 1 0
0 1 0 0 0 0 0 0
0 0 0 1 0 0 0 0
在整个练习过程中,我们实现了一个回溯算法,该算法旨在通过迭代向潜在解决方案迈出一步(在安全单元格中放置一个皇后棋子),如果算法以某种方式达到无效状态,它将通过撤消先前的移动(在我们的情况下,通过移除我们放置的最后一个棋子)并寻找新的移动来进行回溯。正如您可能已经注意到的那样,回溯与递归密切相关,这就是为什么我们选择使用递归函数来实现我们的算法,从而巩固我们对一般概念的理解。
注意
要访问此特定部分的源代码,请参阅packt.live/2Bn7nyt.
您还可以在packt.live/2ZrKRMQ.
上在线运行此示例
在本章的下一个和最后一节中,我们将考虑 Python 编程中经常被忽视的一些行政任务,即调试、测试和版本控制。
测试、调试和版本控制
在编程中,需要注意的是,编写代码的实际任务并不是整个过程的唯一元素。还有其他行政程序在流程中扮演着重要角色,但通常被忽视了。在本节中,我们将逐个讨论每个任务,并考虑在 Python 中实现它们的过程,从测试开始。
测试
为了确保我们编写的软件按照我们的意图工作并产生正确的结果,有必要对其进行特定的测试。在软件开发中,我们可以对程序应用多种类型的测试:集成测试、回归测试、系统测试等等。其中最常见的是单元测试,这是我们在本节讨论的主题。
单元测试表示关注软件的个别小单元,而不是整个程序。单元测试通常是测试流水线的第一步——一旦我们相当有信心认为程序的各个组件工作正常,我们就可以继续测试这些组件如何一起工作,以及它们是否产生我们想要的结果(通过集成或系统测试)。
在 Python 中,可以使用unittest
模块轻松实现单元测试。采用面向对象的方法,unittest
允许我们将程序的测试设计为 Python 类,使过程更加模块化。这样的类需要从unittest
的TestCase
类继承,并且单独的测试需要在不同的函数中实现,如下所示:
import unittest
class SampleTest(unittest.TestCase):
def test_equal(self):
self.assertEqual(2 ** 3 - 1, 7)
self.assertEqual('Hello, world!', 'Hello, ' + 'world!')
def test_true(self):
self.assertTrue(2 ** 3 < 3 ** 2)
for x in range(10):
self.assertTrue(- x ** 2 <= 0)
在SampleTest
类中,我们放置了两个测试用例,希望使用assertEqual()
方法在test_equal()
函数中检查两个给定的数量是否相等。在这里,我们测试 23-1 是否确实等于 7,以及 Python 中的字符串连接是否正确。
类似地,test_true()
函数中使用的assertTrue()
方法测试给定参数是否被评估为True
。在这里,我们测试 23 是否小于 32,以及 0 到 10 之间整数的完全平方的负值是否为非正数。
要运行我们实现的测试,可以使用以下语句:
>>> unittest.main()
test_equal (__main__.SampleTest) ... ok
test_true (__main__.SampleTest) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK
生成的输出告诉我们,我们的两个测试都返回了积极的结果。需要记住的一个重要的副作用是,如果在 Jupyter 笔记本中运行单元测试,最后的语句需要如下所示:
unittest.main(argv=[''], verbosity=2, exit=False)
由于单元测试需要作为 Python 类中的函数实现,unittest
模块还提供了两个方便的方法setUp()
和tearDown()
,它们分别在每个测试之前和之后自动运行。我们将在下一个练习中看到这方面的一个例子。现在,我们将继续讨论调试。
调试
调试一词的字面意思是从给定的计算机程序中消除一个或多个错误,从而使其正确工作。在大多数情况下,调试过程是在测试失败后进行的,确定程序中存在错误。然后,为了调试程序,我们需要确定导致测试失败的错误的源头,并尝试修复与该错误相关的代码。
程序可能采用多种形式的调试。这些包括以下内容:
-
打印调试:可以说是最常见和基本的调试方法之一,打印调试涉及识别可能导致错误的变量,在程序中的各个位置放置这些变量的
print
语句,以便跟踪这些变量值的变化。一旦发现变量值的变化是不希望的,我们就会查看程序中print
语句的具体位置,从而(粗略地)确定错误的位置。 -
日志记录:如果我们决定将变量的值输出到日志文件而不是标准输出,这就被称为日志记录。通常会使用日志记录来跟踪我们正在调试或监视的程序执行中发生的特定事件。
-
跟踪: 要调试一个程序,在这种情况下,我们将跟踪程序执行时的低级函数调用和执行堆栈。通过从低级别的角度观察变量和函数的使用顺序,我们也可以确定错误的来源。在 Python 中,可以使用
sys
模块的sys.settrace()
方法来实现跟踪。
在 Python 中,使用打印调试非常容易,因为我们只需要使用print
语句。对于更复杂的功能,我们可以使用调试器,这是专门设计用于调试目的的模块/库。Python 中最主要的调试器是内置的pdb
模块,以前是通过pdb.set_trace()
方法运行的。
从 Python 3.7 开始,我们可以选择更简单的语法,通过调用内置的breakpoint()
函数。在每个调用breakpoint()
函数的地方,程序的执行将暂停,允许我们检查程序的行为和当前特性,包括其变量的值。
具体来说,一旦程序执行到breakpoint()
函数,将会出现一个输入提示,我们可以在其中输入pdb
命令。模块的文档中包含了许多可以利用的命令。一些值得注意的命令如下:
-
h
: 用于帮助,打印出您可以使用的完整命令列表。 -
u
/d
: 分别用于上和下,将运行帧计数向一个方向移动一级。 -
s
: 用于步骤,执行程序当前所在的指令,并在执行中的第一个可能的位置暂停。这个命令在观察代码对程序状态的即时影响方面非常有用。 -
n
: 用于下一个,执行程序当前所在的指令,并且只在当前函数中的下一个指令处暂停,当执行返回时也会暂停。这个命令与s
有些类似,不过它以更高的速率跳过指令。 -
r
: 用于返回,直到当前函数返回为止。 -
c
: 用于继续,直到达到下一个breakpoint()
语句为止。 -
ll
: 用于longlist,打印出当前指令的源代码。 -
p [expression]
: 用于打印,评估并打印给定表达式的值
总的来说,一旦程序的执行被breakpoint()
语句暂停,我们可以利用前面不同命令的组合来检查程序的状态并识别潜在的错误。我们将在下面的练习中看一个例子。
练习 1.08: 并发测试
在这个练习中,我们将考虑并发或并行相关程序中一个众所周知的错误,称为竞争条件。这将作为一个很好的用例来尝试我们的测试和调试工具。由于在 Jupyter 笔记本中集成pdb
和其他调试工具仍处于不成熟阶段,所以我们将在这个练习中使用.py
脚本。
执行以下步骤来完成这个练习:
-
我们程序的设置(在以下步骤中实现)如下。我们有一个类,实现了一个计数器对象,可以被多个线程并行操作。这个计数器对象的实例的值(存储在其初始化为
0
的value
属性中)在每次调用其update()
方法时递增。计数器还有一个目标,即其值应该递增到。当调用其run()
方法时,将会生成多个线程。每个线程将调用update()
方法,因此将其value
属性递增到与原始目标相等的次数。理论上,计数器的最终值应该与目标相同,但由于竞争条件,我们将看到这并不是这样。我们的目标是应用pdb
来跟踪程序内部变量的变化,以分析这种竞争条件。 -
创建一个新的
.py
脚本,并输入以下代码:
import threading
import sys; sys.setswitchinterval(10 ** -10)
class Counter:
def __init__(self, target):
self.value = 0
self.target = target
def update(self):
current_value = self.value
# breakpoint()
self.value = current_value + 1
def run(self):
threads = [threading.Thread(target=self.update) \
for _ in range(self.target)]
for t in threads:
t.start()
for t in threads:
t.join()
这段代码实现了我们之前讨论过的Counter
类。请注意,有一行代码设置了系统的切换间隔;我们稍后会讨论这个。
- 希望
counter
对象的值应该增加到其真正的目标值,我们将用三个不同的目标值测试其性能。在同一个.py
脚本中,输入以下代码来实现我们的单元测试:
import unittest
class TestCounter(unittest.TestCase):
def setUp(self):
self.small_params = 5
self.med_params = 5000
self.large_params = 10000
def test_small(self):
small_counter = Counter(self.small_params)
small_counter.run()
self.assertEqual(small_counter.value, \
self.small_params)
def test_med(self):
med_counter = Counter(self.med_params)
med_counter.run()
self.assertEqual(med_counter.value, \
self.med_params)
def test_large(self):
large_counter = Counter(self.large_params)
large_counter.run()
self.assertEqual(large_counter.value, \
self.large_params)
if __name__ == '__main__':
unittest.main()
在这里,我们可以看到在每个测试函数中,我们初始化一个新的counter
对象,运行它,最后将其值与真实目标进行比较。测试用例的目标在setUp()
方法中声明,正如我们之前提到的,在测试执行之前运行:
Run this Python script:test_large (__main__.TestCounter) ... FAIL
test_med (__main__.TestCounter) ... FAIL
test_small (__main__.TestCounter) ... ok
====================================================================
FAIL: test_large (__main__.TestCounter)
--------------------------------------------------------------------
Traceback (most recent call last):
File "<ipython-input-57-4ed47b9310ba>", line 22, in test_large
self.assertEqual(large_counter.value, self.large_params)
AssertionError: 9996 != 10000
====================================================================
FAIL: test_med (__main__.TestCounter)
--------------------------------------------------------------------
Traceback (most recent call last):
File "<ipython-input-57-4ed47b9310ba>", line 17, in test_med
self.assertEqual(med_counter.value, self.med_params)
AssertionError: 4999 != 5000
--------------------------------------------------------------------
Ran 3 tests in 0.890s
FAILED (failures=2)
正如你所看到的,程序在两个测试中失败了:test_med
(计数器的最终值只有 4,999,而不是 5,000)和test_large
(值为 9,996,而不是 10,000)。你可能会得到不同的输出。
-
多次重新运行这段代码,看到结果可能会有所不同。
-
现在我们知道程序中有一个 bug,我们将尝试调试它。在
update()
方法的两条指令之间放置一个breakpoint()
语句,重新实现我们的Counter
类,如下面的代码所示,并重新运行代码:
class Counter:
...
def update(self):
current_value = self.value
breakpoint()
self.value = current_value + 1
...
- 在我们的 Python 脚本的主范围内,注释掉对单元测试的调用。相反,声明一个新的
counter
对象,并使用终端运行脚本:
sample_counter = Counter(10)
sample_counter.run()
在这里,你会看到终端中出现一个pdb
提示(你可能需要先按Enter让调试器继续):
图 1.4:pdb 界面
- 输入
ll
并按Enter键,查看我们在程序中暂停的位置:
(Pdb) ll
9 def update(self):
10 current_value = self.value
11 breakpoint()
12 -> self.value = current_value + 1
这里,输出表明我们当前在update()
方法内增加计数器值的两条指令之间暂停。
- 再次按Enter返回到
pdb
提示符,并运行p self.value
命令:
(Pdb) p self.value
0
我们可以看到计数器的当前值是0
。
- 返回到提示符并输入
n
命令。然后再次使用p self.value
命令检查计数器的值:
(Pdb) n
--Return--
> <ipython-input-61-066f5069e308>(12)update()->None
-> self.value = current_value + 1
(Pdb) p self.value
1
-
我们可以看到值已经增加了 1。重复这个在
n
和p self.value
之间交替的过程,观察在程序进行过程中self.value
中存储的值没有更新。换句话说,值通常保持在 1。这就是我们在计数器的大值中看到的 bug 表现方式,就像我们在单元测试中看到的那样。 -
使用Ctrl + C退出调试器。
注意
要访问这一特定部分的源代码,请参阅packt.live/2YPCZFJ
。
这一部分目前没有在线交互示例,需要在本地运行。
对于那些感兴趣的人,我们程序的错误源于多个线程可以在大致相同的时间增加计数器的值,覆盖彼此所做的更改。随着线程数量的增加(例如我们在测试用例中有的 5,000 或 10,000),这种事件发生的概率变得更高。正如我们之前提到的,这种现象称为竞争条件,是并发和并行程序中最常见的错误之一。
除了演示一些pdb
命令之外,这个练习还说明了设计测试以覆盖不同情况是必要的事实。虽然程序通过了我们的目标为 5 的小测试,但在目标值较大时失败了。在现实生活中,我们应该对程序进行测试,模拟各种可能性,确保程序即使在边缘情况下也能正常工作。
有了这些,让我们继续进行本章的最后一个主题,版本控制。
版本控制
在本节中,我们将简要讨论版本控制的一般理论,然后讨论使用 Git 和 GitHub 实现版本控制的过程,这两者可以说是行业中最流行的版本控制系统。版本控制对于编程项目来说就像定期备份数据对于常规文件一样重要。实质上,版本控制系统允许我们将项目中的进度与本地文件分开保存,以便以后可以回到它,即使本地文件丢失或损坏。
使用当前版本控制系统(如 Git 和 GitHub)提供的功能,我们还可以做更多事情。例如,这些系统的分支和合并功能为用户提供了一种创建共同项目的多个版本的方法,以便可以探索不同的方向;实现最受欢迎方向的分支最终将与主分支合并。此外,Git 和 GitHub 允许平台上的用户之间的工作无缝进行,这在团队项目中非常受欢迎。
为了了解我们可以利用 Git 和 GitHub 的可用功能,让我们进行以下练习。
练习 1.09:使用 Git 和 GitHub 进行版本控制
这个练习将引导我们完成开始使用 Git 和 GitHub 所需的所有步骤。如果您还没有使用版本控制的经验,这个练习对您将是有益的。
执行以下步骤完成此练习:
-
首先,如果您还没有,请注册 GitHub 帐户,方法是访问
www.github.com/
并注册。这将允许您在他们的云存储上托管您想要进行版本控制的文件。 -
前往
git-scm.com/downloads
并下载适用于您系统的 Git 客户端软件并安装。这个 Git 客户端将负责与 GitHub 服务器通信。如果您可以在终端中运行git
命令,那么您就知道您的 Git 客户端已成功安装:
$ git
usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]
[--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
[-p | --paginate | -P | --no-pager] [--no-replace-objects] [--bare]
[--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
<command> [<args>]
否则,您的系统可能需要重新启动才能完全生效。
- 现在,让我们开始将版本控制应用于一个示例项目的过程。首先,创建一个虚拟文件夹,并生成一个 Jupyter 笔记本和一个名为
input.txt
的文本文件,其中包含以下内容:
1,1,1
1,1,1
- 在 Jupyter 笔记本的第一个单元格中,编写一个名为
add_elements()
的函数,该函数接受两个数字列表并按元素相加。该函数应返回一个由元素和总和组成的列表;您可以假设两个参数列表的长度相同:
def add_elements(a, b):
result = []
for item_a, item_b in zip(a, b):
result.append(item_a + item_b)
return result
- 在下一个代码单元格中,使用
with
语句读取input.txt
文件,并使用readlines()
函数和列表索引提取文件的最后两行:
with open('input.txt', 'r') as f:
lines = f.readlines()
last_line1, last_line2 = lines[-2], lines[-1]
请注意,在open()
函数中,第二个参数'r'
指定我们正在读取文件,而不是写入文件。
- 在一个新的代码单元格中,使用
str.split()
函数和','
参数将这两个文本输入字符串转换为数字列表,然后使用map()
和int()
函数逐个元素地应用转换:
list1 = list(map(int, last_line1[: -1].split(',')))
list2 = list(map(int, last_line2[: -1].split(',')))
- 在一个新的代码单元格中,对
list1
和list2
调用add_elements()
。将返回的列表写入相同的输入文件中的新行,格式为逗号分隔值(CSV):
new_list = add_elements(list1, list2)
with open('input.txt', 'a') as f:
for i, item in enumerate(new_list):
f.write(str(item))
if i < len(new_list) - 1:
f.write(',')
else:
f.write('\n')
这里的'a'
参数指定我们正在写入文件以追加一个新行,而不是完全读取和覆盖文件。
- 运行代码单元格,并验证文本文件是否已更新为以下内容:
1,1,1
1,1,1
2,2,2
- 到目前为止,我们的示例项目的当前设置是:我们有一个文件夹中的文本文件和 Python 脚本;当运行时,脚本可以更改文本文件的内容。这种设置在现实生活中是相当常见的:您可以有一个包含一些信息的数据文件,您希望跟踪,并且可以读取该数据并以某种方式更新它的 Python 程序(也许是通过预先指定的计算或添加外部收集的新数据)。
现在,让我们在这个示例项目中实现版本控制。
- 转到您的在线 GitHub 帐户,单击窗口右上角的加号图标(+),然后选择
New repository
选项,如下所示:
图 1.5:创建一个新的存储库
在表单中输入一个新存储库的示例名称,并完成创建过程。将这个新存储库的 URL 复制到剪贴板上,因为我们以后会用到它。
正如名称所示,这将创建一个新的在线存储库,用于托管我们想要进行版本控制的代码。
- 在您的本地计算机上,打开终端并导航到文件夹。运行以下命令以初始化本地 Git 存储库,这将与我们的文件夹关联:
$ git init
- 仍然在终端中,运行以下命令将我们项目中的所有内容添加到 Git 并提交它们:
git add .
git commit -m [any message with double quotes]
您可以用文件的名称替换git add .
中的.
。当您只想注册一个或两个文件时,这个选项是有帮助的,而不是您在文件夹中的每个文件。
- 现在,我们需要链接我们的本地存储库和我们创建的在线存储库。为此,请运行以下命令:
git remote add origin [URL to GitHub repository]
请注意,“origin”只是 URL 的一个传统昵称。
- 最后,通过运行以下命令将本地注册的文件上传到在线存储库:
git push origin master
-
转到在线存储库的网站,验证我们创建的本地文件是否确实已上传到 GitHub。
-
在您的本地计算机上,运行 Jupyter 笔记本中包含的脚本并更改文本文件。
-
现在,我们想要将这个更改提交到 GitHub 存储库。在您的终端上,再次运行以下命令:
git add .
git commit
git push origin master
- 转到 GitHub 网站验证我们第二次所做的更改是否也已在 GitHub 上进行了更改。
通过这个练习,我们已经走过了一个示例版本控制流水线,并看到了 Git 和 GitHub 在这方面的一些用法示例。我们还复习了使用with
语句在 Python 中读写文件的过程。
注意
要访问本节的源代码,请参阅packt.live/2VDS0IS
您还可以在packt.live/3ijJ1pM
上在线运行此示例。
这也结束了本书第一章的最后一个主题。在下一节中,我们提供了一个活动,这个活动将作为一个实践项目,概括了本章中我们讨论的重要主题和内容。
活动 1.01:构建数独求解器
让我们通过一个更复杂的问题来测试我们迄今为止学到的知识:编写一个可以解决数独谜题的程序。该程序应能够读取 CSV 文本文件作为输入(其中包含初始谜题),并输出该谜题的完整解决方案。
这个活动作为一个热身,包括科学计算和数据科学项目中常见的多个程序,例如从外部文件中读取数据并通过算法操纵这些信息。
-
使用本章的 GitHub 存储库中的
sudoku_input_2.txt
文件作为程序的输入文件,将其复制到下一步中将要创建的 Jupyter 笔记本的相同位置(或者创建一个格式相同的自己的输入文件,其中空单元格用零表示)。 -
在新的 Jupyter 笔记本的第一个代码单元中,创建一个
Solver
类,该类接受输入文件的路径。它应将从输入文件中读取的信息存储在一个 9x9 的 2D 列表中(包含九个子列表,每个子列表包含谜题中各行的九个值)。 -
添加一个辅助方法,以以下方式打印出谜题的格式:
-----------------------
0 0 3 | 0 2 0 | 6 0 0 |
9 0 0 | 3 0 5 | 0 0 1 |
0 0 1 | 8 0 6 | 4 0 0 |
-----------------------
0 0 8 | 1 0 2 | 9 0 0 |
7 0 0 | 0 0 0 | 0 0 8 |
0 0 6 | 7 0 8 | 2 0 0 |
-----------------------
0 0 2 | 6 0 9 | 5 0 0 |
8 0 0 | 2 0 3 | 0 0 9 |
0 0 5 | 0 1 0 | 3 0 0 |
-----------------------
- 在类中创建一个
get_presence(cells)
方法,该方法接受任何 9x9 的 2D 列表,表示未解决/半解决的谜题,并返回一个关于给定数字(1 到 9 之间)是否出现在给定行、列或象限中的指示器。
例如,在前面的示例中,该方法的返回值应能够告诉您第一行中是否存在 2、3 和 6,而第二列中是否没有数字。
- 在类中创建一个
get_possible_values(cells)
方法,该方法还接受表示不完整解决方案的任何 2D 列表,并返回一个字典,其键是当前空单元格的位置,相应的值是这些单元格可以取的可能值的列表/集合。
这些可能值的列表应通过考虑一个数字是否出现在给定空单元格的同一行、列或象限中来生成。
- 在类中创建一个
simple_update(cells)
方法,该方法接受任何 2D 不完整解决方案列表,并在该列表上调用get_possible_values()
方法。根据返回的值,如果有一个只包含一个可能解的空单元格,就用该值更新该单元格。
如果发生了这样的更新,该方法应再次调用自身以继续更新单元格。这是因为更新后,剩余空单元格的可能值列表可能会发生变化。该方法最终应返回更新后的 2D 列表。
- 在类中创建一个
recur_solve(cells)
方法,该方法接受任何 2D 不完整解决方案列表并执行回溯。首先,该方法应调用simple_update()
,并返回谜题是否完全解决(即 2D 列表中是否有空单元格)。
接下来,考虑剩余空单元格的可能值。如果还有空单元格,并且没有可能的值,返回一个负结果,表示我们已经达到了一个无效的解决方案。
另一方面,如果所有单元格至少有两个可能的值,找到可能值最少的单元格。依次循环这些可能的值,将它们填入空单元格,并在其中调用recur_solve()
以使用算法的递归性质更新单元格。在每次迭代中,返回最终解是否有效。如果通过任何可能的值都找不到有效的最终解决方案,则返回一个负结果。
- 将前面的方法封装在一个
solve()
方法中,该方法应打印出初始的谜题,将其传递给recur_solve()
方法,并打印出该方法返回的解决方案。
例如,在前面的谜题中,当调用solve()
时,Solver
实例将打印出以下输出。
初始谜题:
-----------------------
0 0 3 | 0 2 0 | 6 0 0 |
9 0 0 | 3 0 5 | 0 0 1 |
0 0 1 | 8 0 6 | 4 0 0 |
-----------------------
0 0 8 | 1 0 2 | 9 0 0 |
7 0 0 | 0 0 0 | 0 0 8 |
0 0 6 | 7 0 8 | 2 0 0 |
-----------------------
0 0 2 | 6 0 9 | 5 0 0 |
8 0 0 | 2 0 3 | 0 0 9 |
0 0 5 | 0 1 0 | 3 0 0 |
-----------------------
解决的谜题:
-----------------------
4 8 3 | 9 2 1 | 6 5 7 |
9 6 7 | 3 4 5 | 8 2 1 |
2 5 1 | 8 7 6 | 4 9 3 |
-----------------------
5 4 8 | 1 3 2 | 9 7 6 |
7 2 9 | 5 6 4 | 1 3 8 |
1 3 6 | 7 9 8 | 2 4 5 |
-----------------------
3 7 2 | 6 8 9 | 5 1 4 |
8 1 4 | 2 5 3 | 7 6 9 |
6 9 5 | 4 1 7 | 3 8 2 |
-----------------------
扩展
-
前往Project Euler网站,
projecteuler.net/problem=96
,测试你的算法是否能解决包含的谜题。 -
编写一个程序,生成数独谜题,并包括单元测试,检查我们的求解器生成的解是否正确。
注意
此活动的解决方案可在第 648 页找到。
摘要
本章介绍了 Python 编程的最基本构建模块:控制流、数据结构、算法设计以及各种日常任务(调试、测试和版本控制)。我们在本章中获得的知识将为我们在未来章节中的讨论做好准备,在那里我们将学习 Python 中其他更复杂和专业的工具。特别是在下一章中,我们将讨论 Python 在统计学、科学计算和数据科学领域提供的主要工具和库。
PGM59
MAF28
第二章:Python 统计的主要工具
概述
本章介绍了大多数统计从业者在 Python 中使用的主要库的实际介绍。它将涵盖一些最重要和有用的概念、函数和每个关键库的应用程序编程接口(API)。几乎本书其余部分所需的所有计算工具都将在本章介绍。
在本章结束时,您将了解 NumPy 库的数组矢量化背后的思想,并能够使用其抽样功能。您将能够初始化 pandas 数据框架以表示表格数据并操纵其内容。您还将了解数据分析中数据可视化的重要性,并能够利用 Python 的两个最流行的可视化库:Matplotlib 和 Seaborn。
介绍
在上一章中对 Python 语言进行了复习之后,我们现在准备着手处理本书的主要主题:数学和统计。
除其他外,计算数学和统计的一般领域可以分为三个主要的工具中心组件:表示和工程;分析和计算;最后是可视化。在 Python 编程语言的生态系统中,专门的库专门用于这些组件中的每一个(即 pandas、NumPy、Matplotlib 和 Seaborn),使整个过程变得模块化。
虽然可能存在其他类似的软件包和工具,但我们将讨论的库已被证明具有广泛的功能和支持强大的计算、数据处理和可视化选项,使它们成为多年来 Python 程序员首选的工具之一。
在本章中,我们将介绍这些库的每一个,并了解它们的主要 API。通过实践方法,我们将看到这些工具如何在 Python 中创建、操纵、分析和可视化数据方面提供了极大的自由和灵活性。了解如何使用这些工具也将使我们能够更好地应对本研讨会后面章节中的更复杂的主题。
科学计算和 NumPy 基础知识
到目前为止,在本研讨会中已经多次使用了术语科学计算;在该术语的最广泛意义上,它表示使用计算机程序(或任何具有计算能力的东西)来模拟和解决数学、工程或科学中的特定问题的过程。示例可能包括数学模型来查找和分析生物和社会数据中的模式和趋势,或者使用经济数据进行未来预测的机器学习模型。正如您可能已经注意到的那样,这个定义与数据科学的一般领域有重要的重叠,有时甚至可以互换使用这些术语。
在 Python 中许多(如果不是大多数)科学计算项目的主要工具是 NumPy 库。由于 NumPy 是一个外部库,不会预先安装在 Python 中,我们需要下载并安装它。正如您可能已经知道的那样,在 Python 中安装外部库和软件包可以使用包管理器(如 pip 或 Anaconda)轻松完成。
从您的终端运行以下命令,使用 pip 在您的 Python 环境中安装 NumPy:
$ pip install numpy
如果您目前在 Anaconda 环境中,您可以运行以下命令:
$ conda install numpy
通过这些简单的命令,我们已经完成了安装过程中的所有必要步骤。
NumPy 的一些最强大的功能包括对象的矢量化、多维数组表示;实现广泛的线性代数函数和变换;以及随机抽样。我们将在本节中涵盖所有这些主题,从数组的一般概念开始。
NumPy 数组
实际上,在上一章中,当我们讨论 Python 列表时,我们已经接触到了数组的概念。一般来说,数组也是一系列不同元素,可以单独访问或作为整体进行操作。因此,NumPy 数组与 Python 列表非常相似;事实上,声明 NumPy 数组的最常见方式是将 Python 列表传递给numpy.array()
方法,如下所示:
>>> import numpy as np
>>> a = np.array([1, 2, 3])
>>> a
array([1, 2, 3])
>>> a[1]
2
我们需要牢记的最大区别是,NumPy 数组中的元素需要是相同类型的。例如,在这里,我们试图创建一个包含两个数字和一个字符串的数组,这导致 NumPy 强制将数组中的所有元素转换为字符串(<U21
数据类型表示少于 21 个字符的 Unicode 字符串):
>>> b = np.array([1, 2, 'a'])
>>> b
array(['1', '2', 'a'], dtype='<U21')
与我们可以创建多维 Python 列表的方式类似,NumPy 数组也支持相同的选项:
>>> c = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
>>> c
array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
注意
在使用 NumPy 时,我们经常将多维数组称为矩阵。
除了使用 Python 列表进行初始化外,我们还可以创建特定形式的 NumPy 数组。特别是,可以使用np.zeros()
和np.ones()
分别初始化全零或全一的矩阵,指定维度和数据类型。让我们看一个例子:
>>> zero_array = np.zeros((2, 2)) # 2 by 2 zero matrix
>>> zero_array
array([[0., 0.],
[0., 0.]])
这里,元组(2, 2)
指定正在初始化的数组(或矩阵)应具有二乘二的维度。正如我们在零后面看到的点所示,NumPy 数组的默认数据类型是浮点数,并且可以使用dtype
参数进一步指定:
>>> one_array = np.ones((2, 2, 3), dtype=int) # 3D one integer matrix
>>> one_array
array([[[1, 1, 1],
[1, 1, 1]],
[[1, 1, 1],
[1, 1, 1]]])
全零或全一矩阵是数学和统计学中常见的对象,因此这些 API 调用在以后将被证明非常有用。现在,让我们看一个常见的矩阵对象,其元素都是随机数。使用np.random.rand()
,我们可以创建一个给定形状的矩阵,其元素在 0(包括)和 1(不包括)之间均匀抽样:
>>> rand_array = np.random.rand(2, 3)
>>> rand_array
array([[0.90581261, 0.88732623, 0.291661 ],
[0.44705149, 0.25966191, 0.73547706]])
请注意,这里我们不再将所需矩阵的形状作为元组传递,而是作为np.random.rand()
函数的单独参数传递。
如果您对随机性的概念和从各种分布中进行随机抽样不熟悉,不用担心,因为我们将在本章后面涵盖这个主题。现在,让我们继续讨论 NumPy 数组,特别是关于索引和切片。
您会记得,为了访问 Python 列表中的单个元素,我们将其索引传递到列表变量旁边的方括号中;对于一维 NumPy 数组也是如此:
>>> a = np.array([1, 2, 3])
>>> a[0]
1
>>> a[1]
2
然而,当数组是多维的时,我们不再使用多个方括号来访问子数组,而是只需使用逗号来分隔各个索引。例如,我们可以按如下方式访问三乘三矩阵中第二行第二列的元素:
>>> b = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
>>> b
array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
>>> b[1, 1]
5
切片 NumPy 数组可以以相同的方式进行:使用逗号。这种语法在帮助我们访问矩阵中具有多个维度的子矩阵方面非常有用:
>>> a = np.random.rand(2, 3, 4) # random 2-by-3-by-4 matrix
>>> a
array([[[0.54376986, 0.00244875, 0.74179644, 0.14304955],
[0.77229612, 0.32254451, 0.0778769 , 0.2832851 ],
[0.26492963, 0.5217093 , 0.68267418, 0.29538502]],
[[0.94479229, 0.28608588, 0.52837161, 0.18493272],
[0.08970716, 0.00239815, 0.80097454, 0.74721516],
[0.70845696, 0.09788526, 0.98864408, 0.82521871]]])
>>> a[1, 0: 2, 1:]
array([[0.28608588, 0.52837161, 0.18493272],
[0.00239815, 0.80097454, 0.74721516]])
在上面的例子中,a[1, 0: 2, 1:]
帮助我们访问原始矩阵a
中的数字;即在第一个轴(对应索引1
)中的第二个元素,第二个轴(对应0: 2
)中的前两个元素,以及第三个轴(对应1:
)中的最后三个元素。这个选项是 NumPy 数组比 Python 列表更强大和灵活的一个原因,因为 Python 列表不支持多维索引和切片,正如我们所演示的。
最后,另一个重要的用于操作 NumPy 数组的语法是np.reshape()
函数,正如其名称所示,它可以改变给定 NumPy 数组的形状。需要这种功能的情况可能会多次出现:当我们需要以某种方式显示数组以便更好地阅读时,或者当我们需要将数组传递给只接受特定形状数组的内置函数时。
我们可以在以下代码片段中探索这个函数的效果:
>>> a
array([[[0.54376986, 0.00244875, 0.74179644, 0.14304955],
[0.77229612, 0.32254451, 0.0778769 , 0.2832851 ],
[0.26492963, 0.5217093 , 0.68267418, 0.29538502]],
[[0.94479229, 0.28608588, 0.52837161, 0.18493272],
[0.08970716, 0.00239815, 0.80097454, 0.74721516],
[0.70845696, 0.09788526, 0.98864408, 0.82521871]]])
>>> a.shape
(2, 3, 4)
>>> np.reshape(a, (3, 2, 4))
array([[[0.54376986, 0.00244875, 0.74179644, 0.14304955],
[0.77229612, 0.32254451, 0.0778769 , 0.2832851 ]],
[[0.26492963, 0.5217093 , 0.68267418, 0.29538502],
[0.94479229, 0.28608588, 0.52837161, 0.18493272]],
[[0.08970716, 0.00239815, 0.80097454, 0.74721516],
[0.70845696, 0.09788526, 0.98864408, 0.82521871]]])
请注意,np.reshape()
函数不会就地改变传入的数组;相反,它会返回原始数组的副本,新形状的数组而不修改原始数组。我们也可以将这个返回值赋给一个变量。
另外,请注意,虽然数组的原始形状是(2, 3, 4)
,但我们将其改为(3, 2, 4)
。只有当两个形状产生的元素总数相同时才能这样做(2 x 3 x 4 = 3 x 2 x 4 = 24)。如果新形状与数组的原始形状不对应,将会引发错误,如下所示:
>>> np.reshape(a, (3, 3, 3))
-------------------------------------------------------------------------
ValueError Traceback (most recent call last)
...
ValueError: cannot reshape array of size 24 into shape (3,3,3)
说到重塑 NumPy 数组,转置矩阵是重塑的一种特殊形式,它翻转了矩阵中的元素沿着其对角线。计算矩阵的转置是数学和机器学习中的常见任务。可以使用[array].T
语法计算 NumPy 数组的转置。例如,当我们在终端中运行a.T
时,我们得到矩阵a
的转置,如下所示:
>>> a.T
array([[[0.54376986, 0.94479229],
[0.77229612, 0.08970716],
[0.26492963, 0.70845696]],
[[0.00244875, 0.28608588],
[0.32254451, 0.00239815],
[0.5217093 , 0.09788526]],
[[0.74179644, 0.52837161],
[0.0778769 , 0.80097454],
[0.68267418, 0.98864408]],
[[0.14304955, 0.18493272],
[0.2832851 , 0.74721516],
[0.29538502, 0.82521871]]])
有了这个,我们可以结束我们对 NumPy 数组的介绍。在下一节中,我们将学习与 NumPy 数组紧密相关的另一个概念:矢量化。
矢量化
在计算机科学的最广泛意义上,矢量化一词表示将数学运算应用于数组(在一般意义上)的过程,逐个元素。例如,一个加法运算,其中数组中的每个元素都加上相同的项,就是一个矢量化操作;同样,对于矢量化乘法,数组中的所有元素都乘以相同的项。一般来说,当所有数组元素都经过相同的函数处理时,就实现了矢量化。
当在 NumPy 数组(或多个数组)上执行适用的操作时,默认情况下会进行矢量化。这包括二进制函数,如加法、减法、乘法、除法、幂和取模,以及 NumPy 中的几个一元内置函数,如绝对值、平方根、三角函数、对数函数和指数函数。
在我们看到 NumPy 中的矢量化操作之前,值得讨论矢量化的重要性及其在 NumPy 中的作用。正如我们之前提到的,矢量化通常是在数组中的元素上应用常见操作。由于该过程的可重复性,矢量化操作可以被优化为比其在for
循环中的替代实现更有效。然而,这种能力的权衡是数组中的元素需要是相同的数据类型——这也是任何 NumPy 数组的要求。
有了这个,让我们继续进行下一个练习,我们将在这个练习中看到这种效果。
练习 2.01:计时 NumPy 中的矢量化操作
在这个练习中,我们将计算通过使用 NumPy 数组实现各种矢量化操作(如加法,乘法和平方根计算)与不使用矢量化的纯 Python 替代方案相比所实现的加速。为此,请执行以下步骤:
- 在新的 Jupyter 笔记本的第一个单元格中,导入 NumPy 包和
timeit
库中的Timer
类。后者将用于实现我们的计时功能:
import numpy as np
from timeit import Timer
- 在一个新的单元格中,使用
range()
函数初始化一个包含从 0(包括)到 1,000,000(不包括)的数字的 Python 列表,以及使用np.array()
函数的 NumPy 数组对应项:
my_list = list(range(10 ** 6))
my_array = np.array(my_list)
- 现在,我们将在以下步骤中对这个列表和数组应用数学运算。在一个新的单元格中,编写一个名为
for_add()
的函数,它返回一个列表,其中的元素是my_list
变量中的元素加上1
(我们将使用列表推导式)。再编写一个名为vec_add()
的函数,它返回相同数据的 NumPy 数组版本,即my_array + 1
:
def for_add():
return [item + 1 for item in my_list]
def vec_add():
return my_array + 1
- 在下一个代码单元格中,初始化两个
Timer
对象,同时传入前面两个函数。这些对象包含我们将用于跟踪函数速度的接口。
对每个对象调用repeat()
函数,并使用参数 10 和 10——实质上,我们重复了 100 次的定时实验。最后,由于repeat()
函数返回表示每个函数的每次实验中经过的时间的数字列表,我们打印出此列表的最小值。简而言之,我们希望每个函数的最快运行时间:
print('For-loop addition:')
print(min(Timer(for_add).repeat(10, 10)))
print('Vectorized addition:')
print(min(Timer(vec_add).repeat(10, 10)))
该程序产生的输出如下:
For-loop addition:
0.5640330809999909
Vectorized addition:
0.006047582000007878
虽然你的可能不同,但两个数字之间的关系应该是清楚的:for
循环加法函数的速度应该比向量化加法函数的速度低得多。
- 在下一个代码单元格中,实现相同的速度比较,我们将数字乘以
2
。对于 NumPy 数组,只需返回my_array * 2
:
def for_mul():
return [item * 2 for item in my_list]
def vec_mul():
return my_array * 2
print('For-loop multiplication:')
print(min(Timer(for_mul).repeat(10, 10)))
print('Vectorized multiplication:')
print(min(Timer(vec_mul).repeat(10, 10)))
从输出中验证,向量化的乘法函数也比for
循环版本更快。运行此代码后的输出如下:
For-loop multiplication: 0.5431750800000259
Vectorized multiplication: 0.005795304000002943
- 在下一个代码单元格中,实现相同的比较,计算数字的平方根。对于 Python 列表,导入并在列表推导式中使用
math.sqrt()
函数。对于 NumPy 数组,返回表达式np.sqrt(my_array)
:
import math
def for_sqrt():
return [math.sqrt(item) for item in my_list]
def vec_sqrt():
return np.sqrt(my_array)
print('For-loop square root:')
print(min(Timer(for_sqrt).repeat(10, 10)))
print('Vectorized square root:')
print(min(Timer(vec_sqrt).repeat(10, 10)))
从输出中验证,向量化的平方根函数再次比其for
循环对应函数更快:
For-loop square root:
1.1018582749999268
Vectorized square root:
0.01677640299999439
还要注意,np.sqrt()
函数被实现为向量化,这就是为什么我们能够将整个数组传递给该函数。
这个练习介绍了一些 NumPy 数组的向量化操作,并演示了它们与纯 Python 循环对应函数相比有多快。
注意
要访问此特定部分的源代码,请参阅packt.live/38l3Nk7.
您也可以在packt.live/2ZtBSdY.
上在线运行此示例。
这就结束了 NumPy 中的向量化主题。在下一个也是最后一个关于 NumPy 的部分中,我们将讨论该软件包提供的另一个强大功能:随机抽样。
随机抽样
在上一章中,我们看到了如何使用random
库在 Python 中实现随机化的示例。然而,在该库中实现的大多数方法中,随机化是均匀的,在科学计算和数据科学项目中,有时我们需要从除均匀分布以外的分布中抽取样本。NumPy 再次提供了广泛的选择。
一般来说,从概率分布中进行随机抽样是从该概率分布中选择一个实例的过程,具有更高概率的元素更有可能被选择(或抽取)。这个概念与统计学中的随机变量的概念密切相关。随机变量通常用于模拟统计分析中的某些未知数量,它通常遵循给定的分布,具体取决于它所模拟的数据类型。例如,人口成员的年龄通常使用正态分布(也称为钟形曲线或高斯分布)来建模,而到达银行的客户通常使用泊松分布来建模。
通过随机抽样给定与随机变量相关的分布,我们可以获得该变量的实际实现,从而可以执行各种计算,以获得有关所讨论的随机变量的见解和推断。
我们将在本书的后面重新访问概率分布的概念和用法。现在,让我们简单地专注于手头的任务:如何从这些分布中抽取样本。这是通过np.random
包来实现的,该包包括了允许我们从各种分布中抽取的接口。
例如,以下代码片段初始化了一个从正态分布中抽取的样本(请注意,由于随机性,您的输出可能与以下内容不同):
>>> sample = np.random.normal()
>>> sample
-0.43658969989465696
您可能已经意识到正态分布由两个统计数据来指定:均值和标准差。这些可以分别在np.random.normal()
函数中使用loc
(默认值为0.0
)和scale
(默认值为1.0
)参数来指定,如下所示:
>>> sample = np.random.normal(loc=100, scale=10)
>>> sample
80.31187658687652
还可以一次性以 NumPy 数组的形式抽取多个样本,而不仅仅是单个样本。为此,我们可以在np.random.normal()
函数的size
参数中指定所需的输出数组形状。例如,在这里,我们正在创建一个从相同正态分布中抽取的 2 x 3 矩阵样本:
>>> samples = np.random.normal(loc=100, scale=10, size=(2, 3))
>>> samples
array([[ 82.7834678 , 109.16410976, 101.35105681],
[112.54825751, 107.79073472, 77.70239823]])
这个选项允许我们取得输出数组,并可能对其应用其他 NumPy 特定的操作(如矢量化)。另一种方法是顺序地将单个样本抽取到列表中,然后将其转换为 NumPy 数组。
重要的是要注意,每个概率分布都有自己定义它的统计数据。正态分布,正如我们所见,有一个均值和一个标准差,而前面提到的泊松分布则是用λ(lambda)参数来定义的,它被解释为区间的期望。让我们通过一个例子来看一下:
>>> samples = np.random.poisson(lam=10, size=(2, 2))
>>> samples
array([[11, 10],
[15, 11]])
通常,在 NumPy 中从概率分布中抽取样本之前,您应该始终查阅相应的文档,以了解该特定分布可用的参数以及它们的默认值是什么。
除了概率分布,NumPy 还提供了其他与随机性相关的功能,这些功能可以在random
模块中找到。例如,np.random.randint()
函数返回两个给定数字之间的随机整数;np.random.choice()
从给定的一维数组中随机抽取样本;而np.random.shuffle()
则在原地随机打乱给定的序列。
这些功能在以下代码片段中展示,提供了在 Python 中处理随机性方面的重要灵活性,特别是在科学计算中:
>>> np.random.randint(low=0, high=10, size=(2, 5))
array([[6, 4, 1, 3, 6],
[0, 8, 8, 8, 8]])
>>> np.random.choice([1, 3, 4, -6], size=(2, 2))
array([[1, 1],
[1, 4]])
>>> a = [1, 2, 3, 4]
>>> for _ in range(3):
... np.random.shuffle(a)
... print(a)
[4, 1, 3, 2]
[4, 1, 2, 3]
[1, 2, 4, 3]
每当编程中涉及随机性时,我们需要讨论的最后一个重要主题就是可重现性。这个术语表示在不同运行中从程序中获得相同的结果的能力,特别是当程序中存在与随机性相关的元素时。
当程序中存在错误但只在某些随机情况下才显现时,可重现性是至关重要的。通过强制程序每次执行时生成相同的随机数,我们有另一种方法来缩小并识别这种类型的错误,除了单元测试之外。
在数据科学和统计学中,可重现性是至关重要的。如果一个程序不可重现,那么一个研究人员可能会发现一个统计上显著的结果,而另一个研究人员却无法做到,即使两者使用相同的代码和方法。这就是为什么许多从业者已经开始在数据科学和机器学习领域非常重视可重现性的原因。
实现可重现性的最常见方法(也是最容易编程的方法)是简单地固定程序(特别是其库)的种子,这些程序利用随机性。固定与随机性相关的库的种子可以确保在同一程序的不同运行中生成相同的随机数。换句话说,这允许产生相同的结果,即使程序在不同的机器上运行多次。
为了做到这一点,我们可以简单地将一个整数传递给产生我们程序随机性的库/包的适当种子函数。例如,要为random
库设置种子,我们可以写如下代码:
>>> import random
>>> random.seed(0) # can use any other number
对于 NumPy 中的随机包,我们可以写如下代码:
>>> np.random.seed(0)
设置这些库/包的种子通常是一个很好的做法,当你为一个团队或一个开源项目做贡献时;再次,它确保团队的所有成员能够达到相同的结果,并消除了误解。
这个话题也结束了我们对 NumPy 库的讨论。接下来,我们将转向 Python 中数据科学和科学计算生态系统的另一个重要部分:pandas 库。
在 pandas 中处理表格数据
如果 NumPy 用于矩阵数据和线性代数运算,pandas 则设计用于处理表格形式的数据。就像 NumPy 一样,pandas 可以使用 pip 包管理器在 Python 环境中安装:
$ pip install pandas
如果你使用 Anaconda,你可以使用以下命令下载它:
$ conda install pandas
安装过程完成后,启动 Python 解释器并尝试导入该库:
>>> import pandas as pd
如果这个命令没有出现任何错误消息,那么你已经成功安装了 pandas。有了这个,让我们继续我们的讨论,从 pandas 中最常用的数据结构开始,DataFrame
,它可以表示表格数据:具有行和列标签的二维数据。这与 NumPy 数组形成对比,NumPy 数组可以具有任何维度,但不支持标记。
初始化 DataFrame 对象
有多种方法可以初始化DataFrame
对象。首先,我们可以通过传递一个 Python 字典来手动创建一个,其中每个键应该是列的名称,该键的值应该是该列包含的数据,以列表或 NumPy 数组的形式。
例如,在下面的代码中,我们正在创建一个包含两行三列的表格。第一列按顺序包含数字 1 和 2,第二列包含 3 和 4,第三列包含 5 和 6:
>>> import pandas as pd
>>> my_dict = {'col1': [1, 2], 'col2': np.array([3, 4]),'col3': [5, 6]}
>>> df = pd.DataFrame(my_dict)
>>> df
col1 col2 col3
0 1 3 5
1 2 4 6
关于DataFrame
对象的第一件事是,正如你从前面的代码片段中看到的那样,当一个被打印出来时,输出会自动由 pandas 的后端格式化。表格格式使得该对象中表示的数据更易读。此外,当在 Jupyter 笔记本中打印出DataFrame
对象时,也会使用类似的格式化以实现可读性,如下面的截图所示:
图 2.1:在 Jupyter 笔记本中打印的 DataFrame 对象
初始化DataFrame
对象的另一种常见方法是,当我们已经用 2D NumPy 数组表示其数据时,我们可以直接将该数组传递给DataFrame
类。例如,我们可以使用以下代码初始化我们之前看过的相同 DataFrame:
>>> my_array = np.array([[1, 3, 5], [2, 4, 6]])
>>> alt_df = pd.DataFrame(my_array, columns=['col1', 'col2', 'col3'])
>>> alt_df
col1 col2 col3
0 1 3 5
1 2 4 6
也就是说,初始化DataFrame
对象的最常见方式是通过pd.read_csv()
函数,这个函数读取 CSV 文件(或任何以不同分隔特殊字符格式化的文本文件)并将其呈现为DataFrame
对象。我们将在下一节中看到这个函数的运行,我们将了解 pandas 库中更多功能的工作方式。
访问行和列
一旦我们已经有了用DataFrame
对象表示的数据表,我们可以使用多种选项与该表进行交互和操作。例如,我们可能关心的第一件事是访问某些行和列的数据。幸运的是,pandas 为这项任务提供了直观的 Python 语法。
要访问一组行或列,我们可以利用loc
方法,该方法接受我们感兴趣的行/列的标签。从语法上讲,这个方法与方括号一起使用(以模拟 Python 中的索引语法)。例如,使用我们上一节中相同的表,我们可以传入一行的名称(例如0
):
>>> df.loc[0]
col1 1
col2 3
col3 5
Name: 0, dtype: int64
我们可以看到先前返回的对象包含我们想要的信息(第一行和数字 1、3 和 5),但它的格式是陌生的。这是因为它作为Series
对象返回。Series
对象是DataFrame
对象的特例,只包含 1D 数据。我们不需要太关注这种数据结构,因为它的接口与DataFrame
的接口非常相似。
仍然考虑loc
方法,我们可以传入一个行标签列表来访问多个行。以下代码返回我们示例表中的两行:
>>> df.loc[[0, 1]]
col1 col2 col3
0 1 3 5
1 2 4 6
假设您想按列访问我们表中的数据。loc
方法通过我们在 NumPy 数组中熟悉的索引语法(用逗号分隔的行索引和列索引)提供了这个选项。访问第一行和第二列和第三列中的数据:
>>> df.loc[0, ['col2', 'col3']]
col2 3
col3 5
Name: 0, dtype: int64
请注意,如果您想要返回DataFrame
对象中的整列,可以在行索引中使用特殊字符冒号:
,表示应返回所有行。例如,要访问我们的DataFrame
对象中的'col3'
列,我们可以说df.loc[:, 'col3']
。然而,在访问整列的特殊情况下,还有另一种简单的语法:只使用方括号而不使用loc
方法,如下所示:
>>> df['col3']
0 5
1 6
Name: col3, dtype: int64
早些时候,我们说在访问DataFrame
中的单个行或列时,将返回Series
对象。这些对象可以使用,例如,for
循环进行迭代:
>>> for item in df.loc[:, 'col3']:
... print(item)
5
6
在更改DataFrame
对象中的值方面,我们可以使用前面的语法为行和列分配新值:
>>> df.loc[0] = [3, 6, 9] # change first row
>>> df
col1 col2 col3
0 3 6 9
1 2 4 6
>>> df['col2'] = [0, 0] # change second column
>>> df
col1 col2 col3
0 3 0 9
1 2 0 6
此外,我们可以使用相同的语法声明新的行和列:
>>> df['col4'] = [10, 10]
>>> df.loc[3] = [1, 2, 3, 4]
>>> df
col1 col2 col3 col4
0 3 0 9 10
1 2 0 6 10
3 1 2 3 4
最后,即使在loc
方法中通常通过指定它们的实际索引来访问DataFrame
对象中的行和列,也可以使用布尔值(True
和False
)数组来实现相同的效果。
例如,我们可以通过编写以下内容访问我们当前表中的第二行和第二和第四列中的项目:
>>> df.loc[[False, True, False], [False, True, False, True]]
col2 col4
1 0 10
在这里,行的布尔索引列表[False, True, False]
表示只返回第二个元素(即第二行),而列的布尔索引列表类似地指定要返回第二和第四列。
虽然这种访问DataFrame
对象中元素的方法可能看起来很奇怪,但它对于过滤和替换任务非常有价值。具体来说,我们可以在loc
方法中使用条件,而不是传入布尔值列表作为索引。例如,要显示我们当前的表,只显示第一行中值大于5
的列(应该是第三和第四列),我们可以编写以下内容:
>>> df.loc[:, df.loc[0] > 5]
col3 col4
0 9 10
1 6 10
3 3 4
同样,这种语法在过滤出满足某些条件的DataFrame
对象中的行或列并可能为它们分配新值方面特别有用。这种功能的一个特殊情况是查找和替换任务(我们将在下一节中介绍)。
操作 DataFrame
在本节中,我们将尝试一些用于操作DataFrame
对象的方法和函数,以便操作这些对象中的数据。当然,还有许多其他可用的方法(可以在官方文档中找到:pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html
)。然而,下表中给出的方法是最常用的,可以在帮助我们创建、维护和改变数据表方面提供强大的功能和灵活性:
图 2.2:用于操作 pandas 数据的方法
下面的练习将演示前面方法的效果,以便更好地理解。
练习 2.02:数据表操作
在这个实践练习中,我们将学习前面部分包含的函数和方法。我们的目标是看到这些方法的效果,并执行常见的数据操作技术,比如重命名列、填充缺失值、排序数值,或者将数据表写入文件。
执行以下步骤完成这个练习:
- 从这个研讨会的 GitHub 存储库中,将
Exercise2.02/dataset.csv
文件复制到Chapter02
文件夹中的新目录中。文件的内容如下:
id,x,y,z
0,1,1,3
1,1,0,9
2,1,3,
3,2,0,10
4,1,,4
5,2,2,3
-
在新目录中创建一个新的 Jupyter 笔记本。确保这个笔记本和 CSV 文件在同一个位置。
-
在这个笔记本的第一个单元格中,导入 pandas 和 NumPy,然后使用
pd.read_csv()
函数读取dataset.csv
文件。指定这个函数的index_col
参数为'id'
,这是我们样本数据集中的第一列的名称:
import pandas as pd
import numpy as np
df = pd.read_csv('dataset.csv', index_col='id')
- 当我们打印这个新创建的
DataFrame
对象时,我们可以看到它的值直接对应于我们原始的输入文件:
x y z
id
0 1 1.0 3.0
1 1 0.0 9.0
2 1 3.0 NaN
3 2 0.0 10.0
4 1 NaN 4.0
5 2 2.0 3.0
注意这里的NaN
(不是一个数字)值;NaN
是DataFrame
对象在初始化时将填充空单元格的默认值。由于我们的原始数据集被设计为包含两个空单元格,这些单元格被适当地填充为NaN
,正如我们在这里所看到的。
此外,NaN
值在 Python 中被注册为浮点数,这就是为什么包含它们的两列的数据类型相应地转换为浮点数(值中的小数点表示)。
- 在下一个单元格中,使用
rename()
方法将当前列重命名为'col_x'
、'col_y'
和'col_z'
。在这里,columns
参数应该使用 Python 字典指定每个旧列名到它的新名字的映射:
df = df.rename(columns={'x': 'col_x', 'y': 'col_y', \
'z': 'col_z'})
当运行代码行后,可以观察到df
打印出的变化:
col_x col_y col_z
id
0 1 1.0 3.0
1 1 0.0 9.0
2 1 3.0 NaN
3 2 0.0 10.0
4 1 NaN 4.0
5 2 2.0 3.0
- 在下一个单元格中,使用
fillna()
函数将NaN
值替换为零。之后,使用astype(int)
将表格中的所有数据转换为整数:
df = df.fillna(0)
df = df.astype(int)
结果的DataFrame
对象现在如下所示:
col_x col_y col_z
id
0 1 1 3
1 1 0 9
2 1 3 0
3 2 0 10
4 1 0 4
5 2 2 3
- 在下一个代码单元格中,通过将
[1, 3, 4]
列表传递给drop
方法,从数据集中删除第二、第四和第五行:
df = df.drop([1, 3, 4], axis=0)
注意,axis=0
参数指定我们传递给方法的标签指定数据集的行,而不是列。类似地,要删除特定列,可以使用列标签的列表,同时指定axis=1
。
结果表现如下:
col_x col_y col_z
id
0 1 1 3
2 1 3 0
5 2 2 3
- 在下一个单元格中,创建一个全零的 2 x 3
DataFrame
对象,并使用相应的列标签作为当前df
变量:
zero_df = pd.DataFrame(np.zeros((2, 3)), columns=['col_x', 'col_y', \
'col_z'])
输出如下:
col_x col_y col_z
0 0.0 0.0 0.0
1 0.0 0.0 0.0
- 在下一个代码单元格中,使用
pd.concat()
函数将两个DataFrame
对象连接在一起(指定axis=0
,以便垂直连接两个表,而不是水平连接):
df = pd.concat([df, zero_df], axis=0)
我们当前的df
变量现在打印出以下内容(注意表格底部新增的两行):
col_x col_y col_z
0 1.0 1.0 3.0
2 1.0 3.0 0.0
5 2.0 2.0 3.0
0 0.0 0.0 0.0
1 0.0 0.0 0.0
- 在下一个单元格中,按
col_x
列中的数据按升序对我们当前的表进行排序:
df = df.sort_values('col_x', axis=0)
结果数据集现在如下所示:
col_x col_y col_z
0 0.0 0.0 0.0
1 0.0 0.0 0.0
0 1.0 1.0 3.0
2 1.0 3.0 0.0
5 2.0 2.0 3.0
- 最后,在另一个代码单元中,将我们的表转换为整数数据类型(与之前的方式相同),并使用
to_csv()
方法将此表写入文件。将'output.csv'
作为输出文件的名称传递,并指定index=False
,以便输出中不包括行标签:
df = df.astype(int)
df.to_csv('output.csv', index=False)
书面输出应如下所示:
col_x, col_y, col_z
0,0,0
0,0,0
1,1,3
1,3,0
2,2,3
这就是本练习的结束。总的来说,这个练习模拟了使用表格数据集的简化工作流程:读取数据,以某种方式操纵数据,最后将数据写入文件。
注意
要访问此特定部分的源代码,请参阅packt.live/38ldQ8O
。
您还可以在packt.live/3dTzkL6
上在线运行此示例。
在下一个也是最后一个关于 pandas 的部分中,我们将考虑库提供的一些更高级的功能。
高级 Pandas 功能
访问和更改DataFrame
对象的行和列中的值是使用 pandas 库处理表格数据的最简单的方法之一。在本节中,我们将介绍另外三种更复杂但也提供了强大选项来操作我们的DataFrame
对象的选项。第一个是apply()
方法。
如果您已经熟悉了其他数据结构的这种方法的概念,那么对于为DataFrame
对象实现的这种方法也是一样的。从一般意义上讲,此方法用于将函数应用于DataFrame
对象中的所有元素。与我们之前讨论的矢量化概念类似,apply()
方法之后的结果DataFrame
对象的元素将是原始数据的每个元素被馈送到指定函数时的结果。
例如,假设我们有以下DataFrame
对象:
>>> df = pd.DataFrame({'x': [1, 2, -1], 'y': [-3, 6, 5], \
'z': [1, 3, 2]})
>>> df
x y z
0 1 -3 1
1 2 6 3
2 -1 5 2
现在,假设我们想创建另一列,其条目是x_squared
列中的条目。然后,我们可以使用apply()
方法,如下所示:
>>> df['x_squared'] = df['x'].apply(lambda x: x ** 2)
>>> df
x y z x_squared
0 1 -3 1 1
1 2 6 3 4
2 -1 5 2 1
这里的术语lambda x: x ** 2
只是一种快速声明无名称函数的方法。从打印输出中,我们看到'x_squared'
列已正确创建。另外,请注意,对于诸如平方函数之类的简单函数,我们实际上可以利用我们已经熟悉的 NumPy 数组的简单语法。例如,以下代码将产生与我们刚才考虑的代码相同的效果:
>>> df['x_squared'] = df['x'] ** 2
然而,对于更复杂且不容易矢量化的函数,最好是完全编写它,然后将其传递给apply()
方法。例如,假设我们想创建一个列,如果同一行中x
列中的元素是偶数,则每个单元格应包含字符串'even'
,否则包含字符串'odd'
。
在这里,我们可以创建一个名为parity_str()
的单独函数,该函数接受一个数字并返回相应的字符串。然后,可以将此函数与df['x']
上的apply()
方法一起使用,如下所示:
>>> def parity_str(x):
... if x % 2 == 0:
... return 'even'
... return 'odd'
>>> df['x_parity'] = df['x'].apply(parity_str)
>>> df
x y z x_squared x_parity
0 1 -3 1 1 odd
1 2 6 3 4 even
2 -1 5 2 1 odd
Pandas 中另一个常用的略微高级的功能是pd.get_dummies()
函数。该函数实现了一种称为独热编码的技术,用于数据集中的分类属性(或列)。
我们将在下一章更详细地讨论分类属性的概念,以及其他类型的数据。现在,我们只需要记住,有时统计和机器学习模型无法解释纯分类数据。相反,我们希望有一种方法将数据的分类特征转换为数字形式,同时确保不丢失任何信息。
独热编码就是这样一种方法;它通过为每个唯一值生成一个新的列/属性,并用布尔数据填充新列中的单元格,指示原始分类属性的值。
这种方法通过示例更容易理解,所以让我们考虑前面例子中创建的新的'x_parity'
列:
>>> df['x_parity']
0 odd
1 even
2 odd
Name: x_parity, dtype: object
这一列被认为是一个分类属性,因为它的值属于一组特定的类别(在这种情况下,类别是odd
和even
)。现在,通过在该列上调用pd.get_dummies()
,我们得到以下的DataFrame
对象:
>>> pd.get_dummies(df['x_parity'])
even odd
0 0 1
1 1 0
2 0 1
正如我们从打印输出中所观察到的,DataFrame
对象包括两列,对应于原始分类数据中的唯一值('x_parity'
列)。对于每一行,对应于原始数据中的值的列被设置为1
,而其他列被设置为0
。例如,第一行原始包含odd
在'x_parity'
列中,所以它的新odd
列被设置为1
。
我们可以看到,使用独热编码,我们可以将任何分类属性转换为一组新的二进制属性,使数据对于统计和机器学习模型来说是可读的数字。然而,这种方法的一个很大的缺点是维度的增加,因为它创建了与原始分类属性中的唯一值数量相等的新列。因此,如果分类数据包含许多不同的值,这种方法可能会导致我们的表格大大增加。根据您的计算能力和资源,该方法的推荐唯一分类值的数量限制为 50。
value_counts()
方法是 pandas 中另一个有价值的工具,你应该掌握。这个方法,要调用在DataFrame
对象的一列上,返回该列中的唯一值及其相应的计数的列表。因此,这个方法只适用于分类或离散数据,其值属于给定的、预先确定的可能值集合。
例如,仍然考虑我们样本数据集的'x_parity'
属性,我们将检查value_counts()
方法的效果:
>>> df['x_parity'].value_counts()
odd 2
even 1
Name: x_parity, dtype: int64
我们可以看到,在'x_parity'
列中,我们确实有两个条目(或行)的值为odd
,一个条目为even
。总的来说,这种方法在确定值的分布方面非常有用,再次,特别是对于分类和离散数据类型。
我们将讨论的下一个也是最后一个 pandas 的高级功能是groupby
操作。这个操作允许我们将DataFrame
对象分成子组,其中组中的行都共享一个分类属性中的值。从这些单独的组中,我们可以计算描述性统计(这是我们将在下一章中深入探讨的概念),以进一步探索我们的数据集。
我们将在下一个练习中看到这一点,我们将探索一个样本学生数据集。
练习 2.03:学生数据集
通过考虑一个真实数据集的样本,我们将运用我们对 pandas 最常见函数的知识,包括我们一直在讨论的内容,以及新的groupby
操作。
执行以下步骤来完成这个练习:
- 创建一个新的 Jupyter 笔记本,在它的第一个单元格中运行以下代码以生成我们的样本数据集:
import pandas as pd
student_df = pd.DataFrame({'name': ['Alice', 'Bob', 'Carol', \
'Dan', 'Eli', 'Fran'],\
'gender': ['female', 'male', \
'female', 'male', \
'male', 'female'],\
'class': ['FY', 'SO', 'SR', \
'SO',' JR', 'SR'],\
'gpa': [90, 93, 97, 89, 95, 92],\
'num_classes': [4, 3, 4, 4, 3, 2]})
student_df
这段代码将产生以下输出,以表格形式显示我们的样本数据集:
name gender class gpa num_classes
0 Alice female FY 90 4
1 Bob male SO 93 3
2 Carol female SR 97 4
3 Dan male SO 89 4
4 Eli male JR 95 3
5 Fran female SR 92 2
我们数据集中的大多数属性都是不言自明的:在每一行(代表一个学生)中,name
包含学生的姓名,gender
表示学生是男性还是女性,class
是一个可以取四个唯一值的分类属性(FY
代表大一,SO
代表大二,JR
代表大三,SR
代表大四),gpa
表示学生的累积分数,最后,num_classes
保存学生目前正在上多少门课的信息。
- 在一个新的代码单元格中,创建一个名为
'female_flag'
的新属性,其各个单元格应该包含布尔值True
,如果对应的学生是女性,则为True
,否则为False
。
在这里,我们可以看到我们可以利用apply()
方法,同时传入一个 lambda 对象,如下所示:
student_df['female_flag'] = student_df['gender']\
.apply(lambda x: x == 'female')
但是,我们也可以简单地使用student_df['gender'] == 'female'
表达式声明新属性,该表达式按顺序评估条件:
student_df['female_flag'] = student_df['gender'] == 'female'
- 这个新创建的属性包含了旧的
gender
列中包含的所有信息,因此我们将使用drop()
方法从数据集中删除后者(请注意,我们需要指定axis=1
参数,因为我们正在删除一列):
student_df = student_df.drop('gender', axis=1)
我们当前的DataFrame
对象应该如下所示:
name class gpa num_classes female_flag
0 Alice FY 90 4 True
1 Bob SO 93 3 False
2 Carol SR 97 4 True
3 Dan SO 89 4 False
4 Eli JR 95 3 False
5 Fran SR 92 2 True
- 在一个新的代码单元格中,编写一个表达式,对分类属性
class
应用独热编码:
pd.get_dummies(student_df['class'])
- 在同一个代码单元格中,将这个表达式包含在
pd.concat()
函数中,将这个新创建的DataFrame
对象与我们的旧对象连接起来,同时删除class
列(因为我们现在有了这个属性信息的替代):
student_df = pd.concat([student_df.drop('class', axis=1), \
pd.get_dummies(student_df['class'])], axis=1)
当前数据集现在应该如下所示:
name gpa num_classes female_flag JR FY SO SR
0 Alice 90 4 True 1 0 0 0
1 Bob 93 3 False 0 0 1 0
2 Carol 97 4 True 0 0 0 1
3 Dan 89 4 False 0 0 1 0
4 Eli 95 3 False 0 1 0 0
5 Fran 92 2 True 0 0 0 1
- 在下一个单元格中,对
student_df
调用groupby()
方法,并使用female_flag
参数将返回的值赋给一个名为gender_group
的变量:
gender_group = student_df.groupby('female_flag')
正如你可能已经猜到的,这里我们将相同性别的学生分组,因此男性学生将被分在一起,女性学生也将被分在一起,但与第一组分开。
重要的是要注意,当我们尝试打印存储在gender_group
变量中的这个GroupBy
对象时,我们只会得到一个通用的基于内存的字符串表示:
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x11d492550>
- 现在,我们想计算前面分组中每个组的平均 GPA。为此,我们可以使用以下简单的语法:
gender_group['gpa'].mean()
输出将如下所示:
female_flag
False 92.333333
True 93.000000
Name: gpa, dtype: float64
我们对gender_group
变量的命令非常直观:我们想要计算特定属性的平均值,因此我们使用方括号['gpa']
访问该属性,然后对其调用mean()
方法。
- 类似地,我们可以使用以下代码计算男性学生和女性学生的总课程数:
gender_group['num_classes'].sum()
输出如下:
female_flag
False 10
True 10
Name: num_classes, dtype: int64
在整个练习中,我们提醒自己一些 pandas 中重要的方法,并通过一个真实数据集的示例看到了groupby
操作的效果。这个练习也结束了我们关于 pandas 库的讨论,这是 Python 中处理表格数据的首选工具。
注意
要访问此特定部分的源代码,请参考packt.live/2NOe5jt
。
您也可以在packt.live/3io2gP2
上在线运行此示例。
在本章的最后一节中,我们将讨论典型数据科学/科学计算流水线的最后一部分:数据可视化。
使用 Matplotlib 和 Seaborn 进行数据可视化
数据可视化无疑是任何数据流水线的重要组成部分。良好的可视化不仅可以帮助科学家和研究人员发现有关其数据的独特见解,还可以以直观、易于理解的方式传达复杂、高级的想法。在 Python 中,大多数数据可视化工具的后端都连接到 Matplotlib 库,该库提供了非常广泛的选项和功能,我们将在接下来的讨论中看到。
首先,要安装 Matplotlib,只需运行以下命令之一,取决于您的 Python 包管理器是哪一个:
$ pip install matplotlib
$ conda install matplotlib
Python 中的惯例是从 Matplotlib 库中导入pyplot
包,如下所示:
>>> import matplotlib.pyplot as plt
这个pyplot
包,现在的别名是plt
,是 Python 中任何可视化功能的主要工具,因此将被广泛使用。
总的来说,与其学习库的理论背景,本节我们将采取更加实践的方法,并介绍 Matplotlib 提供的许多不同的可视化选项。最终,我们将获得实用的经验,这将有益于您将来的项目。
散点图
散点图是最基本的可视化方法之一-在平面(或其他高维空间)上绘制一系列点的列表。这只需通过plt.scatter()
函数完成。例如,假设我们有一个包含五个点的列表,其 x 和 y 坐标分别存储在以下两个列表中:
>>> x = [1, 2, 3, 1.5, 2]
>>> y = [-1, 5, 2, 3, 0]
现在,我们可以使用plt.scatter()
函数创建散点图:
>>> import matplotlib.pyplot as plt
>>> plt.scatter(x, y)
>>> plt.show()
上述代码将生成以下图表,该图表与我们输入plt.scatter()
函数的两个列表中的数据完全对应:
图 2.3:使用 Matplotlib 的散点图
请注意代码片段末尾的plt.show()
命令。该函数负责显示由上述代码定制的图表,并且应放置在与可视化相关的代码块的末尾。
至于plt.scatter()
函数,我们可以指定参数进一步定制我们的图表。例如,我们可以定制各个点的大小,以及它们各自的颜色:
>>> sizes = [10, 40, 60, 80, 100]
>>> colors = ['r', 'b', 'y', 'g', 'k']
>>> plt.scatter(x, y, s=sizes, c=colors)
>>> plt.show()
上述代码产生以下输出:
图 2.4:带有大小和颜色自定义的散点图
当您希望在散点图中可视化的点属于不同的数据组时,此功能非常有用,这种情况下,您可以为每个组分配一个颜色。在许多情况下,使用此方法发现了不同数据组形成的聚类。
注意
要查看 Matplotlib 颜色及其用法的完整文档,请参阅以下网页:matplotlib.org/2.0.2/api/colors_api.html.
总的来说,散点图用于可视化我们感兴趣的数据的空间分布。使用散点图的一个潜在目标是揭示数据中存在的任何聚类,这可以为我们提供关于数据集属性之间关系的进一步见解。
接下来,让我们考虑折线图。
折线图
折线图是另一种最基本的可视化方法,其中点沿着曲线绘制,而不是分散绘制。这是通过简单的plt.plot()
函数完成的。例如,我们在以下代码中绘制正弦波(从 0 到 10):
>>> import numpy as np
>>> x = np.linspace(0, 10, 1000)
>>> y = np.sin(x)
>>> plt.plot(x, y)
>>> plt.show()
请注意,这里的np.linspace()
函数返回两个端点之间均匀间隔的数字数组。在我们的例子中,我们获得了 0 到 10 之间的 1,000 个均匀间隔的数字。这里的目标是对这些数字进行正弦函数并将其绘制出来。由于点非常接近彼此,它将产生真正平滑函数被绘制的效果。
这将导致以下图表:
图 2.5:使用 Matplotlib 的折线图
与散点图的选项类似,这里我们可以定制线图的各种元素,特别是线条的颜色和样式。以下代码绘制了三条单独的曲线(y = x图,自然对数函数和正弦波),提供了一个示例:
x = np.linspace(1, 10, 1000)
linear_line = x
log_curve = np.log(x)
sin_wave = np.sin(x)
curves = [linear_line, log_curve, sin_wave]
colors = ['k', 'r', 'b']
styles = ['-', '--', ':']
for curve, color, style in zip(curves, colors, styles):
plt.plot(x, curve, c=color, linestyle=style)
plt.show()
上述代码产生以下输出:
图 2.6:带有样式自定义的折线图
注意
可以在 Matplotlib 的官方文档中找到完整的线型列表,具体在以下页面:matplotlib.org/3.1.0/gallery/lines_bars_and_markers/linestyles.html.
通常,线图用于可视化特定函数的趋势,该函数由按顺序排列的点列表表示。因此,这种方法非常适用于具有一些顺序元素的数据,例如时间序列数据集。
接下来,我们将考虑 Matplotlib 中条形图的可用选项。
条形图
条形图通常用于通过各个条的高度表示数据集中唯一值的计数。在 Matplotlib 中,这是使用plt.bar()
函数来实现的,如下所示:
labels = ['Type 1', 'Type 2', 'Type 3']
counts = [2, 3, 5]
plt.bar(labels, counts)
plt.show()
plt.bar()
函数接受的第一个参数(在本例中为labels
变量)指定了各个条的标签,而第二个参数(在本例中为counts
)指定了条的高度。使用这段代码,将生成以下图形:
图 2.7:使用 Matplotlib 的条形图
](image/B15968_02_07.jpg)
图 2.7:使用 Matplotlib 的条形图
与往常一样,您可以使用c
参数指定各个条的颜色。对我们来说更有趣的是能够使用堆叠或分组条来创建更复杂的条形图。与其简单地比较不同数据的计数,堆叠或分组条用于可视化每个条在较小子组中的组成。
例如,假设在Type 1
、Type 2
和Type 3
的每个组中,如前面的例子中,我们有两个子组,Type A
和Type B
,如下所示:
type_1 = [1, 1] # 1 of type A and 1 of type B
type_2 = [1, 2] # 1 of type A and 2 of type B
type_3 = [2, 3] # 2 of type A and 3 of type B
counts = [type_1, type_2, type_3]
实质上,Type 1
、Type 2
和Type 3
的总计仍然相同,但现在每个都可以进一步分为两个子组,由 2D 列表counts
表示。一般来说,这里的类型可以是任何东西;我们的目标只是简单地使用堆叠或分组条形图来可视化每个大类型中子组的组成。
首先,我们的目标是创建分组条形图;我们的目标是以下可视化效果:
图 2.8:分组条形图
](image/B15968_02_08.jpg)
图 2.8:分组条形图
这是一种更高级的可视化,因此创建图形的过程更加复杂。首先,我们需要指定分组条的各个位置及其宽度:
locations = np.array([0, 1, 2])
width = 0.3
然后,我们在适当的数据上调用plt.bar()
函数:一次在Type A
数字上([my_type[0] for my_type in counts]
,使用列表推导),一次在Type B
数字上([my_type[1] for my_type in counts]
):
bars_a = plt.bar(locations - width / 2, [my_type[0] for my_type in counts], width=width)
bars_b = plt.bar(locations + width / 2, [my_type[1] for my_type in counts], width=width)
术语locations - width / 2
和locations + width / 2
指定了Type A
条和Type B
条的确切位置。重要的是,我们在plt.bar()
函数的width
参数中重用这个width
变量,以便每组的两个条紧挨在一起。
接下来,我们想要自定义每组条的标签。另外,请注意,我们还将plt.bar()
的返回值分配给两个变量bars_a
和bars_b
,然后将用于生成图例:
plt.xticks(locations, ['Type 1', 'Type 2', 'Type 3'])
plt.legend([bars_a, bars_b], ['Type A', 'Type B'])
最后,当我们调用plt.show()
时,所需的图形将被显示。
因此,这是创建分组条形图的过程,其中属于一组的单个条放在一起。另一方面,堆叠条形图将条形放在彼此之上。这两种类型的图表大多用于传达相同的信息,但使用堆叠条形图,每个组的总计更容易进行视觉检查和比较。
要创建堆叠条形图,我们利用plt.bar()
函数在声明非第一组时使用bottom
参数。具体来说,我们这样做:
bars_a = plt.bar(locations, [my_type[0] for my_type in counts])
bars_b = plt.bar(locations, [my_type[1] for my_type in counts], \
bottom=[my_type[0] for my_type in counts])
plt.xticks(locations, ['Type 1', 'Type 2', 'Type 3'])
plt.legend([bars_a, bars_b], ['Type A', 'Type B'])
plt.show()
上述代码将创建以下可视化效果:
图 2.9:堆叠条形图
](image/B15968_02_09.jpg)
图 2.9:堆叠条形图
这就结束了我们在 Matplotlib 中对条形图的介绍。通常,这些类型的图表用于可视化分类属性中不同值组的计数或百分比。正如我们所观察到的,Matplotlib 提供了可扩展的 API,可以以灵活的方式生成这些图表。
现在,让我们继续我们下一个可视化技术:直方图。
直方图
直方图是一种将多个条放在一起的可视化,但它与条形图的联系就到此为止了。直方图通常用于表示属性内的值的分布(更准确地说是数值属性)。接受一个数字数组,直方图应包含多个条,每个条跨越特定范围,表示属于该范围的数字的数量。
假设我们的数据集中有一个包含存储在x
中的样本数据的属性。我们可以在x
上调用plt.hist()
来绘制属性值的分布,如下所示:
x = np.random.randn(100)
plt.hist(x)
plt.show()
上述代码产生了类似以下的可视化:
图 2.10:使用 Matplotlib 的直方图
注意
你的输出可能与我们这里有些不同,但直方图的一般形状应该是一样的——钟形曲线。
可以在plt.hist()
函数中指定bins
参数(默认值为 10)来自定义应生成的条形的数量。粗略地说,增加箱子的数量会减少每个箱子跨越的范围的宽度,从而提高直方图的粒度。
然而,直方图中也可能使用太多的箱子而导致糟糕的可视化效果。例如,使用相同的变量x
,我们可以这样做:
plt.hist(x, bins=100)
plt.show()
上述代码将产生以下图表:
图 2.11:直方图中使用太多的箱子
这种可视化可能比前面的例子更糟糕,因为它导致我们的直方图变得分散和不连续。解决这个问题的最简单方法是增加输入数据和箱子数量之间的比率,要么增加输入数据,要么使用更少的箱子。
直方图在帮助我们比较多个属性的分布方面也非常有用。例如,通过调整alpha
参数(指定直方图的不透明度),我们可以在一个图表中叠加多个直方图,以突出它们的差异。以下代码和可视化演示了这一点:
y = np.random.randn(100) * 4 + 5
plt.hist(x, color='b', bins=20, alpha=0.2)
plt.hist(y, color='r', bins=20, alpha=0.2)
plt.show()
输出将如下所示:
图 2.12:叠加直方图
在这里,我们可以看到,虽然两个分布的形状大致相似,但一个在另一个的右侧,表明它的值通常大于左侧属性的值。
我们需要注意的一个有用的事实是,当我们简单地调用plt.hist()
函数时,会返回一个包含两个数字数组的元组,表示相应直方图中各个条的位置和高度,如下所示:
>>> plt.hist(x)
(array([ 9., 7., 19., 18., 23., 12., 6., 4., 1., 1.]),
array([-1.86590701, -1.34312205, -0.82033708, -0.29755212,
0.22523285, 0.74801781, 1.27080278, 1.79358774,
2.31637271, 2.83915767, 3.36194264]),
<a list of 10 Patch objects>)
两个数组包括了由 Matplotlib 处理的输入数据的所有直方图相关信息。然后可以使用这些数据来绘制直方图,但在某些情况下,甚至可以将数组存储在新变量中,并使用这些统计数据对数据进行进一步分析。
在下一节中,我们将继续讨论本章中将要讨论的最后一种可视化类型:热力图。
热力图
热力图是使用二维数组生成的,其中具有高值的数字对应于热色,低值的数字对应于冷色。使用 Matplotlib,可以使用plt.imshow()
函数创建热力图。假设我们有以下代码:
my_map = np.random.randn(10, 10)
plt.imshow(my_map)
plt.colorbar()
plt.show()
上述代码将产生以下可视化:
图 2.13:使用 Matplotlib 的热图
请注意,通过这种表示,输入的 2D 数组中的任何分组结构(例如,如果有一组单元格的值明显大于其余部分)将被有效地可视化。
热图的一个重要用途是当我们考虑数据集的相关矩阵时(这是一个包含数据集内任意属性对之间相关性的 2D 数组)。热图将能够帮助我们找出任何高度相关的属性。
这结束了我们在本节中关于可视化库 Matplotlib 的最后一个讨论主题。下一个练习将通过一个实际示例帮助我们巩固所学到的知识。
练习 2.04:概率分布的可视化
当我们讨论抽样时,我们简要提到概率分布是统计学和机器学习中广泛使用的数学对象,用于对现实生活数据进行建模。虽然许多概率分布可能抽象且复杂,但能够有效地可视化它们的特征是理解它们的用途的第一步。
在这个练习中,我们将应用一些可视化技术(直方图和折线图)来比较 NumPy 中的抽样函数与它们真实的概率分布。对于给定的概率分布,概率密度函数(也称为PDF)定义了根据该分布的任何实数的概率。这里的目标是验证,通过足够大的样本量,NumPy 的抽样函数是否给出了给定概率分布的真实 PDF 的真实形状。
执行以下步骤完成此练习:
- 从您的终端,也就是您的 Python 环境中(如果您正在使用),安装 SciPy 包。您可以像往常一样使用 pip 进行安装:
$ pip install scipy
使用 Anaconda 安装 SciPy,请使用以下命令:
$ conda install scipy
SciPy 是 Python 中另一个流行的统计计算工具。它包含了各种概率分布的简单 API,我们将在这里使用。我们将在下一章中重新讨论这个库。
- 在 Jupyter 笔记本的第一个代码单元中,导入 NumPy、SciPy 的
stats
包和 Matplotlib,如下所示:
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
- 在下一个单元格中,使用 NumPy 从均值为
0
,标准差为1
的正态分布中抽取 1,000 个样本:
samples = np.random.normal(0, 1, size=1000)
- 接下来,我们将在我们绘制的样本的最小值和最大值之间创建一个
np.linspace
数组,并最终在数组中的数字上调用真实的 PDF。我们这样做是为了在下一步中将这些点绘制在图表中:
x = np.linspace(samples.min(), samples.max(), 1000)
y = stats.norm.pdf(x)
- 为绘制的样本创建一个直方图,并为通过 PDF 获得的点创建一个折线图。在
plt.hist()
函数中,指定density=True
参数,以便将条的高度归一化为概率值(0 到 1 之间的数字),alpha=0.2
参数使直方图颜色较浅,bins=20
参数使直方图的粒度更大:
plt.hist(samples, alpha=0.2, bins=20, density=True)
plt.plot(x, y)
plt.show()
上述代码将创建(大致)以下可视化:
图 2.14:正态分布的直方图与 PDF
我们可以看到,我们绘制的样本的直方图与正态分布的真实 PDF 非常匹配。这证明了 NumPy 的抽样函数和 SciPy 的 PDF 函数之间的一致性。
注意
要获得更平滑的直方图,可以尝试增加直方图中的条数。
- 接下来,我们将为参数为(2, 5)的 Beta 分布创建相同的可视化。目前,我们不需要太多了解概率分布本身;同样,在这里,我们只想测试一下 NumPy 的抽样函数和 SciPy 的相应概率密度函数。
在下一个代码单元格中,按照之前的步骤进行操作:
samples = np.random.beta(2, 5, size=1000)
x = np.linspace(samples.min(), samples.max(), 1000)
y = stats.beta.pdf(x, 2, 5)
plt.hist(samples, alpha=0.2, bins=20, density=True)
plt.plot(x, y)
plt.show()
这将生成以下图表:
图 2.15:Beta 分布的直方图与概率密度函数
- 使用参数α = 1 为 Gamma 分布创建相同的可视化:
samples = np.random.gamma(1, size=1000)
x = np.linspace(samples.min(), samples.max(), 1000)
y = stats.gamma.pdf(x, 1)
plt.hist(samples, alpha=0.2, bins=20, density=True)
plt.plot(x, y)
plt.show()
然后绘制以下可视化:
图 2.16:Gamma 分布的直方图与概率密度函数
在本练习中,我们学习了如何结合直方图和折线图来验证 NumPy 和 SciPy 实现的多个概率分布。我们还简要介绍了概率分布及其概率密度函数的概念。
注
要访问本特定部分的源代码,请参阅packt.live/3eZrEbW
。
您还可以在packt.live/3gmjLx8
上在线运行此示例。
这个练习作为 Matplotlib 主题的结论。在下一节中,我们将通过 Seaborn 和 pandas 提供的一些简写 API 来快速创建复杂的可视化,结束本章的讨论。
Seaborn 和 Pandas 的可视化简写
首先,让我们讨论 Seaborn 库,它是继 Matplotlib 之后 Python 中第二受欢迎的可视化库。尽管仍由 Matplotlib 支持,Seaborn 提供了简单、表达力强的函数,可以促进复杂的可视化方法。
成功通过 pip 或 Anaconda 安装 Seaborn 后,程序员通常使用sns
别名导入库的约定。现在,假设我们有一个具有两个数值属性的表格数据集,并且我们想要可视化它们各自的分布:
x = np.random.normal(0, 1, 1000)
y = np.random.normal(5, 2, 1000)
df = pd.DataFrame({'Column 1': x, 'Column 2': y})
df.head()
通常,我们可以创建两个直方图,一个用于每个属性。然而,我们也想检查两个属性之间的关系,在这种情况下,我们可以利用 Seaborn 中的jointplot()
函数。让我们看看它的作用:
import seaborn as sns
sns.jointplot(x='Column 1', y='Column 2', data=df)
plt.show()
如您所见,我们可以将整个DataFrame
对象传递给 Seaborn 函数,并在函数参数中指定要绘制的元素。这个过程可能比使用 Matplotlib 传递实际属性更简单。
上述代码将生成以下可视化:
图 2.17:使用 Seaborn 的联合图
这个可视化包括两个属性的散点图和它们各自的直方图附加到适当的坐标轴上。从这里,我们可以观察到我们放入两个直方图的各个属性的分布,以及从散点图中观察它们的联合分布。
再次,因为这是一个相当复杂的可视化,可以为输入数据提供重要见解,手动在 Matplotlib 中创建可能会相当困难。Seaborn 成功的地方在于为这些复杂但有价值的可视化技术构建了一个流水线,并创建了简单的 API 来生成它们。
让我们考虑另一个例子。假设我们有一个与练习 2.03中考虑的相同学生数据集的较大版本,学生数据集,其外观如下:
student_df = pd.DataFrame({
'name': ['Alice', 'Bob', 'Carol', 'Dan', 'Eli', 'Fran', \
'George', 'Howl', 'Ivan', 'Jack', 'Kate'],\
'gender': ['female', 'male', 'female', 'male', \
'male', 'female', 'male', 'male', \
'male', 'male', 'female'],\
'class': ['JR', 'SO', 'SO', 'SO', 'JR', 'SR', \
'FY', 'SO', 'SR', 'JR', 'FY'],\
'gpa': [90, 93, 97, 89, 95, 92, 90, 87, 95, 100, 95],\
'num_classes': [4, 3, 4, 4, 3, 2, 2, 3, 3, 4, 2]})
现在,我们想考虑数据集中学生的平均 GPA,按班级分组。此外,在每个班级内,我们还对女生和男生之间的差异感兴趣。这需要一个分组/堆叠条形图,其中每个组对应一个班级,并分为女生和男生的平均值。
使用 Seaborn,这可以通过一行代码完成:
sns.catplot(x='class', y='gpa', hue='gender', kind='bar', \
data=student_df)
plt.show()
这将生成以下图表(注意图例如何自动包含在图表中):
图 2.18:使用 Seaborn 的分组条形图
除了 Seaborn,pandas 库本身也提供了直接与 Matplotlib 交互的独特 API。这通常是通过DataFrame.plot
API 完成的。例如,仍然使用我们之前使用的student_df
变量,我们可以快速生成gpa
属性数据的直方图,如下所示:
student_df['gpa'].plot.hist()
plt.show()
然后创建以下图表:
图 2.19:使用 pandas 的直方图
假设我们对班级的百分比分布感兴趣(即,每个班级在所有学生中所占的比例)。我们可以从班级计数(通过value_counts()
方法获得)生成一个饼图:
student_df['class'].value_counts().plot.pie()
plt.show()
这将产生以下输出:
图 2.20:来自 pandas 的饼图
通过这些示例,我们可以了解到 Seaborn 和 Matplotlib 如何简化创建复杂可视化的过程,特别是对于DataFrame
对象,只需使用简单的函数调用。这清楚地展示了 Python 中各种统计和科学工具之间的功能集成,使其成为最受欢迎的现代科学计算语言之一,如果不是最受欢迎的话。
这结束了本书第二章要涵盖的内容。现在,让我们通过一个真实数据集进行实际操作。
活动 2.01:分析 Communities and Crime 数据集
在这个活动中,我们将练习一些基本的数据处理和分析技术,使用一个名为Communities and Crime的在线数据集,希望巩固我们的知识和技术。具体来说,我们将处理数据集中的缺失值,遍历属性,并可视化它们的值的分布。
首先,我们需要将这个数据集下载到我们的本地环境中,可以在此页面上访问:packt.live/31C5yrZ
数据集的名称应该是CommViolPredUnnormalizedData.txt
。从与此数据集文本文件相同的目录中,创建一个新的 Jupyter 笔记本。现在,执行以下步骤:
-
首先,导入我们将使用的库:pandas、NumPy 和 Matplotlib。
-
使用 pandas 从文本文件中读取数据集,并通过在
DataFrame
对象上调用head()
方法打印出前五行。 -
循环遍历数据集中的所有列,并逐行打印它们。在循环结束时,还要打印出总列数。
-
注意,数据集的不完整值在不同单元格中表示为
'?'
。在DataFrame
对象上调用replace()
方法,将该字符替换为np.nan
,以忠实地表示 Python 中的缺失值。 -
使用
df.isnull().sum()
打印出数据集中列的列表及其各自的缺失值数量,其中df
是DataFrame
对象的变量名。 -
使用
df.isnull().sum()[column_name]
语法(其中column_name
是我们感兴趣的列的名称),打印出NumStreet
和PolicPerPop
列中缺失值的数量。 -
计算一个包含
state
属性值列表及其各自计数的DataFrame
对象。然后,使用DataFrame.plot.bar()
方法将该信息可视化为条形图。 -
请注意,默认情况下,图表的比例尺上的标签重叠。通过使用
f, ax = plt.subplots(figsize=(15, 10))
命令使图表变大来解决这个问题。这应该放在任何绘图命令的开头。 -
使用与之前使用的相同值计数
DataFrame
对象,调用DataFrame.plot.pie()
方法来创建相应的饼图。调整图形大小以确保图表的标签正确显示。 -
创建一个直方图,代表数据集中地区人口规模的分布(包含在
population
属性中)。调整图形大小以确保图表的标签正确显示。
图 2.21:人口分布的直方图
- 创建一个等效的直方图来可视化数据集中家庭规模的分布(包含在
householdsize
属性中)。
图 2.22:家庭规模分布的直方图
注意
此活动的解决方案可在第 653 页找到。
摘要
本章介绍了 Python 中用于数据科学和统计计算的核心工具,即 NumPy 用于线性代数和计算,pandas 用于表格数据处理,Matplotlib 和 Seaborn 用于可视化。这些工具将在本书的后续章节中广泛使用,并且它们将在您未来的项目中证明有用。在下一章中,我们将深入了解本书中将要使用的一些统计概念的具体内容,并学习如何在 Python 中实现它们。
XBC94
ABB35