Python-单行代码指南-全-

Python 单行代码指南(全)

原文:zh.annas-archive.org/md5/2d268ac5e8adebd2959b9bdd15903b81

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

图片

通过这本书,我希望帮助你成为 Python 专家。为此,我们将专注于Python 单行代码:将有用的程序浓缩成一行 Python 代码。专注于单行代码将帮助你更快、更简洁地阅读和编写代码,并提高你对语言的理解。

还有五个理由,我认为学习 Python 单行代码将帮助你提高,并且值得学习。

第一,通过提升你的核心 Python 技能,你将能够克服许多制约你进步的小编程弱点。没有对基础的深刻理解,很难取得进展。单行代码是任何程序的基本构建块。理解这些基本构建块将帮助你掌握高级复杂性,而不会感到不知所措。

第二,你将学会如何利用广受欢迎的 Python 库,例如用于数据科学和机器学习的库。本书由五个单行代码章节组成,每一章涉及 Python 的不同领域,从正则表达式到机器学习。这种方法将给你一个关于可以构建的 Python 应用程序的概览,并教你如何使用这些强大的库。

第三,你将学会编写更 Pythonic 的代码。Python 初学者,尤其是那些来自其他编程语言的人,往往会以非 Pythonic 的方式编写代码。我们将介绍 Python 特有的概念,如列表推导式、多重赋值和切片,这些都将帮助你编写易于阅读且能够与其他开发者分享的代码。

第四,学习 Python 单行代码迫使你清晰而简洁地思考。当你让每个代码符号都发挥作用时,就没有空间进行冗长和不专注的编码。

第五,你新的单行代码技能将帮助你透过复杂的 Python 代码库,给朋友和面试官留下深刻印象。你也可能会发现用一行代码解决挑战性编程问题既有趣又令人满意。而且你并不孤单:一个充满活力的 Python 极客社区正在竞争解决各种实际(或不太实际)问题的最精简、最 Pythonic 的解决方案。

Python 单行代码示例

本书的核心论点是,学习 Python 单行代码既是理解更高级代码库的基础,也是提升编程技能的绝佳工具。在理解一个拥有成千上万行代码的代码库之前,你必须理解一行代码的含义。

让我们快速看一下一个 Python 单行代码。不要担心如果你还没完全理解它。你将在第六章掌握这行代码。

q = lambda l: q( ➊[x for x in l[1:] if x <= l[0]]) + [l[0]] + q([x for x in l if x > l[0]]) if l else []

这一行代码是压缩著名的快速排序算法的一种简洁优美的方式,尽管对许多 Python 初学者和中级开发者来说,其含义可能很难理解。

Python 的一行代码往往是相互衔接的,所以本书中的一行代码会逐步增加复杂性。在这本书中,我们将从简单的一行代码开始,逐步构建起更复杂的代码。例如,前面的快速排序一行代码既困难又长,它是基于更简单的列表推导式概念 ➊。下面是一个更简单的列表推导式,用来创建一个包含平方数的列表:

lst  = [x**2 for x in range(10)]

我们可以将这一行代码拆解成更简单的一行代码,教会你 Python 的重要基础知识,比如变量赋值、数学运算符、数据结构、for 循环、成员运算符和 range() 函数——这些都在一行 Python 代码中完成!

请知道,基础并不意味着琐碎。我们要看的所有一行代码都是有用的,每一章都涉及计算机科学中的一个独立领域,给你一个广阔的视角,展示 Python 的强大功能。

关于可读性的说明

Python 之禅包括了 19 条指导原则,针对 Python 编程语言。你可以通过在 Python shell 中输入import this来阅读它:

>>> import this

The Zen of Python, by Tim Peters

Beautiful is better than ugly.

Explicit is better than implicit.

Simple is better than complex.

Complex is better than complicated.

Flat is better than nested.

Sparse is better than dense.

Readability counts.

--snip--

根据Python 之禅,“可读性很重要。”一行代码是解决问题的极简程序。在许多情况下,将一段代码重写成 Python 一行代码会提高可读性,并使代码更符合 Python 风格。一个例子是使用列表推导式将创建列表的过程压缩成一行代码。看下面的例子:

# BEFORE

squares = []

for i in range(10):

    squares.append(i**2)

print(squares)

# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

在这段代码中,我们需要五行代码来创建前 10 个平方数的列表并将其打印到 shell。然而,使用一行代码的解决方案要好得多,它能以更具可读性和更简洁的方式完成相同的任务:

# AFTER

print([i**2 for i in range(10)])

# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

输出结果是一样的,但这一行代码建立在更符合 Python 风格的列表推导式概念上。它更容易阅读,且更简洁。

然而,Python 的一行代码有时也难以理解。在某些情况下,写一个 Python 一行代码并不一定更具可读性。但正如国际象棋大师必须知道所有可能的棋步才能决定哪一步最优一样,你必须了解所有表达思路的方式,这样你才能决定最好的实现方式。追求最美的解决方案并非低优先级的问题;它是 Python 生态系统的核心。正如Python 之禅所教,“美胜于丑。”

这本书适合谁?

你是一个初学到中级水平的 Python 编程者吗?像你身边的许多人一样,你可能在编程进展上遇到了一些瓶颈。这本书可以帮助你。你已经读了很多在线编程教程。你写过自己的源代码,并成功发布了一些小项目。你已经完成了基础编程课程,并阅读过一两本编程教材。也许你甚至完成了大学里的技术课程,学习了计算机科学和编程的基础知识。

也许你受限于某些信念,比如认为大多数程序员比你更快理解源代码,或者认为自己远没有达到程序员的前 10%。如果你想达到更高的编码水平并加入顶尖的编程专家行列,你需要学习新的实用技能。

我能理解,因为在我十年前开始学习计算机科学时,我也曾困惑于自己对编程一无所知。同时,似乎所有的同龄人都已经非常有经验并且熟练。

在本书中,我希望帮助你克服这些限制性信念,并推动你迈向 Python 大师的一步。

你将学到什么?

这里是你将学到的内容概述。

第一章:Python 复习 介绍了 Python 的基础知识,帮助你刷新记忆。

第二章:Python 技巧 包含 10 个一行代码技巧,帮助你掌握基础内容,如列表推导式、文件输入、lambda函数、map()zip()all()量词、切片和基本的列表运算。你还将学习如何使用、操作和利用数据结构来解决日常问题。

第三章:数据科学 包含 10 个用于数据科学的一行代码,基于 NumPy 库构建。NumPy 是 Python 强大机器学习和数据科学能力的核心。你将学习 NumPy 的基础知识,如数组、形状、轴、类型、广播、高级索引、切片、排序、搜索、聚合和统计。

第四章:机器学习 涵盖了 10 个用于机器学习的简洁代码,使用 Python 的 scikit-learn 库。你将学习回归算法来预测值,诸如线性回归、K 近邻和神经网络等示例。你还将学习分类算法,如逻辑回归、决策树学习、支持向量机和随机森林。此外,你将学习如何计算多维数据数组的基本统计数据,以及用于无监督学习的 K 均值算法。这些算法和方法是机器学习领域最重要的算法之一。

第五章:正则表达式 包含 10 个一行代码,帮助你更好地使用正则表达式。你将学习各种基础的正则表达式,可以通过组合(和重新组合)来创建更复杂的正则表达式,使用分组和命名分组、负向前瞻、转义字符、空白字符、字符集(和否定字符集)、贪婪/非贪婪操作符。

第六章:算法 包含了 10 个单行算法,涉及广泛的计算机科学主题,包括字谜、回文、超集、排列、阶乘、素数、斐波那契数、混淆、搜索和算法排序。许多算法构成了更高级算法的基础,并包含了深入算法教育的种子。

后记 总结了本书,并将你释放到真实的世界中,带着你新获得的、更强的 Python 编程技能。

在线资源

为了增强本书的训练材料,我添加了额外的资源,你可以在网上找到,链接地址是 https://pythononeliners.com/http://www.nostarch.com/pythononeliners/。这些互动资源包括以下内容:

Python 备忘单 你可以下载这些 Python 备忘单作为可打印的 PDF 文件,并将它们钉在墙上。备忘单包含了 Python 语言的核心特性,如果你仔细学习它们,你可以刷新你的 Python 技能,并确保填补任何知识空白。

单行代码视频课程 作为我的 Python 邮件课程的一部分,我录制了很多来自本书的 Python 单行代码课程,你可以免费访问。这些课程将帮助你学习,并提供多媒体学习体验。

Python 谜题 你可以访问在线资源来解决 Python 谜题,并使用Finxter.com应用免费测试和训练你的 Python 技能,随着你阅读本书,你还可以衡量自己的学习进度。

代码文件和 Jupyter 笔记本 你必须挽起袖子,开始动手编写代码,才能朝着 Python 精通的目标迈进。花点时间玩弄不同的参数值和输入数据。为了方便你,我已经将所有 Python 单行代码作为可执行代码文件添加进来。

第一章:PYTHON 复习**

图片

本章的目的是刷新你对基本 Python 数据结构、关键字、控制流操作及其他基础知识的记忆。我写这本书是为了帮助中级 Python 程序员提升到更高的编程水平。要达到专家级别,你需要彻底学习基础知识。

理解基础知识让你能够退一步看整体——这是无论你想成为谷歌的技术负责人、计算机科学教授,还是成为一名优秀的程序员,都会用到的重要技能。例如,计算机科学教授通常对自己领域的基础知识有非常深刻的理解,这让他们能够从第一原理出发进行论证,并发现研究的空白,而不是被最新的最先进技术所迷惑。本章介绍了最重要的 Python 基础知识,它们为本书后续更高级的主题奠定了基础。

基本数据结构

对数据结构的透彻理解是你作为程序员可以获得的最基本技能之一。无论你是创建机器学习项目、处理大型代码库、搭建和管理网站,还是编写算法,它都能为你提供帮助。

数值数据类型和结构

两种最重要的数值数据类型是整数和浮点数。整数是没有小数点的正数或负数(例如,3)。浮点数是带有浮动小数精度的正数或负数(例如,3.14159265359)。Python 提供了多种内置的数值运算功能,并且可以在这些数值数据类型之间进行转换。请仔细研究列表 1-1 中的示例,以掌握这些非常重要的数值运算。

## Arithmetic Operations

x, y = 3, 2

print(x + y) # = 5

print(x - y) # = 1

print(x * y) # = 6

print(x / y) # = 1.5

