原文:zh.annas-archive.org/md5/8f93267a61af3c28aa765d9c5f3f38e8

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:制作一些游戏

本章涵盖

  • 在我们的程序中添加随机性

  • 设计和编程一个密码破解逻辑游戏

  • 设计和编程一个“运气”掷骰子游戏

人们学习编程的原因有很多。有些人想要自动化我们在上一章中做的繁琐任务。有些人想要与人工智能(AI;你将在第十一章中尝到一些)一起工作。其他人想要制作交互式网站、Android 或 iOS 应用,或者 Alexa 技能。程序员可以制作的东西是无穷无尽的。

另一个学习编程的流行原因是创建游戏。因此,我们认为我们应该继续我们的 Copilot 编程之旅,与你一起设计两个小型电脑游戏。第一个是一个密码破解游戏,你使用线索来识别电脑的秘密代码。第二个是一个两人掷骰子游戏,每个玩家都需要平衡风险和运气,在另一个玩家之前达到所需的分数。这些游戏不使用图形和动画,而是使用文本。我们做出这个决定是为了帮助我们专注于游戏逻辑,而不是游戏的表现方式或玩家与游戏交互的方式。在这个过程中,如果你对进一步提高你的游戏制作能力感兴趣,我们会提供一些下一步的建议。而且不用担心,你现在的技能是迈向那个目标的良好开端!

10.1 游戏程序

如果你考虑与家人或朋友玩桌面游戏,你可以将发生的事情分解为两个主要阶段。第一个阶段是游戏设置。这包括设置游戏板,给每个玩家提供起始资金或卡片等。第二个阶段是游戏进行。在桌面游戏中,游戏通常包括一个人轮流,假设还没有人获胜,另一个人轮流。随着每一轮的进行,游戏状态(板,玩家的资金等)都会发生变化。在编程电脑游戏时,我们需要注意实现这些阶段中的每一个。

在许多针对视频游戏设计的编程环境中,这两个阶段通常被作为独立的常规函数。有一个设置函数用于设置游戏,还有一个更新函数用于改变游戏状态,这可能是因为玩家采取了行动,或者是因为时间已经过去。图 10.1 显示了视频游戏的基本流程。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/10-1.png

图 10.1 大多数视频游戏的基本流程

让我们暂时关注一下游戏的更新部分。假设玩家可以按键盘上的按钮在 2D 游戏中向前移动。更新函数将使玩家的角色向前移动,并检查玩家的移动是否使玩家与游戏世界中的某个物体发生了交互。一些游戏也会根据时间改变状态,而不管玩家的动作如何。在这些游戏中,更新函数也会更新游戏本身的状态(例如,在 Pac-Man 游戏中移动幽灵)。在每个时间步或用户动作结束时,游戏将检查结束条件。结束条件通常包括以下内容:

  • 玩家通过完成目标赢得了游戏。

  • 玩家因从悬崖上掉下来、撞到怪物等原因输掉了游戏。

  • 玩家选择退出游戏。

有许多编程环境针对游戏设计,我们推荐 Python 的 pygame 模块 [1] 作为良好的起点。如果您继续追求游戏设计,我们建议您查看 Unity [2] 以进行 3D 游戏设计。然而,在本章中,我们将仅使用标准 Python 来向您展示如何设计一个基本游戏,而无需深入这些新环境。

10.2 添加随机性

许多游戏,包括我们在本章中将要构建的两个游戏,都依赖于随机性来使它们变得有趣。在我们的第一个游戏中,我们需要计算机生成我们将要推断的数字的随机数字。在第二个游戏中,我们需要掷骰子,这仅仅相当于生成介于 1 和 6 之间的随机数。敌人的行为、谜题的初始方向或攻击造成的伤害,即随机性,通常涉及游戏设计的所有这些和其他许多方面。

因此,我们需要能够在我们的 Python 程序中生成随机数。为了了解如何做到这一点,让我们使用 Copilot Chat,就像我们在上一章中所做的那样。

LPHuman 我需要在我的 Python 程序中生成随机数。我该如何操作?

**https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 您可以使用 Python 中的 random 模块来生成随机数。以下是一个示例:

*````*```pypython*````*

*`*import random*`*

*`*# Generate a random integer between 1 and 10 (inclusive)*`*

*`*random_number = random.randint(1, 10)*`*

*`*print(random_number)*`*

*`*# Generate a random float between 0 and 1*`*

*`*random_float = random.random()*`*

*`*print(random_float)*`*

