Python-项目非实践指南-全-

Python 项目非实践指南(全)

原文:zh.annas-archive.org/md5/eb0159e01cc81d815f11342bedd7ea38

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

image

欢迎来到不切实际的 Python 项目!在这里,你将使用 Python 编程语言探索火星、木星以及银河系的最远端;诗人的灵魂;金融的世界;间谍活动和选举舞弊的黑暗面;游戏节目的骗局;等等。你将运用马尔可夫链分析写出俳句,使用蒙特卡洛模拟来建模金融市场,采用图像堆叠技术来提升你的天文摄影水平,运用遗传算法繁育出一支巨型老鼠军队,同时还会获得使用 pygame、Pylint、pydocstyle、tkinterpython-docxmatplotlibpillow 等模块的经验。最重要的是,你会玩得很开心。

本书适合谁阅读

你可以把这本书当作你的第二本 Python 书籍。它旨在跟进并补充一本完整的初学者书籍或入门课程。你将能够继续通过基于项目的方法进行自学,而无需浪费金钱或书架空间来重新学习你已经掌握的概念。放心,我不会让你感到迷茫;所有的代码都有注释和解释。

这些项目适合任何希望通过编程进行实验、验证理论、模拟自然,或仅仅是为了乐趣的人。这包括那些将编程作为工作一部分的人(比如科学家和工程师),但并非专业程序员的人,还有我所称之为“坚定的非专业人士”的人——那些喜欢将编程问题作为一种有趣消遣的业余爱好者。如果你一直想尝试本书中介绍的概念,却发现从零开始复杂的项目太令人畏惧或耗时,那么本书就是为你准备的。

本书内容

在完成项目的过程中,你将增加对有用的 Python 库和模块的了解;学习更多快捷键、内置函数和有用的技巧;并练习设计、测试和优化程序。此外,你还能够将你所做的与现实世界中的应用、数据集和问题联系起来。

引用拉尔夫·沃尔多·爱默生的话:“没有热情,就没有伟大的成就。”这同样适用于学习体验。本书的最终目标是激发你的想象力,引导你开发自己感兴趣的项目。如果一开始这些项目看起来过于雄心勃勃,不要担心;一点点努力和大量的谷歌搜索可以创造奇迹——而且比你想象的更快。

以下是本书各章节的概述。你不必按顺序完成它们,但最简单的项目在前面,当新概念、模块和技术首次引入时,我会更详细地解释。

第一章:傻名字生成器 这个热身项目介绍了 Python 的 PEP 8 和 PEP 257 风格指南,以及 Pylint 和 pydocstyle 模块,它们分析你的代码是否符合这些指南。最终产品是一个受美国网络电视节目Psych启发的傻名字生成器。

第二章:寻找回文咒语 学习如何在保存 DC 漫画中的女巫扎塔娜免于痛苦死亡的过程中分析你的代码。在在线词典中搜索扎塔娜需要用来击败一个逆时间反派的魔法回文。

第三章:解决字谜 编写一个程序,帮助用户根据他们的名字创建一个短语字谜;例如,Clint Eastwood 变成 old west action。然后帮助汤姆·马沃罗·里德尔(Tom Marvolo Riddle)用语言筛选器推导出他的字谜,“I am Lord Voldemort”(我是伏地魔)。

第四章:破解美国内战密码 调查并破解历史上最成功的军事密码之一——联邦路线密码。然后帮助双方间谍使用之字形铁丝栅栏密码发送和解码秘密消息。

第五章:编码英国内战密码 通过解码英国内战的空白密码读取一个显而易见的隐藏信息。然后,通过设计并实现代码,完成更困难的任务——编写一个空白密码,从而拯救苏格兰玛丽女王的头颅。

第六章:写不可见墨水 帮助一名公司内鬼背叛福尔摩斯的父亲,并使用不可见的电子墨水逃避侦查。本章基于 CBS 电视节目Elementary的一集。

第七章:利用遗传算法繁殖巨型大鼠 使用受达尔文进化启发的遗传算法,培育出一种体型与雌性獒犬相当的超级大鼠种群。然后帮助詹姆斯·邦德在眨眼之间破解一个具有 100 亿种可能组合的保险箱。

第八章:为俳句诗歌计数音节 教你的计算机计算英语中的音节,为下一章写作日本俳句诗歌做准备。

第九章:通过马尔可夫链分析写俳句 教你的计算机通过将第八章中的音节计数模块与马尔可夫链分析结合,以及使用几百首古代和现代俳句的训练语料库,写出俳句。

第十章:我们是孤单的吗?探索费米悖论 使用德雷克方程、银河系的维度和可探测的“发射气泡”大小的假设,研究外星无线电信号的缺失。学习并使用流行的tkinter模块构建银河系和地球无线电气泡的图形显示。

第十一章:蒙提霍尔问题 帮助世界上最聪明的女人赢得蒙提霍尔问题的辩论。然后使用面向对象编程(OOP)构建一个蒙提著名游戏的版本,并加入有趣的图形界面。

第十二章:保障你的退休金 使用基于蒙特卡洛的金融模拟来规划你(或你父母)的安全退休生活。

第十三章:模拟外星火山 使用pygame模拟木星卫星欧罗巴上的火山爆发。

第十四章:使用火星轨道器绘制火星地图 创建一个基于重力的街机游戏,推动卫星进入圆形的地图轨道,同时避免燃料耗尽或在大气中燃烧。显示关键参数的读数,追踪轨道路径,添加行星的阴影,并缓慢旋转火星轴心,同时学习轨道力学!

第十五章:通过行星堆叠提升你的天文摄影技巧 通过使用 Python 图像库对低质量视频图像进行光学堆叠,揭示木星的云带和大红斑。学习如何使用内置的osshutil模块处理文件、文件夹和目录路径。

第十六章:利用本福德定律发现欺诈行为 使用本福德定律调查 2016 年总统选举中的投票篡改。使用matplotlib将结果总结成图表。

每章结束时至少会有一个实践项目或挑战项目。每个实践项目都附有解决方案。但这并不意味着它是最佳解决方案——你可能会自己想出更好的方法,所以不要提前查看!

然而,在挑战项目中,你将真正独立完成任务。当科尔特斯于 1519 年入侵墨西哥时,他烧毁了自己的卡拉维尔船只,以使他的征服者们意识到无法回头;他们必须以坚定不移的决心面对阿兹特克人。因此,“烧掉你的船”这一表达成为了全身心投入或完全承诺一项任务的代名词。这就是你面对挑战项目时应有的态度——就好像你的船已经被烧掉——如果你这样做,你很可能会从这些练习中学到比书中的任何其他部分更多的知识!

Python 版本、平台和 IDE

我在本书中构建的每个项目都是在 Microsoft Windows 10 环境下使用 Python v3.5 完成的。如果你使用的是不同的操作系统,也没问题:我会在适当的地方为其他平台建议兼容的模块。

本书中的代码示例和屏幕截图来自 Python IDLE 文本编辑器或交互式 shell。IDLE 代表集成开发与学习环境。它是一个集成开发环境(IDE),加上一个L,使得这个首字母缩略词参考了Monty Python的埃里克·艾德尔。交互式 shell,也称为解释器,是一个窗口,允许你立即执行命令并测试代码,而无需创建文件。

IDLE 有许多缺点,比如没有行号列,但它是免费的,并且与 Python 一起捆绑提供,因此每个人都可以使用它。你可以随意使用任何你喜欢的 IDE。网上有很多选择,比如 Geany(发音为genie)、PyCharm 和 PyScripter。Geany 适用于多种操作系统,包括 Unix、macOS 和 Windows。PyCharm 适用于 Linux、Windows 和 macOS。PyScripter 适用于 Windows。有关可用的 Python 开发工具及其兼容平台的详细信息,请访问*wiki.python.org/moin/DevelopmentTools/

代码

本书中的每个项目都提供了完整的代码,我建议你尽可能手动输入这些代码。一位大学教授曾经告诉我,我们是通过“手”来学习的,我不得不承认,亲自输入代码会迫使你最大程度地关注代码的每一部分。

但是,如果你想快速完成项目,或者不小心删除了所有工作,你可以从www.nostarch.com/impracticalpython/下载所有代码,包括练习项目的解决方案。该网站还包含本书的勘误表,以便进行未来的更新或更改。

编码风格

本书的重点是问题解决和初学者的趣味性,因此代码有时可能会偏离最佳实践和最高效的实现方式。有时你可能会使用列表推导式或特殊运算符,但大多数时候,你将专注于简单、易学的代码。

保持简洁对于那些阅读本书的非程序员来说非常重要。他们的代码很多可能是“一次性代码”——只为特定目的使用一两次,之后就被丢弃。这类代码可能会与同事共享,或在员工更替时交给他们,因此应该易于理解和上手。

本书中的所有主要项目代码都有注释,并以独立的方式进行解释,通常遵循Python 增强提案 8,也就是PEP 8的风格建议。关于 PEP 8 的详细信息,以及帮助你遵循这些指南的软件,详见第一章。

获取帮助的途径

承接编程挑战可能会是非常具有挑战性的。编程并不总是你可以凭直觉就能解决的事情——即便是像 Python 这样友好的语言。在接下来的章节中,我将提供有用信息来源的链接和参考,但对于你自己设计的项目,在线搜索无疑是最好的解决方式。

成功搜索的关键是知道该问什么。这一过程一开始可能会让人感到沮丧,但把它看作是一场“二十个问题”的游戏。随着每次搜索的推进,继续改进你的关键词,直到找到答案或达到收效递减的地步。

如果书籍和在线搜索都没能解决问题,那么下一步就是请教别人。你可以在线提问,既可以付费,也可以在像 Stack Overflow (stackoverflow.com/) 这样的免费论坛上提问。但需要注意的是:这些网站的成员对愚蠢的问题可不太宽容。在发布问题之前,务必阅读他们的“如何提问?”页面;例如,你可以在 stackoverflow.com/help/how-to-ask/ 找到 Stack Overflow 的相关内容。

前进!

感谢你花时间阅读介绍部分!你显然希望从本书中获得尽可能多的知识,而且你已经迈出了一个好的开始。当你完成阅读时,你将更加熟练于 Python,并且更好地准备好解决具有挑战性的实际问题。让我们开始吧。

第一章:傻名字生成器

image

美国电视网曾播出一部侦探喜剧叫做Psych,其中超观察力的业余侦探肖恩·斯宾塞在假装使用超能力的同时解决案件。该剧的一个特色是肖恩总是用临时编造的搞笑名字来介绍他的配角加斯,比如 Galileo Humpkins、Lavender Gooms 和 Bad News Marvin Barnes。正如肖恩可能会说的,这些名字有些荒唐得让人忍不住想中风,而且不是那种好的类型!

项目 #1:生成化名

在这个热身项目中,你将编写一个简单的 Python 程序,通过随机组合名字和姓氏来生成疯狂的名字。如果运气好的话,你将产生大量的别名,让任何配角都感到骄傲。你还将回顾最佳编码实践指南,并应用外部程序来帮助你编写符合这些指南的代码。

心理学不是你的兴趣吗?将我代码列表中的名字替换成你自己的笑话或主题。你可以轻松地将这个项目变成权力的游戏名字生成器,或者你可能想发现属于你自己的“本尼迪克特·康伯巴奇”名字;我最喜欢的是 Bendylick Cricketbat。

目标

使用符合既定风格指南的 Python 代码随机生成有趣的配角名字。

规划和设计项目

规划时间从来都不是浪费的时间。不管你是为了乐趣还是为了盈利编程;在某个时刻,你需要相对准确地估算项目将需要多长时间,可能遇到哪些障碍,以及你需要什么工具和资源来完成工作。而为了做到这一点,你首先需要知道你究竟想创造什么!

一位成功的经理曾告诉我,他的秘诀就是不断提问:想做什么?为什么要做这个?为什么要以这种方式做?你有多少时间?有多少金钱?回答这些问题对设计过程非常有帮助,并能让你清晰地看到目标。

在他所著的《Think Python, 第二版》(O'Reilly,2015)一书中,艾伦·道尼描述了两种软件开发计划:“原型和修补”以及“设计开发”。使用原型和修补,你从一个简单的程序开始,然后使用修补,或编辑的代码,来处理测试过程中遇到的问题。这种方法在你处理一个自己不太理解的复杂问题时是很好的。然而,它也可能产生复杂且不可靠的代码。如果你对问题和解决方法有清晰的认识,你应该使用设计开发计划来避免未来出现的问题及其随之而来的修补。这种方法可以使编码更容易高效,并通常会导致更强大、更可靠的代码。

对于本书中的所有项目,你将从一个明确定义的问题或目标开始,这将成为你设计决策的基础。接下来,我们将讨论策略,以更好地理解问题并创建一个设计开发计划。

策略

你将从两个列表开始——名字的“名”和“姓”。这些列表相对较短,因此它们不会占用大量内存,也不需要动态更新,也不应该在运行时出现问题。由于你只需要从列表中读取名字,你将使用元组作为容器。

使用你两个名字的元组,你可以通过点击一个按钮生成新名字——将一个名和一个姓配对。这样,用户可以轻松地重复该过程,直到出现足够搞笑的名字。

你还应该在解释器窗口中以某种方式突出显示名字,使其从命令提示符中脱颖而出。IDLE shell 提供的字体选项不多,但你可能知道——太清楚了——错误会以红色显示。print() 函数的默认输出是标准输出,但加载 sys 模块后,你可以使用 file 参数将输出重定向到错误通道,且带有标志性的红色显示:print(something, file=sys.stderr)

最后,你将确定 Python 编程中有哪些风格推荐。这些指南不仅应该涵盖代码本身,还应涵盖嵌入代码中的文档。

伪代码

“你总是可以指望美国人在尝试一切其他方法后做出正确的决定。” 这句名言,虽然与温斯顿·丘吉尔的联系微弱,却总结了许多人写伪代码时的方式。

伪代码 是一种高级的、非正式的描述计算机程序的方式,使用结构化的英语或任何人类语言。它应该像一种简化的编程语言,包含关键词和正确的缩进。开发人员使用它忽略真正编程语言的所有复杂语法,专注于底层逻辑。尽管伪代码被广泛使用,但它没有官方标准——只有一些指南。

如果你发现自己已经陷入沮丧的境地,可能是因为你没有花时间写伪代码。我是真正的伪代码信徒,因为伪代码——毫无例外——曾在我迷失方向时将我引导到了解决方案。因此,在本书的大多数项目中,你将使用某种形式的伪代码。至少,我希望你能看到它的实用性,但我也希望你能养成在自己项目中写伪代码的习惯。

我们的搞笑名字生成器的一个非常高层次的伪代码可能看起来像这样:

Load a list of first names
Load a list of surnames
Choose a first name at random
Assign the name to a variable
Choose a surname at random
Assign the name to a variable
Print the names to the screen in order and in red font
Ask the user to quit or play again
If user plays again:
    repeat
If user quits:
    end and exit

除非你正在努力通过编程课程或向他人提供清晰的指示,否则要关注伪代码的目的;不要过于担心死板地遵循(非标准的)书写规则。而且,不要仅仅局限于编程——你可以将伪代码的过程应用到更多的事情上。一旦你掌握了它,你可能会发现它有助于你完成其他任务,比如做税务、规划投资、建造房屋或准备露营旅行。这是一种很好的方式,可以帮助你集中思维,并将编程的成功经验带入现实生活。如果国会也能使用它就好了!

代码

清单 1-1 是生成有趣名字的代码,pseudonyms.py,它从两个名字元组中编译并打印一个假名列表。如果你不想输入所有名字,可以输入其中一部分,或者从nostarch.com/impracticalpython/下载代码。

pseudonyms.py

➊ import sys, random

➋ print("Welcome to the Psych 'Sidekick Name Picker.'\n")
   print("A name just like Sean would pick for Gus:\n\n")

   first = ('Baby Oil', 'Bad News', 'Big Burps', "Bill 'Beenie-Weenie'",
            "Bob 'Stinkbug'", 'Bowel Noises', 'Boxelder', "Bud 'Lite' ",
            'Butterbean', 'Buttermilk', 'Buttocks', 'Chad', 'Chesterfield',
            'Chewy', 'Chigger", "Cinnabuns', 'Cleet', 'Cornbread', 'Crab Meat',
            'Crapps', 'Dark Skies', 'Dennis Clawhammer', 'Dicman', 'Elphonso',
            'Fancypants', 'Figgs', 'Foncy', 'Gootsy', 'Greasy Jim', 'Huckleberry',
            'Huggy', 'Ignatious', 'Jimbo', "Joe 'Pottin Soil'", 'Johnny',
            'Lemongrass', 'Lil Debil', 'Longbranch', '"Lunch Money"',
            'Mergatroid', '"Mr Peabody"', 'Oil-Can', 'Oinks', 'Old Scratch',
            'Ovaltine', 'Pennywhistle', 'Pitchfork Ben', 'Potato Bug',
            'Pushmeet','Rock Candy', 'Schlomo', 'Scratchensniff', 'Scut',
            "Sid 'The Squirts'", 'Skidmark', 'Slaps', 'Snakes', 'Snoobs',
            'Snorki', 'Soupcan Sam', 'Spitzitout', 'Squids', 'Stinky',
            'Storyboard', 'Sweet Tea', 'TeeTee', 'Wheezy Joe',
            "Winston 'Jazz Hands'", 'Worms')

   last = ('Appleyard', 'Bigmeat', 'Bloominshine', 'Boogerbottom',
           'Breedslovetrout', 'Butterbaugh', 'Clovenhoof', 'Clutterbuck',
           'Cocktoasten', 'Endicott', 'Fewhairs', 'Gooberdapple', 'Goodensmith',
           'Goodpasture', 'Guster', 'Henderson', 'Hooperbag', 'Hoosenater',
           'Hootkins', 'Jefferson', 'Jenkins', 'Jingley-Schmidt', 'Johnson',
           'Kingfish', 'Listenbee', "M'Bembo", 'McFadden', 'Moonshine', 'Nettles',
           'Noseworthy', 'Olivetti', 'Outerbridge', 'Overpeck', 'Overturf',
           'Oxhandler', 'Pealike', 'Pennywhistle', 'Peterson', 'Pieplow',
           'Pinkerton', 'Porkins', 'Putney', 'Quakenbush', 'Rainwater',
           'Rosenthal', 'Rubbins', 'Sackrider', 'Snuggleshine', 'Splern',
           'Stevens', 'Stroganoff', 'Sugar-Gold', 'Swackhamer', 'Tippins',
           'Turnipseed', 'Vinaigrette', 'Walkingstick', 'Wallbanger', 'Weewax',
           'Weiners', 'Whipkey', 'Wigglesworth', 'Wimplesnatch', 'Winterkorn',
           'Woolysocks')

➌ while True:
    ➍ firstName = random.choice(first)

    ➎ lastName = random.choice(last)

       print("\n\n")
    ➏ print("{} {}".format(firstName, lastName), file=sys.stderr)
       print("\n\n")

    ➐ try_again = input("\n\nTry again? (Press Enter else n to quit)\n ")
       if try_again.lower() == "n":
           break

➑ input("\nPress Enter to exit.")

清单 1-1:从名字元组中生成傻乎乎的假名

首先,导入sysrandom模块 ➊。你将使用sys来访问系统特定的错误消息功能,这样你就可以在 IDLE 窗口中将输出颜色设置为醒目的红色。而random模块则可以让你随机选择名字列表中的项。

➋处的print语句向用户介绍程序。换行命令\n强制换行,单引号''允许你在打印输出中使用引号,而无需使用反斜杠转义字符,这样可以提高代码可读性。

接下来,定义你的名字元组。然后初始化while循环 ➌。将while = True设置为“保持运行,直到我告诉你停止”。最终,你会使用break语句来结束循环。

循环开始时,从first元组中随机选择一个名字,并将其赋值给变量firstName ➍。它使用random模块的choice方法从一个非空序列中返回一个随机元素——在这种情况下,就是第一个名字的元组。

接下来,从last元组中随机选择一个姓,并将其赋值给变量lastName ➎。现在你有了两个名字,打印它们,并通过在print语句 ➏中提供可选参数file=sys.stderr来欺骗 IDLE 使用红色的“错误”字体。使用更新的字符串格式方法,而不是较旧的字符串格式操作符%),将名字变量转换为字符串。要了解更多关于新方法的信息,请参阅docs.python.org/3.7/library/string.html

一旦显示出名字,要求用户选择是重新玩一轮还是退出,通过input提供带引号的提示。在这种情况下,还需要加入几行空白,以便在 IDLE 窗口中让有趣的名字更加明显。如果用户通过按下 ENTER 键进行响应,try_again变量➐不会返回任何内容。由于没有返回内容,if语句中的条件不成立,while循环继续执行,并且打印出一个新名字。如果用户按下 N 键,if语句会导致break命令,循环结束,因为while语句不再被评估为True。使用小写字符串方法.lower()来避免玩家启用 CAPS LOCK 键。换句话说,无论用户输入小写 N 还是大写 N,程序都会将其读取为小写。

最后,要求用户通过按下 ENTER 键退出➑。按下 ENTER 不会将input()的返回值赋给一个变量,程序结束,控制台窗口关闭。在 IDLE 编辑器窗口按 F5 执行完成的程序。

这段代码是可行的,但仅仅能运行是不够的——Python 程序应该以优雅的方式运行。

使用 Python 社区的风格指南

根据Python 禅意www.python.org/dev/peps/pep-0020/),“应该有一种——最好只有一种——明显的做事方式。”为了提供一种明显的“正确做法”并在这些实践中建立共识,Python 社区发布了Python 增强提案,这些提案是用于 Python 标准库的编码约定。最重要的提案是PEP 8,这是 Python 编程的风格指南。PEP 8 随着时间的推移不断发展,因为新的约定被发现,而过去的约定随着语言的变化而变得过时。

PEP 8(*www.python.org/dev/peps/pep-0008/)规定了命名约定、空白行、制表符和空格的使用、最大行长度、注释等标准。其目标是提高代码的可读性,并使其在广泛的 Python 程序中保持一致。当你开始编程时,应努力学习并遵循公认的约定,避免坏习惯的养成。本书中的代码将严格遵循 PEP 8,但我在某些约定上做了一些调整(例如,使用较少的注释代码、较少的空白行和较短的文档字符串),以适应出版行业的需求。

当你在跨职能团队中工作时,标准化的名称和程序尤为重要。科学家和工程师之间的交流常常会出现误解,就像 1999 年工程师因为不同团队使用不同的测量单位而导致火星气候轨道器失败一样。在近二十年的时间里,我构建了地球的计算机模型,并将其转移给工程部门。工程师们使用脚本将这些模型加载到他们自己的专有软件中。他们会在项目之间共享这些脚本,以提高效率并帮助没有经验的人。由于这些“命令文件”是根据每个项目定制的,当模型更新时属性名称发生变化时,工程师们常常感到恼火。事实上,他们的一项内部准则是:“乞求、贿赂或威胁模型设计师使用一致的属性名称!”

使用 Pylint 检查你的代码

你应该熟悉 PEP 8,但你仍然会犯错误,而且与指南进行代码对比会很麻烦。幸运的是,像 Pylint、pycodestyle 和 Flake8 这样的程序可以帮助你轻松遵循 PEP 8 的风格建议。在这个项目中,你将使用 Pylint。

安装 Pylint

Pylint 是 Python 编程语言的源代码、错误和质量检查工具。要下载免费的副本,请访问 www.pylint.org/#install 并找到适合你平台的安装按钮。此按钮将显示安装 Pylint 的命令。例如,在 Windows 中,进入包含你 Python 副本的文件夹(如 C:\Python35),使用 SHIFT-右键点击打开上下文菜单,然后根据你的 Windows 版本选择 在此处打开命令窗口在此处打开 PowerShell 窗口。运行 pip install pylint(如果同时安装了 Python 2 和 3,则使用 pip3)。

运行 Pylint

在 Windows 中,Pylint 从命令窗口运行,或者在更新的系统中使用 PowerShell(你可以通过在包含你想检查的 Python 模块的文件夹中 SHIFT-右键点击来打开这两者)。输入 pylint 文件名 来运行程序(参见 图 1-1)。.py 扩展名是可选的,并且你的目录路径会有所不同。在 macOS 或其他基于 Unix 的系统中,使用终端模拟器。

image

图 1-1:Windows 命令窗口与运行 Pylint 的命令

命令窗口将显示 Pylint 的结果。以下是一个有用的输出示例:

C:\Python35\Python 3 Stuff\Psych>pylint pseudonyms.py
No config file found, using default configuration
************* Module pseudonyms
C: 45, 0: No space allowed around keyword argument assignment
    print(firstName, lastName, file = sys.stderr)
                                    ^ (bad-whitespace)
C:  1, 0: Missing module docstring (missing-docstring)
C:  2, 0: Multiple imports on one line (sys, random) (multiple-imports)
C:  7, 0: Invalid constant name "first" (invalid-name)
C: 23, 0: Invalid constant name "last" (invalid-name)
C: 40, 4: Invalid constant name "firstName" (invalid-name)
C: 42, 4: Invalid constant name "lastName" (invalid-name)
C: 48, 4: Invalid constant name "try_again" (invalid-name)

每行开头的资本字母是消息代码。例如,C: 15, 0表示在第 15 行、第 0 列发生了编码标准违规。你可以参考以下的键值对来了解不同的 Pylint 消息代码:

R 重构,表示违反了“最佳实践”度量

C 规范,表示违反了编码标准

W 警告,表示风格问题或小的编程问题

E 错误,表示重要的编程问题(即很可能是一个 bug)

F 致命错误,表示阻止进一步处理的问题

Pylint 将在报告结束时评分,评估程序是否符合 PEP 8。此时,您的代码得到了 4 分(满分 10 分):

Global evaluation
-----------------
Your code has been rated at 4.00/10 (previous run: 4.00/10, +0.00)
处理虚假常量名称错误

您可能注意到,Pylint 错误地认为所有全局空间中的变量名都表示常量,因此应该全部使用大写字母。您可以通过多种方式解决这个问题。首先是将代码嵌入到 main() 函数中(如 清单 1-2 所示);这样,它就不在全局空间中了。

   def main():
       some indented code
       some indented code
       some indented code
➊ if __name__ == "__main__":
    ➋ main()

清单 1-2:定义并调用一个 main() 函数

__name__ 变量是一个特殊的内建变量,您可以用它来评估程序是以独立模式运行,还是作为一个导入的模块运行;请记住,模块只是一个在另一个 Python 程序中使用的 Python 程序。如果您直接运行程序,__name__ 会被设置为 __main__。在 清单 1-2 中,__name__ 被用来确保当程序被导入时,main() 函数不会被执行,直到您有意调用它;但是当您直接运行程序时,if 语句中的条件会被满足 ➊,main() 会自动调用 ➋。您并不总是需要这种约定。例如,如果您的代码仅定义了一个函数,您可以将其作为模块加载并调用,而不需要 __name__

让我们将除了 import 语句以外的所有内容都嵌入到 pseudonyms.py 文件中的 main() 函数下,然后将 main() 函数调用嵌入到一个 if 语句下,如 清单 1-2 所示。您可以自己进行更改,也可以从网站上下载 pseudonyms_main.py 程序。重新运行 Pylint。您应该会在命令窗口中看到以下结果。

C:\Python35\Python 3 Stuff\Psych>pylint pseudonyms_main
No config file found, using default configuration
************* Module pseudonyms_main
C: 47, 0: No space allowed around keyword argument assignment
        print(firstName, lastName, file = sys.stderr)
                                        ^ (bad-whitespace)
C:  1, 0: Missing module docstring (missing-docstring)
C:  2, 0: Multiple imports on one line (sys, random) (multiple-imports)
C:  4, 0: Missing function docstring (missing-docstring)
C: 42, 8: Invalid variable name "firstName" (invalid-name)
C: 44, 8: Invalid variable name "lastName" (invalid-name)

现在,那些关于无效常量名称的烦人评论已经消失了,但您还没有完全解决问题。尽管我喜欢它们,但 Python 的约定不允许使用 驼峰命名法(camel case),例如 firstName

配置 Pylint

在评估小型脚本时,我倾向于使用 Pylint 默认设置,并忽略虚假的“常量名称”错误。我还喜欢运行 -rn 选项(-reports=n 的简写)来抑制 Pylint 返回的大量冗余统计信息:

C:\Python35\Python 3 Stuff\Psych>pylint -rn pseudonyms_main.py

请注意,使用 -rn 会禁用代码评分选项。

Pylint 的另一个问题是,其最大行长度默认值为 100 个字符,而 PEP 8 推荐 79 个字符。为了遵循 PEP 8,您可以使用以下选项运行 Pylint:

C:\Python35\Python 3 Stuff\Psych>pylint --max-line-length=79 pseudonyms_main

现在,您会看到为 main() 函数缩进名称导致一些行超出了规范:

C: 12, 0: Line too long (80/79) (line-too-long)
C: 14, 0: Line too long (83/79) (line-too-long)
--snip--

您可能不想每次运行 Pylint 时都进行配置,幸运的是,您不必这样做。相反,您可以使用命令 –-generate-rcfile 创建自己的自定义配置文件。例如,要抑制报告并将最大行长度设置为 79,请在命令提示符下输入以下内容:

your pathname>pylint -rn --max-line-length=79 --generate-rcfile > name.pylintrc

--generate-rcfile > name.pylintrc 语句之前放置你想要的更改,并在 .pylintrc 扩展名之前提供你自己的名称。你可以独立创建一个配置文件,如前所示,或者在评估 Python 程序时同时创建。.pylintrc 文件会自动保存在当前工作目录中,尽管也可以选择添加目录路径(有关更多详情,请参见 pylint.orgpylint.readthedocs.io/en/latest/user_guide/run.html)。

要重用你自定义的配置文件,使用 --rcfile 选项,后面跟着你的个人配置文件名称和你正在评估的程序名称。例如,要在 pseudonyms_main.py 程序上运行 myconfig.pylintrc,请输入以下内容:

C:\Python35\Python 3 Stuff\Psych>pylint --rcfile myconfig.pylintrc pseudonyms_main

用文档字符串描述你的代码

Pylint 发现 pseudonyms_main.py 程序缺少文档字符串。根据 PEP 257 风格指南 (www.python.org/dev/peps/pep-0257/),文档字符串是出现在模块、函数、类或方法定义中的第一条语句的字符串字面量。文档字符串基本上是对你的代码做什么的简短描述,它可能包括代码的特定方面,如所需的输入。以下是一个单行文档字符串示例,使用三重引号表示:

def circ(r):
    """Return the circumference of a circle with radius of r."""
    c = 2 * r * math.pi
    return c

前面的文档字符串只是简单地说明了函数的作用,但文档字符串可以更长,并包含更多的信息。例如,以下是一个多行的文档字符串,描述了相同函数的输入和输出信息:

def circ(r):
    """Return the circumference of a circle with radius of r.

    Arguments:
    r – radius of circle

    Returns:
        float: circumference of circle
    """
    c = 2 * r * math.pi
    return c

不幸的是,文档字符串是与个人、项目和公司相关的事情,你会发现有很多相互矛盾的指导方针。Google 有自己的格式和优秀的风格指南。科学界的一些成员使用 NumPy 文档字符串标准。而 reStructuredText 是一种流行的格式,主要与 Sphinx 配合使用——一个利用文档字符串生成 Python 项目文档的工具,支持如 HTML 和 PDF 等格式。如果你曾经阅读过 Python 模块的文档(readthedocs.org/),那么你就看到了 Sphinx 的实际应用。你可以在 “Further Reading” 里找到这些不同风格的指南链接,第 14 页。

你可以使用一个名为 pydocstyle 的免费工具检查你的文档字符串是否符合 PEP 257 标准。在 Windows 或其他操作系统上安装它,打开命令窗口并运行 pip install pydocstyle(如果安装了 Python 2 和 3,请使用 pip3)。

要运行 pydocstyle,在包含你想检查的代码的文件夹中打开命令窗口。如果不指定文件名,pydocstyle 将在文件夹中的 所有 Python 程序上运行,并给出反馈:

C:\Python35\Python 3 Stuff\Psych>pydocstyle
.\OLD_pseudonyms_main.py:1 at module level:
        D100: Missing docstring in public module
.\OLD_pseudonyms_main.py:4 in public function `main`:
        D103: Missing docstring in public function
.\ pseudonyms.py:1 at module level:
        D100: Missing docstring in public module
.\ pseudonyms_main_broken.py:1 at module level:
        D200: One-line docstring should fit on one line with quotes (found 2)
.\ pseudonyms_main_broken.py:6 in public function `main`:
        D205: 1 blank line required between summary line and description
(found 0)

如果你指定的文件没有文档字符串问题,pydocstyle 将不会返回任何内容:

C:\Python35\Python 3 Stuff\Psych>pydocstyle pseudonyms_main_fixed.py

C:\Python35\Python 3 Stuff\Psych>

在本书的所有项目中,我将使用相对简单的文档字符串,以减少注释代码的视觉噪音。如果你想练习,随时可以扩展这些文档字符串。你也可以通过 pydocstyle 来检查你的结果。

检查代码风格

我小时候,叔叔会从我们的小镇开车到大城市去“做发型”。我一直搞不懂这与普通理发有什么区别,但我知道怎么“设计”我们的有趣名字生成器代码,使其符合 PEP 8 和 PEP 257 的规范。

pseudonyms_main.py 复制一份,命名为 pseudonyms_main_fixed.py,然后立即使用以下命令通过 Pylint 对其进行评估:

your_path>pylint --max-line-length=79 pseudonyms_main_fixed

不要使用 -rn 来抑制报告。你应该在命令窗口底部看到以下输出:

Global evaluation
-----------------
Your code has been rated at 3.33/10

现在根据 Pylint 输出修正代码。在以下示例中,我用粗体标出了修正部分。我修改了名称元组,以解决行长度问题。你还可以从本书的资源中下载修正后的代码 pseudonyms_main_fixed.py,链接在 www.nostarch.com/impracticalpython/

pseudonyms_main_fixed.py

"""Generate funny names by randomly combining names from 2 separate lists."""
import sys
import random

def main():
    """Choose names at random from 2 tuples of names and print to screen."""
    print("Welcome to the Psych 'Sidekick Name Picker.'\n")
    print("A name just like Sean would pick for Gus:\n\n")

    first = ('Baby Oil', 'Bad News', 'Big Burps', "Bill 'Beenie-Weenie'",
             "Bob 'Stinkbug'", 'Bowel Noises', 'Boxelder', "Bud 'Lite'",
             'Butterbean', 'Buttermilk', 'Buttocks', 'Chad', 'Chesterfield',
             'Chewy', 'Chigger', 'Cinnabuns', 'Cleet', 'Cornbread',
             'Crab Meat', 'Crapps', 'Dark Skies', 'Dennis Clawhammer',
             'Dicman', 'Elphonso', 'Fancypants', 'Figgs', 'Foncy', 'Gootsy',
             'Greasy Jim', 'Huckleberry', 'Huggy', 'Ignatious', 'Jimbo',
             "Joe 'Pottin Soil'", 'Johnny', 'Lemongrass', 'Lil Debil',
             'Longbranch', '"Lunch Money"', 'Mergatroid', '"Mr Peabody"',
             'Oil-Can', 'Oinks', 'Old Scratch', 'Ovaltine', 'Pennywhistle',
             'Pitchfork Ben', 'Potato Bug', 'Pushmeet', 'Rock Candy',
             'Schlomo', 'Scratchensniff', 'Scut', "Sid 'The Squirts'",
             'Skidmark', 'Slaps', 'Snakes', 'Snoobs', 'Snorki', 'Soupcan Sam',
             'Spitzitout', 'Squids', 'Stinky', 'Storyboard', 'Sweet Tea',
             'TeeTee', 'Wheezy Joe', "Winston 'Jazz Hands'", 'Worms')

    last = ('Appleyard', 'Bigmeat', 'Bloominshine', 'Boogerbottom',
            'Breedslovetrout', 'Butterbaugh', 'Clovenhoof', 'Clutterbuck',
            'Cocktoasten', 'Endicott', 'Fewhairs', 'Gooberdapple',
            'Goodensmith', 'Goodpasture', 'Guster', 'Henderson', 'Hooperbag',
            'Hoosenater', 'Hootkins', 'Jefferson', 'Jenkins',
            'Jingley-Schmidt', 'Johnson', 'Kingfish', 'Listenbee', "M'Bembo",
            'McFadden', 'Moonshine', 'Nettles', 'Noseworthy', 'Olivetti',
            'Outerbridge', 'Overpeck', 'Overturf', 'Oxhandler', 'Pealike',
            'Pennywhistle', 'Peterson', 'Pieplow', 'Pinkerton', 'Porkins',
            'Putney', 'Quakenbush', 'Rainwater', 'Rosenthal', 'Rubbins',
            'Sackrider', 'Snuggleshine', 'Splern', 'Stevens', 'Stroganoff',
            'Sugar-Gold', 'Swackhamer', 'Tippins', 'Turnipseed',
            'Vinaigrette', 'Walkingstick', 'Wallbanger', 'Weewax', 'Weiners',
            'Whipkey', 'Wigglesworth', 'Wimplesnatch', 'Winterkorn',
            'Woolysocks')

    while True:         
        first_name = random.choice(first)
        last_name = random.choice(last)

        print("\n\n")
        # Trick IDLE by using "fatal error" setting to print name in red.
        print("{} {}".format(first_name, last_name), file=sys.stderr)
        print("\n\n")

        try_again = input("\n\nTry again? (Press Enter else n to quit)\n ")

        if try_again.lower() == "n":
            break

    input("\nPress Enter to exit.")

if __name__ == "__main__":
    main()

Pylint 对修改后的代码打出了 10 分(满分)的评分:

Global evaluation
-----------------
Your code has been rated at 10.00/10 (previous run: 3.33/10, +6.67)

正如你在上一节看到的,运行 pydocstyle 对 pseudonyms_main_fixed.py 进行检查时没有报错,但不要以为这就代表代码没有问题,甚至可以认为它已经足够好。例如,这个文档字符串也能通过检查:"""ksjkdls lskjds kjs jdi wllk sijkljs dsdw noiu sss."""

编写简洁、有用且有效的文档字符串和注释是很难的。PEP 257 会帮助你处理文档字符串,但注释更具自由性和“开放范围”。注释过多会造成视觉噪音,可能让用户感到反感,并且通常不需要,因为写得好的代码本身就能自解释。添加注释的好理由包括澄清意图和避免潜在的用户错误,例如当要求特定的度量单位或输入格式时。为了找到合适的注释平衡,遇到好的例子时要注意。此外,想一想如果你在五年后重新接手自己写的代码,你希望看到什么!

Pylint 和 pydocstyle 易于安装,易于使用,它们将帮助你学习并遵守 Python 社区接受的编码标准。在你将代码发布到网络论坛寻求帮助之前,使用 Pylint 运行代码也是一种良好的做法,这样可以促使你收到“更温和、更友好”的回应!

总结

现在你应该知道如何编写符合 Python 社区期望的代码和文档了。更重要的是,你已经为一个伙伴、黑帮分子、线人,或者其他任何角色生成了一些非常有趣的名字。以下是我最喜欢的几个:

Pitchfork Ben Pennywhistle ‘Bad News’ Bloominshine
Chewy Stroganoff ‘Sweet Tea’ Tippins
Spitzitout Winterkorn Wheezy Joe Jenkins
‘Big Burps’ Rosenthal Soupcan Sam Putney
Bill ‘Beenie-Weenie’ Clutterbuck Greasy Jim Wigglesworth
Dark Skies Jingley-Schmidt Chesterfield Walkingstick
Potato Bug Quakenbush Jimbo Woolysocks
Worms Endicott Fancypants Pinkerton
Cleet Weiners Dicman Overpeck
Ignatious Outerbridge Buttocks Rubbins

进一步阅读

若要查看这些资源的可点击版本,请访问 www.nostarch.com/impracticalpython/

伪代码

一些相对正式的伪代码标准的描述可以在 users.csc.calpoly.edu/~jdalbey/SWE/pdl_std.htmlwww.slideshare.net/sabiksabz/pseudo-code-basics/ 找到。

风格指南

以下是您在创建 Python 程序时可以参考的风格指南列表。

第三方模块

以下是一些使用第三方模块的资源。

实践项目

尝试这些与字符串处理相关的项目。我的解决方案可以在附录中找到。

猪拉丁语

要构成“猪拉丁语”(Pig Latin),你需要取一个以辅音字母开头的英语单词,将辅音字母移到单词的末尾,然后在单词后加上“ay”。如果单词是以元音字母开头,则只需在单词后加上“way”。历史上最著名的“猪拉丁语”短语之一是“Marty Feldman”在梅尔·布鲁克斯的喜剧杰作《年轻的弗兰肯斯坦》中说的“ixnay on the ottenray”。

编写一个程序,输入一个单词,并使用索引和切片返回它的猪拉丁语等价词。运行 Pylint 和 pydocstyle 检查你的代码,并修正任何样式错误。你可以在附录中找到解决方案,或者从www.nostarch.com/impracticalpython/下载pig_latin_practice.py

贫穷人士的条形图

英语中六个最常用的字母可以通过助记法“etaoin”来记住(发音为eh-tay-oh-in)。编写一个 Python 脚本,输入一个句子(字符串),并返回一个简单的条形图类型的显示,参见图 1-2。提示:我使用了一个字典数据结构和两个尚未介绍的模块,pprintcollections/defaultdict

image

图 1-2:附录中 ETAOIN_practice.py 程序生成的条形图样输出

挑战项目

对于挑战项目不提供解决方案。你需要独立完成这些项目!

贫穷外籍人士的条形图

使用在线翻译工具将你的文本转换成另一种基于拉丁字母的书写系统(如西班牙语或法语),重新运行“贫穷人士的条形图”代码,并比较结果。例如,西班牙语版本的图 1-2 中的文本会生成图 1-3 中的结果。

image

图 1-3:在西班牙语翻译的文本上运行 EATOIN_challenge.py 的结果,参见图 1-2

西班牙语句子中出现的L字母数量是原来的两倍,U字母的数量是原来的三倍。为了使不同输入的条形图能够直接比较,请更改代码,使得每个字母都在图表中有一个键,即使没有相应的值也要显示出来。

中间部分

重写有趣名字生成器的代码,加入中间名。首先,创建一个新的middle_name元组,然后拆分现有的名字-中间名组合(如“Joe ‘Pottin Soil’”或“Sid ‘The Squirts’”),并将其添加到元组中。你还应将一些明显的昵称(如“Oil Can”)移到middle_name元组中。最后,添加一些新的中间名(如“The Big News”或“Grunts”或“Tinkie Winkie”)。使用 Python 的random模块,使得每次生成中间名的概率为一半或三分之一。

完全不同的东西

开始自己的一份有趣名字列表,并将其添加到有趣名字生成器中。提示:电影片尾名单是一个丰富的猎取资源!

第二章:查找回文短语**

image

雷达、皮划艇、回转器、性别。这些词有什么共同点?它们是回文,即正着读和反着读一样的词。更棒的是回文短语,即整个短语也具有相同的特性。拿破仑就是最著名回文短语的作者。当他第一次看到厄尔巴岛时,作为流放地,他说:“Able was I ere I saw Elba。”

2011 年,DC Comics 出版了一个有趣的故事,巧妙地利用了回文短语。超级英雄女巫扎塔娜被下了诅咒,只有通过回文方式才能施展魔法。她想出了一些足够用的两字短语,比如nurses runstack catspuff up,成功击败了持剑的攻击者。这让我想知道:到底有多少“具有攻击性”的回文短语?是否有更好的选择适合扎塔娜?

在本章中,你将从互联网上加载词典文件,并使用 Python 首先发现回文,然后在这些文件中发现更复杂的回文短语。接着,你将使用一个名为cProfile的工具分析回文短语代码,从而使其更加高效。最后,你将筛选回文短语,看看有多少个具有“攻击性”特征。

查找和打开词典

本章的所有项目都需要一个以文本文件格式列出的单词表,通常称为词典文件,所以我们从学习如何加载一个词典文件开始。

尽管名为“词典文件”,这些文件仅包含单词——没有发音、音节数量、定义等信息。这是个好消息,因为这些内容反而会妨碍我们。更棒的是,词典文件可以在线免费获取。

你可以在表 2-1 中列出的地址找到合适的词典文件。下载其中一个文件,或者如果文件直接打开,复制并粘贴内容到文本编辑器(如记事本或 WordPad,macOS 上的 TextEdit)中,并将其保存为.txt文件。将词典文件与 Python 代码保存在同一文件夹中。我使用了2of4brif.txt文件来准备这个项目。它可以在表 2-1 中列出的第一个网站上下载的12dicts-6.0.2.zip文件中找到。

表 2-1: 可下载的词典文件

文件 单词数量
wordlist.aspell.net/12dicts/ 60,388
inventwithpython.com/dictionary.txt 45,000
www-personal.umich.edu/~jlawler/wordlist.html 69,903
greenteapress.com/thinkpython2/code/words.txt 113,809

除了表 2-1 中的文件外,Unix 及类 Unix 操作系统通常还会附带一个以换行符分隔的包含超过 20 万个单词的大型字典文件。该文件通常存储在/usr/share/dict/words/usr/dict/words中。在 Debian GNU/Linux 中,单词列表位于/usr/share/opendict/dictionaries。macOS 字典通常位于/Library/Dictionaries,并且包含非英语字典。如果你想使用这些文件,你可能需要在线搜索你的操作系统和版本,找到确切的目录路径。

一些字典文件会排除aI作为单词。其他字典可能将每个字母作为一个单独的“头词”包括在字典中(例如以d开头的单词会以d作为头词)。在这些项目中,我们将忽略一字母回文,因此这些问题不应成为困扰。

处理打开文件时的异常

每当你加载外部文件时,你的程序应该自动检查输入/输出问题,比如缺失文件或文件名错误,并在出现问题时通知你。

使用以下tryexcept语句来捕获和处理在执行过程中检测到的异常

➊ try:
    ➋ with open(file) as in_file:
           do something
   except IOError➌ as e:
    ➍ print("{}\nError opening {}. Terminating program.".format(e, file),
              file=sys.stderr)   
    ➎ sys.exit(1)

try语句首先执行 ➊。with语句会在嵌套代码块执行完毕后自动关闭文件,不管该块是如何退出的 ➋。在结束进程前关闭文件是一种良好的实践。如果不关闭文件,可能会用完文件描述符(这通常是长时间运行的大脚本的问题)、在 Windows 中锁定文件使其无法进一步访问、损坏文件,或者在写入文件时丢失数据。

如果出现问题,且错误类型与except关键字后面指定的异常类型匹配 ➌,则跳过剩余的try语句,执行except语句 ➍。如果没有出现问题,则执行try语句并跳过except语句。except语句中的print语句会让你知道出现了问题,而file=sys.stderr参数会在 IDLE 解释器窗口中将错误信息显示为红色。

sys.exit(1) ➎语句用于终止程序。sys.exit(1)中的1表示程序遇到错误并未成功关闭。

如果发生一个未命名的异常,并且它不匹配except语句中的命名异常,它会被传递给任何外部的try语句或主程序执行。如果没有找到处理程序,未处理的异常将导致程序停止并显示标准的“回溯”错误信息。

加载字典文件

列表 2-1 将字典文件作为列表加载。你可以手动输入这个脚本,或者从nostarch.com/impracticalpython/下载它作为load_dictionary.py

你可以将这个文件作为模块导入到其他程序中,并通过一行语句运行它。记住,模块只是一个可以在另一个 Python 程序中使用的 Python 程序。正如你可能已经知道的,模块代表了一种抽象。抽象意味着你不必担心所有的编码细节。抽象的一个原则是封装,即隐藏细节。我们将文件加载代码封装在一个模块中,这样你就不需要看到或担心其他程序中的详细代码。

load_dictionary.py

   """Load a text file as a list.

   Arguments:
   -text file name (and directory path, if needed)

   Exceptions:
   -IOError if filename not found.

   Returns:
   -A list of all words in a text file in lower case.

   Requires-import sys

   """
➊ import sys

➋ def load(file):
       """Open a text file & return a list of lowercase strings."""
       try:
           with open(file) as in_file:
            ➌ loaded_txt = in_file.read().strip().split('\n')
            ➍ loaded_txt = [x.lower() for x in loaded_txt]
               return loaded_txt
       except IOError as e:
        ➎ print("{}\nError opening {}. Terminating program.".format(e, file),
               file=sys.stderr)
           sys.exit(1)

清单 2-1:将字典文件加载为列表的模块

在文档字符串之后,我们导入了系统函数sys,以便我们的错误处理代码可以正常工作 ➊。接下来的代码块定义了一个基于之前文件打开讨论的函数 ➋。该函数以文件名作为参数。

如果没有抛出异常,文本文件的空白字符会被去除,其内容将被拆分成单独的行并添加到列表中 ➌。我们希望每个单词在返回之前都成为列表中的一个独立项。而且由于大小写对 Python 具有重要意义,列表中的单词会通过列表推导式 ➍转换为小写。列表推导式是一种简写方式,用于将列表或其他可迭代对象转换为另一个列表。在这种情况下,它替代了for循环。

如果遇到 I/O 错误,程序会显示标准的错误信息,由e表示,并附带描述事件的消息,告知用户程序即将结束 ➎。然后,sys.exit(1)命令终止程序。

这个代码示例是为了说明这些步骤如何一起工作。通常情况下,你不会在模块中调用sys.exit(),因为你可能希望你的程序在终止之前做一些事情——比如写入日志文件。在后面的章节中,我们会将try-except块和sys.exit()都移到main()函数中,以便更清晰地控制。

项目 #2:寻找回文

你将从在字典中找到单个单词的回文开始,然后转向更困难的回文短语。

目标

使用 Python 在英文词典文件中搜索回文。

策略与伪代码

在你进入代码之前,先退后一步,思考一下你想要做的事情。从概念上讲,识别回文是很简单的:只需将一个单词与其反向切片后的结果进行比较。以下是一个将单词从前到后切片然后再从后到前切片的例子:

>>> word = 'NURSES'
>>> word[:]
'NURSES'
>>> word[::-1]
'SESRUN'

如果你在切片字符串(或任何可切片类型)时不提供值,默认会使用字符串的起始位置、结束位置和步长为1

图 2-1 展示了反向切片过程。我提供了起始位置为2,步长为-1。由于没有提供结束索引(冒号之间没有索引或空格),意味着要反向切片(因为索引步长是-1),直到没有更多字符可用。

image

图 2-1:负切片的示例,针对 word = 'NURSES'

负切片的行为与正切片不完全相同,正负位置值和端点是不对称的。这可能会导致混淆,因此让我们将负切片限制为简单的 [::-1] 格式。

在词典中查找回文所需的代码行数比加载词典文件还少!这是伪代码:

Load digital dictionary file as a list of words
Create an empty list to hold palindromes
Loop through each word in the word list:
    If word sliced forward is the same as word sliced backward:
        Append word to palindrome list
Print palindrome list

回文代码

清单 2-2,palindromes.py,读取英文词典文件,识别哪些单词是回文,将它们保存到列表中,并将列表以堆叠的形式打印出来。你可以从本书的资源页面下载此代码,网址为 www.nostarch.com/impracticalpython/。你还需要 load_dictionary.py 和一个词典文件;将所有三个文件保存在同一文件夹中。

palindromes.py

   """Find palindromes (letter palingrams) in a dictionary file."""
➊ import load_dictionary
➋ word_list = load_dictionary.load('2of4brif.txt')
➌ pali_list = []

➍ for word in word_list:
       if len(word) > 1 and word == word[::-1]:
           pali_list.append(word)

   print("\nNumber of palindromes found = {}\n".format(len(pali_list)))
➎ print(*pali_list, sep='\n')

清单 2-2:查找加载的词典文件中的回文

首先,通过将 load_dictionary.py 作为模块导入 ➊。注意,导入时不使用 .py 扩展名。此外,由于该模块与此脚本在同一文件夹中,因此我们无需指定模块的目录路径。并且由于模块已经包含了所需的 import sys 语句,我们不需要在此重复它。

为了用词典中的单词填充我们的单词列表,可以通过点符号 ➋ 调用 load() 函数,来自 load_dictionary 模块。将外部词典文件的名称传递给它。如果词典文件与 Python 脚本在同一文件夹中,则不需要指定路径。你使用的文件名可能会根据你下载的词典有所不同。

接下来,创建一个空列表来保存回文 ➌,并开始循环遍历 word_list 中的每个单词 ➍,将正向切片与反向切片进行比较。如果这两个切片相同,则将该单词添加到 pali_list。注意,只有长度大于一的单词才被允许(len(word) > 1),这符合回文的最严格定义。

最后,以一种美观的方式打印回文——堆叠显示且没有引号或逗号 ➎。你可以通过循环遍历列表中的每个单词来完成这项工作,但有一种更高效的方式。你可以使用 拆包 操作符(由 * 表示),它将列表作为输入,并将其扩展为函数调用中的位置参数。最后一个参数是多个列表值之间的分隔符。默认分隔符是空格(sep=' '),但你可以改为将每个项目打印在新的一行上(sep='\n')。

单词回文很少,至少在英语中是这样的。使用一个包含 60,000 个单词的词典文件,你最多可能会找到约 60 个回文单词,约占所有单词的 0.1%。尽管它们很稀少,但使用 Python 查找它们还是很容易的。那么,让我们继续更有趣、更复杂的回文组合。

项目 #3:寻找回文组合

查找回文词对比查找单词回文需要更多的努力。在本节中,我们将规划并编写代码来查找词对回文。

目标

使用 Python 搜索英语词典中的双词回文。使用 cProfile 工具分析和优化回文代码。

策略与伪代码

例子词对回文有nurses runstir grits。(如果你在想,grits 是一种玉米早餐食品,类似于意大利的玉米粥。)

像回文一样,回文词对正反读都相同。我喜欢把这些看作是一个核心词,比如nurses,从中衍生出一个回文序列反向单词(见图 2-2)。

image

图 2-2:解析词对回文图

我们的程序将分析核心词。基于图 2-2,我们可以对核心词做出以下推断:

  1. 它可以是奇数或偶数个字母。

  2. 单词的一个连续部分在反向读取时拼写出一个实际单词。

  3. 这个连续部分可以占据核心词的一部分或全部。

  4. 另一个连续部分包含一个回文序列的字母。

  5. 回文序列可以占据核心词的一部分或全部。

  6. 回文序列不必是实际的单词(除非它占据了整个单词)。

  7. 这两部分不能重叠或共享字母。

  8. 该序列是可逆的。

注意

如果反向单词占据了整个核心词并且不是回文,它被称为反向回文(semordnilap)。反向回文与回文相似,除了一个关键的区别:它不是在反向读取时拼写出相同的单词,而是拼写出不同的单词。例子有batsstab,还有wolfflow。顺便说一下,semordnilap 就是回文反过来的拼写。

图 2-3 表示一个任意的六个字母的单词。Xs 代表当单词反向阅读时,可能形成实际单词的部分(例如runnurses中)。Os 代表可能的回文序列(例如nurses中的ses)。图 2-3 左栏中表示的单词像图 2-2 中的nurses一样,反向单词位于开头。右栏中的单词像grits一样,反向单词位于结尾。请注意,每一列的组合数是单词中字母的总数加一;还要注意,上下行表示的是相同的情况。

每列的顶部行表示一个反向词(semordnilap)。每列的底部行表示一个回文。它们都是反向单词,只是类型不同的反向单词。因此,它们被视为同一个实体,并且可以通过单行代码在单个循环中识别。

image

图 2-3:六个字母核心单词中反向单词(X)和回文序列(O)的可能位置

要查看图示的实际操作,考虑 图 2-4,其中展示了 palingrams devils livedretro porterDevilsporter 是两个核心单词,并且在回文序列和反向单词方面相互镜像。将其与倒序词 evil 和回文 kayak 进行比较。

image

图 2-4:单词中的反向单词(X)和回文序列(O),倒序词和回文。

回文既是反向单词 也是 回文序列。由于它们具有与倒序词相同的 X 模式,因此可以使用与倒序词相同的代码进行处理。

从策略角度来看,你需要遍历字典中的每个单词,并根据 图 2-3 中的 所有组合 进行评估。假设字典包含 60,000 个单词,程序大约需要进行 500,000 次循环。

要理解循环,看看 图 2-5 中的核心单词 stack cats。你的程序需要从单词的结尾字母开始,逐步加入字母。在寻找像 stack cats 这样的 palingram 时,它将同时评估单词末尾是否存在回文序列 stack,并且在开始处是否有反向单词。注意, 图 2-5 中的第一个循环会成功,因为单个字母 (k) 在这种情况下可以作为回文。

image

图 2-5:示例循环遍历核心单词,同时寻找回文和反向单词

但你还没有完成。为了捕捉 图 2-3 中的“镜像”行为,你需要反向运行循环,寻找单词开头的回文序列和结尾的反向单词。这将使你能够找到像 stir grits 这样的 palingram。

下面是一个找到 palingram 的伪代码:

Load digital dictionary as a list of words
Start an empty list to hold palingrams
For word in word list:
    Get length of word
    If length > 1:
        Loop through the letters in the word:
            If reversed word fragment at front of word is in word list and letters
            after form a palindromic sequence:
               Append word and reversed word to palingram list
            If reversed word fragment at end of word is in word list and letters
            before form a palindromic sequence:
               Append reversed word and word to palingram list
Sort palingram list alphabetically
Print word-pair palingrams from palingram list

Palingrams 代码

清单 2-3,palingrams.py,遍历单词列表,识别哪些单词形成单词对 palingram,保存这些对到一个列表中,并将列表作为堆叠项目打印出来。你可以从 www.nostarch.com/impracticalpython/ 下载代码。我建议你使用 2of4brif.txt 字典文件开始,这样你的结果会和我的一致。将字典文件和 load_dictionary.py 存放在与 palingrams 脚本相同的文件夹中。

palingrams.py

   """Find all word-pair palingrams in a dictionary file."""

   import load_dictionary

   word_list = load_dictionary.load('2of4brif.txt')

   # find word-pair palingrams

➊ def find_palingrams():

       """Find dictionary palingrams."""

       pali_list = []

       for word in word_list:

        ➋ end = len(word)

        ➌ rev_word = word[::-1]

        ➍ if end > 1:

            ➎ for i in range(end):

                ➏ if word[i:] == rev_word[:end-i] and rev_word[end-i:] in word_list:

                       pali_list.append((word, rev_word[end-i:]))

                ➐ if word[:i] == rev_word[end-i:] and rev_word[:end-i] in word_list:

                       pali_list.append((rev_word[:end-i], word))

    ➑ return pali_list

➒ palingrams = find_palingrams()

   # sort palingrams on first word

   palingrams_sorted = sorted(palingrams)

   # display list of palingrams

➓ print("\nNumber of palingrams = {}\n".format(len(palingrams_sorted)))

   for first, second in palingrams_sorted:

       print("{} {}".format(first, second))

清单 2-3:在加载的字典中查找并打印单词对 palingram

在使用palindromes.py代码加载词典文件后,定义一个函数来查找回文组合 ➊。使用函数可以让你在之后将代码隔离开来,并计算处理词典中所有单词所需的时间。

立即启动一个名为pali_list的列表,用于存储程序发现的所有回文组合。接下来,启动一个for循环来评估word_list中的单词。对于每个单词,找到它的长度并将其赋值给变量end ➋。单词的长度决定了程序用来切片单词的索引,从而查找每一个可能的反转单词-回文序列组合,如图 2-3 所示。

接下来,反向切片单词并将结果赋值给变量rev_word ➌。word[::-1]的替代方法是''.join(reversed(word)),一些人认为这种方法更具可读性。

由于你正在寻找单词对的回文组合,排除掉单字母单词 ➍。然后嵌套另一个for语句,遍历当前单词中的字母 ➎。

现在,运行一个条件判断,要求单词的后半部分是回文,并且前半部分是单词列表中反转的单词(换句话说,是真正的单词) ➏。如果单词通过测试,它将被添加到回文组合列表中,并紧接着是反转后的单词。

根据图 2-3,你知道需要重复条件判断,但要改变切片方向和单词顺序,以反转输出。换句话说,你必须捕获单词开头的回文序列,而不是结尾 ➐。返回回文组合列表以完成函数 ➑。

定义了函数后,调用它 ➒。由于在循环过程中添加到回文组合列表中的词典单词的顺序发生变化,回文组合将不会按字母顺序排列。因此,排序该列表,以确保词对中的第一个单词按字母顺序排列。打印列表的长度 ➓,然后将每个单词对单独打印在一行上。

按照现有的写法,palingrams.py在约 60,000 个单词的词典文件上运行大约需要三分钟。在接下来的部分中,我们将探讨这个长时间运行的原因,并看看我们能做些什么来解决这个问题。

回文组合分析

分析是一个分析过程,它在程序执行时收集程序行为的统计数据——例如函数调用的数量和持续时间。分析是优化过程中的关键部分。它能告诉你程序中哪些部分占用了最多的时间或内存。这样,你就知道应该在哪些地方集中精力以提高性能。

使用 cProfile 进行分析

配置文件是一个度量输出——记录程序各部分执行的时长和频率。Python 标准库提供了一个方便的分析接口cProfile,它是一个适用于分析长时间运行程序的 C 扩展。

find_palingrams()函数中的某些内容可能导致palingrams.py程序的相对较长运行时间。为了确认,我们运行cProfile

将以下代码复制到一个新文件中,命名为cprofile_test.py,并将其保存在与palingrams.py和字典文件相同的文件夹中。该代码导入了cProfile和 palingrams 程序,并对find_palingrams()函数进行cProfile分析——通过点符号调用。再次提醒,你无需指定.py扩展名。

import cProfile
import palingrams
cProfile.run('palingrams.find_palingrams()')

运行cprofile_test.py,在它完成后(你会看到解释器窗口中的>>>),你应该会看到类似下面的内容:

         62622 function calls in 199.452 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000  199.451  199.451 <string>:1(<module>)
        1  199.433  199.433  199.451  199.451 palingrams.py:7(find_palingrams)
        1    0.000    0.000  199.452  199.452 {built-in method builtins.exec}
    60388    0.018    0.000    0.018    0.000 {built-in method builtins.len}
     2230    0.001    0.000    0.001    0.000 {method 'append' of 'list' objects}

所有的循环、切片和搜索在我的机器上花费了 199.452 秒,但当然你的时间可能与我的不同。你还会获得一些内置函数的额外信息,且由于每个 palingram 都调用了内置的append()函数,你甚至可以看到找到的 palingrams 数量(2,230)。

注意

最常见的运行 cProfile 方式是在解释器中直接运行。这可以让你将输出转储到文本文件,并通过网页查看器查看。更多信息,请访问 docs.python.org/3/library/profile.html

使用 time 进行性能分析

另一种对程序进行计时的方法是使用time.time(),它返回一个纪元时间戳——自 1970 年 1 月 1 日 UTC 午夜 12 点以来的秒数(Unix 纪元)。将palingrams.py复制到一个新文件中,保存为palingrams_timed.py,并在文件顶部插入以下代码:

import time
start_time = time.time()

现在去文件的末尾,添加以下代码:

end_time = time.time()
print("Runtime for this program was {} seconds.".format(end_time - start_time))

保存并运行文件。你应该在解释器窗口的底部看到以下反馈——大约几秒钟:

Runtime for this program was 222.73954558372498 seconds.

运行时间比之前长,因为你现在在评估整个程序,包括打印输出,而不仅仅是find_palingrams()函数。

cProfile不同,time不提供详细的统计数据,但像cProfile一样,它可以在单个代码组件上运行。编辑你刚才运行的文件,将开始和结束时间语句(如下所示的粗体部分)移到我们长时间运行的find_palingrams()函数两侧。保持文件顶部和底部的importprint语句不变。

start_time = time.time()
palingrams = find_palingrams()
end_time = time.time()

保存并运行文件。你应该在解释器窗口的底部看到以下反馈:

Runtime for this program was 199.42786622047424 seconds.

这现在与使用cProfile时的初始结果相匹配。如果重新运行程序或使用不同的计时器,你不会得到完全相同的时间,但不要纠结于此。重要的是相对时间,用于指导代码优化。

Palingram 优化

抱歉,三分钟的等待时间对我来说太长了。根据我们的性能分析结果,我们知道 find_palingrams() 函数占用了大部分处理时间。这可能与读取和写入列表、对列表进行切片或在列表中查找有关。使用其他数据结构(如元组、集合或字典)可能会加速该函数。特别是,使用 in 关键字时,集合比列表要快得多。集合使用哈希表进行非常快速的查找。通过哈希,文本字符串会被转换为独特的数字,这些数字比引用的文本要小得多,查找也更高效。而列表则需要对每个项进行线性搜索。

想象一下,如果你在家里找丢失的手机,你可以通过查看每个房间来模拟一个列表,直到在(俗话说的)最后一个地方找到它。但如果你模拟一个集合,你基本上可以从另一部电话拨打你的手机号码,听到铃声后直接走到正确的房间。

使用集合的一个缺点是,集合中项目的顺序不可控,并且不允许重复值。使用列表时,顺序被保留且允许重复,但查找速度较慢。幸运的是,我们不关心顺序或重复,因此集合是最佳选择!

Listing 2-4 是原始 palingrams.py 程序中的 find_palingrams() 函数,经过编辑,改用一个单词集合而不是一个单词列表。你可以在一个名为 palingrams_optimized.py 的新程序中找到它,你可以从 www.nostarch.com/impracticalpython/ 下载,或者如果你想自己检查新的运行时间,也可以直接对你的 palingrams_timed.py 版本做这些更改。

palingrams_optimized.py

def find_palingrams():

    """Find dictionary palingrams."""

    pali_list = []

 ➊ words = set(word_list)

 ➋ for word in words:

        end = len(word)

        rev_word = word[::-1]

        if end > 1:

            for i in range(end):

             ➌ if word[i:] == rev_word[:end-i] and rev_word[end-i:] in words:

                    pali_list.append((word, rev_word[end-i:]))

             ➍ if word[:i] == rev_word[end-i:] and rev_word[:end-i] in words:

                    pali_list.append((rev_word[:end-i], word))

    return pali_list

Listing 2-4: 使用集合优化的 find_palingrams() 函数

只有四行代码需要更改。定义一个新的变量 words,它是 word_list 的集合 ➊。然后遍历集合 ➋,查找单词切片在这个集合中的成员资格 ➌➍,而不是像以前那样在列表中查找。

这是 palingrams_optimized.pyfind_palingrams() 函数的新运行时间:

Runtime for this program was 0.4858267307281494 seconds.

哇!从三分钟缩短到不到一秒!这就是优化!差异就在于数据结构。验证一个单词是否属于列表是让我们头疼的地方。

为什么我先展示了“错误”的做法?因为这就是现实世界中的情况。你先让代码运行起来,然后再进行优化。这是一个简单的例子,经验丰富的程序员从一开始就能做对,但它代表了优化的总体概念:先让它尽可能地工作,再去改进它。

dnE ehT

你已经编写了用于查找回文和回文字符串的代码,使用 cProfile 对代码进行了性能分析,并通过使用适合任务的数据结构优化了代码。那么,我们在 Zatanna 面前表现如何?她有机会获胜吗?

在这里,我列出了一些在 2of4brif 字典文件中找到的“更具攻击性”的回文字符串——从意外的 相同灌肠 到严厉的 躯干腐烂,再到作为地质学家的我最喜欢的 风化矿石

倾倒泥土 昏昏欲睡剑 相同灌肠
腿部凝胶 牛仔裤挖掘 麻风病人排斥
冰雹鳗鱼 乳制品袭击 猛击哺乳动物
风化矿石 升起先生 锅不停
滑行放屁 躯干腐烂 天鹅啃咬
狼流 伙伴陷阱 坚果震惊
拍打伙伴 拍打小腿 旋钮撞击

进一步阅读

Think Python, 2nd Edition》(O’Reilly,2015),由 Allen Downey 编写,简洁明了地描述了哈希表及其为何如此高效。这也是一本很棒的 Python 参考书。

实践项目:字典清理

互联网上可用的数据文件并不总是“即插即用”。你可能会发现,在将数据应用到项目之前,你需要稍微处理一下数据。如前所述,一些在线字典文件将每个字母作为一个单词。若要允许在回文字符串中使用一字母单词(例如 acidic a),这些字母将会造成问题。你可以通过直接编辑字典文本文件来删除它们,但这很繁琐且不划算。相反,编写一个简短的脚本,在字典加载到 Python 后删除这些单词。为了测试它是否有效,可以编辑你的字典文件,加入一些像 bc 这样的单字母单词。有关解决方案,请参见附录,或在网上找到副本 (dictionary_cleanup_practice.py),网址为 www.nostarch.com/impracticalpython/

挑战项目:递归方法

在 Python 中,通常有不止一种方法解决问题。请查看 Khan Academy 网站上的讨论和伪代码(www.khanacademy.org/computing/computer-science/algorithms/recursive-algorithms/a/using-recursion-to-determine-whether-a-word-is-a-palindrome/)。然后重写 palindrome.py 程序,使其使用递归来识别回文。

第三章:解决字谜

image

字谜 是通过重新排列另一个单词的字母形成的单词。例如,Elvis 产生了令人毛骨悚然的三重奏 evilslivesveils。这是否意味着 Elvis 仍然活着,但隐藏了他邪恶的存在?在《哈利·波特与密室》一书中,“I am Lord Voldemort” 是邪恶巫师真名 Tom Marvolo Riddle 的字谜。 “Lord Earldom Vomit” 也是 Tom Marvolo Riddle 的字谜,但作者 J.K. Rowling 很有眼光地放弃了这个版本。

在本章中,你将首先找到给定单词或姓名的所有字谜。然后,你将编写一个程序,让用户可以交互式地从自己的名字构建字谜短语。最后,你将成为计算机巫师,看看如何从 “Tom Marvolo Riddle” 中提取 “I am Lord Voldemort”。

项目 #4:查找单词字谜

你将从分析简单的单词字谜开始,并搞清楚如何通过编程来识别它们。完成这个任务后,你就准备好在接下来的章节中处理字谜短语了。

目标

使用 Python 和字典文件查找给定英语单词或单一姓名的所有单词字谜。你可以在 第二章 开头阅读查找和加载字典文件的说明。

策略与伪代码

超过 600 家报纸和 100 个网站都有一个叫做 Jumble 的字谜游戏。该游戏创建于 1954 年,现在是世界上最受欢迎的字谜游戏。Jumble 可能会非常让人沮丧,但找字谜几乎像找回文一样简单——你只需要知道所有字谜的共同特点:它们必须包含相同数量的相同字母。

识别字谜

Python 没有内建的字谜运算符,但你可以很容易地编写一个。在本章的项目中,你将从 第二章 加载字典文件作为字符串列表。因此,程序需要验证两个字符串是否为彼此的字谜。

让我们来看一个例子。Potsstop 的字谜,你可以通过 len() 函数验证 stoppots 的字母数相同。但 Python 无法知道两个字符串是否有相同数量的单个字符——至少在不将字符串转换为其他数据结构或使用计数函数的情况下无法知道。所以,与其简单地将这两个单词视为字符串,不如将它们表示为包含单个字符字符串的两个列表。你可以在 shell 中(例如 IDLE)创建这些列表,并将它们命名为 wordanagram,正如我在这里所做的那样:

>>> word = list('stop')
>>> word
['s', 't', 'o', 'p']
>>> anagram = list('pots')
>>> anagram
['p', 'o', 't', 's']

这两个列表符合我们对字谜对的描述;也就是说,它们包含相同数量的相同字母。但如果你尝试用比较运算符 == 将它们相等,结果会是 False

>>> anagram == word
False

问题在于运算符(==)仅在两个列表具有相同数量的相同项且这些项按相同顺序排列时,才会认为它们是等价的。你可以通过内置函数sorted()轻松解决这个问题,它可以接受一个列表作为参数,并按字母顺序重新排序其内容。因此,如果你分别对这两个列表调用sorted(),然后比较排序后的列表,它们将是等价的。换句话说,==会返回True

>>> word = sorted(word)
>>> word
['o', 'p', 's', 't']
>>> anagram = sorted(anagram)
>>> anagram
['o', 'p', 's', 't']
>>> anagram == word
True

你也可以将一个字符串传递给sorted()函数来创建一个排序后的列表,类似于前面的代码片段。这对将字典文件中的单词转换为排序后的单字符字符串列表非常有用。

现在你已经知道如何验证是否找到一个变位词,让我们设计整个脚本——从加载字典和提示用户输入单词(或名字)到搜索并打印所有的变位词。

使用伪代码

记住,使用伪代码进行规划可以帮助你发现潜在的问题,早期发现问题可以节省时间。以下的伪代码应有助于你更好地理解我们将在下一节中编写的脚本anagrams.py

Load digital dictionary file as a list of words
Accept a word from user
Create an empty list to hold anagrams
Sort the user-word
Loop through each word in the word list:
    Sort the word
    if word sorted is equal to user-word sorted:
        Append word to anagrams list
Print anagrams list

该脚本将首先从字典文件中加载单词到一个字符串列表中。在开始遍历字典查找变位词之前,你需要知道你想要查找变位词的单词,并且需要一个地方来存储找到的变位词。所以,首先要求用户输入一个单词,然后创建一个空列表来存储变位词。一旦程序遍历完字典中的每个单词,它将打印出变位词列表。

变位词查找器代码

清单 3-1 加载一个字典文件,接受程序中指定的单词或名字,并查找该单词或名字在字典文件中的所有变位词。你还需要在第二章中获取字典加载的代码。你可以从www.nostarch.com/impracticalpython/下载这两个文件,分别为anagrams.pyload_dictionary.py。请将这两个文件保存在同一文件夹内。你可以使用在第二章中使用的相同字典文件,或者下载另一个文件(有关建议,请参见表格 2-1,位于第 20 页)。

anagrams.py

➊ import load_dictionary

➋ word_list = load_dictionary.load('2of4brif.txt')

➌ anagram_list = []

   # input a SINGLE word or SINGLE name below to find its anagram(s):
➍ name = 'Foster'
   print("Input name = {}".format (name))
➎ name = name.lower()
   print("Using name = {}".format(name))

   # sort name & find anagrams
➏ name_sorted = sorted(name)
➐ for word in word_list:
       word = word.lower()
       if word != name:
           if sorted(word) == name_sorted:
               anagram_list.append(word)

   # print out list of anagrams
   print()
➑ if len(anagram_list) == 0:
       print("You need a larger dictionary or a new name!")
   else:
    ➒ print("Anagrams =", *anagram_list, sep='\n')

清单 3-1:给定一个单词(或名字)和一个字典文件,该程序会搜索并打印出一个包含所有变位词的列表。

你从导入你在第二章中创建的load_dictionary模块开始 ➊。这个模块将打开一个字典文本文件,并通过其load()函数将所有单词加载到一个列表中 ➋。你使用的.txt文件可能不同,具体取决于你下载了哪个字典文件(参见“查找并打开字典”,位于第 20 页)。

接下来,创建一个空列表,命名为anagram_list,用于存储找到的任何字谜 ➌。让用户添加一个单独的单词,例如他们的名字 ➍。这不一定是一个正式的名字,但我们将在代码中称其为name,以便与字典中的word区分开。打印这个名字,以便用户能看到输入了什么内容。

下一行预见到了一个可能出现的问题用户行为。人们往往会输入以大写字母开头的名字,但字典文件可能不包括大写字母,而这对 Python 来说是有影响的。因此,首先使用.lower()字符串方法将所有字母转换为小写 ➎。

现在对名字进行排序 ➏。如前所述,你可以将sorted()传递给一个字符串,而不仅仅是一个列表。

输入按字母顺序排列在列表中后,接下来是查找字谜。开始遍历字典单词列表中的每个单词 ➐。为了安全起见,将单词转换为小写字母,因为比较操作是区分大小写的。转换后,将单词与未排序的名称进行比较,因为一个单词不能是它自己的字谜。接下来,对字典单词进行排序,并与排序后的名称进行比较。如果匹配,则将该字典单词添加到anagram_list中。

现在显示结果。首先,检查字谜列表是否为空。如果为空,打印一个俏皮的回复,这样就不会让用户空等 ➑。如果程序找到了至少一个字谜,则使用拆包(*)操作符打印列表。记住在第二章中提到的,拆包让你将列表中的每个成员打印在单独的一行 ➒。

以下是该程序的示例输出,使用输入名字Foster

Input name = Foster
Using name = foster

Anagrams =
forest
fortes
softer

如果你想使用其他输入,可以在源代码中更改name变量的值。作为练习,尝试调整代码,使程序提示用户输入名字(或单词);你可以使用input()函数实现这一点。

项目#5:寻找短语字谜

在上一个项目中,你将一个名字或单词的字母重新排列,找到单一单词的字谜。现在,你将从一个名字中推导出多个单词。这些短语字谜只包含输入名称的一部分字母,因此你需要几个单词来用尽所有字母。

目标

编写一个 Python 程序,让用户通过互动从名字中的字母构建字谜短语。

策略与伪代码

最棒的短语字谜是那些描述与名字拥有者相关的著名特征或行为的。例如,“Clint Eastwood”的字母可以重新排列形成old west action,“Alec Guinness”可以变成genuine class,“Madam Curie”变为radium came,“George Bush”变为he bugs Gore,而“Statue of Liberty”则包含built to stay free。我自己的名字重新排列后得出a huge navel,这其实并不是我的特点。

到此时,你可能会看到一个战略性的挑战:计算机如何处理上下文内容?发明了 Watson 的 IBM 团队似乎知道答案,但对于我们其他人来说,这块巨石有点难以搬动。

暴力破解法是在线异位词生成器中常用的一种方法。这些算法接收一个名字并返回大量随机的异位词短语(通常是 100 个到 1 万多个)。大多数返回的短语是无意义的,滚动浏览这些内容可能是件繁琐的事。

另一种方法是认识到人类在处理上下文问题时最为擅长,因此编写一个程序帮助人类解决问题。计算机可以获取初始名称并提供从其中一些(或所有)字母组成的单词;然后用户可以选择一个“合适”的单词。程序随后将根据名称中剩余字母重新计算单词选项,直到每个字母都被使用或所有可能的单词选项耗尽。这个设计发挥了双方的优势。

你需要一个简单的界面,提示用户输入初始名称,展示潜在的单词选项,并显示剩余的字母。程序还需要跟踪正在生成的异位词短语,并在每个字母都被使用时通知用户。由于很可能会有许多失败的尝试,所以界面应该允许用户随时重新开始过程。

由于异位词具有相同数量的相同字母,另一种识别异位词的方法是统计每个字母的出现次数。如果你将你的名字看作一个字母集合,那么一个单词可以由你的名字构成,当且仅当(1)该单词的所有字母都出现在你的名字中,并且(2)它们的出现频率相同或更少。显然,如果e在一个单词中出现三次,而在你的名字中出现两次,那么这个单词不能由你的名字衍生出来。因此,如果构成一个单词的字母集合不是你名字中字母集合的子集,那么这个单词就不能是你名字的异位词。

使用 Counter 来统计字母

幸运的是,Python 自带了一个名为collections的模块,其中包含了几种容器数据类型。其中一种类型,Counter,用于统计项的出现次数。Python 将这些项存储为字典的键,将计数存储为字典的值。例如,下面的代码片段统计了在一个列表中每种盆栽树的数量。

   >>> from collections import Counter
➊ >>> my_bonsai_trees = ['maple', 'oak', 'elm', 'maple', 'elm', 'elm', 'elm', 'elm']
➋ >>> count = Counter(my_bonsai_trees)
   >>> print(count)
➌ Counter({'elm': 5, 'maple': 2, 'oak': 1})

my_bonsai_trees列表包含了多种相同类型的树木 ➊。Counter统计这些树木 ➋,并创建一个便于引用的字典 ➌。请注意,print()函数是可选的,这里使用它是为了清晰展示。单独输入count也会显示字典内容。

你可以使用Counter,而不是sorted()方法,来查找单词异位词。输出将是两个字典,而不是两个排序后的列表,这两个字典也可以直接用==进行比较。下面是一个示例:

   >>> name = 'foster'
   >>> word = 'forest'
   >>> name_count = Counter(name)
   >>> print(name_count)
➊ Counter({'f': 1, 't': 1, 'e': 1, 'o': 1, 'r': 1, 's': 1})
   >>> word_count = Counter(word)
   >>> print(word_count)
➋ Counter({'f': 1, 't': 1, 'o': 1, 'e': 1, 'r': 1, 's': 1})

Counter 为每个单词生成一个字典,将单词中的每个字母映射到它出现的次数➊➋。这些字典是无序的,但尽管没有排序,Python 仍能正确识别相同字母和相同计数的字典是相等的:

>>> if word_count == name_count:
        print("It's a match!")

It's a match!

Counter 提供了一种很棒的方式来查找在名称中“适配”的单词。如果一个单词中每个字母的计数小于或等于该字母在名称中出现的计数,那么这个单词就可以从该名称中派生出来!

伪代码

我们现在做出了两个重要的设计决策:(1)让用户交互式地每次构建一个字谜;(2)使用 Counter 方法来查找字谜。这足以开始思考高层次的伪代码:

Load a dictionary file
Accept a name from user
Set limit = length of name
Start empty list to hold anagram phrase
While length of phrase < limit:
    Generate list of dictionary words that fit in name
    Present words to user
    Present remaining letters to user
    Present current phrase to user
    Ask user to input word or start over
    If user input can be made from remaining letters:
        Accept choice of new word or words from user
        Remove letters in choice from letters in name
        Return choice and remaining letters in name
    If choice is not a valid selection:
        Ask user for new choice or let user start over
    Add choice to phrase and show to user
    Generate new list of words and repeat process
When phrase length equals limit value:
    Display final phrase
    Ask user to start over or to exit
分配工作

随着过程性代码变得越来越复杂,有必要将大部分代码封装到函数中。这使得管理输入和输出、执行递归以及阅读代码变得更加容易。

主函数 是程序开始执行的地方,并且使得高层次的组织变得可能,例如管理代码的各个部分,包括处理用户。在短语字谜程序中,主函数将包装所有“工作蜂”函数,处理大部分用户输入,跟踪逐渐生成的字谜短语,确定短语何时完成,并向用户显示结果。

用铅笔和纸草拟任务及其流程是一个很好的方法,可以帮助你理清想做什么以及在哪里做(类似于“图形伪代码”)。图 3-1 是一个流程图,突出显示了函数分配。在这种情况下,三个函数应该足够:main()find_anagrams()process_choice()

main() 函数的主要任务是设置字母计数限制并管理负责通用短语字谜构建的 while 循环。find_anagrams() 函数将获取名称中剩余的当前字母集合,并返回可以由这些字母组成的所有可能单词。然后,单词将显示给用户,并且当前的短语由 main() 函数“拥有”并显示。接着,process_choice() 函数提示用户重新开始或选择一个字谜单词。如果用户做出选择,这个函数将判断所选字母是否可用。如果不可用,用户将被提示重新选择或重新开始。如果用户做出有效选择,所选字母将从剩余字母列表中移除,并且返回所选字和剩余字母列表。main() 函数将返回的选择添加到现有的短语中。如果达到限制,完成的短语字谜将显示出来,用户将被询问是否重新开始或退出。

注意,你在 全局 范围内请求初始名称,而不是在 main() 函数中。这让用户可以随时重新开始,而无需重新输入他们的名称。目前,如果用户想选择一个全新的名称,他们必须退出程序并重新开始。在第九章,你将使用一个菜单系统,允许用户完全重置他们的操作而无需退出。

image

图 3-1:带有函数分配高亮的短语字谜流程图

字谜短语代码

本节的代码从用户那里获取一个名称,并帮助他们构建该名称的字谜短语。你可以从 www.nostarch.com/impracticalpython/ 下载整个脚本,保存为 phrase_anagrams.py。你还需要下载 load_dictionary.py 程序。将这两个文件保存在同一文件夹中。你可以使用在 “项目 #4:寻找单词字谜” 中使用的同一个字典文件,位于 第 36 页。

设置和查找字谜

清单 3-2 导入了 phrase_anagrams.py 使用的模块,加载了一个字典文件,询问用户输入一个名称,并定义了 find_anagrams() 函数,该函数完成了大部分与寻找字谜相关的工作。

phrase_anagrams.py, 第一部分

➊ import sys
   from collections import Counter
   import load_dictionary

➋ dict_file = load_dictionary.load('2of4brif.txt')
   # ensure "a" & "I" (both lowercase) are included
   dict_file.append('a')
   dict_file.append('i')
   dict_file = sorted(dict_file)

➌ ini_name = input("Enter a name: ")

➍ def find_anagrams(name, word_list):
       """Read name & dictionary file & display all anagrams IN name."""
    ➎ name_letter_map = Counter(name)
       anagrams = []
    ➏ for word in word_list:
        ➐ test = ''
        ➑ word_letter_map = Counter(word.lower())
        ➒ for letter in word:
               if word_letter_map[letter] <= name_letter_map[letter]:
                   test += letter
           if Counter(test) == word_letter_map:
               anagrams.append(word)
    ➓ print(*anagrams, sep='\n')
       print()
       print("Remaining letters = {}".format(name))
       print("Number of remaining letters = {}".format(len(name)))
       print("Number of remaining (real word) anagrams = {}".format(len(anagrams)))

清单 3-2:导入模块,加载字典,并定义 find_anagrams() 函数

import 语句 ➊ 开始,按照推荐的顺序导入 Python 标准库、第三方模块,然后是本地开发的模块。你需要 sys 来在 IDLE 窗口中将特定的输出显示为红色,并让用户通过按键退出程序。你将使用 Counter 来帮助识别输入名称的字谜。

接下来,使用导入的模块 ➋ 加载字典文件。文件名参数应为你正在使用的字典的文件名。由于一些字典文件省略了 aI,如果需要,可以将它们附加到字典中,并对列表进行排序,以便它们出现在正确的字母顺序位置,而不是出现在列表的末尾。

现在从用户那里获取一个名称,并将其分配给变量 ini_name(或“初始名称”) ➌。你将从这个初始名称派生出 name 变量,并随着用户逐步构建名称字谜,name 变量会不断变化。将初始名称保留为一个单独的变量将允许你在用户希望重新开始或再试一次时重置一切。

下一个代码块是 find_anagrams() ➍,用于在 name 中查找变位词的函数。该函数的参数包括一个 name 和一个单词列表。函数通过使用 Counter 来计算名字中给定字母出现的次数,并将计数分配给变量 name_letter_map ➎;Counter 使用字典结构,字母作为键,计数作为值。然后,函数创建一个空列表来保存变位词,并开始 for 循环遍历字典文件中的每个单词 ➏。

for 循环从创建一个名为 test 的空字符串开始 ➐。使用这个变量累积 word 中“适合” name 的所有字母。然后为当前单词创建一个 Counter,就像你为 name 所做的那样,并称其为 word_``letter``_map ➑。遍历 word 中的字母 ➒,检查每个字母的计数是否与 name 中的计数相同或更少。如果字母满足这个条件,则将其添加到 test 字符串中。由于某些字母可能会被拒绝,所以通过对 test 运行 Counter 并将其与 word_letter_map 进行比较来结束循环。如果它们匹配,则将该单词追加到变位词列表中。

函数以使用 print 和带有分散运算符的单词列表结尾,并为用户提供一些统计信息 ➓。请注意,find_``anagrams() 不返回任何内容。这是人际交互部分的地方。程序将继续运行,但在用户选择列表中的一个单词之前不会发生任何事情。

处理用户的选择

清单 3-3 定义了 process_choice() 函数,位于 phrase_anagrams.py 中,它接收用户选择的单词(或多个单词),检查它们与 name 变量中剩余的字母是否匹配,并将可接受的选择(连同任何剩余的字母)返回给 main() 函数。像 main() 一样,这个函数可以直接与用户交互。

phrase_anagrams.py, 第二部分

➊ def process_choice(name):
       """Check user choice for validity, return choice & leftover letters."""
       while True:
        ➋ choice = input('\nMake a choice else Enter to start over or # to end: ')
           if choice == '':
               main()
           elif choice == '#':
               sys.exit()
           else:
            ➌ candidate = ''.join(choice.lower().split())
        ➍ left_over_list = list(name)
        ➎ for letter in candidate:
               if letter in left_over_list:
                   left_over_list.remove(letter)
        ➏ if len(name) - len(left_over_list) == len(candidate):
               break
           else:
               print("Won't work! Make another choice!", file=sys.stderr)
    ➐ name = ''.join(left_over_list)  # makes display more readable
    ➑ return choice, name

清单 3-3:定义了 process_choice() 函数

从定义只有一个名为 name 的参数的函数开始 ➊。第一次运行程序时,这个参数将与 ini_name 变量相同,即用户启动程序时输入的完整姓名。用户选择用于变位词短语的单词(或多个单词)后,它将表示姓名中剩余的字母。

使用一个 while 循环开始函数,直到用户做出有效选择为止,并从用户那里获取输入 ➋。用户可以选择从当前变位词列表中输入一个或多个单词,按 ENTER 重新开始,或按 # 退出。使用 # 而不是单词或字母,以避免与有效选择混淆。

如果用户做出选择,将字符串赋给变量 candidate,去除空白并转换为全小写 ➌。这样可以直接与 name 变量进行比较。之后,从 name 变量中构建一个列表以保存任何剩余的字母 ➍。

现在开始一个循环,减去 candidate ➎ 中使用的字母。如果选中的字母存在于列表中,它将被移除。

如果用户输入了一个不在显示列表中的单词,或输入了多个单词,可能会导致某些字母不在列表中。为检查这一点,可以从 name 中减去剩余字母,如果结果与 candidate 中字母的数量相等,则认为输入有效,并跳出 while 循环 ➏。否则,显示警告并将其标红,以便在使用 IDLE 窗口的用户看到。while 循环将继续提示用户,直到做出有效选择。

如果用户选择的所有字母都通过了测试,剩余字母的列表将被转换回字符串,并用于更新 name 变量 ➐。将列表转换为字符串并非绝对必要,但这样可以保持 name 变量类型一致,并且使你能够清晰地显示剩余字母,无需额外的 print 参数。

最后,将用户的选择和剩余字母的字符串(name)一起返回给 main() 函数 ➑。

定义 main() 函数

列出 3-4 定义了 phrase_anagrams.py 中的 main() 函数。这个函数封装了之前的函数,运行一个 while 循环,并决定用户何时成功创建字谜短语。

phrase_anagrams.py, 第三部分

   def main():
       """Help user build anagram phrase from their name."""
    ➊ name = ''.join(ini_name.lower().split())
       name = name.replace('-', '')
    ➋ limit = len(name)
       phrase = ''
       running = True

    ➌ while running:
        ➍ temp_phrase = phrase.replace(' ', '')
        ➎ if len(temp_phrase) < limit:
               print("Length of anagram phrase = {}".format(len(temp_phrase)))

            ➏ find_anagrams(name, dict_file)
               print("Current anagram phrase =", end=" ")
               print(phrase, file=sys.stderr)

            ➐ choice, name = process_choice(name)
               phrase += choice + ' '

        ➑ elif len(temp_phrase) == limit:
               print("\n*****FINISHED!!!*****\n")
               print("Anagram of name =", end=" ")
               print(phrase, file=sys.stderr)
               print()
            ➒ try_again = input('\n\nTry again? (Press Enter else "n" to quit)\n ')
               if try_again.lower() == "n":
                   running = False
                   sys.exit()
               else:
                   main()

➓ if __name__ == '__main__':
       main()

列出 3-4:定义并调用 main() 函数

首要任务是将 ini_name 变量转化为一个连续的小写字母字符串,并且没有空格 ➊。记住,大小写对 Python 很重要,因此需要在每次出现字符串时都转换为小写;这样,比较才能按预期工作。Python 还将空格视为字符,所以在进行字母计数之前,需要移除空格和连字符。通过声明这个新的 name 变量,你可以保留初始名称,以防用户想要重新开始。只有 name 会在 process_choice() 函数中被修改。

接下来,获取名称 ➋ 的长度,用作 while 循环的限制。这将帮助你确定当字谜短语使用完名称中的所有字母时,应该结束循环。在 while 循环外执行此操作,以确保你使用的是完整的初始名称。然后,分配一个变量来保存字谜短语,并将 running 变量设置为 True,以控制 while 循环。

现在开始一个大的循环,让你遍历名称并构建字谜短语 ➌。首先,准备一个字符串来保存增长中的短语,并去除空格 ➍。空格会被计算为字母,并在与 limit 变量比较短语长度时引起偏差。接下来,进行比较,如果短语的长度小于限制,显示当前短语的长度,作为与用户互动的序曲 ➎。

现在是时候让其他函数发挥作用了。调用find_anagrams() ➏并传入姓名和字典文件,以获取姓名中的字谜列表。在显示的列表底部,向用户展示当前的短语。使用print()函数的end参数,将两个print语句显示在同一行。这样,你可以在 IDLE 窗口中使用红色字体显示短语,将其与显示中的其他信息区分开来。

接下来,调用process_choice()函数 ➐获取用户的单词选择,并将其添加到逐步完成的字谜短语中。这还会获取更新后的name变量,以便程序在短语未完成时可以在while循环中再次使用它。

如果短语的长度等于limit变量 ➑,那么姓名的字谜就完成了。让用户知道他们完成了,并用红色字体显示该短语。请注意,你没有为短语长度大于limit变量的情况设置条件。这是因为process_choice()函数已经处理了这种情况(选择的字母多于可用字母时会未通过验证标准)。

main()函数通过询问用户是否想要再试一次来结束。如果用户输入n,程序结束;如果按下 ENTER 键,main()函数将再次被调用 ➒。如前所述,用户更改初始姓名的唯一方法是退出并重新启动程序。

main()函数外,结束时使用标准的两行代码来调用main()函数,当程序未作为模块导入时 ➓。

运行示例会话

在这一部分,我包含了一个示例互动会话,使用phrase_anagrams.py和姓名Bill Bo。粗体字体表示用户输入,斜体粗体字体表示在显示中使用红色字体的地方。

Enter a name: Bill Bo

Length of anagram phrase = 0

bib

bill

blob

bob

boil

boll

i

ill

lib

lilo

lo

lob

oi

oil

Remaining letters = billbo

Number of remaining letters = 6

Number of remaining (real word) anagrams = 14

Current anagram phrase =

Make a choice else Enter to start over or # to end: ill

Length of anagram phrase = 3

bob

Remaining letters = bbo

Number of remaining letters = 3

Number of remaining (real word) anagrams = 1

Current anagram phrase = ill

Make a choice else Enter to start over or # to end: Bob

***** FINISHED!!! *****

Anagram of name = ill Bob

Try again? (Press Enter else "n" to quit)

找到的字谜数量取决于你使用的字典文件。如果你在构建字谜短语时遇到困难,可以尝试使用更大的字典。

项目 #6:寻找伏地魔:高卢策略

你有没有想过汤姆·里德尔是怎么想出字谜“I am Lord Voldemort”的?他是不是拿起羽毛笔写在羊皮纸上,还是只是挥舞了一下魔杖?Python 的魔力是否也有所帮助?

假设你是霍格沃茨的计算机魔法教授,汤姆·里德尔,学校的学长和模范学生,来找你寻求帮助。使用你在前一部分中提到的phrase_anagrams.py法术,他能够在第一个字谜列表中找到I am Lord,令他非常高兴。但剩下的字母tmvoordle只生成了像doltdroollooterlover这样的琐碎单词。里德尔一定不太高兴。

回头看,问题很明显:伏地魔是法语词,无法在任何英语词典中找到。Vol de la mort在法语中意味着“死亡之翼”,因此伏地魔可以 loosely 理解为“死亡飞行”。但 Riddle 是百分之百的英语名字,而到目前为止,你一直在使用英语。除非进行逆向工程,否则你没有理由突然将英语词典换成法语词典,就像你没有理由去用荷兰语、德语、意大利语或西班牙语一样。

可以尝试随机打乱剩余的字母,看看能得到什么。遗憾的是,可能的组合数是字母的阶乘除以重复字母的阶乘(o出现两次):9! / 2! = 181,440。如果你要浏览所有这些排列,每个排列只花一秒钟时间,那么完成整个列表将需要超过两天!如果你让 Tom Riddle 来做这件事,他可能会用你来做魂器!

到此为止,我想探讨两条逻辑路径。其一我称之为“高卢战略”,另一条是“英国蛮力法”。我们将在这里讨论第一条,第二条将在下一节讨论。

注意

Marvolo 显然是一个人为创造的词,用来使伏地魔的字谜成立。J.K. Rowling 本可以通过使用Thomas代替Tom,或者省略LordI am部分,获得更多的自由。在书籍翻译成非英语语言时,常常使用这样的技巧。在某些语言中,一个或两个名字可能需要更改。在法语中,字谜是“I am Voldemort”。在挪威语中是“Voldemort the Great”。在荷兰语中是“My name is Voldemort”。在其他语言中,如中文,根本无法使用字谜!

Tom Riddle 痴迷于战胜死亡,如果你在tmvoordle中寻找死亡,你会发现既有古老的法语词morte(就像在托马斯·马洛里的著名书籍《亚瑟王之死》中的用法),也有现代法语词mort。去除mort后,剩下的就是vodle,五个字母,排列组合的数量非常可控。事实上,你可以轻松地在解释器窗口中找到volde

➊ >>> from itertools import permutations
   >>> name = 'vodle'
➋ >>> perms = [''.join(i) for i in permutations(name)]
➌ >>> print(len(perms))
   120
➍ >>> print(perms)
   ['vodle', 'vodel', 'volde', 'voled', 'voedl', 'voeld', 'vdole', 'vdoel',
   'vdloe', 'vdleo', 'vdeol', 'vdelo', 'vlode', 'vloed', 'vldoe', 'vldeo',
   'vleod', 'vledo', 'veodl', 'veold', 'vedol', 'vedlo', 'velod', 'veldo',
   'ovdle', 'ovdel', 'ovlde', 'ovled', 'ovedl', 'oveld', 'odvle', 'odvel',
   'odlve', 'odlev', 'odevl', 'odelv', 'olvde', 'olved', 'oldve', 'oldev',
   'olevd', 'oledv', 'oevdl', 'oevld', 'oedvl', 'oedlv', 'oelvd', 'oeldv',
   'dvole', 'dvoel', 'dvloe', 'dvleo', 'dveol', 'dvelo', 'dovle', 'dovel',
   'dolve', 'dolev', 'doevl', 'doelv', 'dlvoe', 'dlveo', 'dlove', 'dloev',
   'dlevo', 'dleov', 'devol', 'devlo', 'deovl', 'deolv', 'delvo', 'delov',
   'lvode', 'lvoed', 'lvdoe', 'lvdeo', 'lveod', 'lvedo', 'lovde', 'loved',
   'lodve', 'lodev', 'loevd', 'loedv', 'ldvoe', 'ldveo', 'ldove', 'ldoev',
   'ldevo', 'ldeov', 'levod', 'levdo', 'leovd', 'leodv', 'ledvo', 'ledov',
   'evodl', 'evold', 'evdol', 'evdlo', 'evlod', 'evldo', 'eovdl', 'eovld',
   'eodvl', 'eodlv', 'eolvd', 'eoldv', 'edvol', 'edvlo', 'edovl', 'edolv',
   'edlvo', 'edlov', 'elvod', 'elvdo', 'elovd', 'elodv', 'eldvo', 'eldov']
   >>>
➎ >>> print(*perms, sep='\n')
   vodle
   vodel
   volde
   voled
   voedl
   --snip--

itertools ➊模块导入permutations开始。itertools模块是 Python 标准库中的一组函数,用于创建高效的迭代器。你通常会想到数字的排列,但itertools的版本是针对可迭代对象中的元素,其中包括字母。

输入名字或在此情况下,输入名字中的剩余字母后,使用列表推导创建名字的排列列表 ➋。将排列中的每个元素连接在一起,使得最终列表中的每一项都将是vodle的一个独特排列。使用join生成的新名字作为一个元素是,'vodle',而不是一个难以阅读的单字符元组,('v', 'o', 'd', 'l', 'e')

获取排列的长度作为一个检查点;这样你可以确认它确实是 5 的阶乘 ➌。最后,无论你以何种方式打印它 ➍➎,volde都很容易找到。

项目 #7:寻找伏地魔:英国暴力破解法

现在假设 Tom Riddle 不擅长字谜(或者法语)。他无法识别 mortmorte,于是你又回到了将剩余的九个字母反复排列数千次,寻找一个他认为令人愉悦的字母组合。

从积极的一面来看,这是一个比你刚才看到的交互式解决方案更有趣的问题。你只需要通过某种形式的筛选,缩小所有排列的范围。

目标

tmvoordle 的字谜数量减少到一个可以管理的数字,这个数字仍然包含 Voldemort

策略

根据《牛津英语词典,第 2 版》,目前使用中的英语单词有 171,476 个,这比 tmvoordle 中的所有排列还要少!不论是哪种语言,你可以推测,通过 permutations() 函数生成的大多数字谜都是无意义的。

使用 密码学,即密码和加密的科学,你可以安全地排除许多无用的、无法发音的组合,例如 ldtmvroeo,而无需目视检查它们。密码学家长期研究语言,并编制了关于单词和字母重复模式的统计数据。我们可以使用许多密码分析技术来完成这个项目,但让我们集中于三种技术:辅音-元音映射、三元组频率和二元组频率。

使用辅音-元音映射进行筛选

一个 辅音-元音映射c-v 映射)简单地将单词中的字母替换为 cv,根据需要。例如,Riddle 变成 cvcccv。你可以编写一个程序,遍历字典文件并为每个单词创建 c-v 映射。默认情况下,不可能的组合,如 ccccccvvv,将被排除。你还可以通过移除那些 c-v 映射是 可能的,但出现频率很低的单词,进一步排除成员。

C-v 映射相当全面,但这是好事。目前,Riddle 的一个选择是创造一个新的专有名词,专有名词不一定需要出现在字典中。所以,刚开始的时候你不想过于排外

使用三元组进行筛选

由于初始筛选器需要较宽的视野,你将需要在较低的层级再次进行筛选,以安全地从排列中移除更多的字谜。三元组是由三个连续字母组成的三连词。毫不奇怪,英语中最常见的三元组是单词 the,紧随其后的是 anding。在另一端,像 zvq 这样的三元组则极为罕见。

你可以在网站如 norvig.com/ngrams/count_3l.txt 上找到三字母组合的出现频率统计数据。对于任何字母组合,比如 tmvoordle,你可以生成并使用一个最不常见的三字母组合列表,以进一步减少排列组合的数量。对于这个项目,你可以使用 least-likely_trigrams.txt 文件,该文件可以从 www.nostarch.com/impracticalpython/ 下载。这个文本文件包含了 tmvoordle 中出现频率处于英语三字母组合最低 10%的三字母组合。

使用二字母组合进行过滤

二字母组合(也称为 双字母组合)是指字母对。英语中常见的二字母组合包括 anster。另一方面,像 kgvloq 这样的组合很少见。你可以在以下网站上找到有关二字母组合出现频率的统计数据:* www.math.cornell.edu/~mec/2003-2004/cryptography/subs/digraphs.html * 和 * practicalcryptography.com/ *。

表格 3-1 是基于 tmvoordle 字母集合和一个包含 60,000 个单词的英语词典文件构建的。图表左侧的字母是二字母组合的起始字母,顶部的字母代表结束字母。例如,要查找 vo,从左侧的 v 开始,横向读取到 o 下方的列。在 tmvoordle 中,vo 只出现了 0.8% 的时间。

表格 3-1: 来自 60,000 词英语词典的 tmvoordle 字母组合的相对频率(黑色方格表示未出现)

d e l m o r t v
d 3.5% 0.5% 0.1% 1.7% 0.5% 0.0% 0.1%
e 6.6% 2.3% 1.4% 0.7% 8.9% 2.0% 0.6%
l 0.4% 4.4% 0.1% 4.2% 0.0% 0.4% 0.1%
m 0.0% 2.2% 0.0% 2.8% 0.0% 0.0% 0.0%
o 1.5% 0.5% 3.7% 3.2% 5.3% 7.1% 2.4% 1.4%
r 0.9% 6.0% 0.4% 0.7% 5.7% 1.3% 0.3%
t 0.0% 6.2% 0.6% 0.1% 3.6% 2.3% 0.0%
v 0.0% 2.5% 0.0% 0.0% 0.8% 0.0% 0.0%

假设你正在寻找“类英语”的字母组合,可以使用这样的频率图来排除不太可能出现的字母对。可以将其看作一个“二字母筛网”,只有未着色的方格才能通过。

为了安全起见,只需排除出现频率低于 0.1% 的二字母组合。我已将这些部分用黑色标记。请注意,如果裁剪过于严格,很容易会遗漏 Voldemort 中所需的 vo 组合!

你可以通过标记不太可能出现在单词开头的二元组来设计更具选择性的过滤器。例如,虽然二元组 lm 在单词 内部 中出现并不罕见(如 almanacbalmy),但你需要非常幸运才能找到一个以 lm 开头的单词。你不需要密码学来查找这些二元组;只需尝试发音它们!这些以灰色阴影显示的起始选择在 表 3-2 中。

表 3-2: 表 3-1 的更新,其中灰色阴影方块表示不太可能出现在单词开头的二元组

d e l m o r t v
d 3.5% 0.5% 0.1% 1.7% 0.5% 0.0% 0.1%
e 6.6% 2.3% 1.4% 0.7% 8.9% 2.0% 0.6%
l 0.4% 4.4% 0.1% 4.2% 0.0% 0.4% 0.1%
m 0.0% 2.2% 0.0% 2.8% 0.0% 0.0% 0.0%
o 1.5% 0.5% 3.7% 3.2% 5.3% 7.1% 2.4% 1.4%
r 0.9% 6.0% 0.4% 0.7% 5.7% 1.3% 0.3%
t 0.0% 6.2% 0.6% 0.1% 3.6% 2.3% 0.0%
v 0.0% 2.5% 0.0% 0.0% 0.8% 0.0% 0.0%

现在,你可以使用三个过滤器来处理 tmvoordle 的 181,440 个排列:c-v 映射、三元组和二元组。作为最后的过滤器,你应该给用户选择只查看以给定字母开头的字谜的选项。这将使用户能够将剩余的字谜划分为更易管理的“块”,或者专注于那些听起来更具挑战性的字谜,比如那些以 v 开头的字谜!

英国暴力破解代码

接下来的代码生成 tmvoordle 的所有排列,并通过刚才描述的过滤器。然后,它给用户一个选择,查看所有排列或仅查看以给定字母开头的排列。

你可以从 www.nostarch.com/impracticalpython/ 下载你需要的所有程序。本节中的代码是一个名为 voldemort_british.py 的脚本。你还需要同一文件夹中的 load_dictionary.py 程序,以及本章早些时候用于项目的相同字典文件。最后,你还需要一个名为 least-likely_trigrams.txt 的新文件,它是一个包含在英语中出现频率较低的三元组的文本文件。将所有这些文件下载到同一文件夹中。

定义 main() 函数

清单 3-5 导入了 voldemort_british.py 所需的模块,并定义了其 main() 函数。在 phrase_anagrams.py 程序中,你在代码的末尾定义了 main() 函数。这里我们将它放在开始处。其优点是你可以从一开始就看到函数的作用——它如何运行程序。缺点是,你还不知道任何辅助函数的作用。

voldemort_british.py, 第一部分

➊ import sys
   from itertools import permutations
   from collections import Counter
   import load_dictionary

➋ def main():
       """Load files, run filters, allow user to view anagrams by 1st letter."""
    ➌ name = 'tmvoordle'
       name = name.lower()

    ➍ word_list_ini = load_dictionary.load('2of4brif.txt')
       trigrams_filtered = load_dictionary.load('least-likely_trigrams.txt')

    ➎ word_list = prep_words(name, word_list_ini)
       filtered_cv_map = cv_map_words(word_list)
       filter_1 = cv_map_filter(name, filtered_cv_map)
       filter_2 = trigram_filter(filter_1, trigrams_filtered)
       filter_3 = letter_pair_filter(filter_2)
       view_by_letter(name, filter_3)

列表 3-5:导入模块并定义 main() 函数

首先导入你在前面项目中使用过的模块 ➊。然后定义 main() 函数 ➋。name 变量是剩余字母的字符串 tmvoordle ➌。将其转换为小写字母,以防止用户输入错误。接下来,使用 load_dictionary 模块加载字典文件和三元组文件作为列表 ➍。你的字典文件名可能与所示的不同。

最后,按顺序调用所有各个函数 ➎。我稍后将描述这些函数的具体功能,但基本上,你需要准备词汇表,准备 c-v 图,应用三个筛选器,并让用户查看所有的字谜,或者根据字谜的首字母查看一个子集。

准备词汇表

列表 3-6 通过仅包括与 name 变量中字母数量相同的单词(在这个例子中是九个字母)来准备词汇表。你还应确保所有单词都转换为小写字母,以保持一致性。

voldemort_british.py, 第二部分

➊ def prep_words(name, word_list_ini):
       """Prep word list for finding anagrams."""
    ➋ print("length initial word_list = {}".format(len(word_list_ini)))
       len_name = len(name)
    ➌ word_list = [word.lower() for word in word_list_ini
                    if len(word) == len_name]
    ➍ print("length of new word_list = {}".format(len(word_list)))
    ➎ return word_list

列表 3-6:创建与 name 变量长度相等的单词列表

定义 prep_words() 函数,接受一个名字字符串和字典单词列表作为参数 ➊。我建议你在单词列表经过筛选前后打印它们的长度;这样,你可以跟踪筛选器的影响。先打印字典的长度 ➋。分配一个变量来保存名字的长度,然后使用列表推导式通过遍历 word_list_ini 中的单词,筛选出那些长度与 name 中字母数量相同的单词,并将它们转换为小写字母 ➌。接下来,打印这个新单词列表的长度 ➍,最后返回这个新列表供下一个函数使用 ➎。

生成 C-V 图

你需要将准备好的词汇表转换为 c-v 图。记住,你不再关心字典中的实际单词;这些已经被审查并被拒绝。你的目标是将剩余字母打乱,直到它们形成类似专有名词的东西。

列表 3-7 定义了一个函数,用于为 word_list 中的每个单词生成 c-v 图。程序 voldemort_british.py 将使用 c-v 图来判断一个混合字母组合是否符合英语中的辅音-元音模式。

voldemort_british.py, 第三部分

➊ def cv_map_words(word_list):
       """Map letters in words to consonants & vowels."""
    ➋ vowels = 'aeiouy'
    ➌ cv_mapped_words = []
    ➍ for word in word_list:
           temp = ''
           for letter in word:
               if letter in vowels:
                   temp += 'v'
               else:
                   temp += 'c'
           cv_mapped_words.append(temp)

       # determine number of UNIQUE c-v patterns
    ➎ total = len(set(cv_mapped_words))
       # target fraction to eliminate
    ➏ target = 0.05
       # get number of items in target fraction
    ➐ n = int(total * target)
    ➑ count_pruned = Counter(cv_mapped_words).most_common(total - n)
    ➒ filtered_cv_map = set()
       for pattern, count in count_pruned:
           filtered_cv_map.add(pattern)
       print("length filtered_cv_map = {}".format(len(filtered_cv_map)))
    ➓ return filtered_cv_map

列表 3-7:从 word_list 中的单词生成 c-v 图

定义 cv_map_words() 函数,接受准备好的词汇表作为参数 ➊。由于辅音和元音构成一个二进制系统,你可以使用一个字符串来定义元音 ➋。创建一个空列表来保存这些图 ➌。然后遍历单词和每个单词中的字母,将字母转换为 cv ➍。使用一个名为 temp 的变量来积累这个图,然后将其附加到列表中。请注意,temp 每次循环时都会重新初始化。

你希望了解给定 c-v 映射模式(例如 cvcv)的出现频率,以便可以去除那些出现可能性较低的项。在计算频率之前,你需要将列表简化为唯一的 c-v 映射——目前,cvcv 可能会被多次重复。因此,将 cv_mapped_words 列表转换为集合,以去除重复项,并获取其长度 ➎。现在,你可以定义一个目标百分比来进行去除,使用分数值 ➏。从像 0.05 这样的低值开始——相当于 5%——这样你就不太可能去除那些可以形成有效专有名词的字谜。将这个目标值乘以 cv_mapped_words 集合的总长度,并将结果赋值给变量 n ➐。一定要将 n 转换为整数;因为它代表的是一个计数值,不能是浮动数。

Counter 模块的数据类型有一个非常实用的方法 most_common(),它会根据你提供的 count 值返回列表中最常见的项;在此例中,该值为 c-v 映射列表的长度 total 减去 n。你传给 most_common() 的值必须是整数。如果你传递 most_common() 函数的是列表的长度,它将返回列表中的所有项。如果你减去最不可能出现的 5% 的计数,你将有效地从列表中去除这些 c-v 映射 ➑。

记住,Counter 返回的是字典,但你只需要最终的 c-v 映射,而不是它们相关的频率计数。因此,初始化一个名为 filtered-cv-map 的空集合 ➒,并遍历 count_pruned() 中的每个键值对,只将键添加到新集合中。打印该集合的长度,这样你可以看到过滤器的影响。然后,完成并返回过滤后的 c-v 映射,以便在下一个函数中使用 ➓。

定义 C-V 映射过滤器

清单 3-8 应用了 c-v 映射过滤器:基于 name 变量中字母的排列生成字谜,然后程序将其转换为 c-v 映射,并将这些字谜与通过 cv_map_words() 函数构建的过滤后的 c-v 映射进行比较。如果一个字谜的 c-v 映射出现在 filtered_cv_map 中,则程序会将该字谜存储,以供下一个过滤器使用。

voldemort_british.py,第四部分

➊ def cv_map_filter(name, filtered_cv_map):
       """Remove permutations of words based on unlikely cons-vowel combos."""
    ➋ perms = {''.join(i) for i in permutations(name)}
       print("length of initial permutations set = {}".format(len(perms)))
       vowels = 'aeiouy'
    ➌ filter_1 = set()
    ➍ for candidate in perms:
           temp = ''
           for letter in candidate:
               if letter in vowels:
                   temp += 'v'
               else:
                   temp += 'c'
        ➎ if temp in filtered_cv_map:
               filter_1.add(candidate)
       print("# choices after filter_1 = {}".format(len(filter_1)))
    ➏ return filter_1

清单 3-8:定义 cv_map_filter() 函数

定义 cv_map_filter() 函数,接受两个参数:名字,后面跟着 cv_map_words() 返回的 c-v 映射集合 ➊。使用集合推导和 permutations 模块生成排列的集合 ➋。我在“项目 #6:寻找伏地魔:高卢赌局”中,具体描述了这个过程,详见 第 49 页。在这里使用集合,允许后续进行集合操作,例如计算两个过滤器集合的差异。这也能去除重复项,因为 permutations 将每个 o 视为单独的项,并返回 9!,而不是 9! / 2!。请注意,permutations 会将 tmvoordletmvoordle 视为不同的字符串。

现在初始化一个空集合来保存第一个过滤器的内容 ➌,并开始循环检查排列 ➍。使用术语 candidate,因为这些大多数不是单词,而只是随机字母的字符串。对于每个候选项,循环遍历字母并将它们映射为 cv,就像你在 cv_words() 函数中做的那样。检查每个 c-v 映射 temp 是否存在于 filtered_cv_map 中。这是使用集合的一个原因:成员检查非常快速。如果候选项符合条件,则将其添加到 filter_1 ➎。最后返回你的新字谜集合 ➏。

定义三元组过滤器

列表 3-9 定义了三元组过滤器,它移除包含不太可能的三字母组合的排列。它使用一个来源于多个加密学网站的文本文件,这些内容已经根据 tmvoordle 中的字母进行了调整。此函数将只返回包含这些三元组之一的排列;main() 函数将把新的集合传递给下一个过滤函数。

voldemort_british.py, 第五部分

➊ def trigram_filter(filter_1, trigrams_filtered):
       """Remove unlikely trigrams from permutations."""
    ➋ filtered = set()
    ➌ for candidate in filter_1:
        ➍ for triplet in trigrams_filtered:
               triplet = triplet.lower()
               if triplet in candidate:
                   filtered.add(candidate)
    ➎ filter_2 = filter_1 - filtered
       print("# of choices after filter_2 = {}".format(len(filter_2)))
    ➏ return filter_2

列表 3-9:定义了 trigram_filter() 函数

三元组过滤器的参数包括来自 c-v 映射过滤器的输出以及外部的非可能三元组列表 trigrams_filtered ➊。

初始化一个空集合来保存包含禁止的三元组的排列 ➋。然后启动另一个 for 循环,检查通过上一个过滤器的候选项 ➌。一个嵌套的 for 循环查看三元组列表中的每一个三元组 ➍。如果该三元组出现在候选项中,它将被添加到过滤器中。

现在,你可以使用集合操作从 filter_1 ➎ 中减去新的过滤器,然后返回差异,用于下一个过滤器 ➏。

定义二元组过滤器

列表 3-10 定义了二元组过滤器,它移除不太可能出现的字母对。如果它们在排列中的任何位置出现,将触发过滤器;如果它们出现在排列的开头,则仅在这种情况下触发。禁止的二元组基于 表 3-2 中的阴影单元格。该函数返回此过滤器的结果,用于最终的过滤函数。

voldemort_british.py, 第六部分

➊ def letter_pair_filter(filter_2):
       """Remove unlikely letter-pairs from permutations."""
    ➋ filtered = set()
    ➌ rejects = ['dt', 'lr', 'md', 'ml', 'mr', 'mt', 'mv',
                  'td', 'tv', 'vd', 'vl', 'vm', 'vr', 'vt']
    ➍ first_pair_rejects = ['ld', 'lm', 'lt', 'lv', 'rd',
                             'rl', 'rm', 'rt', 'rv', 'tl', 'tm']
    ➎ for candidate in filter_2:
        ➏ for r in rejects:
               if r in candidate:
                   filtered.add(candidate)
        ➐ for fp in first_pair_rejects:
               if candidate.startswith(fp):
                   filtered.add(candidate)
    ➑ filter_3 = filter_2 - filtered
       print("# of choices after filter_3 = {}".format(len(filter_3)))
    ➒ if 'voldemort' in filter_3:
           print("Voldemort found!", file=sys.stderr)
    ➓ return filter_3

列表 3-10:定义了 letter_pair_filter() 函数

该过滤器接受前一个过滤器的结果作为参数 ➊。初始化一个空集合来保存任何被丢弃的排列 ➋。然后将两个被拒绝的对列表分别赋值给变量 rejects ➌ 和 first_pair_rejects ➍。这两个列表是手动输入的。第一个表示 表 3-2 中阴影为黑色的单元格;第二个参考阴影为灰色的单元格。任何包含第一个列表中的成员的排列——无论在哪里——都会被丢弃;以第二个列表中的成员开头的排列将不被允许。你可以向这些列表中添加或删除二元组来更改过滤器的行为。

开始循环排列——继续称这些为“候选项”,因为它们不一定是单词 ➎。一个嵌套的for循环遍历rejects中的所有对,判断是否有任何一对在candidate中,并将其添加到filtered集合中 ➏。第二个嵌套的for循环对first_pair_rejects执行相同的过程 ➐。将filtered从上一个函数filter_2返回的集合中减去 ➑。

为了增加趣味性确保没有过度过滤,可以检查filter_3 ➒中是否包含voldemort,并用引人注目的红色字体打印一条公告,以提醒发现此项,供 IDLE 用户使用。最后,返回最终的过滤结果集 ➓。

让用户选择起始字母

你无法预先知道过滤是否成功。你可能依然会得到成千上万的排列。提供只查看部分输出的选项并不会减少总体数量,但它会使得心理上更容易接受。 示例 3-11 向voldemort_british.py中添加了查看以特定输入字母开头的字谜列表的功能。

voldemort_british.py, 第七部分

➊ def view_by_letter(name, filter_3):
       """Filter to anagrams starting with input letter."""
    ➋ print("Remaining letters = {}".format(name))
    ➌ first = input("select a starting letter or press Enter to see all: ")
    ➍ subset = []
    ➎ for candidate in filter_3:
           if candidate.startswith(first):
               subset.append(candidate)
    ➏ print(*sorted(subset), sep='\n')
       print("Number of choices starting with {} = {}".format(first, len(subset)))
    ➐ try_again = input("Try again? (Press Enter else any other key to Exit):")
       if try_again.lower() == '':
        ➑ view_by_letter(name, filter_3)
       else:
        ➒ sys.exit()

示例 3-11:定义了 view_by_letter() 函数

定义view_by_letter()函数,将name变量和filter_3作为参数 ➊。需要name,以便展示用户可以过滤的字母选项 ➋。获取用户输入,询问他们是否想查看所有剩余的排列,还是只查看以某个特定字母开头的排列 ➌。然后开始一个空列表,用于保存这个子集 ➍。

一个for循环,配合条件语句,检查候选项是否以选定字母开头,并将通过检查的字母添加到subset ➎。然后通过解包操作符打印这个列表 ➏。接着程序会询问用户是否希望重新尝试或退出 ➐。如果用户按下 ENTER,view_by_letter()会被递归调用,并从头开始重新运行 ➑。否则,程序退出 ➒。请注意,Python 默认的递归深度限制是 1,000,但在这个项目中我们会忽略它。

运行 main()函数

在全局空间中,示例 3-12 通过调用main()函数完成代码,如果用户以独立模式运行程序而不是将其导入到另一个程序中。

voldemort_british.py, 第八部分

if __name__ == '__main__':
    main()

示例 3-12:调用 main() 函数

完成程序的示例输出如下所示。在程序应用第三个过滤器后,剩下 248 个排列,其中有 73 个以v开头,非常容易处理。我省略了排列的打印输出以简化说明。如输出所示,voldemort成功通过了过滤。

length initial word_list = 60388
length of new word_list = 8687
length filtered_cv_map = 234
length of initial permutations set = 181440
# choices after filter_1 = 123120
# of choices after filter_2 = 674
# of choices after filter_3 = 248
Voldemort found!
Remaining letters = tmvoordle
select a starting letter or Enter to see all: v

有趣的是,另一个存活下来的排列是lovedmort。鉴于伏地魔杀害(或指使杀害)了那么多人,这可能是最合适的名字。

总结

在这一章中,你首先编写了代码,找到给定单词或名字的字谜。然后你扩展了这个功能,找到了短语名称字谜,并与用户互动。最后,你采用了密码分析技术,从近 200,000 个可能的字谜中找出伏地魔。在此过程中,你应用了 collectionsitertools 模块中的有用功能。

进一步阅读

Jumble 网站是 www.jumble.com/

你可以在以下网站找到一些具有代表性的在线字谜生成器:

更多字谜程序可以在 Allen Downey 编写的《Think Python, 2nd Edition》(O'Reilly,2015)中找到。

Al Sweigart 编写的《Cracking Codes with Python》(No Starch Press,2017)提供了更多计算单词模式的代码,例如在 voldemort_british.py 程序中过滤时使用的那些模式。

实践项目:寻找双字谜

可以浏览加密学网站查找频率统计数据,或者你也可以自己推导这些数据。编写一个 Python 程序,找到 tmvoordle 中的所有双字谜,并计算它们在字典文件中的出现频率。确保在像 volvo 这样的单词上测试你的代码,以避免忽略同一单词中重复的双字谜。你可以在附录中找到解决方案,或者从 www.nostarch.com/impracticalpython/ 下载 count_digrams_practice.py

挑战项目:自动字谜生成器

查看我在“进一步阅读”中刚才提到的在线字谜生成器,并编写一个 Python 程序来模仿其中一个。你的程序应该根据输入的名字自动生成短语字谜,并显示一部分(例如前 500 个)供用户查看。

第四章:解码美国内战密码**

image

加密学是通过使用密码密文进行安全通信的科学。密码是用其他词替代整个词语;而密文则是将单词中的字母进行打乱或替换(所以从技术上讲,摩尔斯电码实际上就是摩尔斯密文)。加密学的目标之一是使用密钥将可读的明文加密为不可读的密文,然后再将其解密回明文。密码分析的目标是在不知道密钥或加密算法的情况下解码密码和密码本。

在本章中,我们将研究美国内战中使用的两种密码:北方使用的路线密码和双方都使用的铁轨密码。我们还将探讨是什么使其中一种密码如此成功,以及我们如何从其应用中汲取经验教训,以便为没有经验的用户和不熟悉你代码的 Python 用户编写更好的程序。

项目#8:路线密码

在美国内战中,联邦在加密学领域几乎在所有方面都优于南方联邦。联邦有更好的密码、更好的密文和更训练有素的人员。但也许它最大的优势在于领导力和组织结构。

美国军事电报部的负责人是安森·斯塔格(图 4-1)。作为西联电报的共同创始人,斯塔格从经验中知道,当电报操作员发送完整单词时,他们犯错误的概率比发送随机字母和数字的密文低。他还知道,军事电报只需要保密足够长的时间,以便完成命令。他的安全解决方案是一种混合加密系统,称为路线置换密码,它结合了经过置换的真实单词和代码词,成为了历史上最成功的军事密码之一。

image

图 4-1:美国电报队的安森·斯塔格将军,1865 年

置换密码打乱字母或单词的排列方式,不同于替代密码,后者是将明文中的字母用不同的字符或符号替代。图 4-2 展示了一个路线置换密码的例子。信息从左到右写在预定的若干列和行上,重要的明文词汇被代码词替代,最后一行填充了虚拟的占位符词。读者通过上下遍历这些列来确定重新排列的词语顺序,如图所示。起始词是REST,然后加密路线通过箭头显示出来。

image

图 4-2:使用实际联邦代码词的路线密码

要完全解码此消息,你需要知道起点和用于遍历消息并创建最终密文的路线以及代码词的含义。

在 20 世纪初,杰出的军事密码分析师威廉·弗里德曼(William Friedman)批评 Stager 的路由密码。他认为它过于简单,并且觉得南方联盟军队从未破解它的可能性非常小。但事实上,战争期间发送的数十万条路由密码显然从未被破解,而不是因为没有努力过。在一次早期的众包例子中,南方联盟军队将加密消息发布在报纸上,希望能获得解密的帮助,但没有成功。虽然一些历史学家推测这种密码曾在某些时候被破解,但 Stager 的设计教给我们几个重要的教训:

为人为错误设计。 军事密码必须简单,因为每天可能会发送数百条。路由密码中使用的真实单词使其不太可能被电报操作员弄乱。Stager 了解他的客户,并为他们设计。他认识到自己劳动力的局限性,并相应地调整了他的产品。相比之下,南方联盟军队在破译自己复杂的消息时遇到了巨大困难,有时甚至放弃,绕过敌军阵地去面对面交流!

创新胜过发明。 有时候,你不需要发明新东西;你只需要重新发现旧事物。适用于电报传输的简短字词置换密码本身太弱,无法单独使用,但结合了代号和破坏性虚假词汇后,它们使得南方联盟军队陷入困境。

分享学习。 因为电报队的每个人都使用相同的方法,所以很容易在现有技术的基础上进行改进并分享经验教训。这使得路由密码随着时间的推移而不断发展,加入了俚语和故意拼写错误,同时增加了大量的地名、人物和日期的代号词。

Stager 的实用密码可能并不讨后来的“纯粹主义者”喜欢,但它在当时是完美的设计。它背后的概念是永恒的,可以轻松转移到现代应用中。

目标

在哈里·特特尔多夫(Harry Turtledove)获奖的 1992 年小说《南方的枪火》中,时间旅行者为南方联盟军队提供现代武器,改变了历史的进程。假设你没有携带 AK-47 步枪,而是带着你的笔记本电脑、几块额外的电池和 Python,回到 1864 年,设计一个算法,解密基于假设的加密矩阵和路径的路由密码。在 Stager 的精神指导下,你将编写一个用户友好的程序,减少人为错误。

策略

当涉及到解决密码时,如果你知道你在处理哪种类型的密码,事情会变得容易很多。在这种情况下,你知道它是一个置换密码,因为它由被打乱的真实单词组成。你还知道存在代号词和虚假词。你的任务是找出解密路由密码中置换部分的方法,然后让别人去担心代号词,而你则去享受一杯应得的薄荷酒。

创建控制消息

为了理解如何操作,创建你自己的消息和路由密码。将其称为你的控制信息

  • 列数 = 4

  • 行数 = 5

  • 起始位置 = 左下角

  • 路由 = 交替上下列

  • 明文 = 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

  • 密文 = 16 12 8 4 0 1 5 9 13 17 18 14 10 6 2 3 7 11 15 19

  • 密钥 = –1 2 –3 4

使用数字序列作为明文,可以让你在消息的任何地方立即判断自己是否正确解密了全部或部分内容。

置换矩阵如 图 4-3 所示,灰色箭头表示加密路径。

image

图 4-3:控制消息的置换矩阵,包含路由密码路径及生成的密文

密钥跟踪了列中路由的顺序方向。路由不必按顺序穿过列。例如,它可以先向下移动第一列,再向上移动第三列,接着向下移动第四列,最后向上移动第二列。负数表示从底部开始,向上读取一列;正数则表示相反的顺序。对于控制信息,程序中使用的最终密钥将是一个列表:[–1, 2, –3, 4]。这个列表将指示程序从第一列的底部开始向上读取,移动到第二列的顶部并向下读取,接着移动到第三列的底部并向上读取,再移动到第四列的顶部并向下读取。

注意,你不应在密钥中使用 0,因为用户是人类,习惯从 1 开始计数。当然,Python 从 0 开始计数,所以你需要在背后从密钥值中减去 1。这样,大家都能受益!

在 “路由置换密码:暴力破解攻击” 中的 第 88 页,你可以使用这种紧凑的密钥结构通过暴力破解路由密码,自动尝试数百个密钥,直到明文被恢复。

设计、填充和清空矩阵

你将输入密文作为一个连续的字符串。为了让程序解开该字符串中的路由,你首先需要构建并填充一个转换矩阵。密文字符串只是置换矩阵中的列,在 图 4-3 中按顺序连接在一起的结果。由于置换矩阵有五行,密文中的每五个元素表示一个单独的列。你可以使用一个列表的列表来表示这个矩阵:

>>> list_of_lists = [['16', '12', '8', '4', '0'], ['1', '5', '9', '13', '17'],
['18', '14', '10', '6', '2'], ['3', '7', '11', '15', '19']]

这个新列表中的每一项现在代表一个列表——每个列表代表一列——而每个列表中的五个元素代表组成该列的行。这有点难理解,所以我们将在单独的行中打印每一个这些嵌套列表:

>>> for nested_list in list_of_lists
        print(nested_list)
[16, 12, 8, 4, 0]
[1, 5, 9, 13, 17]
[18, 14, 10, 6, 2]
[3, 7, 11, 15, 19]

如果你从左到右读取每个列表,从顶部开始,你将沿着换位路径进行读取,该路径是上下交替的列(参见图 4-3)。从 Python 的角度来看,第一个读取的列是list-of-lists[0],起始点是list-of-lists[0][0]

现在,通过按照与起始列相同的方向(向上)读取所有列来规范化路线。这需要反转每个其他列表中元素的顺序,如下所示,已加粗显示:

[16, 12, 8, 4, 0]
[17, 13, 9, 5, 1]
[18, 14, 10, 6, 2]
[19, 15, 11, 7, 3]

一个模式浮现出来。如果你从右上角开始,向下读取每一列,直到左下角,数字将按数字顺序排列;你已恢复明文!

为了复制这一点,你的脚本可以遍历每个嵌套列表,移除该列表中的最后一个项并将该项添加到一个新的字符串中,直到翻译矩阵被清空。脚本将通过密钥知道它需要反转哪些嵌套列表,并知道依次清空矩阵的顺序。输出将是一个恢复后的明文字符串:

'0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19'

现在你应该对策略有了一个非常概括的理解。接下来,让我们更详细地描述,并编写伪代码。

伪代码

脚本可以分为三个主要部分:用户输入、翻译矩阵填充和解密为明文。你应该能够在下面的伪代码中看到这些部分:

Load the ciphertext string.
Convert ciphertext into a cipherlist to split out individual words.
Get input for the number of columns and rows.
Get input for the key.
Convert key into a list to split out individual numbers.
Create a new list for the translation matrix.
For every number in the key:
    Create a new list and append every n items (n = # of rows) from the cipherlist.
    Use the sign of key number to decide whether to read the row forward or backward.
    Using the chosen direction, add the new list to the matrix. The index of each
    new list is based on the column number used in the key.
Create a new string to hold translation results.
For range of rows:
    For the nested list in translation matrix:
          Remove the last word in nested list
          Add the word to the translation string.
Print the translation string.

循环前的所有内容基本上只是收集和重新格式化密码数据。第一个循环负责构建和填充矩阵,第二个循环从该矩阵中创建一个翻译字符串。最后,打印出翻译字符串。

路线密码解密代码

清单 4-1 接受一个使用路线密码加密的消息、换位矩阵中的列数和行数,以及一个密钥,然后显示翻译后的明文。它将解密所有“常见”的路线密码,其中路线从列的顶部或底部开始,并沿着列向上和/或向下继续。

这是原型版本;一旦你确认它可以正常工作,你可以将其打包供其他人使用。你可以在www.nostarch.com/impracticalpython/下载此代码。

route_cipher_decrypt_prototype.py

➊ ciphertext = "16 12 8 4 0 1 5 9 13 17 18 14 10 6 2 3 7 11 15 19"

   # split elements into words, not letters

➋ cipherlist = list(ciphertext.split())

➌ # initialize variables

   COLS = 4

   ROWS = 5

   key = '-1 2 -3 4'  # neg number means read UP column vs. DOWN

   translation_matrix = [None] * COLS

   plaintext = ''

   start = 0

   stop = ROWS

   # turn key_int into list of integers:

➍ key_int = [int(i) for i in key.split()]

   # turn columns into items in list of lists:

➎ for k in key_int:

    ➏ if k < 0:  # reading bottom-to-top of column

           col_items = cipherlist[start:stop]

       elif k > 0:  # reading top-to-bottom of columnn

           col_items = list((reversed(cipherlist[start:stop])))

       translation_matrix[abs(k) - 1] = col_items

       start += ROWS

       stop += ROWS

   print("\nciphertext = {}".format(ciphertext))

   print("\ntranslation matrix =", *translation_matrix, sep="\n")

   print("\nkey length = {}".format(len(key_int)))

   # loop through nested lists popping off last item to new list:

➐ for i in range(ROWS):

       for col_items in translation_matrix:

        ➑ word = str(col_items.pop())

        ➒ plaintext += word + ' '

   print("\nplaintext = {}".format(plaintext))

清单 4-1: route_cipher_decrypt_prototype.py 的代码

从将密文➊作为字符串加载开始。你要处理的是单词而不是字母,因此根据空格使用split()字符串方法将字符串分开,创建一个名为cipherlist的新列表➋。split()方法是join()方法的反操作,你之前见过。你可以在任何字符串上进行分割;该方法默认为连续的空白字符运行,删除每个空白字符后才移至下一个。

现在是时候输入你对密码的了解了➌:列和行,这些构成了矩阵,还有包含路由的密钥。将列数和行数初始化为常量。然后创建一个名为translation_matrix的空列表,用来保存每列的内容作为(嵌套)列表。通过将值None乘以列数来分配占位符。你可以使用这些空项的索引将列按正确顺序放回原位,以应对密钥顺序不按数字排列的情况。

一个名为plaintext的空字符串将保存解密后的消息。接下来是一些切片参数。请注意,这些参数中的一些是根据行数推导出来的,行数等于每列中的项目数。

现在,使用列表推导式将密钥变量(一个字符串)转换为整数列表——这是一种对列表执行操作的简洁方式➍。你稍后会用密钥中的数字作为索引,因此它们需要是整数。

下一段代码是一个for循环,它填充translation_matrix,它只是一个包含列表的列表➎。由于每列变成一个嵌套列表,并且key_int列表的长度等于列数,循环的范围就是密钥,它也描述了路由。

在循环内,使用条件判断密钥是正数还是负数➏;如果密钥是正数,那么切片的方向将被反转。根据绝对密钥值将切片分配到translation_matrix的正确位置,并减去 1(因为密钥不包括 0,而列表索引是从 0 开始的)。通过将切片的端点按行数推进并打印一些有用的信息,完成循环。

最后一段代码➐遍历行数——这相当于嵌套列表中单词的数量——以及每个嵌套列表。这两次循环的前两次展示在图 4-4 中。每当你停在每个嵌套列表时,就能使用我最喜欢的 Python 函数之一——列表的pop()方法➑。pop()方法会移除并返回列表的最后一个项,除非提供了特定的索引。它销毁了嵌套列表,但反正你已经用完它了。

image

图 4-4:第一次和第二次遍历嵌套列表,移除并将每个末尾项附加到翻译字符串

一旦你弹出一个单词,就把它连接到plaintext字符串并加一个空格➒。剩下的就是显示解密后的密文。数字测试集的输出如下:

plaintext = 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

那看起来像是成功了!

破解路由密码

上述代码假设你已经知道加密矩阵的路径,或者已经正确猜测出密钥。如果这些假设不成立,你唯一的办法就是尝试每一个可能的密钥和矩阵排列。你可以在《路由置换密码:暴力破解攻击》的第 88 页中,自动化选择密钥的过程——对于特定列数。可是,正如你所看到的,联合路由密码对暴力破解攻击有很强的防护能力。你可以破解它,但你最终会获得大量数据,让你感觉就像那只追上了汽车并抓住它的狗。

随着信息变长,置换密码中可能的加密路径数量变得过于庞大,甚至现代计算机也难以用暴力破解法解决。例如,如果有八列,并且允许路径跳过任何列,那么组合列的方式就是八的阶乘:8 × 7 × 6 × 5 × 4 × 3 × 2 × 1 = 40,320。也就是说,40,320 条路径你开始选择不同的列路径之前。如果路径可以在列中上下变动,组合的数量就增加到 10,321,920。若考虑到从列中的任何位置开始——而不是从顶部或底部——并允许任何矩阵路径(如螺旋状),那么情况就会变得更加复杂!

由于这个原因,即使是简单的置换密码,也可能拥有成千上万甚至数百万种可能的路径。即便路径数量对计算机来说是可管理的,且暴力破解攻击可能奏效,你仍然需要一种方法来筛选出无数的结果,并且无论是通过计算方式选出一个胜者,还是选择一小部分候选结果进行人工检查。

对于更常见的字母置换密码,可以编写一个函数,通过将每次解密的尝试与字典文件进行比较,来检测是否为英语。如果解密出来的字典单词数量超过某个阈值百分比,那么你大概率破解了这个密码。同样,如果常见的字母对(双字母组合)出现频率较高,比如erthonan,你可能已经找到了密码的解决方案。不幸的是,这种方法不适用于像你现在使用的单词置换密码。

字典无法帮助你判断单词是否已经正确排列。对于单词排列,你可以尝试使用诸如语法规则和概率语言模型(例如n-gram)的方式,程序化地遍历数千个解密结果并挑选候选结果,但斯塔格在他的路由密码中巧妙地使用了代号和虚假单词,这将大大增加破解的难度。

密码分析师认为,尽管存在上述问题,简单的、直白的置换密码在没有计算机的情况下还是相对容易破解的。他们会寻找有意义的常见单词或字母对,利用这些线索来猜测置换矩阵的行数。

为了说明这一点,让我们使用由数字组成的控制信息。在图 4-5 中,你可以看到一个 4×5 矩阵的密文结果,每个结果都是从网格的四个角之一开始,按照交替的顺序路径进行的。所有情况都包含相邻数字的重复(在图 4-5 中用阴影标出)。这些重复表示你在网格中横向移动,并且提供了关于矩阵设计和所走路径的线索。你可以立即看到有五行,因为每对常见的数字对中的第一个是第五个单词。此外,知道消息中有 20 个单词,你可以推测列数为四(20 / 5 = 4)。通过合理的假设,消息是从左到右写的,你甚至可以猜测路径。例如,如果你从右下角开始,你首先上移到 3,然后左移到 2,再下移到 18,再左移到 17,然后上移到 1,最后左移到 0。当然,使用单词时会更困难,因为单词之间的联系不如数字那样明确,但使用数字更能突显这一点。

image

图 4-5:按逻辑顺序排列的字符或单词(阴影部分)可以用于推测加密路径。

看图 4-6,这是基于图 4-2 中的消息。结束单词和可能的链接单词,如“is just”或“heading to”,被阴影标出。

image

图 4-6:对图 4-2 中路线密码的人工破解。示出一个五行矩阵。

总共有 20 个单词,可以有 4 行、5 行或 10 行。我们怀疑不会使用两列矩阵,因此我们实际处理的是 4×5 或 5×4 的排列。如果加密路径像图 4-5 所示,那么我们预期在四行矩阵中,每两个阴影单词之间会有两个非阴影单词,而在五行矩阵中会有三个非阴影单词。无论你从哪个方向阅读密文,按照四列模式来构思合理的单词对都更加困难。所以,我们可能在处理一个五列的解法,从矩阵的左侧开始——因为从左到右读取的链接单词是有意义的。

请注意,图 4-6 中的阴影单词如何填充在图 4-7 中换位矩阵的顶部和底部行中。这是我们所期望的,因为路径在每一列的顶部和底部“转弯”了。图形化解决方案:上帝赐予不懂数学者的礼物!

image

图 4-7:图 4-6 中的阴影单词放置在换位矩阵中

这看起来很简单,但我们知道路由密码是如何工作的。联邦的破译员最终也发现了它,但密码词的使用让他们无法完全进入系统。要破解这些代码,他们需要一份捕获的密码本或一个能够获取并分析大数据的大型组织,而这在 19 世纪的南方联盟是无法实现的。

添加用户界面

本项目的第二个目标是编写代码,减少人为错误,尤其是对于经验较少的人(包括技术人员、实习生、同事和 1864 年的电报员)。当然,使程序更具用户友好的最佳方法是包括一个图形用户界面(GUI),但有时这并不实际或不可行。例如,破解代码程序会自动循环通过成千上万的可能密钥,自动生成这些密钥比直接从用户获取它们要容易。

在这个例子中,你将假设用户会打开程序文件并输入一些内容,甚至做一些小的代码修改。以下是一些指南:

  1. 从有用的文档字符串开始(参见第一章)。

  2. 将所有必需的用户输入放在最上面。

  3. 使用注释来澄清输入要求。

  4. 清晰地将用户输入与其余代码分开。

  5. 将大多数过程封装在函数中。

  6. 包括函数以捕捉可预测的用户错误。

这种方法的好处在于没有侮辱任何人的智商。如果用户滚动查看代码,甚至修改代码,也没有任何障碍。如果他们只想输入一些值并获得一个黑盒解决方案,那么他们也会很高兴。而且,我们通过简化操作并减少出错的可能性,尊重了安森·斯塔格的精神。

指导用户并获取输入

列表 4-2 展示了重新打包后的原型代码,供与他人分享。你可以在* www.nostarch.com/impracticalpython/*找到这段代码。

route_cipher_decrypt.py,第一部分

➊ """Decrypt a path through a Union Route Cipher.

➋ Designed for whole-word transposition ciphers with variable rows & columns.
   Assumes encryption began at either top or bottom of a column.
   Key indicates the order to read columns and the direction to traverse.
   Negative column numbers mean start at bottom and read up.
   Positive column numbers mean start at top & read down.

   Example below is for 4x4 matrix with key -1 2 -3 4.
   Note "0" is not allowed.
   Arrows show encryption route; for negative key values read UP.

     1   2   3   4

    ___ ___ ___ ___
   | ^ | | | ^ | | | MESSAGE IS WRITTEN
   |_|_|_v_|_|_|_v_|
   | ^ | | | ^ | | | ACROSS EACH ROW
   |_|_|_v_|_|_|_v_|
   | ^ | | | ^ | | | IN THIS MANNER
   |_|_|_v_|_|_|_v_|
   | ^ | | | ^ | | | LAST ROW IS FILLED WITH DUMMY WORDS
   |_|_|_v_|_|_|_v_|
   START        END

   Required inputs - a text message, # of columns, # of rows, key string

   Prints translated plaintext
   """
➌ import sys

   #==============================================================================
➍ # USER INPUT:

➎ # the string to be decrypted (type or paste between triple-quotes):
   ciphertext = """16 12 8 4 0 1 5 9 13 17 18 14 10 6 2 3 7 11 15 19
   """

➏ # number of columns in the transposition matrix:
   COLS = 4

   # number of rows in the transposition matrix:
   ROWS = 5

➐ # key with spaces between numbers; negative to read UP column (ex = -1 2 -3 4):
   key = """ -1 2 -3 4 """

➑ # END OF USER INPUT - DO NOT EDIT BELOW THIS LINE!
   #==============================================================================

➒ ________________________________________________________________________________

列表 4-2:route_cipher_decrypt.py 的文档字符串、导入和用户输入

从一个多行文档字符串开始 ➊。文档字符串告知用户该程序仅解密典型的路由密码——即从列的顶部或底部开始的密码——并告诉他们如何输入密钥信息 ➋。包含一个示意图以帮助说明。

接下来,导入sys以访问系统字体和功能 ➌。你将检查用户输入是否符合接受标准,因此你需要在 shell 中以引人注目的红色显示消息。将这个import语句放在这里有些两难。由于战略目标是将工作代码隐藏起来,你应该在程序的后面应用它。但 Python 将所有import语句放在顶部的惯例太强大,无法忽视。

现在是输入部分。你有多少次见过或处理过需要在程序中 各处 进行修改或输入的代码?这对作者来说可能很混乱,甚至对其他用户来说更加糟糕。因此,为了方便、礼貌和防止错误,将所有这些重要变量移到顶部。

首先,用一行分隔输入部分,然后通过全大写的注释提醒用户他们即将进行输入 ➍。必需的输入已经通过注释明确定义。你可以使用三重引号来处理文本输入,以便更好地容纳较长的文本片段。请注意,我已经输入了来自 图 4-3 的数字串 ➎。接下来,用户需要添加转换矩阵的列数和行数 ➏,然后是提议的(或已知的)密钥 ➐。

在用户输入部分结束时,使用声明性注释标明,并提醒不要编辑以下行 ➑。然后添加一些额外的空格,以更清晰地将输入部分与程序的其余部分分开 ➒。

定义 main() 函数

清单 4-3 定义了 main() 函数,它运行程序并在解码后打印明文。main() 函数可以在它调用的函数之前或之后定义,只要它是最后被调用的函数。

route_cipher_decrypt.py, 第二部分

def main():
    """Run program and print decrypted plaintext."""
 ➊ print("\nCiphertext = {}".format(ciphertext))
    print("Trying {} columns".format(COLS))
    print("Trying {} rows".format(ROWS))
    print("Trying key = {}".format(key))

    # split elements into words, not letters
 ➋ cipherlist = list(ciphertext.split())
 ➌ validate_col_row(cipherlist)
 ➍ key_int = key_to_int(key)
 ➎ translation_matrix = build_matrix(key_int, cipherlist)
 ➏ plaintext = decrypt(translation_matrix)

 ➐ print("Plaintext = {}".format(plaintext))

清单 4-3:定义了 main() 函数

通过打印用户输入到 shell 来开始 main() 函数 ➊。然后,将密文按空格拆分成列表,正如你在原型代码中所做的那样 ➋。

接下来的系列语句调用了你将很快定义的函数。第一个函数检查输入的行和列是否适合消息长度 ➌。第二个函数将 key 变量从字符串转换为整数列表 ➍。第三个函数构建了转换矩阵 ➎,第四个函数对矩阵运行解密算法并返回明文字符串 ➏。通过打印明文 ➐ 来完成 main() 函数。

验证数据

当你继续为最终用户打包 route_cipher_decrypt.py 时,你需要验证输入是否有效。清单 4-4 预见了常见的用户错误,并为用户提供了有用的反馈和指导。

route_cipher_decrypt.py, 第三部分

➊ def validate_col_row(cipherlist):
       """Check that input columns & rows are valid vs. message length."""
       factors = []
       len_cipher = len(cipherlist)
    ➋ for i in range(2, len_cipher):  # range excludes 1-column ciphers
           if len_cipher % i == 0:
               factors.append(i)
    ➌ print("\nLength of cipher = {}".format(len_cipher))
       print("Acceptable column/row values include: {}".format(factors))
       print()
    ➍ if ROWS * COLS != len_cipher:
           print("\nError - Input columns & rows not factors of length "
                 "of cipher. Terminating program.", file=sys.stderr)
           sys.exit(1)

➎ def key_to_int(key):
       """Turn key into list of integers & check validity."""
    ➏ key_int = [int(i) for i in key.split()]
       key_int_lo = min(key_int)
       key_int_hi = max(key_int)
    ➐ if len(key_int) != COLS or key_int_lo < -COLS or key_int_hi > COLS \
           or 0 in key_int:
        ➑ print("\nError - Problem with key. Terminating.", file=sys.stderr)
           sys.exit(1)
       else:
        ➒ return key_int

清单 4-4:定义了用于检查和准备用户输入的函数

validate_col_row() 函数检查输入的列数和行数是否适合 cipherlist 的长度,该长度作为参数传递 ➊。换位矩阵的大小始终与消息中的单词数量相同,因此列数和行数必须是消息大小的因子。要确定所有允许的因子,首先创建一个空列表来存储因子,然后获取 cipherlist 的长度。使用 cipherlist,而不是输入的 ciphertext,因为密文中的元素是 字母,而不是单词。

通常,若要获取一个数字的因子,你会使用范围(1, 数字+ 1),但你不希望这些端点出现在factors列表中,因为具有这些维度的翻译矩阵就是明文。所以将这些值从范围中排除 ➋。由于一个数的因子可以整除该数,因此使用模运算符(%)来查找因子,并将它们添加到factors列表中。

接下来,为用户显示一些有用的信息:密码列表的长度以及可接受的行列选择 ➌。最后,将用户的两个选择相乘,并将结果与密码列表的长度进行比较。如果它们不匹配,则在终端显示一个红色的警告消息(使用我们之前的file=sys.stderr技巧),并终止程序 ➍。使用sys.exit(1),其中1表示异常退出。

现在定义一个函数来检查密钥,并将其从字符串转换为列表 ➎。将key变量作为参数传递给它。将key中的每一项分割出来并转换为整数;将该列表命名为key_int,以便与用户输入的key变量区分 ➏。接下来,确定key_int列表中的最小值和最大值。然后使用if语句确保该列表包含的项数与列数相同,并且key中的项没有过大、过小或等于0 ➐。如果任何条件不符合,程序将终止并显示错误消息 ➑。否则,返回key_int列表 ➒。

构建和解码翻译矩阵

列表 4-5 定义了两个函数,一个用于构建翻译矩阵,另一个用于解码翻译矩阵,并将main()函数作为模块或独立模式调用。

route_cipher_decrypt.py, 第四部分

➊ def build_matrix(key_int, cipherlist):
       """Turn every n items in a list into a new item in a list of lists."""
       translation_matrix = [None] * COLS
       start = 0
       stop = ROWS
       for k in key_int:
           if k < 0:  # read bottom-to-top of column
               col_items = cipherlist[start:stop]
           elif k > 0:  # read top-to-bottom of columnn
               col_items = list((reversed(cipherlist[start:stop])))
           translation_matrix[abs(k) - 1] = col_items
           start += ROWS
           stop += ROWS
       return translation_matrix

➋ def decrypt(translation_matrix):
       """Loop through nested lists popping off last item to a string."""
       plaintext = ''
       for i in range(ROWS):
           for matrix_col in translation_matrix:
               word = str(matrix_col.pop())
               plaintext += word + ' '
       return plaintext

➌ if __name__ == '__main__':
       main()

列表 4-5:定义了用于构建和解码翻译矩阵的函数

这两个函数表示在route_cipher_decrypt_prototype.py程序中对代码的封装。详细描述请参见列表 4-1。

首先,定义一个函数来构建翻译矩阵;将key_intcipherlist变量作为参数传递给它 ➊。让该函数返回一个列表的列表。

接下来,将解密代码打包为一个函数,在这个函数中从每个嵌套列表的末尾弹出元素,并使用translation_matrix列表作为参数 ➋。返回明文,由main()函数打印出来。

以条件语句结束,使得程序能够作为模块运行或独立运行 ➌。

如果你只是偶尔或一次性使用这段代码,你会欣赏它的简单直观。如果你打算修改代码以便用于自己的目的,你也会欣赏到关键变量的可访问性以及主要任务的模块化。你不必深入程序中挖掘或理解像list1list2这样难以理解的变量之间的区别。

以下是程序的输出,使用图 4-3 中的密文:

Ciphertext = 16 12 8 4 0 1 5 9 13 17 18 14 10 6 2 3 7 11 15 19

Trying 4 columns
Trying 5 rows
Trying key = -1 2 -3 4

Length of cipher = 20
Acceptable column/row values include: [2, 4, 5, 10]

Plaintext = 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

你现在应该能够轻松地解密带有已知密钥的路线置换密码,或者使用脚本清晰且易于访问的界面调整密钥来测试怀疑的路线。你将有机会通过自动尝试每一个可能的密钥,真正破解这些密码,详见《路线置换密码:暴力破解》,位于第 88 页。

项目 #9:铁路栅栏密码

南方联邦的军官和间谍在密码学方面几乎是自给自足的。这导致了不太复杂的解决方案,其中之一就是铁路栅栏密码,由于其与分裂木栅栏的锯齿形状相似,因此得名(见图 4-8)。

image

图 4-8:一座铁路栅栏

铁路栅栏是一种简单易用的置换密码,像联邦的路线密码一样,但与路线密码不同的是,它是对字母进行置换而非单词,使其更容易出错。而且,由于可能的密钥数量远比路线密码中的路径数量有限,铁路栅栏密码也更容易被“破解”。

联邦和南方都使用铁路栅栏作为场地密码,而间谍们可能并不经常使用密码词。密码本需要严格控制,显而易见的原因是它们更有可能保存在军事电报办公室,而不是随身携带,容易被渗透的卧底特工带走。

有时,南方联邦使用更复杂的维吉尼亚密码(见《项目 #12:隐藏维吉尼亚密码》位于第 106 页)用于重要消息——以及一些不重要的消息来误导敌人——但这是一项繁琐的解密工作,进行加密也同样费时,并不适合快速的现场通讯。

尽管南方联邦及南方人普遍缺乏密码学方面的训练,但他们依然聪明且富有创新精神。他们在秘密消息艺术方面的成就之一,就是在冷战广泛采用之前 100 年,使用微型摄影。

目标

编写 Python 程序,帮助间谍加密和解密“二轨”(两行)铁路栅栏密码。你应该以一种能够减少缺乏经验的用户操作错误的方式编写程序。

策略

要使用铁路栅栏密码加密信息,请按照图 4-9 中的步骤操作。

image

图 4-9:二轨铁路栅栏密码的加密过程

明文写完后,去掉空格,所有字母都转换为大写字母(步骤 2)。使用大写字母是密码学中的常规做法,因为它掩盖了专有名词和句子的开头,从而给密码分析员提供了更少的线索来解密信息。

然后将消息按堆叠的方式写出,每隔一个字母就将下一个字母写在前一个字母的下面,并且向右移动一个位置(步骤 3)。这就是“铁丝栅栏”比喻的体现。

然后写出第一行,紧接着在同一行上写出第二行(步骤 4),接着将字母分成五个一组,以制造出独立词的假象,并进一步迷惑密码分析师(步骤 5)。

要解密铁丝栅栏密码,逆向执行过程。只需去掉空格,将消息分为两半,将第二半放在第一半的下方,偏移一个字母,然后按之字形模式读取消息。如果密文的字母数是奇数,将多出的字母放在第一(上)半部分。

为了方便希望使用铁丝栅栏密码的人,按照前面的步骤写两个程序,一个用来加密,另一个用来解密。图 4-9 本质上就是你的伪代码,接下来我们开始吧。既然你已经知道如何为缺乏经验的用户打包代码,从一开始就采用这种方法吧。

铁丝栅栏密码加密代码

本节中的代码允许用户输入明文消息,并在解释器窗口中打印出加密结果。此代码可通过书本资源下载,网址为www.nostarch.com/impracticalpython/

指导用户并获取输入

列表 4-6,位于rail_fence_cipher_encrypt.py的顶部,提供了程序的说明,并将明文赋值给一个变量。

rail_fence_cipher_encrypt.py, 第一部分

➊ r"""Encrypt a Civil War 'rail fence' type cipher.

   This is for a "2-rail" fence cipher for short messages.

   Example text to encrypt:  'Buy more Maine potatoes'

   Rail fence style:  B Y O E A N P T T E
                       U M R M I E O A O S

   Read zigzag:       \/\/\/\/\/\/\/\/\/\/

   Encrypted:  BYOEA NPTTE UMRMI EOSOS

   """
   #------------------------------------------------------------------------------
➋ # USER INPUT:

   # the string to be encrypted (paste between quotes):
➌ plaintext = """Let us cross over the river and rest under the shade of the trees
   """

➍ # END OF USER INPUT - DO NOT EDIT BELOW THIS LINE!
   #------------------------------------------------------------------------------

列表 4-6:rail_fence_cipher_encrypt.py 的文档字符串和用户输入部分

从多行文档字符串开始,在第一组三重引号前加上r(表示“原始”)前缀 ➊。没有这个前缀,Pylint 会对下文中的\/\斜杠报出严重警告。幸运的是,pydocstyle 会指出这一点,你可以修复它(阅读第一章了解更多关于 Pylint 和 pydocstyle 的信息)。如果你想了解更多关于原始字符串的内容,请参见 Python 文档中的第 2.4.1 节(docs.python.org/3.6/reference/lexical_analysis.html#string-and-bytes-literals)。

接下来,用一行分隔程序的文档字符串和import语句与输入部分,并通过大写注释让用户知道他们要开始了 ➋。用注释清楚地定义输入要求,并将明文放入三重引号中,以更好地容纳长文本字符串 ➌。

最后,用声明结束用户输入部分,并提醒不要编辑以下内容 ➍。

加密消息

将列表 4-7 添加到rail_fence_cipher_encrypt.py中,以处理加密过程。

rail_fence_cipher_encrypt.py, 第二部分

➊ def main():
       """Run program to encrypt message using 2-rail rail fence cipher."""
       message = prep_plaintext(plaintext)
       rails = build_rails(message)
       encrypt(rails)

➋ def prep_plaintext(plaintext):
       """Remove spaces & leading/trailing whitespace."""
    ➌ message = "".join(plaintext.split())
    ➍ message = message.upper()  # convention for ciphertext is uppercase
       print("\nplaintext = {}".format(plaintext))
       return message

➎ def build_rails(message):
       """Build strings with every other letter in a message."""
       evens = message[::2]
       odds = message[1::2]
    ➏ rails = evens + odds
       return rails

➐ def encrypt(rails):
       """Split letters in ciphertext into chunks of 5 & join to make string."""
    ➑ ciphertext = ' '.join([rails[i:i+5] for i in range(0, len(rails), 5)])
       print("ciphertext = {}".format(ciphertext))

➒ if __name__ == '__main__':
       main()

Listing 4-7: 定义加密明文消息的函数

首先,定义一个main()函数来运行程序 ➊。拥有一个main()函数可以让你在以后有需要时,将此程序作为模块在另一个程序中使用。这个函数调用了其他三个函数:一个用于准备输入的明文,一个用于构建加密所用的“rails”,以及一个用于将加密文本拆分成五个字母一组的块。

接下来,定义一个函数,用于处理输入字符串并为加密做准备 ➋。这个过程包括去除空格 ➌ 和将字母转换为大写(如图 4-9 的第 2 步所示) ➍。然后,换行后将明文打印到屏幕上并返回。

现在,定义一个函数,按图 4-9 的第 3 步,将message字符串切片为偶数位置(从 0 开始,步长为 2)和奇数位置(从 1 开始,步长为 2) ➎。然后,将这两个字符串连接成一个新字符串,命名为rails ➏,并返回。

定义一个加密函数,该函数以rails字符串作为参数 ➐。使用列表推导式将密文分成五个一组的块(如图 4-9 的第 5 步所示) ➑。然后,将加密文本打印到屏幕上。最后编写代码,以便在作为模块或独立模式下运行程序 ➒。

以下是该程序的输出:

plaintext = Let us cross over the river and rest under the shade of the trees
ciphertext = LTSRS OETEI EADET NETEH DOTER EEUCO SVRHR VRNRS UDRHS AEFHT ES

Rail Fence Cipher 解密代码

本部分的代码允许用户输入使用 rail fence cipher 加密的消息,并在解释器窗口中显示明文。此代码可与本书的其他资源一起下载,网址为www.nostarch.com/impracticalpython/

导入模块,指示用户并获取输入

Listing 4-8 开始的指令与rail_fence_cipher_encrypt.py程序中的指令类似(Listing 4-6),导入了两个模块,并获取了用户输入。

rail_fence_cipher_decrypt.py,第一部分

   r"""Decrypt a Civil War 'rail fence' type cipher.

   This is for a 2-rail fence cipher for short messages.

   Example plaintext:  'Buy more Maine potatoes'

   Rail fence style:  B Y O E A N P T T E
                       U M R M I E O A O S

   Read zigzag:       \/\/\/\/\/\/\/\/\/\/

   Ciphertext:  BYOEA NPTTE UMRMI EOSOS

   """
➊ import math
   import itertools

   #------------------------------------------------------------------------------
   # USER INPUT:

   # the string to be decrypted (paste between quotes):
➋ ciphertext = """LTSRS OETEI EADET NETEH DOTER EEUCO SVRHR VRNRS UDRHS AEFHT ES

   """

   # END OF USER INPUT - DO NOT EDIT BELOW THIS LINE!
   #------------------------------------------------------------------------------

Listing 4-8: 导入模块,指示用户并获取用户输入

这里的一个不同之处在于,你需要导入mathitertools模块 ➊。你将使用math进行四舍五入。itertools模块是 Python 标准库中的一组函数,能够创建用于高效循环的迭代器。你将在解密过程中使用itertoolszip_longest()函数。

唯一的另一个变化是用户应该输入密文,而不是明文 ➋。

解密消息

Listing 4-9 定义了用于准备和解码密文的函数,并完成了rail_fence_cipher_decrypt.py

rail_fence_cipher_decrypt.py,第二部分

➊ def main():

       """Run program to decrypt 2-rail rail fence cipher."""

       message = prep_ciphertext(ciphertext)

       row1, row2 = split_rails(message)

       decrypt(row1, row2)

➋ def prep_ciphertext(ciphertext):

       """Remove whitespace."""

       message = "".join(ciphertext.split())

       print("\nciphertext = {}".format(ciphertext))

       return message

➌ def split_rails(message):

       """Split message in two, always rounding UP for 1st row."""

    ➍ row_1_len = math.ceil(len(message)/2)

    ➎ row1 = (message[:row_1_len]).lower()

       row2 = (message[row_1_len:]).lower()

       return row1, row2

➏ def decrypt(row1, row2):

       """Build list with every other letter in 2 strings & print."""

    ➐ plaintext = []

    ➑ for r1, r2 in itertools.zip_longest(row1, row2):

           plaintext.append(r1)

           plaintext.append(r2)

    ➒ if None in plaintext:

           plaintext.pop()

       print("rail 1 = {}".format(row1))

       print("rail 2 = {}".format(row2))

       print("\nplaintext = {}".format(''.join(plaintext)))

➓ if __name__ == '__main__':

       main()

Listing 4-9: 准备、解码并打印消息

这里的main()函数➊与第 4-7 节中加密程序使用的函数相似。调用了三个函数:一个用于准备输入字符串,一个用于在栅栏密码中“分割轨道”,还有一个用于将两个轨道重新拼接成可读的明文。

从一个函数开始,重复加密过程中使用的预处理步骤➋。移除五个字母块之间的空格,以及在粘贴密文时产生的其他空白字符,然后打印并返回密文。

接下来,你需要将消息分成两半,以逆转加密过程➌。正如我在《策略》中提到的,第 81 页上,字符数为奇数的消息中的额外字母被分配到顶部行。为了处理奇数情况,使用math.ceil()方法➍。“Ceil”代表“上限”,所以当你除以 2 时,答案总是向上舍入到最接近的整数。将这个数字赋值给row_1_len变量。知道了第一行的长度后,使用这个值和切片操作,将message变量分割成两个字符串,分别表示两行➎。最后,通过返回行变量来结束函数。

现在,只需从两行中选择并连接每个其他字母,将明文拼接回来。定义一个decrypt()函数,并传入row1row2字符串➏。通过创建一个空列表来存放结果,开始翻译过程➐。接下来,你需要一种简单的方法来处理密文字符数为奇数的情况——这样会导致两行长度不同——因为 Python 会通过抛出索引越界错误来阻止你遍历两个不等长的序列。这就是为什么我们导入了itertools模块——它的函数有助于循环遍历,绕过这个问题。

itertools.zip_longest()函数接受两个字符串作为参数,并无怨无悔地循环遍历它们,在较短的字符串结束时向plaintext列表添加空值(None)➑。你不想打印这个空值,因此如果它存在,使用你在路线密码代码中应用的pop()方法将其移除➒。通过打印两个行(轨道)到屏幕上,接着是解密后的密文,完成解密过程。

结束时使用标准代码,运行程序作为模块或独立模式➓。程序的输出如下:

ciphertext = LTSRS OETEI EADET NETEH DOTER EEUCO SVRHR VRNRS UDRHS AEFHT ES

rail 1 = LTSRSOETEIEADETNETEHDOTERE
rail 2 = EUCOSVRHRVRNRSUDRHSAEFHTES

plaintext = letuscrossovertheriverandrestundertheshadeofthetrees

请注意,单词之间不会有空格,但没关系——你可不想让密码分析员完全无所作为!

总结

这完成了我们对美国内战密码的探索。你编写了一个帮助用户解密路由密码的程序,并获得了关于密码如何工作及如何破解的宝贵见解。你可以在以下实践项目中实施对密码的自动化攻击,但请记住,凭借其众多可能的路径和使用代码词,联邦的路由密码仍然是一个难解的难题。

接下来,你编写了程序来加密和解密二轨栅栏密码。考虑到手动加密和解密过程的繁琐和易出错,为战争中的双方提供一个自动化的方式来完成大部分工作将是非常有价值的。为了进一步解决这些问题,你编写的代码对没有经验的密码分析员或间谍来说也非常友好。

进一步阅读

更多适合初学者的 Python 程序,用于处理换位密码,可以在 Al Sweigart 的《用 Python 破解密码》(No Starch Press,2018 年)一书中找到。

Gary Blackwood 的《神秘信息:密码与密码学的历史》(企鹅出版社,2009 年)和 Simon Singh 的《密码书:从古埃及到量子密码学的秘密科学》(Anchor 出版社,2000 年)提供了密码学的精彩和图文并茂的概述。

网站 www.civilwarsignals.org/pages/crypto/crypto.htmlwww.mathaware.org/mam/06/Sauerberg_route-essay.html 包含了 Edward Porter Alexander 尝试破解路由密码的描述。Alexander 是南方联邦军信号兵团的创始人,也是一个杰出的军事创新者,拥有许多令人印象深刻的成就。

实践项目

通过这些项目来提高你的密码学技能。解决方案可以在附录和在线找到。

破解林肯密码

在他的书《神秘信息:密码与密码学的历史》中,Gary Blackwood 复原了亚伯拉罕·林肯用路由密码加密并发送的实际信息:

这个信息被滞留了,请确认为什么,若能填补,你会得到他们,海王星,公报,请与他们联系。

使用 route_cipher_decrypt.py 程序来解密这个密码。列数和行数必须是消息长度的因数,路线从一个角落开始,不跳过列,并在每次更换列时改变方向。密码单词的定义和明文解答可以在附录中找到。

识别密码类型

你越早知道你正在处理的密码类型,就越早能够破解它。单词置换密码很容易识别,但字母置换密码可能看起来像字母的替换密码。幸运的是,你可以通过使用密文中字母出现的频率来区分这两者。由于字母仅被打乱而不是替换,在置换密码中,它们的频率分布将与明文所在语言的分布相同。然而,军事信息是一个例外,因为它们使用行话并省略了许多常见的单词。对于这些信息,你需要基于其他军事信息构建一个频率表。

编写一个 Python 程序,接受一个密文字符串作为输入,并确定它更可能是置换密码还是替换密码。使用* cipher_a.txt cipher_b.txt 文件进行测试,这些文件可以从www.nostarch.com/impracticalpython/下载。解决方案可以在附录中找到,也可以在本书网站的identify_cipher_type_practice.py*中找到。

将密钥存储为字典

编写一个简短的脚本,将路径密码的密钥拆分为两部分:一部分记录列的顺序,另一部分记录通过列中的行阅读的方向(向上或向下)。将列号作为字典的键,阅读方向作为字典的值。让程序交互式地请求用户为每一列输入密钥值。解决方案可以在附录中找到,并在线在key_dictionary_practice.py文件中提供。

自动化可能的密钥

要尝试解密路径密码,使用路径中任何列的组合,你需要知道这些组合是什么,以便将它们作为参数输入解密函数。编写一个 Python 程序,接受一个整数(例如列数)并返回一个元组集合。每个元组应包含列号的唯一排列,如(1, 2, 3, 4)。包括负值——例如(2, -3, 4, -1)——以捕捉向上或向下遍历列的加密路径。解决方案在附录中提供,并可以在本书网站的permutations_practice.py中下载。

路径置换密码:暴力破解

复制并修改 route_cipher_decrypt.py 程序,破解图 4-2 中的路线密码。与其输入单一密钥,不如循环遍历所有可能的密钥—假设列数已知—并打印出结果(使用早期的排列代码生成这个四列密码的密钥)。列的顺序变换以及通过转置矩阵上下来回路径的影响在图 4-10 中有清晰展示。虚线代表列数的阶乘;实线显示了上下读取列的效果(由密钥中的负值体现)。如果你只需要处理 4 的阶乘,那么作为密码分析师,你的工作会很轻松。但随着密码长度的增加,可能的密钥数量会激增。而一些实际的联邦路线密码有 10 列或更多!

image

图 4-10:路线密码的可能密钥数与列数的关系

下面是 384 种由图 4-2 加密文本生成的四个翻译:

using key = [-4, -1, -2, -3]
translated = IS HEADING FILLER VILLAGE YOUR SNOW SOUTH GODWIN ARE FREE TO YOU
WITH SUPPLIES GONE TRANSPORT ROANOKE JUST TO REST

using key = [1, 2, -3, 4]
translated = REST ROANOKE HEADING TO TRANSPORT WITH SNOW GONE YOU ARE FREE TO
GODWIN YOUR SUPPLIES SOUTH VILLAGE IS JUST FILLER

using key = [-1, 2, -3, 4]
translated = VILLAGE ROANOKE HEADING TO GODWIN WITH SNOW GONE YOU ARE FREE TO
TRANSPORT YOUR SUPPLIES SOUTH REST IS JUST FILLER

using key = [4, -1, 2, -3]
translated = IS JUST FILLER REST YOUR SUPPLIES SOUTH TRANSPORT ARE FREE TO YOU
WITH SNOW GONE GODWIN ROANOKE HEADING TO VILLAGE

正确答案已经给出,但你可以理解,在使用了密码词和虚拟词的情况下,快速找出它有多么困难。不过,你做到了你的工作。去喝一杯薄荷酒或者一些甜茶吧。

该项目的解决方案在附录和 www.nostarch.com/impracticalpython/ 中提供,文件名为 route_cipher_hacker.py。你还需要使用 perms.py 程序,它基于之前的实践项目。

挑战项目

挑战项目没有提供解决方案。

路线密码编码器

一位初出茅庐的联邦电报员需要加密以下消息,并附上密码词(表 4-1)。通过编写一个程序来帮助他们,该程序将消息作为输入,自动替换密码词,使用虚拟词填充底行,并使用密钥[-1, 3, -2, 6, 5, -4]转置这些词。使用一个 6×7 的矩阵,并自行编造虚拟词。

我们将在 4 月 16 日晚上在维克斯堡执行炮火任务,然后前往大湾口,在那里我们将减少要塞。准备在 4 月 25 日或 29 日过河。波特海军上将。

表 4-1: 密码词

电池 HOUNDS
维克斯堡 ODOR
四月 CLAYTON
16 SWEET
大湾口 TREE
大湾口 OWL
要塞 BAILEY
河流 HICKORY
25 MULTIPLY
29 ADD
海军上将 HERMES
波特 LANGFORD

考虑使用 Python 字典来存储本表中的密码词词汇。

三轨篱笆密码

写一个版本的铁路篱笆密码,它使用三个轨道(行)而不是两个。你可以在 en.wikipedia.org/wiki/Rail_fence_cipher 上找到提示。

第五章:英国内战密码编码

image

1587 年,苏格兰的玛丽女王因一张纸条丧命。55 年后,查理一世的支持者约翰·特雷瓦尼恩爵士因一张纸条保住了性命。是什么造成了这种差异?隐写术。

隐写术是一种经得起时间考验的做法,用来将信息隐藏得如此精妙,以至于其存在根本不会被怀疑。这个名字源自希腊语,意为“隐藏的文字”,而一个非常字面意义上的希腊示例是用带有蜡层的木板写字,然后刮掉蜡层,在木板上写字,再覆盖一层新的光滑蜡膜。一个现代的例子是通过微妙地改变图像的颜色成分将信息嵌入图像中。即便是一张简单的 8 位 JPEG 图像,也包含了人眼无法察觉的更多颜色,因此在没有数字处理或过滤的情况下,信息几乎是不可见的。

在本章中,你将使用无效密码,这其实根本不是一种密码,而是一种将明文隐藏在其他非密码材料中的隐写技术。Null意味着“没有”,所以在无效密码中,你选择不加密信息。以下是使用每个单词的第一个字母的无效密码示例:

Nice uncles live longer. Cruel, insensitive people have eternal regrets.

首先,你将编写代码来找到救了约翰爵士的隐蔽讯息,然后你将完成一个更为困难的任务——编写一个无效密码。最后,你将有机会编写一个程序,如果玛丽使用了该程序的输出,可能会保住她的头颅。

项目 #10:特雷瓦尼恩密码

玛丽女王依靠隐写术和加密技术来保护她的讯息。这个策略是有效的,但她的应用存在缺陷。她在不知情的情况下依赖一个名叫吉尔伯特·吉福德的双重间谍来走私她的讯息。吉福德首先将讯息交给伊丽莎白女王的间谍头目,后者破解了密码,并用伪造的讯息替换,诱使玛丽自陷囹圄。接下来,正如他们所说的,这就是历史。

对于约翰·特雷瓦尼恩来说,结局更加美好。约翰爵士是一位杰出的骑士,他在英国内战中帮助查理一世抵抗奥利弗·克伦威尔,后来被捕并关押在科尔切斯特城堡。就在他被处决前一天,他收到了来自一位朋友的信。信件并非走私,而是直接交到看守手中,尽管他们检查过信件,但并没有发现任何欺骗。读完信后,约翰爵士请求独自到教堂祈祷。当他的看守回来找他时,他已经消失了。

这是约翰爵士收到的讯息:

尊敬的约翰爵士:希望,这是受苦者最好的安慰,我恐怕现在无法为您提供太多帮助。我想对您说的仅有这一点:如果我能偿还我欠您的,我会毫不犹豫地为您效劳。虽然我能做的不多,但我能做的,您可以非常确信我一定会做。我知道,如果死亡来临,普通人会害怕,但它不会让您害怕,您把它视为对忠诚的至高荣誉的奖赏。祈祷您能避免这杯苦酒。我不担心您会抱怨任何苦难;只有在通过顺从您能避免它们时,那才是智者的行为。告诉我,如果您能的话,我可以为您做任何您希望我做的事情。将军将在星期三回去。恭敬的,您的仆人,R.T.

正如您可能猜到的,这封看似无害的信中包含了一个隐藏的信息,以下是用粗体显示的部分:

尊敬的约翰爵士:希望,这是受苦者最好的安慰,我恐怕现在无法为您提供太多帮助。我想对您说的仅有这一点:如果我能偿还我欠您的,我会毫不犹豫地为您效劳。虽然我能做的不多,但我能做的,您可以非常确信我一定会做。我知道,如果死亡来临,普通人会害怕,但它不会让您害怕,您把它视为对忠诚的至高荣誉的奖赏。祈祷您能避免这杯苦酒。我不担心您会抱怨任何苦难;只有在通过顺从您能避免它们时,那才是智者的行为。告诉我,如果您能的话,我可以为您做任何您希望我做的事情。将军将在星期三回去。恭敬的,您的仆人,R.T.

这个空白密码利用标点符号后的每三个字母来让约翰爵士知道“教堂东端的面板滑动”。有传闻说,后来在城堡的一堵墙的壁龛中发现了一个狭窄楼梯的遗迹。这个通道在发现时被封锁,但它可能是约翰爵士在 1642 年左右的逃生通道。

这种临时逃生如果使用传统的密码是无法实现的。只有通过巧妙地隐藏消息,使用隐写术,作者才能如此迅速地将其交到约翰爵士手中。而空白密码的美妙之处在于,即使约翰爵士不知道模式,但怀疑消息存在,他也能相当迅速地找到它。

如果约翰爵士的朋友更小心一点,隐藏的是加密的密文而非明文,约翰爵士可能就无法在剩余的短时间内解密出消息——除非他事先得知了密码的类型和密钥。

目标

编写代码,找出空白密码中标点符号后隐藏的字母,并让用户选择在标点符号后查找的字母数量,以寻找解决方案。

策略与伪代码

空白密码依赖于发送者和接收者都知道的重复模式。例如,每三个单词可能是实际消息的一部分,或者更好的是,每三个单词的最后一个字母。在 Trevanion 密码中,它是标点符号后的第三个字母。

要找到 Trevanion 密码,假设标点符号是开始计数的信号,然后编写代码找到每个标点符号后的第 n个字母,并将这些字母保存到字符串或列表中。一旦你弄清楚如何做,你就可以轻松编辑代码,以便它适用于任何起始点,例如每个大写字母单词、每个单词的第二个字母,或者每三个单词的起始字母。

唯一的争议点涉及标点符号。例如,空白密码的作者是否希望将标点符号包括在明文中?如何处理计数范围内出现的第二个标点符号?如果两个标点符号连续出现会怎样?

如果仔细观察 Trevanion 密码,你应该会看到由于重复使用词语’tis,导致了双重标点符号。消息结尾处还有一串标点符号,作者在此提供了自己的首字母。为了解决这个问题,约翰爵士和他的朋友可能在约翰爵士入狱之前就已经制定了一些规则,或者约翰爵士通过反复试验得出了这些规则。

根据消息结尾,标点符号不包括在字母计数内。如果约翰爵士的朋友打算将它们包括在内,那么隐藏消息将以大写的T结尾,因为T在标点符号后面三个字符,而关键是这不是三个字母。这意味着,如果读者在计数范围内遇到标点符号,他们必须重新开始计数。

所以这些是规则:

  • 每遇到一个标点符号,启动字母计数。

  • 如果遇到标点符号,则重置计数。

  • 标点符号不能成为明文消息的一部分。

由于你可能不知道字母计数应该是多少,因此编写代码以检查所有计数,直到用户提供的限制。伪代码相当简单:

Load a text file and strip it of whitespace
Get user input on how many letters after punctuation to look ahead and examine
Loop through number of letters from 1 to this lookahead value
    Start an empty string to hold the translation
    Start a counter
    Start a ➊first-found marker and set to False
    Loop through characters in the text
        If character is punctuation
            Counter = 0
            First-found = True
        Otherwise, if ➋first-found is True
            Counter + 1
        If counter = lookahead value
            Add character to translation string
    Display translation for this lookahead value

请注意,第一次找到的变量➊将保持False,直到遇到标点符号为止,此时它将设置为True ➋。这防止程序在找到第一个标点符号之前进行计数。

现在你准备好编写代码了!

Trevanion 密码代码

本节中的代码将找到使用特定字母数编码的 Trevanion 类型空白密码,该字母数位于每个标点符号后。你还需要包含 Trevanion 密码的文本文件。你可以从www.nostarch.com/impracticalpython/下载脚本和文本文件,分别命名为null_cipher_finder.pytrevanion.txt。请将这些文件保存在同一文件夹中。

加载文本

Listing 5-1 导入一些有用的模块并加载包含空密码的文本文件。

null_cipher_finder.py, 第一部分

➊ import sys
   import string

➋ def load_text(file):
       """Load a text file as a string."""
    ➌ with open(file) as f:
        ➍ return f.read().strip()

Listing 5-1: 导入模块并加载空密码文本

首先,导入现在已经熟悉的 sys 模块,以便处理用户输入过程中可能发生的异常 ➊。还需要导入 string 模块,以便访问一些有用的常量集合,比如字母和标点符号。

接下来,定义一个函数来加载包含空密码的文本文件 ➋。这个函数类似于你在 第二章 中用来加载字典文件的函数。稍后 main() 函数会调用它来实际加载文件。

使用 with 打开文件 ➌ 来启动 load_text() 函数。通过使用 with,你可以确保文件在加载后会自动关闭。使用 read() 加载文件内容,并使用 strip() 去除前后的空白字符。注意,你可以在同一行中结合 return 语句来完成这一步 ➍。

寻找隐藏的信息

Listing 5-2 定义了一个查找隐藏信息的函数。它接受两个参数,第一个是消息,即去除空白字符后的原始文本文件,第二个是标点符号后要检查的字母数。这个检查值是从用户那里获得的,作为 main() 函数的一部分。

null_cipher_finder.py, 第二部分

def solve_null_cipher(message, lookahead):
    """Solve a null cipher based on number of letters after punctuation mark.

    message = null cipher text as string stripped of whitespace
    lookahead = endpoint of range of letters after punctuation mark to examine
    """
 ➊ for i in range(1, lookahead + 1):
        ➋ plaintext = ''
           count = 0
           found_first = False
        ➌ for char in message:
            ➍ if char in string.punctuation:
                   count = 0
                   found_first = True
            ➎ elif found_first is True:
                   count += 1
            ➏ if count == i:
                   plaintext += char
        ➐ print("Using offset of {} after punctuation = {}".
                 format(i, plaintext))
           print()

Listing 5-2: 搜索隐藏的字母

lookahead 值视为 for 循环中的范围终点,这样你就可以检查消息中所有介于其间的字母,查找是否存在隐藏的信息。将范围设置为 (1, lookahead + 1) ➊;这样,你就可以从标点符号后的第一个字母开始,并将用户选择的字母包含在评估中。

现在,分配几个变量 ➋。首先,初始化一个空字符串用于存储翻译后的明文。然后,将计数器设置为 0。最后,将 found_first 变量设置为 False。记住,程序使用这个变量来推迟计数,直到遇到第一个标点符号。

接下来,开始遍历消息中的字符 ➌。如果遇到标点符号,重置计数器为 0,并将 found_first 设置为 True ➍。如果已经找到了标点符号,而当前字符不是标点符号,则将计数器加 1 ➎。如果找到了你要找的字母—即计数器已经达到当前的 lookahead 值(i)—则将字母添加到明文字符串中 ➏。

当你检查完消息中所有字符以匹配当前的 lookahead 值后,显示当前的密钥和翻译 ➐。

定义 main() 函数

Listing 5-3 定义了 main() 函数。你可能记得在 第三章 中提到过,main() 函数就像程序的项目经理:它接收输入,跟踪进度,并告诉其他函数何时工作。

null_cipher_finder.py, 第三部分

def main():
    """Load text, solve null cipher."""
    # load & process message:
 ➊ filename = input("\nEnter full filename for message to translate: ")
 ➋ try:
        loaded_message = load_text(filename)
    except IOError as e:
        print("{}. Terminating program.".format(e), file=sys.stderr)
        sys.exit(1)
 ➌ print("\nORIGINAL MESSAGE =")
    print("{}".format(loaded_message), "\n")
    print("\nList of punctuation marks to check = {}".
          format(string.punctuation), "\n")   

    # remove whitespace:
 ➍ message = ''.join(loaded_message.split())

    # get range of possible cipher keys from user:
 ➎ while True:
     ➏ lookahead = input("\nNumber of letters to check after " \
                          "punctuation mark: ")
     ➐ if lookahead.isdigit():
            lookahead = int(lookahead)
            break
        else:
         ➑ print("Please input a number.", file=sys.stderr)
    print()

    # run function to decode cipher
 ➒ solve_null_cipher(message, lookahead)

列表 5-3:定义了 main() 函数

首先询问用户文件的名称(名称 + 扩展名) ➊,然后使用 try 调用 load_text() 函数 ➋。如果找不到文件,打印红色错误信息——对于使用 IDLE 窗口的用户——并使用 sys.exit(1) 退出程序,其中 1 表示错误终止。

打印消息后,列出 string 模块中的标点符号 ➌。程序将只识别这个列表中的字符作为标点符号。

接下来,取出已加载的消息并移除所有空格 ➍。你只会计算字母和标点符号,因此空格会妨碍操作。启动一个 while 循环,在用户输入无效值时继续请求输入 ➎。询问用户要检查标点符号后几个字母 ➏。这将作为一个范围,起始值为 1,结束值为用户选择的数字加 1。如果输入值是数字 ➐,将其转为整数,因为 input 返回的是字符串。然后,使用 break 退出循环。

如果用户输入无效值,如“Bob”,则使用 print 语句请求一个数字,并且对于 shell 用户,使用 sys.stderr 使字体变为红色 ➑。然后,while 循环会重复请求输入。

lookahead 变量与 message 一起传递给 solve_null_cipher 函数 ➒。现在只剩下调用 main() 函数。

运行 main() 函数

在全局空间中,通过调用 main() 完成代码——但仅当程序以独立模式运行,而不是被导入到另一个程序中时(列表 5-4)。

null_cipher_finder.py, 第四部分

if __name__ == '__main__':
    main()

列表 5-4:调用了 main() 函数

以下是使用 Trevanion 密码作为输入的完成程序的示例输出:

Enter full filename for message to translate: trevanion.txt

ORIGINAL MESSAGE =
Worthie Sir John: Hope, that is the beste comfort of the afflicted, cannot
much, I fear me, help you now. That I would saye to you, is this only: if ever
I may be able to requite that I do owe you, stand not upon asking me. 'Tis not
much I can do: but what I can do, bee you verie sure I wille. I knowe that,
if deathe comes, if ordinary men fear it, it frights not you, accounting for
it for a high honour, to have such a rewarde of your loyalty. Pray yet that
you may be spared this soe bitter, cup. I fear not that you will grudge any
sufferings; onlie if bie submission you can turn them away, 'tis the part of a
wise man. Tell me, an if you can, to do for you anythinge that you wolde have
done. The general goes back on Wednesday. Restinge your servant to command.
R.T.

List of punctuation marks to check = !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~

Number of letters to check after punctuation mark: 4

Using offset of 1 after punctuation = HtcIhTiisTbbIiiiatPcIotTatTRRT

Using offset of 2 after punctuation = ohafehsftiuekfftcorufnienohe

Using offset of 3 after punctuation = panelateastendofchapelslides

Using offset of 4 after punctuation = etnapthvnnwyoerroayaitlfogt

在此输出中,程序已检查到标点符号后的第四个字母,但正如您所看到的,它是使用标点符号后面的三个字母找到解决方案的。

项目 #11:编写空密码

这是一个基于每个单词开头的非常弱的空密码的未完成示例。花一点时间尝试完成这个句子:

H__________ e__________ l__________ p__________ m__________ e__________.

你可能觉得很困难,因为无论是使用字母还是完整单词,要产生一个不显得生硬且不引起怀疑的空密码都需要艰苦的工作和时间。问题的核心是上下文。如果密码包含在通信中,那么这些通信必须连贯,以避免引起怀疑。这意味着它必须涉及一个相关话题,并且在合理的句子数范围内保持与该话题的一致性。正如你可能看到的,草拟关于任何话题的一个句子都不是一件容易的事!

关键是要可靠地避免上下文,而一个很好的方法就是使用列表。没有人期望购物清单是严格有序的或者合乎逻辑的。列表还可以根据接收者量身定制。例如,通讯员可能会讨论书籍或电影,并交换他们最喜欢的书单或影单。囚犯可能会开始学习外语,并从导师那里定期接收词汇表。商人可能会从其中一个仓库收到每月的库存清单。通过使用列表,即使单词被打乱,仍然可以尊重上下文,以便正确的字母出现在正确的位置。

目标

编写代码,将空白密码隐藏在一个单词列表中。

列表密码代码

list_cipher.py 代码,在 清单 5-5 中,将一个空白密码嵌入在一个字典单词的列表中,伪装成词汇训练。你还需要在 第二章 和 第三章 中使用过的 load_dictionary.py 程序。你可以从 www.nostarch.com/impracticalpython/ 下载这个文件以及以下脚本。最后,你还需要在 第二章 和 第三章 中使用过的字典文件。你可以在 第 2-1 表 中找到适合的在线字典,位置在 第 20 页。所有上述文件应该保存在同一个文件夹中。

list_cipher.py

➊ from random import randint
   import string
   import load_dictionary

   # write a short message that doesn't contain punctuation or numbers!
   input_message = "Panel at east end of chapel slides"

   message = ''
   for char in input_message:
    ➋ if char in string.ascii_letters:
           message += char
   print(message, "\n")
➌ message = "".join(message.split())

➍ # open dictionary file
   word_list = load_dictionary.load('2of4brif.txt')

   # build vocabulary word list with hidden message
➎ vocab_list = []
➏ for letter in message:
       size = randint(6, 10)
    ➐ for word in word_list:
           if len(word) == size and word[2].lower() == letter.lower()\
           and word not in vocab_list:
               vocab_list.append(word)
               break

➑ if len(vocab_list) < len(message):
       print("Word List is too small. Try larger dictionary or shorter message!")
   else:
       print("Vocabulary words for Unit 1: \n", *vocab_list, sep="\n")

清单 5-5:在列表中隐藏空白密码

首先,导入 random 模块的 randint() 函数 ➊。这允许选择一个(伪)随机的整数值。然后加载 string 模块,以访问 ASCII 字母。最后,导入你的 load_dictionary 模块。

接下来,编写一条简短的秘密消息。请注意,相关的注释禁止使用标点符号或数字。尝试在字典文件的内容中使用这些会引发问题。因此,通过检查 string.ascii_letters 中的成员来筛选出除了字母以外的所有内容,string.ascii_letters 包含大写字母和小写字母 ➋:

'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

显示消息后,去除空白字符 ➌。加载字典文件 ➍,并开始一个空列表来保存词汇单词 ➎。

使用 for 循环遍历消息中的每个字母 ➏。命名一个 size 变量,并使用 randint() 函数为其分配一个介于 6 到 10 之间的随机值。这个变量将确保单词足够长,看起来像是一个可信的词汇。你可以根据需要将最大值设置得更高。

嵌套另一个for循环,使用它来遍历字典中的单词 ➐,检查它们的长度是否与size变量相符,并将第 2 个索引位置(即单词的第三个字母)的小写字母与消息循环中的当前(小写)字母进行比较。你可以更改单词中的索引值,但要确保它不超过最低可能的size变量减去 1!最后的比较防止同一个单词被使用两次。如果单词通过测试,则将其附加到vocab_list中,并继续处理消息中的下一个字母。

一个典型的字典文件应包含足够的单词来加密一个简短的消息。但是,为了安全起见,使用条件语句检查vocab_list的长度是否不小于消息的长度 ➑。如果较短,那么你在到达消息末尾之前就已经用完了单词,应该给用户打印警告。否则,打印单词列表。

列表密码输出

下面是代码的输出(我已将每第三个字母突出显示,以便阅读,尽管没有任何辅助工具消息也很容易找到):

Panelateastendofchapelslides

Vocabulary words for Unit 1:

alphabets
abandoning
annals
aberration
ablaze
abandoned
acting
abetted
abasement
abseil
activated
adequately
abnormal
abdomen
abolish
affecting
acceding
abhors
abalone
ampersands
acetylene
allegation
absconds
aileron
acidifying
abdicating
adepts
absent

使用字符宽度一致的字体并堆叠单词会严重削弱密码的安全性。我们将在《拯救玛丽》一章中,第 102 页讨论如何解决这个问题。

总结

在本章中,你编写了一个程序,揭示了特雷瓦尼翁型空密码中的隐藏信息。然后,你又编写了第二个程序,生成了一个空密码,并将其隐藏在语言学习者的词汇表中。在接下来的实践项目中,你可以探索如何使这个列表密码更加安全。

进一步阅读

关于玛丽·斯图亚特女王和约翰·特雷瓦尼翁爵士的更多详情可以在《神秘信息:密码和暗号的历史》(企鹅集团,2009 年)和《密码书:从古埃及到量子密码学的秘密科学》(Anchor,2000 年)中找到,前者作者为加里·布莱克伍德,后者为西蒙·辛格。

实践项目

现在你已经是一个空密码的专家,试试看能否改变玛丽·斯图亚特女王的命运,然后偷看一下约翰爵士最机密的信件。

拯救玛丽

编码的最佳部分是思考问题及如何解决它们。让我们回顾一下玛丽·斯图亚特女王的悲剧案例。我们知道的情况如下:

  • 玛丽被禁止通信,因此信件必须通过走私方式送入。这意味着叛徒吉尔伯特·吉福德无法从中除去。吉福德是玛丽唯一认识的、能送信的人。

  • 玛丽和她的通信者对不安全的密码过于信任,因此言语过于轻率。如果他们少一点信心,也许会更加忍耐。

  • 玛丽的狱卒们发现了一个明显的密码本,他们认为里面包含了有罪证据,便继续工作,直到找到了它。

伪双重间谍吉福德并未掌握玛丽所使用密码的细节。现在,假设玛丽使用了空白密码。如果信件的内容有些煽动性——但并非叛国——那么她的监禁者可能会忽视这条消息。如果进行过粗略检查,使用可变模式可能足以阻止密码分析师。

正如你所看到的,将空白密码隐藏在列表中比隐藏在信件中要容易得多。支持玛丽的家庭列表可以用作此目的。这些家庭可以是已知的支持者,或者在马基雅维利式的扭曲中,既有朋友有敌人!这条消息不会公开煽动叛乱,但足够接近,以至于缺乏加密会暗示根本没有使用任何形式的加密。

对于这个练习项目,编写一个程序,将消息“Give your word and we rise”嵌入一个姓氏列表中。为了隐藏消息中的字母,从第二个名字的第二个字母开始,接着是第三个名字的第三个字母,然后在剩余的单词中交替使用第二个和第三个字母。

除了未使用的名字外,在列表的前面包含“Stuart”和“Jacob”作为空白词,以帮助隐藏密码的存在。不要在这些空白名字中嵌入密码字母,并且在为下一个单词选择密码字母位置时,完全忽略它们;如果第二个字母在空白名字之前的单词中已使用,则在空白名字之后的单词中使用第三个字母。空白密码将占据以下加粗字母的位置(空白词的位置由你决定,但不要让它们影响模式):

First Second Third STUART Fourth Fifth JACOB Sixth Seventh Eighth

程序可以垂直或水平打印列表。姓名列表应该用一条简短的消息进行可信的介绍,但这条消息不应该是密码的一部分。

姓名列表可以从 www.nostarch.com/impracticalpython/ 下载,作为 supporters.txt 并加载为标准字典文件。你可以在附录和网上找到解决方案,文件名为 save_Mary_practice.py

科尔切斯特捕捉

你不再是那个喝醉的傻瓜,在接下来的信件到达科尔切斯特城堡时,负责看守囚犯约翰·特雷瓦尼昂:

约翰爵士:你们这些人真是奇怪又太难了。不过,我们会团结起来,像你一样坚持到底。还有谁能爱敌人,失败时坚持不懈,仇恨并绝望?在我们都能做的情况下,让我们感受希望。- R.T.

这句话看起来很笨拙,即使对于 17 世纪来说也是如此,你决定在将其交给你的囚犯之前,仔细检查一下。

编写一个 Python 程序,输入 n,并根据每个第 n 个单词的第 n 个字母后的字母检查并显示空白密码。例如,输入 2 将会在这条消息中找到加粗字母:

所以,冷没能让女人感到满意。

你可以从 nostarch.com/impracticalpython/ 下载消息的文本文件,文件名为 colchester_message.txt。解决方案可以在附录和在线找到,文件名为 colchester_practice.py。请将文本和 Python 文件保存在同一个文件夹中。

第六章:用隐形墨水书写

image

2012 年秋季,犯罪剧情剧 Elementary 在 CBS 电视网首播。这部剧重新构思了 21 世纪纽约的 Sherlock Holmes 神话,主演 Jonny Lee Miller 饰演 Holmes,Lucy Liu 饰演他的搭档 Joan Watson 博士。在 2016 年的一集(“你抓住了我,那谁抓住了你?”)中,Sherlock 的疏远父亲 Morland Holmes 雇佣 Joan 查找他组织中的间谍。她迅速通过在一封电子邮件中发现 Vigenère 密码来破案。但一些剧迷对此不满:Vigenère 密码并不隐蔽,像 Morland Holmes 这样聪明的人怎么可能自己没发现?

在这个项目中,你将使用隐写术解决这个难题,但不会像在 第五章 中那样使用空密码。为了隐藏这个消息,你将使用一个名为 python-docx 的第三方模块,它允许你通过 Python 直接操作 Microsoft Word 文档来隐藏文本。

项目 #12:隐藏 Vigenère 密码

Elementary 的这一集里,中国投资者雇佣 Morland Holmes 的咨询公司与哥伦比亚政府谈判石油许可证和钻探权。一年过去了,最后一刻,一位竞争对手抢先达成了交易,留下中国投资者空手而归。Morland 怀疑自己团队中的某个成员背叛了他,便让 Joan Watson 独自进行调查。Joan 通过在一封电子邮件中发现 Vigenère 密码找到了间谍。

剧透警告

解密后的内容从未提及,间谍在随后的剧集中被谋杀。

Vigenère 加密,也被称为不可破译的密码,毫无疑问是有史以来最著名的加密方法。它由法国学者 Blaise de Vigenère 于 16 世纪发明,是一种多表替换加密方法,在最常用的版本中,采用了一个单一的关键字。这个关键字,比如 BAGGINS,会反复打印在明文上,就像在 图 6-1 中所示的消息一样。

image

图 6-1:使用 Vigenère 加密关键字 BAGGINS 打印在上方的明文消息

然后使用字母表的表格,或称 表格,来加密消息。图 6-2 是 Vigenère 表格前五行的示例。请注意,每一行中的字母都会向左移动一个字母。

image

图 6-2:Vigenère 表格的一部分

明文字母上方的关键字字母决定了用于加密的行。例如,要加密 sspeak 中,注意它上方的关键字字母是 B。往下找到 B 行,并横向阅读到明文 s 位于列顶部的位置。使用交点处的 T 作为密文字母。

图 6-3 显示了使用维吉尼亚密码加密的完整信息示例。如果这样的文本出现在文档中,肯定会引起注意,并成为审查的对象!

image

图 6-3:使用维吉尼亚密码加密的信息

维吉尼亚密码一直未被破解,直到 19 世纪中期,计算机前身的发明者查尔斯·巴贝奇意识到,使用一个短的关键字与长消息配合时,会导致重复的模式,这些模式可以揭示关键字的长度,并最终破解出密钥。维吉尼亚密码的破译对专业密码学是一次重创,维多利亚时代的原版《福尔摩斯与华生》时期,密码学领域没有取得显著进展。

这种密码的存在也导致了 Elementary 这一集中的“难以置信”问题。为什么需要外部顾问来查找这么明显可疑的电子邮件?让我们看看是否能用 Python 提供一个合理的解释。

目标

假设你是这一集中的公司间谍,并使用 Python 将一个总结竞标细节的秘密信息隐藏在一个正式文本文件中。从未加密的信息开始,到加密版本为止。

平台

你的程序应当能够与广泛使用的文字处理软件兼容,因为输出文件需要在不同的公司之间共享。这意味着需要使用适用于 Windows 的 Microsoft Office 套件,或兼容的 macOS 或 Linux 版本。将输出限制为标准的 Word 文档也意味着硬件问题由 Microsoft 承担!

因此,本项目是在 Windows 版 Word 2016 上开发的,结果通过 Word for Mac v16.16.2 进行了检查。如果你没有 Word 的许可证,可以使用免费的 Microsoft Office Online 应用,访问 products.office.com/en-us/office-online

如果你目前使用 Word 的替代软件,如 LibreOffice Writer 或 OpenOffice Writer,你仍然可以打开并查看本项目中使用和生成的 Word (.docx) 文件;然而,正如在 “检测隐藏信息” 中所讨论的,隐藏的信息很可能会被破坏,详见 第 119 页。

策略

你是一名具有初级 Python 知识的会计师,工作在一个非常聪明且多疑的人手下。你从事的项目具有高度保密性,采用了诸如电子邮件过滤器等控制措施来保持机密性。如果你成功地将信息泄露出去,必定会进行彻底的调查。因此,你需要在电子邮件中隐藏一条明显可疑的信息,无论是直接包含在邮件中还是作为附件,且要避免初步检测和后续的内部审计。

以下是一些约束条件:

  • 你不能直接将信息发送到竞争公司,只能通过中介发送。

  • 你需要将消息打乱,以避开搜索关键词的电子邮件过滤器。

  • 你需要将加密信息隐藏起来,以免引起怀疑。

中介设置起来很容易,而且互联网上有很多免费的加密网站——但最后一项就更有问题了。

隐写术是答案,但正如你在上一章中看到的,将一条简短的信息隐藏在空白密码中并不是一项简单的任务。替代技术包括将文本行垂直或单词水平地微调,改变字母的长度,或使用文件的元数据——但你是一个会计师,Python 知识有限,时间更少。如果能像过去的隐形墨水那样有一种简单的方法该多好。

创建隐形墨水

在这个电子墨水的时代,隐形墨水或许正好够疯狂,足以奏效!一种隐形字体可以轻松地阻止在线文档的视觉浏览,并且在纸质打印件中也根本不存在。由于内容被加密,数字过滤器在寻找类似竞标或生产油田的西班牙名字时将什么也找不到。最棒的是,隐形墨水使用起来非常简单——只需将前景文本设置为背景颜色。

格式化文本和改变颜色需要使用像 Microsoft Word 这样的文字处理器。要在 Word 中制作隐形电子墨水,你只需要选择一个字符、单词或行,并将字体颜色设置为白色。接收者然后需要选择整个文档,并使用高亮工具(参见图 6-4)将选定文本涂成黑色,从而隐藏标准的黑色字母并将隐藏的白色字母显现出来。

image

图 6-4:Word 2016 中的文本高亮颜色工具

仅在 Word 中选择文档并不会显示白色文本(图 6-5),因此,除非某人非常怀疑,否则很难发现这些隐藏的消息。

image

图 6-5:上图:Word 文档中可见的假消息的一部分;中图:使用 CTRL-A 选中的文档;下图:使用高亮工具将高亮颜色设置为黑色,揭示真实消息

当然,你可以仅通过文字处理器来完成所有这些工作,但有两种情况采用 Python 方法更为合适:1)当你需要加密一条长消息,不想手动插入并隐藏所有行时,2)当你需要发送多条消息时。正如你将看到的,简短的 Python 程序将大大简化这个过程!

考虑字体类型、字距和字形调整

放置隐形文本是一个关键的设计决策。一个选择是使用假消息中可见单词之间的空格,但这可能会触发与间距相关的问题,使得最终产品看起来可疑。

比例字体使用可变的字符宽度来提高可读性。示例字体有 Arial 和 Times New Roman。等宽字体则使用固定的字符宽度,以便支持文本对齐和识别单独的字符,特别是像(或{这样的细小字符。因此,等宽字体在编程界面中非常受欢迎。示例字体有 Consolas 和 Courier New。

字距调整是一个排版过程,用来调整单个字符字形之间的间距和重叠,以提高它们的视觉效果。字间距调整(tracking)用于调整整行或整块文本的字符间距,目的相同。这些调整有助于可读性,确保字母之间既不会过于紧密以至于无法区分,也不会过于分散以至于单词无法识别。请注意,我们是读单词,而不是字母。如果你有疑问,试试这样读:peopl raed wrds nt lttrs. 当然,语境也有帮助。

字母对之间的字距调整首先进行,然后是字间距调整(tracking),在此过程中,字母对的相对字距得以保持。如前所述,这些可变宽度和自动修正可能会导致问题,尤其是在你试图隐藏使用比例字体的单词之间的字符时:

伟大的思想没有什么是微不足道的。 比例字体,没有隐藏字母
伟大的思想没有什么是微不足道的。 比例字体,单词之间隐藏了字母
To$a3great.mind2nothingKis little. | 隐藏字母揭示($3.2K)
伟大的思想没有什么是微不足道的。 等宽字体,没有隐藏字母
伟大的思想没有什么是微不足道的。 等宽字体,单词之间隐藏了字母。
To``$``a``3``great``.``mind``2``nothing``K``is little. 隐藏字母揭示($3.2K)

如果使用等宽字体,一致的间距提供了一个方便的隐藏位置。但是,由于专业信件更可能使用比例字体,因此隐形墨水技术应该集中在更易控制的行间空隙上。

在段落之间使用空行是最简单的编程和阅读方式,而且它不需要很长的虚假消息,因为你可以简明扼要地总结一个提案的要点。这一点非常重要,因为你不希望在可见的虚假消息后附加空白页。因此,你的隐藏消息的占用空间应小于虚假消息的占用空间。

避免问题

在开发软件时,反复问自己一个好问题是:“用户怎么会把这个搞砸?”这里可能出现的问题之一是加密过程会改变隐藏信息中的字母,导致字距和字形调整可能会使某个单词越过换行符,从而导致自动换行。这会导致伪造信息中的段落之间出现不均匀和可疑的空隙。避免这种情况的一种方法是在输入真实信息的每一行时稍微提前按下 ENTER 键,这样可以在行尾留出一些空白空间,以便适应加密过程中可能发生的变化。当然,你仍然需要验证结果。假设代码能正常工作就像假设詹姆斯·邦德已经死了一样危险!

使用 python-docx 操控 Word 文档

一个名为 python-docx 的免费第三方模块允许 Python 操作 Microsoft Word(.docx)文件。要下载和安装本书中提到的第三方模块,你将使用首选安装程序(pip),这是一种包管理系统,可以轻松地安装基于 Python 的软件。对于 Windows 和 macOS 上的 Python 3,版本 3.4 及更高版本自带 pip;对于 Python 2,pip 的预安装从版本 2.7.9 开始。Linux 用户可能需要单独安装 pip。如果你发现需要安装或升级 pip,可以参照 pip.pypa.io/en/stable/installing/ 上的说明,或者在网上搜索如何在特定操作系统上安装 pip。

使用 pip 工具,你可以通过在命令行、PowerShell 或终端窗口中运行 pip install python-docx 来安装 python-docx,具体取决于你的操作系统。关于 python-docx 的在线说明可以在 python-docx.readthedocs.io/en/latest/ 上找到。

对于这个项目,你需要理解 paragraphrun 对象。python-docx 模块通过以下层次结构组织数据类型:

  • document:包含多个 paragraph 对象的整个文档

  • paragraph:通过在 Word 中按 ENTER 键分隔的文本块;包含一个或多个 run 对象

  • run:具有相同 样式 的一串连接的文本

paragraph 被认为是一个 块级 对象,python-docx 对其的定义如下:“一个块级项目将其包含的文本流动到其左右边缘之间,每当文本超出右边界时,都会增加一行。对于 paragraph 对象,边界通常是页面边距,但如果页面布局为列格式,边界也可以是列边界;如果 paragraph 位于表格单元格内,边界则是单元格边界。表格也是一个块级对象。”

paragraph 对象具有多种属性,用于指定其在容器中的位置——通常是页面——以及它如何将内容分割成单独的行。你可以通过 paragraphParagraphFormat 属性访问 paragraph 的格式属性,并且可以使用 段落样式分组 来设置所有 paragraph 属性,或者直接应用它们到 paragraph

run 是一种内联级别的对象,出现在段落或其他块级对象中。run 对象具有只读的 font 属性,可以访问一个 font 对象。font 对象提供了用于获取和设置该 run 字符格式的属性。你需要这个功能来将隐藏消息的文本颜色设置为白色。

样式是指 Word 中段落和字符(run 对象)或两者组合的属性集合。样式包括常见的属性,如字体、颜色、缩进、行距等。你可能注意到这些属性在 Word 的“开始”功能区的样式窗格中有分组显示(见 图 6-6)。任何样式的变化——甚至是一个字母——都需要创建一个新的 run 对象。目前,只有在打开的 .docx 文件中存在的样式才可用。这一情况可能会在未来的 python-docx 版本中有所变化。

image

图 6-6:Microsoft Word 2016 中的样式窗格

你可以在 python-docx 中找到完整的样式使用文档,地址是 python-docx.readthedocs.io/en/latest/user/styles-using.html

下面是一个 python-docx 看到的段落和 run 的示例:

我是一个包含单个 run 的段落,因为我的所有文本都是相同的样式。

我是一个包含两个 run 的段落。我是第二个 run,因为我的样式变成了粗体。

我是一个包含三个 run 的段落。我是第二个 run,因为我的样式变成了粗体。第三个 run 是我最后一个单词。

如果这些内容有任何不清楚的地方,别担心。你不需要详细了解 python-docx。就像任何一段代码,你主要需要知道你想做什么。在线搜索应该能找到大量有用的建议和完整的代码示例。

注意

为了使这一切顺利进行,不要更改真实(隐藏)消息中的样式,并确保每行的结尾都通过手动按下 ENTER 键来强制换行。不幸的是,Word 没有自动换行所产生的软回车的特殊字符。因此,你无法进入一个已有自动换行的 Word 文档,并使用查找和替换将它们全部转换为强制回车。生活就是这样。

下载资源

你需要的外部文件可以从 www.nostarch.com/impracticalpython/ 下载,并应保存在与代码相同的文件夹中:

template.docx 一份格式化为正式 Holmes Corporation 样式、字体和边距的空白 Word 文档

fakeMessage.docx 没有信头和日期的假消息,格式为 Word 文档

realMessage.docx 真实消息的明文,未加信头和日期,格式为 Word 文档

realMessage_Vig.docx 使用维吉尼亚密码加密的真实消息

example_template_prep.docx 用于创建模板文档的假消息示例(程序运行时不需要此文件)

注意

如果你使用的是 Word 2016,制作空白模板文件的简单方法是编写假消息(包括信头)并保存文件。然后删除所有文本,再次保存文件并更改文件名。当你使用 python-docx 将这个空白文件分配给一个变量时,所有现有样式将被保留。当然,你也可以使用已经包含信头的模板文件,但为了更好地学习 python-docx,我们将在这里使用 Python 构建信头。

花点时间查看这前四个文档在 Word 中的样子。这些文件构成了elementary_ink.py程序的输入。假消息和真实消息——第二和第三项——也展示在图 6-7 和 6-8 中。

image

图 6-7: fakeMessage.docx 文件中的“假”文本

image

图 6-8: realMessage.docx 文件中的真实消息

请注意,真实消息包含一些数字和特殊字符。这些字符不会被我们将使用的维吉尼亚表格加密,我特意加入它们以强调这一点。理想情况下,当我们稍后添加维吉尼亚密码时,这些数字和字符应该被拼写出来(例如,将“3”拼写为“三”,将“%”拼写为“百分比”),以确保最大程度的保密。

伪代码

以下伪代码描述了如何加载两个消息和模板文档,将它们交织在一起并使用白色字体将真实消息隐藏在空行中,然后保存合成的消息。

Build assets:
In Word, create an empty doc with desired formatting/styles (template)
In Word, create an innocuous fake message that will be visible & have enough     blank lines to hold the real message
In Word, create the real message that will be hidden
Import docx to allow manipulation of Word docs with Python
Use docx module to load the fake & real messages as lists
Use docx to assign the empty doc to a variable
Use docx to add letterhead banner to empty doc
Make counter variable for lines in real message
Define function to format paragraph spacing with docx
For line in fake message:
    If line is blank and there are still lines in the real message:
        Use docx & counter to fill blank with line from real message
        Use docx to set real message font color to white
        Advance counter for real message
    Otherwise:
        Use docx to write fake line
    Run paragraph spacing function
Use docx to save final Word document

代码

Listing 6-1 中的elementary_ink.py程序加载了真实消息、假消息和空白模板文档。它使用白色字体将真实消息隐藏在假消息的空行中,然后将合成的消息保存为一份无害且专业的信函,可以附加到电子邮件中。你可以从* www.nostarch.com/impracticalpython/*下载代码。

导入 python-docx,创建列表并添加信头

Listing 6-1 导入python-docx,将假消息和真实消息中的文本行转换为列表项,加载设置样式的模板文档,并添加信头。

elementary_ink.py, 第一部分

   import docx
➊ from docx.shared import RGBColor, Pt

➋ # get text from fake message & make each line a list item
   fake_text = docx.Document('fakeMessage.docx')
   fake_list = []
   for paragraph in fake_text.paragraphs:
       fake_list.append(paragraph.text)

➌ # get text from real message & make each line a list item
   real_text = docx.Document('realMessage.docx')
   real_list = []
   for paragraph in real_text.paragraphs:
    ➍ if len(paragraph.text) != 0:  # remove blank lines
           real_list.append(paragraph.text)

➎ # load template that sets style, font, margins, etc.
   doc = docx.Document('template.docx')

➏ # add letterhead
   doc.add_heading('Morland Holmes', 0)
   subtitle = doc.add_heading('Global Consulting & Negotiations', 1)
   subtitle.alignment = 1
   doc.add_heading('', 1)
➐ doc.add_paragraph('December 17, 2015')
   doc.add_paragraph('')

Listing 6-1:导入 python-docx,加载重要的 .docx 文件,并添加信头

导入 docx 模块后——而不是“python-docx”——使用 docx.shared 来访问 docx 模块中的颜色(RGBColor)和长度(Pt)对象 ➊。这些将允许你更改字体颜色并设置行间距。接下来的两个代码块将假消息 ➋ 和真实消息 ➌ 的 Word 文档加载为列表。在每个 Word 文档中按下 ENTER 键的位置决定了这些列表中的项。为了隐藏真实消息,删除任何空白行,使得消息尽可能短 ➍。现在,你可以使用列表索引来合并这两条消息,并跟踪它们各自的来源。

接下来,加载包含预先设置样式、字体和页边距的模板文档 ➎。docx 模块将写入这个变量,并最终将其保存为最终文档。

输入准备好之后,格式化最终文档的信头,使其与 Holmes Corporation 的信头一致 ➏。add_heading() 函数添加一个带有文本和整数参数的标题样式段落。整数 0 表示最高级别的标题,或从模板文档继承的标题样式。副标题使用 1 格式化,这是下一个可用的标题样式,并且居中对齐,再次使用整数 10 = 左对齐,2 = 右对齐)。请注意,当你添加日期时,不需要提供整数 ➐。当没有提供参数时,默认情况下继承现有样式层级,在模板中是左对齐的。此代码块中的其他语句只是添加空行。

格式化与交错消息

列表 6-2 进行实际的工作,格式化行间距并交错消息。

elementary_ink.py, 第二部分

➊ def set_spacing(paragraph):
       """Use docx to set line spacing between paragraphs."""
       paragraph_format = paragraph.paragraph_format
       paragraph_format.space_before = Pt(0)
       paragraph_format.space_after = Pt(0)

➋ length_real = len(real_list)
   count_real = 0  # index of current line in real (hidden) message

   # interleave real and fake message lines
   for line in fake_list:
    ➌ if count_real < length_real and line == "":
        ➍ paragraph = doc.add_paragraph(real_list[count_real])
        ➎ paragraph_index = len(doc.paragraphs) - 1
           # set real message color to white
           run = doc.paragraphs[paragraph_index].runs[0]
           font = run.font
        ➏ font.color.rgb = RGBColor(255, 255, 255) # make it red to test
        ➐ count_real += 1
       else:
        ➑ paragraph = doc.add_paragraph(line)

    ➒ set_spacing(paragraph)

➓ doc.save('ciphertext_message_letterhead.docx')

   print("Done")

列表 6-2:格式化段落并交错假消息与真实消息的行

定义一个函数,使用 python-docxparagraph_format 属性格式化段落之间的间距 ➊。隐藏行之前和之后的行间距设置为 0 点,以确保输出中段落之间没有可疑的大间隙,如 图 6-9 左侧的间隙。

image

图 6-9:没有 python-docx 段落格式化的假消息行间距(左)与有格式化的行间距(右)

接下来,通过获取存储真实消息的列表的长度来定义工作空间 ➋。记住,隐藏的真实消息需要比可见的假消息短,以便有足够的空白行容纳它。然后启动一个计数器,程序将用它来跟踪当前正在处理的真实消息中的行(列表项)。

由于从伪造信息创建的列表最长,并为真实信息设置了维度空间,因此通过两个条件循环遍历伪造信息:1)是否已达到真实信息的结尾,2)伪造列表中的一行是否为空 ➌。如果仍有真实信息行,而伪造信息行为空,则使用count_real作为real_list的索引,并使用python-docx将其添加到文档中 ➍。

通过获取doc.paragraphs的长度并减去 1 ➎,获取你刚刚添加的行的索引。然后使用此索引将真实信息行设置为一个run对象(它将是列表中的第一个run[0],因为真实信息使用单一的样式),并将其字体颜色设置为白色 ➏。由于程序现在已经在此块中从真实列表中添加了一行,count_real计数器将增加 1 ➐。

随后的else块处理从伪造列表中选择的行不是空的情况。在这种情况下,伪造信息行将直接添加到段落中 ➑。通过调用行间距函数set_spacing() ➒,完成for循环。

一旦超过了真实信息的长度,for循环将继续添加伪造信息的剩余部分——在此案例中是 Kurtz 先生的签名信息——并将文档作为 Word .docx 文件保存到最后一行 ➓。当然,在实际生活中,你应该使用一个不那么可疑的文件名,比如ciphertext_message_letterhead.docx

请注意,因为你正在使用基于伪造信息的for循环,循环结束后,即在你达到伪造列表中项的末尾后,无法再添加任何更多的隐藏行。如果你想要更多空间,你必须在伪造信息的底部输入硬回车,但要小心不要添加太多,以免强制分页并产生一个神秘的空白页!

运行程序,打开保存的 Word 文档,使用 CTRL-A 选择所有文本,然后将高亮颜色(见图 6-4)设置为深灰色,以查看两条信息。秘密信息应已被揭示(见图 6-10)。

image

图 6-10:使用深灰色高亮显示的 Word 文档,展示了伪造的信息和未加密的真实信息

添加维吉尼亚密码

目前的代码使用了真实信息的纯文本版本,因此任何更改文档高亮颜色的人都能读取并理解其中的敏感信息。既然你知道 Kurtz 先生是使用维吉尼亚密码加密的,那么返回并修改代码,将纯文本替换为加密文本。为此,找到以下行:

real_text = docx.Document('realMessage.docx')

这一行加载了真实信息的纯文本,所以将文件名更改为此处加粗显示的文件名:

real_text = docx.Document('realMessage_Vig.docx')

重新运行程序,通过选择整个文档并将高亮颜色设置为深灰色(见图 6-11)再次显示隐藏的文本。

image

图 6-11:用深灰色突出显示的 Word 文档,展示了假信息和加密后的真实信息

秘密信息应该是可见的,但对任何无法解读密码的人来说是无法阅读的。比较图 6-11 中的加密信息与图 6-10 中的未加密版本。请注意,数字和百分号在两个版本中都有出现。保留这些内容是为了展示与加密选择相关的潜在陷阱。你可能需要增强维吉尼亚密码,以包括这些字符——或者直接拼写出来。这样,即使你的信息被发现,你也会尽量避免留下关于主题的线索。

如果你想使用维吉尼亚密码来编码自己的信息,可以通过互联网搜索“在线维吉尼亚编码器”。你会找到多个网站,例如www.cs.du.edu/~snarayan/crypt/vigenere.html,可以在其中输入或粘贴明文。如果你想编写自己的 Python 程序来加密维吉尼亚密码,请参阅 Al Sweigart 的《Python 破解密码》(No Starch Press,2018)。

如果你自己尝试处理真实信息,无论是加密还是未加密的,确保使用与假信息中相同的字体。字体不仅指的是字体类型,比如 Helvetica 斜体,还包括字体大小,如 12 号。记住在“考虑字体类型、字距和行距”中提到的,在第 109 页,如果你尝试混合字体,尤其是比例字体和等宽字体,隐藏信息的行可能会换行,导致真实信息的段落之间出现不均匀的间距。

检测隐藏信息

琼·沃森或其他任何侦探,能快速找到你隐藏的信息吗?事实上,可能不会。事实上,在我写这些话的时候,我正在看《Elementary》的一集,琼正在通过阅读一箱电子邮件打印件调查一家公司!维吉尼亚密码的使用可能只是整个精心构思的系列剧中的一点懒散写作。尽管如此,我们还是可以推测出哪些因素可能会暴露你的秘密信息。

由于最终的投标很可能直到接近投标日期才被发送,因此搜索可以限制为投标确定后发送的通信,从而消除很多噪音。当然,侦探不会确切知道他们在寻找什么——甚至不知道是否内奸——这使得搜索范围很大。而且,信息也有可能通过电话交谈或秘密会议传递。

假设邮件的数量是可控的,并且正在追寻隐藏信息的假设,调查员可能会通过多种方式检测到你的隐形墨水。例如,只要这些白色的无意义加密词没有显示出来,Word 拼写检查器是不会标记它们的。如果你进行检查,选中并重设了某些隐藏词的字体颜色,即便它们的颜色恢复为白色,它们也会被永久暴露,拼写检查器会用一条指责性的红色波浪线标出这些词(参见图 6-12)。

image

图 6-12:之前暴露的隐形加密词被 Word 拼写和语法工具下划线标记

如果调查员使用与 Word 不同的软件打开文档,该软件的拼写检查器很可能会揭示隐藏的词汇(参见图 6-13)。这种风险在 Microsoft Word 占据市场主导地位的情况下有所减轻。

image

图 6-13:LibreOffice Writer 中的拼写检查器将突出显示隐形词汇。

其次,使用 CTRL-A 来高亮显示 Word 文档中的所有文本并不会显示隐藏的文本,但它会表明一些空白行比其他行要长(参见图 6-14),这会让非常细心的人察觉到有问题。

image

图 6-14:选择整个 Word 文档会揭示空白行长度的差异。

第三,使用某些电子邮件软件中的预览功能打开 Word 文档时,选中内容时(通过滑动或使用 CTRL-A)可能会揭示隐藏的文本(参见图 6-15)。

image

图 6-15:在 Yahoo! Mail 预览面板中选择整个文档会显示隐藏的文本。

但在 Yahoo! Mail 预览面板中选择隐藏文本会显示文本,而在 Microsoft Outlook 预览面板中则不会显示(参见图 6-16)。

image

图 6-16:在 Microsoft Outlook 预览面板中选择整个文档并不会显示隐藏的文本。

最后,将 Word 文档保存为纯文本(.txt)文件将移除所有格式,并将隐藏的文本暴露出来(图 6-17)。

image

图 6-17:将 Word 文档保存为纯文本(.txt)文件会显示隐藏的文本。

要使用隐写术隐藏秘密信息,你不仅需要隐藏信息的内容,还要隐藏信息的存在。我们的电子隐形墨水并不能总是保证这一点,但从间谍的角度来看,上述提到的弱点要么是他们犯了错误(理论上可以控制),要么是调查员采取了非常规且不太可能的行动,比如拷贝文本、以不同格式保存文件,或使用不常见的文字处理软件。假设《Elementary》中的间谍认为这些是可以接受的风险,那么电子隐形墨水提供了一个合理的解释,为什么公司内部调查会失败。

总结

在这一章中,你使用隐写术将加密信息隐藏在 Microsoft Word 文档中。你使用了一个名为python-docx的第三方模块,通过 Python 直接访问和操作文档。其他流行文档类型(如 Excel 表格)也有类似的第三方模块可供使用。

进一步阅读

你可以在 python-docx.readthedocs.io/en/latest/pypi.python.org/pypi/python-docx 上找到 python-docx 的在线文档。

用 Python 自动化无聊的事情(No Starch Press,2015)由阿尔·斯威加特(Al Sweigart)编写,涵盖了允许 Python 操作 PDF、Word 文件、Excel 表格等模块。第十三章包含了关于python-docx的有用教程,附录部分则介绍了如何使用 pip 安装第三方模块。

你可以在阿尔·斯威加特(Al Sweigart)的《破解 Python 密码学》(No Starch Press,2018)一书中找到适合初学者的密码学 Python 程序。

神秘信息(企鹅出版集团,2009)由加里·布莱克伍德(Gary Blackwood)编写,是一本有趣且图文并茂的关于隐写术和密码学的历史书籍。

练习项目:检查空白行数

通过编写一个函数来改进隐藏信息的程序,该函数将比较虚假信息中的空白行数与真实信息中的行数。如果没有足够的空间来隐藏真实信息,函数应该警告用户并告诉他们需要在虚假信息中添加多少空白行。在elementary_ink.py代码的副本中插入并调用该函数,调用位置在加载模板文档之前。你可以在附录中找到解决方案,也可以在 www.nostarch.com/impracticalpython/ 上找到 elementary_ink_practice.py 的代码。进行测试时,从同一网站下载 realMessageChallenge.docx 文件,并使用它作为真实信息。

挑战项目:使用等宽字体

重写 elementary_ink.py 代码以使用等宽字体,并在单词之间的空隙中隐藏你自己的短信息。有关等宽字体的描述,请参见 “考虑字体类型、字距和字间距” 以及 第 109 页。像往常一样,挑战项目没有提供解决方案。

第七章:利用遗传算法培育巨型大鼠

image

遗传算法是一种通用的优化程序,旨在解决复杂问题。它们于 1970 年代发明,属于进化算法的一类,因其模仿达尔文自然选择的过程而得名。它们特别适用于当问题了解不多、面对非线性问题或需要在大型搜索空间中寻找暴力解决方案时。最棒的是,它们是易于理解和实现的算法。

在本章中,你将使用遗传算法培育一种超级大鼠军队,来让它们在世界范围内造成恐慌。之后,你将转换角色,帮助詹姆斯·邦德在几秒钟内破解一个高科技保险箱。这两个项目将帮助你很好地理解遗传算法的机制和强大之处。

寻找所有可能解中的最佳解

遗传算法优化,意味着它们从一组可用的备选方案中选择最佳解(根据某些标准)。例如,如果你在寻找从纽约到洛杉矶的最快驾车路线,遗传算法永远不会建议你飞行。它只能从你提供的条件中选择。在优化过程中,这些算法比传统方法更快,并能避免过早收敛到次优解。换句话说,它们高效地搜索解空间,同时又足够全面,以避免选择一个好的答案而错过更好的答案。

穷举搜索引擎不同,后者通过纯粹的暴力方式尝试每一个可能的解,遗传算法并不会尝试所有可能的解。相反,它们会不断地对解进行评分,然后利用这些评分来进行“有根据的猜测”。一个简单的例子是“温暖-寒冷”游戏,在游戏中你通过别人根据你接近或搜索方向的提示,告诉你是越来越接近目标(温暖)还是远离目标(寒冷)。遗传算法使用适应度函数,类似于自然选择的过程,来丢弃“寒冷”的解,并在“温暖”的解上进行改进。基本过程如下:

  1. 随机生成一组解的种群。

  2. 衡量每个解的适应度。

  3. 选择最佳(最温暖)的解并丢弃其他解。

  4. 交叉(重组)最佳解中的元素,生成新的解。

  5. 通过改变解中一些元素的值来进行突变。

  6. 返回第 2 步并重复。

选择-交叉-突变的循环会继续,直到达到一个停止条件,比如找到已知的答案,找到一个“足够好”的答案(基于最低阈值),完成指定的迭代次数,或达到时间截止。由于这些步骤与进化过程非常相似,完全符合适者生存的原则,因此遗传算法中使用的术语通常比计算术语更加生物学化。

项目 #13:培育超级大鼠军团

这是你成为疯狂科学家的机会,拥有一个充满沸腾烧瓶、冒泡试管和发出“BZZZTTT”声音的机器的秘密实验室。所以,戴上黑色橡胶手套,开始把灵活的垃圾清道夫变成庞大的食人怪物吧。

目标

使用遗传算法模拟繁殖出平均体重为 110 磅的老鼠。

策略

你的梦想是培育出一种体型与公獒犬相当的老鼠(我们已经确定你是疯了)。你将从Rattus norvegicus,即棕色老鼠开始,然后加入一些人造甜味剂、1950 年代的原子辐射、很多耐心和一点点 Python,但不涉及基因工程——你是老派的,宝贝!这些老鼠将从不到一磅长大到可怕的 110 磅,差不多是雌性公獒犬的体型(见图 7-1)。

image

图 7-1:棕色老鼠、雌性公獒犬和人类的体型比较

在你开始进行这样一项庞大的任务之前,明智的做法是先在 Python 中模拟结果。而且你已经画出了比计划更好的东西——你画出了图形伪代码(见图 7-2)。

image

图 7-2:利用遗传算法培育超级老鼠的方式

图 7-2 展示了遗传算法是如何工作的。你的目标是从一个初始体重大大低于 110 磅的老鼠种群中,培育出一个平均体重为 110 磅的种群。往后,每一代(或世代)的老鼠都代表着该问题的一个候选解。就像任何动物饲养员一样,你会筛除那些不理想的雄性和雌性,它们会被人道地送去——对于Austin Powers迷来说——一个邪恶的宠物园。然后,你会让剩下的老鼠交配繁殖,这一过程在遗传编程中被称为交叉

剩下的老鼠的后代基本上会和它们的父母一样大,因此你需要对一些老鼠进行突变。虽然突变很少发生,而且通常会导致中性或无益的特征(在这种情况下是低体重),但有时你会成功地培育出更大的老鼠。

然后整个过程变成了一个大循环,无论是有机进行还是通过程序化进行,这让我不禁怀疑我们是否真的是外星模拟中的虚拟存在。不管怎么说,这个循环的结束——停止条件——是当老鼠达到了预期的体型,或者你实在受不了再和老鼠打交道时。

要输入到你的仿真中,你需要一些统计数据。因为你是科学家,所以使用公制系统,无论你是不是疯子。你已经知道雌性公獒犬的平均体重大约是 50,000 克,你可以在表 7-1 中找到有用的老鼠统计数据。

表 7-1: 棕色老鼠体重和繁殖统计数据

参数 已发布值
最小体重 200 克
平均体重(雌性) 250 克
平均体重(雄性) 300–350 克
最大体重 600 克*
每窝幼鼠数量 8–12
每年窝数 4–13
寿命(野生,圈养) 1–3 年,4–6 年
*在圈养情况下,个别老鼠可能达到 1,000 克。

由于存在家养和野生的棕色老鼠,因此某些统计数据可能会有较大差异。圈养的老鼠通常比野生老鼠得到更好的照料,因此它们的体重更大,繁殖更多,幼鼠也更多。所以当有范围数据时,你可以选择较高的值。对于这个项目,首先可以从表 7-2 中的假设开始。

表 7-2: 超级老鼠遗传算法的输入假设

变量和值 注释
GOAL = 50000 目标体重(雌性牛头獒,单位:克)
NUM_RATS = 20 实验室可容纳的成年老鼠总数
INITIAL_MIN_WT = 200 初始种群中成年老鼠的最小体重(单位:克)
INITIAL_MAX_WT = 600 初始种群中成年老鼠的最大体重(单位:克)
INITIAL_MODE_WT = 300 初始种群中最常见的成年老鼠体重(单位:克)
MUTATE_ODDS = 0.01 老鼠发生突变的概率
MUTATE_MIN = 0.5 最少有利突变对老鼠体重的影响因子
MUTATE_MAX = 1.2 最有利突变对老鼠体重的影响因子
LITTER_SIZE = 8 每对交配老鼠每窝的幼鼠数量
LITTERS_PER_YEAR = 10 每对交配老鼠每年繁殖的窝数
GENERATION_LIMIT = 500 停止繁殖程序的代数上限

由于老鼠繁殖非常频繁,你不需要考虑寿命。尽管你会保留上一代的一些父母,但随着后代体重的逐代增加,这些父母将很快被淘汰。

超级老鼠代码

super_rats.py 代码遵循了图 7-2 中的一般工作流程。你也可以从 www.nostarch.com/impracticalpython/ 下载代码。

输入数据和假设

清单 7-1,在程序开始时的全局空间中,导入模块并将表 7-2 中的统计数据、因子和假设作为常量进行分配。程序完成并正常工作后,可以自由地修改表中的值,看看它们如何影响你的结果。

super_rats.py,第一部分

➊ import time
   import random
   import statistics

➋ # CONSTANTS (weights in grams)
➌ GOAL = 50000
   NUM_RATS = 20
   INITIAL_MIN_WT = 200
   INITIAL_MAX_WT = 600
   INITIAL_MODE_WT = 300
   MUTATE_ODDS = 0.01
   MUTATE_MIN = 0.5
   MUTATE_MAX = 1.2
   LITTER_SIZE = 8
   LITTERS_PER_YEAR = 10
   GENERATION_LIMIT = 500

   # ensure even-number of rats for breeding pairs:
➍ if NUM_RATS % 2 != 0:
       NUM_RATS += 1

清单 7-1:导入模块并分配常量

首先导入 timerandomstatistics 模块 ➊。你将使用 time 模块来记录遗传算法的运行时间。为遗传算法计时是非常有趣的,即使只是为了惊叹于它们能如此迅速找到解决方案。

random 模块将满足算法的随机需求,你将使用 statistics 模块来获取平均值。虽然这是 statistics 的一个较弱用途,但我希望你能了解这个模块,因为它在许多情况下非常有用。

接下来,分配 表 7-2 中描述的输入变量,并确保注意单位是克 ➋。使用大写字母来命名这些变量,因为它们表示常量 ➌。

现在,我们假设使用繁殖配对,因此需要检查用户输入的是偶数只大鼠,如果不是,就添加一只大鼠 ➍。稍后,在 “挑战项目” 的 第 144 页,你将有机会尝试不同的性别分布。

初始化种群

清单 7-2 是程序的购物代表。它去宠物店挑选初始的繁殖大鼠种群。由于你需要配对繁殖,它应该选择偶数只大鼠。由于你不能负担一个那种无限空间的火山巢穴,你需要在每一代中维持一个固定的成年大鼠数量——尽管数量可以临时增加以容纳窝内的小鼠。记住,随着大鼠长大到像大狗那样大,它们需要更多的空间!

super_rats.py,第二部分

➊ def populate(num_rats, min_wt, max_wt, mode_wt):
       """Initialize a population with a triangular distribution of weights."""
    ➋ return [int(random.triangular(min_wt, max_wt, mode_wt))\
               for i in range(num_rats)]

清单 7-2:定义了创建初始大鼠种群的函数

populate() 函数需要知道你想要的成年大鼠数量、大鼠的最小和最大体重,以及最常见的体重 ➊。请注意,这些参数都将使用在全局空间中找到的常量。你不需要将它们作为参数传递给函数,以便函数能够访问它们。但为了清晰起见,我在这里以及后续的函数中都这样做,并且因为局部变量的访问效率更高。

你将使用上述四个参数和 random 模块,后者包括不同类型的分布。你将在这里使用三角分布,因为它能让你牢牢控制最小值和最大值,并允许你在统计中建模偏斜性。

由于棕色大鼠在野外、动物园、实验室和作为宠物中都有分布,它们的体重偏向较高。野生大鼠通常较小,因为它们的生活条件恶劣、残酷且短暂,尽管实验室里的大鼠可能会反驳这一点!使用列表推导式来循环遍历大鼠的数量,并为每只大鼠分配一个体重。将这一切通过 return 语句捆绑在一起 ➋。

衡量种群的适应度

衡量大鼠的适应度是一个两步过程。首先,通过比较所有大鼠的平均体重与斗牛獒目标体重来对整个种群进行评分。然后,对每只大鼠进行评分。只有体重排名在前 n 百分比的大鼠(由 NUM_RATS 变量确定)才能再次繁殖。虽然种群的平均体重是一个有效的适应度衡量标准,但它的主要作用是在这里是确定是否该停止循环并宣布成功。

清单 7-3 定义了 fitness()select() 函数,这两个函数共同构成了遗传算法的测量部分。

super_rats.py,第三部分

➊ def fitness(population, goal):
       """Measure population fitness based on an attribute mean vs target."""
       ave = statistics.mean(population)
       return ave / goal

➋ def select(population, to_retain):
       """Cull a population to retain only a specified number of members."""
    ➌ sorted_population = sorted(population)
    ➍ to_retain_by_sex = to_retain//2
    ➎ members_per_sex = len(sorted_population)//2
    ➏ females = sorted_population[:members_per_sex]
       males = sorted_population[members_per_sex:]
    ➐ selected_females = females[-to_retain_by_sex:]
       selected_males = males[-to_retain_by_sex:]
    ➑ return selected_males, selected_females

列表 7-3:定义了遗传算法的测量步骤

定义一个函数来评估当前一代的适应度 ➊。使用statistics模块获取种群的平均值,并将其除以目标体重返回。当这个值等于或大于 1 时,你就知道是时候停止繁殖了。

接下来,定义一个函数,根据体重将老鼠种群减少到NUM_RATS值,这里用to_retain参数表示 ➋。它还将接受一个population参数,表示每一代的父母。

现在,排序种群,以便你能区分大鼠和小鼠 ➌。取你想要保留的老鼠数量,并使用地板除法将其除以 2,确保结果是一个整数 ➍。执行此步骤是为了保留最大的公鼠和母鼠。如果你只选择种群中最大的老鼠,理论上你会只选择公鼠。通过对sorted_population使用地板除法将其除以 2,可以得到当前种群按性别分组的总人数 ➎。

公鼠通常比母鼠大,因此做出两个简化假设:首先,假设种群中恰好一半是雌性,其次,假设最大的母鼠体重大于或等于最小的公鼠体重。这意味着排序后的种群列表的前一半是母鼠,后一半是公鼠。接着,通过将sorted_population分为两半,取下半部分作为母鼠 ➏,上半部分作为公鼠,创建两个新列表。现在,只需从这两个列表的末尾取出最大体重的老鼠 ➐——使用负切片——并返回它们 ➑。这两份列表包含了下一代的父母。

当你第一次运行这个函数时,它所做的就是按照性别排序老鼠,因为初始的老鼠数量已经等于NUM_RATS常量。之后,传入的种群参数将包括父母和孩子,其值将超过NUM_RATS

培养新一代

列表 7-4 定义了程序的“交叉”步骤,意味着它将培育下一代。一个关键假设是,每只幼鼠的体重大于或等于母鼠的体重,并且小于或等于父鼠的体重。违反此规则的情况将在“突变”函数中处理。

super_rats.py,第四部分

➊ def breed(males, females, litter_size):
       """Crossover genes among members (weights) of a population."""
    ➋ random.shuffle(males)
       random.shuffle(females)
    ➌ children = []
    ➍ for male, female in zip(males, females):
        ➎ for child in range(litter_size):
            ➏ child = random.randint(female, male)
            ➐ children.append(child)
    ➑ return children

列表 7-4:定义了一个培养新一代老鼠的函数

breed()函数的参数包括从select()函数返回的已选择公鼠和母鼠的体重列表,以及一窝幼鼠的数量 ➊。接下来,随机打乱这两个列表 ➋,因为你在select()函数中已经对它们进行了排序,如果不打乱直接迭代,最小的公鼠会和最小的母鼠配对,以此类推。你需要考虑到爱情和浪漫;最大的公鼠可能会被最娇小的母鼠吸引!

开始一个空列表来存储子代 ➌。现在进入细节。使用 zip() 函数遍历打乱的列表,将每对雄性和雌性配对 ➍。每对老鼠可以有多个后代,所以开始另一个循环,以出生窝大小作为范围 ➎。出生窝大小是一个常量,名为 LITTER_SIZE,你在输入参数中提供的值,如果值为 8,则会得到八个子代。

对于每个子代,在母鼠和父鼠的体重之间随机选择一个体重 ➏。请注意,你不需要使用 male + 1,因为 randint() 会使用提供范围内的所有数字。还要注意,两个值可以相同,但第一个值(母鼠的体重)永远不能大于第二个值(父鼠的体重)。这是简化假设的另一个原因,假设雌性必须不大于最小的雄性。通过将每个子代添加到子代列表中 ➐ 来结束循环,然后返回 children ➑。

变异种群

一小部分子代应该会经历变异,其中大多数变异将导致不利的特征。这意味着体重低于预期,包括那些无法生存的“小个子”。但偶尔会有一次有益的变异,结果会使老鼠变得更重。

清单 7-5 定义了 mutate() 函数,它应用你在常量列表中提供的变异假设。调用 mutate() 后,应该检查新种群的适应度,如果目标体重没有达到,就重新开始循环。

super_rats.py,第五部分

➊ def mutate(children, mutate_odds, mutate_min, mutate_max):
       """Randomly alter rat weights using input odds & fractional changes."""
    ➋ for index, rat in enumerate(children):
           if mutate_odds >= random.random():
            ➌ children[index] = round(rat * random.uniform(mutate_min,
                                                            mutate_max))
       return children

清单 7-5:定义了变异一小部分种群的函数

该函数需要子代列表、变异发生的几率以及变异的最小和最大影响 ➊。这些影响是你应用于老鼠体重的因子。在程序开始时的常量列表中(以及在表格 7-2 中),它们偏向最小值,因为大多数变异不会导致有益的特征。

遍历子代列表,并使用 enumerate()——一个很方便的内置函数,作为自动计数器——来获取索引 ➋。然后使用 random() 方法生成一个 0 到 1 之间的随机数,并将其与变异发生的几率进行比较。

如果 mutate_odds 变量大于或等于随机生成的数字,则该索引处的老鼠(体重)会发生变异。从一个由最小和最大变异值定义的均匀分布中选择一个变异值;这基本上是在最小-最大范围内随机选择一个值。由于这些值偏向最小值,因此结果更可能是体重减少而不是增加。将当前体重乘以这个变异因子,并将其四舍五入为整数 ➌。最后返回变异后的 children 列表。

注意

关于突变统计的有效性,你可以找到一些研究表明有益突变非常稀有,也有一些研究认为它们比我们想象的更常见。狗的繁殖显示,实现体型的巨大变化(例如,吉娃娃与大丹犬)并不需要数百万年的进化。在 20 世纪的一个著名研究中,俄罗斯遗传学家德米特里·贝利亚耶夫从 130 只银狐开始,在 40 年的时间里,仅通过选择每一代中最温顺的狐狸,成功地实现了显著的生理变化。

定义main()函数

列表 7-6 定义了main()函数,该函数管理其他函数并决定何时满足停止条件。它还将显示所有重要结果。

super_rats.py, 第六部分

def main():
    """Initialize population, select, breed, and mutate, display results."""
 ➊ generations = 0

 ➋ parents = populate(NUM_RATS, INITIAL_MIN_WT, INITIAL_MAX_WT,
                       INITIAL_MODE_WT)
    print("initial population weights = {}".format(parents))
    popl_fitness = fitness(parents, GOAL)
    print("initial population fitness = {}".format(popl_fitness))
    print("number to retain = {}".format(NUM_RATS))

 ➌ ave_wt = []

 ➍ while popl_fitness < 1 and generations < GENERATION_LIMIT:
        selected_males, selected_females = select(parents, NUM_RATS)
        children = breed(selected_males, selected_females, LITTER_SIZE)
        children = mutate(children, MUTATE_ODDS, MUTATE_MIN, MUTATE_MAX)
     ➎ parents = selected_males + selected_females + children
        popl_fitness = fitness(parents, GOAL)
     ➏ print("Generation {} fitness = {:.4f}".format(generations,
                                                      popl_fitness))
     ➐ ave_wt.append(int(statistics.mean(parents)))
        generations += 1
 ➑ print("average weight per generation = {}".format(ave_wt))
    print("\nnumber of generations = {}".format(generations))
    print("number of years = {}".format(int(generations / LITTERS_PER_YEAR)))

列表 7-6:定义了 main() 函数

首先,通过初始化一个空列表来存储代数。最终你会用它来计算实现目标所需的年数 ➊。

接下来,调用populate()函数 ➋并立即打印结果。然后,获取初始种群的适应度,并打印该值以及每代保留的小鼠数量,即NUM_RATS常量。

为了增加趣味,初始化一个列表来存储每代的平均体重,以便在最后查看 ➌。如果你将这些体重与年份数进行对比,你会发现趋势是指数型的。

现在,开始大的遗传循环——选择、配对、突变。这是一个while循环,停止条件是达到目标体重或在没有达到目标体重的情况下达到大量的代数 ➍。注意,突变子代之后,你需要将它们与父代合并,生成一个新的parents列表 ➎。小鼠大约需要五周时间成熟并开始繁殖,但你可以通过调整LITTERS_PER_YEAR常量,从最大可能值减少来考虑这一点(参见表 7-1),就像我们在这里所做的那样。

在每次循环结束时,显示fitness()函数的结果,精确到四位小数,以便你监控算法并确保其按预期进行 ➏。获取这一代的平均体重,将其添加到ave_wt列表中 ➐,然后将代数加 1。

完成main()函数,显示每一代的平均体重列表、代数以及通过LITTERS_PER_YEAR变量计算出的年数 ➑。

运行main()函数

完成程序时,使用熟悉的条件语句来决定程序是独立运行还是作为模块运行。获取结束时间并打印程序运行所用的时间。性能信息应该仅在模块作为独立程序运行时打印,因此确保将其放置在if语句中。参见列表 7-7。

super_rats.py, 第七部分

if __name__ == '__main__':
    start_time = time.time()
    main()
    end_time = time.time()
    duration = end_time - start_time
    print("\nRuntime for this program was {} seconds.".format(duration))

列表 7-7:运行 main() 函数和 time 模块,如果程序没有被导入

总结

使用表 7-2 中的参数,super_rats.py程序大约需要两秒钟才能运行。平均而言,大鼠需要约 345 代,或 34.5 年,才能达到目标体重 110 磅。这对于一个疯狂的科学家来说,时间实在太长了!但有了你的程序,你可以寻找减少目标时间的方法。

灵敏度研究通过对单一变量进行多次更改并判断结果来工作。在某些变量相互依赖的情况下,你应该小心操作。而且,由于结果是随机的(即不确定的),你应该在每次改变参数时进行多次运行,以捕捉可能的结果范围。

在你的繁殖计划中,你可以控制的两个因素是繁殖大鼠的数量(NUM_RATS)和突变发生的几率(MUTATE_ODDS)。突变几率受到饮食和辐射暴露等因素的影响。如果你一次只改变一个变量并重新运行super_rats.py,你就可以判断每个变量对项目时间表的影响。

一个直接的观察是,如果你从每个变量的小值开始,慢慢增加它们,你会得到显著的初步结果(参见图 7-3)。之后,两条曲线迅速下降并趋于平稳,这是递减收益的经典例子。每条曲线平稳化的点就是优化节省成本和减少工作量的关键。

例如,保留超过 300 只大鼠几乎不会带来太大好处。你只是在喂养和照顾一大堆多余的大鼠。同样,试图将突变几率提高到 0.3 以上也几乎没有任何收获。

像这样的图表,使得规划前进的道路变得容易。标记为“基线”的水平虚线表示使用表 7-2 中的输入数据所得到的平均结果。你只需保留 50 只大鼠而不是 20 只,就能将这个时间减少超过 10 年。你还应该集中精力增加有益突变的数量。这将更有回报,但风险更大,且更难控制。

image

图 7-3:两个参数对达到目标体重所需时间的影响

如果你重新运行模拟,使用 50 只大鼠并将突变几率提高到 0.05,你理论上可以在 14 年内完成项目,比最初的基线提高了 246%。现在,这才是优化!

繁殖超级大鼠是理解遗传算法基本原理的一种有趣且简单的方法。但要真正理解它们的强大,你需要尝试一些更困难的任务。你需要一个计算量庞大到无法通过暴力破解解决的问题,而下一个项目正是这样的难题。

项目 #14: 破解高科技保险箱

你是 Q,詹姆斯·邦德遇到了一个问题。他必须参加一个恶棍的豪华晚宴,悄悄溜到那个恶棍的私人办公室,破解他的墙面保险箱。对于 007 来说,这简直是小菜一碟,除了一个问题:这是一个 Humperdink BR549 数字保险箱,需要 10 位数字,共有 100 亿种可能的组合。而且,只有所有数字输入完毕后,锁轮才会开始转动。你不能把听诊器放到保险箱上,慢慢转动旋钮!

作为 Q,你已经拥有一台自动拨号设备,可以通过暴力破解所有可能的解决方案,但邦德根本没有时间使用它。原因如下。

组合锁应该真正被称为排列锁,因为它需要有序的组合,而根据定义,这些组合是排列。更具体地说,锁依赖于带重复的排列。例如,一个有效的——尽管不安全——组合可能是 999999999。

在处理第三章中的字谜和第四章中第 87 页的“实践项目”时,你使用了itertools模块的permutations()迭代器,但在这里它无法提供帮助,因为permutations()返回的是重复的排列。要生成适合锁的排列,你需要使用itertoolsproduct()迭代器,它计算多个数字集合的笛卡尔积:

>>> from itertools import product
>>> combo = (1, 2)
>>> for perm in product(combo, repeat=2):
    print(perm)
(1, 1)
(1, 2)
(2, 1)
(2, 2)

可选的repeat关键字参数允许你获取一个可迭代对象与自身相乘的笛卡尔积,这正是你在此情况下需要做的。注意,product()函数返回所有可能的组合,而permutations()函数仅返回(1, 2)(2, 1)。你可以在docs.python.org/3.6/library/itertools.html#itertools.product.Listing阅读更多关于product()的信息。

列表 7-8 是一个 Python 程序,名为brute_force_cracker.py,它使用product()来暴力破解正确的组合:

brute_force_cracker.py

➊ import time
   from itertools import product

   start_time = time.time()

➋ combo = (9, 9, 7, 6, 5, 4, 3)

   # use Cartesian product to generate permutations with repetition
➌ for perm in product([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], repeat=len(combo)):
    ➍ if perm == combo:
           print("Cracked! {} {}".format(combo, perm))

   end_time = time.time()
➎ print("\nRuntime for this program was {} seconds.".format
         (end_time - start_time))

列表 7-8:使用暴力破解方法找到保险箱的组合

导入time和来自itertoolsproduct迭代器 ➊。获取开始时间,然后将保险箱组合输入为一个元组 ➋。接下来使用product(),它返回给定序列的所有带重复的排列的元组。序列包含所有有效的单数字输入(0–9)。你应该将repeat参数设置为组合中的数字位数 ➌。比较每个结果与组合是否匹配,如果匹配,则打印"Cracked!",并显示组合和匹配的排列 ➍。最后显示运行时间 ➎。

对于最多八位数字的组合,这种方式非常有效。之后,等待的过程变得越来越不舒适。表 7-3 是程序运行时间与组合中数字个数的记录。

表 7-3: 密码组合与运行时间对比(2.3 GHz 处理器)

数字位数 运行时间(秒)
5 0.035
6 0.147
7 1.335
8 12.811
9 133.270
10 1396.955

注意,向组合中添加一个数字会将运行时间增加一个数量级。这是指数级增长。对于 9 位数字,你需要等待超过 2 分钟才能得到答案。对于 10 位数字,则需要超过 20 分钟!对邦德来说,这可真是个漫长的时间,足以让他悄悄去“上厕所”。

幸运的是,你是 Q,你了解遗传算法。你所需要做的就是评估每个候选组合的适应度。可以选择监测功率消耗的波动,测量操作的时间延迟,或者监听声音。假设你使用了一个声音放大工具,并且还有一个防止多次输入错误密码后被锁定的工具。由于 BR549 保险箱的安全措施,声音工具一开始只能告诉你多少个数字是正确的,而不能告诉你哪些数字正确,但只需一点时间,你的算法就能锁定正确答案。

目标

使用遗传算法在庞大的搜索空间中快速找到保险箱的密码组合。

策略

这里的策略很简单。你将随机生成一个 10 位数字的序列,并与真实密码组合进行比较,根据匹配情况进行评分;在现实世界中,你会使用固定在保险箱门上的声音探测器来找到匹配的数字。然后,你会改变解决方案中的一个值,再次进行比较。如果找到另一个匹配,你就丢弃旧的序列,继续使用新的序列;否则,保留旧的序列,重新尝试。

由于一个解决方案完全替代了另一个解决方案,这表示遗传物质的交叉达到了 100%,所以你基本上只在使用选择和变异。单独使用选择和变异就可以生成一个强大的爬山算法。爬山是一种优化技术,它从一个任意的解决方案开始,并在解决方案中改变(变异)一个值。如果结果有改善,就保留新的解决方案,过程继续重复。

爬山算法的一个问题是,它可能会陷入局部最小值或最大值,从而找不到最优的全局值。想象一下,你正在寻找图 7-4 中波动函数的最低点。当前的最佳猜测由大黑点标记。如果你所做的变化(变异)的幅度太小,无法“跳出”局部低谷,算法就无法找到真正的低点。从算法的角度看,因为每个方向都会导致更差的结果,它就认为自己已经找到了正确的答案。因此,它会过早地收敛到一个解。

image

图 7-4:爬山算法“卡住”在局部最小值的示例

在遗传算法中使用交叉操作有助于避免过早收敛的问题,允许相对较大的变异也能起到同样的作用。因为你不必担心遵循生物学现实,这里可以让变异空间涵盖组合中的所有可能值。这样你就不会卡住,而爬山算法也变得可接受。

开锁器代码

safe_cracker.py代码接受一个由n个数字组成的组合,并通过爬山算法从随机起点开始达到目标组合。该代码可以从www.nostarch.com/impracticalpython/下载。

设置并定义 fitness()函数

清单 7-9 导入了必要的模块并定义了fitness()函数。

safe_cracker.py, 第一部分

➊ import time
   from random import randint, randrange

➋ def fitness(combo, attempt):
       """Compare items in two lists and count number of matches."""
       grade = 0
    ➌ for i, j in zip(combo, attempt):
           if i == j:
               grade += 1
       return grade

清单 7-9:导入模块并定义 fitness() 函数

在导入一些熟悉的模块 ➊ 之后,定义一个fitness()函数,它将真实组合和尝试的解作为参数 ➋。定义一个变量grade并将其设置为0。然后使用zip()同时遍历组合和你的尝试 ➌。若它们相同,则将grade加 1 并返回。注意,你并没有记录匹配的索引,只是函数找到了匹配。这模拟了声音检测设备的输出。它最初只能告诉你有多少锁轮转动,而不能告诉你它们的位置。

定义并运行 main()函数

由于这是一个简短且简单的程序,大部分算法都运行在main()函数中,而不是多个函数中,清单 7-10。

safe_cracker.py, 第二部分

def main():

    """Use hill-climbing algorithm to solve lock combination."""

 ➊ combination = '6822858902'

    print("Combination = {}".format(combination))

    # convert combination to list:

 ➋ combo = [int(i) for i in combination]

    # generate guess & grade fitness:

 ➌ best_attempt = [0] * len(combo)

    best_attempt_grade = fitness(combo, best_attempt)

 ➍ count = 0

    # evolve guess

 ➎ while best_attempt != combo:

        # crossover

     ➏ next_try = best_attempt[:]

        # mutate

        lock_wheel = randrange(0, len(combo))

     ➐ next_try[lock_wheel] = randint(0, 9)

        # grade & select

     ➑ next_try_grade = fitness(combo, next_try)

        if next_try_grade > best_attempt_grade:

            best_attempt = next_try[:]

            best_attempt_grade = next_try_grade

        print(next_try, best_attempt)

        count += 1

    print()

 ➒ print("Cracked! {}".format(best_attempt), end=' ')

    print("in {} tries!".format(count))

if __name__ == '__main__':

    start_time = time.time()

    main()

    end_time = time.time()

    duration = end_time - start_time

 ➓ print("\nRuntime for this program was {:.5f} seconds.".format(duration))

清单 7-10:定义 main() 函数,并在未导入时运行和计时程序

提供真实的组合作为一个变量 ➊,并使用列表推导将其转换为列表,方便后续使用 ➋。生成一个与组合长度相等的零列表,并将其命名为best_attempt ➌。此时,任何组合都与其他组合一样好。你应该保留这个名字——best_attempt——因为在你爬山的过程中,只需要保留最好的解。生成初始尝试后,用fitness()函数对其进行评分,然后将结果赋值给一个名为best_attempt_grade的变量。

count变量初始化为零。程序将使用此变量记录破解代码所需的尝试次数➍。

现在,启动一个while循环,直到你找到正确的组合 ➎。将best_attempt副本赋值给一个next_try变量 ➏。你复制它是为了避免别名问题;当你修改next_try中的元素时,你不希望不小心更改best_attempt,因为如果next_try未通过适应度测试,你可能还需要继续使用best_attempt

现在是时候突变副本了。组合中的每个数字都旋转保险箱中的一个锁轮,因此命名一个变量 lock_wheel 并随机设置它等于组合中的一个索引位置。这代表了在此迭代中要更改的单个元素的位置。接下来,随机选择一个数字,并用它替换 lock_wheel 索引位置的值 ➐。

next_try 进行评分,如果它比上次尝试更优,则将 best_attemptbest_attempt_grade 重置为新值 ➑。否则,best_attempt 将在下一次迭代中保持不变。并排打印 next_trybest_attempt,这样你可以在程序结束时滚动查看尝试过程,观察它们是如何演变的。通过增加计数器完成循环。

当程序找到组合时,显示 best_attempt 值以及找到该值所需的尝试次数 ➒。记住,end=' ' 参数会防止打印行末尾换行,并在当前行的结尾和下一行的开始之间放置一个空格。

完成程序的条件语句,使 main() 独立运行,并显示精确到五位小数的运行时间 ➓。注意,计时代码放在条件语句之后,因此如果程序作为模块导入,则不会运行。

总结

safe_cracker.py 程序的最后几行输出如下所示。为了简洁起见,我省略了大部分演化比较。此运行是针对一个 10 位数的组合。

[6, 8, 6, 2, 0, 5, 8, 9, 0, 0] [6, 8, 2, 2, 0, 5, 8, 9, 0, 0]
[6, 8, 2, 2, 0, 9, 8, 9, 0, 0] [6, 8, 2, 2, 0, 5, 8, 9, 0, 0]
[6, 8, 2, 2, 8, 5, 8, 9, 0, 0] [6, 8, 2, 2, 8, 5, 8, 9, 0, 0]
[6, 8, 2, 2, 8, 5, 8, 9, 0, 2] [6, 8, 2, 2, 8, 5, 8, 9, 0, 2]

Cracked! [6, 8, 2, 2, 8, 5, 8, 9, 0, 2] in 78 tries!

Runtime for this program was 0.69172 seconds.

十亿种可能的组合,程序仅在 78 次尝试内并且不到一秒的时间内找到了解决方案。即使是詹姆斯·邦德也会对这个结果印象深刻。

这就结束了遗传算法的部分。你已经使用一个示例工作流培养了巨型啮齿动物,然后将其调整为通过暴力算法解决爬坡问题。如果你想继续玩数字达尔文并实验遗传算法,可以在维基百科上找到一个长长的示例应用列表 (en.wikipedia.org/wiki/List_of_genetic_algorithm_applications)。示例包括:

  • 全球气温变化建模

  • 集装箱装载优化

  • 交付车辆路线优化

  • 地下水监测网络

  • 学习机器人行为

  • 蛋白质折叠

  • 稀有事件分析

  • 破译代码

  • 拟合函数的聚类

  • 过滤和信号处理

进一步阅读

《Python 中的遗传算法》(Genetic Algorithms with Python)(亚马逊数字服务有限公司,2016)由克林顿·谢泼德(Clinton Sheppard)编写,是一本使用 Python 介绍遗传算法的初学者入门书籍。它有纸质版或作为便宜的电子书可以从 leanpub.com/genetic_algorithms_with_python/ 获取。

挑战项目

继续通过这些建议的项目培养超级老鼠并破解超级保险箱。像往常一样,挑战项目没有提供解决方案,你需要自己解决问题。

建立一群老鼠后宫

由于单只雄性老鼠可以与多只雌性老鼠交配,因此不需要雄性和雌性老鼠的数量相等。重写super_rats.py代码,以适应不等数量的雄性和雌性个体。然后使用与之前相同的老鼠总数重新运行程序,但使用 4 只雄性和 16 只雌性。这将如何影响达到目标体重 50,000 克所需的年数?

创建一个更高效的开锁器

如当前代码所示,当safe_cracker.py代码找到一个锁轮匹配时,该匹配并没有被显式保存。只要while循环在运行,就没有任何东西可以阻止一个正确的匹配被随机覆盖。修改代码,使正确猜测的索引不再受到未来的更改影响。对比两版代码的运行时间,以评估此更改的影响。

第八章:计数俳句诗歌的音节**

image

诗歌可能是文学的最高形式。正如柯尔律治所说,“最好的词语按最好的顺序排列。”诗人必须以极简的语言讲述故事,传播思想,描绘场景,或唤起强烈的情感,同时遵循严格的节奏和押韵规则、风格和结构。

计算机喜欢规则和结构,甚至有潜力激发情感。在 1996 年出版的《虚拟缪斯:计算机诗歌实验》一书中,作者查尔斯·哈特曼描述了早期尝试编写能够模仿人类诗歌的算法。引用哈特曼的话说:“诗歌互动的复杂性,诗人、文本和读者之间微妙的舞蹈,导致了一场犹豫的博弈。在这场博弈中,经过适当编程的计算机有机会插入一些有趣的动作。”

哈特曼描述的早期程序,充其量只能产生糟糕的嬉皮诗。那时的目标是“将计算出的机械化无序引入语言,将结果放回语言存在的具体世界,看看尘土如何落定。”正如我们在几个章节中提到的,上下文是编程中弱的一环,比如专有名词的字谜和空白密码。为了写出能够通过文学“至高”测试的计算机诗歌,你不能忽视上下文。

让计算机模拟这一最具人类特质的活动是一个引人入胜的挑战——而且无疑是我们无法放弃的挑战。在本章及下一章中,你将教你的计算机生成一种传统的日本诗歌形式——俳句

日本俳句

俳句由三行组成,分别为五个、七个和五个音节。这些诗歌很少押韵,题材通常直接或间接地涉及自然世界——主要是季节。如果做得好,一首俳句能够让你沉浸在场景中,就像唤起一段记忆一样。

我这里提供了三首俳句范例。第一首是大师布孙(1715–1783)创作的,第二首是大师一茶(1763–1828)创作的,第三首是我自己创作的,基于儿时公路旅行的回忆。

黄昏时静立

听啊。。。在遥远的地方

青蛙的歌声!

—布孙

好朋友蝉蜕

你会当看护者吗

为我的小墓地吗?

—一茶

远方的云层

我让自己假装

远山的景象

—沃恩

由于其唤起性特征,每一首俳句都内置了程序员可以“利用的空隙”。这一点彼得·贝伦森在他 1955 年出版的书《日本俳句》中有一句话总结得很好:“俳句并不总是期待成为一个完整的,甚至是清晰的陈述。读者应该将自己的联想和意象添加到文字中,从而成为自己在诗中愉悦的共同创作者。”哈特曼补充道:“读者的大脑在稀疏的材料上最为活跃。我们从最少的星星中画出最清晰的星座。因此,对于一小组可以赋予意象意义的词语来说,胡说八道的因素很低。”简单来说,搞砸一首短诗更为困难。读者总是认为诗人有某种意图,并且如果他们找不到,就会自己构造一个。

尽管有这个优势,训练计算机写诗并非易事,你将需要两个完整的章节才能完成。在本章中,你将编写一个程序来计算单词和短语中的音节数,以便你能够遵循俳句的音节结构。在第九章,你将使用一种叫做马尔可夫链分析的技术来捕捉俳句的本质——那种难以捉摸的唤起性成分——并将现有的诗歌转变为新的,有时甚至是更好的作品。

项目 #15:音节计数

计算英语音节是困难的。问题在于,正如查尔斯·哈特曼所说,英语的拼写古怪且语言历史错综复杂。例如,一个词像aged(年老的)根据它是描述一个人还是奶酪,可能是一个音节,也可能是两个音节。一个程序如何在不沦为无休止的特殊情况列表的情况下,准确地计算音节呢?

答案是它无法做到,至少没有“备忘单”。幸运的是,借助于一门叫做自然语言处理NLP)的科学分支,这些“备忘单”存在了,它处理的是计算机的精确结构化语言与人类使用的含糊且常常模糊的“自然”语言之间的互动。NLP 的示例应用包括机器翻译、垃圾邮件检测、搜索引擎问题理解和手机用户的预测文本识别。NLP 的最大影响还未到来:即挖掘大量以前无法使用的、结构不良的数据,并与我们的计算机“霸主”进行无缝对话。

在本章中,你将使用一个 NLP 数据集来帮助计算单词或短语中的音节数量。你还将编写代码,找到缺失于此数据集的单词,并帮助你建立一个辅助字典。最后,你将编写一个程序,帮助你检查你的音节计数代码。在第九章,你将把这个音节计数算法作为模块,嵌入到一个程序中,帮助你在计算上创作文学中的最高成就:诗歌。

目标

编写一个 Python 程序,统计英语单词或短语中的音节数。

策略

对于你和我来说,数音节很简单。将手背放在下巴下方开始说话,每次下巴碰到手时,你就说了一个音节。电脑没有手或下巴,但每个元音音素代表一个音节——而计算机可以数元音音素。然而,这并不容易,因为没有简单的规则来执行这一操作。有些书面语言中的元音是哑音,比如 like 中的 e,而有些元音组合成一个声音,比如 moo 中的 oo。幸运的是,英语中的单词数量不是无限的。已经有相当详尽的单词列表,其中包含你需要的很多信息。

语料库 是文本的一个高级名称。在第九章中,你将使用一个由俳句组成的训练语料库,它教会 Python 如何写新的俳句。在本章中,你将使用这个相同的语料库来提取音节计数。

你的音节计数器应评估短语和单个单词,因为最终你将使用它来统计俳句中整个的音节数。该程序将接收一些文本作为输入,统计每个单词的音节数,并返回总音节数。你还需要处理标点符号、空格和缺失的单词等问题。

你需要遵循的主要步骤是:

  1. 下载一个包含音节计数信息的大型语料库。

  2. 将音节计数语料库与俳句训练语料库进行比较,找出所有缺失在音节计数语料库中的单词。

  3. 构建一个缺失单词及其音节数的字典。

  4. 编写一个程序,使用音节计数语料库和缺失单词字典来统计训练语料库中的音节数。

  5. 编写一个程序,将音节计数程序与更新后的训练语料库进行比对。

使用语料库

自然语言工具包(NLTK)是一个流行的程序和库套件,专门用于处理 Python 中的自然语言数据。它于 2001 年作为宾夕法尼亚大学计算机与信息科学系的一门计算语言学课程的一部分创建。随着众多贡献者的帮助,它不断发展和扩展。要了解更多信息,请访问官方 NLTK 网站 www.nltk.org/

在这个项目中,你将使用 NLTK 访问卡内基梅隆大学发音词典(CMUdict)。该语料库包含近 125,000 个单词及其发音映射。它是机器可读的,适用于语音识别等任务。

安装 NLTK

你可以在 www.nltk.org/install.html 上找到安装 NLTK 的说明,适用于 Unix、Windows 和 macOS。如果你使用的是 Windows,我建议你先打开 Windows 命令提示符或 PowerShell,并尝试使用 pip 进行安装:

python -m pip install nltk

你可以通过打开 Python 交互式 shell 并输入以下内容来检查安装:

>>> import nltk
>>>

如果没有遇到错误,那么你就可以继续了。否则,请按照网站上提供的说明操作。

下载 CMUdict

要访问 CMUdict(或任何其他 NLTK 语料库),你需要下载它。你可以使用便捷的 NLTK 下载器来实现这一点。安装 NLTK 后,在 Python shell 中输入以下内容:

>>> import nltk
>>> nltk.download()

NLTK 下载器窗口(图 8-1)应该已经打开。点击顶部的 Corpora 标签,然后在标识符列中点击 cmudict。接着,滚动到窗口底部并设置下载目录;我使用了默认目录,C:\nltk_data。最后,点击 下载 按钮加载 CMUdict。

image

图 8-1:NLTK 下载器窗口,已选择 cmudict 进行下载

当 CMUdict 下载完成后,退出下载器并在 Python 交互式 shell 中输入以下内容:

>>> from nltk.corpus import cmudict
>>>

如果没有遇到错误,则表示语料库已成功下载。

计算声音而不是音节

CMUdict 语料库将单词分解为一组 音素 ——在特定语言中感知上不同的 声音单元 ——并通过数字(0、1 和 2)标记元音的词汇重音。CMUdict 语料库为每个元音标记一个且仅一个这些数字,因此你可以使用这些数字来识别单词中的元音。

将单词视为音素集合将帮助你避免一些问题。首先,CMUdict 不会包括书写中未发音的元音。例如,下面是 CMUdict 如何表示单词 scarecrow

[['S', 'K', 'AE1', 'R', 'K', 'R', 'OW0']]

每个带有数字后缀的项目表示一个发音元音。注意,scare 结尾的静音 e 被正确省略了。

其次,有时多个连续的书写元音会被发音为一个单一音素。例如,CMUdict 是如何表示 house 的:

[['HH', 'AW1', 'S']]

注意语料库如何将书写中的双元音 ou 作为一个单一元音 'AW1' 来处理,用于发音目的。

处理有多重发音的词语

正如我在介绍中提到的,某些单词有多种不同的发音;agedlearned 就是两个例子:

[['EY1', 'JH', 'D'], ['EY1', 'JH', 'IH0', 'D']]
[['L', 'ER1', 'N', 'D'], ['L', 'ER1', 'N', 'IH0', 'D']]

注意嵌套列表。语料库识别到这两个词可以用一个或两个音节发音。这意味着它会返回多个音节数,对于某些词,你需要在代码中考虑这一点。

管理缺失的词语

CMUdict 非常有用,但在语料库中,一个词要么存在,要么不存在。仅仅几秒钟,我就找到了 50 多个词——比如 dewdropbathwaterduskyridgelinestorksdragonflybeggararchways——在一个 1500 词的测试用例中缺失。因此,你的策略之一应该是检查 CMUdict 中缺失的词,并通过为你的语料库创建一个语料库来解决这些遗漏!

训练语料库

在第九章,你将使用数百首俳句作为训练语料库来“教”你的程序如何创作新的俳句。但是你不能指望 CMUdict 包含这个语料库中的所有单词,因为一些单词是日语词汇,比如sake。正如你已经看到的那样,甚至一些常见的英语单词在 CMUdict 中也缺失。

所以,首要任务是检查训练语料库中的所有单词是否存在于 CMUdict 中。为此,你需要从 www.nostarch.com/impracticalpython/ 下载训练语料库,名为train.txt。将它和本章中的所有 Python 程序保存在同一个文件夹里。该文件包含略低于 300 首俳句,并且为了确保训练集的可靠性,这些俳句被随机重复了大约 20 次。

一旦你找到不在 CMUdict 中的单词,你将编写一个脚本,帮助你准备一个 Python 字典,将单词作为键,音节计数作为值;然后你将把这个字典保存到一个文件中,以便在音节计数程序中支持 CMUdict。

缺失单词的代码

本节中的代码将查找 CMUdict 中缺失的单词,帮助你准备一个包含单词及其音节计数的字典,并将字典保存到文件中。你可以从 nostarch.com/impracticalpython/ 下载代码,文件名为 missing_words_finder.py

导入模块、加载 CMUdict 并定义 main()函数

清单 8-1 导入模块,加载 CMUdict,并定义运行程序的main()函数。

missing_words_finder.py, 第一部分

   import sys
   from string import punctuation
➊ import pprint
   import json
   from nltk.corpus import cmudict

➋ cmudict = cmudict.dict()  # Carnegie Mellon University Pronouncing Dictionary

➌ def main():
    ➍ haiku = load_haiku('train.txt')
    ➎ exceptions = cmudict_missing(haiku)
    ➏ build_dict = input("\nManually build an exceptions dictionary (y/n)? \n")
       if build_dict.lower() == 'n':
           sys.exit()
       else:
        ➐ missing_words_dict = make_exceptions_dict(exceptions)
           save_exceptions(missing_words_dict)

清单 8-1:导入模块,加载 CMUdict,并定义 main()

你从一些熟悉的导入开始,并添加几个新的模块。pprint模块允许你以易于阅读的格式“美化打印”缺失单词的字典 ➊。你将使用 JavaScript 对象表示法(json)将这个字典写出作为持久化数据,json是一种基于文本的计算机数据交换方式,能够很好地与 Python 数据结构配合使用;它是标准库的一部分,在多个编程语言中都得到标准化,数据安全且易于阅读。最后,导入 CMUdict 语料库。

接下来,调用cmudict模块的dict()方法,将语料库转化为一个字典,字典的键是单词,值是它们的音素 ➋。

定义main()函数,该函数将调用其他函数来加载训练语料库,查找 CMUdict 中缺失的单词,构建包含单词及其音节计数的字典,并保存结果 ➌。你将在定义main()后定义这些函数。

调用该函数加载俳句训练语料库,并将返回的集合赋值给名为haiku的变量 ➍。然后调用一个函数,查找缺失的单词并将它们作为集合返回 ➎。使用集合可以去除不需要的重复单词。cmudict_missing()函数还会显示缺失单词的数量和一些其他统计信息。

现在,询问用户是否想手动构建一个字典以处理缺失的单词,并将他们的输入赋值给build_dict变量 ➏。如果他们想停止,退出程序;否则,调用一个函数来构建字典 ➐,然后再调用另一个函数保存字典。请注意,用户并不一定非得按y键继续,尽管这是提示。

加载训练语料库并寻找缺失的单词

清单 8-2 加载并准备训练语料库,将其内容与 CMUdict 进行比较,并跟踪差异。这些任务由两个函数分工完成。

missing_words_finder.py,第二部分

➊ def load_haiku(filename):
       """Open and return training corpus of haiku as a set."""
       with open(filename) as in_file:
     ➋ haiku = set(in_file.read().replace('-', ' ').split())
     ➌ return haiku

   def cmudict_missing(word_set):
       """Find and return words in word set missing from cmudict."""
    ➍ exceptions = set()
       for word in word_set:
           word = word.lower().strip(punctuation)
           if word.endswith("'s") or word.endswith("’s"):
               word = word[:-2]
        ➎ if word not in cmudict:
               exceptions.add(word)
       print("\nexceptions:")
       print(*exceptions, sep='\n')
    ➏ print("\nNumber of unique words in haiku corpus = {}"
             .format(len(word_set)))
       print("Number of words in corpus not in cmudict = {}"
             .format(len(exceptions)))
       membership = (1 - (len(exceptions) / len(word_set))) * 100
    ➐ print("cmudict membership = {:.1f}{}".format(membership, '%'))
       return exceptions

清单 8-2: 定义加载语料库的函数并查找缺失的单词

定义一个函数来读取来自 haiku 训练语料库中的单词 ➊。train.txt中的 haiku 已经被重复多次,且原始 haiku 包含了重复的单词,如moonmountainthe。没有必要多次评估一个单词,因此将这些单词加载为集合来去重 ➋。你还需要用空格替换连字符。连字符在 haiku 中很常见,但你需要将它们两侧的单词分开,以便在 CMUdict 中进行检查。函数结束时返回haiku集合 ➌。

现在是时候找出缺失的单词了。定义一个函数cmudict_missing(),它以一个序列作为参数——在这种情况下,是load_haiku()函数返回的单词集。创建一个空的exceptions集合来保存任何缺失的单词 ➍。遍历每个 haiku 单词集中的单词,将其转换为小写,并去除任何前后标点。请注意,你不想去除除了连字符之外的内部标点,因为 CMUdict 识别诸如wouldn’t之类的单词。拥有所有格的单词通常不在语料库中,因此去掉尾部的’s,因为这不会影响音节数的统计。

注意

小心使用文本处理软件生成的弯引号(’)。这些与简单文本编辑器和命令行中使用的直引号(')不同,CMUdict 可能无法识别这些弯引号。如果你向训练文件或 JSON 文件中添加新单词,请务必使用直引号来表示缩写或所有格名词。

如果在 CMUdict 中找不到该单词,将其添加到exceptions ➎。打印这些单词作为检查,并提供一些基本信息 ➏,如有多少个唯一单词、缺失单词的数量以及训练语料库中有多少百分比的单词是 CMUdict 的成员。将百分比的精度设置为小数点后一位 ➐。函数结束时返回异常单词集。

构建缺失词字典

列表 8-3 继续了 missing_words_finder.py 代码,现在通过将音节数作为值分配给缺失的单词,来补充 CMUdict 数据。由于缺失的单词数量应该相对较小,用户可以手动分配这些音节数,因此编写代码帮助用户与程序交互。

missing_words_finder.py,第三部分

➊ def make_exceptions_dict(exceptions_set):
       """Return dictionary of words & syllable counts from a set of words."""
    ➋ missing_words = {}
       print("Input # syllables in word. Mistakes can be corrected at end. \n")
       for word in exceptions_set:
           while True:
            ➌ num_sylls = input("Enter number syllables in {}: ".format(word))
            ➍ if num_sylls.isdigit():
                   break
               else:
                   print("                 Not a valid answer!", file=sys.stderr)
        ➎ missing_words[word] = int(num_sylls)
       print()
    ➏ pprint.pprint(missing_words, width=1)

    ➐ print("\nMake Changes to Dictionary Before Saving?")
       print("""
       0 - Exit & Save
       1 – Add a Word or Change a Syllable Count
       2 - Remove a Word
       """)

    ➑ while True:
           choice = input("\nEnter choice: ")
           if choice == '0':
               break
           elif choice == '1':
               word = input("\nWord to add or change: ")
               missing_words[word] = int(input("Enter number syllables in {}: "
                                               .format(word)))
           elif choice == '2':
               word = input("\nEnter word to delete: ")
            ➒ missing_words.pop(word, None)

       print("\nNew words or syllable changes:")
    ➓ pprint.pprint(missing_words, width=1)

       return missing_words

列表 8-3:允许用户手动计算音节并构建字典

从定义一个函数开始,该函数接受由 cmudict_missing() 函数返回的异常集合作为参数 ➊。立即将一个空字典赋值给名为 missing_words 的变量 ➋。让用户知道如果他们犯了错误,会有机会稍后修正;然后,使用 forwhile 循环遍历缺失的单词集,向用户展示每个单词,询问音节数作为输入。单词将是字典的键,num_sylls 变量将成为它的值 ➌。若输入是数字 ➍,则跳出循环。否则,警告用户并让 while 循环再次请求输入。如果输入通过,便将该值作为整数添加到字典中 ➎。

使用 pprint 来单独显示每个键/值对,作为检查。width 参数作为换行符号 ➏。

给用户提供机会在保存 missing_words 字典为文件之前进行最后修改 ➐。使用三引号展示选项菜单,然后用 while 循环保持选项活跃,直到用户准备好保存 ➑。三个选项是退出,触发 break 命令;添加新单词或更改现有单词的音节数,需要输入单词和音节数;以及删除条目,使用字典的 pop() 函数 ➒。将 None 参数添加到 pop() 中意味着如果用户输入的单词不在字典中,程序不会抛出 KeyError

最后,给用户一个最后查看字典的机会,以防有更改 ➓,然后返回字典。

保存缺失单词字典

持久数据 是在程序终止后仍然保存的数据。为了在后面章节中编写的 count_syllables.py 程序中使用 missing_words 字典,你需要将它保存到文件中。列表 8-4 就是完成这一操作的代码。

missing_words_finder.py,第四部分

➊ def save_exceptions(missing_words):
       """Save exceptions dictionary as json file."""
    ➋ json_string = json.dumps(missing_words)
    ➌ f = open('missing_words.json', 'w')
       f.write(json_string)
       f.close()
    ➍ print("\nFile saved as missing_words.json")

➎ if __name__ == '__main__':
       main()

列表 8-4:将缺失单词字典保存到文件并调用 main()

使用 json 来保存字典。定义一个新函数,接受缺失单词集合作为参数 ➊。将 missing_words 字典赋值给一个名为 json_string 的新变量 ➋;然后,打开一个以 .json 为扩展名的文件 ➌,写入 json 变量,并关闭文件。提醒用户文件名 ➍。最后,编写代码,让程序可以作为模块或独立模式运行 ➎。

json.dumps()方法将missing_words字典序列化为一个字符串。序列化是将数据转换为更易传输或存储格式的过程。例如:

>>> import json
>>> d = {'scarecrow': 2, 'moon': 1, 'sake': 2}
>>> json.dumps(d)
'{"sake": 2, "scarecrow": 2, "moon": 1}'

请注意,序列化的字典被单引号包围,使其成为一个字符串。

我在这里提供了missing_words_finder.py的部分输出。顶部的缺失单词列表和底部的手动音节计数都已缩短,以便简洁。

--snip--
froglings
scatters
paperweights
hibiscus
cumulus
nightingales

Number of unique words in haiku corpus = 1523
Number of words in corpus not in cmudict = 58
cmudict membership = 96.2%

Manually build an exceptions dictionary (y/n)?
y
Enter number syllables in woodcutter: 3
Enter number syllables in morningglory: 4
Enter number syllables in cumulus: 3
--snip--

别担心——你不需要为所有单词分配音节计数。missing_words.json文件已经完成,并且在需要时可以下载。

注意

对于有多种发音的单词,比如 jagged our,你可以通过手动打开missing_words.json文件并在任意位置添加键/值对,强制程序使用你喜欢的发音。我就是通过这种方式调整了单词sake,让它使用两音节的日语发音。因为该文件首先会检查单词的成员资格,它会覆盖 CMUdict 中的值。

现在你已经解决了 CMUdict 中的漏洞,你可以编写计数字节数的代码了。在第九章,你将把这段代码作为模块在markov_haiku.py程序中使用。

计数字节数的代码

本节包含了count_syllables.py程序的代码。你还需要在上一节中创建的missing_words.json文件。你可以从www.nostarch.com/impracticalpython/下载这两个文件。将它们保存在同一个文件夹中。

准备、加载和计数

清单 8-5 导入必要的模块,加载 CMUdict 和缺失单词字典,并定义一个函数来计数字节数。

count_syllables.py,第一部分

   import sys
   from string import punctuation
   import json
   from nltk.corpus import cmudict

   # load dictionary of words in haiku corpus but not in cmudict
   with open('missing_words.json') as f:
       missing_words = json.load(f)

➊ cmudict = cmudict.dict()

➋ def count_syllables(words):
       """Use corpora to count syllables in English word or phrase."""
       # prep words for cmudict corpus
       words = words.replace('-', ' ')
       words = words.lower().split()
    ➌ num_sylls = 0
    ➍ for word in words:
           word = word.strip(punctuation)
           if word.endswith("'s") or word.endswith("’s"):
               word = word[:-2]
        ➎ if word in missing_words:
               num_sylls += missing_words[word]
           else:
            ➏ for phonemes in cmudict[word][0]:
                   for phoneme in phonemes:
                    ➐ if phoneme[-1].isdigit():
                           num_sylls += 1

    ➑ return num_sylls

清单 8-5:导入模块、加载词典并计数字节数

在导入一些熟悉的模块后,加载包含所有 CMUdict 中缺失的单词和音节计数的missing_words.json文件。使用json.load()恢复存储为字符串的字典。接下来,使用dict()方法将 CMUdict 语料库转化为字典➊。

定义一个名为count_syllables()的函数来计数字节数。它应该能够接受单词短语,因为你最终会希望将它传递给俳句的每一行。按照之前在missing_words_finder.py程序中做的那样准备单词➋。

分配一个num_sylls变量来保存音节计数,并将其初始化为0 ➌。然后开始循环输入的单词,去掉末尾的标点符号和's。注意,撇号的格式可能会让你感到困扰,因此提供了两种版本:一种是直撇号,另一种是弯撇号 ➍。接下来,检查单词是否是缺失单词的小字典中的成员。如果找到了该单词,则将该单词在字典中的值加到num_sylls中 ➎。否则,开始查看音素,这些音素在 CMUdict 中表示一个值;对于每个音素,查看它所组成的字符串 ➏。如果在字符串的末尾找到数字,那么你就知道这个音素是元音。以单词aged为例,只有第一个字符串(在此处以灰色突出显示)末尾有数字,因此该单词包含一个元音:

[['EY1', 'JH', 'D'], ['EY1', 'JH', 'IH0', 'D']]

请注意,如果有多个发音,你使用第一个值([0]);记住,CMUdict 将每个发音表示为一个嵌套列表。这可能会偶尔导致错误,因为正确的选择取决于上下文。

检查音素的末尾是否有数字,如果有,则将1加到num_sylls中 ➐。最后,返回单词或短语的总音节数 ➑。

定义 main() 函数

在完成程序后,清单 8-6 定义并运行了 main() 函数。程序在以独立模式运行时会调用此函数——例如,用于检查单词或短语——但如果你将 syllable_counter 作为模块导入,则不会调用此函数。

count_syllables.py, 第二部分

   def main():
    ➊ while True:
           print("Syllable Counter")
        ➋ word = input("Enter word or phrase; else press Enter to Exit: ")
        ➌ if word == '':
               sys.exit()
        ➍ try:
               num_syllables = count_syllables(word)
               print("number of syllables in {} is: {}"
                     .format(word, num_syllables))
               print()
           except KeyError:
               print("Word not found.  Try again.\n", file=sys.stderr)
➎ if __name__ == '__main__':
       main()

清单 8-6:定义并调用 main() 函数

定义main()函数并开始while循环 ➊。提示用户输入一个单词或短语 ➋。如果用户按下 ENTER 键但没有输入任何内容,程序将退出 ➌。否则,启动一个try-except块,以防用户输入的单词在字典中找不到而导致程序崩溃 ➍。只有在独立模式下,才应引发异常,因为你已经准备好让程序在没有异常的情况下运行在 haiku-training 语料库上。在此块内,调用count_syllables()函数并传递输入,然后在交互式命令行中显示结果。最后使用标准代码,使程序能够作为独立程序运行或作为其他程序中的模块 ➎。

检查你的程序

你已经精心调整了音节计数程序,确保它能与训练语料库配合使用。在继续进行 haiku 程序时,你可能想要将一两首诗加入到这个语料库中,但添加新的 haiku 可能会引入一个新的单词,而这个单词不在 CMUdict 或你的异常字典中。在你回去重新构建异常字典之前,请检查是否真的需要这样做。

清单 8-7 将自动计算训练语料库中每个单词的音节数,并显示失败的单词。你可以从* www.nostarch.com/impracticalpython/下载这个程序,文件名为test_count_syllables_w_full_corpus.py。将其保存在与count_syllables.pytrain.txtmissing_words.json*相同的文件夹中。

test_count_syllables_w_full_corpus.py

   import sys
   import count_syllables

   with open('train.txt.') as in_file:
    ➊ words = set(in_file.read().split())

➋ missing = []

➌ for word in words:
       try:
           num_syllables = count_syllables.count_syllables(word)
           ##print(word, num_syllables, end='\n') # uncomment to see word counts
    ➍ except KeyError:
           missing.append(word)

➎ print("Missing words:", missing, file=sys.stderr)

清单 8-7:尝试计算训练语料库中单词的音节数,并列出所有失败的情况

打开更新后的train.txt训练语料库,并将其加载为集合,以删除重复项 ➊。创建一个空列表,命名为missing,用于存储无法计数音节的新单词 ➋。missing中的单词将不会出现在 CMUdict 或你的missing_words字典中。

遍历新训练语料库中的单词 ➌,并使用try-except块处理当count_syllables.py找不到单词时抛出的KeyError ➍。将该单词追加到missing列表中,然后显示该列表 ➎。

如果程序显示一个空列表,则说明新哈库中的所有单词已经存在于 CMUdict 或missing_words.json中,因此不需要进行任何调整。否则,你可以选择手动将单词添加到missing_words.json文件中,或者重新运行missing_words_finder.py来重建missing_words.json

总结

在本章中,你已经学习了如何下载 NLTK 并使用其中一个数据集——卡内基梅隆发音词典(CMUdict)。你将 CMUdict 数据集与哈库训练语料库进行了比对,并为任何缺失的单词构建了一个支持的 Python 字典。你使用 JavaScript 对象表示法(JSON)将这个 Python 字典保存为持久数据。最后,你编写了一个可以计算音节的程序。在第九章中,你将使用你的音节计数程序帮助生成新的哈库诗。

进一步阅读

虚拟缪斯:计算机诗歌实验(卫斯理大学出版社,1996 年)由查尔斯·O·哈特曼(Charles O. Hartman)编写,是一本生动有趣的书,探讨了人类与计算机在早期合作写诗的过程。

《使用 Python 进行自然语言处理:使用自然语言工具包分析文本》(O'Reilly,2009)由史蒂文·伯德(Steven Bird)、厄恩·克莱因(Ewan Klein)和爱德华·洛珀(Edward Loper)编写,是一本易于入门的 NLP 书籍,使用 Python 进行操作,包含大量练习和有用的与 NLTK 网站的集成。该书的新版已更新至 Python 3 和 NLTK 3,并可在线访问,网址为* www.nltk.org/book/*。

斯蒂芬·F·德安吉利斯(Stephen F. DeAngelis)的《自然语言处理的重要性日益增长》是Wired杂志上一篇关于自然语言处理(NLP)在大数据中日益扩展角色的文章。在线版本可以在* www.wired.com/insights/2014/02/growing-importance-natural-language-processing/*上查看。

实践项目:音节计数器与词典文件

编写一个 Python 程序,允许你测试 count_syllables.py(或任何其他音节计数 Python 代码)与词典文件的对比。在允许用户指定要检查多少个单词后,随机选择单词并在不同的行上显示每个单词及其音节数。输出应类似于以下打印结果:

ululation 4
intimated 4
sand 1
worms 1
leatherneck 3
contenting 3
scandals 2
livelihoods 3
intertwining 4
beaming 2
untruthful 3
advice 2
accompanying 5
deathly 2
hallos 2

可下载的词典文件列在表格 2-1 中,位于第 20 页。你可以在附录中找到解决方案,并且可以从 www.nostarch.com/impracticalpython/ 下载,文件名为 test_count_syllables_w_dict.py

第九章:9

使用马尔可夫链分析写俳句**

image

计算机可以通过重新排列现有的诗歌来写诗。这基本上就是人类所做的事。你和我没有发明我们使用的语言——我们是学习的。为了说话或写作,我们只是重新组合现有的词汇——而且很少以一种真正原创的方式。正如 Sting 曾经谈到创作音乐时所说:“我不认为流行音乐中有所谓的创作。我认为我们在流行音乐中做的是整理……我是一个很好的整理者。”

在本章中,你将编写一个程序,用马尔可夫链的方式把“最好的词按最好的顺序”排成俳句。但要做到这一点,Python 需要良好的示例,因此你需要提供日本大师们的俳句训练语料库。

为了以有意义的方式重新排列这些词,你将使用马尔可夫链,这个名字来自俄罗斯数学家安德烈·马尔可夫。马尔可夫链分析是概率论中的一个重要部分,它是一种基于当前状态的特性来预测下一个状态的过程。现代应用包括语音和手写识别、计算机性能评估、垃圾邮件过滤以及谷歌的 PageRank 算法。

通过马尔可夫链分析、训练语料库和第八章中的音节计数程序,你将能够创作出符合音节规则的俳句,并且在很大程度上保持“主题一致”。你还将学习如何使用 Python 的logging模块来帮助监控程序的行为,提供简单的开关反馈。同时,在第 184 页的“挑战项目”中,你可以邀请朋友们通过社交媒体来看看他们能否分辨出你模拟的俳句与真正的俳句之间的区别。

项目 #16:马尔可夫链分析

就像第七章中的遗传算法一样,马尔可夫链分析听起来很复杂,但实际上很容易实现。你每天都在做这件事。如果你听到有人说:“Elementary, my dear . . . ,”你会自然而然地想:“Watson。”每当你的大脑听到这个短语时,它都会进行一次样本采集。根据样本数量,它就能预测出答案。另一方面,如果你听到有人说:“I want to go to . . . ,”你可能会想“厕所”或者“电影院”,但大概率不会想到“路易斯安那州的霍马”。有许多可能的答案,但有些答案的可能性更高。

在 20 世纪 40 年代,Claude Shannon 开创了使用马尔可夫链来统计建模文本中字母序列的方式。例如,在一本英文书籍中,每次出现字母组合th时,下一个最可能出现的字母是e

但你不仅仅想知道最可能的字母是什么;你还想知道获得该字母的实际概率,以及获得其他每个字母的概率,这是一个非常适合计算机解决的问题。为了解决这个问题,你需要将文本中的每个二字母对映射到它后面紧接着的字母。这是一个经典的字典应用,二字母对作为键,字母作为值。

当应用于单词中的字母时,马尔可夫模型是一个数学模型,它根据前面连续的k个字母计算一个字母的出现概率,其中k是一个整数。二阶模型意味着字母出现的概率取决于其前面的两个字母。零阶模型意味着每个字母是独立的。这个逻辑同样适用于单词。考虑以下两个俳句示例:

A break in the clouds Glorious the moon
The moon a bright mountaintop Therefore our thanks dark clouds come
Distant and aloof To rest our tired necks

一个将每个俳句单词映射到其后续单词的 Python 字典如下所示:

             'a': ['break', 'bright'],
             'aloof': ['glorious'],
             'and': ['aloof'],
             'break': ['in'],
             'bright': ['mountaintop'],
             'clouds': ['the', 'come'],
             'come': ['to'],
             'dark': ['clouds'],
             'distant': ['and'],
             'glorious': ['the'],
             'in': ['the'],
             'moon': ['a', 'therefore'],
             'mountaintop': ['distant'],
             'our': ['thanks', 'tired'],
             'rest': ['our'],
             'thanks': ['dark'],
             'the': ['clouds', 'moon', 'moon'],
             'therefore': ['our'],
             'tired': ['necks'],
             'to': ['rest']

由于这里只有两个俳句,因此大多数字典键只有一个值。但请看一下列表底部的themoon出现了两次。这是因为马尔可夫模型将每个单词的出现存储为一个单独的重复值。因此,对于键the,如果你随机选择一个值,选择moonclouds的概率是 2:1。相反,模型会自动筛选掉极为罕见或不可能的组合。例如,许多单词可以接在the后面,但不能再接另一个the

以下字典将每一对单词映射到其后面紧接着的单词;这意味着它是一个二阶模型。

             'a break': ['in'],
             'a bright': ['mountaintop'],
             'aloof glorious': ['the'],
             'and aloof': ['glorious'],
             'break in': ['the'],
             'bright mountaintop': ['distant'],
             'clouds come': ['to'],
             'clouds the': ['moon'],
             'come to': ['rest'],
             'dark clouds': ['come'],
             'distant and': ['aloof'],
             'glorious the': ['moon'],
             'in the': ['clouds'],
             'moon a': ['bright'],
             'moon therefore': ['our'],
             'mountaintop distant': ['and'],
             'our thanks': ['dark'],
             'our tired': ['necks'],
             'rest our': ['tired'],
             'thanks dark': ['clouds'],
             'the clouds': ['the'],
             'the moon': ['a', 'therefore'],
             'therefore our': ['thanks'],
             'to rest': ['our']

请注意,映射是从第一个俳句到第二个俳句的,因此字典中包含项'and aloof': ['glorious']'aloof glorious': ['the']。这种行为意味着你的程序可以从一个俳句跳转到另一个,而不仅仅局限于单一俳句中的单词对。它可以自由地形成新的单词对,即使这些单词对是大师们从未设想到的。

由于训练语料非常短,the moon是唯一具有多个值的单词对。对于其他所有单词对,你都“锁定”在单一的结果中。在这个例子中,训练语料的大小极大地决定了每个键的值的数量,但随着语料库的增大,马尔可夫模型中的k值将产生更大的影响。

k的大小决定了你是会生成胡言乱语、抄袭,还是创作出一篇清晰的原创作品。如果k等于 0,那么你将根据单词在语料库中的整体频率随机选择词汇,你很可能会生成大量的废话。如果k值很大,结果会受到严格限制,你会开始逐字复述训练文本。所以,较小的k值促进创造力,较大的k值则促使重复。挑战在于找到两者之间的适当平衡。

举例来说,如果你在之前的俳句上使用一个三阶马尔可夫模型,所有生成的键都会有一个值。与词对月亮相关的两个值将会丢失,因为前一个词对变成了两个键,每个键都有一个唯一的值:

             'the moon a': ['bright'],
             'the moon therefore': ['our']

由于俳句较短——仅 17 个音节——且可用的训练语料库相对较小,使用k值为 2 应该足以强制执行某种秩序,同时仍能允许程序中进行创造性的词汇替换。

目标

编写一个程序,使用马尔可夫链分析生成俳句。允许用户通过独立重新生成第二行和第三行来修改俳句。

策略

模拟俳句的总体策略是,基于人类写的俳句训练语料库,建立一阶和二阶马尔可夫模型。接着,你将使用这些模型和第八章中的count_syllables.py程序生成符合俳句音节结构 5-7-5 的创新俳句。

程序应当逐个生成俳句的每个词,首先从语料库中随机选择一个词来初始化(或播种)俳句;使用一阶马尔可夫模型选择俳句的第二个词;然后用二阶模型选择每一个后续的词。

每个词汇都来源于一个前缀——一个确定要选择哪个词汇进入俳句的词或词对;词汇映射字典中的键表示前缀。因此,前缀决定的词就是后缀

选择和舍弃词汇

当程序选择一个词时,它首先计算该词的音节数,如果该词不合适,就会选择一个新词。如果基于诗句中的前缀没有合适的词语,程序就会求助于我所称之为幽灵前缀,即在俳句中没有出现的前缀。例如,如果俳句中的词对是寺庙钟,且根据马尔可夫模型,所有跟随的词音节数过多,无法完成这一行,程序将随机选择一对新词,并用它来选择俳句中的下一个词。新的词对前缀不应包含在该行中——也就是说,寺庙钟将不会被替换。尽管你可以通过多种方式选择合适的新词,但我更喜欢这种技术,因为它可以通过保持程序中的一致性简化整个过程。

你可以通过图 9-1 和图 9-2 中的函数完成这些步骤。假设你正在处理一个五音节的句子,图 9-1 展示了如果所有选择的词汇都符合音节目标,会发生的高层次情况。

image

图 9-1:五音节俳句行的高层图形伪代码

程序从语料库中随机选择种子词the,然后计算它的音节数。接下来,它基于前缀the从一阶模型中选择bright。然后它计算bright的音节数,并将该音节数加到句子的音节总数中。由于音节总和不超过五,程序将bright添加到句子中,继续从二阶模型中基于前缀The bright选择autumn,然后重复音节计数过程。最后,程序基于前缀bright autumn选择moon,计算音节数,并且由于句子的音节总数正好为五,程序将moon加入句子,完成整个句子。

图 9-2 展示了程序需要利用一个幽灵前缀来成功完成一个五音节句子的情况。

image

图 9-2:选择带有随机选择的幽灵前缀(full white)的新后缀

假设在马尔可夫模型中,跟随前缀temple gong的唯一词是glorious。这个词的音节数对句子来说太多,因此程序随机选择一个幽灵前缀full white。词moon跟随幽灵前缀,并满足句子剩余的音节数,因此程序将其添加到句子中。程序然后丢弃full white前缀,句子完成了。通过这个幽灵前缀技巧,你不能保证新后缀在语境上完全合适,但同时,这是将创造力融入过程的一种方式。

从一行到另一行的延续

马尔可夫模型是让你将俳句注入上下文和意义的“特殊酱汁”,使得每一行能够从一行延续到另一行。日本大师们通常写作的俳句每行是一个独立的短语,但上下文的联系贯穿整首诗,如本朝的这首俳句:

在寂静的午夜

我们的老稻草人倒下了

奇怪的空洞回响

—本朝

即使大师们偏好每一行俳句都代表一个完整的思想,他们也并不严格遵循这一规则。以下是芭村俳句中的一个例子:

我的两棵梅树

如此优雅,看看它们开花

现在一行,之后一行

—芭村

芭蕉的俳句第一行单独来看并不符合语法,因此读者必须不间断地继续读到下一行。当诗句从一行延续到下一行时,没有停顿或语法断裂,这种现象称为跨行连贯。根据《虚拟缪斯》作者查尔斯·哈特曼的说法,跨行连贯使得音步行在诗歌中充满了活力和柔韧性。这是件好事,因为如果没有语法上的延续,很难让算法写出连贯的诗歌。为了让程序在多行之间保持“思路”的延续,你需要使用上一行结尾的词对作为当前行的起始前缀。

最后,你应该给用户提供一个机会,不仅可以构建诗歌,还能通过重新生成第二行和第三行与诗歌进行互动编辑。写作大部分时间是重写,若留下两行完美的诗句,却没有办法重新“投掷骰子”来修改不合适的行,那将是无法容忍的。

伪代码

如果你遵循我刚才所提到的策略,你的高级伪代码应该像这样:

Import count_syllables module
Load a training-corpus text file
Process the training corpus for spaces, newline breaks, and so on
Map each word in corpus to the word after (Markov model order 1)
Map each word pair in corpus to the word after (Markov model order 2)
Give user choice of generating full haiku, redoing lines 2 or 3, or exiting
If first line:
    Target syllables = 5
    Get random word from corpus <= 4 syllables (no 1-word lines)
    Add word to line
    Set random word = prefix variable
    Get mapped words after prefix
    If mapped words have too many syllables
        Choose new prefix word at random & repeat
    Choose new word at random from mapped words
    Add the new word to the line
    Count syllables in word and calculate total in line
    If syllables in line equal target syllables
        Return line and last word pair in line
Else if second or third line:
    Target = 7 or 5
    Line equals last word pair in previous line
    While syllable target not reached:
        Prefix = last word pair in line
        Get mapped words after word-pair prefix
        If mapped words have too many syllables
            Choose new word-pair prefix at random and repeat
        Choose new word at random from mapped words
        Add the new word to the line
        Count syllables in word and calculate total in line
        If total is greater than target
            Discard word, reset total, and repeat
        Else if total is less than target
            Add word to line, keep total, and repeat
        Else if total is equal to target
            Add word to line
    Return line and last word pair in line
Display results and choice menu

训练语料库

马尔可夫模型是从语料库构建的,因此它们是独特的,取决于该语料库。从埃德加·赖斯·巴勒斯的完整作品中构建的模型,将不同于从安妮·赖斯的作品中构建的模型。每个人都有自己的独特风格,或者说是声音,只要样本足够大,马尔可夫方法可以生成一个统计模型来表示你的风格。就像指纹一样,这个模型可以将你与某份文档或手稿联系起来。

为了构建马尔可夫模型,你将使用一个文本文件,该文件包含近 300 首古今俳句,其中 200 多首是由大师们创作的。理想情况下,你的训练语料库应该由成千上万首同一作者创作的俳句组成(以保持一致的“声音”),但这些作品很难找到,尤其是许多古老的日本俳句在音节规则上并不严格遵守,无论是故意的,还是翻译成英语时造成的。

为了增加马尔可夫模型中每个键的值的数量,初始语料库中的俳句被复制了 18 次,并随机分布在文件中。这对俳句内部的单词关联没有影响,但增加了俳句之间的互动。

举例来说,假设下面这首俳句的结尾词对是独特的,仅与第二首俳句的起始词相对应;这将导致一个相当无用的键值对'hollow frog': ['mirror-pond']

张开嘴巴,露出

你整个湿润的内心

傻傻的空心青蛙

镜面池塘的星星

突如其来的夏日阵雨

水面泛起涟漪

如果你复制并打乱这首俳句,你可能会引入一个介词,从而大大增加将奇怪的空心青蛙与某些有意义的事物连接的几率:

张开嘴巴,露出

你整个湿润的内心

傻傻的空心青蛙

城市田野中

沉思樱花树

陌生人如同朋友

马尔可夫模型现在将两个值分配给'hollow frog''mirror-pond''in'。每当你复制俳句时,你会看到每个关键字对应的值的数量增加,但这仅在一定程度上有用;过一段时间后,收益递减开始显现,你会一遍又一遍地添加相同的值,却没有任何新的收获。

调试

调试是找出并修复计算机硬件和软件中的错误(bug)的过程。当你在为复杂问题编写解决方案时,你需要密切关注程序的执行,以便在出现意外情况时找出问题的根源。例如,如果你在俳句的第一行中得到了七个音节,而不是五个,你想知道音节计数函数是否失败,还是映射单词时出了问题,或者程序是否认为它在第二行。为了找出问题出在哪里,你需要监控程序在每个关键步骤返回的内容,这就需要使用临时代码或日志记录。我将在接下来的两节中讨论这两种技术。

构建临时代码

这里定义的临时代码是你为了帮助开发程序而编写的临时代码,完成后会删除。这个名字来源于建筑中的脚手架——是必要的,但没有人希望它永远存在。

一种常见的临时代码是print()语句,用于检查函数或计算的返回值。用户不需要看到输出,因此在确认程序正常工作后,你会删除它。

有用的临时代码输出包括值或变量的类型、数据集的长度以及增量计算的结果。正如 Allen Downey 在《Think Python》中所说,“花时间构建临时代码可以减少你调试时所花的时间。”

使用print()语句进行调试的缺点是,你必须在之后返回并删除(或注释掉)所有这些语句,并且你有可能不小心删除对最终用户有用的print()语句。幸运的是,有一种替代方案可以避免这些问题,它叫做logging模块。

使用 logging 模块

logging模块是 Python 标准库的一部分(docs.python.org/3/library/logging.html)。使用logging,你可以在任何你选择的位置获取程序正在做什么的定制报告。你甚至可以将报告写入永久的日志文件。以下交互式 Shell 示例使用logging检查一个元音计数程序是否正常工作:

➊ >>> import logging
➋ >>> logging.basicConfig(level=logging.DEBUG,
                           format='%(levelname)s - %(message)s')

   >>> word = 'scarecrow'
   >>> VOWELS = 'aeiouy'
   >>> num_vowels = 0
   >>> for letter in word:
           if letter in VOWELS:
               num_vowels += 1
        ➌ logging.debug('letter & count = %s-%s', letter, num_vowels)

   DEBUG - letter & count = s-0
   DEBUG - letter & count = c-0
   DEBUG - letter & count = a-1
   DEBUG - letter & count = r-1
   DEBUG - letter & count = e-2
   DEBUG - letter & count = c-2
   DEBUG - letter & count = r-2
   DEBUG - letter & count = o-3
   DEBUG - letter & count = w-3

要使用 logging 模块,首先导入它 ➊。然后设置你想要查看的调试信息以及显示格式 ➋。DEBUG 级别是最低的信息级别,用于诊断详细信息。请注意,输出使用了字符串格式化 '%s'。你可以包含更多的信息——例如,日期和时间可以通过 format='%(asctime)s' 显示——但对于这段代码,你真正需要检查的只是程序是否正确计数元音。

对于每个评估的字母,输入自定义文本消息以显示变量值。请注意,你必须将非字符串对象(如整数和列表)转换为字符串 ➌。接下来是 logging 输出。你可以看到累计计数,并且可以查看哪些字母实际上改变了计数。

像脚手架一样,logging 是为开发者而不是用户设计的。就像 print() 函数一样,logging 也会使程序变慢。要禁用 logging 消息,只需在导入模块后插入 logging.disable(logging.CRITICAL) 调用,如下所示:

>>> import logging
>>> logging.disable(logging.CRITICAL)

将禁用调用放在程序的顶部,可以让你轻松找到它,并切换消息的开启和关闭。logging.disable() 函数将会抑制指定级别或更低级别的所有消息。由于 CRITICAL 是最高级别,传递它给 logging.disable() 函数会关闭所有消息。这比手动查找并注释掉 print() 语句要好得多!

代码

本节中的 markov_haiku.py 代码将使用名为 train.txt 的训练语料库,准备 Markov 模型作为字典,并一字一字地生成俳句。第八章 中的 count_syllables.py 程序和 missing_words.json 文件将确保 markov_haiku.py 为每行使用正确的音节数。你可以从 www.nostarch.com/impracticalpython/(第九章 文件夹)下载所有这些文件。务必将它们放在同一目录中。

设置

清单 9-1 导入必要的模块,然后加载和准备外部文件。

markov_haiku.py, 第一部分

➊ import sys
   import logging
   import random
   from collections import defaultdict
   from count_syllables import count_syllables

➋ logging.disable(logging.CRITICAL)  # comment out to enable debugging messages
   logging.basicConfig(level=logging.DEBUG, format='%(message)s')

➌ def load_training_file(file):
       """Return text file as a string."""
       with open(file) as f:
        ➍ raw_haiku = f.read()
           return raw_haiku

➎ def prep_training(raw_haiku):
       """Load string, remove newline, split words on spaces, and return list."""
       corpus = raw_haiku.replace('\n', ' ').split()
       return corpus

清单 9-1:导入、加载和准备训练语料库

从单独的行开始列出导入项 ➊。你需要 logging 来接收调试消息,而 defaultdict 将帮助你通过自动创建新键来从列表构建字典,而不是抛出错误。你还会从 第八章 中你编写的 count_syllables.py 程序中导入 count_syllables 函数。你应该已经熟悉这些导入。

在导入语句后紧跟禁用logging的语句,这样你可以轻松找到它。要查看日志信息,你需要注释掉这个语句 ➋。以下语句配置了你将看到的内容,如前一节所述。我选择从显示中省略级别指定。

接下来,定义一个函数来加载训练语料库的文本文件 ➌。使用内置的read()函数将数据作为字符串读取,程序可以在将其转换为列表之前进行准备 ➍。返回该字符串,以供下一个函数使用。

prep_training()函数 ➎接收来自load_training_file()函数的输出作为参数。然后,它将换行符替换为空格,并根据空格将单词拆分成列表项。最后,函数将语料库作为列表返回。

构建马尔可夫模型

马尔可夫模型实际上是 Python 字典,使用单词或单词对作为键,紧随其后的单词作为值。通过重复尾部单词在值列表中的出现,捕获尾部单词的统计频率——类似于集合,字典不能有重复的,但可以有重复的

列表 9-2 定义了两个函数。这两个函数都以语料库作为参数,并返回一个马尔可夫模型。

markov_haiku.py, 第二部分

➊ def map_word_to_word(corpus):
       """Load list & use dictionary to map word to word that follows."""
    ➋ limit = len(corpus) - 1
    ➌ dict1_to_1 = defaultdict(list)
    ➍ for index, word in enumerate(corpus):
           if index < limit:
            ➎ suffix = corpus[index + 1]
               dict1_to_1[word].append(suffix)
    ➏ logging.debug("map_word_to_word results for \"sake\" = %s\n",
                    dict1_to_1['sake'])
    ➐ return dict1_to_1
➑ def map_2_words_to_word(corpus):
       """Load list & use dictionary to map word-pair to trailing word."""
    ➒ limit = len(corpus) - 2
       dict2_to_1 = defaultdict(list)
       for index, word in enumerate(corpus):
           if index < limit:
            ➓ key = word + ' ' + corpus[index + 1]
               suffix = corpus[index + 2]
               dict2_to_1[key].append(suffix)
       logging.debug("map_2_words_to_word results for \"sake jug\" = %s\n",
                     dict2_to_1['sake jug'])
       return dict2_to_1

列表 9-2:定义了构建一阶和二阶马尔可夫模型的函数

首先,定义一个函数,将每个单词映射到其尾部单词 ➊。该程序只会使用此函数从种子词中选择俳句的第二个单词。它的唯一参数是prep_training()函数返回的语料库列表。

设置一个限制,以便不能选择语料库中的最后一个单词 ➋,因为这样会导致索引错误。现在,使用defaultdict ➌初始化一个字典。你希望字典的值是列表,用于存储你找到的所有后缀,因此将list作为参数传递。

开始遍历语料库中的每个单词,使用enumerate将每个单词的索引转换为对象 ➍。使用条件判断和limit变量来防止选择最后一个单词作为键。定义一个名为suffix的变量,表示尾部单词 ➎。其值将是当前单词的索引位置加 1——即列表中的下一个单词。将这个变量作为当前单词的值添加到字典中。

为了检查一切是否按计划工作,使用logging显示单个键的结果 ➏。语料库中有成千上万的单词,因此你不想打印出所有单词。选择一个你知道在语料库中存在的单词,比如sake。请注意,你使用的是旧的字符串格式化方法%,因为它适合当前日志记录器的设计。最后返回字典 ➐。

下一个函数map_2_words_to_word()基本上与前一个函数相同,只是它使用两个连续的单词作为键,并映射到后续的单个单词➑。重要的变化是将限制设定为距语料库结尾两个单词的地方➒,使得键由两个单词组成,并且中间有空格➓,并且对suffix的索引加 2。

选择随机单词

没有关键字,程序无法利用 Markov 模型,因此用户或程序必须提供模拟俳句中的第一个单词。列表 9-3 定义了一个函数,随机选择一个第一个单词,方便自动化种子生成。

markov_haiku.py,第三部分

➊ def random_word(corpus):
       """Return random word and syllable count from training corpus."""
    ➋ word = random.choice(corpus)
    ➌ num_syls = count_syllables(word)
    ➍ if num_syls > 4:
           random_word(corpus)
       else:
        ➎ logging.debug("random word & syllables = %s %s\n", word, num_syls)
           return (word, num_syls)

列表 9-3:随机选择一个种子单词以启动俳句

定义该函数并将corpus列表传递给它➊。然后分配一个word变量,并使用randomchoice()方法从语料库中选择一个单词➋。

使用count_syllables()函数,该函数来自count_syllables模块,用于计算单词的音节数;将计算结果存储在num_syls变量中➌。我不喜欢在俳句中使用单一单词的行,所以不允许该函数选择音节数超过四个的单词(记住,最短的俳句行有五个音节)。如果发生这种情况,请递归调用random_word()函数,直到找到一个合适的单词➍。请注意,Python 的默认最大递归深度为 1,000,但只要使用的是合适的俳句训练语料库,找到合适单词之前几乎不可能超过这个限制。如果情况并非如此,你可以稍后通过使用while循环调用该函数来处理这个条件。

如果单词的音节数少于五个,请使用logging显示单词及其音节数➎;然后将单词和音节数作为元组返回。

应用 Markov 模型

为了选择紧跟种子单词的单个单词,请使用 Markov 模型的 1 阶。之后,程序应使用 2 阶模型选择所有后续单词,该模型使用单词对作为键。列表 9-4 为每个这些操作定义了一个单独的函数。

markov_haiku.py,第四部分

➊ def word_after_single(prefix, suffix_map_1, current_syls, target_syls):
       """Return all acceptable words in a corpus that follow a single word."""
    ➋ accepted_words = []
    ➌ suffixes = suffix_map_1.get(prefix)
    ➍ if suffixes != None:
        ➎ for candidate in suffixes:
               num_syls = count_syllables(candidate)
               if current_syls + num_syls <= target_syls:
                ➏ accepted_words.append(candidate)
    ➐ logging.debug("accepted words after \"%s\" = %s\n",
                     prefix, set(accepted_words))
       return accepted_words

➑ def word_after_double(prefix, suffix_map_2, current_syls, target_syls):
       """Return all acceptable words in a corpus that follow a word pair."""
       accepted_words = []
    ➒ suffixes = suffix_map_2.get(prefix)
       if suffixes != None:
           for candidate in suffixes:
               num_syls = count_syllables(candidate)
               if current_syls + num_syls <= target_syls:
                   accepted_words.append(candidate)
       logging.debug("accepted words after \"%s\" = %s\n",
                     prefix, set(accepted_words))
    ➓ return accepted_words

列表 9-4:两个根据前缀、Markov 模型和音节数选择单词的函数

定义一个名为word_after_single()的函数,用于根据前一个单一的种子单词选择俳句中的下一个单词。该函数的参数包括前一个单词、Markov 1 阶模型、当前音节数和目标音节数➊。

开始一个空列表,用于存储符合条件的单词,这些单词既跟随前缀,又没有超出音节目标的音节数➋。将这些后续单词命名为suffixes,并使用字典的get()方法,该方法根据键返回字典值,将这些单词分配给变量➌。如果请求一个字典中不存在的键,get()方法不会引发KeyError,而是返回None

存在一个极为罕见的情况,即前缀可能是语料库中的最后一个单词,并且该单词是唯一的。在这种情况下,将没有后缀。使用 if 语句来预见这种情况 ➍。如果没有后缀,调用 word_after_single() 的函数——你将在下一部分定义——将选择一个新的前缀。

每个后缀都代表一个候选单词,但程序尚未确定该候选词是否“合适”。因此,使用 for 循环、count_syllables 模块和 if 语句来判断将该单词添加到行中是否会违反每行的目标音节数➎。如果目标音节数未超出,则将该单词添加到已接受单词列表中➏。通过 logging 消息显示这些可接受的单词,然后返回它们➐。

下一个函数 word_after_double() 和之前的函数类似,不同之处在于它接收的是单词对以及马尔可夫二阶模型(suffix_map_2)➑,并从该字典中获取后缀➒。但和 word_after_single() 函数一样,word_after_double() 返回的是一个可接受单词的列表➓。

生成俳句行

准备好所有辅助函数后,你可以定义实际编写俳句行的函数。该函数可以构建完整的俳句或仅更新第二行或第三行。有两条路径可以选择:一种是在程序只有一个词的后缀时使用,另一种则适用于其他所有情况。

构建第一行

清单 9-5 定义了写入俳句行并初始化俳句第一行的函数。

markov_haiku.py, 第五部分

➊ def haiku_line(suffix_map_1, suffix_map_2, corpus, end_prev_line, target_syls):
       """Build a haiku line from a training corpus and return it."""
    ➋ line = '2/3'
       line_syls = 0
       current_line = []
    ➌ if len(end_prev_line) == 0:  # build first line
        ➍ line = '1'
        ➎ word, num_syls = random_word(corpus)
           current_line.append(word)
           line_syls += num_syls
        ➏ word_choices = word_after_single(word, suffix_map_1,
                                            line_syls, target_syls)
        ➐ while len(word_choices) == 0:
               prefix = random.choice(corpus)
               logging.debug("new random prefix = %s", prefix)
               word_choices = word_after_single(prefix, suffix_map_1,
                                                line_syls, target_syls)
        ➑ word = random.choice(word_choices)
           num_syls = count_syllables(word)
           logging.debug("word & syllables = %s %s", word, num_syls)
        ➒ line_syls += num_syls
           current_line.append(word)
        ➓ if line_syls == target_syls:
               end_prev_line.extend(current_line[-2:])
               return current_line, end_prev_line

清单 9-5:定义了写入俳句行并初始化第一行的函数

定义一个函数,接受两个马尔可夫模型、训练语料库、上一行最后一个单词对以及当前行的目标音节数➊。立即使用一个变量来指定正在模拟的是哪一行俳句➋。大多数处理将针对第二行和第三行(以及可能是第一行的最后一部分),这时你会使用已有的单词对前缀,因此将这些作为基础情况。之后,开始一个计数器来统计当前行的音节总数,并初始化一个空列表来存放当前行的单词。

使用一个 if 语句,当 end_prev_line 参数的长度——上一行最后两个单词的音节数为 0 时,表示没有前一行,此时你就在第一行➌。该 if 块中的第一条语句将 line 变量设置为 1 ➍。

选择初始种子词,并通过调用random_word()函数 ➎获取其音节数。通过将wordnum_syls变量一起赋值,你实际上是在“解包”random_word()函数返回的(word, num_sylls)元组。函数在return语句处结束,因此返回元组是返回多个变量的好方法。在这个程序的更高级版本中,你可以使用带有yield关键字的生成器函数,因为yield返回一个值而不放弃执行控制。

接下来,将word附加到current_line并将num_syls加到运行总数中。现在你已经有了种子,使用word_after_single()函数 ➏收集该种子的所有可能后缀。

如果没有可接受的单词,启动一个while循环来处理这种情况。该循环将持续进行,直到返回一个非空的可接受单词列表 ➐。程序将选择一个新的前缀——一个幽灵前缀——使用random模块的choice方法。(记住,这个前缀不会成为俳句的一部分,而仅仅是用于重新访问马尔可夫模型。)在while循环内部,一条logging消息将告诉你选择了哪个幽灵前缀。然后程序将再次调用word_after_single()函数。

一旦可接受的单词列表构建完成,再次使用choiceword_choices列表中选择一个单词 ➑。因为列表可能包含重复的单词,所以这就是你看到马尔可夫模型统计影响的地方。接下来,计算单词的音节数并logging结果。

将音节计数加到该行的运行总数,并将单词附加到current_line列表 ➒。

如果前两个词的音节数等于 5 ➓,则定义一个变量end_prev_line并将其赋值为上一行的最后两个词;这个变量是第二行的前缀。最后,返回整行和end_prev_line变量。

如果第一行的目标音节数还没有达到,程序将跳转到下一部分的while循环以完成这一行。

构建剩余的行

在列表 9-6 中,haiku_line()函数的最后部分处理了这样一种情况,即俳句已经包含了一个词对前缀,程序可以在马尔可夫模型 2 中使用它。程序利用这个前缀来完成第一行——假设前两个词还没有总共五个音节——并构建第二行和第三行。用户还可以在完整的俳句写完后重新生成第二行或第三行。

markov_haiku.py, 第六部分

    ➊ else: # build lines 2 and 3
        ➋ current_line.extend(end_prev_line)

    ➌ while True:
           logging.debug("line = %s\n", line)
        ➍ prefix = current_line[-2] + ' ' + current_line[-1]
        ➎ word_choices = word_after_double(prefix, suffix_map_2,
                                            line_syls, target_syls)
        ➏ while len(word_choices) == 0:
               index = random.randint(0, len(corpus) - 2)
               prefix = corpus[index] + ' ' + corpus[index + 1]
               logging.debug("new random prefix = %s", prefix)
               word_choices = word_after_double(prefix, suffix_map_2,
                                                line_syls, target_syls)
           word = random.choice(word_choices)
           num_syls = count_syllables(word)
           logging.debug("word & syllables = %s %s", word, num_syls)

        ➐ if line_syls + num_syls > target_syls:
               continue
           elif line_syls + num_syls < target_syls:
               current_line.append(word)
               line_syls += num_syls
           elif line_syls + num_syls == target_syls:
               current_line.append(word)
               break

    ➑ end_prev_line = []
       end_prev_line.extend(current_line[-2:])

    ➒ if line == '1':
           final_line = current_line[:]
       else:
           final_line = current_line[2:]

       return final_line, end_prev_line

列表 9-6:使用马尔可夫模型 2 来完成写俳句行的函数

从一个else语句开始,只有在存在后缀时才会执行 ➊。由于haiku_line()函数的最后部分必须处理第一行以及第二行和第三行,因此使用一个技巧,在步骤➑的条件外,将end_prev_line列表(在步骤➑外部构建)添加到current_line列表中 ➋。稍后,当你将最终的行添加到俳句中时,你将丢弃这个前导单词对。

启动一个while循环,直到该行的目标音节数达到为止 ➌。每次迭代的开始都包含一条调试信息,告诉你当前循环正在评估的路径:'1''2/3'

将上一行的最后两个单词添加到当前行的开头时,当前行的最后两个单词将始终是前缀 ➍。

使用马尔科夫顺序 2 模型,创建一个可接受的单词列表 ➎。若该列表为空,程序将使用幽灵前缀过程 ➏。

使用音节数来评估接下来该做什么 ➐。如果音节过多,使用continue语句重新开始while循环。如果音节不足,将单词附加到当前行,并将其音节数加到该行的音节数中。否则,将单词附加到行中并结束循环。

将行中的最后两个单词赋值给end_prev_line变量,以便程序将其作为下一行的前缀 ➑。如果当前路径是行'1',将当前行复制到名为final_line的变量中;如果路径是行'2/3',使用索引切片排除前两个单词,然后赋值给final_line ➒。这就是如何从第二行或第三行中移除初始的end_prev_line单词对。

编写用户界面

清单 9-7 定义了markov_haiku.py程序的main()函数,该函数运行设置功能和用户界面。界面向用户呈现一个选项菜单,并显示生成的俳句。

markov_haiku.py,第七部分

def main():
    """Give user choice of building a haiku or modifying an existing haiku."""
    intro = """\n
    A thousand monkeys at a thousand typewriters...
    or one computer...can sometimes produce a haiku.\n"""
    print("{}".format(intro))

 ➊ raw_haiku = load_training_file("train.txt")
    corpus = prep_training(raw_haiku)
    suffix_map_1 = map_word_to_word(corpus)
    suffix_map_2 = map_2_words_to_word(corpus)
    final = []

    choice = None
 ➋ while choice != "0":

     ➌ print(
            """
            Japanese Haiku Generator

            0 - Quit
            1 - Generate a Haiku
            2 - Regenerate Line 2
            3 - Regenerate Line 3
            """
            )

     ➍ choice = input("Choice: ")
        print()

        # exit
     ➎ if choice == "0":
            print("Sayonara.")
            sys.exit()

        # generate a full haiku
     ➏ elif choice == "1":
            final = []
            end_prev_line = []
            first_line, end_prev_line1 = haiku_line(suffix_map_1, suffix_map_2,
                                                    corpus, end_prev_line, 5)
            final.append(first_line)
            line, end_prev_line2 = haiku_line(suffix_map_1, suffix_map_2,
                                              corpus, end_prev_line1, 7)
            final.append(line)
            line, end_prev_line3 = haiku_line(suffix_map_1, suffix_map_2,
                                              corpus, end_prev_line2, 5)
            final.append(line)

        # regenerate line 2
     ➐ elif choice == "2":
            if not final:
                print("Please generate a full haiku first (Option 1).")
                continue
            else:
                line, end_prev_line2 = haiku_line(suffix_map_1, suffix_map_2,
                                                  corpus, end_prev_line1, 7)
                final[1] = line

        # regenerate line 3
     ➑ elif choice == "3":
            if not final:
                print("Please generate a full haiku first (Option 1).")
                continue
            else:
                line, end_prev_line3 = haiku_line(suffix_map_1, suffix_map_2,
                                                  corpus, end_prev_line2, 5)
                final[2] = line

        # some unknown choice
     ➒ else:
            print("\nSorry, but that isn't a valid choice.", file=sys.stderr)
            continue

     ➓ # display results
        print()
        print("First line = ", end="")
        print(' '.join(final[0]), file=sys.stderr)
        print("Second line = ", end="")
        print(" ".join(final[1]), file=sys.stderr)
        print("Third line = ", end="")
        print(" ".join(final[2]), file=sys.stderr)
        print()

    input("\n\nPress the Enter key to exit.")

if __name__ == '__main__':
    main()

清单 9-7:启动程序并呈现用户界面

在介绍信息之后,加载并准备训练语料库,并构建两个马尔科夫模型。然后创建一个空列表来存储最终的俳句 ➊。接下来,命名一个choice变量并将其设置为None。启动一个while循环,直到用户选择 0 ➋。通过输入0,用户决定退出程序。

使用带三引号的 print() 语句来显示菜单 ➌,然后获取用户的选择 ➍。如果用户选择 0,退出并说再见 ➎。如果用户选择 1,他们希望程序生成一个新的俳句,所以重新初始化 final 列表和 end_prev_line 变量 ➏。然后为三行调用 haiku_line() 函数,并传递正确的参数——包括每行的目标音节数。请注意,end_prev_line 变量名会随每行变化;例如,end_prev_line2 存储第二行的最后两个词。最后一个变量 end_prev_line3 只是一个占位符,以便你可以重用函数;换句话说,它从未被实际使用。每次调用 haiku_line() 函数时,它都会返回一行,你需要将其附加到 final 列表中。

如果用户选择 2,程序会重新生成第二行 ➐。在程序重新构建一行之前,需要有一首完整的俳句,因此需要使用 if 语句来处理用户提前操作的情况。然后调用 haiku_line() 函数,并确保传递 end_prev_line1 变量,将其与前一行连接,并将音节目标设置为七个音节。将重建的行插入到 final 列表的索引 1 位置。

如果用户选择 3,重复此过程,只不过将音节目标设置为 5,并将 end_prev_line2 传递给 haiku_line() 函数 ➑。将这一行插入到 final 列表的索引 2 位置。

如果用户输入菜单中没有的选项,提醒他们并继续循环 ➒。最后显示俳句。使用 join() 方法和 file=sys.stderr 来在 shell 中打印出漂亮的输出 ➓。

使用标准代码结束程序,以便将程序作为模块或独立模式运行。

结果

要评估一个写诗程序,你需要用客观标准来衡量一些主观的东西——诗歌是否“好”。对于 markov_haiku.py 程序,我提出以下基于原创性和人性化两个标准的分类:

重复 训练语料库中的俳句逐字重复。

一首俳句——至少对某些人来说——与人类诗人写的俳句几乎无法区分。它应该代表初步结果,或是第二行或第三行经过几次重生后的结果。

种子 一首有一定价值的俳句,但许多人可能会怀疑它是由计算机写的,或者一首你可以通过更改或重新排列不超过两个词来转变为一首好俳句的作品(稍后会更详细说明)。这可能需要对第二行或第三行进行多次重生。

垃圾 一首明显是随机拼凑的词汇组合,毫无诗意可言。

如果你使用程序生成大量俳句,并将结果归入这些类别,你可能会得到图 9-3 中的分布情况。大约 5%的时间,你会重复训练语料库中的现有俳句;10%的时间,你会生成一首好俳句;约 25%会是及格或可修正的俳句;其余的则是垃圾。

image

图 9-3:使用 markov_haiku.py 生成 500 个俳句的主观结果

考虑到马尔可夫过程的简单性,图 9-3 中的结果令人印象深刻。再引用查尔斯·哈特曼的话,“这里是语言从无到有,从纯粹的统计噪声中自我创造出来……我们可以看着意义逐渐演化,意识慢慢站立在自己奇迹般的双腿上。”

好俳句

以下是一些被归类为“好”的模拟俳句的示例。在第一个示例中,程序巧妙地——如果你不知道是算法的功劳,你甚至可以说是“巧妙地”——改变了我在第八章中的俳句,生成了一个具有相同含义的新俳句。

我所放任的云层

我自己假装远离

遥远的山脉

在下一个示例中,程序成功地复制了传统俳句中的常见主题:图像或思想的并置。

我凝视着的镜子

显示的是我父亲的面容

一池古老寂静的池塘

在这个例子中,你会发现镜子其实就是静止池塘的表面,尽管你可以将脸本身解读为池塘。

运行程序有点像淘金:有时你会找到一块金块。左边的俳句是 300 多年前由 Ringai 创作的。在右边的俳句中,程序做了微妙的修改,使得诗句现在唤起了晚春霜冻的画面——这是季节进程中的一次倒退。

在这黑暗的水中从我冰冻的井中汲起闪闪发光的春天            —Ringai 从我的冰冻井中汲起闪闪发光的春天站立不动                         —Python

以下是更多“好”俳句的示例。第一个俳句尤为突出,因为它是由训练语料库中的三个独立俳句组成的,但在整个过程中保持了清晰的上下文联系。

当我走在小路上

十一位勇敢的骑士在风暴中疾驰

通过风雨中的树林

一条摇摆的线

穿越黑暗的深红色天空

在这个冬日的池塘上

这样的一种生命

生锈的门吱吱作响

连事物都感受到痛苦

石桥!坐着

静静地什么也不做

然而春天来临,草长

黑暗的天空,哦!秋天

雪花!一只腐烂的南瓜

崩塌且被覆盖

荒凉的沼泽变得模糊

黑色的云层破碎,四散开来

在松树中,坟墓

种子俳句

计算机协助人类写诗的概念已经存在一段时间。诗人通常通过模仿早期的诗歌来“启发灵感”,没有理由认为计算机不能作为网络合作的一部分,提供初稿。即使是相当糟糕的计算机创作,也有可能为创作过程“播种”,并帮助人类克服创作障碍。

以下是markov_haiku.py程序生成的三个种子俳句示例。左侧是计算机生成的略显不准确的俳句,右侧是我调整后的版本。我在每个版本中只更改了一个单词,并将其加粗显示。

我的生命必须像另一个花朵一样结束,什么饥饿的风 我的生命必须像另一个花朵一样结束,什么饥饿的风是死亡
码头漂浮在热烈抚摸的夜晚中,正是在黎明前 码头漂浮在热烈抚摸的夜晚中,正是在黎明前
月升在坟墓上,我的旧悲伤一把锋利的铲子刺星星 月升在坟墓上,我的旧悲伤一把锋利的铲子刺星星

最后的句子含义隐晦,但似乎有效,因为它充满了自然的联想(如月亮与星星、坟墓与铲子、坟墓与悲伤)。无论如何,你不必过于纠结其含义。用 T.S. 艾略特的话来说:意义就像是小偷给狗的肉,用来分散注意力,让诗歌完成它的工作!

总结

经过两章的学习,你现在已经有了一个能够模拟由大师创作的日本俳句的程序——至少能为人类诗人提供一个有用的起点。此外,你还应用了logging模块来监控程序在关键步骤中的操作。

进一步阅读

虚拟缪斯:计算机诗歌实验(Wesleyan University Press,1996 年)由查尔斯·O·哈特曼(Charles O. Hartman)编著,是一本引人入胜的书,探讨了人类与计算机早期合作创作诗歌的过程。

如果你想了解更多关于克劳德·香农的信息,可以阅读吉米·索尼(Jimmy Soni)和罗德·古德曼(Rod Goodman)编著的玩转思维:克劳德·香农如何发明信息时代(Simon & Schuster,2017 年)。

你可以在 Global Grey 网站(www.globalgreyebooks.com/)找到日本俳句:二百二十首十七音节诗*(The Peter Pauper Press,1955 年) 的数字版,译者为彼得·贝伦森(Peter Beilenson)。

在论文《Gaiku:利用词汇联想规范生成俳句》(计算语言学协会,2009)中,Yael Netzer 及其合著者探讨了使用词汇联想规范(WANs)生成俳句的方法。你可以通过向人们提交触发词并记录他们的即时反应(例如,将houseflyarrestkeeper等联想)来建立 WAN 语料库。这会产生类似人类生成的俳句中紧密联系、直觉性的关系。你可以在网上找到这篇论文,网址是www.cs.brandeis.edu/~marc/misc/proceedings/naacl-hlt-2009/CALC-09/pdf/CALC-0905.pdf

用 Python 自动化无聊的事》(No Starch Press, 2015)由 Al Sweigart 编写,其中有一章关于调试技巧的有用概述,包括logging

挑战项目

我在这一部分中描述了一些衍生项目的建议。与所有挑战项目一样,你将独立完成——不会提供解决方案。

新词生成器

在他 1961 年获奖的科幻小说《陌生的土地》中,作家 Robert A. Heinlein 创造了词汇grok,表示深刻的直觉理解。这个词进入了流行文化——特别是计算机编程文化——现在已被收入牛津英语词典

想出一个听起来合法的新词并不容易,部分原因是人类对我们已经知道的词汇有很强的依赖性。但是计算机不受这种困扰。在《虚拟缪斯》中,Charles Hartman 观察到,他的诗歌创作程序有时会生成一些有趣的字母组合,例如runkinavatheformitor,这些组合很容易代表新的词汇。

编写一个程序,使用马尔科夫顺序 2、3 和 4 模型重新组合字母,并使用该程序生成有趣的新词。给它们定义一个意思并开始使用它们。谁知道呢——你可能会创造出下一个frickinfrabjouschortletrill

图灵测试

根据 Alan Turing 的说法:“如果一台计算机能够欺骗一个人,让他相信它是人类,那么它就应该被称为智能。”利用你的朋友测试markov_haiku.py程序生成的俳句。将计算机生成的俳句与一些大师或你自己写的俳句混合。由于计算机生成的俳句往往是断句的,因此要小心选择人类的俳句,这些俳句也需要是断句的,以防聪明的朋友轻松识破。使用小写字母和最少的标点符号也有助于此。我在图 9-4 中提供了一个例子,使用了 Facebook。

image

图 9-4:Facebook 上的图灵测试实验示例

难以置信!这简直太难以置信了!难以置信!

特朗普总统以简短、简单的句子著称,他的句子使用“最好的词汇”,而简短、简单的句子非常适合俳句。事实上,华盛顿邮报曾刊登过他一些竞选演讲中无意间形成的俳句。以下是其中的一些:

他是个非常棒的人。

我前几天见过他。

在电视上。

他们想外出。

他们想过上好生活。

他们想努力工作。

我们必须做到这一点。

我们需要合适的人选。

所以福特会回归。

使用唐纳德·特朗普演讲的在线文字稿,为markov_haiku.py程序构建一个新的训练语料库。记得你需要重新访问第八章,并为卡内基梅隆大学发音词典中没有的单词构建一个新的“缺失词汇”字典。然后重新运行程序,生成能够捕捉这个历史时刻的俳句。保存最好的作品,并重新挑战图灵测试,看看你的朋友能否分辨出你的俳句和真正的特朗普名言。

写俳句,还是不写俳句

威廉·莎士比亚写了许多符合俳句音节结构的著名短语,如“我们的昨日”,“心灵之刃”,以及“离别是如此甜蜜的悲伤”。使用一部或多部莎士比亚的剧作作为markov_haiku.py程序的训练语料库。这里的最大挑战是计算那些古老英语的音节。

马尔可夫音乐

如果你有音乐天赋,可以在线搜索“用马尔可夫链作曲”。你应该能找到很多关于如何使用马尔可夫链分析作曲的资料,方法是使用现有歌曲的音符作为训练语料库。生成的“马尔可夫音乐”就像我们的种子俳句——为人类词曲作者提供灵感。

第十章:我们是孤独的吗?探索费米悖论**

image

科学家们使用德雷克方程来估算当前在银河系内可能存在的能够产生电磁辐射(如无线电波)的文明数量。在 2017 年,这个方程被更新,以考虑到 NASA 开普勒卫星发现的新系外行星。这个结果发布在科学期刊天体生物学上,令人震惊。为了让人类成为第一个也是唯一的技术先进物种,在宜居外星行星上出现高级文明的概率必须低于 1000 万亿分之一!然而,正如诺贝尔物理学奖得主恩里科·费米(Enrico Fermi)著名的观察,“他们都在哪儿?”

费米对星际旅行的怀疑超过了对外星生命存在的怀疑,但他的这个问题后来成为了费米悖论,并转变为推测:“如果他们在那里,他们早该到这里了。”根据 SETI 研究所的说法,即使只有适度的火箭技术,一个渴望的文明也能在 1000 万年内探索整个银河系,甚至可能殖民银河系。虽然这听起来像是很长的时间,但它只有银河系年龄的 1/1000!因此,有些人开始接受费米悖论作为我们在宇宙中孤独的证据,另一些人则发现这个论点存在漏洞。

在本章中,你将通过根据德雷克方程的传输量和输出计算一个文明检测另一个文明的概率,来研究外星无线电信号的缺失。你还将使用 Python 的事实标准 GUI 包tkinter,快速且轻松地创建银河系的图形模型。

项目 #17:模拟银河系

我们的银河系是一个相当常见的螺旋星系,就像图 10-1 所示的那样。

image

图 10-1:螺旋星系 NGC 6744,银河系的“亲兄弟”

从横截面看,银河系是一个扁平的盘状结构,中央膨胀区很可能包含一个超大质量黑洞。四条“螺旋臂”——由相对密集的气体、尘埃和恒星组成——从这个中央质量辐射开来。银河系的尺寸如图 10-2 所示。

image

图 10-2:银河系的示意轮廓(边缘视图)(LY = 光年)和简化模型

由于与更密集的恒星相关的高辐射水平,银河系的中心被认为对生命相当不适宜。因此,对于本项目,你可以将银河系简化为一个简单的盘状结构,忽略一些膨胀区的体积,但仍为核心附近的一些高级文明留出空间(参见图 10-2 中的银河模型)。

目标

对于给定数量的高级银河文明和平均无线电气泡大小,估算任何文明检测到任何其他文明无线电传输的概率。为了提供视角,可以将地球当前的无线电气泡大小绘制在银河系的二维图形表示上。

策略

以下是完成此项目所需的步骤:

  1. 使用德雷克方程估算传输文明的数量。

  2. 选择它们无线电气泡的大小范围。

  3. 生成一个公式来估算一个文明检测到另一个文明的概率。

  4. 构建银河系的图形模型,并绘制地球的无线电辐射气泡。

为了使描述尽可能接近代码,以下任务将在各自的章节中详细描述。请注意,前两步并不需要使用 Python。

估算文明数量

您可以手动使用德雷克方程估算高级文明的数量:

N = R^* · f[p] · n[e] · f[l] · f[i] · f[c] · L

其中:

N = 我们银河系中电磁辐射可被检测到的文明数量

R^* = 银河系中恒星的平均形成率(每年新恒星数量)

f^p = 拥有行星的恒星比例

n^e = 对于拥有行星的恒星,适合生命存在的行星的平均数量

f^l = 发展出生命的行星的比例

f^i = 具有智能、文明生命的生命承载行星的比例

f^c = 将其存在迹象释放到太空中的文明的比例

L = 文明释放可检测信号的时间长度(单位:年)

得益于近年来系外行星探测的进展,前三个组件(R*,*f*p,ne)正变得越来越受限制。对于*n*e,近期的研究表明,所有行星中可能有 10%到 40%的行星适合某种形式的生命存在。

对于其余的组件,地球是唯一的例子。在地球的 45 亿年历史中,智人只存在了 20 万年,文明只存在了 6,000 年,无线电传输也仅有 112 年的历史。关于L,战争、瘟疫、冰河时代、小行星撞击、超级火山、超新星爆炸和日冕物质抛射等都可能破坏文明传播无线电信号的能力。传输时间越短,文明共存的可能性越小。

根据维基百科上关于德雷克方程的文章(en.wikipedia.org/wiki/Drake_equation),在 1961 年,德雷克及其同事估计银河系中有通讯的文明数量在 1,000 到 1 亿之间。最近的更新将这一范围定为 1(只有我们)到 15,600,000(表格 10-1)。

表格 10-1: 一些德雷克方程的输入和结果

参数 德雷克 1961 年 德雷克 2017 年 您的选择
R^* 1 3
f[p] 0.35 1
n^e 3 0.2
f[l] 1 0.13
f[i] 1 1
f[c] 0.15 0.2
L 50 × 10⁶ 1 × 10⁹
N 7.9 × 10⁶ 15.6 × 10⁶
**显示的范围的中点

对于程序的输入,您可以使用表中的估算值、在网上找到的估算值或您自己计算的值(在表格的最后一列)。

选择射电气泡的尺寸

没有聚焦成束以进行定向传输的射电波是附带的。可以将这些视为“行星泄漏”。因为我们选择不向可能来吃掉我们的外星人广播我们的位置,所以我们几乎所有的传输都是附带的。这些传输目前形成了一个围绕地球扩展的球体,直径大约为 225 光年(LY)。

一个 225 光年的气泡听起来很震撼,但真正重要的是 可探测 的大小。射电波前受到 平方反比定律 的制约,这意味着它在扩展过程中持续失去功率密度。由于吸收或散射,额外的功率损失可能会发生。在某个时刻,信号变得太弱,无法与背景噪声区分开。即使是我们最先进的技术——突破监听计划中的射电望远镜——也只能探测到我们自己射电气泡大约 16 光年远的地方。

由于我们实际上是在调查为什么 我们 没有探测到外星人,您应该假设在这个项目中,其他文明的技术与我们相似。另一个假设应该是,像我们一样,所有外星人都有一种偏执的行星意识,并且没有广播“我们在这里”的信号来宣布它们的存在。研究那些比我们目前可以探测到的气泡稍小到稍大的气泡尺寸,应该是一个合理的起点。这将建议一个 30 到 250 光年的直径范围。尽管我们无法探测到一个 250 光年的气泡,但如果我们能够探测到的话,看看概率会是多少也会很有趣。

生成检测概率的公式

随着银河系中高级文明数量的增加,发现另一个文明的概率也随之增加。这是直观的,但如何分配实际的概率呢?

计算机的一个优点是,它们允许我们通过暴力破解的方式,找到那些可能直观也可能不直观的解决方案。这里的一种方法是制作一个银河系盘面的三维模型,随机分布文明,并使用 Python 的许多工具之一来计算它们之间的欧几里得距离。但由于需要分析的文明数量可能达到数亿,这种方法在计算上将非常昂贵。

由于我们处理的是巨大的未知数,没必要追求超精确。我们只需要大致正确,因此一个简单的简化方法是将银河系划分为一系列无线电气泡“等效体积”,方法是将银河圆盘体积除以无线电气泡体积(见图 10-3)。

image

图 10-3:用体积等同于 200 光年无线电气泡的立方体来建模银河系

你可以使用以下方程来求体积,其中R是银河圆盘的半径,r是无线电气泡的半径:

圆盘体积 = π × R² × 圆盘高度

无线电气泡体积 = 4/3 × π × r³

缩放圆盘体积 = 圆盘体积 / 无线电气泡体积

缩放圆盘体积是“适合”银河系的等效体积数量。可以把这些当作从 1 到最大体积数编号的盒子。

要放置文明,你只需随机选择一个盒子编号。重复选择表示在同一个盒子内有多个文明。假设同一个盒子内的文明可以相互检测。这并不完全正确(见图 10-4),但由于你将使用大量文明,误差往往会相互抵消,就像求和大量四舍五入的数字一样。

image

图 10-4:单个等效体积级别的检测问题

为了避免每次更改文明数量和/或无线电气泡尺寸时都要重复这个过程,你可以将结果以公式的形式保存——一个多项式方程——该公式可以用于生成所有未来的概率估算。多项式是多个代数项的和或差。我们在学校学过的著名二次方程就是一个二次多项式方程(意味着变量的指数不大于 2):

ax² + bx + c = 0

多项式会形成漂亮的曲线,因此它们非常适合解决这个问题。但为了让公式适用于不同数量的文明和气泡大小,你需要使用文明数量与总体积的比率。总体积由缩放圆盘体积表示,这与等效体积的总数相同。

在图 10-5 中,每个点代表其下方比率的检测概率。图中显示的方程是多项式表达式,它生成了连接各点的线。通过这个公式,你可以预测任何文明数量与体积比率的概率,最大值为 5(超过这个值时,我们假设概率为 1.0)。

image

图 10-5:检测概率与文明数量与缩放银河体积比率的关系

在图 10-5 中,文明与体积的比率被显示在 x 轴上。例如,比率为 0.5 意味着文明的数量是可用广播泡泡等效体积的二分之一,比率为 2 则意味着文明的数量是体积的两倍,依此类推。y 轴表示一个等效体积中包含多个文明的概率。

另一个需要注意的点来自于图 10-5,即需要许多文明才能确保它们每个都有一个室友。假设 1000000 个等效体积中有 999999 个包含至少两个文明,你利用上帝般的力量随机放置一个新的文明。那么,新的文明最终出现在唯一一个有单一居民的剩余体积中的概率是百万分之一。那个最后的等效体积就像是大海捞针!

注意

计算机建模的一个公理是从简单开始,再逐步增加复杂性。最简单的“基本假设”是,先进文明在银河系中是随机分布的。在第 214 页的“挑战项目”中,你将有机会利用银河适居区的概念来挑战这一假设。

检测概率代码

检测概率代码随机选择一组位置(广播泡泡等效体积)进行定位,并统计其中有多少位置仅出现一次(即只包含一个文明),然后多次重复实验以收敛到一个概率估计。接着,该过程会为新的文明数量重新执行。输出结果以概率与每体积中文明的比率为横坐标,而非实际文明数量,并将其转化为多项式表达式,以便结果能够轻松迁移。这意味着这个程序只需运行一次。

为了生成多项式方程并检查它是否符合数据,你将使用NumPymatplotlibNumPy库支持大规模的多维数组和矩阵,并包含许多可以对其进行操作的数学函数。matplotlib库支持二维绘图和基本的三维绘图,而NumPy则是其数值数学扩展。

安装这些科学 Python 发行版有几种方法。一种方法是使用SciPy,这是一个用于科学和技术计算的开源 Python 库(见 scipy.org/index.html)。如果你打算进行大量的数据分析和绘图,你可能想要下载并使用一个免费的包,比如 Anaconda 或 Enthought Canopy,它们支持 Windows、Linux 和 macOS。这些包免去了你需要查找并安装所有必要的数据科学库的麻烦,且能保证正确的版本。你可以在 scipy.org/install.html 上找到这些类型包的列表,并附带有它们的网站链接。

另外,你可能希望直接使用 pip 下载这些产品。我使用了 scipy.org/install.html 中的说明来执行这个操作。由于matplotlib需要大量的依赖项,这些依赖项需要同时安装。对于 Windows,我从我的 Python35 文件夹中启动 PowerShell,并运行以下特定于 Python 3 的命令(如果你没有安装多个版本的 Python,python3中的 3 可以省略):

$ python3 -m pip install --user numpy scipy matplotlib ipython jupyter pandas sympy nose

你需要的所有其他模块都已经捆绑在 Python 中。至于 清单 10-1 和 10-2 中的代码,你可以手动输入,也可以从 www.nostarch.com/impracticalpython/ 下载副本。

计算一系列文明的检测概率

清单 10-1 导入模块并完成所有刚才描述的工作,除了拟合多项式和显示matplotlib质量检查。

probability_of_detection.py, 第一部分

➊ from random import randint
   from collections import Counter
   import numpy as np
   import matplotlib.pyplot as plt

➋ NUM_EQUIV_VOLUMES = 1000  # number of locations in which to place civilizations
   MAX_CIVS = 5000  # maximum number of advanced civilizations
   TRIALS = 1000  # number of times to model a given number of civilizations
   CIV_STEP_SIZE = 100  # civilizations count step size

➌ x = []  # x values for polynomial fit
   y = []  # y values for polynomial fit

➍ for num_civs in range(2, MAX_CIVS + 2, CIV_STEP_SIZE):
       civs_per_vol = num_civs / NUM_EQUIV_VOLUMES
       num_single_civs = 0
    ➎ for trial in range(TRIALS):
           locations = []  # equivalent volumes containing a civilization
        ➏ while len(locations) < num_civs:
               location = randint(1, NUM_EQUIV_VOLUMES)
               locations.append(location)
        ➐ overlap_count = Counter(locations)
           overlap_rollup = Counter(overlap_count.values())
           num_single_civs += overlap_rollup[1]

    ➑ prob = 1 - (num_single_civs / (num_civs * TRIALS))

       # print ratio of civs-per-volume vs. probability of 2+ civs per location
    ➒ print("{:.4f}  {:.4f}".format(civs_per_vol, prob))
    ➓ x.append(civs_per_vol)
       y.append(prob)

清单 10-1:导入模块,随机选择无线电气泡等效体积位置,并计算每个位置的多文明概率

导入熟悉的random模块和Counter模块,用于计算每个位置的文明数量(由该位置被选择的次数来表示)➊。关于Counter的工作原理稍后会解释。你将使用NumPymatplotlib的导入来拟合并显示多项式。

为一些常量赋值,这些常量表示用户输入的等效体积数、最大文明数、试验次数——即,给定文明数下重复实验的次数——以及计数的步长➋。因为结果是可预测的,你可以使用一个较大的步长值 100 而不影响精度。注意,无论等效体积数是 100 还是 100,000+,你都会得到非常相似的结果。

你需要一系列配对的(x, y)值来表示多项式表达式,因此先创建两个列表来存储这些值➌。x 值将是每单位体积的文明数量,y 值将是相应的检测概率。

开始一系列嵌套循环,最高层循环表示要模拟的文明数量➍。你至少需要两个文明才能让一个发现另一个,将最大值设置为MAX_CIVS加 2,以便在计算多项式时超出范围。使用CIV_STEP_SIZE常量作为步长值。

然后,计算总体的civs_per_vol比率,并启动一个名为num_single_civs的计数器,用于跟踪包含单一文明的位置数量。

你已经选择了要分配的文明数量,现在使用for循环遍历试验次数➎。对于每个试验,你分配相同数量的文明。将一个空列表分配给变量locations,然后对于每个文明➏,随机选择一个位置编号并将其添加到列表中。列表中的重复值将表示包含多个文明的位置。

在这个列表➐上运行Counter并获取结果。通过获取仅出现一次的位置数量来结束循环,并将其添加到num_single_civs计数器中。以下是这三个语句如何工作的示例:

>>> from collections import Counter
>>> alist = [124, 452, 838, 124, 301]
>>> count = Counter(alist)
>>> count
Counter({124: 2, 452: 1, 301: 1, 838: 1})
>>> value_count = Counter(count.values())
>>> value_count
Counter({1: 3, 2: 1})
>>> value_count[1]
3

alist列表包含五个数字,其中一个(124)是重复的。在这个列表上运行Counter会生成一个字典,数字作为键,出现的次数作为值。将Countercount中的值一起传递——使用values()方法——会创建另一个字典,原先的值变为键,出现的次数变为新值。你需要知道哪些数字只出现一次,因此使用字典方法value_count[1]返回未重复的数字数量。当然,这些数字将代表包含单一文明的无线电气泡等效体积。

现在使用Counter的结果来计算每个位置上多个文明的概率,针对当前文明分布数量➑。这等于 1 减去单一占用位置的数量,除以每次试验中的文明数量,再乘以试验次数。

接下来打印文明与体积的比率,以及多个文明共享一个位置的概率➒。输出的前几行如下:

0.0020  0.0020
0.1020  0.0970
0.2020  0.1832
0.3020  0.2607
0.4020  0.3305
0.5020  0.3951
0.6020  0.4516
0.7020  0.5041

这个输出用于初步的质量检查步骤,属于可选项;如果你想加快运行速度,可以将其注释掉。最后将这些值添加到xy列表➓中。

生成预测公式并检查结果

列表 10-2 使用NumPy对列表 10-1 中计算的每单位体积的文明比率与探测概率进行多项式回归。你将在下一个程序中使用这个多项式方程来获取概率估算值。为了检查结果曲线是否与数据点吻合,matplotlib将显示实际值和预测值。

probability_of_detection.py, 第二部分

➊ coefficients = np.polyfit(x, y, 4)  # 4th order polynomial fit
➋ p = np.poly1d(coefficients)
   print("\n{}".format(p))
➌ xp = np.linspace(0, 5)
➍ _ = plt.plot(x, y, '.', xp, p(xp), '-')
➎ plt.ylim(-0.5, 1.5)
➏ plt.show()

列表 10-2:执行多项式回归并显示质量检查图

从将变量coefficients赋值为NumPypolyfit()方法的输出 ➊ 开始。该方法的参数是xy列表以及表示拟合多项式次数的整数。它返回一个系数向量p,该向量最小化平方误差。

如果你打印coefficients变量,你将得到以下输出:

[-0.00475677  0.066811   -0.3605069   0.92146096  0.0082604 ]

为了获得完整的表达式,将coefficients变量传递给poly1d并将结果赋值给一个新变量 ➋。打印该变量,你将看到与图 10-5 中所示的类似方程:

           4           3          2
-0.004757 x + 0.06681 x - 0.3605 x + 0.9215 x + 0.00826

为了检查多项式是否足够好地再现输入,你需要绘制文明与体积的比值在 x 轴上的图,y 轴则表示概率。为了得到 x 轴的值,你可以使用NumPylinspace()方法,它会返回指定区间内均匀分布的数字。使用区间(0, 5),因为这个范围几乎涵盖了完整的概率范围。

要发布计算值和预测值的符号,首先将plot()方法的xy列表传递进去,使用圆点(点)进行绘制,这相当于图 10-5 中的点 ➍。然后传递预测的 x 轴值(xp),并且为了得到预测的 y 轴概率,传递p相同的变量,使用破折号绘制结果。

最后,将 y 轴限制在–0.51.5之间 ➎,并使用show()方法实际显示图表(图 10-6) ➏。得到的图形简单且稀疏,因为它的唯一目的是确认多项式回归按预期工作。你可以通过增加或减少步骤 ➊ 中第三个参数来改变多项式拟合。

image

图 10-6:计算结果(点)与多项式预测结果(线)

拥有这些结果,你现在可以在眨眼之间估算任何数量文明的检测概率。Python 所需要做的就是解一个多项式方程。

构建图形模型

图形模型将是一个银河盘的 2D 顶视图。在此显示上绘制地球当前排放气泡的大小,将有助于把银河系的规模与我们在其中微小的位置进行对比。

模拟银河系的关键是模拟螺旋臂。每个螺旋臂代表一个对数螺旋,这一几何特征在自然界中极为常见,因此被称为spira mirabilis——“奇迹螺旋”。如果你将图 10-7 与图 10-1 进行对比,你可以看到飓风的结构与银河系的结构有着惊人的相似性。飓风的眼睛甚至可以被认为是一个超大质量黑洞,眼墙则代表了事件视界!

image

图 10-7:飓风伊戈尔

由于螺旋线是从中心点或极点向外辐射的,你可以更容易地使用极坐标来绘制它们(图 10-8)。在极坐标中,传统笛卡尔坐标系中的(xy)坐标被(r,θ)替代,其中 r 是距离中心的距离,θ 是 r 和 x 轴之间的角度。极点的坐标是(0,0)。

image

图 10-8:极坐标系示例

对数螺旋的极坐标方程为:

r = ae^(bθ)

其中 r 是从原点的距离,θ 是从 x 轴起的角度,e 是自然对数的底数,ab 是任意常数。

你可以使用这个公式绘制单个螺旋线;然后,旋转并重新绘制螺旋线三次,以产生银河系的四条臂。你将通过不同大小的圆圈构建螺旋线,这些圆圈代表星星。图 10-9 是图形模型的一种实现示例。由于这些模拟是随机的,每个模拟结果会略有不同,而且你可以调整多个变量来改变外观。

image

图 10-9:使用对数螺旋模型的银河系

我是通过 tkinter(发音为“tee-kay-inter”)生成图 10-9 中的图像的,tkinter 是 Python 中用于开发桌面应用程序的默认图形用户界面(GUI)库。尽管主要用于图形用户界面元素,如窗口、按钮、滚动条等,但 tkinter 也可以生成图表、曲线图、屏幕保护程序、简单游戏等。它的一个优势是,作为 Python 标准发行版的一部分,它在所有操作系统之间都具有可移植性,无需安装外部库。同时,它也有很好的文档支持,易于使用。

大多数 Windows、macOS 和 Linux 机器上都已预装 tkinter。如果你的系统没有安装,或者需要最新版本,可以从www.activestate.com/下载并安装它。和往常一样,如果模块已安装,你应该能够在解释器窗口中导入它而不会出现错误:

>>> import tkinter
>>>

入门级的 Python 书籍有时会介绍 tkinter,你可以在docs.python.org/3/library/tk.html找到官方在线文档。有关 tkinter 的一些其他参考资料可以在“进一步阅读”中找到,位于第 212 页。

图形模型的缩放

图形模型的比例单位是光年每像素,每个像素的宽度等同于一个无线电气泡的直径。因此,当被研究的无线电气泡直径发生变化时,比例单位将会改变,图形模型需要重新构建。以下公式将根据气泡的大小调整模型:

缩放盘半径 = 盘半径 / 气泡直径

其中盘面半径为 50,000,长度单位为光年。

当选定的电波气泡较小时,图形模型“放大”,当它较大时,则“缩小”(见图 10-10)。

image

图 10-10:电波气泡直径对银河模型外观的影响

银河模拟器代码

银河模拟器代码将计算任何数量的文明和电波气泡大小的检测概率,然后生成银河图形模型。当使用与我们当前排放气泡相同大小的气泡时,它将在太阳系的大致位置用红色标记并注释我们的气泡。你可以从www.nostarch.com/impracticalpython/下载代码。

输入数据和关键参数

清单 10-3 通过导入模块并将常用的用户输入放在顶部来启动galaxy_simulator.py

galaxy_simulator.py, 第一部分

➊ import tkinter as tk
   from random import randint, uniform, random
   import math

   #=============================================================================
➋ # MAIN INPUT

   # scale (radio bubble diameter) in light-years:
➌ SCALE = 225  # enter 225 to see Earth's radio bubble

   # number of advanced civilizations from the Drake equation:
➍ NUM_CIVS = 15600000
   #=============================================================================

清单 10-3:导入模块并分配常量

导入tkintertk,这样你在调用tkinter类时就不用输入全名 ➊。如果你使用的是 Python 2,请使用大写字母的Tkinter。你还需要randommath模块。

使用注释突出显示主要的用户输入部分 ➋,并分配两个输入值。使用SCALE表示每个文明周围可检测到的电磁气泡直径(单位:光年) ➌;NUM_CIVS表示要模拟的文明数量,可以根据德雷克方程或完全猜测来确定 ➍。

设置 tkinter 画布并分配常量

清单 10-4 中的代码实例化了一个tkinter窗口对象,并创建了一个画布,你可以在上面绘制内容。这就是银河图或图形模型将显示的地方。它还分配了与银河系维度相关的常量。

galaxy_simulator.py, 第二部分

   # set up display canvas
➊ root = tk.Tk()
   root.title("Milky Way galaxy")
➋ c = tk.Canvas(root, width=1000, height=800, bg='black')
➌ c.grid()
➍ c.configure(scrollregion=(-500, -400, 500, 400))

   # actual Milky Way dimensions (light-years)
➎ DISC_RADIUS = 50000
   DISC_HEIGHT = 1000
➏ DISC_VOL = math.pi * DISC_RADIUS**2 * DISC_HEIGHT

清单 10-4:设置 tkinter 窗口和画布并分配常量

首先创建一个窗口,通常命名为root ➊。这是一个顶层窗口,将容纳其他所有内容。在下一行,给窗口设置一个标题——“银河系”——该标题将显示在窗口框架的左上角(参见图 10-9 示例)。

接下来,向根窗口添加一个组件,称为小部件小部件代表“Windows 小工具”。tkinter中有 21 个核心小部件,包括标签、框架、单选按钮和滚动条。将Canvas小部件分配给包含所有绘图对象的画布 ➋。这是一个通用的小部件,用于图形和其他复杂布局。指定父窗口、屏幕的宽度和高度以及背景颜色。将画布命名为c,表示画布

你可以将Canvas小部件分成行和列,像表格或电子表格一样。这个网格中的每个单元格可以容纳不同的小部件,而这些小部件可以跨越多个单元格。在单元格内,你可以使用STICKY选项来对齐小部件。为了管理窗口中的每个小部件,你需要使用grid几何管理器。由于你在这个项目中只使用了一个小部件,因此不需要向管理器传递任何东西 ➌。

最后,通过配置canvas使用scrollregion ➍。这将原点坐标(0, 0)设置为canvas的中心。你需要这样做才能用极坐标绘制银河系的螺旋臂。如果没有它,默认原点将是canvas的左上角。

传递给configure的参数设置了canvas的限制。这些应该是canvas宽度和高度的一半;例如,600, 500的滚动限制将需要canvas的尺寸为1200, 1000。这里显示的值在小型笔记本上效果很好,但如果你发现需要更大的窗口,可以稍后更改它们。

在输入部分之后跟上银河系的尺寸常量 ➎。你可以在函数中为这些变量赋值,但将它们放在全局空间中能使代码解释的流程更加逻辑化。前两个常量是银河圆盘的半径和高度,见图 10-2。最后一个常量代表圆盘体积 ➏。

缩放银河系并计算探测概率

列表 10-5 定义了根据使用中的无线电气泡直径来缩放银河系尺寸的函数,并计算一个文明探测到另一个文明的概率。后者的函数是你应用先前描述的probability_of_detection.py程序中构建的多项式方程的地方。

galaxy_simulator.py, 第三部分

➊ def scale_galaxy():
       """Scale galaxy dimensions based on radio bubble size (scale)."""
       disc_radius_scaled = round(DISC_RADIUS / SCALE)
    ➋ bubble_vol = 4/3 * math.pi * (SCALE / 2)**3
    ➌ disc_vol_scaled = DISC_VOL/bubble_vol
    ➍ return disc_radius_scaled, disc_vol_scaled

➎ def detect_prob(disc_vol_scaled):
       """Calculate probability of galactic civilizations detecting each other."""
    ➏ ratio = NUM_CIVS / disc_vol_scaled  # ratio of civs to scaled galaxy volume
    ➐ if ratio < 0.002:  # set very low ratios to probability of 0
           detection_prob = 0
       elif ratio >= 5:  # set high ratios to probability of 1
           detection_prob = 1
    ➑ else:
           detection_prob = -0.004757 * ratio**4 + 0.06681 * ratio**3 - 0.3605 * \
                            ratio**2 + 0.9215 * ratio + 0.00826
    ➒ return round(detection_prob, 3)

列表 10-5:缩放银河尺寸并计算探测概率

定义一个名为scale_galaxy()的函数,将银河系的尺寸缩放到无线电气泡的大小 ➊。它将使用全局空间中的常量,因此不需要传递任何参数。计算缩放后的圆盘半径,然后使用球体体积公式计算无线电气泡的体积,并将结果赋值给bubble_vol ➋。

接下来,将实际的圆盘体积除以bubble_vol以获得缩放后的圆盘体积 ➌。这是可以容纳在银河系中的无线电气泡“等效体积”数量。每个气泡代表一个文明可能存在的位置。

通过返回disc_radius_scaleddisc_vol_scaled变量来结束函数 ➍。

现在,定义一个名为 detect_prob() 的函数来计算检测概率,该函数将缩放后的盘面体积作为参数 ➎。对于多项式中的 x 项,计算文明数量与缩放后的盘面体积之比 ➏。由于多项式回归在端点可能出现问题,因此使用条件语句将非常小的比率设置为 0,将大比率设置为 1 ➐。否则,应用由 probability_of_detection.py 代码生成的多项式表达式 ➑,然后返回保留三位小数的概率 ➒。

使用极坐标

清单 10-6 定义了一个函数,用于使用极坐标随机选择 (x, y) 位置。这个函数将选择在图形模型中展示的一些恒星的位置。由于显示是二维的,因此无需选择 z 位置。

galaxy_simulator.py, 第四部分

➊ def random_polar_coordinates(disc_radius_scaled):
       """Generate uniform random (x, y) point within a disc for 2D display."""
    ➋ r = random()
    ➌ theta = uniform(0, 2 * math.pi)
    ➍ x = round(math.sqrt(r) * math.cos(theta) * disc_radius_scaled)
       y = round(math.sqrt(r) * math.sin(theta) * disc_radius_scaled)
    ➎ return x, y

清单 10-6:定义一个函数,随机选择一个 (x, y) 极坐标位置

该函数以缩放后的盘面半径作为参数 ➊。使用 random() 函数选择一个介于 0.0 和 1.0 之间的浮动值,并将其赋给变量 r ➋。接下来,从 0 到 360 度之间的均匀分布中随机选择 theta(2π 是 360 度的弧度等效值) ➌。

生成点以 均匀 分布在 单位 圆盘上的转换公式是:

image

方程式得出的 (x, y) 值在 -1 到 1 之间。为了将结果缩放到银河盘面,乘以缩放后的盘面半径 ➍。函数的最后返回 xy ➎。

构建螺旋臂

清单 10-7 定义了一个函数,该函数使用对数螺旋方程构建螺旋臂。这个螺旋可能很神奇,但大部分魔法在于对初始的简单螺旋进行调整,逐步完善螺旋臂。你将通过改变恒星的大小,随机微调它们的位置,并为每个臂复制螺旋,稍微将其向后移动并使恒星变暗,从而实现这一点。

galaxy_simulator.py, 第五部分

➊ def spirals(b, r, rot_fac, fuz_fac, arm):
       """Build spiral arms for tkinter display using logarithmic spiral formula.

       b = arbitrary constant in logarithmic spiral equation
       r = scaled galactic disc radius
       rot_fac = rotation factor
       fuz_fac = random shift in star position in arm, applied to 'fuzz' variable
       arm = spiral arm (0 = main arm, 1 = trailing stars)
       """
    ➋ spiral_stars = []
    ➌ fuzz = int(0.030 * abs(r)) # randomly shift star locations
       theta_max_degrees = 520
    ➍ for i in range(theta_max_degrees):  # range(0, 600, 2) for no black hole
           theta = math.radians(i)
           x = r * math.exp(b * theta) * math.cos(theta + math.pi * rot_fac)\
               + randint(-fuzz, fuzz) * fuz_fac
           y = r * math.exp(b * theta) * math.sin(theta + math.pi * rot_fac)\
               + randint(-fuzz, fuzz) * fuz_fac
           spiral_stars.append((x, y))
    ➎ for x, y in spiral_stars:
        ➏ if arm == 0 and int(x % 2) == 0:
               c.create_oval(x-2, y-2, x+2, y+2, fill='white', outline='')
           elif arm == 0 and int(x % 2) != 0:
               c.create_oval(x-1, y-1, x+1, y+1, fill='white', outline='')
        ➐ elif arm == 1:
               c.create_oval(x, y, x, y, fill='white', outline='')

清单 10-7:定义 spirals() 函数

定义一个名为 spirals() 的函数 ➊。其参数在函数文档字符串中列出。前两个参数 br 来自对数螺旋方程。接下来的 rot_fac 是旋转因子,它允许你围绕中心点移动螺旋,从而生成新的螺旋臂。模糊因子 fuz_fac 让你调整恒星与螺旋线中心的距离。最后,arm 参数让你指定是前导臂还是尾随臂,后者会稍微偏移——即绘制在前导臂之后——并且恒星较小。

初始化一个空列表,用于存储构成螺旋的恒星位置 ➋。定义一个fuzz变量,将一个任意常数与缩放后的圆盘半径的绝对值相乘 ➌。仅靠螺旋方程会生成排列整齐的星星(见图 10-11 中的左侧两个面板)。通过模糊化,星星会在螺旋线上来回移动,位于螺旋线两侧。你可以在图 10-11 的最右面板中看到这一效果,特别是在亮星部分。我是通过反复试验确定这些值的;如果你喜欢,可以随意调整。

image

图 10-11:通过移动螺旋线并随机改变星星位置填充螺旋臂

现在是时候构建螺旋线了。首先,使用一个值范围来表示对数螺旋方程中的θ ➍。大约520的范围将生成如图 10-9 所示的银河系,它有一个中央“黑洞”。否则,使用范围(0, 600, 2)—或类似的值—生成一个明亮的中央核心,星星密集分布(参见图 10-12)。你可以调整这些值,直到得到你喜欢的效果。遍历theta中的值,并应用对数螺旋方程,使用余弦计算 x 值,使用正弦计算 y 值。请注意,你要将fuzz值乘以模糊因子后加到结果中。将每对(x, y)坐标添加到spiral_stars列表中。

image

图 10-12:没有中央黑洞的图形模型(与图 10-9 比较)

后面在main()函数中,你将指定rot_fac变量,它将使螺旋围绕中心旋转。在程序构建完四个主臂后,它将使用rot_fac来构建四个新的臂,这些臂会略微偏移第一个四个臂,从而生成图 10-11 中每条亮星弧线左侧的暗淡拖尾星星带。

现在你已经有了恒星位置列表,开始在(x, y)坐标上执行for循环 ➎。然后使用条件语句选择主臂、引导臂以及x为偶数的位置 ➏。对于这些位置,使用canvas小部件的create_oval()方法来创建一个星星对象并显示。此方法的前四个参数定义了一个边界框,椭圆将适应该框。xy后面的数字越大,椭圆就越大。将填充颜色设置为白色,并且不要使用轮廓;默认的轮廓是细黑线。

如果x值为奇数,将星星的尺寸缩小一个等级。如果arm值为1,则星星位于偏移臂上,因此将其尽可能小 ➐。

注意

这些星星对象仅用于视觉效果。它们的大小和数量并不按比例缩放。为了更现实,它们应该要小得多,而且数量要多得多(超过 1000 亿颗!)

散射星雾

螺旋臂之间的空间并非完全没有星星,因此下一个函数(清单 10-8)会在星系模型中随机散布点,而不考虑螺旋臂的分布。可以把这个想象为在遥远星系的照片中看到的光晕效果。

galaxy_simulator.py, 第六部分

➊ def star_haze(disc_radius_scaled, density):
       """Randomly distribute faint tkinter stars in galactic disc.

       disc_radius_scaled = galactic disc radius scaled to radio bubble diameter
       density = multiplier to vary number of stars posted
       """
    ➋ for i in range(0, disc_radius_scaled * density):
        ➌ x, y = random_polar_coordinates(disc_radius_scaled)
        ➍ c.create_text(x, y, fill='white', font=('Helvetica', '7'), text='.')

清单 10-8:定义了 star_haze() 函数

定义 star_haze() 函数,并传递两个参数:缩放后的圆盘半径和一个整数倍增因子,函数将使用该因子来增加随机星星的基本数量 ➊。因此,如果你希望雾霾更浓厚,而不是轻微的雾霾,可以在调用 main() 中的函数时增加密度值。

启动一个for循环,其中最大范围值等于缩放后的圆盘半径乘以density ➋。通过使用半径值,你将星星的数量缩放到显示的圆盘大小。然后调用random_polar_coordinates()函数来获取一个(xy)坐标对 ➌。

最后,通过使用(xy)坐标对 ➍ 来为画布创建显示对象。由于你已经使用了最小的椭圆大小来表示沿着和周围螺旋臂的星星,因此可以使用 create_text() 方法,而不是 create_oval()。通过此方法,你可以使用一个句号来代表星星。字体大小参数将允许你缩放雾霾星星,直到找到一个美观的效果。

图 10-13 是没有星际雾霾的星系模型(左)与有星际雾霾的星系模型(右)的对比图。

image

图 10-13:没有星际雾霾的星系模型(左)与有星际雾霾的星系模型(右)

你可以对雾霾进行创意设计。例如,可以增加星星的数量并将其着色为灰色,或者使用循环来变化星星的大小和颜色。但是,不要使用绿色,因为宇宙中没有绿色的星星!

定义 main() 函数

清单 10-9 在 galaxy_simulator.py 中定义了 main() 函数。它会调用相应函数来缩放星系、计算探测概率、构建星系显示和发布统计信息。它还将运行 tkinter 主循环。

galaxy_simulator.py, 第八部分

def main():
       """Calculate detection probability & post galaxy display & statistics."""
    ➊ disc_radius_scaled, disc_vol_scaled = scale_galaxy()
       detection_prob = detect_prob(disc_vol_scaled)

       # build 4 main spiral arms & 4 trailing arms
    ➋ spirals(b=-0.3, r=disc_radius_scaled, rot_fac=2, fuz_fac=1.5, arm=0)
       spirals(b=-0.3, r=disc_radius_scaled, rot_fac=1.91, fuz_fac=1.5, arm=1)
       spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=2, fuz_fac=1.5, arm=0)
       spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=-2.09, fuz_fac=1.5, arm=1)
       spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=0.5, fuz_fac=1.5, arm=0)
       spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=0.4, fuz_fac=1.5, arm=1)
       spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=-0.5, fuz_fac=1.5, arm=0)
       spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=-0.6, fuz_fac=1.5, arm=1)
       star_haze(disc_radius_scaled, density=8)

       # display legend
    ➌ c.create_text(-455, -360, fill='white', anchor='w',
                     text='One Pixel = {} LY'.format(SCALE))
       c.create_text(-455, -330, fill='white', anchor='w',
                     text='Radio Bubble Diameter = {} LY'.format(SCALE))
       c.create_text(-455, -300, fill='white', anchor='w',
                     text='Probability of detection for {:,} civilizations = {}'.
                     format(NUM_CIVS, detection_prob))

       # post Earth's 225 LY diameter bubble and annotate
    ➍ if SCALE == 225:
        ➎ c.create_rectangle(115, 75, 116, 76, fill='red', outline='')
           c.create_text(118, 72, fill='red', anchor='w',
                         text="<---------- Earth's Radio Bubble")

       # run tkinter loop
    ➏ root.mainloop()
➐ if __name__ == '__main__':
       main()

清单 10-9:定义并调用了 main() 函数

通过调用 scale_galaxy() 函数来启动 main(),获取缩放后的圆盘体积和半径 ➊。然后调用 detect_prob() 函数,并传递 disc_vol_scaled 变量。将结果赋值给名为 detection_prob 的变量。

现在构建银河系显示(图形模型) ➋。这会多次调用spirals()函数,每次调用都有小的变化。arm参数指定明亮的主臂和微弱的拖尾臂。rot_fac(旋转因子)变量确定螺旋线的绘制位置。主臂和拖尾臂之间的微小旋转因子变化(例如,从21.91)使得微弱的臂略微偏离明亮的臂绘制。通过调用star_haze()函数完成显示。再次提醒,可以随意尝试这些参数。

接下来,显示图例和统计信息。从尺度 ➌ 和无线电气泡直径开始,然后是给定数量文明的探测概率。参数包括 x 和 y 坐标、填充(文本)颜色、对齐锚点——其中左对齐表示w(“west”)——以及文本。注意使用{:,}来插入千位分隔符的逗号。这是较新的字符串格式化方法的一部分。你可以在docs.python.org/3/library/string.html#string-formatting查看更多内容。

如果用户选择了225光年的无线电气泡直径 ➍,那么显示的尺度与我们自己的辐射气泡相同,因此会在大致的位置处显示一个红色像素,标注出我们的太阳系 ➎。使用tkinter显示单个像素有很多方法。在这里,使用create_rectangle()方法,但你也可以用以下语句绘制一条长度为一个像素的线:

c.create_line(115, 75, 116, 75, fill='red')

使用create_rectangle()方法时,前两个参数是点(x0y0),它们对应左上角的位置,和(x1y1),即底部右角外部像素的位置。使用create_line()方法时,参数对应起始和结束点。默认的线宽为一个像素。

通过执行tkintermainloop()函数,也称为事件循环 ➏,结束main()函数。这样可以保持root窗口打开,直到你关闭它。

返回到全局空间,通过允许程序作为独立程序运行或作为模块被另一个程序调用来结束程序 ➐。

最终的显示将类似于图 10-14,展示了地球的无线电气泡和一个中央黑洞。

image

图 10-14:最终显示,地球 225 光年直径的无线电气泡显示在银河系地图上

请注意,尽管在这个尺度下我们的无线电气泡比针孔还小,如果文明的探测范围为 112.5 光年,并且如果这些文明的数量如当前德雷克方程式的高端参数所预测的那样多,那么探测的概率就是 1!

结果

鉴于输入的不确定性和简化假设的使用,你不应在这里追求精确。你要寻找的是方向性。我们(或任何类似我们的人)是否应该期望探测到一个没有主动尝试与我们联系的文明?根据图 10-15,可能不会。

image

图 10-15:不同无线电气泡直径和不同文明数量下,一个文明探测到另一个文明的概率

根据我们目前的技术,我们可以探测到来自 16 光年远的文明发出的辐射,这相当于一个直径为 32 光年的无线电气泡。即使银河系中有 1560 万先进文明,正如维基百科更新版的德雷克方程预测的那样,探测到 32 光年无线电气泡的机会也不到 4%!

再看看图 10-14,你就能开始欣赏我们银河系的广袤和空旷。天文学家甚至为此有一个词:Laniakea,夏威夷语为“不可衡量的天国”。

地球,就像卡尔·萨根所描述的那样,只是“悬浮在阳光中的一粒尘埃”。而最近的研究表明,探测文明的无线电波的机会窗口比我们想象的要小得多。如果其他文明效仿我们,转向数字信号和卫星通信,那么它们的偶然无线电泄漏将至少减少四倍。我们都变得不经意间“隐身”,大约盛开一百年左右,然后逐渐消逝。

鉴于这些事实,政府不再资助使用射电望远镜寻找外星智能生命也就不足为奇了。如今,努力转向寻找外星行星大气层中气体的光学方法,比如生命和工业活动的废弃物。

总结

在本章中,你获得了使用tkintermatplotlibNumPy的经验。你生成了一个多项式表达式,用于合理估算探测到外星无线电信号的可能性,并且使用了随时可用的tkinter模块,为分析添加了一个酷炫的视觉组件。

进一步阅读

我们是否孤独?外星生命发现的哲学意义(BasicBooks,1995 年)由 Paul Davies 撰写,是一部深刻探讨寻找外星生命的著作,由一位杰出的科学家和获奖的科普作家讲述。

《描述螺旋星系支架结构的新公式》(皇家天文学会月刊,2009 年 7 月 21 日)由 Harry I. Ringermacher 和 Lawrence R. Mead 撰写 (arxiv.org/abs/0908.0892v1),提供了用于建模哈勃望远镜观测到的螺旋星系形状的公式。

“Tkinter 8.5 参考:Python 的 GUI”(新墨西哥科技大学计算机中心,2013)由 John W. Shipman 编写,是官方tkinter文档的有用补充。可以在* infohost.nmt.edu/tcc/help/pubs/tkinter/tkinter.pdf*找到。

另一个有用的在线tkinter资源是* wiki.python.org/moin/TkInter/*。

Tkinter GUI 应用开发 HOTSHOT(Packt Publishing,2013)由 Bhaskar Chaudhary 编写,采用基于项目的方式教授tkinter

实践项目

尝试这三个衍生项目。你可以在附录中找到它们,或者从* www.nostarch.com/impracticalpython/*下载它们。

一个遥远的银河系

厌倦了生活在银河系中?谁不厌倦呢?幸运的是,天地间不只有对数螺旋。使用 Python 和tkinter为我们建造一个新家——但不一定是一个现实的家。为了获取灵感,可以访问 Alexandre Devert 在他的 Marmakoide 博客上的文章《在圆盘和球面上散布点》(* blog.marmakoide.org/)。图 10-16 中的示例就是用galaxy_practice.py*构建的。

image

图 10-16:由 galaxy_practice.py 程序生成的星系

建立一个银河帝国

选择星系中的一个位置,设定平均旅行速度为光速的 5 到 10%,时间步长为 50 万年。然后模拟一个太空帝国的扩展。在每个时间步中,计算扩展中的殖民泡泡的大小并更新星系图。通过将家园位置设置为星系中心,速度设置为1,并确认到达星系边缘需要 50,000 年来检查结果。

当程序运行起来时,你可以进行有趣的实验。例如,你可以测试我们需要以多快的速度才能在 1000 万年内探索银河系,正如本章介绍中提到的那样(见图 10-17)。

image

图 10-17:核心位置帝国在低于光速的旅行下,经过 1000 万年的扩展

你还可以估算星际迷航联邦在其前 100 年中能够探索多少银河系,假设他们在 4 倍曲速下的平均速度为光速的 100 倍(见图 10-18)。

image

图 10-18: 星际迷航 联邦在 4 倍曲速下的前 100 年扩展

这些图形是通过empire_practice.py程序构建的。

预测可探测性的间接方法

预测探测概率的另一种方法是使用极坐标将文明分布在银河盘中——作为 xyz 点——然后将这些点四舍五入到最近的无线电气泡半径。共享相同位置的点代表可能相互探测到的文明。但是要小心——这种方法是用立方体而不是球体来进行四舍五入,因此你需要将半径转换为产生相同体积的立方体边长。

编写程序,以预测在给定的 15,600,000 个随机分布在银河系中的传输文明的情况下,探测 16 光年半径气泡的概率(这是我们当前技术的极限)。在分布文明时,使用完整的 50,000 光年半径和 1,000 光年高度的银河模型。

解决方案见rounded_detection_practice.py。请注意,程序运行可能需要几分钟。

挑战项目

这里有一些后续项目,供你自行尝试。记住,我不会提供挑战项目的解决方案。

创建棒旋星系

随着我们获得并分析新的天文数据,对银河系的理解也在不断发展。科学家现在认为银河系的核心是拉长的,呈棒状。使用 Ringermacher 和 Mead 论文中提供的方程,参考“进一步阅读”中的第 212 页,创建一个新的tkinter可视化银河模型,体现棒旋星系的概念。

将宜居区添加到你的银河系中

太阳系有金发姑娘区,适合生命的诞生。位于这些区域的行星温暖足够,至少部分水体保持液态。

还有一种理论认为,像太阳系一样,星系也有宜居区,其中生命更有可能发展。对于银河系的宜居区的一种定义是,其内边界约在距银河中心 13,000 光年处,外边界约在距银河中心 33,000 光年处(见图 10-19)。由于核心区域有高水平的辐射、大量超新星以及由于众多密集星体引起的复杂的轨道扰动引力场,因此被排除在外。边缘区域因金属含量低而被排除,而金属元素对行星的发展至关重要。

image

图 10-19:近似银河宜居区(阴影部分)叠加在银河模型上

宜居区模型的一个改进排除了螺旋臂,原因类似于核心排除的原因。我们的存在并不与此相悖。地球位于猎户“蹄”区域,这是位于射手臂和英仙臂之间的一个相对较小的特征。

编辑galaxy_simulation.py程序,使其仅使用银河适居区的体积,不管你如何定义它。你应该研究这些体积可能是什么,以及它们对德雷克方程计算的文明数量(N)会产生什么影响。考虑使用区域,比如核心、螺旋臂、外缘等,在这些区域内,N的值不同,但文明仍然是随机分布的。在银河地图上突出显示这些区域,并发布它们的探测概率估算值。

第十一章:蒙提霍尔问题

image

作为电视游戏节目《让我们做交易》的主持人,蒙提·霍尔会给参赛者展示三扇关闭的门,并要求他们选择其中一扇。背后有一个珍贵的奖品;另外两扇门后面是臭臭的山羊。参赛者一选择了门,蒙提就会打开剩下的其中一扇,露出一只山羊。然后,参赛者会得到最后的选择:换门还是坚持原来的选择。

在 1990 年,"世界上最聪明的女人"玛丽琳·沃斯·萨万特在她的周刊《Parade》杂志专栏《问玛丽琳》中表示,参赛者应该选择换门。尽管她的答案是正确的,但却引发了一场关于仇恨邮件、性别刻板印象和学术迫害的风暴。许多数学教授在这个过程中让自己尴尬,但这场丑陋事件也有光明的一面。激烈的讨论让公众接触到了统计学的科学,沃斯·萨万特提出的一个练习也进入了成千上万的课堂。这些手动测试——后来被计算机复制——都证实了她被嘲笑的“女性逻辑”是正确的。

在这一章中,你将使用蒙特卡洛模拟(MCS),这是一种通过对一系列随机输入的不同结果建模来验证沃斯·萨万特是对的。之后,你将使用tkinter来构建一个有趣的图形界面,来响应她要求学生们帮助做实验的请求。

蒙特卡洛模拟

假设你想知道掷骰子六次,每次都得到不同的面朝上的概率。如果你是数学天才,你可能会直接使用确定性公式 6! / 6⁶来计算,或者

image

得到 0.015。如果你不那么擅长数学,也可以通过 Python 和大量掷骰子得到相同的答案:

>>> from random import randint

>>> trials = 100000

>>> success = 0

>>> for trial in range(trials):

           faces = set()

           for rolls in range(6):

               roll = randint(1, 6)

               faces.add(roll)

           if len(faces) == 6:

               success += 1

>>> print("probability of success = {}".format(success/trials))

probability of success = 0.01528

这个例子使用了for循环和randint函数,随机选择 1 到 6 之间的一个数字,代表骰子的一面,并重复 6 次。它将每个结果添加到名为faces的集合中,该集合不允许重复。集合长度达到 6 的唯一方式是每次掷骰子都会得到一个唯一的数字,这样就算作一次成功。外层的for循环会执行这个六次掷骰子的实验 100,000 次。成功次数与实验次数的比值就是与确定性公式相同的概率 0.015。

蒙特卡洛模拟使用重复随机抽样——在这个例子中,每一次掷骰子就是一次随机抽样——来预测在指定条件范围下的不同结果。在这个例子中,条件范围是一个六面骰子,每次掷骰子没有重复,进行 100,000 次试验。当然,MCS 通常应用于更复杂的问题——那些有许多变量和不确定性范围广的问题,其结果无法轻易预测。

有多种类型的 MCS,但大多数应用遵循以下基本步骤:

  • 列出输入变量。

  • 为每个变量提供概率分布。

  • 启动循环:

    • 从每个输入的分布中随机选择一个值。

    • 使用确定性计算中的值,确定性计算是一种总是从相同输入产生相同输出的计算。

    • 重复指定的次数。

  • 聚合结果并生成统计数据,例如计算的平均结果。

对于掷骰子的示例,步骤如下:

  • 输入变量 = 六次掷骰子的结果。

  • 投掷概率分布 = 均匀分布(每个面朝上的概率为 1/6)。

  • 循环:

    • 随机选择的值 = 掷骰子结果(从分布中抽取)。

    • 计算 = 将六个值添加到一个集合中,并且如果集合的长度等于 6,则将 success 变量加 1。

    • 重复 = 100,000 次。

  • 聚合:将 success 变量除以 100,0000 以计算概率。

纳西姆·塔勒布(Nassim Taleb),《黑天鹅》和《随机的愚弄》一书的广受好评作者,是 MCS 的支持者。他认为我们的大脑是为了迅速摆脱困境而设计的,而不是处理复杂的不确定性或概率问题。我们不擅长处理高度偏斜的分布和非线性,但有些人的大脑天生更能够通过 MCS 理解风险,而不是其他方法。在现实生活中,我们不会观察概率分布;我们只是观察事件。

每次 MCS 运行代表一个单一事件,例如你在退休期间是否会用完钱。对于许多人来说,MCS 使风险变得真实。它帮助我们理解事情可能变得多糟或多好——这是我们无法仅从数学抽象中得到的。通过 MCS 获得的洞察力,我们可以为防范风险和利用机遇做好准备。

为了支持蒙提霍尔问题背后的数学原理,你将使用如前面的掷骰子示例这样的 MCS 应用。然后,在第十二章中,你将使用 MCS 构建一个退休金模拟器,规划你(或你父母)的安全退休生活。

项目 #18:验证 vos Savant

为了验证 vos Savant 的说法是否正确,使用蒙特卡洛方法模拟成千上万次“游戏”,以观察结果如何。这可以是一个简化的程序,因为目标只是简单地确认,没有任何附加内容。

目标

编写一个简单的 Python 程序,使用蒙特卡洛模拟来确定通过更改初始选择来赢得蒙提霍尔问题的概率。

策略

蒙提霍尔问题的正确解答是在蒙提揭示了山羊后换门。从统计学角度来看,这将使你赢的机会翻倍!

查看图 11-1。在游戏开始时,所有的门都是关闭的,每个门隐藏奖品的概率是 1/3。用户只能选择一扇门,这意味着奖品隐藏在其他两扇门中的概率是 2/3。在揭示羊之后,概率仍然是 2/3,但它会转移到剩下的那扇门。记住,蒙提知道奖品在哪里隐藏,并且他永远不会揭示那个门。所以,留在原选择的成功概率是 1/3,而切换选择的成功概率是 2/3。

image

图 11-1:蒙提霍尔问题在揭示羊之后的获胜概率

如果你对数学计算有疑虑,你可以使用 MCS 来提供支持证据,就像我们在掷骰子示例中所做的那样。你只需随机选择一扇中奖门,随机选择一个参赛者的选择,并记录两者匹配的次数。重复这个过程数千次,你将会收敛到确定性的数学解。

Vos Savant 验证代码

本节中描述的monty_hall_mcs.py程序将自动化选择门和记录结果的过程,使你能够运行数千次试验并在不到一秒钟的时间内评估它们。你可以从www.nostarch.com/impracticalpython.com/下载代码。

获取运行次数输入

清单 11-1 通过询问用户他们希望模拟多少次运行(或游戏)来启动monty_hall_mcs.py程序。你还会为用户提供一个默认值。这是引导用户给出合理首次回应的好方法,同时也能节省他们一些按键操作。

monty_hall_mcs.py,第一部分

➊ import random

➋ def user_prompt(prompt, default=None):
       """Allow use of default values in input."""
    ➌ prompt = '{} [{}]: '.format(prompt, default)
    ➍ response = input(prompt)
    ➎ if not response and default:
           return default
       else:
           return response

   # input number of times to run simulation
➏ num_runs = int(user_prompt("Input number of runs", "20000"))

清单 11-1:导入模块并定义 user_prompt() 函数

首先导入random模块以运行 MCS ➊。接下来,定义一个名为user_prompt()的函数,提示用户输入要运行的游戏次数,或者在提供默认值的情况下接受默认值 ➋。此函数接受两个参数;第一个是提示用户执行操作的文本,第二个是默认值,初始值为None。立即重新定义prompt变量,以便按照约定在括号中显示默认值 ➌。将用户的输入赋值给名为response的变量 ➍。如果用户按下 ENTER 键而没有输入任何内容,并且存在默认值,user_prompt()函数将返回默认值 ➎。否则,函数将返回用户的输入。使用此函数通过将返回值赋给num_runs变量来确定运行的次数 ➏。每次运行将代表一个参赛者进行一次游戏。

运行 MCS 并显示结果

列表 11-2 随机选择获胜的门和用户的第一次选择,然后汇总并展示统计数据。有趣的是,用户的第二次选择——是否换门——并不是获得正确答案所必需的。如果最初选择的是获胜的门,正确答案是不要换门。同样,如果最初选择和获胜的门不同,正确答案是换门。没有必要模拟参赛者可能会做什么或不做什么。

monty_hall_mcs.py,第二部分

   # assign counters for ways to win
➊ first_choice_wins = 0
   pick_change_wins = 0
➋ doors = ['a', 'b', 'c']

   # run Monte Carlo
➌ for i in range(num_runs):
       winner = random.choice(doors)
       pick = random.choice(doors)

    ➍ if pick == winner:
           first_choice_wins += 1
       else:
           pick_change_wins += 1

➎ print("Wins with original pick = {}".format(first_choice_wins))
   print("Wins with changed pick = {}".format(pick_change_wins))
   print("Probability of winning with initial guess: {:.2f}"
         .format(first_choice_wins / num_runs))
   print("Probability of winning by switching: {:.2f}"
         .format(pick_change_wins / num_runs))

➏ input("\nPress Enter key to exit.")

列表 11-2:运行蒙特卡洛模拟并展示结果

分配两个变量来跟踪切换或不切换是否是获胜的结果 ➊。然后,创建一个列表来表示三个门 ➋。

MCS 从一个 for 循环开始,该循环遍历运行次数 ➌。在该循环内部,使用 random.choice()doors 列表中选择获胜的门和用户的第一次选择,并将它们分配给变量。

由于这是一个二进制系统——用户切换或者不切换——你只需要一个条件,根据 pick 变量和 winning 变量的关系来增加计数器 ➍。

完成程序,展示最终结果。显示实际的计数值,以及计算出的概率 ➎。然后,让用户知道程序已经完成 ➏。

这是默认情况下 20,000 次运行的示例输出:

Input number of runs [20000]:
Wins with original pick = 6628
Wins with changed pick = 13372
Probability of winning with initial guess: 0.33
Probability of winning by switching: 0.67

Press Enter key to exit.

有些人对计算机打印输出不感兴趣,他们需要更有说服力的东西。所以在下一个项目中,你将以更具互动性的方式重新包装代码——包括门、奖品和山羊。这样也能满足 Marilyn vos Savant 对学童参与并帮助恢复她的荣誉的呼吁。

项目 #19:蒙提霍尔游戏

蒙提霍尔问题中的三扇门游戏足够简单,你可以使用 tkinter 构建。你在第十章中已经开始使用 tkinter 图形界面。现在,你将通过添加交互按钮让用户点击来进一步构建这些知识。

目标

使用 tkinter 构建的 GUI 模拟蒙提霍尔问题。跟踪切换门或保持不变是否会导致获胜。此外,在游戏进行时更新并展示这些统计数据。

面向对象编程简介

tkinter 模块是使用面向对象编程(OOP)编写的。OOP 是一种围绕数据结构构建的语言模型,这些数据结构称为对象,由数据方法以及它们之间的交互组成——与过程式编程中使用的操作逻辑不同。对象是由构建的,类就像是对象的蓝图。

OOP 是一个抽象的概念,写大型复杂程序时更容易理解。它减少了代码重复,使代码更容易更新、维护和重用。因此,现在大多数商业软件都是使用 OOP 构建的。

如果你在小程序中实现了面向对象编程(OOP),就像我们到目前为止写的那些程序,大部分程序会显得过于复杂。事实上,我最喜欢的一句名言,来自英国计算机科学家乔·阿姆斯特朗,正是关于 OOP 的这一方面:“面向对象语言的问题在于它们携带了所有这些隐式环境。你想要的是一根香蕉,但你得到的是一只拿着香蕉和整个丛林的猩猩!”

尽管如此,OOP 生成的对象非常适合图形用户界面(GUI)和游戏,甚至适用于一些小型项目。我们来看一个例子,使用类似《龙与地下城》类型的桌面游戏,在游戏中玩家可以扮演不同的角色,比如矮人、精灵和巫师。这些游戏使用角色卡来列出每个角色类型的重要信息。如果你让你的棋子代表一个矮人,它将继承卡片上的特征(见图 11-2)。

image

图 11-2:角色扮演桌游中的矮人角色卡

清单 11-3 和 11-4 再现了桌游风格的玩法,让你创建虚拟卡片,表示矮人和精灵,命名你的棋子并让它们进行战斗。战斗的结果将影响其中一个角色的生命值,即角色的健康状况。请务必注意,OOP 可以让你轻松创建许多相同的对象——在这种情况下,是矮人或精灵——通过从预定义的模板“打印”它们,这个模板叫做

➊ >>> import random
➋ >>> class Dwarf(object):
        ➌ def __init__(self, name):
            ➍ self.name = name
               self.attack = 3
               self.defend = 4
               self.body = 5
        ➎ def talk(self):
               print("I'm a blade-man, I'll cut ya!!!")
➏ >>> lenn = Dwarf("Lenn")
   >>> print("Dwarf name = {}".format(lenn.name))
   Dwarf name = Lenn
   >>> print("Lenn's attack strength = {}".format(lenn.attack))
   Lenn's attack strength = 3
   >>>
➐ >>> lenn.talk()
   I'm a blade-man, I'll cut ya!!!

清单 11-3:导入 random 模块,创建一个 Dwarf 类,并实例化一个矮人对象

首先导入random模块来模拟掷骰子 ➊;这将是你角色战斗的方式。现在定义一个矮人角色的类,将类名的首字母大写,并传递一个object参数,这将是你的矮人名字 ➋。类是创建某种类型对象的模板。例如,当你创建一个列表或字典时,你是在从一个类创建它们。

Dwarf类的定义就像图 11-2 中的卡片;它基本上是一个矮人的遗传蓝图。它将分配属性,比如力量和活力,以及方法,比如角色如何移动或说话。属性是类的实例范围内的变量,而方法是属性,它们恰好是函数,当运行时会传递一个指向它们实例的引用。类是一种数据类型,当你创建该数据类型的对象时,它也被称为该类的一个实例。设置实例的初始值和行为的过程称为实例化

接下来,定义一个构造函数方法,也称为初始化方法。它为你的对象设置初始属性值 ➌。__init__()方法是一个特殊的内建方法,Python 会在创建新对象时自动调用它。在这种情况下,你将传递两个参数:self和对象的name

self参数是对正在创建的类实例的引用,或者是对调用方法时实例的引用,技术上称为上下文实例。如果你创建一个新的矮人并命名为“Steve”,那么幕后self将变成 Steve。例如,self.attack就变成了“Steve 的攻击”。如果你再创建一个名为“Sue”的矮人,那么这个对象的self就会变成“Sue”。这样,Steve 的生命属性的作用域就与 Sue 的分开了。

接下来,在构造函数定义下方列出一些矮人的属性 ➍。你需要一个名字,以便区分不同的矮人,还需要一些关键战斗特征的数值。注意,这个列表与图 11-2 中的卡片很相似。

定义一个talk()方法,并传递self ➎。通过传递self,你将方法与对象关联起来。在更复杂的游戏中,方法可能包括移动行为和解除陷阱的能力。

完成类定义后,创建一个Dwarf类的实例,并将该对象赋值给本地变量lenn,即矮人的名字 ➏。接着,打印名字和攻击属性,以证明你可以访问它们。最后,调用talk()方法 ➐,此时应显示一条信息。

清单 11-4 创建了一个精灵角色,采用了与清单 11-3 中相同的过程,并让它与矮人对战。精灵的body属性被更新,以反映战斗结果。

➊ >>> class Elf(object):
           def __init__(self, name):
               self.name = name
               self.attack = 4
               self.defend = 4
               self.body = 4
   >>> esseden = Elf("Esseden")
   >>> print("Elf name = {}".format(esseden.name))
   Elf name = Esseden
   >>> print("Esseden body value = {}".format(esseden.body))
   Esseden body value = 4
   >>>
➋ >>> lenn_attack_roll = random.randrange(1, lenn.attack + 1)
   >>> print("Lenn attack roll = {}".format(lenn_attack_roll))
   Lenn attack roll = 3
➌ >>> esseden_defend_roll = random.randrange(1, esseden.defend + 1)
   >>> print("Esseden defend roll = {}".format(esseden_defend_roll))
   Esseden defend roll = 1
   >>>
➍ >>> damage = lenn_attack_roll - esseden_defend_roll
   >>> if damage > 0:
       esseden.body -= damage
➎ >>> print("Esseden body value = {}".format(esseden.body))
   Esseden body value = 2

清单 11-4:创建一个 Elf 类,实例化一个精灵对象,模拟战斗,并更新对象属性

定义一个Elf类,并提供一些属性 ➊。使它们与矮人的属性略有不同,并且平衡得当,像一个精灵一样。实例化一个名为Esseden的精灵,并使用print访问他的namebody属性。

让你的两个角色通过掷一个虚拟骰子来互动,骰子的最大值等于角色的攻击或防御值。使用random模块在 1 到 Lenn 的attack属性加 1 之间选择一个骰子值 ➋,然后重复此过程以获得 Esseden 的防御值 ➌。通过将 Esseden 的掷骰值从 Lenn 的掷骰值中减去 ➍,计算对 Esseden 的伤害,如果伤害为正数,则从 Esseden 的body属性中减去它。使用print()确认精灵的当前生命值 ➎。

正如你所想象的那样,构建许多相似的角色并跟踪它们不断变化的属性,在过程化编程中会变得非常复杂。面向对象编程(OOP)为你的程序提供了模块化结构,使得隐藏复杂性和作用域所有权变得简单,并且允许以小块解决问题,同时生成可以共享、修改并在其他地方使用的模板。

策略和伪代码

现在回到我们的三门游戏。游戏的规则构成了程序的伪代码的主体:

Initialize game window and show closed doors and instructions
Choose winning door at random
Get player's door choice
Reveal a door that isn't the winning door or the player's choice
Get player's choice to switch doors or not
If player switches:
    Reveal new door
    If winner:
        Record as win for switching
    Otherwise:
        Record as win for staying put
Else if player stays with first choice:
    Reveal chosen door
    If winner:
        Record as win for staying put
    Otherwise:
        Record as win for switching
Display number of wins for each strategy in game window
Reset game and close all doors

开始设计游戏时,首先画出游戏窗口的样子是很有用的,窗口应该包含指令、消息和按钮类型。我怀疑你不会想看到我粗糙的草图,所以不如看看图 11-3。

image

图 11-3:游戏窗口在第一轮播放后的视图

这是游戏完成后第一轮的样子,右侧显示了获胜统计信息。注意,在做出初次选择之前,更换门的单选按钮是灰色的。

游戏资源

游戏资源是指构建游戏时所需要的物品。这些资源将包括一系列用于表示门、山羊和奖品的图像(见图 11-4)。

image

图 11-4: monty_hall_gui.py 程序的构建块图像

我使用了 Microsoft PowerPoint 将 3 个基础图像合成成 10 个图像,代表游戏的所有可能状态(见图 11-5)。这是一个设计决策;通过增加几行代码,我也可以仅使用基础图像获得相同的结果。

image

图 11-5: monty_hall_gui.py 程序的合成图像

蒙提·霍尔游戏代码

monty_hall_gui.py程序将蒙提·霍尔问题转变为一个有趣且具有教育意义的游戏。你还将需要图 11-5 中显示的 10 个游戏资源。从* www.nostarch.com/impracticalpython/* 下载它们,并将所有文件保存在同一文件夹中。

导入模块并定义游戏类

清单 11-5 导入模块并定义Game类以及初始化方法__init__()

monty_hall_gui.py, 第一部分

➊ import random
   import tkinter as tk

➋ class Game(tk.Frame):
       """GUI application for Monty Hall Problem game."""

    ➌ doors = ('a', 'b', 'c')

    ➍ def __init__(self, parent):
           """Initialize the frame."""
        ➎ super(Game, self).__init__(parent)  # parent will be the root window
        ➏ self.parent = parent
           self.img_file = 'all_closed.png'  # current image of doors
           self.choice = ''  # player's door choice
           self.winner = ''  # winning door
           self.reveal = ''  # revealed goat door
        ➐ self.first_choice_wins = 0  # counter for statistics
           self.pick_change_wins = 0  # counter for statistics
        ➑ self.create_widgets()

清单 11-5:导入模块并定义 Game 类和 _ init _() 方法

首先导入randomtkinter模块 ➊。接下来,定义一个名为Game的类 ➋。这个类的祖先(在括号中显示)将是tkinterFrame类。这意味着Game类是从现有的Frame“基础”类派生出来的,并将方便地继承它的一些有用方法。Frame小部件主要作为其他小部件的几何布局主控,帮助将它们组合成复杂的布局。

请注意,类有其自己的文档字符串规范,你可以在www.python.org/dev/peps/pep-0257/上找到。正如在第一章中所述,为了简洁起见,本书将主要展示单行文档字符串。

每个Game实例都将使用相同的三个门,因此可以使用类属性来实现 ➌。任何在方法外部赋值的变量都会成为类属性,类似于在过程化程序中函数外部赋值的变量会成为全局变量。为了防止该属性被意外修改,可以通过使用元组使其不可变。稍后,当你想操作门时,可以从该元组创建列表。

现在,就像之前的矮人和精灵示例一样,为游戏对象定义一个初始化方法 ➍。需要一个self参数,但你还需要一个parent,它将是容纳游戏的root窗口。

基类也可以称为超类,而super()函数让你调用超类的方法,从而访问继承的方法——在这种情况下,来自父类的方法。首先,将Game传递给super(),这意味着你想调用Game的超类方法,即Frame ➎。然后,将self作为参数传递,引用新实例化的Game对象。__init__(parent)语句部分调用Frame的初始化方法,并将parent(根窗口)作为参数传递。现在,你的Game对象可以使用预构建的tkinter Frame类中的属性。注意,这个语句可以简化为super().__init__()

接下来,给一系列实例属性赋值 ➏。最好通过__init__()方法初始化属性,因为这是在创建对象后第一个被调用的方法。这样,这些属性将立即对类中的其他方法可用。首先,将parent(即根窗口)赋给实例。然后,命名一个属性来保存图像文件之一(如图 11-5 所示),并将其赋值为显示所有门关闭的图像,这将在每局游戏开始时显示给玩家。接下来,命名属性以保存玩家选择的门、获胜的门以及用来揭示第一只山羊的门。

使用一个计数器来跟踪玩家选择第一个门时获得的胜利次数,另一个计数器来记录玩家换门后获得的胜利次数 ➐。最后,调用一个方法,创建运行游戏所需的标签、按钮和文本控件 ➑。

为图像和说明创建控件

列表 11-6 定义了create_widgets()方法的第一部分,该方法用于构建游戏所需的标签、按钮和文本控件。前两个控件将是tkinter标签,用于显示图 11-5 中的图像以及提供游戏说明。

monty_hall_gui.py, 第二部分

    ➊ def create_widgets(self):
           """Create label, button, and text widgets for game."""
           # create label to hold image of doors
        ➋ img = tk.PhotoImage(file='all_closed.png')
        ➌ self.photo_lbl = tk.Label(self.parent, image=img,
                                     text='', borderwidth=0)
        ➍ self.photo_lbl.grid(row=0, column=0, columnspan=10, sticky='W')
        ➎ self.photo_lbl.image = img

           # create the instruction label
        ➏ instr_input = [
               ('Behind one door is CASH!', 1, 0, 5, 'W'),
               ('Behind the others:  GOATS!!!', 2, 0, 5, 'W'),
               ('Pick a door:', 1, 3, 1, 'E')
               ]
        ➐ for text, row, column, columnspan, sticky in instr_input:
               instr_lbl = tk.Label(self.parent, text=text)
               instr_lbl.grid(row=row, column=column, columnspan=columnspan,
                              sticky=sticky, ➑ipadx=30)

列表 11-6:定义了一个创建小部件的方法

定义一个方法,称为 create_widgets(),它以 self 作为参数 ➊。然后为保存门的图像分配一个属性 ➋。注意,不需要在属性名前加上 self,因为该属性只会在方法内部使用。PhotoImage 类接受图像文件名作为参数,tkinter 使用它在画布、标签、文本或按钮小部件中显示图像。完成此步骤后,可以在 tkinter 标签中使用图像,因此为 photo_lbl 变量分配一个值,将父窗口和图像作为参数传递,并指定没有文本和细边框 ➌。

要将标签放置到父窗口中,请使用 grid() 方法并将其传递到第一行和第一列,让图像跨越 10 列,并使用 W 进行左对齐 ➍。这样,窗口的上部分将显示关闭门的图像。columnspan 选项允许小部件跨越多个列。该值不会影响图像大小,但 改变放置说明文本和其他小部件的位置数量。例如,如果设置 columnspan=2,那么只有两列可用于放置说明、按钮和消息。

通过创建图像对象 ➎ 的引用来完成照片标签。如果不这样做,图像将无法始终显示。

根据 tkinter 文档,tkinter 是建立在另一个产品(Tk)之上的一个层,而这两个之间的接口没有正确处理图像对象的引用。Tk 小部件保存对内部对象的引用,但 tkinter 不会。Python 使用垃圾回收模块自动回收不再需要的对象的内存。当 Python 内存分配器的垃圾回收器丢弃 tkinter 对象时,tkinter 告诉 Tk 释放图像。但由于图像正在使用中,Tk 无法释放它,因此它会将图像设置为透明。解决该问题的建议包括使用全局变量、使用实例属性,或者像这里一样,向小部件实例添加一个属性(photo_lbl.image = img)。更多信息请参阅 effbot.org/tkinterbook/photoimage.htm

最后,将说明文本作为标签小部件添加。这个过程是提供一组参数列表,然后遍历这些参数来构建小部件。首先从一个包含元组 ➏ 的列表开始,其中每个元组包含创建 Label 对象的选项;你可以在下一条语句 ➐ 中看到每个元组的含义。随着 for 循环的进行,在 parent 窗口中创建每个标签,并为其分配一些文本。然后使用 grid() 方法将文本放置到窗口中,位置依据元组列表中的信息。

使用ipadx选项与grid() ➑。该选项指的是标签内 x 方向的内边距,因此你可以调整它以微调窗口中文本的外观。在这种情况下,你为标签添加了 30 像素,使文本在视觉上对齐。

创建单选按钮和文本小部件

Listing 11-7 通过为三个门创建单选按钮小部件,继续定义create_widgets()方法。玩家通过选择 A、B 或 C 单选按钮来做出初始的门选择。然后,由win_reveal()方法处理他们的选择,你稍后会定义这个方法。该方法将确定获胜的门并揭示一只山羊。

另一个单选按钮组被创建用来获取玩家是否选择换门。结果将由稍后定义的show_final()方法处理。除了揭示玩家最终选择的门背后是什么,这个方法还将使用在本列表末尾定义的Text小部件更新胜利统计数据。

monty_hall_gui.py, 第三部分

          # create radio buttons for getting initial user choice
        ➊ self.door_choice = tk.StringVar()
           self.door_choice.set(None)

        ➋ a = tk.Radiobutton(self.parent, text='A', variable=self.door_choice,
                              value='a', command=self.win_reveal)
           b = tk.Radiobutton(self.parent, text='B', variable=self.door_choice,
                              value='b', command=self.win_reveal)
           c = tk.Radiobutton(self.parent, text='C', variable=self.door_choice,
                              value='c', command=self.win_reveal)

           # create widgets for changing door choice
        ➌ self.change_door = tk.StringVar()
           self.change_door.set(None)

        ➍ instr_lbl = tk.Label(self.parent, text='Change doors?')
           instr_lbl.grid(row=2, column=3, columnspan=1, sticky='E')

        ➎ self.yes = tk.Radiobutton(self.parent, state='disabled', text='Y',
                                     variable=self.change_door, value='y',
                                     command=self.show_final)
           self.no = tk.Radiobutton(self.parent, state='disabled', text='N',
                                    variable=self.change_door, value='n',
                                    command=self.show_final)

           # create text widgets for win statistics
        ➏ defaultbg = self.parent.cget('bg')
        ➐ self.unchanged_wins_txt = tk.Text(self.parent, width=20,
                                             height=1, wrap=tk.WORD,
                                             bg=defaultbg, fg='black',
                                             borderwidth=0)
           self.changed_wins_txt = tk.Text(self.parent, width=20,
                                           height=1, wrap=tk.WORD, bg=defaultbg,
                                           fg='black', borderwidth=0)

Listing 11-7: 为 create_widgets() 方法构建单选按钮和文本小部件

首先创建 A、B 和 C 门的单选按钮。当用户与tkinter小部件进行交互时,结果是一个事件。你可以使用变量来跟踪这些事件,例如,当玩家通过按下单选按钮选择一个门时。对于小部件特定的变量,tkinter有一个变量类。使用string变量类StringVar,并将其分配给名为door_choice的变量 ➊。立即使用set()方法将该变量的值设置为None

接下来,为三个门设置按钮小部件 ➋。玩家将点击其中一个作为他们的第一个门选择。使用Radiobutton类,并传递父窗口、要显示的文本、你刚分配的door_choice变量、与门名相等的值和一个命令。该命令调用win_reveal()方法,你很快会定义它。请注意,方法名后面不包括括号。

对按钮 B 和 C 重复此过程。这主要是一个复制粘贴的练习,因为你只需要更改门的标识。

现在,构建切换门的单选按钮。首先创建另一个字符串变量,就像你为初始门选择所做的那样 ➌。这个变量将保存yn,取决于选择了哪个单选按钮。

使用Label类构建一个说明标签 ➍。然后构建self.yes单选按钮 ➎。使用Radiobutton类,传入父窗口,将其状态设置为disabled。这样,窗口将在初始化时将是灰色的“是/否”按钮,玩家无法在选择一个门之前尝试更改门。文本参数是按钮的名称;使用简写 Y 表示yes。将控件的变量参数设置为change_door变量,将其值设置为y,然后调用show_final()函数。对“否”按钮重复此过程。

你需要的最后一个控件是Text控件,用于显示换门和不换门的计数。使用Text类来显示统计信息,并将文本框的颜色设置为与父窗口匹配。为此,使用cget()方法获取parent的背景色(bg),然后将其赋值给一个变量 ➏。cget()方法返回tkinter选项的当前值,类型为字符串。

创建一个文本对象,用于显示坚持原选择所获得的胜利次数 ➐。你需要传递给控件父窗口、宽度和高度、如果文本超出一行时如何换行、背景色、前景色(文本颜色)以及文本框的边框宽度。注意,此时不包含任何实际文本;这些将在稍后由show_final()方法添加。

使用另一个文本控件来显示由于换门而赢得的次数。

排列控件

列表 11-8 通过使用tkinterGrid几何管理器来定位游戏窗口中其余未定位的控件,从而完成了create_widgets()方法。

monty_hall_gui.py, 第四部分

           # place the widgets in the frame
        ➊ a.grid(row=1, column=4, sticky='W', padx=20)
           b.grid(row=1, column=4, sticky='N', padx=20)
           c.grid(row=1, column=4, sticky='E', padx=20)
           self.yes.grid(row=2, column=4, sticky='W', padx=20)
           self.no.grid(row=2, column=4, sticky='N', padx=20)
        ➋ self.unchanged_wins_txt.grid(row=1, column=5, columnspan=5)
           self.changed_wins_txt.grid(row=2, column=5, columnspan=5)

列表 11-8:调用 grid() 方法将控件定位到框架中

使用grid()方法将门按钮定位到父窗口 ➊。将三个门按钮放在同一行同一列,并使用sticky对齐方式将它们分开:W表示左对齐,N表示居中,E表示右对齐。使用padx稍微调整按钮的位置。对剩余的按钮重复此过程,然后将胜利统计文本控件定位到窗口右侧的五列上 ➋。

更新门图片

你需要在游戏中多次开关门,因此列表 11-9 定义了一个辅助方法,根据需要更新门图片。请注意,使用面向对象编程时,不需要将文件名作为参数传递给方法。对象的所有方法可以直接访问以self开头的属性。

monty_hall_gui.py, 第五部分

    ➊ def update_image(self):
           """Update current doors image."""
        ➋ img = tk.PhotoImage(file=self.img_file)
        ➌ self.photo_lbl.configure(image=img)
        ➍ self.photo_lbl.image = img

列表 11-9:定义了一个方法来更新当前的门图片

定义一个函数,名为update_image(),它将self作为参数 ➊。然后像在列表 11-6 中那样使用PhotoImage类 ➋。文件名self.img_file将在其他方法中更新。

因为你已经创建了持有门图像的标签,所以使用configure()方法来更改标签——在这种情况下,通过加载一张新图像 ➌。你可以使用configure()config()方法。最后,将图像分配给一个小部件属性,以防止垃圾回收 ➍,如代码清单 11-6 所述。

选择获胜的门并揭示山羊

代码清单 11-10 定义了一个方法,选择获胜的门并揭示门,然后打开并关闭揭示的门。它还激活了“是/否”按钮,这些按钮在玩家做出第一次门选择之前是灰显的。

monty_hall_gui.py, 第六部分

    ➊ def win_reveal(self):
           """Randomly pick winner and reveal unchosen door with goat."""
        ➋ door_list = list(self.doors)
        ➌ self.choice = self.door_choice.get()
           self.winner = random.choice(door_list)

        ➍ door_list.remove(self.winner)

        ➎ if self.choice in door_list:
               door_list.remove(self.choice)
               self.reveal = door_list[0]
           else:
               self.reveal = random.choice(door_list)

        ➏ self.img_file = ('reveal_{}.png'.format(self.reveal))
           self.update_image()

           # turn on and clear yes/no buttons
        ➐ self.yes.config(state='normal')
           self.no.config(state='normal')
           self.change_door.set(None)

           # close doors 2 seconds after opening
        ➑ self.img_file = 'all_closed.png'
           self.parent.after(2000, self.update_image)

代码清单 11-10:定义一个方法来随机选择获胜的门并揭示门

定义一个方法,叫做win_reveal(),该方法以self作为参数 ➊。立即创建一个来自类属性doors的门列表 ➋。你将根据玩家的第一次门选择以及程序随机选择的获胜门来修改这个列表。

现在,将self.choice属性赋值为self.door_choice字符串变量,通过get()方法访问 ➌。这个属性的值是用户第一次选择的门单选按钮所决定的。接下来,从门列表中随机选择获胜的门。

从门列表中移除获胜的门 ➍。然后使用条件语句检查玩家的选择是否仍在门列表中;如果是,移除它,以防被揭示 ➎。这样,列表中只会剩下一个门,因此将其分配给self.reveal属性。

如果玩家选择了获胜的门,那么列表中剩下两扇门,随机选择其中一扇并将其分配给self.reveal。然后更新该门的self.img_file属性 ➏,接着调用更新图片标签以显示新图像的方法。图 11-6 是门 B 的揭示图像示例。

image

图 11-6:门 B 的揭示图像

接下来,将“是”和“否”按钮的状态设置为normal ➐。此后,它们将不再被灰显。方法的最后,将图像文件更改为all_closed.png,并在 2000 毫秒后调用self.update_image()方法,作用于parent窗口 ➑。这将确保门保持开启状态不超过 2 秒钟。

揭示玩家的最终选择

代码清单 11-11 定义了一个函数的第一部分,该函数接收玩家的最终门选择并揭示门后面的内容。该函数还将跟踪更换门和坚持原选择的获胜次数。

monty_hall_gui.py, 第七部分

    ➊ def show_final(self):
           """Reveal image behind user's final door choice & count wins."""
        ➋ door_list = list(self.doors)

        ➌ switch_doors = self.change_door.get()

        ➍ if switch_doors == 'y':
               door_list.remove(self.choice)
               door_list.remove(self.reveal)
            ➎ new_pick = door_list[0]
            ➏ if new_pick == self.winner:
                   self.img_file = 'money_{}.png'.format(new_pick)
                   self.pick_change_wins += 1
               else:
                   self.img_file = 'goat_{}.png'.format(new_pick)
                   self.first_choice_wins += 1
        ➐ elif switch_doors == 'n':
            ➑ if self.choice == self.winner:
                   self.img_file = 'money_{}.png'.format(self.choice)
                   self.first_choice_wins += 1
               else:
                   self.img_file = 'goat_{}.png'.format(self.choice)
                   self.pick_change_wins += 1

           # update door image
        ➒ self.update_image()

代码清单 11-11:定义一个方法来揭示玩家的最终选择并更新胜利列表

定义一个名为 show_final() 的方法,接受 self 作为参数 ➊。创建一个新的门列表副本 ➋,然后获取 self.change_doors 变量,并将其赋值给名为 switch_doors 的属性 ➌。这个变量将保存 'y''n',具体取决于玩家点击了哪个单选按钮。

如果玩家选择换门 ➍,则从列表中删除他们的第一次选择和已揭示的门,并为剩下的门分配一个 new_pick 属性 ➎。如果这个新选择是获胜的门 ➏,则引用正确的图像并增加 self.pick_change_wins 计数器。否则,将图像设置为山羊并增加 self.first_choice_wins 计数器。

如果玩家决定不换门 ➐ 且他们的第一次选择是获胜的门 ➑,则展示钱袋并增加 self.first_choice_``wins 计数器。否则,展示一只山羊并增加 self.pick_change_wins 计数器。

最后,调用 update_image() 方法来更新图像 ➒。再次强调,你不需要传递新的图像文件名,因为它可以访问你在前面的代码中更改过的 self.img_file 属性。

显示统计信息

清单 11-12 完成了 show_final() 方法,通过更新游戏窗口的获胜统计、禁用是/否按钮并关闭所有的门。

monty_hall_gui.py, 第八部分

           # update displayed statistics
        ➊ self.unchanged_wins_txt.delete(1.0, 'end')
        ➋ self.unchanged_wins_txt.insert(1.0, 'Unchanged wins = {:d}'
                                          .format(self.first_choice_wins))
           self.changed_wins_txt.delete(1.0, 'end')
           self.changed_wins_txt.insert(1.0, 'Changed wins = {:d}'
                                        .format(self.pick_change_wins))

           # turn off yes/no buttons and clear door choice buttons
        ➌ self.yes.config(state='disabled')
           self.no.config(state='disabled')
        ➍ self.door_choice.set(None)

        ➎ # close doors 2 seconds after opening
           self.img_file = 'all_closed.png'
           self.parent.after(2000, self.update_image)

清单 11-12:显示获胜统计,禁用是/否按钮,并关闭所有的门

self.unchanged_wins_txt 文本小部件 ➊ 中删除任何文本。删除从文本索引 1.0 开始。格式为 line.column,因此你指定的是文本小部件的第一行和第一列(行号从 1 开始,列号从 0 开始)。以 'end' 结束,这样可以确保从起始索引后面的所有文本都被删除。

接下来,使用 insert() 方法将 self.first_choice_wins 属性的值和一些描述性文本添加到文本小部件 ➋。插入从文本索引 1.0 开始。

self.changed_wins_txt 文本小部件重复这个过程,然后通过将其 config 状态设置为 'disabled' 来禁用是/否按钮 ➌。将 self.door_choice 字符串变量重置为 None,这样你就可以开始新的一局游戏了 ➍。

通过关闭门结束方法,正如你在 清单 11-10 中所做的 ➎。

设置根窗口并运行事件循环

清单 11-13 完成了 monty_hall_gui.py 程序,通过设置 tkinterroot 窗口、实例化游戏对象并运行 mainloop()。或者,这段代码可以封装在一个 main() 函数中。

monty_hall_gui.py, 第九部分

   # set up root window & run event loop
➊ root = tk.Tk()
➋ root.title('Monty Hall Problem')
➌ root.geometry('1280x820')  # pics are 1280 x 720
➍ game = Game(root)
   root.mainloop()

清单 11-13:设置 root 窗口,创建游戏对象,并运行 mainloop()

Tk 类无参数实例化 ➊。这会创建一个顶级的 tkinter 小部件,它将是游戏应用程序的主窗口。将其赋值给一个名为 root 的变量。

给窗口设置标题➋和像素大小➌。注意,图像的大小会影响几何布局,确保它们在窗口中吸引人地适配,并且在窗口下方留出足够空间显示说明和信息。

现在,创建游戏➍。将root窗口传递给它,它将是包含游戏的主窗口。这将导致新游戏被放置在root窗口中。

通过调用mainloop()方法来结束,它会在root上保持窗口打开,并等待处理事件。

总结

在这一章中,你使用了简单的蒙特卡洛模拟来验证更换门是蒙提霍尔问题的最佳策略。然后,你使用tkinter构建了一个有趣的界面,允许学生通过手动测试来验证这个结论,一局一局地进行。最重要的是,你学会了如何使用面向对象编程来构建响应用户输入的交互式控件。

进一步阅读

有关tkinter的有用参考可以在 “进一步阅读” 和第 212 页找到。

你可以在网上找到 1990 年蒙提霍尔问题争议的总结,链接为 marilynvossavant.com/game-show-problem/

练习项目:生日悖论

需要多少人才能确保有 50/50 的机会,两个人人会有相同的生日月和日期?根据生日悖论,其实不需要那么多!和蒙提霍尔问题一样,结果是反直觉的。

使用 MCS 来确定需要多少人才能达到 50%的概率。让程序打印出人数和一系列房间人数的概率。如果你发现自己查找日期格式化的方法,停下来简化一下!你可以在附录中找到解决方案,birthday_paradox_practice.py,或在 www.nostarch.com/impracticalpython/ 在线查找。

第十二章:确保你的“鸡蛋”**

image

婴儿潮一代是指 1946 年至 1964 年间出生的美国人。他们构成了一个庞大的群体——大约占美国人口的 20%,因此他们对美国文化的各个方面产生了巨大影响。金融行业很快就迎合了他们的需求,几十年来,这些需求主要集中在投资增长上。但在 2011 年,最年长的婴儿潮一代达到了 65 岁,并开始大量退休,每天达到 10,000 人随着寿命比前几代人更长,婴儿潮一代的退休期可能会和他们的职业生涯一样长。资助这个 30 到 40 年的退休期是一个巨大的问题,也是一项巨大的商业机会。

在财务顾问主要专注于增加婴儿潮一代财富的那些年,他们依赖于简单的“4%规则”来进行退休规划。简单来说,每退休一年,如果你花费的金额不超过退休首年储蓄的 4%,你将永远不会用完钱。但正如马克·吐温所言,“所有的概括都是错误的,包括这一条!”我们的投资价值和花费金额常常处于波动之中,通常是由于我们无法控制的外部因素。

作为 4%规则的更为复杂的替代方法,金融行业采用了蒙特卡洛模拟(参见第十一章了解 MCS 概述)。使用 MCS,你可以在数千个生命周期中测试和比较退休策略。目标是确定在退休期间每年可以支出多少金额,在考虑到预期寿命的前提下,确保不会耗尽储蓄。

随着不确定性来源的增加,MCS 相较于其他方法的优势也在提升。在第十一章中,你应用了 MCS 来处理单一变量,并使用了简单的概率分布。在这里,你将关注寿命的不确定性,同时捕捉股市、债券市场和通货膨胀的真实周期性和相互依赖性。这将帮助你评估和比较不同的退休策略,从而实现安全和幸福的退休生活。

项目#20:模拟退休生命周期

如果你认为自己还太年轻,不必担心退休问题,那就再想想吧。婴儿潮一代曾也有同样的想法,现在超过一半的人退休储蓄不足。对于大多数人来说,退休后能吃到神户牛肉还是狗粮,取决于他们多久开始储蓄。由于复利的魔力,即使是适度的储蓄也能在几十年里积累起来。早早知道自己未来需要的数字,让你能够设定实际的目标,顺利过渡到黄金岁月。

目标

构建一个蒙特卡洛模拟,以估算退休期间用尽资金的概率。将退休年限视为一个关键的不确定因素,并利用历史股市、债券和通货膨胀数据来捕捉这些变量的周期性和相互依赖性。

策略

为了规划你的项目,别犹豫,去看看竞争对手的情况。许多养老基金计算器可以在网上免费使用。如果你玩这些计算器,你会发现它们展示了高度的输入参数变动性。

拥有许多参数的计算器可能看起来更好(见图 12-1),但随着每增加一个细节,你会开始陷入“兔子洞”,尤其是在涉及美国复杂的税法时。当你预测未来 30 到 40 年的结果时,细节可能变得杂乱无章。所以,最好保持简单,专注于最重要且可控的问题。你可以控制退休时间、投资资产配置、储蓄和支出的多少,但你无法控制股市、利率和通货膨胀。

image

图 12-1:三个在线养老基金计算器的示例输入面板

当你无法知道问题的“正确”答案时,查看一系列情境并基于概率做决策是最好的。对于涉及“致命错误”的决策,比如资金耗尽,理想的解决方案是那些能降低这一事件发生概率的方案。

在你开始之前,你需要了解一些术语,因此我整理了一份你在这个项目中会用到的金融术语表:

债券 债券是一种债务投资形式,你将钱借给一个实体——通常是政府或公司——并在一定时间内获得利息。借款方会按照约定的利率(债券的收益率)支付利息,且在期限结束时偿还全额本金,前提是发行实体没有破产并违约。债券的价值可能随着时间波动,因此如果你提前出售债券,可能会亏损。债券对于退休人员来说很有吸引力,因为它们提供安全、稳定、可预测的回报。美国政府发行的国债被认为是最安全的。然而,不幸的是,大多数债券的回报率偏低,因此容易受到通货膨胀的影响。

有效税率 这是个人或已婚夫妇被征税的平均税率,涵盖了地方税、州税和联邦税。税收可能很复杂,州和地方税率差异很大,存在很多扣除和调整机会,且不同类型的收入(如短期与长期资本利得)有不同的税率。税法也是累进的,这意味着随着收入增加,你的税负比例也会增加。根据金融服务公司 The Motley Fool 的说法,2015 年,美国人平均的基于收入的税率为 29.8%。这还不包括销售税和财产税!你还可以指望国会在 30 年的退休期间至少调整一次这些税率。由于这些复杂性,在这个项目中,你应调整你的提取(支出)参数,以考虑税收问题。

指数 投资于多种资产是最安全的,而不是将所有(积蓄)鸡蛋放在一个篮子里。指数是一个假设的证券投资组合,或多个篮子的组合,旨在代表金融市场的广泛部分。例如,标准普尔 500(S&P 500)代表美国 500 家最大公司,这些公司大多数是支付股息的公司。基于指数的投资——例如指数共同基金——允许投资者方便地购买一个包含数百家公司股票的单一产品。

通货膨胀 这是由于需求增加、货币贬值、能源成本上升等原因导致的价格上涨。通货膨胀是财富的潜在毁灭者。通货膨胀率是可变的,但自 1926 年以来年均大约为 3%。按此速率计算,货币的价值每 24 年减半。适度的通货膨胀(1%到 3%)通常表明经济在增长,工资也在上涨。较高的通货膨胀率和负通货膨胀都是不受欢迎的。

案例数量 这些是 MCS 中进行的试验或运行;每个案例代表一个单一的退休生命周期,并使用一组新的随机选择值进行模拟。对于你将要运行的模拟,50,000 到 100,000 个案例之间的数量应能提供一个适当可重复的答案。

破产概率 这是指在退休结束前用完资金的概率。你可以通过用没有资金的案例数除以总案例数来计算它。

起始值 起始值是退休开始时所持有的所有流动投资的总价值,包括支票账户、经纪账户、税延个人退休账户(IRA)等。这与净资产不同,后者包括房屋、汽车和法贝热蛋等资产。

股票 股票是一种表示对公司所有权的证券,并代表对公司部分资产和收益的索赔。许多股票支付股息,这是一种类似于债券或银行账户支付的利息的定期支付。对于普通人来说,股票是增长财富的最快方式,但它们并非没有风险。股票的价格可以在短时间内迅速波动——这既受到公司表现的影响,也受到投资者贪婪或恐惧引发的投机行为的影响。退休人员倾向于投资于最大的美国分红支付公司,因为它们提供稳定的收入,并且股票价格波动比小公司更小。

总回报 总回报是资本利得(资产价值变化,如股价)、利息和股息的总和。通常以年度为单位进行报价。

取款 也称为费用或支出,取款是你在某一年需要覆盖所有费用的税前总收入。对于 4%规则来说,这代表退休第一年起始价值的 4%。这个数值应该在每年根据通货膨胀进行调整。

历史回报很重要

使用固定投资回报和通货膨胀值的“养老金模拟器”(见图 12-1)会严重扭曲现实。预测能力仅与假设基础的准确性相关,而回报可能是高度波动的、相互依赖的,并且是周期性的。当退休开始或出现重大意外支出时,恰逢市场大幅下跌,波动性对退休人员影响最大。

图 12-2 中的图表显示了美国最大公司 S&P 500 指数和 10 年期国债的年回报率,后者是一种相对安全、中等风险、固定收益的投资。图表还包括年通货膨胀率和重大金融事件,如大萧条。

image

图 12-2:1926 年至 2013 年股票和债券市场的年通胀率加总回报率

金融学者对图 12-2 中趋势的长期研究得出了一些有用的观察结果,关于美国市场:

  • 牛市(上涨市场)通常持续的时间是熊市(下跌市场)的五倍。

  • 有害的高通货膨胀率可能会持续长达十年。

  • 债券往往提供低回报,难以跟上通货膨胀的步伐。

  • 股票回报轻松超越通货膨胀,但代价是价格波动剧烈。

  • 股票和债券回报往往呈反向相关;这意味着,当股票回报增加时,债券回报会减少,反之亦然。

  • 大公司的股票和国债都不能保证你一帆风顺。

基于这些信息,财务顾问建议大多数退休人员持有一个多元化的投资组合,包括多种投资类型。这种策略利用一种投资类型作为另一种投资的“对冲”,抑制高点但提高低点,从理论上减少波动性。

在图 12-3 中,年投资回报通过 S&P 500 和一个假设的 40/50/10 百分比组合进行绘制,分别是 S&P 500、10 年期国债和现金。三个月期国库券是一种非常短期的债券,具有稳定的价格和较低的收益(像是放在床垫里的钱),代表现金。

image

图 12-3:1926 年至 2013 年,S&P 500 与 S&P 500、10 年期国债和现金的组合年回报对比

这种多元化的投资组合比单纯的股市投资提供了更加平稳的投资体验,同时仍能有效抵御通货膨胀。但它显然会产生不同于那些假设回报始终保持恒定且为正值的在线计算器的结果。

通过使用历史数据,你可以捕捉到好时光和坏时光的真实测量时长,以及最高点和最低点。你还需要考虑到 4%规则完全忽略的一点:黑天鹅事件

黑天鹅事件是具有重大影响、极不可能发生的事件。这些事件可能是好的,比如遇到你的配偶,也可能是坏的,比如 1987 年 10 月的“黑色星期一”股市崩盘。蒙特卡罗模拟(MCS)的一个优点是它能够考虑到这些意外事件;缺点是你必须编程将它们纳入,如果这些事件真的无法预见,你又如何知道应该包括什么呢?

已经发生的黑天鹅事件,比如大萧条,会在历史回报数据的年度值中体现出来。因此,一种常见的方法是使用历史结果并假设未来不会发生比现在更糟或更好的事情。当模拟使用大萧条时期的数据时,模拟的投资组合将经历与当时真实投资组合相同的股票、债券和通胀行为。

如果使用过去的数据似乎过于限制,你总是可以编辑过去的结果,以反映更低的低点和更高的高点。但大多数人更为务实,更愿意处理那些他们知道已经发生的事件——而不是僵尸末日或外星人入侵——因此,真实的历史结果为财务规划提供了一个可靠的方式来融入现实。

一些经济学家认为 1980 年前的通货膨胀和回报数据用处有限,因为联邦储备委员会现在在货币政策和通货膨胀控制方面发挥着更为积极的作用。另一方面,正是这种正是会让我们暴露于黑天鹅事件之中的思维方式!

最大的不确定性

退休规划中最大的未知数是你——或者你幸存的配偶——的去世日期,财务顾问们委婉地称之为“计划结束”。这一不确定性影响着每一个与退休相关的决策,比如你何时退休、退休后花费多少、何时开始领取社会保障、你将留给继承人的遗产等等。

保险公司和政府通过精算寿命表来应对这种不确定性。基于某个人群的死亡率经验,精算寿命表可以预测一个特定年龄的人的预期寿命,这个预期寿命表示在死亡之前的平均剩余年数。你可以在* www.ssa.gov/oact/STATS/table4c6.html *找到社会保障表。根据该表,2014 年 60 岁的女性的预期寿命为 24.48 年;这意味着计划的结束时间将在她的第 84 年。

精算表对于大规模人群非常有效,但对于个体来说,它们仅是一个起点。在制定您自己的退休计划时,您应该根据家族历史和个人健康状况,审视一系列的数值。

为了处理您模拟中的这种不确定性,考虑将退休年限视为随机变量,其值从频率分布中随机选择。例如,您可以输入您预期的最可能、最小和最大退休年限,并使用这些值构建一个三角分布。最可能的值可以来自精算表,但端点应该根据您的个人健康状况和家族历史来确定。

基于 60 岁男性退休年限的三角分布的示例结果见图 12-4。最低退休年限设定为 20 年,最可能为 22 年,最大为 40 年。分布的抽样次数为 1,000 次。

image

图 12-4:基于 1,000 次抽样的三角分布,退休年限与生命年数的关系

如您所见,最小值和最大值之间的每个可能时间区间都可以进行模拟,但从最可能值到最大值,区间的频率逐渐减小,这表明活到 100 岁是可能的,但不太可能。还要注意,图表明显偏向高端。这将确保保守的结果,因为从财务角度来看,早死是一个乐观的结果,而比预期活得长则是最大的财务风险。

一种定性方式呈现结果

MCS 的一个问题是如何理解成千上万的模拟结果,并以易于消化的方式呈现结果。大多数在线计算器使用类似图 12-5 中的图表来展示结果。在此示例中,对于一项 10,000 次模拟,计算器会将几个选定的结果绘制在图表上,x 轴为年龄,y 轴为投资价值。曲线在左侧与退休时的投资初始值重合,在右侧则展示计划结束时的价值。总体的退休金持续概率也可能被呈现出来。金融顾问认为低于 80 到 90%的概率是有风险的。

image

图 12-5:典型金融行业退休模拟器的示例展示

从这种分析中获得的最重要信息是用完资金的概率。查看终点和平均结果以及输入参数的摘要也是很有意思的。在您的 Python 模拟器中,您可以在解释器窗口中打印这些结果,如下所示:

Investment type: bonds
Starting value: $1,000,000
Annual withdrawal: $40,000
Years in retirement (min-ml-max): 17-25-40
Number of runs: 20,000

Odds of running out of money: 36.1%

Average outcome: $883,843
Minimum outcome: $0
Maximum outcome: $7,607,789

对于图形展示,我们不必重复他人已经做过的内容,而是找到一种新的方式来展示结果。每个案例的部分结果——即退休结束时剩余的资金——可以作为条形图中的垂直线呈现,如图 12-6 所示。

image

图 12-6:模拟退休期结果在条形图中以垂直列的形式展示

在这张图表中,每根柱子代表单个模拟寿命中的退休部分,每根柱子的高度表示该寿命结束时剩余的资金。由于每根柱子代表一个单独的类别,而不是连续测量的区间,因此你可以随意排列柱子而不影响数据。间隙代表资金耗尽的情况,可以按其在模拟中发生的顺序排列。通过在解释器窗口中记录的定量统计数据,这个图表提供了一种定性方式来呈现结果。

这张图表的高峰和低谷代表了许多可能未来的财富变化。在一生中,你可能会死于贫困;而在下一生,你则可能是百万富翁。这让人联想到那句古老的谚语:“若不是上帝的恩典,我也许就在那里。”但另一方面,它也强调了艾森豪威尔将军的观察:“计划是无用的,但规划是不可或缺的。”通过财务规划,你可以“抬高图表中的低谷”,并消除或大幅减少退休后破产的几率。

为了制作这张图表,你将使用matplotlib,这是一个支持 2D 绘图和基本 3D 绘图的库。欲了解更多关于matplotlib的信息以及如何安装它,请参阅《检测概率代码》一节,见第 194 页。

伪代码

基于之前的讨论,程序设计策略应该集中在几个重要的退休参数上,并利用金融市场的历史行为来模拟结果。以下是高级伪代码:

Get user input for investment type (all stocks, all bonds, or a blend)
Map investment type choice to a list of historical returns
Get user input for the starting value of investments
Get user input for the initial yearly withdrawal amount
Get user input for the minimum, most likely, and maximum duration of retirement
Get user input on number of cases to run
Start list to hold outcomes
Loop through cases:
    For each case:
        Extract random contiguous sample of returns list for duration period
        Extract same interval from inflation list
        For each year in sample:
            If year not equal to year 1:
                Adjust withdrawal for inflation
            Subtract withdrawal from investments
            Adjust investments for returns
            If investments <= 0:
                Investments = 0
                Break
    Append investments value to outcomes list
Display input parameters
Calculate and display the probability of ruin
Calculate and display statistics
Display a subset of outcomes as a bar chart

查找历史数据

你可以在许多网站上找到回报和通货膨胀信息(参见《进一步阅读》一节,见第 263 页中的一些例子),但我已经将你所需的信息整理为一系列可下载的文本文件。如果你选择自己编制列表,请注意,不同网站上关于通货膨胀和回报的估算值可能会有所不同。

对于回报,我使用了三种投资工具:标准普尔 500 股指、10 年期国债和三个月期国库券,所有数据均来自 1926 年至 2013 年(1926–1927 年国库券的数值为估算)。我利用这些数据生成了相同时间段的额外混合回报。以下是文件名及其内容的描述:

SP500_returns_1926-2013_pct.txt 标准普尔 500 指数的总回报(1926–2013)

10-yr_TBond_returns_1926-2013_pct.txt 10 年期国债的总回报(1926–2013 年)

3_mo_TBill_rate_1926-2013_pct.txt 三个月期国库券利率(1926–2013 年)

S-B_blend_1926-2013_pct.txt 由 50/50 比例的标准普尔 500 指数和 10 年期国债组成(1926–2013 年)

S-B-C_blend_1926-2013_pct.txt 由 40/50/10 比例的标准普尔 500 指数、10 年期国债和三个月期国库券组成(1926–2013 年)

annual_infl_rate_1926-2013_pct.txt 美国年平均通货膨胀率(1926–2013 年)

以下是标准普尔 500 指数文本文件的前七行示例:

11.6
37.5
43.8
-8.3
-25.1
-43.8
-8.6

这些值是百分比,但在代码中加载时,你需要将它们转换为小数值。请注意,年份未包含在内,因为这些值按时间顺序排列。如果所有文件覆盖相同的时间段,那么实际年份并不重要,但为了良好的记录管理,你应该将其包含在文件名中。

代码

将你的退休资金模拟器命名为 nest_egg_mcs.py。你需要在 “查找历史数据” 中描述的文本文件,这些文件位于 第 249 页。从 www.nostarch.com/impracticalpython/ 下载这些文件,并将它们与 nest_egg_mcs.py 保存在同一文件夹中。

导入模块并定义函数以加载数据并获取用户输入

Listing 12-1 导入模块并定义一个函数来读取历史回报和通货膨胀数据,以及另一个函数来获取用户输入。程序运行后,可以随意修改或添加历史数据进行实验。

nest_egg_mcs.py, 第一部分

   import sys

   import random

➊ import matplotlib.pyplot as plt

➋ def read_to_list(file_name):

       """Open a file of data in percent, convert to decimal & return a list."""

       ➌ with open(file_name) as in_file:

           ➍ lines = [float(line.strip()) for line in in_file]

           ➎ decimal = [round(line / 100, 5) for line in lines]

           ➏ return decimal

➐ def default_input(prompt, default=None):

       """Allow use of default values in input."""

➑     prompt = '{} [{}]: '.format(prompt, default)

➒     response = input(prompt)

➓     if not response and default:

           return default

       else:

           return response

Listing 12-1: 导入模块并定义函数以加载数据并获取用户输入

import 语句应该都很熟悉。需要 matplotlib 库来绘制结果的条形图。你只需要图形绘制功能,正如 import 语句中所指定的那样 ➊。

接下来,定义一个名为 read_to_list() 的函数,用于加载数据文件并处理其内容 ➋。你将把文件名作为参数传递给它。

使用 with 打开文件,这会自动关闭文件 ➌,然后使用列表推导式来构建文件内容的列表 ➍。立即将列表中的项目从百分比转换为保留五位小数的小数值 ➎。历史回报通常最多保留两位小数,所以保留五位小数应该足够了。你可能会注意到一些数据文件的值更为精确,但那只是通过 Excel 进行预处理的结果。最后返回 decimal 列表 ➏。

现在,定义一个名为default_input()的函数来获取用户输入 ➐。该函数将提示符和默认值作为参数。函数调用时,提示符和默认值将被指定,程序将显示默认值并加上括号 ➑。将一个response变量赋值为用户的输入 ➒。如果用户未输入任何内容且存在默认值,则返回默认值;否则,返回用户的回答 ➓。

获取用户输入

清单 12-2 加载数据文件,将结果列表通过字典映射到简单名称,并获取用户输入。该字典将用于为用户提供多种投资类型选择。总体而言,用户输入包括:

  • 用于投资的类型(股票、债券或两者的混合)

  • 他们退休储蓄的起始金额

  • 每年的提款或支出金额

  • 他们预计在退休后生活的最小年数、最可能的年数和最大年数

  • 需要运行的案例数量

nest_egg_mcs.py, 第二部分

   # load data files with original data in percent form
➊ print("\nNote: Input data should be in percent, not decimal!\n")
   try:
       bonds = read_to_list('10-yr_TBond_returns_1926-2013_pct.txt')
       stocks = read_to_list('SP500_returns_1926-2013_pct.txt')
       blend_40_50_10 = read_to_list('S-B-C_blend_1926-2013_pct.txt')
       blend_50_50 = read_to_list('S-B_blend_1926-2013_pct.txt')
       infl_rate = read_to_list('annual_infl_rate_1926-2013_pct.txt')
   except IOError as e:
       print("{}. \nTerminating program.".format(e), file=sys.stderr)
       sys.exit(1)

   # get user input; use dictionary for investment-type arguments
➋ investment_type_args = {'bonds': bonds, 'stocks': stocks,
                           'sb_blend': blend_50_50, 'sbc_blend': blend_40_50_10}

➌ # print input legend for user
   print("   stocks = SP500")
   print("    bonds = 10-yr Treasury Bond")
   print(" sb_blend = 50% SP500/50% TBond")
   print("sbc_blend = 40% SP500/50% TBond/10% Cash\n")
   print("Press ENTER to take default value shown in [brackets]. \n")

   # get user input
➍ invest_type = default_input("Enter investment type: (stocks, bonds, sb_blend,"\
                               " sbc_blend): \n", 'bonds').lower()
➎ while invest_type not in investment_type_args:
       invest_type = input("Invalid investment. Enter investment type " \
                           "as listed in prompt: ")

   start_value = default_input("Input starting value of investments: \n", \
                               '2000000')
➏ while not start_value.isdigit():
       start_value = input("Invalid input! Input integer only: ")

➐ withdrawal = default_input("Input annual pre-tax withdrawal" \
                              " (today's $): \n", '80000')
   while not withdrawal.isdigit():
       withdrawal = input("Invalid input! Input integer only: ")

   min_years = default_input("Input minimum years in retirement: \n", '18')
   while not min_years.isdigit():
       min_years = input("Invalid input! Input integer only: ")

   most_likely_years = default_input("Input most-likely years in retirement: \n",
                                     '25')
   while not most_likely_years.isdigit():
       most_likely_years = input("Invalid input! Input integer only: ")

   max_years = default_input("Input maximum years in retirement: \n", '40')
   while not max_years.isdigit():
       max_years = input("Invalid input! Input integer only: ")

   num_cases = default_input("Input number of cases to run: \n", '50000')
   while not num_cases.isdigit():
       num_cases = input("Invalid input! Input integer only: ")

清单 12-2:加载数据,将选择映射到列表,并获取用户输入

在打印警告提示输入数据应以百分比形式出现后,使用read_to_list()函数加载六个数据文件 ➊。在打开文件时使用try来捕获与缺失文件或错误文件名相关的异常。然后使用except块来处理异常。如果你需要复习tryexcept,请参考“处理打开文件时的异常”,见第 21 页。

用户将有一个选择投资工具进行测试。为了让他们输入简单的名称,可以使用字典将名称映射到你刚刚加载的数据列表 ➋。稍后,你会将这个字典及其键传递给一个函数作为参数:montecarlo(investment_type_args[invest_type])。在请求输入之前,打印一个图例以帮助用户 ➌。

接下来,获取用户的投资选择 ➍。使用default_input()函数并列出选择的名称,这些名称会映射回数据列表。将默认值设置为'bonds',以查看这种被认为“安全”的选择表现如何。务必加上.lower()方法,以防用户不小心输入了一个或两个大写字母。对于其他可能的输入错误,使用while循环检查输入是否与investment_type_args字典中的名称匹配;如果没有找到输入,提示用户输入正确的答案 ➎。

继续收集输入,并使用默认值引导用户进行合理的输入。例如,$80,000 是$2,000,000 起始值的 4%;另外,25 年是 60 岁女性进入退休的一个较好的最可能值,最大值为 40 将允许她们活到 100 岁,50,000 个案例应该可以迅速给出一个较好的破产概率估算。

对于数值输入,使用while循环检查输入是否为数字,以防用户在数字中加入美元符号($)或逗号 ➏。对于withdrawal金额,使用提示引导用户输入今天的美元金额,并告知他们无需担心通货膨胀 ➐。

检查其他错误输入

清单 12-3 检查其他输入错误。退休的最小、最可能和最大年数的顺序应当符合逻辑,并且最大年限为 99 年。允许较长的退休时间使得乐观的用户可以评估医学科学在抗衰老治疗方面取得重大进展的情况!

nest_egg_mcs.py,第三部分

   # check for other erroneous input
➊ if not int(min_years) < int(most_likely_years) < int(max_years) \
       or int(max_years) > 99:
    ➋ print("\nProblem with input years.", file=sys.stderr)
       print("Requires Min < ML < Max with Max <= 99.", file=sys.stderr)
       sys.exit(1)

清单 12-3:检查错误并设置退休年份输入的限制

使用条件语句确保最小输入年份小于最可能年份,最可能年份小于最大年份,且最大年份不超过 99 年 ➊。如果遇到问题,提醒用户 ➋,提供一些澄清说明,并退出程序。

定义蒙特卡洛引擎

清单 12-4 定义了将运行蒙特卡洛模拟的函数的第一部分。程序使用循环遍历每个案例,退休年份输入将用于采样历史数据。对于回报率和通货膨胀列表,程序会随机选择一个起始年份或索引。退休年份数,赋值给duration变量,从根据用户输入构建的三角分布中抽取。如果选择了 30 年,那么将在此起始索引上加上 30,创建结束索引。随机选择的起始年份将决定退休人员余生的财务命运!正如人们所说,时机就是一切。

nest_egg_mcs.py,第四部分

➊ def montecarlo(returns):
       """Run MCS and return investment value at end-of-plan and bankrupt count."""
    ➋ case_count = 0
       bankrupt_count = 0
       outcome = []

    ➌ while case_count < int(num_cases):
           investments = int(start_value)
        ➍ start_year = random.randrange(0, len(returns))
        ➎ duration = int(random.triangular(int(min_years), int(max_years),
                                            int(most_likely_years)))
        ➏ end_year = start_year + duration
        ➐ lifespan = [i for i in range(start_year, end_year)]
           bankrupt = 'no'

           # build temporary lists for each case
        ➑ lifespan_returns = []
           lifespan_infl = []
           for i in lifespan:
            ➒ lifespan_returns.append(returns[i % len(returns)])
               lifespan_infl.append(infl_rate[i % len(infl_rate)])

清单 12-4:定义蒙特卡洛函数并启动循环遍历各个案例

montecarlo()函数以returns列表作为参数 ➊。第一步是启动计数器以追踪当前运行的案例 ➋。请记住,不需要使用实际日期;列表中的第一年是索引 0,而不是 1926 年。此外,启动一个计数器来记录提前用完资金的案例数。然后,启动一个空列表,用来保存每次运行的结果,即运行结束时剩余的金额。

开始while循环,遍历各个案例 ➌。为起始投资金额指定一个新的变量,称为investments,该金额为用户指定的初始投资值。由于investments变量会不断变化,你需要保留原始输入变量,以便在每个案例中重新初始化。而且,由于所有用户输入的内容都是字符串,你需要在使用之前将其转换为整数。

接下来,分配一个start_year变量,并从可用年份的范围中随机选择一个值 ➍。为了得到模拟生命中的退休时间,使用random模块的triangular()方法从一个由用户输入的min_yearsmost_likely_yearsmax_years定义的三角分布中抽取值 ➎。根据文档,triangular()返回一个随机浮动数N,使得低值 <= N <= 高值,并且在这两个边界之间具有指定的模式

将这个duration变量加到start_year变量中,并将结果赋值给end_year变量 ➏。现在,创建一个新的列表lifespan,该列表包含从起始年份到结束年份之间的所有索引 ➐。这些索引将用于将退休期间与历史数据匹配。接下来,分配一个bankrupt变量,并赋值为'no'。破产意味着你已经没钱了,稍后这个结果将通过break语句提前结束while循环。

使用两个列表来存储所选lifespan ➑的适用回报和通货膨胀数据。使用for循环填充这些列表,循环中每个lifespan项作为回报和通货膨胀列表的索引。如果lifespan的索引超出了其他列表的范围,则使用取模(%)运算符来循环索引 ➒。

让我们进一步了解一下这个列表的背景。随机选择的start_year变量和计算出的end_year变量决定了如何对回报和通货膨胀列表进行抽样。样本是连续的一段金融历史,构成了一个案例。随机选择一个区间使得该程序与在线计算器有所不同,后者通常随机选择单个年份,并且可能会对每个资产类别和通货膨胀使用不同的年份!市场结果并非完全混乱;牛市和熊市是周期性的,通货膨胀趋势也是如此。导致股票下跌的事件同样会影响债券价格和通货膨胀率。随机选择年份会忽略这种相互依赖关系,并破坏已知的行为模式,从而导致不现实的结果。

在图 12-7 中,退休人员(即案例 1)选择在 1965 年退休——在大通货膨胀开始时——并投资债券。由于结束年份发生在回报列表的结束前,退休跨度恰好适合该列表。回报和通货膨胀都在同一个区间内进行抽样。

image

图 12-7:债券和通货膨胀列表的图表,标注了 1965 年开始的退休

在图 12-8 中,退休人员或案例 2 选择在 2000 年退休。由于列表的结束年份是 2013 年,因此 MCS 函数所取的 30 年样本必须“循环”并覆盖 1926 年至 1941 年。这迫使退休人员经历两次衰退和一次大萧条。

image

图 12-8:债券和通货膨胀列表的图表,标注了案例 2 中使用的区间

程序将需要模拟你在案例 2 中看到的循环段——因此使用了模运算符,它允许你将列表视为无尽的循环。

模拟案例中的每年

清单 12-5 继续了 montecarlo() 函数,并对给定案例的每年退休生活进行循环,根据该年的回报增减投资价值,从投资中扣除通货膨胀调整后的取款金额,并检查是否耗尽了投资。程序将最终的投资价值——代表死亡时剩余的储蓄——保存到列表中,以便在最后计算总体破产概率。

nest_egg_mcs.py, 第五部分

        # loop through each year of retirement for each case run
        ➊ for index, i in enumerate(lifespan_returns):
            ➋ infl = lifespan_infl[index]

            ➌ # don't adjust for inflation the first year
               if index == 0:
                   withdraw_infl_adj = int(withdrawal)
               else:
                   withdraw_infl_adj = int(withdraw_infl_adj * (1 + infl))

            ➍ investments -= withdraw_infl_adj
               investments = int(investments * (1 + i))

            ➎ if investments <= 0:
                   bankrupt = 'yes'
                   break

        ➏ if bankrupt == 'yes':
               outcome.append(0)
               bankrupt_count += 1
           else:
               outcome.append(investments)

        ➐ case_count += 1

    ➑ return outcome, bankrupt_count

清单 12-5:模拟每个案例中退休每年的结果

启动 for 循环,遍历一个案例中的所有年份 ➊。对 returns 列表使用 enumerate(),并利用 enumerate() 生成的索引从通货膨胀列表中获取该年平均的通货膨胀值 ➋。使用条件语句,在第一年后开始应用通货膨胀 ➌。根据是否处于通胀或通缩时期,这将逐步增加或减少取款金额。

investments 变量中减去通货膨胀调整后的取款值,然后根据该年的回报调整 investments ➍。检查 investments 的值是否大于 0。如果不是,将 bankrupt 变量设置为 'yes' 并结束循环 ➎。对于破产的情况,将 0 添加到 outcome 列表 ➏。否则,循环将继续,直到达到退休的持续时间,因此将 investments 的剩余值添加到 outcome

一个人的生命刚刚结束:30 到 40 年的假期、孙辈、宾果游戏和疾病在不到一秒钟的时间里消逝。所以,在循环处理下一个生命周期之前,先增加案例计数器 ➐。通过返回 outcomebankrupt_count 变量来结束函数 ➑。

计算破产概率

清单 12-6 定义了一个计算破产概率的函数,也称为“破产概率”。如果你是风险规避型,或者想为继承人留下大笔遗产,你可能希望这个数字低于 10%。而那些风险偏好较高的人,可能会满足于达到 20% 或更高。毕竟,钱不能带走!

nest_egg_mcs.py, 第六部分

➊ def bankrupt_prob(outcome, bankrupt_count):
       """Calculate and return chance of running out of money & other stats."""
    ➋ total = len(outcome)
    ➌ odds = round(100 * bankrupt_count / total, 1)

    ➍ print("\nInvestment type: {}".format(invest_type))
       print("Starting value: ${:,}".format(int(start_value)))
       print("Annual withdrawal: ${:,}".format(int(withdrawal)))
       print("Years in retirement (min-ml-max): {}-{}-{}"
             .format(min_years, most_likely_years, max_years))
       print("Number of runs: {:,}\n".format(len(outcome)))
       print("Odds of running out of money: {}%\n".format(odds))
       print("Average outcome: ${:,}".format(int(sum(outcome) / total)))
       print("Minimum outcome: ${:,}".format(min(i for i in outcome)))
       print("Maximum outcome: ${:,}".format(max(i for i in outcome)))

    ➎ return odds

清单 12-6:计算并显示“破产概率”和其他统计数据

定义一个名为 bankrupt_prob() 的函数,该函数接受从 montecarlo() 函数返回的 outcome 列表和 bankrupt_count 变量作为参数 ➊。将 outcome 列表的长度赋值给一个名为 total 的变量 ➋。然后,通过将破产案例的数量除以总案例数,计算破产的概率,并四舍五入到小数点后一位 ➌。

现在,显示仿真输入参数和结果 ➍。你在 “以定性方式呈现结果” 中看到了这个文本输出的例子,位于 第 246 页。最后返回 odds 变量 ➎。

定义并调用 main() 函数

清单 12-7 定义了 main() 函数,该函数调用了 montecarlo()bankrupt_count() 函数,并创建了条形图显示。不同情况下的结果可能有很大的差异——有时你会破产,而有时你会成为千万富翁!如果打印的统计数据没有清楚地显示这一点,条形图一定会。

nest_egg_mcs.py, 第七部分

➊ def main():
       """Call MCS & bankrupt functions and draw bar chart of results."""
    ➋ outcome, bankrupt_count = montecarlo(investment_type_args[invest_type])
       odds = bankrupt_prob(outcome, bankrupt_count)

    ➌ plotdata = outcome[:3000]  # only plot first 3000 runs

    ➍ plt.figure('Outcome by Case (showing first {} runs)'.format(len(plotdata)),
                  figsize=(16, 5))  # size is width, height in inches
    ➎ index = [i + 1 for i in range(len(plotdata))]
    ➏ plt.bar(index, plotdata, color='black')
       plt.xlabel('Simulated Lives', fontsize=18)
       plt.ylabel('$ Remaining', fontsize=18)
    ➐ plt.ticklabel_format(style='plain', axis='y')
    ➑ ax = plt.gca()
       ax.get_yaxis().set_major_formatter(plt.FuncFormatter(lambda x, loc: "{:,}"
                                                            .format(int(x))))
       plt.title('Probability of running out of money = {}%'.format(odds),
                 fontsize=20, color='red')
    ➒ plt.show()

   # run program
➓ if __name__ == '__main__':
       main()

清单 12-7:定义并调用 main() 函数

定义一个不需要任何参数的 main() 函数 ➊,并立即调用 montecarlo() 函数来获取 outcome 列表和 bankrupt_count() 函数 ➋。使用你在 清单 12-2 中创建的投资名称与回报列表的字典映射。你传递给 montecarlo() 函数的参数是字典名 investment_type_args,其中包含用户输入的 invest_type 作为键。将返回的值传递给 bankrupt_prob() 函数,以获得破产的概率。

将新变量 plotdata 赋值为 outcome 列表中的前 3,000 项 ➌。条形图可以容纳更多项,但显示它们会非常慢,并且没有必要。由于结果是随机的,通过显示更多的案例,你不会获得更多额外的信息。

现在你将使用 matplotlib 来创建和显示条形图。首先,创建一个图形 ➍。文本条目将作为新窗口的标题。figsize 参数是窗口的宽度和高度,以英寸为单位。你可以通过添加每英寸像素数(dpi)来调整此参数,例如 dpi=200

接下来,使用列表推导式根据 plotdata 列表的长度 ➎ 来构建索引,起始值为 1(表示第一年)。每个垂直条的 x 轴位置将由索引定义,每个条形的高度将是相应的 plotdata 项,表示每个模拟生命结束时剩余的资金。将这些传递给 plt.bar() 方法,并将条形的颜色设置为黑色 ➏。请注意,条形的其他显示选项,例如改变条形轮廓的颜色(edgecolor='black')或其厚度(linewidth=0)。

为 x 轴和 y 轴提供标签,并将字体大小设置为 18。结果可能达到数百万,默认情况下,matplotlib在注释 y 轴时会使用科学计数法。要覆盖这一点,可以调用ticklabel_format()方法,并将 y 轴样式设置为'plain' ➐。这样可以处理科学计数法,但没有千位分隔符,这使得数字难以阅读。为了解决这个问题,首先使用plt.gca() ➑获取当前坐标轴。然后,在下一行,获取 y 轴并使用set_major_formatter()Func_Formatter()方法以及一个 lambda 函数,应用 Python 的字符串格式化技巧以添加千位分隔符。

对于图表的标题,显示“耗尽资金的概率”——通过odds变量捕捉——并用醒目的红色大字体显示。然后,使用plt.show() ➒将图表绘制到屏幕上。在全局空间中,最后写上允许该程序作为模块导入或以独立模式运行的代码 ➓。

使用模拟器

nest_egg_mcs.py 程序大大简化了退休规划的复杂世界,但不要因此而贬低它。简单的模型通过挑战假设、提高意识和聚焦问题来增值。在退休规划——或任何复杂问题——中,很容易陷入细节,因此最好先了解大致的情况。

让我们通过一个例子来演示,假设初始值为 2,000,000 美元,投资一个“安全稳健”的债券投资组合,采用 4%的提款率(即每年 80,000 美元),退休年龄在 29-30-31 岁之间,模拟 50,000 次。如果你运行这个情景,你应该得到类似于图 12-9 的结果。几乎一半的情况下,你会耗尽资金!由于相对较低的收益率,债券无法跟上通货膨胀——记住,你不能盲目应用 4%提款法则,因为你的资产配置很重要。

image

图 12-9:使用 matplotlib 制作的条形图,代表债券-only 投资组合的蒙特卡洛模拟

请注意,80,000 美元的提款是税前金额。假设有效税率为 25%,那么你实际到手的收入仅为 60,000 美元。根据皮尤研究中心的数据,美国中产阶级的中位数可支配收入(税后)目前为 60,884 美元,因此尽管你是百万富翁,但实际上并没有过上奢华的生活。如果你想要 80,000 美元的可支配收入,你必须除以 1 减去有效税率;在本例中,计算式为 80,000 / (1 – 0.25) = 106,667 美元。这要求你每年提取略高于 5%的资金,且根据投资类型,破产的概率在 20%到 70%之间!

表 12-1 记录了变化的资产类型和提款率场景的结果。广泛认为安全的结果以灰色标记。如果避免全债券组合,4%规则表现良好。超过 4%,股票的增长潜力提供了减少破产概率的最佳机会——在考虑的选项中,并且风险低于大多数人假设的风险。这就是为什么金融顾问推荐在退休投资组合中加入健康的股票份额。

表 12-1: 30 年退休期按资产类型和提款率的破产概率

资产类型 年度(税前)提款百分比
3%
--- ---
10 年期国债 0.135
标普 500 股票 0
50/50 混合 0
40/50/10 混合 0

金融顾问还建议不要在退休初期过度消费。一些给大家庭的邮轮旅行、一栋豪华新房或一项昂贵的新爱好,可能会在晚年让你跌入财务悬崖。为了调查这一点,复制nested_egg_mcs.py并将副本命名为nested_egg_mcs_1st_5yrs.py;按照示例 12-8、12-9 和 12-10 中描述的方式调整代码:

nest_egg_mcs_1st_5yrs.py, 第一部分

   start_value = default_input("Input starting value of investments: \n", \
                               '2000000')
   while not start_value.isdigit():
       start_value = input("Invalid input! Input integer only: ")

➊ withdrawal_1 = default_input("Input annual pre-tax withdrawal for " \
                                 "first 5 yrs(today's $): \n", '100000')
   while not withdrawal_1.isdigit():
       withdrawal_1 = input("Invalid input! Input integer only: ")

➋ withdrawal_2 = default_input("Input annual pre-tax withdrawal for " \
                                "remainder (today's $): \n", '80000')
   while not withdrawal_2.isdigit():
       withdrawal_2 = input("Invalid input! Input integer only: ")

   min_years = default_input("Input minimum years in retirement: \n", '18')

示例 12-8:将用户的提款输入分为两部分

在用户输入部分,替换原有的withdrawal变量为两个提款变量,并编辑提示信息,要求用户输入前五年的提款金额 ➊,以及剩余退休年限的提款金额 ➋。将默认值设置为用户在前五年内应期待更高的提款金额。包括while循环以验证用户输入。

montecarlo()函数中,修改调整通货膨胀的提款金额的代码。

nest_egg_mcs_1st_5yrs.py, 第二部分

            # don't adjust for inflation the first year

            if index == 0:

             ➊ withdraw_infl_adj_1 = int(withdrawal_1)

             ➋ withdraw_infl_adj_2 = int(withdrawal_2)

            else:

             ➌ withdraw_infl_adj_1 = int(withdraw_infl_adj_1 * (1 + infl))

             ➍ withdraw_infl_adj_2 = int(withdraw_infl_adj_2 * (1 + infl))

         ➎ if index < 5:

             ➏ withdraw_infl_adj = withdraw_infl_adj_1

            else:

                withdraw_infl_adj = withdraw_infl_adj_2

            investments -= withdraw_infl_adj

            investments = int(investments * (1 + i))

示例 12-9:调整两个提款变量的通货膨胀,并决定使用哪个

将调整通货膨胀后的提款设置为仅适用于第一年的输入提款 ➊➋。否则,对两者都进行通货膨胀调整 ➌➍。这样,第二次提款金额将在五年后切换时“准备好”。

使用条件语句来指定何时应用每个调整通货膨胀后的提款 ➎。将这些分配给现有的withdraw_infl_adj变量,这样你就不需要再修改其他代码了 ➏。

最后,更新bankrupt_prob()函数中打印的统计数据,以包括新的提款值,如示例 12-10 所示。这些应替换旧的提款打印语句。

nest_egg_mcs_1st_5yrs.py, 第三部分

    print("Annual withdrawal first 5 yrs: ${:,}".format(int(withdrawal_1)))
    print("Annual withdrawal after 5 yrs: ${:,}".format(int(withdrawal_2)))

示例 12-10:打印两个提款期的提款值

现在你可以运行新的实验(见表 12-2)。

表 12-2: 30 年退休期按资产类型和不同提款率的破产概率

资产配置 年度(税前)提款百分比(前五年 / 其后)
4% / 4%
--- ---
10 年期国债 0.479
标普 500 股票 0.069
50/50 混合 0.079
40/50/10 混合 0.089

在表 12-2 中,安全的结果用灰色阴影标示,第一列则重复了常数 4%的结果,作为对照。如果你的投资组合中有足够的股票,你可以承受一些早期支出,因此,一些顾问将 4%规则替换为 4.5%规则或 5%规则。但如果你提前退休——比如 55 至 60 岁之间——无论是否经历任何高支出年份,你破产的风险将会更大。

如果你为 50/50 的股票债券混合组合运行模拟器,使用不同的退休年限,你应该得到与表 12-3 中类似的结果。只有一个结果(灰色阴影部分)具有低于 10%的破产概率。

表 12-3: 4%提款率(50/50 股票债券混合)下破产概率与退休年限的关系

退休年数 4%提款
30 0.079
35 0.103
40 0.194
45 0.216

进行这样的模拟迫使人们面对艰难的决策,并为他们生活中的一个大段时间制定现实的计划。尽管模拟每年“卖出”资产来资助退休,但更好的现实生活解决方案是护栏策略,即优先使用利息和股息,并保持现金储备,以避免在市场低迷时不得不卖出资产。假设你能够保持投资纪律,这一策略将使你在一定程度上突破模拟器计算的安全提款限额。

总结

在本章中,你编写了一个基于蒙特卡洛方法的退休计算器,能够从历史金融数据中进行真实的采样。你还使用了matplotlib提供了一种查看计算器输出的替代方法。虽然所使用的示例本可以通过确定性建模,但如果加入更多的随机变量——如未来税率、社会保障支付和医疗保健费用——蒙特卡洛模拟很快成为建模退休策略的唯一实用方法。

进一步阅读

《聪明的投资者:价值投资的权威之作,修订版》(哈珀商业,2006 年)由本杰明·格雷厄姆所著,被许多人(包括亿万富翁投资者沃伦·巴菲特)视为有史以来最伟大的投资书籍。

Fooled by Randomness: The Hidden Role of Chance in Life and in the Markets, Revised Edition(《随机的愚弄:机遇在生活和市场中的隐秘角色,修订版》)(Random House Trade Paperbacks,2005 年)由纳西姆·尼古拉斯·塔勒布(Nassim Nicholas Taleb)撰写,是“对我们在统计学上自欺欺人的历史及原因进行引人入胜的探讨。” 其中包括关于在金融分析中使用蒙特卡洛模拟的讨论。

The Black Swan: The Impact of the Highly Improbable, 2nd Edition(《黑天鹅:高概率事件的影响,第二版》)(Random House Trade Paperbacks,2010 年)由纳西姆·尼古拉斯·塔勒布(Nassim Nicholas Taleb)撰写,是一本“通过历史、经济学和人类脆弱性的愉快漫游”,并且其中还包括关于在金融中使用蒙特卡洛模拟的讨论。

你可以在 www.investopedia.com/terms/f/four-percent-rule.asp 找到 4% 规则的概述。

有关 4% 规则的可能例外情况,可以在 www.cnbc.com/2015/04/21/the-4-percent-rule-no-longer-applies-for-most-retirees.html 进行讨论。

你可以在以下网站找到历史金融数据:

挑战项目

通过完成这些挑战项目,成为注册金融分析师(CFA)^(1)。

一图胜千金

想象一下你是一个 CFA,您的潜在客户,一位富有的德州石油勘探者,无法理解你对他 1000 万美元投资组合的 MCS 结果。“该死,伙计!什么该死的装置竟然让我在一个案例中破产,在另一个案例中却变得值 8000 万?”

通过编辑 nest_egg_mcs.py 程序,使其运行单个 30 年的案例,使用历史区间来呈现糟糕和好的结果,例如从大萧条的开始到第二次世界大战结束的时期,但只运行极端案例。对于每个案例中的每一年,输出年份、回报率、通货膨胀率和结果。更好的是,编辑条形图显示,使用每 年的 结果而不是每 个案例 的结果,以便提供有说服力的视觉解释。

混合与匹配

编辑 nest_egg_mcs.py,以便用户可以生成他们自己的投资组合。使用我在本章开始时提供的标准普尔 500 指数、10 年期国库券和三个月期国库券的文本文件,并添加任何您喜欢的其他内容,如小盘股、国际股票,甚至黄金。只需记住每个文件或列表中的时间间隔应该相同。

让用户选择投资类型及其百分比。确保他们的输入总和为 100%。然后通过加权和累加每年的回报率创建一个混合列表。最后,在柱状图显示顶部展示投资类型和百分比。

真是走运!

编辑 nest_egg_mcs.py 以计算在 30 年退休期间遇到大萧条(1939–1949)或大衰退(2007–2009)的概率。您需要确定回报列表中与这些事件对应的索引号,然后计算它们在运行的案例数量中出现的次数。在 Shell 中显示结果。

全力以赴

若要以不同的方式查看结果,复制并编辑 nest_egg_mcs.py,以便柱状图显示所有结果按从小到大排序。

第十三章:模拟外星火山

image

快!说出太阳系中最具火山活动的天体!如果你认为是地球,那你就错了——它是木星的四颗伽利略卫星之一——木卫一(Io,“EYE-oh”)。

对木卫一火山活动的首次证据出现在 1979 年,当时旅行者 1 号飞越木星系统并拍摄了著名的照片。但这些壮观的照片并不令人感到意外。天体物理学家 Stan Peale 和两位合著者已经根据木卫一内部模型发布了这一结果。

计算机建模是理解自然和做出预测的强大工具。以下是一般的工作流程:

  1. 收集数据。

  2. 分析、解释并整合数据。

  3. 生成解释数据的数值方程。

  4. 构建一个最佳“拟合”数据的计算机模型。

  5. 使用模型进行预测并研究误差范围。

计算机建模的应用范围广泛,包括野生动物管理、天气预报、气候预测、碳氢化合物生产以及黑洞模拟等领域。

在本章中,你将使用一个名为pygame的 Python 包——通常用于创建游戏——来模拟木卫一的一个火山。你还将尝试不同类型的喷发物(喷发粒子),并将它们的模拟行为与木卫一巨大 Tvashtar 羽流的照片进行比较。

项目#21:木卫一的羽流

潮汐加热是木卫一火山活动的罪魁祸首。随着木卫一的椭圆轨道将其带过木星及其姐妹卫星的引力场,木卫一经历了潮汐拉力的变化。它的表面上下弯曲最多可达 100 米,导致其内部发生显著的摩擦加热和熔化。炽热的岩浆迁移到表面,形成巨大的熔岩湖,喷射出脱气的硫(S[2])和二氧化硫(SO[2]),喷射速度达到每秒 1 公里。由于木卫一的低重力和缺乏大气层,这些气体羽流可以达到数百公里的高度(参见图 13-1a 中的 Tvashtar 羽流)。

image

图 13-1:a) 木卫一,顶部是 330 公里的 Tvashtar 羽流,9 点钟位置是较短的 Prometheus 羽流;b) 木卫一的火山环沉积物(NASA 图片)

当气体和尘土向上喷射,然后四面八方下落时,形成了伞形的喷发羽流。这些表面沉积物形成了同心的红色、绿色、黑色和黄色环状结构。如果图 13-1b 是彩色的,它看起来有点像发霉的意大利辣香肠比萨。

pygame 的一片天地

pygame包是一套跨平台的 Python 模块,通常用于编程 2D 街机风格的视频游戏。它支持图形、动画、音效、音乐和多种输入设备,如键盘和鼠标。学习pygame不仅仅是学习编程的一种有趣方式。街机风格的游戏因智能手机和平板电脑的普及而重新流行起来,而移动游戏现在的收入几乎与主机和 PC 游戏的总和相当。

pygame包使用简单直接媒体库(SDL),这是一种应用程序编程接口(API)。API 是可重用的代码库,能够使图形处理变得相对简单,让你可以专注于游戏设计,同时使用像 Python 这样的高级语言。微软的DirectX API 用于为 Windows 平台创建游戏和多媒体应用程序。为了跨平台工作,有两个开源库——SDL,主要用于 2D 工作,以及OpenGL(开放图形库),用于 3D 应用程序。如前所述,你将使用 SDL,它正式支持 Windows、macOS、Linux、iOS 和 Android。

pygame包还使用面向对象编程(OOP)。如果你不熟悉 OOP 或需要复习,可以参考“面向对象编程简要介绍”在第 223 页的内容。此外,许多 Python 入门书籍通常会包括关于pygame的章节,且有专门的书籍讲解该包(关于一些例子,请参见第 281 页的“进一步阅读”)。

在继续之前,你需要将pygame安装到你的系统中。有关在你喜欢的平台上安装免费版本的说明,请访问pygame.org/wiki/GettingStarted#Pygame%20Installation

关于如何安装pygame的视频教程也可以在网上找到。为了确保视频适合你的情况,务必检查视频的日期、讨论的平台以及所使用的pygame和 Python 的版本。你可以在brysonpayne.com/2015/01/10/setting-up-pygame-on-a-mac/找到适用于安装了较旧版本 Python 的 Mac 用户的额外说明。

目标

使用pygame构建一个基于重力的 2D 模拟,展示木卫二(Io)的 Tvashtar 火山喷发。使用 NASA 的图像校准喷发物的尺寸。在喷发中使用多种粒子类型,追踪粒子的飞行轨迹,并让喷发自动运行,直到被停止。

策略

构建一个全面的、全物理的 Io 羽流模拟最好是通过超级计算机来完成。由于你可能没有超级计算机,并且目标是制作一个酷炫的 pygame 显示,你将通过逆向工程所需的参数来让 SO[2] 符合 Tvashtar 羽流。记住,作弊是人类赠送给自己的礼物;这就是我们与动物的不同——除了猎豹!

由于 Io 羽流的组成已经知道,你将根据 SO[2] 和硫气体(S[2])的原子质量来调整你的重力场,它们恰好具有相同的原子质量。当这些粒子的飞行路径与 NASA 照片中的 Tvashtar 羽流的尺寸相匹配时,你将根据新粒子与 SO[2] 之间的原子质量差异,调整其他喷射粒子的速度,以观察粒子类型如何影响羽流的尺寸。较轻的粒子将被喷射得更高,反之亦然。

使用游戏草图进行规划

我建议你在开始任何 pygame 项目时,先画出游戏应该是什么样的,行动将如何展开。即使是最简单的街机游戏也可能变得复杂,草图将帮助你管理这种复杂性。在典型游戏中,你必须考虑的事情有:玩家行为、计分、消息与指令、游戏实体及其交互(例如碰撞、音效和音乐)以及游戏结束条件。

绘制游戏草图——或者在这种情况下,是绘制模拟——最好是在白板上完成,可以是实际白板或数字白板。我为 Io 火山模拟器设计的布局如 图 13-2 所示。

image

图 13-2:Io 火山模拟器的游戏草图

图 13-2 中的草图包含了火山模拟器的指南和关键行为:

  • 没有直接的玩家交互。 你将通过编辑 Python 代码来控制模拟,而不是通过鼠标或键盘。

  • 背景将是 NASA 的羽流图像。 为了将模拟与 SO[2]/S[2] 粒子进行校准,你需要一个实际的 Tvashtar 羽流背景。

  • 发射点是可旋转的。 粒子应从羽流图像的中央基座喷出,并以一定角度范围内喷射,而不仅仅是直线上升。

  • 粒子是随机选择的。 程序会随机选择粒子的类型。每个粒子将有一个独特的颜色,用以与其他粒子区分。

  • 粒子的飞行路径应可见且持久。 每个粒子的飞行轨迹应作为一条线被记录下来,并在整个模拟过程中保持可见,且该线的颜色应与粒子的颜色相匹配。

  • 有色标图例列出了粒子类型。 程序应在屏幕的左上角显示一个粒子名称的图例。图例中的字体颜色应与粒子的颜色匹配,且图例应显示在粒子路径上方,以确保始终可见。

  • 粒子运动应在 SO[2] 粒子与木卫一表面相交的高度停止。 仿真已调校为 SO[2]的行为,因此下落的粒子应在 SO[2]羽流的适当位置停止。

  • 没有音效。 在太空中,没有人能听到你的尖叫声。

一旦完成你的图示,你可以开始从中挑选部分,并按逻辑顺序列出它们;这样就把计划分解成一系列可管理的步骤。例如,你需要找到并准备一个合适的背景图像,决定要模拟哪些粒子并查找它们的原子质量,定位发射点,校准 SO[2]行为以适应羽流图像,等等。你仍在编写伪代码,但游戏草图使得这个过程变得更加有趣!

规划粒子类

由于这个仿真是基于粒子的,因此有一个面向对象的Particle类作为多个粒子类型的蓝图是合乎逻辑的。该类应支持随机生成粒子类型,并且所有粒子共有的常量和其他属性可以作为类属性存储。这些属性是与方法处于同一级缩进的属性。Particle类还应包含方法,使类的实例能够被抛出、受重力影响、可见,并在移动超出仿真边界时销毁。

类中使用的属性和方法分别显示在表 13-1 和 13-2 中。类属性——即所有类实例共享的属性——以斜体显示;其他则为实例属性。

表 13-1: Particle类的属性(斜体 = 类属性)

Attributes 属性描述
gases_colors 可用粒子类型及其颜色的字典
VENT_LOCATION_XY Tvashtar 火山口的 x 和 y 位置(图像中的位置)
IO_SURFACE_Y 木卫一表面 y 值,在 SO[2]羽流边界的 y 值
VELOCITY_SO2 SO[2]粒子的速度(每帧像素数)
GRAVITY 重力加速度(每帧像素数)
vel_scalar SO[2]/粒子原子量比率的字典
screen 游戏屏幕
background Tvashtar 羽流的 NASA 图像
image 表示粒子的pygame矩形表面
rect 用于获取表面尺寸的矩形对象
gas 单个粒子的类型(SO[2]、CO[2]等)
color 单个粒子类型的颜色
vel 粒子的速度,相对于 SO[2]速度
x 粒子的 x 位置
y 粒子的 y 位置
dx 粒子的 delta-x
dy 粒子的 delta-y

表 13-2: Particle类的方法

Method 方法描述
__init__() 初始化并设置随机选择的粒子类型的参数
vector() 随机选择喷射方向并计算运动向量(dx 和 dy)
update() 调整粒子轨迹以适应重力,绘制粒子后方的轨迹,并销毁超出模拟边界的粒子

我将在下一节中更详细地解释这些属性和方法。

代码

tvashtar.py 代码将生成基于 pygame 的 Io 火山羽流模拟。你还需要背景图像 tvashtar_plume.gif。从 www.nostarch.com/impracticalpython/ 下载这两个文件,并将它们保存在同一个文件夹中。

导入模块、初始化 pygame 和定义颜色

从一些设置步骤开始,例如选择颜色,正如在清单 13-1 中所示。

tvashtar.py, 第一部分

➊ import sys
   import math
   import random
   import pygame as pg

➋ pg.init()  # initialize pygame

➌ # define color table
   BLACK = (0, 0, 0)
   WHITE = (255, 255, 255)
   LT_GRAY = (180, 180, 180)
   GRAY = (120, 120, 120)
   DK_GRAY = (80, 80, 80)

清单 13-1:导入模块,初始化 pygame, 并定义颜色表

从一些常见的 import 开始,并为 pygame ➊ 添加一个。接下来,调用 pygame.init() 函数。这将初始化 pygame 模块并启动所有底层部分,让它能够使用声音、检查键盘输入、运行图形等等 ➋。请注意,pygame 可以从多个地方进行初始化,比如在 main() 函数中的第一行:

def main():
    pg.init()

或者在程序结束时,当 main() 函数以独立模式调用时:

if __name__ == "__main__":
    pg.init()
    main()

停下来并使用 RGB 颜色模型 ➌ 分配一些颜色变量。该模型混合了红色、绿色和蓝色,其中每种颜色的值范围从 0 到 255。如果你在网上搜索“RGB 颜色代码”,可以找到数百万种颜色的数值代码。但由于你将使用的 NASA 图像是灰度的,所以只需使用黑色、白色和灰色阴影即可。现在定义这个表格将使你在以后 pygame 需要定义颜色时,只需输入一个名字。

定义粒子类

清单 13-2 定义了 Particle 类及其初始化方法。你将使用这些来实例化一个粒子对象。粒子的关键属性,如类型、速度、颜色等,都是通过初始化方法来设定的。

tvashtar.py, 第二部分

➊ class Particle(pg.sprite.Sprite):
       """Builds ejecta particles for volcano simulation."""

    ➋ gases_colors = {'SO2': LT_GRAY, 'CO2': GRAY, 'H2S': DK_GRAY, 'H2O': WHITE}

    ➌ VENT_LOCATION_XY = (320, 300)
       IO_SURFACE_Y = 308
       GRAVITY = 0.5  # pixels-per-frame; added to dy each game loop
       VELOCITY_SO2 = 8  # pixels-per-frame

       # scalars (SO2 atomic weight/particle atomic weight) used for velocity
    ➍ vel_scalar = {'SO2': 1, 'CO2': 1.45, 'H2S': 1.9, 'H2O': 3.6}

    ➎ def __init__(self, screen, background):
           super().__init__()
           self.screen = screen
           self.background = background
        ➏ self.image = pg.Surface((4, 4))
           self.rect = self.image.get_rect()
        ➐ self.gas = random.choice(list(Particle.gases_colors.keys()))
           self.color = Particle.gases_colors[self.gas]
        ➑ self.vel = Particle.VELOCITY_SO2 * Particle.vel_scalar[self.gas]
        ➒ self.x, self.y = Particle.VENT_LOCATION_XY
        ➓ self.vector()

清单 13-2:定义了 Particle 类和 Particle 初始化方法

定义一个名为 Particle 的类,用来表示可能形成火山羽流的 任何 气体分子 ➊。这个类的 祖先(如括号中所示)将是 Sprite 类。这意味着 Particle 类是从一个名为 Sprite 的内建 pygame 类型派生的。精灵(Sprite)只是代表离散游戏对象(如导弹或小行星)的 2D 位图。你通过将 pg.sprite.Sprite 传递给你的 Particle 类,就可以 继承 Sprite 类,即将其属性和方法添加到你的新类中,就像你给函数传递参数一样。

将所有粒子共有的属性作为类属性进行分配。第一个是一个字典,将粒子类型映射到颜色,以便在模拟过程中区分不同的粒子 ➋。这些颜色将用于粒子、粒子的路径以及图例中的名称。

现在,分配四个常量,VENT_LOCATION_XYIO_SURFACE_YGRAVITYVELOCITY_SO2 ➌。第一个常量是图像中 Tvashtar 火山口的 x 和 y 坐标,它将代表所有粒子的“发射点”(见图 13-3)。我最初猜测了这些值,然后在模拟运行时进行了微调。

image

图 13-3:带有粒子发射点注释的模拟背景图

第二个常量是图像中与 SO[2]烟羽外缘相交的 Io 表面最高点的 y 值(见图 13-2)。你将在这个 y 值处停止所有下落的粒子,因此视图将优化为 SO[2]。

第三个常量表示重力加速度,地球上的值为 9.86 m/s²,Io 上的值为 1.796 m/s²。但你在这里处理的是像素和帧,而非现实世界的单位,因此你需要通过实验来找到一个在游戏/模拟的尺度下看起来合适的值。我选择的0.5是随意的,但在一定程度上受到街机游戏中有效参数的指导。

第四个常量是 SO[2]粒子被喷射时的速度,以像素/帧为单位。记住,烟羽主要由 SO[2]组成,因此你希望使用能使 SO[2]粒子“适应”Tvashtar 烟羽图像的参数,然后调整其他粒子的速度相对于SO[2]。GRAVITYVELOCITY_SO2的值并不唯一。如果我选择了更大的GRAVITY值,我就需要增加VELOCITY_SO2,以便 SO[2]粒子仍然能“填满”NASA 图像中的烟羽区域。

接下来,为粒子速度➍构建一个标量字典。对于每个粒子,将 SO[2]的原子质量(64)除以该粒子的原子质量,即可得到该粒子的标量。由于 SO[2]是参考粒子,其标量为 1。之后,为了获得非 SO[2]粒子的速度,你需要将VELOCITY_SO2常量乘以标量。如你所见,其他所有粒子的质量都比 SO[2]轻,因此会产生较大的烟羽。

为粒子对象定义一个构造方法 ➎。你需要一个self参数以及用于绘制和检查模拟边界的screen,还需要一个background,它将是 Tvashtar 羽流的图像。你将在main()函数中稍后分配screenbackground,该函数定义在程序的末尾。请注意,虽然在本书中为了简洁我使用了一行文档字符串,但你应该在类文档字符串中包括这些类型的参数。有关类文档字符串的更多指南,请参见 www.python.org/dev/peps/pep-0257/

__init__()方法中,立即通过super调用内置Sprite类的初始化方法。这将初始化精灵,并建立它所需的rectimage属性。使用super时,你不需要显式引用基类(Sprite)。有关super的更多信息,请访问文档 docs.python.org/3/library/functions.html#super

接下来,让粒子(self)知道它将使用screenbackground变量,将它们分配给属性。

图像和图形由pygame放置在一个矩形表面上。事实上,Surface对象是pygame的核心;甚至screen属性也是Surface的一个实例。将粒子图像分配给Surface对象,并使其成为一个边长为 4 像素的正方形 ➏。

接下来,你需要为图像表面获取一个rect对象。它基本上是一个与Surface对象关联的矩形,pygame需要它来确定Surface对象的尺寸和位置。

通过从gases_colors字典中的键中随机选择,选择一个粒子(gas)类型 ➐。请注意,你需要将其转换为列表才能进行选择。由于有可能在__init__()方法中为实例属性命名为gases_colors,请包括类名——而不是self——以确保引用的是属性。

一旦你有了类型,就可以将其用作之前构建的字典中的键,来访问诸如颜色和标量之类的内容。从为所选粒子获取正确的颜色开始,然后获取其vel_scalar值,并利用它来确定粒子的速度 ➑。

粒子对象将在火山口处实例化,因此通过解包VENT_LOCATION_XY元组来获取其初始的 x 和 y 位置 ➒。最后调用vector()方法,它将计算粒子的运动向量 ➓。

喷射粒子

列表 13-3 定义了vector()方法,它决定粒子的发射方向,并计算其初始的 delta-x 和 delta-y 向量分量。

tvashtar.py, 第三部分

    ➊ def vector(self):
           """Calculate particle vector at launch."""
        ➋ orient = random.uniform(60, 120)  # 90 is vertical
        ➌ radians = math.radians(orient)
        ➍ self.dx = self.vel * math.cos(radians)
           self.dy = -self.vel * math.sin(radians)

列表 13-3:定义了 vector() 方法,属于 Particle

vector() 方法 ➊ 用于计算粒子的运动矢量。首先选择一个粒子的发射方向,并将其赋值给 orient 变量 ➋。由于爆炸性的火山喷发物质是沿多个方向喷射,而不是垂直向上,因此需要随机选择一个方向,使用的范围是从 90 度左右 30 度的区间,其中 90 度代表垂直发射方向。

orient 变量的范围是通过反复试验确定的。这个参数,结合 VELOCITY_SO2GRAVITY 常数,代表了可以调整的“旋钮”,用来校准 SO[2] 粒子与喷发柱图像的行为。调整这些常数,直到粒子的最大高度与喷发柱的顶点对齐后,你可以进一步调整角度范围,使 SO[2] 粒子达到喷发柱的侧向极限(但不超过该范围)(参见 图 13-4)。

image

图 13-4:将 orient 变量校准到 Tvashtar 喷发柱

math 模块使用 弧度 而非度数,因此需要将 orient 转换为弧度 ➌。弧度是标准的角度单位,当半径围绕圆圈一圈时所形成的角度(参见 图 13-5 左侧)。一个弧度稍小于 57.3 度。右侧的 图 13-5 是弧度与度数的对比,列出了常见的角度。要将度数转换为弧度,你可以将度数乘以π再除以 180——像个傻瓜一样——或者直接使用 math 模块!

image

图 13-5:弧度的定义(左)及常见角度的弧度与度数(右)

pygame 中,物体是按 x 和 y 增量移动的。粒子的方向和速度用来得到它的 delta-x (dx)delta-y (dy) 矢量分量。这些分量表示粒子初始位置与完成单次游戏循环后位置之间的差异。

你可以使用三角函数计算矢量分量。有关有用的三角函数公式,请参见 图 13-6。

image

图 13-6:游戏中常用的三角函数公式

对于角度 θ,使用 orient 变量。self.vel 属性等价于 r。知道这两个分量后,可以使用三角函数公式来推导 self.dxself.dy ➍。推导 self.dx 时,将 self.vel 乘以 orient 的余弦值,推导 self.dy 时,将 self.vel 乘以 orient 的正弦值。需要注意的是,self.dy 必须为负,因为粒子是向上喷射的,而在 pygame 中,y 值是 向下 增加的。

更新粒子并处理边界条件

清单 13-4 通过定义一个更新粒子的方法,完成了Particle类,该方法使粒子在屏幕上移动。这包括应用重力、绘制线条追踪粒子的路径,以及当粒子移出屏幕或下沉至 Io 表面时“杀死”粒子。

tvashtar.py, 第四部分

    ➊ def update(self):
           """Apply gravity, draw path, and handle boundary conditions."""
        ➋ self.dy += Particle.GRAVITY
        ➌ pg.draw.line(self.background, self.color,(self.x, self.y),
                          (self.x + self.dx, self.y + self.dy))
        ➍ self.x += self.dx
           self.y += self.dy

        ➎ if self.x < 0 or self.x > self.screen.get_width():
            ➏ self.kill()
        ➐ if self.y < 0 or self.y > Particle.IO_SURFACE_Y:
               self.kill()

清单 13-4:定义了 update()方法并完成了 Particle 类

定义update()方法,方法接收self作为参数➊。在每个游戏循环中,通过将GRAVITY类属性添加到self.dy来应用重力➋。重力是一个只在垂直方向起作用的力向量,因此只有self.dy受到影响。

为了在粒子后面绘制路径,使用pygamedraw.line()方法,该方法将 Io 的背景图像、粒子的颜色以及粒子之前和当前的位置坐标作为参数➌。要获取当前位置,你需要将self.dxself.dy加到self.xself.y上。

接下来,像在draw.line()方法中那样,通过将self.dxself.dy加到self.xself.y上,更新粒子的self.xself.y属性➍。

现在,检查粒子是否已经越过屏幕的左边或右边界➎。对于左侧,使用self.x等于零,对于右侧,获取screen属性的宽度。如果粒子已经越过屏幕的任一边缘,则使用内置的kill()方法将其从包含它的所有组中移除➏。正如你稍后会看到的,pygame使用容器——称为——来管理精灵,而将精灵从组中移除会使其不再参与游戏。

对 y 方向重复此过程➐,但对于最大值,使用Particle类的IO_SURFACE_Y常量,它将使粒子停在接近 Io 表面的位置,就像 SO[2]粒子停的位置(参见图 13-2 和 13-4)。

定义 main()函数

清单 13-5 定义了main()函数的第一部分,该部分设置了游戏屏幕、窗口标题、图例、精灵组和游戏时钟。

tvashtar.py, 第五部分

def main():
    """Set up and run game screen and loop."""
 ➊ screen = pg.display.set_mode((639, 360))
 ➋ pg.display.set_caption('Io Volcano Simulator')
 ➌ background = pg.image.load('tvashtar_plume.gif')

    # Set up color-coded legend
 ➍ legend_font = pg.font.SysFont('None', 24)
 ➎ water_label = legend_font.render('--- H2O', True, WHITE, BLACK)
    h2s_label = legend_font.render('--- H2S', True, DK_GRAY, BLACK)
    co2_label = legend_font.render('--- CO2', True, GRAY, BLACK)
    so2_label = legend_font.render('--- SO2/S2', True, LT_GRAY, BLACK)

 ➏ particles = pg.sprite.Group()

 ➐ clock = pg.time.Clock()

清单 13-5:定义了 main()函数的第一部分

第一步是使用pygamedisplay.set_mode()方法分配screen变量➊。参数是像素维度;在这种情况下,你使用稍微比 NASA 图像尺寸小的值来确保良好的适配。请注意,尺寸必须以元组形式提供,因此需要包含两组括号。

接下来,使用pygamedisplay.set_caption()方法➋为你的游戏窗口命名,然后将background变量分配为 Tvashtar 喷流的 NASA 照片➌。使用pygameimage.load()方法从图像创建一个新的Surface对象。pygame包支持多种图像格式,包括 PNG、JPG 和 GIF。返回的Surface将继承图像文件的颜色和透明度信息。由于你在这里导入的是灰度图像,所以你的颜色选择将受到限制。

现在,添加一些代码以构建将在屏幕左上方显示的图例。

命名一个legend_font变量,并使用pygamefont.SysFont()方法选择大小为 24 的None字体 ➍。你将在渲染文本时使用这个。pygame包的font模块让你将新的字体集(称为 TrueType 字体)渲染到一个新的Surface对象上。如果你不想指定字体,pygame自带了一个内置的默认字体,你可以通过传递None作为字体名称来访问它。

按照重量顺序发布粒子名称,最轻的放在最上面。要创建标签,调用之前创建的legend_font对象的render()方法来生成一个新的 surface 对象 ➎。传递一些文本,然后传递True(以开启抗锯齿,使文本看起来更平滑),接着是所描述粒子的颜色。最后一个参数BLACK是可选的,它将标签的背景颜色设置为黑色,以便文本在屏幕上绘制的所有粒子路径上方清晰可见。为剩下的三个粒子重复这个过程,并将S2添加到so2_label中,因为这两种气体具有相同的原子质量,在模拟中将表现相同。

现在,启动一个名为particles的精灵组 ➏。由于游戏通常有多个精灵在屏幕上移动,pygame使用一个容器——精灵组——来管理它们。事实上,你必须将精灵放入一个组,否则它们不会起作用。

完成这一部分,创建一个Clock对象来跟踪和控制模拟的帧率 ➐。pygame的“时钟”控制游戏运行的速度,基于每秒显示的帧数(fps)。你将在下一部分设置这个值。

完成 main()函数

Listing 13-6 通过设置模拟的运行速度(以每秒帧数为单位)并启动实际运行模拟的while循环来完成main()函数。它还处理事件,当用户通过鼠标、操纵杆或键盘控制程序时,这些事件会发生。由于这是一个模拟程序而非真正的游戏,用户控制仅限于关闭窗口。此列表以全局作用域结束,包含作为模块或独立模式运行程序的标准代码。

tvashtar.py, 第六部分

    ➊ while True:
        ➋ clock.tick(25)
        ➌ particles.add(Particle(screen, background))
        ➍ for event in pg.event.get():
               if event.type == pg.QUIT:
                   pg.quit()
                   sys.exit()

        ➎ screen.blit(background, (0, 0))
           screen.blit(water_label, (40, 20))
           screen.blit(h2s_label, (40, 40))
           screen.blit(co2_label, (40, 60))
           screen.blit(so2_label, (40, 80))

        ➏ particles.update()
           particles.draw(screen)

        ➐ pg.display.flip()

➑ if __name__ == "__main__":
       main()

Listing 13-6:启动游戏时钟和循环,并处理 main()函数中的事件

启动一个while循环来运行模拟 ➊。然后使用clock.tick()方法设置模拟的速度限制 ➋。传入25,这将设置最大帧率为每秒 25 帧。如果你想要更有活力的火山,可以增加此值。

现在是时候让这场演出的主角登场了。使用Particle类实例化一个粒子,传入screenbackground作为参数,并将新粒子添加到particles精灵组中 ➌。每一帧,都会随机生成一个新粒子并从火山口发射,产生一阵令人愉悦的粒子喷雾(见图 13-7)。

image

图 13-7:模拟启动,随机粒子以每秒 25 帧的速度生成

启动一个for循环来处理事件 ➍。所有在当前帧中发生的事件都被pygame记录并保存在事件缓冲区中。它的event.get()方法创建了一个包含所有这些事件的列表,便于你逐一评估它们。如果发生QUIT事件(即用户关闭了游戏窗口),则调用pygamequit()方法和系统的exit()方法来结束模拟。

为了渲染游戏对象并更新视觉显示,pygame使用了一种叫做blitting的过程。Blit块传输的缩写,指的是将一个矩形Surface对象中的像素复制到另一个矩形对象中。通过将背景绘制到屏幕上,你可以用 Io 图像覆盖整个屏幕。通过 blitting,你可以将同一张图像多次绘制到屏幕上的不同位置。这个过程可能较慢,因此游戏开发者使用巧妙的技术来解决这一缺陷,比如只在当前更新的区域周围进行 blitting,而不是每次游戏循环都绘制整个屏幕。

要将背景绘制到屏幕上,调用屏幕的blit()方法,并传入源和目标的相关参数 ➎。在第一个例子中,background变量是源,而目标是背景的左上角坐标。由于背景将覆盖整个屏幕,使用屏幕的原点坐标(0, 0)。对图例标签重复此操作,将它们放置在屏幕的左上角。

接下来,调用particles组的update()方法 ➏。此方法不会更新屏幕,而是让精灵运行各自的self.update()方法。之后,使用draw()方法根据每个精灵的rect属性将精灵绘制到屏幕上。该方法需要一个绘图表面,因此传入screen

draw()方法负责绘制精灵,所以现在你只需要使用flip()方法更新实际的游戏图形 ➐。翻转是一种双缓冲技术,在这种技术中,你将所有内容从screen对象复制到实际的显示屏上。翻转通过在后台矩形上完成工作,避免了显示图形这一固有的缓慢过程,从而防止屏幕闪烁,然后使用blit()方法的一个版本将图形复制到最终显示。

列表在main()函数外结束,代码允许程序以模块或独立模式运行 ➑。

运行模拟

图 13-8 展示了运行模拟器约一分钟的结果。水蒸气羽流延伸至窗口顶部。第二高的羽流由硫化氢形成,接着是二氧化碳,然后是二氧化硫/硫(S[2])气体,这与设计上完美匹配 Tvashtar 羽流。

image

图 13-8:运行 tvashtar.py 一分钟的结果

要仅使用 SO[2]运行模拟器,请进入Particle类的__init__方法,修改选择gascolor实例属性的代码行:

        self.gas = 'SO2'
        self.color = random.choice(list(Particle.gases_colors.values()))

通过随机选择颜色,你可以在所有可能的self.orient角度用尽后,保持羽流的运动感。如果你想加速或减慢喷发速度,可以进入main()函数,尝试调整clock.tick()方法的每秒帧数参数。

在现实生活中,羽流物质的成分通过光谱学推测,光谱学是一种分析光如何与物质相互作用的测量技术。它包括可见光和非可见光波长,这些波长会被吸收、发射或散射。通过“喷发物的光谱”以及表面上的颜色,提供了硫丰富羽流的关键证据。

总结

在本章中,你学习了如何使用pygame包来模拟重力并构建外星火山的动画。在下一章中,你将使用pygame构建一个真正的街机游戏,涉及玩家互动和胜负条件。

进一步阅读

游戏编程:L 线,学习的快捷通道(Wiley, 2007)由 Andy Harris 编写,是一本极其有用且全面的 570 页pygame入门书。

更多 Python 编程从零开始(Cengage Learning Course Technology, 2012)由 Jonathon Harbour 编写,是《Python 编程从零开始》的续集,采用了一个(py)游戏为主的教学方法。

用 Python 发明自己的计算机游戏,第四版(No Starch Press, 2016)由 Al Sweigart 编写,是一本适合初学者的 Python 和游戏设计入门书籍。

pygame 的在线“新手指南”可以在 www.pygame.org/docs/tut/newbieguide.html 找到,“备忘单”可以在 www.cogsci.rpi.edu/~destem/gamedev/pygame.pdf 中找到。

《木卫一 Pele 羽状云中的气体和尘土的三维模拟》,作者 William J. McDoniel 等,记录了使用直接蒙特卡罗模拟和德克萨斯大学德克萨斯先进计算中心的超级计算机模拟木卫一 Pele 羽状云的过程。该文章可在 cfpl.ae.utexas.edu/wp-content/uploads/2016/01/McDoniel_PeleDust.pdf 获取。

实践项目:走得更远

你是亨利国王的弓箭手,参加了阿金库尔战役。法国人正在冲锋,你想尽可能远地击中他们。你应该把长弓拿成什么角度?

如果你上过物理课,你大概知道答案是 45 度。但是你能相信那个脖子细长的物理学家吗?最好快速运行一个计算机模拟来验证一下。复制并编辑 tvashtar.py 代码,随机发射粒子在 25、35、45、55 和 65 度。将 self.color 设置为 WHITE 用于 45 度,将所有其他角度设置为 GRAY(见 图 13-9)。

image

图 13-9:修改后的木卫一火山模拟器,发射角度为 25、35、45、55 和 65 度

你可以在附录中找到一个解决方案,practice_45.py,或者从 www.nostarch.com/impracticalpython/ 下载。将它保存在与 tvashtar_plume.gif 文件相同的文件夹中。

挑战项目

继续进行这些挑战项目的实验。不提供解决方案。

冲击 canopy

被认为,木卫一(Io)巨大的羽状云的可见性是通过气体凝结成尘土,在 冲击 canopy 中增强的,即气体粒子达到最高点并开始落回表面的位置。使用 self.dy 属性编辑路径颜色,在 tvashtar.py 程序的副本中,羽状云的顶点路径应比下方的路径更亮(见 图 13-10)。像所有挑战项目一样,提供的没有解决方案。

image

图 13-10:使用更亮的路径颜色高亮显示冲击 canopy

源头

复制并编辑 tvashtar.py 代码,使得只模拟 SO[2],并用没有拖尾路径的小白色圆圈表示粒子(见 图 13-11)。

image

图 13-11:SO[2] 模拟的屏幕截图,圆圈表示单个粒子

带有子弹

如果你在没有大气的星球上垂直发射一颗子弹,子弹落地时的速度会与它离开枪口时相同吗?这个问题让许多人感到困惑,但你可以使用 Python 来解答。复制并编辑tvashtar.py代码,使其发射一个 90 度朝向的 SO[2]粒子。打印粒子的self.y属性以及发射点坐标(y = 300)处self.dy的绝对值。比较这个点的起始速度和结束速度,看看它们是否相同或相似。

注意

电视节目《破坏神话》第 50 集探讨了一个神话,即子弹被射向空中后,当它们最终落回地面时,仍然保持致命的威力。他们发现,垂直发射的子弹在地球上由于风阻会翻滚并减速。如果发射角度稍微偏离垂直,子弹则会保持旋转和弹道轨迹,并以致命的速度返回地球。这是唯一一个获得三项评级(破除、可信、证实)的神话!*

第十四章:使用火星轨道探测器进行火星映射**

image

火星轨道探测器已经成功进入火星轨道,但情况并不理想。轨道是高度椭圆形的,而项目的映射目标需要低轨道的圆形轨道。幸运的是,探测器上有足够的推进剂来进行修正,前提是控制中心的技术人员有足够的耐心和技能来完成任务!

在本章中,你将根据这个场景设计并构建一个游戏。你将再次使用pygame(有关pygame的概述,请参见第 267 页的“A Slice of pygame”),通过让游戏足够真实,帮助推进 STEM(科学、技术、工程和数学)教育,教会玩家轨道力学的基本知识。

注意

尽管它们共享相同的名称,游戏中的火星轨道探测器与 2014 年由印度空间研究组织(ISRO)发射的火星轨道探测任务并无直接关系。游戏中的探测器是模仿 1996 年由 NASA 发射的火星全球探测器

游戏中的天体动力学

因为你希望游戏尽可能真实,所以需要快速回顾一下与太空飞行相关的一些基本科学知识。这部分内容简短、精炼,并专门针对游戏开发和玩法。

万有引力定律

重力理论认为,像恒星和行星这样的巨大物体会扭曲它们周围的时空,就像一个沉重的保龄球放在床垫上一样,保龄球会造成一个突然且剧烈的凹陷,凹陷在球的周围,但很快就会平缓开来。这种行为通过艾萨克·牛顿的万有引力定律在数学上得以描述:

image

其中 F 是重力作用力,m[1] 是物体 1 的质量,m[2] 是物体 2 的质量,d 是物体之间的距离,G 是万有引力常数(6.674 × 10^(–11) N · m² · kg^(–2))。

两个物体根据它们的质量乘积除以它们之间距离的平方相互吸引。因此,当物体靠得很近时,重力会更强,就像保龄球下方床垫的深凹形状一样。举例来说,一个 220 磅(100 公斤)的人,在珠穆朗玛峰顶上的体重会比在海平面上轻超过半磅,因为他离地球中心要少 8,848 米。(这假设地球的质量为 5.98 × 10²⁴公斤,海平面距离地球中心 6.37 × 10⁶米。)

今天,我们通常将重力视为一种——就像保龄球类比中的床垫——而不是像牛顿所定义的引力点。这个场仍然用牛顿的定律来定义,结果是加速度,通常以米/秒²表示。

根据牛顿的第二定律,力等于质量 × 加速度。你可以通过将引力方程重新写成如下形式来计算物体 1 (m[1]) 对物体 2 (m[2]) 施加的力:

image

其中 a = 加速度,G 是引力常数,m[1] 是物体的质量,d 是物体之间的距离。力的方向是从物体 2 指向物体 1 的质量中心 (m[1])。

极小物体对大物体的引力通常可以忽略不计。例如,一个质量为 1000 千克的卫星对火星施加的力大约是火星对该卫星施加的力的 1.6 × 10^(–21) 倍!因此,在你的模拟中可以安全地忽略卫星的质量。

注意

作为本项目的简化,距离是从物体的中心点计算的。在现实生活中,围绕行星运行的卫星会因为行星形状、地形、地壳密度等的变化,经历引力加速度的微小变化。根据《大英百科全书》,这些变化会导致地球表面引力加速度的变化约为 0.5%。

开普勒行星运动定律

1609 年,天文学家约翰·开普勒发现行星轨道是椭圆形的,从而使他能够解释和预测行星的运动。他还发现,连接太阳和绕行行星之间的线段在相等的时间间隔内扫过相等的面积。这个概念被称为开普勒行星运动第二定律,见于 图 14-1,图中展示了行星在其轨道上不同点的位置。

image

图 14-1:开普勒行星运动第二定律:行星靠近太阳时,轨道速度增加。

这一规律适用于所有天体,这意味着一个绕行物体在靠近它所围绕的天体时会加速,而在远离时会减速。

轨道力学

围绕行星运行本质上是永远自由下落。你正朝着行星引力井的核心下落——这个核心位于行星的实际中心——但是你的切向速度足够快,使得你不断“错过”行星(见 图 14-2)。只要你平衡你的动量和引力的作用,轨道将永远持续下去。

image

图 14-2:当宇宙飞船的速度使其保持“自由下落”绕天体运动时,就实现了轨道。

在太空的真空中围绕行星运行时,可能会发生一些违反直觉的现象。由于没有摩擦或风阻,宇宙飞船可能以意想不到的方式运动。

向后飞行

如果你曾经看过一集《星际迷航》,你可能注意到,绕行的企业号似乎像一辆汽车绕轨道行驶一样绕着行星行驶。这当然是可以做到的——而且看起来确实很酷——但它需要消耗宝贵的燃料。如果没有必要持续将航天器的某个部分指向行星,那么航天器的机头将始终指向轨道中的相同方向。因此,在每次轨道运动中,它会有一些时刻看起来像是飞行反向(参见图 14-3)。

image

图 14-3:航天器在轨道中保持相同的姿态,除非被迫改变。

你可以将此归咎于牛顿和他的惯性定律,该定律指出,静止的物体保持静止,运动中的物体以相同的速度和相同的方向保持运动,除非受到不平衡力的作用。

提升和降低轨道

刹车在太空中不起作用,没有摩擦力,惯性非常顽固。为了降低航天器的轨道,你必须启动推进器来减速,使其进入行星的引力井。为了实现这一点,你必须逆行航天器,使它的机头朝向与当前速度矢量相反——这是一种说法,即你必须尾部先行。当然,这假设主推进器位于航天器的后部。相反,如果你想提高轨道,则需要顺行航天器,使其机头朝着你前进的方向。这两个概念在图 14-4 中有所展示。

image

图 14-4:顺行和逆行的定义是根据航天器的机头相对于其绕行天体的旅行方向来确定的。

采取内轨道

如果你在轨道上追逐另一艘航天器,你是加速还是减速去追上它?根据开普勒的第二定律,你需要减速。这将降低你的轨道,从而导致更快的轨道速度。就像赛马一样,你要选择内轨道。

在图 14-5 的左侧,两个航天飞机并排在几乎相同的轨道上,以相同的速度行驶。

image

图 14-5:轨道悖论:减速以加速!

靠近行星的航天器旋转 180 度,进行逆行推进以减慢其即时速度。外侧的航天器进行顺行推进以增加其即时速度。它们同时停止推进,内侧航天器下降到较低的轨道,而外侧航天器转移到较高的轨道。大约一个小时后,由于离行星较近,内侧航天器的速度会变得更快,并且顺利追上并超过外侧航天器。

使椭圆轨道圆化

你可以通过在远地点近地点施加发动机脉冲来将高椭圆轨道转为圆形轨道,具体取决于情况。远地点(如果物体绕地球轨道运行,则称为远地点)是椭圆轨道中的最高点——物体离其绕行天体最远的点(见图 14-6)。近地点(如果物体绕地球轨道运行,则称为近地点)是轨道中的最低点。

image

图 14-6:椭圆轨道中远地点和近地点的位置

为了提高近地点,航天器在远地点施加顺行推力(见图 14-7 左侧)。为了在环形化轨道的同时降低轨道,航天器必须在近地点施加逆行推力(见图 14-7 右侧)。

该机动的一个有些反直觉的部分是,初始轨道——即本应存在的轨道——和最终的实际轨道将在施加发动机脉冲的点重合。

image

图 14-7:在远地点环形化并提高轨道(左侧),在近地点环形化并降低轨道(右侧)

使用霍曼转移提高和降低轨道

霍曼转移轨道使用椭圆轨道在同一平面内的两个圆形轨道之间进行切换(见图 14-8)。轨道可以被提升或降低。该机动相对较慢,但它消耗的燃料最少。

要改变一个轨道,使其具有不同的近地点远地点,航天器需要进行两次发动机脉冲。一种脉冲将航天器推入转移轨道,另一种脉冲则将其推入最终目的地轨道。当提高轨道时,航天器将按运动方向施加速度变化,而当降低轨道时,它将施加与运动方向相反的速度变化。这些速度变化必须发生在轨道的对侧,如图 14-8 所示。如果没有第二次推力,轨道将仍然在第一次推力的点相交,如图 14-7 右侧所示。

image

图 14-8:使用霍曼转移技术转移到较低的圆形轨道

使用单切线烧蚀提高和降低轨道

单切线烧蚀技术比霍曼转移更快地将航天器从一个轨道转移到另一个轨道,但效率较低。烧蚀是推力或脉冲的另一种说法。与霍曼转移一样,轨道可以被提升或降低。

这个机动需要两次发动机冲击,第一次是与轨道切线的,第二次是非切线的(见图 14-9)。如果初始轨道是圆形的,如图所示,那么轨道上的所有点都代表远地点和近地点,航天器可以在任何时候施加第一次燃烧。

image

图 14-9:通过一次切线燃烧转移到更高的圆形轨道

就像霍曼转移一样,顺行燃烧会抬高轨道,而逆行燃烧则会降低轨道。如果轨道是椭圆形的,第一次燃烧将在远地点进行顺行燃烧以抬高轨道,或者在近地点进行逆行燃烧以降低轨道。

执行螺旋轨道转移

螺旋转移利用连续的低推力燃烧来改变轨道的大小。在游戏中,你可以通过使用逆行或顺行的燃烧来模拟这一过程,这些燃烧通常很短且间隔均匀,如图 14-10 所示。

image

图 14-10:通过在规则间隔内进行短时间逆行燃烧执行螺旋轨道

为了降低轨道,所有燃烧必须是逆行的;为了抬高轨道,航天器使用顺行燃烧。

执行同步轨道

同步轨道中,航天器绕行行星一圈所需的时间与行星绕其轴自转一圈的时间相同。如果同步轨道平行于赤道且没有轨道倾斜,则它是静止轨道;对于在被环绕体上的观察者而言,卫星在天空中的固定位置上看起来是静止的。通信卫星通常使用地球静止轨道,其高度为 22,236 英里,环绕地球。类似的轨道在火星上叫做气静止轨道,在月球上叫做月静止轨道

项目 #22:火星轨道器游戏

在现实生活中,一系列方程被用来精确执行轨道机动。在游戏中,你将依靠直觉、耐心和反应能力!你还需要在一定程度上依赖仪器飞行,主要使用航天器的高度显示和轨道圆形度的测量。

目标

使用pygame构建一款教学游戏,教授轨道力学的基础知识。游戏的目标是将卫星轻推到一个圆形的映射轨道中,而不至于耗尽燃料或在大气层中燃烧殆尽。

策略

从游戏草图设计阶段开始,正如你在第十三章中所做的那样。这张草图应当捕捉游戏的所有关键点,比如游戏的外观、声音、运动方式以及如何与玩家进行互动(见图 14-11)。

image

图 14-11:火星轨道器游戏的主要玩法草图

图 14-11 中的草图描述了主要的游戏玩法。你需要一个单独的草图来描述胜负条件。对于主要的游戏玩法,关键点如下:

  • 视角是任务控制中心。 游戏画面应类似于任务控制中心的监视器,玩家可以通过它操作迷失的航天探测器。

  • 火星位于正中央。 每个人都喜欢红色星球,所以它将占据漆黑屏幕的中央位置。

  • 火星是动态的。 火星的地球仪将缓慢地围绕其轴旋转并投下阴影。当卫星经过这个阴影时,亮度会显著降低。

  • 卫星的初始轨道是随机选择的。 卫星在启动时会以随机但受限的方向和速度出现。偶尔,这可能会导致游戏瞬间失败。这仍然比现实任务要好,现实任务有 47%的失败率!

  • 无需调整卫星的进退轨道。 在点燃推进器之前不断旋转航天探测器会极大地削弱游戏体验。假设姿态推进器环绕在机身周围,玩家可以使用箭头键选择要点燃的推进器。

  • 点燃推进器时会发出嗡嗡声。 尽管太空中没有声音,但每当玩家点燃推进器时,给他们带来听到一声愉快的嗡嗡声的满足感。

  • 卫星天线始终指向火星。 卫星会慢慢地自动旋转,以使其遥感天线始终指向火星。

  • 卫星的轨道路径可见。 一条细白线将从卫星后方延伸出来,并持续存在,直到玩家按下空格键清除它。

  • 数据读数显示在屏幕顶部。 你将在窗口顶部的框中显示对游戏玩法有用的信息。关键数据包括航天探测器的速度、高度、燃料和轨道偏心率(轨道圆形度的度量)。

  • 启动时会显示简短的介绍。 游戏开始时,介绍文字会出现在屏幕中央,并持续约 15 秒。该文字不会干扰游戏玩法,因此玩家可以立即开始操作卫星。

  • 胜利条件和关键控制显示在永久图例中。 关键的信息,如任务目标和控制键,将永久显示在屏幕的左下角和右下角。

图 14-12 中的游戏草图描述了成功和失败的情况。玩家在获胜时需要奖励,而在失败时则需要有趣的结果。

image

图 14-12:火星探测器游戏中的胜负结果草图

胜负结果的关键点如下:

  • 改变卫星图像以显示坠毁燃烧。 如果卫星的高度降到 68 英里以下,它将在大气中燃烧。移动的卫星图像将被一个发光的红色版本替换,粘附在火星的侧面;这类似于你可能在真实的任务控制显示屏上看到的场景。

  • 卫星如果燃料耗尽,将在太空中迷失。 尽管不现实,但如果卫星燃料耗尽,让它飞出屏幕并进入太空深处。这会让玩家感到非常沮丧!

  • 胜利条件解锁奖品。 如果卫星在目标高度范围内达到圆形轨道,新文本会促使玩家按下 M 键。

  • 按下 M 键更换火星图像。 当 M 键解锁后,按下它会使火星图像变为彩虹图像,其中冷色表示土壤湿度较高的区域,暖色则表示较干燥的区域。

对于游戏玩法,卫星的大小和轨道速度不会现实,但整体行为是正确的。你应该能够正确执行在《游戏中的天体动力学》一书中第 286 页描述的所有轨道机动。

游戏资源

你将需要的火星轨道器游戏资源包括两张卫星图像、两张行星图像和一个音效文件。你可以在过程开始时将这些准备好,也可以在需要时再制作它们。后者的方法让你可以在编码过程中适当休息,这是一些人偏好的方式。

找到好的无版权图形和音效文件可能是一项挑战。你可以在线找到合适的资源——无论是免费的还是付费的——但最好是尽可能自己制作。这可以避免未来出现任何法律问题。

我为这个项目使用的精灵(2D 图标或图像)见图 14-13。你需要一颗卫星、一颗红色“烧毁”版的卫星、一张火星的极冠居中的视图,以及同一视图上带有彩色叠加层的图像,表示映射的土壤湿度梯度。我在免费的图标网站 AHA-SOFT (www.aha-soft.com/) 找到卫星精灵,然后复制并重新着色制作了坠毁版本。两个火星精灵都是修改过的 NASA 图像,专为游戏制作。

image

图 14-13:用于游戏精灵的卫星、坠毁卫星、火星和火星叠加图像

我为卫星启动推进器时制作了一个声音文件,使用开源程序 Audacity 中的白噪声生成器。你可以在 www.audacityteam.org/ 下载 Audacity 的免费版本。我将文件保存为 Ogg Vorbis 格式,这是一种开源标准音频压缩格式,免费且与 Python 和 pygame 配合良好。你也可以使用其他格式,如 MP3 和 WAV,但有些格式存在已知问题,或包含专有组件,在你尝试将游戏商业化时可能会引发法律问题。

你可以从本书的官方网站下载这些文件,文件名分别为 www.nostarch.com/impracticalpython/ 中的 satellite.pngsatellite_crash_40x33.pngmars.pngmars_water.pngthrust_audio.ogg。下载时,请保留文件名,并将其存放在与代码相同的文件夹中。

代码

图 14-14 是你将构建的最终游戏屏幕示例。你可以参考此图来了解代码的作用。

image

图 14-14:最终版本的 mars_orbiter.py 游戏启动屏幕示例

你可以在 www.nostarch.com/impracticalpython/ 下载完整的程序 (mars_orbiter.py)。

导入并构建颜色表

列表 14-1 导入所需的模块并构建一个颜色表。

mars_orbiter.py, 第一部分

➊ import os
   import math
   import random
   import pygame as pg

➋ WHITE = (255, 255, 255)
   BLACK = (0, 0, 0)
   RED = (255, 0, 0)
   GREEN = (0, 255, 0)
   LT_BLUE = (173, 216, 230)

列表 14-1:导入模块并构建颜色表

首先,导入操作系统模块,使用 os ➊。游戏将在全屏模式下启动,但玩家可以选择退出全屏。这个模块将允许你在玩家按下 ESC 后控制游戏窗口的位置。

你将使用 math 模块进行重力和三角计算,使用 random 来为卫星生成随机位置和速度。像 第十三章 中一样导入 pygame,并用 pg 代替 pygame,以减少输入量。

最后,像 第十三章 中一样,构建一个 RGB 颜色表 ➋。这让你在需要分配颜色时,可以直接输入颜色名称,而不是 RGB 值元组。

定义卫星类的初始化方法

列表 14-2 定义了 Satellite 类及其初始化方法,你将在游戏中使用这个方法来实例化卫星对象。由于该方法定义较长,所以分为两个列表展示。

mars_orbiter.py, 第二部分

➊ class Satellite(pg.sprite.Sprite):
       """Satellite object that rotates to face planet & crashes & burns."""

    ➋ def __init__(self, background):
        ➌ super().__init__()
        ➍ self.background = background
        ➎ self.image_sat = pg.image.load("satellite.png").convert()
           self.image_crash = pg.image.load("satellite_crash_40x33.png").convert()
        ➏ self.image = self.image_sat
        ➐ self.rect = self.image.get_rect()
        ➑ self.image.set_colorkey(BLACK)  # sets transparent color

列表 14-2:定义卫星类初始化方法的第一部分

Satellite对象 ➊ 定义一个类;如果你需要复习面向对象编程,阅读第十一章。将pygameSprite类传递给它,因为从Satellite类实例化的对象将是精灵。如第十三章所述,Sprite是一个内置类,用于作为创建精灵的模板。你的新类将从这个基类继承精灵所需的特性,包括重要的属性如rectimage,你稍后会处理这些属性。

接下来,为Satellite对象 ➋ 定义__init__()方法,并传入self,这个名字在类定义中有特殊含义,指代当前对象。你还需要传入一个background对象。卫星的轨迹将会绘制在这个对象上。

__init_()方法内,立即使用super ➌ 调用内置Sprite类的初始化方法。这将初始化精灵并建立它所需的rectimage属性。通过super,你无需显式引用基类(Sprite)。有关super的更多信息,请参见清单 11-5 和第 229 页,或访问文档* docs.python.org/3/library/functions.html?highlight=super#super *。

接下来,将background作为对象属性 ➍ 赋值给self。然后使用pygameimage.load()方法加载你的两个卫星图像——一个是正常的,另一个是烧毁后的——并在同一步骤中对它们运行convert()方法 ➎。此方法将对象转换为pygame可以高效使用的图形格式,一旦游戏循环开始。如果没有这一步,游戏可能会明显变慢,因为png格式会在每秒 30 次或更多的速度下动态转换。

你一次只会使用一个卫星图像,取决于玩家是否在大气层中烧毁,因此使用通用的self.image属性来保存加载和转换后的图像 ➏。未烧毁的卫星图像将作为默认图像;如果卫星对象接近火星,这个图像将被红色的烧毁图像替换。

现在,获取图像的矩形信息 ➐。记住,pygame将精灵放置在矩形表面对象上,它需要知道这些矩形的尺寸和位置,以便在游戏运行时进行处理。

最后,使卫星图像的黑色部分变得透明 ➑。卫星图标位于黑色背景上(见图 14-13),你希望烧毁后的图像部分覆盖火星,因此使用BLACK常量与图像对象的colorkey()方法将图标的背景设置为透明。否则,你会看到一个黑色框和一个红色的卫星图像重叠在火星上。注意,如果你想输入黑色的 RGB 值,你需要将其作为元组输入:(0, 0, 0)

设置卫星的初始位置、速度、燃料和音效

清单 14-3 完成了Satellite类初始化方法的定义。卫星对象的初始位置和速度是从有限范围内随机选择的;遥感天线的方向被初始化,油箱被加满,音效被添加。

mars_orbiter.py, 第三部分

        ➊ self.x = random.randrange(315, 425)
           self.y = random.randrange(70, 180)
        ➋ self.dx = random.choice([-3, 3])
        ➌ self.dy = 0
        ➍ self.heading = 0  # initializes dish orientation
        ➎ self.fuel = 100
           self.mass = 1
           self.distance = 0  # initializes distance between satellite & planet
        ➏ self.thrust = pg.mixer.Sound('thrust_audio.ogg')
        ➐ self.thrust.set_volume(0.07)  # valid values are 0-1

清单 14-3:通过初始化参数完成 Satellite 类初始化方法

游戏开始时,卫星将在屏幕顶部附近的一个随机点出现。你将从一系列 x 和 y 值中选择准确的位置 ➊。

你还将随机选择卫星的速度,但它会足够慢,确保卫星无法逃脱轨道。将速度随机设置为-3 或 3。负值会导致卫星沿逆时针方向运行,反之亦然。仅使用 delta-x(dx)属性 ➋,让重力来处理dy。正如在第十三章中讨论的那样,pygame通过改变 x 坐标(称为 delta-x 或dx)和 y 坐标(称为 delta-y 或dy)来移动精灵。这些向量分量会在每个游戏循环中被计算并加到精灵的当前位置(self.xself.y)上。

接下来,将dy属性设置为0 ➌。稍后,gravity()方法将在加速新实例化的卫星向行星下方移动时建立一个初始的dy值。

为卫星的航向分配一个属性 ➍。遥感天线将读取行星表面的土壤湿度,应始终指向火星。如果你记得图 14-3,除非克服惯性,否则这不会发生。你将使用一个方法来实际旋转卫星,所以现在只需将heading属性初始化为0

现在,将油箱加满 100 单位燃料 ➎。若想与现实生活相联系,它可能代表 100 千克的肼,类似于麦哲伦探测器上用于绘制金星表面的燃料。

接下来,将物体的质量设置为1。这基本上意味着你将在重力方程中仅使用火星的质量,因为你需要将两个物体的质量相乘。如前所述,卫星对火星的引力可以忽略不计,因此不需要计算它。卫星的mass属性是为了完整性和作为占位符,以便以后如果你想尝试不同的值时可以使用。

以下distance属性存储了卫星与它正在绕行的天体之间的距离。实际值将通过稍后你定义的方法来计算。

现在是时候添加音效了。你将在 main() 函数中初始化 pygame 的声音混音器,但目前,先为推力音效命名一个 thrust 属性 ➏。将白噪声的短片段以 Ogg Vorbis 格式(.ogg)传递给混音器的 Sound 类。最后,设置播放音量,使用 0 到 1 之间的值 ➐。你可能需要根据你的 PC 来校准这个值。理想情况下,你希望设置一个值,让每个玩家至少能够听到,然后通过他们自己的计算机音量控制进行微调。

启动推进器并检查玩家输入

清单 14-4 定义了 Satellite 类的 thruster()check_keys() 方法。第一个方法确定当卫星的某个推进器被启动时执行的操作。第二个方法检查玩家是否通过按下箭头键与推进器进行了交互。

mars_orbiter.py 第四部分

    ➊ def thruster(self, dx, dy):
           """Execute actions associated with firing thrusters."""
        ➋ self.dx += dx
           self.dy += dy
        ➌ self.fuel -= 2
        ➍ self.thrust.play()

    ➎ def check_keys(self):
           """Check if user presses arrow keys & call thruster() method."""
        ➏ keys = pg.key.get_pressed()
           # fire thrusters
        ➐ if keys[pg.K_RIGHT]:
            ➑ self.thruster(dx=0.05, dy=0)
           elif keys[pg.K_LEFT]:
               self.thruster(dx=-0.05, dy=0)
           elif keys[pg.K_UP]:
               self.thruster(dx=0, dy=-0.05)
           elif keys[pg.K_DOWN]:
               self.thruster(dx=0, dy=0.05)

清单 14-4:为 Satellite 类定义了 thruster() check_keys() 方法

thruster() 方法以 selfdxdy 作为参数 ➊。后两个参数可以是正数或负数,它们会立即加到卫星的 self.dxself.dy 速度分量上 ➋。接下来,燃料水平减少两个单位 ➌。改变这个值是一种让游戏变得更难或更容易的方法。最后,调用 thrust 音频属性的 play() 方法,播放嘶嘶声 ➍。请注意,面向对象编程(OOP)方法不是返回值,而是更新现有的对象属性。

check_keys() 方法以 self 作为参数 ➎。首先,你使用 pygamekey 模块来判断玩家是否按下了某个键 ➏。get_pressed() 方法返回一个布尔值元组——1 表示 True0 表示 False——代表当前每个键的状态。True 表示该键已被按下。你可以通过使用键常量来索引这个元组。你可以在 www.pygame.org/docs/ref/key.html 查找所有键盘常量的列表。

例如,右箭头键是 K_RIGHT。如果按下了这个键 ➐,调用 thruster() 方法,并传递 dxdy 值 ➑。在 pygame 中,x 值向屏幕右侧增大,y 值向屏幕下方增大。因此,如果用户按下左箭头键,应该从 dx 中减去;同样地,如果按下上箭头键,应该减小 dy 值。右箭头键将增加 dx,下箭头键将增加 dy。屏幕顶部的数据显示将帮助玩家将卫星的运动与底层的 dxdy 值联系起来(见 图 14-14)。

定位卫星

仍然在卫星类中,列表 14-5 定义了locate()方法。这个方法计算卫星与行星之间的距离,并确定指向行星的天线航向。你稍后会使用距离属性来计算引力和轨道的偏心率。偏心率是衡量轨道偏离完美圆形的程度。

mars_orbiter.py,第五部分

    ➊ def locate(self, planet):
           """Calculate distance & heading to planet."""
        ➋ px, py = planet.x, planet.y
        ➌ dist_x = self.x - px
           dist_y = self.y - py
           # get direction to planet to point dish
        ➍ planet_dir_radians = math.atan2(dist_x, dist_y)
        ➎ self.heading = planet_dir_radians * 180 / math.pi
        ➏ self.heading -= 90  # sprite is traveling tail-first
        ➐ self.distance = math.hypot(dist_x, dist_y)

列表 14-5:为卫星类定义了locate()方法

要定位卫星,你需要将locate()方法传递给卫星self)和行星对象 ➊。首先,确定对象在 x-y 平面上的距离。获取行星的 x 和 y 属性 ➋;然后从卫星的 x 和 y 属性中减去它们 ➌。

现在,使用这些新的距离变量来计算卫星航向与行星之间的角度,从而可以将卫星天线旋转到指向行星的位置。math模块使用弧度,因此分配一个名为planet_dir_radians的局部变量来存储方向的弧度,并将dist_xdist_y传递给math.atan2()函数来计算反正切 ➍。由于pygame使用的是角度(唉),你需要使用标准公式将弧度转换为角度;或者,你可以使用math模块来完成这项操作,但有时候看到幕后的人会更好 ➎。这个应该是卫星对象的一个共享属性,命名为self.heading

pygame中,精灵的前方默认是朝东的,这意味着卫星精灵是尾部优先地绕行的(参见图 14-13 中的卫星图标)。为了让天线指向火星,你需要从航向角度减去 90 度,因为负角度在pygame中会导致顺时针旋转 ➏。这一操作不会使用玩家的任何燃料配额。

最后,使用math模块计算卫星与火星之间的欧几里得距离,通过计算 x 和 y 分量的斜边 ➐。你应该将这个值设为卫星对象的一个属性,因为稍后在其他函数中你将会用到它。

注意

在实际生活中,有多种方法可以使卫星的天线始终指向行星,而不消耗大量的燃料。技术包括缓慢翻滚或旋转卫星、使天线端比另一端更重、使用磁力矩或使用内部飞轮——也叫做反应轮或动量轮。飞轮使用电动机,可以通过太阳能电池板提供电力,从而消除了对重型和有毒液体推进剂的需求。

旋转卫星并绘制其轨道

列表 14-6 通过为卫星天线旋转指向行星并绘制轨迹定义了卫星类的后续方法。稍后,在main()函数中,你将添加代码,让玩家按下空格键即可删除并重新开始轨迹。

mars_orbiter.py, 第六部分

    ➊ def rotate(self):
           """Rotate satellite using degrees so dish faces planet."""
        ➋ self.image = pg.transform.rotate(self.image_sat, self.heading)
        ➌ self.rect = self.image.get_rect()

    ➍ def path(self):
           """Update satellite’s position & draw line to trace orbital path."""
        ➎ last_center = (self.x, self.y)
        ➏ self.x += self.dx
           self.y += self.dy
        ➐ pg.draw.line(self.background, WHITE, last_center, (self.x, self.y))

列表 14-6:定义了 rotate() path() 方法用于 Satellite

rotate()方法将使用在locate()方法中计算的heading属性来将卫星天线指向火星。将self传递给rotate() ➊,这意味着当它被调用时,rotate()方法将自动将卫星对象的名称作为参数。

现在,使用pygametransform.rotate()方法旋转卫星图像 ➋。将原始图像传递给它,然后传递heading属性;将这些赋值给self.image属性,这样你就不会破坏原始的主图像。你需要在每次游戏循环中都对图像进行变换,而快速变换图像会导致它质量下降。因此,每次进行变换时,始终保留一张主图像,并基于主图像创建一个新的副本来进行操作。

通过获取变换后的图像的rect对象来结束该函数 ➌。

接下来,定义一个名为path()的方法,并将self传递给它 ➍。这个方法将绘制一条线标记卫星的路径,并且因为绘制一条线需要两个点,所以在移动卫星之前,先分配一个变量来记录卫星的中心位置(元组) ➎。然后使用dxdy属性增加 x 和 y 的位置 ➏。最后,使用pygamedraw.line()方法来定义这条线 ➐。该方法需要一个绘图对象,因此传递background属性,然后是线条颜色以及之前和当前的 x-y 位置元组。

更新卫星对象

列表 14-7 更新卫星对象并完成类定义。精灵对象几乎总是有一个update()方法,该方法在游戏运行时每帧调用一次。所有发生在精灵上的操作,比如移动、颜色变化、用户交互等,都包括在这个方法中。为了避免它们变得过于复杂,update()方法通常会调用其他方法。

mars_orbiter.py, 第七部分

    ➊ def update(self):
           """Update satellite object during game."""
        ➋ self.check_keys()
        ➌ self.rotate()
        ➍ self.path()
        ➎ self.rect.center = (self.x, self.y)
           # change image to fiery red if in atmosphere
        ➏ if self.dx == 0 and self.dy == 0:
               self.image = self.image_crash
               self.image.set_colorkey(BLACK)

列表 14-7:定义了 update() 方法用于 Satellite

从定义update()方法并将对象或self传递给它开始 ➊。接下来,调用你之前定义的方法。第一个方法检查玩家通过键盘进行的交互 ➋。第二个方法旋转卫星对象,使得天线始终指向火星 ➌。最后一个方法更新卫星的 x-y 位置,并在它后面绘制路径,以便你可以看到轨道 ➍。

程序需要跟踪卫星精灵的位置,因为它绕火星轨道运行,所以要分配一个rect.center属性,并将其设置为卫星当前的 x-y 位置 ➎。

最后一部分代码在玩家在大气层中坠毁时会更改卫星图像 ➏。火星大气层的顶部大约位于其表面上方 68 英里。出于我稍后解释的原因,假设 68 的高度值——这是从行星中心测量的像素数——等同于大气层的顶部。如果卫星在游戏过程中低于此高度,main()函数将把它的速度——由dxdy表示——设置为0。检查这两个值是否为0,如果是,则将图像更改为image_crash,并将其背景设置为透明(就像之前为主卫星图像所做的那样)。

定义 Planet 类初始化方法

列表 14-8 定义了Planet类,你将用它来实例化一个planet对象。

mars_orbiter.py, 第八部分

➊ class Planet(pg.sprite.Sprite):
       """Planet object that rotates & projects gravity field."""

    ➋ def __init__(self):
           super().__init__()
        ➌ self.image_mars = pg.image.load("mars.png").convert()
           self.image_water = pg.image.load("mars_water.png").convert()
        ➍ self.image_copy = pg.transform.scale(self.image_mars, (100, 100))
        ➎ self.image_copy.set_colorkey(BLACK)
        ➏ self.rect = self.image_copy.get_rect()
           self.image = self.image_copy
        ➐ self.mass = 2000
        ➑ self.x = 400
           self.y = 320
           self.rect.center = (self.x, self.y)
        ➒ self.angle = math.degrees(0)
           self.rotate_by = math.degrees(0.01)

列表 14-8:开始定义 Planet

你现在可能已经非常熟悉创建Planet类的初步步骤。首先,你用大写字母命名类,然后将Sprite类传递给它,这样它就可以方便地继承这个内置pygame类的特性 ➊。接下来,你为planet对象定义一个__init__()初始化方法 ➋。然后,像在Satellite类中一样调用super()初始化方法。

将图像作为属性加载,并同时将其转换为pygame的图形格式 ➌。你需要正常的火星图像和用于映射土壤湿度的图像。你可以使用原始尺寸的卫星精灵,但火星图像太大。将图像缩放到 100 像素 × 100 像素 ➍,并将缩放后的图像分配给一个新属性,这样重复转换就不会损坏原始图像。

现在,将变换后的图像的透明颜色设置为黑色,正如你之前对卫星图像所做的那样 ➎。pygame中的精灵都“安装”在矩形表面上,如果你不让黑色透明,行星表面的角落可能会重叠并覆盖卫星绘制的白色轨道路径(见图 14-15)。

image

图 14-15:火星 rect 覆盖轨道路径的角落

和往常一样,获取精灵的rect对象 ➏。接下来会有另一个转换,所以再次复制图像属性并将其命名为self.image

为了施加重力,行星需要质量,因此命名一个mass属性并将其值设为2000 ➐。之前你将卫星的质量设为1;这意味着火星的质量是卫星的 2000 倍!这没问题,因为你并不是使用现实世界的单位,时间和距离的尺度也与现实不同。如果你将距离缩放,使得卫星离火星只有几百个像素,你也需要缩放重力。尽管如此,卫星仍会根据重力表现得非常现实。

行星的质量值是通过实验确定的。为了缩放重力,你可以在之后更改此质量值,或者使用引力常数(G)变量。

planet 对象的 xy 属性设置为屏幕的中心点——你将在 main() 函数中使用 800 × 645 的屏幕大小——并将这些值分配给 rect 对象的中心 ➑。

最后,分配你需要的属性来慢慢旋转火星绕其轴 ➒。你将使用与旋转卫星时相同的 transform.rotate() 方法,因此你需要创建一个 angle 属性。然后,使用一个 rotate_by 属性来指定每次游戏循环中旋转角度的增量(单位:度)。

旋转行星

清单 14-9 通过定义 rotate() 方法继续实现 Planet 类。该方法使行星绕其轴旋转,并在每个游戏循环中进行微小的变化。

mars_orbiter.py, 第九部分

    ➊ def rotate(self):
           """Rotate the planet image with each game loop."""
        ➋ last_center = self.rect.center
        ➌ self.image = pg.transform.rotate(self.image_copy, self.angle)
           self.rect = self.image.get_rect()
        ➍ self.rect.center = last_center
        ➎ self.angle += self.rotate_by

清单 14-9:定义了一个使行星绕其轴旋转的方法

rotate() 方法还将对象作为参数 ➊。随着方形火星图像的旋转,边界矩形对象(rect)保持不动,并且必须扩展以适应新的配置(参见 图 14-16)。这种大小的变化可能会影响 rect 的中心点,因此需要分配一个 last_center 变量,并将其设置为行星的当前中心点 ➋。如果不这样做,火星在游戏运行时会绕其轴心晃动。

image

图 14-16:边界矩形的大小发生变化,以适应旋转的图像。

接下来,使用 pygametransform.rotate() 方法旋转复制的图像,并将其分配给 self.image 属性 ➌;你需要将复制的图像和 angle 属性传递给该方法。旋转后,立即重置图像的 rect 属性,并将其中心位置移回 last_center,以避免旋转过程中发生的 rect 偏移 ➍。

planet 对象被实例化时,角度属性将从 0 度开始,然后每帧增加 0.1——通过 rotate_by 属性进行赋值 ➎。

定义 gravity() 和 update() 方法

清单 14-10 通过定义 gravity()update() 方法来完成 Planet 类。在第十三章中,你将重力视为一个施加在 y 轴方向的常量。此处应用的方法稍微复杂一些,因为它考虑了两个物体之间的距离。

mars_orbiter.py, 第十部分

    ➊ def gravity(self, satellite):

           """Calculate impact of gravity on satellite."""

        ➋ G = 1.0  # gravitational constant for game

        ➌ dist_x = self.x - satellite.x

           dist_y = self.y - satellite.y

           distance = math.hypot(dist_x, dist_y)

           # normalize to a unit vector

        ➍ dist_x /= distance

           dist_y /= distance

           # apply gravity (dx & dy represent pixels/frame)

        ➎ force = G * (satellite.mass * self.mass) / (math.pow(distance, 2))

        ➏ satellite.dx += (dist_x * force)

           satellite.dy += (dist_y * force)

    ➐ def update(self):

           """Call the rotate method."""

           self.rotate()

清单 14-10:定义了 Planet 类的 gravity() update() 方法

定义 gravity() 方法并传递 self 和卫星对象 ➊。你仍然在 Planet 类中,所以这里的 self 代表火星。

首先命名一个本地变量 G;大写的 G 是 万有引力常数,也叫做 比例常数 ➋。现实中,这个数值非常小,是通过经验得出的,基本上是一个转换数,用于将所有单位计算正确。由于在游戏中你不使用现实世界的单位,因此将其设置为 1;这样它就不会对重力方程产生影响。在游戏开发过程中,你可以调节这个常数的大小,以微调重力的强度及其对轨道物体的影响。

你需要知道这两个物体之间的距离,因此先获取它们在 x 方向和 y 方向上的距离 ➌。然后,使用 math 模块的 hypot() 方法来获取欧几里得距离。这将表示重力方程中的 r

由于你将直接在重力方程中处理卫星与火星之间的距离的 大小,你从距离向量中需要的只是 方向。因此,将 dist_xdist_y 除以 distance 来“归一化”这个向量,使其成为一个大小为 1 的单位向量 ➍。你基本上是在将直角三角形每条边的长度除以它的斜边。这保留了向量的方向,由 dist_xdist_y 的相对差异表示,但将其大小设置为 1。注意,如果你不执行此归一化步骤,结果将是不现实但有趣的(见 图 14-17)。

image

图 14-17:“Spirograph” 轨道,使用未归一化的距离向量产生

使用牛顿的公式计算重力,公式我在 “万有引力定律” 中已经描述过,见 第 286 页 ➎。最后,将归一化的距离乘以 force——计算每一步加速度如何改变速度——并将这些值加到卫星对象的 dxdy 属性上 ➏。

请注意,你并没有将这些变量作为 self 的属性进行赋值。这些只是方法中的中间步骤,不需要与其他方法共享,你可以像在过程式编程中一样将它们当作局部变量来处理。

最后,定义一个将在每个游戏循环中调用的方法,用于更新 planet 对象 ➐。使用它来调用 rotate() 方法。

计算偏心率

你已经完成了类的定义。现在是时候定义一些函数,帮助你运行游戏了。清单 14-11 定义了一个计算卫星轨道偏心率的函数。玩家需要在某个特定的高度范围内实现一个圆形轨道,而这个函数将提供圆形度的测量。

mars_orbiter.py, 第十一部分

➊ def calc_eccentricity(dist_list):
       """Calculate & return eccentricity from list of radii."""
    ➋ apoapsis = max(dist_list)
       periapsis = min(dist_list)
    ➌ eccentricity = (apoapsis - periapsis) / (apoapsis + periapsis)
       return eccentricity

清单 14-11:定义一个用于测量轨道偏心率的函数

定义calc_eccentricity()函数,并传入一个距离列表 ➊。在main()函数中,每次游戏循环时,你将把sat.distance属性——记录卫星高度——添加到该列表中。要计算偏心率,你需要知道轨道的远地点和近地点。通过在该列表中查找最大值和最小值来获得它们 ➋。然后,计算eccentricity ➌。稍后,在main()函数中,你将以八位小数显示这个数值,让它在数据输出中看起来既酷又精确。

注意,圆形轨道的远地点和近地点值相同,因此对于完美的圆形轨道,计算结果将为0。通过返回eccentricity变量来结束该函数。

定义函数以创建标签

游戏将需要大量的文本用于指令和遥测数据输出。逐行显示这些文本会导致代码冗余,因此列表 14-12 将定义两个函数——一个用于发布指令,另一个用于显示你需要与玩家分享的速度、高度、燃料和偏心率数据流。

mars_orbiter.py, 第十二部分

➊ def instruct_label(screen, text, color, x, y):
       """Take screen, list of strings, color, & origin & render text to screen."""
    ➋ instruct_font = pg.font.SysFont(None, 25)
    ➌ line_spacing = 22
    ➍ for index, line in enumerate(text):
           label = instruct_font.render(line, True, color, BLACK)
           screen.blit(label, (x, y + index * line_spacing))

➎ def box_label(screen, text, dimensions):
       """Make fixed-size label from screen, text & left, top, width, height."""
       readout_font = pg.font.SysFont(None, 27)
    ➏ base = pg.Rect(dimensions)
    ➐ pg.draw.rect(screen, WHITE, base, 0)
    ➑ label = readout_font.render(text, True, BLACK)
    ➒ label_rect = label.get_rect(center=base.center)
    ➓ screen.blit(label, label_rect)

列表 14-12:定义函数以创建指令和输出标签

定义一个名为instruct_label()的函数,用于在游戏屏幕上显示指令 ➊。将屏幕、包含文本的列表、文本颜色以及pygame surface对象的左上角坐标传递给它,这个surface对象将用于承载文本。

接下来,告诉pygame使用哪个字体 ➋。font.SysFont()方法的参数包括字体和大小。使用None作为字体类型时,会调用pygame的内置默认字体,这个字体应该能在多个平台上工作。注意,该方法接受None'None'两种格式。

介绍和指令文本将占用多行(参见图 14-14 中的示例)。你需要指定文本字符串之间的行间距(单位:像素),因此为此分配一个变量,并将其设置为22 ➌。

现在,开始遍历文本字符串列表 ➍。使用enumerate()获取索引,这个索引将与line_spacing变量一起,用于将字符串显示在正确的位置。文本需要被放置在一个表面上。将此表面命名为label,并将你想显示的文本行传递给font.render()方法,启用抗锯齿以使文本更平滑,设置文本颜色,并将背景色设置为黑色。最后,通过 blit 将该表面绘制到屏幕上。将label变量和左上角坐标传递给方法,y的值定义为y + index * line_spacing

接下来,定义一个名为box_label()的函数,用于屏幕顶部作为仪表显示的数据输出标签(参见图 14-18) ➎。此函数的参数包括屏幕、文本和一个包含矩形表面尺寸的元组,矩形表面将用于形成仪表。

image

图 14-18:游戏窗口顶部的读数标签(上方为标题标签,下方为数据标签)

instruct_label() 函数创建的表面会根据显示的文本量自动调整大小。这对于静态显示很好用,但读数数据会不断变化,导致仪表随着调整文本大小而扩展或收缩。为了解决这个问题,你将使用一个单独的 rect 对象,指定大小来作为文本对象的基础。

从设置字体开始,正如你在 ➋ 中做的那样。将一个变量 base 赋值为 pygamerect 对象;使用 dimensions 参数来指定大小 ➏。该参数允许你通过指定矩形的左上角坐标、宽度和高度,精确地放置矩形的位置。生成的矩形应该足够宽,以容纳游戏将要显示的最长数据读数。

现在,使用 draw_rect() 方法 ➐ 绘制 base。参数包括绘图表面、填充颜色、rect 的名称和宽度为 0,这样填充矩形而不是绘制边框。你将把文本对象放在这个白色矩形的顶部。

重复渲染文本 ➑ 的代码,然后获取 labelrect ➒。注意,在 get_rect() 方法中有一个参数,将中心设置为 base 的中心。这使得你可以将文本标签放置在白色的基础矩形上方。最后,将其绘制到屏幕上,指定源矩形和目标矩形 ➓。

映射土壤湿度

清单 14-13 定义了允许玩家在满足游戏胜利条件时“映射”火星的函数。当玩家按下 M 键时,这些函数将由 main() 函数调用,行星的图像将被一个彩色叠加图层替换,我们假装它表示土壤湿度。当玩家松开按键时,火星的正常视图将恢复。键盘检查也将在 main() 函数中执行。

mars_orbiter.py, 第十三部分

➊ def mapping_on(planet):
       """Show soil moisture image of planet."""
    ➋ last_center = planet.rect.center
    ➌ planet.image_copy = pg.transform.scale(planet.image_water, (100, 100))
    ➍ planet.image_copy.set_colorkey(BLACK)
       planet.rect = planet.image_copy.get_rect()
       planet.rect.center = last_center

➎ def mapping_off(planet):
       """Restore normal planet image."""
    ➏ planet.image_copy = pg.transform.scale(planet.image_mars, (100, 100))
       planet.image_copy.set_colorkey(BLACK)

清单 14-13:定义函数,使玩家能够制作火星的土壤湿度地图

首先定义一个函数,该函数以 planet 对象作为参数 ➊。开始时,像在 清单 14-9 中一样赋值一个 last_center 变量;它将用于防止行星在轴上摇晃 ➋。

接下来,将火星的水图像缩放为与正常图像相同的大小,并将其赋值给行星的 image_copy 属性,因为如果反复使用转换,会降低图像质量 ➌。将图像的背景设置为透明 ➍,获取其 rect,并将 rect 的中心设置为 last_center 变量;这样火星就能保持在屏幕中央。

现在,定义另一个函数,用于当玩家停止主动绘制火星时 ➎。它也将planet对象作为参数。你只需要做的就是将行星图像重置为原始版本 ➏。因为你仍在使用image_copy属性,所以无需重新获取rect,但需要设置透明色。

投射阴影

清单 14-14 定义了一个函数,为火星添加“黑暗面”并在行星后方投射阴影。阴影将是一个黑色的半透明矩形,其右边缘与行星精灵的中心对齐(参见图 14-19)。这假设太阳位于屏幕的右侧,并且火星上是春分或秋分。

image

图 14-19:半透明白色的阴影矩形(左)和最终的半透明黑色(右)

mars_orbiter.py, 第十四部分

➊ def cast_shadow(screen):
       """Add optional terminator & shadow behind planet to screen."""
    ➋ shadow = pg.Surface((400, 100), flags=pg.SRCALPHA)  # tuple is w,h
    ➌ shadow.fill((0, 0, 0, 210))  # last number sets transparency
       screen.blit(shadow, (0, 270))  # tuple is top left coordinates

清单 14-14:定义一个函数,为火星添加一个黑暗面并让它投射阴影

cast_shadow()函数将screen对象作为参数 ➊。分配一个 400 像素×100 像素的pygame表面给一个名为shadow的对象 ➋。使用pygameSRCALPHA标志——表示“源 alpha”——来指示将使用每像素的 alpha(透明度)。将对象填充为黑色,并将 alpha 值——由最后一个数字表示——设置为210 ➌。Alpha 是 RGBA 颜色系统的一部分,值的范围是 0 到 255,所以这个值非常暗,但不是完全不透明。最后,将表面绘制到屏幕上,并指定其左上角的坐标。要关闭阴影,只需注释掉main()中的函数调用或将 alpha 值设置为0

定义 main() 函数

清单 14-15 开始定义运行游戏的main()函数。初始化了pygame包和声音混音器,设置了游戏屏幕,并将玩家指令存储为列表。

mars_orbiter.py, 第十五部分

def main():
       """Set up labels & instructions, create objects & run the game loop."""
    ➊ pg.init()  # initialize pygame

       # set up display:
    ➋ os.environ['SDL_VIDEO_WINDOW_POS'] = '700, 100'  # set game window origin
    ➌ screen = pg.display.set_mode((800, 645), pg.FULLSCREEN)
    ➍ pg.display.set_caption("Mars Orbiter")
    ➎ background = pg.Surface(screen.get_size())

    ➏ pg.mixer.init()  # for sound effects

    ➐ intro_text = [
           ' The Mars Orbiter experienced an error during Orbit insertion.',
           ' Use thrusters to correct to a circular mapping orbit without',
           ' running out of propellant or burning up in the atmosphere.'
           ]

       instruct_text1 = [
           'Orbital altitude must be within 69-120 miles',
           'Orbital Eccentricity must be < 0.05',
           'Avoid top of atmosphere at 68 miles'
           ]

       instruct_text2 = [
           'Left Arrow = Decrease Dx',
           'Right Arrow = Increase Dx',
           'Up Arrow = Decrease Dy',
           'Down Arrow = Increase Dy',
           'Space Bar = Clear Path',
           'Escape = Exit Full Screen'
           ]

清单 14-15:通过初始化pygame和声音混音器并设置游戏屏幕和指令,启动main()函数

通过初始化pygame ➊,开始main()函数。然后,使用os模块的environ()方法来分配游戏窗口左上角的坐标 ➋。这一步不是严格必要的,但我想演示你可以控制窗口在桌面上的显示位置。

接下来,分配一个变量来保存screen对象,并将显示模式设置为全屏 ➌。如果玩家退出全屏模式,则使用元组(800, 645)来指定屏幕大小。

现在使用pygamedisplay.set_caption()方法将游戏窗口命名为“火星轨道器” ➍。然后,使用pygameSurface类创建一个与屏幕大小相同的游戏背景对象 ➎。

初始化pygame的声音混音器,以便播放推进器的音效 ➏。你在卫星的初始化方法中已经定义了这个声音。

游戏将从一个简短的介绍开始,该介绍将在 15 秒后消失。描述键盘控制和获胜条件的永久性图例位于屏幕的底部角落。将这些内容作为列表输入➐。稍后,你将把这些列表传递给你在 Listing 14-12 中编写的instruct_label()函数。列表中的每个项目,通过逗号分隔,将在游戏窗口中作为单独的行显示(见 Figure 14-19)。

实例化对象,设置轨道验证,映射和计时功能

Listing 14-16,仍然在main()函数中,实例化了planetsatellite对象,分配了一些有用的变量来确定轨道偏心率,准备了函数中的游戏时钟,并分配了一个变量来跟踪映射功能的状态。

mars_orbiter.py, 第十六部分

       # instantiate planet and satellite objects
    ➊ planet = Planet()
    ➋ planet_sprite = pg.sprite.Group(planet)
    ➌ sat = Satellite(background)
    ➍ sat_sprite = pg.sprite.Group(sat)

       # for circular orbit verification
    ➎ dist_list = []
    ➏ eccentricity = 1
    ➐ eccentricity_calc_interval = 5  # optimized for 120 mile altitude

       # time keeping
    ➑ clock = pg.time.Clock()
       fps = 30
       tick_count = 0

       # for soil moisture mapping functionality
    ➒ mapping_enabled = False

Listing 14-16:在main()中实例化对象并分配有用变量

继续编写main()函数,创建一个planet对象,来自Planet类➊,然后将其放入一个精灵组中➋。记住,在第十三章中提到,pygame使用名为groups的容器来管理精灵。

接下来,实例化一个卫星对象,将Satellite类的初始化方法与background对象传递➌。卫星需要background来绘制它的轨迹。

创建卫星后,将其放入自己的精灵组中 ➍。通常,你应将截然不同的精灵类型放在自己的容器中。这使得管理显示顺序和碰撞处理变得更容易。

现在,分配一些变量来帮助计算偏心率。开始一个空列表,用来存储每个游戏循环中计算出的距离值➎,然后将eccentricity变量设置为占位符值1➏,表示一个非圆形的起始轨道。

你需要定期更新eccentricity变量,以评估玩家对轨道所做的任何更改。记住,你需要轨道的远地点和近地点来计算偏心率,对于大的椭圆轨道,可能需要一段时间来实际采样这些值。好消息是,你只需要考虑“获胜”轨道在 69 到 120 英里之间。因此,你可以优化 120 英里以下轨道的采样率,通常卫星精灵完成这些轨道所需时间不到 6 秒。使用 5 秒并将该值分配给eccentricity_calc_interval变量➐。这意味着,对于高于 120 英里的轨道,计算出的偏心率可能不完全正确,但考虑到轨道在该高度不符合获胜条件,它足够准确。

接下来处理时间管理。使用一个clock变量来保存pygame的游戏时钟,它将控制游戏的速度,单位是每秒帧数 ➑。每一帧代表时钟的一个刻度。将名为fps的变量赋值为30,这意味着游戏每秒更新 30 次。接下来,赋值一个tick_count变量,用于判断何时清除介绍文本,以及何时调用calc_eccentricity()函数。

在这一部分结束时,命名一个变量来启用映射功能,并将其设置为False ➒。如果玩家达到获胜条件,你将把它改为True

启动游戏循环并播放声音

列表 14-17,仍然在main()函数中,启动了游戏时钟和while循环,也称为游戏循环。它还会接收事件,例如玩家使用方向键发射推进器。如果玩家发射推进器,则播放 Ogg Vorbis 音频文件,玩家会听到令人满意的嘶嘶声。

mars_orbiter.py,第十七部分

    ➊ running = True
       while running:
        ➋ clock.tick(fps)
           tick_count += 1
        ➌ dist_list.append(sat.distance)

           # get keyboard input
        ➍ for event in pg.event.get():
            ➎ if event.type == pg.QUIT:  # close window
                   running = False
            ➏ elif event.type == pg.KEYDOWN and event.key == pg.K_ESCAPE:
                   screen = pg.display.set_mode((800, 645))  # exit full screen
            ➐ elif event.type == pg.KEYDOWN and event.key == pg.K_SPACE:
                   background.fill(BLACK)  # clear path
            ➑ elif event.type == pg.KEYUP:
                ➒ sat.thrust.stop()  # stop sound
                   mapping_off(planet)  # turn off moisture map view
            ➓ elif mapping_enabled:
                   if event.type == pg.KEYDOWN and event.key == pg.K_m:
                       mapping_on(planet)

列表 14-17:在 main() 中启动游戏循环,获取事件并播放声音

首先为while循环分配一个running变量,用来控制运行游戏的状态 ➊,然后开始循环。使用时钟的tick()方法设置游戏速度,并传入你在前一个列表中命名的fps变量 ➋。如果游戏感觉很慢,可以将速度设置为 40 fps。对于每一帧—即每一轮循环—通过时钟递增计数器 1。

接下来,将卫星对象的sat.distance值添加到dist_list ➌。这个值表示卫星与行星之间的距离,每个游戏循环通过卫星的locate()方法进行计算。

现在,收集玩家通过键盘输入的数据 ➍。正如前一章所述,pygame会记录每一次用户交互—称为事件—并保存在事件缓冲区中。event.get()方法创建一个事件列表,你可以使用if语句对其进行评估。在开始时,检查玩家是否关闭了窗口以退出游戏 ➎。如果为True,将running设置为False以结束游戏循环。

如果玩家按下 ESC 键,则表示退出全屏模式,因此使用在main()开始时调用的display.set_mode()方法将屏幕大小重置为 800 × 645 像素 ➏。如果玩家按下空格键,将背景填充为黑色,这样可以擦除卫星的白色轨道路径 ➐。

当玩家按下方向键时,卫星对象会播放嘶嘶声,但check_keys()方法中并没有任何指示它停止播放的内容。因此,传递pygame任何KEYUP事件 ➑;当pygame检测到玩家释放方向键时,调用stop()方法来停止thrust播放声音 ➒。

为了绘制火星,玩家必须按住 M 键,因此使用相同的KEYUP事件来调用mapping_off()函数。这将把行星图像重置为正常的未映射状态。

最后,检查mapping_enabled变量是否为True,意味着玩家已经达成胜利条件并准备绘制火星 ➓。如果玩家按下 M 键,调用mapping_on()函数以显示土壤湿度覆盖层,替代行星的常规视图。

应用重力、计算偏心率并处理失败情况

清单 14-18 通过对卫星施加重力并计算其轨道的偏心率,继续执行main()函数的while循环。偏心率值将决定轨道是否为圆形,这是游戏的一个胜利条件。该清单还绘制背景并响应燃料耗尽或在大气层中烧毁的失败条件。

mars_orbiter.py,第十八部分

           # get heading & distance to planet & apply gravity
        ➊ sat.locate(planet)
           planet.gravity(sat)

           # calculate orbital eccentricity
        ➋ if tick_count % (eccentricity_calc_interval * fps) == 0:
               eccentricity = calc_eccentricity(dist_list)
            ➌ dist_list = []

           # re-blit background for drawing command - prevents clearing path
        ➍ screen.blit(background, (0, 0))

           # Fuel/Altitude fail conditions
        ➎ if sat.fuel <= 0:
            ➏ instruct_label(screen, ['Fuel Depleted!'], RED, 340, 195)
               sat.fuel = 0
               sat.dx = 2
        ➐ elif sat.distance <= 68:
               instruct_label(screen, ['Atmospheric Entry!'], RED, 320, 195)
               sat.dx = 0
               sat.dy = 0

清单 14-18:应用重力,计算偏心率并处理失败条件

调用卫星的locate()方法,并将planet对象作为参数传递 ➊。该方法计算指向火星的航向和距离,您可以使用这些信息来调整天线、计算轨道偏心率并应用重力。然后,为了施加重力,调用行星的gravity()方法并将卫星对象传递给它。

如果tick_counteccentricity_calc_interval * fps的模运算结果为0 ➋,调用计算偏心率的函数,并将dist_list变量传递给它。然后,将dist_list变量重置为0,以重新开始距离采样 ➌。

接下来,调用屏幕的blit()方法,并将背景和左上角的坐标传递给它 ➍。此语句的位置很重要。例如,如果将它移到更新精灵的代码之后,您将无法在游戏屏幕上看到卫星或火星。

现在,处理玩家在未实现圆形轨道之前燃料耗尽的情况。首先,从卫星对象的fuel属性获取当前燃料水平 ➎。如果燃料水平为0或以下,使用instruct_label()函数宣布燃料已耗尽 ➏,然后将卫星的dx属性设置为2。这将使卫星精灵快速飞出屏幕并进入太空深处,海拔读数会越来越大。尽管不太现实,这可以确保玩家知道他们已经失败!

最后一个失败案例是当玩家在大气层中烧毁时。如果卫星的distance属性小于或等于68 ➐,在屏幕中央附近显示一个标签,告知玩家他们已经进入大气层,然后将卫星的速度属性设置为0。这将使重力将精灵锁定在行星上(图 14-20)。此外,当dxdy0时,卫星的update()方法(清单 14-7)将把卫星的图像切换为红色的“坠毁”版本。

image

图 14-20:卫星在坠毁配置下

高度参数有点像作弊,因为高度等同于distance属性,它是从行星和卫星精灵的中心测量的,而不是从行星的表面到卫星的距离。这归结于比例问题。行星的大气层是非常薄的薄层——在游戏的比例下,火星的大气层厚度不到 2 像素!根据游戏设计,当卫星天线的尖端擦到行星时,卫星会燃烧,但由于卫星精灵的大小不现实地大,因此精灵的 68 英里的中心点必须推得更远。

奖励成功、更新和绘制精灵

清单 14-19,仍在main()函数的while循环中,通过启用功能让获胜玩家能够映射火星土壤的湿度。在现实生活中,这可能通过雷达或微波共振器来完成,它们可以远程测量裸土中的湿度,深度可以达到几英寸。该清单还会更新行星和卫星精灵,并将它们绘制到屏幕上。

mars_orbiter.py, 第十九部分

           # enable mapping functionality
        ➊ if eccentricity < 0.05 and sat.distance >= 69 and sat.distance <= 120:
            ➋ map_instruct = ['Press & hold M to map soil moisture']
               instruct_label(screen, map_instruct, LT_BLUE, 250, 175)
            ➌ mapping_enabled = True
           else:
               mapping_enabled = False

        ➍ planet_sprite.update()
        ➎ planet_sprite.draw(screen)
           sat_sprite.update()
           sat_sprite.draw(screen)

清单 14-19:启用映射功能并在游戏循环中更新精灵

如果轨道是圆形并且满足高度要求➊,显示一条信息,提示玩家按 M 键映射土壤湿度➋。将文本放在括号中,因为instruct_label()函数期望的是一个列表。将文本颜色设置为浅蓝色,并将其放置在屏幕中央附近。

接下来,将mapping_enabled变量设置为True ➌;否则,如果轨道偏离目标参数,则将其设置为False

最后,通过精灵组➍调用行星精灵的update()方法,然后实际将其绘制到屏幕上➎。draw()方法的参数是screen,即绘制精灵的对象。对卫星精灵重复这些步骤。

显示指令和遥测并投射阴影

清单 14-20 通过显示指令、数据读取和行星的阴影来完成while循环和main()函数。游戏介绍文本只会在启动时短暂显示。

mars_orbiter.py, 第二十部分

           # display intro text for 15 seconds
        ➊ if pg.time.get_ticks() <= 15000:  # time in milliseconds
               instruct_label(screen, intro_text, GREEN, 145, 100)

           # display telemetry and instructions
        ➋ box_label(screen, 'Dx', (70, 20, 75, 20))
           box_label(screen, 'Dy', (150, 20, 80, 20))
           box_label(screen, 'Altitude', (240, 20, 160, 20))
           box_label(screen, 'Fuel', (410, 20, 160, 20))
           box_label(screen, 'Eccentricity', (580, 20, 150, 20))

        ➌ box_label(screen, '{:.1f}'.format(sat.dx), (70, 50, 75, 20))
           box_label(screen, '{:.1f}'.format(sat.dy), (150, 50, 80, 20))
           box_label(screen, '{:.1f}'.format(sat.distance), (240, 50, 160, 20))
           box_label(screen, '{}'.format(sat.fuel), (410, 50, 160, 20))
           box_label(screen, '{:.8f}'.format(eccentricity), (580, 50, 150, 20))

        ➍ instruct_label(screen, instruct_text1, WHITE, 10, 575)
           instruct_label(screen, instruct_text2, WHITE, 570, 510)

           # add terminator & border
        ➎ cast_shadow(screen)
        ➏ pg.draw.rect(screen, WHITE, (1, 1, 798, 643), 1)

        ➐ pg.display.flip()

➑ if __name__ == "__main__":
       main()

清单 14-20:显示文本和行星阴影,并调用 main() 函数

概述游戏的文本应该悬停在屏幕中央,足够时间让玩家阅读,然后消失。使用if语句和pygametick.get_ticks()方法来控制,后者返回自游戏开始以来经过的毫秒数。如果过去的时间少于 15 秒,则使用instruct_label()函数以绿色显示清单 14-15 中的文本列表。

接下来,制作数据读数的仪表,从头部框开始。使用box_label()函数,并为每一个五个读数仪表调用它 ➋。对数据读数 ➌ 重复此操作。请注意,当你将文本传递给函数时,可以使用字符串格式方法。

使用instruct_label()函数将清单 14-15 中制作的指令放置在屏幕的底角 ➍。如果你想区分描述获胜条件的指令和定义关键功能的指令,可以自由更改文本颜色。

现在,调用显示行星阴影的函数 ➎,然后作为最后的修饰,使用pygamedraw.rect()方法添加一个边框 ➏。传递给它screen对象、边框颜色、角落坐标和线宽。

完成main()函数及其游戏循环,通过翻转显示器 ➐。正如前一章所描述的,flip()方法将所有内容从屏幕对象复制到可视显示器上。

最后,在全局空间中调用main(),使用标准语法使其独立运行或作为模块 ➑。

总结

在本章中,你使用pygame构建了一个 2D 街机风格的游戏,包含图像精灵、音效和键盘游戏控制。你还创造了一种有趣的启发式方法来学习轨道力学。在《游戏中的天体动力学》一节中展示的所有技术都应该适用于此游戏。在接下来的“挑战项目”部分,你可以继续改进游戏和玩家体验。

挑战项目

通过根据以下建议改进火星轨道器游戏并添加新挑战,打造属于你自己的游戏。和往常一样,挑战项目没有提供解决方案。

游戏标题画面

复制并编辑mars_orbiter.py程序,使得标题画面在主游戏画面之前短暂出现。让标题画面显示一个类似于火星全球探测器的 NASA 任务徽章(参见图 14-21),但要使其在游戏中独特于火星轨道器。你可以在space.jpl.nasa.gov/art/patches.html查看一些其他彩色的 NASA 徽章。

image

图 14-21: 火星全球探测器 任务徽章

智能仪表

复制并编辑mars_orbiter.py程序,使得当高度和偏心率读数超出目标范围时,使用红色背景或红色文本颜色。注意:圆形偏心率值应该保持红色,直到高度值处于范围内!

无线电黑障

复制并编辑mars_orbiter.py程序,使得当卫星位于shadow矩形区域内时,键盘控制被锁定。

得分系统

复制并编辑 mars_orbiter.py 程序,使其能够对玩家进行评分,并保持最佳成绩在可显示的高分榜中。最高得分会奖励那些达到最低允许轨道同时使用最少燃料并在最短时间内完成任务的玩家。例如,得分中的燃料部分可以是剩余燃料的数量;轨道部分可以是最大允许高度(120)减去圆形轨道的高度;时间部分可以是达到圆形轨道所花时间的倒数乘以 1,000。将三个部分加在一起,得到最终得分。

策略指南

复制并编辑 mars_orbiter.py 程序,使其包含一个弹出式策略指南或帮助文件,可以通过加入“为玩家设计的天体动力学”中的一些图像来实现,见第 286 页。例如,在说明中添加一行,告诉玩家按住 H 键以获得帮助。这样可以显示并循环显示不同轨道机动的图像,如霍曼转移轨道或单切点烧制。务必包含对每种技术的优缺点的评论,并在指南打开时暂停游戏。

气动刹车

气动刹车是一种节省燃料的技术,通过大气摩擦减缓航天器的速度(图 14-22)。复制并编辑 mars_orbiter.py 程序以包含气动刹车。在 main() 函数中,将最低获胜高度设置为 70 英里,最低安全高度设置为 60 英里。如果卫星的高度在 60 到 70 英里之间,减少其速度。

image

图 14-22:使用大气层代替逆行烧制来圆化轨道

图 14-23 是游戏中使用气动刹车来圆化椭圆轨道的示例。大气层的顶部设置为 80 英里。气动刹车起到了类似于在近日点进行逆行烧制的作用,但你必须小心且有耐心,先将轨道从大气层中抬升,再将其圆化。

image

图 14-23:使用气动刹车圆化轨道。注意低燃料消耗。

NASA 使用了类似的技术将 Mars Global Surveyor 从其椭圆捕获轨道移动到最终的绘图轨道。这个过程花费了几个月的时间,因为他们需要保护航天器免于在大气中过热。

入侵警报!

复制并编辑 mars_orbiter.py 程序,创建一个新的 planet 对象,并让它在屏幕上飞行,通过引力干扰卫星的轨道。创建一个新的精灵来表示彗星或小行星,并以随机间隔发射它(但不要发射得 频繁!)。不要将火星的 gravity() 方法应用于该对象,以免它进入火星轨道,而是将新对象的 gravity() 方法应用于卫星。调整新对象的质量,使其能明显扰动卫星的轨道,扰动范围大约为 100 像素左右。允许该对象经过火星或卫星而不发生碰撞。

过顶

当前,火星轨道器使用的是 赤道 轨道。这是为了简化编码,因为只需要旋转一个火星图像。但真实的映射轨道使用极轨道——即垂直于赤道轨道的轨道——并经过行星的两极(见 图 14-24)。随着行星在轨道下方旋转,卫星能够映射其整个表面。而在赤道轨道上,由于行星表面的曲率,高纬度区域基本无法映射(见 图 14-24 中的虚线)。

image

图 14-24:极轨与赤道轨道;赤道轨道的假想北极和南极映射边界由虚线表示。

复制并编辑 mars_orbiter.py 程序,使卫星沿极轨道运行。这只需要更换火星图像。但你不能再使用单一的俯视图像;视角需要垂直于行星的自转轴。视频示例见 youtu.be/IP2SDbhFbXk;火星动画 gif 示例见 gph.is/2caBKKS。你不能直接在 pygame 中使用动画 gif,但可以将它们分解并使用单独的帧。在线可以找到分解帧的工具,在下一章中,你将使用这些工具从视频中提取图像。

第十五章:通过行星堆叠提高你的天文摄影水平

image

如果你曾经通过望远镜观察过木星、火星或土星,你可能会有些失望。行星看起来很小,几乎没有什么细节。你想要放大并提高倍率,但它没有效果。大于 200 倍的放大倍率往往会变得模糊。

问题在于空气湍流,或者天文学家所说的视像。即使在晴朗的夜晚,空气也在不断运动,热气流的上升和下降很容易模糊代表天体的光点。然而,随着 20 世纪 80 年代电荷耦合器件(CCD)的商业化,天文学家找到了解决湍流的方法。数字摄影允许一种叫做图像堆叠的技术,其中许多照片——有些好,有些差——被平均或堆叠成一张单独的图像。通过足够多的照片,持久不变的特征(如行星表面)会主导瞬时特征(如流动的云层)。这使得天文摄影师能够提高放大限制,并补偿不理想的观测条件。

在本章中,你将使用一个名为pillow的第三方 Python 模块来堆叠数百张木星的图像。最终结果将是一个具有比任何单个图像更高信噪比的图像。你还将处理与 Python 代码所在不同文件夹中的文件,并使用 Python 的操作系统(os)和 shell 实用工具(shutil)模块来操作文件和文件夹。

项目#23:堆叠木星

木星是一颗大而明亮、色彩斑斓的气体巨行星,是天文摄影师的最爱目标。即使是业余望远镜也能看到它的橙色条纹,这些条纹是由线性云带形成的,还有大红斑,一个椭圆形的风暴,其大小足以吞没地球(见图 15-1)。

image

图 15-1:卡西尼太空探测器拍摄的木星照片

木星是研究图像堆叠的绝佳对象。它的线性云带和大红斑为眼睛提供了校准点,用于判断边缘定义和清晰度的改进,而且它相对较大的尺寸使得噪声容易被察觉。

噪声表现为“颗粒感”。每个颜色带都有自己的伪影,导致图像上出现彩色斑点。噪声的主要来源是相机(电子读出噪声和热信号)以及来自光本身的光子噪声,因为随着时间的推移,光子以可变的数量撞击传感器。幸运的是,噪声伪影是随机的,可以通过堆叠图像大部分被消除。

目标

编写程序裁剪、缩放、堆叠并增强图像,以创建更清晰的木星照片。

pillow 模块

要处理图像,你需要一个免费的第三方 Python 模块,名为 pillow。它是 Python Imaging Library (PIL) 的继任项目,后者已于 2011 年停用。pillow 模块是从 PIL 仓库“分支”出来的,并将代码升级到 Python 3。

你可以在 Windows、macOS 和 Linux 上使用 pillow,它支持多种图像格式,包括 PNG、JPEG、GIF、BMP 和 TIFF。它提供了标准的图像处理功能,如改变单个像素、遮罩、处理透明度、过滤和增强以及添加文本。但 pillow 的真正优势在于它能够轻松编辑大量图像。

使用 pip 工具安装 pillow 非常简单(有关 pip 的更多信息,请参见 “使用 python-docx 操作 Word 文档” 第 110 页)。在命令行中输入 pip install pillow 即可。

大多数主要的 Linux 发行版将 pillow 包含在之前包含 PIL 的软件包中,因此你可能已经在系统上安装了 pillow。无论你使用什么平台,如果 PIL 已经安装,你需要在安装 pillow 之前先卸载它。有关安装说明,请参见 pillow.readthedocs.io/en/latest/installation.html

处理文件和文件夹

在本书前面的所有项目中,你都将支持文件和模块与 Python 代码放在同一文件夹中。这对于简单项目来说很方便,但对于广泛使用来说不太现实,尤其是在你需要处理本项目中生成的数百个图像文件时。幸运的是,Python 附带了几个可以帮助处理这个问题的模块,比如 osshutil。但首先,我将简要讨论一下目录路径。

目录路径

目录路径是指向文件或文件夹的地址。它以根目录开始,在 Windows 中根目录用字母(例如 C:*)表示,而在 Unix 系统中则用正斜杠 (/*) 表示。Windows 中的其他驱动器会被分配不同的字母,而 macOS 中的驱动器位于 /volume 下,Unix 中的驱动器则位于 /mnt(即“挂载”)。

注意

在本章的示例中,我使用的是 Windows 操作系统,但你也可以在 macOS 和其他系统上实现相同的结果。正如常见的做法,我在这里将目录文件夹互换使用。

路径名称的显示方式取决于操作系统。Windows 使用反斜杠 (*) 来分隔文件夹,而 macOS 和 Unix 系统使用正斜杠 (/*)。此外,在 Unix 系统中,文件夹和文件名是区分大小写的。

如果你在 Windows 上编写程序并输入带有反斜杠的路径名,其他平台将无法识别这些路径。幸运的是,os.path.join() 方法会自动确保你的路径名适用于 Python 正在运行的操作系统。我们来看看这个以及其他示例,见 Listing 15-1。

➊ >>> import os
➋ >>> os.getcwd()
   'C:\\Python35\\Lib\\idlelib'
➌ >>> os.chdir('C:\\Python35\\Python 3 Stuff')
   >>> os.getcwd()
   'C:\\Python35\\Python 3 Stuff'
➍ >>> os.chdir(r'C:\Python35\Python 3 Stuff\Planet Stacking')
   >>> os.getcwd()
➎ 'C:\\Python35\\Python 3 Stuff\\Planet Stacking'
➏ >>> os.path.join('Planet Stacking', 'stack_8', '8file262.jpg')
   'Planet Stacking\\stack_8\\8file262.jpg'
➐ >>> os.path.normpath('C:/Python35/Python 3 Stuff')
   'C:\\Python35\\Python 3 Stuff'
➑ >>> os.chdir('C:/Python35')
   >>> os.getcwd()
   'C:\\Python35'

列表 15-1:使用 os 模块操作 Windows 路径名

导入 os 模块以访问操作系统相关功能 ➊ 后,获取 当前工作目录cwd) ➋。cwd 在进程启动时被分配;也就是说,当你从 shell 运行脚本时,shell 和脚本的 cwd 会是相同的。对于 Python 程序,cwd 是包含该程序的文件夹。当你获取 cwd 时,你会看到完整路径。请注意,你必须使用额外的反斜杠来转义作为文件分隔符使用的反斜杠字符。

接下来,使用 os.chdir() 方法 ➌ 更改 cwd,传递给它包含双反斜杠的完整路径。然后,再次获取 cwd,以查看新的路径。

如果你不想输入双反斜杠,可以在路径名参数字符串前加一个 r,将其转换为 原始字符串 ➍。原始字符串使用不同的规则来处理反斜杠转义序列,但即使是原始字符串也不能以单个反斜杠结尾。路径仍然会以双反斜杠显示 ➎。

如果你希望你的程序与所有操作系统兼容,请使用 os.path.join() 方法,并传递文件夹名和文件名,不需要分隔符字符 ➏。os.path 方法会根据你使用的系统返回正确的分隔符。这使得文件名和文件夹名的操作不依赖于平台。

os.path.normpath() 方法会根据您使用的系统修正分隔符➐。在显示的 Windows 示例中,不正确的 Unix 类型分隔符会被反斜杠替代。原生 Windows 也支持使用正斜杠,并会自动进行转换➑。

完整的目录路径—从根目录开始—被称为 绝对路径。你可以使用快捷方式,称为 相对路径,使得操作目录更为简便。相对路径是从当前工作目录的角度进行解释的。绝对路径以正斜杠或驱动器标签开始,而相对路径则不以此开始。在下面的代码片段中,你可以在不输入绝对路径的情况下改变目录—Python 能够识别新的位置,因为它是在 cwd 内部。在幕后,相对路径会与指向 cwd 的路径连接,从而生成一个完整的绝对路径。

>>> os.getcwd()
'C:\\Python35\\Python 3 Stuff'
>>> os.chdir('Planet Stacking')
>>> os.getcwd()
'C:\\Python35\\Python 3 Stuff\\Planet Stacking'

你可以通过使用点(.)和双点(..)来标识文件夹并减少输入。例如,在 Windows 中,.\ 指代 cwd,..\ 指代包含 cwd 的父目录。你还可以使用点来获取 cwd 的绝对路径:

>>> os.path.abspath('.')
'C:\\Python35\\Python 3 Stuff\\Planet Stacking\\for_book'

点文件夹可以在 Windows、macOS 和 Linux 中使用。有关 os 模块的更多信息,请参见 docs.python.org/3/library/os.html

Shell 工具模块

shutil模块提供了用于处理文件和文件夹的高级功能,如复制、移动、重命名和删除。由于它是 Python 标准库的一部分,你可以通过输入 import shutil 来加载shutil。在本章的代码段中,你会看到该模块的示例用法。同时,你可以在docs.python.org/3.7/library/shutil.html找到该模块的文档。

视频

Brooks Clark 在美国德克萨斯州休斯顿的一个有风的夜晚录制了用于此项目的木星彩色视频。该视频为 101 MB 的.mov文件,时长约为 16 秒。

视频长度故意设得较短。木星的自转周期约为 10 小时,这意味着即使只有一分钟的曝光时间,静态照片也可能出现模糊,而你想通过堆叠视频帧来强化的特征可能会改变位置,极大地复杂化了这一过程。

为了将视频帧转换为单独的图像,我使用了由 DVDVideoSoft 开发的免费多媒体程序集 Free Studio。Free Video to JPG Converter 工具允许在恒定时间或帧间隔下捕捉图像。我将间隔设置为跨越整个视频长度采样帧,以提高在空气平稳、可见度良好时捕捉到图像的机会。

几百张图像应该足以进行堆叠并显示显著的改进。在这种情况下,我捕捉了 256 帧。

你可以在www.nostarch.com/impracticalpython/在线找到名为video_frames的图像文件夹,位于本书资源中。下载该文件夹并保留其名称。

视频中的一帧示例,采用灰度显示,见图 15-2。木星的云带模糊不清,大红斑不明显,且图像对比度较低,这是放大常见的副作用。噪点伪影还让木星呈现颗粒状外观。

image

图 15-2:木星视频中的一帧示例

除了这些问题,风还晃动了相机,不精确的追踪导致行星向左侧框架偏移。你可以在图 15-3 中看到横向漂移的示例,我已将五个随机选择的帧叠加在一起,黑色背景设置为透明。

image

图 15-3:基于五个随机选择的帧,展示木星视频中的抖动和漂移示例

移动未必是坏事,因为调整图像的位置可以平滑与 CCD 传感器表面、镜头或传感器上的灰尘等相关的缺陷。但图像叠加的关键假设是,图像必须完全对齐,以便像木星的云带这样的持久特征在平均图像时相互增强。为了获得高信噪比,图像必须经过配准。

图像配准是将数据转换到相同坐标系的过程,以便可以进行比较和整合。配准无疑是图像叠加中最难的部分。天文学家通常使用商业软件——如 RegiStax、RegiStar、Deep Sky Stacker 或 CCDStack——来帮助他们对齐和叠加天文照片。然而,你将亲自动手,使用 Python 来完成这一过程。

策略

叠加图像所需的步骤如下(第一个步骤已经完成):

  1. 从视频录制中提取图像。

  2. 裁剪围绕木星的图像。

  3. 将裁剪后的图像调整为相同的大小。

  4. 将图像叠加成一张图像。

  5. 增强并过滤最终图像。

代码

你可以将所有步骤整合到一个程序中,但我选择将它们分布到三个程序中。这是因为你通常希望在过程中停下来检查结果,此外你可能还希望运行后续的处理步骤,比如增强,而不必完全重新运行整个工作流。第一个程序将裁剪和缩放图像,第二个程序将叠加图像,第三个程序将增强图像。

裁剪和缩放代码

首先,你需要对图像进行配准。对于像月亮和木星这样的大型明亮物体,天文摄影中的一种方法是裁剪每张图像,使其四个边界与天体的表面相切。这将去除大部分天空区域,并缓解任何抖动和漂移问题。对裁剪后的图像进行缩放,将确保它们的大小一致,并稍微平滑它们以减少噪声。

你可以从 www.nostarch.com/impracticalpython/ 下载 crop_n_scale_images.py。将其保存在包含捕获的视频帧文件夹的目录中。

导入模块并定义 main() 函数

列表 15-2 执行了模块导入并定义了运行 crop_n_scale_images.py 程序的 main() 函数。

crop_n_scale_images.py, 第一部分

➊ import os
   import sys
➋ import shutil
➌ from PIL import Image, ImageOps

   def main():
       """Get starting folder, copy folder, run crop function, & clean folder."""
       # get name of folder in cwd with original video images
    ➍ frames_folder = 'video_frames'

       # prepare files & folders
    ➎ del_folders('cropped')
    ➏ shutil.copytree(frames_folder, 'cropped')

       # run cropping function
       print("start cropping and scaling...")
    ➐ os.chdir('cropped')
       crop_images()
    ➑ clean_folder(prefix_to_save='cropped')  # delete uncropped originals

       print("Done! \n")

列表 15-2:导入模块并定义 main() 函数

首先导入操作系统(os)和系统(sys)模块 ➊。os模块已包含了对sys的导入,但这个功能可能在未来会取消,因此最好自己手动导入sysshutil模块包含了前面描述的 shell 实用工具 ➋。在图像库中,你将使用Image来加载、裁剪、转换和过滤图像;还将使用ImageOps来缩放图像 ➌。请注意,在import语句中必须使用 PIL,而不是 pillow

启动 main() 函数,并将起始文件夹的名称赋值给 frames_folder 变量 ➍。该文件夹包含从视频中捕获的所有原始图片。

你将把裁剪后的图片存储在一个名为 cropped 的新文件夹中,但如果该文件夹已存在,shell 工具不会创建它,因此请调用稍后编写的 del_folders() 函数 ➎。如上所述,如果文件夹不存在,该函数不会抛出错误,因此可以在任何时候安全运行。

你应该始终在原始图片的副本上进行操作,因此使用 shutil.copytree() 方法将包含原始图片的文件夹复制到一个名为 cropped 的新文件夹中 ➏。现在,切换到此文件夹 ➐ 并调用 crop_images() 函数,该函数将裁剪并缩放图片。然后调用 clean_folder() 函数,它会删除仍然存在于 cropped 文件夹中的原始视频帧 ➑。请注意,在将参数传递给 clean_folder() 函数时使用参数名,因为这使得函数的目的更加明确。

打印 Done! 以便在程序完成时通知用户。

删除和清理文件夹

清单 15-3 定义了用于删除文件和文件夹的辅助函数,这些函数位于 crop_n_scale_images.py 中。shutil 模块如果目标目录中已经有一个同名的文件夹,将拒绝创建新的文件夹。如果你想多次运行该程序,首先必须删除或重命名现有的文件夹。程序还会在裁剪图片后重命名它们,在开始叠加这些图片之前,你需要删除原始图片。由于将会有数百个图像文件,这些函数将自动化本来繁琐的任务。

crop_n_scale_images.py,第二部分

➊ def del_folders(name):
       """If a folder with a named prefix exists in directory, delete it."""
    ➋ contents = os.listdir()
    ➌ for item in contents:
        ➍ if os.path.isdir(item) and item.startswith(name):
            ➎ shutil.rmtree(item)

➏ def clean_folder(prefix_to_save):
       """Delete all files in folder except those with a named prefix."""
    ➐ files = os.listdir()
       for file in files:
        ➑ if not file.startswith(prefix_to_save):
            ➒ os.remove(file)

清单 15-3:定义用于删除文件夹和文件的函数

定义一个名为 del_folders() 的函数来删除文件夹 ➊。唯一的参数将是你想要删除的文件夹名称。

接下来,列出文件夹的内容 ➋,然后开始循环遍历内容 ➌。若函数遇到一个以文件夹名称开头并且也是一个目录的项 ➍,则使用 shutil.rmtree() 删除该文件夹 ➎。正如你稍后会看到的,删除文件夹与删除文件的方法是不同的。

注意

使用 rmtree() 方法时要始终小心,因为它会 永久 删除文件夹及其内容。你可能会删除系统中的大部分内容,丢失与 Python 项目无关的重要文档,并可能破坏你的计算机!

现在,定义一个辅助函数来“清理”文件夹,并传递一个不想删除的文件名 ➏。一开始这可能有点反直觉,但因为你只想保留最后一批已处理的图片,所以不必显式列出文件夹中的任何其他文件。如果文件名没有以你提供的前缀(例如 cropped)开头,那么它们将被自动删除。

这个过程类似于上一个函数。列出文件夹内容 ➐,并开始遍历该列表。如果文件名没有以你提供的前缀开头 ➑,则使用os.remove()删除该文件 ➒。

裁剪、缩放和保存图像

Listing 15-4 通过在木星周围拟合一个框并裁剪图像来注册从视频中捕获的帧(见图 15-4)。这种技术在明亮的图像和黑色背景的场景中效果很好(另见进一步阅读,在第 343 页中有其他示例)。

image

图 15-4: 将原始视频帧裁剪到木星周围以对齐图像

通过将图像紧密裁剪在木星周围,你可以解决所有漂移和抖动问题。

每个裁剪后的图像还会被缩放到更大且一致的大小,并稍微平滑以减少噪声。裁剪和缩放后的图像将保存在它们自己的文件夹中,这个文件夹将在之后由main()函数创建。

crop_n_scale_images.py, 第三部分

➊ def crop_images():
       """Crop and scale images of a planet to box around planet."""
    ➋ files = os.listdir()
    ➌ for file_num, file in enumerate(files, start=1):
        ➍ with Image.open(file) as img:
            ➎ gray = img.convert('L')
            ➏ bw = gray.point(lambda x: 0 if x < 90 else 255)
            ➐ box = bw.getbbox()
               padded_box = (box[0]-20, box[1]-20, box[2]+20, box[3]+20)
            ➑ cropped = img.crop(padded_box)
               scaled = ImageOps.fit(cropped, (860, 860),
                                     Image.LANCZOS, 0, (0.5, 0.5))
               file_name = 'cropped_{}.jpg'.format(file_num)
            ➒ scaled.save(file_name, "JPEG")

   if __name__ == '__main__':
       main()

Listing 15-4: 裁剪初始视频帧至围绕木星的框,并重新缩放

crop_images()函数不接受任何参数 ➊,但最终将处理一个名为cropped的副本,该副本包含原始视频帧所在的文件夹。你在调用此函数之前已经在main()函数中创建了这个副本。

通过列出当前(cropped)文件夹的内容 ➋ 来开始该函数。程序会按顺序为每张图像编号,因此使用enumerate()for循环,并将start选项设置为1 ➌。如果你之前没有使用过enumerate(),它是一个非常方便的内建函数,作为自动计数器;计数值将被分配给file_num变量。

接下来,命名一个变量img来存放图像,并使用open()方法打开文件 ➍。

为了将边界框的边界适应木星,你需要将图像中所有非木星部分的像素设为黑色(0, 0, 0)。不幸的是,木星之外有一些噪声相关的黑色以外的像素,并且木星的边缘是模糊和渐变的。这些问题会导致边界框形状不规则,如图 15-5 所示。幸运的是,你可以通过将图像转换为黑白图像轻松解决这些问题。然后,你可以使用该转换后的图像来确定每张彩色照片的正确框尺寸。

image

图 15-5: 由于定义边界框尺寸的问题,裁剪后的图像大小不规则

为了消除破坏边界框技术的噪声影响,将加载的图像转换为“L”模式——由 8 位黑白像素组成——并将该变量命名为gray,表示灰度图 ➎。使用这种模式时,图像只有一个通道(与 RGB 彩色图像的三个通道不同),因此在进行阈值化时,你只需要决定一个单一的值——即设定一个阈值,超过或低于该值时触发某个操作。

为一个新变量bw赋值,用来保存真正的黑白图像 ➏。使用point()方法(用于更改像素值)和一个 lambda 函数,将任何小于 90 的值设为黑色(0),其他所有值设为白色(255)。阈值是通过反复试验得出的。point()方法现在返回一个干净的图像,适合进行边界框的拟合(见图 15-6)。

image

图 15-6:将原始视频帧之一转换为纯黑白的屏幕截图

现在,调用Image模块的getbox()方法,作用于bw ➐。此方法通过将边界框拟合到图像的非零区域来修剪掉黑色边框。它返回一个元组,包含边框的左、上、右、下像素坐标。

如果使用box裁剪视频帧,会得到一个与木星表面相切的图像(见图 15-7 中的中间图像)。这是你想要的效果,但视觉上不够美观。因此,通过赋值一个新的框变量padded_box,并将其四个方向的边缘都扩展 20 像素(见图 15-7 中的最右图像),为图像添加一些黑色填充。由于填充是一致的,并且应用于所有图像,它不会影响裁剪的结果。

image

图 15-7:初始裁剪与木星表面相切(box)和带填充的最终裁剪(padded_box

接下来,通过crop()方法裁剪每张图像 ➑。此方法以padded_box作为参数。

为了缩放图像,使用ImageOps.fit()方法。此方法接受图像、一个像素宽度和高度的元组、一个重采样方法、一个边框(0表示无边框)以及甚至是从中心裁剪,中心由元组(0.5, 0.5)指定。pillow模块提供了多种图像缩放算法,但我选择了流行的Lanczos滤镜。放大图像往往会降低其清晰度,但 Lanczos 可以在强边缘产生振铃伪影;这有助于增强感知清晰度。这种意外的边缘增强可以帮助眼睛集中注意力于那些在原始视频帧中模糊且微弱的特征。

缩放后,赋值一个file_name变量。每个被裁剪的 256 个图像文件名将以cropped_开头,并以图像编号结尾,图像编号会传递给format()方法的替换字段。最后,通过保存文件来结束函数 ➒。

返回到全局作用域,添加让程序可以作为模块或独立运行的代码。

注意

我使用 JPEG 格式保存文件,因为它是通用的、易于读取的,且能够很好地处理色彩渐变,内存占用也非常小。然而,JPEG 使用的是“有损”压缩,这意味着每次保存文件时,图像都会有一点微小的损失;你可以在存储空间的开销下调整压缩的程度。在大多数情况下,处理天文照片时,你会希望使用一种无损格式,例如 TIFF。

在工作流程的这个阶段,你已经将原始视频帧裁剪成围绕木星的框框;然后你将裁剪后的图像缩放到更大且一致的尺寸(图 15-8)。

image

图 15-8:裁剪和缩放后图像的相对大小

在接下来的部分,你将编写代码来堆叠裁剪和缩放后的图像。

堆叠代码

stack_images.py 代码将上一程序生成的图像进行平均,生成一张单一的堆叠图像。你可以从本书的资源网站下载该代码,地址是 www.nostarch.com/impracticalpython/。请将其保存在与 crop_n_scale_images.py 程序相同的文件夹中。

列表 15-5 导入模块,加载图像,创建颜色通道(红色、蓝色、绿色)的列表,平均通道,重新合并通道,并创建并保存最终的堆叠图像。代码足够简单,因此我们不需要使用 main() 函数。

stack_images.py

➊ import os
   from PIL import Image

   print("\nstart stacking images...")

   # list images in directory
➋ os.chdir('cropped')
   images = os.listdir()

   # loop through images and extract RGB channels as separate lists
➌ red_data = []
   green_data = []
   blue_data = []
➍ for image in images:
       with Image.open(image) as img:
           if image == images[0]:  # get size of 1st cropped image
               img_size = img.size  # width-height tuple to use later
        ➎ red_data.append(list(img.getdata(0)))
           green_data.append(list(img.getdata(1)))
           blue_data.append(list(img.getdata(2)))

➏ ave_red = [round(sum(x) / len(red_data)) for x in zip(*red_data)]
   ave_blue = [round(sum(x) / len(blue_data)) for x in zip(*blue_data)]
   ave_green = [round(sum(x) / len(green_data)) for x in zip(*green_data)]

➐ merged_data = [(x) for x in zip(ave_red, ave_green, ave_blue)]
➑ stacked = Image.new('RGB', (img_size))
➒ stacked.putdata(merged_data)
   stacked.show()

➓ os.chdir('..')
   stacked.save('jupiter_stacked.tif', 'TIFF')

列表 15-5:分离并平均颜色通道,然后重新合成成单一图像

首先,重复使用在之前程序中用过的一些导入 ➊。接下来,将当前目录更改为 cropped 文件夹,该文件夹包含木星的裁剪和缩放图像 ➋,并立即使用 os.listdir() 获取文件夹中的图像列表。

使用 pillow,你可以操作单个像素或像素组,并且可以针对单独的颜色通道(如红色、蓝色和绿色)进行操作。为了演示这一点,你将处理单独的颜色通道来堆叠图像。

创建三个空列表来存储 RGB 像素数据 ➌,然后开始遍历图像列表 ➍。首先,打开图像。接着,获取第一张图像的宽度和高度,以像素为单位,作为一个元组。记住,在之前的程序中,你将所有的小裁剪图像缩放到了一个更大的尺寸。稍后你需要这些尺寸来创建新的堆叠图像,size 会自动为你获取这些信息。

现在使用 getdata() 方法获取选定图像的像素数据 ➎。传递方法颜色通道的索引:0 表示红色,1 表示绿色,2 表示蓝色。将结果适当地追加到数据列表中。每张图像的数据将形成数据列表中的一个单独列表。

要对每个列表中的值进行平均,可以使用列表推导将所有图像中的像素求和,并除以图像总数 ➏。注意,你使用了带有解包(*)操作符的zip。例如,red_data列表是一个列表的列表,每个嵌套列表代表一个 256 张图像文件中的数据。使用带有*zip可以解包这些列表的内容,从而使图像 1 中的第一个像素与图像 2 中的第一个像素相加,依此类推。

要合并平均的颜色通道,使用带有zip的列表推导 ➐。接下来,使用Image.new() ➑创建一个新的图像,命名为stacked。将方法的颜色模式('RGB')和包含所需图像宽度和高度的img_size元组传递给它,这个元组之前是从其中一个裁剪过的图像中获得的。

使用putdata()方法填充新的stacked图像,并将merged_data列表传递给它 ➒。此方法将数据从一个序列对象复制到图像中,从左上角 (0, 0) 开始。使用show()方法显示最终图像。最后,切换到父目录并将图像保存为名为jupiter_stacked.tif的 TIFF 文件 ➓。

如果你将其中一个原始视频帧与最终的叠加图像(jupiter_stacked.tif)进行比较,如图 15-9 所示,你会看到边缘定义和信噪比的明显提升。这在颜色上最为明显,因此如果你还没有运行该程序,可以花点时间从网站上下载Figure 15-9.pdf。当图像以彩色显示时,叠加的好处包括更平滑、像“奶油”般的白色条带,更清晰的红色条带,以及更明显的大红斑。然而,仍然有改进的空间,接下来你将编写一个程序来增强最终的叠加图像。

image

图 15-9:一个原始视频帧与最终叠加图像(jupiter_stacked.tif)的对比

注意

如果在叠加图像中大红斑看起来有些粉红,那是因为它确实是!它会时常褪色,许多公开的木星图片由于处理过程夸大了颜色,因此这种微妙的色调常常被忽略。或许这样更好,因为“伟大的粉红斑”听起来总不如“伟大的红斑”那么有气势。

增强代码

你已经成功地叠加了所有视频帧,但木星仍然歪斜,且其特征较为模糊。你可以使用pillow中的滤镜、增强器和变换工具进一步改善叠加图像。随着图像的增强,你会越来越远离“真实”原始数据。出于这个原因,我选择将增强过程隔离到一个单独的程序中。

通常,堆叠后的第一步是增强细节,使用高通滤波器或锐化掩模算法,然后微调亮度、对比度和色彩。代码将利用 pillow 的图像增强功能来执行这些步骤——尽管顺序不同。你可以从 nostarch.com/impracticalpython/ 下载代码文件 enhance_image.py。将其与之前的 Python 程序保存在同一文件夹中。

注意

天文图像的处理可能相当复杂,关于这一主题已经有整本书籍。这个工作流程中省略了一些标准步骤。例如,原始视频没有进行校准,且未纠正由于湍流造成的畸变效应。像 RegiStax 或 AviStack 这样的高级软件可以通过扭曲单独的图像来防止模糊,从而确保像云带边缘这样的扭曲特征在所有图像中正确重叠。

列表 15-6 导入了 pillow 类,并打开、增强并保存了前面代码生成的堆叠图像。由于增强图像有很多可能的选项,尽管程序很小,我还是选择将其模块化。

enhance_image.py

➊ from PIL import Image, ImageFilter, ImageEnhance

➋ def main():
       """Get an image and enhance, show, and save it."""
    ➌ in_file = 'jupiter_stacked.tif'
       img = Image.open(in_file)
    ➍ img_enh = enhance_image(img)
       img_enh.show()
       img_enh.save('enhanced.tif', 'TIFF')

➎ def enhance_image(image):
       """Improve an image using pillow filters & transforms."""
    ➏ enhancer = ImageEnhance.Brightness(image)
    ➐ img_enh = enhancer.enhance(0.75)  # 0.75 looks good

    ➑ enhancer = ImageEnhance.Contrast(img_enh)
       img_enh = enhancer.enhance(1.6)
       enhancer = ImageEnhance.Color(img_enh)
       img_enh = enhancer.enhance(1.7)

    ➒ img_enh = img_enh.rotate(angle=133, expand=True)

    ➓ img_enh = img_enh.filter(ImageFilter.SHARPEN)

       return img_enh

   if __name__ == '__main__':
       main()

列表 15-6:打开图像、增强它并使用新名称保存

导入部分除了最后两个模块 ➊ 外,其他都很常见。这些新模块,ImageFilterImageEnhance,包含了预定义的滤镜和类,可以用来通过模糊、锐化、亮化、平滑等方式改变图像(查看 pillow.readthedocs.io/en/5.1.x/ 以查看每个模块中包含的完整列表)。

首先定义 main() 函数 ➋。将堆叠图像赋值给名为 in_file 的变量,然后传递给 Image.open() 打开该文件 ➌。接下来,调用 enhance_image() 函数并传入图像变量 ➍。显示增强后的图像,然后将其保存为 TIFF 文件,这样图像质量不会退化。

现在,定义一个增强函数 enhance_image(),它将图像作为参数 ➎。用更简单的话来说,pillow 文档中提到,所有增强类都实现了一个公共接口,包含一个名为 enhance(factor) 的方法,该方法返回增强后的图像。factor 参数是一个浮动值,用于控制增强的程度。值为 1.0 时返回原图;较低的值会减弱颜色、亮度、对比度等;较高的值则会增强这些特性。

要改变图像的亮度,首先创建ImageEnhance模块中的Brightness类的实例,并传入原始图像 ➏。模仿pillow文档,将该对象命名为enhancer。为了得到最终的增强图像,你调用该对象的enhance()方法,并传入factor参数 ➐。此时,你将亮度降低了 0.25。行尾的# 0.75注释是一种有用的方式来尝试不同的系数。使用这个注释保存你喜欢的值,这样如果其他测试值没有得到令人满意的结果,你可以记住并恢复它们。

继续增强图像,调整对比度到➑。如果你不想手动调整对比度,可以试试pillow的自动对比度方法。首先,从 PIL 导入ImageOps。然后,将以步骤➑开头的两行代码替换为一行:img_enh = ImageOps.autocontrast(img_enh)

接下来,增加颜色的饱和度。这将帮助让大红斑更加明显。

没有人愿意看一张倾斜的木星图像,所以将图像旋转到一个更“传统”的视角,其中云带水平,大红斑位于右下方。调用Image模块的rotate()方法,并传入一个角度,角度按逆时针方向计算(单位为度),并让它自动扩展输出图像,以确保整个旋转后的图像都能显示完整 ➒。

现在,锐化图像。即使是在高质量的图像上,锐化可能也是必需的,以改善数据转换、调整大小、旋转图像等带来的插值效果。尽管一些天文摄影资源推荐将锐化操作放在前面,但在大多数图像处理工作流程中,锐化是最后一步。这是因为锐化依赖于图像的最终大小(观看距离)以及所使用的媒体。锐化还可能增加噪点伪影,并且是“有损”的操作,可能会删除数据——这通常不希望在进行其他编辑之前发生。

锐化与之前的增强操作略有不同,因为你需要使用ImageFilter类。无需中间步骤,你只需要通过调用图像对象的filter()方法,并传入预定义的SHARPEN滤镜 ➓,就可以用一行代码创建新的图像。pillow模块还提供了其他有助于定义边缘的滤镜,如UnsharpMaskEDGE_ENHANCE,但对于这张图像,效果与SHARPEN几乎没有区别。

最后,通过返回图像并应用代码来运行程序,无论是作为模块还是独立模式。

最终增强后的图像与随机视频帧及最终堆叠图像进行比较,见图 15-10。所有图像都已旋转,以便于比较。

image

图 15-10:一帧随机视频、堆叠 256 帧的结果以及最终增强图像

当你以彩色查看时,最能看到改进。如果你想在运行程序之前看到彩色版本,可以在网站上查看或下载 Figure 15-10.pdf 文件。

注意

如果你熟悉 pillow,你可能知道可以使用 Image.blend() 方法仅通过几行代码堆叠图像。然而,在我看来,结果图像的噪声明显比通过分离和平均各个颜色通道所得到的图像要高,就像你在 stack_images.py 程序中所做的那样。

总结

图 15-10 中的最终图像不会赢得任何奖项,也不会出现在 Sky & Telescope 杂志上,但重点是接受挑战。而且,结果相较于从视频中捕获的单张图像有了显著改进。颜色更加鲜明,云带更加清晰,红斑区更加分明。你还可以看清红斑区下风处的动荡区(参见图 15-1)。

尽管开始时输入较为粗糙,但你成功地完成了图像配准、通过堆叠去除噪声,并使用滤镜和变换增强了最终图像。所有这些步骤都是使用 Python 图像库的免费分支 pillow 完成的。你还通过使用 Python 的 shutilos 模块获得了操作文件和文件夹的经验。

对于更高级的图像处理,你可以使用开源计算机视觉库 OpenCV,通过安装和导入 cv2NumPy 模块来实现。其他选项还包括 matplotlibSciPyNumPy。就像使用 Python 时一样,处理问题总有不止一种方式!

进一步阅读

Python 自动化无聊的事情:完全初学者的实用编程(No Starch Press,2015)由 Al Sweigart 编写,书中包含了多个关于文件、文件夹以及 pillow 库的实用章节。

使用 Python 进行天文学研究的在线资源包括 Python for Astronomers (prappleizer.github.io/)和 Practical Python for Astronomers (python4astronomers.github.io/)。

如果你想了解更多关于 OpenCV-Python 库的内容,可以查看教程 docs.opencv.org/3.4.2/d0/de3/tutorial_py_intro.html。请注意,NumPy 的知识是学习这些教程及编写优化 OpenCV 代码的前提。另一个选择是 SimpleCV,它可以帮助你更容易地入门计算机视觉和图像处理,且学习曲线比 OpenCV 更平缓,但只支持 Python 2。

天文摄影(Rocky Nook,2014)由 Thierry Legault 编写,是任何有意从事严肃天文摄影的人的必备资源。这本书是一本全面且易读的参考书,涵盖了从设备选择到图像处理的各个方面。

“使用 Python 对太阳图像进行对齐”(LabJG,2013),这是 James Gilbert 的一篇博客,包含了使用边界框技术裁剪太阳的代码。它还包括一种巧妙的方法,通过使用太阳黑子作为注册点来重新对齐旋转的太阳图像。你可以在 labjg.wordpress.com/2013/04/01/aligning-sun-images-using-python/ 中找到它。

一个谷歌研究团队找到了如何通过堆叠去除股票摄影网站上图像的水印,以及这些网站如何更好地保护其版权。你可以在 research.googleblog.com/2017/08/making-visible-watermarks-more-effective.html 阅读相关内容。

挑战项目:消失的艺术

图像堆叠技术不仅可以去除噪声——它们还可以去除在拍摄现场移动的任何物体,包括人。例如,Adobe Photoshop 有一个堆叠脚本,可以使非静止的物体神奇地消失。它依赖于一种叫做 中位数 的统计平均值,这个值就是按从小到大的顺序排列的数字列表中的“中间”值。这个过程需要多张照片——最好是使用三脚架拍摄——这样你想去除的物体会在每张图像中发生位置变化,而背景保持不变。通常你需要 10 到 30 张每隔 20 秒拍摄的照片,或者从视频中提取的相似间隔的帧。

对于均值,你将数字求和并除以总数;而对于中位数,你需要对数字进行排序并选择中间值。在图 15-11 中,显示了一排五张图像,每张图像都标出了相同的像素位置。在第四张图像中,一只乌鸦飞过,破坏了原本完美的白色背景。如果使用均值堆叠,鸟的影像仍然存在。但如果对图像进行中位数堆叠——也就是说,对红、绿、蓝通道进行排序并取中间值——你就能得到每个通道的背景值(255)。鸟的痕迹就消失了。

image

图 15-11:五张白色图像,突出显示相同的像素并显示其 RGB 值。中位数堆叠去除了黑色像素。

当你使用中位数进行平均时,伪值会被推到列表的两端。这使得去除离群值变得容易,比如天文照片中的卫星或飞机,只要包含离群值的图像数量少于总图像数量的一半。

掌握了这些知识后,编写一个图像堆叠程序,去除你度假照片中的不速之客。你可以从网站上下载 moon_cropped 文件夹进行测试,该文件夹包含五张合成的月球图像,每张图像都被一架飞过的飞机“破坏”了(见图 15-12)。

image

图 15-12:用于测试中值平均法方法的合成月球照片

你最终的叠加图像应该没有飞机的痕迹(图 15-13)。

image

图 15-13:使用中值平均法叠加 moon_cropped 文件夹中的图像结果

由于这是一个挑战项目,因此没有提供解决方案。

第十六章:利用本福德定律发现欺诈

image

在电子计算器发明之前,如果你需要计算一个数字的对数,你需要查阅表格。天文学家西蒙·纽康(Simon Newcomb)使用过这样的表格,并且在 1881 年,他注意到前面几页表格上用于查找以最小数字开头的数字,比后面几页的表格更加磨损。通过这一平凡的观察,他意识到——至少对于自然界中的测量值和常数——首位数字更有可能是小数字,而不是大数字。他发表了一篇简短的文章并继续前行。

几十年来,这一统计学现象,像托尔金的魔戒一样,“被人们所遗忘。”直到 1938 年,物理学家弗兰克·本福德重新发现并证实了这一现象,他收集了超过 20,000 个真实世界数据样本,数据来源包括河流的测量、街道地址、《读者文摘》杂志中的数字、分子质量、棒球统计、死亡率等等。作为推广这一科学发现的人,他得到了所有的荣誉。

根据本福德定律,也被称为首位数字定律,自然发生的数字分布中,首位数字的出现频率是可预测的,并且是不均匀的。事实上,一个数字以 1 开头的概率是以 9 开头的概率的六倍!这一点非常反直觉,因为大多数人会认为数字的分布是均匀的,每个数字以 1/9(11.1%)的概率出现在第一位。由于这种认知偏差,本福德定律已成为财务、科学和选举数据中用于欺诈检测的有力工具。

在这一章中,你将编写一个 Python 程序,比较现实生活中的数据集与本福德定律,并判断它们是否存在欺诈行为。你还将最后一次使用matplotlib,为分析添加一个有用的可视化组件。作为数据集,你将使用 2016 年美国总统选举中投出的选票。

项目 #24:本福德的首位数字定律

图 16-1 显示了一组符合本福德定律的数字的首位有效数字的条形图。令人惊讶的是,尺度并不重要。无论澳大利亚道路的长度是以英里、公里还是古比特为单位,统计出来的结果都会遵循本福德定律!作为一个统计原理,它是尺度不变的。

image

图 16-1:根据本福德定律,首位数字的出现频率

数学家们花了大约一百年的时间来找出一个他们认为令人满意的本福特定律解释。对于我们其他人来说,让我们只说宇宙中的小事物比大事物多。弗兰克·本福特用拥有英亩土地比拥有英亩更容易的类比。事实上,你可以通过假设 1 的个数是 2 的两倍,3 的个数是 1 的三倍,依此类推,来紧密地复制本福特定律产生的频率。只需取每个九个数字的倒数(1 / d),然后除以所有倒数的总和(2.83)。然后将结果乘以 100 以获得百分比(见图 16-2)。

image

图 16-2:本福特定律与首位数字的倒数成比例的近似比较

由于刚讨论的尺寸关系,本福特定律可以通过对数刻度进行可视化,该刻度用于绘制按指数关系相关的数据。在半对数(“半对数”)图中,一个变量往往受限,如前导数字集(1–9),而另一个变量则覆盖包括几个数量级的广泛数值。

在半对数图纸上,水平 x 轴值为对数值,垂直 y 轴值由水平线表示,不是(见图 16-3)。在 x 轴上,水平划分不规则,这种非线性模式随 10 的幂重复。在对数纸上的每十年,如 1 至 10 或 10 至 100,数值之间的划分宽度与图 16-1 中条形的长度成比例。例如,图 16-3 中 1 和 2 之间的距离是 1 和 10 之间距离的 30.1%。正如一位作者所说,你可以通过简单地将飞镖扔到对数纸上来得出本福特定律!

image

图 16-3:两个十年半对数图纸示例

要使数字数据集符合本福特定律,必须满足一定条件。数字必须是随机的,没有指定的最小值或最大值。数字应覆盖几个数量级,并且数据集应该足够大;文献中的建议要求至少 100 到 1,000 个样本,尽管已经证明即使包含 50 个数字的数据集也可以符合本福特定律。不遵循本福特定律的分布示例包括职业篮球运动员的身高、美国电话号码(仅最后四位数字是真正随机的)、受心理障碍影响的价格($1.99 与$2.00 之间)以及医疗保险的赔款。

应用本福特定律

大多数财务和会计数据遵循自然出现的数字,因此符合 Benford 定律。例如,假设你拥有一个价值 1,000 美元的股票共同基金。为了使基金的价值增长到 2,000 美元,它需要通过增长 100%来实现翻倍。而从 2,000 美元增加到 3,000 美元,仅需增长 50%。若要使首位数字为 4,基金需要再增长 33%。正如 Benford 定律预测的那样,首位数字从 1 变成 2 所需要的增长量大于从 3 变成 4 所需的增长量,依此类推。由于 Benford 分布是一种“分布的分布”,因此财务数据集通常符合这一规律,因为它们是由数字的组合而成——尽管也会有例外。

因为人们通常没有意识到 Benford 定律,在伪造数字记录时往往没有考虑到这一点。这为法务会计师提供了一个强有力的工具,能够迅速识别可能存在欺诈行为的数据集。事实上,与 Benford 定律的比较在美国的联邦、州及地方刑事案件中作为证据是合法可接受的。

在 1993 年 State of Arizona v. Nelson 案中,被告将近 200 万美元转移到虚假的供应商账户,企图诈骗州政府。尽管被告小心翼翼地伪造了看似合法的支票,但首位数字的分布明显违反了 Benford 定律(见图 16-4),最终导致定罪。

image

图 16-4:欺诈支票中的首位数字频率与预期的 Benford 定律频率的比较,State of Arizona v. Wayne James Nelson (CV92-18841)

Benford 定律对于内部商业审计也很有用。假设有一条规定,所有超过$10,000 的差旅和娱乐费用必须由公司副总裁批准。这种财务阈值可能会诱使员工采取拆分发票等手段来规避系统。图 16-5 基于一组范围从$100 到$12,000 的费用,其中所有超过$9,999 的值都被拆分为两等份。正如你可以猜到的,首位数字的频率在 5 和 6 附近出现了尖峰,明显违反了 Benford 定律。

image

图 16-5:针对范围在$100 到$12,000 之间的账单,将金额超过$9,999 的发票拆分,违反了 Benford 定律。

在更广泛的层面上,Benford 定律揭示了大型企业财务数据中的不规则性——例如收入数字。一个来自安然公司的例子,该公司曾经实行制度化的财务欺诈,见于图 16-6。安然公司在 2001 年的破产是当时历史上最大的破产事件,导致多名高层管理人员入狱。此丑闻还导致了全球最大的一家跨国会计公司之一、“五大”会计事务所之一——安达信的解散。

image

图 16-6:来自恩隆 2000 年财务数据的首位数字频率与基于本福特定律的预期频率对比(摘自《华尔街日报》

显然,当犯罪分子不知道本福特定律时,这一规律在欺诈检测中最有效。如果你了解这一规律的工作原理,你可以欺骗它,而我们将在本章末的实践项目中进行这一操作。因此,你可以使用本福特定律标记可能存在欺诈的数据集,但不能用它证明相反的情况。

进行卡方检验

审计员和调查员使用多种统计方法验证数据集是否遵循本福特定律。在这个项目中,你将使用卡方拟合优度检验,这是一种常用的方法,用于确定经验(观察到的)分布是否与理论(预期)分布有显著差异。显著性水平或p-值用于区分两者。最常见的显著性水平是 0.05,但其他常用的包括 0.01 和 0.10。显著性水平为 0.05 表示 5%的风险,即错误地得出存在差异的结论。

以下是进行卡方拟合优度检验的步骤:

  1. 找到自由度df),它定义为类别数(k)减去 1:

    df = k – 1

    对于本福特定律,类别水平是首位数字(1–9),因此df = 8。

  2. 通过将样本大小乘以每个级别的理论比例来计算每个级别的预期频数:

    E[i] = np[i]

    其中,E是第i级的预期频率,n是样本大小,p是第i级的理论概率。对于 1,000 个样本,根据本福特定律分布,预期以 1 开头的样本数量为 1,000 × 0.301 = 301(见图 16-1)。

  3. 计算卡方随机变量(X²),也称为检验统计量,它可以帮助你判断两个分布是否相同:

    image

    其中,O是类别变量第i级的观察频数,E是类别变量第i级的预期频数,df代表自由度

  4. 查阅卡方分布表(表 16-1),读取与计算得出的自由度对应的行。如果检验统计量小于p-值列中显示的显著性值,那么你无法拒绝观察分布和理论分布相同的假设。

表 16-1: 卡方分布表

自由度 超过临界值的概率
0.99 0.95
--- ---
1 0.000
2 0.020
3 0.115
4 0.297
5 0.554
6 0.872
7 1.239
8 1.647
9 2.088
10 2.558
不显著

在表 16-2 中,对于P-值为 0.05 时,具有 8 个自由度的临界值为 15.51。如果你计算出的检验统计量小于 15.51,则对应的P-值大于 0.05,你将得出结论,观察到的分布与本福德定律预测的分布之间没有统计显著差异。这里的P-值是指 8 个自由度的检验统计量比 15.51 更极端的概率。

请注意,你应当对计数进行卡方检验。如果你的数据是百分比、平均值、比率等,需先将这些值转换为计数再进行检验。

目标

编写一个 Python 程序,加载数值数据,记录首位数字的出现频率,使用卡方拟合优度检验将这些频率与本福德定律进行比较,并以表格和图形形式呈现比较结果。

数据集

2016 年美国总统选举充满了选民舞弊的指控。最著名的是,俄罗斯被指控支持唐纳德·特朗普,而民主党全国委员会被指控在党内提名过程中偏袒希拉里·克林顿,而不是伯尼·桑德斯。特朗普总统还指控有 500 万到 600 万人非法投票,并且在 2017 年 5 月签署了一项行政命令,成立了一个委员会来审查选民舞弊和选民压制问题。

对于这个项目,你将使用 2016 年总统选举的投票记录数据集。这包括了伊利诺伊州 102 个县的最终按县划分的投票结果,该州由希拉里·克林顿赢得。自 2016 年 6 月以来,伊利诺伊州选民注册系统数据库成为了一次来源不明的恶意网络攻击的受害者。伊利诺伊州选举官员确认,黑客访问了成千上万的记录,但显然没有修改任何数据。

伊利诺伊州的总统选票上有出人意料的多位候选人,因此该数据集已被解析,仅包括希拉里·克林顿、唐纳德·特朗普、加里·约翰逊和吉尔·斯坦。这些候选人的投票结果被汇总在一个包含 408 行的文本文件中,前五行如下:

962
997
1020
1025
1031

你可以在 www.elections.il.gov/ElectionInformation/DownloadVoteTotals.aspx 在线查找候选人和选票的完整统计信息。

对于这个项目,你只需要选票,可以从 www.nostarch.com/impracticalpython/ 下载 Illinois_votes.txt。你需要将这个文件与 Python 代码保存在同一个文件夹中。

策略

假设你是一个调查员,正在调查 2016 年总统选举中的选民欺诈指控,且你被分配到伊利诺伊州。在深入分析数据之前,你需要标记任何明显的异常。贝福德定律不能帮助你确定是否有人非法投票,但它是检测选票 篡改 的一个不错的起点——即,在选票投下后改变选票。

在这种情况下,沟通结果的能力与定量分析同样重要。选举委员会不仅包括专家,还包括许多对统计学知识有限的普通人。陪审团可能也不会有任何专家。为了说服自己——以及他人——投票结果是有效的(或无效的),你需要展示多个比较结果,例如表格、图表和定量卡方变量(检验统计量)。

分析中涉及的各个步骤非常适合封装成函数。因此,我们不看伪代码,而是来看一下你可能需要的函数:

load_data() 将数据加载为列表。

count_first_digits() 统计每个县的观察选票总数中的首位数字。

get_expected_counts() 确定根据本福德定律预测的每个首位数字的计数。

chi_square_test() 对观察值与预期值进行卡方拟合优度检验。

bar_chart() 生成一个柱状图,将观察到的首位数字百分比与预期百分比进行比较。

main() 获取数据集文件名,调用函数并打印统计信息。

代码

你将在本节中使用 benford.py 代码来研究选民欺诈,但它足够灵活,可以用于 任何 数据集,其中包含分类值的计数,例如医学测试结果、所得税收入或客户退款。也可以用于与欺诈无关的应用,如检测由大量低价值交易引起的流程低效;数据收集和处理中的问题,如缺失数据、截断值或拼写错误;以及测量策略或调查中的偏差,如偏向最佳情况或最差情况的抽样。

你可以从 www.nostarch.com/impracticalpython/ 下载代码。你还需要在第 353 页的 “数据集”中描述的 Illinois_votes.txt 文本文件。

导入模块和加载数据

清单 16-1 导入模块并定义加载数据的函数。在此项目中,你将使用一种格式为制表符分隔的文本文件(从 Microsoft Excel 导出),并将其作为字符串列表加载。

benford.py, 第一部分

   import sys
   import math
➊ from collections import defaultdict
➋ import matplotlib.pyplot as plt

   # Benford's law percentages for leading digits 1-9
➌ BENFORD = [30.1, 17.6, 12.5, 9.7, 7.9, 6.7, 5.8, 5.1, 4.6]

➍ def load_data(filename):
       """Open a text file & return a list of strings."""
    ➎ with open(filename) as f:
           return f.read().strip().split('\n')

清单 16-1:导入模块并定义加载数据的函数

此时,大部分导入的模块应该已经熟悉了。collections模块提供了标准 Python 容器(如集合、元组、列表和字典)的专用替代方案 ➊。为了统计首位数字的频率,你需要defaultdict,它是dict的一个子类,通过调用工厂函数来提供缺失的值。使用defaultdict时,你可以通过循环构建字典,它会自动创建新键,而不是抛出错误。它返回一个字典对象。

最后的导入是用于与matplotlib绘图 ➋。有关matplotlib及其安装方法的更多信息,请参见第 194 页的“检测概率代码”。

现在,将一个变量赋值为一个包含从 1 到 9 的本福德定律百分比的列表 ➌。然后,定义一个函数来读取文本文件并返回一个列表 ➍。像之前一样,使用with,因为它会在完成后自动关闭文件 ➎。

统计首位数字

清单 16-2 定义了一个函数,用于统计首位数字并将结果存储在字典数据结构中。最终的计数以及每个计数的频率(以百分比形式)会作为列表返回,以便在后续函数中使用。该函数还将对数据进行质量控制。

benford.py, 第二部分

➊ def count_first_digits(data_list):

       """Count 1st digits in list of numbers; return counts & frequency."""

    ➋ first_digits = defaultdict(int)  # default value of int is 0

    ➌ for sample in data_list:

        ➍ if sample == '':

               continue

           try:

               int(sample)

           except ValueError as e:

               print(e, file=sys.stderr)

               print("Samples must be integers. Exiting", file=sys.stderr)

               sys.exit(1)

        ➎ first_digits[sample[0]] += 1

       # check for missing digits

       keys = [str(digit) for digit in range(1, 10)]

       for key in keys:

           if key not in first_digits:

               first_digits[key] = 0

    ➏ data_count = [v for (k, v) in sorted(first_digits.items())]

       total_count = sum(data_count)

       data_pct = [(i / total_count) * 100 for i in data_count]

    ➐ return data_count, data_pct, total_count

清单 16-2:定义一个函数来统计首位数字并返回计数和频率

count_first_digits()函数接受由load_data()函数返回的字符串列表作为参数 ➊。你将在main()中调用它。

创建一个名为first_digits的字典,使用defaultdict ➋。这个步骤只是为后续填充字典做准备。defaultdict的第一个参数是一个可调用对象(无参数)。在此例中,可调用对象是int的类型构造器,因为你需要计数整数。使用defaultdict时,每当操作遇到缺失的键时,会调用一个名为default_factory的函数,并且不传递任何参数,返回的结果将作为该键的值。不存在的键会得到default_factory返回的值。

现在,启动一个for循环,遍历data_list中的样本 ➌。如果样本为空——即,如果文本文件中包含空行 ➍——使用continue跳过它。否则,使用try将样本转换为整数。如果发生异常,说明样本不是有效的计数值,因此通知用户并退出程序。在下面的输出示例中,输入文件包含一个浮动值(0.01),而main()函数打印出文件名。

Name of file with COUNT data: bad_data.txt
invalid literal for int() with base 10: '0.01'
Samples must be integers. Exiting.

如果样本通过了检验,将其第一个元素(前导数字)作为字典键,并将值加 1 ➎。因为你使用了defaultdict并设置为int,所以键会自动初始化为0

为了将计数与本福德定律分布进行比较,你需要将键按数字顺序列出,因此使用列表推导式和sorted来创建一个新的first_digits版本,命名为data_count ➏。这将按键排序并返回值,如下所示:

[129, 62, 45, 48, 40, 25, 23, 21, 15]

接下来,求和计数,然后创建一个新列表并将计数转换为百分比。最后,通过返回这两个列表和总计数来结束函数 ➐。由于列表中的计数从 1 到 9 排序,因此你不需要关联的前导数字——它已隐含在排序中。

获取预期计数

列表 16-3 定义了get_expected_counts()函数,该函数接受观察数据并根据本福德定律计算出前导数字的预期计数。这些预期计数作为一个列表返回,稍后你将使用它与卡方拟合优度检验一起,查看观察数据与本福德定律的符合程度。

benford.py, 第三部分

➊ def get_expected_counts(total_count):
       """Return list of expected Benford's law counts for a total sample count."""
    ➋ return [round(p * total_count / 100) for p in BENFORD]

列表 16-3:定义了一个函数,用于计算数据集的预期本福德定律计数

该函数的参数是你从列表 16-2 中的count_first_digits()函数返回的总计数 ➊。为了获得本福德定律下你应该预期的计数,你需要使用每个数字的频率概率,因此通过除以 100 将BENFORD列表中的百分比转换为概率。然后将total_count变量乘以此概率。你可以通过列表推导式在return语句中完成这一切 ➋。

确定拟合优度

列表 16-4 定义了一个函数,用于实现“执行卡方检验”中的卡方检验,该检验描述在第 352 页中。这一检验计算观察计数与本福德定律预测的预期计数之间的拟合优度。该函数将首先计算卡方检验统计量,然后将其与卡方分布表中自由度为 8、p 值为 0.05 的条目进行比较。根据比较结果,函数返回TrueFalse

benford.py, 第四部分

➊ def chi_square_test(data_count, expected_counts):
       """Return boolean on chi-square test (8 degrees of freedom & P-val=0.05)."""
    ➋ chi_square_stat = 0  # chi-square test statistic
    ➌ for data, expected in zip(data_count, expected_counts):
        ➍ chi_square = math.pow(data - expected, 2)
           chi_square_stat += chi_square / expected
    ➎ print("\nChi Squared Test Statistic = {:.3f}".format(chi_square_stat))
       print("Critical value at a P-value of 0.05 is 15.51.")

    ➏ return chi_square_stat < 15.51

列表 16-4:定义了一个函数,用于衡量观察数据与本福德定律的拟合优度

卡方检验作用于计数,因此该函数需要count_first_digits()get_expected_counts()函数返回的数据计数和预期计数列表 ➊。定义一个名为chi_square_stat的变量来存储卡方检验统计量,并将其值初始化为0 ➋。

使用 zip 遍历 data_countexpected_counts 中的九个值;zip 会将一个列表中的第一个项目与第二个列表中的第一个项目配对,依此类推 ➌。要计算卡方统计量,首先减去每个数字的计数并平方结果 ➍。然后,将此值除以该数字的预期计数,并将结果加到 chi_square_stat 变量中。接着,将结果打印至小数点后三位 ➎。

返回 chi_square_stat 变量与 15.51 的布尔值测试,这是与 8 自由度下 p 值为 0.05 对应的临界值(见 表 16-1) ➏。如果 chi_square_stat 小于该值,函数将返回 True;否则返回 False

定义条形图函数

列表 16-5 定义了一个函数的第一部分,用于以 matplotlib 条形图的形式显示观察到的计数百分比。在第十二章中,你使用了类似的代码来绘制退休储备模拟的结果。这个函数还将以红点的形式绘制本福德法则的百分比,这样你可以通过视觉估算观察数据与期望分布的拟合程度。

matplotlib 网站包含了许多用于构建各种图表的代码示例。此代码部分基于 matplotlib.org/examples/api/barchart_demo.html 上的演示示例。

benford.py, 第五部分

➊ def bar_chart(data_pct):
       """Make bar chart of observed vs expected 1st-digit frequency (%)."""
    ➋ fig, ax = plt.subplots()

    ➌ index = [i + 1 for i in range(len(data_pct))]  # 1st digits for x-axis

       # text for labels, title, and ticks
    ➍ fig.canvas.set_window_title('Percentage First Digits')
    ➎ ax.set_title('Data vs. Benford Values', fontsize=15)
    ➏ ax.set_ylabel('Frequency (%)', fontsize=16)
    ➐ ax.set_xticks(index)
       ax.set_xticklabels(index, fontsize=14)

列表 16-5:定义了 bar_chart() 函数的第一部分

定义 bar_chart() 函数,它接受一个参数,即观察数据中首位数字的频率列表——以百分比表示 ➊。plt.subplots() 函数返回一个包含图形和坐标轴对象的元组;将此元组解包到名为 figax 的变量中 ➋。

接下来,使用列表推导式创建一个从 1 到 9 的数字列表 ➌。这个 index 变量将定义图表中每个竖直条形的 x 轴位置。

设置图表的标题、标签等。将图表窗口命名为窗口 'Percentage First Digits' ➍,然后在图表内显示标题 ➎。这里我使用了通用标题,但你可以根据需要进行自定义。使用 fontsize 关键字参数将文本大小设置为 15。请注意,窗口标题是 fig 的属性,而其他标签将是 ax 的属性。

使用 set_ylabel() 将 y 轴命名为 “Frequency (%)” ➏,然后根据 index 变量设置 x 轴的刻度标记 ➐。刻度标签将是数字 1 到 9,因此再次使用 index 变量并将字体大小设置为 14

完成条形图函数

列表 16-6 通过定义条形图、在每个条形的顶部标注其频率值,并将本福德分布的值绘制为红色圆点,完成了 bar_chart() 函数。

benford.py, 第六部分

       # build bars
    ➊ rects = ax.bar(index, data_pct, width=0.95, color='black', label='Data')

       # attach a text label above each bar displaying its height
    ➋ for rect in rects:
        ➌ height = rect.get_height()
        ➍ ax.text(rect.get_x() + rect.get_width()/2, height,
                   '{:0.1f}'.format(height), ha='center', va='bottom',
                   fontsize=13)

       # plot Benford values as red dots
    ➎ ax.scatter(index, BENFORD, s=150, c='red', zorder=2, label='Benford')

       # Hide the right and top spines & add legend
    ➏ ax.spines['right'].set_visible(False)
       ax.spines['top'].set_visible(False)
    ➐ ax.legend(prop={'size':15}, frameon=False)

    ➑ plt.show()

列表 16-6:完成了生成条形图的函数

给变量命名为 rects,表示矩形,并用它来保存条形图中的条形 ➊。你可以使用 bar() 方法生成这些条形,它会返回一个包含所有条形的容器。传递给它索引变量和百分比频率的列表,将每个条形的宽度设置为 0.95,填充为黑色,并将 label 参数设置为 'Data'。最后一个参数是非常方便的方式来自动生成图例。你将在函数的后面部分利用这一点。

我喜欢在条形图上方绘制实际的条形值,这样就不需要眯眼看 y 轴并尝试猜测值了。为此,首先通过循环遍历 rects ➋ 中的每个条形图(rect),获取其高度 ➌,即其 y 轴值。然后,调用 ax 对象的 text() 方法 ➍,并传入条形的左侧位置——通过 get_x() 方法获取——然后加上条形宽度的一半,以将标签居中放置在条形上方。由于使用了 get_width() 方法,因此你只需为条形宽度赋值一次,这在步骤 ➊ 中已完成。接下来是条形高度——格式化为一位小数——然后是水平和垂直对齐方式。将这些设置为文本边界框的中心和底部。最后,设置文本大小。

现在,开始构建 matplotlib 的“标记”——在这种情况下是圆点——用于标示 Benford 分布频率在每个首位数字的位置。使用 scatter() 方法来完成这项工作,它用于生成散点图 ➎。

scatter() 的前两个参数是每个标记的 x-y 位置,由 indexBENFORD 列表中的连续对表示。接下来是标记的大小,设置为 150,然后是颜色。redDodgerBlue 都可以很好地使用。你希望标记显示在条形图的顶部,所以将 zorder 设置为 2。图形中的元素被称为 matplotlib 的“艺术家”,具有较高 zorder 值的艺术家将覆盖具有较低值的艺术家。最后,使用 label 参数来创建图例。

接下来的两条语句用于美学效果。默认情况下,matplotlib 会在图表内部绘制一个边框,而上边框可能会干扰放置在每个条形顶部的标签。因此,通过将其可见性设置为 False ➏,移除顶部和右侧的边框。

使用 legend() 构建图形的图例 ➐。它可以不带参数工作,但可以将其大小属性设置为 15,并关闭图例周围的边框,以获得一个可能更具吸引力的结果。最后调用 plt.show() 来显示图表 ➑。一个示例的条形图如图 16-7 所示。

image

图 16-7: bar_chart() 函数的示例输出

main() 函数中,你将以文本的形式在解释器窗口中显示附加信息。包括卡方检验统计量的值。

定义并运行 main() 函数

清单 16-7 定义了 main() 函数并以模块或独立模式运行程序。由于大部分工作都在各个函数中完成,main() “主要”调用这些函数并打印一些统计数据。

benford.py, 第七部分

   def main():
       """Call functions and print stats."""
       # load data
       while True:
        ➊ filename = input("\nName of file with COUNT data: ")
           try:
               data_list = load_data(filename)
           except IOError as e:
               print("{}. Try again.".format(e), file=sys.stderr)
           else:
               break
    ➋ data_count, data_pct, total_count = count_first_digits(data_list)
    ➌ expected_counts = get_expected_counts(total_count)
       print("\nobserved counts = {}".format(data_count))
       print("expected counts = {}".format(expected_counts), "\n")

    ➍ print("First Digit Probabilities:")
    ➎ for i in range(1, 10):
           print("{}: observed: {:.3f}  expected: {:.3f}".
                 format(i, data_pct[i - 1] / 100, BENFORD[i - 1] / 100))

    ➏ if chi_square_test(data_count, expected_counts):
           print("Observed distribution matches expected distribution.")
       else:
           print("Observed distribution does not match expected.",
                 file=sys.stderr)

    ➐ bar_chart(data_pct)

➑ if __name__ == '__main__':
       main()

清单 16-7:定义了 main() 函数并以模块或独立模式运行程序

首先,提示用户输入需要分析的计数数据文件名 ➊;将这个请求嵌入一个 while 循环中,直到用户输入有效的文件名或关闭窗口为止。用户可以输入文件名或完整路径名,如果他们想加载存储在当前工作目录之外的数据集。例如,在 Windows 中:

Name of file with COUNT data: C:\Python35\Benford\Illinois_votes.txt

使用 try 语句调用之前构建的 load_data() 函数,并传递文件名给该函数。如果文件名有效,则返回的列表将赋值给 data_list 变量。如果发生异常,则捕获并打印错误。否则,从 while 循环中 break 退出。

接下来,将返回的数据计数列表传递给 count_first_digits() 函数,并解包结果为变量 data_countdata_pcttotal_count,它们分别是首位数字计数、百分比和总计数的列表 ➋。然后,通过调用 get_expected_counts() 函数并传递 total_count 变量,生成本福德定律分布下预期的计数列表 ➌。打印观察到的计数和预期的计数列表。

现在,制作一个表格,比较数据中首位数字的频率与预期值。使用概率值,因为小数值在终端中易于对齐。首先使用一个 print 语句输出表头 ➍,然后循环打印数字 1 到 9,对于每个数字,输出观察到的计数(数据),接着是预期的计数,每个值保留三位小数 ➎。注意,两个列表的索引从零开始,因此必须从 i 中减去 1。

将这两个计数列表传递给 chi_square_test() 函数,以计算观察到的数据与预期分布的匹配程度 ➏。如果函数返回 True,使用 print 语句告知用户观察到的分布符合本福德定律(或者,更准确地说,两者之间没有显著差异)。否则,报告它们不匹配,对于终端用户,可以将字体颜色设置为红色。

chi_square_test() 函数将在解释器窗口中显示其结果,因此调用 bar_chart() 函数生成柱状图 ➐。将数据计数列表作为百分比传递给它。

回到全局空间,使用运行程序作为模块或独立模式的代码结束程序 ➑。

如果你在 Illinois_votes.txt 数据集上运行程序,你将看到如图 16-8 所示的输出。根据本福德定律,投票结果没有明显的异常。

image

图 16-8:benford.py 对数据集 Illinois_votes.txt 的输出结果

如果你只使用特朗普的选票运行程序,然后只使用希拉里的选票,你将得到如图 16-9 所示的结果。特朗普的分布,检验统计量为 15.129,勉强通过卡方检验。

image

图 16-9:特朗普结果(左)与希拉里结果(右)在伊利诺伊州的比较

在这种情况下,你应该小心得出立即结论。数据集很小——每个候选人只有 102 个样本——结果可能受到人口统计和城乡投票率差异等因素的影响。关于这种城乡差异的有趣文章可以在www.chicagotribune.com/news/data/ct-illinois-election-urban-rural-divide-2016-htmlstory.html找到。

在“实践项目:打败本福德”(见第 364 页)中,你将有机会篡改伊利诺伊州的选票计数并改变结果。然后,你将使用前面的代码查看结果与本福德定律的符合程度。

总结

很久以前,在第一章中,我们使用了“穷人条形图”实践项目(见第 15 页)和“穷外国人条形图”挑战项目(见第 16 页),探讨了语言中字母出现频率的不规则性和可预测性。这为密码分析提供了强大的工具。在书的结尾,我们回到了原点,发现即使是数字也有这种特征,从而成为欺诈检测的强大工具。只需一段简短而简单的 Python 程序,你就能撼动天柱,把高高在上的人拉下凡尘——这一切都源于有人注意到书的封面很脏。

好了,这就是《不切实际的 Python 项目》的全部内容。希望你玩得开心,学到新知识,并受到启发,创造自己的不切实际的项目!

进一步阅读

《本福德定律:法医会计、审计与欺诈检测的应用》(John Wiley & Sons,2012)由马克·尼格里尼(Mark Nigrini)编著,涵盖了本福德定律的数学、理论和测试,并结合示例应用,包括欺诈、逃税和庞氏骗局。

实践项目:打败本福德

通过这个实践项目测试你在操控选举方面的技能。你可以在附录中找到解决方案,beat_benford_practice.py,或者从www.nostarch.com/impracticalpython/下载。

一个数据集不应该仅仅因为它符合本福德定律而被认为有效。原因很简单:如果你了解本福德定律,那么你就可以破解它。

为了证明这一点,假设你是一个高级黑客,受一个邪恶的外国政府指使,能够访问伊利诺伊州所有的选票记录。编写一个 Python 程序,篡改按县的选票,使唐纳德·特朗普赢得该州,但选票总数依然遵守本福德法则。要小心;伊利诺伊州是一个“蓝色”州,因此你不希望制造出压倒性的胜利(通常定义为在普选中领先 10-15 个百分点)。为了避免引起怀疑,特朗普应该以少数几个百分点险胜。

注意

各州对选票重计有相关规定。在篡改选举之前,欺诈者需要了解这些规则,以避免重计带来的审查。每个州的实际法定规则阅读起来并不有趣,但明尼苏达州公民选举诚信组织提供了易于理解的摘要。伊利诺伊州的摘要可以在 ceimn.org/searchable-databases/recount-database/illinois/ 找到。*

你的程序应该从其他候选人那里窃取选票,同时保留按县的总票数;这样,总的投票数不会发生变化。作为质量控制步骤,打印出特朗普和克林顿按县的旧票数和新票数,以及他们的旧全州票数和新全州票数。然后,写出一个文本文件,供你输入到benford.py中,这样你就可以检查你在本福德法则方面的表现了。

每个候选人的数据集已经准备好并列在这里;你可以从www.nostarch.com/impracticalpython/下载它们。这些数据集每个都是一列数字,代表选票,按县名字母顺序排序(所以不要更改顺序!)。

Clinton_votes_Illinois.txt

Johnson_votes_Illinois.txt

Stein_votes_Illinois.txt

Trump_votes_Illinois.txt

图 16-10 显示了我运行benford.py时的结果,输出来自我的尝试 beat_benford_practice.py,该程序使用了前面的数据集。分布通过了卡方检验,并且在视觉上给出一个令人信服的——但可以理解为不完美的——拟合,符合本福德法则预测的值。

image

图 16-10:运行 beat_benford_practice.py 输出的分布结果,在 benford.py 中执行的结果。恶作剧成功!

这里显示的是来自 beat_benford_practice.py 的一些输出行,包含按县的旧票数和新票数:

image

从上往下的第三行代表库克县,其中包含芝加哥。注意,这里克林顿依然获胜,但胜利的幅度较小。如果特朗普在这个蓝色县城完全获胜,那将是一个巨大的红色警告,表明可能发生了选票篡改,即使他只以微弱的优势赢得整个州!

挑战项目

尝试完成这些挑战项目。这里不提供解决方案。

本福德法则与战场州

在一个注定会获胜的州,候选人不需要作弊。如果你是调查选民舞弊的调查员,你很可能会从关键摇摆州开始。这些州的选举结果可能会有较大的波动,候选人也会在这些州花费大量的选举资金和时间。根据 Ballotpedia 的资料(ballotpedia.org),特朗普 2016 年的关键摇摆州是亚利桑那州、爱荷华州、密歇根州、威斯康星州、俄亥俄州、宾夕法尼亚州、北卡罗来纳州和佛罗里达州。希拉里的摇摆州是科罗拉多州、内华达州、新罕布什尔州和弗吉尼亚州。

各州的在线投票记录通常以多种格式提供,例如 Microsoft Excel 电子表格。收集关键摇摆州的记录,将它们转换为文本文件,并通过benford.py程序进行处理。为了帮助你入门,你可以在这里找到俄亥俄州的选举记录:www.sos.state.oh.us/elections/

当无人注意时

美国众议院前议长蒂普·奥尼尔(Tip O'Neill)曾著名地说:“所有政治都是地方性的。”请牢记这一点,并使用benford.py程序检查一些地方选举,比如法官、市长、县监督、警长和市议会成员等选举。这些选举通常比参议院席位、州长或总统选举受到的关注要少。如果你发现任何不规则现象,在大声疾呼之前,确保投票数据集符合本福德定律的有效应用!

第十七章:实践项目解决方案

image

本附录提供了每一章实践项目的解决方案。数字版可通过本书官方网站下载,网址为 www.nostarch.com/impracticalpython/

傻乎乎的名字生成器**

猪拉丁语

pig_Latin_practice.py

"""Turn a word into its Pig Latin equivalent."""
import sys

VOWELS = 'aeiouy'

while True:
    word = input("Type a word and get its Pig Latin translation: ")

    if word[0] in VOWELS:
        pig_Latin = word + 'way'
    else:
        pig_Latin = word[1:] + word[0] + 'ay'
    print()
    print("{}".format(pig_Latin), file=sys.stderr)

    try_again = input("\n\nTry again? (Press Enter else n to stop)\n ")
    if try_again.lower() == "n":
        sys.exit()

穷人的条形图

EATOIN_practice.py

"""Map letters from string into dictionary & print bar chart of frequency."""
import sys
import pprint
from collections import defaultdict

# Note: text should be a short phrase for bars to fit in IDLE window
text = 'Like the castle in its corner in a medieval game, I foresee terrible \
trouble and I stay here just the same.'

ALPHABET = 'abcdefghijklmnopqrstuvwxyz'

# defaultdict module lets you build dictionary keys on the fly!
mapped = defaultdict(list)
for character in text:
    character = character.lower()
    if character in ALPHABET:
        mapped[character].append(character)

# pprint lets you print stacked output
print("\nYou may need to stretch console window if text wrapping occurs.\n")
print("text = ", end='')
print("{}\n".format(text), file=sys.stderr)
pprint.pprint(mapped, width=110)

寻找回文咒语**

词典清理

dictionary_cleanup_practice.py

"""Remove single-letter words from list if not 'a' or 'i'."""

word_list = ['a', 'nurses', 'i', 'stack', 'b', 'c', 'cat']

word_list_clean = []

permissible = ('a', 'i')

for word in word_list:

    if len(word) > 1:

        word_list_clean.append(word)

    elif len(word) == 1 and word in permissible:

        word_list_clean.append(word)

    else:

        continue

print("{}".format(word_list_clean))

解开字谜**

寻找二元组

count_digrams_practice.py

"""Generate letter pairs in Voldemort & find their frequency in a dictionary.

Requires load_dictionary.py module to load an English dictionary file.

"""
import re
from collections import defaultdict
from itertools import permutations
import load_dictionary

word_list = load_dictionary.load('2of4brif.txt')

name = 'Voldemort'  #(tmvoordle)
name = name.lower()

# generate unique letter pairs from name
digrams = set()
perms = {''.join(i) for i in permutations(name)}
for perm in perms:
    for i in range(0, len(perm) - 1):
        digrams.add(perm[i] + perm[i + 1])
print(*sorted(digrams), sep='\n')
print("\nNumber of digrams = {}\n".format(len(digrams)))

# use regular expressions to find repeating digrams in a word
mapped = defaultdict(int)
for word in word_list:
    word = word.lower()
    for digram in digrams:
        for m in re.finditer(digram, word):
            mapped[digram] += 1

print("digram frequency count:")
count = 0
for k in sorted(mapped):
    print("{} {}".format(k, mapped[k]))

解码美国内战密码**

黑客林肯

密码词 明文
WAYLAND captured
NEPTUNE Richmond

明文: 记者在里士满被捕,请查明他们为何被拘留,并尽量把他们放出来,如果可以的话,这样就填满了

识别密码类型

identify_cipher_type_practice.py

"""Load ciphertext & use fraction of ETAOIN present to classify cipher type."""
import sys
from collections import Counter

# set arbitrary cutoff fraction of 6 most common letters in English
# ciphertext with target fraction or greater = transposition cipher
CUTOFF = 0.5

# load ciphertext
def load(filename):
    """Open text file and return list."""
    with open(filename) as f:
        return f.read().strip()

try:
    ciphertext = load('cipher_a.txt')
except IOError as e:
    print("{}. Terminating program.".format(e),
          file=sys.stderr)
    sys.exit(1)

# count 6 most common letters in ciphertext
six_most_frequent = Counter(ciphertext.lower()).most_common(6)
print("\nSix most-frequently-used letters in English = ETAOIN")
print('\nSix most frequent letters in ciphertext =')
print(*six_most_frequent, sep='\n')

# convert list of tuples to set of letters for comparison
cipher_top_6 = {i[0] for i in six_most_frequent}

TARGET = 'etaoin'
count = 0
for letter in TARGET:
    if letter in cipher_top_6:
        count += 1

if count/len(TARGET) >= CUTOFF:
    print("\nThis ciphertext most-likely produced by a TRANSPOSITION cipher")
else:
    print("This ciphertext most-likely produced by a SUBSTITUTION cipher")

将密钥存储为字典

key_dictionary_practice.py

"""Input cipher key string, get user input on route direction as dict value."""
col_order = """1 3 4 2"""
key = dict()
cols = [int(i) for i in col_order.split()]
for col in cols:
    while True:
        key[col] = input("Direction to read Column {} (u = up, d = down): "
                         .format(col).lower())
        if key[col] == 'u' or key[col] == 'd':
            break
        else:
            print("Input should be 'u' or 'd'")

    print("{}, {}".format(col, key[col]))

自动化可能的密钥

permutations_practice.py

"""For a total number of columns, find all unique column arrangements.

Builds a list of lists containing all possible unique arrangements of
individual column numbers, including negative values for route direction
(read up column vs. down).

Input:
-total number of columns

Returns:
-list of lists of unique column orders, including negative values for
route cipher encryption direction

"""
import math
from itertools import permutations, product

#------BEGIN INPUT-----------------------------------------------------------

# Input total number of columns:
num_cols = 4

#------DO NOT EDIT BELOW THIS LINE--------------------------------------------

# generate listing of individual column numbers
columns = [x for x in range(1, num_cols+1)]
print("columns = {}".format(columns))

# build list of lists of column number combinations
# itertools product computes the Cartesian product of input iterables
def perms(columns):
    """Take number of columns integer & generate pos & neg permutations."""
    results = []
    for perm in permutations(columns):
        for signs in product([-1, 1], repeat=len(columns)):
            results.append([i*sign for i, sign in zip(perm, signs)])
    return results

col_combos = perms(columns)
print(*col_combos, sep="\n")  # comment-out for num_cols > 4!
print("Factorial of num_cols without negatives = {}"
      .format(math.factorial(num_cols)))
print("Number of column combinations = {}".format(len(col_combos)))

路线换位密码:暴力破解

本实践项目使用了两个程序。第二个程序 perms.py 作为模块在第一个程序 route_cipher_hacker.py 中使用。它是从之前描述的 permutations_practice.py 程序构建的,该程序详见 “自动化可能的密钥” 页 371。

路线密码黑客

route_cipher_hacker.py

"""Brute-force hack a Union route cipher (route_cipher_hacker.py).

Designed for whole-word transposition ciphers with variable rows & columns.
Assumes encryption began at either top or bottom of a column.
Possible keys auto-generated based on number of columns & rows input.
Key indicates the order to read columns and the direction to traverse.
Negative column numbers mean start at bottom and read up.
Positive column numbers means start at top & read down.

Example below is for 4x4 matrix with key -1 2 -3 4.
Note "0" is not allowed.
Arrows show encryption route; for negative key values read UP.

  1   2   3   4
___ ___ ___ ___
| ^ | | | ^ | | | MESSAGE IS WRITTEN
|_|_|_v_|_|_|_v_|
| ^ | | | ^ | | | ACROSS EACH ROW
|_|_|_v_|_|_|_v_|
| ^ | | | ^ | | | IN THIS MANNER
|_|_|_v_|_|_|_v_|
| ^ | | | ^ | | | LAST ROW IS FILLED WITH DUMMY WORDS
|_|_|_v_|_|_|_v_|
START        END

Required inputs - a text message, # of columns, # of rows, key string
Requires custom-made "perms" module to generate keys
Prints off key used and translated plaintext
"""
import sys
import perms

#==============================================================================
# USER INPUT:

# the string to be decrypted (type or paste between triple-quotes):
ciphertext = """REST TRANSPORT YOU GODWIN VILLAGE ROANOKE WITH ARE YOUR IS JUST
SUPPLIES FREE SNOW HEADING TO GONE TO SOUTH FILLER
"""

# the number of columns believed to be in the transposition matrix:
COLS = 4

# the number of rows believed to be in the transposition matrix:
ROWS = 5

# END OF USER INPUT - DO NOT EDIT BELOW THIS LINE!
#==============================================================================

def main():
    """Turn ciphertext into list, call validation & decryption functions."""
    cipherlist = list(ciphertext.split())
    validate_col_row(cipherlist)
    decrypt(cipherlist)

def validate_col_row(cipherlist):
    """Check that input columns & rows are valid vs. message length."""
    factors = []
    len_cipher = len(cipherlist)
    for i in range(2, len_cipher):  # range excludes 1-column ciphers
        if len_cipher % i == 0:
            factors.append(i)
    print("\nLength of cipher = {}".format(len_cipher))
    print("Acceptable column/row values include: {}".format(factors))
    print()
    if ROWS * COLS != len_cipher:
        print("\nError - Input columns & rows not factors of length "
              "of cipher. Terminating program.", file=sys.stderr)
        sys.exit(1)

def decrypt(cipherlist):
    """Turn columns into items in list of lists & decrypt ciphertext."""
    col_combos = perms.perms(COLS)
    for key in col_combos:
        translation_matrix = [None] * COLS
        plaintext = ''
        start = 0
        stop = ROWS
        for k in key:
            if k < 0: # reading bottom-to-top of column
                col_items = cipherlist[start:stop]
            elif k > 0: # reading top-to-bottom of columnn
                col_items = list((reversed(cipherlist[start:stop])))
            translation_matrix[abs(k) - 1] = col_items
            start += ROWS
            stop += ROWS
        # loop through nested lists popping off last item to a new list:
        for i in range(ROWS):
            for matrix_col in translation_matrix:
                word = str(matrix_col.pop())
                plaintext += word + ' '
        print("\nusing key = {}".format(key))
        print("translated = {}".format(plaintext))
    print("\nnumber of keys = {}".format(len(col_combos)))

if __name__ == '__main__':
    main()
perms.py

perms.py

"""For a total number of columns, find all unique column arrangements.

Builds a list of lists containing all possible unique arrangements of
individual column numbers including negative values for route direction

Input:
-total number of columns

Returns:
-list of lists of unique column orders including negative values for
route cipher encryption direction

"""
from itertools import permutations, product

# build list of lists of column number combinations
# itertools product computes the Cartesian product of input iterables
def perms(num_cols):
    """Take number of columns integer & generate pos & neg permutations."""
    results = []
    columns = [x for x in range(1, num_cols+1)]
    for perm in permutations(columns):
        for signs in product([-1, 1], repeat=len(columns)):
            results.append([i*sign for i, sign in zip(perm, signs)])
    return results

编码英国内战密码**

拯救玛丽

save_Mary_practice.py

"""Hide a null cipher within a list of names using a variable pattern."""
import load_dictionary

# write a short message and use no punctuation or numbers!
message = "Give your word and we rise"
message = "".join(message.split())

# open name file
names = load_dictionary.load('supporters.txt')

name_list = []

# start list with null word not used in cipher
name_list.append(names[0])

# add letter of null cipher to 2nd letter of name, then 3rd, then repeat
count = 1
for letter in message:
    for name in names:
        if len(name) > 2 and name not in name_list:
            if count % 2 == 0 and name[2].lower() == letter.lower():
                name_list.append(name)
                count += 1
                break
            elif count % 2 != 0 and name[1].lower() == letter.lower():
                name_list.append(name)
                count += 1
                break

# add two null words early in message to throw off cryptanalysts
name_list.insert(3, 'Stuart')
name_list.insert(6, 'Jacob')

# display cover letter and list with null cipher
print("""
Your Royal Highness: \n
It is with the greatest pleasure I present the list of noble families who
have undertaken to support your cause and petition the usurper for the
release of your Majesty from the current tragical circumstances.
""")

print(*name_list, sep='\n')

科尔切斯特捕获

colchester_practice.py

"""Solve a null cipher based on every nth letter in every nth word."""
import sys

def load_text(file):
    """Load a text file as a string."""
    with open(file) as f:
        return f.read().strip()

# load & process message:
filename = input("\nEnter full filename for message to translate: ")
try:
    loaded_message = load_text(filename)
except IOError as e:
    print("{}. Terminating program.".format(e), file=sys.stderr)
    sys.exit(1)

# check loaded message & # of lines
print("\nORIGINAL MESSAGE = {}\n".format(loaded_message))

# convert message to list and get length
message = loaded_message.split()
end = len(message)

# get user input on interval to check
increment = int(input("Input max word & letter position to \
                      check (e.g., every 1 of 1, 2 of 2, etc.): "))
print()

# find letters at designated intervals
for i in range(1, increment + 1):
    print("\nUsing increment letter {} of word {}".format(i, i))
    print()
    count = i - 1
    location = i - 1
    for index, word in enumerate(message):
        if index == count:
            if location < len(word):
                print("letter = {}".format(word[location]))
                count += i
            else:
                print("Interval doesn't work", file=sys.stderr)

写隐形墨水**

检查空白行数量

elementary_ink_practice.py

"""Add code to check blank lines in fake message vs lines in real message."""
import sys
import docx
from docx.shared import RGBColor, Pt

# get text from fake message & make each line a list item
fake_text = docx.Document('fakeMessage.docx')
fake_list = []
for paragraph in fake_text.paragraphs:
    fake_list.append(paragraph.text)

# get text from real message & make each line a list item
real_text = docx.Document('realMessageChallenge.docx')
real_list = []
for paragraph in real_text.paragraphs:
    if len(paragraph.text) != 0:  # remove blank lines
        real_list.append(paragraph.text)

# define function to check available hiding space:
def line_limit(fake, real):
    """Compare number of blank lines in fake vs lines in real and
    warn user if there are not enough blanks to hold real message.

    NOTE:  need to import 'sys'

    """
    num_blanks = 0
    num_real = 0
    for line in fake:
        if line == '':
            num_blanks += 1
    num_real = len(real)
    diff = num_real - num_blanks
    print("\nNumber of blank lines in fake message = {}".format(num_blanks))
    print("Number of lines in real message = {}\n".format(num_real))
    if num_real > num_blanks:
        print("Fake message needs {} more blank lines."
              .format(diff), file=sys.stderr)
        sys.exit()

line_limit(fake_list, real_list)

# load template that sets style, font, margins, etc.
doc = docx.Document('template.docx')

# add letterhead
doc.add_heading('Morland Holmes', 0)
subtitle = doc.add_heading('Global Consulting & Negotiations', 1)
subtitle.alignment = 1
doc.add_heading('', 1)
doc.add_paragraph('December 17, 2015')
doc.add_paragraph('')

def set_spacing(paragraph):
    """Use docx to set line spacing between paragraphs."""
    paragraph_format = paragraph.paragraph_format
    paragraph_format.space_before = Pt(0)
    paragraph_format.space_after = Pt(0)

length_real = len(real_list)
count_real = 0  # index of current line in real (hidden) message

# interleave real and fake message lines
for line in fake_list:
    if count_real < length_real and line == "":
        paragraph = doc.add_paragraph(real_list[count_real])
        paragraph_index = len(doc.paragraphs) - 1

        # set real message color to white
        run = doc.paragraphs[paragraph_index].runs[0]
        font = run.font
        font.color.rgb = RGBColor(255, 255, 255)  # make it red to test
        count_real += 1

    else:
        paragraph = doc.add_paragraph(line)

    set_spacing(paragraph)

doc.save('ciphertext_message_letterhead.docx')

print("Done"))

为俳句诗歌计数音节**

音节计数器与词典文件

test_count_syllables_w_dict.py

"""Load a dictionary file, pick random words, run syllable-counting module."""
import sys
import random
from count_syllables import count_syllables

def load(file):
    """Open a text file & return list of lowercase strings."""
    with open(file) as in_file:
        loaded_txt = in_file.read().strip().split('\n')
        loaded_txt = [x.lower() for x in loaded_txt]
        return loaded_txt
try:
    word_list = load('2of4brif.txt')
except IOError as e:
    print("{}\nError opening file. Terminating program.".format(e),
          file=sys.stderr)
    sys.exit(1)

test_data = []
num_words = 100
test_data.extend(random.sample(word_list, num_words))

for word in test_data:
    try:
        num_syllables = count_syllables(word)
        print(word, num_syllables, end='\n')
    except KeyError:
        print(word, end='')
        print(" not found", file=sys.stderr)

我们是孤独的吗?探索费米悖论**

遥远的银河

galaxy_practice.py

"""Use spiral formula to build galaxy display."""
import math
from random import randint
import tkinter

root = tkinter.Tk()
root.title("Galaxy BR549")
c = tkinter.Canvas(root, width=1000, height=800, bg='black')
c.grid()
c.configure(scrollregion=(-500, -400, 500, 400))
oval_size = 0

# build spiral arms
num_spiral_stars = 500
angle = 3.5
core_diameter = 120
spiral_stars = []
for i in range(num_spiral_stars):
    theta = i * angle
    r = math.sqrt(i) / math.sqrt(num_spiral_stars)
    spiral_stars.append((r * math.cos(theta), r * math.sin(theta)))
for x, y in spiral_stars:
    x = x * 350 + randint(-5, 3)
    y = y * 350 + randint(-5, 3)
    oval_size = randint(1, 3)
    c.create_oval(x-oval_size, y-oval_size, x+oval_size, y+oval_size,
                  fill='white', outline='')

# build wisps
wisps = []
for i in range(2000):
    theta = i * angle
    # divide by num_spiral_stars for better dust lanes
    r = math.sqrt(i) / math.sqrt(num_spiral_stars)
    spiral_stars.append((r * math.cos(theta), r * math.sin(theta)))
for x, y in spiral_stars:
    x = x * 330 + randint(-15, 10)
    y = y * 330 + randint(-15, 10)
    h = math.sqrt(x**2 + y**2)
    if h < 350:
        wisps.append((x, y))
        c.create_oval(x-1, y-1, x+1, y+1, fill='white', outline='')

# build galactic core
core = []
for i in range(900):
    x = randint(-core_diameter, core_diameter)
    y = randint(-core_diameter, core_diameter)
    h = math.sqrt(x**2 + y**2)
    if h < core_diameter - 70:
        core.append((x, y))
        oval_size = randint(2, 4)
        c.create_oval(x-oval_size, y-oval_size, x+oval_size, y+oval_size,
                      fill='white', outline='')
    elif h < core_diameter:
        core.append((x, y))
        oval_size = randint(0, 2)
        c.create_oval(x-oval_size, y-oval_size, x+oval_size, y+oval_size,
                      fill='white', outline='')

root.mainloop()

建立银河帝国

empire_practice.py

"""Build 2-D model of galaxy, post expansion rings for galactic empire."""
import tkinter as tk
import time
from random import randint, uniform, random
import math

#=============================================================================
# MAIN INPUT

# location of galactic empire homeworld on map:
HOMEWORLD_LOC = (0, 0)

# maximum number of years to simulate:
MAX_YEARS = 10000000

# average expansion velocity as fraction of speed of light:
SPEED = 0.005

# scale units
UNIT = 200

#======================================================================

# set up display canvas
root = tk.Tk()
root.title("Milky Way galaxy")
c = tk.Canvas(root, width=1000, height=800, bg='black')
c.grid()
c.configure(scrollregion=(-500, -400, 500, 400))

# actual Milky Way dimensions (light-years)
DISC_RADIUS = 50000

disc_radius_scaled = round(DISC_RADIUS/UNIT)

def polar_coordinates():
    """Generate uniform random x,y point within a disc for 2-D display."""
    r = random()
    theta = uniform(0, 2 * math.pi)
    x = round(math.sqrt(r) * math.cos(theta) * disc_radius_scaled)
    y = round(math.sqrt(r) * math.sin(theta) * disc_radius_scaled)
    return x, y

def spirals(b, r, rot_fac, fuz_fac, arm):
    """Build spiral arms for tkinter display using Logarithmic spiral formula.

    b = arbitrary constant in logarithmic spiral equation
    r = scaled galactic disc radius
    rot_fac = rotation factor
    fuz_fac = random shift in star position in arm, applied to 'fuzz' variable
    arm = spiral arm (0 = main arm, 1 = trailing stars)
    """
    spiral_stars = []
    fuzz = int(0.030 * abs(r))  # randomly shift star locations
    theta_max_degrees = 520
    for i in range(theta_max_degrees):  # range(0, 700, 2) for no black hole
        theta = math.radians(i)
        x = r * math.exp(b*theta) * math.cos(theta + math.pi * rot_fac)\
            + randint(-fuzz, fuzz) * fuz_fac
        y = r * math.exp(b*theta) * math.sin(theta + math.pi * rot_fac)\
            + randint(-fuzz, fuzz) * fuz_fac
        spiral_stars.append((x, y))
    for x, y in spiral_stars:
        if arm == 0 and int(x % 2) == 0:
            c.create_oval(x-2, y-2, x+2, y+2, fill='white', outline='')
        elif arm == 0 and int(x % 2) != 0:
            c.create_oval(x-1, y-1, x+1, y+1, fill='white', outline='')
        elif arm == 1:
            c.create_oval(x, y, x, y, fill='white', outline='')

def star_haze(scalar):
    """Randomly distribute faint tkinter stars in galactic disc.
    disc_radius_scaled = galactic disc radius scaled to radio bubble diameter
    scalar = multiplier to vary number of stars posted
    """
    for i in range(0, disc_radius_scaled * scalar):
        x, y = polar_coordinates()
        c.create_text(x, y, fill='white', font=('Helvetica', '7'), text='.')

def model_expansion():
    """Model empire expansion from homeworld with concentric rings."""
    r = 0 # radius from homeworld
    text_y_loc = -290
    x, y = HOMEWORLD_LOC
    c.create_oval(x-5, y-5, x+5, y+5, fill='red')
    increment = round(MAX_YEARS / 10)# year interval to post circles
    c.create_text(-475, -350, anchor='w', fill='red', text='Increment = {:,}'
                  .format(increment))
    c.create_text(-475, -325, anchor='w', fill='red',
                  text='Velocity as fraction of Light = {:,}'.format(SPEED))

    for years in range(increment, MAX_YEARS + 1, increment):
        time.sleep(0.5) # delay before posting new expansion circle
        traveled = SPEED * increment / UNIT
        r = r + traveled
        c.create_oval(x-r, y-r, x+r, y+r, fill='', outline='red', width='2')
        c.create_text(-475, text_y_loc, anchor='w', fill='red',
                      text='Years = {:,}'.format(years))
        text_y_loc += 20
        # update canvas for new circle; no longer need mainloop()
        c.update_idletasks()
        c.update()

def main():
    """Generate galaxy display, model empire expansion, run mainloop."""
    spirals(b=-0.3, r=disc_radius_scaled, rot_fac=2, fuz_fac=1.5, arm=0)
    spirals(b=-0.3, r=disc_radius_scaled, rot_fac=1.91, fuz_fac=1.5, arm=1)
    spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=2, fuz_fac=1.5, arm=0)
    spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=-2.09, fuz_fac=1.5, arm=1)
    spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=0.5, fuz_fac=1.5, arm=0)
    spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=0.4, fuz_fac=1.5, arm=1)
    spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=-0.5, fuz_fac=1.5, arm=0)
    spirals(b=-0.3, r=-disc_radius_scaled, rot_fac=-0.6, fuz_fac=1.5, arm=1)
    star_haze(scalar=9)

    model_expansion()

    # run tkinter loop
    root.mainloop()

if __name__ == '__main__':
    main()

预测可检测性的曲折方法

rounded_detection_practice.py

"""Calculate probability of detecting 32 LY-diameter radio bubble given 15.6 M
randomly distributed civilizations in the galaxy."""
import math
from random import uniform, random
from collections import Counter

# length units in light-years
DISC_RADIUS = 50000
DISC_HEIGHT = 1000
NUM_CIVS = 15600000
DETECTION_RADIUS = 16

def random_polar_coordinates_xyz():
    """Generate uniform random xyz point within a 3D disc."""
    r = random()
    theta = uniform(0, 2 * math.pi)
    x = round(math.sqrt(r) * math.cos(theta) * DISC_RADIUS, 3)
    y = round(math.sqrt(r) * math.sin(theta) * DISC_RADIUS, 3)
    z = round(uniform(0, DISC_HEIGHT), 3)
    return x, y, z

def rounded(n, base):
    """Round a number to the nearest number designated by base parameter."""
    return int(round(n/base) * base)

def distribute_civs():
    """Distribute xyz locations in galactic disc model and return list."""
    civ_locs = []
    while len(civ_locs) < NUM_CIVS:
        loc = random_polar_coordinates_xyz()
        civ_locs.append(loc)
    return civ_locs

def round_civ_locs(civ_locs):
    """Round xyz locations and return list of rounded locations."""
    # convert radius to cubic dimensions:
    detect_distance = round((4 / 3 * math.pi * DETECTION_RADIUS**3)**(1/3))
    print("\ndetection radius = {} LY".format(DETECTION_RADIUS))
    print("cubic detection distance = {} LY".format(detect_distance))

    # round civilization xyz to detection distance
    civ_locs_rounded = []

    for x, y, z in civ_locs:
        i = rounded(x, detect_distance)
        j = rounded(y, detect_distance)
        k = rounded(z, detect_distance)
        civ_locs_rounded.append((i, j, k))

    return civ_locs_rounded

def calc_prob_of_detection(civ_locs_rounded):
    """Count locations and calculate probability of duplicate values."""
    overlap_count = Counter(civ_locs_rounded)
    overlap_rollup = Counter(overlap_count.values())
    num_single_civs = overlap_rollup[1]
    prob = 1 - (num_single_civs / NUM_CIVS)

    return overlap_rollup, prob

def main():
    """Call functions and print results."""
    civ_locs = distribute_civs()
    civ_locs_rounded = round_civ_locs(civ_locs)
    overlap_rollup, detection_prob = calc_prob_of_detection(civ_locs_rounded)
    print("length pre-rounded civ_locs = {}".format(len(civ_locs)))
    print("length of rounded civ_locs_rounded = {}".format(len(civ_locs_rounded)))
    print("overlap_rollup = {}\n".format(overlap_rollup))
    print("probability of detection = {0:.3f}".format(detection_prob))

    # QC step to check rounding
    print("\nFirst 3 locations pre- and post-rounding:\n")
    for i in range(3):
        print("pre-round: {}".format(civ_locs[i]))
        print("post-round: {} \n".format(civ_locs_rounded[i]))

if __name__ == '__main__':
    main()

蒙提·霍尔问题**

生日悖论

birthday_paradox_practice.py

"""Calculate probability of a shared birthday per x number of people."""
import random

max_people = 50
num_runs = 2000

print("\nProbability of at least 2 people having the same birthday:\n")

for people in range(2, max_people + 1):
    found_shared = 0
    for run in range(num_runs):
        bdays = []
        for i in range(0, people):
            bday = random.randrange(0, 365)  # ignore leap years
            bdays.append(bday)
        set_of_bdays = set(bdays)
        if len(set_of_bdays) < len(bdays):
            found_shared += 1
    prob = found_shared/num_runs
    print("Number people = {} Prob = {:.4f}".format(people, prob))

print("""
According to the Birthday Paradox, if there are 23 people in a room,
there's a 50% chance that 2 of them will share the same birthday.
""")

模拟外星火山**

跨越距离

practice_45.py

import sys
import math
import random
import pygame as pg

pg.init()  # initialize pygame

# define color table
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
LT_GRAY = (180, 180, 180)
GRAY = (120, 120, 120)
DK_GRAY = (80, 80, 80)

class Particle(pg.sprite.Sprite):
    """Builds ejecta particles for volcano simulation."""

    gases_colors = {'SO2': LT_GRAY, 'CO2': GRAY, 'H2S': DK_GRAY, 'H2O': WHITE}

    VENT_LOCATION_XY = (320, 300)
    IO_SURFACE_Y = 308
    GRAVITY = 0.5  # pixels-per-frame
    VELOCITY_SO2 = 8  # pixels-per-frame

    # scalars (SO2 atomic weight/particle atomic weight) used for velocity
    vel_scalar = {'SO2': 1, 'CO2': 1.45, 'H2S': 1.9, 'H2O': 3.6}

    def __init__(self, screen, background):
        super().__init__()
        self.screen = screen
        self.background = background
        self.image = pg.Surface((4, 4))
        self.rect = self.image.get_rect()
        self.gas = 'SO2'
        self.color = ''
        self.vel = Particle.VELOCITY_SO2 * Particle.vel_scalar[self.gas]
        self.x, self.y = Particle.VENT_LOCATION_XY
        self.vector()

    def vector(self):
        """Calculate particle vector at launch."""
        angles = [65, 55, 45, 35, 25]  # 90 is vertical
        orient = random.choice(angles)
        if orient == 45:
            self.color = WHITE
        else:
            self.color = GRAY
        radians = math.radians(orient)
        self.dx = self.vel * math.cos(radians)
        self.dy = -self.vel * math.sin(radians)  # negative as y increases down

    def update(self):
        """Apply gravity, draw path, and handle boundary conditions."""
        self.dy += Particle.GRAVITY
        pg.draw.line(self.background, self.color, (self.x, self.y),
                     (self.x + self.dx, self.y + self.dy))
        self.x += self.dx
        self.y += self.dy
        if self.x < 0 or self.x > self.screen.get_width():
            self.kill()
        if self.y < 0 or self.y > Particle.IO_SURFACE_Y:
            self.kill()

def main():
    """Set up and run game screen and loop."""
    screen = pg.display.set_mode((639, 360))
    pg.display.set_caption("Io Volcano Simulator")
    background = pg.image.load("tvashtar_plume.gif")

    # Set up color-coded legend
    legend_font = pg.font.SysFont('None', 26)
    text = legend_font.render('White = 45 degrees', True, WHITE, BLACK)

    particles = pg.sprite.Group()

    clock = pg.time.Clock()

    while True:
        clock.tick(25)
        particles.add(Particle(screen, background))
        for event in pg.event.get():
            if event.type == pg.QUIT:
                pg.quit()
                sys.exit()

        screen.blit(background, (0, 0))
        screen.blit(text, (320, 170))

        particles.update()
        particles.draw(screen)

        pg.display.flip()

if __name__ == "__main__":
    main()

利用本福德定律发现欺诈**

击败本福德

beat_benford_practice.py

"""Manipulate vote counts so that final results conform to Benford's law."""

# example below is for Trump vs. Clinton, Illinois, 2016 Presidental Election

def load_data(filename):
    """Open a text file of numbers & turn contents into a list of integers."""
    with open(filename) as f:
        lines = f.read().strip().split('\n')
        return [int(i) for i in lines]  # turn strings to integers

def steal_votes(opponent_votes, candidate_votes, scalar):
    """Use scalar to reduce one vote count & increase another, return as lists.

    Arguments:
    opponent_votes – votes to steal from
    candidate_votes - votes to increase by stolen amount
    scalar - fractional percentage, < 1, used to reduce votes

    Returns:
    list of changed opponent votes
    list of changed candidate votes

    """
    new_opponent_votes = []
    new_candidate_votes = []
    for opp_vote, can_vote in zip(opponent_votes, candidate_votes):
        new_opp_vote = round(opp_vote * scalar)
        new_opponent_votes.append(new_opp_vote)
        stolen_votes = opp_vote - new_opp_vote
        new_can_vote = can_vote + stolen_votes
        new_candidate_votes.append(new_can_vote)
    return new_opponent_votes, new_candidate_votes

def main():
    """Run the program.

    Load data, set target winning vote count, call functions, display
    results as table, write new combined vote total as text file to
    use as input for Benford's law analysis.

    """
    # load vote data
    c_votes = load_data('Clinton_votes_Illinois.txt')
    j_votes = load_data('Johnson_votes_Illinois.txt')
    s_votes = load_data('Stein_votes_Illinois.txt')
    t_votes = load_data('Trump_votes_Illinois.txt')

    total_votes = sum(c_votes + j_votes + s_votes + t_votes)

    # assume Trump amasses a plurality of the vote with 49%
    t_target = round(total_votes * 0.49)
    print("\nTrump winning target = {:,} votes".format(t_target))

    # calculate extra votes needed for Trump victory
    extra_votes_needed = abs(t_target - sum(t_votes))
    print("extra votes needed = {:,}".format(extra_votes_needed))

    # calculate scalar needed to generate extra votes
    scalar = 1 - (extra_votes_needed / sum(c_votes + j_votes + s_votes))
    print("scalar = {:.3}".format(scalar))
    print()

    # flip vote counts based on scalar & build new combined list of votes
    fake_counts = []
    new_c_votes, new_t_votes = steal_votes(c_votes, t_votes, scalar)
    fake_counts.extend(new_c_votes)
    new_j_votes, new_t_votes = steal_votes(j_votes, new_t_votes, scalar)
    fake_counts.extend(new_j_votes)
    new_s_votes, new_t_votes = steal_votes(s_votes, new_t_votes, scalar)
    fake_counts.extend(new_s_votes)
    fake_counts.extend(new_t_votes)  # add last as has been changing up til now

    # compare old and new vote counts & totals in tabular form
    # switch-out "Trump" and "Clinton" as necessary
    for i in range(0, len(t_votes)):
        print("old Trump: {} \t new Trump: {} \t old Clinton: {} \t " \
              "new Clinton: {}".
              format(t_votes[i], new_t_votes[i], c_votes[i], new_c_votes[i]))
        print("-" * 95)
    print("TOTALS:")
    print("old Trump: {:,} \t new Trump: {:,} \t old Clinton: {:,}  " \
          "new Clinton: {:,}".format(sum(t_votes), sum(new_t_votes),
                                     sum(c_votes), sum(new_c_votes)))

    # write out a text file to use as input to benford.py program
    # this program will check conformance of faked votes to Benford's law
    with open('fake_Illinois_counts.txt', 'w') as f:
        for count in fake_counts:
            f.write("{}\n".format(count))

if __name__ == '__main__':
    main()
posted @ 2025-11-27 09:17  绝不原创的飞龙  阅读(24)  评论(0)    收藏  举报