print(x // y) # = 1

print(x % y) # = 1

print(-x) # = -3

print(abs(-x)) # = 3

print(int(3.9)) # = 3

print(float(x)) # = 3.0

print(x ** y) # = 9

列表 1-1:数值数据类型

大多数运算符是显而易见的。请注意,//运算符执行整数除法,结果是一个向下取整的整数值(例如,3 // 2 == 1)。

布尔值

类型为布尔型的变量只能取两个值——FalseTrue

在 Python 中,布尔类型和整数类型密切相关:布尔类型在内部使用整数值(默认情况下,布尔值False由整数0表示,布尔值True由整数1表示)。列表 1-2 给出了这两个布尔关键字的示例。

x = 1 > 2

print(x)

# False

y = 2 > 1

print(y)

# True

列表 1-2:布尔值FalseTrue

在评估给定的表达式后,变量x表示布尔值False,变量y表示布尔值True

你可以在 Python 中使用布尔值与三个重要的关键字组合,创建更复杂的表达式。

关键字:and,or,not

布尔表达式表示基本的逻辑运算符。通过将它们与以下三个关键字结合使用,你可以构造多种可能复杂的表达式:

and 表达式 x and y 会在 xTrue yTrue 时评估为 True。如果其中任何一个为 False,整个表达式也会变成 False

or 表达式 x or y 会在 xTrue 或者 yTrue(或者两者都为 True)时评估为 True。如果其中任何一个为 True,整个表达式也会变成 True

not 表达式 not x 如果 xFalse 时评估为 True,否则评估为 False

考虑 清单 1-3 中的 Python 代码。

x, y = True, False

print((x or y) == True)

# True

print((x and y) == False)

# True

print((not y) == True)

# True

清单 1-3: 关键字 andornot

通过使用这三个关键词,你可以表达所有你需要的逻辑表达式。

布尔运算符优先级

布尔运算符的应用顺序是理解布尔逻辑的一个重要方面。例如,考虑自然语言中的语句 "下雨且天气寒冷或刮风"。我们可以用两种方式来解释这个表达式:

"(下雨且天气寒冷)或刮风" 在这种情况下,如果是刮风的话,语句会被评估为True——即使没有下雨。

并且 "下雨且(天气寒冷或刮风)" 然而,在这种情况下,如果没有下雨,无论天气是否寒冷或刮风,语句会被评估为False

布尔运算符的顺序很重要。这个语句的正确解释应该是第一个,因为 and 运算符优先级高于 or 运算符。让我们看一下 清单 1-4 中的代码片段。

## 1\. Boolean Operations

x, y = True, False

print(x and not y)

# True

print(not x and y or x)

# True

## 2\. If condition evaluates to False

if None or 0 or 0.0 or '' or [] or {} or set():

    print("Dead code") # Not reached

清单 1-4: 布尔数据类型

这段代码展示了两个重要的观点。首先,布尔运算符按优先级排序——not 运算符具有最高优先级,其次是 and 运算符,然后是 or 运算符。其次,以下值会自动评估为 False:关键字 None、整数值 0、浮点值 0.0、空字符串或空容器类型。

字符串

Python 字符串 是字符的序列。字符串是不可变的,因此在创建后不能修改。虽然还有其他方式来创建字符串,但以下是五种最常用的方法:

单引号 'Yes'

双引号 "Yes"

三引号用于多行字符串 '''Yes'''"""Yes"""

字符串方法 str(5) == '5' 结果为 True

连接 'Py' + 'thon' 变成 'Python'

经常,你会显式地在字符串中使用 空白字符。最常用的空白字符包括换行符 \n、空格符 \s 和制表符 \t

清单 1-5 展示了最重要的字符串方法。

## Most Important String Methods

y = "    This is lazy\t\n   "

print(y.strip())

# Remove Whitespace: 'This is lazy'

print("DrDre".lower())

# Lowercase: 'drdre'

print("attention".upper())

# Uppercase: 'ATTENTION'

print("smartphone".startswith("smart"))

# Matches the string's prefix against the argument: True

print("smartphone".endswith("phone"))

# Matches the string's suffix against the argument: True

print("another".find("other"))

# Match index: 2

print("cheat".replace("ch", "m"))

# Replaces all occurrences of the first by the second argument: meat

print(','.join(["F", "B", "I"]))

# Glues together all elements in the list using the separator string: F,B,I

print(len("Rumpelstiltskin"))

# String length: 15

print("ear" in "earth")

# Contains: True

清单 1-5: 字符串数据类型

这个非详尽的字符串方法列表展示了字符串数据类型的强大,你可以通过 Python 内建功能解决许多常见的字符串问题。如果对如何处理字符串问题有疑问,可以查阅在线参考文档,列出了所有内建的字符串方法:https://docs.python.org/3/library/string.html#module-string

布尔值、整数、浮点数和字符串是 Python 中最重要的基本数据类型。但通常,你需要构建数据项,而不仅仅是创建它们。在这些情况下,容器类型是答案。但是在我们深入容器数据结构之前,先快速了解一个重要的特殊数据类型:None

关键字 None

关键字 None 是 Python 常量,表示没有值。其他编程语言,如 Java,使用 null 值来代替。然而,null 这个术语常常让初学者感到困惑,他们误以为它等同于整数值 0。实际上,Python 使用关键字 None,如列表 1-6 所示,用来表明它与任何数字值、空列表或空字符串不同。一个有趣的事实是,NoneNoneType 数据类型中唯一的值。

def f():

   x = 2

# The keyword 'is' will be introduced next

print(f() is None)

# True

print("" == None)

# False

print(0 == None)

# False

列表 1-6:使用关键字 None

这段代码展示了几个 None 数据值的示例(以及它不代表的东西)。如果你没有为一个函数定义返回值,默认返回值就是 None

容器数据结构

Python 提供了容器数据类型,这些数据类型能够高效地处理复杂操作,同时易于使用。

列表

列表是一种容器数据类型,用于存储一系列元素。与字符串不同,列表是可变的——你可以在运行时修改它们。我可以通过一系列示例来最好地描述列表数据类型:

l = [1, 2, 2]

print(len(l))

# 3

这段代码展示了如何使用方括号创建一个列表,并如何用三个整数元素填充它。你还可以看到列表可以包含重复元素。len() 函数返回列表中的元素数量。

关键字:is

关键字 is 简单地检查两个变量是否引用了内存中的同一个对象。这可能会让 Python 新手感到困惑。列表 1-7 检查两个整数和两个列表是否引用了内存中的同一个对象。

y = x = 3

print(x is y)

# True

print([3] is [3])

# False

列表 1-7:使用关键字 is

如果你创建了两个列表——即使它们包含相同的元素——它们仍然引用内存中两个不同的列表对象。修改其中一个列表对象不会影响另一个列表对象。我们说列表是 可变的,因为你可以在创建后修改它们。因此,如果你检查两个列表是否引用内存中的同一个对象,结果会是 False。然而,整数值是 不可变的,因此不存在一个变量改变对象后会意外地更改所有其他变量的风险。原因在于,你不能改变整数对象 3——尝试这么做只会创建一个新的整数对象,而不会修改旧的对象。

添加元素

Python 提供了三种常见方式来向现有列表添加元素:appendinsertlist concatenation

# 1\. Append

l = [1, 2, 2]

l.append(4)

print(l)

# [1, 2, 2, 4]

# 2\. Insert

l = [1, 2, 4]

l.insert(2, 3)

print(l)

# [1, 2, 3, 4]

# 3\. List Concatenation

print([1, 2, 2] + [4])

# [1, 2, 2, 4]

所有这三种操作都会生成相同的列表 [1, 2, 2, 4]。但是,append 操作是最快的,因为它不需要遍历列表来将元素插入到正确的位置(如 insert 操作),也不需要从两个子列表创建一个新的列表(如 list concatenation 操作)。大致来说,只有当你希望将元素添加到列表中的某个特定位置(而不是最后一个位置)时,才会使用 insert 操作。而你会使用 list concatenation 操作来连接两个任意长度的列表。请注意,第四种方法 extend(),可以高效地将多个元素添加到给定的列表中。

移除元素

你可以通过使用 list 方法 remove(x) 来轻松地从列表中移除元素 x

l = [1, 2, 2, 4]

l.remove(1)

print(l)

# [2, 2, 4]

该方法作用于列表对象本身,而不是创建一个新列表来保存变更。在之前的代码示例中,我们创建了一个名为 l 的列表对象,并通过删除元素修改了该对象在内存中的内容。这通过减少冗余的列表数据副本来节省内存开销。

反转列表

你可以通过使用方法 list.reverse() 来反转列表元素的顺序:

l = [1, 2, 2, 4]

l.reverse()

print(l)

# [4, 2, 2, 1]

反转列表也会修改原始的列表对象,而不仅仅是创建一个新的列表对象。

排序列表

你可以通过使用方法 list.sort() 来对列表元素进行排序:

l = [2, 1, 4, 2]

l.sort()

print(l)

# [1, 2, 2, 4]

同样,排序列表会修改原始的列表对象。结果列表是按升序排序的。包含字符串对象的列表将按升序字典顺序排序(从 'a''z')。通常,排序函数假设两个对象可以进行比较。大致来说,如果你能够计算出 a > b,对于任何数据类型的对象 ab,Python 也可以对列表 [a, b] 进行排序。

索引列表元素

你可以通过使用方法 list.index(x) 来查找指定列表元素 x 的索引:

print([2, 2, 4].index(2))

## print([2, 2, 4].index(2,1))

# 1

方法 index(x) 查找列表中元素 x 的第一次出现并返回其索引。像其他主要编程语言一样,Python 将索引 0 分配给第一个序列,索引 i–1 分配给第 i 个序列。

数据结构直观地作为一种先进先出(FIFO)的结构工作。可以把它想象成一堆文件:每次新增一张纸时,你会把它放在旧文件堆的最上面,而在处理堆中的文件时,你会一直取出最上面的一张。栈依然是计算机科学中的一种基本数据结构,广泛用于操作系统管理、算法、语法解析和回溯。

Python 列表可以直观地用作栈,通过列表操作 append() 向栈中添加元素,通过 pop() 删除最近添加的元素:

stack = [3]

stack.append(42) # [3, 42]

stack.pop() # 42 (stack: [3])

stack.pop() # 3 (stack: [])

由于列表实现的效率,通常不需要导入外部的栈库。

集合

集合 数据结构是 Python 以及许多其他编程语言中的一种基础集合数据类型。对于分布式计算的流行语言(例如 MapReduce 或 Apache Spark)来说,它们几乎专注于集合操作作为编程原语。那么,集合到底是什么呢?集合是一个无序的唯一元素集合。我们将这个定义分解为它的几个主要部分。

集合

集合是一个包含元素的集合,类似于列表或元组。集合中的元素可以是原始元素(整数、浮动数、字符串),也可以是复杂元素(对象、元组)。然而,集合中的所有数据类型必须是 可哈希的,这意味着它们有一个与之相关联的哈希值。对象的哈希值永远不会改变,它用于将该对象与其他对象进行比较。我们来看一下 清单 1-8 中的一个例子,它通过检查字符串的哈希值来创建一个由三个字符串组成的集合。你尝试创建一个由列表组成的集合,但由于列表不可哈希,操作失败。

hero = "Harry"

guide = "Dumbledore"

enemy = "Lord V."

print(hash(hero))

## print(hash(guide))

# -5197671124693729851

## Can we create a set of strings?

characters = {hero, guide, enemy}

print(characters)

# {'Lord V.', 'Dumbledore', 'Harry'}

## Can we create a set of lists?

team_1 = [hero, guide]

team_2 = [enemy]

teams = {team_1, team_2}

# TypeError: unhashable type: 'list'

清单 1-8:集合数据类型只允许可哈希元素。

你可以创建一个字符串集合,因为字符串是 可哈希的。但是你不能创建一个由列表组成的集合,因为列表是 不可哈希的。原因在于哈希值依赖于项的内容,而列表是 可变的;如果你更改列表数据类型,那么哈希值也必须发生变化。由于可变数据类型不可哈希,所以你不能在集合中使用它们。

无序

与列表不同,集合中的元素没有固定的顺序。无论你以什么顺序将元素放入集合,你永远无法确定集合存储这些元素的顺序。以下是一个例子:

characters = {hero, guide, enemy}

print(characters)

# {'Lord V.', 'Dumbledore', 'Harry'}

我先放入英雄,但我的解释器却先打印敌人(显然,Python 解释器站在黑暗面)。请注意,你的解释器可能会以另一种顺序打印集合中的元素。

唯一

集合中的所有元素必须是唯一的。严格来说,集合中任意两个值 x, y,满足 x!=y 时,它们的哈希值也应该不同,即 hash(x)!=hash(y)。因为集合中的每两个元素 xy 都是不同的,所以你不能创造一支哈利·波特的克隆军队去对抗伏地魔。

clone_army = {hero, hero, hero, hero, hero, enemy}

print(clone_army)

# {'Lord V.', 'Harry'}

无论你多少次将相同的值放入同一个集合,集合只会存储该值的一个实例。原因在于这些值的哈希值相同,集合每个哈希值最多只能包含一个元素。普通集合数据结构的扩展是多重集合数据结构,它可以存储相同值的多个实例。然而,它在实际应用中很少被使用。相比之下,几乎在任何非平凡的代码项目中,你都会使用集合——例如,将一组顾客与一组访问过商店的人员进行交集运算,这将返回一个新的包含同时访问过商店的顾客的集合。

字典

字典是一个用于存储(键, 值)对的有用数据结构:

calories = {'apple' : 52, 'banana' : 89, 'choco' : 546}

你可以通过在方括号内指定键来读取和写入元素:

print(calories['apple'] < calories['choco'])

# True

calories['cappu'] = 74

print(calories['banana'] < calories['cappu'])

# False

使用keys()values()函数访问字典的所有键和值:

print('apple' in calories.keys())

# True

print(52 in calories.values())

# True

使用items()方法访问字典的(键, 值)对:

for k, v in calories.items():

    print(k) if v > 500 else None

# 'choco'

这样,你就可以轻松地遍历字典中的所有键和值,而无需单独访问它们。

成员资格

使用关键字in来检查集合、列表或字典中是否包含某个元素(参见列表 1-9)。

➊ print(42 in [2, 39, 42]) 

  # True

➋ print("21" in {"2", "39", "42"}) 

  # False

  print("list" in {"list" : [1, 2, 3], "set" : {1,2,3}})

  # True

列表 1-9:使用关键字in

你可以使用关键字in来测试整数值42 ➊ 是否存在于整数值列表中,或者测试字符串值"21"是否存在于字符串集合中 ➋。我们说xy成员,如果元素x出现在集合y中。

检查集合成员比检查列表成员要快:要检查元素x是否出现在列表y中,你需要遍历整个列表,直到找到x或者检查完所有元素。然而,集合的实现方式类似于字典:要检查元素x是否出现在集合y中,Python 会在内部执行一次操作y[hash(x)],并检查返回值是否不为None

列表和集合推导式

列表推导式是 Python 中的一个流行特性,帮助你快速创建和修改列表。其简单公式是[ 表达式 + 上下文 ]

表达式 告诉 Python 如何处理列表中的每个元素。

上下文 告诉 Python 应该选择列表中的哪些元素。上下文由任意数量的forif语句组成。

例如,在列表推导式语句[x for x in range(3)]中,第一部分x是(标识)表达式,第二部分for x in range(3)是上下文。该语句创建了列表[0, 1, 2]range()函数返回 0、1 和 2 这三个连续的整数值——当使用一个参数时,如示例中所示。另一个列表推导式的代码示例如下:

# (name, $-income)

customers = [("John", 240000),

             ("Alice", 120000),

             ("Ann", 1100000),

             ("Zach", 44000)]

# your high-value customers earning >$1M

whales = [x for x,y in customers if y>1000000]

print(whales)

# ['Ann']

集合推导式类似于列表推导式,但创建的是一个集合而不是列表。

控制流

控制流功能让你可以在代码中做出决策。算法常常被比作烹饪食谱,它们由一系列顺序的命令组成:把水倒进锅里,加入盐,加入米,倒掉水,最后把米盛出来。如果没有条件执行,这些命令的执行将仅需几秒钟,米饭也肯定不会做好。例如,你可能会先加入水、盐和米,然后立即把水倒掉,而不等水变热、米变软。

你需要根据不同的情况做出不同的反应:只有水已经热了,你才需要把米放进锅里;只有米已经煮软了,你才需要把水从锅里倒掉。几乎不可能用一种方式编写程序,预见现实世界中发生的所有确定性事件。相反,你需要编写能够在满足不同条件时作出不同响应的程序。

if、else 和 elif

关键字ifelseelif(见列表 1-10)使你能够对不同的代码分支进行条件执行。

➊ x = int(input("your value: ")) 

➋ if x > 3: 

      print("Big")

➌ elif x == 3: 

      print("Medium")

➍ else: 

      print("Small")

列表 1-10:使用关键字ifelseelif

这段代码首先获取用户输入,将其转换为整数,并存储在变量x中➊。然后测试变量的值是否大于➋、等于➌,或者小于➍值3。换句话说,代码以差异化的方式响应现实世界中的不可预测输入。

循环

为了允许代码片段的重复执行,Python 使用两种类型的循环:for循环和while循环。通过使用这些循环,你可以轻松地编写一个仅由两行代码组成的程序,且能永远执行下去。否则,这种重复执行会变得很困难(另一个方法是递归)。

在列表 1-11 中,你可以看到这两种循环变体的实际应用。

# For loop declaration

for i in [0, 1, 2]:

   print(i)

'''

0

1

2

'''

# While loop - same semantics

j = 0

while j < 3:

   print(j)

   j = j + 1

'''

0

1

2

'''

列表 1-11:使用关键字forwhile

两种循环变体都会将整数012打印到终端,但以两种不同的方式完成任务。

for循环声明了一个循环变量i,它会依次取列表[0, 1, 2]中的所有值。循环会一直执行,直到值用尽。

while循环会在满足特定条件时执行循环体——在我们的例子中,当j < 3时。

终止循环有两种基本方式:你可以定义一个最终评估为False的循环条件,或者在循环体的特定位置使用关键字break。列表 1-12 展示了后一种方式的示例。

while True:

   break # no infinite loop

print("hello world")

# hello world

列表 1-12:使用关键字break

你创建了一个while循环,其循环条件总是评估为True。所以乍一看,它似乎会一直运行下去。无限while循环是常见做法,例如在开发 web 服务器时,服务器会重复执行以下过程:等待新的网页请求并提供服务。然而,在某些情况下,你仍然希望提前终止循环。在 web 服务器的例子中,当服务器检测到正在受到攻击时,你会停止提供文件以确保安全。在这些情况下,你可以使用关键字break来停止循环并立即执行后续代码。在 Listing 1-12 中,循环提前结束后,代码执行print("hello world")

还可以强制 Python 解释器跳过循环中的某些区域,而不提前结束循环。例如,你可能希望跳过恶意的网页请求,而不是完全停止服务器。你可以通过使用continue语句来实现这一点,它会结束当前的循环迭代并将执行流返回到循环条件(参见 Listing 1-13)。

while True:

  continue

  print("43") # dead code

Listing 1-13: 使用关键字continue

这段代码会一直执行,但从未执行过print语句。原因是continue语句结束当前的循环迭代并将其带回到开始,所以执行从未到达print语句。永远不会执行的代码被称为死代码。因此,continue语句(以及break语句)通常在某些条件下,通过使用条件 if-else 语句来使用。

函数

函数帮助你在需要时重用代码片段:只需编写一次,但可以多次使用它们。你可以使用关键字def定义一个函数,接着是函数名称和一组参数来定制函数体的执行。用两组参数调用函数可以极大地改变函数的结果。例如,你可以定义函数square(x),它返回输入参数x的平方值。调用square(10)的结果是10 × 10 = 100,而调用square(100)的结果是100 × 100 = 10,000

关键字return终止函数并将执行流传递给调用该函数的地方。你还可以在return关键字后提供一个可选值,以指定函数的返回结果(参见 Listing 1-14)。

def appreciate(x, percentage):

   return x + x * percentage / 100

print(appreciate(10000, 5))

# 10500.0

Listing 1-14: 使用关键字return

你创建了一个函数appreciate(),它计算给定投资在指定回报率下的增值情况。在代码中,你计算了当假设利率为 5%时,$10,000 的投资在一年内增值了多少。结果是$10,500。你使用了关键字return来指定函数的返回值应该是原始投资和该投资的名义利息之和。函数appreciate()的返回值类型为 float。

Lambda 表达式

你可以使用关键字 lambda 来定义 Python 中的 lambda 函数。Lambda 函数 是匿名函数,它们没有在命名空间中定义。简单来说,lambda 函数就是没有名称的函数,通常用于单次使用。其语法如下:

lambda <arguments> : <return expression>

一个 lambda 函数可以有一个或多个参数,用逗号分隔。在冒号(:)之后,你定义了返回表达式,该表达式可以(也可以不)使用已定义的参数。返回表达式可以是任何表达式,甚至是另一个函数。

Lambda 函数在 Python 中扮演着重要角色。在实际的代码项目中,你会经常看到它们:例如,用来简化代码或创建各种 Python 函数的参数(如 map()reduce())。请看 示例 1-15 中的代码。

print((lambda x: x + 3)(3))

# 6

示例 1-15:使用关键字 lambda

首先,你创建了一个 lambda 函数,它接受一个值 x 并返回表达式 x + 3 的结果。返回的结果是一个函数对象,可以像其他函数一样被调用。由于其语义的原因,你将这个函数称为 增量函数。当用参数 x=3 调用这个增量函数时——即 示例 1-15 中 print 语句后的后缀 (3)——结果是整数值 6。本书大量使用 lambda 函数,因此请确保你正确理解它们(尽管你也将有机会进一步提升对 lambda 函数的直观理解)。

总结

本章为你提供了一个简洁的 Python 快速入门课程,以刷新你对基础 Python 知识的掌握。你学习了最重要的 Python 数据结构,以及如何在代码示例中使用它们。你学会了如何通过使用 if-elif-else 语句以及 whilefor 循环来控制程序的执行流程。你复习了 Python 中的基本数据类型——布尔型、整数、浮点型和字符串,并了解了哪些内建操作和函数是常用的。大多数实际代码片段和复杂算法都围绕着更强大的容器类型构建,如列表、栈、集合和字典。通过学习给定的示例,你学会了如何添加、删除、插入和重新排序元素。你还了解了成员操作符和列表推导式:这是一个高效且强大的内建方法,用于在 Python 中以编程方式创建列表。最后,你学会了函数的定义方法(包括匿名的 lambda 函数)。现在,你已经准备好学习 Python 中的前 10 个基础单行代码。

第二章:PYTHON 技巧**

Image

对我们来说,技巧是一种出人意料地快速或简便地完成任务的方法。在本书中,你将学习多种技巧和技术,使代码更加简洁,并提高实现的速度。虽然本书的所有技术章节都展示了 Python 技巧,但这一章关注的是“低垂的果实”:你可以迅速而轻松地采纳的技巧,但对提高编码生产力有极大帮助。

本章也为后面更高级的章节奠定基础。你需要理解这些单行代码中介绍的技能,才能理解后续的内容。特别地,我们将涵盖一系列基本的 Python 功能,帮助你编写高效的代码,包括列表推导式、文件访问、map() 函数、lambda 函数、reduce() 函数、切片、切片赋值、生成器函数以及 zip() 函数。

如果你已经是一个高级程序员,可以快速浏览这一章,并选择你想要深入研究的部分——以及你已经掌握得很好的一些部分。

使用列表推导式查找高收入者

在这一节中,你将学习到一个既优美又强大、高效的 Python 特性——列表推导式。你将在接下来的许多单行代码中使用列表推导式。

基础知识

假设你在一家大公司的 HR 部门工作,需要找出所有年收入至少为 10 万美元的员工。你期望的输出结果是一个元组列表,每个元组包含两个值:员工姓名和员工的年薪。你可以开发出以下代码:

employees = {'Alice' : 100000,

             'Bob' : 99817,

             'Carol' : 122908,

             'Frank' : 88123,

             'Eve' : 93121}

top_earners = []

for key, val in employees.items():

    if val >= 100000:

        top_earners.append((key,val))

print(top_earners)

# [('Alice', 100000), ('Carol', 122908)]

虽然代码是正确的,但有一种更简单、更加简洁——因此也更易读——的方法可以实现相同的结果。其他条件相同的情况下,行数更少的解决方案能让读者更快地理解代码的含义。

Python 提供了一种强大的创建新列表的方法:列表推导式。其简单的公式如下:

[ expression + context ]

包围的括号表示结果是一个新的列表。上下文定义了选择哪些列表元素。表达式定义了如何在将结果添加到列表之前修改每个列表元素。下面是一个示例:

[x * 2 for x in range(3)]

方程中的粗体部分,for x in range(3),是上下文,剩余部分 x * 2 是表达式。大致来说,表达式将上下文生成的 0、1、2 的值翻倍。因此,列表推导式的结果是如下列表:

[0, 2, 4]

表达式和上下文都可以是任意复杂的。表达式可以是上下文中定义的任何变量的函数,并且可以执行任何计算——它甚至可以调用外部函数。表达式的目标是修改每个列表元素,然后将其添加到新列表中。

上下文可以由一个或多个变量组成,这些变量是通过一个或多个嵌套的 for 循环定义的。你也可以通过使用 if 语句来限制上下文。在这种情况下,只有在用户定义的条件成立时,新的值才会被添加到列表中。

列表推导式最好通过示例来解释。仔细研究以下示例,你将能更好地理解列表推导式:

print([➊x ➋for x in range(5)])

# [0, 1, 2, 3, 4]

表达式 ➊:恒等函数(不改变上下文变量 x)。

上下文 ➋:上下文变量 x 获取由范围函数返回的所有值:0, 1, 2, 3, 4

print([➊(x, y) ➋for x in range(3) for y in range(3)])

# [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2)]

表达式 ➊:从上下文变量 xy 创建一个新的元组。

上下文 ➋:上下文变量 x 遍历范围函数返回的所有值 (012),而上下文变量 y 遍历范围函数返回的所有值 (012)。这两个 for 循环是嵌套的,因此上下文变量 y 会对上下文变量 x 的每个值重复其迭代过程。因此,总共有 3 × 3 = 9 种上下文变量组合。

print([➊x ** 2 ➋for x in range(10) if x % 2 > 0])

# [1, 9, 25, 49, 81]

表达式 ➊:对上下文变量 x 应用平方函数。

上下文 ➋:上下文变量 x 遍历范围函数返回的所有值——0123456789——但仅当它们是奇数时,即 x % 2 > 0

print([➊x.lower() ➋for x in ['I', 'AM', 'NOT', 'SHOUTING']])

# ['i', 'am', 'not', 'shouting']

表达式 ➊:对上下文变量 x 应用字符串小写函数。

上下文 ➋:上下文变量 x 遍历列表中的所有字符串值:'I''AM''NOT''SHOUTING'

现在,你应该能够理解以下的代码片段。

代码

让我们考虑之前提到的员工薪水问题:给定一个包含字符串键和整数值的字典,创建一个新的 (key, value) 元组列表,其中键对应的值大于或等于 100,000。 示例 2-1 展示了相关代码。

## Data

employees = {'Alice' : 100000,

             'Bob' : 99817,

             'Carol' : 122908,

             'Frank' : 88123,

             'Eve' : 93121}

## One-Liner

top_earners = [(k, v) for k, v in employees.items() if v >= 100000]

## Result

print(top_earners)

列表推导式的单行解决方案:示例 2-1

这段代码的输出是什么?

它是如何工作的

让我们来分析这个单行代码:

top_earners = [ ➊(k, v) ➋for k, v in employees.items() if v >= 100000]

表达式 ➊:为上下文变量 kv 创建一个简单的 (key, value) 元组。

上下文 ➋:字典方法 dict.items() 确保上下文变量 k 遍历字典中的所有键,同时上下文变量 v 遍历与上下文变量 k 关联的值——但仅在上下文变量 v 的值大于或等于 100,000 时,这一点通过 if 条件得到确保。

单行代码的结果如下:

print(top_earners)

# [('Alice', 100000), ('Carol', 122908)]

这个简单的单行程序介绍了一个重要的概念——列表推导式。在本书的多个实例中,我们使用了列表推导式,因此在继续阅读之前,请确保理解本节中的示例。

使用列表推导式查找高信息量的词语

在这个单行代码中,你将更深入地探讨列表推导式的强大功能。

基础概念

搜索引擎根据文本信息与用户查询的相关性来对文本进行排名。为此,搜索引擎分析要搜索的文本内容。所有文本都由单词组成。某些单词能提供大量关于文本内容的信息,而其他单词则没有。例如,前者的单词包括whitewhaleCaptainAhab(你知道这段文本吗?)。后者的单词包括istoastheahow,因为大多数文本都包含这些单词。过滤掉那些对意义贡献不大的单词是实现搜索引擎时的常见做法。一种简单的启发式方法是过滤掉所有字符数为三个或更少的单词。

代码

我们的目标是解决以下问题:给定一个多行字符串,创建一个由多个列表组成的列表——每个列表包含该行中所有字符数大于三的单词。清单 2-2 提供了数据和解决方案。

## Data

text = '''

Call me Ishmael. Some years ago - never mind how long precisely - having

little or no money in my purse, and nothing particular to interest me

on shore, I thought I would sail about a little and see the watery part

of the world. It is a way I have of driving off the spleen, and regulating

the circulation. - Moby Dick'''

## One-Liner

w = [[x for x in line.split() if len(x)>3] for line in text.split('\n')]

## Result

print(w)

清单 2-2:一行解决方案,用于查找具有高信息量的单词

这段代码的输出是什么?

工作原理

这行代码通过使用两个嵌套的列表推导式,创建了一个由多个列表组成的列表:

  • 内层列表推导式[x for x in line.split() if len(x)>3]使用字符串的split()函数将给定行分割成一系列单词。我们遍历所有单词x,如果它们的字符数大于三,就将它们添加到列表中。

  • 外层列表推导式创建了之前语句中使用的字符串line。同样,它使用split()函数根据换行符'\n'将文本分割开来。

当然,您需要习惯以列表推导式的方式思考,因此其含义可能对您来说并不直观。但是在读完本书后,列表推导式将成为您的基本工具——您会很快像这样阅读和编写符合 Python 风格的代码。

读取文件

在本节中,您将读取一个文件并将结果存储为一个字符串列表(每行一个字符串)。您还将删除行首和行尾的空白字符。

基础知识

在 Python 中,读取文件非常简单,但通常需要几行代码(以及一两次 Google 搜索)才能完成。以下是 Python 中读取文件的一种标准方式:

filename = "readFileDefault.py" # this code

f = open(filename)

lines = []

for line in f:

    lines.append(line.strip())

print(lines)

"""

['filename = "readFileDefault.py" # this code',

'',

'f = open(filename)',

'lines = []',

'for line in f:',

'lines.append(line.strip())',

'',

'print(lines)']

"""

代码假设您已经将这段代码片段存储在名为readFileDefault.py的文件中,并且该文件位于某个文件夹中。代码接着打开该文件,创建一个空列表lines,并通过在for循环体中使用append()操作遍历文件中的所有行,将字符串添加到列表中。您还使用了字符串方法strip()来移除任何行首或行尾的空白字符(否则换行符'\n'将出现在字符串中)。

要访问你计算机上的文件,你需要知道如何打开和关闭文件。只有在打开文件后,你才能访问文件的数据。关闭文件后,你可以确定数据已经写入文件。Python 可能会创建一个缓冲区,并等待一段时间,才将整个缓冲区的数据写入文件中(图 2-1)。原因很简单:文件访问速度较慢。为了提高效率,Python 避免每次单独写入每一个字节,而是等待直到缓冲区填充足够的字节,再一次性将整个缓冲区写入文件。

images

图 2-1:在 Python 中打开和关闭文件

这就是为什么在读取文件后最好使用命令f.close()关闭文件,以确保所有数据正确写入文件,而不是停留在临时内存中。不过,Python 在一些特殊情况下会自动关闭文件:其中一个例外是当引用计数降为零时,正如你将在以下代码中看到的。

代码

我们的目标是打开一个文件,读取所有行,去除前后空格字符,并将结果存储在一个列表中。清单 2-3 提供了这行代码。

print([line.strip() for line in open("readFile.py")])

清单 2-3:逐行读取文件的单行解决方案。

在继续阅读之前,不妨先猜猜这个代码片段的输出是什么。

工作原理

你使用 print() 语句将结果列表打印到命令行。你通过使用列表推导式来创建该列表(请参见 “使用列表推导式查找收入最高的人” 在 第 18 页)。在列表推导式的表达式部分,你使用字符串对象的 strip() 方法。

列表推导式的上下文部分会遍历文件中的所有行。

一行代码的输出就是这行代码本身(因为它读取了名为readFile.py的 Python 源代码文件),并将其包装成字符串,填入一个列表中:

print([line.strip() for line in open("readFile.py")])

# ['print([line.strip() for line in open("readFile.py")])']

本节展示了通过让代码更简洁、精炼,可以在不牺牲效率的情况下提高可读性。

使用 Lambda 和 Map 函数

本节介绍了两个重要的 Python 特性:lambdamap() 函数。这两个函数是你 Python 工具箱中的宝贵工具。你将使用这些函数在一个字符串列表中查找另一个字符串的出现。

基础知识

在第一章中,你学习了如何使用def关键字定义一个新函数,后跟函数的内容。然而,这并不是在 Python 中定义函数的唯一方式。你还可以使用lambda 函数定义一个简单的具有返回值的函数(返回值可以是任何对象,包括元组、列表和集合)。换句话说,每个 lambda 函数都会向其调用环境返回一个对象值。需要注意的是,这给 lambda 函数带来了实际的限制,因为与标准函数不同,lambda 函数并不设计用来执行没有返回对象值的代码。

注意

我们在第一章中已经介绍了 lambda 函数,但由于它是本书中贯穿始终的重要概念,我们将在本节深入探讨。

lambda 函数允许你通过使用关键字lambda在一行中定义一个新函数。这在你需要快速创建一个只会使用一次并且可以立即进行垃圾回收的函数时非常有用。我们首先来研究 lambda 函数的具体语法:

lambda arguments : return expression

你以关键字lambda开始定义函数,后跟一系列函数参数。在调用该函数时,调用者必须提供这些参数。然后,你在函数定义中包含一个冒号(:)和返回表达式,该表达式根据 lambda 函数的参数计算返回值。返回表达式计算函数的输出,并且可以是任何 Python 表达式。以下是一个函数定义的示例:

lambda x, y: x + y

这个 lambda 函数有两个参数,xy。返回值就是这两个参数的和,x + y

通常在你只会调用一次函数,并且可以在一行代码中轻松定义它时,才会使用 lambda 函数。一个常见的例子是将 lambda 与map()函数一起使用,map()函数的输入参数是一个函数对象f和一个序列s。然后,map()函数将在序列s中的每个元素上应用函数f。当然,你可以定义一个完整的命名函数来定义函数参数f,但这样做通常不方便,并且会降低可读性——特别是当函数很短且只需要使用一次时——因此,通常最好在这里使用 lambda 函数。

在展示单行代码之前,我将简要介绍另一个小的 Python 技巧,帮助你更轻松地处理问题:通过使用表达式y in x来检查字符串x是否包含子字符串y。如果字符串x中至少有一个y,该表达式将返回True。例如,表达式'42' in 'The answer is 42'的结果为True,而表达式'21' in 'The answer is 42'的结果为False

现在让我们来看一下我们的单行代码。

代码

给定一个字符串列表,接下来的单行代码(Listing 2-4)将创建一个包含布尔值和原始字符串的元组的新列表。布尔值表示字符串 'anonymous' 是否出现在原始字符串中!我们将结果列表命名为 mark,因为布尔值 标记 了列表中包含字符串 'anonymous' 的元素。

## Data

txt = ['lambda functions are anonymous functions.',

       'anonymous functions dont have a name.',

       'functions are objects in Python.']

## One-Liner

mark = map(lambda s: (True, s) if 'anonymous' in s else (False, s), txt)

## Result

print(list(mark))

Listing 2-4:标记包含字符串 'anonymous' 的字符串的单行解决方案

这段代码的输出是什么?

工作原理

map() 函数将布尔值添加到原始 txt 列表中的每个字符串元素。如果字符串元素包含单词 anonymous,则布尔值为 True。第一个参数是匿名的 lambda 函数,第二个参数是你想检查的字符串列表。

你使用 lambda 返回表达式 (True, s) if 'anonymous' in s else (False, s) 来查找字符串 'anonymous'。值 s 是 lambda 函数的输入参数,在此例中是一个字符串。如果查询的字符串 'anonymous' 存在于该字符串中,表达式将返回元组 (True, s)。否则,返回元组 (False, s)

单行代码的结果如下:

## Result

print(list(mark))

# [(True, 'lambda functions are anonymous functions.'), 

# (True, 'anonymous functions dont have a name.'), 

# (False, 'functions are objects in Python.')]

布尔值表明列表中只有前两个字符串包含子字符串 'anonymous'

在接下来的单行代码中,你会发现 lambda 表达式非常有用。你也在不断向目标前进:理解你在实践中遇到的每一行 Python 代码。

练习 2-1

使用列表推导式而不是 map() 函数来实现相同的输出。(你可以在本章末尾找到解决方案。)

使用切片提取匹配的子字符串环境

本节讲解了 切片 的重要基础概念——从原始完整序列中提取子序列的过程——用于处理简单的文本查询。我们将搜索文本中的特定字符串,然后提取该字符串及其周围的一些字符,以提供上下文。

基础知识

切片是许多 Python 概念和技能的核心,无论是高级还是基础的,譬如在使用 Python 内置的数据结构(如列表、元组和字符串)时。切片也是许多高级 Python 库(如 NumPy、Pandas、TensorFlow 和 scikit-learn)的基础。深入学习切片将对你作为 Python 编程人员的职业生涯产生积极的连锁反应。

切片从序列中提取子序列,例如提取字符串的一部分。语法非常简单。假设你有一个变量 x,它引用了一个字符串、列表或元组。你可以使用以下符号来提取子序列:

x[start:stop:step].

结果子序列从索引 start(包含)开始,到索引 stop(不包含)结束。你可以包含一个可选的第三个 step 参数,用于确定提取哪些元素,因此你可以选择仅包括每隔 step 个元素。例如,对变量 x = 'hello world' 使用切片操作 x[1:4:1],结果为字符串 'ell'。对同一变量使用切片操作 x[1:4:2],结果为字符串 'el',因为每隔一个元素才会被包含到结果切片中。回顾 第一章,在 Python 中,任何序列类型(如字符串和列表)的第一个元素的索引是 0。

如果你不包含 step 参数,Python 会假定步长默认为 1。例如,切片调用 x[1:4] 将返回字符串 'ell'

如果你不包含起始或结束参数,Python 假定你希望从开始处切片,或从结束处切片。例如,切片调用 x[:4] 将返回字符串 'hell',而切片调用 x[4:] 将返回字符串 'o world'

研究以下示例,以进一步提升你的直观理解。

  s = 'Eat more fruits!'

  print(s[0:3])

  # Eat

➊ print(s[3:0]) 

  # (empty string '')

  print(s[:5])

  # Eat m

  print(s[5:])

  # ore fruits!

➋ print(s[:100]) 

  # Eat more fruits!

  print(s[4:8:2])

  # mr

➌ print(s[::3]) 

  # E rfi!

➍ print(s[::-1]) 

  # !stiurf erom taE

  print(s[6:1:-1]) 

  # rom t

这些 Python 切片基本的 [start:stop:step] 模式的变种突出了该技巧的许多有趣属性:

  • 如果 start >= stopstep 为正数,则切片为空 ➊。

  • 如果 stop 参数大于序列长度,Python 将切片到并包含最右边的元素 ➋。

  • 如果 step 大小为正,默认的起始位置是最左边的元素,默认的结束位置是最右边的元素(包含) ➌。

  • 如果 step 大小为负(step < 0),切片将按反向顺序遍历序列。在没有指定起始和结束参数的情况下,你将从最右侧的元素(包含)切片到最左侧的元素(包含)➍。注意,如果给定了 stop 参数,相应的位置将被排除在切片之外。

接下来,你将使用切片和 string.find(value) 方法来查找给定字符串中 value 的索引位置。

代码

我们的目标是在一个多行字符串中查找特定的文本查询。你希望在文本中找到查询内容,并返回其即时环境,最多包括查询前后 18 个位置。提取环境以及查询本身有助于看到找到的字符串的文本上下文——就像 Google 在搜索关键词时展示的文本片段。在 示例 2-5 中,你要在亚马逊致股东的信中查找字符串 'SQL',并返回该字符串前后最多 18 个字符的环境。

## Data

letters_amazon = '''

We spent several years building our own database engine,

Amazon Aurora, a fully-managed MySQL and PostgreSQL-compatible

service with the same or better durability and availability as

the commercial engines, but at one-tenth of the cost. We were

not surprised when this worked.

'''

## One-Liner

find = lambda x, q: x[x.find(q)-18:x.find(q)+18] if q in x else -1

## Result

print(find(letters_amazon, 'SQL'))

示例 2-5:在文本中查找字符串及其直接环境的单行解决方案

猜一猜这段代码的输出。

工作原理

你定义了一个包含两个参数的 lambda 函数:一个字符串值x,和一个查询q,用于在文本中查找。你将 lambda 函数赋值给名称find。函数find(x, q)在字符串文本x中查找字符串查询q

如果查询q没有出现在字符串x中,你直接返回结果-1。否则,你使用切片操作在文本字符串上切出查询第一次出现的部分,并在查询左侧和右侧各加上 18 个字符,以捕捉查询的上下文。你发现qx中的第一次出现的索引是通过字符串函数x.find(q)来找到的。你调用该函数两次:一次用于确定切片的起始索引,另一次用于确定结束索引,但这两次函数调用返回相同的值,因为查询q和字符串x都没有发生变化。虽然这段代码完全正常工作,但冗余的函数调用导致了不必要的计算——这是一个可以通过添加一个辅助变量来暂时存储第一次函数调用结果并重用的缺点。

本讨论突出了一个重要的折中:通过将自己限制为一行代码,你不能定义和重用一个辅助变量来存储查询的第一次出现的索引。相反,你必须执行相同的find函数来计算起始索引(并将结果减去 18 个索引位置),并计算结束索引(并将结果加上 18 个索引位置)。在第五章,你将学习一种更高效的字符串模式搜索方法(使用正则表达式),解决这个问题。

在搜索亚马逊给股东的信中查询'SQL'时,你会在文本中找到查询的一个出现:

## Result

print(find(letters_amazon, 'SQL'))

# a fully-managed MySQL and PostgreSQL

结果,你得到字符串及其周围的几个单词,以提供查找的上下文。切片是你基础 Python 学习中的一个关键元素。让我们通过另一个切片单行代码来进一步加深你的理解。

结合列表推导式和切片

本节结合了列表推导式和切片,用于从一个二维数据集中采样。我们的目标是从一个庞大的样本中创建一个更小但具有代表性的样本。

基础知识

假设你在一家大银行担任金融分析师,并正在为股票价格预测训练一个新的机器学习模型。你拥有一个真实世界股票价格的训练数据集。然而,数据集非常庞大,模型训练在你的电脑上似乎永远也训练不完。例如,在机器学习中,通常需要测试不同模型参数集的预测准确度。在我们的应用中,假设你必须等上好几个小时,直到训练程序结束(在大规模数据集上训练高度复杂的模型实际上确实需要几个小时)。为了加快速度,你通过排除每隔一个的股票价格数据点将数据集减半。你并不期望这种修改会显著降低模型的准确性。

本节中,你将使用本章前面学习过的两个 Python 特性:列表推导式和切片。列表推导式允许你遍历每个列表元素并随后修改它。切片允许你快速选择给定列表中的每隔一个元素——它自然适用于简单的过滤操作。让我们详细看看这两个特性如何结合使用。

代码

我们的目标是从数据中创建一个新的训练数据样本——一个由六个浮动值组成的列表的列表——只包括原始数据集中每隔一个的浮动值。请查看清单 2-6。

## Data (daily stock prices ($))

price = [[9.9, 9.8, 9.8, 9.4, 9.5, 9.7],

         [9.5, 9.4, 9.4, 9.3, 9.2, 9.1],

         [8.4, 7.9, 7.9, 8.1, 8.0, 8.0],

         [7.1, 5.9, 4.8, 4.8, 4.7, 3.9]]

## One-Liner

sample = [line[::2] for line in price]

## Result

print(sample)

清单 2-6:单行代码解决方案来抽样数据

和往常一样,看看你能否猜出输出结果。

它是如何工作的

我们的解决方案是一个两步法。首先,你使用列表推导式遍历原始列表price的所有行。第二,你通过切片每一行来创建一个新的浮动值列表;你使用line[start:stop:step],其中 start 和 stop 使用默认参数,步长为2。新的浮动值列表仅包含三个(而不是六个)浮动值,结果是以下数组:

## Result

print(sample)

# [[9.9, 9.8, 9.5], [9.5, 9.4, 9.2], [8.4, 7.9, 8.0], [7.1, 4.8, 4.7]]

这个使用内置 Python 功能的一行代码并不复杂。然而,你将在第三章中了解一个更简短的版本,它使用 NumPy 库进行数据科学计算。

练习 2-2

在学习完第三章后,重新审视这一行代码,并用 NumPy 库来提出一个更简洁的解决方案。一点提示:使用 NumPy 更强大的切片功能。

使用切片赋值来修正损坏的列表

本节向你展示了 Python 中一个强大的切片功能:切片赋值。切片赋值在赋值操作的左侧使用切片符号来修改原始序列的子序列。

基础知识

假设您在一家小型互联网创业公司工作,负责追踪用户的 Web 浏览器(如谷歌 Chrome、Firefox、Safari)。您将数据存储在数据库中。为了分析数据,您将收集到的浏览器数据加载到一个包含大量字符串的列表中,但由于跟踪算法中的一个 bug,每隔一个字符串会被损坏,需要替换为正确的字符串。

假设您的 Web 服务器总是将用户的第一次 Web 请求重定向到另一个 URL(这在 Web 开发中是一种常见做法,称为 HTML 代码 301:永久移动)。您得出结论,在大多数情况下,第一个浏览器值将等于第二个浏览器值,因为用户的浏览器在等待重定向发生时保持不变。这意味着您可以轻松地重现原始数据。实质上,您想要将列表中的每隔一个字符串复制一次:列表 ['Firefox', 'corrupted', 'Chrome', 'corrupted'] 变成 ['Firefox', 'Firefox', 'Chrome', 'Chrome']

如何以快速、易读且高效的方式(最好是一行代码)实现这一点?您的第一个想法是创建一个新的列表,遍历损坏的列表,并将每个未损坏的浏览器字符串添加到新列表中两次。但您放弃了这个想法,因为那样您就得在代码中维护两个列表——而且每个列表可能有数百万个条目。此外,这种解决方案需要几行代码,会影响代码的简洁性和可读性。

幸运的是,您已经读过一个很棒的 Python 特性:切片赋值。您将使用切片赋值来选择并替换索引 ij 之间的元素序列,通过使用切片符号 lst[i:j] = [0 0 ...0]。因为您在赋值操作的左侧使用了切片 lst[i:j](而不是之前在右侧使用),这个特性被称为切片赋值

切片赋值的思路很简单:将原始序列中左侧选定的所有元素替换为右侧的元素。

代码

我们的目标是用它前面的字符串替换每个字符串;请参见 列表 2-7。

## Data

visitors = ['Firefox', 'corrupted', 'Chrome', 'corrupted',

            'Safari', 'corrupted', 'Safari', 'corrupted',

            'Chrome', 'corrupted', 'Firefox', 'corrupted']

## One-Liner

visitors[1::2] = visitors[::2]

## Result

print(visitors)

列表 2-7:替换所有损坏字符串的单行解决方案

这段代码中的固定浏览器序列是什么?

它是如何工作的

单行解决方案将 'corrupted' 字符串替换为它们前面的浏览器字符串。您使用切片赋值符号来访问 visitors 列表中每个被损坏的元素。我在以下代码片段中高亮显示了选定的元素:

visitors = ['Firefox', 'corrupted', 'Chrome', 'corrupted',

            'Safari', 'corrupted', 'Safari', 'corrupted',

            'Chrome', 'corrupted', 'Firefox', 'corrupted']

代码通过赋值操作右侧的切片来替换这些选定的元素。这些元素在以下代码片段中被高亮显示:

visitors = ['Firefox', 'corrupted', 'Chrome', 'corrupted',

            'Safari', 'corrupted', 'Safari', 'corrupted',

            'Chrome', 'corrupted', 'Firefox', 'corrupted']

前面的元素被后面的元素替换。因此,结果 visitors 列表如下所示(高亮显示了被替换的元素):

## Result

print(visitors)

'''

['Firefox', 'Firefox', 'Chrome', 'Chrome',

'Safari', 'Safari', 'Safari', 'Safari',

'Chrome', 'Chrome', 'Firefox', 'Firefox']

'''

结果是,原始列表中的每个 'corrupted' 字符串都被其前一个浏览器字符串替换。这样,你就清理了受损的数据集。

使用切片赋值来解决这个问题是完成你小任务最快、最有效的方法。请注意,清洗后的数据具有非偏倚的浏览器使用统计信息:在受损数据中占有 70% 市场份额的浏览器,在清洗后的数据中仍将保持 70% 的市场份额。清洗后的数据可以用于进一步分析——例如,找出是否 Safari 用户是更好的客户(毕竟,他们往往在硬件上花费更多)。你已经学会了一种简单且简洁的方式来编程修改列表并且在原地进行修改。

使用列表连接分析心脏健康数据

在本节中,你将学习如何使用列表连接操作来反复复制较小的列表,并将它们合并成一个较大的列表,从而生成循环数据。

基础知识

这次,你正在为一家医院开发一个小型代码项目。你的目标是通过跟踪患者的心脏周期来监控和可视化患者的健康统计数据。通过绘制预期的心脏周期数据,你将帮助患者和医生监控心脏周期的任何偏差。例如,给定一个包含单次心脏周期的测量数据列表 [62, 60, 62, 64, 68, 77, 80, 76, 71, 66, 61, 60, 62],你希望在 图 2-2 中实现该可视化效果。

images

图 2-2:通过复制 选定的 测量数据中的值 来可视化预期的心脏周期

问题在于列表中的第一个和最后两个数据值是冗余的:[62, 60, 62, 64, 68, 77, 80, 76, 71, 66, 61, 60, 62]。当仅绘制单个心脏周期时,这些冗余数据可能会有用,以表示一个完整的周期已被可视化。然而,我们必须去除这些冗余数据,以确保我们复制相同的心脏周期时,预期的心脏周期不会像 图 2-3 中的那样。

images

图 2-3:通过复制 所有 测量数据中的值(不过滤冗余数据) 来可视化预期的心脏周期

显然,你需要 清理 原始列表,去除第一个和最后两个冗余数据值:[62, 60, 62, 64, 68, 77, 80, 76, 71, 66, 61, 60, 62] 变为 [60, 62, 64, 68, 77, 80, 76, 71, 66, 61]

你将结合切片操作与 Python 新特性 列表连接,该特性通过 连接(即 合并)现有的列表来创建一个新列表。例如,操作 [1, 2, 3] + [4, 5] 会生成新列表 [1, 2, 3, 4, 5],但不会替换原始列表。你可以使用 * 运算符将 同一列表 一次次地连接起来,创建更大的列表:例如,操作 [1, 2, 3] * 3 会生成新列表 [1, 2, 3, 1, 2, 3, 1, 2, 3]

此外,您将使用matplotlib.pyplot模块来绘制您生成的心脏数据。matplotlib 的plot(data)函数期望一个可迭代的参数data——可迭代对象就是指可以迭代的对象,例如列表——并将其作为后续数据点的y值,用于二维图形的绘制。我们来深入了解一下这个示例。

代码

给定一个反映测量心脏周期的整数列表,您首先需要通过去除列表中的前两个和最后两个值来清理数据。其次,您通过将心脏周期数据复制到未来时间点来创建一个新的列表,预测未来的心率。列表 2-8 展示了代码。

## Dependencies

import matplotlib.pyplot as plt

## Data

cardiac_cycle = [62, 60, 62, 64, 68, 77, 80, 76, 71, 66, 61, 60, 62]

## One-Liner

expected_cycles = cardiac_cycle[1:-2] * 10

## Result

plt.plot(expected_cycles)

plt.show()

列表 2-8:预测不同时间心率的一行解决方案

接下来,您将学习这个代码片段的结果。

它是如何工作的

这个一行解决方案包含两个步骤。首先,您使用切片来清理数据,通过使用负的停止参数-2来切片,直到右边但跳过最后两个冗余的值。其次,您使用复制操作符*将结果数据值重复 10 次。最终结果是一个由 10 × 10 = 100 个整数组成的列表,这些整数是通过连接的心脏周期数据。当您绘制结果时,您会看到先前在图 2-2 中显示的期望输出。

使用生成器表达式查找支付低于最低工资的公司

本节结合了您已经学过的一些 Python 基础知识,并介绍了有用的any()函数。

基础知识

您在美国劳工部从事执法工作,负责找出支付低于最低工资的公司,以便进行进一步调查。就像饿狼盯着肉车一样,您的公平劳动标准法(FLSA)官员已经在等待那些违反最低工资法的公司名单。您能给他们吗?

这是您的武器:Python 的any()函数,它接受一个可迭代对象(例如列表),并在至少一个元素的值为True时返回True。例如,表达式any([True, False, False, False])的结果为True,而表达式any([2<1, 3+2>5+5, 3-2<0, 0])的结果为False

注意

Python 的创造者 Guido van Rossum 非常喜欢内建函数any(),甚至提议将其作为 Python 3 的内建函数之一。请参阅他 2005 年的博客文章《Python 3000 中的reduce()命运》,详情请见www.artima.com/weblogs/viewpost.jsp?thread=98196

一个有趣的 Python 扩展是列表推导式的概括:生成器表达式。生成器表达式的工作方式与列表推导式完全相同——但它不会在内存中创建一个实际的列表。这些数字是动态生成的,而不是显式存储在列表中。例如,代替使用列表推导式来计算前 20 个数字的平方,sum([x*x for x in range(20)]),你可以使用生成器表达式:sum(x*x for x in range(20))

代码

我们的数据是一个字典的字典,存储着公司员工的时薪。你希望提取出至少有一名员工的工资低于你所在州最低工资(< $9)的公司列表;见列表 2-9。

## Data

companies = {

    'CoolCompany' : {'Alice' : 33, 'Bob' : 28, 'Frank' : 29},

    'CheapCompany' : {'Ann' : 4, 'Lee' : 9, 'Chrisi' : 7},

    'SosoCompany' : {'Esther' : 38, 'Cole' : 8, 'Paris' : 18}}

## One-Liner

illegal = [x for x in companies if any(y<9 for y in companies[x].values())]

## Result

print(illegal)

列表 2-9:找到低于最低工资的公司的一行解决方案

哪些公司需要进一步调查?

它是如何工作的

你在这行代码中使用了两个生成器表达式。

第一个生成器表达式y<9 for y in companies[x].values()生成了输入给any()函数的内容。它检查每个公司员工的薪水,查看是否低于最低工资y<9。结果是一个布尔值的可迭代对象。你使用字典函数values()返回字典中存储的所有值。例如,表达式companies['CoolCompany'].values()返回时薪的集合dict_values([33, 28, 29])。如果其中至少有一个低于最低工资,any()函数将返回True,公司名称x将作为字符串存储在结果列表illegal中,如下所述。

第二个生成器表达式是列表推导式[x for x in companies if any(...)],它创建了一个公司名称的列表,其中先前调用的any()函数返回True。这些公司是那些支付低于最低工资的公司。注意,表达式for x in companies访问所有字典的键——公司名称'CoolCompany''CheapCompany''SosoCompany'

因此,结果如下:

## Result

print(illegal)

# ['CheapCompany', 'SosoCompany']

三家公司中有两家需要进一步调查,因为它们至少有一名员工的薪资过低。你的工作人员可以开始与 Ann、Chrisi 和 Cole 交谈!

使用 zip()函数格式化数据库

在本节中,你将学习如何通过使用zip()函数将数据库列名应用到一系列行中。

基础知识

zip()函数接受可迭代对象iter_1iter_2...iter_n,并通过将相应的i-th 值对齐为一个元组,将它们聚合成一个单一的可迭代对象。结果是一个元组的可迭代对象。例如,考虑这两个列表:

[1,2,3]

[4,5,6]

如果你将它们压缩在一起——经过一个简单的数据类型转换,正如你稍后会看到的——你将得到一个新的列表:

[(1,4), (2,5), (3,6)]

将它们解压回原始元组需要两个步骤。首先,你需要移除结果的外部方括号,得到以下三个元组:

(1,4)

(2,5)

(3,6)

然后,当你将它们一起压缩时,你将得到新的列表:

[(1,2,3), (4,5,6)]

所以,你又得到了原来的两个列表!下面的代码片段展示了这一过程的完整实现:

lst_1 = [1, 2, 3]

lst_2 = [4, 5, 6]

# Zip two lists together

zipped = list(zip(lst_1, lst_2))

print(zipped)

# [(1, 4), (2, 5), (3, 6)]

# Unzip to lists again

lst_1_new, lst_2_new = zip(➊*zipped)

print(list(lst_1_new))

print(list(lst_2_new))

你使用星号操作符 * 来解包 ➊ 列表中的所有元素。此操作符去掉了列表 zipped 的外部括号,使得 zip() 函数的输入由三个可迭代对象组成(元组 (1, 4), (2, 5), (3, 6))。如果你将这些可迭代对象进行 zip 操作,你会将前三个元组值 123 打包成一个新元组,再将接下来的三个元组值 456 打包成另一个新元组。最终,你得到的迭代对象是 (1, 2, 3)(4, 5, 6),这就是原始(未解压)数据。

现在,假设你在公司财务部门的 IT 分支工作。你负责维护所有员工的数据库,包含列名:'name''salary''job'。然而,你的数据格式不规范——它是以 ('Bob', 99000, 'mid-level manager') 形式存储的多行数据。你想将列名与每条数据关联起来,使其变为可读的形式 {'name': 'Bob', 'salary': 99000, 'job': 'mid-level manager'}。你该如何实现?

代码

你的数据包含列名和组织为元组列表(行)的员工数据。将列名分配给每一行数据,进而创建一个字典列表。每个字典将列名分配给相应的数据值(列出 2-10)。

## Data

column_names = ['name', 'salary', 'job']

db_rows = [('Alice', 180000, 'data scientist'),

           ('Bob', 99000, 'mid-level manager'),

           ('Frank', 87000, 'CEO')]

## One-Liner

db = [dict(zip(column_names, row)) for row in db_rows]

## Result

print(db)

列出 2-10:将数据库格式应用于元组列表的简洁解法

那么,数据库 db 的打印格式是什么?

它是如何工作的

你通过使用列表推导式创建列表(更多关于表达式 + 上下文的内容请参见 “使用列表推导式查找高薪员工” 以及 第 18 页)。上下文由变量 db_rows 中每一行的元组组成。表达式 zip(column_names, row) 将列名和每一行数据打包在一起。例如,列表推导式创建的第一个元素是 zip(['name', 'salary', 'job'], ('Alice', 180000, 'data scientist')),它生成一个 zip 对象,转换成列表后格式为 [('name', 'Alice'), ('salary', 180000), ('job', 'data scientist')]。这些元素是 (*key*, *value*) 形式,因此你可以通过使用转换函数 dict() 将其转换成字典,达到所需的数据库格式。

注意

zip() 函数不关心一个输入是列表而另一个是元组。该函数只要求输入是可迭代的(列表和元组都是可迭代的)。

这是单行代码片段的输出结果:

## Result

print(db)

'''

[{'name': 'Alice', 'salary': 180000, 'job': 'data scientist'},

{'name': 'Bob', 'salary': 99000, 'job': 'mid-level manager'},

{'name': 'Frank', 'salary': 87000, 'job': 'CEO'}]

'''

每个数据项现在都与其名称关联,形成了一个字典列表。你已经学会了如何有效地使用 zip() 函数。

总结

在本章中,你已经掌握了列表推导式、文件输入、lambda 函数、map()zip() 函数、all() 量词、切片和基本的列表运算。你还学会了如何使用和操作数据结构来解决各种日常问题。

数据结构的来回转换是一项对编程生产力有深远影响的技能。可以放心,当你提高快速操作数据的能力时,你的编程生产力将迅速提升。像本章中看到的小型处理任务,实际上会对常见的“千刀万剐”产生显著影响:频繁执行许多小任务对整体生产力的压迫性伤害。通过使用本章介绍的 Python 技巧、函数和特性,你已经有效地抵御了这些千刀万剐的伤害。用比喻来说,新获得的工具帮助你在每次受伤后更快恢复。

在下一章,你将通过深入学习 NumPy 库提供的一组工具来进一步提升你的数据科学技能,这些工具专为 Python 中的数值计算而设计。

练习 2-1 的解答

下面是如何使用列表推导式替代 map() 函数,达到相同的效果,即过滤掉包含字符串 'anonymous' 的所有行。在这种情况下,我甚至推荐使用更快、更简洁的列表推导式特性。

mark = [(True, s) if 'anonymous' in s else (False, s) for s in txt]

第三章:数据科学**

Image

分析现实世界数据的能力是 21 世纪最受追捧的技能之一。在强大硬件能力、算法和无处不在的传感技术的帮助下,数据科学家从天气统计、金融交易、客户行为等大量原始数据中提取有意义的信息。如今世界上最大的公司——谷歌、Facebook、苹果和亚马逊——本质上是庞大的数据处理实体,数据是它们商业模式的核心。

本章将帮助你掌握使用 Python 的数值计算库NumPy来处理和分析数值数据的技能。我将给你提供 10 个实际问题,并解释如何用一行 NumPy 代码来解决它们。因为 NumPy 是许多高层数据科学和机器学习库(如 Pandas、scikit-learn 和 TensorFlow 等)的基础,仔细学习本章将提升你在数据驱动的现代经济中的市场竞争力。所以,请集中注意力!

基本的二维数组运算

在这里,你将用一行代码解决一个日常的会计任务。我将介绍 NumPy 的一些基本功能,NumPy 是 Python 用于数值计算和数据科学的重要库。

基础知识

NumPy 库的核心是NumPy 数组,它们存储你想要处理、分析和可视化的数据。许多更高级的数据科学库(如 Pandas)都建立在 NumPy 数组之上,或者隐式或显式地依赖于它。

NumPy 数组类似于 Python 列表,但具有一些额外的优点。首先,NumPy 数组占用更少的内存,并且在大多数情况下运行速度更快。其次,当访问多个维度时,NumPy 数组更加方便,这些数据被称为多维数据(多维列表难以访问和修改)。由于 NumPy 数组可以包含多个维度,我们通常从维度的角度来看待数组:具有两个轴的数组是二维数组。第三,NumPy 数组具有更强大的访问功能,例如广播(broadcasting),你将在本章中学习到更多相关内容。

清单 3-1 举例说明如何创建一维、二维和三维 NumPy 数组。

import numpy as np

# Creating a 1D array from a list

a = np.array([1, 2, 3])

print(a)

"""

[1 2 3]

"""

# Creating a 2D array from a list of lists

b = np.array([[1, 2],

              [3, 4]])

print(b)

"""

[[1 2]

 [3 4]]

"""

# Creating a 3D array from a list of lists of lists

c = np.array([[[1, 2], [3, 4]],

              [[5, 6], [7, 8]]])

print(c)

"""

[[[1 2]

  [3 4]]

 [[5 6]

  [7 8]]]

"""

清单 3-1:在 NumPy 中创建一维、二维和三维数组

你首先通过使用该库的事实标准名称 np 将 NumPy 库导入到命名空间中。导入库后,你可以通过将一个标准的 Python 列表作为参数传递给 np.array() 函数来创建一个 NumPy 数组。一维数组对应于一个简单的数字值列表(事实上,NumPy 数组也可以包含其他数据类型,但我们在这里重点讨论数字)。二维数组对应于一个嵌套的 列表的列表,其中包含数字值。三维数组对应于一个嵌套的 列表的列表的列表,其中包含数字值。括号的开闭数量可以告诉你 NumPy 数组的维度。

NumPy 数组比内置的 Python 列表更强大。例如,你可以对两个 NumPy 数组进行基本的算术运算符 +-*/。这些 按元素操作 将两个数组 ab(例如,用 + 运算符将它们相加)按元素组合在一起,即将数组 a 中的每个元素与数组 b 中对应位置的元素进行组合。换句话说,按元素操作会聚合数组 ab 中处于相同位置的两个元素。清单 3-2 展示了对二维数组进行基本算术操作的示例。

import numpy as np

a = np.array([[1, 0, 0],

              [1, 1, 1],

              [2, 0, 0]])

b = np.array([[1, 1, 1],

              [1, 1, 2],

              [1, 1, 2]])

print(a + b)

"""

[[2 1 1]

 [2 2 3]

 [3 1 2]]

"""

print(a - b)

"""

[[ 0 -1 -1]

 [ 0  0 -1]

 [ 1 -1 -2]]

"""

print(a * b)

"""

[[1 0 0]

 [1 1 2]

 [2 0 0]]

"""

print(a / b)

"""

[[1.  0.  0\. ]

 [1.  1.  0.5]

 [2.  0.  0\. ]]

"""

清单 3-2:基本算术数组操作

注意

当你将 NumPy 运算符应用于整数数组时,它们也会尽量生成整数数组作为结果。只有在使用除法运算符 a / b 对两个整数数组进行除法时,结果才会是浮动数组。通过小数点可以看出这一点:1.0.,和 0.5

如果你仔细观察,你会发现每个操作都将两个对应的 NumPy 数组按元素进行组合。当对两个数组进行加法时,结果是一个新数组:每个新值是第一个和第二个数组中对应值的和。减法、乘法和除法也同样如此,如下所示。

NumPy 提供了更多用于操作数组的功能,包括 np.max() 函数,它计算 NumPy 数组中所有值的 最大 值。np.min() 函数计算 NumPy 数组中所有值的 最小 值。np.average() 函数计算 NumPy 数组中所有值的 平均 值。

清单 3-3 给出了这三种操作的示例。

import numpy as np

a = np.array([[1, 0, 0],

              [1, 1, 1],

              [2, 0, 0]])

print(np.max(a))

## print(np.min(a))

## print(np.average(a))

# 0.6666666666666666

清单 3-3:计算 NumPy 数组的最大值、最小值和平均值

NumPy 数组中所有值的最大值是 2,最小值是 0,平均值是 (1 + 0 + 0 + 1 + 1 + 1 + 2 + 0 + 0) / 9 = 2/3。NumPy 还有许多更强大的工具,但这些已经足够解决以下问题:如何根据人们的年薪和税率,找到他们的最大税后收入?

代码

让我们用 Alice、Bob 和 Tim 的薪资数据来解决这个问题。似乎在过去三年中,Bob 的薪水最高。但考虑到我们三位朋友的个人税率,他是否真的是拿到最多钱的人呢?看一下列表 3-4。

## Dependencies

import numpy as np

## Data: yearly salary in ($1000) [2017, 2018, 2019]

alice = [99, 101, 103]

bob = [110, 108, 105]

tim = [90, 88, 85]

salaries = np.array([alice, bob, tim])

taxation = np.array([[0.2, 0.25, 0.22],

                     [0.4, 0.5, 0.5],

                     [0.1, 0.2, 0.1]])

## One-liner

max_income = np.max(salaries - salaries * taxation)

## Result

print(max_income)

列表 3-4:使用基本数组运算的单行解决方案

猜猜看:这段代码的输出是什么?

工作原理

在导入 NumPy 库后,你将数据放入一个具有三行(每行代表一个人:Alice、Bob 和 Tim)和三列(每列代表一年:2017 年、2018 年和 2019 年)的二维 NumPy 数组中。你有两个二维数组:salaries保存每年的收入,taxation保存每个人和每年的税率。

为了计算税后收入,你需要从总收入中扣除税额(以美元为单位),而这些数据存储在薪资数组中。为此,你使用了重载的 NumPy 运算符-*,它们对 NumPy 数组执行元素级的计算。

两个多维数组的元素级乘法叫做Hadamard 积

列表 3-5 展示了从总收入中扣除税费后,NumPy 数组的样子。

print(salaries - salaries * taxation)

"""

[[79.2  75.75 80.34]

 [66.   54.   52.5 ]

 [81.   70.4  76.5 ]]

"""

列表 3-5:基本数组运算

在这里,你可以看到 Bob 的高收入在支付了 40%和 50%的税率后显著减少,这在第二行中展示。

代码片段打印了这个结果数组的最大值。np.max()函数简单地找出数组中的最大值,并将其存储在max_income中。因此,最大值是 Tim 在 2017 年的$90,000 收入,他只需缴纳 10%的税——这段单行代码的结果是81.(再次说明,点表示浮动数据类型)。

你已经使用了 NumPy 的基本元素级数组运算来分析一组人的税率。现在让我们使用相同的示例数据集来应用 NumPy 的中级概念,如切片和广播。

使用 NumPy 数组:切片、广播和数组类型

这个单行代码展示了三个有趣的 NumPy 特性的强大功能:切片、广播和数组类型。我们的数据是一个包含多种职业和薪资的数组。你将结合这三种概念,在每隔一年就将数据科学家的薪资提高 10%。

基础知识

我们问题的核心是如何在具有多行的 NumPy 数组中更改特定的值。你想要更改单一行中的每隔一个值。让我们探索一下你需要了解的基础知识,才能解决这个问题。

切片和索引

NumPy 中的索引和切片与 Python 中的索引和切片类似(参见第二章):你可以使用括号操作[]来指定索引或索引范围,从而访问一维数组的元素。例如,索引操作x[3]返回 NumPy 数组x的第四个元素(因为第一个元素的索引是 0)。

你也可以通过为每个维度独立指定索引,并使用逗号分隔的索引来访问不同的维度,从而对多维数组进行索引。例如,索引操作y[0,1,2]将访问第一个轴的第一个元素,第二个轴的第二个元素,以及第三个轴的第三个元素。请注意,这种语法对于多维 Python 列表是无效的。

接下来,我们将介绍 NumPy 中的切片。研究清单 3-6 中的示例,掌握 NumPy 中的一维切片,如果在理解这些示例时遇到困难,可以随时回顾第二章中的基本 Python 切片知识。

import numpy as np

a = np.array([55, 56, 57, 58, 59, 60, 61])

print(a)

# [55 56 57 58 59 60 61]

print(a[:])

# [55 56 57 58 59 60 61]

print(a[2:])

# [57 58 59 60 61]

print(a[1:4]) 

# [56 57 58]

print(a[2:-2])

# [57 58 59]

print(a[::2])

# [55 57 59 61]

print(a[1::2])

# [56 58 60]

print(a[::-1])

# [61 60 59 58 57 56 55]

print(a[:1:-2])

# [61 59 57]

print(a[-1:1:-2])

# [61 59 57]

清单 3-6:一维切片示例

下一步是彻底理解多维切片。与索引类似,你分别对每个轴应用一维切片(以逗号分隔)来选择该轴上元素的范围。花些时间彻底理解清单 3-7 中的示例。

import numpy as np

a = np.array([[0, 1, 2, 3],

              [4, 5, 6, 7],

              [8, 9, 10, 11],

              [12, 13, 14, 15]])

print(a[:, 2])

# Third col: [ 2  6 10 14]

print(a[1, :])

# Second row: [4 5 6 7]

print(a[1, ::2])

# Second row, every other element: [4 6]

print(a[:, :-1])

# All columns except last:

# [[ 0  1  2]

# [ 4  5  6]

# [ 8  9 10]

# [12 13 14]]

print(a[:-2])

# Same as a[:-2, :]

# [[ 0  1  2  3]

# [ 4  5  6  7]]

清单 3-7:多维切片示例

研究清单 3-7,直到你理解多维切片。你可以通过使用语法a[slice1, slice2]来执行二维切片。对于任何额外的维度,添加逗号分隔的切片操作(使用start:stopstart:stop:step切片操作符)。每个切片选择其各自维度中元素的独立子序列。如果你理解了这个基本概念,从一维切片到多维切片就变得非常简单。

广播

广播描述了将两个 NumPy 数组自动转换为相同形状的过程,从而可以应用某些按元素操作(参见“切片与索引”第 46 页)。广播与 NumPy 数组的形状属性密切相关,而形状属性又与轴的概念紧密相关。因此,接下来让我们深入探讨轴、形状和广播。

每个数组包含多个,每个维度对应一个轴(清单 3-8)。

import numpy as np

a = np.array([1, 2, 3, 4])

print(a.ndim)

## b = np.array([[2, 1, 2], [3, 2, 3], [4, 3, 4]])

print(b.ndim)

## c = np.array([[[1, 2, 3], [2, 3, 4], [3, 4, 5]],

              [[1, 2, 4], [2, 3, 5], [3, 4, 6]]])