*```py`*```*```py`*

此代码导入 random 模块,然后使用 randint 函数生成一个介于 1 和 10(包含)之间的随机整数,并使用 random 函数生成一个介于 0 和 1 之间的随机浮点数。

Copilot 建议使用名为 random 的模块中的函数来生成随机数。让我们检查一下 random 模块是否是 Python 内置的。

LPHuman 这个随机模块是 Python 内置的吗?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 是的,random 模块是 Python 的一个内置模块,所以你不需要安装任何额外的模块来使用它。你只需在你的 Python 代码中导入它,然后开始使用它的函数。

我们将遵循 Copilot 的建议导入这个模块来测试我们刚刚学到的两个函数。我们将在 Python >>>提示符下这样做,这是我们之前章节用来探索 Python 功能的地方。要进入 Python 提示符,请按 Ctrl-Shift-P,输入REPL,然后选择 Python: Start REPL。

根据 Copilot 对我们第一个问题的回答,我们知道我们可以使用randint函数在从最低允许数值到最高允许数值的范围内生成一个随机整数。我们可能会使用这个函数来生成可以组合成更大数字的单独的数字。每个数字在 0 到 9 之间,因此我们将请求randint函数生成 0 到 9 之间的随机数:

>>> import random
>>> random.randint(0, 9)
5
>>> random.randint(0, 9)
1
>>> random.randint(0, 9)
9                            #1
>>> random.randint(0, 9)
9
>>> random.randint(0, 9)
5
>>> random.randint(0, 9)
0                           #2
>>> random.randint(0, 9)
4

#1 我们恰好得到了最大值。

#2 我们恰好得到了最小值。

或者,我们可能想要生成骰子的点数。每次投掷的点数在 1 到 6 之间,因此我们可以请求randint函数生成 1 到 6 之间的随机数:

>>> random.randint(1, 6)
2
>>> random.randint(1, 6)
2
>>> random.randint(1, 6)
4
>>> random.randint(1, 6)
1
>>> random.randint(1, 6)
5

Copilot 还告诉我们另一个函数叫做random。(是的,模块和这个函数都叫做random!所以,我们需要使用random.random()来调用这个函数。)这个函数不会生成随机整数;相反,它生成介于 0 和 1 之间(不包括 1)的随机分数数。例如,你可能会得到一个像 0.1926502 这样的随机数。这类带有小数的数字被称为浮点数(或浮点数)。以下是这个函数的一些调用示例:

>>> random.random()
0.03853937835258148
>>> random.random()
0.44152027974631813
>>> random.random()
0.774000627219771
>>> random.random()
0.4388949032154501

我们可以想象这个函数在游戏中也很有用。例如,你可以将这些浮点值视为事件发生的概率,数值越高表示概率越高。然后你可以使用这些浮点值来确定事件是否应该发生。然而,在本章的游戏中,我们不需要这个函数。

10.3 示例 1:猜数字游戏

我们的第一个游戏将基于一个古老的密码破解游戏,叫做猜数字游戏。这可能会让你想起 Wordle 游戏(但如果你之前没有玩过 Wordle 也不要担心)。我们将能够与电脑玩这个游戏。在这个游戏中,随机性扮演着至关重要的角色,我们将看到。

*### 10.3.1 游戏的工作原理

在这个游戏中,玩家 1 想出一个秘密代码,这是一个由四个数字组成的序列。玩家 2 必须猜出这个秘密代码是什么。在我们的游戏中,电脑将是玩家 1,而人类玩家将是玩家 2。

这就是它的工作方式。电脑将随机选择四个不同的数字(不允许重复数字)作为秘密代码。例如,它可能会选择数字 1862。然后,你将猜测你认为电脑的四个数字是什么。例如,你可能会猜测 3821。

对于每个猜测,您会得到两个信息。首先,您会被告知您的猜测中有多少位数字与秘密代码中相应位置完全匹配。我们将把在秘密代码中正确位置的数字称为“正确的”。比如说秘密代码是 1862,而您的猜测是 3821。您的猜测和秘密代码的第二位数字都是 8,所以这是一个匹配。没有其他匹配,所以您会被告知这个猜测中正确的数字数量是 1。

其次,您会被告知您的猜测中有多少位数字在秘密代码中的某个其他位置存在。我们将把在秘密代码中但位置不同的数字称为“错位的”。再次以 1862 作为秘密代码,3821 作为您的猜测。您的猜测中的第三位数字是 2。它不匹配秘密代码中的第三位数字(那是 6),但在秘密代码的某个其他位置有一个 2。同样,您的猜测中的第四位数字是 1。它不匹配秘密代码中的第四位数字,但在秘密代码的某个其他位置有一个 1。总的来说,您的两个数字(1 和 2)存在于秘密代码中,尽管它们没有匹配它们预期的位置。您会从这个猜测中得知错位的数字数量是 2。您可以使用这些线索来缩小秘密代码可能是什么的范围。

Wordle

如果您之前玩过 Wordle,您可能会注意到 Wordle 和我们这里的游戏之间的一些相似之处。Wordle 使用字母,而我们使用数字,但您对猜测的反馈类型是相似的。在两种情况下,您都会被告知关于字母或数字在正确或错误位置的信息。在 Wordle 中,您会得到关于您每个字母的线索。例如,如果您的猜测的第一个字母是 h,您可能会被告知 h 在单词中,但位置不正确。相比之下,在我们的游戏中,您不会得到关于每个数字的单独线索,而是会得到关于您的猜测的整体线索。尽管如此,我们希望您会被这些相似之处以及您正在构建的东西与最近全球现象的游戏相似之处所吸引!

我们找到了一个免费的“猜数字”游戏版本,您可以在 www.mathsisfun.com/games/bulls-and-cows.xhtml 上玩。我们建议您在继续之前玩几轮游戏,这样游戏的工作方式就能在您脑海中变得非常清晰。(注意,他们使用“bulls”而不是“correct”,使用“cows”而不是“misplaced”。)

在表 10.1 中,我们提供了一个与游戏的交互示例。我们包括了一个注释列,以传达我们的思考和从每个猜测中学到的知识。

表 10.1 游戏示例
猜测 错位 正确 注释
0123 1 0 答案中包含 0、1、2、3 中的一个数字;没有数字在正确的位置。
4567 3 0 答案中包含 4、5、6、7 中的 3 个数字;没有数字在正确的位置。
9045 0 1 因为 0123 中的一个数字和 4567 中的三个数字在答案中,我们知道 8 和 9 不在答案中。我们知道至少有一个数字 4 或 5 必须来自之前的猜测,而 0 可能在答案中。一个正确意味着 4 或 5 在正确的位置,或者 4 或 5 不在解决方案中,而 0 不在解决方案中。
9048 0 0 从之前的猜测中,我们知道 8、9 和 0 不在答案中。零个正确和零个放错告诉我们 4 也不在答案中,而从之前的猜测中,我们现在知道 5 是最后一位数字。
1290 1 0 回到最初的猜测,我们想知道 1、2 和 3 中的哪个数字在答案中。我们知道 9 和 0 不在答案中,所以一个数字放错意味着 1 或 2 在答案中,而 3 不在答案中。此外,无论 1 和 2 中的哪个数字在答案中,它目前都放在了错误的位置。
6715 2 1 因为 4 不在解决方案中,我们知道从第二个猜测中 5、6 和 7 在。我们的猜测告诉我们 1 不在答案中,而 6 和 7 放错了位置。由于 1 不在答案中,2 必须是(从之前的猜测中)。因为 5 在最后一位,我们之前在第二和第三位尝试了 2,但都没有正确,所以 2 必须在第一位。因为我们尝试了 6 在第一和第三位,但都不正确,所以 6 必须在第二位。这样,第三位就剩下 7 了。我们找到了答案。
2675 0 4 是的,这是正确的。

游戏的挑战在于你必须在有限的猜测次数内成功猜出计算机的秘密代码。在我们表 10.1 中的例子中,我们用了七个猜测来猜出代码 2675。对于每个猜测,我们都得到了数字放错的数量和数字正确的数量来指导我们的思考。

在我们刚才提到的游戏的免费版本中,你不允许在猜测中包含相同的数字多次。例如,猜测 1231 是不允许的,因为有两个 1。在我们的游戏版本中,我们也将保持这一限制。

10.3.2 自顶向下的设计

我们的整体任务是编写一个程序来与计算机玩 Bulls and Cows 游戏。让我们对这个大任务进行自顶向下的设计,就像我们在第七章和第九章中所做的那样。在这个游戏中,必须发生什么?回答这个问题将帮助我们把这个游戏分解成更小的任务。为了帮助我们,我们思考了游戏的规则和我们的例子,并思考了游戏每个步骤会发生什么。这些高级步骤都出现在图 10.2 中,所以让我们逐一分解它们。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/10-2.png

图 10.2 Bulls and Cows 游戏步骤

我们将从设置开始。为了能够玩游戏,电脑必须随机生成一个秘密代码。我们需要确保这个秘密代码没有重复的数字。对我们来说,这听起来像是足够复杂且自成一体的东西,因此它应该是一个独立的子任务函数。

电脑生成其秘密代码后,我们可以进入游戏本身。这里是玩家开始猜测的地方。我们可能会认为我们可以简单地使用input来询问玩家的猜测,从而避免为这个任务创建一个单独的函数。但我们需要确保玩家输入了正确的数字数量,并且他们的猜测中没有重复的数字。这超出了我们用一个input调用的能力,所以我们将把这个任务也作为一个单独的函数来处理。

一旦玩家做出有效的猜测,我们需要弄清楚两件事:有多少数字是正确的,有多少数字是错误的?我们应该有一个函数来完成这两个任务吗?或者,也许我们应该有两个函数,一个用于正确信息,一个用于错误信息?我们在两边都看到了很好的论据。如果我们把任务合并到同一个函数中,我们可以把玩家反馈集中在一个地方,这可能使我们更容易确认它是否正确编写。另一方面,有两个独立的函数会使测试每种类型的反馈(正确或错误)变得更容易,但代价是分散了反馈逻辑到两个函数中。我们在这里多少有些任意地选择使用一个函数,但如果你希望有两个独立的函数,我们鼓励你在完成本节内容后自己尝试一下。

让我们盘点一下。我们有一个生成电脑秘密代码的函数。我们有一个获取玩家下一个猜测的函数。我们有一个获取玩家猜测的正确/错误提示的函数。这些都是我们乐意从顶层函数中分离出来的三个主要子任务。

是否还有其他可以分离的子任务?在我们的顶层函数中确实还有更多的工作要做。例如,我们需要检测玩家的猜测是否与秘密代码匹配,并在这种情况下结束游戏。尽管如此,我们觉得我们不需要为这个任务单独创建一个函数。为了确定用户的猜测是否等于秘密代码,我们可以使用 Python 的==运算符,它直接告诉我们两个值是否相等。而为了结束游戏,我们可以使用return语句来结束顶层游戏函数,从而停止程序。同样,如果玩家用完了所有的猜测而没有猜出秘密代码,那么我们需要告诉他们他们输了游戏,但同样,我们可以用一小段 Python 代码来完成这个任务。因此,我们将在这里停止,我们的主要顶层函数调用三个子任务函数。

当我们在第七章中处理拼写建议问题时,我们需要将我们的一个子任务分割成子子任务。但在这里,我们的三个子任务中的每一个都可以作为一个单独的函数来管理。

例如,让我们再次思考我们的第一个子任务:生成计算机的秘密代码,不允许有重复的数字。我们能否从这个地方分割出任何子子任务?也许我们可以有一个函数来检查一个建议的秘密代码中是否有任何重复的数字。然后,我们可以继续生成秘密代码,调用我们的子子任务函数,直到它告诉我们没有重复。这会有效,但我们也可以逐个生成秘密代码的数字,并从一开始就不允许将重复的数字添加到代码中。这个后者的计划似乎不需要分割出任何子子任务。

现在我们来考虑我们的第二个子任务:获取玩家的下一个猜测。我们可以将一个子子任务分割出来,告诉我们一个猜测是否有效(即,它有正确的长度且没有重复)。虽然我们当然可以这样做,但在子任务函数本身进行几个检查就足够了。(你的思绪是否回到了第七章中的例子,关于有效密码和检测有效密码,我们将有效性检查分割成自己的函数?如果是这样,区别在于检查密码是否有效可能是一个比我们这里需要的有效性检查更实质性的任务。)当然,我们可以将其分割成另一个子子任务,但我们不会这样做。我们已经论证了我们的第三个子任务本身是可行的,所以我们的自顶向下设计就到这里为止。

我们将命名我们的顶级函数为play。在其中,我们将调用三个函数,分别对应我们刚刚确定的三个子任务。我们将我们的第一个子任务(生成计算机的秘密代码)的函数命名为random_string,第二个子任务(获取玩家的下一个猜测)的函数命名为get_guess,第三个子任务(向玩家提供反馈)的函数命名为guess_result。请参见图 10.3,了解这种自顶向下设计以树状图的形式表示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/10-3.png

图 10.3 Bulls and Cows 游戏的自顶向下设计。最顶层(最左侧)的函数是play,它调用random_stringget_guessguess_result

10.3.3 参数和返回类型

通常,我们在自顶向下的设计过程中本身定义每个函数的参数类型和返回值,但这次我们想在这里单独讨论这一点,因为有一些细微的方面。例如,你可能已经在想象我们将使用整数来表示秘密代码和猜测;但是,正如我们很快就会看到的,这并不是最佳选择。在我们为每个函数编写之前,我们将就如何表示所有函数的数据做出一些决定。

*play函数是我们顶级函数,也是我们游戏的起点。这个函数可以没有任何参数。在函数的某个地方,我们不得不硬编码秘密代码有四个数字以及玩家有,比如说,10 次猜测的事实。但这不会很灵活。如果我们想玩一个秘密代码有七个数字且玩家有 100 次猜测的游戏版本,我们就必须进入代码并做出所有必要的更改。因此,为了使游戏易于配置,我们可以向这个函数提供一些参数。例如,而不是总是让秘密代码是四位数字,我们可以使用一个参数来允许秘密代码的长度设置为任何我们想要的。同样,而不是直接将玩家猜测的最大次数放入函数中,我们也可以将其作为一个参数,这样我们就可以轻松地更改它。然后,要改变游戏玩法,我们只需要用这些参数的不同值调用这个函数,而不必修改函数本身的代码。

使用参数和变量来避免魔法数字

允许猜测的次数和秘密代码中的数字位数是我们可以用来解释代码设计中一个重要原则的好例子。这个原则是,当我们编写代码时,如果一个数字可以是参数或变量,它应该是。这个原则确保代码尽可能灵活。当程序员看到使用的是数字而不是友好的名称时,他们称之为“魔法数字”,这正是我们想要避免的。在我们讨论玩家获得的猜测次数或秘密代码的位数时,如果我们遵守这个原则,那些应该是参数。在某个时候,这些参数需要给出具体的数字以便代码能够工作,但我们应该尽可能在代码的最高级别分配它们的值(例如,玩家可能在游戏开始时设置这些参数)。

为了帮助遵守这一基本原则,每当你在代码中看到原始数字(例如,4)时,问问自己这能否是一个参数或变量。通常情况下,它应该是。

根据我们在第三章中的讨论,添加这些参数是另一个例子,说明如何使函数通用而不是不必要的限制。我们的random_string函数是生成计算机秘密代码的函数。为什么我们在函数名中放string?我们不应该返回一个像 1862 这样的随机整数吗?字符串与这个有什么关系?

嗯,返回整数的问题在于秘密代码可能以 0 开头。像 0825 这样的秘密代码是一个完全有效的四位秘密代码。但 0825 作为一个整数是 825,这没有足够的数字。字符串'0825'只是恰好每个都是数字的四个字符,所以以'0'开头没有问题。

除了这些,让我们提前思考我们最终需要与计算机的秘密代码做什么。我们需要逐位比较它与玩家的猜测,以确定哪些数字匹配。使用字符串的索引将使我们能够轻松访问字符串中的每个字符,这正是我们所需要的。访问整数的每个单独的数字更具挑战性:整数不支持索引!

因此,我们的random_string函数将接受秘密代码所需数字的数量作为参数,并返回一个相同长度的随机字符串,其中字符串中的每个字符都是数字字符。当我们谈论字符串中的数字时,我们指的是字符串的字符。它们碰巧是数字字符,但它们仍然是字符,就像'a''*'一样。不要被字符串看起来像数字的事实所迷惑!以下是一个示例,说明这些字符串与其他字符串以相同的方式工作:

>>> s = '1862'
>>> s[0]        #1
'1'
>>> s[1]
'8'
>>> s[2]
'6'
>>> s[3]
'2'
>>> s + 1           #2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
>>> s + '1'              #3
'18621'

#1 我们像往常一样访问字符串的字符。

#2 我们不能将字符串和整数相加。

#3 这不是字符串连接,而是数字相加。

那么get_guess函数呢,它是用来从玩家那里获取下一个猜测的?与random_string一样,这个函数需要知道有效猜测的数字数量,所以我们将它作为一个参数。它将返回一个字符串,给出玩家的猜测。

最后,让我们谈谈guess_result函数,这个函数告诉我们有多少位数字是正确的,有多少位是放错了位置的。这个函数将需要玩家的猜测字符串和计算机的秘密代码字符串来进行比较,因此我们需要这个函数接受这两个参数。我们需要返回两份信息——正确的数字数量和放错位置的数字数量——所以我们将返回一个包含两个整数的列表。

10.3.4 实现我们的函数

完成了我们的自顶向下设计后,我们现在可以与 Copilot 一起编写我们每个函数的代码。一如既往,我们将从下到上编写函数。这意味着我们首先将实现我们的三个子任务函数,然后最终实现我们的顶级play函数。

random_string

像往常一样,当与 Copilot 一起生成代码时,我们提供def行和文档字符串,然后 Copilot 编写代码。在每一份代码列表中,我们还会提供一些关于代码如何工作的指导。

我们希望我们的random_string函数接受秘密代码中的数字数量作为参数,并返回一个没有重复的随机秘密代码。在这个函数的代码中,我们可能期望 Copilot 使用random.randint在一个循环中,只要我们没有足够的数字,这个循环就会继续运行。为了避免添加重复的数字,代码只有在那个数字尚未存在于秘密代码中时,才会将一个随机数字添加到秘密代码中。我们在 Copilot 的结果中找到了这样的解决方案,这就是我们在以下列表中展示的解决方案。

列表 10.1 用于生成秘密代码的函数random_string
import random           #1

**def random_string(length):**
 **'''**
 **length is an integer.**

 **Return a string of the given length, where each character**
 **is a digit from 0 to 9, and with no repeated digits.**
 **'''**
    s = ''          #2
    while len(s) < length:             #3
        r = random.randint(0, 9)     #4
        if str(r) not in s:        #5
            s += str(r)          #6
    return s           #7

#1 不要忘记导入 random 模块。

#2 从空字符串(目前还没有数字)开始作为秘密代码

#3 当我们还需要数字时循环

#4 生成一个从 0 到 9 的随机整数

#5 如果这个数字还没有在秘密代码中 . . .

#6 . . . 将这个数字添加到秘密代码中。

#7 返回秘密代码

在本章中,我们并没有专注于测试,即使我们是在测试,我们也不会在这个函数的文档字符串中包含精确的测试。这是因为这个函数的结果是随机的。随机性可能会使测试函数变得困难,因为很难控制你得到的随机值。我们可以做的是在我们的文档字符串中添加一个测试,至少检查生成的秘密代码包含正确的字符数,没有重复的字符,并且每个字符都是有效的数字。你也可以通过运行这些函数并观察它们的结果始终在你的随机范围内,并且结果看起来是随机的来测试这样的函数。

如果你坚持进行测试,你会发现我们对这个函数的实现非常完美,没有必要做更多。如果你只想让游戏工作,你可以跳到开始编写get_guess函数的地方。如果你想了解更多关于 Python 和函数设计的信息,我们想利用这个机会花更多的时间来查看这个函数的不同解决方案。

我们鼓励你检查当你按下 Ctrl-Enter 时出现的窗口中的 Copilot 建议,以查看其他可能性。例如,我们可以从一个包含从 0 到 9 的所有整数的列表开始,然后使用一个名为random.shuffle的函数将这个列表随机排序。然后,我们只需从那个打乱顺序的列表中取出前length个整数(其中length是秘密代码的长度),这就是秘密代码!我们保证不会得到任何重复的数字,因为打乱顺序的原始列表中没有重复的数字。这个函数的版本可能如下所示。

列表 10.2 函数random_string的替代解决方案
import random
def random_string(length):
 '''
 length is an integer.

 Return a string of the given length, where each character
 is a digit from 0 to 9, and with no repeated digits.
 '''
    digits = list(range(10))               #1
    random.shuffle(digits)             #2
    return ''.join(str(d) for d in digits[:length])    #3

#1 创建列表 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

#2 以随机顺序打乱列表

#3 从打乱顺序的列表中返回前“length”个数字

如果你想了解更多关于这段代码的功能,你拥有相应的工具去做!不要假设代码超出了你的理解范围。你可以让 Copilot 解释代码。你可能会得到以下这样的解释。

LPHuman 我的random_string函数做什么?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 该代码是一个名为random_string的 Python 函数,它接受一个整数length作为输入,并返回一个给定长度的字符串,其中每个字符都是 0 到 9 之间的数字,且没有重复的数字。

函数首先使用range函数和list构造函数创建一个从 0 到 9 的数字列表。然后使用 random 模块中的shuffle函数打乱列表。这确保了数字的随机顺序。

最后,该函数使用列表推导式将打乱列表的前length个数字转换为字符串,并使用join方法将它们连接成一个单一的字符串。

总体来说,random_string函数生成一个没有重复数字的随机数字字符串,这对于生成唯一的标识符或密码很有用。

什么是列表推导式?你不需要自己解决这个问题,你可以询问 Copilot 来解释它。

LPHuman 这个列表推导式是如何工作的?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 该函数中的列表推导式用于将打乱列表的前length个数字转换为字符串,并使用join方法将它们连接成一个没有分隔符的单个字符串。

下面是列表推导式是如何工作的分解:

  1. str(d) for d in digits[:length]是生成字符串列表的表达式。它将digits列表前length个元素中的每个数字转换为字符串,使用str函数,并返回结果字符串。

  2. digits[:length]是列表推导式操作的迭代器。它取digits列表的前length个元素,这些元素已经被随机打乱。

  3. 整个列表推导式被括号包围,它从表达式生成的元素中创建了一个新列表。

最后,在生成的字符串列表上调用join方法,将它们连接成一个没有分隔符的单个字符串。

Copilot 的一些文本可能不完全易懂,但你可以继续询问它解释你不确定的部分,直到你满意地理解了它是如何工作的。

我们不需要展示这个函数的第二个工作解决方案,但我们要借此机会提醒你已经走了多远。随着你通过本书的进展,你一直在开发工具和技术来理解和创建软件,以及学习和持续学习的信心!回到我们手头的这个问题,让我们编写我们的下一个函数。

get_guess

我们的get_guess函数接受所需数字的数量作为参数,并从玩家那里返回一个有效的猜测。记住,为了使猜测有效,它必须包含正确的数字数量,并且不能有任何重复的数字。以下是我们从 Copilot 获取的以下列表中的内容。

列表 10.3 函数get_guess用于用户输入
def get_guess(length):
 '''
 length is an integer.

 Keep asking the player to enter a string where each character 
 is a digit from 0 to 9, until they enter a valid guess.
 A valid guess has the given length and has no repeated digits.
 '''
    guess = input('Enter a guess: ')                           #1
    while len(guess) != length or not guess.isdigit()\    #2
            or len(set(guess)) != length:                
        guess = input('Enter a guess: ')                #3
    return guess       #4

#1 首先尝试从玩家那里获取一个有效的猜测

#2 当长度错误,或者猜测不是全部数字或者有重复时……

#3 ……随后尝试从玩家那里获取一个有效的猜测

#4 返回有效的猜测

那个 while-loop 条件是复杂的!最后一部分,len(set(guess)) != length,负责检查用户是否输入了任何重复的数字。它是使用 Python 集合来做到这一点的。我们确实在第七章的 7.7.7 节中看到过这个技巧,但那是很久以前的事情了!所以,如果你需要复习,不要犹豫,向 Copilot 请求解释那一行。

guess_result

我们的 guess_result 函数接受两个字符串作为参数:玩家的最新猜测和秘密代码。它返回一个包含两个整数的列表:正确数字的数量和错误数字的数量。

这是一个难以正确实现的函数,因此尽管我们之前没有在先前的函数中展示我们想要运行的测试,但我们将在该函数的文档字符串中提供一些明确的测试。我们添加这些文档字符串测试是因为这个函数的逻辑将足够复杂,以至于仅通过阅读代码很难确定代码是否正确。对于我们的文档字符串测试,我们需要一些不同秘密代码和用户猜测的示例案例,以确保函数返回的正确和错误数字的数量符合预期。当然,根据第六章,如果你对进行严肃的测试感兴趣,你可能还想添加更多的测试用例。我们的第一个测试用例是

>>> guess_result('3821', '1862')

这里的正确返回值是 [1, 2],因为有一个数字是正确的(8),还有两个错误的数字(2 和 1)。我们的第二个测试用例是

>>> guess_result('1234', '4321')

这次的正确返回值是 [0, 4],因为没有正确的数字,但猜测中的所有四个数字都是错误的。以下是我们的完整文档字符串,包括测试用例和 Copilot 的代码。

列表 10.4 函数 guess_result 用于获取猜测的结果
def guess_result(guess, secret_code):
 '''
 guess and secret_code are strings of the same length.

 Return a list of two values:
 the first value is the number of indices in guess where
 the character at that index matches the character at the
 same index in secret_code; the second value is the 
 number of indices in guess where the character at that 
 index exists at a different index in secret_code.

 >>> guess_result('3821', '1862')
 [1, 2]
 >>> guess_result('1234', '4321')
 [0, 4]
 '''
    correct = 0                     #1
    misplaced = 0          #2
    for i in range(len(guess)):             #3
        if guess[i] == secret_code[i]:    #4
            correct += 1        #5
        elif guess[i] in secret_code:     #6
            misplaced += 1             #7
    return [correct, misplaced]      #8

#1 这个变量用于记录正确的数字。

#2 这个变量用于记录错误的数字。

#3 遍历数字字符串的每个索引

#4 这个数字是正确的。

#5 将正确计数增加 1

#6 这不是匹配但却是错误的。

#7 将错误计数增加 1

#8 返回列表中的两个结果

在这里使用 elif 是微妙的。如果它被写成 if 而不是 elif,那么代码就会出错!你明白为什么吗?如果不明白,在继续阅读这里的解释之前,试着与 Copilot 进行对话。

假设 if 条件 guess[i] == secret_code[i]True。在这种情况下,我们将 correct 增加 1,并跳过 elif 语句。(记住,只有当前面的 if 和任何前面的 elif 条件为 False 时,elif 语句才会运行。)

现在,想象一下,如果我们把elif改为if。如果if条件guess[i] == secret_code[i]True,我们仍然会增加correct的值。但然后,我们会检查guess[i] in secret_code条件,这个条件也会是True。毕竟,我们刚刚说过guess[i] == secret_code[i],这证明了guess[i]确实在secret_code中某个位置。所以,我们会错误地执行misplaced += 1,而这绝对是我们不希望做的(它是一个匹配的数字,而不是一个错误的数字)!

play

我们已经完成了子任务函数!现在我们只需要编写play顶级函数。

我们的play函数接受两个整数作为参数:秘密代码中的数字数量和猜测次数,以及玩家需要猜测秘密代码的猜测次数。它不返回任何内容——它只是玩游戏!我们这个函数的最终提示和代码如下所示。

列表 10.5 游戏的play函数
def play(num_digits, num_guesses):
 '''
 Generate a random string with num_digits digits.
 The player has num_guesses guesses to guess the random 
 string. After each guess, the player is told how many 
 digits in the guess are in the correct place, and how 
 many digits exist but are in the wrong place.
 '''
    answer = random_string(num_digits)                         #1
    print('I generated a random {}-digit number.'.format(num_digits))
    print('You have {} guesses to guess the number.'.format(num_guesses))
    for i in range(num_guesses):                        #2
        guess = get_guess(num_digits)            #3
        result = guess_result(guess, answer)          #4
        print('Correct: {}, Misplaced: {}'.format( \   #5
                            result[0], result[1]))     #5
        if guess == answer:            #6
            print('You win!')
            return             #7
    print('You lose! The correct answer was \   #8
                     {}.'.format(answer))       #8

#1 生成计算机的秘密代码

#2 对每个可能的玩家猜测循环一次

#3 从玩家那里获取下一个有效的猜测

#4 获取对这个猜测的“正确”和“错误位置”反馈

#5 告诉玩家提示信息

#6 玩家正确猜出了秘密代码!

#7 退出函数;这标志着游戏的结束。

#8 如果我们到达这里,那么玩家已经用完了猜测次数。

如果我们现在运行程序,什么也不会发生。那是因为我们还没有调用play函数!我们总是需要记得调用我们的顶级函数。要调用它,我们可以在play函数下方添加这一行:

play(4, 10)

数字4表示我们正在使用四位数代码和猜测,而数字10表示我们有 10 次机会正确猜测秘密代码。你可以更改这些参数来调整你喜欢的游戏。

在我们继续之前,让我们试玩一下我们的游戏。我们将展示我们的前几个猜测,然后是我们的最终猜测:

I generated a random 4-digit number.
You have 10 guesses to guess the number.
Enter a guess: 0123
Correct: 1, Misplaced: 0
Enter a guess: 4567
Correct: 1, Misplaced: 0
Enter a guess: 8901
Correct: 2, Misplaced: 0
Enter a guess: 8902
Correct: 2, Misplaced: 1
...
Enter a guess: 2897
Correct: 1, Misplaced: 3
You lose! The correct answer was 8927.

我们已经成功设计了一个计算机游戏,这与其他我们在本书中编写的程序非常不同。我们的计算机游戏与用户互动,具有随机性,涉及两个玩家(人类玩家猜测和计算机玩家提供正确/错误提示),并且有胜负条件。这有很多新内容!然而,我们也希望你能看到我们从前面的章节中学到了多少,并取得了哪些进步。我们仍在进行自顶向下的设计,仍在设计函数,仍在测试代码,仍在阅读代码,并且仍在与 Copilot 进行对话。在写这一章之前,你可能没有编写过游戏,但本质上,你已经具备了编写游戏所需的技能。不要让看似新的程序或应用类型阻止你尝试去做。

10.3.5 为 Bulls and Cows 添加图形界面

你可能会对我们的游戏看起来不像你最近玩过的游戏而感到失望,因为没有图形,只有文本。例如,没有好的输入区域,没有可以点击的按钮,也没有任何图形界面。我们已经在本章中讨论了为什么我们专注于文本游戏,但这并不意味着将这个游戏转换成图形界面版本完全超出了你的能力范围,因为你可以通过与 Copilot 交互来查看它是否可以帮助你开始。

挑战在于程序员使用一种称为事件驱动编程的编程风格来创建图形用户界面,这是我们还没有教过你的,而且在这个章节中也无法详细讲解。你可能会阅读事件驱动编程的代码并对其有所理解,如果你要求 Copilot 编写代码来生成图形界面,你将需要这样做。然而,如果 Copilot 没有给你你想要的结果,在没有首先熟悉事件驱动编程的情况下,可能很难修复。如果你想要了解更多,有一本关于游戏编程的好书,它包括使用 Python 的图形用户界面和事件驱动编程,书名为用 Python 发明你自己的电脑游戏 [3]。

事件驱动编程

事件驱动编程通常用于与用户交互的程序。在较高层次上,程序设置了一种用户与程序交互的方式(例如,通过按按钮或输入文本),然后只是坐着等待用户做些什么,有时在等待用户输入的同时更新游戏状态。一旦人类与游戏交互,代码就会将这种交互识别为事件,每个事件都与执行时应该运行的代码相关联。例如,当用户点击退出游戏按钮时,它会触发当用户想要退出时需要执行的代码(例如,保存游戏状态并退出程序)。

当我们要求 Copilot 为 Bulls and Cows 创建图形界面时,我们对它给出的结果印象深刻,所以我们想展示它做得有多好。即使不了解事件驱动编程,代码也相对容易阅读。例如,你可能可以找到游戏的标题并更改它。为此,我们向 Copilot Chat 提出了以下问题:

LPHuman 能否将这个程序转换为使用图形界面而不是文本界面?

Copilot 通过逐步列出我们需要做出的更改来回应我们。然后它重写了代码,使游戏现在使用带有按钮和编辑框的图形界面,而不是文本界面。有关新界面的详细信息,请参阅图 10.4。生成此界面的代码可以在本书的网站上找到,如果你有兴趣运行它或了解代码的话。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/10-4.png

图 10.4 由 Copilot 提供的游戏图形界面。请注意,它显示了秘密代码,可能是为了帮助测试(您在玩游戏之前应该将其删除)。

10.4 示例 2:Bogart

我们的第二个游戏将是一款两人掷骰子游戏。在 Bulls and Cows 中,我们的一个玩家是人类,另一个是电脑。这次,我们将为两个玩家编写游戏。随机性再次将扮演关键角色。完成后,您可以和朋友或家人玩!

*### 10.4.1 游戏的工作原理

我们将要编写的游戏被称为 Bogart,由 Crab Fragment Labs 设计。这是一款两人掷骰子游戏。游戏还使用筹码或硬币。显然,我们不需要实际的骰子或筹码,因为我们正在将其作为电脑游戏实现。

Bogart

Bogart 游戏由詹姆斯·欧内斯特设计(© 1999 詹姆斯·欧内斯特和 Cheapass Games,经许可使用;crabfragmentlabs.com/)。您可以从以下链接免费下载原始游戏的说明手册作为游戏.pdf 文件的一部分:crabfragmentlabs.com/shop/p/chief-herman-1。如果您喜欢这款游戏,并且无法停止玩我们的复制品,我们鼓励您支持 Crab Fragment Labs 正在进行的工作。我们感谢他们允许我们在这里使用他们的游戏!

当游戏开始时,筹码堆是空的(里面没有筹码)。两个玩家中的一个被随机选中开始游戏。从那里,每个玩家轮流进行,直到游戏结束。我们首先解释玩家如何进行回合,然后给出游戏结束的规则。图 10.5 还提供了游戏流程的概述。

在玩家回合开始时,一枚筹码被加入筹码堆,该玩家掷一枚骰子。如果那枚骰子是 1,那么玩家的回合就结束了,他们不会收集任何筹码。如果那枚骰子不是 1,那么玩家可以决定是否继续他们的回合。如果他们决定不继续回合,他们将收集筹码堆中的所有筹码(使筹码堆为空)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/10-5.png

图 10.5 Bogart 游戏中的玩家回合

如果他们决定继续他们的回合,那么将有两枚筹码加入筹码堆,然后他们掷两枚骰子。如果其中一枚或两枚骰子是 1,那么玩家的回合就结束了。否则,玩家再次决定是否继续他们的回合。

如果他们继续他们的回合,那么他们掷三枚骰子,然后掷四枚骰子,然后掷五枚骰子。(玩家不能选择跳过一定数量的骰子;他们必须从一枚骰子开始依次掷出所有骰子。)如果他们掷出了 1,那么他们的回合结束,他们不会收集任何筹码。如果他们决定不继续回合,那么他们将从筹码堆中收集所有筹码。

在这个游戏中掷出 1 点对当前玩家来说是个坏消息。掷一枚骰子时,掷出 1 点的概率很低,只有 1/6(大约 17%)。掷两枚骰子时,至少掷出一个 1 点的概率更高,这次大约是 31%。掷三枚骰子时(42%),掷四枚骰子时(52%),或者掷五枚骰子时(60%),概率更高。所以,你延长回合的时间越长,最终掷出 1 点并一无所获的概率就越高。更糟糕的是,如果你比对方更保守地玩,你可能会给对方留下一个满满的赌注。同时,你延长回合的时间越长,赌注中的筹码积累得就越快,假设你最终成功结束回合,你可以收集的筹码就越多。整个技巧就是决定何时冒险获取更多筹码,何时收手。

这就是玩家回合的工作方式。玩家轮流进行回合,掷骰子并收集筹码,直到游戏结束。游戏有三种结束方式:

  1. 如果一名玩家总共收集到 30 枚或更多的筹码,那么这名玩家就赢了。

  2. 假设一名玩家在他们的回合中掷出一枚骰子,然后两枚,接着三枚,然后四枚,最后五枚,一次也没有掷出 1 点。那么,这名玩家立刻获胜。他们收集了多少筹码并不重要:如果他们掷出五枚骰子,其中没有一枚是 1 点,他们就会赢。

博格特游戏示例

让我们玩几轮游戏,以确保我们清楚游戏是如何运作的。赌注一开始是空的。假设玩家 1 是被随机选中的第一个玩家。我们向赌注中加一枚筹码,玩家 1 掷一枚骰子。假设他们掷出了 5 点。现在玩家 1 必须决定是否结束他们的回合并从赌注中拿走一枚筹码,或者继续他们的回合。

假设他们继续他们的回合。我们向赌注中加两枚筹码;现在赌注中有三枚筹码。玩家 1 掷两枚骰子。假设他们掷出了 4 点和 2 点。

他们应该结束回合并拿走三枚筹码吗?不,不。他们想要更多。他们继续他们的回合。我们向赌注中加三枚筹码;现在赌注中有六枚筹码。玩家 1 掷三枚骰子。这次,他们掷出了 6 点、5 点,哦不!——一枚 1 点。玩家 1 的回合结束了。他们没有得到任何筹码,并且给玩家 2 留下了六个诱人的筹码。

现在轮到玩家 2 了。我们在赌注中加一枚筹码——现在总共有七枚筹码了!——然后玩家 2 掷一枚骰子。假设他们掷出了 2 点。如果玩家 2 现在结束他们的回合,他们将收集赌注中的七枚筹码。这感觉很多,所以让我们假设玩家 2 决定结束他们的回合。

现在赌注已经空了,轮到玩家 1 了。玩家 1 需要追赶:他们有 0 枚筹码,而玩家 2 有 7 枚筹码。我们在这里停下来,但游戏将继续进行,直到其中一名玩家积累到 30 枚或更多的筹码,或者他们掷出五枚骰子并且没有掷出任何 1 点。

10.4.2 自顶向下的设计

正如我们在实现我们的牛牛游戏时的情况一样,实现博加特是一个大问题,我们需要自顶向下的设计。我们现在将提供这个设计,但在这里我们鼓励你在继续之前自己尝试一下。我们推荐这样做是因为我们发现,由于交互元素的众多,这个游戏的自顶向下设计非常微妙。例如,玩家的回合可以以三种方式结束:收集筹码、不收集筹码,以及立即获胜。我们需要能够确定这些情况中的哪一种发生了。作为另一个例子,在每个玩家的回合之后,我们需要切换到另一个玩家——但不幸的是,并不总是这样:如果一个玩家赢得了游戏,我们希望在那一刻停止游戏并宣布他们为胜者,而不是切换到另一个玩家!我们将主要关注我们的成功自顶向下设计,但偶尔我们会解释我们为什么做出某些决定,以及其他决定为什么出错。

我们将命名我们的顶级函数为play。以下是我们需要解决游戏的主要子任务:

  1. 初始化筹码池,并将玩家 1 和玩家 2 的筹码数设为零。这是游戏设置阶段的一部分。

  2. 随机选择玩家 1 或玩家 2 开始游戏。这同样是游戏设置的一部分。

  3. 现在我们进入游戏阶段。只要游戏没有结束,就执行以下操作:

    1. 打印筹码池中的筹码数,玩家 1 的筹码数,以及玩家 2 的筹码数。

    2. 为当前玩家转动一圈。

    3. 如果当前玩家赢得了筹码,就将筹码给当前玩家,并将筹码池重置为零筹码。

    4. 切换到另一个玩家的回合。

  4. 打印获胜游戏的玩家(玩家 1 或玩家 2)的名字。

在本书的这一部分,我们怀疑你已经足够熟悉 Copilot 的代码,对哪些任务需要单独的函数有一些很好的直觉。任务 1 只是几个变量赋值,所以我们不需要为那个任务创建一个单独的函数。我们不需要为以下任务创建单独函数的任务还包括任务 2(只是一个对random.randint的调用)、3a(几个print调用)和 4(一个print调用)。我们将把剩余的子任务捕获在函数中。以下小节将描述每个剩余的子任务。

任务 3。当游戏没有结束时

我们将有一个while循环,只要游戏没有结束就会继续,所以我们需要一个函数来告诉我们游戏是否结束!这个函数如何知道游戏是否结束?它需要知道当前玩家 1 和玩家 2 的筹码数。这样,它可以检查这些数中的任何一个是否至少为 30。但记住,游戏还有另一种结束方式,那就是当一个玩家掷出五颗骰子,且没有一颗是 1 时。因此,这个函数还需要知道当前玩家的最近一次骰子滚动情况。

我们将把这个函数命名为game_over。它将接受三个参数:玩家 1 的筹码、玩家 2 的筹码和骰子点数列表。如果游戏结束,则返回True,否则返回False。这个函数的代码需要检查几个条件,但我们应该能够做到这一点,而无需将其拆分成更小的子任务。

任务 3b. 为当前玩家进行一次完整的回合

我们将把这个函数命名为take_full_turn。这个函数需要知道现在筹码堆中有多少筹码,以便根据需要更新它。它还需要返回更新后的筹码堆中的筹码数量。除此之外,我们还需要管理很多事情才能完成一个完整的回合,因此我们需要控制这个函数的复杂性。以下是这个函数可能需要做的事情:

  1. 允许玩家掷一个骰子,然后掷两个骰子,然后掷三个骰子,依此类推,直到玩家的回合结束。

  2. 根据本轮发生的情况更新当前玩家的筹码数量。我们可以添加一个额外的返回值来将此更新信息传达给调用此函数的人。

  3. 判断游戏是否结束。我们可以在返回值中添加一个额外的值,其中True表示游戏结束,False表示游戏未结束。

我们最初尝试让函数完成这三件事,但无法从 Copilot 那里获得令人满意的代码。这并不令人惊讶,因为我们要求函数做太多。这导致我们专注于这个函数的核心,即第 1 点。

但好吧,如果我们只关注第 1 点,那么我们如何更新当前玩家的筹码数量(第 2 点),以及我们如何知道游戏是否结束(第 3 点)?对于第 2 点,我们想到的解决方案是在这个函数中根本不更新当前玩家的筹码计数,而是返回本轮后筹码堆中的总筹码数量。

例如,如果筹码堆中有 10 个筹码,而这个玩家的回合产生了 6 个筹码,那么我们将返回 16。玩家可能或可能无法收集这 16 个筹码——这取决于他们的回合如何结束——但我们现在不会处理这个问题(这是由调用函数来管理的)。

对于第 3 点(知道游戏是否结束),我们的解决方案是让函数将其返回值的一部分作为最近的骰子点数列表。这个点数列表(对于第 2 点)也将由调用函数所需。调用这个函数的函数可以使用这些点数来确定游戏是否结束。

总结来说,我们的函数将筹码堆中的筹码数量作为参数,并返回两个值:玩家回合后筹码堆中新的筹码数量和最近的骰子点数列表。

要完成一个完整的回合,我们需要能够掷骰子:首先掷一个骰子,然后掷两个骰子,然后掷三个骰子,依此类推。我们将这部分拆分成一个名为roll_dice的函数。它将接受要掷的骰子数量作为参数,并返回一个点数列表。例如,如果我们要求该函数掷三个骰子,我们可能会得到回执[6, 1, 4]

我们还需要能够根据最近的点数列表确定回合是否结束。如果玩家掷出了任何 1,或者掷出了五个骰子且没有一个是 1,那么回合就结束了。我们将这部分也拆分出来,命名为turn_over。它将接受点数列表作为参数,如果回合结束则返回True,如果没有结束则返回False

如果回合还没有结束,我们需要询问玩家他们是否想要继续他们的回合。我们将要求他们给出是(y)或否(n)的回应。我们可以通过调用input函数来实现。如果玩家想要继续,我们可以再次调用roll_dice。我们不需要一个单独的函数来请求用户输入,所以我们将不会进一步分解这部分。如果我们想要验证用户的输入(拒绝任何不是 y 或 n 的回应),这将增加足够的复杂性,以至于我们可能会将其拆分出来。

总结来说,我们已经将take_full_turn函数拆分成了两个子任务:roll_diceturn_over。这些函数不需要进一步拆分。对于roll_dice,我们可以使用random.randint循环生成所需的点数。而turn_over将涉及对骰子点数的几个检查,以确定它们是否结束了我们不需要进一步拆分的回合。

任务 3c. 如果当前玩家赢得了筹码

当玩家的回合结束时,我们会得到新筹码数量和结束回合的最终骰子点数列表。我们需要确定玩家是否获得了这些筹码。(如果最终的点数列表中不包含 1,则玩家收集筹码;否则,他们不收集,筹码留在筹码堆中。)

我们将拆分出一个函数来告诉我们玩家是否收集了筹码。我们将将其命名为wins_chips。这个函数将接受最近的点数列表作为参数,如果玩家赢得了筹码则返回True,否则返回False

任务 3d. 切换到另一玩家的回合

我们将把这个函数命名为switch_player。假设当前玩家刚刚完成他们的回合。我们需要切换到另一个玩家的回合,但前提是游戏还没有结束。我们的函数将封装这个逻辑。它需要调用game_over来确定游戏是否结束,因此我们需要至少三个参数给switch_player:玩家 1 的筹码,玩家 2 的筹码,以及最近的骰子滚动列表。我们还需要一个参数来指示当前玩家(1 或 2),这样我们就可以返回下一个新当前玩家的编号。因此,该函数将接受这四个参数,并返回一个 1 或 2,表示我们将切换到的下一个玩家。

如果游戏结束,这个函数不会做任何事情。如果游戏没有结束,那么我们需要将 1 改为 2 或将 2 改为 1。我们已经有game_over函数,这足以分解这个函数。

我们已经完成了自顶向下的设计!查看图 10.6 以了解我们的函数树。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/10-6.png

图 10.6 博加特游戏的顶向下设计

10.4.3 实现我们的函数

现在是时候与 Copilot 一起编写我们每个函数的代码了。一如既往,我们将从叶函数开始,逐步向上(在图中向左),只有在所有其他函数都已实现后,才实现顶级play函数。我们可以从任何叶函数开始。我们将从game_over函数开始,因为它既需要play,也需要switch_player。然后,我们将继续处理其他函数。

game_over

这个函数接受三个参数:玩家 1 拥有的筹码数量,玩家 2 拥有的筹码数量,以及最近的骰子滚动列表。如果游戏结束,则返回True,如果没有结束,则返回False,如下所示。

列表 10.6 博加特游戏的game_over函数
def game_over(player1, player2, rolls):
 '''
 player1 is the number of chips that player 1 has.
 player2 is the number of chips that player 2 has.
 rolls is the last list of dice rolls.

 Return True if the game is over, False otherwise.

 The game is over if player1 has at least 30 chips, 
 or player 2 has at least 30 chips, 
 or there are 5 rolls none of which is a 1.
 '''
    return player1 >= 30 or player2 >= 30 or \       #1
            (len(rolls) == 5 and not 1 in rolls)     #1

#1 游戏结束的三种方式

记住,游戏有三种结束方式:玩家 1 至少有 30 个筹码,玩家 2 至少有 30 个筹码,或者某个玩家在最后一轮中成功掷出五个骰子而没有掷出 1。

直接返回布尔值

你可能期望看到代码是这样的,使用if-else和实际的return Truereturn False行:

if player1 >= 30 or player2 >= 30 or (len(rolls) == 5
        and not 1 in rolls):
    return True
else:
    return False

这会工作,但更常见的是程序员直接使用return语句与True/False表达式。这是因为表达式的结果是返回的内容。如果表达式为真,则返回True,如果表达式为假,则返回False。这与if-else版本做的是完全相同的事情!

roll_dice

这是掷骰子并给我们的游戏增加随机性的函数。它接受要掷的骰子数量作为参数,并返回骰子滚动列表。我们期望 Copilot 在这里使用random.randint,如列表 10.7 所示。

除了返回滚动列表外,如果此函数还能打印出每次掷骰子的结果,那就更有帮助了。这样,玩家就可以看到他们确切掷出了什么。在文档字符串中,我们要求 Copilot 打印滚动结果(除了返回滚动列表之外)。

列表 10.7 Bogart 中的 roll_dice 函数
import random
def roll_dice(n):
 '''
 Create a list of n random integers between 1 and 6.
 Print each of these integers, and return the list.
 '''
    rolls = []                   #1
    for i in range(n):          #2
        roll = random.randint(1, 6)      #3
        print(roll)                   #4
        rolls.append(roll)      #5
    return rolls      #6

#1 骰子滚动列表(介于 1 和 6 之间的整数),开始为空

#2 循环 n 次,每次掷一次骰子

#3 使用“randint”生成介于 1 和 6 之间的随机整数

#4 打印滚动结果供玩家查看

#5 将滚动结果添加到滚动列表中

#6 返回滚动列表

turn_over

此函数接受最近的滚动列表,并使用该列表确定当前玩家的回合是否结束。如果回合结束,则返回 True,如果没有结束,则返回 False,如以下列表所示。

列表 10.8 Bogart 中的 turn_over 函数
def turn_over(rolls):
 '''
 Return True if the turn is over, False otherwise.

 The turn is over if any of the rolls is a 1, 
 or if there are exactly five rolls.
 '''
    return 1 in rolls or len(rolls) == 5      #1

#1 回合结束的两种方式

玩家的回合结束有两种方式。第一种是当滚动列表中出现 1 时。第二种是当玩家掷了五颗骰子。

len(rolls) == 5 真的足够使回合结束吗?我们不是还要检查是否掷出了任何 1 吗?不,如果玩家掷了五颗骰子,那么无论骰子结果如何,他们的回合就结束了。如果他们掷出了任何 1,那么回合就结束了(因为他们掷出了 1,并且不会收集任何筹码)。如果他们没有掷出任何 1,那么回合仍然结束(在这种情况下,他们将自动赢得游戏)。

take_full_turn

现在我们已经准备好 take_full_turn,如列表 10.9 所示。此函数接受当前赌注中筹码的数量作为参数。它将处理当前玩家的完整回合的所有滚动,然后返回两个值:赌注中新的筹码数量和最终的滚动列表。

列表 10.9 Bogart 中的 take_full_turn 函数
def take_full_turn(pot_chips):
 '''
 The pot has pot_chips chips.

 Take a full turn for the current player and, once done, 
 return a list of two values:
 the number of chips in the pot, and the final list of dice rolls.

 Begin by rolling 1 die, and put 1 chip into the pot.
 Then, if the turn isn't over, ask the player whether 
 they'd like to continue their turn.
 If they respond 'n', then the turn is over.
 If they respond 'y', then roll one more die than last time, 
 and add 1 chip to the pot for each die that is rolled.
 (for example, if 3 dice were rolled last time, then 
 roll 4 dice and add 4 chips to the pot.)
 If the turn is not over, repeat by asking the player again 
 whether they'd like to continue their turn.
 '''
    rolls = roll_dice(1)      #1
    pot_chips += 1                       #2
    while not turn_over(rolls):                   #3
        keep_going = input('Continue? (y/n) ')      #4
        if keep_going == 'y':                       #5
            rolls = roll_dice(len(rolls) + 1)         #6
            pot_chips += len(rolls)           #7
        else:
            break                  #8
    return pot_chips, rolls    #9

#1 掷一颗骰子

#2 向赌注中添加一枚筹码

#3 当当前玩家的回合没有结束时 …

#4 … 询问玩家是否想继续他们的回合。

#5 如果他们想继续他们的回合 …

#6 … 比上一次多掷一颗骰子。

#7 向赌注中添加新的筹码

#8 退出 while 循环

#9 返回赌注中的筹码数量和最终的滚动列表

wins_chips

此函数接受一个滚动列表作为参数。如果滚动结果良好(即不包含 1),则玩家将收集筹码。如果滚动结果包含任何 1,则玩家不会收集筹码。此函数在以下列表中返回 True 如果玩家可以收集筹码,否则返回 False

列表 10.10 Bogart 中的 wins_chips 函数
def wins_chips(rolls):
 '''
 Return True if the player wins chips, False otherwise.

 The player wins the chips if none of the rolls is a 1.
 '''
    return not 1 in rolls      #1

#1 当没有 1 时返回 True,否则返回 False

switch_player

这个函数接受四个参数:玩家 1 拥有的筹码数量、玩家 2 拥有的筹码数量、当前玩家最近掷出的骰子列表(由当前玩家掷出)以及当前玩家的编号。如果游戏尚未结束,则该函数返回另一个玩家的编号。如果游戏结束,则该函数返回当前玩家(因为另一个玩家没有回合!)。请参见以下列表。

列表 10.11 Bogart 中的switch_player函数
def switch_player(player1, player2, rolls, current_player):
 '''
 player1 is the number of chips that player 1 has.
 player2 is the number of chips that player 2 has.
 rolls is the last list of dice rolls.
 current_player is the current player (1 or 2).

 If the game is not over, switch current_player to the other player.
 Return the new current_player.
 '''
    if not game_over(player1, player2, rolls):     #1
        if current_player == 1:                #2
            current_player = 2
        else:
            current_player = 1
    return current_player        #3

#1 如果游戏尚未结束…

#2 …从 1 切换到 2 或从 2 切换到 1。

#3 返回新的当前玩家

play

我们已经到达了play函数!我们决定不添加任何参数来编写这个函数。你可能想知道我们是否可以通过添加一个参数来使代码更灵活,比如为赢得所需的筹码数量(目前为 30)添加一个参数。你当然可以这样做;我们在这里没有这样做是为了保持代码中参数数量的较小。这个函数不返回任何内容。相反,它玩游戏,如下面的列表所示。

列表 10.12 Bogart 中的play函数
def play():
 '''
 Play the game until the game is over.

 The pot starts with 0 chips, and each player starts with 0 chips.

 Randomly decide whether player 1 or player 2 goes first.

 Before each turn, print three lines of information:
 1\. The number of chips in the pot
 2\. The number of chips that each player has
 3\. Whether it is player 1's turn or player 2's turn

 Take a full turn for the current player. 
 If they won the chips, add the chips in the pot to the 
 total for that player and reset the pot to have 0 chips.

 Then, switch to the other player's turn.

 Once the game is over, print the current player 
 (that's the player who won).
 '''
    pot_chips = 0      #1
    player1 = 0        #1
    player2 = 0        #1
    current_player = random.randint(1, 2)      #2
    rolls = []
    while not game_over(player1, player2, rolls):     #3
        print('Pot chips:', pot_chips)               #4
        print('Player 1 chips:', player1)              #4
        print('Player 2 chips:', player2)            #4
        print('Player', current_player, 'turn')      #4
        pot_chips, rolls = take_full_turn(pot_chips)     #5
        if wins_chips(rolls):                           #6
            if current_player == 1:                   #7
                player1 += pot_chips                       #8
            else:
                player2 += pot_chips          #9
            pot_chips = 0                              #10
        current_player = switch_player(player1, player2,  #11
                                       rolls, current_player)
    print('Player', current_player, 'wins!')   #12

#1 开始筹码池和玩家,初始筹码为 0

#2 随机让玩家 1 或玩家 2 开始游戏

#3 当游戏尚未结束时…

#4 …打印当前游戏状态。

#5 让当前玩家进行回合

#6 如果当前玩家赢得筹码…

#7 …然后轮到玩家 1…

#8 …将筹码池中的筹码给玩家 1;…

#9 …否则,将筹码池中的筹码给玩家 2。

#10 将筹码池重置为 0 筹码

#11 切换到另一个玩家的回合

#12 打印出获胜者

我们现在拥有了游戏所需的所有代码。只需添加

play()

在你现有的所有代码下方添加调用我们的play函数,然后你就可以玩游戏了!

自定义游戏

我们对 Copilot 给出的结果很满意,游戏当然可以玩。但是,诚实地讲,与玩家的互动可以更友好一些。例如,当我们运行游戏并多次按下 y 时,游戏可能会这样开始:

筹码池筹码:0

玩家 1 筹码:0

玩家 2 筹码:0

玩家 2 回合

4

Continue? (y/n) y

5

2

Continue? (y/n) y

3

1

4

筹码池筹码:6

玩家 1 筹码:0

玩家 2 筹码:0

玩家 1 回合

2

继续吗?(y/n)

没有欢迎信息。像 4、5、2 这样的数字只是在那里,没有任何上下文说明它们的意义。游戏会询问我们是否想要“继续?(y/n)”。继续什么?这并不清楚。

我们可以通过在我们的函数中增加print调用来使与玩家的互动更加愉快。我们只需自己将想要的内容放入每个print语句中即可。为什么不直接使用 Copilot 呢?这样做可能比试图说服 Copilot 打印出我们想要的确切内容要容易得多。例如,下面的列表显示了play的新版本,我们在其中添加了更多的print调用,以解释正在发生的事情,并更好地格式化游戏输出。

列表 10.13 Bogart 中格式更好的play函数
def play():
 '''
 Play the game until the game is over.

 The pot starts with 0 chips, and each player starts with 0 chips.

 Randomly decide whether player 1 or player 2 goes first.

 Before each turn, print three lines of information:
 1\. The number of chips in the pot
 2\. The number of chips that each player has
 3\. Whether it is player 1's turn or player 2's turn

 Take a full turn for the current player. 
 If they won the chips, add the chips in the pot to the
 total for that player
 and reset the pot to have 0 chips.

 Then, switch to the other player's turn.

 Once the game is over, print the current player 
 (that's the player who won).
 '''
    pot_chips = 0
    player1 = 0
    player2 = 0
    current_player = random.randint(1, 2)
    rolls = []

    print('Welcome to Bogart!')       #1
    print()                            #2

    while not game_over(player1, player2, rolls):
        print('Pot chips:', pot_chips)
        print('Player 1 chips:', player1)
        print('Player 2 chips:', player2)
        print('Player', current_player, 'turn')
        pot_chips, rolls = take_full_turn(pot_chips)
        if wins_chips(rolls):
            print('Player', current_player, 'gets',  #3
                  pot_chips, 'chips!')                 #3
            if current_player == 1:
                player1 += pot_chips
            else:
                player2 += pot_chips
            pot_chips = 0
        current_player = switch_player(player1, player2, 
                                       rolls, current_player)

        print()
        print()
        print('-=' * 20)     #4
        print()

    print('Player', current_player, 'wins!')

#1 打印欢迎信息

#2 打印一个空行

#3 打印消息说明当前玩家获得了筹码

#4 将每个回合与下一个回合分开

我们鼓励你在其他地方也添加print调用,以使游戏交互更加出色!例如,在roll_dice中,你可以添加一个print调用来告诉玩家骰子即将被掷出(以下输出中的ROLLS)。在take_full_turn中,你可以添加一个print调用来在询问玩家是否想继续他们的回合之前打印出筹码池中的筹码数量。以下是我们在做出这些更改后与我们的游戏交互的示例:

欢迎来到 Bogart!

筹码池筹码:0

玩家 1 筹码:0

玩家 2 筹码:0

玩家 2 回合

ROLLS

2

筹码池目前有 1 个筹码。

继续回合?(y/n) y

ROLLS

6

3

筹码池目前有 3 个筹码。

继续回合?(y/n) y

ROLLS

1

1

6

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=

筹码池筹码:6

玩家 1 筹码:0

玩家 2 筹码:0

玩家 1 回合

ROLLS

5

筹码池目前有 7 个筹码。

继续回合?(y/n)

也尝试对你的代码做出这样的修改。让你的游戏变得独一无二!

在本章中,我们使用 Copilot 帮助我们编写了两个电脑游戏:一个逻辑游戏(类似于 Wordle,但使用数字),和一个两人掷骰子游戏。我们能够做到这一点,得益于我们在整本书中学到的技能,包括问题分解、编写清晰的 docstrings 以及与 Copilot Chat 交互。

10.5 练习

  1. 在以下guess_result函数中存在一个错误,导致正确和错误放置的数字计数不正确。找出并修复错误:
def guess_result(guess, secret_code): 
   correct = 0 
   misplaced = 0 
   for i in range(len(guess)): 
      if guess[i] == secret_code[i]: 
  correct += 1 
     if guess[i] in secret_code: 
         misplaced += 1 
return [correct, misplaced]
    1. 将本章中的get_guess函数修改为包含错误信息,引导玩家输入有效的猜测。确保这些信息清晰且有帮助。发挥创意!以下是一些示例:

    2. “错误:猜测必须只包含数字。”

    3. “错误:猜测不得包含重复的数字。”

    1. 为什么我们选择将数字的位数和猜测的次数作为参数传递给 Bulls and Cows 游戏的play函数?这个决定如何提高游戏的灵活性?
    1. 实现一个简单的掷骰子游戏,玩家掷两个骰子。如果总和是 7 或 11,玩家获胜。如果总和是 2、3 或 12,玩家失败。对于任何其他总和,玩家继续掷骰子,直到他们要么匹配他们的初始掷骰结果(获胜)要么掷出 7(失败)。仔细思考你如何想将主要问题分解成更小的子问题!
    1. 实现一个 Bogart 游戏的版本,其中一个玩家是人类,另一个是电脑。为此,你需要想出一些电脑可以用来结束他们回合的规则。例如,电脑当然希望在他们的回合中积累足够的筹码以赢得游戏时停止掷骰子!电脑也可能希望在当前回合中能够赚取大量筹码时停止掷骰子。
    1. 《价格猜猜看》 游戏节目是许多有趣游戏的绝佳来源。例如,你可以尝试实现名为 Any Number 的游戏:

priceisright.fandom.com/wiki/Any_Number

在你的游戏中,玩家应该继续选择数字,直到揭示出其中一个奖项的所有数字;那就是玩家赢得的奖项。

    1. 使用 Pygame 库创建一个简单的游戏。在这个游戏中,玩家控制一个角色左右移动以收集下落的物体。目标是尽可能多地收集物体而不要错过任何一个。你可以在这里对游戏中物体的外观灵活处理,尽情享受乐趣!作为一个潜在的扩展:你能想到如何跟踪玩家的最高分吗?

摘要

  • 游戏有一个常见的程序流程,包括游戏设置和游戏玩法。

  • 随机性是许多游戏的重要成分。

  • 我们可以通过使用 random 模块中的函数来为我们的 Python 游戏添加随机性。

  • 我们可以使用与本书中相同的流程实现游戏,其中问题分解起着关键作用。*****

第十一章:创建一个作者身份识别程序

本章节涵盖

  • 使用自顶向下设计编写作者身份识别程序

  • 了解代码重构以及为什么你会这样做

在第七章中,我们在编写拼写建议程序时学习了问题分解和自顶向下的设计。在这里,我们将把自顶向下的设计提升到下一个层次,解决一个更大的问题。我们仍然在做第七章中同样的工作:将问题分解为子问题,并在需要时进一步将那些子问题分解为更小的子子问题。而且,就像之前一样,我们希望设计出具有少量参数且能返回对调用者有意义的和有用的结果的函数。如果我们能够设计出被多个其他函数调用的函数,那也是一个好兆头——这有助于减少代码重复!

我们包括这一章节,是因为我们希望提供一个比第七章中解决的拼写建议问题更真实的例子。我们希望这里的例子能够激励人心,感觉像是一个你可能会想要解决的问题。

在本章中,我们将编写一个尝试识别神秘书籍未知作者的程序。这将是使用人工智能(AI)进行预测的程序的一个例子。我们无法抗拒在关于编程与 AI 的书籍中包含一个 AI 例子的机会!

11.1 作者身份识别

这个问题基于我们同事米歇尔·克雷格[1]创建的作业。让我们先看看这两段书籍摘录:

  • 摘录 1——我还没有向你描述最独特的一部分。大约六年前——确切地说,是在 1882 年 5 月 4 日——泰晤士报上出现了一则广告,要求提供玛丽·莫斯坦小姐的地址,并表示她出面将对她有利。广告中没有附上姓名或地址。当时我刚刚以家庭教师身份加入了塞西尔·福雷斯特夫人的家庭。在她的建议下,我在广告栏中发布了我的地址。同一天,通过邮局寄来一个小纸板箱,上面写着我收,我发现里面装有一颗非常大的、光泽的珍珠。里面没有附上任何文字。从那时起,每年的同一天,总会出现类似的箱子,里面装着类似的珍珠,没有任何关于发送者的线索。专家们认为这些珍珠是罕见品种,价值不菲。你们自己可以看到,它们非常漂亮。

  • 摘录 2——那是 11 月底的一个周五夜晚,多佛路就在与这个历史有关的第一批人之前。对于他来说,多佛路位于多佛邮件之外,因为邮件车笨拙地爬上射手山。他和其他乘客一样沿着邮件车旁边的泥泞小路上山;并不是因为他们在这种情况下对步行锻炼有丝毫的兴趣,而是因为山丘、马具、泥泞和邮件车都如此沉重,以至于马匹已经三次停下来,而且一次还把马车拉到路上,意图把它拉回布莱克希思。然而,缰绳、鞭子、车夫和守卫联合起来,读到了那条禁止有强烈支持论点的文章,即某些动物被赋予了理性;于是马队屈服了,并返回了它们的职责。

假设我们问你这两段摘录是否可能是由同一作者写的。你可能做出的一个合理假设是,不同的作者有不同的写作风格,这些差异会在我们可以计算其文本的指标中体现出来。

例如,与第二段摘录相比,似乎撰写第一段摘录的人使用了相当多的短句。我们在第一段摘录中发现了像“没有附上名字或地址”和“没有附上任何文字”这样的短句;这些句子在第二段中不存在。同样,第一段摘录中的句子似乎比第二段中的句子简单;看看第二段中所有的逗号和分号。

这种分析可能会让你认为这些文本是由不同的作者写的,确实如此。第一段是由亚瑟·柯南·道尔爵士写的,第二段是由查尔斯·狄更斯写的。

公平地说,我们绝对是有选择性地挑选了这两段摘录。道尔确实使用了一些长而复杂的句子。狄更斯确实使用了一些简短的句子。但是,至少对于我们从中摘录这两段摘录的两本书来说,道尔的句子平均来说比狄更斯的短。更普遍地说,如果我们比较两位不同作者所写的两本书,我们可能会期望在平均意义上找到一些可以量化的差异。

假设我们有一系列我们知道作者的书。我们有一本道尔写的,一本狄更斯写的,等等。然后,出现了一本神秘的书。哦不!我们不知道是谁写的!这是道尔的失落《福尔摩斯探案故事》?狄更斯的失落《雾都孤儿》续集?我们想知道这位未知作者是谁,为了做到这一点,我们将转向一种基本的 AI 技术。

我们的策略将是为每位作者找到一个 签名,使用我们知道他们写过的其中一本书。我们将把这些签名称为 已知签名。每个这样的签名都将捕捉关于书籍文本的指标,例如每句话的平均单词数和平均句子复杂度。然后,我们将为未知作者的神秘书籍找到一个签名。我们将称之为 未知签名。我们将查看所有已知签名,将每个签名与我们的未知签名进行比较。我们将使用最接近的一个作为我们对未知作者的猜测。

当然,我们并不知道这个未知的作者是否真的是我们拥有的签名作者之一。它可能是一个全新的作者,例如。即使这个未知的作者确实是我们拥有的签名作者之一,我们仍然可能猜错。毕竟,也许同一个作者以不同的风格写作书籍(给他们的书籍带来非常不同的签名),或者我们未能捕捉到我们每位作者写作中最显著的特点。实际上,在本章中,我们并不是追求一个行业级的作者识别程序。尽管如此,考虑到这个任务的难度,我们认为我们将向您展示的方法效果非常好。

机器学习

正如我们在这里所做的那样,作者识别是一个 机器学习(ML) 任务。机器学习是人工智能的一个分支,旨在帮助计算机“学习”数据以便做出预测。机器学习有多种形式;我们在这里使用的是监督学习。在监督学习中,我们可以访问训练数据,它由对象及其已知的类别(或标签)组成。在我们的案例中,我们的对象是书籍文本,每本书的类别是写这本书的作者。我们可以通过计算每本书的特征(例如,每句话的平均单词数、平均句子复杂度等)来在训练集上训练(即学习)。后来,当我们得到一本我们不知道作者的书时,我们可以使用我们在训练中学到的知识来做出预测(或猜测)。

11.2 使用自顶向下设计进行作者识别

好的,我们想要“编写一个程序来确定一本书的作者。”这似乎是一个艰巨的任务,如果我们试图一次性完成,使用一个单独的函数,确实会是这样。但就像在第七章中我们的拼写建议示例中做的那样,我们不会这么做。我们将系统地把这个问题分解成我们可以解决的子问题。

在第七章中,我们通过读取输入、处理输入并产生输出结果的方式来解决拼写建议问题。我们可以把我们的作者识别程序看作也是遵循这个模型:

  • 输入步骤 — 对于输入步骤,我们需要询问用户神秘书籍的文件名。

  • 过程步骤 — 对于过程步骤,我们需要确定神秘书籍的签名(这是未知签名),以及我们知道作者每本书的签名(这些是已知签名)。为每本书创建签名通常被称为机器学习中的训练阶段。我们还需要将未知签名与每个已知签名进行比较,以确定哪个已知签名最接近。这些比较是机器学习中的预测阶段。

  • 输出步骤 — 对于输出步骤,我们需要向用户报告最接近已知签名的未知签名。

也就是说,为了解决我们的整体作者识别问题,我们需要解决这三个子问题。我们开始自顶向下的设计!

我们将顶层函数命名为make_guess。在其中,我们将解决我们确定的三个子问题。

对于输入步骤,我们只是要求用户输入一个文件名。这可以通过少量代码完成,所以我们可能不需要为这个步骤创建一个单独的函数。输出步骤看起来类似:假设我们已经知道哪个已知签名最接近,我们可以直接向用户报告。相比之下,过程步骤看起来需要做很多工作,我们当然希望进一步分解这个子问题。这就是我们接下来要做的。

11.3 分解过程子问题

我们将整体过程函数命名为process_data。它将接受神秘书籍的文件名和已知作者书籍目录的名称作为参数,并返回最接近的已知签名的名称。

观察我们对该过程步骤的描述,似乎我们在这里有三个子问题需要解决:

  • 确定神秘书籍的签名。这是我们未知的签名。我们将这个函数命名为make_signature

  • 确定已知作者每本书的签名。这些是我们的已知签名。我们将这个函数命名为get_all_signatures

  • 将未知签名与每个已知签名进行比较,以确定哪个已知签名最接近。因为接近的签名会有小的差异,我们将这个函数命名为lowest_score

我们将依次为这些子问题制定自顶向下的设计方案。图 11.1 展示了我们目前的设计图。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-1.png

图 11.1 process_data的三个子任务函数图

11.3.1 确定神秘书籍的签名

这个任务的函数make_signature将接受我们书籍的文本作为参数,并返回书籍的签名。在这个阶段,我们需要决定我们将使用哪些特征来确定每本书的签名。让我们通过回顾之前的例子段落来分析这个问题。我们注意到,根据句子的复杂性和长度,作者之间的段落存在差异。你可能也怀疑过,作者在使用的单词长度和用词方式上可能存在差异(例如,一些作者可能比其他作者更重复)。因此,我们希望有一些特征基于作者句子的结构,而其他特征基于作者使用的单词。我们将详细探讨这些特征。

与作者句子结构相关的特征

在我们之前的道尔对狄更斯例子中,我们讨论了使用每句话的平均单词数作为一个特征。我们可以通过将总单词数除以总句子数来计算这个值。例如,考虑以下文本:

就在同一天,通过邮局寄来一个小纸板箱,上面写着我的名字,我发现里面有一个非常巨大而光亮的珍珠。没有附上任何字条。

如果你计算单词和句子,你应该会发现有 32 个单词(纸板算作一个单词)和两个句子,因此我们将计算每句话的平均单词数为 32/2 = 16。这将是我们所说的每句话的平均单词数特征。

我们还注意到,句子的复杂度可能在作者之间有所不同(即,一些作者的句子比其他作者有更多的逗号和分号),因此将其作为另一个特征是有意义的。更复杂的句子有更多的短语,这些短语是连贯的句子片段。将一个句子分解为其组成部分短语是一项艰巨的挑战,尽管我们可以尝试做得更准确,但在这里我们将采用一个更简单的经验法则。也就是说,我们将说一个短语是通过逗号、分号或冒号与其他短语分开的。再次查看之前的文本,我们发现有三个短语。第一个句子有两个短语:“The same day there arrived through the post a small card-board box addressed to me”和“which I found to contain a very large and lustrous pearl.”第二个句子没有逗号、分号或冒号,所以只有一个短语。由于有三个短语和两个句子,我们会说这个文本的句子复杂度为 3/2 = 1.5。这将是我们所说的平均句子复杂度特征。

我们希望这些句子级别的特征直观上是有意义的,可以作为区分作者写作风格的东西。接下来,让我们开始探讨作者在用词上可能存在的差异。

与作者词汇选择相关的特征

你可能可以想到自己的单词级特征指标,但我们将使用三个在我们经验中效果很好的指标。首先,可能有些作者的平均单词长度比其他作者短。为此,我们将使用平均单词长度,这仅仅是每个单词的平均字母数。让我们考虑我们创建的这个样本文本:

一颗珍珠!珍珠!光泽的珍珠!稀有。多么好的发现。

如果你数一下字母和单词,你应该会发现有 41 个字母和 10 个单词。(在这里不要把标点符号算作字母。)所以,我们将计算平均单词长度为 41/10 = 4.1。这将是平均单词长度的特征。

第二,可能有些作者比其他作者更频繁地使用相同的单词。为了捕捉这一点,我们将使用作者使用的不同单词的数量除以总单词数。对于我们之前的样本文本,只有七个不同的单词被使用:一个珍珠光泽的稀有的什么好的,和找到的。总共有十个单词,所以这个指标的计算结果是 7/10 = 0.7。这将是不同单词除以总单词数的特征。

第三,可能有些作者倾向于一次性使用很多单词,而其他作者则倾向于多次使用相同的单词。为了计算这个指标,我们将使用一次的单词数量除以总单词数。对于我们的样本文本,有五个单词是只使用了一次:光泽的稀有的什么好的,和找到的。总共有十个单词,所以这个指标的计算结果是 5/10 = 0.5。这将是使用一次的单词数除以总单词数的特征。

总的来说,我们有五个特征将构成每个签名。我们需要将这些数字存储在一个单独的值中,所以我们将为每个签名使用五个数字的列表。

让我们深入了解我们将如何实现每个特征,从单词级特征开始,然后到句子级特征。我们将按以下顺序进行:

  • 平均单词长度

  • 不同单词除以总单词数

  • 使用一次的单词数除以总单词数

  • 每句平均单词数

  • 平均句子复杂度

对于每一个,我们最终将编写一个函数。我们有一个更新后的图,其中包含每个这五个新函数的函数名,这将帮助我们实现图 11.2 中的make_signature。我们需要进一步分解这些问题,还是它们就这样可以?让我们看看!

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-2.png

图 11.2 函数图,包含make_signature的额外五个子任务
平均单词长度

用于此任务的函数average_word_length将接受书籍的文本作为参数,并返回平均单词长度。我们可能通过在文本上使用 split 方法来开始解决这个问题。提醒一下,split 方法用于将字符串分割成其各个片段的列表。默认情况下,split 会在空格周围分割。书籍文本是一个字符串,如果我们围绕空格分割,我们就会得到其单词!这正是我们需要的。然后我们可以遍历这个单词列表,计算字母数和单词数。

这是个不错的开始,但我们需要更加小心,因为我们不希望最终将非字母字符计为字母。例如,“pearl”有五个字母。但“pearl.”、“pearl!!”或“(pearl)”也是如此。啊哈——这听起来像是我们的一项子任务!具体来说,我们可以将清理单词的子任务划分为一个单独的函数,用于average_word_length。我们将称这个清理函数为clean_word

我们的clean_word函数还有一个好处,那就是帮助我们识别一个“单词”实际上不是一个单词的情况。例如,假设我们的文本中的一个“单词”是……当我们将其传递给clean_word时,我们会得到一个空字符串。这表示这实际上根本不是一个单词,所以我们不会将其计为一个单词。

不同单词与总单词的比例

用于此任务的函数different_to_total将接受书籍的文本作为参数,并将不同单词的数量除以总单词数。

average_word_length一样,我们需要小心只计算字母,而不是标点符号。但是等等——我们刚刚讨论了clean_word函数,我们需要的average_word_length。我们也可以在这里使用该函数!实际上,我们将在我们五个特征任务中的大多数任务中使用clean_word。这是有用的一般用途函数的标志!我们的自顶向下的设计进展顺利。我们可以看到clean_word函数如何在图 11.3 中更新的功能图中被两个函数调用。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-3.png

图 11.3 包含两个函数的功能图,这两个函数都使用了我们的clean_word函数来帮助

然而,这里有一个额外的复杂性,涉及到像pearlPearlPEARL这样的单词。我们希望将这些视为相同的单词,但如果我们简单地使用字符串比较,它们将被视为不同的单词。这里的解决方案之一是将这个子问题分离出来,将其转换为全小写的字符串。我们也可以将其视为清理单词的另一个部分,与去除标点符号一样。我们将选择第二个选项。那么,我们将使我们的clean_word函数不仅去除标点符号,还将单词转换为小写。

你可能会想知道我们是否需要在这里拆分另一个子任务,一个确定不同单词数量的子任务。你可以这样做,这样做并不错误。然而,如果我们坚持不这样做,我们会发现函数在没有这个额外子任务的情况下仍然相当易于管理。随着时间的推移,实践和经验将帮助你预测何时需要进一步分解任务。

一次使用的单词数除以总单词数

这个任务的函数exactly_once_to_total将接受书籍的文本作为参数,并返回一次使用的单词数除以总单词数。我们还需要在这里使用clean_word函数,原因与我们在前两个任务中需要它的原因类似:确保我们只处理字母,而不是标点符号。同样,虽然我们可以拆分一个子任务来确定一次使用的单词数量,但我们会发现用 Python 代码做这件事并不需要太多,所以我们不会进一步拆分这个任务。

每句的平均单词数

这个任务的函数average_sentence_length将接受书籍的文本作为参数,并返回每句的平均单词数。为了将我们的文本拆分为单词以进行前三个任务,我们可以使用字符串拆分方法。我们如何将文本拆分为句子?有没有一个字符串方法可以做到这一点?

很遗憾,没有。因此,将任务拆分为将文本字符串拆分成句子的任务将是有帮助的。我们将为这个子任务命名函数为get_sentencesget_sentences函数将接受书籍的文本作为参数,并从文本中返回一个句子列表。

什么是句子?我们将句子定义为由句号(.)、问号(?)或感叹号(!)分隔的文本。这个规则虽然方便且简单,但会犯错误。例如,这个文本中有多少个句子?

在那时,我只是以家庭教师的身份加入了塞西尔·福雷斯特夫人的家庭。

答案是一。然而,我们的程序将提取两个句子,而不是一个。它会被单词*Mrs.*欺骗,该单词在结尾处有一个句号。如果你继续在本章之后进行作者识别,你可以尝试使你的规则更加健壮,或者使用复杂的高级自然语言处理(NLP)软件来做得更好。然而,对于我们来说,我们将满足于这个有时会出错但大多数时候会正确的规则。如果我们偶尔出错,这些错误对我们的指标的影响将不会很大。

每句的平均复杂度

我们将为这个任务命名的函数是average_sentence_complexity。它将接受句子的文本作为参数,并返回句子复杂度的度量。

正如我们之前讨论的,我们感兴趣的是使用句子中的短语数量来量化句子复杂性。就像我们使用标点符号来分隔句子一样,我们将使用不同的标点符号来分隔短语。具体来说,我们将说短语是由逗号(,)、分号(;)或冒号(:)分隔的。

有一个子任务来将句子分解成短语,就像我们有一个子任务来将文本分解成句子一样,那会很棒!我们将把这个子任务命名为 get_phrasesget_phrases 函数将接受一本书的句子作为参数,并返回句子中的短语列表。

让我们暂停一下,思考一下我们正在用 get_sentencesget_phrases 函数做什么。回想起来,它们非常相似。它们之间的区别仅在于它们用来进行分割的字符。get_sentences 关注的是句号、问号和感叹号,而 get_phrases 关注的是逗号、分号和冒号。我们看到一个机会,可以创建一个父任务来简化这两个任务!

也就是说,想象一下我们有一个名为 split_string 的函数,它接受两个参数,即文本和分隔符字符的字符串,并返回由任何分隔符分隔的文本片段列表。然后我们可以用 '.?!' 来分割成句子,用 ',;:' 来分割成短语。这将使 get_sentencesget_phrases 的实现更加容易,并减少代码重复。这是一个胜利!

到目前为止,我们已经完全实现了支持高级函数 make_signature 所需的所有函数,如图 11.4 所示。接下来,我们将转向 get_all_signatures 函数。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-4.png

图 11.4 函数图,其中包含 make_signature 函数的所有支持函数已全部完成
确定每个已知签名

我们刚刚努力将 make_signature 函数分解为五个主要任务,每个任务对应于我们签名的一个特性。我们设计该函数是为了确定未知签名——即试图识别的神秘文本的签名。

我们接下来的任务是确定我们已知作者的所有书籍的签名。在这本书的资源中,在 ch11 文件夹下,你会找到一个名为 known_authors 的目录。在那里,你会找到几个文件,每个文件都按作者命名。每个文件都包含那位作者所写的书籍。例如,如果你打开 Arthur_Conan_Doyle.txt,你会找到亚瑟·柯南·道尔的书籍《血字的研究》的文本。我们需要确定这些文件中每个文件的签名。

令人惊讶的是,我们解决这个问题的实际工作量远小于表面上看起来那么多。这是因为我们可以使用那个相同的 make_signature 函数,即我们为确定神秘书籍的签名而设计的函数,来同样确定任何已知书籍的签名!

我们将把这个任务的函数命名为get_all_signatures。这个函数接受一本书的文本作为参数是没有意义的,因为它应该能够获取我们所有已知书籍的签名。相反,它将接受一个已知书籍的目录作为参数。它的行为将是遍历该目录中的文件,计算每个文件的签名。

我们需要一个函数来告诉我们哪个签名对应哪本书。换句话说,我们需要它将每本书与其对应的签名关联起来。这种关联正是 Python 有字典的原因!因此,我们将让这个函数返回一个字典,其中键是文件名,值是对应的签名。我们的函数图不需要任何新的函数来支持get_all_signatures函数,所以图 11.5 中更新的图只显示了get_all_signatures如何调用make_signature

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-5.png

图 11.5 get_all_signatures调用make_signature的函数图更新
寻找最接近的已知签名

让我们回顾一下到目前为止我们设计的:

  • 我们已经设计了make_signature函数来为我们获取神秘书籍的未知签名。

  • 我们已经设计了get_all_signatures函数来获取我们所有的已知签名。

现在,我们需要设计一个函数来告诉我们那些已知的签名中哪一个是最合适的;也就是说,哪一个已知的签名与我们的未知签名最接近。我们的每个签名都将是一个包含五个数字的列表,表示我们五个特征的量。这些数字的顺序将与之前使用的顺序相同:平均单词长度、不同单词数除以总单词数、仅使用一次的单词数除以总单词数、每句话的平均单词数和平均句子复杂性。

假设我们有两个签名。第一个是[4.6, 0.1, 0.05, 10, 2],这意味着这本书的平均单词长度是 4.6,不同单词数除以总单词数是 0.1,等等。第二个签名是[4.3, 0.1, 0.04, 16, 4]

有许多方法可以得到一个总分,表示签名之间的差异。我们将使用的方法将为每个特征提供一个差异分数,然后我们将把这些分数加起来得到总分。

让我们看看第一个特征每个签名的值:4.6 和 4.3。如果我们减去这些值,我们得到差值 4.6 – 4.3 = 0.3。我们可以用 0.3 作为这个特征的答案,但结果证明如果我们使用不同的权重来加权每个差值,效果会更好。每个权重都表示该特征的重要性。我们将使用一些经验证明效果良好的权重([11, 33, 50, 0.4, 4])。你可能想知道这些权重从何而来。但请注意,它们并没有什么神奇之处:在多年的学生工作中,我们发现这些权重似乎效果不错。这将是更强大的作者识别程序的一个起点。在进行这类研究时,人们通常会调整他们的训练,这意味着调整权重以获得更好的结果。

当我们说我们使用权重 [11, 33, 50, 0.4, 4] 时,这意味着我们将第一个特征的差值乘以 11,第二个特征的差值乘以 33,依此类推。因此,对于第一个特征,我们不会得到 0.3 的差值,而是得到 0.3 × 11 = 3.3。

我们需要小心处理第四个这样的特征,其中差值是负数。我们不希望从 10 – 16 = –6 开始,因为这是一个负数,这会抵消其他特征的一些正差值。相反,我们首先需要使这个数字为正,然后乘以它的权重。从一个数字中移除负号称为取绝对值,绝对值表示为 abs。因此,这个第四个特征的完整计算是 abs(10 – 16) × 0.4 = 2.4。

表 11.1 给出了每个特征的计算方法。如果我们把所有五个分数加起来,我们得到一个总分 14.2。

表 11.1 计算两个签名之间的差异
特征编号 签名 1 中特征值 签名 2 中特征值 特征权重 特征贡献
1 4.6 4.3 11 abs(4.6 – 4.3) × 11 = 3.3
2 0.1 0.1 33 abs(0.1 – 0.1) × 33 = 0
3 0.05 0.04 50 abs(0.05 – 0.04) × 50 = .5
4 10 16 0.4 abs(10 – 16) × 0.4 = 2.4
5 2 4 4 abs(2 – 4) × 4 = 8
Sum 14.2

记住我们在自顶向下的设计中的位置:我们需要一个函数来告诉我们哪个已知签名是最好的。现在我们知道如何比较两个签名并得到该比较的分数。我们希望将未知签名与每个已知签名进行比较,以确定哪个已知签名最好。分数越低,签名越接近;分数越高,签名越不同。因此,我们最终会选择比较分数最低的签名。

我们将为这个任务命名的函数为lowest_score。它将接受三个参数:一个将作者名字映射到他们已知签名的字典、一个未知签名和权重列表。该函数将返回与我们的未知签名比较分数最低的签名。

考虑这个函数需要完成的工作。它需要遍历已知的签名。我们可以用for循环来完成这个任务——不需要子任务。它需要将未知签名与当前已知签名进行比较。哦!那是一个子任务,体现了我们在表 11.1 中概述的评分机制。我们将为这个子任务命名的函数为get_score。我们的get_score函数将接受两个要比较的签名和权重列表,并返回这两个签名之间的比较分数。

11.4 我们自顶向下设计的总结

我们做到了!我们已经将原始的大问题分解成几个更小的问题,这些问题可以作为一个函数来实现。

图 11.6 展示了我们在分解问题的过程中所做的工作。记住,我们从一个make_guess函数开始,这个函数将解决整体问题。为了帮助make_guess,我们创建了一个process_data函数,它将为make_guess做一些工作。为了帮助process_data,我们又创建了三个更多函数,make_signatureget_all_signatureslowest_score,每个函数都有自己的辅助函数,等等。在勾勒出解决我们问题的所需函数后,我们的下一步将是实现它们。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-6.png

图 11.6 make_guess的完整函数图

11.5 实现我们的函数

现在我们已经准备好让 Copilot 实现我们需要的每个函数。我们设计函数的方式是从上到下——从最大的问题开始,逐步细化到更小的问题。但请记住,从第七章我们知道这不是实现函数的顺序;相反,我们是从下到上(或如图 11.6 所示从右到左)实现函数的。

正如我们在第七章的例子中一样,我们不会过多关注测试、提示工程、调试或代码阅读。我们鼓励你运行我们提供的文档字符串测试的 doctest,并进一步鼓励你为每个函数添加额外的测试。

11.5.1 clean_word

我们将从clean_word函数开始。像往常一样,我们提供函数头(def行)和文档字符串,并让 Copilot 填写代码。我们还提供了一些注释,简要说明代码的工作原理。

记住,我们希望clean_word函数能够移除可能出现在单词周围的标点符号,并将单词转换为小写。但我们不希望干扰单词中间的标点符号,比如card-board中的“-”。我们已经编写了文档字符串来清楚地说明我们的需求。

列表 11.1 分析用的清洁单词
def clean_word(word):
 '''
 word is a string.

 Return a version of word in which all letters have been
 converted to lowercase, and punctuation characters have been
 stripped from both ends. Inner punctuation is left untouched.

 >>> clean_word('Pearl!')
 'pearl'
 >>> clean_word('card-board')
 'card-board'
 '''
    word = word.lower()                #1
    word = word.strip(string.punctuation)   #2
    return word

#1 将单词转换为小写

#2 使用字符串模块从两端去除标点符号

当我们在第三章处理密码函数时,我们看到了 Copilot 使用字符串模块,在这里我们又看到了 Copilot 在这样做。我们知道从第三章的工作中,除非我们首先导入字符串,否则这不会起作用,所以添加

import string

上述函数如以下列表所示。

列表 11.2 分析用的清洁单词,已完成
import string
def clean_word(word):
 '''
 word is a string.

 Return a version of word in which all letters have been
 converted to lowercase, and punctuation characters have 
 been stripped from both ends. Inner punctuation is left
 untouched.

 >>> clean_word('Pearl!')
 'pearl'
 >>> clean_word('card-board')
 'card-board'
 '''
    word = word.lower() 
    word = word.strip(string.punctuation) 
    return word

这完成了 clean_word 函数,因此我们可以在图 11.7 的函数图中标记为完成。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-7.png

图 11.7 完整的函数图,clean_word 现已完成

11.5.2 average_word_length

现在我们来处理我们五个标志性功能函数中的第一个:average_word_length。它需要确定每个单词的平均字母数,但我们不想将周围的标点符号算作字母,也不包括没有字母的单词。我们想在这里使用 clean_word 函数,如以下列表所示。和往常一样,我们编写了文档字符串,希望它能指导 Copilot 做出这些决定。

列表 11.3 平均单词长度
def average_word_length(text):
 '''
 text is a string of text.

 Return the average word length of the words in text.
 Do not count empty words as words.
 Do not include surrounding punctuation.

 >>> average_word_length('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 4.1
 '''
    words = text.split()        #1
    total = 0                      #2
    count = 0                #3
    for word in words:        #4
        word = clean_word(word)       #5
        if word != '':              #6
            total += len(word)     #7
            count += 1            #8
    return total / count        #9

#1 将字符串拆分为其单词

#2 总计将计算所有单词中的字母总数。

#3 count 将计算单词的数量。

#4 遍历每个单词

#5 协作者调用 clean_word 为我们服务!

#6 仅考虑不为空的单词

#7 添加单词中的字母数

#8 将 1 添加到计数以记录这个单词

#9 返回字母数与单词数的比例

你会注意到在这个 doctest 中,我们将字符串分成了两行,第一行以 \ 字符结束。我们这样做的原因是,如果不这样做,字符串将无法在书的一行中显示。我们还需要保持第二行没有任何缩进;否则,doctest 会将那个缩进作为字符串中的空格。在你的电脑上,你可以将字符串放在一行中输入,不必担心 \ 或缩进。

现在,我们可以在更新的图(图 11.8)中标记 average_word_length 为完成。尽管令人满意,但一个接一个地在图中标记这些可能会有些过于嘈杂,所以我们将定期回顾这个图。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-8.png

图 11.8 完整的函数图,average_word_length 现已完成

11.5.3 different_to_total

这是我们的第二个标志性功能。我们需要这个功能来计算不同单词的使用次数与总单词数的比例。同样,我们不想包括周围的标点符号或空单词。

列表 11.4 不同单词与总单词数的比例
def different_to_total(text):
 '''
 text is a string of text.

 Return the number of unique words in text
 divided by the total number of words in text.
 Do not count empty words as words.
 Do not include surrounding punctuation.
 >>> different_to_total('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 0.7
 '''
    words = text.split()     #1
    total = 0                     #2
    unique = set()               #3
    for word in words:
        word = clean_word(word)     #4
        if word != '':
            total += 1
            unique.add(word)      #5
    return len(unique) / total      #6

#1 将字符串拆分为其单词

#2 总计将计算非空单词的总数。

#3 找到的一组唯一单词

#4 协作者再次为我们调用 clean_word!

#5 集合就像列表,但不接受重复项。

#6 如果这个单词不为空,则考虑这个单词

这段代码使用 Python 集合而不是 Python 列表来存储它找到的唯一单词。集合与列表类似,但不会接受重复项,所以如果你尝试多次添加相同的单词,它只会保留一个单词的副本。这就是为什么我们可以在返回语句中使用len(unique)来获取唯一单词的总数。

在这里我们不需要了解更多的 Python 集合知识。但如果你有兴趣了解更多,可以向 Copilot 请求代码解释,或者通过 Google 搜索 Python 集合的相关信息。

11.5.4 exactly_once_to_total

我们准备实现第三个签名功能。这个功能计算使用一次的单词数与总单词数的比值,如下所示。我们向 Copilot 提供了提示,并收到了以下函数。

列表 11.5 使用一次的单词占比:尝试 1
def exactly_once_to_total(text):
 '''
 text is a string of text.

 Return the number of words that show up exactly once in text
 divided by the total number of words in text.
 Do not count empty words as words.
 Do not include surrounding punctuation.

 >>> exactly_once_to_total('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 0.5
 '''
    words = text.split()
    unique = set()
    total = 0
    for word in words:
        word = clean_word(word)
        if word != '':
            if word not in unique:
                unique.add(word)
            else:
                unique.remove(word)   #1
            total += 1
    return len(unique) / total

#1 从唯一集合中移除单词?

当我们浏览这段代码时,看起来unique是一个记录唯一单词的集合。最后,我们将唯一单词的数量除以总单词数。

然而,使用 doctest 运行我们的测试用例,你会发现我们的代码没有通过测试。我们需要的结果是 0.5,但遗憾的是,我们的代码给出了 0.6。我们的代码是错误的。我们需要保持警惕!

让我们回顾一下代码,看看为什么它是错误的。想象一下一个单词在文本中使用了三次。第一次我们的代码找到它时,代码会将其添加到unique中。第二次,它会从unique移除它。到目前为止一切正常,因为这个词不再只使用一次了,所以它不应该在那里。但是第三次,它会将这个词再次添加到unique中!所以,我们的代码将会把一些单词计为只使用了一次,而实际上它们被使用了多次。我们查看了按下 Ctrl-Enter 时出现的列表,并找到了这个替代版本,如下所示,它是正确的。

列表 11.6 使用一次的单词占比:尝试 2
def exactly_once_to_total(text):
 '''
 text is a string of text.

 Return the number of words that show up exactly once in text
 divided by the total number of words in text.
 Do not count empty words as words.
 Do not include surrounding punctuation.

 >>> exactly_once_to_total('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 0.5
 '''
    words = text.split()
    total = 0
    unique = set()             #1
    once = set()             #2
    for word in words:
        word = clean_word(word)
        if word != '':
            total += 1
            if word in unique:     #3
                once.discard(word)     #4
            else:
                unique.add(word)     #5
                once.add(word)        #6
    return len(once) / total        #7

#1 跟踪我们看到的所有单词

#2 跟踪使用一次的单词——这是我们最终关心的

#3 我们再次看到了这个单词。

#4 所以这个单词必须从once中移除。

#5 我们现在已经看到了这个单词。

#6 到目前为止,这个单词只使用了一次。

#7 返回使用一次的单词数与总单词数的比值

这段代码有点棘手!要理解它,首先关注else代码。这是第一次看到每个单词时运行的代码。那个单词会被添加到uniqueonce集合中。once集合将为我们跟踪使用一次的单词。

现在想象一下,我们第二次看到一个单词。当单词已经在unique中时(我们第一次看到这个单词时添加到那里),if代码将会运行。现在,因为我们已经看到了这个单词多次,我们需要从once集合中移除它。这正是if代码所做的:它使用once.discard(word)once中移除单词。

总结来说,当我们第一次看到某个单词时,它会添加到once中。当我们再次看到它时,它会从once中移除,并且永远无法再次添加到once中。once集合正确地跟踪了只使用过一次的单词。

11.5.5 split_string

我们已经完成了三个单词级别的签名特征函数。在我们能够继续到两个句子级别的签名特征函数之前,我们需要编写get_sentences。但为了编写get_sentences,我们首先需要split_string,这正是我们现在要工作的。

我们的split_string函数应该能够根据任意数量的分隔符分割字符串。它本质上与句子或短语无关。我们包含了一个 docstring 测试来强调这一点:尽管我们将使用它来分割句子和短语,但它比这更通用。看看下面的列表。

列表 11.7 分割字符串周围的分隔符
def split_string(text, separators):
 '''
 text is a string of text.
 separators is a string of separator characters.

 Split the text into a list using any of the one-character
 separators and return the result.
 Remove spaces from beginning and end
 of a string before adding it to the list.
 Do not include empty strings in the list.

 >>> split_string('one*two[three', '*[')
 ['one', 'two', 'three']
 >>> split_string('A pearl! Pearl! Lustrous pearl! Rare. \
What a nice find.', '.?!') 
 ['A pearl', 'Pearl', 'Lustrous pearl', 'Rare', \
'What a nice find']
 '''
    words = []        #1
    word = ''             **#2
    for char in text:
        if char in separators:    #3
            word = word.strip()      #4
            if word != '':          #5
                words.append(word)      #6
            word = ''                 #7
        else:
            word += char           #8
    word = word.strip()      #9
    if word != '':          **
        words.append(word)  ** #9
    return words #9******

******#1 更好的变量名应该是 all_strings。

#2 更好的变量名应该是 current_string。

#3 当前字符串在这里结束。

#4 从当前字符串的开始和结束处移除任何空格

#5 如果当前字符串不为空 . . .

#6 . . . 将其保存为分割字符串之一。

#7 清除当前字符串,为下一个字符串做准备

#8 向当前字符串添加内容(尚未分割)

#9 处理最终的分割字符串,如果它不为空****** ******您可能对for循环之后和return语句之前的代码感到好奇。它似乎在复制for循环内部的某些代码,那么它在那里做什么呢?这段代码之所以存在,是因为当循环找到分隔符字符时,它才会将分割字符串添加到我们的字符串列表中。如果文本不以分隔符字符结尾,循环就不会添加最终的分割字符串。循环下面的代码确保这个最终的分割字符串不会丢失。

自从我们更新了包含我们已完成函数的图表以来已经有一段时间了。是时候更新了!这也提醒我们,我们是从底部向上(在图表中从右到左)完成函数的。因此,图 11.9 显示了到目前为止我们已完成的函数。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-9.png

图 11.9 完整函数图更新了different_to_totalexactly_once_to_totalsplit_string现在已完成

11.5.6 get_sentences

在我们的自顶向下设计中,我们将get_sentences的大部分工作委托给了split_string函数。因此,我们希望 Copilot 通过调用split_string来实施get_sentences

我们不希望我们的句子开头或结尾有任何空格,也不希望有任何空句子。我们最初在文档字符串中提出了这些要求,但即使没有它们,Copilot 也能正确地调用split_string函数,并且该函数无论如何都会处理这些边缘情况。

列表 11.8 从文本中返回句子列表
def get_sentences(text):
 '''
 text is a string of text.

 Return a list of the sentences from text.
 Sentences are separated by a '.', '?' or '!'.

 >>> get_sentences('A pearl! Pearl! Lustrous pearl! Rare. \
What a nice find.')
 ['A pearl', 'Pearl', 'Lustrous pearl', 'Rare', \
'What a nice find']
 '''
    return split_string(text, '.?!')     #1

#1 使用句子分隔符调用 split_string

11.5.7 平均句子长度

现在我们有了get_sentences函数,我们可以继续进行average_sentence_length,这是我们第四个签名特征。这个特征计算每个句子的平均单词数。我们向 Copilot 提供了提示,并收到了以下列表中显示的函数。

列表 11.9 每个句子的平均单词数
def average_sentence_length(text):
 '''
 text is a string of text.

 Return the average number of words per sentence in text.
 Do not count empty words as words.

 >>> average_sentence_length('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 2.0
 '''
    sentences = get_sentences(text)    #1
    total = 0
    for sentence in sentences:        #2
        words = sentence.split()     #3
        for word in words:       #4
            if word != '':
                total += 1
    return total / len(sentences)     #5

#1 获取句子列表

#2 遍历句子

#3 将当前句子分解为其单词

#4 遍历句子的单词

#5 返回单词数除以句子数

11.5.8 获取短语

就像我们需要get_sentences函数才能实现average_sentence_length一样,我们需要get_phrases函数才能实现average_sentence_complexity

get_sentences函数一样,我们期望 Copilot 调用split_string来获取短语。这正是它为我们所做的事情,如下列所示。

列表 11.10 从句子中返回短语列表
def get_phrases(sentence):
 '''
 sentence is a sentence string.

 Return a list of the phrases from sentence.
 Phrases are separated by a ',', ';' or ':'.

 >>> get_phrases('Lustrous pearl, Rare, What a nice find')
 ['Lustrous pearl', 'Rare', 'What a nice find']
 '''
    return split_string(sentence, ',;:')    #1

#1 使用短语分隔符调用 split_string

11.5.9 平均句子复杂度

在完成get_phrases后,我们现在可以提示实现average_sentence_complexity。代码如下列所示。

列表 11.11 每个句子的平均短语数
def average_sentence_complexity(text):
 '''
 text is a string of text.

 Return the average number of phrases per sentence in text.

 >>> average_sentence_complexity('A pearl! Pearl! Lustrous \
pearl! Rare. What a nice find.')
 1.0
 >>> average_sentence_complexity('A pearl! Pearl! Lustrous \
pearl! Rare, what a nice find.')
 1.25 **#1
 **'''**
    sentences = get_sentences(text)     #2
    total = 0
    for sentence in sentences:               #3
        phrases = get_phrases(sentence)       #4
        total += len(phrases)             #5
    return total / len(sentences)      #6**

**#1 我们将一个句号改为逗号,使其成为 5/4 = 1.25。

#2 获取句子列表

#3 遍历句子

#4 获取当前句子的短语列表

#5 添加当前句子的短语数

#6 返回短语数除以句子数** **我们现在真的在进步!我们已经完成了创建make_signature所需的所有函数,如图 11.10 所示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-10.png

图 11.10 完整函数图已更新,显示我们现在可以编写make_signature

11.5.10 创建签名

到目前为止,我们已经编写了九个函数,虽然它们都很重要,但我们现在可能感到有点不满意,因为我们甚至还没有处理文本签名。我们有一些函数可以清理单词,以各种方式分割字符串,并计算签名的单个特征,但没有一个函数可以创建完整的签名。

现在情况有所改变,因为我们终于准备实现make_signature来为我们提供文本的签名。这个函数将接受一本书的文本,并返回一个包含五个数字的列表,每个数字都是调用我们五个特征函数之一的结果。

列表 11.12 文本的数字签名
def make_signature(text):
 '''
 The signature for text is a list of five elements:
 average word length, different words divided by total words, 
 words used exactly once divided by total words,
 average sentence length, and average sentence complexity.

 Return the signature for text. 

 >>> make_signature('A pearl! Pearl! Lustrous pearl! \
Rare, what a nice find.')
 [4.1, 0.7, 0.5, 2.5, 1.25]
 '''
    return [average_word_length(text),            #1
            different_to_total(text),            ** #1
            exactly_once_to_total(text),         ** #1
            average_sentence_length(text),        #1
            average_sentence_complexity(text)]   ** #1******

#1 我们五个特征函数都被调用了。 ******请注意,这个函数可以简单地通过调用我们五个特征函数来实现。现在停下来思考一下,如果没有先进行良好的自顶向下的设计,这个函数会多么混乱。我们在这里调用的所有五个函数的代码都必须在一个函数中,它们各自的变量和计算混合在一起,形成了一团糟。幸运的是,我们使用了自顶向下的设计!因此,我们的函数更容易阅读,也更容易让我们相信自己正在做正确的事情。

11.5.11 get_all_signatures

我们的process_data函数有三个子任务需要我们实现。我们刚刚完成了第一个(make_signature),所以现在我们将继续进行其第二个子任务,即我们的get_all_signatures函数。

从现在起,我们假设你的工作目录包含你的代码,并且它还包含我们提供的书籍子目录。我们需要这个函数为我们目录中的每个已知作者文件返回签名。我们希望 Copilot 在这里调用make_signature,使这个函数比其他方式简单得多。

Copilot 确实为我们做了这件事,但我们得到的代码仍然有两个问题。我们的初始代码如下所示。

列表 11.13 从已知作者处获取所有签名:尝试 1
def get_all_signatures(known_dir):
 '''
 known_dir is the name of a directory of books.
 For each file in directory known_dir, determine its signature.

 Return a dictionary where each key is
 the name of a file, and the value is its signature.
 '''
    signatures = {}                                 #1
    for filename in os.listdir(known_dir):             #2
        with open(os.path.join(known_dir,           #3
                               filename)) as f:    ** #3
            text = f.read()                            #4
            signatures[filename] = make_signature(text)   #5
    return signatures**

#1 我们最初为空的字典将文件名映射到签名。

#2 遍历已知作者目录中的每个文件

#3 打开当前文件

#4 读取文件中的所有文本

#5 为文本创建签名并将其存储在字典中** **尝试从 Python 提示符运行此函数作为

>>> get_all_signatures('known_authors')

你会得到以下错误:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\repos\book_code\ch11\authorship.py", line 207, 
  in get_all_signatures
    for filename in os.listdir(known_dir):
                    ^^
NameError: name 'os' is not defined

错误告诉我们,该函数试图使用名为 os 的模块,但我们没有这个模块。这个模块是 Python 内置的,我们知道在这种情况下要做什么:导入它!也就是说,我们需要添加

import os

在此函数上方。之后,我们仍然得到一个错误:

>>> get_all_signatures('known_authors')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\repos\book_code\ch11\authorship.py", line 209, 
  in get_all_signatures
    text = f.read()
           ^^^^^^^^
  File "…\Lib\encodings\cp1252.py", line 23, in decode
    return codecs.charmap_decode(input,self.errors,decoding_table)[0]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d in 
position 2913: character maps to <undefined>

你可能想知道UnicodeDecodeError是什么。如果你对技术解释感兴趣,可以谷歌搜索或向 ChatGPT 提问。我们需要知道的是,我们打开的每个文件都是用特定的方式编码的,而 Python 选择了错误的编码来尝试读取这个文件。

然而,我们可以通过在函数顶部附近添加注释来指导 Copilot 修复它。(当你遇到这些错误时,你可以在生成的错误代码上方直接放置一个注释。然后,一旦你删除了错误的代码,Copilot 通常可以生成新的正确代码。)一旦我们这样做,一切都会好起来,如下所示。

列表 11.14 从已知作者处获取所有签名:尝试 2
import os

def get_all_signatures(known_dir):
 '''
 known_dir is the name of a directory of books.
 For each file in directory known_dir, determine its signature.

 Return a dictionary where each key is
 the name of a file, and the value is its signature.
 '''
    signatures = {}
 # Fix UnicodeDecodeError **#1
    for filename in os.listdir(known_dir):
        with open(os.path.join(known_dir, filename), 
                  encoding='utf-8') as f:
            text = f.read()
            signatures[filename] = make_signature(text)
    return signatures**

#1 这个提示告诉 Copilot 修复我们之前看到的错误。 现在,如果你运行这个函数,你应该会看到一个作者和他们的签名的字典,如下所示:

>>> get_all_signatures('known_authors')
{'Arthur_Conan_Doyle.txt': [4.3745884086670195, 
0.1547122890234636, 0.09005503235165442, 
15.48943661971831, 2.082394366197183], 
'Charles_Dickens.txt': [4.229579999566339, 
0.0796743207788547, 0.041821158307855766, 
17.286386709736963, 2.698477157360406], 
'Frances_Hodgson_Burnett.txt': [4.230464334694739, 
0.08356818832607418, 0.04201769324672584, 
13.881251286272896, 1.9267338958633464], 
'Jane_Austen.txt': [4.492473405509028, 
0.06848572461149259, 0.03249477538065084, 
17.507478923035084, 2.607560511286375], 
'Mark_Twain.txt': [4.372851190055795, 
0.1350377851543188, 0.07780210466840878, 
14.395167731629392, 2.16194089456869]}

为了简单起见,我们没有在这个函数的文档字符串中添加测试。如果我们这样做,我们会创建一个假的、小的书籍,类似于我们在第六章第二个示例中所做的。不过,我们在这里想继续我们的函数分解的整体目的,所以如果你愿意继续这个练习,我们可以留给你。如图 11.11 所示,我们已经完成了两个 process_data 子任务。让我们继续前进!

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-11.png

图 11.11 完整函数图更新以显示 make_signatureget_all_signatures 已完成

11.5.12 获取分数

让我们实现 get_score,我们需要编码比较签名的方式。记得我们之前在每个特征上找到差异,乘以权重,然后将所有这些加在一起得到一个总分?这正是我们希望 get_score 做的。

在文档字符串中解释这个公式将是一个挑战。我们甚至不确定它是否应该放在那里:文档字符串应该解释如何使用你的函数,而不是它内部的工作方式。而且,可以说,我们的函数用户不会关心这个特定的公式。我们可以做的是使用一个通用的文档字符串,不包含我们的特定公式,看看 Copilot 会如何处理它。以下是在以下列表中的内容。

列表 11.15 比较两个签名
def get_score(signature1, signature2, weights):
 '''
 signature1 and signature2 are signatures.
 weights is a list of five weights.

 Return the score for signature1 and signature2.

 >>> get_score([4.6, 0.1, 0.05, 10, 2],\
 [4.3, 0.1, 0.04, 16, 4],\
 [11, 33, 50, 0.4, 4]) **#1
 **14.2**
 **'''**
    score = 0
    for i in range(len(signature1)):                #2
        score += abs(signature1[i] - signature2[i]) \   #3
                 * weights[i]                           #3
    return score**

**#1 这些权重 [11, 33, 50, 0.4, 4] 对我们来说效果很好。

#2 遍历每个签名索引

#3 将加权差异加到分数** **Copilot 实现了我们想要的公式。现在,在我们开始认为 Copilot 与我们心灵相通或类似的事情之前,记住我们在这里使用的公式是用于比较签名的非常常见的指标。多年来,许多学生和其他程序员都使用这个公式实现了作者识别。Copilot 只是将其反馈给我们,因为它在训练数据中非常常见。如果 Copilot 碰巧给了我们一个不同的公式,我们可以尝试在注释中描述我们想要的,或者如果失败了,我们可以自己更改代码以得到我们想要的结果。

11.5.13 最低分

我们的 lowest_score 函数最终将汇总我们实现 process_data 所需要的一切。我们刚刚实现的 get_score 函数为我们提供了任何两个签名之间的分数。我们的 lowest_score 函数将针对每个已知签名调用一次 get_score,将未知签名与每个已知签名进行比较。然后,它将返回与未知签名分数最低的已知签名,如下所示。

列表 11.16 最接近的已知签名
def lowest_score(signatures_dict, unknown_signature, weights):
 '''
 signatures_dict is a dictionary mapping keys to signatures.
 unknown_signature is a signature.
 weights is a list of five weights.
 Return the key whose signature value has the lowest 
 score with unknown_signature.

 >>> d = {'Dan': [1, 1, 1, 1, 1],\ **#1
 **'Leo': [3, 3, 3, 3, 3]}** #1
 **>>> unknown = [1, 0.8, 0.9, 1.3, 1.4]**
 **>>> weights = [11, 33, 50, 0.4, 4]**
 **>>> lowest_score(d, unknown, weights)** **#2
 **'Dan'**
 **'''**
    lowest = None
    for key in signatures_dict:           **#3
        score = get_score(signatures_dict[key],           #4
                          unknown_signature, weights) 
        if lowest is None or score < lowest[1]:      #5
            lowest = (key, score)       #6
    return lowest[0]   #7******

****#1 在 doctest 中使用变量以使测试本身更容易阅读

#2 这一行更容易阅读,因为我们使用了我们的变量。

#3 遍历每个作者名称

#4 获取将此已知签名与未知签名进行比较的分数

#5 如果这是第一次比较,或者我们已经找到了一个更低的分数…

#6 . . . 这个存储了该键的最佳键和分数。

#7 lowest[0]是最佳键。**** ****第一个参数signatures_dict是一个将作者名称映射到其已知签名的字典。这最终将来自get_all_signatures函数。第二个参数unknown_signature最终将来自在神秘书籍上调用make_signature的结果。第三个参数weights将是我们调用此函数时硬编码的。

11.5.14 process_data

只剩下两个函数了!其中一个是process_data——它感觉我们花了很长时间,但我们终于准备好了。

我们在下面的列表中process_data函数将接受两个参数:神秘书籍的文件名和已知作者书籍的目录。它将返回我们认为写了神秘书籍的作者。

列表 11.17 最接近神秘作者的特征
def process_data(mystery_filename, known_dir):
 '''
 mystery_filename is the filename of a mystery book whose 
 author we want to know.
 known_dir is the name of a directory of books.

 Return the name of the signature closest to 
 the signature of the text of mystery_filename.
 '''
    signatures = get_all_signatures(known_dir)           #1
    with open(mystery_filename, encoding='utf-8') as f:    #2
        text = f.read()                                #3
        unknown_signature = make_signature(text)         #4
    return lowest_score(signatures, unknown_signature,    #5
                        [11, 33, 50, 0.4, 4])             #5

#1 获取所有已知签名

#2 Copilot 使用我们之前的工作来正确地获取编码。

#3 读取神秘书籍的文本

#4 获取未知签名

#5 返回具有最低比较分数的特征

再次注意我们有多么依赖我们之前的功能。这个极其有用的process_data函数现在实际上不过是一个精心编排的函数调用列表。

在本章的书籍资源中,我们包括了一些未知作者文件,例如 unknown1.txt 和 unknown2.txt。这些文件应该与你的代码(以及已知作者文件的子目录)一起位于你的当前工作目录中。

让我们调用process_data来猜测谁写了'unknown1.txt'

>>> process_data('unknown1.txt', 'known_authors')
'Arthur_Conan_Doyle.txt'

我们的程序猜测亚瑟·柯南·道尔写了 unknown1.txt。如果你通过打开文件查看 unknown1.txt 的文本,你会发现我们的猜测是正确的。这本书叫做《四个签名》,是亚瑟·柯南·道尔的一本知名作品。

11.5.15 make_guess

要猜测一本书的作者,我们目前需要输入 Python 代码来运行process_data。这对用户来说不是很友好;如果我们能运行程序并让它询问我们想要处理哪个神秘书籍文件,那就太好了。

我们将通过实现make_guess,我们最高级别的函数,来给我们的程序添加最后一笔!这个函数将询问用户神秘书籍的文件名,使用process_data获取最佳猜测,并告诉用户关于这个猜测的信息,如下所示。

列表 11.18 与用户交互并猜测文本的作者
def make_guess(known_dir):
 '''
 Ask user for a filename.
 Get all known signatures from known_dir,
 and print the name of the one that has the lowest score 
 with the user's filename.
 '''
    filename = input('Enter filename: ')       #1
    print(process_data(filename, known_dir))     #2

#1 询问用户神秘书籍的文件名

#2 调用 process_data 来完成所有工作并报告我们的猜测

这完成了我们图中的所有函数!图 11.12 显示我们已经从底部到顶部检查了图中的每个函数。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/11-12.png

图 11.12 make_guess所需的所有功能现在都已完整!

如果你将所有代码都放在你的 Python 文件中,你将在文件底部添加以下代码行后能够运行它来猜测神秘书籍的作者:

make_guess('known_authors')

例如,当我们运行程序并输入unknown1.txt作为未知书籍时,会发生以下情况:

Enter filename: unknown1.txt       
Arthur_Conan_Doyle.txt

它正确地告诉我们 unknown1.txt 是由亚瑟·柯南·道尔写的!尝试为我们提供的其他每个未知书籍文件运行它。有多少个它猜对了?哪些猜错了?

恭喜!你已经完成了你的第一个真实世界的自顶向下设计。看看我们取得了什么成就——一个任何初学者程序员都应该为之自豪的作者识别程序。你的程序使用 AI 通过使用训练数据中的书籍文本来学习个别作者是如何写作的(他们平均使用较短或较长的单词,平均使用较短或较长的句子等?)。然后,它将这种学习应用到对神秘书籍的预测中,通过确定神秘书籍最接近哪个作者来做出预测——非常酷!我们解决了一个非常困难的问题,我们通过分解问题并让 Copilot 为每个子问题编写代码来做到了这一点。

11.6 进一步探索

在人们完成自顶向下的设计之后,他们通常会看到重构代码的机会,这意味着在不改变其行为的前提下,使代码更加整洁或更有组织性。我们可以以几种方式重构我们的程序。例如,你可能会注意到我们许多签名功能函数将字符串拆分成单词,然后忽略空单词。这个任务(从字符串中返回非空单词列表)可以拆分成一个独立的子任务函数,这将进一步简化任何调用它的函数。

我们还可能决定应该将权重传递给process_data函数,而不是在该函数中硬编码权重。然后,权重将在make_guess函数中硬编码,将决策提升到函数层次结构中的更高位置,因此如果需要的话,更容易找到和更改。

还有可能在程序的功能或效率方面进行改进。就功能而言,目前我们的程序只是简单地打印出对神秘书籍作者的最好猜测。但我们对那个猜测一无所知。是否有另一个作者与猜测的作者非常接近?如果是这样,我们可能想知道这一点。更普遍地说,我们可能想知道前几个猜测,而不仅仅是最好的猜测。这样,即使最好的猜测是错误的,我们也有关于作者可能是谁的有用信息。这些都是我们可以添加到程序中的额外功能。

为了提高效率,让我们再次思考那个get_all_signatures函数。这个函数做了很多工作!如果我们已知目录中有五本书,那么它将读取这五个文件并计算每个签名。这有什么大不了的?只有五个文件,而计算机真的很快。但想象一下,如果我们有 100 个文件或 10,000 个文件。可能一次只做所有这些工作是可以接受的,但我们的程序并不是这样做的。实际上,每次我们运行程序来猜测神秘书籍的作者时,它都会运行那个get_all_signatures函数,这意味着每次都要重新创建那些签名。这是一大笔浪费的努力;如果我们可以将这些签名存储在某个地方,以后再也不用计算它们,那将很棒。确实,如果我们为了效率而重新设计代码,第一步就是确保已知文本的签名只计算一次,之后可以重复使用。

这正是像 Copilot 这样的工具所做的事情!OpenAI 只对大量代码语料库训练了 GitHub Copilot 一次。这需要成千上万或数百万的计算机小时。但现在训练完成之后,它可以为我们的代码编写提供帮助,而无需每次都从头开始训练。一次训练然后使用该训练进行许多后续预测的想法是机器学习中的常见范式。

11.7 练习

  1. 以下哪一项不是本章描述的基于 AI 的作者识别过程中的步骤?

    1. 计算神秘书籍的平均词长

    2. 将神秘书籍的签名与已知签名进行比较

    3. 询问用户神秘书籍的文件名

    4. 查找神秘书籍的总页数

  2. 建立一个可以根据邮件内容区分垃圾邮件和非垃圾邮件(ham)的分类器。使用诸如词频、某些关键词的存在以及邮件长度等特征。以下是你需要采取的步骤:

    1. 收集垃圾邮件和非垃圾邮件的数据集。你可以在网上找到公开可用的数据集,例如 Enron 垃圾邮件数据集。

    2. 预处理邮件(移除停用词、标点符号等)。

    3. 提取特征(例如,词数、某些单词的存在)。

    4. 使用我们的标记数据(监督学习)训练一个分类器。一个简单而有效的分类器选择是朴素贝叶斯分类器(你可以自由使用 Python 库来帮助你)。

    5. 使用单独的邮件集测试分类器以检查其准确性。

  3. 在这个练习中,你将创建一个简单的基于 n-gram 的文本生成程序。n-gram 是从给定文本或语音样本中连续的n个项的序列。你将使用这些 n-gram 来生成模仿输入文本风格的新的文本。关键思想是构建一个模型,该模型经过训练,知道哪些词通常跟在哪些词后面(例如,“猫吃”是有意义的,“纸巾吃”则没有意义),然后,在可能的选项中随机选择下一个词。如有需要,可以查阅 n-gram 以获取更多信息。以下是你需要采取的步骤:

    1. 选择可以加载到 Python 中的输入文本。你可以使用类似“简·奥斯汀的《傲慢与偏见》”的东西。

    2. 通过将其转换为小写并删除标点符号来预处理文本。

    3. 从输入文本中创建 n-gram。n-gram 是从给定文本中连续的n个项的序列。为了简单起见,在这个例子中我们将使用二元组(n = 2)。

    4. 使用生成的 n-gram 来生成新的文本。从一个随机的 n-gram 开始,根据 n-gram 模型不断添加新词,直到达到所需的长度。

摘要

  • 随着我们程序复杂性的增加,自顶向下的设计变得越来越重要。

  • 作者识别是猜测神秘书籍作者的过程。

  • 我们可以使用关于单词(例如,平均单词长度)和句子(例如,每句平均单词数)的特征来描述每个已知作者是如何写作的。

  • 机器学习是计算机科学的一个重要领域,研究机器如何从数据中学习并做出预测。

  • 在监督学习中,我们有某些以对象(例如,书籍)及其类别(谁写了每本书)形式存在的训练数据。我们可以从这些数据中学习,以对新对象做出预测。

  • 签名由一系列特征组成,每个对象一个签名。

  • 代码重构意味着改进代码的设计(例如,通过减少代码重复)。************************

第十二章:未来方向

本章涵盖

  • 使用提示模式编写和解释代码

  • 生成式 AI 工具的当前局限性和未来方向

在这一章的最后,我们希望让你一窥人们目前正在使用生成式 AI 工具(如 GitHub Copilot 和 ChatGPT)的创造性方式。例如,可以使得 Copilot Chat 向你提问,而不是反过来。还可以让 Copilot 扮演不同的角色,以更好地帮助你完成当前的编程任务。我们将简要介绍,目前还不清楚这将成为多少标准做法,但我们想借此机会展示这些新工具的创造力。我们还将讨论一些生成式 AI 工具的当前局限性(你已经在本书中看到了一些!),并分享我们对未来可能性的看法。

12.1 提示模式

在整本书中,我们阐述了为什么直接在 Python 中编程与使用 Copilot 编程相比有着非常不同的体验。我们关注的焦点不是编写代码,而是编写提示并与生成的代码交互,以确定其是否正确,并在需要时进行修复。但伴随着这些差异,没有 Copilot 的编码与有 Copilot 的编码之间也存在令人惊讶的相似之处。

当程序员编写代码时,他们不会每次编写新程序时都从头开始。研究人员和程序员已经创建了设计模式的目录,即组织程序以使其更容易编写、调试和扩展的通用方法。最著名的此类目录是一本名为《设计模式:可重用面向对象软件元素》的书[1],也被称为“四人帮”书,因为它是由四位作者共同撰写的。它需要熟悉面向对象编程,而我们在这本书中没有教授,所以我们不建议你马上拿起那本书。只需知道,成千上万的程序员已经通过使用这本书中的模式节省了数千小时,而没有重新发明轮子。

以设计模式的一个例子来说明,假设你正在编写一个电脑游戏,其中人类玩家与电脑对战。你希望实现几个不同难度(例如,初学者、中级、高级和专家)的 AI 对手,为人类提供不同技能水平的对手。每个 AI 对手都将有自己的代码来决定其行为。使用if条件确定 AI 对手应该做什么是可能的:

if ai_opponent == 'beginner':
    # make decision consistent with beginner AI opponent
elif ai_opponent == 'intermediate':
    # make decision consistent with intermediate AI opponent

但那样做的缺点是组织上的。我们会把所有 AI 代码放在同一个地方。我们从这本书中学到的知识告诉我们,像那样的大型函数不容易设计和测试。

组织这类程序的干净方式是使用一种称为策略模式的模式。我们不会在这里详细介绍该模式,但重要的是,人们已经为他人使用记录了这种模式[1]。模式文档会告诉你其意图(目的)、我们为什么要使用它的动机、实现它所需的代码结构以及示例代码。

研究人员开始整理可用于 GitHub Copilot 和 ChatGPT 等生成 AI 工具的模式。它们被称为提示模式,类似于设计模式,它们告诉你如何构建一个提示以实现一个期望的目标。每个这些模式的文档与设计模式的文档类似,但与示例代码不同,我们得到的是我们可以使用的示例提示。

在这本书中,我们大部分时间都在关注两种类型的 AI 响应:生成代码(通过在 VS Code 中按 Tab 键)和解释代码(通过使用 Copilot Chat)。新兴的提示模式可以帮助生成 AI 工具超越这些,帮助处理其他与代码相关的任务。

为了让你了解提示模式的前沿状态,我们将在这里采样几个。对于更多,我们鼓励你查看我们使用的提示模式目录[2]。但请注意:我们正在引用一篇学术论文,而不是入门指南,因为我们目前不知道有任何入门指南(这还是新事物)。

Copilot Chat 可能不会参与这些提示模式

在撰写本文时,Copilot Chat 拒绝参与一些已记录的提示模式,声称它们与编程辅助无关。虽然其中一些可能不太具体地与编程相关,但它们都是为了帮助程序员设计的。与 ChatGPT 不同,Copilot 被设计成始终试图专注于编程主题。如果你决定进一步探索各种模式,你可能希望在无法与 Copilot 有效互动时使用 ChatGPT。

12.1.1 交互模式反转

到目前为止,我们一直是请求代码和提问的人,而 Copilot 一直是回答的人。但我们可以反转脚本,让 Copilot 提问,我们回答。这被称为交互模式反转

此模式的目的是在我们不知道 AI 需要什么信息来完成任务时,帮助我们与之互动。动机是,AI 可能知道如何更快地通过询问我们达到其目标,而不是如果我们提供不完整或不帮助的信息。我们的提示结构将看起来像这样:

我希望你问我问题以达到 X。你应该继续提问,直到我们达到这个目标,其中 X 是我们想要达到的目标。

这种结构并不是为了精确地规定我们应该使用的提示。相反,它提示我们需要在提示中传达的关键思想。例如,这里的关键思想是我们希望 Copilot 提问,并且我们希望它在我们达到目标之前不要停止。

在第三章中,我们编写了一个函数,不断询问用户输入密码,直到他们输入了一个强大的密码。假设我们不确定如何设计这个函数。我们可以通过提供代码提示让 Copilot 为我们设计它,但这样它可能会做出我们不认同的决定。相反,我们可以让 Copilot 提问。让我们使用 Copilot Chat 来做这件事。

LPHuman 我想要一个 Python 函数,它会不断询问用户输入密码,直到他们提供了一个强大的密码。在你能写出那个 Python 函数之前,一次问我一个问题。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 当然,我很乐意帮助你编写一个不断询问用户输入强大密码的 Python 函数!以下是一些开始的问题:

  1. 你认为什么是一把强大的密码?

  2. 函数应该有任何输入参数吗?

  3. 函数应该返回什么?

  4. 函数应该有任何副作用,比如打印消息到控制台吗?

  5. 函数应该有任何错误处理,比如处理用户无效的输入吗?

Copilot 未能遵循我们“一次问一个问题”的指示。让我们提醒它。

LPHuman 一次问我一个问题。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 当然,让我们从第一个问题开始:

  1. 你认为什么是一把强大的密码?

现在,我们可以开始回答这些问题。Copilot 将使用这些答案为我们编写代码。

LPHuman 密码必须至少包含一个大小写字母,至少一个数字和一个标点符号。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 很好,感谢你告诉我!接下来是下一个问题:

    1. 函数应该有任何输入参数吗?

LPHuman 我不知道。它们会被用来做什么?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 输入参数用于将数据传递给函数,以便函数可以对数据进行某些操作。例如,如果我们想编写一个将两个数字相加的函数,我们需要将这两个数字作为输入参数传递给函数。

在我们的密码函数的情况下,我们可能想要传递一些参数来定义强大密码的要求。例如,我们可能想要传递密码的最小长度、所需的大小写字母数量、所需的数字数量和所需的标点符号数量。

或者,我们可以将这些要求硬编码到函数本身中,但使用输入参数会使函数更加灵活和可重用。

LPHuman 请将它们硬编码。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 当然,我们可以将密码要求硬编码到函数本身中。这是下一个问题:

    1. 函数应该返回什么?

LPHuman 该函数应返回用户的强密码。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 很好,感谢你告诉我!这是下一个问题:

    1. 函数是否应该有任何副作用,例如向控制台打印消息?

我们在这里停止,因为我们认为阅读完整的交互并不有趣。我们继续与 Copilot 交谈并回答了问题 4 和 5,Copilot 拥有了编写我们想要的代码所需的信息。在回答了所有五个问题后,我们最终收到的代码如下所示。

列表 12.1 通过翻转交互模式设计的函数
import string

def get_strong_password():
    '''
    Keep asking the user to enter a password until they provide 
    a strong password.
    A strong password has at least one uppercase character, one 
    number, and one punctuation character.
    '''
    # Print the password requirements
    print("Your password must contain at least:")
    print("- One uppercase letter")
    print("- One number")
    print("- One punctuation character")

    # Keep asking for a password until a strong one is provided
    while True:                                          #1
        password = input("Enter a password: ")

        # Check if the password meets the requirements
        if not any(c.isupper() for c in password):        #2
            print("Password does not contain an uppercase letter.")
        elif not any(c.isdigit() for c in password):        #3
            print("Password does not contain a number.")
        elif not any(c in string.punctuation      #4
                     for c in password):          #4
            print("Password does not contain a punctuation character.") #4
        else: #4
            print("Password is strong!") #4
            return password                     #5

#1 没有办法使这个 True 条件变为 False!

#2 如果密码中没有大写字母

#3 如果密码中没有数字字符

#4 如果密码中没有标点符号

#5 这就是我们退出“while True”循环的方法。

到目前为止,在我们的 while 循环中,我们始终使用一个我们希望最终变为 False 的循环条件(否则,我们会遇到无限循环)。例如,我们可以想象有一个循环,实际上说的是,“当密码无效时继续循环。”但在这里,代码使用的是 while True,而 True 是一个永远不会变为 False 的条件。必须有一种其他方式退出循环——确实有!当密码有效时,我们通过 return 语句退出循环。也就是说,这里的逻辑是“永远循环;如果密码有效,停止循环。”while True 形式的 while 循环是本书中看到的一种常见替代结构。

这段代码很好地实现了我们的任务。它开始时通过一些 print 调用来告诉用户强密码的规则。如果用户的密码实际上不强,它还会告诉用户密码有什么问题。它持续循环,直到用户输入一个强密码。当我们最终从用户那里得到一个强密码时,我们返回它。

现在,我们已经知道了如何编写这个函数,因为我们已经在第三章的工作中做到了这一点。而且,到这一点,你的 Python 知识已经足够强大,你可以直接跳到编写函数签名,让 Copilot 编写代码,就像我们在整本书中做的那样。但对于你不太熟悉且可能不知道从何开始的任务,这种翻转交互模式可能会有所帮助。

12.1.2 个性模式

这种模式的目的是让 AI 扮演一个特定的角色或观点。我们为什么会想要这样做呢?好吧,这里有一个例子:假设你是一名入门级 Python 编程课程的学生。你的讲师正在课程中使用 Copilot,但他们禁止了一些他们认为会妨碍入门级 Python 学习的 Python 特性。你可以要求 Copilot 扮演那个讲师的角色,然后帮助你检查代码,以找到并(如果需要)移除任何不允许的 Python 特性。我们的提示结构将如下所示:

从现在起,扮演角色 X。提供角色 X 会创建的输出。

我们将用特定的角色来填充 X。让我们尝试使用我们的入门级编程讲师角色。我们将要求 Copilot 扮演这个角色,禁止一系列更高级的 Python 概念。(如果你从未听说过这些,请不要担心!)

LPHuman 从现在起,扮演一个入门级 Python 编程讲师的角色。在你教授的课程中,以下 Python 特性是不允许的:列表推导式、生成器、“lambda”以及“any”函数。我会提供代码,你需要检查代码中是否有不允许的 Python 特性,并提出如何重写代码以避免使用这些特性。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 当然,我可以做到!请提供你希望我审查的代码。

让我们给它提供上一节中的 get_strong_password 函数。它使用了 any 函数,根据我们的角色规则,这个函数是不允许的。以下是我们的原始代码以及 Copilot 的响应。

LPHuman

*`*def get_strong_password():*`*
 *`*'''*`*
 *`*Keep asking the user to enter a password until they provide*`* 
 *`*a strong password.*`*
 *`*A strong password has at least one uppercase character, one*`* 
 *`*number, and one punctuation character.*`*
 *`*'''*`*
 *`*# Print the password requirements*`*
 *`*print("Your password must contain at least:")*`*
 *`*print("- One uppercase letter")*`*
 *`*print("- One number")*`*
 *`*print("- One punctuation character")*`*

 *`*# Keep asking for a password until a strong one is provided*`*
 *`*while True:*`*
 *`*password = input("Enter a password: ")*`*

 *`*# Check if the password meets the requirements*`*
 *`*if not any(c.isupper() for c in password):*`*
 *`*print("Password does not contain an uppercase letter.")*`*
 *`*elif not any(c.isdigit() for c in password):*`*
 *`*print("Password does not contain a number.")*`*
 *`*elif not any(c in string.punctuation for c in password):*`*
 *`*print("Password does not contain a punctuation character.")*`*
 *`*else:*`*
 *`*print("Password is strong!")*`*
 *`*return password*`*

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot get_strong_password 函数使用了 any 函数,这在你的课程中是不允许的。下面是修改后的函数版本,它没有使用 any 函数:

