Python-统计和微积分研讨会-全-

Python 统计和微积分研讨会(全)

原文:zh.annas-archive.org/md5/6cbaed7d834977b8ea96cc7aa6d8a083

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于本书

你是否想要开始开发人工智能应用程序?您是否需要对关键数学概念进行复习?充满有趣的实践练习,《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 浏览器控制台

图 0.1:Jupyter 浏览器控制台

一旦您运行了 Jupyter 服务器,点击New,选择Python 3。一个新的浏览器标签页将打开一个新的空白笔记本。重命名 Jupyter 文件:

图 0.2:Jupyter 服务器界面

图 0.2:Jupyter 服务器界面

Jupyter 笔记本的主要构建模块是单元格。有两种类型的单元格:In(输入的缩写)和Out(输出的缩写)。您可以在In单元格中编写代码、普通文本和 Markdown,按Shift + Enter(或Shift + Return),那个特定In单元格中编写的代码将被执行。结果将显示在Out单元格中,然后您将进入一个新的In单元格,准备好下一个代码块。一旦您习惯了这个界面,您将慢慢发现它提供的强大和灵活性。

当您开始一个新的单元格时,默认情况下假定您将在其中编写代码。但是,如果您想要编写文本,那么您必须更改类型。您可以使用以下键序列来执行此操作:Esc | M | Enter。这将将所选单元格转换为MarkdownM)单元格类型:

图 0.3:Jupyter Notebook

图 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关键字扩展复合条件,以实现嵌套在多个级别上的条件语句。

当条件不满足时,我们可能希望程序执行不同的一组指令。为了实现这种逻辑,我们可以使用elifelse语句,这些语句应该紧随在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 整除。

为了实现这一点,请按照以下步骤进行:

  1. 创建一个新的 Jupyter 笔记本,并声明一个名为x的变量,其值为任何整数,如下面的代码所示:
x = 130
  1. 在声明之后,编写一个if语句,检查x是否可以被 5 整除。相应的代码块应该打印出一个指示条件是否满足的语句:
if x % 5 == 0:
    print('x is divisible by 5')

在这里,%是 Python 中的取模运算符;var % n表达式返回当我们用数字n除以变量var时的余数。

  1. 在同一个代码单元格中,编写两个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')
  1. 编写最终的else语句,以打印一条消息,说明x既不能被 5 整除,也不能被 6 或 7 整除(在同一个代码单元格中):
else:
    print('x is not divisible by 5, 6, or 7')
  1. 每次为x分配一个不同的值来测试我们的条件逻辑。以下输出是x被赋值为104832的一个示例:
x is divisible by 6
  1. 现在,我们不想打印关于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')
  1. 检查输出文本文件中的消息是否正确。如果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循环来连续打印变量的值,并在每次迭代中递增相同的变量。可以想象,当这个程序执行时,将打印出012,当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)迭代器中的值,依次是012,在for循环的每次迭代中。

在 Python 的for循环中,除了range()之外,还可以使用其他类型的迭代器。以下表格简要总结了一些最常见的用于for循环的迭代器。如果您对此表中包含的数据结构不熟悉,不要担心;我们将在本章后面介绍这些概念:

图 1.1:数据集及其示例列表

图 1.1:数据集及其示例列表

还可以在彼此内部嵌套多个循环。当给定程序的执行位于循环内部时,我们可以使用break关键字退出当前循环并继续执行。

练习 1.02:猜数字游戏

在这个练习中,我们将把我们对循环的知识付诸实践,并编写一个简单的猜数字游戏。程序开始时随机选择一个介于 0 和 100 之间的目标整数。然后,程序将接受用户输入作为猜测这个数字的猜测。作为回应,程序将打印出一条消息,如果猜测大于实际目标,则打印Lower,如果相反,则打印Higher。当用户猜对时,程序应该终止。

执行以下步骤完成这个练习:

  1. 在新的 Jupyter 笔记本的第一个单元格中,导入 Python 中的random模块,并使用其randint函数生成随机数:
import random
true_value = random.randint(0, 100)

每次调用randint()函数时,它都会生成两个传递给它的数字之间的随机整数;在我们的情况下,将生成介于 0 和 100 之间的整数。

虽然它们在本练习的其余部分中并不需要,但如果您对随机模块提供的其他功能感兴趣,可以查看其官方文档docs.python.org/3/library/random.html

注意

程序的其余部分也应该放在当前代码单元格中。

  1. 使用 Python 中的input()函数接受用户的输入,并将返回的值赋给一个变量(在以下代码中为guess)。这个值将被解释为用户对目标的猜测:
guess = input('Enter your guess: ')
  1. 使用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

注意

下面代码片段中的#符号表示代码注释。注释被添加到代码中,以帮助解释特定的逻辑部分。

  1. 使用我们当前的代码,如果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.')
  1. 目前,用户只能在程序终止之前猜一次。为了实现允许用户重复猜测直到找到目标的功能,我们将迄今为止开发的逻辑包装在一个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.')
  1. 尝试通过执行代码单元格重新运行程序,并尝试不同的输入选项,以确保程序可以很好地处理其指令,并处理无效输入的情况。例如,当目标数字被随机选择为 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 字符串由单引号或双引号中的任何给定文本数据表示。例如,在以下代码片段中,ab变量保存相同的信息:

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 列表的内容。

执行以下步骤完成此练习:

  1. 创建一个新的 Jupyter 笔记本,并在一个代码单元格中声明一个名为a的变量,如下所示:
a = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

这个变量表示一个 3 x 3 的 2D 表,列表中的各个子列表表示行。

  1. 在一个新的代码单元格中,通过循环遍历列表a中的元素来迭代行(暂时不要运行单元格):
for row in a:
  1. 在这个for循环的每次迭代中,a中的一个子列表被分配给一个名为row的变量。然后,我们可以通过索引访问 2D 表中的单个单元格。以下for循环将打印出每个子列表中的第一个元素,或者换句话说,表中每行的第一个单元格中的数字(147):
for row in a:
    print(row[0])
  1. 在一个新的代码单元格中,通过嵌套的for循环打印出表a中所有单元格的值,内部循环将遍历a中的子列表:
for row in a:
    for element in row:
        print(element)

这应该打印出从 1 到 9 的数字,每个数字在单独的行中。

  1. 最后,在一个新的单元格中,我们需要以格式良好的消息打印出这个表的对角线元素。为此,我们可以使用一个索引变量——在我们的例子中是i——从0循环到2来访问表的对角线元素:
for i in range(3):
    print(a[i][i])

您的输出应该是 1、5 和 9,每个在单独的行中。

注意

这是因为表/矩阵中对角线元素的行索引和列索引相等。

  1. 在一个新的单元格中,使用 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 字典。例如,以下语句初始化了一个将整数从-11(包括边界)映射到它们的平方的字典:

>>> square_dict = {i: i ** 2 for i in range(-1, 2)}
>>> square_dict
{-1: 1, 0: 0, 1: 1}

正如我们所看到的,这个字典包含了每个x-11之间的x - x ** 2键值对,这是通过在字典初始化中放置for循环来完成的。

要从字典中删除键值对,我们需要使用del关键字。假设我们想删除'Alice'键及其对应的值。我们可以这样做:

>>> del score_dict['Alice']

尝试访问已删除的键将导致 Python 解释器引发错误:

>>> score_dict['Alice']
KeyError: 'Alice'

要牢记的 Python 字典最重要的一点是,只有不可变对象可以作为字典键。到目前为止,我们已经看到字符串和数字作为字典键。列表是可变的,初始化后可以改变,不能用作字典键;而元组可以。

练习 1.04:购物车计算

在这个练习中,我们将使用字典数据结构构建一个购物应用程序的骨架版本。这将使我们能够复习和进一步了解数据结构以及可以应用于它的操作。

执行以下步骤完成此练习:

  1. 在第一个代码单元中创建一个新的 Jupyter 笔记本,并声明一个字典,表示可以购买的任何商品及其相应的价格。在这里,我们将添加三种不同类型的笔记本电脑及其美元价格:
prices = {'MacBook 13': 1300, 'MacBook 15': 2100, \
          'ASUS ROG': 1600}

注意

这里显示的代码片段使用反斜杠(\)将逻辑分割成多行。当代码执行时,Python 将忽略反斜杠,并将下一行的代码视为当前行的直接延续。

  1. 在下一个单元格中,初始化一个表示我们购物车的字典。字典在开始时应该是空的,但它应该将购物车中的商品映射到要购买的副本数量:
cart = {}
  1. 在一个新的单元格中,编写一个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".')
  1. 在第一个条件情况下,接受另一个用户输入,询问应该将哪个商品添加到购物车。使用条件语句来增加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.')
  1. 在下一个单元格中,循环遍历cart字典,并计算用户需要支付的总金额(通过查找购物车中每件商品的数量和价格):
# Calculation of total bill.
running_sum = 0
for item in cart:
    running_sum += cart[item] * prices[item]  # quantity times price
  1. 最后,在一个新的单元格中,通过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}')
  1. 运行程序并尝试使用不同的购物车来确保我们的程序是正确的。例如,如果您要将两台 MacBook 13 和一台华硕 ROG 添加到我的购物车中并停止,相应的输出将如下所示:图 1.2:购物车应用程序的输出

图 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:查找最大值

在任何入门编程课程中,查找数组或列表的最大/最小值是一个常见的练习。在这个练习中,我们将考虑这个问题的一个提升版本,即我们需要编写一个函数,返回列表中最大元素的索引和实际值(如果需要进行平局处理,我们返回最后一个最大元素)。

执行以下步骤完成这个练习:

  1. 创建一个新的 Jupyter 笔记本,并在一个代码单元格中声明我们目标函数的一般结构:
def get_max(my_list):
    ...
    return ...
  1. 创建一个变量来跟踪当前最大元素的索引,称为running_max_index,应初始化为0
def get_max(my_list):
    running_max_index = 0
    ...
    return ...
  1. 使用for循环和enumerate操作循环遍历参数列表中的值及其对应的索引:
def get_max(my_list):
    running_max_index = 0
    # Iterate over index-value pairs.
    for index, item in enumerate(my_list):
         [...]
    return ...
  1. 在每一步迭代中,检查当前元素是否大于或等于与运行索引变量对应的元素。如果是这样,将当前元素的索引分配给运行的最大索引:
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 [...]
  1. 最后,将运行的最大索引及其对应的值作为一个元组返回:
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]
  1. 在一个新的单元格中,调用这个函数来测试不同情况下的各种列表。一个例子如下:
>>> 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的数量。

执行以下步骤以完成此练习:

  1. 在一个新的 Jupyter 笔记本中,定义一个函数,该函数接受一个名为n的整数,并返回我们之前得到的数量:
def solve(n):
    return 2 * solve(n - 1) + 1
  1. 在函数中创建一个条件来处理基本情况,即n = 1(注意,只需一步即可移动单个盘子):
def solve(n):
    if n == 1:
        return 1
    return 2 * solve(n - 1) + 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来指示一个无效状态,即我们的输入列表只包含奇数。返回False0这样的标志实际上是一个常见的做法,我们在本章的后续示例中也会遵循这种做法。

考虑到这一点,让我们开始本节的练习。

练习 1.07:N-Queens 问题

数学和计算机科学中的另一个经典算法设计问题是 N 皇后问题,它要求我们在* n * x * n 棋盘上放置 n *个皇后棋子,以便没有皇后棋子可以攻击另一个。如果两个皇后棋子共享相同的行、列或对角线,那么一个皇后可以攻击另一个棋子,因此问题实质上是找到皇后棋子的位置组合,使得任意两个皇后在不同的行、列和对角线上。

对于这个练习,我们将设计一个回溯算法,为任何正整数n搜索这个问题的有效解决方案。算法如下:

  1. 考虑到问题的要求,我们认为为了放置n个棋子,棋盘的每一行都需要包含恰好一个棋子。

  2. 对于每一行,我们迭代地遍历该行的所有单元格,并检查是否可以在给定单元格中放置一个新的皇后棋子:

a. 如果存在这样的单元格,我们在该单元格中放置一个棋子,然后转到下一行。

b. 如果新的皇后棋子无法放置在当前行的任何单元格中,我们知道已经达到了一个无效状态,因此返回False

  1. 我们重复这个过程,直到找到一个有效的解决方案。

以下图描述了这个算法在n=4时的工作方式:

图 1.3:N-Queens 问题的递归

图 1.3:N-Queens 问题的递归

现在,让我们实际实现算法:

  1. 创建一个新的 Jupyter 笔记本。在第一个单元格中,声明一个名为N的变量,表示棋盘的大小,以及我们需要在棋盘上放置的皇后数量:
N = 8
  1. 国际象棋棋盘将被表示为一个 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语句打印出同一行中的单元格。

  1. 在下一个单元格中,编写一个函数,该函数接受一个棋盘、一个行号和一个列号。该函数应该检查是否可以在给定的行和列号位置的棋盘上放置一个新的皇后棋子。

请注意,由于我们正在逐行放置棋子,每次检查新棋子是否可以放在给定位置时,我们只需要检查位置上方的行:

# 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
  1. 在同一个代码单元中,实现一个函数,该函数接受一个棋盘和一个行号。该函数应该遍历给定行中的所有单元格,并检查是否可以在特定单元格放置一个新的皇后棋子(使用前面步骤中编写的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
  1. 在同一个代码单元中,编写一个最终求解器函数,该函数包装了两个函数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)
  1. 在另一个代码单元中,运行前面步骤中的总体函数以生成并打印出解决方案:
>>> 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 类,使过程更加模块化。这样的类需要从unittestTestCase类继承,并且单独的测试需要在不同的函数中实现,如下所示:

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脚本。

执行以下步骤来完成这个练习:

  1. 我们程序的设置(在以下步骤中实现)如下。我们有一个类,实现了一个计数器对象,可以被多个线程并行操作。这个计数器对象的实例的值(存储在其初始化为0value属性中)在每次调用其update()方法时递增。计数器还有一个目标,即其值应该递增到。当调用其run()方法时,将会生成多个线程。每个线程将调用update()方法,因此将其value属性递增到与原始目标相等的次数。理论上,计数器的最终值应该与目标相同,但由于竞争条件,我们将看到这并不是这样。我们的目标是应用pdb来跟踪程序内部变量的变化,以分析这种竞争条件。

  2. 创建一个新的.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类。请注意,有一行代码设置了系统的切换间隔;我们稍后会讨论这个。

  1. 希望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)。你可能会得到不同的输出。

  1. 多次重新运行这段代码,看到结果可能会有所不同。

  2. 现在我们知道程序中有一个 bug,我们将尝试调试它。在update()方法的两条指令之间放置一个breakpoint()语句,重新实现我们的Counter类,如下面的代码所示,并重新运行代码:

class Counter:
    ...
    def update(self):
        current_value = self.value
        breakpoint()
        self.value = current_value + 1
    ...
  1. 在我们的 Python 脚本的主范围内,注释掉对单元测试的调用。相反,声明一个新的counter对象,并使用终端运行脚本:
sample_counter = Counter(10)
sample_counter.run()

在这里,你会看到终端中出现一个pdb提示(你可能需要先按Enter让调试器继续):

图 1.4:pdb 界面

图 1.4:pdb 界面

  1. 输入ll并按Enter键,查看我们在程序中暂停的位置:
(Pdb) ll
  9         def update(self):
 10             current_value = self.value
 11             breakpoint()
 12  ->         self.value = current_value + 1

这里,输出表明我们当前在update()方法内增加计数器值的两条指令之间暂停。

  1. 再次按Enter返回到pdb提示符,并运行p self.value命令:
(Pdb) p self.value
0

我们可以看到计数器的当前值是0

  1. 返回到提示符并输入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. 我们可以看到值已经增加了 1。重复这个在np self.value之间交替的过程,观察在程序进行过程中self.value中存储的值没有更新。换句话说,值通常保持在 1。这就是我们在计数器的大值中看到的 bug 表现方式,就像我们在单元测试中看到的那样。

  2. 使用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 所需的所有步骤。如果您还没有使用版本控制的经验,这个练习对您将是有益的。

执行以下步骤完成此练习:

  1. 首先,如果您还没有,请注册 GitHub 帐户,方法是访问www.github.com/并注册。这将允许您在他们的云存储上托管您想要进行版本控制的文件。

  2. 前往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>]

否则,您的系统可能需要重新启动才能完全生效。

  1. 现在,让我们开始将版本控制应用于一个示例项目的过程。首先,创建一个虚拟文件夹,并生成一个 Jupyter 笔记本和一个名为input.txt的文本文件,其中包含以下内容:
1,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
  1. 在下一个代码单元格中,使用with语句读取input.txt文件,并使用readlines()函数和列表索引提取文件的最后两行:
with open('input.txt', 'r') as f:
    lines = f.readlines()
last_line1, last_line2 = lines[-2], lines[-1]

请注意,在open()函数中,第二个参数'r'指定我们正在读取文件,而不是写入文件。

  1. 在一个新的代码单元格中,使用str.split()函数和','参数将这两个文本输入字符串转换为数字列表,然后使用map()int()函数逐个元素地应用转换:
list1 = list(map(int, last_line1[: -1].split(',')))
list2 = list(map(int, last_line2[: -1].split(',')))
  1. 在一个新的代码单元格中,对list1list2调用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,1
2,2,2
  1. 到目前为止,我们的示例项目的当前设置是:我们有一个文件夹中的文本文件和 Python 脚本;当运行时,脚本可以更改文本文件的内容。这种设置在现实生活中是相当常见的:您可以有一个包含一些信息的数据文件,您希望跟踪,并且可以读取该数据并以某种方式更新它的 Python 程序(也许是通过预先指定的计算或添加外部收集的新数据)。

现在,让我们在这个示例项目中实现版本控制。

  1. 转到您的在线 GitHub 帐户,单击窗口右上角的加号图标(+),然后选择New repository选项,如下所示:图 1.5:创建一个新的存储库

图 1.5:创建一个新的存储库

在表单中输入一个新存储库的示例名称,并完成创建过程。将这个新存储库的 URL 复制到剪贴板上,因为我们以后会用到它。

正如名称所示,这将创建一个新的在线存储库,用于托管我们想要进行版本控制的代码。

  1. 在您的本地计算机上,打开终端并导航到文件夹。运行以下命令以初始化本地 Git 存储库,这将与我们的文件夹关联:
$ git init
  1. 仍然在终端中,运行以下命令将我们项目中的所有内容添加到 Git 并提交它们:
git add .
git commit -m [any message with double quotes]

您可以用文件的名称替换git add .中的.。当您只想注册一个或两个文件时,这个选项是有帮助的,而不是您在文件夹中的每个文件。

  1. 现在,我们需要链接我们的本地存储库和我们创建的在线存储库。为此,请运行以下命令:
git remote add origin [URL to GitHub repository]

请注意,“origin”只是 URL 的一个传统昵称。

  1. 最后,通过运行以下命令将本地注册的文件上传到在线存储库:
git push origin master
  1. 转到在线存储库的网站,验证我们创建的本地文件是否确实已上传到 GitHub。

  2. 在您的本地计算机上,运行 Jupyter 笔记本中包含的脚本并更改文本文件。

  3. 现在,我们想要将这个更改提交到 GitHub 存储库。在您的终端上,再次运行以下命令:

git add .
git commit
git push origin master
  1. 转到 GitHub 网站验证我们第二次所做的更改是否也已在 GitHub 上进行了更改。

通过这个练习,我们已经走过了一个示例版本控制流水线,并看到了 Git 和 GitHub 在这方面的一些用法示例。我们还复习了使用with语句在 Python 中读写文件的过程。

注意

要访问本节的源代码,请参阅packt.live/2VDS0IS

您还可以在packt.live/3ijJ1pM上在线运行此示例。

这也结束了本书第一章的最后一个主题。在下一节中,我们提供了一个活动,这个活动将作为一个实践项目,概括了本章中我们讨论的重要主题和内容。

活动 1.01:构建数独求解器

让我们通过一个更复杂的问题来测试我们迄今为止学到的知识:编写一个可以解决数独谜题的程序。该程序应能够读取 CSV 文本文件作为输入(其中包含初始谜题),并输出该谜题的完整解决方案。

这个活动作为一个热身,包括科学计算和数据科学项目中常见的多个程序,例如从外部文件中读取数据并通过算法操纵这些信息。

  1. 使用本章的 GitHub 存储库中的sudoku_input_2.txt文件作为程序的输入文件,将其复制到下一步中将要创建的 Jupyter 笔记本的相同位置(或者创建一个格式相同的自己的输入文件,其中空单元格用零表示)。

  2. 在新的 Jupyter 笔记本的第一个代码单元中,创建一个Solver类,该类接受输入文件的路径。它应将从输入文件中读取的信息存储在一个 9x9 的 2D 列表中(包含九个子列表,每个子列表包含谜题中各行的九个值)。

  3. 添加一个辅助方法,以以下方式打印出谜题的格式:

-----------------------
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 | 
-----------------------
  1. 在类中创建一个get_presence(cells)方法,该方法接受任何 9x9 的 2D 列表,表示未解决/半解决的谜题,并返回一个关于给定数字(1 到 9 之间)是否出现在给定行、列或象限中的指示器。

例如,在前面的示例中,该方法的返回值应能够告诉您第一行中是否存在 2、3 和 6,而第二列中是否没有数字。

  1. 在类中创建一个get_possible_values(cells)方法,该方法还接受表示不完整解决方案的任何 2D 列表,并返回一个字典,其键是当前空单元格的位置,相应的值是这些单元格可以取的可能值的列表/集合。

这些可能值的列表应通过考虑一个数字是否出现在给定空单元格的同一行、列或象限中来生成。

  1. 在类中创建一个simple_update(cells)方法,该方法接受任何 2D 不完整解决方案列表,并在该列表上调用get_possible_values()方法。根据返回的值,如果有一个只包含一个可能解的空单元格,就用该值更新该单元格。

如果发生了这样的更新,该方法应再次调用自身以继续更新单元格。这是因为更新后,剩余空单元格的可能值列表可能会发生变化。该方法最终应返回更新后的 2D 列表。

  1. 在类中创建一个recur_solve(cells)方法,该方法接受任何 2D 不完整解决方案列表并执行回溯。首先,该方法应调用simple_update(),并返回谜题是否完全解决(即 2D 列表中是否有空单元格)。

接下来,考虑剩余空单元格的可能值。如果还有空单元格,并且没有可能的值,返回一个负结果,表示我们已经达到了一个无效的解决方案。

另一方面,如果所有单元格至少有两个可能的值,找到可能值最少的单元格。依次循环这些可能的值,将它们填入空单元格,并在其中调用recur_solve()以使用算法的递归性质更新单元格。在每次迭代中,返回最终解是否有效。如果通过任何可能的值都找不到有效的最终解决方案,则返回一个负结果。

  1. 将前面的方法封装在一个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 | 
-----------------------

扩展

  1. 前往Project Euler网站,projecteuler.net/problem=96,测试你的算法是否能解决包含的谜题。

  2. 编写一个程序,生成数独谜题,并包括单元测试,检查我们的求解器生成的解是否正确。

注意

此活动的解决方案可在第 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 替代方案相比所实现的加速。为此,请执行以下步骤:

  1. 在新的 Jupyter 笔记本的第一个单元格中,导入 NumPy 包和timeit库中的Timer类。后者将用于实现我们的计时功能:
import numpy as np
from timeit import Timer
  1. 在一个新的单元格中,使用range()函数初始化一个包含从 0(包括)到 1,000,000(不包括)的数字的 Python 列表,以及使用np.array()函数的 NumPy 数组对应项:
my_list = list(range(10 ** 6))
my_array = np.array(my_list)
  1. 现在,我们将在以下步骤中对这个列表和数组应用数学运算。在一个新的单元格中,编写一个名为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
  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循环加法函数的速度应该比向量化加法函数的速度低得多。

  1. 在下一个代码单元格中,实现相同的速度比较,我们将数字乘以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
  1. 在下一个代码单元格中,实现相同的比较,计算数字的平方根。对于 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 对象

图 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对象中的行和列,也可以使用布尔值(TrueFalse)数组来实现相同的效果。

例如,我们可以通过编写以下内容访问我们当前表中的第二行和第二和第四列中的项目:

>>> 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.2:用于操作 pandas 数据的方法

下面的练习将演示前面方法的效果,以便更好地理解。

练习 2.02:数据表操作

在这个实践练习中,我们将学习前面部分包含的函数和方法。我们的目标是看到这些方法的效果,并执行常见的数据操作技术,比如重命名列、填充缺失值、排序数值,或者将数据表写入文件。

执行以下步骤完成这个练习:

  1. 从这个研讨会的 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
  1. 在新目录中创建一个新的 Jupyter 笔记本。确保这个笔记本和 CSV 文件在同一个位置。

  2. 在这个笔记本的第一个单元格中,导入 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')
  1. 当我们打印这个新创建的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不是一个数字)值;NaNDataFrame对象在初始化时将填充空单元格的默认值。由于我们的原始数据集被设计为包含两个空单元格,这些单元格被适当地填充为NaN,正如我们在这里所看到的。

此外,NaN值在 Python 中被注册为浮点数,这就是为什么包含它们的两列的数据类型相应地转换为浮点数(值中的小数点表示)。

  1. 在下一个单元格中,使用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
  1. 在下一个单元格中,使用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. 在下一个代码单元格中,通过将[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
  1. 在下一个单元格中,创建一个全零的 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
  1. 在下一个代码单元格中,使用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
  1. 在下一个单元格中,按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
  1. 最后,在另一个代码单元中,将我们的表转换为整数数据类型(与之前的方式相同),并使用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

这一列被认为是一个分类属性,因为它的值属于一组特定的类别(在这种情况下,类别是oddeven)。现在,通过在该列上调用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操作。

执行以下步骤来完成这个练习:

  1. 创建一个新的 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保存学生目前正在上多少门课的信息。

  1. 在一个新的代码单元格中,创建一个名为'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'
  1. 这个新创建的属性包含了旧的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
  1. 在一个新的代码单元格中,编写一个表达式,对分类属性class应用独热编码:
pd.get_dummies(student_df['class'])
  1. 在同一个代码单元格中,将这个表达式包含在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
  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>
  1. 现在,我们想计算前面分组中每个组的平均 GPA。为此,我们可以使用以下简单的语法:
gender_group['gpa'].mean()

输出将如下所示:

female_flag
False    92.333333
True     93.000000
Name: gpa, dtype: float64

我们对gender_group变量的命令非常直观:我们想要计算特定属性的平均值,因此我们使用方括号['gpa']访问该属性,然后对其调用mean()方法。

  1. 类似地,我们可以使用以下代码计算男性学生和女性学生的总课程数:
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 的散点图

图 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:带有大小和颜色自定义的散点图

图 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 的折线图

图 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:带有样式自定义的折线图

图 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 1Type 2Type 3的每个组中,如前面的例子中,我们有两个子组,Type AType 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 1Type 2Type 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 / 2locations + width / 2指定了Type A条和Type B条的确切位置。重要的是,我们在plt.bar()函数的width参数中重用这个width变量,以便每组的两个条紧挨在一起。

接下来,我们想要自定义每组条的标签。另外,请注意,我们还将plt.bar()的返回值分配给两个变量bars_abars_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 的直方图

图 2.10:使用 Matplotlib 的直方图

注意

你的输出可能与我们这里有些不同,但直方图的一般形状应该是一样的——钟形曲线。

可以在plt.hist()函数中指定bins参数(默认值为 10)来自定义应生成的条形的数量。粗略地说,增加箱子的数量会减少每个箱子跨越的范围的宽度,从而提高直方图的粒度。

然而,直方图中也可能使用太多的箱子而导致糟糕的可视化效果。例如,使用相同的变量x,我们可以这样做:

plt.hist(x, bins=100)
plt.show()

上述代码将产生以下图表:

图 2.11:直方图中使用太多的箱子

图 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:叠加直方图

图 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 的热图

图 2.13:使用 Matplotlib 的热图

请注意,通过这种表示,输入的 2D 数组中的任何分组结构(例如,如果有一组单元格的值明显大于其余部分)将被有效地可视化。

热图的一个重要用途是当我们考虑数据集的相关矩阵时(这是一个包含数据集内任意属性对之间相关性的 2D 数组)。热图将能够帮助我们找出任何高度相关的属性。

这结束了我们在本节中关于可视化库 Matplotlib 的最后一个讨论主题。下一个练习将通过一个实际示例帮助我们巩固所学到的知识。

练习 2.04:概率分布的可视化

当我们讨论抽样时,我们简要提到概率分布是统计学和机器学习中广泛使用的数学对象,用于对现实生活数据进行建模。虽然许多概率分布可能抽象且复杂,但能够有效地可视化它们的特征是理解它们的用途的第一步。

在这个练习中,我们将应用一些可视化技术(直方图和折线图)来比较 NumPy 中的抽样函数与它们真实的概率分布。对于给定的概率分布,概率密度函数(也称为PDF)定义了根据该分布的任何实数的概率。这里的目标是验证,通过足够大的样本量,NumPy 的抽样函数是否给出了给定概率分布的真实 PDF 的真实形状。

执行以下步骤完成此练习:

  1. 从您的终端,也就是您的 Python 环境中(如果您正在使用),安装 SciPy 包。您可以像往常一样使用 pip 进行安装:
$ pip install scipy

使用 Anaconda 安装 SciPy,请使用以下命令:

$ conda install scipy

SciPy 是 Python 中另一个流行的统计计算工具。它包含了各种概率分布的简单 API,我们将在这里使用。我们将在下一章中重新讨论这个库。

  1. 在 Jupyter 笔记本的第一个代码单元中,导入 NumPy、SciPy 的stats包和 Matplotlib,如下所示:
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
  1. 在下一个单元格中,使用 NumPy 从均值为0,标准差为1的正态分布中抽取 1,000 个样本:
samples = np.random.normal(0, 1, size=1000)
  1. 接下来,我们将在我们绘制的样本的最小值和最大值之间创建一个np.linspace数组,并最终在数组中的数字上调用真实的 PDF。我们这样做是为了在下一步中将这些点绘制在图表中:
x = np.linspace(samples.min(), samples.max(), 1000)
y = stats.norm.pdf(x)
  1. 为绘制的样本创建一个直方图,并为通过 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

图 2.14:正态分布的直方图与 PDF

我们可以看到,我们绘制的样本的直方图与正态分布的真实 PDF 非常匹配。这证明了 NumPy 的抽样函数和 SciPy 的 PDF 函数之间的一致性。

注意

要获得更平滑的直方图,可以尝试增加直方图中的条数。

  1. 接下来,我们将为参数为(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 分布的直方图与概率密度函数

图 2.15:Beta 分布的直方图与概率密度函数

  1. 使用参数α = 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 分布的直方图与概率密度函数

图 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 的联合图

图 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 的分组条形图

图 2.18:使用 Seaborn 的分组条形图

除了 Seaborn,pandas 库本身也提供了直接与 Matplotlib 交互的独特 API。这通常是通过DataFrame.plotAPI 完成的。例如,仍然使用我们之前使用的student_df变量,我们可以快速生成gpa属性数据的直方图,如下所示:

student_df['gpa'].plot.hist()
plt.show()

然后创建以下图表:

图 2.19:使用 pandas 的直方图

图 2.19:使用 pandas 的直方图

假设我们对班级的百分比分布感兴趣(即,每个班级在所有学生中所占的比例)。我们可以从班级计数(通过value_counts()方法获得)生成一个饼图:

student_df['class'].value_counts().plot.pie()
plt.show()

这将产生以下输出:

图 2.20:来自 pandas 的饼图

图 2.20:来自 pandas 的饼图

通过这些示例,我们可以了解到 Seaborn 和 Matplotlib 如何简化创建复杂可视化的过程,特别是对于DataFrame对象,只需使用简单的函数调用。这清楚地展示了 Python 中各种统计和科学工具之间的功能集成,使其成为最受欢迎的现代科学计算语言之一,如果不是最受欢迎的话。

这结束了本书第二章要涵盖的内容。现在,让我们通过一个真实数据集进行实际操作。

活动 2.01:分析 Communities and Crime 数据集

在这个活动中,我们将练习一些基本的数据处理和分析技术,使用一个名为Communities and Crime的在线数据集,希望巩固我们的知识和技术。具体来说,我们将处理数据集中的缺失值,遍历属性,并可视化它们的值的分布。

首先,我们需要将这个数据集下载到我们的本地环境中,可以在此页面上访问:packt.live/31C5yrZ

数据集的名称应该是CommViolPredUnnormalizedData.txt。从与此数据集文本文件相同的目录中,创建一个新的 Jupyter 笔记本。现在,执行以下步骤:

  1. 首先,导入我们将使用的库:pandas、NumPy 和 Matplotlib。

  2. 使用 pandas 从文本文件中读取数据集,并通过在DataFrame对象上调用head()方法打印出前五行。

  3. 循环遍历数据集中的所有列,并逐行打印它们。在循环结束时,还要打印出总列数。

  4. 注意,数据集的不完整值在不同单元格中表示为'?'。在DataFrame对象上调用replace()方法,将该字符替换为np.nan,以忠实地表示 Python 中的缺失值。

  5. 使用df.isnull().sum()打印出数据集中列的列表及其各自的缺失值数量,其中dfDataFrame对象的变量名。

  6. 使用df.isnull().sum()[column_name]语法(其中column_name是我们感兴趣的列的名称),打印出NumStreetPolicPerPop列中缺失值的数量。

  7. 计算一个包含state属性值列表及其各自计数的DataFrame对象。然后,使用DataFrame.plot.bar()方法将该信息可视化为条形图。

  8. 请注意,默认情况下,图表的比例尺上的标签重叠。通过使用f, ax = plt.subplots(figsize=(15, 10))命令使图表变大来解决这个问题。这应该放在任何绘图命令的开头。

  9. 使用与之前使用的相同值计数DataFrame对象,调用DataFrame.plot.pie()方法来创建相应的饼图。调整图形大小以确保图表的标签正确显示。

  10. 创建一个直方图,代表数据集中地区人口规模的分布(包含在population属性中)。调整图形大小以确保图表的标签正确显示。图 2.21:人口分布的直方图

图 2.21:人口分布的直方图

  1. 创建一个等效的直方图来可视化数据集中家庭规模的分布(包含在householdsize属性中)。图 2.22:家庭规模分布的直方图

图 2.22:家庭规模分布的直方图

注意

此活动的解决方案可在第 653 页找到。

摘要

本章介绍了 Python 中用于数据科学和统计计算的核心工具,即 NumPy 用于线性代数和计算,pandas 用于表格数据处理,Matplotlib 和 Seaborn 用于可视化。这些工具将在本书的后续章节中广泛使用,并且它们将在您未来的项目中证明有用。在下一章中,我们将深入了解本书中将要使用的一些统计概念的具体内容,并学习如何在 Python 中实现它们。

XBC94

ABB35

第三章:Python 的统计工具箱

概述

在上一章中,我们了解了 Python 中三个主要的库,这些库帮助我们在统计学/机器学习项目中执行各种任务。而本章则开始了统计学及其相关概念的正式话题。虽然其中包含了一些理论讨论点,但我们也将使用直观的例子和实际编码活动来帮助理解。本章学到的内容将为我们在本工作坊的后续统计学相关章节做好准备。

在本章结束时,您将了解统计学和统计方法的基本概念。您还将能够使用 Python 工具和库执行各种与统计学相关的任务,并且将对 Python 中一些高级统计库进行概述,例如 statsmodels 和 PyMC3。

介绍

到目前为止,我们已经学会了如何使用 Python 语言,特别是它的三个核心库——NumPy、pandas 和 Matplotlib,用于统计学和数据科学。然而,为了充分利用这些工具,我们需要对统计学本身有扎实的理论理解。通过了解统计检验和技术背后的思想,我们将能够更有效地利用 Python 提供的工具。

在统计学和机器学习中,Python 库提供了很好的选择——从数据清洗/处理到建模和推断。然而,仍然需要对统计学有基本的理解,这样我们才能根据手头的数据做出关于应该在我们的过程中使用什么样的技术的初步决定。

因此,在本章中,我们将学习统计学的核心概念,例如推断、抽样、变量等。我们还将介绍一系列可以帮助促进更高级统计技术和需求的 Python 工具。所有这些都将通过实际讨论和示例进行演示。

统计学概述

在本节中,我们将简要讨论统计学这一总体领域的目标,并谈论一些其基本思想。这次对话将为本章和本书的后续主题设定背景。

一般来说,统计学是关于处理数据的,无论是处理、分析还是从我们手头的数据中得出结论。在给定数据集的情境下,统计学有两个主要目标:描述数据和从中得出结论。这些目标与统计学的两个主要类别——描述性统计和推断性统计——分别相吻合。

在描述性统计中,会有关于数据集的一般特征的问题:平均数是多少?最大值和最小值之间的差异是多少?哪个值出现最多?等等。这些问题的答案帮助我们了解所讨论的数据集构成了什么,以及数据集的主题是什么。我们在上一章中看到了这方面的简要示例。

在推断性统计中,目标是更进一步:在从给定数据集中提取适当的见解之后,我们希望利用这些信息并推断未知数据。其中一个例子是根据观察到的数据对未来进行预测。这通常是通过各种统计和机器学习模型来实现的,每种模型只适用于某些类型的数据。这就是为什么了解统计学中有哪些类型的数据是非常重要的,这将在下一节中描述。

总的来说,统计学可以被认为是研究数据的领域,这就是为什么它是数据科学和机器学习的基础。使用统计学,我们可以通过我们有时有限的数据集来了解世界的状态,并从中做出适当和可操作的决策,这些决策是基于我们获得的数据驱动知识。这就是为什么统计学在各个研究领域被广泛使用,从科学到社会科学,有时甚至是人文科学,当研究中涉及到分析元素时。

说到这里,让我们开始本章的第一个技术主题:区分数据类型。

统计学中的数据类型

在统计学中,数据主要分为两种类型:分类数据和数值数据。根据数据集中属性或变量所属的类型,其数据处理、建模、分析和可视化技术可能会有所不同。在本节中,我们将解释这两种主要数据类型的细节,并讨论每种类型的相关要点,这些要点总结在下表中:

图 3.1:数据类型比较

图 3.1:数据类型比较

在本节的其余部分,我们将更详细地讨论前述比较中的每一个,从下一小节开始讨论分类数据。

分类数据

当属性或变量是分类的时,它可以取的可能值属于一个预定的固定值集。例如,在与天气相关的数据集中,您可能有一个属性来描述每天的整体天气,这种情况下该属性可能属于一个离散值列表,如"晴天""有风""多云""雨"等。这个属性列中的单元格必须取这些可能的值之一;一个单元格不能包含,例如,一个数字或一个不相关的字符串,比如"苹果"。这种数据的另一个术语是名义数据

由于数据的性质,在大多数情况下,分类属性的可能值之间没有顺序关系。例如,我们之前描述的天气相关数据没有可以应用的比较操作:"晴天"既不大于也不小于"有风",依此类推。这与数值数据形成对比,尽管我们还没有讨论它,但它表达了明显的顺序性。

在讨论数据类型的差异时,让我们现在通过一些要点来了解处理分类数据时需要牢记的一些要点。

如果要使用概率分布对一个未知的分类属性进行建模,就需要一个分类分布。这种分布描述了变量是预定义的K个可能类别之一的概率。幸运的是,当我们从各自的库中调用它们时,大多数建模将在各种统计/机器学习模型的后端完成,所以我们现在不必担心建模的问题。

在数据处理方面,通常使用编码方案来转换属性中的分类值为数值,机器可解释的值。因此,在分类数据中,常见的字符串值不能被输入到只接受数值数据的模型中。

例如,有些人倾向于使用简单的编码,将每个可能的值分配一个正整数,并用其相应的数值替换它们。考虑以下样本数据集(存储在名为weather_df的变量中):

weather_df

输出将如下所示:

     temp    weather
0    55      windy
1    34      cloudy
2    80      sunny
3    75      rain
4    53      sunny

现在,您可以在weather属性上调用map()方法,并传入字典{'有风': 0, '多云': 1, '晴天': 2, '雨': 3}map()方法简单地将字典定义的映射应用于属性)来对分类属性进行编码,如下所示:

weather_df['weather_encoded'] = weather_df['weather'].map(\
                                {'windy': 0, 'cloudy': 1, \
                                 'sunny': 2, 'rain': 3})

这个 DataFrame 对象现在将包含以下数据:

weather_df

输出如下:

     temp    weather    weather_encoded
0    55      windy      0
1    34      cloudy     1
2    80      sunny      2
3    75      rain       3
4    53      sunny       2

我们看到分类列weather已经成功通过一对一映射转换为了weather_encoded中的数值数据。然而,这种技术可能存在潜在的危险:新属性在数据上隐含地放置了一个顺序。由于0 < 1 < 2 < 3,我们无意中对原始分类数据施加了相同的排序;如果我们使用的模型特别将其解释为真正的数值数据,这是特别危险的。

这就是为什么当我们将分类属性转换为数值形式时必须小心。实际上,我们在上一章中已经讨论了一种能够在不施加数值关系的情况下转换分类数据的特定技术:独热编码。在这种技术中,我们为分类属性中的每个唯一值创建一个新属性。然后,对于数据集中的每一行,如果该行具有原始分类属性中的相应值,则在新创建的属性中放置1,在其他新属性中放置0

以下代码片段重申了我们如何可以使用 pandas 实现独热编码,以及它对我们当前的样本天气数据集会产生什么影响:

pd.get_dummies(weather_df['weather'])

这将产生以下输出:

     cloudy    rain    sunny    windy
0    0         0       0        1
1    1         0       0        0
2    0         0       1        0
3    0         1       0        0
4    0         0       1        0

在本章后面我们将讨论的各种描述性统计中,众数——出现最多的值——通常是唯一可以用于分类数据的统计量。因此,当我们的数据集中的分类属性中有缺失值,并且我们希望用一个中心趋势统计量填充它们时,这是我们将在本章后面定义的一个概念,应该考虑的唯一统计量是众数。

在进行预测方面,如果一个分类属性是我们机器学习流水线的目标(也就是说,如果我们想要预测一个分类属性),则需要使用分类模型。与回归模型相反,回归模型对数值连续数据进行预测,分类模型或简称分类器,要记住其目标属性可能取值的可能值,并且只在这些值中进行预测。因此,在决定应该对数据集进行训练以预测分类数据的机器学习模型时,请确保只使用分类器。

分类数据和数值数据之间的最后一个重大区别在于可视化技术。在上一章中讨论了许多适用于分类数据的可视化技术,其中最常见的两种是条形图(包括堆叠和分组条形图)和饼图。

这些类型的可视化关注每个唯一值在整个数据集中所占的比例。

例如,对于前面的天气数据集,我们可以使用以下代码创建一个饼图:

weather_df['weather'].value_counts().plot.pie(autopct='%1.1f%%')
plt.ylabel('')
plt.show()

这将创建以下可视化:

图 3.2:天气数据的饼图

图 3.2:天气数据的饼图

我们可以看到在整个数据集中,值为'sunny'的出现了 40%的时间,而其他每个值都出现了 20%的时间。

到目前为止,我们已经涵盖了分类属性和数值属性之间最大的理论差异,我们将在下一节中讨论。然而,在继续之前,还有一个应该提到的分类数据类型的子类型:二进制数据。

二进制属性,其值只能是TrueFalse,是一个分类属性,其可能值集合包含了上述两个布尔值。由于布尔值可以被机器学习和数学模型轻松解释,通常不需要将二进制属性转换为其他形式。

实际上,原本不是布尔形式的二进制属性应该被转换为TrueFalse值。在上一章的示例学生数据集中,我们遇到了这样的例子:

student_df

输出如下:

     name    sex       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

在这里,列'sex'是一个分类属性,其值可以是'female''male'。因此,为了使这些数据更适合机器处理(同时确保不会丢失或添加任何信息),我们可以对属性进行二值化,我们已经通过以下代码完成了这一步骤:

student_df['female_flag'] = student_df['sex'] == 'female'
student_df = student_df.drop('sex', axis=1)
student_df

输出如下:

     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

由于新创建的列'female_flag'包含了来自列'sex'的所有信息,而且只有这些信息,我们可以简单地从数据集中删除后者。

除此之外,二进制属性可以以任何其他方式(处理、预测和可视化)处理为分类数据。

让我们现在将我们迄今讨论的内容应用到以下练习中。

练习 3.01:可视化天气百分比

在这个练习中,我们得到了一个样本数据集,其中包括特定城市在五天内的天气情况。这个数据集可以从packt.live/2Ar29RG下载。我们的目标是使用迄今为止讨论的分类数据可视化技术,来可视化这个数据集中的分类信息,以检查不同类型天气的百分比:

  1. 在一个新的 Jupyter 笔记本中,导入 pandas、Matplotlib 和 seaborn,并使用 pandas 读取上述数据集:
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
weather_df = pd.read_csv('weather_data.csv')
weather_df.head()

当打印出此数据集的前五行时,您应该看到以下输出:

图 3.3:天气数据集

图 3.3:天气数据集

正如您所看到的,此数据集的每一行告诉我们在给定城市的给定日期的天气情况。例如,在0号那天,St Louis是晴天,而New York多云

  1. 在笔记本中的下一个代码单元中,计算数据集中所有天气类型的计数(发生次数),并使用plot.bar()方法可视化该信息:
weather_df['weather'].value_counts().plot.bar()
plt.show()

此代码将产生以下输出:

图 3.4:天气类型的计数

图 3.4:天气类型的计数

  1. 使用plot.pie(autopct='%1.1f%%')方法将与上一步相同的信息可视化为饼图:
weather_df['weather'].value_counts().plot.pie(autopct='%1.1f%%')
plt.ylabel('')
plt.show()

此代码将产生以下输出:

图 3.5:天气类型的计数

图 3.5:天气类型的计数

  1. 现在,我们想要可视化这些天气类型的计数,以及每种天气类型在每个城市中所占百分比的信息。首先,可以使用groupby()方法计算这些信息,如下所示:
weather_df.groupby(['weather', 'city'])['weather'].count()\
                                        .unstack('city')

输出如下:

city       New York    San Francisco    St Louis
weather            
cloudy     3.0         NaN              3.0
rain       1.0         NaN              1.0
sunny      1.0         4.0              1.0
windy      NaN         1.0              NaN

我们看到这个对象包含了我们想要的信息。例如,看看表中cloudy行,我们可以看到cloudy天气类型在纽约出现了三次,在圣路易斯也出现了三次。我们有多个地方有NaN值,表示没有发生。

  1. 最后,我们将上一步中的表可视化为堆叠条形图:
weather_df.groupby(['weather', 'city'])\
                   ['weather'].count().unstack('city')\
                   .fillna(0).plot(kind='bar', stacked=True)
plt.show()

这将产生以下图表:

图 3.6:天气类型的计数与城市相关

图 3.6:天气类型的计数与城市相关

在整个练习过程中,我们已经将关于分类数据的知识付诸实践,以可视化从样本天气数据集中计算出的各种计数类型。

要访问本节的源代码,请参阅packt.live/2ArQAtw

您也可以在packt.live/3gkIWAw上在线运行此示例。

有了这个,让我们继续讨论第二种主要类型的数据:数值数据。

数值数据

这个术语在帮助我们理解这是什么类型的数据方面是直观的。数值属性应该包含数值和连续值或实数。数值属性的值可以具有特定的范围;例如,它们可以是正数、负数或在 0 和 1 之间。然而,数值属性意味着其数据可以在给定范围内取任何值。这与分类属性中的值明显不同,后者只属于给定的离散值集。

数值数据有许多例子:人口成员的身高、学校学生的体重、某些地区待售房屋的价格、田径运动员的平均速度等。只要数据可以表示为实数,它很可能是数值数据。

鉴于其性质,数值数据与分类数据有很大的不同。在接下来的文本中,我们将阐述一些在统计和机器学习方面最重要的差异,我们应该牢记。

与可以用于建模分类数据的少数概率分布相反,数值数据有许多概率分布。这些包括正态分布(也称为钟形曲线分布)、均匀分布、指数分布、学生 t 分布等。每种概率分布都设计用于建模特定类型的数据。例如,正态分布通常用于建模具有线性增长的数量,如年龄、身高或学生的考试成绩,而指数分布则用于建模给定事件发生之间的时间量。

因此,重要的是研究哪种特定的概率分布适合你试图建模的数值属性。适当的分布将允许一致的分析和准确的预测;另一方面,选择不当的概率分布可能导致不直观和不正确的结论。

另一个话题是,许多处理技术可以应用于数值数据。其中最常见的两种包括缩放和归一化。

缩放涉及将数值属性中的所有值添加和/或乘以固定数量,以将原始数据的范围缩放到另一个范围。当统计和机器学习模型只能处理给定范围内的值时(例如,正数或 0 到 1 之间的数字可以更容易地处理和分析),就会使用这种方法。

最常用的缩放技术之一是最小-最大缩放方法,其公式如下所示,其中ab为正数:

图 3.7:最小-最大缩放的公式

图 3.7:最小-最大缩放的公式

X'X分别表示变换后和变换前的数据,而Xmax 和Xmin 分别表示数据中的最大值和最小值。可以数学证明,公式的输出始终大于a且小于b,但我们不需要在这里详细讨论。我们将在下一个练习中再次回到这种缩放方法。

至于归一化,尽管有时这个术语与缩放可以互换使用,但它指的是将数值属性特定地缩放到其概率分布的归一化形式的过程。我们的目标是获得一个转换后的数据集,它很好地遵循我们选择的概率分布的形状。

例如,假设我们在一个数值属性中拥有的数据遵循均值为4,标准差为10的正态分布。以下代码随机生成了这些数据,并对其进行可视化:

samples = np.random.normal(4, 10, size=1000)
plt.hist(samples, bins=20)
plt.show()

这产生了以下图表:

图 3.8:正态分布数据的直方图

图 3.8:正态分布数据的直方图

现在,假设你有一个模型,假设这些数据符合正态分布的标准形式,其中均值为0,标准差为1,如果输入数据不符合这种形式,模型将难以学习。因此,你希望以某种方式将前述数据转换为这种标准形式,而不牺牲数据的真实模式(特别是一般形状)。

在这里,我们可以应用正态分布数据的归一化技术,其中我们从数据点中减去真实均值,并将结果除以真实标准差。这个缩放过程更普遍地被称为标准缩放器。由于前述数据已经是一个 NumPy 数组,我们可以利用矢量化并进行如下归一化:

normalized_samples = (samples - 4) / 10
plt.hist(normalized_samples, bins=20)
plt.show()

这段代码将生成我们新转换的数据的直方图,如下所示:

图 3.9:归一化数据的直方图

图 3.9:归一化数据的直方图

我们看到,虽然数据已成功转移到我们想要的范围,现在它以0为中心,大部分数据位于-33之间,这是正态分布的标准形式,但数据的一般形状并没有改变。换句话说,数据点之间的相对差异没有改变。

另外,在实践中,当真实均值和/或真实标准差不可用时,我们可以用样本均值和标准差来近似这些统计量,如下所示:

sample_mean = np.mean(samples)
sample_sd = np.std(samples)

对于大量样本,这两个统计量提供了一个可以进一步用于这种转换的良好近似。有了这个,我们现在可以将这些归一化的数据输入到我们的统计和机器学习模型中进行进一步分析。

说到均值和标准差,这两个统计量通常用于描述数值数据。为了填补数值属性中的缺失值,通常使用均值和中位数等集中趋势测量。在一些特殊情况下,比如时间序列数据集,可以使用更复杂的缺失值插补技术,比如插值,我们可以估计缺失值在序列中在两者之间的某个位置。

当我们想要训练一个预测模型来针对数值属性时,会使用回归模型。与分类器不同,回归模型不是对条目可能取的分类值进行预测,而是寻找连续数值范围内的合理预测。因此,与我们讨论过的类似,我们必须小心地只在目标值是数值属性的数据集上应用回归模型。

最后,在可视化数值数据方面,我们已经看到了一系列可用的可视化技术。就在这之前,我们看到直方图被用来描述数值属性的分布,告诉我们数据在其范围内是如何分布的。

此外,折线图和散点图通常是可视化属性与其他属性模式的良好工具。(例如,我们绘制了各种概率分布的概率密度函数作为折线图。)最后,我们还看到热图被用来可视化二维结构,可以用来表示数据集中数值属性之间的相关性。

在我们继续讨论下一个话题之前,让我们对缩放/归一化的概念进行快速练习。再次,最流行的缩放/归一化方法之一被称为最小-最大缩放,它允许我们将数值属性中的所有值转换为任意的范围[a, b]。我们将在下面探讨这种方法。

练习 3.02:最小-最大缩放

在这个练习中,我们将编写一个函数,以便简化将最小-最大缩放应用于数值属性的过程。该函数应该接受三个参数:dataabdata应该是一个 NumPy 数组或 pandas 的Series对象,ab应该是实数正数,表示data应该转换成的数值范围的端点。

回顾数值数据部分中包含的公式,最小-最大缩放由以下给出:

图 3.10:最小-最大缩放的公式

图 3.10:最小-最大缩放的公式

让我们看看需要遵循的步骤:

  1. 创建一个新的 Jupyter 笔记本,在第一个代码单元格中,导入我们将在本练习中使用的库,如下所示:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

在我们将要使用的数据集中,第一列名为'Column 1',包含来自均值为 4,标准差为 10 的正态分布的 1,000 个样本。第二列名为'Column 2',包含来自 1 到 2 的均匀分布的 1,000 个样本。第三列名为'Column 3',包含参数为 2 和 5 的 Beta 分布的 1,000 个样本。在下一个代码单元格中,读取我们预先为您生成的'data.csv'文件(可以在packt.live/2YTrdKt找到),使用 pandas 作为DataFrame对象,并打印出前五行:

df = pd.read_csv('data.csv')
df.head()

您应该看到以下数字:

     Column 1    Column 2    Column 3
0    -1.231356   1.305917    0.511994
1    7.874195    1.291636    0.155032
2    13.169984   1.274973    0.183988
3    13.442203   1.549126    0.391825
4    -8.032985   1.895236    0.398122
  1. 在下一个单元格中,编写一个名为min_max_scale()的函数,它接受三个参数:dataab。如前所述,data应该是数据集属性中的值数组,而ab指定输入数据要转换成的范围。

  2. 考虑到我们对data的(隐含)要求(一个 NumPy 数组或 pandas 的Series对象——两者都可以利用矢量化),使用矢量化操作实现缩放函数:

def min_max_scale(data, a, b):
    data_max = np.max(data)
    data_min = np.min(data)
    return a + (b - a) * (data - data_min) / (data_max \
                                              - data_min)
  1. 首先我们将考虑'Column 1'属性中的数据。为了观察这个函数对我们数据的影响,让我们首先可视化当前数据的分布:
plt.hist(df['Column 1'], bins=20)
plt.show()

这段代码将生成类似以下的图表:

图 3.11:未缩放数据的直方图

图 3.11:未缩放数据的直方图

  1. 现在,使用相同的plt.hist()函数来可视化调用df['Column 1']上的min_max_scale()函数返回的值,将数据缩放到范围[-3, 3]
plt.hist(min_max_scale(df['Column 1'], -3, 3), bins=20)
plt.show()

这将产生以下结果:

图 3.12:缩放数据的直方图

图 3.12:缩放数据的直方图

我们看到,虽然数据分布的一般形状保持不变,但数据的范围已经有效地改变为从-33

  1. 对于'Column 2'属性,进行相同的过程(使用直方图可视化缩放前后的数据)。首先,我们可视化原始数据:
plt.hist(df['Column 2'], bins=20)
plt.show()
  1. 现在我们可视化缩放后的数据,应该缩放到范围[0, 1]
plt.hist(min_max_scale(df['Column 2'], 0, 1), bins=20)
plt.show()
  1. 第二个代码块应该生成类似以下的图表:图 3.13:缩放数据的直方图

图 3.13:缩放数据的直方图

  1. 对于'Column 3'属性,进行相同的过程(使用直方图可视化缩放前后的数据)。首先,我们可视化原始数据:
plt.hist(df['Column 3'], bins=20)
plt.show()
  1. 现在我们可视化缩放后的数据,应该缩放到范围[10, 20]
plt.hist(min_max_scale(df['Column 3'], 10, 20), \
                          bins=20)
plt.show()
  1. 第二个代码块应该生成类似以下的图表:图 3.14:缩放数据的直方图

图 3.14:缩放数据的直方图

在这个练习中,我们更详细地考虑了数值数据的缩放/归一化概念。我们还重新访问了plt.hist()函数作为可视化数值数据分布的方法。

注意

要访问此特定部分的源代码,请参阅packt.live/2VDw3JP

您还可以在packt.live/3ggiPdO上在线运行此示例。

这个练习结束了本章关于数值数据的讨论。连同分类数据一起,它构成了您可能在给定数据集中看到的大多数数据类型。然而,实际上除了这两种数据类型之外,还有另一种数据类型,这种数据类型较少见,我们将在下一节中讨论。

序数数据

序数数据在某种程度上是分类数据(序数属性中的值属于特定给定集合)和数值数据(其中值为数字——这一事实意味着它们之间存在有序关系)的组合。序数数据的最常见示例是字母分数("A""B""C""D""E"),整数评分(例如,在 1 到 10 的范围内),或者质量排名(例如,“优秀”,“好”,和“差”,其中“优秀”意味着比“好”更高的质量级别,而“好”本身又比“差”更好)。

由于序数属性中的条目只能取特定一组值中的一个,应该使用分类概率分布来对这种类型的数据进行建模。出于同样的原因,序数属性中的缺失值可以使用属性的众数来填充,分类数据的可视化技术也可以应用于序数数据。

然而,其他过程可能与我们讨论的分类数据有所不同。在数据处理方面,您可能会为每个序数值分配一个一对一的映射,以及一个数字值/范围。

在字母分数的例子中,通常情况下,等级"A"对应于原始分数的范围[90, 100],其他字母等级也有它们自己的连续范围。在质量排名的例子中,“优秀”,“好”和“差”可以分别映射为 10,5 和 0,但是,除非可以量化值之间的质量差异程度,否则这种转换是不可取的。

在将机器学习模型拟合到数据并让其预测序数属性的未见值方面,应该使用分类器来执行此任务。此外,由于排名是构成许多不同学习结构的独特任务,已经付出了相当大的努力来进行机器学习排名,其中专门设计和训练模型以预测排名数据。

这个讨论结束了统计学和机器学习中的数据类型主题。总的来说,我们已经了解到数据集中常见的两种主要数据类型:分类和数值数据。根据您的数据属于哪种类型,您将需要使用不同的数据处理、机器学习和可视化技术。

在下一节中,我们将讨论描述统计以及如何在 Python 中进行计算。

描述统计

如前所述,描述统计和推断统计是统计学领域的两个主要类别。通过描述统计,我们的目标是计算特定的数量,可以传达关于我们的数据的重要信息,或者换句话说,描述我们的数据。

在描述统计中,有两个主要的子类别:中心趋势统计和离散统计。实际术语暗示了它们各自的含义:中心趋势统计负责描述给定数据的中心,而离散统计传达有关数据远离其中心的传播或范围的信息。

这种区别最清晰的例子之一来自熟知的正态分布,其统计数据包括均值和标准差。均值是通过计算概率分布中所有值的平均值得出的,适用于估计分布的中心。正如我们所见,标准形式的正态分布的均值为 0,表明其数据围绕着轴上的 0 点。

另一方面,标准差表示数据点与均值的变化程度。在不深入细节的情况下,在正态分布中,它被计算为与分布均值的平均距离。低值的标准差表明数据与均值的偏差不大,而高值的标准差意味着个别数据点与均值相差很大。

总的来说,这些类型的统计数据及其特性可以总结在以下表中:

图 3.15:描述性统计类型

图 3.15:描述性统计类型

还有其他更专业的描述性统计,比如衡度,用于衡量数据分布的不对称性,或者峰度,用于衡量分布峰值的陡峭程度。然而,这些并不像我们之前列出的那些常用,因此本章不会涉及。

在下一小节中,我们将开始更深入地讨论前述统计数据,从集中趋势测量开始。

集中趋势

形式上,常用的集中趋势统计数据有均值、中位数和众数。中位数被定义为当所有数据点沿着轴排序时的中间值。众数,正如我们之前提到的,是出现最多次的值。由于它们的特性,均值和中位数仅适用于数值数据,而众数通常用于分类数据。

这三个统计数据都很好地捕捉了集中趋势的概念,以不同的方式代表了数据集的中心。这也是为什么它们经常被用来替换属性中的缺失值。因此,对于缺失的数值,你可以选择均值或中位数作为潜在的替代,而如果分类属性包含缺失值,则可以使用众数。

特别地,均值经常被用来填补数值属性中的缺失值并非是任意的。如果我们要将概率分布拟合到给定的数值属性上,那么该属性的均值实际上将是样本均值,对真实总体均值的估计。总体均值的另一个术语是该总体内未知值的期望值,换句话说,就是我们应该期望来自该总体的任意值是多少。

这就是为什么均值,或者来自相应分布的值的期望,应该在某些情况下用来填补缺失值。虽然对于中位数来说情况并非如此,但对于它在替换缺失数值方面的作用可以提出类似的论点。另一方面,众数是替换缺失分类值的良好估计,因为它是属性中出现最频繁的值。

离散度

与集中趋势统计不同,离散度统计再次试图量化数据集中的变化程度。一些常见的离散度统计包括标准差、范围(最大值和最小值之间的差异)和四分位数。

标准差,正如我们所提到的,计算了每个数据点与数值属性的均值之间的差异,对它们进行平方,取平均值,最后取结果的平方根。个别数据点离均值越远,这个数量就越大,反之亦然。这就是为什么它是一个很好的指标,用来衡量数据集的离散程度。

范围——最大值和最小值之间的距离,或者 0%和 100%分位数之间的距离——是描述数据集离散程度的另一种更简单的方法。然而,由于其简单性,有时这个统计量并不能传达与标准差或四分位数一样多的信息。

四分位数被定义为数据集中特定部分落在其下的阈值。例如,中位数,数值数据集的中间值,是该数据集的 50%分位数,因为(大致上)一半的数据集小于该数字。同样,我们可以计算常见的四分位数数量,如 5%,25%,75%和 95%分位数。这些四分位数在量化我们的数据分散程度方面可能更具信息量,因为它们可以解释数据的不同分布。

此外,四分位距,另一个常见的离散统计量,被定义为数据集的 25%和 75%分位数之间的差异。

到目前为止,我们已经讨论了中心趋势统计和离散统计的概念。让我们通过一个快速练习来加强一些重要的想法。

练习 3.03:可视化概率密度函数

第二章Python 统计学的主要工具练习 2.04中,我们考虑了比较概率分布的概率密度函数与其抽样数据的直方图的任务。在这里,我们将实现该程序的扩展,我们还将可视化每个分布的各种描述性统计信息:

  1. 在新的 Jupyter 笔记本的第一个单元格中,导入 NumPy 和 Matplotlib:
import numpy as np
import matplotlib.pyplot as plt
  1. 在一个新的单元格中,使用np.random.normal()随机生成来自正态分布的 1,000 个样本。计算均值、中位数和 25%和 75%四分位数的描述性统计如下:
samples = np.random.normal(size=1000)
mean = np.mean(samples)
median = np.median(samples)
q1 = np.percentile(samples, 25)
q2 = np.percentile(samples, 75)
  1. 在下一个单元格中,使用直方图可视化样本。我们还将通过绘制垂直线来指示各种描述性统计的位置——在均值点处绘制红色垂直线,在中位数处绘制黑色垂直线,在每个四分位数处绘制蓝色线:
plt.hist(samples, bins=20)
plt.axvline(x=mean, c='red', label='Mean')
plt.axvline(x=median, c='black', label='Median')
plt.axvline(x=q1, c='blue', label='Interquartile')
plt.axvline(x=q2, c='blue')
plt.legend()
plt.show()

请注意,我们在各种绘图函数调用中结合了label参数的规范和plt.legend()函数。这将帮助我们创建一个带有适当标签的图例,如下所示:

图 3.16:正态分布的描述性统计

图 3.16:正态分布的描述性统计

这里有一件有趣的事情:均值和中位数几乎在 x 轴上重合。这是正态分布的许多数学上方便的特性之一,在许多其他分布中找不到:它的均值等于它的中位数和众数。

  1. 将相同的过程应用于参数为25的 Beta 分布,如下所示:
samples = np.random.beta(2, 5, size=1000)
mean = np.mean(samples)
median = np.median(samples)
q1 = np.percentile(samples, 25)
q2 = np.percentile(samples, 75)
plt.hist(samples, bins=20)
plt.axvline(x=mean, c='red', label='Mean')
plt.axvline(x=median, c='black', label='Median')
plt.axvline(x=q1, c='blue', label='Interquartile')
plt.axvline(x=q2, c='blue')
plt.legend()
plt.show()

这应该生成一个类似于以下的图表:

图 3.17:Beta 分布的描述性统计

图 3.17:Beta 分布的描述性统计

  1. 将相同的过程应用于参数为5的 Gamma 分布,如下所示:
samples = np.random.gamma(5, size=1000)
mean = np.mean(samples)
median = np.median(samples)
q1 = np.percentile(samples, 25)
q2 = np.percentile(samples, 75)
plt.hist(samples, bins=20)
plt.axvline(x=mean, c='red', label='Mean')
plt.axvline(x=median, c='black', label='Median')
plt.axvline(x=q1, c='blue', label='Interquartile')
plt.axvline(x=q2, c='blue')
plt.legend()
plt.show()

这应该生成一个类似于以下的图表:

图 3.18:Gamma 分布的描述性统计

图 3.18:Gamma 分布的描述性统计

通过这个练习,我们学会了如何使用 NumPy 计算数据集的各种描述性统计,并在直方图中可视化它们。

注意

要访问此特定部分的源代码,请参阅packt.live/2YTobpm

您也可以在packt.live/2CZf26h上在线运行此示例。

除了计算描述性统计信息,Python 还提供其他附加方法来描述数据,我们将在下一节讨论。

与 Python 相关的描述性统计

在这里,我们将讨论描述数据的两种中间方法。第一种是在DataFrame对象上调用的describe()方法。根据官方文档(可在pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.describe.html找到),该函数“生成总结数据集分布的集中趋势、离散度和形状的描述性统计,不包括NaN值。”

让我们看看这种方法的效果。首先,我们将创建一个包含数值属性、分类属性和顺序属性的样本数据集,如下所示:

df = pd.DataFrame({'numerical': np.random.normal(size=5),\
                   'categorical': ['a', 'b', 'a', 'c', 'b'],\
                   'ordinal': [1, 2, 3, 5, 4]})

现在,如果我们在df变量上调用describe()方法,将生成一个表格摘要:

df.describe()

输出如下:

        numerical    ordinal
count   5.000000     5.000000
mean    -0.251261    3.000000
std     0.899420     1.581139
min     -1.027348    1.000000
25%     -0.824727    2.000000
50%     -0.462354    3.000000
75%     -0.192838    4.000000
max     1.250964     5.000000

正如你所看到的,打印输出中的每一行表示数据集中每个属性的不同描述统计信息:值的数量(count)、均值、标准差和各种四分位数。由于numericalordinal属性都被解释为数值数据(根据它们包含的数据),describe()默认只为它们生成这些报告。另一方面,categorical列被排除在外。为了强制报告适用于所有列,我们可以指定include参数如下:

df.describe(include='all')

输出如下:

        numerical     categorical    ordinal
count   5.000000      5              5.000000
unique  NaN           3              NaN
top     NaN           a              NaN
freq    NaN           2              NaN
mean    -0.251261     NaN            3.000000
std     0.899420      NaN            1.581139
min     -1.027348     NaN            1.000000
25%     -0.824727     NaN            2.000000
50%     -0.462354     NaN            3.000000
75%     -0.192838     NaN            4.000000
max     1.250964      NaN            5.000000

这迫使该方法计算适用于分类数据的其他统计信息,例如唯一值的数量(unique)、众数(top)和众数的计数/频率(freq)。正如我们讨论过的,大多数数值数据的描述统计不适用于分类数据,反之亦然,这就是为什么在前面的报告中使用NaN值来指示这种非适用性。

总的来说,pandas 的describe()方法提供了一种快速概括数据集及其属性的方法。这在探索性数据分析任务中特别方便,当我们想要广泛地探索一个我们尚不熟悉的新数据集时。

Python 支持的第二种与描述统计相关的方法是箱线图的可视化。显然,箱线图是一种可视化技术,不仅仅是语言本身的独特之处,但是 Python,特别是其 seaborn 库,提供了一个相当方便的 API,即sns.boxplot()函数,以便于这个过程。

理论上,箱线图是可视化数值数据集分布的另一种方法。同样,可以使用sns.boxplot()函数生成它:

sns.boxplot(np.random.normal(2, 5, size=1000))
plt.show()

这段代码将产生一个与以下类似的图形:

图 3.19:使用 seaborn 的箱线图

图 3.19:使用 seaborn 的箱线图

在前面的箱线图中,中间的蓝色框表示输入数据的四分位距(从 25%到 75%的四分位数)。框中间的垂直线是中位数,而框外左右两个阈值分别表示输入数据的最小值和最大值。

重要的是要注意,最小值被计算为 25%四分位数减去四分位距乘以 1.5,最大值是 75%四分位数加上四分位距也乘以 1.5。通常做法是将最小值和最大值之间的任何数字视为异常值,在前面的图中显示为黑点。

实质上,箱线图可以在视觉上表示由 pandas 的 describe() 函数计算的统计数据。这个 seaborn 中的函数与其他可视化工具的不同之处在于,它可以轻松地根据 seaborn 提供的标准创建多个箱线图。

让我们在下一个示例中看到这一点,我们将扩展样本数据集到 1000 行,并生成随机数据:

df = pd.DataFrame({'numerical': np.random.normal(size=1000),\
                   'categorical': np.random.choice\
                                  (['a', 'b', 'c'], size=1000),\
                   'ordinal': np.random.choice\
                              ([1, 2, 3, 4, 5], size=1000)})

在这里,'numerical' 属性包含来自标准正态分布的随机抽样,'categorical' 属性包含从列表 ['a','b','c'] 中随机选择的值,而 'ordinal' 也包含从列表 [1, 2, 3, 4, 5] 中随机选择的值。

我们的目标是使用这个数据集生成一个稍微复杂的箱线图可视化——一个表示 'categorical' 中不同值的 'numerical' 数据分布的箱线图。一般的过程是将数据集分成不同的组,每个组对应于 'categorical' 中的唯一值,并且对于每个组,我们希望使用 'numerical' 属性中的相应数据生成一个箱线图。

然而,使用 seaborn,我们可以通过为 sns.boxplot() 函数指定 xy 参数来简化这个过程。具体来说,我们将使我们的 x 轴包含 'categorical' 中不同的唯一值,y 轴表示 'numerical' 中的数据,代码如下:

sns.boxplot(y='numerical', x='categorical', data=df)
plt.show()

这将生成以下图表:

图 3.20:使用 seaborn 的多重箱线图

图 3.20:使用 seaborn 的多重箱线图

这个可视化包含了我们想要显示的内容:'numerical' 属性数据的分布,表示为箱线图,并按 'categorical' 属性中的唯一值进行分隔。考虑到 'ordinal' 中的唯一值,我们可以按照以下相同的过程进行:

sns.boxplot(y='numerical', x='ordinal', data=df)
plt.show()

这将生成以下图表:

图 3.21:使用 seaborn 的多重箱线图

图 3.21:使用 seaborn 的多重箱线图

如你所想象的,当我们想要分析数值属性在分类或有序数据方面的分布差异时,这种可视化方法是理想的。

这就结束了本章关于描述性统计的话题。在下一节中,我们将讨论另一类统计学:推断统计。

推断统计

与描述性统计不同,我们的目标是使用特定的数量描述数据集的各种特征,而在推断统计中,我们希望对数据集执行特定的统计建模过程,以便我们可以推断进一步的信息,无论是关于数据集本身还是关于来自同一总体的未见数据点。

在这一部分,我们将介绍一些不同的推断统计方法。通过这些讨论,我们将看到每种方法都是针对特定数据和情况设计的,统计学家或机器学习工程师有责任适当地应用它们。

我们将讨论的第一种方法是古典统计学中最基本的方法之一:t 检验。

T-Tests

通常,t 检验(也称为学生 t 检验)用于比较两个均值(平均)统计量,并得出它们是否足够不同的结论。t 检验的主要应用是比较事件(例如实验药物、锻炼常规等)对总体的影响与对照组。如果均值足够不同(我们称之为统计显著),那么我们有充分的理由相信给定事件的影响。

统计学中有三种主要类型的 t 检验:独立样本 t 检验(用于比较两个独立样本的均值),配对样本 t 检验(用于比较同一组在不同时间的均值),以及单样本 t 检验(用于将一组的均值与预定均值进行比较)。

t 检验的一般工作流程是首先声明这两个均值确实相等的零假设,然后考虑 t 检验的输出,即相应的 p 值。如果 p 值大于一个固定的阈值(通常选择 0.05),那么我们就不能拒绝零假设。另一方面,如果 p 值低于阈值,我们就可以拒绝零假设,这意味着这两个均值是不同的。我们看到这是一种推断统计方法,因为我们可以从中推断出关于我们的数据的事实;在这种情况下,我们可以推断出我们感兴趣的两个均值是否不同。

我们不会深入讨论这些测试的理论细节;相反,我们将看到如何简单地利用 Python 提供的 API,或者具体来说是 SciPy 库。我们在上一章中使用了这个库,所以如果你还不熟悉这个工具,一定要回到第二章Python 的统计主要工具,看看它如何在你的环境中安装。

让我们设计我们的样本实验。假设我们有两个数字数组,每个数组都是从一个未知分布中抽取的,我们想要找出它们各自的均值是否相等。因此,我们有我们的零假设,即这两个数组的均值相等,如果我们的 t 检验的 p 值小于 0.05,则可以拒绝这个假设。

为了生成这个例子的合成数据,我们将从标准正态分布(均值为 0,标准差为 1)中使用20个样本来生成第一个数组,然后从均值为0.2,标准差为 1 的正态分布中使用另外20个样本来生成第二个数组:

samples_a = np.random.normal(size=20)
samples_b = np.random.normal(0.2, 1, size=20)

为了快速可视化这个数据集,我们可以使用plt.hist()函数,如下所示:

plt.hist(samples_a, alpha=0.2)
plt.hist(samples_b, alpha=0.2)
plt.show()

这生成了以下的图表(注意你自己的输出可能会有所不同):

图 3.22:t 检验样本数据的直方图

图 3.22:t 检验样本数据的直方图

现在,我们将从scipy.stats包中调用ttest_ind()函数。这个函数便利了独立样本 t 检验,并将返回一个具有名为pvalue的属性的对象;这个属性包含了 p 值,将帮助我们决定是否拒绝我们的零假设:

scipy.stats.ttest_ind(samples_a, samples_b).pvalue

以下是输出结果:

0.8616483548091348

根据这个结果,我们不拒绝我们的零假设。再次强调,你的 p 值可能与前面的输出不同,但很可能也不会低于 0.05。我们最终的结论是,我们没有足够的证据表明我们的两个数组的均值是不同的(即使它们实际上是从两个均值不同的正态分布中生成的)。

让我们重复这个实验,但这次我们有更多的数据——每个数组现在包含 1,000 个数字:

samples_a = np.random.normal(size=1000)
samples_b = np.random.normal(0.2, 1, size=1000)
plt.hist(samples_a, alpha=0.2)
plt.hist(samples_b, alpha=0.2)
plt.show()

现在的直方图看起来像这样:

图 3.23:t 检验样本数据的直方图

图 3.23:t 检验样本数据的直方图

再次运行 t 检验,我们看到这次我们得到了不同的结果:

scipy.stats.ttest_ind(samples_a, samples_b).pvalue

以下是输出结果:

3.1445050317071093e-06

这个 p 值远低于 0.05,因此拒绝了零假设,并给了我们足够的证据表明这两个数组的均值是不同的。

这两个实验展示了我们应该牢记的一个现象。在第一个实验中,我们的 p 值不够低,无法拒绝零假设,即使我们的数据确实是从两个均值不同的分布中生成的。在第二个实验中,有了更多的数据,t 检验在区分这两个均值方面更具有决定性。

实质上,每个数组中只有 20 个样本,第一个 t 检验没有足够高的置信水平来输出较低的 p 值,即使两个均值确实不同。有了 1,000 个样本,这种差异更加一致和稳健,因此第二个 t 检验能够积极地输出较低的 p 值。一般来说,许多其他统计方法在使用更多数据作为输入时也会同样证明更具有决定性。

我们已经看过独立样本 t 检验的一个例子,作为推断统计的一种方法,用于测试两个给定总体的平均值之间的差异程度。总的来说,scipy.stats包提供了一系列广泛的统计测试,它们处理所有的计算工作,并且只返回最终的测试输出。这符合 Python 语言的一般哲学,保持 API 在高层次,以便用户可以以灵活和便利的方式利用复杂的方法。

注意

有关scipy.stats包中可用内容的更多详细信息可以在其官方文档中找到docs.scipy.org/doc/scipy-0.15.1/reference/tutorial/stats.html

可以从该包中调用的一些最常用的测试包括:用于均值差异的 t 检验或 ANOVA;用于确定样本是否来自正态分布的正态性测试;以及计算样本总体的均值和标准差的贝叶斯可信区间。

摆脱scipy.stats包,我们已经看到 pandas 库也支持各种统计功能,特别是其方便的describe()方法。在下一节中,我们将研究第二种推断统计方法:数据集的相关矩阵。

相关矩阵

相关矩阵是一个二维表,包含给定数据集中每对属性之间的相关系数。两个属性之间的相关系数量化了它们的线性相关程度,或者换句话说,它们在线性方面的行为有多相似。相关系数的范围在-1 到+1 之间,其中+1 表示完美的线性相关,0 表示没有相关性,-1 表示完美的负相关。

如果两个属性具有很高的线性相关性,那么当一个属性增加时,另一个属性也倾向于以相同的常数乘以增加。换句话说,如果我们在散点图上绘制两个属性的数据,个别点倾向于沿着一个具有正斜率的直线。对于没有相关性的两个属性,最佳拟合线倾向于水平,而具有负相关性的两个属性则由具有负斜率的直线表示。

两个属性之间的相关性在某种程度上可以告诉我们属性之间共享多少信息。我们可以从两个相关的属性中推断出,无论是正相关还是负相关,它们之间都存在某种潜在的关系。这就是相关矩阵作为推断统计工具的背后思想。

在一些机器学习模型中,建议如果我们有高度相关的特征,应该在将其输入模型之前只保留一个特征。在大多数情况下,拥有另一个与模型已经训练的特征高度相关的属性并不会提高其性能;更重要的是,在某些情况下,相关特征甚至会误导我们的模型,使其预测走向错误的方向。

这意味着两个数据属性之间的相关系数,以及数据集的相关矩阵,对我们来说是一个重要的统计对象。让我们通过一个快速的例子来看看这一点。

假设我们有一个包含三个属性'x'、'y'和'z'的数据集。'x'和'z'中的数据是以独立的方式随机生成的,因此它们之间不应该有相关性。另一方面,我们将'y'生成为'x'中的数据乘以 2 再加上一些随机噪音。这可以通过以下代码实现,该代码创建了一个包含 500 个条目的数据集:

x = np.random.rand(500,)
y = x * 2 + np.random.normal(0, 0.3, 500)
z = np.random.rand(500,)
df = pd.DataFrame({'x': x, 'y': y, 'z': z})

从这里开始,相关矩阵(其中包含数据集中每对属性的相关系数)可以很容易地通过corr()方法计算出来:

df.corr()

输出如下:

     x                     y                     z
x    1.000000              0.8899950.869522      0.019747 -0.017913
y    0.8899950.869522      1.000000              0.045332 -0.023455
z    0.019747 -0.017913    0.045332 -0.023455    1.000000

我们看到这是一个 3x3 的矩阵,因为在调用的 DataFrame 对象中有三个属性。每个数字表示行和列属性之间的相关性。这种表示的一个效果是矩阵中的对角线值都为 1,因为每个属性与自身完全相关。

对我们来说更有趣的是不同属性之间的相关性:由于'z'是独立于'x'(因此也独立于'y')生成的,'z'行和列中的值相对接近 0。相比之下,'x'和'y'之间的相关性接近 1,因为一个属性被生成为大约是另一个属性的两倍。

此外,通常使用热力图来直观表示相关矩阵。这是因为当数据集中有大量属性时,热力图可以帮助我们更有效地识别高度相关的属性所对应的区域。可以使用 seaborn 库中的sns.heatmap()函数来可视化热力图:

sns.heatmap(df.corr(), center=0, annot=True)
bottom, top = plt.ylim()
plt.ylim(bottom + 0.5, top - 0.5)
plt.show()

annot=True参数指定矩阵中的值应该打印在热力图的每个单元格中。

这段代码将产生以下结果:

图 3.24:代表相关矩阵的热力图

图 3.24:代表相关矩阵的热力图

在这种情况下,当通过视觉检查相关矩阵热力图时,我们可以专注于明亮的区域,除了对角线单元格,以识别高度相关的属性。如果数据集中存在负相关的属性(在我们当前的例子中没有),那么这些属性也可以通过暗区域来检测。

总的来说,给定数据集的相关矩阵可以成为我们理解数据集中不同属性之间关系的有用工具。我们将在接下来的练习中看到一个例子。

练习 3.04:识别和测试均值的相等性

在这个练习中,我们将练习两种推断统计方法来分析我们为您生成的一个合成数据集。可以从 GitHub 仓库packt.live/3ghKkDS下载数据集。

在这里,我们的目标是首先确定数据集中哪些属性彼此相关,然后应用 t 检验来确定任何一对属性是否具有相同的均值。

有了这些说法,让我们开始吧:

  1. 在一个新的 Jupyter 笔记本中,导入pandasmatplotlibseaborn,以及从 SciPy 的stats模块中导入ttest_ind()方法:
import pandas as pd
from scipy.stats import ttest_ind
import matplotlib.pyplot as plt
import seaborn as sns
  1. 读取您下载的数据集。前五行应该如下所示:图 3.25:读取数据集的前五行

图 3.25:读取数据集的前五行

  1. 在下一个代码单元中,使用 seaborn 生成代表该数据集相关矩阵的热力图。从可视化中,确定哪对属性彼此相关性最高:
sns.heatmap(df.corr(), center=0, annot=True)
bottom, top = plt.ylim()
plt.ylim(bottom + 0.5, top - 0.5)
plt.show()

这段代码应该产生以下可视化效果:

图 3.26:数据集的相关矩阵

图 3.26:数据集的相关矩阵

从这个输出中,我们可以看到属性'x'和'y'的相关系数相当高:0.94。

  1. 使用 seaborn 中的jointplot()方法,创建一个组合图,其中包括一个二维平面上的散点图,点的坐标分别对应于'x'和'y'中的个别值,以及代表这些值分布的两个直方图。观察输出并决定这两个分布是否具有相同的均值:
sns.jointplot(x='x', y='y', data=df)
plt.show()

这将产生以下输出:

图 3.27:相关属性的组合图

图 3.27:相关属性的组合图

从这个可视化中,不清楚这两个属性是否具有相同的均值。

  1. 不使用可视化,而是使用 0.05 的显著性水平运行 t 检验,以决定这两个属性是否具有相同的均值:
ttest_ind(df['x'], df['y']).pvalue

该命令将产生以下输出:

0.011436482008949079

这个 p 值确实低于 0.05,使我们能够拒绝这两个分布具有相同均值的零假设,尽管它们高度相关。

在这个练习中,我们应用了本节中学到的两种推断统计方法来分析数据集中一对相关属性。

注意

要访问本特定部分的源代码,请参阅packt.live/31Au1hc

您也可以在packt.live/2YTt7L7上在线运行此示例。

在关于推断统计的下一个和最后一节中,我们将讨论使用统计和机器学习模型作为使用统计进行推断的方法。

统计和机器学习模型

使用数学或机器学习模型对给定数据集进行建模,这本身就能够将数据集中的任何潜在模式和趋势推广到未见数据点,是推断统计学的另一种形式。机器学习本身可以说是计算机科学中增长最快的领域之一。然而,大多数机器学习模型实际上利用了数学和统计理论,这就是为什么这两个领域密切相关的原因。在本节中,我们将考虑在给定数据集上训练模型的过程以及 Python 如何帮助促进该过程。

重要的是要注意,机器学习模型实际上并不像人类那样学习。大多数情况下,模型试图解决一个最小化训练误差的优化问题,这代表了它在训练数据中处理模式的能力,希望模型能够很好地推广到从相同分布中抽取的未见数据。

例如,线性回归模型生成最佳拟合线,通过给定数据集中的所有数据点。在模型定义中,这条线对应于具有最小距离和的线,通过解决最小化距离和的优化问题,线性回归模型能够输出最佳拟合线。

总的来说,每个机器学习算法以不同的方式对数据和优化问题进行建模,每种适用于特定的设置。然而,Python 语言内置的不同抽象级别使我们能够跳过这些细节,并在高层次应用不同的机器学习模型。我们需要记住的是,统计和机器学习模型是推断统计的另一种方法,我们能够根据训练数据集中的模式对未见数据进行预测。

假设我们的任务是在前一节中拥有的样本数据集上训练模型,其中学习特征是'x'和'z',我们的预测目标是'y'。也就是说,我们的模型应该学习'x'或'z'与'y'之间的任何潜在关系,并从中知道如何从'x'和'z'的数据中预测'y'的未见值。

由于'y'是一个数值属性,我们将需要一个回归模型来训练我们的数据,而不是一个分类器。在这里,我们将使用统计学和机器学习中最常用的回归器之一:线性回归。为此,我们将需要 scikit-learn 库,这是 Python 中最受欢迎的预测数据分析工具之一,如果不是最受欢迎的。

要安装 scikit-learn,请运行以下pip命令:

$ pip install scikit-learn

你也可以使用conda命令来安装它:

$ conda install scikit-learn

现在,我们导入线性回归模型并将其拟合到我们的训练数据:

from sklearn import linear_model
model = linear_model.LinearRegression()
model.fit(df[['x', 'z']], df['y'])

一般来说,由机器学习模型对象调用的fit()方法接受两个参数:独立特征(即用于进行预测的特征),在这种情况下是'x''z',以及依赖特征或预测目标(即我们想要进行预测的属性),在这种情况下是'y'

这个fit()方法将在给定数据上启动模型的训练过程。根据模型的复杂性以及训练数据的大小,这个过程可能需要相当长的时间。然而,对于线性回归,训练过程应该相对快速。

一旦我们的模型训练完成,我们可以查看它的各种统计数据。可用的统计数据取决于所使用的具体模型;对于线性回归,我们通常考虑系数。回归系数是独立特征和预测目标之间线性关系的估计值。实质上,回归系数是线性回归模型为特定预测变量(在我们的情况下是'x''z')和我们想要预测的特征的最佳拟合线的斜率估计值。

这些统计数据可以按如下方式访问:

model.coef_

这将给我们以下输出:

array([1.98861194, 0.05436268])

你自己实验的输出可能与前面的不完全相同。然而,这些系数存在明显的趋势:第一个系数(表示'x''y'之间的估计线性关系)约为 2,而第二个系数(表示'z''y'之间的估计线性关系)接近于 0。

这个结果与我们生成这个数据集的方法非常一致:'y'被生成为大致等于'x'中的元素乘以 2,而'z'是独立生成的。通过观察这些回归系数,我们可以获得关于哪些特征是最佳(线性)预测器的信息。一些人认为这些类型的统计数据是可解释性统计数据,因为它们为我们提供了关于预测过程如何进行的见解。

对我们来说更有趣的是对未见数据进行预测的过程。这可以通过在模型对象上调用predict()方法来完成,如下所示:

model.predict([[1, 2], [2, 3]])

输出将如下所示:

array([2.10790143, 4.15087605])

在这里,我们将任何能表示二维表的数据结构传递给predict()方法(在前面的代码中,我们使用了嵌套列表,但理论上,你也可以使用二维 NumPy 数组或 pandas 的DataFrame对象)。这个表的列数需要等于训练数据中独立特征的数量;在这种情况下,我们有两个('x''z'),所以[[1, 2], [2, 3]]中的每个子列表都有两个元素。

从模型产生的预测中,我们看到当'x'等于 1 且'z'等于 2(我们的第一个测试案例)时,相应的预测大约为 2。这与'x'的系数约为 2 和'z'的系数接近 0 的事实一致。第二个测试案例也是如此。

这就是机器学习模型如何用于对数据进行预测的一个例子。总的来说,scikit-learn 库为不同类型的问题提供了各种模型:分类、回归、聚类、降维等等。模型之间的 API 与我们所见到的fit()predict()方法一致。这使得灵活性和流程化程度更高。

机器学习中的一个重要概念是模型选择。并非所有模型都是平等的;由于其设计或特性,某些模型更适合于给定的数据集。这就是为什么模型选择是整个机器学习流程中的重要阶段。在收集和准备训练数据集之后,机器学习工程师通常会将数据集馈送到多个不同的模型中,一些模型可能由于性能不佳而被排除在外。

我们将在接下来的练习中演示这一点,我们将介绍模型选择的过程。

练习 3.05:模型选择

在这个练习中,我们将进行一个样本模型选择过程,尝试将三种不同的模型拟合到一个合成数据集中,并考虑它们的性能:

  1. 在新的 Jupyter 笔记本的第一个代码单元中,导入以下工具:
import numpy as np
from sklearn.datasets import make_blobs
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.ensemble import GradientBoostingClassifier
import matplotlib.pyplot as plt

注意

我们还不熟悉一些工具,但随着我们进行这个练习,它们将被解释给我们。

现在,我们想要创建一个位于二维平面上的合成数据集。这些点中的每一个都属于一个特定的组,属于同一组的点应该围绕一个共同的中心点旋转。

  1. 这些合成数据可以使用我们从sklearn.datasets包中导入的make_blobs函数生成:
n_samples = 10000
centers = [(-2, 2), (0, 0), (2, 2)]
X, y = make_blobs(n_samples=n_samples, centers=centers, \
                  shuffle=False, random_state=0)

正如我们所看到的,这个函数接受一个名为n_samples的参数,该参数指定应该生成的数据点的数量。另一方面,centers参数指定了个体点所属的总组数以及它们各自的坐标。在这种情况下,我们有三组围绕着(-2, 2)(0, 0)(2, 2)的点。

  1. 最后,通过将random_state参数指定为0,我们确保每次重新运行此笔记本时生成相同的数据。正如我们在第一章Python 基础中提到的,这在可重现性方面是一个良好的实践。

我们的目标是在这些数据上训练各种模型,以便当馈送一个新的点列表时,模型可以以高准确度决定每个点应该属于哪个组。

这个函数返回一个包含两个对象的元组,我们分别将它们分配给变量Xy。元组中的第一个元素包含数据集的独立特征;在这种情况下,它们是点的* x y *坐标。第二个元组元素是我们的预测目标,即每个点所属组的索引。约定是将独立特征存储在名为X的矩阵中,将预测目标存储在名为y的向量中,就像我们正在做的那样。

  1. 打印出这些变量,看看我们正在处理什么。将X作为输入类型:
X

这将产生以下输出:

array([[-0.23594765,  2.40015721],
       [-1.02126202,  4.2408932 ],
       [-0.13244201,  1.02272212],
       ...,
       [ 0.98700332,  2.27166174],
       [ 1.89100272,  1.94274075],
       [ 0.94106874,  1.67347156]])

现在,将y作为输入类型:

y

这将产生以下输出:

array([0, 0, 0, ..., 2, 2, 2])
  1. 现在,在一个新的代码单元中,我们想要使用散点图来可视化这个数据集:
plt.scatter(X[:, 0], X[:, 1], c=y)
plt.show()

我们使用数据集中的第一个属性作为* x 坐标,第二个属性作为散点图中点的 y *坐标。我们还可以通过将我们的预测目标y传递给参数c来快速指定属于同一组的点应该具有相同的颜色。

这个代码单元将产生以下散点图:

图 3.28:用于机器学习问题的散点图

图 3.28:用于机器学习问题的散点图

模型选择过程中最常见的策略是首先将数据分成训练数据集和测试/验证数据集。训练数据集用于训练我们想要使用的机器学习模型,而测试数据集用于验证这些模型的性能。

  1. sklearn.model_selection包中的train_test_split()函数简化了将数据集拆分为训练和测试数据集的过程。在下一个代码单元中,输入以下代码:
X_train, X_test, \
y_train, y_test = train_test_split(X, y, shuffle=True, \
                                   random_state=0)

正如我们所看到的,这个函数返回了四个对象的元组,我们将其分配给了前面的四个变量:X_train包含训练数据集中独立特征的数据,而X_test包含测试数据集中相同特征的数据,y_trainy_test也是如此。

  1. 我们可以通过考虑我们的训练数据集的形状来检查拆分是如何进行的:
X_train.shape
(7500, 2)

默认情况下,训练数据集是从输入数据的 75%中随机选择的,而测试数据集是剩余的数据,随机洗牌。这由前面的输出所示,我们的训练数据集中有 7500 条记录,原始数据中有 10000 条记录。

  1. 在下一个代码单元中,我们将初始化我们导入的机器学习模型,而不指定任何超参数(稍后会详细介绍):
models = [KNeighborsClassifier(), SVC(),\
          GradientBoostingClassifier()]
  1. 接下来,我们将循环遍历每个模型,在我们的训练数据集上对它们进行训练,并最终使用accuracy_score函数计算它们在测试数据集上的准确性,该函数比较y_test中存储的值和我们模型在y_pred中生成的预测值:
for model in models:
    model.fit(X_train, y_train)
    y_pred = model.predict(X_test)

    print(f'{type(model).__name__}: {accuracy_score(y_pred, y_test)}')

同样,fit()方法用于在X_trainy_train上训练每个模型,而predict()用于让模型对X_test进行预测。这将产生类似以下的输出:

KNeighborsClassifier: 0.8792
SVC: 0.8956
GradientBoostingClassifier: 0.8876

从这里,我们可以看到SVC模型表现最好,这在某种程度上是预期的,因为它是使用的三种模型中最复杂的模型。在实际的模型选择过程中,您可能会加入更多的任务,比如交叉验证,以确保最终选择的模型是最佳选项。

这是我们模型选择练习的结束。通过这个练习,我们熟悉了使用 scikit-learn 模型的一般流程。正如我们所看到的,fit/predict API 在库中实现的所有模型中都是一致的,这为 Python 程序员提供了高度的灵活性和便利性。

这个练习也结束了推断统计的一般主题。

注意

要访问本节的源代码,请参阅packt.live/2BowiBI

您也可以在packt.live/3dQdZ5h上线运行此示例。

在本章的下一个和最后一节中,我们将迭代许多其他库,这些库可以支持各种特定的统计程序。

Python 的其他统计工具

在上一章中,我们考虑了 Python 的三个主要库,它们构成了常见数据科学/科学计算流程的大部分内容:NumPy 用于多维矩阵计算,pandas 用于表格数据操作,Matplotlib 用于数据可视化。

在这个过程中,我们还讨论了一些支持工具,这些工具很好地补充了这三个库;它们是 seaborn 用于实现复杂可视化、SciPy 用于统计和科学计算能力、scikit-learn 用于高级数据分析需求。

不用说,还有其他工具和库,即使它们在我们的讨论中没有很好地融入,也为科学计算中的特定任务提供了独特而强大的功能。在本节中,我们将简要讨论其中一些,以便我们可以全面了解 Python 工具可用于哪些特定任务。

这些工具包括:

  • statsmodels:这个库最初是 SciPy 整体生态系统的一部分,但最终分拆成了自己的项目。该库提供了广泛的统计测试和分析技术、模型和绘图功能,全部组合成一个具有一致 API 的综合工具,包括时间序列分析能力,而其前身 SciPy 在这方面有些欠缺。

statsmodels 的主网站可以在这里找到:www.statsmodels.org/stable/index.html

  • PyMC3:在称为贝叶斯统计学的统计学子领域中,有许多独特的概念和程序,可以在建模和预测方面提供强大的能力,而这些能力在我们考虑的库中得不到很好的支持。

在 PyMC3 中,贝叶斯统计建模和概率编程技术被实现为其自己的生态系统,具有绘图、测试和诊断能力,这使得它成为可能是最受欢迎的概率编程工具,不仅适用于 Python 用户,还适用于所有科学计算工程师。

有关如何开始使用 PyMC3 的更多信息,请访问其主页docs.pymc.io/

  • SymPy:远离统计学和机器学习,如果您正在寻找一个支持符号数学的 Python 库,SymPy 很可能是您最好的选择。该库涵盖了代数、微积分、离散数学、几何和与物理相关的应用等广泛的核心数学子领域。SymPy 也以其相对简单的 API 和可扩展的源代码而闻名,这使得它成为寻找 Python 中符号数学库的用户的热门选择。

您可以从 SymPy 的网站了解更多信息www.sympy.org/en/index.html

  • Bokeh:我们列表中的最后一个条目是一个可视化库。与 Matplotlib 或 seaborn 不同,Bokeh 是一个专门设计用于交互和网页浏览的可视化工具。Bokeh 通常是可视化工程师的首选工具,他们需要在 Python 中处理大量数据,但希望生成交互式报告/仪表板作为 Web 应用程序。

要阅读官方文档并查看一些示例的画廊,您可以访问主网站docs.bokeh.org/en/latest/index.html

这些库为各自的统计学和数学子领域提供了很好的支持。同样,也总是可能找到其他符合您特定需求的工具。使用像 Python 这样受欢迎的编程语言的最大优势之一是,许多开发人员每天都在努力开发新的工具和库,以满足各种目的和需求。到目前为止,我们讨论过的库将帮助我们完成大部分统计计算和建模的基本任务,然后我们可以整合其他更高级的工具来进一步扩展我们的项目。

在我们结束本章之前,我们将通过一项活动来巩固我们迄今为止学到的一些重要概念。

活动 3.01:重新审视社区和犯罪数据集

在这个活动中,我们将再次考虑我们在上一章中分析过的“社区和犯罪”数据集。这一次,我们将应用本章学到的概念,从这个数据集中获得额外的见解:

  1. 在存储数据集的同一目录中,创建一个新的 Jupyter 笔记本。或者,您可以再次在packt.live/2CWXPdD下载数据集。

  2. 在第一个代码单元格中,导入我们将使用的库:numpypandasmatplotlibseaborn

  3. 与上一章一样,读取数据集并打印出它的前五行。

  4. 用 NumPy 中的nan对象替换每个'?'字符。

  5. 关注以下列:'population'(包括给定地区的总人口数量)、'agePct12t21''agePct12t29''agePct16t24''agePct65up',每个列中包括该人口中不同年龄组的百分比。

  6. 编写代码,在我们的数据集中创建包含这些年龄组实际人数的新列。这些应该是列'population'中的数据和每个年龄百分比列的乘积。

  7. 使用 pandas 的groupby()方法计算每个州不同年龄组的总人数。

  8. 调用我们数据集上的describe()方法,打印出其各种描述性统计信息。

  9. 关注'burglPerPop''larcPerPop''autoTheftPerPop''arsonsPerPop''nonViolPerPop'列,每个列描述了各种犯罪(入室盗窃、偷窃、汽车盗窃、纵火和非暴力犯罪)每 10 万人中的数量。

  10. 在一个单一的可视化中,通过箱线图来展现这些列中数据的分布。从图中识别出五种犯罪中哪一种最常见,哪一种最不常见。

  11. 关注'PctPopUnderPov''PctLess9thGrade''PctUnemployed''ViolentCrimesPerPop''nonViolPerPop'列。前三个描述了给定地区人口中属于相应类别的百分比(生活在贫困线以下的人口比例、25 岁以上没有完成九年级教育的人口比例、劳动力中失业的人口比例)。最后两个给出了每 10 万人中的暴力和非暴力犯罪数量。

  12. 计算适当的统计对象,并相应地对其进行可视化以回答这个问题。识别与彼此相关性最大的一对列。

注意

此活动的解决方案可在 659 页找到。

总结

本章正式阐述了统计学和机器学习中的各种入门概念,包括不同类型的数据(分类、数值和有序),以及统计学的不同子类别(描述性统计和推断统计)。在我们的讨论中,我们还介绍了相关的 Python 库和工具,可以帮助促进相应主题的程序。最后,我们简要介绍了一些其他 Python 库,如 statsmodels、PyMC3 和 Bokeh,它们可以在统计和数据分析中提供更复杂和高级的用途。

在下一章中,我们将开始书中的新部分,涉及数学密集型主题,如序列、向量、复数和矩阵。具体来说,在下一章中,我们将深入研究函数和代数方程。

PSQ66

WRC42

第四章:函数和代数与 Python

概述

在前一章中,我们讨论了大量与统计相关的主题,包括变量、描述性统计和推断。在本章中,我们回到数学的一般主题,并研究其中两个最基本的组成部分:函数和代数。这些主题将与它们在 Python 中的实现并行介绍和理论讨论。掌握这些主题将使您能够解决一些可以使用数学和编程解决的最常见的现实生活问题,我们将在本章的最后一个活动中看到一个例子。

通过本章结束时,您将对数学函数的概念以及域、范围和绘图等相关概念有牢固的掌握。此外,您还将学习如何通过手工解决代数方程或方程组,以及通过 Python 编程解决。

介绍

虽然数学可以分为多个子领域,如微积分、数论和几何学,但有一些基本概念是每个数学学生都必须熟悉的。其中两个概念是函数和代数,这是本章的主要内容。

函数是描述从一个对象到另一个对象的某种映射的一般数学过程。函数可以接受一个数字并产生另一个数字。它还可以接受一组数字或向量并返回单个输出,甚至多个输出。函数如此重要,以至于它们也广泛应用于其他科学领域,包括物理学、经济学,正如我们在本书中所看到的,编程。

本章的目标是在数学背景下建立关于函数概念的具体基础讨论。这个讨论将与其他相关主题结合,如域、范围和函数的绘图。对这些主题的扎实理解将使您能够在以后的章节中探索更复杂的数学分析。

除了函数,我们还将考虑代数,这是数学中最重要的部分之一。虽然这个术语通常表示对数学对象的分析和操作,但我们将在代数方程和方程组的背景下考虑它。这将使我们能够研究它在数学中的重要作用,同时学习如何将这些知识应用于实际问题。

函数

如前所述,函数是数学对象,通常接受一些输入并产生所需的输出。因此,函数通常被认为是一个数学对象到另一个数学对象的映射。当函数接收输入并随后产生输出时,还可以使用关系的概念,这强调了函数本身建立的可能输入集和可能输出集之间的关系。

函数通常由小写字母f和括号表示,括号中包围着f接受的输入。这个符号,f(x),也表示f在接受x作为输入时产生的输出。例如,假设函数f输出其输入的平方;f可以表示为f(x) = x2。

我们看到,在 Python 中声明函数的语法也遵循这种约定。例如,要在 Python 中声明相同的平方函数,代码将如下所示:

def f(x):
    return x ** 2

当我们想要获得f作为其输入的值时,我们只需说我们在输入上调用f。例如:

print(f(2))
print(f(-3))

这段代码将分别打印出49。正如我们所知,函数返回的值也可以通过赋值存储在变量中。

函数最重要的特征之一是没有输入可以映射到不同的输出。一旦一个输入x与相应的输出f(x)关联起来,那个输出是确定的,不能有多个可能的值。另一方面,完全可能多个输入映射到相同的输出。换句话说,多个x的值可以导致f(x)成为一个常见值。

也有可能一个函数不需要接受任何输入,也不一定需要产生任何输出。例如,在编程的情境中,一个函数的工作是读取并返回特定文件中包含的数据,不需要接受任何输入。另一个例子是更新全局变量的值的函数,在这种情况下不需要返回任何东西。也就是说,这些函数可以被认为属于一类特定的一般函数的子集,因此我们的讨论仍将围绕具有输入和输出的函数展开。

在接下来的小节中,让我们考虑数学和编程中一些常见类型的函数。

常见函数

虽然每个函数在自己的方式上都是独特的,但有一些特殊的分类或家族函数,我们需要了解。这些是常数、线性、多项式、对数和指数函数,它们总结在下表中:

图 4.1:特殊函数家族表

图 4.1:特殊函数家族表

花点时间考虑我们表格的第三列,其中包含每个函数家族的样本函数的图。我们将在本节后面更深入地讨论函数的图的理论细节,但现在我们看到每个函数家族都给我们一个独特的图形风格;事实上,从它们的图形中识别函数是我们即将进行的练习的主题。

请注意,常数和线性函数实际上是多项式函数家族的子集(当x的较大幂的系数都为零时)。你可能已经注意到的另一个有趣的事实是对数函数的输入必须是正数,这就是为什么它的图不会延伸到y轴的左侧。相反,指数函数的输出(假设常数是正的)总是正的;相应地,它的图始终在x轴上方。这些观点直接过渡到我们的下一个主题:函数的域和值域。

域和值域

域和值域是函数概念中的两个基本概念。函数的域表示函数接受的所有可能输入的集合,而值域指定了所有可能输出的集合。

大多数情况下,可以通过考虑函数的公式表达来确定给定函数的域和值域。例如,线性函数f(x) = mx + c接受任何实数值的x来产生一个实数值的mx + c,因此它的域和值域都是实数集合R。另一方面,二次函数f(x) = x2*只产生非负输出,因此它的值域是非负实数集合。

函数的域和值域也可以通过其图来检查。考虑一个具有单个输入和单个输出的函数的图的投影:其域对应于图在x轴上的投影;同样,当图在y轴上投影时得到值域。这就是为什么我们可以说对数函数f(x) = ln(x)的域是正数集合。相反,指数函数f(x) = ex*的值域也是正数集合。

总的来说,函数的定义域和值域取决于函数本身的形式,并且可以对函数的各种行为提供高度信息。其中一个经常感兴趣的行为是函数的根,我们将在下一小节中讨论。

函数的根和方程

函数的根是使输出等于零的属于其定义域的值。再次强调,函数的根取什么值高度依赖于函数本身。仍然使用前面表中包含的例子,图 4.1,我们看到f(x) = mx + c接受* x = - c / m作为其唯一根,如果m不为零,而f(x) = ln(x)的唯一根是x = 1。一些函数可能有多个根:f(x) = x2 - 3x + 2x = 1x = 2作为其根,而f(x) = 0(其图对应于x轴)接受所有x的值作为其根。最后,如果一个函数的值域不包括 0,那么函数本身就没有任何根;这样的例子包括f(x) = exf(x) = x2 + 1f(x) = 3

找到函数f(x)的所有根的过程等同于解方程f(x) = 0。这里的方程一词表示我们有两个分开的量,f(x)0,它们在数学表达式中相等。解方程可以说是数学中最核心的任务之一,有多种技术可应用于特定类型的方程。

我们在这里只是作为函数主题的一部分介绍了方程的概念,我们将在本章的后面再回到它。现在,我们将继续讨论函数的最后一个重要组成部分:图。

函数的图

在前面的例子中,函数的图是输出行为的视觉表示,关于函数输入的。具体地说,通过函数图,我们旨在检查在函数范围内,随着函数的输入在其定义域内的变化,输出如何变化。

在编程的背景下,函数的图可以通过连接散点对应于函数在x轴上一组细粒度均匀间隔值上的各个值来生成。例如,假设我们想要可视化函数f(x) = x + 1-1010之间的图,我们首先使用 NumPy 声明相应的均匀间隔的x值:

x = np.linspace(-10, 10, 1000)

这个 NumPy 函数生成了一个在-1010之间的 1,000 个均匀间隔的数字的数组,这由x的输出所说明:

图 4.2:NumPy 生成的均匀间隔的数字

图 4.2:NumPy 生成的均匀间隔的数字

然后可以使用 Matplotlib 的plot()函数生成图:

plt.plot(x, x + 1)
plt.show()

请记住,由于矢量化,表达式x + 1将计算出一个与x大小相同的数组,其元素是x的元素加 1。这是 Python 语言的一个很好的特性,或者更具体地说,是 NumPy 库,它允许我们快速生成构成函数图的点。

这段代码应该产生以下可视化:

图 4.3:Python 中 f(x) = x + 1 的图

图 4.3:Python 中 f(x) = x + 1 的图

相同的逻辑可以应用于不同形式的函数。我们将在下一个练习中回到这个过程。现在,让我们回到我们的理论讨论。

函数的图是其公式表达的直观可视化,并包含了我们需要了解的有关该函数的所有信息。特别地,我们已经论证过函数图可以帮助我们确定函数的定义域和值域。此外,给定一个图,我们甚至可以确定该图是否是一个有效函数的图。这是通过垂直线测试来完成的,该测试规定如下。

给定二维平面上的图形,如果对于每条垂直线(与y轴平行的每条线),图形有多于一个交点,则它不是有效函数的绘图。这是我们之前所述的函数要求的一个直接推论:一个单一的输入不能映射到多于一个输出。如果一个图形确实与一条垂直线有至少两个交点,那么这意味着x轴上的一个点可以映射到y轴上至少两个点,这必然意味着这不是一个函数的绘图。

例如,考虑单位圆的以下图形(其中心为O(0, 0),半径等于1),它未通过垂直线测试,由红线表示:

图 4.4:单位圆的垂直线测试

图 4.4:单位圆的垂直线测试

这意味着单位圆实际上不是我们考虑的二维平面上函数的绘图。

这个话题也标志着我们对函数定义的介绍结束。在我们进入下一节之前,让我们通过一个旨在巩固我们迄今学到的所有概念的练习。

练习 4.01:从图形中识别函数

在这个练习中,我们将练习分析给定图形的函数行为的技能。这个过程将使我们能够结合我们之前提到的各种主题,并理解函数行为与其图形之间的联系。

对于以下每个图形:

  • 确定它是否对应于一个函数,如果是,继续下一步。

  • 确定函数的定义域、值域和公式(提示:使用标记的刻度)。

  • 确定函数是否至少有一个根。

  • 使用 Python 重现绘图(轴及其箭头不是必需的)。

  1. 水平线图 4.5:水平线

图 4.5:水平线

该图对应于一个函数。函数是f(x) = 2,定义域是实数集,值域是{2}。函数没有任何根。

以下代码可用于重现绘图:

import numpy as np
import matplotlib.pyplot as plt
x = np.linspace(-5, 5, 1000)
plt.plot(
    x,  # evenly spaced numbers in the x-axis
    np.ones(1000) * 2  # all 2s in the y-axis
)
plt.show()
  1. 旋转的二次曲线图 4.6:旋转的二次曲线

图 4.6:旋转的二次曲线

该图不对应于一个函数,因为它未通过垂直线测试。

  1. 直线图 4.7:直线

图 4.7:直线

图对应于函数f(x) = x的绘图。这个函数的定义域和值域都是一组实数。函数有一个根:x = 0

以下代码可用于重现绘图(使用与水平线解决方案中相同的x变量):

plt.plot(x, x)
plt.show()
  1. 二次曲线图 4.8:二次曲线

图 4.8:二次曲线

该图对应于函数f(x) = x2 的绘图。函数的定义域是所有实数的集合,而值域是非负数的集合。函数也有一个根:x = 0

以下代码可用于重现绘图(感谢 NumPy 数组的矢量化):

plt.plot(x, x ** 2)
plt.show()

通过这个快速练习,我们巩固了对函数和许多相关概念的理解,包括定义域、值域、垂直线测试以及使用 Python 绘制图形的过程。

注意

要访问此特定部分的源代码,请参阅packt.live/2YRMZhL

您也可以在packt.live/2YSBgj2上在线运行此示例。

在下一节中,我们将讨论函数的转换。

函数转换

变换是数学函数中最重要的概念之一。正如术语的名称所暗示的那样,函数的变换是在将函数的返回值通过特定的变换技术(如移动或缩放)后得到的输出。在最一般的意义上,我们可以将这个过程看作是复合函数:将一个函数的输出通过另一个函数。然而,由于它们的特定特性和有用性,有特定类型的函数通常被用作变换,我们将在接下来的小节中介绍它们,从移动开始。

由于变换最容易在应用到函数的图表上时理解,我们也将根据这一点来进行下面的讨论。

移动

函数的移动发生在函数的图表沿着x轴和/或y轴的特定距离上移动。例如,在下面的可视化中,蓝色曲线是函数f(x) = x2的图表,而红色曲线是同一图表向上移动1*后的图表:

图 4.9:函数的垂直移动

图 4.9:函数的垂直移动

我们看到属于f(x) = x2图表的每个点(x, y)实际上被转换为(x, y + 1)。由于如果(x, y)属于f(x)的图表,那么y = x2,移动的输出本质上是函数f(x) = x2 + 1的图表。

这个例子让我们能够概括垂直移动的每种情况:任何给定函数f(x)的垂直移动的输出是新函数f(x) + c。在我们的例子中,这是c = 1,对应着向上水平移动1。然而,c也可以是负数,这种情况下函数向下移动,甚至可以是零,这种情况下变换是恒等变换,函数的图表不会改变。

我们看到当一个改变被加到(或者从中减去)函数的输出值时,或者换句话说,图表上的点的y坐标发生了垂直移动。同样,通过对函数的输入值进行改变(当一个数字被加到点的x坐标时),可以对函数进行水平移动。

一般来说,当函数f(x)的图表在二维平面上向左移动了一个量c时,结果图表是函数f(x + c)的图表。相反,函数f(x - c)的图表对应于函数f(x)向右移动c的水平移动。

仍然使用函数f(x) = x2的例子,以下图表可视化了函数向右移动2,换句话说,函数f(x) = (x - 2)2的图表:

图 4.10:函数的水平移动

图 4.10:函数的水平移动

也可以结合垂直移动和水平移动来变换函数,使整个图表朝任意方向移动。例如,假设我们想要将函数f(x) = x2向东北方向(上和右)移动一个向量(2, 1),那么变换后的函数将是f(x) = (x - 2)*2 + 1

总的来说,变换作为一种移动函数图表的方式,垂直和/或水平地移动。因此,移动也是一种仿射变换,它被定义为将图表的所有点以相同方向和恒定距离移动。然而,移动不能改变图表的大小和比例。在下一节中,我们将讨论另一种可以改变大小和比例的变换方法:缩放。

缩放

缩放变换通过特定的比例因子拉伸或收缩函数的图形,具体取决于缩放因子。考虑以下可视化中对我们熟悉的函数f(x) = x2*应用缩放变换的输出:

图 4.11:函数的缩放

图 4.11:函数的缩放

通过前面的缩放变换,函数图上的每个点(x, y)都被转换为(x, y / 2),有效地将图形水平缩放,使其更靠近x轴。变换后的图形对应于函数f(x) = x2 / 2,比原始图形更宽,因为曲线被缩放到更靠近x轴。除了原点(0, 0)之外,原始图形的任何点都被拉下,使其更靠近x轴。这也会使图形的整体斜率变得不那么陡峭。相反,将变换后的图形远离x轴的缩放变换可能是f(x) = 2x2,或f(x) = 3x2,从而使变换后的图形的斜率更陡。

在这些变换中,我们将函数图的y坐标乘以一个常数,这使我们能够控制相对于x轴的缩放。类似地,当通过将函数图的x坐标乘以一个常数来应用缩放时,图形将相对于y轴被拉伸或收缩。

总的来说,缩放变换的效果由缩放因子控制——图形的xy坐标被乘以的常数。正的缩放因子不会改变图形相对于坐标轴的相对位置。

当进行垂直缩放(即缩放y坐标)时,小于 1 的正因子将图形靠近x轴,而大因子将图形远离轴。水平缩放(即缩放x坐标)也是如此;小于 1 的正因子将图形y轴,而大因子将图形

虽然这种拉/推效应对于负缩放因子也是一样的,但当函数被负常数缩放时,其图形将沿着相应的轴翻转

图 4.12:函数的负缩放

图 4.12:函数的负缩放

就像我们在平移的情况下看到的那样,可以同时对函数应用多个缩放变换,以获得组合效果。

总的来说,平移和缩放构成了函数变换中最常见的两种方法。在下一个练习中,我们将练习识别这两种变换的技能,从它们对函数图的影响来识别。

练习 4.02:函数变换识别

在这里,我们的目标是分析特定变换对函数图的影响,并识别变换的类型和特征。这个练习将帮助我们熟悉变换如何操纵函数的行为。

以下图形包括三次函数f(x) = x3 - x的绘图,以及正弦函数f(x) = sin(x)的绘图,也因其周期性而常被称为正弦波:

图 4.13:三次函数和正弦波的图形

图 4.13:三次函数和正弦波的图形

每个图形包括这两个函数绘图中的一个作为蓝色曲线,以及从中得到的特定变换的结果作为红色曲线。对于每个图形:

  1. 识别可能产生这种效果的变换。

  2. 如果是平移,确定平移向量的值(即上/下移动多少,左/右移动多少)。

  3. 如果是缩放,确定缩放因子是正数还是负数,并估计其值(使用刻度标记作为提示)。

  4. 通过使用 Python 生成相同的图表来验证您的估计(不包括坐标轴和箭头)。

现在让我们来看一下图表:

  1. 三次曲线的第一次变换图 4.14:三次曲线的第一次变换

图 4.14:三次曲线的第一次变换

红色曲线是原始正弦波的移动结果。它是向左水平移动2,因此移动是-2

以下代码可用于重现绘图:

import numpy as np
import matplotlib.pyplot as plt
x = np.linspace(-5, 5, 1000)
plt.plot(x, x ** 3 - x, c='blue')  
plt.plot(x, (x + 2) ** 3 - (x + 2), c='red')  
plt.ylim(-5, 5)
plt.show()
  1. 三次曲线的第二次变换图 4.15:三次曲线的第二次变换

图 4.15:三次曲线的第二次变换

红色曲线是原始正弦波的复合移动的结果。它是向右水平移动2,再向上垂直移动2

以下代码可用于重现绘图(使用相同的变量x):

plt.plot(x, x ** 3 - x, c='blue')  # original func
plt.plot(x, (x - 2) ** 3 - (x - 2) + 2, c='red')  # transformed func
plt.ylim(-5, 5)
plt.show()
  1. 正弦波的第一次变换图 4.16:正弦波的第一次变换

图 4.16:正弦波的第一次变换

红色曲线是通过缩放得到的。它是沿着x轴的垂直缩放,缩放因子为2

以下代码可用于重现绘图(使用相同的变量x):

plt.plot(x, np.sin(x), c='blue')  # original func
plt.plot(x, np.sin(x) * 2, c='red')  # transformed func
plt.ylim(-5, 5)
plt.show()
  1. 正弦波的第二次变换图 4.17:正弦波的第二次变换

图 4.17:正弦波的第二次变换

红色曲线是通过缩放得到的。它是沿着y轴的水平缩放,将曲线拉近到2的因子,因此缩放因子是2

以下代码可用于重现绘图(使用相同的变量x):

plt.plot(x, np.sin(x), c='blue')  # original func
plt.plot(x, np.sin(x * 2), c='red')  # transformed func
plt.ylim(-5, 5)
plt.show()

因此,我们已经学会了通过检查变换对函数图形的影响来识别变换类型及其变化程度。这个练习也结束了本章关于函数的主题。

注意

要访问此特定部分的源代码,请参阅packt.live/2D2U7iR

您还可以在packt.live/2YPtHcJ上在线运行此示例。

在下一节中,我们将深入探讨一个之前简要提到的相关概念:方程。

方程

当一个函数被赋予值 0 时,就形成了一个方程,我们被要求找到满足方程的函数输入值,通常是x。这些值被称为原始函数的根。找到这些值的过程称为解方程,这是数学中一个丰富的主题,特别是代数。

在本节中,我们将讨论手工解方程的两种基本方法,并检查 Python 中可用的计算工具,以便自动解方程的过程。我们将从第一种方法开始,即代数操作。

代数操作

虽然我们将这个过程归类为一种方法,但代数学通常是一种将方程转化为更简单形式的技术,以便可以轻松找到解。一些典型的转化方程的方法包括在方程的两边加/减一个常数,将方程的两边乘/除以一个非零常数,或将方程的所有项移到一边。

一个简单的例子是3x - 5 = 6方程。

要解出x,我们首先将左边的数字5移到右边,即在方程的两边都加上5。这给我们3x = 11

最后,我们将两边都乘以1/3,这样我们就得到了变量x的值,即x = 11/3

这个简单的例子说明了代数操作整个过程的思想,以便我们可以隔离x的值。让我们通过另一个例子来强调这一点。假设我们有一个方程:3x - 7 - 2(19x - 7) = (5x + 9) / 3 + 12

虽然这个方程似乎比第一个复杂得多,但过程实际上是相同的。我们首先展开括号内的项,并将涉及x的项收集到一组中,然后将剩余的项收集到另一组中。

这将给我们以下代数变换:

图 4.18:替换值以找到 x 的值

](image/B15968_04_18.jpg)

图 4.18:替换值以找到 x 的值

我们看到,总的来说,这个过程是相当简单的,特别是当我们只处理x线性项时。术语线性表示的是变量x乘以一个常数的数量。总的来说,这两个方程的一般术语是线性方程,它只包含x的线性项。正如我们所见,解线性方程是一个直接的过程,即使我们手工进行解也是如此。

相比之下,多项式方程是具有大于1的次数的变量x的项的方程。多项式方程可以通过一种特定的技术有效地解决,这将在下一小节中讨论。现在,让我们考虑一个非线性方程的例子,3ex+2 + 3 = 2(ex + 100),它可以简单地通过代数操作来解决。

请注意,e是自然对数函数的数学常数;它约为2.71828

注意

您可以在mathworld.wolfram.com/e.html找到有关这个常数的更多信息。

要解决这个方程,我们首先展开这个方程中的项,如下所示:3exe2 + 3 = 2ex + 200

左侧的变换是可能的,因为对于所有正数a和实数xy,恒等式ax + y = ax ay成立。现在,我们看到即使这个方程中没有x的线性项,我们仍然可以采用隔离涉及x*的项并将它们分组的策略:

图 4.19:替换方程中的值以找到 ex

图 4.19:替换方程中的值以找到 ex

现在,有了ex 的值,我们想要提取出x项。为此,我们将自然对数函数f(x) = ln(x)应用到方程的两边。由于对于所有实数xln( ex ) = x,这一步将把方程的左侧转换为简单的x

图 4.20:替换方程中的值以找到 x

图 4.20:替换方程中的值以找到 x

总的来说,使用代数变换来解方程的一般思想是将涉及x的所有项分组并将它们操纵成一个单一项。同样,这种策略并不总是适用于任何方程,因为有时不可能将所有x项简化为一个单一项。这是多项式方程的情况,我们将在下一种解方程的方法——因式分解的背景下讨论它。

因式分解

虽然它在技术上属于代数的大类,因式分解特指将给定方程操纵成以下形式的过程:

图 4.21:因式分解公式

图 4.21:因式分解公式

如果这些项的乘积等于零,那么至少其中一个项必须等于零才能满足方程。换句话说,解原方程等价于解每个方程f1(x) = 0f2(x) = 0,…,和fn(x) = 0。理想情况下,我们希望每个这些fi(x) = 0方程都比原方程更容易解决。

让我们考虑一个起始例子:x2 = 100

使用恒等式x2 - y2 = (x - y) (x + y)对于所有实数xy值,方程等价于(x - 10) (x + 10) = 0

由于它们的乘积为零,x - 10x + 10也必须为零。解这两个方程给出了原方程的解:x = 10x = -10

虽然这是一个相当简单的例子,但它能够说明一些要点。首先,通过将方程分解为相乘等于 0 的不同项,问题被转化为一组更简单的子问题。此外,通过分解,我们可以实现简单的加法/乘法操作无法实现的事情:解多项式方程。

让我们考虑下一个例子:x3 - 7x2 + 15x = 9

我们看到,即使所有涉及x的项已经被分组在一起,也不清楚我们应该如何进行简单的代数运算。

在这里,一个有洞察力的数学学生可能会注意到这个方程接受x = 1x = 3作为解(因为将这些值代入使方程的左边评估为 0)。多项式方程接受x = c作为解不仅意味着通过在方程中用c替换x,它将评估为零,而且还意味着方程本身可以被分解成形式(x - c) g(x) = 0,其中g(x)是方程的另一个分解项。这个技巧还有另一个名字,鲁菲尼法则,关于这个你可以在mathworld.wolfram.com/RuffinisRule.html找到更多信息。考虑到这一点,我们尝试根据(x - 1)的项来分解给定的方程如下:

图 4.22:分解给定的方程

](image/B15968_04_22.jpg)

图 4.22:分解给定的方程

记住方程也接受x = 3作为解,我们继续将(x2 - 6x + 9)分解为(x - 3)乘以另一个项。如果你熟悉二次方程公式,你可能已经能够判断方程可以分解为(x - 1) (x - 3)2 = 0

最后,我们证明了给定的方程接受两个解:x = 1x = 3

  • n 的多项式方程是指x的最大次数为n的方程。总的来说,我们希望将这样的方程分解为n个不同的因子。这是因为可以数学证明,n次多项式方程最多只能有n*个唯一解。

换句话说,如果我们成功地将一个方程转化为n个不同的因子,那么这些因子中的每一个都是x的线性项,可以很容易地使用我们上面讨论的第一种方法来解决。例如,方程2x3 - 7x2 + 7x - 2 = 0可以分解为(x - 1) (x - 2) (2x - 1) = 0,这给出了三个解:x = 1x = 2x = 1 / 2

当然,有些情况下,n次多项式方程不能被分解为xn个不同线性项。考虑以下例子方程x3 + 4x - 5 = 0

这接受了x = 1的解,因此有一个因子为(x - 1)

图 4.23:x = 1 的因子

](image/B15968_04_23.jpg)

图 4.23:x = 1 的因子

现在,考虑x2 + x + 5这个项。如果我们尝试将各种x的值代入方程,我们会发现没有一个值能满足方程。这表明这个方程没有解,或者更具体地说,x2 + x + 5对所有的x值都大于 0,我们将证明这个陈述。

当我们想要证明一个变量的二次函数始终大于 0 时,我们可以利用(g(x))2 对于所有实数值的x和所有函数g始终是非负的这一事实(这是因为任何实数的平方都是非负的)。如果我们能够将x2 + x + 5重写为(g(x))2 + c的形式,其中c是一个正常数,我们就可以证明这个项始终是正的。

在这里,我们使用完成平方技术x项分组成一个平方。这种技术涉及使用恒等式(a + b)2 = a2 + 2ab + b2对所有的ab值来构造(g(x))2。具体来说,项x可以重写为2 x (1/2),因为我们需要它以 2 乘以x再乘以另一个数字的形式。所以,我们有x2 和2 x (1/2);因此我们需要(1/2)2 = 1/4来将这三个数字的和完成为一个平方:x*2 + x + 1/4 = x2 + 2 x (1/2) + (1/2)2 = (x + 1/2)2。

因此整个项可以转换为x2 + x + 5 = (x2 + x + 1/4) + 19/4 = (x + 1/2)2 + 19/4

(x + 1/2)2 对于任何实数值的x都是非负的,因此整个项(x + 1/2)2 + 19/4大于或等于19/4。这就是说,没有任何实数值的x使得项x2 + x + 5等于0;换句话说,方程x2 + x + 5 = 0没有任何解。

这就是解决多项式方程的因式分解技术的概述。最后,关于方程的主题,我们将讨论使用 Python 自动化解方程的过程。

使用 Python

除了手动解方程的两种方法之外,我们还可以利用 Python 的计算能力自动解决任何方程。在本节中,我们将在SymPy库的背景下探讨这个过程。

广义上说,SymPy 是 Python 中用于符号数学的最佳库之一,这是一个涵盖符号(如xyf(x))的代数计算的总称。虽然 SymPy 提供了广泛的 API,包括对不同数学子领域的支持,包括微积分、几何、逻辑和数论,但在本章中,我们只会探索它解方程和(在下一节中)解方程组的选项。

注意

您可以在其官方网站docs.sympy.org/latest/index.html上找到有关该库的更多信息。

首先,我们需要为我们的 Python 环境安装该库。这个过程,像往常一样,可以通过pipconda来完成。运行以下命令中的任意一个:

$ pip install sympy
$ conda install sympy

在成功安装了该库之后,让我们使用一个特定的示例来探索它提供的选项,即我们在上一节中考虑的方程:x3 - 7x2 + 15x = 9

作为符号数学工具,SymPy 提供了一个简单的 API 来声明变量和函数。为此,我们首先从 SymPy 库中导入Symbol类并声明一个名为x的变量:

from sympy import Symbol
x = Symbol('x')

当在 Jupyter 笔记本中打印x时,我们会看到这个字母实际上被格式化为一个数学符号:

图 4.24:Jupyter 笔记本中的 SymPy 符号

图 4.24:Jupyter 笔记本中的 SymPy 符号

现在,为了解决给定的方程,我们从sympy.solvers包中导入solve()函数。这个solve()函数接受一个包含 SymPy 符号的表达式(在这种情况下,就是我们的变量x),并找到使表达式等于 0 的x的值。换句话说,要解x3 - 7x2 + 15x = 9,我们输入以下代码:

from sympy.solvers import solve
solve(x ** 3 - 7 * x ** 2 + 15 * x - 9, x)

此代码片段返回x的解列表,这种情况下是[1, 3]。我们看到这对应于我们之前通过因式分解找到的解。

让我们检查另一个我们之前解决过的例子:3ex + 2 + 3 = 2(ex + 100. 记住这个方程有一个根,x = ln( 197 / (3e2 - 2) ),约为 2.279。现在,我们将这个方程输入到solve()函数中(在导入内置的math库中的常数e之后):

from math import e
solve(3 * e ** (x + 2) + 3 - 2 * (e ** x + 100), x)

这将给我们以下输出:

[2.27914777845756]

正如我们所看到的,这与我们代数分析得到的解决方案相同。总的来说,通过声明变量并将任何形式的函数作为solve()函数的输入,SymPy 为我们提供了一种灵活和方便的方式来在 Python 中计算解方程。

这个话题也结束了我们对方程和找到它们解决方法的讨论。在我们进入本章的下一个话题之前,让我们通过一个练习来练习我们在本节学到的知识。

练习 4.03:盈亏分析简介

盈亏分析是经济学和金融工程中的常见实践。盈亏分析的目标是找到企业收入平衡成本的特定时间点。因此,找到这些时间点对于对业务所有者和利益相关者非常重要,他们希望知道是否以及何时会获利。

这种情景可以很容易地使用数学变量和函数进行建模,这是我们将在本练习中进行的。具体来说,我们的目标是通过解决盈亏平衡点来对一个简单的业务进行建模和进行盈亏分析。最终,您将更加熟悉使用数学模型、函数和变量来表示现实生活情况的过程。

情景:一家汉堡餐厅每卖出一份汉堡的原料成本为 6.56 美元。它还每个月有固定成本 1312.13 美元,用于厨师工资、租金、水电费等。餐厅的老板想进行盈亏分析,以确定何时收入将覆盖成本。

  1. 在第一个代码单元格中创建一个新的 Jupyter 笔记本,并导入 NumPy、Matplotlib 和 SymPy:
import numpy as np
import matplotlib.pyplot as plt
from sympy.solvers import solve
from sympy import Symbol
  1. 假设餐厅将每个卖出的汉堡的价格定为 8.99 美元,让x表示每个月需要卖出的汉堡的数量,以便收入等于成本。写出这种情况下x的方程。

x是卖出的汉堡的数量时,8.99x是餐厅将获得的收入,而6.56x + 1312.13是餐厅将发生的成本。因此x的方程将是:

8.99x = 6.56x + 1312.13

  1. 通过手工解出x并在 Jupyter 笔记本的下一个单元格中使用 Python 验证结果。为了测试目的,将 SymPy 返回的解决方案列表存储到名为sols的变量中。

通过简单的代数变换,我们可以解出xx = 1312.13 / (8.99 – 6.56) = 539.97。因此,餐厅需要大约卖出 540 份汉堡才能实现盈亏平衡。

以下代码可以使用 SymPy 来解决x的问题:

x = Symbol('x')
sols = solve(8.99 * x - 6.56 * x - 1312.13, x)

sols变量应该有值[539.971193415638],这对应于我们的解决方案。

  1. 不要解出x作为盈亏平衡点,构建一个关于x的函数,表示每个月餐厅的总利润(收入减去成本)。

该函数应为f(x) = 8.99x - 6.56x - 1312.13 = 2.43x - 1312.13

  1. 在 Jupyter 笔记本的下一个代码单元格中,使用 NumPy 和 Matplotlib 绘制这个函数,x的值在 0 到 1000 之间,并在 0 处绘制一条水平线,颜色应为黑色:
xs = np.linspace(0, 1000, 1000)
plt.plot(xs, 2.43 * xs - 1312.13)
plt.axhline(0, c='k')
plt.show()

这应该产生以下图表:

图 4.25:盈亏分析的可视化

图 4.25:盈亏分析的可视化

我们的利润曲线与水平线的交点代表盈亏平衡点。在这种情况下,我们看到它大约在x坐标为540处,这对应于实际的盈亏平衡点。

  1. 假设餐厅平均每月销售 400 个汉堡,现在让x成为餐厅可以设置的汉堡价格,以便他们可以实现盈亏平衡。写下这种情况下x的方程。

x是汉堡的价格时,400x是餐厅将获得的利润,而(400) 6.56 + 1312.13 = 3936.13(每个汉堡6.56美元和固定金额1312.13美元)是餐厅将发生的成本。因此,x的方程将是400x = 3936.13

  1. 通过手工求解x,并在 Jupyter 笔记本中使用 SymPy 验证结果。将 SymPy 返回的解列表存储在名为sols1的变量中。

通过将两边都除以 400,可以简单地解出方程,得到x = 9.84。解决相同方程的 Python 代码如下,也得到相同的结果:

sols1 = solve(400 * x - 3936.13, x)
sols1
  1. 在下一个代码单元格中,绘制代表利润和成本之间差异的函数,x值在010之间,以及水平线在0处:
xs = np.linspace(0, 10, 1000)
plt.plot(xs, 400 * xs - 3936.13)
plt.axhline(0, c='k')
plt.show()

这应该产生以下图表:

图 4.26:盈亏分析的可视化

图 4.26:盈亏分析的可视化

再次,两条线的交点(代表盈亏平衡点)与我们得出的实际解相符。

这就是我们练习的结束。在其中,我们通过建模一个样本现实生活中的业务,使用数学函数和变量,介绍了盈亏分析的概念。我们已经学会了如何找到要生产的产品数量,以及设置盈亏平衡的正确价格。

注意

要访问此特定部分的源代码,请参阅packt.live/3gn3JU3

您也可以在packt.live/3gkeA0V上在线运行此示例。

当然,现实生活中的业务场景更加复杂,涉及更多因素。我们将在本章末尾的活动中回到盈亏分析的任务,但在此之前,我们需要讨论本章的最后一节:方程组。

方程组

方程是我们需要通过解出特定变量的值来满足的相等式。在方程组中,我们有涉及多个变量的多个方程,目标仍然是相同的:解出这些变量的值,使系统中的每个方程都得到满足。

总的来说,系统可以有任意数量的方程。然而,可以严格证明,当系统的方程数量不等于其变量数量时,系统要么有无穷多个解,要么没有解。在本节中,我们只考虑这两个数字相等的情况。

此外,我们将考虑两种不同类型的方程组:线性方程组和非线性方程组。我们将考虑解决这两种类型的方程组的方法,无论是手工还是使用 Python。首先,让我们讨论线性方程组的概念。

线性方程组

与线性方程类似,线性方程组只包含常数和其变量的线性项,由线性方程组组成,这些方程也只包含其变量和常数的线性组合。

这种系统的一个简单示例如下:

图 4.27:线性方程组的示例

图 4.27:线性方程组的示例

正如我们所看到的,这个方程组有两个变量:xy。这两个方程中包含这些变量与常数(线性项)相乘以及常数本身。

要解决这个方程组,您可能已经注意到,如果我们将两个方程的各自的两边相加,我们将得到一个额外的方程,3y=8,然后我们可以解出y=8/3,随后解出x=5-8/3=7/3

总的来说,这种方法涉及将我们提供的方程分别乘以不同的常数并将它们相加,以依次消除变量。目标是获得一个只剩下单个变量的线性项(可能还有常数)的方程。然后我们可以解出这个变量。然后将这个变量的解值代入原方程,这个过程将继续进行下去,直到解出所有的变量。

尽管当我们拥有的变量/方程的数量相对较小时,这个过程是直接的,但随着这个数量的增加,它可能会变得非常混乱。在本小节中,我们将考虑一种称为行简化高斯消元的方法,这将帮助我们规范化并自动化解方程组的过程。

假设我们被要求解下面的一般线性方程组,其中有n个变量和n个方程:

图 4.28:具有 n 个变量和 n 个方程的线性方程组

图 4.28:具有 n 个变量和 n 个方程的线性方程组

在这里,cij 是第i个方程中变量xj 的常数系数。同样,这些cij 值可以取任何常数值,这个方程组是任何线性方程组的最一般形式。

为了应用行简化方法,我们构造了所谓的增广矩阵,即以下内容:

图 4.29:一个增广矩阵

图 4.29:一个增广矩阵

矩阵的左侧部分是一个n乘以n的子矩阵,其元素对应于原方程组中的常数系数;矩阵的右侧部分是一个包含n个值的列,这些值对应于原方程组中方程右侧的常数值。

现在,从这个增广矩阵中,我们可以进行三种类型的变换:

  • 交换任意两行的位置。

  • 将一行乘以非零常数。

  • 将一行添加到任何其他行(可能也要乘以非零常数)。

该方法的目标是将增广矩阵转换成简化行梯阵形式,或者,因为我们有一个n个方程和n个变量的系统,转换成一个单位矩阵,其中第i行的第i个元素为 1,该行的其他元素为0。基本上,我们希望将增广矩阵转换成这个矩阵:

图 4.30:矩阵变换

图 4.30:矩阵变换

完成这一步之后,ci'值对应于构成原方程组解的值。换句话说,解将是x1=c1x2=c2,依此类推。

尽管这种数学概括可能看起来令人生畏,但让我们通过考虑一个具体的例子来揭开这个过程的神秘面纱。假设我们要解下面的线性方程组:

图 4.31:线性方程组

图 4.31:线性方程组

我们首先构造相应的增广矩阵:

图 4.32:相应的增广矩阵

图 4.32:相应的增广矩阵

现在,我们的目标是通过使用三种提到的变换方法将这个矩阵转换成单位形式。我们首先将第二行减去三倍的第一行,然后除以 4 得到:

图 4.33:将矩阵转换为单位矩阵的第一步

图 4.33:将矩阵转换为单位矩阵的第 1 步

同样,目标是在左侧创建单位矩阵的结构,这可以通过强制非对角元素为零来实现。我们已经对第二行的第一个元素做到了这一点,所以现在让我们尝试对第三行做同样的事情,通过减去两倍的第一行:

图 4.34:将矩阵转换为单位矩阵

图 4.34:将矩阵转换为单位矩阵的第 2 步

第三行的第二个元素要转换为 0,可以通过减去两倍的第二行来实现:

图 4.35:将矩阵转换为单位矩阵的第 3 步

图 4.35:将矩阵转换为单位矩阵的第 3 步

一旦最后一行处于正确形式,转换其他行也相对容易。现在我们将第二行减去三倍的第三行,并乘以-1,得到:

图 4.36:将矩阵转换为单位矩阵的第 4 步

图 4.36:将矩阵转换为单位矩阵的第 4 步

至于第一行,我们首先加上三倍的第三行,以消除最后一个元素:

图 4.37:将矩阵转换为单位矩阵的第 5 步

图 4.37:将矩阵转换为单位矩阵的第 5 步

最后,我们减去三倍的第二行,这样就可以得到增广矩阵的行阶梯形式(左侧为单位矩阵):

图 4.38:单位矩阵

图 4.38:单位矩阵

这对应于解x = 1y = 2,和z = 3。我们可以通过将这些值代入原始方程组来确保我们的解确实是正确的,这显示它们确实满足该系统。

这就是使用行简化方法的过程。如前所述,解线性方程组的另一种方法是矩阵解法。这涉及将给定系统表示为矩阵方程。具体来说,从任何线性方程组的一般形式开始:

图 4.39:线性方程组

图 4.39:线性方程组

我们可以将其重写为矩阵表示形式Ax = c,其中A是包含常数系数的nn矩阵,x是包含我们要求解的变量的向量:x1,x2,…,xn,c同样是包含常数系数c1,c2,…,cn 的向量。由于矩阵和向量的乘积的定义,方程Ax = c确实等价于原始方程组。

在这个矩阵表示中,向量x可以很容易地求解为x = A-1 c,其中A-1 是A逆矩阵。任何给定矩阵M的逆矩阵M-1 是满足方程A A-1 = I的矩阵,其中I是单位矩阵。

矩阵和向量之间的这种乘积称为点积,它输出另一个向量,其元素等于原始矩阵和向量中对应元素的乘积之和。在我们的情况下,A-1 和c之间的点积将给出构成系统解的向量。

有些矩阵没有对应的逆矩阵;这些矩阵称为奇异矩阵。我们可以用来判断矩阵是否奇异的标志之一是,如果矩阵的一行恰好是另一行乘以一个常数得到的。

从理论上讲,这类似于一个系统中的一个方程的系数是另一个方程的系数的精确乘法。如果是这种情况,我们要么有重复信息(当两个方程具有相同信息时,系统有无穷多个解),要么有冲突信息(当两个方程右侧的常数不匹配时,系统没有解)。

这背后的理论不在本书的范围之内。目前,我们只需要知道,如果线性方程组的对应系数矩阵没有逆矩阵,那么该系统就没有明确的解。

因此,并非每个矩阵都有其自己的逆矩阵,即使给定的矩阵有,计算逆矩阵的过程也可能非常复杂。幸运的是,在 Python 中可以相对容易地使用 NumPy 来完成这个过程,我们将在即将进行的练习中看到。具体来说,NumPy 中的linalg(代表线性代数)包提供了许多与线性代数相关的算法的高效实现。在这里,我们对inv()函数感兴趣,它接受表示矩阵的二维 NumPy 数组,并返回相应的逆矩阵。我们将在下一个练习中首次亲身体验这个函数的效果;有关该包的更多信息也可以在docs.scipy.org/doc/numpy/reference/routines.linalg.html找到。

练习 4.04:使用 NumPy 进行矩阵解法

在这个练习中,我们将编写一个程序,该程序接受一个线性方程组,并使用矩阵解法方法产生其解。同样,这将通过使用 NumPy 计算系数矩阵的逆矩阵来完成:

  1. 创建一个 Jupyter 笔记本。在其第一个单元格中,导入 NumPy 及其linalg包中的inv()函数:
import numpy as np
from numpy.linalg import inv
  1. 在下一个代码单元格中,声明一个名为solve_eq_sys()的函数(用于测试目的),它接受两个参数:coeff_matrix,它存储线性方程组中常数系数的矩阵,以及c,它存储方程右侧的常数值向量:
def solve_eq_sys(coeff_matrix, c):

这两个参数完全定义了线性方程组的一个实例,solve_eq_sys()函数的工作是计算其解。我们进一步假设这些参数都存储为 NumPy 数组。

  1. 回想一下,系统的解为x = A-1 c,我们只需返回coeff_matrix的逆矩阵和c的乘积。

可以使用 NumPy 的inv()函数计算逆矩阵:

inv_matrix = inv(coeff_matrix)

最后,可以使用dot()方法计算解决方案,该方法计算矩阵和向量的点积:

return inv_matrix.dot(c)

我们的函数应该如下所示:

def solve_eq_sys(coeff_matrix, c):
    inv_matrix = inv(coeff_matrix)
    return inv_matrix.dot(c)
  1. 在下一个代码单元格中,为我们之前考虑的方程组声明相应的系数矩阵和c向量,并对它们调用solve_eq_sys()函数:图 4.40:线性方程组

图 4.40:线性方程组

这段代码应该是:

coeff_matrix = np.array([[1, 3, -2],\
                         [3, 5, 6],\
                         [2, 4, 3]])
c = np.array([1, 31, 19])
solve_eq_sys(coeff_matrix, c)

这段代码应该产生以下输出:

array([1., 2., 3.])

我们看到,这个输出恰好对应于使用行简化方法得出的方程组的实际解:x = 1y = 2z = 3

  1. 现在,我们想考虑系数矩阵是奇异的情况。我们通过在以下没有解的示例线性方程组上测试我们的代码来做到这一点:图 4.41:线性方程组示例

图 4.41:线性方程组示例

我们看到,如果我们将第一个方程乘以2,得到的方程与第三个方程矛盾。换句话说,没有一组变量xyz的值可以满足该系统。

在下一个代码单元格中,对此系数矩阵调用inv()函数:

inv(np.array([[1, 3, -2],\
              [3, 5, 6],\
               [2, 6, -4]]))

我们将看到这段代码产生了一个LinAlgError: Singular matrix错误,我们将在下一步中修复这个错误。

为了测试目的,取消注释此单元格。

  1. 回到我们的代码,并用一个try...except块修改我们当前的solve_eq_sys()函数来处理这个错误,这将需要从 NumPy 中导入:
from numpy.linalg import inv, LinAlgError

现在,如果输入矩阵是奇异的,函数应该返回False。它应该如下所示:

def solve_eq_sys(coeff_matrix, c):
    try:
        inv_matrix = inv(coeff_matrix)
        return inv_matrix.dot(c)
    except LinAlgError:
        return False
  1. 在下一个代码单元格中,对我们在步骤 5中使用的示例方程组调用此函数:
coeff_matrix = np.array([[1, 3, -2],\
                         [3, 5, 6],\
                         [2, 6, -4]])
c = np.array([1, 31, 19])
solve_eq_sys(coeff_matrix, c)

这次,函数返回值False,这是我们期望的行为。

通过这个练习,我们学会了如何使用 NumPy 实现矩阵解法来解决线性方程组。这也结束了线性方程组的主题。

注意

要访问此特定部分的源代码,请参阅packt.live/2NPpQpK

您也可以在packt.live/2VBNg6w上在线运行此示例。

在本章的下一节和最后一节中,我们将考虑不完全线性的方程组。

非线性方程组

当一个系统包含一个方程,其中包含一些变量的非线性项时,我们在上一节讨论的方法不适用。例如,考虑以下系统:

图 4.42:非线性方程组示例

图 4.42:非线性方程组示例

问题出在非线性项x2 上,这使我们想要对系统应用的任何转换变得复杂。

然而,我们仍然可以有一个系统化的方法来解决这些类型的系统。具体来说,注意到从任一方程中,我们都可以解出一个变量的另一个变量。为了做到这一点,我们对每个方程进行代数变换,使得一个变量可以纯粹地表示为另一个变量。特别地,y可以表示为x的函数,如下所示:

图 4.43:替换方程以找到 y 的值

图 4.43:替换方程以找到 y 的值

因此,为了使系统有一个有效的解,y的两个值需要匹配。换句话说,我们有以下只包含x的方程:

图 4.44:在两边替换 y 的值

图 4.44:在两边替换 y 的值

这只是一个关于x的多项式方程,正如我们所知,可以通过因式分解来解决。具体来说,该方程可以因式分解为(x - 2) (x - 1) = 0,显然接受x = 1x = 2作为解。这些x的每个值对应于y的一个值,可以通过将 1 和 2 代入原始方程组来找到。最终,该系统有两个解:(x = 1, y = 4)(x = 2, y = 3)

总的来说,这种方法称为替换,表示我们能够通过转换方程来解出一个变量的另一个变量。然后将这个解代入另一个方程中,这样我们就得到了一个单变量的方程。

让我们看另一个例子,应用这种方法来解决以下方程组:

图 4.45:方程组示例

图 4.45:方程组示例

虽然有多种解决方法,但一种明显的方法是解出第二个方程中的y,得到y = (x2 - 1) / 2,然后将其代入第一个方程中,如x2 - 2x - (x2 - 1)2 / 4 = -1

通过一些代数运算,我们可以将方程简化为x4 -6x2 + 8x -3 = 0

现在我们有一个只包含一个变量的方程,所以我们可以应用我们在上一节学到的技巧来解出x。一旦我们得到x的解,我们也可以使用之前的y = (x2 - 1) / 2代换来解出y

在这里,可以应用因式分解来找到满足这个方程的x的值。让我们尝试代入一些x的值,比如-1012,看哪个能使函数的值为 0。注意到x = 1是一个有效的解,我们首先将方程关于(x - 1)进行因式分解,得到(x - 1) (x3 + x2 - 5x + 3) = 0

我们再次注意到x = 1仍然满足方程x3 + x2 – 5x + 3 = 0,因此我们再次进行因式分解得到(x - 1)2 (x2 + 2x - 3) = 0

二次函数x2 + 2x - 3可以因式分解为(x - 1) (x + 3)。最终,我们得到以下方程(x - 1)3 (x + 3) = 0

有两个x的值满足方程:x = 1x = -3。通过将它们代入原方程组,我们可以解出y,得到方程组的两个解:(x = 1, y = 0)(x = -3, y = 4)

不幸的是,并非所有的非线性方程组都允许我们以如此简单的方式使用代换法。在许多情况下,需要使用微妙而巧妙的技巧来解决复杂的方程组。

那么,如果我们想要自动化地找到这些方程组的解,该怎么办呢?这就是sympy库提供的符号计算能力再次派上用场的地方。我们已经看到,使用 SymPy,我们可以解决任何一个一元方程。同样的想法也可以应用于非线性方程组,只是在这种情况下,我们将一系列符号函数传递给solve()函数。

在这一节中,我们想要使用 SymPy 来解决我们有的两个方程组;首先:

图 4.46:第一个方程组

图 4.46:第一个方程组

其次:

图 4.47:第二个方程组

图 4.47:第二个方程组

为此,我们首先将我们的变量声明为 SymPy 中Symbol类的实例:

x = Symbol('x')
y = Symbol('y')

我们可以调用 SymPy 中的solve()函数来找到我们有的方程组的解。对于第一个方程组:

solve([x + y - 5, x ** 2 - x + 2 * y - 8],\
       x, y)

这段代码将返回[(1, 4), (2, 3)],这是我们之前得到的xy的有效解的列表。至于第二个方程组:

solve([x ** 2 - 2 * x - y ** 2 + 1, x ** 2 - 2 * y - 1],\
       x, y)

这段代码返回[(-3, 4), (1, 0)],这也对应着我们得到的解。正如我们所看到的,SymPy 为我们提供了一个简单的语法,让我们轻松地解决方程和方程组。

这个例子也标志着这一节材料的结束。为了结束这一章,我们将考虑对我们之前进行的盈亏分析练习的扩展。

活动 4.01:多变量盈亏分析

正如我们在第一个盈亏分析练习的结尾提到的,随着我们模型中变量的数量增加,盈亏分析可能变得非常复杂。当模型中有多个变量时,需要使用方程组来找到盈亏平衡点,这就是我们将在这个活动中做的事情。

回想一下,在我们的例子中,汉堡餐厅的商业模型中,我们每生产一个汉堡的成本是 6.56 美元,每个月的固定成本是 1312.13 美元,用于水电费、房租和其他费用。在这个活动中,我们将探讨企业的总利润如何随着我们销售的汉堡数量和每个汉堡的价格而变化。

我们需要这个模型的一个额外信息是餐厅所在地区的人们对汉堡的需求。假设,平均而言,餐厅观察到他们的收入每个月大约是 4000 美元,所以对汉堡的需求大约是 4000 除以一个汉堡的价格。

要完成此活动,请执行以下步骤:

  1. 将餐厅每月生产的汉堡数量和每个汉堡的价格视为我们模型的两个变量。用这两个变量表示餐厅的月收入、成本和总利润。

  2. 构建一个与盈亏平衡点相对应的方程组:当餐厅生产的汉堡数量满足需求且收入等于成本时。

  3. 通过手工解决这个方程组,并在 Jupyter 笔记本中使用 SymPy 验证结果。

  4. 在同一个 Jupyter 笔记本中,编写一个 Python 函数,该函数接受生产的汉堡数量和每个汉堡的价格的任何组合。该函数应返回餐厅的总利润。

  5. 在下一个代码单元格中,创建一个潜在值列表,表示每月生产的汉堡数量,范围从 300 到 500。使用每个汉堡 9.76 美元的固定价格生成相应利润列表,并将其存储在名为profits_976的变量中(用于测试)。将这个利润列表作为生产的汉堡数量的函数绘制出来。

  6. 在下一个代码单元格中,生成相同的利润列表,这次使用每个汉堡 9.99 美元的固定价格,并将其存储在名为profits_999的变量中。创建相同的图表,并解释它与盈亏平衡点的关系。

  7. 在下一个单元格中,创建一个潜在值列表,表示要生产的汉堡数量;它应该是 300 到 500 之间的每个偶数(例如,300、302、304,…,500)。另外,创建一个 NumPy 数组,其中包含 5 到 10 之间的 100 个均匀间隔的潜在汉堡价格。

  8. 最后,生成一个二维列表,其中第一个列表中第i个数字表示将要生产的汉堡数量,第二个列表中第j个数字(NumPy 数组)表示每个汉堡的价格。将此列表存储在名为profits的变量中以进行测试。

  9. 使用 Matplotlib 创建一个热力图,以可视化前一步生成的利润的二维列表,作为生产的汉堡数量(作为y轴)和每个汉堡的价格(作为x轴)的函数。

注意

此活动的解决方案可在第 665 页找到。

总结

本章正式介绍了数学上函数和变量的定义。还讨论了与函数相关的各种主题,如定义域、值域和函数的图。在本章的第二部分,我们讨论了方程和方程组的概念,以及寻找它们解的特殊方法。在这些讨论中,还检查了 SymPy 库和 NumPy 中计算矩阵的逆的函数。我们通过完成一个使用代数和函数构建多变量盈亏分析的任务来结束本章。

在下一章中,我们将继续讨论数学中的另一个重要主题:序列和级数。

FKV27

GCH43

第五章:用 Python 进行更多数学

概述

在本章结束时,你将能够掌握序列和级数的基本概念,并编写实现这些概念的 Python 函数。你将了解基本三角函数及其应用之间的关系,比如著名的毕达哥拉斯定理。你将练习向量微积分,并通过在 Python 中进行向量代数来了解它的适用性。最后,你会感到高兴,因为复数并不比其他类型的数字差;它们与三角学密切相关,并且对现实世界的应用很有用。

介绍

在上一章中,我们用 Python 介绍了函数和代数,从基本函数开始,然后进行变换和解方程。在本章中,我们将介绍序列和级数,在现实世界中有许多应用,比如金融,也是理解微积分的基础。此外,我们还将探讨三角学、向量和复数,以便更好地理解数学世界。

任何出色的 Python 程序员的核心技能包括对背景数学的深刻理解以及有效地应用它们。把向量和复数看作是我们数学工具箱的有价值扩展,它们将有助于有效地描述、量化和解决来自金融、科学、商业和社会领域的现实问题。

序列和级数等在利润、损失、股利或其他支付定期发生的情况中出现。三角学和三角函数对解决地理空间问题至关重要,而向量在物理学和工程学、机器学习等领域得到广泛应用,其中多个不同的值被分组在一起,方向的概念至关重要。复数是一些最基本的概念,在电磁学、光学、量子力学和计算机科学等领域有广泛的应用。

序列和级数

如果你参加一个电视节目,其中 1 万美元的问题是“给定数字 2、4、8、16 和 32,序列中接下来是什么?”,你最好的猜测是什么?如果你的回答是 64,那么恭喜你——你刚刚更接近理解数学抽象中的一个关键概念:序列。序列就像普通意义上的顺序一样,是一种事物相互跟随的特定顺序。在这里,事物(在大多数情况下)是相关的整数或实数。元素的顺序很重要。元素也被称为序列的成员或术语。

例如,在你参与的电视节目的前述序列中,每个术语都是前一个数字乘以 2 得到的;这个序列没有结束,因为可以无限产生整数数字。在其他情况下,序列中的元素可以出现多次。想想一年中每个月的天数,或者随机事件的结果序列,比如抛硬币的结果。自古印度以来就已知的一个著名序列是斐波那契数列——1、1、2、3、5、8、13……这个序列中,每个术语都是前两个术语的和。

也就是说,我们需要知道至少两个术语才能推导出其他任何术语。换句话说,我们需要读取前两个数字(在前述序列中是 1 和 1,但通常是任意两个数字)才能推导和预测第三个数字。我们知道一些序列,比如斐波那契数列,其中包含一些内在的逻辑;我们可以遵循一个基本规则,并推导出序列的任何术语。

在本章中,我们将重点关注基本序列,也称为级数,这些序列在应用数学和编程的许多领域中反复出现,属于三种基本类别之一:等差、等比和递归。这些不是唯一的可能性;然而,它们是最受欢迎的序列家族,并且说明了它们所蕴含的逻辑。

一系列数字{αn} = {α1, α2, α3, ..., αΝ, ...}是一个有序的术语(元素或成员)的集合,对于这个集合有一个规则,将每个自然数 n = 1, 2, 3, ..., N 与序列中的一个术语关联起来。序列的长度(即其术语的数量)可以是有限的或无限的,因此相应地称为有限或无限序列。

一个级数是一个按照以下方式求和的数学序列:

Figure 5.1: 级数方程

图 5.1:级数方程

这也可以使用求和符号进行求和,如下所示:

图 5.2:无限级数的方程

图 5.2:无限级数的方程

在前面的情况下,我们的级数是无限的(即,它是无限序列的所有项的和)。然而,一个级数,就像一个序列一样,也可以是有限的。为什么一个总和会有无限个项?因为事实证明,在许多情况下,通过应用已知的公式进行计算更有效。此外,即使序列是无限的,求和也可以收敛到一个数字(不是无穷大)或某个函数。由于这个原因,级数可以被认为是已知函数的构建块,它们的术语可以用来表示越来越复杂的函数,从而使得对它们的性质的研究变得直观。级数和序列在数学、物理、工程、金融等领域中无处不在,并且自古以来就为人所知。它们出现并且作为无限和的定义在导数和其他函数中特别有用。

等差数列

像大多数数学概念一样,序列无处不在我们的日常生活中。你可能以前没有想过,但每次你乘坐出租车时,后台都会运行一个序列来计算你的乘车总费用。有一个初始费用,每公里(或英里)增加一个固定金额。因此,在任何给定时刻,都有一个实际的对应数字(到目前为止的乘车费用)。所有这些小计的有序集合形成一个序列。同样,随着你的成长,你的身高是一个随时间(天或月)变化的实数序列(以厘米或英寸表示的身高)。这两个例子都构成了随时间非递减的序列——换句话说,每个术语要么大于等于任何先前的术语,但从不小于。然而,这两个例子之间存在微妙的差异:随着我们的成长,我们增加身高的速度不同(儿童增长速度快,青少年增长速度慢,成年人增长速度为零),而出租车费用增加的速度是恒定的。这导致我们需要引入一种特殊类别的序列——等差数列,其定义如下。

任意两个连续项之间的差是恒定的序列称为等差。因此,等差数列的公式如下:αn+1- αn = d

在这里,d是常数,必须对所有n成立。当然,很明显,如果你知道参数d和一些(任何)项αn,那么项αn+1 可以通过前面的关系直接找到。通过重复,所有项αn+2,αn+3 ...,以及项αn-1,αn-2 都可以找到。换句话说,如果你知道参数d和序列的第一项α1,那么所有项(即唯一确定的)的序列都是已知的。给出我们序列的第n*项的一般公式如下:

αn = α1 + (n – 1)d

在这里,d被称为公差。

相反,要测试一个通用序列是否是等差数列,只需检查其项的所有成对差异αn+1 – αn,并查看这些是否是相同的常数。在相应的等差数列中,前面序列的和变成了下面的形式:

Σnj αj = Σnj [ α1 + (j – 1)d ] = n(α1 + αn)/2

这意味着通过知道长度n,序列的第一个和最后一个项,我们可以确定从α1 到αn 的所有项的和。请注意,和(α1 + αn)给出整个序列的算术平均数的两倍,因此该序列不过是算术平均数的n倍。

现在,我们知道算术序列的主要逻辑和组成部分。现在,让我们看一些具体的例子。目前,我们不需要在 Python 中导入任何特定的库,因为我们将创建自己的函数。让我们提醒自己,这些函数总是需要以def开头,后面跟着一个空格,函数名(任何我们喜欢的东西),以及函数在括号内接受的参数列表,后面跟着一个分号。接下来的行是缩进的(向右缩进四个空格),并且是函数的逻辑,也就是函数的算法或方法。例如,考虑以下例子:

def my_function(arg1, arg2):
    '''Write a function that adds two numbers 
       and returns their sum'''
    result = arg1 + arg2
    return result

在最终语句result之后,返回的是函数的返回值。因此,例如,如果我们正在编写前面的my_function定义,该定义接收两个输入数字arg1arg2,那么我们可以将它传递给一个新变量,比如以下变量:

summed = my_function(2,9)
print(summed)

输出将如下所示:

11

在这里,summed是一个新变量,它正是由my_function返回(生成)的。请注意,如果在函数定义中缺少return语句,则语法仍然正确,函数仍然可以被调用。但是,summed变量将等于None

现在,如果我们想创建一个(任何)数字序列,我们应该在我们的函数内包含一个迭代。在 Python 中,可以通过forwhile循环来实现这一点。让我们看一个例子,一个函数输出一个n个和的序列:

def my_sequence(arg1, arg2, n):
    '''Write a function that adds two numbers n times and 
       prints their sum'''
    result = 0
    for i in range(n):
        result = result + arg1 + arg2
        print(result)

在这里,我们初始化变量 result(为零),然后迭代地将arg1 + arg2加到它上面。这个迭代发生了n次,其中n也是我们新函数my_sequence的一个参数。每次循环(跟在for语句后面的)执行时,result增加了arg1 + arg2,然后打印在屏幕上。为简单起见,我们在这里省略了return语句。在这里,我们使用了 Python 内置的range()方法,它生成一个整数序列,从 0 开始,以给定的停止整数之前的一个数结束(我们提供的数字)。让我们调用我们的函数:

my_sequence(2,9,4)

我们将获得以下输出:

11
22
33
44

如果我们使用了while循环,我们将得到相同的结果:

def my_sequence(arg1, arg2, n):
    '''Write a function that adds two numbers n times 
       and prints their sum'''
    i = 0
    result = 0
    while i < n:
        result = result + arg1 + arg2
        i += 1
        print(result)

如果我们调用my_sequence函数,我们将得到与先前相同输入的相同输出。

生成器

在 Python 中进行顺序操作的另一个有趣选项是使用生成器。生成器是类似于函数的对象,返回一组可迭代的项目,一次一个值。简单地说,如果一个函数包含至少一个yield语句,它就成为一个生成器函数。使用生成器而不是函数的好处是,我们可以根据需要(这里是无限次数)调用生成器,而不会使系统的内存过载。在某些情况下,它们可以是无价的工具。要获取一系列项的一个项,我们使用next()方法。首先,让我们定义我们的函数:

def my_generator(arg1, arg2, n):
    '''Write a generator function that adds 
       two numbers n times and prints their sum'''
    i = 0
    result = 0
    while i < n:
        result = result + arg1 + arg2
        i += 1
        yield result

现在,让我们多次调用next()方法:

my_gen = my_generator(2,9,4)
next(my_gen)

以下是输出:

11

第二次调用该方法:

next(my_gen)

以下是输出:

22

第三次调用它:

next(my_gen)

以下是输出:

33

第四次调用该方法:

next(my_gen)

以下是输出:

44

因此,我们得到了与上一个示例相同的结果,但是一次一个。如果我们重复调用next()方法,我们将收到错误消息,因为我们已经耗尽了我们的生成器:

next(my_gen)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
StopIteration

现在,我们准备在 Python 代码中实现我们学到的序列关系。

练习 5.01:确定算术序列和算术级数的第 n 项

在这个练习中,我们将使用一个简单的 Python 函数创建有限和无限的算术序列。作为输入,我们希望提供序列的第一项a1,公差d和序列的长度n。我们的目标是获得以下内容:

  • 序列的一个项(第n项)。

  • 完整的数字序列。

  • 算术序列的n项的和,以便将其与先前给出的算术级数的结果进行比较。

为了计算前面的目标,我们需要提供序列的第一项a1,公差d和序列的长度n作为输入。让我们实现这个练习:

  1. 首先,我们想要编写一个函数,根据通用公式αn = α1 + (n – 1)d 返回第n项:
def a_n(a1, d, n):
    '''Return the n-th term of the arithmetic sequence.
    :a1: first term of the sequence. Integer or real.
    :n: the n-th term in sequence
    returns: n-th term. Integer or real.'''
    an = a1 + (n - 1)*d
    return an

通过这样做,我们获得了序列的第n项,而无需知道任何其他前面的项。例如,让我们用参数(4, 3, 10)调用我们的函数:

a_n(4, 3, 10)

我们将得到以下输出:

31
  1. 现在,让我们编写一个函数,它将初始项a1递增dn次,并将所有项存储在列表中:
def a_seq(a1, d, n):
    '''Obtain the whole arithmetic sequence up to n.
    :a1: first term of the sequence. Integer or real.
    :d: common difference of the sequence. Integer or real.
    :n: length of sequence
    returns: sequence as a list.'''
    sequence = []
    for _ in range(n):
        sequence.append(a1)
        a1 = a1 + d
    return sequence
  1. 要检查结果列表,请添加以下代码:
a_seq(4, 3, 10)

输出如下:

[4, 7, 10, 13, 16, 19, 22, 25, 28, 31]

在这里,我们得到了一个长度为 10 的算术序列,从4开始,增加 3。

  1. 现在,让我们生成无限序列。我们可以使用之前介绍的 Python 生成器来实现这一点:
def infinite_a_sequence(a1, d):
    while True:
        yield a1
        a1 = a1 + d
for i in infinite_a_sequence(4,3):
    print(i, end=" ")

如果运行上述代码,您会注意到我们必须手动中止执行;否则,for循环将永远打印出序列的元素。使用 Python 生成器的另一种方法是,如前所述,直接在生成器对象(这里是infinite_a_sequence())上调用next()方法。

  1. 让我们通过调用sum() Python 方法来计算我们序列的项的和:
sum(a_seq(4, 3, 10))

输出如下:

175
  1. 最后,实现αn = α1 + (n – 1)d 公式,这给我们算术级数,以便我们可以将其与我们的求和结果进行比较:
def a_series(a1, d, n):
    result = n * (a1 + a_n(a1, d, n)) / 2
    return result
  1. 运行函数,如下:
a_series(4, 3, 10)

输出如下:

175.0

注意

要访问此特定部分的源代码,请参阅packt.live/2D2S52c.

您还可以在packt.live/31DjRfO.上在线运行此示例

有了这个,我们通过使用序列或级数得到了相同的结果,得到了算术序列元素的总和。能够用两种独立的数学方法交叉验证给定结果的能力对于所有级别的程序员来说都是非常有用的,并且是科学验证的核心。此外,了解不同的方法(在这里,我们用来得到级数结果的两种方法),以及每种方法的优势(以及劣势)对于在高级水平上编写代码至关重要。

我们将学习另一种不同但同样基本的序列类别:几何序列。

几何序列

传染病从一个人传播到另一个或更多人,取决于给定社区的人口密度。在像大流行这样的情况下,对于一种中等传染性的疾病,平均每个患病者每天感染两个人是现实的。因此,如果在第 1 天只有一个感染者,第 2 天将有两个新感染者,第 3 天,对于每个先前感染的两个人,另外两个人将感染疾病,使新感染者的数量增加到四个。同样,在第 4 天,会出现八个新病例,依此类推。我们可以看到疾病扩展的速度并不是恒定的,因为新病例的数量取决于给定时刻现有病例的数量,这解释了大流行是如何呈指数增长和传播的。

前面的数字(1, 2, 4, 8...)形成一个序列。请注意,现在,算术序列的要求没有得到满足:两个连续项之间的差异不是恒定的。然而,比率是恒定的。这说明了前面的序列是一种特殊类型的序列,称为几何,并且被定义为一个序列或一组有序数字,其中任意两个连续项的比率是恒定的。

在数学的简洁语言中,我们可以将前面的行为写成αn+1 = r αn。

在这里,αn 是第n天的病例数量,αn+1 是第n+1天的新病例数量,r>0是定义增长速度(或减速速度)的系数。这被称为公比。前面的公式是通用的,这意味着它适用于所有成员n。因此,如果它对n成立,那么它对n-1n-2等也成立。通过递归地处理前面的关系,我们可以轻松地得到αn = rn-1α方程。

在这里,我们给出了几何序列的第n项,一旦给出了第一项α=α1 和公比r。术语α被称为比例因子

请注意,r可以有任何非零值。如果r>1,每一代,αn+1 都比前一代大,因此序列是不断增加的,而如果r<1,则相反:随着n的增加,αn+1 趋于零。因此,在传染病的初始例子中,r>1意味着传播是增加的,而r<1则导致传播减少。

让我们编写一个 Python 函数,根据αn = rn-1α的公式计算几何函数的第n项:

def n_geom_seq(r, a, n):
    an = r**(n-1) * a 
    return an 

该函数中的输入是r,公比,a,比例因子,和n,我们想要找到的第n项。让我们用一些参数调用这个函数,(2, 3, 10)

n_geom_seq(2, 3, 10) 

输出如下:

1536

同样,对于算术序列的情况,我们将几何级数定义为序列长度n的项的总和:

图 5.3:几何序列

图 5.3:几何序列

或者,我们可以这样表达:

图 5.4:几何序列的另一种表达

图 5.4:几何序列的另一种表达

为了更好地理解几何级数,让我们看看它在 Python 中是如何工作并进行可视化的。我们需要定义一个函数,接受ran(就像之前一样)作为输入,并计算第二个公式,即到第n项的级数:

def sum_n(r, a, n):
    sum_n = a*(1 - r**n) / (1 - r) 
    return sum_n

现在,像之前一样,为参数(2, 3, 10)调用函数:

sum_n(2, 3, 10)

输出如下:

3069.0

看一下几何序列的以下示例图,其中值对r>1递增:

图 5.5:几何序列 r>1 递增

图 5.5:几何序列 r>1 递增

看一下几何序列的以下示例图,其中值对r<1递减:

图 5.6:几何序列 r<1 递减

图 5.6:几何序列 r<1 递减

在本节中,我们已经看到了几何序列的进展以及如何在 Python 中轻松找到它的项,以及几何级数。我们现在准备在练习中实现我们所学到的内容,以便更好地理解序列及其应用。

练习 5.02:编写一个函数来找到序列的下一个项

培养皿中细菌数量以几何序列增加。给定每天的细菌数量,跨越一定天数n,编写一个函数,计算第n+1天的细菌数量。按照以下步骤完成这个练习:

  1. 编写一个函数,接受可变数量的参数(*args)并计算任何元素与其前一个元素之间的比率(从第二个元素开始)。然后,检查找到的所有比率是否相同,并返回它们的唯一值。否则,函数返回-1(序列没有唯一的公共比率):
def find_ratio(*args):
    arg0=args[0]
    ratios = []
    for arg in args[1:]:
        ratio = round(arg/arg0,8)
        arg0=arg
        ratios.append(ratio)
    if len(set(ratios)) == 1:
        return ratio
    else:
        return -1
  1. 现在,检查find_ratio函数的两种不同情况。首先,让我们使用以下序列:
find_ratio(1,2,4,8,16,32,64,128,256,512)

输出如下:

2.0
  1. 现在,让我们使用以下序列:
find_ratio(1,2,3)

输出如下:

-1

如前面的输出所示,find_ratio函数打印出比率,如果存在的话,或者如果序列不是几何序列,则打印-1

  1. 现在,编写第二个函数,读取一个序列并打印出下一个项将是什么。为此,读取一个(逗号分隔的)数字列表,找到它们的比率,然后预测下一个项:
def find_next(*args):
    if find_ratio(*args) == -1:
        raise ValueError('The sequence you entered' \
                         'is not a geometric sequence. '\
                         'Please check input.')
    else:
        return args[-1]*find_ratio(*args)

请注意,我们要通过调用我们之前编写的find_ratio()函数来检查序列是否具有公共比率。如果没有,就引发一个错误;如果有,就找到下一个项并返回它。

  1. 通过使用以下序列来检查它是否有效:
find_next(1,2,4)

以下是前面代码的输出:

8.0
  1. 现在,尝试使用不同的序列:
find_next(1.36,0.85680,0.539784,0.34006392)

输出如下:

0.2142402696

它有效。在第一种情况下,明显的结果8.0被打印出来。在第二种情况下,找到并打印出了递减的几何序列的不太明显的结果。总之,我们能够编写一个函数,检测几何序列,找到它的比率,并用它来预测下一个序列项。这在现实生活中非常有用,比如需要验证复利利率的情况。

注意

要访问此特定部分的源代码,请参阅packt.live/2NUyT8N

您也可以在packt.live/3dRMwQV上在线运行此示例。

在前面的章节中,我们看到序列,无论是等差还是等比,都可以用两种等价的方式来定义。我们看到序列的第n项是通过知道序列的给定项(通常是第一项,但不一定)和公差或公比来确定的。更有趣的是,我们看到序列的第n项可以通过知道第(n-1)项来找到,而第(n-1)项又可以通过知道第(n-2)项来找到,依此类推。因此,这里有一个有趣的模式,决定了我们研究的两种序列类型,实际上超出了它们。事实证明,我们可以概括这种行为,并以纯粹递归的方式定义序列,而不一定是等差或等比的。现在,让我们继续下一节,我们将了解递归序列。

递归序列

递归序列是一个元素序列,υn,通过递归关系产生,也就是说,每个元素都是唯一地从前面的元素产生出来的。

υn 可以依赖于一个或多个在它之前的元素。例如,我们在本章前面看到的斐波那契数列是一个递归序列,其中知道第n项需要知道第(n-1)项和第(n-2)项。另一方面,阶乘只需要前面的元素。具体来说,它由递归关系定义,n! = n(n-1)! , n > 0,和初始条件,0! = 1

让我们将前面的公式转换成 Python 代码:

def factorial(n):
    if n == 0 or n ==1:
        return 1
    elif n == 2:
        return 2
    else:
        return n*factorial(n - 1)

前面的代码是阶乘函数的递归实现:为了计算n的结果,我们调用n-1的函数,然后n-1调用n-2的函数,依此类推,直到n=2

如果我们对n=11的情况执行前面的函数,我们得到以下结果:

factorial(11)

输出如下:

39916800

请注意,到目前为止我们看到的前两类序列(等差和等比)是互斥的,但是递归序列不是,也就是说,序列既可以是递归的,也可以是等差的或等比的。按照惯例,我们用术语递归来表示这些类型的序列,与等比和等差不同,它们不能以非递归的方式表示。

现在我们已经探讨了递归序列的基本概念,我们可以在 Python 中实现这一点,并编写计算递归定义的任何序列的任意数量的元素的代码。

练习 5.03:创建自定义递归序列

在这个练习中,我们将使用我们在前一节中解释的概念创建一个自定义的递归序列。给定序列的前三个元素,Pn,也就是P1=1P2=7,和P3=2,找出递归定义的序列的接下来七个项,关系为Pn+3= (3Pn+1 - Pn+2)/(P*n – 1)。按照以下步骤完成这个练习:

  1. 定义一个 Python 函数,它是递归的,并实现了前面给出的序列的第n个元素的关系:
def p_n(n):
    if n < 1:
        return -1
    elif n == 1:
        return 1
    elif n == 2:
        return 7
    elif n == 3:
        return 2
    else:
        pn = (3*p_n(n-2) - p_n(n-1) )/ (p_n(n-3) + 1)
        return pn

在这里,我们首先定义了基本情况,也就是在简介中给出的已知结果:如果n=1,那么P=1,如果n=2,那么P=7,如果n=3,那么P=2。我们还包括了n<1的情况。这是无效的输入,惯例上,我们的函数返回值为-1。这使得我们的函数有界,并且受到保护,不会进入无限循环和无效输入。一旦处理了这些情况,我们就定义了递归关系。

  1. 现在,让我们测试我们的函数,并打印出序列的前 10 个值(三个对应于基本情况,另外七个是我们要找的):
for i in range(1,11):
    print(p_n(i))

输出如下:

1
7
2
9.5
-0.4375
9.645833333333334
-1.0436507936507937
53.29982363315697
-5.30073825572847
-3784.586609737289

从前面的输出中可以看出,我们的函数有效,并返回了序列的已知值(P1 = 1P2 = 7,和P3 = 2)以及我们正在寻找的下一个项(P_1P_10)。

  1. 作为奖励,让我们使用matplotlib模块绘制我们的发现。我们将创建一个包含序列前九个值的列表,然后用pyplot绘制它:
from matplotlib import pyplot as plt
plist = []
for i in range(1,40):
    plist.append(p_n(i))

plt.plot(plist, linestyle='--', marker='o', color='b')
plt.show()

输出如下:

图 5.7:使用 matplotlib 库创建的图表

图 5.7:使用 matplotlib 库创建的图表

注意

要访问本节的源代码,请参阅packt.live/2D3vlPF

您也可以在packt.live/3eY05Q4上在线运行此示例。

我们可以看到,一个简单而明确定义的递归关系可以导致明显随机或混沌的结果。实际上,如果您继续绘制前述序列的项,很快就会注意到在项的模式中没有明显的规律,它们在 0 周围广泛且不对称地振荡。这促使我们得出结论,即使定义了一个递归序列并预测了它的第 n 项是直接的,相反的情况并不总是成立。正如我们所看到的,鉴于一个序列(一系列数字),检查它是否形成等差数列、等比数列或两者都不是,是非常简单的。然而,要回答一个给定的序列是否由递归关系导出——更不用说这个递归是什么——是一个非平凡的任务,在大多数情况下都无法回答。

在本节中,我们介绍了序列是什么,为什么它们很重要,以及它们与数学中另一个重要概念的联系:级数。我们研究了三种一般类型的序列,即等差、等比和递归,并看到它们如何可以在 Python 中通过几个简单的步骤实现。在下一节中,我们将深入研究三角学,并学习如何使用 Python 轻松解决三角学问题。

三角学

三角学是研究三角形,特别是它们的角与边的关系。三角形的三条边(边)中的两条的比值提供了关于特定角的信息,并且对于这样一对边,我们给它一个特定的名称,并称之为函数。三角学和数学的美妙之处在于这些函数,它们诞生于三角形内部,在三角形不存在的任何其他情况下都有(抽象的)意义,并且作为独立的数学对象运行。因此,诸如正切、余弦和正弦之类的函数在大多数数学、物理和工程领域都可以找到,而无需参考三角形。

让我们看看最基本的三角函数及其用法。

基本三角函数

我们将从定义一个直角三角形(或简称直角三角形)开始,三角形 ABC。它的一个角(下图中的角 BCA)是一个直角,即 90 度角。直角的对边称为斜边(下图中的边h),而其他两边(ab)称为腿。它们也被称为相对于各自角的对边邻边。例如,边b是相邻于下图中的右下角的(角 CAB 或θ),而当我们提到顶角(角 CBA)时,它是对边:

图 5.8:直角三角形

图 5.8:直角三角形

最常见的三角函数是通过前面的图表定义的,并且定义如下:

图 5.9:三角函数

图 5.9:三角函数

对于正切函数,也成立 tanθ = sinθ/cosθ

此外,对于任何角度θ,以下恒等式始终成立:sinθ2 + cosθ2 = 1

根据构造,三角函数是周期性的。这意味着,无论三角形的边的大小如何,前述函数都会在每 2π重复一次。这将在下一个练习中变得明显,我们将在其中绘制它们。正弦和余弦函数的范围是区间[-1,1]。这意味着它们可以获得的最小值是-1,最大值是 1,无论输入θ是什么。

最后但并非最不重要的,直角三角形的边是通过著名的毕达哥拉斯定理连接的:h2 = a2 + b2

在 Python 代码中,毕达哥拉斯定理的一个简单实现是编写一个函数,利用math模块的平方根(sqrt)方法,计算h,给定ab;例如:

from math import sqrt
def hypotenuse(a,b):
    h = sqrt(a**2 + b**2)
    return h

a=3b=4调用此函数会给我们以下结果:

hypotenuse(a = 3, b = 4)

输出如下:

5.0

现在,让我们看一些具体的例子,以便我们能够掌握这些想法。

练习 5.04:绘制直角三角形

在这个练习中,我们将编写 Python 函数,用于绘制给定点p1 和p2*的直角三角形。直角三角形将对应于三角形腿的端点。我们还将计算非直角的三角函数。让我们绘制基本的三角函数:

  1. 导入numpypyplot库:
import numpy as np
from matplotlib import pyplot as plt

现在,编写一个函数,当给定两边p1 和p2 时,使用毕达哥拉斯定理返回斜边:

def find_hypotenuse(p1, p2):
    p3 = round( (p1**2 + p2**2)**0.5, 8)
    return p3
  1. 现在,让我们编写另一个函数,实现sincostan函数的关系。输入是给定角的邻边、对边和斜边的长度,结果是三角函数值的元组:
def find_trig(adjacent, opposite, hypotenuse):
    '''Returns the tuple (sin, cos, tan)'''
    return opposite/hypotenuse, adjacent/hypotenuse, \
           opposite/adjacent
  1. 现在,编写绘制三角形的函数。为简单起见,将直角放在坐标轴的原点(0,0),第一个输入点沿x轴放在(p1,0),第二个输入点沿y轴放在(0,p2):
def plot_triangle(p1, p2, lw=5):
    x = [0, p1, 0]
    y = [0, 0, p2]
    n = ['0', 'p1', 'p2']
    fig, ax = plt.subplots(figsize=(p1,p2))
    # plot points
    ax.scatter(x, y,  s=400, c="#8C4799", alpha=0.4)
    ax.annotate(find_hypotenuse(p1,p2),(p1/2,p2/2))

    # plot edges
    ax.plot([0, p1], [0, 0], lw=lw, color='r')
    ax.plot([0, 0], [0, p2], lw=lw, color='b')
    ax.plot([0, p1], [p2, 0], lw=lw, color='y')
    for i, txt in enumerate(n):
        ax.annotate(txt, (x[i], y[i]), va='center')

在这里,我们创建了包含点的列表xy,还有一个标签列表n。然后,我们创建了一个pyplot对象,首先绘制点,然后绘制边。最后两行用于注释我们的绘图;即,在我们的点旁边添加标签(从列表n中)。

  1. 我们需要选择两个点来定义一个三角形。然后,我们需要调用我们的函数来显示绘图:
p01 = 4
p02 = 4
print(find_trig(p01,p02,find_hypotenuse(p01,p02)))
plot_triangle(p01,p02)

第一行打印三角函数sincostan的值。然后,我们绘制我们的三角形,在这种情况下是等腰三角形,因为它有两条相等长度的边。

输出将如下所示:

图 5.10:绘制等腰三角形

图 5.10:绘制等腰三角形

结果是预期的和正确的——在四舍五入误差后——因为这种特定形状的几何形状很简单(一个等腰直角三角形,两个角相等于π/4)。然后,我们检查了结果(请注意,在 NumPy 中,π的值可以直接调用np.pi)。

  1. 最后,为了对sincos三角函数有一个总体概述,让我们绘制它们:
x = np.linspace(0, 10, 200)
sin = np.sin(x)
cos = np.cos(x)
plt.xticks([0, np.pi/2, np.pi, 3*np.pi/2, 2*np.pi, \
            5*np.pi/2, 3*np.pi], \
           ['0','','\u03C0','','2\u03C0','','3\u03C0'])
plt.plot(x, sin, marker='o', label='sin')
plt.plot(x, cos, marker='x', label='cos')
plt.legend(loc="upper left")
plt.ylim(-1.1, 1.6)
plt.show()

输出将如下所示:

图 5.11:正弦和余弦三角函数的绘图

图 5.11:正弦和余弦三角函数的绘图

在这个练习中,我们启动了对三角学领域的探索,并看到如何在 Python 中得到有用的可视化。

注意

要访问此特定部分的源代码,请参阅packt.live/2Zz0TnU

您也可以在packt.live/2AoxS63上在线运行此示例

有了这个,我们已经建立了主要的三角函数,并看到它们如何在角度和相关的三角函数值之间提供操作,由 sin、cos 或 tan 函数给出。此外,我们看到这三个函数是周期性的,即每 2π重复一次,而前两个是有界的,即它们可以取的值永远不会超出区间[-1,1]。这些值可以直接在 Python 或科学口袋计算器中找到。然而,在许多情况下,需要进行反向过程:如果我给出 sin、cos 或 tan 的值,我能找到角度吗?这样的函数存在吗?我们将在下一节中回答这些问题。

反三角函数

反三角函数是三角函数的反函数,与它们的对应函数一样有用。反函数是一个反转原始函数操作或结果的函数。请记住,三角函数接受角度作为输入值,并输出纯数(比值)。反三角函数则相反:它们接受纯数作为输入,并给出角度作为输出。因此,例如,如果一个点π被映射到点-1(如 cos 函数所做的那样),那么它的反函数需要完全相反。这种映射需要对反函数定义的每个点都成立。

sin(x)函数的反函数称为arcsin(x):如果y=sin(x),那么x=arcsin(y)。请记住sin是一个周期函数,因此许多不同的x被映射到相同的y。因此,反函数会将一个点映射到几个不同的点。这是不允许的,因为它与函数的定义相冲突。为了避免这个缺点,我们需要限制我们的arcsin(以及类似地arccos)的定义域为区间[-1,1],而映射y=arcsin(x)y=arccos(x)则限制在范围[-π/2,π/2]和[0, π]。

我们可以定义三个基本的反三角函数如下:

  • arcsin(x) = y,使得 arcsin(sin(x)) = x

  • arccos(x) = y,使得 arccos(cos(x)) = x

  • arctan(x) = y,使得 arctan(tan(x)) = x

在 Python 中,这些函数可以从math模块或numpy库中调用。由于大多数 Python 实现的三角反函数返回弧度,我们可能希望将结果转换为度数。我们可以通过将弧度乘以 180 然后除以π来实现这一点。

让我们看看这如何在代码中编写。请注意,输入x表示为-1 和 1 之间的纯数,而输出表示为弧度。让我们导入所需的库并声明x的值:

from math import acos, asin, atan, cos
x = 0.5

现在,要打印余弦的反函数,添加以下代码:

print(acos(x))

输出如下:

1.0471975511965979

要打印正弦的反函数,添加以下代码:

print(asin(x))

输出如下:

0.5235987755982989

要打印正切的反函数,添加以下代码:

print(atan(x))

输出如下:

0.4636476090008061

让我们尝试为acos函数添加一个超出范围[-1,1]的输入:

x = -1.2
print(acos(x))

我们将会得到一个错误,如下所示:

Traceback (most recent call last):
    File "<stdin>", line 1, in <module>

asin也会发生类似的情况。这是可以预料到的,因为不存在任何角度φ可以返回-1.2作为 cos(或 sin)。然而,这个输入在atan函数中是允许的:

x = -1.2
print(atan(x))

输出如下:

-0.8760580505981934

最后,让我们来看看反函数arccos(cos(x))的反函数给我们带来了什么:

print(acos(cos(0.2)))

输出如下:

0.2

正如预期的那样,我们检索到cos函数的输入值。

反三角函数在数学、物理和工程学中有各种应用。例如,可以使用反三角函数来计算积分。不定积分如下:

图 5.12:反三角函数

图 5.12:反三角函数

在这里,a是一个参数,C是一个常数,积分立即可以通过反三角函数得到解决。

练习 5.05:使用反三角函数找到通往宝藏的最短路径

在这个练习中,您将获得一张指向B的秘密地图,几个世纪以来一些宝贵的宝藏一直在那里。您在点A,指令很明确:您必须向南导航 20 公里,然后向西导航 33 公里,以便到达宝藏。然而,直线段AB是最短的。您需要找到地图上的角度θ,以便您的导航正确定位:

图 5.13:点 A、B 和 C 的图形表示

图 5.13:点 A、B 和 C 的图形表示

我们需要找到角θ,即线段ABAC之间的角度。按照以下步骤进行:

  1. 导入atan(arctan 或反正切)函数:
from math import atan, pi
  1. 使用BCAC找到θ的正切:
AC = 33
BC = 20
tan_th = BC/AC
print(tan_th)

输出如下:

0.6060606060606061
  1. 然后,通过取反正切函数来找到角度。其参数是θ的正切:
theta = atan(tan_th)
  1. 将其转换为度并打印出值:
theta_degrees = theta*180/pi
print(theta_degrees)

输出如下:

31.218402764346372

因此,答案是我们需要转动 31.22 度才能正确导航。

  1. 作为奖励分,计算我们将沿着路径AB行进的距离。这只是由勾股定理给出的:

AB2 = AC2 + BC2

在 Python 中,使用以下代码:

AB = (AC**2 + BC**2)**0.5
print(AB)

输出如下:

38.58756276314948

课程将是 38.59 公里。

在 Python 中通过调用find_hypotenuse()函数很容易计算。正如预期的那样,这比路径AC + BC = 53公里要短得多。

注意

要访问此特定部分的源代码,请参考packt.live/31CF4qr。

您还可以在packt.live/38jfVlI上在线运行此示例。

练习 5.06:找到与对象的最佳距离

您正在参观当地的竞技场观看您最喜欢的节目,您站在竞技场的中间。除了主舞台外,还有一个观看屏幕,这样人们就可以观看并不会错过节目的细节。屏幕底部距离您的眼睛高 3 米,屏幕本身高 7 米。视野角是通过观看屏幕的底部和顶部形成的。找到最佳距离x,使视野角最大化:

图 5.14:眼睛和屏幕之间形成的视野角

图 5.14:眼睛和屏幕之间形成的视野角

这是一个稍微复杂的问题,需要一些代数,但我们将把它分解成简单的步骤并解释逻辑。首先注意问题的情节如何引导我们并帮助我们找到解决方案。这个明显复杂的现实世界问题转化为一个更抽象和简单的几何图像。按照以下步骤完成此练习:

  1. 计算x。这是三角形的底边,也是角θ1(也是θ=θ12)的邻边。答案x将由观看角θ2 或等效地,tan(θ2)最大化的条件给出。从屏幕的前述图中,我们可以立即得出三个角度的以下关系:θ1(内角)、θ2(外角)和θ=θ12:

tan(θ1) = 对边/邻边 = 3/x

tan(θ) = tan(θ12) = 对边/邻边 = (7+3)/x .

现在,使用代数来处理这两个关系,并得到θ2 的条件。

  1. 两个角的正切和的已知身份如下:图 5.15:两个角的正切的公式

图 5.15:两个角的正切的公式

通过在后一个关系中代入我们找到的tan(θ)tan(θ1),并经过代数运算,我们得到以下结果:

tan(θ2) = 7x/(30+x2) 或

θ2 = arctan(7x/(30+x2)).

换句话说,我们已经结合了问题的要素,并发现角度θ1 应该随着距离x的变化而变化,这是前一行给出的函数x的函数。

  1. 让我们绘制这个函数,看看它是如何变化的。首先,加载必要的库:
from matplotlib import pyplot as plt
import numpy as np
  1. 然后,通过使用numpyarctan方法,通过定义域x和值y来绘制函数。这些可以使用pyplotplot()方法轻松绘制,如下所示:
x = np.linspace(0.1, 50, 2000)
y = np.arctan(7*x / (30+x**2) )
plt.plot(x,y)
plt.show()

输出将如下所示:

图 5.16:使用 arctan 方法绘制函数的图形

图 5.16:使用 arctan 方法绘制函数的图形

从前面的图表中,我们可以看到函数获得了最大值。

  1. 确定函数的最大值y和位置x,以及发生这种情况的位置:
ymax = max(y)
xmax = x[list(y).index(ymax)]
print(round(xmax,2), round(ymax,2))

输出如下:

5.47 0.57
  1. 最后,将找到的角度转换为度数:
ymax_degrees = round(ymax * 180 / np.pi, 2)
print(ymax_degrees)

输出如下:

32.58

因此,观察角度θ2 在 32.58 度时达到最大值,并且当我们站在距屏幕 5.47 米的地方时发生。我们使用三角函数和反三角函数,在 Python 中实现它们,并找到了一个来自现实生活中几何设置的问题的答案。这更加清楚地说明了几何和三角学概念如何被有用地和轻松地编码以提供预期的结果。

注意

要访问此特定部分的源代码,请参阅packt.live/2VB3Oez

您也可以在packt.live/2VG9x2T上在线运行此示例。

现在,我们将继续研究数学中的另一个核心概念,它在代数、物理学、计算机科学和应用数据科学中有着广泛的应用:向量。

向量

向量是具有大小(大小)和方向(方向)的抽象数学对象。向量由一个箭头表示,它有一个基(尾部)和一个头。箭头的头部显示向量的方向,而箭头的长度显示它的大小。

标量与向量相反,是一个单独的数字。它是一个非向量,即一个纯整数、实数或复数(我们稍后会看到),它没有元素,因此没有方向。

向量通常用粗体字母A、带箭头的字母或普通字母表示,如果在讨论中关于符号的表示没有歧义。向量A的大小被写成|A|或简单地写成A。现在,让我们来看看各种向量运算。

向量运算

简而言之,向量是由两个、三个或更多个数字组成的集合(可以想象成列表或数组),形成一个数学对象。这个对象存在于一个特定的几何空间中,称为向量空间,具有一些属性,如度量属性和维度。向量空间可以是二维的(想象一下你的书页上的平面),三维的(我们周围的普通欧几里德空间),或者在数学和物理学中的许多抽象情况下更高维度的。用于识别向量的元素或数字等于空间的维度。现在我们已经定义了一个向量空间——向量的游乐场——我们可以用一组坐标轴(通常的xyz轴)来装备它,标记原点并测量空间。在这样一个明确定义的空间中,我们需要确定一组数字(两个、三个或更多)来唯一定义一个向量,因为向量被假定从坐标轴的原点开始。向量的元素可以是整数、有理数、实数或(很少)复数。在 Python 中,它们通常由列表或 NumPy 数组表示。

与实数类似,向量上定义了一组线性运算。在两个向量 A = (a1, a2, a3)和 B = (b1, b2, b3)之间,我们可以定义以下内容:

图 5.17:点 A、B 和 C 及它们在执行向量运算时的关系

图 5.17:执行向量操作时点 A、B 和 C 及其关系

现在让我们看看可以对这些向量执行的各种操作:

  • 加法作为导致向量C = A + B = (a1 + b1, a2 + b2, a3 + b3)的操作。

  • 减法作为导致向量C = A - B = (a1 - b1, a2 - b2, a3 - b3)的操作。

  • 点积(或内积或标量)C = b. b = a1 b1 + a2 b2 + a3 b3。

  • 向量C = A x B叉积(或外积),它垂直于由AB定义的平面,并具有元素(a2b3 - a3b2, a3b1 - a1b3, a1b2 – a2b1)。

  • 向量AB逐元素或 Hadamard 乘积是向量C,其元素是AB的元素的成对乘积;即C = (a1 b1, a2 b2, a3 b3)*。

我们可以在 Python 代码中定义并使用前述公式如下:

import numpy as np
A = np.array([1,2,3]) # create vector A
B = np.array([4,5,6]) # create vector B

然后,要找到AB的和,输入以下代码:

A + B

输出如下:

array([5, 7, 9])

要计算差异,输入以下代码:

A - B

输出如下:

array([-3, -3, -3])

要找到逐元素乘积,输入以下代码:

A*B

输出如下:

array([ 4, 10, 18])

要找到点积,使用以下代码:

A.dot(B)

输出如下:

32

最后,叉积可以计算如下:

np.cross(A,B)

输出如下:

array([-3,  6, -3])

请注意,向量加法、减法和点积是可结合和可交换的操作,而叉积是可结合但不可交换的。换句话说,a x b 不等于 b x a,而是 b x a,这就是为什么它被称为反交换

此外,向量A可以乘以标量λ。在这种情况下,您只需将每个向量元素乘以相同的数字,即标量:λ A = λ (a1, a2, a3) = (λ a1, λ a2, λ a3)

向量之间的另一个重要操作是点积,因为它可以说是数学、计算机科学及其应用中最常见的操作。点积是一种有趣的操作,它在实数领域中没有类似物。实际上,它需要两个向量作为输入,以产生单个标量作为输出。这意味着操作的结果(标量)与其成分(向量)的类型不同,因此通常不存在逆操作(点除法)。

根据定义,它如下所示:

图 5.18:θ角的图形表示*

图 5.18:θ角的图形表示

这可以用以下方程表示:

A.B = |A| |B| cos(θ)

这里,θ是向量AB之间的角度。

让我们看一些典型的情况:

  • 如果AB是正交的,则点积消失:

如果且仅当θ = angle(A,B) = π/2时,A.B = 0,因为|A||B|不为零。

  • 如果AB共线且共方向,则θ = 0cos(θ)=1A.B = |A| |B|。如果它们共线且方向相反,则θ = πcos(θ)=-1A.B = -|A| |B|

  • 它遵循对向量与自身的点积的定义:A.A = |A| |A|或|A| = √(A.A)

  • 它直接遵循A.B = |A| |B| cos(θ),其中两个向量之间的角度如下给出:θ = arccos(A.B / |A| |B|)

这里,arccos是我们在前一节中看到的反余弦函数。

例如,我们可以编写一个 Python 程序,利用numpy和前面给出的关系计算任意两个给定向量之间的角度θ

import numpy as np
from math import acos
A = np.array([2,10,0])
B = np.array([9,1,-1])

要找到每个向量的范数(大小),我们可以使用以下代码:

Amagn = np.sqrt(A.dot(A))
Bmagn = np.sqrt(B.dot(B))

作为替代方案,您还可以使用以下代码:

Amagn = np.linalg.norm(A)
Bmagn = np.linalg.norm(B)

打印它们的值:

print(Amagn, Bmagn)

您将得到以下输出:

10.198039027185569
9.1104335791443

这两种替代方案都会导致相同的结果,您可以通过再次打印AmagnBmagn来立即检查。

最后,我们可以按如下方式找到角度θ

theta = acos(A.dot(B) / (Amagn * Bmagn))
print(theta)

输出如下:

1.2646655256233297

现在,让我们看一个练习,我们将执行刚学到的各种向量运算。

练习 5.07:可视化向量

在这个练习中,我们将编写一个在二维空间中绘制两个向量的函数。我们将不得不找到它们的和以及它们之间的角度。

执行以下步骤完成这个练习:

  1. 导入必要的库,即numpymatplotlib
import numpy as np
import matplotlib.pyplot as plt
  1. 创建一个函数,接受两个向量作为输入,每个向量作为一个列表,绘制它们,并可选择绘制它们的和向量:
def plot_vectors(vec1, vec2, isSum = False):

    label1 = "A"; label2 = "B"; label3 = "A+B"
    orig = [0.0, 0.0]  # position of origin of axes

vec1vec2列表中分别包含两个实数。每对数字表示相应向量的端点(头部)坐标,而原点设置为(0,0)。标签设置为"A""B""A+B",但您可以更改它们,甚至将它们设置为plot_vectors函数的变量(或不带)默认值。布尔变量isSum默认设置为False,和vec1+vec2将不会被绘制,除非显式设置为True

  1. 接下来,我们将坐标放在一个matplotlib.pyplot对象上:
    ax = plt.axes()
    ax.annotate(label1, [vec1[0]+0.5,vec1[1]+0.5] )   
    # shift position of label for better visibility
    ax.annotate(label2, [vec2[0]+0.5,vec2[1]+0.5] )
    if isSum: 
        vec3 = [vec1[0]+vec2[0], vec1[1]+vec2[1]]     
        # if isSum=True calculate the sum of the two vectors
        ax.annotate(label3, [vec3[0]+0.5,vec3[1]+0.5] )

    ax.arrow(*orig, *vec1, head_width=0.4, head_length=0.65)
    ax.arrow(*orig, *vec2, head_width=0.4, head_length=0.65, \
             ec='blue')
    if isSum:
        ax.arrow(*orig, *vec3, head_width=0.2, \
                 head_length=0.25, ec='yellow')
        # plot the vector sum as well

    plt.grid()
    e=3 
    # shift limits by e for better visibility
    plt.xlim(min(vec1[0],vec2[0],0)-e, max(vec1[0],\
                 vec2[0],0)+e) 
    # set plot limits to the min/max of coordinates
    plt.ylim(min(vec1[1],vec2[1],0)-e, max(vec1[1],\
                 vec2[1],0)+e) 
    # so that all vectors are inside the plot area

在这里,我们使用 annotate 方法向我们的向量添加标签,以及 arrow 方法来创建我们的向量。星号运算符*用于解包列表origvec1vec2中的参数,以便它们可以从arrow()方法中正确读取。plt.grid()在绘图的背景上创建一个网格,以引导眼睛,这是可选的。添加e参数是为了使绘图限制足够宽,绘图可读。

  1. 接下来,给我们的图表加上标题并绘制它:
    plt.title('Vector sum',fontsize=14)
    plt.show()
    plt.close()
  1. 现在,我们将编写一个函数,计算两个输入向量之间的角度,如前所述,借助点(内)积:
def find_angle(vec1, vec2, isRadians = True, isSum = False):
    vec1 = np.array(vec1)
    vec2 = np.array(vec2)

    product12 = np.dot(vec1,vec2)
    cos_theta = product12/(np.dot(vec1,vec1)**0.5 * \
                           np.dot(vec2,vec2)**0.5 )
    cos_theta = round(cos_theta, 12)
    theta = np.arccos(cos_theta)

    plot_vectors(vec1, vec2, isSum=isSum)
    if isRadians:
        return theta
    else:
        return 180*theta/np.pi

首先,我们将我们的输入列表映射到numpy数组,以便我们可以使用这个模块的方法。我们计算点积(命名为product12),然后将其除以vec1的大小与vec2的大小的乘积。回想一下,向量的大小由其与自身的点积的平方根(或**0.5)给出。根据点积的定义,我们知道这个数量是两个向量之间角度的余弦。最后,在四舍五入余弦以避免输入错误后,利用numpyarccos方法计算角度。

  1. 我们希望将我们编写的两个函数find_angleplot_vectors结合起来,并在后者内部调用前者。我们还希望给用户选择以弧度(isRadians=True)或度数(isRadians=False)打印角度结果的选项。现在我们准备尝试我们的函数。首先,让我们尝试使用两个垂直向量:
ve1 = [1,5]
ve2 = [5,-1]
find_angle(ve1, ve2, isRadians = False, isSum = True)

输出如下:

图 5.19:两个垂直向量的绘图

图 5.19:两个垂直向量的绘图

图看起来不错,结果是 90 度,如预期。

  1. 现在,让我们尝试使用相同的函数创建两个共线向量:
ve1 = [1,5]
ve2 = [0.5,2.5]
find_angle(ve1, ve2, isRadians = False, isSum = True)

输出如下:

图 5.20:两个共线向量的绘图

图 5.20:两个共线向量的绘图

输出为 0 度,如预期。

  1. 最后,再次使用相同的函数,让我们创建两个通用向量:
ve1 = [1,5]
ve2 = [-3,-5]
find_angle(ve1, ve2, isRadians = False, isSum = True)

输出如下:

图 5.21:两个通用向量的绘图

图 5.21:两个通用向量的绘图

总之,我们已经学习了向量作为生活在向量空间中的数学对象。我们学会了如何在 Python 中构造和表示向量以及如何可视化它们。向量遵循一些简单的规则,并且可以进行操作。在处理实数时,加法和减法遵循完全相同的逻辑。乘法有些更复杂,并且定义了不同类型的乘积。最常见的乘积是内积或点积,由于其简单的几何表示,在数学和物理界广受欢迎。我们学会了如何在 Python 中计算任意两个向量的点积,并且利用我们对点积的知识(以及一些 NumPy 方法)找到了这对向量之间的角度。简而言之,在二维空间中,向量是一对形成有趣属性的数字。

注意

要访问此特定部分的源代码,请参阅packt.live/2Zxu7n5.

您还可以在packt.live/2YPntJQ.上在线运行此示例

接下来,我们将学习如何将一对两个数字组合成一个更令人兴奋的对象,即复数。

复数

自古代数字系统以来,数学思想一直在发展,关于数字及其关系的数学思想也在历史上从具体到抽象不断演变。例如,自然数集合的概念是为了让我们周围世界中的所有物体直接对应于该集合中的某个数字。随着算术和代数的发展,人们意识到除了自然数或整数之外,还需要小数和有理数,因此引入了小数和有理数。同样,在毕达哥拉斯时代,人们发现有理数无法解决我们当时所知的几何构造的所有数学问题。这是因为引入了无理数——从其他数字的平方根得出并且没有比率表示的数字。

复数是实数的扩展,并包括一些特殊的数字,可以解决一些实数无法解决的方程。

这样的数字实际上是存在的,并且有符号i。它被称为虚数或虚数单位,尽管它并不虚构;它和我们见过的所有其他数字一样真实,并且正如我们将看到的那样,具有一些非常美丽的性质。

复数的基本定义

我们将虚数i定义如下:

i2 = -1

任何由实数和虚数(部分)组成的数字都称为复数。例如,考虑以下数字:

z = 3 – i

z = 14/11 + i 3

z = -√5 – i 2.1

所有前述的数字都是复数。它们的实部表示为Re(z),虚部表示为Im(z)。对于前述的例子,我们得到以下结果:

Re(z) = 3 , Im(z) = -1

Re(z) = 14/11 , Im(z) = 3

Re(z) = -√5 , Im(z) = -2.1

让我们看一些使用代码的例子。在 Python 中,虚数单位用字母j表示,复数写成如下形式:

c = <real> + <imag>*1j,

这里,<real><imag>都是实数。同样,复数可以定义如下:

c = complex(<real>, <imag>).

在代码中,它变成了如下形式:

a = 1
b = -3
z = complex(a, b)
print(z)

输出如下:

(1-3j)

我们还可以使用realimag函数来分离任何复数z的实部和虚部。首先,让我们使用real函数:

print(z.real)

输出如下:

1.0

现在,使用imag函数:

print(z.imag)

输出如下:

-3.0

换句话说,任何复数都可以被分解并写成z=Re(z) + i Im(z)。因此,一个复数是两个实数的一对,并且可以被视为生活在二维空间中的向量。因此,向量的几何和代数,如前一节所讨论的,也可以在这里应用。

接受复数作为输入的方法和函数可以在cmath模块中找到。该模块包含复数的数学函数。那里的函数接受整数、浮点数或复数作为输入参数。

复共轭被定义为与复数z具有相同实部和相反虚部的复数z**(也);也就是说,如果z = x+iy,那么z* = x -iy。注意,乘积zz**是实数x2+y2,这给出了z的模的平方:

zz = zz = |z|2

复数被绘制在复平面上,类似于向量(如下图所示)。这是由实部在x轴上和虚部在y轴上形成的平面。复共轭只是相对于实轴的向量的反射:

图 5.22:复数的绘图

图 5.22:复数的绘图

复数z可以被视为具有坐标(x, y)的向量。或者,我们可以将其写成具有极坐标(r, φ)的向量。复共轭z**或是一个与z相同的向量,但相对于x*轴反射。

如果一个复数的实部和虚部都是零,那么这个复数就是零。可以对两个复数z = x+iyw = u+iv执行以下操作:

  • 加法z+w = (x+u) + i(y+v)

  • 减法z-w = (x-u) + i(y-v)

  • 乘法z w = (x+iy)(u+iv) = (xu-yv) + i(xv + yu)

  • 除法z/w = (x+iy)/(u+iv) = (ux+vy)+i(uy-xv) / (u2+v2)

极坐标表示和欧拉公式

复数很容易被视为复平面上的向量。因此,它有一个大小,由向量的大小确定,以及一个方向,由与x(实)轴形成的角度φ确定。要确定这两个数,我们需要找到z=x+iy的绝对值(或模),r

r = |z| = √x2+y2

它的角度(也称为参数arg相位),φ,如下:

φ = arg(z) = arctan(x+iy) = arctan(y/x)

这两个关系都源自复向量的几何关系。第一个关系简单地是勾股定理的应用,而第二个来自对角度φ应用正切关系。

通过检查向量的图形表示(见前面的图),我们可以看到以下内容:

cos(φ) = x/r 和

sin(φ) = y/r

x = r cos(φ) 和

y = r sin(φ)

通过用z = x+iy替换这些,我们得到以下结果:

z = r (cos(φ) + i sin(φ))

我们可以在 Python 中编写一些代码,一旦给定了(x, y)(笛卡尔坐标),就可以找到(r, φ)(极坐标),反之亦然:

def find_polar(z):
    from math import asin
    x = z.real
    y = z.imag
    r = (x**2 + y**2)**0.5
    phi = asin(y/r)
    return r, phi
find_polar(1-3j)

输出如下:

(3.1622776601683795, -1.2490457723982544)

同样,我们可以使用cmath模块中的polar方法:

import cmath
z = 1-3j
cmath.polar(z)

输出如下:

(3.1622776601683795, -1.2490457723982544)

注意

不允许输入(0,0),因为这会导致除以零。

因此,复数可以用它的模,r,和相位,φ,来表示,而不是用它的横坐标(x,实部)和纵坐标(y,虚部)。模,r,是一个实数,非负数,相位,φ,在区间[-π,π]内:对于纯实数,它是0π,对于纯虚数,它是π/2-π/2。后者的表示被称为极坐标,而前者被称为矩形或笛卡尔;它们是等价的。也可以用以下表示:

z = r e= r (cos(φ) + i sin(φ))

这是自然对数的底。这被称为欧拉公式。特殊情况φ=π给出了以下结果:

e+ 1 = 0

这就是欧拉恒等式。

使用欧拉公式的好处在于,复数乘法和除法获得了简单的几何表示。要乘(除)两个复数z1 和z2,我们只需将它们各自的模相乘(除)并加上(减去)它们的幅角:

z1 ** z2 = r e= r1 ** r2 ei(φ1+φ2)

现在,让我们在 Python 中使用一些复数进行数学运算。我们将编写两个复数的加法、减法、乘法和除法:

def complex_operations2(c1, c2):
    print('Addition =', c1 + c2)
    print('Subtraction =', c1 - c2)
    print('Multiplication =', c1 * c2)
    print('Division =', c1 / c2)

现在,让我们尝试这些函数对一对通用的复数c1=10+2j/3c2=2.9+1j/3

complex_operations2(10+2j/3, 2.9+1j/3)

输出如下:

Addition = (12.9+1j)
Subtraction = (7.1+0.3333333333333333j)
Multiplication = (28.77777777777778+5.266666666666666j)
Division = (3.429391054896336-0.16429782240187768j)

我们可以对纯实数和纯虚数做同样的操作:

complex_operations2(1, 1j)

输出如下:

Addition = (1+1j)
Subtraction = (1-1j)
Multiplication = 1j
Division = -1j

从最后一行,我们很容易看出1/i = -i,这与虚数单位的定义一致。cmath库还为复数提供了有用的函数,如phasepolar,以及带有复数参数的三角函数:

import cmath
def complex_operations1(c):
    modulus = abs(c)
    phase = cmath.phase(c)
    polar = cmath.polar(c)
    print('Modulus =', modulus)
    print('Phase =', phase)
    print('Polar Coordinates =', polar)
    print('Conjugate =',c.conjugate())
    print('Rectangular Coordinates =', \
           cmath.rect(modulus, phase))
complex_operations1(3+4j)

输出如下:

Modulus = 5.0
Phase = 0.9272952180016122
Polar Coordinates = (5.0, 0.9272952180016122)
Conjugate = (3-4j)
Rectangular Coordinates = (3.0000000000000004+3.9999999999999996j)

因此,计算给定复数的模、相位或共轭变得非常简单。请注意,最后一行给出了复数的直角(或笛卡尔)形式,给定其模和相位。

现在我们已经了解了复数的算术和表示方式,让我们继续看一个涉及逻辑的练习,并结合我们在前几节中使用和学习的内容。

练习 5.08:复数的条件乘法

在这个练习中,您将编写一个函数,该函数读取一个复数c,如果复数的幅角大于零,则将其乘以自身,如果幅角小于零,则取c的平方根,如果幅角等于零,则不执行任何操作。绘制并讨论您的发现:

  1. 导入必要的库,并且可以选择地抑制任何警告(这不是必需的,但如果您希望保持输出整洁,这是有帮助的,因为警告取决于您使用的库的版本):
import cmath
from matplotlib import pyplot as plt
import warnings
warnings.filterwarnings("ignore")
  1. 现在,定义一个函数,该函数使用 Matplotlib 的pyplot函数来绘制输入复数c的向量:
def plot_complex(c, color='b', label=None):

    ax = plt.axes()
    ax.arrow(0, 0, c.real, c.imag, head_width=0.2, \
             head_length=0.3, color=color)
    ax.annotate(label, xy=(0.6*c.real, 1.15*c.imag))
    plt.xlim(-3,3)
    plt.ylim(-3,3)
    plt.grid(b=True, which='major') #<-- plot grid lines
  1. 现在,创建一个函数,读取输入复数c,通过调用之前定义的函数绘制它,然后根据输入的相位研究不同的情况。绘制操作前后的相位以及结果,以便将结果向量与输入向量进行比较:
def mult_complex(c, label1='old', label2='new'):

    phase = cmath.phase(c)
    plot_complex(c, label=label1)

    if phase == 0:
        result = -1
    elif phase < 0:
        print('old phase:', phase)
        result = cmath.sqrt(c)
        print('new phase:', cmath.phase(result))
        plot_complex(result, 'red', label=label2)
    elif phase > 0:
        print('old phase:', phase)
        result = c*c
        print('new phase:', cmath.phase(result))
        plot_complex(result, 'red', label=label2)
    return result

请注意,对于负相位,我们取c的平方根(使用math.sqrt()方法),而对于正相位,我们取c的平方。

  1. 现在,转换一个位于复平面上半部的数字:
mult_complex(1 + 1.2j)

输出如下:

图 5.23:位于复平面上半部的数字的绘图

图 5.23:位于复平面上半部的数字的绘图

这里,一个具有正幅角φ(蓝色向量)的复数正在被转换(或映射)为一个具有更大模的新复数(红色向量),并且新的幅角是先前值的两倍。这是预期的:记住欧拉公式c=r eiφ的极坐标表示?很明显,平方c2 是一个具有原始幅角φ和模r2 两倍的数字。

  1. 接下来,转换一个位于复平面下半部的数字:
mult_complex(1-1.2j)

输出如下:

图 5.24:位于复平面下半部的数字的绘图

图 5.24:位于复平面下半部的数字的绘图

在这种情况下,计算了平方根。与第一个例子类似,新转换的向量的模是原始向量的模的平方根,幅角是原始幅角的一半。

注意

有趣的事实:在两种情况下,向量都是逆时针旋转的。

  1. 编写一个while迭代,调用mult_complex()函数n次,以检查如果我们保持向量旋转会发生什么:
c0 = 1+1.2j
n = 0
while n < 6:
    c0 = mult_complex(c0, None, str(n))
    n+=1

输出如下:

图 5.25:旋转向量的绘图

图 5.25:旋转向量的绘图

通过这样,我们看到了向量和向量代数如何用于可视化几何运算。特别是,除法和乘法复数的结果是获得几何表示,这在处理大量数据和可视化时非常有用。

注意

要访问此特定部分的源代码,请参阅packt.live/31yU8W1

您也可以在packt.live/2BXWJOw上在线运行此示例。

活动 5.01:使用级数计算您的退休计划

在许多国家,一些雇主提供退休计划(也称为 401(k))。这些计划允许您直接从工资中捐款,因此是一种轻松有效的储蓄和投资退休的方式。您的任务是编写一些代码,根据捐款金额和持续时间计算和绘制您的月度回报。

退休计划会像等比数列一样随时间累积。这是一种投资:您每月存钱,以便以后每月收取,带有附加值或利息。计算退休回报的主要要素是您当前的余额,每月的捐款,雇主匹配(雇主的捐款),退休年龄,回报率(您从 401(k)投资中预期的平均年回报),预期寿命和任何其他费用。在现实情况下,会引入上限:雇主匹配(通常在 50%至 100%之间)不能超过您年薪的 6%。同样,员工的捐款在一年内不能超过一定金额(通常为 18K),无论工资有多高。

执行以下步骤以完成此活动:

  1. 确定我们问题的变量。这些将是我们函数的变量。确保仔细阅读活动描述,并内化已知和要计算的内容。

  2. 识别序列并编写一个函数,计算某一年n的退休计划价值。该函数应接受当前余额、年薪、年份n等输入,并返回每年的捐款、雇主匹配和退休总价值的元组。

  3. 识别级数并编写一个函数,计算n年后退休计划的累积价值。当前函数应读取输入,调用前一个函数计算每年计划的价值,并对所有(每年)储蓄进行求和。为了可视化目的,应将捐款(每年)、雇主匹配(每年)和总价值(每年)作为列表返回到一个元组中。

  4. 对各种选择的值运行函数,并确保其正常运行。

  5. 使用 Matplotlib 绘制结果。

注意

此活动的解决方案可在第 672 页找到。

摘要

在本章中,您已经得到了关于序列、级数、三角学、向量和复数等最核心数学概念的一般性和有益的阐述,更重要的是,我们使用具体而简短的例子在 Python 中实现了它们。作为一个现实生活的例子,我们研究了一个退休计划以及我们储蓄随时间的变化。然而,许多其他情况可以模拟成序列或级数,并通过应用向量或复数进行研究。这些概念和方法在物理学、工程学、数据科学等领域被广泛应用。线性代数,即对向量、矩阵和张量的研究,严重依赖于对几何和向量概念的理解,并几乎无处不在地出现在数据科学和神经网络的研究中。另一方面,几何和三角学明确地用于模拟物理运动(例如在视频游戏中)以及地理空间应用中的更高级概念。然而,对这些概念有背景知识使得使用和应用数据科学方法更加具体和可理解。

在下一章中,我们将讨论矩阵以及如何将它们应用于解决现实世界的问题。我们还将研究马尔可夫链,它们用于将与概率、矩阵和极限相关的概念联系在一起。

NDN74

ETB65

第六章:使用 Python 进行矩阵和马尔可夫链

概述

在本章中,我们打算使用 Python 深入研究矩阵及其应用。我们将探讨不同的矩阵操作技术,这将帮助我们有效地在实际应用中使用它们来构建有用的工具。

在本章结束时,您将了解矩阵并能对其进行操作。您将使用转移矩阵实现矩阵的一种应用,称为马尔可夫链,然后使用马尔可夫链和马尔可夫性质来解决现实世界问题。

介绍

矩阵是数字或表达式按行和列排列并被视为单个实体的矩形数组。由于我们将矩阵视为单个对象,如果我们对其进行操作,它将应用于其中的每个元素:

图 6.1:一个简单的 m×n 矩阵,其中 m 行 n 列

图 6.1:一个简单的 m×n 矩阵,其中 m 行 n 列

一个简单的线性单维数组很少能满足我们生活中与空间和时间相关的几乎所有属性都需要超过一个维度。紧凑性是使用矩阵的主要原因之一。当矩阵是封闭和有界的,或者简单地其点彼此之间的距离固定时,矩阵是紧凑的。主要基于这两个原因,矩阵在几乎每个领域都有应用,包括从图论、线性变换和概率论到物理学的不同分支,如量子力学和电磁学等基本数学概念。

马尔可夫链模型及其变体是这样一个应用,将矩阵、极限和概率的概念联系在一起,以产生在不确定性占主导地位的现实世界问题中的结果。在数学空间中,每当存在不确定性时,决策都是基于概率的;这构成了马尔可夫链的基础。这些使用一种特定类型的矩阵,称为转移矩阵,来构建状态图。马尔可夫链实际上是一个无记忆过程,主要基于当前状态来决定下一个状态的结果。我们在一些非常重要的用例中发现它们的应用,包括页面排名算法、自动完成应用程序和文本生成器。我们将在本章后面更详细地研究这些概念,为此,我们首先需要了解矩阵。

单矩阵上的矩阵操作

在本章中,我们将学习不同的矩阵操作方式以及如何在 Python 中实现它们。广义上理解矩阵的工作意味着理解二维或多维数组的基本原理。一旦我们对二维矩阵的基础有了很好的理解,有兴趣的人可以深入研究矩阵的高级研究,其中包括稀疏矩阵、向量空间、特征值和特征向量等特殊类型的矩阵,这可能涉及超过两个维度。

Python 中的矩阵可以使用列表或数组来实现。在 Python 中,嵌套列表可以很好地工作,但 Python 有一个强大的包,使矩阵实现变得更容易,称为 NumPy。SciPy 是另一个帮助进行矩阵操作的包,但通常更适合于更大的矩阵计算。在本章中,我们将在整个章节中使用这两个模块。

矩阵的基本操作

在这一点上,假设您已经安装了 Python 及其默认库以运行基本的 Python 程序。

一旦您安装好了这个包,让我们定义我们的第一个矩阵:

# Importing Numpy package
import numpy as np
# Initializing and printing matrix z
x = np.array([[1, 2], [3, 4]])
print(x)

这个矩阵与下面的矩阵z相同,只是更好地表示,并且在可能的情况下,作为一个良好的实践是可取的:

# Initializing and printing matrix z
z = np.array([[1, 2],\
              [3, 4]])
print(type(z))

请注意,在前面的代码中,我们已经打印了变量z的类型。你能猜到输出是什么吗?输出应该如下所示:

<type 'numpy.ndarray'>

ndarray是 NumPy 使用的标准数组格式。数组对象是同质的、多维的,并且具有与其分配相关联的数据类型对象。

例如,让我们取矩阵z中的一个元素,这个矩阵我们之前定义过:

# Printing the data types for matrix-z
print(z)
print(type(z))
print(z[0][1])
print(type(z[0][1]))

这将产生以下输出:

[[1 2]
 [3 4]]
[[5 6]
 [7 8]]
<type 'numpy.ndarray'>
2
<type 'numpy.int64'>
[Finished in 0.221s]

我们发现给定矩阵的元素是int64类型,即 64 位整数类型。其他数据类型包括np.float32np.complexnp.boolnp.objectnp.string_np.unicode_

目前,知道我们几乎每个数据结构都是使用 Python 版本 3.8 和 NumPy 版本 1.17 就足够了。截至出版日期,NumPy 有一个特殊的类叫做matrix类,它几乎做了与ndarray相同的事情。唯一的区别是matrix类保持其 2D 性质,并且内置了一些操作符,比如*表示乘法,**表示幂。虽然matrix类可能会派上用场并且可以被探索,但官方的 NumPy 文档建议使用普通的ndarray而不是np.matrix,因为后者可能会在将来被移除。因此,值得注意的是,在这个上下文中,术语ndarray可以被视为与术语matrix同义,并且在本章中可以互换使用。

让我们继续使用ndarray。假设我们有一个单独的矩阵,我们将看到一些我们可以对其进行的简单操作。我们可以使用之前定义的相同矩阵z

让我们打印元素的总和:

# Sum of all elements of the matrix
print(z)
print(np.sum(z))

这将产生以下输出:

[[1 2]
 [3 4]]
10
[Finished in 0.237s]

这很简单。现在让我们看看我们可以做的其他事情。

让我们找到z矩阵的最大值、最小值、平均值和标准差:

# Value of the largest integer in the matrix
print("Max ", np.max(z))
# Value of the smallest integer in the matrix
print("Min ", np.min(z))
# Mean of elements in the matrix
print("Mean ", np.mean(z))
# Standard deviation
print("Standard deviation ", np.std(z))

这将产生以下输出:

('Max ', 4)
('Min ', 1)
('Mean ', 2.5)
('Standard deviation ', 1.1180339887498949)
[Finished in 0.207s]

还有许多其他操作可以在ndarray上执行,包括常见的数学函数,如 sin、cos、log 和平方根,以及统计函数,如寻找相关系数和累积和,我们很快将使用其中一些。

检查矩阵

现在,我们将处理一些有用的函数,这些函数可以帮助我们更多地了解我们正在使用的任何数组。让我们继续使用到目前为止一直在使用的相同矩阵/ndarray z

  1. 让我们打印一个矩阵的信息:
# 1\. Information about a matrix
print("Information: ")
print(np.info(z))

输出将如下所示:

Information: 
class:  ndarray
shape:  (2, 2)
strides:  (16, 8)
itemsize:  8
aligned:  True
contiguous:  True
fortran:  False
data pointer: 0x7ff57665fef0
byteorder:  little
byteswap:  False
type: int64
None
  1. 现在,为了确定矩阵的形状,请写下以下代码:
# 2\. Gives the shape of the matrix
print("Shape: ")
print(np.shape(z))

输出将如下所示:

Shape: 
(2, 2)
  1. 要检查矩阵是 2D 还是 3D 矩阵,请写下以下代码:
# 3\. Dimensions of the matrix
print("Dimensions: ")
print(np.ndim(z))

输出将如下所示:

Dimensions: 
2
  1. 要打印矩阵的数据类型,请使用以下代码:
# 4\. Data type of the matrix
print("Data type of elements")
print(z.dtype.name)

输出将如下所示:

Data type of elements
int64
  1. 要打印矩阵的长度,请使用以下代码:
print("Length of the ndarray: ")
print(len(z))

输出将如下所示:

Length of the ndarray: 
2

正如我们所看到的,info函数已经显示了我们调用的另外两个函数的值,即 shape 和 type。然而,这些函数只能起到有限的作用,有时这就是所需的。正如我们所知,多维ndarray是一个数组的数组,而 NumPy 数组的len函数将始终是第一维的长度。如果z是一个 2D 矩阵,那么len(z)将是z中的行数。

在接下来的练习中,我们将创建一个矩阵。我们可以用嵌套列表创建一个矩阵,但是这个问题将详细说明矩阵在现实世界中是如何打包和利用的。

练习 6.01:计算阳光到达地球所需的时间

在这个练习中,我们将计算阳光到达地球所需的时间。

正如我们所知,地球围绕太阳在椭圆轨道上运行。因此,地球和太阳之间的距离会发生变化,这将改变光到达地球所需的时间。我们可以使用三个主要方程来处理这个问题。

计算时间的数学公式如下:

图 6.2:计算时间的公式

图 6.2:计算时间的公式

我们需要计算地球和太阳之间的距离r

图 6.3:计算距离的公式

图 6.3:计算距离的公式

在前述方程中,a的值为 149,600,000 千米,这是半长轴距离,e为 0.0167,这是地球轨道的离心率,θ是从近日点开始的角度。

需要计算前述方程中所需的因变量θ,计算如下:

图 6.4:计算因变量的公式

图 6.4:计算因变量的公式

请注意这里n是从 1 月 3 日近日点开始的天数。为了简化问题,我们将其视为一年的开始。

不要被这些方程式所困扰,它们只是简单的常数数学乘法,可以轻松通过一个称为math的巧妙 Python 库解决。

现在让我们开始练习:

  1. 首先,导入mathnumpy库:
import math
import numpy as np

我们稍后将使用这些库。

  1. 接下来,定义两个常数并使用大写字母,这是命名这些常数的标准 Python 做法:
def earth_sun_distance():
    # Semi-major axis between earth and sun
    A = 149600000
    # Eccentricity of earth
    E = 0.0167
    l = []

A在这里是地球和太阳之间的半长轴距离。

E被称为地球的离心率。

l是我们初始化用于存储值的列表。

  1. 让我们跳到代码的主要部分。对于 365 天中的每一天,计算theta,因为每年的每一天它都不同。然后,计算地球到太阳的距离,最后将该距离附加到列表中:
    # Calculating the distance between earth and sun
    for i in range(365):
        theta = (2 * math.pi * i) / 365.25
        r = A*(1 - E**2) / (1 - (E * math.cos(theta)))
        l.append(r)
    return l

请注意我们之前导入的math库中的math.pimath.cos函数的使用。

  1. 计算所需的时间(以秒为单位),假设光速为恒定值 299,792 千米/秒:
# Calculating the time taken
S = 299792
t = np.divide(l, S)

在这里,我们首先利用 NumPy 数组的功能,使用divide函数,它将值应用于列表的所有成员,而无需使用循环。我们将其值存储在t中,它会自动转换为 NumPy 数组。

  1. 最后,我们在这里做了两件事。首先,我们使用另一个有用的 Python 函数zip(),它将两个列表的相应元素绑在一起,然后我们使用np.asarray()函数,将元组列表转换为 NumPy 数组:
sunny = np.asarray(list(zip(l, t)))
print("Earth sun distance: \n", sunny)

运行程序以查看输出:

Earth sun distance:
[[  1.52098320e+08   5.07346160e+02]
 [  1.52097938e+08   5.07344885e+02]
 [  1.52096791e+08   5.07341061e+02]
 [  1.52094881e+08   5.07334688e+02]
 [  1.52092207e+08   5.07325770e+02]
 [  1.52088771e+08   5.07314309e+02]
 ...
 [  1.52072354e+08   5.07259546e+02]
 [  1.52078259e+08   5.07279242e+02]
 [  1.52083406e+08   5.07296411e+02]
 [  1.52087793e+08   5.07311046e+02]
 [  1.52091420e+08   5.07323143e+02]
 [  1.52094284e+08   5.07332697e+02]
 [  1.52096385e+08   5.07339707e+02]
 [  1.52097723e+08   5.07344168e+02]]
[Finished in 0.197s]

现在,我们以系统化的表格格式拥有了地球和太阳之间的距离值以及光线到达地球所需的时间。我们可以继续向我们的矩阵添加其他参数,这就是使用矩阵和 NumPy 数组的灵活性。

请注意,这些值绝不准确,我们为简单起见做了一些安全假设,但这仍然是矩阵可以用于几乎任何事情的很好的例证。还要注意,这里反映的值是 Python 中使用的科学计数法格式,并且可以根据需要轻松转换为浮点数或其他类型。左边的值是以千米为单位,右边的值是以 507.346...秒的形式。

  1. 将结果附加如下:
d = []
for i in range(1,len(l) - 1):
    d.append(l[i]-l[i-1])
print(d)

输出的一部分如下:

[-382.2014582455158, -1146.4797523021698, -1910.3842301666737,
 -2673.6658524870872, -3436.075836390257, -4197.365758448839,
 -4957.287656396627, -5715.5941315591335, -6472.038449823856,
 -7226.374643236399, -7978.357610076666, -8727.743215203285,
 -9474.288]

注意

要访问此特定部分的源代码,请参阅packt.live/3irS3Bk.

您也可以在packt.live/3abV9pe上在线运行此示例。

矩阵中的运算和乘法

现在我们了解了如何执行简单的操作,让我们执行一系列操作,例如调整大小、重塑和转置,形成一个新的矩阵。

当矩阵中的行和列的索引交换时,沿对角线翻转它们,这被称为矩阵的转置。现在让我们来看看如何转置矩阵。这可以通过三种不同的方式完成,如下所示:

print("matrix z: ")
print(z)
# Transpose matrix
# Method 1
print("new matrix: ")
print(np.transpose(z))
# Method 2
print(z.transpose())
# Method 3
t = z.transpose()
print(t)

如果您运行此代码,输出将如下所示:

matrix z: 
[[1 2]
 [3 4]]
new matrix: 
[[1 3]
 [2 4]]
[[1 3]
 [2 4]]
[[1 3]
 [2 4]]
[Finished in 0.207s]

在方法 3 中,我们将转置矩阵的值分配给一个新变量。

我们现在将看到的函数是在执行矩阵操作时最常用的函数之一。

我们将处理的第一个函数是展平。将矩阵转换为单行的过程称为矩阵的展平

# Flatten the array
y = z.ravel()
print(y)

这将产生以下输出:

[1 2 3 4]

现在让我们看一下各种比较运算符:

# Comparison operators on matrix
print(z == 3)

在这种情况下,矩阵中的所有值都与基本值(在本例中为3)进行比较,并且布尔结果针对矩阵中的相应索引显示。输出如下:

[[False False]
 [ True False]]

要检查z的值是否小于3,请使用以下代码:

print(z < 3 )

输出如下:

[[ True  True]
 [False False]]

reshape是一个函数,用于根据函数内部传递的行和列的值来改变矩阵的维度。要重塑矩阵,请使用以下代码:

# Reshaping the matrix
r = z.reshape(4,1)
print(r)

这将产生以下输出:

[[1]
 [2]
 [3]
 [4]]

要调整矩阵的大小,请使用以下代码:

# Resizing the matrix
resize = np.resize(z,(3,3))
print(resize)

这将产生以下输出:

 [[1 2 3]
 [4 1 2]
 [3 4 1]]
[Finished in 0.205s]

请注意,当我们使用resize函数时,值会被迭代重复,直到满足大小,即使可能并未添加原始矩阵中的所有值。还要注意,通常使用reshape函数代替ravel函数来展平矩阵。

矩阵中的轴

这个相对简单的主题易于理解,但同样容易误解,因此我们需要独立处理它。对于 Python 中的数组,为具有多个维度的任何矩阵或数组定义轴。在处理复杂的数据科学和数据操作问题时,我们经常需要处理超过两个维度的数据,这很难可视化并且容易混淆。为了简化这一点,矩阵中的维度由轴表示。

简单地说,2D 矩阵将有两个轴,水平和垂直,但在这种情况下,它们将以数字形式表示或命名。第一个轴称为轴 0,沿着行向下垂直运行,第二个称为轴 1,沿着列水平运行。

我们之前使用的一组函数可以用于沿着单个轴运行,这在大型数据集的情况下减少了计算的开销。让我们处理一些例子。为了清晰起见,我们将创建一个稍大一些的矩阵:

import numpy as np
z = np.array([[1, 5, 9, 4],\
              [8, 3, 7, 6]])
print(z.max(axis = 0))
print(z.max(axis = 1))

这将产生以下输出:

[8 5 9 6]
[9 8]
[Finished in 0.198s]

这里发生的是沿着每个轴计算最大值。在返回的第一个数组中,比较是185397,以及46,因为这些是沿着轴 0 的唯一两个元素。类似地,在轴 1 的情况下,比较是沿着子数组的四个元素进行的,并返回最大元素。

让我们再举一个例子:

print(z.sum(axis = 1))

你能猜到结果吗?让我们看一下输出:

[19 24]
[Finished in 0.255s]

现在让我们看一个最后的、更复杂的例子:

print(np.concatenate((z[1], z[0]), axis=0))

这将产生以下输出:

[8 3 7 6 1 5 9 4]
[Finished in 0.252s]

我们所做的首先是使用一个接受两个数组的连接函数。取出的两个数组分别是数组z的第一个和第二个元素,分别是[8 3 7 6][1 5 9 4]。由于这两个数组各自具有单个维度,我们沿着轴 0 取它们。如果我们在这里输入轴 1,NumPy 将抛出AxisError,如下所示:

print(np.concatenate((z[1], z[0]), axis=1))

这将产生以下输出:

Traceback (most recent call last):
  File "/matrix.py", line 9, in <module>
    print(np.concatenate((z[1], z[0]), axis=1))
numpy.core._internal.AxisError: axis 1 is out of bounds for array of dimension 1

练习 6.02:矩阵搜索

在这个练习中,我们将在按升序排序的矩阵中搜索给定的输入值,无论是按行还是按列。这将帮助我们理解在矩阵内部遍历的一般规则,特别是如果我们不使用 NumPy 数组。

为了给出一个提示,我们将在矩阵上实现二分搜索。即使您以前没有处理过二分搜索,这也足够容易理解。

目标是根据值是否存在于矩阵中返回TrueFalse值:

  1. 让我们定义我们要搜索的输入矩阵:
matrix = [[7, 10, 15, 18],\
          [25, 29, 35, 47],\
           [56, 78, 85, 104]]
  1. 现在,让我们定义并编写一个名为matrixsearch()的函数,它将以此矩阵作为输入,以及我们要搜索的值。我们首先将处理边缘情况,即矩阵为空或目标值为非零的情况:
def matrixsearch(matrix, value):
    # Check for edge cases
    if value is None or not matrix:
         return False
  1. 接下来,我们将定义四个变量:
# Initialize the variables
    row = len(matrix)
    col = len(matrix[0])
    start = 0
    end   = row * col - 1

请注意这里rowcolumn变量的初始化方式。在任何矩阵中,它们的初始化方式都是一样的,值得理解。startend变量被初始化为矩阵中的第一个和最后一个值,因为矩阵已经排序,可以被视为一个单一的列表,从起始到对角线的另一端。

  1. 现在是程序的实际逻辑,我们将把它分解成几个步骤来帮助理解。在从起始到结束的循环中,首先我们找到矩阵的中点(将矩阵视为一个列表):
    while start <= end:
        mid = int((start + end) / 2)
  1. 然后,我们定义一个名为pointer的变量,它由我们找到的这个中间值的值初始化:
        pointer = matrix[int(mid/col)][int(mid%col)]
        print(int(mid/col), int(mid%col))

请注意,/用于除法,%在这里用作模数。因此,在第一次迭代中,它们的值将分别为(1,1)。

  1. 现在,我们来到二分查找的核心部分,我们通过与我们拥有的值进行比较来增加或减少我们的指针。如果我们找到该值,我们返回True,否则我们将继续循环,直到我们找到或者在最后找不到任何东西时返回False
        if pointer == value:
            return True
        elif pointer < value:
            start = mid + 1
        else:
            end = mid - 1
    return False
sol = matrixsearch(matrix, 78)
print(sol)

输出将如下所示:

1 1
2 0
2 2
2 1
True

在这个练习中,我们使用 NumPy 实现了对矩阵的二分查找,并且根据矩阵的值,我们的代码返回了True

注意

要访问此特定部分的源代码,请参阅packt.live/3eVd0Ch

您也可以在packt.live/2ZusZkj上在线运行此示例。

多个矩阵

到目前为止,我们已经学会了在拥有单个矩阵时执行操作和操作。接下来,我们将处理多个矩阵。Python 中矩阵的组合在当今的数据科学中最常用,因为它需要存储和处理大型数组。让我们从一个简单的例子开始。我们将取两个矩阵zx,并将值相乘如下:

import numpy as np
z = np.array([[1, 2],\
              [3, 4]])
x = np.array([[4, 5],\
              [7, 8]])
print(np.multiply(x,z))
print(np.multiply(z,x))

如果运行上述代码,您将看到以下输出:

[[ 4 10]
 [21 32]]
[[ 4 10]
 [21 32]]
[Finished in 0.206s]

输出显示,直观地,两个矩阵的相应元素相乘得到一个乘积值。这只是元素级的乘法,或者在数学上称为 Hadamard 乘积。

现在让我们稍微改变矩阵x

import numpy as np
z = np.array([[1, 2],\
              [3, 4]])
x = np.array([[4, 5, 6],\
              [7, 8, 9]])
print("Multiplying with a number: ")
print(np.multiply(3,x))
print(np.multiply(x,3))
print("Multiplication between matrices of different sizes: ")
print(np.multiply(x,z))
print(np.multiply(z,x))

现在,输出将如下所示:

Multiplying with a number: 
[[12 15 18]
 [21 24 27]]
[[12 15 18]
 [21 24 27]]
Multiplication between matrices of different sizes: 
Traceback (most recent call last):
    File "/Users/…/matrix operations.py", line 52, in <module>
    print(np.multiply(x,z))
ValueError: operands could not be broadcast together with shapes (2,3) (2,2) 
[Finished in 0.204s]

我们得到的是ValueError,这是由于 NumPy 中数组的广播属性导致的。

广播

重要的是要理解广播的概念,以便我们知道在使用数组进行矩阵运算时允许什么,不允许什么。

简而言之,广播是 NumPy 处理两个具有不同形状的数组的方式。一般规则是,在两者中较小的数组将以某种方式在较大的数组上进行广播,使它们兼容。根据官方文档,广播的一般规则如下:

  • 它从尾部维度开始向前工作。

  • 当它们中的一个为 1 时,或者当它们两个相等时,比较的两个维度是相等的。

注意

您也可以参考官方文档docs.scipy.org/doc/numpy/user/basics.broadcasting.html

因此,正如我们在前面的例子中看到的,当乘以相同维度的矩阵和标量变量时,乘法运算完美地进行。另一方面,如果两个矩阵的维度不同,将抛出ValueError,因为 NumPy 无法有效地将较小矩阵的值广播到较大矩阵上。这种广播主要是在内部完成的,以使数组更快速和更节省内存。它提供了一种将数组向量化以在 C 中实现循环而不是 Python 的方法,这有效地使其更快。在这里需要记住的一件重要的事情是,在一对 NumPy 数组的情况下,操作是基于逐元素的。为了克服维度的问题,采用的两种主要方法是reshapenewaxis。在我们结束之前,让我们看一下广播概念的另一个变化:

import numpy as np
z = np.array([[1,2],\
              [3]])
x = np.array([[4,5]])
print(np.multiply(x,z))

猜猜输出会是什么样子?让我们来看看:

[[list([1, 2, 1, 2, 1, 2, 1, 2]) list([3, 3, 3, 3, 3])]]
[Finished in 0.244s]

由于这里的数组z不是一个常规的方形数组,NumPy 在内部不将其解释为矩阵,而是将其视为常规对象行,并对其进行逐元素乘法。因此,z[0]乘以x[0]z[1]乘以x[1],以产生在这种情况下恰好是一个列表的对象。

多个矩阵的操作

我们现在将执行两个或多个矩阵之间的操作,并查看将帮助我们实现这一目标的函数。我们将涵盖如何编写矩阵的逆、逻辑运算符、点积、特征值和特征向量、外积以及矩阵的行列式。

应该注意到,矩阵还有许多其他用途,官方的 NumPy 文档是一个非常好的资源,可以根据用户的需求引用信息。我们将要涵盖的大部分主题都是 NumPy 库的线性代数包的一部分。对于我们将要涵盖的每个主题,物理学和数学中都有更广泛的应用,但应该足够了解它们在理解矩阵方面起着非常重要的作用。

注意

有关 NumPy 库的更多信息,请参阅docs.scipy.org/doc/numpy/reference/

单位矩阵

单位矩阵在对角线上有 1,在其他地方都是 0。我们将使用 NumPy 的linalg函数创建单位矩阵:

import numpy as np
from numpy.linalg import inv
def identity():
    print(np.identity(3))

输出将如下所示:

[[ 1\.  0\.  0.]
 [ 0\.  1\.  0.]
 [ 0\.  0\.  1.]]
[Finished in 0.206s]

单位矩阵

eye函数类似于单位矩阵,唯一的区别是可以偏移矩阵的值。这意味着它将从第 k 行开始创建一个单位矩阵,如下所示:

def eye():
    print(np.eye(3, k = 1))

输出将如下所示:

[[ 0\.  1\.  0.]
 [ 0\.  0\.  1.]
 [ 0\.  0\.  0.]]
[Finished in 0.277s]

矩阵的逆

逆或乘法逆是当你将其与原始矩阵相乘时产生单位矩阵的矩阵。矩阵的逆在应用于 3D 几何和图形时最常用:

def inverse():
    z = np.array([[1,2],\
                  [3,4]])
    z_inv = inv(z)
    product = np.dot(z, z_inv)
    print(z_inv)
    print(product)

这将产生以下输出:

# Output of print(z_inv)
[[-2\.   1\. ]
 [ 1.5 -0.5]]
# Output of print(product)
 [[  1.00000000e+00   0.00000000e+00]
 [  8.88178420e-16   1.00000000e+00]]
[Finished in 0.202s]

我们这里有两个输出。第一个是所谓的矩阵的逆,第二个是我们将逆乘以原始矩阵,使用dot函数产生单位矩阵。显示的值是浮点数,不应该成为一个问题。

逻辑运算符

我们将在这里创建两个列表,包含二进制的True(1)或False(0)值。我们将使用 NumPy 的内置logical_and()函数对它们进行AND操作的输出:

def and_op():
    m1 = [True, False, False]
    m2 = [True, False, True]
    print(np.logical_and(m1, m2))

输出将如下所示:

[ True False False]
[Finished in 0.253s]

非常简单。你也可以使用 1 和 0 代替TrueFalse,它仍然会给出相同的结果。实际上,只要不是 0,它就被视为True。让我们看一个使用 1 和 0 的例子:

def and_op():
    m1 = [0, 1, 0]
    m2 = [1, 1, 0]
    print(np.logical_and(m1, m2))

输出将如下所示:

[False  True False]
[Finished in 0.192s]

使用logical_orlogical_notlogical_xor函数也可以对其他逻辑函数进行同样的操作。

外函数或向量积

np.outer是用于生成向量或两个矩阵的叉积的函数:

def vector():
    horizontal = np.array([[1,3,2]])
    vertical = np.array([[2],\
                         [0],\
                         [1]])
    print("Output for dimension 1 x 1: ")
    print(horizontal.dot(vertical))
    print("Output for dimension 3 x 3: ")
    print(vertical.dot(horizontal))
    print("Output using outer for getting cross product: ")
    print(np.outer(vertical.ravel(), horizontal.ravel()))
    print(np.outer(horizontal.ravel(), vertical.ravel()))

这将产生以下输出:

Output for dimension 1 x 1: 
[[4]]
Output for dimension 3 x 3: 
[[2 6 4]
 [0 0 0]
 [1 3 2]]
Output using outer for getting cross product: 
[[2 6 4]
 [0 0 0]
 [1 3 2]]
[[2 0 1]
 [6 0 3]
 [4 0 2]]
[Finished in 0.289s]

到目前为止,我们已经学会了使用矩阵的各种方法。我们使用的方法列表绝对不是限制性的,随时需要进行某种操作时,深入探索库始终是一个好习惯。值得再次提到的是,根据工作领域的要求,有几种特定类型的矩阵具有有限的使用情况。

使用矩阵解决线性方程

线性方程是代数的基本组成部分,任何学过基本初等数学的人都知道它们是如何工作的。让我们简要地介绍一下它们,然后我们可以看看如何在 Python 中使用矩阵轻松解决它们。

线性方程通常以以下形式出现:

图 6.5:计算线性方程的公式

图 6.5:计算线性方程的公式

这里,a1、a2、..、an 是系数,x1、x2、.. xn 是变量。

这些具有两个变量的线性方程可以在二维空间图中表示,其中x是水平维度,y是垂直维度。

让我们快速举一个具有两个变量的线性方程的例子。假设方程是y = 2x + 6。这种表示被称为斜率-截距形式,格式为y = mx + c,其中m是斜率,c是方程的y截距。

这里,m=2c=6,并且可以通过绘制不同的xy值在图表上画出一条线:

图 6.6:在二维空间中表示 y = 2x + 6

图 6.6:在二维空间中表示 y = 2x + 6

不详细讨论,我们可以想象平面上可能有另一条线,它要么与该线平行,要么与该线相交。线性方程的目的是找到这些线的交点,并根据交点的值找到变量xy的值。随着维度的增加,可视化变得困难,但基本上概念是相同的。矩阵极大地简化了解这些方程的过程。通常有两个矩阵,一个包含xy的系数,另一个包含变量xy。它们的点积产生了结果矩阵,即先前提到的常数或y截距。一旦我们快速进行了一次练习,就很容易理解。

练习 6.03:使用矩阵执行线性方程

我们现在将使用矩阵来解决一个线性方程问题。

约翰出差三天,心情不错,准备花光所有的钱。他随身携带了三种货币面额。第一天,约翰在他喜欢的最新电子平板上花了 435 美元,其中a面额的37b面额的20c面额的12。第二天,他去跳伞,分别花了abc面额的15324,总共 178 美元。第三天,他决定去看戏,花了 70 美元,分别是abc面额的5402。你能告诉各个面额的值是多少吗?

从问题中我们可以看出,有三个方程和三个未知变量需要计算。

  1. 让我们使用 NumPy 数组将三天的已知值放入矩阵中:
import numpy as np
# Input
z = np.array([[37, 20, 12],\
              [15, 32, 4],\
              [5,  40, 2]])

我们现在有了需要处理的矩阵。有几种方法可以解决这个问题。本质上,这就是我们需要做的:

Ax = b

其中A是我们知道值的矩阵,x是具有未知变量的矩阵,b是结果矩阵。

  1. 结果的b矩阵如下。这些是约翰在三天内的花费金额:
r = np.array([[435],[178],[70]])

有几种方法可以在 Python 中解决这个问题:

方法 1:通过计算 x = A-1b 来找到x

  1. 让我们首先使用我们之前学到的函数来计算矩阵 A 的逆:
print(np.linalg.inv(z))

注意

我们将使用矩阵的点积而不是纯乘法,因为这些不是标量变量。

输出如下:

[[-0.06282723  0.28795812 -0.19895288]
 [-0.0065445   0.0091623   0.02094241]
 [ 0.28795812 -0.90314136  0.57853403]]

这里不需要理解这个矩阵,因为这只是一个中间步骤。

  1. 然后,我们取两个矩阵的点积来产生一个矩阵X,这是我们的输出:
X = np.linalg.inv(z).dot(r)
print(X)

输出如下:

[[10\.  ]
 [ 0.25]
 [ 5\.  ]]

方法 2:使用linalg包中的内置方法:

  1. 甚至可以使用另一个名为solve()的 NumPy 函数更轻松地完成相同的事情。让我们在这里将输出变量命名为y
y = np.linalg.solve(z,r)
print(y)

这产生了以下输出:

[[10\.  ]
 [ 0.25]
 [ 5\.  ]]

而且在一行代码中,我们就能够用 Python 解决线性方程。我们可以推断和理解,使用 Python 可以轻松解决具有大量未知变量的类似方程。

因此,我们可以看到使用方法 1 和方法 2 后的输出是相同的,即$10、25 美分和$5,这些是我们试图确定的相应面额。

如果我们接收约翰的开销信息是迭代的而不是一次性的呢?

  1. 让我们首先添加我们收到的有关约翰开销的信息:
a = np.array([[37, 20, 12]])
  1. 然后,让我们也添加有关约翰其他两笔开销的信息:
b = np.array([[15, 32, 4]])
c = np.array([[5,  40, 2]])
  1. 我们可以轻松地使用concat()函数将这些数组绑定在一起形成一个矩阵:
u = np.concatenate((a, b, c), axis=0)
print(u)

这产生了以下输出:

[[37 20 12]
 [15 32  4]
 [ 5 40  2]]
[Finished in 0.188s]

这是我们用于前面程序的相同输入。

同样,如果我们有更多这样的数据,我们可以应用循环来形成一个更大的矩阵,然后用它来解决问题。

注意

要访问此特定部分的源代码,请参考packt.live/3eStF9N.

您也可以在packt.live/38rZ6Fl上在线运行此示例。

转移矩阵和马尔可夫链

现在,我们将看一下矩阵的一个应用,这是一个独立的研究领域。马尔可夫链利用转移矩阵、概率和极限来解决现实世界的问题。现实世界很少像我们为解决它们创建的数学模型那样完美。一辆汽车可能想要从 A 点到 B 点,但在现实中,距离和速度可能是不够的参数。一只过马路的猫可能会完全改变计算出的汽车行驶时间。股市可能在几天内似乎遵循可预测的模式,但在夜间发生了一个事件,完全崩溃了。这个事件可能是一些全球性事件、政治声明或公司报告的发布。当然,我们在数学和计算模型方面的发展还没有达到我们可以预测每一个这些事件结果的地步,但我们可以尝试并确定某些事件发生的概率比其他事件更高。以前面的例子为例,如果公司报告将在特定日期发布,那么我们可以预期某只股票会受到影响,并且我们可以根据对公司进行的市场分析来对此进行建模。

马尔可夫链就是这样一种模型,其中依赖于马尔可夫性质的变量只考虑当前状态来预测下一个状态的结果。因此,从本质上讲,马尔可夫链是一个无记忆过程。它们利用转换状态图和转换矩阵进行表示,用于映射在给定当前事件的情况下发生某一事件的概率。当我们称之为无记忆过程时,很容易将其与与过去事件无关的东西混淆,但实际情况并非如此。当我们举例说明它们是如何工作时,事情就容易理解得多了。在我们开始使用马尔可夫链之前,让我们首先深入了解转换状态和矩阵的工作原理,以及它们为什么被使用。

马尔可夫链的基础知识

为了保持简单,让我们将概念分解成片段,并从我们已有的信息中逐步学习它们,然后才能将它们放在一起理解这些概念。

随机模型与确定性模型

当我们试图解决现实世界中的问题时,我们经常遇到超出我们控制范围的情况,很难制定。模型旨在模拟给定系统的功能方式。虽然我们可以在我们的模型中考虑系统的大部分元素,但许多方面无法被确定,因此根据它们发生的可能性进行模拟。这就是概率介入的地方。我们试图找到在一组情况下发生特定事件的概率。我们使用的模型有两种主要类型,确定性和随机性。确定性模型是具有一组参数值和函数的模型,并且可以形成可预测的数学方程,并提供可预测的输出。随机模型包括随机性,即使它们具有初始值和方程,它们也提供可能性的定量值。在随机模型中,不会有固定的答案,而是某些事件发生的可能性大于其他事件。线性规划是确定性模型的一个很好的例子,而天气预测是一个随机模型。

过渡状态图

广义上,这些构成了面向对象编程的基础,您可以根据给定的事件和条件描述对象可能具有的所有可能状态。状态是在满足某个先前条件时对象在某一特定时刻的状态。让我们举个例子来说明这一点:

图 6.7:风扇的状态转移图

图 6.7:风扇的状态转移图

这是台式风扇调节器的状态转移图,通常情况下,每次我们顺时针旋转它,它都会改变状态,直到它转回“关闭”位置。

在这里,台式风扇的状态在速度方面发生变化,而动作是扭转。在这种情况下,它是基于事件的,而在其他一些情况下,它将基于满足的条件。

让我们以使用与我们将要实施的内容一致的马尔可夫链进行文本生成的示例。我们将回顾一个儿歌的前两行:

汉普蒂·韦尔壁上坐着,

汉普蒂·韦尔摔了个大跟头。

首先,让我们准备一个包含句子中所有单词频率的频率表:

图 6.8:韵文中单词的频率表

图 6.8:韵文中单词的频率表

标记是存在的单词数,而键是唯一单词。因此,值将如下所示:

标记 = 12

键=9

我们甚至可能不需要我们在这里学到的一切,但一旦您决定实施更复杂的问题,这将是重要的。每个转换图都有一个起始状态和结束状态,因此我们将在这里添加这两个状态作为关键:

图 6.9:起始和结束状态的频率表

图 6.9:起始和结束状态的频率表

然后我们准备一个状态图表来显示从一个状态到下一个状态的转换。在这种情况下,需要显示哪个词将跟随当前词。因此,我们将形成如下的成对关系:

图 6.10:词对

图 6.10:词对

如果我们按照关键词而不是标记来压缩这个,我们会看到一些关键词有多个转换,如下所示:

图 6.11:一些关键词有多个转换

图 6.11:一些关键词有多个转换

这不仅是为了减少状态转换,还为我们正在做的事情增加意义,我们很快就会看到。整个目的是确定哪些词可以与其他词配对。我们现在准备绘制我们的状态转换图。

我们将所有唯一的关键字添加为状态,并显示这些单词可以转换到哪些状态:

图 6.12:状态转换图

图 6.12:状态转换图

如果我们看前面的图表,我们可以根据给定的条件跟随任何单词来完成韵脚。剩下的是关键词在给定单词之后出现的概率。为此,请看下面的图表,我们可以很直观地看到概率如何根据它们的频率在关键词之间分配。请注意,Humpty 总是跟在 Dumpty 后面,因此概率为 1:

图 6.13:带概率的状态转换图

图 6.13:带概率的状态转换图

现在我们已经讨论了状态转换图,我们将继续绘制转换矩阵。

转换矩阵

在马尔可夫过程中,我们需要以数学格式显示状态转换的概率,为此使用转换矩阵。行和列简单地是转换图的状态。转换矩阵中的每个值显示从一个状态到另一个状态的转换概率。可以想象,这种矩阵中的许多值将为 0。对于前面讨论的问题,转换矩阵将有 9 个状态和许多 0。我们将以一个更简单的转换图为例,并找到其相应的矩阵:

图 6.14:具有状态 1、2 和 3 的状态图

图 6.14:具有状态 1、2 和 3 的状态图

当我们看这个图表时,我们看到了三个转换状态。请注意,我们在这里没有明确包括起始和结束状态,但在某些情况下可能是必要的。外向箭头表示从一个状态到下一个状态的转换。一旦我们有了图表,就很容易绘制矩阵。写行和列等于图表的状态。在这种情况下,将是 3。然后,第 0 行将显示第 1 个状态的转换,第 1 行将显示第二个状态,依此类推。一般来说,矩阵中的每一行表示一个状态的转换概率。

让我们先看矩阵,然后我们可以讨论一些其他事情:

图 6.15:转换矩阵

图 6.15:转换矩阵

除了行的属性,我们还可以观察到另一件事。给定行中所有概率的总和将始终等于 1。在这里,第一行的总和将是 1/5 + 2/5 + 2/5 = 5/5 = 1。这是因为这些状态是穷尽的。

如果两个给定状态之间没有转换,那么矩阵中的状态值将为 0。我们可以通过比较矩阵中的值的数量和图表中可以看到的状态转换的数量来验证这一点。在这种情况下,它们都等于 7。

练习 6.04:查找状态转换的概率

给定一个包含四个状态ABCD的数组,这些状态是随机生成的,让我们找到这四个状态之间的转移概率。我们将找到每个状态转移的概率,并从中形成一个转移矩阵。

让我们从给定的输入数组在 Python 中生成一个转移矩阵。我们将在未来推断相同的概念,用于创建马尔可夫链。

  1. 使用 Python 中的random包生成一个包含字符ABCD的随机状态数组。然后,我们将通过创建一个名为LEN_STR的常量来定义我们想要的元素数量,在这种情况下,我们将其设置为50
# Generate random letters from 4 states A B C D
import random
tokens = []
LEN_STR = 50
for i in range(LEN_STR):
    tokens.append(random.choice("ABCD"))
print(tokens)
LEN_TOKENS = len("ABCD")

这产生了以下输出:

['C', 'A', 'A', 'B', 'A', 'A', 'D', 'C', 'B', 'A', 'B',  'A', 'A', 'D', 'A', 'A', 'C', 'B', 'C', 'D', 'D', 'C',  'C', 'B', 'A', 'D', 'D', 'C', 'A', 'A', 'D', 'C', 'A',  'D', 'A', 'A', 'A', 'C', 'B', 'D', 'D', 'C', 'A', 'A',  'B', 'A', 'C', 'A', 'D', 'D']

注意

我们从字符串的长度生成的另一个常量LEN_TOKENS的使用将指示问题中将存在的状态的数量。

  1. 接下来,我们将找到字母的相对值并将它们转换为整数,主要是因为整数更容易进行计算:
# Finding the relative values with ordinal values of 
# ASCII characters
relative_value = [(ord(t) - ord('A')) for t in tokens]
print(relative_value)

这产生了以下输出:

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

这里我们使用基数值是为了方便,但我们也可以使用字典或其他方法来做到这一点。如果你不知道,ord()函数在这里返回字符串中字符的 ASCII 值。例如,AD的 ASCII 值分别为6568

  1. 现在,找到这些 ASCII 值之间的差异,并将它们放入一个名为ti的列表中。我们也可以直接更新令牌列表,但为了清晰起见,我们将它们分开保持在不同的列表中:
#create Matrix of zeros
m = [[0]*LEN_TOKENS for j in range(LEN_TOKENS)]
print(m)
# Building the frequency table(matrix) from the given data
for (i,j) in zip(relative_value, relative_value [1:]):
    m[i][j] += 1
print(list(zip(relative_value, relative_value [1:])))
print(m)

这产生了以下输出:

[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
[(2, 0), (0, 0), (0, 1), (1, 0), (0, 0), (0, 3), (3, 2),  (2, 1), (1, 0), (0, 1), (1, 0), (0, 0), (0, 3), (3, 0),  (0, 0), (0, 2), (2, 1), (1, 2), (2, 3), (3, 3), (3, 2),  (2, 2), (2, 1), (1, 0), (0, 3), (3, 3), (3, 2), (2, 0),  (0, 0), (0, 3), (3, 2), (2, 0), (0, 3), (3, 0), (0, 0),  (0, 0), (0, 2), (2, 1), (1, 3), (3, 3), (3, 2), (2, 0),  (0, 0), (0, 1), (1, 0), (0, 2), (2, 0), (0, 3), (3, 3)]
[[8, 3, 3, 6], [5, 0, 1, 1], [5, 4, 1, 1], [2, 0, 5, 4]]

我们现在根据我们之前生成的LEN_TOKENS常量的大小初始化了一个零矩阵,并用它来构建一个零矩阵。

在第二部分中,我们正在创建成对的元组,就像我们在之前的问题中所做的那样,并根据两个状态之间的转换次数更新转移矩阵的频率。这个的输出是前面代码中的最后一行。

注意

我们选择迭代地更新矩阵m的值,而不是创建新的矩阵。

  1. 我们现在将生成概率,这仅仅是给定行中的相对频率。就像在第一行中,从 A 到 A 的转换是 8,从 A 到任何状态的总转换次数是 20。所以,在这种情况下,概率将是8/20 = 0.4
# Finding the Probability
for state in m:
    total = sum(state)
    if total > 0:
        state[:] = [float(f)/sum(state) for f in state]

代码对每一行都是这样的,如果sum函数大于0,我们就找到概率。请注意这里使用float函数是为了避免在某些 Python 版本中进行类型转换为int。另外,请注意使用state[:],它创建了一个浅拷贝,从而防止内部类型转换冲突。

  1. 现在,让我们通过添加以下代码来打印state对象:
for state in m:
      print(state)

在这里,我们遍历矩阵中的行并打印出值,这就是我们的转移矩阵。

这产生了以下输出:

[0.4, 0.15, 0.15, 0.3]
[0.7142857142857143, 0.0, 0.14285714285714285,  0.14285714285714285]
[0.45454545454545453, 0.36363636363636365,  0.09090909090909091, 0.09090909090909091]
[0.18181818181818182, 0.0, 0.45454545454545453,  0.36363636363636365]

因此,我们能够构建一个描述状态转移的转移矩阵,它显示了从一个状态转移到下一个状态的概率。因此,A找到A作为下一个字母的可能性是0.4A转到B的概率是0.15,依此类推。

注意

要访问此特定部分的源代码,请参阅packt.live/31Ejr9c.

您也可以在packt.live/3imNsAb.上在线运行此示例。

马尔可夫链和马尔可夫性质

转换状态和矩阵基本上涵盖了大部分马尔可夫链。此外,还有一些值得理解的东西。如前所述,当变量仅依赖于当前状态来确定其下一个状态时,马尔可夫性质适用。形成的概率模型可能确定当前状态的结果的可能性,但过去的状态被视为独立的,不会影响结果。以抛硬币为例;我们可以创建一个关于正面或反面概率的图表,但这不会决定结果。

马尔可夫性质本质上应该满足两个标准:

  • 它应该只依赖于当前状态。

  • 它应该是特定的离散时间。

在不至于混淆的情况下,模型中考虑的时间要么是离散的,要么是连续的。抛硬币可以被视为离散时间事件,因为它有明确的结果,比如正面或反面。另一方面,天气模式或股票市场是连续时间事件;例如,天气在一天中是变化的,没有开始和结束时间来衡量其变化。为了处理这样的连续事件,我们需要使用分箱等技术使它们变得离散。简而言之,分箱意味着根据数量或时间将数据分组成固定数量。由于马尔可夫链是无记忆的,它本质上成为了一个离散时间和状态空间过程。

有一些专门用于特定目的的特殊矩阵。例如,稀疏矩阵在数据科学中被广泛使用,因为它们在内存和计算上都很高效。我们没有过多地处理矩阵内的元素操作,因为这本质上就像处理一个列表的列表,但在未来花一些时间进行这方面的工作是值得的。

除了马尔可夫链,还有一些随机过程的模型。这些包括自回归模型、泊松模型、高斯模型、移动平均模型等。每种模型都以不同的方式处理随机性,Python 中几乎所有模型都有支持库。即使在马尔可夫链中,还涉及到涉及多维矩阵或二阶矩阵、隐马尔可夫模型、MCMC 或马尔可夫链蒙特卡洛方法等复杂的主题。你必须自己决定你想深入研究的程度。

活动 6.01:使用马尔可夫链构建文本预测器

这项活动的目标是基于我们所学的知识构建我们自己的文本预测器。我们将获取一位著名领导人的演讲文本,并基于演讲内容使用马尔可夫链模型和状态转换构建文本预测器。执行以下步骤来实现这个目标:

  1. 首先,找到一位著名人物的演讲的合适且足够大的文本,比如科学家或政治或精神领袖。为了开始,样本文本文件churchill.txt已经添加到 GitHub 仓库中,网址为packt.live/38rZy6v

  2. 生成一个描述状态转换的列表,显示给定单词与其后跟随的单词之间的相关性。

  3. 整理你制作的列表,并通过将跟随给定单词的单词分组在不同位置形成哈希表。例如,这些后续单词将分组形成John: [cannot, completely, thought, …]

John cannot…, John completely…, and John thought ..,

  1. 使用随机单词生成器生成并为第一个单词分配一个值。

  2. 最后,创建一个随机生成器,根据我们声明的转换状态创建一个句子。

提示

这项活动需要一些你应该熟悉的 Python 方法。

为了开始,你可以使用open('churchill.txt').read()从文本文件中读取演讲文本,然后使用split()将其分割成单词列表。

然后,您可以遍历列表,并将元素附加到新列表中,该列表将存储关键字和其后的单词。

然后,使用字典为新列表中的每个元组形成键值对。

可以使用np.random()函数生成随机词语语料库。

句子的形成来自于连接我们生成的列表的元素。

注意

此活动的解决方案可在第 677 页找到。

通过这种方式,我们制作了自己的文本预测器。这样的文本预测器可以被认为是在文本生成领域的广阔和快速增长的领域中的基础步骤。它远非完美;这有几个原因,其中一些如下:

  • 我们选择的文本样本通常比我们选择的文本大得多。我们的文本包含约 22,000 个单词,而在实践中,数百万个单词被作为数据输入。

  • 在停用词、标点和句子的开头/结尾方面,使用 NLP 的适当规则进行了更好的调节。

  • 我们使用简单的随机生成器来选择我们的单词,而实际模型使用概率和统计模型来生成更准确的输出。

话虽如此,我们已经完成了我们的第一个文本预测器,更复杂的文本预测器基本上是基于我们所描述的方式。

尽管这绝对不能被认为是流畅的,但我们仍然只用了几行代码编写了我们的第一个文本预测器,这是一个很好的开始。

摘要

在本章中,我们能够涵盖矩阵的主题,这对于许多主题都是基础,无论是在数学中还是在使用 Python 中。今天的数据科学主要基于矩阵的有效使用。我们研究了它们在马尔可夫链的形式中的应用,尽管这是一个重要的主题,但在应用矩阵的领域下,还有许多可以探索的主题。

接下来,我们将深入了解统计学的世界。我们将首先使用更正式和系统化的方法来理解统计学的基本知识,然后理解概率和变量的作用,最后将这些概念联系起来以实施统计建模。

YKA34

LHL39

第七章:7. 使用 Python 进行基本统计

概述

在本章中,我们将学习如何使用主要的描述性统计指标,并且产生和理解探索性数据分析中使用的主要可视化。

在本章结束时,你将能够加载和准备一个数据集进行基本的统计分析,计算和使用主要的描述性统计指标,使用描述性统计来理解数值和分类变量,并使用可视化来研究变量之间的关系。

介绍

Python 及其分析库,如 pandas 和 Matplotlib,使得在许多类型的数据集上执行简单和复杂的统计计算变得非常容易。本章介绍了任何统计分析的第一步:定义和理解问题,加载和准备数据集,之后,逐个理解变量并探索它们之间的一些关系。

本章包括三个部分:在第一部分中,我们介绍本章将使用的数据集以及一个假设的(但非常现实的)业务问题。然后我们加载数据集并执行许多常见的数据准备任务,包括更改变量类型和过滤有用的观察。有了准备好的数据集,第二部分介绍了描述性统计主要指标的简要概念介绍,然后这些知识立即应用到我们正在处理的数据集上。作为这一部分的一部分,我们将产生一个如何将描述性统计信息转化为知识的示例。第三部分介绍了学习者对探索性数据分析EDA)的实践。从一些问题和基本计算开始,我们用一些最有用的统计可视化,如直方图、箱线图和散点图,来补充我们对基本统计的理解。

本章将采用与其他对待这个主题的传统方法不同的方法;我们不仅仅是呈现统计概念,而是更加实际地将它们作为进行数据分析的工具,这意味着将数据转化为信息,将信息转化为知识。

数据准备

所有应用统计都始于一个数据集和一个需要解决的问题。在现实世界中,我们从不孤立地进行统计分析;总是有一个需要解决的业务问题,一个需要定量理解的主题,或者一个需要提出科学问题。理解问题总是任何统计分析的第一步。第二步是收集和准备数据。数据收集不是本书的主题,所以我们将直接进行数据准备。因此,在进行一些统计计算之前,我们需要确保我们理解我们的业务问题,并且已经准备好我们的数据集。

介绍数据集

在这个小节中,我们将介绍本章将使用的数据集,并进行一些基本的数据准备任务。了解数据集将在我们定义业务问题时给你更多的背景信息。

我们将使用策略游戏数据集,其中包含来自苹果应用商店的策略游戏的真实世界信息(可在www.kaggle.com/tristan581/17k-apple-app-store-strategy-games获取,遵循以下许可证:署名 4.0 国际(CC BY 4.0))。它是在 2019 年 8 月收集的,包含约 17,000 个策略游戏的 18 列。文件包含的列的描述如下:

  • 网址:游戏的网址

  • ID:分配的 ID

  • 名称:游戏的名称

  • 副标题:名称下的次要文本

  • 图标网址:512 像素 x 512 像素的 JPG

  • 平均用户评分:四舍五入到最接近的 0.5

  • 用户评分计数:国际评分数量;空值表示低于 5

  • 价格:美元价格

  • 应用内购买:可用应用内购买的价格

  • 描述:应用描述

  • 开发者:应用开发者

  • 年龄评级:4+、9+、12+或 17+

  • 语言:ISO2A 语言代码

  • 大小:应用的大小(以字节为单位)

  • 主要类型:主要类型

  • 类型:应用的类型

  • 原始发布日期:发布日期

  • 当前版本发布日期:最后更新日期

注意

你也可以从 GitHub 仓库下载数据集packt.live/2O1hv2B

介绍业务问题

我们将使用这个数据集以及一个虚构的业务问题来学习如何将数据转化为信息,将信息转化为有用的建议。想象一下,这是一个光荣的星期一早晨,你正在享受一杯优质的危地马拉咖啡。作为一个才华横溢的分析团队的一部分,你收到了以下消息:

你所在的游戏开发公司的 CEO 提出了一项计划,以加强公司在游戏市场的地位。根据他的行业知识和其他商业报告,他知道吸引新客户的一个非常有效的方法是在移动游戏领域建立良好的声誉。鉴于这一事实,他有以下计划:为 iOS 平台开发一款策略游戏,这将吸引大量积极的关注,从而将大量新客户带到公司。他确信只有游戏得到用户的好评,他的计划才会奏效。由于他是移动游戏领域的新手,他请求你帮助回答以下问题:什么类型的策略游戏得到了用户的好评?

现在,你是这个业务问题的所有者。在处理数据之前,你必须确保你理解了问题,并且至少在原则上,问题是可以通过你拥有的数据集来解决的(部分或完全)。我们将使用之前介绍的数据集来进行一些统计分析,并就在游戏行业的这个子市场中,什么因素使得策略游戏获得良好的评价提出一些建议。

准备数据集

让我们开始加载数据集和本章将使用的库。首先,让我们加载我们现在将使用的库,它们是 NumPy、Seaborn、pandas 和 Matplotlib:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# line to allow the plots to be shown in the Jupyter Notebook
%matplotlib inline

现在让我们读取数据集。由于 pandas 的强大功能,这可以通过一行代码完成。数据集包含 17,007 个策略游戏的 18 列。这行代码将读取 CSV 文件并创建一个准备好使用的 DataFrame:

games = pd.read_csv('data/appstore_games.csv')

检查我们是否从文件中加载了正确/预期的行数和列数总是一个好主意:

games.shape

这给我们以下输出:

(17007, 18)

好的,现在我们知道我们已经从文件中读取了所有的行和列。

现在让我们快速查看我们新创建的 DataFrame 的前五行,看看它是什么样子的:

games.head()

这给出了以下输出:

图 7.1:DataFrame 的前五行和九列

图 7.1:DataFrame 的前五行和九列

这里有几件事情需要注意:

  • 列名已经被正确读取(URL、名称、ID 等);然而,文件中的名称并不那么友好,如果我们想要轻松输入,因为它们包含大写和小写字母,有些包含单词之间的空格。

  • DataFrame 使用自动生成的整数索引加载。我们可以通过查看最左边的列和粗体打印的整数来看到这一点(012,…)。

让我们分别解决这两个问题。

首先,对于 DataFrame 列的名称,有一个统一的标准格式是很有用的。当然,这是个人选择,关于这个没有严格的指导方针。然而,我建议以下格式:

  • 小写名称

  • 没有空格-使用下划线代替空格

使用这种格式将使您能够通过实现以下目标更快地输入:

  • 在输入时避免混淆(“这个字母是大写还是小写?”)。

  • 利用您喜欢的 IDE 和/或 Jupyter Notebook 中的自动补全功能(在 Jupyter 中使用Tab键)。

为了进行更改,让我们创建一个字典(使用 Python 的字典推导功能),其中包含原始列名和转换后的版本:

original_colums_dict = {x: x.lower().replace(' ','_') \
                        for x in games.columns}
original_colums_dict 

得到的字典如下:

图 7.2:原始和转换后的列名的字典

图 7.2:原始和转换后的列名的字典

现在我们可以使用这个字典来使用rename函数更改列名:

games.rename(columns = original_colums_dict,\
             inplace = True)

第二个问题与 DataFrame 的索引有关。始终建议使用有意义的 DataFrame 索引,因为这将使我们的数据处理更容易,特别是与其他表合并的问题。在这种情况下,我们有一列包含我们数据集每一行的唯一 ID(id),所以让我们使用这列作为 DataFrame 的索引:

games.set_index(keys = 'id', inplace = True)

现在我们可以再次使用以下代码行来查看修改后 DataFrame 的前几行和列:

games.head()

结果如下:

图 7.3:修改后 DataFrame 的前几行和列

图 7.3:修改后 DataFrame 的前几行和列

现在看起来更好了;然而,它仍然需要一些准备工作。

我们知道这个数据集中有 18 列;然而,有一些列我们可以预料到不会提供有用的信息。我们如何知道一列是否会提供有用的信息?一切都取决于上下文:记住,在这种情况下,我们的目标是了解什么使一个策略游戏获得很高的评分。在这种情况下,可以安全地假设游戏的 URL 和游戏图标的 URL 不会提供有关这个问题的任何有用信息。因此,没有理由保留这些列。要删除这些列,运行以下代码:

games.drop(columns = ['url', 'icon_url'], inplace = True)

另一个重要的处理步骤是确保我们的 DataFrame 中的列被正确类型化,这将使其他所有操作更容易:计算、可视化和新特征的创建。我们可以使用info()方法来检查我们的列的类型以及其他有用的信息:

games.info()

这会产生以下输出:

图 7.4:info 方法的输出

图 7.4:info 方法的输出

如您所见,在概念上是数值和分类变量的列似乎具有正确的类型:float64object。然而,我们有两列是日期,并且它们的类型是object。让我们使用以下代码将这两列转换为datetime类型:

games['original_release_date'] = pd.to_datetime\
                                 (games['original_release_date'])
games['current_version_release_date'] =\
pd.to_datetime(games['current_version_release_date'])

运行前面的代码行后,我们可以再次使用info方法检查列类型:

games.info()

这会产生以下输出:

图 7.5:更改两个日期列类型后 info 方法的输出

图 7.5:更改两个日期列类型后 info 方法的输出

请注意,包含日期的两列(original_release_datecurrent_version_release_date)现在具有正确的datetime64类型。

我们的数据集现在似乎已经准备好开始分析一些数据了。使用术语“似乎”是因为当我们分析数据集时,我们可能会发现需要进行一些额外的准备/清理工作。

您可以再次使用head()方法查看处理后的 DataFrame:

games.head()

输出如下:

图 7.6:处理后 DataFrame 的前几行和列

图 7.6:处理后 DataFrame 的前几行和列

在处理真实世界的数据集时,几乎可以肯定会在某些列中找到缺失值,因此检查数据集的每一列中有多少缺失值是一个好主意。我们可以使用以下代码行来做到这一点:

games.isnull().sum()

前面的代码行显示了以下输出:

图 7.7:按列计算的空值数量

图 7.7:按列计算的空值数量

我们看到subtitle列中有超过 11,000 个缺失值,尽管对于我们将在本章进行的分析来说,也许该列并不那么重要(也许我们应该将其删除?你觉得呢?)。另一方面,average_user_ratinguser_rating_count具有相同数量的缺失值:9,446。这表明这些缺失值可能是相关的。让我们使用np.array_equal NumPy 函数来验证这一猜测。该函数评估两个数组是否在元素级别上相等,并在这种情况下返回True。我们将使用此函数来检查这些列在相应位置是否存在缺失值。通过这样做,我们将确认缺失值发生在相同的行上。以下代码行实现了我们刚刚解释的内容:

np.array_equal(games['average_user_rating'].isnull(),\
               games['user_rating_count'].isnull())

这给出了结果True

从结果中,我们可以得出结论,每当我们在其中一列中有一个缺失值时,另一列也显示出缺失值。因此,我们的猜测是正确的:如果我们没有user_rating_count,那么我们也没有average_user_rating(如果是这种情况,那么我们将不得不分别处理这两列的缺失值)。回到列的描述(介绍数据集部分)中,对于用户评分计数,我们发现“null 表示低于 5”,因此如果少于 5 个人对游戏进行评分,那么我们根本没有评分。

继续探索缺失值,对于应用内购买列,我们有 9,324 个相对较高的值。最后,对于价格、语言和大小,我们分别有 24、60 和 1 个缺失值,鉴于我们数据的维度,这对我们的目的来说并不是什么大问题。

现在我们知道我们的数据集中的某些列中有缺失值,有许多方法可以处理它们。插补基本上意味着用合理的值替换缺失值。有非常复杂的方法可以做到这一点,这超出了本书的范围。我们将使用非常简单的方法来处理一些缺失值;但是,我们将等到完成准备工作后再决定如何处理这些缺失值。插补通常是数据准备过程中的最后一步。

现在是我们决定哪些观察结果(游戏)对我们的分析相关的时候了;换句话说,我们必须回答这个问题:我们应该保留所有我们拥有的游戏吗?

从我们分析的背景来看,有一件事是清楚的:没有评分的游戏对我们的目标毫无用处,因为我们想要了解评分。因此,我们应该将它们排除在我们的分析之外。以下代码行将仅保留average_user_rating不为空的行:

games = games.loc[games['average_user_rating'].notnull()]

请记住,average_user_rating中的空值数量为9446,因此最后一行代码将删除这些行。

我们应该意识到另一个事实:评分游戏的人数。从数据集描述中,我们知道至少有五个用户必须对游戏进行评分,游戏才能有评分。出于我们将在第九章《使用 Python 进行中级统计》中解释的原因,我们将只保留那些至少有 30 个用户评分的游戏(基本上,出于技术原因,大小为30可以保证平均评分可用于分析)。以下代码将执行我们刚刚描述的操作:

games = games.loc[games['user_rating_count'] >= 30]

让我们使用shape方法来查看我们还剩下多少观察结果:

games.shape

结果如下:

(4311, 15)

现在,再次检查每列中有多少缺失值是一个好主意。我们将使用与此任务之前使用的相同代码:

games.isnull().sum()

这向我们展示了以下输出:

图 7.8:按列计算空值的数量

图 7.8:按列计算空值的数量

现在我们看到,从可能相关的列中,只有两个存在缺失值:in-app_purchases1,313)和languages14)。

注意

关于所有练习和相关测试脚本的一个快速说明:如果您正在使用命令行界面CLI)(如 Windows 的命令提示符或 Mac 的终端)运行测试脚本,它将抛出一个错误,即Implement enable_gui in a subclass,这与笔记本中使用的一些命令有关(如%matplotlib inline)。因此,如果您想运行测试脚本,请使用 IPython shell。最好在 Jupyter 笔记本上运行练习的代码。

练习 7.01:使用字符串列生成数值列

在这个练习中,我们将在我们的 DataFrame 中创建一个新的数值变量,该变量将包含游戏可用语言的数量信息。这将是如何将文本数据转换为数值信息的一个示例。

  1. 创建一个我们迄今为止一直在使用的 DataFrame 的副本,并将其命名为games2,这样我们就有了另一个要处理的对象:
games2 = games.copy()
  1. 使用head方法查看languages列的前五个值:
games2['languages'].head()

结果如下:

图 7.9:语言列的前五个值

图 7.9:语言列的前五个值

  1. 如您所见,它由逗号分隔的语言缩写字符串组成。使用fillna()方法将该列中的缺失值替换为EN,这是最常见的值:
games2['languages'] = games2['languages'].fillna('EN')
  1. 使用str访问器方法中的split(其工作方式与字符串的split方法相同)方法获取不同语言的列表,并将结果系列保存在名为list_of_languages的对象中:
list_of_languages = games2['languages'].str.split(',')
  1. 最后,让我们在 DataFrame 中创建一个名为n_languages的列,该列包含每个结果列表中的元素数量。为此,使用一个lambda函数和返回列表长度的apply方法:
games2['n_languages'] = list_of_languages.apply(lambda x: len(x))
  1. 新列的前 10 个元素应该是这样的:
id
284921427    17
284926400     1
284946595     1
285755462    17
286210009     1
286313771     1
286363959     1
286566987     1
286682679     1
288096268     1
Name: n_languages, dtype: int64

在这个练习中,我们使用了一个文本列,并通过使用 pandas 的str.split方法和 lambda 函数,从基于文本的列生成了一个数值列。

注意

要访问此特定部分的源代码,请参阅packt.live/31xEnyy

您也可以在packt.live/38lpuk2上在线运行此示例。

在这一部分,我们谈到了定义业务问题的重要性,并介绍了我们将在本章剩余部分使用的数据集。在我们所做的所有工作之后,数据集已经到了我们可以开始理解价值的地步。为此,我们将使用描述性统计,这是下一节的主题。

计算和使用描述性统计

描述性统计是一组我们用来总结一组测量(数据)信息的方法,这有助于我们理解它。在本节中,我们将首先解释描述性统计的必要性。之后,我们将介绍描述性统计中最常见的指标,包括均值、中位数和标准偏差。首先,我们将在概念层面上理解它们,使用一个简单的测量集,然后我们将应用我们在上一节中学到的关于它们的知识到我们准备好的数据集上。

描述性统计的必要性

我们为什么需要描述性统计?以下是一个示例,将向您展示为什么我们需要这些类型的分析工具:我们的大脑非常擅长各种任务,比如识别人脸表达的情感。试着注意一下,你的大脑在阅读以下面孔的情感时付出了多少努力:

图 7.10:一种面部表情

图 7.10:一种面部表情

答案:几乎没有,当然你可以对图片中发生的事情说出有意义的话。

现在,相比之下,让我们的大脑做一个不同的任务:我们将使用上一节的游戏数据集,随机选择 300 个观察结果从平均用户评分列中,然后打印它们。以下代码行就是这样做的:

random_ratings = games['average_user_rating'].sample(n=300)
for r in random_ratings:
    print(r, end=', ')

输出如下:

图 7.11:300 个平均用户评分的随机样本

图 7.11:300 个平均用户评分的随机样本

看一下输出,试着分析它,并回答以下问题:

  • 需要付出多少心理努力才能说出有意义的话?

  • 仅凭数字,你能对平均评分说些什么呢?

你对这些问题的答案很可能是:

  • 需要付出多少心理努力才能说出有意义的话?"我花了一些时间看这些数字。"

  • 仅凭数字,你能对平均评分说些什么?"不多,数字以.0 或.5 结尾。"

仅仅通过观察图 7.10,我们就可以自动地通过说女人笑来总结和理解包含在成千上万像素中的信息。然而,在图 7.11的情况下,我们无法自动地了解有关它们的信息。这就是为什么我们需要描述性统计:它允许我们通过执行相对简单的计算来总结和理解数值信息。

统计概念简要回顾

如果你正在阅读这本书,很可能你已经学习或使用了一些最常见的描述性统计,对于我来说很难提供关于已经在成百上千本统计学书籍中介绍的概念的原始定义。因此,你可以将以下页面视为描述性统计测量中最重要概念的简要回顾。

在本小节中,为了使概念性解释更容易理解,我们将稍微偏离我们的策略游戏数据集,并使用一个包含 24 个观察结果的小数据集。假设我们有 24 个男性的身高(以米为单位)。这里是原始观察结果:

    1.68, 1.83, 1.75, 1.80, 1.88, 1.80, 1.89, 1.84,
    1.90, 1.65, 1.67, 1.62, 1.81, 1.73, 1.84, 1.78,
    1.76, 1.97, 1.81, 1.75, 1.65, 1.87, 1.85, 1.64.

我们将使用这个小数据集来概念性地介绍最重要的描述性统计。为了使我们的计算更容易,让我们创建一个包含这些值的 pandas 系列:

mens_heights = pd.Series\
               ([1.68, 1.83, 1.75, 1.8, 1.88, 1.8, 1.89, 1.84,\
                 1.9, 1.65, 1.67,1.62, 1.81, 1.73, 1.84, 1.78,\
                 1.76, 1.97, 1.81, 1.75, 1.65, 1.87, 1.85, 1.64])

现在我们准备好复习最常用的描述性统计了。

算术平均值:也简称为平均值,这是一种分布中心或一组数字的中心的度量。给定一组观察结果,平均值通过将观察结果相加并将该总和除以观察数量来计算。公式如下,其中x bar ()是平均值,n是观察数量:

图 7.12:算术平均值公式

图 7.12:算术平均值公式

当大多数值聚集在一个中心周围时,平均值尤其有用且信息丰富,因为在这种情况下,平均值将接近该中心,因此将接近许多值,使其成为一组观察结果的代表数字。你在真实世界数据中遇到的许多(也许大多数)数值变量将具有聚集在平均值周围的值,因此平均值通常是一组测量的典型值的良好指标。

让我们使用内置的 pandas 方法计算男性身高的平均值:

mens_heights.mean()

结果如下:

1.7820833

这个数字告诉我们 1.78 米是 24 个男性身高集合的代表值。进行了这个计算后,我们知道一个典型男性(来自我们得到样本的总体)的身高大约为1.78米。

关于平均值需要注意的一点是,它会受到极端值的影响。当分析极端值可能比最常见值大几个数量级的变量时,这一点尤为棘手。

最后,值得一提的是,算术平均值通常是大多数人在说“平均值”时所指的,尽管还有其他平均值,比如中位数,我们稍后会定义。

标准偏差:这是数据的分散程度的一种度量。它衡量了一组测量结果中观察结果的不同或分散程度。数学公式基于算术平均值,供您参考,如下所示:

图 7.13:标准偏差公式

图 7.13:标准偏差公式

在前面的公式中,s代表标准偏差,x bar ()代表平均值,N代表观测次数。由于一个技术细节(超出我们的范围),公式的分母是N – 1而不仅仅是N,但让我们假装一会儿分母是N:如果你仔细看公式,你会发现根号符号内的是关于平均值的平方偏差的平均值。因此,标准偏差是观测结果与其平均值的平均距离。公式中有平方根,所以得到的数字与原始测量结果具有相同的单位。

让我们再次使用 pandas 内置方法计算男性身高的小样本的标准偏差:

mens_heights.std()

这给出了以下结果:

0.0940850

我们的答案是0.094米,或者 9.4 厘米。这个数字的解释是,平均而言,一个男人的身高与平均值(在这种情况下为 1.78 米)相差 9.4 厘米。请注意我们说的是平均而言,这意味着我们可以预期个别男性的身高与平均值相差的距离会比 9.4 厘米更近或更远,但 9.4 是一个能够告诉我们典型观测结果与平均值相差多远的信息性数字。

为了更好地理解标准偏差的概念,将前面的计算与另一组假设的测量结果进行对比将会很有用。假设我们有另外 24 个男性的身高,平均值相似:

mens_heights_2 = pd.Series\
                 ([1.77, 1.75, 1.75, 1.75, 1.73, 1.75, 1.73, 1.75,\
                   1.74, 1.76, 1.75, 1.75, 1.74, 1.76, 1.75, 1.76,\
                   1.76, 1.76, 1.75, 1.73, 1.74, 1.76, 1.76, 1.76])

让我们计算平均值,看看这 24 个男性与我们第一组的平均值相比如何:

mens_heights_2.mean()

结果如下:

1.750416

这比第一组的平均值低 3 厘米。现在让我们计算第二组的标准偏差:

mens_heights_2.std()

结果如下:

0.01082

这只有 1 厘米,这意味着第二组的身高更加均匀。换句话说,第二组的身高彼此更接近(也就是说,它们的分散程度更小)比第一组的身高。由于我们的观测次数很少,我们可以通过仔细观察第二组的测量结果来得知:请注意所有的测量结果都在 1.73 和 1.77 之间,因此与第一组相比,变异性较小。

在极端情况下,如果所有观察结果完全相同,也就是说它们之间没有变化,那么标准偏差将为零。观测结果越不同,标准偏差就会越大。

数据的分散程度还有其他度量方法,但标准偏差可能是最重要的。您应该确保知道如何解释它。

四分位数:四分位数是位置测量。它们表明当观察值从最小值(最小值)到最大值(最大值)排序时,该值处于某个相对位置。通常,第一、第二和第三个四分位数通常表示为Q1Q2Q3

  • Q1:将数据分成这样一种方式,以便 25%的观察值低于这个值。

  • Q2:也称为中位数,这是将数字集合分成两半的数字,意味着 50%的观察值低于这个值,另外 50%的观察值高于这个值。中位数是另一种类型的平均值。

  • Q3:将数据分成这样一种方式,以便 75%的观察值低于这个值。

再次使用我们的前 24 个身高,我们可以对它们进行排序,并将这个小数据集分成四部分,每部分包括 6 个观察值。这在下图中有直观展示:

图 7.14:四分位数示例

图 7.14:四分位数示例

在这个例子中,我们可以将数据集完全分成四部分。第一部分包括最小的六个观察值;第六个观察值是1.68,下一个(属于第二个四分位数)是1.73。从技术上讲,1.681.73之间的任何数字(1.68 < Q1 < 1.73)都可以称为第一个四分位数,例如1.70,因为陈述25%的观察值低于 1.70是正确的。我们也可以选择1.71,因为陈述25%的观察值低于 1.71也是正确的。

中位数(Q2)将观察分成两半。在这种情况下,1.80是第 12 和第 13 个观察值,所以中位数是1.80。(如果有一个数字在第 12 和第 13 个观察值之间,中位数将是中间的数字。)

最后,对于第三个四分位数(Q3),我们从图中看到,1.841.85之间的任何数字(比如 1.845)都是一个值,将底部 75%的观察值与顶部 25%的观察值分开。

请注意,我没有给出任何计算四分位数的公式,那么四分位数是如何计算的呢?有一些方法我们在这本书中不会详细介绍。确切的方法并不重要,真正重要的是你理解这个概念。现在让我们看看如何计算这些值。在下面的代码行中,我们将使用 pandas 的quantile方法(四分位数是更一般的分位数概念的特例,这个概念与百分位数的概念接近):

mens_heights.quantile([0.25, 0.5, 0.75])

结果如下:

0.25    1.7175
0.50    1.8000
0.75    1.8425
dtype:  float64

我们必须向这个方法传递一个列表,指示我们想要进行分割的观察的百分比(比例)——这些分别是 25%,50%和 75%,分别对应Q1Q2Q3。正如你所看到的,第一个四分位数(1.7175)是 1.68 和 1.73 之间的一个数字,中位数是1.80,第三个四分位数(1.8425)是 1.84 和 1.85 之间的一个数字。

要计算分位数,而不是四分位数,我们可以使用任何比例将观察分成两部分:例如,假设我们想要将观察分成底部 80%和顶部 20%,80th 百分位数等同于 0.8 分位数,是一个数字,下面有 80%的观察。类似地,33rd 百分位数等同于 0.33 分位数,是一个数字,下面有 33%的观察。这就解释了为什么我们必须向 pandas 的分位数函数传递一个分位数列表;例如,下面的代码计算了 0.33 和 0.8 分位数(分别对应 33rd 和 80th 百分位数):

mens_heights.quantile([0.33, 0.80])

结果是:

0.33    1.750
0.80    1.858
dtype: float64

根据这个结果,我们的 80%观察值低于1.858。作为一个小练习,通过比较其值与图 7.14中所示范围,检查 33rd 百分位数是否符合你的预期。

描述性统计不仅仅是关于标准的度量指标,比如平均值、中位数等。对数据进行的任何简单的描述性计算也被认为是描述性统计,包括求和、比例、百分比等。例如,让我们计算身高超过 1.80 米的男性比例。有很多方法可以做到这一点,但我们将使用两步方法。首先,让我们运行以下代码行,如果观察满足条件则给出值True,否则给出False

mens_heights >= 1.8

结果看起来像这样:

图 7.15:布尔系列

图 7.15:布尔系列

第二步是计算我们有多少个True值。我们可以使用sum方法来做到这一点,它将True转换为 1,False转换为 0。然后我们可以简单地通过shape方法将观察值的数量除以。整行代码看起来像这样:

(mens_heights >= 1.8).sum()/mens_heights.shape[0]

结果如下:

0.54166

或者我们 24 个身高中有 54%等于或大于 1.8 米。这个比例也被认为是描述性统计,因为它也是在描述我们的数据发生了什么。

前面的计算可以更简洁地进行,如下所示:

(mens_heights >= 1.8).mean()

作为一个小练习,您可以使用图 7.12中给出的平均值公式来推断为什么mean方法会给出布尔系列中True值的比例。

使用描述性统计

现在我们已经重新理解了描述性统计的最重要的度量标准,是时候回到我们的原始数据集,并开始在给定的业务问题背景下使用这些概念了。

我们在前一节中审查的描述性统计数据是如此重要,以至于 pandas 的describe方法(属于 series 和 DataFrames)会计算所有这些统计数据。此外,我们还会发现以下内容:

  • 计数:列中非空值的数量

  • 最小值:最小值

  • 最大值:最大值

当在 DataFrame 上使用describe方法时,它会取所有数值类型的列并计算它们的描述性统计数据:

games.describe()

这会得到以下输出:

图 7.16:DataFrame 的数值列的描述性统计

图 7.16:DataFrame 的数值列的描述性统计

在解释结果之前,您需要知道,默认情况下,pandas 以科学计数法显示输出:例如,4.311000e+03表示 4.311 x 10³ = 4,311。符号"e+k"表示(x 10^k),而"e-k"表示(x 10^-k)。

为了将这些概念付诸实践,让我们读取并解释user_rating_count变量的统计数据:

  • count:输出中的计数值为 4,311。这是我们变量中非空观察值的数量。

  • mean:平均值的输出为 5,789.75。现在我们知道,平均而言我们的数据集中大约有 5,800 个用户对游戏进行评分。为了提取关于这个数字更多的信息(以及它是否有用或不有用),让我们读取其他统计数据。

  • std:标准偏差值为 5,592.43。平均而言,我们数据集中评分游戏的用户数量与平均值相差近 5,600 个用户:想想看,平均评分数量大约为 5,800,而与该数字的典型偏差几乎为 5,600。这告诉我们这个变量的可变性非常大(离散度很高),换句话说:我们有一些游戏只有少数用户评分,而有一些游戏有非常多的用户评分。

  • min25%50%75%max:这些数字告诉我们关于变量观察值分布的重要信息。我们看到最小值是 30(记住我们明确选择了至少 30 个评分的游戏),最大值超过了 300 万!第一四分位告诉我们数据集中 25%的游戏拥有少于 70 个用户评分;考虑到均值为 5,789.75,这是非常少的。中位数是 221 个用户评分,因此一半的游戏拥有少于 221 个评分!最后,第三四分位表明 75%的观察值低于 1,192。

到目前为止,我们大致上只是读取了结果,现在让我们更多地讨论一下,以便将这些数据转化为有用的信息。

知道最大值(超过 300 万),我们可以确定这个变量的均值受到极端值的极大影响,即极其受欢迎的游戏。也许均值对于这个变量来说并不那么有信息量。也许谈论游戏的典型用户评分数量并没有意义,因为数据表明没有典型的数量。事实上,我们知道超过 75%的观察值拥有少于 1,200 个用户评分。这意味着我们应该观察一些受欢迎的游戏。让我们测试一下这个直觉,并深入研究一下评分最多的游戏,使用系列的sort_values方法,以降序查看user_rating_count的前 10 个值:

games['user_rating_count'].sort_values(ascending=False).head(10)

结果如下:

图 7.17:用户评分计数的前 10 个值

图 7.17:用户评分计数的前 10 个值

请注意,只有两个游戏拥有超过一百万的用户评分,这解释了我们从标准差(5,592.43)观察到的高变异性。让我们检查有多少游戏至少拥有 100,000 个用户评分。首先,我们将按条件>= 100000过滤我们的列,然后我们将使用sum方法,该方法将计算满足该条件的值的数量:

(games['user_rating_count'] >= 100000).sum()

结果如下:

40

因此,只有 40 个游戏拥有超过 100,000 个评分,这不到当前数据集的 1%(4,311 个游戏)和原始数据集的 0.23%(17,007 个游戏)。

总之,游戏的用户评分数量变化很大。此外,我们样本中有 25%的游戏拥有少于 70 个用户评分,一半的游戏拥有少于 221 个。此外,我们只有 40 个游戏拥有超过 100,000 个用户评分,其中排名第一和第二的最受欢迎的游戏分别拥有超过 300 万和超过 120 万个评分。

以下是我们向 CEO 呈现信息的方式:

用户评分数量的数据,这是游戏受欢迎程度的代理,表明策略游戏要成为极其受欢迎是非常困难的。数据告诉我们,不到 1%的策略游戏可以被认为是极其受欢迎的(就用户评分数量而言),而超过 75%的游戏拥有少于 1,200 个用户评分,表明用户基数相对较低。请记住,对于这个练习,我们已经排除了那些拥有少于 30 个用户评分的游戏,因此策略游戏成为极其受欢迎的几率远低于 1%。

上述段落是如何从描述性统计中提取有价值信息的一个例子。请注意,我们无需提及任何统计术语,如均值、中位数或标准差,也无需提及分位数、百分位数等术语。还要注意,均值为 5,789.75 这个事实并没有被使用,因为对于我们试图传达的信息来说,这个事实并不是必要的。这是像 CEO 这样的人想要听到的信息,因为它清晰、有信息量且可操作。

我们用一个非常重要的建议来结束本节:不要犯一个错误,把描述性统计计算的列表作为你的分析。另一个常见的错误是包括一个包含分析的段落,比如平均值是 x标准差是 y最大值是 88,基本上只是重复了统计表中包含的信息。请记住,你的工作不仅是进行计算,还要解释这些数字的含义以及它们对你试图解决的问题的影响。

练习 7.02:计算描述性统计

在这个练习中,我们将使用描述性统计来计算平均用户评分变量的值。为此,我们将使用我们在上一节讨论的描述性统计指标。此外,我们还将进行其他描述性计算,包括计数和比例。

  1. 计算average_user_rating列的描述性统计:
games['average_user_rating'].describe()

结果如下:

count    4311.000000
mean        4.163535
std         0.596239
min         1.500000
25%         4.000000
50%         4.500000
75%         4.500000
max         5.000000
Name: average_user_rating, dtype: float64

如你所见,中位数和第三四分位数的值相同,为4.5,这意味着至少有 25%的游戏的平均评分为4.5

  1. 计算评分恰好为4.5的游戏的数量和比例。可以使用mean方法来实现这个目标:
ratings_of_4_5 = (games['average_user_rating'] == 4.5).sum()
proportion_of_ratings_4_5 = (games['average_user_rating'] == 4.5)\
                            .mean()
print(f'''The number of games with an average rating of 4.5 is \{ratings_of_4_5}, \
which represents a proportion of {proportion_of_ratings_4_5:.3f} or \
{100*proportion_of_ratings_4_5:.1f}%''')

我们得到的输出如下:

The number of games with an average rating of 4.5 is 2062, which represents a proportion of 0.478 or 47.8%
  1. 使用unique方法查看该变量的唯一值。你注意到了什么?
games['average_user_rating'].unique()

在这个练习中,我们使用描述性统计来理解我们商业问题的另一个关键变量。

注意

要访问本节的源代码,请参阅packt.live/2VUWhI3

你也可以在packt.live/2Zp0Z1u上在线运行这个例子。

以下是一些可能会出现在脑海中的问题:

  • 这个变量的平均值是你用来理解这个变量的统计量吗?你会选择它作为变量的典型值,还是会选择另一个值?

  • 根据标准差,你认为这个变量的变异性高还是低?

  • 你如何用描述性统计获得的信息来总结成一段话?

在本节中,我们了解了为什么需要和使用描述性统计。从我们介绍的第一个例子中,我们了解到我们的大脑没有自动理解数值信息的能力。因此,如果我们想要理解数值数据,就有必要拥有这些类型的分析工具。

然后我们简要回顾了(或介绍了)一些最常用的描述性统计指标,包括平均值、标准差和分位数。然后我们立即应用了这些知识,使用策略游戏数据集计算了数值变量的描述性统计。我们分析了用户评分计数变量的结果,并生成了一个包含非技术人员可以理解的相关信息的摘要。

最后,我们使用了 pandas 内置方法,如meanstddescribequantile等进行了所有计算。

现在我们知道了描述性统计的基础知识,我们可以扩展我们的统计工具包,并通过可视化来补充我们的分析,这将在下一节中介绍。

探索性数据分析

在本节中,我们将回顾我们在本章第一节对一个商业问题进行了一些初步分析,该问题如下:

您所在的游戏开发公司的 CEO 提出了一项计划,以加强公司在游戏市场的地位。根据他的行业知识和其他商业报告,他知道吸引新客户的一种非常有效的方法是在移动游戏领域建立良好的声誉。鉴于这一事实,他制定了以下计划:为 iOS 平台开发一款策略游戏,这将吸引大量积极的关注,从而为公司带来大量新客户。他确信只有游戏得到用户的好评,他的计划才会奏效。由于他是移动游戏领域的新手,他请求您帮助回答以下问题:哪种类型的策略游戏得到了用户的好评?

在本节中,我们将进行一些探索性数据分析。您可以将本节视为上一节的延续,因为我们将继续使用描述性统计,并且除此之外,我们还将扩展我们的分析工具包,其中包括最强大的分析工具之一:可视化。

探索性数据分析EDA)领域(就像与数据相关的任何其他领域一样)发展迅速,本节内容仅涵盖了一些非常基本的概念和可视化。尽管如此,很可能您会在遇到的每个数据分析项目中使用本节中呈现的可视化。

什么是探索性数据分析?

现在我们已经复习了基本的统计定义,我们准备利用它们并用可视化来补充我们从中获得的信息。简而言之,探索性数据分析是通过将描述性统计与可视化相结合来分析数据的过程。进行探索性数据分析的原因和目标包括以下内容:

  • 理解变量的分布

  • 理解两个或多个变量之间的关系

  • 检测无法通过数值计算找到的模式

  • 发现数据中的异常或离群值

  • 制定关于因果关系的假设

  • 告诉我们如何构建新变量(特征工程)

  • 告诉我们可能的正式推断统计检验

根据定义,在进行探索性数据分析时,我们正在探索数据,因此没有固定步骤或一套固定的步骤要遵循,需要涉及很多创造力。然而,为这个过程提供一些结构也非常重要,否则我们可能会产生没有明确结束点的图表和计算。在数据分析中(就像在生活中一样),如果没有明确的目的,很容易迷失方向,因为有无数种方法来查看任何规模适中的数据集。在开始进行探索性数据分析之前,我们需要定义我们试图找到的是什么,这就是业务问题理解的关键所在。

我建议分两步进行探索性数据分析:

  1. 单变量步骤:了解数据集中最重要的每个变量:分布、关键特征、极端值等。

  2. 寻找关系:在考虑业务/科学问题的情况下,制定一系列问题,这些问题的答案将为您提供有关您试图解决的问题的有用信息,然后让这些问题引导探索性数据分析过程。

第一步通常被称为单变量分析,相对来说比较简单。对于第二步,我们使用双变量多变量技术,因为我们需要寻找变量之间的关系。在下一节中,我们将进行单变量探索性数据分析。

单变量探索性数据分析

正如其名称所示,单变量探索性数据分析是关于一次分析一个变量。在上一节中,我们看到使用 pandas 计算主要描述性统计有多么容易。现在我们将使用适当的可视化来补充我们从描述性统计中获得的信息。

在进行探索性数据分析之前,了解我们正在处理的变量类型是绝对必要的。作为复习,这里有我们可能遇到的主要变量类型:

  1. 数值变量:那些可以取数值的变量:

a. 连续:这些是可以在某个区间内取一整个范围的实值的变量,例如身高、体重和质量。

b. 离散:这些是只取特定有限数值的变量,通常是整数。例如,家庭中的孩子数量或雇员数量。

  1. 分类变量:只能取指定数量的类别作为值的变量:

a. 有序:变量的类别具有一定的自然顺序。例如,变量年龄组可以有类别 20-29、30-39、40-49 和 50+。请注意,这些类别中有一定的顺序:20-29 小于 40-49。

b. 名义:这些是分类变量,其中没有排序关系。例如,蓝色、绿色和红色是没有任何排序的类别。

  1. 与时间相关的变量:与日期或日期时间相关的变量。

让我们首先识别数据集中的数值变量。就像我们在准备数据集时做的那样,我们意识到概念上是数值的变量具有相应的 Python 数值数据类型。让我们再次使用修改后的游戏 DataFrame 中的info方法来检查这一点:

games.info()

输出如下:

图 7.18:数据集中的数值变量

图 7.18:数据集中的数值变量

正如我们所看到的,概念上是数值的列:average_user_ratinguser_rating_countpricesize的数据类型为float64

单个数值变量最常用的有用可视化是直方图。让我们看看它的样子,然后描述它是如何构建的。为此,我们将使用size变量。由于这个变量是以字节为单位的,所以在可视化之前,我们将把它转换为兆字节:

games['size'] = games['size']/1e6

现在我们可以使用 pandas 内置的hist方法:

games['size'].hist(bins=30, ec='black');

我们还包括了两个额外的参数:bins,它是我们看到的柱子的数量,以及ec(边缘颜色),它是柱子边缘的颜色。得到的图如下:

图 7.19:尺寸直方图

图 7.19:尺寸直方图

直方图是通过将变量的范围分成等大小的间隔(称为区间)来构建的。在这种情况下,最大值和最小值分别为 4,005.6 和 0.2,因此范围约为 4,005。由于我们指定了 30 作为区间的数量,每个区间的大小约为 133 ~ 4,005 / 30。因此,第一个区间大约从最小值(约为 0)到 133,第二个区间从 133 到 266 = 133 + 133,依此类推。柱子的高度对应于落入特定区间的观察数量。例如,我们看到第一个柱子略微超过 2,500 个观察,这是落入第一个区间的观察数量(从 0 到约 133)。与四分位数一样,构建直方图的确切算法因使用的软件而异。Pandas 使用 Matplotlib 实现,因此如果想了解详情,请查阅 Matplotlib 的文档。

你应该习惯看直方图并尝试阅读它们。例如,尺寸的直方图显示如下:

  • 重要比例的游戏落在第一个区间(最高的柱子)中。

  • 大多数值都在 500MB 以下。

  • 观察数量(频率)随着变量增加而减少:随着尺寸的增加,我们观察到的游戏越来越少。

  • 图表的x轴延伸到大约 4000 MB;然而,我们甚至看不到一个柱形,原因是我们的观测值很少,那个柱形非常小,几乎可以忽略不计。这意味着我们至少有一个极端值(一个非常大尺寸的游戏)。

  • 在 1000 MB 以上的柱形的高度非常低,所以 1000 MB 以上的游戏非常少。

直方图是对我们从描述性统计中得到的数值信息的完美补充:

games['size'].describe()

输出如下:

count        4311.000000
mean         175.956867
std          286.627800
min          0.215840
25%          40.736256
50%          97.300480
75%          208.517632
max          4005.591040
NameL size,  dtype: float64

中位数告诉我们,超过一半的游戏大小小于 97.3 MB,而最大大小的游戏比中位数大 40 倍以上,我们可以认为这是一个异常值或者是远远超过变量大部分值的观测值。与用户评分计数一样,我们可以通过按降序对系列进行排序然后显示前 12 个值来检查最大的游戏:

games['size'].sort_values(ascending=False).head(12)

输出如下:

图 7.20:尺寸最大的值

图 7.20:尺寸最大的值

我们看到实际上有一些异常值,不仅仅是最大值。实际上,异常值没有标准定义——它取决于上下文。我们之所以称这些观测值为异常值,是因为它们可以被认为是一组游戏中的大尺寸,其中 75%的游戏尺寸小于 208 MB。

好的,回到直方图。pandas 的一个非常酷的功能是它能够使用DataFrame类的hist方法同时为许多数值变量生成直方图。当数据集中有很多数值变量并且您想快速查看它们时,这个功能将非常有用:

games.hist(figsize = (10, 4), bins = 30, ec = 'black');
# This line prints the four plots without overlap
plt.tight_layout()

pandas 自动获取所有数值变量并生成一个网格(可调整大小)的图表。在我们的情况下,有四个数值变量,结果如下:

图 7.21:DataFrame 直方图示例

图 7.21:DataFrame 直方图示例

pricesizeuser_rating_count中有一些极端值(异常值),这导致我们无法真正看到这些变量的值是如何分布的。

凭借我们对分位数(和百分位数)的了解,让我们创建一个过滤器,将在这三个变量中排除最大值的 1%,这样就有望更好地理解分布:

filter_price = games['price'] <= games['price'].quantile(0.99)
filter_user_rating_count = games['user_rating_count'] \
                          <= games['user_rating_count'].quantile(0.99)
filter_size = games['size'] <= games['size'].quantile(0.99)
filter_exclude_top_1_percent = filter_price \
                               & filter_user_rating_count \
                               & filter_size
games[filter_exclude_top_1_percent].hist(figsize = (10, 4),\
                                         bins = 30, ec = 'black');
# This line prints the four plots without overlap
plt.tight_layout()

结果如下:

图 7.22:DataFrame 直方图示例

图 7.22:DataFrame 直方图示例

现在直方图已经揭示了更多信息,我们可以从直方图中读取以下几点:

  • 大多数游戏是免费的,而极少数不免费的游戏中,绝大多数价格低于 10 美元。

  • 4.5 是最常见的平均用户评分;事实上,我们观察到很少有低于 3 的平均评分的游戏。

  • 大尺寸的游戏很少见。

  • 大多数游戏的用户评分很少。

作为练习,尝试从这些图表中提取更多信息,并结合这些变量的描述性统计进行补充:例如,评论大小变量的衰减模式,或者我们在用户评分计数中看到的高度集中模式:是什么导致了这些形状?

现在让我们谈谈另一个用于查看连续变量分布的有用图表:箱线图。箱线图是位置统计量Q1、中位数和Q3的图形表示,通常还显示最小值和最大值。可以使用以下代码生成包含 24 个观测值的样本数据集的箱线图:

mens_heights.plot(kind='box');

结果如下(已在图中添加了描述性统计的注释):

图 7.23:箱线图示例

图 7.23:箱线图示例

箱线图由两个边缘线和一个箱子组成。第一条边缘线(通常)从最小值开始,然后延伸到Q1,标志着箱子的开始,因此第一条边缘线覆盖了底部 25%的观察结果。箱子从Q1Q3,覆盖了观察结果的中间一半。箱子的高度称为四分位距IQR),是离散度的度量,告诉我们观察结果的中间 50%有多么集中:较大的 IQR 意味着更多的离散度。箱子中间的线对应于中位数,最后,上边缘线(通常)结束于最大值。

请注意,我在括号中添加了一对“通常”来表示最大值和最小值。当一个观察值高于Q3 + 1.5 x IQR(或低于Q1 - 1.5 x IQR)时,通常被认为是异常值的候选,并被绘制为一个点。如果我们有这样的观察结果,那么上(下)边缘线将结束于Q3 + 1.5 x IQR(或Q1 - 1.5 x IQR)。例如,这是size变量的箱线图:

games['size'].plot(kind='box');

结果如下:

图 7.24:尺寸的箱线图

图 7.24:尺寸的箱线图

在这种情况下,上边缘线并不是在最大值结束,而是延伸到Q3 + 1.5 x IQR。从这个图中,我们可以说这个变量有很多极端值。尽管箱线图有时对于单变量 EDA 可能有用,但直方图更可取。箱线图最好用于分析数值变量与分类变量的关系。我们将在下一小节中回到箱线图。

要完成本小节,让我们看看如何生成条形图,用于显示分类变量的计数、比例或百分比。让我们看看age_rating列,这是一个分类变量。以下代码将计算变量各个值的游戏数量:

games['age_rating'].value_counts()

结果如下:

4+     2287
9+     948
12+    925
17+    151
Name: age_rating,  dtype: int64

由于结果也是一个 pandas 系列,我们可以链接方法,并使用plot方法和参数kind='bar'来获得我们的条形图:

games['age_rating'].value_counts().plot(kind = 'bar');

结果如下:

图 7.25:年龄评级的绝对计数的条形图

图 7.25:年龄评级的绝对计数的条形图

如果我们想要可视化比例,我们可以通过在value_counts方法中添加normalize=True参数来修改前面的代码行:

games['age_rating'].value_counts(normalize=True).plot(kind='bar');

图表看起来几乎相同,唯一的变化在于y轴标签,现在显示比例:

图 7.26:年龄评级比例的条形图

图 7.26:年龄评级比例的条形图

最后,可视化比例的另一种选择是使用饼图。饼图已知存在一些问题,其中包括它们不是传达信息的好方法,这就是为什么我从不使用它们。然而,它们用于呈现业务信息,所以如果你的老板要求你做一个饼图,这里是如何生成一个:

games['age_rating'].value_counts().plot(kind = 'pie');

这是结果:

图 7.27:年龄评级的饼图

图 7.27:年龄评级的饼图

饼图的问题在于它们只是美化文件的一种方式,而不是传达定量信息的好方法;可视化是用于在我们想要补充和超越数值计算所能传达的内容时使用的。如果我们想要传达比例(或百分比),最好的方法就是显示实际值,如下所示:

percentages = 100*games['age_rating'].value_counts(normalize=True)
for k, x in percentages.items():
    print(f'{k}: {x:0.1f}%')

这将产生以下输出:

4+: 53.1%
9+: 22.0%
12+: 21.5%
17+: 3.5%

双变量 EDA:探索变量之间的关系

探索变量之间的关系是统计分析中最有趣的方面之一。在探索变量对之间的关系时,考虑到只有数值和分类变量的最广泛的划分,我们有三种情况:

  • 数值与数值

  • 数值与分类

  • 分类与分类

对于第一种情况,散点图是首选的可视化方式。对于第二种情况,根据我们想要找到的内容,我们有一些选择,但通常箱线图最有用。最后,对于第三种情况,最常见的选择是呈现所谓的列联表:尽管存在一些用于比较分类数据的可视化选项,但它们并不常见。

正如我们在探索性数据分析中所说的,当进行这种类型的分析时,通常在开始生成可视化之前制定要回答的问题列表是一个好主意。牢记我们的业务问题,我们将尝试回答以下三个问题:

  • 尺寸和平均用户评分之间的关系是什么?

  • 年龄评级和平均用户评分之间的关系是什么?

  • 有无应用内购买和游戏评分之间的关系是什么?

我们将尝试使用散点图来回答第一个问题。在其最简单的版本中,散点图使用笛卡尔平面显示一对变量的每个点。每对点由一个点表示,点的模式表明两个绘制变量之间是否存在某种关系。以下表格显示了使用散点图可以检测到的一些模式的示例:

图 7.28:散点图中的模式示例

图 7.28:散点图中的模式示例

请记住,表中的示例仅供参考:通常,现实世界的数据集不会提供如此容易识别的模式。

虽然我们可以使用 pandas 创建图表,但我们将使用 Seaborn,这是一个非常流行的统计可视化工具,可以仅用几行代码生成美丽和复杂的图表。我们将使用scatterplot函数,该函数接受将放在每个轴上的变量的名称和 DataFrame 的名称:

sns.scatterplot(x='size', y='average_user_rating',\
                data=games, \
                # this is for controlling the size of the points
                s=20);

这里是输出:

图 7.29:尺寸与平均用户评分的散点图

图 7.29:尺寸与平均用户评分的散点图

如果我们看一下图的右上角,似乎某种尺寸的游戏,比如超过 1,500 MB 的游戏,倾向于具有 3.5 及以上的评分。由于尺寸是图形质量和游戏复杂性的代理,这个图似乎表明提高获得体面平均评分的机会的一种方法是制作一定复杂性和视觉质量的游戏。然而,图表还显示相对较小的游戏获得了 5 的平均评分。

现在让我们探索数值和分类变量之间的关系。也许如果我们将平均评分视为分类变量,我们可以更多地了解评分。毕竟,由于数据集的一些怪癖,这个变量是离散的而不是连续的;它只取整数和半数值。以下代码使用字典中定义的映射对变量进行分类:

ratings_mapping = {1.5: '1_poor', 2.: '1_poor',\
                   2.5: '1_poor', 3: '1_poor',\
                   3.5: '2_fair', 4\. : '2_fair',\
                   4.5: '3_good',5\. : '4_excellent'}
games['cat_rating'] = games['average_user_rating']\
                      .map(ratings_mapping)

我们创建了一个使用分类变量的新平均用户评分比例。现在我们可以使用箱线图来查看不同类型评分的尺寸值分布是否发生变化:

sns.boxplot(x='cat_rating', y='size', \
            data=games[games['size'] <= 600], \
            order=['1_poor', '2_fair', '3_good', '4_excellent']);

结果如下:

图 7.30:尺寸与分类用户评分的箱线图

图 7.30:尺寸与分类用户评分的箱线图

我们将数据集限制在 600 MB 以下的游戏,以查看尺寸评分的关系是否适用于不太大的游戏。我们看到分布实际上是不同的,一般来说,评分较低的游戏的尺寸要小于其他类别(箱线图低于其他)。请注意,好评和优秀评分的游戏的分布几乎相同,也许表明对于 600 MB 以下的游戏,复杂性和高质量图形在一定程度上影响评分。

最后,让我们回顾第三种情况:如何探索两个分类变量之间的关系。为此,让我们探索年龄评分与我们刚刚创建的分类评分之间的关系。我们可以生成一个表,计算我们两个变量的值组合中有多少观察结果。这通常被称为列联表。Pandas 有一个方便的crosstab函数来实现这个目的:

pd.crosstab(games['age_rating'], games['cat_rating'])

结果如下:

图 7.31:年龄评分与分类用户评分的列联表

图 7.31:年龄评分与分类用户评分的列联表

拥有计数是很好的,但是仍然有点难以理解这些数据。为了找出这两个变量是否相关,我们需要找出年龄评分的比例是否根据游戏的好坏而变化。例如,如果我们发现 4+游戏中有 90%的游戏评分较低,同时 17+游戏中只有 15%的游戏评分较低,那么可以合理地假设这些变量之间存在某种关系。为了进行这种计算,我们必须对前一个表格进行标准化。我们通过添加normalize='index'参数来实现这一点:

100*pd.crosstab(games['age_rating'],\
                games['cat_rating'], \
                normalize='index')

我们已经将整个表乘以 100,这样更容易阅读为百分比:

图 7.32:行标准化的年龄评分与分类用户评分的列联表

图 7.32:行标准化的年龄评分与分类用户评分的列联表

由于行已经被标准化,每行的总和应该为 100。现在我们可以轻松比较不同用户评分在不同年龄评分中的分布。例如,我们观察到,无论年龄评分如何,优秀评分的游戏比例几乎相同,其他列也是如此(多多少少)。这表明也许游戏的年龄评分对游戏的评分并不是一个重要因素。

这就是统计分析成为一门艺术的地方。最初的探索结果产生了新的问题和假设,我们将进一步使用更多的数值和可视化分析来探索,希望经过几次迭代后,我们将产生有关手头问题的有用信息。

我将通过说明,尽管本书的范围集中在可视化两个变量之间的关系上,但也可以通过可视化探索三个或更多变量之间的关系。但是,请记住,在可视化中使用两个以上的变量通常会大幅增加分析的复杂性。

练习 7.03:练习 EDA

在这个练习中,我们将使用箱线图来可视化免费游戏和付费游戏的评分是否不同。

  1. 首先,让我们看看数据集中有哪些不同的价格。为此,查看价格的唯一值:
games['price'].unique()

输出如下:

array([  2.99,   1.99,   0\.  ,   0.99,   5.99,   7.99,   4.99,
         3.99, 9.99,  19.99,   6.99,  11.99,   8.99, 139.99,
         14.99,  59.99])
  1. 看起来所有的游戏都是以一定数量的美元加 99 美分出售的。我们知道在实际中 2.99 意味着 3 美元。使用内置的 round 方法将这个变量转换为整数值,这样值就是漂亮的整数:
games['price'] = games['price'].round()
  1. 由于这是一个离散数值变量,请使用条形图来可视化每个价格的游戏分布:
games['price'].value_counts().sort_index().plot(kind='bar');

输出如下:

图 7.33:按价格观察次数的条形图

图 7.33:按价格观察次数的条形图

  1. 看起来大多数游戏都是免费的。为了简化分析,创建一个名为cat_price的分类变量,指示游戏是免费还是付费:
games['cat_price'] = (games['price'] == 0).astype(int)\
                      .map({0:'paid', 1:'free'})
  1. 使用箱线图来可视化前一点中创建的变量之间的关系:
sns.boxplot(x='cat_price', y='average_user_rating', \
            data=games);

输出如下:

图 7.34:箱线图:cat_price 与平均用户评分

图 7.34:箱线图:cat_price 与平均用户评分

从图表中,我们可以看到免费和付费游戏的平均用户评分分布几乎相同。这表明免费与付费游戏的状态不会影响游戏的评分。

在这个练习中,我们使用箱线图来探索变量价格的分布,并查看这个变量与平均用户评分之间是否存在某种关系。

注意

要访问此特定部分的源代码,请参阅packt.live/2VBV2gI

您也可以在packt.live/2YUGv1I上线运行此示例。

在本节中,我们学习了 EDA,这是将描述性统计与可视化相结合的过程。我们了解了几种在几乎每种统计分析中使用的最有用的可视化类型。您已经熟悉了直方图、箱线图、条形图和散点图,这些都是强大的工具,可以补充数值分析并揭示有关数据集的有用信息。

在每个统计分析中,EDA 是一个必不可少的步骤,因为它允许我们了解数据集中的变量,识别它们之间的潜在关系,并生成可以使用形式推断方法进行正式测试的假设。现在我们已经学会了描述性统计的基础知识,我们可以继续学习推断统计学,但首先,我们必须学习一些概率论的基础知识,这是下一章的主题。

活动 7.01:查找评分高的策略游戏

您所在的游戏开发公司已经提出了一项计划,以加强其在游戏市场的地位。根据行业知识和其他商业报告,很明显吸引新客户的一个非常有效的方法是在移动游戏领域建立良好的声誉。鉴于这一事实,您的公司有以下计划:为 iOS 平台开发一款策略游戏,这款游戏将受到很多积极关注,从而将大量新客户带到公司。公司相信只有游戏得到用户的好评,这个计划才会奏效。由于您在移动游戏领域有丰富的经验,因此您被要求回答以下问题:哪种类型的策略游戏得到了很高的用户评分?

这项活动的目标是双重的:首先是基于两个分类变量的组合创建一个新变量。然后,使用groupby方法对用户评分进行描述性统计,以查看平均用户评分与新创建变量之间是否存在关系。

完成步骤:

  1. 加载numpypandas库。

  2. 加载策略游戏数据集(在本章的data文件夹中)。

  3. 执行本章第一部分中的所有转换:

a. 更改变量的名称。

b. 将id列设置为index

c. 删除urlicon_url列。

d. 将original_release_datecurrent_version_release_date更改为datetime

e. 从 DataFrame 中删除average_user_rating为空的行。

f. 仅保留 DataFrame 中user_rating_count等于或大于 30 的行。

  1. 打印数据集的维度。您必须有一个包含 4,311 行和 15 列的 DataFrame。

  2. 用字符串EN填充languages列中的缺失值,以表示这些游戏只能用英语进行游玩。

  3. 创建一个名为free_game的变量,如果游戏价格为零,则其值为free,如果价格大于零,则其值为paid

  4. 创建一个名为multilingual的变量,如果语言列只有一个语言字符串,则其值为monolingual,如果语言列至少有两个语言字符串,则其值为multilingual

  5. 创建一个变量,其中包含在上一步中创建的两个变量的四种组合(free-monolingualfree-multilingualpaid-monolingualpaid-multilingual)。

  6. 计算price_language变量中每种类型的观察数量。

  7. 在 games DataFrame 上使用groupby方法,按照新创建的变量进行分组,然后选择average_user_rating变量并计算描述统计信息。

Note

此活动的解决方案可在第 681 页找到。

在这个活动中,我们展示了创建一个新的分类变量的一种方法,该变量是由两个其他分类变量的可能组合产生的。然后,我们使用groupby方法来计算新创建变量的可能值的描述统计信息。

Summary

在本章中,我们学习了进行任何统计分析的第一步:首先,我们定义了我们的业务问题并介绍了数据集。根据我们想要解决的问题,我们相应地准备了数据集:删除了一些记录,填补了缺失值,转换了一些变量的类型,并创建了新变量。然后,我们了解了描述统计的必要性;我们学会了使用 pandas 轻松计算它们以及如何使用和解释这些计算。在最后一部分,我们学习了如何将可视化与描述统计相结合,以更深入地了解数据集中变量之间的关系。在本章中学到的概念和技术,您可以在进行任何数据分析时进行实践。然而,要在分析中变得更复杂,您需要对概率论的基础有很好的掌握,这是我们下一章的主题。

QDN92

MWM57

第八章:基础概率概念及其应用

概述

在本章结束时,您将熟悉概率论中的基本和基础概念。您将学习如何使用 NumPy 和 SciPy 模块进行模拟,并通过计算概率来解决问题。本章还涵盖了如何使用模拟计算事件的概率和理论概率分布。除此之外,我们还将概念化地定义和使用包含在 scipy.stats 模块中的随机变量。我们还将了解正态分布的主要特征,并通过计算概率分布曲线下的面积来计算概率。

介绍

在上一章中,我们学习了如何进行任何统计分析的第一步。给定一个业务或科学问题和相关数据集,我们学习了如何加载数据集并准备进行分析。然后,我们学习了如何计算和使用描述性统计来理解变量。最后,我们进行了探索性数据分析,以补充我们从描述性统计中收集到的信息,并更好地理解变量及其可能的关系。在对分析问题有了基本了解之后,您可能需要再进一步使用更复杂的定量工具,其中一些工具在以下领域中使用:

  • 推断统计学

  • 机器学习

  • 规范分析

  • 优化

所有这些领域有什么共同点?很多:例如,它们具有数学性质,它们大量使用计算工具,并且它们以某种方式使用概率论,这是应用数学中最有用的分支之一,为其他学科提供了基础和工具,比如前面提到的学科。

在本章中,我们将对概率论进行非常简要的介绍。与传统的统计书籍不同,在本章中,我们将大量使用模拟来将理论概念付诸实践,并使其更具体化。为此,我们将广泛使用 NumPy 和 SciPy 的随机数生成能力,并学习如何使用模拟来解决问题。在介绍了必要的基础概念之后,我们将向您展示如何使用 NumPy 生成随机数,并利用这些能力来计算概率。在这样做之后,我们将定义随机变量的概念。

在本章后期,我们将更深入地探讨两种随机变量:离散和连续,并且对于每种类型,我们将学习如何使用 SciPy 创建随机变量,以及如何使用这些分布计算确切的概率。

随机性、概率和随机变量

这是一个密集的部分,有许多理论概念需要学习。虽然很沉重,但你将以对概率论中一些最基本和基础概念非常好的掌握完成这一部分。我们还将介绍非常有用的方法,您可以使用 NumPy 进行模拟,以便您可以使用一些代码进行操作。通过使用模拟,我们希望向您展示理论概念如何转化为实际数字和可以用这些工具解决的问题。最后,我们将定义随机变量概率分布,这是在使用统计学解决现实世界问题时需要了解的两个最重要的概念之一。

随机性和概率

我们都对随机性的概念有直观的了解,并在日常生活中使用这个术语。随机性意味着某些事件以不可预测或无规律的方式发生。

关于随机事件的一个悖论事实是,尽管个别随机事件根据定义是不可预测的,但在考虑许多这样的事件时,可以以非常高的信心预测某些结果。例如,当一次抛硬币时,我们无法知道我们将看到两种可能结果中的哪一个(正面或反面)。另一方面,当抛硬币1,000次时,我们几乎可以肯定我们会得到 450 到 550 个正面。

我们如何从个别不可预测的事件转变为能够预测一系列事件的有意义结果?关键在于概率论,这是数学的一个分支,形式化了对随机性的研究和对某些结果可能性的计算。概率可以被理解为不确定性的度量,概率论给了我们理解和分析不确定事件的数学工具。这就是为什么概率论作为决策工具如此有用:通过严谨和逻辑地分析不确定事件,我们可以做出更好的决策,尽管存在不确定性。

不确定性可能来自无知或纯粹的随机性,以至于抛硬币并不是一个真正的随机过程,如果你知道硬币的质量、手指的确切位置、投掷时施加的确切力量、确切的重力吸引力等等。有了这些信息,你原则上可以预测结果,但在实践中,我们不知道所有这些变量或者实际上做出确切预测的方程。另一个例子可能是足球比赛的结果、总统选举的结果,或者一周后是否会下雨。

鉴于我们对未来发生的事情一无所知,分配概率是我们能够做出最佳猜测的方法。

概率论也是一个大生意。整个行业,如彩票、赌博和保险,都是建立在概率法则和如何从中获利的基础上的。赌场不知道玩轮盘的人在下一局会赢,但由于概率法则,赌场老板完全确定轮盘是一款盈利的游戏。保险公司不知道客户明天是否会发生车祸,但他们确信有足够的汽车保险客户支付保费是一项盈利的业务。

尽管下一节会感觉有点理论性,但在我们能够用它们解决分析问题之前,有必要了解最重要的概念。

基础概率概念

我们将从大多数这一主题的处理中找到的基本术语开始。我们必须学习这些概念,以便能够严格解决问题,并以技术上正确的方式传达我们的结果。

我们将从实验的概念开始:在受控条件下发生的情况,我们从中得到一个观察。我们观察到的结果称为实验的结果。以下表格呈现了一些实验的示例,以及一些可能的结果:

图 8.1:示例实验和结果

图 8.1:示例实验和结果

实验的样本空间包括所有可能结果的数学集。最后,事件是样本空间的任何子集。样本空间的每个元素称为简单事件,因为它由单个元素组成。现在我们有了四个相互关联且对概率论至关重要的术语:实验、样本空间、事件和结果。继续使用前表中的示例,以下表格呈现了实验的样本空间和事件的示例:

图 8.2:示例实验、样本空间和事件

图 8.2:示例实验、样本空间和事件

注意

请注意,上表中的细节假设每分钟最多进行 1 笔交易。

值得注意的是,我们将样本空间定义为数学意义上的集合。因此,我们可以使用从集合论中知道的所有数学运算,包括获取子集(即事件)。由于事件是较大集合的子集,它们本身也是集合,因此我们可以使用并集、交集等。事件的常规表示法是使用大写字母,如 A、B 和 C。

当实验的结果属于事件时,我们说事件发生了。例如,在实验掷骰子中,如果我们对事件得到奇数感兴趣,并且观察到任何结果,即 1、3 或 5,那么我们可以说事件发生了。

在进行随机实验时,我们不知道会得到哪个结果。在概率理论中,我们为与实验相关的所有可能事件分配一个数字。这个数字就是我们所知道的事件的概率

我们如何为事件分配概率?有几种选择。但是,无论我们使用哪种方法为事件分配概率,只要我们为事件分配概率的方式满足以下四个条件,概率理论及其结果就成立。给定事件AB及其概率,表示为P(A)P(B)

  1. a:事件的概率始终是 0 到 1 之间的数字。越接近 1,事件发生的可能性就越大。极端情况下,事件无法发生为 0,事件一定会发生为 1。

  2. b:如果 A 是空集,则概率必须为 0。例如,对于实验掷骰子,事件得到大于 10 的数字不存在,因此这是空集,其概率为 0。

  3. c:这基本上表示进行实验时,一定会发生某种结果。

  4. d对于不相交的事件 A 和 B:如果我们有一组不重叠的事件 A 和 B,则事件(A U B),也称为A 或 B,的概率可以通过添加各自的概率来获得。这些规则也适用于两个以上的事件。

在概念和理论方面,本小节内容较多,但现在理解这些内容很重要,以避免以后出现错误。幸运的是,我们有 Python 和 NumPy 以及它们出色的数值能力,将帮助我们将这些理论付诸实践。

注意

关于所有练习和相关测试脚本的快速说明:如果您正在使用 CLI(如 Windows 的命令提示符或 Mac 的终端)运行测试脚本,它会抛出错误,比如在子类中实现 enable_gui。这与笔记本中使用的一些命令有关(如%matplotlib inline)。因此,如果您想运行测试脚本,请使用 IPython shell。本书中的练习代码最好在 Jupyter 笔记本上运行。

使用 NumPy 进行模拟的介绍

要开始将所有这些理论付诸实践,让我们从加载本章中将使用的库开始:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# line to allow the plots to be shown in the Jupyter Notebook
%matplotlib inline

我们将广泛使用 NumPy 的随机数生成能力。我们将使用np.random模块,它能够生成遵循许多最重要的概率分布的随机数(稍后详细介绍概率分布)。

让我们开始模拟一个随机实验:掷一个普通骰子

让我们学习如何使用 NumPy 执行这个实验。有不同的方法可以做到这一点。我们将使用random模块中的randint函数,该函数生成low(包括)和high(不包括)参数之间的随机整数。由于我们想生成 1 到 6 之间的数字,我们的函数将如下所示:

def toss_die():
    outcome = np.random.randint(1, 7)
    return outcome

让我们连续十次使用我们的函数来观察它的工作方式:

for x in range(10):
    print(toss_die())

以下是一个示例输出:

6, 2, 6, 5, 1, 3, 3, 6, 6, 5 

由于这些数字是随机生成的,你很可能会得到不同的值。

对于这个函数和几乎每个产生随机数(或其他随机结果)的函数,有时需要以任何时刻运行代码的人都能获得相同结果的方式生成随机数。为此,我们需要使用“种子”。

让我们添加一行代码来创建种子,然后连续十次使用我们的函数来观察它的工作方式:

np.random.seed(123)
for x in range(10):
    print(toss_die(), end=', ')

结果如下:

6, 3, 5, 3, 2, 4, 3, 4, 2, 2

只要运行包含种子函数内部的数字 123 的第一行,任何运行此代码的人(使用相同的 NumPy 版本)都将获得相同的输出数字。

numpy.random模块中另一个有用的函数是np.random.choice,它可以从向量中抽样元素。假设我们有一个由 30 名学生组成的班级,我们想随机选择其中的四个。首先,我们生成虚构的学生名单:

students = ['student_' + str(i) for i in range(1,31)]

现在,可以使用np.random.choice来随机选择其中四个:

sample_students = np.random.choice(a=students, size=4,\
                                   replace=False)
sample_students

以下是输出:

array(['student_16', 'student_11', 'student_19', \
       'student_26'], dtype='<U10')

replace=False参数确保一旦选择了一个元素,就不能再次选择它。这被称为无重复抽样

相反,带替换抽样意味着在产生每个样本时考虑向量的所有元素。想象一下,向量的所有元素都在一个袋子里。我们每次随机抽取一个元素作为样本,然后在抽取下一个样本之前将我们得到的元素放回袋子里。这样的应用可能是:假设我们将在 12 周内每周给小组中的一个学生出一次突击测验。所有学生都是可能被给予测验的对象,即使该学生在之前的周被选中过。为此,我们可以使用replace=True,如下所示:

sample_students2 = np.random.choice(a=students, \
                                    size=12, replace=True)
for i, s in enumerate(sample_students2):
    print(f'Week {i+1}: {s}')

结果如下:

Week 1: student_6
Week 2: student_23
Week 3: student_4
Week 4: student_26
Week 5: student_5
Week 6: student_30
Week 7: student_23
Week 8: student_30
Week 9: student_11
Week 10: student_6
Week 11: student_13
Week 12: student_5

正如你所看到的,可怜的学生 6 在第 1 周和第 10 周被选中,学生 30 在第 6 周和第 8 周被选中。

现在我们知道如何使用 NumPy 生成骰子结果和获取样本(带或不带替换),我们可以用它来练习概率。

练习 8.01:带和不带替换的抽样

在这个练习中,我们将使用random.choice来产生带和不带替换的随机样本。按照以下步骤完成这个练习:

  1. 导入 NumPy 库:
import numpy as np
  1. 创建两个包含四种不同花色和 13 种不同等级的标准牌组的列表:
suits = ['hearts', 'diamonds', 'spades', 'clubs']
ranks = ['Ace', '2', '3', '4', '5', '6', '7', '8', \
         '9', '10', 'Jack', 'Queen', 'King']
  1. 创建一个名为cards的列表,其中包含标准牌组的 52 张牌:
cards = [rank + '-' + suit for rank in ranks for suit in suits]
  1. 使用np.random.choice函数从牌组中抽取一手(五张牌)。使用replace=False,以便每张牌只被选择一次:
print(np.random.choice(cards, size=5, replace=False)) 

结果应该看起来像这样(你可能会得到不同的卡片):

['Ace-clubs' '5-clubs' '7-clubs' '9-clubs' '6-clubs']
  1. 现在,创建一个名为deal_hands的函数,返回两个列表,每个列表中都有五张牌,从同一副牌中抽取。在np.random.choice函数中使用replace=False。这个函数将执行替换的抽样:
def deal_hands():
    drawn_cards = np.random.choice(cards, size=10, \
                                   replace=False)
    hand_1 = drawn_cards[:5].tolist()
    hand_2 = drawn_cards[5:].tolist()
    return hand_1, hand_2

要打印输出,请这样运行函数:

deal_hands()

你应该得到类似这样的结果:

(['9-spades', 'Ace-clubs', 'Queen-diamonds', '2-diamonds', 
  '9-diamonds'],
 ['Jack-hearts', '8-clubs', '10-clubs', '4-spades', 
  'Queen-hearts'])
  1. 创建一个名为deal_hands2的第二个函数,它与上一个函数相同,但在np.random.choice函数中使用了replace=True参数。这个函数将执行替换的抽样:
def deal_hands2():
    drawn_cards = np.random.choice(cards, size=10, \
                                   replace=True)
    hand_1 = drawn_cards[:5].tolist()
    hand_2 = drawn_cards[5:].tolist()
    return hand_1, hand_2
  1. 最后,运行以下代码:
np.random.seed(2)
deal_hands2()

结果如下:

(['Jack-hearts', '4-clubs', 'Queen-diamonds', '3-hearts', 
  '6-spades'],
 ['Jack-clubs', '5-spades', '3-clubs', 'Jack-hearts', '2-clubs'])

正如你所看到的,通过允许带替换抽样,Jack-hearts牌在两手中都被抽中,这意味着在抽取每张牌时,考虑了所有 52 张牌。

在这个练习中,我们练习了带和不带替换的抽样的概念,并学会了如何使用np.random.choice函数应用它。

注意

要访问此特定部分的源代码,请参考packt.live/2Zs7RuY

你也可以在packt.live/2Bm7A4Y上在线运行这个示例。

概率作为相对频率

让我们回到概念部分的问题:我们如何为事件分配概率?在相对频率方法下,我们所做的是重复进行实验很多次,然后将事件的概率定义为它发生的相对频率,也就是我们观察到事件发生的次数除以我们进行实验的次数:

图 8.3:计算概率的公式

图 8.3:计算概率的公式

让我们用一个实际的例子来了解这个概念。首先,我们将进行 100 万次掷骰子的实验:

np.random.seed(81)
one_million_tosses = np.random.randint(low=1, \
                                       high=7, size=int(1e6))

我们可以从数组中获取前 10 个值:

one_million_tosses[:10]

看起来是这样的:

array([4, 2, 1, 4, 4, 4, 2, 2, 6, 3])

记住这个实验的样本空间是 S = {1, 2, 3, 4, 5, 6}。让我们使用相对频率方法定义一些事件并为它们分配概率。首先,让我们使用一些简单的事件:

  • A:观察到数字 2

  • B:观察到数字 6

我们可以利用 NumPy 的向量化能力,通过对比操作得到的布尔向量求和来计算发生简单事件的次数:

N_A_occurs = (one_million_tosses == 2).sum()
Prob_A = N_A_occurs/one_million_tosses.shape[0]
print(f'P(A)={Prob_A}')

结果如下:

P(A)=0.16595

按照完全相同的程序,我们可以计算事件B的概率:

N_B_occurs = (one_million_tosses == 6).sum()
Prob_B = N_B_occurs/one_million_tosses.shape[0]
print(f'P(B)={Prob_B}')

结果如下:

P(B)=0.166809

现在,我们将尝试一些复合事件(它们有多个可能的结果):

  • C:观察到奇数(或{1, 3, 5})

  • D:观察到小于 5 的数字(或{1, 2, 3, 4})

因为事件观察到奇数将在我们得到 1 3 5 时发生,我们可以将我们在口语中使用的翻译成数学中的OR运算符。在 Python 中,这是|运算符:

N_odd_number = (
    (one_million_tosses == 1) | 
    (one_million_tosses == 3) | 
    (one_million_tosses == 5)).sum()
Prob_C = N_odd_number/one_million_tosses.shape[0]
print(f'P(C)={Prob_C}')

结果如下:

P(C)=0.501162

最后,让我们计算D的概率:

N_D_occurs = (one_million_tosses < 5).sum()
Prob_D = N_D_occurs/one_million_tosses.shape[0]
print(f'P(D)={Prob_D}')

我们得到以下值:

P(D)=0.666004

在这里,我们使用了相对频率方法来计算以下事件的概率:

  • A:观察到数字 2:0.16595

  • B:观察到数字 6:0.166809

  • C:观察到奇数:0.501162

  • D:观察到小于 5 的数字:0.666004

总之,在相对频率方法下,当我们有一组来自重复实验的结果时,我们用来计算事件概率的方法是计算事件发生的次数,然后将该次数除以总实验次数。就是这么简单。

在其他情况下,概率的分配可能基于定义而产生。这就是我们可能称之为理论概率的东西。例如,按定义,一个公平硬币有相等的概率显示两种结果中的任何一种,比如正面或反面。由于这个实验只有两种结果{正面,反面},并且概率必须加起来等于 1,每个简单事件必须有 0.5 的发生概率。

另一个例子如下:公平骰子是指六个数字具有相同的发生概率,因此投掷任何数字的概率必须等于1/6 = 0.1666666。事实上,numpy.randint函数的默认行为是模拟选择的整数数字,每个数字出现的概率相同。

使用理论定义,并知道我们用 NumPy 模拟了一个公平骰子,我们可以得出我们之前提到的事件的概率:

  • A:观察到数字 2,P(A) = 1/6 = 0.1666666

  • B:观察到数字 6,P(B) = 1/6 = 0.1666666

  • C:观察到奇数:P(观察到 1 观察到 3 观察到 5) = P(观察到 1) + 或 P(观察到 3) + P(观察到 5) = 1/6 + 1/6 + 1/6 = 3/6 = 0.5

  • D:观察到小于 5 的数字:P(观察到 1 观察到 2 观察到 3 观察到 4) = P(观察到 1) + 或 P(观察到 2) + P(观察到 3) + P(观察到 4) = 1/6 + 1/6 + 1/6 + 1/6 = 4/6 = 0.666666

注意这里有两件事:

  • 这些数字令人惊讶(或者如果你已经知道这个结果,那就不足为奇了)地接近我们用相对频率方法得到的结果。

  • 我们可以根据基本概率概念部分的规则 4 来分解CD的和。

定义随机变量

通常,您会发现其值是(或似乎是)随机过程的结果的数量。以下是一些例子:

  • 两个骰子的结果的和

  • 掷十枚硬币时出现的正面数

  • IBM 股票一周后的价格

  • 一个网站的访问者数量

  • 一个人一天摄入的卡路里数量

所有这些都是可以变化的数量的例子,这意味着它们是变量。此外,由于它们的值部分或完全取决于随机性,我们称它们为随机变量:其值由随机过程确定的数量。随机变量的典型表示法是大写字母,如XYZ。相应的小写字母用于指代它们的值。例如,如果X两个骰子的结果的和,以下是一些如何阅读符号的示例:

  • P(X = 10)X取值为 10 的概率

  • P(X > 5)X取大于 5 的值的概率

  • P(X = x)X取值为x的概率(当我们在做一般性陈述时)

由于X是两个骰子的和,X可以取以下值:{2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}。根据我们在上一节学到的知识,我们可以这样模拟我们的随机变量X的大量值:

np.random.seed(55)
number_of_tosses = int(1e5)
die_1 = np.random.randint(1,7, size=number_of_tosses)
die_2 = np.random.randint(1,7, size=number_of_tosses)
X = die_1 + die_2

我们已经模拟了两个骰子投掷了 100,000 次,并得到了X的相应值。这些是我们向量的第一个值:

print(die_1[:10])
print(die_2[:10])
print(X[:10])

结果如下:

[6 3 1 6 6 6 6 6 4 2]
[1 2 3 5 1 3 3 6 3 1]
[7  5  4 11  7  9  9 12  7  3]

因此,在第一次模拟的投掷中,我们在第一个骰子上得到了6,在第二个骰子上得到了1,所以X的第一个值是7

就像实验一样,我们可以定义随机变量上的事件,并计算这些事件的相应概率。例如,我们可以使用相对频率定义来计算以下事件的概率:

  • X = 10X取值为 10 的概率

  • X > 5X取大于 5 的值的概率

计算这些事件的概率的计算基本上与我们之前所做的相同:

Prob_X_is_10 = (X == 10).sum()/X.shape[0]
print(f'P(X = 10) = {Prob_X_is_10}')

结果如下:

P(X = 10) = 0.08329

对于第二个事件,我们有以下内容:

Prob_X_is_gt_5 = (X > 5).sum()/X.shape[0]
print(f'P(X > 5) = {Prob_X_is_gt_5}')

结果如下:

P(X > 5) = 0.72197

我们可以使用条形图来可视化我们的模拟中每个可能值出现的次数。这将帮助我们更好地了解我们的随机变量:

X = pd.Series(X)
# counts the occurrences of each value
freq_of_X_values = X.value_counts()
freq_of_X_values.sort_index().plot(kind='bar')
plt.grid();

生成的图表如下:

图 8.4:X 的值的频率

图 8.4:X 的值的频率

我们可以看到,在 100,000 次X中,它大约 5800 次取到了值 3,大约 14000 次取到了值 6,这也非常接近值 8 出现的次数。我们还可以观察到最常见的结果是数字 7。

根据概率的相对频率定义,如果我们将频率除以X的值的数量,我们可以得到观察到X的每个值的概率:

Prob_of_X_values = freq_of_X_values/X.shape[0]
Prob_of_X_values.sort_index().plot(kind='bar')
plt.grid();

这给我们带来了以下的图表:

图 8.5:X 的值的概率分布

图 8.5:X 的值的概率分布

这个图看起来几乎和上一个一样,但在这种情况下,我们可以看到观察到X的所有可能值的概率。这就是我们所说的随机变量的概率分布(或简称分布):观察到随机变量可以取的每个值的概率。

让我们用另一个例子来说明随机变量和概率分布这两个概念。首先,我们来定义随机变量:

Y: 投掷 10 枚公平硬币时出现的正面数。

现在,我们的任务是估计概率分布。我们知道这个随机变量可以取 11 个可能的值:{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}。对于这些值中的每一个,都有一个相应的概率,使得Y变量取得该值。直觉上,我们知道观察到变量的极端值是非常不可能的:得到 10 个正面(Y=10)或 10 个反面(Y=0)是非常不可能的。我们也期望Y变量大部分时间取得 4、5 和 6 这样的值。我们可以计算概率分布来验证我们的直觉。

再次,让我们模拟抛 10 枚硬币的实验。然后,我们可以观察这个随机变量的值。让我们开始模拟 1 百万次抛 10 枚公平硬币:

np.random.seed(97)
ten_coins_a_million_times = np.random.randint(0, 2, \
                                              size=int(10e6))\
                                              .reshape(-1,10) 

前面的代码将产生一个 1,000,000 x 10 的矩阵,每一行代表抛 10 枚硬币的实验。我们可以将 0 视为反面,1 视为正面。这里是前 12 行:

ten_coins_a_million_times[:12, :]

结果如下:

array([[0, 1, 1, 1, 1, 1, 0, 1, 1, 0],
       [0, 0, 1, 1, 1, 0, 1, 0, 0, 0],
       [0, 1, 0, 1, 1, 0, 0, 0, 0, 1],
       [1, 0, 1, 1, 0, 1, 0, 0, 1, 1],
       [1, 0, 1, 0, 1, 0, 1, 0, 0, 0],
       [0, 1, 1, 1, 0, 1, 1, 1, 1, 0],
       [1, 1, 1, 1, 0, 1, 0, 1, 0, 1],
       [0, 1, 0, 0, 1, 1, 1, 0, 0, 0],
       [1, 0, 0, 1, 1, 1, 0, 0, 0, 0],
       [0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
       [1, 0, 1, 1, 1, 0, 0, 0, 1, 0],
       [0, 0, 0, 0, 1, 1, 1, 0, 1, 1]])

为了产生不同的Y值,我们需要将每一行相加,如下所示:

Y = ten_coins_a_million_times.sum(axis=1)

现在,我们可以使用之前计算的对象(Y)来计算某些事件的概率,例如获得零个正面的概率:

Prob_Y_is_0 = (Y == 0).sum() / Y.shape[0]
print(f'P(Y = 0) = {Prob_Y_is_0}')

输出如下:

P(Y = 0) = 0.000986

这是一个非常小的数字,与我们的直觉一致:得到 10 个反面的可能性非常小。实际上,在 100 万次实验中只发生了 986 次。

就像之前一样,我们可以绘制Y的概率分布:

Y = pd.Series(Y)
# counts the occurrences of each value
freq_of_Y_values = Y.value_counts()
Prob_of_Y_values = freq_of_Y_values/Y.shape[0]
Prob_of_Y_values.sort_index().plot(kind='bar')
plt.grid();

这是输出:

图 8.6:Y 的概率分布

图 8.6:Y 的概率分布

得到 5 个正面的概率约为 0.25,所以大约 25%的时间,我们可以期望得到 5 个正面。得到 4 或 6 个正面的机会也相对较高。得到 4、5 或 6 个正面的概率是多少?我们可以通过使用Prob_of_Y_values来轻松计算,通过添加得到 4、5 或 6 个正面的相应概率:

print(Prob_of_Y_values.loc[[4,5,6]])
print(f'P(4<=Y<=6) = {Prob_of_Y_values.loc[[4,5,6]].sum()}')

结果如下:

4    0.205283
5    0.246114
6    0.205761
dtype: float64
P(4<=Y<=6) = 0.657158

因此,大约有 2/3(约 66%)的时间,当抛 10 枚公平硬币时,我们会观察到 4、5 或 6 个正面。回到概率作为不确定性度量的定义,我们可以说,我们有 66%的信心,当抛 10 枚公平硬币时,我们会看到 4 到 6 个正面。

练习 8.02:计算轮盘赌中的平均赢数

在这个练习中,我们将学习如何使用np.random.choice来模拟真实世界的随机过程。然后,我们将利用这个模拟,计算如果我们玩很多次,平均会赢得/失去多少钱。

我们将模拟去赌场玩轮盘赌。欧洲轮盘赌包括一个球随机落在 0 到 36 之间的任何整数上,每个数字落球的机会均等。允许许多种投注方式,但我们将只以一种方式进行(相当于著名的押红色或黑色的方式)。规则如下:

  • 在 19 到 36 的数字上押注m个单位(你喜欢的货币)。

  • 如果轮盘赌的结果是所选数字中的任何一个,那么你赢得m个单位。

  • 如果轮盘赌的结果是 0 到 18 之间的任何数字(包括 18),那么你就输掉m个单位。

为了简化,让我们假设赌注是 1 单位。让我们开始吧:

  1. 导入 NumPy 库:
import numpy as np
  1. 使用np.random.choice函数编写一个名为roulette的函数,模拟欧洲轮盘赌的任意次数的游戏:
def roulette(number_of_games=1):

    # generate the Roulette numbers
    roulette_numbers = np.arange(0, 37)

    outcome = np.random.choice(a = roulette_numbers, \
                               size = number_of_games,\
                               replace = True)
    return outcome
  1. 编写一个名为payoff的函数,它编码了前面的赔付逻辑。它接收两个参数:outcome,轮盘赌的数字(0 到 36 之间的整数);以及units,默认值为 1 的赌注单位:
def payoff(outcome, units=1):
    # 1\. Bet m units on the numbers from 19 to 36
    # 2\. If the outcome of the roulette is any of the 
    #    selected numbers, then you win m units
    if outcome > 18:
        pay = units
    else:
    # 3\. If the outcome of the roulette is any number 
    #    between 0 and 18 (inclusive) then you lose m units
        pay = -units
    return pay
  1. 使用np.vectorize对函数进行矢量化,以便它也可以接受轮盘赌结果的矢量。这将允许你传递一个结果的矢量,并获得相应的赔付:
payoff = np.vectorize(payoff)
  1. 现在,模拟玩 20 次轮盘赌(押注一单位)。使用payoff函数获得结果向量:
outcomes = roulette(20)
payoffs = payoff(outcomes)
print(outcomes)
print(payoffs)

输出如下:

[29 36 11  6 11  6  1 24 30 13  0 35  7 34 30  7 36 32 12 10]
[ 1  1 -1 -1 -1 -1 -1  1  1 -1 -1  1 -1  1  1 -1  1  1 -1 -1]
  1. 模拟 100 万次轮盘赌游戏,并使用结果获得相应的赔偿。将赔偿保存在名为payoffs的向量中:
number_of_games = int(1e6)
outcomes = roulette(number_of_games)
payoffs = payoff(outcomes)
  1. 使用np.mean函数计算赔偿向量的平均值。你得到的值应该接近-0.027027:
np.mean(payoffs)

负数意味着平均每下注一单位就会损失-0.027027。请记住,你的损失就是赌场的利润。这是他们的生意。

在这个练习中,我们学会了如何使用 NumPy 的随机数生成功能来模拟真实世界的过程。我们还模拟了大量事件以获得长期平均值。

注意

要访问本节的源代码,请参阅packt.live/2AoiyGp

你也可以在packt.live/3irX6Si上在线运行这个例子。

通过这样,我们学会了如何通过分配概率来量化不确定性来理解随机事件。然后,我们定义了概率论中一些最重要的概念。我们还学会了如何使用相对频率定义为事件分配概率。此外,我们介绍了随机变量的重要概念。在计算上,我们学会了如何使用 NumPy 模拟值和样本,以及如何使用模拟来回答关于某些事件概率的问题。

根据随机变量可以取的值的类型,我们可以有两种类型:

  • 离散随机变量

  • 连续随机变量

我们将在接下来的两节中提供一些例子。

离散随机变量

在本节中,我们将继续学习和处理随机变量。我们将研究一种特定类型的随机变量:离散随机变量。这些类型的变量在各种应用领域中都会出现,如医学、教育、制造等,因此了解如何处理它们非常有用。我们将学习可能是最重要的,也是最常见的离散分布之一:二项分布。

离散随机变量的定义

离散随机变量只能取特定数量的值(技术上来说是可数数量的值)。通常,它们可以取的值是特定的整数值,尽管这并非必要。例如,如果一个随机变量可以取{1.25, 3.75, 9.15}这组值,它也被认为是离散随机变量。我们在上一节介绍的两个随机变量就是离散随机变量的例子。

假设你是一家生产汽车零部件的工厂的经理。生产零件的机器平均会有 4%的次品。我们可以将这 4%解释为生产次品的概率。这些汽车零件被包装在包含 12 个单位的箱子中,因此,原则上每个箱子可以包含从 0 到 12 个次品。假设我们不知道哪个零件是次品(直到使用时),也不知道何时会生产次品。因此,我们有一个随机变量。首先,让我们正式定义它:

Z:12 箱装中次品的数量。

作为工厂经理,你最大的客户之一问了你以下问题:

  • 有 12 个非次品零件(零次品)的箱子占百分之多少?

  • 有 3 个或更多次次品的箱子占百分之多少?

如果你知道变量的概率分布,你可以回答这两个问题,所以你问自己以下问题:

Z 的概率分布是什么样子?

为了回答这个问题,我们可以再次使用模拟。要模拟一个单独的箱子,我们可以使用np.random.choice并通过p参数提供概率:

np.random.seed(977)
np.random.choice(['defective', 'good'], \
                 size=12, p=(0.04, 0.96))

结果如下:

array(['good', 'good', 'good', 'good', 'good', 'good', 'good', \
       'defective', 'good', 'good', 'good', 'good'], dtype='<U9')

我们可以看到这个特定的盒子包含一个次品。请注意,在函数中使用的概率向量必须加起来为 1:因为观察到次品的概率为 4%(0.04),观察到好品的概率为100% – 4% = 96%(0.96),这些值被传递给 p 参数。

现在我们知道如何模拟单个盒子,为了估计我们的随机变量的分布,让我们模拟大量的盒子;100 万个已经足够了。为了使我们的计算更简单更快,让我们使用 1 和 0 来表示次品和好品。要模拟 100 万个盒子,只需将大小参数更改为一个大小为12 x 1,000,000的元组即可:

np.random.seed(10)
n_boxes = int(1e6)
parts_per_box = 12
one_million_boxes = np.random.choice\
                    ([1, 0], \
                     size=(n_boxes, parts_per_box), \
                     p=(0.04, 0.96))

前五个盒子可以使用以下公式找到:

one_million_boxes[:5,:]

输出将如下所示:

array([[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])

输出中的每个零代表一个非次品,一个代表一个次品。现在,我们计算每个盒子中有多少次品,然后我们可以计算观察到 0、1、2、...、12 个次品的次数,然后我们可以绘制 Z 的概率分布:

# count defective pieces per box
defective_pieces_per_box = one_million_boxes.sum(axis=1)
# count how many times we observed 0, 1,…, 12 defective pieces
defective_pieces_per_box = pd.Series(defective_pieces_per_box)
frequencies = defective_pieces_per_box.value_counts()
# probability distribution
probs_Z = frequencies/n_boxes

最后,让我们将其可视化:

print(probs_Z.sort_index())
probs_Z.sort_index().plot(kind='bar')
plt.grid()

输出将如下所示:

0    0.612402
1    0.306383
2    0.070584
3    0.009630
4    0.000940
5    0.000056
6    0.000004
7    0.000001

概率分布将如下所示:

图 8.7:Z 的概率分布

图 8.7:Z 的概率分布

从这个模拟中,我们可以得出结论,大约 61%的盒子将被运送到零次品,大约 30%的盒子将包含一个次品。我们还可以看到,在一个盒子中观察到三个或更多次品是非常非常不可能的。现在,你可以回答你的客户的问题了:

  • 有多少百分比的盒子有 12 个非次品? 答案:61%的盒子将包含 12 个非次品。

  • 有多少百分比的盒子有 3 个或更多次品? 答案:只有大约 1%的盒子会包含 3 个或更多次品。

二项分布

事实证明,在某些条件下,我们可以找出某些离散随机变量的确切概率分布。二项分布是适用于随机变量并满足以下三个特征的理论分布:

  • 条件 1:对于单个观察,通常表示为成功失败,只有两种可能的结果。如果成功的概率是p,那么失败的概率必须是1 – p

  • 条件 2:实验被固定次数执行,通常用n表示。

  • 条件 3:所有实验都是独立的,这意味着知道一个实验的结果不会改变下一个实验的概率。因此,成功(和失败)的概率保持不变。

如果满足这些条件,那么我们说随机变量遵循二项分布,或者随机变量是二项随机变量。我们可以使用以下公式得到二项随机变量X的确切概率分布:

图 8.8:计算 X 的概率分布的公式

图 8.8:计算 X 的概率分布的公式

从技术上讲,数学函数,它接受离散随机变量(x)的可能值并返回相应的概率被称为概率质量函数。请注意,一旦我们从前一个方程中知道了np的值,概率就只取决于x的值,因此前一个方程为二项随机变量定义了概率质量函数。

好的,这听起来和看起来非常理论化和抽象(因为它是)。然而,我们已经介绍了两个遵循二项分布的随机变量。让我们验证以下条件是否成立:

Y: 抛掷 10 枚公平硬币时出现的正面数量。

  • 条件 1:对于每个单独的硬币,只有两种可能的结果,正面反面,每种结果的固定概率为 0.5。由于我们对正面的数量感兴趣,正面可以被认为是我们的成功反面是我们的失败

  • 条件 2:硬币的数量固定为 10 枚。

  • 条件 3:每次抛硬币都是独立的:我们隐含地(逻辑上)假设一枚硬币的结果不会影响任何其他硬币的结果。

所以,我们有了在前述公式中使用的数字:

  • p = 0.5

  • n = 10

如果我们想要得到得到五个正面的概率,那么我们只需要在公式中用已知的pn替换x = 5

图 8.9:在概率分布公式中替换 x、p 和 n 的值

图 8.9:在概率分布公式中替换 x、p 和 n 的值

现在,让我们用 Python 进行这些理论计算。是时候介绍另一个 Python 模块了,我们将在本章和接下来的章节中大量使用。scipy.stats模块包含许多统计函数。其中有许多可以用来创建遵循许多常用概率分布的随机变量的函数。让我们使用这个模块来创建一个遵循理论二项分布的随机变量。首先,我们用适当的参数实例化随机变量:

import scipy.stats as stats
Y_rv = stats.binom(
    n = 10, # number of coins
    p = 0.5 # probability of heads (success)
)

创建后,我们可以使用该对象的pmf方法来计算Y可以取的每个可能值的精确理论概率。首先,让我们创建一个包含Y可以取的所有值(从 0 到 10 的整数)的向量:

y_values = np.arange(0, 11)

现在,我们可以简单地使用pmf(代表概率质量函数)方法来获得前述值的相应概率:

Y_probs = Y_rv.pmf(y_values) 

我们可以像这样可视化我们得到的pmf

fig, ax = plt.subplots()
ax.bar(y_values, Y_probs)
ax.set_xticks(y_values)
ax.grid()

我们得到的输出如下:

图 8.10:Y 的 pmf

图 8.10:Y 的 pmf

这看起来与我们使用模拟得到的结果非常相似。现在,让我们比较两个图。我们将创建一个 DataFrame 来使绘图过程更容易:

Y_rv_df = pd.DataFrame({'Y_simulated_pmf': Prob_of_Y_values,\
                        'Y_theoretical_pmf':  Y_probs},\
                        index=y_values)
Y_rv_df.plot(kind='bar')
plt.grid();

输出如下:

图 8.11:Y 的 pmf 与模拟结果

图 8.11:Y 的 pmf 与模拟结果

这两组柱状图几乎是相同的;我们从模拟中得到的概率非常接近理论值。这显示了模拟的力量。

练习 8.03:检查随机变量是否符合二项分布

在这个练习中,我们将练习如何验证随机变量是否符合二项分布。我们还将使用scipy.stats创建一个随机变量并绘制分布。这将是一个大部分概念性的练习。

在这里,我们将检查随机变量Z:12 盒装中有缺陷汽车零件的数量是否符合二项分布(记住我们认为 4%的汽车零件有缺陷)。按照以下步骤完成这个练习:

  1. 按照通常的惯例导入 NumPy、Matplotlib 和scipy.stats
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
%matplotlib inline
  1. 就像我们在定义离散随机变量部分所做的那样,尝试概念性地检查Z是否满足二项随机变量的三个特征:

a. 条件 1:对于每个单独的汽车零件,只有两种可能的结果,有缺陷好的。由于我们对有缺陷零件感兴趣,那么这个结果可以被认为是成功,成功的固定概率为 0.04(4%)。

b. 条件 2:每盒的零件数量固定为 12,因此实验在每盒中进行了固定次数。

c. 条件 3:我们假设有缺陷的零件之间没有关系,因为机器随机产生平均 4%的有缺陷零件。

  1. 确定该变量的分布的pn参数,即p = 0.04n = 12

  2. 使用前面的参数使用理论公式来获得每箱恰好有一个次品的精确理论概率(使用x = 1):图 8.12:用概率分布公式替换 x、p 和 n 的值

图 8.12:用概率分布公式替换 x、p 和 n 的值

  1. 使用scipy.stats模块生成Z随机变量的实例。将其命名为Z_rv
# number of parts per box
parts_per_box = 12
Z_rv = stats.binom\
       (n = parts_per_box,\
        p = 0.04 # probability of defective piece (success)
        )
  1. 绘制Z的概率质量函数:
z_possible_values = np.arange(0, parts_per_box + 1)
Z_probs = Z_rv.pmf(z_possible_values)
fig, ax = plt.subplots()
ax.bar(z_possible_values, Z_probs)
ax.set_xticks(z_possible_values)
ax.grid();

结果如下:

图 8.13:Z 的 pmf

图 8.13:Z 的 pmf

在这个练习中,我们学习了如何检查离散随机变量具有二项分布所需的三个条件。我们得出结论,我们分析的变量确实具有二项分布。我们还能够计算其参数并使用它们创建一个二项随机变量,并绘制了分布。

注意

要访问此特定部分的源代码,请参阅packt.live/3gbTm5k

您也可以在packt.live/2Anhx1k上在线运行此示例。

在本节中,我们专注于离散随机变量。现在,我们知道它们是可以取特定数值的随机变量的一种。通常,这些是整数值。通常,这些类型的变量与计数有关:将通过考试的学生人数,穿过桥的汽车数量等。我们还了解了离散随机变量的最重要的分布,称为二项分布,以及如何使用 Python 获得二项随机变量的精确理论概率。

在下一节中,我们将专注于连续随机变量。

连续随机变量

在本节中,我们将继续处理随机变量。在这里,我们将讨论连续随机变量。我们将学习连续和离散概率分布之间的关键区别。此外,我们将介绍所有分布的鼻祖:著名的正态分布。我们将学习如何使用scipy.stats处理这种分布,并回顾其最重要的特征。

定义连续随机变量

在原则上,有些随机量可以在一个区间内取任意实数值。一些例子如下:

  • 一周后 IBM 股票的价格

  • 一个人一天摄入的卡路里数量

  • 英镑和欧元之间的收盘汇率

  • 特定群体中随机选择的男性的身高

由于它们的性质,这些变量被称为连续随机变量。与离散随机变量一样,有许多理论分布可以用来模拟现实世界的现象。

为了介绍这种类型的随机变量,让我们看一个我们已经熟悉的例子。再次加载我们在第七章《使用 Python 进行基本统计》中介绍的游戏数据集:

games = pd.read_csv('./data/appstore_games.csv')
original_colums_dict = {x: x.lower().replace(' ','_') \
                        for x in games.columns}
# renaming columns
games.rename(columns = original_colums_dict, inplace = True)

数据集中的一个变量是游戏大小(以字节为单位)。在可视化此变量的分布之前,我们将把它转换为兆字节:

games['size'] = games['size']/(1e6)
# replacing the one missing value with the median
games['size'] = games['size'].fillna(games['size'].median())
games['size'].hist(bins = 50, ec='k');

输出如下:

图 8.14:游戏大小的分布

图 8.14:游戏大小的分布

让我们定义我们的随机变量X如下:

X:从应用商店随机选择的策略游戏的大小。

定义了这个随机变量后,我们可以开始询问关于某些事件的概率的问题:

  • P(X > 100)X严格大于 100MB 的概率

  • P(100 ≤ X ≤ 400)X在 100 和 400MB 之间的概率

  • P(X = 152.53)X恰好为 152.53MB 的概率

到目前为止,您知道如何使用概率的相对频率定义来估计这些概率:计算事件发生的次数,然后除以总事件数(在这种情况下是游戏数):

# get the number of games to use as denominator
number_of_games = games['size'].size
# calculate probabilities
prob_X_gt_100 = (games['size'] > 100).sum()/number_of_games
prob_X_bt_100_and_400 = ((games['size'] >= 100) & \
                         (games['size'] <= 400))\
                         .sum()/number_of_games
prob_X_eq_152_53 = (games['size'] == 152.53).sum()/number_of_games
# print the results
print(f'P(X > 100) = {prob_X_gt_100:0.5f}')
print(f'P(100 <= X <= 400) = {prob_X_bt_100_and_400:0.5f}')
print(f'P(X = 152.53) = {prob_X_eq_152_53:0.5f}')

结果如下:

P(X > 100) = 0.33098
P(100 <= X <= 400) = 0.28306
P(X = 152.53) = 0.00000

注意我们计算的最后一个概率,P(X = 152.53)。随机变量取特定值(如 152.53)的概率是零。对于任何连续随机变量,这总是如此。由于这些类型的变量原则上可以取无限多个值,因此取确切特定值的概率必须为零。

前面的例子表明,当我们有足够多关于连续随机变量的数据点时,我们可以使用数据来估计随机变量在某些区间内取值的概率。然而,关于一个变量有大量观察结果的情况可能并非总是如此。鉴于这一事实,让我们考虑以下问题:

  • 如果我们根本没有数据呢?

  • 如果我们没有足够的数据呢?

  • 我们能否进行模拟来估计某些事件的概率(就像我们对离散随机变量所做的那样)?

这些都是合理的问题,我们可以通过更多了解理论连续概率分布来回答它们:

  • 如果我们根本没有数据呢?我们可以对变量做出一些合理的假设,然后使用众多理论连续概率分布之一对其进行建模。

  • 如果我们没有足够的数据呢?我们可以对变量做出一些合理的假设,用数据支持这些假设,并使用估计技术(下一章的主题)来估计所选择的理论连续概率分布的参数。

  • 我们能否进行模拟来估计某些事件的概率(就像我们对离散随机变量所做的那样)?可以。一旦我们选择了概率分布以及其参数,我们可以使用模拟来回答复杂的问题。

为了澄清前面的答案,在接下来的小节中,我们将介绍最重要的连续概率分布:正态分布。

值得注意的是,对于连续随机变量,概率分布也被称为概率密度函数pdf

正态分布

让我们介绍概率论中最著名和重要的分布:正态分布。正态分布的概率密度函数由以下方程定义:

图 8.15:正态分布的概率密度函数

图 8.15:正态分布的概率密度函数

在这里,πe是众所周知的数学常数。不要试图理解方程;您需要知道的只有两件事:首先,当我们有两个参数时,分布就完全确定了:

  • µ:分布的均值

  • σ:分布的标准差

其次,如果X是一个遵循正态分布的随机变量,那么对于可能的值x,前面的公式将给出一个与变量接近 x的概率直接相关的值。与二项分布的公式不同,我们通过直接将值x代入公式来得到概率,在连续随机变量的情况下,情况不同:公式给出的值没有直接解释。下面的例子将澄清这一点。

我们将使用scipy.stats模块创建一个遵循正态分布的随机变量。假设某一男性人群的身高服从均值为 170 厘米,标准差为 10 厘米的正态分布。要使用scipy.stats创建这个随机变量,我们需要使用以下代码:

# set the mu and sigma parameters of the distribution
heights_mean = 170
heights_sd = 10
# instantiate the random variable object
heights_rv = stats.norm(
        loc = heights_mean, # mean of the distribution
        scale = heights_sd  # standard deviation
)

前面的代码创建了正态分布的随机变量,其概率密度函数如下所示:

图 8.16:正态分布随机变量的概率密度函数

图 8.16:正态分布随机变量的概率密度函数

对于每个值x,比如175,我们可以使用pdf方法得到 pdf 的值,就像这样:

heights_rv.pdf(175)

结果如下:

0.03520653267642

这个数字是如果你在前面的公式中用175替换x会得到的:

图 8.17 替换 x=175 的值

图 8.17 替换 x=175 的值

要明确,这不是观察到一个身高为 175 厘米的男性的概率(请记住,这个变量取特定值的概率应该是零),因为这个数字没有简单的直接解释。但是,如果我们绘制整个密度曲线,那么我们就可以开始理解我们的随机变量的分布。要绘制整个概率密度函数,我们必须创建一个包含该变量可能取的一系列可能值的向量。根据男性身高的背景,假设我们想要绘制介于 130 厘米和 210 厘米之间的值的 pdf,这些是健康成年男性的可能值。首先,我们使用np.linspace创建值的向量,在这种情况下,它将在 120 和 210 之间(包括这两个值)创建 200 个等间距的数字:

values = np.linspace(130, 210, num=200)

现在,我们可以生成 pdf 并将其与创建的值绘制在一起:

heights_rv_pdf = heights_rv.pdf(values)
plt.plot(values, heights_rv_pdf)
plt.grid();

曲线看起来是这样的:

图 8.18:均值=170,标准差=10 的正态分布示例

图 8.18:均值=170,标准差=10 的正态分布示例

曲线越高,观察到与相应x轴数值周围的值的可能性就越大。例如,我们可以看到,我们更有可能观察到男性身高在 160 厘米到 170 厘米之间,而不是在 140 厘米到 150 厘米之间。

现在我们已经定义了这个正态分布的随机变量,我们可以使用模拟来回答关于它的某些问题吗?当然可以。事实上,现在,我们将学习如何使用已定义的随机变量来模拟样本值。我们可以使用rvs方法来生成这些值,该方法从概率分布中生成随机样本:

sample_heighs = heights_rv.rvs\
                (size = 5, \
                 random_state = 998 # similar to np.seed)
for i, h in enumerate(sample_heighs):
    print(f'Men {i + 1} height: {h:0.1f}')

结果如下:

Men 1 height: 171.2
Men 2 height: 173.3
Men 3 height: 157.1
Men 4 height: 164.9
Men 5 height: 179.1

在这里,我们正在模拟从人口中随机抽取五名男性并测量他们的身高。请注意,我们使用了random_state参数,它起到了与numpy.seed类似的作用:确保运行相同代码的人会得到相同的随机值。

与之前一样,我们可以使用模拟来回答与这个随机变量相关的事件的概率问题。例如,找到一个身高超过 190 厘米的男性的概率是多少?以下代码使用我们之前定义的随机变量计算了这个模拟:

# size of the simulation
sim_size = int(1e5)
# simulate the random samples
sample_heights = heights_rv.rvs\
                 (size = sim_size,\
                  random_state = 88 # similar to np.seed)
Prob_event = (sample_heights > 190).sum()/sim_size
print(f'Probability of a male > 190 cm: {Prob_event:0.5f} \
 (or {100*Prob_event:0.2f}%)')

结果是:

Probability of a male > 190 cm: 0.02303 (or 2.30%)

正如我们将在下一节中看到的,有一种方法可以从density函数中获得精确的概率,而无需模拟值,这有时可能是计算昂贵且不必要的。

正态分布的一些特性

宇宙和数学的一个令人印象深刻的事实是,现实世界中许多变量都遵循正态分布:

  • 人类身高

  • 大多数哺乳动物物种的体重

  • 标准化测试的分数

  • 制造过程中与产品规格的偏差

  • 诸如舒张压、胆固醇和睡眠时间等医学测量

  • 诸如某些证券的回报率等金融变量

正态分布描述了许多现象,并且在概率和统计学中被广泛使用,因此了解两个关键属性是值得的:

  • 正态分布完全由其两个参数确定:均值和标准差。

  • 正态分布的经验法则告诉我们,根据标准偏差的数量,我们将找到多少比例的观察值。

让我们了解这两个关键特性。首先,我们将说明分布的参数如何决定其形状:

  • 平均值决定了分布的中心。

  • 标准差决定了分布的宽度(或扩散程度)。

为了说明这一特性,假设我们有以下三个男性身高的人口。每个人口对应一个不同的国家:

  • 国家 A:平均值=170 厘米,标准差=10 厘米

  • 国家 B:平均值=170 厘米,标准差=5 厘米

  • 国家 C:平均值=175 厘米,标准差=10 厘米

有了这些参数,我们可以可视化和对比三个不同国家的分布。在可视化之前,让我们创建随机变量:

# parameters of distributions
heights_means = [170, 170, 175]
heights_sds = [10, 5, 10]
countries = ['Country A', 'Country B', 'Country C']
heights_rvs = {}
plotting_values = {}
# creating the random variables
for i, country in enumerate(countries):
    heights_rvs[country] = stats.norm(
        loc = heights_means[i], # mean of the distribution
        scale = heights_sds[i]  # standard deviation
    )

有了这些创建的对象,我们可以进行可视化:

# getting x and y values for plotting the distributions
for i, country in enumerate(countries):
    x_values = np.linspace(heights_means[i] - 4*heights_sds[i], \
                           heights_means[i] + 4*heights_sds[i])
    y_values = heights_rvs[country].pdf(x_values)
    plotting_values[country] = (x_values, y_values)

# plotting the three distributions
fig, ax = plt.subplots(figsize = (8, 4))
for i, country in enumerate(countries):
    ax.plot(plotting_values[country][0], \
            plotting_values[country][1], \
            label=country, lw = 2)

ax.set_xticks(np.arange(130, 220, 5))
plt.legend()
plt.grid();

这个情节看起来是这样的:

图 8.19:不同参数的正态分布比较

图 8.19:不同参数的正态分布比较

尽管国家 A国家 B的人口具有相同的平均值(170 厘米),但标准差的差异意味着国家 B的分布更集中在 170 厘米附近。我们可以说这个国家的男性身高更加均匀。国家 A国家 C的曲线基本上是一样的;唯一的区别是国家 C的曲线向右偏移了 5 厘米,这意味着在国家 C比在国家 A国家 B更有可能找到身高在 190 厘米及以上的男性(在x=190及以上,绿色曲线的y轴值大于其他两个曲线)。

正态分布的第二个重要特征被称为经验法则。让我们拿我们的例子来说明,男性身高的人口服从正态分布,平均身高为 170 厘米,标准差为 10 厘米:

  • 约 68%的观察结果将落在区间内:平均值±1 个标准差。对于男性的身高,我们会发现大约 68%的男性身高在 160 厘米和 180 厘米(170±10)之间。

  • 约 95%的观察结果将落在区间内:平均值±2 个标准差。对于男性的身高,我们会发现大约 95%的男性身高在 150 厘米和 190 厘米(170±20)之间。

  • 超过 99%的观察结果将落在区间内:平均值±3 个标准差。几乎所有的观察结果将在距离平均值不到三个标准差的距离内。对于男性的身高,我们会发现大约 99.7%的男性身高在 150 厘米和 200 厘米(170±30)之间。

经验法则可以快速地让我们了解当我们考虑到离平均值几个标准差时,我们期望看到的观察比例。

为了完成本节和本章,你应该知道关于任何连续随机变量的一个非常重要的事实,那就是概率分布下的面积将给出变量在某个范围内的概率。让我们用正态分布来说明这一点,并将其与经验法则联系起来。假设我们有一个正态分布的随机变量,平均值=170标准差=10。在x=160x=180之间(离平均值一个标准差),概率分布下的面积是多少?经验法则告诉我们 68%的观察结果将落在这个区间内,所以我们期望a,这将对应于曲线下的面积在区间[160, 180]内。我们可以用 matplotlib 可视化这个图。生成图的代码有点长,所以我们将把它分成两部分。首先,我们将创建绘图函数,确定x轴上的限制,并定义要绘制的向量:

from matplotlib.patches import Polygon
def func(x):
    return heights_rv.pdf(x)
lower_lim = 160
upper_lim = 180
x = np.linspace(130, 210)
y = func(x)

现在,我们将创建一个有阴影区域的图:

fig, ax = plt.subplots(figsize=(8,4))
ax.plot(x, y, 'blue', linewidth=2)
ax.set_ylim(bottom=0)
# Make the shaded region
ix = np.linspace(lower_lim, upper_lim)
iy = func(ix)
verts = [(lower_lim, 0), *zip(ix, iy), (upper_lim, 0)]
poly = Polygon(verts, facecolor='0.9', edgecolor='0.5')
ax.add_patch(poly)
ax.text(0.5 * (lower_lim + upper_lim), 0.01, \
        r"$\int_{160}^{180} f(x)\mathrm{d}x$", \
        horizontalalignment='center', fontsize=15)
fig.text(0.85, 0.05, '$height$')
fig.text(0.08, 0.85, '$f(x)$')
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.xaxis.set_ticks_position('bottom')
ax.set_xticks((lower_lim, upper_lim))
ax.set_xticklabels(('$160$', '$180$'))
ax.set_yticks([]);

输出将如下所示:

图 8.20:概率密度函数下的面积作为事件的概率

图 8.20:pdf 下的面积作为事件的概率

我们如何计算将给我们曲线下面积的积分?scipy.stats模块将使这变得非常容易。通过随机变量的cdf累积分布函数)方法,本质上是 pdf 的积分,我们可以通过减去下限和上限来轻松评估积分(记住微积分的基本定理):

# limits of the integral
lower_lim = 160
upper_lim = 180
# calculating the area under the curve
Prob_X_in_160_180 = heights_rv.cdf(upper_lim) - \
                    heights_rv.cdf(lower_lim)
# print the result
print(f'Prob(160 <= X <= 180) = {Prob_X_in_160_180:0.4f}')

结果如下:

Prob(160 <= X <= 180) = 0.6827

这就是我们如何从pdf中获得概率,而无需进行模拟。让我们看最后一个例子,通过与之前的结果联系起来,来澄清这一点。几页前,对于同一人群,我们问道,找到一个身高超过 190 厘米的男性的概率是多少?我们通过进行模拟得到了答案。现在,我们可以这样获得确切的概率:

# limits of the integral
lower_lim = 190
upper_lim = np.Inf # since we are asking X > 190
# calculating the area under the curve
Prob_X_gt_190 = heights_rv.cdf(upper_lim) - \
                heights_rv.cdf(lower_lim)
# print the result
print(f'Probability of a male > 190 cm: {Prob_X_gt_190:0.5f} \
      (or {100*Prob_X_gt_190:0.2f}%)')

结果如下:

Probability of a male > 190 cm: 0.02275 (or 2.28%)

如果您将此与我们之前得到的结果进行比较,您会发现它几乎是一样的。然而,这种方法更好,因为它是精确的,不需要我们进行任何计算密集或占用内存的模拟。

练习 8.04:在教育中使用正态分布

在这个练习中,我们将使用scipy.stats中的正态分布对象和cdf及其逆ppf来回答教育问题。

在心理测量学和教育领域,一个众所周知的事实是,许多与教育政策相关的变量都呈正态分布。例如,标准化数学测试的分数遵循正态分布。在这个练习中,我们将探讨这一现象:在某个国家,高中学生参加一项标准化数学测试,其分数遵循以下参数的正态分布:平均值=100标准差=15。按照以下步骤完成这个练习:

  1. 按照通常的惯例导入 NumPy、Matplotlib 和scipy.stats
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
%matplotlib inline
  1. 使用scipy.stats模块生成一个名为X_rv的正态分布随机变量实例,其平均值=100标准差=15
# producing the normal distribution
X_mean = 100
X_sd = 15
# create the random variable
X_rv = stats.norm(loc = X_mean, scale = X_sd)
  1. 绘制X的概率分布:
x_values = np.linspace(X_mean - 4 * X_sd, X_mean + 4 * X_sd)
y_values = X_rv.pdf(x_values)
plt.plot(x_values, y_values, lw=2)
plt.grid();

输出将如下:

图 8.21:测试分数的概率分布

图 8.21:测试分数的概率分布

  1. 教育部决定,被认为在数学上胜任的人的最低分数是 80。使用cdf方法计算将获得高于该分数的学生的比例:
Prob_X_gt_80 = X_rv.cdf(np.Inf) - X_rv.cdf(80)
print(f'Prob(X >= 80): {Prob_X_gt_80:0.5f} \
(or {100*Prob_X_gt_80:0.2f}%)')

结果如下:

Prob(X >= 80): 0.90879 (or 90.88%)

大约 91%的学生在数学上被认为是胜任的。

  1. 一个非常严格的大学希望为被录取到他们的项目的高中生设定非常高的标准。该大学的政策是只录取人口中数学分数处于前 2%的学生。使用ppf方法(本质上是cdf方法的逆函数)并使用参数1-0.02=0.98来获得录取的分数线:
proportion_of_admitted = 0.02
cut_off = X_rv.ppf(1-proportion_of_admitted)
print(f'To admit the top {100*proportion_of_admitted:0.0f}%, \
the cut-off score should be {cut_off:0.1f}')
top_percents = np.arange(0.9, 1, 0.01)

结果应该如下:

To admit the top 2%, the cut-off score should be 130.8

在这个练习中,我们使用了正态分布和cdfppf方法来回答关于教育政策的现实问题。

注意

要访问此特定部分的源代码,请参阅packt.live/3eUizB4

您也可以在packt.live/2VFyF9X上在线运行此示例。

在本节中,我们学习了关于连续随机变量的知识,以及这些类型变量的最重要的分布:正态分布。本节的关键是,连续随机变量由其概率密度函数确定,而概率密度函数又由其参数确定。在正态分布的情况下,它的两个参数是平均值和标准差。我们使用一个例子来演示这些参数如何影响分布的形状。

另一个重要的收获是,你可以使用概率密度函数下方的面积来计算某些事件的概率。这对于任何连续随机变量都是成立的,当然也包括遵循正态分布的随机变量。

最后,我们还学习了正态分布的经验法则,这是一个很好的经验法则,如果你想快速了解分布均值附近k个标准差处的值的比例。

现在你已经熟悉了这个重要的分布,我们将在下一章中继续使用它,当我们再次在中心极限定理的背景下遇到它时。

活动 8.01:在金融中使用正态分布

在这个活动中,我们将探讨使用正态分布来理解股票价格的每日收益的可能性。在活动结束时,你应该对正态分布是否适合描述股票的每日收益有自己的看法。

在这个例子中,我们将使用 Yahoo! Finance 提供的有关微软股票的每日信息。按照以下步骤完成此活动:

注意

完成此活动所需的数据集可以在packt.live/3imSZqr找到。

  1. 使用 pandas 从data文件夹中读取名为MSFT.csv的 CSV 文件。

  2. 可选地,重命名列,使它们易于处理。

  3. date列转换为适当的datetime列。

  4. date列设置为 DataFrame 的索引。

  5. 在金融中,股票的每日收益被定义为每日收盘价的百分比变化。通过计算adj close列的百分比变化,创建 MSFT DataFrame 中的returns列。使用pct_change系列 pandas 方法来实现。

  6. 将分析期限限制在2014-01-012018-12-31(包括在内)之间的日期。

  7. 使用直方图可视化returns列的分布,使用 40 个箱子。它看起来像正态分布吗?

输出应该如下所示:

图 8.22:微软股票收益的直方图

图 8.22:微软股票收益的直方图

  1. 计算returns列的描述统计信息:
count    1258.000000
mean        0.000996
std         0.014591
min        -0.092534
25%        -0.005956
50%         0.000651
75%         0.007830
max         0.104522
Name: returns, dtype: float64
  1. 创建一个名为R_rv的随机变量,它将代表微软股票的每日收益。使用收益列的平均值和标准差作为该分布的参数。

  2. 绘制R_rv的分布和实际数据的直方图。然后,使用plt.hist()函数和density=True参数,使真实数据和理论分布以相同的比例出现:图 8.23:微软股票收益的直方图

图 8.23:微软股票收益的直方图

  1. 在查看前面的图后,你会说正态分布是否为微软股票的每日收益提供了准确的模型?

  2. 额外活动:使用包含宝洁公司股票信息的PG.csv文件重复前面的步骤。

这个活动是关于观察现实世界的数据,并尝试使用理论分布来描述它。这很重要,因为通过拥有一个理论模型,我们可以利用其已知的特性得出现实世界的结论和影响。例如,你可以使用经验法则来描述公司的每日收益,或者你可以计算一天内损失一定金额的概率。

注意

此活动的解决方案可以在第 684 页找到。

总结

本章简要介绍了概率论的数学分支。

我们定义了概率的概念,以及一些最重要的规则和相关概念,如实验、样本空间和事件。我们还定义了随机变量这一非常重要的概念,并举例说明了两种主要的离散和连续随机变量。在本章的后面,我们学习了如何使用scipy.stats模块创建随机变量,我们还用它来生成概率质量函数和概率密度函数。我们还讨论了宇宙中两个最重要的随机变量:正态分布和二项分布。这些在许多应用领域被用来解决现实世界的问题。

当然,这只是对这个主题的简要介绍,目标是向您介绍并让您熟悉概率论中一些基本和基础概念,特别是那些对理解和使用推断统计学至关重要的概念,而这将是下一章的主题。

WUE84

JNP97

第九章:9. 使用 Python 进行中级统计

概述

在本章中,我们将进一步学习一些中级统计概念。我们将了解大数定律告诉我们随着样本的增大,样本均值的价值是什么。

通过本章的学习,你将能够应用中心极限定理来描述样本均值的分布,创建置信区间来描述平均值的可能取值并带有一定的置信度,使用假设检验来评估基于样本提供的证据的结论,并使用回归方程来分析数据。

介绍

在之前的章节中,我们已经使用描述性统计和可视化技术描述和探索了数据。我们还研究了概率、随机性以及使用随机变量的模拟来解决问题。分布的概念也被讨论过,这在本章后面将扮演更重要的角色。

在应用统计思想时,有一些重要的问题需要回答,涉及方法论。这些问题的例子可能包括“我应该让我的样本有多大?”或者“我们对结果有多有信心?”。在本章中,我们将看看我们如何应用统计学中最重要的两个定理,首先从它们的实际影响开始,然后再转向使用这些重要思想衍生出的更有用的技术来解决常见问题。

在本章中,我们将解释大数定律是什么,并澄清样本大小如何影响样本均值。我们将讨论中心极限定理,以及它在置信区间和假设检验中的应用。使用 Python,我们将构建函数来计算置信区间,描述样本统计和民意调查中的误差范围。我们将在 Python 中进行假设检验,评估收集样本的证据与一组相互矛盾的假设。最后,利用 Python 的线性回归能力,我们将创建一个线性模型来预测新的数据值。

大数定律

有很多人声称有很多方案和系统可以让你在赌场大赢家。但是这些人忽视了赌场为什么能够赚钱的原因;赔率总是对赌场有利,确保赌场最终总是赢得胜利(从长远来看)。赌场所依赖的是一种叫做大数定律的东西。

在我们弄清楚赌场为什么总是在长期内让自己成为赢家之前,我们需要定义几个术语。第一个是样本平均值,或者样本均值。当人们想到平均值时,他们通常会想到样本均值。你可以通过将结果相加然后除以结果的数量来计算样本均值。比如说我们抛硬币 10 次,有 7 次是正面。我们可以计算样本均值,或者每次抛硬币得到正面的平均次数,如下所示:

图 9.1:样本均值公式

图 9.1:样本均值公式

样本均值通常用表示,读作x bar

我们需要理解的第二个术语是期望值。期望值是基于概率我们可以期望的理论值。对于离散的例子,比如我们的抛硬币实验,我们通过将每个结果乘以其发生的概率来计算它。对于我们的抛硬币例子,我们将硬币的每一面的正反面数,正面为 1,反面为 0,乘以每一面发生的概率,这种情况下每一面的概率都是 0.5。数学上写出来就是:

图 9.2:期望值公式

图 9.2:期望值公式

我们可以期望每次抛硬币得到 0.5 个正面,这是有道理的,因为在任何一次抛硬币中我们有 50%的机会得到正面。

另一个术语是样本,它是一组结果。在这种情况下,抛硬币结果的集合就是我们的样本。样本的一个重要特征是其大小,或者你拥有的结果数量。我们有 10 次抛硬币,所以我们的样本大小是 10。最后一个术语是独立性的概念,即一个结果绝对不会影响另一个结果。我们的抛硬币是独立的;在第一次抛硬币得到正面不会以任何方式影响第 10 次抛硬币的结果。

注意我们的样本平均值和期望值不同。虽然在 10 次抛硬币的样本中得到 7 次正面似乎不太可能,但这并不是不可能的结果。然而,我们知道大约一半的样本应该是正面。如果我们继续抛 10 次硬币会发生什么?甚至再抛 100 次或 1,000 次呢?这个问题的答案由大数定律提供。大数定律规定,随着样本大小的增长,样本均值将收敛到我们的期望值。换句话说,随着我们抛硬币的次数越来越多,样本平均值应该越来越接近 0.5。

Python 和随机数

在本章中,我们将多次使用 random 库,但它实际上并不是真正的随机数——它是我们所谓的伪随机数。伪随机数是通常从算法生成的数。我们使用一个称为种子的数字初始化算法。很多时候,种子是基于程序执行的时间或日期。然而,Python(以及大多数其他语言)允许你将种子设置为任何你想要的数字。如果你用相同的种子初始化你的算法,那么每次都会生成相同的伪随机数。当你使用随机数并希望每次产生相同的结果时,这是很有用的。

练习 9.01:大数定律的实践

让我们在 Python 中扩展我们的抛硬币实验。首先,让我们创建一个抛硬币模拟器。打开你的 Jupyter 笔记本并输入以下代码:

  1. 我们首先需要导入random Python 包并设置seed方法:
# coin_flip_scenario.py
# import the random module
import random
random.seed(54321)
  1. 让我们为样本大小定义一个变量,并在这种情况下将其设置为10
# set the sample size or coin flips you what to run
sample_size = 10
  1. 我们创建一个空列表,以便收集我们抛硬币实验的结果:
# create a for loop and collect the results in a list
# 1 = heads and 0 = tails
result_list = []
for i in range(sample_size):
    result = random.randint(0, 1)
    result_list.append(result)
  1. 定义两个变量来编译结果(正面次数和每次抛硬币的平均正面次数):
# compile results
num_of_heads = sum(result_list)
avg_of_heads = float(num_of_heads) / sample_size
  1. 最后,我们将结果打印到控制台:
# print the results
print(f'Results: {num_of_heads} heads out of {sample_size} \
flips.')
print(f'Average number of heads per flip is {avg_of_heads}.')
  1. 运行你的笔记本应该得到以下类似的结果:
Results: 4 heads out of 10 flips. Average number of 
heads per flip is 0.4.
  1. 由于我们在这个模拟中生成随机数,你得到的结果可能会有所不同。在 10 次抛硬币中得到 4 次正面(每次抛硬币平均 0.4 次正面)似乎是可能的,但与我们的期望值 0.5 不同。但是请注意,当我们将样本大小从 10 增加到 100 时会发生什么:
# set the sample size or coin flips you what to run
sample_size = 100
  1. 重新运行整个程序(确保包括带有random.seed(54321)的行),这次结果将如下所示:
Results: 51 heads out of 100 flips. Average number     of heads per flip is 0.51.

注意,样本平均值(0.51)现在与样本大小为 100 相比,更接近期望值(0.50)而不是 10。这是大数定律的一个典型例子。

注意

要访问本节的源代码,请参阅 https://packt.live/2VCT9An。

你也可以在 https://packt.live/2NOMGhk 上在线运行此示例。

练习 9.02:随时间变化的抛硬币平均值

让我们回到我们的抛硬币模拟器代码,并将其构建出来,以便在我们抛硬币时保持一个运行的样本平均值。我们将抛硬币 20,000 次,并使用折线图来显示样本均值随时间的变化,并与我们的期望值进行比较。

  1. 导入randommatplotlib Python 包并设置随机种子:
# coin_clip_scenario_2.py
# import the module
import random
import matplotlib.pyplot as plt
random.seed(54321)
  1. 定义样本大小或抛硬币次数:
# set the sample size or coin flips you what to run
sample_size = 20000
  1. 初始化我们将用来收集模拟结果的变量:
# initialize the variables required for our loop
# 1 = heads and 0 = tails
num_of_heads = 0
heads_list = []
trials_list = []
freq_list = []
  1. 运行模拟并收集结果:
# create a for loop and collect the results in a list
for i in range(1,sample_size+1):
    result = random.randint(0, 1)
    if result == 1:
        num_of_heads += 1
    avg_of_heads = float(num_of_heads) / i
    heads_list.append(num_of_heads)
    trials_list.append(i)
    freq_list.append(avg_of_heads)
  1. 将结果打印到控制台:
# print the results
print(f'Results: {num_of_heads} heads out of {sample_size} flips.')
print(f'Average number of heads is {avg_of_heads}')
  1. 创建一条线图,显示随时间变化的样本均值,并使用虚线标记我们的期望值:
#create a simple line graph to show our results over time
plt.plot(trials_list, freq_list)
plt.ylabel('Sample Average')
plt.xlabel('Sample Size')
plt.hlines(0.50,0,sample_size,linestyles='dashed')
plt.show()
  1. 运行我们的笔记本将产生以下结果:
Results: 10008 heads out of 20000 flips. Average number of 
heads is 0.5004

该代码将生成以下图表,显示随着样本量的增加,每次抛硬币的平均正面朝上数量的变化(用实线表示)。请注意,大约在 2,000 次抛硬币后,样本均值与期望值相匹配(约为每次抛硬币 0.5 个正面朝上):

图 9.3:样本量每次抛硬币的平均正面朝上数量

图 9.3:样本量每次抛硬币的平均正面朝上数量

注意

要访问此特定部分的源代码,请参阅 https://packt.live/2BZcR2h。

您还可以在 https://packt.live/31AIxpc 上在线运行此示例。

大数定律在现实世界中的实际应用

通过概率的角度分析的最佳赌场游戏之一是轮盘赌。玩这个游戏相对简单。游戏的中心是一个巨大的轮盘,上面有 1 到 36 的空格和标签,0 和 00(双零)。奇数是红色的,偶数是黑色的,两个零空格都是绿色的。轮盘旋转,球被放入轮盘空格中,与轮盘旋转的方向相反。最终,球落入轮盘上的 38 个空格之一。球落在哪里的结果是人们下注的对象。他们可以下很多不同类型的赌注,从落在一个特定的数字上到球会落在哪个颜色的空格上。赌场根据您下注的类型进行支付。当大多数人第一次看到轮盘赌时,他们中的许多人会问这样的问题:“两个绿色空格是怎么回事?”我们将在接下来的几页中清楚地看到为什么绿色空格对赌场非常重要,但首先让我们谈谈我们可以从玩轮盘赌中期望什么。

为了使游戏更简单,我们将每次都下注球落在红色号码上。赢得这样的赌注的支付比例是 1:1,所以如果我们下注 5 美元并赢了,我们可以保留我们的 5 美元并赢得 5 美元。如果我们输了赌注,我们什么也不赢,失去了 5 美元的赌注。如果我们下注红色,以下是可能发生的概率:

  • 在红色上下注,如果球落在红色上,我们赢得:1

  • 在红色上下注,如果球落在黑色上,我们失去:2

  • 在红色上下注,如果球落在绿色上,我们失去:3

让我们看看以我们可以赢得或失去的金额来看结果,当下注 1 美元时:

  • 在红色上下注,如果球落在红色上,我们赢得 1 美元

  • 在红色上下注,如果球落在黑色上,我们失去 1 美元

  • 在红色上下注,如果球落在绿色上,我们失去 1 美元

这是一个离散分布的例子。要计算离散分布的期望值,您需要将结果的值乘以它发生的概率。如果您看一下前面的两个列表,我们有轮盘赌游戏中每个结果的概率和值,所以现在我们可以计算我们可以赢得或输掉的预期金额:

(落在红色上的概率落在红色上时的赢或输) + (落在黑色上的概率落在黑色上时的赢或输) + (落在绿色上的概率落在绿色上时的赢或输)*

现在,如果我们根据我们计算的概率计算我们可以赢得的预期金额,我们将得到(0.4741)+(0.474-1)+(0.053-1) ≈ -0.05*值:

前面的计算告诉我们,我们预计每下注 1 美元在红色上会损失约 5 美分。如果我们增加下注,我们预计会损失更多的钱。

练习 9.03:计算轮盘赌游戏的平均赢利如果我们不断下注红色

让我们重新调整我们的模拟代码,模拟玩轮盘赌游戏并跟踪每场游戏我们赢得或输掉的平均金额。然后,我们将像我们为抛硬币的情况那样绘制结果的图表:

  1. 导入randommatplotlib包:
# roulette simulation.py
# import the module
import random
import matplotlib.pyplot as plt
random.seed(54321)
  1. 创建一个样本大小的变量,并将其设置为10。创建一个名为bet的变量,并将其设置为 1 美元:
# set the number of games of roulette you want to play
sample_size = 10
#set the amount of money you want to bet
bet = 1
  1. 初始化我们将用于收集模拟结果的变量:
# initialize the variables required for our loop
# 1 to 36 represent numbers on roulette wheel, 37 represents 0, 38 represents 00
net_money = 0
wins = 0
money_track = []
trials_track = []
  1. 运行模拟并收集结果:
# create a for loop and collect the results in a list
for i in range(1,sample_size+1):
    result = random.randint(1,38)
    if result % 2 == 1 and result != 37:
        net_money += bet
        wins += 1
    else:
        net_money -= bet
    money_track.append(net_money/i)
    trials_track.append(i)
  1. 打印模拟结果和平均期望值:
# print the results
print(f'Results: You won {wins} games out of\
{sample_size} and won an average of\
{net_money/sample_size} dollars per game')
print(f'Results: You are expected to win\
{((18/38)*bet+(20/38)*(-bet))} per game')
  1. 绘制每场游戏中净变化的期望值和净变化的样本平均值的图表:
#create a simple line graph to show our results over time
plt.plot(trials_track, money_track)
plt.ylabel('Net Money')
plt.xlabel('Number of games')
plt.hlines(((18/38)*bet+(20/38)*(-bet)), 0,            sample_size, linestyles='dashed')
plt.show()
  1. 运行你的笔记本,你会得到以下结果:
Results: You won 4 games out of 10 and won an average of -0.2 dollars per game
Results: You are expected to win -0.05263157894736842 per game

上述代码将生成以下图表:

图 9.4:进行了 10 场游戏的轮盘每场游戏的平均净收益

图 9.4:进行了 10 场游戏的轮盘每场游戏的平均净收益

在上图中,实线代表我们玩的 10 场游戏中每场游戏的平均赢钱数。虚线代表我们每场游戏可以期望赢得或输掉的金额。我们应该每场游戏输掉大约 5 美分,但在这种特定情况下,我们总共输掉了 20 美分,远少于每场游戏输掉 5 美分。如果从代码中删除random.seed(54321)并重新运行模拟,结果将会有所不同。随意尝试并改变每次下注的金额,看看会发生什么。

注意

要访问此特定部分的源代码,请参阅 https://packt.live/3dTdlEb。

你也可以在 https://packt.live/2ZtkOEV 上在线运行此示例。

但这并不反映赌场的情况。没有赌场一天只开放 10 场轮盘赌游戏。那么,如果我们将游戏次数从 10 次改为 100,000 次,我们的图会发生什么变化?将样本大小变量设置为 100,000 并重新运行代码,得到的图看起来像这样:

图 9.5:进行了 100,000 场游戏的轮盘每场游戏的平均净收益

图 9.5:进行了 100,000 场游戏的轮盘每场游戏的平均净收益

请注意,蓝线迅速收敛到每场游戏的平均净收益-0.05 美元。具体来说,这次模拟产生了-0.054 美元的净收益,与我们的预期值相差不远。实际上,长期来看,赌场会赚钱,赌徒会输钱。现在,回到绿色空间的问题。如果我们从游戏中移除它们,将会有 18 个红色和 18 个黑色的空间。让我们在这些条件下重新计算我们的期望值:

(落在红色上的概率落在红色上时的赢得或输掉的钱)+*

(落在黑色上的概率落在黑色上时的赢得或输掉的钱)*

图 9.6:计算期望值的公式

图 9.6:计算期望值的公式

这意味着没有绿色空间,赌场和赌徒在长期内都不会赢钱或输钱;双方都会带着与他们开始时一样的金额离开。

中心极限定理

通过快速回顾前一节,大数定律告诉我们,随着样本的增大,样本均值越接近于总体均值。虽然这告诉我们样本均值的值应该是什么,但它并不告诉我们分布的任何信息。为此,我们需要中心极限定理。中心极限定理CLT)指出,如果样本量足够大,样本均值的分布近似正态分布,均值为总体均值,标准差为总体标准差除以n的平方根。这很重要,因为我们不仅知道我们的总体均值可以取得的典型值,而且我们也知道分布的形状和方差。

正态分布和中心极限定理

第八章基础概率概念及其应用中,我们看了一种连续分布,称为正态分布,也称为钟形曲线或高斯曲线(这三个名称的意思是一样的)。虽然有许多正态分布的实例,但这并不是它特殊的主要原因。正态分布之所以特殊是因为许多统计数据的分布都遵循正态分布,包括样本均值。

了解样本均值的分布在我们日常解决的许多典型统计问题中非常重要。我们获取均值和方差信息并将其放在一起,以了解我们的样本均值将如何从样本到样本变化。这告诉我们样本均值是否是我们期望出现的东西,还是我们不期望出现并且需要更仔细研究的东西。我们可以从据称相同的两个不同群体中取两个不同的样本,并证明它们实际上彼此显着不同。

从均匀分布中随机抽样

我们可以通过在 Python 中构建一些模拟来说明和验证中心极限定理,这正是我们将在接下来的练习中要做的。我们将要运行的第一个模拟是从均匀分布中随机抽取样本。均匀分布是每个结果被选中的可能性都是相等的分布。如果我们绘制均匀分布,它看起来像是一条横穿页面的直线。均匀分布的一些例子是掷骰子、抛硬币或典型的随机数生成器。

练习 9.04:显示均匀分布的样本均值

让我们从一个生成 0 到 100 之间的随机数的随机数生成器中抽取一个随机样本并计算样本平均值:

  1. 导入我们将使用的以下 Python 包并设置seed
# sample_from_uniform_dist.py
# import the module
import random
import matplotlib.pyplot as plt
import math
import numpy as np
import scipy.stats as stats
random.seed(54312)
  1. 创建每个样本的大小和要抽取的总样本数的变量。由于中心极限定理规定我们需要足够大的样本,我们选择了样本大小为 30。接下来,我们需要大量的样本均值来绘制图表,并将该值设置为 10,000:
# select the sample size you want to take
sample_size = 30
# select the number of sample mean you want to simulate
calc_means = 10000
  1. 初始化我们将用于收集样本均值的列表,并运行我们的模拟指定次数,收集每个样本的样本均值:
mean_list = []
# run our loop and collect a sample
for j in range(calc_means):
    # initialize the variables to track our results
    sample_list = []
    for i in range(sample_size):
        sample_list.append(random.randint(0, 100))
    sample_mean = sum(sample_list) / sample_size
    mean_list.append(sample_mean)
  1. 创建我们收集的样本均值的直方图。在直方图的顶部,我们将覆盖中心极限定理所说的样本均值分布应该是什么样子的:
"""
create a histogram of our sample and compare it 
to what the CLT says it should be 
"""
n, bins, patches = plt.hist(mean_list, \
                            math.floor(math.sqrt(calc_means)),\
                            density=True, facecolor='g', alpha=0.75)
plt.grid(True)
mu = 50
sigma = math.sqrt(((100 ** 2) / 12)) / (math.sqrt(sample_size))
x = np.linspace(mu - 3 * sigma, mu + 3 * sigma, 100)
plt.plot(x, stats.norm.pdf(x, mu, sigma))
plt.show()

在我们的笔记本中运行此代码将给我们以下结果:

图 9.7:从样本大小为 30 的均匀分布的 10,000 个样本中的样本平均值的分布

图 9.7:从样本大小为 30 的均匀分布的 10,000 个样本中的样本平均值的分布

中心极限定理给出的期望分布几乎完全覆盖了我们模拟结果的直方图。随意尝试并更改样本大小和用于生成图表的样本均值的数量。

注意

要访问此特定部分的源代码,请参阅 https://packt.live/31JG77I。

您还可以在 https://packt.live/3ggAq5m 上在线运行此示例。

从指数分布中随机抽样

我们知道中心极限定理适用于从均匀分布中取得的样本均值,但是对于看起来一点也不像均匀分布的东西呢?中心极限定理不限制我们抽取的样本的分布,那么对于看起来一点也不像正态分布的东西,它会起作用吗?让我们看看指数分布。指数分布是一种分布,它在从左到右迅速下降,然后趋于平稳,但并没有完全接触到零。以下图表是典型的指数分布:

图 9.8:指数分布示例

图 9.8:指数分布示例

现实世界中有很多指数分布的例子。例如,热液体冷却的速度,放射性衰变,以及机械零件的故障建模。

练习 9.05:从指数分布中抽取样本

在这个练习中,我们将随机抽样指数分布。以下是我们可以用来模拟从指数分布中抽样的代码:

  1. 导入我们需要的 Python 包。为了看到取较小样本的影响,我们将样本大小设置为5(参考以下代码),但保持样本数为10000
# sample_from_exp_dist.py
# import the module
import random
import matplotlib.pyplot as plt
import math
import numpy as np
import scipy.stats as stats
# select the sample size you want to take
sample_size = 5
# select the number of sample mean you want to simulate
calc_means = 10000
  1. 初始化我们将用来收集模拟结果的变量。运行模拟,但这次从指数分布中取样,而不是均匀分布:
mean_list = []
# run our loop and collect a sample
for j in range(calc_means):
    # initialize the variables to track our results
    sample_list = []
    for i in range(sample_size):
        draw = np.random.exponential(1)
        sample_list.append(draw)
    sample_mean = sum(sample_list) / sample_size
    mean_list.append(sample_mean)
  1. 创建我们收集的样本均值的直方图,并叠加中心极限定理对其的预期:
""" create a histogram of our sample and compare it to what the CLT says it should be """
n, bins, patches = plt.hist(mean_list, \
                   math.floor(math.sqrt(calc_means)), \
                   density=True, facecolor='g', \
                   alpha=0.75)
plt.grid(True)
mu = 1
sigma = 1 / (math.sqrt(sample_size))
x = np.linspace(mu - 3 * sigma, mu + 3 * sigma, 100)
plt.plot(x, stats.norm.pdf(x, mu, sigma))
plt.show()
  1. 在我们的 Jupyter 笔记本中输入的代码将给我们以下图形:图 9.9:来自指数分布的 5 个样本的 10,000 个样本平均值分布

图 9.9:来自指数分布的 5 个样本的 10,000 个样本平均值分布

与之前的练习Exercise 9.04中的显示均匀分布的样本均值一样,橙色线告诉我们中心极限定理对我们的预期。虽然我们的绿色直方图与我们的预期相似,但显然向右倾斜,根本不是钟形曲线。但请记住,中心极限定理要求我们取足够大的样本。显然,5 不够大,所以让我们将样本大小从 5 增加到 50 并重新运行代码。这样做应该会产生以下结果:

图 9.10:来自指数分布的 50 个样本的 10,000 个样本平均值分布

图 9.10:来自指数分布的 50 个样本的 10,000 个样本平均值分布

这看起来更接近我们的预期。显然,50 个样本的大小足够大,而 5 个样本的大小不够。但现在你可能会有一个问题:“什么样本大小足够大,我们如何知道?”。答案实际上取决于基础分布;基础分布偏斜越大,您必须取足够大的样本来确保中心极限定理的样本足够大。

注意

要访问此特定部分的源代码,请参阅 https://packt.live/2D2phXE。

您也可以在 https://packt.live/2NRcvNP 上在线运行此示例。

在本章后面,我们将介绍如何计算所需的样本大小,但我们只考虑中心极限定理适用于样本大小为 30 或更大的样本。

置信区间

正如我们在之前的模拟中看到的,我们的样本均值可能会因样本而异。在模拟中,我们有奢侈的取 10,000 个样本的条件,但在现实世界中我们做不到;这将是非常昂贵和耗时的。通常,我们只有足够的资源来收集一个样本。那么我们如何能对我们样本的结果有信心呢?在报告我们的样本均值时,有没有办法考虑到这种变异性?

好消息是中心极限定理给了我们关于样本均值的方差的一个概念。我们可以应用中心极限定理,并通过使用置信区间来考虑抽样变异性。更一般地,置信区间是一个统计量(样本均值的一个例子)的一系列值,基于一个具有一定置信度的分布,用于估计包含真实均值的值的可能性有多大。我们不总是只计算样本均值的置信区间;这个想法适用于从样本中计算出的任何统计量(唯一的区别是如何计算它)。置信区间可以用来计算我们需要抽取多大的样本以及误差范围是多少。

计算样本均值的置信区间

我们将计算的第一种置信区间是z 置信区间,它将根据标准正态模型(有时称为 z 分布)为我们的样本均值提供一个区间(或范围)的值。

为了计算样本均值的 z 置信区间,我们需要知道四件事:

  • 样本均值

  • 样本大小

  • 人口方差

  • 关键值或某个置信水平

我们计算样本均值和大小是从我们收集的样本中计算出来的。人口方差不是我们从样本中计算出来的;人口方差是一个给定给我们的值。通常,这是一些先前研究和研究中给出的方差的接受值。谜题的最后一块是关键值,或置信水平;这就是正态分布和中心极限定理发挥作用的地方。为了了解关键值是什么,让我们看一下标准正态分布(它是一个总是具有均值为 0 和方差为 1 的正态分布)及其曲线下的面积:

图 9.11:标准正态模型的示例

图 9.11:标准正态模型的示例

我们知道在我们的正态分布中,我们的均值在中心(在这种情况下是 0)。曲线下面积从-1 到 1 占总面积的 68%。另一种说法是,由这个分布描述的值中有 68%在-1 和 1 之间。大约 95%的值在-2 和 2 之间。将这应用于样本均值的分布,我们可以找到 95%的样本均值将取的范围。参考图 9.7

图 9.12:来自 30 个样本的均匀分布的 10,000 个样本的样本平均值分布

图 9.12:来自 30 个样本的均匀分布的 10,000 个样本的样本平均值分布

如果我们看一下,我们的钟形曲线的中心是 50,这是从 0 到 100 的均匀分布的预期值。从 0 到 100 的均匀分布的预期标准差约为 5.27 (4)。因此,应用与之前相同的逻辑,大约 68%的值在 45 和 55 之间,大约 95%的值在 40 和 60 之间。这些范围就是我们的置信区间。

计算 z 置信区间的更正式方程如下:

图 9.13:计算 z 置信区间的公式

图 9.13:计算 z 置信区间的公式

在这个方程中:

  • 是样本均值。

  • n是样本大小。

  • σ是人口标准差。

  • Z是我们置信水平的关键值。

我们最终的置信区间将是两个数字:一个上限,我们将两个项相加,一个下限,我们将两个项相减。幸运的是,这是我们可以在 Python 中编写一个函数的事情,如下所示:

def z_confidence_interval(data, st_dev, con_lvl):
    import statistics as st
    import scipy.stats as sp
    import math
    sample_mean = st.mean(data)
    n = len(data)
    crit_value = sp.norm.ppf(((1 - con_lvl) / 2) + \
                             con_lvl)
    lower_limit = sample_mean - (crit_value * \
                                 (st_dev/math.sqrt(n)))
    higher_limit = sample_mean + (crit_value * \
                                  (st_dev / math.sqrt(n)))
    print (f'Your {con_lvl} z confidence interval         is ({lower_limit}, {higher_limit})')
    return (lower_limit,higher_limit)

此函数将以下内容作为输入:我们收集的数据,以及总体标准偏差(由我们给出),以及置信水平。它将在控制台上打印置信水平并将其作为元组返回。

练习 9.06:找到民意调查数据的置信区间

您正在进行一场政治竞选,并决定进行 30 个焦点群体,每个群体约有 10 人。您获得了结果,并希望向您的候选人报告典型 10 人群体中会投票给他们的人数。由于每个焦点群体都有一些变化,您决定最准确的方法是给出 95%的 z-置信区间。您假设根据过去的经验,标准偏差为 2.89。让我们使用 Python 对此进行建模:

  1. 导入random Python 包并将种子设置为39809。这将确保我们每次运行程序时都获得相同的结果:
import random
random.seed(39809)
  1. 初始化我们的样本列表并从焦点群体中收集我们的样本。然后,我们只需将信息输入到我们的函数中:
sample_list = []
for i in range(30):
    sample_list.append(random.randint(0, 10))
z_confidence_interval(sample_list,2.89,0.95)
  1. 如果您做得正确,那么在运行笔记本时应打印出以下内容:
Your 0.95 z confidence interval is (3.965845784931483, 6.034154215068517)

这告诉我们,在典型的焦点群体中,每个群体中有 4 到 6 人会投票给我们的候选人。这向您表明,竞选活动应继续努力说服更多人投票给您的候选人。

注意

要访问此特定部分的源代码,请参阅 https://packt.live/2Zp6XiU。

您还可以在 https://packt.live/3eUBL1B 上在线运行此示例。

小样本置信区间

z-置信区间适用于样本足够大的情况(记住我们的经验法则是样本大小为 30 或更大);但是如果您的样本不够大怎么办?那么您可以使用t-置信区间,它基本上与 z-置信区间相同,但有两个例外:

  • t-置信区间不假设您知道总体标准偏差,因此我们使用样本标准偏差。

  • 它使用 t-分布来计算临界值,而不是 z(标准正态)分布。两者之间的区别在于 t-分布在平均值周围的集中程度较低,以解释不知道总体标准偏差的情况。

为了计算 t-置信区间,我们需要知道两件事;第一是自由度,它是样本大小减 1(n-1)计算得出。第二是置信水平。t-置信区间的公式如下:

图 9.14:计算 t-置信区间的公式

图 9.14:计算 t-置信区间的公式

在这个方程中:

  • 是样本均值。

  • tn-1 是具有n-1自由度的临界值。

  • s是样本标准偏差。

  • n是样本大小。

就像 z-区间一样,我们的最终答案将是下限和上限。我们将编写一个 Python 函数来为我们完成所有计算工作:

def t_confidence_interval(data, con_lvl):
    import statistics as st
    import scipy.stats as sp
    import math
    sample_mean = st.mean(data)
    sample_st_dev = st.stdev(data)
    n = len(data)
    crit_value = sp.t.ppf(((1 - con_lvl) / 2) + \
                          con_lvl, n-1)
    lower_limit = sample_mean - (crit_value * \
                  (sample_st_dev/math.sqrt(n)))
    higher_limit = sample_mean + (crit_value * \
                   (sample_st_dev/math.sqrt(n)))
    print(f'Your {con_lvl} t confidence interval is \
({lower_limit},{higher_limit})')
    return (lower_limit,higher_limit)

让我们使用与 z-置信区间相同的样本列表。t_confidence_interval函数的使用方式与我们的 z-置信区间函数相同;我们将输入要计算 t-置信区间的数据列表,并指定我们的置信水平。无需包括总体标准偏差;t-置信区间使用样本标准偏差,并将自动为我们计算。t_confidence_interval函数的正确使用方式如下:

t_confidence_interval(sample_list,0.95)

如果您做得正确,当您运行前面的代码时,笔记本中应输出以下内容:

Your 0.95 t confidence interval is (3.827357936126168,6.172642063873832)

注意,t-置信区间比我们的 z-置信区间更宽。这是因为我们在使用样本标准偏差估计总体标准偏差时存在更多的不确定性,而不是使用已知值。

t-置信区间的好处在于它不仅可以用于小样本或者你不知道总体标准差的情况;它可以在任何需要使用 z-置信区间的情况下使用。事实上,随着样本量的增大,t-分布越接近于 z(标准正态)分布。所以,如果你对所给的总体标准差的值不确定,或者在查看以前的研究时发现,你总是可以保险起见使用 t-置信区间。

样本比例的置信区间

让我们回到政治竞选的例子。在不同的焦点小组给出了不明确的结果之后,一项新的民意调查显示你的候选人正在赢得竞选,其中 350 人的样本中有 54%表示他们将投票给你的候选人,而你的对手得到了另外 46%。你想计算这个比例的置信区间,以便考虑抽样变异性。

我们知道如何计算样本均值的置信区间,但是如何计算样本比例的置信区间呢?样本的百分比与样本的均值不同。幸运的是,我们有一个计算样本比例置信区间的公式:

图 9.15:计算置信区间的公式

](image/B15968_09_15.jpg)

图 9.15:计算置信区间的公式

在这个方程中:

  • 是样本比例。在这个例子中,是投票给你的人的 54%。

  • n是样本量。在这个例子中,是 350 人。

  • Z是我们从标准正态分布中得到的临界值。我们计算方法与 z-置信区间相同。

在应用之前,有一些条件需要满足:

  • 我们样本中的观察结果是独立的-所以在我们的例子中,一个人的答案不会影响另一个人的答案。

  • 我们需要至少有 10 个成功和 10 个失败-所以我们需要至少有 10 个人投票给我们,还有 10 个人会投票给你的对手。

同样,我们可以在 Python 中创建一个函数来进行计算:

def prop_confidenct_interval(p_hat, n, con_lvl):
    import math
    import scipy.stats as sp
    crit_value = sp.norm.ppf(((1 - con_lvl) / 2) + \
                             con_lvl)
    lower_limit = p_hat - (crit_value * (math.sqrt(\
                 (p_hat * (1-p_hat)) / n)))
    higher_limit = p_hat + (crit_value * (math.sqrt(\
                  (p_hat * (1 - p_hat)) / n)))
    print(f'Your {con_lvl} proportional confidence \
interval is ({lower_limit},{higher_limit})')
    return (lower_limit,higher_limit)

与我们创建的其他函数不同,我们不需要输入我们数据值的列表。相反,我们可以直接输入我们的统计数据并设置置信水平。为了创建我们的民意调查的置信区间,我们输入信息如下:

prop_confidenct_interval(0.54,350, 0.95)

并且以下结果将被打印在控制台中:

Your 0.95 proportional confidence interval is (0.4877856513683282,0.5922143486316719)

这告诉我们,我们可以有 95%的把握,我们的候选人得到的选票比例的真实值在 48.8%和 59.2%之间。因此,民意调查的结果是不确定的,这表明我们仍然需要更多的工作来说服人们投票给我们的候选人。请注意,这通常是民意调查得到的误差范围。误差范围是我们的点估计器(在这个例子中是)与任一边界之间的距离(因为置信区间是对称的;无论我们使用上限还是下限都没有关系)。对于这次民意调查,我们的误差范围将是0.592 - 0.54 = 0.052

因此,前面民意调查的误差范围约为 5.2%。这是在你接受任何民意调查结果时需要记住的事情,无论是政治还是其他方面。

假设检验

在前一节中,我们进行了模拟,样本均值在不同样本中发生了变化,尽管是从同一总体中抽样的。但是我们如何知道我们计算的样本均值是否与预设值或者不同样本显著不同?我们如何知道差异是变异性的作用,还是测量值不同?答案在于进行假设检验。

假设检验是一种旨在确定统计量是否与我们的预期显著不同的统计检验。假设检验的例子包括检查样本均值是否与预先建立的标准显著不同,或者比较两个不同样本,看它们是否在统计上不同或相同。

假设检验的部分

任何假设检验都有三个主要部分:假设、检验统计量和 p 值。假设是你进行检验的对象,以确定它们是否应该被拒绝或接受。任何测试都有两个假设:一个零假设(通常用符号H0 表示)和一个备择假设(通常用符号HA 表示)。零假设是我们一直假定或已知为真的东西;换句话说,它是我们预先建立的标准。备择假设是我们将要与零假设进行比较的备选项;在实际情况下,它是我们想要证明为真的东西。

以下是一些假设的例子:

  • 你是一家制造公司的领导,你有一个通常每小时使用 15 升燃料的流程。你的公司正在测试对这个流程的改变,以尝试使用更少的燃料。他们对 24 小时进行了抽样,发现新流程每小时使用 13.7 升燃料。公司需要知道这种减少是否显著,或者是否可以归因于流程中的差异。你的零假设将是流程通常使用的:HO: μ = 15。我们希望证明新流程使用更少的燃料,所以我们的备择假设是:HA: μ < 15

  • Richard 是你们城市的一名商业面包师。他在考虑是否要投资于他工厂的制面包设备。通常情况下,他的工厂在一个班次可以制作大约 15,000 个面包。Richard 派了一个班次去尝试新设备,连续 5 个班次平均每班次可以制作 17,500 个面包。你告诉 Richard 要测试一下这是否显著不同;零假设将基于他通常的产量(HO: μ=15000),备择假设将是他想要尝试证明的(HA: μ = 15000)。

  • Linda 是她公司质量控制部门的分析师。公司生产的一个零件需要 15 英寸长。由于公司无法测量每个零件,Linda 抽样了 100 个零件,发现这个样本的平均长度是 14.89 英寸。她告诉你,他们期望每个零件都是 15 英寸(HO: μ = 15),他们想要尝试弄清楚样本是否证明平均零件通常不是 15 英寸(HA:μ ≠ 15)。

前述情况中的每一种都描述了你将遇到的三种典型假设检验之一:上尾检验、下尾检验和双尾检验。了解你正在进行的测试类型是必要的,这样你才能正确地写出你的假设并计算你的 p 值。

检验统计量是一个描述我们观察到的样本与我们假设或已知的平均值相比的数字。这是我们进行不同测试时最容易变化的部分;它基于我们正在测试的特定统计量和所使用的检验。这是统计检验中最数学化的部分,通常用公式表示。p 值是我们假设检验的最后部分;它通常被定义为假设零假设为真时观察到类似我们收集的样本的概率。我们将这个值与某个显著性水平进行比较(0.05 是最常用的显著性水平);如果我们的 p 值小于显著性水平,那么我们拒绝零假设,并且有证据表明备择假设为真。同样,如果我们的 p 值大于显著性水平,我们未能拒绝零假设,并且没有证据表明备择假设为真。

Z-检验

就像我们的 z-置信区间一样,基于标准正态模型的假设检验称为z-检验。就像 z-置信区间一样,z-检验假设我们知道总体标准偏差,并且我们有足够大的样本(同样,经验法则是样本大小至少为 30)。z-检验的基本设置如下:

  • HO:μ = μO(不用担心;μO 通常是我们认为的平均值,只是一个数字)

  • HA: μ < μO 或 HA: μ > μO 或 HA: μ ≠ μO(μO 将始终与我们的零假设相匹配)

  • 检验统计量:5

其中:

是样本平均值。

σ是已知的总体标准偏差。

n是样本大小。

  • P 值:6

一旦你掌握了这些数学,这些计算就不难,我们可以使用 Python 使计算变得非常简单。

练习 9.07:Z-检验实例

让我们从具有已知总体均值的分布中随机抽取一个样本,并看看我们的 z-检验是否能选择正确的假设:

  1. 让我们从导入我们将需要的所有库开始这个练习,以便能够运行我们的代码并设置seed值:
import scipy.stats as st
import numpy as np
import pandas as pd
import math as mt
import statistics as stat
import statsmodels.stats.weightstats as mod
import statsmodels.stats.proportion as prop
np.random.seed(12345)
  1. 我们将编写一个函数来进行 z-检验。输入将是一个样本(以列表的形式),总体标准偏差(记住,指定这一点是 z-检验的要求之一),我们假设的值,测试的显著性水平,以及测试类型(上尾、下尾或双尾检验)。我们将从给定的列表中计算样本均值和样本大小。然后,我们将输入计算我们的检验统计量。然后,根据我们决定要进行的假设检验,我们相应地计算 p 值。最后,我们将我们的 p 值与显著性水平进行比较,如果它小于我们的显著性水平,我们拒绝零假设。否则,我们未能拒绝零假设:
def z_test(sample, pop_st_dev, hypoth_value, \
           sig_level, test_type):
    sample_mean = stat.mean(sample)
    sample_size = len(sample)
    test_statistic = (sample_mean - hypoth_value) / \
                     (pop_st_dev / (mt.sqrt(sample_size)))
    if test_type == 'lower':
        p_value = st.norm.cdf(test_statistic)
    if test_type == 'upper':
        p_value = 1 - st.norm.cdf(test_statistic)
    if test_type == 'two':
        p_value = 2 * (1 - st.norm.cdf(abs(            test_statistic)))
    print(f'P Value = {p_value}')
    if p_value < sig_level:
        print(f'Results are significant. Reject the Null')
    else:
        print(f'Results are insignificant. '\
               'Do Not Reject the Null')
  1. 我们从均值为15,标准偏差为1的正态分布中抽取一个随机样本大小为50。我们将把样本均值打印到控制台,以便我们知道它是多少(每次运行此代码时它都会有所不同,因为我们每次都会抽取一个随机样本)。我们使用我们的 z-检验函数进行一个下尾检验,因为我们想要看到我们的均值是否显著小于16。我们指定包含我们数据的列表(data1),总体标准偏差(我们知道这是1),假设的值(我们想要看到它是否显著小于16),显著性水平(大多数情况下这将是0.05),最后是测试类型(因为我们想要看到均值是否小于16,这是一个下尾检验):
# 1 - Lower Tailed Test
# Randomly Sample from Normal Distribution mu=     and st_dev = 3
data1 = np.random.normal(15, 1, 50)
# Test to see if Mean is significantly less then 16
print(f'Sample mean: {stat.mean(data1)}')
z_test(data1,1,16,0.05,'lower')
# most of the time, the null should be rejected

当我们运行此代码时,我们应该得到类似以下的结果:

Sample mean: 14.94804802516884
P Value = 5.094688086201483e-14
Results are significant.  Reject the Null
(-7.43842374885694, 5.094688086201483e-14)

由于我们的检验统计量的 p 值小于 0.05(从科学计数法写出来,是 0.0000000000000509),我们知道 15.06 的样本均值显著小于 16,基于我们的样本量为 50。由于我们从平均值为 15 的总体中抽取了样本,测试结果符合我们的预期。同样,由于我们一开始是随机抽样的,您的结果可能会有所不同,但是对于大多数样本来说,这个测试应该会拒绝零假设。在返回的元组中,第一个值是检验统计量,第二个是我们的 p 值。

  1. 接下来,让我们测试一下我们的均值是否显著大于14。按照单侧检验的相同模式,我们的代码将如下所示:
#test to see if the mean is significantly more than 14
print(f'Sample mean: {stat.mean(data1)}')
z_test(data1,1,14,0.05,'upper')
#most of the time the null should reject

当我们运行代码时,以下输出将显示在控制台中:

Sample mean: 14.94804802516884
P Value = 1.0159539876042345e-11
Results are significant.  Reject the Null
(6.703711874874011, 1.0159539876042345e-11)
  1. 对于我们最后的 z 检验,我们将执行一个双侧检验,并查看我们的样本均值是否与15显著不同。在这个测试中,我们实际上并不关心它是高于还是低于15;我们只是想看看它是否不同:
#test to see if the mean is significantly different than 15
print(f'Sample mean: {stat.mean(data1)}')
z_test(data1,1,15,0.05,'two')
#most of the type we should not reject the null

当我们运行此代码时,结果如下:

Sample mean: 14.94804802516884
P Value = 0.7133535345453159
Results are insignificant.  Do Not Reject the Null
(-0.3673559369914646, 0.7133535345453159)

这个结果是有道理的,因为我们对平均值为 15 的总体进行了抽样。

注意

要访问此特定部分的源代码,请参阅 https://packt.live/2C24ItD。

您还可以在 https://packt.live/2NNyntn 上在线运行此示例。

比例 Z-检验

z 检验最常见的用途不是测试样本均值的显著性,而是测试百分比的显著性。这需要的假设与比例 z 置信区间的要求相同:随机样本、独立性,以及至少 10 次成功和 10 次失败。我们将按照以下方式计算此测试的检验统计量:

图 9.16:计算检验统计量的公式

图 9.16:计算检验统计量的公式

我们将以与样本均值的 z 检验相同的方式计算 p 值。我们不需要为此测试创建一个函数;在statsmodels.stats.proportion Python 包中已经存在一个名为proportions_ztest的函数。此函数的语法如下:

proportions_ztest(x,n,Po, alternative=['smaller',\
                                       'larger','two-sided'])

这里:

x是我们样本中的成功次数。

n是我们样本的大小。

Po是我们想要进行检验的假设值。

备用项指定了一个单侧、双侧或双侧检验。

此函数的输出是一个元组;第一个元素是检验统计量,第二个元素是 p 值。让我们回到我们的民意调查例子:你的竞选活动进行了一项民意调查,并对 350 人进行了抽样。在 350 人中,有 193 人表示他们会投票给你。我们想要看看我们收集的这个样本是否证明了大多数人会投票给你。

我们将把我们的 z 检验结果分配给一个名为results的变量。我们调用函数,其中193是成功/将为我们投票的人数,样本大小为350。由于我们想要测试我们的样本是否证明我们获得了大多数选票,我们想要执行一个单侧检验,其中假设值为0.50

#z-test for proportion
results = prop.proportions_ztest(193,350,.50, \
                                 alternative='larger')
print(results)

当代码运行时,以下内容将打印到控制台:

(1.93454148164361, 0.026523293494118718)

我们的 p 值约为 0.027,这是一个显著的结果,显著水平为 0.05。这告诉我们,我们的样本证明了我们获得了大多数选票。

T-检验

虽然 z 检验对比例进行假设检验很有用,但在测试样本均值时并不是很实用,因为我们通常不知道总体的标准差。还有其他情况下,我们的样本量非常小。对于这种情况,我们可以使用 t 检验,它类似于我们的 t 置信区间。就像 t 置信区间一样,您不需要知道总体标准差;您可以使用样本来估计它。

t 检验的公式如下:

图 9.17:计算 t 检验的公式

图 9.17:计算 t-检验的公式

在这个方程中:

  • 是样本均值。

  • μO 是我们正在测试的假设值。

  • s是样本标准差。

  • n是样本大小。

我们将使用 t-分布而不是标准正态分布来计算 p 值。但是,我们不会过多关注这个特定测试的机制,因为它与我们已经涵盖的其他假设检验类似。我们将创建一个函数来进行我们的 t-检验,类似于我们的 z-检验:

def t_test(sample, hypoth_value, sig_level, test_type):
    sample_mean = stat.mean(sample)
    sample_st_dev = stat.stdev(sample)
    sample_size = len(sample)
    test_statistic = (sample_mean - hypoth_value) / \
                     (sample_st_dev/(mt.sqrt(sample_size)))
    if test_type == 'lower':
        p_value = st.t.cdf(test_statistic,df=sample_size-1)
    if test_type == 'upper':
        p_value = 1 - st.t.cdf(test_statistic,df=sample_size-1)
    if test_type == 'two':
        p_value = 2 * (1 - st.t.cdf(abs(test_statistic), \
                                    df=sample_size-1))
    print(f'P Value = {p_value}')
    if p_value < sig_level:
        print(f'Results are significant.  Reject the Null')
    else:
        print(f'Results are insignificant. '\
               'Do Not Reject the Null')

在上述代码中:

  • sample是样本测量的列表。

  • hypoth_value是您正在测试的值。

  • sig_level是显著性水平。

  • test_type是测试类型——较低、较高或两者。

练习 9.08:T-检验

我们将检查两个不同的样本:一个大样本和一个小样本。这两个样本都将从均值为 50、标准差为 10 的正态分布中随机选择。两个样本之间唯一的区别是大样本的大小为 100,而较小的样本的大小为 10:

  1. 首先,让我们导入我们将使用的库,设置种子,然后随机生成我们的大样本:
import scipy.stats as st
import numpy as np
import pandas as pd
import math as mt
import statistics as stat
import statsmodels.stats.weightstats as mod
import statsmodels.stats.proportion as prop
np.random.seed(1)
data1 = np.random.normal(50, 10, 100)
  1. 为我们的 t-检验创建函数:
def t_test(sample, hypoth_value, sig_level, test_type):
    sample_mean = stat.mean(sample)
    sample_st_dev = stat.stdev(sample)
    sample_size = len(sample)
    test_statistic = (sample_mean - hypoth_value) / \
                     (sample_st_dev/(mt.sqrt(sample_size)))
    if test_type == 'lower':
        p_value = st.t.cdf(test_statistic,df=sample_size-1)
    if test_type == 'upper':
        p_value = 1 - st.t.cdf(test_statistic,df=sample_size-1)
    if test_type == 'two':
        p_value = 2 * (1 - st.t.cdf(abs(test_statistic), \
                                    df=sample_size-1))
    print(f'P Value = {p_value}')
    if p_value < sig_level:
        print(f'Results are significant.  Reject the Null')
    else:
        print(f'Results are insignificant. '\
               'Do Not Reject the Null')
  1. 我们将运行三个不同的测试:一个是看样本均值是否与50显著不同,一个是看样本均值是否显著低于51,还有一个是看样本均值是否显著高于48
print('large sample')
print(f'Sample mean: {stat.mean(data1)}')
t_test(data1,50,0.05,'two')
t_test(data1,51,0.05,'lower')
t_test(data1,48,0.05,'upper')

运行此代码将产生以下结果:

large sample
Sample mean: 50.60582852075699
P Value = 0.4974609984410545
Results are insignificant.  Do Not Reject the Null
P Value = 0.32933701868279674
Results are insignificant.  Do Not Reject the Null
P Value = 0.002109341573010237
Results are significant.  Reject the Null

第一个测试是不显著的,我们没有证据表明均值与50显著不同。第二个测试也是不显著的;样本也不能证明均值显著大于51。最后一个测试是显著的;样本证明均值显著高于48

  1. 现在,我们将运行相同的三个测试,只是这次我们将使用大小为5的样本(我们将使用大样本的前5个元素):
# select the first 5 elements of the data set
data2 = data1[:5]
print(data2)
#two-tailed test = Is the sample mean significantly 
#different from 50?
print('small sample')
print(f'Sample mean: {stat.mean(data2)}')
t_test(data2,50,0.05,'two')
#lower tailed = Is the sample mean significantly 
#lower than 51?
t_test(data2,51,0.05,'lower')
#upper tailed = is the sample mean significantly 
#more than 48?
t_test(data2,48,0.05,'upper')

运行上述代码会产生以下结果:

[66.24345364 43.88243586 44.71828248 39.27031378 58.65407629]
small sample
Sample mean: 50.553712409836436
P Value = 0.918572770568147
Results are insignificant.  Do Not Reject the Null
P Value = 0.4671568669546634
Results are insignificant.  Do Not Reject the Null
P Value = 0.32103491333328793
Results are insignificant.  Do Not Reject the Null

前两个测试的结果没有改变,而第三个测试尽管样本均值几乎相同,但结果确实改变了。差异的原因是由于样本量较小;由于样本量较小,不确定性较小,测试更保守,不太可能拒绝零假设。这可以在我们的检验统计量方程中显示:

图 9.18:计算 t-检验的检验统计量的公式

图 9.18:计算 t-检验的检验统计量的公式

注意分母7;如果n较小,则8的值将较大(对于一个恒定的s)。这会导致测试统计量的分母值较大,从而导致整体测试统计量较小。

注意

要访问此特定部分的源代码,请参阅 https://packt.live/38mMShg。

您还可以在 https://packt.live/3gkBdlK 上在线运行此示例。

2-样本 t-检验或 A/B 测试

我们将要看的最后一个测试是 2-样本 t-检验。这是一个假设检验,比较两个不同样本的均值,并可以告诉您一个均值是否显著更高、显著更低或与另一个均值显著不同。其中之一的应用是一种称为 A/B 测试的东西。A/B 测试是您向两个不同的群体展示网站或应用的两个不同版本,并收集某种性能度量。性能度量的例子可能是花费的金额、点击广告的人数,或者人们在您的手机游戏内进行微交易的金额。收集数据后,您测试两个样本均值,并查看两个不同版本之间的差异是否显著。

对于双样本检验,零假设和备择假设的工作方式与单样本检验有些不同。你不是将样本均值与一个值进行比较,而是将其与另一个均值进行比较。我们通常通过将差异与零进行比较来展示这一点。使用一些代数,你可以弄清备择假设应该如何设置:

  • 上侧(均值 1 大于均值 2):13

  • 下侧(均值 1 小于均值 2):14

  • 双尾(均值 1 与均值 2 不同):15

对于双样本 t 检验,零假设将始终设置为 0 (9)。换句话说,零假设是说两个均值之间没有差异,而另一个是说有差异。双样本 t 检验的检验统计量如下:

10,自由度为 16

对于这个好消息是,我们不必手工计算,也不必费力创建自己的函数来执行这个操作。scipy.stats 包中有一个专门用于这个检验的函数。该函数如下:

scipy.stats.ttest_ind(x1,x2,equal_var=False)

在这里:

  • x1是第一个样本中的数据列表。

  • x2是第二个样本中的数据列表。

  • 我们将 equal_var 设置为 False,因为我们不知道两个样本的方差是否相同。

该函数返回两个值:有符号的检验统计量和 p 值。一些人可能已经注意到,没有选项来指定你正在执行哪种检验。这是因为该函数始终假定你正在进行双尾检验。那么你如何使用它来获得你的单尾检验的结果呢?由于 t 分布是对称的,单尾检验的 p 值将是双尾检验的 p 值的一半。第二件要看的事情是检验统计量的符号。对于下侧检验,只有在检验统计量为负数时才能拒绝零假设。同样,对于上侧检验,只有在检验统计量为正数时才能拒绝零假设。因此,对于单尾检验:

  • 下侧:如果 a 小于你的显著性水平并且你的检验统计量为负数,则拒绝零假设。

  • 上侧:如果 b 小于你的显著性水平并且你的检验统计量为正数,则拒绝零假设。

练习 9.09:A/B 测试示例

我们有两个样本,一个来自均值为 50 的正态分布,另一个来自均值为 100 的分布。两个样本的大小都为 100。在这个练习中,我们将确定一个样本的样本均值是否显著不同、较低或较高于另一个样本:

  1. 首先,让我们导入我们将使用的库:
import scipy.stats as st
import numpy as np
  1. 让我们绘制我们的随机样本并打印样本均值,这样我们就知道它们是什么。记得设置种子:
# Randomly Sample from Normal Distributions 
np.random.seed(16172)
sample1 = np.random.normal(50, 10, 100)
sample2 = np.random.normal(100,10,100)
print(f'Sample mean 1: {stat.mean(sample1)}')
print(f'Sample mean 2: {stat.mean(sample2)}')

结果如下:

Sample mean 1: 50.54824784997514
Sample mean 2: 97.95949096047315
  1. 我们将使用 scipy 包中的函数执行双样本 t 检验并打印结果:
two_tail_results = st.ttest_ind(sample1, sample2, \
                                equal_var=False)
print(two_tail_results)

结果如下:

Ttest_indResult(statistic=-33.72952277672986,     pvalue=6.3445365508664585e-84)

由于默认情况下,该函数进行双尾检验,我们知道样本 1 的均值与样本 2 的均值显著不同。如果我们想进行下侧检验(其中样本 1 的均值显著低于样本 2),我们将使用相同的代码。唯一的区别是我们会将 p 值除以 2,并检查我们的检验统计量是否为负数。由于我们的 p 值除以 2 小于 0.05,并且我们的检验统计量为负数,我们知道样本 1 的均值显著低于样本 2 的均值。

  1. 如果我们想测试样本 2 的均值是否显著高于样本 1 的均值,我们只需在函数中交换样本 1 和样本 2 的位置:
upper_tail = st.ttest_ind(sample2, sample1, equal_var=False)
print(upper_tail)

结果如下:

Ttest_indResult(statistic=33.72952277672986, 
pvalue=6.3445365508664585e-84)

就像下尾检验一样,我们会将 p 值除以 2。但是,我们会检查检验统计量是否为正。由于 p 值除以 2 小于 0.05 且检验统计量为正,我们知道样本 2 的均值明显大于样本 1 的均值。

注意

要访问此特定部分的源代码,请参阅 https://packt.live/3iuHmOr。

您还可以在 https://packt.live/3ghpdl4 上在线运行此示例。

线性回归简介

我们已经描述并测试了样本统计数据,但是如果我们想要使用数据的特征来描述另一个特征怎么办?例如,移动应用的价格如何影响下载量?为了做到这一点,我们将使用线性回归对数据进行建模。线性回归是指我们使用一个或多个自变量的线性方程来描述一个因变量。通常,我们的回归方程是斜率-截距形式,如下所示:

图 9.19:线性回归公式

图 9.19:线性回归公式

在这里:

  • β1 是我们方程的斜率,通常称为系数。

  • βO 是方程的截距。

我们如何得出系数和截距的值?它始于残差——即预测 y 值与实际 y 值之间的差异。查看残差的另一种方式是,这是我们方程预测偏离的量。虽然我们在这里不会详细介绍,但我们使用微积分来找出最小化所有残差总和的β1、βO 的值。我们不一定受限于一个系数;我们可以有多个(两个或更多)系数,如下所示:

图 9.20:具有多个系数的线性回归公式

图 9.20:具有多个系数的线性回归公式

幸运的是,我们可以使用 Python 来为我们做所有的计算,特别是sklearn包中的线性模型函数。

练习 9.10:线性回归

我们的任务是尝试使用葡萄酒的其他特征来预测红葡萄酒的 pH 水平。可以从 GitHub 存储库 https://packt.live/3imVXv5 下载数据集。

注意

这是 UCI 机器学习库(http://archive.ics.uci.edu/ml)提供的葡萄酒质量数据集。尔湾,加利福尼亚:加利福尼亚大学,信息与计算机科学学院。P. Cortez, A. Cerdeira, F. Almeida, T. Matos, and J. Reiss。通过从理化性质中进行数据挖掘来建模葡萄酒偏好。在决策支持系统中,Elsevier,47(4):547-553,2009。

  1. 导入我们需要的包并读取数据:
# import packages and read in data
import pandas as pd
import statistics as st
import scipy.stats as sp
import math
import sklearn.linear_model as lm
import matplotlib.pyplot as plt
from mpl_toolkits import mplot3d
import numpy as np
data = pd.read_csv("winequality-red.csv")
  1. 将数据子集化为我们需要的两列(我们将尝试使用柠檬酸的量来预测pH水平)。将pH水平设置为我们的因变量,柠檬酸作为自变量:
data1 = data[['pH','citric acid']]
plt.scatter(x=data1['citric acid'], y=data1['pH'])
y = data1['pH']
x = data1[['citric acid']]
  1. 拟合线性模型并将数据作为散点图和我们的线性回归模型进行绘制:
model = lm.LinearRegression()
model.fit(x,y)
plt.scatter(x, y,color='g')
plt.plot(x, model.predict(x),color='k')
plt.show()

输出如下:

图 9.21:线性方程似乎很好地适配了我们的数据

图 9.21:线性方程似乎很好地适配了我们的数据

如果你看图片,你会注意到这条线很好地适配了数据。让我们添加另一个自变量;在这种情况下,残留糖的数量,并看看它是否改善了预测。

  1. 这一次,我们将柠檬酸和残留糖设置为自变量并拟合模型:
#can we predict the pH of the wine using 
#citric acid and residual sugar?
data2 = data[['pH','citric acid','residual sugar']]
y = data2['pH']
x = data2[['citric acid', 'residual sugar']]
model = lm.LinearRegression()
model.fit(x,y)
y_pred = model.predict(x)
  1. 创建一个三维散点图并在3d空间中绘制线以检查它是否很好地适配我们的数据:
threedee = plt.figure().gca(projection='3d')
threedee.scatter(data2['citric acid'],     data2['residual sugar'],data2['pH'])
threedee.set_xlabel('citric acid')
threedee.set_ylabel('residual sugar')
threedee.set_zlabel('pH')
xline = np.linspace(0, 1, 100)
yline = np.linspace(0, 16, 100)
zline = xline*(-0.429) + yline*(-0.000877)+3.430
threedee.plot3D(xline, yline, zline, 'red')
plt.show()

输出如下:

图 9.22:线性方程似乎不太适配我们的数据

图 9.22:线性方程似乎不太适配我们的数据

如果您看图片,我们的线性模型似乎不如我们拟合的第一个模型那样适合数据。 基于此,残留糖可能不会出现在我们的最终模型中。

注意

要访问此特定部分的源代码,请参阅 https://packt.live/2Anl3ZA。

您也可以在 https://packt.live/3eOmPlv 上在线运行此示例。

活动 9.01:标准化测试表现

您被要求描述 2015 年 PISA 测试的结果,并调查互联网基础设施的普及对测试成绩可能产生的影响。

要下载数据集,请转到 GitHub 存储库 https://packt.live/3gi2hCg,下载pisa_test_scores.csv文件,并将该文件保存到您的工作目录中。

注意

这个 PISA 测试成绩数据集是基于世界银行提供的数据(https://datacatalog.worldbank.org/dataset/education-statistics)。 世界银行 Edstats。

保存文件后,执行以下操作:

  1. 使用置信区间描述学生在阅读、科学和数学方面的典型得分。

  2. 使用假设检验,评估互联网基础设施的普及是否会导致更高的测试成绩。

  3. 构建一个线性模型,使用阅读和写作成绩来预测数学成绩。

注意

此活动的解决方案可在第 688 页找到。

摘要

在本章中,我们研究了大数定律,以及样本均值统计的稳定性如何受样本大小的影响。 通过中心极限定理,我们研究了置信区间和假设检验的理论基础。 置信区间用于描述样本统计数据,如样本均值、样本比例和误差限。 假设检验是通过收集样本的证据来评估两个相反的假设。

下一章将开始您的微积分学习,您将研究瞬时变化率和找到曲线斜率等主题。 在研究完这些内容后,我们将研究积分,即找到曲线下的面积。 最后,我们将使用导数来找到复杂方程和图形的最优值。

NKJ24

VBM37

第十章:10. 使用 Python 进行基础微积分

概述

在本章中,您将学习如何在给定的x值处计算函数的导数。您还将学习如何计算给定值之间函数的积分,并使用导数来解决优化问题,例如最大化利润或最小化成本。在本章结束时,您将能够使用微积分来解决各种数学问题。

介绍

微积分被称为变化的科学,因为它的工具是为了处理不断变化的值,如行星和抛射物的位置和速度而开发的。以前,没有办法表达这种变量的变化。

微积分中的第一个重要主题是导数。这是给定点的函数变化率。直线遵循一个简单的模式,称为斜率。这是y值(上升)在给定的x值范围内(水平移动)的变化:

图 10.1:直线的斜率

图 10.1:直线的斜率

图 10.1中,线条中的y值每增加 1 个单位,x值就增加 2 个单位,因此我们将 2 除以 1 得到斜率为 2。

然而,曲线的斜率并不像直线那样在整条曲线上是恒定的。因此,正如您在图 10.2中所看到的,该函数在点A的变化率与点B的变化率是不同的:

图 10.2:找到曲线的斜率

图 10.2:找到曲线的斜率

然而,如果我们在点A上放大得足够近,我们会发现曲线被一条直线相当精确地近似。

图 10.3:放大曲线

图 10.3:放大曲线

这就是导数的工作原理:我们使x的变化,即水平移动,足够小,以至于曲线的这一小部分的斜率将近似等于该点的曲线变化率。让我们看看在 Python 中是什么样子。

编写导数函数

尽管在微积分课程中对导数进行了大量炒作,但用于数值计算导数的函数实际上非常简单。

在 Jupyter 笔记本中,我们将定义一个函数f(x),即抛物线y = x2:

def f(x):
    return x**2

现在我们可以编写一个函数,使用经典公式在任何点(x, f(x))计算导数:

图 10.4:计算导数的公式

图 10.4:计算导数的公式

分子是上升,分母是水平移动Δ x表示x的变化,并且我们将通过将 1 除以一百万来使其成为一个非常小的小数:

def f(x):
    return x**2
def derivative(f,x):
    """
    Returns the value of the derivative of
    the function at a given x-value.
    """
    delta_x = 1/1000000
    return (f(x+delta_x) - f(x))/delta_x

注意

下面代码片段中显示的三引号(""")用于表示多行代码注释的起始和结束点。注释被添加到代码中,以帮助解释特定的逻辑部分。

现在我们可以计算任何x值处函数的导数,并且我们将得到一个非常精确的近似值:

for i in range(-3,4):
    print(i,derivative(f,i))

如果您运行上述代码,您将得到以下输出:

-3 -5.999999000749767
-2 -3.999998999582033
-1 -1.999999000079633
0 1e-06
1 2.0000009999243673
2 4.0000010006480125
3 6.000001000927568

这些值与它们的实际值只有一点偏差(-5.999999 而不是-6)。我们可以将打印输出四舍五入到最接近的十分位,这样我们将更清楚地看到这些值:

for i in range(-3,4):
    print(i,round(derivative(f,i),1))

输出将是:

-3 -6.0
-2 -4.0
-1 -2.0
0 0.0
1 2.0
2 4.0
3 6.0

我们已经计算了函数y = x2 在许多点的导数,我们可以看到模式:导数始终是x值的两倍。这是近似该点曲线的直线的斜率。这种方法的强大之处将在这个练习中变得清晰。

练习 10.01:找到其他函数的导数

我们可以使用导数函数计算我们可以表达的任何函数的导数。当我们可以简单地使用计算斜率的微小运行方法时,就无需进行繁琐的代数运算。在这里,我们的函数将找到一些看起来复杂的函数的导数。我们重复使用了f,但您也可以调用其他函数。在这个练习中,您将找到给定x值处每个函数的导数:

图 10.5:给定 x 值的函数定义

图 10.5:给定 x 值的函数定义

执行以下步骤:

  1. 首先,我们需要从math模块导入平方根函数:
from math import sqrt
  1. 以下是前述方程中的函数,转换为 Python 代码:
def f(x):
    return 6*x**3
def g(x):
    return sqrt(2*x + 5)
def h(x):
    return 1/(x-3)**3
  1. 如果您还没有定义导数函数,请定义它:
def derivative(f,x):
    """Returns the value of the derivative of
    the function at a given x-value."""
    delta_x = 1/1000000
    return (f(x+delta_x) - f(x))/delta_x
  1. 然后通过调用每个函数和所需的x值打印出导数:
print(derivative(f,-2),derivative(g,3),derivative(h,5))

输出如下:

71.99996399265274 0.30151133101341543 -0.18749981253729509

您刚刚学会了一个非常重要的技能:在特定x值处找到函数(任何函数)的导数。这就是微积分学生要做大量艰难代数的原因:要将导数作为一个函数得到,然后他们可以代入x值。然而,使用 Python,我们直接计算了函数的数值导数,而不需要进行任何代数运算。

注意

要访问此特定部分的源代码,请参阅packt.live/2AnlJOC

您还可以在packt.live/3gi4I7S上在线运行此示例。

找到切线的方程

微积分中一个常见的问题是找到曲线在给定点处的切线方程。还记得我们的点AB吗?切线是在这些点附近紧密逼近曲线的线,正如您在图 10.6中所看到的:

图 10.6:曲线的两条切线

图 10.6:曲线的两条切线

让我们使用图 10.6中的信息。方程如下:

图 10.7:f(x)的方程

图 10.7:f(x)的方程

图 10.6中点Ax值为-0.48,Bx值为 0.67。使用 Python 的好处是,给定的值是整数、负数还是小数都无关紧要,计算机会轻松处理这些数字计算。

要找到直线的方程,我们只需要一个斜率和一个点。如果您记得您的代数,您可以使用这个公式:

图 10.8:直线的方程

图 10.8:直线的方程

我们已知函数和点(x0, y0),因此我们可以从给定的x值处函数的导数中找到斜率m。切线的方程将以y = mx + b的形式表示,我们唯一不知道的是b,即线的y截距。但是,如果我们重新排列前面的方程,我们可以在方程的右侧看到它:

图 10.9:点处的直线方程

图 10.9:点处的直线方程

我们需要使用已有的导数函数找到斜率m,然后将其代入y0 - m x0。为此,请执行以下步骤:

  1. 首先,我们将定义我们的f(x)函数:
def f(x):
    return x**3 - 2*x**2 + 1
  1. 然后,我们将编写一个函数,根据斜率和一个点返回直线的y截距。将其命名为point_slope
def point_slope(m,x,y):
    """Finds the y-intercept of a line
    given its slope m and a point (x,y)"""
    return y-m*x
  1. 最后,我们将编写一个函数,该函数接受函数f和一个x值,并找到fx处的导数,将其放入point_slope函数中,并以y = mx + b的形式打印出直线的方程。将其命名为tangent_line
def tangent_line(f,x):
    """Finds the equation of the line 
    tangent to f at x."""
  1. 我们通过对fx处进行导数运算来找到切线的斜率:
    m = derivative(f,x)
  1. 然后我们使用point_slope函数来找到y截距:
    y0 = f(x)
    b = point_slope(m,x,y0)
    print("y = ",round(m,2),"x + ",round(b,2))
  1. 现在,要得到在x = -0.48x = 0.67处切线的方程,使用以下代码:
for x in [-0.48,0.67]:
    tangent_line(f,x)

输出如下:

y =  2.61 x +  1.68
y =  -1.33 x +  1.3

在本节中,我们学习了如何找出在特定 x 值处的切线方程。

计算积分

微积分的一个主要主题是微分微积分,这意味着取导数,就像我们在本章中一直在做的那样。另一个主要主题是积分微积分,它涉及使用许多小 切片 来累加面积或体积。

在手工计算积分时,我们被教导要逆转我们用来找到导数的代数。但是那个代数变得混乱,并且在某些情况下是不可能的。我们在学校学到的 困难 版本是黎曼和,它要求我们将曲线下的面积切成矩形 切片 并将它们相加以获得面积。但你永远无法在一个现实的时间内处理超过 10 个切片,当然也不可能在考试中。

然而,使用 Python,我们可以使用任意多的切片,并且它可以节省我们通过许多步骤获得代数方程的麻烦。找到代数方程的目的是获得准确的数值,如果使用程序可以得到最准确的数值,那么我们肯定应该选择这条路线。

图 10.10 显示了一个函数和其下的面积。通常面积由函数本身、一个较低的 xa、一个较高的 xbx 轴界定。

图 10.10:由函数 f(x) 定义的曲线下的面积 S

图 10.10:由函数 f(x) 定义的曲线下的面积 S

我们要做的是将面积 S 切成等宽的矩形,由于我们知道高度(f(x)),所以使用 Python 将它们全部加起来会很容易。图 10.11 显示了 f(x) = x2 的情况:

图 10.11:面积 S 被切成 10 个等宽的矩形

图 10.11:面积 S 被切成 10 个等宽的矩形

首先,我们将定义函数并选择矩形的数量(以便两者的值都容易更改)。在这种情况下,我们将使用 20 个矩形,这将比 图 10.11 中显示的 10 个矩形给我们更高的准确度:

def f(x):
    return x**2
number_of_rectangles = 20

然后我们定义我们的积分函数。首先,通过将范围 (b – a) 划分为 num 个矩形来获得等宽:

def integral(f,a,b,num):
    """Returns the sum of num rectangles
    under f between a and b"""
    width = (b-a)/num

然后我们将循环遍历范围,随着我们的遍历,我们将矩形的面积相加。我们可以使用一行列表推导来实现这一点。对于每个 n,我们将矩形的底部(width)乘以高度(f(x))来获得每个矩形的面积。最后,我们返回所有面积的总和:

    area = sum([width*f(a+width*n) for n in range(num)])
    return area

这就是函数调用的样子:

for i in range(1,21):
    print(i,integral(f,0,1,i))

输出显示,随着更多的矩形,我们越来越接近实际面积的值:

1 0.0
2 0.125
3 0.18518518518518517
4 0.21875
5 0.24000000000000005
6 0.2546296296296296
7 0.26530612244897955
8 0.2734375
9 0.279835390946502
10 0.2850000000000001
11 0.2892561983471075
12 0.292824074074074
13 0.2958579881656805
14 0.29846938775510196
15 0.30074074074074075
16 0.302734375
17 0.3044982698961938
18 0.3060699588477366
19 0.3074792243767312
20 0.3087500000000001

看起来增长得很慢。如果我们直接跳到 100 个矩形呢?这将产生 图 10.12 中所示的情况:

图 10.12:较小的矩形更好地逼近面积

图 10.12:较小的矩形更好地逼近面积

下面是我们如何更改 print 语句以给出 100 个矩形的面积:

print(100,integral(f,0,1,100))

输出将如下所示:

100 0.32835000000000014

那么,使用 1,000 个矩形,这个积分将会非常困难和耗时地手工计算?使用 Python,我们只需将 100 改为 1000,就可以得到一个更准确的逼近:

print(1000,integral(f,0,1,1000))

输出将如下所示:

1000 0.33283350000000034

将 100,000 个矩形相加得到 0.3333283333. 看起来它接近于 0.333,或者 1/3. 但增加更多的零不会花费我们任何代价,所以随时增加矩形的数量以获得更准确的结果。

使用梯形

我们可以更快地获得更好的逼近,而不是使用矩形,而是使用梯形。这样,我们就不会错过太多的面积,就像在 图 10.13 中所示的那样:

图 10.13:使用梯形更好地逼近曲线

图 10.13:使用梯形更好地逼近曲线

以下是梯形规则的公式:

图 10.14:梯形面积的公式

图 10.14:梯形面积的公式

端点x = ax = b处的段的高度计算一次,而所有其他高度计算两次。这是因为在梯形面积的公式中有两个高度。您能猜到如何调整您的积分函数以成为梯形吗?

def trap_integral(f,a,b,num):
    """Returns the sum of num trapezoids
    under f between a and b"""
    width = (b-a)/num
    area = 0.5*width*(f(a) + f(b) + 2*sum([f(a+width*n) for n in range(1,num)]))
    return area

现在我们将使用5个梯形运行trap_integral函数:

print(trap_integral(f,0,1,5))

输出将如下:

0.3400000000000001

因此,仅使用 5 个梯形,我们将误差降低到 3%。(请记住,我们知道该函数的真实面积值为 0.333...)使用 10 个梯形,我们得到 0.335,误差为 0.6%。

练习 10.02:找到曲线下的面积

在这个练习中,我们将找到给定间隔内以下函数的面积:

图 10.15:间隔的公式

图 10.15:间隔的公式

执行以下步骤来找到面积。编写了trap_integral函数来使用梯形来近似曲线下的面积,很容易:只需定义函数(您可能需要导入trig函数和pi)并声明端点。让它使用 100 个梯形,因为那将非常准确和快速:

  1. 首先,导入您需要的math函数并定义fgh
from math import cos,pi
def f(x):
    return x**3 + 3
def g(x):
    return 3*cos(x)
def h(x):
    return ((x**2 - 1)*(x**2+1))/x**2
  1. 然后在指定的x值之间对每个函数调用trap_integral函数:
print(trap_integral(f,3,4,100))
print(trap_integral(g,0,pi/4,100))
print(trap_integral(h,2,4,100))

输出如下:

46.75017499999999
2.1213094390731206
18.416792708494786

到目前为止,您可能已经看到了这种数值方法的威力。如果您可以用 Python 表达一个函数,您可以使用用于在曲线下添加所有矩形的函数或更准确地说,用于在曲线下添加所有梯形的函数来获得其积分的非常准确的近似值。

注意

要访问此特定部分的源代码,请参考packt.live/3dTUVTG

您还可以在packt.live/2Zsfxxi上在线运行此示例。

使用积分解决应用问题

如果曲线围绕x轴或y轴或与其中一个轴平行的线旋转,以形成 3D 对象,我们可以使用积分工具来计算这个固体的体积。例如,假设抛物线y = x2 围绕其对称轴旋转形成一个抛物面,如图 10.16中所示:

图 10.16:绕 z 轴旋转的抛物线

图 10.16:绕 z 轴旋转的抛物线

我们可以通过将抛物面的所有切片相加来找到体积,就像在二维中使用矩形一样,现在我们在三维中使用圆柱体。在图 10.16中,切片是沿着图形向上而不是向右移动的,因此我们可以在脑海中翻转它,并重新定义曲线y = x2 为y = sqrt(x)

现在每个圆柱体的半径是y值,假设我们从x = 0x = 1

图 10.17:将抛物面侧翻

图 10.17:将抛物面侧翻

端点仍然是01,但曲线的半径是y值,即sqrt(x)。因此,每个圆形切片的体积是圆柱体的体积(pi * radius2 ** height),在这种情况下是pi * r2 ** thickness,或pi * sqrt(x)2 ** width*。

首先,我们从math模块导入sqrtpi并定义f(x)

from math import sqrt, pi
def f(x):
    return sqrt(x)

然后我们将定义一个函数,该函数将使用抛物面的函数和x的起始和结束值。它首先定义运行体积和我们将使用的切片数:

def vol_solid(f,a,b):
    volume = 0
    num = 1000

然后,我们通过将x值的范围除以切片的数量来计算切片的厚度:

    width = (b-a)/num

现在我们计算每个圆柱切片的体积,即pi * r2 ** 宽度*。我们将其添加到运行体积中,当循环完成时,我们返回最终体积:

    for i in range(num):
    #     volume of cylindrical disk
        vol = pi*(f(a+i*width))**2*width
        volume += vol    
    return volume

让我们把01之间的所有体积加起来:

print(vol_solid(f,0,1))

输出将如下所示:

1.5692255304681022

这个值是有界抛物面积的近似值。同样,我们将函数分成的切片越多,近似值就越接近真实体积。

练习 10.03:找到旋转体的体积

这是另一个旋转体问题:找到以下函数在给定区间上绕x轴旋转时形成的固体的体积。

在下图中,绿色曲线是f(x) = 4 – 4x2,红色曲线是g(x) = 1-x2。找到当函数之间的区域围绕x轴旋转时形成的固体的体积。

图 10.18:两个函数的二维外观

图 10.18:两个函数的二维外观

固体的结果形状如下:

图 10.19:结果形状像一个环

图 10.19:结果形状像一个环

这就像一个找到环的面积的问题,就像前面的图所示。公式如下:

图 10.20:环形面积公式

图 10.20:环形面积公式

现在使用 Python 来找到固体的体积,执行以下步骤:

  1. 像往常一样创建fg,以及第三个函数(h)作为fg的平方的差,来自环面积公式:
def f(x):
    return 4 -4*x**2
def g(x):
    return 1-x**2
def h(x):
    return f(x)**2-g(x)**2
  1. 现在,固体的体积将是在函数之间制成的给定数量(num)的圆柱体的总和。我们和我们的积分函数做同样的事情。圆柱的半径与我们积分时的矩形的高度相同:
def vol_solid(f,a,b):
    volume = 0
    num = 10000
    width = (b-a)/num
    for i in range(num):
  1. 圆柱的体积是pir2**h,我们将其加到总体积中:
        vol = pi*(f(a+i*width))*width
        volume += vol    
    return volume
  1. 在这里,我们在-11之间的x上调用vol_solid函数:
print(vol_solid(h,-1,1))

输出将如下所示:

50.26548245743666

因此,得到的固体的体积为 50.3 立方单位。因此,我们已经使用我们的函数找到了固体的体积,并且已经调整它以找到两个曲线之间的固体的体积。

注意

要访问此特定部分的源代码,请参阅packt.live/2NR9Svg

您还可以在packt.live/3eWJaxs上在线运行此示例。

使用导数解决优化问题

在许多应用问题中,我们正在寻找一个最佳点,在这个点,误差最低,例如,或者利润最高。传统的方法是使用函数对情况进行建模,找到函数的导数,并解决使导数为零的输入。这是因为导数在局部最小值和最大值处为零,如下图所示:

图 10.21:一个三次函数和我们想要找到的点

图 10.21:一个三次函数和我们想要找到的点

我们在图中给出的函数是f(x) = x3 - 2.8x2 + 1.2x + 0.85。我们有兴趣找到局部最大值,点A,和局部最小值,点B。我们需要对函数进行微分,并手动解决得到的方程。但是使用计算机,我们可以从网格左侧的一个x值开始,小步骤地检查f(x),直到我们得到方向的改变。为此,我们可以使用我们的导数函数来检查导数何时改变符号。

首先,我们定义f(x)

def f(x):
    return x**3-2.8*x**2+1.2*x+0.85

然后,我们将定义一个名为find_max_mins的函数,从最小的x值开始,小步骤地检查导数是否等于零,或者是否改变符号,从正变为负,或者反之。最数学的方法是检查前一个导数乘以新的导数是否为负:

def find_max_mins(f,start,stop,step=0.001):
    x = start
    deriv = derivative(f,x)
    while x < stop:
        x += step
        #take derivative at new x:
        newderiv = derivative(f,x)
        #if derivative changes sign
        if newderiv == 0 or deriv*newderiv < 0:
            print("Max/Min at x=",x,"y=",f(x))
            #change deriv to newderiv
            deriv = newderiv

最后,我们调用函数,这样它将打印出导数改变符号的所有值:

find_max_mins(f,-100,100)

输出如下:

Max/Min at x= 0.247000000113438 y= 0.9906440229999803
Max/Min at x= 1.6200000001133703 y= -0.3027919999998646

这些是图 10.21f的局部最大值和局部最小值。

练习 10.04:寻找最快的路线

我们可以使用这种找到极值的方法来找到复杂函数的最小值。在传统的微积分课程中,学生们必须代数地求导,将其设为零,然后解决所得方程。我们可以在 Python 中对情况进行建模,并使用我们的导数和find_max_min函数轻松找到最小值。这就是情况:灯塔位于离岸 6 公里处,直线海岸上的小屋距离离灯塔最近的海岸点有 9 公里。如果你划船的速度是 3 公里/小时,步行的速度是 5 公里/小时,你应该在哪里着陆才能尽快地从灯塔到小屋?

图 10.22:灯塔到小屋的距离

图 10.22:灯塔到小屋的距离

执行以下步骤完成练习:

  1. 我们的目标是最小化这次旅行所需的时间,所以让我们制定一个时间公式。记住,时间是距离除以速度:图 10.23:计算时间的公式

图 10.23:计算时间的公式

  1. 这就是我们需要最小化的函数。最佳的x将在 0 到 9 公里之间,所以当我们调用我们的find_max_mins函数时,我们将把它们设置为我们的起始和结束值:
from math import sqrt
def t(x):
    return sqrt(x**2+36)/3 + (9-x)/5

find_max_mins(t,0,9)

输出将如下所示:

Max/Min at x= 4.4999999999998375 y= 3.4000000000000004

这非常接近沿着海滩的 4.5 公里。这是一个非常有用的计算:我们找到了两点之间的最短距离,当其他约束条件已经放置。

要访问此特定部分的源代码,请参阅packt.live/31DwYxu

您还可以在packt.live/38wNRM5上在线运行此示例。

练习 10.05:箱子问题

对于所有微积分学生来说,有一个经典问题,即制造商有一块矩形材料,他们想通过从角落切割相同大小的正方形来制成一个箱子,就像下图所示:

图 10.24:从矩形的角落切割正方形

图 10.24:从矩形的角落切割正方形

在这种情况下,材料的尺寸是 10 英寸乘以 12 英寸。问题是:找出要切割的正方形的大小,以使得所得箱子的体积最大化:

  1. 箱子体积的公式将是长度乘以宽度乘以高度。就x而言,从角落切下的正方形的长度,箱子的长度是12-2x,因为两个角落被切出了 12 英寸的边。同样,箱子的宽度将是10-2x。高度,一旦“盖子”向上弯曲,将是x。所以,体积是:图 10.25:计算体积的公式

图 10.25:计算体积的公式

  1. 以下是如何在 Python 中定义这个函数的方法:
def v(x):
    return x*(10-2*x)*(12-2*x)
  1. 到目前为止,您知道如何将其放入您的find_max_mins函数中。我们只想插入 0 到 5 之间的值,因为超过 5 英寸将意味着我们将没有一边(宽度为 10 英寸):
find_max_mins(v,0,5)

输出将如下所示:

Max/Min at x= 1.8109999999999113 y= 96.77057492400002

通过切割边长为 1.81 英寸的正方形来实现最大体积。这是体积的图表:

图 10.26:实现最大值的图表

图 10.26:实现最大值的图表

我们可以看到,当每边切割 1.81 英寸的正方形时,最大体积就会实现,因为这是图中最大点的位置。

要访问此特定部分的源代码,请参阅packt.live/3gc11AC

您还可以在packt.live/2NNSNmb上在线运行此示例。

练习 10.06:最佳罐头

一个圆柱体罐子可以容纳 355 立方厘米的苏打水。哪些尺寸(半径和高度)将使得构造罐子的金属成本最小?你可以忽略罐子的顶部:

  1. 圆柱体的表面积是底部的面积(一个圆,所以πr2)加上它的侧面积,这是一个底部为2πr,高度为h的矩形。圆柱体的体积是πr2h,所以我们把它全部放在一起:图 10.27:计算圆柱体体积的公式

图 10.27:计算圆柱体体积的公式

  1. 体积已经设定为 355。从那里,我们可以得到一个关于* r *的表达式,然后我们将表面积都转化为一个变量的表达式:图 10.28:在公式中替换值

图 10.28:在公式中替换值

  1. 让我们用 Python 表达它,并把它放到我们的find_max_mins函数中:
from math import pi
def surf_area(r):
    return pi*r**2 + 710/r
find_max_mins(surf_area,0.1,10)

运行代码时,输出将如下所示:

Max/Min at x= 4.834999999999949 y= 220.28763352297025

因此,解决方案是半径约为 4.8 厘米,高度约为 355/(π(4.8)2) = 4.9 厘米。这意味着罐子的宽度大约是高度的两倍。这是一个surf_area函数的图,显示了 2 到 6 厘米之间的罐子。你可以看到最小化材料的点,在 4.5 和 5 厘米之间。我们计算得到确切的值是 4.9 厘米:

图 10.29:找到制作罐子所需的最小材料

图 10.29:找到制作罐子所需的最小材料

注意

要访问本节的源代码,请参阅packt.live/2Zu2bAK

你也可以在packt.live/38lUNeE上在线运行这个例子。

练习 10.07:计算两艘移动船之间的距离

中午时,A 船在 B 船的北面 20 公里。如果 A 船以 6 公里/小时向南航行,B 船以 8 公里/小时向东航行,找到两艘船之间距离最小的时间。以下图显示了这种情况:

图 10.30:A 和 B 船向南和东移动

图 10.30:A 和 B 船向南和东移动

执行以下步骤找到时间:

  1. 距离是速度乘以时间,因此两艘船之间的距离可以用这个方程来建模:图 10.31:计算距离的公式

图 10.31:计算距离的公式

  1. 让我们用 Python 表达这个问题,并把它放到我们的find_max_mins函数中:
from math import sqrt
def d(t):
    return sqrt((20-6*t)**2+(8*t)**2)
  1. 我们假设时间将在04小时之间:
find_max_mins(d,0,4)

输出将如下所示:

Max/Min at x= 1.1999999999999786 y= 16.0

因此时间是 1.2 小时,由下图上的最小点表示。两小时的十分之一是 12 分钟,这意味着两艘船在下午 1:12 时最接近。这是距离与时间的图:

图 10.32:距离与时间的图

图 10.32:距离与时间的图

注意

要访问本节的源代码,请参阅packt.live/38k2kuF

你也可以在packt.live/31FK3GG上在线运行这个例子。

活动 10.01:最大圆锥体积

这是一个经典的优化问题,如果你手工完成,将导致一些非常复杂的方程式来求导和解决。然而,使用 Python 将使微积分部分变得更容易。你从一个圆开始,切出一个θ度的扇形。然后你在下图中连接点AB,制作一个圆锥:

图 10.33:圆锥体积

图 10.33:圆锥体积

问题,就像在盒子问题中一样,是找到切出的角度,使圆锥体的体积最大化。这将要求你想象切出角度,连接点制作圆锥体,并计算结果圆锥体的体积。

完成步骤:

  1. 找到AB的弧长。

  2. 找到* h *,结果圆锥体的高度。

  3. 找到* r *,圆锥的底半径。

  4. 找出圆锥的体积表达式作为角度切出的函数(θ)。

注意

此活动的解决方案可在第 694 页找到。

总结

微积分的工具使数学家和科学家能够处理不断变化的值,这些工具改变了科学研究的方式。突然之间,我们可以使用无限小的步骤来近似曲线在某一点的斜率,或者使用无限小的矩形来近似曲线下的面积。这些工具是在我们现代计算机和免费编程软件的世界出现数百年之前发展起来的,但是没有理由限制自己只使用牛顿、莱布尼兹和伯努利可用的工具。

在本章中,我们学会了通过简单地将函数从一个点到另一个点的上升除以这些点之间的无限小运行来对函数进行求导。我们只需告诉 Python 将 1 除以一百万,就可以得到这个小数。没有计算机的话,将这些小数插入函数将是一项艰巨的任务,但是 Python 可以像处理整数一样轻松地将小数插入函数。

我们利用导数的概念来找到函数的最高或最低输出,其中导数等于零。这使我们能够找到函数的最优值,例如产生最短距离或最大体积。

微积分中第二重要的主题是积分,这使我们能够使用矩形、梯形或圆柱体逐层构建复杂的面积或体积。使用 Python,我们可以轻松地组合数百或数千个切片,准确地近似一个面积或体积。

我们只是触及了微积分和 Python 赋予我们处理变化值、无限小值以及无限大值的能力的表面。

在下一章中,我们将扩展这些基本工具,以找到曲线的长度、表面的面积,以及对机器学习最有用的,表面上的最小点。

WFT54

GLS48

第十一章:11.使用 Python 进行更多微积分

概述

在本章中,您将学习如何根据其方程计算曲线的长度。您将学习如何在三维空间中使用偏导数来计算表面积。跟随中世纪的数学家的脚步,您将使用无限级数来计算诸如π之类的常数,并确定级数的收敛区间。像现代数学家和机器学习工程师一样,您将学习如何使用偏导数找到表面上的最小点。在本章结束时,您将能够使用微积分解决各种数学问题。

介绍

在上一章中,我们学习了如何计算导数和积分。现在,我们将使用这些工具来找到曲线和螺旋线的长度,并将这种推理扩展到三维空间,以找到复杂表面的面积。我们还将研究微积分中常用的工具,即无限级数,用于计算重要常数和近似复杂函数。最后,我们将研究机器学习中的一个重要概念:找到曲线上的最小点。当您使用神经网络时,您会创建一种“误差函数”,并努力找到曲面上使误差最小的点。我们将创建自己的梯度下降函数,不断向下移动,直到到达曲面的底部。

曲线的长度

导数和积分的一个主要用途是找到曲线的长度。有一个公式:

图 11.1:计算曲线长度的公式

图 11.1:计算曲线长度的公式

前面的公式包含积分和导数。要找到曲线的长度,我们需要我们的导数和积分函数。如果您还没有它们,请将它们复制并粘贴到您的代码中:

from math import sqrt

def derivative(f,x):
    """Returns the value of the derivative of     the function at a given x-value."""
    delta_x = 1/1000000
    return (f(x+delta_x) - f(x))/delta_x

def trap_integral(f,a,b,num):
    """Returns the sum of num trapezoids     under f between a and b"""
    width = (b-a)/num
    area = 0.5*width*(f(a) + f(b) + 2*sum([f(a+width*n) \                                        for n in range(num)]))
    return area

以下是公式的 Python 版本:

def curve_length(f,a,b,num):
    def g(x):
        return sqrt(1+(derivative(f,x)**2))
    return trap_integral(g,a,b,num)

请注意,我们只是将数学符号转换为 Python 代码。我们在f函数内定义了g函数。g函数是公式中平方根下的所有内容。然后,我们使用我们的trap_integral函数来找到ab之间g函数的累积值。

让我们检查一下我们知道长度的曲线,比如线y = 2x。我们可以使用勾股定理计算曲线的长度,x = (0,0)x = (2,4)之间的距离,结果为 2√5 或 4.47 个单位:

def f(x):
    return 2*x
print(curve_length(f,0,2,1000))

前面的代码输出了 4.47...作为输出。

但是当我们尝试检查我们知道长度的实际曲线,比如半圆时,我们遇到了问题。我们知道以下方程的长度,因为它是半径为 1 的圆的一半周长。所以,我们应该得到π或 3.1415...作为输出:

图 11.2:计算半圆长度的公式

图 11.2:计算半圆长度的公式

让我们把f(x)改成前面半圆的方程:

def f(x):
    return sqrt(1-x**2)
print(curve_length(f,-1,1,100))

当您执行前面的代码时,会出现错误。错误消息的最后一行(我读到的第一行)说:

ValueError: math domain error

这是因为半圆在-1 和 1 处的导数是无穷大。这些点处的切线是垂直的,如下图所示:

图 11.3:垂直切线,斜率无穷大

图 11.3:垂直切线,斜率无穷大

所以,这种方法已经遇到了问题。让我们看看它是否能找到正常多项式的长度,比如下图所示的多项式:

图 11.4:一个复杂的多项式

图 11.4:一个复杂的多项式

这是一个 5 次多项式,意味着x的最高指数是5。曲线的方程如下:

图 11.5:曲线的方程

图 11.5:曲线方程

尽管看起来很复杂,但在曲线上没有地方导数是无穷大的,就像图 11.3中那样。这意味着我们可以在它上面使用我们的曲线长度函数。

以下是多项式的代码:

def f(x):
    return 0.7*x**5 + 1.6*x**4-2.05*x**3 -3*x**2+2.95*x+2.9
print(curve_length(f,-2,1,1000))

曲线的长度如下:

9.628984854276812

我们可以使用 Wolfram Alpha 来解决这个问题,方法是输入length of curve y = ... from –2 to 1,然后检查它是否是一个很好的近似值。但是使用 Python,有一种更直接的方法来计算曲线的长度,它不会遇到我们在导数中遇到的问题。事实上,它甚至不使用导数或积分。你可以简单地使用毕达哥拉斯定理找到曲线的微小部分的长度,并累加所有这些微小部分,如下图所示。我们知道宽度,我们对微小直角三角形的斜边感兴趣。我们可以计算高度,即x处的函数和x处的函数之间的差,再加上宽度:

图 11.6:找到曲线的微小部分的长度

图 11.6:找到曲线的微小部分的长度

在前面图表中显示的直角三角形的斜边如下:

图 11.7:计算直角三角形斜边的公式

我们所要做的就是遍历从ab的区间,计算所有这些长度。以下是如何在 Python 中实现的:

def f(x):
    return 0.7*x**5 + 1.6*x**4-2.05*x**3 -3*x**2+2.95*x+2.9
def curve_length2(f,a,b,num=1000):
    """Returns the length of f between\
    a and b using num slices"""
    output = 0
    width = (b-a)/num
    for i in range(num):
        output += sqrt((f(a+(i+1)*width)-f(a+i*width))**2 + width**2)
    return output

这应该让你想起积分程序:创建一个运行总和,然后循环遍历曲线的每个片段,在此过程中添加面积(在本例中是弧长)。最后,返回运行总和的最终值。

这是我们感兴趣的区间的曲线长度:

print(curve_length2(f,-2,1))

这给出了曲线的长度为9.614118659973549。这甚至比以前的版本更接近,而且麻烦要少得多。现在轮到你在以下练习中做同样的事情了。

练习 11.01:找到曲线的长度

在这个练习中,你将得到以下曲线方程。使用这个方程,确定两个给定x值之间的曲线长度:

图 11.8:曲线方程

图 11.8:曲线方程

这些值将从x = -1x = 1

执行以下步骤:

  1. 首先,我们需要使用前述方程创建一个circle函数:
def circle(x):
    return sqrt(1-x**2)

注意

这又是一个半圆。这次,我们的curve_length2函数将毫无问题地累加弧线的微小切片。

  1. 现在,我们将在该曲线上运行curve_length2函数(我们已经编码过了),以累加所有微小段,就像以前一样:
def curve_length2(f,a,b,num=1000):
    """Returns the length of f between\
       a and b using num slices"""
    output = 0
    width = (b-a)/num
    for i in range(num):
        output += sqrt((f(a+(i+1)*width)-f(a+i*width))**2 \
                        + width**2)
    return output
  1. 现在,我们打印函数的输出,从x = -1x = 1进行测量:
print(curve_length2 (circle,-1,1))

输出如下:

3.1415663562164773

这次没有错误消息。我们得到了一个很好的近似值,即半径为 1 的圆的半周长,我们知道是π。

注意

要访问此特定部分的源代码,请参阅packt.live/3gkI5Qi

你也可以在packt.live/3eVpSbz上在线运行这个例子。

练习 11.02:找到正弦波的长度

数学和科学中一个非常重要和有用的函数是正弦波。它在 0 和 2π之间完成一个周期,如下图所示:

图 11.9:正弦波的一个周期

图 11.9:正弦波的一个周期

测量它的波长(2π)和振幅(它上下移动的距离,即 1 个单位)很容易,但实际曲线有多长呢?在这个练习中,我们将找到从 0 到 2π的正弦波的长度。

执行以下步骤:

  1. 我们将再次使用我们的curve_length2函数,但现在我们必须从math模块导入我们的sinpi函数:
from math import sin, pi
  1. 我们已经编写了curve_length2函数,它将累加曲线的所有部分。我们只需要告诉它要使用的函数,以及开始和结束的x值:
print(curve_length2(sin,0,2*pi))

输出如下:

7.640391636335927

如您所见,使用curve_length2函数,计算正弦波的长度变得非常容易。

注意

要访问此特定部分的源代码,请参阅packt.live/3dUy3nk

您还可以在packt.live/2VFy2xd上在线运行此示例。

螺旋的长度

那么,极坐标中的螺旋怎么样,其中r,即与原点的距离,是与与x轴成角度(θ)的函数?我们不能使用我们的xy函数来测量以下图中显示的螺旋:

图 11.10:阿基米德螺旋

图 11.10:阿基米德螺旋

在前面的图中,我们有一个从(5,0)开始,绕中心旋转 7.5 圈,最终到达(11,π)的螺旋。该曲线的公式为r(θ) = 5 + 0.12892θ。旋转的弧度数是 7.5 乘以 2π,即 15π。我们将使用与前一节相同的思路:我们将找到从r(θ)r(θ+step)的直线长度,step 是中心角的一个小步长,如下图所示:

图 11.11:近似曲线的一小部分的长度

图 11.11:近似曲线的一小部分的长度

三角形中央角的对边就像我们积分问题中的切片或前一个曲线长度程序中三角形的斜边一样。这次,它不是直角三角形,所以我们不能使用斜边。但是对于这个问题,有一个称为余弦定律的公式。在三角形 ABC 中,角 C 的对边长度如下:

图 11.12:余弦定律

图 11.12:余弦定律

我们所需要做的就是将其放入一个函数中,就像这样:

def opposite(a,b,C):
    """Returns the side opposite the given angle in
       a triangle using the Law of Cosines
       Enter side, side, angle"""
    c = sqrt(a**2 + b**2 - 2*a*b*cos(C))
    return c

然后,我们只需要编写一个函数,从起始角度开始,沿着曲线采取微小步骤,测量每个微小角度的对边,直到达到结束角度:

from math import sqrt,cos,pi
def spiral(r,a,b,step=0.0001):
    """Returns length of spiral r from
       a to b taking given step size"""
    length = 0
    theta = a
    while theta < b:
        length += opposite(r(theta),r(theta+step),step)
        theta += step
    return length

我们的函数如下:

def r(theta):
    return 5 + 0.12892*theta

因此,我们所要做的就是在该螺旋上执行我们的螺旋函数,从 0 到 15π:

spiral(r,0,2*pi*7.5)

输出如下:

378.8146271783955

如您所见,螺旋的长度为378.8146271783955。在下一个练习中,我们将看看如何找到极坐标螺旋曲线的长度。

练习 11.03:找到极坐标螺旋曲线的长度

在这个练习中,您将找到极坐标螺旋曲线的长度,该曲线从(3,0)开始,围绕中心旋转 12 次,最终到达(16,0)。

执行以下步骤以找到所需的长度:

  1. 我们不知道这个螺旋的公式,但我们知道半径在 12 个旋转中增加了 13 个单位(从 3 到 16)。这意味着对于角度θ的每个 2π增加,半径增加 13/12 个单位。因此,我们将 13/12 除以 2π。半径的增加可以表示如下:图 11.13:计算半径增加的公式

图 11.13:计算半径增加的公式

  1. 我们可以用 Python 这样表示:
def r(theta):
    return 3 + 0.1724*theta
  1. 我们可以检查以确保r(0) = 3r(24π) = 16
print(r(0),r(24*pi))

输出如下:

3.0 15.998653763493127
  1. 现在,我们只需将其放入我们的螺旋函数中:
spiral(r,0,2*pi*12)

输出如下:

716.3778471288748

在这个练习中,我们仅通过知道曲线的起始值和结束值以及它围绕中心旋转的次数,就轻松找到了这个螺旋曲线的长度,即716.3778471288748

注意

要访问此特定部分的源代码,请参阅packt.live/2YT70EH

您还可以在packt.live/2YV4wFT上在线运行此示例。

练习 11.04:找到卷中的绝缘长度

您被要求找到所示卷中绝缘的(近似)长度:

图 11.14:使用微积分测量卷起的材料

图 11.14:使用微积分测量卷起的材料

您测量卷起并发现中心是一个直径为 4 英寸的空圆(因此r(0) = 2)。卷的外径为 26 英寸。您从中心到外部计算层数,并估计螺旋需要 23 个半转,因此r(2π23.5)= 26/2 = 13*。

执行以下步骤计算长度:

  1. 使用前面的数据计算方程:图 11.15:计算半径的公式

图 11.15:计算半径的公式

  1. 这就是螺旋图的样子:图 11.16:绝缘卷的图表

图 11.16:绝缘卷的图表

  1. 现在,将我们的r代码更改为这个螺旋并不难:
def r(theta):
    return 2 + 0.0745*theta
  1. 现在,我们可以在这个函数上运行我们的螺旋函数,从02π23.5
spiral(r,0,2*pi*23.5)

输出如下:

1107.502879450013

1,107 英寸只是超过 92 英尺的绝缘。

注意

要访问此特定部分的源代码,请参阅packt.live/2VE9YKZ

您还可以在packt.live/31D43tG上在线运行此示例。

练习 11.05:找到阿基米德螺旋的长度

对于这个练习,您已经得到了阿基米德螺旋的方程。找到从θ=0θ=2π的螺旋长度:

图 11.17:阿基米德螺旋的方程

图 11.17:阿基米德螺旋的方程

注意

这适用于对数螺旋和阿基米德螺旋。

执行以下步骤找到长度:

  1. 我们只需用指数函数重新定义r
from math import e
def r(theta):
    return 2*e**(0.315*theta)
  1. 然后,我们从0运行螺旋函数:
spiral(r,0,2*pi)

输出如下:

41.518256747758976

这个螺旋的长度是41.518256747758976

注意

要访问此特定部分的源代码,请参阅packt.live/2VEtjfo

您还可以在packt.live/2VHasQN上在线运行此示例。

表面积

让我们学习如何将这个从二维转换为三维,并计算 3D 表面的面积。在第十章使用 Python 进行基础微积分中,我们学习了如何计算旋转表面的面积,但这是一个第三维度zxy的值的函数的表面。

公式

解决这个问题的传统代数方法是通过对表面进行双重积分:

图 11.18:计算表面积的公式

图 11.18:计算表面积的公式

这里,z = f(x,y)(x,y,f(x,y))。那些花括号 d 是增量,意味着我们将处理偏导数。偏导数是导数,但只针对一个变量,即使函数依赖于多个变量。这是一个返回函数f在特定点(v,w)处相对于变量u的偏导数的函数。根据我们感兴趣的变量是x还是y,函数将朝着那个方向迈出微小的一步并计算导数,就像我们已经做过的那样:

def partial_d(f,u,v,w,num=10000):
    """returns the partial derivative of f
    with respect to u at (v,w)"""
    delta_u = 1/num
    try:
        if u == 'x':
            return (f(v+delta_u,w) - f(v,w))/delta_u
        else:
            return (f(v,w+delta_u) - f(v,w))/delta_u
    except ValueError:
        pass

在代码中有一个try...except块,以防抛出ValueError。如果斜率太大,就像垂直线一样,就会发生这种情况。如果发生这种情况,它将忽略它并继续进行。

现在,我们需要一个 3D 向量和一个cross函数来计算面积公式中的叉积。叉积给出垂直于给定向量的向量的长度,但也给出由给定向量形成的平行四边形的面积。

图 11.19:两个向量的叉积

图 11.19:两个向量的叉积

如果您知道向量之间的角度,可以使用它来找到叉积:

图 11.20:计算两个向量的叉积的公式

图 11.20:计算两个向量的叉积的公式

如果不这样做,就像我们的情况一样,可以使用 3D 向量来表示向量在每个方向上的位移,xyz。例如,假设我们有两个向量,u = 2i + 3j + 4kv = 5i + 6j + 7k。它们由它们在三个维度中的位移定义。i部分是x方向的位移,j部分是y方向的位移,k部分是z方向的位移。好消息是,会有一些零来简化事情。要交叉两个向量,我们可以将它们放入矩阵中,并对它们进行操作,如下矩阵的行列式:

图 11.21:使用矩阵计算两个向量的叉积

图 11.21:使用矩阵计算两个向量的叉积

我们将编写一个函数来执行两个 3D 向量的操作。我们需要放入的只是ijk的系数。因此,如果u = ai + bj + ckv = di + ej + fk,我们将得到以下结果:

图 11.22:对 3D 向量执行数学运算

图 11.22:对 3D 向量执行数学运算

让我们使用列表来表示向量,所以u = [a,b,c]u[0] = au[1] = bu[2] = c为系数:

def cross(u,v):
    """Returns the cross product of 2 3D vectors
    [[i,j,k],\
    [1,0,dz/dx],\
    [0,1,dz,dy]]
    cross([1,-1,2],[2,3,-5])
    >>> [-1, -9, 5]
    """
    return [u[1]*v[2]-v[1]*u[2],\
            -u[0]*v[2]+v[0]*u[2],\
            u[0]*v[1]-v[0]*u[1]]

我们编写了一个长的文档字符串,以清楚地说明函数的用途,如何放入值以及我们将得到什么输出。让我们检查一下,以确保我们得到正确的输出:

print(cross([2,3,4],[5,6,7]))

输出如下:

[-3, 6, -3]

可以了。现在,我们需要编写一个函数来找到 3D 向量的大小,因为这将给我们平行四边形的面积。这只是将勾股定理扩展到三维。因此,如果u = ai + bj + ck,则向量u的大小是a

def mag(vec):
    """Returns the magnitude of a 3D vector"""
    return sqrt(vec[0]**2+vec[1]**2+vec[2]**2)

这是半圆的样子,其表面由平行四边形近似。更多的平行四边形应该意味着更准确的近似:

图 11.23:使用更多的平行四边形

图 11.23:使用更多的平行四边形

我们的面积函数将循环遍历网格中的所有xy点,计算每个点的偏导数,并使用叉积来找到该点处平行四边形的面积:

from math import sqrt
def sphere(x,y):
    """Sphere of radius 1"""
    return sqrt(1-x**2-y**2) 
def area(f,ax,bx,ay,by,num=1000):
    """Returns area of parallelogram formed by
    vectors with given partial derives"""
    running_sum = 0
    dx = (bx-ax)/num
    dy = (by-ay)/num
    for i in range(num):
        for j in range(num):
            x = ax+i*dx
            y = ay+j*dy
            dz_dx=partial_d(f,'x',x,y)
            dz_dy=partial_d(f,'y',x,y)
            try:
                running_sum += mag(cross([1,0,dz_dx],[0,1,dz_dy]))*dx*dy
            except:
                pass
    return running_sum

首先,我们将面积的运行总和设置为 0。然后,我们计算dxdyxy的微小变化,因为我们将表面分成相等的切片。try...except块只是忽略(pass)当切线的斜率是垂直的时候,会出现无限的偏导数的错误,就像我们在图 11.3中看到的那样。如果没有错误,它将添加在该点由偏导数形成的平行四边形的面积。现在,我们使用 1,000 个点在每个方向上运行半球的面积函数,并得到一个相当准确的近似值。我们知道半径为 1 的球体的表面积的一半是 2π,或 6.28:

print("Area of hemisphere:",area(sphere,-1,1,-1,1))

输出如下:

Area of hemisphere: 6.210356913122

现在,让我们快速进行一个基于这个概念的练习。

练习 11.06:寻找 3D 表面的面积-第 1 部分

现在,让我们找到一个复杂表面的面积,这将很难使用代数方法找到。考虑以下方程的表面:

图 11.24:表面的方程

图 11.24:表面的方程

表面显示在以下图像中:

图 11.25:复杂的 3D 表面

图 11.25:复杂的 3D 表面

执行以下步骤以找到面积:

  1. 让我们将函数放入我们的面积程序中,看看我们得到什么:
from math import sin, cos, sqrt
def surface(x,y):
    return 10*sin(sqrt(x**2+y**2))
print("Area of wave surface:",area(surface,-5,5,-5,5))
  1. 运行程序以查看输出:
Area of wave surface: 608\. 2832236305994

从前面的代码中,我们可以清楚地看到,使用 Python 几行代码就可以轻松找到甚至复杂表面的面积有多容易。

注意

要访问此特定部分的源代码,请参阅packt.live/3gwd6kr

您也可以在packt.live/2ZpgwOQ上在线运行此示例。

练习 11.07:找到 3D 曲面的面积-第 2 部分

找到曲面的面积a

这就是曲面的样子:

图 11.26:另一个 3D 曲面

图 11.26:另一个 3D 曲面

执行以下步骤找到面积:

  1. 定义我们的曲面函数以返回表达式:
def surface(x,y):
    return 3*cos(x)+2*cos(x)*cos(y) 
  1. 运行surface函数以获得值:
print("Area of surface:",area(surface,0,6.28,0,6.28))

输出如下:

Area of surface: 99.80676808568984

这个 3D 曲面的面积是99.80676808568984

注意

要访问此特定部分的源代码,请参阅packt.live/2VCaObq

您也可以在packt.live/2NPXvQo上在线运行此示例。

练习 11.08:找到曲面的面积-第 3 部分

找到曲面的面积b

这就是曲面的样子:

图 11.27:曲面

图 11.27:曲面a

执行以下步骤找到面积:

  1. 定义我们的曲面函数以返回新表达式:
def surface(x,y):
    return sqrt(1+sin(x)*cos(y))
  1. 运行surface函数:
print("Area of surface:",area(surface,0,6.28,0,6.28))

输出如下:

Area of surface: 42.80527549685105

这个曲面的面积是42.80527549685105

注意

要访问此特定部分的源代码,请参阅packt.live/3gwdLlV

您也可以在packt.live/3dUNWdt上在线运行此示例。

无限级数

数学家经常遇到对他们来说太复杂以至于无法解决或处理的函数,近似一直是数学中的重要组成部分。对于试图代数地进行导数和积分的数学家来说,许多表达式没有漂亮的解、导数、积分等。一般来说,科学家在现实生活中遇到的微分方程没有代数解,因此他们必须使用其他方法。稍后会详细介绍微分方程,但有一类重要的近似使用简单函数来近似函数。

多项式函数

解决多项式方程很容易,可以求导和积分,比如y = x2,甚至以下方程:

图 11.28:多项式方程

图 11.28:多项式方程

所有项都是一个接一个地添加(或相减),没有三角、对数或指数函数参与,使事情变得困难。以下是用简单多项式近似函数的公式:

图 11.29:泰勒级数

图 11.29:泰勒级数

这个公式被称为泰勒级数:任何函数(可导)都可以使用一定精度在特定点用一定页数的多项式来近似。

级数

数学家有一种表示将一堆遵循某种模式的数字相加的符号:

图 11.30:计算级数的公式

图 11.30:计算级数的公式

看起来像一个E的大符号实际上是希腊字母 sigma,或者S,表示数字的总和。sigma 下面的方程是变量从哪里开始(在这种情况下是 1),sigma 上面是i的最后一个整数值(在这种情况下是 10)。在 sigma 右边是对变量的操作的表达式。在这种情况下,我们只是将i,变量,从 1 加到 10。这几乎就是您在 Python 中编写列表推导式的方式。如下:

s = sum([i for i in range(1,11)])

列表推导式中的第一个项是在 sigma 级数表达式中看到的内容-在这种情况下是i。例如,直到n的整数平方和的级数如下:

图 11.31:1 到 n 的整数平方和的级数

图 11.31:1 到 n 的整数平方和的级数

在 Python 中,我们会这样写:

s = sum([i**2 for i in range(1,n+1)])

一个古老但有用的级数是 arctangent 级数。它计算具有给定正切的角度(以弧度表示);例如:

图 11.32:arctangent 级数的方程

图 11.32:arctangent 级数的方程

根据前述方程,arctan 的方程如下:

图 11.33:arctan 的方程

图 11.33:arctan 的方程

该系列按照以下模式计算:

图 11.34:arctan 级数的方程

图 11.34:arctan 级数的方程

这是 sigma 表达式:

图 11.35:一个 sigma 表达式

图 11.35:一个 sigma 表达式

通过插入x的正切,我们可以计算出角度的近似值:

图 11.36:将 x 的值代入方程

图 11.36:将 x 的值代入方程

对于几个世纪前的数学家来说,这是相当多的计算,但这是 Python 的等效代码。请注意,列表推导式的前半部分与前述 sigma 表达式非常接近:

def arctan(x,n):
    """Returns the arctangent of x using a series of n terms."""
    return sum([((-1)**(i-1)*(x**(2*i-1)))/(2*i-1) \
                              for i in range(1,n+1)])
print(arctan(1/1.732,10))

因此,经过 10 个项,我们得到了以下结果:

0.523611120446175

这非常接近a

收敛

数学家们希望简化 arctan 级数,以便使用以下事实轻松计算π

图 11.37:正切的三角函数

图 11.37:正切的三角函数

根据前述方程,arctan 的方程如下:

图 11.38:计算 arctan 的公式

图 11.38:计算 arctan 的公式

他们认为将x替换为 arctan 级数中的1会使计算π变得轻而易举。以下是前几个项:

图 11.39:将 x = 1 代入 arctan 级数

图 11.39:将 x = 1 代入 arctan 级数

这个表达式给出了π的近似值:

图 11.40:寻找π的近似值的方程

图 11.40:寻找π的近似值的方程

我们只需编写 sigma 右侧的代码,添加n的范围代码,并将它们相加:

for n in range(1,10):
    print(4*sum([((-1)**(i-1))/(2*i-1) for i in range(1,n+1)]))

我们可以在输出中显示逼近π的进展:

4.0
2.666666666666667
3.466666666666667
2.8952380952380956
3.3396825396825403
2.9760461760461765
3.2837384837384844
3.017071817071818
3.2523659347188767

这与π不太接近。跳过更高数量的项呢?让我们改变循环的代码:

for n in [100,1000,1000000]:

这是输出:

3.1315929035585537
3.140592653839794
3.1415916535897743

经过 100 万项计算,我们只得到了五位正确的小数。这个级数收敛到(即非常接近或趋向于)π/4,但速度太慢,无法实际使用。因此,几个世纪以来,数学家一直在寻找更好的级数来近似π。

练习 11.09:计算π的 10 个正确数字

在 1706 年,英国数学家和天文学家约翰·马钦使用他改进的级数计算了π的 100 位小数。以下是该级数:

图 11.41:一个 arctan 函数

图 11.41:一个 arctan 函数

使用前述 arctan 函数来计算π的 10 个正确数字。按照以下步骤进行:

  1. 只需调用我们的 arctan 函数。10 个项应该足够了:
print(4*(4*arctan(1/5,10)-arctan(1/239,10)))
  1. 运行上述代码以查看输出:
3.1415926535897922

使用 10 个项可以得到一个相当不错的近似值。它甚至给出了超过 10 个正确的数字。

注意

要访问此特定部分的源代码,请参阅packt.live/3dPjVvD

您也可以在packt.live/3dVlTKR上在线运行此示例。

练习 11.10:使用欧拉的表达式计算π的值

伟大的德国数学家欧拉提出了以下表达式:

图 11.42:欧拉的表达式

图 11.42:欧拉的表达式

使用这个表达式来近似π。它是否比调整后的 arctan 公式更快地收敛?

执行以下步骤:

  1. 以下是使用欧拉级数近似π的代码:
from math import sqrt
for n in [100,1000,1000000]:
    print(sqrt(6*sum([1/(i**2) for i in range(1,n+1)])))
  1. 它收敛得更快吗?运行前面的代码以查看输出:
3.1320765318091053
3.1406380562059946
3.1415916986605086

看起来它似乎并没有更快地收敛。100 万项后,您仍然只有五位正确的小数。

注意

要访问此特定部分的源代码,请参阅packt.live/2NRnnLD

您还可以在packt.live/38lHXgm上在线运行此示例。

20 世纪的公式

这是卓越的印度数学家拉马努金用来近似π的公式:

图 11.43:拉马努金近似π的公式

图 11.43:拉马努金近似π的公式

以下是如何在 Python 中编写该代码:

from math import sqrt, factorial
one_over_pi = 2*sqrt(2)/9801*sum([(factorial(4*k)*(1103+26390*k))/ \
              (((factorial(k))**4)*(396**(4*k))) for k in range(10)])
print(1/one_over_pi)

10 个项后的输出如下:

3.141592653589793

这非常准确!!

收敛区间

系列收敛(趋向于一个值)的值范围称为收敛区间。使用 Python,找到这个区间相当简单:您通过系列运行一些数字,如果它们变得无限大,它们就不在该区间内。如果它们产生一个数字,它们就在该区间内。例如,让我们看一个非常常见的教科书问题,并使用 Python 解决它。

练习 11.11:确定收敛区间-第 1 部分

确定以下幂级数的收敛区间:

图 11.44:幂级数

图 11.44:幂级数

执行以下步骤:

  1. 将总和输入 Python:
  def mystery_sum(x):
    return sum([(((-1)**n)*n)/(4*n)*(x+3)**n for n in \
                      range(1,1000000)])

由于我们不能使用数字“无穷大”,我们找到了从 n = 1 到 100 万的所有项的和。

  1. 运行所有从-10 到 10 的整数,看看是否收敛到一个数字:
for x in range(-10,11):
    print(x,mystery_sum(x))
  1. 当您运行此代码时,将会出现OverflowError
OverflowError: int too large to convert to float

这意味着一些数字变得很大,这正是我们预期的。我们需要添加一个条件,以便如果出现错误,它将简单地返回Infinity。这是通过try...except块完成的。

  1. 让我们告诉 Python 尝试一行代码。如果它抛出特定错误(在本例中是OverflowError),不要停止程序,而是执行以下操作:
def mystery_sum(x):
    try:
        return sum([(((-1)**n)*n)/(4*n)*(x+3)**n \
                                  for n in range(1,1000000)])
    except OverflowError:
        return "Infinity"
  1. 现在,输出给我们一些无穷大和一些实际值:
-10 Infinity
-9 Infinity
-8 Infinity
-7 Infinity
-6 Infinity
-5 Infinity
-4 249999.75
-3 0.0
-2 -0.25
-1 Infinity
0 Infinity
1 Infinity
...

看起来我们的收敛区间是-5 < x < -1。这意味着如果x在该区间内,我们可以使用该级数获得有用的值。否则,我们无法使用它。

注意

要访问此特定部分的源代码,请参阅packt.live/38k30A2

您还可以在packt.live/31AtmMU上在线运行此示例。

练习 11.12:确定收敛区间-第 2 部分

确定以下幂级数的收敛区间:

图 11.45:幂级数

图 11.45:幂级数

执行以下步骤:

  1. 在 Python 中定义总和:
def mystery_sum(x):
    try:
        return sum([n*x**n/(5**(2*n)) for n in range(1,10000)])
    except OverflowError:
        return "Infinity"

for x in range(-30,30):
    print(x,mystery_sum(x))
  1. 以下是一些输出:
-30 Infinity 
-29 Infinity 
-28 Infinity 
-27 Infinity 
-26 -1.0561866634327267e+174 
-25 -5000.0 
-24 -0.24989587671803576 
-23 -0.24956597222222246 
-22 -0.24898143956541371 
-21 -0.24810964083175827 
-20 -0.24691358024691298
...
18 9.18367346938776 
19 13.19444444444444 
20 19.999999999999993 
21 32.812499999999964 
22 61.11111111111108 
23 143.74999999999983 
24 599.9999999999994 
25 49995000.0 
26 5.3728208568640556e+175 
27 Infinity 
28 Infinity
29 Infinity

x在-25 和 25 之间的所有输出都保持很小(在 0 和 600 之间),无论我们使用多少项,因此我们将称之为收敛区间-25 < x < 25

注意

要访问此特定部分的源代码,请参阅packt.live/38pwuwC

您还可以在packt.live/2YS46jl上在线运行此示例。

练习 11.13:找到常数

在这个练习中,我们将在 Python 中表示一个无限级数并找到总和。我们将使用一个著名的常数,它被定义为该级数的和:

图 11.46:级数的总和

图 11.46:级数的总和

这个著名常数的值是多少?让我们按照以下步骤来确定这个值:

  1. 导入阶乘模块并将前述方程转换为 Python,如下所示:
from math import factorial
print(sum([1/factorial(n) for n in range(10000)]))
  1. 运行前面的代码以查看输出:
2.7182818284590455

著名的常数是e,自然对数的底数。

注意

要访问此特定部分的源代码,请参阅packt.live/2AoyubH

您还可以在packt.live/2BZ4aVw上在线运行此示例。

活动 11.01:寻找表面的最小值

机器学习中的一个重要任务是最小化函数。当您训练神经网络时,您正在改变矩阵或张量中的值,以查看哪些值提供了更好地逼近您的测试数据。在网络的每个值处,您都可以看到它对您的错误有多大贡献。这听起来像是表面上不同点的偏导数,不是吗?

这个例子是梯度下降的过程。让我们考虑一下,我们想要找到我们函数的最小值的位置。我们表面上的每个点都有一个偏导数,我们可以使用这些偏导数向更低的值移动一点。我们将从一个随机的地方开始,计算该点的偏导数,然后沿着降低z值的方向移动,也就是上下值。因此,如果z关于x的偏导数(我们称为dz_dx)是负的,这意味着z随着x的增加而减少,我们将希望向正x方向移动。如果dz_dx是正的,这意味着z随着x的增加而增加,所以我们将希望朝相反的方向移动,因此我们将向负x方向移动。我们将对y方向做同样的事情。这将如下所示:

图 11.47:下降到最小值的路径

图 11.47:下降到最小值的路径

这个活动的第一部分是创建一个找到表面最小点的函数。可以通过以下步骤编写此函数:

  1. 编写一个函数,该函数将在表面上创建一个随机的(x, y)位置。您可以调用random模块的uniform函数来生成这些值。

  2. 计算z关于xy的偏导数。

  3. 通过偏导数的负值乘以一个微小的step量来改变xy,以防偏导数很大。

  4. 计算这个新位置的偏导数,并保持循环,直到偏导数都非常小(小于 0.0001),或者位置偏离表面。

  5. 在一堆随机位置上运行函数,将最小z值保存到mins列表中。

  6. 最后,打印mins列表的最小值。

编写函数后,可以在已知值的函数上测试它,以验证它是否按预期工作。然后可以在不知道最小点的函数上运行它,以确定这个未知位置。具体步骤如下:

  1. 在表面上测试您的函数6。您的函数应该发现最小值为 0,在点(0, 0)处。

  2. 一旦您对您的函数有信心,就可以使用它来确定7的最小值,其中-1 < x < 5-1 < y < 5

您会发现,根据您的起始点,您的函数将收敛到不同的最小点 - 局部最小值和全局最小值。

注意

此活动的解决方案可在第 696 页找到。

总结

在上一章中,我们学习了导数和积分的力量,因此在本章中,我们基于这些工具来解决一些非常困难的问题,比如螺旋线的长度和三维表面的面积。我们甚至通过引入偏导数将导数和积分扩展到三维空间。在微积分课上,我们需要使用大量的代数来使用这些工具,但是通过使用 Python,我们对情况进行了建模并测试了我们的函数。我们创建了包含我们变化数值的变量,并在必要时循环计算数百万次。对于以前世纪的数学家来说,这似乎就像是某种魔法灯。

在下一章中,我们将处理更多的变化率和数量,并通过使用 Python 避免大量的代数。我们将找出一个不断变化的混合物中有多少盐,捕食者何时何地会捕捉到猎物,以及我们需要投资多长时间才能赚到 100 万美元。

FAB62

RUC47

第十二章:12.使用 Python 进行中级微积分

概述

在本章结束时,你将能够解决涉及变量变化的方程的问题。在本章中,你将使用数值方法来模拟人口和温度,并使用微分方程来计算它们的过去值或预测它们的未来值。当你知道数字在特定范围内时,你将学会使用二分搜索来猜测和检查,以获得非常准确的解决方案。你还将模拟物体移动的情况,并在给定它们的速度微分方程时解决它们的未来位置。

介绍

数学学生经常抱怨代数和几何中出现的问题没有现实世界的应用,比如因式分解多项式或者角的二等分,但对于微分方程却不能这样说。使用本章中将学到的工具,你将能够用微分方程模拟和解决物理、电子和工程中的真实问题。Python 是数学家和科学家的完美工具,他们想要进行数字计算和解决问题,但又不想为此再获得计算机科学学位。Python 是最受欢迎的编程语言之一,因为它易于使用且没有不必要的抽象。

到了 17 世纪,数学家们用数学方程模拟了物体的运动,并把目光投向了外层空间的行星。牛顿模拟了它们的运动,他提出的方程不仅涉及未知数,还涉及这些数字的变化。例如,他的方程不仅包含一个未知角度,还包含这个角度的变化(它的角速度),甚至是角度变化的变化(它的加速度)!当时没有工具来解决这些方程,所以他不得不自己发明这些工具。这些工具后来被称为微积分。

微分方程

在数学课上解方程通常涉及一个未知数x。方程隐藏了这个值,但给了你一些提示,告诉你如何找到这个值,比如1。但是要解微分方程,你只能得到关于函数的导数的信息,然后期望你找到这个函数。可能是像下面这样简单的东西:

图 12.1:找到导数为 2 的函数

图 12.1:找到导数为 2 的函数

这意味着找到一个导数为 2 的函数。这也可以写成如下形式:

图 12.2:表示函数导数的另一种方式

图 12.2:表示函数导数的另一种方式

通过简单的积分,我们可以找到适用于这个方程的函数,因为我们知道函数y = 2x的导数是 2。事实上,许多相关函数,比如y = 2x + 1y = 2x + 2y = 2x + 3等等,它们的导数都是 2。所以,我们写出一个一般形式,即y = 2x + C

当我们没有太多线索时,事情就变得更加复杂,就像在这个方程中:

图 12.3:函数值为函数本身的导数

图 12.3:函数值为函数本身的导数

这是要求一个导数为函数本身的函数。

为了理解微分方程是如何使用的,让我们从简单的函数开始,以及涉及到现实世界中的东西,比如金钱。

利息计算

微分方程研究中有一个关键工具起源于中世纪的利息计算研究。让我们来看下面的练习。

练习 12.01:计算利息

一个储蓄账户每年支付 2%的利息。如果投资了 3500 美元,5 年后账户中有多少钱?

简单利息的公式如下:

图 12.4:简单利息公式

图 12.4:简单利息公式

在这里,I是利息,P是本金或原始投资金额,r是利率或增长率,t是投资金额已投资的时间。根据这个公式,金额的利息为I = (3500)(0.02)(5) = 350 美元。按照以下步骤完成此练习:

  1. 这是一个很好的机会来开始一个程序,它将接受初始金额、利率和时间,并使用前面的公式输出利息收入:
def amount(p0,rate,t):
    """Returns the amount after t
    years starting at p0 and growing
    at the given rate per year"""
    return p0*rate*t
  1. 如您在amount函数的文档字符串中所看到的,它将接受一个起始金额和增长率,并返回给定年数后的投资金额。让我们看看 1-5 年内的利息收入:
for i in range(1,6):
    print(i,amount(3500,0.02,i))

以下是输出:

1 70.0
2 140.0
3 210.0
4 280.0
5 350.0

但这并不是利息的真正工作方式。每年几次,我们计算该年份的利息收入,将其加到本金中,新的本金更高。下一次的利息计算是在更高的数字上,因此称为复利。给定每年 n 次复利t年后的金额的公式如下:

图 12.5:计算 t 年后的金额公式

图 12.5:计算 t 年后的金额公式

  1. 让我们将amount函数更改为以下内容:
def amount(p0,rate,t,comps):
    """Returns the amount after t
    years starting at p0 and growing
    at the given rate per year
    compounded comps times per year"""
    for i in range(int(t*comps)):
        p0 += p0*rate/comps
    return p0

在这个函数中,我们添加了按复利次数给出的年份的利息收入。如果我们每年只计算一次复利,看起来是这样的:

for i in range(1,6):
    print(i,amount(3500,0.02,i,1))

这就是我们得到的:

1 3570.0
2 3641.4
3 3714.228
4 3788.51256
5 3864.2828112

因此,在 5 年结束时,我们赚了 364 美元,而不仅仅是 350 美元。即使利率相同,复利更频繁也会使金额增长更快。如果我们将复利更改为每年 12 次(每月复利),我们将在 5 年后得到 3867 美元,比年复利多一点。

注意

要访问此特定部分的源代码,请参阅packt.live/3dUWz7C

您也可以在packt.live/3iqUKCO上在线运行此示例。

练习 12.02:计算复利-第 1 部分

在一个年利率为 5.5%的储蓄账户中投资了 2000 美元,按月复利。要将金额增长到 8000 美元需要多长时间?按照以下步骤来解决这个问题:

  1. 我们将使用我们从上一个练习中的amount函数打印出投资的前 5 年:
for i in range(1,6):
    print(i,amount(2000,0.055,i,12))

输出如下:

1 2112.815720771071
2 2231.9951349686903
3 2357.8972049231984
4 2490.9011412619493
5 2631.4075450724245
  1. 5 年后,金额只有 2631 美元。要达到 8000 美元,我们必须走 20 或 30 年:
for i in [5,10,15,20,25,30]:
    print(i,amount(2000,0.055,i,12))

输出如下:

5 2631.4075450724245
10 3462.1528341320413
15 4555.167544964467
20 5993.251123444263
25 7885.343112872511
30 10374.775681348801

在 25 到 30 年之间的某个时候,我们将达到 8000 美元。更精确的方法是更聪明地猜测。

  1. 我们将范围减半,并根据我们得到的结果猜测更高或更低。例如,25 年和 30 年的平均值是 27.5,因此我们输入以下内容:
print(amount(2000,0.055,27.5,12))

以下是输出:

9044.814313545687

因此,我们将在 27.5 年内达到 9000 美元。达到 8000 美元的时间必须少于这个时间。

  1. 我们将计算 25 和 27.5 的平均值并将其代入:
def average(a,b):
    return (a+b)/2
print(amount(2000,0.055,average(25,27.5),12))

以下是输出:

8445.203624219383
  1. 让我们编写一个程序,直到找到答案为止。这称为二分搜索。让我们创建一个bin_search函数,它将使用我们正在使用的函数的名称,我们正在搜索的范围的下限和上限以及目标输出(在本例中为 8000)作为参数:
def bin_search(f,lower,upper,target):
    for i in range(20):
        avg = average(lower,upper)
  1. 这是关键的一行。它将平均值插入函数中,使用所有其他必需的参数,并将输出分配给guess变量。我们将检查该变量是否等于我们的目标,或者我们是否需要猜测更高或更低:
        guess = f(2000,0.055,avg,12)
        if guess == target:
            return guess
        if guess > target:
            upper = avg
        else:
            lower = avg
    return avg
  1. 我们将我们范围的下限和上限以及我们的目标数字代入我们的函数,以获得我们的近似值:
bin_search(amount,25,30,8000)

输出如下:

25.333333015441895
  1. 看起来我们将在25 年零 4 个月内达到 8000 美元。让我们检查一下:
amount(2000,0.055,25.334,12)

果然,复利后的余额超过了 8000 美元:

8030.904658737448

我们将再次使用二分搜索,但现在,让我们使用我们的代码来找到一个在微分方程中经常出现的相当重要的数学常数。

注意

要访问此特定部分的源代码,请参阅packt.live/3iq95PV

您还可以在packt.live/2BpdbHI上在线运行此示例

练习 12.03:计算复利-第 2 部分

如果您以 100%的利率投资$1,连续复利 1 年,您会赚多少?

请记住,复利的频率越高,最终金额就越高。您认为会是多少?$1.50?$2?本金、利率和时间都是 1,但comps变量是什么?按照以下步骤完成这个练习:

  1. 为了近似连续复利,我们将每秒复利一次(每年365246060*次):
print(amount(1,1,1,365*24*60*60))

输出如下:

2.7182817853606362

大约是$2.72。这个数字,2.71828…,是自然对数的底数e。它对于模拟自然界中的人口非常有用,因为动物、植物和微生物不会等到月底才繁殖-它们是持续不断地繁殖。因此,当利息连续复利或人口自然增长时,我们将使用这个公式:

图 12.6:计算复利的公式

图 12.6:计算复利的公式

  1. 让我们创建一个函数来快速完成这个任务。首先,我们需要从math模块中导入e以进行连续复利:
from math import e
  1. 创建一个pert函数,它将插入初始金额或人口、增长率和时间,并返回最终金额:
def pert(P,r,t):
    return P*e**(r*t)

我们将在本章中多次使用这个函数。现在,让我们回答更多的投资问题。

注意

要访问此特定部分的源代码,请参阅packt.live/2D2Q1r0

您还可以在packt.live/31G5pDQ上在线运行此示例

练习 12.04:计算复利-第 3 部分

一个人以每月复利的 18%年利率借了$5,000。1 年后这个人会欠多少钱?按照以下步骤完成这个练习:

  1. 我们可以将其放入我们的函数调用中:
amount(5000,0.18,1,12)

输出如下:

5978.090857307678

为了比较,让我们看看如果利息是连续复利会发生什么。

  1. 我们将使用我们的pert函数输入P = 5000r = 0.18t = 1作为值:
print("Continuous:",pert(5000,0.18,1))

得到的金额如下:

5986.096815609051

注意

要访问此特定部分的源代码,请参阅packt.live/31ES5Qi

您还可以在packt.live/3f5j0s4上在线运行此示例

练习 12.05:成为百万富翁

如果您以每日复利 8%的年利率投资$1,000,要成为百万富翁需要多长时间?如果初始金额是$10,000 呢?按照以下步骤完成这个练习:

  1. 首先,让我们定义bin_search函数,如下所示:
def bin_search(f,lower,upper,target):
    for i in range(20):
        avg = average(lower,upper)
        #Be sure to change this line
        #if the principal, rate or
        #compounding changes:
        guess = f(1000,0.08,avg,365)
        if guess == target:
            return guess
        if guess > target:
            upper = avg
        else:
            lower = avg
    return avg
  1. 让我们猜一些野生的猜测,看看如果$1,000 投资这些年,我们会得到多少:
for i in [10,20,30,40,50]:
    print(i,amount(1000,0.08,i,365))

这是输出:

10 2225.34584963113
20 4952.164150470476
30 11020.277938941583
40 24523.929773205105
50 54574.22533744746
  1. 50 年后,您仍然只有$54,000,而不是一百万。但是 100 年后,您将拥有近 300 万:
amount(1000,0.08,100,365)

这是输出:

2978346.0711824815
  1. 答案必须在 50 和 100 之间。看起来是我们二分搜索的任务:
print(bin_search(amount,50,100,1000000))

我们得到以下输出:

86.3588809967041
  1. 这表明在 86.36 年后,我们将拥有 100 万美元。如果初始投资是$10,000,那么在bin_search函数中更新guess变量:
        guess = f(10000,0.08,avg,365)

这是我们将打印所需输出的方法:

for i in [10,15,20,30,40,50,60]:
    print(i,amount(10000,0.08,i,365))

输出如下:

10 22253.458496311334
15 33196.803971077774
20 49521.64150470513
30 110202.77938941623
40 245239.2977320514
50 545742.2533744735
60 1214465.2585152255
  1. 因此,我们在 50 到 60 年之间就能达到 100 万美元。让我们在我们的二分搜索函数中将1000改为10000并检查一下:
print(bin_search(amount,50,60,1000000))

我们得到以下输出:

57.57260322570801

超过 57.57 年才能达到一百万美元。

因此,我们开始学习微分方程是通过研究复利来学习的。一定金额的钱每年、每月或每天都会有利息率。初始金额

注意

要访问此特定部分的源代码,请参阅packt.live/31ycoPg

您也可以在packt.live/2NMT9sX上在线运行此示例

现在,我们将同样的推理扩展到人、动物、细菌和热量的数量,这些数量不断变化,或者说是连续的。

人口增长

微分方程对于找到特定时间的人口、动物和细菌的数量的公式非常有用;例如:

图 12.7:计算时间 t 的微分方程

图 12.7:计算时间 t 的微分方程

这个微分方程意味着y的变化速率与y成比例,或者说人口的增长与其数量成比例。这就是人口增长率的定义:人口的一部分或百分比。解决方案类似于涉及连续复利的利息问题:

图 12.8:计算变化率的微分方程

图 12.8:计算变化率的微分方程

练习 12.06:计算人口增长率-第 1 部分

在 1980 年代,肯尼亚的年人口增长率为 4%。以这个速度,人口翻倍需要多长时间?按照以下步骤完成这个练习:

  1. 无论初始人口是多少,我们都在寻找使因子ert 等于 2 的t。我们可以使用我们的pert函数和二项式搜索函数,稍作调整:
def bin_search(f,lower,upper,target):
    for i in range(40):
        avg = average(lower,upper)
        guess = f(1,0.04,avg)
        if guess == target:
            return guess
        if guess > target:
            upper = avg
        else:
            lower = avg
    return avg
  1. 我们正在寻找时间t,以 4%的增长率将我们的初始人口从 1 增加到 2。我们估计这个时间会在 1 到 100 年之间:
print(bin_search(pert,1,100,2))

输出如下:

17.32867951408025

我们可以用代数来验证这一点。我们取方程两边的对数并解出t

图 12.9:解决时间(t)的方程

图 12.9:解决时间(t)的方程

  1. 这意味着在 17 年多一点的时间内,肯尼亚的人口将翻倍。我们可以用我们的amount函数来验证这一点。1989 年,肯尼亚的人口为 2100 万:
print(amount(21000000,0.04,17.3,1000000))

以下是输出:

41951845.46179989

是的,每年使用一百万次复利,人口在 17.3 年内增长到了将近 4200 万。

作为对此的回应,肯尼亚政府大力推广计划生育。这有效果吗?

注意

要访问此特定部分的源代码,请参阅packt.live/2BxsfCT

您也可以在packt.live/2Zuoy9c上在线运行此示例

练习 12.07:计算人口增长率-第 2 部分

2010 年,肯尼亚的人口为 4200 万。到 2019 年,人口为 5250 万。该范围的年人口增长率是多少?

我们可以再次使用二分搜索函数,返回给定初始人口(以百万计)、时间t和 9 年后的目标人口(以百万计)的增长因子r

bin_search函数中,将时间更改为9

        guess = f(1,avg,9)

然后,我们将找到这 9 年的年增长率。我们知道它在 0 到 2 之间:

print(bin_search(pert,0,2,52.5/42))

打印出的值如下:

0.024793727925498388

计划生育项目一定是有效的!肯尼亚将其人口增长率降低到了 2.5%。

注意

要访问此特定部分的源代码,请参阅packt.live/3eWKzDW

您也可以在packt.live/31EKPUq上在线运行此示例

放射性材料的半衰期

与人口问题类似,半衰期问题涉及一个群体,但其中一半是放射性材料的原子,其中一半的原子随着时间变成了不同物质的原子。例如,碳-14 衰变成氮-14,大约需要 5730 年才能使一半的碳衰变。这使得放射性碳测年成为从考古学到检测伪造艺术品的关键工具。

练习 12.08:测量放射性衰变

镭-226 的半衰期为 1600 年。在给定样本中,800 年内将消失多少镭?

意思是“物质的衰变速率与物质的数量成比例”的微分方程表达如下:

图 12.10:用于计算物质衰变速率的微分方程

图 12.10:用于计算物质衰变速率的微分方程

解决方案与我们的人口问题类似,只是衰减因子是负的,因为数量减少:

图 12.11:使用负衰变因子计算变化率

图 12.11:使用负衰变因子计算变化率

这意味着最终金额等于时间的初始金额,e,乘以衰减因子,r,和时间,t的乘积。我们可以像解决人口问题一样使用我们的二分搜索函数。我们正在寻找在 1,600 年内使我们的人口减半的增长率r。按照以下步骤完成这个练习:

  1. bin_search函数中的guess =行中将t更改为1600
        guess = f(1,avg,1600)
  1. 然后,搜索增长因子,我们认为它将在-2 和 0 之间。我们的目标金额是起始金额的一半:
print(bin_search(pert,-2,0,0.5))

以下是输出:

-0.0004332169864937896
  1. 这就是镭-226 的衰变因子r。我们要做的就是将其插入我们的pert函数中,以找出 800 年后剩下的样本的百分比:
pert(1,-0.0004332,800)

以下是输出:

0.7071163910309745

因此,大约 71%的样本在 800 年后仍然存在。

注意

要访问此特定部分的源代码,请参阅packt.live/2YSzQ84

您还可以在packt.live/2ByUwJj上在线运行此示例

练习 12.09:测量历史文物的年龄

对一块布进行了放射性碳测年。这意味着科学家们测量了有多少碳-14(半衰期 5730 年)衰变成了更稳定的同位素。他们发现剩下的碳-14 的数量是碳-13 的 10 倍。这块布有多大年龄?

如果碳-14 需要 5730 年使其数量减半,我们需要找到我们的 Pert 公式的速率r

图 12.12:Pert 公式

图 12.12:Pert 公式

按照以下步骤完成这个练习:

  1. 我们使用我们的二分搜索函数来解决r
def bin_search(f,lower,upper,target):
    for i in range(40):
        avg = average(lower,upper)
  1. 这是更改的那一行。如果我们在pert函数中放入一个起始金额为1r将是avg5730将是目标时间:
        guess = f(1,avg,5730)
        if guess == target:
            return guess
        if guess > target:
            upper = avg
        else:
            lower = avg
    return avg
print(bin_search(pert,-2,0,0.5))

以下是输出:

-0.00012096809405193198

r = -0.000120968,因此我们的 Pert 公式变为如下:

图 12.13:在 Pert 公式中替换 r 的新值

图 12.13:在 Pert 公式中替换 r 的新值

这意味着x克的碳-14 衰变了,剩下的是 10x 克,是整个样本的 10 倍。因此,衰变量是整个样本的 1/11 或 0.091。结束金额是 1-0.091。这使我们的 Pert 方程如下:

图 12.14:带有结束金额的 Pert 方程

图 12.14:带有结束金额的 Pert 方程

  1. 我们方程中唯一未知的是t,因此我们正在更改我们的bin_search函数,以便有策略地猜测和检查正确的t。返回到您的bin_search函数;开头应该是这样的:
def bin_search(f,lower,upper,target):
    for i in range(40):
        avg = average(lower,upper)
  1. 这是我们正在更改的行。我们将 1 代入原始量,长小数是我们的r,时间范围的平均值用于时间。目标是样本的 0.091,这将保持猜测和平均值,直到返回确切的年数以达到目标值:
        guess = f(1,-0.000120968,avg)
        if guess == target:
            return guess
  1. 由于它是一个递减函数,如果猜测小于目标值,我们将会超过目标值,upper数字将被替换为平均值:
        if guess < target:
            upper = avg
        else:
            lower = avg
    return avg
print(bin_search(pert,1,100000,0.91))
print(pert(1,-0.000120968,5730))
  1. 请注意,我们更改了if guess < target:行。我们正在寻找从 1 到 0.91 的衰减量所需的年数,以给定的速率。我们怀疑它在 1 到 100,000 年之间。第二个print行只是检查我们的pert函数确认在 5,730 年后,剩余量正好是原始量的一半。当我们运行代码时,这是输出:
779.633287019019
0.5000002702800457

根据我们的计算,这块布大约有780 年的历史。

因此,我们最初编写此代码是为了测量投资中剩余的金额,该投资以给定的利率增长一段时间。在本节中,我们将此应用于物体中放射性物质的剩余量,该物质以已知速率衰变,时间未知。这就是科学家计算考古文物年龄的方法。

注意

要访问此特定部分的源代码,请参阅packt.live/3eOJJJv

您也可以在packt.live/38mESgn上在线运行此示例。

接下来,我们将使用相同的思路,但将其应用于物体的温度变化,比如一杯咖啡或人体的温度。

牛顿冷却定律

你是否曾经想过在警察节目中带着乳胶手套的犯罪现场调查员CSI)如何判断受害者的死亡时间?艾萨克·牛顿被认为是发现物质冷却遵循微分方程的人:

图 12.15:温度变化速率的微分方程

图 12.15:温度变化速率的微分方程

看看这个微分方程与我们以前看到的微分方程略有不同?这不是物质温度变化速率与物质温度成比例,而是说“物质温度变化速率与物质温度与环境温度之间的差值成比例”。因此,如果一杯热咖啡放在热的房间里,它的温度变化速度会比放在非常冷的房间里慢。同样,我们知道警察节目中受害者的体温起始温度:98.6°F。

练习 12.10:计算死亡时间

一名调查员到达犯罪现场并测量环境和尸体的温度。如果环境温度为 65°,尸体温度为 80°,调查员记录时间并等待一小时。尸体温度与环境温度的差为 15 度。一个小时后,环境温度仍为 65°,尸体进一步冷却至 75°。温度差现在为 10 度。受害者是何时死亡的?

有了这些信息,她可以建立以下方程:

图 12.16:计算死亡时间的方程

图 12.16:计算死亡时间的方程

按照以下步骤完成这个练习:

  1. 我们可以使用二分搜索来找出温度的衰减率。我们需要导入e并确保我们有pertaverage函数:
from math import e
def pert(P,r,t):
    return P*e**(r*t)
def average(a,b):
    return (a+b)/2
  1. 我们的bin_search函数的第一部分与以前相同:
def bin_search(f,lower,upper,target):
    for i in range(40):
        avg = average(lower,upper)
  1. 这里的重要变化是:我们的原始量(温度差)为 15 度,我们想知道r,即我们 Pert 公式中的变化率:
        guess = f(15,avg,1)
        if guess == target:
            return guess
        if guess > target:
            upper = avg
        else:
            lower = avg
    return avg
print(bin_search(pert,-2,0,10))

这是输出:

-0.4054651081078191

这就是这种情况的衰减速率,所以我们知道了尸体温度与环境温度之间的初始差异(98.6-65),以及最终差异(10)和衰减速率。这是情况的图表:

图 12.17:冷却体的图表

图 12.17:冷却体的图表

我们只需要知道差异衰减到 10 所需的小时数。我们的方程如下:

图 12.18:差异衰减到 10 所需的小时数

图 12.18:差异衰减到 10 所需的小时数

  1. 我们改变我们的二分搜索函数来获取时间:
def bin_search(f,lower,upper,target):
    for i in range(40):
        avg = average(lower,upper)
        guess = f(33.6,-.4055,avg)
        if guess == target:
            return guess
        if guess > target:
            upper = avg
        else:
            lower = avg
    return avg

但是,如果时间太短,差异将太大。绕过这个最简单的方法是将更高的t作为函数调用的较低部分,将较低的t作为搜索范围的上限

  1. 调查员推断时间必须在 0 到 5 小时之间:
print(bin_search(pert,5,0,10))

输出将如下:

2.9887570258370033

几乎正好 3 小时。这看起来接近前图中曲线的y值为 10 的时间。

  1. 让我们在我们的pert函数中检查一下。从r = -0.4055t = 3.0开始,差异为 33.6 度。希望最终得到 10:
pert(33.6,-0.4055,3)

以下是输出:

9.954513505592326

所以,现在,当明星侦探在凌晨 2:30 到达现场时,调查员可以说,“死亡时间大约是晚上 11:30。”

注意

要访问此特定部分的源代码,请参阅packt.live/38jN68K.

您还可以在packt.live/3gefegi.上在线运行此示例

练习 12.11:计算温度变化的速率

一杯温度为 175°F 的咖啡放在一个 72°F 的房间里。我们等待 15 分钟,测量咖啡的温度,发现它已经变成了 140°F。按照这个速度,从开始算起 1 小时后它的温度会是多少?按照以下步骤完成这个练习:

  1. 差异从 103°(175-72)开始。在 0.25 小时内,它变为 68°(140-72)。现在,我们可以建立一个方程:图 12.19:计算咖啡温度差异的方程

图 12.19:计算咖啡温度差异的方程

  1. 我们可以改变我们的二分搜索函数以反映这种情况。将guess=行更改为bin_search函数中的以下内容:
        guess = f(103,avg,0.25)
  1. 运行它,找出在-2 和 0 之间的r将给我们带来 68°的差异:
print(bin_search(pert,-2,0,68))

这是输出:

-1.6608851322143892
  1. 太快了!将其放入我们的 Pert 公式中,P = 103t=1
pert(103,-1.6608851322143892,1)

以下是输出:

19.566987911888482

这是 1 小时的差异。如果房间温度为 72°,那意味着咖啡将是72 + 19.5 = 91.5°

注意

访问此特定部分的源代码,请参阅packt.live/3gl5p0i.

您还可以在packt.live/2YTdCmw.上在线运行此示例

混合问题

在代数中,有一些需要计算的文字问题,您必须计算出需要添加多少材料到混合物中才能获得特定的浓度或数量。在微积分中,自然,问题必须更难:例如,混合物正在改变;材料正在进入混合物,材料正在流出。您必须找出在特定时间后有多少混合物或溶剂。让我们看看以下练习,以更好地理解这个概念。

练习 12.12:解决混合问题-第 1 部分

一个罐子里含有 82 加仑的卤水,其中溶解了 18 磅的盐。每分钟以 5 加仑的速度流入罐子的卤水中含有每加仑 3 磅的溶解盐。这种混合物通过搅拌保持均匀,以每分钟 2 加仑的速度流出罐子。39 分钟后罐子里有多少盐?

正如你可以想象的,这种问题会导致一些复杂的微分方程,只有经过几页的代数运算,你才能得到一个方程(通常涉及到* e*的某个幂),然后你可以将时间代入并得到最终的数量。然而,使用编程,我们只需从给定的起始溶液开始,并添加和减去问题所需的任何材料。这只是一个跟踪溶液和溶质的问题。按照以下步骤完成这个练习:

  1. 让我们创建一个函数,以找出在t分钟后的盐含量,给定我们的初始条件:
def salt_content(t):
    salt = 18 #pounds
    brine = 82 #gallons
  1. 然后,每分钟都会添加 5 加仑的卤水,其中含有 15 磅(每加仑 3 磅盐每加仑)的盐:
    for i in range(t):
        brine += 5
        salt += 15
  1. 现在,每分钟流出 2 加仑的卤水,但其中含有多少盐呢?这要求我们找出每加仑卤水的浓度:图 12.20:计算每加仑卤水浓度的公式

图 12.20:计算每加仑卤水浓度的公式

这可以很容易地转换为代码,如下所示:

        concentration = salt/brine
  1. 因此,每分钟离开罐子的盐将是流出的溶液加仑数乘以盐的浓度:
        salt_out = 2*concentration
        salt -= salt_out
        brine -= 2
  1. 循环结束后,我们可以打印出卤水和盐的最终数量:
    print(i,brine,salt)
  1. 为了解决我们的问题,我们只需运行我们的salt_content函数,t=39
salt_content(39)

输出如下:

38 199 469.2592152141211

这意味着在 39 分钟后,我们最终得到 469 磅盐。这个数字非常接近解析解,但并不完全相同。我们该怎么做才能得到更准确的结果呢?记住,自然对数的底数e的背后思想是模拟值的恒定变化,而我们只是每分钟计算一次我们溶液的变化。

  1. 让我们引入一个名为frac的变量,它将让我们计算分钟的变化:
def salt_content(t,frac=0.001):
    salt = 18 #pounds
    brine = 82 #gallons
  1. 参数中的frac=0.001值表示我们将每分钟计算一千次变化。这意味着我们将循环的次数乘以 1,000,或者 1/frac,我们将我们的数量变化乘以frac
    for i in range(int(t/frac)):
        brine += 5*frac
        salt += 15*frac
        concentration = salt/brine
        salt_out = 2*concentration*frac
        salt -= salt_out
        brine -= 2*frac
    print(i,brine,salt)
salt_content(39)

输出变成了以下内容:

38999 198.99999999966812 470.74539697793307

470.7 磅盐甚至更接近解析解,使用更小的分钟分数并不会改变输出太多。

注意

要访问此特定部分的源代码,请参阅packt.live/2BlX2Tn

您也可以在packt.live/3dSrEcm上在线运行此示例。

让我们在其他问题上使用这个函数。

练习 12.13:解决混合问题-第 2 部分

一个罐子里含有 10,000 升浓度为每 100 升 1 千克盐的卤水溶液。每秒以 20 升的速度流入罐中含有每 100 升 2 千克盐的卤水。混合物(均匀)以每秒 10 升的速度流出。找出在 5 分钟内罐中有多少盐。按照以下步骤完成这个练习:

  1. 因此,我们需要进行一些简单的算术运算来找出我们的初始盐量,但是每 100 升 1 千克盐是 10,000 升中的 100 千克盐,而流入罐中的 20 升中是 0.4 千克盐。这是我们的新函数:
def salt_content(t,frac=.001):
    salt = 100
    brine = 10000
    for i in range(int(t/frac)):
        brine += 20*frac
        salt += 0.4*frac
        concentration = salt/brine
        salt_out = 10*concentration*frac
        salt -= salt_out
        brine -= 10*frac
    return salt

现在,让我们调用salt_content函数:

print(salt_content(5*60))

当我们调用函数时,输出如下:

183.0769053279811

(记住,我们所有的数字都是以秒为单位的,我们想要 5 分钟,因此是5*60参数。)

输出告诉我们,在 5 分钟内溶液中有 183 千克盐。这非常接近解析解。

  1. 我们可以通过将硬编码的数字更改为变量来简化我们的任务,因此当我们遇到不同初始卤水量的问题时,例如,我们只需在函数调用中输入不同的数字。我们需要变量来表示初始卤水量(或任何溶液)、溶质的初始量(到目前为止,我们一直在使用盐)、卤水的流入速度、盐的流入速度和卤水的流出速度。以下是如何更改函数的方法:
def salt_content(t,salt_0,brine_0,salt_in,brine_in,v_out,frac=.001):
    salt = salt_0 #pounds
    brine = brine_0 #gallons
    for i in range(int(t/frac)):
        brine += brine_in * frac
        salt += salt_in* frac
        concentration = salt/brine
        salt_out = v_out*concentration* frac
        salt -= salt_out
        brine -= v_out* frac
    return salt
  1. 现在,要解决最后一个问题,我们的函数调用将有更多的参数:
salt_content(300,100,10000,0.4,20,10)

输出如下:

183.0769053279811

如您所见,输出应与步骤 1中的相同。让我们将其应用到更多问题上。

注意

要访问此特定部分的源代码,请参阅packt.live/3gkTWOd.

您还可以在packt.live/3eSWF17.上在线运行此示例

练习 12.14:解决混合问题-第 3 部分

一个大桶中含有 100 升糖水混合物,含有 900 克糖。每分钟以 5 克糖每升的速度进入大桶的糖水混合物为 2 升。另一个含有每升 10 克糖的混合物以每分钟 1 升的速度流入大桶。大桶保持混合,所得的混合物以每分钟 3 升的速度从大桶中排出。在 1 小时内找出大桶中的糖量。按照以下步骤完成此练习:

  1. 这里唯一的诀窍是总溶液进入速度为每分钟 3 升,总溶质进入速度为每分钟 20 克。以下是函数调用:
salt_content(60,900,100,20,3,3)
  1. 输出将如下所示:
705.2374486274181

溶质的量为 705 克。

注意

要访问此特定部分的源代码,请参阅packt.live/2YRWNIl.

您还可以在packt.live/2YRWKfD.上在线运行此示例

练习 12.15:解决混合问题-第 4 部分

如果我们添加纯水会怎样?这会让它更难还是更容易?让我们试试这个。

一个罐子中含有 1200 升水和 18 克盐的卤水混合物。淡水以每分钟 15 升的速度进入罐子,并且罐子被搅拌以保持均匀。一根管子以每分钟 10 升的速度排出混合物。15 分钟后罐子中有多少盐?按照以下步骤完成此练习:

  1. 我们可以使用我们的salt_content函数,但变量将设置为0。这使得以下函数调用:
print(salt_content(15,18,1200,0,15,10))
  1. 15 分钟后的盐含量输出如下:
15.944648402124784

盐含量从 18 克减少到 15.9 克。

注意

访问此特定部分的源代码,请参阅packt.live/2ZsLTIs.

您还可以在packt.live/2AnLrT8.上在线运行此示例

因此,我们已经看到了通常需要大量代数操作才能找到情况的方程的微分方程的几个主题,以便(大概)我们可以插入一个变量并获得所寻找的温度、位置或数量。使用 Python 进行建模和运行模拟已经为我们节省了大量的代数,并且仍然为我们提供了非常准确的答案。

欧拉方法

在大学数学课程中,您学习了所有这些代数方法来求导数和积分以及解决微分方程。我们没有提到拉普拉斯变换,这是解决微分方程的更复杂的方法。现在,关于微分方程的肮脏秘密是,除非您主修工程学,否则学校不会告诉您的是,您在现实生活中遇到的大多数微分方程都没有解析解

好消息是,数百年来一直有避免混乱代数的数值方法,随着计算机的发明,这些方法已经成为标准。即使存在解析解,数值方法对于实际目的几乎与解析方法一样准确,并且只需花费一小部分时间即可获得解决方案。

欧拉方法的思想非常简单:

  1. 从已知点开始。

  2. 使用微分方程在此点计算导数。这是曲线在此点处的方向。

  3. 向计算出的方向迈出一小步。

  4. 重复直到达到所需范围的末尾。

练习 12.16:使用欧拉方法解决微分方程

给定微分方程2。您想知道在特定值x处函数y=f(x)的输出。您在图上给出了一个点:f(0) = 1。这意味着,“在每个点上,这个函数的导数是该点的 y 值。”请记住,导数是图上的点朝向或方向。欧拉方法是从初始值开始,即在这种情况下,在(0,1),并使用微分方程计算到下一个点的方向。微分方程DE)规定斜率是y值,因此我们在正x方向上迈出一小步:

图 12.21:朝着正确方向迈出小步(希望如此)

图 12.21:朝着正确方向迈出小步(希望如此)

导数如下:

图 12.22:函数的导数

图 12.22:函数的导数

因此,ΔY变为以下内容:

图 12.23:计算ΔY 的公式

图 12.23:计算ΔY 的公式

这是导数和步长的乘积。要找到下一个y值,我们将ΔY添加到先前的y值。在新点上,我们重复这个过程:计算这一点的函数斜率,乘以步长,然后加到当前的y值上。按照以下步骤进行:

  1. 让我们编写一个 Python 函数来做到这一点:
def euler(x0,y0,target_x,stepsize):
    x,y = x0,y0
    while x<target_x:
        slope = y #from diff eq
        x += stepsize
        y += stepsize*slope
        print(x,y)
    return y
  1. 因此,我们知道初始的xy。我们想知道x=2时的y;步长可以是½:
print(euler(0,1,2,0.5))

以下是输出:

0.5 1.5
1.0 2.25
1.5 3.375
2.0 5.0625
5.0625
  1. 我们不再需要euler函数内的print语句,因此将其注释掉:
        #print(x,y)
  1. 第一行是计算斜率的结果,即y值 1,乘以步长½,然后向上移动该距离。如果导数为负,我们将向下移动。在第二行,我们将y值 1.5 乘以步长 0.5,得到 0.75。我们从 0.75 上升到 2.25 等等。在 x 方向上采取小步骤,直到达到目标 x 值 2,我们最终得到y值 5.0625。我们不再需要打印出每一步,但让我们将步长减半 10 次:
for n in [0.5**i for i in range(10)]:
    print(n,euler(0,1,2,n))

以下是输出:

1.0 4.0
0.5 5.0625
0.25 5.9604644775390625
0.125 6.583250172027423
0.0625 6.958666757218805
0.03125 7.166276152788222
0.015625 7.275669793128417
0.0078125 7.3318505987410365
0.00390625 7.3603235532692795
0.001953125 7.374657160341845

因此,步长越小,我们似乎越接近 7.37。这是近似路径的图形:

图 12.24:使用较小的步长获得更好的近似

图 12.24:使用较小的步长获得更好的近似

第四条曲线(右侧的曲线)是我们近似路径的步长为 1 的路径。第三个图形的步长为½,第二条曲线为¼,第一条曲线为 1/8。我们选择a微分方程,因为我们知道代数解。

x为 2 时,e2 = 7.389。添加y=ex*的实际曲线(左侧的第一条曲线),我们可以看到步长越小,近似值越接近实际曲线:

图 12.25:实际曲线添加到第一条曲线的左侧

图 12.25:实际曲线添加到第一条曲线的左侧

但是最后的近似值,步长为 0.001953125,需要在 0 和 2 之间进行 1,024 步。很容易理解为什么在计算机发明之前,欧拉方法不如代数方法受欢迎。

注意

要访问此特定部分的源代码,请参阅packt.live/2VEQiaa

您还可以在packt.live/2ByZvtv上在线运行此示例。

练习 12.17:使用欧拉方法评估函数

初始值问题IVP)上使用欧拉方法和步长 0.001:

图 12.26:欧拉方法在初始 VP 上

图 12.26:欧拉方法在初始 VP 上

在这里,y(0) = 1,以便计算近似解y(x),当x=0.3时:

  1. euler函数中,在slope=行中输入微分方程:
def euler(x0,y0,target_x,stepsize):
    x,y = x0,y0
    while x<target_x:
        slope = x+y**2 #from diff eq
        x += stepsize
        y += stepsize*slope
    return y
  1. 在函数调用中输入适当的参数:
print(euler(0,1,0.3,0.001))

输出应该如下所示:

1.48695561935322

这意味着通过从我们已知的点(0,1)开始,按微分方程指定的方向迈出微小步骤,我们能够预测 1.49 是对应于 x 值 0.3 的近似y值。

注意

要访问此特定部分的源代码,请参阅packt.live/3inHj6S.

您也可以在packt.live/2VFLEbF.上在线运行此示例

Runge-Kutta 方法

由于 Euler 方法仅基于每个点的导数,它存在一个问题,即始终超出或低于真实曲线。毫不奇怪,在 Euler 方法被发明的几个世纪以来,已经对其进行了改进以抵消其缺点。其中一种改进是Runge-KuttaRK)方法,它将四个近似值平均在一起,其中之一是 Euler 方法,使用区间的开始,另一个使用区间的结束,另外两个近似值使用区间的中点。当这些近似值平均在一起时,中点的近似值被赋予更高的权重。

以下是当 DE 给出时的方程,f(x,y),起始xyx0 和y0,以及步长h

图 12.27:给出 f(x,y)时的方程

图 12.27:给出 f(x,y)时的方程

对于下一个y,我们将前面四个近似值平均在一起,k2 和k3 的权重加倍:

图 12.28:对前 4 个近似值进行平均的公式

图 12.28:对前 4 个近似值进行平均的公式

然后,当然,x增加了h

图 12.29:将 x 增加 h

图 12.29:将 x 增加 h

这是一大堆代码,但它的功能令人印象深刻。

练习 12.18:实现 Runge-Kutta 方法

在 IVP 上使用 Runge-Kutta 方法和步长 0.2:

图 12.30:步长为 0.2 的 Runge-Kutta 方法

图 12.30:步长为 0.2 的 Runge-Kutta 方法

  1. 首先,我们定义微分方程。让我们称之为deriv(x,y)
def deriv(x,y):
    return x**2 + y**2
  1. 现在,我们将定义 Runge-Kutta 方法,称之为rk4
def rk4(x0,y0,target_x,h):
    while x0 <= target_x:
        print(x0,y0)
        k1 = h*deriv(x0,y0)
        k2 = h*deriv(x0 + h/2, y0 + k1/2)
        k3 = h*deriv(x0 + h/2, y0 + k2/2)
        k4 = h*deriv(x0 + h, y0 + k3)
        #These are the values that are fed back into the function:
        y0 = y0 + (1/6)*(k1 + 2*k2 + 2*k3 + k4)
        x0 = x0 + h
  1. 当我们从y(0) = 0开始,并且我们想要使用步长为 0.2 来计算y(1)时,这就是我们所说的:
rk4(0,0,1,0.2)

我们的进展如下打印出来:

0 0
0.2 0.0026668666933346665
0.4 0.021360090381533078
0.6 0.0724512003541295
0.8 0.17409018097333867
1.0 0.35025754914481283
  1. 使用相同的步长解决相同的问题,但使用 Euler 方法的准确性较低。在euler函数中,将slope=行更改为匹配新微分方程:
        slope = x**2 + y**2
  1. 现在,我们使用 Euler 方法打印出解决方案:
print(euler(0,0,1,0.2))

以下是输出:

0.2428567456277198

这与 Runge-Kutta 解决方案并不十分接近。然而,在计算机出现之前,Runge-Kutta 改进可能更有用,因为我们可以简单地减小 Euler 方法中的步长并获得更好的近似值。这是步长为 0.001 的 Euler 方法的相同输出:

print(euler(0,0,1,0.001))

以下是输出:

0.34960542576393877

这只是对用于解方程的数值方法的简要介绍,不是通过代数来解决,而是通过将起始点输入计算机程序并按微分方程指示的方向迈出小步来解决。这是微积分的一个庞大领域,特别是现在免费软件和编程语言,再加上快速的计算机处理器,使以前费力的计算变得轻松。

注意

要访问此特定部分的源代码,请参阅packt.live/3eWxF95.

您也可以在packt.live/3dUlkkg.上在线运行此示例

追踪曲线

微积分中的一个重要话题是追踪曲线,这是一个代理追逐移动目标所经过的路径。由于追逐者直接朝向目标移动,然后目标移动,这种情况导致了各种微分方程。代数可能会变得非常丑陋,这就是微积分教授喜欢这个话题的原因。然而,正如我们所知,微分方程通常是关于寻找一般代数解的,也就是一个函数,而不是一个数字。理论上,我们可以将值代入函数中,以找到特定时间的粒子位置或房间的温度。使用 Python,我们通过对情况进行建模并找到数值解来跳过代数步骤。我们在一般性上失去了一些东西,但在计算的便利性上获得了一些东西。

练习 12.19:找到捕食者捕捉猎物的位置

一只兔子从(0,0)开始,以每秒 1 单位的正y方向奔跑。一只狐狸从(20,0)开始追逐兔子,奔跑速度是兔子的 1.5 倍。狐狸在什么y值处追到兔子?

执行以下步骤:

  1. 首先,我们需要从math模块中获取一些函数来测量距离和角度:
from math import sqrt, atan2,sin,cos
  1. 我们将编写一个函数,使用毕达哥拉斯定理来测量捕食者位置和猎物位置之间的距离:
def dist(x1,y1,x2,y2):
    """Returns distance from (x1,y1) to (x2,y2)"""
    return sqrt((x1-x2)**2 + (y1-y2)**2)
  1. 关键在于猎物和捕食者位置之间y的变化与x的变化代表我们想要的角度的正切。我们知道它们的位置,因此我们使用反正切函数atan2来计算角度,使得捕食者直接指向猎物。我们真正想知道的是如何改变捕食者的xy坐标,使其朝着猎物移动 1 单位。为了使捕食者朝着猎物转向,我们需要找到两点之间的角度,如下图所示:图 12.31:捕食者和猎物之间的角度

图 12.31:捕食者和猎物之间的角度

  1. 一旦我们知道变化,我们就可以将向量乘以我们想要的任何速度:
def towards(x1,y1,x2,y2):
    """Returns unit vector in [x,y] format from point
    1 to point 2"""
    dx,dy = x2-x1,y2-y1
    angle = atan2(dy,dx)
    return [cos(angle),sin(angle)]

我们计算xy的变化,使用arctangent函数计算角度,然后我们使用余弦和正弦来找到捕食者xy坐标的相应变化,使其朝着猎物走一步。

  1. 现在,追逐可以开始了。我们将捕食者和猎物放在它们所在的位置。然后,我们开始一个循环,其中我们将猎物移动一单位(或更准确的增量):
def chase():
    predator_x,predator_y = 20,0
    predator_v = 1.5 #prey is 1
    prey_x,prey_y = 0,0
    inc = 0.001
    while dist(predator_x,predator_y,prey_x,prey_y) > 0.001:
        prey_y += 1*inc
        p_vec = towards(predator_x,predator_y,\
                        prey_x,prey_y)
        predator_x += predator_v*p_vec[0]*inc
        predator_y += predator_v*p_vec[1]*inc
        #print(dist(predator_x,predator_y,prey_x,prey_y))
    return predator_y
  1. 现在,我们运行追逐并打印出捕食者捕捉猎物的y值:
y = chase()
print("Y:",y)
print("dist:",dist(1,1,4,5))
print("towards:",towards(1,1,2,2.732))

输出如下:

Y: 23.997299988652507
dist: 5.0
towards: [0.5000110003630132, 0.8660190526287391]

这非常接近理论值 24。

注意

要访问此特定部分的源代码,请参阅packt.live/3f6x44Z.

您还可以在packt.live/2NO1A7v.上在线运行此示例

练习 12.20:使用乌龟可视化追踪曲线

在这个练习中,我们将可视化捕食者和猎物的路径,这被称为追踪曲线。Python 中有一个内置模块,基于 Logo 编程语言的虚拟乌龟,可以根据我们编写的代码轻松创建可以在屏幕上四处走动的虚拟代理。按照以下步骤完成这个练习:

  1. 首先,我们从turtle模块导入函数:
from turtle import *
  1. 我们根据所需的左下点设置屏幕的大小,我们将其设置为(-30,-30),并设置右上点,我们将其设置为(40,40):
setworldcoordinates(-30,-30,40,40)
  1. 设置捕食者和猎物意味着创建一个Turtle对象并设置其颜色、位置和速度。乌龟在行走时会留下路径,所以我们告诉它penup,以防止它在到达起始位置之前绘制。然后,我们告诉它pendown,这样它就会开始绘制:
#set up predator
predator = Turtle()
predator.color("red")
predator.penup()
predator.setpos(20,0)
predator.pendown()
predator.speed(0)
  1. 我们通过使乌龟变成绿色并赋予它乌龟的形状来设置猎物:
#set up prey
prey = Turtle()
prey.color("green")
prey.shape("turtle")
prey.setheading(90)
prey.speed(0)
  1. pursue函数应该看起来很熟悉,但它有内置函数来计算距离,甚至指向另一个乌龟:
def pursue():
    inc = 0.05
    while predator.distance(prey)>0.05:
        predator.setheading(predator.towards(prey))
        prey.forward(inc)
        predator.forward (1.5*inc)
    print("y:",predator.ycor())
  1. 我们将执行pursue函数,然后一旦它打印输出,我们会告诉程序完成,这样图形窗口就不会冻结:
pursue()
done()
  1. 如果你运行这个,你可以观看追逐。这是最终输出应该看起来像的:图 12.32:捕食者的路径是一个对数曲线

图 12.32:捕食者的路径是一个对数曲线

  1. 扩展:将猎物的路径改为圆形。在使猎物向前移动的行后,添加这行:
prey.left(.3)

这将使猎物每一步左转一小部分度数。但是如果每次转弯都是一样的,最终会形成一个圆。结果路径看起来像这样:

图 12.33:当猎物沿着圆形路径逃离时的追逐曲线

图 12.33:当猎物沿着圆形路径逃离时的追逐曲线

注意

要访问此特定部分的源代码,请参阅packt.live/3dWHDG6。

这一部分目前没有在线交互式示例,需要在本地运行。

位置、速度和加速度

微分方程经常用于研究抛射体的路径,这可以说是微积分的起源。牛顿发明了微积分工具来解决由他对行星运动的研究得出的微分方程,并且表明地球上的自由落体物体受到与轨道行星相同的物理定律的约束。

练习 12.21:计算抛射体离地面的高度

一个球以初速度 29 米/秒向上抛出。它在击中地面之前要多久?按照以下步骤完成这个练习:

  1. 在代数课上,我们被引导使用方程来计算抛射体的高度:图 12.34:计算抛射体高度的公式

图 12.34:计算抛射体高度的公式

这里,h0 是初始高度,v0 是初始向上速度,t是经过的秒数,g是重力加速度,大约 32 英尺或 9.8 米每秒。但是抛射体不使用方程来计算它们的位置;它们只是沿着它们的导数指示的方向运动。

  1. 让我们模拟一下:
v = 29
g = 9.8
h = 0
t = 0

因此,对于第一秒,球将以 29 米/秒的速度向上抛出,但会受到每秒 9.8 米的重力减速,这意味着一秒后,它只有29 - 9.8 = 19.2米/秒。因此,一秒后,球应该在空中 19.2 米高。我们每秒重复一次,直到它的高度为 0。

  1. height函数应该是这样的:
def height(v0,h0,t):
    """Calculates the height a projectile given the
    initial height and velocity and the elapsed time."""
    v,h = v0,h0
    for i in range(1,t+1):
        v -= g
        h += v
    return h
  1. 速度和高度被分配它们的起始值,v0 和h0,然后速度通过g和加速度(由于重力)进行更新,然后高度h通过速度进行更新。我们每秒重复计算并检查球的高度何时返回到零:
for j in range(1,10):
    print(j,round(height(v,h,j),1))

以下是输出:

 –
1 19.2
2 28.6
3 28.2
4 18.0
5 -2.0
6 -31.8
7 -71.4
8 -120.8
9 -180.0

看起来球在 4 到 5 秒之间击中地面。但是当我们将t = 5放入前面的公式中时,我们得到以下结果:

图 12.35:替换计算抛射体高度公式中的值

图 12.35:替换计算抛射体高度公式中的值

  1. 5 秒后,球应该仍然在空中 22.5 米。我们的代码有什么问题?正如您现在应该知道的那样,球不仅每秒改变一次速度。它的速度不断变化。就像复利一样,我们需要每秒计算新的速度。对于 Python 来说很容易。我们只需引入一个inc变量来增加时间。请记住,这会增加我们循环计算的次数,因此for i in range行也会改变。然后,gv乘以增量。我们将每半秒重新计算一次:
def height(v0,h0,t):
    """Calculates the height a projectile given the
    initial height and velocity and the elapsed time."""
    inc = 0.5
    v,h = v0,h0
    for i in range(int(t/inc)):
        v -= g*inc
        h += v*inc
    return h
  1. 使用相同的代码运行此代码:
for j in range(1,7):
    print(j,round(height(v,h,j),1))

输出现在如下:

1 21.7
2 33.5
3 35.6
4 27.8
5 10.3
6 -17.1
  1. 球在空中停留的时间更长,在 5 秒时,它在空中的高度为 10.3 米。如果我们使增量非常小,它应该在 5 秒时更接近 22.5 米。将inc更改为 0.001,再次运行,您将获得以下输出:
1 24.1
2 38.4
3 42.9
4 37.6
5 22.5
6 -2.4
  1. 要回答球何时触地的问题,我们将不得不在 5 秒和 6 秒之间进行二分搜索。与以前的搜索一样,我们使用我们的bin_search函数,并更改guess =行以反映我们猜测的数字:
def bin_search(f,lower,upper,target):
    def average(a,b):
        return (a+b)/2
    for i in range(40):
        avg = average(lower,upper)
        guess = f(29,0,avg)
        if guess == target:
            return avg
        if guess < target:
            upper = avg
        else:
            lower = avg
    return avg
  1. 我们只需要更改height函数的参数的guess =行。最后一个参数t是我们正在搜索的内容,因此我们正在平均。二分搜索函数将在 5 和 6 之间插入值,并返回返回 0 的t值:
print(bin_search(height,5,6,0))

以下是输出:

5.918000000000575

现在,我们解决二次方程:

29t – 4.9t2 = 0

对于t,我们得到t = 0t = 5.9184。当然,我们扔出球之前,球的高度为 0,第二个值非常接近我们得到的值。函数的图形如下所示。忽略空气阻力,粒子高度随时间的图形遵循抛物线路径:

图 12.36:没有空气阻力的抛射物路径

图 12.36:没有空气阻力的抛射物路径

这是对我们代码的测试,因为我们有一个很好的公式来检查我们的输出。现在,我们将继续进行更难的关于速度和加速度的微积分问题,其中没有公式可以帮助我们检查答案。

注意

要访问此特定部分的源代码,请参阅packt.live/2VEAkN4

您还可以在packt.live/2Bzpz7Z上在线运行此示例。

计算带空气阻力的抛射物高度的示例

数学学生被迫研究从代数到微积分的完美抛物线路径中行进的粒子。不幸的是,这不是真实粒子的行进方式。在现实生活中,物体穿过空气或水等介质,并根据介质的密度、其横截面积和其他因素而减速。这导致了一个复杂的施加在抛射物上的力的方程。简而言之,抛射物上的力是由于重力加速度和与其速度的平方成比例的减速。方程如下:

F = mg - kv2

有空气阻力,我们需要知道抛射物的质量m。由于重力加速度g为 9.8 m/s2。变量k是至少三个不同因素的组合,但值k = 0.27对于这种情况产生了现实结果。

与上一个练习一样,我们计算加速度并使用它来更新速度。然后,我们根据速度更新抛射物的位置。

抛射物上的力由两部分组成:由于重力的通常加速度和阻力分量。让我们编写一个 Python 函数来计算:

def force(v,mass,g,k,inc):
    """Returns the downward force on a
    projectile"""
    gravity = mass*g*inc
    drag = k*(v**2)*inc
    if v > 0:
        return gravity + drag
    return gravity - drag

很多时候,我们的值都乘以inc,增量变量,这样我们可以采取更小的步骤来获得更好的近似值,就像我们以前做的那样。gravitydrag变量直接来自力的方程。请注意,如果速度大于 0,抛射物正在向上运动,因此向下的力是重力和阻力力的总和。否则,抛射物正在向下运动,因此重力的力仍然向下,但阻力正在减速,因此我们使用重力和阻力的差异。

现在,我们将调整我们在上一个练习中的height函数,以计算高度等于 0 所需的时间,并添加对我们的force函数的调用:

def height(v0,h0):
    """Calculates the time it takes a projectile given the 
    initial height and velocity to hit the ground."""
    inc = 0.001
    v,h = v0,h0
    t = 0
    while h >= 0:
        v -= force(v,1,9.8,0,inc) #test with k=0
        h += v*inc
        t += inc
    return round(t,1),round(v,1)

在这个函数中,是v -=这一行在起重要作用。速度将受到向下的力的影响。当我们使用k = 0运行时,我们应该得到与上一个问题中相同的时间和结束速度,没有空气阻力:

print(height(29,0))

输出如下:

(5.9, -29.0)

是的;在上一个练习中,抛射物需要 5.9 秒才能到达地面。当没有空气阻力且结束高度与初始高度相同时,结束速度将与初始速度相同,只是方向相反,因此为-29 米/秒。

现在,让我们使用更现实的值k0.27,看看粒子到达地面需要多长时间,以及它将以多快的速度运行。你有什么预测?

height函数中的v -=行更改为以下内容:

        v -= force(v,1,9.8,0.27,inc)

当您运行程序时,输出将如下所示:

(2.2, -5.9)

因此,抛射物只用了 2.2 秒就上升并下降,最终速度为-5.9 米/秒。如果我们将具有和没有空气阻力的抛射物的高度图进行对比,我们会发现在有空气阻力的情况下高度要少得多:

图 12.37:带空气阻力的抛射物的高度(内曲线)

图 12.37:带空气阻力的抛射物的高度(内曲线)

这确实是很大的阻力。尝试使用不同的k值,即阻力常数,以获得不同的结束时间和结束速度。这引出了数学和科学中一个非常有趣的概念,即终端速度,当抛射物上下的力相等时,它不再加速。

练习 12.22:计算终端速度

如果您的抛射物从 3000 米的初始高度开始并从飞机上跳下(向下速度为 0),它会达到什么速度?它会简单地继续加速直到抛射物撞击地面吗?

将质量更改为 80 公斤,这是人类的平均体重,k更改为0.27。按照以下步骤完成此练习:

  1. 确保您有来自上一个示例的force函数。

  2. 修改您的height函数,使其看起来像这样:

def height(v0,h0): 
    """Calculates the velocity of a projectile given the  
    initial height and velocity and the elapsed time.""" 
    inc = 0.001
    v,h = v0,h0 
    t = 0
    for i in range(500): 
  1. 这是一个重要的行,我们在其中告诉force函数质量、k的值等等:
        v -= force(v,80,9.8,0.27,inc)
        h += v*inc
        if i % 50 == 0:
            print("v:",round(v,1))
        t += inc
  1. 我们进行了 500 次循环,但只在每 50 次循环时打印出速度。让我们用这行来运行它:
height(0,3000)

这是我们收到的输出:

v: -0.8
v: -34.1
v: -48.6
v: -52.6
v: -53.6
v: -53.8
v: -53.9
v: -53.9
v: -53.9
v: -53.9

速度从 0 开始,变得越来越负,直到停止减少。它在大约 54 米/秒左右稳定下来(为负,因为它向下),这大约是每小时 120 英里,人体在自由落体中的终端速度。这是随时间变化的速度图:

图 12.38:带空气阻力的自由落体物体的速度

图 12.38:带空气阻力的自由落体物体的速度

注意

要访问此特定部分的源代码,请参阅packt.live/2NNmWBM。

您还可以在packt.live/2BUuXCp上在线运行此示例。

现在,让我们完成一个活动,测试我们在本章学到的知识。

活动 12.01:找到粒子的速度和位置

x-y平面上移动的粒子的速度矢量具有以下分量:

图 12.39:粒子速度矢量的微分方程

图 12.39:粒子速度矢量的微分方程

找到曲线的切线水平的所有时间(和坐标),然后找到t=1时粒子的速度。

执行以下步骤完成此活动:

  1. 编写dx/dtdy/dt的函数。

  2. 循环遍历输出,找到导数为 0 的位置,找到导数从正变负或反之的值。然后,使用二分搜索找到更精确的近似值。

  3. 创建一个position函数,并使用循环按照之前给出的导数(位置的变化)来改变粒子的位置,以便在所需的经过时间停止,并打印出x-y坐标。

  4. 将您在步骤 2中找到的时间插入position函数中,以找到导数为 0 时粒子的x-y坐标。

  5. 您被要求在时间t=1时粒子的速度。使用您得到的微分方程找到粒子速度的垂直和水平分量,并找到以这些分量作为腿的直角三角形的斜边。

注意

此活动的解决方案可在第 702 页找到。

总结

微积分是一套非常强大的工具,用于模拟真实情况,从热量传递到行星运动。它使我们能够计算函数在瞬间的变化率和复杂曲线下的面积(这些任务仅使用代数和几何的工具似乎是不可能的)。在本章中,我们能够处理值的变化率(导数)作为一个值本身,并使用 Python 循环和函数计算出一些非常精确的结果。模拟导致微分方程的情况,比如抛射物的路径,正是推动第一台电子计算机的发展。

数学课程可能仍然强调对方程的代数解,甚至微分方程,但正如我们在本章中所看到的,使用计算机是模拟现实生活情况的一种简单方法,比如捕食者追逐猎物。我们改变了变量,比如投资中的资金量,混合物中的盐量,以及捕食者的朝向,重复计算了数千次,每一步重新计算数量和距离,得到了非常精确的结果。Python 是设置一些起始条件并让程序运行直到抛射物击中地面或达到终端速度的完美工具。Python 还帮助我们避免了繁琐的代数运算,并让我们通过创建一个简单的模型来蛮力得到答案,比如一个下落的物体或一个捕食者追逐猎物。这是简单的,因为我们不必重复数千次计算——计算机会做。此外,这些数值方法已经用于没有简单代数解的微分方程,甚至适用于那些有解的方程。希望本章已经证明了使用计算机来模拟和分析复杂的现实情况的力量。

现在,您已经学会了如何利用 Python 的循环、变量、条件、函数和列表来解决统计学、概率论和微积分中的复杂问题。您还学会了如何计时执行代码并绘制输出。您使用了 Python 的最先进的数值包numpy来加速计算并操纵数组,适用于各种应用。您还看到 Python 编程被应用于太阳下的每一个数学主题,现在您将能够将其应用于未来遇到的任何现实生活情况。

JMK95

GEA39

附录

1. Python 基础

活动 1.01:构建数独求解器

解决方案

  1. 首先,我们定义Solver类,将其输入谜题存储在其cells属性中,如下所示:
from copy import deepcopy
class Solver:
    def __init__(self, input_path):
        # Read in the input file and initialize the puzzle
        with open(input_path, 'r') as f:
            lines = f.readlines()
        self.cells = [list(map(int, line.split(','))) \
                      for line in lines]
  1. 以漂亮的格式打印出谜题的辅助方法可以循环遍历谜题中的单元格,同时在适当的位置插入分隔字符'-''|'
    # Print out the initial puzzle or solution in a nice format.
    def display_cell(self):
        print('-' * 23)
        for i in range(9):
            for j in range(9):
                print(self.cells[i][j], end=' ')
                if j % 3 == 2:
                    print('|', end=' ')
            print()
            if i % 3 == 2:
                print('-' * 23)
        print()
  1. get_presence()方法可以维护三个单独的布尔变量列表,用于表示各行、列和象限中 1 到 9 之间数字的存在。这些布尔变量在开始时都应初始化为False,但我们可以循环遍历输入中的所有单元格,并根据需要将它们的值更改为True
        """ 
        True/False for whether a number is present in a row, 
        column, or quadrant.
        """
        def get_presence(cells):
            present_in_row = [{num: False for num in range(1, 10)}
                              for _ in range(9)]
            present_in_col = [{num: False for num in range(1, 10)}
                              for _ in range(9)]
            present_in_quad = [{num: False for num in range(1, 10)}
                               for _ in range(9)]
            for row_id in range(9):
                for col_id in range(9):
                    temp_val = cells[row_id][col_id]
                    """
                    If a cell is not empty, update the corresponding 
                    row, column, and quadrant.
                    """
                    if temp_val > 0:
                        present_in_row[row_id][temp_val] = True
                        present_in_col[col_id][temp_val] = True
                        present_in_quad[row_id // 3 * 3 \
                                        + col_id // 3]\
                                        [temp_val] = True
            return present_in_row, present_in_col, present_in_quad

对象象限进行索引可能有些棘手。上述代码使用公式row_id // 3 * 3 + col_id // 3,这实际上导致了从左上象限索引为0,上中1,上右2,中左3,...,底部中7,底部右8的计数。

  1. get_possible_values()方法可以调用get_presence()并生成剩余空单元格的可能值列表:
        # A dictionary for empty locations and their possible values.
        def get_possible_values(cells):
            present_in_row, present_in_col, \
            present_in_quad = get_presence(cells)
            possible_values = {}
            for row_id in range(9):
                for col_id in range(9):
                    temp_val = cells[row_id][col_id]
                    if temp_val == 0:
                        possible_values[(row_id, col_id)] = []
                        """ 
                        If a number is not present in the same row, 
                        column, or quadrant as an empty cell, add it 
                        to the list of possible values of that cell.
                        """
                        for num in range(1, 10):
                            if (not present_in_row[row_id][num]) and\
                               (not present_in_col[col_id][num]) and\
                               (not present_in_quad[row_id // 3 * 3 \
                               + col_id // 3][num]):
                                possible_values[(row_id, col_id)]\
                                .append(num)
            return possible_values
  1. simple_update()方法可以以相当直接的方式实现,其中我们可以使用一个标志变量(这里称为update_again)来指示我们在返回之前是否需要再次调用该方法:
        # Fill in empty cells that have only one possible value.
        def simple_update(cells):
            update_again = False
            possible_values = get_possible_values(cells)
            for row_id, col_id in possible_values:
                if len(possible_values[(row_id, col_id)]) == 1:
                    update_again = True
                    cells[row_id][col_id] = possible_values[\
                                            (row_id, col_id)][0]
            """
            Recursively update with potentially new possible values.
            """
            if update_again:
                cells = simple_update(cells)
            return cells
  1. recur_solve()方法包含多个教学组件,但逻辑流程简单易实现:
        # Recursively solve the puzzle
        def recur_solve(cells):
            cells = simple_update(cells)
            possible_values = get_possible_values(cells)
            if len(possible_values) == 0:
                return cells  # return when all cells are filled
            # Find the empty cell with fewest possible values.
            fewest_num_values = 10
            for row_id, col_id in possible_values:
                if len(possible_values[(row_id, col_id)]) == 0:
                    return False  # return if an empty is invalid
                if len(possible_values[(row_id, col_id)]) \
                   < fewest_num_values:
                    fewest_num_values = len(possible_values[\
                                            (row_id, col_id)])
                    target_location = (row_id, col_id)
            for value in possible_values[target_location]:
                dup_cells = deepcopy(cells)
                dup_cells[target_location[0]]\
                         [target_location[1]] = value
                potential_sol = recur_solve(dup_cells)
                # Return immediately when a valid solution is found.
                if potential_sol:
                    return potential_sol
            return False  # return if no valid solution is found
  1. 最后,我们将所有这些方法放在solve()方法中,该方法在self.cells上调用recur_solve()
    # Functions to find a solution.
    def solve(self):
        def get_presence(cells):
            ...
        def get_possible_values(cells):
            ...
        def simple_update(cells):
            ...
        def recur_solve(cells):
            ...
        print('Initial puzzle:')
        self.display_cell()
        final_solution = recur_solve(self.cells)
        if final_solution is False:
            print('A solution cannot be found.')
        else:
            self.cells = final_solution
            print('Final solution:')
            self.display_cell()
  1. 按如下方式打印返回的解决方案:
solver = Solver('sudoku_input/sudoku_input_2.txt')
solver.solve()

输出的一部分如下:

Initial puzzle:
-----------------------
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 | 
-----------------------

注意

要访问此特定部分的源代码和最终输出,请参阅packt.live/3dWRsnE.

您还可以在packt.live/2BBKreC.上在线运行此示例

2. Python 的统计主要工具

活动 2.01:分析社区和犯罪数据集

解决方案

  1. 数据集下载后,可以导入库,并使用 pandas 在新的 Jupyter 笔记本中读取数据集,如下所示:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
df = pd.read_csv('CommViolPredUnnormalizedData.txt')
df.head()

我们还打印出数据集的前五行,应该如下所示:

图 2.21:数据集的前五行

图 2.21:数据集的前五行

  1. 要打印列名,我们可以简单地在for循环中迭代df.columns,如下所示:
for column in df.columns:
    print(column)
  1. 可以使用 Python 中的len()函数计算数据集中的列总数:
print(len(df.columns))
  1. 要用np.nan对象替换特殊字符'?',可以使用replace()方法:
df = df.replace('?', np.nan)
  1. 要打印出数据集中列的列表及其各自的缺失值数量,我们使用isnull().sum()方法的组合:
df.isnull().sum()

上述代码应产生以下输出:

communityname             0
state                     0
countyCode             1221
communityCode          1224
fold                      0
                       ... 
autoTheftPerPop           3
arsons                   91
arsonsPerPop             91
ViolentCrimesPerPop     221
nonViolPerPop            97
Length: 147, dtype: int64
  1. 可以按如下方式访问并显示两个指定列的缺失值数量:
print(df.isnull().sum()['NumStreet'])
print(df.isnull().sum()['PolicPerPop'])

您应该获得01872作为输出。

  1. 使用条形图计算和可视化'state'中唯一值的计数(以及调整图的大小),可以使用以下代码:
state_count = df['state'].value_counts()
f, ax = plt.subplots(figsize=(15, 10))
state_count.plot.bar()
plt.show()

这应该产生以下图表:

图 2.22:州计数的条形图

图 2.22:州计数的条形图

  1. 使用以下代码可以计算和可视化相同信息的饼图:
f, ax = plt.subplots(figsize=(15, 10))
state_count.plot.pie()
plt.show()

将生成以下可视化:

图 2.23:州计数的饼图

图 2.23:州计数的饼图

  1. 使用直方图计算和可视化人口分布,可以使用以下代码:
f, ax = plt.subplots(figsize=(15, 10))
df['population'].hist(bins=200)
plt.show()

这应该产生以下图表:

图 2.24:人口分布的直方图

图 2.24:人口分布的直方图

  1. 要计算和可视化家庭规模分布,可以使用以下代码:
f, ax = plt.subplots(figsize=(15, 10))
df['householdsize'].hist(bins=200)
plt.show()

这应该产生以下图表:

图 2.25:家庭规模分布的直方图

图 2.25:家庭规模分布的直方图

注意

要访问此特定部分的源代码,请参阅packt.live/2BB5BJT

你也可以在packt.live/38nbma9上线运行此示例。

3. Python 的统计工具箱

活动 3.01:重新审视社区和犯罪数据集

解决方案

  1. 可以导入库,并使用 pandas 读取数据集,如下所示:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
df = pd.read_csv('CommViolPredUnnormalizedData.txt')
df.head()

您的输出应该如下所示:

图 3.29:数据集的前五行

图 3.29:数据集的前五行

  1. 要用np.nan对象替换特殊字符,我们可以使用以下代码:
df = df.replace('?', np.nan)
  1. 要计算不同年龄组的实际计数,我们可以简单地使用表达式df['population'] * df['agePct...'],以向量化方式计算计数:
age_groups = ['12t21', '12t29', '16t24', '65up']

for group in age_groups:
    df['ageCnt' + group] = (df['population'] * \
                            df['agePct' + group]).astype(int)
df[['population'] \
  + ['agePct' + group for group in age_groups] \
  + ['ageCnt' + group for group in age_groups]].head()

请注意,我们正在使用astype(int)将最终答案四舍五入为整数。这些新创建的列的前五行应该如下所示:

图 3.30:不同年龄组的实际计数

图 3.30:不同年龄组的实际计数

  1. 表达式df.groupby('state')给我们一个GroupBy对象,将我们的数据集聚合成不同的组,每个组对应'state'列中的唯一值。然后我们可以在该对象上调用sum()并检查相关的列:
group_state_df = df.groupby('state')
group_state_df.sum()[['ageCnt' + group for group in age_groups]]

这应该打印出每个州不同年龄组的计数。输出的前五列应该如下所示:

图 3.31:每个州不同年龄组的计数

图 3.31:每个州不同年龄组的计数

  1. 使用df.describe()方法,您可以获得以下输出:图 3.32:数据集的描述

图 3.32:数据集的描述

  1. 可生成可视化各种犯罪数量的箱线图,如下所示:
crime_df = df[['burglPerPop','larcPerPop',\
               'autoTheftPerPop', 'arsonsPerPop',\
               'nonViolPerPop']]
f, ax = plt.subplots(figsize=(13, 10))
sns.boxplot(data=crime_df)
plt.show()

这应该产生以下图表:

图 3.33:各种犯罪数量的箱线图

图 3.33:各种犯罪数量的箱线图

  1. 从图表中,我们可以看到五种犯罪中非暴力犯罪是最常见的,而纵火犯罪是最不常见的。

  2. 可以使用与给定列对应的相关矩阵的热图来可视化所需的信息:

feature_columns = ['PctPopUnderPov', 'PctLess9thGrade', \
                   'PctUnemployed', 'ViolentCrimesPerPop', \
                   'nonViolPerPop']
filtered_df = df[feature_columns]
f, ax = plt.subplots(figsize=(13, 10))
sns.heatmap(filtered_df.dropna().astype(float).corr(), \
                                 center=0, annot=True)
bottom, top = ax.get_ylim()
ax.set_ylim(bottom + 0.5, top - 0.5)
plt.show()

这应该产生以下热图:

图 3.34:各种人口特征的热图

图 3.34:各种人口特征的热图

从图表中,我们可以看到贫困线以下人口的百分比和失业率之间高度相关(相关系数为0.77)。这是一个可以理解但富有启发性的洞察,揭示了各种与犯罪相关的因素是如何相互联系的。

注意

要访问此特定部分的源代码,请参阅packt.live/3f8taZn

你也可以在packt.live/3ikxjeF上线运行此示例。

4. 使用 Python 进行函数和代数

活动 4.01:多变量收支平衡分析

解决方案

  1. x表示餐厅每月生产的汉堡数量,y表示每个汉堡的价格。然后,月收入将是xy,成本将是6.56x + 1312.13,最后,总利润将是两者之间的差异:xy - 6.56x - 1312.13

  2. 要达到收支平衡,生产的汉堡数量x必须等于需求,这给我们带来了方程:x = 4000/y。此外,总利润应该为零,这导致xy - 6.56x = 1312.13

总的来说,我们有以下方程组:

图 4.48:方程组

图 4.48:方程组

  1. 从第一个方程中,我们可以解出x = 409.73628。将这个值代入第二个方程,我们可以解出y = 9.76237691

要在 Python 中解决这个系统,我们首先声明我们的变量和常数:

COST_PER_BURGER = 6.56
FIXED_COST = 1312.13
AVG_TOWN_BUDGET = 4000
x = Symbol('x')  # number of burgers to be sold
y = Symbol('y')  # price of a burger

然后我们可以在对应的函数列表上调用 SymPy 中的solve()函数:

solve([x * (y - COST_PER_BURGER) - FIXED_COST,\
       x * y - AVG_TOWN_BUDGET])

这段代码应该产生以下输出,对应于系统的实际解决方案:

[{x: 409.736280487805, y: 9.76237690066856}]
  1. 这个函数最具挑战性的一点是,如果餐厅生产的汉堡数量x超过需求4000/y,他们的收入仍然是4000。然而,如果汉堡的数量较少,那么收入就是xy。因此,我们的函数需要有一个条件来检查这个逻辑:
def get_profit(x, y):
    demand = AVG_TOWN_BUDGET / y
    if x > demand:
        return AVG_TOWN_BUDGET - x * COST_PER_BURGER \
                                   - FIXED_COST

    return x * (y - COST_PER_BURGER) - FIXED_COST
  1. 以下代码生成了指定的列表和相应的图表,当每个汉堡的价格为$9.76 时:
xs = [i for i in range(300, 501)]
profits_976 = [get_profit(x, 9.76) for x in xs]
plt.plot(xs, profits_976)
plt.axhline(0, c='k')
plt.xlabel('Number of burgers produced')
plt.ylabel('Profit')
plt.show()

输出应该如下所示:

图 4.49:售价为$9.76 的盈亏平衡图

图 4.49:售价为$9.76 的盈亏平衡图

利润曲线的倒置 V 形与水平线在0处的交点表示分析中每个汉堡价格固定为$9.76 的盈亏平衡点。这个交点的x坐标略高于400,大致对应于步骤 3中的盈亏平衡解,当x大约为410y大约为 9.76 时。

  1. 以下代码生成了指定的列表和相应的图表,当每个汉堡的价格为$9.99 时:
xs = [i for i in range(300, 501)]
profits_999 = [get_profit(x, 9.99) for x in xs]
plt.plot(xs, profits_999)
plt.axhline(0, c='k')
plt.xlabel('Number of burgers produced')
plt.ylabel('Profit')
plt.show()

输出应该如下所示:

图 4.50:售价为$9.99 的盈亏平衡图

图 4.50:售价为$9.99 的盈亏平衡图

类似地,利润曲线与水平线交点处的两个交点0表示分析中每个汉堡价格固定为$9.99 的盈亏平衡点。

我们看到,随着生产的汉堡数量增加,餐厅的利润呈线性增长。然而,当这个数量满足需求并且利润曲线达到峰值后,曲线开始线性下降。这是当餐厅过度生产并且增加产品数量不再有利时。

  1. 以下代码生成了指定的列表:
xs = [i for i in range(300, 501, 2)]
ys = np.linspace(5, 10, 100)
profits = [[get_profit(x, y) for y in ys] for x in xs]

profits是一个相当大的二维列表,但该列表中的前几个元素应如下所示:

图 4.51:利润的二维列表

图 4.51:利润的二维列表

  1. 可以使用以下代码生成指定的热图:
plt.imshow(profits)
plt.colorbar()
plt.xticks([0, 20, 40, 60, 80],\
           [5, 6, 7, 8, 9, 10])
plt.xlabel('Price for each burger')
plt.yticks([0, 20, 40, 60, 80],\
           [300, 350, 400, 450, 500])
plt.ylabel('Number of burgers produced')
plt.show()

输出应该如下所示:

图 4.52:利润的热图作为生产和价格的函数

图 4.52:利润的热图作为生产和价格的函数

从图中我们可以看出,有特定的xy的组合来控制餐厅的利润行为。

例如,当每个汉堡的价格较低时(地图的左侧区域),总利润明显低于 0。当我们移动到图的右侧时,最亮的区域代表了两个变量的组合将产生最高利润。

注意

要访问此特定部分的源代码,请参阅packt.live/2C6dKWz

您也可以在packt.live/2NTfEwG上在线运行此示例。

5. 使用 Python 进行更多数学运算

活动 5.01:使用级数计算您的退休计划

解决方案

执行以下步骤完成此活动:

  1. 首先,我们需要确定输入变量,并注意问题归结为计算具有公比(1 + 利息)和年薪比例的等比数列的n项。

annual_salary和百分比contrib是我们为计划做出的贡献。current_balance是我们在第 0 年拥有的钱,应该加到总金额中。annual_cap是我们可以贡献的最大百分比;任何超出该值的输入值应该等于contrib_capannual_salary_increase告诉我们每年我们的工资预计增加多少。employer_match给我们雇主为计划贡献的百分比(通常在 0.5 和 1 之间)。最后,当前年龄,计划的年限,预期寿命以及计划可能产生的任何其他费用都是输入变量。per_month布尔变量确定输出是作为每年还是每月的回报金额打印。

  1. 定义第一个函数retirement_n,来计算我们序列的第 n 项,它返回贡献和雇主匹配作为逗号分隔的元组:
def retirement_n(current_balance, annual_salary, \
                 annual_cap, n, contrib, \
                 annual_salary_increase, employer_match, \
                 match_cap, rate):
    '''
    return :: retirement amount at year n
    '''

    annual_salary_n = annual_salary*\
                      (1+annual_salary_increase)**n

    your_contrib = contrib*annual_salary_n
    your_contrib = min(your_contrib, annual_cap)
    employer_contrib = contrib*annual_salary_n*employer_match
    employer_contrib = min(employer_contrib,match_cap\
                           *annual_salary_n*employer_match)

    contrib_total = your_contrib + employer_contrib

    return your_contrib, employer_contrib,         current_balance + contrib_total*(1+rate)**n

如此所示的输入是当前余额和绝对值的年薪。我们还定义了贡献,贡献上限(即允许的最大值),年薪的增加,雇主匹配以及回报率作为相对值(0 到 1 之间的浮点数)。年度上限也应该被视为绝对值。

  1. 定义一个函数,将每年的个人金额相加,并计算我们计划的总价值。这将把这个数字除以计划要使用的年数(偿还期限),以便函数返回计划的每年回报。作为输入,它应该读取当前年龄,计划的持续时间和预期寿命(偿还期限是通过从预期寿命中减去当前年龄+计划年限来找到的):
def retirement_total(current_balance, annual_salary, \
    annual_cap=18000, contrib=0.05, \
    annual_salary_increase=0.02, employer_match=0.5, \
    match_cap=0.06, rate=0.03, current_age=35, \
    plan_years=35, life_expectancy=80, fees=0, \
    per_month=False):

    i = 0
    result = 0
    contrib_list = []; ematch_list = []; total_list = []

    while i <= plan_years:
        cn = retirement_n(current_balance=current_balance, \
             annual_salary=annual_salary, \
             annual_cap=annual_cap, n=i, \
             contrib=contrib, match_cap=match_cap, \
             annual_salary_increase=annual_salary_increase,\
             employer_match=employer_match, rate=rate)

        contrib_list.append(cn[0])
        ematch_list.append(cn[1]) 
        total_list.append(cn[2])

        result = result + cn[2]
        i+=1

前一个函数的主要操作是设置一个循环(while迭代),在其中调用前一个函数,并找到每年的计划价值n(我们在这里称它为cn以简洁起见)。结果是所有年份的价值之和,并存储在result变量中。我们切片cn(cn[0],cn[1],cn[2]),因为retirement_n函数返回三个数量的元组。我们还将贡献(员工),匹配(员工)和总额的值存储在三个单独的列表中。这些将从此函数返回。

  1. 最后,减去可能需要包括的任何费用并返回结果:
    result = result - fees

    years_payback = life_expectancy - (current_age + plan_years)

    if per_month:
        months = 12
    else:
        months = 1
    result = result / (years_payback*months)
    print('You get back:',result)

    return result, contrib_list, ematch_list, total_list
  1. 检查我们的函数和输出:
result, contrib, ematch, total = retirement_total(current_balance=1000, plan_years=35,\
                 current_age=36, annual_salary=40000, \
                 per_month=True)

输出如下:

You get back: 3029.952393422356
  1. 绘制您的发现。绘制已计算的内容总是一个很好的做法,因为它有助于您理解主要信息。此外,可以检查函数是否存在潜在错误:
from matplotlib import pyplot as plt
years = [i for i in range(len(total))]
plt.plot(years, total,'-o',color='b')
width=0.85
p1 = plt.bar(years, total, width=width)
p2 = plt.bar(years, contrib, width=width)
p3 = plt.bar(years, ematch, width=width)
plt.xlabel('Years')
plt.ylabel('Return')
plt.title('Retirement plan evolution')
plt.legend((p1[0], p2[0], p3[0]), ('Investment returns','Contributions','Employer match'))
plt.show()

将以以下方式显示情节:

图 5.26:退休计划演变情节

图 5.26:退休计划演变情节

有了这个,我们创建了一个 Python 程序,根据当前的贡献和一组其他参数来计算退休计划的每月或每年回报。我们已经看到了我们对序列和级数的知识如何应用到现实生活场景中,以产生有关金融和社会利益的结果。

注意

要访问此特定部分的源代码,请参阅packt.live/2YVgQWE

您还可以在packt.live/38rOHts上在线运行此示例。

6. 使用 Python 进行矩阵和马尔可夫链

活动 6.01:使用马尔可夫链构建文本预测器

解决方案

有几种方法可以解决这个问题,值得一提的是,我们将采取的方法可能是使用文本预测的最简单方式。在实际实践中,文本预测要复杂得多,并且有许多其他因素会影响它们,我们将在活动结束时简要介绍。

  1. 我们将使用温斯顿·丘吉尔在第二次世界大战期间从敦刻尔克被解救的盟军士兵后在英国下议院发表的演讲的文本。这篇演讲本身值得一读,如果您感兴趣,可以在网上轻松找到。

注意

您可以从packt.live/38rZy6v下载演讲稿。

  1. 这个列表存储在名为churchill.txt的文本文件中。阅读该文本文件:
# Churchill's speech
churchill = open('churchill.txt').read()
keywords = churchill.split()
print(keywords)

我们将其保存在名为churchill的字符串对象中,然后使用字符串中的split()函数对我们拥有的文本进行标记化,并将其存储在名为keywords的列表中。这将产生以下输出:

['The', 'position', 'of', 'the', 'B.', 'E.F', 'had',  'now', 'become', 'critical', 'As', 'a', 'result', 'of',  'a', 'most', 'skillfully', 'conducted', 'retreat',….]
  1. 接下来,我们遍历列表并将元素附加到一个新列表中,该列表将存储关键字和其后的单词:
keylist = []
for i in range(len(keywords)-1):
    keylist.append( (keywords[i], keywords[i+1]))
print(keylist)

这将产生以下输出:

[('The', 'position'), ('position', 'of'), ('of', 'the'),  ('the', 'B.'), ('B.', 'E.F'), ('E.F', 'had'), ('had',  'now'), ('now', 'become'), ('become', 'critical'),  ('critical', 'As'),….]

注意

这里的列表已经初始化,并且是一个元组列表,如果您愿意,可以将其转换为列表,但这并非必要。

  1. 然后,初始化一个名为word_dict的字典。一旦我们有了字典,我们就会遍历先前的keylist数组,并将左侧的单词添加到字典中的键的前一个元组中,并将右侧的单词添加为该字典中的值。如果左侧的单词已添加到字典中,我们只需将右侧的单词附加到字典中的相应值中:
# Create key-value pairs based on follow-up words
word_dict = {}
for beginning, following in keylist:
    if beginning in word_dict.keys():
        word_dict[beginning].append(following)
    else:
        word_dict[beginning] = [following]
print(word_dict)

这将产生以下输出:

{'magnetic': ['mines'], 'comparatively': ['slowly'],  'four': ['hundred', 'thousand', 'days', 'or', 'to'],  'saved': ['the', 'not'], 'forget': ['the'],….}
  1. 做到这一点后,我们现在准备构建我们的预测器。首先,我们定义一个 NumPy 字符串,它从先前的关键字集合中选择一个随机单词,这将是我们的第一个单词:
first_word = np.random.choice(keywords)
while first_word.islower():
    first_word = np.random.choice(keywords)

前面代码的第二部分旨在确保我们的句子以大写字母开头。如果不深入了解自然语言处理的工作原理,只要我们了解在原始文本中使用的大写字母单词将为构建更全面的陈述铺平道路,就足够简单了。只要它存在于我们使用的关键字语料库中,我们也可以在这里指定一个特定的单词,而不是随机选择它。

  1. 将这个单词添加到一个新列表中:
word_chain = [first_word]

这里的第一个单词是从我们使用的文本文件中单词语料库中随机生成的,使用random函数。

然后,我们将根据先前建立的字典附加其他单词。

  1. 通常,我们将查看我们刚刚附加到word_chain的单词,从列表中的第一个单词开始。将其用作我们创建的字典中的键,并随机跟随先前创建的字典中该特定键的值列表:
WORDCOUNT = 40
for i in range(WORDCOUNT):
    word_chain.append(np.random.choice(word_dict[\
                                       word_chain[-1]]))

注意使用我们初始化的静态变量WORDCOUNT,它指定我们希望句子有多长。如果您不习惯广泛使用嵌套的 Python 函数,只需从最内部的函数开始解决,并使用outer函数的值。

  1. 最后,我们将定义一个名为sentence的字符串变量,这将是我们的输出:
sentence = ' '.join(word_chain)
print(sentence)

注意

由于这里选择的第一个单词和字典中的值都是随机选择的,因此我们每次都会得到不同的输出。

让我们看一些我们将生成的输出:

Output 1: 
British tanks and all the New World, with little or fail. We have been reposed is so plainly marked the fighters which we should the hard and fierce. Suddenly the sharpest form. But this Island home, some articles of all fall
Output 2
That expansion had been effectively stamped out. Turning once again there may be very convenient, if necessary to guard their knowledge of the question of His son has given to surrender. He spurned the coast to be held by the right
Output 3:
Air Force. Many are a great strength and four days of the British and serious raids, could approach or at least two armored vehicles of the government would observe that has cleared, the fine Belgian Army compelled the retreating British Expeditionary
Output 4
30,000 men we can be defended Calais were to cast aside their native land and torpedoes. It was a statement, I feared it was in adverse weather, under its main French Army away; and thus kept open our discussions free, without
Output 5
German bombers and to give had the House by views freely expressed in their native land. I thought-and some articles of British and in the rescue and more numerous Air Force, and brain of it be that Herr Hitler has often

注意

要访问此特定部分的源代码,请参阅packt.live/3gr5uQ5

您还可以在packt.live/31JeD2b上在线运行此示例。

7. 使用 Python 进行基本统计

活动 7.01:查找评分很高的策略游戏

解决方案

  1. 加载numpypandas库如下:
import pandas as pd
import numpy as np
  1. 加载策略游戏数据集(在本章的dataset文件夹中):
games = pd.read_csv('../data/appstore_games.csv')

注意

您可以从 GitHub 存储库下载数据集packt.live/2O1hv2B

  1. 执行我们在本章第一部分所做的所有转换。更改变量的名称:
original_colums_dict = {x: x.lower().replace(' ','_') \
                        for x in games.columns}
games.rename(columns = original_colums_dict,\
             inplace = True)
  1. 'id'列设置为index
games.set_index(keys = 'id', inplace = True)
  1. 删除'url''icon_url'列:
games.drop(columns = ['url', 'icon_url'], \
           inplace = True)
  1. 'original_release_date''current_version_release_date'更改为datetime
games['original_release_date'] = pd.to_datetime\
                                 (games['original_release_date'])
games['current_version_release_date'] = \
pd.to_datetime(games['current_version_release_date'])
  1. 从 DataFrame 中删除'average_user_rating'为空的行:
games = games.loc[games['average_user_rating'].notnull()]
  1. 在 DataFrame 中仅保留'user_rating_count'等于或大于30的行:
games = games.loc[games['user_rating_count'] >= 30]
  1. 打印数据集的维度。您必须有一个包含4311行和15列的 DataFrame。您应该得到以下输出:
(4311, 15)
games.shape
  1. 用字符串EN填充languages列中的缺失值,以指示这些游戏仅以英语提供:
games['languages'] = games['languages'].fillna('EN')
  1. 创建一个名为free_game的变量,如果游戏的价格为零,则具有free的值,如果价格高于零,则具有paid的值:
games['free_game'] = (games['price'] == 0).astype(int)
                      .map({0:'paid', 1:'free'})
  1. 创建一个名为multilingual的变量,如果language列只有一个语言字符串,则具有monolingual的值,如果language列至少有两个语言字符串,则具有multilingual的值:
number_of_languages = games['languages'].str.split(',') \
                                        .apply(lambdax: len(x))
games['multilingual'] = number_of_languages == 1
games['multilingual'] = games['multilingual'].astype(int)
                        .map({0:'multilingual', 1:'monolingual'})
  1. 创建一个变量,其中包含上一步中创建的两个变量的四种组合(free-monolingualfree-multilingualpaid-monolingualpaid-multilingual):
games['price_language'] = games['free_game'] + '-' \
                        + games['multilingual']
  1. 计算price_language变量中每种类型的观察次数。您应该得到以下输出:
games['price_language'].value_counts()

输出将如下所示:

free-monolingual     2105
free-multilingual    1439
paid-monolingual     467
paid-multilingual    300
Name: price_language, dtype: int64
  1. games DataFrame 上使用groupby方法,按新创建的变量进行分组,然后选择average_user_rating变量并计算描述性统计信息:
games.groupby('price_language')['average_user_rating']\
                               .describe()

输出将如下所示:

图 7.35:按 price_language 类别分组的摘要统计信息

图 7.35:按 price_language 类别分组的摘要统计信息

注意

要访问此特定部分的源代码,请参阅packt.live/2VBGtJZ

您也可以在packt.live/2BwtJNK上在线运行此示例。

8.基础概率概念及其应用

活动 8.01:在金融中使用正态分布

解决方案

执行以下步骤完成此活动:

  1. 使用 pandas,从data文件夹中读取名为MSFT.csv的 CSV 文件:
import pandas as pd
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
%matplotlib inline
msft = pd.read_csv('../data/MSFT.csv')
  1. 可选地,重命名列,使其易于使用:
msft.rename(columns=lambda x: x.lower().replace(' ', '_'),\
            inplace=True)
  1. date列转换为适当的datetime列:
msft['date'] = pd.to_datetime(msft['date'])
  1. date列设置为 DataFrame 的索引:
msft.set_index('date', inplace = True)
  1. 在金融中,股票的日收益被定义为每日收盘价的百分比变化。通过计算adj close列的百分比变化,在 MSFT DataFrame 中创建returns列。使用pct_change系列 pandas 方法来实现:
msft['returns'] = msft['adj_close'].pct_change()
  1. 将分析期限限制在2014-01-012018-12-31之间的日期(包括在内):
start_date = '2014-01-01'
end_date = '2018-12-31'
msft = msft.loc[start_date: end_date]
  1. 使用直方图来可视化收益列的分布。使用 40 个箱子来做到这一点。看起来像正态分布吗?
msft['returns'].hist(ec='k', bins=40);

输出应如下所示:

图 8.24:MSFT 股票收益直方图

图 8.24:MSFT 股票收益直方图

  1. 计算returns列的描述性统计信息:
msft['returns'].describe()

输出如下:

count    1258.000000
mean        0.000996
std         0.014591
min        -0.092534
25%        -0.005956
50%         0.000651
75%         0.007830
max         0.104522
Name: returns, dtype: float64
  1. 创建一个名为R_rv的随机变量,表示MSFT 股票的日收益。使用返回列的均值和标准差作为此分布的参数:
R_mean = msft['returns'].mean()
R_std = msft['returns'].std()
R_rv = stats.norm(loc = R_mean, scale = R_std)
  1. 绘制R_rv的分布和实际数据的直方图。使用plt.hist()函数和density=True参数,使真实数据和理论分布以相同的比例显示:
fig, ax = plt.subplots()
ax.hist(x = msft['returns'], ec = 'k', \
        bins = 40, density = True,);
x_values = np.linspace(msft['returns'].min(), \
                       msft['returns'].max(), num=100)
densities = R_rv.pdf(x_values)
ax.plot(x_values, densities, color='r')
ax.grid();

输出如下:

图 8.25:MSFT 股票收益直方图

图 8.25:MSFT 股票收益直方图

注意

要访问此特定部分的源代码,请参阅packt.live/2Zw18Ah

您也可以在packt.live/31EmOg9上在线运行此示例。

在查看前面的图后,你会说正态分布是否为微软股票的日收益提供了准确的模型?

不,正态分布并不能提供关于股票分布的非常准确的近似,因为理论分布并不完全遵循直方图的一般形状。尽管直方图关于中心对称且“钟形”,我们可以清楚地观察到零附近的值的频率比我们在正态分布中期望的要高得多,这就是为什么我们可以观察到柱子在图的中心处的红色曲线上方。此外,我们可以观察到许多极端值(左右两侧的小柱子),这些值在正态分布中不太可能出现。

9. Python 中级统计

活动 9.01:标准化测试表现

解决方案

  1. 我们将使用之前创建的 t-置信区间函数来计算 95%的置信区间。我已经在这里重新创建了它以确保完整性:
# We will use the T-Confidence Interval Function 
# we wrote earlier in the Chapter
print("For Math:")
t_confidence_interval(list(data['Math']),0.95)
print("For Reading:")
t_confidence_interval(list(data['Reading']),0.95)
print("For Science:")
t_confidence_interval(list(data['Science']),0.95)

这段代码的输出应该是以下内容:

For Math:
Your 0.95 t confidence interval is (448.2561338314995,473.6869804542148)
For Reading:
Your 0.95 t confidence interval is (449.1937943789569,472.80078847818595)
For Science:
Your 0.95 t confidence interval is (453.8991748650865,476.9790108491992)

看起来我们可以以 95%的置信度说,一个国家的数学平均分在448.3473.7之间,在阅读方面在449.2472.8之间,在科学方面在453.9477.0之间。

  1. 我们将数据集分成两个不同的数据集;一个是每 100 人中有超过50个互联网用户的数据集,另一个是每 100 人中有50个或更少互联网用户的数据集:
# Using A Hypothesis Test, evaluate whether having 
# widespread internet infrastructure could have an 
# impact on scores
# We need to divide the data set into majority 
# internet (more than 50 users out of 100) and 
# minority internet(50 users or less) 
data1 = data[data['internet_users_per_100'] > 50]
data0 = data[data['internet_users_per_100'] <= 50]
print(data1)
print(data0)

这里有两个数据集,data1data0。请注意data1包含所有每 100 人中有超过 50 个互联网用户的国家,而data0包含每 100 人中有 50 个或更少互联网用户的国家:

              internet_users   Math      Reading   Science
              _per_100
Country Code                                                    
ALB           63.252933        413.1570  405.2588  427.2250
ARE           90.500000        427.4827  433.5423  436.7311
ARG           68.043064        409.0333  425.3031  432.2262
AUS           84.560519        493.8962  502.9006  509.9939
AUT           83.940142        496.7423  484.8656  495.0375
...           ...              ...       ...       ...
SWE           90.610200        493.9181  500.1556  493.4224
TTO           69.198471        417.2434  427.2733  424.5905
TUR           53.744979        420.4540  428.3351  425.4895
URY           64.600000        417.9919  436.5721  435.3630
USA           74.554202        469.6285  496.9351  496.2424
[63 rows x 4 columns]
              internet_users   Math      Reading   Science
              _per_100
Country Code                                                      
DZA           38.200000        359.6062  349.8593  375.7451
GEO           47.569760        403.8332  401.2881  411.1315
IDN           21.976068        386.1096  397.2595  403.0997
PER           40.900000        386.5606  397.5414  396.6836
THA           39.316127        415.4638  409.1301  421.3373
TUN           48.519836        366.8180  361.0555  386.4034
VNM           43.500000        494.5183  486.7738  524.6445
  1. 由于我们要比较两个可能具有不同方差的样本,我们将使用scipy.stats包中的 2 样本 t 检验函数。我们的显著性水平将是 5%。由于我们想要测试互联网用户多数的均值是否更高,这将是一个上尾检验。这意味着我们将把 p 值除以 2,并且只有在检验统计量为正时才接受结果为显著。以下代码将运行我们的测试(注意——这是代码的截断版本;完整的代码可以在 GitHub 存储库中找到):
import scipy.stats as sp
math_test_results = sp.ttest_ind(data1['Math'],\
                    data0['Math'],equal_var=False)
print(math_test_results.statistic)
print(math_test_results.pvalue / 2)
reading_test_results = sp.ttest_ind(data1['Reading'],\
                       data0['Reading'],equal_var=False)
print(reading_test_results.statistic)
print(reading_test_results.pvalue / 2)
science_test_results = sp.ttest_ind(data1['Science'],\
                       data0['Science'],equal_var=False)
print(science_test_results.statistic)
print(science_test_results.pvalue / 2)

结果如下:

For Math: (note - statistic must be positive in     order for there to be significance.)
3.6040958108257897
0.0036618262642996438
For Reading: (note - statistic must be positive     in order for there to be significance.)
3.8196670837378237
0.0028727977455195778
For Science: (note - statistic must be positive     in order for there to be significance.)
2.734488895919944
0.01425936325938158

对于数学、阅读和科学,p 值(第二个数字)小于 0.05,检验统计量(第一个数字)为正。这意味着在所有三个测试中,多数互联网用户组的测试成绩显著提高,而少数互联网用户组的测试成绩。

注意

这样的结果总是会引起统计学中一个著名的说法——相关并不意味着因果。这意味着仅仅因为我们发现了互联网多数群体平均分的显著增加,并不意味着互联网导致了分数的增加。可能存在一些第三个未知变量,称为潜在变量,可能导致差异。例如,财富可能是增加分数和互联网使用背后的原因。

  1. 对于我们的最后任务,我们将建立一个线性回归模型,描述数学成绩与阅读和科学成绩的关系。首先,让我们从我们的 DataFrame 中提取分数,并将数学分数放在一个独立的 DataFrame 中,与阅读和科学分数分开。我们将使用sklearn.linear_model中的LinearRegression函数,并将其分配给它自己的变量。然后,我们将使用较小的 DataFrame 拟合模型。最后,我们将打印回归方程的截距和系数:
#import sklearn linear model package
import sklearn.linear_model as lm
# Construct a Linear Model that can predict math 
#    scores from reading and science scores
y = data['Math']
x = data[['Science','Reading']]
model = lm.LinearRegression()
model.fit(x,y)
print(model.coef_)
print(model.intercept_)

结果如下:

[1.02301989 0.0516567 ]
-38.99549267679242

系数按顺序列出,所以科学是第一个,然后是阅读。这将使你的方程为:

图 9.23:数学成绩与阅读和科学成绩的公式

图 9.23:以阅读和科学成绩为基础的数学成绩的公式

  1. 最后,我们将绘制点和回归,并注意线性模型很好地拟合了数据:
import matplotlib.pyplot as plt
from mpl_toolkits import mplot3d
import numpy as np
threedee = plt.figure().gca(projection='3d')
threedee.scatter(data['Science'], data['Reading'],\
                 data['Math'])
threedee.set_xlabel('Science Score')
threedee.set_ylabel('Reading Score')
threedee.set_zlabel('Math Score')
xline = np.linspace(0, 600, 600)
yline = np.linspace(0, 600, 600)
zline = xline*1.02301989 + \
        yline*0.0516567-38.99549267679242
threedee.plot3D(xline, yline, zline, 'red')
plt.show()

结果如下:

图 9.24:线性方程似乎很好地适合我们的数据

图 9.24:线性方程似乎很好地适合我们的数据

注意

要访问此特定部分的源代码,请参阅packt.live/3is2GE8。

您还可以在packt.live/3dWmz2o上在线运行此示例

10. 使用 Python 进行基础微积分

活动 10.01:最大圆锥体积

解决方案:

  1. 要找到所得圆锥的体积,您需要圆锥的高度和底部的半径,就像图 10.33右侧的图中所示。首先,我们找到底部的周长,它等于左侧切割圆上的弧长 AB。您可以将R设置为1,因为我们感兴趣的只是角度。

弧度测量使得找到弧长变得容易。它只是从切割中剩下的角度,即2π - θ乘以半径R,我们将其设置为1。因此θ也是圆锥底部的周长。我们可以建立一个方程并解决r

图 10.34:计算半径的公式

图 10.34:计算半径的公式

  1. 我们将把它编码到我们的程序中。我们需要从 Python 的math模块导入一些东西并定义r变量:
from math import pi,sqrt,degrees
def v(theta):
    r = (2*pi - theta)/(2*pi) 
  1. 圆锥的高度可以使用毕达哥拉斯定理找到,因为圆锥的斜高,即原始圆的半径,我们设置为1图 10.35:计算斜边的公式

图 10.35:计算斜边的公式

圆锥的体积是:

图 10.36:计算圆锥体积的公式

图 10.36:计算圆锥体积的公式

  1. 所以,我们将把它添加到我们的函数中:
    h = sqrt(1-r**2)
    return (1/3)*pi*r**2*h

不难,是吗?这就是我们在使用 Python 时所要做的一切。如果我们按照传统方式进行微积分,我们需要一个仅用一个变量θ表示体积V的表达式。但是我们有一个关于θr表达式,一个关于rh表达式,以及一个关于hr的体积表达式。我们的程序将几乎瞬间计算出体积。

  1. 现在我们可以通过我们的find_max_mins函数运行它。角度以弧度测量,所以我们将从06.28进行检查,并打印出度的版本:
find_max_mins(v,0,6.28)

输出将如下所示:

Max/Min at x= 1.1529999999999838 y= 0.40306652536733706

因此,从原始圆中切出的最佳角度是 1.15 弧度,约为 66 度。

注意

要访问此特定部分的源代码,请参阅packt.live/3iqx6Xj。

您还可以在packt.live/2VJHIqB上在线运行此示例

11. 使用 Python 进行更多微积分

活动 11.01:寻找曲面的最小值

解决方案:

  1. 我们需要导入random模块以使用其uniform函数,该函数在给定范围内选择一个随机小数值:
import random
from math import sin, cos,sqrt,pi
  1. 创建一个函数,它将为我们提供f相对于u在(v,w)处的偏导数:
def partial_d(f,u,v,w,num=10000):
    """returns the partial derivative of f
    with respect to u at (v,w)"""
    delta_u = 1/num
    try:
        if u == 'x':
            return (f(v+delta_u,w) - f(v,w))/delta_u
        else:
            return (f(v,w+delta_u) - f(v,w))/delta_u
    except ValueError:
         pass
  1. 接下来,我们将需要一个曲面函数,x的范围,y的范围和一个步长:
def min_of_surface(f,a,b,c,d,step = 0.01):
  1. 我们将调用random模块的uniform函数来生成起始点的xy值:
    x,y = random.uniform(a,b),random.uniform(c,d)
  1. 我们可能也会打印出测试目的的起始点。如果我们只是说print(x,y,f(x,y)),我们会得到不必要的长小数,所以我们在打印时会将所有内容四舍五入到个小数位:
    print(round(x,2),round(y,2),round(f(x,y),2))
  1. 1 万步可能足够了。我们也可以将其设置为无限循环,使用while True
    for i in range(100000):
  1. 在(x,y)处计算偏导数:
        dz_dx = partial_d(f,'x',x,y, 10000)
        dz_dy = partial_d(f,'y',x,y, 10000)
  1. 如果偏导数都非常接近 0,那意味着我们已经下降到了z的最小值。这可能是局部最小值,但对于这个随机起点,再走更多步也不会有任何进展:
        if abs(dz_dx) < 0.001 and abs(dz_dy) < 0.001:
            print("Minimum:", round(x,2),round(y,2),round(f(x,y),2))
            break
  1. x方向迈出一个微小步骤,与偏导数的值相反。这样,我们总是在z值下降。对y也是一样:
        x -= dz_dx*step
        y -= dz_dy*step
  1. 如果xy超出了我们给定的值范围,打印Out of Bounds并跳出循环:
        if x < a or x > b or y < c or y > d:
            print("Out of Bounds")
            break
  1. 最后,打印出我们最终到达的位置的值,以及它的z值:
    print(round(x,2),round(y,2),round(f(x,y),2))
  1. 让我们在一个我们知道最小值的表面上进行测试:一个抛物面(3D 抛物线),其最小值为 0,在点(0,0)。我们将在-5 到 5 之间的值上进行测试。以下是表面的方程:图 11.48:3D 抛物面的方程

图 11.48:3D 抛物面的方程

  1. 在 Python 中,它看起来像这样:
def surface(x,y):
    return x**2 + y**2

表面的样子如下:

图 11.49:抛物面的图形

图 11.49:抛物面的图形

  1. 我们选择这个,因为与其二维等价物类似,最小点在(0,0),最小的z值是 0。让我们在抛物面上运行min_of_surface函数:
min_of_surface(surface,-5,5,-5,5)

输出如下:

-1.55 2.63 9.29
Minimum: -0.0 0.0 0.0

选择的随机点是(-1.55, 2.63),产生了一个 z 值为 9.29。在它的行走后,它找到了在(0,0)处的最小点,z 值为 0。如果重新运行代码,它将从不同的随机点开始,但最终会到达(0,0)。

  1. 现在我们对min_of_surface函数的工作很有信心,让我们尝试另一个表面:图 11.50:另一个表面的方程

图 11.50:另一个表面的方程

我们将使用-1 < x < 5-1 < y < 5

  1. 首先,重新定义表面函数,然后为指定的范围运行min_of_surface函数:
def surface(x,y):
    return 3*cos(x)+5*x*cos(x)*cos(y)
min_of_surface(surface,-1,5,-1,5)

输出将如下所示:

-0.05 4.07 3.14
Minimum: 1.1 3.14 -1.13

看起来从这个随机点找到的最小点是(1.1,3.14),最小的z值是-1.13

  1. 当我们重新运行代码以确保一切正确时,有时会收到Out of Bounds消息,有时会得到相同的结果,但很多时候,我们最终会到达这一点:
3.24 0.92 -12.8
Minimum: 3.39 0.0 -19.34
  1. 让我们将min_of_surface放入循环中,这样我们就可以运行多次试验:
for i in range(10):
    min_of_surface(surface,-1,5,-1,5)

以下是输出:

1.62 4.65 -0.12
Out of Bounds
2.87 0.47 -15.24
Minimum: 3.39 0.0 -19.34
2.22 0.92 -5.91
Minimum: 3.39 0.0 -19.34
-0.78 -0.85 0.32
Out of Bounds
1.23 3.81 -0.61
Minimum: 1.1 3.14 -1.13
1.96 -0.21 -4.82
Minimum: 3.39 -0.0 -19.34
-0.72 3.0 4.93
Out of Bounds
2.9 -0.51 -15.23
Minimum: 3.39 -0.0 -19.34
1.73 -0.63 -1.58
Minimum: 3.39 -0.0 -19.34
2.02 2.7 2.63
Minimum: 1.1 3.14 -1.13

每次程序产生Minimum时,它都是我们已经看到的两个点中的一个。发生了什么?让我们看一下函数的图形:

图 11.51:f(x,y) = 3cos(x) + 5x cos(x) * cos(y)的图形

图 11.51:1的图形

图表显示的是存在多个最小值。有一个全局最小值,在这个函数深入负数,还有一个局部最小值,在该valley中的任何点都会简单地下降到点(1.1, 3.14),无法离开。

注意

要访问此特定部分的源代码,请参考packt.live/2ApkzCc。

您还可以在packt.live/2Avxt1K上在线运行此示例。

12. 使用 Python 进行中级微积分

活动 12.01:找到粒子的速度和位置

解决方案

  1. 对于第一部分,我们只需要找到2的位置。让我们为dx/dtdy/dt编写函数:
from math import sqrt,sin,cos,e
def dx(t):
    return 1 + 3*sin(t**2)
def dy(t):
    return 15*cos(t**2)*sin(e**t)
  1. 现在,我们可以从 0 到 1.5 循环,并查看dy/dt从正变为负或反之的位置:
t = 0.0
while t<=1.5:
    print(t,dy(t))
    t += 0.05

以下是输出的重要部分:

1.0000000000000002 3.3291911769931715
1.0500000000000003 1.8966982923409172
1.1000000000000003 0.7254255490661741
1.1500000000000004 -0.06119060343046955
1.2000000000000004 -0.3474047235245454
1.2500000000000004 -0.04252527324380706
1.3000000000000005 0.8982461584089145
1.3500000000000005 2.4516137491656442
1.4000000000000006 4.5062509856573225
1.4500000000000006 6.850332845507693

我们可以看到dy/dt在 1.1 和 1.15 之间的某处为零,并且在 1.25 和 3 之间再次为零,因为输出改变了符号。

  1. 让我们使用二分搜索来缩小这些范围。这与之前的bin_search函数相同,只是guess =行不同。我们只是将平均值插入f函数以获得我们的猜测:
def bin_search(f,lower,upper,target):
    def average(a,b):
        return (a+b)/2
    for i in range(40):
        avg = average(lower,upper)
        guess = f(avg)
        if guess == target:
            return guess
        if guess < target:
            upper = avg
        else:
            lower = avg
    return avg
print(bin_search(dy,1.1,1.15,0))

答案是t = 1.145

  1. 对于其他范围,您必须将if guess < target更改为if guess > target,并以这种方式调用函数:
print(bin_search(dy,1.25,1.3,0))

答案是t = 1.253。但那太容易了。挑战在于找到这些时间点粒子的确切x-y位置。

  1. 我们需要一个position函数,它将采取微小的步骤,就像我们的球问题一样:
def position(x0,y0,t):
    """Calculates the height a projectile given the
    initial height and velocity and the elapsed time."""
  1. 首先,我们设置我们的增量变量,并将名为elapsed的变量设置为0
    inc = 0.001
    elapsed = 0
  1. 我们的vxvy的初始值将是 0 时的导数,xy也将从 0 开始:
    vx,vy = dx(0),dy(0)
    x,y = x0,y0
  1. 现在,我们开始循环并运行,直到经过的时间达到所需的t
    while elapsed <= t:
  1. 我们计算水平和垂直速度,然后增加xy以及循环计数器:
        vx,vy = dx(elapsed),dy(elapsed)
        x += vx*inc
        y += vy*inc
        elapsed += inc
    return x,y
  1. 现在,我们将找到的时间放入position函数中,以获取我们知道导数为 0 的时间点粒子的位置:
times = [1.145,1.253]
for t in times:
    print(t,position(-2,3,t))

输出结果如下:

1.145 (0.4740617265786189, 15.338128944560578)
1.253 (0.9023867438757808, 15.313033269941062)

这些是垂直速度为 0 的位置。

  1. 对于第二部分,在那里我们需要找到t = 1时粒子的速度,速度将是由垂直速度和水平速度形成的直角三角形的斜边:
def speed(t):
    return sqrt(dx(t)**2+dy(t)**2)
speed(1.0)

输出如下:

4.848195599011939

粒子的速度是每秒 4.85 个单位。

注意

要访问此特定部分的源代码,请参阅packt.live/3dQjSzy.

您也可以在packt.live/3f0IBCE上在线运行此示例。

posted @ 2025-10-24 09:52  绝不原创的飞龙  阅读(15)  评论(0)    收藏  举报