print(c.ndim)

# 3

清单 3-8:三个 NumPy 数组的轴和维度

这里,你可以看到三个数组:abc。数组的属性 ndim 存储这个特定数组的轴数。你只需为每个数组打印它。数组 a 是一维的,数组 b 是二维的,数组 c 是三维的。每个数组都有一个相关的形状属性,它是一个元组,表示每个轴上元素的数量。对于二维数组,元组中有两个值:行数和列数。对于高维数组,i 第个元组值指定第 i 轴上元素的数量。因此,元组中的元素个数就是 NumPy 数组的维度。

注意

如果你增加数组的维度(例如,从二维数组转为三维数组),新的轴将成为轴 0,而低维数组的第 i 个轴将成为高维数组的第(i* + 1)个轴。*

清单 3-9 给出了与 清单 3-8 中相同数组的形状属性。

import numpy as np

a = np.array([1, 2, 3, 4])

print(a)

"""

[1 2 3 4]

"""

print(a.shape)

# (4,)

b = np.array([[2, 1, 2], [3, 2, 3], [4, 3, 4]])

print(b)

"""

[[2 1 2]

 [3 2 3]

 [4 3 4]]

"""

print(b.shape)

# (3, 3)

c = np.array([[[1, 2, 3], [2, 3, 4], [3, 4, 5]],

              [[1, 2, 4], [2, 3, 5], [3, 4, 6]]])

print(c)

"""

[[[1 2 3]

  [2 3 4]

  [3 4 5]]

 [[1 2 4]

  [2 3 5]

  [3 4 6]]]

"""

print(c.shape)

# (2, 3, 3)

清单 3-9:1D、2D 和 3D NumPy 数组的形状属性

在这里,你可以看到 shape 属性包含的信息比 ndim 属性多得多。每个 shape 属性都是一个元组,表示每个轴上元素的数量:

  • 数组 a 是一维的,因此 shape 元组只有一个元素,表示列数(四个元素)。

  • 数组 b 是二维的,因此 shape 元组有两个元素,分别列出行数和列数。

  • 数组 c 是三维的,因此 shape 元组包含三个元素——每个轴一个元素。轴 0 有两个元素(每个元素是一个二维数组),轴 1 有三个元素(每个是一个一维数组),轴 2 有三个元素(每个是一个整数值)。

现在你理解了 shape 属性,理解广播的基本概念会更容易:通过重新排列数据,将两个数组转换为相同的形状。我们来看一下广播是如何工作的。广播自动修正形状不同的 NumPy 数组之间的逐元素操作。例如,乘法运算符 * 通常在应用于 NumPy 数组时执行逐元素乘法。但如果左右数据不匹配会发生什么呢(比如,左边的操作数是一个 NumPy 数组,而右边是一个浮点值)?在这种情况下,NumPy 会自动从右侧数据创建一个新数组。这个新数组的大小和维度与左边的数组相同,且包含相同的浮点值。

因此,广播是将低维数组转换为高维数组以执行逐元素操作的过程。

同质值

NumPy 数组是 同质的,这意味着所有的值都有相同的类型。以下是可能的数组数据类型的非详尽列表:

bool Python 中的布尔数据类型(1 字节)

int Python 中的整数数据类型(默认大小:4 或 8 字节)

float Python 中的浮点数据类型(默认大小:8 字节)

complex Python 中的复数数据类型(默认大小:16 字节)

np.int8 一个整数数据类型(1 字节)

np.int16 一个整数数据类型(2 字节)

np.int32 一个整数数据类型(4 字节)

np.int64 一个整数数据类型(8 字节)

np.float16 一个浮点数据类型(2 字节)

np.float32 一个浮点数据类型(4 字节)

np.float64 一个浮点数据类型(8 字节)

清单 3-10 展示了如何创建具有不同类型的 NumPy 数组。

import numpy as np

a = np.array([1, 2, 3, 4], dtype=np.int16)

print(a) # [1 2 3 4]

print(a.dtype) # int16

b = np.array([1, 2, 3, 4], dtype=np.float64)

print(b) # [1\. 2\. 3\. 4.]

print(b.dtype) # float64

清单 3-10:具有不同类型的 NumPy 数组

这段代码有两个数组,ab。第一个数组 a 的数据类型是 np.int16。这些数字是整数类型(数字后面没有“点”)。具体来说,当打印数组 adtype 属性时,你会得到 int16 结果。

第二个数组 b 的数据类型是 float64。所以,即使你基于一个整数列表创建数组,NumPy 也会将数组类型转换为 np.float64

这里有两个重要的要点:NumPy 让你控制数据类型,并且 NumPy 数组的数据类型是同质的。

代码

你有多个职业的数据,并且你希望每隔一年仅增加数据科学家的薪水 10%。清单 3-11 展示了代码。

## Dependencies

import numpy as np

## Data: yearly salary in ($1000) [2025, 2026, 2027]

dataScientist =     [130, 132, 137]

productManager =    [127, 140, 145]

designer =          [118, 118, 127]

softwareEngineer =  [129, 131, 137]

employees = np.array([dataScientist,

                      productManager,

                      designer,

                      softwareEngineer])

## One-liner

employees[0,::2] = employees[0,::2] * 1.1

## Result

print(employees)

清单 3-11:使用切片和切片赋值的单行解决方案

花点时间思考一下这段代码片段的输出。你预期会有什么变化?结果数组的数据类型是什么?这段代码的输出是什么?

原理

这段代码将你放在 2024 年。首先,你创建了一个 NumPy 数组,每一行代表一个专业人员(数据科学家、产品经理、设计师或软件工程师)的预期年薪。每一列对应未来几年的薪水,分别是 2025、2026 和 2027 年。最终的 NumPy 数组有四行三列。

你有资金可以加强公司中最重要的专业人才。你相信数据科学的未来,所以你决定奖励公司中那些隐藏的英雄:数据科学家。你需要更新 NumPy 数组,使得从 2025 年起,每隔一年数据科学家的薪资增加 10%(非累积),并且是从 2025 年开始。

你开发了以下这个漂亮的单行代码:

employees[0,::2] = employees[0,::2] * 1.1

它看起来简单且简洁,提供以下输出:

[[143 132 150]

 [127 140 145]

 [118 118 127]

 [129 131 137]]

尽管很简单,你的单行代码包含了三个有趣且高级的概念。

切片

首先,你使用了切片切片赋值的概念。在这个例子中,你通过切片获取了 NumPy 数组employees第一行的每隔一个值。然后,进行一些修改,通过切片赋值更新第一行的每隔一个值。切片赋值使用与切片相同的语法,但有一个关键区别:你选择赋值操作左边的切片。这些元素将被赋值操作右边指定的元素所替换。在代码片段中,你用更新后的薪资数据替换了 NumPy 数组中第一行的内容。

广播

第二,你使用了广播,它会自动调整不同形状的 NumPy 数组之间的逐元素操作。在这一行代码中,左侧操作符是一个 NumPy 数组,而右侧是一个浮动值。同样,NumPy 会自动创建一个新数组,使其与左侧数组的大小和维度相同,并在概念上填充该数组为浮动值的副本。实际上,NumPy 会执行的计算更像如下:

np.array([130 137]) * np.array([1.1, 1.1])
数组类型

第三,你可能已经意识到,结果数据类型不是浮动类型,而是整数类型,即使你正在执行浮点运算。当你创建数组时,NumPy 发现它只包含整数值,因此认为它是一个整数数组。你对整数数组进行的任何操作都不会改变数据类型,NumPy 会将值四舍五入为整数。同样,你可以通过使用dtype属性来访问数组的类型:

print(employees.dtype)

# int32

employees[0,::2] = employees[0,::2] * 1.1

print(employees.dtype)

# int32

总结一下,你已经学习了切片、切片赋值、广播和 NumPy 数组类型——在一行代码中取得了相当大的成就。接下来,我们将基于这个知识,通过解决一个具有现实世界影响的小型数据科学问题来进一步探讨:检测各个城市污染测量中的异常值。

条件数组搜索、筛选和广播以检测异常值

在这一行代码中,你将探讨各个城市的空气质量数据。具体而言,给定一个二维的 NumPy 数组,其中包含多个城市(行)的污染测量值(列),你将找出那些污染测量值超过平均水平的城市。通过阅读本节,你将掌握在数据集中查找异常值的重要技巧。

基础概念

空气质量指数(AQI)衡量对健康的不良影响风险,通常用于比较城市空气质量的差异。在这一行代码中,你将查看四个城市的 AQI:香港、纽约、柏林和蒙特利尔。

这一行代码找出污染超过平均水平的城市,定义为那些其峰值 AQI 超过所有城市所有测量值的总体平均值的城市。

我们解决方案的一个重要部分将是找出满足特定条件的 NumPy 数组中的元素。这是数据科学中非常常见的问题,你将经常使用它。

那么,让我们来探讨一下如何找到满足特定条件的数组元素。NumPy 提供了一个函数nonzero(),用于查找数组中不等于零的元素的索引。示例 3-12 给出了一个例子。

import numpy as np

X = np.array([[1, 0, 0],

              [0, 2, 2],

              [3, 0, 0]])

print(np.nonzero(X))

示例 3-12:非零函数

结果是一个包含两个 NumPy 数组的元组:

(array([0, 1, 1, 2], dtype=int64), array([0, 1, 2, 0], dtype=int64)).

第一个数组给出了行索引,第二个数组给出了非零元素的列索引。在这个二维数组中有四个非零元素:1、2、2 和 3,分别位于原数组中的X[0,0]X[1,1]X[1,2]X[2,0]位置。

那么,如何使用nonzero()来查找数组中满足某个条件的元素呢?你将使用 NumPy 的另一个强大功能:结合广播的布尔数组操作(参见示例 3-13)!

import numpy as np

X = np.array([[1, 0, 0],

              [0, 2, 2],

              [3, 0, 0]])

print(X == 2)

"""

[[False False False]

 [False  True  True]

 [False False False]]

"""

示例 3-13:在 NumPy 中使用广播和逐元素布尔操作符

广播发生时,整数值2会被复制(在概念上)到一个具有与原数组相同形状的新数组中。然后,NumPy 对每个整数与值2进行逐元素比较,并返回结果布尔数组。

在我们的主代码中,你将结合nonzero()函数和布尔数组操作来查找满足特定条件的元素。

代码

在示例 3-14 中,你正在从一组数据中找到具有高于平均值的污染峰值的城市。

## Dependencies

import numpy as np

## Data: air quality index AQI data (row = city)

X = np.array(

    [[ 42, 40, 41, 43, 44, 43 ], # Hong Kong

     [ 30, 31, 29, 29, 29, 30 ], # New York

     [ 8, 13, 31, 11, 11, 9 ], # Berlin

     [ 11, 11, 12, 13, 11, 12 ]]) # Montreal

cities = np.array(["Hong Kong", "New York", "Berlin", "Montreal"])

## One-liner

polluted = set(cities[np.nonzero(X > np.average(X))[0]])

## Result

print(polluted)

示例 3-14:使用广播、布尔操作符和选择性索引的一行解决方案

看看你能否判断出这段代码的输出是什么。

工作原理

数据数组X包含四行(每行对应一个城市)和六列(每列对应一个测量单位——在本例中是天数)。字符串数组cities包含四个城市的名称,按数据数组中出现的顺序排列。

这是一个一行代码,用于查找具有高于平均值的 AQI 值的城市:

## One-liner

polluted = set(cities[np.nonzero(X > np.average(X))[0]])

你需要先理解各个部分,才能理解整体。为了更好地理解这行代码,让我们从内部开始逐步分析。该一行代码的核心是布尔数组操作(参见示例 3-15)。

print(X > np.average(X))

"""

[[ True  True  True  True  True  True]

 [ True  True  True  True  True  True]

 [False False  True False False False]

 [False False False False False False]]

"""

示例 3-15:使用广播的布尔数组操作

你使用布尔表达式通过广播将两个操作数调整为相同的形状。然后使用np.average()函数计算所有 NumPy 数组元素的平均 AQI 值。布尔表达式随后执行逐元素比较,生成一个布尔数组,如果相应的测量值是高于平均的 AQI 值,则该位置为True

通过生成这个布尔数组,你可以精确知道哪些元素满足高于平均值的条件,哪些元素不满足。

记得 Python 中的True值由整数1表示,而False值由0表示。事实上,TrueFalse对象是bool类型,它是int的子类。因此,每个布尔值也是一个整数值。有了这个,你可以使用nonzero()函数找到满足条件的所有行和列索引,如下所示:

print(np.nonzero(X > np.average(X)))

"""

(array([0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 2], dtype=int64),

array([0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 2], dtype=int64))

"""

你有两个元组,第一个给出非零元素的行索引,第二个给出它们各自的列索引。

我们只关心那些空气质量指数(AQI)值高于平均值的城市名字,而不关心其他内容,所以你只需要行索引。你可以使用这些行索引通过高级索引从字符串数组中提取城市名字,高级索引是一种允许你定义一系列数组索引的索引技巧,无需它是连续的切片。这样,你可以通过指定整数序列(要选择的索引)或布尔序列(选择布尔值为True的特定索引)来访问给定 NumPy 数组中的任意元素:

print(cities[np.nonzero(X > np.average(X))[0]])

"""

['Hong Kong' 'Hong Kong' 'Hong Kong' 'Hong Kong' 'Hong Kong' 'Hong Kong'

 'New York' 'New York' 'New York' 'New York' 'New York' 'New York'

 'Berlin']

"""

你会注意到在生成的字符串序列中有很多重复项,因为香港和纽约有多个 AQI 高于平均值的测量数据。

现在,只剩下一件事要做:去除重复项。你可以通过将序列转换为 Python 集合来完成这项工作,集合默认是去重的,这样可以简洁地总结所有污染超过平均 AQI 值的城市名称。

练习 3-1

回到《基本二维数组运算》中的税务示例,参见第 42 页,并通过使用选择性布尔索引的思路从矩阵中提取薪资最高的人的名字。问题回顾:在给定一组人的年薪和税率的情况下,我们如何找到税后收入最高的人?

总结一下,你学习了如何在 NumPy 数组上使用布尔表达式(再次使用广播)和nonzero()函数来查找满足特定条件的行或列。在通过这行代码保存环境后,我们继续分析社交媒体中的网红。

布尔索引过滤二维数组

在这里,你将通过从一个小数据集中提取 Instagram 用户(粉丝数超过 1 亿)来巩固你的数组索引和广播知识。具体来说,给定一个二维的网红数组(行),第一列定义了网红的名字(字符串),第二列定义了网红的粉丝数,你将找出所有粉丝数超过 1 亿的网红名字!

基础知识

NumPy 数组通过增加多维切片和多维索引等额外功能,丰富了基本的列表数据类型。看看代码清单 3-16 中的代码片段。

import numpy as np

a = np.array([[1, 2, 3],

              [4, 5, 6],

              [7, 8, 9]])

indices = np.array([[False, False, True],

                    [False, False, False],

                    [True, True, False]])

print(a[indices])

# [3 7 8]

代码清单 3-16:NumPy 中的选择性(布尔)索引

你创建了两个数组:a 包含二维数值数据(可以将其视为 数据数组),indices 包含布尔值(可以将其视为 索引数组)。NumPy 的一个伟大特性是,你可以使用布尔数组对数据数组进行细粒度访问。简单来说,你创建一个新数组,其中仅包含那些在索引数组 indices 中对应位置为 True 的数据数组 a 元素。例如,如果 indices[i,j] == True,则新数组包含 a[i,j] 的值。类似地,如果 indices[i,j] == False,新数组则不包含 a[i,j] 的值。这样,结果数组包含三个值:378

在接下来的这行代码中,你将使用这个特性对社交网络进行简单分析。

代码

在列表 3-17 中,你可以找到粉丝超过 1 亿的 Instagram 超级明星的名字!

## Dependencies

import numpy as np

## Data: popular Instagram accounts (millions followers)

inst = np.array([[232, "@instagram"],

                 [133, "@selenagomez"],

                 [59,  "@victoriassecret"],

                 [120, "@cristiano"],

                 [111, "@beyonce"],

                 [76,  "@nike"]])

## One-liner

superstars = inst[inst[:,0].astype(float) > 100, 1]

## Results

print(superstars)

列表 3-17:使用切片、数组类型和布尔运算符的单行解决方案

像往常一样,在阅读解释之前,看看你是否能在脑海中计算出这个单行代码的结果。

原理

数据由一个二维数组 inst 组成,每一行代表一位 Instagram 网红。第一列表示他们的粉丝数(以百万为单位),第二列表示他们的 Instagram 名称。根据这些数据,你想提取粉丝超过 1 亿的 Instagram 网红的名字。

解决这个问题的方式有很多种,一行代码的最简单方法如下:

## One-liner

superstars = inst[inst[:,0].astype(float) > 100, 1]

让我们一步步分解这行代码。内部表达式计算出一个布尔值,表示每位网红的粉丝数是否超过 1 亿:

print(inst[:,0].astype(float) > 100)

# [ True  True False  True  True False]

第一列包含粉丝数,因此你使用切片来访问这些数据;inst[:,0] 返回所有行的第一列。但是,由于数据数组包含混合数据类型(整数和字符串),NumPy 会自动将数组的类型设置为非数值类型。原因是数值类型无法表示字符串数据,所以 NumPy 将数据转换为可以表示数组中所有数据(字符串和数值)的类型。你需要对数据数组的第一列执行数值比较,检查每个值是否大于 100,因此你首先通过 .astype(float) 将结果数组转换为浮点类型。

接下来,你检查浮点类型的 NumPy 数组中的值是否都大于整数值 100。这里,NumPy 再次使用广播机制自动将两个操作数转换为相同的形状,从而可以逐元素地进行比较。结果是一个布尔值数组,显示出四位网红的粉丝超过 1 亿。

现在,你可以使用这个布尔数组(也叫做 掩码索引数组)通过布尔索引来选择粉丝超过 1 亿的网红(即选择行):

inst[inst[:,0].astype(float) > 100, 1]

由于你只对这些影响者的名字感兴趣,你选择第二列作为最终结果,并将其存储在superstars变量中。

我们数据集中拥有超过 1 亿 Instagram 粉丝的影响者如下:

# ['@instagram' '@selenagomez' '@cristiano' '@beyonce']

总结来说,你已经将 NumPy 的概念(如切片、广播、布尔索引和数据类型转换)应用于一个小型的数据科学问题,这个问题来自社交媒体分析。接下来,你将学习物联网中的新应用场景。

广播、切片赋值和重塑以清理每个 i-th 数组元素

现实世界中的数据很少是干净的,可能由于多种原因(包括损坏或故障的传感器)而包含错误或缺失值。在这一部分,你将学习如何处理一些小的清理任务,以消除错误的数据点。

基础知识

假设你在花园里安装了一个温度传感器,用来测量几周的温度数据。每个星期天,你都会把温度传感器从花园里带进屋内,以数字化传感器的值。你知道,星期天的传感器值是有问题的,因为在一天的部分时间里,它们测量的是你家里的温度,而不是外面的温度。

你想通过将每个星期天的传感器值替换为过去七天的平均传感器值来清理数据(你将星期天的值包含在平均计算中,因为它并不是完全错误的)。在开始编写代码之前,我们先探讨一下你需要作为基本理解的最重要概念。

切片赋值

使用 NumPy 的切片赋值功能(参见“使用 NumPy 数组:切片、广播和数组类型”第 46 页),你在等式的左侧指定你想要替换的值,在等式的右侧指定替换这些值的内容。代码清单 3-18 提供了一个例子,以防你需要简单的回顾。

import numpy as np

a = np.array([4] * 16)

print(a)

# [4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4]

a[1::] = [42] * 15

print(a)

# [ 4 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42]

代码清单 3-18:简单的 Python 列表创建和切片赋值

这个代码片段创建了一个包含值4的数组,重复了 16 次。你使用切片赋值将最后 15 个值替换为值42。回想一下,a[start:stop:step]这种写法选择了从索引start开始,直到索引stop结束(不包括stop),并且只考虑每个step的元素。如果没有指定参数,NumPy 会假定默认值。a[1::]表示替换所有序列元素,除了第一个。代码清单 3-19 展示了如何将切片赋值与一个你已经多次见过的功能结合使用。

import numpy as np

a = np.array([4] * 16)

a[1:8:2] = 16

print(a)

# [ 4 16  4 16  4 16  4 16  4  4  4  4  4  4  4  4]

代码清单 3-19:NumPy 中的切片赋值

在这里,你将替换索引 1 到 8 之间(不包括 8)的每隔一个值。你可以看到,只需要指定一个单一的值16,就能替换选定的元素,因为——你猜对了——广播!等式右侧的内容会自动转换为一个与左侧数组形状相同的 NumPy 数组。

重塑

在深入了解单行代码之前,你需要学习一个重要的 NumPy 函数:x.reshape((a,b)),它将 NumPy 数组 x 转换为一个具有 a 行和 b 列的新数组(形状为 (a,b))。以下是一个示例:

a = np.array([1, 2, 3, 4, 5, 6])

print(a.reshape((2, 3)))

'''

[[1 2 3]

 [4 5 6]]

'''

如果列数是明确的,你也可以让 NumPy 自动计算列数。假设你想将一个包含六个元素的数组重新形状为一个具有两行的二维数组。NumPy 现在能够自动计算出需要三列,以匹配原始数组中的六个元素。下面是一个示例:

a = np.array([1, 2, 3, 4, 5, 6])

print(a.reshape((2, -1)))

'''

[[1 2 3]

 [4 5 6]]

'''

列参数的形状值 -1 表示 NumPy 应该将其替换为正确的列数(在本例中是三列)。

轴参数

最后,让我们考虑下面的代码片段,它引入了 axis 参数。这里有一个数组 solar_x,包含了埃隆·马斯克的 SolarX 公司每天的股价。我们想计算早晨、中午和晚上的平均股价。我们该如何实现呢?

import numpy as np

# daily stock prices

# [morning, midday, evening]

solar_x = np.array(

    [[1, 2, 3], # today

     [2, 2, 5]]) # yesterday

# midday - weighted average

print(np.average(solar_x, axis=0))

# [1.5 2.  4\. ]

数组 solar_x 包含 SolarX 公司的股价。它有两行(每天一行)和三列(每列一个股价)。假设我们想计算早晨、中午和晚上的平均股价。大致来说,我们想将每列中的所有值通过平均值合并。换句话说,我们沿着轴 0 计算平均值。这正是关键字参数 axis=0 的作用。

代码

这是你解决下列问题所需的所有知识(示例 3-20):给定一个温度值数组,如何将每第七个温度值替换为过去七天的平均值(包括第七天的温度值)。

## Dependencies

import numpy as np

## Sensor data (Mo, Tu, We, Th, Fr, Sa, Su)

tmp = np.array([1, 2, 3, 4, 3, 4, 4,

                5, 3, 3, 4, 3, 4, 6,

                6, 5, 5, 5, 4, 5, 5])

## One-liner

tmp[6::7] = np.average(tmp.reshape((-1,7)), axis=1)

## Result

print(tmp)

示例 3-20:使用平均值和重塑操作符、切片赋值以及轴参数的单行解决方案

你能计算出这段代码的输出吗?

它是如何工作的

数据以一维数组的形式到达,包含传感器值。

首先,你创建一个数据数组 tmp,它是一个一维的传感器值序列。在每一行中,你定义了七天的传感器值。

第二,你使用切片赋值来替换这个数组中的所有星期天的值。由于星期天是第七天,你使用表达式 tmp[6::7] 来选择相应的星期天值,从原始数组 tmp 的第七个元素开始。

第三步,我们将一维传感器数组重新调整为一个具有七列三行的二维数组,这样可以更方便地计算每周的平均温度值来替换周日的数据。由于重塑,现在可以将每一行的七个值合并为一个平均值。要重塑数组,你将元组值-17传递给tmp.reshape(),这告诉 NumPy 自动选择行数(轴 0)。大致来说,你指定了七列,NumPy 会根据需要创建足够的行数,以满足七列的条件。在我们的例子中,重塑后得到如下数组:

print(tmp.reshape((-1,7)))

"""

[[1 2 3 4 3 4 4]

 [5 3 3 4 3 4 6]

 [6 5 5 5 4 5 5]]

"""

每一行代表一周,每一列代表一个工作日。

现在,你通过使用带有轴参数的np.average()函数计算七天的平均值:axis=1告诉 NumPy 将第二个轴压缩为一个单一的平均值。注意,周日的值已包含在平均值的计算中(参见本节开头的问题描述)。这是方程右侧的结果:

print(np.average(tmp.reshape((-1,7)), axis=1))

# [3\. 4\. 5.]

这一行代码的目标是替换三项周日的温度值。其他所有值应该保持不变。让我们看看你是否达成了这个目标。在替换完所有周日的传感器值后,你将得到以下一行代码的最终结果:

# [1 2 3 4 3 4 3 5 3 3 4 3 4 4 6 5 5 5 4 5 5]

注意,你仍然拥有一个一维的 NumPy 数组,里面存有所有的温度传感器值。但现在,你已经用更具代表性的读数替换了那些不代表性的数据。

总结来说,这行代码主要是要理解数组形状和重塑的概念,以及如何使用axis属性来进行聚合函数,如np.average()。虽然这个应用比较具体,但它在很多场景中都会有所帮助。接下来,你将学习一个超级通用的概念:在 NumPy 中排序。

何时使用 sort() 函数以及何时使用 argsort() 函数

排序在许多情况下都是有用的,甚至是必不可少的。比如你在书架上搜索Python 单行代码。如果你的书架按字母顺序排列,找书会更加轻松。

这个一行代码的解决方案将展示如何通过 NumPy 在一行 Python 代码中实现排序。

基础知识

排序是更高级应用的核心,比如商业计算、操作系统中的进程调度(优先级队列)和搜索算法。幸运的是,NumPy 提供了多种排序算法。默认的排序算法是流行的快速排序算法。在第六章中,你将学习如何实现快速排序算法。然而,对于这一行代码,你将采取更高层次的方法,将排序函数视为一个黑盒,你只需要将 NumPy 数组输入,得到一个排序后的 NumPy 数组。

图 3-1 展示了将一个未排序的数组转换为排序数组的算法。这是 NumPy 的 sort() 函数的目的。

images

图 3-1:sort()argsort()函数的区别

但通常,获取将未排序数组转换为排序数组的索引数组也是很重要的。例如,未排序数组元素 1 的索引是 7。因为数组元素 1 是排序数组中的第一个元素,所以它的索引 7 是排序索引数组中的第一个元素。这就是 NumPy 的 argsort() 函数的作用:它在排序后创建一个新的原始索引数组(见 图 3-1 中的示例)。粗略来说,这些索引将排序原始数组中的元素。通过使用这个数组,你可以重建排序后的数组和原始数组。

列表 3-21 展示了如何在 NumPy 中使用 sort()argsort()

import numpy as np

a = np.array([10, 6, 8, 2, 5, 4, 9, 1])

print(np.sort(a))

# [ 1  2  4  5  6  8  9 10]

print(np.argsort(a))

# [7 3 5 4 1 2 6 0]

列表 3-21:NumPy 中的 sort()argsort() 函数

你创建一个未排序的数组 a,用 np.sort(a) 对其进行排序,并用 np.argsort(a) 获取排序后的原始索引。NumPy 的 sort() 函数与 Python 的 sorted() 函数不同,它也可以对多维数组进行排序!

图 3-2 展示了排序二维数组的两种方式。

images

图 3-2:沿一个轴进行排序

数组有两个轴:轴 0(行)和轴 1(列)。你可以沿轴 0 排序,称为垂直排序,或者沿轴 1 排序,称为水平排序。通常,axis 关键字定义了你进行 NumPy 操作的方向。列表 3-22 展示了如何技术性地实现这一点。