*`*import string*`*

*`*def get_strong_password():*`*
 *`*'''*`*
 *`*Keep asking the user to enter a password until they provide*`*
 *`*a strong password.*`*
 *`*A strong password has at least one uppercase character, one*`* 
 *`*number, and one punctuation character.*`*
 *`*'''*`*
 *`*# Print the password requirements*`*
 *`*print("Your password must contain at least:")*`*
 *`*print("- One uppercase letter")*`*
 *`*print("- One number")*`*
 *`*print("- One punctuation character")*`*

 *`*# Keep asking for a password until a strong one is provided*`*
 *`*while True:*`*
 *`*password = input("Enter a password: ")*`*

 *`*# Check if the password meets the requirements*`*
 *`*has_uppercase = False*`*
 *`*has_number = False*`*
 *`*has_punctuation = False*`*
 *for c in password:* #1
 *`*if c.isupper():*`*
 *`*has_uppercase = True*`*
 *`*elif c.isdigit():*`*
 *`*has_number = True*`*
 *`*elif c in string.punctuation:*`*
 *`*has_punctuation = True*`*
 *`*if not has_uppercase:*`*
 *`*print("Password does not contain an uppercase letter.")*`*
 *`*elif not has_number:*`*
 *`*print("Password does not contain a number.")*`*
 *`*elif not has_punctuation:*`*
 *`*print("Password does not contain a punctuation character.")*`*
 *`*else:*`*
 *`*print("Password is strong!")*`*
 *`*return password*`*

