随机性的艺术-全-
随机性的艺术(全)
原文:
zh.annas-archive.org/md5/f812e066945cbc0a186ab62ee866fc8a译者:飞龙
前言

在这本书中,随机性是通过随机过程产生的,随机过程在被请求时返回一个输出。这个输出通常是一个 0 到 1 之间的数字,但也可以是任何预定义集合中的元素(如小于 100 的整数、字母、颜色、狗的品种等)。在一个随机过程中,了解之前的输出对于预测未来的输出没有任何帮助。一个随机过程的输出必须是不可预测的;我们无法提前知道下一个返回的值或集合中的元素是什么。
这个定义避开了任何哲学上的顾虑。事实上,我们并不关心是否存在真正的随机性;我们关心的是,有些过程能以足够的精度逼近这个定义,从而使我们能够实现目标。
最终,这本书是关于通过使用随机过程来解决问题的。
这本书适合谁?
这本书适合那些对使用随机过程实现非随机目标感到好奇或着迷的人。正如我们将要学习的那样,随机性为我们提供了一个强有力的方法,可以解决那些原本可能超出我们理解范围的问题。
如果你从事计算机科学、工程或数学工作;如果你是视觉艺术家或音乐家;或者你是任何领域的科学家,这本书会对你有所帮助。这本书适合所有足够好奇、愿意拿起它、翻阅并阅读这些文字的人。简而言之,这本书适合你。
你能学到什么?
这本书是对我们可以称之为应用随机性的主题性调查。到最后,你将知道何时可以将随机性作为有效工具,以及如何使用它。
以下内容提供了每一章的描述。
第一章:随机性的本质 探讨了生成真正的随机性以及通过确定性算法逼近随机性的方法。
第二章:隐藏信息 讨论了如何将随机性作为隐写术的核心部分,隐写术是一种让信息不被察觉地隐藏起来的艺术。
第三章:模拟现实世界 探讨了由随机性驱动的仿真,这对于几乎所有现代科学、工程和经济学领域都至关重要。
第四章:优化世界 讨论了一种出乎意料的强大优化方法,涉及一群智能体或解的进化,这两者都使用随机性。
第五章:群体优化 继续展示涉及随机性的优化实例。
第六章:机器学习 探讨了神经网络、极限学习机、随机森林以及其他机器学习形式如何大量利用随机性。
第七章:艺术 考虑了如何利用随机性创作艺术的方式。
第八章:音乐 尝试从零开始生成音乐并演变出愉悦的旋律。
第九章:音频信号 探索了压缩感知如何通过稀疏的随机样本集合重建信号。
第十章:实验设计 展示了随机性如何在实验设计中发挥作用,以及它在从研究中获得有意义结果中的重要性。
第十一章:计算机科学算法 探讨了在计算机科学领域常用的随机化算法。
第十二章:采样 讨论了从复杂概率分布中抽取样本是应用概率模型和贝叶斯分析时的基本要求。随机性是这个过程的关键。
这本书的结尾列出了帮助你了解每一章主题的额外资源。
我期望你知道的内容
这是一本中级水平的书。你应该熟悉编程,特别是 Python,并且对 Python 的标准扩展库如 NumPy、Matplotlib 以及稍微了解 Pillow、SciPy 和 scikit-learn 有一定的熟悉。如果这些名字对你来说毫无意义,你可以在本书的 GitHub 网站上找到简短的介绍(github.com/rkneusel9/TheArtOfRandomness)。
你应该对高中数学感到舒适,甚至可能超出一些。当涉及到数学内容时,我会进行解释;无论如何,你从直接阅读代码中都会受益,即使有些数学内容不太清晰。
如何使用本书
这是一本实践性很强的书,充满了实验内容。代码是用 Python 编写的,使用了之前提到的标准库。需要时,我会描述额外的库。我假设你使用的是运行 Ubuntu 20.04 或更高版本的 Linux 系统。所有内容也可以在 Windows 或 macOS 上运行,但安装 Python 库可能稍微复杂一些。
要在 Ubuntu 下安装核心库,或者在安装了 pip3 的 Windows 和 macOS 上,可以尝试以下命令:
> pip3 install numpy
> pip3 install scipy
> pip3 install matplotlib
> pip3 install pillow
> pip3 install scikit-learn
无论这些命令安装的是哪个版本,都应该能正常工作。
如果使用 Windows,建议从 www.python.org 安装 Python。任何当前版本的 Python 3 都可以。安装时,选择安装 pip 或 pip3 的选项,并将 Python 添加到你的系统路径中,这样你就可以在命令提示符下运行它。如果这样做,安装命令应该会成功。
在继续阅读之前,我建议你从本书的 GitHub 仓库中下载所有文件,网址是 github.com/rkneusel9/TheArtOfRandomness,你可以通过命令行克隆仓库来下载。
> git clone [`github.com/rkneusel9/TheArtOfRandomness`](https://github.com/rkneusel9/TheArtOfRandomness)
或者使用浏览器将所有内容下载为 ZIP 文件(点击 代码)。
第一章是起点,在这一章中,我们了解了真正的随机过程以及那些是确定性近似的过程,还有一些混合方法。本章还开发了随机性引擎,这是一个我们将在每个实验中使用的 Python 类。随机性引擎为实验提供随机值,并让我们在不同的随机性来源之间进行选择。
可以随意以任何顺序阅读剩余章节,尽管我推荐按顺序阅读第四章和第五章。
每一章都会介绍一个主题,然后通过实验来探索它。一些实验包括低分辨率的测试图像,以展示概念的实际应用。首先,我们运行代码(在你提前查看代码之后),并解释结果。大多数实验允许多种参数设置,以帮助你建立直觉。接着,我们会分析代码的关键部分,以了解随机性是如何以及在何处发挥作用的。许多章节最后会有一个“练习”部分,提出一些问题,鼓励你继续探索。
你可能会有问题,随时可以联系我。如果你发现任何 bug,或者用本书中的资料做出了令人惊叹的成果,也请告诉我:rkneuselbooks@gmail.com 。
第一章:随机性的本质**

随机过程驱动着我们在本书后面将要开发的系统。本章介绍了特定的随机过程,包括那些真正的随机过程,以及那些是确定性的但仍然足够随机以供使用的过程——即伪随机和拟随机过程。
我们将从简要讨论概率与随机性之间的关系开始。在学习如何判断一个过程是否是随机的之后,我们将探索真正的随机过程,即那些受到真实随机性强烈影响的过程。我们还将学习伪随机和拟随机过程之间的区别。最后,我们将使用 Python 创建 RE 类,这是我们将在所有实验中使用的随机性引擎。
概率与随机性
概率分布表示一个随机变量可以取的所有可能值,以及每个值出现的可能性。对我们来说,随机变量是一个随机过程的输出。
概率分布有两种类型。连续型概率分布返回来自无限集合的值,这意味着在允许范围内的任何实数。在这里,实数指的是实数集合ℝ中的元素,所有数字都在数轴上。离散型概率分布则限制返回来自有限集合的值,比如硬币的正反面或骰子上的数字。
随机过程生成的值被称为样本,这些值来自某种概率分布,无论是连续型的还是离散型的。例如,掷硬币会产生正面或反面的样本,而掷骰子会从集合 {1, 2, 3, 4, 5, 6} 中返回样本(假设是标准六面骰)。
如果一个随机过程返回一个单一的数字,我们如何知道它是从哪个分布中采样的呢?在某些情况下,我们有理论知识,但在其他情况下,我们只能生成大量样本。随着时间的推移,每种可能结果出现的相对频率将变得显而易见,并充当真实概率分布的替代品。
离散分布
作为离散概率分布的一个例子,假设由于一位当地巫师的慷慨,我拥有一颗独一无二的三面骰。我的三面骰上显示的是数字 0、1 或 2。因此,每次掷骰时,都会有一面朝上。当我掷骰子 50,000 次,并记录每一面朝上的次数时,我得到了 表 1-1 中的结果。
表 1-1: 掷三面骰 50,000 次
| 面 | 次数 |
|---|---|
| 0 | 33,492 |
| 1 | 8,242 |
| 2 | 8,266 |
结果表明,出现 0、1 或 2 的概率是不相等的:0 出现了 33,492/50,000 = 66.984%的时间,1 出现了 16.484%的时间,2 出现了 16.532%的时间。如果我重复实验,每个结果的精确次数会略有不同,但显然,很多这样的实验会导致结果,其中 0 大约 67%的时间出现,1 和 2 各自大约 16.5%的时间出现。注意,67% + 16.5% + 16.5% = 100%,如果我的魔法骰子只有 0、1、2 三种结果,且比例为 67:16.5:16.5,那么这就是应该出现的情况。
掷三面骰子是一个随机过程,从一个概率分布中抽样,其中 0 的概率是 67%,1 或 2 的概率各是 16.5%。结果是有限的,所以概率分布是离散的。
让我们再做两个实验,它们从离散概率分布中抽样。第一个实验掷 50,000 次公平的硬币。第二个实验掷 50,000 次标准的六面骰子。如之前所做,我们将不同的结果统计出来,用 0 表示反面,用 1 表示正面。表 1-2 显示了硬币翻转的结果。
表 1-2: 公平硬币掷 50,000 次
| 面 | 次数 |
|---|---|
| 0 | 25,040 |
| 1 | 24,960 |
表 1-3 显示了掷骰子的结果。
表 1-3: 掷标准骰子 50,000 次
| 面 | 次数 |
|---|---|
| 1 | 8,438 |
| 2 | 8,252 |
| 3 | 8,292 |
| 4 | 8,367 |
| 5 | 8,336 |
| 6 | 8,315 |
就像我的魔法三面骰子的结果并不等概率一样,掷硬币和标准六面骰子的结果则是每个结果具有相等的概率。其计数几乎是均匀的。这种均匀分布,即每个可能的结果都具有相等的概率,是本书中我们将使用的最常见的分布类型。
注意
为了进行这个实验,我并没有真的掷 50,000 次硬币和骰子。相反,我使用了伪随机生成器,稍后将在本章中讨论。
连续分布
将连续概率分布视为离散分布的极限。例如,一个六面骰子从六个可能的结果中选择。一个 20 面骰子,有时被称为 D20,从 20 个可能的结果中选择。如果我们能让骰子的面数延伸到无穷大,那么这种骰子每次掷出时,将从无限多个结果中选择。这就是连续概率分布所做的事情。
假设一个随机过程生成范围为 0 到 1 的实数,并且所有实数出现的概率相等,那么该随机过程就是从连续均匀分布中抽样,就像骰子从离散均匀分布中抽样一样。在本书中,我们将广泛使用连续均匀分布。同样,我们偶尔也会使用正态(有时称为高斯)分布。
在这一点引入一些数学符号将使后续内容更容易理解。我们最常用的均匀分布从[0, 1)区间中抽取样本,意味着样本大于或等于 0,并且严格小于 1,即 0 ≤ x < 1。当写出像[0, 1)这样的区间时,请注意是使用方括号还是圆括号。方括号表示包含该限制,而圆括号则不包含该限制。因此,(0, 1)表示一个区间,其中所有可能的实数除了 0 和 1 之外都允许。同样,[0, 1]包括 0 和 1,而 0, 1)包括 0 但不包括 1。后面章节中描述的伪随机和准随机过程通常会生成[0, 1)区间的输出。
均匀分布是直观的,无论是连续的还是离散的:每个可能的结果都有相同的出现概率。然而,在正态分布中,一些值比其他值更可能被生成。
欣赏正态分布的最佳方式是检查其直方图。例如,[图 1-1 展示了来自正态分布的 6000 万个样本的分布。

图 1-1:显示正态(曲线)和均匀(线)分布的直方图
图 1-1 中的值大约从−6 到 6。接近 0 的值最有可能出现,而极端值最不可能出现。正态分布是普遍存在的;许多物理现象都遵循这种分布。关键是,当我们从任何分布中获取大量样本时,均值将遵循正态分布。这就是中心极限定理,它是统计学的基础。
在上一节中,表 1-2 和表 1-3 统计了正反面出现的次数以及每个骰子面出现的次数。直方图是这种表格的图形表示。可能的输出被放入指定宽度的箱子中。对于骰子来说,天然的箱子宽度是 1,这样每个面就会落入带有相同标签的箱子中。
对于连续分布,箱子覆盖一个范围。例如,如果我们有一个生成 0, 1)区间中数字的过程,并且我们想要 10 个箱子,那么我们可能会将每个箱子的宽度设为 0.1。那么,x = 0.3052 的样本将落入从 0 开始计算的第 3 个箱子,因为:
![Image
同样,一个样本x = 0.0451 会落入区间 0,依此类推。当所有样本都被放入区间后,直方图会绘制每个区间的计数或落入该区间的样本比例。比例是通过将每个区间的计数除以所有区间的总和得到的。使用区间比例的直方图可以近似真实的概率分布。
让我们回到图 1-1。该图使用了 1,000 个区间,这解释了为什么曲线看起来更像一条曲线而不是条形图。图中绘制的是每个区间的比例,而不是计数,这让我们可以在不必担心生成直方图所用样本数量的情况下比较不同的分布。随着样本数量的增加,这种直方图会更接近真实的概率分布。
图 1-1 中的水平线表示一个连续的均匀分布,在–6, 6)的范围内选择值。如果每个值的选择概率相等,那么平均而言,每个区间的样本数量会相同,1/1,000 = 0.001,从而解释了均匀分布的y轴值。
我们只需要记住,驱动我们实验的随机过程是根据某种分布生成值的,主要是均匀分布或正态分布。还有许多其他标准分布,但我们在本书中不会深入探讨它们。
注意
想要更深入了解概率和统计,我推荐 Alex Reinhart 的《Statistics Done Wrong》(2015 年)或我的书《深度学习数学》(2021 年),这两本书均由 No Starch Press 出版。你将会看到关于概率和统计的讨论,以及微分学,我们将在本书中的多个地方提到这些内容。
我们需要知道我们的随机过程离“真正的随机”有多近。在深入探讨随机性引擎之前,让我们考虑一下如何测试一个随机过程的输出。
随机性测试
我们怎么知道一个随机过程的输出是否真的是随机的?简短的回答是:我们不能。然而,这不应该让我们气馁。我们并不是试图解决深奥的哲学问题,尽管它们可能很有趣。相反,我们要做的是找到足够好的方法来实现我们的即时目标,仅此而已。
这串二进制数字是随机的吗?
0101010011100000110000011101101111111011100000
好吧,它看起来有点随机,但我们怎么知道呢?早些时候,我们使用每个可能输出的频率来判断样本是否符合预期。一个随机过程以相等的概率生成 0 或 1 也应该符合预期。在这种情况下,有 23 个零和 23 个一。这是否表明序列是随机的?
当我说我们无法判断一个过程是否真的随机时,你开始明白我的意思了。我们所能做的就是应用测试,以增加我们对序列是随机的信心。我们可以检查各种输出的预期频率,但这并不充分。例如,以下序列也有相等数量的零和一:
![图片
我们大多数人不会认为这两种序列是特别随机的。
事实上,前面提到的三个序列都不是随机过程的输出。第一个是 6502 微处理器程序的操作码的二进制表示,用于在旧版 Apple II 计算机的屏幕上显示字母 A。操作码的比特模式不是随机的,而是严重依赖于微处理器的内部架构。我手动生成了另外两个序列,它们有相等数量的零和一。
多年来,研究人员发明了许多统计测试,它们共同设计用于检测一串值是否值得被称为随机。我们在这里没有足够的空间深入探讨这些测试,但它们远远超出了频率的范畴,还考虑了各种短期和长期的相关性。一些这样的测试套件有 DieHarder、TestU01 和 PractRand。这些套件通常需要大量的值,远超过我们在这里能处理的数量。
那么一个人该怎么做呢?我们无法证明一个随机引擎正在生成随机输出,但我们可以获得足够的信心,以便或多或少地相信它。为此,我们将使用一个名为ent的命令行程序,源自熵(entropy)一词。它应用了一组小的统计测试,可能会影响我们的信念。
许多 Linux 发行版都包括ent,但如果你的系统没有,可以使用以下命令安装它:
> sudo apt-get install ent
请访问以下网站,查看编译好的 Windows 版本(以及它的 GitHub 仓库链接,如果你想查看ent的源代码):www.fourmilab.ch/random。 要在 macOS 上安装ent,在前面的命令中将sudo apt-get替换为brew。
ent程序需要一个字节文件,它假定这些字节在[0, 255]范围内均匀分布。这意味着被测试的随机过程必须将其输出转换为一组均匀分布的字节。我们将在本章稍后学习如何做到这一点。
目前,书籍的 GitHub 页面包含一个文件,我们可以用它来理解ent的输出,ent_test.bin。像这样将其传递给ent:
> ent ent_test.bin
Entropy = 7.999996 bits per byte.
Optimum compression would reduce the size
of this 40000000 byte file by 0 percent.
Chi square distribution for 40000000 samples is 241.36, and randomly
would exceed this value 72.09 percent of the times.
Arithmetic mean value of data bytes is 127.5064 (127.5 = random).
Monte Carlo value for Pi is 3.141776714 (error 0.01 percent).
Serial correlation coefficient is -0.000234 (totally uncorrelated = 0.0).
文件ent_test.bin包含了由一种良好但很少使用的伪随机数生成器 MWC(乘法与进位)生成的字节。我们可能期望ent报告该文件是随机的。然而,ent不会为我们做出这个判断。相反,ent会运行一组六个统计测试并报告结果,留给我们自己决定这些结果是否足以支持随机性的信念。
第一个测试衡量字节的熵。熵 是衡量系统无序度的标准。对物理学家来说,熵与系统的微观状态数有关——例如,气体分子的位置和动量,它们导致相同的宏观物理量,如温度和压力,以及这些分子可以如何排列。然而,ent 报告的熵比这更深刻。它是 香农熵,一种衡量信息内容的标准。在这种情况下,它以比特为单位表达。一个字节有 8 比特,因此最大程度随机的序列将具有 8.0 的熵,意味着信息内容最大化。我们的测试文件具有每字节 7.999996 比特的熵,非常接近 8,这是一个良好的迹象。
我们使用 ent 报告的熵来估算文件压缩的可能性。这是熵的另一种表现形式。压缩算法通过利用文件中包含的信息(通过其熵来衡量)来工作。熵越低,数据的冗余性越高,信息内容越低。如果信息内容较低,就有其他方式表达这些信息,占用更少的空间。然而,如果文件是随机的且熵已最大化,则无法以其他方式表达文件内容,因此无法压缩。
接下来,ent 应用了 χ² 测试。这里重要的是报告的百分比。如果这个百分比低于 5% 或高于 95%,那么预期频率——即每个字节值出现的次数——就是可疑的。这里我们得到了 72%,所以我们仍然处于可靠的范围内。
如果字节序列是随机的,我们可能会正确地预计字节的平均值为 255/2 = 127.5。这里,我们得到了一个平均值 127.5064,非常接近。
可以通过随机数估算 π;ent 将此用作另一种随机性测试。在这种情况下,ent 估算出的值与显示的数字相差 0.01%。如果字节序列中有某些因素偏向模拟结果,那么它应该在计算的 π 值中表现出来。我们将在第三章中使用随机数估算 π。
最终输出行应用统计测试来衡量字节 n 与字节 n + 1 的相关性;也就是说,它关注字节的顺序。如果字节之间没有序列相关性,至少在一个字节到下一个字节的层面上,结果系数将为零。在这里,它略微为负,但非常接近零。
总的来说,ent的报告让我们对文件ent_test.bin的内容是否可以称为随机有了很大的信心。你会用这种信心水平来保护你的银行账户吗?我真心希望不是,但我们并不关注密码学;我们关注的是足够随机的随机过程,不论是自然的还是合成的,足以支持我们的实验。为此,ent是我们唯一需要的工具。
然而,ent的输出过于冗长,特别是我们在本章中将经常使用它。让我们定义一个简化版本。与之前显示的输出不同,我将以如下方式报告ent的结果:
entropy: 7.999996
chi2 : 72.09
mean : 127.5064
pi : 3.141776714 (0.01)
corr : -0.000234
让我们开始使用我们精巧的随机性检测器。我们将从真正的随机过程开始。
真正的随机过程
在本节中,我们将回顾几种通常被认为是真正随机过程的来源:掷硬币、掷骰子、各种形式的电噪声以及放射性元素的衰变。我们将在本书后续的实验中使用我们在此创建的数据集。本节涵盖的随机过程也为下一节的伪随机过程提供了对比,后者只是给人随机的假象。
人类在几个世纪以来,已经发展出多种生成随机性的方式,包括掷硬币和掷骰子。让我们考虑这些方法,看看我们是否可以将它们当作随机性引擎来信任。
掷硬币
大多数人认为掷硬币是一个合理的随机性来源,但真的是这样吗?2009 年,两个加利福尼亚大学伯克利分校的本科生总共掷了 40,000 次硬币,并记录了硬币的起始方向,正面朝上或反面朝上。表 1-4 展示了他们的发现(数据使用经许可)。
表 1-4: 手动掷硬币 40,000 次
| 方向 | 正面 | 反面 | p 值 |
|---|---|---|---|
| 朝上 | 10,231 | 9,770 | 0.0011 |
| 反面朝上 | 9,985 | 10,016 | 0.8265 |
看一眼表 1-4,可以看到当硬币正面朝上时,翻转结束时正面朝上的次数更多。反面朝上的情况也是如此;反面朝上的次数更多。我们可以使用χ²检验来查看这些比例是否与公平硬币的预期 50-50 分配一致。得到的 p 值在最右列。
p 值是指在零假设为真的情况下,观测到的正反面次数的概率。在统计检验中,零假设是被检验的假设。在此例中,对于χ²检验,零假设是观测到的正反面次数与相等概率一致。p 值为我们提供了支持或反对该假设的证据。如果 p 值低于标准的、略显任意的经验法则阈值 0.05(5%),则我们认为该 p 值是统计显著的,并宣称反对零假设。
p 值越小,我们的证据就越强。对于 0.05 的 p 值阈值,我们可能会期望大约每 20 次中有 1 次错误地拒绝零假设;而对于 0.01 的 p 值,错误拒绝的比率变成了每 100 次 1 次,随着 p 值变得越来越小,错误拒绝的比率也越来越低。然而,只有死亡和税收是确定的。一个小的 p 值并不是任何事情的证明;它只是一个指标,是相信与否的理由,尽管可能有强有力的证据支持。
再次查看表 1-4 中的“正面朝上”行。p 值为 0.0011,或 0.11%。根据χ² 检验,给定零假设为真,观察到的计数(或更极端的差异)的概率为 0.11%。因此,我们有证据支持拒绝零假设。换句话说,我们有证据表明进行正面朝上的实验部分的受试者 1 不是随机的,而是有偏向正面的倾向。
然而,受试者 2 得到的结果与零假设一致。对于她来说,p 值为 0.8265,或 83%。同样,这里的 p 值意味着χ² 检验报告了 83% 的概率,表明在零假设为真的情况下观察到这些计数是合理的。这完全有道理,因此我们有证据支持零假设,适用于反面朝上的情况。
χ² 检验将计数与预期的 50-50 分布进行了比较。我们可以再进行一次检验:t 检验。t 检验比较两个数据集,并返回一个 p 值,我们可以将其解释为这两个数据集是否由相同的过程生成的可能性。在这个例子中,正面朝上和反面朝上的数据集之间进行 t 检验,得到了 p 值 0.0139,或 1.39%,再次低于标准的 0.05 阈值。这作为证据表明这两个数据集很可能来自不同的过程。
在这种情况下,这意味着什么?我们有两个受试者的单一翻转集,每个受试者每次翻转硬币时,硬币的正面始终朝上。可以想象,但并未证明,受试者 1 在翻转时非常一致,因此可能使得硬币投掷存在偏差,从而当正面是起始条件时,正面出现的几率较大。对于我们来说,这个有趣的例子表明人类不能被信任去随机行动。
我们有证据表明受试者 1 对硬币翻转存在偏差。我们是否被这种偏差困住了?实际上,并没有。美国数学家和计算机科学家约翰·冯·诺依曼提出了一种巧妙的算法,可以使有偏的硬币变得公平。这个算法非常简单:
-
将有偏的硬币翻转两次。
-
如果两次翻转结果相同——即,都是正面或都是反面——从第 1 步重新开始。
-
否则,保留第一次翻转的结果,忽略第二次。
将这个算法应用于受试者 1 生成的正反面序列,我们得到了 2,475 次正面和 2,538 次反面。χ² 检验得到了 0.37 的 p 值,远高于 0.05,这强烈证明结果数据集现在的表现符合预期。
为什么冯·诺依曼算法有效?考虑一枚偏的硬币,其正面朝上的概率不是 0.5,而是 0.8,这意味着反面朝上的概率是 0.2,因为概率之和为 1。在这种情况下,掷硬币两次会产生四种可能的正反面组合,并且具有以下概率:

记住,如果事件是独立的,即使是偏硬币的投掷也是如此,那么它们的概率相乘。而且,正面后接反面和反面后接正面的概率是相等的。因此,在这两种情况下,始终选择第一个(或第二个)结果必然会以相同的概率选择正面或反面。
文件40000cointosses.csv包含了本实验中使用的数据集,以及相关的代码40000cointosses.py。
注意
请查看原始网页,了解关于实验如何进行的其他评论,包括有关将结果仅视为进一步实验可能揭示有趣内容的提示的适当警告: www.stat.berkeley.edu/∼ldous/Real-World/coin_tosses.html。
骰子投掷
投掷公平硬币是一个随机过程,投掷公平骰子也是如此。但真的有“公平”的骰子吗?制造过程中可能存在的缺陷、形状的轻微偏差,或者骰体内的不均匀密度可能会导致偏差。然而,总的来说,尤其是对于我们的目的来说,我们可能认为骰子投掷足够随机,可以作为有用的数据。
我收集了 14 颗六面骰,并使用游戏中的骰盅将它们一起掷出。然后我拍下了结果的照片,以便统计每个面朝上的次数。我重复了这个过程 50 次,总共进行了 700 次骰子投掷。表 1-5 显示了结果。
表 1-5: 骰子投掷计数
| 结果 | 计数 |
|---|---|
| 1 | 122 |
| 2 | 98 |
| 3 | 106 |
| 4 | 126 |
| 5 | 119 |
| 6 | 129 |
正如之前所述,如果骰子是公平的,我们期望每个结果的出现次数相同。在 700 次投掷中,我们期望每个可能的结果出现 700/6 ≈ 117 次——由于实验规模较小,这是一个简单的数字,但足以让我们关注本节的主要问题:掌握我们对真正随机过程的理解。
表 1-5 中的计数值并不是 117,而是有偏差,通常偏差较大。这是否意味着骰子是加权的?也许是,但我们永远无法确定;我们只能收集有利于一个答案的证据。χ²检验是我们在这里选择的工具,就像在前面的硬币掷投中一样。应用它返回的 p 值为 0.28,远高于通常认为具有统计显著性的 0.05 阈值。因此,我们不拒绝零假设,并认为骰子是合理公平的,因此可能是一个真正的随机来源。
如果目标是生成真正的随机数,宏观物理系统可能不足以胜任;涉及的偏差太多,尽管我们可以使用像冯·诺依曼(von Neumann)算法来改善这种情况。而且,基于物理的随机性引擎,或许是基于自动掷骰子的方式,随着时间推移会退化,进一步引入偏差。因此,我们必须朝着不同的方向寻找。现在让我们考虑更适合用作随机性引擎的过程。
轮盘赌轮
轮盘赌是另一个在考虑潜在随机性来源时会想到的物理过程。在轮盘赌中,人们下注预测弹珠最终落在哪个位置。它是试图“破解”系统的人们的最爱目标,因为玩家可以在轮盘转动时下注。从表面上看,轮盘赌应该和掷骰子一样是随机的,但机械缺陷,特别是如果轮盘倾斜哪怕一点点,也会让最终的球位置偏向那些聪明的玩家。
我能找到的第一个轮盘赌“破解”事件发生在 1880 年左右,当时英国人约瑟夫·贾格(Joseph Jagger),一名纺织工人,意识到蒙特卡洛的轮盘赌轮的构造缺陷使他能够可靠地预测球的最终位置,从而比失误更多地获胜。他的成功促使轮盘赌轮设计的改进。
大约在 1960 年,爱德华·索普(Edward Thorp)与克劳德·香农(Claude Shannon)合作,构建了可能是世界上第一个可穿戴计算机,唯一的目的是用来玩轮盘赌。完整的内容可以在索普 1998 年发表的论文《第一个可穿戴计算机的发明》中找到。该计算机体积小,只有 12 个晶体管,并通过藏在鞋子里的脚踏开关操作。当轮盘转动时,脚踏开关启动一个计时器,通过一个小耳机发出八种音调中的一种,每种音调表示球更可能落入的一个八分之一区域。虽然该系统脆弱,但它有效,并且在 1961 年在拉斯维加斯试验时取得了一定的成功。
在 1970 年代,J·多伊恩·法默(J. Doyne Farmer)和诺曼·帕卡德(Norman Packard)基本上用微型计算机重复了这个实验。像索普和香农一样,他们在赌场中同样取得了成功;请参阅法默简短的页面 www.doynefarmer.com/roulette 或托马斯·巴斯(Thomas Bass)所著的《幸福派》(The Eudaemonic Pie)一书中的更详细内容,该书可以通过互联网档案馆在线获取 (archive.org)。
使用电压
大多数台式计算机都有麦克风输入插孔。像 Audacity 这样的程序可以从这个输入设备录制样本。我们可能会认为,在没有连接麦克风的情况下进行录音会得到一个空文件,但事实并非如此。麦克风输入是模拟输入,容易受到电子噪声的影响:由于相关组件的特性和其他环境因素,电压会出现微小的随机变化。我们将利用这种电压的变化作为随机性引擎。
本实验要求你录制一个 WAV 文件。使用什么工具录制都没有关系。我使用了 Audacity,这是一款开源的声音编辑器,适用于大多数操作系统。在 Ubuntu 系统中,可以使用以下命令安装它:
> sudo apt-get install audacity
访问 www.audacityteam.org 安装适用于 Windows 和 macOS 的 Audacity。
我们希望使用单声道(mono)和较高的采样率(如 176,400 Hz,样本每秒)从麦克风输入进行录制。对于 Audacity 来说,这意味着将项目速率更改为 176,400(参见屏幕左下角),并将录音通道下拉菜单更改为单声道。我们还需要选择麦克风作为输入源。系统中该设备的名称超出了我的预知能力,但通过实验,点击麦克风图标旁边的下拉菜单,应该能找到正确的设备。要测试输入,请选择点击开始监视。你应该看到只有一个通道(例如左声道)产生的逐渐变化的声音条。如果没有,继续调整,直到出现。
要录制样本,请点击红色的录音按钮。我建议录制几分钟。要停止录音,请点击方形的停止按钮。在文件菜单中使用适当的选项将录音导出为 WAV 文件,在文件保存对话框中设置输出格式为32 位浮点 PCM。如果你无法使用此方法录制 WAV 文件,本书的网站上有一些小的 WAV 文件,你可以在接下来的代码中使用。
音频信号是表示为随时间变化的连续电压。采样音频信号意味着在设定的时间间隔——即采样率——内测量瞬时电压,并将该电压转换为某个范围内的数字。例如,光盘使用 16 位采样,因此每个电压都被赋予一个范围为−32,768 到 32,767 的数字。采样率决定了电压测量的频率。
当数字化信号被回放时——意味着将其转换回电压以驱动扬声器——音质的好坏取决于用于表示信号的数字数量以及采样的频率。在我们的实验中,样本表示为 32 位浮点数,采样率为 176,400 Hz。相比之下,光盘的采样率为 44,100 Hz。
图 1-2 展示了一部分音频样本及其对应的直方图。

图 1-2:音频样本(左)和相应的直方图(右)
图 1-2 中的x轴是时间,即样本编号,y轴是浮动的样本值,或表示当时电压的数字化值。水平线是文件中所有样本的均值;图 1-2 只显示了前 200 个。正如我们所预料的,均值几乎为 0。
图 1-2 的右侧显示了一个两秒钟片段中所有样本的直方图。我们以前见过这种形状;它几乎与图 1-1 完全相同,告诉我们噪声样本呈正态分布,均值为 0。我们将利用这个观察结果,把音频流转换成一个随机字节文件。
我们不能直接使用录音;必须处理样本,使其更具随机性,使用silence.py中的代码。让我们逐部分地分析。
首先,我们import一些库函数:
import sys
import numpy as np
from scipy.io.wavfile import read as wavread
sys模块提供了命令行的接口;numpy我们已经知道了。为了读取 WAV 文件,我们需要 SciPy 中的read函数。在这里,我将其重命名为wavread。
在脚本的底部,我们加载所需的 WAV 文件并进行处理:
s, d = wavread(sys.argv[1])
print("sampling rate: %d" % s)
n = len(d)//2
a = MakeBytes(d[:n])
b = MakeBytes(d[n:])
if (len(a) < len(b)):
c = a[::-1] ^ b[:len(a)]
else:
c = a[:len(b)] ^ b[::-1]
c.tofile(sys.argv[2])
wavread函数返回采样率(s)和样本本身作为 NumPy 向量(d)。我们显示采样率,然后将样本分为两半,并将每一半传递给MakeBytes,然后分别将返回值赋给a和b。MakeBytes将一个音频样本向量转换为字节向量。
最终的字节集在c中。这是a和b中字节的异或(XOR)。XOR 是一种对整数位进行的逻辑操作。如果 XOR 的一个输入为 1,另一个为 0,则输出为 1。如果两个输入相同,则输出为 0。我记得那句“要么一个,要么另一个,但不能都选。”XOR 与标准的 OR 操作不同,OR 操作在任一输入为 1 时输出 1,包括两者都为 1 的情况,如下所示:
1 XOR 1 = 0,但 1 OR 1 = 1
使用生成的字节流的一部分来修改另一部分,是改变位模式的强大方法,这增加了输出的随机性。在silence.py中,字节流在 XOR 之前被反转([::-1]),以增加过程的随机性。
MakeBytes返回的字节数取决于实际传递给它的样本,而不是样本的数量。因此,a的向量长度可能与b不同,这就是if语句和基于len的索引的原因。当c准备好时,它通过tofile以二进制形式写入磁盘。
所有的操作都在MakeBytes函数中。将音频样本流转换为字节需要四个步骤:
-
从每个样本中减去总体均值。
-
根据每个样本的符号将其转换为位:如果为正,设为 1;如果为负,设为 0。
-
使用前一节中的冯·诺依曼算法来去偏比特。
-
将每一组 8 个位组合成一个输出字节。
函数MakeBytes执行了这些步骤,使用了以下代码:
def MakeBytes(A):
➊ t = A - A.mean()
➋ thresh = (t.max()-t.min())/100.0
w = []
for i in range(len(t)):
if (np.abs(t[i]) < thresh):
continue
w.append(1 if t[i] > 0 else 0)
➌ b = []
k = 0
while (k < len(w)-1):
if (w[k] != w[k+1]):
b.append(w[k])
k += 2
➍ n = len(b)//8
c = np.array(b[:8*n]).reshape((n,8))
z = []
for i in range(n):
t = (c[i] * np.array([128,64,32,16,8,4,2,1])).sum()
z.append(t)
return np.array(z).astype("uint8")
代码将样本作为A传入,这是一个 NumPy 向量。接下来是四个步骤。首先,我们减去任何均值(t) ➊。然后,我们将thresh定义为样本最大范围的 1%。稍后我会解释为什么。
步骤 2 表示使用每个样本的符号作为一个比特 ➋。我们观察到样本呈正态分布,且均值已被减去,因此大约一半的样本会是负数,另一半是正数。这听起来很像掷硬币:要么一个值,要么另一个值。因此,我们将正样本设为 1,负样本设为 0。那么,thresh的意义何在呢?
位被收集到列表w中,初始为空。初始化w后,循环检查每个样本,判断该样本的绝对值是否小于最大范围的 1%。如果是,我们忽略该样本(continue);否则,我们使用样本的符号将一个新的比特添加到w中。样本中可能存在一个微小的、非随机的低振幅信号。忽略接近零的样本有助于去除这种信号。实际上,我们的意思是我们只对“较大”的偏离零均值的值感兴趣。
我们现在有一个长长的单个位列表(w)。这些位可能有偏,代表一系列不公平的掷硬币结果。为了解决这个问题,我们使用冯·诺依曼算法进行去偏,生成一个新的位列表b ➌。
最后,我们将b中的每一组 8 个位转换成z中的新字节列表 ➍,方法是将b重塑为一个N×8 的数组,然后按位与每个字节中的每个比特位置值相乘。当一切完成后,代码将字节列表转换为 NumPy 数组并返回。
你可能需要再次查看代码,确保你理解每个步骤。核心概念是我们利用样本正态分布的特点,生成一个比特流,在去偏后,形成最终输出字节流。
为了测试这段代码,我用 Audacity 做了一个 30 分钟的录音,并将样本保存为文件silence.wav。然后,我使用silence.py将这个大 WAV 文件转换成silence.bin。
注意
文件 silence.wav 太大,无法包含在书的资源库中。不过,如果你非常希望拥有它,可以联系我,我会尽力帮忙。
这是我用来转换 WAV 文件的命令行:
> python3 -W ignore silence.py silence.wav silence.bin
wavread函数往往会抱怨它无法理解的 WAV 文件元素。将-W ignore添加到命令行中可以抑制这些警告。
结果输出文件silence.bin的大小不到 6MB。它是一个随机字节的集合,所以我们可以将它传递给ent来生成报告:
entropy: 7.999867
chi2 : 0.01
mean : 127.4948
pi : 3.136872354 (0.15)
corr : 0.000610
这些值是相当不错的。唯一一个不符合我们预期的值是χ²,但我们可以接受这一点。以后可以将silence.bin保留在手边,作为本书后续章节中随机数引擎的来源。
随机物理过程
许多物理过程是随机的,尽管可能存在偏差。在这一部分中,我们将探讨三种导致随机性的物理过程:大气射频噪声、旅行者 1 号和旅行者 2 号探测器在其超过 40 年的任务中所探测到的等离子体和带电粒子速率,以及放射性同位素的衰变。
大气射频噪声
1997 年,来自都柏林圣三一大学的计算机科学教授 Mads Haahr 启动了* www.random.org ,这是一个致力于从大气射频噪声中生成真正随机数字的网站。该网站自启动以来一直在运行,并向互联网用户提供免费的随机数字服务,同时还提供一些付费的随机数字相关服务。你可以浏览 www.random.org *上的服务,但为了本书的目的,我们将继续使用免费的随机字节收集。
从网站获取随机数据有两种主要方式。首先,可以通过* www.random.org/bytes 获取 16KB 块的随机数据。只需选择所需格式并下载。然而,由于我们需要更大的字节集合作为实验的随机数引擎,因此使用 archive.random.org 提供的存档文件会更合适。从网站直接下载文件需要支付小额费用。然而,如果你熟悉使用种子下载,文件可以合法地免费获取。我们的参考 Linux 发行版包括 Transmission,一个用于访问种子下载的客户端。该应用也可用于其他操作系统;访问 transmissionbt.com *获取下载说明。
作为测试,我使用 Transmission 下载了几个月的随机字节(选择了二进制文件选项)。然后,我使用cat命令将所有二进制文件合并为一个。例如,像这样的命令会将所有以.bin结尾的二进制文件合并为文件random_bytes:
> cat *.bin >random_bytes
然后我将random_bytes(约 126MB)传递给ent,得到了以下结果:
entropy: 7.999999
chi2 : 98.32
mean : 127.4880
pi : 3.142011833 (0.01)
corr : -0.000015
我们看到ent对random_bytes非常满意。我还拥有来自* random.org *的更大字节集合,包括过去两年的位数据。它的大小超过 500MB,ent也喜欢这个数据集。
entropy: 8.000000
chi2 : 40.68
mean : 127.5023
pi : 3.141375348 (0.01)
corr : 0.000021
代码显示熵值已最大化,每个字节 8 位。
旅行者号等离子体和带电粒子数据
1977 年,NASA 发射了双子座旅行者航天器,探索外太阳系。旅行者 1 号在接近木星和土星后,继续向太阳系外部进发。旅行者 2 号则在大巡游中经过木星、土星、天王星和海王星。迄今为止,旅行者 2 号是唯一一艘探测天王星和海王星的航天器。2012 年 8 月,旅行者 1 号成为首个人造物体离开太阳系并进入星际空间。截至本文写作时,2023 年 10 月,两个航天器仍在良好运行,并且有足够的电力,或许还能再工作十年。
旅行者号最著名的是它们返回的精彩图像。然而,两个航天器都携带了多个科学仪器,用于测量它们所穿越环境的各项参数。这些仪器包括用于测量等离子体质子和其他带电粒子及核子通量的设备。这些测量数据并非完全随机,而是围绕某些值波动,就像我们之前使用的麦克风输入一样。因此,可以利用旅行者号的数据构造一个适合作为随机源的二进制文件。
文件voyager_plasma_lecp.bin包含由一组旅行者数据文件构成的字节,其中包括 1977 年至 1980 年的等离子体质子计数、密度和温度数据,以及 1977 年至 2021 年期间的低能带电粒子通量(粒子在单位时间内通过某个区域)。我使用process_vgr_data.py中的代码合并了几个较小的文件,以处理单独的等离子体和带电粒子数据集。
对于每种类型的数据——等离子体质子、低能质子、低能离子和宇宙射线质子——处理过程是相同的:在时间间隔内找到中位值,并且如果观察值高于中位值,则标记为 1 位,如果低于中位值,则标记为 0 位。为了扩展位的集合,我对中位值及其 80%和 120%的值重复了这个过程。然后,我使用冯·诺依曼算法对最终的位集合进行了去偏处理。
生成的文件包含 77,265 字节,ent报告如下:
entropy: 7.995518
chi2 : 0.01
mean : 127.5484
pi : 3.150733867 (0.29)
corr : -0.001136
这些是合理的数值。
图 1-3 展示了来自等离子体和低能带电粒子实验的旅行者数据样本。

图 1-3:旅行者数据样本。从左上角开始,顺时针方向:低能离子、宇宙射线质子、等离子体质子温度和等离子体质子速度。
每个图中的虚线标记了数据集的中位值。高于中位值的观察值变成 1 位,低于中位值的变成 0 位。右上方的图显示了整个任务期间宇宙射线质子的通量。垂直虚线标记了 2012 年 8 月,这是旅行者 1 号正式离开太阳系的日期。注意,在这之后宇宙射线质子数量的增加。
注意
旅行者数据集是从多个网站收集的。通常它们以文本格式呈现,需要我进行相当多的处理,才能整理成适合使用的格式。文件使用了不一致的格式,存在拼写错误,有时还使用了错误年份的数据。如果你想要我使用过的文件,请直接与我联系。*
放射性衰变
从 1996 年到 2022 年 12 月,HotBits 网站(* www.fourmilab.ch/hotbits *)通过最随机的随机过程——放射性衰变,向公众提供了真正的随机数据。
放射性元素,如 HotBits 使用的铯-137,是不稳定的。最终,这些原子通过某种过程衰变成另一种元素,在这个例子中是铋-137。原子中的质子数决定了它是哪个元素。铯的原子核中有 55 个质子;这就是它的原子序数。同位素是元素的不同版本,其中中子的数量(也在原子核中)有所变化。两者的总和,忽略非常轻的电子,给出了原子质量。如果铯-137 有 55 个质子,它就必须有 82 个中子。当铯-137 的一个原子衰变时,其中一个中子会转变为质子,使原子序数变为 56,从而将原子转变为铋-137,这是一个具有 56 个质子和 81 个中子的原子。铋-137 是稳定的,因此衰变过程停止。这个过程对于不同元素会有所不同。例如,铀的衰变经历多个阶段,最终转变为稳定的铅同位素。
当一个中子变为质子时,它会释放出一个贝塔粒子(电子)和一个反中微子。中微子几乎没有质量,几乎无法探测。然而,盖革计数器可以轻松探测到贝塔粒子。这就是 HotBits 网站生成随机位的方式。为了完整性,铯到铋的衰变经历两个阶段:铋的原子核开始时处于亚稳定状态,然后大约两分钟后,通过释放一个伽马射线回到基态。伽马射线是一种高能光子,也就是光。
特定衰变发生的时间由量子物理学以及我们所称的所有“怪异现象”决定。对于放射性衰变,起作用的怪异现象是量子隧穿效应,即便铯原子缺乏足够的能量去将自己从所处的碗形区域推出来,它仍然有一定的非零概率能够做到这一点。量子隧穿效应在经典物理中没有类似的解释。
HotBits 通过利用两对检测之间的时间间隔来输出 1 或 0,利用了这种不可预测性。首先,检测到一个β粒子,然后是另一个。两次检测之间的时间间隔称为T[1]。接下来,检测到另一对,并将其时间间隔标记为T[2]。如果T[1] ≠ T[2],则生成一个比特。如果T[1] < T[2],则该比特为 0;否则,T[1] > T[2],输出比特为 1。每次比特之后,比较的方向会被反转,以防止物理设置引入任何系统性偏差。
HotBits 需要一个 API 密钥才能一次请求最多 2,048 字节。在 24 小时内,分配的字节数量有限制。我在写这本书时每天下载字节,现在我有了一个 3,033,216 字节的文件。根据ent的测试,这个数据相当不错:
entropy: 7.999935
chi2 : 18.90
mean : 127.5246
pi : 3.144891758 (0.11)
corr : 0.000100
在本节中,我们讨论了几种不同的生成随机数据的过程。虽然它们都很有用(正如我们稍后将看到的),但没有一种能够生成大量的随机数——在本书的后续部分,我们有时需要数百万个随机数。我们别无选择,只能转向我们可以称之为合成随机过程的方式,并通过确定性手段生成随机数。前一句话中使用的确定性一词可能会让你不安,但我怀疑你到下一节结束时会更容易接受通过确定性过程模拟随机过程的想法。
确定性过程
正如冯·诺依曼曾经说过的:“任何试图通过确定性方式生成随机数的人,当然是生活在罪恶之中。” 随机过程的核心思想在于其不可预测性,因此,了解先前发生的事情对预测之后的结果毫无帮助。根据定义,确定性过程遵循可预测的算法;因此,它不可能是真正的随机过程。
那么,为什么要使用确定性过程呢?即使过程是确定性的,它也可以逼近一个随机过程,直到输出足够有用。伪随机过程逼近一个随机过程。我们将在本书中广泛使用伪随机过程。
本节还讨论了伪随机过程与准随机过程的关系。最后,我们将讨论两种可能已在你的计算机上可用的混合过程。
伪随机数
有许多方法可以逼近一个随机过程,按需生成伪随机数。在这里,我将介绍一种这样的方法,用于我们未来的实验。
线性同余生成器(LCG)是一种生成伪随机数序列的简单方法。LCG 生成的数字对于视频游戏和许多实验来说足够好,但它们在统计学上较弱,不推荐用于严肃的用途,如密码学。
理解 LCG 的最好方法就是直接动手实践:
x[i + 1] = (ax[i] + c) mod m
整个生成器就是那个单一方程,其中x是由生成器产生的值。下标表示序列中的下一个值,x[i + 1]是从前一个值x[i]推导出来的。初始值x[0]是种子,它为生成器提供了初始值。几乎所有的伪随机生成器都使用某种形式的种子。
这个方程由两部分组成。第一部分是ax[i] + c,其中a和c是精心选择的正整数。第二部分取第一部分的结果y,并计算y mod m,其中m是另一个精心选择的正整数。mod 运算符指的是取模,实际上就是求余数。计算时,生成器首先使用整数除法找出y/m,然后得到余数,该余数必须在[0, m)范围内。最终结果既作为生成器的输出,又作为计算下一个输出的值。
为了演示这个原理,让我给你展示一个 LCG 的实际应用。我将使用小数字,这有助于理解,但在实际应用中会是糟糕的选择,正如我们接下来看到的那样:
x[i + 1] = (3x[i] + 0) mod 7
种子必须小于 7,所以我们使用x[0] = 4。以下是这个 LCG 产生的序列:

输出看起来有些随机,但在x[5]之后,序列开始重复。所有伪随机生成器最终都会重复。重复前的输出数量决定了生成器的周期。在这里,周期为 6,4、5、1、3、2、6 将永远重复。
如果常数选择得当,周期可以大得多。在我们的实验中,我们将使用一组 1980 年代流行的常数,这些常数后来被称为MINSTD(最小标准生成器)。它生成一个无符号整数序列,周期为 2³¹ ≈ 10⁹。对于许多应用来说,这是一个合理的周期,但一个好的生成器不仅仅看它的周期。伪随机生成器越接近真正的随机序列,就越好。像ent这样的统计测试套件,或者更专业的测试套件,旨在揭示生成器输出序列中的各种相关性。通过这些测试套件,很快就能发现 MINSTD 对于严肃工作来说是一个差劲的生成器,但对于我们的目的来说已经足够。
我使用 MINSTD 创建了一个 100 百万字节的文件,并将其交给了ent。我得到了以下结果:
entropy: 7.999998
chi2 : 75.99
mean : 127.5058
pi : 3.141177006 (0.01)
corr : -0.000144
输出非常合理,因此 MINSTD 可能对我们有用。
该生成器使用a = 48,271、c = 0 和m = 2,147,483,647 来构造生成方程:
x[i + 1] = 48271x[i] mod 2147483647
如果你熟悉计算机如何内部存储数字,你会注意到 m 并没有使用 32 位整数的全部 32 位。相反,m = 2³¹ – 1 是可以存储在 有符号 32 位值中的最大正整数。由于取模操作,生成器的输出必须在 [0, 2³¹ – 1) 的范围内,这意味着该生成器最多只能生成约 20 亿个唯一值。
如果我们需要生成一个字节序列 [0, 255],那就没问题。但如果我们想创建一个 64 位浮点值的序列,即 C 语言中的 double 类型呢?在这种情况下,我们需要做更多的工作。最简单的方法是将 x 除以 m,因为那必须会产生一个在 [0, 1) 范围内的数字。对于许多应用来说,这已经足够了,但它仍然只能从大约 20 亿个数字中选择一个。你不需要知道这些细节,但一个 64 位浮点值可以存储更多内容,因为它使用的是 52 位的二进制尾数。如果我们想充分利用 64 位浮点值能给我们的容量,就需要从 MINSTD 生成两个输出,并将从它们中提取的 52 位赋值给浮点值的尾数,指数为 0。幸运的是,我们并不需要那么高的精度,但值得注意的是,简单地除以 m 并没有给你所有你可能想象中的内容。
我之前提到过伪随机生成器使用种子值,即起始值。这个要求是双刃剑。将种子设置为特定的数字会导致生成器重复输出相同的数值序列,但缺点是有时我们可能需要费些劲来确保不重复使用相同的种子。
例如,使用种子 x = 8,675,309 的 MINSTD 每次都会产生这个序列:
0.00304057, 0.77134655, 0.66908364, 0.33651287, 0.8128977, . . .
与此同时,使用 x = 1,234 作为种子将始终产生:
0.02773777, 0.93004224, 0.06911496, 0.24831591, 0.45733623, . . .
这些浮点数是通过将 x 除以 m = 2,147,483,647 生成的。
通常,我们会使用伪随机生成器,比如 NumPy 内置的那些,而不指定种子值。在这种情况下,我们希望得到一个不可重复的序列,至少对我们来说是不可重复的,因为我们无法知道选择了哪个种子。如果没有提供种子值,NumPy 会从 /dev/urandom 中选择一个值,稍后在本章会讨论这个特殊的系统设备。
我们最常用的伪随机生成器是 PCG64,这是 NumPy 默认使用的生成器。它是一个优秀的生成器,周期为 2¹²⁸。NumPy 以前的默认生成器是梅森旋转算法(Mersenne Twister)。从统计上讲,它仍然非常好,远远优于 MINSTD。梅森旋转算法的周期为 2^(19,937) – 1,这也解释了它更常见的名字:MT19937。准确地说,它的周期是一个巨大的数字,达 6002 位,并且对人类来说完全没有意义。PCG64 的周期也不容小觑:
2¹²⁸ = 340, 282, 366, 920, 938, 463, 463, 374, 607, 431, 768, 211, 456
这只是一个在人的理解中没有意义的数字。我们任何实验都无法穷尽由 MT19937 或 PCG64 生成的序列,书中将频繁使用这两种序列。
这些生成器的输出遵循均匀分布。任何其他分布,比如正态分布,都可以通过一组均匀分布的数字来生成。例如,通过 Box-Muller 变换,可以将两个均匀分布的数字 u[1] 和 u[2] 转换为两个正态分布的数字:

关于伪随机生成器,还有很多可以说的;许多计算机科学家一生致力于与之相关的研究。然而,我们现在已经了解了满足我们需求的所有内容。
准随机序列
对于一些实验,我们需要的随机数据是 空间填充型 的。我的意思是,一个生成器生成的序列看似随机,但随着时间推移,会较为均匀地填充空间。纯随机过程并不提供这种保证,但准随机序列则能做到这一点。
随机或伪随机序列与准随机序列之间的区别,最好通过实例来理解。图 1-4 比较了两个包含 40 个点的序列,一个是伪随机生成的,另一个是准随机生成的。

图 1-4:两组 40 个点,随机(在 0.1 处)和准随机(在 0.2 处)
下方的序列,在 y = 0.1 时,是伪随机生成的。它覆盖了从 0 到 1 的范围(x 轴),但中间存在空隙。第二个序列,在 y = 0.2 时,是准随机序列。它覆盖了相同的范围,但更为一致,没有拥挤或空隙。只要足够的样本,两个序列最终都会填满整个范围,但准随机序列通过更均匀地散布值来做到这一点。
我们的准随机过程使用了所谓的 哈尔顿序列。哈尔顿序列基于一个质数,首先按照基数(质数)划分区间 0, 1),然后是基数的平方,再是基数的立方,以此类推。对于基数为 2,区间首先被分为两部分,然后是四分之一、八分之一,以此类推。这个过程最终将在无限极限中覆盖整个区间,并且大致均匀地填充该区间。例如,[表 1-6 展示了给定基数的哈尔顿序列的前几个值(四舍五入到三位数字)。
表 1-6: 哈尔顿序列
| 基数 | 序列 |
|---|---|
| 2 | 0, 0.5 , 0.25 , 0.75 , 0.125, 0.625, 0.375, 0.875 |
| 3 | 0, 0.333, 0.667, 0.111, 0.444, 0.778, 0.222, 0.556 |
| 5 | 0, 0.2 , 0.4 , 0.6 , 0.8 , 0.04 , 0.24 , 0.44 |
每个序列都从 0 开始,并且完全是确定性的,就像是带有固定种子的伪随机生成器。
如果准随机序列如此可预测,为什么它还有用呢?有时,均匀填充空间比纯粹的随机填充更重要。例如,我们的一些实验涉及在多维空间中搜索,找到我们认为最佳的某个点,根据某种定义的“最佳”。在这种情况下,初始化搜索时,使得最初评估的位置大致均匀地代表整个空间往往更有意义。
准随机序列最有用的地方是组合使用。图 1-4 使用了一个基数为 2 的单一准随机序列来填充一维空间,即从 0 到 1 的区间。如果我们想填充一个二维空间,比如xy平面呢?为此,我们需要一对数字。看似显而易见的做法是采样两次,将第一个数字作为x坐标,第二个数字作为y坐标。让我们看看当我们将这种方法应用于伪随机数和准随机数时会发生什么。
图 1-5 试图用点填充二维空间。

图 1-5:从左到右:坏的准随机数、伪随机数和好的准随机数序列的示例
在图 1-5 的中间,我绘制了从伪随机生成器中采样的 500 对数据点。这些点在空间中是随机分布的,但有些区域的点较少,而有些区域的点较多,这是预期的。接下来,我用基数为 2 的准随机序列重复了这个实验。也就是说,我让序列连续采样两个值,并将它们绘制成一个点。结果如图 1-5 左侧所示。显然,出现了一些奇怪的情况。
这个图并不是一个错误;准随机序列正做着它应该做的事情。我们将随机过程定义为一种在已知前值的情况下,无法预测后续值的过程。这正是伪随机生成器所提供的;因此,我们可以使用按顺序生成的值作为二维空间中一个点的坐标。然而,准随机序列并不保证前面的值不能预测接下来会发生什么。相反,序列是完全可预测的。采样对之间会存在简单的关系,这正是我们在图 1-5 左侧看到的情况。
这并不意味着我们不能在一维情况之外使用准随机序列。诀窍是为每个维度使用不同的基数。在这里,我们有两个维度,因此需要两个准随机序列,每个序列使用不同的基数。图 1-5 右侧的图就是这样生成的。x坐标是使用基数 2 的准随机序列中的前 500 个样本,y坐标是使用基数 3 的准随机序列中的前 500 个样本。请记住,基数必须是质数。由于这两个准随机序列使用的基数不同,因此它们的值并不(简单地)相关,因此这些点填充了二维空间。同样,由于每个序列填充了区间[0, 1),而我们将它们成对绘制,直观上看它们也会填充二维空间,这就是图示所展示的内容。
这个故事的寓意是,我们可以使用准随机序列,每个问题维度一个基数,按某种方式填充空间,这种方式看起来像是随机的。
结合确定性和真正随机的过程
真正的随机过程是黄金标准,但它们通常相对较慢,至少考虑到计算机的工作速度。伪随机生成器是一个不错的替代品,但它只是一个真正随机过程的“追随者”。为什么不将两者结合起来呢?本节将探讨两种将真正的随机过程与伪随机生成器结合的方法。我称这些为混合过程。我们将讨论/dev/urandom,即 Linux 操作系统的方法,以及RDRAND,一种由许多新款 Intel 和 AMD 处理器支持的基于 CPU 的混合处理器指令。
/dev/urandom,以下简称urandom,和RDRAND的工作方式相同:它们使用一种缓慢的真正随机源来重新种子一个加密安全的伪随机数生成器。我们在本章前面学到,伪随机生成器有种子,种子设置了生成器的初始状态。以相同的方式设置种子,生成的值序列就会重复。混合方法通常会重新种子生成器,目的是改变序列,使得即便对手弄清楚伪随机生成器的工作方式,在实践中也无关紧要,因为种子是随机的,任何对生成器状态的了解在短时间后都将变得毫无用处。
我在上一段中插入了一个新短语:加密安全。我们的实验并不担心对手和加密技术(嗯,主要是);我们关心的是伪随机数生成器在“相当不错”的意义上表现良好,换句话说,我们的实验使用该生成器时表现良好。
一个加密安全的伪随机生成器是一个高质量的伪随机生成器,具有以下特性:
-
攻击者对生成器在时间 t[c] 时的状态的了解,并不能帮助攻击者知道在任何早于 t[c] 的时刻 t 时,生成器的状态。
-
对于任何输出位i,没有一个多项式时间的算法可以在所有先前生成的位 0,...,i–1 上操作,以比 50%的准确度更好地预测i。
第二个特性被称为下一位测试。它标志着加密安全生成器作为高质量生成器的标准。
短语多项式时间算法来自于对算法的研究,特别是它们在时间和空间方面的表现。多项式时间算法是好的算法,它们可能在宇宙热寂之前某个时候完成。经典的冒泡排序算法就是一个多项式时间算法,因为它的运行时间与需要排序的元素数量的平方成正比;也就是说,排序n个元素的时间大约是n²的量级,这是一个多项式。任何时间或空间资源由多项式限制的算法也是多项式时间算法。例如,快速排序算法在n个元素时的时间复杂度是n log n。这是一个容易被多项式如n²所界定的函数,因此快速排序也是多项式时间算法。任何按 2^n增长的算法都不是多项式时间算法,因为 2^n是指数级的,而不是多项式的。指数级算法是糟糕的,因为它们很快变得不可处理,这正是那些关注伪随机数生成器攻击的人所希望避免的。
从算法的角度来看,混合过程使用强伪随机生成器按需提供随机值,同时定期从真正的随机源重新播种该生成器。让我们探讨urandom和RDRAND对这个问题的解决方法。
从 urandom 读取随机字节
Unix 系统将许多非文件的事物视为文件,urandom也不例外。为了从urandom获取随机字节,我们需要将其视为文件。例如,让我们运行 Python,打开urandom,并将其 60 百万字节写入磁盘文件,以便ent进行评估:
> python3
>>> b = open("/dev/urandom", "rb").read(60000000)
>>> open("ttt.bin", "wb").write(b)
60000000
输出文件是ttt.bin,这是一个临时的名字。当我运行代码时,ent报告了以下内容:
entropy: 7.999997
chi2 : 23.16
mean : 127.4981
pi : 3.141671600 (0.00)
corr : 0.000024
正如预期的那样,urandom表现良好,并且可以作为我们的随机性引擎来使用。
Linux 内核维护一个熵池,这是一个从系统操作中派生的字节集合,内核每 300 秒用它来更新伪随机生成器的种子,具体信息可以在内核文件random.c中找到,该文件可在github.com/torvalds/linux/blob/master/drivers/char/random.c查看。如果有好奇心,文件中也暴露了内核的熵源。
我们可以通过读取文件entropy_avail的内容来监视熵池的大小:
> cat /proc/sys/kernel/random/entropy_avail
3693
在这种情况下,我们得知在我执行命令的那一刻,熵池中有 3,693 字节,意味着当重新种子间隔到期时,3,693 字节可以用来重新种子生成器。
Linux 使用 ChaCha20 伪随机数生成器。这是一个相对较新的生成器,整体性能非常出色,即使在更高强度的测试套件下也能表现良好。这里的细节我们不关心,只关心urandom能提供结果。
注意
如果你花费大量时间审阅与 /dev/urandom 相关的资源,你会遇到它的“亲戚”, /dev/random。后者设备是为了少量高质量的随机数据而设计的,它会阻塞直到足够的熵可用。这与我们的需求相悖,因此我们完全忽略了 /dev/random。事实上,2022 年 3 月宣布,未来版本的 Linux 内核将把 /dev/random变成 /dev/urandom*的别名。
使用 RDRAND 指令
如果你的 CPU 支持,RDRAND 指令可以提供高质量的随机数,采用与 Linux 内核和urandom类似的方法。主要的区别在于,熵源是 CPU 本身的一部分,且伪随机生成器会在返回 1,022 个随机值之后更新,即RDRAND是基于返回样本的数量重新种子的,而不是像urandom那样基于固定的时间间隔。
使用的伪随机数生成器不是 ChaCha20,而是 CTR_DRBG,这是一种由美国国家标准与技术研究院(NIST)开发的生成器,NIST 隶属于美国商务部。NIST 与美国政府的紧密关系导致一些人对RDRAND的输出产生不信任,但由于我们不关心加密安全问题,RDRAND是可以使用的。
与urandom不同,urandom作为二进制文件可立即被所有编程语言访问,而RDRAND是一个 CPU 指令,因此我们需要通过底层代码或安装一个使用该指令的 Python 库来访问它。实际上,rdrand库非常适合:
> pip install rdrand
> python3
>>> from rdrand import RdRandom
>>> RdRandom().random()
0.2047133384122450
>>> b = rdrand.rdrand_get_bytes(60000000)
>>> open("ttt.bin", "wb").write(b)
60000000
RdRandom类提供了一个接口来访问RDRAND,并支持random方法来返回一个随机浮动值。或者,rdrand_get_bytes函数返回请求的随机字节数,这里是 6000 万字节,供我们将ent的报告与urandom的输出进行比较。
将ttt.bin中的字节传递给ent,我们得到以下结果:
entropy: 7.999997
chi2 : 54.51
mean : 127.5036
pi : 3.141309600 (0.01)
corr : -0.000159
就我们的目的而言,这些结果与urandom的结果没有区别;因此,我们也将不时依赖RDRAND。
如果你想在 C 语言中访问RDRAND,drng.c中的代码可以作为指南。请注意文件顶部的gcc编译指令。
在下一节中,我将详细介绍 RE 类,这是一个为不同随机源提供封装的 Python 类,也是我们将为所有书中的实验一致使用的随机引擎。
注意
有关生成伪随机数的其他方法,请参见我的书籍 《随机数与计算机》 (Springer, 2018)。它包括了对伪随机数生成相关内容的全面讨论,包括特定算法的代码。
本书的随机引擎
在本节中,我们将构建 RE 类,这是一个随机引擎,将支持本书后续的大部分实验。如果你更喜欢直接进入实践,可以先阅读 RE.py,然后回来补充遗漏的细节。
我们希望设计一个类,完成以下功能:
-
提供指定数量的均匀随机样本向量
-
使用 PCG64、MT19937 或 MINSTD 生成伪随机输出
-
使用
urandom或RDRAND生成混合随机输出 -
为任何指定的素数基生成准随机输出
-
在任意范围内生成浮动数或整数,[a, b)
-
生成字节([0, 255])或比特([0, 1])
-
允许使用种子值重复生成相同的输出
-
将一个字节文件作为随机源
在对 RE 类进行简短的测试之前,让我们先回顾一下它的源代码。
RE 类
RE 类通过其构造函数进行配置,并且只提供一个公共方法:random。该方法接受一个单一参数,一个整数,指定要返回的样本数量,并作为 NumPy 向量返回。
RE 类的私有方法实现了可能的随机源,如 表 1-7 所示。
表 1-7: RE 的方法
| 方法 | 描述 |
|---|---|
Fetch |
从磁盘文件中取样 |
MINSTD |
从 MINSTD 中取样 |
Urandom |
从 /dev/urandom 读取 |
RDRAND |
从 RDRAND 读取 |
Quasirandom |
从 Halton 序列中取样 |
NumPyGen |
从 PCG64 或 MT19937 取样 |
每个私有方法接受一个单一参数,即要返回的样本数量。此外,除了 Fetch,每个私有方法返回一个浮动点向量,样本范围为 [0, 1)。
NumPyGen 和 MINSTD
让我们首先看一下 NumPyGen 和 MINSTD,因为它们比较简单。为了节省空间,我删除了注释和文档字符串。它们存在于 RE.py 中:
def NumPyGen(self, N):
return self.g.random(N)
def MINSTD(self, N):
v = np.zeros(N)
for i in range(N):
self.seed = (48271 * self.seed) % 2147483647
v[i] = self.seed * 4.656612875245797e-10
return v
让我们从 NumPyGen 开始,因为它是最简单的。RE 类的构造函数(稍后我们会讨论)会创建一个 NumPy 生成器,如果这是所需的随机源,并将其存储在 g 成员变量中。别用那种眼神看我;我们是在做实验,g 是一个完全合适的变量名。
NumPy 已经知道如何返回浮动数向量,所以剩下的就是告诉 NumPy 我们想要 N 个,然后立即将其返回给调用者 random,我们稍后也会讨论这个。
类似地,MINSTD 的任务也是返回一组浮点数样本。我们首先创建一个向量来存储样本,然后循环应用 LCG 方程。
x[i+1] = (ax[i]) mod m
其中 a = 48,271,m = 2³¹ – 1 = 2,147,483,647。为了将 x[i+1] 转换为 [0, 1) 范围内的浮点数,我们将其除以 m,或者像这里一样,乘以 1/m。
有几个值得注意的地方。首先,如果选择了 MINSTD 生成器,则 seed 成员变量 就是 x。这就是为什么它会被更新。其次,MINSTD 方程是迭代的;为了得到 x[i+1],我们需要 x[i],因为伪随机值通常需要顺序生成。在实践中,这意味着我们实现的 MINSTD 稍微有点慢,但对于我们的实验来说是可以接受的。
记住,MINSTD 使用的是 32 位整数,这意味着 MINSTD 返回的浮点数精度仅为 32 位浮点数。如果你了解 C 语言,这意味着它返回的是 float 类型,而不是 double。Python 中的 float 对应 C 语言中的 double,并且也使用 64 位(其中 52 位用于尾数或有效数字)。对于本书中的大部分内容,精度损失并不重要,但如果你希望在项目中使用 RE,应当注意这一点。
RDRAND
RDRAND 方法需要使用 CPU 的 RDRAND 指令,它通过 rdrand 模块来实现。当导入 RE 类时,Python 会尝试加载 rdrand(见 RE.py 顶部)。如果存在,RDRAND 方法将使用该库访问样本。如果库不存在,RDRAND 仍然有效,但会回退到 NumPy 的 PCG64 生成器并发出相应的警告信息。
让我们来看看 RDRAND,忽略 rdrand 不可用的部分:
def RDRAND(self, N):
v = np.zeros(N)
rng = rdrand.RdRandom()
for i in range(N):
v[i] = rng.random()
return v
rdrand 模块支持多个方法,但我们仅使用 random,它返回一个位于 [0, 1) 范围内的单一浮点数,使用了所有 52 位尾数。然而,由于 random 返回的是一个单一数字,我们需要一个循环,因此 RDRAND 并不是特别快速。如果你想要从 rdrand 获取字节,直接使用模块的 rdrand_get_bytes 函数会更高效。
Urandom
RE 类使用 /dev/urandom 如下:
def Urandom(self, N):
with open("/dev/urandom", "rb") as f:
b = bytearray(f.read(4*N))
return np.frombuffer(b, dtype="uint32") / (1<<32)
代码首先将请求的字节数的四倍读取到一个 Python 字节数组中。然后,它告诉 NumPy 将字节数组视为缓冲区并从中读取 32 位无符号整数。得到的数组被除以 2³² 转换为 [0, 1) 范围内的向量。像 MINSTD 一样,我们将 urandom 视为 32 位浮点数源。
准随机
准随机数有些不同,正如我们在本章前面所见,但其实现与其他来源类似:
def Quasirandom(self, N):
v = []
while (len(v) < N):
v.append(Halton(self.qnum, self.base))
self.qnum += 1
return np.array(v)
输出向量(v)是逐个样本构建的。while 循环调用 Halton,这是一个包含在 Quasirandom 中的函数。该函数根据给定的素数基返回 Halton 序列中的特定数字。下一个要使用的数字存储在成员变量 qnum 中。我们稍后会在讨论 RE 构造函数时回到 qnum。
随机
RE 类的唯一公共方法是 random:
def random(self, N=1):
if (not self.disk):
v = self.generatorsself.kind
if (self.mode == "float"):
v = (self.high - self.low)*v + self.low
elif (self.mode == "int"):
v = ((self.high - self.low)*v).astype("int64") + self.low
elif (self.mode == "byte"):
v = np.floor(256*v + 0.5).astype("uint8")
else:
v = np.floor(v + 0.5).astype("uint8")
else:
v = self.Fetch(N)
return v[0] if (N == 1) else v
如果我们没有使用磁盘文件,那么过程是一样的,不管随机源是什么:首先,生成请求的浮点样本数量,范围在[0, 1)之间,然后将它们修改为所需的范围和类型。
由于所有随机源都接受相同的参数并返回相同的[0, 1)向量,我们将特定方法的引用存储在字典generators中。然后,为了获得v,我们只需要根据kind调用相应的方法,并传入样本数(默认为 1)。
拿到v之后,我们根据选定的配置进行修改。如果我们需要浮点数,则将其乘以high并加上low,默认值分别为 1 和 0。这会返回一个范围在[low, high)之间的浮点数。
对于整数,我们首先将v映射到[0, high)之间,然后加上low,得到一个范围在[low, high)之间的整数。在这两种情况下,上限都不包括在内,符合 Python 惯例。
获取字节时,先将其乘以 256 并四舍五入。最后,获取比特时,将v四舍五入。
Fetch
你问Fetch怎么办?它是Urandom和random的混合:
def Fetch(self, N=1):
if (self.mode == "byte"):
nbytes = N
else:
nbytes = 4*N
b = []
n = nbytes
while (len(b) < nbytes):
t = self.file.read(n)
if (len(t) < n):
n = n - len(t)
self.file.close()
self.file = open(self.kind, "rb")
b += t
if (self.mode == "byte"):
v = np.array(b, dtype="uint8")
else:
v = np.frombuffer(bytearray(b), dtype="uint32")
v = v / (1 << 32)
if (self.mode == "float"):
v = (self.high - self.low)*v + self.low
elif (self.mode == "int"):
v = ((self.high - self.low)*v).astype("int64") + self.low
elif (self.mode == "byte"):
v = np.floor(256*v + 0.5).astype("uint8")
else:
v = np.floor(v + 0.5).astype("uint8")
return v
代码分为三个段落。第一段计算从磁盘文件中读取的字节数(nbytes)。与Urandom一样,我们将自己限制为 32 位浮点数。
第二段从磁盘读取字节并将其存储在b中。如果文件读取完毕,我们将从头开始。记住这一点,以确保你不会请求文件无法提供的过多样本。可以将文件大小除以四视为生成器的周期。
第三段根据需要处理数据。如果我们需要字节,则直接完成;我们只需将列表b转换为 NumPy 向量并返回。如果不是字节,我们首先将字节作为缓冲区处理,并将其读取为无符号 32 位整数,然后将其除以 2³²,使得v的范围在[0, 1)之间。然后,我们将数据转换为与random方法相同的最终输出格式。random和Fetch之间有一些重复代码,但从教学角度来看,这种清晰性是值得的。
构造函数
RE类的构造函数配置随机引擎。第一部分定义默认值并构建私有方法字典:
def __init__(self, mode="float", kind="pcg64", seed=None, low=0, high=1, base=2):
self.generators = {
"pcg64" : self.NumPyGen,
"mt19937": self.NumPyGen,
"minstd" : self.MINSTD,
"quasi" : self.Quasirandom,
"urandom": self.Urandom,
"rdrand" : self.RDRAND,
}
self.mode = mode
self.kind = kind
self.seed = seed
self.low = low
self.high = high
self.base = base
self.disk = False
mode定义了RE返回的内容。它是一个字符串:float、int、byte或bit。使用kind来定义源。可能的值是generators的键或磁盘文件的路径名。这里区分大小写。
使用low和high来设置输出范围,字节和比特不受影响。请记住,输出不包括high。要获得确定性的序列,请设置seed。最后,如果处理的是拟随机序列,使用base。
第二部分处理源特定的内容:
if (self.kind == "pcg64"):
self.g = np.random.Generator(np.random.PCG64(seed))
elif (self.kind == "mt19937"):
self.g = np.random.Generator(np.random.MT19937(seed))
elif (self.kind == "minstd"):
if (seed == None):
self.seed = np.random.randint(1,93123544)
elif (self.kind == "quasi"):
if (seed == None):
self.qnum = 0
elif (seed < 0):
self.qnum = np.random.randint(0,10000)
else:
self.qnum = seed
elif (self.kind == "urandom") or (self.kind == "rdrand"):
pass
else:
self.disk = True
self.file = open(self.kind, "rb")
解析第二部分留给你自己。我只想指出,对于准随机序列,种子值用于设置初始的 Halton 序列值,如果给定负种子,则该值是随机的。使用负种子为确定性的 Halton 序列添加一些随机性。
到此为止,我们已经完成了RE的实现。现在,让我们学习如何使用它。
RE 类示例
以下示例展示了我们如何使用RE类。
第一个示例导入了RE,并使用所有默认设置定义了一个生成器——PCG64,返回[0, 1)区间内的浮动值:
>>> from RE import *
>>> g = RE()
>>> g.random(5)
array([0.44018704, 0.98320526, 0.61820454, 0.3124574 , 0.32110503])
>>> g.random(5)
array([0.47792361, 0.67769858, 0.50001674, 0.35449271, 0.92454641])
第二个示例创建了一个 MT19937 生成器的实例,并使用它返回五个位于[–3, 5)区间内的浮动值:
>>> RE(kind='mt19937', low=-3, high=5).random(5)
array([ 4.51484908, 2.31892577, 0.98488816, -1.36846592, 1.70944267])
接下来,我们使用urandom返回[–3, 5)区间内的整数,并使用RDRAND进行字节采样:
>>> RE(kind='urandom', low=-3, high=5, mode='int').random(5)
array([2, 2, 4, 3, 2])
>>> RE(kind='rdrand', mode='byte').random(9)
array([ 67, 173, 207, 230, 10, 127, 241, 21, 213], dtype=uint8)
这是一个示例,指定了一个种子值,以便每次返回相同的序列:
>>> RE(kind='minstd', seed=5, mode='bit').random(9)
array([0, 0, 0, 0, 1, 1, 1, 1, 0], dtype=uint8)
>>> RE(kind='minstd', seed=5, mode='bit').random(9)
array([0, 0, 0, 0, 1, 1, 1, 1, 0], dtype=uint8)
对于一个准随机序列,如果没有提供种子,则每次序列都从零开始。如果种子小于零,则起始位置随机设置:
>>> RE(kind='quasi', base=2).random(6)
array([0\. , 0.5 , 0.25 , 0.75 , 0.125, 0.625])
>>> RE(kind='quasi', base=2).random(6)
array([0\. , 0.5 , 0.25 , 0.75 , 0.125, 0.625])
>>> RE(kind='quasi', base=2, seed=-1).random(6)
array([0.3458252, 0.8458252, 0.2208252, 0.7208252, 0.4708252, 0.9708252])
>>> RE(kind='quasi', base=2, seed=-1).random(6)
array([0.74029541, 0.49029541, 0.99029541, 0.01373291, 0.51373291,
0.26373291])
这个示例将kind设置为文件名,从磁盘文件(这里是hotbits.bin)进行采样:
>>> RE(kind='hotbits.bin').random(5)
array([0.58051941, 0.79079893, 0.91321132, 0.26857162, 0.49829243])
现在我们已经知道如何使用RE,让我们开始实验吧。
总结
本章重点介绍了生成随机性的过程,即随机过程。首先,我们探讨了概率与随机性之间的关系,并了解到随机过程是从概率分布中抽样的,这些分布可以是连续的或离散的。我们了解到,通常没有具体的答案来回答“我们如何知道一个过程的输出是否是随机的?”然而,确实有一些方法可以测试序列,以增强我们对输出是随机的或非随机的信心。实际上,我们将ent的输出声明为标准,因为我们的实验不需要最先进的随机过程。
接下来,我们讨论了真正的随机过程。我们从经典方法开始,比如抛硬币和掷骰子,然后将注意力转向物理过程,如模拟信号中的随机波动、大气效应引起的无线电频率噪声,以及放射性元素的衰变。
伪随机和准随机序列是随机过程的模拟。尽管它们假装是来自真正随机过程的输出,但它们并非如此。然而,它们确实以足够的精确度模拟真正的随机过程,使得它们成为我们实验的主要驱动因素。在学习了伪随机和准随机生成器的基础之后,我们讨论了混合生成器——伪随机性和真正随机过程的结合。混合生成器提供加密安全的序列。
本章的最后,展示了RE类的设计和代码,这个随机性引擎将驱动我们所有的实验。
第二章:隐藏信息**

隐写术(Steganography),源自希腊语“隐蔽写作”,是一种隐藏信息的艺术,使得敌人无法察觉该信息的存在。与加密术不同,加密术依赖于破解加密算法的困难,而隐写术则依赖于保密性。只要知道如何隐藏信息,任何人都可以读取被隐藏的信息。如果隐藏信息的艺术被称为隐写术,那么隐写分析就是检测隐藏信息的过程。这是一个经典的军备竞赛:隐写术专家改进他们的技术,而隐写分析则试图阻止他们。
尽管隐写术可以在任何可以隐藏 0 和 1 的地方存在,但它最常见于文本、二进制文件、音频、视频和图像中。除了视频之外,这些也是我们实验的目标。
在字符串中
本节展示了两个文本实验。第一个使用固定偏移隐藏信息,第二个则使用随机偏移。
欺骗的简短历史
隐写术已经存在了很长时间,并且与加密术紧密相连。
公元前 5 世纪,古希腊作家希罗多德讲述了关于米利都的流放暴君赫斯提亚乌斯的故事,米利都位于现代土耳其。他声称赫斯提亚乌斯剃光了一名奴隶的头发,并在他的头皮上刺上了一条信息,然后等待奴隶的头发长回,再将他送往米利都。赫斯提亚乌斯的朋友剃了那名奴隶的头发,并读取了信息——这是一条反对波斯人起义的指令。最终,起义失败,赫斯提亚乌斯的头颅被送给了波斯国王大流士。
1605 年,弗朗西斯·培根爵士开发了一种结合隐写术和加密术的秘密通信方法。首先,培根为字母表中的每个字母设计了一个五位二进制代码。然后,他使用该密码通过对字体做微小修改来隐藏信息,以表示 0 或 1(培根用“a”和“b”代替 0 和 1)。
例如,我们可能会用这种密码隐藏信息“EAT AT JOES”:
alIce openeD thE Door anD foUNd ThaT iT LEd inTo a SmaLl passage
aabaa aaaaab aab baaa aab aabba baab ab bba aaba a baaba
第一行展示了文本,这是《爱丽丝梦游仙境》中的一行,其中小写字母代表“a”,大写字母代表“b”。实际上,“a”字母使用的字体几乎与“大写 b”的字体相同,但稍有不同。每个信息字母的实际培根密码在第二行显示。为了找出消息,我们将字母按五个一组组织,如下所示:

固定偏移
在第一次世界大战期间,一名德国间谍从纽约发出了以下消息:
显然,中立者的抗议完全被忽视和拒绝。伊斯曼遭受重创。封锁问题影响了对副产品的禁运借口,排除了动物脂肪和植物油。
该信件包含一条隐藏的信息。提取每个单词的第二个字母,我们得到:
珀尔辛号从纽约出发,日期为 6 月 1 日。
请注意,我们将最终的“i”解释为“1”。最终,消息变得毫无意义,因为珀辛将船从纽约开出,日期是 5 月 28 日。
尽管使用第二个字母比第一个字母更好,因为它更难意外地发现消息,但安全性仍然较低。然而,这个想法为将消息隐藏在单词串中提供了一个起点。我们从开发一个脚本开始,使用来自 池文本(一个包含大量文本的文档,从中选择单词)和每个单词的选定字母偏移来嵌入文本消息。这个示例没有随机性,但它为接下来的实验做了准备。
这是我们的方法:
-
从单词的开头选择一个字母偏移量。德国间谍为每个单词的第二个字母使用了偏移量为 1。回想一下,计算机科学家使用的是零基计数,从 0 开始,而不是从 1 开始。
-
通过从池文本(例如一本书)中选择单词来隐藏一个源消息(仅文本),使得每个单词的当前字母就是所选单词的偏移字母。
-
将生成的单词列表写入磁盘,作为隐藏消息。
我们需要的源代码在 steg_simple.py 中。建议在继续之前先查看它。请注意,文件没有导入 RE,这里没有任何随机性。
要了解它是如何工作的,这里有一个示例,通过使用偏移量为 2 从 alice.txt 中选择单词来隐藏 message.txt 的内容:
Three may keep a secret, if two of them are dead.
使用命令行
> python3 steg_simple.py encode 2 message.txt alice.txt output.txt
生成以下内容并保存到 output.txt:
GET SCHOOLROOM VERY THERE ONE COME THAT SAYING LIKE THE
SHE HAPPENED THAT WAS THE NECK WORDS THE LITTLE ALICE
TOFFEE HOT NOW THOUGHT DOOR OFF INTO ASHAMED GREAT
MOMENT TEARS LARGE DEEP AND THE HEARD AND
偏移量是基于零的,意味着偏移量为 0 使用第一个字母,因此偏移量为 2 使用第三个字母。考虑到这一点,我们可以看到嵌入的消息是:
GET SCHOOLROOM VERY THERE ONE COME THAT SAYING LIKE THE SHE HAPPENED
THAT WAS THE NECK WORDS THE LITTLE ALICE TOFFEE HOT NOW THOUGHT DOOR
OFF INTO ASHAMED GREAT MOMENT TEARS LARGE DEEP AND THE HEARD AND
每个下划线字母就是消息的内容:
THREE MAY KEEP A SECRET IF TWO OF THEM ARE DEAD
首先,steg_simple.py 读取消息文件,去除所有不是字母的字符,然后将剩余字符转为大写。结果是一个包含消息中单词的 Python 列表。池文本也会做类似的处理。
接下来,steg_simple.py 逐个字母处理消息中的每个单词,通过扫描池文本来查找字母偏移与当前消息字母匹配的单词。每找到一个匹配项,就将其附加到输出的单词列表中。当所有的消息字母都被类似地处理后,输出列表会被写入磁盘。
改变偏移量会修改输出列表;例如,将其设置为 3 会得到如下结果:
SITTING NOTHING THERE ITSELF LATE SEEMED STRAIGHT VERY LOOK TRIED
MAKE DROP MANAGED HERSELF AFTER WHICH NEAR MILES SORT LATITUDE ROOF
LITTLE FLOWERS THROUGH THROUGH HALF ANOTHER WITH WISE THEM USUALLY
CHERRYTART PINEAPPLE SAID LIKE CHEATED FOND
现在我们已经嵌入了消息,让我们把它取回来:
> python3 steg_simple.py decode 2 output.txt tmp.txt
tmp.txt 文件包含以下内容:
THREEMAYKEEPASECRETIFTWOOFTHEMAREDEAD
我们丢失了标点和空格,消息以全大写字母形式“喊”出来,但它是可以辨认的。
现在让我们逐步分析这个示例的源代码。输入消息和(如果是编码)池文件都会通过 ProcessText 处理,去除非字母字符并返回一个单词列表,如 清单 2-1 所示。
def ProcessText(s):
s = s.upper().split()
text = []
for t in s:
z = ""
for c in t:
if (c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
z += c
text.append(z)
return text
清单 2-1:将一串文本转换为一个单词列表
外部循环处理由第一行(s)生成的单词列表。该列表仍然包含非空格字符。内部循环遍历每个单词的字符,构建仅包含字母的版本,并将其附加到text中。完成所有操作后,函数返回text。
steg_simple.py 的底部根据模式解析命令行,是编码还是解码。代码展示在 Listing 2-2 中。
offset = int(sys.argv[2])
if (sys.argv[1] == "encode"):
sfile = sys.argv[3]
pfile = sys.argv[4]
dfile = sys.argv[5]
Encode(offset, sfile, pfile, dfile)
elif (sys.argv[1] == "decode"):
dfile = sys.argv[3]
sfile = sys.argv[4]
Decode(offset, dfile, sfile)
else:
print("Unknown option")
Listing 2-2: 解析命令行
所有的操作都在Encode和Decode中进行。我们先从Encode开始,如 Listing 2-3 所示。
def Encode(offset, sfile, pfile, dfile):
➊ msg = ProcessText(open(sfile).read())
pool= ProcessText(open(pfile).read())
enc = []
idx = 0
➋ for word in msg:
for c in word:
done = False
while (not done) and (idx < len(pool)):
if (len(pool[idx]) <= offset):
pass
elif (pool[idx][offset] != c):
pass
else:
enc.append(pool[idx])
done = True
idx += 1
➌ with open(dfile, "w") as f:
f.write(" ".join(enc)+"\n")
Listing 2-3: 编码消息
首先,Encode读取并处理消息和池文本 ➊。接下来是一个三重嵌套的循环 ➋,首先处理消息中的每个单词(msg),然后逐个字符处理(c),最后在池文本(pool)中搜索与偏移字符匹配的单词。池文本的索引(idx)最初设置为零,并且之后只会递增,因此每次字符的搜索不会从头开始,而是继续在池文本文件中移动。理论上,这意味着选中的单词集合更加多样化,因为相同的单词不会重复选择以匹配消息中的相同字符。
处理完整个消息后,enc按顺序包含了选中的单词。剩下的就是将它们写入磁盘 ➌。注意 Python 的惯用法,通过在字符串常量(一个空格)上调用join方法将单词列表转换为单个字符串,最后添加一个换行符。
解码消息的过程相对简单,如 Listing 2-4 所示。
def Decode(offset, dfile, sfile):
enc = ProcessText(open(dfile).read())
plain = ""
for w in enc:
plain += w[offset]
with open(sfile, "w") as f:
f.write(plain+"\n")
Listing 2-4: 解码消息
Decode处理编码后的消息文件,生成一个单词列表(enc)。然后该函数检查每个单词,提取offset字母,并将其添加到plain中,这是输出字符串。最后,Decode将输出字符串写入解码后的消息文件。
随机偏移
之前的实验有效,但并不难被破解。通过一些试验和错误,可以找到偏移并揭示隐藏的信息。我们可以通过随机改变偏移值来稍微提高安全性,使得第一个单词的第三个字母很重要,第二个单词的第五个字母很重要,接着是第一个字母和第三个字母,依此类推。结果仍然不是特别安全,但可能会使得意外发现信息变得困难。
文件steg_text.py实现了这种方法,并引入了我们将在本章剩余部分使用的一种技术,即使用固定种子的随机引擎生成一个确定性的随机选择偏移序列。让我们看看这种方法能为我们带来什么。
首先,我们在与关于不共享秘密的引用相同的输入消息上运行steg_text.py。在这种情况下,命令行是:
> python3 steg_text.py message.txt alice.txt output.txt
Your secret key is 499377
请注意“秘密密钥”,它实际上是一个伪随机数生成器种子。没有它,我们无法恢复隐藏的消息。
每次运行 steg_text.py 都会生成一个新的 output.txt 和秘密密钥。对于之前的运行,output.txt 变成了:
IT WHAT THERE THE HEAR SEEMED NATURAL VERY SKURRIED HE ALICE UP
DEAR THIS DIFFERENT VOICE CROCODILE WATERS IT THEIR BEFORE THAT
RAILWAY WOODEN SOON HALF SITS CATCHING THE TREMBLING TAIL YOUARE
WENT HUNDRED THE ANIMALS BIRDS
这对应于每个单词的偏移量:
1132131311412343131323422324231413243
THREEMAYKEEPASECRETIFTWOOFTHEMAREDEAD
偏移量是使用报告的种子伪随机选择的,这里的种子是 499,377。
要恢复原始消息,请使用:
> python3 steg_text.py 499377 output.txt tmp.txt
> cat tmp.txt
THREEMAYKEEPASECRETIFTWOOFTHEMAREDEAD
使用 steg_text.py 时,输出更难解析,因为偏移量是随机的。如果我们知道有隐藏消息,就需要付出努力去恢复它,因为每个单词字母的路径有很多种选择。例如,图 2-1 展示了通过前面三个单词的所有路径,总共有 40 种组合。

图 2-1:通过前三个单词的所有路径
每个组合如下:

组合 ITH、ITE、TWE、THE、THR、TAR、TAH 是常见英语单词可能的开头,例如:Ithaca、item、tweet、theme、three、tarnish 和 tahini。第四个单词 THE 给每个现有的前缀加了一个字母,其中只有 THRE 和 TART 看起来是合理的。接下来的单词 HEAR 让人清楚地知道,消息的第一个单词很可能是 THREE,或者是 TART 后面下一个单词的开头,假设消息本身没有被加密。
这个示例的源代码类似于 steg_simple.py,并使用了清单 2-1 中显示的相同 ProcessText 函数。对我们来说,重要的部分是编码器和解码器函数。我们从编码器开始,如清单 2-5 所示。
def Encode(mfile, pfile, ofile):
msg = ProcessText(open(mfile).read())
pool= ProcessText(open(pfile).read())
➊ key = RE(mode='int', low=10000, high=1000000).random()
rng = RE(mode='int', low=1, high=5, seed=key)
enc = []
idx = 0
for word in msg:
for c in word:
➋ offset = rng.random()
done = False
while (not done) and (idx < len(pool)):
if (len(pool[idx]) <= offset):
pass
elif (pool[idx][offset] != c):
pass
else:
enc.append(pool[idx])
done = True
idx += 1
with open(ofile, "w") as f:
f.write(" ".join(enc)+"\n")
➌ print("Your secret key is %d" % key)
清单 2-5:使用随机偏移进行编码
和 steg_simple.py 一样,消息和池文件会被加载和处理。秘密密钥是使用默认的随机引擎 PCG64 ➊ 选择的。手中有了 key,一个新的生成器就会使用 key 作为种子(rng)进行初始化。
每个消息字母通过在池中搜索匹配的单词来编码,但这次,生成器 ➋ 返回要使用的 offset。在所有消息字母处理完毕后,函数会写入最终输出文件并报告秘密密钥 ➌。请比较清单 2-5 和清单 2-3;固定的 offset 现在被随机生成的值替代了。
解码器在清单 2-6 中展示。
def Decode(key, ofile, mfile):
enc = ProcessText(open(ofile).read())
rng = RE(mode='int', low=1, high=5, seed=key)
plain = ""
for w in enc:
plain += w[rng.random()]
with open(mfile, "w") as f:
f.write(plain+"\n")
清单 2-6:使用随机偏移进行解码
秘密密钥构成了生成器的种子;然后,消息按单词解码,每次使用生成器中的下一个值,最后将消息写入磁盘。
有人可能会反对,因为实验中选用的词语虽然是实际存在的词,但并没有形成有意义的句子,这可能会引起对手的注意。我同意这种看法。我的辩护是,简单的程序无法生成有意义的句子,但我们仍然在嵌入一个消息,因此我们仍然遵循隐写术的精神,即便没有完全遵守它的绝对规则。别担心,章节后面的实验会给出明确的结果。
使用文本隐写术只是热身练习,因为我们可以更好地利用数字时代所提供的工具。所以,让我们放弃文本,转向隐藏任意文件到其他文件中的方法。
在随机数据中
在第一章中,我们使用了ent工具来帮助我们判断文件是否包含随机数据。在这里,我们将一个任意文件嵌入到一个随机数据的文件中,目的是使得像ent这样的工具仍然让我们相信该文件包含随机数据,尽管实际上它不再是随机的。这才是真正的隐写术,因为在嵌入我们希望隐藏的数据之前和之后,文件看起来都是随机的。
为了实现我们的目标,我们需要从比特的角度思考,而不是字符和单词。我们想要隐藏的文件以及池文件,仅仅是一串比特;我们不关心这些比特代表什么。因此,关键是将源文件的比特随机散布到池文件的比特中。
例如,如果我们想要隐藏源比特 11011011 在
110101011000101010010110101011001010010111
然后我们需要将比特随机散布。
110101011001101010010010101011001110010111
以便我们可以稍后恢复随机比特位置,从而重建原始文件。
这种方法适用于池文件比我们想要隐藏的文件大得多的情况。对于任何源比特,我们选择的池文件比特与源比特相同的概率是 50%,因为我们假设池文件是随机的。因此,通过足够的池文件比特和源文件比特的随机分布,我们不认为大多数工具像ent会发现文件已被修改。然而,隐藏比特和池文件比特之间的正确比例很难确定。隐藏比特越少越好,但我们可以插入多少比特才不会显得显眼呢?我们将在接下来的实验中简要探讨这个问题。
我们几乎准备好考虑编码问题了。我们的计划是将源文件的比特随机散布到池文件的比特中。我们可以通过使用RE和固定的种子值来生成一系列随机偏移值,从而获得随机的比特位置,每个源比特对应一个偏移值。
但虽然我们无疑可以用现有的方法对源文件进行编码,我们能否将它恢复出来?我们从编码文件中读取多少比特?我们并不知道源文件的长度,因此我们不仅需要编码源文件,还需要编码它的长度。
例如,如果源文件长度为 10,356 字节,我们不仅需要编码文件的所有 8 × 10,356 = 82,848 比特,还需要编码文件的字节数。我们将使用 32 比特来编码文件长度,所以我们将编码 82,848 + 32 = 82,880 比特,前 32 个比特表示编码的文件长度(单位:字节)。然后,为了恢复文件,我们读取 32 个比特并形成长度,以便知道要提取多少额外的比特。
让我们来列出步骤。要编码一个文件:
-
读取源文件并将其转换为比特列表。
-
在比特列表前添加 32 位,表示源文件的长度。
-
读取池文件并将其转换为比特列表。
-
使用提供的密钥作为种子,生成一个随机的偏移位置列表,每个源比特对应一个偏移位置。
-
在每个偏移位置设置池比特为对应的源比特值。
-
将池比特转换回字节集合并写入磁盘。
解码文件的方法:
-
读取编码文件并将其转换为比特列表。
-
使用提供的密钥作为种子,生成 32 个偏移位置。
-
计算文件长度和要读取的比特数。
-
生成那么多比特的偏移位置。
-
收集这些位置的比特并将它们转换为字节。
-
将字节写入磁盘,作为提取的文件。
这看起来有点复杂,需要进行一些记录,但最终是直接的:我们将源文件的比特随机散布在池文件中,然后再收集它们以提取源文件。
在我们进入代码之前,我们首先学习如何运行它,以便你能进行实验。使用代码时,我们需要一个要嵌入的文件和一个池文件。本书的 GitHub 仓库包含RandomDotOrg_sm.bin,这是一个来自random.org的 5MB 随机数据文件,经过许可使用。它将作为本例的池文件。如我们所见,ent喜欢这个文件:
entropy: 7.999969
chi2 : 84.34
mean : 127.4992
pi : 3.140270649 (0.04)
corr : 0.000322
至于源文件,请查看目录test_images,其中包含了一些标准的图像处理测试图像,涵盖各种类型和大小。我们将在本章后面再次使用这些图像,实验如何将文件隐藏在图像中。目前,我们需要的是boat.png,如图 2-2 所示。

图 2-2:船的图像
我们需要的代码在steg_random.py中。要编码文件,请使用如下命令行:
> python3 steg_random.py 12345 test_images/boat.png output.bin data/RandomDotOrg_sm.bin
该代码运行大约需要 30 秒。完成后,output.bin文件包含了隐藏的图像。ent仍然喜欢这个文件吗?它喜欢:
entropy: 7.999969
chi2 : 86.38
mean : 127.4903
pi : 3.140005417 (0.05)
corr : 0.000403
要恢复图像,请第二次运行steg_random.py,使用相同的密钥(12345):
> python3 steg_random.py 12345 output.bin tmp.png
现在,文件tmp.png包含了船的图像。
make_random.py中的代码使用RE类中可用的随机数生成器生成随机字节文件。我建议使用这个代码来创建实验所需的池文件。例如,使用以下命令生成一个包含 500 万字节的文件,使用梅森旋转算法:
> python3 make_random.py 5000000 none mt19937.bin mt19937
与大多数程序一样,运行make_random.py而不带参数会告诉我们如何使用该代码。
你能隐藏多少?
让我们尝试找出在工具如ent给出可疑结果之前,我们可以隐藏多少比特。为此,我们需要使用steg_random_test.py中的代码。我让你自己阅读代码;它是一个简单的脚本,执行以下操作:
-
使用make_random.py生成一个 1000 万字节的随机文件
-
使用steg_random.py嵌入越来越大的文件,这些文件仅由重复的字母A组成
-
运行
ent对结果进行分析,并提取估算的π值 -
绘制π的估算值与嵌入文件大小的关系
重复字母A创建了一个完全非随机的文件;它尽可能地非随机,熵为零。因此,这是最糟糕的嵌入文件,它会对输出产生最负面的影响。字母A本身没有什么特别之处,任何单字节值都可以。
图 2-3 展示了结果。

图 2-3:嵌入越来越大的A重复文件对π*估算值的影响
π的估算值与嵌入的A字符数量之间存在线性关系。池文件本身的估算误差为 0.01%,这是不错的。隐藏 50,000 个A将误差提升至 0.05%,这通常不会引起注意。然而,100,000 个A将误差提高到 0.5%,这开始显得有些可疑。
由于池文件有 1000 万字节,100,000 字节等于 10⁵/10⁷ = 0.01 = 1%,这意味着隐藏小于池文件大小 1%的文件通常不会引起注意。
例如,表 2-1 显示了使用urandom生成的 10 百万字节池文件,通过steg_random.py隐藏 100,000 字节的结果,这些字节全部是A字符,或者是alice.txt的前 100,000 个字符,或者是使用RDRAND生成的其他随机文件的输出。
表 2-1: 按隐藏文件类型估算的π值
| 隐藏文件类型 | 估算的π值 |
|---|---|
urandom池 |
3.142167657 (0.02%) |
隐藏RDRAND |
3.142429257 (0.03%) |
| 隐藏alice.txt | 3.146091658 (0.14%) |
| 隐藏A字符 | 3.157525263 (0.51%) |
这些结果表明,对于一个大小为池文件 1%的隐藏文件,我们应该期待像ent这样的工具在与非随机性相关的度量指标上显示出轻微的增加,比如在估算π时出现更大的误差。请注意,alice.txt是一个文本文件,因此我们可以期待二进制文件的效果会更好。故事的寓意是:将你的数据隐藏在一个至少比数据本身大 100 倍的随机文件中。
现在让我们看一下代码。
steg_random.py 代码
steg_random.py中的代码比我们目前使用的代码稍微复杂一些。我建议在继续之前先复习一下steg_random.py。你会看到两个工具函数,MakeBit和MakeByte。我们将在这里和后续的章节中使用这些函数,所以在继续编码和解码函数之前,我们先来定义它们。
位与字节之间的转换
我们打算处理位和字节,所以拥有可以相互转换的函数会很有帮助。这就是MakeBit和MakeByte的作用。列表 2-7 展示了MakeBit。
def MakeBit(byt):
b = np.zeros(8*len(byt), dtype="uint8")
k = 0
for v in byt:
s = format(v, "08b")
for c in s:
b[k] = int(c)
k += 1
return b
列表 2-7:将字节转换为位
参数(byt)是我们希望转换为输出位数组(b)的字节数组。每个字节包含八个位,因此我们提前定义了b,然后使用for循环逐一处理byt中的字节来填充它。
对于每个字节,我们要求 Python 将其转换为二进制字符串(s),并且特别注意前导零。然后,我们使用不断增加的索引(k)将每一位放入输出数组中,最后返回给调用者。
我们还需要将位数组转换回字节数组;MakeByte在列表 2-8 中展示。
def MakeByte(b):
n = len(b)//8
t = b.reshape((n,8))
byt = np.zeros(n, dtype="uint8")
for i in range(n):
v = (t[i] * np.array([128,64,32,16,8,4,2,1])).sum()
byt[i] = v
return byt
列表 2-8:将位转换为字节
参数是一个位数组(b),因此其长度将是 8 的倍数。因此,n是我们在输出数组中需要的字节数。为了得到字节,我们首先将b重新排列,将每八个位分为一组。换句话说,如果数组是一个包含 32 位的向量
11010101001011101101011001010100
它变成了
11010101
00101110
11010110
01010100
在t中,是一个 4×8 的位数组,表示 4 个字节。
接下来,我们将t的每一行与表示字节中每个位的位值的向量相乘。最后,我们将得到的向量求和,得到实际的字节值(v),并将其放入输出数组byt中。
现在,让我们来学习如何将位隐藏在随机文件中。
文件编码
文件的编码通过巧妙命名的Encode函数完成:
def Encode(key, sfile, dfile, pfile):
src = np.fromfile(sfile, dtype="uint8")
s = format(len(src), "08x")
b3 = int(s[0:2],16); b2 = int(s[2:4],16)
b1 = int(s[4:6],16); b0 = int(s[6:8],16)
src = MakeBit(np.hstack(([b3,b2,b1,b0],src)))
step = RE(mode="int", low=1, high=16, seed=key).random(len(src))
idx = [step[0]]
for i in range(1, len(step)):
idx.append(idx[-1]+step[i])
pool = MakeBit(np.fromfile(pfile, dtype="uint8"))
if (len(pool) <= idx[-1]):
print("Pool file is too small")
exit(1)
for i in range(len(src)):
pool[idx[i]] = src[i]
dest = MakeByte(pool)
dest.tofile(dfile)
我们的目标是将源文件(sfile)的位随机地分散到池文件(pfile)的位中,并将结果输出到目标文件(dfile)。生成器的种子存储在key中。
在第一个代码段中,我们将源文件读取到字节数组(src)中。如前所述,我们还需要编码文件的长度,我们通过将字节数表示为十六进制字符串(s)来完成。我们使用一个无符号的 32 位整数作为文件长度,因此我们只能隐藏小于 4,294,967,296 字节的文件。这应该不会成为问题。表示长度的各个字节从src中提取出来,并按从最高有效位(b3)到最低有效位(b0)的顺序解释为十六进制数。由于src仍然是一个字节向量,我们通过使用 NumPy 的hstack函数将长度的 4 个字节加到开头。最后,我们使用MakeBit将src转换为位向量。
在第二段代码中,我们构建了idx,它是一个不断增加的偏移量向量,每个位对应src的一个偏移量。这些是pool中的位置,将用src的位值进行更新。首先,step是一个偏移量向量,从 1 到 15 位不等。注意使用key来设置种子。idx向量是通过将每个偏移量添加到idx的最后一个元素,然后再将新的偏移量附加到idx来构建的。
第三段代码读取池文件作为位向量(pool)。如果池文件太短,无法容纳idx中的所有位偏移位置,程序将显示简洁的消息并立即终止。
目前,我们拥有所需的一切:src作为位,pool作为位,idx告诉我们src的位应该放到哪里。因此,第四段代码是一个简单的循环,将pool的正确位设置为src的对应位。我们按顺序遍历src,同时遍历idx,但idx中的值是pool中需要更新的位置。
最后一段将更新后的pool转换为字节向量并写入目标文件(dfile)。
我们可以正式对文件进行编码;现在让我们把它解码回来。
解码文件
解码文件使用了一个同名的Decode函数:
def Decode(key, sfile, dfile):
src = MakeBit(np.fromfile(sfile, dtype="uint8"))
rng = RE(mode="int", low=1, high=16, seed=key)
step = rng.random(32)
bits = []
idx = [step[0]]
for i in range(1, len(step)):
idx.append(idx[-1]+step[i])
for i in range(len(idx)):
bits.append(src[idx[i]])
n = MessageLength(bits)
offset = idx[-1]
step = rng.random(8*n)
idx = [offset + step[0]]
bits = []
for i in range(1, len(step)):
idx.append(idx[-1]+step[i])
for i in range(len(idx)):
bits.append(src[idx[i]])
dest = MakeByte(np.array(bits))
dest.tofile(dfile)
在这里,sfile是由Encode生成的文件名,dfile是所需的输出文件名。请注意,key必须与用于编码的值匹配。
代码首先读取编码后的文件,并立即将其转换为位向量(src)。
在接下来的代码段中,创建了正确设置种子的生成器(rng)。请注意RE的实例化:整数类型,默认的 PCG64 生成器,位范围从 1 到 15,并使用种子。这是编码时使用的配置,因为它必须一致。
通过将step中的 32 个值添加到idx中的每个最终偏移量中,提取前 32 个编码位,方法与Encode类似。得到的bits被传递到辅助函数MessageLength中,参考列表 2-9。
def MessageLength(bits):
b = MakeByte(np.array(bits))
n = 256**3*b[0] + 256**2*b[1] + 256*b[2] + b[3]
return n
列表 2-9:将 32 位转换为无符号整数
该函数将 32 位向量转换为 4 字节的字节向量(b);按正确的位值依次乘以每个字节,从最高位字节开始;然后相加得到实际的编码文件长度(以字节为单位,n)。处理 32 位值的字节与将其视为基数为 256 的数字类似,因此使用了 256 的指数。
在最后一段中,计算编码文件剩余部分的位位置,就像之前一样从step和idx计算,但我们需要加上额外的偏移量来考虑已经读取的 32 位。idx构建完成后,立即用于提取bits。所有收集到的位被转换为字节(dest),最终输出到文件(dfile)。
呼!在steg_random.py中发生了很多事情。幸运的是,大部分内容可以转移到本章的剩余两节,那里我们将数据隐藏到音频文件和图像中。让我们学会小心地低语,这样除非我们希望别人听见,否则没人能听到我们的声音。
在音频文件中
数字音频无处不在,是隐写术的天然目标。本节探讨如何将文件嵌入到数字音频中,特别是 WAV 文件。我们的方法与上一节类似:我们将把文件的位散布到数字样本中。然而,区别在于,我们不会改变随机的位,而是改变最低有效位,以最小化影响,使得信息几乎是静默的。
我们需要的文件是steg_audio.py。在继续之前,务必检查一下这个文件。你会注意到它使用了MakeBit和MakeByte,这些我们之前已经使用过——分别参见清单 2-7 和 2-8。同样,你还会看到MessageLength(清单 2-9)。
我们在第一章中讨论了数字采样,当时我们从麦克风输入生成随机位。那时,我们需要 32 位浮点数样本。在这里,我们将使用更常见的有符号 16 位样本,范围为[–32,768, 32,767]。这种样本每个使用两个字节。我们将限制只改变最低有效位,从而使样本值最多改变 1,听不出任何差别。
WAV 文件未压缩,因此可能相当大。大多数互联网音频是 MP3 格式的,这种格式采用有损压缩。人类听觉系统并不需要所有原始音频样本中的信息——大约 90%的信息可以丢弃,这就是所谓的“损失”。然而,由于我们计划使用最简单的音频隐写方法,并且如果文件转换为 MP3 格式,隐藏的文件将会丢失,因此我们必须使用完整的 WAV 文件。有更复杂的音频隐写方法使用回声等特征,它们能在压缩过程中生存下来,但这些内容超出了本书的范围。
该算法的需求与上一节的需求非常相似:
-
读取源文件并将其转换为位,以及文件的字节长度。
-
读取原始 WAV 文件,确保源文件能适配,并且如果是立体声,只使用一个声道。
-
生成一个随机但不断增长的索引列表,指向 WAV 样本中的每一位,代表源文件的每一位。
-
改变选定样本的第 0 位,使其与相应的源文件位匹配。
-
将修改后的音频样本写入一个新的音频文件。
我们首先将使用代码隐藏文件,然后再探索代码本身。
一个安静的现场表演
在详细讲解代码之前,让我们先试试 steg_audio.py。本书的 GitHub 仓库提供了几种示例 WAV 文件,由作曲家 Paul Kneusel 提供 (www.paulkneusel.com)。首先,让我们在 Fireflies 中隐藏一张图像,这是一首 2016 年由钢琴家 Kristen Kosey 演奏的现场表演曲目。Fireflies 是一首安静的曲子,从而增加了我们可能听到由样本改变所产生的效果的概率。以下是命令行:
> python3 steg_audio.py 2718281828 test_images/tulips_gray.png tmp.wav Fireflies.wav
Using 3712480 samples to store the file
首先,我们提供秘密密钥 2718281828,然后是要嵌入的文件 tulips_gray.png,接着是输出文件名 tmp.wav,最后是源 WAV 文件 Fireflies.wav。代码告诉我们它用了 3,712,480 个样本来存储 tulips_gray.png 的比特。能够识别秘密密钥的读者会得到额外积分。
与之前一样,我们使用相同的代码恢复隐藏的文件:
> python3 steg_audio.py 2718281828 tmp.wav tmp.png
这会生成 tmp.png,即在 图 2-4 中显示的原始图像文件。

图 2-4:恢复的图像
播放输出文件 tmp.wav。仔细听,尝试辨别 tmp.wav 和原始文件 Fireflies.wav 之间的差异。图像文件被巧妙地隐藏,几乎无法察觉。这就是隐写术的本质:不被察觉。
好的,steg_audio.py 可以工作,但它到底做了什么?我们需要将每个样本的最低有效位改为与我们隐藏的文件对应的比特。让我们查看 tulips_gray.png 的前八个比特,以及对应的样本偏移量和样本值,如 表 2-2 中所示。
表 2-2: tulips_gray.png 的前八个比特与偏移量和样本
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |
|---|---|---|---|---|---|---|---|---|
| 比特 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 1 |
| 偏移量 | 35 | 36 | 50 | 68 | 85 | 90 | 92 | 99 |
| 样本 | 65,533 | 5 | 7 | 65,532 | 65,535 | 65,532 | 5 | 65,534 |
样本 35 的值为 65,533,我们希望样本 35 的最低有效位为 1。同样,样本 36 的值为 5,我们希望其最低有效位为 0,以此类推。
我们之前讨论过,WAV 文件使用的是范围为 [–32,768, 32,767] 的有符号 16 位整数。这是正确的,但当我们读取音频样本时,为了避免处理负数,我们将其解释为范围为 [0, 65,535] 的无符号整数。我们将在写入输出 WAV 文件之前,再将解释方式改回有符号 16 位整数。
我们希望样本 35 当前为 65,533,使其第一个比特为 1。为此我们有几种选择。一个方法是使用低级位操作。但请注意,当整数的第 0 位为 1 时,数字为奇数,而当第 0 位为 0 时,数字为偶数。在这种情况下,65,533 已经是奇数,意味着第 0 位已经是 1,所以我们可以保持样本不变。
第二个样本当前值为 5,但我们希望位 0 为 0。由于 5 是奇数,我们减去 1 使新样本值变为 4,且位 0 为 0,符合我们的要求。这个过程会对我们要隐藏的文件中的每个位重复进行。样本的最低有效位要么已经正确,要么偏差 1,需要加 1 或减 1。总结一下:只有当我们需要样本为奇数时才加 1;同样,只有当我们需要样本为偶数时才减 1。
让我们回到我之前关于图像文件“完全不可察觉”的傲慢说法。假设对手获取了原始的Fireflies声音文件。对样本进行逐比特比较可能会提供一些线索,表明有些异常,但我们仅在需要更改样本时才会进行更改。因此,单纯提取可疑 WAV 文件和原始文件之间不同的样本,不足以发现隐藏的文件,因为平均来说,只有一半的样本需要被修改。换句话说,没有关于所用样本分布的知识,原始文件很难恢复。
如果对手只有修改过的 WAV 文件呢?在这种情况下,一个精明的对手如果怀疑存在隐写术,可能会认为样本的最低有效位(least-significant bit)是最可能被改变的,因为修改其他位会在播放 WAV 文件时产生噪音,但实际上并未出现噪音。这种怀疑可能促使对手检查位 0 在样本中的分布,并与其他 WAV 文件进行对比。
如果你运行steg_audio_test.py,你会看到以下输出:
Fireflies: [5104781 5103286] 0.6398434737379124
Attitude : [1863322 1860129] 0.09798008575083986
Fun-Key : [1664208 1661616] 0.15522993473071336
Encoded : [5093639 5114428] 7.681149082012241e-11
每行报告了对应 WAV 文件中位 0 的分布情况。例如,Fireflies.wav中有 5,104,781 个偶数样本和 5,103,286 个奇数样本(仅针对通道 0)。每行的最后一个值是χ²检验的 p 值,该检验用于判断样本的偶数和奇数计数是否符合预期,即样本的偶奇性是否随机。只有 p 值的前几个数字才有意义。
请记住,p 值大于 0.05 表示支持零假设,即样本是偶数或奇数的可能性相同。Fireflies、Attitude 和 Fun-Key 的 p 值与零假设的结果高度一致。
最后一行,Encoded,显示的是tmp.wav的分布情况,这是带有tulips_gray.png文件隐藏其中的 Fireflies 版本。此时,p 值为 7 × 10^(–11),这是接近 0 的最好的近似值。换句话说,χ²检验告诉我们,位 0不符合零假设——而且偏差极大。看到这些结果后,我们的对手会确信有些不对劲。
那么,我的傲慢是否合理呢?不,完全不合理。一项简单的统计测试,再加上一些关于如何在 WAV 文件中隐藏信息的思考,强有力地支持了这种观点:这个 WAV 文件已经被篡改。而且,尽管我们的对手强烈怀疑我们篡改了 WAV 文件,但他们依然无法提取信息,因为他们不知道哪一位被改变了。
我鼓励你尝试使用steg_audio.py和手头的 WAV 文件。当你准备好时,继续阅读以跟随代码的讲解。
steg_audio.py 代码
steg_audio.py文件包含五个函数和底部的一个小驱动程序。我们已经熟悉了MakeBit、MakeByte和MessageLength函数(见清单 2-7,2-8 和 2-9)。
剩下的两个函数是Encode和Decode。你可能会注意到一个趋势。steg_audio.py的整体结构与steg_random.py的结构相匹配。让我们仔细看看Encode,跟踪这个过程。最好分段吸收这个函数,所以清单 2-10 展示了第一部分。
def Encode(key, sfile, dfile, pfile):
src = np.fromfile(sfile, dtype="uint8")
s = format(len(src), "08x")
b3 = int(s[0:2],16); b2 = int(s[2:4],16)
b1 = int(s[4:6],16); b0 = int(s[6:8],16)
src = MakeBit(np.hstack(([b3,b2,b1,b0],src)))
sample_rate, wav = wavread(pfile)
if (wav.ndim == 2):
samples = wav[:,0].astype("uint16")
else:
samples = wav.astype("uint16")
if (len(src) > len(samples)):
print("The input WAV file is too short")
exit(1)
else:
print("Using %d samples to store the file" % len(src))
清单 2-10:在音频文件中隐藏数据(第一部分)
清单 2-10 的第一段与steg_random.py中的对应代码相同。嵌入的文件在预先添加文件大小(以字节为单位,src)后被加载并转换为比特。
第二段读取音频文件,保持采样率(sample_rate)和原始样本作为有符号 16 位整数(raw)。接着,if语句将样本解释为无符号 16 位整数,同时如果有多个通道,则选择通道 0。最终结果是samples,即我们要更新的实际值。第三段是一个合理性检查,用来验证是否有足够的样本来隐藏源文件。
清单 2-11 展示了Encode的其余部分。
step = RE(seed=key, mode="int", low=1, high=5).random(len(src))
idx = [step[0]]
for i in range(1, len(src)):
idx.append(idx[-1]+step[i])
if (len(samples) <= idx[-1]):
print("Audio file too short")
exit(1)
for i in range(len(src)):
if (src[i] == 0):
if ((samples[idx[i]] % 2) == 1):
samples[idx[i]] -= 1
else:
if ((samples[idx[i]] % 2) == 0):
samples[idx[i]] += 1
out = samples.astype("int16")
if (wav.ndim == 2):
wav[:,0] = out
wavwrite(dfile, sample_rate, wav)
else:
wavwrite(dfile, sample_rate, out)
清单 2-11:在音频文件中隐藏数据(第二部分)
准备好样本后,我们可以开始隐藏一些数据。首先,我们使用提供的密钥(step)创建一个偏移量向量。我们在steg_random.py中也做了同样的事。这些偏移量用于构建idx,一个要修改的样本向量。最终的合理性检查确认了有足够的样本。
第二个for循环设置所选样本的最低有效位,根据当前位(src[i])的值,必要时将其改为奇数或偶数。
循环体需要一些解释。如果当前源位是 0,那么当前样本(samples[idx[i]])有两种可能性。如果样本是奇数,即除以 2 后的余数为 1,我们从样本中减去 1 使其变为偶数。否则,我们要存储的位是 1,这意味着我们检查样本是否为偶数,如果是的话,我们加 1 使其变为奇数。
最后一段将样本重新解释为有符号整数(out),并使用原始采样率将其写入磁盘。如果音频文件是立体声,则更新通道 0,其他通道保持不变。
要提取隐藏的文件,我们使用Decode逆向该过程,如列表 2-12 所示。
def Decode(key, sfile, dfile):
sample_rate, wav = wavread(sfile)
if (wav.ndim == 2):
samples = wav[:,0].astype("uint16")
else:
samples = wav.astype("uint16")
rng = RE(mode="int", low=1, high=5, seed=key)
step = rng.random(32)
bits = []
idx = [step[0]]
for i in range(1, len(step)):
idx.append(idx[-1]+step[i])
for i in range(len(idx)):
bits.append(samples[idx[i]] % 2)
n = MessageLength(bits)
offset = idx[-1]
step = rng.random(8*n)
idx = [offset + step[0]]
bits = []
for i in range(1, len(step)):
idx.append(idx[-1]+step[i])
for i in range(len(idx)):
bits.append(samples[idx[i]] % 2)
msg = MakeByte(np.array(bits))
msg.tofile(dfile)
列表 2-12:解码隐藏在音频文件中的数据
该过程包括四个步骤:读取编码的 WAV 文件作为无符号整数样本,生成与编码文件时使用的相同的索引集,提取位并将其写入磁盘。
在第二段中,伪随机生成器被配置为与Encode中相同,然后生成前 32 个步骤值以提取编码的文件长度。我们需要文件长度来确定重构隐藏文件时需要查询多少样本。注意对MessageLength的调用,列表 2-9。
在第三段中,文件的实际位已经被提取出来,因为我们现在知道它们的数量。最后,这些位被转换为字节并写入输出文件(dfile)。
接下来我们尝试在图像中隐藏数据。这个过程类似于在随机字节文件或音频文件中隐藏数据,但我们将不再使用秘密密钥的概念。
图像文件中的内容
本章的最后实验是将文件隐藏在图像中。这个方法应该很熟悉:我们通过对现有像素颜色做小的改变,将文件的位分散到图像的像素中。这是最紧凑的实验,就代码而言,但在深入之前,我们需要讨论一些关于数字图像的内容。具体来说,我们需要了解计算机如何存储和解读图像,然后我将介绍算法。实验之后是代码讲解。你知道流程。
定义图像格式
“图像格式”这一术语有多重含义,特别是在使用 Python 时。
第一个提到的是图像如何存储在磁盘上。存储方式有很多选项,主要可以分为两大类:有损和无损。
有损图像格式通过丢弃信息进行压缩,类似于 MP3 文件。保留的信息足以将图像重构到一定程度的原始图像保真度。最常见的有损图像格式是 JPEG,通常具有.jpg文件扩展名。该格式使用离散余弦变换(DCT)对小块进行处理,以保留重要的细节层级,同时丢弃那些对图像重构贡献不大的部分。
无损图像格式要么保留所有像素信息,要么无损压缩图像,这意味着恢复的数据与原始未压缩图像在逐位上完全一致。最常见的无损图像格式是 PNG(.png)。我们将仅使用 PNG 文件,因为像 JPEG 这样的有损格式不被接受,原因与 MP3 文件相同:对图像进行的细微更改会丢失。先进的隐写术技术对有损格式是强大的,但它们远超我们在这里可以探索的范围。
“图像格式”还指的是像素本身,主要的划分为灰度图像和彩色图像。
灰度图像是最简单的:每个像素是一个单独的数字,一个字节,表示从黑色(0)到白色(255)以及其中的灰色阴影的像素强度。16 位像素的灰度图像很少见,因此我们在这里将忽略它们。
计算机在表示彩色图像时有许多选择。最常见的是将每个像素的颜色分为红色、绿色和蓝色的组合。也就是说,一个显示为淡红色的像素可以表示为红色、绿色和蓝色的混合,得到了这种颜色。红色、绿色和蓝色的 通道 通常是字节,0 表示没有这种颜色,255 是该颜色的最大强度。例如,淡红色是 A94141,这是表示红色、绿色和蓝色数量的三个十六进制数。将十六进制转换为十进制,我们得到 169(红色,最大值为 255)、65(绿色)和 65(蓝色)。
使用 NumPy 和 PIL
对 NumPy 来说,灰度图像是一个 2D 字节数组。同样,彩色图像或 RGB 图像是一个 3D 字节数组——每个颜色通道对应一个 2D 数组。我们将使用 RGB 图像进行接下来的实验。如果加载的是灰度图像,它将通过复制灰度值到每个通道来转换为 RGB。
NumPy 将图像作为数组使用,但我们如何将它们首先导入到 Python 中呢?为此,我们需要 Python 图像库 (PIL),现在称为 Pillow。它包含在大多数 Linux 发行版中,并且适用于 Windows 和 macOS。对于 Linux 和 macOS,输入以下内容:
> pip install pillow
任何 8.4 或更高版本都应该能工作。我们只需要它来读取和写入图像文件。
本书的 GitHub 页面包括一系列标准测试图像。我们已经使用了其中的一些,而没有将它们导入到 Python 中。现在让我们来解决这个问题:
>>> import numpy as np
>>> from PIL import Image
>>> im = Image.open("apples.png")
>>> d = np.array(im)
>>> d.shape
(352, 375, 3)
>>> im = Image.open("barbara.png")
>>> d = np.array(im)
>>> d.shape
(256, 256)
>>> im = Image.open("barbara.png").convert("RGB")
>>> d = np.array(im)
>>> d.shape
(256, 256, 3)
在这里,我展示了 Python 提示符和手动输入的命令。前两行加载了 NumPy 和 PIL 的 Image 类。我们只需要 Image。
接下来的两行将文件 apples.png 加载到 im 中,这是 Image 类的一个实例。为了将图像转换为 NumPy 数组,将其传递给 np.array 创建 d。注意 d 的形状:(352,375,3)。该图像有 352 行(即高度)和 375 列(即宽度)。最后的维度是 3,表示红色、绿色和蓝色通道。
以下两行加载barbara.png并将其转换为 NumPy 数组。在这种情况下,数组只有两个维度:高度和宽度,都是 256。这是一个具有一个通道的灰度文件。
最后三个命令再次加载barbara.png,但立即将图像传递给Image的convert方法,将其转换为 RGB 图像。NumPy 数组现在具有三维,虽然作为灰度图像,每个颜色通道都是相同的。
尽管已经有很多书籍介绍了 Python 中的图像处理,但幸运的是我们只需要知道如何打开图像文件,确保它是 RGB,转化为 NumPy 数组,并将 NumPy 数组以图像的形式写入磁盘。继续之前的例子:
>>> d = np.array(Image.open("apples.png"))
>>> t = d[:,:,0]
>>> d[:,:,0] = d[:,:,1]
>>> d[:,:,1] = t
>>> im = Image.fromarray(d)
>>> im.save("bad_apples.png")
第一行重新读取apples.png,然后立即将其转换为 NumPy 数组。我们将始终使用这种习惯用法。
接下来的三行交换红色和绿色颜色通道。仔细阅读它们。由于 NumPy 将第三个索引作为通道,我们需要明确提到高度和宽度。
下一行使用Image的fromarray方法将d转换为Image对象:im。最后一行使用save方法将修改后的图像保存为bad_apples.png。PIL 使用文件扩展名来指定格式,在这种情况下是 PNG 文件。打开bad_apples.png来了解为什么我选择这个名字。
隐藏比特到像素中
为了将隐藏文件的比特散布到图像的像素中,我们需要加载源文件并使用MakeBit将其转换为比特。然后我们加载图像文件,确认它是 RGB 图像,并将其转换为 NumPy 数组。
我们之前的实验修改了池文件的比特,使其与源文件的比特匹配。对池文件的随机偏移序列是可重现的,因为我们将随机引擎的种子设置为密钥。虽然我们可以在这里做同样的事情,但我们将采取不同的方法。正如他们所说,生活需要一些变化。
我们将使用未修改的图像作为我们的密钥。当隐藏文件时,我们将初始化随机引擎,但不设置种子,这意味着每次运行代码时,隐藏的文件会在图像的不同位置。由于我们修改的是特定的比特,因此我们通过使用未修改的图像可以知道我们修改了哪些比特。为了简化,我们将像处理音频样本时一样:每个图像文件的字节隐藏一个文件的比特。
尽管我们可以设置像素的最低有效位,就像我们对音频文件所做的那样,但我们永远无法检测到图像像素是否已经有正确的比特值。由于我们放弃了密钥以获得每次运行不同的修改像素集,我们永远无法知道哪些像素碰巧已经有了正确的比特值。
与其寻找像素的第 0 位是 0 还是 1,或者等效地询问该像素是偶数还是奇数,我们通过将 1 加到像素值来编码 0 位,将 2 加到像素值来编码 1 位。在这种情况下,未修改的图像中任何与修改后图像对应像素不同的像素,都会存储隐藏文件的一位。
在 RGB 图像中,每个像素实际上有三个字节;我们可以修改其中任何一个。当我们实现这个算法时,我们将通过要求 NumPy 将三维数组转换为一个单一的向量来“解开”图像,这是图像数据在计算机内存中的存储方式。红色、绿色和蓝色字节在解开的数组中的位置并不重要,只要 NumPy 能够保证这个过程是可重复的,而它做到了。
将图像字节作为一个向量后,我们选择一个位置子集——与隐藏文件中的位数一样多——并通过加 1 来修改它们,如果相应的位是 0,或者加 2 如果该位是 1。
字节是无符号数字,范围在[0, 255]之间,这意味着如果像素值是 255,我们加 1 来编码 0 时,将回绕到 0(或者加 2 编码 1 时回绕到 1)。这可不行,所以我们做出了一个决策:在编码之前,我们将所有大于 253 的图像像素字节修改为 253,这样加 1 后变成 254,加 2 后变成 255,从而避免溢出。
我们能够修改图像像素字节有两个原因:首先,代码每次都会为未修改的图像执行这一操作,即使在解码时也是如此,因此这是可重复的;其次,因为字节表示的是颜色值,255 与 253 之间的差异太微妙,以至于无法看出,特别是在整体颜色强度或阴影有全局变化时。
生成的代码是steg_image.py,我鼓励你在继续阅读之前先研究一下它。我们将在实验后逐步讲解这段代码。
这种方法与音频方法一样脆弱。如果我们使用有损图像格式(如 JPEG)存储编码后的图像,我们将丢弃编码。我们可以使用 JPEG 作为原始图像,因为 NumPy 每次都会以相同的方式解码它,但这甚至可能存在风险,因为未来版本的 NumPy 可能会改变解码图像时使用的算法(虽然这种可能性不大,但确实存在)。再次强调,我们将仅限于使用无损压缩的 PNG 文件。
现在,让我们玩得开心一点吧!
在一张图像中隐藏另一张图像
在我们的第一次实验中,我们将在第二张图像(apples.png)中隐藏一张图像(cameraman.png)。我们需要的命令行是:
> python3 steg_image.py encode test_images/cameraman.png test_images/apples.png tmp.png
我们告诉steg_image.py我们要编码,然后提供要编码的文件(cameraman.png),接着是参考图像(apples.png)和输出图像文件(tmp.png)。
打开apples.png和tmp.png,仔细观察,如果你的软件允许,可以快速切换两者。你看到有什么区别吗?仔细检查可能会发现一些差异,但我眼睛并没有察觉到什么。
要恢复cameraman.png,请使用:
> python3 steg_image.py decode tmp.png test_images/apples.png output.png
cameraman.png 文件是 apples.png 大小的 16%,并且轻松地隐藏在苹果图像中。那么文件有多大时我们才能察觉到它?
如果你运行 steg_image_test.py,你会得到一个新的输出目录 steg_image_test,其中包含多个图像,文件名类似 apple_A_0.60.png 和 violet_rand_0.40.png。这些文件是图像,每个图像隐藏了特定数量的 A 字符或从 RDRAND 读取的随机数据。文件名的第一部分是测试图像来源,apples.png 或 violet.png。后者是一个 512×512 像素的淡紫色图像。
每个文件名中的分数表示用于存放编码数据的参考像素图像的比例。例如,apple_A_0.60.png 隐藏了字母 A,因此 apples.png 中 60% 的像素已经被修改。比例从 5% 开始,一直到 99%。查看这些输出文件,尤其是紫色文件。即使 99% 的像素已经被修改成相同的字节重复或高度随机的字节,我们仍然无法察觉输出图像文件与原始图像之间的区别。这是不可察觉的隐写术的一个例子。为了简洁起见,我不会详细讨论 steg_image_test.py,但请你一定看看它。
我们已经将一张图像隐藏在另一张图像中。那我们为什么不能将一条消息隐藏在一张图像中,再把这张隐藏有消息的图像隐藏在另一张图像中,依此类推,最终生成一张包含俄罗斯套娃图像的输出图像呢?为什么不呢?让我们来试试。
我们要运行的脚本在 russian_dolls_example 中,详见 清单 2-13。
echo "Encoding..."
python3 steg_image.py encode kilroy.txt test_images/apples_32.png /tmp/encode0.png
python3 steg_image.py encode /tmp/encode0.png test_images/peppers_128.png /tmp/encode1.png
python3 steg_image.py encode /tmp/encode1.png test_images/fruit2.png /tmp/encode2.png
python3 steg_image.py encode /tmp/encode2.png test_images/tulips.png russian_dolls.png
echo "Decoding..."
python3 steg_image.py decode russian_dolls.png test_images/tulips.png /tmp/decode0.png
python3 steg_image.py decode /tmp/decode0.png test_images/fruit2.png /tmp/decode1.png
python3 steg_image.py decode /tmp/decode1.png test_images/peppers_128.png /tmp/decode2.png
python3 steg_image.py decode /tmp/decode2.png test_images/apples_32.png output.txt
清单 2-13:编码和解码俄罗斯套娃
这是一个你可以运行的 Shell 脚本:
> sh russian_dolls_example
完成后,查看带有所有嵌套图像和原始消息的图像 russian_dolls.png。然后将源文件 kilroy.txt 与 output.txt 进行比较,看看它们是否相同。编码过程每个阶段的中间图像保存在系统的 tmp 目录中。
图 2-5 展示了构建俄罗斯套娃堆的过程。

图 2-5:构建俄罗斯套娃堆
首先,“Kilroy was here”这条文本消息被隐藏在苹果图像的小版本中。然后,该图像被隐藏在辣椒图像中,辣椒图像又被隐藏在水果图像中,最终这些都被隐藏在郁金香图像中作为最终输出。逆向这个过程,逐步解码,就能得到原始信息:“Kilroy was here”。
注意
“Kilroy was here”这句话在二战期间被美英军队使用,部分作为玩笑,部分作为标记他们已经到过的地方。战后它成为了一种早期的网络迷因,通常会伴随一个傻乎乎的角色面孔。
让我们一起走一遍代码,看看 Kilroy 出现过的地方。
steg_image.py 代码
在我们所有的实验中,steg_image.py 可能是最简单的。它使用了MakeBit和MakeByte,并有一个简单的驱动程序来调用Encode和Decode。让我们集中讨论这两个函数,从Encode开始,见列表 2-14。
def Encode(mfile, sfile, dfile):
➊ msg = MakeBit(np.fromfile(mfile, dtype="uint8"))
simg = np.array(Image.open(sfile).convert("RGB"))
simg[np.where(simg > 253)] = 253
row, col, channel = simg.shape
➋ simg = simg.ravel()
if (len(msg) > len(simg)):
print("Message file too long")
exit(1)
➌ rng = RE(kind="mt19937")
p = np.arange(len(simg))
np.random.shuffle(p)
p = p[:len(msg)]
p.sort()
➍ for i in range(len(p)):
simg[p[i]] += msg[i]+1
simg = simg.reshape((row, col, channel))
Image.fromarray(simg).save(dfile)
列表 2-14:将文件隐藏在图像中
要隐藏的文件(mfile)被读取为字节,并立即转换为比特向量 ➊。接下来,读取参考图像,如果图像还不是 RGB 格式,则转换为 RGB 格式,然后转为 NumPy 数组(simg)。
如前所述,我们需要防止在编码比特时发生溢出,因此我们使用 NumPy 的where函数来定位图像中大于 253 的位置。where函数返回一组索引,不论simg的维度如何,这些索引会立即用于将这些位置更改为 253,这是新的最大值。这样可以涵盖图像中的所有红、绿、蓝部分。
我们打算将图像作为向量使用,因此接下来我们获取实际的行、列和通道(= 3)编号,这些将在稍后重新形成图像时使用。
接下来,我们告诉 NumPy 通过调用ravel ➋将三维图像数组转换为向量。在 NumPy 中,ravel是一个反义词,即一个词语本身就是其反义词。在这种情况下,ravel的意思是展开,将数组转换为向量,一个一维的结构。调用ravel后,simg就成为了图像字节的向量。我们不关心每个特定的红、绿、蓝字节的具体位置;NumPy 会一致地应用ravel。
我们将图像表示为字节向量,将源文件表示为比特向量。为了将比特分散到字节中,我们需要一个位置向量;这就是rng ➌的作用。首先,我们使用梅森旋转算法初始化rng。接下来的四行代码构建了索引向量,即我们将要修改的字节(p)。
我们希望p的元素数量与src中的比特数量相同。首先,我们将p设置为 0,1,2,…,n – 1,其中n是图像simg中的字节数。得到的向量经过洗牌,创建了一个值的唯一排列。
以下两行保留了p中我们需要的元素,以便将源文件(msg)的每个比特放置其中,并对p进行排序,使得索引列表按数字顺序排列。这个最后的步骤是必要的,因为我们没有设置rng的种子,因此必须对图像字节的更新应用某种顺序,以保持msg中比特的顺序。我们在回顾Decode时,你会明白我是什么意思。
为了将msg的比特分散,我们只需要遍历它们,并使用p中的对应索引 ➍。我们将比特值加 1,使得 0 变成 1,1 变成 2。这个变化使我们能够通过使用参考图像作为密钥来检测所有被修改的比特。
最后,我们reshape更新后的图像字节,创建 3D 输出数组。注意,reshape在内存中正确地重新解释了字节,这种解释方式与ravel所用的顺序相匹配。换句话说,reshape反向操作了数组的排列顺序。该数组被传递给 PIL 的Image类,作为 PNG 文件输出。
如清单 2-15 所示,Decode使用参考图像作为密钥恢复隐藏的文件。
def Decode(dfile, sfile, mfile):
dimg = np.array(Image.open(dfile)).ravel()
simg = np.array(Image.open(sfile).convert("RGB")).ravel()
simg[np.where(simg > 253)] = 253
i = np.where(dimg != simg)
d = dimg[i] - simg[i] - 1
b = MakeByte(d.astype("uint8"))
b.tofile(mfile)
清单 2-15:解码图像
Decode将两个图像文件加载为 NumPy 数组,一个是包含隐藏数据的图像(dimg),另一个是参考图像(simg)。然后,该函数立即将这两个图像文件转换为字节向量,参考图像通过convert转为 RGB 图像,正如Encode所做的那样。与Encode相同,参考图像的字节值大于 253 的会被设置为 253。
NumPy 的where返回两个图像不同的所有位置。因为我们假设参考图像是完全与创建dimg时使用的图像相同,所以任何差异代表隐藏文件的比特。
编码图像与参考图像之间的差异为我们提供了比特值,但这些比特是 1 和 2,而不是 0 和 1。因此,我们再减去 1,将d变成隐藏比特值的向量。因为我们坚持在编码时按数值顺序更改字节,所以图像中差异出现的顺序与输出比特的正确顺序匹配。换句话说,我们不需要排序;where返回的索引已经是排序好的。
剩下的两行将d转换为字节并将其写入输出文件(mfile)。
练习
隐写术提供了许多可能性。以下是一些可供探索和思考的内容:
-
贝肯密码通过微妙地改变字体类型来嵌入表示字母的二进制代码。要将任意二进制数据嵌入文本块中,需要做什么改变?使用等宽字体的单词之间的空格怎么样?
-
制作你自己的“太空船密码”来在图画中隐藏秘密信息。这可能是一个适合孩子们的好活动。
-
我们的文本实验忽略了编码单词之间的空格。请修正这个疏漏。
-
你能想到一个简单的句子结构,拥有多种词汇选择,并利用它来编码类似于steg_text.py的文本消息吗?使用大量形容词、副词、名词和动词生成随机句子,使得输出语法正确。
-
在隐藏数据到音频文件时,我们使用了通道 0。使用两个立体声通道可以加倍能够编码的数据量。
-
模拟在随机字节文件中隐藏二进制数据,但将其替换为白噪声音频文件(随机样本)。这消除了仅改变每个样本最低位的限制。你能使用两个通道编码多少数据,直到声音发生变化?随机字节文件本身听起来像什么?声音可能是检测数据随机性的好方法吗?
-
有损图像压缩破坏了我们通过改变字节值来编码信息的简单方法。我们如何增加冗余,使信息即使经过 JPEG 压缩后仍能保持?
-
当图像被调整大小时,图像编码会发生什么变化?你能想到使用最近邻插值保留信息的办法吗?最近邻插值在放大图像时复制现有像素,在缩小图像时丢弃像素,也就是说,它不进行插值。
告诉我你的想法和进展如何。
总结
本章探讨了随机性和隐写术,即信息的隐藏。我们首先定义了这些术语,然后回顾了一些历史上的应用。仅这一部分内容就足以成为一本单独的书,但它为实验做了铺垫。
首先,我们通过随机选择离散单词列表中的字母,将文本隐藏在其他文本中。伪随机生成器在种子、解码隐藏消息所需的秘密密钥方面 proved to be helpful.
接下来的实验使用了一组随机字节的文件。隐藏的数据被散布在随机数据中,逐位分布,同样使用伪随机生成器种子作为秘密密钥。我们发现,随机数据和隐藏数据的大小比例大约为 100:1 时,通常可以保留足够的随机性,以正确隐藏我们的信息。
然后,我们将隐藏文件的位分散到一组 WAV 音频样本的第 0 位中;这导致了一个输出音频文件,听起来并没有任何改变的迹象。然而,对 WAV 文件中第 0 位分布的统计测试揭示了篡改的迹象,尽管提取隐藏数据仍然极其困难。
最后,我们使用参考图像中的颜色信息来嵌入文件。在这个实验中,不需要秘密密钥;参考文件本身就是密钥。每次编码都会生成一组不同的修改后的图像字节,代价是需要整个参考图像作为密钥。我们了解了计算机如何存储和处理彩色图像,以及如何在 NumPy 中操作图像数据。我们将在第五章中再次使用这些知识。
想象一下隐写术的非法用途并不困难。我在这里想起了本·帕克的智慧:能力越大,责任越大。如果你打算将隐写术用于除了简单的“我想知道它是否有效”的实验之外的其他目的,请仔细考虑,放下这本书,喝杯咖啡,散步,忘掉它。
下一章将引导我们进入一个广阔的、充满随机性的模拟世界。我觉得会很有趣。希望你也同意。
阅读poem.txt。它是一个彩蛋。
第三章:模拟现实世界**

计算机模拟 是使用随机性来模拟现实世界事件和过程的程序。更具体地说,计算机模拟操控模型,即现实世界的编程替代物。
本章将从定义模型是什么开始。接着,我们将通过两个简单的模拟示例来初步入门:通过投掷飞镖来估算π,以及将人们聚集在一个房间里,估算至少有两人共享生日的概率。一旦完成这些示例,我们将进一步深入,通过模拟探索达尔文进化论,捕捉自然选择和基因漂变的本质特征。
模型简介
我们可以用许多方式定义模型,但我喜欢丹尼尔·L·哈特尔在《群体遗传学与基因组学导论》(牛津大学出版社,2020)中的定义:
模型是对复杂情境的有意简化,旨在消除不必要的细节,以便集中于本质部分。
把模型看作是我们希望探索或表征的事物的近似。对于那个事物是什么以及我们如何建模它,并没有具体的要求。
在这一章中,模型是一个代码片段,它试图捕捉现实世界过程的本质特征,例如,当越来越多的人聚集在一个房间里时,分享生日的概率如何变化,或者自然选择和基因漂变如何影响种群的基因组。模拟让我们控制实验世界,同时允许随机行为,理解已经发生的或可能发生的事情,特别是当关键参数(环境因素)变化时。
请考虑以下陈述,这句话归功于英国统计学家乔治·博克斯:
所有的模型都是错误的,但有些是有用的。
除非特别琐碎,否则所有模型在某种程度上都是错误的,特别是那些关于现实世界的模型。如果模型设计得好并且实施得当,它可能会得出关于被建模过程的有价值的结论。过程一词意味着一系列事件,即时间。许多模型模拟的是随着时间展开的过程;例如,我们将探讨作用于种群层面的基本进化过程。
一个好的模型能够捕捉到足够的被建模对象的特征,以得出值得信赖的结论,同时考虑现实的因素。对模型输出的盲目信任并不推荐。充其量,模型属于“信任,但验证”这一类别——这是所有科学主张的一个良好经验法则。
让我们通过投掷飞镖来估算π,逐步进入模拟的世界。
估算π
我们将通过向飞镖靶投掷飞镖来生成π的估算值,π是圆的周长与直径的比率。在现实生活中进行这项活动会非常耗时,因此我们将模拟这个过程;也就是说,我们将创建一个模型。
使用飞镖靶
首先,让我们了解一下向靶子投掷飞镖是如何告诉我们π值的。为此,我们需要一个图示(图 3-1)。

图 3-1:模拟飞镖投掷
图 3-1 显示了一个内有圆形的正方形。圆的直径没有明确标出,但我们假设它是 2,即半径是 1。直径也与正方形的边长相同;因此,正方形和圆的面积是

含义:

我们通过将圆的面积除以正方形的面积,并乘以 4 来计算π。
如果我们投掷许多飞镖,或选择许多随机点,它们最终会覆盖圆和正方形的区域。我们可以利用落在每个形状内部的飞镖数量作为面积的代理。我们现在有了一个算法:投掷飞镖并计算落在圆内(N)和落在正方形内(M)的数量,然后将N除以M并乘以 4,从而估算出π。
上一个图中的示例点都位于第一象限,这对我们的估算非常有效,因为正方形和圆的面积比例与它们在第一象限的部分比例相同。具体而言,第一象限是完整形状大小的 1/4,因此面积需要除以 4。但圆和正方形的面积都被除以 4,这意味着它们的比例保持不变,π/4。这意味着我们只需要投掷落在第一象限的飞镖。
现在我们了解了如何通过投掷飞镖来估算各自的面积和π,那么我们应该如何实际“投掷”它们呢?答案就在前面关于第一象限的评论中。
这是算法:
-
随机选择两个[0, 1)之间的数字,称它们为x和y。这将成为飞镖落点(x, y)。
-
增加M,即正方形内点数的计数器。
-
如果x² + y² ≤ 1,则增加N,即圆内的点数。
-
对所有期望的飞镖,重复步骤 1 到 3。
-
返回(4N)/M作为π的估算值。
我们选择[0, 1)中的点,这样所有点都位于第一象限并落在正方形内。因此,如果我们投掷n个飞镖,M = n,我们只需要判断这些点是否也在圆内。
在第 2 步中,我们提出一个关于圆的问题,x² + y² ≤ 1 源自勾股定理:a² + b² = c²,其中c是直角三角形的斜边。这里的三角形边是x和y,意味着半径(r = 1)是斜边,x² + y²。任何形成斜边小于r = 1 的点都在圆内。
在我们继续之前,我们应该问一下,这是否是一个公正的飞镖投掷过程模型,以及我们是否做了任何不公平的假设。毕竟,一个模型的目的是模拟过程中的最重要的部分。我们使用两个均匀选取的随机数,位于[0, 1)之间,来表示飞镖可能落的位置,我们只做了一个假设:所有飞镖都落在第一象限。将随机值限制在[0, 1)区间内可以排除超出范围的飞镖,因此我们将每次投掷飞镖都视为至少落在正方形内。
回答了这些问题后,我们准备好进行测试了。
模拟随机飞镖
我们想要的代码在sim_pi.py中。要运行它,提供模拟的飞镖数量和所需的随机性来源。例如:
> python3 sim_pi.py 10000 pcg64
pi = 3.16120000
这次使用 PCG64 投掷了 10,000 次飞镖。结果是π ≈ 3.1612。四位小数的正确值是 3.1416,所以我们接近了正确值。这个估计只使用了四位小数,因为我们用一个分母为 10,000 的分数来逼近π。再进行十次运行得到:
3.1496,3.1188,3.1468,3.1700,3.1292,3.1372,3.0916,3.1608,3.1236,3.1140
合并所有 11 次运行结果得到π ≈ 3.1366,和四位小数的正确值相比偏差约为 0.16%。
让我们增加掷飞镖的次数:
> python3 sim_pi.py 1_000_000 pcg64
pi = 3.14157600
结果差不多了。六位小数的正确值是 3.141593。
让我们孤注一掷——这样应该能搞定:
> python3 sim_pi.py 100_000_000 pcg64
pi = 3.14180732
奇怪。我们投掷了前一次的 100 倍飞镖,但结果却不如前一次好。我们的方法没有问题;这就是随机数的特性。第二次使用 PCG64 和 1 亿次飞镖的运行结果是 3.14160636,比之前好一些。不过,这也引出了一个问题:为什么会有如此大的变化?这就是随机生成器的特点,也提醒我们要多次重复模拟,以确认它们产生合理的输出并获得多个估计值。
使用RE类支持的其他随机性来源进行的单独运行结果是:
> python3 sim_pi.py 100_000_000 mt19937
pi = 3.14151340
> python3 sim_pi.py 100_000_000 urandom
pi = 3.14148696
> python3 sim_pi.py 100_000_000 rdrand
pi = 3.14139680
> python3 sim_pi.py 100_000_000 minstd
pi = 3.14156084
> python3 sim_pi.py 100_000_000 RandomDotOrg.bin
pi = 3.14161204
最后一轮使用的是RandomDotOrg.bin,一个来自random.org的 510MB 随机数据文件。所有的随机性来源都产生了合理的π估计值,但仍然不令人满意。为什么它们没有更接近实际值 3.14159265…?
理解 RE 类的输出
让我们重新考虑我们希望随机投掷飞镖来模拟什么。我们希望比较区域,因此我们希望飞镖尽可能均匀地覆盖这些区域。
图 3-2,它是图 1-5 的重复,展示了发生了什么。图中的中间图表展示了使用随机生成器时点的位置。存在间隙和点集中的地方。尽管覆盖了区域,但并不是均匀密集的。

图 3-2:差的准随机序列(左),伪随机序列(中),和好的准随机序列(右)
图 3-2 中的右侧图,来自一对准随机序列,在区域上分布更为均匀。我们来尝试使用这个序列。我们需要的代码在 sim_pi_quasi.py 中:
> python3 sim_pi_quasi.py 10000 2 3
pi = 3.14480000
> python3 sim_pi_quasi.py 100000 2 3
pi = 3.14208000
> python3 sim_pi_quasi.py 1_000_000 2 3
pi = 3.14157200
第一个参数是投掷的飞镖数;另外两个参数是准随机序列的基数。为了覆盖 2D 平面,我们需要两个不同的基数,这里是 2 和 3。当飞镖数增加时,估计的质量也会提高。在使用 100 万个飞镖时,它已经与 PCG64 的 sim_pi.py 的第一次运行匹配。这里没有随机性;每次使用相同数量的飞镖和相同的基数运行都会得到相同的输出。此外,因为我们在纯 Python 中生成准随机序列,飞镖数量增加时,运行时间会显著增加。例如,这次运行
> python3 sim_pi_quasi.py 10_000_000 2 3
pi = 3.14159680
精确到五位小数,但在我的 Intel i7 参考系统上运行了 12 分钟。要求 1 亿个样本时,经过两个半小时的运行后,π ≈ 3.14159184。
让我们暂时关注每个伪随机生成器的性能,这些生成器由 RE 类支持。文件 sim_pi_test.py 使用 200 万次模拟飞镖投掷,分别为 PCG64、MT19937、MINSTD、urandom 和 RDRAND 估算 π 50 次。结果就是 图 3-3 中的箱线图。

图 3-3:箱线图展示了不同随机源的 π 估计分布
箱线图 是一种总结数据集的图示;在这种情况下,是 50 个 π 的估计值,也就是每个伪随机生成器的 sim_pi.py 文件的 50 次独立运行。每个生成器的输出会生成一个带有横向条形的箱子。这个条形代表中位数值,或第 50 百分位数。中位数以下的一半估计值与中位数以上的一半估计值相等。箱子的上下限分别是第 25 百分位数和第 75 百分位数。因此,75% 的估计值位于箱子的上限以下,剩下的 25% 位于其上。
胡须线,在 Matplotlib 中被称为飞行器,超出了盒子的范围。盒子的高度,即第 75 百分位数与第 25 百分位数之间的差,称为 四分位距(IQR)。胡须线是盒子四分位数加上或减去 1.5 倍的 IQR。任何超出胡须线的数据值都是 离群值 的候选者,这些值与其余数据相比,具有典型的异常性。离群值可能是错误,也可能是我们在寻找的有趣现象;上下文才是关键。
图 3-3 中的五个箱子在统计上是相同的。对于 MINSTD,有两个潜在的离群值,但另一次运行 sim_pi_test.py 会生成一个新的图表,显示不同的箱子和潜在离群值,甚至来自 RDRAND,这是我们可以获得的最接近真实随机源的东西。随机过程有时会产生奇怪的输出;这没有任何意义。这个现象部分解释了为什么检测真实的癌症聚集区可能很棘手。
实现飞镖模型
我们的掷镖模拟已经完成。现在,让我们回顾一下代码,了解其工作原理:
import sys
from RE import *
def Simulate(N, rng):
v = rng.random(2*N)
x = v[::2]
y = v[1::2]
d = x*x + y*y
inside = len(np.where(d <= 1.0)[0])
return 4.0*inside/N
N = int(sys.argv[1])
kind = sys.argv[2]
rng = RE(kind=kind)
pi = Simulate(N, rng)
print("pi = %0.8f" % pi)
底部的代码解析命令行,以获取投掷的镖数(N)和要使用的随机源类型(kind)。创建一个生成器(rng),并将其与镖数一起传递给Simulate,该函数返回一个π的估算值,然后将其打印出来。
所有的操作都在Simulate中。我们需要N个点,即我们投掷的镖落地点。我们可以选择使用rng两次——第一次获取x坐标,再次获取y坐标——或者生成两倍于所需点数的点,然后将它们成对划分。我选择了后者。因此,v包含 2N个值。第一个点以及随后的每个点成为x,而第二个点以及随后的每个点成为y。
根据设计,所有点都在正方形内部。我们只需决定哪些点也在圆内。为此,我们需要知道是否满足x² + y² ≤ r²,其中半径r = 1。为此,我们将d设置为x² + y²,并使用 NumPy 的where来查找小于或等于 1 的索引。这些索引的计数告诉我们有多少点位于圆内。最后,函数返回π的估算值,即圆内的点数除以投掷的镖数,再乘以四。
我们的掷镖模拟已经完成。现在,让我们模拟一个聚会,看看需要多少人才能使至少有两个人共享生日的概率超过 50%。
生日悖论
需要多少人参加聚会,才能使至少有两个人共享生日的概率超过 50%?有一种数学方法可以计算这个概率,但如果我们不懂数学,我们可以通过大量实验来找到答案:我们可以举行许多聚会,邀请不同人数的人,在每次聚会上判断是否有至少两个人共享生日。虽然这种方法可行,但它会非常缓慢且昂贵。
模拟 100,000 场聚会
假设我们不愿意写一份价值百万美元的资助提案来用实际的人进行这个实验,更不用说获得审查委员会的批准和成千上万人的知情同意,那么还有其他方法可以解决这个问题吗?你猜对了:模拟。
每个人都有一个生日,因此我们将模拟房间里的人数,并为每个人随机分配一个生日。然后,我们将检查每一对可能的组合,看看他们是否有相同的生日。一年有 365 天(忽略闰年),所以我们将通过选择一年中的某一天作为实际生日的代表来表示生日。换句话说,每个模拟的人都会被分配一个在[0, 364]范围内的整数。如果两个人的数字相同,则他们共享生日。
我们想要的是给定人数下的匹配概率,这意味着一次模拟是不够的。我们需要很多次模拟,针对固定人数的房间。匹配次数与模拟次数之比趋近于我们所寻求的概率。
这是我们的算法:
-
固定房间中的人数(K)。
-
给每个K个人分配一个生日([0, 364])。
-
检查每一对可能的组合。如果他们的生日相同,增加M的值。
-
从步骤 2 开始,重复N次。
-
对于房间中K个人,估算的概率是M/N。
我们将K的值从 2 变化到 50。N应该是一个大数,比如N = 100,000,用于模拟 100,000 个有K个人的派对。我们总是可以将N增大并重新尝试,因为变化模拟参数并观察结果是模拟的一个重要部分。如果我们在稍微改变时遇到问题,可能是代码有 bug,或者更糟的是,设计中存在逻辑缺陷。
测试生日模型
我们需要的代码在birthday.py中。让我们先运行它,了解输出结果,然后再逐步分析它。例如,以下是运行一个询问 11 人房间内至少有一对生日相同的概率时的输出:
> python3 birthday.py 11 minstd
11 people in the room, probability of at least 1 match = 0.140430
我们知道,房间里 11 个人至少有一对生日相同的概率大约是 14%。第二个参数是要使用的随机源,这里使用的是 MINSTD。可以尝试其他随机源。
如果我们添加第三个参数,我们可以存储输出并将其导入 Python,以便理解其含义:
> python3 birthday.py 11 minstd 11.npy
11 people in the room, probability of at least 1 match = 0.142610
> python3
>>> import numpy as np
>>> d = np.load("11.npy")
>>> d
array([85739, 13462, 663, 125, 10, 0, 0, 1])
第一行代码再次运行。注意概率发生了轻微变化;随机选择生日会产生不同的结果,这些结果最终会在多次模拟后趋于均值。稍后我们会对此进行实验。
接下来,我运行了 Python(并忽略了启动消息),然后导入了 NumPy 和输出文件11.npy。d数组包含了 100,000 次模拟中找到相同生日的人数的直方图,其中d的索引表示数字。表 3-1 显示了匹配次数以及它们出现的频率。
表 3-1: 匹配次数与出现频率
| 匹配 | 次数 | 百分比 |
|---|---|---|
| 0 | 85,739 | 85.739 |
| 1 | 13,462 | 13.462 |
| 2 | 663 | 0.663 |
| 3 | 125 | 0.125 |
| 4 | 10 | 0.010 |
| 5 | 0 | 0.000 |
| 6 | 0 | 0.000 |
| 7 | 1 | 0.001 |
在 85.7%的情况下,当房间里有 11 个人时,没人有相同的生日。同样,在 13.5%的情况下,出现了一对生日相同的人。最后,在 100,000 次运行中,有一次出现了七对生日相同的人。这就是随机性的本质:有时会发生一些令人惊讶的事情。
接下来,我运行了五次birthday.py,每次使用RE内置的随机数源,并且每次房间里都有 23 个人。返回的平均概率是 0.507478,即 50.7%。这是第一个返回大于 50%概率的人员数量;因此,为了回答本节开头的问题,我们平均需要 23 个人在一个房间里,才能有超过 50%的机会至少有两个人共享生日。
让我们试着可视化这里发生的事情(图 3-4)。

图 3-4:至少一个匹配的概率与房间内人数的关系(左)以及按房间内人数统计的匹配直方图(右)
图 3-4 的左侧展示了至少一个匹配的概率与房间内人数的关系。垂直线表示 23 个人,虚线水平线表示 50%的概率。正如所述,23 个人是超过 50%概率的最小人数。
图 3-4 的右侧展示了三个直方图,显示返回特定匹配次数的运行比例。条形图有偏移,以防止重叠,但最左边的条形图代表实际的匹配次数。当房间里只有 10 个人时,没有匹配的概率很高,而多个匹配的概率几乎为零。对于 23 个人,出现一个匹配的情况相对常见,两个匹配较少,三个匹配大约发生 3%的时间。对于 40 个人,已经过了 23 人的临界点,因此出现多个匹配的概率大于没有匹配的概率。
实现生日模型
让我们通过birthday.py来走一遍,见清单 3-1。
import sys
import numpy as np
from RE import *
def Simulate(rng, M):
matches = []
for n in range(100_000):
match = 0
bdays = rng.random(M)
for i in range(M-1):
for j in range(i+1,M):
if (bdays[i] == bdays[j]):
match += 1
matches.append(match)
matches = np.array(matches)
return np.bincount(matches)
people = int(sys.argv[1])
rng = RE(kind=sys.argv[2], low=0, high=365, mode="int")
matches = Simulate(rng, people)
prob = matches[1:].sum() / matches.sum()
print("%d people in the room, probability of at least 1 match = %0.6f" % (people, prob))
if (len(sys.argv) == 4):
np.save(sys.argv[3], matches)
清单 3-1:模拟检查房间内多个人的生日
与sim_pi.py一样,所有的操作都在Simulate中。清单 3-1 底部的代码解析命令行,以获取房间里的人数和随机数源,随机数源可以是RE支持的任何一个,或者是一个文件名,并且如果指定了,还会获取输出文件的名称(一个 NumPy 数组)。请注意,rng配置为返回[0, 365]区间的整数。
随机数源(rng)和房间内的人数被传递给Simulate。返回值是一个直方图,表示在固定的 100,000 次模拟中发生特定次数匹配的频率(matches)。matches的第一个元素是没有匹配的次数,因此,所有剩余元素的总和除以所有元素的总和就是一个或多个匹配的概率(prob)。然后,代码会显示该概率,并在请求时将直方图写入磁盘。
在Simulate中,M是房间内的人数,在所有 100,000 次模拟中保持固定。Matches将保存每次模拟的结果,即找到的匹配次数。
第一个for循环涵盖了模拟过程。对于每次模拟,随机选择一组生日(bdays),每个人(共M人)都有一个生日。然后,i和j的双重循环比较第i个人的生日与所有其他人的生日,统计每次的match。i和j的循环限制避免了重复计数;如果第i个人的生日与第j个人的生日匹配,那么第j个人的生日也会与第i个人的匹配,而这个已经被计数过了。所有人的生日都比较完毕后,match中的计数会被追加到matches中。
最后,在所有 100,000 次模拟结束后,matches列表会转化为一个 NumPy 数组,并传递给np.bincount来统计每个匹配次数的出现频率。
birthday.py是一个公平的模拟吗?它是否做了我们所期望的事?正如 Hartl 所说,它是否“消除了多余的细节,集中精力关注本质”?这里的核心任务是,在人数固定的情况下,公平地选择生日。我们假设生日在一年中的分布是均匀的——这是一个合理的假设。
到目前为止我们讨论的模拟是热身。让我们进一步提升难度,探索世界上最重要的过程——至少对地球上无数生物来说——进化。
模拟进化
生物的进化是一个复杂的过程,受到遗传和环境因素的影响。在这一部分,我们将探索两个因素:自然选择和基因漂变。
自然选择,由达尔文在 19 世纪描述,通常被称为“适者生存”。它认为,在某个环境中,基因型(遗传代码)能够提高生存和繁殖可能性的生物,更有可能将它们的基因传递给后代。通过这种方式,随着时间的推移,生物的特征发生改变,通常最终导致物种之间无法相互繁殖——也就是说,产生了新物种。
虽然自然选择与提高生存和繁殖的可能性有关,基因漂变是由环境变化引起的效应,环境变化使得一小部分生物种群与更大的种群隔离。在基因漂变中,隔离期间存在的亚种群会有不同的基因组合,这可能导致整体基因库的迅速变化,通常会导致新物种的出现。
我们希望模拟自然选择和基因漂变的核心要素。我们先从自然选择开始;一旦模拟了自然选择,模拟基因漂变将变得更加清晰。
自然选择
以下是模拟自然选择的要求:
-
我们需要一群生物,每个生物由一组基因组成。一个生物的基因决定了它对环境的适应能力。
-
我们需要一个环境,并以某种方式描述它,说明生物体如何适应这个环境。此外,我们还需要一个衡量该环境适应度的标准。
-
我们需要模拟自然选择的两个最重要工具:生物体之间的繁殖(交叉)和随机变异。这个模拟必须受到生物体与环境适应度水平的影响。
-
我们需要从一代跨越到另一代,这样我们才能随着时间的推移监控种群。
-
最后,我们需要轻松地可视化种群在代际演化中的特征。
让我们逐一处理这些声明。
生物体
我们的生物体在其基因组中有六个基因,每个基因有 16 种可能的变种或等位基因。因此,生物体是一个六维向量,每个元素的值都在[0, 15]之间。这些数字在我们继续讲解时将变得更加明确。
环境
我们将通过一组基因来定义我们的环境,这些基因对应于“理想”生物体,即最适应环境的生物体。在自然界中,大多数生物体都非常适应自己的环境;如果不适应,它们会迅速灭绝。然而,基于模拟的精神,我们将选择一组基因作为“最佳”基因,并用它们作为环境的代理。
我们将使用环境基因向量与生物体基因向量之间的距离作为衡量生物体适应度的标准。这个距离越小,生物体越适应环境。虽然在讨论向量(点)时有许多可能的“距离”定义,我们将使用欧几里得距离:两个点之间的直线距离。我们将假设每个基因向量是六维空间中某个点的坐标。
如果环境的基因向量是e = (e[0], e[1], e[2], e[3], e[4], e[5]),生物体的基因向量是x = (x[0], x[1], x[2], x[3], x[4], x[5]),那么它们之间的欧几里得距离是:

换句话说,它是各坐标差异的平方和的平方根。这里,每个坐标是[0, 15]之间的整数,用来表示该基因的选定等位基因。此外,我们将定义一个最小距离,解释为“足够好”。
交叉与变异
有性繁殖是混合基因和创造基因库多样性的绝妙方法。我们的生物体将通过交叉繁殖,选择一个基因组位置,并复制第一个亲本所有基因,直到该位置,然后复制第二个亲本剩余的所有基因。新的组合将成为后代的遗传密码。最后,我们将通过随机选择一个基因并随机改变其值来应用随机变异。图 3-5 展示了交叉与变异的过程。

图 3-5:交叉与变异生成新后代生物体(青蛙图片来源于公共领域,感谢维基共享资源提供)
我们的生物体不是青蛙,但你大概能理解。两个生物体通过第一个生物体的前两个基因和第二个生物体的最后四个基因来创造后代。然后,变异将改变其中一个基因,从 10 变为 2。
为了模拟适应度对繁殖的影响,我们将偏向选择那些适应度较低的生物体,使其更有可能繁殖。我们将使用一个贝塔分布来实现这一点,NumPy 中已包含该分布。贝塔分布通过两个参数影响样本的整体直方图形状。如果两个参数a和b都等于 1,贝塔分布将模仿均匀分布。如果稍微增加b参数,分布会被修改,从而使得选择接近零的值的概率更高。
因此,在培育下一代时,我们将选择与零较为接近的个体作为种群成员。我们将按适应度对种群进行排序,适应度较高的生物体排在二维数组的前面,其中每一行代表一个生物体,每一列代表一个基因。
最终的结果是,适应度较高的生物体更可能繁殖。因此,随着代际的变化,我们预期整个种群会逐步接近环境的理想适应度。
代际种群
我之前提到过将种群保持在二维数组中。我们将把种群规模固定为 384 个生物体;原因稍后会显现出来。因此,生物体种群变成了一个 384 行 6 列的二维数组。每一代将繁殖出另外 384 个生物体。换句话说,我们的生物体是季节性的;它们经历一个季节(时间步),并在繁殖下一代后死亡。种群遗传学家通常使用这样的离散模型。
因此,模拟实现每个步骤如下:
-
随机选择初始种群。
-
随机选择一个环境。
-
对于N代,计算每个生物体的适应度,按适应度排序种群,并通过交叉和变异繁殖每个种群成员。
-
基于种群的序列创建输出。
可视化
每一代产生一个 384 个生物体的种群,每个生物体有 6 个基因,由 16 种等位基因中的一种表示。现在我们将了解为什么种群总是 384 个生物体,且每个生物体有 6 个基因。我们希望输出的图像是每一行显示种群的图像,每个像素代表一个生物体,同时显示环境。因此,输出图像将有 384 列,并加上额外的列来展示环境。每个像素的颜色来自相应生物体的基因编码,基因映射到 24 位 RGB 颜色值的 4 位中,如图 3-6 所示。

图 3-6:基因映射到 RGB 颜色
在图像中,基因向量变成了森林绿色的像素。随着代际的演化,我们预计种群会向环境的颜色靠近。当然,随机突变也会在其中起作用,适应度也会影响结果;我们将会对这两者进行实验。
我们从静态环境开始。
静态世界
我们将在实验后深入研究代码。要在静态环境下运行实验,使用darwin_static.py:
> python3 darwin_static.py 500 60 0.01 4 minstd darwin_static.png 73939133
有几个参数,在我们的大多数实验中都很常见:
500 代数(行数)
60 适应度偏差,[0, 1000]
0.01 突变概率
4 “足够好”阈值
minstd 生成器名称或文件名
darwin_static.png 输出图像文件名
73939133 种子值(可选)
当仿真运行时,你会看到每代的平均适应度。随着代际的演变,适应度逐渐降低,直到它接近“足够好”值。仿真结束后,请查看darwin_static.png。需要颜色,但图像开始时类似于图 3-7。

图 3-7:可视化静态世界
即使指定了种子,运行之间仍会有差异,因为我们使用的是 NumPy 的贝塔分布函数,而它并不考虑我们的种子值。
从上到下读取图像。最上面一行是初始的、随机生成的 384 个生物的种群。每一行之后是从上一代繁殖出来的下一代,每次都会根据适应度进行排序,因此适应度更高的生物会靠近左边。最左边的条纹是环境,代表着理想基因组的颜色。
随着你逐行查看图像,种群变得更像理想环境。然而,它从未完全崩溃并精确匹配环境。三个命令行参数会影响种群与环境匹配的速度和一致性:适应度偏差、突变概率和“足够好”的阈值。我们来逐一理解每个参数。
适应度偏差
适应度偏差是一个范围在 0 到 1,000 之间的整数。如我们在代码中所见,这个值会被 1,000 除后加到第二个贝塔分布参数上。其目的是增加适应环境更好的基因组的生物的适应度。如果适应度偏差为 0,那么适应环境更好的生物不会获得生殖上的任何好处。随着偏差的增加,生殖上的好处也会增加,从而使得种群更快速地接近环境的理想状态。
作为例子,再次运行darwin_static.py,只改变适应度偏差从 60 改为 600。种群应该在几代内接近环境的理想状态。将适应度偏差改为 0 并重新运行。现在你注意到什么了吗?种群无法改善,因为适应度偏差为 0 意味着没有基于基因组的繁殖优势。如果将适应度偏差设为 15,你可能需要大约 1500 代,但最终你应该会看到种群适应环境。即使是微小的繁殖优势,在长期中也能产生影响。
变异概率
现在,将适应度偏差设为 60,并调整变异率,以概率的形式表示。例如,变异率为 0.01 时,每个新繁殖出来的个体有 1%的几率发生随机变异。1%的变异率相比于真实动物来说异常高,但我们需要在没有数百万代的情况下看到我们想要的效果。
将darwin_static.py的变异率改为 0;这意味着每一代将仅通过交叉遗传来产生。运行几次并观察输出。你注意到什么了吗?种群的适应度应该会停留在 4(距离理想基因组 4 个单位的地方),并且会无限期地保持在那里。因为基因组已经“理想”,所以无法再发生其他变化;不论交叉点在哪里,选出任意两个个体进行交叉,它们的后代基因组仍然与父母完全相同。
让我们看看种群对变异的敏感性。将变异率从 0.01 调整为 0.1(10%),并再运行几次。注意到种群适应了环境,但从未完全适应。实际上,当你向下查看输出图像的行时,你可能会看到一些区域,其中许多种群成员已经适应,但随后出现了新的突变,迅速改变了平衡,导致种群在随后的几代中再次适应。
我的实验中使用了 0.1 的变异率,最终种群的平均适应度通常在 7.5 到 8.5 之间,远高于没有变异时的 4。如果将变异率改为 0.2 甚至 0.8,种群将更难适应环境,因为变异不断将种群推离理想状态。如果将变异率调低,比如设为 0.005,种群适应得很好,但随着时间推移(即输出图像的行数增加),你会看到小群体的突变体出现,然后适应,再次出现并发生新的突变。在输出图像中,这些群体表现为图像右侧的一片片颜色——适应性最差的生物,它们繁殖的概率最低。
“足够好”阈值
最终的命令行参数是神秘的“足够好”值,它表示环境理想基因组与有机体基因组之间的最小距离。在计算种群的适应度时,任何小于该值的距离都将被设定为该值。通过在保持适应度偏差和突变率固定(例如,分别为 60 和 0.01)的情况下,改变“足够好”值进行实验。 “足够好”值越高,种群对环境的适应就越粗糙。将其设置为 0 时,种群将快速崩溃到理想状态(如果适应度偏差较大);如果突变率为 0,种群将停留在那个状态。
我建议在你对如何调整适应度偏差、突变率和“足够好”值来影响结果有了直观理解之后,再尝试darwin_static.py进行实验。在提前预测你期望在输出图像中看到什么。当使用模拟时,至关重要的一点是要改变参数,特别是要将它们推到极限。这不仅有助于理解模拟试图捕捉的过程,还能作为对模拟本身的理性检查,可能揭示出一些弱点或错误,使得结果的有效性受到影响。
在现实世界中,环境并不是静态的,至少对于进化通常起作用的时间范围而言是如此(尽管快速进化是可能的)。让我们向模拟中加入一个新特性,使环境随着时间慢慢变化,以理解这种变化如何影响种群。
逐渐变化的世界
darwin_slow.py中的代码几乎与上一节的代码完全相同,但它引入了一个新特性:环境将在命令行指定的间隔内发生轻微变化。例如:
> python3 darwin_slow.py 500 60 0.01 4 0.01 mt19937 darwin_slow.png 66
新的参数是伪随机生成器(mt19937)前的 0.01。它表示每次生成时以 1%的概率略微修改环境。darwin_slow.py示例会生成一张输出图像,其中环境变化了四次。输出图像与静态情况相似,但每次环境过渡都在左侧用黑线标出。例如,前两次过渡如图 3-8 所示。

图 3-8:环境的变化
细节仅在全彩图像中可见;请参阅书籍的 GitHub 仓库中的darwin_slow.png。当第一次过渡发生时,初始的随机种群正在适应环境。然后,当环境再次变化时,种群会迅速适应新的环境。
如果你查看完整的darwin_slow.png图像——最好使用可以全分辨率水平查看的图像查看工具——你会注意到,在第二次环境变化后,种群适应得相当好,但这需要几个代数的时间。视觉效果是颜色被涂抹到右边,那里的有机体适应性较差。我建议多次运行模拟,而不是使用固定的 66 种子,以观察不同颜色下的整体效果。然后,探索在平滑的环境变化发生时,适应性偏差和突变率的调整会如何影响结果。为了帮助你入门,观察在表 3-2 列出的参数下多次运行时的变化。
表 3-2: 尝试的参数
| 适应性偏差 | 突变率 | 环境概率 |
|---|---|---|
| 600 | 0.01 | 0.300 |
| 60 | 0.01 | 0.300 |
| 1 | 0.01 | 0.005 |
前两个参数集展示了一个快速变化的环境的效果,其中适应性强的有机体拥有较强的优势,而适应性较弱的则有较弱的优势。最后一组参数使用了最弱的生殖优势,但它与几乎静态的环境相结合。
缓慢变化的环境给有机体足够的时间来适应。然而,在地球漫长的历史中,并非所有的环境变化都是缓慢的;有些变化非常突然,甚至可能在一夜之间发生。让我们模拟一场灾难。
灾难性世界
大约 6600 万年前的一天,地球上的生命形式正在各自忙碌时,一颗巨大的小行星粗暴地打断了它们的生活,结果导致了非鸟类恐龙超过 1 亿年的统治结束。对它们来说是坏消息;对我们来说是好消息。一场灾难发生了,生命做出了回应,并在撞击之后表现出截然不同的样貌。大约 2.52 亿年前的“大灭绝”事件中,生命几乎灭绝了。同样的事情发生了,灭绝后的世界与灭绝前的世界看起来截然不同。
我们已经模拟了渐进的环境变化;现在,让我们给模拟一个猛击,看看会发生什么。我们需要darwin_catastrophic.py。试试看:
> python3 darwin_catastrophic.py 500 60 0.01 4 0.01 pcg64 darwin_catastrophic.png 12345
这些参数与darwin_slow.py中的参数相同,不同之处在于每当环境变化时,它是通过选择一个全新的理想环境基因组来实现的。过渡变化十分明显。生成的图像与darwin_slow.py中的图像相似,但没有水平黑线标示过渡。在这种情况下,过渡变化通常非常明显。要理解我的意思,可以运行代码,或者查看本书 GitHub 页面上的darwin_catastrophic.png。
尝试调整模拟参数,探索结果。例如,将代数更改为 2000 代,通过缩小查看完整的输出图像。可以清楚地看到,种群对每次环境灾难的延迟反应。
我们的最终模拟引入了基因漂变。那么,突然分裂的种群在适应新环境时表现如何呢?
基因漂变
种群瓶颈发生在种群经历了突然的数量减少时。一种种群瓶颈是创始人效应,它发生在一个小种群从一个较大的种群中分裂出来时。新较小种群中等位基因的随机组合可能会极大地影响有机体的长期生存和进化。darwin_drift.py 中的代码模拟了由于创立一个新的较小种群而导致的基因漂变。
这段代码与之前的示例类似,但有所不同。首先,一个较大的种群(384 个有机体)经过多代进化,试图适应环境。然后,整个种群的一定比例“分裂”出来成为新的种群。这两个种群现在分别进化。想象一下,有机体的群落被困在一个岛上,无法再与大陆上的亲戚繁殖。为了增加趣味性,分裂后发生了一次灾难,因此我们可以观察两个种群如何应对环境的突变(适应得好或不好)。
例如:
> python3 darwin_drift.py 500 60 0.01 4 0.2 pcg64 darwin_drift 1337
pcg64 前的 0.2 现在表示将会分裂出来形成新种群的种群比例;也就是说,随机选择 20% 的种群分裂出来形成新的种群,其余 80% 作为较大的种群保留。
与我们的其他模拟不同,darwin_drift.py 期望的是一个基础文件名(darwin_drift),而不是一个完整的文件名。代码输出一张图片,darwin_drift.png,以及一个按代数绘制的平均种群适应度图,darwin_drift_plot.png。与之前一样,使用了 500 代,并且采用了 60 的适应度偏差和 1% 的突变概率。
那么我们的图像是什么样子的呢?再次建议你查看本书 GitHub 仓库中的彩色图像,但部分输出已在图 3-9 中展示。

图 3-9:可视化基因漂变
这幅较大图像的片段显示了分裂后模拟的一部分。较小的种群在左边,较大的种群在右边。同时,如果你仔细观察环境,会发现大约在图像下方的六分之一处发生了突发灾难。两个种群对灾难的反应不同。这一点在彩色图像版本中尤为明显。
在灾难发生之前,两个种群都相对适应它们的环境。然而,较小的种群在灾难之后无法恢复或成功调整到新环境。较大的种群最终适应了新环境。彩色版本的图像清楚地显示,较小的种群有时会产生适应新环境的世代,但这种适应从未持续很久。这个效应反映了现实:小种群通常非常脆弱,容易受到快速环境变化的影响,因为它们缺乏足够的遗传多样性,无法及时适应。
图 3-10 追踪了平均种群适应度随世代变化的情况。

图 3-10:灾难前后每代的平均种群适应度
创始人效应事件发生在第 145 代左右,出现了两条线。较粗的线表示较小的种群。一开始,两个种群都相对适应环境,环境尚未发生变化,尽管可以认为较小的种群在适应度上存在更多变异。
灾难发生在第 318 代左右。紧接着,两个种群的适应度得分显著提高。记住,适应度得分越低越好。随后的几代开始适应新环境,但适应速度不同。较大的种群,仍然是原始种群的 80%,随着时间的推移逐渐适应新环境;然而,较小的种群未能做到这一点,至少在模拟的 500 代内是如此。在大多数darwin_drift.py的运行中,较小的种群未能像较大的种群一样适应新环境。有时,情况正好相反。我们将在讨论中谈论这种效应。
如果种群分裂为一半(0.5)会发生什么?或者,如果新种群只是一个很小的部分,比如 5%或 10%呢?在这种情况下,最简单的实验方法是固定适应度偏差和突变率。然后,固定种群比例并调整这些参数。
在图 3-9 中,当种群分裂时,新生成的较小种群的成员是随机选取的。因此,由于偶然因素,它们通常在基因型的代表性上与较大种群不均衡。这种差异可能意味着不常见的基因型现在有机会变得更加常见。
这个效应在drift.py代码中得到了体现。首先,选择一个 10,000 个数字的“种群”,范围是[0, 9]。然后,通过随机选择较大种群中的 50 个成员,构建 20 个子种群。最后,显示较大种群的平均值以及 20 个子种群的平均值。如果每个子种群的数字组合相同,那么它们的平均值将非常接近。然而,它们并不是:
Population mean = 4.562900
Sub-population means:
3.60, 4.42, 4.52, 4.82, 4.36, 5.40, 4.72, 4.54, 4.24, 4.28,
4.76, 4.66, 4.98, 4.90, 4.50, 4.50, 5.10, 4.44, 4.30, 4.62
子群体的均值范围从 3.60 到 5.40 不等。均匀选择的数字应给出一个 4.5 的总体均值,这接近较大的总体均值。由于随机性,子群体代表了非常不同的数字集合(基因组)。现在我们明白了为什么种群瓶颈会导致基因漂变。
仿真测试
我们使用了四组不同的代码来进行前面的仿真。为了避免详细描述每一组,从而避免造成“死于 PowerPoint”的效果,我们将在这里介绍其中一组,并根据需要展示其他组的代码片段。
请查看列表 3-2,其中包含了darwin_static.py的关键部分。
❶ ngen = int(sys.argv[1])
advantage = int(sys.argv[2])
mutation = float(sys.argv[3])
good = float(sys.argv[4])
kind = sys.argv[5]
oname = sys.argv[6]
if (len(sys.argv) == 8):
seed = int(sys.argv[7])
rng = RE(kind=kind, seed=seed)
else:
rng = RE(kind=kind)
❷ npop = 384
pop = np.zeros((npop, 6))
for i in range(npop):
❸ pop[i,:] = (16*rng.random(6)).astype("uint8")
environment = (16*rng.random(6)).astype("uint8")
hpop = np.zeros((ngen,npop,6))
henv = np.zeros((ngen,6))
❹ for g in range(ngen):
❺ fitness = np.zeros(npop)
for i in range(npop): d = np.sqrt(((pop[i]-environment)**2).sum())
if (d < good):
d = good
fitness[i] = d
❻ idx = np.argsort(fitness)
pop = pop[idx]
fitness = fitness[idx]
❼ hpop[g,:,:] = pop
henv[g,:] = environment
print("%6d: fitness = %0.8f" % (g, fitness.mean()))
❽ nxt = []
for i in range(npop):
nxt.append(Mate(pop,fitness,advantage))
pop = np.array(nxt)
列表 3-2:模拟静态环境
我已排除了与生成输出图像相关的注释和代码。如果你对这些部分的工作原理感兴趣,请查看文件本身。我建议至少阅读MakeRGB,以了解基因到 RGB 颜色值的映射。
代码自然地分为三个部分:解析命令行 ➊、设置仿真 ➋ 和运行仿真 ➍。在第一部分中,随机引擎(rng)被配置为返回 0, 1)范围内的浮点数,且可以选择是否使用种子。这个引擎用于不同的功能,因此最好仅使用基本范围,并根据需要调整边界和数据类型。
然后,初始种群(pop)包含 384 个生物体(npop) ➋。每个生物体都被赋予一个随机生成的基因组 ➌。environment 也类似地被定义。最后两个变量,hpop和henv,分别追踪种群的演化和其他代码变体中的环境。请注意,这里使用的是一个 3D 数组,想象它是由多个 2D 数组组成的,每个数组保存该代的种群。输出图像是通过同时使用hpop和henv生成的。
仿真现在已经准备好,初始种群和环境已经定义。主循环 ➍ 评估每一代的表现。循环体有四个步骤:计算每个生物体的适应度 ➎,按适应度对种群排序 ➏,保存种群副本以生成图像 ➐,最后繁殖下一代 ➑。
让我们逐步了解每个步骤。为了计算适应度,我们将每个生物体的基因组与环境的基因组进行差异计算,之后对所有基因的差值进行平方求和,再应用平方根。这是 NumPy 版本的[公式 3.1。获得适应度后,idx会对种群和适应度向量进行排序,使得适应度更高的生物体靠近种群的前面 ➏。然后,hpop存储排序后的种群和环境以供输出图像生成 ➐。同时,种群的平均适应度也会被打印出来。随着种群的演化,这一平均值应该会减少,具体取决于突变率和适应度偏差。
本代的最后一步是替换它 ➑。重复调用 Mate 函数从现有种群中繁殖出一个新的 npop 生物体群体,详见示例 3-3。
def Mate(pop, fitness, advantage):
a = advantage / 1000
i = int(len(pop)*np.random.beta(1,1+a))
j = i
while (j == i):
j = int(len(pop)*np.random.beta(1,1+a))
c = int(6*rng.random())
org = np.hstack((pop[i][:c], pop[j][c:]))
if (rng.random() < mutation):
c = int(6*rng.random())
org[c] = int(16*rng.random())
return org
示例 3-3:产生下一代
这里,Mate 函数接受当前种群及其相关适应性(均已排序),以及适应性偏置(advantage)。如前所述,适应性偏置被除以 1,000。
该函数需要选择两个不同的生物体,索引 i 和 j,然后通过交叉生成一个新的生物体。调用 NumPy 的 beta 函数返回一个位于 [0, 1) 区间的值,该值在根据种群大小进行缩放后,将返回 [0, 383] 范围内的一个整数。while 循环会持续运行,直到选中一个不同的第二个生物体(j)。
图 3-11 显示了不同适应性偏置值下的贝塔分布。

图 3-11:由适应性偏置值调整后的贝塔分布。如果分布的左侧高于右侧,适应性较强的生物体更可能繁殖后代。
如果偏置为 0,贝塔分布就表现为均匀分布。图中有 100 个柱状图,因此每个柱状图大约会出现 1% 的时间(实线)。适应性偏置较弱(60)时,会偏好较小的值,即较适应的生物体,同时强烈排斥最不适应的个体。类似地,偏置为 900 时,则线性选择最适应到最不适应的生物体。
交叉选择一个基因位置,[0, 5],并通过保留一个父代的前 c 个基因,加入另一个父代的其余基因来构建后代(org)。然后,如果随机值小于全局的 mutation 阈值,则随机选择一个基因赋予一个新值,[0, 15]。最后,该函数返回新生物体的基因组。
总结一下:首先配置,然后在代数中循环,评估当前种群的适应性,再利用这些信息来繁殖下一代。待尘埃落定后,生成输出图像。
文件 darwin_slow.py 通过种群的演化改变环境,几乎与 darwin_static.py 一模一样。这个循环在繁殖下一代后增加了一个额外的代码段:
if (rng.random() < eprob):
offset = 2*rng.random(6)-1
environment = environment + offset
environment = np.maximum(0,np.minimum(15,environment))
environment = (environment + 0.5).astype("uint8")
这里,eprob 是环境变化的概率,从命令行读取。如果环境发生变化,我们会添加一个 offset 向量,逐基因地将理想基因组改变 ±1。将此与 darwin_catastrophic.py 中的灾难性环境变化进行比较:
if (rng.random() < eprob):
environment = (16*rng.random(6)).astype("uint8")
最终的程序 darwin_drift.py 结构上与 darwin_catastrophic.py 类似,但在经历一定代数后,种群会分裂成两部分。分裂之后,环境会发生灾难性的改变。尽管涉及了记账操作,但从概念上讲,并没有发生新的变化。
练习
使用以下练习作为扩展你对模拟强大功能理解的跳板。在进行这些练习时,思考任何脑海中浮现的“假如”问题:
-
修改sim_pi.py,使其分别调用两次
rng,第一次获取x坐标,第二次获取y坐标。你注意到有什么不同吗?你预料到吗? -
修改birthday.py,使N = 1,000,000 或N = 10,000。有什么显著的不同吗?
-
我们假设生日在一年中均匀分布。这在西方国家并不完全成立,至少在这些国家中,9 月的生日更为常见。那么,在这种情况下,随机选取的两人有相同生日的真实概率会发生什么变化?是增加还是减少?你可能希望探讨一下文件birthday_true.py,它使用了来自英国的数据。
-
聚会中至少需要多少人,才有 99%的概率出现至少一个生日重合?
-
我们的进化模拟假设每一代的所有成员都会繁殖并死亡,产生与上一代大小相同的新一代。如果最不适应的 10%死亡且不繁殖,而最适应的 2%繁殖两次,会发生什么?
加分题:
在他 1889 年的书《概率论》中,约瑟夫·伯特朗概述了三种计算随机选取的圆弦长于圆内切正三角形边长的概率的方法。文件bertrand0.py、bertrand1.py和bertrand2.py实现了对应三种方法的模拟:
bertrand0.py 使用圆上两个随机选取的点定义的弦。
bertrand1.py 使用与随机选取的圆半径上某点垂直的弦。
bertrand2.py 使用圆内一个随机选择的点作为弦的中点。
运行三种方法来选择圆的随机弦。例如:
> python3 bertrand0.py 500 b0.png mt19937 359
Probability is approximately 154/500 = 0.3080000
它们生成估算的概率,并附带展示所选弦的图形,如图 3-12 所示。

图 3-12:选择的圆弦,当选择圆周上的点时
每种方法的估算概率是多少?哪一种是正确的?这被称为伯特朗悖论,它提醒我们在定义我们要模拟的内容及其方式时需要小心。检查代码,看看弦是如何被选取的。
总结
在这一章中,我们开始了对模型和模拟的初步探索;在本书中,我们将继续遇到各种模型。
我们从简单的模拟开始,估算通过掷飞镖的方法得到π的值,以及一个房间中平均需要多少人,才能有超过 50%的概率至少两人有相同的生日。然后我们构建了一个模型来模拟生物进化的两个关键方面:自然选择和遗传漂变。我们发现,即使是不完整的模型,也可以成为有用的工具,提供有价值的见解。
我们的模拟捕捉了一些重要进化机制的精髓,比如自然选择和基因漂变,但现实中有一大部分被忽略了:死亡和灭绝。例如,许多小型种群在应该灭绝的时候却继续进化。灭绝是自然的;几乎所有曾经存在的物种都已经灭绝(尽管我们没有理由急于加速这一过程)。加入死亡和灭绝将使模拟变得更加复杂,这超出了我们在本书中能够实现的范围。尽管如此,本节中的模拟在其范围内仍然具有实际意义和示范作用。所有的类比在某个点都会失败——这并不意味着它们毫无用处。
下一章将继续探讨有用的随机性,通过深入优化的世界来展开。随机性能否在寻找最佳解的过程中发挥作用?
注意
没有正式的解答可以解决伯特兰悖论。包括我在内的许多人认为 p = 1/2 是最合理的答案。
第四章:优化世界**

优化是寻找某物最佳组合的过程,通常是定义一个函数或算法的参数。在数学中,优化通常涉及函数,并利用其导数来定位最小值或最大值。在本章中,我们将采用一种不同的方法,涉及随机性。我们将使用的算法分为两大类:群体智能和进化算法。统称为元启发式算法。
使用元启发式算法进行优化比基于微积分的优化更灵活。我们优化的对象不必是数学函数;它可以是算法或其他过程。事实上,任何可以被视为在某个空间中定位最佳位置的问题,其中该空间以某种形式表示问题,都适用于群体智能和进化算法。我经常使用这两类算法,从曲线拟合到演化神经网络架构都有应用。一旦你理解了将任务表述为通用优化问题的过程,你会开始在各个领域看到它们。
在本章中,我们将使用群体智能和进化算法将数据拟合到已知的函数。然后,我们将从零开始演化出最佳拟合函数。然而,我们将首先简要介绍群体智能和进化算法的基础知识。我们已经使用过一种进化算法,尽管当时它并没有被称为进化算法。在第三章中实现的算法用于探索自然选择和遗传漂变,它是一种遗传算法,是我们在本章中将遇到的两种进化算法之一。
带有随机性的优化
想象一个巨大的干草堆,其中每个位置代表问题的一个可能解决方案。我们希望找到干草堆中提供最佳解决方案的部分——也就是说,我们要找到针。问题是:我们该如何找到它?
我们将使用以下通用算法来搜索干草堆:
-
一群(种群)“代理”随机分布在干草堆中。
-
每个代理调查其当前的位置,并给出该位置解决问题的优劣评分。
-
代理将他们的数字报告给总部。
-
每个代理报告完毕后,总部会评估所有数字并存储当前已知的最佳位置,如果在此迭代中有代理发现了更好的位置,则更新该位置。
-
总部根据收到的信息将每个代理指派到干草堆中的新位置。
-
该过程从步骤 2 开始重复,直到找到最佳位置或时间(迭代次数)耗尽。
在实施时,我们有很大的灵活性。事实上,基于这种方法的算法已被数百篇文章公开发布。许多人声称这些算法的灵感来源于自然,但这样的说法往往并不可靠,并且通常并非必要。
这是群体智能算法的例子还是进化算法的例子?其实是两者兼具。这两者的区别在于第 4 步发生的事情:过程总部用来决定智能体下一步应该去哪里的方法。这个区分对于研究人员来说至关重要,但对我们来说重要性较低。
在群体智能算法中,称为粒子的智能体共同工作,定位空间中的新位置进行探索。它们相互之间保持积极的意识,并从每个粒子的经验中“学习”,让整个群体不断朝着空间中越来越好的位置移动,从而找到越来越好的问题解决方案。
另一方面,进化算法运用诸如交叉和变异等技术来繁殖新的智能体(生物体)。在第三章中,我们将生物体的适应度定义为其基因组与当前环境中理想生物体基因组之间的距离。在这里,适应度是衡量生物体基因组所代表的解决方案(在大海捞针中的位置)如何解决问题的标准。通过几代繁殖更适应的解决方案,并辅以随机变异,应该能够让种群逐步接近问题的最佳解决方案。
在实际操作中,我们需要知道的就是这两种算法通过搜索空间来找到最佳位置。我们将配置我们的任务,使得最佳位置能够转化为最佳解决方案。
在成千上万的群体智能和进化算法中,我们应该使用哪一种?每种算法都有其优缺点,可能更适用于某些特定类型的问题。你需要尝试几种。
在本章中,我们将使用五种算法:两种群体智能算法,两种进化算法,以及一种显而易见但许多人并不认为是群体算法的算法。我们没有足够的篇幅逐一讲解每个算法的代码,留给你们作为练习(如果有问题,请随时联系我)。我们将边学习这些算法,边学习使用它们的框架。
这两种群体智能算法分别是粒子群优化 (PSO)和Jaya。PSO 是群体智能算法的鼻祖,许多受自然启发的算法都是 PSO 的变种。Jaya 是一种更新的算法,它没有需要调整的参数——要么它有效,要么它无效。尽管 PSO 有许多变种,我们这里将使用其中两种:标准型和简化型。
这两种进化算法分别是遗传算法 (GA),它是我们在第三章中使用的一种变体,以及差分进化 (DE),另一种传统且广泛使用的技术。DE 是我常用的算法之一,但它有时会有一个让人烦恼的习惯——过快地收敛到局部最小值。
最后一个算法是随机优化(RO)。在 RO 中,粒子之间不进行通信;它们进行局部搜索,并在找到新的位置时移动,但完全不知道其他粒子发现了什么。总部监视每个粒子,以跟踪整体上找到的最佳位置,但从不根据这些信息发布命令。
我们通过实践学习得最好,所以让我们开始将一个函数拟合到数据中。
使用群体进行拟合
在科学和工程中,一个常见的任务是将一个函数拟合到一组测量数据中,其中“拟合”意味着找到一组最佳的参数,使得已知类型的函数能够尽可能精确地逼近数据。对于这个任务,我们已经知道了函数的形式;我们只需要学习参数值,以便将函数调整到数据上。在下一节中,我们将从数据开始,利用群体智能算法来告诉我们最佳的拟合函数和参数是什么(希望如此!)。我在这里使用“群体”一词是广义的,既指通过群体智能算法操控的粒子群,也指通过进化算法培养和进化出的种群。对于我们来说,这种区分并不重要。
让我们从一个简单的例子开始:一些数据,一个函数,以及最适合将函数拟合到数据的参数。该示例的代码在curfit_example.py中。它从一个二次函数生成一组带有随机噪声的点。然后,使用 NumPy 的 polyfit 函数来拟合一个二次函数:ax² + bx + c。图 4-1 展示了图表和拟合函数。

图 4-1:将多项式拟合到一些数据
使用拟合函数,我们可以为任何* x 近似计算出y*,这通常也是我们首先进行数据拟合的原因。
你可能会问,如果polyfit能够拟合数据,为什么还要使用群体智能。遗憾的是,polyfit只适用于多项式,或者说是* x *的幂和的函数。如果你的函数不是多项式,还有其他函数可以使用,比如 SciPy 的 curve_fit。然而,我们不仅仅是为了曲线拟合而使用它;我们将其作为热身练习。SciPy 对于我们将在本章和下一章中探讨的其他优化问题并不会有太大帮助。
曲线
现在我们已经了解了曲线拟合的基本概念,接下来让我们用群体智能来实现它。我们需要的代码在curves.py中。我们将先使用它,然后再分析其中的部分内容。我强烈建议你通读这段代码,熟悉一下其中的内容。
代码需要一个包含测量点及拟合函数的数据文件。我们将使用curves.py来拟合前面的示例。我们需要的输入文件是curfit_example.txt:
3
p[0]*x**2+p[1]*x+p[2]
10.2772497 0.0000000
12.2926738 0.7142857
15.7968918 1.4285714 11.9787533 2.1428571
7.5707351 2.8571429
0.2314503 3.5714286
-0.1762932 4.2857143
-9.0166104 5.0000000
-21.6965056 5.7142857
-50.3670945 6.4285714
-60.2153079 7.1428571
-88.6989830 7.8571429
-107.3679996 8.5714286
-145.8216296 9.2857143
-173.1300077 10.0000000
第一行是参数的数量,后面跟着拟合函数。该函数以 Python 代码的形式给出,其中拟合参数是向量p的元素,数据点用x表示。
我们希望拟合一个类似ax² + bx + c的三参数函数,所以我们使用p[0]*x**2+p[1]*x+p[2]。如果你想要像 sin x这样的函数,使用np.sin(x)(使用 NumPy)。注意,数据点按y然后x列出。
让我们使用curves.py和这个文件通过差分进化来拟合数据:
> python3 curves.py curfit_example.txt -10 20 20 1000 0 DE pcg64
Minimum mean total squared error: 16.430381313 (curfit_example.txt)
Parameters:
0: -2.7702810873939598
1: 9.8170736277919577
2: 6.6657767196319488
(73 best updates, 20020 function calls, time: 1.618 seconds)
输出告诉我们几个信息,但首先看一下参数。这些是p的元素,即找到的最佳参数集。将它们与图 4-1 进行比较。拟合效果相当好。
通用算法说,粒子需要评估它们在稻草堆中的位置,以确定当前位置表示的解决方案有多好。拟合函数中有三个参数;因此,我们的稻草堆是一个三维空间,粒子最初随机分布在这个空间中。三维空间中的每个点对应一个p向量,即一组三个参数。curves.py会报告在搜索过程中找到的最佳位置。
对于每个粒子,在稻草堆中的每个位置,我们计算目标函数的值,这是一个适应度函数,用于告诉我们该位置解的质量。对于曲线拟合,我们的目标函数衡量的是测量点(x, y)与函数返回的同一x位置的y值之间的均方误差。如果ŷ = f(x, p)是函数在x位置对某粒子位置p的输出,则均方误差 (MSE)为:

求和是针对所有测量点(x[i], y[i]).
MSE 越接近零,说明函数拟合测量数据的效果越好,这意味着给出最小 MSE 的粒子位置就是找到的最佳拟合位置。群体算法不断调整粒子的位置,直到找到最小值或我们耗尽迭代次数。
curves.py文件接受许多命令行参数:
curfit_example.txt 数据文件
-10 下界
20 上界
20 粒子数量
1000 迭代次数
0 容差
DE 算法(DE,Jaya,PSO,GA,RO)
pcg64 随机源
第一个参数是包含测量数据点的文件名。第一行是拟合的参数数量,接着是实现拟合函数的代码。文件的其余部分是实际的数据点,y然后是x,每一行一个数据对。
接下来的两个参数指定搜索的边界。这些限制了群体可以移动的空间大小。指定标量值会将该值应用于所有维度;否则,需指定每个维度,用x分隔。在此案例中,我们告诉curves.py将其搜索空间限制为从(–10,–10,–10)到(20,20,20)的立方体。边界通常是有用的,但必须包含实际的最佳值;否则,搜索只会返回给定边界内的最佳位置。
以下参数,也是20,指定了群体的大小,或者说是散布在干草堆中的粒子数量。通常,群体越小,迭代次数越多(这里是 1,000 次)效果更好,但这只是一个经验法则,例外情况很多。
我们正在最小化均方误差(MSE)。如果 MSE 小于给定的容差,搜索会提前停止。通过将容差设置为 0,我们告诉curves.py搜索 1,000 次群体位置,或者如果找到没有误差的位置则提前停止。最后一个参数是RE的随机源。另一个参数是显示数据点和拟合结果的输出图像文件的名称,也可以提供。
群体算法是随机的,意味着它们的输出会因运行的不同而变化,因为它们在搜索过程中随机分配初始粒子位置,并使用随机值。对于许多问题,变化是微妙的,不太重要,但有时群体会迷失方向。因此,最好多次重复搜索(如果可能的话),以确保结果是有意义的。
要尝试其他的群体算法—PSO、Jaya、GA 和 RO—请通过名称指定。我怀疑你会发现,PSO、Jaya 甚至 RO 给出的结果和 DE 一样好。然而,GA 是另一个问题。数值输出很差,虽然如果你绘制结果,图形看起来通常至少还算合理。这是否意味着 GA 是一个有缺陷的算法?不,它只是并不适合这个任务。一般来说,GA 最适合非数值优化问题和维度(参数)较高的问题。在这个例子中,使用 GA 意味着要求它进化出一个每个基因有三个基因的有机体种群。这对于进化来说几乎没有什么可以工作的内容。
让我们看一个曲线拟合的例子。要拟合的函数在sinexp.txt文件中:

这个函数是正弦曲线和正态曲线的和,包含五个参数:我们处于一个五维的搜索空间中,每个粒子是该空间中的一个点。我无法想象一个五维的干草堆,但我们依然在这个空间中寻找针。
让我们试试使用 Jaya 的curves.py:
> python3 curves.py sinexp.txt -3 23 20 1000 0 Jaya mt19937 fit.png
Minimum mean total squared error: 0.000000015 (sinexp.txt)
Parameters:
0: 1.9999892608106149
1: 3.0000001706464414
2: 20.0001115681148427
3: 7.9999997934624147
4: 0.6000128598331004
(137 best updates, 20020 function calls, time: 1.412 seconds)
我第一次尝试代码时,拟合失败,返回了 0.1656 的最小 MSE,比之前的拟合大了几个数量级。良好结果的图形见图 4-2。

图 4-2:使用 Jaya 对方程 4.2 的拟合
搜索依然使用了 20 个粒子和 1,000 次迭代。我将搜索空间限制在了五个维度的-3 到 20 之间。在这个例子中,数据集是直接从函数生成的,参数值分别为 2、3、20、8 和 0.6。这也解释了极低的 MSE:测量中没有噪声。
注意
在使用代码时,你可能会遇到运行时警告。这是由于群体算法使用了对指数函数来说过大的参数值。你可以在命令行中加上 -W ignore ,这样就能抑制这些警告。
curves.py 代码
让我们看看一些代码,感受一下群体算法在做什么;它也将帮助你理解如何将各个部分组合在一起。从curves.py的底部开始。代码的本质,如你在阅读时会看到的,是以下内容:
rng = RE(kind=kind)
b = Bounds(lower, upper, enforce="resample", rng=rng)
i = RandomInitializer(npart, ndim, bounds=b, rng=rng)
obj = Objective(X, Y, func)
swarm = DE(obj=obj, npart=npart, ndim=ndim, init=i, tol=tol, max_iter=niter, bounds=b, rng=rng)
swarm.Optimize()
res = swarm.Results()
第一行使用命令行给定的源(kind)配置一个RE的实例。接下来的四行配置差分进化的搜索。Jaya、PSO、RO 和 GA 的代码是相同的,只有在 PSO 的情况下,构造函数中会多一个参数。让我们逐行看;这些是使用框架配置任何群体搜索的步骤,接下来我们会再次看到它们。
首先,群体搜索是有边界的,所以我们需要一个Bounds类的实例,或者如果需要重写其方法(通常是Validate),可以使用它的子类。参数包括下限和上限、随机源和一个名为enforce的参数,默认设置为resample。回顾一下通用的搜索算法,第 5 步表示总部指示代理根据当前位点的目标函数值移动到新位置。有时,这些新位置可能超出了指定的边界。enforce参数决定在这种情况下该怎么办。通过将其设置为resample,任何超出边界的粒子维度将被该维度上的随机选择值替代。另一个选项是clip,它将违规维度修剪为允许的最小值或最大值。大多数情况下,这并不是我们想要的。
RandomInitializer参数提供了一个初始化器来配置群体。它接收群体中粒子的数量(npart)、搜索空间的维度(ndim)以及上一行配置的边界(b)。
搜索还需要知道如何评估目标函数,这是一个Objective的实例。在曲线拟合示例中,X和Y是测量点,func是要拟合的函数,所有这些都从命令行给定的数据文件中读取。稍后我会展示目标函数。
我们现在准备创建群体算法对象(swarm),这里是DE的一个实例。我们提供目标函数、粒子数量、维度、初始化器、边界以及随机源。我们还指定了容忍度(tol)和迭代次数(max_iter)。
给定所有的配置,使用群体对象是直接的:调用Optimize方法。当调用返回时,搜索结束。调用Results以返回一个包含搜索信息的字典。
res 中最重要的元素是 gpos 和 gbest。这两者都返回追踪群体在搜索过程中找到的最佳位置的列表。因此,这些列表的最终元素返回最佳位置(gpos)和相应的目标函数值(gbest)。该位置是一个向量,每个维度对应搜索空间中的一个值;这里每个维度是我们拟合数据的函数的一个参数值。gbest 是一个标量,表示该组参数的均方误差(MSE)。
让我们来看一下 Objective 类。作为目标函数传递的对象至少需要有一个名为 Evaluate 的方法。具体细节并不重要,但因为 Python 使用鸭子类型,所以任何具有接受单个参数的 Evaluate 方法的对象都可以接受。这里是 curves.py 使用的代码:
class Objective:
def __init__(self, x, y, func):
self.x = x
self.y = y
self.func = func
self.fcount = 0
def Evaluate(self, p):
self.fcount += 1
x = self.x
y = eval(self.func)
return ((y - self.y)**2).mean()
构造函数保持对测量点 x 和 y 的引用,并包含表示拟合函数的字符串 (func)。许多应用没有附加信息,在这种情况下,构造函数不做任何操作,也不需要指定。此外,请注意 Objective 并没有继承任何其他类,它只需要实现 Evaluate 方法,就可以被优化框架接受。
Evaluate 方法由群体算法调用。参数 p 是粒子在群体中的当前位置,即一个可能的参数值向量。第一行增加了 fcount,这是 Evaluate 被调用次数的内部计数器。curves.py 退出时会显示 fcount 的最终值。
下一行看起来有点奇怪:它将 self.x(即 x 数据)的引用赋值给局部变量 x。接下来的行使用 Python 的 eval 函数来计算函数值;因为 eval 同时使用了 x 和 p 作为变量名,所以我们需要在 Evaluate 中存在这些变量名——因此需要 x = self.x。计算出的函数值保存在 y 中。这些就是 公式 4.1 中的 ŷ 值。
最后,我们计算 MSE 并将其返回,作为给定粒子位置 p 的目标函数值(或适应度值)。注意这里没有取平方根。我省略了它,以节省一点时间。即使没有最终的平方根,最小的 MSE 仍然是最小的。
当群体算法运行时,它会调用 Evaluate 数千次,将粒子位置映射到均方误差(MSE)值。群体算法完全不知道目标函数在衡量什么;它们只知道将表示粒子位置的向量传递给目标函数,目标函数返回一个标量值,较小的值比较大的值更好。这使得框架具有通用性,适用于广泛的问题。
总结一下,使用框架的步骤如下:
-
确定如何将潜在的解决方案映射到一个多维空间中的位置,供群体进行搜索。
-
使用该映射创建一个目标函数类,支持至少一个
Evaluate方法,用于接受一个候选位置向量并返回一个标量,表示该位置代表的解的质量。框架总是进行最小化,因此返回值越小,解越好。要进行最大化时,返回适应度值的负值。 -
创建一个
Bounds对象,用于设置搜索空间的边界以及如果超出边界时的处理方式。 -
创建一个初始化器(
RandomInitializer),用以提供群体粒子的初始位置。 -
创建一个群体类的实例,
DE、PSO、Jaya、RO或GA。 -
通过调用
Optimize进行搜索,并使用Results返回结果。
随着实践的深入,我们将更熟悉这个框架。现在,让我们回顾一下群体智能和进化算法,以理解它们之间的差异以及随机性所在。随机性远不仅仅存在于群体粒子在搜索空间中的初始配置中;每种算法的操作都依赖于随机性。
优化算法
目前有成百上千种群体优化算法,但是什么使得它们彼此不同呢?简短的答案是:搜索方法,或者说总部在每次迭代中如何指挥代理到达新位置。
各种算法方法从最简单的 RO 开始——在 RO 中,代理之间不进行通信,而是独立地从一个更好的位置漂移到另一个更好的位置——到更复杂的算法,这些算法将代理的当前位置信息、历史记录以及群体和邻域的关联信息纳入考虑。
在本节中,我将总结框架中选择的五种算法的基本操作。在所有情况下,基本操作都是相同的:将粒子散布在整个搜索空间中,评估每个粒子的质量,决定它们下一步去哪里,并重复这个过程,直到找到最佳位置或时间耗尽。正是“决定它们下一步去哪里”这一部分区分了不同的算法。
随机优化(RO)
群体粒子通过一个浮点数向量***x**[i]*来表示,每个组件对应搜索空间中的一个维度。换句话说,粒子是搜索空间中的点。在每次迭代中,粒子会构造一个与当前距离一定的新的位置,并判断这个新位置是否具有更好的适应度值,也就是说,目标函数在新位置的值是否更低。如果是,粒子就移动到新位置;否则,它保持原地。新的候选位置是

其中,η(η)是一个比例参数(η = 0.1),N(0, 1)是一个从均值为 0、标准差为 1 的正态分布中取样的向量。如果
的目标函数值较低,那么
;否则,粒子在下一次迭代中保持原位。粒子不会利用其他粒子对搜索空间的学习结果。
Jaya
Jaya,梵语中意为“胜利”,是一种没有可调参数的群体智能算法。群体算法依赖启发式方法,因此通常会有可调参数来提高其在不同情况下的表现。而 Jaya 则不同,它要么有效,要么无效。
在每次迭代中,第i个粒子通过以下方式更新:

其中,x[best]和x[worst]是群体中任何粒子的当前最佳和最差位置,r[1]和r[2]是[0, 1)之间的随机向量,逐项计算。竖线符号表示对向量的每个分量取绝对值。换句话说,Jaya 将粒子移动到群体的最佳位置,远离群体的最差位置。
粒子群优化(PSO)
PSO 的更新公式取决于具体实现。我们的框架提供了两种:经典 PSO 和精简版 PSO。curves.py文件使用的是精简版,因此在PSO构造函数中设置了bare=True。不过,建议从经典 PSO 开始。
在经典 PSO 中,每个粒子(x[i])与另外两个向量相关联。第一个,
,是该粒子在搜索空间中找到的最佳位置;第二个,υ[i],是粒子的速度,控制粒子在搜索空间中移动的速度和方向。
经典的 PSO 更新规则通过两个步骤完成。首先,速度更新:

这里,ω是惯性因子,用来乘以当前速度。它是一个标量,通常在 0.5, 1)之间,典型的初始值为 0.9。它会随着迭代逐步减小。理论上,这会随着搜索进展而减缓粒子的速度,因为粒子很可能正接近最佳位置。第二项计算粒子目前已知的最佳位置![Image 与其当前的位置x[i]之间的差异。这个差值逐项乘以c[1] = c[1]U[0, 1),即在[0, 1)之间的随机向量,再乘以标量c[1]。我们使用典型的值 1.49。速度更新的最后一项计算群体当前最佳位置g与粒子当前位置之间的差异,并将其乘以向量c[2] = c[2]U[0, 1)。通常,c[1] = c[2]。
其次,粒子的位置通过新计算出的速度进行更新:
x[i] ← x[i] + υ[i]
注意
如果你有物理学背景,并且像我一样,对速度和位置的相加感到困扰,可以想象一个 Δ t = 1 乘以v[i],其中* Δ t 是每次迭代之间的时间步长。现在单位是正确的。
基础 PSO,有时称为 BBPSO,不使用速度向量。相反,粒子的位置通过从正态分布中抽取样本来更新。如果x[i]是表示粒子i当前位置的向量,那么x[ij]就是该向量的* j *分量。考虑到这一点,计算

如果 p ∼ U[0, 1) < p[b],否则

对于每个粒子(i)的每个分量(j)。这里,
表示从均值为
、标准差为σ的正态分布中抽取样本。通常,p[b] = 0.5,因此粒子* j *的分量有 50%的几率从正态分布中计算得出,另外 50%的几率只是简单复制粒子最佳位置的对应分量
。
遗传算法(GA)
从我们的进化实验中我们知道,遗传算法(GA)涉及交叉(crossover)和随机变异。GA.py中的代码遵循这一模式,但与整体优化框架相契合。特别是,GA.py默认操作浮动值,而不是整数。如果需要,你可以通过子类化Bounds并实现Validate方法来强制使用整数值。
粒子的更新规则,x[i],涉及与随机选择的配偶进行交叉,在这种情况下,配偶是从表现最好的前 50%的粒子中选择的——参见GA构造函数中的top参数。此外,当前的最佳粒子位置,即最优粒子,将未修改地传递到下一代。
我们的进化实验通过代际交配繁殖每个个体。这里,个体仅在随机值小于CR概率时才进行交配,默认值为 0.8。当个体繁殖时,它将被后代替代。无论x[i]是否交配,都会有一定概率发生随机变异,通过为一个随机选择的维度分配一个随机值。因此,对于任何更新,粒子可能会被其后代替代,并且可能会发生随机变异。默认的变异概率为 5%(在GA构造函数中的F)。
根据经验法则,GA 似乎最适合那些非数学问题(如曲线拟合),并且涉及更多的维度,以便为进化提供更大的“基因组”进行操作。不知道这是否对生物进化有影响;无论如何,GA 的另一个特点是收敛较慢。你通常需要更多数量级的迭代(甚至更多),才能得到一个与 Jaya 或 DE 快速找到的解相似的解。让我们现在转到那里。
差分进化(DE)
DE 由 Price 和 Storn 于 1995 年发明,与此同年,粒子群优化(PSO)由 Kennedy 和 Eberhart 发明。像 PSO 一样,DE 经得起时间的考验,并已发展成一系列相似的方法。DE 是一种进化算法,其中粒子在每次迭代之间通过交叉和突变的过程更新。然而,与遗传算法(GA)中直接的交叉和突变不同,DE 用一个新向量替换x[i],从某种意义上说,这个新向量是四个父代的后代。DE 并不是模仿自然的。
为了更新粒子x[i],首先选择群体中其他三个成员,且它们不能是x[i]。从这三个成员中创建一个捐赠向量:
υ = υ[1] + F(υ[2] – υ[3])
某些 DE 变种要求υ[1]是群体中表现最好的成员,而不是随机选择的成员。在这种情况下,F在遗传算法中起到突变的作用。此时,默认值为F = 0.8。
x[i]和υ的后代是逐个分量(逐基因)创建的,其中,在概率CR下,使用υ的对应分量;否则,保留x[i]的分量。默认值为CR = 0.5,意味着x[i]的后代平均保留 50%的现有值(基因)。
DE 有很多变种,因此出现了专门的命名法来描述它们。DE.py中的代码默认为“DE/rand/1/bin”,这意味着捐赠向量使用三个随机选择的向量(“rand”),一个差分(υ[2] – υ[3]),以及伯努利交叉(“bin”)。伯努利试验类似于掷硬币,其中成功的概率为p,失败的概率为 1 – p。这里,p = CR是交叉概率。
DE类支持两种额外的选择类型和一种额外的交叉类型,如果你想尝试它们的话。如果用于构建捐赠向量的三个向量之一始终是当前群体中的最佳向量,则标签会以“DE/best/1”开头。此外,支持一种新的选择模式:DE/toggle/1,它在每次更新时在“rand”和“best”之间切换。最后,伯努利交叉可以替换为遗传算法风格的交叉,这意味着DE类支持来自三种不同选择和两种交叉类型的六种差分变体。你可以随意尝试它们。你注意到它们之间有什么不同吗,尤其是在群体收敛的速度上?提示:查看DE的Results方法返回的字典中gbest元素的所有值,以及跟踪每个新群体最佳位置(gpos)和目标函数值(gbest)的giter元素。
本节的目标是通过最小化数据点和函数值之间的均方误差(MSE),将已知函数拟合到数据集上。我们要寻找的是函数的参数,因为我们已经知道了我们想要的形式。这就引出了一个问题:如果我们只有数据,而不知道函数的形式怎么办?有几种方法可以解答这个问题。一个是使用机器学习模型——毕竟,机器学习模型的设计目的就是:从一组数据中学习一个模型(函数)。我们将在第五章中讨论这个问题。另一种方法是进化出一段逼近数据的代码。让我们尝试这种方法。
拟合数据
曲线拟合要求我们搜索已知函数的参数。在这一节中,我们只有数据,而我们的目标是进化出一段代码,使得它能够逼近一个拟合数据的函数。我们依然希望y = f(x)——也就是说,对于给定的x,我们得到一个近似的y——但这里的f(x)是 Python 代码。进化代码被称为遗传编程(GP),它有着悠久的历史,追溯到 1990 年代初期。相关的术语是符号回归。
正如名称所示,GP 通常使用 GA。然而,我们的实现采用了前一节中的框架,因此我们可以选择任何一种群体智能和进化算法。为了使用群体算法,我们需要找到我们想要的(代码)和一个多维空间之间的映射关系,其中空间中的每个位置表示一个可能的解。对于曲线拟合,映射关系很直接。如果函数中有n个参数,那么在搜索空间中也有n个参数,每个特定点的坐标实际上就是该参数的值。
在这里,我们需要更聪明一点。为了识别映射关系,我们可以考虑如何表示我们函数的代码,从这个角度出发,映射关系可能会更容易理解。
我们希望一个函数能够处理标量输入值x,并得出标量输出值y。所以,我们需要进行数学运算。我们将使用标准的算术运算,另外加上取反、模运算和幂运算。
做数学运算意味着数学表达式。在这里,事情变得更加复杂。操控数学表达式相当棘手,比我们在这本书中愿意讨论的还要复杂。传统的 GP 使用进化算法操作表达式,包含交叉和变异,交叉将两个表达式合并,而变异则改变表达式中的一个项。
幸运的是,我们可以使用一个捷径。如果我们有一个栈,并且了解后缀表示法,我们就可以具备生成表达式并将代码映射到搜索空间中的位置所需的一切。我将做详细解释,但在此之前,我们需要确保我们对栈和后缀表示法有一个清晰的认识。
介绍栈和后缀表示法
想象一下自助餐厅托盘的堆叠。当新的托盘被添加到栈中时,它们会放在所有现有托盘的上面。当有人需要托盘时,他们拿取最上面的托盘,这意味着最后被添加到栈中的托盘是第一个被拿走的托盘。栈就像自助餐厅的托盘(虽然更干净)。
考虑这个例子。我们有三个数字:1、2 和 5。我们还有一个当前为空的栈。我们首先将 1推入栈,然后推入 2,最后推入 5。图 4-3显示了栈的变化过程,从左到右。

图 4-3:栈操作
在左侧,栈是空的;然后,向右移动,我们依次添加 1、2,最后添加 5。此时栈的深度为三,底部是 1,顶部是 5。
现在是时候弹出栈中的一个值了。我们得到什么值?在队列中,我们会得到 1,即第一个进入的值。而在栈中,我们得到 5,即最后一个被推入的值。再次弹出栈,我们得到 2,最后得到 1,栈变为空。栈中的值是按相反顺序弹出的,与它们被推入栈的顺序相反。
栈是操作后缀形式表达式的自然结构,即操作数先出现,运算符跟在其后。例如,常用的中缀表示法,我们通常写作a + b,而在后缀表示法中,这变成了a b +。后缀表示法,也叫逆波兰表示法(RPN),是由波兰数学家扬·Łukasiewicz 于 1924 年提出的。后缀表示法不需要括号来改变运算符的优先级。相反,它是逐步构建表达式的。将后缀表示法与栈结合起来,评估任意表达式变得非常简单。这正是我们所需要的。
为了更好地理解我的意思,我们将中缀表达式y = a(b + c) – d 转换为后缀表示法,并使用栈和伪代码实现它。在后缀表示法中,它变成了a b c + × d –。要评估它,从左到右移动,直到遇到运算符,这里是+。操作数是左侧的两个变量,b和c。计算b + c并将“b c +”替换为结果t[0]。此时表达式变为a t[0] × d –。重复此过程以找到×,操作数为a和t[0]。计算乘积并将其替换为t[1],得到t[1] d –。最后,评估t[1] – d,得到表达式的值y。
让我们在代码中实现这个过程,使用栈来保存值。请考虑以下内容:
push(a) | a
push(b) | a b
push(c) | a b c
add | a t0
mul | t1
push(d) | t1 d
sub | y
右侧的值显示了每条指令执行后栈的状态,其中t[0] = b + c,t[1] = a × t[0],y = t[1] – d。这些表达式将结果y留在栈上,而push指令则将值压入栈中。像add这样的二元操作会从栈中弹出两个值,进行相加,并将结果重新压入栈中。因此,一系列的线性语句和栈就是我们实现任何关于x并得到y的函数所需的——至少,对于涉及算术运算、取反和幂运算的函数。
以这种方式编写的函数成为没有循环的指令序列。如果我们找到将这些序列映射到浮点向量的方法,那么我们就成功了。
代码与点的映射
要演化代码,我们需要四种基本的算术运算:加法(add)、减法(sub)、乘法(mul)和除法(div)。我们还需要指数运算(pow),并且为了稳妥起见,我们还加入了取模运算(mod)。
后缀表示法区分了减法运算符和取反运算,后者被视为不同的指令,因此我们还需要取反(neg)指令,x → –x。
最后,我们需要两条指令:halt和push。如果执行了halt,代码会停止,并忽略之后的任何指令。push指令会将x或一个数字(常数)压入栈中。
我们有九条指令。我们希望按顺序执行一系列指令,这让我想起一个向量,其中每个元素是一个指令,我们从索引 0 开始,一直到向量的末尾执行这些指令。
每条指令都变成一个值,例如,add是 1,sub是 2,所以如果某个粒子的某个组件是 2,那么这个组件就表示减法指令。粒子位置是浮点数,不是整数,所以我们只保留每个位置的整数部分,这意味着一个浮点值为 2.718 的组件会被解释为 2,从而意味着减法指令。
表 4-1 包含了我们将用于指令的映射(粒子位置的整数部分)。
表 4-1: 粒子位置与指令的映射
| 指令 | 编号 |
|---|---|
add |
1 |
sub |
2 |
mul |
3 |
div |
4 |
mod |
5 |
pow |
6 |
neg |
7 |
push(x) |
8 |
halt |
9 |
剩下的就是处理将常数压入栈中。如果我们不能做到这一点,我们就只能演化出类似x的表达式,比如xx + x – x – x,这将毫无意义。
指令编号从 1 开始,而不是 0。这是故意为之。这样编号使得向量组件在 0, 1)范围内可用,因为这个范围没有与任何指令相关联。
让我们使用这个范围将任意数字压入栈中。当我们运行搜索时,我们会指定一个最小值和最大值,比如-1 和 11。然后,我们将[0, 1)范围内的值映射到[–1, 11)范围。因此,要将常数 3.1472 压入栈中,我们发出指令 0.3456,因为:
a + f(b – a) = –1 + 0.3456(11 – ^–1) = 3.1472
通过这种方式处理,可以让我们在给定的范围内指定任意的常数值。
例如,[表 4-2 展示了由 gp.py 生成的一段代码,该程序是我们正在开发的,同时也给出了实际的粒子位置值。
表 4-2: 一个进化代码示例
| 指令 | 粒子值 |
|---|---|
push(x) |
— |
push(x) |
8.5251446 |
push(3.00482) |
0.6502409 |
mul |
3.3605457 |
push(7.07870) |
0.8539350 |
push(-9.09650) |
0.0451748 |
mod |
5.0708302 |
add |
1.3708454 |
halt |
9.7707617 |
div |
4.2693693 |
sub |
2.6309877 |
div |
4.6783009 |
pow |
6.5429319 |
任务是拟合一个表示直线的噪声数据集。进化后的函数很好地拟合了这些数据。这个案例中的数字限制是 –10 到 10,我让搜索(简单粒子群优化,PSO)使用 12 条指令。所有进化后的函数都会以 x 开始,栈上最初表示的是 push(x)。此外,当函数退出时,它会返回栈顶的值作为 y,剩余的栈值会被忽略。
如果粒子值 ≥ 1,则整数部分指定一个指令,所以 8.525 → 8,这表示推送 x,就像 3.360 → 3 表示乘法。
看一下第二个粒子向量分量 0.6502409,它在数字限制下变为:
–10 + 0.6502409(10 – ^–10) = 3.00482
这个数字将与 x 相乘,即第二条和第三条指令实现了 3.00482x。这些数据点是通过给直线 3x – 2 添加少量随机噪声生成的。进化后的函数立即实现了 3.00482x,这非常令人鼓舞。
接下来的三条指令将 7.07870 和 –9.09650 推入栈中,然后执行 mod。这看起来是一个奇怪的操作,但考虑一下 Python 对表达式的处理方式:
>>> 7.07870 % -9.09650
-2.0178000000000003
这些指令将 –2.0178 留在栈上。
接下来的指令是 add。我们将栈顶的两个值相加,刚才我们得知它们是 3.00482x 和 –2.0178。有趣的是,这等价于中缀表达式 3.00482x – 2.0178,而我之前提到这些数据点是通过 3x – 2 生成的。进化后的代码实现了当初生成数据点时使用的表达式。
紧接着 add 的指令是 halt,它使得函数退出,并将栈上的和作为结果。halt 之后的指令永远不会被执行。
太棒了!我们有了一个方法,一种将浮点向量映射到代码以实现函数的方式。它有点奇怪,但我们将继续尝试,看看能走到哪里。接下来的任务是创建 gp.py。
创建 gp.py
如果你还没有阅读过 gp.py,请先浏览一遍。所有基本的框架部分都在那里,我们不会讨论每一行,所以在开始之前了解它将会有所帮助。
代码导入了之前曲线拟合练习中的所有框架组件;在主代码之前定义了一些辅助函数(GetData、Number StrExpression)和目标函数类,这些代码解释命令行;构造框架对象;并运行搜索。让我们在这里回顾 Number 和目标函数类。主代码与曲线拟合代码相似。
Number 函数将粒子值从 [0, 1) 转换为在执行 gp.py 时命令行上指定的范围。具体而言:
def Number(f, gmin=-20.0, gmax=20.0):
return gmin + f*(gmax-gmin)
这是对前面方程的直接实现,gmin 和 gmax 是命令行上传递的限制。这些限制约束了进化代码可用的常数范围;因此,可能需要一些实验来找到合理的限制。例如,如果你运行搜索并看到常数达到了限制,那么指定的范围可能太小,可以将其大小加倍再试一次。请记住,群体智能和进化算法是随机的和启发式的。控制它们操作的参数非常多,通常需要管理这些参数以获得好的结果。
一次成功的群体搜索利用一个目标函数,引导群体朝着好的解决方案前进,这里是通过一段代码最小化已知数据点与这些数据点输出结果之间的均方误差(MSE)。因此,我们接下来的关注点是目标函数类 Objective:
class Objective:
def __init__(self, x,y, gmin=-20.0, gmax=20.0):
self.fcount = 0
self.x = x.copy()
self.y = y.copy()
self.gmin = gmin
self.gmax = gmax
def Evaluate(self, p):
self.fcount += 1
y = np.zeros(len(self.x))
for i in range(len(self.x)):
y[i] = Expression(self.x[i],p, self.gmin, self.gmax)
if (np.isnan(y[i])):
y[i] = 1e9
return ((y - self.y)**2).mean()
该类有两个方法,一个是构造函数,另一个是 Evaluate。构造函数保存数据点和命令行传递的数字限制。它还初始化了 fcount,用于追踪目标函数被评估的次数。
Evaluate 方法接受粒子位置(p),并通过它传递数据点的 x 坐标,生成输出向量 y。然后,它返回 y 和 self.y 之间的均方误差(MSE)作为目标函数值。
并不是每个由粒子位置表示的代码都是有效的。尤其是在搜索初期,随机生成的粒子位置可能会变成失败的代码块,因为它们尝试做一些不可能的操作,比如从空栈中提取值或除以零。Evaluate 中的 NaN 检查捕捉到这些情况,并确保返回一个非常高的目标函数值。
Expression 函数将粒子位置作为代码进行评估。它接受 x 值、粒子位置(p)和数字范围;请参见 Listing 4-1。
def Expression(x, expr, gmin=-20.0, gmax=20.0):
➊ def BinaryOp(s,op):
b = s.pop()
a = s.pop()
if (op == 0):
c = a + b
elif (op == 1):
c = a - b
elif (op == 2):
c = a * b
elif (op == 3):
c = a / b
elif (op == 4):
c = a % b
elif (op == 5):
c = a**b
s.append(c)
bad = 1e9
➋ s = [x]
try:
➌ for e in expr:
if (e < 1.0):
s.append(Number(e, gmin=gmin, gmax=gmax)) else:
op = int(np.floor(e))
if (op < 7):
BinaryOp(s, op-1)
elif (op == 7):
s.append(-s.pop())
elif (op == 8):
s.append(x)
elif (op == 9):
break
except:
return bad
try:
➍ return s.pop()
except:
return bad
Listing 4-1:将粒子位置解释为代码
列表 4-1 是gp.py的核心部分。首先,有一个嵌套函数BinaryOp ➊,它实现了所有二进制操作,如加法和指数运算。栈(s)是一个标准的 Python 列表,弹出两次以获取操作数。注意顺序:如果我们想要a - b,并且b是栈顶项,那么第一次弹出的是b,而不是a。第二个参数决定了操作类型。一种更紧凑的实现可能会使用 Python 的eval函数。然而,我们需要尽可能快,所以我们选择了冗长但显著更快的复合if结构。
代码使用x ➋初始化栈,然后开始对粒子位置(expr)的组件进行循环 ➌。所有内容都在try块内,以捕获任何错误。错误将返回bad作为函数值。
如果粒子组件小于 1.0,它会将常量Number的输出压入栈中。否则,值的整数部分决定了操作。如果小于 7,则执行适当的二进制操作;否则,指令是取反、推送x或停止,后者会跳出循环,从而忽略剩余的粒子组件。最后,函数返回栈顶项(如果有的话)作为函数值 ➍。
gp.py的其余部分很简单:解析命令行,创建框架对象(Bounds、RandomInitializer、Objective),然后,使用适当的群体对象,调用Optimize和Results来报告搜索的效果。如果给定了最终的图表名称,我们将生成图表,展示数据点和拟合结果。
我们的曲线拟合代码使用了基本的 PSO。该代码同时使用了基本 PSO 和经典 PSO:
elif (alg == "PSO"):
swarm = PSO(obj=obj, npart=npart, ndim=ndim, init=i, tol=0, max_iter=niter, bounds=b,
rng=rng, vbounds=Bounds([-10]*ndim, [10]*ndim, enforce="clip", rng=rng),
inertia=LinearInertia(), ring=True, neighbors=6)
elif (alg == "BARE"):
swarm = PSO(obj=obj, npart=npart, ndim=ndim, init=i, tol=0, max_iter=niter, bounds=b,
rng=rng, bare=True)
我们可以通过在命令行中传递PSO或BARE来区分这两者。请注意,PSO 使用了一些我们之前没有见过的选项。其中之一是LinearInertia,它在线性下降的过程中将ω从 0.9 减少到 0.4。惯性是每个粒子上一个迭代的速度与当前速度的乘积系数。
还有三个额外的选项。两个是ring和neighbors,它们一起工作。经典 PSO 的一种变体包括邻域的概念,邻域是相互协调的粒子集合。邻域的实际效果是将全局最佳位置g替换为邻域最佳位置。粒子安排成邻域的方式被称为拓扑结构。PSO类支持环形拓扑结构——最简单的一种。可以想象粒子形成一个圆圈;然后,对于任何粒子,左侧和右侧的neighbors粒子构成当前粒子的邻域。作为挑战,试着修改PSO.py以适应冯·诺依曼邻域,这是性能最佳的拓扑之一。通过在网上仔细搜索,你会发现冯·诺依曼拓扑的详细信息。
最后的新选项是vbounds,它设置了每个粒子组件的最大速度限制,类似于bounds设置粒子群在搜索空间中的位置限制。在速度的情况下,enforce是clip,以保持速度组件在限制内,而不是沿该维度重新采样。
可调参数的数量有时使得设置一个成功的标准 PSO 搜索变得棘手,即使有邻域的支持也是如此。因此,这些值更像是“指导方针”而不是实际规则。
现在,让我们测试一下gp.py,看看它能为我们做什么(以及不能做什么)。
演化拟合函数
让我们通过几个实验来测试一下gp.py。
拟合一条直线
要运行gp.py,可以使用如下命令行:
> python3 gp.py data/x1_2n.txt -5 5 22 20 10000 bare minstd plot.png
非法操作很可能会在搜索的早期发生,因此我建议通过添加-W ignore来忽略运行时错误。命令行使用了输入文件x1_2n.txt,这就是之前提到的嘈杂直线。数值被限制在–5, 5)范围内,程序的最大长度是 22 条指令,尽管halt通常会出现在更早的时候。
粒子群有 20 个粒子,并运行 10,000 次迭代,使用基础的 PSO 算法和 MINSTD 随机数源。结果会写入plot.png,并且代码本身会显示出来:
Minimum mean total squared error: 0.385596890 (x1_2n.txt)
push(x)
push(x)
add
push(x)
add
push(4.82483)
push(2.39118)
div
sub
halt
add
mod
div
sub
sub
sub
div
mul
halt
push(x)
pow
add
div
(23 best updates, 200020 function calls, time: 257.076 seconds)
请注意,halt出现在第九条指令中(最初的push(x)始终存在,因此不算在内)。
[图 4-4 展示了原始数据点以及拟合函数的输出。

图 4-4:拟合嘈杂的直线
拟合效果很好,这令人鼓舞。如果我们连一条直线都拟合不好,那么就不应该期待能拟合更复杂的函数。
我们现在有了两种不同的拟合直线的方法,但达到解决方案的演化路径完全不同。第一个解决方案是演化得到的
(x)(3.00482) + (7.07870 mod – 9.09650) = 3.00482x – 2.0178
但是第二个方案得到了:
x + x + x – (4.82483 / 2.39118) = 3x – 2.01776
第二个解决方案将x与自身相加三次,而不是与一个常数相乘。两种解决方案都得到了几乎相同的截距,不是通过推动学习到的值,而是通过实施两个学习到的值的不同二进制操作。
data目录包含多个数据集,其中许多是gpgen.py的输出,您可以使用它们创建自定义数据集,生成最多五次幂的嘈杂多项式。运行gpgen.py而不带参数可以了解它的工作原理。现在,让我们使用其中的一些数据文件,推动gp.py的极限。
拟合一个二次函数
我们很容易就演化出了直线方程。那二次方程呢?
> python3 gp.py data/x2_2n.txt -5 5 22 20 10000 bare minstd plot.png
对我来说,运行结果是:
Minimum mean total squared error: 0.263703051 (x2_2n.txt)
push(x)
push(x)
mul
push(-2.97844)
sub
push(x)
sub
push(x)
sub
halt
(98 best updates, 200020 function calls, time: 290.683 seconds)
halt之后的指令会被忽略,因为它们没有任何效果。从现在开始,我会始终如一地这样做。得到的拟合结果见图 4-5。

图 4-5:拟合嘈杂的二次函数
演化出的代码等价于:
(x² – ^–2.97844) – x) – x) = x² – 2x + 2.97844
将相同的数据集提供给 NumPy 的 polyfit 例程,结果为:
x² – 2.02x + 2.99
这让我们对进化搜索越来越有信心。
拟合一个四次方程
之前的例子都使用了最基本的 PSO,看起来它非常适合这个任务。让我们尝试不同的数据集,一个四次方程,并使用不同的算法。它们的效果是否一样好?
具体来说,我们将拟合 x4_-2x3_3x2_-4x_5_50n.txt 中的点,这是一种四次方程的噪声版本,y = x⁴ – 2x³ + 3x² – 4x + 5。唯一从一次运行到下一次运行变化的参数是优化算法。例如,这里是最基本的 PSO 命令行:
> python3 gp.py data/x4_-2x3_3x2_-4x_5_50n.txt -25 25 22 25 15000 bare pcg64 plot.png
要使用差分进化算法,只需将 bare 改为 DE 并重新运行。我们将查看每个算法的单次运行结果。
该框架的设计注重清晰性,而非速度。由于每个粒子独立评估目标函数,因此有很多并行化的机会。不幸的是,我们没有利用这些机会,因此需要耐心来复制每个算法的搜索过程:DE、最基本的 PSO、典型 PSO、Jaya、GA 和 RO。此外,框架不使用种子值,因此你运行的代码将产生不同的输出,但可能相似。
图 4-6 显示了每个算法的拟合情况,从左上角的 DE 到右下角的 RO。

图 4-6:每个算法的拟合结果
显然,并非每个算法都达到预期的效果。表 4-3 显示了它们生成的等效方程。
表 4-3: 每个算法进化得到的拟合方程
| 算法 | 等效方程 |
|---|---|
| NumPy | y = 1.01x⁴ – 2.01x³ + 2.76x² – 3.34x + 5.76 |
| 差分进化 | y = x⁴ – 2.19254x³ + 3x² |
| 最基本 PSO | y = x⁴ – 2.23835x³ |
| 典型 PSO | y = x^(4.09896) |
| Jaya | y = –x³ + 19.36026x² |
| GA | y = 21.78212x² |
| RO | ![]() |
第一个方程是 NumPy 的 polyfit 例程返回的拟合结果。数据是从四次方程生成的,所以我们预计 NumPy 的拟合结果是最好的,我们将其视为黄金标准。
差分进化算法(DE)得到了最优拟合的函数。将其与 NumPy 的拟合结果进行比较。DE 的拟合恢复了多项式的前三项,系数与 NumPy 的拟合结果差不多。类似地,最基本的 PSO 恢复了多项式的前两项。典型的 PSO 仅恢复了第一项,x⁴(或接近)。
Jaya 产生了一个令人兴奋的结果。两个项彼此相互作用,但它们的和成为了数据集的粗略近似。作为练习,试着绘制 –x³,19.36x² 及其和,看看我的意思。
GA 和 RO 都产生了较差的输出。GA 最终得到了一个二次方程,而 RO 无论如何拟合的只是左侧的数据集点,x < –3 左右。
这些结果来自单次运行。我们知道,群体优化算法是随机的,并且每次运行的结果会有所不同。也许我们这样做有点不公平。于是我又进行了五次 Jaya 搜索,以下是得到的等效方程:

结果表明,Jaya 算法并未很好地收敛到局部最小值。它要么捕捉到了数据的* x *⁴特征,要么停留在二次项上。
Jaya 并不是唯一可能被低估的算法。GA 和 RO 在初始运行时产生了较差的结果。如果我们增加群体规模并进行更多迭代,会怎么样呢?直观地说,这些算法可能确实适合这样做。搜索粒子越多,我们就越有可能在搜索空间中找到好的位置,因此 RO 使用较大的群体是有道理的。对于 GA 来说,较大的种群增加了基因库的规模,因此我们也应当能够获得更好的性能,这就像第三章中基因漂变的例子,较大种群能在灾难发生后更好地适应环境。
使用 RO 算法运行 125 个粒子的群体,进行 150,000 次迭代,得到了一个计算公式为 21.54962x²的函数,但这并不令人鼓舞,因为 RO 甚至没有捕捉到数据集的四次项特性。使用 512 个粒子(有机体)运行 GA 算法,进行 30,000 次迭代,并要求所有有机体与表现最好的 20%成员(top=0.2)进行繁殖,得到了图 4-7,其中包含显示拟合结果的图表。

图 4-7:遗传算法对于 512 个有机体和 30,000 代的解决方案
演化出的指令集如下:
push(x)
push(-21.60479)
mul
push(21.92950)
push(-24.20913)
sub
sub
push(-24.96894)
push(-11.43579)
neg
neg
push(-21.53894)
sub
push(x)
mod
mul
push(x)
push(x)
mul
push(23.76431)
mul
add
add
这个函数相当奇怪,使用了所有 22 条可能的指令(初始的push(x)始终存在),这与其他算法得到的结果也有很大不同。等效的函数是
y = 23.76431x² – 21.60479x – 46.13863 – 24.96894(10.10315 mod x)
这种形式更有意义:一个带有额外模运算项的二次函数,从而能够解释在一般二次形式上方出现的奇怪振荡。
拟合正态曲线
前面的例子试图演化一个函数来匹配一个多项式。如果我们改为拟合一个噪声正态(高斯)曲线,会发生什么呢?在加入随机噪声之前,源函数为:

含噪声的数据点在noisy_exp.txt中。
我进行了三次搜索,分别是 DE、简化 PSO 和 Jaya。所有搜索都使用了 25 个粒子和 20,000 次迭代。我将数值限制在[–25, 25]之间,并且对演化出的函数限制了最大指令数为 22 条。
图 4-8 展示了拟合结果。

图 4-8:演化一个函数以拟合噪声正态曲线
DE 和裸骨 PSO 的结果几乎相同,并且重合。它们很好地拟合了数据集。正如我们在其他实验中看到的,Jaya 接近但没有产生像其他算法那样好的拟合。
那么,演化出的函数是什么?在这种情况下,不仅等效的函数是具有示范性的,代码的形式也是如此,因此我们将一起考虑这两者;见 表 4-4。
表 4-4: 按算法比较演化出的程序
| DE | 裸骨 PSO | Jaya |
|---|---|---|
push(x) |
push(x) |
push(x) |
push(x) |
push(x) |
neg |
push(0.35484) |
push(x) |
push(7.95565) |
push(x) |
push(2.80857) |
push(x) |
push(x) |
push(x) |
pow |
mul |
neg |
push(x) |
pow |
push(x) |
neg |
halt |
mul |
pow |
pow |
halt |
|
halt |
演化出的等效函数是 y = 0.35484^(x²)(DE),y = 2.80857^(–x²)(裸骨 PSO),以及 y = 7.95565^(–x²)(Jaya)。再次强调,我们试图从噪声数据中恢复的函数是

我在这里用 e 的前五位数字进行了近似。以这种方式写出来,很明显裸骨 PSO 搜索几乎精确地演化出了这个函数,所以我们应该期望它能够很好地拟合数据。
DE 结果一开始看起来很奇怪。它是一个指数函数,但底数是 0.35484,而不是 e,指数是 x²,而不是 –x²。然而,1/e ≈ 0.36788,这意味着 DE 演化出了与裸骨 PSO 相同的函数,因为

而且 0.35484 相当接近 0.36788。最后,Jaya 有正确的思路,但没有收敛到正确的底数,7.95565 > e。
练习
群体算法的应用非常广泛。以下是你可能希望更详细探索的一些内容:
-
在 curves 目录下,你会找到一个 NIST 目录。它包含了来自美国国家标准与技术研究院(NIST)的示例曲线拟合数据文件,NIST 是美国商务部的一部分。我已经将 .txt 版本进行了格式化,以便它们能够与 curves.py 一起使用。原始版本以 .dat 结尾。
这些是具有挑战性的曲线拟合测试文件,旨在测试高性能的曲线拟合程序。是否有任何群体算法能够接受这一挑战?如果可以,哪些文件能够拟合,哪些无法拟合?
-
Results方法是群体对象的一部分,返回一个字典,正如我们在本章中所看到的那样。我们可以通过使用gbest和giter字典中的值,追踪目标函数值随群体迭代的变化。第一个是每个新的全局最优目标函数值的列表,第二个是标记该值成为全局最优的迭代次数的列表。查看plot_gbest_giter.py中的代码,了解如何绘制这些值以跟踪群体在搜索过程中的学习。使用本章中的示例捕获其他搜索的相应列表,绘制类似的图表。不同的群体是否在相同问题上以相同的速度收敛?
-
gaussian.py文件位于micro目录中,执行群体搜索,旨在最小化一个由两个倒转正态曲线构成的二维函数,即函数为z = f(x, y)。gaussians.png文件展示了该函数的三维图,函数有两个最小值,其中一个比另一个低。由于要最小化的函数有两个输入,因此搜索空间是二维的,这使得可以绘制群体中每个粒子的位置,并在搜索过程中追踪它们的移动。
运行gaussian.py,不带任何参数,以了解如何进行搜索,并输出每一步群体的图像。然后,翻阅这些图像,观察群体如何搜索。已知的最佳位置是空框,群体当前的最佳位置是星号。修改算法,观察它们如何收敛并遍历空间。不同的算法搜索空间的方式一样吗?它们是否收敛到全局最小值,如果是,收敛的速度一样吗?
-
GWO.py文件,也在micro目录下,实现了灰狼优化算法(Grey Wolf Optimizer,GWO)。GWO 是一种流行的群体智能算法,理论上模拟了狼群狩猎时的行为(我并不认同这一点)。
使用gaussian.py测试 GWO,然后将curves.py和gp.py改编为也使用 GWO。只需要简单的复制粘贴。GWO 的表现如何,与 DE、简化版 PSO 和 Jaya 相比怎么样?通常有人声称,GWO 没有可调参数,就像 Jaya 一样。这并不完全正确。
eta参数,默认值为 2,是可以调整的,有时调整它有助于搜索。如果 GWO 表现不好,可以调整eta,例如设置为 3 或 4,再试一次。 -
考虑一下MiCRO.py中的代码,它也位于micro目录下。这个代码实现了一种带有讽刺意味的群体算法,灵感来源于放牧的牛群,我称之为最小意识随机优化(Minimally Conscious Random Optimization)。它旨在展示如何轻松地创建一个“新颖的”,“受自然启发”的群体算法。MiCRO 背后的理念是,群体就像是一群牛,它们在完全无视彼此的情况下,毫无目的地放牧。每次迭代时,以一定的概率,某只动物可能会抬头,考虑另一个位置比它自己的位置更好。如果发生这种情况,这只动物就会跳到那个表现更好的邻居周围的区域,并继续放牧。所以,这个算法是 RO(随机优化),但有一个轻微的概率能够注意到表现更好的邻居;因此,群体是最小意识的。
使用gaussian.py探索 MiCRO 的表现,然后利用RO.py和MiCRO.py中的代码作为指南,发明你自己的群体算法。你的算法有效吗?表现好不好?它真的是受自然启发的吗,还是这种“自然启发”只是事后的一种辩解?
-
gp.py 演化出的代码仅限于算术运算、幂运算和取模运算。请添加正弦、余弦和正切运算符作为可用操作符。每个操作符从栈中取出一个项,并返回一个项到栈中。
尝试拟合 cos.txt、sin.txt 和 tan.txt 数据集,这些文件位于 data 目录下。群体算法能做到吗?
最近的研究表明,GWO(灰狼优化算法)与其他几种流行的自然启发算法完全不新颖,它们不过是将旧的粒子群优化(PSO)思想包装在常常牵强的比喻中。既然如此,难免会有人好奇我为什么会把 GWO 列在这里。本书的重点是实用性和易于应用。GWO 很受欢迎,并且在提供问题解决方案方面表现良好。从这个角度看,是否新颖并不重要。对于更广泛的优化领域,理解什么是新颖的,什么不是,至关重要。我怀疑,最终,许多自然启发算法会证明它们只是对已有方法的另类演绎。但是,如果 GWO 有效,那就有效,所以我们将它保留在我们的小算法集合中,尽管这可能会让真正的优化研究者感到不满。
文件 nature-inspired_algorithms.pdf 列出了几十种自然启发和物理启发的群体优化算法。这个列表并不是详尽无遗的。
总结
本章向我们介绍了群体智能和进化算法。我们使用软件框架开发了两个应用程序:一个是将数据拟合到已知的函数形式,即传统的曲线拟合,另一个是从头开始演化代码以实现最佳拟合函数。我们学习了如何使用框架,并通过探索每种群体算法来发展直觉,了解它们是如何工作的,以及如何最好地应用。每种群体算法都严重依赖于随机性,从粒子初始化配置到每个更新步骤,它们都会在搜索空间中移动粒子。
本章的实验通过定位已知函数形式的最佳参数或从头开始演化函数来拟合数据。两种尝试都取得了成功,尽管并不是每个算法的表现都同样出色。
DE(差分进化)证明非常适合这些任务,进一步证明了它作为常用算法的价值。然而,我很惊讶看到最简单的 PSO 表现得这么好。标准 PSO 并没有那么有效,但它有更多的参数可以调整,因此可能通过一些实验来改进(你可以调整 c[1]、c[2]、ω,即惯性参数的变化以及其他参数)。
Jaya 算法并非完全令人失望,但其表现与我在其他地方使用时相似——既不特别好也不特别差。它有时能够恢复函数形式的本质,但不能恢复其具体细节,即使允许多次迭代。作为最终的例子,我再次使用 Jaya 算法运行了一个搜索,以拟合噪声正态函数,并进行了 120,000 次迭代,是之前的六倍。结果比第一次运行的拟合更差,后者的函数是

该函数看起来与正态曲线有些相似,但并未很好地拟合数据。
严格来说,RO(重置优化)并不是一种群体算法,因为粒子之间并不相互影响。不过,结合这里的示例和在其他领域的经验,RO 在许多情况下仍值得尝试。我们将在第五章中再次遇到 RO。
曲线拟合并不是遗传算法(GA)的强项。我们在第三章中了解到,当模拟自然选择和基因漂变时,遗传算法是有效的。此外,我们几乎没有深入探讨它的一些参数,比如顶级繁殖有机体的比例(top)或特定的变异和交叉概率(分别是F和CR)。
我们还没有结束对群体算法的探讨。让我们暂时抛开曲线和数据集,将群体技术应用到其他领域,比如图像处理,或与仿真相结合。
第五章:群体优化**

群体技术不仅仅用于优化数学函数。在本章中,我们将使用随机性来将圆形填充到正方形中,布置基站,增强图像,以及组织超市的商品陈列。我们将应用与上一章相同的群体智能和进化算法集。
在正方形中填充圆
一个经典的数学问题涉及将相同直径的圆放入一个正方形。等价的表述是,对于给定数量的点,找到单位正方形([0, 1])中位置,使得任意两点之间的最小距离尽可能大。这些点的位置对应于最佳填充圆的圆心。例如,在哪个位置放置两个点,才能使它们之间的距离最大?在对角的两个角落。此时,点之间的距离是
,而且无法再更大。
那么三个点呢?四个点呢?十七个点呢?现在答案就不那么明显了。我们可能会采用 Locatelli 和 Raber 在 2002 年的论文《在正方形中填充相同大小的圆:一种确定性全局优化方法》中详细描述的复杂算法来处理这个问题,但我们在这里不会这样做。相反,我们将使用群体搜索中的随机性。我们需要将某个多维空间中的位置映射到候选解,然后在这个空间中搜索最佳解。
如果我们有 n 个点,并且想要知道 n 个圆心的坐标,这些圆心之间的每一对距离尽可能远,同时仍在 [0, 1] 范围内,我们需要找到 n 个点。刚开始时,我们可能认为这是一个 n 维的问题。然而,实际的维度是 2n:我们需要 x 和 y 坐标来确定一个点。我们知道搜索的边界是每个维度都在 [0, 1] 之间。因此,我们将使用由 [0, 1] 限制的 2n 维向量来表示粒子,其中每一对组件是一个点,(x, y)。换句话说,如果我们要找五个点,每个粒子就是一个 10 元素的向量:
p = (x[0], y[0]; x[1], y[1]; x[2], y[2]; x[3], y[3]; x[4], y[4])
要运行搜索,我们需要问题的维度和界限,我们现在已经有了这两个信息。唯一剩下的问题是目标函数,它告诉我们每个粒子位置所代表的解有多好。问题的描述为我们指引了方向:我们需要最大化任意两点之间的最小距离。如果有五个点,我们计算每一对可能的点之间的距离,找出最小的那个距离,然后返回相反值。我们的框架只支持最小化,因此为了最大化,我们返回负值。将对的最小距离变为负数后,它就变成了最负的数。
群体搜索
我们需要的代码在 circles.py 中。考虑放下书本,通读一下文件以理解代码流程。理解之后,我们就可以从目标函数类开始:
class Objective:
def __init__(self):
self.fcount = 0
def Evaluate(self, p):
self.fcount += 1
n = p.shape[0]//2
xy = p.reshape((n,2))
dmin = 10.0
for i in range(n):
for j in range(i+1,n):
d = np.sqrt((xy[i,0]-xy[j,0])**2 + (xy[i,1]-xy[j,1])**2)
if (d < dmin):
dmin = d
return -dmin
构造函数只是初始化 fcount,它用来统计 Evaluate 被调用的次数。Evaluate 方法接收一个位置向量(p),它立即被重塑为一组 (x, y) 对(xy)。
Evaluate 中的第二段代码会遍历 xy 中每一对点,并计算它们之间的欧几里得距离。如果该距离是目前找到的最小距离,我们就将其保存在 dmin 中。我们希望最大化任意两点之间的最小距离,因此我们首先找到粒子位置表示的任意两点之间的最小距离。
最后一行返回 dmin 的负值。由于框架是为了最小化而设计的,返回最小的成对距离的负值就迫使框架最大化这个最小距离——这正是我们想要的。
现在我们已经具备了实现搜索所需的一切。circles.py 的主体遵循标准方法,从命令行获取值并设置框架对象,然后调用 Optimize 执行搜索。
在代码中,主要步骤如下:
rng = RE(kind=kind)
b = Bounds([0]*ndim, [1]*ndim, enforce="clip", rng=rng)
i = RandomInitializer(npart, ndim, bounds=b, rng=rng)
obj = Objective()
swarm = PSO(obj=obj, npart=npart, ndim=ndim, init=i,
bounds=b, max_iter=niter, bare=True, rng=rng)
swarm.Optimize()
res = swarm.Results()
我们创建了所需的随机引擎,接着是边界、初始化器和目标函数实例。请注意,目标函数不需要任何附加信息。
swarm 对象,配置为基础的 PSO 搜索,后面跟着 Optimize 和 Results。没有显示的是报告最佳点集及其之间距离的代码,然后将所有搜索结果(包括点的位置简单图示)转储到提供的输出目录。
尝试在没有命令行选项的情况下运行 circles.py,看看它期望什么。现在我们已经准备好了,就来使用它吧。
代码
让我们来打包一些圆圈。我创建了两个 Shell 脚本,go_circle_results 和 go_plots。前者运行 circles.py,用于 2 到 20 个圆圈和 7 种算法:基础的 PSO、标准 PSO、DE、GWO、Jaya、RO 和 GA。输出结果存储在 results 目录中。我建议你在晚上启动它,第二天早上再回来查看,因为这个框架是为了清晰设计的,而非速度。用以下命令运行:
> sh go_circle_results
当 go_circle_results 完成后,执行 go_plots 来生成一系列图表,显示每个算法定位的圆圈配置。我的结果在 表 5-1 中,不过由于群体搜索的随机性,你的结果会有所不同。
表 5-1: 每个算法已知和找到的最大中心距离
| n | 已知 | 基础 | DE | PSO | GWO | Jaya | RO | GA |
|---|---|---|---|---|---|---|---|---|
| 2 | 1.4142 | 1.4142 | 1.4142 | 1.4142 | 1.4142 | 1.4142 | 1.4142 | 1.4134 |
| 3 | 1.0353 | 1.0353 | 1.0353 | 1.0353 | 1.0353 | 1.0353 | 1.0301 | 1.0264 |
| 4 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 0.9998 | 0.9969 |
| 5 | 0.7071 | 0.7071 | 0.7070 | 0.6250 | 0.7025 | 0.7071 | 0.6796 | 0.6052 |
| 6 | 0.6009 | 0.5951 | 0.5953 | 0.5995 | 0.5988 | 0.5858 | 0.5723 | 0.5884 |
| 7 | 0.5359 | 0.5359 | 0.5223 | 0.5000 | 0.5072 | 0.5176 | 0.5000 | 0.4843 |
| 8 | 0.5176 | 0.5090 | 0.5045 | 0.5000 | 0.5002 | 0.4801 | 0.4661 | 0.4355 |
| 9 | 0.5000 | 0.5000 | 0.4202 | 0.5000 | 0.4798 | 0.5000 | 0.4421 | 0.4470 |
| 10 | 0.4213 | 0.4147 | 0.3697 | 0.4195 | 0.4187 | 0.3517 | 0.3788 | 0.3819 |
| 11 | 0.3980 | 0.3978 | 0.3296 | 0.3694 | 0.3895 | 0.3918 | 0.3588 | 0.3787 |
| 12 | 0.3887 | 0.3726 | 0.2989 | 0.3717 | 0.3289 | 0.3819 | 0.3496 | 0.3542 |
| 13 | 0.3660 | 0.3595 | 0.2752 | 0.3333 | 0.3277 | 0.2832 | 0.3212 | 0.3230 |
| 14 | 0.3451 | 0.3354 | 0.2537 | 0.3333 | 0.3116 | 0.3435 | 0.3037 | 0.3204 |
| 15 | 0.3372 | 0.3256 | 0.2303 | 0.3333 | 0.3278 | 0.2437 | 0.2949 | 0.2995 |
| 16 | 0.3333 | 0.2996 | 0.2269 | 0.2500 | 0.3011 | 0.2220 | 0.2760 | 0.2761 |
| 17 | 0.3060 | 0.2985 | 0.2062 | 0.2913 | 0.2952 | 0.1992 | 0.2658 | 0.2721 |
| 18 | 0.3005 | 0.2782 | 0.1927 | 0.2808 | 0.2703 | 0.2126 | 0.2516 | 0.2493 |
| 19 | 0.2900 | 0.2697 | 0.1852 | 0.2500 | 0.1905 | 0.1731 | 0.2384 | 0.2559 |
| 20 | 0.2866 | 0.2632 | 0.1789 | 0.2500 | 0.2419 | 0.1659 | 0.2200 | 0.2342 |
表 5-1 显示了点之间的已知最佳距离以及群体搜索得到的距离,按算法分类。这些数值将成为我们的黄金标准。
对于 n < 10,许多距离可以通过几何推导得到,正如 表 5-2 所示。
表 5-2: 已知圆心距离
| n | 距离 |
|---|---|
| 2 | ![]() |
| 3 | ![]() |
| 4 | 1 |
| 5 | ![]() |
| 6 | ![]() |
| 7 | ![]() |
| 8 | ![]() |
| 9 | 0.5 |
| 10 | 0.42127954 |
这些表达式来自 Croft、Falconer 和 Guy 的《几何中的未解问题》(Springer,1991 年)中的表 D1。由 go_plots 调用的 plot_results.py 文件使用该表生成显示打包圆形的图表。如果打包是最优的,圆形几乎不会接触。否则,圆形之间会有间隙或重叠。
检查 表 5-1 可以发现 2 到 4 个圆是直接的;每个算法都找到了最佳排列。对于 5 个圆,基本的 PSO、DE 和 Jaya 都达到了收敛解。我们不会在已知距离与差分进化解之间的一万分之一的差异上争辩。
群体在 5 个圆之后开始出现问题。对于 6 个圆,虽然没有任何群体精确到四位小数,但有几个方法非常接近。 图 5-1 展示了每个算法的输出图表。

图 5-1: 打包 6 个圆圈。从左上到右:PSO,GWO,DE,简化版 PSO,GA,RO 和 Jaya。
尽管解决方案在圆心距离方面是独特的,但在旋转方面并不独特。标准的 PSO、DE 和 GWO 结果本质上是相同的,只是在某些情况下旋转了 90 度。
通过圆圈的数量查看go_plots生成的所有图形。随着n的增大,群体越发挣扎,但仍然有一些不错的n值,例如n = 9,在这种情况下群体更有可能找到高度对称的解决方案。由于我们在打包一个正方形,n的值是完全平方数—如 4、9 和 16—因此可以得到对齐良好的打包。然而,只有少数算法找到了理想的n = 9 输出,且没有任何算法找出最好的n = 16 结果。
让我们继续解决一个更实际的问题。
放置手机塔
放置手机塔并非一种学术练习;涉及到实际的效用和成本。在这一部分,我们将实验一个(简化版的)手机塔位置问题。
我们的输入是一组手机塔,每个塔有不同的有效范围,以及一张显示手机塔可以放置位置的掩膜。输出是一个位置集合,指示应该放置指定塔的位置以最大化覆盖范围。
我们将使用的代码在cell.py中。它与其他群体实验的结构相同,但稍微更高级,因为评估粒子位置需要检查是否存在非法的塔位置并构建图像。目标函数类的Evaluate方法更为复杂,但仍然接受一个粒子位置并返回一个评分,较低的评分意味着更好的解决方案。
我将概述攻击计划;然后我们将逐步浏览代码的关键部分,再进行一些实验。
群体搜索
我们需要将一定范围内的数字向量转换为可能的解决方案。我们将使用手机塔和地图,告诉我们可以在哪里放置这些塔。让我们从表示塔和地图开始。
手机塔向所有方向辐射,因此我们将它们表示为圆圈,圆的直径表示塔的强度,圆心表示塔的位置。并非所有塔的强度都相同。我们用一个(0, 1]范围内的浮动数值来指定一个塔;具体方法稍后会明确。
这些地图是灰度图像。如果一个像素的值为 0,则该像素是一个可能的塔位置;如果像素值为 255,则该位置不可用。我将一组地图放在maps目录中。你可以在任何图形程序中制作自己的地图;使用 255 标记塔无法放置的区域,0 则表示其他区域。地图不必是正方形。需要注意的是,地图越大,搜索越慢,这也是为什么提供的地图较小。
塔的大小是地图最大维度的一半的分数。例如,给定的地图大小是每边 80 像素。因此,一个大小为 0.1 的塔的直径为 0.1 × 40 = 4 像素,而一个 0.6 塔的直径为 0.6 × 40 = 24 像素。塔的位置存储在一个文本文件中,每行一个数字,行数表示塔的数量。查看 towers 目录中的文件,你会明白我的意思。
粒子位置代表塔的位置。如果有 n 个塔,我们需要 2n 维的粒子,就像我们在打包圆形时一样。粒子位置中的每两个元素是一个塔的中心位置。指定塔的顺序与粒子元素的顺序一一对应。例如,towers0 有六行,表示六个塔:0.1、0.2、0.3、0.4、0.5、0.6。因此,使用 towers0 进行搜索时,涉及到的是 12 维的粒子。
(x[0], y[0]; x[1], y[1]; x[2], y[2]; x[3], y[3]; x[4], y[4]; x[5], y[5])
其中,(x[0], y[0]) 是 0.1 塔的位置,(x[1], y[1]) 是 0.2 塔的位置,依此类推。
图 5-2 显示了默认地图。

图 5-2:默认地图
第一张地图是空白的,没有禁区。其他地图上标记的区域对塔是禁止进入的。可以把这些区域想象成道路、停车场、湖泊等。
我们有塔和地图。我们知道如何表示塔的位置和大小。那么,如何将塔、地图和位置结合起来得到评分呢?我们希望通过将塔放置在合法位置,尽可能多地覆盖地图。因此,我们希望最小化没有被塔覆盖的合法地图像素的数量;我们希望在放置塔后,剩下的零像素尽可能少。这听起来像是目标函数的工作。
对于给定的粒子位置,目标函数需要判断是否有任何提出的塔中心位于非法位置。如果即使一个塔的位置不合法,目标函数将通过立即返回 1.0 的评分(最大可能值)来拒绝整个配置,意味着地图没有任何被覆盖。
如果所有提出的塔位置都是合法的,那么就可以计算覆盖情况了。未覆盖像素的数量除以地图中的像素总数,得出一个[0, 1]区间的值,其中 0 表示所有像素都已覆盖。这个值越低,覆盖效果越好。
我选择的方法从一张与地图图像大小相同的空图开始。我们通过将每个塔覆盖的像素添加到当前像素值中,来向图像中添加塔。
通过这种方式添加像素有两个目的:首先,最初为空的图像中,所有在塔覆盖区域内仍为 0 的像素将被暴露;其次,逐个塔添加像素,构建出一个易于理解的图像。我们将能够清楚地看到每个塔及其覆盖的区域,包括塔重叠的区域。
总结一下,对于给定的粒子位置,我们:
-
将塔的坐标转换为一组点,就像我们之前为打包圆形所做的那样。
-
如果任何塔的中心落在地图的非法区域,则返回 1.0 作为得分。
-
将每个塔添加到最初为空的图像数组中,包括所有被覆盖的像素,如果所有塔的中心都被允许的话。
-
返回未覆盖像素的数量与总像素数之比作为得分。
这些步骤将粒子位置映射到解决方案,从而生成一个表示解决方案质量的单一数字。
图 5-3 展示了左侧的输入地图和使用六个塔(位于towers0)通过经典粒子群搜索生成的输出结果,右侧为塔的放置情况。

图 5-3:输入掩膜(左)和生成的塔放置结果(右)
塔的重叠仅轻微,且没有塔中心位于被遮挡的区域。请查看example目录中的文件,查看这些图像的更详细内容。
让我们回顾一下cell.py中的关键部分,以了解这些步骤是如何被转换为代码的。
代码
cell.py中的代码相对复杂。在继续之前,花一些时间好好研究这个文件。
最重要的代码部分是目标函数类及其相关代码;请参见列表 5-1。
class Objective:
def __init__(self, image, towers, radius):
self.image = image.copy()
self.R, self.C = image.shape
➊ self.radii = (towers*radius).astype("int32")
self.fcount = 0
def Collisions(self, xy):
n = 0
for i in range(xy.shape[0]):
x,y = xy[i]
if (self.image[x,y] != 0):
n += 1
return n
def Evaluate(self, p):
self.fcount += 1
n = p.shape[0]//2
➋ xy = np.floor(p).astype("uint32").reshape((n,2))
if (self.Collisions(xy) != 0):
return 1.0
empty = np.zeros((self.R, self.C))
➌ cover = CoverageMap(empty, xy, self.radii)
zeros = len(np.where(cover == 0)[0])
uncovered = zeros / (self.R*self.C)
return uncovered
列表 5-1:目标函数类
构造函数存储地图(image)、塔的向量(towers)和radius,即地图图像最大维度的一半。这设置了最大的塔范围;例如,如果塔的范围为 1,那么表示塔的圆形的半径就是radius像素,即地图图像的高度或宽度的一半,以较大的为准。在内部,radii是一个表示塔半径的像素向量 ➊。
Evaluate方法首先将粒子位置向量重塑为(x, y)点,正如我们在circles.py中做的那样。在这种情况下,我们需要的是像素坐标,因此floor确保点的值为整数 ➋。
Collisions方法首先检查任何提议的塔中心是否位于禁区内。这是一个简单的地图图像查询。如果中心像素不是 0,则计为一次碰撞。如果发生任何碰撞,则返回 1.0 的得分,表示所有像素都没有被覆盖。
假设没有碰撞,现在是放置塔并计算得分的时候了。创建一个与地图图像大小相同的empty图像,并将其与塔的中心(xy)和半径(radii)一同传递给CoverageMap ➌。返回值cover是一个类似于图 5-3 右侧的图像,但没有适当的缩放到[0, 255]—它是一个浮点数组。如果cover的某个元素为 0,意味着该元素不在任何塔的覆盖范围内,因此我们使用 NumPy 的where函数计算该元素的数量,并将其除以地图中的像素总数来计算得分。
CoverageMap 方法不属于 Objective 类,因为它在 cell.py 中的其他地方使用。然而,它对于代码的成功至关重要,因此让我们详细了解一下它(清单 5-2)。
def CoverageMap(image, xy, radii):
im = image.copy()
R,C = im.shape
➊ for k in range(len(radii)):
x,y = xy[k]
➋ for i in range(x-radii[k],x+radii[k]):
for j in range(y-radii[k],y+radii[k]):
if ((i-x)**2 + (j-y)**2) <= (radii[k]*radii[k]):
if i < 0 or j < 0:
continue
if i >= R or j >= C:
continue
im[i,j] += 0.5*(k+1)/len(radii)
imax = im.max()
➌ for k in range(len(radii)):
x,y = xy[k]
im[x,y] = 1.4*imax
return im
清单 5-2:为一组塔位置生成覆盖图
CoverageMap 方法接受地图图像、塔的中心位置和塔的半径作为输入。其目标是填充 im。当前传递一个空图像给 CoverageMap 似乎有些奇怪,但稍后的函数调用会传递地图图像本身。
塔依次应用,中心位置为 (x, y) ➊。 (低效的) 双重循环 ➋ 检查地图中每一个可能位于当前塔范围内的像素。内循环的主体检查当前像素 (i, j) 是否在当前塔的磁盘范围内(if 语句)。如果是,并且 (i, j) 像素在图像空间内,则根据以下公式递增当前像素值
0.5(k + 1)/n
其中 n 是塔的数量。这个方程通过每个塔的特定量递增像素值(im 是一个浮动点数组)。结果会导致图 5-3 的右侧,其中每个磁盘具有不同的强度,并且重叠部分是可见的。
在所有塔添加后,一个最终的循环会添加每个塔的中心点 ➌。中心点的值总是最大像素值强度的 1.4 倍,以使中心点可见(最好在计算机屏幕上查看)。由于 im 是一个浮动点数组,它不受 [0, 255] 的限制。缩放将在稍后的代码中进行,当输出图像写入磁盘时。
CoverageMap 方法返回一个二维数组,其中任何剩余的零值表示没有被任何塔覆盖的像素。零值的数量与像素总数的比例即为给定塔位置的最终得分。
cell.py 的主体形式上与 circles.py 相似:解析命令行,创建框架对象并执行搜索。然而,搜索不是通过调用 Optimize 来执行的,而是通过反复调用 Step,以便可以在每次迭代中显示当前最佳得分。
搜索配置如同在清单 5-3 中所示。
rng = RE(kind=kind)
x,y = map_image.shape
lower = [0,0]*len(towers)
upper = [x,y]*len(towers)
b = Bounds(lower, upper, enforce="resample", rng=rng)
ndim = 2*len(towers)
w = x if (x>y) else y
radius = w//2
i = RandomInitializer(npart, ndim, bounds=b, rng=rng)
obj = Objective(map_image, towers, radius)
swarm = DE(obj=obj, npart=npart, ndim=ndim, init=i, bounds=b,
max_iter=niter, tol=1e-9, rng=rng)
清单 5-3:配置搜索
这里 radius 设置了任何塔的最大半径。
搜索本身是一个循环(清单 5-4)。
k = 0
swarm.Initialize()
while (not swarm.Done()):
swarm.Step()
res = swarm.Results()
t = " %5d: gbest = %0.8f" % (k,res["gbest"][-1])
print(t, flush=True)
k += 1
res = swarm.Results()
清单 5-4:运行搜索
Initialize 方法配置群体,Done 在搜索完成时返回 True(所有迭代完成或达到容忍度),而 Step 执行群体的一个更新(它像总部一样工作)。
每次迭代都会调用Results方法来报告当前最佳值——未被塔楼覆盖的图像像素的比例。循环退出后,最后一次调用Results会返回最佳的塔楼位置集。附加的代码捕获每次迭代的输出,并生成最优配置的覆盖图。请参阅命令行中的frames选项。
最终,覆盖图被生成并存储在输出目录中,如列表 5-5 所示。
p = res["gpos"][-1]
n = p.shape[0]//2
xy = p.astype("uint32").reshape((n,2))
radii = (towers*radius).astype("int32")
cover = CoverageMap(map_image, xy, radii)
c2 = (cover/cover.max())**(0.5)
c2 = c2/c2.max()
img = Image.fromarray((255*c2).astype("uint8"))
img.save(outdir+"/coverage.png")
列表 5-5:生成覆盖图
群体的最佳位置(p)被转换为一组(x, y)点,并与塔楼的相应半径一起传递给CoverageMap,同时还传入输入地图本身(map_image)。与目标函数中的Evaluate方法不同,这里传递的是带有遮罩区域的地图,而不是空白图像。
生成的覆盖图(cover)会通过平方根函数进行处理,以压缩强度,然后转换为灰度图像(img),并写入输出目录。
由于需要,我们跳过了cell.py中的代码,但仔细阅读文件后,这些部分会变得清晰。现在,让我们看看cell.py能做什么。
在没有参数的情况下运行cell.py可以展示如何使用它:
> python3 cell.py
cell <map> <towers> <npart> <niter> <alg> <kind> <outdir> [frames]
<map> - map image (.png)
<towers> - text file w/towers and ranges
<npart> - number of swarm particles
<niter> - number of swarm iterations
<alg> - DE|RO|PSO|BARE|GWO|JAYA|GA
<kind> - randomness source
<outdir> - output directory (overwritten)
frames - 'frames' ==> output frame per iteration
让我们使用map_01和towers0来运行代码。例如:
> python3 cell.py maps/map_01.png towers/towers0 20 100 ga pcg64 test frames
我们使用 20 个粒子的 GA 进行 100 次迭代,并将输出结果存储到名为test的目录中。frames关键字会输出每次迭代时的当前最佳塔楼位置,这样我们就可以直观地追踪群体的演变。
请注意,100 次迭代相对于我们在circles.py中使用的 10,000 次或更多的迭代来说并不多。生成覆盖图的所有操作都需要时间,因此运行 10,000 次迭代是不可行的。幸运的是,我们通常只需要几百次迭代。
在cell.py运行时,它会输出当前群体最佳分数,并在搜索结束时给出总结。输出目录中包含README.txt文本文件,其中包含原始地图图像(map.png)和最终的覆盖图(coverage.png)。如果你想更详细地查看群体演化,Python 的pickle文件(.pkl)包含了群体对象。输出目录中还包含一个frames目录,里面保存了每次迭代时群体最佳配置的图像。翻阅这些文件,你可以看到群体如何演化。
我的运行最终得到了一个覆盖值为 0.358,这意味着大约 36%的地图没有被塔楼覆盖,具体情况见图 5-4。

图 5-4:塔楼放置
towers0中的所有六个塔楼在输出中都可见。塔楼重叠非常少,这是一个好兆头。每个塔楼的中心避免了遮罩区域。
要实验 cell.py,请运行 shell 脚本 go_tower_results、go_towers 和 go_towers0。第一个脚本将 towers0 应用到所有样本地图,使用所有算法。第二个脚本将所有塔楼文件和算法应用于 map_02。最后,第三个脚本将每个塔楼文件应用于 map_00,仅使用基础的 PSO 来演示当没有障碍物时群体是如何放置塔楼的。
运行 go_results 和 go_towers,然后运行 make_results_plot.py 和 make_towers_plot.py,生成包含所有结果的图像文件,每一行显示不同群体算法的输出。
最终脚本 go_towers0 生成了在图 5-5 中看到的输出。

图 5-5:在默认地图上放置塔楼
它在空白地图上对每个塔楼文件运行基础 PSO。请注意,towers0 无法完全覆盖地图,但结果位置不会重叠,这意味着基础 PSO 找到了一个最优配置(多个配置中的一个)。towers3 的输出也差不多,小塔楼与大塔楼没有重叠。
将这些结果与 towers1 和 towers2 的结果进行对比。是否能够立刻看出 towers1 是否能够完全覆盖地图,尚不清楚,但 towers2 确实能够——然而,地图的某些小部分仍然未被覆盖。我怀疑运行超过 300 次迭代会解决这个问题。它能解决吗?在使用带有遮蔽区域的地图和空白地图时,运行时间有差别吗?如果有,为什么会这样?
随意尝试不同的自定义地图和塔楼的数量及大小,看看是否能找到一个最佳或更具实用性的塔楼大小组合。哪个效果最好,许多小塔楼还是几个大塔楼?
让我们使用随机性来实现一个“make it pretty”图像滤镜。
图像增强
当我在手机图库中查看自己拍摄的照片时,我会被提供一个重新制作图片的选项;我称之为“make it pretty”滤镜。在这一节中,我们将使用群体搜索来实现一个针对灰度图像的“make it pretty”滤镜。
我们的滤镜基于一种在学术文献中经常出现的滤镜。这是一个很好的例子,展示了将现有论文稍作修改后,作为新技术发表的趋势。快速回顾文献发现有八篇论文都在实施这种方法,只对优化算法做了微小的调整:PSO、萤火虫算法、布谷鸟搜索、差分进化、PSO、布谷鸟、布谷鸟和差分进化,依次类推。虽然我们的实现只是这条光辉研究路线中的又一篇,但我以教学为借口,并不依赖新颖性或适用性来进行辩护。
抛开免责声明不谈,"make it pretty" 滤镜将一个局部图像增强函数应用于输入的灰度图像,以使其看起来更漂亮。如果这句话听起来像泥一样模糊,不用担心——我会解释清楚的。
我们打算对输入图像的每个像素应用一个函数,生成一个新的输出像素。让我们应用这个函数。

到像素 i 行和 j 列的位置;也就是 g[ij]。我们需要找到 a、b、m 和 k 来使图像看起来尽可能好。
G 是原始图像的均值强度,即通过将所有像素强度相加并除以像素数量得到的值。µ 和 σ 变量分别是当前像素 g[ij] 周围 3×3 区域的均值 (µ) 和标准差 (σ)。可以将其想象成一个井字棋(圈叉游戏)板,如 图 5-6 所示。

图 5-6:像素偏移
3×3 区域在图像上滑动,访问每个像素,计算 µ 和 σ,然后使用 方程 5.1 来创建新的像素值,
。请注意,
是输出图像中 ij 像素的值;它并不更新原始图像中的像素 g[ij]。
用于更新图像像素的函数有四个参数我们需要找到,以及依赖于原始图像和我们如何应用函数的其他值。然而,函数仅告诉我们如何根据给定的 a、b、m 和 k 来更新图像;它并没有说明输出图像在人眼观察者看来有多美观。为了做到这一点,我们需要一个目标函数。
研究人员声称,以下函数捕捉了让图像看起来让人愉悦的某些因素:

我们将使用 F 作为目标函数。F 的值越高,图像对于人眼观察者的效果就越好——至少理论上是这样。I 是输入图像经过边缘检测处理后的像素强度之和。edgels 变量是边缘检测版本中高于阈值(此处为 20)的边缘数量。最后,r 和 c 是图像的维度(行和列),h 是图像的熵:

这里 p[i] 是 64-bin 直方图中第 i 个区间的像素强度概率。熵在这个意义上指的是图像的信息量。
这个练习表面上似乎类似于曲线拟合。我们有一个带有需要优化的参数的函数,但应用该函数的算法包含更多步骤。然而,正如我们很快将了解到的那样,这些额外的步骤对我们的群体算法几乎没有影响。它们仍然提供浮动点向量给目标函数,并期待返回一个标量质量度量值。群体算法对它们正在优化的内容浑然不觉。
增强函数
我们有一个四参数优化问题:对于给定的图像,我们希望找到最佳的 a、b、m 和 k 值,这些值都是浮动点数。从框架的角度来看,一旦我们确定了参数的边界,设置过程是直接的。所有的酷炫代码都将在目标函数类中。完整程序位于 enhance.py。
我们从列表 5-6 开始,这是目标函数类,它实现了之前给出的精细图像增强和F函数。
class Objective:
def __init__(self, img):
self.img = img.copy()
self.fcount = 0
def F(self, dst):
r,c = dst.shape
Is = Image.fromarray(dst).filter(ImageFilter.FIND_EDGES)
Is = np.array(Is)
edgels = len(np.where(Is.ravel() > 20)[0])
h = np.histogram(dst, bins=64)[0]
p = h / h.sum()
i = np.where(p != 0)[0]
ent = -(p[i]*np.log2(p[i])).sum() F = np.log(np.log(Is.sum()))*(edgels/(r*c))*ent
return F
def Evaluate(self, p):
self.fcount += 1
a,b,m,k = p
dst = ApplyEnhancement(self.img, a,b,m,k)
return -self.F(dst)
列表 5-6:图像增强目标函数类
类构造函数存储了原始图像的副本。Evaluate方法从提供的粒子位置中提取a、b、m和k,将它们和原始图像传递给ApplyEnhancement,返回一个新的图像dst。我们将新图像传递给F方法以计算分数。由于我们希望最大化F,因此返回其负值。
我会稍后解释ApplyEnhancement;现在我们先专注于F。该方法是线性的,并且大量使用了 NumPy 和 PIL(Image和ImageFilter)提供的强大函数。在这种情况下,逐行分析是有意义的。
首先,我们提取图像的维度(r、c)。在下一行,我们对图像应用边缘检测滤波器,生成Is作为dst的输出。边缘检测器的输出类似于图 5-7。

图 5-7:边缘检测器的作用
我们将 PIL 图像Is重新转换为 NumPy 数组,然后使用where来统计边缘像素大于 20 的数量。我们选择 20 是因为这是一个经验选定的阈值,效果很好。统计结果存储在edgels中。
目前唯一未确定的F部分是熵h,这里表示为ent。为了获得这一点,我们首先需要图像的直方图,使用 64 个箱子,这可以通过 NumPy 的histogram函数在一行代码内便捷地获得。通过将直方图按所有箱子的总和进行缩放,可以将每个箱子的计数转换为每个箱子的概率估计p。
得到p后,我们通过将概率乘以概率的对数(以 2 为底)来计算熵(ent)。F中的倒数第二行直接对应于F的方程,返回其值。
接下来是列表 5-7 中的ApplyEnhancement。
def ApplyEnhancement(g, a,b,c,k):
def stats(g,i,j):
rlo = max(i-1,0); rhi = min(i+1,g.shape[0])
clo = max(j-1,0); chi = min(j+1,g.shape[1])
v = g[rlo:rhi,clo:chi].ravel()
if len(v) < 3:
return v[0],1.0
return v.mean(), v.std(ddof=1)
rows,cols = g.shape
dst = np.zeros((rows,cols))
G = g.mean()
for i in range(rows):
for j in range(cols):
m,s = stats(g,i,j)
dst[i,j] = ((k*G)/(s+b))*(g[i,j]-c*m)+m**a
dmin = dst.min()
dmax = dst.max()
return (255*(dst - dmin) / (dmax - dmin)).astype("uint8")
列表 5-7:将一组参数应用于图像
我们通过应用方程 5.1 来增强原始图像。输出图像(dst)是逐像素构建的,使用局部的 3×3 区域均值(m)和标准差(s),结合参数a、b、c和k。请注意,c在方程 5.1 中是m。
辅助函数stats定义了围绕(i, j)的 3×3 区域,考虑了图像的索引限制。然后,它返回均值和标准差。if语句处理了像素太少以至于无法进行有意义标准差计算的边界情况。注意在调用std时的ddof关键词。默认情况下,NumPy 通过除以数值的数量来计算偏差估计的方差,而不是通过除以比数值数量少 1 的数量来计算无偏估计。许多统计软件包默认使用无偏估计。在大多数情况下,特别是当数据集中的值少于 20 个时,我们希望使用无偏估计,因此设置ddof=1。回想一下,标准差是方差的平方根。
剩下的就是配置搜索,具体见 Listing 5-8。
orig = np.array(Image.open(src).convert("L"))
img = orig / 256.0
ndim = 4
rng = RE(kind=kind)
b = Bounds([0.0,1.0,0.0,0.5], [1.5,22,1.0,1.5], enforce="resample", rng=rng)
i = RandomInitializer(npart, ndim, bounds=b, rng=rng) obj = Objective(img)
swarm = GWO(obj=obj, npart=npart, ndim=ndim, init=i, bounds=b, max_iter=niter, rng=rng)
Listing 5-8: 配置搜索
配置遵循我们的框架:随机源(rng)、边界(b)、初始化器(i)、目标函数(obj)和swarm对象,这里是GWO。我们强制将输入图像(orig)转换为灰度图像(convert),并将其按 256 进行缩放,使其位于 [0, 1) 范围内(img)。在这个范围内操作图像比在 [0, 255] 范围内更为常见。操作完成后,图像将被缩放回 [0, 255],并转换为整数类型,最后写入磁盘。
搜索是四维的(ndim),所以有四个边界。每个维度的边界不同。边界 a ∈ [0, 1.5],b ∈ [1.0, 22],m ∈ [0, 1],k ∈ [0.5, 1.5] 是基于文献中的值。正如我们将看到的,它们似乎效果很好,但可以尝试调整它们,特别是如果你注意到输出值接近边界时。我们很快就会学会如何在搜索后找到这些值。
运行搜索就像调用Optimize方法对swarm对象进行操作那样简单,但为了在过程中跟踪F评分,我们将手动循环执行,而不是直接调用(见 Listing 5-9)。
k = 0
swarm.Initialize()
while (not swarm.Done()):
swarm.Step()
res = swarm.Results()
t = " %5d: gbest = %0.8f" % (k,res["gbest"][-1])
print(t, flush=True)
s += t+"\n"
k += 1
res = swarm.Results()
pickle.dump(res, open(outdir+"/results.pkl","wb"))
a,b,m,k = res["gpos"][-1]
dst = ApplyEnhancement(img, a,b,m,k)
Image.fromarray(dst).save(outdir+"/enhanced.png")
Image.fromarray(orig).save(outdir+"/original.png")
Listing 5-9: 运行搜索
搜索在所有指定的迭代次数后结束。然后,我们通过pickle将最终结果(res)输出到目标目录。使用gpos键返回最终的参数集合。最后,使用最佳参数集对图像进行增强,并将其与原始图像一起写入输出目录以便进行对比。
enhance.py 是否有效?让我们来找出答案。
代码
运行enhance.py,不带参数,以了解它在命令行中需要哪些输入:
> python3 enhance.py
enhance <src> <npart> <niter> <alg> <kind> <output>
<src> - source grayscale image
<npart> - number of particles
<niter> - number of iterations
<alg> - BARE,RO,DE,PSO,JAYA,GWO,GA
<kind> - randomness source
<output> - output directory (overwritten)
我们需要提供原始图像、群体大小、迭代次数、算法类型、随机性源和输出目录名称。
images 目录包含一组 128×128 像素的灰度图像,我们将用它们进行实验。考虑到框架的顺序性质和每次调用目标函数时需要进行的大量图像操作,搜索速度并不是特别快,因此较小的图像效果最好。程序也能处理较大的图像,它们不必是正方形;唯一需要的是耐心。
尝试这个命令行:
> python3 enhance.py images/barbara.png 10 60 gwo minstd babs
0: gbest = -4.77187094
1: gbest = -4.80063898
2: gbest = -5.09058855
3: gbest = -5.09058855
--snip--
输出显示了每次迭代的当前群体最佳F分数。由于我们想最大化F,所以该值是负数。命令行指定了 GWO,群体为 10 个粒子,60 次迭代,输出目录名为babs。
在我的系统上,搜索以以下输出结束:
59: gbest = -6.09797382
Search results: GWO, 10 particles, 60 iterations
Optimization minimum -6.09797382 (time = 216.419)
(14 best updates, 610 function evaluations)
因此,最佳参数集在 14 次群体最佳更新后得到了F = 6.09797。babs输出目录包含:
enhanced.png
original.png
README.txt
results.pkl
给我们提供了增强后的图像、原图、pickle 格式的结果以及一个 README 文件,其中包含在搜索过程中生成的所有输出。
增强后的图像应该看起来更清晰,具有更好的对比度,比原图更好。不幸的是,打印版本可能无法清晰显示这些差异。不过,图 5-8 展示了两张图片,左边是原图,右边是增强版。

图 5-8:原图与增强图像
特别注意书架上的书。它们的定义更加清晰,显示了改善的对比度。
要提取增强参数,加载results.pkl文件。
> python3
>>> import numpy as np; import pickle
>>> res = pickle.load(open("babs/results.pkl","rb"))
>>> res["gpos"][-1]
array([0.01867829, 1.00785356, 0.45469097, 1.1731131 ])
这告诉我们,a = 0.01867,b = 1.0078,m = 0.45469,k = 1.17311。我们之前没有使用过pickle,它需要一个文件对象(open的输出),并且必须使用二进制模式("rb")。
images目录中有九张图片。我们将对这些图片运行各种群体和进化算法,然后收集结果输出,生成展示原图和增强版的复合图像,以便评估每个算法的表现。为此,我创建了两个 Python 脚本:process_images.py和merge_images.py。
首先运行process_images.py。我建议你在晚上开始运行,早上回来查看。这个脚本使用每个群体算法处理images中的每一张图片。群体包含 10 个粒子,并且在所有情况下都会运行 75 次迭代。
当process_images.py完成时,使用merge_images.py生成复合图像,展示结果,这些结果位于output目录中。例如:
> python3 merge_images.py zelda zelda_results.png
创建zelda_results.png,如图 5-9 所示。标准 PSO 看起来是这里的胜者。

图 5-9:复合图像。左上角开始:原图、裸骨 PSO、DE、GA、GWO、Jaya、标准 PSO、RO。
表 5-3 列出了每个算法的F分数和参数。
表 5-3: 每个算法的F分数和参数
| F | a | b | m | k | |
|---|---|---|---|---|---|
| GWO | 4.59328 | 0.00030 | 2.21893 | 0.71926 | 0.99232 |
| 标准 PSO | 5.07767 | 0.00585 | 1.50612 | 0.39545 | 1.46734 |
| Jaya | 4.31448 | 1.21667 | 1.00493 | 0.76448 | 1.47750 |
| DE | 4.29245 | 1.24982 | 1.00076 | 0.76535 | 1.46355 |
| 裸骨 PSO | 4.27962 | 1.09779 | 1.01205 | 0.89588 | 1.49625 |
| GA | 4.10057 | 1.14040 | 1.64773 | 0.12196 | 1.19704 |
| RO | 4.01708 | 1.08478 | 5.07904 | 0.53249 | 0.97552 |
主观上看起来最好的图像也是F得分最高的图像——这是一个好兆头。GWO 图像对比度较低,但非常清晰,其F得分位居第二。GWO 参数也与其他算法有很大不同。增强函数的参数空间可能具有相当复杂的结构,并且存在多个局部最小值。对于 Zelda 图像,GWO 算法似乎与其他结果有所不同。这种情况在其他图像中也会发生吗?哪个算法似乎整体表现最佳?
程序F.py将一组特定的参数应用于图像。如果我们应用 GWO 结果中的a和经典 PSO 结果中的b、m和k,会发生什么?
> python3 F.py images/zelda.png zelda2.png 0.00030 1.50612 0.39545 1.46734
F = 5.28999404
新的F得分更高,输出文件zelda2.png看起来比经典 PSO 结果更好。
这里还有很多值得探索的内容。我将在“练习”部分提供一些建议,见第 169 页,包括一种(可能的)增强彩色图像的方法。目前,让我们继续进行另一个实验,该实验结合了优化和仿真,通过找出最佳的商品陈列方式来最大化超市的利润。
安排超市布局
你有没有注意到,超市通常把牛奶放在最远的地方,尽可能远离入口?或者糖果总是在最前面,靠近收银台?超市商品的摆放并不是偶然的;它是故意设计的,以最大化利润。许多人到商店来买生活必需品,比如牛奶,常常会顺便捎带点其他东西,比如糖果。超市通过这种商品布局来最大化此类偶然事件的发生,并增加收入。
本节尝试复制这样的商品排列,以验证或反驳常见的商店做法。这个实验结合了优化和仿真。我们将通过一组模拟顾客来优化商店的商品摆放,并评估这一安排。我们的目标是商品的排列方式,目标函数得分是一天的收入,函数本身是对数百名顾客的模拟。从群体初始化和位置更新到顾客的收集和习惯,随机性无处不在。要开始,请阅读store.py。
环境
让我们定义一下我们的操作环境。实际的商店本质上是二维的;有一个布置在某个地面空间上的布局。我们的框架使用位置向量(一维实体)。我们将商店设为一维,因此位置向量可以是一个商店布局,每个元素代表一个商品。顾客将从商店的左侧(索引 0)进入,沿着商店向右走,就像在图 5-10 中一样。

图 5-10:在一维超市购物(插图:Joseph Kneusel)
人们通常去商店购买特定的商品;我们称之为目标商品。购物者还会有冲动购买的商品,如果在找到目标商品之前遇到这些商品,他们也会购买。
例如,图 5-10 显示了两位购物者,他们在考虑目标商品(用问号表示)和冲动商品(用感叹号表示)。
左侧的购物者正在寻找钻石商品,但如果看到圆形商品,他们也会购买。由于他们先遇到钻石商品,再遇到圆形商品,因此他们只购买了钻石商品并离开了商店。
右侧的购物者正在寻找三角形商品,但如果遇到方形商品,他们也会购买。在寻找三角形商品时,他们发现了方形商品并购买了两者。
购物模拟需要商品,这些商品位于products.pkl文件中。该文件包含三个列表,每个列表有 24 个元素:计数、名称和价格,按此顺序排列。这些数据来自实际的一段时间内购买的商品集合。商品按购买频率递减存储,因此最常购买的商品排在最前面,最少购买的商品排在最后面。
我们通过将每个计数除以所有计数的总和,将计数转换为购买概率。我们将在目标函数中使用购买概率。
如果商店里有 24 个商品,我们就有 24 维的位置向量。我们正在寻找商品的最佳排列,以最大化每日收入。我们稍后将更详细地讨论模拟部分,但目前让我们关注商品的顺序以及如何在群体中表示它。
一开始,我们可能会考虑将位置向量设为[0, 23]中的离散值,其中每个数字代表一个商品,是从products.pkl文件中读取的商品列表的索引。然而,我实现了一种替代方法,使用 0, 1)中的位置向量。
最终,我们需要一个向量,将商品按照特定顺序排列,这是向量{0, 1, 2, 3, . . . , 23}的某种排列。诀窍是将这种排列抽象化,这样我们仍然可以在[0, 1)中使用连续的浮点数值。我们不直接使用商品编号,而是将每个位置向量传递给 NumPy 的argsort函数,该函数返回对向量进行排序所需的索引顺序。对于一个 24 元素的向量,argsort的输出是数字 0 到 23 的排列。
这种方法有效——我们将看到它确实有效——令人印象深刻。我们要求群体生成一组浮动的实数,[0, 1),这些数只有在确定它们的排序顺序之后才有用。它之所以有效,可能与使用整数值需要某种截断或舍入浮动小数点数字的情况有关,而实现的方法直接使用这些数字本身。如果将粒子位置中的某个元素从 0.304 更改为 0.288,使得整个向量的排序顺序变为更有利的配置,群体会利用这个变化,而截断可能会把这两个数字都看作 0。
以下是我们需要实现的步骤:
-
用 [0, 1) 中的 24 个元素位置向量来初始化群体。
-
初始化一个随机生成的购物者集合。
-
运行我们通常的群体搜索,在该过程中,每个位置向量通过将购物者传递到商店并使用当前向量的排序顺序作为产品的排列来进行评估。然后,统计每个购物者花费的金额。他们总是能找到目标产品,但可能找不到冲动产品。最后,返回购物者总花费的负值,因为我们希望最大化每日收入。
-
让群体算法像往常一样更新位置,直到所有迭代完成。
-
报告找到的最佳位置的排序顺序,作为产品的“理想”排序。
接下来的两个部分详细介绍如何实现购物者以及目标函数是如何工作的。有了这些信息,我们就准备好去购物,看看我们的模拟结果是否与食品行业专家的意见一致。
购物者
一个购物者是 Shopper 类的一个实例,如[清单 5-10 所示。
class Shopper:
def __init__(self, fi, pv, rng):
self.item_values = pv
➊ self.target = Select(fi,rng)
➋ self.impulse = np.argsort(rng.random(len(fi)))[:3]
while (self.target in self.impulse):
self.impulse = np.argsort(rng.random(len(fi)))[:3]
def GoShopping(self, products):
spent = 0.0
for p in products: if (p == self.target):
spent += self.item_values[p]
➌ break
if (p in self.impulse):
spent += self.item_values[p]
return spent
清单 5-10: Shopper 类
构造函数通过选择目标(target)和冲动(impulse)产品来配置购物者。它还会保留产品价格(item_values)的副本,以便在购物时使用。
对 Select 的调用返回目标产品,这是产品列表中的一个索引 ➊。Select 方法利用了 fi 是按递减顺序排列的产品购买概率这一事实(清单 5-11)。
def Select(fi, rng):
t = rng.random()
c = 0.0
for i in range(len(fi)):
c += fi[i]
if (c >= t):
return i
清单 5-11:根据购买频率选择产品
我们选择一个随机值,0, 1)(t)。然后,我们将每个产品的连续概率加到 c 上,直到其值等于或超过 t。当这种情况发生时,必然发生,因为 t < 1 且所有产品的概率总和为 1.0,这时返回当前产品的索引(i)。
让我们看一个例子,来澄清 Select 是如何工作的。假设有五个产品,每个产品的选择概率如下:
0.5, 0.3, 0.1, 0.07, 0.03
这意味着产品 0 大约有 50% 的概率被购买,而产品 4 只有 3% 的概率被购买。总和是 1.0,即 100%。现在,选择一个随机值 t ∈ [0, 1)。这将有一半时间小于 0.5,意味着 Select 将返回索引 0。前两个产品概率的总和是 0.5 + 0.3 = 0.8。但是一半时间 t < 0.5,因此 0.5 和 0.8 之间的差异是 0.5 > t ≤ 0.8 的时间段:30% 的时间。类似地,10% 的时间 0.8 > t ≤ 0.9,7% 的时间 0.9 > t ≤ 0.97,3% 的时间 0.97 > t ≤ 1.0。因此,Select 返回的索引反映了该项目的真实购买概率。
[图 5-10 显示了一个单一的冲动购买产品。实际上,模拟选择了三个独特的冲动购买产品,它们不是目标产品 ➋。argsort 的调用返回产品索引的排列,因此保持前三个确保了唯一的产品。while 循环在必要时重复此过程,以确保目标不是冲动购买之一。
在评估粒子位置时,我们调用 GoShopping 方法。它接收一个 products 列表,这是当前粒子的排序顺序。然后它遍历列表,检查当前产品是否为目标或冲动购买之一。如果是,方法将价格添加到 spent 中,表示购物者购买了该商品。如果是目标商品,则循环退出,任何未遇到的冲动购买商品将被忽略 ➌。方法最后返回总消费金额。
Shopper 类表示一个单独的购物者。Objective 类管理购物者集合。
目标函数
Objective 类评估一个单一粒子位置,或者说是一种产品配置,如 清单 5-12 所示。
class Objective:
def __init__(self, nshoppers, pci, pv, rng):
self.nshoppers = nshoppers
self.fcount = 0
self.shoppers = []
for i in range(nshoppers):
shopper = Shopper(pci, pv, rng)
self.shoppers.append(shopper)
def Evaluate(self, p):
self.fcount += 1
➊ order = np.argsort(p)
revenue = 0.0
for i in range(self.nshoppers):
revenue += self.shoppers[i].GoShopping(order)
return -revenue
清单 5-12: 目标 函数类
构造函数构建了一个随机初始化的购物者列表,这意味着我们在整个模拟过程中使用相同的购物者集合。这里,pci 是每个产品被选择的概率,按概率从高到低排列,pv 是相关的价格。
Evaluate 方法接收一个单一的粒子位置;然而,我们并不关心 p 的值,而是关心它们需要按什么顺序进行移动以对其进行排序 ➊。这就是 GoShopping 用来确定购物者花费多少钱的产品顺序。为了得到总收入,每个购物者都被要求去购物并统计花费的金额(revenue)。目标函数的值是该金额的负值(为了最大化)。
store.py 的其余部分加载产品并解析命令行:
products = pickle.load(open("products.pkl","rb"))
nshoppers = int(sys.argv[1])
npart = int(sys.argv[2])
niter = int(sys.argv[3])
alg = sys.argv[4].upper()
kind = sys.argv[5]
然后代码创建购买概率列表(pci)。
ci = products[0] # product counts
ni = products[1] # product names
pv = products[2] # product values
pci = ci / ci.sum() # probability of being purchased
N = len(ci) # number of products
在初始化群体并运行搜索之前:
ndim = len(ci)
rng = RE(kind=kind)
b = Bounds([0]*ndim, [1]*ndim, enforce="resample", rng=rng)
i = RandomInitializer(npart, ndim, bounds=b, rng=rng)
obj = Objective(nshoppers, pci, pv, rng)
swarm = Jaya(obj=obj, npart=npart, ndim=ndim, init=i, max_iter=niter, bounds=b, rng=rng)
swarm.Optimize()
res = swarm.Results()
文件的其余部分生成一份报告,显示搜索的成功程度。
购物模拟
足够的准备工作;让我们运行看看会得到什么输出:
> python3 store.py 250 20 200 pso mt19937
Maximum daily revenue $1114.28 (time 38.440 seconds)
(25 best updates, 4020 function evaluations)
Product order:
cream cheese ( 2.3%) ($1.57)
berries ( 1.9%) ($1.98)
misc. beverages ( 1.6%) ($2.37)
candy ( 1.7%) ($2.23)
chicken ( 2.4%) ($1.49)
beef ( 3.0%) ($1.35)
dessert ( 2.1%) ($1.76)
onions ( 1.8%) ($2.10)
coffee ( 3.3%) ($1.23) salty snack ( 2.1%) ($1.66)
apples ( 1.9%) ($1.87)
butter ( 3.1%) ($1.29)
chocolate ( 2.8%) ($1.42)
frankfurter ( 3.4%) ($1.19)
root vegetables ( 6.2%) ($1.01)
shopping bags ( 5.6%) ($1.02)
canned beer ( 4.4%) ($1.08)
brown bread ( 3.7%) ($1.15)
bottled beer ( 4.6%) ($1.06)
fruit/vegetable juice ( 4.1%) ($1.11)
pastry ( 5.1%) ($1.04)
yogurt ( 7.9%) ($1.01)
rolls/buns (10.5%) ($1.00)
whole milk (14.5%) ($1.00)
milk rank = 23
candy rank = 3
Upper half median probability of being selected = 2.1
median product value = 1.71
Lower half median probability of being selected = 4.8
median product value = 1.05
代码需要模拟的购物者数量(250)、粒子数量(20)和迭代次数(200)、算法(pso)以及随机源(mt19937)。输出会显示在屏幕上。
首先,我们被告知这次运行的最大日收入为$1,114.28。产品顺序如下所示,其中第一款产品位于商店前端,这里是奶油奶酪(奇怪的是)。该顺序提供了产品名称、购买概率和价格。
传统智慧告诉我们将牛奶放在商店的后面,糖果放在前面。在这个案例中,牛奶最终成为了第 23 号产品,位于商店的后部,而糖果则是第 3 号产品,靠近前部。此次运行遵循了传统智慧——这是一个好兆头。
输出的其余部分给出了商店前半部分产品的中位购买概率以及这些产品的中位价格,然后是商店后半部分产品的同样数据。如果商群按照我们预期的方式排列商店,那么价格较高的低概率商品会出现在商店的前部(产品列表的前半部分)。与此同时,通常价格较低的高概率商品则会出现在商店的后部。这正是我们在输出中看到的:后部的商品更可能被购买,而且通常价格较低。
总体而言,搜索产生了合理的输出,验证了传统智慧。
脚本go_store对每个算法进行 10 次搜索,将结果保存在output目录中。可以通过以下命令运行它:
> sh go_store
然后继续使用process_results.py:
> python3 process_results.py
这应该会生成一个results目录,其中包含 NumPy 文件(.npy),存储每个算法和运行的牛奶和糖果排名,以及每次运行的最佳收入。同时,还包括一张图,展示了每个算法在不同运行中的牛奶和糖果排名;请参见图 5-11。

图 5-11:各算法在 10 次运行中的产品排名,其中实心圆表示牛奶,空心圆表示糖果
在图中,实心圆表示牛奶在排名中的位置,空心圆则表示糖果的位置。几乎所有算法都能将牛奶放在商店的最靠后的位置,PSO 的两种变体和 Jaya 可能是最一致的(对于这次go_store的单次运行)。一个明显的例外是 RO。尽管它每次运行都能将牛奶排在糖果后面,但有时位置并不理想。例如,在第 9 次运行中,牛奶和糖果几乎紧挨在一起。
process_results.py代码还会输出一个摘要。例如:
Mean revenue by algorithm: t-test, best vs rest:
Bare: $1148.59 ( 5.09) Bare vs DE: 0.05296
DE: $1127.86 ( 8.62) Bare vs GA: 0.00017
GA: $1112.06 ( 5.81) Bare vs GWO: 0.36133
GWO: $1141.01 ( 6.30) Bare vs Jaya: 0.04042
Jaya: $1130.88 ( 6.20) Bare vs PSO: 0.01244
PSO: $1125.73 ( 6.47) Bare vs RO: 0.00000
RO: $1023.75 (10.03)
第一部分展示了不同算法在 10 次运行中的平均收入。平均值的标准误(标准差除以样本数量的平方根,这里是 10)放在括号中。最简版 PSO 是赢家,平均日收入为$1,148.59,相比之下,RO 的收入仅为$1,023.75。
输出的右侧部分需要一些解释。我想比较不同算法在各次运行中的收入。代码找出了表现最好的算法,在这种情况下是最简版 PSO,并与其他算法进行 t 检验。t 检验是一种假设检验,用于判断两个数据集是否可能来自相同的数据生成过程。显示的值是 p 值,它表示在假设两个数据集来自同一数据生成过程的前提下,观察到的均值和标准差差异(或更大)的概率。如果 p 值较高,那么这两个数据集很可能来自同一个数据生成过程,这意味着检验的原假设可能有效。在这种情况下,最简版 PSO 的结果与 GWO 的结果没有显著差异,因为 p 值为 0.36。
p 值越小,结果越可能不是来自同一个数据生成过程。对于 RO 和 GA,p 值非常低,这让我们确信最简版 PSO 的结果更好。然而,其他算法的 p 值也很低。那么,最简版 PSO 是否真的在这项任务中远超其他算法,还是这次仅仅是运气好呢?
为了找出答案,我再次运行了go_store五次,并将process_results.py的输出累积到results_per_run.txt文件中。每次运行的最佳算法有所不同,但还是有一些趋势。在六次运行中,有三次 Jaya 表现最好;两次是最简版 PSO;一次是 GWO。GA 和 RO 的结果总是最差的。通过 t 检验的 p 值来看,Jaya、最简版 PSO 和 GWO 是适用于这个任务的优秀算法,且 Jaya 和最简版 PSO 之间可能没有显著差异。
products.pkl中的产品是按照购买概率递减的顺序排列,同时价格是递增的顺序,这意味着最不可能购买的产品是最贵的,反之亦然。因此,我们可能期望通过将产品按以下方式排序来最大化利润:最不可能购买但最贵的产品排在前面,而最可能购买、最便宜的产品排在最后,放在店铺的最远端。store.py的运行结果是群体试图实现这一理想排序的结果。
运行product_order.py,传入一个输出目录(go_store创建的output)以及另一个目录名,比如orders。你将生成一系列图表,每个算法一个,显示每个产品位置的均值,从 0 到 23,涵盖每个算法的 10 次运行。同时,还会绘制反映理想产品排序的曲线,即products.pkl中的产品顺序的反向排序。
图 5-12 显示了通过每种算法的 10 次运行后,按商店位置计算的平均产品成本,并与理想排序(平滑曲线)进行比较。

图 5-12:比较按产品顺序排列的平均群体值与理想值。从左上角到右:基础 PSO,DE,GA,GWO,Jaya,经典 PSO,RO。
首先,请注意,群体搜索并没有试图匹配理想的顺序。相反,任何匹配都是群体尝试最大化每日利润的一个涌现效应。
其次,除了 RO 外,所有算法在匹配最便宜产品的排序上都很有效。我们也在 图 5-11 中看到这一点,通过牛奶和糖果摆放的一致性。算法之间的大部分差异体现在最前面的几个产品的顺序上。我们也可以看到,位于商店前端的产品相对于后端的产品,其误差条较大。
产品靠近商店前端的摆放可能更为困难,因为这些产品是最少被购买的。简单的 PSO 和 Jaya 算法在更贵的产品上表现得相当不错,但可以说 GWO 更接近理想曲线。这告诉我们,算法之间的差异是微妙的,至少从这种角度来看,尽管 GA 在匹配早期产品排序方面几乎与 RO 一样差。
练习
对群体算法已经厌倦了吗?我可没有。这里有更多内容可以探索和思考:
-
Circles.py 使用了
enforce="clip"来打包圆形。将其更改为enforce="resample"。如果结果突然不同,为什么会这样? -
在正方形中打包圆形是一个与在立方体中打包球体类似的问题。修改一份 circles.py 来在立方体中打包球体,将二维问题转化为三维问题。运行你的代码并与 sphere_dmin.png 中的数据进行比较。如果遇到困难,可以查看 spheres.py。
-
在空地图上放置基站塔会返回(主要是)不重叠的塔。如果地图上非常繁忙且允许的位置较少,cell.py 会产生什么样的输出?你可以自己制作地图,或者尝试 maps 目录中的 map_busy.png。群体算法能找到放置塔的位置吗?
-
enhance.py 文件用于处理灰度图像。将其修改为处理 RGB 图像的增强。一个粗略的方法可以在 rgb 目录中的 process_rgb_images.py 文件中找到。这个目录里还包含了一些 RGB 图像(original)。process_rgb_images.py 是否始终产生良好的结果?为什么?实现一个新版本,该版本不单独增强每个通道,而是寻求一组跨所有通道效果最佳的参数,或许通过对每个通道求和 F 来实现。
-
将 enhance.py 修改为使用
ddof=0而不是ddof=1——使用有偏方差而不是无偏方差。你是否注意到结果有所不同? -
杂货店仿真中,每次群体的迭代都使用相同的购物者集合。如果在每次迭代前重新生成购物者集合,结果会如何?你认为这会有什么影响吗?
-
在杂货店仿真中,模拟结果使用了 250 个购物者。如果购物者数量减少到只有五个?十个?五十个?我所说的 Jaya 和基础 PSO 算法非常适合这个任务的论点会受到什么影响?
总结
本章继续探索群体优化算法。我们学习了如何将圆形物体放入正方形中、在避免限制区域的同时放置手机信号塔、实现“美化”过滤器,以及将优化与仿真相结合,开发杂货店的产品布局计划。
我们使用相同的算法集合完成了所有这些工作。我们没有改变任何一个群体智能或进化算法来适应问题。相反,将问题转化为适当的形式使得算法能够直接应用。这是一种广泛适用的强大能力。现实世界中的许多过程,归根结底,都是优化问题,这意味着群体算法可能有它的作用。它们是通用算法,就像许多我们现在要讨论的机器学习算法一样。
本章及前一章的实验介绍了一种强大的通用优化问题方法。如果我们能够将问题转化为在一个多维空间中寻找最佳位置,其中每个点代表一个可能的解,那么群体智能和进化算法可能是适用的。我无法过分强调这一概念的有用性。
我们使用了一个简单的框架,支持少量的标准群体算法,而实际上有数百种算法可供选择——不过并非所有算法都等同。我们设计这个框架的目的是让它易于使用且具教学性,而非性能优越。这个框架是通向更复杂工具的垫脚石,如果你经常使用群体算法,应该考虑探索像这样的高级工具包:
inspyred pythonhosted.org/inspyred
pyswarms github.com/ljvmiranda921/pyswarms
DEAP github.com/DEAP/deap
这些工具包支持多种群体算法,包括群体智能和进化算法,并且针对性能进行了优化。其他语言的工具包包括:
Java cs.gmu.edu/~clab/projects/ecj
群体算法将在第七章中再次出现,但现在,我们将探索人工智能世界中的随机性。
第六章:机器学习**

机器学习的目标是训练模型,在给定以前未见过的输入时生成正确的输出。这通常通过反复向模型提供一组已知的输入和输出,直到模型成功地将输出正确地分配给输入,或者学习成功为止。
在本章中,我们将通过构建两个数据集来探讨机器学习中的随机性,这些数据集分别用于组织学切片和手写数字图像。正如我们将要学习的,随机性在构建合适的机器学习数据集时至关重要。
接下来,我们将探索神经网络中的随机性——这是人工智能革命的驱动力。我们将专注于传统的神经网络架构;在处理高级模型时,随机性同样重要,甚至更为关键。
在神经网络之后是极限学习机,这是一种简单的神经网络,基本上依赖于随机性。与它们的“成年”版本不同,极限学习机不需要大量训练,而是依赖随机性的力量来完成大部分学习任务。
随机森林将作为本章的结尾,它们的成功也极度依赖于随机性。
我将在本章中指出随机性出现的地方。随机性是机器学习成功的核心,从你最喜欢的智能音响到你可能(不久之后)会乘坐的自动驾驶汽车,随机性都起到了关键作用。
数据集
在机器学习中,我们从样本数据中训练模型。因此,在开始探索之前,我们必须构建数据集。随机性在这一过程中起着至关重要的作用。
我们将构建两个数据集。第一个由组织学切片中细胞的测量数据组成,旨在帮助模型学习组织样本是良性(类别 0)还是恶性(类别 1)。
第二个数据集由 28×28 像素的手写数字图像组成:1、4、7 和 9。图像不是以常规格式存储的;相反,它们被展开成向量,第一行图像接着第二行,以此类推,从而将 28×28 像素映射到 784 维向量。
组织学切片数据
机器学习的礼仪规定,训练一个模型至少需要两个数据集。第一个是训练集,它由成对的(x,y)组成,其中x是输入向量,y是相应的输出标签。第二个是测试集,它与训练集的性质相同,但直到训练完成后才使用。模型在测试集上的表现决定了它学习得如何。
raw目录包含bc_data.npy和bc_labels.npy文件。第一个文件是一个包含 569 行 30 列的二维 NumPy 数组的数据集。每一行是一个样本,每一列是一个特征。每个样本的 30 个元素表示三种不同细胞在组织切片上的 10 个测量值。第二个文件包含标签,0 代表良性,1 代表恶性。数据行与标签之间是一一对应的。因此,bc_data.npy的第 0 行表示一个良性样本的特征,而第 2 行表示恶性样本的特征,因为bc_labels.npy中向量的第一个元素是 0,第三个元素是 1。
我们将从 569 个样本中构建两个数据集,使用 70/30 的划分方式,这意味着 70%的样本用于训练(398 个),剩余 30%用于测试(171 个)。
由于机器学习模型的学习过程通常较慢,我们应该担心 398 个样本不足以训练模型。我们需要更多的数据,但目前没有更多样本,我们该怎么办?
随机性帮助了我们。我们可以通过创建看似来自与训练数据相同来源的伪样本来增强数据。我们将对现有数据进行随机更改——足够让它变得不同,但不会过度改变以致标签不再准确。数据增强是现代机器学习中一个强大的部分,它帮助模型学会不专注于训练集的细节,而是寻求区分不同类别的更一般特征。
在增强训练样本之前,我们需要对数据进行标准化。许多机器学习模型在处理特征值范围不同的情况下表现困难。例如,一个特征的范围可能是[0, 2],而另一个特征的范围可能是[–30,000, 30,000]。为了将两个特征转换到相同的相对范围,我们会减去每个特征的均值,然后除以特征的标准差。经过这种转换后,每个特征的均值接近零,标准差为一。
我们已经标准化了特征,并将其分为两个互不重叠的组,一个用于训练,另一个用于测试。现在我们准备通过使用 主成分分析(PCA) 来增强训练数据。如果我们能够在 30 个维度上绘制数据,我们会看到数据在某些方向上比在其他方向上分布得更广。PCA 找出这些方向,实际上是旋转 30 维坐标系,使得第一个坐标对准数据中变异性最大的方向,第二个坐标对准下一个方向,依此类推。这意味着,后面的坐标在表示数据时的重要性较小(尽管可能在区分不同类别时并非如此)。我们将利用这些方向的重要性逐渐降低的特性,随机改变坐标方向,生成与原始数据相似但不完全相同的训练数据。通过做出微小的改变,我们可以(合理地)确信新数据仍然代表原始类别的一个实例。
我们需要的代码在 build_bc_data.py 文件中。让我们逐步讲解关键部分,从加载原始数据并将其分成训练集和测试集开始(列表 6-1)。
np.random.seed(8675309)
x = np.load("raw/bc_data.npy")
y = np.load("raw/bc_labels.npy")
➊ x = (x - x.mean(axis=0)) / x.std(ddof=1,axis=0)
i = np.argsort(np.random.random(len(y)))
x = x[i]
y = y[i]
n = int(0.7*len(y))
xtrn = x[:n]
ytrn = y[:n]
xtst = x[n:]
ytst = y[n:]
列表 6-1:拆分原始组织学数据
首先,我们修正 NumPy 的伪随机数种子,这样每次运行代码时都会构建相同的数据集。通常,改变 NumPy 的种子并不是一个好主意,因为它会影响到 所有 使用 NumPy 的代码,甚至是其他模块(比如我们在本章稍后会使用的 scikit-learn 模块)。然而,在这种情况下,我们愿意冒这个风险。
接下来,我们在标准化之前加载原始数据和标签 ➊。我们想要每个特征的均值。x 的列是特征,因此需要使用 axis=0。这个关键词会将 mean 函数应用到 x 的每一行,从而返回一个包含 30 个元素的向量,每个元素都是 x 相应列的均值。
我们从每个样本或每行x中减去这个均值。通过 NumPy 的广播规则,我们可以自动完成这个操作,无需使用循环。NumPy 足够聪明,能够察觉到我们正在尝试从一个二维数组的第二维为 30 的数据中减去一个 30 元素的向量,因此它执行减法操作,并为每一行重复这一过程。
接下来,我们将均值已被减去的数据除以每个特征的标准差。同样,axis=0 让我们能够跨行应用该函数。由于 NumPy 的广播规则,除法操作会应用到 x 的每一行,生成最终的标准化数据集。
接下来的三行通过将 i 分配为从 0 到 568 的数字的随机排列来对数据集进行随机化。NumPy 的 argsort 函数并不对向量进行排序,而是返回能够将其排序的索引序列。接下来的两行将这个排列应用到 x 和 y 上,从而同步地打乱数据和标签。
最后的五行代码将原始数据集分割为训练集(xtrn,ytrn)和测试集(xtst,ytst)。注意,我们在增强之前就已将数据集分割;如果在增强之后再进行分割,样本的增强版本很可能会出现在测试集中,这样会使得模型看起来比实际更好。
现在我们可以开始增强数据了。首先,我们需要学习原始数据的主成分,然后构建一个新的训练集,其中每个原始样本都被保留,并附加九个该样本的增强版本。
在列表 6-2 中,scikit-learn 提供了PCA。
from sklearn import decomposition
pca = decomposition.PCA(n_components=xtrn.shape[1])
pca.fit(x)
列表 6-2:使用 PCA 学习主成分
PCA类遵循 scikit-learn 的标准方法,即在对实例调用fit之前,先定义一个类的实例。我们将组件的数量设置为数据集中的特征数量(30)。
我们将使用训练好的pca对象在循环中构建一个包含增强样本的新训练集,如列表 6-3 所示。
start = 24
nsets = 10
nsamp = xtrn.shape[0]
newx = np.zeros((nsets*nsamp, xtrn.shape[1]))
newy = np.zeros(nsets*nsamp, dtype="uint8")
for i in range(nsets):
if (i == 0):
newx[0:nsamp,:] = xtrn
newy[0:nsamp] = ytrn else:
newx[(i*nsamp):(i*nsamp+nsamp),:] = generateData(pca, xtrn, start)
newy[(i*nsamp):(i*nsamp+nsamp)] = ytrn
列表 6-3:增强样本
新的训练数据位于newx和newy中。每个现有的训练样本将伴随九个增强版本,因此新的训练集将包含 3,980 个样本,而不是仅有 398 个。
循环将数据集按 398 个样本的块构建。第一次遍历存储原始数据,后续的遍历调用generateData函数,返回原始数据集中的样本的一个新的增强版本。新样本的顺序与原始顺序相同,这意味着标签的顺序也保持不变。
在列表 6-4 中,generateData函数应用 PCA 变换,并从start(第 24 个)开始,改变最不重要的坐标方向。
def generateData(pca, x, start):
original = pca.components_.copy()
ncomp = pca.components_.shape[0]
a = pca.transform(x)
for i in range(start, ncomp):
pca.components_[i,:] += np.random.normal(scale=0.1, size=ncomp)
b = pca.inverse_transform(a)
pca.components_ = original.copy()
return b
列表 6-4:应用 PCA
PCA 是一个可逆的变换。generateData函数通过在从第 24 个(共 30 个)主成分开始的每个主成分上添加一个小的、正态分布的值来改变 PCA 成分。当逆变换使用这些更改后的成分时,得到的值(b),即一个 398 样本的块,将不再与原始数据完全相同。这些增强版本将构建新数据集的下一个块。
新的训练数据集位于newx和newy中,但样本的顺序并非随机的,因为它是按块构建的。因此,在将训练集和测试集写入磁盘之前,我们执行最终的随机化操作(列表 6-5)。
i = np.argsort(np.random.random(nsets*nsamp))
newx = newx[i]
newy = newy[i]
np.save("datasets/bc_train_data.npy", newx)
np.save("datasets/bc_train_labels.npy", newy)
np.save("datasets/bc_test_data.npy", xtst)
np.save("datasets/bc_test_labels.npy", ytst)
列表 6-5:存储增强后的数据集
组织学训练和测试数据集现在可以使用了。我们多次应用随机化操作,打乱数据的顺序,并改变主成分,以构建增强版本的训练数据。
手写数字
深度学习革命的第一个重大成功之一是正确识别图像中的物体。虽然我们将构建的数据集在相对上并不先进,但它是 MNIST 数据集的一部分,MNIST 是一个常用于机器学习的更大的“工作马”数据集。我们将构建一个包含手写数字 1、4、7 和 9 的数据集。我选择这四个数字,因为即使是人类也常常把它们混淆,所以我们可能会预期机器学习模型也会犯同样的错误(时间会证明)。
Figure 6-1 展示了每种数字类型的样本。

Figure 6-1: 示例数字,其中 1 和 7 常常被混淆,4 和 9 也是如此
图像是 28×28 像素的灰度图,每个像素的值是[0, 255]范围内的整数。我们将把数字当作 784 维的向量(28 × 28 = 784)来处理。我将数字和它们的标签分别收集在文件mnist_data.npy和mnist_labels.npy中。
原始数字数据集相对较小,每个数字有 100 个样本;我们将原始数据拆分为 50 个训练样本和 50 个测试样本。我们将对每个图像进行多次增强,以扩展训练集的大小。
我们使用 PCA 来增强组织学数据,但由于这里处理的是图像,我们将应用基本的图像处理变换,随机生成每个训练图像的略微改变版本。特别地,我们将把每个图像旋转在[–3, 3]度之间;大约 10%的时间,我们将图像缩放至[0.8, 1.2]倍原始尺寸,同时保持最终尺寸为 28×28 像素,通过裁剪或将较小的图像嵌入到一个空白的 28×28 图像中。
我们需要的代码在build_mnist_dataset.py中。虽然它与构建组织学数据集的代码类似,但有所不同,主要包括将原始数据按 50/50 分配为训练集和测试集、存储未增强的训练数据,并将训练数据增强 20 次而不是 10 次,最终得到一个包含 4,200 个样本的增强训练集(200 个原始样本加上每个原始样本增强的 20 个版本);参见 Listing 6-6。
newx = []
newy = []
for i in range(len(ytrn)):
newx.append(xtrn[i]) newy.append(ytrn[i])
➊ for j in range(20):
newx.append(augment(xtrn[i]))
newy.append(ytrn[i])
xtrn = np.array(newx)
ytrn = np.array(newy)
➋ i = np.argsort(np.random.random(len(ytrn)))
xtrn = xtrn[i]
ytrn = ytrn[i]
Listing 6-6: 增强数字图像
原始的 200 个训练样本(xtrn,ytrn)逐一检查。首先,我们将原始图像添加到增强后的输出(newx,newy)。然后,我们通过多次调用augment ➊,添加该图像的 20 个增强版本。添加完增强图像后,我们会再次打乱整个训练集,以混合图像的顺序 ➋。
Listing 6-7 展示了增强图像的代码。
from scipy.ndimage import rotate, zoom
def augment(x):
im = x.reshape((28,28))
if (np.random.random() < 0.5):
angle = -3 + 6*np.random.random()
im = rotate(im, angle, reshape=False)
if (np.random.random() < 0.1):
f = 0.8 + 0.4*np.random.random()
t = zoom(im, f)
if (t.shape[0] < 28):
im = np.zeros((28,28), dtype="uint8")
c = (28-t.shape[0])//2
im[c:(c+t.shape[0]),c:(c+t.shape[0])] = t
if (t.shape[0] > 28):
c = (t.shape[0]-28)//2
im = t[c:(c+28),c:(c+28)]
return im.ravel()
Listing 6-7: 增强图像
输入图像(x),一个 NumPy 向量,首先被重塑为一个 28×28 元素的二维数组,im。接下来,两个if语句会检查随机值是否小于 0.5 或 0.1。第一个语句在 50%的时间内执行,应用一个随机旋转操作,使图像旋转某个在[–3, 3]度范围内的角度。注意这里使用了scipy.ndimage中的rotate。reshape=False关键字强制rotate返回与输入数组大小相同的输出数组。
第二个if语句,在 10%的时间内执行,使用zoom按一个随机的缩放因子在[0.8, 1.2]范围内放大图像,这意味着缩放后的图像大小从原始图像的 80%到 120%不等。调用zoom后的代码确保输出图像仍然是 28×28 像素,通过将较小的图像嵌入到一个空白的 28×28 图像中,或者在放大超过 100%时,选择中央的 28×28 像素。经过展开处理并重新转换为 784 元素向量后,返回新增强的图像。
build_mnist_dataset.py中的代码存储了较小的、未经增强的训练集和增强后的训练集。文件mnist_test.py使用sklearn的MLPClassifier(我们稍后会学习)来训练 40 个模型,使用训练集并保持每个模型的整体准确性。模型使用默认值,并且有一个 100 个节点的单一隐藏层。未经增强的数据集的平均准确率为 87.3%,而增强后的训练数据的平均准确率为 90.3%,这是一个具有统计学意义的差异,证明了随机增强过程有助于训练模型。虽然花时间详细讲解数据集的构建可能显得有些繁琐,但很难过分强调随机性在这个过程中的重要性。数据集的构建对现代机器学习至关重要,以至于有些比赛的模型是固定的,必须良好构建数据集才能获得获胜结果(可以搜索“Data-Centric AI Competition”)。
现在我们已经有了数据集,让我们来进行测试。
神经网络
神经网络是一个由节点组成的集合,逐层将输入转换为输出。网络节点接受多个输入并产生一个输出,这一操作与生物神经元的工作原理足够相似,因此得名“神经网络”。神经网络不是人工的大脑;它们是前馈的、定向的、无环的图,这是一种在计算机科学中常用的数据结构。
网络的操作将输入转化为输出;换句话说,神经网络是一种函数,y = f(x; θ)。训练神经网络意味着寻找θ,一组使网络按预期执行的参数。
解剖分析
如果神经网络是由层级中的节点构成的集合,那么从一个节点开始是有意义的。请参见图 6-2。

图 6-2:神经网络节点
数据从左到右流动。输入(x),无论是网络的输入还是先前网络层的输出,都与权重(w)相乘并与偏置(b)相加,然后将总和传递给激活函数(h)以产生输出,a。用符号表示为:
a = h(w[0]x[0] + w[1]x[1] + w[2]x[2] + b)
激活函数是非线性的,或者是某个超越x一次方的函数。现代网络通常使用修正线性单元(ReLU)激活函数,它会输出输入,除非输入小于零,在这种情况下输出为零,h = max(0, x)。
网络层由一组节点组成,这些节点共同接收上一层的输出作为它们的输入,并产生新的输出,每个节点一个,传递给下一层。层中的每个节点都会接收每个输入;用图论的术语来说,网络是完全连接的。
数据通过网络逐层流动,直到输出。通过这种方式,网络将输入x映射到输出y。输出通常是一个向量,但也可以是标量,y。
在神经网络中传递数据最简单的方式是将层之间的权重表示为矩阵,将偏置表示为向量。这种表示法自动地将每个输入应用于每个节点和激活函数,从而将整个层的操作简化为矩阵乘以向量,再加上另一个传递给激活函数向量版本的向量。训练意味着学习一组矩阵作为权重,每层一个矩阵,以及一组偏置向量,每层一个。
权重和偏置存在于层的节点之间的连接上,是θ的参数;这些是网络在训练过程中学习的内容。这类似于拟合曲线,但在这种情况下,函数由网络的架构决定。与曲线拟合不同,训练神经网络通常不涉及将训练集上的误差降到零。相反,目标是引导网络学习权重和偏置,使其能够广泛适用于新的输入。毕竟,训练模型的整个目的是使其能够处理新的、未知的输入。
神经网络训练的详细讨论超出了我们当前的范围,因为我们的目标是理解随机性在过程中的作用。如果你有兴趣了解更多神经网络的内容,我推荐我的书籍《实践深度学习:基于 Python 的入门》(2021 年),也可以从 No Starch Press 获得。记住,训练过程会产生一组特定的权重和偏置,使网络能够针对当前问题进行调整。
随机性
随机性在训练过程的开始阶段至关重要,特别是在选择初始权重和偏置时。
训练神经网络遵循以下一般算法:
-
随机初始化网络的权重和偏差。
-
将随机选择的训练数据子集传入网络。
-
使用网络输出与期望输出之间的误差度量来更新权重和偏差。
-
从第二步开始重复,直到训练完成(具体如何决定)。
在这一部分,我们关注的是第一步。第二步和第三步涉及到一个损失函数,这是衡量网络错误的指标,并且包括一个两步过程来更新权重和偏差:反向传播和梯度下降。前者使用微积分中的链式法则来确定每个权重和偏差值如何影响误差,后者则利用这些度量形成的梯度来调整权重和偏差,从而最小化损失函数。
初始化
直到 2010–2012 年,深度神经网络(具有多层结构)才在场景中爆发。促成这一发展的因素之一是认识到以往初始化模型的权重和偏差的算法相对较差;现在有了更好的选择。
我们将通过使用sklearn中的MLPClassifier类来实现一个传统的神经网络,从而探索这些选项。MLP代表多层感知器,“感知器”是神经网络的一个古老名称(我建议你搜索 Frank Rosenblatt 和他的感知器机器——这项研究在当时没有得到足够的重视,实在是太迷人了)。
初始化网络
MLPClassifier类包括一个内部方法_init_coef,该方法负责分配初始权重和偏差。我们将对MLPClassifier进行子类化,并重写这个方法,从而改变初始化方法,同时仍然能利用MLPClassifier所提供的其他功能。请查看 Listing 6-8。
from sklearn.neural_network import MLPClassifier
def normal(rng, mu=0, sigma=1):
if (normal.state):
normal.state = False
return sigma*normal.z2 + mu
else:
u1,u2 = rng.random(2)
m = np.sqrt(-2.0*np.log(u1))
z1 = m*np.cos(2*np.pi*u2)
normal.z2 = m*np.sin(2*np.pi*u2)
normal.state = True
return sigma*z1 + mu
normal.state = False
class Classifier(MLPClassifier):
def _init_coef(self, fan_in, fan_out, dtype):
➊ def normvec(fan_in, fan_out):
vec = np.zeros(fan_in*fan_out)
for i in range(fan_in*fan_out):
vec[i] = normal(self.rng)
return vec.reshape((fan_in,fan_out))
if (self.init_scheme == 0):
➋ return super(Classifier, self)._init_coef(fan_in, fan_out, dtype)
elif (self.init_scheme == 1):
➌ vec = self.rng.random(fan_in*fan_out).reshape((fan_in,fan_out))
weights = 0.01*(vec-0.5)
biases = np.zeros(fan_out)
elif (self.init_scheme == 2):
➍ weights = 0.005*normvec(fan_in, fan_out)
biases = np.zeros(fan_out) elif (self.init_scheme == 3):
➎ weights = normvec(fan_in, fan_out)*np.sqrt(2.0/fan_in)
biases = np.zeros(fan_out)
return weights.astype(dtype, copy=False), biases.astype(dtype,
copy=False)
Listing 6-8: 重写初始化方法
该子类覆盖了 scikit-learn 的做法 ➋,提供了三种其他方法。该方法返回一个权重矩阵和一个偏差向量,适用于具有fan_in输入和fan_out输出的层。dtype参数指定数据类型,通常是 32 位或 64 位浮点数。
默认情况下,scikit-learn 使用Glorot 初始化。我们通过调用父类版本的方法 ➋ 来实现。Glorot 初始化依赖于输入和输出的数量,它是提高模型性能的初始化方法之一。至少,这是它的说法。我们将对它进行测试。
另一种现代初始化方法是He 初始化 ➎,它适用于使用 ReLU 激活函数的网络。He 初始化依赖于一个来自正态分布的样本矩阵,该分布的均值为 0,标准差为 1。我们通过嵌入式的normvec函数 ➊来实现这一点,这使我们能够使用RE类。稍后我们将具体看看如何操作。
我们通过使用小的均匀分布 ➌ 或正态分布 ➍ 随机值来初始化经典的神经网络。第一个从均匀分布中抽取随机样本,范围是 [–0.005, 0.005]。第二个使用按 0.005 缩放的正态分布样本。两种方法从直觉上看都是合理的,但正如我们将看到的,它们并不理想。
normal 函数返回一个正态分布的样本,其均值为 mu,标准差为 1。正态分布的样本对称地围绕均值选择;请参见图 1-1 中 第 4 页的示例。NumPy 提供了一个函数来从正态分布中选择样本,但我们想使用我们的 RE 类,因此我们需要定义一个函数,该函数从 RE 返回的均匀分布样本中创建正态分布样本。我们可以使用第一章中介绍的 Box-Muller 变换:

这里 u[1] 和 u[2] 是 0, 1) 范围内的均匀分布样本,这正是 RE 返回的内容(多么方便!)。
实现 normal 的代码需要解释。请注意最后一行的缩进:它不是 normal 的一部分,而是在定义 normal 后立即引用它。
Box-Muller 变换方程使用一对均匀分布的样本来生成一对正态分布的样本。虽然我们可以要求 RE 对象返回一对均匀分布的样本,但我们希望 normal 在调用时仅返回一个正态分布的样本。我们可以生成两个样本并丢弃一个,或者小心地尝试保留两个样本。这样,每次调用 normal 时返回一个单一的样本,并且仅在需要时生成新样本。要实现这一点,要求 normal 在调用之间保留状态。Python 中实现这一点的方法是使用类,但这似乎有些过于复杂。相反,我们将利用 Python 函数是对象这一特性;我们可以随意为对象添加新属性(成员变量)。我们将 normal 定义为一个 Python 函数,然后立即为其添加一个初始化为 False 的 state 变量——这就是[Listing 6-8 中的最后一行。
当我们调用 normal 时,state 变量的值是 False。这个值会在调用之间持续存在,因为它是 normal 函数的一个属性,normal 使用 state 的值来决定是生成两个新样本并返回第一个,同时缓存第二个,还是直接返回第二个。如果 state 是 True,则返回第二个样本 z2,并将其乘以所需的标准差 (sigma),加上均值 (mu)。然后将 state 设置为 False。如果 state 是 False,则从 rng 获取两个均匀样本,并使用它们计算两个正态样本 z1 和 z2。缓存 z2 并返回 z1,并适当进行缩放和偏移。
要使用Classifier,我们通过 scikit-learn 创建一个神经网络,并添加两个新的属性:init_scheme用来指定所需的初始化方案,以及rng,它是RE的一个实例,用于访问我们在全书中开发的不同随机数源。
实验初始化
让我们尝试一下Classifier和神经网络初始化。过程中,我们将设置一个 scikit-learn 训练会话。
我们需要的代码在init_test.py中。它加载了之前创建的数字数据集,并使用每个初始化方案训练了 12 个模型。对于每个初始化方案,训练多个模型是必要的,因为初始化是随机的;例如,在一个初始化方案下我们可能会得到一个不太好的结果,而实际上它是一个有效的方法。通过对多个模型的结果取平均值,我们可以应用统计测试。
清单 6-9 显示了init_test.py主要部分的开头。
import numpy as np
from Classifier import *
from RE import *
from scipy.stats import ttest_ind, mannwhitneyu
xtrn = np.load("../../data/datasets/mnist_train_data.npy")/256.0
ytrn = np.load("../../data/datasets/mnist_train_labels.npy")
xtst = np.load("../../data/datasets/mnist_test_data.npy")/256.0
ytst = np.load("../../data/datasets/mnist_test_labels.npy")
N = 12
init0 = []
for i in range(N):
init0.append(Run(0, xtrn,ytrn,xtst,ytst))
init0 = np.array(init0)
清单 6-9:设置初始化实验
这些导入语句使得Classifier类可用,同时导入了RE和两个统计测试:t 检验和曼-惠特尼 U 检验,它是 t 检验的非参数版本。非参数检验对数据没有假设,且要求更为严格。我常常同时使用这两种检验,以考虑到 t 检验结果无效的可能性——因为数据并非正态分布,这是 t 检验的一个基本假设。
接下来,我们加载数字数据集,首先是训练集(xtrn),然后是测试集(xtst)。我们将数据除以 256,将其从[0, 255]映射到 0, 1)。
以下代码段累计了初始化方案 0 的Run输出。返回值是使用 scikit-learn 的 Glorot 初始化的模型的整体准确率。类似的代码会捕获其他四个初始化方案的准确率。
然后,我们将所有方案的结果转化为均值和标准误差后再打印,如[清单 6-10 所示。
m0,s0 = init0.mean(), init0.std(ddof=1)/np.sqrt(N)
m1,s1 = init1.mean(), init1.std(ddof=1)/np.sqrt(N)
m2,s2 = init2.mean(), init2.std(ddof=1)/np.sqrt(N)
m3,s3 = init3.mean(), init3.std(ddof=1)/np.sqrt(N)
清单 6-10:报告结果
最后,我们运行统计测试,将一个初始化方案与另一个进行比较。从理论上讲,我们可能会预期初始化方案 3(He)是表现最好的,因此我们将它(init3)与其他方案进行比较,并报告相应的 p 值(“u”是曼-惠特尼 U 检验的 p 值);见清单 6-11。
_,p = ttest_ind(init3,init0)
_,u = mannwhitneyu(init3,init0)
print("init3 vs init0: p=%0.8f, u=%0.8f" % (p,u))
_,p = ttest_ind(init3,init2)
_,u = mannwhitneyu(init3,init2)
print("init3 vs init2: p=%0.8f, u=%0.8f" % (p,u))
_,p = ttest_ind(init3,init1)
_,u = mannwhitneyu(init3,init1)
print("init3 vs init1: p=%0.8f, u=%0.8f" % (p,u))
清单 6-11:运行统计测试
统计测试ttest_ind和mannwhitneyu首先返回各自的检验统计量,然后返回相应的 p 值。p 值告诉我们,假设两组准确率来自同一分布时,我们测得的均值差异的概率。p 值越小,两组来自同一分布的可能性越小,从而增加我们对观察到的差异是真实的信心。
训练网络
配置和训练神经网络的代码位于 Run 函数中,详见 Listing 6-12。
def Run(init_scheme, xtrn,ytrn,xtst,ytst):
clf = Classifier(hidden_layer_sizes=(100,50), max_iter=4000)
clf.init_scheme = init_scheme
clf.rng = RE()
clf.fit(xtrn,ytrn)
pred = clf.predict(xtst)
_,acc = Confusion(pred,ytst)
return acc
Listing 6-12: 训练神经网络
在这里,我们开始体会到 scikit-learn 的强大。Run 函数的参数包括要使用的初始化方案,[0, 3],接着是训练集和测试集。首先,我们通过创建 Classifier 的实例来创建神经网络,Classifier 是我们对 scikit-learn 中 MLPClassifier 的子类。默认情况下,神经网络使用 ReLU 激活函数,这正是我们需要的。它还会训练,直到训练不再改善或 max_iter 完成整个训练集的迭代。每次通过训练集的过程被称为 epoch。
hidden_layer_sizes 关键字定义了模型的架构。我们知道输入有 784 个元素,输出有 4 个类别,因为有四个类别,分别是数字 1、4、7 和 9。输入和输出之间的层是隐藏层;我们指定了两个:第一个有 100 个节点,第二个有 50 个节点。
在训练之前,我们需要设置初始化方案(init_scheme)并定义 rng,它是 RE 的一个实例,使用 PCG64 默认值并且浮动值在 0, 1) 之间。
使用 clf.fit(xtrn, ytrn) 来训练神经网络,其中 fit 方法接受输入向量(xtrn)和相关标签(ytrn)。当该方法返回时,模型已经训练完毕,并为所有的权重和偏置(θ)找到了合适的值。
评估网络
当给定测试数据(xtst)时,predict 会生成一组预测类别标签 pred,其长度与已知类别标签 ytst 相同。我们通过构建 混淆矩阵 来比较两者,评估模型的表现。混淆矩阵的行表示已知的真实类别标签,列表示模型分配的标签。矩阵元素是每种可能的真实标签与分配标签配对出现的次数。一个完美的分类器会始终分配正确的标签,这意味着混淆矩阵将是对角线的。任何偏离主对角线的计数都表示错误。
Confusion 函数生成一个混淆矩阵和整体准确率,基于已知标签和预测标签的集合;详见 [Listing 6-13。
def Confusion(y,p):
cm = np.zeros((4,4), dtype="uint16")
for i in range(len(p)):
cm[y[i],p[i]] += 1
acc = np.diag(cm).sum() / cm.sum()
return cm, acc
Listing 6-13: 创建混淆矩阵
混淆矩阵(cm)是一个 4×4 矩阵,因为有四个类别。最重要的操作是通过先按已知类别标签(y)索引,再按分配的类别标签(p)索引来更新 cm。每次发生特定的真实类别标签与分配标签的配对时,添加一个计数。
整体准确率要么是已知标签与分配标签匹配的次数,要么是主对角线的和除以所有矩阵元素的和。
运行 init_test.py 需要几分钟时间。我的运行结果如下:
init0: 0.92667 +/- 0.00167
init1: 0.89958 +/- 0.00311
init2: 0.90083 +/- 0.00183
init3: 0.92500 +/- 0.00213
init3 vs init0: p=0.54429253, u=0.55049935
init3 vs init2: p=0.00000002, u=0.00004054
init3 vs init1: p=0.00000088, u=0.00006765
第一个块显示了每种初始化方案下 12 个模型的平均准确度(均值 ± 标准误)。Glorot 和 He 初始化的平均准确度分别为 92.7%和 92.5%。较旧的均匀和正态初始化策略分别达到了 90.0%和 90.1%。这些差异很大,但它们在统计上显著吗?为此,看看第二个结果块。
将 He 初始化(init3)与 Glorot(init0)进行比较,t 检验和 Mann-Whitney U 的 p 值都约为 0.5。这些 p 值强烈表明两种方法之间没有差异。
现在,看看比较 He 初始化和旧方法的 p 值。它们几乎为零,意味着平均准确度的差异很可能是实质性的——He 和 Glorot 初始化都导致了显著更好的模型性能。现代深度学习得到了验证。虽然从未怀疑过,但直接确认而不是盲目相信是有益的。
极限学习机
极限学习机是一个简单的单隐藏层神经网络。极限学习机与传统神经网络的区别在于权重和偏置的来源。
将输入向量映射通过神经网络的第一个隐藏层涉及一个权重矩阵,W,和一个偏置向量,b
z = h(Wx + b)
其中x和z是向量,h是一个激活函数,接受一个向量输入并输出一个向量。
在传统神经网络中,W和b是在训练过程中学习得到的。在极限学习机中,W和b是随机生成的,与训练数据无关。随机矩阵有时能够将输入(例如数字图像作为向量)映射到一个新的空间,在这个空间中,分类更容易分离。
如果我们使用这个方程映射所有的训练数据,隐藏层的输出(z)变成一个矩阵(Z),它的行数与训练样本的数量相同,列数与隐藏层节点的数量相同。换句话说,随机的W矩阵和b偏置向量已经生成了一个新的训练数据版本,Z。
随机权重矩阵和偏置向量连接输入层和隐藏层。为了完成神经网络的构建,我们需要在隐藏层输出和模型输出之间添加一个权重矩阵;在这种情况下没有偏置向量。学习过程发生在这里,但我们需要绕个圈子来看它。
训练数据集由输入样本和相应的类标签[0, 3]组成。许多机器学习模型不使用整数类标签,而是将标签转换为one-hot 向量。例如,如果有四个类,则 one-hot 向量有四个元素,除了对应类标签的元素为 1,其余为 0。如果类标签是 2,则 one-hot 向量为{0, 0, 1, 0}。同样,如果类标签是 0,one-hot 向量为{1, 0, 0, 0}。为了完成极限学习机的构建,我们需要将训练集类标签转化为这种形式。我假设接下来的部分中标签已经被如此转化。
作为 one-hot 向量,类标签变成了一个矩阵Y,行数与训练样本的数量相同,列数与类别数相同。从隐层输出Z到已知标签(作为 one-hot 向量)Y的线性映射使用矩阵B:
Y = BZ
我们希望找到作为第二个权重矩阵的B。
因为我们知道通过将训练数据推送通过隐层得到的Z和已知标签Y,我们通过以下方式生成B:
YZ^(–1) = B
其中Z^(–1)是矩阵Z的逆。矩阵的逆相当于标量值的倒数,它们互相抵消。为了找到Z的逆,我们需要 Moore-Penrose 伪逆,这是 NumPy 在其线性代数模块linalg中提供的。
现在我们拥有了构建极限学习机所需的所有内容:
-
随机选择权重矩阵W和偏置向量b。
-
将训练数据通过第一隐层,Z = h(WX + b),其中X和Z现在是矩阵,每一行代表一个训练样本。
-
通过使用Z(隐层输出)的伪逆来计算输出权重矩阵,B = YZ^(–1)。
-
使用W、b和B作为极限学习机的权重和偏置。
这有些抽象。让我们通过代码使其具体化。
实现
文件elm.py实现了一个极限学习机,并将其应用于数字数据集。列表 6-14 展示了train函数。
def train(xtrn, ytrn, hidden=100):
inp = xtrn.shape[1]
m = xtrn.min()
d = xtrn.max() - m
w0 = d*rng.random(inp*hidden).reshape((inp,hidden)) + m
b0 = d*rng.random(hidden) + m
z = activation(np.dot(xtrn,w0) + b0)
zinv = np.linalg.pinv(z)
w1 = np.dot(zinv, ytrn)
return (w0,b0,w1)
列表 6-14:定义一个极限学习机
第一行将inp设置为训练数据中的特征数,这里是 784。接下来的两行定义了最小训练特征值(m)和它与最大特征值的差(d)。
以下两行通过在训练数据范围[m, m + d]内随机采样生成随机权重矩阵和偏置向量,Wi(w0)和b(b0)。同样,W和b是完全随机的,但一旦选择就固定下来。
极限学习机的最后一部分是B(w1),它是通过将训练数据传递通过隐层得到的。
z = activation(np.dot(xtrn,w0) + b0)
其中activation是选择的激活函数(h())。
最后,为了定义 B,我们将 YZ 乘以 (–1):
zinv = np.linalg.pinv(z)
w1 = np.dot(zinv, ytrn)
函数的返回值是已定义和训练好的极限学习机:(w0,b0,w1)。让我们进行测试。
测试
文件 elm.py 从中提取的 train 函数,训练一个极限学习机来分类数字数据集,使用用户提供的隐藏层节点数。例如:
> python3 elm.py 200 mt19937
[[49 0 1 0]
[ 1 42 0 9]
[ 2 4 39 4]
[ 1 5 6 37]]
accuracy = 0.835000
我们使用 Mersenne Twister 伪随机数生成器构建一个包含 200 个隐藏层节点的机器。输出显示混淆矩阵和总体准确率 83.5%。混淆矩阵几乎是对角线的,这很有意义。
让我们仔细分析混淆矩阵。行表示真实的类别标签,从上到下分别是 1、4、7 和 9。列从左到右是模型分配的类别标签(我们很快会看到)。查看最上面的一行,在测试数据集中,50 个 1 中,模型将 1 预测为 1 次达 49 次,但有一次将其预测为 7。
模型在混淆矩阵的最后一行,处理数字 9 时遇到最大困难。在 49 次测试中,模型正确预测了 37 次,但错将 9 预测为 7 次和 4 次。只有一次,模型将 9 错误预测为 1。
train 函数创建了极限学习机。为了使用它,我们需要 predict,如 清单 6-15 所示。
def predict(xtst, model):
w0,bias,w1 = model
z = activation(np.dot(xtst,w0) + bias)
return np.dot(z,w1)
清单 6-15:使用极限学习机进行预测
我们从提供的模型中提取权重矩阵和偏置向量,然后将测试数据通过隐藏层,最后通过输出层。activation 变量设置为当前使用的特定激活函数—默认情况下是 ReLU。我们将在下一节中尝试不同的激活函数。
predict 的输出是一个二维矩阵,行数与 xtst 中的行数相同(200),列数与类别数相同(4)。例如,第一个测试样本(xtst[0])的预测输出为:
0.19551782, 0.90971894, 0.05398019, –0.06542743
第一个已知的测试标签,作为一个 one-hot 向量为:
0, 1, 0, 0
请注意,模型输出向量中最大值的索引为 1,与 one-hot 标签中最大的值相同。换句话说,第一个测试样本属于类别 1(4),并且模型成功地预测了类别 1 为最可能的类别。最后的输出值是负数:模型输出的不是概率,而是决策函数值,其中最大值表示最可能的类别标签,即使我们没有与该值相关联的真实概率。
因此,要构建混淆矩阵,我们需要类似 清单 6-16 的代码。
def confusion(prob,ytst):
nc = ytst.shape[1]
cm = np.zeros((nc,nc), dtype="uint16")
for i in range(len(prob)):
n = np.argmax(ytst[i])
m = np.argmax(prob[i])
cm[n,m] += 1
acc = np.diag(cm).sum() / cm.sum()
return cm,acc
清单 6-16:为极限学习机构建混淆矩阵
confusion 函数返回混淆矩阵和总体准确率,但我们不是直接使用 ytst 和 prob 中的值,而是应用 NumPy 的 argmax 函数来返回四个元素向量中最大值的索引。
列表 6-17 显示了 elm.py 的其余部分,该部分加载数字数据集,将其缩放到 256,然后使用三行代码训练和测试极限学习机。
model = train(xtrn, ytrn, nodes)
prob = predict(xtst,model)
cm,acc = confusion(prob,ytst)
列表 6-17:训练和测试极限学习机
nodes 参数是从命令行读取的隐藏层节点数。
极限学习机对隐藏层节点数有多敏感?这是一个很好的问题。
默认情况下,elm.py 中的代码使用 ReLU 激活函数。然而,文件中定义了几个其他的激活函数。在本节中,我们将探索每种激活函数与不同数量的隐藏层节点的组合,看看是否有最佳组合。
ReLU 激活函数使用 NumPy 的 maximize 函数,该函数按元素返回两个参数中的最大值:
def relu(x):
return np.maximum(x,0)
我们并不局限于仅使用 ReLU。传统上,神经网络大量使用了 sigmoid 和双曲正切函数:
def sigmoid(x):
return 1 / (1 + np.exp(-0.01*x))
def tanh(x):
return np.tanh(0.01*x)
这两个函数都返回 S 形曲线。我在 sigmoid 和双曲正切函数中加入了 0.01 的系数来缩放 x。这通常不是做法,但在这里是必要的,以防止溢出错误。
为了有趣,我定义了几个其他激活函数:
def cube(x):
return x**3
def absolute(x):
return np.abs(x)
def recip(x):
return 1/x
def identity(x):
return x
让我们测试激活函数,随着隐藏层节点数从 10 到 400 的变化。代码位于 elm_test.py 中。它使用了 train、predict 和 confusion 函数。主循环如 列表 6-18 所示。
acts = [relu, sigmoid, tanh, cube, absolute, recip, identity]
nodes = [10,20,30,40,50,100,150,200,250,300,350,400]
N = 50
acc = np.zeros((len(acts),len(nodes),N))
for i,act in enumerate(acts):
for j,n in enumerate(nodes):
for k in range(N):
activation = act
model = train(xtrn, ytrn, n)
prob = predict(xtst, model)
_,a = confusion(prob, ytst)
acc[i,j,k] = a
np.save("elm_test_results.npy", acc)
列表 6-18:测试激活函数和隐藏层大小
对每种激活函数和隐藏层大小的组合,训练了 50 个极限学习机,并跟踪整体准确度(acc)。注意将 act 赋值给 activation。在 Python 中,函数可以自由赋值给变量,然后在引用该变量时使用(如在 train 中)。
运行 elm_test.py,传入一个随机源(我使用了 MT19937)。当它完成后,运行 elm_test_results.py 解析输出并生成类似于 图 6-3 的图表,显示按隐藏层大小和激活函数的平均准确率。误差条存在但较小。

图 6-3:极限学习机性能作为隐藏层大小和激活函数的函数
图 6-3 最显而易见的结论是,y = x³是一个糟糕的激活函数,因为它总是导致比其他激活函数更差的模型。另一个有趣的观察是激活函数的形状相似:随着隐藏层节点数量的增加,模型准确性迅速提高,然后达到最大值后缓慢下降。激活函数之间的差异很小,尤其是在 100 个隐藏节点的最大值附近;然而,双曲正切函数在本次实验中表现最佳。实际上,tanh在大多数实验中表现最好,因此可以公平地说,对于这个特定数据集,使用tanh和 100 个隐藏层节点的极限学习机是最合适的选择。
恒等激活函数,f(x) = x,避免了非线性;所有的性能都来自于线性顶层将隐藏层的输出映射到每个类别的预测。
鲁莽的群体优化
极限学习机的吸引力在于,与训练相同规模的传统神经网络相比,模型训练的速度惊人。的确,第一次训练的模型可能不是那么好,但连续尝试几次并保留表现最好的模型似乎是合理的。我可以想象一种情景,其中自主系统可能需要快速训练一个模型,以应对快速变化的输入数据。
然而,极限学习机中的随机部分——即从输入到隐藏层的权重矩阵和偏置向量——让我产生了疑问:我们是否可以通过群体优化来学习权重矩阵和偏置向量?这行得通吗?如果可以,是否比随机版本更有效?我的想法显然忽略了极限学习机本来就是通过随机权重和偏置来工作的,但我想知道即使比传统神经网络训练更具计算开销,群体优化是否可能发挥作用。
真正的挑战在于问题的维度。我们的输入是 784 维的向量。我们已经得出结论,100 个隐藏层节点似乎是一个不错的选择,因此权重矩阵和偏置向量的总维度是:
784 × 100 + 100 = 78,500
我们将要求群体在 78,500 维的空间中搜索,以找到一个好的位置,进而获得具有最高准确性的模型。这是一个艰巨的任务。
我实验过的代码在elm_swarm.py中。我不会逐步讲解它,但你会看到它遵循了前几章中的类似优化代码。目标函数使用每个粒子的位置作为权重矩阵和偏置向量,然后学习输出权重矩阵,并在测试集上评估模型以得出整体准确性。因此,每次调用Evaluate方法都会得到一个经过训练和测试的模型。
要运行代码,可以使用这样的命令行:
> python3 elm_swarm.py 100 tanh 20 60 de de.pkl
在这里,差分进化(de)用于寻找一个具有tanh激活函数和 100 个隐藏层节点的模型。该种群有 20 个粒子,并运行 60 次迭代,之后报告最终的混淆矩阵和准确率。最佳模型被存储在文件de.pkl中。当前最佳的准确率会在每次迭代时显示,因此你可以看到种群的学习过程。运行elm_swarm.py而不带参数可以查看所有选项。
例如,前一个命令产生了以下输出:
0: 0.90000 (mean swarm distance 111.844796164)
1: 0.90000 (mean swarm distance 111.253958636)
2: 0.90000 (mean swarm distance 110.932668482)
3: 0.90000 (mean swarm distance 110.743740374)
4: 0.90000 (mean swarm distance 110.701071153)
--snip--
57: 0.93000 (mean swarm distance 106.803938775)
58: 0.93000 (mean swarm distance 106.803938775)
59: 0.93000 (mean swarm distance 106.803938775)
[[50 0 0 0]
[ 0 52 0 0] [ 1 2 41 5]
[ 1 3 2 43]]
final accuracy = 0.930000 (DE-tanh-100, 20:60, 1220 models
evaluated, 4 best updates)
每次迭代的最佳准确率与种群中粒子之间的平均距离一起显示。如果种群正在收敛,那么在搜索过程中,这个距离会缩小,正如这里所示。两个粒子之间的距离使用公式计算,找到二维或三维空间中两点之间的距离。

但扩展到 78,500 维。结果仍然是一个标量。
经过 60 次迭代,种群找到了一个权重和偏置向量,使得在保留的测试集上的总体准确率达到了 93%。这次运行有四次最佳种群更新。第二次运行使用 GWO 进行 600 次迭代,找到了一个总体准确率为 94%的模型。在这次运行中,种群从初始的粒子间距离约 90 缩小到第 600 次迭代时不到 1。总共进行了 8 次最佳种群更新。
我对所有算法运行了elm_swarm.py,每个算法运行了五次。表 6-1 显示了结果的平均准确率。
表 6-1: 每个优化算法的模型平均准确率
| 算法 | 准确率(均值 ± 标准误差) |
|---|---|
| GWO | 0.9260 ± 0.0087 |
| 差分进化 | 0.9200 ± 0.0016 |
| 基本粒子群优化 | 0.9190 ± 0.0019 |
| Jaya 算法 | 0.9180 ± 0.0034 |
| GA | 0.9170 ± 0.0058 |
| RO | 0.9170 ± 0.0025 |
| 粒子群优化(PSO) | 0.9160 ± 0.0024 |
结果在统计学上没有显著差异,但从最好到最差的排名是典型的(除了 PSO 之外)。GWO 的均值标准误差较大,因为一次搜索找到了一个准确率为 95.5%的模型,这是我遇到的最高准确率。
我们平均需要运行多少次elm.py,才能找到一个符合或超过给定准确率的模型?文件elm_brute.py会生成一个又一个极限学习机,最多进行给定最大次数的迭代,尝试找到一个符合或超过指定测试集准确率的模型。
从结构上看,elm_brute.py是对elm.py的一个改动,它将在一个循环中进行模型创建和测试,并在成功时报告性能,或记录无法满足准确率阈值的情况。对不同阈值运行elm_brute.py,每个阈值进行 10 次运行,最多 2000 次迭代,生成了表 6-2。
表 6-2: 为达到给定准确率尝试的模型数量
| 目标准确率 | 均值 | 最小值 | 最大值 | 成功次数 |
|---|---|---|---|---|
| 0.70 | 1 | 1 | 1 | 10 |
| 0.75 | 1 | 1 | 1 | 10 |
| 0.80 | 1 | 1 | 1 | 10 |
| 0.85 | 2.2 | 1 | 6 | 10 |
| 0.90 | 147.2 | 15 | 270 | 10 |
| 0.91 | 504.2 | 87 | 1,174 | 9 |
| 0.915 | 858.4 | 69 | 1,710 | 9 |
| 0.92 | 788.3 | 74 | 1,325 | 4 |
第一列显示目标准确率。任何达到或超过该准确率的模型都被视为成功。接下来是找到一个达到或超过阈值模型所需的平均模型数,之后是最小和最大值。最后,显示在该阈值下,10 次实验中成功的次数。
85% 或以下的目标容易找到,平均只需进行两次搜索左右。然而,在 90% 的阈值下,突然出现一个跃升,平均需要创建大约 150 个模型。最少为 15,最多为 270,这意味着测试模型数量的分布存在长尾现象。
在 90% 以上时,模型的平均数量再次大幅增加,甚至出现了长尾现象,在 91.5% 时最多可达到 1,710。成功搜索的次数也减少,这意味着大多数情况下,2000 次迭代不足以找到准确率为 92% 或更高的模型。
我们现在有两种不同的方法。第一种方法通过随机分配权重和偏置并反复测试来盲目地寻找合适的模型——一种蛮力方法。第二种方法使用有原则的群体搜索来定位权重和偏置,并且更为成功。例如,前述的群体方法使用了 1,220 个候选模型来找到一个准确率为 93% 的模型,而运行 elm_brute.py 需要 21,680 个候选模型才能找到一个准确率为 93.5% 的模型。
群体技术在搜索如此高维空间时能够找到优良模型,令人印象深刻。这个方法如果不对代码进行大规模优化以提高速度,是不切实际的,但我们取得的任何成功都让人着迷。
极限学习机是随机性在实际应用中的典型例子。它们的结构鼓励实验,所以请大胆尝试。如果你发现了有趣的东西,请告诉我。与此同时,让我们继续探讨机器学习中随机性的最后一个例子。
随机森林
随机森林 是一组(或集成)决策树。我们稍后会定义这些术语。随机森林背后的思想是在 1990 年代发展起来的,并由 Breiman 在他那篇恰如其分地命名为《随机森林》的 2001 年论文中提出。因此,它们有着一定的历史背景。决策树本身甚至更早,可以追溯到 1960 年代初期。让我们从那里开始。
决策树
决策树是一种机器学习模型,由一系列是或否的问题组成,这些问题并不是问人,而是问特征向量。特征向量的答案序列从根节点开始,沿着树的路径走到一个叶子节点,即终端节点。然后,我们将与叶子节点关联的类标签赋予输入的特征向量。
决策树的一个优点是可解释性——通过它们的操作,决策树能够自我解释。神经网络难以自我解释,这个问题催生了 可解释人工智能(XAI),这是现代深度学习的一个子领域。
决策树最好通过一个示例来理解,在这个示例中,我们将使用一个包含三种鸢尾花的两项测量的小数据集。该数据集是二维的;有两个特征,总共有 150 个样本。我们将使用前 100 个样本来训练决策树,其余的 50 个样本用于测试。由于数据集只有两个维度,我们可以按类别绘制其特征;见 图 6-4。

图 6-4:鸢尾花特征
在 图 6-4 中,圆圈代表类别 0,方块代表类别 1,菱形代表类别 2。类别 0 与其他两个类别有很好的分离,而后者的重叠较大。因此,我们可以预期决策树分类器对类别 0 会表现得很好,但经常会把类别 1 和类别 2 混淆。
让我们为这个数据集构建一棵决策树。我使用的代码也生成了 图 6-4,代码文件是 iris_tree.py。它使用了 scikit-learn 的 DecisionTreeClassifier 类,并将树的深度限制为三层。像大多数 scikit-learn 类一样,fit 方法使用训练数据,predict 方法使用测试数据。iris_tree.py 的输出是:
[[18 0 0]
[ 1 4 11]
[ 0 1 15]]
0.74
这是一个混淆矩阵,总体准确率为 74%。类别 0 完全被正确分类,18 个样本中有 18 个被正确分类,而决策树将大多数类别 1 错误标记为类别 2。
图 6-5 显示了决策树的样子。

图 6-5:鸢尾花决策树
树的根位于顶部。每个框是一个节点,每个框的第一行包含一个问题——在本例中是“x[0] ≤ 5.45?”或者说特征 0 是否小于或等于 5.45?如果答案是“是”,则向左移动;否则,向右移动。然后考虑该节点中的问题。继续这个过程,直到到达一个叶节点,或者没有子节点的节点。
节点的值部分表示该节点处每个类别的训练样本数量。例如,最左边的叶节点的 value=[1,0,0],表示该节点中只有一个类别 0 的样本。因此,任何指向该节点的路径都会将类别 0 分配给输入特征向量。同样,紧邻右侧的叶节点标记为 [0,5,0],因此指向该节点的特征向量会被分配为类别 1。最后,第二个右侧的叶节点标记为 [1,18,24],表示该节点中有 1 个类别 0 的训练样本和 24 个类别 2 的样本。当多个类别都出现在同一节点时,按照多数规则,该节点会将特征向量分配为类别 2。
每个节点中有另外两行:样本数和基尼指数。前者是该节点上训练样本的数量,后者是该节点的值向量的和。决策树算法使用基尼指数来拆分节点;我们在这里不详细讨论,但你可以查看 scikit-learn 文档了解更多信息。
经典的决策树是确定性的;相同的数据集会生成相同的决策树。Scikit-learn 对此行为做了一些修改,但通过固定伪随机种子(我在iris_tree.py中鲁莽地这么做),我们可以生成一个可重复的决策树。我们接下来假设决策树是完全确定性的。
决策树可以自我解释。例如,输入特征向量{5.6, 3.3}将遍历图 6-6 中的路径。

图 6-6:决策树的路径
这是类 0 的一个示例,因为特征 0 的值在 5.45 和 5.75 之间,特征 1 的值大于 3.25。
决策树的确定性特征促成了随机森林的出现。对于某个特定的数据集,决策树要么表现很好,要么表现不好;它们通常会过拟合数据,对训练集的具体特征过于敏感,而对模型可能遇到的数据类型的整体特征不够敏感——至少不如我们希望的那样敏感。换句话说,它们可能在训练集上表现良好,但在其他所有数据上表现较差。
让我们学习如何通过引入随机性来遏制决策树过拟合的倾向。
额外的随机性
有三种技术将一个孤立的决策树转变为一个决策树森林,或称随机森林。第一种是袋装法(bagging),它通过对原始数据集进行有放回抽样(称为自助抽样)生成多个新的数据集。第二种是对于每个新的自助抽样数据集,使用可用特征的随机子集,第三种是集成法(ensembling),即创建多个模型,这些模型以某种方式投票或以其他方式组合它们的输出。让我们在将这些技术结合起来构建随机森林之前先回顾一下它们。
通过袋装法创建数据集
我有一个包含 100 个样本的数据集,其中特征 0 来自鸢尾花数据集。这些值是来自一个父分布的测量值,特定的这些值集合是该父分布的一个样本。样本的平均值为 5.85,我们可以将其视为对总体均值的估计,但不一定是实际的值。我们预计,在 95%的置信度下,实际的总体均值将在什么范围内?
我们只有一组 100 个值,但我们将使用引导法生成另一组数据,该组数据是从第一组中随机抽取的,并允许同一个样本被选择多次。这被称为“带替换的抽样”。现在我们有两组样本,它们都可以合理地认为来自母体总体。我们可以多次重复这个过程,生成多个数据集,每个数据集都有一个均值。然后,利用这些均值集合,我们使用 NumPy 的 quantile 函数来估算 95% 的置信范围。
文件 bootstrap.py 实现了这个过程。它加载鸢尾花训练集,保留特征 0,然后生成 10,000 个引导数据集,保留每个数据集的均值。为了获得 95% 的置信区间,我们需要知道 2.5% 和 97.5% 的分位数值。分位数提供了数据集的百分位数。第 50 百分位数是中位数,即排序后的数据的中间值。第 25 百分位数表示 25% 的数据低于该值,而第 75 百分位数则表示 75% 的数据低于该值。
为了获得 95% 的置信范围,我们需要找出 95% 数据落在其中的值,这意味着我们需要排除 5% 的数据:底部和顶部各 2.5%。为此,我们需要找出第 2.5 百分位数和 97.5 百分位数,这两个值可以通过 NumPy 获得。让我们回顾一下代码,见 清单 6-19。
import numpy as np
from RE import * def bootstrap(x):
n = RE(mode="int", low=0, high=len(x)).random(len(x))
return x[n]
x = np.load("iris_train_data.npy")[:,0]
means = [x.mean()]
for i in range(10000):
y = bootstrap(x)
means.append(y.mean())
means = np.array(means)
L = np.quantile(means, 0.025)
U = np.quantile(means, 0.975)
print("mean from single measurement %0.4f" % x.mean())
print("population mean 95%% confidence interval [%0.4f, %0.4f]" % (L,U))
清单 6-19: 引导法置信区间
首先,加载鸢尾花训练数据,保留特征 0(x)。然后生成 10,000 个 x 的引导版本,并跟踪每个版本的均值。使用 quantile 查找下界(L)和上界(U)置信区间,再报告它们。
bootstrap 函数创建了一个与 x 长度相同的向量 n,其中每个值是范围从 0 到 len(x)-1 的整数。这些是 x 中的索引,可能会出现相同的索引多次——一种带有替换的抽样。
每次运行 bootstrap.py 都会产生略有不同的范围:
> python3 bootstrap.py
mean from single measurement 5.8500
population mean 95% confidence interval [5.6940, 6.0090]
该输出表示,真实的总体均值在 95% 的置信度下介于 5.694 和 6.009 之间。没有引导法过程,我们无法仅凭一个测量集知道这个范围。如果我们对分布的形状做出假设,认为它是正态分布或遵循 t 分布,那么我们可以进行估算;而通过引导法,我们不需要假设数据是正态分布的。
引导法是一种在构建随机森林时获取置信区间的有用技术,因为每个引导数据集都是一组合理的测量数据。从这个角度来看,引导数据集是用于训练模型的新增数据集。袋装法(Bagging)是利用引导数据集训练多个决策树的过程。
模型集成的组合
装袋方法有帮助,因为每棵决策树都是在稍微不同的数据集上训练的;任何导致某个训练集过拟合的因素,希望能在另一个训练集中得到补偿。
我们将使用装袋方法创建一个决策树集成,其中我们使用自助采样数据集训练多个模型,并平均它们的预测结果,看看是否能改善普通决策树的结果。我们将使用本章开头的组织学数据集。文件bagging.py训练一个用户指定数量的决策树,每棵树都使用组织学数据集的不同自助采样版本。然后,我们将每个模型应用于组织学测试数据,并平均得到的预测结果,生成集成输出。平均模型输出是一种集成方法;我们将在本章后面使用另一种方法:投票。
请参考列表 6-20 中的相关代码。
def Bootstrap(xtrn, ytrn):
n = RE(mode="int", low=0, high=len(xtrn)).random(len(xtrn))
return xtrn[n], ytrn[n]
xtrn = np.load("../data/datasets/bc_train_data.npy")
ytrn = np.load("../data/datasets/bc_train_labels.npy")
xtst = np.load("../data/datasets/bc_test_data.npy")
ytst = np.load("../data/datasets/bc_test_labels.npy")
trees = []
for i in range(N):
tr = DecisionTreeClassifier()
if (bag):
x,y = Bootstrap(xtrn,ytrn)
tr.fit(x,y)
else:
tr.fit(xtrn,ytrn)
trees.append(tr)
preds = []
for i in range(N):
preds.append(trees[i].predict(xtst))
preds = np.array(preds)
pred = np.floor(preds.mean(axis=0) + 0.5).astype("uint8")
cm, acc = Confusion(pred, ytst)
列表 6-20:使用装袋方法构建决策树集成
代码定义了一个Bootstrap函数,加载组织学训练和测试数据集,使用自助采样训练集创建并训练多棵决策树,然后在测试数据上做出预测,最后将结果求平均,生成最终的预测集。
第一个for循环创建了一个决策树对象(即DecisionTreeClassifier的实例)。如果bag为真,它使用自助采样版本的训练数据来训练树;如果bag为假,它每次使用整个数据集(没有装袋)。Bootstrap函数需要选择特征向量和正确的标签。
接下来的循环创建了preds,这是每棵决策树的预测列表。每棵树都是在不同的自助采样数据集上训练的,因此如果bag为真,预测结果将有些微差异。将preds转为 NumPy 数组后,我们可以对每一行求平均,得到一个单一的向量,表示每个测试样本(列)的所有树输出的平均值。我们希望给这个平均值分配一个类别标签,0 或 1,因此我们加上 0.5,然后使用floor函数将其舍入到最接近的整数(0 或 1)。
最后,调用Confusion构建混淆矩阵和总体准确率,后续代码(未展示)将显示结果。
运行一次bagging.py,使用 60 棵树和装袋方法,返回结果为:
> python3 bagging.py 60 1
Bagging with 60 decision trees:
[[101 3]
[ 9 58]]
overall accuracy 0.9298
first six accuracies: 0.9357 0.9064 0.9357 0.8830 0.9123 0.9123
总体集成准确率为 92.98%。前六棵决策树的准确率也显示在内。你的运行结果可能会有所不同。
再次运行代码且不使用装袋方法时,返回的结果是:
> python3 bagging.py 60 0
Bagging with 60 decision trees:
[[99 5]
[13 54]]
overall accuracy 0.8947
first six accuracies: 0.9006 0.9006 0.8947 0.8947 0.8713 0.8947
这是一个显著较差的结果。
我运行了bagging.py 30 次,第一次使用了装袋方法,第二次没有使用。各自的平均准确率为 92.73%和 89.77%,使用 Mann-Whitney U 检验的 p 值小于 10^(-10)。装袋对模型质量有显著影响。
自助采样训练集和集成方法构成了随机森林的三分之二。现在让我们添加剩下的三分之一:随机特征集。
使用随机特征集
随机森林的最终要素是使用可用特征的随机子集。传统上,我们使用的特征数量是可用特征的平方根。例如,组织学数据集有 30 个特征,所以我们将在每次需要自助数据集时,随机选择 5 个特征。
公式如下:
-
使用五个随机选择的特征选择一个自助数据集。
-
使用此数据集训练决策树。保持它和选择的特定特征集(用于测试)。
-
对森林中的每棵树重复步骤 1 和步骤 2。
-
对测试数据应用每棵树,只使用树所期望的特征。
-
对测试集的结果取平均,以获得随机森林的最终预测。
在代码中,添加随机特征选择只是对装袋示例的一个小调整;查看forest.py。 列表 6-21 显示了与bagging.py的相关更改。
def Bootstrap(xtrn, ytrn):
n = RE(mode="int", low=0, high=len(xtrn)).random(len(xtrn))
nf = xtrn.shape[1]
m = np.argsort(RE().random(nf))[:int(np.sqrt(nf))]
return xtrn[n][:,m], ytrn[n], m
trees = []
for i in range(N):
tr = DecisionTreeClassifier()
x,y,m = Bootstrap(xtrn,ytrn)
tr.fit(x,y)
trees.append((tr,m))
preds = []
for i in range(N):
tr,m = trees[i]
preds.append(tr.predict(xtst[:,m]))
preds = np.array(preds)
列表 6-21:实现一个随机森林
首先,我们必须修改Bootstrap,不仅要选择训练集的随机采样(n),还要选择一个随机的特征集(nf,最终得到m)。必须返回提取的特定特征,以便在测试时使用。
第二段像之前一样训练树,但同时带上m。最后,在测试时,我们对xtst应用每棵树,只保留适当的特征子集。forest.py的输出与bagging.py的输出相同,除了前六个单独树的准确率。
我按照之前的方法运行了代码 30 次,使用 60 棵树,以获得表 6-3 中的平均准确率。我包含了之前的平均准确率,以展示随着每个阶段的随机森林添加,准确率的提升。
表 6-3: 按模型类型的平均准确率
| 选项 | 平均准确率 |
|---|---|
| 无装袋 | 89.47% |
| 装袋,集成 | 92.98% |
| 装袋,集成,随机特征 | 95.25% |
所有随机森林步骤结合起来,相比简单的决策树,显著提高了性能。
正如你所料,scikit-learn 也通过RandomForestClassifier类支持随机森林:
from sklearn.ensemble import RandomForestClassifier
RandomForestClassifier类支持所有三种技巧以及其他技巧;请参阅 scikit-learn 文档。使用 60 棵树和所有默认设置训练 30 个RandomForestClassifier实例,结果的平均准确率为 96.55%,比forest.py的结果还要好。
与投票结合的模型
在我们离开这一部分之前,让我们研究森林的大小如何影响性能。为了进行此实验,我们将切换到 MNIST 数字数据集。此外,我们将不再对每个模型的输出取平均,而是通过投票来分配类别标签。
我们需要的代码在forest_mnist.py中。它基于forest.py,通过不同大小的森林循环,以确定使用那么多棵树的 20 个模型的平均准确率。输出是一个图形,但在我们检查它之前,让我们回顾一下如何实现投票:
preds = []
for i in range(N):
tr,m = trees[i]
preds.append(tr.predict(xtst[:,m]))
preds = np.array(preds)
pred = []
for i in range(preds.shape[1]):
pred.append(np.argmax(np.bincount(preds[:,i])))
pred = np.array(pred)
cm, a = Confusion(pred, ytst)
acc.append(a)
第一个代码段捕捉了森林中每棵树的预测结果。第二个代码段创建了pred,一个向量,存储每个模型在每个测试样本上最常选择的类别标签。为了获取测试样本i的获胜者,我们首先使用bincount统计每个标签在所有模型中出现的频率(preds的行),然后使用argmax返回出现次数最多的类别标签的索引。若出现平局,则选择首次出现最大值的标签,即较小的索引。通过这种方式打破平局可能会对较低的类别标签引入轻微偏差,但我们可以接受这一点——可以将其视为一种系统性误差,因为所有森林大小都受到相同影响。
图 6-7 展示了准确率如何随着森林大小的变化而变化。

图 6-7:数字数据集的平均准确率与森林大小的关系
起初,增加树木数量有助于提高性能,但最终,随着森林规模的增大,会出现饱和现象,回报递减。
练习
机器学习是一个广泛且至关重要的领域。以下是与本章主题相关的一些练习,帮助你提升机器学习的专业知识和直觉:
-
我们对数字数据集进行了相对保守的数据增强,进行了小幅旋转和缩放。其他图像处理选项可能有助于提高本章模型的性能。通过向
augment函数中添加其他选项来进行实验,文件位置为build_mnist_dataset.py。Python 中PIL模块的Image和ImageFilter类可能会有所帮助。使用Image.fromarray将一个dtype为 uint8 的 NumPy 数组转换为 PIL 图像。反向操作时,将Image对象传递给np.array即可。 -
MLPClassifier的Classifier子类在init_test.py中定义了多种初始化神经网络的方法。可以添加新的方法,看看它们如何影响结果。如果所有权重矩阵初始值为零或某个常数值,会发生什么?如果偏置向量为零呢?考虑实验 beta 分布(np.random.beta),因为调整其两个参数可以生成形状各异的样本。 -
极限学习机使用一个随机生成的权重矩阵和偏置向量将输入映射到第一个隐藏层。我们在本章中使用的选择方法在文献中比较常见。如果改变该方法,按非均匀分布选择随机值会发生什么呢?可以考虑使用
np.random.normal(或者基于RE的版本)以及 beta 分布。会产生很大影响吗? -
创建一个两层极限学习机,其中
w0、w1、b0和b1是随机选择的。最终的权重矩阵w2将像以前一样从第二层隐藏层的输出中学习。将其性能与单层版本进行比较。 -
在datasets目录中,你会找到以mnist_14x14开头的文件。它们包含了所有 MNIST 数字[0, 9]的 14×14 像素版本。尝试将它们替换为本章中使用的四位数字版本。各种模型的表现如何?
-
修改elm_brute.py,以跟踪相同准确度下经过多次运行测试的所有模型的数量。然后使用
np.histogram和 Matplotlib 绘制固定准确度(例如 0.92)的直方图。它们的形状是什么样的?这种形状对你来说有意义吗? -
我们主要忽略了 scikit-learn 的
RandomForestClassifier,转而使用我们自己开发的版本。通过阅读该类的文档页面并尝试不同选项,深入探索 scikit-learn 的方法。考虑使用组织学数据集和数字数据集。 -
运行rf_vs_mlp.py,然后运行rf_vs_mlp_results.py。(需要一些耐心。)考虑输出结果,这展示了输入特征向量的缩放如何影响模型性能。哪种类型的模型对特征的相对范围敏感,是神经网络还是随机森林?为什么会这样?思考一下神经网络的目标,并与随机森林中的单棵决策树进行比较。为什么一种模型会关注特征范围,而另一种则可能不会?
总结
本章探讨了在机器学习中构建数据集时随机性的重要性,既包括在训练过程中对样本的排序,也包括通过合理的新样本增强现有样本,以扩大模型学习的数据类型。
接下来,我们讨论了神经网络的初始化。我们对子类化了 scikit-learn 的MLPClassifier,重写了其初始化方法,允许我们添加备用的初始化方式。然后,我们检查了这些方法对模型性能的影响。
接下来,我们探讨了极限学习机,这是一种将随机性作为其核心组成部分的神经网络子类型。我们了解了这些机器在我们的数据集上的表现,然后考虑了隐藏层大小和激活函数的影响。我们通过用一个群体优化练习学习到的随机权重矩阵和偏置向量替换原有的权重和偏置,来结束这一部分内容。我们发现,群体算法能够生成超越大多数极限学习机(在相同架构下)表现的模型。
最后,我们实验了随机森林,它是由一组决策树组成的。我们了解了什么是决策树,以及如何通过集成法、投票法和随机特征选择来构建随机森林。
下一章将从实践中抽离,带来通过生成艺术来提升我们的生活。
第七章:ART**

在本章中,我们将探索生成艺术——通过使用随机性算法创建的图像。我们将从“随机艺术”开始,这是一个总括性的词汇,涵盖了三种不同的艺术图像生成方法。虽然也存在其他方法,但这些将让你体验一下可能的艺术形式。我们还将学习分形艺术,分形曾在几十年前非常流行,当时个人计算机终于足够强大,能够做一些有趣的图形处理。
本章中我们将生成许多彩色图像,尽管本书是黑白的。我建议你运行代码以查看它们的实际效果。
创建随机艺术
我们将从三个例子开始。第一个例子模仿了一款令早期 Apple II 个人计算机爱好者惊叹的程序。它还将我们引入 Python 的 turtle 图形包。
第二个例子实现了一个随机漫步,实际上是布朗运动的模拟,即通过显微镜观察流体中粒子的随机运动。这是一个简单的过程,配合合适的颜色表,能够生成适合印在 T 恤和咖啡杯上的美丽图像。
最后的例子通过使用随机选择的函数、旋转和颜色表来扭曲二维点阵,生成独特的图像,同样适合打印。
莫尔条纹
莫尔条纹出现在数字图像中,当计算机屏幕的离散世界与应该是连续表示的图像相遇时,也就是说,当画一条线时,该线无法通过显示器强加的网格准确表示时。 图 7-1 展示了一个例子。

图 7-1:“Brian’s Theme”由 Apple II 计算机呈现
图 7-1 是 1979 年由 Brian Howard 创建的简单 BASIC 程序《Brian’s Theme》;它可以在 1983 年的 Apple II DOS 3.3 系统主盘中找到。该程序从一个随机点开始,接着从该点画出一条条线到达屏幕边缘,每次都按随机选定的值进行步进。Apple II 显示器的 280×160 像素的粗糙分辨率,以及绘制时点的颜色处理方式,产生了莫尔条纹。
让我们来创造自己的莫尔条纹。我们需要的代码在moire.py文件中。它向我们介绍了 Python 的 turtle 图形模块(turtle),这是 Python 标准库的一部分。
注意
“turtle graphics”一词来自于 Logo 编程语言,它使用图形化的“海龟”在屏幕上拖动笔触,帮助儿童学习编程概念。
简单的命令,如FD 10(前进 10),RT 90(右转 90 度)和PD(笔触)使得使用相对较少的编程知识就能创建复杂的图形;可以想象一个老式的螺旋画游戏,只不过是在计算机上实现。
我们希望海龟从一个随机选取的靠近中心的点出发,画出直线到达边缘,每次沿边缘走一定的距离。结果类似于 图 7-2。

图 7-2: “Brian’s Theme” 复刻版
图 7-2 不过是直线一条接一条;这个图案源自莫尔条纹效应。
首先,我们导入必要的模块,并要求海龟配置显示:
import time
import turtle as tu
from RE import *
tu.speed(0)
tu.ht()
tu.getscreen().setup(500,500)
tu.getscreen().bgcolor('black')
x = np.linspace(-200,200,400)
y = np.linspace(-200,200,400)
我们配置海龟,这里是 tu,让它尽可能快地行进,然后隐藏它自己(ht 为“隐藏海龟”)。这样,我们只看到它绘制的内容。
绘图窗口调整为 500×500 像素,背景为黑色。x 和 y 维度使用 NumPy 的 linspace 来生成从 -200 到 200 的 400 个点的向量。海龟的屏幕将原点放置在显示窗口的中央。
我们将绘制一个莫尔条纹图案,稍等片刻,然后清空屏幕并绘制另一个——这是最好的交互式视觉艺术。我们需要一个循环:
while (True):
tu.clear()
X,Y = RE(mode='int', low=-100, high=100).random(2)
step = RE(mode='int', low=2, high=9).random()
r,g,b = RE(mode='int', low=1, high=256).random(3)
color = "#%02x%02x%02x" % (r,g,b)
tu.color(color)
for i in range(0,400,step):
Line(X,Y, x[i],y[0], color)
Line(X,Y, x[0],y[i], color)
Line(X,Y, x[i],y[-1], color)
Line(X,Y, x[-1],y[i], color)
time.sleep(4)
要开始绘图,我们清空屏幕并选择原点(X,Y)和 step 大小。我们使用 HTML 表示法指定颜色为红、绿、蓝;例如,#FF0000 是鲜红色,#E0B0FF 是淡紫色,#A0522D 是铁锈色,等等,按比例混合红、绿、蓝。
一个简单的循环随后使用 Line 从中心点绘制到四个屏幕边缘的图案:
def Line(x0,y0,x1,y1,color):
tu.color('white')
tu.pu()
tu.goto(x0,y0)
tu.pd()
tu.goto(x1,y1)
tu.color(color)
tu.goto(x0,y0)
tu.pu()
Line 方法首先用白色绘制请求的直线,然后用随机选择的颜色重新描绘它,产生闪烁效果。
考虑到现代计算机的速度,你可能会期望莫尔条纹图案会在屏幕上闪现。然而,Python 的海龟图形与其名字一样,以悠闲的速度绘制图案,本质上匹配了我们模仿的 Apple II 代码的速度。
我们将在本章稍后再次使用海龟图形。现在,是时候去散步了。
随机游走
一个 随机游走 算法遵循“两步前进,一步后退”的模型,二维空间内:从原点开始,反复朝随机方向迈步。为了将这个简单的算法转化为艺术,我们跟踪步伐的顺序,创造出一个图像,每一步都会从 Matplotlib 的颜色表中选取一个颜色。
颜色表——也称为色彩映射表或查找表——是一个由红、绿、蓝格式的颜色列表构成的表。大多数颜色表,包括 Matplotlib 中的颜色表,都有 256 个条目。例如,如果某个感兴趣的值是 129,或者可以赋值为 129,那么与之关联的颜色将存储在当前使用的颜色表的索引 129 处。
如果期望的索引是 129 且颜色表是 viridis,那么得到的 RGB 颜色值是:
>>> from matplotlib import cm
>>> cmap = cm.get_cmap("viridis")
>>> cmap(129)
(0.126453, 0.570633, 0.549841, 1.0)
这段代码示例演示了如何访问 Matplotlib 的颜色表,但输出可能不像预期的那样。为什么有四个值?它们为什么不像海龟图形的十六进制颜色值那样在[0, 255]的范围内?RGB 颜色值通常映射到[0, 1],或者作为最大值 1.0 的分数。为了找到对应的字节值,需要乘以 255,并保留整数部分。前述代码中的颜色是十六进制的#20918C。
这解释了前三个值,但第四个分量是透明度值,表示颜色的透明度。透明度值为 1.0 时是完全不透明的,0.0 时是完全透明的,而 0.5 时颜色与后面的像素颜色混合(类似堆叠图形平面)。对于我们的目的,我们允许透明背景(当查看代码时,您会发现这意味着什么)。
Matplotlib 提供了 84 种预定义的颜色表,称为色图;请参见color_map_names.txt获取完整列表。我们的随机漫步代码支持它们的任何组合。
随着随机漫步“反复在随机方向上迈步”,我并没有指定允许的方向集合。从一个点(x,y)出发,可能的方向有四个或八个,如图 7-3 所示。

图 7-3:随机漫步者可以移动的四个(左)或八个(右)方向
左侧的随机漫步者(称为4 连接)仅限于朝着四个方向行走:北、南、东或西。而右侧的漫步者(8 连接)则可以选择对角线行走。walker.py 中的代码支持这两种选择。
请注意,如何标记从(x,y)的偏移量。如果你习惯于使用笛卡尔坐标系作图,就像数学课上那样,可能会对符号感到困惑。通常,我们期望在第一象限作图,其中 x 和 y 都是正值,并分别向右和向上增大;但大多数计算机并不是这样处理的。相反,它们将原点放在屏幕的左上角,因此 y 随着向下移动而增大,x 随着向右移动而增大。从数学角度来看,我们正在第四象限作图,并忽略了 y 轴的符号。
让我们运行walker.py,看看它给我们带来什么:
> python3 walker.py 4 1000000 Reds,Oranges,Reds,Oranges none portrait tshirt.png mt19937 8675309
该命令根据给定的扩展生成输出图像,这里是 PNG 文件;见图 7-4。

图 7-4:示例随机漫步输出
第一个参数告诉代码执行 4 连接的漫步。第二个参数是每个指定颜色表应迈的步数。接下来的就是颜色表,按逗号分隔且不留空格。在这里,我反复使用了 Matplotlib 的 Reds 和 Oranges。每个颜色表 1,000,000 步,总共有四个颜色表,图 7-4 中的图像表示了 400 万步随机漫步,每一步生成一个像素。
在颜色表之后,我们添加背景色或单词none表示透明背景。我们可以使用 HTML 十六进制格式指定颜色,格式中不需要#,因此000000是黑色背景,FFFFFF是白色。
以下参数是输出图像的方向。行走会在某个方向上自然地更为广泛。此参数将输出图像定向为portrait(使输出图像的最长维度位于 y 方向)或landscape(使输出图像使用 x 维度)。
最后的两个参数是随机源和源的种子值。两者都是可选的。
注意
我将输出文件命名为 tshirt.png ,因为我使用这个例子制作了一件真正的 T 恤。有许多在线服务允许你上传图片并订购 T 恤。如果你想自己制作,我推荐使用数百万步、透明背景,甚至封装的 PostScript 输出格式(在命令行使用.eps文件扩展名)。
让我们通过walker.py来走一遍。我将跳过导入常见模块和解析命令行的部分。代码如下:
if (len(sys.argv) == 8):
kind = sys.argv[7]
rng = RE(kind=kind, mode="int", low=0, high=mode)
elif (len(sys.argv) == 9):
kind = sys.argv[7]
seed = int(sys.argv[8])
rng = RE(kind=kind, mode="int", low=0, high=mode, seed=seed)
else:
rng = RE(mode="int", low=0, high=mode)
我们首先配置随机源。在这种情况下,如果是 4 连通,我们选择[0, 3]范围内的随机整数;如果是 8 连通,则选择[0, 7]范围内的随机整数。我们将使用这些值作为 x 和 y 偏移量列表的索引,并将这些偏移量添加到当前位置以迈出一步。代码中的三个案例处理指定随机源、带有种子值的源或默认值。
接下来是行走。主循环跟踪X和Y列表中的点,以及C中的颜色。在这一层,循环遍历指定的颜色表:
X = []; Y = []; C = []
for cname in cnames:
x,y,c = Walk(steps,cname,mode)
X = X + x
Y = Y + y
C = C + c
Walk函数模拟当前颜色表(cname)和mode的完整steps步行走:
def Walk(steps, cname, mode):
try:
cmap = cm.get_cmap(cname)
except:
cmap = cm.get_cmap("inferno")
if (mode == 8):
offset = [[0,-1],[1,-1],[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1]]
else:
offset = [[0,-1],[1,0],[0,1],[-1,0]]
X = [0]
Y = [0]
C = [cmap(0)]
for i in range(steps):
m = rng.random()
X.append(X[-1] + offset[m][0])
Y.append(Y[-1] + offset[m][1])
➊ c = cmap(int(256*i/steps))
C.append((c[0],c[1],c[2]))
return X,Y,C
首先,我们获取颜色表(cmap),然后定义offset,它是 4 连通和 8 连通行走的 x 和 y 偏移值列表。行走本身从(0, 0)开始,使用索引为 0 的颜色(C)。
对steps的循环模拟了步态。我们为 x 和 y 的最后位置分别添加一个随机选择的偏移量。考虑选择颜色的代码 ➊。循环索引i的范围是 0, steps)。除以steps会产生一个分数,[0, 1)。乘以 256 则选择颜色表的一个索引。因此,一次完整的随机行走会遍历整个颜色表一次。还有其他实验选项;请参见[第 238 页的“练习”。
目前,没有输出图像,只有一组点(x,y)及其关联的颜色在C中。为了生成图像,我们需要CreateOutputImage:
def CreateOutputImage(X,Y,C, background):
x = np.array(X)
y = np.array(Y)
xmin = x.min(); xmax = x.max()
dx = xmax - xmin
ymin = y.min(); ymax = y.max()
dy = ymax - ymin
img = np.zeros((dy,dx,4), dtype="uint8")
if (background is not None) and (background != "none"):
try:
r = int(background[:2],16)
g = int(background[2:4],16)
b = int(background[4:],16)
a = 255
except:
r,g,b,a = 0,0,0,0
else:
r,g,b,a = 0,0,0,0 img[:,:,0] = r; img[:,:,1] = g
img[:,:,2] = b; img[:,:,3] = a
for i in range(len(x)):
xx = int((dx-1)*(x[i] - xmin) / dx)
yy = int((dy-1)*(y[i] - ymin) / dy)
c = C[i]
img[yy,xx,0] = int(255*c[0])
img[yy,xx,1] = int(255*c[1])
img[yy,xx,2] = int(255*c[2])
img[yy,xx,3] = 255
return img
该函数分为三部分。第一部分生成 x 和 y 点的 NumPy 向量,并确定每个方向的扩展量(dx, dy),以指定输出图像 img。输出图像使用 4 个通道,而不是我们之前使用的 3 个通道;因为我们要支持透明背景,需要明确指定 alpha 通道。
接下来,我们将整个图像设置为透明背景色。使用 0 作为 alpha 通道的值表示透明背景,使用 255 表示完全不透明的背景。
我们遍历这些点,将每个点映射到图像的一个像素,并明确指定红色、绿色和蓝色的颜色值。查看 xx 和 yy 的赋值,了解它们如何将原始点映射到有效的图像坐标。
注意 img 的索引方式。代码将点 (x, y) 分配给图像,但图像的索引方式是 (yy,xx)。图像是先按行索引,再按列索引,这意味着 y 坐标(使用左上角为原点的约定)表示行,x 表示列。
我们快完成了。CreateOutputImage 函数根据行走的实际点返回图像。为了生成最终输出,我们将图像重新调整方向,以适应竖屏或横屏方向:
img = CreateOutputImage(X,Y,C,background)
rows, cols, _ = img.shape
if (orient == "portrait"):
if (rows < cols):
img = img.transpose([1,0,2])
else:
if (rows > cols):
img = img.transpose([1,0,2])
Image.fromarray(img).save(oname)
transpose 方法按指定的方式重新排列 NumPy 数组的列。我们根据需要交换行和列,但确保通道数保持不变。阅读 walker.py 以跟踪代码的整体流程。
我在章节文件中包含了几个示例输出,以激发你创建自己的作品。我特别喜欢 example1.png;它让我想起了 1970 年代《神秘博士》剧集中的科幻场景。example5.png 文件使用了当时为 1,130,496 字节的 hotbits.bin 文件。循环模式是由于请求的点数超过了从文件中提取的随机样本数,从而导致了重复。example5.txt 文件包含命令的文本。如果你创建了任何有趣的示例,请分享它们。我会在本书的 GitHub 页面上创建一个小画廊。
让我们转换思路,结合随机性和确定性,生成通过 xy 平面扭曲构建的图像。
一个网格
如果你取一个二维的均匀分布的点网格,应用一个将每个点映射到新点的函数,然后绘制这些点,会发生什么?换句话说,当你扭曲一个点网格时,会发生什么?在本节中,我们将揭示答案,并利用这一过程创造随机的抽象数字艺术作品。
图 7-5 说明了这一基本过程。

图 7-5:左侧是点的网格,右侧是经过扭曲后的相同网格
在 图 7-5 的左侧,我们有一个 30×30 点的网格。我们对每个点 (x, y) 应用一个函数,生成一个新点 (x′, y′),该点绘制在 图 7-5 的右侧。
该函数是:
(x′, y′) ← (y³ + x, x² + y)
对于输入点 (x, y),输出的 x 坐标是 y³ + x,而 y 坐标是 x² + y。
同样,函数 (x, y) → (yx², xy²) 将均匀的点网格转换为 图 7-6。

图 7-6:由 (x,y) → (yx², xy²) 变形的均匀网格
我们将生成一组变形函数,随机选择一个,并将其应用于均匀的点网格。然后,我们重复这个过程指定次数,并在此过程中保留所有输出点。
为了添加颜色,我们使每个变形函数返回一个颜色表索引和新的点位置。因此,每个变形函数将输入点映射到一个新的输出点和颜色表索引。每个周期会随机选择一个新的颜色表。
为了增加更多的随机性,我们将通过添加随机选择的 x 和 y 偏移量来平移点集合。因为我们是冒险型人物,所以我们还将以一个随机角度围绕原点旋转这些点。
我定义了一个包含五个变形函数的集合,其中有两个我们已经见过:

输入网格始终在[–1, 1]范围内。每个函数返回新的点和颜色表索引,这些索引是通过将第三个返回值乘以 255 后取整数部分得到的。随机函数应用、旋转、偏移和颜色表的累积效应产生了期望的输出图像。
代码在 warp.py 中。运行方式如下:
> python3 warp.py 300 11 example.png minstd 6502
以产生 图 7-7。

图 7-7:变形效果
warp.py 的第一个参数是每个网格维度上的点数。第二个是周期数,或者说我们将随机选择的函数应用到网格上的次数。最后三个参数是输出图像的名称、随机性源和种子(如果需要)。
首先是变形函数。例如:
def f0(a,b):
x,y = a**3 + b, b**2 + a
c = int(255*(a*b+1)/2)
return x,y,c
这实现了集合中的第一个变形函数。其余四个函数类似。我们将变形函数存储在列表中,以便可以随机选择:
funcs = [f0,f1,f2,f3,f4]
warp.py 的主体解析命令行并从 color_map_names.txt 加载颜色表名称。然后,它定义了空列表来存储所有的 (x, y) 点和关联的 RGB 颜色,分别是 X、Y 和 C。
网格被指定为一个从 –1 到 +1 的 npoints 长向量:
v = -1 + 2*np.arange(npoints)/npoints
所有的操作都在对周期和网格点的循环中:
for k in range(cycles):
n = int(len(cnames)*rng.random())
cmap = cm.get_cmap(cnames[n])
n = int(len(funcs)*rng.random())
fn = funcs[n]
xoff,yoff = rng.random(2)-0.5
theta = np.pi*rng.random()
for i in range(len(v)):
for j in range(len(v)):
n,m,c = fn(v[i],v[j])
x = n*np.cos(theta) - m*np.sin(theta)
y = n*np.sin(theta) + m*np.cos(theta)
X.append(x+xoff)
Y.append(y+yoff)
C.append(cmap(c))
外层循环处理 k 变量,负责循环操作。每个周期会随机选择一个颜色映射(cmap)和一个变形函数(fn)。它还会选择随机的 x 和 y 偏移量(xoff,yoff)以及旋转角度(theta)。
两个内部循环,遍历i和j,在x和y方向上走遍v,访问 2D 网格中的每一个点。选定的函数应用于每个点,n,m,c = fn(v[i],v[j]),返回一个新点(n, m)和颜色表索引(c)。要围绕原点旋转一个点,我们将其乘以旋转矩阵。

根据矩阵乘法的规则,变为:

这与之前的代码相匹配。
旋转完成后,最后一步是添加x和y的偏移量。这些偏移量将点在x和y方向上移动,以避免它们重叠。
每个扭曲、旋转和偏移后的点都会添加到点和颜色的列表中。请注意,扭曲函数返回的是颜色表索引,但C保存的是当前颜色表由cmap返回的元组。
所有周期完成后,我们生成图表并将其保存到磁盘。Matplotlib 依照要求执行:
plt.scatter(X,Y, marker=',', s=0.6, c=C)
plt.axis('off')
plt.tight_layout(pad=0, h_pad=0, w_pad=0)
plt.savefig(oname, dpi=300)
plt.show()
这里,oname是输出图像的名称;它来自命令行参数。
与 Matplotlib 的plot命令不同,scatter接受每个点的颜色列表,这就是为什么我们为每个点构造了C。tight_layout命令去除多余的空间,这在关闭坐标轴后非常实用。
运行warp.py几次来实验代码。如果你将网格设置得过于细致,设置第一个参数超过 300 左右,可能会耗尽内存。虽然并不需要指定伪随机生成器和种子值,但这样做可以让你重新生成输出。文件warp_factor_9.py使用一个用户提供的全局种子创建指定数量的扭曲图像。
> python3 warp_factor_9.py 3141592 100 warpings
在warpings目录中创建 100 张图像。运行大约花费了 15 分钟,并生成了一些吸引人的输出。Matplotlib 的循环色彩表,如flag,展现了惊艳的效果。
虽然warp.py能生成漂亮的图像,但它的随机性并不特别深刻;它从一组选项中选择,依靠可能的选项组合来产生新颖性。
下一节将带领我们了解分形:一个充满数学、涌现行为和随机性的世界。
分形的乐趣
分形是由自身的较小副本构成的数学对象;它们是自相似的。我们的关注点是数学分形,但近似分形在自然界中很常见——例如,树的分枝、肺部的气道和蕨类植物的叶片。
在本节中,我们将探讨随机性作为生成分形图像的一种手段。关于艺术和计算机图形学的分形有着丰富的文献。我们将做一个最简单的介绍。
首先,我们将玩混沌游戏,用 Python 的海龟画图构建简单的分形图像。然后,我们将了解生成分形图像的更复杂方法——迭代函数系统(IFS)。最后,我们将把所有内容结合起来,构建ifs.py。
混沌游戏
让我们来玩一下混沌游戏,这个名字由数学家和分形探索者迈克尔·巴恩斯利命名。它的运作方式如下:
-
选取三个点,(x[0], y[0])、(x[1], y[1])和(x[2], y[2]),作为三角形的顶点。
-
设置x = x[0]和y = y[0]。
-
随机选取三角形的一个顶点,(x[n], y[n]),其中n在[0, 2]之间。
-
使用x ← 0.5(x + x[n])和y ← 0.5(y + y[n])进行更新。
-
绘制(x, y)。
-
从第 3 步开始重复。
该算法在xy平面上绘制点。让我们看看这些点的集合是否最终覆盖了平面,或者是否存在某种模式。运行sierpinski.py:
> python3 sierpinski.py
应该会弹出一个小窗口。Python 海龟也在其中,似乎随意地跳跃在窗口中。每次它移动时,都会留下一个小点。点的颜色对应于第 3 步中选定的三角形顶点。
让程序运行一段时间,绘制它想要绘制的内容。当你等待海龟完成任务的时间足够长后,按下一个键并检查结果图像。它应该看起来与图 7-8 相似。

图 7-8:混沌游戏的实际操作
这幅图是一个分形,被称为谢尔宾斯基三角形,得名于波兰数学家瓦茨瓦夫·谢尔宾斯基,他在 1915 年研究了这种形状。
分形是由自身的副本构建而成的。例如,主三角形是由三个较小的三角形构建的,每个较小的三角形又是由三个更小的三角形构建的,依此类推,永无止境。因此,游戏并没有填满xy平面,而只是填充了平面上的一个子集。这样的事物被称为“分形”,因为它们的维度是非整数的。
这个分形大于一条线(维度 1),但小于一个平面(维度 2)。定义分形维度的方式有多种,但最常见的可能是豪斯多夫维度。对于谢尔宾斯基三角形,豪斯多夫维度为 log 3 / log 2 = 1.5849625……,大于一条线但小于一个平面。
让我们来看一下sierpinski.py,如清单 7-1 所示。
X = [-200,0,200]; Y = [-200,200,-200]
x = X[0]; y = Y[0]
colors = ['#E7FFAC','#ACE7FF','#97A2FF']
tu.color(colors[0])
rng = RE(mode='int', low=0, high=3)
done = False
def Done():
global done
done = True
tu.onkeypress(Done)
tu.listen()
while (not done):
n = rng.random()
x = 0.5*(x + X[n])
y = 0.5*(y + Y[n])
tu.color(colors[n])
tu.goto(x,y)
tu.pd()
tu.dot(1)
tu.pu()
tu.ht()
tu.done()
清单 7-1:揭示谢尔宾斯基三角形
第一段代码配置了海龟、一些颜色和伪随机生成器(rng)。它还定义了两个列表来保存三角形的顶点,X和Y。另一个重要部分是将x和y赋值为第一个顶点。任何随机的初始值都可以,但可能需要几次游戏迭代才能落到分形内的点上。
第二段仅为方便之用。它定义了一个全局变量done,以及一个事件处理程序,当我们按下键时,海龟图形会调用该处理程序。该处理程序的唯一任务是将全局变量done设置为True,以便while循环退出。海龟的onkeypress和listen方法告诉海龟应该监听绘图窗口中的按键,并在收到按键时执行某些操作。
所有有趣的东西都在第三段——while循环,它会一直运行直到按下一个键。它随机选择一个顶点n,并在更新x和y后,在该点上放置一个正确颜色的点。这个过程会重复,点一个接一个地放置。
混沌游戏使用三角形的顶点和随机性来揭示分形的吸引子,或者说是构成分形的点集。
混沌游戏同样适用于n边形(n > 2),正如polygon.py中的代码所示。我在这里不会详细讲解;它是一个改进版的sierpinski.py,可以接受命令行中的n,然后可选地接受一个随机源和一个种子值。图 7-9 展示了n = 5 和 n = 9 的结果。

图 7-9:带有五边形(左)和九边形(右)的混沌游戏
阅读polygon.py以理解它的功能。
迭代函数系统
本节介绍了迭代函数系统(IFS),即将平面中的点映射到其他位置的方程系统。在接下来的章节中,我们将 IFS 与混沌游戏结合,生成分形图像,IFS 的吸引子。请注意,接下来会涉及到与向量和矩阵相关的数学。如果这些概念对你来说比较陌生,不用担心。我们可以在不完全掌握过程细节的情况下,利用 IFS 制作出令人惊叹的图像。不过,如果有可能的话,深入理解会更好。
要构建一个 IFS,我们需要理解几个概念。首先,矩阵将xy平面中的向量映射到xy平面中的新向量。其次,xy平面中的向量是指一个点(x,y)的另一种方式。第三,什么是收缩映射。最后,一组带有偏移向量和相关概率的收缩映射构成了一个 IFS,使我们能够使用混沌游戏创建分形图像。让我们开始吧。
之前,我们使用矩阵将一组点按某个角度θ围绕原点旋转:

我们将其写作x′ = Mx,其中粗体小写字母是向量,粗体大写字母是矩阵。
更具体地说,乘以一个二维向量

通过矩阵

将向量映射到一个新的二维向量,x′:

这也展示了如何通过矩阵乘以二维向量:将矩阵行的每个元素与向量的相应元素相乘并求和。我们说矩阵M是从x到x′的映射。
在第三章中,我们学习了两点之间的欧几里得距离。考虑两个向量,x[0]和x[1],以及它们在矩阵M下的映射:
和
。如果d(x[0],x[1])是x[0]和x[1]之间的欧几里得距离,我们有

对于 0 < α < 1,M是一个压缩映射,将点聚集在一起。我们将使用压缩映射来表示我们的 IFS,每个映射由一个矩阵表示。
一个映射还可以包括一个常数偏移向量,它加到应用矩阵后得到的向量上。我们可以将其写成

其中,向量相加是逐元素进行的,像 NumPy 那样。然而,更方便的做法是将 2×2 矩阵替换为一个 3×3 矩阵,并加入偏移向量:

一组压缩映射矩阵,{M[0],M[1],M[2],...},形成一个 IFS,其中每个M是一个 3×3 矩阵,将映射与偏移向量结合。IFS 有吸引子,即一组点,后续的映射将这些点映射到吸引子中的其他点。通过这种方式,使用 IFS 中的一个矩阵对吸引子上的点进行映射会得到吸引子上的另一个点。形成吸引子的点就是我们想要成像的分形,混沌游戏是我们用来找到它们的工具。
通过点绘制的分形
我们几乎已经到了需要的地方。我们有了一个 IFS,即一组矩阵,M。我们还知道,混沌游戏帮助我们找到 IFS 的吸引子上的点。为了将混沌游戏应用于一组矩阵,我们给每个映射分配一个概率。然后,在玩混沌游戏时,我们根据该概率选择下一个要应用的映射。分配的概率改变了吸引子点的加权。
让我们开始实践。我们将使用的代码在ifs.py文件中。通读这个文件以了解整体内容。文件的大部分内容是IFS类。运行ifs.py并不带任何参数会教会我们如何配置命令行:
> python3 ifs.py
ifs <points> <output> <fractal> <color> [<kind> | <kind> <seed>]
<points> - number of points to calculate
<output> - output image
<fractal> - name from the list below or 'random'
<color> - <hex> (no '#')|maps
<kind> - randomness source
<seed> - seed value
circle dragon fern koch shell sierpinski tree thistle
maple spiral mandel tree2 tree3 fern2 dragon2
输出底部的文字被分配给不同的 IFS,意味着不同的映射和概率集合。我们通过命令行中的名称指定我们想要的 IFS。映射在IFS类中嵌入的字典中是硬编码的。例如,下面是sierpinski IFS 的定义:
"sierpinski": {
"nmaps":3,
"probs":[0.3333,0.3333,0.3333],
"maps":[
[[.5, 0, 0], [0, .5, 0], [0,0,1]],
[[.5, 0, .5], [0, .5, 0], [0,0,1]],
[[.5, 0, .25], [0, .5, .5], [0,0,1]]]},
该 IFS 由三个映射组成,或者说由三个 3×3 矩阵组成:

每个映射的选择概率为 1/3,且在玩混沌游戏时,每个映射被选中的概率相等。
其他 IFS 定义方式类似,尽管根据需要可以有更多或更少的映射。maple IFS 及其后续的 IFS 由 Paul Bourke 提供(* paulbourke.net/fractals/ifs*),并已获得许可。Bourke 的网站是一个宝库,里面有许多有趣的计算机图形和几何学页面,包括许多关于分形和 IFS 的内容。我强烈推荐去看看。
剩下的命令行参数如上所述,有两个选项值得特别提及。第一个是使用maps来指定颜色。指定maps的效果与sierpinski.py相同;点被绘制成与选定的 IFS 映射相关的颜色。这个选项可以在输出图像中显示映射。第二个是使用random来生成 IFS,它会随机生成一个 IFS,然后迭代找到它所代表的分形。我们稍后会试验这个选项。现在,让我们使用fern IFS 来看看得到什么样的输出。尝试这个命令行:
> python3 ifs.py 1_000_000 fern.png fern maps
它使用四种不同颜色绘制蕨类 IFS 的 100 万个吸引子点。结果如图 7-10 所示。

图 7-10:蕨类吸引子的 100 万个点
第四个映射是蕨类植物的狭窄茎部。探索ifs.py支持的其他分形,或者浏览misc目录,其中包含每种分形使用 100 万个点的图像。
ifs.py代码使用 Matplotlib 来生成输出图。Matplotlib 的图形是互动式的。试着生成shell IFS 的 1000 万个点,然后点击放大镜图标,在中心周围画一个框来进行放大。用 1000 万个点,你应该能够放大两到三次。螺旋会永远继续下去。
IFS 分形与自然界中的物体相似,这不可能仅仅是巧合;自相似的模式必定有生物学基础,即使它们不是严格的,而只是近似的数学分形。
虽然查看ifs.py生成的漂亮分形图像很有趣,但理解它们背后的“原理”更加有趣。让我们分析一下代码,然后尝试纯粹随机的 IFS 分形。
IFS 类
预定义映射的字典构成了IFS类的主体。就实际代码而言,有一些方法值得思考:ChooseMap、GeneratePoints、StoreFractal和RandomMaps。我会把RandomMaps留到下一节讨论;StoreFractal是 Matplotlib 的一个直接应用,用来使用相关的颜色绘制生成的点。这就剩下了GeneratePoints和ChooseMap。我们从GeneratePoints开始,如清单 7-2 所示。
def GeneratePoints(self):
self.xy = np.zeros((self.npoints,3))
xy = np.array([self.rng.random(), self.rng.random(), 1.0])
for i in range(100):
m = self.maps[self.ChooseMap(),:,:]
xy = m @ xy
for i in range(self.npoints):
k = self.ChooseMap()
m = self.maps[k,:,:]
xy = m @ xy
self.xy[i,:] = [xy[0],xy[1],k]
清单 7-2:寻找分形吸引子上的点
GeneratePoints 方法填充一个包含 npoints 行和三列的 NumPy 数组:x、y 和选定地图的索引。代码迭代一个随机初始化的向量 xy。第三个元素,一个常量 1,允许我们使用 3×3 矩阵作为地图。
随机选择的点不太可能位于吸引子上;因此,在我们将点存储到 self.xy 之前,我们迭代 100 次,确保新点位于吸引子上。每次迭代都涉及根据分配的概率选择一个地图,然后进行与该地图的矩阵乘法,x → Mx,在代码中变为 xy = m @ xy。NumPy 使用 @ 进行矩阵乘法。
虽然 GeneratePoints 方法承担了大部分工作,但它依赖于 ChooseMap (列表 7-3)。
def ChooseMap(self):
r = self.rng.random()
a = 0.0
k = 0
for i in range(self.nmaps):
if (r > a):
k = i else:
return k
a += self.probs[i]
return k
列表 7-3:选择一个地图
IFS 构造函数根据选择的 IFS 创建成员变量 nmaps、probs 和 maps。ChooseMap 方法通过在 0, 1) 范围内选择一个随机数 (r),然后逐步累加每个地图的概率,直到累积和 (a) 超过所选值,最终返回当前的索引 k。这种方法按比例选择地图,与它们分配的概率成正比。
使用 IFS 类非常简单:
app = IFS(npoints, name, ctype, rng, show=True)
app.GeneratePoints()
app.StoreFractal(outfile)
构造函数接受点数、分形名称、颜色、初始化的 RE 对象和一个标志,用于在调用 StoreFractal 时显示或隐藏分形。GeneratePoints 和 StoreFractal 方法完成整个过程。将 IFS 封装到一个类中,并使用一个简洁的主驱动程序,让我们能够将 ifs.py 作为程序或模块使用;在下一节中,我们将作为模块使用它。
随机 IFS
使用 random 作为分形名称运行 ifs.py 会生成一组具有随机概率的随机地图。每个地图是一个 3×3 矩阵

RandomMaps 方法创建一个包含地图和概率的集合,如 列表 7-4 所示。
def RandomMaps(self):
def mapping():
while (True):
a,b,c,d,e,f = -1 + 2*self.rng.random(6) if (a*a+d*d) >= 1:
continue
if (b*b+e*e) >= 1:
continue
if a*a+b*b+d*d+e*e - (a*e-d*b)**2 >= 1:
continue
break
return [[a,b,c],[d,e,f],[0,0,1]]
nmaps = 2 + int(4*self.rng.random()) # [2,5]
probs = self.rng.random(nmaps)
probs = probs / probs.sum()
maps = []
for k in range(nmaps):
maps.append(mapping())
return nmaps, probs, np.array(maps)
列表 7-4:创建一个随机 IFS
代码首先选择一个随机数目的地图,[2, 5],和概率 (probs)。然后,它循环 nmaps 次,调用嵌套函数 mapping,该函数返回一个有效的地图矩阵。mapping 方法会不断选择 –1, 1) 范围内的随机元素,直到所有约束条件得到满足。
让我们创建 100 个随机分形,感受一下随机生成的 IFS 吸引子的样子。文件 ifs_maps.py 包含我们需要的代码。它使用命令行传入的主种子值来生成一组随机分形。以下是我使用的命令行:
> python3 ifs_maps.py 100 fractals 271828 >ifs_maps_271828.txt
如果你使用相同的命令行,你将得到在 fractals 目录中的相同分形集合。文件 ifs_maps_271828.txt 包含随机生成的映射和种子值。ifs_maps.py 中的代码只有几十行,但它展示了如何在另一个程序中使用 IFS 类。
[图 7-11 展示了通过之前的命令行生成的分形。(这些在彩色版中效果更好。)

图 7-11:随机生成的 IFS 分形
即使没有颜色,我们也能看到随机 IFS 所能产生的效果。数字标识了分形。所使用的映射位于 ifs_maps_271828.txt 文件中。
IFS 映射
现在我们理解了通过矩阵和概率集合表示的函数系统迭代过程,我们将生成自己的 IFS 映射。本节是可选的,因为涉及了一些线性代数。
请参见 图 7-12。

图 7-12:Sierpiński 三角形的三个收缩映射
这展示了构成 Sierpiński 三角形的三个映射的效果。这些映射将单位正方形(最外层的正方形)映射到三个更小的正方形之一。对角线显示了方向,以澄清翻转或旋转。在这里,映射缩小了单位正方形并使其偏离原点,但并没有翻转或旋转。吸引子的递归自相似外观来自于在每个更小的正方形中重复此映射,并在这些小正方形中继续重复,以此类推,永无止境。
让我们将 图 7-12 中的插图转换为三个 3×3 矩阵,或者说映射。我们将追踪单位正方形中的三个点 (0, 0)、(1, 0) 和 (0, 1) 在每个映射中最终的位置。
我们将从顶部的映射开始,它将单位正方形映射到 x 范围 [0.25, 0.75] 和 y 范围 [0.5, 1]。该映射将单位正方形中的任何点映射到这个更小的正方形中的适当位置。点 (1, 0) 必须映射到 (0.75, 0.5);同样地,(0, 0) → (0.25, 0.5) 和 (1, 0) → (0.25, 1)。我们如何找到执行此操作的矩阵 M?
从数学上讲,我们需要让M像这样作用

对于适当选择的 a、b、c、d、e 和 f,相同的矩阵还必须将 (0, 0) 和 (0, 1) 分别映射到 (0.25, 0.5) 和 (0.25, 1)。
执行矩阵乘法给我们两个方程,将 (1, 0) 映射到 (0.75, 0.5):

第三个方程仅包含常数,因此我们将忽略它。
我们还有两个需要考虑的点,它们使用相同的矩阵,因此我们有四个额外的方程:

让我们找到 a、b 和 e。这个过程会重复进行,以找到 c、d 和 f。如果我们将涉及 a、b 和 e 的方程组合起来,我们得到:

这给我们三个方程和三个未知数,因此我们可以找到一个唯一解。首先,让我们将方程以矩阵形式重新写出:

这是一个矩阵方程,形式为 b = Ax,我们需要找到 x。一种方法是使用 克莱姆法则,它通过计算行列式的比值,逐个元素地求解方程。特别地,我们可以通过以下方式找到 x 的每个元素:

对于 |A| 是矩阵的行列式,|A[i]| 是通过将 A 的第 i 列替换为 b 形成的矩阵的行列式。因此,我们得到 a,b 和 e 如下:

通过行列式的计算,或许可以使用 NumPy 的 np.linalg.det 函数,告诉我们 a = 0.5,b = 0.0 和 e = 0.25。对于 c,d 和 f 的相应方程分别给出 0.0,0.5 和 0.5。
现在我们得到了所需的映射矩阵:

这确实是 ifs.py 中的 sierpinski 矩阵之一。
我们可以将这个过程推广,编写即刻的解决方案以获得期望的地图。假设单位正方形上有三点,(a[0], a[1]),(b[0], b[1]) 和 (c[0], c[1])。如果它们在映射后的期望位置分别是 (A[0], A[1]),(B[0], B[1]) 和 (C[0], C[1]),那么我们可以通过计算找到地图:

以及:

这个过程会生成地图,但并没有提及在玩混沌游戏时选择地图的相关概率。直觉和实验有助于为概率分配提供帮助。
有很多与 IFS 相关的程序,其中许多程序让你可以交互式地设计 IFS 映射,因此你不必手动计算地图。例如,Larry Riddle 的 IFS Construction Kit 是一个 Windows 程序,用于创建和动画化 IFS 分形,其中包括一个图形设计工具(larryriddle.agnesscott.org/ifskit)。我能够在 Linux 上使用 wine 运行该程序,下载 Riddle 在安装页面上提到的缺失 .ocx 和 .dll 文件。
练习
创作生成艺术是无止境的。以下是与本章实验相关的一些练习:
-
moire.py 中的代码从单个点画到边缘。尝试从角落画;然后,尝试多个不同步长的点。
-
修改 walker.py,使其选择一个随机的颜色表索引,或者使用步数对 256 取模,循环遍历颜色表。
-
为什么要将 walker.py 限制为单一的行走者?
-
warp.py 中可用函数的列表相当简洁。添加新的函数,假设 x 和 y 的输入值在 [–1, 1] 范围内,并返回新的 x 和 y 值以及一个颜色表索引 [0, 255]。
-
如果你改变 ifs.py 中每个地图的概率,会发生什么?
-
使用文本中描述的手动过程为 ifs.py 创建新的 IFS 地图。它们会生成预期的结果吗?
-
使用 IFS 构建工具包或类似程序创建新的 IFS 地图,并将其添加到 ifs.py 库中。
总结
本章向我们介绍了生成艺术中的随机性,虽然我们只触及了表面。我们探讨了通过莫尔条纹效应、随机漫步者以及随机选择的形变函数和颜色表生成的图像。
接下来是分形的内容。我们了解到 IFS 有吸引子,或者说是自相似的分形。通过使用随机映射序列,混沌游戏会落在吸引子上,从而为我们提供了一种生成无限多分形图像的方法。随机选择的映射集合会生成迷人且令人着迷的分形图像集合。自然界结构的形成似乎与 IFS 的吸引子之间有着深刻的联系。
本章聚焦于视觉上令人愉悦的内容。在下一章,我们将深入探讨听觉上令人愉悦的内容。
第八章:音乐**

在本章中,我们将继续探索声音和音乐中的随机性。我们将开始通过随机样本、频率空间中的随机漫步以及在音乐音阶上的上下随机漫步来生成声音。这些项目将为本章最具雄心的实验做准备:从零开始演化出悦耳的旋律。虽然我们无法真正量化这样的旋律,但这并不会阻止我们尝试。
创建随机声音
初看起来,生成随机声音似乎很简单。例如,如果我们有某种方法来创建声音文件,比如 WAV(.wav 文件扩展名),那么我们应该只需要以指定的播放速率获取随机声音样本——对吧?让我们实现这个,它将引导我们使用本节所需的音频工具。
WAV 文件可以通过 SciPy 的 wavfile 模块轻松读取和写入。要写入 WAV 文件,我们需要两样东西:指定的采样率以及样本本身,它们必须在一些范围内,这些程序如 mplayer 或 Audacity 可以理解。
我们以每秒样本数来衡量采样率,即样本播放的速度。采样率越高,音频质量越好。22,050 Hz(每秒周期数)的采样率对于我们的目的来说足够了。这是 CD 的一半采样率。
这些样本是量化的电压,表示一个连续范围被划分为指定数量的离散步骤,每个离散值指定一个特定的模拟电压水平。离散样本对应于输出的音频波形。样本通常是 16 位有符号整数,但我们将使用范围在 [–1, 1] 之间的 32 位浮动点样本。大多数音频程序处理浮动点样本时不会遇到困难。
为了制造随机声音,我们需要生成随机样本,设置 WAV 输出,并将样本写入磁盘以供播放。让我们尝试一下,看看会发生什么。我们想要的代码在 random_sounds.py 文件中。
我们先运行一下:
> python3 random_sounds.py 3 tmp.wav
播放三秒的输出文件 tmp.wav。我建议你先调低音量。你听到了你预期的声音吗?可以考虑查看清单 8-1。
from scipy.io.wavfile import write as wavwrite
def WriteOutputWav(samples, name):
s = (samples - samples.min()) / (samples.max() - samples.min())
s = (-1.0 + 2.0*s).astype("float32")
wavwrite(name, rate, s)
rate = 22050
duration = float(sys.argv[1])
oname = sys.argv[2]
nsamples = int(duration * rate)
samples = -1.0 + 2.0*np.random.random(nsamples)
WriteOutputWav(samples, oname)
清单 8-1:生成随机样本
我省略了关于命令行格式的常见信息,专注于相关的代码。
首先,我们从 SciPy 导入 wavwrite。我将 write 重命名为 wavwrite,以澄清这个函数的作用。暂时忽略 WriteOutputWav 函数。
文件的主要部分固定了采样 rate,并从命令行读取秒数的持续时间,以及输出的 WAV 文件名(oname),然后计算 nsamples。
如果样本以给定的 rate 播放,并且我们想要一个总的 duration(秒数),那么乘积(四舍五入为整数)将为我们提供必须生成的样本数。samples 是在 [–1, 1) 范围内使用 NumPy 的伪随机生成器随机选择的。在这里使用 RE 没有意义。
剩下的就是使用 WriteOutputWav 来创建输出的 WAV 文件。我们将在本节的所有实验中使用这个函数。第一行将样本缩放到 [0, 1] 的范围,这样我们可以在生成样本时更加灵活。第二行将范围从 [0, 1] 转换到 [–1, 1],这是浮点样本的有效范围。最后一行使用 wavwrite 将 WAV 文件写出。
random_sounds.py 的输出之所以让人感到刺耳,是因为人类对声音的感知方式。我们喜欢那些由一组漂亮的正弦波叠加而成的声音;换句话说,就是有基频和谐波的音调。一个随机的、无关的样本集合只能通过叠加大量正弦波来表示。
图 8-1 显示了左侧的 440 Hz 正弦波和右侧的随机噪声。

图 8-1:左上:一个正弦波;右上:随机噪声;左下:正弦波的频率谱;右下:随机噪声
图 8-1 的顶部显示了实际的声音样本随时间的变化。底部显示了频谱,即信号中各个正弦波的强度,因此 x 轴不再是时间,而是频率。
正弦波从根本上来说是一个频率为 440 Hz 的单一波形。其他频率的能量很可能是由于对纯正弦波的不完美近似所导致的。垂直刻度是对数标度,这意味着在 440 Hz 之外几乎没有能量。然而,随机噪声的频谱在整个频率范围内(直到 8,000 Hz)大致均匀,反映了必须叠加的正弦波的数量,以近似随机信号。x 轴同样是对数刻度。
以下两个部分探讨了生成随机声音的其他方法,均利用了随机游走的思想——不是在空间中,而是在频率上。我们将通过正弦波的叠加来产生声音。第一部分不关注频率的混合,而第二部分使用了 C 大调音阶中的音符频率。
正弦波
如果我们将两个不同频率的正弦波相加,它们会合并成一个新的波形。两个正弦波都为正时,它们会相互加强,结果波形会更为积极。当一个为正,另一个为负时,它们会相互抵消。例如,考虑 图 8-2。

图 8-2:两个正弦波(左)及其和(右)
左侧是两个频率比为 3:1 的正弦波,右侧是左边两个正弦波的叠加。叠加足够多的波形,任何所需的输出波形都是可能的。
sine_walker.py 中的代码创建了一组随机游走者,每个游走者生成 0.5 秒的正弦波,然后改变频率以用于下一个 0.5 秒的时间段。对于每个 0.5 秒的时间块,最终的波形是所有游走者的波形之和。我们来运行代码并逐步解析:
> python3 sine_walker.py 5 3 walk.wav
这应该会生成一个 5 秒的输出文件,包含三个独立的正弦波随机游走。听一听 walk.wav;它让我想起了 1950 年代科幻电影中的音效。
sine_walker.py 文件解析命令行参数,然后配置我们需要的随机游走值:
nsamples = int(duration * rate)
samples = np.zeros(nsamples, dtype="float32")
dur = 0.5
step_samp = int(dur * rate)
fstep = 5
freq = np.zeros(nwalkers, dtype="uint32")
freq[:] = (440 + 800*(rng.random(nwalkers)-0.5)).astype("uint32")
首先,我们使用 nsamples 定义 samples,它保存所有输出。接下来的两行定义了 dur,即步长的持续时间,和 step_samp,即每个步长中的样本数量。每个正弦波对应一个特定的频率,生成这些样本数。接下来,fstep 设置步长频率,freq 是一个初始频率的向量,范围为 40, 840) 赫兹。双重定义处理了只有一个游走者的情况。
然后我们循环直到生成所有样本。每个步长为 0.5 秒,每个游走者使用其当前频率和随机选择的振幅生成一个正弦波,包含 step_samp 个样本。我们将所有游走者的波形加和,并将合成后的波形分配到下一个 0.5 秒的样本中;参见 [列表 8-2。
k = 0
➊ while (k < nsamples):
➋ for i in range(nwalkers):
r = rng.random()
if (r < 0.33333):
freq[i] += fstep
elif (r < 0.66666):
freq[i] -= fstep
freq[i] = min(max(100,freq[i]),4000)
➌ amp = rng.random()
if (i == 0):
t = amp*np.sin(2*np.pi*np.arange(rate*dur)*freq[i]/rate)
else:
t += amp*np.sin(2*np.pi*np.arange(rate*dur)*freq[i]/rate)
n = 1
➍ while (np.abs(t[-n]) > 1e-4):
n += 1
t = t[:-n]
if ((k+len(t)) < nsamples):
➎ samples[k:(k+len(t))] = t
k += len(t)
lo = np.quantile(samples, 0.1)
hi = np.quantile(samples, 0.9)
samples[np.where(samples <= lo)] = lo
samples[np.where(samples >= hi)] = hi
WriteOutputWav(samples, oname)
列表 8-2:生成正弦波游走
while 循环遍历所有输出样本 ➊。接下来的 for 循环 ➋ 遍历当前步长的所有游走者。随机值决定是否增加、减少或保持每个游走者的频率不变。然后使用 min 和 max 快速检查,确保频率在 [100, 4000] 范围内。
正弦波可以写作 y = A sin ωx,其中 A 为振幅,ω 为频率(ω)。我们随机选择一个振幅 ➌,并用它创建步长的样本,这些样本会被加到现有的样本 t 中,从而将所有游走者的结果加总在当前步长内。
每个步长的波形从零振幅开始,因为正弦函数从零开始。因此,我们希望前一步的结束位置也在零振幅处。第二个 while 循环 ➍ 尝试从步长波形的末尾扫描,找到一个接近零的样本。
最后,我们将步长的样本放入输出 samples 向量 ➎,如果它们适合的话。当 samples 满了时,进行剪辑,以保持样本位于第 10 百分位和第 90 百分位之间,然后通过 WriteOutputWav 写入磁盘。
使用一个游走者生成一个 15 秒或更长的样本。你能听到这个游走吗?可能有助于暂时将 amp=1 设置为使每个步长的音量相同。你也可以用智能手机上的应用程序实时显示频率谱。
如果你增加更多的游走者会发生什么?使用像 Audacity 这样的程序查看 50 个游走者的波形。它应该开始呈现出噪音的特征,结构较少。
用任意组合的正弦波发出奇怪的声音很有趣,但让我们看看是否能采用更具音乐性的方式。
C 大调音阶
正弦步进器的频率按固定的 5 Hz 间隔逐步变化。note_walker.py中的代码与sine_walker.py中的几乎相同,但不同之处在于,正弦波的频率不是按常数赫兹变化,而是依照 C 大调音阶中的音符频率逐步变化:
frequencies = np.array([
146.83 , 164.81 , 174.61 , 196\. ,
220\. , 246.94 , 261.63 , 293.66 , 329.63 , 349.23 ,
392\. , 440\. , 493.88 , 523.25 , 587.33 , 659.26 ,
698.46 , 783.99 , 880\. , 987.77 , 1046.5 ])
在这个列表中,中央 C 的频率是 261.63 Hz,中央 C 之上的 A 音是 440 Hz。note_walker.py文件使用的命令行与sine_walker.py相同。仔细阅读代码并尝试一下。结果和sine_walker.py一样吗?输出的声音让你想起了什么乐器?
注意
各种频率的电子振荡器与其他电路组合,以调制最终波形,是早期模拟音乐合成器的核心。我们可以通过软件模拟模拟合成器;参考 github.com/yuma-m/synthesizer。它基于 Python,并且页面提供了安装依赖的完整说明。GitHub 页面上的示例使用正弦波作为基本波形,就像我们在这里使用的一样。
让我们从随机变化的正弦波跃升到从头开始演化旋律。
生成旋律
我们将使用群体搜索来生成旋律,目标是从零开始创作出一首“悦耳”的旋律。
首先,我们将设置好我们的环境。接着,我们将学习如何使用程序melody_maker.py来生成旋律。最后,我们将逐步讲解代码的关键部分。目标函数比我们之前处理过的要复杂得多。
群体搜索
在本节中,我们将使用 MIDI 文件,而不是直接生成 WAV 文件。MIDI(数字音乐仪器接口)是数字音乐的标准格式。它可能有些复杂,但我们的使用方式简单至极:只有一条旋律线。因此,对于我们来说,MIDI 变成了一个 NumPy 向量,由一对数字组成,第一个是音符编号(60 是中央 C),接着是时值,其中各时值的比例表示全音符、半音符、四分音符、八分音符,等等。
我们演变出的旋律最终以 MIDI 文件的形式表示。因此,我们需要一些额外的软件,超出常规工具包的范畴,来播放 MIDI 文件、在代码中处理 MIDI 文件,并将 MIDI 文件转化为乐谱图像。让我们安装 wildmidi、midiutil 和 musescore3。
使用以下命令安装 wildmidi:
> sudo apt-get install wildmidi
wildmidi 插件可以从命令行播放 MIDI 文件 (.mid)。对于 macOS 和 Windows,请查看官方网站 github.com/Mindwerks/wildmidi/releases。
我们需要 midiutil 来在 Python 中处理 MIDI 文件:
> sudo pip3 install midiutil
midiutil 库用于读取和写入 MIDI 文件,尽管我们只会写入文件。
最后,为了生成我们演变出的旋律的乐谱,我们需要 musescore3:
> sudo apt-get install musescore3
macOS 和 Windows 的版本可以从主站下载 (musescore.org/en/download)。如果找不到 musescore3,请安装最新版本(musescore4),并相应地更新 melody_maker.py。
一旦所有东西都安装好,我们就可以开始了。如果你没有安装 musescore3,代码仍然可以运行,但你将无法看到最终的视觉效果。
melody_maker.py 代码
我们需要用来生成旋律的代码在 melody_maker.py 文件中:
> python3 melody_maker.py
melody_maker <length> <outfile> <npart> <max_iter> <alg> <mode> [<kind> | <kind> <seed>]
<length> - number of notes in the melody
<outdir> - output directory
<npart> - swarm size
<max_iter> - maximum number of iterations
<alg> - algorithm: PSO,DE,RO,GWO,JAYA,GA,BARE
<mode> - mode
<kind> - randomness source
<seed> - random seed
许多命令行参数都很熟悉或不言而喻,比如旋律中的音符数量。mode 参数表示我们想要使用的音乐模式或音阶。传统上,有七种模式,所有这些都支持,包括蓝调和五声音阶(摇滚)。其他模式使用它们的古典希腊名字,或者使用 major 或 minor 来表示标准的大调和小调。模式的名称在 表 8-1 中列出,附有一系列音程和通常与该模式相关的词汇。表 8-1 中的音程表示音阶中音符之间的步伐,其中 H 代表半音(半音程),W 代表全音(全音程)。
表 8-1: 模式、音程和特征
| 模式 | 音程 | 特征 |
|---|---|---|
| Ionian (major) | W W H W W W H |
明亮、积极、强烈、简单 |
| Aeolian (minor) | W H W W H W W |
悲伤 |
| Dorian | W H W W W H W |
轻盈、凉爽、爵士感 |
| Lydian | W W W H W W H |
明亮、通透、尖锐 |
| Mixolydian | W W H W W H W |
凯尔特风格 |
| Phrygian | H W W W H W W |
阴郁、压抑 |
| Locrian | H W W H W W W |
更加阴暗,“邪恶” |
例如,如果候选旋律的第一个音符是中音 C(MIDI 音符 60),并且所需的模式是 major,那么音阶的音符是:

目标函数会部分通过旋律与目标音阶的匹配度来打分。
如果你不熟悉音乐、音阶或任何音乐理论,别担心。我们需要知道的是,音阶或音符之间的间隔不同,这些间隔在演奏时会影响旋律的音色。例如,大调音阶(Ionian 模式)中的旋律听起来明亮,而小调音阶(Aeolian 模式)中的旋律通常听起来悲伤。这些规则并非硬性规定——只是一些指导原则。我们会在不同的模式下生成许多旋律。
让我们运行 melody_maker.py:
> python3 melody_maker.py 20 tmp 20 10000 bare mixolydian
Melody maker:
npart = 20
niter = 10000
alg = BARE
Optimization time = 114.775 seconds
63,0.90 60,0.60 63,0.60 60,0.60 63,0.60 60,0.60 61,1.20
65,0.60 68,0.60 65,0.60 68,1.20 72,1.20 68,0.60 65,1.20
70,0.60 67,1.20 63,1.20 67,0.60 70,1.20 73,0.60
31 best updates, final objective value 1.6637
我没有指定种子,因此你的运行结果会完全不同。输出会告诉我们搜索的过程,然后输出一串长长的整数和浮动数字。这就是进化后的旋律,已写入输出的 MIDI 文件中,该文件位于 tmp 目录下,此外还有其他文件,包括一个 NumPy 向量、一个 Python pickle 文件和乐谱(score.png)。旋律是成对的,所以第一个音符是 (63,0.9),即中音 C 上方的附点八分音符 E-flat。
输出的 MIDI 文件也在 tmp 文件夹中。可以用 wildmidi 播放它。
> wildmidi tmp/melody_BARE.mid
其中BARE会被我们选择的任何群体算法替代。我选择了mixolydian作为调式,因此,理论上,这个旋律应该听起来有些“凯尔特风格”。真的像吗?我其实并不知道。
演化后的旋律在score.png中;请参见图 8-3。

图 8-3:演化后的旋律
我略过了返回旋律的目标函数值。我们将在稍后查看代码时更详细地探讨这一点。不过,就像我们所有的优化实验一样,越小越好。
尝试实验不同的旋律、调式、算法、群体大小和不同长度的迭代次数。更多的迭代通常会带来更好的性能,这应该意味着更好听的旋律,或者至少是更符合期望调式的旋律。
你可能希望运行melody_examples中的示例。该文件作为一个 shell 脚本运行。
> sh melody_examples
并在example_melodies内生成从ex0到ex8的目录,使用不同的群体算法和调式。我固定了种子,因此你会听到我听到的旋律,这也暗示了可能的结果范围。
以下部分实验了melody_maker.py。第一个部分查询旋律在进化过程中是如何变化的,第二部分则专注于算法,以了解它们偏好的旋律类型,最后一部分构建了一个包含四种调式的旋律库。
旋律的演化
让我们使用基础的 PSO 算法来演化一个大调旋律。这个实验的目的是在旋律进化的过程中聆听它。旋律应该从不稳定且远离目标调式的状态,逐渐发展为一个初学者钢琴学生可能演奏的曲调(或者是我被告知的那样)。
文件evolve.py运行melody_maker.py,使用 20 个粒子和基础 PSO 算法演化一个包含 20 个音符的大调旋律。生成器和种子是固定的;不同的只是迭代次数,从 1 次到 50,000 次不等,正如图 8-4 所示。
固定的种子意味着 10 次迭代找到的最佳旋律通过了 1 次迭代找到的最佳旋律。每次更高的迭代次数告诉我们,如果早期的迭代继续进行,会在哪个位置结束。换句话说,允许相同的初始配置进行不同次数的迭代。

图 8-4:逐步演化的旋律。从上到下:1 次、1,000 次、10,000 次和 50,000 次迭代。
目录evolve_results包含每种迭代次数(1、10、100、1,000、5,000、10,000 和 50,000)的 MIDI 文件和乐谱图像。我推荐使用wildmidi来播放这些文件。在 1 次迭代和 50,000 次迭代后的旋律相比,如何?图 8-4 展示了不同迭代次数下的选择旋律乐谱。虽然早期的旋律杂乱无章,但在 10,000 次迭代和 50,000 次迭代后的旋律变化不大——除了调性之外几乎没有变化。
目标函数值我们尚未理解,但它随着迭代次数的增加而减少。当然,这就是它所能做到的,但它的下降速度在 1000 次迭代后开始趋于平缓。10,000 次迭代和 50,000 次迭代之间分数的微小变化意味着我们可能不想让搜索运行得太长时间,因为这样有可能在过程中错过一些潜在的令人兴奋的旋律。
当旋律进化时,究竟发生了什么?群体算法会在合适的 MIDI 音符号码和时值范围内随机初始化。选定的音乐模式有效地改变了群体在搜索过程中使用的目标函数。尽管群体搜索包含随机性,但最强烈影响最终结果的是群体的初始配置——至少,我认为是这样。初始群体配置、算法方法和随机性的结合导致收敛到一个或多或少符合目标函数的旋律。
探索算法
algorithms.py 文件会对我们在整本书中使用的 7 个群体算法中的每一个运行 melody_maker.py 10 次。每次运行都会生成一个 36 音符的旋律,采用利底亚模式,使用 20 个粒子和 10,000 次迭代。你可以运行这个文件,生成输出到 algorithms 目录中。或者,由于种子值是固定的,你也可以听七个 MP3 文件,这些文件将按算法顺序连接输出。
尽管这些算法的任务是相同的——学习一个利底亚模式的旋律——但希望最终的旋律能够揭示出各个算法之间的区别。让我们看看使用哪种群体算法是否重要,以及是否有些算法生成的结果比其他算法“更好”。
我听了所有的 MP3 文件(使用wildmidi的-o选项和lame生成的)并根据我认为它们的音质对旋律进行了排名。以下是我从最好到最差的排名,包括我无法选择一个算法而导致的并列:
-
简化版 PSO
-
遗传算法,PSO
-
差分进化,Jaya
-
GWO
-
随机优化
你的排名可能不同,但我猜你会同意,在这种情况下简化版 PSO 表现最佳,而随机优化最差。GWO 和随机优化都生成匆忙的输出;旋律播放得更快,因此生成的 MP3 文件比其他算法短大约 30 秒。
在深入探讨代码之前,我们将进行另一个实验,使用“最佳”算法——简化版 PSO,来创建一个不同模式的歌曲库。
构建旋律库
文件songs.py与algorithms.py相似,但使用简化版的 PSO 算法反复生成 36 个音符的旋律,涵盖四种调式:大调、小调、多利安调式和蓝调。在这种情况下,群体中有 32 个粒子,共进行 30,000 次迭代。代码运行需要一些时间,因此我为输出创建了 MP3 文件:major.mp3、minor.mp3、dorian.mp3和blues.mp3。在聆听时,请牢记表 8-1 中的描述。如果你认同这些描述,意味着目标函数至少在某种程度上捕捉到了这些调式的特点,即使它可能没有捕捉到完美的旋律特征。
实现
现在是时候探索melody_maker.py了。从高层次看,它与其他所有的群体优化实验没有什么不同:我们解析命令行,初始化群体框架对象,然后调用Optimize来执行搜索。最终结果会被转换成 MIDI 文件对象并写入磁盘。
让我们理解一下群体的结构——粒子位置与旋律之间的映射关系——然后再深入了解目标函数类,因为它是整个过程的核心。
如果我们想要在旋律中有n个音符,每个粒子就变成一个 2n元素的向量,包含n对数据,(MIDI 音符号,时长)。MIDI 音符号是有限制的(参见MusicBounds),范围为[57, 81],其中 57 表示休止符。再次强调,钢琴上的中音 C 是音符 60,每次增减一个单位对应半音的变化。时长是整数,当我们创建 MIDI 文件时,会将其乘以 0.3 来控制节奏。音符之间的比例是重要的,因此时长为 4 的音符是时长为 2 的音符的两倍,依此类推。因此,粒子就是我们正在考虑的旋律,搜索的目标是根据随机生成的旋律集合和选定算法的细节,找到由目标函数决定的最佳旋律。
一切都依赖于MusicObjective类。它相当复杂,有超过 150 行代码。我会从最后开始,首先讲解Evaluate方法,然后再逐步补充其他部分——标准的自上而下设计。回顾一下,群体使用得分来判断旋律的质量,得分越低越好。下面是代码:
def Evaluate(self, p):
self.fcount += 1
s = self.Distance(p[::2], self.mode)
d = self.Durations(p)
i = self.Intervals(p[::2], self.mode)
l = self.Leaps(p[::2], self.mode)
return 4*s+3*d+2*i+l
得分是输出的多部分函数,涉及Distance、Durations、Intervals和Leaps方法。各部分的值被求和,但并不等权重,Distance方法的输出是Leaps方法输出的四倍重要。最终得分的每个部分都在[0, 1]范围内,越低越好。
Distance方法测量当前旋律(粒子)中的音符和给定音阶中预期旋律音符之间的汉明距离。ModeNotes方法返回两个二进制向量,其中 1 表示对应音符在音阶中。第一个向量是当前旋律,第二个包括该音阶中的音符,假设旋律的第一个音符是根音。换句话说,ModeNotes返回两个以 0 和 1 表示的二进制数字作为向量。两个二进制数之间的汉明距离是不同的位数。例如,10110111 和 10100101 之间的汉明距离为 2,因为有两个对应的位不同。Distance方法通过音符的数量对汉明距离进行缩放,返回一个[0, 1]区间的值;它被认为是目标函数中最重要的部分,因为一个好的指定音阶旋律应该主要由该音阶中的音符组成。
Durations方法是一个临时度量,使用旋律中不同音符持续时间的计数与偏好四分音符和二分音符的预期混合之间的根平方误差距离。目的是最小化附点音符。
Intervals方法是另一个临时度量,它考察旋律中一个音符与下一个音符之间的间距。我们人类通常偏好大三度、小三度和纯五度,这意味着从音符i到音符i + 1 的间隔应该是 3、4 或 7 个半音。
最后,Leaps尝试最小化跳跃,即音符之间超过 5 个半音的间隔。它与Intervals测量的内容存在竞争关系,但Leaps的权重是Intervals的一半。
现在我们对目标函数的作用有了高层次的理解,让我们看一下对应的代码及其核心部分。
距离
Distance方法使用旋律音符与符合期望音阶的旋律音符之间的汉明距离。以下是代码:
def Distance(self, notes, mode):
A,B = self.ModeNotes(notes, mode)
lo = int(notes.min() - self.lo)
hi = int(notes.max() - self.lo)
a = A[lo:(hi+2)]
b = B[lo:(hi+2)]
score = (np.logical_xor(a,b)*1).sum()
score /= len(a)
return score
ModeNotes方法(未显示)返回两个列表,其中每个元素如果对应的音符在旋律中(A)或属于给定音阶的旋律(B),则值为 1。a和b中的版本覆盖了给定旋律的范围。
score变量保存a和b之间的汉明距离。汉明距离是指不匹配的位数。通过旋律的长度进行缩放,将计数转化为旋律的一个分数值[0, 1],并返回该值。
持续时间
Durations方法计算一个评分,反映音符持续时间的分布与临时预定义的“最佳”混合(偏好四分音符和二分音符)之间的匹配程度。以下是代码:
def Durations(self, p):
d = p[1::2].astype("int32")
dp = np.bincount(d, minlength=8)
b = dp / dp.sum()
a = np.array([0,0,100,0,60,0,20,0])
a = a / a.sum()
return np.sqrt(((a-b)**2).sum())
首先,我们将d设置为当前旋律的持续时间,以便bincount可以创建相应的分布b,并将其缩放为概率。所期望的音符持续时间混合存储在a中,并同样缩放为概率。
返回两个分布之间平方距离的总和作为持续时间评分。
音程
两个音符之间的音程是用半音来衡量的。音程方法计算旋律中大三度(4 个半音)、小三度(3 个半音)和五度(7 个半音)的数量,并将这些数字转化为分数。在代码中,这变成了:
def Intervals(self, notes, mode):
_,B = self.ModeNotes(notes, mode)
minor = major = fifth = 0
for i in range(len(notes)-1):
x = int(notes[i]-self.lo)
y = int(notes[i+1]-self.lo)
if (B[x] == 1) and (B[y] == 1):
if (abs(x-y) == 3):
minor += 1
if (abs(x-y) == 4):
major += 1
if (abs(x-y) == 7):
fifth += 1
w = (3*minor + 3*major + fifth) / 7
return 1.0 - w/len(notes)
代码检查旋律中的每一对音符。我们使用半音的差异来计算三度和五度的数量。然后,我们将 w 赋值为这些计数的加权平均数,其中,按照规定,我偏爱三度而非五度,比例为 3:1。
w 的值越高,旋律越符合期望的音程排列;因此,从 1 中减去缩放后的 w 分数以进行最小化。
跳跃
目标函数评分的最后一部分是 跳跃:
def Leaps(self, notes, mode):
_,B = self.ModeNotes(notes, mode)
leaps = 0
for i in range(len(notes)-1):
x = int(notes[i]-self.lo)
y = int(notes[i+1]-self.lo)
if (B[x] == 1) and (B[y] == 1):
if (abs(x-y) > 5):
leaps += 1
return leaps / len(notes)
跳跃是指两个音符之间的音程差超过 5 个半音,无论是向上还是向下。较小的音程意味着旋律更加平滑。我们返回旋律中包含跳跃的部分。
为什么在目标函数中使用这些组件而不是其他组件?没有特别的理由,仅仅是因为在对什么构成好旋律的思考中提到了其中一些。音乐是主观的,不可能创建一个完全客观的目标函数。 第 254 页上的“练习”要求你思考其他可能适合 MusicObjective 的术语。
生成式 AI
第七章和第八章中关于生成艺术和音乐的讨论没有提到人工智能,因此这些内容是不完整的。然而,将人工智能引入其中会使这些章节变成一本书。因此,我会引导你去了解基于 AI 的生成艺术和音乐实例。大多数这些实例使用生成对抗网络、深度风格迁移、变分自编码器或相关技术,这些技术依赖于深度神经网络来从某个学习的表示空间中采样,或将嵌入表示中的特征融合起来,从多个输入构建新的输出。
如果你想探索人工智能在这一领域的应用,* aiartists.org* 是一个很好的起点,并且提供了通向艺术家和工具的链接,这些工具可以用来创作基于 AI 的艺术和音乐。Al Biles 的 GenJam 提供了一种有趣的、先进的进化算法和音乐方法,网址是 genjam.org。我推荐观看视频示例,特别是 TEDx 演讲,它展示并解释了这个系统。我们已经目睹了强大的基于 AI 的文本、图像和视频生成系统的爆发,包括 Stable Diffusion、DALL-E 2 和 ChatGPT。新系统和更新每周都会出现,但这些应该足以让你入门:
DALL-E 2 openai.com/dall-e-2
Stable Diffusion beta.dreamstudio.ai/
ChatGPT openai.com/blog/chatgpt
练习
像生成艺术一样,生成音乐也没有尽头。以下是与本章实验相关的一些练习。
-
修改sine_walker.py中的裁剪范围。这会如何影响整体声音?在 Audacity 中,波形是什么样子的?
-
你可以通过更改频率表来改变note_walker.py中的调性。例如,要从 C 大调改为 D 小调,可以将 B 音符降低:246.94 → 233.08,493.88 → 466.16,987.77 → 932.33。
-
addProgramChange函数的最后一个参数在melody_maker的StoreMelody函数中指定了 MIDI 乐器编号。默认值是 0,表示声学钢琴。可以通过修改MIDI_instruments.txt列表来更改此数字。例如,试试 30 代表失真电吉他,13 代表木琴。几乎无法听到气息的双簧管是 68。或者,尝试 114,钢鼓,放手一搏。 -
修改melody_maker的目标函数加权,在
Evaluate方法中进行。若所有组件的加权相同,效果如何?如果反转加权会发生什么? -
你能在melody_maker的目标函数中添加其他术语吗?
总结
本章向我们介绍了生成音乐中的随机性,从音频和随机行走的正弦波开始,产生了其他 worldly 的音效。将频率限制在音乐音阶内,使这些奇怪的声音转变成类似管风琴的音响。
本章的结尾我们通过群体智能和进化算法从零开始演化旋律。我们在一定程度上取得了成功,演化出的旋律大多数符合预期的音乐调式。在这个过程中,我们还看到如何通过代码创建简单的 MIDI 文件。
在下一章,我们将转换话题,探索随机性在一个完全不同领域中的应用:从少量测量数据中恢复信号。
第九章:音频信号

本章我们将继续探讨连续信号与离散信号之间的关系。奈奎斯特-香农采样定理描述了连续信号和离散信号之间的关系。为了正确地离散化一个连续信号,我们必须以至少是信号中最高频率两倍的采样率进行采样。正是这个定理使得光盘的音频信号采样率为 44.1 kHz,也就是每秒 44,100 次采样。在这个采样率下,任何最高频率为 22,050 Hz 的信号都会被捕捉到。需要注意的是,22 kHz 是人耳能够听到的最高频率的理论上限,尽管大多数成人的上限要低得多;我自己的上限大约是 13.5 kHz。
本章将探讨压缩感知(或称为压缩感知技术),这是一种在奈奎斯特和香农的框架下进行逆袭的技术。通过压缩感知,我们可以在对信号进行数字化时采集比奈奎斯特-香农定理所要求的更少的数据。这是一个令人兴奋的现实世界反问题,涉及到随机性。
我们将从压缩感知的主要观点开始讲解;我们会涉及一些数学内容,但我鼓励你自行探索其余部分。接下来,我们将探讨一维的压缩感知,特别是音频信号,看看它如何让我们突破奈奎斯特极限。最后,由于解压后的图像对压缩感知而言与时间信号没有什么区别,我们将应用压缩感知技术,从看似过少的数据中重构图像。
第一部分包含一些矩阵-向量的数学运算,但它不超过我们在第七章遇到的迭代函数系统的内容。
压缩感知
数字化信号通常意味着在一个指定且恒定的时间间隔内读取模拟到数字转换器的输出。每秒的读取次数就是采样率,这是奈奎斯特-香农定理所关注的内容。如果我们按照奈奎斯特-香农定理获取样本,我们就能从这些样本中准确重构信号。
当我们以固定时间间隔进行采样时,我们称之为均匀采样。然而,有时不希望进行均匀采样,可能是因为成本过高或存在过多的风险(例如,在 X 射线断层扫描中)。在这种情况下,如果能采集较少的数据但仍能重构整个信号,那就很有帮助。例如,如果我们想要的信号表示为x,我们将测量信号的某个子集y,然后从y重构x。从数学角度看,我们可以将这个过程表示为一个矩阵方程

在这里,我们知道向量y因为我们已经测量过它,而矩阵C因为它决定了我们采样的x的部分。我们要找的是x,即按照标准采样理论我们应该测量的向量。暂时记住方程 9.1。
让我们回到代数课,通常要求我们解方程组,通常是两个方程和两个未知数:

在这里,a 到 f 是常数。因为有两个方程和两个未知数,我们可以找到满足这两个方程的 x 和 y 值,前提是其中一个方程不是另一个方程的倍数。以矩阵形式,我们将方程组写成:

矩阵-向量乘法的规则告诉我们,要将矩阵 A 的每一行与向量 x 对应的元素相乘,然后求和。这将矩阵方程转化为方程组。无论向量中有多少个元素,这个规则都适用。
这个方程组能够工作的原因是未知数的数量与方程的数量相等,意味着向量中的元素数量,这里是 b 和 x,与矩阵 A 的行数相匹配。对于这样的方程,解(如果存在的话),即使方程成立的 x 向量,是 A^(–1)b = x,其中 A^(–1) 是 A 的逆矩阵。例如,这个系统

变为

解为

其中,矩阵 A 的逆矩阵是 A^(–1),使得 AA^(–1) = A^(–1)A = I。
在这里,I 是 单位矩阵——所有元素为零、对角线上的元素为一的矩阵。在矩阵的世界里,I 类似于数字 1。使用 NumPy 的 linalg.inv 函数来求解 A^(–1)。
A 是一个 方阵,意味着它的行数和列数相等。如果 A 是方阵,并且行数与 b 和 x 中的元素数量匹配,那么我们可以使用 A^(–1) 来求解 x。
现在是有趣的部分。回到 方程 9.1。根据设计,y(我们测量的值)的元素比 x(完整信号)的元素要 少。如果 y 中有 N 个元素,x 中有 M 个元素,那么 C 是一个 N×M 的矩阵,具有 N 行和 M 列。方程 方程 9.1 中的未知数比方程的数量要多。这样的系统称为 欠定。欠定系统有无限多个解;有无限多个向量 x,当它们与 C 相乘时,得到 y。
我们想通过测量 y 来得到 x,但是仅凭 y 本身没有足够的信息来告诉我们 哪个 无限集合中的 x 向量是我们需要的。压缩感知可以提供帮助——至少在某些情况下。
根据压缩感知理论,如果x是稀疏的,也就是说其大部分元素基本为零,那么我们可以通过解决逆问题从y恢复出x,这个逆问题寻找一个x,使得Ax - b之间的差异最小,同时强烈鼓励x保持稀疏性。正如我们将看到的,存在能够执行这种优化的算法。
太好了!我们开始进入正题。我们测量包含某些元素子集的y,这些元素本应出现在x中,我们通过解决一个最小化问题从y得到x。但事情并没有那么简单;这个优化技巧只有在x是稀疏时才有效。大多数信号并不是稀疏的;例如,音频信号通常不是。回想一下在第八章中处理波形的情况。我们就此注定失败了吗?不一定。
虽然音频信号并不稀疏,但存在类似傅里叶变换的方式,可以将时间变化的信号映射到频率变化的信号,通常情况下,频域信号是稀疏的。因此,如果我们可以将x写成x = Ψs的形式,其中Ψ是某个变换矩阵(psi),而s是稀疏向量,那么公式 9.1 就变成了:

其中Θ = CΨ。
尽管x不是稀疏的,因此无法恢复,但s是稀疏的,这意味着优化技巧或许有可能奏效。通过将y中的测量结果与外部知识结合起来,得知s是稀疏的,我们就能找到s。一旦得到s,就可以恢复出x。
那么,所有这些浮动的矩阵是做什么的呢?测量矩阵 C 在数学上可以是任何满足某种不相干性的矩阵,这种不相干性是指C中的元素与Ψ中的元素之间的关系。对于我们来说,C的元素是二值的,即零或一,作用是选择实际测量的x中特定的元素。这一要求提供了C和Ψ之间所需的数学不相干性。最重要的一点是,C在某种程度上是随机的。在实际操作中,我们并不会显式定义C,但我们的测量过程隐含地使用了它。随机性是整个操作的关键所在。
Ψ矩阵是一个变换矩阵,它将稀疏向量s转化为我们最终想要测量的新表示x。对于我们来说,Ψ是一个类似傅里叶变换的离散余弦变换(DCT)。信号在这个领域通常是稀疏的,因此在压缩感知中非常有用。
最后,Θ表示测量过程作用于Ψ的组合。
现在我们有足够的信息来尝试求解y = Θs,寻找一个既稀疏又能产生我们已有的测量集y的s。有多种算法可以使用,但我们将采用 scikit-learn 提供的 Lasso 算法。同样,我们还需要 DCT 及其逆变换,SciPy 提供了这些工具:
from sklearn.linear_model import Lasso
from scipy.fftpack import dct, idct
Lasso 最小化以下内容

其中n是样本数量,或者是y中的元素数量。
双竖线符号表示范数,它是一种衡量距离的度量。第一个项使用ℓ²范数的平方,而第二个项将ℓ¹范数乘以α。向量x的ℓ^p范数定义为:

ℓ²范数是欧几里得距离。ℓ¹范数,有时称为曼哈顿距离或出租车距离,是向量x各元素绝对值的总和。Lasso 使用这个项,并将其按α缩放,用以找到一个s向量,该向量最小化测量值y和Θs之间的欧几里得距离,同时最小化s各元素绝对值的总和。这个后者约束迫使s的许多元素趋向零,从而确保稀疏性。
为了理解为什么 Lasso 目标函数中存在ℓ¹范数项,考虑一个简单的情况,其中我们有一个二维向量和一个单元素输出。这类似于找到一个尽可能稀疏的* x* + * y* = c的解,其中* x* = 0 或* y* = 0。几何上,最小化ℓ¹范数导致的情况如图 9-1 左侧所示,而最小化ℓ²范数如右侧所示。最小化ℓ²范数是标准的最小二乘回归。

图 9-1:最小化ℓ¹范数(左)和ℓ²范数(右)
图 9-1 中的直线表示某个c的ax + by = c的无限解集。左侧的菱形和右侧的圆形分别对应常数ℓ¹和ℓ²范数。最小化ℓ¹范数在* y为零时与直线相交,而最小化ℓ²范数在 x或 y都不为零的点与直线相交。随着维度的增加,这一趋势持续下去。在每种情况下,最小化ℓ¹范数意味着解的稀疏性,而最小化ℓ*²范数则将“能量”分布在每个维度上,正好与强制稀疏性相反。
让我们总结一下。我们希望通过测量它的一个子集,y,来获取x。为了求解随机的测量矩阵A,我们希望y = Ax。这个表达式是欠定的,意味着有无数个x可以作为解,因此我们需要额外的信息来找到我们(可能)想要的那个解。我们通过将x表示为其他形式(基)来获得这个信息,在这种形式下它变得稀疏。如果是稀疏的,那么找到一个有意义且简洁的解的可能性就显现出来。常用的基来自傅里叶变换家族,如离散余弦变换(DCT),x = Ψs,其中Ψ包含了 DCT,s是我们希望找到的稀疏向量。如果我们找到s,我们就找到了x。
将测量矩阵与离散余弦变换(DCT)结合,得到一个新的方程,y = Θs,其中我们已知y和Θ。方程仍然是欠定的,但我们知道s是稀疏的。为了求解s,我们使用一种优化算法,该算法能在过程中最小化s¹范数。这强制执行稀疏性,并让我们有信心可能找到一个合适的s。
让我们试试这个方法,看看会发生什么。
信号生成
我们将逐步讲解cs_signal.py,它演示了压缩感知过程以及为什么我们需要使用随机测量。首先,运行它,然后我将解释它生成的各种图表。命令行如下:
> python3 cs_signal.py 0.2 minstd 65536
应该会依次显示几个图表;关闭每个图表后,继续查看下一个。输出文件也会被创建。
代码首先生成一个一秒钟的信号,它是由三条正弦波组成的 C 大调和弦。标准的奈奎斯特采样法给出了这个信号,采样率为 4,096 Hz,即x。这是一个演示,所以我们从x开始,然后丢弃其中的大部分,生成一个y,这是我们最初可能测量到的信号。命令行中包括一个0.2的参数,表示保留x的 20%,即y包含 20%的样本;剩余的 80%被丢弃。命令行的其余部分指定了随机性源(minstd)和种子值(65536)。
信号来自于:
rate = 4096
dur = 1.0
f0,f1,f2 = 261.63, 329.63, 392.0
samples = np.sin(2*np.pi*np.arange(rate*dur)*f0/rate)
samples += np.sin(2*np.pi*np.arange(rate*dur)*f1/rate)
samples += np.sin(2*np.pi*np.arange(rate*dur)*f2/rate)
我们在第八章中使用了类似的代码。三个频率(f0、f1、f2)是 C 大调和弦。samples向量是最终的信号,x。它是一个包含 4,096 个元素的向量,因为采样率是 4,096 Hz,持续时间是 1 秒。
让我们从x构建y。这个过程隐式地使用了测量矩阵。我们将保留x中的 20%样本,首先通过均匀间隔选择样本,然后随机选择。均匀样本对应于以低于奈奎斯特极限的速率测量信号:
nsamp = int(frac*len(samples))
u = np.arange(0, len(samples), int(len(samples)/nsamp))
bu = samples[u]
r = np.argsort(rng.random(len(samples)))[:nsamp]
br = samples[r]
y中有nsamp个样本。第一个y向量是bu,均匀采样,第二个是br,随机采样。图 9-2 显示了原始信号及标记的均匀和随机样本(cs_signal_samples.png)。

图 9-2:随机采样(上)与均匀采样(下)
我们得到了测量值。现在我们需要Θ,即Ψ和测量矩阵的组合。一旦我们有了它,就可以开始使用 Lasso 了。我们有两个y向量,因此我们需要两个Θ矩阵:
D = dct(np.eye(len(samples)))
U = D[u,:]
R = D[r,:]
第一个,U,只保留均匀选择的测量值。第二个,R,使用随机选择的测量值。在这里,D是离散傅里叶变换矩阵,Ψ,而U和R分别是Θ[u]和Θ[r]。
我们进行两次优化,首先是y[u] = Θ[u]s[u],然后是y[r]* = Θ[r]s[r]*,其中下标现在表示均匀采样和随机采样的测量值:
lu = Lasso(alpha=0.01, max_iter=6000)
lu.fit(U, bu)
su = lu.coef_
lr = Lasso(alpha=0.01, max_iter=6000)
lr.fit(R, br)
sr = lr.coef_
Lasso 遵循 scikit-learn 的惯例,创建一个类的实例,然后调用 fit 来进行优化。对于 Lasso,解向量隐藏在 coef_ 成员变量中,我们提取它以获得 su 和 sr,即均匀和随机的 s 向量。 图 9-3 显示了这两个 s 向量(cs_signal_sparse.png)。

图 9-3:随机(上)和均匀(下)解向量, s
最上面的图显示了 s[u],底部显示了 s[r]。这些尖峰对应于 DCT 组件。两个 s 向量都是稀疏的,大多数 4,096 个元素接近零,但底部的向量有超过 10 个非零元素,而顶部的向量只有 3 个(形状有时既为正也为负)。回想一下,x 是三条正弦波的总和,因此 s[r] 向量中的三条正弦波和三次尖峰似乎很有前景。
Lasso 已经为我们求解了 y = Θs。现在我们需要 x = Ψs,我们通过调用逆 DCT 来找到它:
ru = idct(su.reshape((len(samples),1)), axis=0)
rr = idct(sr.reshape((len(samples),1)), axis=0)
图 9-4 显示了 x[u] (ru) 和 x[r] (rr); 参见 cs_signal_recon.png。

图 9-4:从上至下:原始信号、随机采样重建信号、均匀采样重建信号
最上面的图显示了原始信号。中间的图显示了通过随机选择的测量值,从原始信号的 20% 重建的信号。最后,底部的图显示了通过均匀选择的测量值,同样是原始信号的 20%,重建的信号。你认为哪一种更忠实地捕捉到了原始信号?
最后一步是将信号输出为 WAV 文件:
WriteOutputWav(samples, "original.wav")
WriteOutputWav(rr, "recon_random.wav")
WriteOutputWav(ru, "recon_uniform.wav")
请参阅 清单 8-1 来回顾 WriteOutputWav 是如何工作的。播放输出文件。我想你会同意,随机采样产生了更好的结果。
均匀采样在深层数学原因上失败,原因与测量矩阵和 DCT 变换基之间的相干性有关。然而,我们可以直观地理解均匀采样在低于 Nyquist 率时失败,这意味着会出现 混叠,即高频信号看起来像低频信号,且无法将两者区分开来。另一方面,通过随机采样,混叠的可能性降低,使得 Lasso 更可能找到合适的 s 向量。
重新运行 cs_signal.py,但将分数从 20% 改为更小和更大的值。有没有地方出现问题?看看你能否仅通过原始信号的 10%、5% 甚至 1% 来重建信号,然后尝试反方向。即使是稍微超过 50% 的采样,也似乎对均匀采样的质量产生了显著影响。为什么会这样呢?考虑一下 Nyquist-Shannon 采样定理的要求。
解开的图像
文件cs_image.py将压缩感知应用于图像。它类似于cs_signal.py,但它在选择测量的组件(像素)之前展开图像。图像是x,选定的掩膜像素形成y。代码期望以下命令行参数:
> python3 cs_image.py
cs_image <image> <output> <fraction> <alpha> [ <kind> | <kind> <seed> ]
<image> - source image (RGB or grayscale)
<output> - output directory (overwrittten)
<fraction> - fraction of image to sample
<alpha> - L1 lambda coefficient
<kind> - randomness source
<seed> - seed value
输入图像可以是灰度图像或 RGB 图像。如果是 RGB 图像,每个通道将使用相同的随机掩膜单独处理。输出目录包含原始图像、重建图像和一个参数文件。
代码尝试从 scikit-image 中导入。如果 scikit-image 没有安装,它也会运行,但你可以通过以下命令安装:
> pip3 install scikit-image
如果存在 scikit-image,代码会导入structural_similarity,它测量两幅图像之间的平均结构相似性——这里是原始图像和重建图像。相似性越高越好,1.0 表示完全匹配。
代码加载输入图像,将其转换为 RGB,并测试是否真的是灰度图像:
simg = np.array(Image.open(sname).convert("RGB"))
grayscale = False
if (np.array_equal(simg[:,:,0],simg[:,:,1])):
grayscale = True
一个转换为 RGB 的灰度图像,其每个通道都会相同,因此会调用array_equal。这个测试不是完全无懈可击的,但对我们来说已经足够好了。
下一步生成随机掩膜,实际图像像素的子集,用来构造y:
row, col, _ = simg.shape
mask = np.zeros(row*col, dtype="uint8")
M = int(fraction*row*col)
k = np.argsort(rng.random(row*col))[:M]
mask[k] = 1
mask向量对于选定的像素值为 1。
代码的其余部分会针对每个图像通道调用CS,如果是 RGB 图像,或者如果是灰度图像,则调用第一个通道,然后将原始图像、重建图像和参数转储到输出目录中。所有操作都在CS中:
def CS(simg, mask, fraction, alpha, rng):
row, col = simg.shape
f = simg.ravel()
N = len(f)
k = np.where(mask != 0)[0]
y = f[k]
D = dct(np.eye(N))
A = D[k, :]
seed = int(10000000*rng.random())
lasso = Lasso(alpha=alpha, max_iter=6000, tol=1e-4, random_state=seed)
lasso.fit(A, y.reshape((len(k),)))
r = idct(lasso.coef_.reshape((N, 1)), axis=0) r = (r - r.min()) / (r.max() - r.min())
oimg = (255*r).astype("uint8").reshape((row,col))
return oimg
CS函数是cs_signal.py中精简版的核心代码。它形成了展开的图像(f),然后选择掩膜区域来形成y。
为了使代码从给定的种子值可复现,我们定义了局部变量seed,并在调用fit之前将其传递给Lasso构造函数。
当fit退出时,逆 DCT 使用稀疏向量(s)来恢复图像。图像没有被缩放到[0, 255],因此我们首先将其缩放到[0, 1],然后乘以 255 并重塑(oimg)。
让我们来看看cs_image.py是否有效。这个命令行
> python3 cs_image.py images/peppers.png peppers 0.1 0.001 mt19937 66
尝试重建辣椒图像。它将在运行几分钟后生成图 9-5。

图 9-5:原始图像(左),掩膜(中),重建图像(右)
原始图像在左侧,10%的掩膜在中间,重建图像在右侧。最好查看彩色版本;查看peppers目录中的文件。我反转了掩膜图像,以便将选定的像素显示为黑色。
重建的图像并不特别引人注目,直到你记得原始图像信息中有 90%被丢弃,或者实际上根本就没有被测量过。
我曾声称 Lasso 找到稀疏的 s 向量。信号示例是稀疏的,但图像呢?测试图像为 128×128 = 16,384 像素,这意味着 s 含有这么多元素。使用 barbara.png 图像进行快速测试,保留 20% 的像素,返回的 s 中有 70% 是零。降到 10% 时,零的比例上升到 81%,而升到 80% 时,零的比例降至仅 15%。测量减少意味着 s 更稀疏,这似乎是合理的。回想一下,s 是图像在离散余弦变换空间中的表示。如果我们在尝试最优拟合 y 的少量测量时,能找到少数低频成分,我们可能会期待大部分 s 在施加 ℓ¹ 正则化后变为零。
cs_image_test 脚本反复运行 cs_image.py,在同一测试图像上,测量像素的比例从 1% 到 80% 不等。图 9-6 显示了重建图像的结果。

图 9-6:通过改变原始像素的比例重建 zelda.png 图像
在 10% 时,我们已经能够开始辨认图像,但直到 20% 时,才清楚地知道这是一张人的面孔。注意,我调整了原始 zelda.png 图像的亮度,使其使用整个 [0, 255] 范围;这使其亮度与重建图像一致。
图 9-7 显示了重建图像与原始图像之间的平均结构相似性指数 (SSIM) 的变化曲线。

图 9-7:平均结构相似性指数随测量数量的变化
正如我们预期的那样,随着测量像素数量的增加,指数迅速增加。结果令人鼓舞,因为原始图像和从减少 20% 测量中重建的图像之间几乎没有感知上的差异。
压缩感知应用
压缩感知被广泛应用于许多领域,包括医学影像学,在磁共振成像和各种断层扫描形式中,压缩感知的应用显著提高了获取时间。将压缩感知应用于断层扫描意味着采集更少的投影,从而大幅减少使用的 X 光能量(电离辐射)。
磁共振成像是压缩感知的自然应用目标。图像采集过程实际上是在k 空间(或傅里叶空间)中进行测量,相当于直接测量s。通过二维反傅里叶变换恢复所需图像,就像我们通过逆离散余弦变换从s恢复x一样。许多 k 空间采样策略已被开发出来,以加速图像采集,同时仍能生成具有临床价值的图像。磁共振图像采集的工作原理使得本章中的简单随机采样不适用,但存在用于以数学上不相干的方式采样 k 空间的替代方法,这些方法能够减少采集时间。例如,GE 的HyperSense,一种先进的压缩感知方法,能够将扫描时间减少最多 50%。更快的扫描时间意味着患者所需的扫描时间减少。
然而,压缩感知的未来有些不确定。深度神经网络在解决逆线性问题方面也非常擅长——事实上,可能比传统的压缩感知更好。将深度神经网络替代传统的压缩感知,或与其结合使用,是一个活跃的研究领域。
练习
本章简要介绍了两个实验,首先是处理一维信号,然后是将图像表示为一维向量。以下是一些可能的进一步探索方向:
-
cs_signal.py中的代码是针对整个一秒钟的声音样本工作的。你如何修改这种基本方法来压缩任意 WAV 文件?提示:试着仅保留每几百毫秒声音的随机子集,并重建每个子集。
-
假设你构建了一个任意的 WAV 文件系统,你能否使用相同的测量矩阵(相同的随机采样)来处理每个子集,还是更好地以某种方式进行修改——也许使用固定的伪随机种子并根据需要以块的方式选择测量?
-
我们所有的图像实验都使用了α = 0.001。试着将α的值从接近 0 逐渐增加到 1,甚至更大。如果α = 0,则 Lasso 中的ℓ¹正则化项消失,优化变成了仅使用ℓ²范数的标准最小二乘法。当α非常小时,压缩感知是否有效?注意,scikit-learn 文档中对 Lasso 的警告是不要使用α = 0,因此在这种情况下,请将
Lasso替换为LinearRegression。 -
cs_image.py文件包含检查所提供的随机性源是否为
quasi,如果是,则将种子值解释为准随机生成器的基数。如果使用不同的素数基数,如 2、3 或 13,使用quasi会发生什么?你能解释你看到的结果吗? -
我们处理 RGB 图像时是逐个颜色通道进行的。作为替代方案,我们可以将整个 RGB 图像展开为一个三倍大小的向量,然后进行优化(记得在输出时重新构建 RGB 图像)。修改cs_image.py来实现这一点。这有影响吗?会有帮助还是有害?
-
所有的随机测量矩阵都是平等的吗?
总结
压缩感知突破了奈奎斯特-香农采样定理的限制,允许从比最初认为可能的更少的样本中重建信号。在本章中,我们实验了压缩感知的基本形式,并将其应用于音频信号和图像。
首先,我们讨论了压缩感知中的核心概念,包括稀疏性和ℓ¹正则化。接着,我们将压缩感知问题表示为一个逆线性问题,形式为y = Cx,其中y为测量向量,x为期望输出向量。实际上,稀疏性约束意味着使用x = Ψs的另一种形式,其中s为稀疏向量,Ψ为基。对我们来说,Ψ来源于离散余弦变换,在这种变换下,信号被认为是稀疏的。
压缩感知问题变成了寻找解向量s,使得y和CΨs = Θs之间的ℓ²距离尽可能小,同时满足∥s∥[1]也尽可能小的约束。我们发现,Lasso 回归非常好地实现了这一目标。
手头有了理论,我们进行了两组实验。第一组实验旨在利用低于奈奎斯特限制的均匀采样和随机采样来重建一个一秒钟的音频信号,即 C 大调和弦。均匀采样无法恢复信号,直到采样率超过播放率的一半(此时保留了超过 50%的样本)。另一方面,使用压缩感知的随机采样,即使丢弃了最多 90%的原始数据,仍然能得到良好的结果。
在第二个实验中,我们处理了灰度图像和 RGB 图像。与信号一样,我们成功地使用了压缩感知和离散余弦变换,从原始图像的 10%的像素恢复出图像,尽管通常伴随有相当大的噪声。DCT 不一定是图像的最佳基底,但更好的基底,如小波变换,超出了本书的讨论范围。
我们通过指出压缩感知对医学影像的贡献来结束这一章,它改善了患者的舒适度,并减少了电离辐射的暴露。最后,我们提到,深度神经网络的最新进展可能会对压缩感知的未来产生重大影响。
在下一章中,我们将暂停实验,探讨在实验中如何使用随机性。现代科学在很大程度上依赖于精心设计的实验,而随机性在这一过程中起着重要作用。
第十章:实验设计

科学方法是科学的基石。它涉及从假设中创造理论,通过实验进行测试,并通过从实验中获得的证据支持这些理论。在本章中,我们将探讨实验设计,或者说实验设计,这是科学方法的基础部分。随机性对于成功的实验设计至关重要,主要有两个原因。首先,无论哪个领域,许多测量都涉及不确定性或其他超出研究人员控制的因素,称为随机噪声。我们在实验设计中使用随机性来应对噪声,就像用火对抗火一样。其次,随机性使得实验结果符合统计学的预期。
本章通过模拟(第三章)来探讨实验设计中三种常见的随机化方法。我们的示例模仿了医学研究,但所涉及的概念适用于各个领域。
实验中的随机化
假设一位研究员想了解公众对于一项关于实施噪音法令的选票提案的看法,该法令旨在限制晚上 8 点后举办的吵闹派对。他决定通过电话调查,随机从电话簿中挑选 100 个名字,并在一个星期三下午给每个人打电话。他得到了 64 个答录机留言,36 个接通电话,且有 17 人愿意交谈。在这 17 人中,15 人支持该法令,2 人反对。 这些结果能否公正地反映公众对于这一问题的立场?
我怀疑你的答案是否定的,因为结果中可能有许多偏差来源。研究员使用了电话簿,而电话簿通常只列出固定电话;依赖被叫者愿意提供意见;并且在工作日的下午进行电话调查。
他的样本严重偏向于退休人员,这些人往往年纪较大,且(通常)不太倾向于举办派对,因此他的结果并不一定反映整体人群的情况。他的样本排除了最有可能受到法令影响的群体——年轻人,他们通常使用手机,并且在他收集数据的时间段内可能正处于学校或工作中。
避免这种样本偏差是进行实验时使用随机性的一个重要原因。然而,调查和投票中的偏差很难修正,且常常导致相反的结果。更微妙且可能更危险的是,医学研究中在选择群体时的样本偏差。
bad_sample.py 中的代码生成了一个包含四个随机选择特征的个体群体:年龄、收入、是否吸烟以及每周平均饮酒量。这些特征与个体的年龄相关,因此年纪较大的人更有可能收入较高、不吸烟且饮酒较少。
Population 函数生成一个作为 NumPy 数组的个体群体:
def Population(npop):
pop = []
for i in range(npop):
age = 20 + int(55*rng.random())
income = int(age*200 + age*1000*rng.random())
income = int(income/1000)
smoker = 0
if (rng.random() < (0.75 - age/100)):
smoker = 1
drink = 1.0 - age/100
drink = int(14*drink*rng.random())
pop.append([age, income, smoker, drink])
return np.array(pop)
代码通过循环附加四元组特征列表,每个特征列表都是根据选择的age(年龄)派生的。请注意,age的单位是年,income(收入)是千元,smoker(吸烟者)是二元变量,drink(饮酒量)是每周平均饮酒次数。
这段代码从总体中随机选择一个子集,以模拟一个医学研究中的随机样本。例如,以下是bad_sample.py的一次运行:
> python3 bad_sample.py 1000 10 1 mt19937 4004
age : 47.22 42.20 (t= 0.9782, p=0.32823)
income: 33.20 24.80 (t= 1.4284, p=0.15350)
smoker: 0.27 0.10 (t= 1.2126, p=0.22555)
drink : 3.14 3.20 (t=-0.0714, p=0.94309)
总体规模是 1,000,我们从中随机选择 10 个样本。1参数表示采样一次。像往常一样,随机源和种子跟随其后。
我们根据年龄、收入、吸烟情况和饮酒情况对结果进行排序。第一列显示的是整个总体的这些值的均值。第二列是 10 人随机样本的均值。括号中的值是 t 检验结果,其中t是t统计量,p是 p 值。总体和样本之间的显著差异会产生一个低 p 值。t统计量的符号使得正的t表示总体均值超过样本均值。
在这种情况下,10 人的随机样本在饮酒和总体年龄方面与总体相似。然而,收入有所不同,这可能会影响那些宣称此样本能代表总体的研究结果。
让我们再次运行相同的命令:
> python3 bad_sample.py 1000 10 1 mt19937 6502
age : 47.35 56.10 (t=-1.7258, p=0.08468)
income: 32.43 31.80 (t= 0.1118, p=0.91097)
smoker: 0.29 0.10 (t= 1.3255, p=0.18530)
drink : 3.20 2.60 (t= 0.7774, p=0.43713)
这个随机样本明显比总体年龄大,因此吸烟的可能性要小得多。尝试几次运行bad_sample.py。虽然有些样本与总体相似,但其他样本则显著偏离。
运行bad_sample.py,但将样本数量从 1 改为 40:
> python3 bad_sample.py 1000 10 40 mt19937 8080
10 6.36529487 0.49581353
总体中的每个人都是一个四元组(年龄、收入、吸烟、饮酒)。因此,他们在四维空间中成为一个点。如果我们考虑个体年龄、收入、吸烟和饮酒特征的均值,那么整个总体就变成了空间中的一个点。bad_sample.py文件报告了总体均值点与样本均值点之间的均值(±标准误)欧几里得距离,对于每个 40 个样本的采样而言。在这种情况下,样本量为 10 时,均值距离为 6.37 ± 0.50。
让我们将样本大小增加到 100:
> python3 bad_sample.py 1000 100 40 mt19937 8080
100 2.12064926 0.23245698
总体与样本之间的均值距离会减少。如果我们有更大的样本,它应该能更好地代表整个总体。这个效应是科研和机器学习模型中尽可能使用大量数据的动机所在。
文件bad_sample_test.py对 10,000 人的总体和从 10 到 5,000 的样本量重复此过程。对于每个样本量,我们收集 40 个样本。结果适合绘制图形,如图 10-1 所示。

图 10-1:总体均值与样本均值之间的欧几里得距离与样本量的关系
图 10-1 中的 x 轴表示每个数据集中的样本数量。我们绘制了这些样本均值与总体均值的平均偏差,并用误差条表示标准误差。我在误差条上添加了端点,以便更容易看到范围。每个点是基于 40 对总体-样本的均值计算得出的。
更大的样本量导致总体均值和样本均值之间的差距变小,表明较大的样本更可能是总体的更好代表。注意,随着样本量的增加,误差条变得更小。样本中的噪声,或与总体均值的偏差,几乎降至零。
样本量为 10 时的误差条很大,均值偏差约为 7,约为样本量 500 时均值偏差的七倍。还要注意,随着样本量的增加,曲线下降得非常快。对于许多实验来说,可能不需要特别大的样本量,但研究的效应强度会影响样本量的选择。更多的样本总是更好的。
实验通常会报告均值结果以及均值的标准差或标准误差。接着,他们会进行显著性检验,比如 t 检验;如果 p 值低于(任意设定的)0.05 阈值,他们就宣布结果显著,并标记为统计学上显著。然而,这只是故事的一部分。如果样本量很大,比如在一些基于广泛收集健康数据的队列研究中,那么结果可能会是 p < 0.05,但由于样本量庞大,基于 Cohen 的 d 测量的 效应大小 可能很小。换句话说,效果虽然真实存在,但由于效应大小较小,可能在实际中并不具有太大意义。故事的启示是:尽可能报告效应大小。
图 10-1 中小样本量的噪声证明了我在前一段中提到的问题。如果效应很小,并且我们预计与总体均值的偏差很小,那么小样本量不太可能捕捉到这种效应,因为它被样本自身固有的噪声所掩盖。我们将在本章后面使用实验设计技术来补偿选择偏差时再次讨论这个概念。
最后,正是图 10-1 中小样本量使其成为不适合研究的选择的原因,也是驱动进化的基因漂变的原因。在第三章中,我们看到,某个亚群体中的性状随机混合,并且偶然与更大群体隔离,导致了基因漂变,最终形成了新物种。从这个意义上讲,进化虽然是一个糟糕的研究者,但却是一个聪明的修补匠,能够利用样本偏差,并将其转化为长期有用的东西。
既然我们知道样本偏差可能导致糟糕的研究结果,那么我们就来探讨一下如何补偿这种偏差。
实验设计中的随机化分为三大类。我们将在本章剩余部分使用的代码支持所有三种方法。
简单
如果实验正在测试一个结果——例如某种特定治疗的有效性——我们可能会通过一次招募一个成员来建立治疗组和对照组,并通过掷硬币决定组的分配。我们可以用一段 Python 代码来模拟这种 简单随机化:
>>> from RE import *
>>> RE(mode="int", low=0, high=2).random(10)
array([1, 1, 1, 0, 0, 0, 0, 1, 0, 1])
>>> RE(mode="int", low=0, high=2).random(10)
array([0, 1, 1, 0, 1, 1, 0, 0, 1, 1])
我们配置 RE 来返回硬币翻转结果,0 或 1。每次调用返回 10 次翻转。因此,我们为 20 名参与者生成了分配:治疗组 11 名,对照组 9 名。
简单随机化看起来是一个不错的方法,但它有一个明显的缺点,类似于我们在处理 bad_sample.py 时观察到的情况。
让我们再试一次为 10 名参与者进行分配的实验:
>>> RE(mode="int", low=0, high=2).random(10)
array([0, 1, 1, 1, 0, 0, 1, 1, 1, 1])
如果我们遵循简单随机化,我们会得到治疗组 7 人,对照组只有 3 人——这似乎不太明智。请注意,这不是一个人为的例子;该输出来自于那段代码的单次运行。
虽然简单随机化在研究规模较小时不是最佳方法,但它通常在较大规模的研究中效果很好:
>>> s = RE(mode="int", low=0, high=2).random(10000)
>>> np.bincount(s)
array([4852, 5148])
在这种情况下,治疗组和对照组大小之间的差异只是总研究规模的一小部分,因此我们可以预期每组会相似。
简单随机化的弱点,尤其是在小规模研究中,是治疗组和对照组中的受试者数量可能会高度不平衡。我们可以通过区块随机化来弥补这一点。
区块
区块随机化 确保每组中的受试者数量相同。对于一个只有两组——治疗组和对照组——的二项实验,我们首先选择一个区块大小,通常在 4 到 6 之间。我们将在示例中使用固定的区块大小 4。接下来,创建所有可能的四个受试者的区块,其中分配是平衡的。对我们来说,这意味着所有四位二进制数字的组合,其中 1 和 0 的数量相同:
1100, 1010, 1001, 0110, 0101, 0011
每个区块有两个 1 和两个 0。
我们选择足够多的区块来覆盖受试者,以生成最终的组分配。为了实现完全平衡的组,受试者数量必须是区块大小的倍数,在这种情况下是四的倍数。所以,对于 32 名受试者,总共需要八个区块,因为 32 / 4 = 8。我们从区块集随机选择这八个区块,但无论选择哪些区块,1 和 0 的总数都会相同。
例如,下面是一个随机选择的八个区块的序列:
>>> from RE import *
>>> b = ["1100","1010","1001","0110","0101","0011"]
>>> r = RE(mode="int", low=0, high=6)
>>> "".join([b[i] for i in r.random(8)])
'11000011101010101010100100110110'
有 16 名受试者被分配到治疗组和对照组。再次运行代码时,顺序会有所不同,但每组分配的人数仍然是 16。将这个顺序应用到选定的 32 名受试者中,可能通过按顺序匹配受试者的 ID 号来实现。参见表 10-1。
表 10-1: 匹配受试者 ID 与分配的组别
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | . . . | |
|---|---|---|---|---|---|---|---|---|---|
| 受试者 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | . . . |
| 组别 | 1 | 1 | 0 | 0 | 0 | 0 | 1 | 1 | . . . |
这一过程持续进行,直到所有 32 名受试者都被分配完毕。
区组随机化比简单随机化有优势,因为它平衡了治疗组和对照组中的受试者数量。然而,这两种方法都没有关注可能影响或掩盖治疗效果的其他特征。如果治疗效果显著并且广泛适用,那么简单随机化和区组随机化都能反映这一点。但是,如果治疗效果较弱或仅对特定人群相关,那么单独使用任何一种方法都是不理想的。于是,分层随机化应运而生。
分层
在 分层随机化 中,我们将受试者分配到治疗组和对照组,使得每个组包含具有其他特征(即协变量)的均衡受试者,这些特征是我们(实验者)认为可能影响结果的。例如,如果我们为一个研究项目设计了一个区组设计,旨在测试补充剂服用三个月后的耐力,而我们的治疗组恰好有大量 25 岁以下的不吸烟者,那么在实验结束时,治疗组与对照组之间的耐力差异可能会很大。
分层随机化尽可能通过将对照组的协变量与治疗组匹配来管理组的组成。
分层随机化有不同的实施方法。在我们的模拟中,我们将从一个大范围的潜在受试者群体中随机挑选一个治疗组受试者。根据该受试者的协变量,我们将寻找一个匹配的受试者,放入对照组。这样,我们就能确保治疗组和对照组中的受试者数量平衡,而且我们知道每个治疗组的受试者都有一个匹配的对照组,以使两组的整体特征相同。换句话说,我们将调整协变量的差异。
我们通过 bad_sample.py 的经验发现,小规模研究容易受到年龄、收入、吸烟和饮酒等因素的显著变化影响。这些因素就是我们在模拟中使用的协变量。
定义模拟
本章的其余部分将使用design.py中的代码。像往常一样,我建议在继续之前先阅读代码。我们将在下一部分进行详细讲解;在这一部分中,我们将定义希望通过模拟实现的目标。
这是场景设定:我们希望评估一种新型补充剂对健康的影响,使用一年后的效果。为此,我们首先选择一个治疗组,该组在一年内服用补充剂。然后,我们将他们的总体健康评分与没有服用补充剂的对照组进行比较。
注意
现代临床试验通常会使用安慰剂,即“假”治疗,作为对照组。在这种情况下,安慰剂通常是看起来像补充剂的糖丸。无论是参与者还是研究人员,都无法知道谁接受了什么治疗,这样可以使研究保持双盲设计。另一种方法是,对照组可能完全不知情补充剂组的情况。在这种情况下,他们会在研究开始和结束时接受调查或测试,但不会给与安慰剂。虽然我们会使用后一种方法,但两种方法在此情境下都适用。
模拟是有偏的;治疗组总是会有正向的健康效益。效应的强度由我们控制。显然,没有补充剂;模拟的目的是了解三种实验设计选择如何影响实验的结果。治疗效应容易被检测到吗?如果我们看到有改善,我们会多么相信这一测量效应是真实的?我们将尝试回答这些问题。
正如我们在bad_sample.py中所做的那样,我们将使用相同的协变量生成个体人群:年龄、收入、吸烟和饮酒。与bad_sample.py不同,我们不会使用年龄来影响其他协变量,而是立即将协变量分入不同的类别。换句话说,我们不会说某个人是 48 岁或 11 岁;相反,我们会从三个年龄类别中选择,即年龄将是 0、1 或 2。收入和每周饮酒量也会进行同样的处理。吸烟是二元的,0 或 1。
每次运行代码都会累积用户选择的实验次数的统计数据。我们随机生成一个人群,并根据所需的随机化方案选择治疗组和对照组。然后,我们施加治疗(补充剂),并使用两个组的健康评分生成度量指标。当所有实验完成后,我们使用收集到的信息来创建输出,提供对随机化方案效果的洞察。
与所有模拟一样,我们必须小心确保所模拟的事物在所需的程度上是现实的合理近似。记住 Box 的名言:所有模型都是错误的,但有些模型是有用的。
实现模拟
让我们理解一下design.py的关键部分,从文件底部的主要循环开始。然后,根据需要,我们将讨论调用循环的函数。接下来是分析结果的代码。从算法角度看,主要循环会按照期望的实验次数(nsimulations)重复以下步骤:
-
创建一个人口(
Person对象的列表)。 -
根据用户提供的随机化方案(
typ)选择control和treatment组。 -
通过调用
Treat方法并传递期望的治疗效应大小(beta),将处理应用于treatment组。 -
累计
control和treatment组的统计信息。这包括每个受试者的健康评分以及每个协变量的平均值。 -
将收集到的数据添加到
results列表中,每个元素是一个字典,包含该模拟实验的结果。
主要循环位于清单 10-1 中。
results = []
for nsim in range(nsimulations):
pop = []
for i in range(npop):
pop.append(Person())
control, treatment = [Simple, Block, Stratified]typ
for subject in treatment:
subject.Treat(beta)
ch, c_age, c_income, c_smoker, c_drink = Summarize(control)
th, t_age, t_income, t_smoker, t_drink = Summarize(treatment)
results.append({
"c_age": c_age,
"c_income": c_income,
"c_smoker": c_smoker,
"c_drink": c_drink,
"t_age": t_age,
"t_income": t_income,
"t_smoker": t_smoker,
"t_drink": t_drink,
"ttest": ttest_ind(th,ch),
"d": Cohen_d(th,ch),
})
清单 10-1:主要循环
有五段代码。第一段创建了pop,这是一个Person对象的列表,我们从中选择control和treatment组的受试者。
第二段代码,只有一行,选择适当的随机化函数,传递人口和实验中的受试者总数(nsubj)。对于Block和Stratified随机化,每组的受试者数量始终是所需总数的一半。control和treatment都是从pop中选择的Person对象列表。
下一段代码对treatment组中的每个受试者调用Treat方法。参数beta,0, 1),是用户提供的治疗效应大小,其中较高的beta意味着更强的正向治疗效应。换句话说,随着beta的增加,补充剂的效果提高。
第四段代码计算control和treatment组的每个受试者健康评分和协变量均值。
最后一段代码将此实验的结果附加到results列表中。调用ttest_ind对治疗组和对照组的健康评分进行 t 检验,而Cohen_d计算 Cohen 的d值来衡量效应大小。
让我们探索由主要循环调用的辅助函数,接下来是每个随机化函数。我们希望正确模拟后者。
函数和类
人口是Person实例的列表([清单 10-2)。
class Person():
def __init__(self):
self.age = int(3*rng.random())
self.income = int(3*rng.random())
self.smoker = 0
if (rng.random() < 0.2):
self.smoker = 1
self.drink = int(3*rng.random())
self.adj = 2*(rng.random() - 0.5)
def Health(self):
return 3*(2-self.age) + 2*self.income - 2*self.smoker - self.drink + self.adj
def Treat(self, beta=0.03):
self.adj += 3*binomial(300, beta, rng) / 300 # [0,1]
清单 10-2: Person 类
一个人是五个特征的集合。其中三个特征是[0, 2]范围内的整数:age、income和drink。注意调用了全局定义的rng,它是RE类的实例,依据提供的随机源和种子进行初始化。smoker特征是二进制的,因此一个人有 20%的概率是吸烟者。第五个特征adj是一个[–1, 1]范围内的随机浮点数。我们将在下一节中学习为什么使用这种分箱方案。
Health 方法返回一个浮动值,表示一个人的整体健康状况:
Health = 3 × (2 – age) + 2 × income – 2 × smoker – drink + adj
更好的健康(更高的浮动值)与较年轻的年龄、更高的收入水平、不吸烟以及尽量减少饮酒相关,还包括随机调整。
Treat 方法通过根据期望治疗效果 beta 修改 adj 来应用治疗。它通过将 adj 增加三倍来自二项分布的随机抽样结果(使用固定试验次数 300)来实现。
我们以前没有处理过二项分布。如果某个事件的发生概率在每次试验中为 p,可以想象为掷一枚带有偏向的硬币,正面朝上的概率为 p,并且有 n 次试验,那么在 n 次试验中,成功事件的次数将遵循一个二项分布,预期的结果范围是 [0, n]。
例如,如果事件的发生概率 p = 0.7(即 70%),并且有 n = 10 次试验,那么在重复实验中,事件的预期次数将遵循图 10-2 中的分布。

图 10-2:10 次试验,每次试验成功概率为 70% 的二项分布
在 10 次试验中,每次试验成功概率为 70% 时,最常见的成功次数是 7,正如我们所预期的那样。然而,大约 2.5% 的时间内,每次试验都成功了。
随着成功概率的增加,在给定次数试验中可能的成功事件数量也会增加。在 Treat 中,我们将成功试验的次数按 300 次概率 beta 进行缩放,因此整个表达式变为 [0, 1] 之间的一个数,我们将其乘以 3 并加到 adj 上。当试验次数很大时,二项分布看起来就像一个窄的正态分布,其中治疗效应集中在提供的 beta 值附近。
binomial 函数使用由 rng 提供的均匀分布模拟从二项分布中抽取样本。这个算法效率不高,因为它使用了多次对 rng 的调用来返回一个来自目标二项分布的样本。但对我们来说,这已经足够了(见清单 10-3)。
def binomial(n,a,rng):
k = 0
p = a if (a <= 0.5) else 1.0-a
for i in range(n):
if (rng.random() <= p):
k += 1
return k if (a <= 0.5) else n-k
清单 10-3:从二项分布中采样
成功试验的次数保存在 k 中。条件返回和对 p 的赋值利用了二项分布的对称性,返回任何给定成功概率(a)的目标样本。
主循环调用了另外两个函数,Cohen_d 和 Summarize,如同在清单 10-4 中所示。
def Cohen_d(a,b):
s1 = np.std(a, ddof=1)**2
s2 = np.std(b, ddof=1)**2
return (a.mean()-b.mean())/np.sqrt(0.5*(s1+s2))
def Summarize(subjects):
h = []
age = income = smoker = drink = 0.0
for subject in subjects:
h.append(subject.Health())
age += subject.age
income += subject.income
smoker += subject.smoker
drink += subject.drink
age /= len(subjects)
income /= len(subjects)
smoker /= len(subjects)
drink /= len(subjects)
return np.array(h), age, income, smoker, drink
清单 10-4:附加辅助函数
Cohen_d 函数通过计算两个数据集的均值差异以及它们平均方差的平方根来衡量效应大小。
Summarize 函数从治疗组或对照组中查询一组对象,并返回一个包含对象健康评分、平均年龄、收入、吸烟和饮酒值的向量。
让我们深入探讨,讨论一下不同的随机化方案。
方案
清单 10-5 使用简单随机化来掷硬币,将选定的对象分配到治疗组或对照组。
def Simple(pop, nsubj):
order = np.argsort(rng.random(len(pop)))
c = []; t = []
for k in range(nsubj):
if (rng.random() < 0.5):
c.append(pop[order[k]])
else:
t.append(pop[order[k]])
return c,t
清单 10-5:简单随机分配
Simple 函数首先生成群体的随机排序。严格来说,这不是必需的,因为我们已经随机生成了 pop,但我们不应该假设如果不需要也可以不做此操作。
for 循环按顺序挑选对象,并根据掷硬币的结果将它们分配到治疗组或对照组。注意,掷硬币的结果意味着每个组中的对象数量可能不相同。完成后,我们返回两个列表。
区块随机化确保治疗组和对照组的大小平衡,如清单 10-6 所示。
def Block(pop, nsubj):
ns = 4*(nsubj//4)
blocks = ["1100","1010","1001","0110","0101","0011"]
nblocks = ns//4
seq = ""
for i in range(nblocks):
n = int(len(blocks)*rng.random())
seq += blocks[n]
order = np.argsort(rng.random(len(pop)))
c = []; t = []
for i in range(ns):
if (seq[i] == "1"):
t.append(pop[order[i]])
else:
c.append(pop[order[i]])
return c,t
清单 10-6:区块随机化
Block 的第一行使用整数除法将 ns 设置为不超过 nsubj 的最接近的 4 的倍数。接下来是作为二进制数字字符串的区块定义。这些区块是我们将四个项目均匀分配到两个组(治疗组(1)和对照组(0))的六种方式。
我们创建 seq 字符串,包含区块数(nblocks),通过随机拼接区块定义,直到字符串长度达到 ns 字符。
最后的 for 循环模拟了 Simple,在根据当前 seq 的特征将对象分配到治疗组和对照组之前,对 pop 的顺序进行随机化。
我们的分层随机化方法是从群体中随机选取一名对象进入治疗组,然后在群体中寻找一名未分配且具有相同特征的人,将其分配到对照组。通过这种方式,两个组在数量和总体特征上都得到了平衡。在代码中,这对应于清单 10-7。
def Stratified(pop, nsubj):
def match(n,m,pop,selected):
if (selected[m]):
return False
if (pop[n].age != pop[m].age):
return False
if (pop[n].income != pop[m].income):
return False
if (pop[n].smoker != pop[m].smoker):
return False
if (pop[n].drink != pop[m].drink):
return False
return True
selected = np.zeros(len(pop), dtype="uint8")
c = []; t = []
while (len(t) < nsubj//2):
n = int(len(pop)*rng.random())
while (selected[n] == 1):
n = int(len(pop)*rng.random())
selected[n] = 1
t.append(pop[n])
m = int(len(pop)*rng.random())
while (not match(n,m, pop, selected)):
m = int(len(pop)*rng.random())
selected[m] = 1
c.append(pop[m])
return c,t
清单 10-7:分层随机化
清单 10-7 创建了一个标志向量 selected,用于标记已经分配到某组的群体成员。然后 while 循环运行,直到治疗组包含所请求数量的一半对象。while 循环的主体将 n 设置为群体中未选择的成员的索引,该成员将成为治疗组的一员。一旦找到 n,我们更新 selected,并将该成员添加到治疗组(t)。
接下来,我们找到了另一位未选择的群体成员,其特征与我们刚刚加入治疗组的人的特征相匹配;这就是Person类使用特征分类的原因。共有 3 × 3 × 2 × 3 = 54 种可能的特征组合。在一个大规模的群体中,我们几乎可以确保找到匹配,这发生在match内嵌函数返回True时。当我们将匹配项添加到对照组后,外层的while循环继续,直到为所有受试者分配完毕。
design.py中的其余代码分析了多个实验的结果。虽然我不会逐行讲解,但我会在下一节描述分析内容。
探索模拟
我们准备运行一些实验。首先,让我们通过一个简短的回顾来定位自己:
-
我们正在模拟实验,使用已知的正向治疗效应大小,这个大小是我们提供的。
-
我们提供了随机化方案:简单、区块或分层。
-
我们正在评估多个实验的结果,以了解随机化方案的影响。
我们模拟的不是一个单一的实验,而是几十个到上百个实验。我们将评估这些实验的综合结果,以(希望)得出随机化方案对实验结果微妙影响的图景。design.py文件代表了一个包含所有治疗研究的完整宇宙,这些研究都是使用给定的随机化方案进行的。
让我们深入了解。design.py文件期望接收几个命令行参数:
> python3 design.py
design <npop> <nsubj> <beta> <nsim> <type> <plot> [<kind> | <kind> <seed>]
<npop> - population size (e.g. 1000)
<nsubj> - number of subjects in the experiment (e.g. 40)
<beta> - supplement effect strength [0..1]
<nsim> - number of simulations to run (e.g. 100)
<typ> - selection type: 0=simple, 1=block, 2=stratified
<plot> - 1=show plot, 0=no plot
<kind> - randomness source
<seed> - seed value
我们必须提供人口规模(npop)、每个实验中的受试者数量(nsubj)、治疗效应强度(beta)、要模拟的实验数量(nsim)以及使用的随机化类型(type)。和往常一样,随机性来源和可选的种子值排在最后。
简单
我们可以像这样运行第一次模拟:
> python3 design.py 10000 32 0.3 40 0 1 minstd 6809
mean p-value (lowest) : 0.01138
mean p-value (highest): 0.82919
mean Cohen's (lowest) : 1.00568
mean Cohen's (highest): 0.07732
delta age : (high, low, t, p) = (0.16556, 0.41945, -3.27589, 0.01691)
delta income: (high, low, t, p) = (0.21191, 0.30570, -0.64742, 0.54132)
delta smoker: (high, low, t, p) = (0.12377, 0.11255, 0.21945, 0.83357)
delta drink : (high, low, t, p) = (0.38284, 0.22193, 0.83403, 0.43620)
一张图表应该会显示出来,并伴随文本输出;我们很快就会看到它。我们请求模拟 40 次实验,每次实验从 10000 人的池子中随机选择 32 个受试者,采用简单随机化(0)。处理效应大小为 0.3,这意味着对每个Person对象调用Treat方法时,会向个体的整体健康状况添加一个围绕 0.3 的随机值。
这些统计数据来自模拟结果。前两行显示的是最低和最高 10%的实验的平均 p 值。回想一下,实验的 p 值来自治疗组和对照组的健康评分。在这种情况下,每个实验每组大约有 16 个受试者,共模拟了 40 个实验,因此最高和最低 10%的 p 值对应于 4 个实验。因为我们使用了简单随机化,所以某个特定模拟实验的治疗组和对照组大小并不总是 16。
接下来的两行报告了相同实验组的平均 Cohen’s d值。Cohen’s d是治疗组和对照组均值差异的标准化版本。因此,正如我们在使用分层随机化时所看到的,我们可以期望至少是最低的 10%组的平均效应大小与所要求的效应大小相近。在这里,我们要求的效应大小较小,为 0.3,但均值却为 1.0。对于最高的 10%组,效应大小不显著,p 值为 0.83。对于这一组实验,我们没有发现治疗组与对照组之间的显著差异。
最后的四行输出展示了每个实验的协变量差异的均值,分别是低 p 值组和高 p 值组。我们在这里使用 t 检验来检查治疗组和对照组之间的协变量差异是否显著。在这次运行中,年龄差异是显著的。
年龄的t统计量为负,这意味着低 p 值实验中的治疗组和对照组年龄差异较大。换句话说,在低 p 值实验中,治疗组和对照组的年龄协变量差异比高 p 值实验中的更大。年龄是健康评分中最突出的因素,因此治疗组和对照组之间的显著不平衡会强烈影响测量结果。
那么,我们应该如何看待这些结果呢?我们进行了许多模拟实验,即便是表现最好的那些——那些在处理后显示出显著差异的实验——我们也不应相信结果,因为这些实验的协变量差异很大。如果我们只进行了一次实验,可能看不到任何效果,并且会认为治疗是失败的。在那些确实看到效果的情况下,结果很可能是人为夸大的,因为处理组与对照组在某个重要的协变量上差异较大。
请记住,这些结果来自 40 个实验,意味着 32 个样本和简单随机分配到治疗组和对照组的实验结果往往不可靠,这些结果要么夸大了治疗的有效性,要么完全掩盖了它。
图 10-3 的左侧显示了design.py图表,这是一个直方图,展示了 40 个模拟实验中治疗组与对照组健康状况之间 t 检验的 p 值。垂直虚线 0.05 左侧的结果通常会被认为是统计学显著的结果。

图 10-3: design.py 按随机化类型的示例输出图
从图 10-3 中的最左侧图可以看出,大约 12.5%的实验结果显示出统计学上的显著差异。这些是我们可能在科学期刊上看到的实验。剩余的 87.5%的实验没有产生统计学上显著的治疗效果。根据科学规范,这些实验应该作为负结果发布,但此类论文很少出现——这是科学文献中一个已知的问题。
尽管我们事先并不知道治疗效应在现实中是否存在且为正,但在我们的案例中我们知道——并且所有 40 个模拟实验应该都能找到正结果。那么,为什么没有找到呢?受试者群体的规模是一个影响因素,我们将很快对此进行实验,但正如模拟所示,简单随机化导致的协变量不平衡也是一个因素,它既掩盖又夸大了效应。
区组
切换到区组随机化需要在命令行中将 0 改为 1:
> python3 design.py 10000 32 0.3 40 1 1 minstd 6809
mean p-value (lowest) : 0.02021
mean p-value (highest): 0.94917
mean Cohen's (lowest) : 0.89363
mean Cohen's (highest): 0.00087
delta age : (high, low, t, p) = (0.15625, 0.45312, -2.12870, 0.07735)
delta income: (high, low, t, p) = (0.21875, 0.42188, -1.53562, 0.17554)
delta smoker: (high, low, t, p) = (0.10938, 0.14062, -0.35729, 0.73310)
delta drink : (high, low, t, p) = (0.37500, 0.17188, 1.80858, 0.12051)
与简单随机化结果相比,变化不大。为每个实验选择平衡的处理组和对照组大小,消除了任何由不良分组导致的效应。不过,这种效应不太可能在我们这里考虑的多个实验层面上显现出来。图 10-3 中的中间图与简单随机化图类似;在这两者中,12.5%的实验在p < 0.05 水平上具有统计学显著性。然而,尽管没有达到简单随机化案例中那样的差异,但在最低和最高 p 值实验组之间,年龄差异再次显著。我们或许可以通过增加受试者数量来改善这个问题,但先让我们切换到分层随机化,看看会发生什么。
分层
要切换到分层随机化,将命令行中的 1 改为 2:
> python3 design.py 10000 32 0.3 40 2 1 minstd 6809
mean p-value (lowest) : 0.19833
mean p-value (highest): 0.64602
mean Cohen's (lowest) : 0.46910
mean Cohen's (highest): 0.16422
请注意,输出中没有任何“delta”行,因为在分层随机化中,处理组和对照组的协变量组成是相同的,因此所有的 delta 都是零;没有需要报告的内容。回忆一下,“delta”行是指处理组和对照组之间单个实验差异的均值。这些差异为零,因为我们为每个处理组的受试者选择了一个匹配的对照组受试者。
该图位于图 10-3 的右侧。它看起来不像简单或区组随机化的图表,因为协变量效应已经通过分层(匹配)选择过程得到补偿。该图所显示的是由其他因素导致的结果。
然而,没有实验的 p 值低于 0.05 的阈值。在适当考虑了我们认为会影响健康的协变量后,治疗效应虽然存在,但由于研究中只有 32 名受试者,太弱以至于无法检测到。
对于最低 p 值组,科恩的d值为 0.47——与我们应获得的 0.3 值相差不远,这表明我们一致地检测到了治疗效应。为了进一步改善,我们将通过增加受试者数量来调整研究规模。
增加受试者数量
让我们将受试者数量从 32 增加到 128:
> python3 design.py 10000 128 0.3 40 2 1 minstd 6809
mean p-value (lowest) : 0.06149
mean p-value (highest): 0.20789
mean Cohen's (lowest) : 0.33364
mean Cohen's (highest): 0.22381
最低 10%的实验的平均 p 值为 0.06,平均d为 0.334,所以我们已经接近了,但没有一个实验通过了 0.05 的魔法门槛。
让我们再次将研究规模翻倍:
> python3 design.py 10000 256 0.3 40 2 1 minstd 6809
mean p-value (lowest) : 0.00781
mean p-value (highest): 0.05495
mean Cohen's (lowest) : 0.33582
mean Cohen's (highest): 0.24168
现在,即使是最高 p 值组也接近 0.05,且两个d值大约在 0.3 左右。图表(未显示)说明几乎所有的实验都产生了p < 0.05 的结果。
通过分层随机化,我们已经考虑了协变量,因此我们在这些均值中看到了实际的治疗效应。不过,我们仍然需要足够多的受试者来从数据中提取治疗效应。让我们来看看为达到期望的效应大小,我们需要多少受试者。
cohen_d_test.py中的代码使用design.py来估算为达到最高 10%的实验中平均 p 值为 0.05 或更小所需的受试者数量。运行该代码会生成如下结果:
0.9: 50 subjects, p=0.03350000, d=0.7079
0.8: 60 subjects, p=0.03402000, d=0.6172
0.7: 80 subjects, p=0.02784000, d=0.5373
0.6: 90 subjects, p=0.03872000, d=0.4702
0.5: 110 subjects, p=0.04107000, d=0.4141
0.4: 170 subjects, p=0.04417000, d=0.3213
0.3: 280 subjects, p=0.04682000, d=0.2431
0.2: 580 subjects, p=0.04017000, d=0.1724
0.1: 2370 subjects, p=0.04922000, d=0.0811
请求的效应大小位于左侧,后面是估算的所需受试者数量、p 值以及模拟实验中最高 10%的p和d值。我的代码运行大约花了五个小时,大部分时间都在处理d = 0.1 的情况。通常,打印出的d值接近请求的值,这是一个好兆头。
该代码是一个循环,遍历期望的效应大小,并通过一个内部的while循环递增受试者数量,直到平均 p 值为 0.05 或更小:
def RunTest(beta, nsubj):
cmd = "python3 design.py 100000 %d %0.1f 20 2 minstd 6809 >/tmp/xyzzy"
os.system(cmd % (nsubj,beta))
lines = [i[:-1] for i in open("/tmp/xyzzy")]
pv = float(lines[2].split()[-1])
d = float(lines[5].split()[-1])
return pv,d
base = 10
for beta in [0.9,0.8,0.7,0.6,0.5,0.4,0.3,0.2,0.1]:
pvalue = 10.0
k = 1
while (pvalue > 0.05):
pvalue,d = RunTest(beta, k*base)
k += 1
print("%0.1f: %3d subjects, p=%0.8f, d=%0.4f" % (beta, k*base, pvalue, d), flush=True)
受试者数量从 10(base)开始,并在while循环中通过一个递增值k来乘以 1,直到 p 值达到或低于 0.05。RunTest函数构建design.py命令行,执行它并解析输出文件,获取p和d。
计算模拟的统计功效
让我们使用statsmodels包中的TTestIndPower来检查模拟是否准确捕捉了所需的受试者数量,以达到期望的d值。
TTestIndPower类对两个独立样本——治疗组和对照组——进行功效分析。请参见power_analysis.py,它为与cohen_d_test.py相同的效应大小集生成输出。将两者在受试者数量上的结果进行比较,见表 10-2。
表 10-2: 比较计算和估算的受试者数量
| d | 计算 | 估算 |
|---|---|---|
| 0.9 | 26 | 50 |
| 0.8 | 33 | 60 |
| 0.7 | 43 | 80 |
| 0.6 | 59 | 90 |
| 0.5 | 85 | 110 |
| 0.4 | 132 | 170 |
| 0.3 | 234 | 280 |
| 0.2 | 526 | 580 |
| 0.1 | 2,102 | 2,370 |
计算结果针对阈值 0.05(显著性水平α)和统计功效 0.9,这意味着我们希望有 90%的概率获得至少 0.05 作为治疗组和对照组之间 t 检验的 p 值。我选择 0.9 的功效值是因为经验代码试图使得最高 10% p 值的均值为 0.05 或更低,这意味着几乎所有的模拟实验都会发现一个 p 值为 0.05 或更低的结果。计算结果和估算的受试者数量大致一致且合理,这验证了模拟的有效性。
练习
本章只有两个练习,两个练习都具有挑战性。
处理两种治疗
将区块随机化应用于一个包含三种条件的研究:对照、治疗 1 和治疗 2。区块大小应为条件数的倍数;将区块大小固定为 6,意味着每个区块将每个条件使用两次。例如,[0,0,1,1,2,2]是一个有效的六人区块,其中 0 是对照,1 接受治疗 1,2 接受治疗 2。
我们需要一种方法从包含所有可能排列的集合中随机选择,这些排列由两次使用的三种条件组成。换句话说,我们希望得到一个 NumPy 数组,其中每一行是一个有效的区块。可以使用如下代码:
import numpy as np
from itertools import permutations
b = [0,0,1,1,2,2]
l = list(set(permutations(b)))
blocks = np.array(l)
blocks数组是一个 90×6 的矩阵,包含 90 个唯一的区块,当构建平衡队列时可以从中选择。permutations函数返回一个可迭代对象,set用于返回基本区块b的所有唯一排列。为了创建 NumPy 数组,在传递给array之前,将集合转换为列表。
你的任务是:修改design.py的副本,为对照组、治疗 1 组和治疗 2 组选择受试者进行简单和区块随机化。对于简单随机化,掷一个三面骰子。暂时忽略分层随机化。
结合区块随机化和分层随机化
为了将区块随机化和分层随机化结合起来,创建区块时,所有受试者根据所需的协变量匹配,并且每个区块以平衡的方式包含所有条件。如果有三个条件——如前一个练习中的情况——并且每个区块包含六个受试者,则每个区块中的受试者必须在协变量方面达到某种匹配程度。
修改design.py的副本,参考可能性 1,但考虑到四个协变量的 56 种可能组合。放入区块的受试者必须在协变量的值上完全一致。
例如,如果区块是[0,1,2,1,2,0],协变量集合是[2,1,0,1],则在年龄组 2、收入组 1、吸烟者 0、饮酒者 1 的群体中寻找六个受试者,并按照区块指定的条件分配给他们。继续对n个受试者进行操作,其中n是六的倍数,即区块大小。将此功能添加到你修改过的design.py文件中,参考练习 1。
总结
本章重点讨论了实验设计中的随机化,特别是在受试者选择过程中的随机化。我们在简要讨论了随机化的必要性后,探讨了最常见的几种类型:简单随机化、块随机化和分层随机化。
简单随机化使用抛硬币或掷骰子将受试者分配到治疗组或对照组。虽然这个过程可能由于组别大小不均而引入偏差,但块随机化通过确保治疗组和对照组的大小平衡,消除了这种可能性。
我们了解了协变量,它是指那些可能通过增强或掩盖任何可能的治疗效应,影响研究结果的因素。分层随机化使用协变量来构建具有匹配特征的治疗组和对照组。
接着,我们创建了一个模拟实验,测试不同随机化方案对实验结果的影响,而我们已知应观察到的治疗效应。我们发现,使用简单随机化的小规模队列容易产生偏差;而块随机化通过确保治疗组和对照组的大小平衡,消除了由于组别不平衡引起的偏差,分层随机化则纠正了治疗组和对照组中的偏差。这使得我们能够在受试者数量达到某个最小规模的前提下,衡量我们期望从模拟中获得的效应。
最后,我们将分层设置中所需的受试者数量与大多数模拟实验中期望效应大小达到 p < 0.05 水平所需的受试者数量进行了匹配,这个数量是通过基于 t 检验的效能分析得出的。实测的数量与模型结果相当一致,因此我们将此视为我们方法的验证。
让我们继续探索随机算法,这是计算机科学中一种重要的算法类别。
第十一章:计算机科学算法**

虽然我们到目前为止所研究的所有内容都可以称为“随机化算法”,但在计算机科学中,这个术语指的是两大类算法——本章的主题。
随机化算法在其操作过程中使用随机性。该算法通过快速产生正确答案,但有时不产生,或者通过快速运行并以某种概率返回错误或非最优结果来完成其目标。
我们将首先定义这两大类随机化算法并举例说明。接下来,我们将学习如何估算一个种群中的动物数量。之后,我们将学习如何证明一个数字是素数,且达到任何所需的可信度,同时避免通过穷举所有可能的除数来进行暴力搜索。最后,我们将介绍随机化快速排序,这是一种经典的随机化算法示例。
拉斯维加斯与蒙特卡洛
拉斯维加斯和蒙特卡洛是与赌博密切相关的地方,也就是依赖于随机性和概率的机会游戏。然而,当计算机科学家提到拉斯维加斯和蒙特卡洛时,他们通常是指两种主要类型的随机化算法。
拉斯维加斯算法总是能在随机的时间内返回正确的结果;也就是说,算法执行所需的时间不是确定性的,但输出是正确的。
另一方面,蒙特卡洛算法不能保证输出是正确的,但其运行时间是确定性的。输出不正确的概率是非零的,但对于一个实际的蒙特卡洛算法,这个概率是很小的。我们遇到的大多数算法,包括群体智能和进化算法,都是蒙特卡洛算法。通过允许算法在找到正确输出之前退出,拉斯维加斯算法可以转变为蒙特卡洛算法。
我们将研究的第一个例子是一个排序算法,它是一个 Las Vegas 或 Monte Carlo 算法,具体取决于我们的选择。第二个是一个用于验证矩阵乘法的 Monte Carlo 算法。
排列排序
排列是对一组项目的可能排列。如果集合中有 n 个项目,则有 n! = n(n - 1)(n - 2) …… 1 种可能的排列。例如,如果集合包含三个元素,假设 A = {1, 2, 3},那么就有六种可能的排列:
{1, 2, 3}, {1, 3, 2}, {2, 1, 3}, {2, 3, 1}, {3, 1, 2}, {3, 2, 1}
请注意,一种排列将项目从小到大排序。因此,如果给定一个无序的数字向量,我们可能会使用一种排序算法,生成排列直到找到能排序项目的那个。虽然我们可以实现确定性排序,但我们也可以使用随机排列,希望在测试过多候选排列之前能偶然找到正确的顺序。permutation sort算法(也叫bogosort或stupid sort)实现了这个想法。
运行文件permutation_sort.py时不带任何参数:
permutation_sort <items> <limit> [<kind> | <kind> <seed>]
<items> - number of items in the list
<limit> - number of passes maximum (0=Las Vegas else Monte Carlo)
<kind> - randomness source
<seed> - seed value
代码生成一个整数随机向量,范围在[0, 99]之间,并通过尝试随机排列最多达到limit来对其进行排序。为了对每个排列进行评分,代码返回一对对元素乱序的比例,其中a > b表示a位于索引i,b位于索引i + 1。如果数组已经排序,得分为零。
如果limit为零,算法将一直运行直到找到正确的排列,这取决于可能的排列数量。随着排列数的增加(n!),如果我们坚持尝试直到成功,运行时间会迅速增加。通过这种方式,limit为 0 会将permutation_sort.py变成一个拉斯维加斯算法。
例如,要将permutation_sort.py作为拉斯维加斯算法运行,使用:
> python3 permutation_sort.py 6 0 minstd 42
sorted: 0 25 44 57 65 96 (268 iterations)
代码在测试了 268 个可能的 6! = 720 个排列后找到了正确的元素顺序。将随机源从minstd更改为pcg64时,排序需要 59 次迭代,而mt19937则使用了 20 次。我们将限制设置为 0,使代码一直运行直到成功,但测试的排列数通常远小于最大值。
如果我们切换到蒙特卡罗算法:
> python3 permutation_sort.py 6 100 minstd 42
partially: 0 57 25 44 65 96 (score = 0.16667, 100 iterations)
我们得到了一个部分排序的数组,得分大于 0。由于固定的随机源和种子,我们知道需要 268 次迭代才能排序数组:
> python3 permutation_sort.py 6 268 minstd 42
sorted: 0 25 44 57 65 96 (268 iterations)
列表 11-1 显示了permutation_sort.py中的主循环。
v = np.array([int(rng.random()*100) for i in range(N)], dtype="uint8")
k = 0
score = Score(v)
while (score != 0) and (k < limit):
k += 1
i = np.argsort(rng.random(len(v)))
s = Score(v[i])
if (s < score):
score = s
v = v[i]
列表 11-1:permutation_sort.py 中的主循环
我们创建向量(v),并初始化score。主循环while一直运行,直到得分为零或limit超出。如果是拉斯维加斯算法,我们将limit设置为一个非常大的数字,以增加在尝试这么多次之前找到真实排序的概率。
while循环的主体创建了v的随机排序并计算得分。每当找到更低的得分时,代码会重新排序v,以便在达到限制时至少返回一个部分排序的向量;然而,这并不是严格必要的。
文件的其余部分显示结果或计算得分(列表 11-2)。
def Score(arg):
n = 0
for i in range(len(arg)-1):
if (arg[i] > arg[i+1]):
n += 1
return n / len(arg)
列表 11-2:对排列进行评分
让我们绘制平均排列次数与要排序项目数量的关系,permutation_sort_plot.py,它为[n]范围在[2, 10]之间的 10 次调用permutation_sort.py绘制了均值和标准误差。结果见图 11-1。

图 11-1:作为项目数量的函数,测试的排列平均数量(以百万计)
图 11-1 展示了组合爆炸,即问题的运行时间或资源使用量随着输入大小的增加而迅速增长的现象。当排序最多九个项目的列表时,排列排序工作还算不错;但如果超过九个,复杂度就会爆炸。
我们在permutation_sort_plot.py的输出中也看到了这种效果:
2: 0.127855 +/- 0.002026
3: 0.128128 +/- 0.001737
4: 0.129859 +/- 0.002469
5: 0.131369 +/- 0.002483
6: 0.136637 +/- 0.003704
7: 0.172775 +/- 0.008236
8: 0.534369 +/- 0.081601
9: 1.987567 +/- 0.488691
10: 44.133984 +/- 10.929158
输出显示了作为n的函数,排序该大小向量所需的平均时间(±标准误差)。七个元素后,运行时间迅速增加。
组合爆炸是许多原本有用的算法的诅咒,它通常会达到一个点,宇宙的多个生命周期都不足以找到正确的输出。
排列排序与阶乘密切相关,这就是我们得到这些结果的原因:
| 2! = 2 | 5! = 120 | 8! = 40,320 |
|---|---|---|
| 3! = 6 | 6! = 720 | 9! = 362,880 |
| 4! = 24 | 7! = 5,040 | 10! = 3,628,800 |
阶乘以惊人的速度增长。如果我们要对 20 个项目进行排序,我们需要
20! = 2,432,902,008,176,640,000
需要检查的排列数。如果每个排列需要 1 毫秒的时间,我们就需要超过 7700 万年的计算时间才能检查完所有排列。这并不意味着排列排序不能偶然在不到一秒的时间内排序 20 个项目,但这种概率极低。这就是随机算法的悖论。
矩阵乘法
我有三个矩阵,A、B和C。我们将用粗体大写字母表示矩阵。AB = C吗?我们怎么知道?
引入矩阵乘法
首先,让我们确保我们对矩阵乘法有相同的理解。矩阵是一个二维数字数组,因此这里的矩阵可能是:

这些是 2×2 矩阵,有两行和两列。如果行数等于列数,我们就正在处理方阵。然而,行数和列数不必相等;例如,我们可能有一个 3×5 或 1,000×13 的矩阵。后者在机器学习中很常见,行代表观察值,列代表与这些观察值相关的特征。n×1 矩阵是列向量,而 1×n 矩阵是行向量。
问题是,是否AB = C意味着我们知道如何找到AB。在 NumPy 中,为了乘以两个二维数组,我们将每个对应的元素相乘(清单 11-3)。
>>> A = np.array([[1,2],[3,4]])
>>> B = np.array([[1,0],[2,3]])
>>> A*B
array([[ 1, 0],
[ 6, 12]])
清单 11-3:在 NumPy 中按元素相乘
不幸的是,矩阵乘法更为复杂。我们首先要检查第一个矩阵的列数是否等于第二个矩阵的行数。如果不相等,则不能进行矩阵乘法。因此,要将一个n×m矩阵乘以一个u×v矩阵,需要m = u。如果这一条件成立,我们就可以进行乘法运算,得到一个n×v的结果。本节中的方阵自动满足这一要求。
矩阵乘法过程需要将第二个矩阵的每一列与第一个矩阵的行相乘,其中列的元素与行的对应元素相乘。然后我们将这些乘积求和以产生单一的输出值。例如,用符号表示,乘法两个 2×2 矩阵返回一个新的 2×2 矩阵:

我们从 0 开始索引矩阵,就像我们处理 NumPy 数组一样。然而,许多数学书籍是从 1 开始索引的,所以矩阵 A 第一行的第一个元素记作 a[11],而不是 a[00]。
数学上,如果 A 是一个 n × m 矩阵,B 是一个 m × p 矩阵,那么 C = AB 的元素是一个 n × p 矩阵:

记住矩阵乘法不满足交换律;一般来说,AB ≠ BA。在方程 11.1 中的求和展示了这一点:单一的索引按行访问 A,按列访问 B,因此交换 A 和 B 的顺序会导致矩阵中的不同元素被混合。
方程 11.1 中的求和使用了索引变量 k,并且对 i 和 j 的所有值进行了两次隐含的求和,以填充输出矩阵 C。这些观察结果指向一个实现:矩阵乘法变成了一个三重循环,索引 2D 数组的元素。
列表 11-4 将方程 11.1 中的循环转换为代码。
def mmult(A,B):
n,m = A.shape
p = B.shape[1]
C = np.zeros((n,p), dtype=A.dtype)
for i in range(n):
for j in range(p):
for k in range(m):
C[i,j] += A[i,k]*B[k,j]
return C
列表 11-4:朴素矩阵乘法
我们将使用这个实现,即使 NumPy 原生支持通过几种方式进行矩阵乘法,例如通过 @ 运算符。为了理解原因,我们将学习计算机科学家如何衡量算法的性能。
引入大 O 符号
计算机科学家通过将算法的性能与输入大小增加时类似的函数进行比较,来描述算法的资源使用情况,该函数能够捕捉算法的资源使用如何随着输入的增长而变化。这里的资源指的是内存或时间。例如,一个
的(n)算法会随着输入大小n的增加而线性增加所使用的内存。线性函数可以写作 y = mx + b,其中 x 是输入,但在大 O 符号中,我们忽略乘法和常数因素,所以 y = x 在 x 非常大时与 x 是功能上相同的。
清单 11-4 中的矩阵乘法代码包含一个三重嵌套的循环。如果输入矩阵是方阵(n×n),则I = J = K = n。每个循环执行 n 次,使得最内层的循环每次外层循环递增时执行 n 次,外层循环也必须执行 n 次来递增最外层循环。因此,乘法两个 n×n 矩阵所需的操作总数按 n³ 规模增长。创建输出矩阵 C 和计算函数前两行所需的时间不会改变该函数的本质特征——即它需要通过三个循环进行 n³ 次迭代。
因此,计算机科学家会写下 清单 11-4 是一个
(n³) 算法,并且是一个“n 立方”的实现。通常,我们希望算法的增长率为
(n) 或更优。随着 n 的增加,算法所需的工作量按线性增长,或者更好的是,按子线性增长,如
(log n) 或
(n log n)。换句话说,工作量与 n 的关系是一个直线图。理想情况下,我们希望
(1) 算法,它们在常数时间内运行,无论输入的大小如何,但这并非总是可能的。一个在
(n²) 时间内运行的算法通常是可以容忍的,但
(n³) 只适用于小的 n 值。
请注意,
(n)、
(n²) 和
(n³) 都是 n 的幂次。这类算法被称为 多项式时间 算法。我们永远不希望有在 超多项式时间 内运行的算法,这类算法的运行时间(或资源使用)无法通过任何多项式来追踪。例如,一个运行在
(2^n) 时间内的算法就是一个 指数时间 算法,它的资源使用随输入规模的增大而剧烈增长,增长速度远超任何多项式。更糟糕的是我们之前实验过的排列排序;它是一个
(n!) 算法,运行在 阶乘时间 内。为了理解阶乘时间比指数时间更糟糕,可以绘制一个图,比较 2^n 和 n! 在 n = [1, 8] 时的增长情况。
如 Listing 11-4 中的矩阵乘法是
(n³)。我们的目标是快速检查在给定三个矩阵的情况下,是否AB = C。我们首先将A和B相乘,然后检查结果的每个元素是否与C中对应的元素匹配。乘法是
(n³),检查的时间是
(n²),因为我们需要检查每个元素。由于立方体增长速度远快于平方,整体的朴素算法运行时间基本为
(n³)。让我们看看是否可以做得更好。
引入 Freivalds 算法
1977 年,拉脱维亚计算机科学家 Rūsiņš Freivalds 发明了一种随机算法,能够以高概率正确回答AB = C的问题,并且运行时间为
(n²)。
对于以下内容,我们假设A、B和C是n×n的矩阵。该算法也适用于非方阵,但此限制使得理解过程更为简单。
算法本身是直接的:
-
选择一个随机的n维向量,r = {0, 1}^n,即一个由 0 和 1 组成的随机向量。
-
计算D = A(Br) – Cr。(注意:括号很重要。)
-
如果D的所有元素都是零,则声明“是”,AB = C;否则,声明“否”。
初看起来,Freivalds 算法似乎不会有帮助。然而,回想一下矩阵乘法是如何工作的。表达式Br是将一个n×n的矩阵与一个n×1 的向量相乘,返回一个n×1 的向量。接下来的A乘法返回另一个n×1 的向量。同样,Cr也是一个n×1 的向量。此时并没有进行完整的n×n矩阵乘法。随着n的增大,计算节省的速度会更快。Freivalds 的算法运行在
(n²)时间内,比起朴素算法的
(n³)运行时间,这是一个相当大的改进。
将B乘以r相当于选择B的列的随机子集,并将它们的值在行中相加。例如:

该算法的假设是,检查三者矩阵的随机元素时,如果它们相等,结果将更频繁地使D成为全零向量,而不是D偶然变为全零。对所涉及概率的分析(我们不会在此讨论)表明,给定AB ≠ C的情况下,D为全零的概率小于或等于 1/2。
如果一个计算涉及随机选择的r返回错误答案的概率至多为 1/2,那么两次运行算法的随机向量(如果我们运行算法两次)返回错误答案的概率至多为(1/2)(1/2) = 1/4。这里的错误答案是输出“是”,但实际上AB ≠ C。
每次应用算法都是独立于任何先前应用的。对于独立事件,如公平硬币的抛掷,概率是相乘的,因此k次运行 Freivalds 算法意味着错误“是”结果的概率为 1/2^k或更小。这意味着通过多次运行算法,我们可以提高对结果的信心。
当AB = C时,算法总是返回“是”,这意味着它是单边的——只有在AB ≠ C时,输出才会出错。在双边的错误中,算法可能在任何情况下都出错,具有一定的概率。
测试 Freivalds 算法
让我们尝试使用freivalds.py算法,它生成 1,000 个随机的n×n矩阵三元组,n由命令行提供。在所有情况下,AB ≠ C,因此我们将失败的次数作为 1,000 次中的一个比例报告。
按如下方式运行freivalds.py:
freivalds <N> <mode> <reps> [<kind> | <kind> <seed>]
<N> - matrix size (always square)
<mode> - 0=Freivalds', 1=naive
<reps> - reps of Freivalds' (ignored for others)
<kind> - randomness source
<seed> - seed
第一个参数是矩阵的维度。第二个参数决定是否使用计算AB - C的朴素算法或 Freivalds 算法。第三个参数是重复测试的次数,使用随机的r向量。我们稍后会使用这个选项来跟踪错误率。像往常一样,其他参数启用任何随机源和一个种子,以重复相同的随机矩阵序列。
例如:
> python3 freivalds.py 3 0 1 mt19937 19937
0.08598161 0.132
告诉我们,使用 Freivalds 算法对 1,000 个 3×3 矩阵进行单次测试,每次测试大约需要 0.09 秒,并且在 1,000 次测试中失败了 13.2%。
要使用朴素算法,只需在命令行上将 0 改为 1:
> python3 freivalds.py 3 1 1 mt19937 19937
0.05456829 0.000
如预期的那样,没有失败,因为完整的计算总是能捕捉到当AB ≠ C时的情况。虽然朴素算法似乎比 Freivalds 算法运行得更快,但这只是一个错觉;随着n的增加,两者的差距会逐渐增大。
在检查 3×3 矩阵时失败 13%的时候并不是很令人振奋。让我们重新测试,但检查两次而不是一次:
> python3 freivalds.py 3 0 2 mt19937 19937
0.14664984 0.016
现在我们仅失败了 1.6%的测试,但几乎加倍了运行时间。让我们尝试进行四次测试而不是两次:
> python3 freivalds.py 3 0 4 mt19937 19937
0.26030445 0.000
经过四次测试,Freivalds 算法成功率为 1,000/1,000。
Freivalds 算法是概率性的。随着矩阵大小的增大,错误的可能性迅速减少。为了看到这个效果,在固定重复次数为 1 的情况下改变矩阵大小。当n = 11 时,错误率通常低于 0.1%。
错误率随着矩阵大小的增大而下降是有道理的。随机选择的数值之和恰好等于两个相等的值(A(Br) 和 Cr)的概率应随着求和的数值增加而减少。
让我们探讨一下运行时间与n的关系。运行freivalds_plots.py来生成图 11-2 中的图表。

图 11-2:将 Freivalds 算法的运行时间与朴素算法的运行时间对比,作为矩阵大小的函数(左),并单独绘制 Freivalds 算法的运行时间,以展示其
(n^(2))复杂度——请注意 y轴范围(右)
在 图 11-2 的左侧,我们可以看到随着矩阵大小的增加,Freivalds 算法与朴素算法之间运行时间的增长。朴素算法的复杂度为
(n³),比 Freivalds 算法的
(n²) 增长得要快得多,后者单独显示在右侧。
性能提升与错误发生概率随 n 增加而减少的结合,使得 Freivalds 算法特别有用。是的,它是概率性的,但在最需要它的地方(大 n),它也最有可能是正确的。
在查看 freivalds.py 之前,我得做一个自白。我们可以做得比
(n³) 更好,特别是对于 n > 100 的矩阵。Volker Strassen 的 1969 年矩阵乘法算法具有约为
(n^(log[2] 7)) ≈
(n^(2.807)) 的运行时间,略好于朴素算法。基于 BLAS 库的 NumPy 利用了 Strassen 算法,这也是为什么我们在这一节中没有使用 NumPy。然而,
(n²) 优于
(n^(2.807)),因此即使有 Strassen 矩阵乘法,Freivalds 算法仍然是有用的。
银河算法
还有比 Strassen 算法更具渐近行为的矩阵乘法算法。目前最好的算法具有复杂度
(n^(2.373)) 左右。然而,这些算法在实践中完全无用。这个看似矛盾的现象与大 O 表示法有关,大 O 表示法展示的是总体行为,但忽略了乘法因子和常数。这意味着,运行时间为 10n³ 的算法与运行时间为 10,000n³ + 10,000 的算法是一样的。两者的规模都是
(n³),但在实践中,第一个更可能是有用的。
比 Strassen 算法在整体复杂度上更优的矩阵乘法算法,比如 Coppersmith–Winograd 算法,其常数非常大,只有当 n 大到远超过当前计算机能够处理的范围时(如果有可能的话),该算法才有实际意义。
这些算法被 Kenneth W. Regan 称为 银河算法。即使这些算法在渐近行为上是“最优的”,我们在实践中也无法有效使用它们。尽管这些算法在理论上很重要,但它们不会很快出现在我们的工具箱中。
代码解析
列表 11-5 包含了实现 Freivalds 算法的代码。mmult 函数在 列表 11-4 中。array_equal 函数会判断 A(Br) 与 Cr 之间差值的绝对最大值是否低于 eps,如果是,返回 True。
def array_equal(a,b, eps=1e-7):
return np.abs(a-b).max() <= eps
k = 0
m = 1000
s = time.time()
for i in range(m):
A = 100*rng.random(N*N).reshape((N,N))
B = 100*rng.random(N*N).reshape((N,N))
C = A@B + 0.1*rng.random(N*N).reshape((N,N))
if (mode == 0):
t = True
for j in range(reps):
r = (2*rng.random(N)).astype("uint8").reshape((N,1))
t &= array_equal(mmult(A,mmult(B,r)), mmult(C,r))
else:
t = array_equal(mmult(A,B), C)
k += 1 if t else 0
print("%0.8f %0.3f" % (time.time()-s, k/m))
列表 11-5:Freivalds 算法
外部for循环执行 1000 次试验,每次使用随机选择的矩阵集。C是这样设置的,它永远不等于AB,因此每次调用array_equal都应返回False。
外部for循环的主体要么直接相乘A和B(mode==1),要么通过生成一个随机二进制向量r来使用 Freivalds 算法。注意,r被重新形状为n×1 的列向量,这是矩阵乘法所要求的。
内部的for循环每次应用 Freivalds 算法重复(reps),并将结果与t进行与运算。与运算意味着,在进行reps次测试,每次使用不同的r向量后,只有当所有测试都给出错误结果时,t才会保持为真。每次测试都应该看到array_equal返回False,因为按设计AB ≠ C。一旦t变为False,它将在剩余的所有测试中保持False,因此即使array_equal返回正确结果,也会导致t具有预期的值。
如果在内部循环后t仍然为True,则说明试验失败,我们会增加k的值。所有试验完成后,我们将打印总运行时间以及 1000 次试验中失败的比例。
Freivalds 算法是一种蒙特卡洛算法,因为它可能会产生一个错误输出并声称AB = C,尽管实际情况并非如此,而且这种错误输出的概率是可以最小化的。
接下来,我们将转向另一类问题:要估算一个集合中物品的数量,是否有必要逐个计数?
动物计数
生态学家通常希望知道某个特定物种在某一地区的数量,尽管逐一统计每只动物通常是不可能的。这时就需要使用标记和再捕方法,这是一种通过小样本来估算种群数量的策略。
在标记和再捕中,生态学家首先进入现场捕捉n只样本,然后进行标记并释放。过一段时间后,他们重新进入现场,再次捕捉动物,直到捕获到至少一只已标记的动物。如果他们捕获了K只动物,其中有k只被标记,那么他们现在就拥有了估算总体数量N所需的所有信息。他们通过使用比率来完成这个过程。
最初,生态学家标记了N动物中的n只,这意味着标记的动物占总体种群的比例为n/N。再捕阶段捕获了K只动物,其中有k只被标记。假设没有出生、死亡或迁徙,这两个比例应该大致相等,因此通过解这个方程得到N:

这个方程得出了Lincoln-Petersen 种群估算,因此是N[L]。
一种稍微少偏的种群估算(或如此声称)来自Chapman 种群估算:

最后,我们采用贝叶斯方法来进行标记和再捕:

这种方法要求再捕组中至少有三只标记的动物,以避免除以零的情况。
让我们通过mark_recapture.py比较这三种对相同种群大小的不同估算方法:
> python3 mark_recapture.py
mark_recapture <pop> <mark> <reps> [<kind> | <kind> <seed>]
<pop> - population size (e.g. 1000)
<mark> - number to mark (< pop)
<reps> - number of repetitions
<kind> - randomness source
<seed> - seed
代码通过随机标记指定数量的动物,然后重捕部分种群计算已标记的数量,来模拟标记和重捕的过程。我们先运行几次代码,熟悉输出。我们将真实种群大小固定为 1,000,并最初标记 100 个动物,即 10%。将重复次数设置为 1 时进行一次采样,这与生态学家在实际中可能做的相似。我们得到:
> python3 mark_recapture.py 1000 100 1 mt19937 11
Lincoln-Petersen population estimate = 1250
Chapman population estimate = 1132
Bayesian population estimate = 1633
> python3 mark_recapture.py 1000 100 1 mt19937 12
Lincoln-Petersen population estimate = 666
Chapman population estimate = 636
Bayesian population estimate = 753
> python3 mark_recapture.py 1000 100 1 mt19937 13
Lincoln-Petersen population estimate = 833
Chapman population estimate = 783
Bayesian population estimate = 980
估算值在每次运行中差异较大,这是我们从随机化算法中可以预期的结果。虽然林肯-彼得森和查普曼估算值通常较低,但贝叶斯估算值更接近真实种群大小,甚至超过了种群大小。
使用单次重复相当于试图从单个收集的数据点进行概括,因此我们增加重复次数:
> python3 mark_recapture.py 1000 100 25 mt19937 11
Lincoln-Petersen population estimate = 1028.4713 +/- 78.4623
Chapman population estimate = 940.0367 +/- 61.6982
Bayesian population estimate = 1345.2015 +/- 166.3078
> python3 mark_recapture.py 1000 100 25 mt19937 12
Lincoln-Petersen population estimate = 1052.0985 +/- 61.3198
Chapman population estimate = 963.4317 +/- 49.9620
Bayesian population estimate = 1345.9986 +/- 108.4192
> python3 mark_recapture.py 1000 100 25 mt19937 13
Lincoln-Petersen population estimate = 1112.8340 +/- 80.5759
Chapman population estimate = 1009.5146 +/- 63.3742
Bayesian population estimate = 1485.2546 +/- 169.0492
输出现在反映了 25 次重复的平均值和标准误差,提供了更好的估算行为概览。林肯-彼得森和查普曼的结果接近真实种群大小,而贝叶斯估算值则持续偏高。标准误差也具有说明性,贝叶斯的标准误差比其他两个估算器要大,表明试次之间的变化较大。
尝试不同的种群大小和最初标记的动物数量组合进行实验。
图 11-3 展示了三张有些拥挤的图表。

图 11-3:三种标记重捕估算器作为真实种群大小和初始标记比例的函数。图中显示的是与真实种群大小的偏差:林肯-彼得森(左上)、查普曼(右上)和贝叶斯(底部)。
在图 11-3 的左上方图表中,每条绘制的七条线代表不同的真实种群大小,范围从 100 到 10,000。x轴表示生态学家第一次去野外时标记的真实种群比例。所绘制的值是林肯-彼得森估算值与该组合下的真实种群大小之间的中位数偏差。如果曲线高于零,说明估算值偏低;低于零,则偏高。换句话说,图表显示的是N[真实] – N[估算],因此低估为正差,过度估算为负差。其余两张图展示了查普曼(右上)和贝叶斯(底部)估算器的相同信息。
对于种群超过 1,000 的情况,林肯-彼得森估算器通常在初始标记超过 10%的种群时有效,但在实践中可能难以实现。然而,对于小型种群,该估算器需要标记约 20%的种群才能达到可靠性。可以使用模拟来基于怀疑的种群大小和最初标记的动物数量,生成林肯-彼得森估算器的修正函数。
查普曼估计器通常低估真实的种群数量,以至于人们开始质疑它与林肯-彼得森估计相比的实用性。然而,这种低估对于大于 1000 的种群来说是相对一致的,因此,同样可以通过模拟得出一个修正系数。
贝叶斯估计器的表现则截然不同。它总是高估实际的种群数量,只有当种群变大并且最初标记的百分比也较高(至少 15%)时,才会趋近于真实的种群值。在实际操作中,这些条件很难满足。
图 11-3 是mark_recapture_range.py的输出,可以通过查看清单 11-6 中mark_recapture.py的相关部分来理解。
lincoln = []
chapman = []
bayes = []
for j in range(nreps):
pop = np.zeros(npop, dtype="uint8")
idx = np.argsort(rng.random(npop))[:nmark]
pop[idx] = 1
K = nmark
while (True):
idx = np.argsort(rng.random(npop))[:K]
k = pop[idx].sum()
if (k > 2):
break
K += 5
lincoln.append(nmark*K/k)
chapman.append((nmark+1)*(K+1)/(k+1) - 1)
bayes.append((nmark-1)*(K-1)/(k-2))
清单 11-6:模拟标记和回捕估计
外部的for循环遍历nreps处理试验。对于每个试验,我们创建一个种群(pop)向量,其中npop是从命令行输入的种群大小。该向量最初为零,因为我们还没有标记任何动物。
接下来的两行表示生态学家的第一次实地考察。argsort技巧结合只保留排序顺序中的前nmark个元素,将idx设置为生态学家最初捕获并标记的pop索引(pop[idx]=1)。
第二段代码表示回捕阶段,在该阶段,生态学家返回现场并捕获与最初标记的动物数量相同的动物(K)。我们通过在内部while循环中分配的idx中的K索引来表示被捕获的动物。
标记是二进制的,因此pop中所选元素的总和即为已标记动物的数量k。如果k大于或等于 3,则跳出while循环。否则,将K增加 5 并再次尝试。最后一段代码计算该试验中真实种群数量的三个估计值。
当外部for循环退出时,我们得到了三个关于给定种群大小和最初标记数量的估计向量。mark_recapture.py的其余部分显示了结果。根据模拟结果,我的偏好是林肯-彼得森估计器。
让我们从计数转向数学上重要的任务——素性测试。
素性测试
素数——只能被自己和 1 整除的整数——深受数论学者的喜爱。自古以来,素数就吸引着人类的目光,目前大量计算能力被用于寻找梅森素数,其形式为 2^p – 1,其中p是素数。
已知最大的素数是梅森素数。截止到目前,已知的最大梅森素数是 2018 年发现的:
M[82,589,933] = 2^(82,589,933) − 1
M[82,589,933]是一个 24,862,048 位的数字。梅森素数有时用其编号而非指数表示。因此,M[82,589,933],即第 51 个梅森素数,可能被表示为M[51]。
注意
要为寻找梅森质数贡献力量,访问 www.mersenne.org 并注册加入“大型互联网梅森质数搜索”计划。
我们如何知道 n 是质数?定义为我们提供了一个自然的起点用于质数测试算法:如果唯一能整除 n(结果没有余数)的数字是 1 和 n,那么 n 就是质数。
让我们将这个定义转化为一个算法。蛮力法是测试每个可能是 n 因子的数字。实际上,这意味着要测试每个整数直到
,因为任何大于
的 n 因子必定会与某个小于
的数字相乘,并在到达
之前就被发现。
当考虑到包含接近 2500 万个数字的数字时,所涉及的工作量会急剧增加。如果 n 是质数,我们是否需要测试每一个整数直到
?
米勒-拉宾测试 是一种快速的随机算法,用于判断一个正整数 n 是否为质数。然而,要理解米勒-拉宾测试,我们需要了解一些关于模运算的知识。
模运算
我们习惯于整数集合,表示为 ℤ,来自德语“Zahl”(数字)。整数是无限的,从零向两边无限延伸。如果我们将范围限制为集合 {0, 1, 2, 3},我们可以通过需要时进行“回绕”来定义该集合上的算术运算。当和小于 4 时,加法按预期进行:1 + 1 = 2 和 2 + 1 = 3。然而,如果和超过 4,我们就会回绕。例如,2 + 3 = 1,因为 2 + 3 = 5,我们从 5 中减去 4 得到 1。另一种看待这些运算的方式是,在每次加法后应用模运算符,返回除以 4 后的余数。例如,5 mod 4 = 1。
皮埃尔·费尔马,17 世纪的法国数学家,意识到如果 n 是质数,那么
a^(n – 1) ≡ 1 (mod n), 0 < a < n
其中 ≡ 表示:
a^(n – 1) mod n = 1 mod n = 1
太好了!我们有了一个质数测试:选择一个整数 0 < a < n,将其升到 n – 1 次方,再除以 n,看看余数是否为 1。如果是,那么 n 就是质数,所以算法有效并且能够识别 n 为质数。然而,一些合成数也会通过这个测试,对于许多 a 值,这意味着单靠这个测试不足以证明 n 是质数。如果测试失败,则 n 绝对不是质数。
米勒-拉宾测试结合了费尔马的测试和另一个事实——如果 n 是质数,那么以下公式也可能成立

对于某个 r 在 [0, s) 范围内,其中 n = 2^sd + 1 且 d 是奇数。这是可能成立的,因为有时即使 n 是合成数,也会存在满足该同余关系的 a 值。我们稍后将讨论这些非见证数。
第一个条件,费马测试,足够简单,但让我们来拆解第二个条件。我们需要将 n 表示为 2^sd + 1,或者等价地,表示为 n–1 = 2^sd。对于适当选择的 s 和 d,2^sd 是费马条件中指数的另一种表示方式。
≡ –1 (mod n) 中的所有数学运算都是模 n 运算,这意味着数字位于集合 {0, 1, 2, . . . , n – 1} 中,通常表示为 ℤ[n]。我们将负数视为倒数计数,因此 –1 ≡ n – 1。
第二个条件检查是否存在某个 x 使得 x² ≡ –1 (mod n)。Miller-Rabin 测试使用一系列这样的 x 值,寻找一个当模 n 平方时结果为 –1(即 n – 1)。序列从 r = 0 和 d 作为指数开始。下一个检查使用平方,实际上是 r = 1:

这一切都是模 n 运算。接下来的平方操作返回 r = 2,依此类推。
如果这种表达式序列中的任何一个等于 n – 1,那么 n 具有相当高的概率是一个素数。否则,n 绝对不是素数,而 a 是这一事实的 见证。
Miller-Rabin 测试
让我们把 Miller-Rabin 实现成代码,如 列表 11-7 所示。
def MillerRabin(n, rounds=5):
if (n==2):
return True
if (n%2 == 0):
return False
s = 0
d = n-1
while (d%2 == 0):
s += 1
d //= 2
for k in range(rounds):
a = int(rng.random()) # [1,n-1]
x = pow(a,d,n)
if (x==1) or (x == n-1):
continue
b = False
for j in range(s-1):
x = pow(x,2,n)
if (x == n-1):
b = True
break
if (b):
continue
return False
return True
列表 11-7:Miller-Rabin 代码实现
函数 MillerRabin 接受 n 和 rounds,默认值为 5。第一段代码捕获了琐碎的情况。由于一半的数字是偶数,直接测试 2 和偶性可以节省时间。
第二段代码定位 s 和 d 使得 n = 2^sd + 1。对于任何 n(正整数),总是可以找到一个 s 和 d 的分解。
目前,我们将重点关注第三段中的外层 for 循环部分,该部分实现了对随机选择的 a 和初始 x 值 a^d (mod n) 的 Miller-Rabin 测试。
内置的 Python 函数 pow 计算指数,并接受一个可选的第三个参数,因此 pow(a,d,n) 可以高效地实现 a^d (mod n)。
以下的 if 检查是否为 1 或 –1。如果是这种情况,则费马测试通过,因此这一轮外层 for 循环结束。
否则,内层 for 循环开始执行 x = a^d 的连续平方序列,同时寻找一个等于 –1 的结果。如果找到这样的平方,内层循环将中断,外层循环继续;否则,n 是合数,MillerRabin 返回 False。
当所有轮次(k 的循环)完成,并且每个测试都支持 n 是素数的假设时,MillerRabin 返回 True。
外部的for循环应用了米勒-拉宾测试,重复地为随机选择的a值进行测试。由于一个a值能够证明n是复合数的,它是一个见证数,而一个a值导致在n不是质数时错误地宣称它是质数的则是一个非见证数。对于给定的n,不可能所有的a值都是非见证数,因此多次应用外部循环主体可以最小化非质数输入返回True的概率。
你可以在文件miller_rabin.py中找到MillerRabin。它需要一个待测试的数字、测试轮数(即要尝试的a值数量)以及随机数源:
> python3 miller_rabin.py
miller_rabin <n> <rounds> [<kind> | <kind> <seed>]
<n> - number to test
<rounds> - number of rounds
<kind> - randomness source
<seed> - seed
例如:
> python3 miller_rabin.py 73939133 1
73939133 is probably prime
> python3 miller_rabin.py 73939134 1
73939134 is composite
对于这些情况,输出必须是正确的,因为 73,939,133 是质数,并且最接近的两个质数之间相差 2:
> python3 miller_rabin.py 8675309 1
8675309 is probably prime
> python3 miller_rabin.py 8675311 1
8675311 is probably prime
8,675,309 和 8,675,311 是孪生质数,因此测试是正确的。
米勒-拉宾始终将质数标记为质数。让我们探讨米勒-拉宾何时会失败。
非见证数
如前所述,见证数a证明n不是质数。此外,对于某些复合数,如果米勒-拉宾测试选择了一个非见证数作为a,测试将失败。
我们将通过使用一个已知的非见证数集来强制米勒-拉宾算法失败,以查看是否能够检测到预期的失败次数。
我们的目标是n = 65。作为 5 的倍数,65 是复合数。共有 64 个潜在的见证数,从 1 到 64。这些潜在见证数中,已知 8、18、47、57 和 64 是非见证数。如果米勒-拉宾测试进行一次并选择了一个非见证数作为a,它将失败并错误地宣称 65 是质数。
由于在 64 个可能的数字中有五个非见证数,因此米勒-拉宾测试在单轮情况下应该大约有 5/64 = 7.8%的时间失败。我通过运行miller_rabin.py 1,000 次,并统计输出显示为质数的次数,发现恰好有 78 次,意味着失败率为 78/1,000 = 7.8%。
最坏情况下,米勒-拉宾单轮失败的概率对于任意的n为 1/4。由于每一轮与前一轮是独立的,运行k轮测试意味着最坏的失败概率为(1/4)^k = 4^(-k)。然而,对于大多数n值,实际的失败概率远低于这个值。
让我们继续使用 65。知道它的单轮失败率大约为 7.8%,运行两轮应该大约有(5/64)² ≈ 0.61%的时间失败。运行miller_rabin.py 20,000 次,得到了 131 次失败,失败率为 131/20,000 = 0.655%。三轮测试的失败率大约为 0.05%。我们可以通过将k设置得足够高来达到任何期望的精度。
米勒-拉宾性能
让我们将 Miller-Rabin 的运行时表现与在brute_primes.py中实现的暴力方法进行比较。prime_tests.py中的代码同时运行 Miller-Rabin 和暴力算法,测试最大 1 位、2 位、3 位……到 15 位的质数。回想一下,当输入是质数时,暴力算法的运行时间最长。
最大的单数字质数是 7,而最大的 15 位质数是 999,999,999,999,989。在图 11-4 中,我们绘制了对每个质数进行五次运行的miller_rabin.py和brute_primes.py的平均值,以展示随着输入增大,运行时间的变化。

图 11-4:比较 Miller-Rabin 与暴力质数测试
暴力算法的运行时复杂度是
,而 Miller-Rabin 的运行时复杂度是
(log³ n)。尽管暴力算法是次线性的,但它很快变得不可管理,因为
且 1/2 < 1。
Miller-Rabin 是一个蒙特卡罗算法,因为它声称n是质数,即使在n不是质数时仍有非零的概率。如果n确实是质数,Miller-Rabin 总是正确地标记它,但它也会错误地将一些合成数标记为质数,无论循环次数多少。因此,Miller-Rabin 的假阳性率是非零的,但其假阴性率为零。然而,在实际应用中,增加轮次可以使假阳性率降低到任何期望的水平。
我们还有一个随机化算法需要考虑。
使用快速排序
快速排序由英国计算机科学家 Tony Hoare 于 1959 年开发,可能仍然是最广泛使用的排序算法。例如,它是 NumPy 的默认排序算法。
如果你参加本科的算法课程,你几乎肯定会接触到快速排序,因为它实现简单且容易理解,即使它是递归的。虽然大多数课程都会专注于表征其运行时复杂度,我们将以更高的层次讨论该算法,并对两种版本进行实验:标准的非随机版本和随机版本。
快速排序是一种递归的分治算法,这意味着它会在更小的子问题上反复调用自身,直到遇到基本条件为止,此时实现会将所有部分重新组合,输出排序结果。
算法如下:
-
如果输入数组为空或仅包含一个元素,直接返回它。
-
选择一个基准元素,可以是数组中的第一个元素或随机选取。
-
将数组分成三个子集:小于基准元素的、等于基准元素的以及大于基准元素的。
-
返回快速排序对较小元素、与基准相等的元素以及较大元素进行排序后的拼接结果。
第 1 步是基本条件。如果数组为空或只包含一个元素,那么它就是已排序的。第 2 步选择一个枢轴值,这是我们在第 3 步中用来将数组划分为三部分的元素:小于、等于和大于枢轴的部分。第 2 步是随机性发挥作用的地方。非随机快速排序总是选择数组中的特定元素,因为它已假设数组是随机排序的。然而,随机快速排序会随机选择枢轴元素。我们将实验非随机快速排序和随机快速排序之间的微妙差异。
第 4 步是递归部分。如果我们将已排序的低区间与相同的区间合并,再加上已排序的高区间,数组就会被排序。我们通过使用排序例程来排序低区间和高区间,也就是再次调用快速排序(Quicksort)。我们假设每次调用数组的一部分时,处理的元素数量会逐渐变少,直到最终每个部分只有一个元素,这是第 1 步的基本条件。
天真的排序方法,如冒泡排序或侏儒排序,运行时间是
(n²),其中n是要排序的元素个数。正如我们所了解的那样,
(n²) 算法适用于小n值,但随着n的增加,性能会迅速变得无法管理。快速排序的平均运行时间复杂度是
(n log n),增长速度较慢。因此,尽管快速排序是在 50 多年前引入的,但至今仍被广泛使用。
虽然快速排序的平均时间复杂度是
(n log n),但如果传递给快速排序的数组已经大部分或完全排序,那么复杂度会变成
(n²),这和冒泡排序一样,效果并不好。这种情况发生在数组已经按顺序或逆顺序排列时。让我们来看看随机快速排序是否能帮我们解决这个问题。
在 Python 中运行快速排序
文件Quicksort.py实现了两次快速排序。第一次实现使用一个随机枢轴(QuicksortRandom),第二次实现总是使用数组的第一个元素作为枢轴(Quicksort)。这些函数位于清单 11-8 中。
def QuicksortRandom(arr):
if (len(arr) < 2):
return arr
pivot = arr[np.random.randint(0, len(arr))]
low = arr[np.where(arr < pivot)]
same = arr[np.where(arr == pivot)]
high = arr[np.where(arr > pivot)]
return np.hstack((QuicksortRandom(low), same, QuicksortRandom(high)))
def Quicksort (arr):
if (len(arr) < 2):
return arr
pivot = arr[0]
low = arr[np.where(arr < pivot)]
same = arr[np.where(arr == pivot)]
high = arr[np.where(arr > pivot)]
return np.hstack((Quicksort(low), same, Quicksort(high)))
清单 11-8:随机和非随机快速排序
在这个例子中,我们使用 NumPy 而不是我们的RE类,因为 NumPy 已经加载,这样可以最小化调用QuicksortRandom时的开销。两者的实现唯一的区别在于如何分配pivot。
两种实现都遵循快速排序算法的每一步。首先,我们检查基本条件,即arr已经排序。然后,根据选定的pivot将数组分为low、same和high。最后,NumPy 的hstack函数将递归调用Quicksort返回的向量进行拼接。
一个高性能的实现不会调用where三次,因为每次都会对arr进行一次完整的遍历,但我们这里只关心随着输入大小变化,性能之间的相对差异。
实验快速排序
文件quicksort_tests.py生成了两张图。第一张,位于图 11-5 的左侧,比较了随机化快速排序和非随机快速排序随着输入数组大小增加的表现。在所有情况下,输入数组都是随机顺序的。因此,图 11-5 的左侧代表了平均情况运行时。绘制的点是五次运行的均值。虚线表示y = n log n。

图 11-5:随机化和非随机快速排序在随机输入下(左)以及相同算法在病态输入下(右)的表现
图 11-5 中最右侧的图展示了已经排好序的输入情况下的运行时间。这个情况迫使确定性快速排序变成了一个
(n²)算法,这就是它跟随曲线y = n²的原因。另一方面,随机化快速排序不受输入顺序的影响,仍然按之前的方式运行。
正确解读图 11-5 需要一些解释。算法的渐进运行时性能忽略了乘法因子和常数,因为它们不会改变随着n增大时函数的整体形式。随机化快速排序的运行时间比非随机快速排序稍长,因为它需要额外的一步:选择数组中的一个随机索引。因此,将两者的运行时间一起绘制会使得我们不容易看到QuicksortRandom和Quicksort之间的总体函数形式是相同的。此外,绘制y = n log n时,y轴的数值尺度完全不同,但函数的形式仍然相同。因此,为了将三者一起绘制,图 11-5 将每个y值除以最大y值,将输出映射到[0, 1],无论实际范围如何。这清晰地表明,随机化快速排序和非随机快速排序的规模是一样的,且都遵循
(n log n)——所有曲线基本重合。
现在重新考虑图 11-5 右侧的情况,即输入数组已经排好序。再次地,虚线表示y = n log n,随机化快速排序仍然遵循这一形式。然而,非随机快速排序,它将数组的第一个元素作为基准元素,却并不遵循这一形式。相反,它遵循虚线y = n²,这意味着这种病态输入情况改变了非随机快速排序,使其变成了一个
(n²)算法。
随机化快速排序是一种拉斯维加斯算法,因为它总是返回正确的输出——一个已排序的数组。尽管涉及的随机性不会使实现变得更容易,但它能够避免一个在实践中比我们最初预期的更常见的病态情况。因此,我推荐总是使用随机化快速排序。
要理解为什么非随机快速排序在已排序的输入上表现如此糟糕,可以考虑当枢轴是数组中最小的值时会发生什么;例如,当选取第一个元素作为枢轴并且输入数组已经排序时会发生什么。发生这种情况时,低向量为空,并且忽略枢轴的重复值,数组中剩余的所有值都进入高向量。每次函数递归时都会发生这种情况,将递归转化为n层深的函数调用列表。将每次递归调用中通过数组的
(n)次(通过where隐式实现),我们得到了一个
(n²)的算法,这与冒泡排序一样差。
在每一层选择一个随机的枢轴,可以确保长期来看这种情况不会发生,因为这就相当于进行n次* n 面公正骰子掷骰,每次都掷出 1—这随着n*的增加会变得越来越不可能。
练习
考虑以下练习,进一步探索随机化算法:
-
编写一个拉斯维加斯算法,定位满足a² + b² = c²的正整数a、b和c。你的代码将是一个拉斯维加斯算法,因为有无数的解,即所有的毕达哥拉斯三元组。
-
你能编写一个成功的拉斯维加斯程序,找到正整数a、b和c,使得存在某个n > 2,满足a^n + b^n = c^n吗?如果不能,那么一个蒙特卡洛算法如何?你的停止标准可能是什么?我建议搜索“费马最后定理”。
-
扩展排列排序的运行时间图,考虑n = 11、12 甚至 13 的情况。你需要等多久?
-
绘制 Freivalds 算法失败案例的平均试验次数图,作为* n *的函数,表示方阵的大小。
-
文件test_mmult.py生成适合从第四章的curves.py的输出。使用该输出和curves.py生成拟合。拟合的指数是你对朴素算法的预期吗?那么对于使用 Strassen 算法的 NumPy 呢?
-
我有一个装满弹珠的袋子。我想估算袋子里有多少个弹珠。因此,我随机挑选一个,做标记后放回袋子中。然后我再随机挑选一个弹珠,做标记后放回袋子中。我重复这个过程,计算所选弹珠的数量,直到我挑选到一个已经标记过的弹珠。如果所挑选并标记的弹珠数量是k,那么袋子里的弹珠数量大致为!Image
其中,左边的下取整符号(⌊)和右边的上取整符号(⌉)表示“舍入到最接近的整数”。我通过一个简短的描述了解了这个算法,但该描述没有推导公式,也没有引用文献。尽管如此,它还是起作用的。在进行了一些实验后,我发现如果稍微调整公式,它的估算会更好,调整后的公式是:
![Image]()
实现这个算法并探索它的平均效果。然后查看count.py,该程序运行算法进行多次迭代,计算结果的平均值并生成图表。例如
> python3 count.py 1_000_000_000 40 pcg64 6502 N = 1023827699, iterations 41414, total 1656576估算袋中略多于 10 亿颗弹珠。正确的数量恰好是 10 亿。它使用了 40 次算法迭代,总共标记了 170 万个弹珠。图 11-6 是结果图,count_plot.png,显示了 40 次估算的结果、真实值(实线)和总体估算(虚线)。
![Image]()
图 11-6:40 次弹珠估算
如果你知道这个算法的参考文献或如何推导估算公式,请与我联系。
-
你能想到一种“修正因子”来调整林肯-彼得森人口估算方法吗,尤其是在认为总体较小时的情况?
-
非随机快速排序的运行时性能如何随数组的无序程度增加而变化?为了弄清楚这一点,固定数组大小(n),但改变数组的无序程度。例如,先从一个已排序的数组开始,然后交换两个元素,再交换三个,依此类推。从
(n²)到
(n log n)的过渡,随着交换元素的数量增加,是否呈现线性变化?还是说它看起来更为快速?
总结
在这一章中,我们探讨了随机化算法,区分了拉斯维加斯算法和蒙特卡洛算法。前者最终总是能产生正确的输出,而后者可能会产生不正确的输出。我们考虑了排列排序和弗雷瓦尔兹算法来测试矩阵乘法。我们了解到,通过对考虑的候选排列数量施加限制,我们可以将排列排序从拉斯维加斯算法转变为蒙特卡洛算法。一般来说,我们可以将拉斯维加斯算法转化为蒙特卡洛算法,但反之则不可。
接着,我们讨论了生态学家用来估计动物种群的标记和重捕算法。我们通过标记已知数量的动物,然后重捕动物并观察标记的数量来估计种群中动物的数量。通过足够的数量,标记动物与重捕动物的比例应该与最初标记的动物与种群总数的比例相匹配。我们探索了与这一过程相关的三种估算器,并观察了它们的表现。
米勒-拉宾算法能够快速判断一个正整数是否是质数。然而,作为一种随机化算法,它有一定的概率错误地将合数判定为质数。我们学习了通过反复应用该算法来减少假阳性的可能性。
我们通过比较非随机和随机化快速排序的实现来结束这一章。随机化快速排序对运行时间几乎没有影响,同时能够防止已经(或大部分)排序的病态输入。
在我们的最后一章,我们将考虑随机性与从概率分布中抽样的关系。
第十二章:抽样

我们的大多数实验都通过从均匀分布中提取样本来进行。虽然我们也使用过正态分布(第一章)、贝塔分布(第三章)和二项分布(第九章),但经得起时间考验的均匀分布仍是我们最古老且最亲密的朋友。
在本章中,我们将从任意概率分布中抽样,无论是离散的还是连续的。这一能力对模拟至关重要,也是贝叶斯推断的基础。
首先,我们将讨论术语并解析术语贝叶斯推断。接下来,我们将深入探讨从任意离散概率分布中抽样,首先是一维的,然后是二维的。
在处理完离散分布后,我们将继续通过逆变换抽样、拒绝抽样以及使用 Metropolis-Hastings 算法的马尔可夫链蒙特卡罗抽样来从连续分布中抽样。
这可能是我们最数学化的一章,但不要因此感到困惑。如果你想继续探索这些算法或将它们应用于不同的情况,代码才是最重要的。
抽样简介
在深入讨论抽样之前,让我们先达成一致,了解一些术语及其所包含的概念。我们还将介绍与贝叶斯统计和推断相关的概念,后者是抽样算法发展的主要推动力和受益者。
术语
如果我们掷一个标准的骰子,结果可能是六个输出中的一个,每个结果出现的概率相等。我们将这种分布表示为一个条形图,图中的条形标记为 1 至 6,每个条形的高度相等,对应于 1/6 的分数。每个条形所代表的分数的总和为 1,因为该图是离散概率分布。
概率质量函数 (PMF) 告诉我们任何离散结果的概率。对于标准骰子,PMF 是每个结果的 1/6。对于二项分布,PMF 取决于试验次数(n)和每次试验中事件发生的概率(p),公式如下:
。这里的 k,k = 0, . . . , n,是* n *次试验中事件发生的次数。可以将连续情况看作是将离散情况扩展到更多的可能结果;例如,一个条形图,其中条形逐渐变窄,直到它们的宽度变得无穷小。谈论无穷小通常意味着微积分,正如这里所示。离散分布转变为连续分布,从而:

这里,f(x)是概率密度函数 (PDF),它是概率质量函数的连续类比。符号 P(X = i) 表示随机变量 X 取值 i 的概率。
∫符号只是一个老式的“S”字母,代表“求和”。求和的对象是由宽度为dx(一个实体,而不是d与x的乘积)和高度为f(x)的矩形所形成的无限多个面积,即在x处的 PDF 函数值。如果给定了限制条件,
,求和就是对从x = a到x = b的x进行求和。如果没有给定限制条件,意味着“对所有有意义的x求和,即使是从–∞到+∞。”
对离散分布进行抽样,返回x的概率是P(X = x),即与标记为x的条形图相关的概率。抽样连续分布并得到特定x的概率,反直觉地是P(x) = 0,对于任何实数x都成立。我们不能讨论返回x作为样本的概率,而是讨论样本落在某个范围[a, b]中的概率。这个概率是:

这不过是从a到b之间 PDF 下的面积。积分中的变量是虚拟变量。我从x换成了t,以避免与我们在方程左边讨论的x混淆。
在连续情况下,我们必须讨论某个范围的概率,因为并不是所有的无穷大都是相同的,这一点是 19 世纪乔治·康托尔首次意识到的。由于实数比整数多得多,从连续分布中选择任何一个实数的概率实际上是零。虽然本章的算法返回的样本看起来像是来自所需的连续分布,但不要被迷惑。像所有计算一样,我们永远不会使用实数,而是以某种形式使用有理数。最终,我们的样本近似于所需的连续概率分布。然而,正如他们所说的,如果它走起来像只鸭子,叫起来像只鸭子,那它就是只鸭子——或者至少是一个合理有用的类似物。
PMF 和 PDF 与从分布中抽取特定值的概率有关。一个相关的概念是累积分布函数(CDF),我们用于离散分布和连续分布。在x处的 CDF 是从 PMF 或 PDF 的最小值到x之间的面积总和。如果我们对离散分布的所有条形图进行求和,或者对所有 PDF 进行积分,我们得到的面积为 1,因此 CDF 是一个从左侧的 0 到右侧的 1 的函数,随着x的增大而变化。
例如,图 12-1 显示了一个二项分布的 PMF(左上)和 CDF(右上),其中n = 10 次试验,每次的概率p = 0.7。底部则是标准正态分布的 PDF 和 CDF。在这两种情况下,CDF 都从左侧接近 1。

图 12-1:二项分布的 PMF 和 CDF(上)与标准正态分布的 PDF 和 CDF(下)
生成图 12-1 的代码在cdf.py中,它展示了如何将纯数学转化为代码,至少在概率函数方面是这样。我会跳过代码的绘图部分;有关其余相关代码,请参见列表 12-1。
np.random.seed(8675309)
z = np.random.binomial(10,0.7,size=10000)
h = np.bincount(z, minlength=11)
h = h / h.sum()
cdf = np.cumsum(h)
np.random.seed(73939133)
x = np.linspace(-7,7,10000)
y = (1/np.sqrt(2*np.pi))*np.exp(-x**2/2)
cdf = np.cumsum(y*(x[1]-x[0]))
列表 12-1:从 PMF 和 PDF 生成 CDF
第一段代码从二项分布中生成样本。为了节省时间,我使用了 NumPy 的函数来抽取 10,000 个随机样本,这意味着z是一个包含 10,000 个值的向量,每个值是从二项分布中随机选取的样本。为了得到分布本身,我们需要一个直方图。样本在z中是整数,因此获取计数的最有效方法是使用bincount方法。实验次数为n = 10,但可能的结果有 11 种,从 0 次事件到 10 次事件,因此在调用bincount时,minlength=11。
让我们来看一下h = h / h.sum()这一行。bincount方法返回每个结果的计数。我们想要一个离散的概率分布,它的总和必须为 1,因此我们将每个计数除以总和,将它们转换为相加为 1 的分数。因此,h现在是n = 10 和 p = 0.7 的离散二项分布的估计值。为了更好地估计真实的二项分布,可以将样本数量增加到 20,000 个或更多。
离散分布的 CDF 是每个结果概率的累加和。换句话说,

NumPy 的cumsum为我们计算这个累积和,以便在一次函数调用中生成整个 CDF。
连续情况类似,尽管我们不需要从中抽样,因为正态分布的 PDF 有封闭形式的表示。对于标准正态分布(µ = 0, σ = 1),PDF 为:

在代码中,我们使用从–7 到 7 之间均匀分布的 10,000 个x值(linspace)来估计这个函数(y)。
然而,要估计 CDF,我们不能像离散情况那样简单地将y中的值相加。PDF 下的面积必须为 1,但因为它是一个面积,我们将每个y值乘以它与x轴之间矩形的宽度。矩形的宽度是相邻x值之间的差异,因此在求和之前,我们将y乘以x[1] - x[0]。不需要缩放y,因为y中的值是实际的 PDF 值,而不是计数。
本章的目标是从任意分布中抽样,其中“抽样”意味着我们要求一个分布根据可能输出的概率给我们一个数字。分布在 PMF 或 PDF 图中的y轴值越高,oracle 选择该数字(离散的)或该位置附近非常窄范围内的数字(连续的)概率就越高。
例如,nselect.py 中的代码生成了 图 12-2 中的图形,其中 x 轴上的黑点表示来自正态分布的 30 个样本。

图 12-2:来自标准正态分布的 30 个样本
样本集中在 PDF 的中心附近——最有可能被采样的区域。当我们绘制更多样本时,它们在 x 轴上的密度与被选择的概率成正比。通过直方图将密度转换为计数,近似 PDF 本身。
现在,让我们讨论贝叶斯推断,因为它的应用依赖于从复杂的概率分布中高效地采样。
贝叶斯推断
英国部长托马斯·贝叶斯(1701–1761)提出了一个看似简单的方程,最近它颠覆了统计学的世界。推导这个方程是一个基本概率理论的练习。
我们将事件 B 发生的概率表示为,已知事件 A 已经发生,即 P(B|A)。这是给定 A 的条件概率 B。事件 A 发生的概率是 P(A),而 B 和 A 同时发生的概率是它们的 联合概率,即 P(A, B)。
概率论指出,P(B, A) = P(B|A)P(A),反过来交换事件的顺序,我们得到 P(A, B) = P(A|B)P(B)。联合概率表示所有事件组合的概率,这意味着 P(B, A) = P(A, B)。将这些观察结果结合起来告诉我们:

最终的方程是 贝叶斯定理,它将 后验概率 P(B|A) 与 似然 P(A|B) 和 先验概率 P(B) 的乘积相关联。右侧分母 P(A) 是 证据,它是一个归一化值,用来确保后验概率是一个概率——即后验的 PDF 总和为 1。在实践中,P(A) 成为一个通常无法以闭式表达的积分。在贝叶斯建模中,似然和先验概率是选择并已知的函数形式,但证据变得难以处理。这就是我们将要讨论的采样方法派上用场的地方。从后验分布中抽取样本就是 贝叶斯推断。没有先进的采样方法,贝叶斯推断几乎是不可能的;有了这些方法,贝叶斯推断就变成了一种范式转变,正如 Sharon Bertsch McGrayne 在她的著作《那不死的理论》(耶鲁大学出版社,2011)中所指出的:
贝叶斯与马尔可夫链蒙特卡洛(MCMC)的结合被称为“可能是有史以来为处理数据和知识创造的最强大的机制”。
马尔可夫链蒙特卡洛(MCMC)是我们将在本章探讨的采样算法之一。虽然我们在 第十一章 中已经认识到蒙特卡洛方法,但我们将在适当的时候讨论马尔可夫链的部分。
让我们从任意分布开始采样。
离散分布
一个任意的一维离散概率质量函数可能是这样的:
p[X](x) = [1, 1, 3, 4, 5, 1, 7, 4, 3]
虽然这可能出乎意料,但对我们而言,这是一个完全有效的 PMF,表示为 Python 列表。它说明了一个返回 0 到 8 范围内样本的分布(该列表有九个元素),每个样本返回 PMF 中的一个索引。就目前而言,PMF 并未归一化,因此“概率”的总和不是 1;它是 1 + 1 + 3 + 4 + 5 + 1 + 7 + 4 + 3 = 29。为了得到概率,我们需要将每个数字除以这个总和。
PMF 告诉我们,每个值——从 0 到 8——出现的比例,经过大量采样后,它们的出现频率。例如,6 出现的次数是 0 的 7 倍,因为 6 与 0 的比例是 7 : 1。同样,6 与 7 的比例是 7 : 4,依此类推。
让我们计算采样 6 的概率(7/29)与采样 0 的概率(1/29)之间的比率:

结果符合预期。
本节探讨了三种从任意离散分布中采样的方法。两种方法期望 PMF 已归一化(总和为 1),而第三种方法使用作为整数比率表示的 PMF。这看起来可能是一个缺点,但我们经常从通过直方图近似的分布中采样,而直方图的桶是整数计数。
顺序搜索
在第七章中,我们使用 IFS 生成了分形,通过应用根据给定概率选择的映射。换句话说,我们从映射的分布中采样。我们使用的代码在列表 12-2 中。
def ChooseMap(self):
r = self.rng.random()
a = 0.0
k = 0
for i in range(self.nmaps):
if (r > a):
k = i
else:
return k
a += self.probs[i]
return k
列表 12-2:选择一个映射
列表 12-2 实现了通过顺序搜索的反转采样;至少,按照 Luc Devroye 在他的书《非均匀随机变量生成》(Springer,1986)中的说法,叫做反转采样。你可以在 Devroye 的网站上找到他的书。他正在免费提供。我推荐你抓紧时间下载一份。
列表 12-2 中的实现并不简洁。我们可以做得更好,参考列表 12-3。
def Sequential(probs, rng):
k = 0
u = rng.random()
while u > 0:
u -= probs[k]
k += 1
return k-1
列表 12-3:通过顺序搜索的更简洁的反转实现
首先,我们将 PMF 作为probs传递给函数,预计它已归一化。第二个参数是我们老朋友,一个配置为返回 0, 1)区间浮动数值的RE实例。
代码选择一个均匀分布的值u,并按顺序从probs中减去概率,直到结果为零或负值。然后我们返回减去的次数k,作为调整索引后的probs中的采样索引。
我们可以像[图 12-3 所示,使用我们之前讨论的未归一化 PMF 来可视化采样过程。
p[X](x) = [1, 1, 3, 4, 5, 1, 7, 4, 3]

图 12-3:离散分布的顺序采样
概率行反映了这个 PMF,其中每个框的长度与其他框成比例,因此 7 号框比 1 号框长七倍。要获取传入 probs 的概率,请将每个框标签除以总和 29。
图 12-3 显示了选定的 u 作为一个双向箭头。这个 u 约为 0.4,因为它略小于整个 PMF 一半的距离,而 PMF 的总和必须为 1。标记为 5 的框后面的垂直线标记了从 u 中减去的完整概率集合,直到 u < 0。我们按以下顺序减去五个框:1,1,3,4 和 5。根据从 0 开始的索引调整,返回的样本是 4——即与 u 匹配的框下方在值行中的标签。暂时忽略重新排序行。
这个过程使用框的宽度将 [0, 1) 映射到 [0, 8],通过转换均匀输入为非均匀输出,如果我们抽取足够的样本,输出将匹配所需的 PMF。试着通过闭上眼睛并将手指放在 图 12-3 的概率行的某个地方来选择其他样本。然后,将手指滑动到值行,并读取输出,即从左侧覆盖的框数。重复几次后,你应该会发现 6 被选择的频率最高,其次是 4。通过顺序搜索的反转采样是我们的第一种离散采样算法。
让我们做一个小小的改进。图 12-3 的顶部行按顺序展示了 PMF,这意味着分配给 0 的概率是 1/29,而分配给 6 的概率是 7/29。如果我们使用 清单 12-3 从分布中采样,这样是有意义的。然而,要获得 6(最频繁的值)作为一个样本,我们每次都需要从概率向量的开始处进行搜索。如果我们按降序列出概率,那么在通过 清单 12-3 的 while 循环后,最可能的结果将被选中。下一个最可能的结果只需要两次循环,依此类推。这一概念导致了 图 12-3 中的重新排序行。
重新排序行的条形图从左到右按大小降序排列,每个条形图上的标签列出如果该条形图被选中时返回的值。请注意,清单 12-3 中的算法没有改变;它仍然是从 u 中减去概率,但它们现在按从大到小的顺序排列。因此,我们必须将 清单 12-3 返回的索引映射到识别出实际选中的值。例如,如果 清单 12-3 返回索引 1,则映射知道 1 → 4,从而返回 4 作为选中的值。根据 probs 中概率的排列,这一调整应该能加快速度。重新排序的调整是我们的第二种离散采样算法。
快速加载骰子滚动器
我们的最终离散抽样算法相对较新:快速加载骰子滚动器(FLDR),由 Saad 等人在其 2020 年的论文《快速加载骰子滚动器:离散概率分布的近似最优精确采样器》中提出。你可以在他们的 GitHub 网站上找到代码和论文,链接为 github.com/probcomp/fast-loaded-dice-roller。我们只需要 src/python 目录中的 fldr.py 文件。你可以通过浏览器从 GitHub 仓库复制该文件,或者使用 pip 安装完整的包:
> pip3 install fldr
如果你从 GitHub 复制 fldr.py,将其放入本章的文件夹中。
FLDR 论文描述了该算法及其起源。它还提到了前面提到的 Devroye 书籍,这本书激发了该算法的设计。我们不会讨论细节,因为它们相当复杂且富有数学性。然而,了解有更多复杂的思维方式来考虑从离散分布中抽样是很有趣的。
我们将使用的 FLDR 版本需要将 PMF 表示为整数向量,正如我迄今为止所展示的那样。使用 FLDR 需要两个步骤;第一个步骤根据 PMF 对算法进行条件化,第二个步骤根据需求抽取单个样本。我们需要 fldr_preprocess_int 函数来配置采样器,和 fldr_sample 函数来抽取样本。FLDR 代码并不支持 NumPy,但我们可以接受这一点。
现在我们有了算法,接下来将它们互相对比。
运行时性能
让我们来看看我们的算法是否有效,并且它们在运行时性能上如何相互比较。
首先,运行 discrete_test.py,不带参数:
> python3 discrete_test.py
discrete_test <N> [<kind> | <kind> <seed>]
<N> - number of samples
<kind> - randomness source
<seed> - seed
命令行的形式很熟悉。唯一必需的参数是要从“离散分布”章节开头提到的九元素分布中抽取的样本数量,具体见第 328 页:[1, 1, 3, 4, 5, 1, 7, 4, 3]。
让我们选择一些样本:
> python3 discrete_test.py 5000 minstd 476
[157 187 504 722 813 155 1251 693 518] (0.033361 s, sequential)
[164 171 526 674 904 162 1198 702 499] (0.029343 s, reordered)
[165 178 510 702 870 166 1225 683 501] (0.005494 s, FLDR)
[172 172 517 690 862 172 1207 690 517] expected
命令行请求 5000 个样本,并显示每种可能输出被不同算法类型选择的次数。例如,顺序算法选择了 187 次 1。正如我们预期的那样,6 是最常见的输出。最后一行包含了预期的样本数量,它是通过将概率乘以样本数并四舍五入到最近的整数得到的。
这三种算法似乎按照预期工作,结果接近预期输出。从右侧的运行时间来看,顺序算法最慢,重新排序的算法稍微更快,而 FLDR 则快了将近一个数量级。
如果我们要求 50 个样本而不是 5000 个
> python3 discrete_test.py 50 minstd 476
[2 0 10 5 13 0 12 4 4] (0.000363 s, sequential)
[2 1 5 7 13 1 8 8 5] (0.000451 s, reordered)
[3 2 6 5 4 2 12 8 8] (0.000101 s, FLDR)
[2 2 5 7 9 2 12 7 5] expected
输出是噪声的,因为预期频率和采样频率相差较远;例如,顺序算法在 10 次中选择了 2,而预期频率仅为 5。
图 12-4 通过 FLDR 展示了这种效果,使用 FLDR 选择了 50 个样本与 5000 个样本进行对比。

图 12-4:使用 50 个样本(左)和 5000 个样本(右)从离散分布中采样
条形图表示真实分布,点表示样本,二者均以概率表示。
图 12-5 展示了我们的采样器在对⌊N^α⌋个样本进行采样时的运行时性能,其中 α 从 1 变化到 6,步长为 25。请注意,x 轴单位是百万。

图 12-5:样本时间与样本数量的关系
我们可以放心地说,所有三种采样算法的运行时间都是
(n) 时间复杂度。然而,图 12-5 对我们来说是一个很好的实际例子。大 O 符号忽略了乘法因子,因此虽然所有三种算法的运行时间是线性的,但在实际应用中我们希望使用 FLDR。
让我们逐步了解 discrete_test.py,从设置部分开始(清单 12-4)。
from fldr import fldr_preprocess_int, fldr_sample
N = int(sys.argv[1])
if (len(sys.argv) == 4):
rng = RE(kind=sys.argv[2], seed=int(sys.argv[3]))
elif (len(sys.argv) == 3):
rng = RE(kind=sys.argv[2])
else:
rng = RE()
probabilities = [1,1,3,4,5,1,7,4,3]
prob = np.array(probabilities)
prob = prob / prob.sum()
M = len(prob)
清单 12-4:设置 discrete_test.py
我们将重点关注开始部分,在这里我们引入了来自 fldr 的函数,以及结束部分,在这里我们定义了 probabilities。
FLDR 需要整数计数,因此在这种情况下我们使用 probabilities。当使用需要真实概率的顺序采样算法时,我们使用 prob。
清单 12-5 使用每种算法绘制所请求的样本数量。每种情况都会创建一个样本向量 z,并计时绘制这些样本所花费的时间。使用列表推导式来绘制样本。
s = time.time()
z = np.array([Sequential(prob,rng) for i in range(N)])
e = time.time() - s
h = np.bincount(z, minlength=M)
print(h, ("(%0.6f s, sequential)" % e))
idx = np.argsort(prob)[::-1]
p = prob[idx]
s = time.time()
z = np.array([Sequential(p,rng) for i in range(N)])
e = time.time() - s
h = np.bincount(idx[z], minlength=M)
print(h, ("(%0.6f s, reordered)" % e))
s = time.time()
x = fldr_preprocess_int(probabilities)
z = np.array([fldr_sample(x) for i in range(N)])
e = time.time() - s
h = np.bincount(z, minlength=M)
print(h, ("(%0.6f s, FLDR)" % e))
print(np.round(prob*N).astype("uint32"), "expected")
清单 12-5:使用每种算法进行采样
第一个代码段调用了我们在 清单 12-3 中看到的 Sequential,然后使用 bincount 创建直方图,最后显示计数和生成时间。
第二段代码最终调用了 Sequential,但首先将 prob 按降序排列。NumPy 的 argsort 返回能够将 prob 按升序排序的索引。[::-1] 语法则将列表反转,使得 idx 按降序排列。
然后我们用 p 而不是 prob 调用 Sequential。这意味着 z 中的值不是正确的索引,而是指向 idx 的索引,idx 保存了正确的索引。换句话说,idx 是获取正确样本值的映射。通过使用 z 索引的 idx 调用 bincount 可以生成正确的样本频率。考虑花点时间确认一下 idx[z] 是有意义的。
对称性告诉我们,清单 12-5 中的最后一段代码通过重复调用 fldr_sample 使用 FLDR 绘制样本。但首先,我们必须将概率传递给 fldr_preprocess_int,以创建 fldr_sample 使用的结构。
清单 12-5 的最后一行通过将概率与样本数 N 相乘并取整,显示了每个值的预期计数。
如果我们想要采样的分布是二维的呢?那是什么意思呢?让我们找出答案。
二维
我们可以将一维离散分布存储在一个向量中。通过扩展,我们可以想象将二维分布存储在一个矩阵中。但是我们如何解释这个分布呢?
由于一维分布告诉我们每个值应该出现的频率,二维分布则是指一对值,即每个维度的索引。
考虑这个二维分布,例如:

所有元素的总和为 1,因此p[X](x)是一个 PMF。注意,我已经将x替换为x,一个向量。我们还可以写p[X](x, y)以强调我们有两个维度。
分布表示p(X = 0, Y = 0) = 0.1,而p(X = 2, Y = 3) = 0.2,也就是说,变量的值是表示分布的矩阵中行和列的索引。二维概率分布出现在考虑联合分布时——即一对随机变量如何一起出现并拥有某种值的组合。
我们将使用现有的采样技术,通过解开分布、采样,然后将样本转换回二维对来从二维分布中抽取样本。例如,解开之前的分布给我们带来了:
p[X](x) = [0.1, 0.0, 0.1, 0.2, 0.0, 0.0, 0.1, 0.1, 0.2, 0.0, 0.0, 0.2]
如果我们使用上述算法之一从这个分布中抽样,我们将得到范围在[0, 11]之间的样本。为了将样本转换回对,我们必须撤销解开操作,这意味着我们需要知道原始二维分布中的行数和列数。
让我们通过一个例子来演示。使用以下命令行运行discrete_ravel.py:
> python3 discrete_ravel.py 1000 mt19937 10101
输出由四个部分组成。代码本身通过解开p[X](x)进行采样,然后将样本映射回表示x和y组合出现频率的(x, y)对。如果一切顺利,这些频率的一维和二维直方图应该接近p[X](x)。
第一个输出行给出了:
[0.091 0\. 0.1 0.204 0\. 0\. 0.091 0.104 0.205 0\. 0\. 0.205]
这是一个由解开直方图采样得到的 PMF。其值都在 0.1 和 0.2 之间,这让人感到鼓舞。
第二个输出块是相同的估计 PMF,重新映射到二维:
[[0.091 0\. 0.1 0.204]
[0\. 0\. 0.091 0.104]
[0.205 0\. 0\. 0.205]]
这看起来很像p[X](x)。
估计的 PMF 看起来是正确的。至于采样值,这里是从解开的 PMF 中抽取的前八个样本:
[ 3 11 11 7 3 0 7 8]
映射回成对,这些样本变成:
[(0,3), (2,3), (2,3), (1,3), (0,3), (0,0), (1,3), (2,0)]
从一维样本到二维对的转换是
(x, y) = (z ÷ 4, z mod 4)
其中z是 1D 样本值,÷表示整数除法。4 来自于二维 PMF 中的列数。
Listing 12-6 展示了解开、采样、重新映射的过程。
prob2 = np.array([[0.1, 0.0, 0.1, 0.2],
[0.0, 0.0, 0.1, 0.1],
[0.2, 0.0, 0.0, 0.2]])
prob = prob2.ravel()
z = np.array([Sequential(prob,rng) for i in range(N)])
h = np.bincount(z, minlength=len(prob))
h = h / h.sum()
print(h)
print(h.reshape((3,4)))
print(z[:8])
x,y = np.unravel_index(z[:8], prob2.shape)
print([i for i in zip(x,y)])
Listing 12-6: 通过解开采样二维 PMF
二维 PMF 存储在prob2中,展开后变成一维 PMF prob。第二段代码从prob中进行采样,就像我们之前做的那样。注意rng,它是我们RE类的一个实例。我忽略了discrete_ravel.py文件中的一些代码头,因此请确保检查文件本身。像之前一样,样本计数来自bincout,我们然后通过将h除以计数的总和来将其转换回一维 PMF。
接下来是四个输出中的三个,分别为h、h重塑为 3×4 矩阵,以及从z中提取的前八个样本。
在将样本映射到对时,我们通过调用unravel_index来节省时间,它需要一维索引和源数组的形状——这里是来自prob2的 3×4。NumPy 返回x和y坐标,因此一对值为(x[0],y[0]),依此类推,通过zip实现的列表推导式给出。
我们也可以使用这种展开方法处理超过二维的分布。如果我们有三个随机变量——X、Y和Z——那么来自三维 PMF 的样本,p[XYZ](x, y, z),就是三元组(x, y, z),根据某个特定值组合出现的概率。我们展开、在一个维度上进行采样,然后使用unravel_index将其映射回三元组。请记住,随着维度的增加,合理逼近分布所需的样本数量会急剧增加。
假设我们系统中的每个随机变量都有 10 个可能的取值。如果我们只有一个变量,我们必须从一个可以表示为 10 个元素的向量的概率分布中进行采样。如果有两个随机变量,我们需要一个矩阵来表示联合分布,展开后是一个 10 × 10 = 100 个元素的向量。如果有三个随机变量,我们展开为 10 × 10 × 10 = 1,000 个元素;如果有四个随机变量,我们需要 10,000 个元素。
固定值的数量为 10 时,n维的 PMF 展开成一个包含 10^n个元素的向量——分布大小随着维度的增加而呈指数级增长。因此,这个技巧最适用于二维或三维。
图像
现在来点有趣的东西。discrete_2d.py中的代码知道如何使用图像的灰度版本作为离散的二维概率分布,因此我们可以从中进行采样。灰度图像是一个包含从 0 到 255 的整数值的矩阵,使得展开后的灰度图像立刻可以作为分布被 FLDR 使用。
样本变成了像素位置。图像某个像素的强度越高,它被采样的可能性就越大。因此,如果我们绘制足够的样本,将其缩放到[0, 1]范围内并乘以 255,我们可以将估计的分布转回图像并与原图进行比较。说了这么多,让我们来看一些代码。
Listing 12-7 中的四段代码展示了核心代码,省略了导入和命令行处理部分。
image = Image.open(iname).convert("L")
row, col = image.size
row //= 2
col //= 2
image = np.array(image.resize((row,col),Image.BILINEAR))
p = image.ravel()
probabilities = [int(t) for t in p]
x = fldr_preprocess_int(probabilities)
z = np.array([fldr_sample(x) for i in range(N)])
x,y = np.unravel_index(z, (col,row))
im = np.zeros((col,row))
for i in range(len(x)):
im[x[i],y[i]] += 1
im = im / im.max()
os.system("rm -rf %s; mkdir %s" % (oname,oname))
Image.fromarray(image).save(oname+"/"+os.path.basename(iname))
Image.fromarray((255*im).astype("uint8")).save(oname+"/histogram2d.png")
列表 12-7:将图像视为二维分布并从中进行采样
为了将输入图像转换为一维分布,我们首先加载图像,将其调整为原始尺寸的一半,然后将其展开成一个像素强度列表,范围是[0, 255]。使用int的列表推导是必要的,因为 FLDR 不支持 NumPy 数组。
接下来的段落配置 FLDR(x),然后使用它来绘制N个样本(z),其中N是命令行中给出的数值。
获取样本后,unravel_index将一维样本转化为(x, y)对,或像素位置。然后,我们使用这些像素位置来填充im,这是一个二维直方图,用来统计 FLDR 选择每个像素的次数。为了将im转换为图像,我们必须缩放它,使得最常被采样的像素值为 1,这可以通过除以最大值来实现。
最后一几行代码创建了一个输出目录,并将原始图像和采样图像保存到该目录中。我们必须将im(现在的范围是[0, 1])乘以 255,并将其转化为无符号整数,然后才能将其作为图像写入磁盘。
运行discrete_2d.py,不带参数,以了解命令行选项。尝试使用test_images中的图像和images中的图像进行实验。后者包含高对比度图像,这可能使得更容易看出样本来自哪里,尤其是那些反转的图像(文件名中带有“_inv”)。这些渐变图像呈现了线性、二次和三次渐变,从左到右。我们将优先采样更亮的区域,因为它们更有可能被采样。
图 12-6 展示了其中一张高对比度图像,其中白色区域最有可能被采样。这个版本的图像打印效果很好。反转版本通常需要更少的样本。

图 12-6:原始鹰图像(左上角)和使用逐渐增多的样本数量采样的图像
在图 12-6 中,原始图像位于左上角,接下来是使用 60,000、120,000、240,000、480,000 和 960,000 个样本逐步采样得到的图像,按从左到右、从上到下的顺序排列。我使用了以下命令行
> python3 discrete_2d.py images/hawk.png 120_000 tmp mt19937 19937
根据需要调整样本数量。
这个实验结束了我们对离散分布采样的研究。让我们继续探讨更具数学相关性的连续分布采样,并学习一些新技术,最终引入马尔可夫链蒙特卡洛的世界。
连续分布
让我们转向考虑连续分布,这些分布由概率密度函数(PDF)表示,允许其范围内任何实数输入。在这种情况下,前一部分的技术已经不再适用——至少不适用于未做修改的情况——但也存在其他方法,我们将探索其中的三种:逆变换采样、拒绝采样和马尔可夫链蒙特卡洛。
逆变换
我们用 PDF 来表示连续分布。注意“函数”一词,它告诉我们有一个数学关系描述了 PDF 的形状。给定 PDF 的 CDF 是一个积分:

积分是对离散概率求和的连续版本。它表示 PDF 从–∞到某个x之间的区域。将–∞替换为任何 PDF 始终为零的值。
CDF 的值从 0 到 1,这意味着 CDF 的图像会产生从 0 开始、到 1 结束的y轴值;请查看图 12-1 中的 CDF。如果我们在 CDF 图的y轴上选取一个随机值,从那里水平滑动到曲线,再向下移动到x轴,就能得到一个来自 PDF 的样本。再选一个新的y轴起点,重复这个过程,就能得到一个新的x值和另一个来自 PDF 的样本。均匀采样 CDF 图上[0, 1]范围内的y轴值,会生成符合 PDF 形式的x值,若进行直方图统计,结果会呈现出 PDF 的形态。
为了用数学表达这个过程,可以将函数F(x)的图像沿y = x(该线在第一象限从x轴向上以 45 度角斜切)翻转,就得到了该函数的逆函数图像F^(–1)(x),如果它存在的话。逆函数将x和y的值翻转,意味着逆函数的输入类似于原函数的y值,逆函数的输出是产生该输入的原函数的x值。
因此,如果我们知道表示 CDF 的函数的逆函数的形式,我们可以通过在[0, 1]范围内选择随机值作为输入,并将逆 CDF 的输出作为所需样本,来从 PDF 中采样。这一过程叫做逆变换采样。
让我们通过一个例子来讲解。假设我们想从指数分布中抽取样本,其 PDF 为
f(x) = λe^(–λx)
其中λ(lambda)是一个常数,决定了 PDF 从x = 0 时最大值λ开始衰减的速度。这个 PDF 最可能的样本接近零,远离零的样本则不太可能出现。
这个 PDF 的 CDF 是一个积分:

因此,F(x) = 1 – e^(–λx)。如果我们找到这个函数的逆,我们就能通过均匀分布的输入来生成指数分布的样本。为了找到逆函数,将 CDF 等于u(代表均匀样本),然后解出x:

我们现在有了F^(–1)(u),它是一个将[0, 1]范围内的均匀输入u映射到x的关系,x是根据指数分布 PDF 的形状选定的值。在使用逆函数之前,让我们再观察一下。
我们不特别关心这个u和那个x之间的精确配对,因为我们打算随机选择u值。因为u在[0, 1]之间,1 – u也在[0, 1]之间,但沿u-轴“翻转”,因为它是u的补集,使得这两个值之和为 1。因此,我们可以将 1 – u替换为u,我们的样本仍将来自指数分布。这一步不是必需的,但它使得逆函数的图像对我们来说看起来不那么奇怪,因为我们习惯于看到从高点开始随x向右增大而衰减的曲线。
图 12-7 显示了F^(–1)(u) = (–log u)/λ的图像,其中特定的u值已映射到各自的x输出。

图 12-7:从 e^(–λx) 使用–log(u)/λ进行的逆变换采样
x值的分布——或者来自许多u输入的许多x值的正确缩放直方图——随着样本数量的增加,将更好地逼近λe^(–λx)。
文件inverse.py从作为其累积分布函数(CDF)逆函数提供的函数中采样。换句话说,我们给定代码F^(–1)(u)及其对应的概率密度函数(PDF)f(x),以及所需的样本数量,然后它会给我们返回样本,同时绘制 PDF 和样本的直方图。
让我们使用代码从f(x) = 2e^(–2x)中绘制样本。该 PDF 的逆 CDF 是F^(–1)(u) = (–log u)/2。要绘制 1,000 个样本,可以使用如下命令行:
> python3 inverse.py 1000 "-np.log(u)/2" "2*np.exp(-2*x)" tmp minstd 90210
第一个参数是所需的样本数量。第二个参数,双引号括起来的是实现F^(–1)(u)的 NumPy 版本,使用 NumPy 函数并将u作为自变量。接下来的参数,同样用双引号括起来的是f(x),即 PDF。请注意,它是x的函数,而不是u。其余的参数是输出目录和常规的随机数源以及可选的种子。
图 12 显示了inverse.py对 1,000 个(左)和 10,000 个(右)样本的输出。

图 12-8:从 2e^(–2x*)绘制的 1,000 个(左)和 10,000 个(右)样本
代码会对输出进行缩放,使得曲线和直方图匹配。样本遵循期望的分布,更多的样本更好地代表 PDF。10,000 个样本开始选择离零更远的x值。
让我们再看一个例子。Kumaraswamy 分布类似于 Beta 分布,但其 PDF 和 CDF 的函数形式有利于逆采样。具体来说:

这里,a和b是常数,用来定义分布的形状,类似于 Beta 分布中的a和b。我将它作为练习留给你,证明F^(–1)(u)来源于F(x)。
让我们从这个分布中绘制样本,设a = 2,b = 5。我们需要的命令行是:
> python3 inverse.py 10000 "(1-(1-u)**(1/5))**(1/2)"
"10*x**1*(1-x**2)**4" kumaraswamy pcg64 42
结果的图形见图 12-9。如预期的那样,样本遵循分布的形状。

图 12-9:Kumaraswamy (2,5) 的抽样
inverse.py 中的主要代码很简单,因为我们在命令行中提供了 PDF 和逆 CDF,它们的形式允许我们使用 Python 的 eval 函数:
samples = np.zeros(N)
for i in range(N):
u = rng.random()
samples[i] = eval(ifunc)
就是这样。我们创建 samples 来保存请求的 N 个样本,然后通过评估从命令行传入的逆 CDF 函数(作为 ifunc 字符串)来循环生成 samples[i]。inverse.py 的其余部分用于创建输出图形。
逆变换抽样是直接的,并且适用于封闭形式的函数,但由于必须满足的两个条件,它的适用性有限。PDF 必须生成封闭形式的 CDF,并且该 CDF 必须是可逆的,以便得到 F^(–1)(u)。这种情况并不常见,特别是对于任意连续的 PDF。在第 358 页的“习题”中,我建议了另一种 PDF/CDF 组合,可以与 inverse.py 配合使用,但前提是你将 u 限制在 [0, 1] 以外的范围。
让我们探讨下一个连续 PDF 抽样算法——拒绝抽样。与逆变换抽样不同,拒绝抽样适用于任意的 PDF。
拒绝
我们希望从函数 q(x) 中抽样,使得从该函数中得到的多个样本的直方图趋近于该函数本身的形状。虽然我们不知道如何直接从 q(x) 中抽样,但我们可以从一个我们称之为 p(x) 的提议函数中抽样。如果我们找到了一个常数 c,使得
q(x) ≤ cp(x),∀x
然后我们可以使用 p(x) 的样本从 q(x) 中抽样。回想一下,∀ 表示“对于所有”。
首先,我们从提议函数中抽取一个样本,x ∼ p(x),其中 ∼ 表示“从…抽样”。这给了我们一个候选的 x 位置。
接下来,我们选择一个 y 值,它是从 x 向上某个比例的值,但仍然小于 cp(x)。换句话说,我们在区间 [0, cp(x)] 中选择一个均匀值,或 y = ucp(x),其中 u 在 [0, 1] 之间。
如果 y ≤ q(x),我们保留 x 作为 q(x) 的样本;否则,我们拒绝 x 并重新从 p(x) 中抽取另一个样本。我们在保留了所需数量的 q(x) 样本后停止。
图 12-10 展示了两个候选 x 位置的情况。

图 12-10:使用两个候选 x 位置的拒绝抽样
实线曲线是 q(x),我们希望从中抽样的函数。虚线曲线,这里是 q(x) 范围内的均匀值,是 cp(x)。
让我们先看一下x = x[0]。算法要求从p(x)中抽样,得到x[0]。接下来,我们从x[0]到cp(x[0])的某个位置选择一个y。我们可以将其写为y[0] = u[0]cp(x[0])。虽然我们知道y[0]将始终小于或等于cp(x[0]),但我们想知道y[0]是否小于q(x[0])。对于x[0],情况正是如此,因此我们接受x[0]作为来自q(x)的有效样本。
在x[1]中,y[1] = u[1]cp(x[1])大于q(x[1]),因此我们拒绝x[1]作为来自q(x)的有效样本,过程重复进行。
算法上,这个过程简化为以下内容:
-
x ∼ p(x)。
-
u ∼ U0, 1)。
-
如果ucp(x) ≤ q(x),则接受x作为样本;否则,拒绝x。
-
从第 1 步开始重复,直到我们接受了N个样本。
你可能会看到第 3 步的条件写作:
![Image
通过除以cp(x),我们得到这种形式。我们在此问的是,u是否小于从所选x到cp(x)的距离,由q(x)所覆盖。如果不是,就拒绝x并重试。
可以将拒绝采样看作是随机将飞镖投向xy平面。如果飞镖的y坐标小于cp(x)和q(x),我们接受飞镖的x坐标作为来自q(x)的样本。实际上,我们保留所有落在q(x)曲线下方的飞镖的x坐标。我们在第三章中做了类似的事情来估算π。
让我们用rejection.py来实践这个过程:
> python3 rejection.py
rejection <N> <proposal> <c> <func> <limits> <outdir> [<kind> | <kind> <seed>]
<N> - number of samples
<proposal> - uniform|normal_mu_sigma (e.g. normal_0_1)
<c> - proposal multiplier (e.g. 1)
<func> - function to sample from (e.g. 2*x**2+3)
<limits> - lo_hi limit on sampling range (e.g. -3_8.8)
<outdir> - output directory name (overwritten)
<kind> - randomness source
<seed> - seed
拒绝采样适用于任何提议函数p(x),只要我们能从中抽取样本,但rejection.py限制了我们只有两个选项:一个均匀分布,如图 12-10 中虚线所示,和一个具有给定均值(µ)和标准差(σ)的正态分布。Box-Muller 变换让我们能够从正态分布中抽样(参见第一章)。
让我们在图 12-10 中重现这个例子。提议函数是一个均匀分布乘以 4.1,因为这使得提议函数刚好位于采样函数q(x)的最高部分上方。

它是两个以±5 为中心的正态曲线的和,其中一个比另一个高四倍。
现在,我们进行抽样:
> python3 rejection.py 100000 uniform 4.1
"np.exp(-((x-5)/2)**2)+4*np.exp(-((x+5)/2)**2)" -18_18 reject0 pcg64 1313
832745 trials to get 100000 samples (30.7419 s)
输出告诉我们,我们需要超过 830,000 个候选样本才能获得要求的 100,000 个样本。这是一个 12%的转化率,意味着我们拒绝了 88%的候选样本。拒绝采样的效率关键取决于提议函数cp(x)和采样函数q(x)之间的接近程度。提议函数与采样函数越接近,效果越好。
图 12-11 显示了来自q(x)的样本的直方图。提议函数见图 12-10。请注意,拒绝采样并不关心p(x)和q(x)是否归一化(即曲线下的面积为 1)。只要cp(x)高于q(x),一切就会正常进行(尽管可能较慢)。

图 12-11:使用均匀提议函数进行采样
让我们使用正态曲线作为提议函数:
> python3 rejection.py 100000 normal_0_1 4
"np.exp(-((x-5)/2)**2)+4*np.exp(-((x+5)/2)**2)" -18_18 reject1 pcg64 1313
1511344 trials to get 100000 samples (62.7675 s)
提议函数现在是一个均值为 0,标准差为 1 的正态曲线。乘数为 4。
输出结果在reject1中。我们得知需要 150 万个候选样本才能获得 10 万个样本,转化率为 6.6%。图 12-12 显示了直方图(左)和提议函数与采样函数的图表(右)。这些图表可能看起来很奇怪,这是有原因的。

图 12-12:使用 N(0,1)作为提议函数
提议函数是图 12-12 中右侧的虚线曲线。它位于组成q(x)的正态曲线之间,并且在零附近只有一个小区域内,它大于q(x)。算法不能在p(x) < q(x)的区域选择样本;因此,它只在左侧最左边的正态曲线的右侧部分和最右边的正态曲线的左侧部分之间的重叠区域内选择样本。
左侧的直方图中,代码缩放了q(x)和样本的直方图,因此它们的最大y值都为 1。直方图在两个位置有峰值,即重叠区域的左侧和右侧最大值。虽然这不是我们期望的结果,但在给定约束条件下,这次运行的输出是正确的。
让我们尝试更多的例子。文件run_rejection_examples包含清单 12-8。
python3 rejection.py 100000 normal_0_20 4.2
"np.exp(-((x-5)/2)**2)+4*np.exp(-((x+5)/2)**2)" -18_18 reject2 pcg64 1313
python3 rejection.py 100000 normal_-5_2.4 4
"np.exp(-((x-5)/2)**2)+4*np.exp(-((x+5)/2)**2)" -18_18 reject3 pcg64 1313
python3 rejection.py 100000 uniform 4
"np.exp(-((x-5)/2)**2)+4*np.exp(-((x+5)/2)**2)" -11_4 reject4 pcg64 1313
python3 rejection.py 100000 uniform 158
"2*x**2+3" -3_8.8 reject5 pcg64 1313
清单 12-8:附加示例
图 12-13 显示了从上到下生成的reject2到reject5的图表。
最上面的图涵盖了整个q(x)范围,样本从每个峰值处抽取。下一行仅显示从左侧峰值处的样本,因为提议函数覆盖了它并且稍微覆盖了右侧峰值的一部分。第三行将选择范围限制为x ∈ [–11, 4],在限制样本范围的同时仍能反映q(x)的形状。最后一行清单 12-8 切换到一个新的函数,q(x) = 2x² + 3,以及一个均匀提议函数。

图 12-13:命令行在清单 12-8 中的直方图和提议函数
使用q(x)函数和完全或部分覆盖q(x)的提议函数来实验rejection.py。记得选择一个c,使得你想采样的区域中q(x) < cp(x)。
拒绝采样不限于一维。例如,如果我们有q(x,y),只要我们能够从p(x,y)中采样,我们就可以绘制样本。均匀函数,对于所有(x,y)点都是 1,是一个容易使用的p(x,y)。多元正态分布也可以使用,尽管它更难编码和可视化。算法保持不变,但我们不再从p(x)中绘制x,而是从p(x,y)中绘制(x,y)——二维空间中的一个随机点。测试仍然是ucp(x,y) ≤ q(x,y),即:

如果条件成立,点(x,y)就是来自q(x,y)的样本。
扩展到任意维度,x =(x[0],x[1],x[2],...)只要我们能从p(x)中采样就可以。然而,随着维度的增加,除非p(x)与q(x)非常接近,否则被拒绝的样本数会呈指数增长(每增加一个新维度),而这在保持从p(x)中轻松采样的同时是非常困难的。这种现象,即拒绝采样的效用随着问题维度的增加而降低,是维度的诅咒的一个表现,这也是机器学习模型常常面临的问题。
随着问题维度的增加,拒绝采样的低效性促使我们使用最终的采样算法,这个算法可以处理高维问题:马尔可夫链蒙特卡罗。
马尔可夫链蒙特卡罗
我们的最终采样算法是最强大的:马尔可夫链蒙特卡罗(MCMC)。我们在第十章中学习了蒙特卡罗算法。马尔可夫链这一术语以俄罗斯数学家安德烈·马尔可夫(1856–1922)的名字命名,指的是这样一个过程:从当前状态移动到新状态的转移概率仅取决于当前状态,与之前的状态无关。马尔可夫链在模拟中非常有用,因为接下来发生的事情完全依赖于系统当前的状态,而与系统的历史无关。
MCMC 使用马尔可夫链来近似从复杂的概率分布中采样。马尔可夫链中的平稳分布,通常用π表示,是离散情况下的一个向量或连续情况下的概率密度函数(PDF)。无论初始分布如何,行走马尔可夫链最终都会到达由转移概率唯一决定的平稳分布,前提是满足特定的条件。
我们将首先深入研究平稳分布;然后,我们将探索 Metropolis-Hastings 算法,并利用它来行走一个连续的马尔可夫链——最终意识到其平稳分布就是我们想要从中采样的那个 PDF。
行走马尔可夫链
让我们通过一个例子来演示。圆顶帽在今年非常流行,人人都在佩戴,且有三种颜色:红色、绿色或蓝色。一个人明年换圆顶帽颜色的概率取决于他们今年佩戴的颜色。这个概率从每年到每年都是固定的。随着时间的推移,最初的圆顶帽颜色分布会发生什么变化?颜色的分布会持续变化,还是最终会稳定到某种特定的、或许是静态的分布?
我们通过一个矩阵来编码转移概率,其中行表示当前状态(圆顶帽颜色),而该行的列表示从当前状态到新状态(即新的圆顶帽颜色)的转移概率,新状态可以与当前状态相同。
考虑这个转换矩阵:

行表示红色、绿色和蓝色的圆顶帽,列也是如此。因此,如果某人今年佩戴红色圆顶帽,他们明年再次佩戴红色圆顶帽的概率为 53%,换成绿色圆顶帽的概率为 5%,换成蓝色圆顶帽的概率为 42%。这一行的总和为 1,这是必须的。同样,喜欢绿色圆顶帽的人明年继续佩戴绿色圆顶帽的概率为 83%,但 13%的人会换成红色圆顶帽,还有 4%的人会大胆地换上蓝色圆顶帽。
要使用转换矩阵,我们需要一个初始的圆顶帽分布:

向量告诉我们,70%的群体拥有一顶红色的圆顶帽,24%的人拥有绿色的,只有 6%的人拥有蓝色的。
要找出明年分布的情况,我们需要查看转换矩阵作用于每种圆顶帽颜色的群体比例时会发生什么。佩戴红色圆顶帽的人根据转换矩阵的第一行[0.53, 0.05, 0.42]转换为新的颜色。我们将红色圆顶帽佩戴者与转移概率相乘,以得到明年佩戴红色圆顶帽的人所占比例:

对于绿色圆顶帽的人,我们将转换矩阵的第二行乘以 0.24,对于蓝色圆顶帽的人,我们将最后一行乘以 0.06。最后,我们将结果相加以得到新的圆顶帽颜色分布。
最终,这些步骤不过是将当前的分布作为行向量与转换矩阵相乘而已:

明年,41%的人将佩戴红色圆顶帽,25%的人将佩戴绿色的,几乎 34%的人将佩戴蓝色的。马尔科夫性质告诉我们,接下来的分布是将当前分布与转换矩阵相乘,并如此反复。
文件markov_chain.py接受帽子颜色的初始分布(π)和转移矩阵(P),并生成马尔科夫链直到分布达到平稳。为了增加趣味性,红色、绿色和蓝色帽子的分布被视为 RGB 颜色,这样输出文件markov_chain.png就能显示从初始分布到平稳分布的过渡,表现为从左到右的颜色条。
使用之前的值运行代码:
> python3 markov_chain.py 70 24 6 [[53,5,42],[13,83,4],[14,29,57]]
前三个值是初始分布:红色、绿色和蓝色帽子。最后一个参数,不能包含空格,是表示转移矩阵的 Python 列表。虽然这些值与之前略有不同,但输入值已经根据它们各自的总和进行了缩放,因此

同样适用于转移矩阵的各个行。
我们打印马尔科夫链
[0.7 0.24 0.06]
[0.4106 0.2516 0.3378]
[0.297618 0.32732 0.375062]
[0.25279782 0.39532448 0.3518777 ]
[0.23463791 0.44280374 0.32255835]
[0.22708075 0.47280092 0.30011833]
[0.22383348 0.49081312 0.2853534 ]
[0.22238693 0.50131905 0.27629402]
[0.22171771 0.50733942 0.27094286]
[0.22139651 0.51075104 0.26785245]
[0.22123713 0.5126704 0.26609247]
[0.22115578 0.5137451 0.26509912]
[0.2211133 0.51434497 0.26454173]
[0.22109074 0.51467909 0.26423017]
[0.2210786 0.51486493 0.26405647]
[0.221072 0.5149682 0.2639598]
[0.2210684 0.51502555 0.26390605]
[0.22106642 0.51505738 0.2638762 ]
[0.22106533 0.51507504 0.26385963]
[0.22106473 0.51508484 0.26385043]
[0.2210644 0.51509028 0.26384532]
这告诉我们,平稳分布为 22%红色、51.5%绿色和 26.4%蓝色帽子。
尝试修改代码,将输入分布改为1 0 0(100%红色)或0 0 1(100%蓝色)。无论如何,你最终总会到达平稳分布,尽管链中的链接数量可能不同。然后,修改转移矩阵,看看会发生什么。
唯一值得讨论的代码部分是构建链:
eps = 1e-5
last = np.array([10,10,10])
chain = []
while (np.abs(d-last).sum() > eps):
print(d)
chain.append(d)
last = d
d = d @ transition
while循环运行直到新分布与last分布的差异小于或等于eps。chain列表保存分布序列。我们使用@执行矩阵乘法,π ← πP。
MCMC 的强大之处在于,Metropolis-Hastings 算法(我们接下来要讨论的)运行马尔科夫链时,并不直接生成链,而是作为代理从π中返回样本,一旦链足够长,达到平稳分布。
探索 Metropolis-Hastings
一篇题为《通过快速计算机进行状态方程计算》的论文发表于 1953 年 6 月的化学物理学杂志。作者是尼古拉斯·梅特罗波利斯、阿里安娜·W·罗森布鲁斯、马歇尔·N·罗森布鲁斯、奥古斯塔·H·泰勒和爱德华·泰勒。论文介绍了Metropolis 算法,之所以这样命名,是因为梅特罗波利斯的名字出现在论文的第一位。然而,正如科学这一极具人性化的事业中常常发生的那样,导致该算法诞生的过程存在争议。现在看来,真正的发明者更可能是马歇尔和阿里安娜·罗森布鲁斯,而不是梅特罗波利斯。五位作者现在都已去世,因此我们可能永远无法知道完整的故事。我们将使用该算法的现代名称——Metropolis 算法,虽然我们知道,或许应当将其归功于其他人。
1970 年,Wilfred Hastings 将该算法扩展到提议分布不对称的情况;因此,这个算法现在被称为 Metropolis-Hastings (MH)。我们将限制自己使用对称的正态分布作为提议分布,因此从技术上讲,我们只使用了 Metropolis 部分。
MH 使用提议分布生成样本,方式与拒绝采样非常相似;然而,在这种情况下,提议分布是随机游走的(我们很快会了解这意味着什么),因此它改变了马尔可夫链的分布。运行足够长的随机游走,并适当拒绝或接受移动,最终我们会到达马尔可夫链的平稳分布。到那时,MH 返回的样本就来自平稳分布,这正是我们一开始希望从中抽取样本的分布。多么方便啊!
注意
MH 的完整数学处理和它背后发生的事情超出了我们在此讨论的范围。感兴趣的读者可以在 Gregory Gundersen 的博客中找到一个很好的总结: gregorygundersen.com/blog/2019/11/02/metropolis-hastings。
对于我们的目的,我们将接受 MH 的声明,而是关注算法的随机游走版本。MH 需要一个函数,从中采样,该函数定义了我们希望样本在生成其分布直方图时遵循的形式。这是马尔可夫链的平稳分布,因此我们将此函数称为 π(x)(不要与数字 π 混淆)。MH 在多维情况下表现良好,但我们将限制自己到一维,因此是 π(x) 而不是 π(x)。
MH 还需要一个提议分布,Q(x)。我们将使用正态分布,因为它是对称的,且我们知道如何高效地从中采样,x′ ∼ N(x, σ),其中 σ 是用户提供的标准差,x 是均值。
有了 π(x) 和 Q(x),随机游走 MH 就变得简单明了:
-
选择一个初始样本,x;例如,x = 1。
-
基于当前样本提出一个新的样本:x′ ∼ N(x, σ)。
-
定义
![Image]()
-
定义 ρ = min(1, A)。
-
定义 u ∼ uniform(0, 1)。
-
如果 u < ρ,则接受 x′,x ← x′。
-
否则,保留 x。
-
将 x 输出为一个样本。
-
重复步骤 2–8,直到收集到所有所需的样本。
接受值 A 使用当前样本位置 x 和提议样本位置 x′ 来评估我们希望采样的函数。如果这个值通过 ρ(rho)限制为最大值 1,并且小于一个随机均匀样本,则接受提议样本 (x′) 作为新的样本;否则,保留当前样本 x。在循环之前,输出当前的 x 作为分布 π(x) 的样本。
这个算法是实现 MH 的最简单方式。实际上,我们可以使其更简单,因为无需定义 ρ;我们可以直接使用 A,因为 u 总是位于 0, 1) 之间。
在步骤 2 中,新的提议位置,x′,来自提议函数,它是一个以x(当前样本)为中心的正态分布。这就是随机游走的部分。在步骤 6 中,如果x′最终被接受,它将成为我们用来选择下一个提议位置的新x。换句话说,当一个提议被接受时,正态曲线会跳到x轴上的新位置。我们很快将生成显示这种行为的动画。
我们基于π(x′)和π(x)的值来判断接受或拒绝,即提议的新样本位置和当前样本位置的π的y值的比率。如果π在当前的位置具有较高的值,则分数A会较小,这意味着步骤 6 中的比较成功的可能性较低。如果提议被拒绝,x会再次作为π的样本输出。我们希望这样做是因为π在该x处的y值较高。如果π(x)非常小,则π(x′)更可能较大,意味着A大于 1。如果A > 1,ρ = 1,提议位置将始终被选择,因为u < 1。因此,当将π视为 PDF 时,π的不太可能被选中的部分会更少地被采样。
基于这种行为,我们可以想象,随着时间的推移,基于正态分布样本的随机游走将按照每个位置处π的值比例在π上游走,从而生成所需比例的样本。我还没有评论σ,MH 的用户提供参数。我们很快会对此进行实验并理解其影响。
至于A的定义,我将其写作π(x)在当前和提议的x位置上的比值。这是 Metropolis 算法的版本,它适用于对称的提议分布。如果提议分布不是对称的,那么分子和分母各自会有一个额外的乘法因子。在对称情况下,这个因子对分子和分母是相同的,所以它会相互抵消。
A是一个比率,贝叶斯定理将后验写成似然和先验的乘积,除以一个归一化因子,在实践中,这通常是一个无法处理的积分。由于它与比率一起工作,MH 取消了这个积分,因此我们根本不需要计算它。MH 使得仅通过似然和先验就能从后验分布中进行采样。这让贝叶斯学派非常高兴,并导致了本章早些时候关于贝叶斯和 MCMC 的戏剧性引述。
运行算法,看看你的样本是否不符合 π(x)。我们忽略了关于 MH 算法的一个关键声明:它并不声称立即从 π(x) 生成样本,而只是当达到某个极限后,在一段时间后才会这样做。需要多久时间,生成多少样本,我们才能相信这些样本来自 π(x)?这个问题没有万无一失的答案。我们关于雪顶帽实验通常在十次或更少的迭代后就收敛到了平稳分布。这可能也是 MH 算法的情况,但通常认为复杂的 π(x) 函数在生成数千个样本或更多样本之前并不会来自 π(x)。因此,当我们在代码中实现 MH 时,我们会指定一个 burn-in 样本数量,我们会丢弃这些样本,只保留之后的样本。我们在 [第七章 玩混沌游戏生成迭代函数系统的吸引子点时,也做过类似的操作。
这是一个随机游走算法和马尔科夫算法,因为我们从一个基于当前样本 x 的分布中随机抽取下一个候选样本 x′。在随机游走中,下一位置相对于当前的位置。在马尔科夫算法中,历史不重要;只有当前的样本位置 x 会影响任何可能的新位置。最后,这是一个蒙特卡洛算法,因为它依赖于随机性,并且不能保证最初就能从 π(x) 生成准确的样本。
让我们深入一些代码,研究一下 mcmc.py 文件。你会发现代码用于解析命令行、从正态分布中采样,并使用这些样本生成一系列图表——这些我们之前已经多次见过。
mcmc.py 的核心是 MH 函数(清单 12-9)。
def MH(func, nsamples, sigma=1, q=1, burn=1000, limits=None):
samples = [q]
while (len(samples) < (burn+nsamples)):
p = normal(q, sigma)
if (limits is not None):
lo,hi = limits
if (p <= lo) or (p >= hi):
p = q
x = p; num = eval(func)
x = q; den = eval(func)
if (rng.random() < num/den):
q = p
samples.append(q)
samples = np.array(samples)
return samples[burn:], samples[:burn]
清单 12-9:一个随机游走的 Metropolis-Hastings 采样器
与拒绝采样一样,func 是一个定义 π(x) 的字符串。其组成规则与 rejection.py 中相同。我们最终希望获得 nsamples 个样本,去掉前 burn 个样本,因为这些样本会被丢弃。这也解释了 while 循环条件,知道列表 samples 存储了所有生成的样本。
while 循环的主体是 MH 算法的直接实现,忽略了 A 和 ρ 的显式定义。首先,我们从以当前样本位置 q 为中心的正态曲线中采样一个提议位置 p。然后,如果我们设置了 MH limits,它们会限制最终采样自 π(x) 的部分。我们在拒绝采样中也做了类似的操作。
我们将 func 定义为以 x 为自变量,因此我们需要调用 eval 并将其赋值给 x,以获得分子(num)和分母(den)。最后,如果 u 小于 num/den,则接受 p 作为新的 q,然后将 q 添加到 samples 中。
一旦我们获得了所有样本——包括那些标记为烧入的样本,用于绘图目的——在排除烧入样本后,将 samples 返回为一个 NumPy 向量。
运行 mcmc.py 而不带参数,以查看它期望的命令行参数:
> python3 mcmc.py
mcmc <N> <func> <limits> <q> <sigma> <burn> <outdir> yes|no [<kind> | <kind> <seed>]
<N> - number of samples
<func> - function to sample from (e.g. 2*x**2+3)
<limits> - limits for samples (lo_hi, -18_18) or 'none'
<q> - initial sample (e.g. 0)
<sigma> - proposal distribution sigma (e.g. 1)
<burn> - initial samples to throw away (e.g. N//4)
<outdir> - output directory name (overwritten)
yes|no - show or don't show the initial proposal distribution
<kind> - randomness source
<seed> - seed
这里有很多参数,但我们知道其中大多数的作用。我们需要在忽略前 burn 个样本后,获取 N 个样本。我们知道 func 是一个字符串,用来定义 π(x)。如果 limits 不是 none,它会限制 x 轴的采样范围。
我们使用 q 来提供初始样本位置。最后,提议函数的形状,即从中采样的正态分布,取决于 sigma。如果 sigma 太小,正态分布很窄,跳跃到 π(x) 的其他部分就会变得更困难。另一方面,如果 sigma 较大,虽然更容易从整个 π(x) 中进行采样,但也有其上限。
我们理解了 outdir、kind 和 seed。最后一个参数是必需的字符串,可以是 yes 或 no。如果是 yes,则输出的图表将显示 π(x) 和样本的直方图,同时也会显示以初始 q 为中心、标准差为 sigma 的正态分布。请阅读 mcmc.py 以了解如何生成输出图表。让我们运行代码看看它生成了什么。
我们从这条命令行开始:
> python3 mcmc.py 100000 "np.exp(-((x-5)/2)**2)+4*np.exp(-((x+5)/2)**2)" none 0 3 10000 tmp
yes pcg64 2256
100000 samples in 6.7056 s
我们要求在丢弃了 10,000 个烧入样本后,获取 100,000 个样本。我们使用与拒绝采样相同的两正态分布函数之和来表示 π(x)。none 选项会开放所有的 x 轴进行采样,尽管我们最终只会在 π(x) 非零的地方进行采样。初始采样位置为 0,sigma 为 3。最后,我们希望在输出图中看到初始分布函数;我们固定伪随机生成器和种子,并将所有输出保存到 tmp。
图 12-14 展示了 mcmc.py 创建的图表。

图 12-14:使用 Metropolis-Hastings 从 π(x) 采样(上图)以及跟踪图(左下角)和烧入图(右下角)
顶部的图表展示了 π(x) 及 MH 所生成样本的直方图。此外,因为我们在命令行中选择了 yes,所以还包括了初始提议分布,即一个以 x = 0、σ = 3 为中心的正态分布。注意,曲线被缩放到最大值为 1.0。与拒绝采样一样,我们希望 π(x) 的形状与直方图一致。
图 12-14 底部的图表是 trace plots,显示了样本 x 随着样本编号变化的情况。可以将“样本编号”看作是时间,因此图表展示了 x 随时间的变化。左侧图表跟踪的是烧入期后的样本,右侧图表则显示了烧入样本。
这些图是通过与上面图相同的命令行创建的,但总样本数设置为 10,000,其中 1,000 用于预热期。左边,大多数样本集中在 x = –5,那里是构成 π(x) 的较大正态曲线的峰值。剩余的样本集中在 x = 5,较小的峰值。然而,右边的预热期图像在各自的峰值附近跳动。
有许多内容可以用 mcmc.py 进行探索。我会提供两个作为起点的建议。我建议运行这些命令行,然后思考输出,看看你是否完全理解它。记得也查看跟踪图,特别是对于 π(x) = 2x² + 3:
> python3 mcmc.py 200000 "np.exp(-((x-5)/2)**2)+4*np.exp(-((x+5)/2)**2)"
none 3 0.1 100000 tmp yes pcg64 1313
> python3 mcmc.py 10000 "2*x**2+3" -3_8.8 0 1 1000 tmp yes pcg64 2233
之前,我承诺过我们会制作一个展示我们在 MH 实现中的随机游走的电影;现在我会兑现这个承诺。运行以下命令:
> python3 mcmc_movie.py 10000 "np.exp(-((x-5)/2)**2)+4*np.exp(-((x+5)/2)**2)"
-18_18 0 3 1000 tmp yes 900 pcg64 66
10000 samples in 181.5245 s
文件 mcmc_movie.py 与 mcmc.py 非常相似。输出目录 tmp 中包含一个新目录 frames。该目录包含从 frame_0000.png 到 frame_0899.png 的文件,展示了每个提议的样本(细的垂直线)以及每个被接受的样本(粗的垂直线),随着 MH 在其随机游走过程中移动。使用可以按字母顺序翻页浏览文件目录的图像查看器查看这个游走过程,或者从本书的 GitHub 页面下载 mcmc_movie.mp4。
练习
这里有一些你可能想尝试的内容:
-
更新
ChooseMap,在 ifs.py 中使用Sequential(清单 12-3)。 -
使用 inverse.py,并用
替换 u。样本有变化吗?这个 F^(–1)(u) 看起来是什么样的? -
Cauchy 分布由 µ 和 γ 描述。其 PDF 是
![Image]()
对应的 CDF:
![Image]()
尝试用 inverse.py 从这个函数中采样。设定 µ = –2 和 γ = 1。例如:
> python3 inverse.py 30000 "-2+1*np.tan(np.pi*(u-0.5))" "1/(np.pi*1*(1+((x+2)/1)**2))" cauchy pcg64 42你看到了什么?现在,用 inverse_cauchy.py 替换 inverse.py。这两个程序有什么区别?有时算法需要调整才能成功。
-
执行 shell 脚本 run_rejection_c,并用拒绝测试的术语解释输出,考虑当 cp(x) ≫ q(x) 与 q(x) 刚好超过 cp(x) 时的情况。提示:考虑在 q(x) ≈ cp(x) 时为给定的 x 值选择 y 的可能性。
-
使用 mcmc.py 对始终在某个给定范围内为正的函数进行实验。当你改变预热期(burn-in)大小时会发生什么?尝试改变 σ。大 σ 或小 σ 值更有效?试试这个函数:
p[X](x) = sin³(x) + 1
我建议使用如下命令行:
> python3 mcmc.py 100000 "np.sin(x)**3+1" -9.4248_9.4248 0 3 100000 tmp no pcg64 2256你能解释这个图和直方图吗?这些限制大致是 –3π 到 3π。
总结
在这一章中,我们从任意分布中进行采样。首先,我们介绍了贝叶斯推断中的术语和概念,贝叶斯推断是采样技术的主要应用领域。接着,我们从离散分布中进行采样,离散分布通常出现在处理直方图时。我们学习了顺序采样和 FLDR,它们都在
(n)时间内运行——不过,实际上,掷骰子的速度大约快五到七倍。
然后我们通过将二维分布展开为一维分布,实验了从二维离散分布中进行采样。由于灰度图像本质上是二维离散分布,我们从中进行采样,观察到更强烈的像素点被采样的频率更高。
接下来是连续分布,它们由概率密度函数(PDF)表示。在某些情况下,如果累积分布函数是可逆的,采样过程变得简单。对于不可逆的情况,我们探索了两种方法:拒绝采样和使用 MH 算法的马尔科夫链蒙特卡洛(MCMC)方法。
拒绝采样在一维中效果很好,但随着样本维度的增加,它的表现变差。我们探索了在两种提议分布(均匀分布和正态分布)下,算法的表现,意识到提议分布越接近实际的 PDF,拒绝的样本越少,算法的效率越高。
当我们要采样的分布变得复杂或维度较高时,拒绝采样最好被 MCMC 所替代。我们在一维中使用对称正态分布作为提议分布,学习了 MH 算法。动画图表展示了采样算法随着时间的推移如何进展。
第十三章:资源
随机过程
本部分包含讨论不同层次随机性概念的书籍。你可能会面临挑战。
*《探索随机性》 由 Gregory Chaitin 编写(Springer,2012)
这本书探讨了算法信息理论,Chaitin 关于随机性的理论。它与 Chaitin 早期的书籍《不可知的》(Springer,1999)和《数学的极限》(Springer,2002)相关。在深入之前,建议你复习一下 LISP。
*《算法随机性与复杂性》 由 Rodney G. Downey 和 Denis R. Hirschfeldt 编写(Springer,2010)
在理论计算机科学和随机性概念方面的杰作。可以挑选感兴趣的主题,或者只考虑引言部分。
“机会与随机性” 由 Antony Eagle 编写(斯坦福哲学百科全书,2018)
这篇文章(https://plato.stanford.edu/entries/chance-randomness)是对机会、随机性及其关系的深刻哲学探索。请查看文章末尾的“其他互联网资源”部分。
“随机过程” 由 Amir Dembo 编写(Stanford,2021)
可在 http://adembo.su.domains/math-136/nnotes.pdf 查找,这些是关于随机过程(即随机过程)的研究生课程讲义。从前面提到的斯坦福百科全书文章开始。
*《醉汉漫步》 由 Leonard Mlodinow 编写(Pantheon Books,2008)
这是对日常生活中随机性的流行处理。
隐写术
隐写术是一个相对冷门的话题,尤其是与它的“姐姐”——密码学相比。
“捉迷藏:隐写术导论” 由 Niels Provos 和 Peter Honeyman 编写(IEEE,2003)
可以从 IEEE 和网络上的其他网站获取(例如,* niels.xtdnet.nl/papers/practical.pdf*)。
*《数字图像的隐写术技术》 由 Abid Yahya 编写(Springer,2018)
一篇关于数字图像隐写术的学术演示。
第一章 和 第二章 最有可能引起你的兴趣。
Andrew D. Ker 的隐写术讲义(Oxford,2016)
这份详细的讲义集合可以在 https://www.cs.ox.ac.uk/andrew.ker/docs/informationhiding-lecture-notes-ht2016.pdf 查找。
仿真与建模
鉴于计算机模拟和建模现实世界的悠久历史,这一类别中有很多资源可供选择。我提供了三个资源帮助你入门。
*《Python 中的建模与仿真》 由 Allen B. Downey 编写(No Starch Press,2023)
这是继续探索第三章的一个极好的地方。
*《随机仿真的基础与方法》 由 Barry L. Nelson 和 Linda Pei 编写(Springer,2021)
这本书使用 Python 介绍仿真方法。配套网站提供了代码和数据集:https://users.iems.northwestern.edu/~nelsonb/IEMS435。
《计算机仿真技术:权威介绍!》 作者:Harry G. Perros(北卡罗来纳州立大学,2009 年)
一本由 Harry Perros 提供的免费仿真书,来自北卡罗来纳州立大学:https://repository.lib.ncsu.edu/handle/1840.2/2542。
元启发式方法:群体智能与进化算法
有许多关于群体智能和进化算法技术的学术书籍,但大多数都非常数学化且价格昂贵。我列出了一些,还有一些不太技术性的站点和介绍。
《元启发式方法精要》 作者:Sean Luke(Lulu 出版社,2012 年)
作者网站提供的实惠易读的介绍:https://cs.gmu.edu/~sean/book/metaheuristics。我推荐从这本开始。
粒子群体优化中心
这个网站,http://www.particleswarm.info,是与粒子群体优化相关的所有内容的中心。
《应用进化算法工程师使用 Python》 作者:Leonardo Azevedo Scardua(CRC 出版社,2021 年)
这本书看起来很适合我们,但价格相对昂贵。
《Python 实战遗传算法》 作者:Eyal Wirsansky(Packt Publishing 出版社,2020 年)
这本价格实惠的书涵盖了广泛的主题,包括将遗传算法应用于机器学习模型的调优。
《群体智能:原理、进展与应用》 作者:Aboul Ella Hassanien 和 Eid Emary(CRC 出版社,2015 年)
这本相对昂贵的书涵盖了自然启发的群体智能应用。无论灵感来源如何,许多算法在实践中都能很好地工作。
《群体智能》(Springer 出版社)
这个链接https://www.springer.com/journal/11721指向一个与群体智能应用相关的领先期刊的主页。
机器学习
尽管无法提供一份详尽的机器学习资源清单,但这里的资源将帮助你指引到有用的方向。
《深度学习:视觉化方法》 作者:Andrew Glassner(No Starch 出版社,2021 年)
如果你想了解现代深度学习的概述而不被复杂的数学困扰,这本书是个不错的起点。
《百页机器学习书》 作者:Andriy Burkov(2019 年)
这是另一本受欢迎的概述书籍。
《机器学习实战:使用 Scikit-Learn、Keras 和 TensorFlow》 作者:Aurélien Géron(O'Reilly Media 出版社,2019 年)
这本书适合当你准备深入代码时使用。
《机器学习专门化》 作者:Andrew Ng 等人
这是一个 Coursera 上的专业课程(可以在 https://www.coursera.org/specializations/machine-learning-introduction找到),适合那些希望通过视频讲座学习机器学习的人。Ng 与 Daphne Koller 共同创立了 Coursera。
《神经网络与深度学习》,Michael A. Nielsen(Determination Press,2015 年)
这本免费的在线书籍是学习神经网络(深度学习的核心)细节的一个绝佳起点;它可以在 http://neuralnetworksanddeeplearning.com找到。
生成艺术与音乐
这个领域有很多选择。以下是几本吸引我注意的书籍。
《计算音乐合成》,Sean Luke(2021 年)
作者网站上的免费文本:https://cs.gmu.edu/~sean/book/synthesis。
Chromata by Michael Bromley(2015 年)
Chromata 是一个生成数字艺术工具。可以在 www.michaelbromley.co.uk/experiments/chromata 体验它。
AI 生成艺术链接
许多基于 AI 的生成艺术工具的链接集合:pharmapsychotic.com/tools.html。
OpenProcessing
OpenProcessing 教你如何在 Processing 中编程,Processing 是一种为生成艺术而设计的语言;可以在 openprocessing.org 找到。
SuperCollider
SuperCollider 是一个开源的生成声音和音乐工具:supercollider.github.io。
《生成音乐是如何工作的》,Tero Parviainen
这个网站,teropa.info/loop/#/title,包含了一个关于生成音乐的精彩展示。
压缩感知
像群体智能和进化算法一样,关于压缩感知的书籍通常数学内容较重。
《压缩感知的数学简介》,Simon Foucart 和 Holger Rauhut(Springer,2013 年)
这是一本学术书籍,但第一章、第二章、第三章和第十五章可能值得你考虑。
《压缩感知简介》,M. Vidyasagar(SIAM,2019 年)
一本与之前类似但稍微更新的书籍。
《压缩感知磁共振图像重建算法》,Bhabesh Deka 和 Sumit Datta(Springer,2019 年)
磁共振成像是压缩感知的一个迷人的实际应用。本书是关于磁共振成像中压缩感知的最新总结。
压缩感知资源
涵盖压缩感知各方面及应用领域的长列表:dsp.rice.edu/cs。
实验设计
正确的实验设计对进行可靠的研究至关重要,能够产生可信的结果。如果你正在设计一项研究,我强烈建议找一位统计学家或生物统计学家合作。
《实验设计与分析》 作者:Angela Dean、Daniel Voss 和 Danel Draguljić(Springer,2017 年)
本书是实验设计的全方位资源,涵盖从结构到数据收集和分析的各个方面。第一章、第二章和第三章与我们在第十章的讨论相辅相成。
《使用 R 进行实验设计与分析》 作者:John Lawson(CRC Press,2014 年)
本书类似于前一本书,但使用 R 语言,R 是研究人员和统计学家常用的工具。
《实验设计与分析入门》 作者:Gary W. Oehlert(WH Freeman,2000 年)
这本书较旧,但可以免费获取。你可以在http://users.stat.umn.edu/~gary/Book.html找到文本和相关文件。
pyDOE
可以在https://pythonhosted.org/pyDOE找到,pyDOE 是一个用于实验设计的 Python 包。我通过pip3安装了它,但尚未进行评估。该网站包含文档,购买需谨慎。
Coursera 上的实验设计课程
在https://www.coursera.org上的课程中,我推荐“实验设计基础”和“因子与分数因子设计”。
随机化算法
随机化算法对计算机科学家具有重要意义。这里的资源只是众多相关文献的冰山一角。
《概率与计算》 作者:Michael Mitzenmacher 和 Eli Upfal(剑桥大学,2005 年)
这本流行的教材讨论了我们在第十一章中讨论的相同主题,但从更正式的角度进行阐述。
《近似算法的设计》 作者:David P. Williamson 和 David B. Shmoys(剑桥大学,2011 年)
另一本书,虽然较旧但免费提供:https://www.designofapproxalgs.com。
《随机化算法笔记》 作者:James Aspnes(耶鲁大学,2023 年)
这是一本关于 2023 年春季耶鲁大学课程的随机化算法讲义:http://www.cs.yale.edu/homes/aspnes/classes/469/notes.pdf。
《随机化算法》 作者:David R. Karger(MIT,2002 年)
在这门 MIT 开放课程中,你将找到讲义和带解答的习题集:https://ocw.mit.edu/courses/6-856j-randomized-algorithms-fall-2002。
抽样
这些参考文献涉及 MCMC 和其他高级技术,而第十二章中提到的 Devroye 书籍则讨论了更为直接的方法。
《马尔可夫链蒙特卡洛方法简介》 作者:Charles J. Geyer(数学统计学研究所,1992 年)
你可以在* www.mcmchandbook.net/HandbookChapter1.pdf* 找到《马尔可夫链蒙特卡罗手册》第一章的 PDF 文件。
《通过马尔可夫链蒙特卡罗(MCMC)进行贝叶斯推断》 by Charles J. Geyer(UMN, 2021)
这些笔记讨论了 MCMC 以及贝叶斯推断。你可以在www.stat.umn.edu/geyer/3701/notes/mcmc-bayes.pdf找到它们。
MCMC from Scratch 由 Masanori Hanada 和 So Matsuura 编著(Springer, 2022)
这本书是我们在第十二章中讨论内容的进阶。
视频
YouTube 上有许多频道涉及本书所讨论主题背后的数学。
PBS Infinite
可在www.youtube.com/c/pbsinfiniteseries找到,该系列讨论了许多话题,包括随机性和伪随机生成器。
3Blue1Brown
这个精妙的频道 (www.youtube.com/c/3blue1brown) 展示了许多数学主题。教学内容清晰,配有视觉演示,展示了数学概念应该如何呈现。
Numberphile
Numberphile 的影片可以在www.youtube.com/c/numberphile找到,通常包括与专家的对话以及手绘图示。
Computerphile
虽然 Numberphile 涉及数学内容,Computerphile (www.youtube.com/user/Computerphile) 主要关注计算机相关话题,通常与数学、理论计算机科学或机器学习相关。
Mathologer
Mathologer (www.youtube.com/c/Mathologer) 提供了关于大学数学课程,特别是微积分的较长视频。讨论和演示水准一流。
第十四章:索引
A
等位基因,85,93
苹果 II,212
人工智能(AI),253–254
大气噪声,17
Audacity,12,240
B
反向传播,182
培根,弗朗西斯爵士,40
精简粒子群优化(PSO),114
巴恩斯利,迈克尔,224
BASIC 程序,212
巴斯,托马斯,12
贝叶斯,托马斯,327
贝叶斯推断,328
贝叶斯定理,328
伯努利交叉,116
伯特朗,约瑟夫,100
伯特朗悖论,101
大 O 符号,301
比尔斯,阿尔,253
块随机化,276
保尔,保罗,229
博克斯,乔治,74
博克斯-穆勒分布,184
箱型图,79
“布赖恩的主题”,212
C
概率计算(伯特朗),100
微积分积分,324
康托尔,乔治,325
中心极限定理,4
混沌游戏,224–225
查普曼估计,306
科恩的d,275
颜色表,215
组合爆炸,298
压缩感知,255–259
切比雪夫不等式,263
应用,267–268
离散余弦变换,258
反问题,257
Lasso 优化,258
范数,258
欧几里得,258
曼哈顿,258
奈奎斯特-香农采样定理,256
稀疏性,257
欠定系统,257
均匀采样,256
混淆矩阵,187,191
连续分布,3,340
Coppersmith-Winograd 算法,305
克莱默法则,236
加密安全,27
加密学,39
累积分布函数(CDF),325
维度灾难,349
曲线拟合,106,109
D
达尔文,查尔斯,85
数据增强,175,179
数据集
乳腺癌,174
鸢尾花, 198
MNIST, 178, 208
决策树, 198
解释, 200
确定性过程, 21
德弗罗耶,卢克, 329
差分进化(DE)算法, 105, 115
恐龙, 93
离散余弦变换(DCT), 258
离散分布, 2
分治算法, 315
鸭子类型, 111
E
埃伯哈特,拉塞尔, 115
图像增强, 150–159
ent, 6
熵, 7
时代, 187
欧几里得距离, 86, 196
欧几里得范数, 258
Eudaemonic Pie, The (Bass), 12
进化, 84–99
灾难性世界, 93
交叉, 85–86
灭绝, 101
适应度, 86, 90
创始人效应, 93
基因漂变, 85, 93
基因型, 95
逐渐变化的世界, 91
突变, 85–86, 90
自然选择, 85
种群瓶颈, 93
静态世界, 89
进化算法, 103
差分进化, 105, 115
基因的, 105, 114
有机体, 104
实验设计, 271
区块随机化, 276
对照组, 275
协变量, 277
双盲研究, 278
效应大小, 275
功效分析, 291
随机噪声, 271
样本偏差, 272
简单随机化, 275
分层随机化, 277
处理组, 275
可解释人工智能(XAI), 198
指数分布, 340–341
极限学习机, 173, 189
鲁莽群体优化, 194–197
F
农民,J. Doyne, 12
快速加载骰子滚动器, 331
费马,皮埃尔, 311
Fetch, 33–34
拟合数据, 116–132
傅里叶空间, 267
分形
吸引子, 227–228
混沌游戏, 224–225
Hausdorff 维度, 226
迭代函数系统, 227
收缩映射, 228, 232
创建地图, 234
地图, 228–229
自相似, 224
谷形三角形, 225, 235
Freivalds 算法, 301–304
G
银河算法, 304–305
高斯分布, 3
生成对抗网络, 253
生成型 AI, 253–254
遗传学。参见 进化
GenJam, 253
Glorot 初始化, 184。参见 scikit-learn
梯度下降, 182
伟大灭绝事件, 93
灰狼优化器, 133
网格扭曲
函数, 221
旋转, 223
冈德森, 格雷戈里, 353
H
哈尔, 马茨, 17
哈尔顿序列, 24
哈明距离, 251
哈特尔, 丹尼尔, 73
Hausdorff 维度, 226
He 初始化, 183
赫罗多图, 40
直方图, 5
HotBits, 20
布赖恩·霍华德, 212
混合过程, 26
假设检验
曼-惠特尼 U 检验, 186
t 检验, 186
I
IFS 构建工具包, 237
图像格式, 62
灰度, 62
RGB, 62
图像,解开, 263–267
逆问题, 257
逆变换抽样, 340
迭代函数系统, 227
吸引子, 228
收缩映射, 228, 232
地图, 228–229, 234
J
贾亚, 105, 113
K
詹姆斯·肯尼迪, 115
Kilroy 曾在此, 67
奈瑟尔, 保罗(作曲家), 56
k 空间, 267
Kumaraswamy 分布, 342
L
lame, 249
Lasso 优化, 258
拉斯维加斯算法, 296
林肯-彼得森估计, 306
线性同余生成器, 21
有损压缩, 55. 另见 声音
卢卡谢维奇, 扬, 117
M
机器学习
数据增强, 175, 179
决策树, 198
解释, 200
极限学习机, 173
过拟合, 200
随机森林, 173, 198
装袋法, 201
自助法, 201
集成, 203
随机特征选择, 205
磁共振成像, 267
曼哈顿范数, 258
曼-惠特尼 U 检验, 186
标记与重捕
查普曼估计, 306
林肯-彼得森估计, 306
马尔可夫, 安德烈, 349
马尔可夫链, 349
平稳分布, 349
转移概率, 349
马尔可夫链蒙特卡罗(MCMC), 349
深度学习数学(Kneusel), 5
矩阵乘法, 299–300
列向量, 299
平方, 299
麦格雷恩, 沙朗·伯奇, 328
旋律, 245. 另见 声音
梅森素数, 310
梅森旋转器(MT19937), 23
元启发式算法, 103
曲线拟合, 106, 109
泛化算法, 104
遗传编程, 116
杂货店, 159
图像增强, 150
自然启发式, 133–134
目标函数, 107
圆形打包, 137
设置基站, 142
美特罗波利斯-哈斯廷斯抽样, 352
算法, 353
烧入, 354
跟踪图, 357
MiCRO, 133
MIDI(音乐仪器数字接口), 245
米勒-拉宾测试, 311–313
非见证数, 313
性能, 314–315
测试见证数, 312
MINSTD 生成器, 22
模运算, 311
莫尔条纹, 212
蒙特卡罗算法, 296
摩尔-彭若斯伪逆, 190
mplayer, 240
多层感知机(MLP),182–183
MuseScore,245
N
自然选择。见 进化
神经网络
激活函数,193
解剖学,180
架构,187
反向传播,182
混淆矩阵,188,191
极限学习机,173,189
生成对抗网络,253
梯度下降,182
初始化,183,185
修正线性单元,181
训练,182
变分自编码器,253
非均匀随机变量生成(Devroye),329
原假设,9
NumPyGen,30–31
Nyquist-Shannon 采样定理,255
O
优化,103
粒子群,105,113
随机,105,113
过拟合,200
P
Packard, Norman,12
PCG64,23
感知机,183
排列排序(bogosort),296
Pillow(PIL),63
多项式时间算法,27
群体遗传学,88。另见 进化
后缀表示法,117
实用深度学习:基于 Python 的入门(Kneusel),182
Price, Kenneth,115
素性测试,310
Fermat 测试,311
Miller-Rabin,311
非见证数,313
测试见证数,312
群体遗传学与基因组学导论(Hartl),73
主成分分析,175
概率
贝叶斯推理,328
贝叶斯定理,328
条件,328
累积分布函数,325
分布,1
贝塔,87
二项式,282,324
Box-Muller,184
连续,2
离散,2
指数,340
高斯,3
Kumaraswamy,342
正态,3,114,184
均匀,3
证据,328
直方图,5
联合,328
似然,328
正态分布 PDF, 326
归一化, 328
后验概率, 328
先验概率, 328
概率密度函数, 324
概率质量函数, 324
随机变量, 1
伪随机生成器
密码学安全, 27
/dev/urandom, 26
线性同余生成器, 21
梅森旋转器, 23
MINSTD, 22
PCG64, 23
周期, 22
RDRAND 指令, 26
种子, 21
伪随机过程, 1, 21
p 值, 9
Q
准随机生成器, 24
Halton 序列, 24
准随机过程, 1, 21
快速排序, 315–318
R
放射性衰变, 20
随机森林, 173, 198
装袋法, 201
自助法, 201
集成方法, 203
随机特征选择, 205
随机化
块, 276
组合, 292
在实验中, 272
简单, 275
分层, 277
随机化算法, 295
拉斯维加斯算法, 296
蒙特卡洛算法, 296
随机性引擎 (RE), 29
随机噪声, 271. 另见 实验设计
随机数与计算机 (Kneusel), 29
随机过程
大气噪声, 17
抛硬币, 8
去偏, 10
掷骰子, 11
混合, 26
物理, 16
伪随机, 1, 21
准随机, 1, 21
放射性衰变, 20
轮盘赌, 12
测试, 6
真正的随机, 8
随机变量, 1
随机游走, 215
RDRAND, 26
RE 类, 30, 35–36
Regan, Kenneth W., 305
Reinhart, Alex, 5
反波兰表示法 (RPN), 117
Riddle, Larry, 237
Rosenblatt, Frank, 183
S
采样, 324
快速加载骰子滚动器, 331
逆变换, 340
马尔可夫链, 349
平稳分布, 349
转移概率, 349
马尔可夫链蒙特卡罗, 349
Metropolis-Hastings, 352
算法, 353
烧入期, 354
跟踪图, 357
拒绝, 344
算法, 345
提议函数, 344
顺序搜索反转, 329
声音, 240
科学方法, 271
scikit-learn, xxiii, 187. 另见 Glorot 初始化
顺序搜索反转, 329
香农,克劳德, 12
塞尔皮ński,瓦茨瓦夫, 225
简单随机化, 275
仿真, 73–74, 278
生日悖论, 80–84
估计π, 74–80
演化, 84–99
灾难性, 93
遗传漂变, 93
静态, 89
实验, 278–291
正常性检查, 91
软件
Audacity, 12, 240
ent, 6
GenJam, 253
IFS 构建工具包, 237
lame, 249
mplayer, 240
MuseScore, 245
Pillow, 63
wildmidi, 245
声音
C 大调音阶, 244
旋律, 245
MIDI, 245
音乐模式, 246
随机, 239–241
采样, 240
正弦波, 241–243
WAV 文件, 239
源代码
40000cointosses.py, 10
algorithms.py, 249
bad_sample.py, 276
bagging.py, 203
birthday.py, 81
bootstrap.py, 201
brute_primes.py, 314
build_bc_data.py, 175
build_mnist_dataset.py, 178
cdf.py, 326
cell.py, 142
circles.py, 138
cohen_d_test.py, 290
count.py, 319
cs_image.py, 263
cs_signal.py, 260
curfit_example.py, 106
curves.py, 106
darwin_catastrophic.py, 93
darwin_drift.py, 93
darwin_slow.py, 91
darwin_static.py, 89
DE.py, 115
design.py, 278
discrete_2d.py, 338
discrete_ravel.py, 336
discrete_test.py, 332
drift.py, 96
elm_brute.py, 196
elm.py, 190
elm_swarm.py, 195
elm_test.py, 193
elm_test_results.py, 194
enhance.py, 152
evolve.py, 248
fldr.py, 331
forest_mnist.py, 206
forest.py, 205
F.py, 159
freivalds_plots.py, 304
freivalds.py, 303
GA.py, 115
gaussian.py, 133
gpgen.py, 126
gp.py, 119
GWO.py, 133
ifs_maps.py, 233
ifs.py, 229
init_test.py, 185
inverse_cauchy.py, 359
inverse.py, 342
iris_tree.py, 199
make_random.py, 49
make_results_plot.py, 149
make_towers_plot.py, 149
markov_chain.py, 351
mark_recapture.py, 307
mark_recapture_range.py, 309
mcmc_movie.py, 358
mcmc.py, 355
melody_maker.py, 245
merge_images.py, 158
MiCRO.py, 133
miller_rabin.py, 313
mnist_test.py, 180
moire.py, 212
note_walker.py, 244
nselect.py, 327
permutation_sort_plot.py, 298
permutation_sort.py, 296
plot_gbest_giter.py, 133
plot_results.py, 141
polygon.py, 227
power_analysis.py, 291
prime_tests.py, 314
process_images.py, 158
process_results.py, 166
process_rgb_images.py, 169
process_vgr_data.py, 18
product_order.py, 167
PSO.py, 124
Quicksort.py, 316
quicksort_tests.py, 317
random_sounds.py, 240
rejection.py,345
RE.py,29
rf_vs_mlp.py,208
rf_vs_mlp_results.py,208
RO.py,134
sierpinski.py,225
silence.py,14
sim_pi.py,76
sim_pi_quasi.py,78
sim_pi_test.py,78
sine_walker.py,242
songs.py,250
spheres.py,169
steg_audio.py,55
steg_audio_test.py,58
steg_image.py,65
steg_image_test.py,66
steg_random.py,49
steg_random_test.py,50
steg_simple.py,41
steg_text.py,44
store.py,159
test_mmult.py,319
walker.py,216
warp_factor_9.py,224
warp.py,222
螺旋图,212
栈,117
统计学
中央极限定理,4
χ² 检验,7,9
Mann-Whitney U,186
零假设,9
p 值,9
分位数,201
统计学显著性,9
t 检验,9,186
Statistics Done Wrong(Reinhart),5
隐写术,39
音频数据,55
Bacon 密码,40
解码文件,54
在随机数据中嵌入,47
编码文件,52–53
固定偏移,41
历史,40
图像文件,62
池文本,41
随机偏移,44
秘密密钥,44
隐写分析,39
Storn, Rainer,115
Strassen 算法,304
分层随机化,277
群体智能,103,105
算法
精简版 PSO,114
差分进化,115
遗传,114–115
灰狼优化器,133
Jaya,105,113
MiCRO,133
粒子群优化,105,113
随机优化,105,113
粒子,104
T
随机性测试,6
The Theory That Would Not Die(McGrayne),328
Thorp, Edward, 12
真正的随机过程, 8–16
T 恤, 217
t 检验, 9, 167, 186
turtle 图形, 212
V
von Neumann, John, 10, 21
旅行者 太空船, 18
W
WAV 文件, 239
wildmidi, 245
《随机艺术》一书中使用的字体包括 New Baskerville、Futura、The Sans Mono Condensed 和 Dogma。该书由 Boris Veytsman 使用 LaTeX2ɛ 包 nostarch 排版(2008/06/06 v1.3 为 No Starch Press 排版书籍)。
资源
访问 nostarch.com/art-randomness 查看勘误和更多信息。
更多实用无废话的书籍来自
NO STARCH PRESS

深度学习数学
你需要知道的神经网络基础
编者 RONALD T. KNEUSEL
344 页, $49.99
ISBN 978-1-7185-0190-4

Python 中的建模与仿真
科学家和工程师的入门
编者 ALLEN B. DOWNEY
280 页, $39.99
ISBN 978-1-7185-0216-1

实用深度学习
基于 Python 的入门
编者 RONALD T. KNEUSEL
464 页, $59.95
ISBN 978-1-7185-0074-7

统计学误用
极其全面的指南
编者 ALEX REINHART
176 页, $24.95
ISBN 978-1-59327-620-1

深度学习
一种视觉化的方法
编者 ANDREW GLASSNER
768 页, $99.99
ISBN 978-1-7185-0072-3
全彩

算法思维,第二版
学习算法,提升技能
你的编码技能**
编者 DANIEL ZINGARO
480 页, $49.99
ISBN 978-1-7185-0322-9
电话:
800.420.7240 或
415.863.9900
电子邮件:
SALES@NOSTARCH.COM
网站:












替换 u。样本有变化吗?这个 F^(–1)(u) 看起来是什么样的?

浙公网安备 33010602011771号