import numpy as np

a = np.array([[1, 6, 2],

              [5, 1, 1],

              [8, 0, 1]])

print(np.sort(a, axis=0))

"""

[[1 0 1]

 [5 1 1]

 [8 6 2]]

"""

print(np.sort(a, axis=1))

"""

[[1 2 6]

 [1 1 5]

 [0 1 8]]

"""

列表 3-22:沿一个轴进行排序

可选的 axis 参数帮助你沿固定方向对 NumPy 数组进行排序。首先,你按列排序,从最小值开始。然后按行排序。这是 NumPy 的 sort() 函数相比于 Python 内建的 sorted() 函数的主要优势。

代码

这个单行代码将找到 SAT 成绩最高的前三个学生的名字。注意,你要求的是学生的名字,而不是排序后的 SAT 成绩。看看数据,看看你是否能自己找到这个单行解决方案。尝试一下后,看看 列表 3-23。

## Dependencies

import numpy as np

## Data: SAT scores for different students

sat_scores = np.array([1100, 1256, 1543, 1043, 989, 1412, 1343])

students = np.array(["John", "Bob", "Alice", "Joe", "Jane", "Frank", "Carl"])

## One-liner

top_3 = students[np.argsort(sat_scores)][:-4:-1]

## Result

print(top_3)

列表 3-23:使用argsort()函数和带负步长切片的单行解决方案

和往常一样,尝试推测输出结果。

工作原理

我们的初始数据由学生的 SAT 成绩组成,作为一个一维数据数组,另有一个对应学生姓名的数组。例如,John 获得了稳健的 1100 分 SAT 成绩,而 Frank 获得了优异的 1412 分。

任务是找到三位最成功学生的姓名。你将通过运行argsort()函数,获取一个包含原始索引的新排序位置的数组,而不是仅仅排序 SAT 分数。

下面是argsort()函数在 SAT 分数上的输出:

print(np.argsort(sat_scores))

# [4 3 0 1 6 5 2]

你需要保留索引,因为你需要根据students数组中对应的原始位置来查找学生姓名。索引 4 出现在输出的第一个位置,因为 Jane 的 SAT 分数最低,只有 989 分。请注意,sort()argsort()都是按升序排序的,从最低值到最高值。

现在你已经排序了索引,接下来需要通过索引student数组来获取相应学生的姓名:

print(students[np.argsort(sat_scores)])

# ['Jane' 'Joe' 'John' 'Bob' 'Carl' 'Frank' 'Alice']

这是 NumPy 库的一个有用功能:你可以通过使用高级索引来重新排序序列。如果你指定一个索引序列,NumPy 将触发高级索引并返回一个重新排序的 NumPy 数组,按你指定的索引顺序排列。例如,命令students[np.argsort(sat_scores)]的输出为students[[4 3 0 1 6 5 2]],因此 NumPy 创建了一个如下的新数组:

[students[4]  students[3]  students[0]  students[1]  students[6]  students[5]  students[2]]

从中你可以得知,Jane 的 SAT 分数最低,而 Alice 的 SAT 分数最高。剩下的唯一任务就是反转列表,并通过简单的切片提取出前三名学生:

## One-liner

top_3 = students[np.argsort(sat_scores)][:-4:-1]

## Result

print(top_3)

# ['Alice' 'Frank' 'Carl']

Alice、Frank 和 Carl 分别拥有 1543、1412 和 1343 的最高 SAT 分数。

总结一下,你已经了解了两个重要的 NumPy 函数的应用:sort()argsort()。接下来,你将通过在实际的数据科学问题中使用布尔索引和 lambda 函数,进一步提升你对 NumPy 索引和切片的深入理解。

如何使用 Lambda 函数和布尔索引来过滤数组

现实世界的数据通常是噪声的。作为数据科学家,你的工作就是消除噪声,使数据易于访问,并创造出有意义的结论。因此,过滤数据对于现实世界的数据科学任务至关重要。在本节中,你将学习如何用一行代码创建一个最小的过滤函数。

基础知识

要创建一个一行的函数,你需要使用lambda 函数。正如你在第二章中所学,lambda 函数是匿名函数,你可以在一行代码中定义它:

lambda arguments : expression

你定义了一个由逗号分隔的参数列表作为输入。然后 lambda 函数评估表达式并返回结果。

让我们探索如何通过创建一个使用 lambda 函数定义的过滤器函数来解决我们的难题。

代码

考虑下面的问题,见示例 3-24:创建一个过滤器函数,接受一本书的列表x和一个最小评分y,并返回评分高于最小评分y'>y的潜在畅销书列表。

## Dependencies

import numpy as np

## Data (row = [title, rating])

books = np.array([['Coffee Break NumPy', 4.6],

                  ['Lord of the Rings', 5.0],

                  ['Harry Potter', 4.3],

                  ['Winnie-the-Pooh', 3.9],

                  ['The Clown of God', 2.2],

                  ['Coffee Break Python', 4.7]])

## One-liner

predict_bestseller = lambda x, y : x[x[:,1].astype(float) > y]

## Results

print(predict_bestseller(books, 3.9))

示例 3-24:使用 lambda 函数、类型转换和布尔运算符的一行解决方案

在继续之前,先猜一下这段代码的输出。

工作原理

数据由一个二维 NumPy 数组组成,其中每一行包含书籍的标题和平均用户评分(浮动数,范围在 0.0 到 5.0 之间)。评分数据集中有六本书。

目标是创建一个过滤函数,该函数接收书籍评分数据集x和阈值评分y作为输入,并返回评分高于阈值y的书籍。你将阈值设置为 3.9。

你可以通过定义一个匿名的 lambda 函数来实现这一点,该函数返回以下表达式的结果:

x[➊x[:,1] ➋.astype(float)➌> y]

假设数组x具有两列作为我们的书籍评分数组books。为了访问潜在的畅销书,你使用类似于列表 3-17 中的高级索引方案。

首先,你提取第二列 ➊,它包含书籍评分,并通过对 NumPy 数组x使用astype(float)方法 ➋ 将其转换为浮动数组。这是必要的,因为初始数组x包含混合数据类型(浮动数和字符串)。

接下来,你创建一个布尔数组,如果相应行索引的书籍评分大于y,则该数组的值为True ➌。请注意,浮动的y会隐式广播为一个新的 NumPy 数组,使得布尔运算符>的两个操作数具有相同的形状。此时,你已经创建了一个布尔数组,表示每本书是否可以被视为畅销书:x[:,1].astype(float)> y = [ True True True False False True]。因此,前两本书和最后一本书是畅销书。

接着,我们使用布尔数组作为原始书籍评分数组的索引数组,提取出所有评分超过阈值的书籍。更具体地说,我们使用布尔索引x[[ True True True False False True]],从原始数组中获取一个仅包含四本书的子数组:即评分为True值的书籍。这将得到以下一行代码的最终输出:

## Results

print(predict_bestseller(books, 3.9))

"""

[['Coffee Break NumPy' '4.6']

 ['Lord of the Rings' '5.0']

 ['Harry Potter' '4.3']

 ['Coffee Break Python' '4.7']]

"""

总结一下,你已经学会了如何仅使用布尔索引和 lambda 函数来过滤数据。接下来,你将深入学习逻辑运算符,并学会一个有用的技巧,以简洁地编写逻辑与操作。

如何创建具有统计、数学和逻辑功能的高级数组过滤器

本节向你展示了最基本的异常值检测算法:如果观察值偏离均值超过标准差,它就被视为一个异常值。你将通过分析网站数据的一个示例,来确定活跃用户数、跳出率和平均会话时长(单位:秒)。(跳出率是指访问者在仅访问一个网站后立即离开的百分比。高跳出率是一个坏信号:它可能表明网站很无聊或不相关。)你将查看数据并识别异常值。

基础知识

为了解决异常值检测问题,你首先需要学习三项基本技能:理解均值和标准差,计算绝对值,以及执行逻辑与操作。

理解均值和标准差

首先,你将通过使用基础统计学慢慢发展我们对异常值的定义。你会做出一个基本假设:所有观察到的数据都是围绕均值分布的。例如,考虑以下数据值序列:

[ 8.78087409 10.95890859  8.90183201  8.42516116  9.26643393 12.52747974

  9.70413087 10.09101284  9.90002825 10.15149208  9.42468412 11.36732294

  9.5603904   9.80945055 10.15792838 10.13521324 11.0435137  10.06329581 

--snip--

 10.74304416 10.47904781]

如果你绘制这个序列的直方图,你将得到图 3-3 中的结果。

该序列似乎呈现出一个正态分布,其均值为 10,标准差为 1。均值,表示为μ符号,是所有序列值的平均值。标准差,表示为σ符号,衡量数据集围绕均值的变动程度。根据定义,如果数据确实是正态分布的,68.2%的样本值会落在标准差区间[ω[1] = μ – σ,ω[2] = μ + σ]内。这为异常值提供了一个范围:任何不在该范围内的值都被视为异常值。

在这个例子中,我从均值μ=10 和标准差σ=1 的正态分布中生成了数据,结果得到了区间ω[1] = μ – 1 = 9 和ω[2] = μ + 1 = 11。在接下来的内容中,你可以简单地假设任何超出均值周围标准差标记区间的观察值都是异常值。对于我们的数据,这意味着任何不落在区间[9,11]内的值都是异常值。

images

图 3-3:数据值序列的直方图

我用于生成该图的简单代码显示在清单 3-25 中。你能找到定义均值和标准差的代码行吗?

import numpy as np

import matplotlib.pyplot as plt

sequence = np.random.normal(10.0, 1.0, 500)

print(sequence)

plt.xkcd()

plt.hist(sequence)

plt.annotate(r"$\omega_1=9$", (9, 70))

plt.annotate(r"$\omega_2=11$", (11, 70))

plt.annotate(r"$\mu=10$", (10, 90))

plt.savefig("plot.jpg")

plt.show()

清单 3-25:使用 Matplotlib 库绘制直方图

这段代码展示了如何使用 Python 的 Matplotlib 库绘制直方图。不过,这一部分的重点并不在此,我想强调的是如何创建前面提到的数据值序列。

只需导入 NumPy 库并使用模块np.random,它提供了一个函数normal(mean, deviation, shape),该函数会创建一个新的 NumPy 数组,其中的值是从具有给定均值和标准差的正态分布中随机抽取的。这就是你设置mean=10.0deviation=1.0来创建数据序列的地方。在这个例子中,设置shape=500表示你只关心一个包含 500 个数据点的一维数据数组。剩余的代码导入了特殊的 xkcd 绘图样式plt.xkcd(),并使用plt.hist(sequence)根据序列绘制直方图,进行注释样式设置,并输出最终的图表。

注意

xkcd 绘图的名称来自于流行的网络漫画页面 xkcd(xkcd.com/)。

在深入了解一行代码之前,让我们快速回顾一下完成此任务所需的另外两项基本技能。

查找绝对值

第二,你需要将负值转为正值,这样你就可以检查每个异常值是否偏离其均值超过标准差。你关心的只是绝对偏差,而不是它是正还是负。这就是所谓的取绝对值。示例 3-26 中的 NumPy 函数会创建一个包含原始数组绝对值的新 NumPy 数组。

import numpy as np

a = np.array([1, -1, 2, -2])

print(a)

# [ 1 -1  2 -2]

print(np.abs(a))

# [1 1 2 2]

示例 3-26:在 NumPy 中计算绝对值

np.abs()函数将 NumPy 数组中的负值转换为其正值。

执行逻辑与运算

第三,以下 NumPy 函数执行逐元素的逻辑与操作,将两个布尔数组ab合并,并返回一个使用逻辑与操作合并各个布尔值的数组(见示例 3-27)。

import numpy as np

a = np.array([True, True, True, False])

b = np.array([False, True, True, False])

print(np.logical_and(a, b))

# [False  True  True False]

示例 3-27:应用于 NumPy 数组的逻辑与操作

你通过使用np.logical_and(a, b)将数组a中索引为i的每个元素与数组b中索引为i的元素组合在一起。结果是一个布尔值数组,当两个操作数a[i]b[i]都为True时,结果为True,否则为False。通过这种方式,你可以使用标准的逻辑操作将多个布尔数组合并成一个布尔数组。这个操作的一个有用应用是将布尔过滤器数组组合在一起,如下所示的单行代码所做的那样。

请注意,你也可以相乘两个布尔数组ab——这等同于np.logical_and(a, b)操作。Python 将True值表示为整数值1(或任何非零整数值),而将False值表示为整数值0。如果你将任何值与0相乘,结果将是0,因此是False。这意味着,只有当所有操作数都为True时,你才会得到一个True的结果(整数值>1)。

有了这些信息,你现在已经完全具备理解以下单行代码片段的能力。

代码

这行代码将找到所有异常值日期,这些日期的统计值偏离其均值超过标准差。

## Dependencies

import numpy as np

## Website analytics data:

## (row = day), (col = users, bounce, duration)

a = np.array([[815, 70, 115],

              [767, 80, 50],

              [912, 74, 77],

              [554, 88, 70],

              [1008, 65, 128]])

mean, stdev = np.mean(a, axis=0), np.std(a, axis=0)

# [811.2  76.4  88\. ], [152.97764543   6.85857128  29.04479299]

## One-liner

outliers = ((np.abs(a[:,0] - mean[0]) > stdev[0])

            * (np.abs(a[:,1] - mean[1]) > stdev[1])

            * (np.abs(a[:,2] - mean[2]) > stdev[2]))

## Result

print(a[outliers])

示例 3-28:使用均值函数、标准差和布尔运算符与广播的单行解决方案

你能猜出这段代码的输出吗?

它是如何工作的

数据集由表示不同日期的行和三列组成,分别表示每日活跃用户、跳出率和平均会话时长(秒数)。

对每一列,你计算其均值和标准差。例如,"每日活跃用户"这一列的均值为 811.2,标准差为 152.97。请注意,你在这里使用axis参数的方式与“广播、切片赋值和重塑以清理每个i索引元素”中第 60 页的做法相同。

我们的目标是检测出在所有三个列中都是异常值的网站。对于日活跃用户列,任何观察值小于 811.2 – 152.97 = 658.23 或大于 811.2 + 152.23 = 963.43 的值都被视为异常值。

然而,你认为只有当所有三个观察到的列都是异常值时,一天才被认为是异常值。你通过使用逻辑与运算符将这三个布尔数组结合起来来实现这一点。结果是只有一行数据,且这三列都是异常值:

[[1008   65  128]]

总结一下,你已经了解了 NumPy 的逻辑与运算符以及如何使用它进行基本的异常值检测,同时利用 NumPy 库中的简单统计量。接下来,你将学习亚马逊成功的一个秘密成分:如何提供相关的产品购买推荐。

简单的关联分析:购买 X 的人也购买了 Y

你有没有曾经购买过亚马逊算法推荐的产品?推荐算法通常基于一种叫做关联分析的技术。在这一节中,你将了解关联分析的基本概念,并如何踏入推荐系统的深海。

基础知识

关联分析基于历史客户数据,例如亚马逊上的“购买了x的人也购买了y”数据。这种不同产品的关联是一个强大的营销概念,因为它不仅将相关但互补的产品联系在一起,还为你提供了社会证明——知道其他人也购买了某个产品,会增加你自己购买该产品的心理安全感。这是一个对营销人员非常有用的工具。

让我们来看一个实践例子,见 图 3-4。

images

图 3-4:产品-客户矩阵——哪个客户购买了哪个产品?

四个客户 Alice、Bob、Louis 和 Larissa 购买了不同组合的产品:书籍、游戏、足球、笔记本电脑、耳机。假设你知道这四个人购买的每个产品,但不知道 Louis 是否购买了笔记本电脑。你认为 Louis 有可能购买笔记本电脑吗?

关联分析(或协同过滤)为这个问题提供了答案。其基本假设是,如果两个人过去做过类似的事情(例如,购买了相似的产品),他们在未来更有可能继续做类似的事情。Louis 的购买行为与 Alice 相似,而 Alice 购买了笔记本电脑。因此,推荐系统预测 Louis 也可能购买笔记本电脑。

以下代码片段简化了这个问题。

代码

考虑以下问题:有多少客户一起购买了两本电子书?基于这些数据,如果推荐系统发现客户原本只打算购买一本书,它就可以为客户提供一本“书籍捆绑”来购买。请参见 清单 3-29。

## Dependencies

import numpy as np

## Data: row is customer shopping basket

## row = [course 1, course 2, ebook 1, ebook 2]

## value 1 indicates that an item was bought.

basket = np.array([[0, 1, 1, 0],

                   [0, 0, 0, 1],

                   [1, 1, 0, 0],

                   [0, 1, 1, 1],

                   [1, 1, 1, 0],

                   [0, 1, 1, 0],

                   [1, 1, 0, 1],

                   [1, 1, 1, 1]])

## One-liner

copurchases = np.sum(np.all(basket[:,2:], axis = 1)) / basket.shape[0]

## Result

print(copurchases)

列表 3-29:使用切片、轴参数、形状属性以及带广播的基本数组运算的单行解决方案

这段代码的输出是什么?

工作原理

购物篮数据数组包含每个顾客一行,每个产品一列。前两个产品的列索引为 0 和 1,是在线课程,后两个产品的列索引为 2 和 3,是电子书。在单元格(i,j)中值为1表示顾客i购买了产品j

我们的任务是找到购买了两本电子书的顾客比例,所以我们只关心第 2 列和第 3 列。首先,你需要从原始数组中提取相关的列,得到以下子数组:

print(basket[:,2:])

"""

[[1 0]

 [0 1]

 [0 0]

 [1 1]

 [1 0]

 [1 0]

 [0 1]

 [1 1]]

"""

这将给你一个只有第三列和第四列的数组。

NumPy 的all()函数检查 NumPy 数组中的所有值是否都评估为True。如果是,返回True,否则返回False。当与axis参数一起使用时,函数会沿指定的轴执行此操作。

注意

你会注意到,axis参数是许多 NumPy 函数中的一个反复出现的元素,因此值得花时间深入理解axis参数。指定的轴会根据相应的聚合函数(此处为all())被压缩为一个单一值。

因此,应用all()函数在子数组上的结果是:

print(np.all(basket[:,2:], axis = 1))

# [False False False  True False False False  True]

用简单的语言来说:只有第四位和最后一位顾客购买了两本电子书。

因为你对顾客的比例感兴趣,所以你对这个布尔数组求和,得到总和为 2,然后除以顾客的总数 8\。结果是 0.25,即购买了两本电子书的顾客比例。

总结来说,你加深了对 NumPy 基础知识的理解,如shape属性和axis参数,以及如何将它们结合起来分析不同产品的共同购买情况。接下来,你将继续使用这个例子,学习使用 NumPy 和 Python 的特殊功能——即广播列表推导——进行更高级的数组聚合技术。

中级关联分析:寻找畅销商品组合

让我们更详细地探讨关联分析的主题。

基础知识

考虑上一节的例子:你的顾客从四种不同的产品中购买个别商品。你的公司希望通过促销相关产品(向顾客推荐额外的、通常相关的产品)来增加销量。对于每一对产品组合,你需要计算它们被同一顾客购买的频率,并找出最常一起购买的两种产品。

对于这个问题,你已经学会了所有需要了解的内容,我们直接开始吧!

代码

这个单行代码旨在找到最常一起购买的两件商品;参见列表 3-30。

## Dependencies

import numpy as np

## Data: row is customer shopping basket

## row = [course 1, course 2, ebook 1, ebook 2]

## value 1 indicates that an item was bought.

basket = np.array([[0, 1, 1, 0],

                   [0, 0, 0, 1],

                   [1, 1, 0, 0],

                   [0, 1, 1, 1],

                   [1, 1, 1, 0],

                   [0, 1, 1, 0],

                   [1, 1, 0, 1],

                   [1, 1, 1, 1]])

## One-liner (broken down in two lines;)

copurchases = [(i,j,np.sum(basket[:,i] + basket[:,j] == 2))

               for i in range(4) for j in range(i+1,4)]

## Result

print(max(copurchases, key=lambda x:x[2]))

Listing 3-30: 使用 lambda 函数作为max()函数的key参数,结合列表推导式和布尔运算符进行广播的单行解决方案

这个单行代码的输出是什么?

工作原理

数据数组包含历史购买数据,每一行代表一个顾客,每一列代表一个产品。我们的目标是获取一个元组列表:每个元组描述了产品组合及其一起购买的次数。对于每个列表元素,你希望前两个元组值为列索引(即两种产品的组合),第三个元组值为这两种产品一起购买的次数。例如,元组(0,1,4)表示购买产品 0的顾客也购买了产品 1,共四次。

那么,如何实现这一点呢?让我们拆解一下这个单行代码,这里略微调整了一下格式,因为它太宽,无法放在一行内:

## One-liner (broken down in two lines;)

copurchases = [(i,j,np.sum(basket[:,i] + basket[:,j] == 2))

               for i in range(4) for j in range(i+1,4)]

你可以看到外部格式[(..., ..., ...) for ... in ... for ... in ...],这是通过列表推导式创建元组列表(参见第二章)。你关注的是一个四列数组中每一对独特的列索引组合。以下是该单行代码外部部分的结果:

print([(i,j) for i in range(4) for j in range(i+1,4)])

# [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]

因此,列表中有六个元组,每个都是独特的列索引组合。

知道这一点后,你可以深入查看第三个元组元素:这两种产品ij一起购买的次数:

np.sum(basket[:,i] + basket[:,j] == 2)

你使用切片从原始的 NumPy 数组中提取列ij。然后,你将它们按元素相加。对于得到的数组,你按元素检查和是否等于 2,这意味着在两个列中都有 1,从而表示这两种产品被一起购买。结果是一个布尔数组,如果两个产品被同一个顾客一起购买,值为True

你将所有结果元组存储在列表copurchases中。以下是该列表的元素:

print(copurchases)

# [(0, 1, 4), (0, 2, 2), (0, 3, 2), (1, 2, 5), (1, 3, 3), (2, 3, 2)]

现在只剩下一件事:找出最常一起购买的两种产品:

## Result

print(max(copurchases, key=lambda x:x[2]))

你使用max()函数找出列表中的最大元素。你定义了一个键函数,该函数接受一个元组并返回第三个元组值(共购次数),然后从这些值中找到最大值。单行代码的结果如下:

## Result

print(max(copurchases, key=lambda x:x[2]))

# (1, 2, 5)

第二种和第三种产品一共被一起购买了五次。没有其他产品组合能达到这么高的共购频率。因此,你可以告诉你的老板,当销售产品 1时,推荐产品 2,反之亦然。

总结来说,你已经了解了 Python 和 NumPy 的各种核心功能,如广播、列表推导式、lambda 函数以及关键函数。通常,Python 代码的表达力来自于多个语言元素、函数和代码技巧的结合。

总结

在本章中,你学习了基础的 NumPy 知识,如数组、形状、轴、类型、广播、先进索引、切片、排序、搜索、聚合和统计。通过练习重要的技巧,比如列表推导式、逻辑运算和 lambda 函数,你还提升了你的基本 Python 技能。最重要的是,你提升了快速阅读、理解并编写简洁代码的能力,同时在这个过程中掌握了基本的数据科学问题。

让我们保持这种快速学习 Python 相关有趣话题的节奏。接下来,你将深入探索机器学习这个令人兴奋的主题。你将学习基本的机器学习算法,以及如何通过使用流行的 scikit-learn 库,用一行代码利用它们强大的功能。每个机器学习专家都对这个库非常熟悉。不过不用担心——你刚刚掌握的 NumPy 技能将极大帮助你理解接下来讲解的代码片段。

第四章:机器学习**

Image

机器学习几乎在计算机科学的每个领域都有应用。在过去几年中,我参加了多个计算机科学领域的会议,涵盖了分布式系统、数据库和流处理等不同领域,无论走到哪里,机器学习都已经成为其中的一部分。在一些会议中,展示的研究创意中,有超过一半的依赖了机器学习方法。

作为一名计算机科学家,你必须了解基本的机器学习理念和算法,以完善你的整体技能。本章介绍了最重要的机器学习算法和方法,并提供了 10 个实用的单行代码,帮助你在自己的项目中应用这些算法。

监督学习的基础

机器学习的主要目的是利用现有数据做出准确的预测。假设你想编写一个算法,预测未来两天某只股票的价值。为此,你需要训练一个机器学习模型。那么,到底什么是一个模型呢?

从机器学习用户的角度来看,机器学习模型像一个黑箱(图 4-1):你输入数据,输出预测结果。

images

图 4-1:机器学习模型,表示为黑箱

在这个模型中,你将输入数据称为特征,并用变量x表示,它可以是一个数值或一个多维数值向量。然后,模型会进行处理并完成它的“魔法”,在经过一段时间后,你会得到预测结果y,这是模型基于输入特征所预测的输出。对于回归问题,预测结果由一个或多个数值组成——就像输入特征一样。

监督学习分为两个独立的阶段:训练阶段和推理阶段。

训练阶段

训练阶段,你将给模型提供一个特定输入x和你期望的输出y’。当模型输出预测结果y时,你会将其与y’进行比较,如果它们不相同,你就更新模型,以便生成一个更接近y’的输出,如图 4-2 所示。让我们看一个图像识别的例子。假设你训练一个模型,当给定图像(输入)时,预测水果名称(输出)。例如,你的训练输入是一个香蕉的图像,但模型错误地预测为苹果。由于你的期望输出与模型预测不同,你将调整模型,使得下次模型能正确预测香蕉

images

图 4-2:机器学习模型的训练阶段

当你不断向模型提供不同输入的期望输出并调整模型时,你就在使用训练数据训练模型。随着时间的推移,模型将学习你希望在某些输入下得到的输出。这就是为什么数据在 21 世纪如此重要:你的模型将与其训练数据一样好。没有好的训练数据,模型注定会失败。粗略地说,训练数据监督着机器学习过程。这就是我们将其称为有监督学习的原因。

推理阶段

推理阶段,你使用训练好的模型来预测新输入特征x的输出值。请注意,模型具有预测在训练数据中从未观察过的输入的输出的能力。例如,训练阶段中的水果预测模型现在可以识别它从未见过的图像中(在训练数据中学到的)水果名称。换句话说,合适的机器学习模型具备概括能力:它们利用训练数据中的经验来预测新输入的结果。粗略地说,能够良好概括的模型会为新输入数据产生准确的预测。对于未见过的输入数据的泛化预测是机器学习的一个强项,也是它在广泛应用中受欢迎的主要原因。

线性回归

线性回归是你在初学者级别的机器学习教程中最常见的机器学习算法。它通常用于回归问题,其中模型通过使用现有的数据预测缺失的数据值。线性回归的一个显著优势是其简洁性,这对教师和用户都非常有利。但这并不意味着它不能解决实际问题!线性回归在市场研究、天文学和生物学等各个领域都有很多实际应用。在这一节中,你将学到开始使用线性回归所需的所有知识。

基础知识

如何使用线性回归预测某一天的股价?在我回答这个问题之前,让我们先从一些定义开始。

每个机器学习模型都由模型参数组成。模型参数是从数据中估算出来的内部配置变量。这些模型参数决定了给定输入特征时,模型如何准确地计算预测值。对于线性回归,模型参数被称为系数。你可能还记得学校时学过的二维直线公式:f(x) = ax + c。这两个变量ac就是线性方程ax + c中的系数。你可以描述每个输入x是如何转化为输出f(x)的,使得所有输出一起描述二维空间中的一条直线。通过改变系数,你可以描述二维空间中的任何一条直线。

给定输入特征x[1]、x[2]、...、x**[k],线性回归模型将输入特征与系数a[1]、a[2]、...、a**[k]结合,通过使用该公式计算预测输出y

y = f(x) = a[0] + a[1] × x[1] + a[2] × x[2] + ... + a[k] × x[k]

在我们的股价示例中,你有一个输入特征x,即日期。你输入日期x,期望得到股价,即输出y。这将线性回归模型简化为二维直线公式:

y = f(x) = a[0] + a[1]x

让我们看看在图 4-3 中,只改变两个模型参数a[0]和a[1]的三条直线。第一轴描述输入x,第二轴描述输出y。直线表示输入与输出之间的(线性)关系。

images

图 4-3:由不同模型参数(系数)描述的三条线性回归模型(直线)。每条线代表输入与输出变量之间的独特关系。

在我们的股价示例中,假设我们的训练数据是三天的索引[0, 1, 2],与股价[155, 156, 157]相对应。换句话说:

  • 输入x=0应导致输出y=155

  • 输入x=1应导致输出y=156

  • 输入x=2应导致输出y=157

现在,哪条直线最适合我们的训练数据?我在图 4-4 中绘制了训练数据。

images

图 4-4:我们的训练数据,其在数组中的索引作为x坐标,价格作为y坐标

要找到最能描述数据的直线,从而创建线性回归模型,我们需要确定系数。这就是机器学习的作用所在。确定线性回归模型参数的主要方法有两种。首先,你可以通过解析方法计算出最佳拟合线,即通过这些数据点的直线(这是线性回归的标准方法)。其次,你可以尝试不同的模型,逐个对比标记样本数据,最终选择最合适的模型。无论如何,你通过一种叫做误差最小化的过程来确定“最佳”,在这个过程中,模型最小化预测模型值和理想输出之间的平方差(或选择能导致最小平方差的系数),从而选择误差最小的模型。

对于我们的数据,你得到的系数是a[0] = 155.0 和 a[1] = 1.0。然后你将它们代入我们的线性回归公式:

y = f(x) = a[0] + a[1]x = 155.0 + 1.0 × x

并且在同一坐标系中绘制直线和训练数据,如图 4-5 所示。

images

图 4-5:使用我们的线性回归模型制作的预测线

完美的拟合!线(模型预测)和训练数据之间的平方距离为零——因此你已经找到了最小化误差的模型。使用这个模型,你现在可以预测任何 x 值下的股票价格。例如,假设你想预测第 x 天 = 4 的股票价格。为了做到这一点,你只需使用模型计算 f(x) = 155.0 + 1.0 × 4 = 159.0。第 4 天的预测股票价格是 159 美元。当然,这个预测是否准确反映了现实世界是另一个问题。

这就是发生的高层次概览。让我们更仔细地看看如何用代码实现这一点。

代码

清单 4-1 展示了如何用一行代码构建一个简单的线性回归模型(你可能需要先通过在命令行中运行pip install sklearn来安装 scikit-learn 库)。

from sklearn.linear_model import LinearRegression

import numpy as np

## Data (Apple stock prices)

apple = np.array([155, 156, 157])

n = len(apple)

## One-liner

model = LinearRegression().fit(np.arange(n).reshape((n,1)), apple)

## Result & puzzle

print(model.predict([[3],[4]]))

清单 4-1:一个简单的线性回归模型

你能猜出这个代码片段的输出吗?

工作原理

这个一行代码使用了两个 Python 库:NumPy 和 scikit-learn。前者是数值计算(如矩阵运算)的事实标准库,后者是最全面的机器学习库,包含了数百种机器学习算法和技术的实现。

你可能会问:“为什么在 Python 的一行代码中使用库?这不是作弊吗?”这是个好问题,答案是肯定的。任何 Python 程序——无论是否使用库——都使用了基于低级操作构建的高级功能。在可以重用现有代码库的情况下,重新发明轮子是没有太大意义的(也就是站在巨人的肩膀上)。有抱负的编码者往往有一种冲动,想要自己实现所有功能,但这会降低他们的编码生产力。在这本书中,我们将使用,而不是拒绝,世界上一些最优秀的 Python 编程者和先驱者实现的强大功能。这些库的每一个都经过了熟练的编码者多年的开发、优化和调整。

让我们一步步分析清单 4-1。首先,我们创建一个包含三个值的简单数据集,并将其长度存储在一个单独的变量 n 中,以便代码更加简洁。我们的数据是三天内的三次苹果股票价格。变量 apple 将这个数据集作为一维的 NumPy 数组保存。

其次,我们通过调用 LinearRegression() 来构建模型。那么模型的参数是什么呢?为了找到它们,我们调用 fit() 函数来训练模型。fit() 函数接受两个参数:训练数据的输入特征和这些输入的理想输出。我们的理想输出是苹果股票的实际股票价格。但是对于输入特征,fit() 需要一个以下格式的数组:

[<training_data_1>,

<training_data_2>,

--snip--

<training_data_n>]

其中每个训练数据值都是一组特征值的序列:

<training_data> = [feature_1, feature_2, ..., feature_k]

在我们的案例中,输入仅包含一个特征 x(当前日期)。此外,预测也仅包含一个值 y(当前股票价格)。为了将输入数组调整为正确的形状,你需要将其重塑为这种看起来奇怪的矩阵形式:

[[0],

 [1],

 [2]]

只有一列的矩阵称为 列向量。你可以使用 np.arange() 创建一个递增的 x 值序列;然后使用 reshape((n, 1)) 将一维的 NumPy 数组转换为一个具有一列和 n 行的二维数组(见 第三章)。请注意,scikit-learn 允许输出为一维数组(否则,你还需要重塑 apple 数据数组)。

一旦获得了训练数据和理想的输出,fit() 就会进行误差最小化:它会找到模型参数(这意味着直线),使得预测模型值与期望输出之间的差异最小。

fit() 对它的模型感到满意时,它会返回一个模型,你可以使用 predict() 函数预测两个新的股票值。predict() 函数与 fit() 函数有相同的输入要求,所以为了满足这些要求,你需要传入一个包含我们想要预测的两个新值的一列矩阵:

print(model.predict([[3],[4]]))

由于我们的误差最小化为零,你应该得到完美的线性输出 158 和 159。这个结果很好地符合在 图 4-5 中绘制的拟合直线。但通常情况下,很难找到这样一个完美拟合的单一直线模型。例如,如果我们的股票价格是 [157, 156, 159],然后你运行相同的函数并绘制它,你应该得到在 图 4-6 中的直线。

在这种情况下,fit() 函数会找到一条最小化训练数据和预测之间平方误差的直线,正如之前所描述的那样。

让我们总结一下。线性回归是一种机器学习技术,在这种技术中,模型通过学习系数作为模型参数来进行训练。得到的线性模型(例如二维空间中的一条直线)直接为你提供新的输入数据的预测值。当给定数值输入时,预测数值的问题属于回归问题的范畴。在下一节,你将学习机器学习中的另一个重要领域——分类。

images

图 4-6:一个拟合不完美的线性回归模型

一行代码实现逻辑回归

逻辑回归通常用于分类问题,在这些问题中,你预测一个样本是否属于某个特定的类别(或类)。这与回归问题不同,回归问题中给定一个样本并预测一个数值,该数值属于一个连续的范围。例如,一个分类问题是根据不同的输入特征(如发帖频率推文回复数)将 Twitter 用户分为男性和女性。逻辑回归模型属于最基础的机器学习模型之一。本节中介绍的许多概念将构成更高级机器学习技术的基础。

基础知识

为了介绍逻辑回归,我们先简要回顾一下线性回归是如何工作的:给定训练数据,你计算出一条拟合这些数据的直线,并预测输入 x 的结果。一般来说,线性回归非常适合预测连续输出,其值可以取无限多个数值。例如,之前预测的股价,理论上可以是任何正数值。

但如果输出不是连续的,而是分类的,属于有限数量的组或类别呢?例如,假设你想预测一个患者吸烟量与得肺癌的可能性。每个患者要么得肺癌,要么不。这与股价预测不同,这里只有两种可能的结果。预测分类结果的可能性是逻辑回归的主要动机。

Sigmoid 函数

而线性回归拟合训练数据中的一条直线,逻辑回归则拟合一条 S 形曲线,这条曲线称为Sigmoid 函数。S 形曲线帮助你做出二元决策(例如, 是/否)。对于大多数输入值,Sigmoid 函数的输出值要么非常接近 0(一个类别),要么非常接近 1(另一个类别)。在实际应用中,很少会有输入值生成模糊的输出。请注意,虽然对于某些输入值,可能会生成 0.5 的概率值,但曲线的形状设计上是为了最小化这种情况(对于横轴上的大多数可能值,概率值要么非常接近 0,要么非常接近 1)。图 4-7 展示了肺癌情景下的逻辑回归曲线。

images

图 4-7:基于吸烟量预测癌症的逻辑回归曲线

注意

你可以应用逻辑回归进行 多项分类 ,将数据分类为两个以上的类别。为此,你将使用 sigmoid 函数的推广版本,称为 softmax 函数,* 它返回一个包含每个类别概率的元组。Sigmoid 函数将输入特征转换为单一的概率值。然而,为了清晰和可读性,我将在本节中重点讨论* 二项分类 和 sigmoid 函数。

图 4-7 中的 sigmoid 函数近似了一个病人是否患有肺癌的概率,给定他吸烟的数量。当你唯一掌握的信息是病人的吸烟量时,这个概率帮助你做出更有依据的决策:病人是否患有肺癌?