#1 替换“any”的循环

这个版本的函数使用 for 循环遍历密码中的每个字符并检查它是否符合要求。这种方法比使用 any 函数更冗长,但它没有使用不允许的 Python 特性。

如 Copilot Chat 所示,这个程序没有使用 any 函数。Copilot 已经重写了程序以避免使用该函数,从而遵循了我们 Python 编程角色的规则。

any 函数在其参数中有一个或多个值为真时返回 True,如果所有值都为假,则返回 False。在幕后,any 必须查看每个值以检查它是否为真或假。听起来像是一个循环,对吧?确实,Copilot 就是用一个循环来替换这里的 any 函数调用的。

12.2 局限性和未来方向

在我们与 Copilot(以及学生)合作并阅读我们撰写本书期间发表的研究文献的过程中,我们观察到了一些生成式 AI 模型的局限性和未来可能性,我们希望与大家分享。

12.2.1 Copilot(目前)的挑战

我们发现 Copilot 在三种不同类型的场景中存在挑战:虚构问题、不明确问题和大型任务。

虚构问题

在 ChatGPT 和 GitHub Copilot 风靡全球之后不久,编程教师们急忙寻找这些工具无法很好地完成的任务类型。一个早期的建议是使用虚构任务:通过使任务变得晦涩和复杂来试图误导像 Copilot 这样的工具生成错误的代码。对我们来说,这类任务似乎完全无趣,而且对学生来说也不公平,它们的用途似乎是一种最后的努力,试图在不承认 Copilot 等工具造成的破坏的情况下保留之前的编程课程风格。我们的感觉是,随着模型的持续改进,这些虚构任务最终会被 Copilot 解决,但即使它们没有被解决,这类问题通常也不是必须解决的。

