来源:
allendowney.github.io/ThinkPython/
译者:飞龙
10. 字典
本章介绍了一种内置类型——字典。它是 Python 最棒的特性之一,也是许多高效优雅算法的构建模块。
我们将使用字典来计算书中独特单词的数量以及每个单词出现的次数。在练习中,我们还将使用字典来解决单词谜题。
10.1. 字典是一个映射
字典像列表,但更为通用。在列表中,索引必须是整数;在字典中,索引可以是(几乎)任何类型。例如,假设我们创建一个数字单词的列表,如下所示。
lst = ['zero', 'one', 'two']
我们可以使用整数作为索引来获取对应的单词。
lst[1]
'one'
但假设我们想反过来查找一个单词,以得到对应的整数。我们不能用列表做到这一点,但可以用字典。我们首先创建一个空字典,并将其赋值给 numbers
。
numbers = {}
numbers
{}
花括号 {}
表示一个空字典。要向字典中添加项目,我们将使用方括号。
numbers['zero'] = 0
本作业向字典添加了一个项目,表示键与值的关联。在这个例子中,键是字符串 'zero'
,值是整数 0
。如果我们显示字典,会看到它包含一个项目,项目中的键和值由冒号 :
分隔。
numbers
{'zero': 0}
我们可以像这样添加更多项目。
numbers['one'] = 1
numbers['two'] = 2
numbers
{'zero': 0, 'one': 1, 'two': 2}
现在字典包含了三个项目。
要查找键并获取对应的值,我们使用括号运算符。
numbers['two']
2
如果键不在字典中,我们会得到一个 KeyError
。
numbers['three']
KeyError: 'three'
len
函数适用于字典;它返回项目的数量。
len(numbers)
3
用数学语言来说,字典表示从键到值的映射,因此你也可以说每个键“映射到”一个值。在这个例子中,每个数字单词映射到对应的整数。
下图展示了 numbers
的状态图。
[外链图片转存中…(img-K8bdXsIv-1748168124684)]
字典通过一个框表示,框外有“dict”字样,框内是各项内容。每个项目由一个键和指向值的箭头表示。引号表明这里的键是字符串,而不是变量名。
10.2. 创建字典
在前一节中,我们创建了一个空字典,并使用括号运算符一次添加一个项目。相反,我们也可以像这样一次性创建字典。
numbers = {'zero': 0, 'one': 1, 'two': 2}
每个项目由键和值组成,键和值之间用冒号分隔。项目之间用逗号分隔,并被花括号括起来。
另一种创建字典的方法是使用 dict
函数。我们可以像这样创建一个空字典。
empty = dict()
empty
{}
我们也可以像这样复制一个字典。
numbers_copy = dict(numbers)
numbers_copy
{'zero': 0, 'one': 1, 'two': 2}
在执行修改字典的操作之前,通常建议先创建一个字典的副本。
10.3. in
操作符
in
操作符也适用于字典;它会告诉你某个元素是否作为键出现在字典中。
'one' in numbers
True
in
操作符不会检查某个元素是否作为值出现。
1 in numbers
False
要查看某个元素是否作为字典中的值出现,你可以使用values
方法,它返回一个值序列,然后使用in
操作符。
1 in numbers.values()
True
Python 字典中的项存储在一个哈希表中,哈希表是一种组织数据的方式,具有一个显著的特性:无论字典中有多少项,in
操作符所需的时间大致相同。这使得编写一些高效的算法成为可能。
为了演示,我们将比较两种算法,用于寻找一对单词,其中一个是另一个的反转——比如stressed
和desserts
。我们将从读取单词列表开始。
word_list = open('words.txt').read().split()
len(word_list)
113783
这是上一章中的reverse_word
函数。
def reverse_word(word):
return ''.join(reversed(word))
以下函数遍历列表中的单词。对于每个单词,它会反转字母,然后检查反转后的单词是否在单词列表中。
def too_slow():
count = 0
for word in word_list:
if reverse_word(word) in word_list:
count += 1
return count
这个函数运行需要超过一分钟。问题在于,in
操作符会逐个检查列表中的单词,从头开始。如果它没有找到需要的内容——大多数情况下是这样——它就必须一直搜索到末尾。
并且in
操作符位于循环内部,因此它会为每个单词执行一次。由于列表中有超过 10 万个单词,而对于每个单词我们检查超过 10 万个单词,总的比较次数是单词数的平方——大约是 130 亿次。
len(word_list)**2
12946571089
我们可以通过字典使这个函数变得更快。以下循环创建一个字典,将单词作为键存储。
word_dict = {}
for word in word_list:
word_dict[word] = 1
word_dict
中的值都是1
,但它们可以是任何值,因为我们永远不会查找它们——我们仅仅使用这个字典来检查键是否存在。
现在这里有一个版本的函数,它将word_list
替换为word_dict
。
def much_faster():
count = 0
for word in word_dict:
if reverse_word(word) in word_dict:
count += 1
return count
这个函数运行时间不到一秒钟,因此比之前的版本快大约 10,000 倍。
通常,在列表中查找元素所需的时间与列表的长度成正比。而在字典中查找一个键的时间几乎是恒定的——无论项目的数量是多少。
10.4. 计数器集合
假设你有一个字符串,并且想要统计每个字母出现的次数。字典是做这件事的一个好工具。我们从一个空字典开始。
counter = {}
当我们遍历字符串中的字母时,假设我们第一次看到字母'a'
。我们可以像这样将它添加到字典中。
counter['a'] = 1
值 1
表示我们已看到该字母一次。稍后,如果我们再次看到相同的字母,可以像这样增加计数器。
counter['a'] += 1
现在与 'a'
相关联的值是 2
,因为我们已经看到了两次该字母。
counter
{'a': 2}
以下函数使用这些功能来计算每个字母在字符串中出现的次数。
def value_counts(string):
counter = {}
for letter in string:
if letter not in counter:
counter[letter] = 1
else:
counter[letter] += 1
return counter
每次循环时,如果 letter
不在字典中,我们就创建一个键为 letter
、值为 1
的新项。如果 letter
已经在字典中,我们就增加与 letter
相关联的值。
这是一个例子。
counter = value_counts('brontosaurus')
counter
{'b': 1, 'r': 2, 'o': 2, 'n': 1, 't': 1, 's': 2, 'a': 1, 'u': 2}
counter
中的项目显示字母 'b'
出现一次,字母 'r'
出现两次,以此类推。
10.5. 循环和字典
如果你在 for
语句中使用字典,它会遍历字典的键。为了演示,让我们创建一个字典来统计 'banana'
中字母的出现次数。
counter = value_counts('banana')
counter
{'b': 1, 'a': 3, 'n': 2}
以下循环打印的是键,它们是字母。
for key in counter:
print(key)
b
a
n
为了打印值,我们可以使用 values
方法。
for value in counter.values():
print(value)
1
3
2
为了打印键和值,我们可以遍历键并查找相应的值。
for key in counter:
value = counter[key]
print(key, value)
b 1
a 3
n 2
在下一章,我们将看到一种更简洁的方法来完成相同的事情。
10.6. 列表和字典
你可以将列表作为字典的值。例如,下面是一个字典,它将数字 4
映射到一个包含四个字母的列表。
d = {4: ['r', 'o', 'u', 's']}
d
{4: ['r', 'o', 'u', 's']}
但你不能将列表作为字典的键。以下是我们尝试时发生的情况。
letters = list('abcd')
d[letters] = 4
TypeError: unhashable type: 'list'
我之前提到过,字典使用哈希表,这意味着键必须是可哈希的。
哈希是一个函数,它接受一个值(任何类型)并返回一个整数。字典使用这些整数,称为哈希值,用来存储和查找键。
这个系统只有在键是不可变的情况下才有效,因此它的哈希值始终相同。但如果键是可变的,它的哈希值可能会改变,字典将无法工作。这就是为什么键必须是可哈希的,以及为什么像列表这样的可变类型不能作为键的原因。
由于字典是可变的,它们也不能作为键使用。不过,它们可以作为值使用。
10.7. 累积列表
对于许多编程任务,遍历一个列表或字典同时构建另一个是非常有用的。例如,我们将遍历 word_dict
中的单词,生成一个回文列表——即那些正反拼写相同的单词,如“noon”和“rotator”。
在上一章,其中一个练习要求你编写一个函数,检查一个单词是否是回文。这里是一个使用 reverse_word
的解决方案。
def is_palindrome(word):
"""Check if a word is a palindrome."""
return reverse_word(word) == word
如果我们遍历 word_dict
中的单词,就可以像这样计算回文的数量。
count = 0
for word in word_dict:
if is_palindrome(word):
count +=1
count
91
到现在为止,这个模式已经很熟悉了。
-
在循环之前,
count
被初始化为0
。 -
在循环内,如果
word
是回文,我们就增加count
。 -
当循环结束时,
count
包含回文的总数。
我们可以使用类似的模式来列出回文。
palindromes = []
for word in word_dict:
if is_palindrome(word):
palindromes.append(word)
palindromes[:10]
['aa', 'aba', 'aga', 'aha', 'ala', 'alula', 'ama', 'ana', 'anna', 'ava']
下面是它的工作原理:
-
在循环之前,
palindromes
被初始化为空列表。 -
在循环中,如果
word
是回文,我们将它添加到palindromes
的末尾。 -
当循环结束时,
palindromes
是一个回文列表。
在这个循环中,palindromes
被用作 累加器,即一个在计算过程中收集或累积数据的变量。
现在假设我们只想选择具有七个或更多字母的回文。我们可以遍历 palindromes
,并生成一个新列表,其中只包含长回文。
long_palindromes = []
for word in palindromes:
if len(word) >= 7:
long_palindromes.append(word)
long_palindromes
['deified', 'halalah', 'reifier', 'repaper', 'reviver', 'rotator', 'sememes']
遍历列表,选择一些元素并忽略其他元素,这个过程称为 过滤。 ## 10.8. 备忘录
如果你运行了来自 第六章 的 fibonacci
函数,也许你注意到,提供的参数越大,函数运行的时间就越长。
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
return fibonacci(n-1) + fibonacci(n-2)
此外,运行时间会迅速增加。为了理解原因,请考虑以下图示,它展示了 fibonacci
的 调用图,其中 n=4
:
[外链图片转存中…(img-jcgpYxGm-1748168124685)]
调用图展示了一组函数框架,框架之间通过连接线显示每个框架与其调用的函数框架之间的关系。在图的顶部,fibonacci
的 n=4
调用 fibonacci
的 n=3
和 n=2
。接下来,fibonacci
的 n=3
调用 fibonacci
的 n=2
和 n=1
,依此类推。
计算 fibonacci(0)
和 fibonacci(1)
被调用的次数。这是一个低效的解决方案,且随着参数的增大,效率会更差。
一种解决方案是通过将已经计算的值存储在字典中来跟踪这些值。一个先前计算过并为以后使用而存储的值被称为 备忘录。这是一个“备忘录化”版本的 fibonacci
:
known = {0:0, 1:1}
def fibonacci_memo(n):
if n in known:
return known[n]
res = fibonacci_memo(n-1) + fibonacci_memo(n-2)
known[n] = res
return res
known
是一个字典,用来追踪我们已经知道的斐波那契数。它一开始有两个条目:0
映射到 0
,1
映射到 1
。
每当调用 fibonacci_memo
时,它都会检查 known
。如果结果已经存在,它可以立即返回。否则,它必须计算新的值,将其添加到字典中,并返回该值。
比较这两个函数,fibonacci(40)
运行大约需要 30 秒,而 fibonacci_memo(40)
只需大约 30 微秒,因此速度快了约一百万倍。在本章的笔记本中,你会看到这些测量的来源。
10.9. 调试
当你处理更大的数据集时,通过打印输出并手动检查调试可能变得非常繁琐。以下是一些调试大数据集的建议:
-
缩小输入数据:如果可能,减小数据集的大小。例如,如果程序读取一个文本文件,可以从前 10 行开始,或者从你能找到的最小示例开始。你可以编辑文件本身,或者(更好)修改程序,使其只读取前
n
行。如果发生错误,可以将
n
减小到发生错误的最小值。当你找到并修正错误后,可以逐渐增加n
。 -
检查摘要和类型:与其打印并检查整个数据集,不如考虑打印数据的摘要,例如字典中的项数或数字列表的总和。
运行时错误的一个常见原因是值的类型不正确。调试此类错误时,通常只需打印出值的类型即可。
-
编写自检代码:有时你可以编写代码来自动检查错误。例如,如果你正在计算一个数字列表的平均值,你可以检查结果是否不大于列表中的最大值,或不小于最小值。这被称为“合理性检查”,因为它能够检测出“不合理”的结果。
另一种检查方法是比较两个不同计算结果的差异,以查看它们是否一致。这叫做“一致性检查”。
-
格式化输出:格式化调试输出可以让错误更容易被发现。在第六章中,我们看到过一个例子。你可能会觉得有用的另一个工具是
pprint
模块,它提供了一个pprint
函数,以更人性化的格式显示内建类型(pprint
代表“漂亮打印”)。再次强调,花时间构建框架可以减少调试时所花费的时间。
10.10. 词汇表
字典: 包含键值对的对象,也叫做项。
项: 在字典中,键值对的另一种说法。
键: 在字典中,作为键值对的第一部分出现的对象。
值: 在字典中,作为键值对的第二部分出现的对象。这个概念比我们之前使用的“值”更为具体。
映射: 一种关系,其中一个集合的每个元素与另一个集合的元素相对应。
哈希表: 一种由键值对组成的集合,组织方式使得我们可以高效地查找键并找到其对应的值。
可哈希: 像整数、浮点数和字符串这样的不可变类型是可哈希的。像列表和字典这样的可变类型则不是。
哈希函数: 一个接受对象并计算出一个整数,用来在哈希表中定位键的函数。
累加器: 在循环中用于累加结果的变量。
过滤: 遍历一个序列并选择或省略元素。
调用图: 显示程序执行过程中每个帧的图示,图中从每个调用者指向每个被调用者。
备忘录: 为了避免不必要的未来计算,存储的计算结果。
10.11. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
10.11.1. 请求助手
在本章中,我提到字典中的键必须是可哈希的,并给出了简短的解释。如果你想了解更多细节,可以问虚拟助手:“为什么 Python 字典中的键必须是可哈希的?”
在前一节中,我们将一组单词存储为字典的键,以便能够使用更高效的in
运算符版本。我们也可以使用set
,这是另一种内建的数据类型,来完成同样的操作。你可以问虚拟助手:“如何从字符串列表创建一个 Python 集合,并检查一个字符串是否是集合的元素?”
10.11.2. 练习
字典有一个名为get
的方法,它接受一个键和一个默认值。如果键出现在字典中,get
返回对应的值;否则,它返回默认值。例如,这里有一个字典,将字符串中的字母映射到它们出现的次数。
counter = value_counts('brontosaurus')
如果我们查找一个出现在单词中的字母,get
方法会返回它出现的次数。
counter.get('b', 0)
1
如果我们查找一个没有出现的字母,我们会得到默认值0
。
counter.get('c', 0)
0
使用get
编写value_counts
的简洁版。你应该能够消除if
语句。
10.11.3. 练习
你能想到的最长的单词是什么?其中每个字母只出现一次。让我们看看能否找到一个比unpredictably
更长的单词。
编写一个名为has_duplicates
的函数,该函数接受一个序列(如列表或字符串)作为参数,并返回True
,如果序列中有任何元素出现超过一次。
10.11.4. 练习
编写一个名为find_repeats
的函数,它接受一个字典,该字典将每个键映射到一个计数器(类似value_counts
的结果)。该函数应遍历字典并返回一个包含计数大于1
的键的列表。你可以使用以下框架开始编写代码。
def find_repeats(counter):
"""Makes a list of keys with values greater than 1.
counter: dictionary that maps from keys to counts
returns: list of keys
"""
return []
10.11.5. 练习
假设你用两个不同的单词运行value_counts
并将结果保存在两个字典中。
counter1 = value_counts('brontosaurus')
counter2 = value_counts('apatosaurus')
每个字典都将一组字母映射到它们出现的次数。编写一个名为add_counters
的函数,该函数接受两个这样的字典,并返回一个新的字典,包含所有字母以及它们在两个单词中出现的总次数。
有许多方法可以解决这个问题。解决后,你可以考虑问虚拟助手提供其他不同的解决方案。
10.11.6. 练习
如果一个单词是“交错的”,那么我们可以通过交替取字母将其拆分为两个单词。例如,“schooled”是一个交错词,因为它可以拆分为“shoe”和“cold”。
要从一个字符串中选择交替字母,你可以使用切片操作符,它有三个组成部分:起始位置、结束位置和字母之间的“步长”。
在以下切片中,第一个组件是0
,所以我们从第一个字母开始。第二个组件是None
,这意味着我们应该一直选择到字符串的末尾。第三个组件是2
,因此我们选择的字母之间有两个步长。
word = 'schooled'
first = word[0:None:2]
first
'shoe'
我们可以通过完全省略第二个组件,而不是提供None
,来达到相同的效果。例如,下面的切片选择交替的字母,从第二个字母开始。
second = word[1::2]
second
'cold'
编写一个名为is_interlocking
的函数,它接受一个单词作为参数,如果该单词可以被拆分为两个交织的单词,则返回True
。
for word in word_list:
if len(word) >= 8 and is_interlocking(word):
first = word[0::2]
second = word[1::2]
print(word, first, second)
版权 2024 Allen B. Downey
代码许可证: MIT 许可证
文本许可证: 创意共享署名-非商业性使用-相同方式共享 4.0 国际版
11. 元组
本章介绍了另一个内建类型——元组,并展示了列表、字典和元组如何协同工作。它还介绍了元组赋值和一个对具有可变长度参数列表的函数非常有用的特性:打包和解包操作符。
在练习中,我们将使用元组以及列表和字典来解决更多的单词谜题,并实现高效的算法。
有一点需要注意:“tuple”有两种发音方式。有些人说“tuh-ple”,与“supple”押韵。但在编程的上下文中,大多数人说“too-ple”,与“quadruple”押韵。
11.1. 元组像列表一样
元组是一个值的序列。这些值可以是任何类型,并且按整数索引,因此元组与列表非常相似。重要的区别是元组是不可变的。
创建元组时,可以编写一个由逗号分隔的值列表。
t = 'l', 'u', 'p', 'i', 'n'
type(t)
tuple
尽管不是必须的,但通常会将元组括在圆括号中。
t = ('l', 'u', 'p', 'i', 'n')
type(t)
tuple
要创建一个包含单一元素的元组,必须包括一个结尾逗号。
t1 = 'p',
type(t1)
tuple
括号中的单个值不是元组。
t2 = ('p')
type(t2)
str
创建元组的另一种方法是使用内建函数tuple
。如果没有参数,它会创建一个空元组。
t = tuple()
t
()
如果参数是一个序列(字符串、列表或元组),则结果是一个包含序列元素的元组。
t = tuple('lupin')
t
('l', 'u', 'p', 'i', 'n')
因为tuple
是一个内建函数的名称,所以应该避免将其用作变量名。
大多数列表操作符也适用于元组。例如,方括号操作符可以索引一个元素。
t[0]
'l'
而切片操作符用于选择一系列元素。
t[1:3]
('u', 'p')
+
操作符用于连接元组。
tuple('lup') + ('i', 'n')
('l', 'u', 'p', 'i', 'n')
*
操作符将元组重复给定次数。
tuple('spam') * 2
('s', 'p', 'a', 'm', 's', 'p', 'a', 'm')
sorted
函数也适用于元组——但结果是一个列表,而不是元组。
sorted(t)
['i', 'l', 'n', 'p', 'u']
reversed
函数也可以用于元组。
reversed(t)
<reversed at 0x7f23a9a32b60>
结果是一个reversed
对象,我们可以将其转换为列表或元组。
tuple(reversed(t))
('n', 'i', 'p', 'u', 'l')
根据目前的例子,元组看起来可能与列表相同。
11.2. 但是元组是不可变的
如果你尝试使用方括号操作符修改元组,会得到一个TypeError
。
t[0] = 'L'
TypeError: 'tuple' object does not support item assignment
而且元组没有修改列表的任何方法,例如append
和remove
。
t.remove('l')
AttributeError: 'tuple' object has no attribute 'remove'
记住,“属性”是与对象相关联的变量或方法——这个错误信息意味着元组没有名为remove
的方法。
由于元组是不可变的,它们是可哈希的,这意味着它们可以用作字典中的键。例如,下面的字典包含两个作为键的元组,它们映射到整数值。
d = {}
d[1, 2] = 3
d[3, 4] = 7
我们可以像这样在字典中查找元组:
d[1, 2]
3
或者,如果我们有一个指向元组的变量,也可以将其作为键使用。
t = (3, 4)
d[t]
7
元组也可以作为字典中的值出现。
t = tuple('abc')
d = {'key': t}
d
{'key': ('a', 'b', 'c')}
11.3. 元组赋值
你可以在赋值的左边放一个变量元组,在右边放一个值元组。
a, b = 1, 2
值会从左到右赋给变量——在这个例子中,a
得到值1
,b
得到值2
。我们可以这样展示结果:
a, b
(1, 2)
更一般地说,如果赋值的左边是一个元组,右边可以是任何类型的序列——字符串、列表或元组。例如,要将电子邮件地址拆分成用户名和域名,你可以这样写:
email = 'monty@python.org'
username, domain = email.split('@')
split
的返回值是一个包含两个元素的列表——第一个元素赋给username
,第二个赋给domain
。
username, domain
('monty', 'python.org')
左边的变量数量和右边的值数量必须相同——否则会引发ValueError
。
a, b = 1, 2, 3
ValueError: too many values to unpack (expected 2)
如果你想交换两个变量的值,元组赋值非常有用。使用常规赋值时,你需要使用临时变量,像这样:
temp = a
a = b
b = temp
这样是可行的,但使用元组赋值我们可以在不使用临时变量的情况下完成相同的操作。
a, b = b, a
之所以可行,是因为右边的所有表达式会在任何赋值操作之前计算。
我们还可以在for
语句中使用元组赋值。例如,要遍历字典中的项,我们可以使用items
方法。
d = {'one': 1, 'two': 2}
for item in d.items():
key, value = item
print(key, '->', value)
one -> 1
two -> 2
每次循环时,item
会被赋值为一个包含键和对应值的元组。
我们可以像这样更简洁地写这个循环:
for key, value in d.items():
print(key, '->', value)
one -> 1
two -> 2
每次循环时,一个键和值会直接赋给key
和value
。
11.4. 元组作为返回值
严格来说,一个函数只能返回一个值,但如果这个值是一个元组,效果上就和返回多个值相同。例如,如果你想除以两个整数并计算商和余数,计算x//y
然后再计算x%y
效率不高。最好同时计算它们。
内置函数divmod
接受两个参数,返回一个包含商和余数的元组。
divmod(7, 3)
(2, 1)
我们可以使用元组赋值将元组中的元素存储在两个变量中。
quotient, remainder = divmod(7, 3)
quotient
2
remainder
1
下面是一个返回元组的函数示例。
def min_max(t):
return min(t), max(t)
max
和min
是内置函数,用于找到序列中最大的和最小的元素。min_max
同时计算并返回一个包含两个值的元组。
min_max([2, 4, 1, 3])
(1, 4)
我们可以像这样将结果赋给变量:
low, high = min_max([2, 4, 1, 3])
low, high
(1, 4)
11.5. 参数打包
函数可以接受可变数量的参数。以*
操作符开头的参数名称打包参数为元组。例如,下面的函数接受任意数量的参数并计算它们的算术平均值——也就是它们的和除以参数的数量。
def mean(*args):
return sum(args) / len(args)
参数可以有任何你喜欢的名字,但args
是惯用的。我们可以像这样调用函数:
mean(1, 2, 3)
2.0
如果你有一个值的序列,并且想将它们作为多个参数传递给一个函数,你可以使用*
操作符来解包元组。例如,divmod
函数需要两个精确的参数——如果你传递一个元组作为参数,就会报错。
t = (7, 3)
divmod(t)
TypeError: divmod expected 2 arguments, got 1
尽管元组包含两个元素,它仍然被视为一个单独的参数。但如果你解包元组,它会被当作两个参数来处理。
divmod(*t)
(2, 1)
打包和解包可以非常有用,特别是当你想调整一个现有函数的行为时。例如,这个函数接受任意数量的参数,去掉最低和最高的值,然后计算剩余部分的平均值。
def trimmed_mean(*args):
low, high = min_max(args)
trimmed = list(args)
trimmed.remove(low)
trimmed.remove(high)
return mean(*trimmed)
首先,它使用min_max
找到最低和最高的元素。然后,它将args
转换为列表,这样就可以使用remove
方法。最后,它解包这个列表,将元素作为单独的参数传递给mean
,而不是作为一个列表。
这里有一个例子展示了这种影响。
mean(1, 2, 3, 10)
4.0
trimmed_mean(1, 2, 3, 10)
2.5
这种“修剪过的”平均值在一些主观评分的体育项目中使用——比如跳水和体操——用来减少评分偏离其他裁判的影响。
11.6. Zip
元组在遍历两个序列的元素并对对应元素执行操作时非常有用。例如,假设两支队伍进行七场比赛,并且我们将它们的得分记录在两个列表中,每支队伍一个。
scores1 = [1, 2, 4, 5, 1, 5, 2]
scores2 = [5, 5, 2, 2, 5, 2, 3]
让我们看看每个队伍赢了多少场比赛。我们将使用zip
,这是一个内置函数,它接受两个或多个序列并返回一个zip 对象,因为它像拉链的齿一样将序列的元素配对在一起。
zip(scores1, scores2)
<zip at 0x7f23a9a7bdc0>
我们可以使用 zip 对象按对遍历序列中的值。
for pair in zip(scores1, scores2):
print(pair)
(1, 5)
(2, 5)
(4, 2)
(5, 2)
(1, 5)
(5, 2)
(2, 3)
每次进入循环时,pair
都会被赋值为一个包含分数的元组。因此,我们可以将分数赋值给变量,并统计第一支队伍的胜利场数,像这样:
wins = 0
for team1, team2 in zip(scores1, scores2):
if team1 > team2:
wins += 1
wins
3
可惜的是,第一支队伍只赢了三场比赛并输掉了系列赛。
如果你有两个列表,并且想要一个包含配对元素的列表,可以使用zip
和list
。
t = list(zip(scores1, scores2))
t
[(1, 5), (2, 5), (4, 2), (5, 2), (1, 5), (5, 2), (2, 3)]
结果是一个元组列表,因此我们可以像这样获取最后一场比赛的结果:
t[-1]
(2, 3)
如果你有一个键的列表和一个值的列表,可以使用zip
和dict
来创建一个字典。例如,下面是我们如何创建一个字典,将每个字母映射到它在字母表中的位置。
letters = 'abcdefghijklmnopqrstuvwxyz'
numbers = range(len(letters))
letter_map = dict(zip(letters, numbers))
现在我们可以查找一个字母并获取它在字母表中的索引。
letter_map['a'], letter_map['z']
(0, 25)
在这个映射中,'a'
的索引是0
,而'z'
的索引是25
。
如果你需要遍历一个序列的元素及其索引,可以使用内置函数enumerate
。
enumerate('abc')
<enumerate at 0x7f23a808afc0>
结果是一个enumerate 对象,它遍历一个由索引(从 0 开始)和给定序列中的元素组成的配对序列。
for index, element in enumerate('abc'):
print(index, element)
0 a
1 b
2 c
11.7. 比较和排序
关系运算符适用于元组和其他序列。例如,如果你用<
运算符比较元组,它会先比较每个序列中的第一个元素。如果它们相等,则继续比较第二对元素,以此类推,直到找到一对不同的元素。
(0, 1, 2) < (0, 3, 4)
True
后续的元素不会被考虑——即使它们真的很大。
(0, 1, 2000000) < (0, 3, 4)
True
这种比较元组的方式对于排序元组列表或找到最小值或最大值非常有用。作为示例,让我们找到一个单词中最常见的字母。在前一章中,我们编写了value_counts
,它接受一个字符串并返回一个字典,该字典将每个字母映射到其出现的次数。
def value_counts(string):
counter = {}
for letter in string:
if letter not in counter:
counter[letter] = 1
else:
counter[letter] += 1
return counter
这是字符串'banana'
的结果。
counter = value_counts('banana')
counter
{'b': 1, 'a': 3, 'n': 2}
只有三个项时,我们可以很容易地看到最常见的字母是'a'
,它出现了三次。但如果有更多项,自动排序将会非常有用。
我们可以像这样从counter
中获取项。
items = counter.items()
items
dict_items([('b', 1), ('a', 3), ('n', 2)])
结果是一个dict_items
对象,它表现得像一个元组列表,因此我们可以像这样对其进行排序。
sorted(items)
[('a', 3), ('b', 1), ('n', 2)]
默认行为是使用每个元组的第一个元素来排序列表,并使用第二个元素来解决相同的情况。
然而,为了找到出现次数最多的项,我们想要使用第二个元素对列表进行排序。我们可以通过编写一个函数来实现,该函数接受一个元组并返回第二个元素。
def second_element(t):
return t[1]
然后我们可以将该函数作为可选参数key
传递给sorted
,该参数表示此函数应用于计算每个项的排序关键字。
sorted_items = sorted(items, key=second_element)
sorted_items
[('b', 1), ('n', 2), ('a', 3)]
排序关键字决定了列表中项的顺序。出现次数最少的字母排在前面,出现次数最多的字母排在最后。因此,我们可以像这样找到最常见的字母。
sorted_items[-1]
('a', 3)
如果我们只需要最大值,我们就不必排序列表。我们可以使用max
,它也接受key
作为可选参数。
max(items, key=second_element)
('a', 3)
要找到出现次数最少的字母,我们可以用min
来进行相同的操作。
11.8. 反转字典
假设你想要反转一个字典,以便通过查找一个值来得到对应的键。例如,如果你有一个单词计数器,它将每个单词映射到该单词出现的次数,你可以创建一个字典,将整数映射到出现相应次数的单词。
但是有一个问题——字典中的键必须是唯一的,但值不一定是唯一的。例如,在一个单词计数器中,可能有许多单词的出现次数相同。
所以反转字典的一种方法是创建一个新字典,其中值是原字典中键的列表。作为示例,让我们统计parrot
中字母的出现次数。
d = value_counts('parrot')
d
{'p': 1, 'a': 1, 'r': 2, 'o': 1, 't': 1}
如果我们反转这个字典,结果应该是{1: ['p', 'a', 'o', 't'], 2: ['r']}
,这表示出现一次的字母是'p'
、'a'
、'o'
和't'
,出现两次的字母是'r'
。
以下函数接受一个字典并将其反转为一个新的字典。
def invert_dict(d):
new = {}
for key, value in d.items():
if value not in new:
new[value] = [key]
else:
new[value].append(key)
return new
for
语句遍历d
中的键和值。如果该值尚未在新字典中,则将其添加并与包含单个元素的列表相关联。否则,它将被追加到现有的列表中。
我们可以这样测试它:
invert_dict(d)
{1: ['p', 'a', 'o', 't'], 2: ['r']}
我们得到了预期的结果。
这是我们看到的第一个字典中的值是列表的例子。我们会看到更多类似的例子!
11.9. 调试
列表、字典和元组是数据结构。在本章中,我们开始看到复合数据结构,例如元组的列表,或者包含元组作为键和列表作为值的字典。复合数据结构很有用,但容易因数据结构的类型、大小或结构错误而导致错误。例如,如果一个函数期望一个整数列表,而你给它一个普通的整数(不是列表),它可能无法正常工作。
为了帮助调试这些错误,我编写了一个名为structshape
的模块,它提供了一个同名的函数,可以将任何类型的数据结构作为参数,并返回一个字符串来总结其结构。你可以从raw.githubusercontent.com/AllenDowney/ThinkPython/v3/structshape.py
下载它。
我们可以这样导入它。
from structshape import structshape
这是一个简单列表的例子。
t = [1, 2, 3]
structshape(t)
'list of 3 int'
这里有一个列表的列表。
t2 = [[1,2], [3,4], [5,6]]
structshape(t2)
'list of 3 list of 2 int'
如果列表中的元素类型不同,structshape
会按类型将它们分组。
t3 = [1, 2, 3, 4.0, '5', '6', [7], [8], 9]
structshape(t3)
'list of (3 int, float, 2 str, 2 list of int, int)'
这里有一个元组的列表。
s = 'abc'
lt = list(zip(t, s))
structshape(lt)
'list of 3 tuple of (int, str)'
这是一个包含三个项的字典,将整数映射到字符串。
d = dict(lt)
structshape(d)
'dict of 3 int->str'
如果你在跟踪数据结构时遇到困难,structshape
可以帮助你。
11.10. 术语表
打包: 将多个参数收集到一个元组中。
解包: 将元组(或其他序列)视为多个参数。
zip 对象: 调用内置函数zip
的结果,可以用来遍历一系列元组。
enumerate 对象: 调用内置函数enumerate
的结果,可以用来遍历一系列元组。
排序键: 用于排序集合元素的值或计算该值的函数。
数据结构: 一组有组织的值,用于高效地执行某些操作。
11.11. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
Exception reporting mode: Verbose
11.11.1. 向虚拟助手提问
本章中的练习可能比前几章的练习更难,因此我鼓励你向虚拟助手寻求帮助。当你提出更难的问题时,可能会发现答案第一次并不正确,这是一个练习编写良好提示并进行有效跟进的机会。
你可以考虑的一种策略是将一个大问题拆解成可以通过简单函数解决的小问题。让虚拟助手编写这些函数并测试它们。然后,一旦它们工作正常,再请求解决原始问题。
对于下面的一些练习,我会建议使用哪些数据结构和算法。你可能会发现这些建议在解决问题时有用,但它们也是传递给虚拟助手的良好提示。
11.11.2. 练习
在本章中,我提到过元组可以作为字典中的键,因为它们是可哈希的,而它们之所以可哈希,是因为它们是不可变的。但这并不总是正确的。
如果元组包含可变值,例如列表或字典,则该元组不再是可哈希的,因为它包含了不可哈希的元素。举个例子,下面是一个包含两个整数列表的元组。
list0 = [1, 2, 3]
list1 = [4, 5]
t = (list0, list1)
t
([1, 2, 3], [4, 5])
编写一行代码,将值6
附加到t
中第二个列表的末尾。如果你显示t
,结果应为([1, 2, 3], [4, 5, 6])
。
尝试创建一个将t
映射到字符串的字典,并确认你会遇到TypeError
。
d = {t: 'this tuple contains two lists'}
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[77], line 1
----> 1 d = {t: 'this tuple contains two lists'}
d = {1: 'a', 2: 'b', 3: 'c'}
t = ([1, 2, 3], [4, 5, 6])
TypeError: unhashable type: 'list'
更多关于此主题的内容,可以向虚拟助手询问:“Python 元组总是可哈希的吗?”
11.11.3. 练习
在本章中,我们创建了一个字典,将每个字母映射到它在字母表中的索引。
letters = 'abcdefghijklmnopqrstuvwxyz'
numbers = range(len(letters))
letter_map = dict(zip(letters, numbers))
例如,'a'
的索引是0
。
letter_map['a']
0
要朝另一个方向移动,我们可以使用列表索引。例如,索引1
处的字母是'b'
。
letters[1]
'b'
我们可以使用letter_map
和letters
来使用凯撒密码对单词进行编码和解码。
凯撒密码是一种弱加密形式,它通过将每个字母按固定的位移数移动来加密,如果有需要,可以绕回字母表的开头。例如,'a'
移动 2 位是'c'
,而'z'
移动 1 位是'a'
。
编写一个名为shift_word
的函数,接受一个字符串和一个整数作为参数,并返回一个新的字符串,其中的字母按给定的位移数移动。
为了测试你的函数,确认“cheer”移动 7 个位置后是“jolly”,而“melon”移动 16 个位置后是“cubed”。
提示:使用模运算符将字母从'z'
回绕到'a'
。循环遍历单词中的字母,移动每个字母,并将结果附加到字母列表中。然后使用join
将字母连接成一个字符串。
11.11.4. 练习
编写一个名为most_frequent_letters
的函数,该函数接受一个字符串并按频率递减顺序打印字母。
要按递减顺序获取项目,你可以使用reversed
与sorted
一起,或者你可以将reverse=True
作为关键字参数传递给sorted
。
11.11.5. 练习
在之前的练习中,我们通过对两个单词的字母进行排序并检查排序后的字母是否相同,来判断这两个字符串是否是字谜词。现在让我们让这个问题更具挑战性。
我们将编写一个程序,该程序接受一个单词列表并打印出所有字谜词组。以下是输出可能的示例:
['deltas', 'desalt', 'lasted', 'salted', 'slated', 'staled']
['retainers', 'ternaries']
['generating', 'greatening']
['resmelts', 'smelters', 'termless']
提示:对于单词列表中的每个单词,先将字母排序,再将其连接回一个字符串。创建一个字典,将这个排序后的字符串映射到与之为字谜词的单词列表。
11.11.6. 练习
编写一个名为word_distance
的函数,该函数接受两个相同长度的单词,并返回两个单词在多少个位置上有所不同。
提示:使用zip
函数来遍历单词中字母的对应位置。
11.11.7. 练习
“元音交换”(Metathesis)是指单词中字母的交换。如果你可以通过交换两个字母将一个单词转换成另一个单词,那么这两个单词就是“元音交换对”,例如converse
和conserve
。编写一个程序,找出单词列表中的所有元音交换对。
提示:互换对中的单词必须是彼此的字谜词。
致谢:此练习的灵感来源于puzzlers.org
上的一个示例。
版权 2024 Allen B. Downey
代码许可:MIT 许可证
文本许可:知识共享署名-非商业性使用-相同方式共享 4.0 国际
12. 文本分析与生成
此时我们已经涵盖了 Python 的核心数据结构——列表、字典和元组——以及一些使用它们的算法。在本章中,我们将利用它们来探索文本分析和马尔科夫生成:
-
文本分析是一种描述文档中单词之间统计关系的方法,比如一个单词后面跟着另一个单词的概率,以及
-
马尔科夫生成是一种使用与原始文本相似的单词和短语生成新文本的方法。
这些算法类似于大型语言模型(LLM)的部分内容,LLM 是聊天机器人的关键组成部分。
我们将从计算每个单词在书中出现的次数开始。然后我们将查看单词对,并列出每个单词后面可以跟随的单词。我们将制作一个简单版本的马尔科夫生成器,作为练习,你将有机会制作一个更通用的版本。
12.1. 唯一单词
作为文本分析的第一步,让我们阅读一本书——罗伯特·路易斯·史蒂文森的《化身博士》——并统计唯一单词的数量。下载这本书的说明可以在本章的笔记本中找到。
filename = 'dr_jekyll.txt'
我们将使用for
循环从文件中读取行,并使用split
将行分割成单词。然后,为了跟踪唯一单词,我们将每个单词作为字典中的一个键进行存储。
unique_words = {}
for line in open(filename):
seq = line.split()
for word in seq:
unique_words[word] = 1
len(unique_words)
6040
字典的长度是唯一单词的数量——按照这种计算方式大约是6000
。但如果我们检查它们,会发现有些并不是有效的单词。
例如,让我们看看unique_words
中最长的单词。我们可以使用sorted
来排序单词,将len
函数作为关键字参数传入,以便按单词长度排序。
sorted(unique_words, key=len)[-5:]
['chocolate-coloured',
'superiors—behold!”',
'coolness—frightened',
'gentleman—something',
'pocket-handkerchief.']
切片索引[-5:]
选择排序后列表中的最后5
个元素,即最长的单词。
这个列表包括一些合法的长单词,比如“circumscription”,以及一些带连字符的单词,比如“chocolate-coloured”。但一些最长的“单词”实际上是由连字符分隔的两个单词。而其他单词则包含像句号、感叹号和引号等标点符号。
所以,在我们继续之前,让我们处理一下连字符和其他标点符号。
12.2. 标点符号
为了识别文本中的单词,我们需要解决两个问题:
-
当行中出现连字符时,我们应该将其替换为空格——然后当我们使用
split
时,单词就会被分开。 -
分割单词后,我们可以使用
strip
来移除标点符号。
为了处理第一个问题,我们可以使用以下函数,它接受一个字符串,将连字符替换为空格,分割字符串,并返回结果列表。
def split_line(line):
return line.replace('—', ' ').split()
注意,split_line
只会替换连字符,而不会替换破折号。这里有一个例子。
split_line('coolness—frightened')
['coolness', 'frightened']
现在,为了去除每个单词开头和结尾的标点符号,我们可以使用 strip
,但是我们需要一个标点符号的字符列表。
Python 字符串中的字符使用 Unicode,这是一个国际标准,用于表示几乎所有字母表中的字母、数字、符号、标点符号等。unicodedata
模块提供了一个 category
函数,我们可以用它来判断字符是否为标点符号。给定一个字母,它会返回一个字符串,指示该字母属于哪个类别。
import unicodedata
unicodedata.category('A')
'Lu'
字符 'A'
的类别字符串是 'Lu'
—— 'L'
表示它是一个字母,'u'
表示它是大写字母。
字符 '.'
的类别字符串是 'Po'
—— 'P'
表示它是标点符号,'o'
表示它的子类别是“其他”。
unicodedata.category('.')
'Po'
我们可以通过检查类别以 'P'
开头的字符,来找出书中的标点符号。下面的循环将唯一的标点符号存储在字典中。
punc_marks = {}
for line in open(filename):
for char in line:
category = unicodedata.category(char)
if category.startswith('P'):
punc_marks[char] = 1
为了制作一个标点符号的列表,我们可以将字典的键连接成一个字符串。
punctuation = ''.join(punc_marks)
print(punctuation)
.’;,-“”:?—‘!()_
现在我们知道书中哪些字符是标点符号,我们可以编写一个函数,接受一个单词,去除开头和结尾的标点符号,并将其转换为小写。
def clean_word(word):
return word.strip(punctuation).lower()
这是一个示例。
clean_word('“Behold!”')
'behold'
因为 strip
会删除字符串开头和结尾的字符,所以它不会影响带有连字符的单词。
clean_word('pocket-handkerchief')
'pocket-handkerchief'
现在,这是一个使用 split_line
和 clean_word
来识别书中唯一单词的循环。
unique_words2 = {}
for line in open(filename):
for word in split_line(line):
word = clean_word(word)
unique_words2[word] = 1
len(unique_words2)
4005
根据对单词定义的严格标准,约有 4000 个唯一单词。我们可以确认最长单词的列表已经清理干净。
sorted(unique_words2, key=len)[-5:]
['circumscription',
'unimpressionable',
'fellow-creatures',
'chocolate-coloured',
'pocket-handkerchief']
现在让我们看看每个单词的使用频率。
12.3. 单词频率
以下循环计算每个唯一单词的频率。
word_counter = {}
for line in open(filename):
for word in split_line(line):
word = clean_word(word)
if word not in word_counter:
word_counter[word] = 1
else:
word_counter[word] += 1
每当我们第一次遇到一个单词时,我们将其频率初始化为 1
。如果之后再遇到相同的单词,我们就将其频率加一。
为了查看哪些单词最常出现,我们可以使用 items
从 word_counter
获取键值对,并按对中的第二个元素(即频率)进行排序。首先,我们将定义一个函数来选择第二个元素。
def second_element(t):
return t[1]
现在我们可以使用 sorted
和两个关键字参数:
-
key=second_element
表示项目将根据单词的频率进行排序。 -
reverse=True
表示项目将按反向顺序排序,最频繁的单词排在最前面。
items = sorted(word_counter.items(), key=second_element, reverse=True)
这里是五个最常见的单词。
for word, freq in items[:5]:
print(freq, word, sep='\t')
1614 the
972 and
941 of
640 to
640 i
在接下来的部分,我们将把这个循环封装在一个函数中。我们还将用它来演示一个新功能——可选参数。
12.4. 可选参数
我们已经使用了带有可选参数的内置函数。例如,round
有一个名为 ndigits
的可选参数,用于指示保留多少位小数。
round(3.141592653589793, ndigits=3)
3.142
但这不仅仅是内置函数——我们也可以编写带有可选参数的函数。例如,下面的函数接受两个参数,word_counter
和 num
。
def print_most_common(word_counter, num=5):
items = sorted(word_counter.items(), key=second_element, reverse=True)
for word, freq in items[:num]:
print(freq, word, sep='\t')
第二个参数看起来像一个赋值语句,但其实它不是——它是一个可选参数。
如果你用一个参数调用这个函数,num
将获得默认值,即5
。
print_most_common(word_counter)
1614 the
972 and
941 of
640 to
640 i
如果你用两个参数调用这个函数,第二个参数将被赋值给num
,而不是默认值。
print_most_common(word_counter, 3)
1614 the
972 and
941 of
在这种情况下,我们可以说可选参数覆盖了默认值。
如果一个函数既有必需参数又有可选参数,所有必需的参数必须排在前面,后面跟着可选参数。
12.5. 字典减法
假设我们想进行拼写检查——也就是说,找出可能拼写错误的单词列表。做这个的方法之一是找出书中那些不在有效单词列表中的单词。在之前的章节中,我们使用了一个在类似拼字游戏(如拼字游戏)中被认为有效的单词列表。现在我们将使用这个列表来进行罗伯特·路易斯·史蒂文森的拼写检查。
我们可以将这个问题看作集合减法——也就是说,我们想找出一个集合(书中的单词)中不在另一个集合(列表中的单词)中的所有单词。
正如我们之前做过的,我们可以读取words.txt
的内容,并将其分割成一个字符串列表。
word_list = open('words.txt').read().split()
然后我们将把单词作为键存储在字典中,以便我们可以使用in
运算符快速检查一个单词是否有效。
valid_words = {}
for word in word_list:
valid_words[word] = 1
现在,为了识别书中出现但不在单词列表中的单词,我们将使用subtract
,它接受两个字典作为参数,并返回一个新的字典,其中包含第一个字典中不在第二个字典中的所有键。
def subtract(d1, d2):
res = {}
for key in d1:
if key not in d2:
res[key] = d1[key]
return res
下面是我们如何使用它的方法。
diff = subtract(word_counter, valid_words)
要获取可能拼写错误的单词样本,我们可以打印出diff
中最常见的单词。
print_most_common(diff)
640 i
628 a
128 utterson
124 mr
98 hyde
最常见的“拼写错误”单词大多是人名和一些单字母的单词(乌特森先生是杰基尔博士的朋友和律师)。
如果我们选择那些只出现一次的单词,它们更有可能是拼写错误。我们可以通过遍历项目,并列出频率为1
的单词来做到这一点。
singletons = []
for word, freq in diff.items():
if freq == 1:
singletons.append(word)
这是列表中的最后几个元素。
singletons[-5:]
['gesticulated', 'abjection', 'circumscription', 'reindue', 'fearstruck']
它们中的大多数是有效的单词,但不在单词列表中。不过,'reindue'
似乎是'reinduce'
的拼写错误,所以至少我们发现了一个真正的错误。
12.6. 随机数
作为迈向马尔科夫文本生成的一步,接下来我们将从word_counter
中选择一个随机的单词序列。但首先,让我们谈谈随机性。
给定相同的输入,大多数计算机程序是确定性的,这意味着它们每次生成相同的输出。确定性通常是好事,因为我们希望相同的计算产生相同的结果。然而,对于某些应用程序,我们希望计算机能够不可预测。游戏就是一个例子,但还有更多。
让程序真正做到非确定性是很困难的,但有一些方法可以伪装成非确定性。一个方法是使用生成伪随机数的算法。伪随机数并不是真正的随机数,因为它们是通过确定性计算生成的,但仅凭查看这些数字,几乎无法将它们与真正的随机数区分开。
random
模块提供了生成伪随机数的函数——我将在这里简单地称其为“随机数”。我们可以这样导入它。
import random
random
模块提供了一个名为 choice
的函数,可以从列表中随机选择一个元素,每个元素被选中的概率相同。
t = [1, 2, 3]
random.choice(t)
1
如果你再次调用该函数,你可能会得到相同的元素,或者得到不同的元素。
random.choice(t)
2
从长远来看,我们希望每个元素出现的次数大致相同。
如果你用字典调用 choice
,你会得到一个 KeyError
。
random.choice(word_counter)
KeyError: 422
要选择一个随机键,你必须先将键放入列表中,然后调用 choice
。
words = list(word_counter)
random.choice(words)
'posture'
如果我们生成一个随机的单词序列,它没有太大的意义。
for i in range(6):
word = random.choice(words)
print(word, end=' ')
ill-contained written apocryphal nor busy spoke
问题的一部分是我们没有考虑到某些单词比其他单词更常见。如果我们选择具有不同“权重”的单词,结果会更好,这样有些单词会比其他单词更频繁地被选中。
如果我们使用来自 word_counter
的值作为权重,每个单词的选择概率将取决于它的频率。
weights = word_counter.values()
random
模块还提供了另一个名为 choices
的函数,接受权重作为一个可选参数。
random.choices(words, weights=weights)
['than']
它还接受另一个可选参数 k
,该参数指定要选择的单词数。
random_words = random.choices(words, weights=weights, k=6)
random_words
['reach', 'streets', 'edward', 'a', 'said', 'to']
结果是一个字符串的列表,我们可以将其连接成更像句子的东西。
' '.join(random_words)
'reach streets edward a said to'
如果你从书中随机选择单词,你可以感知到词汇量,但一系列随机单词通常没有意义,因为连续单词之间没有关系。例如,在一个真实的句子中,你期望像“the”这样的冠词后面跟着形容词或名词,而可能不是动词或副词。所以,下一步是查看单词之间的这些关系。
12.7. 二元组
我们将不再逐个单词查看,而是查看由两个单词组成的序列,这叫做二元组。由三个单词组成的序列叫做三元组,由若干个单词组成的序列叫做n-元组。
我们来写一个程序,找出书中的所有二元组以及每个二元组出现的次数。为了存储结果,我们将使用一个字典,其中
-
键是表示二元组的大写字母字符串的元组,
-
这些值是表示频率的整数。
我们称之为 bigram_counter
。
bigram_counter = {}
以下函数接受两个字符串组成的列表作为参数。首先,它将这两个字符串组成一个元组,可以作为字典中的键。然后,如果该键不存在,它会将其添加到 bigram_counter
中;如果已经存在,它会增加该键的频率。
def count_bigram(bigram):
key = tuple(bigram)
if key not in bigram_counter:
bigram_counter[key] = 1
else:
bigram_counter[key] += 1
在阅读本书的过程中,我们必须跟踪每一对连续的单词。因此,如果我们看到“man is not truly one”这一序列,我们将添加“大词组”:“man is”,“is not”,“not truly”等等。
为了跟踪这些大词组,我们将使用一个名为window
的列表,因为它就像一本书的窗口,一次只显示两个单词。最初,window
是空的。
window = []
我们将使用以下函数逐个处理单词。
def process_word(word):
window.append(word)
if len(window) == 2:
count_bigram(window)
window.pop(0)
当第一次调用此函数时,它会将给定的单词附加到window
中。由于窗口中只有一个单词,我们还没有形成一个大词组,因此函数结束。
第二次调用时——以及以后每次调用——它会将第二个单词添加到window
中。由于窗口中有两个单词,它会调用count_bigram
来跟踪每个大词组出现的次数。然后,它使用pop
移除窗口中的第一个单词。
以下程序循环遍历书中的单词并逐一处理它们。
for line in open(filename):
for word in split_line(line):
word = clean_word(word)
process_word(word)
结果是一个字典,将每个大词组映射到它出现的次数。我们可以使用print_most_common
来查看最常见的大词组。
print_most_common(bigram_counter)
178 ('of', 'the')
139 ('in', 'the')
94 ('it', 'was')
80 ('and', 'the')
73 ('to', 'the')
看着这些结果,我们可以感知哪些单词对最可能一起出现。我们还可以利用这些结果生成随机文本,像这样。
bigrams = list(bigram_counter)
weights = bigram_counter.values()
random_bigrams = random.choices(bigrams, weights=weights, k=6)
bigrams
是书中出现的大词组列表。weights
是它们的频率列表,因此random_bigrams
是一个样本,其中大词组被选中的概率与它的频率成正比。
这是结果。
for pair in random_bigrams:
print(' '.join(pair), end=' ')
to suggest this preface to detain fact is above all the laboratory
这种生成文本的方式比选择随机单词要好,但仍然没有太多意义。
12.8. 马尔科夫分析
我们可以通过马尔科夫链文本分析做得更好,它为文本中的每个单词计算接下来会出现的单词列表。作为示例,我们将分析《蒙提·派森》歌曲 Eric, the Half a Bee 的歌词:
song = """
Half a bee, philosophically,
Must, ipso facto, half not be.
But half the bee has got to be
Vis a vis, its entity. D'you see?
"""
为了存储结果,我们将使用一个字典,它将每个单词映射到跟随它的单词列表。
successor_map = {}
举个例子,让我们从歌曲的前两个单词开始。
first = 'half'
second = 'a'
如果第一个单词不在successor_map
中,我们必须添加一个新项,将第一个单词映射到包含第二个单词的列表。
successor_map[first] = [second]
successor_map
{'half': ['a']}
如果第一个单词已经在字典中,我们可以查找它,获取我们到目前为止看到的后继单词列表,并附加新的单词。
first = 'half'
second = 'not'
successor_map[first].append(second)
successor_map
{'half': ['a', 'not']}
以下函数封装了这些步骤。
def add_bigram(bigram):
first, second = bigram
if first not in successor_map:
successor_map[first] = [second]
else:
successor_map[first].append(second)
如果相同的大词组出现多次,第二个单词会被多次添加到列表中。通过这种方式,successor_map
会跟踪每个后继单词出现的次数。
正如我们在前一节中所做的,我们将使用一个名为window
的列表来存储连续单词对。我们将使用以下函数逐个处理单词。
def process_word_bigram(word):
window.append(word)
if len(window) == 2:
add_bigram(window)
window.pop(0)
这是我们如何用它来处理歌曲中的单词。
successor_map = {}
window = []
for word in song.split():
word = clean_word(word)
process_word_bigram(word)
这是结果。
successor_map
{'half': ['a', 'not', 'the'],
'a': ['bee', 'vis'],
'bee': ['philosophically', 'has'],
'philosophically': ['must'],
'must': ['ipso'],
'ipso': ['facto'],
'facto': ['half'],
'not': ['be'],
'be': ['but', 'vis'],
'but': ['half'],
'the': ['bee'],
'has': ['got'],
'got': ['to'],
'to': ['be'],
'vis': ['a', 'its'],
'its': ['entity'],
'entity': ["d'you"],
"d'you": ['see']}
单词'half'
可以跟随'a'
、'not'
或'the'
。单词'a'
可以跟随'bee'
或'vis'
。大多数其他单词只出现一次,因此它们只跟随一个单词。
现在,让我们分析这本书。
successor_map = {}
window = []
for line in open(filename):
for word in split_line(line):
word = clean_word(word)
process_word_bigram(word)
我们可以查找任何单词,并找到可以跟随它的单词。
successor_map['going']
['east', 'in', 'to', 'to', 'up', 'to', 'of']
在这个后继列表中,请注意单词'to'
出现了三次,而其他后继只出现了一次。
12.9. 生成文本
我们可以使用前一部分的结果,生成与原文中连续单词之间关系相同的新文本。它是如何工作的:
-
从文本中出现的任何一个单词开始,我们查找它的可能后继,并随机选择一个。
-
然后,使用选中的单词,我们查找它的可能后继,并随机选择一个。
我们可以重复这个过程,生成我们想要的任意多个单词。举个例子,让我们从单词'although'
开始。以下是可以跟随它的单词。
word = 'although'
successors = successor_map[word]
successors
['i', 'a', 'it', 'the', 'we', 'they', 'i']
我们可以使用choice
从列表中以相等的概率选择。
word = random.choice(successors)
word
'i'
如果同一个单词在列表中出现多次,它被选中的概率更大。
重复这些步骤,我们可以使用以下循环生成更长的序列。
for i in range(10):
successors = successor_map[word]
word = random.choice(successors)
print(word, end=' ')
continue to hesitate and swallowed the smile withered from that
结果听起来更像一个真实的句子,但它仍然没有太大意义。
使用多个单词作为successor_map
中的键,我们可以做得更好。例如,我们可以创建一个字典,将每个二元组(bigram)或三元组(trigram)映射到后续单词的列表。作为一个练习,你将有机会实现这个分析,并查看结果是什么样的。
12.10. 调试
到这个阶段,我们正在编写更复杂的程序,你可能会发现你花更多时间进行调试。如果你在调试一个难题时卡住了,下面是一些可以尝试的办法:
-
阅读:检查你的代码,自己读一遍,确认它是否表达了你想表达的意思。
-
运行:通过进行更改并运行不同的版本进行实验。通常,如果你在程序中的正确位置显示正确的内容,问题会变得明显,但有时你需要构建一些支架。
-
沉思:花点时间思考一下!这是哪种错误:语法错误、运行时错误,还是语义错误?你能从错误信息或者程序的输出中获取哪些信息?是什么样的错误可能导致你看到的问题?在问题出现之前,你最后修改了什么?
-
橡皮鸭调试:如果你将问题解释给别人听,你有时会在还没问完问题之前就找到答案。通常你不需要另一个人;你可以只和一只橡皮鸭交谈。这就是著名的策略——橡皮鸭调试的来源。我不是在编造这个——请看
en.wikipedia.org/wiki/Rubber_duck_debugging
。 -
后退:在某些时候,最好的做法是后退——撤销最近的更改——直到你回到一个正常工作的程序。然后你可以重新开始构建。
-
休息:如果你给大脑休息,有时候它会自己找到问题所在。
初学者程序员有时会在某个活动上卡住,忘记其他的活动。每个活动都有它自己的失败模式。
例如,读取你的代码如果问题是拼写错误的话是有效的,但如果问题是概念性误解,则无效。如果你不理解你的程序是做什么的,即使你读它 100 遍也看不出错误,因为错误在你的脑海里。
运行实验是有效的,特别是当你运行小的、简单的测试时。但如果你没有思考或阅读代码就去实验,可能会花费很长时间才能搞清楚发生了什么。
你必须抽时间思考。调试就像是实验科学。你应该对问题至少有一个假设。如果有两个或更多的可能性,尝试想出一个可以排除其中一个的测试。
但即使是最好的调试技术,也会因为错误太多,或者你试图修复的代码过于庞大复杂而失败。有时候最好的选择是退一步,简化程序,直到恢复到一个能正常工作的版本。
初学者程序员通常不愿意后退,因为他们无法忍受删除一行代码(即使它是错的)。如果这样能让你感觉好一点,可以在开始简化程序之前,把你的程序复制到另一个文件中。然后你可以逐个复制回来。
找到一个棘手的 bug 需要阅读、运行、沉思、退步,有时候还需要休息。如果你在某个活动中遇到困难,尝试其他的方法。
12.11. 词汇表
默认值(default value): 如果没有提供参数,将赋给参数的值。
覆盖(override): 用一个参数替换默认值。
确定性(deterministic): 一个确定性的程序每次运行时,只要输入相同,就会做相同的事情。
伪随机(pseudorandom): 伪随机数列看起来像是随机的,但它是由一个确定性的程序生成的。
二元组(bigram): 一个包含两个元素的序列,通常是单词。
三元组(trigram): 一个包含三个元素的序列。
n 元组(n-gram): 一个包含不确定数量元素的序列。
橡皮鸭调试(rubber duck debugging): 通过大声向一个无生命物体解释问题来进行调试的一种方法。
12.12. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
12.12.1. 向虚拟助手询问
在add_bigram
中,if
语句根据字典中是否已存在该键,来创建一个新的列表或将元素添加到现有列表中。
def add_bigram(bigram):
first, second = bigram
if first not in successor_map:
successor_map[first] = [second]
else:
successor_map[first].append(second)
字典提供了一种叫做setdefault
的方法,我们可以用它更简洁地做同样的事情。你可以向虚拟助手询问它是如何工作的,或者把add_word
复制到虚拟助手中并问:“你能用setdefault
重写这个吗?”
在本章中,我们实现了马尔科夫链文本分析和生成。如果你感兴趣,可以向虚拟助手询问更多关于该主题的信息。你可能学到的一件事是,虚拟助手使用的算法在许多方面是相似的——但在重要方面也有所不同。问一个虚拟助手:“像 GPT 这样的语言模型和马尔科夫链文本分析有什么区别?”
12.12.2. 练习
编写一个函数,计算每个三元组(由三个单词组成的序列)出现的次数。如果你使用《化身博士》的文本来测试你的函数,你应该会发现最常见的三元组是“said the lawyer”。
提示:编写一个名为count_trigram
的函数,它类似于count_bigram
。然后编写一个名为process_word_trigram
的函数,它类似于process_word_bigram
。
12.12.3. 练习
现在让我们通过从每个二元组映射到可能的后继词列表来实现马尔科夫链文本分析。
从add_bigram
开始,编写一个名为add_trigram
的函数,该函数接收一个包含三个单词的列表,并使用前两个单词作为键,第三个单词作为可能的后继词,在successor_map
中添加或更新一个条目。
这是一个调用add_trigram
的process_word_trigram
版本。
def process_word_trigram(word):
window.append(word)
if len(window) == 3:
add_trigram(window)
window.pop(0)
你可以使用以下循环来测试你的函数,使用来自书本的单词。
successor_map = {}
window = []
for line in open(filename):
for word in split_line(line):
word = clean_word(word)
process_word_trigram(word)
在下一个练习中,你将使用这些结果生成新的随机文本。
12.12.4. 练习
对于这个练习,我们假设successor_map
是一个字典,它将每个二元组映射到后继词的列表。
为了生成随机文本,我们将从successor_map
中随机选择一个键。
successors = list(successor_map)
bigram = random.choice(successors)
bigram
('doubted', 'if')
现在编写一个循环,按照这些步骤生成更多的 50 个单词:
-
在
successor_map
中查找可以跟随bigram
的单词列表。 -
随机选择其中一个并打印出来。
-
对于下一个迭代,创建一个新的二元组,该二元组包含
bigram
中的第二个单词和所选的后继词。
例如,如果我们从二元组('doubted', 'if')
开始,并选择'from'
作为其后继词,则下一个二元组是('if', 'from')
。
如果一切正常,你应该会发现生成的文本在风格上与原文相似,一些短语是有意义的,但文本可能会从一个话题跳到另一个话题。
作为附加练习,修改你对最后两个练习的解决方案,使用三元组作为successor_map
中的键,看看它对结果产生了什么影响。
版权所有 2024 Allen B. Downey
代码许可证:MIT 许可证