看一下图 4-8 中的预测,它展示了两个新病人(图表底部的浅灰色部分)。你对他们一无所知,只知道他们的吸烟数量。你已经训练了我们的逻辑回归模型(sigmoid 函数),它会根据任何新的输入值 x 返回一个概率值。如果 sigmoid 函数给出的概率超过 50%,模型预测为肺癌阳性;否则,它预测为肺癌阴性

images

图 4-8:使用逻辑回归估算结果的概率

寻找最大似然模型

逻辑回归的主要问题是如何选择最佳拟合训练数据的正确 sigmoid 函数。答案在于每个模型的似然性:即模型生成观察到的训练数据的概率。你想选择具有最大似然性的模型。你的直觉是,该模型最好地近似了生成训练数据的真实世界过程。

要计算给定模型在一组训练数据上的似然性,你需要计算每一个单独训练数据点的似然性,然后将这些似然性相乘,得到整个训练数据集的似然性。如何计算单个训练数据点的似然性?只需将该模型的 sigmoid 函数应用于该训练数据点;它会给出该数据点在此模型下的概率。为了选择对所有数据点具有最大似然的模型,你需要对不同的 sigmoid 函数重复相同的似然性计算(稍微调整一下 sigmoid 函数),如图 4-9 所示。

在前一段中,我描述了如何确定最大似然的 sigmoid 函数(模型)。这个 sigmoid 函数最能拟合数据——因此,你可以用它来预测新的数据点。

现在我们已经讨论了理论,让我们看看如何将逻辑回归实现为一行 Python 代码。

images

图 4-9:测试多个 sigmoid 函数以确定最大似然性

代码

你已经看到过一个健康应用程序的逻辑回归示例(将吸烟量与癌症概率相关联)。这个“虚拟医生”应用程序将是一个非常棒的智能手机应用程序创意,不是吗?让我们使用逻辑回归编写你的第一个虚拟医生,代码如清单 4-2 所示——仅需一行 Python 代码!

from sklearn.linear_model import LogisticRegression

import numpy as np

## Data (#cigarettes, cancer)

X = np.array([[0, "No"],

              [10, "No"],

              [60, "Yes"],

              [90, "Yes"]])

## One-liner

model = LogisticRegression().fit(X[:,0].reshape(n,1), X[:,1])

## Result & puzzle

print(model.predict([[2],[12],[13],[40],[90]]))

清单 4-2:一个逻辑回归模型

猜猜看:这段代码的输出结果是什么?

工作原理

训练数据X包含四条患者记录(行),每条记录有两列。第一列是患者吸烟的数量(输入特征),第二列是类别标签,表示他们是否最终患上了肺癌。

你通过调用LogisticRegression()构造函数来创建模型。你在该模型上调用fit()函数;fit()函数接受两个参数,分别是输入(吸烟量)和输出的类别标签(癌症)。fit()函数期望输入是一个二维数组格式,其中每一行代表一个训练数据样本,每一列代表该样本的一个特征。在此案例中,你只有一个特征值,因此你通过使用reshape()操作将一维输入转换为二维 NumPy 数组。reshape()的第一个参数指定行数,第二个参数指定列数。你只关心列数,这里是1。你将传递-1作为期望的行数,这意味着 NumPy 会自动决定行数。

经过调整形状后的输入训练数据如下所示(本质上,你只是去除了类别标签,保持了二维数组的形状):

[[0],

 [10],

 [60],

 [90]]

接下来,你需要根据患者吸烟的数量预测他们是否患有肺癌:输入的数据为 2、12、13、40、90 支香烟。输出结果如下:

# ['No' 'No' 'Yes' 'Yes' 'Yes']

模型预测前两位患者为肺癌阴性,而后面三位患者为肺癌阳性。

让我们详细看看 sigmoid 函数计算出的概率,看看是如何得出这个预测的!只需在清单 4-2 之后运行以下代码片段:

for i in range(20):

    print("x=" + str(i) + " --> " + str(model.predict_proba([[i]])))

predict_proba()函数接受香烟数量作为输入,并返回一个数组,其中包含肺癌阴性的概率(索引 0)和肺癌阳性的概率(索引 1)。当你运行此代码时,你应该得到如下输出:

x=0 --> [[0.67240789 0.32759211]]

x=1 --> [[0.65961501 0.34038499]]

x=2 --> [[0.64658514 0.35341486]]

x=3 --> [[0.63333374 0.36666626]]

x=4 --> [[0.61987758 0.38012242]]

x=5 --> [[0.60623463 0.39376537]]

x=6 --> [[0.59242397 0.40757603]]

x=7 --> [[0.57846573 0.42153427]]

x=8 --> [[0.56438097 0.43561903]]

x=9 --> [[0.55019154 0.44980846]]

x=10 --> [[0.53591997 0.46408003]]

x=11 --> [[0.52158933 0.47841067]]

x=12 --> [[0.50722306 0.49277694]]

x=13 --> [[0.49284485 0.50715515]]

x=14 --> [[0.47847846 0.52152154]]

x=15 --> [[0.46414759 0.53585241]]

x=16 --> [[0.44987569 0.55012431]]

x=17 --> [[0.43568582 0.56431418]]

x=18 --> [[0.42160051 0.57839949]]

x=19 --> [[0.40764163 0.59235837]]

如果肺癌为负的概率大于肺癌为正的概率,那么预测结果将是肺癌阴性。这发生在x=12时。如果患者吸烟超过 12 支香烟,算法将把他们分类为肺癌阳性

总结来说,你已经学会了如何使用 scikit-learn 库轻松地用逻辑回归进行问题分类。逻辑回归的思想是将一个 S 形曲线(即 sigmoid 函数)拟合到数据上。这个函数为每个新数据点和每个可能的类别分配一个介于 0 和 1 之间的数值。这个数值表示该数据点属于给定类别的概率。然而,在实际应用中,你通常会有训练数据,但没有为训练数据分配类别标签。例如,你有客户数据(比如他们的年龄和收入),但你不知道每个数据点的类别标签。为了从这种数据中提取有用的见解,接下来你将学习另一类机器学习:无监督学习。具体来说,你将学习如何找到相似的数据点聚类,这是无监督学习的一个重要子集。

K-均值聚类的一行代码

如果有一个聚类算法是你必须了解的,无论你是计算机科学家、数据科学家,还是机器学习专家,那就是K-均值算法。在本节中,你将学习其基本概念,并且通过一行 Python 代码来了解何时以及如何使用它。

基础知识

前面的章节讲解了有监督学习,在这种学习中,训练数据是标注过的。换句话说,你知道训练数据中每个输入值的输出值。但在实际应用中,情况并非总是如此。你常常会遇到未标注的数据,尤其是在许多数据分析应用中,这些数据无法明确说明“最优输出”是什么意思。在这种情况下,预测是不可能的(因为没有输出值可供参考),但你仍然可以从这些未标注的数据集中提取有用的知识(例如,找到相似的未标注数据聚类)。使用未标注数据的模型属于无监督学习的范畴。

例如,假设你在一家初创公司工作,该公司为不同目标市场提供服务,目标市场的收入水平和年龄各不相同。你的老板告诉你,找出最符合目标市场的若干目标人物。你可以使用聚类方法来识别公司服务的平均客户画像。图 4-10 展示了一个示例。

images

图 4-10:二维空间中观察到的客户数据

在这里,你可以轻松地识别出三种不同类型的画像,它们的收入和年龄各不相同。但如何通过算法来找到这些呢?这就是聚类算法的应用领域,比如广泛使用的 K-均值算法。给定数据集和一个整数k,K-均值算法会找到k个数据聚类,使得每个聚类的中心(称为质心)与该聚类中的数据点之间的差异最小。换句话说,你可以通过对数据集运行 K-均值算法来找到不同的目标画像,如图 4-11 所示。

images

图 4-11:具有客户画像(聚类中心)的客户数据在二维空间中的分布

聚类中心(黑点)与聚类后的客户数据相匹配。每个聚类中心可以视为一个客户画像。因此,你有三个理想化的画像:一个 20 岁的员工赚 $2000,一个 25 岁的员工赚 $3000,和一个 40 岁的员工赚 $4000。最棒的是,K-Means 算法甚至可以在高维空间中找到这些聚类中心(在这些空间中,人类很难通过视觉找到这些画像)。

K-Means 算法需要“聚类中心的数量 k”作为输入。在这种情况下,你观察数据并“神奇地”定义 k = 3。更高级的算法可以自动找到聚类中心的数量(例如,可以参考 Greg Hamerly 和 Charles Elkan 在 2004 年的论文《Learning the k in K-Means》)。

那么 K-Means 算法是如何工作的呢?简而言之,它执行以下步骤:

Initialize random cluster centers (centroids).

Repeat until convergence

    Assign every data point to its closest cluster center.

Recompute each cluster center as the centroid of all data points assigned to it.

这将导致多个循环迭代:首先,你将数据分配到 k 个聚类中心,然后重新计算每个聚类中心,作为分配给它的数据的质心。

让我们实现它吧!

考虑以下问题:给定二维薪资数据(工作小时数薪资收入),在给定数据集中找到两个工作时长相似且薪资相似的员工聚类。

代码

如何在一行代码中完成这一切?幸运的是,Python 中的 scikit-learn 库已经高效地实现了 K-Means 算法。清单 4-3 显示了运行 K-Means 聚类的一行代码。

## Dependencies

from sklearn.cluster import KMeans

import numpy as np

## Data (Work (h) / Salary ($))

X = np.array([[35, 7000], [45, 6900], [70, 7100],

              [20, 2000], [25, 2200], [15, 1800]])

## One-liner

kmeans = KMeans(n_clusters=2).fit(X)

## Result & puzzle

cc = kmeans.cluster_centers_

print(cc)

清单 4-3:一行代码实现 K-Means 聚类

这段代码的输出是什么?即使你不理解每个语法细节,也试着猜出一个解决方案。这将帮助你发现知识空白,并为大脑更好地吸收算法做准备。

原理

在前几行中,你从 sklearn.cluster 包中导入了 KMeans 模块。这个模块负责执行聚类操作。你还需要导入 NumPy 库,因为 KMeans 模块是基于 NumPy 数组工作的。

我们的数据是二维的。它将工作小时数与一些工人的薪资进行了关联。图 4-12 显示了这个员工数据集中的六个数据点。

images

图 4-12:员工薪资数据

目标是找到两个最适合这些数据的聚类中心:

## One-liner

kmeans = KMeans(n_clusters=2).fit(X)

在这行代码中,你创建了一个新的 KMeans 对象,负责为你处理算法。当你创建 KMeans 对象时,通过使用 n_clusters 函数参数来定义聚类中心的数量。然后你只需调用实例方法 fit(X) 来对输入数据 X 运行 K-Means 算法。此时,KMeans 对象将保存所有结果。剩下的就是从其属性中获取结果:

cc = kmeans.cluster_centers_

print(cc)

请注意,在 sklearn 包中,约定使用尾部下划线来表示一些属性名称(例如,cluster_centers_),以表明这些属性是在训练阶段动态生成的(即 fit() 函数)。在训练阶段之前,这些属性并不存在。这并不是 Python 的通用约定(尾部下划线通常仅用于避免与 Python 关键字的命名冲突——例如,使用 variable list_ 而不是 list)。然而,如果你习惯了这种方式,你会更欣赏 sklearn 包中属性的一致性。那么,聚类中心是什么?这段代码片段的输出是什么?请看 图 4-13。

images

图 4-13:员工薪资数据和二维空间中的聚类中心

你可以看到,两个聚类中心分别是 (20, 2000) 和 (50, 7000)。这也是 Python 一行代码的结果。这些聚类对应着两种理想化的员工角色:第一个每周工作 20 小时,月薪 2000 美元;而第二个每周工作 50 小时,月薪 7000 美元。这两种角色合理地符合了数据。因此,这段一行代码的结果如下:

## Result & puzzle

cc = kmeans.cluster_centers_

print(cc)

'''

[[  50\. 7000.]

 [  20\. 2000.]]

'''

总结一下,本节介绍了无监督学习中的一个重要子主题:聚类。K-Means 算法是一种简单、高效且广泛使用的方法,可以从多维数据中提取 k 个聚类。在背后,算法会反复计算聚类中心,并将每个数据值分配给其最近的聚类中心,直到找到最优的聚类。但聚类并不总是适合找到相似的数据项。许多数据集并不表现出聚类行为,但你仍然希望利用距离信息进行机器学习和预测。让我们继续留在多维空间,探索另一种利用(欧几里得)数据值距离的方法:K-最近邻算法。

K-最近邻算法一行代码实现

流行的 K-最近邻算法KNN)广泛应用于回归和分类任务,涉及推荐系统、图像分类和金融数据预测等多个领域。它是许多高级机器学习技术的基础(例如,信息检索中的应用)。毫无疑问,理解 KNN 是你掌握计算机科学教育中重要组成部分的基石。

基础知识

KNN 算法是一种稳健、直接且流行的机器学习方法。它易于实现,但仍然是一种具有竞争力且快速的机器学习技术。我们迄今讨论的所有其他机器学习模型都使用训练数据来计算原始数据的表示。你可以使用这种表示来预测、分类或聚类新数据。例如,线性回归和逻辑回归算法定义学习参数,而聚类算法则根据训练数据计算聚类中心。然而,KNN 算法不同。与其他方法相比,它不会计算新的模型(或表示),而是将整个数据集作为模型。

是的,你没看错。机器学习模型不过是一组观察结果。你的训练数据中的每一个实例都是你模型的一部分。这有优点也有缺点。一个缺点是,随着训练数据的增长,模型可能迅速膨胀——这可能需要在预处理步骤中进行采样或过滤。然而,一个很大的优点是训练阶段的简单性(只需将新数据值添加到模型中)。此外,你还可以使用 KNN 算法进行预测或分类。给定输入向量x,你执行以下策略:

  1. 找到xk个最近邻(根据预定义的距离度量)。

  2. k个最近邻聚合成一个单一的预测或分类值。你可以使用任何聚合函数,比如平均值、均值、最大值或最小值。

让我们通过一个例子来说明。你的公司为客户销售房屋,已收集了大量客户和房价数据库(参见图 4-14)。有一天,客户问你 52 平方米的房子大概多少钱。你查询 KNN 模型,它立即给出答案 $33,167。事实上,客户在同一周就找到了一套价格为 $33,489 的房子。KNN 系统是如何做出这个惊人准确的预测的?

首先,KNN 系统简单地使用欧几里得距离计算查询 D = 52 平方米k = 3 个最近邻。三个最近邻分别是 A、B 和 C,价格分别为 $34,000、$33,500 和 $32,000。然后,它通过计算这些值的简单平均值来聚合这三个最近邻。由于本例中 k = 3,你将模型表示为3NN。当然,你可以改变相似度函数、参数k以及聚合方法,以便得出更复杂的预测模型。

images

图 4-14:根据三个最近邻 A、B 和 C 计算房子 D 的价格

KNN 的另一个优点是,当有新观察数据时,它可以轻松适应。这对于大多数机器学习模型来说并不常见。在这方面的明显弱点是,随着你增加更多的数据点,寻找 k 个最近邻的计算复杂度会越来越高。为了适应这一点,你可以不断从模型中移除过时的值。

如我之前提到的,你也可以使用 KNN 解决分类问题。你可以使用投票机制:每个最近邻对它的类别进行投票,得票最多的类别获胜,而不是对 k 个最近邻的平均值进行计算。

代码

让我们深入了解如何在 Python 中使用 KNN——用一行代码来实现(见清单 4-4)。

## Dependencies

from sklearn.neighbors import KNeighborsRegressor

import numpy as np

## Data (House Size (square meters) / House Price ($))

X = np.array([[35, 30000], [45, 45000], [40, 50000],

              [35, 35000], [25, 32500], [40, 40000]])

## One-liner

KNN = KNeighborsRegressor(n_neighbors=3).fit(X[:,0].reshape(-1,1), X[:,1])

## Result & puzzle

res = KNN.predict([[30]])

print(res)

清单 4-4:在一行 Python 代码中运行 KNN 算法

猜猜看:这段代码的输出是什么?

工作原理

为了帮助你查看结果,让我们在图 4-15 中绘制来自这段代码的住房数据。

images

图 4-15:二维空间中的住房数据

你能看到一般趋势吗?随着房屋面积的增加,你可以预期它的市场价格会呈线性增长。将面积翻倍,价格也会翻倍。

在代码中(见清单 4-4),客户端请求你预测一栋 30 平方米房屋的价格。使用 k = 3 的 KNN(简称 3NN)预测结果如何?看看图 4-16。

漂亮吧?KNN 算法找到三个与房屋大小最接近的房子,并将预测的房价作为 k=3 个最近邻的平均值。因此,结果是 $32,500。

如果你对这行代码中的数据转换感到困惑,让我快速解释一下这里发生了什么:

KNN = KNeighborsRegressor(n_neighbors=3).fit(X[:,0].reshape(-1,1), X[:,1])

images

图 4-16:二维空间中的住房数据,使用 KNN 预测新数据点(房屋面积为 30 平方米)的房价

首先,你创建一个新的机器学习模型,叫做 KNeighborsRegressor。如果你想用 KNN 解决分类问题,则使用 KNeighborsClassifier

其次,你通过使用 fit() 函数并传入两个参数来训练模型。第一个参数定义输入(房屋大小),第二个参数定义输出(房屋价格)。这两个参数的形状必须是类似数组的数据结构。例如,要使用 30 作为输入,你必须将其传递为 [30]。原因是,通常情况下,输入可以是多维的,而不仅仅是一维的。因此,你需要重塑输入:

print(X[:,0])

"[35 45 40 35 25 40]"

print(X[:,0].reshape(-1,1))

"""

[[35]

 [45]

 [40]

 [35]

 [25]

 [40]]

"""

请注意,如果你将这个 1D 的 NumPy 数组作为 fit() 函数的输入,函数将无法工作,因为它期望的是一个类似数组的观察数据结构,而不是一个整数数组。

总结一下,这行代码教你如何在一行代码中创建第一个 KNN 回归器。如果你有大量变化的数据和模型更新,KNN 是你最好的朋友!接下来,我们将讨论目前非常流行的一个机器学习模型:神经网络。

一行代码中的神经网络分析

神经网络在近年来获得了广泛的流行。这部分归功于该领域的算法和学习技术的改进,但也得益于硬件的提升和通用 GPU(GPGPU)技术的兴起。在这一部分,你将学习到多层感知器MLP),它是最流行的神经网络表示之一。阅读完这部分后,你将能够用一行 Python 代码编写自己的神经网络!

基础知识

对于这一行代码,我准备了一个与我的 Python 同事在电子邮件列表上共同参与的数据集。我的目标是创建一个有相关性的真实世界数据集,所以我请我的电子邮件订阅者参与了这章的数据生成实验。

数据

如果你正在阅读这本书,你一定有学习 Python 的兴趣。为了创建一个有趣的数据集,我请我的电子邮件订阅者就他们的 Python 专业知识和收入回答了六个匿名问题。这些问题的回答将作为简单神经网络示例的训练数据(作为 Python 一行代码)。

训练数据基于以下六个问题的答案:

  • 过去七天里,你一共看了多少小时的 Python 代码?

  • 你从几年前开始学习计算机科学的?

  • 你书架上有多少本编程书?

  • 你在 Python 时间中有多少百分比是用于处理真实世界的项目?

  • 你每月通过销售技术技能(广义上的技术)赚取多少收入(四舍五入到 1000 美元)?

  • 你大约的 Finxter 评分是多少,四舍五入到 100 分?

前五个问题将作为你的输入,第六个问题将作为神经网络分析的输出。在这一行代码部分,你将分析神经网络回归。换句话说,你根据数字输入特征预测一个数值(你的 Python 技能)。本书中我们不会探讨神经网络分类,这是神经网络的另一个重要优势。