不明确的问题

一个不明确的问题是指那些没有精确指定的问题,这意味着我们并不确切知道或在所有情况下都没有决定我们应该做什么。例如,向 Copilot 请求一个函数来决定密码是否强大,直到我们定义了“强密码”的含义,这个问题才是不明确的。你可能会想,Copilot 不可能在这些类型的问题上变得更好。毕竟,如果我们难以精确指定我们想要的行为,我们如何将所需的行为传达给 Copilot 呢?不明确的问题对 Copilot 来说确实具有挑战性,但它们并不是不可能解决的。记得我们在本章前面讨论过的翻转交互提示模式吗?也许,不久的将来,Copilot 会自动切换到那种模式,当你没有足够精确的信息来解决问题时。

大型任务

我们在整本书中花了大量时间教你如何设计小型函数,以及如何使用自顶向下的设计来组织这些函数以解决大型问题。我们这样做是因为当给 Copilot 一个单一的任务来解决时,Copilot 会感到困难。这是像 Copilot 这样的工具的固有局限性,还是 AI 可以克服它?我们不知道。目前,Copilot 在问题分解方面存在困难,即使它能够正确地做到这一点,概率也不利于它。具体来说,它编写代码越多,出错的可能性就越高。例如,如果它需要编写 20 个函数来解决一个任务,每个函数平均有 10 行代码,它几乎肯定会在某个地方犯错误。但反对这些系统也是不明智的。随着 Copilot 学习方式的进步,也许这一切并没有想象中那么遥远。

12.2.2 Copilot 是一种新的编程语言吗?

当我们用 Python 这样的语言编写计算机代码时,背后有一个编译器将 Python 转换为计算机可以理解的汇编代码或机器代码。曾经有一段时间,程序员用 Fortran 或 C 这样的老式计算机语言编写代码,运行编译器生成汇编语言的代码,然后检查汇编代码以确保它是正确的。我们当时不在场,这可能没有发生很多,但我们能理解为什么当时的程序员不相信编译器。编译器是一种新技术,需要时间来修复错误。此外,编译器输出的代码可能比手写的汇编代码效率低。但是,经过几十年的改进,无论是在正确性还是效率上(编译器优化对任何好奇的人来说都是一个真正令人兴奋和有趣的话题!),很少有程序员会查看编译器的输出。有没有可能有一天,人类仅仅使用大型语言模型(LLMs)作为与计算机的主要接口,并且花很少的时间检查他们产生的代码?让我们思考这个问题的两种可能的答案。

为什么大型语言模型(LLMs)可能不会取代编程语言

有理由相信 LLMs 不会成为人类编程计算机的主要接口。最主要的原因是 LLMs 不是一个严格的编程语言。我们信任编译器,因为每种编程语言都有一个规范,并且每行代码都有预期的确切行为。LLMs 没有这个。这只是有人用英语或其他自然语言向 LLM 写作。LLM 并不一定要以特定方式解释自然语言。它不需要根据任何严格的规范输出答案。而且,随机性和非确定性使得它给出的答案可能会变化或错误!编译器是确定性的,到目前为止,编译器是一种成熟、可信的技术。它们不面临这些问题。