第六个问题大致反映了 Python 编程者的技能水平。Finxter(https://finxter.com/)是我们的基于谜题的学习应用,它根据 Python 编程者在解决 Python 谜题中的表现,分配一个评分值给每个 Python 编码者。通过这种方式,它帮助你量化自己的 Python 技能水平。

让我们从可视化每个问题如何影响输出(即 Python 开发者的技能评分)开始,如图 4-17 所示。

images

图 4-17:问卷答案与 Python 技能评分之间的关系

请注意,这些图表仅展示了每个单独特征(问题)如何影响最终的 Finxter 评分,但它们并没有告诉你两个或更多特征组合的影响。此外,还要注意,一些 Python 爱好者并没有回答所有六个问题;在这些情况下,我使用了虚拟值 -1

什么是人工神经网络?

创建人脑(生物神经网络)理论模型的想法在近几十年得到了广泛研究。但人工神经网络的基础理论早在 1940 年代和 1950 年代就已经提出!从那时起,人工神经网络的概念不断被改进和完善。

基本思路是将学习和推理的大任务分解为多个微任务。这些微任务不是独立的,而是相互依赖的。大脑由数十亿个神经元组成,这些神经元通过数万亿个突触连接。在简化模型中,学习仅仅是调整突触的强度(在人工神经网络中也称为权重参数)。那么,如何在模型中“创建”一个新的突触呢?很简单——你将其权重从零增加到非零值。

图 4-18 展示了一个基本的神经网络,包含三个层次(输入层、隐藏层、输出层)。每一层由多个神经元组成,神经元从输入层通过隐藏层连接到输出层。

images

图 4-18:一种用于动物分类的简单神经网络分析

在这个例子中,神经网络被训练用来检测图像中的动物。实际上,你会为图像的每个像素使用一个输入神经元作为输入层。这可能导致数百万个输入神经元与数百万个隐藏神经元相连接。通常,每个输出神经元负责整个输出的一位。例如,要检测两种不同的动物(例如,猫和狗),你只会在输出层使用一个神经元来表示两种不同的状态(0=猫1=狗)。

这个思想是,每个神经元在接收到某个输入脉冲时可以被激活,或者“发射”。每个神经元根据输入脉冲的强度独立决定是否发射。通过这种方式,你模拟了人脑中神经元通过脉冲相互激活的过程。输入神经元的激活通过网络传播,直到到达输出神经元。一些输出神经元会被激活,而其他则不会。输出神经元的发射模式形成了你人工神经网络的最终输出(或预测)。在你的模型中,激活的输出神经元可以表示 1,而未激活的输出神经元可以表示 0。通过这种方式,你可以训练你的神经网络来预测任何可以用一系列 0 和 1 表示的事物(即计算机能表示的任何事物)。

让我们详细了解神经元如何在数学上工作,参见图 4-19。

images

图 4-19:单个神经元的数学模型:输出是三个输入的函数。

每个神经元都与其他神经元相连,但并非所有连接都相等。相反,每个连接都有一个相关的权重。严格来说,一个激活的神经元会向外部邻居传播一个值为 1 的脉冲,而一个非激活的神经元则传播一个值为 0 的脉冲。你可以将权重视为表示激活输入神经元的脉冲通过连接传递给目标神经元的程度。数学上,你将脉冲乘以连接的权重来计算下一个神经元的输入。在我们的示例中,神经元简单地对所有输入进行求和来计算其自身的输出。这就是激活函数,它描述了神经元的输入如何生成输出。在我们的示例中,如果相关的输入神经元也被激活,那么神经元激活的可能性就会更高。这就是脉冲如何在神经网络中传播的方式。

学习算法是做什么的?它使用训练数据来选择神经网络的权重 w。给定一个训练输入值 x,不同的权重 w 会导致不同的输出。因此,学习算法会逐步调整权重 w——在多次迭代中——直到输出层产生与训练数据相似的结果。换句话说,训练算法逐渐减少正确预测训练数据的误差。

网络结构、训练算法和激活函数有很多种。本章将向你展示如何在一行代码中使用神经网络的实用方法。然后,你可以根据需要深入学习更详细的内容(例如,你可以从阅读维基百科上的“神经网络”条目开始,https://en.wikipedia.org/wiki/Neural_network)。

代码

目标是创建一个神经网络,通过使用五个输入特征(对问题的回答)来预测 Python 技能水平(Finxter 评分):

在过去七天里,你接触 Python 代码的小时数是多少?

你是什么时候开始学习计算机科学的?

书籍 你的书架上有多少本编程书?

项目 你花费多少百分比的 Python 时间来实现现实世界的项目?

收入 你每月通过销售你的技术技能(广义上讲)赚多少钱(以 $1000 为单位)?

再次让我们站在巨人的肩膀上,使用 scikit-learn (sklearn) 库进行神经网络回归,就像在清单 4-5 中一样。

## Dependencies

from sklearn.neural_network import MLPRegressor

import numpy as np

## Questionaire data (WEEK, YEARS, BOOKS, PROJECTS, EARN, RATING)

X = np.array(

    [[20,  11,  20,  30,  4000,  3000], 

     [12,   4,   0,   0, 1000,  1500],

     [2,   0,   1,  10,   0,  1400],

     [35,   5,  10,  70,  6000,  3800],

     [30,   1,   4,  65,   0,  3900],

     [35,   1,   0,   0,   0, 100],

     [15,   1,   2,  25,   0,  3700],

     [40,   3,  -1,  60,  1000,  2000],

     [40,   1,   2,  95,   0,  1000],

     [10,   0,   0,   0,   0,  1400],

     [30,   1,   0,  50,   0,  1700],

     [1,   0,   0,  45,   0,  1762],

     [10,  32,  10,   5,   0,  2400],

     [5,  35,   4,   0, 13000,  3900],

     [8,   9,  40,  30,  1000,  2625],

     [1,   0,   1,   0,   0,  1900],

     [1,  30,  10,   0,  1000,  1900],

     [7,  16,   5,   0,   0,  3000]])

## One-liner

neural_net = MLPRegressor(max_iter=10000).fit(X[:,:-1], X[:,-1])

## Result

res = neural_net.predict([[0, 0, 0, 0, 0]])

print(res)

清单 4-5:用一行代码进行神经网络分析

人类是不可能正确地计算出输出的——但你想试试吗?

工作原理

在前几行,你创建了数据集。scikit-learn 库中的机器学习算法使用类似的输入格式。每一行是一个单独的观测值,包含多个特征。行数越多,训练数据就越多;列数越多,每个观测值的特征就越多。在这种情况下,你的输入有五个特征,每个训练数据的输出只有一个特征。

这个一行代码通过使用MLPRegressor类的构造函数创建了一个神经网络。我传递了max_iter=10000作为参数,因为在使用默认的迭代次数(max_iter=200)时,训练无法收敛。

之后,你调用fit()函数,它决定了神经网络的参数。调用fit()后,神经网络已经成功初始化。fit()函数接收一个多维输入数组(每行一个观测值,每列一个特征)和一个一维输出数组(大小 = 观测值的数量)。

剩下的就是在一些输入值上调用预测函数:

## Result

res = neural_net.predict([[0, 0, 0, 0, 0]])

print(res)

# [94.94925927]

请注意,由于该函数的非确定性特性以及不同的收敛行为,实际输出可能会略有不同。

用简单的英语来说:如果 . . .

  • . . . 你在过去一周没有进行任何训练,

  • . . . 你从零年前开始学习计算机科学,

  • . . . 你书架上没有任何 Python 编程书籍,

  • . . . 你把 0%的时间用来实现真正的 Python 项目,并且

  • . . . 你通过出售编程技能赚取了$0,

神经网络估计你的技能水平是非常低(Finxter 评分 94 意味着你在理解 Python 程序print("hello, world")时有困难)。

那么让我们来改变这一点:如果你每周投入 20 小时进行学习,并在一周后重新审视神经网络,会发生什么呢:

## Result

res = neural_net.predict([[20, 0, 0, 0, 0]])

print(res)

# [440.40167562]

不错——你的技能进步了不少!但你对这个评分还是不满意吧?(一个高于平均水平的 Python 程序员在 Finxter 上的评分至少是 1500–1700。)

没问题。买 10 本 Python 书(这本买完后就剩下 9 本)。让我们看看你的评分会发生什么变化:

## Result

res = neural_net.predict([[20, 0, 10, 0, 0]])

print(res)

# [953.6317602]

再次,你取得了显著进展,并且将你的评分翻倍!但仅仅买 Python 书籍并不会帮到你太多。你需要学习它们!我们来做一年:

## Result

res = neural_net.predict([[20, 1, 10, 0, 0]])

print(res)

# [999.94308353]

并没有发生太多事情。这是我对神经网络不太信任的地方。在我看来,你本应该达到更好的表现,至少 1500 的评分。但这也说明了,神经网络的表现仅能依赖于其训练数据。你有的数据非常有限,神经网络无法突破这个限制:数据点太少,知识量太少。

但你不会放弃,对吧?接下来,你将 50%的 Python 时间用来作为 Python 自由职业者出售你的技能:

## Result

res = neural_net.predict([[20, 1, 10, 50, 1000]])

print(res)

# [1960.7595547]

哇!突然间,神经网络认为你是一个专家级的 Python 编程者。的确,神经网络做出了明智的预测!学习 Python 至少一年并做一些实际项目,你就会成为一个优秀的程序员。

总结一下,你已经了解了神经网络的基础知识,以及如何通过一行 Python 代码使用它们。有趣的是,问卷调查数据显示,开始时做一些实际项目——甚至从一开始就做自由职业项目——对你的学习成功非常重要。神经网络显然知道这一点。如果你想了解我成为自由职业者的确切策略,可以参加https://blog.finxter.com/webinar-freelancer/的免费网络研讨会。

在下一节中,你将更深入地了解另一种强大的模型表示方法:决策树。虽然神经网络的训练可能非常昂贵(它们通常需要多台机器和很多小时,有时甚至几周的时间来训练),但决策树则相对轻量。然而,它们是从训练数据中提取模式的快速而有效的方式。

一行代码实现决策树学习

决策树是机器学习工具箱中强大而直观的工具。决策树的一个大优点是,与许多其他机器学习技术不同,它们是人类可读的。你可以轻松地训练一个决策树并展示给你的主管,他们无需了解机器学习的任何内容就能理解你的模型是如何工作的。这对于经常需要为自己的结果向管理层辩护和展示的资料科学家来说尤为重要。在本节中,我将向你展示如何通过一行 Python 代码使用决策树。

基础知识

与许多机器学习算法不同,决策树背后的思想可能源于你自己的经验。它们代表了一种有结构的决策方式。每一个决策都会打开新的分支。通过回答一系列问题,最终你会得出推荐的结果。图 4-20 展示了一个例子。

images

图 4-20:一个简化的决策树,用于推荐学习科目

决策树用于分类问题,比如“根据我的兴趣,我应该学习哪个科目?”你从顶部开始,然后反复回答问题并选择最能描述你特征的选项。最终,你会到达树的叶节点,一个没有子节点的节点。这就是基于你选择的特征推荐的类别。

决策树学习有许多微妙之处。在前面的示例中,第一个问题比最后一个问题更重要。如果您喜欢数学,决策树将永远不会推荐艺术或语言学。这很有用,因为某些特征对分类决策可能比其他特征重要得多。例如,一个预测您当前健康状况的分类系统可能使用您的性别(特征)来实际上排除许多疾病(类别)。

因此,决策节点的顺序有助于性能优化:将对最终分类影响较大的特征置于顶部。在决策树学习中,您将聚合对最终分类影响较小的问题,如图 4-21 所示。

图像

图 4-21:修剪提升了决策树学习的效率。

假设完整的决策树看起来像左侧的树。对于任何特征组合,都有单独的分类结果(树叶)。然而,某些特征可能不会为分类问题提供任何额外信息(例如,示例中的第一个语言决策节点)。出于效率原因,决策树学习会有效地去除这些节点,这个过程称为修剪

代码

您可以用一行 Python 代码创建自己的决策树。清单 4-6 展示了具体操作方法。

## Dependencies

from sklearn import tree

import numpy as np

## Data: student scores in (math, language, creativity) --> study field

X = np.array([[9, 5, 6, "computer science"],

              [1, 8, 1, "linguistics"],

              [5, 7, 9, "art"]])

## One-liner

Tree = tree.DecisionTreeClassifier().fit(X[:,:-1], X[:,-1])

## Result & puzzle

student_0 = Tree.predict([[8, 6, 5]])

print(student_0)

student_1 = Tree.predict([[3, 7, 9]])

print(student_1)

清单 4-6:单行代码中的决策树分类

猜测这段代码的输出!

工作原理

此代码中的数据描述了三名学生在数学、语言和创造力三个领域的估计技能水平(从 1 到 10 的评分)。您还知道这些学生的研究课题。例如,第一个学生在数学方面技能高超,学习计算机科学。第二个学生在语言方面的技能远远超过其他两个技能,学习语言学。第三个学生在创造力方面技能高超,学习艺术。

一行代码创建一个新的决策树对象,并使用fit()函数在标记的训练数据上训练模型(最后一列是标签)。在内部,它创建三个节点,分别为数学、语言和创造力特征。当预测student_0的类别(数学 = 8,语言 = 6,创造力 = 5)时,决策树返回计算机科学。它学习到这种特征模式(高、中、中)是第一类的指标。另一方面,当要求(3, 7, 9)时,决策树预测艺术,因为它学习到分数(低、中、高)暗示第三类。

请注意,该算法是非确定性的。换句话说,当两次执行相同的代码时,可能会得到不同的结果。这在使用随机生成器的机器学习算法中是常见的情况。在这种情况下,特征的顺序是随机组织的,因此最终的决策树可能会有不同的特征顺序。

总结一下,决策树是创建可供人类阅读的机器学习模型的一种直观方式。每个分支表示基于新样本的某个特征作出的选择。树的叶子表示最终的预测(分类或回归)。接下来,我们将暂时离开具体的机器学习算法,探讨机器学习中的一个关键概念:方差。

在一行中获取最小方差的行

你可能听说过大数据中的 Vs:体量(volume)、速度(velocity)、多样性(variety)、真实性(veracity)和价值(value)。方差是另一个重要的 V:它衡量数据从其均值的期望(平方)偏差。实际上,方差是一个重要的度量,在金融服务、天气预测和图像处理等领域有着相关的应用。

基础知识

方差衡量数据在一维或多维空间中围绕其平均值的分布情况。稍后你会看到一个图形示例。事实上,方差是机器学习中最重要的特性之一。它以概括的方式捕捉数据的模式——而机器学习的核心就是模式识别。

许多机器学习算法依赖于方差的某种形式。例如,偏差-方差权衡是机器学习中的一个著名问题:复杂的机器学习模型可能会导致数据过拟合(高方差),但能够非常准确地表示训练数据(低偏差)。另一方面,简单模型通常能很好地进行泛化(低方差),但无法准确表示数据(高偏差)。

那么,方差究竟是什么呢?它是一个简单的统计特性,衡量数据集从其均值的扩展程度。图 4-22 展示了一个例子,绘制了两个数据集:一个具有低方差,另一个具有高方差。

images

图 4-22:两家公司股票价格的方差比较

这个例子展示了两家公司的股票价格。科技初创公司的股票价格围绕其平均值大幅波动。食品公司的股票价格则非常稳定,围绕平均值的波动很小。换句话说,科技初创公司具有较高的方差,而食品公司具有较低的方差。

在数学术语中,你可以通过以下公式计算一组数值X的方差var(X)

images

该值imagesX数据的平均值。

代码

随着年龄的增长,许多投资者希望降低其投资组合的整体风险。根据主流的投资哲学,应该将方差较小的股票视为风险较低的投资工具。粗略来说,投资于稳定、可预测的大公司要比投资小型科技创业公司损失更少。

Listing 4-7 中的单行代码的目标是找出投资组合中方差最小的股票。通过向该股票投资更多资金,你可以期望你的投资组合的整体方差会更低。

## Dependencies

import numpy as np

## Data (rows: stocks / cols: stock prices)

X = np.array([[25,27,29,30],

              [1,5,3,2],

              [12,11,8,3],

              [1,1,2,2],

              [2,6,2,2]])

## One-liner

# Find the stock with smallest variance

min_row = min([(i,np.var(X[i,:])) for i in range(len(X))], key=lambda x: x[1])

## Result & puzzle

print("Row with minimum variance: " + str(min_row[0]))

print("Variance: " + str(min_row[1]))

Listing 4-7:在一行代码中计算最小方差

这段代码的输出是什么?

它是如何工作的

和往常一样,你首先定义你想要在其上运行单行代码的数据(见 Listing 4-7 顶部)。NumPy 数组 X 包含五行(每行代表投资组合中的一只股票),每行有四个值(股票价格)。

目标是找到方差最小的股票的 ID 和方差。因此,单行代码的最外层函数是 min() 函数。你在元组序列 (a,b) 上执行 min() 函数,其中第一个元组值 a 是行索引(股票索引),第二个元组值 b 是该行的方差。

你可能会问:一序列元组的最小值是什么?当然,在使用之前,你需要正确定义此操作。为此,你使用 min() 函数的 key 参数。key 参数接受一个函数,该函数根据序列值返回一个可比较的对象值。再次强调,我们的序列值是元组,你需要找到方差最小的元组(即第二个元组值)。由于方差是第二个值,因此你将返回 x[1] 作为比较的基础。换句话说,方差最小的第二个元组值所在的元组获胜。

让我们来看一下如何创建元组值的序列。你可以使用列表推导式为每个行索引(股票)创建一个元组。第一个元组元素只是行索引 i。第二个元组元素是这一行的方差。你使用 NumPy 的 var() 函数结合切片来计算行的方差。

因此,单行代码的结果如下:

"""

Row with minimum variance: 3

Variance: 0.25

"""

我想补充一下,这个问题还有一种替代的解决方法。如果这不是一本关于 Python 单行代码的书,我更倾向于以下的解决方案,而不是单行代码:

var = np.var(X, axis=1)

min_row = (np.where(var==min(var)), min(var))

在第一行,你计算 NumPy 数组 X 沿列(axis=1)的方差。在第二行,你创建元组。第一个元组值是方差数组中的最小值索引。第二个元组值是方差数组中的最小值。注意,可能有多个行具有相同的(最小)方差。

这个解决方案更具可读性。所以很明显,简洁性和可读性之间存在权衡。仅仅因为你可以把所有内容挤进一行代码并不意味着你应该这么做。在所有条件相同的情况下,编写简洁可读的代码远比将代码搞得冗长、包含不必要的定义、注释或中间步骤要好。

在本节学习了方差的基础知识后,你现在准备好吸收如何计算基础统计数据。

一行代码实现基本统计

作为数据科学家和机器学习工程师,你需要掌握基础统计学。一些机器学习算法完全基于统计学(例如,贝叶斯网络)。

例如,从矩阵中提取基本统计数据(如平均值、方差和标准差)是分析各种数据集(如金融数据、健康数据或社交媒体数据)的关键组成部分。随着机器学习和数据科学的兴起,了解如何使用 NumPy —— 它是 Python 数据科学、统计学和线性代数的核心 —— 将变得越来越有价值。

在这行代码中,你将学习如何使用 NumPy 计算基本统计数据。

基础知识

本节解释了如何沿着轴计算平均值、标准差和方差。这三个计算非常相似;如果你理解了其中一个,你就能理解所有的。

你想要实现的目标是:给定一个包含股票数据的 NumPy 数组,行表示不同的公司,列表示它们的每日股票价格,目标是找到每个公司股票价格的平均值和标准差(见图 4-23)。

images

图 4-23:沿轴 1 计算平均值和方差

这个例子展示了一个二维的 NumPy 数组,但在实际中,数组可能具有更高的维度。

简单平均值、方差、标准差

在研究如何在 NumPy 中完成这些操作之前,让我们逐步建立你需要知道的背景知识。假设你想计算 NumPy 数组中所有值的简单平均值、方差或标准差。你已经在本章中看过了平均值和方差函数的例子。标准差就是方差的平方根。你可以通过以下函数轻松实现这一点:

import numpy as np

X = np.array([[1, 3, 5],

              [1, 1, 1],

              [0, 2, 4]])

print(np.average(X))

# 2.0

print(np.var(X))

# 2.4444444444444446

print(np.std(X))

# 1.5634719199411433

你可能已经注意到你在二维 NumPy 数组 X 上应用了这些函数。但 NumPy 会简单地将数组展平,并在展平后的数组上计算这些函数。例如,展平后的 NumPy 数组 X 的简单平均值计算如下:

(1 + 3 + 5 + 1 + 1 + 1 + 0 + 2 + 4) / 9 = 18 / 9 = 2.0

沿轴计算平均值、方差、标准差

然而,有时你可能希望沿某个轴计算这些函数。你可以通过将axis关键字作为参数传递给平均值、方差和标准差函数来实现这一点(参见第三章了解axis参数的详细介绍)。

代码

列表 4-8 展示了如何沿着某一轴计算平均值、方差和标准差。我们的目标是计算一个二维矩阵中所有股票的平均值、方差和标准差,其中行表示股票,列表示每日价格。

## Dependencies

import numpy as np

## Stock Price Data: 5 companies

# (row=[price_day_1, price_day_2, ...])

x = np.array([[8, 9, 11, 12],

              [1, 2, 2, 1], 

              [2, 8, 9, 9],

              [9, 6, 6, 3],

              [3, 3, 3, 3]])

## One-liner

avg, var, std = np.average(x, axis=1), np.var(x, axis=1), np.std(x, axis=1)

## Result & puzzle

print("Averages: " + str(avg))

print("Variances: " + str(var))

print("Standard Deviations: " + str(std))

列表 4-8:沿某轴计算基本统计数据

猜猜这个难题的输出是什么!

工作原理

这个一行代码使用了axis关键字来指定沿哪个轴计算平均值、方差和标准差。例如,如果你沿着axis=1执行这三个函数,则每一行会被聚合为一个单一的值。因此,结果的 NumPy 数组会减少一个维度。

这个难题的结果如下:

"""

Averages: [10.   1.5  7.   6.   3\. ]

Variances: [2.5  0.25 8.5  4.5  0.  ]

Standard Deviations: [1.58113883 0.5   2.91547595 2.12132034 0.   ]

"""

在继续下一个一行代码之前,我想展示如何用相同的思路处理一个更高维度的 NumPy 数组。

在对高维度 NumPy 数组进行轴向平均时,你总是会在axis参数中定义的轴上进行汇总。这里是一个示例:

import numpy as np

x = np.array([[[1,2], [1,1]],

              [[1,1], [2,1]],

              [[1,0], [0,0]]])

print(np.average(x, axis=2))

print(np.var(x, axis=2))

print(np.std(x, axis=2))

"""

[[1.5 1\. ]

 [1.  1.5]

 [0.5 0\. ]]

[[0.25 0.  ]

 [0.   0.25]

 [0.25 0.  ]]

[[0.5 0\. ]

 [0.  0.5]

 [0.5 0\. ]]

"""

有三个例子展示了如何沿着轴 2 计算平均值、方差和标准差(参见第三章;最内层轴)。换句话说,轴 2 上的所有值将合并为一个单一值,结果是轴 2 从结果数组中消失。深入这三个例子,弄清楚轴 2 是如何被压缩成一个平均值、方差或标准差的。

总结来说,广泛的数据集(包括金融数据、健康数据和社交媒体数据)要求你能够从数据集中提取基本的见解。本节内容让你更深入地理解如何利用强大的 NumPy 工具集,从多维数组中快速高效地提取基本统计数据。这是许多机器学习算法所需的基本预处理步骤。

使用支持向量机进行分类,仅一行代码

支持向量机SVMs)在近年来广受欢迎,因为它们在高维空间中仍然具有强大的分类性能。令人惊讶的是,SVM 即使在特征数(维度)大于数据项的情况下也能工作。这对分类算法来说是非常不同寻常的,因为存在维度灾难:随着维度的增加,数据变得非常稀疏,这使得算法很难从数据集中发现模式。理解 SVM 的基本概念是成为一名成熟的机器学习工程师的基础步骤。

基础知识

分类算法是如何工作的?它们使用训练数据找到一个决策边界,将一个类别的数据与另一个类别的数据分开(在第 89 页的“Logistic 回归一行代码”中,决策边界是 sigmoid 函数的概率是否高于或低于 0.5 的阈值)。

分类的高级视角

图 4-24 展示了一个通用分类器的例子。

images

图 4-24:计算机科学家和艺术家的多样化技能组合

假设你想为有志的大学生建立一个推荐系统。图示展示了训练数据,数据由根据逻辑和创造力两个领域的技能对用户进行分类。一些人具有较高的逻辑能力,而创造力相对较低;另一些人则具有较高的创造力,而逻辑能力相对较低。第一组被标记为计算机科学家,第二组被标记为艺术家

为了分类新用户,机器学习模型必须找到一个决策边界,将计算机科学家和艺术家区分开。大致来说,你将根据用户在决策边界的哪一侧进行分类。在这个例子中,你会将落在左侧区域的用户分类为计算机科学家,将落在右侧区域的用户分类为艺术家。

在二维空间中,决策边界要么是直线,要么是(更高阶的)曲线。前者称为线性分类器,后者称为非线性分类器。在这一节中,我们只讨论线性分类器。

图 4-24 展示了三个有效的数据分隔决策边界。在我们的例子中,无法量化哪个决策边界更好;它们在分类训练数据时都能达到完美的准确度。

但最佳决策边界是什么?

支持向量机为这个问题提供了一个独特而美妙的答案。可以说,最佳的决策边界提供了最大的安全边距。换句话说,SVM 最大化最接近数据点与决策边界之间的距离。目标是最小化新数据点接近决策边界时的误差。

图 4-25 展示了一个例子。

images

图 4-25:支持向量机最大化边距误差。

SVM 分类器找到相应的支持向量,使得支持向量之间的区域尽可能厚实。在这里,支持向量是位于与决策边界平行的两条虚线上的数据点。这些线被称为边际。决策边界是位于两条边际之间的线,且与边际的距离最大。由于最大化了边际和决策边界之间的区域,因此在分类新数据点时,误差边际预计将是最大的。这一思想对于许多实际问题展示了高分类准确性。

代码

是否可以用一行 Python 代码创建你自己的 SVM?查看列表 4-9。

## Dependencies

from sklearn import svm

import numpy as np

## Data: student scores in (math, language, creativity) --> study field

X = np.array([[9, 5, 6, "computer science"],

              [10, 1, 2, "computer science"],

              [1, 8, 1, "literature"],

              [4, 9, 3, "literature"],

              [0, 1, 10, "art"],

              [5, 7, 9, "art"]])

## One-liner

svm = svm.SVC().fit(X[:,:-1], X[:,-1])

## Result & puzzle

student_0 = svm.predict([[3, 3, 6]])

print(student_0)

student_1 = svm.predict([[8, 1, 1]])

print(student_1)

列表 4-9:单行代码实现 SVM 分类

猜猜这段代码的输出是什么。

它是如何工作的

这段代码展示了如何在 Python 中以最基本的形式使用支持向量机。NumPy 数组保存了带标签的训练数据,每行代表一个用户,每列代表一个特征(数学、语言和创造力的技能水平)。最后一列是标签(类别)。

因为你有三维数据,支持向量机通过使用二维平面(线性分隔符)来分离数据,而不是使用一维的直线。如你所见,支持向量机不仅能分离两个类别,也能分离三个类别,如前面的例子所示。

这行代码本身很简洁:你首先通过使用 svm.SVC 类的构造函数来创建模型(SVC 代表 支持向量分类)。然后,调用 fit() 函数基于已标记的训练数据进行训练。

在代码片段的结果部分,你对新观察结果调用 predict() 函数。因为 student_0 的技能被标示为数学=3,语言=3,创造力=6,支持向量机预测该学生的标签是艺术。类似地,student_1 的技能被标示为数学=8,语言=1,创造力=1,因此支持向量机预测该学生的标签是计算机科学

这是该单行代码的最终输出:

## Result & puzzle

student_0 = svm.predict([[3, 3, 6]])

print(student_0)

# ['art']

student_1 = svm.predict([[8, 1, 1]])

print(student_1)

## ['computer science']

总结来说,SVM 即使在高维空间中也表现良好,尤其当特征数多于训练数据向量时。最大化安全边际的思想直观且有助于在分类边界案例时实现稳健的性能——即,位于安全边际内的向量。在本章的最后一部分,我们将后退一步,看看一个用于分类的元算法:随机森林的集成学习。

使用随机森林进行分类的单行代码

让我们继续讨论一种令人兴奋的机器学习技术:集成学习。如果你的预测准确率不足,但你需要在最后时刻完成工作,下面是我的快速建议:尝试这种元学习方法,结合多个机器学习算法的预测(或分类)。在许多情况下,它将为你提供更好的最后时刻结果。

基础知识

在前面的章节中,你已经学习了多个机器学习算法,可以用来快速获取结果。然而,不同的算法有不同的优势。例如,神经网络分类器可以为复杂问题生成出色的结果。然而,由于它们强大的记忆能力,能够记住数据中的细粒度模式,因此也容易发生过拟合。集成学习在分类问题中部分解决了你常常无法预先知道哪种机器学习技术最有效的问题。

这是如何工作的?你创建了一个由多种类型或实例的基本机器学习算法组成的元分类器。换句话说,你训练了多个模型。为了对单个观察值进行分类,你让所有模型独立地对输入进行分类。接下来,你返回根据输入返回次数最多的类别,作为元预测。这就是集成学习算法的最终输出。

随机森林是一种特殊类型的集成学习算法。它们专注于决策树学习。一个森林由许多树组成。同样,随机森林由许多决策树组成。每棵决策树通过在训练阶段树生成过程中注入随机性来构建(例如,选择哪个树节点作为第一个)。这导致了不同的决策树——这正是你所需要的。

图 4-26 显示了如何使用以下场景来进行训练后的随机森林预测。Alice 具有较高的数学和语言技能。集成学习由三棵决策树组成(构建随机森林)。为了对 Alice 进行分类,每棵决策树都会询问 Alice 的分类结果。两棵决策树将 Alice 分类为计算机科学家。由于这是获得最多票数的类别,它将作为分类的最终输出返回。

图像

图 4-26:随机森林分类器聚合三棵决策树的输出

代码

让我们坚持这个例子,根据学生在三个领域(数学、语言、创造力)的技能水平来分类学习领域。你可能认为在 Python 中实现集成学习方法很复杂。但实际上并不复杂,得益于功能强大的 scikit-learn 库(参见 列表 4-10)。

## Dependencies

import numpy as np

from sklearn.ensemble import RandomForestClassifier

## Data: student scores in (math, language, creativity) --> study field

X = np.array([[9, 5, 6, "computer science"],

              [5, 1, 5, "computer science"],

              [8, 8, 8, "computer science"],

              [1, 10, 7, "literature"],

              [1, 8, 1, "literature"],

              [5, 7, 9, "art"],

              [1, 1, 6, "art"]])

## One-liner

Forest = RandomForestClassifier(n_estimators=10).fit(X[:,:-1], X[:,-1])

## Result

students = Forest.predict([[8, 6, 5],

                           [3, 7, 9],

                           [2, 2, 1]])

print(students)

列表 4-10:使用随机森林分类器的集成学习

猜猜看:这段代码的输出是什么?

原理

在清单 4-10 中初始化标注的训练数据后,代码使用RandomForestClassifier类的构造函数创建一个随机森林,构造函数接受一个参数n_estimators,定义森林中的树木数量。接下来,通过调用fit()函数来填充先前初始化(一个空森林)的模型。为此,输入的训练数据由数组X的所有列(除了最后一列)组成,而训练数据的标签定义在最后一列。如同前面的示例,你使用切片来从数据数组X中提取相应的列。

这段代码的分类部分略有不同。我想向你展示如何对多个观察值进行分类,而不仅仅是一个。你可以通过创建一个多维数组,每行代表一个观察值,来实现这一点。

这是代码片段的输出:

## Result

students = Forest.predict([[8, 6, 5],

                           [3, 7, 9],

                           [2, 2, 1]])

print(students)

# ['computer science' 'art' 'art']

请注意,结果仍然是非确定性的(不同执行代码时结果可能不同),因为随机森林算法依赖于随机数生成器,该生成器在不同的时间点返回不同的数字。你可以通过使用整数参数random_state使此调用变为确定性。例如,你可以在调用随机森林构造函数时设置random_state=1RandomForestClassifier(n_estimators=10, random_state=1)。在这种情况下,每次创建一个新的随机森林分类器时,都会得到相同的输出,因为生成的随机数相同:它们都是基于种子整数 1。

总结来说,本节介绍了一种分类的元方法:使用各种决策树的输出以减少分类误差的方差。这是集成学习的一种形式,它将多个基本模型组合成一个单一的元模型,能够利用它们各自的优点。

注意

两棵不同的决策树可能导致高方差的错误:一棵产生良好的结果,而另一棵则没有。通过使用随机森林,你可以减轻这一效果。

这种思想的变种在机器学习中很常见——如果你需要快速提高预测准确性,只需运行多个机器学习模型,并评估它们的输出以找到最佳模型(这是机器学习从业者的一个快速而粗糙的秘密)。某种程度上,集成学习技术自动执行了在实际机器学习流程中通常由专家完成的任务:选择、比较并结合不同机器学习模型的输出。集成学习的最大优势是,这一过程可以在运行时对每个数据值单独进行。

总结

本章涵盖了 10 个基础的机器学习算法,这些算法对于你在该领域的成功至关重要。你已经学习了回归算法,如线性回归、KNN 和神经网络,用于预测数值。你也学习了分类算法,如逻辑回归、决策树学习、SVM 和随机森林。此外,你还学会了如何计算多维数据数组的基本统计量,并使用 K-Means 算法进行无监督学习。这些算法和方法是机器学习领域中最重要的算法之一,如果你想成为一名机器学习工程师,还有很多内容需要学习。这些学习将会带来回报——机器学习工程师在美国通常能赚到六位数的薪水(一个简单的网络搜索应该能验证这一点)!对于那些希望深入了解机器学习的学生,我推荐 Andrew Ng 的优秀(且免费的)Coursera 课程。你可以通过你最喜欢的搜索引擎找到该课程的在线材料。

在下一章,你将学习高效程序员最重要(也是最被低估)的一项技能:正则表达式。虽然这一章更侧重于概念性内容(你了解了基本的概念,但实际的重担由 scikit-learn 库承担),但下一章将会是高度技术性的内容。所以,挽起袖子,继续阅读吧!

第五章:正则表达式

Image

你是办公室职员、学生、软件开发者、经理、博主、研究员、作家、文案、教师,还是自由职业者?很可能,你每天都在电脑前度过许多小时。如果能稍微提高你的日常工作效率,哪怕只是一个小小的百分比,也能带来数千甚至数万美元的生产力提升,以及数百小时的额外空闲时间。

本章向你展示了一种被低估的技术,帮助高级程序员在处理文本数据时更高效:使用正则表达式。本章将展示 10 种使用正则表达式来解决日常问题的方法,这些方法能节省你大量的时间、精力和工作量。仔细学习本章内容——它会是值得你投入的时间!

在字符串中查找基本文本模式

本节介绍了使用re模块和其重要的re.findall()函数的正则表达式。我将从解释几个基本的正则表达式开始。

基础知识

正则表达式(简称regex)正式描述了一个搜索模式,你可以用它来匹配文本的某些部分。图 5-1 中的简单示例展示了如何在莎士比亚的《罗密欧与朱丽叶》文本中搜索Juliet模式。

images

图 5-1:在莎士比亚的《罗密欧与朱丽叶》中搜索模式Juliet

图 5-1 展示了最基础的正则表达式是一个简单的字符串模式。字符串'Juliet'就是一个完全有效的正则表达式。

正则表达式功能强大,能够做的不仅仅是常规的文本搜索,但它们仅由少数几个基本命令构建。掌握这些基本命令,你就能理解并编写复杂的正则表达式。在本节中,我们将重点介绍三条最重要的正则表达式命令,它们扩展了在给定文本中简单字符串模式搜索的功能。

点号正则表达式

首先,你需要知道如何使用点号正则表达式.字符)来匹配任意字符。点号正则表达式可以匹配任何字符(包括空白字符)。你可以使用它来表示你不在乎匹配的是什么字符,只要恰好匹配一个字符即可:

import re

text = '''A blockchain, originally block chain,

is a growing list of records, called blocks,

which are linked using cryptography.

'''

print(re.findall('b...k', text))

# ['block', 'block', 'block']

这个示例使用了re模块的findall()方法。第一个参数是正则表达式本身:你搜索的是任何以字符'b'开头、接着三个任意字符...、最后是字符'k'的字符串模式。这个正则表达式b...k匹配单词'block',但也匹配'boook''b erk''bloek'findall()的第二个参数是你要搜索的text文本。字符串变量text包含了三个匹配模式,正如你在print语句的输出中看到的那样。

星号正则表达式

其次,假设你想匹配以字符'y'开头并以字符'y'结尾,中间包含任意数量字符的文本。如何实现这一点?你可以使用星号正则表达式,即*字符。与点号正则表达式不同,星号正则表达式不能单独使用;它需要修饰另一个正则表达式。考虑以下例子:

print(re.findall('y.*y', text))

# ['yptography']

星号操作符应用于紧接其前面的正则表达式。在这个例子中,正则表达式模式以字符'y'开头,后跟任意数量的字符.*,然后是字符'y'。正如你所看到的,单词'cryptography'包含了这样一个模式的实例:'yptography'

你可能会想,为什么这段代码没有找到'originally''cryptography'之间的长子串,尽管这个子串也应该匹配正则表达式y.*y。原因很简单,因为点号操作符匹配除了换行符以外的任何字符。变量text中存储的字符串是一个多行字符串,包含三行换行符。你也可以将星号操作符与其他任何正则表达式结合使用。例如,你可以使用正则表达式abc*来匹配字符串'ab''abc''abcc''abccdc'

零次或一次正则表达式

第三,你需要知道如何使用零次或一次正则表达式?字符)来匹配零个或一个字符。就像星号操作符一样,问号修饰另一个正则表达式,正如以下例子所示:

print(re.findall('blocks?', text))

# ['block', 'block', 'blocks']

零次或一次正则表达式?应用于紧接其前面的正则表达式。在我们的例子中,这个正则表达式是字符s。零次或一次正则表达式表示它修饰的模式是可选的。

在 Python 的re包中,问号还有另一种用法,但与零次或一次正则表达式无关:问号可以与星号操作符*?结合使用,从而实现非贪婪模式匹配。例如,如果你使用正则表达式.*?,Python 将搜索最小数量的任意字符。相反,如果你使用没有问号的星号操作符*,它会贪婪地匹配尽可能多的字符。

让我们来看一个例子。当使用正则表达式<.*>搜索 HTML 字符串'<div>hello world</div>'时,它会匹配整个字符串'<div>hello world</div>',而不是仅仅匹配前缀'<div>'。如果你只想匹配前缀,可以使用非贪婪正则表达式<.*?>

txt = '<div>hello world</div>'

print(re.findall('<.*>', txt))

# ['<div>hello world</div>']

print(re.findall('<.*?>', txt))

# ['<div>', '</div>']

配备了这三种工具——点号正则表达式.、星号正则表达式*和零次或一次正则表达式?——你现在可以理解下一个单行解决方案。

代码

我们的输入是一个字符串,我们的目标是使用非贪婪的方法,找到所有以字符'p'开头、以字符'r'结尾,并且在其中至少包含一次字符'e'(并可能包含任意数量的其他字符)的模式!

这些类型的文本查询非常常见——尤其是在专注于文本处理、语音识别或机器翻译的公司中(如搜索引擎、社交网络或视频平台)。请查看清单 5-1。

## Dependencies

import re

## Data

text = 'peter piper picked a peck of pickled peppers'

## One-Liner

result = re.findall('p.*?e.*?r', text)

## Result

print(result)

清单 5-1:搜索特定短语的单行解决方案(非贪婪模式)

这段代码会打印出 text 中所有匹配的短语。它们是什么?

工作原理

正则表达式搜索查询是p.*?e.*?r。让我们来分解一下。你在寻找一个以字符 'p' 开头并以字符 'r' 结尾的短语。在这两个字符之间,你需要至少出现一次字符 'e'。除此之外,你允许任意数量的字符(无论是否有空格)。然而,你通过使用.*?以非贪婪的方式进行匹配,这意味着 Python 将搜索尽可能少的任意字符。以下是解决方案:

## Result

print(result)

# ['peter', 'piper', 'picked a peck of pickled pepper']

将这个解决方案与使用贪婪正则表达式 p.*e.*r 时得到的结果进行比较:

result = re.findall('p.*e.*r', text)

print(result)

# ['peter piper picked a peck of pickled pepper']

第一个贪婪星号操作符 .* 匹配几乎整个字符串,直到它终止。

使用正则表达式编写你的第一个网页抓取器

在上一节中,你学习了在字符串中查找任意文本模式的最强大方法:正则表达式。本节将进一步激发你使用正则表达式的动力,并通过一个实际的例子来拓展你的知识。

基础知识

假设你正在作为自由职业的软件开发者工作。你的客户是一家金融科技初创公司,需要及时了解加密货币的最新发展。他们雇佣你编写一个网页抓取器,定期获取新闻网站的 HTML 源代码,并在其中搜索以 'crypto' 开头的词汇(例如,'cryptocurrency''crypto-bot''crypto-crash' 等等)。

你第一次尝试的代码片段如下:

import urllib.request

search_phrase = 'crypto'

with urllib.request.urlopen('https://www.wired.com/') as response:

   html = response.read().decode("utf8") # convert to string

   first_pos = html.find(search_phrase) 

   print(html[first_pos-10:first_pos+10])

方法urlopen()(来自模块urllib.request)从指定的 URL 获取 HTML 源代码。由于结果是字节数组,因此你必须先使用decode()方法将其转换为字符串。然后,你使用字符串方法find()返回搜索字符串第一次出现的位置。通过切片(请参见第二章),你提取出一个子字符串,返回该位置的即时环境。结果是以下字符串:

# ,r=window.crypto||wi

哦,这看起来不太好。事实证明,搜索短语是模糊的——包含 'crypto' 的大多数单词在语义上与加密货币无关。你的网页抓取器生成了假阳性(它找到了你原本并不想找到的字符串结果)。那么你该如何解决呢?

幸运的是,你刚读完这本 Python 书,所以答案显而易见:正则表达式!你消除假阳性的方法是查找 'crypto' 后跟最多 30 个任意字符,接着是 coin 的情况。大致来说,搜索查询是 crypto + <最多 30 个任意字符> + coin。考虑以下两个示例:

  • '``加密机器人正在交易比特币'—是的

  • '``加密加密方法,可以轻松被量子计算机破解``'—不

那么如何解决这个问题,使得两个字符串之间允许最多 30 个任意字符呢?这超出了简单的字符串搜索。你不能列举出每个精确的字符串模式——允许的匹配是几乎无限的。例如,搜索模式必须匹配以下所有情况:'cryptoxxxcoin''crypto coin''crypto bitcoin''crypto is a currency``。比特币',以及其他所有在两个字符串之间最多有 30 个字符的字符组合。即使只有 26 个字母表中的字符,理论上符合我们要求的字符串数量也超过 26[30] = 2,813,198,901,284,745,919,258,621,029,615,971,520,741,376。在接下来的内容中,你将学习如何搜索一个文本,找到与大量可能的字符串模式对应的正则表达式模式。

代码

在这里,给定一个字符串,你将查找 'crypto' 后跟最多 30 个任意字符,接着是 'coin' 的情况。让我们先看一下 清单 5-2,然后再讨论代码如何解决这个问题。

## Dependencies

import re

## Data

text_1 = "crypto-bot that is trading Bitcoin and other currencies"

text_2 = "cryptographic encryption methods that can be cracked easily with quantum computers"

## One-Liner

pattern = re.compile("crypto(.{1,30})coin") 

## Result

print(pattern.match(text_1))

print(pattern.match(text_2))

清单 5-2:查找形如 crypto``(some text)``coin 的文本片段的一行解决方案

这段代码搜索了两个字符串变量 text_1text_2。搜索查询(模式)是否匹配它们?

工作原理

首先,你导入 Python 中的标准正则表达式模块 re。重要的部分发生在这一行代码中,你编译了搜索查询 crypto(.{1,30})coin。这是你可以用来搜索各种字符串的查询。你使用了以下特殊的正则表达式字符。按照从上到下的顺序阅读,你就能理解 清单 5-2 中模式的含义:

  • () 匹配括号内的内容。

  • . 匹配任意字符。

  • {1,30} 匹配前面正则表达式的 1 到 30 次出现。

  • (.{1,30}) 匹配 1 到 30 个任意字符。

  • crypto(.{1,30})coin 匹配由三部分组成的正则表达式:单词 '``crypto``',接着是一个长度为 1 到 30 个字符的任意序列,最后是单词 '``coin``'

我们称该模式为编译过的,因为 Python 会创建一个模式对象,可以在多个位置重用——就像编译后的程序可以多次执行一样。现在,你可以对我们的编译模式和要搜索的文本调用 match() 函数。这将得到以下结果:

## Result

print(pattern.match(text_1))

# <re.Match object; span=(0, 34), match='crypto-bot that is trading Bitcoin'>

print(pattern.match(text_2))

# None

字符串变量text_1匹配模式(通过结果匹配对象指示),但text_2不匹配(通过结果None指示)。尽管第一个匹配对象的文本表示看起来不太美观,但它清楚地表明字符串'crypto-bot that is trading Bitcoin'与正则表达式匹配。

分析 HTML 文档中的超链接

在前面的章节中,你学习了如何使用正则表达式模式.{x,y}来搜索一个字符串中的大量模式。本节将进一步讲解,引入更多的正则表达式。

基础知识