为什么大型语言模型(LLMs)可能会取代编程语言

也有理由相信,就像编译器一样,LLMs 会变得更好,并成为我们的主要编程接口。事实上,对于数据科学来说,这已经开始发生了。

正如我们在整本书中学到的,与 Copilot 一起工作的一个关键挑战是确定生成的代码是否正确。从某种意义上说,这对非程序员来说并不公平:我们给 Copilot 自然语言(我们的偏好),而它给我们回的是非自然语言的代码(计算机的偏好)。如果能“跳过代码”,不仅用自然语言与 Copilot 沟通,还能得到我们的答案,那就太好了。

研究人员开始探索在数百万计算机用户感兴趣的受限领域内这一可能性。以数据科学为例,数据科学家通过探索数据、可视化数据和使用数据来做出预测来理解数据。他们所做的大部分工作都涉及以约束和易于理解的方式操作数据,例如合并电子表格、清理特定列的数据或进行诸如将数据聚类到有意义的类别或简化数据以仅关注其核心基础结构的分析。使用 Python 的数据科学家使用许多库来处理他们的数据,但一个极其流行的库叫做 pandas。

研究人员已经在这个使用 pandas 进行数据科学的环境中“跳过了代码”[3]。以下是它是如何工作的:

  1. 用户以自然语言(如英语)表达他们的意图。

  2. 人工智能生成 Python 代码并运行以获取结果(例如,分析结果表格或新的电子表格)供用户使用。重要的是,用户看不到这段 Python 代码。

  3. 人工智能将代码转换回自然语言,并将转换后的内容(而不是 Python 代码!)呈现给用户。用户收到的自然语言格式一致,可以被人工智能可靠地解释。研究人员这样描述人工智能响应的目的:“这就是你应该向系统提出你刚刚要求系统做的事情。”这个响应有助于用户了解人工智能的能力和有效的查询类型。

  4. 如果第 3 步的自然语言不正确,用户可以编辑。如果用户进行了编辑,他们可以提交新的提示,这会重复循环。