学会更多正则表达式将帮助你快速简洁地解决实际问题。那么,最重要的正则表达式有哪些呢?仔细研究下面的列表,因为我们将在本章中使用所有这些正则表达式。你可以把已经学过的当作一个小复习。

  • 点号正则表达式.匹配任意字符。

  • 星号正则表达式<pattern>*匹配任意多个<pattern>的正则表达式。注意,这包括零次匹配。

  • 至少匹配一个的正则表达式<pattern>+可以匹配任意多个<pattern>,但必须至少匹配一个实例。

  • 零次或一次匹配的正则表达式<pattern>?匹配零次或一次<pattern>的实例。

  • 非贪婪星号正则表达式*?会尽可能匹配少量的任意字符,以匹配整个正则表达式。

  • 正则表达式<pattern>{m}精确匹配m<pattern>的副本。

  • 正则表达式<pattern>{m,n}匹配mn<pattern>的副本。

  • 正则表达式<pattern_1>|<pattern_2>匹配<pattern_1><pattern_2>

  • 正则表达式<pattern_1><pattern_2>先匹配<pattern_1>,然后匹配<pattern_2>

  • 正则表达式(<pattern>)匹配<pattern>。括号用于将正则表达式分组,从而控制执行顺序(例如,(<pattern_1><pattern_2>)|*<pattern_3>*与*<pattern_1>*(*<pattern_2>*|*<pattern_3>*)`是不同的)。括号正则表达式还会创建一个匹配组,稍后你将在本节中看到。

让我们考虑一个简短的示例。假设你创建了正则表达式b?(.a)*。这个正则表达式会匹配哪些模式呢?这个正则表达式会匹配所有以零个或一个b开头,并且后面跟有任意多个以字符'a'结尾的两字符序列。因此,字符串'bcacaca''cadaea'''(空字符串)和'aaaaaa'都会匹配这个正则表达式。

在深入了解下一个一行代码之前,让我们快速讨论一下什么时候使用哪个正则表达式函数。三个最重要的正则表达式函数是re.match()re.search()re.findall()。你已经见过其中的两个,但让我们在这个例子中更深入地学习它们:

import re

text = '''

"One can never have enough socks", said Dumbledore.

"Another Christmas has come and gone and I didn't

get a single pair. People will insist on giving me books."

Christmas Quote

'''

regex = 'Christ.*'

print(re.match(regex, text))

# None

print(re.search(regex, text))

# <re.Match object; span=(62, 102), match="Christmas has come and gone and I didn't">

print(re.findall(regex, text))

# ["Christmas has come and gone and I didn't", 'Christmas Quote']

这三个函数都接受正则表达式和要搜索的字符串作为输入。match()search()函数返回一个匹配对象(如果正则表达式没有匹配到任何内容,则返回None)。匹配对象存储了匹配位置以及更多高级的元信息。match()函数不会在字符串中找到正则表达式(它返回None)。为什么?因为这个函数只会在字符串的开始部分查找模式。search()函数会在字符串的任何位置查找正则表达式的第一个匹配项。因此,它找到了匹配项"Christmas has come and gone and I didn't"

findall()函数的输出最为直观,但也最不适合进一步处理。findall()的结果是一个字符串序列,而不是一个匹配对象——因此它不会给我们匹配位置的精确信息。话虽如此,findall()还是有它的用途:与match()search()方法不同,findall()函数会提取所有匹配的模式,这在你想统计一个词在文本中出现的频率时非常有用(例如,字符串'Juliet'在文本'Romeo and Juliet'中,或者字符串'crypto'在关于加密货币的文章中)。

代码

假设你的公司要求你创建一个小型的网页爬虫,它可以爬取网页并检查其中是否包含指向域名finxter.com的链接。同时,他们还要求你确保超链接的描述中包含字符串'test''puzzle'。在 HTML 中,超链接被包含在<a></a>标签环境中。超链接本身由href属性的值定义。所以更准确地说,目标是解决以下问题,见清单 5-3:给定一个字符串,找出所有指向域名finxter.com并且在链接描述中包含'test''puzzle'的超链接。

## Dependencies

import re

## Data

page = '''

<!DOCTYPE html>

<html>

<body>

<h1>My Programming Links</h1>

<a href="https://app.finxter.com/">test your Python skills</a>

<a href="https://blog.finxter.com/recursion/">Learn recursion</a>

<a href="https://nostarch.com/">Great books from NoStarchPress</a>

<a href="http://finxter.com/">Solve more Python puzzles</a>

</body>

</html>

'''

## One-Liner

practice_tests = re.findall("(<a.*?finxter.*?(test|puzzle).*?>)", page)

## Result

print(practice_tests)

清单 5-3:分析网页链接的单行解决方案

这段代码查找正则表达式的两个匹配项。是哪两个?

它是如何工作的

数据由一个简单的 HTML 网页(存储为多行字符串)组成,里面包含了一些超链接(标签环境<a href="">链接文本</a>)。这个单行解决方案使用了re.findall()函数来检查正则表达式(<a.*?finxter.*?(test|puzzle).*?>)。通过这种方式,正则表达式返回所有在标签环境<a. . .>中的匹配项,且有以下限制。

在开头标签后,你匹配任意数量的字符(非贪婪地,防止正则表达式“吞噬”多个 HTML 标签环境),然后是字符串 'finxter'。接下来,你匹配任意数量的字符(非贪婪地),然后是 'test''puzzle' 中的一个字符串。接着,你再次匹配任意数量的字符(非贪婪地),然后是闭合标签。这样,你就能找到所有包含这些字符串的超链接标签。请注意,这个正则表达式还匹配那些在链接本身中包含 'test''puzzle' 的标签。还要注意,你只使用了非贪婪的星号运算符 '.*?',以确保总是查找最小的匹配,而不是匹配一个被多个嵌套标签环境包围的非常长的字符串。

单行代码的结果如下:

## Result

print(practice_tests)

# [('<a href="https://app.finxter.com/">test your Python skills</a>', 'test'),

#  ('<a href="http://finxter.com/">Solve more Python puzzles</a>', 'puzzle')]

有两个超链接匹配我们的正则表达式:单行代码的结果是一个包含两个元素的列表。然而,每个元素是一个字符串元组,而不是一个简单的字符串。这与我们之前在代码片段中讨论的 findall() 方法的结果不同。为什么会这样呢?返回类型是一个元组列表——每个匹配的匹配组() 包围。比如,正则表达式 (test|puzzle) 使用括号表示法来创建一个匹配组。如果你在正则表达式中使用了匹配组,re.findall() 函数会为每个匹配的组添加一个元组值。这个元组值是匹配该组的子字符串。例如,在我们的例子中,子字符串 'puzzle' 匹配了组 (test|puzzle)。让我们深入探讨匹配组的概念,以更清楚地理解这一点。

从字符串中提取美元金额

这行代码展示了正则表达式的另一个实际应用。在这里,你作为一名财务分析师工作。你的公司正在考虑收购另一家公司,你被分配去阅读另一家公司的报告。你特别关注所有的美元金额。现在,你可以手动扫描整篇文档,但这项工作非常繁琐,而且你不想把一天中最好的时间花在这些琐事上。所以你决定编写一个小的 Python 脚本。但最好的做法是什么呢?

基础知识

幸运的是,你已经阅读了这篇正则表达式教程,因此,你无需浪费大量时间编写自己冗长且易出错的 Python 解析器,而是选择了通过正则表达式实现干净的解决方案——这是一个明智的选择。但在深入问题之前,让我们讨论三个正则表达式的概念。

首先,迟早你会想匹配一个特殊字符,而这个字符在正则表达式语言中也有特殊意义。在这种情况下,你需要使用前缀 \转义该特殊字符的含义。例如,为了匹配括号字符 '('(它通常用于正则表达式中的分组),你需要使用正则表达式 \( 来转义它。这样,正则表达式字符 '(' 就失去了其特殊含义。

其次,方括号环境 [ ] 允许你定义一组特定的字符范围。例如,正则表达式 [0-9] 匹配以下字符之一:'0''1''2'、...、'9'。另一个例子是正则表达式 [a-e],它匹配以下字符之一:'a''b''c''d''e'

第三,正如我们在前面的单行解决方案部分讨论的那样,括号正则表达式 (<pattern>) 表示一个分组。每个正则表达式可以有一个或多个分组。当在带有分组的正则表达式上使用 re.findall() 函数时,返回的仅是匹配的分组,作为一个字符串元组—每个分组对应一个字符串—而不是整个匹配的字符串。例如,正则表达式 hello(world) 应用于字符串 'helloworld' 时,匹配的是整个字符串,但返回的仅是匹配的分组 world。另一方面,当正则表达式 (hello(world)) 包含两个嵌套分组时,re.findall() 函数的结果将是所有匹配分组的元组 ('helloworld', 'world')。请研究以下代码,彻底理解嵌套分组:

string = 'helloworld'

regex_1 = 'hello(world)'

regex_2 = '(hello(world))'

res_1 = re.findall(regex_1, string)

res_2 = re.findall(regex_2, string)

print(res_1)

# ['world']

print(res_2)

# [('helloworld', 'world')]

现在,你已经掌握了理解以下代码片段所需的所有知识。

代码

总结一下,你需要从给定的公司报告中调查所有货币金额。具体来说,你的目标是解决以下问题:给定一个字符串,查找所有包含美元金额的匹配项,金额中可能包含小数部分。以下示例字符串是有效匹配项:$10、$10. 或 $10.00021。如何在一行代码中高效地实现这一点?请查看清单 5-4。

## Dependencies

import re

## Data

report = '''

If you invested $1 in the year 1801, you would have $18087791.41 today.

This is a 7.967% return on investment. 

But if you invested only $0.25 in 1801, you would end up with $4521947.8525.

'''

## One-Liner

dollars = [x[0] for x in re.findall('(\$[0-9]+(\.[0-9]*)?)', report)]

## Result

print(dollars)

清单 5-4:查找文本中所有美元金额的一行解决方案

猜猜看:这段代码的输出是什么?

工作原理

该报告包含四个不同格式的美元金额。目标是开发一个正则表达式来匹配它们。你设计了正则表达式 (\$[0-9]+(.[0-9]*)?),它可以匹配以下模式。首先,它匹配美元符号 $(因为它是正则表达式的特殊字符,所以需要进行转义)。其次,它匹配一个由任意数量的数字(0 到 9)组成的数字(但至少有一个数字)。第三,它匹配一个可选的小数部分,即点字符 '.' 后的任意数量的小数值(这个匹配是可选的,正如零或一个正则表达式 ? 所示)。

此外,你使用列表推导式仅提取所有三个匹配结果中的第一个元组值。再次强调,re.findall()函数的默认结果是一个元组列表,每个成功匹配一个元组,每个匹配组中有一个元组值:

[('$1', ''), ('$18087791.41', '.41'), ('$0.25', '.25'), ('$4521947.8525', '.8525')]

你只对全局组—元组中的第一个值感兴趣。你通过使用列表推导式过滤掉其他值,并获得如下结果:

## Result

print(dollars)

# ['$1 ', '$18087791.41', '$0.25', '$4521947.8525']

值得再次强调的是,即使是实现一个简单的解析器,如果没有正则表达式的强大功能,也会变得非常困难且容易出错!

查找不安全的 HTTP URL

这个单行代码展示了如何解决 Web 开发人员常遇到的一些小而耗时的问题。假设你拥有一个编程博客,你刚刚将网站从不安全的http协议迁移到更安全的https协议。然而,你的旧文章仍然指向旧的 URL。你如何找到所有出现旧 URL 的地方?

基础知识

在前面的章节中,你学习了如何使用方括号符号来指定一个任意范围的字符。例如,正则表达式[0-9]匹配一个单一的数字,范围从 0 到 9。然而,方括号符号比这更强大。你可以在方括号内使用任意字符组合,精确地指定哪些字符匹配,哪些不匹配。例如,正则表达式[0-3a-c]+匹配字符串'01110''01c22a',但不匹配'443''00cd'。你还可以通过使用符号^来指定一个不匹配的固定字符集:正则表达式[⁰-3a-c]+匹配字符串'4444d''Python',但不匹配'001''01c22a'

代码

这里我们的输入是一个(多行)字符串,我们的目标是查找所有以前缀http://开头的有效 URL。然而,不要考虑没有顶级域名的无效 URL(找到的 URL 中必须至少有一个点号)。请看 Listing 5-5。

## Dependencies

import re

## Data

article = '''

The algorithm has important practical applications

http://blog.finxter.com/applications/

in many basic data structures such as sets, trees,

dictionaries, bags, bag trees, bag dictionaries,

hash sets, https://blog.finxter.com/sets-in-python/

hash tables, maps, and arrays. http://blog.finxter.com/

http://not-a-valid-url

http:/bla.ba.com

http://bo.bo.bo.bo.bo.bo/

http://bo.bo.bo.bo.bo.bo/333483--33343-/

'''

## One-Liner

stale_links = re.findall('http://[a-z0-9_\-.]+\.[a-z0-9_\-/]+', article)

## Results

print(stale_links)

Listing 5-5: 查找有效的 http:// URL 的单行解决方案

再次尝试在查看正确输出之前,预测代码会产生什么样的输出。

工作原理

在正则表达式中,你分析一个给定的多行字符串(可能是一个旧的博客文章),以查找所有以http://为前缀的 URL。正则表达式期望出现一个或多个(小写字母)字符、数字、下划线、连字符或点号([a-z0-9_\-\.]+)。注意,你需要转义连字符(\-),因为它通常表示方括号中的范围。同样,你需要转义点号(\.),因为你实际上是想匹配点号而不是任意字符。最终得到如下输出:

## Results

print(stale_links)

# ['http://blog.finxter.com/applications/',

#  'http://blog.finxter.com/',

#  'http://bo.bo.bo.bo.bo.bo/',

#  'http://bo.bo.bo.bo.bo.bo/333483--33343-/']

四个有效的 URL 可能需要迁移到更安全的 HTTPS 协议。

到目前为止,你已经掌握了正则表达式中最重要的特性。但要达到更深的理解,你需要通过练习和学习大量示例——正则表达式也不例外。让我们再学习几个实际的示例,看看正则表达式如何让你的生活更轻松。

验证用户输入的时间格式,第一部分

让我们学习如何检查用户输入格式的正确性。假设你编写了一个基于用户睡眠时间计算健康统计数据的 web 应用程序。用户输入他们入睡和醒来的时间。一个正确的时间格式示例如12:45,但由于网络机器人正在垃圾填充你的用户输入字段,许多“脏”数据导致了服务器的不必要处理开销。为了解决这个问题,你编写了一个时间格式检查器,用于确定输入是否值得进一步在后台应用程序中处理。使用正则表达式,编写代码只需几分钟。

基础知识

在之前的几个章节中,你已经学习了re.search()re.match()re.findall()函数。这些并不是唯一的正则表达式函数。在本节中,你将使用re.fullmatch(regex, string),它会检查regex是否完全匹配string,正如其名称所示。

此外,你将使用正则表达式语法pattern{m,n},它匹配正则表达式patternmn个实例,但不多也不少。注意,它会尽量匹配pattern的最大出现次数。以下是一个示例:

import re

print(re.findall('x{3,5}y', 'xy'))

# []

print(re.findall('x{3,5}y', 'xxxy'))

# ['xxxy']

print(re.findall('x{3,5}y', 'xxxxxy'))

# ['xxxxxy']

print(re.findall('x{3,5}y', 'xxxxxxy'))

# ['xxxxxy']

使用括号符号,代码不匹配少于三个或多于五个'x'字符的子字符串。

代码

我们的目标是编写一个函数input_ok,它接受一个字符串参数并检查它是否具有(时间)格式XX:XX,其中X是 0 到 9 之间的数字;参见清单 5-6。请注意,目前你接受像 12:86 这样的语义错误时间格式,但接下来的单行代码将解决这个更复杂的问题。

## Dependencies

import re

## Data

inputs = ['18:29', '23:55', '123', 'ab:de', '18:299', '99:99']

## One-Liner

input_ok = lambda x: re.fullmatch('[0-9]{2}:[0-9]{2}', x) != None

## Result

for x in inputs:

    print(input_ok(x))

清单 5-6:检查给定用户输入是否符合通用时间格式的单行解决方案 XX:XX

在继续之前,尝试确定代码中六个函数调用的结果。

工作原理

数据由六个输入字符串组成,这些字符串是通过你的网站前端接收到的。它们的格式正确吗?为了检查这一点,你创建了函数input_ok,该函数使用一个输入参数x和一个布尔输出的 lambda 表达式。你使用fullmatch(regex, x)函数,尝试使用我们的时间格式正则表达式匹配输入参数x。如果匹配失败,结果将为None,布尔输出为False。否则,布尔输出为True

正则表达式很简单:[0-9]{2}:[0-9]{2}。这个模式匹配两个从 0 到 9 的数字,后面跟着冒号:,然后再跟着两个从 0 到 9 的数字。因此,列表 5-6 的结果如下:

## Result

for x in inputs:

    print(input_ok(x))

'''

True

True

False

False

False

True

'''

input_ok函数正确地识别了时间输入的正确格式。在这一行代码中,你学习了如何通过合适的工具集,在短短几秒钟内完成那些本来需要多行代码和更多努力的高度实用的任务。

验证用户输入的时间格式,第二部分

在这一节中,你将深入探讨验证用户输入的时间格式,以解决上一节的问题:无效的时间输入,如99:99,不应被视为有效匹配。

基础

解决问题的一个有用策略是分层处理问题。首先,简化问题,解决其中较简单的变体。然后,再精炼解决方案以适应你特定(且更复杂)的问题。本节通过一个重要的方式精炼了之前的解决方案:它不允许无效的时间输入,比如99:9928:66。因此,问题变得更加具体(且更复杂),但你可以重用我们旧解决方案的部分内容。

代码

我们的目标是编写一个input_ok函数,它接受一个字符串参数并检查它是否符合(时间)格式XX:XX,其中X是 0 到 9 之间的数字;参见列表 5-7。此外,给定的时间必须是 24 小时制的有效时间格式,范围从 00:00 到 23:59。

## Dependencies

import re

## Data

inputs = ['18:29', '23:55', '123', 'ab:de', '18:299', '99:99']

## One-Liner

input_ok = lambda x: re.fullmatch('([01][0-9]|2[0-3]):[0-5][0-9]', x) != None

## Result

for x in inputs:

    print(input_ok(x))

列表 5-7:单行解决方案,检查给定的用户输入是否符合通用时间格式XX:XX并在 24 小时制时间中有效

这段代码打印了六行。它们是什么?

工作原理

如本节引言所述,你可以重用之前那行代码的解决方案,轻松解决这个问题。代码保持不变——你只是修改了正则表达式([01][0-9]|2[0-3]):[0-5][0-9]。第一部分([01][0-9]|2[0-3])是一个分组,匹配一天中所有可能的小时。你使用了或操作符|来区分 00 到 19 小时和 20 到 23 小时。第二部分[0-5][0-9]匹配一天中的分钟,从 00 到 59。因此,结果如下:

## Result

for x in inputs:

    print(input_ok(x))

'''

True

True

False

False

False

False

'''

注意,输出的第六行表示时间99:99不再被视为有效的用户输入。这个单行代码展示了如何使用正则表达式检查用户输入是否符合应用程序的语义要求。

字符串中的重复检测

这一行代码引入了正则表达式的一个令人兴奋的功能:在同一个正则表达式中重用你已经匹配过的部分。这一强大的扩展功能让你能够解决一系列新问题,包括检测含有重复字符的字符串。

基础

这一次,你作为计算机语言学研究员,正在分析某些单词用法如何随着时间的推移而变化。你使用已发布的书籍来分类和跟踪单词的使用情况。你的教授要求你分析单词中是否有更多的重复字符使用趋势。例如,单词 'hello' 包含重复字符 'l',而单词 'spoon' 包含重复字符 'o'。然而,单词 'mama' 不会被视为包含重复字符 'a' 的单词。

解决这个问题的简单方法是列举所有可能的重复字符 'aa''bb''cc''dd',...,'zz',并将它们结合成一个或的正则表达式。这种方法繁琐且难以泛化。如果你的教授改变主意,要求你检查两个字符之间最多隔一个字符的重复字符(例如,字符串 'mama' 现在就能匹配)呢?

没问题:如果你了解正则表达式中命名分组的功能,就有一个简单、清晰且有效的解决方案。你已经学习过被括号 (...) 包围的分组。如其名所示,命名分组就是一个带有名称的分组。例如,你可以通过使用语法 (?P<name>...) 将模式 ... 定义为名为 name 的命名分组。在定义了命名分组后,你可以在正则表达式的任何地方使用它,语法是 (?P=name)。考虑以下示例:

import re

pattern = '(?P<quote>[\'"]).*(?P=quote)'

text = 'She said "hi"'

print(re.search(pattern, text))

# <re.Match object; span=(9, 13), match='"hi"'>

在代码中,你会搜索被单引号或双引号包围的子字符串。为此,首先通过正则表达式 ['"] 匹配开头的引号(你需要转义单引号 \'`,以防止 Python 错误地认为单引号表示字符串的结束)。然后,你使用相同的分组来匹配与之对应的闭合引号(无论是单引号还是双引号)。

在进入代码之前,注意你可以使用正则表达式 \s 来匹配任意空白字符。此外,使用语法 [^Y] 你可以匹配不在集合 Y 中的字符。这就是解决问题所需了解的全部内容。

代码

考虑列表 5-8 中说明的问题:给定一段文本,找出所有包含重复字符的单词。在这种情况下,单词被定义为任何由非空白字符组成的字符串,这些字符之间由任意数量的空白字符分隔。

## Dependencies

import re

## Data

text = '''

It was a bright cold day in April, and the clocks were

striking thirteen. Winston Smith, his chin nuzzled into

his breast in an effort to escape the vile wind, slipped

quickly through the glass doors of Victory Mansions,

though not quickly enough to prevent a swirl of gritty

dust from entering along with him.

-- George Orwell, 1984

'''

## One-Liner

duplicates = re.findall('([^\s]*(?P<x>[^\s])(?P=x)[^\s]*)', text)

## Results

print(duplicates)

列表 5-8:一行代码解决方案查找所有重复字符

在这段代码中,找到哪些单词包含重复字符?

工作原理

正则表达式(?P<x>[^\s])定义了一个名为x的新分组。该分组仅由一个不是空格字符的任意字符组成。正则表达式(?P=x)紧随其后,匹配与x分组匹配的相同字符。你已经找到了重复字符!然而,目标不是查找重复字符,而是查找具有重复字符的单词。所以你在重复字符前后匹配任意数量的非空格字符[^\s]*

列表 5-8 的输出如下:

## Results

print(duplicates)

'''

[('thirteen.', 'e'), ('nuzzled', 'z'), ('effort', 'f'),

('slipped', 'p'), ('glass', 's'), ('doors', 'o'),

('gritty', 't'), ('--', '-'), ('Orwell,', 'l')]

'''

正则表达式会查找文本中所有具有重复字符的单词。请注意,在列表 5-8 中的正则表达式有两个分组,因此re.findall()函数返回的每个元素都是一个匹配分组的元组。你在前面的章节中已经看过这种行为。

在这一节中,你增强了你的正则表达式工具集,增加了一个强大的工具:命名分组。结合使用\s匹配任意空格字符和[^...]操作符定义不匹配的字符集这两个小的正则表达式特性,你已经在 Python 正则表达式的掌握上取得了实质性的进展。

检测词汇重复

在上一节中,你学习了命名分组。本节的目标是向你展示更多使用这个强大特性的高级方法。

基础知识

在过去的几年里,我作为一名研究人员,花费大部分时间写作、阅读和编辑研究论文。在编辑我的研究论文时,一位同事常常抱怨我反复使用相同的词汇(并且在文中使用得太密集)。如果有一个工具能够以编程方式检查你的写作,岂不是很有用?

代码

给定一个由小写字母和空格分隔的单词组成的字符串,没有特殊字符。找出一个匹配的子字符串,其中第一个和最后一个单词相同(重复),并且中间最多有 10 个单词。参见列表 5-9。

## Dependencies

import re

## Data

text = 'if you use words too often words become used'

## One-Liner

style_problems = re.search('\s(?P<x>[a-z]+)\s+([a-z]+\s+){0,10}(?P=x)\s', ' ' + text + ' ')

## Results

print(style_problems)

列表 5-9:查找词汇重复的单行解决方案

这个代码能找到词汇重复吗?

工作原理

再次假设给定的text仅由空格分隔的小写字母单词组成。现在,你通过正则表达式来搜索text。乍一看可能有点复杂,但让我们逐步分析:

'➊\s(?P<x>[a-z]+)\s+➋([a-z]+\s+){0,10}➌(?P=x)\s'

你从一个空格字符开始。这一点很重要,确保你从一个完整的单词开始(而不是单词的后缀)。接着,匹配一个名为x的分组,x由一个正数个小写字母字符(从'a''z')组成,后面跟着一个正数个空格字符 ➊。

然后,你继续匹配 0 到 10 个单词,每个单词由一个正数个小写字母字符(从'a''z')组成,后面跟着一个正数个空格字符 ➋。

你以命名分组x结束,后面跟一个空格字符,确保最后的匹配是一个完整的单词(而不是单词的前缀) ➌。

以下是代码片段的输出:

## Results

print(style_problems)

# <re.Match object; span=(12, 35), match=' words too often words '>

你发现了一个匹配的子字符串,它可能(也可能不)被认为是不好的样式。

在这个单行代码中,你将查找重复单词的问题简化到了核心,并解决了这个更简单的变种。请注意,实际上你需要考虑更复杂的情况,例如特殊字符、小写字母和大写字母的混合、数字等。或者,你也可以做一些预处理,将文本转换为期望的形式,例如小写字母、空格分隔的单词,没有特殊字符。

练习 5-1

编写一个 Python 脚本,允许使用更多特殊字符,例如用于结构化句子的字符(句号、冒号、逗号)。

在多行字符串中修改正则表达式模式

在最后的正则表达式单行中,你将学习如何修改文本,而不仅仅是匹配其中的一部分。

基础知识

要将某个正则表达式 regex 模式的所有出现替换为新字符串 replacement,可以使用正则表达式函数 re.sub(regex, replacement, text)。通过这种方式,你可以快速编辑大规模文本数据,而无需进行大量的手动操作。

在前面的章节中,你学习了如何匹配文本中出现的模式。但是如果你不想在某个模式出现时匹配另一个模式该怎么办?负向前瞻正则表达式 A(?!X) 会匹配正则表达式 A,前提是后面不跟着正则表达式 X。例如,正则表达式 not (?!good) 会匹配字符串 'this is not great',但不会匹配字符串 'this is not good'

代码

我们的数据是一个字符串,我们的任务是将所有出现的 Alice Wonderland 替换为 'Alice Doe',但不替换那些被单引号包围的 'Alice Wonderland'。参见 清单 5-10。

## Dependencies

import re

## Data

text = '''

Alice Wonderland married John Doe.

The new name of former 'Alice Wonderland' is Alice Doe.

Alice Wonderland replaces her old name 'Wonderland' with her new name 'Doe'.

Alice's sister Jane Wonderland still keeps her old name.

'''

## One-Liner

updated_text = re.sub("Alice Wonderland(?!')", 'Alice Doe', text)

## Result

print(updated_text)

清单 5-10:替换文本中模式的单行解决方案

这段代码会打印更新后的文本。它是什么?

原理

你将所有的 Alice Wonderland 替换为 Alice Doe,但不包括那些以单引号 ' 结尾的。你通过使用负向前瞻来实现这一点。请注意,你只检查闭合引号是否存在。例如,包含开引号但没有闭引号的字符串会匹配,你可以简单地替换它。这在一般情况下可能不是期望的行为,但在我们的示例字符串中,它能达到预期效果:

## Result

print(updated_text)

'''

Alice Doe married John Doe.

The new name of former 'Alice Wonderland' is Alice Doe.

Alice Doe replaces her old name 'Wonderland' with her new name 'Doe'.

Alice's sister Jane Wonderland still keeps her old name.

'''

你可以看到,原始的 'Alice Wonderland' 在被单引号包围时保持不变——这正是这段代码的目标。

总结

本章内容涵盖了许多内容。你学习了正则表达式,可以用它来匹配给定字符串中的模式。特别是,你学习了 re.compile()re.match()re.search()re.findall()re.sub() 等函数。它们共同涵盖了正则表达式的绝大多数应用场景。你可以在实际使用正则表达式时,学习其他函数。

你还学习了各种基本的正则表达式,可以组合(并重新组合)以创建更高级的正则表达式。你已经了解了空白字符,转义字符,贪婪/非贪婪操作符,字符集(和负字符集),分组和命名分组,以及负向先行断言。最后,你已经了解到,解决原始问题的简化变体往往比过早泛化更好。

唯一剩下的就是把你新学的正则表达式技能应用到实践中。熟悉正则表达式的一个好方法是开始在你喜爱的文本编辑器中使用它们。大多数高级文本和代码编辑器(包括 Notepad++)都配备了强大的正则表达式功能。此外,在处理文本数据时(例如写电子邮件、博客文章、书籍和代码时),考虑使用正则表达式。正则表达式将让你的生活更轻松,节省许多繁琐工作时间。

在下一章中,我们将深入探讨编程的至高纪律:算法。

第六章:算法

Image

算法是古老的概念。一个算法不过是一组指令,就像一份烹饪食谱。然而,算法在社会中所扮演的角色正在急剧增加:随着计算机在我们生活中的作用越来越大,算法和算法决策在各个领域无处不在。

2018 年的一项研究强调了“数据以我们对世界的观察的形式渗透到现代社会中……这些信息反过来可以用来做出有根据的——在某些情况下甚至完全自动化的——决策……看起来这样的算法可能会与人类决策相结合,这是获得社会接受并因此广泛使用的必要发展。”

注意

欲了解更多关于这项研究的信息,请参阅 S. C. Olhede 和 P. J. Wolfe 的《算法在社会中的日益普及:影响、冲击与创新》一书,网址为 royalsocietypublishing.org/doi/full/10.1098/rsta.2017.0364#d2696064e1

随着社会在自动化、人工智能和无处不在的计算的重大趋势下发展,理解算法与不了解算法的人之间的社会差距正在迅速扩大。例如,物流行业正在朝着自动化的方向发展——自动驾驶汽车和卡车的崛起——而专业司机面临着算法接管他们工作的事实。

21 世纪不断变化的热门技能和工作岗位使得年轻人必须理解、掌握并操作基本的算法。虽然唯一不变的就是变化,但算法和算法理论的概念与基础构成了即将到来的变化的基础。粗略地说,理解算法,你就能为未来几十年的发展做好充分准备。

本章旨在提高你对算法的理解,更侧重于直觉和全面理解概念及实际应用,而非理论。虽然算法理论与实际应用和概念理解同样重要,但许多优秀的书籍都专注于理论部分。阅读本章后,你将直观地理解一些计算机科学中最受欢迎的算法——并提高你的 Python 实际编程技能。这为即将到来的技术突破提供了坚实的基础。

注意

《算法导论》(作者:Thomas Cormen 等,MIT 出版社,2009 年)是一本关于算法理论的极好参考书。

让我们从一个小算法开始,解决一个对想要找到好工作的程序员来说相关的简单问题。

使用 Lambda 函数和排序查找字谜

变位词是编程面试中常见的题目,用来测试你的计算机科学词汇和编写简单算法的能力。在本节中,你将学习如何在 Python 中使用简单算法查找变位词。

基础知识

如果两个词由相同的字符组成,并且第一个词中的每个字符在第二个词中恰好出现一次,那么这两个词是变位词。这一点在图 6-1 以及以下示例中有说明:

  • “listen” → “silent”

  • “funeral ” → “real fun”

  • “elvis” → “lives”

images

图 6-1:单词 elvis 是单词 lives 的变位词。

现在,我们将着手解决这个问题,并得出一个简洁的 Pythonic 解决方案来判断两个单词是否是变位词。让我们开始编写代码吧。

代码

我们的目标是编写一个函数 is_anagram(),该函数接收两个字符串 x1x2,并在它们是变位词时返回 True!在继续阅读之前,请暂停片刻思考一下这个问题。你会如何在 Python 中处理这个问题呢?清单 6-1 展示了一个解决方案。

  ## One-Liner

➊ is_anagram = lambda x1, x2: sorted(x1) == sorted(x2)

  ## Results

  print(is_anagram("elvis", "lives"))

  print(is_anagram("elvise", "livees"))

  print(is_anagram("elvis", "dead"))

清单 6-1:检查两个字符串是否是变位词的一行代码解决方案

这段代码打印了三行。它们分别是什么?

工作原理

如果两个字符串具有相同的排序字符序列,那么它们就是变位词,因此我们的方法是对两个字符串进行排序,然后进行逐元素比较。就是这么简单。无需外部依赖。你只需通过使用 Lambda 函数定义(参见第一章)并传入两个参数 x1x2 来创建一个 is_anagram() 函数 ➊。该函数返回表达式 sorted(x1) == sorted(x2) 的结果,如果排序后的字符序列由相同的字符组成,则返回 True。以下是两个排序后字符序列的输出:

print(sorted("elvis"))

# ['e', 'i', 'l', 's', 'v']

print(sorted("lives"))

# ['e', 'i', 'l', 's', 'v']

两个字符串 'elvis''lives' 由相同的字符组成,因此它们的排序列表表示是相同的。三条打印语句的结果如下:

## Results

print(is_anagram("elvis", "lives")) # True

print(is_anagram("elvise", "livees")) # True

print(is_anagram("elvis", "dead")) # False

给高级程序员的小提示:在 Python 中对 n 个元素进行排序的运行时间复杂度是随着 n log(n) 函数渐近增长的。这意味着我们的这一行代码算法比那种检查每个字符是否都出现在两个字符串中并在相同字符存在时删除它们的天真算法更高效。天真算法的渐近增长复杂度是 n**2

然而,还有一种高效的方法,叫做 直方图法,即为两个字符串创建一个直方图,统计该字符串中所有字符的出现次数,然后比较两个直方图。假设字母表大小是常数,直方图法的运行时复杂度是线性的;它的渐近增长复杂度是 n。不妨把这个算法当作一个小练习来实现!

使用 Lambda 函数和负切片查找回文

本节介绍了另一个在面试问题中流行的计算机科学术语:回文。你将使用一个单行代码来检查两个单词是否是彼此的回文。

基础知识

首先要弄清楚的是:什么是回文?回文可以定义为一种元素序列(例如字符串或列表),它从前向后和从后向前读取都是一样的。以下是一些有趣的例子,如果你去掉空格,它们就是回文:

  • “Mr Owl ate my metal worm”

  • “Was it a car or a cat I saw?”

  • “Go hang a salami, I’m a lasagna hog”

  • “老鼠生活在没有邪恶之星上”

  • “Hannah”

  • “Anna”

  • “Bob”

我们的单行解决方案将需要你对切片有基本的理解。正如你在第二章中所学,切片是 Python 特有的概念,用于从序列类型(如列表或字符串)中截取一段范围的值。切片使用简洁的表示法[start:stop:step]来截取一个从索引start(包含)开始并在索引stop(不包含)结束的序列。第三个参数step允许你定义步长,即在截取下一个字符之前,原始序列中跳过多少个字符(例如,step=2意味着你的切片将只包含每隔一个字符的元素)。当使用负的步长时,字符串会反向遍历。

这是你需要知道的所有内容,以便用 Python 提出一个简短而简洁的单行解决方案。

代码

给定一个字符串时,你希望你的代码检查反转后的字符序列是否等于原始序列,以确定该字符串是否是回文。列表 6-2 展示了解决方案。

## One-Liner

is_palindrome = lambda phrase: phrase == phrase[::-1]

## Result

print(is_palindrome("anna"))

print(is_palindrome("kdljfasjf"))

print(is_palindrome("rats live on no evil star"))

列表 6-2:检查一个短语是否是回文的单行解决方案

它是如何工作的

这个简单的单行解决方案不依赖于任何外部库。你定义了一个 lambda 函数,它接受一个参数phrase——即需要测试的字符串——并返回一个布尔值,表示当字符串反转时,字符序列是否保持不变。为了反转字符串,你使用切片(请参见第二章)。

单行代码片段的结果如下:

## Result

print(is_palindrome("anna")) # True

print(is_palindrome("kdljfasjf")) # False

print(is_palindrome("rats live on no evil star")) # True

第一个和第三个字符串是回文,而第二个不是。接下来,让我们深入探讨另一个流行的计算机科学概念:排列。

通过递归阶乘函数计数排列数

本节介绍了一种简单而有效的方法,通过一行代码计算阶乘,以便找出数据集中可能的最大排列数。

基础知识

考虑以下问题:英格兰超级联赛有 20 支足球队,每支队伍在赛季结束时都可以达到 20 个排名中的任何一个。给定 20 支固定的队伍,您可以计算这些排名的所有可能版本的数量。注意,这个问题不是问单个队伍可以达到多少个排名(答案是 20),而是问所有队伍的排名总数有多少种。图 6-2 展示了仅仅三种可能的排名。

images

图 6-2:英格兰超级联赛足球队的三种可能排名

在计算机科学术语中,您会将每个排名表示为一个排列,它被定义为一组元素的特定顺序。我们的目标是找出给定集合的所有可能排列的数量。这些排列的数量对涉及投注应用程序、比赛预测和游戏分析的程序具有重要意义。例如,如果 100 个不同的排名每个都有相同的初始概率,那么某个特定排名的概率是 1/100 = 1%。这可以作为游戏预测算法的基础概率(先验概率)。在这些假设下,随机猜测的排名在一个赛季结束后有 1%的概率是正确的结果。

要计算给定n个元素的排列数量,您可以使用阶乘函数n!。在接下来的几段中,您将了解为什么是这样的。阶乘的定义如下:

n! = n × (n – 1) × (n – 2) × . . . × 1

例如:

1! = 1

3! = 3 × 2 × 1 = 6

10! = 10 × 9 × 8 × 7 × 6 × 5 × 4 × 3 × 2 × 1 = 3,628,800

20! = 20 × 19 × 18 × . . . × 3 × 2 × 1 = 2,432,902,008,176,640,000

让我们来看一下这个是如何工作的。假设您有一个包含 10 个元素的集合S = {s0, s1, s2, . . . , s9} 和 10 个桶B = {b0, b1, b2, . . . , b9}。您希望将集合S中的每个元素放入一个桶中。在足球的例子中,20 支队伍就是元素,20 个排名就是桶。为了得到S的一个特定排列,您只需要将所有元素放入所有桶中。将元素分配给桶的不同方式数量即为S中元素的排列总数。

以下算法确定了一个包含 10 个元素的集合的排列数量(这些元素需要放入 10 个桶中):

  1. 从集合S中取出第一个元素。现在有10 个空桶,所以你有10 个选择来放置元素。你将一个元素放入一个桶中。

  2. 现在一个桶已经被占用了。取出集合中的第二个元素。现在剩下9 个空桶,因此你有9 个选择

  3. 最后,从集合中取出第 10 个(最后一个)元素。现在有九个桶已经占用。只剩下一个空桶,因此你有一个选择

总共,你有 10 × 9 × 8 × 7 × 6 × 5 × 4 × 3 × 2 × 1 = 10! 种选项。每一个元素在桶中的潜在放置方式代表着集合元素的一种排列。因此,具有 n 个元素的集合的排列数就是 n!

递归地,阶乘函数也可以这样定义:

n! = n × (n – 1)!

递归的基本情况如下所示:

1! = 0! = 1

这些基本情况背后的直觉是,具有一个元素的集合有一种排列,具有零个元素的集合也有一种排列(将零个元素分配到零个桶中只有一种方式)。

代码

列表 6-3 中的单行代码将计算具有 n 个元素的集合的排列数 n!

## The Data

n = 5

## The One-Liner

factorial = lambda n: n * factorial(n-1) if n > 1 else 1

## The Result

print(factorial(n))

列表 6-3:定义阶乘函数的单行递归解法

尝试弄清楚这段代码的输出结果是什么。

它是如何工作的

在代码中,你使用了阶乘的递归定义。让我们快速提升对递归的直观理解。斯蒂芬·霍金提出了一种简明的方式来解释递归:“要理解递归,必须首先理解递归。”

《梅里亚姆-韦伯斯特词典》将递归定义为“计算机编程技术,涉及使用一个……函数……多次调用自身,直到满足指定条件,此时每次重复的其余部分会从最后一次调用处理到第一次。”该定义的核心是递归函数,即一个调用自身的函数。但是,如果函数一直调用自身,它将永远不会停止。

基于这个原因,我们设置了一个特定的基本情况。当满足基本情况时,最后一次函数调用终止并返回给倒数第二次函数调用一个解答。倒数第二次函数调用也会将解答返回给倒数第三次函数调用。这引发了一个链式反应,将结果传递到更高的递归层级,直到第一次函数调用返回最终结果。几行英文文本可能让人难以理解这一点,但请跟着我走:我们将在接下来的单行示例中讨论这一点。

一般来说,你可以通过四个步骤创建一个递归函数f

  1. 将原问题拆解成更小的问题实例。

  2. 将较小的问题实例作为函数f的输入(该函数随后将较小的输入拆解成更小的问题实例,以此类推)。

  3. 定义一个基本情况,即可以直接解决的最小输入,无需再调用函数f

  4. 指定如何将获得的较小解重新组合成较大的解。

你创建了一个带有一个参数 n 的 lambda 函数,并将这个 lambda 函数赋值给名称 factorial。最后,你调用命名函数 factorial(n-1) 来计算函数调用 factorial(n) 的结果。值 n 可以是英超联赛的球队数量(n=20),或者是其他值,如清单 6-3 中的值(n=5)。

大致来说,你可以使用 factorial(n-1) 的简化解决方案,通过将前者与输入参数 n 相乘,来构建更难问题 factorial(n) 的解决方案。只要你达到递归的基本情况 n <= 1,你只需返回硬编码的解决方案 factorial(1) = factorial(0) = 1

这个算法展示了如何通过彻底理解问题,通常能够找到解决问题的简单、简洁和高效的方法。在创建自己的算法时,选择最简单的解决方案是你可以做的最重要的事情之一。初学者通常发现自己编写的代码混乱且不必要地复杂。

在这种情况下,阶乘的递归(单行)定义比没有递归的迭代(单行)定义要简短。作为一个练习,试着重写这个单行代码,不使用递归定义,也不使用外部库——这并不简单,当然也没有那么简洁!

计算 Levenshtein 距离

在本节中,你将学习一种重要的实际算法,用于计算 Levenshtein 距离。理解这个算法比之前的算法要复杂,所以你也将训练自己清晰地思考问题。

基础知识

Levenshtein 距离 是一种计算两个字符串之间距离的度量;换句话说,它用于量化两个字符串的相似性。它的另一个名称 编辑距离 精确地描述了它所测量的内容:将一个字符串转换为另一个字符串所需的字符编辑次数(插入、删除或替换)。Levenshtein 距离越小,字符串越相似。

Levenshtein 距离在许多领域中有着重要的应用,比如智能手机上的自动纠正功能。如果你在 WhatsApp 消息中输入 helo,你的手机会检测到该单词不在其词库中,并选择几个高概率的单词作为潜在替换项,然后按 Levenshtein 距离对它们进行排序。例如,Levenshtein 距离最小的单词,因而最大相似度的单词是字符串 'hello',因此你的手机可能会自动将 helo 修正为 hello

让我们考虑一个例子,其中有两个不太相似的字符串 'cat''chello'。知道 Levenshtein 距离计算从第一个字符串到达第二个字符串所需的最小编辑次数,表 6-1 显示了最小的序列。

表 6-1:'cat' 转换为 'chello' 所需的最小序列

当前单词 所做编辑
cat
cht h 替换 a
che e 替换 t
chel 在位置 3 插入 l
chell 在位置 4 插入 l
chello 在位置 5 插入 o

表 6-1 将字符串 'cat' 转换为字符串 'chello',需要五步编辑,这意味着 Levenshtein 距离是 5。

代码

现在让我们编写一个 Python 单行代码来计算字符串 abac,以及 bc 的 Levenshtein 距离(见 清单 6-4)。

## The Data

a = "cat"

b = "chello"

c = "chess"

## The One-Liner

ls = ➊lambda a, b: len(b) if not a else len(a) if not b else min(

 ➋ ls(a[1:], b[1:])+(a[0] != b[0]),

 ➌ ls(a[1:], b)+1,

 ➍ ls(a, b[1:])+1)

## The Result

print(ls(a,b))

print(ls(a,c))

print(ls(b,c))

清单 6-4:用一行代码计算两个字符串的 Levenshtein 距离

基于你目前所知道的,尝试在运行程序之前计算输出结果。

原理

在进入代码之前,让我们快速探索一下一个在这个单行代码中大量使用的 Python 技巧。在 Python 中,每个对象都有一个布尔值,并且是 TrueFalse。大多数对象实际上是 True,直观上,你可能能猜到一些是 False 的对象:

  • 数值 0False

  • 空字符串 ''False

  • 空列表 []False

  • 空集合 set()False

  • 空字典 {}False

作为一个经验法则,如果 Python 对象是空的或零,它们被认为是 False。掌握了这一点后,让我们来看 Levenshtein 函数的第一部分:你创建了一个 lambda 函数,接受两个字符串 ab,并返回将字符串 a 转换为字符串 b 所需的编辑次数 ➊。

有两个简单的情况:如果字符串 a 为空,最小的编辑距离是 len(b),因为你只需要插入字符串 b 中的每个字符。同样,如果字符串 b 为空,最小的编辑距离是 len(a)。也就是说,如果其中一个字符串为空,你可以直接返回正确的编辑距离。

假设两个字符串都非空。你可以通过计算原始字符串 ab 的较小后缀的 Levenshtein 距离来简化问题,如 图 6-3 所示。

images

图 6-3:通过递归解决较小的问题实例,计算单词 'cat''chello' 的 Levenshtein 距离

要以递归方式计算字符串 'cat''chello' 之间的 Levenshtein 距离,你首先解决较容易的问题(递归地):

  1. 你计算后缀 athello 之间的距离,因为如果你知道如何将 at 转换为 hello,那么你可以通过修改第一个字符(或者如果两个字符串都以相同的字符开头,则保持第一个字符不变)轻松地将 cat 转换为 chello。假设这个距离是 5,你现在可以得出结论,catchello 之间的距离也最多是 5,因为你可以重用完全相同的编辑序列(两个词都以字符 c 开头,且你不需要编辑这个字符)。

  2. 你计算atchello之间的距离。假设这个距离是 6,那么你现在可以得出结论:catchello之间的距离至多是 6 + 1 = 7,因为你只需删除第一个单词的字符c(一个额外操作)。从此,你可以重用相同的解法从at变换到chello

  3. 你计算cathello之间的距离。假设这个距离是 5,那么你现在可以得出结论:catchello之间的距离至多是 5 + 1,因为你需要在第二个单词的开头插入字符c(一个额外操作)。

由于这些是你可以对首字符所做的所有可能操作(替换、删除、插入),catchello之间的 Levenshtein 距离是三种情况 1、2 和 3 中的最小值。现在让我们进一步分析列表 6-4 中的三种情况。

首先,你以递归方式计算从a[1:]b[1:]的编辑距离 ➋。如果首字符a[0]b[0]不同,你必须通过替换a[0]b[0]来修正,因此你将编辑距离加一。如果首字符相同,较简单问题ls(a[1:], b[1:])的解也就是较复杂问题ls(a, b)的解,正如你在图 6-3 中所见。

第二,你以递归方式计算从a[1:]b的距离 ➌。假设你已经知道这个距离的结果(从a[1:]b)——那么如何计算从ab的距离呢?答案是,只需删除a开头的第一个字符a[0],这就是一个额外的操作。这样,你就将更复杂的问题简化为更简单的问题。

第三,你以递归方式计算从ab[1:]的距离 ➍。假设你已经知道这个距离的结果(从ab[1:])。那么你如何计算从ab的距离呢?在这种情况下,你只需再走一步(从ab[1:]再到b),通过插入字符b[0]到单词b[1:]的开头,这样距离就增加了一。

最后,你只需选择所有三种结果的最小编辑距离(替换第一个字符、删除第一个字符、插入第一个字符)。

这个简洁的一行代码再次证明了训练递归技能的重要性。递归可能对你来说并不自然,但请放心,在像这样的递归问题学习之后,它会变得更加顺手。

使用函数式编程计算幂集

在这一部分,你将学习一个重要的数学概念——幂集:所有子集的集合。你将在统计学、集合论、函数式编程、概率论和算法分析中用到幂集。

基础知识

幂集 是给定集合 s 的所有子集的集合。它包括空集 {}、原始集合 s 和所有其他可能的子集。以下是几个示例。

示例 1:

  • 给定集合:s = {1}

  • 幂集: P = {{},{1}}

示例 2:

  • 给定集合:s = {1, 2}

  • 幂集: P = {{},{1},{2},{1,2}}

示例 3:

  • 给定集合:s = {1, 2, 3}

  • 幂集: P = {{},{1},{2},{3},{1,2},{1,3},{2,3},{1,2,3}}

要计算一个包含 n 个元素的集合 s 的幂集 P[n],你需要使用 s 中一个包含 (n* – 1) 个元素的子集的较小幂集 P[n]*[–1]。假设你要计算集合 s = {1, 2, 3} 的幂集。

  1. 用零个元素初始化幂集 P[0],即 P[0] = {{}}。换句话说,这是空集的幂集,它只包含空集本身。

  2. 要从包含 (n – 1) 个元素的幂集 P[n][–1] 创建包含 n 个元素的幂集 P[n],你需要从集合 s 中取出一个(任意)元素 x,并使用以下过程将所有生成的子集并入更大的幂集 P[n]*:

  3. 遍历 P[n]*[–1] 中的所有集合 p,并创建一个新子集,该子集由 xp 的并集组成。这将产生一个新的临时集合 T。例如,如果 P[2] = {{}, {1}, {2}, {1,2}},你通过将元素 x = 3 添加到 P[2] 中的所有集合,会创建临时集合 T = {{3}, {1,3}, {2,3}, {1,2,3}}。

  4. 将新的集合 T 与幂集 P[n][–1] 合并,得到幂集 P[n]。例如,你可以通过将临时集合 T 与幂集 P[2] 合并,得到幂集 P[3],合并过程如下: P[3] = T union P[2]。

  5. 一直进行到原始集合 s 为空为止。

我将在接下来的部分更详细地解释这一策略。

reduce() 函数

但首先,你需要正确理解一个你将在一行代码中使用的重要 Python 函数:reduce() 函数。reduce() 函数内置于 Python 2 中,但开发者认为它的使用频率较低,因此没有将其包含在 Python 3 中,所以你需要先从 functools 库中导入它。

reduce() 函数接受三个参数:reduce(function, iterable, initializer)function 参数定义了如何将两个值 xy 合并成一个值(例如,lambda x, y: x + y)。这样,你可以迭代地将 iterable(第二个参数)中的两个值合并为一个值,直到 iterable 中只剩下一个值。initializer 参数是可选的——如果没有设置,Python 会默认将 iterable 的第一个值作为初始值。

例如,调用reduce(lambda x, y: x + y, [0, 1, 2, 3])会执行以下计算:(((0 + 1)+ 2)+ 3) = 6。换句话说,你首先将两个值x=0y=1减少为和x + y = 0 + 1 = 1。然后,将这个 lambda 函数第一次调用的结果作为输入传递给第二次调用:x=1y=2。结果是和x + y = 1 + 2 = 3。最后,我们将 lambdafunction第二次调用的结果作为输入传递给第三次调用,通过设置x=3y=3。结果是和x + y = 3 + 3 = 6

在上一个例子中,你已经看到值x始终携带前一个(lambda)function的结果。参数x作为累积值,而参数y作为来自iterable更新值。这是预期的行为,旨在通过迭代地“减少”iterable参数中的所有值为单一值。可选的第三个参数initializer指定x的初始输入。这使得你可以定义一个序列聚合器,如列表 6-5 所示。

列表算术

在深入研究一行代码之前,你需要理解另外两个列表运算符。第一个是列表连接运算符+,它将两个列表拼接在一起。例如,表达式[1, 2] + [3, 4]的结果是新的列表[1, 2, 3, 4]。第二个是并集运算符|,它对两个集合执行简单的并集操作。例如,表达式{1, 2} | {3, 4}的结果是新的集合{1, 2, 3, 4}

代码

列表 6-5 提供了一个一行代码的解决方案,用于计算给定集合s的幂集。

# Dependencies

from functools import reduce

# The Data

s = {1, 2, 3}

# The One-Liner

ps = lambda s: reduce(lambda P, x: ➊P + [subset | {x} for subset in P], s, ➋[set()])

# The Result

print(ps(s))

列表 6-5:计算给定集合的幂集的一行代码解决方案

猜猜这个代码片段的输出是什么!

工作原理

这个一行代码的思想是将幂集从空集合开始 ➋,并不断向其中添加子集 ➊,直到无法再找到子集。

最初,幂集只包含空集合。在每一步,你从数据集s中取出一个元素x,并通过将x添加到幂集中已经存在的所有子集中来创建新的子集 ➋。正如你在本节介绍部分所看到的,每次考虑数据集s中的一个额外元素x时,幂集的大小都会翻倍。这样,你可以通过一次增加一个数据集元素(但每次增加n个子集)来扩展幂集。请注意,幂集的增长是指数级的:对于任何新的数据集元素x,你都会使幂集的大小翻倍。这是幂集的一个固有特性:它们会迅速超过任何存储容量——即使对于只有几十个元素的小型数据集也是如此。

你使用reduce()函数来维护当前的幂集变量P(最初只包含空集)。通过列表推导,reduce()函数为每个现有子集创建新的子集,并将其添加到幂集P中。具体而言,它将数据集中的值x添加到每个子集中,从而将幂集的大小翻倍(包含带有和不带有数据集元素x的子集)。通过这种方式,reduce()函数反复“合并”两个元素:幂集P和数据集中的元素x

因此,这行代码的结果如下:

# The Result

print(ps(s))

# [set(), {1}, {2}, {1, 2}, {3}, {1, 3}, {2, 3}, {1, 2, 3}]

这行代码很好地展示了理解 lambda 函数、列表推导和集合操作的重要性。

凯撒密码加密使用高级索引和列表推导

在本节中,你将学习一种古老的加密技术——凯撒密码,这也是尤利乌斯·凯撒自己用来掩盖私人对话的工具。不幸的是,凯撒密码非常简单,很容易破解,无法提供真正的保护,但它仍然被用于娱乐和遮掩那些应该保护的论坛内容,以免被天真的读者看到。

基础知识

凯撒密码的核心思想是将需要加密的字符按固定的位置数进行偏移。我们将特别看一种凯撒密码的变种——ROT13 算法。

ROT13算法是一种简单的加密算法,广泛用于许多论坛(例如 Reddit),用以防止剧透或隐藏对话的语义,避免新手看到。ROT13 算法容易破解——攻击者可以通过对加密文本中字母分布的概率分析来破解代码,即使攻击者不知道每个字符移动了多少位置。你不应该依赖这个算法来加密消息!尽管如此,ROT13 算法还是有很多轻度应用:

  • 模糊化在线论坛中的谜题结果。

  • 模糊化电影或书籍中的潜在剧透内容。

  • 嘲笑其他弱加密算法:“56 位 DES 至少比 ROT13 强。”

  • 在网站上模糊化电子邮件地址可以防止 99.999%的电子邮件垃圾机器人。

所以,ROT13 更像是互联网文化中的一个流行笑话和教育工具,而不是一个严肃的加密方法。

这个算法可以用一句话来解释:ROT13 = 将要加密的字符串在 26 个字母的字母表中旋转 13 个位置(模 26)(见图 6-4)。

images

图 6-4:该表显示了字母表中的每个字符在 ROT13 算法下的加密和解密方式。

换句话说,你将每个字符在字母表中向后移动 13 个位置。当移动到最后一个字符z时,你会从字母表的第一个位置a重新开始。

代码

清单 6-6 创建了一个单行代码,用来通过 ROT13 算法加密字符串s

## Data

abc = "abcdefghijklmnopqrstuvwxyz"

s = "xthexrussiansxarexcoming"

## One-Liner

rt13 = lambda x: "".join([abc[(abc.find(c) + 13) % 26] for c in x])

## Result

print(rt13(s))

print(rt13(rt13(s)))

清单 6-6:使用 ROT13 算法加密字符串s的单行代码解决方案

使用图 6-4 来破解这段代码:这段代码的输出是什么?

原理

该单行代码解决方案通过将每个字符分别向右移动 13 个位置,使用存储在abc中的字母表加密每个字符,然后创建这些加密字符的列表,并将这些元素连接起来以得到加密后的短语x

让我们更仔细地看看如何加密每个字符。你使用列表推导(参见第二章)通过将每个字符c替换为字母表中右移 13 个位置的字符来创建加密字符的列表。至关重要的是,要防止所有字母表中索引 >= 13的字符出现溢出。例如,将字符z(索引 25)向右移动 13 个位置,你会得到索引 25 + 13 = 38,但 38 不是字母表中的有效索引。为了解决这个问题,你使用模运算符来确保当字符超出字母表最大索引 25(字符z)时,重新计算字符的最终位置,且索引 == 0(字符a)。然后,继续向右移动剩余的 13 个位置,这些位置在重新开始前还没有应用过(参见图 6-5)。例如,字符z被右移 13 个位置到索引 38 取模 26(在 Python 代码中:38%26),结果是索引 12,即字符m

images

图 6-5:通过从索引 0 重新开始移位操作来防止过度移位, resulting in the following shift sequence: 25 > 0 > 1 > . . . > 12

下面是代码的关键部分,展示了如何将每个字符c移动 13 个位置:

abc[(abc.find(c) + 13) % 26]

首先,你找到字符c在字母表abc中的索引。接着,你通过将整数 13 加到字符c的索引上来移动该索引,并考虑到我们之前提到的模 26 技巧。

这段单行代码的结果如下:

## Result

print(rt13(s))

# kgurkehffvnafknerkpbzvat

print(rt13(rt13(s)))

# xthexrussiansxarexcoming

总结一下,你已经学会了凯撒密码的特殊变体——ROT13 算法,它将字符串中的每个字符在字母表中移动 13 个位置。将字符移动两次 13 + 13 = 26 个位置,结果得到原始字符,这意味着加密和解密使用相同的算法。

使用厄拉托斯特尼筛法寻找质数

找到质数对于加密等实际应用至关重要。许多公钥方法(从密码学的角度来看)之所以安全,是因为计算大数的质因数通常效率低下且缓慢。我们将制作一个单行代码,使用一种古老的算法从一个数字范围中找出所有质数。

基础知识

质数 n 是一个整数,除了 in 外,不能被任何其他整数整除而没有余数。换句话说,对于一个质数,不存在两个整数 a>1b>1 使得它们的乘积等于该质数:a^b=n

假设你想检查给定的数字 n 是否为质数。让我们从一个简单的算法开始来确定质数(参见 清单 6-7)。

def prime(n):

➊ for i in range(2,n):

    ➋ if n % i == 0:

           return False

    return True

print(prime(10))

# False

print(prime(11))

# True

print(prime(7919))

# True

清单 6-7:检查给定数字 n 是否为质数的简单实现

算法检查 2n-1 之间的所有数字 ➊,看看数字 n 是否能整除它而没有余数 ➋。例如,当确定数字 n = 10 是否是质数时,算法会迅速发现,表达式 n % i == 0 对于 i = 2 计算结果为 True。它找到了一个能整除 n 的数字 i,因此 n 不能是质数。在这种情况下,算法会中止进一步的计算并返回 False

检查一个数字的时间复杂度与输入的 n 相同:在最坏情况下,算法需要 n 次循环迭代来检查数字 n 是否为质数。

假设你想计算从 2 到某个最大数字 m 之间的所有质数。你可以简单地重复 清单 6-7 中的质数测试 m-1 次(参见 清单 6-8)。然而,这会带来巨大的处理成本。

# Find all prime numbers <= m

m = 20

primes = [n for n in range(2,m+1) if prime(n)]

print(primes)

# [2, 3, 5, 7, 11, 13, 17, 19]

清单 6-8:找出所有小于最大数字 m 的质数

这里我们使用列表推导式(参见 第二章)来创建一个包含所有小于 m 的质数的列表。我们引入了一个 for 循环,这意味着算法需要 m 次调用 is_prime(n) 函数,因此时间复杂度为 m**2。操作次数随输入 m 的增加呈平方增长。要找出所有小于 m = 100 的质数,需要进行最多 m**2 = 10000 次操作!

我们将构建一个单行代码,显著减少这个时间成本。

代码

通过这行单行代码,我们将编写一个算法,找出所有小于最大整数 m 的质数,且该算法比我们的简单实现更高效。清单 6-9 中的单行代码灵感来自一种古老的算法——厄拉托斯特尼筛法,我将在本节中解释该算法。

## Dependencies

from functools import reduce

## The Data

n=100

## The One-Liner

primes = reduce(lambda r, x: r - set(range(x**2, n, x)) if x in r else r,

                range(2, int(n**0.5) + 1), set(range(2, n)))

## The Result

print(primes)

# {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43,

#  47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97}

清单 6-9:实现厄拉托斯特尼筛法的单行代码解决方案

为了理解这里发生了什么,您可能需要一些额外的背景知识。

它是如何工作的

坦白说,我曾犹豫是否将这行代码包括在书中。它令人困惑、复杂且难以阅读。然而,这正是你在实践中会遇到的代码类型,借助这本书,我希望确保你能理解每一行代码——即使需要一些时间。我在 StackOverflow 上偶然看到了这个单行代码版本。它大致基于一种古老的算法——厄拉托斯特尼筛法,该算法用于计算质数。

注意

为了清晰起见,我修改了原始的 StackOverflow 一行代码。原始的一行代码可以在 stackoverflow.com/questions/10639861/python-prime-generator-in-one-line/ 找到,截止到本文撰写时。

埃拉托斯特尼筛法

算法(概念上)创建了一个包含从2m,最大整数值的巨大数字数组。数组中的所有数字都是质数候选者,意味着算法认为它们可能是质数(但不一定)。在算法的执行过程中,你会筛选掉不能是质数的候选者。只有在这个筛选过程中留下的数字,才是最终的质数。

为了实现这一点,算法会计算并标记数组中不是质数的数字。最后,所有未标记的数字就是质数。

算法重复以下步骤:

  1. 从第一个数字 2 开始,并在每一步中递增,直到找到一个质数x。你知道x是质数,如果它未被标记,因为x未被标记意味着没有任何小于x的数字是它的除数——这就是质数的定义。

  2. 标记所有数字x的倍数,因为它们也不是质数:数字x是所有这些数字的除数。

  3. 进行简单优化:从数字x × x开始标记倍数,而不是从2x开始,因为所有在2xx × x之间的数字已经被标记。这个优化有一个简单的数学推导,我稍后会描述。现在,只需知道你可以从x × x开始标记。

图 6-6 到 6-11 逐步解释了这个算法。

images

图 6-6:初始化埃拉托斯特尼筛法算法

最初,所有 2 到m = 100 之间的数字都未标记(白色单元格)。第一个未标记的数字 2 是一个质数。

images

图 6-7:标记所有 2 的倍数,因为它们不是质数。忽略接下来算法中的已标记数字。

images

图 6-8:标记 3 的倍数为“非质数”。

增加到下一个未标记的数字 3。由于此时它未被标记,它是一个质数。由于你已经标记了所有小于当前数字 3 的倍数,因此没有任何小于 3 的数字是它的除数。根据定义,数字 3 必须是质数。标记所有 3 的倍数,因为它们不是质数。从数字 3 × 3 开始标记,因为在 3 和 3 × 3 = 9 之间的所有 3 的倍数已经被标记。

images

图 6-9:标记 5 的倍数为“非质数”。

转到下一个未标记的数字 5(它是一个质数)。标记所有 5 的倍数。从数字 5 × 5 开始标记,因为在 5 和 5 × 5 = 25 之间的所有 5 的倍数已经被标记。

images

图 6-10:标记 7 的倍数为“非质数”。

增加到下一个未标记的数字 7(它是一个素数)。标记所有 7 的倍数。从 7 × 7 开始标记,因为 7 到 7 × 7 = 49 之间的所有 7 的倍数已经被标记过。

images

图 6-11:将 11 的倍数标记为“非素数”。

增加到下一个未标记的数字 11(它是一个素数)。标记所有 11 的倍数。因为你将从数字 11 × 11 = 121 开始标记,你会发现它已经大于我们的最大值m = 100。这使得算法终止。所有剩余的未标记数字都不能被任何数字整除,因此它们是素数。

埃拉托斯特尼筛法比朴素算法高效得多,因为朴素算法会独立地检查每个数字,忽略了之前的所有计算。而埃拉托斯特尼筛法则重用前面计算步骤的结果——这是许多算法优化领域中的一个常见思路。每次我们划去一个素数的倍数时,实际上是节省了检查这个倍数是否为素数的繁琐工作:我们已经知道它不是素数。

你可能会疑惑,为什么我们从平方的素数开始标记,而不是从素数本身开始。举例来说,在图 6-10 中的算法中,你刚刚找到了素数 7,并从 7 × 7 = 49 开始标记。原因是,你已经在之前的迭代中标记了所有其他倍数,如 7 × 2、7 × 3、7 × 4、7 × 5、7 × 6,因为你已经标记了所有比当前素数 7 小的数字的倍数:2、3、4、5、6。

单行代码解析

具备了对算法的全面概念理解后,你可以开始研究单行代码的解决方案:

## The One-Liner

primes = reduce(lambda r, x: r - set(range(x**2, n, x)) if x in r else r,

                range(2, int(n**0.5) + 1), set(range(2, n)))

这个单行代码使用reduce()函数一步一步地从初始的所有数字集合中移除已标记的数字(在单行代码中:set(range(2, n)))。

将这个集合作为未标记值r的初始值,因为最开始时,所有的值都是未标记的。现在,这个单行代码会遍历所有2n平方根之间的数字x(在单行代码中:range(2, int(n**0.5) + 1)),并从集合r中移除x的倍数(从x**2开始)——但仅当数字x是素数时,因为它在当前时刻没有从集合r中移除。

花 5 到 15 分钟重新阅读这段解释,仔细研究单行代码的不同部分。我保证你会觉得这个练习值得,因为它将显著提高你对 Python 代码的理解能力。

使用 reduce()函数计算斐波那契数列

受欢迎的意大利数学家斐波那契(原名:比萨的莱昂纳多)在 1202 年引入了斐波那契数,并令人惊讶地观察到这些数字在数学、艺术和生物学等多个领域都有重要意义。本节将向你展示如何用一行代码计算斐波那契数。

基础知识

斐波那契数列从 0 和 1 开始,然后,每个后续的元素是前两个元素的和。斐波那契数列的算法是内置的!

代码

清单 6-10 计算了从 0 和 1 开始的前n个斐波那契数的列表。

# Dependencies

from functools import reduce

# The Data

n = 10

# The One-Liner

fibs = reduce(lambda x, _: x + [x[-2] + x[-1]], [0] * (n-2), [0, 1])

# The Result

print(fibs)

清单 6-10:用一行 Python 代码计算斐波那契数列

研究这段代码并猜测输出结果。

原理解析

你将再次使用强大的reduce()函数。一般来说,当你需要聚合实时计算的状态信息时,这个函数非常有用;例如,当你使用前两个计算出的斐波那契数来计算下一个斐波那契数时。这是使用列表推导式难以实现的(参见第二章),因为列表推导式通常无法访问从中新创建的值。

你使用reduce()函数,并传入三个参数,分别对应reduce(function, iterable, initializer),依次将新的斐波那契数添加到聚合器对象中,function指定了如何将iterable对象中的每一个值依次合并到该聚合器对象中。

在这里,你使用一个简单的列表作为聚合器对象,初始斐波那契数为[0, 1]。记住,聚合器对象作为第一个参数传递给function(在我们的示例中是x)。

第二个参数是来自iterable的下一个元素。然而,你通过初始化iterable(n-2)个虚拟值来强制reduce()函数执行function (n-2)次(目标是找到前n个斐波那契数——但你已经有了前两个,0 和 1)。你使用丢弃参数_表示你对iterable的虚拟值不感兴趣。相反,你只需将新计算的斐波那契数附加到聚合器列表x中,这个新数是前两个斐波那契数之和。

另一种多行解决方案

反复求和两个斐波那契数已经是清单 6-10 中单行代码的简单思路。清单 6-11 提供了一种美丽的替代方案。

n = 10

x = [0,1]

fibs = x[0:2] + [x.append(x[-1] + x[-2]) or x[-1] for i in range(n-2)]

print(fibs)

# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

清单 6-11:以迭代方式查找斐波那契数的单行代码解决方案

这段代码片段是由我的一位电子邮件订阅者提交的(欢迎加入我们,访问 blog.finxter.com/subscribe/),它使用了带有副作用的列表推导式:变量 x 被更新n-2次,获取新的斐波那契数列元素。请注意,append()函数没有返回值,而是返回 None,这在布尔值上下文中被视为False。因此,列表推导式通过以下思路生成一个整数列表:

print(0 or 10)

# 10

在两个整数之间执行or运算似乎不太正确,但请记住,布尔类型是基于整数类型的。除了 0 之外的每个整数值都被解释为 True。因此,or操作只是将第二个整数值作为返回值,而不是将其转换为显式的布尔值 True。这是一段精妙的 Python 代码!

总结来说,你已经提高了对 Python 一行代码中的另一个重要模式的理解:使用reduce()函数创建一个列表,该列表动态地使用新更新或添加的元素来计算新的列表元素。你在实践中会经常遇到这个有用的模式。

一个递归二分查找算法

在这一节中,你将学习每个计算机科学家必须掌握的基本算法:二分查找算法。二分查找在许多基本数据结构的实现中有着重要的实际应用,比如集合、树、字典、哈希集合、哈希表、映射和数组。你在每个非平凡的程序中都会使用到这些数据结构。

基础知识

简而言之,二分查找算法通过不断将序列的大小减半,来查找排序后的值序列l中的特定值x,直到只剩下一个值:它要么是要查找的值,要么在序列中不存在。接下来,你将详细了解这一通用思想。

例如,假设你想在一个已排序的列表中查找值 56。一个简单的算法会从列表的第一个元素开始,检查它是否等于 56,并继续检查下一个元素,直到检查完所有元素或者找到该值。在最坏的情况下,算法会遍历每一个列表元素。一个包含 10,000 个元素的已排序列表,需要大约 10,000 次操作来检查每个元素是否等于要查找的值。在算法理论中,我们说运行时间的复杂度是线性的,即与列表元素的数量成正比。这个算法没有利用所有可用信息来实现最大效率。

第一个有用的信息是,列表是已排序的!利用这一点,你可以创建一个只接触少量元素的算法,并且仍然能绝对确定某个元素是否存在于列表中。二分查找算法只遍历log2(n)个元素(以 2 为底的对数)。你只需要进行log2(10,000) < 14 次操作,就能查找一个包含 10,000 个元素的列表!

对于二分查找,你假设列表按升序排列。算法从检查中间元素开始。如果中间值大于你想要的值,你知道中间和列表最后部分之间的所有元素都大于你想要的值。你想要的值不会出现在这一半的列表中,所以你可以通过一次操作立即排除掉这一半的元素。

类似地,如果搜索的值大于中间元素,你可以排除列表的前半部分元素。然后,只需重复每一步将有效列表大小减半的过程来检查元素。 图 6-12 展示了一个可视化示例。

图片

图 6-12:二分查找算法的示例运行

如果子列表包含偶数个元素,则没有明显的中间元素。在这种情况下,你需要向下取整中间元素的索引。

你想在一个排序的八个整数值的列表中找到值 56,并且尽量减少触碰的元素。二分查找算法检查中间元素 x(向下取整),然后丢弃列表中不可能包含 56 的那一半。这个检查有三种一般结果:

  • 元素 x 大于 56\。算法忽略了列表的右半部分。

  • 元素 x 小于 56\。算法忽略了列表的左半部分。

  • 元素 x 等于 56,如 图 6-12 中的最后一行所示。恭喜你——你刚刚找到了所需的值!

清单 6-12 展示了二分查找算法的实际实现。

def binary_search(lst, value):

    lo, hi = 0, len(lst)-1

    while lo <= hi:

        mid = (lo + hi) // 2

        if lst[mid] < value:

            lo = mid + 1

        elif value < lst[mid]:

            hi = mid - 1

        else:

            return mid

    return -1

l = [3, 6, 14, 16, 33, 55, 56, 89]

x = 56

print(binary_search(l,x))

# 6 (the index of the found element)

清单 6-12:二分查找算法

这个算法接受一个列表和一个待查找的值作为参数。然后,它通过使用两个变量 lohi 来反复将搜索空间减半,这两个变量定义了可能包含所需值的列表元素区间:lo 定义了起始索引,hi 定义了区间的结束索引。你检查中间元素落在哪种情况中,并通过调整 lohi 的值来适应潜在元素的区间,如所描述的那样。

虽然这是一个完全有效、可读且高效的二分查找算法实现,但它还不是一个真正的一行解决方案!

代码

现在你将用一行代码实现二分查找算法(见 清单 6-13)!

## The Data

l = [3, 6, 14, 16, 33, 55, 56, 89]

x = 33

## The One-Liner

➊ bs = lambda l, x, lo, hi: -1 if lo>hi else \ 

       ➋ (lo+hi)//2 if l[(lo+hi)//2] == x else \ 

       ➌ bs(l, x, lo, (lo+hi)//2-1) if l[(lo+hi)//2] > x else \ 

       ➍ bs(l, x, (lo+hi)//2+1, hi) 

## The Results

print(bs(l, x, 0, len(l)-1))

清单 6-13:实现二分查找的单行解决方案

猜猜这段代码的输出是什么!

它是如何工作的

因为二分查找天然适合递归方法,学习这段一行代码将增强你对这一重要计算机科学概念的直观理解。注意,尽管我为了可读性将这段一行代码拆分成四行,你当然可以将它写成一行代码。在这段一行代码中,我采用了递归的方式定义二分查找算法。

你通过使用 lambda 运算符和四个参数:lxlohi ➊ 创建了一个新的函数 bs。前两个参数 lx 是变量,分别表示排序后的列表和要查找的值。lohi 参数定义了当前子列表中要查找值 x 的最小和最大索引。在每一层递归中,代码会检查由 hilo 指定的子列表,随着索引 lo 增大和索引 hi 减小,子列表会越来越小。经过有限的步骤后,条件 lo>hiTrue,此时搜索的子列表为空——你没有找到值 x。这是我们递归的基准情况。因为你没有找到元素 x,所以返回 -1,表示没有此元素。

你使用计算 (lo+hi)//2 来找到子列表的中间元素。如果这个元素恰好是你想要的值,你就返回该中间元素的索引 ➋。请注意,你使用整数除法来向下取整到下一个可用的整数值,该值可以作为列表的索引。

如果中间元素大于所需值,意味着右侧的元素也较大,因此你会递归调用该函数,但将 hi 索引调整为仅考虑中间元素左侧的列表元素 ➌。

同样,如果中间元素小于所需值,那么就没有必要搜索中间元素左边的所有元素,因此你会递归调用该函数,但将 lo 索引调整为仅考虑中间元素右侧的列表元素 ➍。

在列表 [3, 6, 14, 16, 33, 55, 56, 89] 中查找值 33 时,结果是索引 4

这一行代码的部分加强了你对一些基本编程特性的理解,如条件执行、基础关键字、算术运算以及程序化序列索引等重要主题。更重要的是,你学会了如何利用递归使复杂的问题变得更简单。

递归快速排序算法

现在你将构建一个一行代码来使用这种流行的算法 快速排序,顾名思义,它能快速地对数据进行排序。

基础知识

快速排序是许多编程面试中的常见问题(谷歌、Facebook 和亚马逊都会问),同时它也是一种高效、简洁、易读的实际排序算法。由于其优雅性,大多数入门级算法课程都会讲解快速排序。

快速排序通过递归地将大问题分解为更小的问题,并以合并小问题的解决方案的方式解决大问题,从而对列表进行排序。

为了求解每个更小的问题,同样的策略会递归使用:将更小的问题进一步分解成更小的子问题,单独求解并合并,从而将快速排序归类为 分治法 算法。

快速排序选择一个枢轴元素,然后将所有大于枢轴的元素放到右侧,将所有小于或等于枢轴的元素放到左侧。这将排序列表的巨大问题分解为两个较小的子问题:排序两个更小的列表。然后,你递归地重复这一过程,直到获得一个包含零个元素的列表,该列表已排序,因此递归终止。

Figure 6-13 展示了快速排序算法的实际运行。

images

Figure 6-13:快速排序算法的示例运行

Figure 6-13 展示了快速排序算法在一个无序整数列表[4, 1, 8, 9, 3, 8, 1, 9, 4]上的应用。首先,它选择 4 作为枢轴元素,将列表分成一个无序子列表[1, 3, 1, 4](其中所有元素小于或等于枢轴)和一个无序子列表[8, 9, 8, 9](其中所有元素大于枢轴)。

接下来,快速排序算法递归地对这两个无序子列表进行排序。一旦子列表最多只包含一个元素,它们就被认为已排序,递归结束。

在每一层递归中,三个子列表(左子列表、枢轴、右子列表)会被连接起来,然后将结果列表传递给更高一层的递归。

代码

你将创建一个q函数,在一行 Python 代码中实现快速排序算法,并对任何作为整数列表传递的参数进行排序(见 Listing 6-14)。

## The Data

unsorted = [33, 2, 3, 45, 6, 54, 33]

## The One-Liner

q = lambda l: q([x for x in l[1:] if x <= l[0]]) + [l[0]] + q([x for x in l if x > l[0]]) if l else []

## The Result

print(q(unsorted))

Listing 6-14:使用递归实现快速排序算法的单行解决方案

现在,你能猜到——最后一次——这段代码的输出吗?

工作原理

这行代码直接类似于我们刚才讨论的算法。首先,你创建一个新的q lambda 函数,它接收一个列表参数l用于排序。从高层次来看,lambda 函数具有以下基本结构:

lambda l: q(left) + pivot + q(right) if l else []

在递归的基本情况中——即列表为空,因此可以被认为已经排序——lambda 函数返回空列表[]

在其他任何情况下,函数将列表l的第一个元素作为枢轴元素,并根据元素是否小于或大于枢轴来将所有元素分为两个子列表(leftright)。为了实现这一点,你使用了简单的列表推导(见第二章)。由于两个子列表未必已经排序,你也会对它们递归地执行快速排序算法。最后,你将三个子列表合并并返回排序后的列表。因此,结果如下:

## The Result

print(q(unsorted))

# [2, 3, 6, 33, 33, 45, 54]

总结

在这一章中,你学习了计算机科学中的重要算法,涵盖了包括变位词、回文、幂集、排列、阶乘、质数、斐波那契数、混淆、搜索和排序等广泛主题。许多这些算法构成了更高级算法的基础,并包含了全面算法教育的种子。提升你对算法和算法理论的理解,是提高编程能力的最有效途径之一。我甚至可以说,缺乏算法理解是大多数中级程序员在学习进程中感到困惑的首要原因。

为了帮助你突破瓶颈,我在我的“Coffee Break Python”电子邮件系列中定期解释新的算法,旨在持续改进(访问 https://blog.finxter.com/subscribe/)。感谢你花费宝贵的时间和精力研究所有的单行代码片段和解释,我希望你已经能够看到自己的技能有所提升。根据我教授成千上万名 Python 学习者的经验,超过一半的中级程序员在理解基础的 Python 单行代码时会遇到困难。只要你保持坚持不懈,就有很大的机会超越中级程序员,成为 Python 大师(或者至少是前 10%的优秀程序员)。

第七章:后记

图片

恭喜你!你已经完成了整本书的学习,掌握了Python 一行代码的技巧,像极少数人一样。你为自己打下了坚实的基础,这将帮助你突破 Python 编程技能的瓶颈。通过仔细学习所有 Python 一行代码,你应该能够征服你将来遇到的任何一行 Python 代码。

就像任何超能力一样,你必须明智地使用它。滥用一行代码将会伤害你的代码项目。在本书中,我将所有算法压缩成一行代码,目的是推动你代码理解能力的提升。但你应该小心,不要在实际的代码项目中过度使用这项技能。不要为了展示你的“一行代码超能力”而把所有东西都压缩到一行代码中。

相反,为什么不利用它来提高现有代码库的可读性,解开其中最复杂的一行代码呢?就像超人利用他的超能力帮助普通人过上舒适的生活一样,你也可以帮助普通程序员维持他们的舒适编程生活。

本书的主要承诺是让你成为 Python 一行代码的高手。如果你认为本书兑现了这个承诺,请在你喜欢的书籍市场(如 Amazon)上给它投票,帮助他人发现它。我也鼓励你通过 chris@finxter.com 给我留言,如果你在阅读本书过程中遇到任何问题,或者希望提供任何正面或负面的反馈。我们非常愿意在未来的版本中根据你的反馈不断改进本书,因此,我将赠送一本免费的 Coffee Break Python Slicing 电子书给任何提供建设性反馈的人。

最后,如果你希望不断提升自己的 Python 技能,可以订阅我的 Python 新闻通讯,地址是 https://blog.finxter.com/subscribe/,在那里我几乎每天都会发布新的计算机科学教育内容,如 Python 备忘单,帮助你和成千上万的其他有抱负的编程者,提供清晰的路径,持续进步,最终掌握 Python。

既然你已经掌握了单行代码,你应该考虑将注意力转向更大的代码项目。学习面向对象编程和项目管理,最重要的是,选择自己的实际代码项目并不断进行实践。这不仅能提高你的学习记忆力,还能极大地激励和鼓励你,在现实世界中创造价值,并且是最具现实意义的训练方式。在学习效率方面,没有什么能够替代实际经验。

我鼓励我的学生至少将 70%的学习时间用于实际项目。如果你每天有 100 分钟用于学习,就花 70 分钟做实际的代码项目,剩下的 30 分钟用来读书和做课程、教程。这看起来很明显,但大多数人仍然做错了,所以他们总是觉得自己还没有准备好开始做实际的代码项目。

和你一起度过这么长的时间非常愉快,我非常感谢你在这本培训书上投入的时间。愿你的投资最终能带来丰厚的回报!祝你在编程职业生涯中一切顺利,希望我们能再见面。

编程愉快!

克里斯

posted @ 2025-11-27 09:16  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报