研究人员提供的一个例子阐明了这个过程[3]。假设我们有一个电子表格,每行代表一名宇航员。每行有三个相关列:宇航员的名字、他们在太空中的总时间以及他们参与的以逗号分隔的任务列表。我们希望计算每位宇航员的平均任务长度。

在第 1 步中,用户编写一个提示,如“计算平均任务长度”。在第 2 步中,人工智能生成与该提示相对应的代码。它运行代码,并在用户的电子表格中添加一个包含平均任务长度的新的列。在第 3 步中,人工智能将代码转换为自然语言任务列表,如下所示:

  1. 创建“任务长度”列。

  2. “太空飞行时间(小时)”列除以(从“任务”列中“,”的数量加 1)。

在第 4 步中,用户可以编辑第 3 步的自然语言,并将更新后的任务重新提交给 AI。我们是否能够在“Python 编程”这个更广泛的领域“跳过代码”,而不是在“使用 pandas 的数据科学”这个更受限的环境中呢?现在还言之过早。数据处理的好处在于可以在视觉媒介中工作,使用电子表格和图表可以直接展示给用户,用户可能能够判断分析是否正确,或者是否需要进一步的提示工程。这样的视觉表示对于通用编程来说并不明显。

尽管如此,我们可以想象一个新时代,在这个时代,人类继续执行重要的任务,如问题分解、指定程序行为、编写测试、设计算法等,但函数编程完全由 LLM(大型语言模型)完成。人类告诉 AI 工具程序需要做什么,并提供测试用例,然后 AI 生成代码。人类可以检查程序是否正常工作,而无需查看代码。

对于 LLM 是否会取代编程语言的另一种看法,我们推荐阅读 Chris Lattner(编程和编译器专家)撰写的博客文章[4]。Lattner 认为,至少在短期内,甚至可能是更长的时间内,编程语言不会消失,因为 LLM 生成的代码可能会存在细微的错误。因此,如果编程语言还会存在一段时间,那么问题是:我们应该使用哪种编程语言?Lattner 说:“最适合 LLM 的语言是那种对人类来说高度可用且易于阅读,但其实现可以扩展到许多不同的用例和应用程序的语言” [4]。现有的语言是否满足这个目标?我们能否通过设计一种比 Python 更容易阅读的编程语言来做得更好?请继续关注!

好吧,也许编程语言会消失,也许不会;也许它们会改变。无论如何,我们是否需要担心程序员的工作?我们认为不需要。任何在软件公司工作过的人都会告诉你,编写代码并不是程序员用他们时间做的唯一或甚至主要的事情。程序员还会与客户会面,确定他们的需求。他们指定程序的功能以及它们如何组合。他们会检查系统的性能和安全问题。他们与其他团队合作,协调大型软件的设计。如果编写代码的步骤变得更容易,也许我们只是得到了更多有用的软件。这就是当我们得到高级语言时发生的事情。没有人会使用汇编语言编写下一个杀手级应用!编译器改善了我们的软件制作方式。如果我们明智地使用 LLM,我们认为 LLM 也能做到这一点。

一个令人兴奋的未来

虽然我们对未来部分不确定,但很清楚,LLMs 将极大地改变编程的未来。也许,现在它们只是帮助软件工程师编写更好代码的辅助工具。也许五年后,然而,大多数软件将由 LLMs 编写,只有一小部分软件工程师会从头开始编写代码。无论哪种结果证明是正确的,变化正在迅速发生,它们可能会使更多的人能够编写满足他们需求的软件。

到目前为止,我们已经对如何使用这些工具有一些见解。在一项调查中,92%的软件开发者表示他们定期使用这些工具[5]。最近的研究发现,使用这些工具的开发者比不使用工具的开发者更有效率[6]。在加州大学圣地亚哥分校进行的一项特别有趣的研究中,研究人员发现,专业开发者使用这些工具的方式有两种:加速和探索[7]。在加速阶段,开发者使用这些工具来帮助他们比如果自己编写代码更快地生成代码。在探索阶段,开发者使用这些工具来帮助他们找到可能有所帮助的库或理解现有的代码库。这些发现与我们在这本书中学到的东西相一致,我们期待未来的研究。

无论如何,我们希望你现在能够根据自己的判断来决定如何使用大型语言模型(LLMs)进行编程,以及 LLMs 可能对编程的未来意味着什么。在这样的时刻,有些人热情地宣称,“编程已经结束了!”而另一些人同样热情地宣称,“编程不会发生太大的变化!”[8],对我们来说,能够自己权衡这些论点以及这种变化可能对我们以及其他人产生的影响是很重要的。这些工具能帮助我们吗?我们认为可以。因此,我们应该使用它们,但也要负责任地使用。有没有担忧?同样,我们认为有,正如我们在整本书中讨论的那样。我们应该采取一些措施,比如测试和调试,来减轻这些担忧。

在这本书中,我们采用的编程教学方法是新的。像我们这样的教师和教授刚刚开始将像 Copilot 这样的工具融入编程课程[9],尽管我们仍在努力平衡我们希望学习者何时使用 Copilot 以及何时不使用 Copilot。无论程序员选择何种平衡来学习编程,我们都要强调,你已经学习了每个人都需要的基本技能,无论是你偶尔在工作时编写代码来自动化繁琐的任务,还是你计划成为一名专业的软件工程师。你有一个强大的基础,无论你的道路如何。

12.3 练习

  1. 解释如何使用人格模式来加强公司中的编码标准。例如,公司可能要求其员工始终使用驼峰命名法(例如,xVal、ZValOut)。提供一个提示示例,让 GitHub Copilot 扮演一个经理的角色,执行特定的编码标准。

  2. 你想创建一个 Python 函数,该函数根据用户的首选用户名和全名生成用户名。该函数应遵循以下规则:

    1. 如果首选用户名可用(即尚未被占用),请使用它。

    2. 如果首选用户名已被占用,通过在首选用户名后附加单个数字(例如,1、2、3、……)来生成新的用户名。

    3. 如果生成的用户名也被占用,使用用户的全名通过组合他们的姓氏和名字的部分来生成用户名。

    4. 如果生成的用户名也被占用,函数应继续请求新的首选用户名。

你会使用哪种交互模式(翻转交互模式或人格模式)来帮助 GitHub Copilot 设计这个功能?请证明你的选择,并概述你将如何开始交互。

摘要

  • 提示模式是一个模板,帮助我们构建一个提示以实现既定目标。

  • 翻转交互模式翻转了剧本:不是向 LLM 提出问题,而是 LLM 向我们提出问题。

  • 当我们不知道如何有效地提示 LLM 时,翻转交互模式很有用。

  • 人格模式用于让 AI 承担特定的角色,例如“入门级编程讲师”或特定的观点。

  • 当我们希望 LLM 从给定视角回应时,人格模式很有用。

  • Copilot 目前在处理设计、定义不明确或规模较大的任务时存在困难。

  • 有些人认为 LLM 将取代编程语言;另一些人认为编程语言将永远存在。

  • LLM 正在帮助人们执行数据科学任务,而这些人们从未见过底层编程语言代码。

  • 可能编程语言本身不会被取代,但主导语言将被开发得比今天的语言更易读。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